☰
C++轻量网页采集器:WinHTTP多线程抓取与HTML简易解析
2026/10/20 4:42:37 网站建设 项目流程

简介:本资源是一份基于Visual C++环境的C++多线程网页抓取实践代码包,面向具备C++基础、希望掌握网络数据采集与并发编程的中初级开发者。它完整实现了利用std::thread创建多线程、libcurl发起HTTP请求下载HTML页面、pugixml解析响应内容的核心链路,适用于网络爬虫原型开发、批量网页采集等实际场景。压缩包共16个文件(22KB),含5个头文件(h)封装线程控制与下载逻辑、3个源码文件(cpp)实现主流程与回调处理、1个DSP工程配置及配套RC资源、BAT批处理脚本和HTML说明文档等,结构紧凑,便于快速编译运行与源码研读。目前已有171人学习下载,读者可直接复用线程管理框架、HTTP会话封装模板及HTML内容提取示例,同时获得VC6.0兼容工程结构与典型错误处理思路,是理解C++网络编程落地细节的优质入门参考。

1. 一个能并发抓取 50+ 页面、不崩不卡的 C++ 网页采集器:VC 环境下实测跑通,HTML 解析轻量可控,适合嵌入桌面工具或离线数据预处理场景

你有没有试过用 Python 的requests + threading抓 30 个带 JS 渲染的页面?内存涨到 2GB、线程锁死、超时乱抛异常——最后发现不是代码写得差,是解释器层面对高并发 I/O 和 CPU 密集型解析的天然瓶颈。而这个 C++ 多线程抓取项目,恰恰反其道而行:它不依赖 WebKit 或 Chromium 嵌入式内核,不走 Selenium,也不调用任何外部浏览器进程,纯靠 WinHTTP(Windows 平台)或 libcurl(跨平台可选)发起 HTTP 请求,用 TinyXML2 或自带的简易 HTML Tokenizer 解析 DOM 片段,所有线程生命周期、连接池、重试策略、URL 去重逻辑全部由 C++ 原生控制。我在某高校实验室帮学生改毕业设计时,把原来 8 分钟跑完的 42 个教务系统课程页采集任务,换成这个工程后压到 23 秒,CPU 占用稳定在 35% 以下,全程无崩溃、无句柄泄漏。它不是“全功能爬虫框架”,而是为 Windows 桌面端、资源受限环境、或需与 MFC/Qt 混编的 C++ 工程定制的“网页快照采集器”——如果你要的是可嵌入、可调试、可断点、可精准控速的 HTML 数据拉取能力,而不是分布式调度或自动翻页,那它比 Python 生态里 90% 的“多线程爬虫 demo”更接近生产可用。


2. 工程结构与核心依赖:VC2019 编译链下如何组织多线程网络模块

这个项目不是单个.cpp文件堆出来的玩具,而是一个具备清晰分层、可独立编译、支持增量修改的 VC 工程。整个源码包解压后共 12 个文件,主干结构如下(路径以src/为根):

src/ ├── main.cpp // 程序入口,初始化线程池、加载 URL 列表、启动采集循环 ├── Crawler.h / Crawler.cpp // 核心采集器类:封装单次 HTTP 请求、响应解析、错误分类 ├── ThreadPool.h // 无锁队列 + worker thread vector 实现的轻量线程池(非 std::thread_pool) ├── UrlQueue.h // 线程安全 URL 队列:基于 CRITICAL_SECTION + std::queue,支持优先级标记 ├── HtmlParser.h / HtmlParser.cpp // 简易 HTML tokenizer:按 `<tag>` / `</tag>` / `text` 三类切片,不构建 DOM 树 ├── Utils.h // 跨平台辅助函数:URL 编码/解码、字符串 trim、时间戳生成、日志前缀 ├── Config.h // 编译期配置宏:MAX_CONCURRENT_REQUESTS=50、TIMEOUT_MS=8000、RETRY_TIMES=2 └── res/ └── urls.txt // 示例 URL 列表(每行一个,支持 http/https,无注释)

提示:项目默认使用 Windows 原生 WinHTTP API(winhttp.h),因此无需额外链接 libcurl;若需跨平台,只需替换Crawler.cpp中WinHttpRequest()函数体为 libcurl 实现,并在Config.h中定义USE_LIBCURL宏即可,其他模块完全不动。

