Python 2.7 + Scrapy 1.4 老框架下的电商全量采集实践

发布时间:2026/10/20 11:11:00
Python 2.7 + Scrapy 1.4 老框架下的电商全量采集实践
简介这套电商数据爬虫系统基于Python 2.7与Scrapy 1.4框架构建专为采集京东、淘宝、天猫三大平台的商品信息设计适合需要获取大规模商品数据的开发者、市场分析师与学术研究者使用。系统核心机制是通过配置文件自定义检索关键词与用户Cookie从而灵活控制采集范围并模拟真实访问行为实现对商品标题、价格、详情、评价等全量信息的高效抓取。压缩包内共22个文件大小仅18KB其中包含12个Python源码文件覆盖爬虫核心模块、中间件、数据管道等另有Shell运行脚本、JSON配置文件、Scrapy配置及说明文档便于直接部署与二次修改。当前已有51人学习下载。借助这套代码可系统了解Scrapy工程的标准目录划分与多爬虫协作流程学习京东、淘宝、天猫三个站点爬虫的编写与调试方法掌握通过配置Cookie应对访问限制、使用CSV或MongoDB存储结果以及项目备份与恢复的常见技巧。对于爬虫初学者和小规模电商数据调研是一份轻量且实用的参考样例。1. 一套老框架电商爬虫为什么到今天还有人翻出来用一个标题上写着 Python 2.7 和 Scrapy 1.4 的爬虫系统第一反应通常是“这玩意还能跑”但恰恰是这种老组合在京东、淘宝、天猫的全量商品采集场景里依然活着而且活得挺好。原因很现实这三家平台的页面结构和数据接口虽然年年改版但核心的商品列表、详情字段、SKU 组合逻辑变化并不大早年沉淀下来的采集逻辑反而比频繁重写的版本更稳定。全量采集这个诉求落到工程上其实是三件事能稳定翻完所有分页、能把异步加载的数据接回来、能在中断后接着跑。标题里的“全量”不是指无限抓而是指在入口类目下尽可能无遗漏地覆盖所有商品。这套系统的价值在于它把三个平台的数据差异封装在一套框架里。京东走列表页加详情页的常规路径淘宝和天猫则依赖异步 JSON 接口和页面内嵌的初始化状态数据。Scrapy 1.4 虽然老但它的请求调度、去重队列、管道机制足够支撑这套逻辑。适合谁适合手头有电商比价、价格监控、选品分析需求但不想从零造轮子的开发者和数据分析人员。也适合那些维护着老项目、被 Python 2 迁移折腾过的人——你会在这里看到很多似曾相识的坑和对应的补法。下面从环境重建开始把这套系统的技术细节一步步拆开。2. 先把环境跑起来Python 2.7 Scrapy 1.4 的兼容层与依赖锁定2.1 版本锁死的现实原因为什么不用新框架重写Scrapy 1.4 发布于 2016 年左右绑定的 Twisted 版本也停留在老一代。现在新建项目肯定不会这么选但在当时Python 2.7 是数据分析生态的主流许多电商采集依赖的第三方库只发布过 py2 版本。重写不是不行而是成本不划算。你要把请求调度、去重逻辑、中间件全部迁移一遍期间还会踩到新框架对旧站点的兼容性问题——比如某些异步 JSON 接口的握手方式变化。所以现实的做法是保留 Scrapy 1.4 作为调度核心把兼容层补好让它能在 Python 2.7 上稳定运行。环境重建的第一步是准备 Python 2.7 的隔离环境。网上能找到的各版本共存方案中最省事的是用 pyenv。装好之后创建虚拟环境再把依赖装进去。这里有一份可用的依赖清单注意版本必须全部锁定否则 pip 会默认解析到新版本导致直接报错。pyenv install 2.7.18 pyenv virtualenv 2.7.18 ec-spider pyenv activate ec-spider pip install --upgrade pip20.3.4 pip install Twisted18.7.0 pip install Scrapy1.4.0 pip install pyOpenSSL19.1.0 pip install six1.12.0 pip install w3lib1.21.0 pip install lxml4.4.1 pip install parsel1.5.2这里几个版本号不是随便写的。Twisted 18.7.0 是支持 Python 2.7 的最后一个较稳版本Scrapy 1.4 依赖它的 reactor 机制做异步下载。w3lib 和 parsel 负责响应内容的解析这两个库如果升到新版接口签名会变Scrapy 1.4 的 Selector 内部调用会崩。所以装完后不要手痒做pip install -U一升全废。参数说明pyenv 安装 2.7.18 时如果系统是较新的 Linux可能会遇到编译 openssl 头文件找不到的问题需要先安装 libssl-dev。另外 pip 版本必须锁 20.3.4因为更高版本的 pip 已经不再支持 Python 2.7 的运行环境装上后 pip 直接无法执行。2.2 三个转换坑把 Python 3 时代的习惯改回 2.7 语法Python 2.7 和 Python 3 在字符串处理上差异最大这也是从新项目转过来的开发者最容易翻车的地方。第一个坑是 encode 和 decode 混用导致的 UnicodeDecodeError。电商页面返回的响应头经常不标准有的写 gb2312 实际是 gbk有的直接缺省。处理方式参考下面这段兼容代码# -*- coding: utf-8 -*- import re def safe_decode(raw_bytes, guess_charsetutf-8): # 优先尝试指定编码 try: return raw_bytes.decode(guess_charset) except UnicodeDecodeError: pass # 从响应头提取编码 m re.search(rcharset([\w-]), raw_bytes[:1024].decode(ascii, ignore)) if m: try: return raw_bytes.decode(m.group(1)) except (UnicodeDecodeError, LookupError): pass # 最后兜底用 gb18030能覆盖 gbk / gb2312 超集 return raw_bytes.decode(gb18030, ignore)在 Scrapy 1.4 里这个函数挂在 downloader middleware 的 process_response 里对 response.body 做统一编码修正后再传给解析函数。逻辑说明先尊重页面显式声明的编码声明错误时再扫码最后用 gb18030 兜底是因为它是 GB 系编码的超集能避免单字解码失败抛异常。参数说明guess_charset 默认 utf-8 是现在的通用标准ignore 参数保证即使有乱码字符也不会中断整个采集任务。第二个坑是字典的迭代方式。Python 3 的.items()返回的是视图对象Python 2.7 的.items()会生成一个列表当字典很大时内存会突然飙升。电商商品字典动辄几十万个字段这里必须用.iteritems()。# Python 2.7 兼容写法 for k, v in product_dict.iteritems(): # v 可能是嵌套 list要先做类型判断 if isinstance(v, list): v v[0] if v else None item[k] v第三个坑是 print 不能用括号。Python 2.7 里print(hello)实际上会被解析成元组打印这在调试日志时不会出错但会把输出的内容变成带括号的字符串结构。要在日志里看到干净的字段值需要写from __future__ import print_function在文件头部或直接用 logger.info。我一般选择后者因为 Scrapy 1.4 已经内置了良好的日志体系。2.3 现代电商站点的 HTTPS 连接给老框架打两个补丁这是整套系统里最“玄学”的部分。Scrapy 1.4 默认使用的 OpenSSL 版本较老而京东、淘宝的 TLS 配置已经更新到现代标准老版本在握手阶段会直接报SSLError: tlsv1 alert protocol version。光靠改设置不够必须升级 pyOpenSSL 并用 Scrapy 的 DOWNLOADER_CLIENT_TLS_METHOD 指定加密方式。在 settings.py 里做如下配置DOWNLOADER_CLIENT_TLS_METHOD TLSv1.2 # 禁用默认的证书校验因为部分商品图片域名的证书链不完整 DOWNLOADER_CLIENT_CONTEXTFACTORY core.compat.TLSCompatContextFactory对应的 core/compat.py 里实现一个兼容工厂类继承旧 ContextFactory 并替换 SSL 上下文生成逻辑# core/compat.py from scrapy.core.downloader.contextfactory import ScrapyClientContextFactory from OpenSSL import SSL class TLSCompatContextFactory(ScrapyClientContextFactory): def _get_context(self, methodNone): ctx SSL.Context(SSL.TLSv1_2_METHOD) # 关闭旧算法套件避免握手失败 ctx.set_cipher_list(HIGH:!aNULL:!MD5) ctx.set_options(SSL.OP_NO_SSLv2) ctx.set_options(SSL.OP_NO_SSLv3) return ctx效果说明补丁打上后响应状态码从 103 错误变为 200。参数说明set_cipher_list 里的 HIGH:!aNULL:!MD5 表示只启用高强度非匿名算法并排除 MD5 签名如果采集过程中仍有个别域名握手失败可以把 TLSv1_2_METHOD 换为 TLSv1_METHOD但大多数情况下保持 1.2 即可。这类补丁不追求覆盖所有场景目标是让超过九成的请求顺利通过。3. 全量采集的关键实现目录入口、翻页循环与异步数据回接3.1 先确定“全量”的边界从类目页构造采集种子电商平台商品动辄上亿直接遍历首页不现实。全量采集的真正落地路径是先抓取所有一级类目再从每个类目下取分页列表。这样“全量”指的是类目已定义的商品全集不是无限制穷举。种子 URL 的构造通常在 start_requests 里完成例如京东的类目导航页面里能提取到所有二级类目的 id然后拼成列表页 URL。# spiders/jd_product.py import scrapy from scrapy.http import Request class JdProductSpider(scrapy.Spider): name jd_product def start_requests(self): # 入口京东首页导航接口返回类目 JSON nav_url https://www.jd.com/ yield Request(nav_url, callbackself.parse_nav, meta{platform: jd}) def parse_nav(self, response): # 用 json 模块解析内嵌的导航数据 import json # 页面里有 var cid [...] 这样的变量块 nav_data response.xpath(//script[contains(text(), cids)]/text()).extract_first() if not nav_data: return cids re.findall(r(\d{5,8}), nav_data) for cid in cids: # 列表页 URL 模板page1 为起始 list_url https://list.jd.com/list.html?cat{cid}page1sortsort_totalsales15_desc.format(cidcid) yield Request(list_url, callbackself.parse_list, meta{cid: cid, page: 1})参数说明sortsort_totalsales15_desc 表示按销量排序这样可以在有限页数内优先采集到热门商品meta 里带 cid 和 page 是为了翻页时拼接下一页和做增量断点。逻辑说明nav_data 为空时直接 return避免空跑。3.2 翻页循环和终止条件不是拿不到下一页就停列表页的翻页方式各平台差异很大。京东相对规矩URL 上带 page 参数淘宝和天猫的搜索/类目列表页是异步加载的翻页靠 POST JSON 里的 page 字段。Scrapy 1.4 里做翻页要注意不要把“下一页无数据”当作终止条件因为电商页面在夜间或活动间隙经常出现部分页面空数据而真正的终点是页码超过最大值或商品 id 集合出现重复闭环。def parse_list(self, response): products response.css(li.gl-item) for prod in products: # 商品 id 可能出现在># spiders/tb_product.py import json import re def parse_list(self, response): raw response.xpath(//script[contains(text(), __INITIAL_STATE__)]//text()).extract_first() if raw: match re.search(rwindow\.__INITIAL_STATE__\s*\s*({.*?});, raw, re.S) if match: state json.loads(match.group(1)) # 商品 id 列表藏在 layers[0].items 里 for item in state[layers][0][items]: item_id item.get(itemId) or item.get(id) if item_id: yield Request( https://h5api.m.taobao.com/h5/mtop.taobao.detail.getdesc/1.0/?itemId{}.format(item_id), callbackself.parse_json_detail, meta{item_id: item_id} )这段的难点在 json 解析的鲁棒性。淘宝页面里 JSON 字符串经常混有单引号、未转义的反斜杠和 HTML 标签直接用 json.loads 必挂。我一般会先用正则把 JSON 块切出来再对每个引号前的反斜杠做转义。更稳妥的是先替换\\为\\\\把单引号换成双引号但注意不要破坏字符串内的 URL。参数说明meta 里的 item_id 用于拼后续的详情接口和做商品库主键。3.4 商品详情页的字段补齐主图、价格、SKU 合并列表页拿到的是基础信息价格、库存、SKU 组合通常要走二次请求。京东的价格是异步接口淘宝的价格藏在详情页的全局变量里。Scrapy 1.4 做这类二次请求时要注意控制并发量否则容易被限流。一个通用的采集 pipeline 里需要把三次请求的数据合并到同一个 item 再入库存。def parse_detail(self, response): sku response.meta[sku] name response.xpath(//div[classsku-name]/text()).extract_first().strip() # 价格走异步接口 price_url https://p.3.cn/prices/mgets?skuIdsJ_{}.format(sku) yield Request(price_url, callbackself.parse_price, meta{sku: sku, name: name}) def parse_price(self, response): import json price_data json.loads(response.text) yield { sku: response.meta[sku], name: response.meta[name], price: price_data[0].get(p), original_price: price_data[0].get(op), platform: jd }参数说明price_url 里的 J_ 前缀是京东价格接口的固定格式op 字段是划线价如果为空说明商品没有促销展示。SKU 信息在这套系统里没有继续拆解到颜色尺码维度原因在后面避坑章节单独说。这里的核心是把不同来源的字段在 item 层统一 schema入库时用 sku platform 做联合唯一键避免重复写。4. 反爬应对与频率控制三个平台的拦截差异和破解档位4.1 识别拦截特征验证码、滑块和 IP 封禁的三种信号电商平台的反爬不是一刀切而是分梯度。低频请求下几乎畅通无阻频率一过阈值就会依次出现请求结果里商品数量突然变少、页面标题变成“访问验证”、响应体里出现滑条组件代码。Scrapy 1.4 里应对这些信号的正确姿势是在 middleware 里预判而不是等请求失败后重试。class AntiBlockMiddleware(object): def process_response(self, request, response, spider): # 检测淘宝滑块响应里有 nc_ 前缀和滑块 js if bnc.js in response.body or bslide in response.body: raise IgnoreRequest(captcha triggered, skip this page) # 检测京东验证status200 但商品数量为 0 且无有效商品节点 if bgl-item not in response.body and bplist not in response.body: if b验证 in response.body: spider.crawler.engine.pause() time.sleep(300) spider.crawler.engine.unpause() return response这段逻辑的关键是“提前喊停”。当检测到滑块时不只是丢弃本请求还要对当前下载器做暂停几分钟处理。参数说明time.sleep(300) 是等待封禁冷却的经验值京东和淘宝的封禁时长通常在 10 到 30 分钟之间冷却太短没用太长浪费任务时间。这种方式虽然粗糙但在 Scrapy 1.4 下是保护 IP 不被拉黑的最直接手段。4.2 Cookie 池与登录态用半自动登录解决搜索接口的风控淘宝和天猫的搜索接口对未登录状态限制明显请求多了直接要扫码登录。纯采集方案里最常见的做法是半自动登录手动扫码一次拿到 Cookie存到本地爬虫启动时加载。Cookie 的有效期通常 1 到 3 天过期后采集任务会大面积失败。在 Scrapy 1.4 里用 cookies 中间件管理比较方便from scrapy.downloadermiddlewares.cookies import CookiesMiddleware class TaobaoCookieMiddleware(CookiesMiddleware): def process_request(self, request, spider): # 每 10 分钟刷新一次 cookie if not hasattr(spider, last_cookie_refresh): spider.last_cookie_refresh 0 if time.time() - spider.last_cookie_refresh 600: with open(cookies/taobao.json, r) as f: cookie_list json.load(f) request.cookies cookie_list spider.last_cookie_refresh time.time()参数说明600 秒是刷新间隔不是每次请求都读文件是为了降低 IO 和文件锁冲突概率。cookie 文件里存的是浏览器导出的 JSON 数组格式为 [{“name”: “…”, “value”: “…”}]。说明一点这些 Cookie 一旦失效刷新也没用需要重新扫码。4.3 User-Agent 池与下载延迟构建像“真人”一样的请求节奏Scrapy 1.4 默认的 UA 是 “Scrapy/1.4.0”这个 UA 在所有平台都会被直接识别。用 fake-useragent 太新旧依赖冲突最稳的是手动维护一份常见的浏览器 UA 列表。同时随机化下载延迟避免每次间隔固定被采样识别出机器行为。# settings.py 核心参数 DOWNLOAD_DELAY 3.0 DOWNLOAD_TIMEOUT 15 RANDOM_UA_TYPE random class RandomUAMiddleware(object): def process_request(self, request, spider): ua random.choice(UAS) request.headers[User-Agent] ua # 随机延迟 2-5 秒 time.sleep(random.uniform(2, 5)) return None这里有个技巧time.sleep 放在 process_request 里而不是依赖 DOWNLOAD_DELAY是指 DOWNLOAD_DELAY 是 Scrapy 内部固定的时间间隔不会随机波动反而更容易被识别。参数说明DOWNLOAD_TIMEOUT 设置 15 秒是为了快速失败避免某个慢接口长期占用下载器线程。RANDOM_UA_TYPE 指定池内随机保证同一个商品页的多次请求不要总用同一个 UA。5. 电商爬虫避坑老框架下的 4 个典型事故与排查路径5.1 现象淘宝详情页返回的 JSON 字段顺序随机变动采集到的数据经常出现某个字段缺失但页面展示正常。排查后发现淘宝详情页的内嵌 JSON 里字段顺序不固定有时 price 在最前有时 price 在 sku 之后。代码如果用state[data][price]按固定路径取值一旦字段位置变化就直接取不到。解决方式是先把 state 整体展开成一个扁平 dict再去取字段。def flatten_json(data, parent_key, sep_): import collections items [] for k, v in data.items(): new_key {}{}{}.format(parent_key, sep, k) if parent_key else k if isinstance(v, collections.MutableMapping): items.extend(flatten_json(v, new_key, sep).items()) elif isinstance(v, list): for idx, item in enumerate(v): items.extend(flatten_json(item, new_key sep str(idx), sep).items()) else: items.append((new_key, v)) return dict(items)原因分析页面模板组件化之后各模块的渲染顺序由服务端动态拼接导致 JSON 结构不稳定。解决逻辑是把多级嵌套拍平用 key 名搜索而不是固定路径。比如找价格就搜所有包含price的 key取第一个不是空字符串的。5.2 现象京东商品页翻页到最后几页重复循环列表翻页时 page 参数已经超过最大页码但页面仍返回 200 且展示的是最后一页的商品。系统会把同一批商品反复采集。原因京东的列表页对超出范围的 page 不返回 404而是静默降级为最后一页。解决方式不能只看状态码要看商品集合是否发生变化。def parse_list(self, response): current_skus set(response.xpath(//li/data-sku).extract()) prev_skus response.meta.get(prev_skus, set()) overlap len(current_skus prev_skus) / float(len(current_skus) or 1) if overlap 0.6: # 认为已经到底 return # 正常处理... yield Request(next_url, callbackself.parse_list, meta{prev_skus: current_skus})原因分析电商类的分页组件对页码边界处理很宽松宁可多渲染一次也不报错。解决逻辑是维护上一页的商品 id 集合计算重复率作为终止信号。这个技巧同样适用于天猫的搜索列表。5.3 现象Python 2.7 内存持续上涨直到 OOM采集跑到 2 小时左右内存从 300M 涨到 1.5G 然后被杀。原因Scrapy 1.4 的 response.body 如果被 lamba 函数或闭包引用就不会在请求完成后释放。尤其在 parse_detail 里用 lambda 做 price 回调的字段组装会导致整个 response 链无法被回收。解决方式是避免在 item 构造中使用任何闭包引用。# 错误写法示范绝对不要这么写 yield Request(price_url, callbacklambda r: self.parse_price(r, name)) # 正确方式用 meta 传参不用闭包 yield Request(price_url, callbackself.parse_price, meta{name: name})参数说明meta 传递的值会被 Scrapy 在新请求中复制两个请求生命周期独立不再互相引用。这个修复后同样运行 2 小时的采集任务内存曲线保持在 450M 左右。这是老框架下最值得记住的一条经验。5.4 现象同一批商品 id 在不同时间的字段内容不一致上午采的商品价格是 99下午重采变 109导致数据入库反复更新。这不算 bug但会造成大量写操作拖慢 pipeline。原因电商的活动价在一天内会切换拼多多的百亿补贴调价频率更高。解决方式是给每个 item 加 fingerprint 字段用商品 id 核心字段的哈希判断是否有变化没有变化就不写库。import hashlib def gen_fingerprint(item): content {}{}{}.format(item[sku], item[name], item[price]) return hashlib.md5(content.encode(utf-8)).hexdigest()这个策略就是后面要单独展开的增量采集核心。先记住结论全量采集不等于每次全量写库对比指纹后写增量既减少数据库压力也让采集结果更稳定。6. 收尾技能指纹去重与断点续爬让全量任务可以随时中断恢复电商爬虫跑几小时后机、断电、被管理员 kill 是常态没有断点续爬机制的采集系统等于没有完成度保障。Scrapy 1.4 的 job 目录机制可以保存请求队列配合指纹去重实现真正意义上的可恢复全量采集。启动命令里加-s JOBDIRjobs/jd_full后Scrapy 会定期把待抓取队列写入本地重启时自动加载。注意 JOBDIR 路径不能被程序自动清理否则白搭。断点恢复之外还要给 item 入库环节加上指纹判定。上面 5.4 里的 gen_fingerprint 函数要放到 pipeline 里在写入数据库前查一次指纹表class DuplicatePipeline(object): def process_item(self, item, spider): fp gen_fingerprint(item) # redis 或 sqlite 里存指纹不存在才写库 if not self.fp_store.exists(fp): self.fp_store.add(fp) # 写入主存储 self.db.insert(item) return item指纹表用 redis 的 set 结构最合适几十万商品也就几十 MB 内存。参数说明指纹内容要包含 sku、平台和价格和标题不包含采集时间和页面 url因为这两个字段每次必然变化会导致指纹永远不同。最后分享一个习惯每次启动任务前先跑 10 个页面的小批量试探确认 Cookie 有效、指纹库连接正常再放开全量跑。这个习惯救过我很多次比起闷头跑 3 小时后发现几百个请求全部 302多花两分钟做前置检查太值了。希望这份老框架下的实践拆解能帮到你。本文还有配套的精品资源点击获取