☰
二手车爬虫数据可视化毕设:Selenium+字体反爬+MySQL+pyecharts全链路解析
2026/9/28 8:05:30 网站建设 项目流程

简介:基于Python的二手车爬虫数据可视化分析毕业设计源码,面向计算机相关专业学生及爬虫入门者,提供一套完整的项目解决方案。程序使用Selenium驱动Google浏览器抓取二手车网站页面,借助lxml的etree对象与XPath解析DOM树,并针对价格、表显里程等关键字段的字体文件加密进行了处理,同时以pyecharts生成可视化图表,通过pymysql完成MySQL数据的写入与读取。资源包共2000个文件,以1745个Python源码文件为主,辅以C头文件、文本说明、HTML页面、JSON配置等,整体大小约53.73MB,目录结构清晰,便于按模块研读。目前已有147人学习下载。从中可以掌握动态网页爬取、加密数据破解、数据入库及可视化展示的完整链路,并可直接改造用于其他二手车平台或类似电商网站的分析项目,是毕业设计、课程设计与技能提升的实用参考资料。

1. 二手车爬虫数据可视化毕设:这套源码把反爬、入库、出图串成了一条完整链路

用 Python 写爬虫不难,难的是拿到一份能直接跑通毕设答辩的完整源码。这套二手车爬虫数据可视化分析设计,恰好把最难啃的部分都覆盖了:Selenium 驱动谷歌浏览器抓取动态页面,lxml 的 etree 对象配合 XPath 解析 DOM 树;价格和表显里程遇到字体文件加密,需要单独破解;数据通过 pymysql 写进 MySQL,最后用 pyecharts 生成 Echarts 图表完成可视化展示。简单说就是:从采集、清洗、入库到出图,一条链路走完,不用你再去东拼西凑。适合正在做毕设的本科生,也适合想快速搞懂字体反爬破解思路的爬虫从业者。下面我按实际跑通的顺序拆开讲,每一步都给你能抄的代码和参数说明。

2. 抓取层拆解:Selenium 驱动浏览器与 XPath 解析的完整流程

2.1 为什么选 Selenium 而不是 Requests

很多爬虫入门教程第一步就是 requests.get 拿 HTML,再正则抽数据。但那套玩法在二手车网站上基本走不通:列表页的车辆数据很多是 JS 动态渲染的,直接 requests 拿到的 HTML 里可能只有个空壳,连价格标签都找不到。另外,页面里嵌了字体加密逻辑,需要在真实浏览器环境里执行 JS 才能拿到被字形替换后的文本,这个动作 requests 也替代不了。

所以这套毕设源码选用 Selenium 驱动谷歌浏览器,本质是让浏览器替你把页面渲染完,再通过 driver.page_source 把渲染后的 DOM 交出来。代价是速度慢、内存占用高,但换来了稳定性和对反爬机制的兼容性。实际跑的时候建议开无头模式,既不用盯着浏览器窗口,也能减少系统资源开销。要注意的是,Selenium 驱动 Chrome 必须有一个对应版本的 chromedriver,版本对不上会在启动阶段直接抛异常,这个坑后面避坑章专门说。

2.2 从启动浏览器到拿到渲染后的 DOM

先看我在这套代码里最常用的一段初始化骨架:

from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from lxml import html # 无头模式配置,减少弹窗和资源占用 opts = Options() opts.add_argument("--headless=new") opts.add_argument("--disable-gpu") opts.add_argument("--no-sandbox") opts.add_argument("--disable-blink-features=AutomationControlled") # 隐藏自动化标识 driver = webdriver.Chrome(options=opts) driver.set_page_load_timeout(20) # 页面加载超时,防止慢页面卡死 # 打开二手车列表页(示例地址,按实际站点替换) driver.get("https://example.com/usedcar/") # 显式等待列表节点出现,最多等 15 秒 wait = WebDriverWait(driver, 15) wait.until(EC.presence_of_element_located((By.XPATH, "//div[contains(@class,'car-list')]"))) # 把渲染完成的 HTML 转成 lxml 的可解析对象 doc = html.etree.HTML(driver.page_source)

这段代码的逻辑很直白:先创建 Chrome 配置项,再启动 driver,用 get 打开目标页面,然后显式等待页面里代表列表的容器节点出现。等这一步通过,说明核心数据已经渲染完成,再取 page_source 就是完整 DOM。传给 lxml 的 etree.HTML 方法之后,后续所有 XPath 查找都在 doc 对象上做。

