• 欢迎来到THBWiki!如果您是第一次来到这里,请点击右上角注册一个帐户
  • 有任何意见、建议、求助、反馈都可以在 讨论板 提出
  • THBWiki以专业性和准确性为目标,如果你发现了任何确定的错误或疏漏,可在登录后直接进行改正

用户Wiki:NicoNicoNii

Da THBWiki.
Jump to navigation Jump to search

关于我

基本信息
人物名 NicoNicoNii
称号 二十一世纪的二十世纪延长型的开发者
能力 操纵程序计数器程度的能力
LDA #$c0, PHA, LDA #$de, EOR #3, PHA, RTS
入东方时间 见下文

入东方时间

根据我对网络评论区的观察,我自认为“入东方时间”是只有资深人士才能安全回答的问题,因为只有“游戏LNN全通关”、“发起过社团”、“熟悉100+角色设定”、“创作过 XXX 作品,点击数破万”是不会产生异议的证据。但对“只是看过什么”的泛 ACG 网民来说则十分危险,不能轻易回答。如果在错误场合给出错误答案,“老登”、“梗小鬼”、“伪粉丝”、“引流狗”、“云玩家”、“二设入脑”、“只认识一部作品的Bad Apple!!厨”等骂名会让你应接不暇(曾经,亲自动手移植Bad Apple!!到新平台可以得到“大神”称号作为免死金牌,但后来因为过度泛滥和某些开发者引发的争议,这免死金牌已经无效了,甚至可能会认为是蹭热点的——然而我自己就移植了两部,让我不寒而栗)。所以我也不敢给出答案,请读者自行甄别。

如果我要假装我是老资历,我会说 2013 年的初中,然后指指我的 6 位数 B 站 UID,再指指我 2014 年搬运的东方 meme(甚至不用我自己指出 UID,单纯现身评论区即可),百试百灵,甚至我主观上没想试,也会灵验。考虑到目前 B 站用户的平均年龄,70% 的人都无法反驳;如果我要更加浑水摸鱼地假装我是老资历,我说 2016 年,绝对无法被证伪,能忽悠 90% 的人(可以拿阅读过《正直者之死》的设定作为挡箭牌——谁闲着没事会去看这个?);如果我看到评论区正在吵架,要假装自己是一般通过以自保,我会说我从未入坑。

这忽然让我想起了计算机领域的一项传统:你可以自称你热爱黑客技术(hacking),你也可以称呼你的好友为“黑客”(hacker),但你不能说你自己是“黑客”。“黑客”是一个头衔,是别人对你的尊称。只有足够多的人承认你为“黑客”之后,你才能自称“黑客”。在过去,“东方众”只是网民的自称,刷一刷“此生无悔入东方,来世愿生幻想乡”,那你就是“东方众”。而到了现在,“东方众”似乎也开始遵守这套“黑客”的逻辑了(实际上,可能从十几年前“伪宅”的自称出现后,这套逻辑就生效了)。如果这样理解,许多令人费解的问题就都能解释通了。我自己从未违反过“黑客守则“:我认识很多“黑客”,其中也有人也说我是”黑客“,但我不说我是“黑客”。这也更加坚定了我无论认识多少“东方”和“东方众”,也不会自称“东方众”的决心。

但另一方面,这个问题在客观上实际上很有趣,涉及到我究竟参与了哪一段 ACG 发展史,因此我也经过了一番基于客观数据的考证(使用stat(1)、浏览器开发工具获取微博时间戳等方法),结果如下:

  1. 候选答案:2012?可能这段时间观看过《琪露诺算术教室》、《最终鬼畜蓝蓝路》、《sweet little sister》(我还记得后者来自蓝蓝路的相关推荐,因为原曲同为U.N. Owen Was Her?)。不过暂无客观数据记录出土,无法证明具体时间。
  2. 候选答案:2013-08-02 07:20:39 +0000(硬盘文件《【音乐】精选东方同人曲【按角色分类】》下载时间)
  3. 候选答案:2013-08-31 18:29:20 +0000(硬盘文件《喜闻乐见的ACG歌曲》下载时间。其中包含当时举办的“世萌吧 ACG 音乐榜单 2008-2012 组”排名前 50 位歌曲,其中《色は匂へど散りぬるを》、《華鳥風月》分别取得第 5 名、第 48 名的成绩)
  4. 候选答案:2013-10-01 15:48:51 +0000(参与了当时 ACG 圈空前的《有屏幕的地方就有 Bad Apple!!》热潮,将其移植到了自己的树莓派 + SSD1306 的 LCD 屏幕上)
  5. 候选答案:2014-04-28 18:17:21 +0800(与网友聊天时调侃:“OpenBSD 与 OpenSSH 连点香油钱都没有,跟博丽神社一个情况)
  6. 候选答案:2014-07-17 17:14:48 +0800(从微博网友“@ACG音乐吧官博娘”抽奖得到一张 CD 专辑《EastNewSound - Felsic Mirage》)
  7. 候选答案:2014-07-24 04:53:55 +0000(将《魔理沙偷走了重要的东西》的 4chan 编程区 meme《阿伯尔森偷走了重要的课程》搬运至 B 站)
  8. 候选答案:2014-05-24 18:22:21 +0000(硬盘文件《【东方纯音乐】 蝉在叫 人坏掉!》下载时间)
  9. 候选答案:2015-01-30(几周前参加 Novell/SUSE 公司的北京办公室举办的 openSUSE 操作系统的新版本发布聚会,友人送了我几张东方角色贴纸)
  10. 候选答案:2016-01-22 04:51:50 +0000(硬盘文件《凋叶棕 - 屠》下载时间,以《正直者之死》为题材的专辑)
  11. 候选答案:2018-11-05 03:47:09 +0800(入坑复古计算,经过一个月的努力,成功将《Bad Apple!!》移植到 TI-84 PCSE 计算器上,内循环使用 Z80 汇编语言完成,但因为是半成品没有发表,演示视频在 2024 年分享至 B 站)
  12. 候选答案:2019-06-13 22:58:57 +0800(继续复古计算,顺手为一个 Atari 800 计算机的《Bad Apple!!》移植视频制作中文字幕并分享,在微博获得 800+ 转发)。
  13. 候选答案:2021-03-31 16:17:14 +0800(继续复古计算,天天折腾 Z80、6502、m68k 古董计算机相关内容,然后意外发现了东方同人音乐一个极为冷门的分支——复古平台的chiptune,一举三得——能听东方音乐,能结合复古计算爱好,此外东方曲风无论旧作新作本来就都深受 chiptune 影响,几乎直接当原作音乐听,相当于平替了原有的播放列表。当时天天在微博上分享仅有的少数 chiptune 专辑——美国的 C64 与 Amiga 平台的东方二创可能比熊猫还稀有。在 2021 年的这一天,因为我高强度分享专辑,被关注我的信息安全专家“教主” @tombkeeper 评论道:“原来你也是越共”)
  14. 候选答案:迄今为止,从未入坑(2010 年代的东方 Project 在 ACG 界,就相当于民用航空业的波音 707。如果你向一位当年的普通乘客提起波音 707,他们更可能回忆起年轻时候的事业,或者自己如何周游世界——而不是像本百科的各位大佬一样,讲解波音 707 的空气动力学设计或者发动机的技术创新。我就是当年的普通ALISON航空??????乘客——这也是我最倾向的答案。我的业余时间 90% 用在软件硬件开发,剩下 10% 不足研究任何 ACG 内容,只够了解一些梗,方便交流,毕竟 ACG 在中国大陆极客圈的地位好比《星际迷航》之于美国)
以下是一个真实的故事:从前有个小男孩,他的父亲说:“要学着像其他人一样。不要愁眉苦脸。”他试啊,试啊,但就是做不到。于是他父亲用皮鞭抽打他,然后狮子把他吃掉了。读者们,如果你还年轻,那么请将他的悲惨人生与死亡作为警告。[...] 你最好隐藏起来,假装自己是庄重、木讷的人。[...] 特别是如果你打算写书 [...] 要严谨:这样可以掩盖许多罪过。并且不要愁眉苦脸。
——《电磁学理论》第 3 卷,第 9 章《移动源产生的波》by 奥利弗·亥维赛
各种番剧的片名,一定给我牢记:我是个中二病、恋爱果然有问题、刀剑神域、未闻花名、柯南、奈亚子潜行,没有看过没关系,关键说话有底气。动漫的名场面,只看很多人刷的就行,“不要停下来啊!”、“kksk!“,不用担心梗的前后有没有联系,毕竟在虚构的世界找真实的一定有问题。“NicoNicoNii~”、“没什么好怕的了!”,今天问问神奇海螺也很喧嚣呢,“真是 High 到不行!”、“咋瓦鲁多!我不做人了!”、“你的下一句话是:‘你为什么这么熟练啊!’”。

但是有一些圈,最好别装熟练:V 家、拉拉人、车万众、月球人,普罗丢色、劈叉舰长——平时不会探头,惹恼他们后果就是被打屁滚尿流。作品也就图一乐,二创才是活力,出了圈的鬼畜、同人、手办一定要被唾弃。说他们恰烂钱,没骨气,骂得越凶,你就越像二次元菊苣……既然如此,不妨大方地说:你好,我不懂二次元。
——《装二次元?简单!两分钟教会你!》by 永远的MG

