简介:这套C语言网页爬虫项目附带Windows编译成品,面向想用C语言动手实现网络爬虫的开发者或网络编程初学者。压缩包共5个文件,两个可直接运行的exe程序、一个C语言源码文件、一个HTTPS证书文件,以及一个Makefile构建脚本,整体仅1.08MB;既省去自行编译的麻烦,又保留了完整的代码与构建过程,方便直接运行或二次修改。已有2208人学习下载。源码部分重点演示了curl库的实际调用:从解析URL、构造并发送HTTP GET请求,到读取响应状态、接收HTML正文,再遍历页面中的超链接提取所需信息;HTTPS证书文件解释了访问加密网站时的服务器身份验证机制,而Makefile则展示了用MinGW或MSYS完成自动化编译的流程。对想掌握C语言网络编程、理解爬虫工作原理,或对比命令行HTTP工具用法的读者来说,这是一份小巧完整的实践资料,可作为课程设计或网络编程入门的参考。
1. C语言网页爬虫:不装 Python 也能跑的 Windows 抓取方案
一位朋友卡在一个很实际的问题上:一台不能随意装运行时的机器,需要定时抓取内部信息页存成快照,环境里没有 Python、没有 Node、没有包管理器。我给他的答案是用 C 写爬虫——这份资源做的就是这件事:socket 连接、DNS 解析、HTTP 请求、结果保存,全部收进一个源码工程,并且附带编译好的 Windows 可执行文件,拿到就能用,想改也有完整代码可以动。它适合运维人员、做数据采集的工程师,以及所有在受限环境里需要稳定抓取方案的人。听起来是原始手段,实质是唯一不需要任何运行时依赖的抓取路径。
2. 先把原理讲透:socket、DNS 与 HTTP 解析的三层拆解
2.1 爬虫的第一件事:建立 socket 连接而不是「读网页」
很多第一次接触 C 语言爬虫的人会翻车,是因为脑子里还带着脚本语言的「请求库」思维,以为调一个函数就拿到网页。C 语言里没有这种封装,所有网络操作都落在操作系统提供的 socket 接口上。socket 本质上是一个文件描述符,你用socket()创建它,用connect()把它接到目标主机的某个端口,然后send()写请求、recv()读响应,整个过程和读写文件很像,但多了建立连接和解析协议这两步。
这份资源里的核心逻辑,第一步就是创建 socket。常见实现是下面这个骨架:
#include <winsock2.h> #include <ws2tcpip.h> WSADATA wsa; WSAStartup(MAKEWORD(2, 2), &wsa); // Windows 下必须先初始化 Winsock SOCKET sock = socket(AF_INET, SOCK_STREAM, 0); // AF_INET 表示 IPv4 地址族 // SOCK_STREAM 表示 TCP 字节流 // 0 表示由系统按协议族默认选择传输协议这段代码的逻辑是:先调用WSAStartup加载 Winsock 库,这是 Windows 网络编程特有的前置步骤,Linux 下不需要。然后是socket()的三参数:第一个参数指定地址族,目前绝大多数场景都是 IPv4;第二个参数决定连接方式是面向字节流的 TCP 还是面向报文的 UDP,爬虫必须用 TCP,因为 HTTP 建立在 TCP 之上;第三个参数直接传 0,让系统根据前两个参数自动挑协议。
建立 socket 之后,还需要把目标主机名解析成 IP,然后才能调用connect()。这一步卡住了很多人:给的参数看起来没问题,但连接总是失败,十有八九是主机名根本没解析成功,或者端口写错。下一节专门拆这一步。
2.2 DNS 解析与 HTTP 报文:请求怎么变成字节流
C 语言爬虫里,connect()不能直接接受域名,它只认 IP。所以要把www.example.com这种主机名先翻译成93.184.216.34这类地址,这个过程就是 DNS 解析。老派写法是用gethostbyname(),新代码推荐用getaddrinfo(),因为它同时支持 IPv4 和 IPv6,而且返回的结构体可以直接喂给connect(),省掉手动填端口和地址族的麻烦。
拿到 IP 并建立 TCP 连接后,爬虫要做的就是把 HTTP 请求以纯文本形式发过去。注意 C 语言里没有现成的 HTTP 库,一切都要手动拼字符串。一个最小可用的 GET 请求是这么组装的:
char req[512]; int len = snprintf(req, sizeof(req), "GET %s HTTP/1.1\r\n" "Host: %s\r\n" "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)\r\n" "Connection: close\r\n" "\r\n", path, host); send(sock, req, len, 0); // len 是实际请求长度,不能用 sizeof(req)这里有两个关键点。第一,HTTP/1.1 协议强制要求带Host头,缺了它,很多服务器会直接返回 400 错误。第二,Connection: close告诉服务器响应发完就断开连接,这样客户端不需要处理 Keep-Alive 的复杂状态,收数据循环只要看到recv()返回 0,就知道内容读完了。这是新手最容易忽略的一个细节:不加这个头,程序会在收数据阶段卡死或漏包。
响应端的解析也同样是纯手工活。服务器返回的数据流包含响应头和正文,两者用\r\n\r\n分隔。常见做法是先在接收缓冲区里找到这个分隔符,把头部截出来,再从头部里提取Content-Length字段,确定正文该读多少字节:
char buf[8192]; int n = recv(sock, buf, sizeof(buf) - 1, 0); if (n <= 0) { // 连接被关闭或出错,收尾清理 closesocket(sock); return NULL; } buf[n] = '\0'; char *p = strstr(buf, "\r\n\r\n"); if (p) { *p = '\0'; // 把头部和正文切开 char *body_start = p + 4; // 正文从分隔符之后开始 char *cl = strstr(buf, "Content-Length: "); long content_len = cl ? atol(cl + 16) : -1; // Content-Length 缺失时,要么是 chunked 编码,要么是 Connection: close }这段代码的逻辑是把接收缓冲区当成一整块字符串处理:先定位\r\n\r\n,它前面是响应头,后面是正文。Content-Length的值决定正文长度,如果这个字段不存在,就要考虑服务器用了 chunked 传输编码,正文会被切成若干块,每块前有一行十六进制长度,这块内容的具体坑我放到第 5 章单独讲。
2.3 HTML 到手之后:提取链接与去重的基本盘
拿到 HTML 响应体,爬虫的核心工作才刚开始。你要从这堆字符串里提取出下一批待抓取的 URL,最简单粗暴的方式是字符串匹配:找<a href=",把引号里的内容抠出来,然后跟当前页面 URL 做拼接,得到绝对地址。这个动作看似简单,实际里面全是细节:相对路径要拼 base、//开头的要补协议、#锚点必须去掉、大小写要不要归一。这些问题不处理,抓全站时会出现大量重复请求。
链接去重是 C 爬虫里最值得花时间的部分。常见做法是维护一个散列表,key 用经过归一化处理的 URL,value 可以是任意标记。每次准备把新链接放进任务队列之前,先查散列表,命中就跳过。为什么不直接比较字符串?因为http://example.com/和http://example.com在语义上是同一个页面,字符串不等,但归一之后是同一个 key,才能保证只抓一次。这个点正是很多半成品爬虫疯狂重复抓取同一批页面的原因。
3. 先从编译好的 exe 跑起来:Windows 下的命令行用法
3.1 参数速览与第一个抓取命令
拿到的编译版 exe,不需要任何额外安装依赖,直接放到一个干净的目录里就能跑。先执行一次带--help的命令,确认当前版本的参数命名,不同分支的编译版参数可能有小差异,但下面这张表覆盖的是这类命令行爬虫最常见的设计:
| 参数 | 默认值 | 作用 |
|---|---|---|
--url | 必填 | 入口页面地址 |
--out | page.html | 保存 HTML 的文件名 |
--depth | 1 | 递归抓取深度,1 表示只抓入口页 |
--timeout | 10 | 单次请求超时秒数 |
--ua | 内置 Mozilla 串 | 自定义 User-Agent |
第一次抓取建议用最保守的参数,先验证网络通不通、程序能不能正确解析。我的习惯是抓一个结构简单、响应快的站点,把超时时间设得宽松一些,排除干扰因素。命令行长这样:
crawler.exe --url http://example.com --out first_page.html --timeout 30命令的逻辑是:程序启动后,先对example.com做 DNS 解析,建立 TCP 连接,发送上面第 2 章讲的那段 HTTP 请求,收到响应后把 HTML 正文写入first_page.html,最后打印一条结束日志。--timeout 30的意思是单次请求最多等 30 秒,超过就放弃并报错,避免内网服务器无响应时程序无限期挂起。
3.2 日志输出怎么看:成功与失败的信号
第一次运行最该看的是终端里的日志,它把第 2 章讲的每个内部步骤都暴露出来了。一份正常的抓取日志长这样:
[info] resolving host: example.com [info] resolved to: 93.184.216.34 [info] connecting to 93.184.216.34:80 [info] connected, sending request [info] recv 3987 bytes, http status 200 [info] saved to first_page.html逐行解读:第一行说明程序在 DNS 解析阶段;第二行的 IP 要跟真实解析结果对上,如果这里卡了很久或者报错,基本可以断定是网络配置问题;第三、四行说明 TCP 连接建立成功;第五行最重要,recv 3987 bytes是实际收到的字节数,http status 200说明服务器认可了这次请求;最后一行确认文件落地。
如果你看到的日志没有http status 200,而是 301、302、403、503,先别急着怀疑程序坏了。301/302 是重定向,说明入口 URL 需要跳转,很多爬虫默认不跟随跳转,得手动加一个--follow-redirect之类的开关或用--header手动带 Referer;403 通常是目标服务器做了访问控制,多半是 UA 太原始,换成浏览器 UA 再试。
3.3 输出文件的基本检查:抓下来的是不是你要的东西
程序提示「saved」不代表任务完成,我一般会立刻检查输出文件的头尾两部分。用记事本打开 HTML 文件,开头应该是<!DOCTYPE html>或者类似标签,结尾能看到</html>,说明响应被完整接收。文件只有几百字节但看似「正常」的情况也要警惕,那可能是服务器的错误页,状态码 200 不代表页面内容正确——服务器对爬虫返回一个伪装成 200 的反爬页面,是常见的事。
文件里中文字符显示成乱码的问题也经常遇到。页面本身可能是 UTF-8,也可能声明了charset=gb2312却实际用了 GBK 编码,C 程序如果按字节流原样保存,浏览器能靠 HTML 里的meta声明自动识别,但记事本打开就是乱。这种情况不用改爬虫代码,先确认响应头里的Content-Type字段,再看 HTML 源码里的charset声明,两个信息对上了,乱码问题就解决了一半。
4. 从「抓一页」到「抓全站」:把源码改成多线程采集器
4.1 多线程改造的边界:什么时候改,什么时候别改
单线程爬虫的逻辑非常简单:拿一个 URL,请求,解析,把新链接塞进队列,再取下一个。这个模型在小规模抓取下完全够用,比如每天抓几百个页面,单线程反而友好。但当你需要抓一个几千页的站点,单线程的速度就是灾难——大部分时间都浪费在等待网络响应上,CPU 完全闲着。
多线程是自然的选择,但有两个边界问题必须先想清楚。第一,线程数和目标服务器之间是零和博弈,起几十个线程并发请求同一站点,很快会把对方服务器压出 503,反过来触发反爬机制。我一般控制在线程数不超过 8 到 10,并加上请求间隔。第二,C 语言的多线程没有「脚本语言里随手开协程」那么廉价,每多一个线程就多一份资源竞争,去重表和任务队列要加锁,这里的设计直接决定程序会不会在跑到一半时崩溃。
Windows 平台下做 C 多线程,常见做法是直接用 CreateThread 创建线程,配合条件变量实现任务分发;也可以用 winpthreads 库把 pthread 接口搬过来。如果你后续有跨平台需求,更推荐后者,因为代码在 Linux 下也能编译。
4.2 链接去重:URL 归一化是重灾区
去重是线程并发下最容易出错的环节。多个线程同时发现同一个新链接,如果只有一份全局去重表,那就得保证「先检查再插入」这个动作是原子的。常见做法是给散列表的查插操作加一把互斥锁,锁的范围越小,线程竞争越轻。
URL 归一化是去重的前置步骤,很多半成品爬虫在这里翻车。http://example.com和http://example.com/是两个字符串,但语义是同一页;http://EXAMPLE.com/index.html和http://example.com/index.html也是同一个页面。不做归一化,去重表就形同虚设。C 语言处理字符串没有高级语言方便,但几个经典规则还是能落地的:
void normalize_url(char *url) { // 1. 去掉 # 锚点,锚点不影响实际请求结果 char *p = strchr(url, '#'); if (p) *p = '\0'; // 2. 只对协议和主机名做小写归一化 char *host = strstr(url, "://"); if (host) { host += 3; while (*host && *host != '/' && *host != ':') { if (*host >= 'A' && *host <= 'Z') *host += ('a' - 'A'); // 大写转小写 host++; } } // 3. 去掉默认端口 :80 和 :443 // 实际代码里用字符串定位 + 删除片段实现 }这段代码的逻辑是:先去锚点,因为#后面的片段只在浏览器里做页内跳转用,服务器不需要;再把主机名统一转小写,避免Example.com和example.com被当成两个 key;端口处理因为要涉及字符串删除,这里没有完整展开,但思路是:80在 HTTP 下、:443在 HTTPS 下可以安全移除,因为请求时系统会按协议补默认端口。
归一化完成后,去重表的 key 就稳定了。实践里我还会把 URL 的长度上限卡死,比如超过 2048 字节的 URL 直接丢弃,因为绝大多数服务器根本不接受这么长的请求行,还容易成为日志伪造的载体。
4.3 任务队列与条件变量:多线程采集器的骨架
任务队列的经典实现是「互斥锁 + 条件变量」。生产者线程从页面里解析出链接,往队列里放;消费者线程从队列里取 URL 去抓。队列为空时消费者不能空转,要阻塞等待条件变量,让出 CPU。C 语言 pthread 接口的标准写法如下:
#include <pthread.h> pthread_mutex_t q_lock = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t q_cond = PTHREAD_COND_INITIALIZER; // 消费者线程:等待任务 while (1) { pthread_mutex_lock(&q_lock); while (queue_empty(&task_queue) && !g_stop) { pthread_cond_wait(&q_cond, &q_lock); // 队列空,挂起等待 } if (g_stop) { pthread_mutex_unlock(&q_lock); break; } Job job = queue_pop(&task_queue); pthread_mutex_unlock(&q_lock); crawl_page(job.url, job.depth); // 锁外执行网络请求 }这段逻辑有两个要点。第一,pthread_cond_wait必须在锁内调用,它会把锁释放并挂起线程,被唤醒时重新拿到锁,这是经典的「等待-释放-重获」过程。第二,外层while判断队列是否为空,而不是用if,因为条件变量可能被虚假唤醒——被唤醒来发现队列还是空的,就再等一轮。这两个细节写错,程序要么死锁,要么空转吃满 CPU。
加锁的范围也值得控制:crawl_page这类网络操作绝对不能放在锁里面执行。一次请求可能耗时几百毫秒到几秒,期间拿着锁不放,所有消费者线程都会阻塞,多线程就退化成单线程了。所以上面代码里,任务是从锁里取的,但网络请求在锁外做。
5. 避坑记录:编译与运行遇到的六个典型问题
5.1 编译报「无法解析的外部符号 __imp_WSAStartup」
现象:在 Windows 上用命令行编译,链接阶段报错,提示找不到WSAStartup、socket、send这些符号。
原因:Winsock 的函数不在默认链接库里,编译器不知道去哪找,需要显式链接ws2_32库。
解决:编译命令末尾加上-lws2_32。如果你用的是 Visual Studio 的项目,在「附加依赖项」里手动添加ws2_32.lib。代码里的WSAStartup调用也不能省,少了它,程序运行到socket()会直接返回错误。
5.2 抓下来的 HTML 文件里中文全是乱码
现象:保存的 HTML 在浏览器里打开正常,但用文本编辑器看是乱码;或者编辑器里正常,浏览器里乱。
原因:网站返回的响应头里有Content-Type: text/html; charset=gb2312,但实际内容是 UTF-8,反之亦然。更隐蔽的是服务器开启了 gzip 压缩,程序收到的是压缩字节流,不经解压直接保存的 HTML 完全不可读,文件头是\x1f\x8b。
解决:先检查文件的前两个字节,如果是\x1f\x8b,说明是 gzip 压缩流,程序需要引入 zlib 库,在保存前用inflate解压。编码问题用 Windows API 的MultiByteToWideChar把 GBK 转成 UTF-8 再落盘,或者在命令行加一个--encoding utf8之类的强制转换开关。
5.3 抓 HTTPS 站时报证书错误或者直接退出
现象:抓http://正常,换https://就报内存访问错误或证书验证失败。
原因:HTTPS 在 HTTP 外面套了一层 TLS,C 语言的原生 socket 不会帮你做加密握手,必须借助 OpenSSL、Windows 自带的 Schannel 或者移植成熟的 HTTP 库。程序没做 TLS 初始化就发了请求,服务器回一个加密数据包,程序把密文当明文解析,自然出错。
解决:确认源码里有没有 TLS 初始化逻辑。如果用的是 OpenSSL,需要查SSL_CTX的证书配置。一个更省事的做法是看工程里是否带了编译好的 TLS 依赖,把 HTTP 入口换成 HTTPS 入口重新编译一次。
5.4 每次请求都卡到超时,但浏览器访问同一地址秒开
现象:程序日志显示connecting之后长时间没动静,直到触发--timeout被强制结束;同地址用浏览器打开却很快。
原因:服务器对请求的User-Agent做了过滤,程序里内置的 UA 串太原始,被服务器当成爬虫挂起,甚至直接丢进黑洞。另一种可能是程序解析出了错误的 IP,连到了一个不通的地址。
解决:先用--ua参数传一个完整的浏览器 UA,看是否改善。另外检查 DNS 解析日志里拿到的 IP,跟本机nslookup的结果对比,不一致的话要考虑 hosts 文件被污染的问题。这类问题的一大特征是不像代码 bug,排查起来比较玄学,但十次里有八次出在 UA 上。
5.5 状态码是 200,程序却提示「没有收到正文」
现象:日志显示recv返回了正常的 HTTP 响应头,http status 200也打印出来了,但保存的文件是空的,或者解析器报「找不到正文结束位置」。
原因:服务器启用了 chunked 传输编码。响应头里没有Content-Length字段,正文被切成多个 chunk,每个 chunk 先发送一行十六进制长度,再发送该长度的数据块,最后以长度为 0 的 chunk 结束。程序拿到第一个 chunk 就当正文到头,自然截断。
解决:解析时检查响应头里有没有Transfer-Encoding: chunked。有的话,进入 chunk 读取循环:读一行,用strtol把十六进制长度转成整数,再读这个长度的字节,直到读到长度为 0 的结束块。这一段逻辑大约三四十行代码,是 C 爬虫里最容易漏掉的标准流程。
5.6 递归深度控制失灵,程序疯狂抓取停不下来
现象:设了--depth 2,程序还是把全站的链接都抓了一遍,毫不收敛。
原因:深度计数只在爬虫主逻辑里增加,没有随任务传递。B 页面是从 A 页面发现的,但 B 的任务记录里忘了标记它的层数,导致所有页面都被当成深度 1 处理。
解决:在任务结构体里加一个depth字段,队列弹出时检查该字段是否达到上限,达到就只解析不抓取。每个由 B 页发现的新链接,入队时的depth = 当前任务的 depth + 1。递归深度不是全局变量,而是跟着任务走的,这个设计原则适用于所有变体。
6. 顺着需求改:抓取策略的三点沉淀
6.1 跑之前先看 robots.txt,定义好「全站」的边界
很多爬虫翻车不是因为写错代码,而是因为把整个站当成了可以随便抓的对象。我的习惯是拿入口域名加/robots.txt先看一眼,尊重其中的Disallow规则,至少把自己的巡检路径限定在允许范围内。这不仅是合规性问题,更实际的价值是让目标服务器的日志里少一些告警,降低被限流的概率。
6.2 请求间隔与 UA 策略:让爬虫看起来像正常访客
线程数设得再克制,如果每个请求之间没有间隔,仍然会在短时间内产生密集访问。常见做法是在每个线程完成一次请求后强制休眠 500 到 1500 毫秒,随机量比固定值更不容易触发频率检测。UA 也建议跟随主流浏览器的公共版本定期更新,把--ua里的内核版本号往当前主流版本靠,这些细节在日志里看不出差异,但在对方服务端的行为画像上有明显区别。
6.3 从「抓取」到「巡检」:把爬虫接进日常脚本
编译好的 exe 不只是抓一次页面就完事,它能被系统任务计划程序定时调用,也可以被别的脚本以子进程方式拉起并检查退出码。我的做法是让爬虫对成功和失败返回不同的退出码,外层批处理脚本根据退出码决定是否发送告警。这种组合方式不需要改一行 C 代码,却能把一个抓取工具升级成网站可用性巡检工具。
从那以后,我每次写采集任务都强制自己先回答三个问题:入口 URL 是什么,抓取范围到哪一层停,抓完的数据落到什么格式。想清楚这三件事再动手调线程和解析代码,踩坑概率至少少一半。希望帮到你。
本文还有配套的精品资源,点击获取