1. 动手之前,先把“抓什么”这件事想清楚
经常有朋友私信问我:能不能写一份Libvio.link的爬虫技术解析大纲?而且开口的时候往往会补一句“这个站结构挺清晰,用常规爬虫应该能直接跑通”。怎么说呢,我特别理解大家想找一个实战靶场的心理,但这里有一个比技术栈更靠前的问题:数据源有没有授权你去抓。任何网站的数据采集,技术只占一半,另一半是边界判断。如果连目标站点的服务协议、robots规则、版权归属都还没搞清楚,那再漂亮的技术拆解也只是在帮自己挖坑。
我自己的处理原则是:具体到某个站点的“解析抓取方案”,尤其是授权状态不明、也没有公开API的数据源,尽量不碰、也不教人碰。原因不是技术不行,而是不值。真遇上纠纷,抓取代码好不好看完全不重要,重要的是你以什么身份、以什么目的、抓了谁的数据、拿数据做了什么。与其去赌一个不确定的边界,不如把精力放在一套能迁移到任何合规项目上的爬虫设计方法论上。这才是一份爬虫技术解析大纲真正值钱的地方。
所以这篇文章不会给你一个“针对某某站点的现成方案”,我会用一套在本地起服务就能完整跑通的模拟数据源,带你走一遍爬虫从架构设计、代码编写、翻页处理、数据清洗到落库存储的完整流程。这套东西学完,你面对任意一个正规数据源(比如自己公司的后台接口、有开放协议的公共数据集、开发环境的mock服务),都能快速按同样的骨架搭出采集程序。
1.1 授权边界判断:一份很实用的自查清单
我在给团队做爬虫培训的时候,第一课永远不是Requests还是Scrapy,而是一张授权判断表。
| 数据源类型 | 能不能抓 | 建议做法 |
|---|---|---|
| 官方公开API/开放数据集 | 能,按文档频率使用 | 申请token,遵守限流策略 |
| 站点有robots.txt明确允许 | 能,但注意只抓允许路径 | 先读robots,再定义抓取范围 |
| 有协议但授权范围不清 | 谨慎 | 邮件/客服确认,留好记录 |
| 服务条款明确禁止第三方采集 | 不建议抓 | 换数据源,或者走商务合作 |
| 要求登录后才能看的数据 | 别绕 | 绕身份验证本身就是明确的违规信号 |
| 涉及个人隐私或版权素材 | 基本别碰 | 风险极高,完全没有必要 |
有人可能觉得这套东西太保守。但我可以讲一个真实教训:我有次接了个爬取行业信息的需求,对方说“这网站数据是公开的、谁也拦不住爬虫”,我顺手查了下站点的Robots协议和版权声明,条款里其实写得很明白“禁止未经授权的批量数据获取”。后来这个需求我没接。不是我清高,而是如果对方产品上线后因为数据源问题吃官司,我作为提供采集方案的人,是有连带风险的。
1.2 合规自检里的四个关键动作
判断一个数据源能不能爬,我建议你养成一套低成本的习惯动作。这套动作做下来不到十分钟,但能挡掉大多数风险:
第一,打开目标站点的域名加/robots.txt,看它明确允许和禁止的路径。注意,有些站点的robots非常开放,比如很多文档站允许所有爬虫抓取;也有的站点只允许搜索引擎收录,不允许商业采集。第二,看站点的服务条款或页面底部的版权声明。第三,如果有开放API,优先走API而不是页面解析,API说明里通常会写清楚限流策略和数据使用范围。第四,留意数据内容的版权属性,比如别人的原创博客文章、付费课程介绍、影视资源信息,哪怕你能打开页面看,也不代表你有权批量复制去二次分发。
其实还有一个特别实用的标准:为什么这个站没有封锁爬虫?有可能是技术防不住,也有可能是人家还没顾上,但绝对不代表这是默许。我见过太多技术不错的同行,栽就栽在“我只是学习一下”这句话上。技术研究没问题,但研究对象应该是授权允许、明确开放的资源,而不是去测试别人站点的防护底线。
2. 一套通用爬虫大纲:先画架构,再写代码
如果你已经确定某个数据源是合规的,那接下来就可以按照一套固定的骨架来设计爬虫。很多新手一上来就写requests.get然后一顿正则硬抠,这属于“手工作坊式采集”,做一两次没问题,但一旦页面结构变化、数据量上来、需要定时更新,马上就会维护不动。正确做法是先把架构画清楚,再填每一层的技术细节。
2.1 爬虫的五个核心环节
一份能落地的爬虫方案,不管目标是网页还是接口,到最后都是这五件事:
- 请求调度:负责向目标地址发起HTTP请求,控制频率、重试、请求头、超时时间。
- 页面解析:拿到HTML或JSON之后,从中抽取你需要的字段。
- 数据清洗:处理缺失值、去重、格式统一、编码问题。
- 存储层:把处理好的数据写入CSV、数据库或者后续的消息队列。
- 监控运维:记录日志、失败重试、增量更新、出问题时能报警。
大多数“爬虫解析大纲”的帖子只讲第二块,也就是解析。但实际上最容易翻车的往往是第一块和第四块。请求层没做好,对方服务器稍微有点压力,你就发现自己被限制访问了;存储层没想好字段结构,数据存进去之后才发现没法做后续统计和更新。所以我做项目的时候,习惯先把目标字段表设计出来,再回头去写解析器。
2.2 技术选型:Requests + BeautifulSoup还是Scrapy?
这是新手问得最多的一个问题。我的建议很简单:如果目标只有几十上百个页面、字段十来个、跑一次就结束,那就用requests加BeautifulSoup,轻量、直观、调试成本低。如果目标有几千上万个页面、需要断点续爬、分布式调度、请求去重、自动重试,那就直接用Scrapy。框架带来的收益会远远超过学习成本。
这里插一句技术原理。requests做的是最底层的HTTP请求,你把请求发出去,服务器把一个HTML文档还给你。BeautifulSoup的作用是拿这个HTML建一棵可以搜索的解析树,比如find_all就是在这棵树里按标签去匹配。Scrapy做的事情更多,它把调度、下载、解析、管道、中间件整套流程都抽象好了,你只需要实现解析回调函数。
2.3 本地演示站点的设计思路
我还想强调一件事:练爬虫最好是练本地服务,而不是逮着一个真实网站就上。真实网站的页面结构不稳定,爬虫代码写出来过两周可能就失效了;而且你在别人线上服务上密集发请求,本质上是对对方服务器资源的占用。比较好的模式是:自己用静态文件搭一个“演示站点”,模拟商品列表、分页、表格这些常见结构。这样无论你什么时候想复习,代码永远能跑通。
下面这个演示站点会包含两个页面,每个页面上有一个商品表格,表格里的字段包含商品ID、名称、价格,页面底部有翻页链接。麻雀虽小五脏俱全,一次正常的HTML表格类爬虫流程里能遇到的核心要素,它都有了。
3. 实操全过程:用本地数据站完整跑通爬虫
接下来进入正题。我会分三步走:第一步先把本地演示数据源搭起来,第二步写爬虫核心脚本,第三步运行并检查结果。整个过程不涉及任何第三方网站,所以你可以放心地把它当作一个长期有效的练手项目。
3.1 第一步:搭建一个可复现的本地测试环境
我建议先建一个项目目录,比如叫spider-demo,然后在里面建一个子目录site用来放模拟站点的页面文件。
先创建site/index.html:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <title>示例开放数据站 - 第1页</title> </head> <body> <h1>示例开放数据站(第1页)</h1> <table id="goods"> <tr><!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <title>示例开放数据站 - 第2页</title> </head> <body> <h1>示例开放数据站(第2页)</h1> <table id="goods"> <tr>python -m http.server 8000 --directory site命令行会输出一行类似“Serving HTTP on 127.0.0.1 port 8000”的提示。打开浏览器访问http://127.0.0.1:8000,如果你能看到那个商品表格,说明模拟站点已经正常启动。
为什么我要用本地静态文件而不是某个爬虫练习网站?原因很简单:可控、零依赖、离线也能跑。真实站点可能在半夜改版、可能因为地区限制返回不同内容、可能因为流量保护把你拒之门外,这些变量对学习技术框架都是干扰项。本地服务把干扰降到最低,你可以把全部注意力放在爬虫本身的逻辑上。
3.2 第二步:爬虫主脚本的编写与讲解
在项目根目录创建spider.py,代码如下:
import csv import time import random from urllib.parse import urljoin import requests from bs4 import BeautifulSoup BASE_URL = "http://127.0.0.1:8000/index.html" HEADERS = { "User-Agent": "DemoCrawler/1.0 (用于本地技术学习验证)" } TIMEOUT = 10 DELAY_RANGE = (0.5, 1.2) def fetch_page(page_url): """发送HTTP请求并返回BeautifulSoup对象""" resp = requests.get(page_url, headers=HEADERS, timeout=TIMEOUT) resp.raise_for_status() resp.encoding = resp.apparent_encoding return BeautifulSoup(resp.text, "html.parser") def parse_goods(soup): """从表格中抽取商品字段""" items = [] rows = soup.select("#goods tr[data-id]") for row in rows: id_value = row.get("data-id") name_node = row.select_one(".name") price_node = row.select_one(".price") if id_value and name_node and price_node: items.append({ "id": id_value.strip(), "name": name_node.get_text(strip=True), "price": price_node.get_text(strip=True), }) return items def collect_page_urls(soup): """提取分页链接,用于继续抓取""" page_urls = [] for a in soup.select(".pagination a"): href = a.get("href") if href and not a.get("class"): page_urls.append(urljoin(BASE_URL, href)) return page_urls def main(): all_goods = [] visited = set() pending = [BASE_URL] while pending: page_url = pending.pop() if page_url in visited: continue visited.add(page_url) print(f"正在抓取: {page_url}") soup = fetch_page(page_url) goods = parse_goods(soup) all_goods.extend(goods) print(f"本次解析到 {len(goods)} 条记录,共累计 {len(all_goods)} 条") for link in collect_page_urls(soup): if link not in visited: pending.append(link) # 限速:模拟正常用户访问节奏 delay = random.uniform(*DELAY_RANGE) time.sleep(delay) # 写入CSV with open("goods_output.csv", "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=["id", "name", "price"]) writer.writeheader() writer.writerows(all_goods) print(f"全部完成,共保存 {len(all_goods)} 条数据到 goods_output.csv") if __name__ == "__main__": main()这段代码有几个地方需要重点解释。
先说resp.encoding = resp.apparent_encoding。这是一个在中文网页里极其常用的小技巧。服务端返回的HTTP头部里如果没有明确字符集,默认编码经常会被识别成ISO-8859-1,中文很容易变成乱码。apparent_encoding是requests根据页面内容自动推断出的编码,通常对中文网页比较可靠。我在演示站点里明明加了<meta charset="utf-8">,却仍要写这一行,是为了适配真实项目中各种不规范页面的情况。
再说选择器和数据清洗。单元格里的价格我直接取了get_text(strip=True)。真实项目里价格字段可能长这样“¥ 99.00/月”,这时候需要在清洗环节把货币符号、空格、单位处理掉。为了不把代码搞得很长,我把清洗步骤省略了,但你在实际使用中一定要加。
最后是翻页去重。我用了一个visited集合来记住哪些URL已经处理过,防止两个页面之间互相引用导致死循环。这个方法在生产环境里其实不够用,更完整的是记录请求指纹或使用Scrapy自带的去重过滤器,但单机的几十个页面场景下这个思路完全够用。
3.3 第三步:运行结果与字段校验
运行爬虫:
python spider.py预期输出大致是:
正在抓取: http://127.0.0.1:8000/index.html 本次解析到 3 条记录,共累计 3 条 正在抓取: http://127.0.0.1:8000/page2.html 本次解析到 3 条记录,共累计 6 条 全部完成,共保存 6 条数据到 goods_output.csv打开goods_output.csv,如果用的是Windows,我建议你用记事本或Excel打开;如果用了UTF-8-sig编码,Excel里就不会出现中文乱码。这又是一个实战中很容易踩的坑:很多程序写CSV时习惯用utf-8编码,但Excel默认用ANSI打开,中文字段出来全是“锟斤拷”。我后来一律写utf-8-sig,带不带BOM在绝大多数场景下都不是问题,但对Excel用户非常友好。
你可能会问:这样一个爬虫是不是太简单了?对,它确实只演示了HTML表格解析。真实项目里还有JSON接口解析、登录态携带、分页参数通过URL的?page=2控制、页面内容由JavaScript动态渲染等等。但这些变化本质上都属于“从页面/接口里拿数据”这一个抽象动作的具体实现。你只要把刚才五个环节的骨架理解透了,遇到新需求就是在对应环节里换实现方式而已。
4. 真实项目里最常见的坑:状态码、编码与频率控制
写爬虫几乎不可能一次跑通不碰到问题。我自己带人做项目时最常见的经验是:代码本身报错反而好办,麻烦的是那种“不报错但结果不对”的情况。比如页面返回200但里面没有数据,比如解析结果全是空的,比如数据抓到一半对方响应开始变慢。下面我把高频问题整理成一张速查表,顺便说一下我的排查思路。
4.1 HTTP状态码异常排查速查表
| 现象 | 可能原因 | 处理方向 |
|---|---|---|
| 403 Forbidden | 被服务器拒绝,可能缺UA或被识别为爬虫 | 检查请求头,降低请求频率;不要尝试伪造身份绕过限制 |
| 404 Not Found | 页面路径变了,或者URL拼接错误 | 打开浏览器手动访问一次,核对新路径 |
| 429 Too Many Requests | 请求频率过高,触发了限流 | 当前方案停止,等待恢复,后续主动加延时 |
| 500 / 503 | 服务器内部错误或过载 | 不是你代码的问题,退避等待后再重试 |
| 200 但内容为空 | 页面可能由JS渲染,或返回了反爬验证页 | 查看返回内容的前几百个字符,确认拿到的到底是什么 |
刚才代码里用到了resp.raise_for_status(),它的作用是:只要状态码是4xx或5xx,就主动抛异常,让程序停下来。这个设计很有必要,否则你以为请求成功,实际拿到的却是一张错误页面,后面的解析逻辑全在垃圾数据上跑。生产环境里我还会给请求加上重试机制,比如连续失败三次才放弃,并记录日志。
4.2 解析不到数据时,先别急着改代码
我见过好几个人在BeautifulSoup选择器上死磕几个小时,最后发现问题是它们请求回来的页面里根本没有那些节点。原因可能是登录态失效、页面弹窗遮住了正文、或者对方返回了移动端适配页面。所以排查顺序很重要:第一步打印响应前500个字符,肉眼看看到底返回了什么;第二步把返回内容存到本地HTML文件里,用浏览器打开;第三步确认目标字段确实存在,再回头调选择器。
另一个排坑点是懒加载。现在很多列表页,商品数据不在初始HTML里,而是页面滚动到底部时通过接口加载。你用requests直接请求页面的时候,那些懒加载内容是根本不会出现在响应里的。遇到这种情况,正确方向不是硬着头皮解析页面,而是打开浏览器开发者工具,切到Network面板,刷新页面看哪个XHR请求返回了商品数据。找到真正的数据接口之后就简单了,直接请求那个接口,通常返回的还是干净的JSON。比起解析HTML,解析JSON舒服太多了。
4.3 给请求频率装上“刹车”:限速与背压
爬虫界有句话叫“君子爬虫,取之有度”。本地演示代码里我加了random.uniform(0.5, 1.2)的随机延时,这个做法的学术名字叫“请求间隔抖动”。为什么要随机而不是固定时间?因为真实的用户操作间隔本来就带有随机性,固定间隔反而容易被服务器端的异常行为检测识别出来。更重要的原因,其实是我真的不想因为写了个脚本就把对方服务器打挂。我接过大大小小的需求,有时候对方数据量就几百条,开个多线程疯狂抓半小时完事,这既不负责任,也不是一个可持续的方案。
遇到大规模抓取需求,我会把延时策略拆成两块:单请求延时和全局并发控制。单请求延时决定两次请求的最小间隔,全局并发控制决定同一时刻最多有多少请求在飞行。哪怕用多线程,我也倾向于把并发数控制在个位数,同时加一个“响应时间滑动平均”的监控:如果最近几十个请求的响应时间明显比之前长,就自动把并发降下来。这个技巧是我跟一个写爬虫框架的老同事学的,他自己管这叫“给服务器留点喘息空间”。讲真,很多站点其实不是讨厌爬虫,而是讨厌那种毫无节制、瞬间轰趴服务的采集方式。
5. 爬虫项目的工程化意识:写完不等于结束
很多教程讲到“数据存到CSV”就结束了,但我实际做下来发现,爬虫项目真正麻烦的是长期维护。页面改版、登录规则变化、数据字段调整,哪个小改动都可能让爬虫悄悄失灵。所以我给自己的项目定了几条规矩,分享出来供你参考。
第一,在解析层和数据存储层之间加一层字段映射。什么意思呢?就是页面上的“商品名称”在代码里统一叫name,数据库列也叫name,不要字段名一会儿叫title一会儿叫product_name。这样后续做数据分析时不用一一对号。第二,给每条数据留一个“抓取时间”字段。第三,能增量更新就千万别全量重抓。也就是说,用一个页面列表页判断哪些是新数据,只对新出现的详情页去发起请求,不要每次都把所有详情页重爬一遍。增量更新的前提是数据里有唯一标识,比如页面里的详情URL或者商品ID,刚才演示代码里表格行的>