东方 OST 信息检索工具开发笔记:THBWiki 获取信息的歪门邪道

THBWiki 最有用的公共服务之一是音乐资料API,其中包含上万种东方音乐专辑的相关信息。然而,音乐资料 API 中只返回同人专辑信息(以及对应的原曲),但无法查询关于原曲本身的相关信息,如出场作品、关卡位置、中英日标题、作者评论等。我在开发东方原曲信息检索工具的时候,发现了许多利用 MediaWiki API 与 Wiki 语法本身获取原曲信息的多种歪门邪道方法,特别在此记载。

获取全部东方乐曲名称映射表

为了正规化东方乐曲的日文、中文、英文名称,THBWiki 使用了独有的 Table Mapping 这一 MediaWiki 插件,将角色头衔、角色能力、符卡名称、乐曲信息等属性作为 key-value 格式的数据结构统一管理。其技术细节,详见帮助:管理映射方案。这就为获取全部东方乐曲名称提供了极佳的技术手段。

本节介绍三种获取全部东方乐曲名称映射表,从简单的方法(效率低),一般的方法(效率低),再到走火入魔的方法(效率极高)。

难度 Easy:映射表 API

获取映射表信息的最普通方式,是使用映射表 API:action=tablemapping

CSRF

action=tablemapping 受 CSRF 攻击保护。要使用此 API,必须先获取 MediaWiki API 通用的 CSRF 安全令牌:

curl 'https://thwiki.cc/api.php'
     -d "action=query"
     -d "meta=tokens"
     -d "format=json" 2>/dev/null  | jq

系统返回:

{
  "batchcomplete": "",
  "query": {
    "tokens": {
      "csrftoken": "+\\"
    }
  }
}

所有 action=tablemapping 后续请求,都需要将csrftoken的字符串传入必选参数token

值得注意的是,API 任何人均可使用,无需登录。由于未登录用户不涉及安全问题,令牌一律为空占位符+\,所以实际上和没有一样。但考虑到 MediaWiki 后续修改占位符的可能性,不建议硬编码内容,还是额外请求一下为好。

使用 action=tablemapping, maction=list

使用 maction=list 可以获得某个分类的映射表。其中selectcat是可选参数,如果不填写,则列出全部映射表(角色头衔、角色能力、符卡名称等)。我们需要查询的是乐曲信息,因此传入参数selectcat=音乐名日文

使用例:

$ curl -X POST --data-urlencode "action=tablemapping" \
               --data-urlencode "format=json" \
               --data-urlencode "maction=list" \
               --data-urlencode "selectcat=音乐名日文" \
               --data-urlencode "token=+\\" \
               https://thwiki.cc/api.php 2>/dev/null | jq

"token=+\\"是未登录用户的统一 CSRF token,必填。为了安全,只能使用POST请求,无法使用GET。此外,因token字段含有特殊符号,必须使用--data-urlencode

系统返回:

{
  "tablemapping": {
    "result": "success",
    "schemes": [
      {
        "id": 1,
        "scheme": "红魔乡音乐名/日文"
      },
      {
        "id": 3,
        "scheme": "花映塚音乐名/日文"
      },
      {
        "id": 5,
        "scheme": "莲台野夜行音乐名/日文"
      },
      {
        "id": 7,
        "scheme": "蓬莱人形音乐名/日文"
      },
      {
        "id": 9,
        "scheme": "辉针城音乐名/日文"
      },

值得注意的是,一次不能查询多个映射表,但/日文后缀可以在得到结果后,人工修改为/英文/中文

使用 action=tablemapping, maction=browse

得到映射表标题后,使用 maction=browse 获得映射表内容。

使用例:

$ curl -X POST --data-urlencode "action=tablemapping" \
               --data-urlencode "format=json" \
               --data-urlencode "maction=browse" \
               --data-urlencode "scheme=红魔乡音乐名/日文" \
               --data-urlencode "token=+\\" \
               https://thwiki.cc/api.php 2>/dev/null | jq

系统返回:

{
  "tablemapping": {
    "result": "success",
    "scheme_data": {
      "id": "1",
      "scheme": "红魔乡音乐名/日文",
      "data": [
        {
          "index_text": "!CAT",
          "value_text": "音乐名日文"
        },
        {
          "index_text": "!DEF",
          "value_text": "缺少参数"
        },
        {
          "index_text": "!TEM",
          "value_text": "红魔乡音乐名"
        },
        {
          "index_text": "1|T",
          "value_text": "赤より紅い夢"
        },
        {
          "index_text": "2|1-1",
          "value_text": "ほおずきみたいに紅い魂"
        },
        {
          "index_text": "3|1-2",
          "value_text": "妖魔夜行"
        },
        {
          "index_text": "4|2-1",
          "value_text": "ルーネイトエルフ"
        },
        ...
        {
          "index_text": "!COR",
          "value_text": "东方红魔乡"
        },

一个 value 可能对应多个 key(游戏 Music Room 编号、游戏关卡编号、专辑编号等),用|分隔。此外,COR 字段指向该映射表所对应的作品,可以用于判断原作的所属类型。这一点在下文中会讲解。

效率问题

API action=tablemapping 最大的问题是效率低下。由于 API 不支持一次请求多个映射表,这会制造严重的请求放大效应。根据jq工具的统计,目前 THBwiki 的音乐名映射表共有 73 个。每个映射表有日、中、英文版本,请求次数放大三倍。这意味着,获取全部映射表需 219 次 HTTPS 请求。可见,重用 HTTPS 连接是极为有必要的,否则光 TLS 握手就握几百次。最后,缓存请求结果同样是重要的,建议只在程序初始化阶段请求一次,之后便不再请求。否则,这上百个请求很容易使你的工具成为管理员的眼中钉。如果管理员一怒之下禁用 API,那就谁都不能用了。

$ jq ".tablemapping.schemes | length" < list.json
73

由于效率问题,这迫使开发者天堂有路你不走,地狱无门你偏行。在下文中,我们将了解比 action=tablemapping 更高效的两种歪门邪道。

难度 Normal:expandtemplates 展开模板

THBWiki 为规范各种信息的格式,要求使用音乐名模板、角色模板等工具。这实际上,这些模板已经是变相的标准库函数或者 API 端点了。但这些模板只能被 MediaWiki 使用。除非客户端拥有完整的 MediaWiki 解析器、数据库与源代码(这显然是不可能的),否则客户端无法解析 MediaWiki 模板。MediaWiki 考虑到这种情况,十分贴心得为开发者准备了 expandtemplates API,可以运行任意模板,堪称 Remote Procedure Call。

音乐名模板的语法,见帮助:音乐名模板。模板名称是 maction=list 得到的映射表名称前缀(不包括/日文, /中文, /英文 后缀),第一个参数是语言代码,第二个参数是映射表的 key。

使用例:

《东方妖妖梦》5 面关底 Boss 的音乐是什么?

$ curl -X POST --data-urlencode "action=expandtemplates" \
               --data-urlencode "text={{妖妖梦音乐名|2|5-2}}" \
               --data-urlencode "prop=wikitext" \
               --data-urlencode "format=json" \
               https://thwiki.cc/api.php 2>/dev/null | jq

系统返回:

{
  "expandtemplates": {
    "wikitext": "広有射怪鳥事 ~ Till When?"
  }
}

音乐名模板摇身变成了神奇海螺,有问必答。

获取《东方红魔乡》音乐盒中的第一首歌曲的日、英、中文标题:

$ curl -X POST --data-urlencode "action=expandtemplates" \
               --data-urlencode "text={{红魔乡音乐名|2|1}},{{红魔乡音乐名|4|1}},{{红魔乡音乐名|1|1}}" \
               --data-urlencode "prop=wikitext" \
               --data-urlencode "format=json" \
               https://thwiki.cc/api.php 2>/dev/null | jq

系统返回:

{
  "expandtemplates": {
    "wikitext": "赤より紅い夢,A Dream More Scarlet than Red,比赤色更红的梦"
  }
}
效率问题

连接重用与缓存:这个 API 任何人都可使用,无需登录。同理,为防止滥用 THBWiki 资源而使你的程序成为管理员的眼中钉(最后导致公共 API 被禁用,谁都不能用),请求 API 时建议保持连接上下文以重用 HTTPS 连接,不要握手几千次。查询后的结果应该存入本地文件或数据库,只在首次初始化时获取信息。

GET 与 POST 请求:POST请求只适用于超长请求。如果内容简短,GET请求是更合适的,以免阻碍 Web 服务器的缓存机制(即使目前不存在,理论上也可以加入,而 POST 请求在 HTTP 层面不可缓存):

curl 'https://thwiki.cc/api.php' -G \
    -d 'action=expandtemplates' \
    -d 'text={{红魔乡音乐名|2|1}},{{红魔乡音乐名|4|1}},{{红魔乡音乐名|1|1}}' \
    -d 'prop=wikitext' -d 'format=json' \
    2>/dev/null | jq

获取大量模板内容:如需获取大量模板内容,与其发送多个 GET 请求,不如构造一个包含大量模板请求的字符串,用没有歧义的特殊符号分割(如 Newline、Tab、Unicode Zero-Width Space U+200B),然后发生 POST 请求。如果请求过长,则分批查询。用这个方法,一分钟很容易获得上千个展开结果。GET 字符串不能超过数千个字符,而 POST 字符串长度可达 MiB 级别。

模板展开是最灵活的数据获取方式,但是上文介绍的方法中,客户端仍需利用 maction=list 先得到映射表名称,获取所有日文映射表的完整内容(以获得所有的 key),然后构造wikitext以展开中文、英文的模板,最后再切分字符串获得结果。这依然会产生至少 75 次请求,效率低下。

难度 Hard:展开解析函数模板

在 MediaWiki 中,除了普通的 HTML 模板,还存在一些内建函数。在 THBWiki,系统的大量扩展能实现极其强大的编程功能,具体细节可查阅帮助:管理映射方案解析函数了解详情。以下,我们介绍几个重要的函数:

getmapnamegetmaparray 函数

getmapname:获取某个分类的映射表,相当于 maction=list

使用例:

$ wikitext='{{#getmapname:音乐名日文}}'
$ curl -X POST --data-urlencode "action=expandtemplates" \
               --data-urlencode "text=$wikitext" \
               --data-urlencode "prop=wikitext" \
               --data-urlencode "format=json" \
               https://thwiki.cc/api.php 2>/dev/null | jq

系统返回:

{   
  "expandtemplates": {
    "wikitext": "红魔乡音乐名/日文\n花映塚音乐名/日文\n莲台野夜行音乐名/日文\n蓬莱人形音乐名/日文\n辉针城音乐名/日文\n风神录音乐名/日文\n..."
   }
}

getmaparray:获取某个映射表中的 key-value,相当于 maction=browse

使用例:

$ wikitext='{{#getmaparray:红魔乡音乐名/日文}}'
$ curl -X POST --data-urlencode "action=expandtemplates" \
               --data-urlencode "text=$wikitext" \
               --data-urlencode "prop=wikitext" \
               --data-urlencode "format=json" \
               https://thwiki.cc/api.php 2>/dev/null | jq

系统返回:

{   
  "expandtemplates": {
    "wikitext": "音乐名日文\n东方红魔乡\n缺少参数\n6\n红魔乡音乐名\n赤より紅い夢\nほおずきみたいに紅い魂\n妖魔夜行\n..."
   }
}
arraymap 函数

以上两个操作与 action=tablemapping 有何区别?最重要的区别,就是 Wikitext 模板可以嵌套。一个函数的返回结果,可以作为下一个函数的输入,从而实现非常强大的数据处理效果。

其中,最为强大的函数是arraymap,它可以按将输入字符串INPUT_EXPRESSION利用分隔符INPUT_SEPARATOR进行分割。分割结果中的每一项,赋予变量名VARIABLE_NAME,然后将每一项加工为OUTPUT_EXPRESSION的值,最后将这些结果再利用分隔符 OUTPUT_SEPARATOR 进行分割,输出处理后的字符串。

{{#arraymap:
    INPUT_EXPRESSION|
    INPUT_SEPARATOR|
    VARIABLE_NAME|
    OUTPUT_EXPRESSION|
    OUTPUT_SEPARATOR
}}

因此,它相当于函数式编程中的array.map(lambda var: do_something(var)),或者过程式编程中的foreach。利用这一函数,可以轻松循环处理上一个函数的所有结果。例如,我们可以对音乐名日文的每一个结果进一步应用getmaparray,获得其中的乐曲名称,从而得到所有映射表中的所有乐曲名称。

$ wikitext=$(cat <<'EOF'
{{#arraymap:
    {{#getmapname:音乐名日文}}|
    \n|
    tablename|
    {{#getmaparray:tablename|\n|pair}}|
    ,
}}
EOF
)

$ curl -X POST --data-urlencode "action=expandtemplates" \
               --data-urlencode "text=$wikitext" \
               --data-urlencode "prop=wikitext" \
               --data-urlencode "format=json" \
               https://thwiki.cc/api.php 2>/dev/null | jq

系统返回:

{
  "expandtemplates": {
    "wikitext": "!CAT 音乐名日文\n!COR 东方红魔乡\n!DEF 缺少参数\n!SOR 6\n!TEM 红魔乡音乐名\n1 赤より紅い夢\n1-1 ほおずきみたいに紅い魂\n1-2 妖魔夜行\n2 ほおずきみたいに紅い魂\n...!CAT 音乐名日文\n!COR 东方花映塚\n!DEF 缺少参数\n!SOR 9\n!TEM 花映塚音乐名\n1 花映塚 ~ Higan Retour\n2 春色小径 ~ Colorful Path\n3 オリエンタルダークフライト\n4 フラワリングナイト\n5 東方妖々夢 ~ Ancient Temple\n6 狂気の瞳 ~ Invisible Full Moon\n7 おてんば恋娘の冒険\n8...!CAT 音乐名日文\n!COR 莲台野夜行\n!DEF 缺少参数\n!SOR 902\n!TEM 莲台野夜行音乐名\n1 夜のデンデラ野を逝く\n2 少女秘封倶楽部\n3 東方妖々夢 ~ Ancient Temple\n4 古の冥界寺\n5 幻視の夜 ~ Ghostly Eyes\n6 魔術師メリー\n7 月の妖鳥、化猫の幻\n8 過去の花 ~ Fairy of Flower\n9 魔法少女十字軍\n10 少女幻葬 ~ Necro-Fantasy\n11 幻想の永遠祭,!CAT 音乐名日文\n!COR 蓬莱人形\n!DEF 缺少参数\n!SOR 901\n!TEM 蓬莱人形音乐名\n1 蓬莱伝説\n2 二色蓮花蝶 ~ Red and White\n3 桜花之恋塚 ~ Japanese Flower\n4 明治十七年の上海アリス\n5 東方怪奇談\n6 エニグマティクドール\n7 サーカスレヴァリエ\n8 人形の森\n9 Witch of Love Potion\n10 リーインカーネイション\n11 U.N.オーエンは彼女なのか?\n12 永遠の巫女\n13 空飛ぶ巫女の不思議な毎日,!CAT 音乐名日文\n!COR 东方辉针城\n...“
  }
}
效率问题

可以看到,这种数据获取方式效率极高,相当于利用 Mediawiki 模板作为一种数据查询编程语言,把整个数据库直接转储出来了。整个乐曲映射表可以仅用 3 个 HTTP 请求全部获取,如果把以上代码重复三次,那么可以同时请求音乐名日文音乐名英文音乐名中文,只需 1 个请求即可获取全部数据。

难度 Lunatic:用解析函数手工构造 JSON 字符串

在以上教程中,我们利用 MediaWiki 解析函数成功获取了所有映射表中的所有字段。然而,应用程序依然需要对 API 返回的字符串进行后处理(如切分字符串),才能使用。如果同时请求多种语言,应用程序还需要额外的逻辑,将同一份映射表的不同语言版本关联起来。如果我们让 MediaWiki 返回的数据更结构化,就能大幅简化应用逻辑。

回忆一下,我们现有的模板用getmapname获取所有的日文模板名,切分出每一个模板名tablename,然后对每一个模板名,使用getmaparray获取其中的全部键、值,一行一对。

{{#arraymap:
    {{#getmapname:音乐名日文}}|
    \n|
    tablename|
    {{#getmaparray:tablename|\n|pair}}|
    ,
}}
切分每一行

我们可以进一步改进 MediaWiki 模板,对每一个映射表中的键、值切分为一行一个的形式。为了验证切分是否正确,我们用<item></item>包围每一行的内容,改进后的程序如下:

$ wikitext=$(cat <<'EOF'
{{#arraymap:
    {{#getmapname:音乐名日文}}|
    \n|
    tablename|
    {{#arraymap:
      {{#getmaparray:tablename|\n|pair}}|
      \n|
      line |
      <item>line</item> |
      \n
    }}|
    ,
}}
EOF
)

$ curl -X POST --data-urlencode "action=expandtemplates" \
               --data-urlencode "text=$wikitext" \
               --data-urlencode "prop=wikitext" \
               --data-urlencode "format=json" \
               https://thwiki.cc/api.php 2>/dev/null | jq

系统返回:

{
  "expandtemplates": {
    "wikitext": "<item>!CAT 音乐名日文</item>\n<item>!COR 东方红魔乡</item>\n<item>!DEF 缺少参数</item>\n<item>!SOR 6</item>\n<item>!TEM 红魔乡音乐名</item>\n<item>1 赤より紅い夢</item>\n<item>1-1 ほおずきみたいに紅い魂</item>\n<item>1-2 妖魔夜行</item>\n<item>2 ほおずきみたいに紅い魂</item>\n<item>2-1 ルーネイトエルフ</item>\n<item>2-2 おてんば恋娘</item>\n<item>3 妖魔夜行</item>\n<item>3-1 上海紅茶館 ~ Chinese Tea</item>\n<item>3-2 明治十七年の上海アリス</item>\n<item>4 ルーネイトエルフ</item>\n<item>4-1 ヴワル魔法図書館</item>\n<item>4-2 ラクトガール ~ 少女密室</item>\n<item>5 おてんば恋娘</item>\n<item>5-1 メイドと血の懐中時計</item>\n<item>..."
  }
}

可以看到,代码逻辑正确,每一行都被准确切分。

切分键与值

现在我们更进一步,我们对切分出的每一行进一步切分,从而可以使数据以 key: value 的格式显示出来。要实现这个目标,我们可以使用pos函数确定字符串中第一个空格的位置,然后用sub获得子字符串:这个空格位置之前的子字符串一律视为 key,这个空格之后的子字符串一律视为 value。为了提升代码可读性,我们可以用vardefine函数将切分结果存储至临时变量。我们还需要使用<nowiki>保护这个空格本身,防止其被解析器自动删除。

{{#vardefine:key_value_boundary | {{#pos:line|<nowiki> </nowiki>}}}}
{{#vardefine:rawkey | {{#sub:line|0|{{#var:key_value_boundary}}}}}}
{{#vardefine:rawvalue | {{#sub:line|{{#var:key_value_boundary}}}}}}

{{#var:rawkey}}: {{#var:rawvalue}}

事实上,rawvalue的位置并非{{#sub:line|{{#var:key_value_boundary}}而应该是{{#sub:line|{{#var:key_value_boundary + 1}}。然而,在模板中进行算术运算会使得代码更加复杂,所以这个错误可以将错就错——正如上文所说,rawvalue变量被赋值时,解析器会忽略参数中的空白符。

完整代码如下:

$ wikitext=$(cat <<'EOF'
{{#arraymap:
    {{#getmapname:音乐名日文}}|
    \n|
    tablename|
    {{#arraymap:
      {{#getmaparray:tablename|\n|pair}}|
      \n|
      line |
      {{#vardefine:key_value_boundary | {{#pos:line|<nowiki> </nowiki>}}}}
      {{#vardefine:rawkey | {{#sub:line|0|{{#var:key_value_boundary}}}}}}
      {{#vardefine:rawvalue | {{#sub:line|{{#var:key_value_boundary}}}}}}
      {{#var:rawkey}}: {{#var:rawvalue}} |
      \n
    }}|
    ,
}}
EOF
)

$ curl -X POST --data-urlencode "action=expandtemplates" \
               --data-urlencode "text=$wikitext" \
               --data-urlencode "prop=wikitext" \
               --data-urlencode "format=json" \
               https://thwiki.cc/api.php 2>/dev/null | jq

系统返回:

{
  "expandtemplates": {
    "wikitext": "!CAT: 音乐名日文\n!COR: 东方红魔乡\n!DEF: 缺少参数\n!SOR: 6\n!TEM: 红魔乡音乐名\n1: 赤より紅い夢\n1-1: ほおずきみたいに紅い魂\n1-2: 妖魔夜行\n2: ほおずきみたいに紅い魂\n2-1: ルーネイトエルフ\n2-2: おてんば恋娘\n3: 妖魔夜行\n3-1: 上海紅茶館 ~ Chinese Tea\n3-2: 明治十七年の上海アリス\n4: ルーネイトエルフ\n4-1: ヴワル魔法図書館\n4-2: ラクトガール ~ 少女密室\n5: おてんば恋娘\n5-1: メイドと血の懐中時計\n5-2: 月時計 ~ ルナ・ダイアル\n..."
  }
}
构造 JSON 键与值格式

现在的键与值已经对机器十分友好了,我们接下来只需要继续略微改进输出格式。

转义引号

我们将keyvalue的字符串用引号包围,为了防止字符串本身含有引号的情况,使用replace函数将所有的"替换为\"

{{#vardefine:key_value_boundary | {{#pos:line|<nowiki> </nowiki>}}}}
{{#vardefine:rawkey | {{#sub:line|0|{{#var:key_value_boundary}}}}}}
{{#vardefine:rawvalue | {{#sub:line|{{#var:key_value_boundary}}}}}}
{{#vardefine:key | {{#replace:{{#var:rawkey}}|"|\"}}}}
{{#vardefine:value | {{#replace:{{#var:rawvalue}}|"|\"}}}}

"{{#var:key}}": "{{#var:value}}"
加入方括号与花括号

接下来,我们将外循环(循环变量tablename)插入如下字符,包围内循环产生的key: value字符串:

[
    {
        "tablename", {
            inner string
        }
    }
]

即:

[
  {{#arraymap:
    {{#getmapname:音乐名日文}}|
    \n|
    tablename|
    {
    "tablename": {
      {{#arraymap:
        {{#getmaparray:tablename|\n|pair}}|
        \n|
        line |
        .... |
        ,
      }}
      }
      }|
      ,
  }}
]
第一个成功的 JSON 输出

完整代码如下:

$ wikitext=$(cat <<'EOF'
[
  {{#arraymap:
    {{#getmapname:音乐名日文}}|
    \n|
    tablename|
    {
    "tablename": {
      {{#arraymap:
        {{#getmaparray:tablename|\n|pair}}|
        \n|
        line |
        {{#vardefine:key_value_boundary | {{#pos:line|<nowiki> </nowiki>}}}}
        {{#vardefine:rawkey | {{#sub:line|0|{{#var:key_value_boundary}}}}}}
        {{#vardefine:rawvalue | {{#sub:line|{{#var:key_value_boundary}}}}}}
        {{#vardefine:key | {{#replace:{{#var:rawkey}}|"|\"}}}}
        {{#vardefine:value | {{#replace:{{#var:rawvalue}}|"|\"}}}}
        "{{#var:key}}": "{{#var:value}}" |
        ,
      }}
      }
      }|
      ,
  }}
]
EOF
)

$ curl -X POST --data-urlencode "action=expandtemplates" \
               --data-urlencode "text=$wikitext" \
               --data-urlencode "prop=wikitext" \
               --data-urlencode "format=json" \
               https://thwiki.cc/api.php 2>/dev/null | jq

系统返回:

{
  "expandtemplates": {
    "wikitext": "[\n  {\n    \"红魔乡音乐名/日文\": {\n      \"!CAT\": \"音乐名日文\",\"!COR\": \"东方红魔乡\",\"!DEF\": \"缺少参数\",\"!SOR\": \"6\",\"!TEM\": \"红魔乡音乐名\",\"1\": \"赤より紅い夢\",\"1-1\": \"ほおずきみたいに紅い魂\",\"1-2\": \"妖魔夜行\",\"2\": \"ほおずきみたいに紅い魂\",\"2-1\": \"ルーネイトエルフ\",\"2-2\": \"おてんば恋娘\",\"3\": \"妖魔夜行\",\"3-1\": \"上海紅茶館 ~ Chinese Tea\",\"3-2\": \"明治十七年の上海アリス\",\"4\": \"ルーネイトエルフ\",\"4-1\": \"ヴワル魔法図書館\",\"4-2\": \"ラクトガール ~ 少女密室\",\"5\": \"おてんば恋娘\",\"5-1\": \"メイドと血の懐中時計\",\"5-2\": \"月時計 ~ ルナ・ダイアル\",\"6\": \"上海紅茶館 ~ Chinese Tea\",\"6-1\": \"ツェペシュの幼き末裔\",\"6-2\": \"亡き王女の為のセプテット\",\"7\": \"明治十七年の上海アリス\",\"7-1\": \"魔法少女達の百年祭\",\"7-2\": \"U.N.オーエンは彼女なのか?\",\"8\": \"ヴワル魔法図書館\",\"9\": \"ラクトガール ~ 少女密室\",\"10\": \"メイドと血の懐中時計\",\"11\": \"月時計 ~ ルナ・ダイアル\",\"12\": \"ツェペシュの幼き末裔\",\"13\": \"亡き王女の為のセプテット\",\"14\": \"魔法少女達の百年祭\",\"15\": \"U.N.オーエンは彼女なのか?\",\"16\": \"紅より儚い永遠\",\"17\": \"紅楼 ~ Eastern Dream...\",\"E\": \"紅より儚い永遠\",\"S\": \"紅楼 ~ Eastern Dream...\",\"T\": \"赤より紅い夢\"\n      }\n      },............\n]"
  }
}

我们成功将输出结果构造为了一个符合 JSON 标准的字符串。由于这个字符串本身是依靠外层 JSON 包装的,这个内层字符串本身可以作为 JSON 再解析一次。这可以靠 jqfromjson过滤器实现。

$ curl -X POST --data-urlencode "action=expandtemplates" \
               --data-urlencode "text=$wikitext" \
               --data-urlencode "prop=wikitext" \
               --data-urlencode "format=json" \
               https://thwiki.cc/api.php 2>/dev/null | jq ".expandtemplates.wikitext | fromjson"

系统返回:

[
  {
    "红魔乡音乐名/日文": {
      "!CAT": "音乐名日文",
      "!COR": "东方红魔乡",
      "!DEF": "缺少参数",
      "!SOR": "6",
      "!TEM": "红魔乡音乐名",
      "1": "赤より紅い夢",
      "1-1": "ほおずきみたいに紅い魂",
      "1-2": "妖魔夜行",
      "2": "ほおずきみたいに紅い魂",
      "2-1": "ルーネイトエルフ",
      "2-2": "おてんば恋娘",
      ...
    }
  },
  {
    "花映塚音乐名/日文": {
      "!CAT": "音乐名日文",
      "!COR": "东方花映塚",
      "!DEF": "缺少参数",
      "!SOR": "9",
      "!TEM": "花映塚音乐名",
      "1": "花映塚 ~ Higan Retour",
      "2": "春色小径 ~ Colorful Path",
      "3": "オリエンタルダークフライト",
      "4": "フラワリングナイト",
      "5": "東方妖々夢 ~ Ancient Temple",
      ...
    }
  },
  ...
}
构造多语言 JSON

现在,我们已经成功利用维基模板这一“函数式编程语言”,将映射表扩展的全部日文乐曲数据构造为了 JSON 字符串。接下来,我们将这一成果扩展到多语言版本。

要做到这一点,我们可以利用explode函数将模板的前缀提取。接着,我们再手工构造三个字符串,分别是该模板的日文、中文、英文版本:

{{#vardefine:table_basename | {{#explode:tablename|/|0}}}}
{{#vardefine:tablename_ja | {{#var:table_basename}}/日文}}
{{#vardefine:tablename_en | {{#var:table_basename}}/英文}}
{{#vardefine:tablename_zh_hans | {{#var:table_basename}}/中文}}

然后,我们在模板中加入更多花括号,使得输出符合这样的格式:

[
    {
        "table_basename", {
            "ja": {  },
            "en": {  },
            "zh-hans": {  },
        }
    }
]

即:

[
  {{#arraymap:
    {{#getmapname:音乐名日文}}|
    \n|
    tablename|
    {{#vardefine:table_basename | {{#explode:tablename|/|0}}}}
    {{#vardefine:tablename_ja | {{#var:table_basename}}/日文}}
    {{#vardefine:tablename_en | {{#var:table_basename}}/英文}}
    {{#vardefine:tablename_zh_hans | {{#var:table_basename}}/中文}}
    {
    "{{#var:table_basename}}": {
      "ja": {
        {{#arraymap:
          {{#getmaparray:{{#var:tablename_ja}}|\n|pair}}|
          \n|
          line |
          .... |
          ,
        }}
      },
      "en": {
        {{#arraymap:
          {{#getmaparray:{{#var:tablename_en}}|\n|pair}}|
          \n|
          line |
          .... |
          ,
        }}
      },
      "zh-hans": {
        {{#arraymap:
          {{#getmaparray:{{#var:tablename_zh_hans}}|\n|pair}}|
          \n|
          line |
          .... |
          ,
        }}
      }
    }
    }|
    ,
  }}
]

然后我们再将提取 key-value 的所有内循环命令重复,三回啊三回!

$ wikitext=$(cat <<'EOF'
[
  {{#arraymap:
    {{#getmapname:音乐名日文}}|
    \n|
    tablename|
    {{#vardefine:table_basename | {{#explode:tablename|/|0}}}}
    {{#vardefine:tablename_ja | {{#var:table_basename}}/日文}}
    {{#vardefine:tablename_en | {{#var:table_basename}}/英文}}
    {{#vardefine:tablename_zh_hans | {{#var:table_basename}}/中文}}
    {
    "{{#var:table_basename}}": {
      "ja": {
        {{#arraymap:
          {{#getmaparray:{{#var:tablename_ja}}|\n|pair}}|
          \n|
          line |
          {{#vardefine:key_value_boundary | {{#pos:line|<nowiki> </nowiki>}}}}
          {{#vardefine:rawkey | {{#sub:line|0|{{#var:key_value_boundary}}}}}}
          {{#vardefine:rawvalue | {{#sub:line|{{#var:key_value_boundary}}}}}}
          {{#vardefine:key | {{#replace:{{#var:rawkey}}|"|\"}}}}
          {{#vardefine:value | {{#replace:{{#var:rawvalue}}|"|\"}}}}
          "{{#var:key}}": "{{#var:value}}" |
          ,
        }}
      },
      "en": {
        {{#arraymap:
          {{#getmaparray:{{#var:tablename_en}}|\n|pair}}|
          \n|
          line |
          {{#vardefine:key_value_boundary | {{#pos:line|<nowiki> </nowiki>}}}}
          {{#vardefine:rawkey | {{#sub:line|0|{{#var:key_value_boundary}}}}}}
          {{#vardefine:rawvalue | {{#sub:line|{{#var:key_value_boundary}}}}}}
          {{#vardefine:key | {{#replace:{{#var:rawkey}}|"|\"}}}}
          {{#vardefine:value | {{#replace:{{#var:rawvalue}}|"|\"}}}}
          "{{#var:key}}": "{{#var:value}}" |
          ,
        }}
      },
      "zh-hans": {
        {{#arraymap:
          {{#getmaparray:{{#var:tablename_zh_hans}}|\n|pair}}|
          \n|
          line |
          {{#vardefine:key_value_boundary | {{#pos:line|<nowiki> </nowiki>}}}}
          {{#vardefine:rawkey | {{#sub:line|0|{{#var:key_value_boundary}}}}}}
          {{#vardefine:rawvalue | {{#sub:line|{{#var:key_value_boundary}}}}}}
          {{#vardefine:key | {{#replace:{{#var:rawkey}}|"|\"}}}}
          {{#vardefine:value | {{#replace:{{#var:rawvalue}}|"|\"}}}}
          "{{#var:key}}": "{{#var:value}}" |
          ,
        }}
      }
    }
    }|
    ,
  }}
]
EOF
)
最终成果

使用例(需要运行上小结末尾的bash变量定义):

$ curl -X POST --data-urlencode "action=expandtemplates" \
               --data-urlencode "text=$wikitext" \
               --data-urlencode "prop=wikitext" \
               --data-urlencode "format=json" \
               https://thwiki.cc/api.php 2>/dev/null | jq ".expandtemplates.wikitext | fromjson"

系统返回:

[
  {
    "红魔乡音乐名": {
      "ja": {
        "!CAT": "音乐名日文",
        "!COR": "东方红魔乡",
        "!DEF": "缺少参数",
        "!SOR": "6",
        "!TEM": "红魔乡音乐名",
        "1": "赤より紅い夢",
        "1-1": "ほおずきみたいに紅い魂",
        "1-2": "妖魔夜行",
        ...
      },
      "en": {
        "!CAT": "音乐名英文",
        "!DEF": "缺少参数",
        "!TEM": "红魔乡音乐名",
        "1": "A Dream More Scarlet than Red",
        "1-1": "A Soul as Scarlet as a Ground Cherry",
        "1-2": "Apparitions Stalk the Night",
        ...
      },
      "zh-hans": {
        "!CAT": "音乐名中文",
        "!DEF": "缺少参数",
        "!TEM": "红魔乡音乐名",
        "1": "比赤色更红的梦",
        "1-1": "如鬼灯般的红色之魂",
        "1-2": "妖魔夜行",
        ...
      }
    }
  },
  {
    "花映塚音乐名": {
      "ja": {
        "!CAT": "音乐名日文",
        "!COR": "东方花映塚",
        "!DEF": "缺少参数",
        "!SOR": "9",
        "!TEM": "花映塚音乐名",
        "1": "花映塚 ~ Higan Retour",
        "2": "春色小径 ~ Colorful Path",
        "3": "オリエンタルダークフライト",
        ...
      },
      "en": {
        "!CAT": "音乐名英文",
        "!DEF": "缺少参数",
        "!TEM": "花映塚音乐名",
        "1": "Flower Reflecting Mound ~ Higan Retour",
        "2": "Spring Lane ~ Colorful Path",
        "3": "Oriental Dark Flight",
        ...
      },
      "zh-hans": {
        "!CAT": "音乐名中文",
        "!DEF": "缺少参数",
        "!TEM": "花映塚音乐名",
        "1": "花映塚 ~ Higan Retour",
        "2": "春色小径 ~ Colorful Path",
        "3": "Oriental Dark Flight",
        ...
      }
    }
  },
  {
    "莲台野夜行音乐名": {
      "ja": {
        "!CAT": "音乐名日文",
        "!COR": "莲台野夜行",
        "!DEF": "缺少参数",
        "!SOR": "902",
        "!TEM": "莲台野夜行音乐名",
        "1": "夜のデンデラ野を逝く",
        "2": "少女秘封倶楽部",
        "3": "東方妖々夢 ~ Ancient Temple",
        ...
      },
      "en": {
        "!CAT": "音乐名英文",
        "!DEF": "缺少参数",
        "!TEM": "莲台野夜行音乐名",
        "1": "Passing on Through the Dendera Fields in the Night",
        "2": "Girls' Sealing Club",
        "3": "Eastern Ghostly Dream ~ Ancient Temple",
        ...
      },
      "zh-hans": {
        "!CAT": "音乐名中文",
        "!DEF": "缺少参数",
        "!TEM": "莲台野夜行音乐名",
        "1": "走在夜晚的莲台野",
        "2": "少女秘封俱乐部",
        "3": "东方妖妖梦 ~ Ancient Temple",
        ...
      }
    }
  },
  {
    "蓬莱人形音乐名": {
      "ja": {
        "!CAT": "音乐名日文",
        "!COR": "蓬莱人形",
        "!DEF": "缺少参数",
        "!SOR": "901",
        "!TEM": "蓬莱人形音乐名",
        "1": "蓬莱伝説",
        "2": "二色蓮花蝶 ~ Red and White",
        "3": "桜花之恋塚 ~ Japanese Flower",
        ...
      },
      "en": {
        "!CAT": "音乐名英文",
        "!DEF": "缺少参数",
        "!TEM": "蓬莱人形音乐名",
        "1": "Legend of Hourai",
        "2": "Dichromatic Lotus Butterfly ~ Red and White",
        "3": "Lovely Mound of Cherry Blossoms ~ Japanese Flower",
        ...
      },
      "zh-hans": {
        "!CAT": "音乐名中文",
        "!DEF": "缺少参数",
        "!TEM": "蓬莱人形音乐名",
        "1": "蓬莱传说",
        "2": "二色莲花蝶 ~ Red and White",
        "3": "樱花之恋塚 ~ Japanese Flower",
        ...
      }
    }
  },
  ...
]

效果拔群!

query, list=categorymembers 获取全部含有音乐的原作

THBWiki 所有的音乐都属于 Category:Music_Room 分类,而 MediaWiki 提供了 query 这一 API 获取同一分类下的全部页面。

使用例:

$ curl 'https://thwiki.cc/api.php' -G \
    -d 'action=query' -d 'list=categorymembers' \
    -d 'cmlimit=50' -d 'cmtitle=Category:Music_Room' \
    -d 'format=json' \
    2>/dev/null | jq

系统返回:

{
  "batchcomplete": "",
  "query": {
    "categorymembers": [
      {
        "pageid": 179698,
        "ns": 0,
        "title": "东方兽王园/Music"
      },
      {
        "pageid": 49061,
        "ns": 0,
        "title": "东方凭依华/Music"
      },
      {
        "pageid": 151115,
        "ns": 0,
        "title": "东方刚欲异闻/Music"
      },
      ...

query, prop=revisions 获取 Music Room 页面源代码

MediaWiki 的 query, prop=revisions API 可以获取任意页面的源代码。

每一个原作的 Music Room 都使用了 Music Room 模板进行编写,这似乎是 THBWiki 特有的一种扩展语法,而不是 MediaWiki 的模板。不过因为语法简单,是简单的 K-V 格式,并不难自己解析。其文件见帮助:翻译表

使用例:

$ curl 'https://thwiki.cc/api.php' -G \
    -d 'action=query' -d 'prop=revisions' \
    -d 'rvprop=content' -d 'rvslots=main' \
    -d 'titles=东方兽王园/Music' \
    -d 'format=json' -d 'formatversion=2' \
    2>/dev/null | jq

系统返回:

{
  "batchcomplete": true,
  "query": {
    "pages": [
      {
        "pageid": 179698,
        "ns": 0,
        "title": "东方兽王园/Music",
        "revisions": [
          {
            "slots": {
              "main": {
                "contentmodel": "wikitext",
                "contentformat": "text/x-wiki",
                "content": "__MUSICROOM__\n*本词条内容为官方游戏TH19'''东方兽王园'''的Music Room\n*如果发现翻译问题可进行改正\n*翻译:[[用户:140706inu|140706inu]]\n__NOTOC__\n\n== MusicRoom ==\ncategory\n标题画面\ntitleJA = {{兽王园音乐名|2|1}}\ntitleZH = [[{{兽王园音乐名|1|1}}]]\ncomposer = ZUN\nmp3 = {{音乐室音频文件|th19_01.mp3}}\nja\n タイトル画面のテーマです。\n \n 獣っぽく力強いタイトル画面にしました。\n 重厚さと儚さ、力強さと無力さ、そういった矛盾する要素が\n 同居した獣の知性を表現しています。\nzh\n 标题画面主题曲。\n \n 本次采用了颇具兽性、充满力量感的标题画面。\n 厚重与纤弱、强大与无力,\n 这些相互矛盾的特征表现着集群而居的兽类的智慧。\ncategory\n[[博丽灵梦]]\n剧情模式前期主题曲\ntitleJA = {{兽王园音乐名|2|2}}\ntitleZH = [[{{兽王园音乐名|1|2}}]]\ncomposer = ZUN\nmp3 = {{音乐室音频文件|th19_02.mp3}}\nja\n 博麗 霊夢 & ストーリーモード序盤のテーマです。\n \n 
...

番外篇:用 Wikidata 获取东方原作版本号与日、中、英标题

东方原作的作品名有时用日文表示、有时用英文表示、有时用中文表示。开发相关工具时,建议将所有原作统一用版本号表示。

那么如何获得原作版本与标题的映射表呢?如果解析文本,那方法有很多,但都比较费力。幸运的是,维基百科背后有一个强大的“维基数据”(Wikidata),这可能是整个人类互联网上最大的公开数据库之一,几乎包罗万象(许多维基百科新条目的资料都是用数据形式,而非模板表示的),其中也自然包括东方 Project 作品信息。

只需要使用 SPARQL 语言向 Wikidata 的 API (https://query.wikidata.org/sparql) 提交一个查询,就可以获得东方作品信息了。可以使用页面 https://query-main.wikidata.org/ 在浏览器中测试。

使用例:

SELECT ?game ?thRelease ?titleJa ?titleEn ?titleZh ?titleZhHans WHERE {
    wd:Q907907 p:P527 [
        ps:P527 ?game ;
        pq:P1545 ?thReleaseValue
    ] .
    BIND(CONCAT("TH", ?thReleaseValue) as ?thRelease)
    OPTIONAL { ?game rdfs:label ?titleJa FILTER(LANG(?titleJa) = "ja") }
    OPTIONAL { ?game rdfs:label ?titleEn FILTER(LANG(?titleEn) = "en") }
    OPTIONAL { ?game rdfs:label ?titleZh FILTER(LANG(?titleZh) = "zh") }
    OPTIONAL { ?game rdfs:label ?titleZhHans FILTER(LANG(?titleZhHans) = "zh-hans") }
}
ORDER BY xsd:float(?thReleaseValue)

其中Q907907是《东方 Project》对象编号,P527是下属作品,P1545是下属作品的发行编号。

系统返回:

gamethReleasetitleJatitleEntitleZhtitleZhHans
http://www.wikidata.org/entity/Q5367731TH1東方靈異伝 〜 Highly Responsive to Prayers.Touhou Reiiden ~ Highly Responsive to Prayers東方靈異傳 ~ Highly Responsive to Prayers.东方灵异传 ~ Highly Responsive to Prayers.
http://www.wikidata.org/entity/Q5368031TH2東方封魔録 〜 the Story of Eastern Wonderland.Touhou Fuumaroku ~ Story of Eastern Wonderland東方封魔錄 ~ the Story of Eastern Wonderland.东方封魔录 ~ the Story of Eastern Wonderland.
http://www.wikidata.org/entity/Q5367726TH3東方夢時空 〜 Phantasmagoria of Dim.Dream.Touhou Yumejikuu ~ Phantasmagoria of Dimensional Dream東方夢時空 ~ Phantasmagoria of Dim.Dream.东方梦时空 ~ Phantasmagoria of Dim.Dream.
http://www.wikidata.org/entity/Q5368459TH4東方幻想郷 〜 Lotus Land Story.Touhou Gensoukyou ~ Lotus Land Story東方幻想鄉 ~ Lotus Land Story.东方幻想乡 ~ Lotus Land Story.
http://www.wikidata.org/entity/Q5367349TH5東方怪綺談 〜 Mystic Square.Touhou Kaikidan ~ Mystic Square東方怪綺談 ~ Mystic Square.东方怪绮谈 ~ Mystic Square.
http://www.wikidata.org/entity/Q1075123TH6東方紅魔郷 〜 the Embodiment of Scarlet Devil.Touhou Koumakyou ~ The Embodiment of Scarlet Devil東方紅魔鄉 ~ the Embodiment of Scarlet Devil.东方红魔乡 ~ the Embodiment of Scarlet Devil.
http://www.wikidata.org/entity/Q1042119TH7東方妖々夢 〜 Perfect Cherry Blossom.Touhou Youyoumu ~ Perfect Cherry Blossom東方妖妖夢 ~ Perfect Cherry Blossom.东方妖妖梦 ~ Perfect Cherry Blossom.
http://www.wikidata.org/entity/Q1050071TH7.5東方萃夢想 〜 Immaterial and Missing Power.Touhou Suimusou ~ Immaterial and Missing Power東方萃夢想 ~ Immaterial and Missing Power.东方萃梦想 ~ Immaterial and Missing Power.
http://www.wikidata.org/entity/Q1042155TH8東方永夜抄 〜 Imperishable Night.Touhou Eiyashou ~ Imperishable Night東方永夜抄 ~ Imperishable Night.东方永夜抄 ~ Imperishable Night.
http://www.wikidata.org/entity/Q1050849TH9東方花映塚 〜 Phantasmagoria of Flower View.Touhou Kaeidzuka ~ Phantasmagoria of Flower View東方花映塚 ~ Phantasmagoria of Flower View.东方花映冢 ~ Phantasmagoria of Flower View.
http://www.wikidata.org/entity/Q1050387TH9.5東方文花帖 〜 Shoot the Bullet.Touhou Bunkachou ~ Shoot the Bullet東方文花帖 ~ Shoot the Bullet.东方文花帖 ~ Shoot the Bullet.
http://www.wikidata.org/entity/Q1045759TH10東方風神録 〜 Mountain of Faith.Touhou Fuujinroku ~ Mountain of Faith東方風神錄 ~ Mountain of Faith.东方风神录 ~ Mountain of Faith.
http://www.wikidata.org/entity/Q5228137TH10.5東方緋想天 〜 Scarlet Weather Rhapsody.Touhou Hisouten ~ Scarlet Weather Rhapsody東方緋想天 ~ Scarlet Weather Rhapsody.东方绯想天 ~ Scarlet Weather Rhapsody.
http://www.wikidata.org/entity/Q867949TH11東方地霊殿 〜 Subterranean Animism.Touhou Chireiden ~ Subterranean Animism東方地靈殿 ~ Subterranean Animism.东方地灵殿 ~ Subterranean Animism.
http://www.wikidata.org/entity/Q860055TH12東方星蓮船 〜 Undefined Fantastic Object.Touhou Seirensen ~ Undefined Fantastic Object東方星蓮船 ~ Undefined Fantastic Object.东方星莲船 ~ Undefined Fantastic Object.
http://www.wikidata.org/entity/Q1050081TH12.3東方非想天則 〜 超弩級ギニョルの謎を追えTouhou Hisoutensoku东方非想天则 ~ 追寻特大型人偶之谜东方非想天则 ~ 追寻特大型人偶之谜
http://www.wikidata.org/entity/Q867001TH12.5ダブルスポイラー 〜 東方文花帖Double Spoiler ~ Touhou BunkachouDouble Spoiler ~ 東方文花帖Double Spoiler ~ 东方文花帖
http://www.wikidata.org/entity/Q867003TH12.8妖精大戦争 〜 東方三月精Yousei Daisensou ~ Touhou Sangetsusei妖精大戰爭 ~ 東方三月精妖精大战争 ~ 东方三月精
http://www.wikidata.org/entity/Q1051713TH13東方神霊廟 〜 Ten Desires.Touhou Shinreibyou ~ Ten Desires東方神靈廟 ~ Ten Desires.东方神灵庙 ~ Ten Desires.
http://www.wikidata.org/entity/Q5899909TH13.5東方心綺楼 〜 Hopeless Masquerade.Touhou Shinkirou ~ Hopeless Masquerade東方心綺樓 ~ Hopeless Masquerade.
http://www.wikidata.org/entity/Q13218187TH14東方輝針城 〜 Double Dealing Character.Touhou Kishinjou ~ Double Dealing Character東方輝針城 ~ Double Dealing Character.
http://www.wikidata.org/entity/Q16866101TH14.3弾幕アマノジャク 〜 Impossible Spell Card.Danmaku Amanojaku ~ Impossible Spell Card彈幕天邪鬼 ~ Impossible Spell Card
http://www.wikidata.org/entity/Q19364189TH14.5東方深秘録 〜 Urban Legend in Limbo.Touhou Shinpiroku ~ Urban Legend in Limbo东方深秘录 ~ Urban Legend in Limbo.
http://www.wikidata.org/entity/Q19891042TH15東方紺珠伝 〜 Legacy of Lunatic Kingdom.Touhou Kanjuden ~ Legacy of Lunatic Kingdom東方紺珠傳 ~ Legacy of Lunatic Kingdom.
http://www.wikidata.org/entity/Q28689822TH15.5東方憑依華 〜 Antinomy of Common Flowers.Touhou Hyouibana ~ Antinomy of Common Flowers東方憑依華 ~ Antinomy of Common Flowers.
http://www.wikidata.org/entity/Q30899159TH16東方天空璋 〜 Hidden Star in Four Seasons.Touhou Tenkuushou ~ Hidden Star in Four Seasons東方天空璋 ~ Hidden Star in Four Seasons.
http://www.wikidata.org/entity/Q55652942TH16.5秘封ナイトメアダイアリー 〜 Violet Detector.Hifuu Nightmare Diary ~ Violet Detector秘封夢魘日記 ~ Violet Detector.
http://www.wikidata.org/entity/Q63208650TH17東方鬼形獣 〜 Wily Beast and Weakest Creature.Touhou Kikeijuu ~ Wily Beast and Weakest Creature東方鬼形獸 ~ Wily Beast and Weakest Creature.
http://www.wikidata.org/entity/Q70207024TH17.5東方剛欲異聞 〜 水没した沈愁地獄Touhou Gouyoku Ibun ~ Sunken Fossil World东方刚欲异闻 ~ 被水淹没的沉愁地狱
http://www.wikidata.org/entity/Q105707774TH18東方虹龍洞 〜 Unconnected Marketeers.Touhou Kouryuudou ~ Unconnected Marketeers東方虹龍洞 ~ Unconnected Marketeers.
http://www.wikidata.org/entity/Q113195313TH18.5バレットフィリア達の闇市場 〜 100th Black Market.Barettofiriatachi no Yami-Ichiba ~ 100th Black Market弹幕狂们的黑市 ~ 100th Black Market.
http://www.wikidata.org/entity/Q118255366TH19東方獣王園 〜 Unfinished Dream of All Living Ghost.Touhou Juuouen ~ Unfinished Dream of All Living Ghost東方獸王園 ~ Unfinished Dream of All Living Ghost.
http://www.wikidata.org/entity/Q133858296TH20東方錦上京 〜 Fossilized Wonders.Touhou Kinjoukyou ~ Fossilized Wonders东方锦上京_~_Fossilized_Wonders.

唯一的不足是,有些作品并没有简体中文(zh-hans)信息,需要本地做繁简转换;还有些标题不规范,例如《东方锦上京》的空格变成下划线,应该是有维基百科编辑者直接复制粘贴链接所致,需要本地替换一下。不过这类问题最佳的解决方法,是直接用维基百科账号编辑 Wikidata,修正全人类的知识库。

工作流程

总体工作流程如下:

  1. 使用方法 2 获取所有 Music Room 条目。
  2. 使用方法 3 对于每个 Music Room 条目,获取源代码。
  3. 解析 Music Room 数据的每个字段,将其转化为字典、数组等数据结构。
  4. 对于所有出现在 Music Room 的音乐名模板,那么使用方法 1 获取原曲名称。Music Room 数据中并没有英文标题,但可以通过自行替换参数中的语言代码解决。
  5. 将处理后的记录保存为 JSON、YAML、TOML、SQL 等格式本地存取,避免重复查询 THBWiki。此外,程序调试时,建议缓存请求参数完全一致的 URL 到本地,如果检测到请求一致,就直接重放结果。

角色与场景信息的提取

每一首音轨有可能对应一个或多个游戏场景,或者对应一个或多个角色,甚至还包括“角色限定的场景曲”(而非角色曲)。这几种信息同样应该作为“上下文”属性以机器可读的形式存储。

角色模板展开

按照编辑规范,每个角色都应该是一个维基链接。而为了方便编辑,链接文字(角色名称)可能是由帮助:角色名模板动态生成的。例如,音轨《小小的贤将》的 category 源代码为:

1面BOSS [[{{Naz}}]]角色曲

我们需要先将{{Naz}}展开为[[娜兹玲]],然后再提取所有的维基链接,得到文本娜兹玲

文本格式

在 Music Room 的category字段,一种有以下几种格式:

1 行,1 个场景,无角色:

"魔界6至9面主题曲"
"[[魔法森林]]场景用曲"
"神灵庙(场景)"
"原对话曲"
"对话曲1"
"对话用曲1"
"Ending画面主题曲"
"角色选择画面"

2 行,2 个场景,无角色

"1至4面\n地狱16至19面主题曲"

2 行,1 个场景,1 个角色

"5面主题曲\n[[神玉]]角色曲",

2 行,1 个场景,多个角色

"3面BOSS\n[[云山]]&[[云居一轮]]角色曲",

2 行,1 个角色,1 个场景,角色曲为“角色曲”

"[[博丽灵梦]]角色曲\n[[博丽神社]]",

2 行,1 个角色,1 个场景,角色曲为“主题曲”

"4面BOSS\n[[{{帕秋莉}}]]主题曲"

1 行,1 个角色,无场景

"[[博丽灵梦(旧作角色)|博丽灵梦]]角色曲"
"冈崎梦美过场曲"

1 行,1 个角色限定的场景曲(不同于角色曲)

"[[雾雨魔理沙(旧作角色)|雾雨魔理沙]]路线1面主题曲"

经过试错,暂时采用的算法是:

  1. 如果字符串里有空格,替换为换行("a b" -> "a\nb")
  2. 如果字符串里有“路线”一词,插入换行("雾雨魔理沙路线1面主题曲" -> "雾雨魔理沙路线\n1面主题曲")
  3. 定义“场景标识符”:["面", "boss", "场景", "对话曲", "对话用曲", "ending", "角色选择画面"]
  4. 定义“角色标识符”:["角色", "路线", "过场曲"]
  5. 用换行符切割字符串
  6. 对于每一行 循环
  7. 如果该行里匹配“场景标识符”关键词,将该字符串插入无歧义场景列表
  8. 如果该行里匹配“角色标识符”关键词,将该字符串插入无歧义角色列表
  9. 否则该行加入“歧义列表”
  10. 结束循环
  11. 对于歧义列表每一项 循环
  12. 如果无歧义场景列表为空,但无歧义角色列表有内容,将该项加入无歧义场景列表
  13. 如果无歧义角色列表为空,但无歧义场景列表有内容,将该项加入无歧义角色列表
  14. 如果两者皆空,且该项含有维基链接,将该项加入无歧义角色列表(因为有链接角色远比有链接的场景多
  15. 结束循环
  16. 最后,展开无歧义场景列表、无歧义角色列表的全部维基链接与模板。

这个算法的准确率并非 100%,仍需要微调,但似乎已经达到了 90% 以上。

除非使用 LLM 模糊匹配,否则很难有无需调试就 100% 准确的算法(但脚本的技术路线是完全确定的传统程序,因此排除该选项)。

抽象原曲标题的提取

同一首东方乐曲的不同版本可能出现在多部作品中。为了解决这个问题,我们不仅要定义“东方音轨”,还需要定义一个抽象的超级对象“东方原曲”。多个“东方音轨”指向同一个“东方原曲”(在 MusicBrainz 中,这一抽象类别被称作“作品”,而具体的音轨被称为“录音”,原理相同)。

更复杂的是,多个乐曲版本的标题不一定相同,因此无法通过标题本身判断官方乐曲之间的联系,而是需要独立的数据表来追踪(即通过 THBWiki 本身的信息进行推断)。在 THBWiki 中,按照编辑规范,每一首音轨的中文标题都是一个超链接,指向相对应的“东方原曲”页面。因此,我们可以将利用链接关系本身建立抽象“东方原曲”对象,“原曲”用于管理“音轨”之间的关系。

例如,《东方灵异传》的乐曲《死なばもろとも》的中文标题源代码为:[[{{灵异传音乐名|1|12}}]],而《东方怪绮谈》的乐曲《Civilization of Magic》的中文标题源代码为:[[{{幺乐团5音乐名|1|12}}]]。首先,我们需要将音乐名模板展开,分别得到 [[同归于尽]], [[Civilization of Magic]]。接着,分别利用 API 访问这两个页面,均会被重定向至条目同归于尽。此时我们建立一个抽象的“东方原曲”对象,并将”音轨“对象《同归于尽》、《Civilization of Magic》都指向“原曲”对象《同归于尽》。

更复杂的是,不同“原曲”对象之间也可能存在引用关系。如果要进一步完善数据,还需要建立“原曲关系”来管理“原曲”的关系,然后由“原曲”来管理“音轨”的关系……这属于相当高阶的特性,初步开发时没有必要实现。

保存原始信息

分析音轨信息时需要展开大量模板,为了方便开发与分析,我建议在数据文件中使用一个特殊的内部保留字段extra保留 THBWiki(或者其他 Wiki)上的原始信息(我使用的是extra.thbwiki.category),而将解析后的信息保存至公开字段,如context.location.zh-hans, context.character.zh-hans

例如,娜兹玲的角色曲被保存为以下形式:

[[soundtrack_list]]

[soundtrack_list.title]
ja = "小さな小さな賢将"
zh-hans = "小小的贤将"
en = "A Tiny, Tiny, Clever Commander"

[soundtrack-list.context.character-list]
zh-hans = [
    "娜兹玲",
]

[soundtrack-list.context.location-list]
zh-hans = [
    "1面BOSS",
]

[soundtrack_list.extra.thbwiki]
title-template = [
    "星莲船音乐名",
    3,
]

[soundtrack_list.extra.thbwiki.category]
zh-hans = [
    "1面BOSS",
    "[[{{Naz}}]]角色曲",
]

[soundtrack_list.extra.thbwiki.linked-page]
text = "小小的贤将"
page = "小小的贤将"

已知问题与后续工作

  1. THBWiki 的网页并不能用 Python requests 等 HTTP/1.1 的程序访问(防御编写糟糕的垃圾爬虫?),但 API 无限制,本方法只涉及 API,不受影响(但如需获取网页,建议使用 curl、pycurl 等 HTTP/2 工具)
  2. 如果音乐作者不是 ZUN,那么音乐名模板的 Music Room 数据中还会有演奏者信息,同样用模板实现,这一模板需要手动解析。
  3. 原曲不仅包括官方游戏,还有官方专辑,其格式有所不同。

这几个问题是平凡的,留给读者作为练习,仿照上例不难,解决方案显然可得。

TouhouWiki.net 同样使用 MediaWiki,虽然模板不同,但同样可以使用 MediaWiki API 获取源代码,将数据额外解析并与 THBWiki 解析的数据合并后,可以获得 ZUN 评论的日、中、英版本。

初步结果

目前的初步结果是将乐曲转换为了以下 TOML 格式。

$ cat data/ost/TH6.toml
threlease = "TH6"

[title]
ja = "東方紅魔郷 〜 the Embodiment of Scarlet Devil."
en = "Touhou Koumakyou ~ The Embodiment of Scarlet Devil"
zh = "東方紅魔鄉 ~ the Embodiment of Scarlet Devil."
zh-hans = "东方红魔乡 ~ the Embodiment of Scarlet Devil."

[[soundtrack_list]]

[soundtrack-list.title]
ja = "妖魔夜行"
zh-hans = "妖魔夜行"
en = "Apparitions Stalk the Night"

[soundtrack-list.context.character-list]
zh-hans = [
    "露米娅",
]

[soundtrack-list.context.location-list]
zh-hans = [
    "1面BOSS",
]

[soundtrack-list.composer]
ja = "ZUN"

[soundtrack-list.commentary]
ja = "ルーミアのテーマです。\nこの曲に限らず、今回、全体的に軽快な曲になっていま>す。\nこの曲は夜の妖怪をイメージしました、\n・・・って言っても良いんだろうか(^^;\nノリ的には結構馬鹿っぽいです。"
zh-hans = "露米娅的主题曲。\n不仅是这首音乐,这次,总体来说音乐变得轻快了。\n这音乐是表现夜晚里妖怪的印象,\n···这样说也可以吧(^^;\n节奏上有些像笨蛋的感觉。"

[soundtrack-list.source.midi]
file-list = [
    "th06_03.mid",
]

[soundtrack-list.source.midi.file_metadata]
ja = "【 妖魔夜行 】from 東方紅魔郷 for 88Pro Comp.ZUN"
zh-hans = "【 妖魔夜行 】from 东方红魔乡 for 88Pro Comp.ZUN\n"

[soundtrack-list.extra.thbwiki]
title-template = [
    "红魔乡音乐名",
    3,
]

[soundtrack-list.extra.thbwiki.category]
zh-hans = [
    "1面BOSS",
    "[[露米娅]]主题曲",
]

[soundtrack-list.extra.thbwiki.linked-page]
text = "妖魔夜行"
page = "妖魔夜行"

# [[soundtrack_list]]
# ...

结论

本评论提供了一种从 THBWiki 获取东方原曲的歪门邪道。

此外,如果不想自己折腾,欢迎使用源代码与解析好的 TOML 文件:https://github.com/TouhouOML/TouhouOML

目前开发未完成,存在许多问题,代码质量极低,日后需要全部重写。

模糊获取二创作品的东方原曲标题

虽然 THBWiki 的音乐资料 API 可以获取同人专辑以及对应的原曲,而且覆盖率极高,但依然不可能 100% 覆盖大量未经正式发布的作品(YouTube, Bandcamp, Soundcloud, BiliBili, NicoNico 等网站大量存在,例如“曲风改编”的实验作品)。此外,有些用户可能希望离线运行,不希望将正在播放的音轨发送到 THBWiki。

大多数作者都在网站备注中注明了音轨的原曲,因此有必要模糊格式五花八门的原曲标题。下面是一些典型代表的开发笔记,其中有写原曲英文标题的,有写日、英标题的,有只写原作编号的,有什么都不懈的,还有和 ZUN 一样写曲评的……

目前初步的想法是:先试 THBWiki,再试备注,再试音轨本身的标题……

Bandcamp

from Touhou Chiptune Remix ~ Aan De Slag Gaan by 40Nix
<div class="tralbumData tralbum-about">東方非想天則
<br>
(Did You See That Shadow? - Unthinkable Natural Law)</div>
from Story of Modulated Frequency by +TEK
<div class="tralbumData tralbum-about">from Touhou 12</div>
from Metamorphosis of Danmaku by Molkirill
<div class="tralbumData tralbum-about">Explicit content (elements of horror music and death metal).<br>
<br>
original track - "U.N. Owen Was Her?" from "Embodiment of Scarlet Devil".</div>
from 東方chipdisk by Pigu
<div class="tralbumData tralbum-about">Original: Septette for a Dead Princess<br>
<br>
オリジナル:亡き王女の為のセプテット</div>
from 東方秘封情報部 ~ Tôhô Spies and Private Eyes by Tangentg
<div class="tralbumData tralbum-about">Inspired by the James Bond theme.<br>
<br>
Original: Heian Alien (composer: ZUN)<br>
Arranged by Tangentg (me).<br>
<br>
This is an unofficial fan arrangement. Original composition and Touhou Project belong to ZUN.</div>
from PC-98 Perfect Cherry Blossom by HertzDevil
<div class="tralbumData tralbum-about">Title Screen theme
<br>
"The title theme from previous games, done in a Japanese style while keeping its essence. This song is meant to belie the tense atmosphere of "Now, let the Danmaku begin!". Frankly, I think this song isn't necessary, but it's also meant to restrain the player's excitement while awaiting Danmaku."
<br>
-- ZUN
<br>

<br>
In practice the title screen theme *is* used with a lot of SFX going on due to menu selection and so on, however this track still ended up using all six FM channels. The perfect fifth (well, 12-TET) playing at a quaver after the bass note reminds me heavily of Disunified Field Theory of Magic, although obviously there are more than that one song that do this.</div>