YAOTU INSIGHTS

Scrapy采集京东商品:解析、渲染与并发调优全攻略

Scrapy采集京东商品:解析、渲染与并发调优全攻略
简介基于Scrapy框架的京东商品数据爬虫项目代码精简、文档齐全适合爬虫入门者、高校学生用于课程设计、毕业设计或快速搭建电商数据采集原型。项目经过完整测试并获导师认可可直接运行或二次开发。资源包含27个文件以9个Python源码为核心覆盖items、pipelines、middlewares、spiders等Scrapy关键模块另有pyc编译文件、xml工程配置、scrapy.cfg及README说明文档整体压缩包仅27KB便于学习阅读。目前已有44人浏览学习。对希望掌握Scrapy框架、理解爬虫目录结构的学习者而言这套资源提供了从请求发送、数据解析到管道存储的完整示例配合文档可快速上手也能作为课程设计或毕业设计的起始模板在此基础上便捷扩展其他功能。内容来源于网络分享版权归原作者所有下载后请合理使用。1. 京东页面对 Scrapy 的考验不只是反爬还有状态管理大多数 Scrapy 入门教程喜欢拿静态页面练手而京东恰恰是“看起来像静态、实际上拆成四五次请求”的典型列表页首屏数据藏在内嵌 JSON 里价格走单独接口部分字段要等 JS 渲染后才出现在 DOM 中。直接把response.xpath打在商品标题上往往能取到结果但稍微换一个品类或翻页参数字段就大面积缺失。这个标题要解决的是在 Scrapy 的规则调度框架内把京东商品页的数据链路完整接住列表页解析、详情页补全、动态渲染、并发控制、数据管道。内容面向已经跑通过基本 Scrapy 项目、想把它推到“可维护的工程资料”程度的开发者。适合的人群是把爬虫当数据结构问题处理的人而不是只想抓一次数据就扔掉的脚本党。抓下来只是第一步字段稳定、请求可追溯、失败可重放才是这个项目真正的价值所在。2. 列表页、详情页与价格接口用 Scrapy 回调链拆解数据源京东商品搜索页虽然返回的是 HTML但有效数据集中在script标签里的__INITIAL_STATE__变量中。这个变量不是标准 JSON而是 JSBridge 渲染前写入页面的状态快照。相比直接解析 HTML 节点从内嵌 JSON 里取值的好处是结构稳定不随页面改版而频繁变动坏处是字段层级深且某些值是null或省略的。常见做法是先用 XPath 取出 script 文本再按 JSON 子串做解析最后兜底从 DOM 提取标题。2.1 列表页解析优先读内嵌 JSONDOM 解析作为兜底import json def parse_list(self, response): # 取出包含 __INITIAL_STATE__ 的 script 标签文本 script response.xpath( //script[contains(text(), __INITIAL_STATE__)]/text() ).get() if not script: self.logger.warning(no init state: %s, response.url) return start script.index({) # 去掉变量名、等号和结尾分号得到完整的 JSON 字符串 state json.loads(script[start:].strip().rstrip(;)) # 这个路径对应商品列表数据京东不同页面结构略有差异 items state[goods][list] for item in items: product { sku_id: item.get(skuId), title: item.get(title), price_url: https://p.3.cn/prices/mgets?skuIdsJ_%s % item.get(skuId), detail_url: https://item.jd.com/%s.html % item.get(skuId), } # 详情页一旦通过价格回调会把数据并回 product yield scrapy.Request( urlproduct[detail_url], meta{product: product}, callbackself.parse_detail, )这段代码的关键是拆两步走先解析列表页拿到 skuId再用 skuId 拼接详情页和价格接口。meta参数的作用在于把当前请求的数据传递到下一个回调函数中避免重新解析一遍列表页。很多初学者会在parse_detail里再次请求列表页拿数据这既浪费流量又增加被封风险。script[start:].strip().rstrip(;)这行是为了去掉 JS 变量声明后的分号因为有些环境下原始文本末尾会残留;。这个方案依赖__INITIAL_STATE__在当前京东页面版本中存在如果后续页面改用其他变量名XPath 的contains(text(), __INITIAL_STATE__)就匹配不到。遇到这种情况优先看页面源代码里还有没有其他以__开头的全局状态变量而不是直接去改json.loads的逻辑。2.2 详情页补全字段而不是从零开始列表页已经提供了标题和 skuId但商品详情页里更有价值的信息是品牌、产地、规格参数这部分只在详情页的规格表或页面 JSON 中存在。在parse_detail中要做的是把已有数据和新增字段合并不要重新构造一个 product 对象。def parse_detail(self, response): product response.meta[product] # 详情页规格参数通常在 ul classparameter2 里 params {} for li in response.xpath(//ul[contains(class, parameter2)]/li): text li.xpath(string(.)).get() if text and in text: key, value text.split(, 1) params[key.strip()] value.strip() product[brand] params.get(品牌) product[origin] params.get(产地) product[detail_params] params yield product这里要注意response.meta默认在重定向时会被丢弃如果京东对无 Cookie 请求返回 302 到验证页面meta就丢了。解决办法是在Request里设置dont_process_responseTrue或者显式带上meta{handle_httpstatus_all: True}。我一般会在 settings 里把REDIRECT_ENABLED保持开启但在详情页回调前给对方一个合理的 User-Agent这样触发验证码重定向的概率会低很多。2.3 价格接口单独请求失败不影响商品数据落库价格是京东页面中变动最频繁的数据而且经常与商品详情页不在同一个域名下。专门拿p.3.cn的接口来做价格采集可以让整个爬虫的关注点更集中。这个接口返回的是 JSON 数组结构为[{p: 4999.00, m: 5999.00}]其中p是当前售价m是原价。def parse_price(self, response): product response.meta[product] try: data json.loads(response.text) product[current_price] data[0].get(p) product[original_price] data[0].get(m) except (IndexError, KeyError, TypeError): # 价格接口偶发空数组保留默认值 product[current_price] None yield product这个回调因为是独立请求即使价格接口暂时不可用商品信息已经通过meta带了过来不会导致整条数据丢失。这正好体现 Scrapy 回调链设计的价值数据的每个组成部分都有独立的状态和失败语义而不是一个parse函数包打天下。在运行scrapy crawl jd_product时日志中每个 URL 的处理时长和失败状态都可以单独观测。字段来源失败策略skuId列表页__INITIAL_STATE__跳过该商品标题列表页内嵌 JSONDOM 兜底品牌/产地详情页ul classparameter2字段留空当前价p.3.cn价格接口置 None 保留商品3. 动态渲染与 iframe 内容在 Scrapy 中集成 Playwright 的取舍不是所有京东数据都藏在接口和 HTML 里。商品评价的概览、某些营销活动页的实时库存、以及登录后可见的价格区间通常由 JS 在浏览器环境中执行后渲染。收到“低于 3000 元才展示”之类的促销逻辑服务器返回的 HTML 里就没有那个数字这时候要么花力气去逆向它的 XHR 接口要么直接用浏览器渲染。接口逆向对反爬升级的抵抗力差而浏览器渲染方案虽然重却更稳定。3.1 什么时候才需要 Playwright而不是继续加解析规则判断标准很直接把response.text存下来直接 grep如果发现目标数据根本不在里面再去谈渲染。很多人一上来就在 Scrapy 里挂无头浏览器结果只是把本来能通过接口拿到的数据拖慢了三倍。京东商品详情页 90% 以上的字段可以通过第 2 章的请求链路拿到真正需要渲染的是“滚动加载后出现的用户评价摘要”和部分店招模块。常见做法是单独为这些区块配置一个 Playwright 请求而不是把整个站点的请求都走浏览器。Scrapy 有一个scrapy-playwright插件可以在不改变原有回调结构的前提下把特定 URL 交给浏览器处理。配置的核心就在 settings 和 Request meta 上。# settings.py DOWNLOAD_HANDLERS { http: scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler, https: scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler, } TWISTED_REACTOR twisted.internet.asyncioreactor.AsyncioSelectorReactor# items.py 中的爬虫代码片段 yield scrapy.Request( urlreview_url, callbackself.parse_review, meta{ playwright: True, playwright_page_methods: [ {method: wait_for_selector, selector: .comment-item}, ], }, )playwright置为 True 后该请求会走浏览器处理playwright_page_methods里声明的是页面加载完成后需要等待的选择器这里尤其注意不要用固定延时。无条件sleep(5)在慢网络下不充分在快网络下浪费 4 秒用wait_for_selector让页面自行告诉爬虫“我准备好了”。等待条件里还可以加入statevisible确保元素真的渲染到可视区域某些懒加载组件仅仅存在于 DOM 但未显示时也可能被误判为成功。3.2 iframe 里的数据切 frame 比改 URL 靠谱京东的店铺首页和部分活动页使用 iframe 嵌入子页面。iframe 的特性是 URL 不变但内容由另一个文档承载直接 XPath 不到任何节点。处理 iframe 的常见做法是用 Playwright 的frame_locator切换到对应的子页面再继续等待和提取。frame_scrapy项目中常见的问题是把 iframe 的 src 单独拉出来请求如果 src 带签名参数单独请求会签名失效。meta{ playwright: True, playwright_page_methods: [ { method: frame_locator, selector: iframe#shopInfo, methods: [ {method: wait_for_selector, selector: .shop-name}, {method: text_content, selector: .shop-name}, ], }, ], }上面这种方式适合简单的取值。我更推荐的做法是在parse_review里直接拿page对象用 Python 原生控制 iframe因为这样能拿到完整的上下文调试时也能打印控制台报错。async def parse_review(self, response): page response.meta.get(playwright_page) if not page: self.logger.error(playwright page is None) return frame page.frame_locator(iframe#shopInfo) shop_name await frame.locator(.shop-name).text_content() yield {shop_name: shop_name.strip()}frame_locator返回的是一个FrameLocator它的选择器范围天然限定在指定 frame 内不会误匹配主页面里同名的元素。京东的 iframe id 可能会随页面改版变化这个值要从response.text里先确认存在再写进代码。直接写死选择器而不过滤id-contains会导致改版后全部失效建议用frame_locator(iframe[src*shop])这类属性前缀匹配提升容错。4. 并发调优与代理 IP 池Scrapy 的稳定抓取要改的五个参数Scrapy 默认的并发设置偏向于“快”但京东的限流策略恰恰对单 IP 的请求频率最敏感。在爬取商品数据这类场景中稳定优于速度因为一旦触发滑块验证重试所花的时间远超通过降速节省的时间。这里先列出一组经验起点再逐个解释调整方向。参数建议值说明CONCURRENT_REQUESTS8~16同一域名下的最大并发数DOWNLOAD_DELAY1.0~2.0同一 IP 下两个请求的时间间隔RANDOMIZE_DOWNLOAD_DELAYTrue在 0.5~1.5 倍之间随机化间隔AUTOTHROTTLE_ENABLEDTrue根据响应延迟动态调节频率COOKIES_ENABLEDFalse尽量不携带持久 Cookie 请求CONCURRENT_REQUESTS不是越高越好当响应体体积大且下载带宽有限时高并发反而让单请求变慢整体吞吐下降。京东的反爬日志里经常出现同一 IP 在 5 秒内请求超过 20 次就被临时封禁的情况把并发降到 8 并开启DOWNLOAD_DELAY后单机跑一两个小时仍然稳定。4.1 User-Agent 池不能只换浏览器版本要换内核特征很多 UA 池只是把 Chrome 版本号改成120、121、122但所有 UA 的 Sec-Ch-UA、Sec-Fetch-Site 等请求头完全一致服务端通过请求头组合特征很容易识别出这是同一套库发出的请求。维护 UA 池的正确做法是让每个 UA 对应一组完整的请求头模板至少包含User-Agent、Accept-Language、Sec-Fetch-Dest、Sec-Fetch-Mode、Sec-Fetch-Site。class RandomUAwareMiddleware: UA_TEMPLATES [ { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Accept-Language: zh-CN,zh;q0.9, Sec-Fetch-Site: same-origin, }, { User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15, Accept-Language: zh-CN,zh;q0.8,en;q0.6, Sec-Fetch-Site: none, }, ] def process_request(self, request, spider): template random.choice(self.UA_TEMPLATES) for key, value in template.items(): request.headers[key] value这个中间件的价值在于把请求头的选择逻辑从settings.py中抽离出来每个请求都有一致但不同的头组合。需要注意Sec-Fetch-Site的值不能乱填它表示请求来源与目标站点的关系京东站内请求通常是same-origin如果填成cross-site会触发更严格的风控校验。京东有些接口还校验Referer这种情况下要在被校验的请求上显式设置Referer为商品页 URL而不是依赖中间件统一处理。4.2 代理 IP 池的接入位置放在下载中间件而不是调度器常见做法是下载中间件的process_request里为每个请求选一个出口 IP。但要注意频繁切换 IP 并不会提高成功率反而可能因为 IP 段变化触发“新设备登录”校验。更稳妥的策略是一个 IP 负责一个品类页面的前 N 个请求完成后整体调换。用request.meta[proxy]传 IP 地址的方式实现粒度控制而不是在中间件里随机换。class ProxyMiddleware: def process_request(self, request, spider): pool spider.crawler.settings.get(PROXY_POOL) if request.meta.get(force_proxy): # 指定代理用于已经触发限流的请求重试 request.meta[proxy] request.meta[force_proxy] else: # 同一爬虫进程内给一个固定代理减少切换频率 if not hasattr(spider, _current_proxy): spider._current_proxy pool.next() request.meta[proxy] spider._current_proxy这段逻辑的关键在spider._current_proxy它让整个爬虫进程内保持同一个出口 IP而不是每个请求都换。当某个请求返回 403 或滑块页时通过重试中间件把force_proxy带上强制该请求换一个新 IP 重新执行。这样做的好处是正常请求不需要频繁切换 IP显著降低了被判定为异常的概率。代理 IP 池的维护频率取决于抓取强度单机低并发场景下每小时换一次池子足矣。4.3 请求失败的语义要区分超时、连接异常与反爬拦截Scrapy 的RetryMiddleware默认对所有 500 系列和超时异常重试两次但这个策略对京东并不合适。京东的接口偶尔返回 200 但内容为空 JSON这种请求即使重试一百次也一样空。更好的做法是用自定义异常类区分失败类型控制重试次数和重试间隔。class JDRetryMiddleware(RetryMiddleware): def process_response(self, request, response, spider): if response.status in self.retry_http_codes: # 京东搜索页偶尔返回 302 到验证码页重试时降速 reason response.status spider.crawler.engine.close_spider(spider, banned_on_%s % reason) return response return super().process_response(request, response, spider)这个写法不建议直接复制进生产环境它只是表达一个思路当检测到验证码跳转时与其快速重试把 IP 打上更高风控标签不如主动暂停整个爬虫。暂停比换 IP 更快因为当前 IP 已经在风控名单里继续请求只会扩大封禁范围。5. 数据管道落地与追溯字段校验和断点恢复的具体做法Scrapy 的 Item Pipeline 是数据的出口也是最后一道质量闸门。京东商品数据的常见问题不是“抓不到”而是“抓到了错误的数据”。比如促销价和原价倒挂、品牌字段混入店铺名、skuId 被解析成科学计数法的数字型。这些问题必须在下游存储之前做结构化校验否则数据进了数据库再清洗成本和可信度都会打折扣。5.1 在 Pipeline 里做价格一致性校验价格是京东数据里最容易被业务方拿去直接做分析的值那就在管道里给价格做规则校验。促销价不能大于原价价格不能为负数或小数位超过两位这些规则可以先写在本地配置里后续调整不需要改动代码逻辑。class PriceValidationPipeline: def process_item(self, item, spider): p item.get(current_price) m item.get(original_price) if p is not None and m is not None: try: p_float float(p) m_float float(m) except (TypeError, ValueError): spider.crawler.stats.inc_value(price_validation/not_number) return item if p_float m_float: spider.crawler.stats.inc_value(price_validation/inverted) # 不丢弃保留原数据并标记异常方便人工复核 item[price_warning] True return itemspider.crawler.stats.inc_value是 Scrapy 内置的统计器记录每个异常类型发生的次数。运行结束后可以在scrapy crawl jd_product -s LOG_LEVELINFO的输出末尾看到这些统计值比盯着日志逐行人工排查高效得多。这个方案的价值在于管道不是简单地“收数据入库”而是给数据附加了一条验证记录下游人员能直接看到哪些商品触发了价格告警。5.2 用scrapy parse做单页快速验证开发阶段不建议每次改完 XPath 都全量跑爬虫scrapy parse命令可以直接指定 URL 和解析函数做单页测试。scrapy parse https://item.jd.com/100012043978.html \ --callback parse_detail \ --spider jd_product \ --meta {product: {sku_id: 100012043978}}命令里的--meta会被打包成response.meta提供给回调函数使用这样不需要先把列表页跑完就能验证详情页解析逻辑是否正确。如果回调函数里依赖了parse_list产生的字段可以在--meta里临时补一个最小字典。这个用法在多人协作时特别有用负责详情页解析的人不需要关心列表页是否挂了只管把自己的回调调通。对于“基于 Scrapy 框架的京东爬虫实现完整资料”来说Pipelines 的字段校验规则、重试中间件的故障语义、以及每个解析函数的输入输出约定应该沉淀为项目 README 里最核心的内容。把这一次调试过程记录成docs/参数变更记录.md下次京东改版后可以通过比对字段变更历史快速确认影响范围而不是重新逆向一遍页面结构。本文还有配套的精品资源点击获取