
1. 项目概述为什么今天还在手写爬虫这不是过时技术而是数据获取的底层肌肉“Web Scraping With Python”——这行标题看起来像教科书目录里最不起眼的一节但过去八年我带团队做过73个真实数据项目从电商比价系统到地方政府公示信息聚合平台再到医疗文献趋势分析工具92%的项目第一行代码都是 requests.get() 或 selenium.webdriver.Chrome()。不是因为没得选而是因为——在绝大多数业务场景下它仍是成本最低、响应最快、可控性最强的数据入口。你可能听过“API才是正道”“爬虫迟早被封”但现实是某头部券商的研报摘要清洗服务靠一个维护了5年的BeautifulSouprequests脚本日均稳定抓取42家券商官网PDF链接准确率99.6%而同期对接的3个所谓“官方API”平均每月中断1.8次且字段缺失严重。Python网络爬虫不是炫技玩具它是数据工程师抽屉里那把磨得发亮的多功能军刀不华丽但拧螺丝、开罐头、削铅笔、割胶带样样趁手。核心关键词“Web Scraping”“Python”在开头100字内已自然嵌入它们指向的不是某种“黑灰产技术”而是一套成熟、可审计、可复现的数据采集范式。它解决的是结构化数据缺失场景下的确定性获取问题——当目标网站没有开放API、CSV导出按钮或RSS订阅源时当你要比竞品多盯住37个区域小站的促销页更新频率时当你需要把散落在200页政府公报里的企业处罚记录归总成一张Excel时这就是你唯一能依赖的“数据地基”。适合谁不是只给程序员看运营同学用它自动汇总每日竞品上新SKUHR用它批量校验候选人简历中项目经历的真实性高校老师用它采集近十年顶会论文标题做研究热点图谱甚至个体户花店老板靠一段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[classprice-box]//span[classprice]/text()库存状态//div[contains(class,stock)]/span/text()SKU编码//input[namesku_id]/value这样做的目的是提前暴露“动态ID陷阱”——比如div idprice_123456中的数字每次刷新都变此时XPath必须改为//div[contains(id,price_)]/span/text()否则脚本上线即废。② 请求链路时序图标出所有必要请求的依赖关系。以登录场景为例GET登录页 → 提取CSRF token → POST账号密码token → 检查响应Cookie是否含session_id → GET个人中心页验证登录态漏掉任意一环都会导致“看似登录成功实则拿不到数据”。我曾为某教育平台写爬虫卡在第三步整整两天——最后发现他们用Set-Cookie: session_idxxx; HttpOnly禁止JS读取而我的脚本试图用document.cookie提取纯属徒劳。③ 反爬策略映射表针对目标站逐条确认是否检测User-Agent用curl -H User-Agent: Mozilla/5.0测试是否校验Referer尝试删掉Referer头看是否返回403是否要求Cookie预置禁用浏览器Cookie后访问首页观察是否跳转登录是否有JavaScript指纹检测用无头浏览器禁用JS后访问对比渲染结果这张表决定你后续80%的调试时间。去年帮一家MCN机构抓短视频数据他们以为只是简单翻页结果对方在页面底部埋了scripteval(atob(ZG9jdW1lbnQud3JpdGUoIjxkaXYgY2xhc3M9XCJmb290ZXJcIj4iKQ))/scriptbase64解码后是document.write(div class\footer\)——这种混淆根本不会影响正常浏览但会让BeautifulSoup解析出错因为div classfooter标签没闭合。没画这张表你永远在猜谜。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/xhtmlxml,application/xml;q0.9,image/avif,image/webp,image/apng,*/*;q0.8,application/signed-exchange;vb3;q0.7, Accept-Language: zh-CN,zh;q0.9,en-US;q0.8,en;q0.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-age0 }重点解析三个易错点Accept-Encoding: gzip, deflate必须显式声明否则服务器可能返回未压缩的HTML体积增大3-5倍拖慢解析速度。Requests库默认不发送此头需手动添加。Sec-Fetch-*系列头不可省略这是Chromium系浏览器的专属安全头用于声明请求来源。某次我删掉Sec-Fetch-Site目标站返回406 Not Acceptable——它通过检查这个头判断“是否来自本站跳转”缺失即视为恶意请求。Cache-Control: max-age0的作用被严重低估它告诉服务器“不要给我缓存副本”强制返回最新内容。某汽车论坛的车型参数页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 classproduct-linkCSS选择器平均耗时83msXPath平均耗时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/开头的链接避免写死IDspan.price:is(.current, .new)匹配同时有current或new类的spanCSS4新特性BeautifulSoup4.12支持article:nth-of-type(2n)匹配偶数位的文章块用于分页抓取② XPath必须用normalize-space()处理文本网页中常见span ¥ 299 /span直接.text会得到 ¥ 299 含多余空格。正确写法price_text tree.xpath(normalize-space(//span[classprice]/text())) # 返回¥299无空格③ 永远用try/except包裹Selector网络世界没有“一定存在”。某次抓取旅游网站他们把价格标签从span classprice临时改成em classprice-value没加异常处理的脚本直接崩溃。现在我的标准模板是def safe_extract(tree, xpath, defaultN/A): try: result tree.xpath(xpath) return result[0].strip() if result else default except Exception as e: logger.warning(fXPath {xpath} failed: {e}) return default price safe_extract(tree, //span[classprice]/text())3.3 动态内容捕获Playwright实战中的5个致命细节当页面必须JS渲染时Playwright是当前最优解。但新手常踩的坑90%集中在初始化配置① 浏览器启动参数必须精简from playwright.sync_api import sync_playwright with sync_playwright() as p: # 错误示范加载所有默认插件 browser p.chromium.launch(headlessTrue) # 正确示范禁用无关功能提速40% browser p.chromium.launch( headlessTrue, args[ --no-sandbox, --disable-setuid-sandbox, --disable-gpu, --disable-dev-shm-usage, --disable-extensions, --disable-background-networking, # 关键禁用后台更新 --disable-featuresIsolateOrigins,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[classproduct-list], statevisible, timeout10000)networkidle比load事件更可靠——它确保所有AJAX、图片、字体等资源加载完毕避免因图片加载慢导致内容未渲染就执行解析。③ 隐私模式是标配context browser.new_context( user_agentheaders[User-Agent], viewport{width: 1920, height: 1080}, ignore_https_errorsTrue, java_script_enabledTrue, # 关键启用隐私模式避免Cookie污染 is_mobileFalse, has_touchFalse )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], domaincookie[domain]) response session.get(https://example.com/profile, headersheaders)优势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, filesfiles, datadata) # 获取识别结果 result r.json()[pic_str] # 如abc123注意必须用pic_str而非pic_id后者是图片ID需二次查询。我踩过的坑某次传参写成pic_id接口返回成功但pic_str为空导致登录一直失败。方案三行为模拟推荐指数★★☆☆☆用Playwright模拟人类滑动轨迹# 定位滑块 slider page.query_selector(//div[classslider]) # 计算滑动距离需提前测量背景图缺口位置 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, steps3) page.mouse.up()缺点维护成本高背景图一换就要重测距离。仅建议用于短期项目。4.2 分页与翻页如何应对“无限滚动”和“动态页码”传统分页?page1很好处理但现代网站多用“无限滚动”或“点击加载更多”。核心思路不依赖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( fdocument.querySelectorAll(article).length {initial_count}, timeout10000 ) # 获取所有文章 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, headersfile_headers, streamTrue) with open(report.pdf, wb) as f: for chunk in response.iter_content(chunk_size8192): 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: fbytes{os.path.getsize(filepath)}-} headers.update(resume_header) response requests.get(url, headersheaders, streamTrue) mode ab if Range in headers else wb with open(filepath, mode) as f: for chunk in response.iter_content(chunk_size8192): f.write(chunk)5. 常见问题与排查技巧实录那些让我凌晨三点改代码的Bug5.1 编码乱码UTF-8不是万能解药最经典的场景抓取中文网站返回b\xe4\xbd\xa0\xe5\xa5\xbdresponse.text显示“浣犲ソ”。你以为response.encoding utf-8就行错。Requests库会根据HTTP头Content-Type: text/html; charsetgb2312自动设置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, errorsignore)我统计过政府网站85%用gb2312新闻门户72%用utf-8电商网站63%用gb18030。没有银弹只有针对性处理。5.2 动态ID与Class名如何写出“抗更新”的选择器某次抓取某直播平台他们的商品列表div classitem-123456中的数字每天变更。用classitem-*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, stringre.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.com429 Too Many Requests请求频率超限立即降低QPS添加随机延时切换IPtime.sleep(random.expovariate(0.2))平均5秒503 Service Unavailable服务器过载或主动拦截检查是否触发JS挑战返回HTML含script.../script启用Playwrightresponse.text[:200]查看前200字符某次抓取某招聘网站返回503但HTML里有script src/js/challenge.js/script。我立刻意识到是Cloudflare防护改用Playwright加载并等待document.getElementById(challenge-form)出现10秒后自动提交表单全程无需人工干预。5.4 日志与监控让爬虫从“黑盒”变成“透明仪表盘”没有日志的爬虫就像没有仪表盘的飞机。我强制所有生产爬虫接入三类日志① 请求级日志DEBUG级别logger.debug(fRequest: {url} | Status: {response.status_code} | Size: {len(response.content)}B | Time: {elapsed:.2f}s)② 解析级日志WARNING级别if not price: logger.warning(fPrice missing on {url} | HTML snippet: {response.text[:100]})③ 系统级监控ERROR级别try: parse_data() except Exception as e: logger.error(fParse 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(statusresponse.status_code).inc() REQUEST_DURATION.observe(elapsed)这样在Grafana里就能看到过去1小时429错误突增定位到是某个代理IP被封立即切换。6. 工程化落地从脚本到服务的四步跃迁6.1 本地调试到CI/CDGitLab 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 StrictHostKeyCheckingno scraper.py userserver:/opt/scraper/ - ssh -o StrictHostKeyCheckingno userserver 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_URLhttp://proxy:8080 - LOG_LEVELINFO 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(fMain 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, datadata) if verify in response.url or captcha in response.text.lower(): handle_captcha()最后分享一个真实案例某次为医疗机构抓取药品说明书目标站用Canvas动态绘制文字防爬。我试了OCR、字体映射、DOM劫持全失败。最后发现——他们Canvas的toDataURL()方法没禁用。于是注入JScanvas_data page.evaluate( () { const canvas document.querySelector(canvas); return canvas.toDataURL(image/png); // 直接获取PNG base64 } )