
简介这是一份面向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(--headlessnew) # 新版无头模式参数旧参数会被忽略 options.add_argument(f--window-size{config[window_size]}) options.add_argument(--disable-blink-featuresAutomationControlled) prefs { profile.managed_default_content_settings.images: 2, # 禁图加速对文字类数据无影响 profile.default_content_setting_values.notifications: 2, # 禁通知弹窗 } options.add_experimental_option(prefs, prefs) driver webdriver.Chrome(optionsoptions) 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 后面比如 page2、page3。这个项目里的分页是后者直接在 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(fraw_html/page_{page_num}.html, w, encodingutf-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 做唯一约束防重复 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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_gap40%) .set_global_opts( title_optsopts.TitleOpts(title二手车价格区间分布), yaxis_optsopts.AxisOpts(name数量), xaxis_optsopts.AxisOpts(name价格区间), toolbox_optsopts.ToolboxOpts(is_showTrue), # 显示保存图片等工具 ) ) 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_optsopts.TitleOpts(title表显里程与价格关系), xaxis_optsopts.AxisOpts(name里程(公里), type_value), yaxis_optsopts.AxisOpts(name价格(万元), type_value), visualmap_optsopts.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 展示系统的大屏项目或者换成任意其他二手车平台的数据采集工具。希望帮到你。本文还有配套的精品资源点击获取