前两天我把一个攒了大半年的项目丢到了 Hacker News 上,标题写着 “Show HN: A Catalogue of Unknown Creatures”。本来只是顺手分享,没想到一天里涌进来一堆人讨论。有人问数据从哪来,有人建议加地图,还有人直接在评论区帮我补了一条我听都没听过的生物。回头翻聊天记录,这个项目最早的灵感其实特别简单:我一直在收集各种奇奇怪怪的民间传说生物,发现网上的资料散得离谱,同一个生物在不同语言、不同年代的名字完全对不上,可信度也不一样,有的有照片,有的只有一句口头描述。要不干脆自己做一个结构化目录,把这些“未知生物”统一管理起来?
这篇文章不会讲什么大道理,就是把我在做这个目录时踩过的坑、想清楚的设计逻辑、以及实际能跑起来的技术方案,完整列出来。如果你也对民俗、神秘动物学、数据可视化或者冷门内容平台感兴趣,这篇东西应该能给你不少能直接用的思路。哪怕你完全不是开发者,光看数据模型和分类方式,也会发现一件有趣的事:给“模糊不清”的信息建模,其实比给“明确”的信息建模更需要设计。
1. 项目定位:先搞清“未知生物目录”到底想做什么
1.1 为什么选这个方向:冷门但有黏性的选题
说实话,“未知生物”不是一个热门赛道。它不像 AI、插件、效率工具那样能快速让人理解价值,甚至第一眼看上去会被人当成“怪谈收集器”。但我做这个项目的核心动机,并不是猎奇。
我做了一圈调研,发现所有现存的民间传说生物资料库,基本都落在两种极端里:一种是纯粹百科式的,什么都收,但没有统一的信息结构,读完一个条目的信息量完全取决于作者心情;另一种是营销号式的,专门挑耸人听闻的内容,几乎没有原始出处。这两类对我来说都没法用。
“目录”的价值在于,它得像科学标本柜一样,用同样的标签、同样的测量字段、同样的证据链去规范每一个条目。哪怕内容本身是“未知”的,字段类型必须是已知的。这就是我决定做这个项目的根本出发点:我想给一团模糊不清的人类集体想象,造一个结构清晰到不能更清晰的容器。
1.2 它到底解决什么问题:不是猎奇,而是记录“灰色地带”
人类的民俗传统里,存在大量“证据不足但反复出现”的描述。某个湖怪在不同年代被不同目击者描述成相似的形状,某个神秘巨兽在村落口述中流传了上百年,但这些都没法被生物学正式确认。
这类信息有几个共同痛处:名称不统一,不同地区叫法完全不一样;出现时间跨度大,有的记录隔了上百年;形态描述不可靠,同一个生物有时候被说成兽类,有时候被说成鱼类;真假混杂,有一部分最终被证明是恶作剧或误认。
所以我这个目录要解决的核心问题,不是“证明某个生物存在”,而是“忠实记录人类曾经声称看到了什么”。这也决定了整个项目的价值观:我们只整理描述,不负责判决。让证据强度、目击次数、来源可靠性这些维度自己去说话,读者自己判断。
1.3 项目的目标用户和边界
我在设计初期圈定了三类核心用户。第一类是民俗学和人类学方向的爱好者,他们需要快速检索不同文化圈的相似母题,比如“化蛇”和“水蛇传说”之间的共性;第二类是神秘动物学爱好者,他们更关注目击记录、照片证据和可信度数据;第三类则是纯粹被好奇心驱动的游客,他们可能只想翻一翻地球上还有哪些未被命名的东西。
边界同样重要。我一开始就想清楚了几条红线:不做纯虚构文学生物的收录,比如小说、电影和游戏里凭空创造的生物,必须和民间传说分开;不做政治化解读,所有文化区域用文化圈而不是政治实体来标记;不进行“存在/不存在”的二元判断,只保留证据条目。
有了这些边界,整个项目的调性才稳住了。它不是一个玩笑项目,也不是一个神棍项目,而是一个偏民俗学数据工程方向的内容项目。
2. 从零搭一个“未知生物目录”的核心设计
2.1 数据模型:我如何给“模糊”建模
项目最开始的版本特别简单,就是一张大表,把所有生物塞进一堆列里:名字、地区、身高、体重、外貌、行为、来源链接。跑了一周就发现问题了。
问题出在“身高”和“体重”这类字段上。一个生物如果在目击记录里有时候是三米、有时候是十几米,我这个列到底填什么?填“3到15米之间”?那检索的时候怎么过滤?更麻烦的是,有些生物并没有明确的形态描述,只有一种模糊的行为描述,比如“会在雾天模仿人声”。这类信息根本塞不进传统的数值字段里。
我推倒重来之后,确定了几个核心原则。第一,所有柔性信息用“标签数组 + 区间字段”来存;第二,所有硬性信息,比如首次目击年份、最近目击年份、目击次数,单独拆出来用数值存;第三,任何无法确认的信息用null而不是填空字符串,这样统计时才能区分“未知”和“不存在”。
最终的生物主表长这样:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | UUID | 全局唯一标识,不暴露录入顺序 |
| common_name | string | 最常用的英文名,比如 Fresno Nightcrawler |
| original_name | string | 原生语言名称 |
| aliases | jsonb | 别名列表,比如同一种生物在不同文化圈的小名 |
| type_tags | text[] | 形态标签,可多选:类人、四足、蛇形、飞行、水生、兽类、变形 |
| cultural_region | string | 文化圈名称,用枚举约束 |
| first_seen_year | integer | 最早目击年份 |
| last_seen_year | integer | 最近目击年份 |
| sighting_count | integer | 有效目击次数 |
| size_range | interval | 例如 “3 至 15 米”,用最小值+最大值表示 |
| weight_range | interval | 同上,允许为空 |
| appearance_summary | text | 外貌描述,最长为500字 |
| behavior_summary | text | 行为描述 |
| diet_tags | text[] | 食性标签:肉食、植食、杂食、未知 |
| evidence_type | text[] | 证据类型:民俗口述、疑似照片、足印模型、录音、物证 |
| credibility_score | smallint | 0 到 100 |
| status | enum | documented / unverified / disputed / hoax |
| citation_json | jsonb | 来源引用列表 |
| created_at | timestamptz | 创建时间 |
“大小”这个区间字段尤其有用。它不用逼我非得给所有生物填一个精确数值,只需要填目击记录中出现过的最小值和最大值,既能算中间值,也不至于丢失原始波动信息。这里我必须承认,我参考了生物多样性格里“形态测量”的设计思路,但做得更宽容,因为我们要记录的对象并不存在可测量的标本。
2.2 分类体系:形态、文化区域、目击类型、可信度
我一开始犯过一个经典错误:试图做一个“完美分类”,结果把每个生物都塞进一个且只能一个分类层级里。比如某个生物半个身子像兽、半个身子像鱼,我之前会强行分到“两栖类”。后来想明白了,我们记录的不是生物学物种,而是人类描述里的形象,所以标签系统比树状分类要合适得多。
于是我把分类拆成四个独立维度,互不干扰。
第一个维度是形态标签,完全扁平化,一个生物可以同时拥有“水生”和“蛇形”两个标签。第二个维度是文化区域,我用了枚举列表,刻意避开主权国家划分,改用文明圈概念,比如“凯尔特口述传统圈”“亚马逊雨林民俗圈”“东亚山地信仰圈”。这样处理不只是为了政治安全,更是因为传说传播的边界本来就不以国界为界。
第三个维度是目击类型,主要分成五类:民俗口述、疑似影像、物理痕迹、官方档案、二手转述。这个维度决定了后续可信度评分的取值区间。
第四个维度才是真正的核心,我把它叫做“证明状态”,不是简单的“真/假”,而是四个互相递进的等级:
| 状态 | 含义 | 示例场景 |
|---|---|---|
| documented | 有明确民俗志或档案记录,跨多个独立来源 | 某地区口述传统中的湖怪,历代地方志均有提及 |
| unverified | 有目击记录但证据不足或来源单一 | 某旅行者日志里的陌生野兽 |
| disputed | 存在相互冲突的解释,有争议 | 疑似照片被怀疑是漂浮木或造假 |
| hoax | 已被证实为恶作剧或故意造假 | 某电视节目自导自演的神秘生物事件 |
这个设计帮我稳住了项目价值观:我记录“曾经有人宣称它存在”这个事实,而不是记录“它存在”这个结论。所以哪怕一条生物最终被打上 hoax 标签,它依然值得留在目录里,因为“一个谣言如何产生和传播”本身就是很有价值的民俗学材料。
2.3 条目结构:从“名字”到“证据链”
一个完整的条目,最理想的阅读流程是从“名字”出发,逐步深入到“证据链”的底层。我把条目拆成了哪些模块,直接决定了代码实现难度。
首先是名称和别名模块。这里我踩了一个不小的坑:一开始 common_name 字段我用的是英文名,后来一条来自夏威夷民间传说的条目,原生名字长达二十几个字母,我才意识到必须单独立一个original_name字段,并且用aliases表来存多语言别名,不然检索框一搜完全搜不到原生语言查法。
其次是“目击时间轴”模块。每个生物条目都有几个关键时间点,我会做成一个单独的子表sightings,存每条目击记录的时间和地点,地点只精确到文化圈层甚至模糊地理坐标。这样在主列表页可以渲染出一条“目击时间线”,一眼就能看出某生物的记录在哪些年份扎堆出现,哪些年份完全断档。
然后是“证据链”模块。每条生物都携带一个证据数组,比如一份1962年的报纸剪报、一枚野营者拍到的胶片、一段村民口述录音。这些证据会被分别打上“已考证”“待考证”“证据驳回”的标签。这个模块的最大价值,是让读者和审校者都能看到判断的依据,而不是只看到结论。
最后是“交叉关联”模块。A 生物和 B 生物在形态描述上有80%相似度,我会在后台生成一条关联记录,方便民俗学爱好者做母题溯源。这个模块开发量不大,但却是社区最喜欢用的功能之一,它意外地帮整个目录建立了一种“知识网络”的感觉。
2.4 技术选型:为什么我用这套组合
说实话,这类内容型项目,技术上没有任何“必须用XX”的理由,但我还是仔细比较过,最后敲定的技术栈是这个组合:Python FastAPI 做后端,PostgreSQL 存数据,React + Next.js 做前端,地图用 Leaflet,动画图表用 Recharts。
选 FastAPI 纯粹是因为它天生自带 OpenAPI 文档,我可以把数据模型直接暴露成可探索的 API 页面,这样社区用户不需要学 SQL 也能查数据。而且它的异步支持对将来挂爬虫和图片处理任务有帮助。
PostgreSQL 是必然选择,因为jsonb和text[]这类半结构化字段太好用了,MySQL 在数组和 JSON 检索上要费很多劲。我尤其依赖 Postgres 的 GIN 索引来加速全文搜索,后面会详细说性能问题。
前端用 Next.js 是因为它支持服务端渲染,对搜索引擎更友好。其他目录型项目会纠结要不要做 SSR,但我想通了,如果一个民俗爱好者在谷歌搜某种生物的名字,却不能直接搜到我目录里的条目,那这项目就失去了传播的意义。
3. 实操环节:录入第一批生物条目会遇到的事
3.1 我录入的二十个初始条目的分类过程
第一批数据我选了二十个来源清晰、资料密度适中的生物,大约花了一个周末来录入。这个阶段不追求数量,而是为了验证数据模型是否扛得住真实世界的复杂情况。
二十个条目里有几个非常有代表性。一个是来自北欧口传传统的“水灵”,在不同地方被描述成半人半鱼、有时又像水獭。我一开始想把它归为水生类,但它的形态标签经常跳动,最后我给它加了“水生”“变形”“类人”三个标签。另一个条目来自北美某地,有照片、有脚印模型、甚至有一小段录音,但后来的调查证明照片里只是枯萎树干,我依然保存了全部证据,只是打上了disputed状态。
录入过程给了我一个很重要的启发:数据模型一定要设计成“允许冲突”的。一个生物同时被两个互斥的形态标签描述,这不是数据错误,而是现象本身如此。我要是强行把这个冲突抹掉,反而丢了民俗学上最有价值的信息。
3.2 数据清洗:统一名称、别名与跨语言处理
数据清洗是最枯燥但最重要的一环。我花在清洗上的时间,比写代码的时间还多。
先说名称统一。同一个生物可能有英文名、原生名、学术译名、营销号编造名。我做的处理是建立aliases表,把名称分成“主要名称”和“别名”两类,别名可以标记语言来源和来源类型。比如一条“卓柏卡布拉”的条目,别名表里存了西语原名、英文音译、还有一个《纽约时报》用过的历史用名。检索系统会同时索引主表和别名表。
跨语言处理上,我一开始天真地以为只要把数据都翻成英文就行,后来发现不行。民俗学概念在翻译过程会变形,比如某个词在当地语言里是“灵魂守护者”,直译成英文却变成“幽灵”,意思差很远。我的解决办法是original_name保留原生拼写,translation_gloss存国内通用的翻译解释,并在其中加一条“语义不可完全对译”的提示。听起来有点矫情,但民俗内容恰恰需要这种边界感。
清洗阶段还有一个小技巧:跑了一组模糊去重脚本,把名称相似度过高的条目自动标出来,人工再决定是合并还是分开。效果还不错,省了几小时手工比对时间。
3.3 可信度评分的设计逻辑:专家分、来源分、目击分
整个页面里,最显眼的元素就是每个生物卡片上的一个数值分数,0到100,从灰到绿到红。这个分数不是随便算的,我一开始试过纯人工打分,很快发现不同人可以给同一个条目打出天差地别的分数。于是我把评分拆成了四个可解释的维度。
第一个维度是“来源分”,满分40。一级民族志、官方档案、正规出版社的著作,来源分30到40;地方报纸、个人出版物,来源分15到30;匿名网络帖子、无出处的营销文章,来源分0到15。第二个维度是“目击分”,满分30。目击次数超过十次且分布于不同年代,给20到30;三次以下给5到10;只有单次孤证直接给0到5。第三个维度是“证据分”,满分20。有疑似物理痕迹或影像加10到20,只有口头描述加0到10。第四个维度是“一致性分”,满分10。不同目击者对生物的描述彼此吻合加7到10,互相矛盾明显减分。
这四个维度加权求和,就得到了最终分数。但我要特意说明一个细节:哪怕分数低于20,我也不会把条目直接删除,因为低分和高分一样,都有信息价值。这个评分模型公布之后,社区参与度反而上来了,因为每个人都能看到自己的补充会如何影响最终分数。
3.4 图片与来源的版权处理:容易翻车的环节
做这种内容项目,版权问题真的是最容易翻车的环节。我先给自己定死一条规则:任何一条没有明确授权或者公有领域声明的图片,一律不上传,只保留图片链接,让来源网站承担展示责任。
实际操作里,我用了一个折中方案:图片统一抓取缩略图存本地,但原图始终保留外链,并且在citation_json里记录图片的 URL、作者、授权类型。如果某张图片是用户上传的,必须勾选“我拥有这张图的版权或已获得授权”才能提交。
还会遇到一些极其模糊的情况,比如某条目配了一张手绘插图,来自一本1985年的田野笔记,作者已经过世,无法确认授权。我的处理办法是把图片标记为“历史文献资料”,并且只展示缩略图,不做商业用途,同时尽量寻找出版方的公开版权声明。这种“灰色处理”不是法务级标准,但对个人项目来说已经是比较稳妥的做法了。
4. 展示层与社区化:怎么让目录“活”起来
4.1 时间线视图与地理地图
数据录进去只是第一步,让读者能直观地看出一条条“传说脉络”才是真正有价值的部分。我想做两个展示视角,一个是时间线,一个是地图。
时间线视图做起来比想象中简单,因为first_seen_year和last_seen_year两个字段天然适合生成区间条。Recharts 的区间条组件直接把两个年份连成一线,鼠标悬停时显示生物名称和状态。观众可以一眼看到,地球上某个区域的传说记录在特定年代突然密集,又在某段时间销声匿迹。这个视角特别适合讲民俗传播的故事。
地图视图麻烦不少。Leaflet 本身很成熟,但问题在于坐标精度。很多传说根本没有精确坐标,我只能根据文化区域质心生成一个模糊位置,并且在地图上用半径大小来表示不确定程度。我不会把某个传说精确标到某个村庄上,因为那样既误导人,也可能干扰现实居民。模糊坐标确实损失了一部分“身临其境”感,但换来了安全性和规范性。
这个模糊坐标设计在 Hacker News 上被不少人夸奖过,他们说一般项目只会无脑把所有点打在地图上,很少考虑数据不确定性的可视化。这让我觉得当初多花的那一天时间非常值。
4.2 开放API的设计:把目录变成生态底座
项目做到中期,我开始意识到光有网页界面还不够。如果别人想基于我的目录做自己的研究,或者写个机器人做每日“神秘生物推荐”,他们需要一个接口。
我利用 FastAPI 的自动文档特性,直接暴露了三层 API。第一层是列表页/api/v1/creatures,支持分页、标签过滤、评分排序;第二层是详情页/api/v1/creatures/{id},返回完整数据,包括证据链和来源引用;第三层是搜索接口/api/v1/search?q=,会对主名、别名、标签做加权检索。
授权不用复杂的 OAuth,直接给 API Key,每位注册用户能拿一个令牌,每天限一千次调用。这个限流策略让免费版足够个人使用,又不至于被爬虫拉爆。
API 真正上线两周后,已经有两个小项目挂在上面做了第三方客户端:一个是预测“哪些生物条目将要升级状态”的社区实验,另一个是给条目生成本地化翻译的众包插件。当时我非常有成就感,因为这代表目录不再是一个孤立网站,而变成了一个小生态里的基础设施。
4.3 社区提交与审校流程:避免猎奇内容淹没数据
开放提交功能之后,我很快发现了一个问题:大量用户想上传的其实是虚构游戏生物和自创角色。这些内容质量并不低,但不符合目录“民间传说+目击记录”的定位。
为此我设计了一套提交与审校流程。用户提交新条目时,必须先选择“来源类型”,按自定义规则划分成四类:民间口述传统、历史档案、目击记录、网络传说。选“网络传说”需要额外填写原始链接和传播时间线,如果不填,就无法进入审校队列。
后台审校界面我把所有待审条目按“可信度评分从低到高”排列,优先处理低分条目,因为低分条目往往有版权或真实性风险。审校者只做三件事:确认来源类型、检查是否有明显伪造痕迹、补充缺失字段。不做“是否可信”的主观判断。整个过程公开透明,审校记录会在条目详情页展示,谁在什么时候改了什么,全部有迹可循。
5. 常见问题与排查技巧实录
5.1 数据录入乱序:为什么越录越乱
项目上线一周后,我发现自己录入新条目时经常搜到重复内容。起因是我在录入界面直接用了浏览器自带搜索,后知后觉地发现搜索接口搜的是common_name,根本不会匹配别名。比如我花半小时录完一条新条目,结果发现“另一个名字”的同款生物早在数据库里躺着了。
教训很简单:搜索必须同时覆盖主名、别名、原始名。我用 Postgres 的pg_trgm扩展做了模糊匹配,并为aliases表建了 GIN 索引,之后重复录入率才降下来。如果你也在做内容型数据库,这个坑千万要避开,一开始就把别名索引建好,别走我的弯路。
5.2 跨界生物的标签冲突问题
有一类生物会同时命中多个分类,比如水系生物又有飞行描述,导致列表页按单一标签过滤时出现缺失或重复。最初有人建议我改成树状分类,说只能选一个标签,这样统计更“干净”。我想了想还是拒绝了。
标签冲突不是数据错误,而是这类描述的固有特征。我的解决办法是前端加了一个“标签交集”筛选模式,用户可以同时选中“水生”和“飞行”,系统返回同时满足两个条件的条目。这算是在分类和真实世界之间找到了一个折中方案。
5.3 图片加载失败与缩略图处理
图片是目录里最占用资源的部分。我最初直接把原图缩略图挂在列表页,结果某些图片来自古董网站,加载极慢,有时候还会因为防盗链直接挂掉。
我做了两层处理。第一层是把所有图片统一抓取到本地 OSS,后台跑一个resize + jpg转Avif的压缩流程,限制最大宽度800像素;第二层是在数据库里加了一个image_ready布尔字段,只有本地的缩略图生成成功,列表页才显示图片卡片,否则只显示一个占位图标并外链原站点。
这个改造让列表页首屏加载时间从四秒多降到了不到一秒,收益非常明显。
5.4 性能问题:条目明明不多,查询却越来越慢
刚开始我觉得几千条数据而已,怎么可能会慢。结果当我在搜索接口里加了“按文化区域过滤、按评分排序、再全文匹配”三层条件后,响应时间飙升到了两秒多。原因是jsonb里的来源数组成了搜索和排序的拖累。
排查下来,问题不在引擎,而在表结构。我把大的文本字段拆到了long_reads字表,列表接口只查主表摘要字段,全文搜索用tsvector的索引字段。改完之后,响应时间回到两百毫秒左右。对于内容型小项目,这个优化思路足够支撑到百万级条目了。
最后分享一个小技巧
做这个项目时,我养成了一个习惯:每录入一条新生物,都会顺手写一段“这条为什么值得收录”的草稿,存在notes字段里。这些草稿后来意外成了审校和社区讨论最好的起点。有人说我的目录有一种难得的“温度”,大概就来自这些看似多余但真实存在的信息。如果你也想做一个类似的内容型项目,少钻技术牛角尖,多想想你的数据凭什么让用户信任,这比任何架构决定都重要。