简介:一份面向Python初学者的爬虫与数据可视化练手项目,聚焦汽车之家网站真实页面,完整演示了从网络请求、HTML解析、数据提取、清洗存储到图表展示的完整流程。项目依赖requests、BeautifulSoup、pandas及matplotlib等常用库,代码中包含模拟浏览器请求、设置请求头等反爬规避策略,适合希望系统性了解爬虫工程结构的读者。资源共15个文件,约311KB,核心包括两个Python脚本(数据采集与可视化视图)、一个CSV数据集、README说明文档,以及少量配置文件、缓存和效果图,结构清晰便于按模块阅读。目前已有152人学习下载。通过实际运行脚本,可掌握汽车报价、用户评论等数据的抓取与整理方法,并学会用条形图、折线图等展示价格分布与趋势;自带的data.csv和可视化结果图也为后续分析提供了可直接复用的数据基础。
1. 爬取汽车之家数据到可视化大屏:一份能直接跑通全链路的 Python 项目
这份资源不是一份挂在网页上的文档,而是一套从requests发请求、BeautifulSoup解析、SQLite入库到ECharts出图的完整源代码,里面连采集好的数据文件都一并打包了。拿到手最直接的用法是:把汽车之家某个车系库里的在售车型、价格区间、口碑评分、油耗数据拉下来,存成结构化表,再通过浏览器看到品牌销量分布和价格区间散点图的可视化大屏。适合三类人——刚过完 Python 语法、想用爬虫练手的初学者;正在做数据分析课设却缺一份真实数据源的学生;以及想研究市售车型定价区间的从业者。核心价值在于,爬虫、清洗、存储、可视化这条链路里每个环节的代码都是可运行、可改的,不需要你从零拼装,改改选择器就能迁移到别的汽车网站。
2. 从选型到落地:爬虫框架、存储结构与请求头的关键细节
2.1 为什么用 requests + BeautifulSoup 而不是 Scrapy
先说结论:这个项目的数据量级在几百到几千条,单机跑一遍十几分钟就结束,用 Scrapy 属于杀鸡用牛刀。requests的同步请求在这个量级下足够快,BeautifulSoup的find/find_all在定位车系卡片这类嵌套结构时非常直观,调试时可以一行行打印结果,不需要去理解 Scrapy 的 Spider、Pipeline、Middleware 三层架构。对第一次写爬虫的人来说,这个选型能把学习成本压到最低。
但你要清楚它的边界。如果哪天要爬全站几十万条数据,或者需要分布式抓取,requests+BeautifulSoup这套组合就会暴露问题:没有自动重试、没有并发调度、没有内置去重队列。到那个量级再迁移到 Scrapy 或者用httpx+parsel,成本也不算高,因为解析逻辑本身可以复用。所以这个项目的选型逻辑不是"更好",而是"在这个数据规模下最省事"。
判断该不该用这套轻量组合,我一般看三个条件:单次任务请求量是否超过一万;是否需要维持登录态;是否需要按规则定时增量抓取。三个都否,就放心用requests+BeautifulSoup。这份源代码走的就是这条技术路线,对新手最友好的一点是,出错时栈信息能直接定位到具体行,不会像 Scrapy 那样被框架层吃掉异常,排查起来直观很多。
2.2 数据表设计:车型、价格、口碑三个维度
字段怎么设计,直接决定后面清洗和可视化费不费劲。这份项目的建表思路是围绕"车型"这一核心实体来组织:一张主表存基本信息,价格拆成最小值和最大值两个字段,而不是把原始字符串整段存进去。这么做的原因很直接——后面做区间统计、画散点图的时候可以直接参与计算,不用每条都重新解析一遍字符串。
CREATE TABLE IF NOT EXISTS car_info ( id INTEGER PRIMARY KEY AUTOINCREMENT, brand TEXT NOT NULL, model TEXT NOT NULL, price_min REAL DEFAULT 0, price_max REAL DEFAULT 0, rating REAL DEFAULT 0, fuel_consumption TEXT DEFAULT '', url TEXT UNIQUE, created_at TEXT DEFAULT (datetime('now', 'localtime')) );这张表的逻辑在于:brand存品牌,model存车系或具体车型名,price_min和price_max是清洗后的数值型价格,rating是口碑评分,fuel_consumption保留字符串是因为油耗数据经常是"7.5L/100km"这种带单位的写法,强行转float会直接报错。url加UNIQUE约束是做去重的第一道保险——数据库层面挡住重复插入,比爬虫脚本里手写判断要可靠得多。
参数说明:price_min和price_max用REAL类型是为了兼容 ECharts 直接读取并参与聚合运算,如果你只做展示不参与计算,存TEXT也行,但后续AVG、GROUP BY这类查询就得写CASE WHEN转换,麻烦很多。rating同样用REAL,方便计算平均分。url的UNIQUE是整个表设计里最关键的一条,少了它,断点续爬时重复抓取的页面会不断插入新行,后期去重成本会高得多。
2.3 请求头与 Cookie:汽车之家反爬的第一道门槛
汽车之家不算是反爬特别激进的门户,但它会对明显异常的请求直接返回验证页。项目里请求头的设置是比较完整的——User-Agent 用浏览器的真实标识,Referer 指向列表页来源,必要时从浏览器复制 Cookie。这三样里缺任何一样,多请求几页就可能被弹验证页,解析结果瞬间全空。
headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Referer": "https://www.autohome.com.cn/car/" }这段配置的逻辑:User-Agent 是服务器识别客户端的第一重信息,缺了或者写成Python-requests/2.31这种默认值,通常第一次请求就会被拦。Referer 表示请求来源,汽车之家的分页接口会校验这个字段,空 Referer 在某些分页请求下会被拒绝。Cookie 不是必须的,但带上可以降低被判定为爬虫的概率,毕竟正常用户浏览器里一定是有 Cookie 的。
参数说明:这些值随浏览器版本更新会变,最稳的做法是复制你自己浏览器里开发者工具看到的完整请求头。注意Accept-Language里zh-CN要放在前面,否则部分接口可能返回英文版或错误编码。还有一个关键认知:汽车之家对请求头的校验不是每次都做,而是有频率阈值,所以后面章节里对请求间隔的控制,重要性比请求头本身更高——头伪装得再像,请求频率爆了照样被拉黑。
3. 数据采集实战:分页、字段解析与入库的完整代码
3.1 从列表页解析车型:定位到每一条车系卡片
拿到列表页 HTML 之后,核心工作就变成了把它转成结构化字段。常见做法是先requests.get拿到页面文本,再用BeautifulSoup按 CSS 类定位车系卡片容器,最后从每张卡片里提取品牌、车系、价格字符串。这里有一整套选择器值得逐行说明。
import requests from bs4 import BeautifulSoup headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" } def parse_list_page(page_url): resp = requests.get(page_url, headers=headers, timeout=10) resp.encoding = 'utf-8' soup = BeautifulSoup(resp.text, 'html.parser') cards = soup.select('.list-dl .list-dl-item') for card in cards: brand = card.select_one('.list-dl-name a').get_text(strip=True) model = card.select_one('.list-dl-item a').get_text(strip=True) price_tag = card.select_one('.font-price span') price_text = price_tag.get_text(strip=True) if price_tag else '暂无报价' print(brand, model, price_text)这段代码里比较关键的是resp.encoding = 'utf-8'这一行:手动指定编码是为了防止 requests 的自动猜测出错,汽车之家页面是 UTF-8 编码,不指定的话偶尔会出现中文乱码,而且这种乱码在写入数据库之后极难追溯。soup.select用 CSS 选择器按层级定位卡片,.list-dl .list-dl-item表示 class 为list-dl的元素下所有 class 为list-dl-item的子元素。
参数说明:timeout=10是必须的,不设的话线程可能在网络异常时永久阻塞。实际页面里车系卡片的类名可能随改版调整,如果抓出来结果为空,先用浏览器开发者工具查看当前 DOM 的真实类名,替换掉这两处选择器即可。这里的price_text还是原始字符串,比如"8.98-13.98万",真正的数值转换放在下一章做,不要在解析层处理——因为列表页还有"暂无报价"、"待上市"这类无法转float的值,统一留给清洗阶段报错,才能精确定位是哪些页面出了问题。
3.2 分页采集:翻页参数与入库循环
列表页一页只显示约 20 个车系,要抓完整数据就得翻页。汽车之家的分页逻辑是 URL 里带页码参数,常见结构是car/0-0-0-0-0-0-0-0-0-0-0-0-0-0-0-{页码}/,每次循环改页码即可。入库前把上一步解析的字段按第 2 章的表结构写入 SQLite,这里我用的是INSERT OR IGNORE。
import sqlite3 import time import requests from bs4 import BeautifulSoup conn = sqlite3.connect('autohome.db') cursor = conn.cursor() for page in range(1, 6): url = f'https://www.autohome.com.cn/car/0-0-0-0-0-0-0-0-0-0-0-0-0-0-0-{page}/' resp = requests.get(url, headers=headers, timeout=10) resp.encoding = 'utf-8' soup = BeautifulSoup(resp.text, 'html.parser') cards = soup.select('.list-dl .list-dl-item') for card in cards: brand = card.select_one('.list-dl-name a').get_text(strip=True) model = card.select_one('.list-dl-item a').get_text(strip=True) price_tag = card.select_one('.font-price span') price_text = price_tag.get_text(strip=True) if price_tag else '暂无报价' cursor.execute( 'INSERT OR IGNORE INTO car_info (brand, model, price_min, price_max, url) VALUES (?, ?, ?, ?, ?)', (brand, model, 0, 0, url) ) conn.commit() print(f'第 {page} 页完成,共 {len(cards)} 条') time.sleep(2) conn.close()逻辑说明:遍历 5 页只是演示,实际跑的时候把range(1, 6)改成你需要的页码区间就行。INSERT OR IGNORE配合建表时的url UNIQUE约束,重复记录天然被跳过。price_min和price_max先塞 0 占位,等第 4 章清洗完再回填,这样解析和数据清洗两个阶段解耦,各自出问题时互不拖累,排查链路更清晰。
参数说明:time.sleep(2)是抓取节奏的关键参数,意思是每抓完一页停 2 秒。2 秒是我在汽车之家的经验值,太快容易被识别,太慢数据量大的时候等到怀疑人生。如果把页码改成range(1, 101),请把间隔相应调长到 3 秒以上。conn.commit()放在每页之后而不是每条之后,是为了减少磁盘写次数,抓 1000 条也不会让 SQLite 变慢。
3.3 详情页补全:口碑评分与油耗的二次请求
列表页给的信息只有品牌、车系、价格区间。口碑评分和油耗在车系详情页里,这份项目单独写了一个补全模块,对每条已入库记录发起详情页请求。实现思路是先从car_info表里把url查出来,再请求详情页,找到口碑分和油耗节点并回写。
for row in cursor.execute('SELECT id, url FROM car_info WHERE rating = 0'): car_id, detail_url = row try: resp = requests.get(detail_url, headers=headers, timeout=10) resp.encoding = 'utf-8' soup = BeautifulSoup(resp.text, 'html.parser') rating_tag = soup.select_one('.score-box .score-digit') rating = float(rating_tag.get_text(strip=True)) if rating_tag else 0 fuel_tag = soup.select_one('.fuel-consumption .phone-font') fuel = fuel_tag.get_text(strip=True) if fuel_tag else '' cursor.execute( 'UPDATE car_info SET rating = ?, fuel_consumption = ? WHERE id = ?', (rating, fuel, car_id) ) conn.commit() time.sleep(1) except Exception as e: print(f'车系 {car_id} 补全失败:{e}')逻辑说明:SELECT的条件是rating = 0,意思是只补全还没有评分的记录,这是断点续爬的另一个关键设计——无论程序中途挂了还是网络请求失败,重跑这一节只会处理遗漏的数据,不会重复请求已完成的行。UPDATE按主键id定位,避免多条同品牌同车系的记录互相覆盖。
参数说明:time.sleep(1)比列表页的 2 秒短,因为详情页的单次请求内容更轻,间隔设小一点整体耗时可控。try/except里只打印错误不中断循环,是我做爬虫养成的习惯——遇到个别页面结构异常继续往下走,全部跑完再看错误列表决定要不要补。注意rating_tag选不到时置 0,后面可视化阶段统一过滤掉这些没有评分的车系,避免零值污染图表。
4. 数据清洗与存储:SQLite 类型转换、价格区间切分与去重
4.1 价格字符串解析:从"8.98-13.98万"到数值区间
列表页存进库里的price_min和price_max还是 0,原始价格字符串需要单独解析回填。如果你拿到手的代码在解析阶段存过price_text字段,这一步直接读出来拆;如果没存,就重跑一遍解析脚本把它补上。清洗函数是这类爬虫项目里最容易被低估的部分。
import re def parse_price(price_text): if not price_text or '暂无报价' in price_text: return 0, 0 nums = re.findall(r'\d+\.?\d*', price_text) if len(nums) == 1: return float(nums[0]), float(nums[0]) elif len(nums) >= 2: return float(nums[0]), float(nums[1]) return 0, 0 cursor.execute('SELECT id, price_text FROM car_info') for car_id, price_text in cursor.fetchall(): p_min, p_max = parse_price(price_text) cursor.execute( 'UPDATE car_info SET price_min = ?, price_max = ? WHERE id = ?', (p_min, p_max, car_id) ) conn.commit()逻辑说明:re.findall把字符串里所有数字找出来,区间价格"8.98-13.98万"会匹配到两个数,分别作为最低价和最高价。只有一个数的情况对应"11.98万"这种一口价或者待上市指导价。两遍都匹配不到就返回0, 0,交给可视化阶段过滤。
参数说明:正则\d+\.?\d*表示匹配整数或小数,\.是可选的原因是部分页面价格写成"139800"不带小数点,这种情况下也能正确提取。这里有个实际工作中经常翻车的点:这个正则会误匹配"2024款 8.98-13.98万"里的2024。所以清洗前要确认price_text里只有价格信息,像这种混合文本建议先按空格分割,只取包含"万"字的分段再去提取数字,否则会把年款年份当成价格上限。
4.2 去重与更新策略:INSERT OR IGNORE 和 REPLACE 怎么选
很多教程喜欢用REPLACE INTO做去重,但它在 SQLite 里的行为是先删除旧记录再插入新记录,导致自增主键一直变大,关联数据也可能丢失。这份项目用的是INSERT OR IGNORE,专门配合url UNIQUE约束,重复键直接跳过,主键保持稳定。三种策略的适用场景差别很大:
| 策略 | 重复时行为 | 主键变化 | 适用场景 |
|---|---|---|---|
| INSERT | 直接报错 | 无 | 只跑一次的脚本 |
| INSERT OR IGNORE | 跳过该条 | 稳定 | 增量续爬 |
| REPLACE | 删除后重插 | 增加 | 需要更新全字段 |
参数说明:如果你需要的是"页面内容更新后覆盖旧数据",INSERT OR IGNORE不能满足,那就应该改成先UPDATE再INSERT的双语句逻辑,让主键复用但内容刷新。项目里对价格和评分的回填用的正是UPDATE方式,因为url是稳定的,内容是可变的,这才是增量爬虫的正确姿势——不能因为一条记录内容变了就删掉重插。
4.3 数据自检:抓完先查一遍再进可视化
清洗完入库,不要直接连可视化,先跑几条查询确认数据量级、字段完整度、价格是否合理。这一步能拦住大部分后期问题,也是我个人的血泪经验——曾经跳过自检直接画图,结果品牌名错位在图表上看起来毫无破绽,排查到半夜才发现是字段顺序写反了。三个最常用的检查命令长这样:
SELECT COUNT(*) FROM car_info; SELECT brand, COUNT(*) AS cnt FROM car_info GROUP BY brand ORDER BY cnt DESC LIMIT 10; SELECT COUNT(*) FROM car_info WHERE price_min < 0 OR price_max < price_min;逻辑说明:第一条确认总记录数在预期范围内,比如抓 5 页车系列表大约是 100 条,差太多说明某页解析为空;第二条看品牌分布,正常情况头部品牌集中、尾部品牌零星分布,如果某个品牌数量异常翻倍,多半是分页 URL 没递进导致同页重复抓取;第三条查非法价格,price_max小于price_min说明解析顺序反了或者正则把数字对调了。
参数说明:这些 SQL 只是自检的起点。实际我还会加一条rating > 0的记录占比,如果低于 70%,说明详情页补全时选择器大面积失效,这时候先去调详情页解析脚本,不要带着残缺数据直接做可视化大屏。这个顺序不要颠倒——自检通过后再进入第 6 章的导出环节,否则图表的坑和数据的坑混在一起,排查起来等同于在双重迷雾里找路。
5. 爬虫避坑指南:汽车之家反爬与常见问题排查
5.1 请求几十页后开始收到验证码页
现象:跑到第 30 页左右,resp.text里开始出现"访问验证"或滑块页的标识,解析结果全是空列表,但程序没有报错。
原因:请求频率超过了网页端的阈值,虽然没有封 IP,但触发了人机校验。这种情况常见于把time.sleep(2)改成 0.5 秒,或者开了多线程并发而完全没有限速。
解决:把列表页请求间隔调回 2 到 3 秒,详情页补全间隔调到 1 到 2 秒。如果已经触发了校验,停 10 分钟以上再继续。顺手把验证码页面抓下来存成.html文件,检查是哪个 URL 触发的,把对应页码往后移——这比盲目换代理靠谱得多。
注意:遇到验证码优先降频,不要第一时间换 IP 池。换 IP 只能解决封禁问题,解决不了频率触发的软校验。
5.2 页面改版导致 select 不到节点
现象:昨天还能正常解析出brand和model,今天跑脚本输出全是空,但 HTTP 状态码是 200,也没收到验证码。
原因:汽车之家的前端类名或 DOM 层级调整了,.list-dl-item这层选择器在新版页面里已不存在。这是爬虫维护成本的大头,也是该项目源码里最容易过期的地方。
解决:用浏览器开发者工具打开列表页,查看当前卡片的真实类名,把soup.select里的选择器替换掉。替换后先跑单个页面,打印前 5 条结果确认字段完整,再放全量。不要一次性改完就跑全量——改错了浪费一整轮请求,改对了也要观察是否会触发频率限制。
5.3 属性字段乱码成"鍝佺墭"这类字符
现象:写入 SQLite 的品牌名变成乱码,比如"品牌"变成"鍝佺墭",在 Python 打印时却完全正常。
原因:resp.encoding自动检测成 GBK 或 Latin-1,而页面实际是 UTF-8。虽然 Python 内部字符串显示正常,但写库时按错误编码转出,读到库里就是乱码。这种问题在爬虫里属于隐藏最深的类型——日志里看全对,库里看全错。
解决:在requests.get之后强制指定resp.encoding = 'utf-8',再交给 BeautifulSoup 解析。已经写进库里的乱码数据不要手动改编码,直接重新请求一次覆盖更新最干净。SQLite 存的本质是字节串,在库里做编码转换反而容易把正常数据也搞坏。
5.4 重复数据越抓越多
现象:跑第二轮增量爬虫后,COUNT(*)的数量翻了倍,而且看起来是同一条记录被插入了多次。
原因:url字段没有加UNIQUE约束,或者分页 URL 没变化导致同一页重复请求。前者是建表走了简化 DDL,后者是循环里页码参数写成了固定值。
解决:先看建表语句里有没有url TEXT UNIQUE,没有就重建表并带上约束。再把分页 URL 打印出来检查页码参数是否真的在变化——有些站点的分页参数不是页码而是 offset,光改数字是无效的。
5.5 SQLite 数据库文件被占用导致读写失败
现象:爬虫跑一半报database is locked,程序退出后重跑还是报同样的错。
原因:连接没有关闭,或者另一个终端手动打开着同一个.db文件。SQLite 对写操作是库级锁,两个写连接同时存在就直接拒绝。
解决:每个脚本用with sqlite3.connect('autohome.db') as conn管理连接,自动提交自动关闭。排查是否开着 DB Browser 之类的图形工具在查看同一个库文件,关掉再跑。项目源码里如果用的是模块级全局连接,记得在main流程结束后调用conn.close()——这也是我多次半夜排查才发现的老实教训。
6. 可视化展示:用 ECharts 把数据变成价格分布与品牌看板
6.1 从 SQLite 导出 JSON:给前端准备好数据接口
ECharts 是纯前端图表库,不直接读 SQLite,所以需要先把数据库查出来存成 JSON 文件。这一步本质上是给可视化大屏做数据接口。
import json import sqlite3 conn = sqlite3.connect('autohome.db') conn.row_factory = sqlite3.Row cursor = conn.cursor() rows = cursor.execute('SELECT brand, model, price_min, price_max, rating FROM car_info WHERE price_min > 0').fetchall() data = { "cars": [ {"brand": r["brand"], "model": r["model"], "price_min": r["price_min"], "price_max": r["price_max"], "rating": r["rating"]} for r in rows ] } with open('car_data.json', 'w', encoding='utf-8') as f: json.dump(data, f, ensure_ascii=False, indent=2)逻辑说明:注意WHERE price_min > 0,把清洗阶段没解析出价格的记录过滤掉,避免前端图表里出现 0 值的假数据。row_factory = sqlite3.Row让查询结果支持按列名访问,比按索引取字段可读性好很多。ensure_ascii=False是必须的,否则 JSON 里的中文会被转成\uXXXX,图表里会直接显示成乱码。
参数说明:indent=2只是让中间文件在调试时可读,正式部署可以去掉以缩小体积。导出后打开 JSON 目测前 20 条,确认brand和price_min是正常值再交给前端——这比在浏览器里开控制台 Debug 省时间得多。
6.2 ECharts 柱状图与散点图:品牌车型数与价格区间
前端页面用单个 HTML 文件就能起本地服务,不需要后端框架。这里展示两个核心图:品牌车型数柱状图,和价格区间散点图。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>汽车之家数据可视化看板</title> <script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script> </head> <body> <div id="brandChart" style="width: 600px; height: 400px;"></div> <div id="priceChart" style="width: 600px; height: 400px;"></div> <script> fetch('car_data.json').then(res => res.json()).then(data => { const brandMap = {}; data.cars.forEach(car => { brandMap[car.brand] = (brandMap[car.brand] || 0) + 1; }); const brandList = Object.entries(brandMap).sort((a, b) => b[1] - a[1]); const brandChart = echarts.init(document.getElementById('brandChart')); brandChart.setOption({ title: { text: '品牌在售车系数量' }, tooltip: {}, xAxis: { type: 'category', data: brandList.map(item => item[0]) }, yAxis: { type: 'value' }, series: [{ type: 'bar', data: brandList.map(item => item[1]) }] }); const priceChart = echarts.init(document.getElementById('priceChart')); priceChart.setOption({ title: { text: '车系价格区间分布' }, tooltip: {}, xAxis: { type: 'value', name: '最低价(万)' }, yAxis: { type: 'value', name: '最高价(万)' }, series: [{ type: 'scatter', data: data.cars.map(car => [car.price_min, car.price_max]) }] }); }); </script> </body> </html>逻辑说明:fetch读取本地 JSON 文件时,浏览器会在你双击打开 HTML 时拦截file://协议的 fetch 请求,所以需要起一个本地静态服务器。在项目目录执行python -m http.server 8000,然后浏览器访问http://localhost:8000打开页面,fetch 才能正常拿到car_data.json。
参数说明:散点图的横纵坐标分别是price_min和price_max,如果解析正确,所有点应该落在对角线y >= x的右上半边区域。看到大量点在y = x直线上抖动,说明一口价车型多,属于正常现象;看到对角线下方出现成片的点,就要回头检查 4.1 节的清洗逻辑。
6.3 图表数据与数据库的一致性验证
图表能画出来不代表数据对。我见过有人把解析错位的字段直接画成图,柱状图倒是出来了,但品牌和价格对不上号。所以我每次做完可视化,会强制走一遍前端与数据库的比对:在浏览器控制台跑一个 JavaScript 统计总数和均价,再回到 SQL 执行同样的聚合,两个数字能对上,说明从数据库到 JSON 到图表这条链路没有丢数据、没有字段错位。
const totals = data.cars.length; const avgPrice = data.cars.reduce((s, c) => s + (c.price_min + c.price_max) / 2, 0) / totals; console.log(totals, avgPrice.toFixed(2));对应回 SQL 端执行SELECT COUNT(*), AVG((price_min + price_max) / 2) FROM car_info WHERE price_min > 0;。从那次被图表数据骗过之后,我每次抓完数据都强制走一遍这条比对流程,宁可多花两分钟,也不让错数据上屏。希望这个验证习惯帮到你——哪怕只省下一次返工的时间,这套流程就值得留作固定动作。
本文还有配套的精品资源,点击获取