做数据采集这行久了会发现一个有意思的现象真正难的从来不是把网页内容抓下来而是抓下来之后它能不能用。我最近在整理一个经典老片的信息库数据源选的是某经典老片资源站下文统一用老片站代称避免具体指向这个站点成立年头久、片目积累厚从上世纪二三十年代的默片到九十年代的录像厅时代作品几乎都有收录对做电影元数据整理的人来说是个难得的样本库。问题是它的页面结构停留在十几年前的技术审美上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 statusfailed。# 阶段一列表页发现只存 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_page200, sessionNone): session session or requests.Session() page 1 while page max_page: r session.get(LIST_TPL.format(pagepage), timeout15) html r.content.decode(gb18030, errorsreplace) ids DETAIL_RE.findall(html) if not ids: # 空页即终止见 1.3 第二点 break rows [(int(i), fhttps://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上的主键让重复发现变成幂等操作重跑列表页不会产生脏数据。errorsreplace是刻意留的宁可让个别生僻字变成替换符也不要因为一个字符让整页解析崩掉——这条经验是用三个小时的重跑换来的。2.2 老站点的编码陷阱gb2312、gbk 与 gb18030 的先后关系编码问题是这类老站点项目里最容易反复踩的坑我把它单独拎出来讲透。页面的 meta 声明写charsetgb2312但 gb2312 只覆盖了 6763 个常用汉字而影片简介里出现鏖龘峯这类字是家常便饭。一旦遇到 gb2312 字符集外的字严格解码会抛UnicodeDecodeError而requests默认走response.text时会依据 headers 里的 Content-Type 或 meta 声明去猜编码猜错了就是满屏锟斤拷。正确的处理顺序是这样优先用 gb18030 解码。原因是 gb18030 是 gb2312 和 gbk 的超集能覆盖的字符最多用 gb18030 解码 gb2312 编码的页面不会出错反过来则会。因此我看到charsetgb2312的站点一律直接r.content.decode(gb18030, errorsreplace)跳过response.text这个会自作主张的封装。def decode_page(resp): raw resp.content m re.search(rbcharset[\]?([\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, errorsreplace) if declared in (utf-8, utf8): return raw.decode(utf-8, errorsreplace) # 声明缺失或不可信时按常见编码依次试解取替换符最少的结果 best, best_bad , 10**9 for enc in (utf-8, gb18030, big5): try: s raw.decode(enc, errorsreplace) except Exception: continue bad s.count(\ufffd) if bad best_bad: best, best_bad s, bad return best这个decode_page里的替换符计数择优逻辑看起来有点土但在声明缺失或声明错误的页面上效果出奇地好。原理很朴素errorsreplace会把无法解码的字节替换成 UFFFD哪种编码解出来的替换符最少哪种编码的假设就最接近真实。我自己在小样本上验证过一百个页面的判定准确率在九成以上。提示不要用response.apparent_encoding来处理这类页面。它基于字符频率统计对短文本和含大量数字、符号的页面极不稳定同一站点不同页面的判定结果可能来回摇摆会让你的数据出现一半乱码一半正常的诡异现象。2.3 翻页终止条件与最后三页的稳妥判定翻页这件事教科书上写直到没有数据为止实际做起来要考虑边界。老片站的列表页在超出范围后会返回一个结构完整但条目区为空的页面同时页面底部的分页条仍然显示到第 N 页只是所有页码都不能点了。这时候如果只看有没有链接来判断会误判成还有下一页。我的处理策略是双保险主判据是本页解析到的有效详情链接数为 0辅判据是连续两页新增 ID 数为 0。后者的作用是应对站点偶发的空页——有些站点在分页参数异常时会返回一个空壳页面但下一页又恢复正常如果只用主判据遇到这种情况就会提前终止漏掉后面所有数据。还有一个实用小技巧把每次翻页的新增 ID 数记进日志。我跑的时候发现新增数会在某个页码之后断崖式下跌到个位数那是因为列表页是按更新时间倒序排的最后几页是老早以前的归档。看到这种情况就可以判断主体数据已经抓完剩下的尾部页面可以按需处理不必强求。2.4 解析器定位策略XPath 兜底与多路径匹配老站点的详情页结构不统一同站不同时期的页面模板能差出三代。硬写一条 XPath 必然大面积失败。我的做法是给每个字段配一组候选路径从上到下依次尝试命中即停。FIELD_XPATHS { title: [//div[classintro]//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_idINTEGERPK详情页 URL 中的数字 ID全站唯一titleTEXTNOT NULL清洗后的片名title_rawTEXT可空原始标题保留用于回溯yearINTEGER可空四位数年份范围 1900-2035 内校验regionTEXT可空地区/国家多个用逗号分隔genresTEXT可空类型标签逗号分隔languageTEXT可空语言runtime_minINTEGER可空片长统一换算为分钟directorTEXT可空导演多人逗号分隔actorsTEXT可空主演人数过多时截取前 8 位summaryTEXT可空简介去除 HTML 残留与多余空白poster_urlTEXT可空海报绝对地址crawled_atTEXTNOT NULL抓取时间ISO 格式updated_atTEXTNOT 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]), sepr[/、,]) rec[director] normalize_split(pick(tree, FIELD_XPATHS[director]), sepr[/、,]) rec[actors] normalize_split(pick(tree, FIELD_XPATHS[actors]), sepr[/、,], limit8) rec[runtime_min] normalize_runtime(pick(tree, FIELD_XPATHS[runtime])) rec[summary] normalize_summary(pick(tree, [//div[classintro]//p[1]//text()])) poster tree.xpath(//div[classpic]//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 titleexcluded.title, yearexcluded.year, runtime_minexcluded.runtime_min, directorexcluded.director, actorsexcluded.actors, summaryexcluded.summary, updated_atexcluded.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, base1.2, jitter0.8, retries3): for attempt in range(retries): try: r session.get(url, timeout15) 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 不要立刻重试因为那通常是服务端在告诉你慢一点。我用的策略是(attempt1) * 5秒第一次 5 秒、第二次 10 秒、第三次 15 秒。三次都失败就把这条记录标记为 failed让队列走下一批回头再统一重跑——而不是死磕一个页面。4.2 状态机式的队列管理queue表里我用了三种状态pending待抓、done成功、failed失败。这个设计让整个采集过程变成可观测、可恢复的。状态含义后续动作pending已发现未抓取由采集器消费done已成功入库不再处理除非做全量刷新failed重试耗尽仍失败定期拉出来重跑或在 rules 更新后重跑重跑失败条目的 SQL 就是一行UPDATE queue SET statuspending WHERE statusfailed AND tries 5。我另外加了一个tries字段记录尝试次数防止某个永久失效的页面被无限重试白耗请求额度。4.3 增量抓取的两种模式增量在这个项目里有两个含义必须分开处理否则会做出很别扭的东西。第一种是发现增量只抓新增的影片。做法是列表页按更新时间倒序翻页时一旦遇到本页所有 ID 都已在 movie 表中就停止翻页。这个模式适合日常维护每天跑一次只花几分钟。第二种是内容增量已有的影片信息可能被站点修订补了简介、改了时长、加了主演。这个不能靠列表页发现得靠定期全量重爬或者对updated_at较旧的记录做抽样重爬。我用的折中方案是按年龄重爬每次运行取updated_at距今最久的 2000 条重爬一遍。轮转下来全库每两周左右刷新一次成本很低。def next_batch(conn, limit2000): return conn.execute( SELECT movie_id, url FROM queue WHERE statuspending ORDER BY movie_id LIMIT ? , (limit,)).fetchall() def refresh_batch(conn, limit2000): 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 errorsreplace部分页面标题正常部分是乱码站点存在多套模板编码声明不一致走 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 给后续做静态站点或检索页面用。导出时注意两个细节一是encodingutf-8-sig否则用表格软件打开中文会乱码二是多值字段演员、类型用统一的逗号分隔不要用分号或斜杠混着来否则下游切分要写一堆兼容逻辑。import csv, json, sqlite3 def export(conn, csv_pathmovies.csv, json_pathmovies.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, encodingutf-8-sig) as f: w csv.writer(f); w.writerow(cols); w.writerows(rows) with open(json_path, w, encodingutf-8) as f: json.dump([dict(zip(cols, r)) for r in rows], f, ensure_asciiFalse, indent1)6.3 后续可以扩展的方向这套流程跑顺之后我顺手做了两件事。第一件是给影片加了系列归并——把片名里数字后缀、罗马数字、副标题剥掉把同系列的片子归到一组这样查某个系列时能一次看全。第二件是做了一个极简的本地检索页用 SQLite 全文索引对片名和简介建虚拟表浏览器打开就能按关键词搜速度比在源站搜索快得多也不受源站搜索功能的限制。如果要把这套东西再往前推一步我会补上三块一是把导演和演员拆成独立的关系表做成人-片双向关联这样才能查某导演的全部作品而不只是数个数二是给清洗规则加一份可回归的测试样本集每次改规则跑一遍确认没有把已经修好的数据改坏三是把采集节奏做成定时任务每天固定时间跑一次发现增量两周做一次内容刷新让数据保持半新鲜的状态。最后分享一个我在这个项目里体会最深的小点数据采集的价值八成不在抓到多少而在抓完之后你敢不敢用。我前期的库有八千多条记录但因为字段脏、年份乱、片名一堆噪声实际能用来做筛选的可能不到一半。后来把清洗和校验的口子收紧记录数虽然没涨但每一条都能直接拿去用这个转变带来的收益比多抓两千条要大得多。所以如果你也在做类似的整理宁可慢一点、窄一点也要让每条落库的数据都经得起翻看。
企业数字化 ERP 产品动态
相关推荐
老片索引站电影元数据采集:Python爬虫管道与数据清洗 先说一句实在话:电影信息采集这件事,技术难度并不高,难的是把边界划清楚、把数据洗干净。标题里提到的那类老片索引站,页面还停留在十几年前的 table 布局,HTML 结构松、编码乱、字段缺,正好是练手爬虫的典… · 2026/11/3 15:48:41
从零构建AI工程体系:生产级交付的七道生死关 1. 为什么“从零构建AI工程体系”不是一句口号,而是生存刚需 最近三个月,我帮六家不同规模的团队做过AI落地诊断。其中四家都卡在同一个地方:模型在Jupyter里跑通了,一上生产环境就崩——不是OOM就是延迟飙升,API响应时… · 2026/11/3 15:48:41
推荐系统多模型融合技术与工程实践 1. 多模型融合在推荐系统中的核心价值在推荐系统领域,单模型往往难以全面捕捉用户行为的复杂模式。我经历过多个工业级推荐项目后发现,当系统DAU超过百万量级时,多模型融合带来的效果提升会变得尤为显著。以某电商平台实践为例,通… · 2026/11/3 15:48:41
Web日志分析工具:SQL注入与XSS攻击检测实战 1. 项目概述:Web日志分析工具的核心价值在网络安全攻防对抗中,Web服务器日志就像战场上的监控摄像头,记录了每一次访问的蛛丝马迹。我花了三个月时间开发的希水涵日志分析工具,正是为了从海量日志中自动识别攻击特征,让… · 2026/11/3 21:24:34
NHANES数据挖掘:从公共数据库到JAMA顶刊的突破方法 1. 项目背景与核心价值这个标题背后反映的是医学研究领域一个极具突破性的案例——利用NHANES这类公开数据库做出的研究成果成功登顶顶级医学期刊JAMA。作为从业十余年的科研工作者,我见证过太多团队在公共数据挖掘上折戟沉沙,而这项研究能获得如此高的认… · 2026/11/3 21:24:34
Hindsight不是工具:LLM服务中全链路回溯的可观测性范式 1. “Hindsight”不是工具名,而是LLM工程中一个被严重误读的概念锚点很多人第一次在GitHub、技术论坛或内部文档里看到“hindsight”这个词,下意识以为是个新开源项目、新API服务,甚至有人直接去Docker Hub搜hindsight:latest——结果当然是4… · 2026/11/3 21:24:34
AddressSanitizer在物业管理系统中的内存安全实践 1. 项目概述:AddressSanitizer在物业管理系统中的创新应用第一次听说将AddressSanitizer这种底层内存检测工具与物业管理系统结合时,我和大多数开发者一样感到意外。但深入思考后,这种跨界组合恰恰体现了现代软件工程的一个核心趋势——用基础… · 2026/11/3 21:24:34
专科生必看:9款提升就业竞争力的AI时代工具 1. 专科生如何应对AI时代的就业挑战 2026届专科生即将面临一个全新的就业环境——AI技术正在重塑各行各业的工作方式。作为职业教育培养的应用型人才,专科生需要特别关注那些能够降低AI替代风险的工具和技能。经过三个月的实测和对比,我整理了这份针对专… · 2026/11/3 21:24:34
PipeSwift:从吞吐量到JCT,流水线并行调度如何优化作业完成时间 “你这集群吞吐能到多少?”和“我提交的那个训练任务,大概什么时候能跑完?”——这两年我发现自己被问到后一个问题的次数越来越多。这两个问题的答案,往往差得很远。前一个数字再漂亮,如果任务在队列里半天不动&#… · 2026/11/3 21:23:34
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 13:10:28
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 6:04:24
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 5:52:46
STM32F411RE与MAX14431实现SPI隔离:工业现场抗共模干扰实战 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
ESP32+大模型AI硬件开发:从Demo到产品的8个工程问题 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
基于MAX14431BAEE+与PIC18F85J10的SPI隔离方案设计与实践 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 13:10:28
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 6:04:24
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 5:52:46