很多人学网络爬虫时第一个练手项目都会选豆瓣电影排行榜的数据抓取。理由很简单页面结构清晰、数据规律、难度适中。但如果你以为requests拿到HTML、正则抠出几条电影名就算学会了爬虫那离真正的高级数据抓取还差着十万八千里。我见过不少同学拿入门代码去跑排行榜抓取结果没跑几次就被限制访问、数据抓不全、字段对不上、跑一半进程卡死。所谓高级不是炫技而是把请求、解析、存储、异常处理、频率控制这条链路彻底打通。这篇文章是我多年做数据采集的实战笔记写给已经跑通过基础爬虫、想真正把豆瓣电影排行榜数据抓干净抓稳定的朋友也适合那些想从“会写脚本”跨到“能做工程化采集”的开发者。1. 从入门到进阶为什么简单的requests方案行不通1.1 入门代码的第一个坑请求头缺失大部分入门教程会给你一段这样的代码import requests url https://movie.douban.com/chart resp requests.get(url) print(resp.text)这段代码能不能跑通在早期是可以的现在大概率不行。服务端从请求里拿不到足够信息来判断你是真人还是脚本第一道防线的常见策略就是直接拒绝无完整头信息的客户端请求。你可能会得到403状态码也可能拿到一个验证页面而不是榜单数据。很多人这时候第一反应是“加个User-Agent”这思路对但只对了一半。真正的浏览器在发送请求时会携带一组完整的头信息包括Accept、Accept-Language、Connection等。单个User-Agent确实能解决一部分问题但一旦请求体量上来行为特征比对会成为更关键的限制因素。做高级抓取脑子里得有一张完整的请求头字段表。1.2 高级爬虫的完整技术栈进阶阶段的爬虫工程不再是一两个脚本文件而是一条完整的流水线。我习惯把它拆成四层层次职责常见技术选型请求层构造请求、管理会话、处理Cookie、控制频率requests Session解析层从HTML中提取结构化字段、清洗数据BeautifulSoup、lxml、正则调度层并发抓取、任务队列、失败重试ThreadPoolExecutor、asyncio存储层数据落地、去重、增量更新SQLite、CSV、Pandas四层之间是典型的流水线关系请求层把HTML拿回来解析层把它变成结构化数据调度层决定抓哪些页面、什么时候抓存储层把成果稳定落盘。每一层都有坑绝大多数“高级”爬虫项目之所以跑不稳都是因为某一层做得太单薄。2. 请求构造的细节伪装与反爬的博弈2.1 请求头全字段配置我先把最常用的一组请求头配置贴出来。这不是拍脑袋写的而是从浏览器开发者工具里直接拷贝的真实请求头结构再删掉敏感字段后整理出来的最小可用集合import requests import random import time HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Accept-Encoding: gzip, deflate, br, Connection: keep-alive, Upgrade-Insecure-Requests: 1, }这里重点说三个容易忽略的字段第一是Accept。如果服务端返回内容协商策略严格Accept字段缺失或写得不对可能拿不到最完整的HTML内容。你希望服务端把完整的、未压缩的HTML交给你得靠它表达。第二是Accept-Language。排行榜数据里有中文标题也有英文名这个字段会影响服务端返回内容的语言版本。如果你完全不带它某些场景下拿到的可能是繁体页面或者默认语言版本后续做字符串匹配时会莫名其妙地失败。第三是Connection: keep-alive。它告诉服务端这个TCP连接可以复用不要每次请求都重新握手。对于批量抓取场景这能显著减少握手次数带来的额外延迟和资源开销。还有个很实用的小细节User-Agent要定期更换。你可以准备一个UA池每次请求从池里随机挑一个。不同的UA配合不同的请求时段能让单机脚本的行为特征更接近多个独立访客的正常浏览节奏。方便起见我常用fake_useragent库来维护这个池子但要注意它偶尔会返回畸形字符串稳妥做法是自己维护一个五六个常用UA的列表。2.2 会话保持与Cookie处理爬虫里会话保持是个容易被忽视的点。requests.get()每次都是独立请求跟浏览器打开新标签页后第一次访问某网站类似——没有历史状态。但在真实浏览场景中你第一次打开首页之后后续访问榜单页、详情页时都会带着之前的上下文。用requests.Session()可以模拟这种连续性session requests.Session() session.headers.update(HEADERS) # 第一次访问首页让服务端建立会话状态 home_resp session.get(https://movie.douban.com, timeout10) # 带着会话状态去访问榜单页 chart_resp session.get(https://movie.douban.com/chart, timeout10)Session对象会自动保存服务端通过Set-Cookie下发的Cookie并在后续请求中带上它们。很多访问控制策略都依赖Cookie来判断“这是不是同一个访客持续浏览”不维护这份状态偶尔也能抓到数据但一旦频率稍高会很容易被判定为无状态的异常流量。还有个进阶操作手动从响应里提取关键Cookie字段。cookie_jar session.cookies.get_dict() print(cookie_jar)我遇到过某些情况页面正常打开了但关键数据被一段安全脚本保护着必须带某个动态生成的Cookie才能取到真实内容。这时候就要从首页响应里解析出动态Cookie把它合并进后续请求头再访问目标页。这类处理必须针对具体站点单独调试没有放之四海而皆准的通用解法。2.3 请求频率与随机延时策略写爬虫时大多数人心里想的是“快点、更快点”但真正让脚本活得久的恰恰是慢。批量抓取时必须在每次请求之间留出间隔而且间隔不能是固定值。固定间隔同样会被识别为机器行为因为你永远不可能1.5秒一帧地精准浏览页面。我常用的延时模板是import random import time def polite_sleep(base1.5, jitter0.8): time.sleep(random.uniform(base - jitter, base jitter))每次请求完成后调用polite_sleep()实际延时会在0.7秒到2.3秒之间浮动。这个窗口既能让单机脚本的请求节奏接近人类阅读速度又不会把总耗时拖到无法接受。如果你要抓的是多页榜单类数据还可以在每翻一页后把base值适当拉大一点。因为连续翻页这种高频目录访问比单页访问更容易被盯上。记住一个核心原则高级爬虫比拼的不是谁请求得猛而是谁能持续稳定地把数据拿回来。3. 数据解析从页面结构到结构化数据3.1 页面结构分析请求拿到HTML之后先别急着写选择器。打开浏览器的开发者工具切到Elements面板花十分钟把页面结构摸清楚后面能省半天时间。榜单页的典型结构是一个外层容器列表里面按顺序排着榜单条目每条下面挂电影名称、评分、评价人数、导演、主演等字段。你要做的就是从容器层逐层往下定位把每个条目内部的字段提取出来。以常见的列表结构为例一个条目的简化HTML大概是div classmovie-item span classrank1/span span classtitle电影名称/span span classrating9.1/span span classvotes108765/span a href/subject/1234567/详情链接/a /div注意真实页面结构随时可能调整写解析代码前一定要以自己当前打开页面的实际结构为准。下面只是演示提取逻辑。3.2 数据提取的两种思路解析层最常见的两个选择是BeautifulSoup与正则。我的经验是能用BeautifulSoup的地方尽量别用正则正则适合处理那些藏在JavaScript变量里、没有HTML标签包裹的数据。BeautifulSoup的典型用法是这样from bs4 import BeautifulSoup soup BeautifulSoup(chart_resp.text, html.parser) movie_items soup.select(.movie-item) for item in movie_items: rank item.select_one(.rank).get_text(stripTrue) title item.select_one(.title).get_text(stripTrue) rating item.select_one(.rating).get_text(stripTrue) print(rank, title, rating)这里有几个容易栽跟头的细节。get_text()会拿到该节点下所有文本子节点的拼接结果如果你的HTML里有嵌套标签比如标签里套了一个提示文本那拼接后会出现意外内容。所以实际解析时建议先用select_one定位到最精确的内层节点再调用get_text(stripTrue)。我的习惯是解析前先写一两行“探测代码”把想要的内容打印出来肉眼确认再决定选择器怎么定。宁可慢一步也别拿一个没有验证过的选择器去抓全量数据。那什么时候该用正则呢我举一个典型场景有些数据不在HTML标签里而是被写进了页尾的JavaScript变量中比如script var movieData {rank:1, title:\u7535\u5f71\u540d\u79f0, rating:9.1}; /script这种结构用CSS选择器完全够不着只能先正则把整段变量抠出来再用JSON模块做二次解析import re import json match re.search(rvar movieData (\{.*?\});, chart_resp.text, re.S) if match: data json.loads(match.group(1)) print(data[title])两种方案对比下来场景推荐方案原因数据在HTML标签内CSS选择器结构清晰、易维护数据在JavaScript变量内正则JSON选择器无法触达页面结构经常变动优先CSS选择器修改成本低只需换选择器一次性临时抓取正则写起来快但稳定性差3.3 数据清洗与字段标准化从HTML里抠出来的东西都是字符串而且带着各种格式杂质。排行列表里的字段尤其明显评分前后可能有换行、空格评价人数有时带逗号分隔符电影名可能包含中英文和年份空格位置不固定。这些数据不洗干净后面做排序、聚合、可视化时全是坑。清洗阶段的典型操作如下def clean_rating(text): # 提取浮点数评分 import re match re.search(r(\d\.\d), text) return float(match.group(1)) if match else None def clean_votes(text): # 去掉逗号和单位转成整数 cleaned re.sub(r[^\d], , text) return int(cleaned) if cleaned else 0 def clean_title(text): # 去掉首尾空白压缩连续空格 return .join(text.split())注意标准化的一个重要原则不要在解析函数里顺手做清洗而是单独维护一套清洗函数。原因很简单同一个字段可能需要从不同页面提取不同页面的原始格式可能略有差异统一入口会好维护得多。我以前图省事把清洗逻辑直接写在解析函数里后来榜单页改版光是改清洗逻辑就折腾了一整天从那之后清洗和解析就永远分开写了。4. 异步并发抓取从串行到批量提速4.1 并发方案的选型对比榜单抓取的单页面任务量并不大但如果你要抓的是多页分页榜单或者挨个访问每部电影的详情页串行请求的耗时就会线性膨胀。一个页面2秒200个页面就是400秒这种速度没法接受。高级方案必须上并发。常见的两个方向方案优点缺点适用场景多线程requests代码改动小、调试方便线程切换有开销页面数量中等、单页等待时间短asyncioaiohttp并发上限高、资源占用少API风格与axios类似但细节多页面数量大、IO等待占比高、Python3.7我的默认选择是线程池方案因为requests库本身就是同步阻塞的放到线程池里管理最自然既不需要改请求代码也不用关心事件循环的坑。只有当单机需要跑到几百个页面时我才会考虑asyncio。4.2 线程池的简单实现用Python标准库里的concurrent.futures就能实现完全不需要引入额外依赖from concurrent.futures import ThreadPoolExecutor, as_completed def fetch_chart_page(page_url): 抓取单个榜单页返回解析后的数据列表 resp session.get(page_url, timeout10) return parse_page(resp.text) urls [https://movie.douban.com/chart?page{}.format(i) for i in range(1, 6)] all_data [] with ThreadPoolExecutor(max_workers3) as executor: future_map {executor.submit(fetch_chart_page, url): url for url in urls} for future in as_completed(future_map): try: all_data.extend(future.result()) except Exception as e: print(页面抓取失败:, e)这里有个容易踩的坑ThreadPoolExecutor拿到的是future对象future.result()会抛出原始异常。如果某个请求超时或被限制你不做异常捕获整个with块都会炸掉之前已经完成的页面数据也跟着丢。所以每个future都必须包一层try/except。4.3 性能实测与频率控制我用一个模拟页面做过简单测试单页请求加解析约用1.5秒10个页面串行要15秒左右改用max_workers3的线程池后10个页面大约只需要6到7秒。提速约两倍而随着并发数增加提速比还会更明显。但并发不是越高越好。频率控制在这里的作用是兜底线程池只解决了“同时发多个请求”的问题但没解决“发得太快”的问题。如果你开20个线程同时打一个页面瞬间请求频率会飙到20次/秒这几乎必然触发访问限制。我通常会把max_workers压在3到5之间同时在每个线程的任务函数里保留polite_sleep()。这样并发总量被限制在一个温和区间每个任务内部又有随机间隔整体节奏既高效又不至于疯狂。这里还有一个实用建议把并发参数做成配置项放外面不要写死在代码里。因为不同时段、不同站点的容忍度不一样参数放进配置线上出问题时改配置即可不用改代码重跑。5. 数据存储与去重让爬下来的数据真正可用5.1 存储方案怎么选抓下来的数据最终要落地。存哪里不同选择对应不同场景存储方案适合情况注意事项CSV一次性分析、想用Excel打开编码必须指定UTF-8 with BOM否则Excel乱码SQLite个人项目、数据量中等、需要去重查询单文件、零部署、支持标准SQLMySQL/PostgreSQL多端协作、数据量大、需要并发写入需要环境配置和权限管理我自己的偏好是SQLite。它不需要额外装数据库服务一个.db文件就把所有数据装下python标准库sqlite3直接支持查询语法跟主流数据库差不多。对单机爬虫项目来说它几乎是性价比最高的方案。建表语句我会预先设计好主键和索引import sqlite3 conn sqlite3.connect(movies.db) cursor conn.cursor() cursor.execute( CREATE TABLE IF NOT EXISTS movie_ranking ( rank INTEGER, title TEXT, rating REAL, votes INTEGER, detail_url TEXT, fetched_at TEXT, PRIMARY KEY (title, fetched_at) ) ) conn.commit()5.2 数据去重策略榜单数据最大的特点是更新频率高今天排名第1的电影明天可能被挤下去。如果你每天跑一次抓取同一部电影会反复出现但它的排名、评分、评价人数都在变化。这种情况下去重不能简单地用“是不是出现过”来判断而要想清楚业务上需要哪些数据。常见的策略有两种按标题归一化去重适合快速判断“这部电影是否已经存在”。需要注意中文标题的格式差异比如全角空格、前后缀不一致。我习惯把标题统一做strip压缩再比较。按内容哈希去重适合判断“字段是否发生变化”。我的做法是import hashlib def item_signature(title, rating, votes): raw {}|{}|{}.format(title, rating, votes) return hashlib.md5(raw.encode(utf-8)).hexdigest()每次抓完一批数据算一遍签名和库里已有数据对比。签名相同说明没有变化不需要更新签名不同说明评分或者评价数变了需要执行UPDATE。这样可以避免把相同数据反复写入。5.3 增量更新设计榜单类数据的增量更新逻辑很清晰每日快照。每次抓取都记录fetched_at时间戳分析时按日期筛数据就能看到电影榜单的每日变化轨迹。我日常的落地流程是先抓取全量榜单数据逐条计算签名。然后查询数据库里已存在的记录。对每条新数据做三种判断完全没出现过就直接INSERT出现过但签名不同就UPDATE数据库里存在但本次榜单没出现说明电影已经从榜单掉出这种情况我会保留历史记录不做物理删除。保留历史可以方便后续分析榜单的变动趋势。这套流程写下来其实就是三行SQL的事但它的价值在于你抓的不是一次性数据而是一个可以持续积累的历史数据集。这方面我的经验是数据积累的时间越长后期能挖掘的信息就越多比单次抓全量数据有意义得多。6. 踩坑记录与合规反思6.1 常见异常与解决方案再稳的爬虫也会遇到异常。我把自己遇到比较多的几种情况整理了一张排查表异常现象可能原因处理方式请求返回403请求头不完整或UA被识别补全HEADERS更换User-Agent请求超时网络抖动或并发过高设置timeout开启重试降低并发连接被重置短时请求过于密集拉大随机延时降低抓取频率返回内容乱码编码判断错误优先用resp.encoding或apparent_encoding页面结构变了导致解析为空前端改版先检查Elements面板更新选择器数据库插入失败重复键或字段超长使用INSERT OR REPLACE截断异常文本其中最常见的其实是第二种和第四种。我见过不少人在跑爬虫时完全不写超时和重试逻辑一次网络抖动就让整个脚本退出。这个问题处理起来很简单加一个带重试的请求包装函数就行def get_with_retry(url, session, headersNone, max_retry3): for attempt in range(max_retry): try: resp session.get(url, headersheaders, timeout10) resp.raise_for_status() return resp except (requests.Timeout, requests.ConnectionError) as e: print(第{}次请求失败重试中: {}.format(attempt 1, e)) time.sleep(2) raise RuntimeError(请求最终失败: {}.format(url))这个小函数能挡掉大部分偶发网络问题。注意重试之间一定要加上延时否则失败后的立刻重试大概率还是会失败因为网络抖动往往有一个恢复时间窗口。6.2 关于爬虫合规的几点实践经验最后聊点偏实践观念的内容。做爬虫这行学习型和破坏型的界限在哪里每个人心里要有数。我的原则很简单遵守目标网站的明确要求抓取频率克制抓取范围克制数据用途纯学习研究。拿排行榜这种公开数据来说它本身不是一个高敏感度的数据源但照样有访问规则。建议先看看站点公开的服务条款和robots协议了解哪些路径允许访问、哪些明确禁止。这不是形式主义而是保护自己也是保护目标站点服务稳定。我自己的操作习惯是给每个爬虫任务设置每日抓取上限跑完自动停止。抓取时间尽量放在服务器低峰期避开晚间高峰。数据仅用于个人学习和分析绝不做转售、公开传播或商业化用途。商品价格类、用户个人信息类数据一律不碰。这里再强调一句实战体会高级爬虫和低水平爬虫的核心区别不在代码多花哨而在于你是否愿意在合规、稳定、数据质量这些维度的“隐形工程”上下足功夫。把请求节奏控制好把数据存扎实把异常处理做全这才是真正能用到生产环境里的抓取能力。如果你也在做类似的数据采集项目不妨从今天这个榜单抓取任务开始把并发调度、存储结构、异常重试、增量更新这几个模块逐步打磨出来。做好了这一套以后再接任何公开数据源的抓取需求骨架都是现成的顶多换换解析规则工程底子就立住了。