ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

老片索引站电影元数据采集:Python爬虫管道与数据清洗

老片索引站电影元数据采集:Python爬虫管道与数据清洗

先说一句实在话:电影信息采集这件事,技术难度并不高,难的是把边界划清楚、把数据洗干净。标题里提到的那类老片索引站,页面还停留在十几年前的 table 布局,HTML 结构松、编码乱、字段缺,正好是练手爬虫的典型样本。我做这个项目的初衷很朴素——想给自己攒一份可检索的本地片库目录,按年份、地区、类型、导演去筛,而不是每次翻十几页网页。它解决的问题是“信息分散、无法结构化查询、重复条目多”,适合已经会写几行 Python、想系统练一遍采集管道的人参考。下文统一把目标站称作“目标站”,不点名,只讲方法。整个过程只采集公开的影片元信息,也就是片名、年份、地区、类型、语言、片长、导演、主演、评分这类描述性字段,不碰任何资源文件。

1. 先把边界定清楚:这个爬虫到底抓什么、不抓什么

1.1 只做元数据,不碰资源分发

很多人一看到“下载站”三个字,第一反应就是去扒下载链接。我的建议是反过来:把下载链接、磁力串、网盘提取码这类字段从数据模型里直接删掉。原因有两层。技术层面,这些字段往往藏在跳转页、验证页或者 JS 动态渲染里,抓取成本高、稳定性差,一旦目标站改版,整条链路就断。非技术层面,元数据属于描述性信息,采集和整理的风险低;而资源分发链路涉及版权,不该由个人项目去触碰。

所以我在写第一行代码之前,先列了一张“抓取白名单”,字段只允许出现在这张表里的内容入库:

字段类别具体字段是否采集说明
基础标识站内 ID、详情页 URL是用于去重和回溯
片名中文名、外文名、别名是别名常缺失,允许为空
时间上映年份、精确日期年份必采,日期选采老片常只有年份
属性地区、类型、语言、片长是类型可能有多个,用分隔符拼接
人员导演、主演是只取前若干位,避免长列表
评价站内评分是注意这是站内分,不是权威分
资源下载链接、提取码、种子否明确排除
正文剧情简介是,但截断只留前若干字,避免长文本

这张表还有个隐藏作用:它就是我后面写 XPath 的清单。字段先定死,解析函数才不会写着写着就膨胀成一个什么都往里塞的字典。

注意:即使只抓元数据,也要先看一眼目标站的 robots 协议和页面上的使用声明。协议里明确禁止的目录,直接跳过,不要抱着“反正没人管”的心态。这是长期做采集的基本素养。

1.2 为什么值得做一个“老站”样本

有人会问,现在都讲究接口化,为什么还要啃老站?因为老站的信息组织方式恰好是最考验解析功底的:没有 JSON 接口,没有语义化标签,字段全靠 div 和 table 堆出来,标题和正文混排,同一类信息在不同页面里位置还会漂移。把这种站啃下来,再去处理结构规范的现代站点,基本是降维打击。

我给自己定的第一个里程碑不是“抓完一万条”,而是“稳定抓完 50 条且字段完整度 90% 以上”。这个目标很小,但它逼着我把编码、空值、字段错位这三个最烦人的问题在早期就解决掉,而不是等到全量跑完才发现一半数据是脏的。

2. 目标站的结构拆解与选型判断

2.1 三类页面的信息层级关系

老片索引站的页面结构基本是三层漏斗:分类列表页 → 分页列表页 → 详情页。分类页给出“按地区”“按类型”“按年份”的入口,分页列表页每页放十几到几十条摘要,每条摘要包含片名、年份、地区和一个指向详情页的链接,详情页才是字段最全的地方。

理解这个层级的价值在于决定“抓哪一层”。如果只抓列表页,字段太少,年份可能只有部分条目有;如果直接抓详情页,又缺少列表页提供的分类归属信息。我的做法是两层都抓、以详情页为主:列表页负责发现 URL 和补充分类标签,详情页负责全字段解析。这样即使详情页某个字段缺失,还能用列表页的信息兜底。

