简介:一个基于C/C++实现的网络爬虫项目,面向网络编程初学者和中级开发者,演示如何从零构建可抓取网页链接、计算页面权重的命令行工具。资源共43个文件,以.c源码和.h头文件为主,辅以.o编译目标文件、makefile构建脚本及txt测试数据,压缩包整体仅31KB,结构紧凑但模块齐全,适合本地快速阅读、修改与重新编译。源码涵盖套接字编程、HTTP客户端、链接解析、哈希表、队列操作、布隆过滤器及PageRank迭代计算等核心模块,配合makefile可一键构建,便于直观理解TCP/IP与HTTP协议在真实爬虫中的落地应用;提供的bfcheck与测试文本还能辅助验证抓取与权重计算效果。已有176人学习下载,对于想通过具体案例掌握C/C++底层网络控制、网页解析与排序算法思想的读者来说,这是一份小而精的参考资料。
1. 一个 C/C++ 网络爬虫源码包能走多远:从抓链接到算权重
要不要下载这个 network-spider.tar.gz,先看它到底解决了什么问题。我拆过一遍后可以明确说,它不是一个玩具 demo:里面同时有 http_client、link_parser、bloomfilter、hashtable、queue_op 和 pagerank 六个模块,串起了一条完整链路——从 start.txt 里的种子 URL 出发,发 HTTP 请求拿 HTML,抽链接、去重、入队,最后用迭代算法给每个页面算出权重并写进 result.txt。也就是说,这个包把 socket 网络编程、HTTP 解析、HTML 解析、PageRank 权重计算四件事揉进了一个能编译能跑的程序,对刚学完套接字编程想看真实工程组织的同学,以及要做链接分析又不想从零写哈希表、布隆过滤器的人,都有直接的参考价值。源码不是零散的练习代码,是能当骨架用的东西,但坑也不少,后面逐条说。
2. 先拆包再编译:network-spider 的文件布局与数据流
2.1 40 多个文件按职责收成六条线:不是乱堆的源码
压缩包里文件乍一看很杂,有.c、.h、.o、.c~、加上makefile和两个文本文件,实际按功能分只有六条线。
| 模块 | 涉及文件 | 在爬虫里的职责 |
|---|---|---|
| 网络请求 | network.c / network.h / http_client.c / http_client.h | 建立 TCP 连接、拼 HTTP 请求、收响应 |
| 链接解析 | link_parser.c / link_parser.h | 从 HTML 里提取 href,补全成绝对 URL |
| 存储结构 | hashtable.c / hash.c / queue_op.c / devide.c | 哈希表存「谁链接了谁」,队列做 BFS 调度 |
| 去重 | bloomfilter.c / bloomfilter.h / bfcheck | 用布隆过滤器判断 URL 是否已抓取 |
| 权重计算 | pagerank.c / pagerank.h | 迭代计算页面权重并排序 |
| 入口产物 | main.c / makefile / start.txt / result.txt | 读种子、启动爬虫、输出结果 |
devide.c这个命名应该是 divide 的笔误,从上下文看它承担的是 URL 或页面的切分,比如把 host 和 path 拆开。.c~结尾是 vim 留下的备份文件,.o是编译产物,这两个会在第 5 章变成两个典型的坑。
2.2 数据从 start.txt 到 result.txt 的完整走向
整个程序的主流程用文字描述就四步,但每一步背后都挂着一个模块:
主函数从start.txt里读出种子 URL,压进队列。循环里每次从队头弹一个 URL,交给http_client发 GET 请求;返回的 HTML 交给link_parser抠链接。抠出来的每个 URL 先过bloomfilter查重,没见过的再进hashtable登记页面关系、压入队列。当抓取页面数到达预设上限或队列清空后,pagerank模块把hashtable里的链接关系转成邻接表,迭代算权重,最后排序写进result.txt。
这个顺序很重要:去重发生在入队之前,但链接关系登记发生在去重之后。如果把顺序写反,同一个 URL 就会被抓很多遍,hashtable 里的出链重复累计,PageRank 权重会被显著抬高。
2.3 makefile 构建与首次运行参数
先让它编译通过再谈别的。包里给了 makefile,标准构建就一条命令:
make clean && makemakefile 的核心内容大概长这样:
CC = gcc CFLAGS = -Wall -O2 -g OBJS = main.o network.o http_client.o link_parser.o \ hashtable.o bloomfilter.o queue_op.o pagerank.o spider: $(OBJS) $(CC) $(CFLAGS) -o spider $(OBJS) %.o: %.c $(CC) $(CFLAGS) -c $< -o $@ clean: rm -f spider $(OBJS)-Wall开警告,-O2做优化,-g保留调试信息。这个工程是纯 socket 实现,不依赖 libcurl,所以链接阶段不需要加额外库。-O2对哈希和 PageRank 迭代这类计算有实际收益,建议保留;如果调试阶段怕变量被优化掉,可以临时改成-O0。
编译通过后,跑一次看效果:
./spider start.txt 20 result.txt常见用法是三个参数:种子文件、最大抓取页数、输出文件。20是页数上限,第一次跑建议设小一点,既验证链路,又不会让网络层的问题被大量请求淹没。跑完后确认result.txt里每一行是「页面 URL + 权重值」的格式,没报段错误就算通了。
3. 抓取端实现:network.c 与 link_parser.c 里的 socket 和解析实战
3.1 socket 网络编程:TCP 连接、请求发送与超时控制
C/C++ 网络爬虫最底层的东西就是伯克利套接字。network.c里做的事情,本质上就是把 host 解析成 IP、连上 80 端口、把 HTTP 请求字节流发出去、再读回响应字节流。典型实现长这样:
int sockfd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in addr; addr.sin_family = AF_INET; addr.sin_port = htons(80); inet_pton(AF_INET, host_ip, &addr.sin_addr); connect(sockfd, (struct sockaddr *)&addr, sizeof(addr)); struct timeval tv = { .tv_sec = 10, .tv_usec = 0 }; setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); char req[1024]; 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); send(sockfd, req, strlen(req), 0);代码逻辑分三段:建连、设超时、发请求。inet_pton是把点分十进制 IP 转成结构体里的二进制地址,htons(80)是端口转网络字节序。SO_RCVTIMEO设的是接收超时,我一般设 10 秒,防止某个服务器不回包让整个爬虫卡死。注意Connection: close是故意为之——爬虫不需要 keep-alive 的复杂状态,让服务器发完数据主动关连接,接收逻辑就能按 EOF 判断 body 结束,省去解析 chunked 的麻烦。
3.2 HTTP 响应拆分:状态码、头部和 body 的边界
响应回来是一整块字节流,第一步是把它拆成「状态行 + Header + body」三段。
char *header_end = strstr(buf, "\r\n\r\n"); if (header_end) { *header_end = '\0'; sscanf(buf, "HTTP/1.%*d %d", &status_code); char *body = header_end + 4; }strstr(buf, "\r\n\r\n")找空行,空行之前是状态行加头部,之后是 body。sscanf里%*d表示跳过协议版本,只取三位状态码。拿到状态码后建议做分支:200才进解析流程,301/302要取Location头重新入队,404/403直接丢弃。很多初版爬虫只处理 200,结果站点做了跳转就断链,这是最常见的翻车点。
3.3 link_parser:从 HTML 里提取 href 并补全绝对地址
链接解析的核心是扫描href=,把引号里的 URL 抠出来。这里最容易写错的地方是漏掉相对路径。
char *pos = html; while ((pos = strstr(pos, "href=")) != NULL) { pos += 5; while (*pos == '"' || *pos == '\'') pos++; char *end = pos; while (*end && *end != '"' && *end != '\'' && *end != '>') end++; if (end > pos) { char url[512]; memcpy(url, pos, end - pos); url[end - pos] = '\0'; normalize_url(base_url, url, full_url, sizeof(full_url)); } }指针操作三行就把 URL 抠出来了:先跳过href=,再吃掉可能存在的引号,然后让end走到引号或>为止。抠出来的原始 URL 可能是/news/1.html、../index.html或者纯相对路径about.html,这些都必须交给normalize_url拼成完整的http://host/path形式,否则请求发出去全是 404。normalize_url里要处理的细节是:从 base_url 里提取scheme://host前缀,遇到/开头直接拼接,遇到../就回退一级目录。
3.4 布隆过滤器与队列调度:URL 去重和 BFS 爬取
抓过的 URL 得有个地方记着。hashtable 负责存链接关系,但查重用的是一套独立的布隆过滤器。布隆过滤器只占位数组,几个哈希函数算出几个位置,全置 1 就算「已见」。
static unsigned long djb2(const char *s) { unsigned long h = 5381; while (*s) h = h * 33 + (unsigned char)*s++; return h; } int bloom_add(bloom_t *bf, const char *url) { unsigned long h1 = djb2(url); unsigned long h2 = sdbm(url); for (int i = 0; i < bf->k; i++) { unsigned long idx = (h1 + i * h2) % bf->bits; bf->bitset[idx / 8] |= 1 << (idx % 8); } }这里用h1 + i * h2的方式从两个基础哈希衍生出 k 个位置,避免存 k 个函数指针。参数上我一般让位数组长度和预计抓取页面数 n 的比值 m/n ≈ 10,哈希函数个数 k = 7,误判率能压到 1% 以下。队列那边用循环队列即可,(rear + 1) % cap == front判满,push 时如果满了就直接丢——爬虫对丢 URL 不敏感,但绝对不能因为队列满把主流程卡死。
4. 权重计算:pagerank.c 里链接矩阵、阻尼系数与收敛判定
4.1 邻接表构建和悬挂页面的权重处理
hashtable 里存的是「A 页面引用了 B 页面」的边,pagerank 模块要把这些边转成可迭代的邻接关系。核心是两个数组:link[i][j]表示 j 页面是否有出链指向 i,out_degree[j]是 j 页面的出链总数。转完之后要单独扫一遍出度为 0 的页面。
悬挂页面必须提前处理。一个没有任何出链的页面,在标准 PageRank 公式里会让权重「漏掉」,分母为零直接崩溃。常见做法是把它的出链均分给所有页面,让它不吞权重也不制造空指针。代码里体现为:
for (j = 0; j < N; j++) { if (link[j][i]) { if (out_degree[j] > 0) sum += rank[j] / out_degree[j]; else sum += rank[j] / N; } }4.2 PageRank 迭代公式与 0.85 阻尼系数的效果
摘要把公式讲得已经很清楚了:每个页面的权重等于所有链接到它的页面权重之和乘以阻尼系数,再加一个基准分。完整迭代过程用双缓冲写法更稳:
double d = 0.85; double rank[N], new_rank[N], max_diff; for (i = 0; i < N; i++) rank[i] = 1.0 / N; for (iter = 0; iter < MAX_ITER; iter++) { for (i = 0; i < N; i++) { double sum = 0.0; for (j = 0; j < N; j++) { if (link[j][i]) sum += rank[j] / (out_degree[j] ? out_degree[j] : N); } new_rank[i] = (1.0 - d) / N + d * sum; } max_diff = 0.0; for (i = 0; i < N; i++) { max_diff = fmax(max_diff, fabs(new_rank[i] - rank[i])); rank[i] = new_rank[i]; } if (max_diff < 1e-6) break; }(1.0 - d) / N是基准分,保证没有任何入链的页面也保留基础权重;d * sum是链接贡献部分。0.85 是经验值,它决定了权重在链接间传播时的衰减速度。d 越接近 1,权重越集中在少数被大量引用的节点上;d 越小,页面自身的基准分影响越大,排名越平均。这里用的是双缓冲,所有新权重算完后再统一回写,避免像单缓冲那样让同一轮里先更新的页面影响后更新的页面。
4.3 收敛判定与 result.txt 排序输出
迭代不能无限制跑。两个终止条件:达到最大迭代次数,或者本轮所有页面的权重变化量最大值小于阈值。max_diff < 1e-6是常见选择,对几百个页面的规模,通常几十轮就收敛了。
收敛后整组权重会近似满足概率分布,也就是所有页面的权重和约等于 1。这是个很好的自检指标——如果 sum(rank) 明显偏离 1,说明有粗链接或悬挂页面处理出了问题。排序输出很简单:按权重降序,格式一行一个「URL 空格 权重」。
qsort(pages, N, sizeof(page_t), cmp_by_rank_desc); FILE *fp = fopen("result.txt", "w"); for (i = 0; i < N; i++) fprintf(fp, "%s %.6f\n", pages[i].url, pages[i].rank);cmp_by_rank_desc比较函数里注意返回值用b->rank > a->rank的差值,不要写成a->rank - b->rank再取反,否则遇到浮点精度问题排序不稳定。输出精度%.6f足够看出排名差异,文件里再带一个 URL 便于对照。
5. 避坑与排错:编译、抓取和权重计算里的五个翻车现场
5.1 备份文件 .c~ 混进构建,链接时报 multiple definition
现象:执行 make 报错,提示multiple definition of 'main'或者某函数重复定义。
原因:包里大量.c~、.h~文件是 vim 备份,跟源文件同名。如果 makefile 里用了通配符gcc *.c,会把main.c~也编译进去,两个 main 直接冲突。
解决:把 makefile 改成显式列出源文件,像第 2 章那样白名单编译;或者先清一遍备份文件:
find . -name "*~" -delete从那以后我拿到任何源码包的第一件事就是先扫一遍有没有~后缀文件,防止这种低级错误浪费一上午。
5.2 相对链接没补全,爬虫抓了一堆 404
现象:result.txt 里出现大量/favicon.ico、index.html这种残缺路径,抓回来的页面全是 404。
原因:link_parser 只做了字符串截取,没把相对路径拼成完整 URL。页面里写<a href="/news/1.html">时,多数爬虫实现没把 host 拼回去,请求就发到了空 host 上。
解决:normalize_url强制做三步——提取 base 的 scheme 和 host、拼接路径、处理../回退。还有一个实际经验:静态资源后缀直接过滤掉,.css/.js/.jpg/.png这些不是 HTML 页面,抓回来也解析不出链接,纯浪费请求。
5.3 布隆过滤器误判把未抓页面拦在外边
现象:跑完一看,实际抓取数远小于设定上限,日志里大量 URL 被跳过,但跳过原因不是 404。
原因:布隆过滤器存在误判率,位数组太小或哈希函数过多时误判会放大。它把从未见过的 URL 判成了「已抓取」,页面被静默丢弃。
解决:把 m/n 调到 10 以上,k 取 7 左右;更稳的做法是让布隆过滤器只当第一道闸,判定「已见」后再去哈希表最终确认——布隆过滤器的特性是只有误判没有漏判,所以它适合前置过滤,不适合当唯一判据。
5.4 PageRank 初始权重全 0,迭代结果全是 0
现象:result.txt 里每一行的权重都是 0.000000。
原因:有两种典型写法错误。一种是初始数组直接 memset 成 0,另一轮迭代后所有 sum 全是 0;另一种是迭代公式里忘写(1.0 - d) / N基准分,又没有链接贡献,结果恒为 0。
解决:初始权重必须设成1.0 / N,迭代公式里的基准分必须保留。跑完后加一行自检:把所有权重求和打印出来,正常应该约等于 1,等于 0 就说明初始化或公式有问题。
5.5 HTTP 响应没有 Content-Length,body 被截断
现象:有些页面只抓到半截 HTML,link_parser 抽出的链接明显偏少,甚至报连接重置。
原因:部分服务器响应用 chunked 编码,没有 Content-Length 字段;如果用固定长度或按 Content-Length 读,body 就会不全。包里的 http_client 在请求头里带了Connection: close,这种情况下按 EOF 读到关闭是更安全的兜底。
解决:先看响应头里有没有Content-Length,有就按长度读;没有就依赖Connection: close一直 recv 到返回 0;recv 时配合SO_RCVTIMEO超时,防止连接不关闭时整条线程卡死。
6. 进阶验证:用最小三页网络对账 result.txt 的权重输出
光跑通不算完,权重算得对不对得验证。我每次拿到新的 pagerank 实现,第一步都是构造一个能手算的最小网络,跑完和手算结果对比。构造三个页面:A 链向 B,B 链向 A 和 C,C 链向 A。出链数分别是 1、2、1,N=3,阻尼系数取 0.85,初始权重 1/3。
第一轮迭代手算如下:
PR(A) = (1-0.85)/3 + 0.85 * (PR(B)/2 + PR(C)/1) = 0.4750 PR(B) = (1-0.85)/3 + 0.85 * (PR(A)/1) = 0.3333 PR(C) = (1-0.85)/3 + 0.85 * (PR(B)/2) = 0.1917继续迭代到收敛,排序应该稳定在 B > A > C。把 start.txt 改成这三个 URL,抓取上限设 3,跑完 result.txt 里前三个权重如果和手算对得上,说明 pagerank 模块没问题。对不上的话,优先查出链统计:B 链向 A 和 C,out_degree 是不是 2。
再验证参数敏感性。把阻尼系数从 0.85 改成 0.6,基准分(1-d)/N = 0.1333变大,链接贡献变小,页面权重会相对拉平,排名顺序可能改变。比如有的页面靠入链数量取胜,有的页面靠入链质量取胜,d 变小时入链质量带来的优势会被稀释。这个实验能帮你理解为什么大型搜索引擎花大量精力调 d 和惩罚参数。
最后一步是验证去重模块。把 start.txt 里故意重复写同一个 URL,跑完看抓取日志,重复 URL 只该被抓一次——这是 bloomfilter 的验收标准,过了这关,工程里的抓取调度逻辑才算真闭环。
从那以后我每次跑权重前都强制自己先过一遍最小网络对账,不确认 pagerank 没玄学问题绝不上大数据集。希望帮到你。
本文还有配套的精品资源,点击获取