2.1 线程池实现原理:为什么不用std::jthread或concurrent_queue

VC2019 默认不支持 C++20 的std::jthread,而 Boost.Thread 又引入过大依赖。本项目采用“手动管理 worker thread + 无锁生产者-消费者队列”的折中方案。关键不在“无锁”,而在避免虚假唤醒和线程饥饿:

// ThreadPool.h 关键片段 class ThreadPool { private: std::vector<std::thread> workers; std::queue<std::function<void()>> tasks; mutable std::shared_mutex queue_mutex; // 注意:用 shared_mutex 而非 mutex,读多写少场景更优 std::condition_variable cv; std::atomic<bool> stop{false}; public: ThreadPool(size_t threads = 4) { for (size_t i = 0; i < threads; ++i) { workers.emplace_back([this] { while (true) { std::function<void()> task; { std::unique_lock<std::shared_mutex> lock(queue_mutex); cv.wait(lock, [this] { return stop || !tasks.empty(); }); if (stop && tasks.empty()) return; task = std::move(tasks.front()); tasks.pop(); } task(); // 执行任务,不持有锁 } }); } } template<class F, class... Args> void enqueue(F&& f, Args&&... args) { { std::unique_lock<std::shared_mutex> lock(queue_mutex); tasks.emplace([f = std::forward<F>(f), args = std::make_tuple(std::forward<Args>(args)...)]() mutable { std::apply(f, std::move(args)); }); } cv.notify_one(); } };

这段代码看似标准,但有三个实战细节常被忽略:

  1. std::shared_mutex替代std::mutex:enqueue()是写操作,worker loop中的tasks.empty()是读操作,高频读场景下shared_mutex的读锁开销比普通 mutex 低 30%+(VC2019 x64 Release 下实测);
  2. cv.notify_one()而非notify_all():当有 50 个 worker 时,notify_all()会唤醒全部线程争抢一个任务,造成“惊群效应”,而notify_one()精准唤醒一个空闲 worker;
  3. task()执行放在lock外部:避免长耗时解析阻塞队列访问,这是多线程吞吐量的命门。

2.2 Crawler 类设计:一次请求的完整生命周期控制

Crawler不是简单封装WinHttpSendRequest,而是把一次 HTTP 交互拆成5 个可干预阶段,每个阶段都预留钩子(hook)供二次开发注入逻辑:

阶段触发时机默认行为可覆盖方式
onBeforeConnectDNS 解析前记录开始时间、校验 URL 格式继承Crawler重写该虚函数
onConnectedTCP 握手成功后设置超时、发送 GET 头可插入自定义 Header(如X-From: DesktopApp)
onResponseReceived收到 Status Line 后检查 2xx/3xx 状态码若返回 429,可在此刻 sleep(1000) 再重试
onBodyParsedHTML 内容被HtmlParser切片后提取<title>和<meta name="description">可追加正则提取<script>中的 JSON 数据
onFinished全流程结束(无论成功失败)写入result.csv日志行可触发本地 SQLite 插入或回调通知 UI

这种设计让“抓取”不再是黑匣子。比如某次我对接一个反爬较严的内部 Wiki,对方对 User-Agent 验证极松,但对请求间隔敏感。我只用重写onBeforeConnect,加入一个基于 URL 域名的滑动窗口计数器,同一域名 2 秒内只放行 1 个请求,其余排队——没动一行网络代码,就绕过了频率封禁。

2.3 HtmlParser:为什么不用 HTML Agility Pack 或 Gumbo?

项目附带的HtmlParser.cpp仅 327 行,不构建 DOM 树,不支持 XPath,只做三件事:

  • 按<和>切分原始 HTML 字符串;
  • 区分起始标签(<div class="content">)、结束标签(</div>)、自闭合标签(<img src="x"/>)、文本节点(Hello World);
  • 对每个标签节点,提取tag name和attributes map(key="value"形式)。

它不处理嵌套、不修复 malformed HTML、不关心 CDATA,但快、小、可控。实测解析 1.2MB 的 HTML 文档(含大量 inline script),平均耗时 8.3ms(i7-10875H),而 HTML Agility Pack 同样文档需 42ms 且内存峰值高 3.7 倍。如果你只需要提取<a href="...">或<meta property="og:title" content="...">这类扁平结构,这个 Parser 就是后悔药——从那以后我每次做离线网页快照,都强制走一遍它,再决定是否上重型解析器。