# 三层结构的抽象,别一上来就写死 URL LEVELS = { "index": "/", "list": "/list/{category}/{page}.html", "detail": "/movie/{movie_id}.html", }

把路径模板抽出来是因为老站的分页规则常常不统一,有的分类是list-1.html,有的是list_1_2.html。模板化之后,每类页面维护一份模板,出问题只需要改一行。

2.2 为什么用 requests + lxml,而不是浏览器自动化

技术选型上我考虑过三个方案:纯 HTTP 请求加解析库、浏览器自动化、以及现成的采集框架。

方案优点缺点本项目是否采用
requests + lxml轻量、快、资源占用低无法执行 JS采用
浏览器自动化能处理动态渲染内存占用大、速度慢十倍以上不采用
通用采集框架配置化、功能够用学习成本高、调试不直观不采用

决定性因素是目标站几乎不依赖 JS 渲染,页面上看到的字段在 HTML 源码里全都有。既然不需要执行脚本,浏览器自动化就是纯浪费——一个页面渲染要一两秒,纯请求只要零点几秒,几千个页面的差距就是几个小时。而通用框架的问题在于调试成本:出问题时你不知道是选择器写错了、中间件拦了,还是管道丢数据了,查起来比自己写的循环更费劲。

lxml 而不是 BeautifulSoup,主要是速度和 XPath 的表达力。XPath 可以直接写“取 class 包含 info 的 div 下的第二个 a 标签的 href”,这种带位置和条件的定位,在字段位置固定的老站里特别好用。

2.3 编码问题:这类站点的第一个坑

老站的编码是绕不开的。服务器返回的头里可能写着一种编码,页面 meta 标签里写着另一种,实际文件又是第三种。我遇到最多的情况是声明为 UTF-8、实际为 GBK,或者反过来。

处理顺序我固定成三步:先看响应头的 charset,再看页面 meta,最后用通用检测兜底。但最稳的做法是:声明优先,检测兜底,解码异常时不要静默跳过。

import requests from lxml import html def fetch(url, session): resp = session.get(url, timeout=(3.05, 10)) resp.raise_for_status() # 优先用响应头声明的编码,其次用 requests 的推断 encoding = resp.encoding or resp.apparent_encoding if encoding and encoding.lower() in ("iso-8859-1", "ascii"): # 这两种通常是服务端没声明,不可信 encoding = resp.apparent_encoding or "gbk" try: text = resp.content.decode(encoding, errors="strict") except UnicodeDecodeError: text = resp.content.decode("gb18030", errors="ignore") return html.fromstring(text)

关键点在于errors="strict"先试一次。很多人图省事直接errors="ignore",结果是乱码字符被默默吞掉,抓了三千条才发现部分片名少了两个字,而且无从定位。宁可让它报错,报错了再去处理那几个异常页面。

3. 从零搭一条能跑的数据采集管道

3.1 目录结构与配置分离

项目不大,但结构还是要分开,否则改一个选择器要翻半天文件。我的目录是这样的:

movie_spider/ ├── config.py # URL 模板、选择器、限速参数 ├── fetcher.py # 请求层:会话、重试、限速 ├── parser.py # 解析层:XPath、字段清洗 ├── storage.py # 存储层:SQLite 建表与写入 └── run.py # 调度入口

选择器全部集中在config.py,用字典管理。这样目标站一改版,我只需要对着浏览器开发者工具重新改这几行,而不用在业务逻辑里大海捞针。

SELECTORS = { "list_item": '//div[contains(@class,"list")]//li', "item_href": './a/@href', "item_title": './a/text()', "detail_title": '//div[contains(@class,"info")]//h1/text()', "detail_meta": '//div[contains(@class,"info")]//li/text()', "detail_intro": '//div[contains(@class,"intro")]//text()', }

