简介:这是一份面向Python爬虫与数据分析方向的毕业设计源码,适合计科、大数据等专业学生用来完成二手车平台数据采集与分析课题。项目以Selenium驱动谷歌浏览器抓取页面,结合lxml的etree与XPath解析DOM树,并针对二手车价格和表显里程的字体加密做了破解处理,后续通过pymysql写入MySQL,再利用pyecharts生成可视化图表,整体覆盖了爬虫、反爬、数据存储与展示的完整链条。资源共2000个文件,以1745个py源码为核心,辅以112个h头文件、88个txt文本、15个html页面以及少量json、pdf、js、css等,压缩包大小53.73MB,目录结构清晰,便于对照学习。已有147人学习下载,适合需要快速搭建同类型爬虫可视化项目或作为毕业设计参考的开发者。
1. 二手车爬虫数据可视化,先想清楚这四件事
做爬虫数据分析最怕的不是拿不到数据,而是拿到一堆没法用的脏数据。这个基于 Python 的二手车爬虫可视化项目,完整走通了“Selenium 抓取 → XPath 解析 → 字体反爬处理 → MySQL 存储 → pyecharts 可视化”这条链路,特别适合做毕业设计或练手完整数据项目的开发者。它解决的核心问题是:目标网站用异步渲染加载车源数据,同时用字体文件对价格、里程做加密显示,普通 requests 方案直接失效。适合的人群是已经会 Python 基础语法、想接触动态网页爬虫和数据可视化全流程的人。你不需要从零造轮子,源码里已经把这些环节串好了,你要做的是理解每个环节为什么这么设计,以及把参数调成你自己的。
整个流程里最值得关注的技术点有三个:Selenium 驱动浏览器模拟真实访问、lxml 的 etree 配合 XPath 解析 DOM 树、字体文件加密的识别与映射还原。这三个点分别对应抓取、解析、清洗三个环节,也是面试或答辩时最容易被追问的地方。接下来我把每个环节怎么实现、参数怎么设、在哪里翻过车,一步步讲清楚。
2. Selenium 动态抓取:浏览器驱动、等待策略与分页参数
2.1 为什么是 Selenium 而不是 requests
二手车平台的车源列表页,价格、表显里程这些关键字段是页面加载后通过 JavaScript 异步请求渲染出来的。如果你拿 requests 直接请求列表页,返回的 HTML 里只有页面框架和固定的导航结构,车源数据根本不在里面。常见做法是先用浏览器开发者工具找接口,直接请求后端 API,但这个项目里的目标站对接口做了签名校验,直接调接口会被拦截。所以这里选了 Selenium——让真实浏览器去加载页面,等异步请求完成后,再从渲染好的 DOM 树里取数据。
换个角度说,Selenium 是用“模拟真实用户操作”换“反爬对抗成本低”。代价是速度慢,一秒只能翻几页,但你做的是毕业设计或者中小规模数据采集,几百上千条车源数据完全够用。而且 Selenium 方案能顺带把页面里肉眼可见的数据校验逻辑一起处理掉。
# config.py 核心配置项 SELENIUM_CONFIG = { "driver_type": "chrome", # 仅支持 google chrome "headless": False, # 调试阶段建议 False,能看到浏览器行为 "window_size": "1920,1080", # 窗口尺寸,部分站点对窗口大小敏感 "page_load_timeout": 30, # 页面加载超时,单位秒 "element_wait_timeout": 10, # 元素等待超时,单位秒 "implicitly_wait": 5, # 隐式等待,找不到元素时轮询 }这里的参数是反复调过的。headless 无头模式在正式采集时可以开,但调试阶段一定要关掉,因为你看不到浏览器实际渲染出来的页面状态,很多定位问题会变成黑匣子。page_load_timeout 设太高会导致网络异常时卡很久,设太低则页面没加载完就报错,30 是均衡值。element_wait_timeout 是显式等待的兜底,后面的代码会用它。
2.2 WebDriver 初始化和页面加载流程
WebDriver 是 Selenium 和浏览器之间的桥。Chrome 浏览器版本和 chromedriver 版本必须对应,否则启动直接报 session 创建失败。我一般会在代码里做个版本断言,启动失败时把当前浏览器版本打出来,方便排查。
from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By def init_driver(config): """初始化 Chrome WebDriver,返回 driver 对象""" options = Options() if config["headless"]: options.add_argument("--headless=new") # 新版无头模式参数,旧参数会被忽略 options.add_argument(f"--window-size={config['window_size']}") options.add_argument("--disable-blink-features=AutomationControlled") prefs = { "profile.managed_default_content_settings.images": 2, # 禁图加速,对文字类数据无影响 "profile.default_content_setting_values.notifications": 2, # 禁通知弹窗 } options.add_experimental_option("prefs", prefs) driver = webdriver.Chrome(options=options) driver.set_page_load_timeout(config["page_load_timeout"]) driver.implicitly_wait(config["implicitly_wait"]) # 隐性等待全局生效 return driver这里有几个关键点。disable_blink_features 是为了降低自动化特征被检测的概率,虽然不能完全伪装成真用户,但至少能过掉基础的 webdriver 检测。禁图片这个 prefs 开关能明显提升加载速度,因为二手车列表页图片特别多,你又不抓图片。隐性等待是全局的,设置 5 秒意味着每次 find_element 找不到元素时最多轮询 5 秒;但它和显式等待的 WebDriverWait 有区别,显式等待是针对某个特定条件的,优先级更高。
def fetch_page(driver, url): """加载列表页并等待车源卡片渲染完成,返回页面 HTML""" driver.get(url) try: # 等待车源卡片元素出现,超过 10 秒则抛异常 WebDriverWait(driver, config["element_wait_timeout"]).until( EC.presence_of_element_located((By.CSS_SELECTOR, "div.car-card")) ) except Exception as e: print(f"[WARN] 等待车源卡片超时: {url}, 错误: {e}") return None # 等异步请求把价格、里程字段渲染到 DOM time.sleep(2) return driver.page_source这个函数做了两重保险:先显式等车源卡片的容器元素出现,再固定睡 2 秒等内部字段渲染完成。第一种等待保证页面框架已加载,第二种 sleep 是为了等那些晚到的异步请求。sleep 是无奈之举,但实际情况里你会发现有些字段就是比容器晚一拍,省掉这个 2 秒会偶尔抽风。更稳妥的做法是同时等待价格字段的 CSS selector 出现,但那样代码要多写几行,看你自己取舍。
2.3 分页抓取与 URL 构造
二手车列表页的分页逻辑通常是页码参数拼在 URL 后面,比如 page=2、page=3。这个项目里的分页是后者,直接在 URL 上改参数。翻页有两种思路:一种是用 Selenium 模拟点击“下一页”按钮,另一种是循环构造 URL 重新加载页面。前者更接近用户操作,但按钮的 class 变化会导致点击失败;后者简单粗暴,每页都是全新加载,页面状态干净。
def crawl_pages(driver, base_url, total_pages): """循环构造分页 URL,逐页抓取页面源码并保存""" all_html = [] for page_num in range(1, total_pages + 1): page_url = f"{base_url}?page={page_num}" print(f"[INFO] 正在抓取第 {page_num} 页: {page_url}") html = fetch_page(driver, page_url) if html: all_html.append(html) # 每页抓完保存一次,避免中途崩溃导致前面全部白抓 with open(f"raw_html/page_{page_num}.html", "w", encoding="utf-8") as f: f.write(html) else: print(f"[WARN] 第 {page_num} 页抓取失败,跳过") # 页面间隔,给服务器和本地资源一点喘息时间 time.sleep(random.uniform(1.5, 3.5)) return all_html这个实现里有几个工程细节。每页 HTML 单独落盘,这是最关键的后悔药机制——一旦后续解析或入库环节爆错,你不需要重新跑整个爬虫,直接从本地 HTML 文件恢复现场就行。抓完 10 页后,内存里只保存了这 10 页的 HTML 源码,总量不大。sleep 随机 1.5 到 3.5 秒是对目标站的基本尊重,也是给自己留余地,避免请求频率太高被临时限制。total_pages 参数可以由外部传入,你在跑之前先手动打开列表页数一下总页数,写死进去即可。
3. XPath 解析与字体加密:从 DOM 树到结构化字段
3.1 etree.HTML 构造 DOM 树与 XPath 定位
拿到 HTML 源码后,解析工作交给 lxml。lxml 的性能在纯 Python 解析库中是第一梯队,它把 HTML 源码解析成一棵 DOM 树,然后你用 XPath 表达式在这棵树上定位节点,提取需要的属性或文本。XPath 比正则表达式适合做 HTML 解析,因为它直接面向标签层级结构,你不用关心各种边界情况。
from lxml import etree def parse_car_list(page_html): """从列表页 HTML 中解析出车源数据,返回字段字典的列表""" tree = etree.HTML(page_html) # 自动修正不闭合标签,返回 Element 对象 car_cards = tree.xpath('//div[contains(@class, "car-card")]') cars = [] for card in car_cards: item = {} # 相对当前卡片的 XPath,从整个页面级表达式改为局部定位 title_node = card.xpath('.//h3[contains(@class, "car-title")]/text()') item["title"] = title_node[0].strip() if title_node else None price_node = card.xpath('.//span[contains(@class, "price")]/text()') item["price_text"] = price_node[0].strip() if price_node else None mile_node = card.xpath('.//div[contains(@class, "mile")]/text()') item["mile_text"] = mile_node[0].strip() if mile_node else None # 详情页链接,后续可用来抓细信息 link_node = card.xpath('.//a[contains(@class, "car-link")]/@href') item["detail_url"] = link_node[0] if link_node else None cars.append(item) return cars这里的 XPath 写法有一个常见的坑:定位卡片集合时用了 //div,这表示在整个文档里找所有符合条件的 div。但提取卡片内部字段时,表达式必须以 .开头,表示从当前卡片节点往下找,否则会搜到页面里其他地方的同名节点,数据就串了。这不是玄学,是 XPath 相对路径和绝对路径的语法规则,但出错率极高。另外 etree.HTML 有自动补齐标签的能力,遇到页面里 div 没闭合的情况,它不会报错而是自动修复,这是比正则解析稳妥得多的原因。
3.2 字体文件加密:认识这个“数字变乱码”的机制
二手车平台的价格和表显里程,页面源码里看起来不是正常数字。比如页面上你肉眼看到“12.80 万”,但 XPath 取出来的文本可能是“6.10 万”或者一串乱码。这是因为页面通过 CSS 引入了自定义字体文件,字体文件把 Unicode 码位和数字字形做了重新映射。浏览器拿到字体文件后按映射绘制字形,你在页面上看到的“12.80 万”实际上是“6.10 万”的码位加上自定义字体渲染出来的结果。XPath 取的是 DOM 里的码位文本,不是渲染后的像素,所以数字对不上。
处理思路是:把字体文件下载下来,解析它的字符映射表(cmap 表),把页面上取到的乱码码位映射回真实数字,再替换回去。这里说的是公开已知的字符映射关系,页面本身就是给所有访客看的数据,我们只是把被字体编码过的文本还原成可读形式再入库。
# 字体映射还原工具函数 import requests from fontTools.ttLib import TTFont def build_font_map(font_file_path, known_chars): """解析字体文件的 cmap 表,建立 字体码位 -> 实际数字 的映射 known_chars: 页面上看到的乱序候选字符,例如 ['1','2','3','4','5','6','7','8','9','0','.'] """ font = TTFont(font_file_path) cmap = font.getBestCmap() # 返回 {unicode码点: 字形名称} 的字典 # 常见做法:观察字体文件里字形名称和字符对应关系,重建成映射字典 # 实际项目中,可以通过比对页面渲染截图来确定字符对应顺序 result = {} for code, glyph_name in cmap.items(): char = chr(code) if char in known_chars: # glyph_name 通常形如 uniED23 或类似编号,需要根据字体内部关系对应 result[char] = glyph_name return result def restore_text(encoded_text, font_map): """把字体加密后的文本还原为明文数字""" # 训练集里收集的字体映射,形如: 真实数字 -> 加密后码位 reverse_map = {v: k for k, v in font_map.items()} restored = [] for ch in encoded_text: if ch in reverse_map: restored.append(reverse_map[ch]) else: restored.append(ch) # 非加密字符原样保留,比如"万"字 return ''.join(restored)这个函数里最重要的判断是 known_chars 怎么来。我的做法是下载字体文件后用 FontCreator 或在线工具打开,看它里面图形的顺序,和页面上显示的数字对照,建立映射表。整个过程就是一次性工作,因为同一个字体文件的映射是固定的。目标站偶尔会更换字体文件来更新映射,所以你需要在程序里对字体文件做哈希校验,如果哈希变了就重新生成映射表。这个逻辑我放在后面的避坑章节详细讲。
表 1 是解析模块的字段清单,你自己写解析时照着这个表格核对:
| 字段 | XPath 定位 | 注意点 |
|---|---|---|
| 车源标题 | h3.car-title 的 text | 可能包含空格和换行,需要 strip |
| 价格 | span.price 的 text | 必须过字体映射还原 |
| 表显里程 | div.mile 的 text | 必须过字体映射还原 |
| 车源链接 | a.car-link 的 @href | 需要拼接域名前缀 |
| 上牌日期 | div.reg-date 的 text | 可能缺失,需要做空值处理 |
3.3 数据清洗与结构化输出
XPath 取出来的字段是字符串,直接入库会有一堆问题:价格可能带“万”字,里程可能带“公里”,上牌日期可能是空字符串。清洗阶段把它们转成统一的数值或标准格式,后续分析才不需要在 SQL 里做各种类型转换。
import re def clean_car_item(item): """清洗单条车源数据,返回可直接入库的字段字典""" # 价格清洗: "12.80万" -> 12.80 price_text = item.get("price_text") or "" price_num = None if "万" in price_text: # 去掉所有非数字和点(但先经过字体还原) price_value = re.search(r"(\d+\.?\d*)", price_text) if price_value: price_num = float(price_value.group(1)) item["price"] = price_num # 表显里程清洗: "5.2万公里" -> 52000 mile_text = item.get("mile_text") or "" if "万" in mile_text: mile_value = re.search(r"(\d+\.?\d*)", mile_text) if mile_value: item["mileage"] = int(float(mile_value.group(1)) * 10000) else: item["mileage"] = None # 上牌年份提取 reg_text = item.get("reg_text") or "" year_match = re.search(r"(20\d{2})", reg_text) item["reg_year"] = int(year_match.group(1)) if year_match else None # 价格或里程为空的数据直接丢弃,避免污染后续分析 if item["price"] is None or item["mileage"] is None: return None return item清洗逻辑里最有价值的部分是价格字段的类型约定。存入 MySQL 时我用了 DECIMAL(10,2) 类型存储价格,单位是万元,保留两位小数。表显里程用 INT 类型存储实际公里数,比如 5.2 万公里存成 52000。这样后续做价格区间统计、平均里程分桶时,SQL 写起来非常顺畅,不需要在语句里做字符串处理。把数据标准化的工作尽量往前放,后面所有环节都会轻松。
4. MySQL 入库与查询:建表语句、pymysql 操作与去重设计
4.1 数据表设计与字段类型选择
二手车数据入库前,先想清楚表结构。这个项目的查询场景集中在价格分析、里程分析、品牌分布、车系排名,所以字段类型要按分析方向来设计,而不是简单地全用 VARCHAR。
CREATE TABLE IF NOT EXISTS second_hand_car ( id INT AUTO_INCREMENT PRIMARY KEY, source_page INT DEFAULT NULL COMMENT '来源页码', title VARCHAR(255) DEFAULT NULL COMMENT '车源标题', brand VARCHAR(50) DEFAULT NULL COMMENT '品牌', model VARCHAR(100) DEFAULT NULL COMMENT '车系', price DECIMAL(10,2) DEFAULT NULL COMMENT '价格(万元)', mileage INT DEFAULT NULL COMMENT '表显里程(公里)', reg_year INT DEFAULT NULL COMMENT '上牌年份', detail_url VARCHAR(500) DEFAULT NULL COMMENT '详情页链接', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '入库时间', UNIQUE KEY uk_detail_url (detail_url(191)) COMMENT 'URL 做唯一约束,防重复' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='二手车车源数据表';这里有两个工程上的取舍。一是 detail_url 加了 UNIQUE KEY,这是最直接的防重复手段。爬虫重复跑场景下,同一辆车源多次抓取,URL 是相同的,利用唯一键插入时直接报错或者用 INSERT IGNORE 跳过,比每轮抓取前做 SELECT 查重要高效得多。二是 price 用 DECIMAL 而不是 FLOAT,原因很简单——浮点数在 MySQL 里会有精度误差,价格统计汇总时会出现 12.80 和 12.79 这种莫名其妙的差异。DECIMAL 是精确小数,专门为货币场景设计的。
4.2 pymysql 连接与批量插入
pymysql 是这套链路里操作 MySQL 的模块,功能上完全覆盖你的增删改查需求。连接参数里最重要的是 charset 要设成 utf8mb4,因为车源标题里可能包含生僻字或特殊符号,MySQL 的 utf8 是 3 字节编码,存不下 4 字节的字符,会报 Incorrect string value 错误。
import pymysql DB_CONFIG = { "host": "127.0.0.1", "port": 3306, "user": "root", "password": "your_password", "database": "car_db", "charset": "utf8mb4", } def insert_cars(car_list): """将清洗后的车源数据批量写入 MySQL,重复 URL 自动跳过""" conn = pymysql.connect(**DB_CONFIG) try: with conn.cursor() as cursor: sql = """ INSERT IGNORE INTO second_hand_car (source_page, title, brand, model, price, mileage, reg_year, detail_url) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) """ # 数据格式对齐,None 值由 MySQL 处理为 NULL rows = [ ( item.get("source_page"), item.get("title"), item.get("brand"), item.get("model"), item.get("price"), item.get("mileage"), item.get("reg_year"), item.get("detail_url"), ) for item in car_list ] affected = cursor.executemany(sql, rows) conn.commit() print(f"[INFO] 批量插入完成,影响行数(含重复被忽略): {affected}") except pymysql.err.IntegrityError as e: conn.rollback() print(f"[ERROR] 唯一键冲突,事务回滚: {e}") finally: conn.close()executemany 批量执行比逐条 execute 快很多,底层是优化的多行一次性写入。但要注意 executemany 遇上一条脏数据报错时,整个事务都会受到影响。所以前面清洗环节把非法数据过滤掉非常关键,宁可在 Python 侧多花一点时间做校验,也不要让脏数据进到入库环节。INSERT IGNORE 的这个 ignore 关键字让重复 URL 的数据静默跳过而不是报错,配合唯一键索引,这就是去重的核心机制。
4.3 查询复用与参数化防注入
可视化环节需要反复查询 MongoDB 里的数据,但查询方式统一封装在一个函数里更可维护。另外,项目里多处用到了字符串拼接 SQL 的情况,这是一个隐患。
def query_car_stats(stat_type, **kwargs): """通用查询函数,stat_type 指定统计类型,返回查询结果列表""" conn = pymysql.connect(**DB_CONFIG) try: with conn.cursor(pymysql.cursors.DictCursor) as cursor: if stat_type == "price_dist": # 价格分桶统计: 0-5万, 5-10万, 10-15万, 15万以上 sql = """ SELECT CASE WHEN price < 5 THEN '0-5万' WHEN price < 10 THEN '5-10万' WHEN price < 15 THEN '10-15万' ELSE '15万以上' END AS price_range, COUNT(*) AS cnt FROM second_hand_car GROUP BY price_range """ elif stat_type == "brand_stats": # 品牌销量 TOP10 sql = """ SELECT brand, COUNT(*) AS cnt FROM second_hand_car GROUP BY brand ORDER BY cnt DESC LIMIT 10 """ else: cursor.execute("SELECT 1") return [] cursor.execute(sql) result = cursor.fetchall() return result finally: conn.close()这个封装的参数化设计主要体现在 stat_type 的分支判断,能支持的价格分布统计、品牌销量排行、里程分桶统计等场景,全部以 SQL 形式直接写死在函数里。实际做可视化时只用调 query_car_stats("price_dist") 就能拿到字典列表,你不需要在可视化代码里写任何 SQL。这样数据查询逻辑和展示逻辑是分开的,后面无论换成 Flask 后端还是 Jupyter Notebook,代价都很小。
5. pyecharts 可视化面板:图表类型选择与页面集成
5.1 pyecharts 图表配置与数据对接
pyecharts 是 Echarts 的 Python 封装,它生成的图表本质还是 HTML + JavaScript,只是你在 Python 侧用链式调用方式配置。它比 matplotlib 更适合做数据展示类项目,因为交互能力和视觉效果强得多,鼠标悬停有提示,图表有缩放、保存图片等内置功能,不需要自己写前端代码。
from pyecharts.charts import Bar, Pie, Scatter from pyecharts import options as opts def render_price_distribution(): """从 MySQL 读取价格分布数据,生成柱状图 HTML""" data = query_car_stats("price_dist") categories = [item["price_range"] for item in data] counts = [item["cnt"] for item in data] bar = ( Bar() .add_xaxis(categories) .add_yaxis("车源数量", counts, category_gap="40%") .set_global_opts( title_opts=opts.TitleOpts(title="二手车价格区间分布"), yaxis_opts=opts.AxisOpts(name="数量"), xaxis_opts=opts.AxisOpts(name="价格区间"), toolbox_opts=opts.ToolboxOpts(is_show=True), # 显示保存图片等工具 ) ) bar.render("chart_price_dist.html") print("[INFO] 价格分布图已生成: chart_price_dist.html")这里的图表配置有几个细节值得注意。category_gap 参数控制柱状图中每根柱子之间的间距,默认是 20%,设为 40% 会让柱子看起来更舒展。toolbox_opts 是 Echarts 自带工具栏,包含数据视图、下载图表功能,这个对答辩或演示场景非常有用,可以直接在浏览器里把图表导出成图片。render 方法输出的是一个独立 HTML 文件,浏览器直接打开即可,不依赖任何后端服务。
5.2 多图表整合为数据展示页面
单张图表不够,这个项目里我整合了四个核心视图:价格分布柱状图、品牌销量排行榜、里程分布直方图、价格与里程散点图。散点图用来观察价格和里程之间的相关性,车辆里程越高、价格越低这个趋势在图上会非常直观。
from pyecharts.charts import Scatter def render_scatter_price_mileage(): """价格与里程散点图,数据按价格升序取前 500 条防过密""" conn = pymysql.connect(**DB_CONFIG) try: with conn.cursor() as cursor: cursor.execute(""" SELECT mileage, price FROM second_hand_car WHERE mileage IS NOT NULL AND price IS NOT NULL ORDER BY price DESC LIMIT 500 """) rows = cursor.fetchall() scatter = ( Scatter() .add_xaxis([r[0] for r in rows]) .add_yaxis("价格(万元)", [r[1] for r in rows]) .set_global_opts( title_opts=opts.TitleOpts(title="表显里程与价格关系"), xaxis_opts=opts.AxisOpts(name="里程(公里)", type_="value"), yaxis_opts=opts.AxisOpts(name="价格(万元)", type_="value"), visualmap_opts=opts.VisualMapOpts( max_=30, # 价格大于 30 万的数据统一映射为最深色 range_color=["#50a3ba", "#eac736", "#d94e5d"], ), ) ) scatter.render("chart_scatter.html") finally: conn.close()散点图这里有个决策值得说明:LIMIT 500 是刻意为之的。二手车源数据几千条全画上去,图表会变成一坨密集的点,看不出任何趋势。取价格 TOP 500 条后,散点的分布形态更有说服力。visualmap 组件给点加了颜色渐变映射,价格从低到高对应从蓝色到红色,你在视觉上一眼就能看出高价车的里程通常更低。这其实就是给答辩准备的视觉冲击点。
表 2 是图表与数据表的对应关系:
| 图表类型 | 数据源 | 展示目的 |
|---|---|---|
| 柱状图 | price_dist 查询 | 价格区间分布情况 |
| 横向柱状图 | brand_stats 查询 | 品牌销量 TOP10 |
| 直方图 | mileage 字段 | 里程分布集中趋势 |
| 散点图 | mileage + price | 价格随里程衰减趋势 |
6. 复现避坑与排错:字体错乱、连接丢失与数据校验的实战记录
6.1 字体映射错乱:页面数字变乱码
现象:第一次跑完解析后,入库价格出现 0.5、1.2 这种明显不对的数值,和页面上肉眼看到的 12.8、15.6 对不上。
原因:目标站更换了字体文件,码位和数字的映射关系变了。我最初建的映射表是写死的一份 JSON,换字体后映射关系整体偏移,所有数字都错了。
解决:每次抓取完成后,单独保存页面里引用的字体文件 URL 和文件哈希。解析前先比对哈希,变了就重新生成映射表。具体到代码里,我把 build_font_map 的触发条件从“启动时执行一次”改成“检测到字体文件哈希变化时执行”。这个改动让字体映射从固定配置变成了自适应逻辑,后面再遇到站点换字体的场景就不用人工介入重新分析了。从那以后我每次复现这个项目,都会强制走一遍“先检查字体哈希,再跑解析”的流程。
6.2 pymysql 连接丢失:CPU 跑着跑着报 2006
现象:爬虫连续跑 20 分钟后,控制台弹出 pymysql.err.OperationalError: (2006, 'MySQL server has gone away'),程序挂掉。
原因:MySQL 默认的 wait_timeout 是 8 小时,但本地开发环境经常有人改了配置,或者服务器资源回收机制把空闲连接断掉了。爬虫在翻页和解析时,数据库连接一直空闲,没做心跳检测,服务器直接断开。
解决:每个函数内部独立创建连接、用完关闭,不让连接跨函数共享。同时给 pymysql.connect 加上 connect_timeout 和 read_timeout 参数。这样即使某条连接断了,下次调用函数时重新获取新连接,不会出现长连接失效的问题。代价是连接建立的开销略大,但对本地 MySQL 来说毫秒级,可以忽略。
6.3 元素等待超时:列表页结构微调导致全挂
现象:某天重新运行完整流程,第二页开始所有页面都返回空数据,fetch_page 里的 WebDriverWait 全部超时。
原因:目标站在某个时间点更新了前端模板,div.car-card 这个 CSS 类名改成了 div.car-item,原有定位表达式失效。但列表页能正常打开,所以程序没有报网络错误,而是静默地返回空列表。
解决:在 fetch_page 的等待选择器上做兼容,写成 div.car-card, div.car-item。静态页面的改动通常只影响类名,不影响整体 DOM 结构,兼容两种方案的成本很低。另外在 parse 阶段增加了数据数量校验,解析结果为 0 时强制告警,不把空结果写进数据库。
6.4 数据入库后分析结果异常:重复数据污染统计
现象:第一次跑出来品牌销量 Top1 是个不知名小品牌,点开数据发现对应品牌出现了两百多条完全相同的车源记录。
原因:detail_url 在某些情况下为空时,UNIQUE KEY 不对 NULL 做去重,MySQL 允许多个 NULL。同一辆车源每次抓取时链接缺失,就产生了多条记录。
解决:入库前对 detail_url 做空值过滤,为空的数据直接丢弃或者用 title + price 拼接一个虚拟 URL 作为去重键。实际项目中我优先选择丢弃,因为连详情页链接都没有的数据,后续也无法深入分析。
6.5 最后验证一套完整的复现清单
到这个环节,你已经理解了整套链路,我可以把实际跑这个项目的顺序和验证标准告诉你。项目源码包解压后,先按 requirements.txt 装依赖,然后按这个顺序操作:
# 1. 初始化数据库 mysql -u root -p < sql/init_db.sql # 2. 配置数据库连接 # 修改 config.py 中的 DB_CONFIG 为自己的用户名密码 # 3. 运行爬虫 python crawl.py --pages 20 --keyword 紧凑型 # 4. 运行可视化 python analyze.py --output report.html每一步的验证标准是:爬虫运行后 database 里 second_hand_car 表有新增数据;report.html 打开后四个图表正常显示且数据不为空。如果你发现某一步断了,回到对应章节检查参数。项目里的源码包不是黑匣子,所有代码都在这篇文章里讲述了对应逻辑,你完全可以改参数、换字段、加图表。这不会是个一次性的作业,而是一套你可以照着扩展成 Flask 展示系统的大屏项目,或者换成任意其他二手车平台的数据采集工具。希望帮到你。
本文还有配套的精品资源,点击获取