ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python爬虫实战:安居客二手车数据采集、清洗与可视化大屏

Python爬虫实战:安居客二手车数据采集、清洗与可视化大屏 简介该资源是一份Python爬虫与数据可视化实战项目适合希望掌握网页抓取、数据清洗及图表展示的初中级开发者。项目以安居客二手车信息为抓取对象覆盖requests/BeautifulSoup网络请求解析、pandas数据处理、matplotlib/Seaborn绘图等关键环节并包含实际抓取的CSV数据与完整Python源码便于对照练习和二次开发。压缩包共15个文件以Python脚本、XML配置文件为主辅以CSV数据表、Markdown说明及项目截图整体仅383KB轻量易用。目前已有121人学习下载对于入门数据分析全流程、理解反爬与数据存储策略具有不错的参考价值。读者可借助源码直接运行观察二手车价格、车龄等维度的可视化结果并迁移到其他二手车或分类信息平台。1. 用 Python 拿下安居客二手车数据先看字段再看价格做二手车数据的老手都知道真正难的不是写爬虫而是拿到数据后怎么让业务方信。安居客二手车列表页看着是整页静态卡片实际上大部分城市站点把车源信息直接渲染在 HTML 里这给 Python 爬虫留了不小的操作空间。可是反爬门槛低不代表能一路平推页面改版、字段缺失、里程单位不统一这些坑一个接一个。这篇文章不是让你照着某份现成源代码抄而是把从列表页抓取、搜索参数构造、数据清洗到 pyecharts 可视化大屏输出这一整条链路拆开讲适合想独立完成一次完整数据采集项目的工程师也适合二手车估价和数据产品团队做城市行情参考。文中所有命令和脚本按可复现的方式给手边有 Python 环境就能跑。2. 爬取安居客二手车列表页先分清静态 HTML 与动态接口2.1 用开发者工具确认数据是“页面上”还是“接口里”写采集脚本前第一步不是打开编辑器而是打开浏览器开发者工具。进入安居客二手车列表页后按 F12 切到 Network 面板刷新页面搜索列表里任意一条车源标题。如果标题出现在 Doc 类型的响应里说明数据由服务端渲染直接用 requests 拿 HTML 即可如果标题只出现在某个 XHR 请求的 JSON 响应里那就不能靠 BeautifulSoup 去解析首页 HTML得先模拟那个接口。常见做法是把地址栏复制到代码中但更稳妥的是从 Network 面板复制请求 URL 和关键请求头。判断错误是新手最常见的返工原因。有人拿到一个含动态加载的城市频道页用 requests 请求后 HTML 里找不到车源于是怀疑被反爬封锁。实际上只是数据源没找对。用 Python 编写爬虫时先用requests.get(url)拉一次页面把返回文本输出到本地文件再打开文件搜索“二手车”或“万公里”等关键词。搜得到走 BeautifulSoup 路线搜不到回到 Network 面板找 JSON 接口。这一步能把后续排错时间省掉一半。2.2 requests BeautifulSoup 抽取字段的最小代码确定数据落在静态 HTML 后解析逻辑就简单了。安居客二手车列表页每个车源是一个卡片区块车名、价格、里程、上牌时间、所在地通常都能在卡片里找到。下面是最小可用解析脚本import requests from bs4 import BeautifulSoup HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Accept-Language: zh-CN,zh;q0.9, } def parse_list_page(html: str): soup BeautifulSoup(html, lxml) items [] for card in soup.select(div.car-card): title_tag card.select_one(h3 a) price_tag card.select_one(span.price) mileage_tag card.select_one(span.mileage) if not title_tag or not price_tag: continue items.append({ title: title_tag.get_text(stripTrue), detail_url: title_tag.get(href), price_text: price_tag.get_text(stripTrue), mileage_text: mileage_tag.get_text(stripTrue) if mileage_tag else None, }) return items resp requests.get(https://www.anjuke.com/ershouche/, headersHEADERS, timeout10) resp.encoding resp.apparent_encoding print(parse_list_page(resp.text)[:2])timeout10保证请求不会因为网络抖动无限挂起resp.apparent_encoding是根据页面内容推断编码避免中文乱码lxml解析速度比 Python 内置的html.parser快适合列表页这种节点较多的场景。卡片使用的div.car-card是常见命名但不同城市频道可能存在差异需要以自己抓到的 HTML 为准。字段解析时优先取select_one而不是find因为select_one在结构不变的前提下表现更稳定。每条数据里保存detail_url而不是只存标题后续做详情页扩展或去重都依赖这个链接。如果某些卡片缺少价格标签直接跳过比补默认值更安全避免把异常数据带进后续分析。2.3 bs4 不负责动态页面先找接口再解析经常有人问“bs4 怎么爬取动态页面”这个说法本身有误导。bs4 只是 DOM 解析器它无法执行 JavaScript页面里由脚本填充的内容对它来说不存在。如果确认目标车源数据来自 XHR 接口正确做法是找到那个接口。操作路径如下在 Network 面板过滤 XHR 请求逐条点击查看响应内容找到包含车源列表的请求后把它需要的 URL、参数和请求头复制出来然后在 Python 里用 requests 模拟请求拿到 JSON 后直接用 Python 标准库json解析根本不需要 bs4。import json import requests API_URL https://www.anjuke.com/ershouche/list # 以实际抓包为准 params {page: 1} resp requests.get(API_URL, paramsparams, headersHEADERS, timeout10) payload resp.json() for item in payload.get(data, {}).get(list, []): print(item.get(title), item.get(price))不要一碰到动态页面就搬出 selenium浏览器自动化虽然能兜底但启动慢、资源占用高。先花十分钟抓接口往往能省一小时。真正需要 selenium 的场景是登录后 cookie 频繁失效、页面按钮点击后才能加载数据或必须模拟鼠标操作筛选二手车条件这些是后期优化才考虑的事。3. 分页请求构造安居客二手车搜索参数与请求频率控制3.1 把城市、页码、排序写进 URL 参数而不是字符串拼接爬取多城市二手车数据时最忌讳用 f-string 把参数拼进 URL。中文城市名、页码和筛选条件存在转码问题一旦某个参数值为空整个 URL 结构就可能出错。正确做法是准备一个基础 URL用params字典传参让 requests 自己处理 URL 编码params { page: 1, city: shanghai, sort: time, } resp requests.get(base_url, paramsparams, headersHEADERS, timeout10) print(resp.url) # 调试时查看实际请求地址打印resp.url是一个被低估的调试技巧它能看到 requests 最终拼出的地址。这样构造参数有两个好处一是代码里不需要关心中文编码二是以后增加筛选条件只需在字典里加 key。安居客不同频道支持的参数名不完全一致常见的有page表示页码、city表示城市、sort表示排序方式。具体参数名从筛选控件变化后的地址栏复制不要凭经验猜。分页范围需要提前设定。爬虫循环前先检查列表页底部总页数避免代码跑到请求不存在的空页。最稳妥的写法是从第一页 HTML 里提取总页数文本再决定循环次数而不是写死一个 100 页上限。3.2 一个带重试和限速的分页循环反爬应对不是把 requests 伪装成浏览器就万事大吉连续快速翻页照样会触发频率限制。常见表现是请求返回 403、内容为空或者直接跳转验证码。下面代码把单页请求封装成带重试的函数import time import random def fetch_page(session, url, params, max_retry3): for attempt in range(1, max_retry 1): try: resp session.get(url, paramsparams, timeout10) if resp.status_code 200: return resp.text if resp.status_code in (403, 429): wait random.uniform(5, 10) * attempt print(f触发限制第 {attempt} 次重试等待 {wait:.2f}s) time.sleep(wait) else: time.sleep(2) except requests.exceptions.RequestException as e: print(f请求异常: {e}第 {attempt} 次重试) time.sleep(2) return None关键参数是max_retry、等待倍率和random.uniform产生的随机间隔。max_retry设为 3 基本够用超过这个次数说明大概率是 IP 被限制而不是偶发网络问题。随机间隔根据attempt递增第一次失败等 5 到 10 秒第二次失败等 10 到 20 秒给服务端的限流窗口留出恢复时间。循环调用时把 requests 的 Session 对象传入而不是每次重新创建。Session 会自动保存 cookie对维持会话状态有帮助也比每次调用requests.get更省连接开销。分页间隔建议设置在 1 到 3 秒之间具体数值取决于目标站点的访问量和自身网络环境没有统一标准。3.3 遇到验证码先降速不要上来就接打码几乎每个爬取项目都会遇到验证码。出现滑块验证码时第一反应不要是接入自动识别服务而是回头检查自己的请求频率和 headers。安居客这类站点对陌生 IP 的访问比较敏感连续高频请求是触发验证码的主要原因。先把分页间隔从 2 秒提高到 5 到 8 秒再换个完整的 User-Agent一半问题能缓解。验证码不是爬虫技术问题是访问节奏问题。维护一个 User-Agent 列表轮换比每次固定同一个 UA 更有效。每分钟请求数控制在个位数采集几千条数据花上十几分钟并不丢人稳定性优先于速度。如果降速后验证码仍频繁出现检查当前网络出口是否被其他爬虫任务共享共享出口 IP 的封禁概率远高于独享 IP。4. 二手车数据清洗与落地爬完不等于能用4.1 把“3万公里”转成 30000文本清洗的常见边界安居客二手车页面上的里程和价格展示给用户看的格式是“3万公里”“12.80万”直接存进数据库没问题但要画价格分布图、算均价必须先转成数字。清洗逻辑不能简单replace(万, )因为中文数字单位只出现在特定位置。下面是一个兼顾常见格式的清洗函数import re def clean_mileage(raw: str): if not raw: return None text raw.replace(万公里, 万).replace(公里, ).strip() match re.search(r(\d\.?\d*)万?, text) if not match: return None value float(match.group(1)) if 万 in text: value * 10000 return int(value) def clean_price(raw: str): if not raw: return None match re.search(r(\d\.?\d*), raw.replace(万, )) if not match: return None return float(match.group(1))清洗函数的核心是re.search定位第一个数字再用if 万 in text判断是否需要乘以 10000。这样的写法能同时兼容“3万公里”“1.2万公里”“8000公里”三种格式。价格同理统一去掉“万”字后转 float后续可视化时再按单位展示。边界情况需要人工确认。有人会把“面议”两个字当成价格存进来clean_price返回 None 后下游聚合计算时会自动跳过。建议清洗阶段宁可保留 None也不要填 0因为均价统计里 0 价格会显著拉低数值干扰业务判断。4.2 用 SQLite 做增量去重而不是每次全量覆盖爬虫跑一次生成一个 CSV 是项目雏形跑多次还能不重复才是可用工程。二手车源有唯一标识就是详情页 URL 里的数字 ID。从detail_url提取 ID 后把它设为数据库主键每次写入用INSERT OR IGNORE自然实现增量去重。import sqlite3 import re conn sqlite3.connect(cars.db) conn.execute( CREATE TABLE IF NOT EXISTS cars ( car_id TEXT PRIMARY KEY, title TEXT, price REAL, mileage INTEGER, city TEXT, crawled_at TEXT ) ) def extract_car_id(url: str): match re.search(r/(\d)(?:\.html)?$, url) return match.group(1) if match else Nonecar_id必须用文本类型而不是整型因为车源 ID 可能超过 32 位整数的表达范围。crawled_at记录抓取时间后续做数据时效性分析时能直接看这条数据是什么时候落的库。用主键去重后重复跑完整分页循环也不会产生脏数据。为什么不建议直接用标题加城市做唯一键因为标题可能被业务员手动修改同一辆车改个标题就变成两条记录。相比标题详情 URL 里的 ID 是服务端生成的基本稳定不变。因此写爬虫时把detail_url完整保留是后面所有去重逻辑的前提。4.3 数据质量校验放在入库之前落库前加一道校验函数能把“页面上解析出的假数据”挡在门外。常见异常包括价格小于 1 万、里程大于 100 万公里、上牌年份早于 1980 年。这些值大部分是页面模板异常或解析错位产生的不属于真实车源。检查项合理范围异常处理price0 price 500waste_to_collect_if_outlier 抛异常或置空mileage0 mileage 1000000置空并记录日志title非空且长度大于 5跳过空记录car_id正则匹配成功匹配失败则跳过校验逻辑不要散落在清洗函数里抽一个validate_record(record)函数返回布尔值调用方在cursor.execute前判断。这样以后换数据库或者接消息队列校验逻辑可以原样复用。异常数据打印到日志文件而不是直接丢弃因为有时候 parse 逻辑出了 bug日志里能看出所有记录的同一字段都缺这比逐条 debug 高效得多。5. 可视化展示用 pyecharts 生成二手车数据大屏5.1 先确定图表回答什么问题再选图表类型数据清洗完就到了可视化展示阶段。pyecharts 是 Python 里生成交互式网页图表最省事的库最终产物是 HTML 文件不需要额外部署图表服务发给任何人都能在浏览器打开。适合做二手车展示的项目通常要回答三个问题城市均价差多少、价格集中在哪个区间、哪些品牌上架量最多。每个问题对应一种图表。不要一开始就堆地图和立体柱状图。可视化大屏的价值不在颜色多而在信息层次清楚。第一屏放价格区间直方图和城市均价条形图第二屏放品牌上架量排行榜第三屏放城市价格热力地图这样看的人能一步步理解数据。5.2 价格分布直方图与品牌均价条形图先读 SQLite用 pandas 聚合数据再交给 pyecharts 渲染。这里用 pyecharts 的Bar和Pie组合import sqlite3 import pandas as pd from pyecharts.charts import Bar from pyecharts import options as opts conn sqlite3.connect(cars.db) df pd.read_sql_query(SELECT title, price, mileage, city FROM cars WHERE price 0, conn) brands df[df[price].notna()] brand_avg brands.groupby(city)[price].mean().sort_values(ascendingFalse).head(10) bar ( Bar() .add_xaxis(brand_avg.index.tolist()) .add_yaxis(城市均价, [round(v, 1) for v in brand_avg.values]) .set_global_opts( title_optsopts.TitleOpts(title安居客二手车城市均价 Top10), yaxis_optsopts.AxisOpts(name万元), xaxis_optsopts.AxisOpts(name城市, axislabel_optsopts.LabelOpts(rotate30)), ) ) bar.render(city_avg_price.html)pd.read_sql_query把 SQL 结果直接转成 DataFrame后续聚合不再需要手写 SQL 循环。groupby(city)[price].mean()得到城市均价sort_values降序后取前 10避免城市太多导致横向条形图标签挤在一起。rotate30是城市名较长时的常用处理把 x 轴标签旋转 30 度防止重叠。5.3 把多个图组合成可视化大屏页面散落的多个 HTML 文件不方便汇报pyecharts 的Page对象可以把多个图放进一个页面配上DraggablePageLayout还能拖动调整位置from pyecharts.charts import Page, Map from pyecharts.commons.utils import JsCode page Page(layoutPage.SimplePageLayout) page.add(bar) city_avg df.groupby(city)[price].mean().reset_index() map_chart ( Map() .add(二手车均价, [list(x) for x in city_avg.values], china) .set_global_opts( title_optsopts.TitleOpts(title全国二手车均价分布), visualmap_optsopts.VisualMapOpts(max_50), ) ) page.add(map_chart) page.render(ershouche_dashboard.html)Map的第二个参数传[城市, 数值]的列表pyecharts 才能正确匹配到地图区域。VisualMapOpts(max_50)决定颜色映射的上限如果城市均价最高 30 万max 设 50 会导致所有城市都显示同一个颜色可视化效果失真。这个值要依据数据实际范围调整。图表选型理由必调参数Bar城市均价对比最直观rotate处理长城市名Map看地域价格差异visualmap_opts.max_匹配数据范围Page单页整合多图汇报SimplePageLayout支持拖拽布局颜色和字体是次要优化点先把图表种类和数据映射调对再谈视觉。上述代码运行前执行pip install pyecharts pandas lxml beautifulsoup4 requests即可不需要额外安装地图数据包pyecharts 内置了 china 地图。6. 一个更省事的落地技巧把爬虫封装成可配置任务项目跑通后日常维护的重点从“改代码”变成“改配置”。把城市、最大页数、请求延迟、数据库路径抽到 JSON 配置里再用argparse接收命令行参数这样调试和定时调度都能复用同一套逻辑。{ cities: [shanghai, beijing], max_pages: 5, delay_range: [1, 3], db_path: cars.db, output_html: dashboard.html }import argparse import json def load_config(args): with open(args.config, r, encodingutf-8) as f: config json.load(f) if args.city: config[cities] args.city if args.pages: config[max_pages] args.pages return config if __name__ __main__: parser argparse.ArgumentParser() parser.add_argument(--config, defaultconfig.json) parser.add_argument(--city, nargs) parser.add_argument(--pages, typeint) args parser.parse_args() config load_config(args)命令行参数优先级高于配置文件这样临时只跑一个城市时不用改 config.json。后续接到定时任务里只需要写一行 cron0 2 * * * cd /opt/cars python crawl.py --config config.json。可视化部分也放进同一个入口爬完数据自动重新生成 HTML每天早上一打开就是最新行情。源代码和数据文件按crawler.py、cleaner.py、visualizer.py、config.json四个文件组织复杂场景再加utils.py放请求头和日志公共逻辑。数据落库才是项目的资产HTML 只是输出物配置化管理之后无论找人接手还是半年后自己回来维护都不至于靠记忆恢复链路。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进