用contains(@class, ...)而不是@class="...",是因为老站的 class 里常带额外空格或者多个类名,精确匹配很容易失效。这是个很小但省事的习惯。

3.2 请求层:会话复用、超时、重试、限速

请求层是整条管道的稳定器,四个要素缺一不可:会话复用、合理超时、指数退避重试、主动限速。

会话复用是为了复用 TCP 连接和 Cookie,减少握手开销。超时我设成连接 3.05 秒、读取 10 秒——这个数字不是随手写的,3.05 是很多系统里 TCP 重传窗口的经典值,比整数 3 秒更容易避免边界抖动;读取 10 秒是给服务器返回大页面的余量,老站图片多、页面重,设太短会频繁超时。

import time, random, requests from requests.adapters import HTTPAdapter class Fetcher: def __init__(self, min_interval=1.2, max_retry=4): self.session = requests.Session() self.session.headers.update({ "User-Agent": "Mozilla/5.0 (compatible; MetaCollector/1.0)", "Accept-Language": "zh-CN,zh;q=0.9", }) adapter = HTTPAdapter(pool_connections=4, pool_maxsize=4) self.session.mount("http://", adapter) self.session.mount("https://", adapter) self.min_interval = min_interval self.max_retry = max_retry self._last = 0.0 def _throttle(self): elapsed = time.monotonic() - self._last wait = self.min_interval - elapsed + random.uniform(0, 0.4) if wait > 0: time.sleep(wait) def get(self, url): for attempt in range(self.max_retry): self._throttle() try: resp = self.session.get(url, timeout=(3.05, 10)) self._last = time.monotonic() if resp.status_code == 200: return resp if resp.status_code in (429, 503): time.sleep(2 ** attempt + random.uniform(0, 1)) continue resp.raise_for_status() except requests.RequestException: self._last = time.monotonic() time.sleep(2 ** attempt + random.uniform(0, 1)) return None

注意里面那个random.uniform(0, 0.4)。固定间隔的请求在服务器日志里是一条极其规律的直线,很容易被识别;加一点随机抖动,行为特征更接近正常访问,也让限速不至于卡得太死。这不是为了“绕过检测”,而是采集端应有的礼貌——别让单个客户端把别人的带宽吃满。

3.3 解析层:字段提取与容错设计

解析层最容易犯的错,是假设“字段一定存在”。老站里一部 1978 年的片子可能没有外文名,可能没有评分,类型可能只写了“剧情”两个字。我的原则是每个字段一个独立的提取函数,返回空字符串而不是抛异常,最后统一在清洗阶段决定怎么补。

def parse_detail(tree): data = {} titles = tree.xpath(SELECTORS["detail_title"]) data["title_cn"] = titles[0].strip() if titles else "" meta_lines = [t.strip() for t in tree.xpath(SELECTORS["detail_meta"]) if t.strip()] meta = {} for line in meta_lines: # 形如 "导演:某某某" 的键值对 for sep in (":", ":"): if sep in line: k, _, v = line.partition(sep) meta[k.strip()] = v.strip() break data["director"] = meta.get("导演", "") data["region"] = meta.get("地区", "") data["year_raw"] = meta.get("年份", meta.get("上映", "")) intro = tree.xpath(SELECTORS["detail_intro"]) data["intro"] = " ".join(s.strip() for s in intro if s.strip())[:300] return data

这里的partition加多重分隔符判断,是因为老站同一个站内不同区域的冒号可能一半中文一半英文。用字典累积而不是按固定顺序取下标,是因为字段行序不稳定——今天“导演”在第一行,明天可能跑到第三行。按“键”找值,比按“位置”找值抗改版能力强得多。

3.4 数据模型:字段设计与类型约定

存储之前要先想清楚字段类型,否则后面查询会很难受。我的约定是:能被当作数字用的字段就存数字,实在没法解析的再存原文并单独保留一个原始字段。