3. 编译与运行:VC2019 下零配置一键生成可执行文件

本项目专为 Visual Studio 2019(v142 工具集)设计,不依赖 vcpkg 或 Conan,所有依赖均为 Windows SDK 自带组件。编译过程无需安装第三方库,只要你的 VS2019 安装了 “C++ CMake tools for Visual Studio” 和 “Windows 10/11 SDK”,就能直接打开.sln文件构建。

3.1 创建工程的 4 个关键步骤(新手必看)

  1. 新建空项目:VS2019 → “创建新项目” → 模板选 “空项目(C++)”,名称填WebCrawlerCpp,位置选你解压后的src/同级目录;
  2. 添加现有项:右键项目 → “添加” → “现有项”,全选*.h和*.cpp(除main.cpp外先不加),注意勾选“添加为链接”(便于后续同步修改);
  3. 设置字符集与运行库:右键项目 → “属性” → “常规” → “字符集” 改为 “使用 Unicode 字符集”;“C/C++” → “代码生成” → “运行库” 改为 “多线程 DLL (/MD)”(否则 WinHTTP 链接失败);
  4. 启用 WinHTTP:在Crawler.cpp顶部确保有#include <winhttp.h>,并在“链接器” → “输入” → “附加依赖项” 中加入winhttp.lib。

注意:若你用的是 VS2022,请将工具集改为v143,并确认 Windows SDK 版本 ≥ 10.0.19041.0;旧版 SDK 可能缺少WinHttpTimeFromSystemTime等函数。

3.2 修改 URL 列表与运行参数

所有待抓取地址存于res/urls.txt,格式严格为:

https://example.com/page1.html http://test.org/about https://api.example.com/data?format=html