几个参数值得单独说一下。headless=new 是无头模式的 Chrome 新写法,老版本写 headless 也能跑,但新写法兼容性更好。set_page_load_timeout 设成 20 秒,是为了防止某个慢接口把整个进程挂住,超过时间直接抛异常让程序往下走。等待条件里那个 XPath 是按页面结构来的,不同站点的列表容器 class 不一样,需要按实际页面改。这里强烈建议用显式等待而不是 time.sleep 固定秒数,因为慢接口时长不可控,睡短了拿不到节点,睡长了浪费时间。

2.3 XPath 定位二手车关键字段:class 命名要按页面改

doc 就绪后,剩下的就是用 XPath 抽字段。这套代码里车名、价格、里程、地区分别从不同结构的节点里取,我把它整理成下面这样:

# 车名:通常包在卡片标题的 a 标签里 names = doc.xpath("//div[contains(@class,'car-title')]/a/text()") # 车源价格:注意此时拿到的可能是字体加密后的字符 prices = doc.xpath("//span[contains(@class,'car-price')]/text()") # 表显里程:多数藏在 li 或 span 的描述性节点里 mileages = doc.xpath("//li[contains(@class,'car-mileage')]/text()") # 所在地区:一般跟随车源卡片,和车名同级 regions = doc.xpath("//div[contains(@class,'car-region')]/text()") # 用 zip 合并成一条条记录,便于后续清洗入库 records = list(zip(names, prices, mileages, regions))

XPath 的写法是这套代码最需要按站点定制的地方。contains(@class,'car-title') 比直接等号匹配更抗干扰,因为页面经常会一个节点挂多个 class,写成等于容易漏数据。用 text() 取文本时有一个隐蔽问题:如果某个字段缺失,XPath 返回的是空字符串而不是报错,zip 之后可能出现某个字段对不齐的情况,所以入库前最好加一层非空过滤。

价格和里程这里还藏着一个更深的坑:页面上肉眼看到的是「10.80万」,但 text() 取出来可能是乱码或特殊字符,这正是字体加密的产物。第一次跑通这套流程的人,十有八九会在这里卡住,看到乱码以为是自己 XPath 写错了。实际不用慌,下一章专门讲怎么把这块解密还原。字段解析跑通了,后面入库才不会翻车。

3. 字体反爬破解:价格和表显里程的加密字体还原方法

3.1 字体加密的原理:先看懂它怎么藏数据

二手车网站的价格和表显里程经常走字体反爬。页面源码里,价格和里程字段的文本是一堆肉眼不认识的特殊字符,但浏览器渲染时,通过 CSS 里定义的 @font-face 字体文件,把特殊字符映射成正常的数字字形。所以你截图看页面,数字是正常的;用 XPath 的 text() 去拿,拿到的却是被替换过的 unicode。

这种反爬的本质是「显示层和源码层分离」。服务端把真实数字按某种规则替换成自定义字符编码,再附带一个只有服务端知道的字体文件,浏览器加载字体后按 cmap 表把字符映射回真实字形。爬虫拿到的页面源码里只有乱码字符,想要还原,就必须把那个 woff 字体文件下载下来,解析它的 cmap 映射表,建立「字符 → 数字」的对应关系,再拿这个映射去替换 text() 取到的内容。

解决思路分三步:找到字体文件地址并下载;用 fontTools 解析字体,提取每个字符对应的字形名称和编码;手动或自动建立字形到真实数字的映射表。这套流程在二手车网站上尤其常见,因为价格和里程是用户最敏感的信息,平台方愿意用反爬手段保护这些核心字段。

3.2 下载字体文件并解析 cmap 映射

字体文件的地址通常藏在页面的 CSS 或内联样式里,通过 @font-face 声明。常见做法是先用 XPath 或正则把 CSS 文本捞出来,再找出 woff 或 ttf 后缀的 URL,然后 requests 下载到本地。下面是这套代码里的核心解析段:

import requests from fontTools.ttLib import TTFont # 从页面 CSS 里提取字体文件地址,常见为 woff 或 ttf font_url = "https://example.com/static/fonts/car-number.woff" r = requests.get(font_url) with open("car-number.woff", "wb") as f: f.write(r.content) # 解析字体文件 font = TTFont("car-number.woff") cmap = font.getBestCmap() # 手动对照字形名与数字后,建立映射字典 glyph_map = { "glyph1": "0", "glyph2": "1", "glyph3": "2", "glyph4": "3", "glyph5": "4", "glyph6": "5", "glyph7": "6", "glyph8": "7", "glyph9": "8", "glyph10": "9", }