字段名类型约束示例值与说明
source_idTEXT唯一从 URL 抽取的站内编号
title_cnTEXT非空中文片名,去首尾空白
title_otherTEXT可空外文名,缺失存空串
yearINTEGER可空只保留四位数字
regionTEXT可空多地区用/分隔
genreTEXT可空多类型用/分隔
languageTEXT可空原文语言
runtimeINTEGER可空统一为分钟
directorTEXT可空多人用/分隔
castTEXT可空只取前 5 位
ratingREAL可空站内评分,保留一位小数
introTEXT可空截断至 300 字
detail_urlTEXT非空完整地址
crawled_atTEXT非空ISO 格式抓取时间

年份必须是整数,这一点特别重要。存成文本的话,你在查询时写year > 1980会得到什么结果取决于数据库的类型推断,可能把"1979"当成字符串比较,结果完全错乱。我在清洗阶段强制转换,转换失败就置空,绝不留半吊子数据。

4. 关键参数怎么算:限速、并发与抓取时间估算

4.1 抓取总耗时的量化估算

参数不能拍脑袋定,算一遍心里才有底。假设目标站点共有 6000 部影片的详情页,加上分类列表页约 400 页,总请求数约 6400 次。

单请求的平均耗时拆解:DNS 复用后接近 0,TCP 复用后约 20 毫秒,服务器处理加传输约 300 毫秒,合计约 0.33 秒。再加上我设的 1.2 秒最小间隔和平均 0.2 秒抖动,单线程的实际节奏是每个请求约 1.73 秒。6400 次请求的理论耗时是 6400 × 1.73 秒 ≈ 11072 秒,也就是大约 3.1 小时。

这个数字是可以接受的,因为它跟“把对方服务器打瘫”完全不在一个量级上——折算下来不到 0.6 个请求每秒,远低于一个有正常访问量的站点自身承受的量级。如果我为了压缩到半小时而把间隔调到 0.1 秒,那才会给目标站带来明显压力,也更容易被限流,最后反而要花更多时间处理重试。

场景单请求间隔理论总耗时风险评估
保守2.0 秒约 4.1 小时极低
默认1.2 秒约 3.1 小时低
激进0.3 秒约 0.9 小时高,易触发限流
多线程 4 并发 + 0.3 秒—约 15 分钟很高,不建议

结论很清楚:单线程加上 1.2 秒间隔,是这个项目的最优区间。多线程在这里几乎没有收益,因为瓶颈是我的主动限速,不是我本地的处理能力。这一点很多新手会想反——觉得并发越高越好,实际上在“礼貌采集”的前提下,并发是纯粹的负收益。

4.2 重试参数与退避策略

重试次数不是越多越好。4 次是我实测下来的平衡点:按 1、2、4、8 秒退避,总等待约 15 秒。如果一个页面等 15 秒还拿不到,大概率不是偶发抖动,而是链接失效、页面删除或者权限变动,再重试也是白等,不如记下来丢进失败队列。

# 失败队列单独落盘,方便事后单独补抓 import json def record_failure(url, reason, path="failed.jsonl"): with open(path, "a", encoding="utf-8") as f: f.write(json.dumps({"url": url, "reason": reason}, ensure_ascii=False) + "\n")

把失败记录下来这件事,看起来不起眼,但它是判断“是偶发问题还是系统性问题”的关键。如果失败队列里全是同一个路径前缀,那就是选择器或者路径模板写错了;如果失败的是零散 URL,那多半天生如此,不用管。没有这份记录,你会在全量跑完之后对着一堆空字段干瞪眼。

5. 常见问题与排查实录

5.1 乱码、空值与字段错位的区分

这三类问题表现相似——都是“数据看起来不对”——但根因完全不同,排查方向也完全不同。