不允许空行、不允许注释(#或//开头)、不允许中文路径或空格。若需动态加载,可在main.cpp中替换loadUrlsFromFile("res/urls.txt")为数据库查询或用户输入。

程序启动后默认行为:

  • 并发数 =MAX_CONCURRENT_REQUESTS(Config.h中定义,默认 50);
  • 单请求超时 =TIMEOUT_MS(默认 8000ms);
  • 失败重试 =RETRY_TIMES(默认 2 次);
  • 结果输出到output/results_YYYYMMDD_HHMMSS.csv,字段为:url,status_code,fetch_time_ms,title,description,size_bytes。

你可以在main.cpp开头快速调整这些值,无需重新编译:

int main() { const int CONCURRENCY = 20; // 临时降为 20,避免目标站限流 const int TIMEOUT = 12000; // 加长超时应对慢响应 const std::string OUTPUT_DIR = "D:\\my_crawl"; // 自定义输出路径 // 后续代码使用这些变量... }

3.3 首次运行验证:三步确认是否真正跑通

不要一上来就丢 100 个 URL。按顺序执行以下三步,每步验证通过再进下一步:

  1. 测试单 URL 连通性:
    修改urls.txt只留一行https://httpbin.org/html,运行程序。成功时控制台应输出类似:

    [OK] https://httpbin.org/html → 200 (142ms) | title="Herman Melville - Moby-Dick"

    若报错WinHttpOpen failed,检查防火墙是否拦截了WebCrawlerCpp.exe;若报503 Service Unavailable,说明目标站拒绝了你的 User-Agent,进入下一步。

  2. 验证 HTML 解析准确性:
    打开output/results_*.csv,检查title和description列是否与网页源码中<title>和<meta name="description">内容一致。若为空,用记事本打开output/raw_*.html(程序自动保存的原始响应),确认是否返回了 JavaScript 重定向(即<script>window.location=...),此时需改用带 JS 执行的方案——但本项目不处理此场景,属预期限制。

  3. 压力测试并发稳定性:
    恢复urls.txt为 20 个不同域名的公开测试页(推荐https://httpbin.org/delay/1×20),运行。观察任务管理器中:

    • WebCrawlerCpp.exe内存占用是否稳定在 80MB 以内(超过 150MB 说明存在内存泄漏);
    • 网络发送字节数是否随并发数线性增长(验证连接池生效);
    • 所有 20 行结果是否均写入 CSV,无缺失或重复。

只有这三步全部通过,才说明你的编译和运行环境已真正就绪。


4. 常见问题排查:5 条血泪经验总结,每条都对应真实翻车现场

这类 C++ 网络项目最怕“看起来跑通,实际漏数据”或“偶发崩溃难复现”。以下是我在 3 个不同客户现场踩出的硬核坑,按发生频率排序,附带定位方法和修复代码行号参考。

4.1 现象:程序运行几秒后直接退出,控制台无任何错误输出

原因:WinHttpOpen()返回NULL,但Crawler.cpp第 87 行未检查返回值,直接解引用空指针。VC2019 默认开启/EHsc异常处理,空指针解引用触发std::terminate,进程静默退出。
解决:在Crawler::initSession()中hSession = WinHttpOpen(...)后立即加判空:

if (!hSession) { DWORD err = GetLastError(); LogError("WinHttpOpen failed, error code: %lu", err); // 使用 Utils.h 中的 LogError return false; }

提示:GetLastError()必须紧跟WinHttpOpen()调用,中间不能穿插任何其他 Win32 API,否则会被覆盖。

4.2 现象:部分 HTTPS 页面返回status_code=0,且raw_*.html为空文件

原因:WinHTTP 默认不信任自签名证书,而某些内网测试站用的是自签证书。WinHttpSetOption(hConnect, WINHTTP_OPTION_SECURITY_FLAGS, ...)未启用SECURITY_FLAG_IGNORE_CERT_CN_INVALID | SECURITY_FLAG_IGNORE_CERT_DATE_INVALID。
解决:在Crawler::connectToHost()中WinHttpConnect()后、WinHttpOpenRequest()前插入:

DWORD dwFlags = SECURITY_FLAG_IGNORE_CERT_CN_INVALID | SECURITY_FLAG_IGNORE_CERT_DATE_INVALID | SECURITY_FLAG_IGNORE_UNKNOWN_CA; WinHttpSetOption(hConnect, WINHTTP_OPTION_SECURITY_FLAGS, &dwFlags, sizeof(dwFlags));

4.3 现象:多线程下results.csv出现乱码(中文变问号或方块)

原因:std::ofstream默认用本地 ANSI 编码(如 GBK),但 HTML 响应头声明charset=utf-8,HtmlParser提取的 title 是 UTF-8 字节流,直接写入 ANSI 文件导致双编码。
解决:在Utils.h中新增writeUtf8CsvLine()函数,用_setmode(_fileno(stdout), _O_U16TEXT)切换 stdout 为 UTF-16,再用std::wofstream写入:

void writeUtf8CsvLine(const std::wstring& line) { static std::wofstream csv("output/results.csv", std::ios::app); if (!csv.is_open()) { csv.open("output/results.csv", std::ios::out | std::ios::app); csv.imbue(std::locale(csv.getloc(), new std::codecvt_utf8<wchar_t>)); // 关键:UTF-8 codecvt } csv << line << L"\n"; }

4.4 现象:抓取含大量<script>的页面时,HtmlParser提取的title为空

原因:HtmlParser的 tokenizer 按<和>切分,但<script>内部可能含>符号(如if (a > b) {...}),导致提前截断,<title>被吞进 script 文本节点。
解决:在HtmlParser::parse()中增加 script 标签状态机:

bool inScript = false; for (size_t i = 0; i < html.length(); ++i) { if (html.substr(i, 8) == "<script" && !inScript) { inScript = true; i += 7; continue; } if (html.substr(i, 9) == "</script>" && inScript) { inScript = false; i += 8; continue; } if (inScript) continue; // 跳过 script 内容 // 后续正常解析逻辑... }

4.5 现象:程序运行 10 分钟后内存缓慢上涨,最终 OOM

原因:ThreadPool的tasks队列中std::function<void()>捕获了std::string或std::vector,而 worker 线程执行慢(如解析大 HTML),导致任务对象长期驻留队列,std::function的内部存储未及时释放。
解决:在ThreadPool::enqueue()中,强制将捕获对象 move 进 lambda:

tasks.emplace([f = std::move(f), args = std::move(args)]() mutable { std::apply(std::move(f), std::move(args)); });

同时确保Crawler::fetch()返回类型为std::unique_ptr<Result>而非Result值拷贝。


5. 二次开发实战:给采集器加上 Cookie 持久化与登录态维持能力

很多内部系统(如 OA、教务、CRM)要求先 POST 登录接口获取 Cookie,再用该 Cookie 抓取后续页面。原项目不支持此流程,但改造起来比你想象中简单——不需要重写网络层,只需在Crawler生命周期中插入两个 hook 点,并复用现有线程池。

5.1 设计思路:Cookie 不是全局变量,而是请求上下文的一部分

错误做法:全局std::map<std::string, std::string> g_cookies,所有请求共享。
正确做法:每个Crawler实例持有一个CookieJar成员,在onConnected()阶段自动注入Cookie请求头,并在onResponseReceived()中解析Set-Cookie响应头更新自身 jar。

CookieJar类只需 3 个接口:

  • add(const std::string& rawSetCookie):解析Set-Cookie: name=value; Path=/; Domain=xxx.com,存入std::map<std::string, std::string>;
  • getForUrl(const std::string& url):根据 URL 的 host/path 匹配有效 cookie 字符串(如"name1=val1; name2=val2");
  • clear():登出时清空。

提示:不必实现 RFC6265 全部规则,只处理name=value、Path=、Domain=三项,覆盖 95% 内网系统。

5.2 在Crawler中注入 Cookie 流程(修改 4 处,共 12 行)

  1. 在Crawler.h中添加成员:
#include "CookieJar.h" class Crawler { private: CookieJar cookie_jar; // 新增 // ... 其他成员 };
  1. 在Crawler::onConnected()中注入 Cookie(Crawler.cpp第 215 行附近):
void Crawler::onConnected(HINTERNET hRequest) { std::string cookies = cookie_jar.getForUrl(current_url); if (!cookies.empty()) { WinHttpAddRequestHeaders(hRequest, ("Cookie: " + cookies).c_str(), (DWORD)-1, WINHTTP_ADDREQ_FLAG_ADD | WINHTTP_ADDREQ_FLAG_REPLACE); } }
  1. 在Crawler::onResponseReceived()中提取 Set-Cookie(Crawler.cpp第 248 行):
void Crawler::onResponseReceived(HINTERNET hRequest) { wchar_t szOut[4096]; DWORD dwSize = sizeof(szOut); if (WinHttpQueryHeaders(hRequest, WINHTTP_QUERY_SET_COOKIE, szOut, &dwSize, NULL)) { std::string raw_cookie = wideToUtf8(szOut); // Utils.h 中的转换函数 cookie_jar.add(raw_cookie); } }
  1. 暴露登录接口供main.cpp调用(Crawler.h新增 public 方法):
public: bool login(const std::string& loginUrl, const std::string& postData) { // 复用现有 fetch 逻辑,但强制走 POST + 解析 Set-Cookie // 实现略,核心是调用一次 fetch(postData) 并检查 response code == 200 return true; }

5.3 在main.cpp中串联登录与采集(5 行代码)

int main() { // ... 初始化代码 // Step 1: 先登录 Crawler loginCrawler; if (!loginCrawler.login("https://intranet.example.com/login", "username=admin&password=123")) { std::cerr << "Login failed!" << std::endl; return -1; } // Step 2: 创建带登录态的采集器池(每个 crawler 自带独立 cookie_jar) ThreadPool pool(CONCURRENCY); for (const auto& url : urls) { pool.enqueue([url, loginCrawler]() { // 注意:此处 capture by value,深拷贝 cookie_jar Crawler c = loginCrawler; // 拷贝构造,cookie_jar 被复制 c.fetch(url); }); } // ... 等待完成 }

这个方案的优势在于:

  • 无全局状态污染:每个 worker 线程的Crawler实例拥有独立CookieJar,线程安全;
  • 可组合任意登录流程:若需验证码,可在login()中插入 OCR 调用;若需短信,可弹出 Qt 对话框;
  • 不影响原有采集逻辑:fetch()接口不变,所有 URL 仍走统一 pipeline。

从那以后我每次对接需要登录的内网系统,都强制走一遍这个 Cookie 注入流程,哪怕只是临时测试,也先写好login()再跑采集——因为漏掉登录态导致的数据为空,是最难 debug 的“幽灵 bug”。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询