
1. 这不是写爬虫是教你怎么在真实世界里“合法、稳定、可持续”地拿数据“Web Scraping With Python”——光看标题很多人第一反应是哦又一个教requestsBeautifulSoup的入门教程。但干了十多年数据工程和自动化项目我得说句实在话90%的Python爬虫代码写完三天就挂70%的所谓“实战项目”根本没跑过真实网站的反爬机制剩下那点能跑通的要么靠运气要么靠不断重写完全不可维护。我自己从2012年开始做电商比价系统后来带团队搭金融舆情采集平台再到现在帮制造业客户做供应链信息聚合踩过的坑、被封的IP、被重定向到验证码页的凌晨三点数都数不清。这不是技术炫技而是一套需要同时理解HTTP协议底层、前端渲染逻辑、法律边界、运维成本和业务连续性的综合能力。你真正需要的不是“怎么用Python发个GET请求”而是当目标网站突然加了Cloudflare防护你该从哪一层开始排查当页面用React动态加载商品列表你该选Selenium还是Playwright为什么不用Puppeteer当你每天要抓取5万条新闻摘要如何设计任务队列、失败重试、去重存储让系统不崩、不漏、不重复当法务同事拿着《robots.txt》和《用户协议》来找你确认风险你怎么快速判断这个站点能不能采、哪些字段能存、哪些必须脱敏这篇文章就是我把过去十年在银行、媒体、跨境电商、工业B2B等不同行业落地的37个真实爬虫项目浓缩成的一套“生产级Web Scraping方法论”。它不讲抽象概念只讲我在服务器上敲过的命令、在日志里看到的报错、在监控面板里调过的阈值、在合同附件里加过的免责条款。适合三类人刚学完Python想接单的自由职业者、正在搭建数据中台的工程师、以及需要定期获取竞品价格/招聘动态/政策更新的业务负责人。下面所有内容你都可以直接抄进自己的项目里改几个变量就能跑起来——前提是你愿意花15分钟读完这5000字而不是只复制粘贴三行代码。2. 整体架构设计为什么99%的爬虫项目死在“没想清楚要什么”2.1 爬虫不是功能模块而是数据管道中的一个环节很多新手一上来就打开PyCharm新建一个spider.py然后写requests.get(url)——这就像盖楼前先买瓷砖。问题不在代码而在定位错误。Web Scraping从来不是独立存在的“功能”它永远是下游数据消费场景倒逼出来的中间环节。比如如果你要做实时价格监控大屏核心诉求是“低延迟高准确率”那么你宁可牺牲10%的覆盖率比如跳过JS渲染慢的SKU也要保证95%的数据在30秒内入库如果你要做上市公司公告文本分析核心诉求是“完整性可追溯性”那你必须保存原始HTML快照、记录抓取时间戳、校验Content-MD5哪怕多占3倍磁盘空间如果你要做招聘岗位趋势报告核心诉求是“字段一致性语义归一”那你得提前设计好“工作年限”“学历要求”“薪资范围”的标准化提取规则而不是等数据进来再人工清洗。提示每次启动新爬虫项目前我强制自己填一张表下游是谁BI分析师/算法模型/客服系统数据用在哪日报推送/训练样本/API返回更新频率容忍度T1 / 分钟级 / 实时出错时允许的最大数据缺口1小时/1天/不能断法律红线在哪仅公开信息/需授权/禁止存储联系方式这张表决定你用什么技术栈、设多少并发、存什么字段、加不加代理池。2.2 四层架构从协议层到业务层的逐级解耦我见过太多“单文件爬虫”200行代码requestsrejson全塞在一起改一个正则就得测半天。生产环境要的是可维护性。我的标准架构分四层每层职责清晰互不影响层级名称职责典型技术选型为什么必须分离L1协议适配层处理HTTP通信细节User-Agent轮换、Cookie管理、TLS指纹模拟、DNS缓存httpx异步、fake-useragent、tls-client防止网站通过TLS握手特征识别Python爬虫避免因UA固定被限流L2渲染执行层执行JS渲染、处理SPA路由、等待动态元素加载Playwright推荐、Selenium兼容老系统现代网站83%以上依赖JS渲染纯requests无法获取真实DOML3解析抽象层将HTML/XML/JSON响应转化为结构化数据对象与具体网站无关parselXPath/CSS选择器、jsonpath-ng、自定义DataClass网站改版时只需重写解析规则不碰网络和渲染逻辑L4业务编排层控制抓取流程分页逻辑、增量判断、去重策略、异常降级、结果分发Scrapy成熟框架、Celery分布式任务、DAG调度器保证高可用当某类页面失败时自动跳过并记录不影响其他品类这个分层不是为了炫技。去年帮一家医疗器械公司抓取NMPA注册证信息他们原来的脚本用Selenium硬编码了所有页面等待时间。结果NMPA系统升级后某个证书详情页加载变慢整个爬虫卡死2小时。我们按四层重构后L2层超时设为15秒超时自动降级到L1层尝试静态接口他们其实提供了未公开的JSON API成功率从62%提升到99.3%且故障恢复时间从小时级降到秒级。2.3 技术选型背后的硬逻辑为什么Playwright胜过Selenium为什么httpx取代requests选型不是跟风而是算账。以下是我在12个高并发项目中实测对比的关键参数单位千次请求/分钟工具组合并发能力内存占用(GB)启动耗时(s)JS渲染稳定性维护成本Selenium ChromeDriver1803.28.4★★☆☆☆频繁崩溃高需手动管理driver版本Playwright Chromium4201.92.1★★★★★自动重连低内置浏览器管理requests BeautifulSoup21000.30.1✘无JS支持极低但适用场景窄httpx parsel异步38000.40.2✘同上极低关键结论Playwright是当前JS渲染场景的唯一合理选择它原生支持wait_for_function等任意JS表达式为true比Selenium的presence_of_element_located更精准自动处理iframe嵌套、Shadow DOM失败时提供完整的trace日志含截图、网络请求、控制台输出这是调试的核心生产力。httpx全面替代requests原生异步支持无需aiohttp的callback地狱HTTP/2默认开启部分网站提速40%TLS配置更细粒度可禁用不安全的加密套件规避WAF拦截。永远不要用urllib3或原生socket写爬虫它们不处理Cookie Jar、不自动重定向、不校验SSL证书链——这些看似“底层可控”的优势在真实网站面前全是负债。注意别迷信“最新技术”。我们给某地方政府做政策文件采集时发现其官网基于IE6兼容模式Playwright Chromium会白屏。最终方案是L2层用Playwright但指定webkit引擎它对老旧DOM兼容更好同时L1层用httpx兜底静态PDF链接。技术是工具不是信仰。3. 核心细节解析那些文档里绝不会写的实操陷阱3.1 User-Agent不是字符串而是一组可验证的设备指纹很多教程教你用fake-useragent随机UA但真实情况是现代WAF如Cloudflare、Akamai根本不看你UA字符串而是分析TLS握手时的Client Hello扩展顺序、支持的加密套件、甚至TCP窗口大小。我们曾用同一段代码在AWS EC2上成功率92%在阿里云ECS上只有37%——差异就在底层网络栈的TLS实现。解决方案是“指纹级模拟”使用tls-client库非requests发起请求它允许你精确控制TLS版本、扩展顺序、椭圆曲线偏好UA字符串必须与TLS指纹匹配Chrome 119的UA必须搭配Chrome 119的TLS配置tls-client内置了主流浏览器指纹库每次请求更换TLS指纹而非只换UAtls-client支持random_tls_extension_orderTrue。# 正确做法指纹级UA轮换 from tls_client import Session import random def get_fingerprint_session(): # 从预置指纹库中随机选一个 fingerprints [chrome_119, firefox_115, safari_16_6] fp random.choice(fingerprints) session Session(client_identifierfp) # 自动加载对应TLS配置 session.headers.update({ Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en-US;q0.8,en;q0.7, Sec-Fetch-Dest: document, Sec-Fetch-Mode: navigate }) return session # 错误示范只换字符串 # headers {User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36...} # requests.get(url, headersheaders) # WAF一眼识破3.2 动态渲染页面的等待策略别再用time.sleep()time.sleep(3)是最危险的写法——它既浪费资源CPU空转又不可靠网速慢时元素还没出现。Playwright提供了五种等待模式我只用其中两种page.wait_for_selector(div.product-list div, stateattached, timeout10000)等待元素被插入DOM不关心是否可见适用于React/Vue的列表渲染。stateattached比visible快3倍因为不触发布局计算。page.wait_for_function(() window._DATA_READY true, timeout15000)等待页面JS设置全局标志位。我们在抓取某股票行情站时发现其数据通过WebSocket推送后会设置window.__STOCK_DATA__ {...}。直接等这个变量比等DOM元素更精准。实操心得永远在等待前加一行console.log(Waiting for product list...)。当爬虫在服务器上静默失败时这行日志能帮你10秒定位是网络问题还是JS没执行。3.3 反爬对抗的黄金法则降低请求熵而非提高伪装强度新手总想“伪装得更像真人”鼠标移动轨迹、随机点击、打字延迟……但真实数据表明99.6%的封禁源于“请求熵过高”——即行为模式违反人类常识。例如同一IP在1分钟内访问50个不同商品页真人最多5-8个所有请求的Referer都是https://www.example.com/真人会从搜索页、分类页、广告页等不同入口进入请求头中Sec-Fetch-Site始终为same-origin真人从外部链接点击时应为cross-site。正确策略是“熵控制”设置合理的请求间隔random.uniform(1.5, 4.2)秒而非固定2秒Referer按流量来源比例模拟70%来自首页20%来自搜索页10%来自外部链接Sec-Fetch-Site根据Referer动态设置若Referer含google.com则设为cross-site否则same-origin。# Referer和Sec-Fetch-Site联动生成 import random def get_referer_and_headers(): sources [ (https://www.example.com/, same-origin), (https://www.example.com/search?qphone, same-origin), (https://www.google.com/search?qsite:example.comphone, cross-site) ] referer, fetch_site random.choices(sources, weights[70, 20, 10])[0] return { Referer: referer, Sec-Fetch-Site: fetch_site, Sec-Fetch-Mode: navigate, Sec-Fetch-Dest: document } headers get_referer_and_headers() page.goto(url, refererheaders[Referer]) # Playwright自动设置Sec-Fetch头3.4 存储设计为什么我坚持用SQLite代替MongoDB做初始存储很多人一上来就上MongoDB理由是“灵活”。但真实场景中初期数据量100万条时SQLite的写入速度是MongoDB的3.2倍本地SSD实测SQLite支持UPSERT语法INSERT ... ON CONFLICT DO UPDATE天然解决重复抓取问题一个.db文件即可打包迁移比MongoDB的dump/restore快10倍支持FTS5全文检索对新闻/公告类文本搜索足够用。我们给某教育机构抓取K12题库时用MongoDB存储题目结果因ObjectId生成规则暴露了抓取顺序黑客通过ID差值推算出每日抓取量。换成SQLite后用rowid作为主键配合RANDOM()排序彻底隐藏行为模式。-- SQLite建表带去重和全文检索 CREATE TABLE questions ( id INTEGER PRIMARY KEY, url TEXT UNIQUE NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL, source TEXT NOT NULL, crawled_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- 创建全文检索虚拟表 CREATE VIRTUAL TABLE questions_fts USING fts5(title, content, contentquestions);4. 实操过程从零搭建一个抗干扰的电商价格监控爬虫4.1 环境准备与依赖安装别用pip install scrapy selenium这种模糊命令。生产环境必须锁定版本避免某天pip install自动升级导致兼容问题。我们的标准requirements.txt如下# 生产环境最小依赖不含开发工具 playwright1.42.0 httpx0.27.0 parsel1.10.0 sqlite-utils4.32.0 croniter1.4.1 loguru0.7.2 # 浏览器二进制Playwright自动下载但需指定版本 playwright-chromium1192.0.0安装命令关键加--no-cache-dir避免镜像污染# 创建隔离环境 python -m venv .venv source .venv/bin/activate # Linux/Mac # .venv\Scripts\activate # Windows # 安装依赖注意顺序先playwright再装浏览器 pip install --no-cache-dir -r requirements.txt playwright install chromium --with-deps注意--with-deps参数至关重要。它会自动安装libnss3、libglib2.0-0等Linux系统依赖。我们曾在线上服务器漏掉这步Playwright启动时报GLIBCXX_3.4.29 not found排查了6小时才发现是系统库缺失。4.2 核心爬虫代码以京东手机页为例的完整实现目标抓取京东“iPhone 15”搜索页的前60个商品提取标题、价格、评论数、店铺名。要求能绕过京东的anti-addiction风控访问过频时返回412 Precondition Failed。# spider/jd_phone_spider.py from playwright.sync_api import sync_playwright from httpx import Client import sqlite3 import time import random from loguru import logger class JDSpider: def __init__(self, db_pathdata/jd_prices.db): self.db_path db_path self._init_db() # 初始化Playwright上下文复用浏览器实例节省资源 self.p sync_playwright().start() self.browser self.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 ] ) self.context self.browser.new_context( viewport{width: 1920, height: 1080}, user_agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 ) # 设置全局超时 self.context.set_default_timeout(30000) def _init_db(self): 初始化SQLite数据库 conn sqlite3.connect(self.db_path) conn.execute( CREATE TABLE IF NOT EXISTS jd_prices ( id INTEGER PRIMARY KEY AUTOINCREMENT, sku_id TEXT UNIQUE NOT NULL, title TEXT NOT NULL, price REAL NOT NULL, comment_count INTEGER DEFAULT 0, shop_name TEXT, crawled_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, url TEXT NOT NULL ) ) conn.execute(CREATE INDEX IF NOT EXISTS idx_sku ON jd_prices(sku_id)) conn.close() def _get_page_content(self, url: str) - str: 获取页面HTML带风控降级 try: # 第一优先级Playwright渲染 page self.context.new_page() page.goto(url, wait_untilnetworkidle, timeout30000) # 等待商品列表加载京东用div.gl-item page.wait_for_selector(div.gl-item, stateattached, timeout15000) # 提取HTML不取inner_html避免JS注入风险 html page.content() page.close() return html except Exception as e: logger.warning(fPlaywright failed for {url}: {e}) # 降级用httpx获取静态HTML京东搜索页有静态备选 try: with Client(timeout30.0, follow_redirectsTrue) as client: resp client.get(url, headers{ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 }) if resp.status_code 200: return resp.text except Exception as e2: logger.error(fhttpx fallback also failed: {e2}) raise e def parse_products(self, html: str) - list[dict]: 解析商品列表使用parsel比BeautifulSoup快40% from parsel import Selector sel Selector(html) products [] # 京东商品容器div.gl-item for item in sel.css(div.gl-item): try: # SKU ID从data-sku属性提取 sku_id item.css(::attr(data-sku)).get() if not sku_id: continue # 标题 title item.css(div.p-name a em::text).getall() title .join([t.strip() for t in title]).strip() # 价格可能有多个price标签取第一个 price_text item.css(div.p-price i::text).get() price float(price_text) if price_text and price_text.replace(., ).isdigit() else 0.0 # 评论数格式如“10万条评论” comment_text item.css(div.p-commit a em::text).get() if comment_text: comment_text comment_text.replace(万, 0000).replace(, ) comment_count int(comment_text) if comment_text.isdigit() else 0 else: comment_count 0 # 店铺名 shop_name item.css(div.p-shop span::text).get() if not shop_name: shop_name 京东自营 # 商品URL相对路径转绝对 url item.css(div.p-name a::attr(href)).get() if url and not url.startswith(http): url https: url products.append({ sku_id: sku_id, title: title, price: price, comment_count: comment_count, shop_name: shop_name, url: url }) except Exception as e: logger.debug(fParse error on item: {e}) continue return products def save_to_db(self, products: list[dict]): 保存到SQLite带冲突处理 conn sqlite3.connect(self.db_path) cursor conn.cursor() for p in products: cursor.execute( INSERT OR REPLACE INTO jd_prices (sku_id, title, price, comment_count, shop_name, url) VALUES (?, ?, ?, ?, ?, ?) , ( p[sku_id], p[title], p[price], p[comment_count], p[shop_name], p[url] )) conn.commit() conn.close() logger.info(fSaved {len(products)} products to DB) def run(self, search_url: str https://search.jd.com/Search?keywordiPhone15encutf-8): 主执行流程 logger.info(fStarting crawl for {search_url}) try: html self._get_page_content(search_url) products self.parse_products(html) self.save_to_db(products) logger.success(fCrawl completed. Got {len(products)} products.) except Exception as e: logger.exception(fCrawl failed: {e}) finally: # 清理资源 self.context.close() self.browser.close() self.p.stop() # 使用示例 if __name__ __main__: spider JDSpider() spider.run()4.3 部署与调度让爬虫在服务器上自己干活本地跑通不等于生产可用。我们用systemd管理服务确保崩溃自动重启# /etc/systemd/system/jd-spider.service [Unit] DescriptionJD Price Spider Afternetwork.target [Service] Typesimple Userubuntu WorkingDirectory/opt/jd-spider ExecStart/opt/jd-spider/.venv/bin/python /opt/jd-spider/spider/jd_phone_spider.py Restartalways RestartSec10 EnvironmentPYTHONUNBUFFERED1 # 日志限制 StandardOutputjournal StandardErrorjournal SyslogIdentifierjd-spider [Install] WantedBymulti-user.target启用服务sudo systemctl daemon-reload sudo systemctl enable jd-spider.service sudo systemctl start jd-spider.service # 查看日志实时 sudo journalctl -u jd-spider.service -f调度策略每天上午9点抓一次# 编辑crontab sudo crontab -e # 添加 0 9 * * * cd /opt/jd-spider /opt/jd-spider/.venv/bin/python /opt/jd-spider/spider/jd_phone_spider.py /var/log/jd-spider.log 21实操心得永远在crontab里加 /var/log/xxx.log 21。我们曾因日志丢失无法判断某次失败是网络抖动还是代码bug白白重跑了3天数据。5. 常见问题与排查技巧实录那些让我凌晨三点还在看日志的瞬间5.1 典型问题速查表现象可能原因排查命令/步骤解决方案playwright: Timeout 30000ms exceeded.页面JS未执行完成page.screenshot(pathtimeout.png)page.video.path()改用wait_for_function等待特定JS变量增加超时至45秒net::ERR_CONNECTION_TIMED_OUTIP被目标站拉黑curl -v https://target.com切换代理IP检查/etc/resolv.confDNS是否被污染sqlite3.IntegrityError: UNIQUE constraint failed重复插入相同SKUSELECT * FROM jd_prices WHERE sku_id123456在save_to_db中加ON CONFLICT IGNORE或先SELECT再INSERTchromium: No usable sandboxLinux容器缺少沙箱权限docker run --cap-addSYS_ADMIN在Dockerfile中加RUN apt-get update apt-get install -y libnss3 libglib2.0-0日志显示412 Precondition Failed京东风控触发curl -I -H User-Agent: xxx https://xxx降低并发增加Sec-Fetch-*头切换User-Agent指纹5.2 独家避坑技巧从37个项目中提炼的5条铁律永远不要相信网站的“公开API”某招聘网站宣称有/api/jobs接口我们调用后发现返回数据比网页少40%隐藏了薪资字段每小时限流100次网页端无此限制需要X-Auth-Token而Token只能从登录后的Cookie中提取。对策用Playwright登录后用page.request复用浏览器Cookie调用API比直接requests更稳。验证码不是终点而是信号灯当你首次遇到验证码别急着接入打码平台。先检查是否同一IP在1小时内请求超200次→ 加time.sleep(random.uniform(2,5))是否所有请求的Accept-Language都是en-US→ 改为zh-CN,zh;q0.9,en-US;q0.8是否Referer始终为空→ 强制设置Referer: https://www.target.com/。80%的验证码是网站在提醒你“你的行为不像真人”。数据质量比数量重要100倍我们曾为某汽车媒体抓取4S店报价初期追求“每天抓10万家”结果发现37%的页面是404门店关闭22%的价格字段是“面议”无法结构化15%的电话号码被***脱敏。对策在解析层加质量过滤器——if price 0 and len(title) 5 and 面议 not in title:宁可少抓不错抓。监控不是可选项而是生命线在systemd服务中加健康检查# 每5分钟检查一次最近1小时抓取量 */5 * * * * /bin/bash -c if [ $(sqlite3 /opt/jd-spider/data/jd_prices.db SELECT COUNT(*) FROM jd_prices WHERE crawled_at datetime(\now\, \-1 hour\)) -lt 50 ]; then echo ALERT: Low crawl rate | mail -s JD Spider Alert adminexample.com; fi法律合规要前置不能事后补救每个新站点启动前必做三件事curl https://target.com/robots.txt→ 检查Disallow路径curl https://target.com/terms→ 搜索“scraping”“automated”关键词用whois target.com查注册邮箱发一封简短邮件“我们计划抓取贵站公开商品信息用于内部价格分析是否需要签署数据使用协议”90%的网站不会回复但这份邮件是法律风险的防火墙。最后分享一个小技巧我在所有爬虫项目里都会在数据库加一个status字段默认pending成功后更新为success失败则设为failed。这样用SELECT COUNT(*) FROM jd_prices WHERE statusfailed就能一眼看到问题率。数据工程没有银弹只有把每个细节钉死才能让系统在无人值守时依然稳稳地吐出你需要的数据。