资讯动态

C语言写爬虫:Windows平台无运行时依赖的抓取与解析方案

发布时间:2026/10/13 15:29:06 来源:尧图企业网站定制
简介这套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 用经过归一化处理的 URLvalue 可以是任意标记。每次准备把新链接放进任务队列之前先查散列表命中就跳过。为什么不直接比较字符串因为http://example.com/和http://example.com在语义上是同一个页面字符串不等但归一之后是同一个 key才能保证只抓一次。这个点正是很多半成品爬虫疯狂重复抓取同一批页面的原因。3. 先从编译好的 exe 跑起来Windows 下的命令行用法3.1 参数速览与第一个抓取命令拿到的编译版 exe不需要任何额外安装依赖直接放到一个干净的目录里就能跑。先执行一次带--help的命令确认当前版本的参数命名不同分支的编译版参数可能有小差异但下面这张表覆盖的是这类命令行爬虫最常见的设计参数默认值作用--url必填入口页面地址--outpage.html保存 HTML 的文件名--depth1递归抓取深度1 表示只抓入口页--timeout10单次请求超时秒数--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手动带 Referer403 通常是目标服务器做了访问控制多半是 UA 太原始换成浏览器 UA 再试。3.3 输出文件的基本检查抓下来的是不是你要的东西程序提示「saved」不代表任务完成我一般会立刻检查输出文件的头尾两部分。用记事本打开 HTML 文件开头应该是!DOCTYPE html或者类似标签结尾能看到/html说明响应被完整接收。文件只有几百字节但看似「正常」的情况也要警惕那可能是服务器的错误页状态码 200 不代表页面内容正确——服务器对爬虫返回一个伪装成 200 的反爬页面是常见的事。文件里中文字符显示成乱码的问题也经常遇到。页面本身可能是 UTF-8也可能声明了charsetgb2312却实际用了 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; charsetgb2312但实际内容是 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 外面套了一层 TLSC 语言的原生 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 是什么抓取范围到哪一层停抓完的数据落到什么格式。想清楚这三件事再动手调线程和解析代码踩坑概率至少少一半。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