☰
Python 2.7 + Scrapy 1.4 老框架下的电商全量采集实践
2026/10/20 11:10:54 网站建设 项目流程

简介:这套电商数据爬虫系统基于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 pip==20.3.4 pip install Twisted==18.7.0 pip install Scrapy==1.4.0 pip install pyOpenSSL==19.1.0 pip install six==1.12.0 pip install w3lib==1.21.0 pip install lxml==4.4.1 pip install parsel==1.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_charset='utf-8'): # 优先尝试指定编码 try: return raw_bytes.decode(guess_charset) except UnicodeDecodeError: pass # 从响应头提取编码 m = re.search(r'charset=([\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, method=None): 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, callback=self.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 模板,page=1 为起始 list_url = 'https://list.jd.com/list.html?cat={cid}&page=1&sort=sort_totalsales15_desc'.format(cid=cid) yield Request(list_url, callback=self.parse_list, meta={'cid': cid, 'page': 1})

参数说明:sort=sort_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(r'window\.__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), callback=self.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[@class="sku-name"]/text()').extract_first().strip() # 价格走异步接口 price_url = 'https://p.3.cn/prices/mgets?skuIds=J_{}'.format(sku) yield Request(price_url, callback=self.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 b'nc.js' in response.body or b'slide' in response.body: raise IgnoreRequest("captcha triggered, skip this page") # 检测京东验证:status=200 但商品数量为 0 且无有效商品节点 if b'gl-item' not in response.body and b'plist' 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, callback=self.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, callback=lambda r: self.parse_price(r, name)) # 正确方式:用 meta 传参,不用闭包 yield Request(price_url, callback=self.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 JOBDIR=jobs/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,多花两分钟做前置检查太值了。希望这份老框架下的实践拆解能帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询