58 条记录的名字全对不上,根因是脚本取了搜索结果的第一条

58 条记录的名字全对不上,根因是脚本取了搜索结果的第一条

半路手记 · 踩坑实录

它不报错,所以我一直以为它是对的

我维护着一份影视条目库,一千二百八十六条记录,存在本地一个 JSON 文件里。每条记录有片名、年份、导演、演员,另外挂一批从外部影片库抓来的字段:外部 id、原始片名、上映日期、评分、剧情简介、海报地址、演职员。

有一阵子这份数据缺外部 id,我写了个脚本批量补。跑完,日志一行行刷“已补全”,没有一条报错。补完的库照常渲染,页面上一张张海报排得整整齐齐。我就当它好了。

直到有天我随手点开一条,看片名和导演——对不上。不是我看错,是整条记录描述的是另一部片子。

再往下查,问题不小。我用一条判据把全量扫了一遍,五十八条记录的名字对不上,占全部的百分之四点五。

第一层:同一条记录里,两拨字段在互相打脸

打开其中一条,看到的是这种形状:

字段值谁写的
片名 / 导演 / 演员河流 / 蔡明亮 / 李康生、苗天、陆弈静人工核对过
tmdb_title / 简介 / 演职员红河谷 / 红河谷剧情 / 冯小宁早期脚本抓的
t_tmdb / 中文简介河流 / 河流剧情后来重抓的

一条记录,三个来源,两个来源互相打脸。

我当时搜的是“河流”,接口返回的第一条是《红河谷》——一部 1997 年的国产战争片,跟我要找的那部毫无关系。脚本照单全收,然后拿这个错的 id 去抓简介、海报、演职员,全部按错片子落库。

更要命的是同一条记录里并存两代机抓字段:早期那批是错的,后来重抓的那批是对的,而渲染层读的是早期那批。所以页面上导演显示冯小宁、海报是另一部片的剧照,可 t_tmdb 那个字段里明明白白写着“河流”。

一份数据里,对的和错的并排放着,谁被显示取决于模板碰巧读了哪个字段。

第二层:根因是一行没有校验的 return

顺着写库的路径往回走,找到补 id 的函数。它长这样:

def search_movie(title, year):
    data = fetch(BASE + "/search/movie", params={"query": title, "year": year})
    if data and data.get("results"):
        return data["results"][0]      # 第一条,不校验片名、年份、导演
    return None

results[0] 是“最像的那个”,不是“对的那个”。这两件事在模糊搜索里差得很远:

  • 同名片子跨年代。同一个中文译名可能对应 1962 年一部、1997 年一部、2019 年一部。
  • 译名不统一。库里的片名是人工译的,接口里存的是另一个译名或者原名,字面能撞上的概率本来就不高。
  • 接口只排相似度,不排正确性。它不知道你要的是哪一部,它只知道哪个字符串更像。

而脚本在拿到这个结果之后,做了什么?什么都没做。没有比对片名,没有比对年份,没有比对导演,直接开始用它抓别的字段。

这里缺的不是 try/except。异常处理挡不住这个——请求成功了,返回了数据,字段齐全,一切正常。缺的是一个“凭什么是它”的判据。

第三层:id 改对了,图还是别人的

这一层是我修的时候才撞上的,也是这件事里最值得记的一层。

海报是本地文件,文件名按外部 id 命名,比如 tmdb_54291.jpg。下载函数里有一条很合理的省事判据:同名文件已经在盘上,就跳过,不重复下载。

于是发生这样的事:我把某条记录的错误 id 改成了正确的 id,重新跑下载,函数看到 tmdb_<正确id>.jpg 已经在盘上——那是当年按错 id 存下来的、属于另一部片子的图——判定“已存在”,跳过。

id 修对了,海报还是别人的。

原因不是幂等写错了,是幂等的键派生自一个本身会出错的值。错误一旦被这个名字固化下来,后面的所有“正确”都绕不过它。所以修的时候必须强制重下,把 force 打开,覆盖掉那个名字底下的旧内容。