cmap 是字体文件里「unicode 编码 → 字形名称」的映射表,getBestCmap() 会优先返回适合程序解析的那个子表。拿到 cmap 后,你就能把一个字符的编码转成字形名,比如 chr(0xE001) 可能对应字形名 glyph4,而 glyph4 在页面上渲染出来是数字 3。glyph_map 里存的就是「字形名 → 真实数字」的对应关系。

这里有个很关键的点:字体映射字典不要写死。同一个站点的字体文件可能会定期重新生成,这次 glyph4 是数字 3,下次可能变成数字 8。我一般会在每次爬取时都重新下载字体文件再解析,而不是复用上一次的 woff。至于映射关系怎么确认,常见做法是先打开一个已知价格的详情页,找一条「页面显示价格 vs 源码文本」都能拿到的记录,人工核对三四个字符后把映射补全,之后就能自动化了。

提示:字体文件体积通常只有几十 KB,下载成本很低,没必要为省这一步去缓存,每次都现拉现解析最稳。

3.3 把加密文本还原成真实数字

拿到 cmap 和 glyph_map 之后,剩下就是写一个替换函数,把取到的乱码文本逐字还原:

def decode_number(encrypted_text, cmap, glyph_map): result = [] for ch in encrypted_text: # 先把字符转成 unicode 码点,再查字形名 glyph_name = cmap.get(ord(ch), None) if glyph_name in glyph_map: result.append(glyph_map[glyph_name]) else: result.append(ch) # 非加密字符,比如小数点、单位,原样保留 return "".join(result) # 使用示例 raw_price = prices[0] # 例:从 text() 取到的乱码 real_price = decode_number(raw_price, cmap, glyph_map) print(raw_price, real_price) # 预期输出:乱码 → 10.80

函数逻辑很简单:遍历加密文本的每个字符,用 ord(ch) 拿到它的 unicode 码点,再用 cmap.get 查这个码点对应的字形名;如果字形名在我们的 glyph_map 里,就换成真实数字,否则就原样保留。这样做的好处是小数点、万、公里这些非加密字符不会被误伤,能直接跟着文本一起保留下来。

常见的翻车点是忽略了小数点和单位。有些站点只对数字做字体替换,小数点里的点还是普通字符,这种情况函数能正常处理;但有些站点的加密范围包含了小数点,那你的 glyph_map 里还需要额外加一条「glyph_dot: "."」的映射。还有一个细节是,mileage 的文本长度不固定,比如「5.6万公里」加密后可能变成 5 个字符,解密后长度会还原成和肉眼一致的文本,你可以把解密结果和页面截图对一眼,核验映射表有没有建全。

4. 数据落库与分析:pymysql 读写 MySQL 与 pyecharts 可视化配置

4.1 建表与写入:价格字段别用 varchar 存

爬下来的数据清洗完后,下一步就是写进 MySQL。这套代码用的是 pymysql,操作方式和 mysql-connector 差不多,但 pymysql 是纯 Python 实现,部署环境里装起来省事,兼容性也更好。先看建表语句:

CREATE TABLE used_car ( id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(120) NOT NULL, price DECIMAL(10,2) COMMENT '价格单位:万', mileage VARCHAR(50), region VARCHAR(20) DEFAULT '', crawl_time DATETIME ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

price 字段一定要用 DECIMAL 而不是 VARCHAR。价格解密后是「10.80」这样的字符串,如果为了省事存成 VARCHAR,后续做价格区间统计时还得 CAST 转类型,而且很容易因为「10.80」和「10.8」这种格式差异导致聚合出错。DECIMAL(10,2) 的意思是总长 10 位、小数位 2 位,存储万元级别的价格完全够用。

写入逻辑用参数化 insert:

import pymysql conn = pymysql.connect( host="127.0.0.1", port=3306, user="root", password="123456", database="car_spider", charset="utf8mb4" ) cursor = conn.cursor() insert_sql = """ INSERT INTO used_car (title, price, mileage, region, crawl_time) VALUES (%s, %s, %s, %s, NOW()) """ for title, price, mileage, region in records: # 这里把解密后的价格字符串转成 float,再传给 DECIMAL 字段 cursor.execute(insert_sql, (title, float(price), mileage, region)) conn.commit() cursor.close() conn.close()

pymysql 的占位符是 %s,不管字段类型是字符串还是数字,都用 %s 占位,然后真正的值放在第二个参数 tuple 里。这里注意不要自己拼 SQL 字符串,一方面防注入,另一方面也避免引号和转义的边界问题。crawl_time 直接用 NOW() 让 MySQL 写时间,省得 Python 这边再处理时区。

4.2 从 MySQL 读数据生成 pyecharts 图表

数据入库不是终点,毕设答辩要看图表。pyecharts 是这套代码做可视化的核心库,它把 Echarts 的配置封装成了 Python 对象,渲染出来是一个独立的 HTML 文件,浏览器打开就能看,演示的时候很方便。下面这段是统计各地区车源数量的柱状图:

from pyecharts.charts import Bar, Pie from pyecharts import options as opts import pymysql conn = pymysql.connect( host="127.0.0.1", port=3306, user="root", password="123456", database="car_spider", charset="utf8mb4" ) cursor = conn.cursor() # 按地区聚合统计车源数量,取前 15 个地区 cursor.execute(""" SELECT region, COUNT(*) AS cnt FROM used_car GROUP BY region ORDER BY cnt DESC LIMIT 15 """) rows = cursor.fetchall() bar = ( Bar() .add_xaxis([row[0] for row in rows]) .add_yaxis("车源数量", [row[1] for row in rows]) .set_global_opts( title_opts=opts.TitleOpts(title="各地区二手车源数量分布"), xaxis_opts=opts.AxisOpts(axislabel_opts=opts.LabelOpts(rotate=15)), yaxis_opts=opts.AxisOpts(name="数量") ) ) bar.render("region_bar.html")

这一段把 SQL 聚合结果直接喂给了 Bar 图。add_xaxis 接收的是地区名列表,add_yaxis 接收的是数量列表,两个列表的顺序必须一一对应。axislabel_opts 里的 rotate=15 很实用,地区名长了以后横轴标签会互相遮挡,旋转 15 度基本能解决。render 方法会生成一个完整的 HTML 文件,里面已经内嵌了 Echarts 的 JS 资源,双击就能打开看效果。

除了地区分布,价格区间分布也是二手车可视化里很常问到的图。做法是用 SQL 按价格分桶:

SELECT CASE WHEN price < 5 THEN '0-5万' WHEN price < 10 THEN '5-10万' WHEN price < 20 THEN '10-20万' ELSE '20万以上' END AS price_range, COUNT(*) AS cnt FROM used_car GROUP BY price_range;

这个思路比在 Python 里对每一行做判断要省事,SQL 负责分桶,Python 只负责取结果,画出来的饼图直接展示各价格区间的占比。数据量小的时候两者差别不大,但数据量过万后,SQL 聚合比 Python 循环快一个量级,答辩时如果被问到性能问题,这个细节能加分。

4.3 常用图表参数与输出说明

pyecharts 的配置项很多,刚上手容易懵。实际做毕设用到的核心就那几个,我整理成下表方便对照:

配置项作用常用写法踩坑点
TitleOpts设置图表标题title_opts=opts.TitleOpts(title="...")不设置则图表没标题,答辩时要手动说明
TooltipOpts鼠标悬停提示框tooltip_opts=opts.TooltipOpts(trigger="axis")柱状图建议 trigger="axis",散点用 "item"
LegendOpts图例开关legend_opts=opts.LegendOpts(pos_top="5%")图例默认在顶部,多个数据系列时要注意区分
AxisOptsX/Y 轴配置xaxis_opts=opts.AxisOpts(name="地区")轴名称不写,图表只有数值没有维度说明
LabelOpts数据标签label_opts=opts.LabelOpts(formatter="{c} 辆")饼图里的 formatter 经常被忽略,默认只显示数字

实际跑的时候,我习惯每张图渲染成一个独立 HTML 文件,文件名用图表的含义命名,比如 price_range_pie.html、region_bar.html。答辩演示时按顺序打开,比在代码里启动本地服务更省心,也不会因为端口占用或静态资源路径问题翻车。如果想要一张大屏把多张图拼在一起,可以再研究 pyecharts 的 Page 和 Grid 组件,但毕设一般做到单图输出就够了。

5. 避坑指南:跑通这套毕设源码前的五个高频翻车点

5.1 启动阶段:浏览器驱动版本不匹配

现象:Chrome 一启动就抛 session 相关异常,日志里提示 chromedriver 版本不支持,或者直接报错无法创建 driver 对象。

原因:chromedriver 必须和本机 Chrome 主版本号严格匹配。Chrome 自动更新后版本变新,旧 chromedriver 就罢工了。这是 Selenium 体系里最经典的环境问题,和代码本身无关。

解决:先用 chrome://version 或命令行确认浏览器版本,再去下载对应版本的 chromedriver。从 Chrome 115 开始,如果你的 selenium 版本足够新,可以直接用 Selenium Manager 自动管理驱动,不用手动下载;老版本就老老实实把 chromedriver 的路径配到环境变量里。顺手把驱动路径写死到脚本开头,方便排查。

5.2 数据解析阶段:字体解密后数字错乱

现象:解密后价格从「10.80」变成「1080」,或者「万」字和数字黏在一起,看起来像 10.80 被放大了一百倍。

原因:字体替换时,小数点或单位字符也参与了加密,而你的 glyph_map 里只建了 0-9 的映射,没有把小数点的字形映射进去,导致小数点被丢弃,数字拼接后自然就错位了。

解决:解密函数里一定要保留非数字字符。我的做法是先打印一条原始乱码文本和页面截图做对照,确认哪些字符是数字、哪些是小数点、哪些是单位文字,再把小数点加进 glyph_map。另一个技巧是解密后做一次 sanity check,如果解密结果长度和肉眼看到的不一致,基本就是映射表漏了字符。

5.3 入库阶段:MySQL 中文乱码

现象:地区、车名写进数据库后全是问号,或者读出来显示成乱码。

原因:连接参数没设置 charset,或者建表时没指定 utf8mb4。pymysql 默认用的字符集可能和你库里的表不一致,中文在传输过程中就被转坏了。

解决:连接参数里加 charset="utf8mb4",同时建表语句带 DEFAULT CHARSET=utf8mb4。如果表已经建好,可以 ALTER TABLE used_car CONVERT TO CHARACTER SET utf8mb4。遇到老项目还要检查 MySQL 服务端的 character_set_server 配置,光改 Python 这边不够。

5.4 翻页抓取:节点失效与状态崩溃

现象:爬第一页正常,翻到第三四页时 Selenium 抛 StaleElementReferenceException,或者元素明明在页面上却定位不到。

原因:列表页 DOM 更新后,之前缓存的 WebElement 对象引用已经失效。Selenium 里每个 WebElement 绑定的是页面里的一个具体节点,DOM 一变,旧引用就不能再用。

解决:每翻一页重新用 XPath 获取节点,不要重用旧元素。解析逻辑尽量写成独立函数,输入 driver,输出解析结果,翻页后重新调用。对于偶发的节点定位失败,在外面包一层 try except,重试一次就能绕过。这个重试机制在反爬环境里尤其重要。

5.5 反爬检测:无头模式被识别

现象:无头模式能打开页面,但列表数据为空,或者弹出一个滑块验证码,怎么点都过不去。

原因:网站通过 navigator.webdriver 等特征识别出这是自动化浏览器,直接返回空数据或触发验证。无头模式这类特征更明显,被识别概率比有头模式高不少。

解决:加 --disable-blink-features=AutomationControlled 隐藏自动化标识,同时配合真实 User-Agent。还不行就改成有头模式,用小窗口跑。注意,做爬虫要遵守网站 robots 协议和法律法规,这套代码只建议用于学习研究,不要对线上站点做高频抓取。

6. 进阶验证:从单页抓取到全站采集的稳定化改造

单页能跑通只是第一步,毕设里通常要展示一套完整的数据采集流程,那就得把单页逻辑封装成可复用的函数,再加上分页、重试、去重和延时。这里我给一个改造后的骨架:

import time import random def parse_and_save(doc): # 把前面章节的 XPath 解析和 pymysql 入库逻辑封装进来 pass def crawl_page(driver, page_num): url = f"https://example.com/usedcar/p{page_num}/" driver.get(url) doc = html.etree.HTML(driver.page_source) records = zip( doc.xpath("//div[contains(@class,'car-title')]/a/text()"), doc.xpath("//span[contains(@class,'car-price')]/text()"), doc.xpath("//li[contains(@class,'car-mileage')]/text()"), ) parse_and_save(records) def run(): for page in range(1, 51): try: crawl_page(driver, page) time.sleep(random.uniform(2, 6)) # 随机延时,降低请求频率 except Exception as e: print(f"第{page}页失败: {e}") continue # 失败页跳过,不中断整个任务

这个改造里有两个验证要点。第一个是去重,用车辆的标题加价格作为唯一标识,在 insert 前先查一下是否存在,避免重复页面重复入库;第二个是数量核对,跑完后用 SQL 数一下总行数,再和页面提示的车源总量对比,差别在正常范围内说明抓取没漏。随机延时不是玄学,是给目标服务器的基本礼貌,频率太高容易被封。从那以后我每次跑爬虫前,都强制走一遍「手动开页面→看 DOM→写 XPath→小批量验证」四步,确认映射和字段没问题再放全量,这套代码的打开方式,我建议你也照着这个顺序来。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询