乱码的特征是出现了不该出现的字符,比如片名里混进了奇怪的符号。这类问题回到编码环节,检查响应头、meta 和实际解码是否一致。空值的特征是字段整列都是空,先确认选择器能不能在单个页面上命中,再去检查是不是整批页面结构变了。字段错位最隐蔽,比如“导演”那一列填的其实是“主演”,这通常是解析时按键取值失败、退化成按位置取值导致的。

我的排查顺序固定为:先抽一个已知正确的页面单测解析函数 → 再抽十个页面看命中率 → 最后跑全量并统计字段完整度。跳过前两步直接跑全量,是最常见的浪费时间的做法。

5.2 被限流和返回异常页的处理

跑到中途突然开始大面积失败,状态码 429 或 503,或者返回一个内容明显不对的页面,这是限流信号。这时候正确的做法是立刻降速并暂停,而不是换 UA 硬冲。硬冲的结果是 IP 被拉进黑名单,接下来几天都别想跑。

我的处理流程是:检测到连续 N 次非 200 响应就触发熔断,sleep 一段较长的时间(比如 10 分钟),然后把间隔翻倍重跑。翻倍这一步很重要,说明之前的速率对目标站来说偏高了,得往回退。

def run_with_backoff(urls, fetcher): interval = fetcher.min_interval while urls: ok, failed = [], [] consec_error = 0 for u in urls: resp = fetcher.get(u) if resp is None: failed.append(u) consec_error += 1 if consec_error >= 5: time.sleep(600) interval *= 2 fetcher.min_interval = interval consec_error = 0 break else: consec_error = 0 ok.append(u) urls = failed return ok

5.3 分页丢页与详情页漏抓

分页丢页是另一个高频问题。老站的分页有时不规整:第一页是list.html,第二页突然变成list_2.html,最后一页的链接可能是“下一页”按钮失效。我的做法是不依赖“下一页”按钮,而是先解析出总页数,然后用模板直接构造每一页的 URL。这样即使某一页的导航坏了,也不会导致后面所有页全部丢失。

详情页漏抓通常发生在列表页解析阶段。列表项的链接可能是一个相对路径、一个带参数的跳转链接,甚至是一个javascript:伪链接。统一处理方式是:只保留以/开头或以站点域名开头的链接,其余全部丢弃并记录,不要试图去还原那些伪链接。

现象可能原因排查动作处理办法
整列字段为空选择器失效单页跑一次解析更新 XPath
部分页面乱码编码不一致打印响应头与 meta按内容实际编码解码
抓取中断后大面积失败触发限流看状态码分布熔断、降速、翻倍间隔
分页只抓到第一页依赖下一页按钮检查总页数解析改为模板构造 URL
数据量比预期少一半列表项选择器漏匹配统计每页条目数放宽 class 匹配条件
同一部片子出现多条详情页有多个入口检查 source_id 唯一性加唯一索引 + 合并策略

提示:表格里最后一条“重复条目”在老站里非常普遍。同一部影片可能在“按地区”和“按类型”两个分类下各有一个详情页,URL 不同但内容一致。解决靠的是主键设计,而不是靠肉眼去重。

6. 数据落地之后:清洗、去重与可用性校验

6.1 片名与年份的归一化

抓下来的原始数据不能直接用。片名里常带各种修饰:全角括号、方括号里的别名、末尾的“HD”“修复版”之类的后缀。这些东西对检索是干扰。我的归一化规则是:去掉成对的括号及其内容、去掉首尾的标点和空白、把全角字符转半角。

年份的归一化更严格:从原始字符串里正则提取第一个四位数字,范围限定在 1900 到当前年份加 2 之间。超出范围的一律置空。这一步能过滤掉大量把“片长 120 分钟”误解析成年份的脏数据——这是我在第一版里真实踩到的坑,导出一看有一批片子“上映于 120 年”。

