资讯详情

资讯详情

Python爬虫实战:汽车车型评测数据全流程抓取指南

很多做数据分析的朋友问过我第一个实战爬虫项目选什么好。我的建议一直很明确选一个以文章为主、但又不只是文章的垂直内容站。车型评测就是非常典型的例子——它有标题、发布时间、作者、评分、长正文、参数表一口气覆盖了列表页、详情页、结构化数据提取和文本清洗练完这一套你再去碰其他内容型网站会有种融会贯通的感觉。我最近花了一个周末写了一个针对某汽车垂直平台的车型评测爬虫从列表页一路爬到详情页把车型标题、分项评分、正文、参数表全部落进了 SQLite。整个过程其实不难但零零碎碎的坑非常多。这篇把我从请求到入库的完整链路以及踩过的问题如实写下来给准备做同类方向的朋友当一份参考。先说明一点下文里目标站统一称为“目标汽车垂直平台”示例域名使用虚构的 example-car.com。爬取公开网页之前务必先确认目标站的 robots 协议和服务条款控制请求频率只采集自己真正需要的字段不要给别人的服务器制造压力也不要用拿到的数据去做明显侵害对方权益的事。1. 车型评测页看着像文章拆开其实是四层结构很多人写爬虫喜欢上来就复制选择器跑通两个页面就以为完工结果换一批车型就开始报错。我现在习惯先打开三到五篇评测详情页把页面里所有值得留存的信息画成一棵树——整理完你才会发现有些看着像表格的区块实际上是图片有些看着像纯文本的车系名称被拆成了好几个节点。提前把字段梳理清楚后面至少能少改两轮代码。1.1 页面里真正值得落库的字段一次完整的车型评测按我的习惯会拆成四个区块文章基础信息、评分信息、正文内容、参数配置表。四个区块的数据形态差异很大对应的解析方式也不同。文章基础信息包括标题、发布日期、所属频道、原文链接、来源作者或编辑。这些字段大部分在页面头部就能拿到结构相对固定适合作为每条记录的主索引。评分信息通常是编辑打的综合分以及外观、内饰、空间、动力、操控、油耗这些分项得分。很多汽车媒体会把评分做成进度条或者星级样式数据会藏在一个宽度百分比或>import random import time import requests from urllib.parse import urljoin UA_POOL [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15, Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0, ] def build_headers(referer: str) - dict: return { User-Agent: random.choice(UA_POOL), Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.5, Connection: keep-alive, Referer: referer, Upgrade-Insecure-Requests: 1, } def fetch_html(url: str, session: requests.Session, referer: str ) - str | None: headers build_headers(referer) for attempt in range(3): try: resp session.get(url, headersheaders, timeout15) resp.raise_for_status() return resp.content except Exception as e: wait_seconds 1.5 attempt * 2 print(fattempt {attempt1} failed: {e}, wait {wait_seconds:.1f}s) time.sleep(wait_seconds) return None这种写法其实没什么高深的地方但很皮实。requests.Session 能自动保留服务器下发的 Cookie减少重复连接UA 随机是为了避免同一个固定 UA 被对方统计出来强制走三次重试基本能扛住偶发超时。2.2 默认请求头里千万别漏 Referer不少内容站点会对不同来源的请求做区分。我测试时发现去掉 Referer 的请求虽然也能拿回页面但响应速度明显变慢偶尔还会被临时限制。加上站内来源之后整体顺畅很多。Referer 的值不要乱填最合理的做法是填上一级来源请求列表页时填首页请求详情页时填列表页地址。2.3 重试和限速要设置但别把目标站当靶场每次请求之间我加了 0.6 到 1.5 秒的随机延时一次完整抓取跑下来也就是几分钟的事没必要开并发。很多人一上来就喜欢高并发用几十个线程猛拉结果把对方服务器弄得响应变慢自己也拿不到数据。爬虫项目里频率控制不是浪费时间是保证长期可用的基础操作。如果你的场景确实需要更快优先考虑把请求间隔降到 0.2 秒左右不要盲目上几十路并行。保持低强度、慢节奏既能完成任务也不会给自己留下不必要的隐患。3. 列表页解析卡片区块、相对链接和翻页边界列表页是入口解析难度通常最低但也最容易出问题。常见问题有卡片选择器写太死、href 是相对地址没处理、翻页循环跑不到尽头。把这一层处理好就能稳定拿到一批待抓取的详情链接。3.1 先分析区块再写选择器目标站的列表页结构大体是外层有一个 review-card 区块里面包含标题的 h3、摘要的 p、以及指向详情页的 a 标签。用 BeautifulSoup 解析时先定位卡片区块再在区块内部找数据比直接写一个全局的长选择器健壮得多。from bs4 import BeautifulSoup def parse_list(html: str, base_url: str) - list[dict]: soup BeautifulSoup(html, lxml) cards soup.select(div.review-card) results [] for card in cards: a_tag card.select_one(a.card-link) title_node card.select_one(h3.card-title) if not a_tag or not title_node: # 宁可跳过也不要因为一条脏数据影响整体 continue results.append({ title: title_node.get_text(stripTrue), url: urljoin(base_url, a_tag[href]), }) return results解析时扎到的教训是不要对每一条失败都急着报错。一个区块里偶尔会混入广告位或者空标题卡片直接 continue 跳过比把脏数据放进库里更合理。3.2 用 urljoin 处理相对地址href 大概率不是全量 URL可能是 /review/1203456.html 这种站内相对路径偶尔还会遇到带 ../ 的路径。不要自己用字符串拼接直接调用 urljoin它会自动处理斜杠、相对路径和 query 参数。这里传的 base_url 用列表页地址就行不需要额外构造。3.3 翻页终止条件与链接去重列表页翻页逻辑我写在了主调度里循环页码每页解析出 URL 列表然后把这些 URL 与全局集合比对。重复率达到一定比例就停解析不到卡片也停。def crawl_list_pages(session: requests.Session): seen set() page 1 while True: list_url fhttps://www.example-car.com/review/list/p{page} html fetch_html(list_url, session, refererhttps://www.example-car.com/) items parse_list(html, list_url) if not items: break new_items [it for it in items if it[url] not in seen] if not new_items: break for it in new_items: seen.add(it[url]) print(fpage {page} got {len(new_items)} links, total {len(seen)}) page 1 time.sleep(random.uniform(0.5, 1.0)) return list(seen)这个写法看起来普通实际上避免了两个隐蔽问题一是列表页后半段可能出现重复推广位导致无限循环二是一篇评测被挂在两个栏目下去重后重复下载的概率大大降低。4. 详情页解析正文容器、评分伪装和参数表详情页是主战场。这里要处理的不只是“拿一段文本”还得考虑正文容器对不对、评分藏在哪个属性里、参数表能不能直接转成 DataFrame。每一个问题在真实页面里都能找到对应场景。4.1 容器选择顺着 DOM 找内容主体进入详情页后第一步永远是确认正文容器。目标站的评测文章主体在 article 标签内文章内容区块的 class 名称是 article-content。我写解析函数时用了两个兜底优先取 article-content取不到就退而求其次选 article 标签。def extract_content(html: str) - dict: soup BeautifulSoup(html, lxml) title soup.select_one(h1.review-title) date_node soup.select_one(span.publish-time) article soup.select_one(div.article-content) or soup.select_one(article) content article.get_text(\n, stripTrue) if article else if len(content) 50: # 内容太短说明选择器失效打印告警 print(WARNING: content seems too short, check selector) return { title: title.get_text(stripTrue) if title else , published_at: date_node.get_text(stripTrue) if date_node else , content: content, content_html: str(article) if article else , }拿不到正文时不要硬写进库。我在本地测试时整个库都是干净的“解析失败”记录浪费大量时间。后来加了长度校验小于 50 个字符直接告警问题第一时间暴露。4.2 分项评分用正则还是用 HTML 属性目标站的评分区有两种形式。综合评分是文字比如“8.9分”直接取文本即可。分项评分则是进度条DOM 上常见的是 span 标签里放一个 style 属性比如 style“width: 86%”86% 对应的就是 8.6 分。解析时用正则把 style 属性里的数字抓出来更稳妥。import re def extract_scores(soup: BeautifulSoup) - dict: score_map {} items soup.select(div.sub-score-item) pattern re.compile(r(\d(?:\.\d)?)) for item in items: name_node item.select_one(.score-name) bar_node item.select_one(.score-bar) if not name_node or not bar_node: continue name name_node.get_text(stripTrue) style_attr bar_node.get(style, ) match pattern.search(style_attr) if match: score_map[name] float(match.group(1)) / 10.0 return score_map这段逻辑有个细节进度条宽度一般是百分比 0 到 100所以要把正则抓出来的数字除以 10。如果你碰到的页面评分范围是 0 到 10 而不是 0 到 100去掉除法即可。务必先打开真实页面确认表达方式再写死。4.3 车型参数表直接交给 pandas.read_html参数表是整个页面里最规整的数据用正则逐个解析反而容易出错。pandas.read_html 可以直接把 HTML 表格读成 DataFrame前提是要指定表格区域或 class避免把页面底部的其他表格也一并读进来。import pandas as pd def extract_params(html: str) - list[dict]: dfs pd.read_html(html, attrs{class: config-table}) if not dfs: return [] df dfs[0] # 第一列是参数名第二列或第三列是参数值 records [] for _, row in df.iterrows(): cells [str(c).strip() for c in row.tolist()] if len(cells) 2: records.append({name: cells[0], value: cells[1]}) return recordsread_html 依赖 lxml 和 html5lib缺哪个装哪个。遇到多级表头时返回的 DataFrame 结构可能不如预期可以先打印 df.head() 确认列名再写清洗逻辑。5. 动态渲染的评测内容先找接口再上浏览器不是所有评测内容都老老实实放在 HTML 源码里。我这次遇到的情况是部分车型页面在列表页能看到摘要但详情页的某些字段由 JavaScript 后加载。遇到这种页面别第一时间打开一个浏览器框架硬怼先判断数据到底从哪来。5.1 先判断数据是不是藏在接口里用浏览器打开页面右键查看源代码搜索一条你在页面上肉眼可见的标题或评分。如果源码里搜不到说明内容是异步加载的。接着按 F12 打开开发者工具的 Network 面板刷新页面筛选 XHR 或 Fetch 请求找一个返回 JSON 的接口里面很可能直接包含文章标题、发布时间、评分甚至是正文片段。如果这个接口的 URL 是直接可访问的且请求头里不需要复杂签名直接用 requests 请求接口拿到 JSON会比解析 HTML 方便很多。我在这次实战里部分列表数据就是通过这类接口拿到的。5.2 用浏览器渲染兜底接口不可用时再考虑浏览器渲染。Playwright 是当前比较顺手的方案可以模拟真实浏览器加载页面。from playwright.sync_api import sync_playwright def render_page(url: str, wait_selector: str div.article-content) - str: with sync_playwright() as p: browser p.chromium.launch(headlessTrue) page browser.new_page() page.goto(url, timeout45000) page.wait_for_selector(wait_selector, timeout15000) html page.content() browser.close() return html用 Playwright 的代价是速度较慢资源占用高10 个页面可能就要几十秒。所以我通常只对接口方案确认不可行的页面段落做渲染而不是全量走浏览器。5.3 不要和不透明加密死磕如果数据接口带签名、token并且签名算法需要通过混淆 JS 才能推出来我建议你停下来算一笔账单页数据量不过十几 KB为破解签名浪费一个星期并不划算。更合理的做法是降低请求频率或者寻找站点的开放接口和其他公开数据源。爬虫的本质是锦上添花目的在于获取公开可读的信息。遇到隐晦的加密参数和严格的风控页面及时回头比纠缠下去更明智。6. 清洗与入库正文去噪、SQLite 主键和增量更新数据抓回来后功课才完成一半。直接从 get_text 拿到的正文里通常混着大量空白字符、广告锚文本和无意义节点。入库前必须过一遍清洗再决定怎么建表、怎么避免重复采集。6.1 清洗正文的空格、换行与隐藏标签清洗逻辑不复杂核心是三步去掉可怕的全角空格和零宽字符把连续空行压缩成单个去掉脚本和样式块。import re def clean_content(raw_text: str) - str: if not raw_text: return text re.sub(r[\u3000\xa0\u200b], , raw_text) text re.sub(rscript.*?/script, , text, flagsre.S) text re.sub(rstyle.*?/style, , text, flagsre.S) text re.sub(r\n{2,}, \n, text) return text.strip()清洗时有一个经验不要在提取文本之前做太激进的 HTML 清洗。因为表格解析可能还需要 HTML 结构我先把原始 HTML 存到 content_html再对用于分析的纯文本做处理两边都不耽误。6.2 统一入库到 SQLiteSQLite 是快速上手的首选单机爬虫不需要额外部署数据库服务。建表时以 url 为主键字段类型按第 1 节的最小字段表来。import sqlite3 import json def init_db(db_path: str): conn sqlite3.connect(db_path) conn.execute( CREATE TABLE IF NOT EXISTS reviews ( url TEXT PRIMARY KEY, title TEXT, published_at TEXT, overall_score REAL, sub_scores TEXT, content TEXT, content_html TEXT, params TEXT, created_at TEXT ) ) conn.commit() return conn def save_review(conn: sqlite3.Connection, review: dict): conn.execute( INSERT OR REPLACE INTO reviews (url, title, published_at, overall_score, sub_scores, content, content_html, params, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) , ( review[url], review[title], review[published_at], review[overall_score], json.dumps(review.get(sub_scores, {}), ensure_asciiFalse), review.get(content, ), review.get(content_html, ), json.dumps(review.get(params, {}), ensure_asciiFalse), review.get(created_at, ), )) conn.commit()created_at 我直接用本地时间字符串不做复杂时区处理。对单机爬虫来说够用就行。6.3 增量更新的主键和状态字段增量更新改写思路用 url 做唯一键每次重抓时 INSERT OR REPLACE 会把旧记录整行替换不会产生重复行。抓取失败的数据我会在内存里单独记一个 failed_urls 列表等到下一轮抓取再重试一次。如果希望记录抓取成功率可以额外增加一个 fetched_status 字段标 UNVISITED、SUCCESS、FAILED这样后续分析哪些车型页面经常加载失败时就有数据可查。7. 实测中值得记录的三个坑编码、伪元素和页面改版最后这部分是这次实战里让我印象最深的三件事。它们都不难解决但第一次遇到的时候非常容易被卡住。7.1 中文字符集动不动就乱码目标站主流页面是 UTF-8但个别老栏目或 CDN 节点会返回 GBK 编码。直接用 resp.text 时 requests 可能没有正确猜测编码导致标题乱码。正确做法是不轻易信任 resp.text改用 resp.content 手动指定编码解码。resp session.get(url, headersheaders, timeout15) html_bytes resp.content try: html html_bytes.decode(utf-8) except UnicodeDecodeError: html html_bytes.decode(gbk, errorsignore)如果你发现页面里英文和数字正常、中文全乱基本就是编码判错第一时间检查这里。7.2 CSS 伪元素缀在正文里这个坑比较隐蔽。目标站正文里某些标题使用 CSS 的 ::before 或 ::after 伪元素渲染前导字符比如车辆版本号、销量标识等。用 get_text 抓文本时这些伪元素内容不会被抓出来但页面显示上有反过来如果你从 outer HTML 里取字符串又会拿到大量不在视觉上出现的隐藏节点。解决办法是以 get_text 为主克制使用 outer HTML解析正文时只提取需要的子节点列表不要一股脑把整块 container 序列化。7.3 爬着爬着页面结构变了内容站三天两头改版不稀奇。我的脚本跑了两天列表页的卡片结构就从 review-card 变成了 article-card老选择器全部失效。靠人工去盯太被动我在脚本里加了一个“解析健康检查”每次解析详情页时如果标题为空或正文长度小于 50输出一条醒目的 WARNING并停止写库。这样一来改版问题不是要把整个程序跑完才知道而是第一页就能发现。如果让我重新写一遍这个爬虫我不会一开始就去优化请求并发或者代码抽象而是会先把告警机制和断点续抓搭好。解析类项目最大的敌人不是速度慢而是错误数据悄悄写进了库里浪费的是后面所有环节的时间。起步慢一点把基础打扎实后面反而省事。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →