Python比价爬虫实战:从requests到SQLite完成历史价格采集

📅 2026/8/27 13:28:51
Python比价爬虫实战:从requests到SQLite完成历史价格采集
简介在电商场景中比价与历史价格追踪是高频需求而手动查看价格耗时费力。通过Python爬虫技术可以高效获取商品价格与平台来源数据。本文从爬虫基础概念出发介绍如何利用requests构建请求、BeautifulSoup解析HTML并借助正则表达式提取页面内嵌的JS数组中的历史价格数据。随后通过SQLite数据库进行结构化存储设计商品表与价格历史表实现增量更新与断点续抓。该方案不仅适用于慢慢买这类比价站点也适用于其他静态页面或接口返回JSON的场景。工程实践中合理设置请求头、随机化请求间隔、处理中文乱码与数据清洗是保证稳定性的关键。文中还总结了403验证码、解析结果为空等常见问题的排查思路。整体来看掌握这些爬虫与数据处理技巧可轻松构建个人比价数据库为购买决策提供数据支撑。 买东西踩坑踩多了我养成了一个习惯大件商品必须先看历史价格。前阵子想换显示器天天手动刷新比价页面太累索性写了一个基于Python的慢慢买比价爬虫把慢慢买上的商品搜索、历史价格、平台来源一次性抓下来再存进数据库里后面想查哪个东西的降价规律直接查表就行。这个项目的代码量不大但覆盖面很完整请求构造、页面解析、数据清洗、结构化存储、增量更新一条链路全都有。对刚学完 requests 和 BeautifulSoup、想拿真实项目练手的朋友非常合适哪怕你已经有爬虫基础也可以把它当成一个“小而美”的参考模板以后要抓别的比价类站点改改选择器和字段就能复用。1. 项目整体思路与设计拆解1.1 为什么选慢慢买做数据源做比价爬虫最直接的想法是去京东、天猫、拼多多挨个抓价格。但真去干过就知道电商平台的反爬不是闹着玩的页面结构动不动就变很多价格数据还是异步加载甚至要在 JS 里跑完签名逻辑才能拿到真实数据。对个人学习项目来说这个成本太高了。慢慢买这类比价站点相当于一个“中间层”它本身已经帮我们把各平台的价格、历史走势、优惠信息聚合好了。我们要做的不是和几个大电商平台搏斗而是从一个页面结构相对简单的站点拿聚合结果一次请求就能拿到多个维度的数据。另外慢慢买的数据对普通消费者够用页面大多还是服务端渲染用 requests 就能直接拿到 HTML不需要上无头浏览器这让整个爬虫的核心逻辑非常干净。有一点必须先说清楚个人学习爬虫尽量只抓公开信息、控制请求频率、遵守网站的 robots 协议也不要把抓下来的数据用于商业用途。这个项目定位是学习 demo不是让你去搞一个无限并发抓站工具。1.2 技术选型requests BeautifulSoup SQLite技术栈我选了 Python 的 requests BeautifulSoup SQLite。这三个组合是爬虫入门最经典的一套原因很简单requests 负责发 HTTP 请求API 简洁代理、超时、Session 这些功能都有不需要额外封装。BeautifulSoup 配合 lxml 解析 HTML对静态页面足够快写起来也直观。虽然正则提取在某些场景更快但页面结构一变正则脚本就容易碎一地。SQLite 是 Python 内置的单文件存储不用安装数据库服务数据量在几万条级别时性能完全够用。而且它支持 SQL 查询后续做去重、关联、统计都很方便。为什么不直接上 Scrapy我也不是没用过但这个小项目的体量用 Scrapy 有点重光是理解 Item、Pipeline、Middleware 这套概念就要花不少时间。用纯 requests 写整个执行流程都在一个文件里新手能一眼看懂先干什么、后干什么。以后真要做大规模采集再平滑迁移到 Scrapy 也不迟解析函数和数据库逻辑都可以直接复用。1.3 数据模型设计一张商品表还是两张我一开始偷懒想用一个表把商品信息和价格历史全塞进去后来发现查询“某个商品最近 30 天价格”时特别别扭数据冗余得厉害。后来拆成了两张表goods 表存商品的静态信息比如名称、链接、平台、当前价格、最后更新时间。price_history 表存价格时间序列一个商品对应多条记录。一个商品有几十条价格记录如果都放一张表每次插入都要重复写入商品名称、链接、平台这些字段数据一多既占空间又容易不一致。拆表之后价格表只需要存商品 ID 和价格日期干净很多。关键约束也要加上。goods 表的 url 字段做唯一索引price_history 表加 (goods_id, price_date) 唯一索引。这样重复插入时可以直接跳过为后面的增量更新省了很多事。2. 核心细节解析与实操要点2.1 请求头与会话保持别让服务器一眼认出脚本requests 默认的 User-Agent 是python-requests/x.x.x服务器看到这种 UA基本可以确定你是脚本。别想着伪装成多复杂的指纹先把最基本的请求头配置好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,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, }这里有个细节Accept-Language最好写成zh-CN,zh;q0.9不然服务端可能返回英文版页面解析选择器就对不上了。另外用requests.Session()而不是裸发请求。Session 会自动保存服务端下发的 Cookie后续请求直接带上。有些站点第一次访问时会种一个标记 Cookie后续请求不带它就可能被判定为异常访问。请求间隔也要随机化。固定间隔 3 秒虽然比不间隔好但规律太明显容易被限流策略盯上。我习惯用time.sleep(random.uniform(3, 6))让请求节奏更像人工操作。这个数字没有标准答案站小就适当放宽别为了快把自己 IP 搞进黑名单。2.2 列表页解析先按F12核对结构写解析器之前建议先打开浏览器开发者工具找到商品列表的容器确认标签结构。我踩过太多次“照着旧教程写选择器结果页面一改就抓空气”的坑。解析时一般关注三个元素商品名称、价格、商品详情页链接。以慢慢买搜索页为例伪代码思路是这样的soup BeautifulSoup(r.text, lxml) for item in soup.select(ul.goods_list li): name_el item.select_one(a.goods_name) price_el item.select_one(span.price) link name_el[href]注意几个容易翻车的地方get_text(stripTrue)一定要加stripTrue否则抓下来的文本前后全是换行和空格。链接通常是相对地址比如/goods/12345.html拼成完整 URL 时用urljoin不要自己拼字符串。价格文本可能是¥1999.00或者1,999.00入库前要做清洗不能直接转 float。如果某个字段在列表页拿不到也不要在列表页硬抠先进详情页看一眼。宁可多请求一次也别把解析逻辑搞得特别复杂。2.3 历史价格数据解析与清洗慢慢买商品页里的历史价格数据并不一定都在 HTML 里老老实实躺着。有些是页面内嵌的 JS 数组有些是动态请求接口返回 JSON。排查思路是打开开发者工具 - Network - 刷新页面 - 筛选 XHR看有没有返回价格数据的接口。如果找到了直接用 requests 请求那个接口解析 JSON 比解析 HTML 舒服一百倍。如果历史数据是嵌在script标签里的 JS 数组比如长这样var historyData [[2024-01-01, 1999], [2024-01-02, 1980]];最简单的方式是正则提取日期和价格对import re pattern re.compile(r\[[\]([^\])[\]\s*,\s*([\d.])\]) pairs pattern.findall(script_text)这里要注意价格可能是整数也可能带小数所以[\d.]要保留小数点日期格式可能是2024-01-01也可能是2024/01/01清洗时统一转成 ISO 标准格式。清洗逻辑通常包括把空值过滤掉、价格字符串转 float、同一日期出现多条记录时只保留最后一条。排序也不能忘按日期升序后面画趋势图或者算最低价都方便。2.4 增量更新与断点续抓有的朋友写爬虫喜欢一股脑全量抓第一次没问题第二次跑就会有一堆重复数据还白白增加对方服务器压力。增量更新的思路其实很简单每次抓取前先看数据库里这个商品最后更新是什么时候如果距离现在没超过设定阈值就跳过历史价格抓取只更新当前价格。price_history 表上的唯一约束这时候就发挥作用了。就算因为程序异常导致重复插入INSERT OR IGNORE也会自动跳过已存在的记录不需要额外写判断逻辑。断点续抓也是同理。如果跑了几百个商品之后程序崩了重新运行时数据库里已有的商品会被跳过没抓完的会继续抓。这个设计的价值跑过一次完整抓取你就懂了。3. 从零到一实现完整代码与运行流程3.1 环境准备Python版本与依赖安装建议用 Python 3.10 以上版本安装依赖只需两行命令pip install requests beautifulsoup4 lxml为了不污染系统环境推荐用虚拟环境python -m venv venv venv\Scripts\activate # Windows # source venv/bin/activate # macOS / Linux整个项目的目录结构可以很清爽manmanbuy-crawler/ ├── crawler.py # 主程序 ├── requirements.txt # 依赖清单 └── manmanbuy.db # 运行后自动生成3.2 商品列表采集搜索关键词拿到商品清单搜索逻辑就是构造请求参数把关键词传给搜索接口然后解析返回的 HTML。下面是核心函数import requests from bs4 import BeautifulSoup from urllib.parse import urljoin import random import time BASE_URL https://www.manmanbuy.com 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,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, } def search_goods(keyword): params {keyword: keyword} r requests.get(BASE_URL /search.aspx, paramsparams, headersHEADERS, timeout10) r.encoding utf-8 soup BeautifulSoup(r.text, lxml) goods [] # 下面的选择器以实际页面为准动手前先用 F12 确认 for item in soup.select(ul.goods_list li): name_el item.select_one(a.goods_name) if not name_el: continue price_el item.select_one(span.price) source_el item.select_one(span.source) goods.append({ name: name_el.get_text(stripTrue), url: urljoin(BASE_URL, name_el[href]), price: price_el.get_text(stripTrue) if price_el else , platform: source_el.get_text(stripTrue) if source_el else , }) return goods代码里有个容易漏的点r.encoding。如果发现抓下来的中文是乱码先检查是不是这里没设置。HTML 头部声明的编码和实际编码不一致时requests 可能判断错手动指定最稳妥。3.3 历史价格采集解析价格走势数据历史价格函数的输入是商品详情页 URL输出是一组(日期, 价格)元组。这里按页面内嵌 JS 数组的格式来写import re def fetch_price_history(goods_url): r requests.get(goods_url, headersHEADERS, timeout10) r.encoding utf-8 # 方案一直接从 HTML 源码中找历史数据 pattern re.compile(r\[[\]([^\])[\]\s*,\s*([\d.])\]) pairs pattern.findall(r.text) if not pairs: # 方案二如果页面是异步加载需要在这里解析 XHR 接口 return [] history [] for date_str, price_str in pairs: try: price float(price_str) history.append((date_str.strip(), price)) except ValueError: continue # 按日期排序并去掉重复日期 history.sort(keylambda x: x[0]) return history这段代码虽然短但把“静态解析”“正则提取”“异常过滤”“排序去重”都涵盖了。如果页面结构完全不同不要硬套重点学这个处理思路。3.4 数据入库与一句话查询数据库操作我用 sqlite3 标准库建表和插入逻辑如下import sqlite3 DB_PATH manmanbuy.db def init_db(): conn sqlite3.connect(DB_PATH) cur conn.cursor() cur.execute( CREATE TABLE IF NOT EXISTS goods ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, url TEXT NOT NULL UNIQUE, platform TEXT, current_price TEXT, last_update TEXT ) ) cur.execute( CREATE TABLE IF NOT EXISTS price_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, goods_id INTEGER NOT NULL, price_date TEXT NOT NULL, price REAL NOT NULL, UNIQUE(goods_id, price_date), FOREIGN KEY(goods_id) REFERENCES goods(id) ) ) conn.commit() return conn def save_goods(conn, goods): cur conn.cursor() cur.execute( INSERT INTO goods(name, url, platform, current_price, last_update) VALUES(?, ?, ?, ?, datetime(now, localtime)) ON CONFLICT(url) DO UPDATE SET name excluded.name, platform excluded.platform, current_price excluded.current_price, last_update datetime(now, localtime) , (goods[name], goods[url], goods[platform], goods[price])) conn.commit() return cur.lastrowid def save_history(conn, goods_id, history): cur conn.cursor() cur.executemany( INSERT OR IGNORE INTO price_history(goods_id, price_date, price) VALUES(?, ?, ?) , [(goods_id, date, price) for date, price in history]) conn.commit()ON CONFLICT(url) DO UPDATE是 SQLite 3.24 以上才支持的语法如果你的 Python 内置的 SQLite 太老可以改成先SELECT再决定INSERT还是UPDATE。数据入库之后查询最低价就很简单SELECT MIN(price), MAX(price), AVG(price) FROM price_history WHERE goods_id 1;也可以关联商品表直接查某个商品 30 天内最低价出现在哪天SELECT g.name, p.price_date, p.price FROM price_history p JOIN goods g ON g.id p.goods_id WHERE p.goods_id 1 AND p.price_date date(now, -30 days) ORDER BY p.price ASC LIMIT 1;3.5 主流程串联与运行效果最后把这些函数串起来变成一个完整入口def main(): conn init_db() keyword 显示器 goods_list search_goods(keyword) print(f搜索到 {len(goods_list)} 个商品) for goods in goods_list: print(f处理: {goods[name]}) goods_id save_goods(conn, goods) history fetch_price_history(goods[url]) save_history(conn, goods_id, history) print(f 写入 {len(history)} 条历史价格) time.sleep(random.uniform(3, 6)) conn.close() if __name__ __main__: main()运行起来效果类似搜索到 20 个商品 处理: XXX显示器 27英寸 2K 写入 89 条历史价格 处理: XXX显示器 34英寸 带鱼屏 写入 120 条历史价格每次运行后manmanbuy.db里就会多一批数据。你可以把keyword改成任何想关注的商品比如“机械键盘”“人体工学椅”“空气炸锅”跑一遍就是一份自己的比价底稿。4. 常见问题与排查技巧实录4.1 返回403或验证码先降速别硬碰跑着跑着突然发现返回 403或者页面变成验证码页这是爬虫最常见的翻车现场。原因基本只有一个请求频率太高被服务端限流了。这时候不要想着怎么绕过验证码正确的做法是停手。把请求间隔从 3 秒调到 8 秒甚至更长等一段时间再跑。对个人项目来说慢不是缺点稳定更重要。我还喜欢在请求函数里加一个重试机制status_code 不是 200 就等几秒重试连续三次失败就放弃这条数据。如果返回 403 或遇到验证码唯一正确的操作是立即停止降低频率休息半小时以上再继续。4.2 解析结果为空别急着改代码先看一眼原始页面很多人一看到goods []就开始怀疑选择器写错了然后一遍遍改 CSS 选择器改到怀疑人生。我的排查习惯是先保存原始 HTML 到本地文件然后用编辑器搜索关键词看看结构到底长什么样。with open(debug.html, w, encodingutf-8) as f: f.write(r.text)如果 HTML 里压根没有商品信息说明页面是动态加载的requests 拿到的只是空壳这时候才需要去分析 XHR 接口或者临时用无头浏览器兜底。选择器也会因为页面改版而失效。我的建议是解析逻辑单独拆函数页面结构变了只改一个函数不至于全盘重写。4.3 中文乱码编码问题一查一个准抓下来的标题变成中文这种鬼东西十有八九是编码判断错了。解决方案就是显式指定编码r.encoding utf-8如果指定之后还是乱就试试r.apparent_encoding它是 requests 根据内容自动判断的结果。不过这个判断不是 100% 准偶尔会慢所以能手动指定就手动指定。4.4 抓取中断与数据重复做好幂等和重试爬虫跑在本地断电、断网、程序崩溃都是常态。中断不可怕可怕的是重新跑一遍又重复插入大量数据。解决思路就一句话让每个写入操作都是幂等的。同一份数据无论写一次还是写十次最终结果都一样。实现方式就是我们前面建表时加的唯一约束配合INSERT OR IGNORE。这样程序中断后重新跑已经写进去的数据不会重复没写进去的数据会补齐。另外数据库写操作要即时 commit。我见过有的朋友攒了一万条数据最后一次性 commit结果程序中途崩溃全部白干。每处理完一个商品就 commit 一次损失最多只有一个商品的数据。4.5 常见问题速查表问题现象常见原因解决办法返回 403 / 遇到验证码请求频率过高降低请求频率暂停一段时间不要尝试绕过验证码页面能打开但解析结果为空选择器失效或数据动态加载保存 HTML 到本地排查必要时抓 XHR 接口中文乱码编码识别错误手动设置r.encoding utf-8商品链接打不开相对地址没拼接完整用urljoin拼接完整 URL价格字段带 ¥ 和逗号文本清洗不彻底先清洗再转 float失败则保留原值重复数据缺少唯一约束建表时加 UNIQUE用INSERT OR IGNORE程序崩溃后进度全丢没有及时 commit每处理完一条就 commit 一次这七类问题覆盖了我跑爬虫时遇到的绝大部分坑。还有一类更隐蔽的问题同一个商品链接在搜索结果里出现多次入库时靠 url 唯一约束能挡掉但如果你没加约束数据就会悄悄翻倍。所以建表时的唯一索引一定不能省。折腾完这个项目我自己最大的感受是爬虫最核心的能力不是“把页面抓下来”而是“把抓下来的数据变得干净、可靠、可复用”。requests 和 BeautifulSoup 只是工具数据建模、异常处理、增量更新这些基本功才是真正值钱的部分。之后我又在这个项目上加了一个每日定时跑的入口把关注的商品价格存成 CSV 导出再配了个简单的命令行查询日常比价基本不看第三方 APP 了。这套代码的扩展空间还很大有需要的话下一步可以加邮件通知功能等商品降到心理价位直接推送提醒。本文还有配套的精品资源点击获取