简介:这是一份基于C/C++实现网络爬虫的简明代码包,面向有一定C语言基础、希望深入理解网络编程与页面权重计算的开发者。网络爬虫的基本任务是自动发起网络请求、接收返回内容、解析页面信息,并以链接关系为基础评估页面重要程度。本项目正是围绕这一流程组织工程结构,核心模块包括客户端请求构造、链接解析、哈希表与布隆过滤器、权重迭代计算等,能够清晰演示从抓取网页到输出权重排序的完整链路。压缩包共43个文件,以源代码、头文件为主,同时包含编译生成的目标文件、构建脚本、若干文本说明与测试数据,整个包仅31KB,便于快速阅读和动手修改。目前已有176人学习,适合用来自学套接字编程、超文本传输协议解析和链接分析算法;读者通过查看工程目录结构,可以直观理解多文件协作的C项目组织形式,直接编译运行后,还能对照源码分析哈希表、布隆过滤器等数据结构在网络爬虫中的具体用法,并在此基础之上扩展更加灵活的抓取策略。
1. 拿到 network-spider.tar.gz 之后:一个 C/C++ 网络抓取包值不值得解
你从课程资源站、技术交流群或者某个师兄的网盘里拿到一个 network-spider.tar.gz,标签后缀写着“网络编程_C/C++_”。这个命名方式基本暴露了它的身份:一份用 C/C++ 编写的网络抓取程序源码,多半是课程设计或者个人练手项目打包的产物。它要解决的问题很明确——当 Python 爬虫在大规模页面抓取时被 GIL、内存占用和解析速度拖住后腿,C/C++ 通过直接操作 socket 网络编程,把每一字节的网络流量都攥在自己手里。适合谁?两类人:一是想真正搞懂 HTTP 请求从 connect 到 recv 全过程的初学者,二是手头有百万级页面要抓、对资源占用极度敏感的从业者。这个包能不能用、怎么编译、坑在哪里,下面逐步拆开说。
2. 解包与构建:从 tar.gz 到跑通抓取的三步操作
拿到任何源码包,第一步都不是急着解压,而是先确认它到底是什么。tar.gz 只是外壳,里面可能是完整的 C/C++ 工程,也可能是打包好的二进制加上一堆运行时库,甚至可能只是作者的学习笔记。先看再解,能省掉后面一大半的排错时间。
2.1 先看包里是什么:解压前的三个确认动作
我一般会先执行下面三个命令,把包的类型、内容和体积搞清楚:
file network-spider.tar.gz # 确认是不是 gzip 压缩的 tar 归档 tar -tzf network-spider.tar.gz | head -50 # 只看文件清单,不实际解压 tar -tzf network-spider.tar.gz | wc -l # 统计包内文件总数file命令返回的结果会告诉你这个 tar.gz 是用 gzip 还是 bzip2 压缩的,压缩格式影响后面的解压参数。tar -tzf中的-t表示列出内容,-z表示处理 gzip 格式,-f指定文件;这条命令不会写任何文件到磁盘,只是把归档里的路径打印出来。通过文件清单能判断工程类型:看到Makefile、configure、src/、include/这类结构,说明是标准 C/C++ 工程;如果看到lib/下放着一堆.so或.a,说明依赖了第三方库,构建前得先装依赖。
注意,不要一上来就tar -xzf解压。如果包内文件的绝对路径写得很乱(比如/usr/bin/evil.sh这种),直接解压有风险。安全做法是解压前先看清单,确认所有路径都在当前目录下。
2.2 构建前的检查清单:依赖库与编译选项怎么定
解压之后,先读两个文件:README(或者README.md)和Makefile。前者写清楚项目的用途、依赖和构建方式,后者暴露真正的编译参数。以我拆过的同类 network-spider 包为例,依赖通常落在三类里:
- libcurl:封装了 HTTP/HTTPS 的传输细节,让上层不用自己写 socket,代价是引入动态库依赖。
- 纯 BSD socket + 手写 HTTP:只依赖系统自带的
<sys/socket.h>和<netinet/in.h>,构建最简单,但要自己处理 HTTP 协议解析。 - pthread / OpenSSL:pthread 负责多线程抓取,OpenSSL 负责 HTTPS 证书验证,这两者在 Linux 下需要安装
libssl-dev才能编译。
用下面的命令检查系统缺不缺依赖:
pkg-config --exists libcurl && echo "libcurl OK" || echo "libcurl MISSING" pkg-config --exists openssl && echo "openssl OK" || echo "openssl MISSING" ls /usr/include/pthread.h && echo "pthread header OK" || echo "pthread header MISSING"pkg-config是 C/C++ 构建里最常用的依赖检测工具,它返回 0 表示找到了对应库的.pc元信息文件。缺 libcurl 就装libcurl4-openssl-dev,缺 OpenSSL 就装libssl-dev。这里有个常见误用:直接用gcc -lcurl编译,但没装开发头文件,结果报错curl/curl.h: No such file or directory。遇到这种错,不要急着改代码,先回来检查pkg-config的输出。
2.3 最小构建命令:从 configure 到 make 的全过程
如果包内带了autogen.sh或configure,说明项目采用 autotools 构建系统,编译流程是标准的四步:
./autogen.sh # 生成 configure 脚本,只在源码包不带 configure 时需要 ./configure --prefix=/usr/local --enable-threads make -j4 # -j4 表示 4 个编译任务并行,加速构建 sudo make install # 安装到 /usr/local/bin,按需执行--prefix指定安装路径,默认是/usr/local;--enable-threads是很多网络抓取项目提供的开关,开启后才编译多线程抓取模块。如果包里只有Makefile没有configure,那构建更直接,就是make一条命令。首次构建建议不要加-j参数,先把编译错误看清楚;确认无误后再用make -j4提速。
构建成功后,二进制一般出现在src/目录或者项目根目录。可以用ls -lh看看产物大小,再用./network-spider --help验证它是否能运行。如果提示找不到动态库,用ldd查看缺失项——这一步后面在避坑章节会展开。
3. socket 网络编程核心:C/C++ 抓取引擎的请求与响应骨架
解包和构建只是热身。真正决定 network-spider 这类项目质量的地方,在于它对 socket 网络编程的理解深度。很多新手写的抓取器能跑通一次请求,但一遇真实网络就崩,问题几乎都出在 TCP 的流式特性上。
3.1 这包为什么用 socket 而不是高大上的框架
常见做法是用 libcurl 封装网络层,但一个合格的 network-spider 源码通常会包含手写的 socket 模块。原因不是 libcurl 不好,而是课程设计和实战项目都需要展示请求构造的底层逻辑:自己拼 HTTP 报文、自己解析响应头、自己管理连接池。这种方式在控制粒度上有优势——你可以精确控制每个连接的超时、缓冲区大小和 keep-alive 策略,这些在 libcurl 里需要复杂回调才能实现。
C/C++ 的 socket 网络编程核心就是一套系统调用序列:socket()创建文件描述符,connect()发起 TCP 握手,send()写请求,recv()读响应。这个流程本身不难,难的是理解 TCP 是字节流协议,不是消息协议——你调用一次recv()拿到的可能是半个 HTTP 响应,也可能是三个响应拼在一起,必须靠协议头里的Content-Length或者Transfer-Encoding字段来判断边界。
3.2 一个最小 HTTP GET 请求的 C 语言骨架
顺着源头捋,network-spider 的抓取模块大概率长这样:
#include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <netdb.h> #include <string.h> #include <unistd.h> #include <stdio.h> int http_get(const char *host, const char *path, char *buf, int buflen) { struct addrinfo hints, *res; memset(&hints, 0, sizeof(hints)); hints.ai_family = AF_INET; // IPv4,避免 DNS 解析拿到 IPv6 地址 hints.ai_socktype = SOCK_STREAM; // 域名解析:getaddrinfo 是阻塞调用,在线程池里要小心 if (getaddrinfo(host, "80", &hints, &res) != 0) { perror("getaddrinfo"); return -1; } int fd = socket(res->ai_family, res->ai_socktype, res->ai_protocol); if (fd < 0) { perror("socket"); return -1; } // 非阻塞连接 + 超时控制,5 秒连不上就放弃 struct timeval tv = { .tv_sec = 5, .tv_usec = 0 }; setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); if (connect(fd, res->ai_addr, res->ai_addrlen) != 0) { perror("connect"); close(fd); return -1; } char req[512]; snprintf(req, sizeof(req), "GET %s HTTP/1.1\r\n" "Host: %s\r\n" "User-Agent: network-spider/1.0\r\n" "Connection: close\r\n" "\r\n", path, host); if (send(fd, req, strlen(req), 0) < 0) { perror("send"); close(fd); return -1; } int total = 0; while (total < buflen - 1) { int n = recv(fd, buf + total, buflen - 1 - total, 0); if (n <= 0) break; // 连接关闭或超时 total += n; } buf[total] = '\0'; close(fd); freeaddrinfo(res); return total; }这段代码里有几个参数值得细看。SO_RCVTIMEO和SO_SNDTIMEO分别给读和写设置 5 秒超时,没有这两行,recv()在服务器不响应时会永远阻塞,线程池直接卡死。Connection: close告诉服务器响应完就断开,简化了处理逻辑——不用解析 keep-alive 连接上的粘包问题,适合任务型抓取,代价是每个请求都要重新握手。
recv()的循环是这段代码的关键。HTTP 响应体可能远大于缓冲区,也可能分多次到达,必须循环读到返回 0(对端关闭)或 -1(超时)。很多新手在这里只调用一次recv(),导致抓回的页面只有前几百字节,HTML 解析直接失败。
3.3 多线程抓取时,连接管理怎么做
单线程抓取速度有限,network-spider 这类项目往往引入了 pthread 做并发。线程数不是越多越好,我实践下来的经验是:抓取普通网站时,线程数控制在 5~10 个;抓 API 接口可以到 20~30 个,但要配合对端的速率限制。线程一多,两个问题跟着来:文件描述符耗尽和对端拒绝连接。
连接管理的核心是每个线程独立持有自己的 socket,用完立即close()。常见错误是线程之间共享同一个 socket——两个线程同时send()会互相干扰,收到的响应也不知道归谁。正确做法是线程池加任务队列:主线程负责分配 URL,工作线程各自建立连接。另外,SO_REUSEADDR这个套接字选项一定要开,否则连接关闭后端口进入 TIME_WAIT 状态,短时间内重新绑定会报Address already in use:
int yes = 1; setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));4. 避坑与排查:用 network-spider 抓数据时最常见的五个翻车点
网络抓取 C/C++ 编译通过只是起点,跑起来才是真考验。下面这些坑是我自己踩过和帮别人排查过的高频问题,每一条都按现象、原因、解决的顺序拆开。
4.1 现象:请求发出去了,recv() 却一直卡到超时
编译好的程序跑起来,日志显示 connect 成功、send 成功,但 recv 一直读到超时返回 -1,页面一个都抓不回来。原因通常是服务器对 HTTP/1.1 的 keep-alive 行为与本机设置不匹配:你请求里没有Connection: close,服务器以为你还要复用连接,于是不主动断开,recv()在等待新的数据时超时。
解决方式有两种:在请求头里显式加Connection: close,让服务器响应完就关闭连接;或者解析响应头里的Content-Length,按长度精确读取响应体后主动关闭,而不是傻等到 recv 返回 0。推荐前一种,代码改动最小。
4.2 现象:抓回来的 HTML 是乱码,或者只有一半
之前提过,recv()一次只能拿到 TCP 流里的一个分片。很多初版代码写的是recv(fd, buf, 4096, 0)然后直接解析 buf,拿到 4096 字节就停了。页面短时没问题,长页面一定截断。另外,服务器如果返回Content-Encoding: gzip压缩内容,你用printf("%s", buf)打印出来也是乱码——那不是编码问题,是根本没解压。
解决方法是循环读取直到连接关闭,然后检查响应头里的Content-Encoding,如果带gzip,用 zlib 的uncompress()或inflate()解压。去 HTML 乱码问题之前,先用curl -I命令对比一下服务器返回的头字段,确认是压缩还是截断。
4.3 现象:线程一多就被对端拒绝连接
从 10 个线程加到 30 个线程,程序开始频繁报Connection refused,但系统资源还很富余。原因有两个:一是大量连接进入 TIME_WAIT 状态,端口被占用光;二是触发了对端的防爬机制,比如同一 IP 每秒连接次数超过阈值,服务器直接拒绝握手。
解决方式是先开SO_REUSEADDR,减少 TIME_WAIT 带来的端口占用;再给线程间加一个简单的限速逻辑——每个线程完成一次请求后usleep(100000)(100 毫秒)再取下一个任务。限速看起来降低了瞬时吞吐,实际上避免了频繁重试,总耗时反而更短。
4.4 现象:VSCode 里编译报 c/c++ 扩展二进制文件不兼容
在 Windows 上用 VSCode 打开 network-spider 源码,配置好 MinGW-w64 后编译,编辑器突然弹出扩展二进制文件不兼容的提示。原因是 VSCode 的 C/C++ 扩展版本和安装的 MinGW-w64 工具链位数不一致:扩展内置了 64 位和 32 位两套二进制,检测到编译器架构与扩展不匹配时就罢工。
解决方式:确认 MinGW-w64 是 x86_64 版本而不是 i686 版本;在c_cpp_properties.json里把compilerPath显式指向gcc.exe的完整路径,比如C:\\mingw64\\bin\\gcc.exe,不要靠 VSCode 自动探测。编译命令不要依赖 VSCode 的任务配置,先在终端里手动执行gcc -o spider main.c -lpthread验证工具链本身没问题。
4.5 现象:抓取进程半夜挂了,白跑几小时
长任务跑在服务器上,第二天发现进程消失,日志停在某个 URL 上,没有崩溃堆栈。原因通常是网络波动导致recv()返回 -1,而代码里把 -1 当作普通错误直接退出线程,线程池没有捕获这个异常,最终主线程也退出。
解决方式是给每个工作线程加一层错误恢复:recv()返回 -1 时先检查errno,如果EAGAIN或EWOULDBLOCK说明只是超时,重试当前请求;其他错误才终止当前任务。并且主线程要用sigaction屏蔽SIGPIPE——对端断开后继续send()会触发这个信号,默认行为是终止进程,这就是进程无声消失的元凶。
5. 从响应到数据:解析、去重与调度的落地套路
网络传输搞定了,下一步是让抓回来的字节变成结构化数据。很多 network-spider 项目挂在最后一步:页面抓了一堆,但不知道怎么从 HTML 里提炼出链接和正文,也不知道怎么避免重复抓同一个 URL。
5.1 响应解析:Content-Length 与 chunked 的分野
HTTP 响应由状态行、响应头和响应体三部分组成,响应头和响应体之间用\r\n\r\n分隔。抓回原始数据后,第一步是定位这个分隔符,把头部和身体拆开。然后检查头部里的两个关键字段:
Content-Length: 12345表示响应体精确长度,按这个数字从流里读取即可。Transfer-Encoding: chunked表示分块传输,响应体由一系列「长度 + 数据块」组成,最后以一个 0 长度的块结束,要按块解析。
// 查找头部结束位置的示例 char *sep = strstr(raw_buf, "\r\n\r\n"); if (!sep) { /* 头部不完整,继续收包 */ } *sep = '\0'; char *headers = raw_buf; char *body = sep + 4; int body_len = raw_len - (body - raw_buf);这段代码把原始缓冲区在分隔符处切断,headers指向响应头,body指向响应体。注意strstr找到的可能是\n\n(服务器不够规范只发单个换行),实际解析时最好同时兼容\r\n\r\n和\n\n。header 解析用strtok_r按行切分,每个字段再用冒号分割成 key-value。
5.2 链接提取与 URL 去重:从正则到哈希
C/C++ 没有现成的正则库,链接提取一般用两个方案:引入 PCRE2 库做正则匹配,或者写手动的字符串扫描。对于 network-spider 这种学习项目,手动扫描更常见——找<a href="...">里的 URL,用strstr配合指针滑动就能做。提取出来的相对路径要补全为绝对 URL,这需要基于当前页面的 Host 和路径做拼接,比如href="/news/1.html"落在http://example.com/a/b页面里,实际地址是http://example.com/news/1.html。
去重用std::unordered_set是最省事的方案:
#include <unordered_set> #include <string> std::unordered_set<std::string> visited; bool try_add_url(const std::string &url) { // 先规范化,再去重 std::string normalized = url; if (normalized.compare(0, 4, "http") != 0) { return false; } // 去掉 fragment,避免 #comment 这类无意义片段导致重复 size_t hash_pos = normalized.find('#'); if (hash_pos != std::string::npos) { normalized.erase(hash_pos); } auto [it, inserted] = visited.emplace(normalized); (void)it; return inserted; // 返回 false 表示已经抓过 }unordered_set底层是哈希表,平均查找复杂度是 O(1),几百万 URL 几十 MB 内存就够。注意去重一定要在规范化之后做,否则http://site.com/a和http://site.com/a#top会被当成两个链接反复抓取。如果 URL 规模上亿,unordered_set的内存占用会到几个 GB,那就要换布隆过滤器方案,但位数组的误判需要谨慎权衡。
5.3 队列与调度:先广后深还是按权重
抓取调度最常见的结构是一个待抓队列加一个已抓集合。初始化时把种子 URL 放进去,工作线程不断从队列取出任务,抓完解析出新链接,规范化后去重,再放回队列尾部。这就是标准的 BFS 广度优先遍历,能保证浅层页面优先被抓,适合站点结构不深、希望快速覆盖首页链接场景。
但真实站点往往需要优先级:列表页的链接价值低于详情页,但列表页能带出大量详情链接。常见做法是双队列结构——高优先级队列(刚提取的详情页 URL)和低优先级队列(列表页 URL),线程优先从高优先级队列取任务。相比单队列,这个改动很小,但对抓取效率的提升非常明显。
队列实现注意线程安全。用std::mutex加锁包裹push和pop,用std::condition_variable通知等待线程有新任务。不要在锁里做 DNS 解析或网络请求,那是抓取器吞吐量杀手。队列长度也要设上限,否则抓取速度大于解析速度时内存暴涨,程序直接 OOM。
6. 进阶:给 network-spider 加超时重试与断点续抓,再验证结果
基础功能跑通之后,有三件事能让这个网络爬虫从“能用”变成“扛得住业务”。第一件事是给每个抓取任务加超时和重试。我曾见过一个网络爬虫项目因为不设重试,一次网络抖动导致 30% 的任务失败,日志里全是超时错误。正确的重试策略是:连接超时重试 2 次,HTTP 5xx 错误重试 1 次,4xx 不重试——4xx 是客户端问题,重试多少次都一样。重试间隔用 200 毫秒起步,指数退避,最大 5 秒,避免重试风暴压垮目标服务器。
第二件事是实现断点续抓。抓取任务跑到一半,机器重启或者程序崩溃,重新运行后如果从头开始,之前几小时的进度全部白费。我一般会为每个 URL 维护一个状态标记:抓取完成、抓取失败、待抓取,定期把待抓队列和已抓集合序列化到本地文件。重启时先加载这个快照,把已完成的任务从队列里剔除。这个机制用std::ofstream写一行一个 URL 就行,不需要引入数据库,但每完成 1000 个任务刷一次盘,避免崩溃丢数据。
第三件事是验证抓取结果的质量。抓完的数据对不对,不能只靠肉眼抽查。最简单的验证是统计三个指标:抓取成功率(成功响应数除以总请求数)、平均响应时间、单页面提取链接数。如果提取链接数突然下降到接近 0,多半是页面结构变了或者被反爬拦截,需要立即检查。再进一步,可以把每次请求的状态码和响应大小记录到日志文件里,用脚本做分布统计——单次调试看不出问题,但批量数据的分布异常一定是代码哪里出了问题。
我早年抓过一个十万级页面的站点,当时没有做断点续抓,跑到第三天凌晨服务器重启,前两天的数据因为没落盘几乎全丢。从那以后我养成了一个习惯:任何网络抓取任务,第一件事不是写抓取逻辑,而是把「队列持久化」和「状态日志」写好。这两个模块看似不起眼,却决定了一个抓取项目是能跑完,还是永远在重跑。希望帮到你。
本文还有配套的精品资源,点击获取