import re def normalize_year(raw: str): if not raw: return None m = re.search(r"(18|19|20)\d{2}", raw) if not m: return None y = int(m.group(0)) return y if 1900 <= y <= 2100 else None def normalize_title(raw: str): s = re.sub(r"[((【\[].*?[))】\]]", "", raw) s = s.strip(" -—·::") return s

6.2 唯一键设计与重复合并

去重的核心是唯一键。用详情页 URL 做唯一键只能解决“同一个页面抓两次”的问题,解决不了“同一部影片有两个页面”的问题。真正的做法是用“归一化片名 + 年份”做一个业务唯一键,写库时用INSERT OR IGNORE加唯一索引,冲突的记录进一张待人工确认的表。

CREATE TABLE IF NOT EXISTS movie ( id INTEGER PRIMARY KEY AUTOINCREMENT, source_id TEXT UNIQUE, title_cn TEXT NOT NULL, title_other TEXT DEFAULT '', year INTEGER, region TEXT DEFAULT '', genre TEXT DEFAULT '', language TEXT DEFAULT '', runtime INTEGER, director TEXT DEFAULT '', cast TEXT DEFAULT '', rating REAL, intro TEXT DEFAULT '', detail_url TEXT NOT NULL, crawled_at TEXT NOT NULL ); CREATE UNIQUE INDEX IF NOT EXISTS idx_title_year ON movie(title_cn, IFNULL(year, 0)); CREATE INDEX IF NOT EXISTS idx_year ON movie(year); CREATE INDEX IF NOT EXISTS idx_region ON movie(region);

这里有个细节:year允许为空,但唯一索引里用了IFNULL(year, 0)。原因是 SQL 里 NULL 不等于 NULL,如果直接用(title_cn, year)建唯一索引,年份缺失的记录永远不会冲突,重复能一路塞进去。用IFNULL归到一个具体值,才能真正拦住重复。

6.3 数据质量抽检

全量跑完之后,我会做三项抽检,每项抽 20 条人工核对:

第一项是完整度,统计每个字段的非空率。片名和链接应该接近 100%,年份和地区在 80% 以上算正常,评分和片长因为老站本身缺失较多,60% 也能接受。低于这些阈值就说明解析有问题。

第二项是分布合理性。把年份按十年分段统计,正常的老片库应该呈现中间高两边低的分布。如果 1990 年代突然是空的,那基本可以确定那一批页面解析全废了。

第三项是交叉验证。随机抽 20 条,去搜索引擎上核对片名和年份是否匹配。这一步最花时间,但最能发现“字段错位”这类隐蔽问题——数据看起来完整,其实导演填的是主演。

7. 我在这个项目里踩过的坑

第一个坑是过早优化。第一版我上来就写了多线程加连接池,跑到一半被限流,调试时还得考虑线程安全问题,成本翻了好几倍。后来推倒重写成单线程,反而一次跑通。这件事让我记住一个顺序:先让它在最慢的配置下跑通,再考虑提效。

第二个坑是把统计口径当成了抓取问题。有一批片子年份总是空,我查了半天解析逻辑,最后发现是列表页本身就没有年份,得从详情页的简介文本里抽。经验是:当某个字段大面积缺失时,先确认数据源里到底有没有这个信息,别急着怀疑代码。

第三个坑是日志写得太少。最初只记成功数,出问题时完全不知道错在哪一步。后来改成每个请求记一行:URL、状态码、耗时、解析出的字段数。这几行日志排查效率提升非常明显,尤其是“字段数突然从 9 降到 2”这种信号,一眼就能看出页面结构变了。

最后分享一个我觉得挺有用的小技巧:给解析函数加一个“字段命中数”的返回。也就是每次解析完顺便统计一下这次解析出了几个非空字段,然后把这个数字写进日志。正常页面大概稳定在 8 到 10 之间,一旦哪天开始出现大量 3、4,就说明目标站改版了——这相当于给自己的采集管道装了个免费的监控告警,比事后翻数据发现问题要早得多。

RELATED READING

延伸阅读

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