ARTICLE DETAIL

资讯详情

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

C语言网络爬虫课程设计:从socket编程到并发抓取的完整实践

C语言网络爬虫课程设计:从socket编程到并发抓取的完整实践 简介C语言网络爬虫课程设计完整工程包面向计算机专业学生和希望了解底层网络编程的开发者用于展示如何用C语言完成网页抓取、链接解析、网页排名等核心流程。压缩包共收录19个文件主体为7个C语言源文件辅以头文件、Makefile编译脚本、可运行的爬虫与排名程序以及说明文档、网址清单、排名结果等文本材料整体大小约965KB。目前已有149人浏览学习适合作为课程设计参考或自学爬虫原理的实践素材。包内源码覆盖了HTTP协议、网络传输库、超文本解析、布隆过滤器、敏感词过滤、多线程同步和内存管理等关键知识点同时给出了可执行程序与输出样例学习者可以对照源码逐步理解每个模块的运行机制。通过动手阅读和运行这份工程既能掌握C语言在真实网络项目中的开发方式也能获得一个易于扩展的爬虫框架为后续加入更多功能奠定良好基础。1. 为什么课程设计敢选C语言写爬虫很多人的课程设计第一反应是 Python但题目偏偏要求 C 语言这往往不是刁难而是想让你把“网络爬虫”四个字从黑盒变成白盒。C 语言没有 requests 库没有 BeautifulSoup一切 HTTP 请求、响应解析、内存管理都要靠自己的手去拼几十行代码就能还原一次网页抓取的全部过程DNS 解析、TCP 握手、构造请求、循环 recv、处理 chunked、提取链接。这个过程做完你对 socket 编程、指针、字符串和内存分配的理解会远超刷一百道练习题。下面的内容不会带你完整实现一个工业级爬虫而是聚焦课程设计里最常被考核的四个点HTTP 客户端、HTML 链接提取、并发抓取、内存与边界处理每一步都给出可编译的最小代码方便你直接抄进课设报告和答辩 PPT。2. 用socket从零写HTTP请求连接、发送、接收与响应解析2.1 为什么不用libcurl课程设计的考核点libcurl 确实把 HTTP 封装得足够好但如果你在课程设计里直接用curl_easy_perform老师很难看出你理解网络模型。常见的做法是自己用 socket API 写一个 HTTP/1.1 客户端再把 curl 作为扩展对比。这一章就按这个思路拆开先建立 TCP 连接再构造请求最后解析响应。gethostbyname返回的he-h_addr_list[0]可能还是网络字节序的 IP 地址memcpy到sin_addr时长度取he-h_length不要硬编码4否则遇到 IPv6 会出错。htons(port)的端口号是 80 时其实无所谓但写成htons才是规范的写法。2.3 构造并发送HTTP/1.1 GET请求连接建立后要向服务器发送一段符合 HTTP/1.1 格式的文本。代码不复杂但有几个细节容易被扣分int send_http_request(int fd, const char *host, const char *path) { char request[2048]; int len snprintf(request, sizeof(request), GET %s HTTP/1.1\r\n Host: %s\r\n User-Agent: CourseCrawler/1.0\r\n Accept: text/html\r\n Connection: close\r\n \r\n, path, host); if (len 0 || len (int)sizeof(request)) { fprintf(stderr, request too long\n); return -1; } ssize_t sent send(fd, request, len, 0); if (sent ! len) { perror(send); return -1; } return 0; }这里snprintf让我们既能拼接字符串又能限制长度返回值如果达到sizeof(request)说明请求超长必须返回错误。Connection: close告诉服务器响应完就关闭连接省去 keep-alive 状态机的处理这是 C 语言课设里“主动降难度”的正确选择。User-Agent是必须的很多网站会拦掉默认的空 UA起一个容易识别的名字即可。2.4 接收数据与缓冲区拼接一个循环读到EOFrecv不会一次返回整个响应它只是把内核缓冲区当前可读的数据拷贝出来所以必须用循环一直读到recv返回 0 或负数。下面的receive_response把数据动态拼进一个malloc出来的缓冲区#define BUF_SIZE 4096 char *receive_response(int fd, size_t *total_len) { size_t cap BUF_SIZE, len 0; char *buf malloc(cap); if (!buf) return NULL; char tmp[BUF_SIZE]; ssize_t n; while ((n recv(fd, tmp, sizeof(tmp), 0)) 0) { if (len n 1 cap) { cap (len n 1) * 2; char *nb realloc(buf, cap); if (!nb) { free(buf); return NULL; } buf nb; } memcpy(buf len, tmp, n); len n; } if (n 0) { perror(recv); free(buf); return NULL; } buf[len] \0; *total_len len; return buf; }这个函数的注意力应该放在容量增长上每次收数据前检查len n 1是否超过cap这里的1是为最后补\0留位置。realloc失败时要free旧指针否则会泄漏。注意recv返回 0 只发生在服务端关闭连接时如果对方一直不关你的程序就会卡住所以实战中还需要加超时见第 5 章。2.5 解析响应头状态码、Content-Length与chunked拿到完整响应后需要把头与 body 分开。HTTP 协议规定头和体之间隔一个空行也就是\r\n\r\n用strstr找到它即可char *parse_response(char *response, int *status, int *is_chunked) { char *header_end strstr(response, \r\n\r\n); if (!header_end) return NULL; *header_end \0; // 状态行HTTP/1.1 200 OK if (strncmp(response, HTTP/1.1 200 , strlen(HTTP/1.1 200 )) ! 0) { fprintf(stderr, unexpected status: %s\n, response); } sscanf(response, HTTP/1.1 %d, status); *is_chunked strstr(response, Transfer-Encoding: chunked) ! NULL; return header_end 4; }这里把header_end位置置为\0这样response字符串只包含响应头方便strstr查字段。sscanf直接取状态码比逐字符解析省事。is_chunked用于区分普通长度与分块传输。对Connection: close的响应body 就是返回指针到字符串结尾如果遇到 chunked还需要去除每个块的长度行课程设计里可以直接把“不支持 chunked”写进限制说明或者补一段解码函数后者在报告里很加分。响应字段含义爬虫里的处理建议Content-Lengthbody字节数可用于预分配缓冲区但动态拼接更安全Transfer-Encoding: chunked分块传输需要解析块头否则body会带有长度行Connection: close响应后关闭连接直接读到recv返回0简化EOF判定Content-Encoding: gzip压缩需要zlib解压课设可跳过把上面的函数串起来就是一个最基础的http_get先解析 URL 提取 host 和 path再tcp_connect、send_http_request、receive_response、parse_response。这个过程中最容易忽略的是perror会污染 stderr在课程设计里建议统一用一个LOG宏把错误信息按[ERROR]和[INFO]分级打印方便答辩演示。3. 从HTML中提取链接字符串扫描比正则更可控3.1 解析的定位这不是HTML解析器而是链接提取器爬虫的核心工作之一是找出页面里的下一个目标 URL。C 语言没有像re.findall那样的正则语法虽然 POSIX regex 库也能用但写出来的man regex风格的函数又长又难调。常见做法是自己写一个基于strstr和strchr的扫描器它只关心a href...这类模式不追求完整 HTML 语义对课程设计完全够用。3.2 提取href的手写扫描函数下面这个函数把一段 HTML 中所有a标签的第一个href属性值提取到links数组#include ctype.h #include strings.h #define MAX_LINK_LEN 511 void extract_hrefs(const char *html, char links[][MAX_LINK_LEN 1], int max_links, int *count) { *count 0; const char *p html; while (*p (*count) max_links) { const char *a strcasestr(p, a); if (!a) break; const char *end_tag strchr(a, ); if (!end_tag) break; const char *q a; while (q end_tag) { q strcasestr(q, href); if (!q || q end_tag) break; const char *key q 4; while (key end_tag isspace((unsigned char)*key)) key; if (key end_tag || *key ! ) { q 1; // 避免死循环 continue; } key; while (key end_tag isspace((unsigned char)*key)) key; if (key end_tag (*key || *key \)) { char quote *key; key; const char *val_end strchr(key, quote); if (val_end val_end end_tag) { int len val_end - key; if (len 0 len MAX_LINK_LEN) { memcpy(links[*count], key, len); links[*count][len] \0; (*count); } break; // 每个a只取第一个href } } q 1; } p end_tag 1; } }这里strcasestr是 GNU 扩展编译时需要加-D_GNU_SOURCE否则就写成strstr并手动对比大小写。外层循环用strchr(a, )找到标签结束位置所有属性扫描都在a和之间进行避免误抓正文中的“href”。内层循环每次找到href后检查后面是否为和引号典型格式是href...或href...。每个标签只提取第一个链接这对搜索引擎常见的“只跟随第一个a标签”需求也算合理。如果页面里一个a有多个href可以去掉break但要小心同样的q 1引导继续扫描。3.3 相对URL与绝对URL的拼接提取出来的href可能是https://example.com/x这样的绝对地址也可能是../a.html或/path这样的相对地址。要抓到下一个页面必须把它补全为绝对 URL。常见规则分三种情况处理void absolute_url(const char *base, const char *href, char *out, size_t out_size) { if (strstr(href, ://)) { snprintf(out, out_size, %s, href); return; } // 跳过//提取协议主机部分 const char *scheme_end strstr(base, ://); if (!scheme_end) { snprintf(out, out_size, %s, href); return; } const char *host_start scheme_end 3; const char *path_slash strchr(host_start, /); if (href[0] /) { int origin_len path_slash ? (int)(path_slash - base) : (int)strlen(base); snprintf(out, out_size, %.*s%s, origin_len, base, href); } else { // 相对上一级目录 const char *last_slash strrchr(base, /); if (last_slash) { snprintf(out, out_size, %.*s%s, (int)(last_slash - base 1), base, href); } else { snprintf(out, out_size, %s/%s, base, href); } } }代码里先判断href是否包含://包含则直接使用。base形如http://example.com/dir/page.htmlscheme_end是://的位置host_start是主机名开始path_slash是后边第一个/。对于/path形式的站内绝对路径只保留协议和主机部分再拼接上/path。对于../a.html这种直接取base中最后一个/之前的部分包含这个/然后把href接在后面。这个函数没有处理../的语义折叠课程设计里注明“仅支持同类目录下的相对链接”即可。href值base URL拼接结果https://a.com/xhttp://b.com/yhttps://a.com/x/zhttp://example.com/dir/pagehttp://example.com/z../up.htmlhttp://example.com/dir/pagehttp://example.com/dir/../up.html未折叠tie.htmlhttp://example.com/dir/pagehttp://example.com/dir/tie.html实际网页里还会出现以//开头的协议相对 URL例如//example.com/a。处理很简单在absolute_url开头判断href[0]/ href[1]/直接把base的http:前缀加上就好。这个细节不写也不影响主流程但写进报告能体现你考虑过协议相对地址。3.4 去重线性查找数组就够了课设爬虫不需要复杂的布隆过滤器用二维字符数组保存已访问 URL每次入队前遍历比较即可。代码如下int is_visited(char visited[][512], int visited_cnt, const char *url) { for (int i 0; i visited_cnt; i) { if (strcmp(visited[i], url) 0) return 1; } return 0; }这个函数是 O(n)但当页面数量在几百级时完全能跑。真正要注意的是两种“形不同实相同”的 URL带尾部斜杠的http://example.com和http://example.com/以及带默认端口号的http://example.com:80。最简单的办法是在入队前做一个字符串规整去掉:80、补上末尾斜杠。代码就一行snprintf但报告里能写成“URL 归一化处理”。4. 多线程并发抓取与内存管理的正确姿势4.1 单线程太慢但多线程不是万能药一个爬虫如果逐个请求网页网络延时会让总耗时翻几十倍。并发能显著提高吞吐但也会引入新的问题共享队列的竞争、线程安全、资源消耗。课程设计里用pthread实现一个固定线程数量的线程池是最常见的方案既能展示你对并发模型的理解又不至于像分布式爬虫那样引入网络通信和节点管理。4.2 任务队列pthread_mutex与条件变量线程池的核心是任务队列。我们要维护一个环形缓冲生产者解析页面后往尾部放 URL消费者工作线程从头部取 URL两端都需要互斥。下面是可用的队列实现#include pthread.h #define QUEUE_CAPACITY 64 typedef struct { char url[512]; } Task; typedef struct { Task items[QUEUE_CAPACITY]; int head, tail, count; pthread_mutex_t lock; pthread_cond_t not_empty; pthread_cond_t not_full; } TaskQueue; void queue_init(TaskQueue *q) { q-head q-tail q-count 0; pthread_mutex_init(q-lock, NULL); pthread_cond_init(q-not_empty, NULL); pthread_cond_init(q-not_full, NULL); } void queue_push(TaskQueue *q, Task task) { pthread_mutex_lock(q-lock); while (q-count QUEUE_CAPACITY) { pthread_cond_wait(q-not_full, q-lock); } q-items[q-tail] task; q-tail (q-tail 1) % QUEUE_CAPACITY; q-count; pthread_cond_signal(q-not_empty); pthread_mutex_unlock(q-lock); } int queue_pop(TaskQueue *q, Task *task) { pthread_mutex_lock(q-lock); while (q-count 0) { pthread_cond_wait(q-not_empty, q-lock); } *task q-items[q-head]; q-head (q-head 1) % QUEUE_CAPACITY; q-count--; pthread_cond_signal(q-not_full); pthread_mutex_unlock(q-lock); return 0; }注意pthread_cond_wait一定要放在while而不是if里因为条件变量存在虚假唤醒。push和pop必须成对使用signalpush唤醒等待空队列的消费者pop唤醒等待满队列的生产者。Task里用固定长度char url[512]是为了避免指针所有权问题你可以在push时strncpy数据而pop时得到一份拷贝这样队列不持有动态内存。线程函数的写法很简单void *worker_thread(void *arg) { TaskQueue *q (TaskQueue *)arg; Task task; while (1) { queue_pop(q, task); if (task.url[0] \0) break; // 毒丸机制 process_url(task.url); } return NULL; }这里用空字符串作为毒丸让线程退出。主线程在所有任务完成后向队列塞入WORKER_NUM个空任务然后pthread_join。但要注意如果队列满毒丸也可能阻塞所以通常在主线程把process_url里新发现的 URL 入队之前先判断是否还有未完成的“活跃任务计数”。课设中一个更简单的做法是用一个atomic_int记录当前待处理任务数当它变为 0 且队列为空时直接注入毒丸。4.3 每个线程的抓取流程与内存释放工作线程调用的process_url负责抓一个页面、解析链接、再入队。这个函数的写法直接决定了内存会不会泄漏void process_url(const char *url) { char *body http_get(url); if (!body) return; char links[128][512]; int link_cnt 0; extract_hrefs(body, links, 128, link_cnt); for (int i 0; i link_cnt; i) { char abs_url[512]; absolute_url(url, links[i], abs_url, sizeof(abs_url)); if (!is_visited(visited, visited_cnt, abs_url)) { add_visited(abs_url); Task t; snprintf(t.url, sizeof(t.url), %s, abs_url); queue_push(queue, t); } } free(body); }这里http_get内部用malloc分配返回体extract_hrefs只是读取所以最后必须free(body)。links是栈上的二维数组不需要手动释放。visited和visited_cnt是多个线程共享的全局变量但这里为了简化没有加锁实际课程设计中需要为visited也加一个互斥锁或者用__atomic_add_fetch做计数。否则两个线程可能抓到同一个 URL。线程间共享的全局变量包括visited数组、visited_cnt、queue。建议把它们封装成一个Crawler结构体把锁也包含进去。如果不想引入复杂结构最省事的办法是只在主线程中做“URL去重”工作线程只管抓取和提取把新链接结果收集到本地数组再由主线程统一去重入队。这其实是一种“生产者-消费者”变体每个工作线程本身是消费者同时又是生产者但生产的任务通过主线程中转。4.4 并发参数怎么选线程数、超时、延时并发不是越大越好本地的 DNS 查询和 socket 连接都会占资源。下表是课程设计里比较合适的起步参数参数推荐值说明工作线程数4 ~ 8超过 8 收益不明显还容易触发限流socket接收超时5 秒SO_RCVTIMEO防止 recv 永久阻塞两次请求间隔100 ~ 500 毫秒usleep(500000)或者用nanosleep单任务最大跳转数3 层用深度字段控制抓取范围在http_get里加延时是最容易的地方每次连接前usleep(200000)。但要注意线程内的usleep会占用线程所以如果希望精确限速可以用一个全局的“下一个允许请求时间”。课程设计里直接用线程数乘以延时来控制速率也说得过去。限速的目的是避免被服务器封杀也是爬虫工程师的基本职业素养。4.5 常见并发坑条件变量与死锁两个典型错误一个是在queue_pop里用if而不是while另一个是在持有队列锁时调用usleep导致其他线程全部卡住。如果你发现程序运行几秒就停止输出先用gdbattach 上去thread apply all bt看线程是不是都等在pthread_cond_wait或锁上。还有一个是毒丸与queue_pop的配合如果消费者线程多于毒丸数会导致部分线程永远阻塞所以必须每个线程一个毒丸。5. 课程设计验收清单与调试技巧5.1 老师会问的边界问题怎么答验收时老师通常不会让你爬大型网站而是让你跑一个本地或小站点然后追问几个问题。第一个是“如果服务器返回 404 你怎么处理”你的代码现在只在parse_response里打印了一条错误应该返回非 200 状态码时直接跳过。第二个是“如果页面是 gzip 压缩的”你的Content-Encoding头没处理body 会是乱码可以诚实地说明未实现也可以加一段检测到 gzip 就用 zlib 的uncompress解压。第三个是“怎么避免重复抓取”把visited数组展示给老师看并解释为什么不用strstr去重。5.2 用setsockopt设置接收超时一个最容易让爬虫挂掉的地方是recv永久阻塞。设置超时的代码struct timeval tv; tv.tv_sec 5; tv.tv_usec 0; setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, tv, sizeof(tv));这条要加在connect之后、send之前。加了超时后recv超时会返回 -1 且errno为EAGAIN或EWOULDBLOCK所以receive_response里perror(recv)会把“Resource temporarily unavailable”误报为错误更合理的处理是超时时关闭 socket 并返回已收到的部分数据或者直接返回 NULL。你可以用一个select或者检查errno来区分。5.3 用Valgrind证明你的内存管理没问题答辩时最有说服力的不是你说“我没有泄漏”而是把 valgrind 输出截图放进报告。命令如下valgrind --leak-checkfull --show-leak-kindsall --error-exitcode1 ./crawler http://example.com重点看两个指标definitely lost和indirectly lost都应该为 0。如果发现有泄漏先看是否所有malloc都有对应的free。最常见的一个泄漏点http_get返回的 body 在process_url里free了但process_url里如果提前return没有执行到free就会漏。另一个是receive_response中realloc失败后只free(buf)但调用者拿不到 NULL会在process_url里再free造成双重释放所以最好让receive_response返回 NULL 时同时设置total_len0。5.4 主流程串联一个可演示的main把所有模块串起来是这样的int main(int argc, char *argv[]) { if (argc 2) { fprintf(stderr, usage: %s start_url\n, argv[0]); return 1; } queue_init(queue); add_visited(argv[1]); Task start_task; snprintf(start_task.url, sizeof(start_task.url), %s, argv[1]); queue_push(queue, start_task); pthread_t threads[WORKER_NUM]; for (int i 0; i WORKER_NUM; i) { pthread_create(threads[i], NULL, worker_thread, queue); } // 等待这里需要活跃任务计数 for (int i 0; i WORKER_NUM; i) { pthread_join(threads[i], NULL); } return 0; }注意实际运行这个main可能无法退出因为线程阻塞在queue_pop等任务。你需要在任务队列为空且无活跃任务时注入毒丸或者简单地在抓取到一定数量后设置全局stop_flag并让queue_pop检查它。课程设计为了演示可以抓取 50 个页面后强制停止并打印统计信息这也是一种成品。最后给你的演示加一个计数功能每抓完一个页面打印[OK] url (深度n, 已抓取N)这样老师一眼就能看到程序在跑也方便验证去重效果。抓取结果可以写入文件用 C 语言的fopen/fwrite把页面内容保存下来这是课程设计报告里最直观的“产出物”展示。本文还有配套的精品资源点击获取
返回列表