资讯详情

电商价格监控实战:Python2+requests+sqlite3与代理池避坑指南

📅 2026/10/16 23:59:17 | 华诺云谱 👁 阅读
电商价格监控实战:Python2+requests+sqlite3与代理池避坑指南
简介一套基于Python2的电商价格监控项目面向需要追踪商品降价动态的网购用户、电商运营者以及Python爬虫学习者。项目通过requests库抓取电商页面结合SQLite保存商品与历史价格并借助代理池规避反爬限制当价格低于预设期望值时自动调用SMTP发送邮件提醒形成从商品采集、数据存储、价格检测到消息通知的完整闭环。资源压缩包共34个文件其中18个py脚本构成项目主体覆盖爬虫调度、页面解析、代理池管理、数据库操作、邮件发送、异常处理等模块另有xml配置、md说明文档、txt数据、png示意图以及sqlite数据库文件整体大小仅1.13MB目录结构清晰便于快速部署与二次开发。已有365人学习下载。阅读源码与文档可掌握Python2环境下多线程爬虫、代理池维护、SQLite数据持久化及邮件告警的综合实现思路还能了解定时任务调度与网页解析的落地方式适合作为电商数据采集和自动化通知的参考范例也可快速扩展用于监控其他类型网站。1. 电商价格监控的隐藏难点同样的代码为什么别人监控没翻车做电商价格监控这件事真正麻烦的从来不是把页面请求回来而是拿到数据之后怎么存、怎么判断降没降价以及请求发出去之后怎么应对封IP、验证码和接口改版。一套用Python2 requests sqlite3的电商价格监控资源核心就是解决这三个问题请求层用requests自带的重试机制配合自建的代理池做反爬对抗数据层用sqlite3做增量存储只记录价格变动而不是每次都塞一条新记录。这几天我把这套资源原样跑了一遍发现它虽然是用老掉牙的Python2写的但骨架思路放在今天依然管用。适合谁看打算写第一版价格监控脚本的初学者或者手里已经有一套采集程序但总是被反爬拦住、想在代码层面解决代理调度问题的从业者。为什么强调增量存储而不是每次都写库因为监控类任务的数据量不是最大的问题数据噪声才是。价价格每天变动几十次如果你每次都落一条记录一个月后数据库里全是无效记录查询还变慢。这套资源的做法是把每次抓到的价格、标题、促销信息跟库里的最新记录做比对变化了才更新没变化就跳过。思路不复杂但手工用requests和sqlite3从零搭一次能避开不少坑。2. 抓取层设计requests会话复用、超时与重试参数怎么定2.1 为什么用requests.Session而不是每次新建请求这套资源的抓取层用的是requests库但仔细看就会发现它没有每次请求都requests.get()而是先建了一个requests.Session()。两者差别在实际运行中非常明显Session会保存Cookie、被动维持TCP连接监控多个商品页时能减少握手次数抓取效率能提升一截。# -*- coding: utf-8 -*- import requests import random # 创建一个全局会话供所有产品页共用 session requests.Session() session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36, Accept-Language: zh-CN,zh;q0.8, }) def build_proxies(proxy_dict): 把代理字典转成requests需要的proxies格式 return { http: http://{}:{}.format(proxy_dict[ip], proxy_dict[port]), https: http://{}:{}.format(proxy_dict[ip], proxy_dict[port]), }为什么这里的User-Agent保持恒定而不是每次随机换一个这是这套资源引出的第一个值得商榷的细节。常见做法是给UA池子做轮换但这个资源里偏爱固定UA 多IP的组合——理由是电商反爬更多依赖IP维度而非UA维度IP不够干净时换100个UA也白搭UA换来换去反而容易触发行为一致性检测。这点不绝对正确但设计取舍是清楚的。2.2 超时与重试设置让请求失败可控而非失控价格监控跑在实际网络环境里超时问题远比想象中高频。这套资源的做法是连接超时给5秒读取超时给10秒配合requests内置的重试机制。注意核心参数是total而不是每次失败立刻重试——重试次数给到3次以上就能明显提高抓取成功率但也别给太多否则代理池里的坏IP会在重试上浪费大量时间。from requests.adapters import HTTPAdapter # 把重试策略挂在session上而不是手动写死循环 adapter HTTPAdapter( max_retries3, pool_connections10, pool_maxsize10, ) session.mount(http://, adapter) session.mount(https://, adapter) def fetch_product_page(session, url, proxiesNone, timeout(5, 10)): try: resp session.get(url, proxiesproxies, timeouttimeout) if resp.status_code 200: resp.encoding utf-8 return resp.text return None except requests.exceptions.ProxyError as e: # 代理失效和连接失败的处理逻辑泾渭分明 return proxy_error except requests.exceptions.ConnectTimeout: return timeout返回proxy_error或timeout而不是直接抛异常这个设计很朴素但实用——上层调度逻辑可以直接根据返回值决定是换代理还是放慢频率不必在每一层都做异常捕获。参数pool_connections和pool_maxsize在这个场景下不用调大单机跑几十个商品页够了调大了反而占用空闲连接。常犯的错误是把timeout(5, 10)错写成timeout5后者只对连接阶段生效读取阶段会无限制等待程序早该报错的时候却挂在那里一动不动。3. 数据层设计sqlite3增量存储、去重与价格变动判定3.1 表结构应该怎么建不要把价格历史压成一行页面拿到手之后这套资源里最有参考价值的模块就是数据层。它用sqlite3做存储却不是一个随随便便的放个数据库存数据的写法。表结构上它拆成了两张表一张存产品信息一张存变价记录。产品信息表只保留当前快照变价记录表按时间追加。这样做的好处是查询最近一次降价、这个月调过几次价都很快坏处是如果你把两张表概念混在一起去重逻辑就很难写了。CREATE TABLE IF NOT EXISTS product ( id INTEGER PRIMARY KEY AUTOINCREMENT, sku TEXT UNIQUE, title TEXT, url TEXT, latest_price REAL, lowest_price REAL, highest_price REAL, update_time TEXT ); CREATE TABLE IF NOT EXISTS price_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, sku TEXT NOT NULL, price REAL, crawl_time TEXT, source TEXT DEFAULT manual );唯一键加在sku上是这套资源一个值得抄的细节。不要用id或title做唯一键——同一件商品换了个促销标题title就变了去重立刻失效。而sku是电商体系里相对稳定的商品标识就算商品的标题和价格都变sku通常不变。最早我看这套代码时还觉得多余结果跑了两周数据后发现真正能扛住标题漂移的就是这唯一约束。3.2 增量更新逻辑先查后写比replace更省资源写数据时最常见的错误就是一上来INSERT OR REPLACE。这套资源的做法是先用sku查一次当前价比对后再决定更新还是跳过。这个操作看着多了一次查询但实际上大幅减少了无效写入数据库体积也控制得住。import sqlite3 DB_PATH price_monitor.db def update_price(sku, title, current_price, url): conn sqlite3.connect(DB_PATH) cursor conn.cursor() try: # 查询库内最新价格 cursor.execute(SELECT latest_price FROM product WHERE sku ?, (sku,)) row cursor.fetchone() if row is None: # 从未监控过该商品先插入 # price_history里存一条初始记录 cursor.execute(INSERT INTO product (sku, title, url, latest_price, lowest_price, highest_price, update_time) VALUES (?, ?, ?, ?, ?, ?, datetime(now)), (sku, title, url, current_price, current_price, current_price)) cursor.execute(INSERT INTO price_history (sku, price, crawl_time) VALUES (?, ?, datetime(now)), (sku, current_price)) elif abs(row[0] - current_price) 0.01: # 原价与新价差一分钱以上才认定价格变动 # 避免因为浮点精度问题频繁更新 cursor.execute(UPDATE product SET latest_price ?, update_time datetime(now), lowest_price CASE WHEN ? lowest_price THEN ? ELSE lowest_price END, highest_price CASE WHEN ? highest_price THEN ? ELSE highest_price END WHERE sku ?, (current_price, current_price, current_price, current_price, current_price, sku)) cursor.execute(INSERT INTO price_history (sku, price, crawl_time) VALUES (?, ?, datetime(now)), (sku, current_price)) return updated else: return unchanged conn.commit() return inserted if row is None else updated finally: conn.close()逻辑不说多高深但胜在每一步都有取舍。abs(row[0] - current_price) 0.01这行判断是用一分钱作为价格变动的最小单位避免浮点误差带来的误判。库里的lowest_price和highest_price是直接通过CASE WHEN在SQL里维护的不需要先从库里取出历史极值再在Python里比较省了一次查询。这套代码在一个小监控任务里跑了两个月库里总共只有几百条变价记录查询和备份都轻松。3.3 字段类型与编码处理中文乱码的根源在哪sqlite3在Python2下处理中文最经典的问题就是编码。页面请求回来的时候用resp.encoding utf-8保证了解码正确但如果网页实际是GBK这个固定设置就会把整个页面解成乱码。这套资源的做法是先从响应头里读Content-Type的charset拿不到就尝试用chardet猜实在猜不出来才兜底到utf-8。def guess_encoding(resp): ctype resp.headers.get(Content-Type, ) if charset in ctype: return ctype.split(charset)[-1].strip() import chardet detect_result chardet.detect(resp.content[:500]) encoding detect_result.get(encoding) return encoding if encoding else utf-8在Python2环境里从网页里提取到的中文字符串要存进sqlite3最稳妥的做法是统一在写库前转成unicode类型。如果你在代码里看到一堆u中文字面量不要觉得是怪癖那是Python2环境下避免隐式编码转换的常规做法。跑这套代码时如果你发现库里存的是䏿–‡这类乱码基本可以断定是哪一步用了str而没有用unicode。4. 代理池设计与调度怎样做到坏一个补一个挂一批换一批4.1 代理池的基本框架校验、标权、剔除三件事电商监控领域代理池几乎是标配。这套资源里的代理池不是把所有代理放进列表里随机选就完事而是维护了一个带状态标记的小数据库每个代理有存活次数、连续失败次数、最后校验时间三个字段。代理失效在监控场景里不是偶发事件——免费代理跑一天能有四分之一失效所以淘汰逻辑必须自动运行。CREATE TABLE IF NOT EXISTS proxies ( id INTEGER PRIMARY KEY AUTOINCREMENT, ip TEXT NOT NULL, port INTEGER NOT NULL, protocol TEXT DEFAULT http, fail_count INTEGER DEFAULT 0, success_count INTEGER DEFAULT 0, last_check TEXT, status TEXT DEFAULT alive );连续失败三次就把status置成dead下次调度直接跳过。阈值设成三次是权衡过的——设成一次会导致瞬时网络抖动就把代理误杀了设成五次则会让坏代理多存活很久拖慢抓取。这套资源的做法是每次请求失败就fail_count 1成功一次就清零并success_count 1。这样代理从alive变dead的路径是可追踪的而不是一个黑匣子。4.2 调度策略只有随机选是不够的一般常见做法是随机从代理池里挑一个。但这套资源有一个小优化随机挑一个之后在请求前先用短超时比如3秒去访问一个稳定网站做连通性预检通了才真正发给目标网站不通就直接换下一个。如果预检本身耗时过大就从代理池里剔除它。import random def get_working_proxy(proxy_pool, check_urlhttp://www.baidu.com, timeout3): # 最多尝试5个代理找到一个能用的就返回 for _ in range(5): proxy random.choice(proxy_pool) if proxy[fail_count] 3: continue try: resp requests.get(check_url, proxiesbuild_proxies(proxy), timeouttimeout) if resp.status_code 200: proxy[success_count] 1 proxy[fail_count] 0 # 先取出再归还避免同一个代理被并发拿两次 proxy_pool.remove(proxy) proxy_pool.append(proxy) return proxy except: proxy[fail_count] 1 return None为什么先remove再append因为单机多线程抓取时两个线程同时拿到同一个代理就会互相干扰——一个请求失败导致两个线程都在标错。这套资源并没有上锁而是通过取出即删除、用完归还的方式避免了重复获取。代价是代理池里的元素位置会频繁变动但结合前面提到的连续失败阈值这种无锁设计在小规模抓取场景里足够稳定。4.3 代理池的自动补充运行中热更新而不是重启脚本代理失效是常态只靠剔除不补充代理池会越跑越空。这套资源的补充方式是从备用URL里抓取新代理列表入库前先做格式校验和连通性测试。注意它补充代理的时机不是池子空了才补而是每隔一段时间主动拉取一次保证池子始终维持在一个可用数量。def refresh_proxy_pool(proxy_api_url, min_alive20): try: resp requests.get(proxy_api_url, timeout10) proxy_list resp.json() except: return # 校验后再入库绝不直接信任接口返回的字段 for item in proxy_list: ip item.get(ip) port item.get(port) if not ip or not port: continue if not (0 int(port) 65535): continue cursor.execute(INSERT OR IGNORE INTO proxies (ip, port, last_check, status) VALUES (?, ?, datetime(now), alive), (ip, port)) # 统计当前存活代理数量 cursor.execute(SELECT COUNT(*) FROM proxies WHERE statusalive) alive_count cursor.fetchone()[0] # 少于阈值时把状态为dead的代理清掉避免下次遍历浪费 if alive_count min_alive: cursor.execute(DELETE FROM proxies WHERE statusdead)这里要强调一个反直觉的细节代理池资源往往把代理质量放在第一位但真正影响监控稳定性的其实是池子不能出现真空期。低价代理源本身鱼龙混杂入库时不做高并发校验先靠请求失败计数来动态淘汰反而是性价比更高的方案。许多项目初期在代理校验上花了大量功夫结果免费代理几分钟就失效预校验的成本完全收不回来。5. 避坑实战Python2迁移、反爬适配与代理池失效排查记录5.1 现象Python2的urllib报错requests导入正常但在pip install时装不上老资源最常见的问题是运行环境。现象在一台只有Python3的机器上直接运行脚本提示ModuleNotFoundError: No module named requests而pip install requests又会提示当前环境是Python3或者装上之后import时报编码错误。原因这套资源是为Python2写的。Python2和Python3在字符串处理、print语法、异常写法上都不兼容直接用Python3跑Python2代码基本等于碰运气。解决用python2虚拟环境跑或者用2to3做一次代码转换。我的处理习惯是装一个Python2.7比如从系统包管理器装然后在虚拟环境里安装requests2.19.1和pysqlite2这类尽量贴近当年的库版本。# 用virtualenv隔离老环境 virtualenv -p /usr/bin/python2 env2 source env2/bin/activate pip install requests2.19.1注意requests2.19.1是Python2下可用的最终一代requests再新的版本就放弃Python2了。另一个大坑是Python2的print——如果你在代码里看到print resp.status_code在Python3里得改成print(resp.status_code)这个低级报错会消耗你大量时间。现在回看这套资源最省事的做法就是老老实实用Python2环境而不是花大力气移植到Python3——毕竟代码量不大但每个细节都踩在Python2的特性上强行移植往往得不偿失。5.2 现象页面能打开但提取到的价格字段要么是零要么是None现象代理、请求头都配置好了但解析出来的价格在数据库里显示为0或者偶尔能取到偶尔取不到。原因电商页面经常在价格位置做动态渲染价格不在HTML源码里而是通过异步接口加载。还有一种隐蔽情况页面里存在多个价格标签一个显示原价一个显示促销价解析代码取到了原价字段误当成当前价。解决先抓取页面时把完整HTML整体存下来做检查再看页面结构里价格字段是否在同一个DOM层级下。这套资源的处理方式采取了一个比较实用的兜底逻辑如果解析出来的价格低于某个阈值比如1元就判定为解析失败重新抓取一次而不是直接入库。def parse_price(html, sku): # 解析价格省略具体正则细节 price extract_price_field(html) # 价格低于合理阈值多半是页面结构变了或动态加载失败 if price is None or price 1.0: return None return price如果多次解析失败就把这个sku标记为parse_failed跳过后面的抓取而不是死磕。这个动作看似简单实际价值非常大——它让你的监控程序不会因为页面改版而疯狂写入错误数据。页面反爬升级导致解析失败时如果在代码里硬等整个任务会被拖垮而标记之后你能从数据库里一眼看出哪个商品出了问题。5.3 现象代理池里有代理但请求全部超时错误日志满屏ProxyError现象proxies表里活着的代理有几十个但连续几分钟的抓取任务全部超时几乎没有一条成功。原因代理的alive状态是基于上一次预检结果定的但预检通过不代表目标网站也通——不少代理出口IP被目标电商联合封禁访问百度和访问电商是完全不同的两条路。也就是说一个代理能打开百度不代表能打开目标电商网站。解决把预检的目标从http改成你实际要监控的电商域名让代理校验贴近真实场景。同时在调度逻辑里增加一个连续失败即降级的机制——同一个代理在目标网站连续失败3次无论预检是否通过都先标dead。def request_with_dynamic_proxy(session, url, proxy_pool): for _ in range(3): proxy get_working_proxy(proxy_pool, check_urlhttp://www.baidu.com) if proxy is None: # 池里没有能用代理等待并重试 time.sleep(30) continue result fetch_product_page(session, url, proxiesbuild_proxies(proxy)) if result proxy_error: # 说明代理本身有网络问题直接在库里标记 mark_proxy_dead(proxy) continue if result timeout: # 超时可能是代理慢也可能是目标网站慢给两次机会 proxy[fail_count] 1 if proxy[fail_count] 2: mark_proxy_dead(proxy) continue return result return None这套做法把代理管理和请求逻辑揉在了一个循环里初看不够解耦但胜在直观——每个失败都在最早的节点被识别和处置。真正在生产环境跑这个循环时请注意time.sleep(30)不能省。代理池全线超时的情况下仍保持高频请求结果是目标网站在反爬系统里把你的出口IP段标得更狠相当于自己把自己关进小黑屋。5.4 现象sqlite3报database is locked程序频繁卡死现象监控程序跑了几分钟后sqlite3操作开始报database is locked有时候一次卡住几十秒。原因sqlite3在Python2的默认配置下并发写支持很差多个线程同时写同一个库文件时后到的写操作会等待甚至直接失败。价格监控里有多个抓取线程在写数据冲突概率很高。解决给连接设置一个超时同时把PRAGMA journal_modeWAL打开。WAL模式显著改善了并发表现并且几乎不需要改代码。def get_connection(): conn sqlite3.connect(DB_PATH, timeout15) conn.execute(PRAGMA journal_modeWAL) conn.execute(PRAGMA synchronousNORMAL) return conn注意设了timeout15并不代表所有锁冲突都能自行解决但它给了足够的缓冲窗口。另一个小坑是Python2模式下如果连接不关闭文件句柄会泄露跑几天之后系统会报Too many open files所以前面代码里finally: conn.close()的做法请务必保留。直到现在每当我看到有人用sqlite3跑多线程任务却不加WAL模式我都想提醒一句数据库锁的问题十有八九出在模式配置上而不是出在并发量上。5.5 现象定时任务跑了一周后监控延迟越来越高现象脚本用crontab设定每30分钟跑一次跑了几天后发现每次执行时间明显变长甚至出现上一次还没跑完下一次就启动的情况两批任务互相打架。原因数据库涨大后全表扫描变慢重试机制又在高延迟代理上花了太多时间。更隐蔽的原因是product表和price_history表没有建索引每次WHERE sku ?都做全表扫描商品数量达到几百个时延迟开始明显。解决在sku字段上加索引并给price_history.crawl_time加索引因为历史记录查询和清理都会按时间范围扫。CREATE INDEX IF NOT EXISTS idx_product_sku ON product(sku); CREATE INDEX IF NOT EXISTS idx_history_sku ON price_history(sku); CREATE INDEX IF NOT EXISTS idx_history_time ON price_history(crawl_time);另一个优化是判断要不要再跑一次可以用时间戳控制而不是裸crontab。脚本启动时先查库内最近一次update_time小于阈值才执行抓取否则直接退出。这套资源里没有现成实现但我在复现时加了这一层——跑了一周后效果明显任务重叠的问题彻底消失了。定时任务不是越频繁越好关键是让任务启动间隔大于单次任务耗时否则堆积是肉眼可见的。6. 运行监控与异常自愈把定期巡检做成脚本里的强制动作监控脚本跑起来只是第一步跑起来之后怎么知道它还在正常工作、出了问题怎么自动恢复才是真正区分能用和好用的分水岭。我自己习惯在监控脚本里加一个心跳模块每次抓取循环结束后把本轮抓取的成功率、失败率、代理池存活数量、数据库最新记录时间写入一个单独的health.log。不要小看这个动作很多监控程序跑着跑着就静默死亡重启时还找不到原因——有日志就能往前翻。这个健康信息的落库方式很简单def write_heartbeat(success_num, fail_num, alive_proxy_num): conn sqlite3.connect(DB_PATH, timeout10) conn.execute(INSERT INTO heartbeat (success_num, fail_num, alive_proxy_num, record_time) VALUES (?, ?, ?, datetime(now)), (success_num, fail_num, alive_proxy_num)) conn.commit() conn.close()接着在每轮抓取结束后做一个基础诊断连续两轮抓取成功率低于50%就往日志里打一个WARN并把代理池里所有存活的代理重新预检一次。这个动作看起来像是在代码里平白多写了逻辑但实际运行中绝大多数监控失效都不是被封IP而是代理池悄悄枯萎——预检一次就能救回来集体失效的代理。验证这套资源有没有跑对最直接的检查手段是看price_history表里时间段的分布sqlite3 price_monitor.db SELECT count(*), substr(crawl_time,1,13) FROM price_history GROUP BY substr(crawl_time,1,13) ORDER BY 2 DESC LIMIT 24;如果某个小时段内记录数突降为0那大概率是那个时段代理池全挂了。如果记录数奇高说明价格变动频繁或者你的解析逻辑误把相同价格当成新记录写入了。这两类异常单看数据就能定位不需要翻页面。最后说一条个人习惯。从那以后每次把监控脚本部署到新机器上我都会强制走一遍放慢刷新、开WAL、加索引、写心跳日志这四步宁可前期多花十几分钟也不赌它自己稳定。跑了大半年真正让脚本翻车的不是反爬玄学而是数据库锁、编码错乱、代理池静默消失这些看起来不显眼的底层问题。希望这套资源的落地步骤和这里踩过的坑能帮你少走一段弯路。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