十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C语言爬虫课程设计:队列、布隆过滤器与PageRank实现

C语言爬虫课程设计:队列、布隆过滤器与PageRank实现 简介这是一份以课程设计为背景的C语言网络爬虫项目面向计算机相关专业学生及对底层网络编程感兴趣的开发者旨在解决常规脚本爬虫无法覆盖的高性能、可控制场景。项目完整覆盖HTTP请求构造、HTML/XML解析、URL编码处理、多线程抓取、错误重试与延时策略等内容并特别引入pagerank排序、bloomfilter去重与DFA敏感词过滤等扩展设计相比普通示例更有工程深度。压缩包共19个文件包含7个C源文件、2个头文件、Makefile构建脚本、README说明文档以及url.txt、top10.txt等测试数据整体大小965KB结构清晰便于阅读调试。已有149人学习。通过这个工程你可以获得一套可直接编译运行的爬虫基础框架同时理解C语言中内存管理、线程同步和网络库协作的典型写法为大型系统开发打下扎实基础。1. 为什么课程设计里会有 C 语言爬虫打开压缩包你看到的不是 Python 工程而是 crawler.c、queue.c、bloomfilter.c、pagerank.c 这一串 C 文件。大多数工程师会用 requests 爬虫完成同类任务但在低内存嵌入式环境、定制协议抓取或需要精确控制每条 TCP 连接的场景里Python 解释器的开销和调度不确定性会成为负担。这个课程设计把完整爬虫链路压缩进几 K 代码FIFO 队列做 BFS 抓取布隆过滤器挡住重复 URLDFA 扫描 HTMLPageRank 输出 top10.txt。适合刚学完数据结构、想在网络编程中练手的学生也适合对内存分配和指针越界还没有形成本能的开发者。读懂这份源码你等于把 socket、指针、字符串处理、文件 I/O 和收敛算法同时过了一遍。2. 爬虫架构拆解队列、抓取循环和并发设计爬虫本质上是一个 BFS 过程从种子页面开始把页面里的链接放入队列依次抓取再把新链接放回队尾。C 语言没有内置容器所以队列必须自己维护抓取循环则要处理 socket、HTTP 状态和文本截断。这套设计决定了后续去重和 PageRank 的输入质量。2.1 用队列实现 BFSqueue.c 的进出规则queue.c 用带头尾指针的单向链表实现 FIFO。入队时在尾部挂一个节点出队时从头部摘下一个节点并把字符串复制到节点内的固定缓冲区。这样抓取主循环不用关心链表内部结构只需要保证每次 dequeue 之后返回的字符串最终有人 free。// queue.c 入队与出队核心逻辑已做简化 typedef struct node { char url[MAX_URL_LEN]; struct node *next; } Node; int enqueue(Queue *q, const char *url) { Node *n (Node *)malloc(sizeof(Node)); if (!n) return -1; // C语言必须处理分配失败 strncpy(n-url, url, MAX_URL_LEN - 1); n-url[MAX_URL_LEN - 1] \0; n-next NULL; if (q-tail) q-tail-next n; else q-head n; q-tail n; q-len; return 0; } char *dequeue(Queue *q) { if (!q-head) return NULL; Node *h q-head; q-head h-next; if (!q-head) q-tail NULL; h-next NULL; return h-url; // 调用方负责 free }这里有两个容易被忽略的点。第一strncpy不会自动补\0所以必须手动写n-url[MAX_URL_LEN-1] \0否则后续打印或比较字符串时会越界读。第二dequeue返回的h-url是堆上分配的数组调用方用完要free(h-url)再释放 h 自身如果误写成只释放 Node就会造成每个 URL 泄漏几十字节。这也是 C 语言内存管理和容器类型最典型的边界谁分配谁释放。2.2 crawler.c 的抓取主循环socket、HTTP 请求与状态处理抓取主循环从队列取 URL解析成 host 和 path用 socket 建立 TCP 连接发送手工拼装的 HTTP GET然后循环 recv直到连接关闭。因为只抓静态页面请求头用Connection: close省去连接池和半包状态机的复杂度。// crawler.c 中一次抓取请求的骨架 int fetch_page(const char *host, const char *path, char *body, size_t cap) { int sock socket(AF_INET, SOCK_STREAM, 0); if (sock 0) return -1; struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(80); addr.sin_addr *(struct in_addr *)hp-h_addr_list[0]; if (connect(sock, (struct sockaddr *)addr, sizeof(addr)) 0) { close(sock); return -1; } snprintf(req, sizeof(req), GET %s HTTP/1.1\r\n Host: %s\r\n User-Agent: CourseCrawler/0.1\r\n Connection: close\r\n\r\n, path, host); send(sock, req, strlen(req), 0); int total 0; while (total (int)cap - 1) { ssize_t n recv(sock, body total, cap - total - 1, 0); if (n 0) break; total n; } body[total] \0; close(sock); return total; }这段 socket 编程必须注意三点gethostbyname返回的地址列表可能为空拷贝前要判断recv返回 0 表示正常结束返回 -1 是网络错误两者不能混为一谈缓冲区容量在每次 recv 时都要参与计算否则并发量一上来指针越界只是时间问题。课程设计里响应状态码的解析应该放在 recv 之后找到第一个\r\n\r\n分隔头与体再判断HTTP/1.1 200是否出现。2.3 并发设计爬虫并发设计到底哪个好很多人在课程设计里纠结要不要上多线程。我的建议是先用单线程把主循环跑通再加入 pthread 消费者模型。多线程引入的难点不是抓取逻辑而是队列同步。如果能控制好这把锁你就能在报告中写清楚“并发粒度”这个加分项。维度单线程 BFS多线程 worker实现难度低逻辑线性高要处理锁和条件变量吞吐量受限于 DNSrecv 等待可叠加 I/O 等待去重一致性天然串行需要额外加锁过载风险对目标站压力可控并发数控制不好会被断连一个稳妥的多线程结构是全局只有一个共享队列worker 线程从队列取 URL抓取和解析全部在线程内完成。下面是可以直接嵌进 crawler.c 的 worker 骨架void *worker(void *arg) { while (1) { char *url thread_safe_dequeue(q); // 内部加 mutex if (!url) break; fetch_and_save(url); free(url); // 释放 deque 返回的缓冲 } return NULL; }线程数一般设为 CPU 核数的 2 到 4 倍效果最好因为 HTTP 抓取大部分时间在等待 I/O不是计算。为了不给目标站造成压力两次请求之间建议usleep(500000)半秒延时。C 语言的优势在于可以精确控制每个 worker 的 sleep 粒度而脚本语言的调度往往被解释器接管无法做到这个级别的精细控制。3. 去重与解析布隆过滤器、DFA 和内存管理抓取过程中最常见的资源浪费是同一个 URL 被反复解析。如果不做去重队列会无限膨胀最后把内存吃光。课程设计里的bloomfilter.c负责 URL 级去重DFA.c负责从 HTML 中提取 href两者都要求精确控制内存边界。3.1 布隆过滤器位图拦截重复 URL简单用哈希表也能去重但百万级 URL 的内存开销通常在几十 MB 以上。布隆过滤器用一个位图表示元素是否存在内存只有哈希表的几十分之一代价是存在误判它只会把不存在的 URL 误判为已存在从而少抓取一个页面不会造成逻辑错误。// bloomfilter.c 中实现的核心操作 #define BF_SIZE (1 20) // 1M bit约 128KB #define BF_HASH 3 // 3 个哈希函数 void bf_add(BloomFilter *bf, const char *data) { unsigned int h bkdr_hash(data); // BKDR 哈希字符串常用 unsigned int h2 djb2_hash(data); // DJB2 哈希分布较均匀 bf-bits[h (BF_SIZE - 1)] 1; bf-bits[h2 (BF_SIZE - 1)] 1; bf-bits[(h ^ h2) (BF_SIZE - 1)] 1; } int bf_test(BloomFilter *bf, const char *data) { unsigned int h bkdr_hash(data); unsigned int h2 djb2_hash(data); return bf-bits[h (BF_SIZE - 1)] bf-bits[h2 (BF_SIZE - 1)] bf-bits[(h ^ h2) (BF_SIZE - 1)]; }这里用h (BF_SIZE-1)代替% BF_SIZE前提是BF_SIZE必须是 2 的幂。第三个哈希用异或组合产生避免三个哈希碰撞落在同一位区。位图大小建议做成目标 URL 总量的 10 倍以上如果预计划抓 10 万页面128KB 位图的误判率通常低于 1%。使用后要free(bf-bits)再free(bf)这是 C 语言内存管理里最容易漏掉的一组。3.2 DFA 扫描 HTML比正则表达式更可控提取a href...是爬虫刚需。C 语言里没有现成的正则替换函数正则表达式的可读性也很差。DFA 状态机逐字节读入 HTML遇到进入标签状态匹配href字符串后进入属性值状态遇到右引号时提取结束。// DFA.c 简化版识别 href... 并提取 URL int extract_href(const char *html, char *out, int out_limit) { int state 0; // 0:文本 1:标签 2:属性名 3:属性值 int n 0; for (int i 0; html[i] ! \0 n out_limit - 1; i) { char c html[i]; switch (state) { case 0: if (c ) state 1; break; case 1: if (c ) state 0; else if ((c h || c H) strncasecmp(html[i], href, 4) 0) { state 2; i 3; // 跳过 hre 剩下的字符 } break; case 2: if (c ) state 3; else if (c ) state 0; break; case 3: if (c || c \) { out[n] \0; return n; } if (n out_limit - 1) out[n] c; break; } } out[n] \0; return n; }strncasecmp在 Linux 上来自strings.hWindows 上需要自己写小写比较函数否则跨平台编译会失败。这个状态机只考虑了双引号和单引号混用的情况实际使用中a href...也可能出现最好把开始引号类型记录下来遇到对应结束引号再返回。链接提取完成后handleURLs.c会过滤掉javascript:和mailto:开头的链接并把相对路径拼成绝对 URL。拼接时用strncpy和strncat手动限制长度避免缓冲区越界。3.3 C 语言内存管理分配次数与释放次数要对上C 语言爬虫比 requests 爬虫难写很大一部分在于所有内存都要手动归还。我见过最多的问题是一块 URL 缓冲从队列出队后经过三层函数处理没人记得它属于谁。可以约定“谁 malloc 谁 free”。dequeue内部 malloc那么调用方在fetch_and_save结束后必须 free如果fetch内部又为 body 申请了内存整个调用链都要有对应的清理。常见泄漏点原因修复方法dequeue 返回的 url 未释放误以为队列节点已释放在 worker 退出循环前加 freebloomfilter 的 bits 未清只释放了结构体先 free(bf-bits) 再 free(bf)多次重定向中 leak path每次拼接申请新内存用栈数组或限定长度提交前可以在主函数末尾打印malloc_count和free_count两个数字不等就不要往下汇报“运行成功”。更严格的做法是用 valgrind 自动检查我们会在最后一章给出具体命令。C 语言的内存管理不是“用完立即释放”而是保证每条路径上都有唯一的释放点。4. PageRank 计算三元树索引与迭代收敛爬虫抓完页面后课程设计还要输出 top10.txt这需要给每个页面计算排名。PageRank 的价值在于不用人工评估内容质量而是通过链接结构判断一个页面是否值得被优先展示。要落地这套算法C 语言需要解决两个问题字符串 URL 如何映射成整数索引以及稀疏矩阵如何迭代计算。4.1 为什么爬虫之后还要算 PageRank简单入链计数把每条链接都看作同等权重两个页面各有一条入链一个来自首页一个来自无人访问的角落得分完全一样。PageRank 通过迭代解决这个问题高权重页面通过出链分散权重悬挂链接会把概率随机分配给所有页面。最终收敛得到的 rank 值就是一个稳定的重要性分布。实现时需要一个从 URL 到整数 ID 的映射方便存储每个页面的邻接关系。4.2 ternaryTree.c 建立 URL 到索引的映射三元树在课程设计里充当字典插入时按字符比较中间子节点表示同一前缀的下一层字符。它比哈希表更节省内存还天然支持按字典序扫描 URL适合 PageRank 需要遍历全部节点的场景。// ternaryTree.c将 URL 映射为整型 ID TST *tst_insert(TST *root, const char *key, int idx) { if (!root) { root tst_create_node(*key); } if (*key root-c) { root-left tst_insert(root-left, key, idx); } else if (*key root-c) { root-right tst_insert(root-right, key, idx); } else { if (*(key 1) ! \0) { root-mid tst_insert(root-mid, key 1, idx); } else { root-index idx; } } return root; }插入时递归终止条件是*(key1) \0这时把当前节点标记为终点并保存 idx。因为结束符在树里占了一层查找 URL 时整串匹配不会出现“前缀也命中”的错误。用全局计数器url_count作为 idx把抓取到的每个 URL 都插入树中就得到了 URL 到 ID 的正向映射。注意插入过程会为每个字符创建节点程序退出前一定要用递归tst_free释放整棵树。4.3 PageRank 迭代公式与收敛PageRank 的更新公式是rank[i] (1-d)/N d * sum(rank[j]/out_degree[j])对所有指向 i 的 j 求和。矩阵是稀疏的用二维数组会浪费大量内存应该用邻接表记录每个页面的入链来源。每次迭代用旧 rank 计算新 rank再判断新旧向量差是否低于阈值。// pagerank.c 中的一次迭代 for (int iter 0; iter MAX_ITER; iter) { double dangling 0.0; for (int i 0; i N; i) if (out_degree[i] 0) dangling rank[i]; for (int i 0; i N; i) { double sum 0.0; for (int k 0; k in_cnt[i]; k) { int j in_links[i][k]; sum rank[j] / out_degree[j]; } new_rank[i] (1.0 - DAMPING) / N DAMPING * sum DAMPING * dangling / N; } double diff 0.0; for (int i 0; i N; i) diff fabs(new_rank[i] - rank[i]); for (int i 0; i N; i) rank[i] new_rank[i]; if (diff THRESHOLD) break; }关键在 dangling 那行没有出链的页面会让概率流失PageRank 模型假设用户停在悬挂页时会随机打开一个页面所以要把这部分概率平均加到所有页面头上。DAMPING 默认取 0.85意思是用户 85% 的时间点击页面链接15% 的时间随意输入地址。THRESHOLD 设成1e-6时通常 30 到 80 次迭代收敛设成1e-8后10 万级的页面会明显变慢。这里用到了fabs所以pagerank.c要包含math.hMakefile 里必须加-lm。4.4 输出 top10.txt 与参数调整PageRank 计算完成后把每个 URL 和 rank 按“URL rank”格式写入临时结果文件再用 sort 按第二列降序截取前 10 行。./pagerank url.txt tempurlfile.txt ranks.txt sort -k2,2nr ranks.txt | head -10 top10.txt-k2,2nr表示只对第二列数字排序n是数值排序r是降序。如果不指定第二维结束位置sort 会把整行作为联合排序键可能把 URL 字符串也参与比较导致结果不符合预期。实际调参时如果 top10.txt 里首页占比异常高多半是抓取页面太少PageRank 退化成入链计数。把最大抓取页数从几百提高到上万重新跑一遍排名会更稳定。5. 构建与排错Makefile、文件 I/O 与重试策略这个项目顶层同时放着源码和编译产物这种状态在 C 语言课程设计里很常见。要完整复现结果必须处理三件事编译依赖、中间文件的读写方式、以及失败时的重试策略。否则换一台机器或者断网再跑结果很可能对不上。5.1 Makefile 的依赖构建与优化选项Makefile 不应该只是把源文件罗列在命令行里还要把头文件依赖写清楚。crawler 依赖 queue.o、bloomfilter.o、DFA.o是因为 crawler.c 里复用了这些模块的符号。修改任何一个.h文件相关的.o都要重新编译。CC gcc CFLAGS -Wall -O2 -stdc99 -D_GNU_SOURCE CRAWLER_OBJS crawler.o queue.o bloomfilter.o DFA.o handleURLs.o PAGERANK_OBJS pagerank.o ternaryTree.o all: crawler pagerank crawler: $(CRAWLER_OBJS) $(CC) -o $ $^ -lpthread pagerank: $(PAGERANK_OBJS) $(CC) -o $ $^ -lm %.o: %.c common.h $(CC) $(CFLAGS) -c $ clean: rm -f *.o crawler pagerank tempurlfile.txt ranks.txt-lpthread在多线程扩展时是必须的在 glibc 2.34 之后虽然已经并入了 libc但保留不会错。-D_GNU_SOURCE能开启strncasecmp等 POSIX 扩展否则 strict c99 下会得到 implicit declaration 警告。命令前的缩进必须用制表符不能用空格这是新手最容易卡住的点。把 crawler 和 pagerank 分离是因为抓取和排名对链接库的要求不同。5.2 文件 I/O把 URL 和中间结果落盘爬虫运行过程中url.txt保存种子 URLtempurlfile.txt保存临时链接关系top10.txt保存最终排名。因为抓取可能中断写文件时用追加模式而不是覆盖模式。void append_url(const char *path, const char *url) { FILE *fp fopen(path, a); if (!fp) { perror(fopen); exit(EXIT_FAILURE); } fprintf(fp, %s\n, url); fclose(fp); }追加模式不会覆盖已有内容适合断点续跑。但普通磁盘写入有缓冲程序在fclose前被杀掉时最后一批数据可能丢失。对tempurlfile.txt这类高频中间文件每写入一批后主动fflush(fp)一次可以让崩溃时只丢最后一次写的数据。读取url.txt时用fgets按行读一定记得把尾部换行符替换成\0否则拼接到 HTTP 请求里会触发 400 错误。5.3 错误处理与重试策略C 语言的异常路径requests 会把 HTTP 状态码、连接失败、超时都包装成异常C 语言里每个函数都通过返回值告诉你结果。DNS 失败可能只是临时抖动connect 超时可能是连接池被拖慢HTTP 404 则没有重试必要。给不同错误设计不同处置方式抓取成功率会明显提高。错误场景返回特征处置方式gethostbyname 失败hp NULLsleep 2s 后重试 3 次connect 超时connect 返回 -1, errno EHOSTUNREACH放弃本次继续下一个recv 收到 -1errno EINTR重新 recvHTTP 状态码 404响应头有 404不写正文直接记录HTTP 3xxLocation 字段拼接到新 URL 后重新入队错误处理不要放在 worker 外面因为任务一旦出队就无法回到队列。这里可以把一个 URL 最多重试 3 次每次失败后把retry_count加一超过 3 次就把 URL 写入failed.txt。recv返回 -1 时第一时间把errno保存到局部变量因为后续任何库函数都可能改写errno这是 C 语言排错里最隐蔽的坑。5.4 常见崩溃非法地址、指针越界遇到 segmentation fault 时先怀疑非法地址来自哪里。gethostbyname返回的h_addr_list可能为空如果目标站没有 A 记录memcpy会读越界strncat拼接长度超过缓冲区大小的链接会在栈上留下不可预测的数据。稳妥做法是在 handleURLs.c 中做两次检查入队前检查 URL 长度拼接前检查最终长度。如果课程设计报告需要“问题与解决”章节用-fsanitizeaddress重新编译再跑一次最小样例能立刻看到越界发生在第几行这个素材比任何描述都有说服力。6. 从编译到 top10.txt一次完整的效果验证6.1 一把跑通make 和运行拿到源码后第一步是清空旧产物重新构建。注意必须在 Linux 或 WSL 环境里因为socket、gethostbyname都是 POSIX APIWindows 默认不开放。make clean make ./crawler http://example.com 300 ./pagerank url.txt tempurlfile.txt ranks.txt sort -k2,2nr ranks.txt | head -10 top10.txt第一条命令会输出每个.o的编译信息没有 error 说明依赖关系完整。第二条命令抓 300 个页面过程中可以用wc -l tempurlfile.txt观察链接关系增长。第三条命令读取抓取结果并计算排名。第 4 章的命令和这里保持一致排序键都是-k2,2nr。6.2 用 valgrind 验证内存管理提交课程设计前跑一遍 valgrind 能发现大部分指针问题。下面这行会在遇到内存错误时返回退出码 1并输出详细信息valgrind --leak-checkfull --show-leak-kindsall --error-exitcode1 \ ./crawler http://example.com 20重点看两个输出段。第一段是Invalid read/write of size 8说明某个指针被释放后又访问或者越界读了相邻对象。第二段是definitely lost: N bytes in M blocks这个数字必须为 0。如果泄漏来源指向dequeue多半是调用方没有释放返回的 url指向tst_insert则是三元树的递归节点没有释放。在 valgrind 日志里搜索by后面的第一行函数名就能定位到释放路径的另一端。6.3 扩展思路给爬虫加一个最小深度控制最后一个技巧很实用在Node结构体中增加一个int depth字段enqueue时把当前深度传进去当depth max_depth时停止入队。这样可以把抓取范围控制在站点某个子树内避免无边界抓取把磁盘写满。改动只涉及 queue.c 和 crawler.c 两处很快就能完成。如果还想压榨单机吞吐把第 2 章的 worker 开到 4 个线程并在每次 recv 后调用usleep(1000)就可以在不触发目标站限流的前提下把带宽用满。扩展完成后重新执行 6.1 的 build 命令跑出来的 top10.txt 应该和单线程版本基本一致但总耗时明显减少。本文还有配套的精品资源点击获取
返回列表