ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python定向爬虫构建商品比价系统:从抓取到排序全流程解析

Python定向爬虫构建商品比价系统:从抓取到排序全流程解析 简介面向毕业设计、课程设计及项目开发场景这套基于 Python 的定向爬虫商品比价系统源码用于抓取特定站点商品信息并完成价格比较给出了可直接运行的完整工程。压缩包内共 17 个文件、约 31KB核心为 10 个 Python 脚本覆盖定向抓取、页面解析、数据清洗、存储与比价处理等环节另有 3 个 pyc 编译文件、2 个 txt 说明、1 个 SQLite 数据库文件及 1 份 Markdown 说明文档便于理解项目结构、快速定位关键模块。源码已经过严格测试可直接参考并在此基础上扩展数据源、优化爬取策略或调整比价逻辑是计算机相关专业学生完成课题设计、课程作业的扎实起点也适合想通过实战项目入门爬虫的开发者。目前已有 77 人浏览学习结合源码和数据库能够较快梳理出从定向爬虫到比价展示的完整实现思路。同时附带的说明文档和数据库文件也能帮助读者减少环境配置与数据准备的时间。1. 从翻报价页面到定向爬虫这个比价系统在解决什么问题打开商品页面、复制标题、再切到另一个平台粘贴搜索来回折腾几轮同一个型号的价格差才勉强拼出来。这种手工比价不仅慢还容易看漏更别说赶上促销节点页面价格一晚上变好几次。用 python 定向爬虫实现的商品比价系统把这一串动作收敛成一条命令输入关键词自动抓取多个目标站点的商品标题、价格和链接数据落库后按价格升序输出直接回答“哪个平台更便宜”。这个项目和通用 python 爬虫最大的差异在“定向”抓取目标是预置的站点列表页面结构相对固定系统把精力花在解析精度、去重和数据清洗上而不是全网遍历。源码包 Commodity-parity-system-master 从目录上就能看出完整链路抓取、解析、存储、比价各占一块单机就能跑通。它适合当毕业设计或课程设计的底稿也适合刚学完 python 基础、想拆一个真实爬虫项目的人。跑起来只需要准备 python 环境再装 requests、beautifulsoup4、lxml 三个依赖库后面的章节会按源码模块逐个展开。2. 数据模型与去重策略先定表结构再写抓取逻辑2.1 数据流从关键词到比价结果需要经过几个环节一条关键词从提交到比价结果落地中间的链路是固定的用户输入关键词爬虫按预置站点列表构造搜索 URL逐站抓取搜索结果页解析商品卡片清洗标题和价格写入数据库最后由查询逻辑按价格排序输出。这个顺序看似常规但决定它的是数据流方向不是代码目录。很多课程设计代码会把“抓取”和“存储”搅在一起解析完直接打印开发时很顺手后续想加去重、重跑、历史价格都无处下手。这个项目把存储单独拉成一层所有字段集中在 products 表里比价逻辑只跟表打交道不碰页面。这样换站点时只需要改爬虫模块存储层和比价逻辑原封不动。对于数据量在几千条级别的比价场景单机 SQLite 完全够用不需要引入 MySQL这也让整个项目可以在任何一台装了 python 的机器上直接演示。2.2 商品表设计价格用整数“分”存储表结构承担了去重、排序、来源追踪三个职责字段设计越贴近后续查询语句写起来越省事。常见的做法是每个站点单独建一张表但那样比价时要跨表查询非常别扭。这个项目把所有平台聚合到一张表里用 platform 字段区分来源查询时一条 SQL 就能完成全站排序。CREATE TABLE IF NOT EXISTS products ( id INTEGER PRIMARY KEY AUTOINCREMENT, keyword TEXT NOT NULL, title TEXT NOT NULL, platform TEXT NOT NULL, product_url TEXT NOT NULL UNIQUE, price_cents INTEGER NOT NULL DEFAULT 0, original_price TEXT, crawled_at TEXT NOT NULL DEFAULT (datetime(now, localtime)) );price_cents 存的是“分”不是“元”。浮点数比较在跨平台场景下容易出现 0.10.2 这类精度问题用整数分存储可以保证排序结果可靠前端展示时再除以 100。original_price 保留页面原始文本比如“¥329.00”或“到手价329”当解析结果出现偏差时可以直接对照原始数据。product_url 的 UNIQUE 约束负责最基础的去重同一个商品链接第二次写入会被拒绝。字段的职责划分如下表字段类型作用示例keywordTEXT搜索关键词比价时按它分组机械键盘N284titleTEXT商品标题用于展示与相似度判断XX品牌 机械键盘 104键platformTEXT站点来源标识platform_aproduct_urlTEXT商品链接UNIQUE 约束去重https://.../item/10001price_centsINTEGER归一化后的价格单位分32900original_priceTEXT页面原始价格文本便于排查到手价329.00crawled_atTEXT抓取时间默认当前时间2025-01-01 10:00:00这张表建议加上两个索引一个按 keyword一个按 price_cents。数据量到达几千条以后全表扫描依然能用但加了索引可以让查询计划更稳定避免关键词多了以后排序变慢。CREATE INDEX idx_products_keyword ON products(keyword); CREATE INDEX idx_products_price ON products(price_cents);索引建立的依据是实际查询条件比价时先 where keyword 再 order by price_cents所以给 keyword 建普通索引加速过滤给 price_cents 建索引加速排序。如果后续要按 platform 过滤再补一个 platform 单列索引即可不需要一上来就建联合索引那是数据量到十万级才需要考虑的事。2.3 入库操作INSERT OR REPLACE 处理重复抓取UNIQUE 约束只能拒绝重复写入但完整链路里第二次跑同一个关键词时期望的行为是更新价格而不是报错。SQLite 的 INSERT OR REPLACE 可以解决这个问题同一 product_url 存在则整行替换不存在则插入新行。conn.execute( INSERT OR REPLACE INTO products (keyword, title, platform, product_url, price_cents, original_price) VALUES (?, ?, ?, ?, ?, ?), (keyword, title, platform, product_url, price_cents, original_price) ) conn.commit()注意 INSERT OR REPLACE 的行为本质是“先删后插”主键 id 会变化。如果后面要追加价格历史表并按商品 id 关联主键变化会导致历史记录对不上号。更稳妥的写法是先按 product_url 查一遍存在则 UPDATE不存在则 INSERT毕设代码里可以直接复用这条 REPLACE 语句但要清楚它带来的副作用。对商品链接这种天然唯一的字段来说URL 本身就是业务主键用它做去重依据比用自增 id 更可靠。3. 定向抓取与解析URL 构造、重试策略与 Selector 匹配3.1 用 params 构造目标 URL而不是字符串拼接定向爬虫的第一步是按关键词构造搜索页 URL。不同站点的 URL 规则差异很大常见参数名有 q、keyword、wd 等中文关键词还要做 URL 编码。直接拿字符串拼接很容易漏编码、漏参数requests 的 params 参数可以自动处理编码和拼接。params {q: keyword, sort: price, page: page_no} resp requests.get( SEARCH_URL_TEMPLATE, paramsparams, headersself.headers, timeout(5, 10) )这里的参数各有用处。SEARCH_URL_TEMPLATE 是站点搜索页的固定前缀不带查询字符串params 里的 q 会被自动编码成 UTF-8 格式拼到问号后面sort 控制搜索结果按价格排序page 用于翻页时递增。headers 至少提供一个常规浏览器的 User-Agent否则部分站点会直接返回 403。timeout 传的是元组 (5, 10)表示连接超时 5 秒、读取超时 10 秒比单个数值更精细适用于网络波动较大的环境。3.2 请求重试与状态码处理网络请求在真实环境里一定会遇到超时、连接中断、临时限流这几类错误都需要重试。常见做法是把重试次数和间隔做成可配置参数每次请求失败后等待一段时间再试避免无限重试拖垮整个任务。def fetch_page(url, retries3, retry_interval1.5, headersNone): for attempt in range(retries): try: resp requests.get(url, headersheaders, timeout(5, 10)) resp.raise_for_status() resp.encoding resp.apparent_encoding return resp.text except requests.RequestException as exc: print(f第 {attempt 1} 次请求失败: {exc}) if attempt retries - 1: time.sleep(retry_interval * (attempt 1)) return Noneretries3 表示单页最多请求 3 次超过后放弃该页并返回 None由上层决定跳过还是记录。retry_interval 是基础等待秒数这里用递增等待第一次失败后等 1.5 秒第二次等 3 秒呈线性退避比固定间隔更有效。resp.raise_for_status() 会把 4xx/5xx 状态码抛成异常避免把错误页当正常页面解析。resp.apparent_encoding 解决部分站点编码声明错误导致的中文乱码问题。抓取过程中常见状态码的处理方式如下状态码含义处理方式200正常返回进入解析流程403访问被拒绝检查 Headers降低频率404 / 410页面不存在放弃该页记录日志500 / 502 / 503站点异常或限流等待后重试403 和 503 需要区分对待。403 通常说明请求头不被接受重试大概率无效要检查 User-Agent 或请求频率503 多为临时性限流睡几秒再试往往就能拿到数据。代码里把两者都归为 RequestException 统一处理虽然简单但会掩盖问题调试时建议按状态码分两个分支写日志便于定位是 IP 层面的问题还是目标站临时故障。3.3 标题与价格的 Selector 解析页面拿到手之后解析环节决定数据质量。BeautifulSoup 配合 lxml 解析器在性能和容错之间比较平衡日常的搜索结果页足够应付。具体的选择器每个站点都不一样但套路固定先定位商品卡片区域再在卡片内提取标题、价格和链接。from urllib.parse import urljoin def parse_product_list(html, selectors): soup BeautifulSoup(html, lxml) items [] for card in soup.select(selectors[card]): title_node card.select_one(selectors[title]) price_node card.select_one(selectors[price]) link_node card.select_one(selectors[link]) if not title_node or not price_node: continue items.append({ title: title_node.get_text(stripTrue), price_text: price_node.get_text(stripTrue), product_url: urljoin(selectors[base_url], link_node.get(href)) }) return itemsparse_product_list 接收两个参数页面 HTML 和选择器配置。selectors 是字典card 是商品卡片的定位表达式title、price、link 分别对应该卡片内的标题、价格和链接。解析时写 continue 而不是给空值是因为一张卡片缺标题或缺价格通常说明它可能是广告位或推荐位不应该混入比价结果。urljoin 用来处理相对链接避免拿到形如 /item/10001 的路径后无法拼出完整地址。选择器层面最常见的坑是过度定制。有人习惯把整段 HTML 结构写死在代码里换一个页面布局就崩。这个项目把选择器集中在配置区换站点时只改配置不动代码这种配置与逻辑分离的方式值得保留。如果字段一直选不中先确认页面是不是动态渲染在浏览器里右键查看网页源码源码里没有商品数据说明数据来自异步接口这种情况下应该去找数据接口而不是硬解析 HTML 骨架。4. 比价核心逻辑价格清洗、窗口函数排名与榜单格式4.1 把页面价格文本转成可比较的整数最终决定比价结果的是价格页面上的“¥329.00”“到手价329满减后319”这类文本不能直接比较。清洗逻辑是去掉货币符号和中文提取数字部分转成整数“分”。这里用正则提取而不是 replace 暴力去字符是因为价格文本中可能混有逗号、空格和促销文案正则一次就能匹配出有效数字。import re def parse_price_to_cents(text: str) - int: if not text: return 0 cleaned text.replace(,, ).replace( , ) m re.search(r(\d(?:\.\d{1,2})?), cleaned) if not m: return 0 return int(round(float(m.group(1)) * 100))正则(\d(?:\.\d{1,2})?)匹配整数部分加最多两位小数匹配结果乘以 100 后取整。保留两位小数是因为有的站点价格显示为 329.9直接乘以 100 得到 32990round 可以规避浮点乘法带来的 32989.99 这类误差。返回 0 的情况统一视为异常价格后续查询时用price_cents 0过滤掉避免价格无效的商品排在榜单最前面。4.2 一条 SQL 输出全站最低价排行比价查询的核心是“同一个关键词下价格从低到高排列”。前面表结构里预留的 keyword 和 price_cents 字段在这里发挥作用SELECT keyword, title, platform, price_cents, product_url FROM products WHERE keyword ? AND price_cents 0 ORDER BY price_cents ASC LIMIT ?;WHERE 子句中 keyword 用参数占位符 ? 传入防止 SQL 拼接注入price_cents 0把清洗失败的记录过滤掉ORDER BY price_cents ASC 升序排列排在最前面的就是当前最低价LIMIT 控制返回条数比价页面默认取前 10 条就够用。这个查询在当前数据量下执行的是索引扫描几千条记录毫秒级就能返回结果。如果以后再添加商城券、满减字段只需要在这个查询外层再套一层计算逻辑不需要改动存储结构。4.3 每个平台只保留最便宜的一条窗口函数排名的用法有时候同一个关键词下一个平台占了好几项另一个平台一条都没有。更公平的展示方式是“每个平台只推最低的那一条”这时可以用窗口函数在 SQL 里直接完成。SQLite 3.25 以上版本支持 ROW_NUMBER()老版本则需要用相关子查询。SELECT keyword, title, platform, price_cents, product_url FROM ( SELECT p.*, ROW_NUMBER() OVER ( PARTITION BY platform ORDER BY price_cents ASC ) AS rn FROM products p WHERE keyword ? AND price_cents 0 ) t WHERE t.rn 1 ORDER BY price_cents ASC;内层查询先按 keyword 过滤再以 platform 分区、按价格升序给每个平台的商品编号价格最低的那条 rn 等于 1外层查询只取 rn1 的行就得到了“每个平台一个最低价”的结果。外层再按价格升序排列输出依然是全站最便宜的排最前面。这个写法对比价场景很实用适合在课程设计里直接演示窗口函数的能力。拿到结果集之后需要把“分”还原成“元”再输出def format_rank(rows): result [] for row in rows: result.append( f{row[platform]:12s} f{row[price_cents] / 100:8.2f}元 f{row[title]} ) return \n.join(result)format_rank 只做展示层转换不改变存储单位。f-string 里price_cents / 100得到元:8.2f控制小数点后两位platform 用:12s左对齐输出在终端里是整齐的表格形文本。业务层始终使用“分”只有到输出边界才转换这个习惯可以避免很多类似 19.9 与 19.90 不相等的问题。5. 调试三板斧与扩展日志、HTML 副本与请求节奏控制5.1 请求失败先看日志不要直接改代码第一次跑爬虫大概率不会一次通过常见现象是有输出但全是 0 元、页面返回 403、某些商品缺标题。先把日志级别设为 DEBUG并给 fetch_page 和 parse_product_list 分别记录入口和出口日志logging.basicConfig( levellogging.DEBUG, format%(asctime)s %(levelname)s: %(message)s ) logging.debug(fetch %s, retries%d, url, retries) logging.debug(parse %d cards, drop 0-price: %d, total, dropped)5.2 选择器失效时保存 HTML 副本Selector 选不中元素时不要反复修改表达式然后重新请求页面。先把 HTML 保存到本地用浏览器开发者工具对照验证with open(debug_page.html, w, encodingutf-8) as f: f.write(html)保存副本的同时记录当时的 URL 和状态码可以完整还原问题现场。在浏览器里打开 debug_page.html用 CtrlShiftC 重新定位商品卡片结构比对实际 class 名称与配置里 Selector 的差异几乎都能一步定位。5.3 请求伪装与抓取节奏控制requests 默认的 User-Agent 很容易被识别常见做法是准备几个常规浏览器的 User-Agent 轮换使用。抓取节奏同样重要连续高频请求会触发站点限流给每个页面设置随机化的等待时间import random USER_AGENTS [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ..., Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ... ] headers {User-Agent: random.choice(USER_AGENTS)} time.sleep(random.uniform(0.8, 2.0))random.uniform(0.8, 2.0) 生成 0.8 到 2 秒之间的随机等待时间让请求间隔没有明显规律同时不影响整体抓取效率。如果站点频繁返回 503可以把等待时间上限提高到 5 秒再试。抓取多页时建议把页号间隔也随机化不要按固定节奏翻页。5.4 给项目加一个价格历史表比价系统做到这里已经能回答“现在哪个平台最便宜”。想进一步做价格波动提醒需要在写入 products 之前把旧价格留底。INSERT OR REPLACE 会丢弃旧数据因此可以先把旧记录查出来插入 price_history 表再执行 REPLACECREATE TABLE IF NOT EXISTS price_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, product_url TEXT NOT NULL, price_cents INTEGER NOT NULL, crawled_at TEXT NOT NULL );写入逻辑放在爬虫入库前先按 product_url 查询旧价格如果存在且与当前价格不一致插入一条历史记录再更新 products 表。price_history 按 (product_url, crawled_at) 建联合索引后查询某商品的历史价格曲线就不需要全表扫描了。本文还有配套的精品资源点击获取
返回列表