这条经验可以搬得很远:凡是缓存、去重、断点续传,只要判据里含有“之前算出来的值”,那个值出错之后,机制本身不会自我纠正,只会忠实地把错误沿用下去。

第四层:为什么我的校验一直是绿的

这份数据有校验脚本,每次改完都跑。它验三件事:

  1. 字段在不在(tmdb_id、poster、credits 有没有缺)
  2. 条数对不对(一千二百八十六条,不能少)
  3. 体积有没有异常缩水(防止一次写入把文件写空)

三条都对,rc=0,全绿。

问题很清楚:这三件事验的都是形状,不是身份。 它验得了“这条记录还是一条记录”,验不了“这条记录描述的还是一件东西”。一条把《红河谷》的全部信息完整地写在《河流》名下的记录,在这套校验里是百分之百健康的。

怎么把五十八条全找出来

不能靠抽样。抽样只能验证你以为会错的地方,而我当时根本不知道会错在哪。

能用的是一条很朴素的判据:找两个独立来源打架的地方。

这份数据里恰好有两个来源——人写字段(片名、年份、导演、演员,是当年一条条核对过的)和机抓字段(脚本写的)。正常记录里两者一致;只要打架,就说明至少有一边错了。

拿“人写的导演”和“机抓的演职员里第一位导演”做比对,一千二百八十六条缩到五十八条,命中的全是真的。

这条判据的边界也要说清楚:它只抓“两个来源不一致”这一类。如果两拨都错,或者人写字段当年就没填,它抓不到。所以它不是一个覆盖全量的方法,它只是在这份数据里最便宜的那条。

修:换号不是改名

五十八条分两类,处置完全不同:

类目条数含义处置
换号22这条记录指错了片子换成正确的外部 id
同号重抓31id 本来就对,错的是挂在 id 上的机抓字段同 id 重抓全部机抓字段

“换号”听起来像改个数字,其实是回答一个问题:这一条到底指哪一部?

判断顺序不能反:

  1. 人写的导演当锚。人工字段里最可靠的是导演——片名会重、译名会飘,导演加年份的组合基本唯一。
  2. 用这位导演的作品年表反查 id。不是在搜索框里搜片名,是拉出这个人的作品列表,在里面找。
  3. 片名、年份、演员三项交叉验证,至少两项命中才认。

为什么不直接搜片名选第一个?因为那正是当初出错的那一步。同一个动作,用在修复上,只会把同样的错误再做一遍。

剩下七条:数据判不了,得人来定

修完五十三条,还剩七条,一条都没动。

它们的共同形状是:片名和年份指向 A 片,导演和演员指向 B 片。谁对谁错,取决于“这条记录本来想收哪一部”——这是内容决策,不是数据问题。硬修就是拿一半证据去覆盖另一半证据,改完看着整齐,其实换了个错法。

这七条留在原地,附上冲突点,等有人认领。

验证

项结果
判据复扫58 → 7
构建1,462 页,无报错
详情页抽查(8 条)旧导演名 0 处残留,新导演名在位
海报逐张开图确认是本片

最后一行是特意加的。文件名和 id 都对得上,不代表图对得上——文件名恰恰就是当初骗过我的那个东西,拿它当验证依据,等于用同一个错来证明自己没错。

如果你也要批量补外部 ID

  • 搜索之后有没有校验?片名、年份、导演,至少比一项,最好比两项。
  • 校验不通过时,是报错,还是静默写了个默认值?后者等于把猜测当成了事实。
  • “取第一条”的地方还有几处?同一个模式通常不会只出现一次。
  • 缓存的键是不是派生自可能出错的值?是的话,那个值错了以后缓存会一直错下去。
  • 校验脚本验的是形状还是身份?字段齐全、条数正确、体积正常,一条都不证明内容对。
  • 有没有可疑的两个独立来源可以互相比对?有一个就够用,比抽样强得多。
  • 修的时候有没有复用出错的那套方法?复用就等于重犯。
  • 判不了的那几条,是不是被“修”掉了?宁可留着报错,不要盖过去。

一句话

取搜索结果的第一条不是默认值,是一个没人复核的判断。它不报错,只是把猜出来的东西,安安稳稳地写成了事实。