☰
Python爬虫实战:汽车车型评测数据全流程抓取指南
2026/10/10 4:43:15 网站建设 项目流程

很多做数据分析的朋友问过我,第一个实战爬虫项目选什么好。我的建议一直很明确:选一个以文章为主、但又不只是文章的垂直内容站。车型评测就是非常典型的例子——它有标题、发布时间、作者、评分、长正文、参数表,一口气覆盖了列表页、详情页、结构化数据提取和文本清洗,练完这一套,你再去碰其他内容型网站会有种融会贯通的感觉。

我最近花了一个周末,写了一个针对某汽车垂直平台的车型评测爬虫,从列表页一路爬到详情页,把车型标题、分项评分、正文、参数表全部落进了 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/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.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, headers=headers, timeout=15) resp.raise_for_status() return resp.content except Exception as e: wait_seconds = 1.5 + attempt * 2 print(f"attempt {attempt+1} 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(strip=True), "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 = f"https://www.example-car.com/review/list/p{page}" html = fetch_html(list_url, session, referer="https://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(f"page {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", strip=True) if article else "" if len(content) < 50: # 内容太短,说明选择器失效,打印告警 print("WARNING: content seems too short, check selector") return { "title": title.get_text(strip=True) if title else "", "published_at": date_node.get_text(strip=True) 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(strip=True) 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 records

read_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(headless=True) page = browser.new_page() page.goto(url, timeout=45000) page.wait_for_selector(wait_selector, timeout=15000) 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(r"<script.*?</script>", "", text, flags=re.S) text = re.sub(r"<style.*?</style>", "", text, flags=re.S) text = re.sub(r"\n{2,}", "\n", text) return text.strip()

清洗时有一个经验:不要在提取文本之前做太激进的 HTML 清洗。因为表格解析可能还需要 HTML 结构,我先把原始 HTML 存到 content_html,再对用于分析的纯文本做处理,两边都不耽误。

6.2 统一入库到 SQLite

SQLite 是快速上手的首选,单机爬虫不需要额外部署数据库服务。建表时以 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_ascii=False), review.get("content", ""), review.get("content_html", ""), json.dumps(review.get("params", {}), ensure_ascii=False), 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, headers=headers, timeout=15) html_bytes = resp.content try: html = html_bytes.decode("utf-8") except UnicodeDecodeError: html = html_bytes.decode("gbk", errors="ignore")

如果你发现页面里英文和数字正常、中文全乱,基本就是编码判错,第一时间检查这里。

7.2 CSS 伪元素缀在正文里

这个坑比较隐蔽。目标站正文里某些标题使用 CSS 的 ::before 或 ::after 伪元素渲染前导字符,比如车辆版本号、销量标识等。用 get_text 抓文本时,这些伪元素内容不会被抓出来,但页面显示上有;反过来,如果你从 outer HTML 里取字符串,又会拿到大量不在视觉上出现的隐藏节点。解决办法是:以 get_text 为主,克制使用 outer HTML,解析正文时只提取需要的子节点列表,不要一股脑把整块 container 序列化。

7.3 爬着爬着页面结构变了

内容站三天两头改版不稀奇。我的脚本跑了两天,列表页的卡片结构就从 review-card 变成了 article-card,老选择器全部失效。靠人工去盯太被动,我在脚本里加了一个“解析健康检查”:每次解析详情页时,如果标题为空或正文长度小于 50,输出一条醒目的 WARNING,并停止写库。这样一来,改版问题不是要把整个程序跑完才知道,而是第一页就能发现。

如果让我重新写一遍这个爬虫,我不会一开始就去优化请求并发或者代码抽象,而是会先把告警机制和断点续抓搭好。解析类项目最大的敌人不是速度慢,而是错误数据悄悄写进了库里,浪费的是后面所有环节的时间。起步慢一点,把基础打扎实,后面反而省事。

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

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

立即咨询