使用 U++ 的简单多请求 Web Crawler。
使用 U++ 库 HttpRequest 类的异步特性来实现带 GUI 的并行 Web Crawler。
引言
本文使用 U++ 框架。请参阅 Ultimate++ 入门指南 以了解该环境的介绍。
U++ 框架提供了一个能够异步操作的 HttpRequest
类。在本例中,我们将利用此功能来构建一个简单的单线程 Web 爬虫,最多支持 60 个并行 HTTP 连接。
设计 GUI
我们将提供一个简单的 GUI 来显示爬取进度
首先,我们将为我们的应用程序设计一个简单的 GUI 布局。这里的 GUI 非常简单,但仍然值得使用布局设计器
布局由 3 个 ArrayCtrl
小部件组成,它们基本上是表格。我们将使用 'work' 来显示单个 HTTP 请求的进度,'finished' 来显示已完成的 HTTP 请求的结果,并且为了好玩,还有一个 'path' 列,它将显示从种子 URL 到已完成 URL 的 URL 路径。
现在,让我们使用此布局并在代码中设置一些内容
#define LAYOUTFILE <GuiWebCrawler/GuiWebCrawler.lay>
#include <CtrlCore/lay.h>
struct WebCrawler : public WithCrawlerLayout<TopWindow> {
WebCrawler();
};
WebCrawler
将是我们的主应用程序类。它前面的奇怪的 #include
会将设计的布局“导入”到代码中,即它定义了 WithCrawlerLayout
模板类,代表我们的布局。通过继承它,我们将 work
、finished
和 path ArrayCtrl
小部件添加为 WebCrawler
的成员变量。我们将在 WebCrawler
构造函数中完成设置。
WebCrawler::WebCrawler()
{
CtrlLayout(*this, "WebCrawler");
work.AddColumn("URL");
work.AddColumn("Status");
finished.AddColumn("Finished");
finished.AddColumn("Response");
finished.WhenCursor = [=] { ShowPath(); }; // when cursor is changed in finished,
// show the path
finished.WhenLeftDouble = [=] { OpenURL(finished); };
path.AddColumn("Path");
path.WhenLeftDouble = [=] { OpenURL(path); }; // double-click opens url in browser
total = 0;
Zoomable().Sizeable();
}
CtrlLayout
是 WithCrawlerLayout
方法,它将小部件放置在设计好的位置。其余代码设置列表的列,并将小部件上的一些用户操作连接到 WebCrawler
中的相应方法(我们稍后会添加这些方法)。
Data Model
现在,无聊的 GUI 部分已经完成,我们将专注于有趣的部分——webcrawler
代码。首先,我们需要一些结构来跟踪事物。
struct WebCrawler : public WithCrawlerLayout<TopWindow> {
VectorMap<String, int> url; // maps url to the index of source url
BiVector<int> todo; // queue of url indices to process
struct Work { // processing record
HttpRequest http; // request
int urli; // url index
};
Array<Work> http; // work records
int64 total; // total bytes downloaded
VectorMap
是 U++ 的独特容器,可以认为是数组和映射的混合体。它提供基于索引的键和值访问,以及一种快速查找键索引的方法。我们将使用 url
来避免重复的 URL 请求(将 URL 作为键),并将“父” URL 的索引作为值,以便稍后显示从种子 URL 开始的路径。
接下来是我们待处理 URL 的队列。从 HTML 中提取 URL 时,我们会将它们放入 url VectorMap
中。这意味着每个 URL 在 url 中都有唯一的索引,所以我们只需要一个索引队列,todo
。
最后,我们需要一些缓冲区来保存我们的并发请求。处理记录 Work
简单地将 HttpRequest
与 URL 索引(仅用于知道我们正在尝试处理哪个 URL)结合起来。Array
是 U++ 的容器,能够存储没有复制形式的对象。
主循环
我们有了数据模型,让我们开始编写代码。先从简单的事情开始,询问用户种子 URL。
void WebCrawler::Run()
{ // query the seed url, then do the show
String seed = "www.codeproject.com"; // predefined seed url
if(!EditText(seed, "GuiWebSpider", "Seed URL")) // query the seed url
return;
todo.AddTail(0); // first url to process index is 0
url.Add(seed, 0); // add to database
Seed
是第一个 URL,所以我们知道它将具有索引 0
。我们只需将其添加到 url
和 todo
。现在,真正的繁重工作开始了。
Open(); // open the main window
while(IsOpen()) { // run until user closes the window
ProcessEvents(); // process GUI events
我们将一直运行循环,直到用户关闭窗口。在此循环中,我们需要处理 GUI 事件。循环的其余部分将处理实际的事务。
while(todo.GetCount() && http.GetCount() < 60)
{ // we have something to do and have less than 60 active requests
int i = todo.Head(); // pop url index from the queue
todo.DropHead();
Work& w = http.Add(); // create a new http request
w.urli = i; // need to know source url index
w.http.Url(url.GetKey(i)) // setup request url
.UserAgent("Mozilla/5.0 (Windows NT 6.1; WOW64; rv:11.0)
Gecko/20100101 Firefox/11.0") // lie a little :)
.Timeout(0); // asynchronous mode
work.Add(url.GetKey(i)); // show processed URL in GUI
work.HeaderTab(0).SetText
(Format("URL (%d)", work.GetCount())); // update list header
}
如果我们有待处理的 todo
并且并发请求少于 60 个,我们会添加一个新的并发请求。
接下来要做的就是推进所有活动的 HTTP 请求。HttpRequest
类通过 Do
方法来实现这一点。此方法在非阻塞模式下尝试推进连接请求。我们所要做的就是为所有活动请求调用此方法,然后读取状态。
然而,即使在“活动”模式下可以不等待实际套接字事件就做到这一点,一个行为良好的程序也应该先等待直到套接字可以写入或读取,以节省系统资源。U++ 提供了 SocketWaitEvent
类正是为此目的。
SocketWaitEvent we; // we shall wait for something to happen to our request sockets
for(int i = 0; i < http.GetCount(); i++)
we.Add(http[i].http);
we.Wait(10); // wait at most 10ms (to keep GUI running)
这里唯一的问题是 SocketWaitEvent
只等待套接字,而我们还需要运行 GUI。我们通过将最大等待时间限制为 10 毫秒来解决此问题(我们知道此时至少会发生一个周期性定时器事件,该事件应该由 ProcessEvents
处理)。
解决了这个问题,我们就可以继续处理请求了。
int i = 0;
while(i < http.GetCount()) { // scan through active requests
Work& w = http[i];
w.http.Do(); // run request
String u = url.GetKey(w.urli); // get the url from index
int q = work.Find(u); // find line of url in GUI work list
if(w.http.InProgress()) { // request still in progress
if(q >= 0)
work.Set(q, 1, w.http.GetPhaseName()); // set GUI to inform user
// about request phase
i++;
}
else { // request finished
String html = w.http.GetContent(); // read request content
total += html.GetCount(); // just keep track about total content length
finished.Add(u, w.http.IsError() ? String().Cat() << w.http.GetErrorDesc()
: String().Cat() << w.http.GetStatusCode()
<< ' ' << w.http.GetReasonPhrase()
<< " (" << html.GetCount() << " bytes)",
w.urli); // GUI info about finished url status,
// with url index as last parameter
finished.HeaderTab(0).SetText(Format("Finished (%d)", finished.GetCount()));
finished.HeaderTab(1).SetText(Format("Response (%` KB)", total >> 10));
if(w.http.IsSuccess()) { // request ended OK
ExtractUrls(html, w.urli); // extact new urls
Title(AsString(url.GetCount()) + " URLs found"); // update window title
}
http.Remove(i); // remove from active requests
work.Remove(q); // remove from GUI list of active requests
}
}
这个循环看起来很复杂,但大部分代码用于更新 GUI。HttpRequest
类有一个方便的 GetPhaseName
方法来描述请求中正在发生的事情。InProgress
在请求完成(成功或某种失败)之前一直为 true
。如果请求成功,我们将使用 ExtractUrls
从 HTML 代码中获取新的 URL 进行测试。
获取新 URL
为了简单起见,ExtractUrls
的实现相当朴素,我们只是扫描“http://
”或 “https://
”字符串,然后读取下一个看起来像 url
的字符。
bool IsUrlChar(int c)
{// characters allowed
return c == ':' || c == '.' || IsAlNum(c) || c == '_' || c == '%' || c == '/';
}
void WebCrawler::ExtractUrls(const String& html, int srci)
{// extract urls from html text and add new urls to database, srci is source url
int q = 0;
while(q < html.GetCount()) {
int http = html.Find("http://", q); // .Find returns next position of pattern
int https = html.Find("https://", q); // or -1 if not found
q = min(http < 0 ? https : http, https < 0 ? http : https);
if(q < 0) // not found
return;
int b = q;
while(q < html.GetCount() && IsUrlChar(html[q]))
q++;
String u = html.Mid(b, q - b);
if(url.Find(u) < 0) { // do we know about this url?
todo.AddTail(url.GetCount()); // add its (future) index to todo
url.Add(u, srci); // add it to main url database
}
}
}
我们将所有候选 URL 添加到 url
和 todo
中,供主循环处理。
最后润色
此时,所有繁重的工作都已完成。其余代码只是两个方便的函数,一个是在双击 finished
或 path
列表时打开 URL。
void WebCrawler::OpenURL(ArrayCtrl& a)
{
String u = a.GetKey(); // read url from GUI list
WriteClipboardText(u); // put it to clipboard
LaunchWebBrowser(u); // launch web browser
}
(我们还额外将 URL 复制到剪贴板。)
另一个函数填充 path
列表,以显示从种子 URL 到 finished
列表中 URL 的路径。
void WebCrawler::ShowPath()
{ // shows the path from seed url to finished url
path.Clear();
if(!finished.IsCursor())
return;
int i = finished.Get(2); // get the index of finished
Vector<String> p;
for(;;) {
p.Add(url.GetKey(i)); // add url index to list
if(i == 0) // seed url added
break;
i = url[i]; // get parent url index
}
for(int i = p.GetCount() - 1; i >= 0; i--) // display in reverted order, with seed first
path.Add(p[i]);
}
在这里,我们使用 VectorMap
的“双重性质”通过索引从子 URL 遍历回种子 URL。
现在唯一缺少的小块代码是 MAIN
。
GUI_APP_MAIN
{
WebCrawler().Run();
}
好了,就是这样,一个带有 GUI 的简单并行 Web 爬虫,大约 150 行代码。
有用链接
历史
- 2020年5月5日:初始版本
- 2020 年 5 月 7 日:ExtractUrls 现在也扫描“https”
- 2020 年 9 月 20 日:与“入门”文章进行交叉链接