
简介这是一份面向计算机专业毕业生及Python爬虫与数据分析初学者的毕业设计源码项目。项目基于Python开发二手车网站数据采集与分析程序使用selenium驱动Google浏览器抓取页面通过lxml的etree与XPath解析DOM树并针对价格、表显里程等关键字段的字体加密做了处理数据入库与读取均经pymysql操作MySQL最终借助pyecharts完成可视化展示可帮助读者完整掌握从数据采集到可视化分析的实现链路。资源包共2000个文件以1745个py源码文件为主体辅以C头文件、txt说明文档、html页面、json数据及pdf资料等压缩包大小53.73MB目录结构便于按模块查阅其中txt和pdf可用作项目说明与扩展资料。目前已有147人学习下载。项目不仅提供可直接运行的成品代码也覆盖了爬虫反爬策略、数据解析、数据持久化及可视化报表等关键知识点并包含完整数据库操作与图表生成思路适合毕业设计参考或进阶学习。1. 二手车爬虫数据可视化分析从车源页面到价格分布图的最小闭环“二手车爬虫数据可视化分析”这个标题挂在毕业设计列表里非常常见但它不是三个技术名词的简单拼接而是一条完整的数据生产流水线爬虫负责把二手车列表页上的标题、价格、里程、上牌日期抓取下来SQLAlchemy把清洗后的结构化数据存入数据库数据可视化部分从数据库取数借助ECharts画出价格分布、价格-里程散点图等图表。适合的人群有两类一是准备拿这个题目交课程设计或毕业设计的在校生二是想把 Python 爬虫、数据库和前端图表串起来练手的数据分析初学者。这套方案的核心优势是不需要云服务器也不需要分布式采集一台 Windows 笔记本就能完成全部开发、演示和答辩。很多人把注意力放在“爬虫”三个字上抓到几千条数据就以为项目完成了实际上能拉开差距的是清洗口径和可视化逻辑。乱抓的数据画出来的图会立刻暴露问题比如价格字段没有统一单位柱状图排序就会乱掉日期字段没有转成标准格式SQL查询和前端统计都会报错。下面按一个实际项目的开发顺序从选型、采集、入库、出图到排错把每一步的参数和坑都说清楚。2. 用Python爬取二手车车源Requests选型、翻页与反爬参数2.1 为什么选RequestsBeautifulSoup而不是Scrapy面对“Python爬虫”这个关键词很多初学者第一反应是学 Scrapy。Scrapy 确实是大规模爬虫的标准方案自带并发调度、去重和管道但它的学习曲线集中在框架约定上你得理解 Spider、Item Pipeline、Downloader Middleware 这些概念才能改一个字段。二手车爬虫数据可视化项目的数据量本身不大一个主流平台的在售车源一般几千到几万条用 requests 完全能压得住而且代码结构更线性答辩的时候可以把每个步骤拆给老师看。requests 在 Python 生态里的地位不必多说它把 HTTP 请求封装成简单的函数调用get、post、session 管理、重定向处理都很直观。配合 BeautifulSoup 做 HTML 解析对静态页面几乎是“请求-解析-提取”三步走。相比之下urllib 虽然也在标准库里但处理 cookies 和请求头要写更多代码不值得。还有一个容易踩的选型坑是 Selenium。很多教程把动态页面渲染当成标配但如果目标车源页面的数据在 HTML 源码里能搜索到用 selenium 会白白增加资源消耗而且它启动浏览器后User-Agent、浏览器指纹和自动化标识都是反爬系统重点检测对象。我的判断方法是先试着一行requests.get拿 HTML搜索标题关键词不在源码里再去看浏览器 Network 里的 XHR 请求。九成列表页的数据其实藏在 XHR 返回的 JSON 里直接调接口比开着无头浏览器去渲染页面高效太多。requests 还有一个容易被忽略的优势Session 对象可以复用底层连接。翻页请求动辄上百次每次重新创建连接会浪费 TCP 握手使用 Session 后整体速度更稳定也能统一设置 headers 和 cookies。这个优化对单机采集不是必须但能体现代码的工程性答辩时可以顺口讲一句。2.2 最小采集脚本请求头和解析字段一个最小可用的采集脚本我一般分成三个函数fetch_page 负责请求parse_cars 负责解析main 负责翻页和汇总。下面这段代码用 CSS 选择器定位列表项具体 class 名参考目标站点的实际 HTML 结构调整。import requests from bs4 import BeautifulSoup import time # 目标列表页的分页URL_p1_作为页码占位符实际使用时替换成真实地址 TARGET_URL https://example.com/usedcar/list_p1_ HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9, } def fetch_page(page_no): url TARGET_URL.replace(_p1_, f_p{page_no}_) resp requests.get(url, headersHEADERS, timeout10) resp.raise_for_status() # 有的站点没有在Response Header里声明charset必须用apparent_encoding兜底 resp.encoding resp.apparent_encoding or utf-8 return resp.text def parse_cars(html): soup BeautifulSoup(html, html.parser) items soup.select(.car-list-item) cars [] for item in items: # 关键字段缺失的那条数据直接跳过避免后面空指针 title_node item.select_one(.car-title) price_node item.select_one(.price-num) if not title_node or not price_node: continue miles_node item.select_one(.miles) year_node item.select_one(.car-year) # 优先取data-url相对路径要补全为完整URL否则无法去重 source_url item.get(data-url) if source_url: source_url https://example.com source_url if source_url.startswith(/) else source_url elif item.find(a): source_url item.find(a).get(href) else: source_url cars.append({ title: title_node.text.strip(), price: price_node.text.strip(), miles: miles_node.text.strip() if miles_node else , year: year_node.text.strip() if year_node else , source_url: source_url, }) return cars if __name__ __main__: all_cars [] for page in range(1, 6): # 先跑5页验证字段是否都能取到 html fetch_page(page) cars_on_page parse_cars(html) all_cars.extend(cars_on_page) print(f第{page}页解析到{len(cars_on_page)}条累计{len(all_cars)}条) # 随机延时模拟人工浏览节奏 time.sleep(1 page % 3) print(f共抓取 {len(all_cars)} 条) print(all_cars[:3])这段代码里resp.encoding resp.apparent_encoding or utf-8是中文站点最容易踩的一个点。部分后端响应头里没有charsetutf-8requests 默认用 ISO-8859-1 解码抓回来的页面标题会变成乱码。用apparent_encoding会从 HTML 内容里推断编码乱码问题基本能解决。CSS 选择器需要对应目标页面真实结构。.car-list-item是示例实际页面里找到列表项的包裹节点优先选 class 语义清晰的父节点不要用div:nth-child(3)这种脆弱的索引。字段取不到时先把该条 HTML 打印出来看标签结构哪里不对。另一个细节是 source_url 用于去重。不同 page 里可能出现同一辆车被推荐位重复展示没有唯一标识时至少要把 href 补全再去重不然存到库里就是一堆无源链接。2.3 翻页与限速别把目标站点当性能测试机翻页逻辑的常见形式有路径式/list/p2/、查询参数式/list?page2以及 POST 表单式。抓第一页时看一眼分页区域的href确定页码参数名不要靠猜。代码里用字符串替换占位符有好处也有风险优点是 URL 模板一目了然缺点是占位符如果出现多次会重复替换。更稳妥的方式是写一个拼接函数def build_page_url(base, page_no): if ? not in base: return f{base.rstrip(/)}/p{page_no}/ return f{base}page{page_no}至于限速这不是道德问题而是技术问题。二手车网站有运营成本和反爬系统频繁请求会导致 IP 临时受限。我一般按“每页 1 到 3 秒随机延时”起步如果被抓过就提高到 3 到 8 秒。下面的重试函数把 403 和 429 当成反爬信号退避时间随重试次数翻倍import random def fetch_page_with_retry(url, max_retries3): for attempt in range(max_retries): try: resp requests.get(url, headersHEADERS, timeout10) if resp.status_code 200: return resp.text if resp.status_code in (403, 429): wait 5 * (attempt 1) random.random() print(f状态码 {resp.status_code}等待 {wait:.1f} 秒后重试) time.sleep(wait) continue resp.raise_for_status() except requests.exceptions.RequestException as e: print(f请求异常: {e}) time.sleep(2) return None403 和 429 都是“访问被拒”的信号但形成原因不同。403 可能是请求头不完整或 IP 被拉黑429 则明确告诉你“请求太频繁”。重试等待时间要随机化固定 5 秒很容易被识别成机器。还有一个习惯每页解析到的条数打印到控制台如果某页条数突然变成 0十有八九是页面结构变了或者被反爬拦截日志会帮你定位。提示抓取任何站点之前先手动浏览几页感受页面结构和响应速度把延时设置成比人工浏览稍快的节奏即可不要用并发轰炸式采集。3. 数据清洗与SQLAlchemy入库把脏页面变成可查询的二手车明细表3.1 清洗车源字段价格/里程/上牌日期的类型陷阱爬虫抓回来的字段本质上是页面里的文本价格可能是“12.80万”“9.98万元”“6.8w”里程可能是“3.2万公里”“6800公里”上牌日期可能是“2019年05月”“2019/05”。如果在清洗阶段偷懒把这些原样塞进数据库后面查询和画图都会很痛苦字符串比较排序会错乱ECharts 拿到的不是数值所有统计都得重新写一遍解析逻辑。这个项目的大部分复杂度其实不在爬虫而在“把文本变成类型正确的数据”。我先写三个解析函数统一输出价格单位统一为“万元”里程统一为“公里”整数日期统一为“YYYY-MM-DD”格式的字符串。import re def parse_price(price_str): # 输入类似 12.80万、9.98万元、6800元、6.8w if not price_str: return None s price_str.replace(,, ).replace( , ).lower() m re.search(r\d(\.\d)?, s) if not m: return None value float(m.group()) if 万 in s or w in s: return round(value, 2) if 元 in s and value 1000: return round(value / 10000, 2) return None def parse_miles(miles_str): # 输入类似 3.2万公里、6800公里、1.2万 if not miles_str: return None m re.search(r\d(\.\d)?, miles_str) if not m: return None value float(m.group()) if 万 in miles_str: return int(value * 10000) return int(value) def parse_year(year_str): # 输入 2019年05月、2019/05、2019 - YYYY-MM-DD if not year_str: return None m re.search(r(\d{4})[年/\-.\s]*(\d{0,2}), year_str) if not m: return None year int(m.group(1)) month int(m.group(2)) if m.group(2) else 1 if month 0: month 1 if 1 month 12: return f{year:04d}-{month:02d}-01 return None这里有个很容易翻车的点parse_price 里“万”的判断要放在“元”的前面因为“12.80万”里没有“元”字顺序不影响但“6800元”如果先被“万”分支漏掉就会走到“元”分支。为什么小于 1000 的“元”价格返回 None因为二手车页面里单位是“元”的基本都是零配件或周边产品不是整车。这个阈值可以按业务调。parse_year 最后统一补上“-01”作为上牌月份的首日是为了让数据库的DATE字段方便排序和筛选。如果只关心车龄直接提取年份存成 Integer 也可以但我建议保留完整日期因为年份分组和“几年车龄”都能从日期派生出来。有些教程喜欢把清洗阶段直接切到 pandas用str.extract处理 DataFrame。pandas 在做多表聚合时确实方便但引入它之前要想清楚本项目的清洗是逐字段转换用 Python 字典加正则已经很清楚再加一个依赖会让答辩时的解释变长。如果你已经熟悉 pandas用它也没问题只是不要把数据倒来倒去。3.2 用SQLAlchemy定义ORM模型并落库SQLAlchemy 是目前 Python 操作数据库最主流的 ORM尤其适合手写 SQL 容易头晕的场景。毕业设计或者个人项目里SQLite 是首选零配置一个文件就是整个数据库开发机、答辩机都能直接跑。等到需要多人并发写或数据量过百万时再改成 MySQL 也只需要替换连接串。SQLAlchemy 的 ORM 模型正好把这种切换成本降到了最低。下面是一个针对二手车明细的模型。字段类型和清洗函数一一对应price 是 Floatmiles 是 Integerlicense_date 是 Date而不是全部用 String。from sqlalchemy import create_engine, Column, Integer, String, Float, Date, DateTime, UniqueConstraint from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker from datetime import datetime Base declarative_base() class Car(Base): __tablename__ car_infos __table_args__ (UniqueConstraint(source_url, nameuq_source),) id Column(Integer, primary_keyTrue, autoincrementTrue) title Column(String(200), nullableFalse, default) price Column(Float) # 单位万元 miles Column(Integer) # 单位公里 license_date Column(Date) # 上牌日期 source_url Column(String(300)) crawled_at Column(DateTime, defaultdatetime.now) def __repr__(self): return fCar id{self.id} title{self.title!r} price{self.price} engine create_engine(sqlite:///usedcars.db, echoFalse) Base.metadata.create_all(engine) Session sessionmaker(bindengine)UniqueConstraint放在__table_args__里会在建表时给source_url加唯一索引。这一行在“增量更新”环节是兜底但注意 SQLite 对 UNIQUE 约束处理是冲突时整条 INSERT 报错所以后面代码里仍然要先查再插不能完全依赖约束。echoFalse在调试时改成echoTrue能看到 ORM 生成的 SQL 语句。有一次我的问题就是入库时字段顺序对不上开启 echo 后发现 SQLAlchemy 把license_date的值传给了miles因为数据库插入是按字段名匹配的当时我字典里 key 拼错了一个字母。这类问题看 SQL 一眼就能发现。3.3 增量更新与去重事务与批量插入爬虫如果每天跑一次数据库里不能翻倍膨胀。增量更新的习惯是每次抓取前先判断这条 source_url 是否已存在存在就跳过不存在才插入。这个方案慢但清楚等数据量大了改成批量查询。session Session() # 一次性取出库里已有的URL集合避免逐条查数据库 existing_urls set(url for (url,) in session.query(Car.source_url).all()) new_cars [] for car in all_cars: if not car.get(source_url): continue # 没有链接的无法去重直接丢掉 if car[source_url] in existing_urls: continue new_car Car( titlecar[title], priceparse_price(car[price]), milesparse_miles(car[miles]), license_dateparse_year(car[year]), source_urlcar[source_url], ) new_cars.append(new_car) existing_urls.add(car[source_url]) # 本轮内重复的链接也跳过 try: session.add_all(new_cars) session.commit() except Exception as e: session.rollback() print(f入库失败: {e}) finally: session.close()existing_urls在一开始就构造一个 set比每条都query.filter_by快不少。注意new_cars列表里如果已经存在同一条 URL要在循环里动态把 URL 加进 set否则一次翻页内重复推荐的车源会被插入两条。真实流量的页面里同一辆车可能出现在多个列表位置也可能在翻页后重复出现。所以没有这一个去重步骤跑三次数据库会胖三倍。还有一个细节如果目标是“看到新上的车”可以记录 crawled_at后续按时间维度分析车源更新节奏。事务里的rollback()是后悔药但最好靠过滤字段避免。4. 二手车数据可视化FlaskECharts展示价格、里程与分布4.1 为什么选FlaskECharts而不是Pyecharts数据可视化部分已经到了项目收口阶段。Pyecharts 是很多人的第一选择因为可以用 Python 写配置直接生成 HTML花很少代码就能出一个交互图。但它的局限也很明显图表生成后就定型了想增加年份筛选、价格区间滑块必须重新生成整个 HTML 或嵌入额外前端代码。FlaskECharts 的方式则把后端和前端分开后端只负责提供/api/...的 JSON 数据前端拿到数据后自由配置图表。毕业设计演示时面试官或老师通常会问“这个筛选是怎么做的”用这种方式你能展示出一个更接近企业项目的前后端分离结构。Flask 作为微框架路由、JSON 响应、静态文件托管都内置几分钟就能跑起一个 Web 服务。对于二手车数据可视化来说不需要 ORM 的 ModelView 或 admin 后台保持轻量即可。这里有一个演示环境的安全感问题。ECharts 本身是前端库用 CDN 引用最常见但答辩和演示往往在一个封闭网络环境CDN 可能加载失败。所以提前下载echarts.min.js放到static目录下通过模板渲染的 URL 来引用是最稳妥的。HTML 模板里使用url_for(static, filenameecharts.min.js)Flask 会自动映射到 static 目录。数据可视化项目的价值不在于图有多炫而在于能否暴露数据的异常。当你把价格分布图画出来单价超过 500 万的车、里程超过 50 万公里的车都会成为明显离群点进而倒推清洗函数是否漏了单位转换。所以图表的重点是“口径一致”不是配色。4.2 后端提供JSON接口从数据库到图表数据Web 可视化看板一般需要两个起步接口价格分布接口和价格-里程散点接口。第一个用来观察行情区间第二个用来验证“里程越高价格越低”的常识是否符合数据真实分布。接口代码里查询用filter过滤掉 None 字段避免前端报错。from flask import Flask, jsonify from sqlalchemy.orm import sessionmaker from sqlalchemy import create_engine app Flask(__name__) engine create_engine(sqlite:///usedcars.db) Session sessionmaker(bindengine) app.route(/api/price_distribution) def price_distribution(): session Session() rows session.query(Car.price).filter(Car.price.isnot(None)).all() session.close() prices [r[0] for r in rows] bins [5, 10, 15, 20, 30, 50] labels [5万内, 5-10万, 10-15万, 15-20万, 20-30万, 30-50万, 50万以上] counts [0] * len(labels) for p in prices: if p bins[0]: counts[0] 1 elif p bins[1]: counts[1] 1 elif p bins[2]: counts[2] 1 elif p bins[3]: counts[3] 1 elif p bins[4]: counts[4] 1 elif p bins[5]: counts[5] 1 else: counts[6] 1 return jsonify(labelslabels, countscounts) app.route(/api/price_miles) def price_miles(): session Session() rows session.query(Car.miles, Car.price).filter( Car.miles.isnot(None), Car.price.isnot(None) ).all() session.close() points [[r[0], r[1]] for r in rows] return jsonify(pointspoints)手动分桶的好处是边界可控比如你想单独看 10 万到 15 万这个刚需区间可以随时调整 bins。如果数据量大到数万这个循环仍然很快它不是瓶颈。散点图接口返回所有点超过一万点建议在后端抽样不然前端渲染会卡。抽样可以用random.sample(points, min(5000, len(points)))不要用列表切片因为按页码抓的数据可能天然有序。接口返回的 JSON 字段名要跟前端约定好labels/counts 和 points 两个命名都很简单不要套一层无意义的data壳。后续想加品牌、城市筛选时可以在此基础上加request.args参数做动态查询。4.3 前端ECharts图表价格分布与价格-里程散点图前端模板的核心是 fetch 接口然后把数据通过setOption塞给 ECharts。初始化一个图表需要两步echarts.init获取实例然后setOption。默认的柱状图就可以满足毕业设计不用额外引入地图或 3D 效果。!DOCTYPE html html langzh-CN head meta charsetUTF-8 title二手车行情看板/title script src{{ url_for(static, filenameecharts.min.js) }}/script /head body div idpriceChart stylewidth: 90%; height: 400px;/div div idmilesChart stylewidth: 90%; height: 400px;/div script fetch(/api/price_distribution) .then(resp resp.json()) .then(data { var chart echarts.init(document.getElementById(priceChart)); chart.setOption({ title: { text: 二手车价格分布 }, tooltip: {}, xAxis: { type: category, data: data.labels }, yAxis: { type: value, name: 车源数量 }, series: [{ name: 车源数量, type: bar, data: data.counts }] }); }); fetch(/api/price_miles) .then(resp resp.json()) .then(data { var chart echarts.init(document.getElementById(milesChart)); chart.setOption({ title: { text: 里程-价格散点图 }, xAxis: { type: value, name: 里程(公里) }, yAxis: { type: value, name: 价格(万元) }, series: [{ name: 车源, type: scatter, data: data.points, symbolSize: 8 }] }); }); /script /body /htmlECharts 的 scatter 数据格式是[[x, y], ...]所以后端接口返回的 points 可以直接给进data字段不需要额外处理。如果散点图点太多导致重叠严重可以把symbolSize调小或者用visualMap加一个颜色维度。这里的图是静态版本的看板展示的是整份数据的整体轮廓。如果想让看板更有交互感最常见的增量是加一个价格区间筛选。前端放一个selectchange 事件里重新 fetch 带参数的新接口接口端用request.args.get接收过滤条件。比如后端可以这样改from flask import request app.route(/api/price_distribution) def price_distribution(): max_price request.args.get(max_price, default50, typefloat) session Session() rows session.query(Car.price).filter(Car.price.between(0, max_price)).all() session.close() # 后续分桶逻辑与之前相同这样前端每次切换筛选条件后端就重新计算一次分桶。对几千条数据来说这个计算量可以忽略。但注意Session的生命周期每次请求打开响应前关闭不要放在全局复用。使用 Flask 的app.teardown_appcontext可以统一关闭会话这里先不展开保持简单。4.4 看板启动检查数据库文件与静态资源图表加载顺序依赖于接口返回接口查询又依赖数据库启动 Flask 前先确认数据库文件存在。有一个简单检查ls -lh usedcars.db如果数据库文件为 0 字节一定是连接串或表名没对上。另外模板里引用的echarts.min.js必须真实存在于static目录否则浏览器会报 404图表区域空白。调试看板时先用浏览器直接访问/api/price_distribution如果返回的是 JSON说明后端正常再回到首页看图表如果仍空白打开开发者工具 Console查看是否有 JS 语法错误。这个排查顺序能帮你把“后端问题”和“前端问题”分开不会在样式里找半天。5. 二手车爬虫项目避坑反爬、编码缺失与数据入库的五个常见问题5.1 页面里有数据但select不到动态渲染与Selenium适用边界现象用 requests 抓回来的 HTML字符串搜索能看到车源标题但 BeautifulSoup 的select(.car-list-item)返回空列表浏览器打开页面时车源卡片是存在且可见的。原因页面框架把数据放在script里的 JSON 字符串或外部 XHR 接口中。字符串搜索能搜到是因为 JSON 里包含标题但 JSON 不在常规 HTML 标签结构里所以用 CSS 选择器自然选不中。另一种情况是 HTML 源码里根本没有数据浏览器执行 JS 之后才生成 DOM这时候 requests 连标题都搜不到。解决第一步打开抓回来的 HTML看标题上下文。如果是scriptwindow.__INITIAL_STATE__...直接切 JSON 解析比任何选择器都可靠。很多站点会把列表数据塞在window.__NUXT__里那是 Nuxt.js 渲染过程的遗留。第二步如果没有 JSON就在浏览器开发者工具里刷新页面看 Network 里 XHR 请求哪个返回的是车源数据直接模拟那个接口。只有这两条路都走不通才上 Selenium。Selenium 的动态加载等待要用显式等待不要time.sleep(5)from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC wait WebDriverWait(driver, 10) items wait.until(EC.presence_of_all_elements_located((By.CSS_SELECTOR, .car-list-item)))显式等待会在 10 秒内轮询元素出现就立即返回不浪费时间。这个方案的成功率取决于目标页面是否把反爬措施做在了浏览器指纹层如果做了Selenium 照样会被识别。所以在抓取阶段先把“能不能用 requests”当作第一优先级能少一个依赖就少一个。5.2 价格字段带“万”字导致排序错乱现象数据库里价格显示为“12.80万”“9.98万元”等字符串柱状图 X 轴按字母顺序排列10 在 2 前面散点图纵轴也没有数值间隔。原因写入数据库前没有做类型转换。SQLite 的动态类型比较宽松INSERT 时给 Float 字段传字符串不会报错但之后排序、比较全部按字符串走直到可视化时才发现。更隐蔽的原因是部分数据是float类型部分是字符串ORM 字段类型和真实数据类型不一致查询结果里混着两种类型。解决在入库环节用 parse_price 统一转成 float。如果已经有一批脏数据可以用 Python 脚本扫描整表对price字段尝试float()失败则解析字符串并回写。注意回写前先看price的原始值不要盲目除以 10000。验证方式很简单库表里执行SELECT price FROM car_infos ORDER BY price DESC LIMIT 5;如果最大价格符合常识说明字段类型正确如果返回“9.98万”这种字符串说明清洗没执行。注意验证价格字段类型最直接的方式是运行一条 ORDER BY 查询不要只看可视化效果。5.3 上牌日期“2019年05月”写入SQLite报错现象爬虫抓取和处理都没报错执行session.commit()时抛出异常提示SQLite Date type only accepts Python date objects但不是所有记录都报错有的能插入。原因ORM 模型里license_date声明为Date清洗函数对部分脏数据返回了原始字符串比如年份格式为“19年”或“00年”的记录正则可能提取失败后走了另一个分支返回了None倒不会报错返回字符串就会报错。SQLAlchemy 在参数绑定时发现类型不匹配直接拒绝。解决在 parse_year 里用datetime.strptime试解析失败就返回 None绝不让字符串漏到Date字段。同时入库前增加一个类型检查也不难对license_date字段做isinstance判断不是datetime.date就丢弃。日志里记录这样的脏数据方便回头修清洗规则。from datetime import datetime def safe_parse_year(text): if not text: return None try: return datetime.strptime(text.strip(), %Y年%m月).date() except ValueError: m re.search(r(\d{4})[年/\-.\s]*(\d{0,2}), text) if m and m.group(2): return datetime(int(m.group(1)), int(m.group(2)), 1).date() return None另一个相关坑是 SQLite 没有真正的 Date 类型它会存成文本或数字但 SQLAlchemy 会在读取时转回 Python date。因此程序内不要手动把日期拼接成字符串去和字段比较要传 date 对象。5.4 重复爬取导致库里数据翻倍现象爬虫脚本每次全量抓取跑几天后车源数量不减反增图表总量和实际车源对不上。原因页面本身会重复展示同一辆车比如推荐位、同城车源加上每次爬取不判断是否已存在同一个 source_url 被插入多次。解决先查再插是最直观的但要注意性能。几千条数据时循环里逐条query.filter_by就是几千次数据库查询速度还能接受几万条时就要改成一次查出全部 URL 集合。前面 3.3 已经给出了批量方案。另外去重不能只靠 title因为同一款车不同车源 title 可能一样甚至同一个车源在不同列表页 title 后面加了“新上架”字样。使用 source_url 相对靠谱但要做拼接和规范化。有些站点的 URL 带跟踪参数比如?fromrecommend去掉 query 参数后再做唯一键否则同一个车会因为参数不同被判为两条。最后建表时的UniqueConstraint是最后保险但它不能处理“已有重复数据”的历史包袱。清洗历史数据时优先保留crawled_at最早或id最小的那条。5.5 反爬返回验证码请求头与代理的顺序现象爬前面几页一切正常到第 30 页后突然解析到 0 条打印 HTML 发现是个验证码页面或者提示“访问过于频繁”。原因访问频率超过阈值触发站点的风控。注意风控不仅是 UA 识别还看 IP 的访问间隔、跳转路径和 headers 一致性。只改 UA 但不降速一样会触发。解决处理顺序很重要。先把time.sleep提高到 3 到 8 秒随机不要用固定间隔再补Referer头设置为列表页所在域名的首页如果还不行切换几个不同排序入口分散压力。代理放到最后因为免费代理池质量差IP 可能被很多爬虫共用反而更容易触发验证码。下面是常见状态码的处理参考状态码含义优先处理方式200正常检查内容是否是验证码页403禁止访问补全请求头、降速、换入口429请求过多指数退避等待切勿连续重试503服务端限流增加延时稍后重试一旦连续出现三次 403最好停下来人工用浏览器访问确认是 IP 问题还是规则变化再决定要不要换 IP。脚本里要写日志记录每页的状态码和条数否则第二天会发现库里数据没涨却完全不知道它在哪一步停住了。6. 进阶定时采集与数据一致性验证6.1 定时任务与日志数据可视化不是一次性的二手车源每天都在变定时采集让看板有“活”的感觉。Linux 下用 crontabWindows 用任务计划程序。关键是日志里要有“本次新增”“本次跳过”两个数字0 2 * * * cd /path/to/usedcar_project /usr/bin/python3 crawler.py logs/crawler.log 21我在 crawler.py 末尾会打印一行[INFO] total500 new12 skip488这样每天瞄一眼日志就能知道站点是否改了结构如果 total 从 500 变成 0优先查反爬如果 new 连续很多天为 0可能是页面字段变了或者库里已经抓全了。6.2 数据正确性验证定时任务跑起来后验证不能只靠“看起来有图”。我会先跑一条 SQL 统计空值SELECT COUNT(*) FROM car_infos WHERE price IS NULL OR miles IS NULL;如果空值比超过 10%说明清洗规则没覆盖某个页面模板。再随机抽取 10 条车源回网页人工核对这个动作看着原始却能发现单位换错、URL 重复等自动化检查不容易抓到的问题。散点图如果出现左上角或右上角异常聚集优先怀疑单位转换而不是车辆真的便宜。我现在接手一个采集项目第一件事就是看日志里条数有没有突然归零。这个习惯帮我避开过好几次反爬升级希望帮到你。本文还有配套的精品资源点击获取