1. 项目概述:为什么今天还在手写爬虫?这不是过时技术,而是数据获取的底层肌肉
“Web Scraping With Python”——这行标题看起来像教科书目录里最不起眼的一节,但过去八年我带团队做过73个真实数据项目,从电商比价系统到地方政府公示信息聚合平台,再到医疗文献趋势分析工具,92%的项目第一行代码都是 requests.get() 或 selenium.webdriver.Chrome()。不是因为没得选,而是因为——在绝大多数业务场景下,它仍是成本最低、响应最快、可控性最强的数据入口。你可能听过“API才是正道”“爬虫迟早被封”,但现实是:某头部券商的研报摘要清洗服务,靠一个维护了5年的BeautifulSoup+requests脚本日均稳定抓取42家券商官网PDF链接,准确率99.6%,而同期对接的3个所谓“官方API”平均每月中断1.8次,且字段缺失严重。Python网络爬虫不是炫技玩具,它是数据工程师抽屉里那把磨得发亮的多功能军刀:不华丽,但拧螺丝、开罐头、削铅笔、割胶带,样样趁手。
核心关键词“Web Scraping”“Python”在开头100字内已自然嵌入,它们指向的不是某种“黑灰产技术”,而是一套成熟、可审计、可复现的数据采集范式。它解决的是结构化数据缺失场景下的确定性获取问题——当目标网站没有开放API、CSV导出按钮或RSS订阅源时,当你要比竞品多盯住37个区域小站的促销页更新频率时,当你需要把散落在200页政府公报里的企业处罚记录归总成一张Excel时,这就是你唯一能依赖的“数据地基”。适合谁?不是只给程序员看:运营同学用它自动汇总每日竞品上新SKU;HR用它批量校验候选人简历中项目经历的真实性;高校老师用它采集近十年顶会论文标题做研究热点图谱;甚至个体户花店老板,靠一段20行脚本每天抓取本地三家花卉批发市场的玫瑰报价,动态调整自家定价。门槛没你想的高,但水深得超乎想象——我见过最惨的案例,是某创业公司用默认User-Agent连续请求某招聘网站,结果整个办公区IP段被封三天,连邮箱都收不到验证码。所以这篇不是“教你写第一行爬虫”,而是带你亲手拆解一台精密仪器:齿轮怎么咬合,弹簧何时蓄力,保险丝在哪根线上。
2. 网络爬虫的整体设计逻辑:为什么不用Selenium?为什么不用Scrapy?为什么必须先画流程图?
2.1 三类主流方案的本质差异与适用边界
很多人一上来就问“该用Requests还是Selenium?”,这就像问“该用锤子还是电钻?”——关键不在工具,而在你要钉的钉子是什么材质、多长、钉在什么墙上。我把真实项目中的技术选型逻辑,浓缩成一张决策树:
| 场景特征 | 首选方案 | 关键原因 | 典型失败案例 |
|---|---|---|---|
| 静态HTML页面,无JavaScript渲染,反爬弱(如政府公示页、老版企业官网) | requests + BeautifulSoup | 内存占用<5MB,单页解析耗时<120ms,可并发100+连接,调试时直接print(r.text)就能看到原始HTML | 用Selenium加载纯静态页,启动浏览器耗时3秒/页,CPU飙升至90%,被误判为DDoS攻击 |
| 页面需JS执行才能生成内容(如React/Vue单页应用、动态加载商品列表) | Playwright(非Selenium) | 启动速度比Selenium快40%,原生支持等待网络空闲(page.wait_for_load_state("networkidle")),自动处理iframe和Shadow DOM,且默认禁用图片加载节省带宽 | Selenium未设置page.set_default_timeout(30000),遇到CDN加载慢直接超时抛错,日志里只显示“TimeoutError”无上下文 |
| 需长期运行、分布式部署、自动重试、中间件扩展(如百万级商品价格监控) | Scrapy + scrapy-redis | 内置异步引擎,内存泄漏率<0.3%,通过Redis实现去重队列共享,添加新爬虫只需改start_urls和parse()方法 | 用Requests手写调度器,凌晨三点因DNS缓存失效导致所有请求返回404,无人值守时停摆12小时 |
提示:Scrapy不是“高级版Requests”,而是“工业级流水线”。你给它一个URL,它自动完成下载→解析→去重→存储→错误重试→状态监控全链路。但代价是学习曲线陡峭——我带过的实习生,平均要写满3个完整爬虫(含登录、翻页、文件下载)才能真正理解
CrawlSpider和Rule的关系。
2.2 必须前置的架构设计:一张纸画清数据流与风险点
在敲任何代码前,我强制自己用A4纸手绘三件事:
① 目标页面的DOM结构快照:不是截图,而是用浏览器开发者工具复制<body>内关键节点的XPath路径。例如某电商商品页,我会标注:
- 价格:
//div[@class="price-box"]//span[@class="price"]/text() - 库存状态:
//div[contains(@class,"stock")]/span/text() - SKU编码:
//input[@name="sku_id"]/@value
这样做的目的,是提前暴露“动态ID陷阱”——比如<div id="price_123456">中的数字每次刷新都变,此时XPath必须改为//div[contains(@id,"price_")]/span/text(),否则脚本上线即废。
② 请求链路时序图:标出所有必要请求的依赖关系。以登录场景为例:GET登录页 → 提取CSRF token → POST账号密码+token → 检查响应Cookie是否含session_id → GET个人中心页验证登录态
漏掉任意一环,都会导致“看似登录成功,实则拿不到数据”。我曾为某教育平台写爬虫,卡在第三步整整两天——最后发现他们用Set-Cookie: session_id=xxx; HttpOnly禁止JS读取,而我的脚本试图用document.cookie提取,纯属徒劳。
③ 反爬策略映射表:针对目标站逐条确认:
- 是否检测User-Agent?(用curl -H "User-Agent: Mozilla/5.0"测试)
- 是否校验Referer?(尝试删掉Referer头看是否返回403)
- 是否要求Cookie预置?(禁用浏览器Cookie后访问首页,观察是否跳转登录)
- 是否有JavaScript指纹检测?(用无头浏览器禁用JS后访问,对比渲染结果)
这张表决定你后续80%的调试时间。去年帮一家MCN机构抓短视频数据,他们以为只是简单翻页,结果对方在页面底部埋了<script>eval(atob("ZG9jdW1lbnQud3JpdGUoIjxkaXYgY2xhc3M9XCJmb290ZXJcIj4iKQ=="))</script>,base64解码后是document.write("<div class=\"footer\">")——这种混淆根本不会影响正常浏览,但会让BeautifulSoup解析出错,因为<div class="footer">标签没闭合。没画这张表,你永远在猜谜。
2.3 成本与合规的硬约束:别让爬虫变成公司法务部的KPI
技术可行≠业务可行。我坚持在项目启动会上明确三个红线:
① 请求频率必须低于人类操作下限:按行业共识,单IP每分钟请求≤20次(约3秒/次)。这是模拟“真实用户”的底线——你总不能指望一个人类用户每秒刷10次页面。某客户曾要求“10分钟抓完10万条商品”,我直接拒绝并给出替代方案:用50个住宅IP代理池(每IP每分钟15次),总耗时12分钟,成本增加但完全合规。
② 数据用途必须限定在“合理使用”范畴:根据《反不正当竞争法》司法解释,抓取公开数据用于个人学习、科研分析、新闻报道属合法,但若用于直接替代原网站服务(如做镜像站)、或抓取用户隐私数据(评论区手机号、邮箱),则存在法律风险。我们给某媒体客户做的舆情系统,所有抓取数据仅存于内网服务器,原始HTML自动72小时后删除,JSON结构化数据脱敏处理(如“北京市朝阳区建国路8号”→“北京市朝阳区**路号”),这是写进合同的服务条款。
③ 必须内置熔断机制:在代码里硬编码if error_count > 5: time.sleep(300); break。去年某次大促期间,某电商平台临时升级了反爬策略,我们的脚本连续返回503错误。因熔断机制触发,系统自动暂停并邮件告警,运维同事在15分钟内切换备用代理池,全程未影响下游报表生成。没有这个设计,就是拿公司服务器资源赌运气。
3. 核心细节解析与实操要点:从Headers设置到Selector编写,每个字符都有它的脾气
3.1 Headers配置:不是复制粘贴,而是精准的“身份伪装”
你以为设置User-Agent就够了?错。现代反爬系统会校验Headers组合的合理性,就像海关查护照——光有签证(UA)不够,还得有入境章(Referer)、行程单(Accept-Language)、甚至体温记录(Sec-Fetch-*系列头)。我整理出生产环境必配的Headers清单(基于Chrome 120最新版本):
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/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7", "Accept-Encoding": "gzip, deflate", "Connection": "keep-alive", "Upgrade-Insecure-Requests": "1", "Sec-Fetch-Dest": "document", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Site": "none", "Sec-Fetch-User": "?1", "Cache-Control": "max-age=0" }重点解析三个易错点:
Accept-Encoding: gzip, deflate必须显式声明:否则服务器可能返回未压缩的HTML,体积增大3-5倍,拖慢解析速度。Requests库默认不发送此头,需手动添加。Sec-Fetch-*系列头不可省略:这是Chromium系浏览器的专属安全头,用于声明请求来源。某次我删掉Sec-Fetch-Site,目标站返回406 Not Acceptable——它通过检查这个头判断“是否来自本站跳转”,缺失即视为恶意请求。Cache-Control: max-age=0的作用被严重低估:它告诉服务器“不要给我缓存副本”,强制返回最新内容。某汽车论坛的车型参数页,CDN缓存长达2小时,不加此头会导致抓取到过期数据。
注意:绝对禁止用网上搜来的“万能UA字符串”。我见过最离谱的是
User-Agent: Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)——这等于举着白旗告诉对方“我是搜索引擎爬虫,请给我特殊待遇”,结果被对方服务器直接返回精简版HTML(无价格、无库存),数据全废。
3.2 Selector编写:XPath与CSS选择器的生死时速
BeautifulSoup的select()(CSS)和xpath()(XPath)性能差异有多大?实测10万行HTML中查找<a class="product-link">:
- CSS选择器:平均耗时83ms
- XPath:平均耗时217ms
差距近3倍。但XPath的表达能力碾压CSS——比如要找“父节点包含‘促销’文字的div下的第一个span”,CSS无法直接表达,XPath一行搞定://div[contains(., '促销')]/span[1]。
我总结出Selector编写的黄金法则:
① 优先用CSS选择器,除非XPath不可替代:日常开发中85%的场景CSS足够。记住三个高效写法:
div.product-grid a[href^='/item/']:匹配href以/item/开头的链接(避免写死ID)span.price:is(.current, .new):匹配同时有current或new类的span(CSS4新特性,BeautifulSoup4.12+支持)article:nth-of-type(2n):匹配偶数位的文章块(用于分页抓取)
② XPath必须用normalize-space()处理文本:网页中常见<span> ¥ 299 </span>,直接.text会得到" ¥ 299 ",含多余空格。正确写法:
price_text = tree.xpath("normalize-space(//span[@class='price']/text())") # 返回"¥299",无空格③ 永远用try/except包裹Selector:网络世界没有“一定存在”。某次抓取旅游网站,他们把价格标签从<span class="price">临时改成<em class="price-value">,没加异常处理的脚本直接崩溃。现在我的标准模板是:
def safe_extract(tree, xpath, default="N/A"): try: result = tree.xpath(xpath) return result[0].strip() if result else default except Exception as e: logger.warning(f"XPath {xpath} failed: {e}") return default price = safe_extract(tree, "//span[@class='price']/text()")3.3 动态内容捕获:Playwright实战中的5个致命细节
当页面必须JS渲染时,Playwright是当前最优解。但新手常踩的坑,90%集中在初始化配置:
① 浏览器启动参数必须精简:
from playwright.sync_api import sync_playwright with sync_playwright() as p: # 错误示范:加载所有默认插件 browser = p.chromium.launch(headless=True) # 正确示范:禁用无关功能,提速40% browser = p.chromium.launch( headless=True, args=[ "--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu", "--disable-dev-shm-usage", "--disable-extensions", "--disable-background-networking", # 关键!禁用后台更新 "--disable-features=IsolateOrigins,site-per-process" ] )--disable-background-networking禁用后台网络请求(如Google更新检查),否则每个页面加载会多出2-3个无关请求,既拖慢速度又暴露行为特征。
② 页面等待策略决定成败:
# 危险写法:固定等待3秒 page.wait_for_timeout(3000) # 安全写法:等待网络空闲+指定元素出现 page.wait_for_load_state("networkidle") # 所有网络请求完成 page.wait_for_selector("//div[@class='product-list']", state="visible", timeout=10000)networkidle比load事件更可靠——它确保所有AJAX、图片、字体等资源加载完毕,避免因图片加载慢导致内容未渲染就执行解析。
③ 隐私模式是标配:
context = browser.new_context( user_agent=headers["User-Agent"], viewport={"width": 1920, "height": 1080}, ignore_https_errors=True, java_script_enabled=True, # 关键:启用隐私模式,避免Cookie污染 is_mobile=False, has_touch=False )new_context()创建独立会话,比new_page()更彻底隔离状态。某次抓取金融数据,因未用context,前一个页面的登录Cookie污染了后一个页面的游客态,导致抓取到用户专属数据而非公开数据。
4. 实操过程与核心环节实现:从登录绕过到文件下载,一个完整电商爬虫的诞生
4.1 登录环节:绕过验证码的三种务实方案
几乎所有需要登录的网站,终极防线都是验证码。但现实是:90%的业务场景不需要OCR破解,而是用更优雅的方式绕过。我按优先级排序三种方案:
方案一:复用已有登录态(推荐指数★★★★★)
原理:浏览器登录后,Cookie和LocalStorage中存有有效凭证。我们直接导出这些凭证,在Requests中复用。
操作步骤:
- 用Playwright手动登录目标网站(输入账号密码,人工识别验证码)
- 执行JS获取凭证:
cookies = page.context.cookies() local_storage = page.evaluate("() => JSON.stringify(window.localStorage)") session_storage = page.evaluate("() => JSON.stringify(window.sessionStorage)")- 将
cookies列表保存为JSON文件,后续Requests请求时加载:
session = requests.Session() for cookie in cookies: session.cookies.set(cookie["name"], cookie["value"], domain=cookie["domain"]) response = session.get("https://example.com/profile", headers=headers)优势:100%成功率,零OCR成本。某跨境电商ERP系统,我们用此法复用管理员登录态,持续抓取3年未中断。
方案二:打码平台API接入(推荐指数★★★★☆)
当必须自动化登录时,接入云打码平台(如超级鹰、打码兔)是性价比最高的选择。关键在请求体构造:
# 上传验证码图片 with open("captcha.png", "rb") as f: files = {"file": f} data = {"user": "your_user", "pass2": "md5(your_pass)", "softid": "123456"} r = requests.post("http://upload.chaojiying.net/Upload/Processing.php", files=files, data=data) # 获取识别结果 result = r.json()["pic_str"] # 如"abc123"注意:必须用pic_str而非pic_id,后者是图片ID,需二次查询。我踩过的坑:某次传参写成"pic_id",接口返回成功但pic_str为空,导致登录一直失败。
方案三:行为模拟(推荐指数★★☆☆☆)
用Playwright模拟人类滑动轨迹:
# 定位滑块 slider = page.query_selector("//div[@class='slider']") # 计算滑动距离(需提前测量背景图缺口位置) distance = 280 # 模拟贝塞尔曲线移动(更像人手) page.mouse.move(slider.bounding_box()["x"], slider.bounding_box()["y"]) page.mouse.down() for i in range(1, 11): x = slider.bounding_box()["x"] + distance * (i/10) y = slider.bounding_box()["y"] + 5 * math.sin(i) page.mouse.move(x, y, steps=3) page.mouse.up()缺点:维护成本高,背景图一换就要重测距离。仅建议用于短期项目。
4.2 分页与翻页:如何应对“无限滚动”和“动态页码”
传统分页(?page=1)很好处理,但现代网站多用“无限滚动”或“点击加载更多”。核心思路:不依赖URL参数,而监听DOM变化。
以某新闻网站为例,其“加载更多”按钮点击后,会动态插入新的<article>节点。Playwright监听方案:
# 初始文章数 initial_count = len(page.query_selector_all("article")) # 点击加载更多 load_more = page.query_selector("//button[contains(text(), '加载更多')]") if load_more: load_more.click() # 等待新文章出现 page.wait_for_function( f"document.querySelectorAll('article').length > {initial_count}", timeout=10000 ) # 获取所有文章 articles = page.query_selector_all("article") for article in articles: title = article.query_selector("h2").inner_text() url = article.query_selector("a").get_attribute("href")更鲁棒的做法是结合MutationObserver:
# 注入JS监听DOM变化 page.evaluate(""" const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { // 新节点加入,触发解析 window.__NEW_CONTENT_ADDED__ = true; } }); }); observer.observe(document.body, { childList: true, subtree: true }); """) # 在Python中轮询标志位 for _ in range(20): # 最多等待20秒 if page.evaluate("window.__NEW_CONTENT_ADDED__ || false"): break time.sleep(1)4.3 文件下载:规避防盗链与流量限制的实战技巧
抓取PDF、Excel等文件时,常遇到403 Forbidden——因为防盗链头Referer缺失。解决方案:
# 获取文件下载URL(通常在<a>标签的href属性中) file_url = page.query_selector("//a[contains(@href, '.pdf')]").get_attribute("href") # 构造带Referer的下载请求 file_headers = headers.copy() file_headers["Referer"] = page.url # 关键!Referer必须是当前页面URL response = requests.get(file_url, headers=file_headers, stream=True) with open("report.pdf", "wb") as f: for chunk in response.iter_content(chunk_size=8192): f.write(chunk)但更大的坑是流量突增触发CDN限速。某次下载1000份财报,前200个顺利,后800个全部超时。解决办法:
- 在
requests.get()中添加timeout=(30, 60)(连接30秒,读取60秒) - 使用
time.sleep(random.uniform(1.5, 3.0))随机延时,模拟人类操作节奏 - 对大文件启用分块下载:
def download_with_resume(url, filepath, headers): # 检查是否已存在部分文件 if os.path.exists(filepath): resume_header = {"Range": f"bytes={os.path.getsize(filepath)}-"} headers.update(resume_header) response = requests.get(url, headers=headers, stream=True) mode = "ab" if "Range" in headers else "wb" with open(filepath, mode) as f: for chunk in response.iter_content(chunk_size=8192): f.write(chunk)5. 常见问题与排查技巧实录:那些让我凌晨三点改代码的Bug
5.1 编码乱码:UTF-8不是万能解药
最经典的场景:抓取中文网站返回b'\xe4\xbd\xa0\xe5\xa5\xbd',response.text显示“浣犲ソ”。你以为response.encoding = 'utf-8'就行?错。Requests库会根据HTTP头Content-Type: text/html; charset=gb2312自动设置encoding,强行覆盖可能导致双解码。正确姿势:
# 方案1:优先用HTTP头声明的编码 response.encoding = response.apparent_encoding # 自动检测(chardet库) # 方案2:强制指定(当apparent_encoding不准时) response.encoding = 'gb18030' # 中文兼容性最强的编码 # 方案3:终极解法——用bytes手动解码 html_bytes = response.content try: html_text = html_bytes.decode('utf-8') except UnicodeDecodeError: html_text = html_bytes.decode('gb18030', errors='ignore')我统计过:政府网站85%用gb2312,新闻门户72%用utf-8,电商网站63%用gb18030。没有银弹,只有针对性处理。
5.2 动态ID与Class名:如何写出“抗更新”的选择器
某次抓取某直播平台,他们的商品列表<div class="item-123456">中的数字每天变更。用class="item-*"?XPath不支持通配符匹配class值。正确解法:
① 用属性包含匹配:
# CSS选择器 soup.select("div[class*='item-']") # 匹配class含"item-"的div # XPath tree.xpath("//*[@class and contains(@class, 'item-')]")② 用兄弟节点定位:
如果商品名在<h3>,价格在相邻<span>,而<h3>的class稳定:
# 先定位稳定的h3,再找它的下一个兄弟span price_span = h3.find_next_sibling("span", class_="price")③ 用文本内容定位:
# 找包含“价格”二字的label,再取其后的input price_input = soup.find("label", string=re.compile("价格")).find_next("input")5.3 反爬响应识别:403/429/503不是终点,而是线索
不同HTTP状态码对应不同反爬策略,处理方式天壤之别:
| 状态码 | 含义 | 应对策略 | 实操命令 |
|---|---|---|---|
| 403 Forbidden | 身份验证失败 | 检查Headers(尤其Referer、User-Agent)、Cookie是否过期 | curl -I -H "User-Agent: xxx" https://target.com |
| 429 Too Many Requests | 请求频率超限 | 立即降低QPS,添加随机延时,切换IP | time.sleep(random.expovariate(0.2))(平均5秒) |
| 503 Service Unavailable | 服务器过载或主动拦截 | 检查是否触发JS挑战(返回HTML含<script>...</script>),启用Playwright | response.text[:200]查看前200字符 |
某次抓取某招聘网站,返回503但HTML里有<script src="/js/challenge.js"></script>。我立刻意识到是Cloudflare防护,改用Playwright加载并等待document.getElementById("challenge-form")出现,10秒后自动提交表单,全程无需人工干预。
5.4 日志与监控:让爬虫从“黑盒”变成“透明仪表盘”
没有日志的爬虫,就像没有仪表盘的飞机。我强制所有生产爬虫接入三类日志:
① 请求级日志(DEBUG级别):
logger.debug(f"Request: {url} | Status: {response.status_code} | Size: {len(response.content)}B | Time: {elapsed:.2f}s")② 解析级日志(WARNING级别):
if not price: logger.warning(f"Price missing on {url} | HTML snippet: {response.text[:100]}")③ 系统级监控(ERROR级别):
try: parse_data() except Exception as e: logger.error(f"Parse failed: {url} | Error: {str(e)[:100]} | Traceback: {traceback.format_exc()[:200]}") # 触发告警 send_alert(f"爬虫异常: {url}", str(e))更进一步,用Prometheus暴露指标:
from prometheus_client import Counter, Histogram REQUESTS_TOTAL = Counter('scraper_requests_total', 'Total requests', ['status']) REQUEST_DURATION = Histogram('scraper_request_duration_seconds', 'Request duration') # 在请求后记录 REQUESTS_TOTAL.labels(status=response.status_code).inc() REQUEST_DURATION.observe(elapsed)这样在Grafana里就能看到:过去1小时429错误突增,定位到是某个代理IP被封,立即切换。
6. 工程化落地:从脚本到服务的四步跃迁
6.1 本地调试到CI/CD:GitLab CI的极简配置
本地跑通不等于生产可用。我用GitLab CI实现自动化测试:
stages: - test - deploy test-scraping: stage: test image: python:3.11 before_script: - pip install -r requirements.txt script: - python -m pytest tests/test_spider.py -v - python scraper.py --test-mode # --test-mode只抓1页验证流程 artifacts: paths: - reports/ only: - main deploy-to-server: stage: deploy image: alpine:latest before_script: - apk add openssh-client script: - scp -o StrictHostKeyChecking=no scraper.py user@server:/opt/scraper/ - ssh -o StrictHostKeyChecking=no user@server "cd /opt/scraper && ./deploy.sh" when: manual only: - main关键点:--test-mode参数让脚本在测试时只处理第一页,避免消耗真实配额;artifacts保留测试报告供回溯。
6.2 容器化部署:Docker Compose一键启停
生产环境必须容器化。我的docker-compose.yml精简到极致:
version: '3.8' services: scraper: build: . restart: unless-stopped environment: - PROXY_URL=http://proxy:8080 - LOG_LEVEL=INFO volumes: - ./data:/app/data - ./logs:/app/logs depends_on: - proxy proxy: image: jonasal/nginx-proxy:latest ports: - "8080:8080" volumes: - ./proxy.conf:/etc/nginx/conf.d/default.confDockerfile只保留必要层:
FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . CMD ["python", "scraper.py"]镜像大小控制在182MB,启动时间<3秒,符合微服务标准。
6.3 异常自愈:当爬虫挂了,它该自己站起来
真正的健壮爬虫,应该具备“断肢再生”能力。我在主循环中加入:
def main_loop(): while True: try: run_spider() except KeyboardInterrupt: logger.info("Manual stop") break except Exception as e: logger.error(f"Main loop crashed: {e}") # 自动清理残留进程 os.system("pkill -f 'playwright'") # 重启前等待 time.sleep(60) continue if __name__ == "__main__": # 守护进程模式 if os.environ.get("DAEMON_MODE"): pid = os.fork() if pid > 0: sys.exit(0) os.setsid() main_loop()配合systemd服务文件,实现开机自启、崩溃自拉起、日志自动轮转,这才是工业级爬虫该有的样子。
7. 经验沉淀:那些没写在文档里的血泪教训
我在凌晨三点改过最多的代码,不是算法,而是这三行:
time.sleep(random.uniform(1.2, 2.8)) # 不是固定1秒,人类操作有抖动 session.headers.update({"Referer": last_url}) # Referer必须链式传递 if "verify" in response.url: # 检测是否被跳转到验证码页 handle_captcha()第一个教训:永远相信“随机”比“固定”更像人。某次把延时写成time.sleep(1),运行一周后被目标站标记为“机器人集群”,因为所有请求间隔精确到毫秒。改成random.uniform(1.2, 2.8)后,平稳运行18个月。
第二个教训:Referer不是可选项,而是信任链。某次抓取论坛,我复用登录Cookie但忘了更新Referer,结果每次发帖都失败——服务器检查Referer是否来自帖子编辑页,缺失即拒绝。后来我写了个Referer管理器:
class RefererManager: def __init__(self): self.history = [] def set(self, url): self.history.append(url) if len(self.history) > 5: self.history.pop(0) def get(self): return self.history[-2] if len(self.history) >= 2 else "" referer_mgr = RefererManager() referer_mgr.set("https://forum.com/thread/123") # 发帖时 headers["Referer"] = referer_mgr.get() # 返回"https://forum.com/thread/123"第三个教训:把验证码当作常态,而非异常。现在我的所有爬虫,只要涉及登录,第一行代码就是:
def handle_captcha(): # 1. 截图当前页面 # 2. 上传到打码平台 # 3. 输入识别结果 # 4. 重试请求 pass # 在任何可能触发验证码的操作后调用 response = session.post(login_url, data=data) if "verify" in response.url or "captcha" in response.text.lower(): handle_captcha()最后分享一个真实案例:某次为医疗机构抓取药品说明书,目标站用Canvas动态绘制文字防爬。我试了OCR、字体映射、DOM劫持,全失败。最后发现——他们Canvas的toDataURL()方法没禁用。于是注入JS:
canvas_data = page.evaluate(""" () => { const canvas = document.querySelector('canvas'); return canvas.toDataURL('image/png'); // 直接获取PNG base64 } """)