做数据采集这行久了,会发现一个有意思的现象:真正难的从来不是"把网页内容抓下来",而是"抓下来之后它能不能用"。我最近在整理一个经典老片的信息库,数据源选的是某经典老片资源站(下文统一用"老片站"代称,避免具体指向),这个站点成立年头久、片目积累厚,从上世纪二三十年代的默片到九十年代的录像厅时代作品几乎都有收录,对做电影元数据整理的人来说是个难得的样本库。问题是它的页面结构停留在十几年前的技术审美上:table 布局、gb2312 编码声明、标题里塞满"【高清】""DVDRip"这类噪声,字段排布还常常前后不一致。我这次做的事情,就是围绕电影信息爬虫搭一套能长期跑的元数据采集流程:从列表页拿索引,进详情页抽字段,做数据清洗归一,落库之后支持增量抓取和检索导出。整套流程适合有 Python 基础、想练手真实站点解析的开发者,也适合做影视资料归档、片单管理、内容研究的同学参考——不需要你会分布式,一台普通笔记本就够跑完全程。
1. 项目整体设计与技术选型拆解
1.1 先把边界划清楚:只碰元信息,不碰资源文件
动手写第一行代码之前,我把这个项目的采集范围钉死在一个很窄的口子里:只要片名、年份、地区、类型、语言、时长、导演、主演、简介、海报地址、详情页 ID 这几项公开的文本与图片链接信息,站点上的资源文件、下载地址、播放地址一律不采集、不存储、不转发。这么做有三层考虑,且每一层都不是空话。
第一层是合规。影视元数据本身属于公开的事实性信息,但资源文件的传播涉及版权,两者性质完全不同。把采集边界限定在"信息"层面,项目性质就从"资源搬运"变成了"资料整理",这件事在自我定位和对外说明上都站得住。
第二层是稳定。资源地址这类字段往往是站点反爬监控最严的部分,采集它们会显著提高被封的概率;而列表页和详情页的文本字段是站点展示给所有访客看的,波动小、结构稳。
第三层是价值密度。真正的检索需求——"我想找 1987 年香港拍的、时长 90 分钟以上的犯罪片"——靠的是结构化字段,资源地址对这类需求毫无帮助。所以我在项目里明确写了一条规则:解析函数只返回白名单里的字段,其他一律丢弃。这条规则后来救了我很多次,因为老片站详情页里确实混着不少链接块,如果解析器是"抓到什么算什么"的思路,很容易在不知不觉中把不该存的东西带进库。
注意:动手前先看站点的 robots.txt 与页面底部的使用声明,把允许抓取的路径、禁止抓取的目录记下来。采集频率控制在一个人工浏览的正常水平,不要对站点造成额外负担,这是任何采集项目的前提。
1.2 技术栈选型:为什么是 requests + lxml + SQLite
选型这块我试过三种组合,最后落在requests + lxml + SQLite上,理由不是"流行",而是逐条对比出来的。
| 方案 | 优点 | 痛点 | 结论 |
|---|---|---|---|
| requests + BeautifulSoup(lxml 解析器) | 上手快,容错好,写起来像说话 | 大批量解析时速度一般;对畸形 HTML 的定位偶尔飘 | 适合原型期 |
| requests + lxml.etree + XPath | 解析快,XPath 表达力强,适合 table 布局 | XPath 写错时报错信息不友好 | 最终采用 |
| Scrapy 框架 | 自带调度、去重、管道,工程化程度高 | 对单站点、万级数据量属于重武器,调试链路长 | 后续扩展再上 |
数据量是决定性的。老片站的详情页大约在万级规模,单机串行加合理间隔就能在一天内跑完,这种量级上 Scrapy 的调度器、去重队列、中间件体系带来的复杂度超过了收益。而 lxml 的 XPath 在面对老式 table 布局时优势明显——老站点的结构特征往往是"第 3 行第 2 列是导演",用 XPath 的//tr[3]/td[2]比用 CSS 选择器去猜 class 名要可靠得多,因为这些站点压根没有语义化的 class。
存储选 SQLite 而不是 MySQL,考虑的是"零运维":单文件、无服务、Python 标准库直接支持,配合唯一索引做幂等写入非常顺手。真要到几十万条、需要多人并发查询的时候再迁库,代价也就是改一下连接串和建表语句。
1.3 站点结构勘察:四个必须提前观察的点
我见过太多人跳过勘察直接开写,结果解析函数改到第五版还在报 None。正式写代码前,我固定会看四件事,这四件事构成了后面所有解析逻辑的地基。
第一,列表页到详情页的链接规律。老片站的详情页 URL 里带一个自增数字 ID,形如/movie/12345.html。这个 ID 是整个项目的主键候选,比片名可靠得多——同名影片、系列片、翻拍片太多了,片名做唯一键必然出问题。
第二,分页的终止信号。要区分三种情况:页码超界返回空列表、返回 404、返回一个"没有更多"的提示页。老片站属于第一种,第 N 页之后返回的还是正常页面结构,只是条目区是空的。这意味着翻页循环必须靠"本页解析到的条目数为 0"来终止,而不能靠状态码。
第三,字段的呈现形式。有些字段是"标签 + 值"的两列结构(导演:某某),有些是纯文本段落里用空格分隔,还有的干脆只出现在简介里。我当时的做法是把所有详情页的关键区块先人工抽样 30 个页面,统计每个字段的出现率,最后得到一张字段可得性表,这直接决定了哪些字段可以设成 NOT NULL,哪些必须是可空。
第四,编码声明的真实情况。页面 head 里写的是gb2312,但实际内容里有大量生僻字、繁体字、异体字,用 gb2312 解码必炸。这个坑我在 2.2 节展开讲。
2. 页面结构与抓取策略的核心细节
2.1 两级抓取模型:列表页负责"发现",详情页负责"补全"
整个采集流程分成两级,这个结构不是刻意设计的,而是被数据现实逼出来的。列表页能提供的字段非常有限,通常只有片名、一个缩略图、也许还有个年份;真正的结构化信息——导演、主演、时长、语言——全部在详情页里。所以流程必然是:先遍历列表页收集详情页 URL 和 ID,形成待抓队列;再逐个请求详情页,抽取完整字段。
这里有个顺序上的取舍值得说。我一开始是"边翻列表边抓详情"的流式做法,看起来省内存,但实际跑起来问题不少:一旦详情页抓取阶段出现异常中断,你就不知道列表翻到哪了;而且列表页的请求和详情页的请求混在一起,限速逻辑也变得难控。后来改成两阶段——第一阶段只抓列表,把(id, detail_url, list_title)落到一张queue表里,标记状态为 pending;第二阶段专门消费这张表,抓完一条更新一条状态。这个改动带来的好处超出预期:断点续跑变成了一个简单的 SQL 查询,重跑失败条目也只需要WHERE status='failed'。
# 阶段一:列表页发现,只存 id 与详情 URL import re, sqlite3, requests LIST_TPL = "https://example-movie-site.test/list/{page}.html" DETAIL_RE = re.compile(r"/movie/(\d+)\.html") def discover(conn, max_page=200, session=None): session = session or requests.Session() page = 1 while page <= max_page: r = session.get(LIST_TPL.format(page=page), timeout=15) html = r.content.decode("gb18030", errors="replace") ids = DETAIL_RE.findall(html) if not ids: # 空页即终止,见 1.3 第二点 break rows = [(int(i), f"https://example-movie-site.test/movie/{i}.html") for i in dict.fromkeys(ids)] # dict.fromkeys 保序去重 conn.executemany( "INSERT OR IGNORE INTO queue(id, url, status) VALUES (?,?,'pending')", rows) conn.commit() page += 1代码里有三个细节值得单独拎出来。dict.fromkeys(ids)用来对同页内重复的 ID 去重并保持顺序,比set好,因为 set 会打乱顺序,日志读起来很别扭。INSERT OR IGNORE配合id上的主键,让重复发现变成幂等操作,重跑列表页不会产生脏数据。errors="replace"是刻意留的,宁可让个别生僻字变成替换符,也不要因为一个字符让整页解析崩掉——这条经验是用三个小时的重跑换来的。
2.2 老站点的编码陷阱:gb2312、gbk 与 gb18030 的先后关系
编码问题是这类老站点项目里最容易反复踩的坑,我把它单独拎出来讲透。页面的 meta 声明写charset=gb2312,但 gb2312 只覆盖了 6763 个常用汉字,而影片简介里出现"鏖""龘""峯"这类字是家常便饭。一旦遇到 gb2312 字符集外的字,严格解码会抛UnicodeDecodeError,而requests默认走response.text时,会依据 headers 里的 Content-Type 或 meta 声明去猜编码,猜错了就是满屏"锟斤拷"。
正确的处理顺序是这样:优先用 gb18030 解码。原因是 gb18030 是 gb2312 和 gbk 的超集,能覆盖的字符最多,用 gb18030 解码 gb2312 编码的页面不会出错,反过来则会。因此我看到charset=gb2312的站点,一律直接r.content.decode("gb18030", errors="replace"),跳过response.text这个会自作主张的封装。
def decode_page(resp): raw = resp.content m = re.search(rb'charset=["\']?([\w-]+)', raw[:2048], re.I) declared = m.group(1).decode("ascii").lower() if m else "" if declared in ("gb2312", "gbk", "gb18030"): return raw.decode("gb18030", errors="replace") if declared in ("utf-8", "utf8"): return raw.decode("utf-8", errors="replace") # 声明缺失或不可信时,按常见编码依次试解,取"替换符最少"的结果 best, best_bad = "", 10**9 for enc in ("utf-8", "gb18030", "big5"): try: s = raw.decode(enc, errors="replace") except Exception: continue bad = s.count("\ufffd") if bad < best_bad: best, best_bad = s, bad return best这个decode_page里的"替换符计数择优"逻辑,看起来有点土,但在声明缺失或声明错误的页面上效果出奇地好。原理很朴素:errors="replace"会把无法解码的字节替换成 U+FFFD,哪种编码解出来的替换符最少,哪种编码的假设就最接近真实。我自己在小样本上验证过,一百个页面的判定准确率在九成以上。
提示:不要用
response.apparent_encoding来处理这类页面。它基于字符频率统计,对短文本和含大量数字、符号的页面极不稳定,同一站点不同页面的判定结果可能来回摇摆,会让你的数据出现"一半乱码一半正常"的诡异现象。
2.3 翻页终止条件与"最后三页"的稳妥判定
翻页这件事,教科书上写"直到没有数据为止",实际做起来要考虑边界。老片站的列表页在超出范围后会返回一个结构完整但条目区为空的页面,同时页面底部的分页条仍然显示到第 N 页,只是所有页码都不能点了。这时候如果只看"有没有链接"来判断,会误判成还有下一页。
我的处理策略是双保险:主判据是"本页解析到的有效详情链接数为 0",辅判据是"连续两页新增 ID 数为 0"。后者的作用是应对站点偶发的空页——有些站点在分页参数异常时会返回一个空壳页面,但下一页又恢复正常;如果只用主判据,遇到这种情况就会提前终止,漏掉后面所有数据。
还有一个实用小技巧:把每次翻页的新增 ID 数记进日志。我跑的时候发现新增数会在某个页码之后断崖式下跌到个位数,那是因为列表页是按更新时间倒序排的,最后几页是老早以前的归档。看到这种情况就可以判断"主体数据已经抓完",剩下的尾部页面可以按需处理,不必强求。
2.4 解析器定位策略:XPath 兜底与多路径匹配
老站点的详情页结构不统一,同站不同时期的页面模板能差出三代。硬写一条 XPath 必然大面积失败。我的做法是给每个字段配一组候选路径,从上到下依次尝试,命中即停。
FIELD_XPATHS = { "title": ["//div[@class='intro']//h1/text()", "//title/text()"], "year": ["//td[contains(.,'年份')]/following-sibling::td[1]//text()"], "director": ["//td[contains(.,'导演')]/following-sibling::td[1]//text()"], "actors": ["//td[contains(.,'主演')]/following-sibling::td[1]//text()"], "runtime": ["//td[contains(.,'片长')]/following-sibling::td[1]//text()"], "region": ["//td[contains(.,'地区') or contains(.,'国家')]/following-sibling::td[1]//text()"], } def pick(tree, paths): for p in paths: got = tree.xpath(p) if got: text = " ".join(t.strip() for t in got if t and t.strip()) if text: return text return Nonecontains(.,'年份')这种写法比写死位置索引更耐改版,因为改版通常是调换行序、加样式,但"年份"这两个字不会变。后面那个pick函数返回第一个非空结果,把"字段缺失"和"字段为空字符串"统一成 None,下游清洗逻辑就只需要处理一种情况,省了很多 if。
3. 从页面到结构化字段:解析与清洗实现
3.1 字段设计与表结构:先定 schema 再写解析
这是我在这个项目里做的最正确的决定之一:先建表,再写解析。理由很直接,如果解析器是"想到什么抓什么",那库里最终会出现一堆半结构化文本,后面做检索时你会花更多时间去补救。定好 schema 之后,解析函数的目标就变成了"向上填表",每个字段的类型、是否可空、长度上限都清清楚楚。
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| movie_id | INTEGER | PK | 详情页 URL 中的数字 ID,全站唯一 |
| title | TEXT | NOT NULL | 清洗后的片名 |
| title_raw | TEXT | 可空 | 原始标题,保留用于回溯 |
| year | INTEGER | 可空 | 四位数年份,范围 1900-2035 内校验 |
| region | TEXT | 可空 | 地区/国家,多个用逗号分隔 |
| genres | TEXT | 可空 | 类型标签,逗号分隔 |
| language | TEXT | 可空 | 语言 |
| runtime_min | INTEGER | 可空 | 片长,统一换算为分钟 |
| director | TEXT | 可空 | 导演,多人逗号分隔 |
| actors | TEXT | 可空 | 主演,人数过多时截取前 8 位 |
| summary | TEXT | 可空 | 简介,去除 HTML 残留与多余空白 |
| poster_url | TEXT | 可空 | 海报绝对地址 |
| crawled_at | TEXT | NOT NULL | 抓取时间,ISO 格式 |
| updated_at | TEXT | NOT NULL | 最近一次更新该记录的时间 |
注意title_raw和title的分工。清洗规则肯定会随样本增加而调整,如果把原始值丢了,每次改规则都得重抓一遍页面,成本极高。留着原始值,清洗就变成了"重跑一遍内存计算",几十秒的事。这个设计后来被我复用了好几个项目,属于投入极小、回报极大的类型。
3.2 详情页解析实录:从 HTML 到一条记录
下面这段是核心解析函数,我做了简化但保留了真实项目里的关键结构:多路径兜底、字段归一、异常隔离。
from lxml import etree def parse_detail(html, movie_id): tree = etree.HTML(html) if tree is None: return None rec = {"movie_id": movie_id} rec["title_raw"] = pick(tree, FIELD_XPATHS["title"]) rec["year"] = normalize_year(pick(tree, FIELD_XPATHS["year"]), rec["title_raw"] or "") rec["region"] = normalize_split(pick(tree, FIELD_XPATHS["region"]), sep=r"[/、,]") rec["director"] = normalize_split(pick(tree, FIELD_XPATHS["director"]), sep=r"[/、,]") rec["actors"] = normalize_split(pick(tree, FIELD_XPATHS["actors"]), sep=r"[/、,]", limit=8) rec["runtime_min"] = normalize_runtime(pick(tree, FIELD_XPATHS["runtime"])) rec["summary"] = normalize_summary(pick(tree, ["//div[@class='intro']//p[1]//text()"])) poster = tree.xpath("//div[@class='pic']//img/@src") rec["poster_url"] = to_abs(poster[0]) if poster else None rec["title"] = normalize_title(rec["title_raw"] or "") return rec这里有个经验点:etree.HTML用默认解析器时容错很强,但没有做严格的 XML 校验,遇到嵌套错乱的 table 会自己"猜"结构。好处是几乎不会抛异常,坏处是猜错了你未必知道。所以我在解析完成后加了一个轻量校验——如果title为空但detail_url请求成功,就把这页的 HTML 存进failed_pages表,方便事后人工看一遍。这个机制帮我发现了两个模板分支,其中一个是针对电视电影的,字段排布和普通影片完全不同。
3.3 清洗规则:标题噪声、年份冲突与时长换算
标题清洗是提升最大的环节。老片站的标题长这样:【高清】某某某 (1987) [国英双语][DVDRip]。如果直接入库,一方面排序和读音检索全乱,另一方面同名判断会失败。我用的规则是有序的:
- 去掉成对的中文方括号
【...】、英文方括号[...]、圆括号里的技术参数(DVD、BD、HD、1080p、国英双语、中字这类关键词命中的); - 从剩下的文本里优先提取
(1987)或1987形式的四位年份,提取后从标题中剥离; - 去掉首尾的空白、全角空格、
·、-等分隔残留; - 全角标点转半角,繁简不做强制转换但记一个标记字段。
NOISE = re.compile(r"[\[【((](?:高清|DVD|BD|HD|1080p|720p|中字|国语|粤语|双语|无删减)[\]】))]", re.I) YEAR_IN_TITLE = re.compile(r"[((\[]?\s*(19\d{2}|20\d{2})\s*[))\]]?") def normalize_title(raw: str) -> str: s = NOISE.sub("", raw) s = YEAR_IN_TITLE.sub("", s) s = s.replace(" ", " ").strip(" -·—[]【】()()") return re.sub(r"\s{2,}", " ", s) def normalize_year(text_field, title_raw): for cand in (text_field, title_raw): if not cand: continue m = YEAR_IN_TITLE.search(cand) if m: y = int(m.group(1)) if 1900 <= y <= 2035: # 合理性校验,防止把片长/编号误当年份 return y return None年份这块必须做范围校验。我遇到过详情页把1234当成"上映年份"的情况,那是页面模板错位把编号填进了年份栏。加了1900-2035的区间约束后,这类脏数据直接变成 NULL,比错数据入库好得多——错数据会污染后续的统计,NULL 只是缺失。
时长换算看起来简单,实际有五种写法:98分钟、98 min、1小时38分、01:38:00、98。统一成分钟数的函数必须把这五种全覆盖,否则你会发现有 30% 的记录时长为空。
def normalize_runtime(text): if not text: return None s = text.strip().lower() m = re.search(r"(\d{1,2})\s*[::]\s*(\d{2})(?:\s*[::]\s*(\d{2}))?", s) if m: # 01:38:00 或 1:38 h, mi = int(m.group(1)), int(m.group(2)) return h * 60 + mi if h < 24 else None h = re.search(r"(\d+)\s*(?:小时|h)", s) mi = re.search(r"(\d+)\s*(?:分|min|m\b)", s) total = (int(h.group(1)) * 60 if h else 0) + (int(mi.group(1)) if mi else 0) if not total: m2 = re.fullmatch(r"(\d{2,3})", s) total = int(m2.group(1)) if m2 else 0 return total if 20 <= total <= 600 else None # 合理区间外一律置空最后那个20 <= total <= 600的区间检查是必须的。页面上有些"片长"字段填的是集数,30表示 30 集;有些填的是文件大小,700表示 700MB。这些值如果不拦,会把你后续的"平均片长"统计彻底带偏。
3.4 幂等入库:UPSERT 与唯一键的取舍
入库我用的INSERT ... ON CONFLICT DO UPDATE,因为同一部影片可能被抓多次(列表页多次发现、或重跑增量)。如果只用INSERT,你得先查后插,多一次查询;如果只用REPLACE,会把crawled_at覆盖掉,丢失"首次发现时间"这个有用信息。
CREATE TABLE IF NOT EXISTS movie ( movie_id INTEGER PRIMARY KEY, title TEXT NOT NULL, title_raw TEXT, year INTEGER, region TEXT, genres TEXT, language TEXT, runtime_min INTEGER, director TEXT, actors TEXT, summary TEXT, poster_url TEXT, crawled_at TEXT NOT NULL, updated_at TEXT NOT NULL ); INSERT INTO movie (movie_id, title, year, runtime_min, director, actors, summary, crawled_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?) ON CONFLICT(movie_id) DO UPDATE SET title=excluded.title, year=excluded.year, runtime_min=excluded.runtime_min, director=excluded.director, actors=excluded.actors, summary=excluded.summary, updated_at=excluded.updated_at WHERE excluded.title <> movie.title OR excluded.summary <> movie.summary;最后那个WHERE条件是个小优化:只有当关键字段真的变了才更新,否则跳过。这样updated_at就变成了"内容变更时间"而不是"最后访问时间",做增量比对的时候更有意义。
唯一键的选择我纠结过一阵。理论上title + year更符合业务直觉,但实际跑下来冲突太多:同名翻拍、系列片第 N 部、译名不统一。而movie_id是站点自己维护的自增 ID,指向明确、不会重名,用它做主键是最稳的。代价是如果站点重建了 URL 体系,ID 会失效——但那属于不可控的外部变化,不值得为它牺牲当前的稳定性。
4. 稳定性与可持续:限速、重试、增量与断点
4.1 请求节流:把参数算清楚再动手
限速这件事最忌讳拍脑袋。我的算法是这样:先测出单次请求的典型耗时(含网络往返和服务器处理),再叠加一个固定间隔,最后加一点随机抖动。对老片站实测,详情页的平均响应时间是 0.6 秒到 1.2 秒,取中位 0.8 秒。
设定目标:单机 QPS 不超过 0.5,也就是两次请求的起始时间间隔不小于 2 秒。那么等待时间 = 2.0 - 0.8 ≈ 1.2 秒,取 1.2 秒作为基准,再叠加 0 到 0.8 秒的随机抖动,实际间隔落在 1.2 到 2.0 秒之间,等效 QPS 在 0.5 到 0.83 之间波动,平均约 0.63。
按这个节奏估算总耗时:假设待抓详情页 3 万条,平均单条周期 = 响应 0.8s + 等待 1.6s ≈ 2.4 秒,3 万条约为 20 小时。列表页约 1000 页,额外约 40 分钟。这个时长完全可接受,可以分两个晚上跑完,中间断点续跑。这也说明为什么我不需要上多线程——单线程 20 小时能干完的活,用并发把它压到 2 小时,风险收益不划算,反而更容易触发站点的访问控制。
import random, time def polite_get(session, url, base=1.2, jitter=0.8, retries=3): for attempt in range(retries): try: r = session.get(url, timeout=15) if r.status_code == 200: time.sleep(base + random.uniform(0, jitter)) return r if r.status_code in (429, 503): # 明确的服务端限流信号 time.sleep((attempt + 1) * 5) # 退避,逐次加长 continue if r.status_code == 404: return None # 页面不存在,不必重试 except requests.RequestException: time.sleep((attempt + 1) * 3) return None这里的退避逻辑值得说明:遇到 429 或 503 不要立刻重试,因为那通常是服务端在告诉你"慢一点"。我用的策略是(attempt+1) * 5秒,第一次 5 秒、第二次 10 秒、第三次 15 秒。三次都失败就把这条记录标记为 failed,让队列走下一批,回头再统一重跑——而不是死磕一个页面。
4.2 状态机式的队列管理
queue表里我用了三种状态:pending(待抓)、done(成功)、failed(失败)。这个设计让整个采集过程变成可观测、可恢复的。
| 状态 | 含义 | 后续动作 |
|---|---|---|
| pending | 已发现未抓取 | 由采集器消费 |
| done | 已成功入库 | 不再处理,除非做全量刷新 |
| failed | 重试耗尽仍失败 | 定期拉出来重跑,或在 rules 更新后重跑 |
重跑失败条目的 SQL 就是一行:UPDATE queue SET status='pending' WHERE status='failed' AND tries < 5。我另外加了一个tries字段记录尝试次数,防止某个永久失效的页面被无限重试,白耗请求额度。
4.3 增量抓取的两种模式
增量在这个项目里有两个含义,必须分开处理,否则会做出很别扭的东西。
第一种是"发现增量":只抓新增的影片。做法是列表页按更新时间倒序,翻页时一旦遇到"本页所有 ID 都已在 movie 表中"就停止翻页。这个模式适合日常维护,每天跑一次,只花几分钟。
第二种是"内容增量":已有的影片信息可能被站点修订(补了简介、改了时长、加了主演)。这个不能靠列表页发现,得靠定期全量重爬,或者对updated_at较旧的记录做抽样重爬。我用的折中方案是"按年龄重爬":每次运行取updated_at距今最久的 2000 条,重爬一遍。轮转下来,全库每两周左右刷新一次,成本很低。
def next_batch(conn, limit=2000): return conn.execute(""" SELECT movie_id, url FROM queue WHERE status='pending' ORDER BY movie_id LIMIT ? """, (limit,)).fetchall() def refresh_batch(conn, limit=2000): return conn.execute(""" SELECT movie_id FROM movie ORDER BY updated_at ASC LIMIT ? """, (limit,)).fetchall()4.4 日志与可观测性:别让自己瞎跑
长篇任务最怕的是"跑了一晚上,早上发现全部失败"。所以我在三个位置写了日志:每个页面的请求结果(状态码、耗时)、每 100 条的成功/失败计数、以及每批结束时的汇总。日志格式我用的是可解析的键值对,方便事后 grep 统计,不用打开 Excel 慢慢数。
class Counter: def __init__(self): self.ok = self.fail = self.skip = 0 def tick(self, kind): setattr(self, kind, getattr(self, kind) + 1) if (self.ok + self.fail + self.skip) % 100 == 0: print(f"[STAT] ok={self.ok} fail={self.fail} skip={self.skip}")这个Counter很土但很管用。我跑第二轮的时候就是靠它发现"skip 数异常高",追下去才发现是清洗规则把某类标题全判成了空串,导致title为 NULL 被 NOT NULL 约束挡掉,走了跳过分支。如果没有计数器,这个问题要到下游分析时才暴露。
5. 常见问题与排查技巧实录
5.1 典型症状与对应处置
下面这张表是我这段时间攒下来的,按"症状 → 可能原因 → 处置"组织,基本覆盖了这类站点的 80% 问题。
| 症状 | 可能原因 | 处置 |
|---|---|---|
| 满屏"锟斤拷"或方块 | 用 gb2312 严格解码遇生僻字 | 改 gb18030 + errors="replace" |
| 部分页面标题正常,部分是乱码 | 站点存在多套模板,编码声明不一致 | 走 2.2 的替换符计数择优逻辑 |
| 解析结果大量 None,但页面看着有内容 | XPath 依赖位置索引,改版后偏移 | 改用 contains(.) 文本锚点定位 |
| 翻页到某页后条目全空,以为到底了 | 站点对高频访问返回空壳页 | 加"连续两页新增为 0"辅判据 |
| 片长字段出现 30、700 这类值 | 该栏被填了集数或文件大小 | 区间校验 20-600,越界置 NULL |
| 年份出现 1234 等离谱值 | 模板错位,编号填进年份栏 | 1900-2035 区间校验 |
| 同一部影片出现多条记录 | 用了片名做主键,译名不统一 | 改用详情页数字 ID 做主键 |
| 跑一晚上全部失败 | 中途被站点限流,重试未生效 | 检查退避逻辑,降低基准 QPS |
| 简介里混着 HTML 标签残片 | 简介字段本身含内联标签 | 用 etree 抽 text() 或做标签剥离 |
5.2 数据质量问题的排查顺序
数据跑完只是开始,真正花时间的是校验。我自己的排查顺序是固定的四步,从粗到细:
第一步,看字段空值率。统计每个字段的 NULL 占比。如果某个字段空值率超过 40%,八成是解析路径的问题,不是数据源没有。我当时发现language字段空值率 62%,回去看页面才发现语言信息是写在简介第一行的"对白语言:英语",只有部分影片有独立字段。这说明该字段需要"字段缺失时从简介里正则兜底"。
第二步,看取值范围。年份分布、片长分布、地区取值集合。地区这块特别容易出脏值,因为写法不统一:"香港"、"中国香港"、"HK"、"香港地区" 都可能出现。我建了一张映射表做归一,把常见别名收敛到标准值,映射表放在独立文件里,方便持续补充。
第三步,看重复。用title + year分组,看同名同年的记录条数。正常情况下应该只有极少数(确实存在同名同年不同片的情况)。如果某个组合有十几条,那多半是把系列片拆成了多条,或者简介里混入了其他片名。
第四步,抽样人工核对。随机抽 30 条,逐条对照页面。这一步没法自动化,但必要性极高——它能发现所有自动化规则都发现不了的"看起来对但其实错"的问题,比如简介抓到了预告片文案、主演抓到了客串演员列表。
5.3 几条踩出来的实操心得
心得一:先跑 200 条,别一上来就跑全量。全量跑完发现问题要重来,200 条跑完发现问题改一改再跑,成本差十倍。这 200 条要刻意挑不同类型——不同年代的、不同地区的、不同模板的,覆盖面比数量重要。
心得二:把清洗规则和解析规则分开写。解析规则只负责"从 HTML 里把原始文本抠出来",清洗规则负责"把原始文本变成规范值"。这两类规则的稳定性差异很大:解析规则受站点改版影响,清洗规则受样本多样性影响。分开写之后,改清洗规则不需要重新抓页面,直接用title_raw和summary重新计算就行。
心得三:给每条记录留一个source_snapshot字段。我在调试阶段会把详情页的关键区块 HTML 片段存进一个临时表,只保留最近 500 条。这个表在事后定位"为什么这条数据的导演是错的"时价值极高,因为你不用重新请求页面,直接看快照就知道当时解析的是哪段内容。
心得四:别迷信"一次跑通"。这类老站点总有你想不到的边角情况:某部影片的详情页是纯图片、某个年份的页面用的是老模板、某个演员名字里有分隔符导致切分错乱。我的做法是每轮跑完都留半小时专门看失败样本,把新的边角情况补进规则里。三轮之后,成功率从 78% 提到 96%,后面的提升就很缓慢了,剩下的 4% 基本是数据源本身缺失,不值得再投入。
心得五:控制好你的期望值。这类站点的元数据完整度天生参差,早年录入的片目很多只有片名和一张海报。如果目标是"每条记录都字段齐全",那注定失望。我的做法是分档:核心字段(片名、ID)必须全,重要字段(年份、导演、时长)目标 80%,辅助字段(语言、地区、简介)有多少算多少。把预期定在这个水平,项目的收尾会顺畅很多。
6. 数据落地之后的检索与再利用
6.1 用 SQL 做组合筛选
数据进库之后,最直接的用法就是写查询。相比在网页上一个个翻,SQL 组合筛选的效率高出一个量级。几个我常用的查询:
-- 找 1980-1989 年、片长 90 分钟以上、地区为香港的影片 SELECT title, year, runtime_min, director FROM movie WHERE year BETWEEN 1980 AND 1989 AND runtime_min >= 90 AND region LIKE '%香港%' ORDER BY year, title; -- 统计各年代影片数量,看片目分布 SELECT (year/10)*10 AS decade, COUNT(*) AS cnt FROM movie WHERE year IS NOT NULL GROUP BY decade ORDER BY decade; -- 找导演作品数最多的前十位(导演字段为逗号分隔,先做粗略统计) SELECT director, COUNT(*) AS cnt FROM movie WHERE director IS NOT NULL AND director <> '' GROUP BY director ORDER BY cnt DESC LIMIT 10;第一条查询对应的场景很典型:想按年代 + 时长 + 地区做片单筛选,这在手工浏览的界面上几乎不可能完成,而在库里就是三行 SQL。第二条的年代分布统计,能让你一眼看出这个站的片目重心在哪个十年——我跑出来发现 1985-1995 这个区间占了近四成,这也解释了为什么详情页里那个年代的字段最全。
6.2 导出与二次加工
库里的数据我一般导出成 CSV 和 JSON 两份。CSV 给表格工具用,JSON 给后续做静态站点或检索页面用。导出时注意两个细节:一是encoding='utf-8-sig',否则用表格软件打开中文会乱码;二是多值字段(演员、类型)用统一的逗号分隔,不要用分号或斜杠混着来,否则下游切分要写一堆兼容逻辑。
import csv, json, sqlite3 def export(conn, csv_path="movies.csv", json_path="movies.json"): cur = conn.execute("""SELECT movie_id,title,year,region,genres,language, runtime_min,director,actors,summary,poster_url FROM movie WHERE title IS NOT NULL ORDER BY movie_id""") cols = [d[0] for d in cur.description] rows = cur.fetchall() with open(csv_path, "w", newline="", encoding="utf-8-sig") as f: w = csv.writer(f); w.writerow(cols); w.writerows(rows) with open(json_path, "w", encoding="utf-8") as f: json.dump([dict(zip(cols, r)) for r in rows], f, ensure_ascii=False, indent=1)6.3 后续可以扩展的方向
这套流程跑顺之后,我顺手做了两件事。第一件是给影片加了"系列归并"——把片名里数字后缀、罗马数字、副标题剥掉,把同系列的片子归到一组,这样查"某个系列"时能一次看全。第二件是做了一个极简的本地检索页,用 SQLite 全文索引对片名和简介建虚拟表,浏览器打开就能按关键词搜,速度比在源站搜索快得多,也不受源站搜索功能的限制。
如果要把这套东西再往前推一步,我会补上三块:一是把导演和演员拆成独立的关系表,做成"人-片"双向关联,这样才能查"某导演的全部作品"而不只是数个数;二是给清洗规则加一份可回归的测试样本集,每次改规则跑一遍,确认没有把已经修好的数据改坏;三是把采集节奏做成定时任务,每天固定时间跑一次发现增量,两周做一次内容刷新,让数据保持"半新鲜"的状态。
最后分享一个我在这个项目里体会最深的小点:数据采集的价值,八成不在"抓到多少",而在"抓完之后你敢不敢用"。我前期的库有八千多条记录,但因为字段脏、年份乱、片名一堆噪声,实际能用来做筛选的可能不到一半。后来把清洗和校验的口子收紧,记录数虽然没涨,但每一条都能直接拿去用,这个转变带来的收益,比多抓两千条要大得多。所以如果你也在做类似的整理,宁可慢一点、窄一点,也要让每条落库的数据都经得起翻看。