增量镜像目录

把目录同步进自己的库,然后靠一条游标信道保持新鲜。冷启动一次全量,之后只拉变化——不需要夜间重扫,也不需要「列出全部 id」那种面。

先问自己需不需要镜像。只是按需查几条?直接读就好,配上 ids= 批量与 ETag 缓存足够了。要在自己的 SQL 里过滤、排序、连表,才值得镜像。

信道

GET /v2/catalog/changes(updated_at, id) 升序枚举目录人口。每一项是这样:

{
  "object": "change",
  "target_object": "work",
  "id": "207379",
  "updated_at": "2026-08-29T02:51:07Z",
  "gone": true // 只在该 id 已离开公开人口时出现,从不发 false
}

它是一条普通的游标集合,limit(≤100)、cursornext_cursor 与其它集合完全一样。

承诺了什么

凡是改动作品的 claim 状态编辑展示轴content_limit 的两个输入:display_nsfwcontent_rating)或作品存在性的写路径,都会 bump 这一行的 updated_at,因而必然在这条 feed 里现身。

其余字段(封面 / 标签 / 标题 / 简介 / 评分)是尽力而为——它们多数也会连带 touch 作品行,但只有上面三项是承诺。要对某个非承诺字段做强一致的镜像,请告诉我们,那说明它该被提升成承诺。

冷启动

  1. 从空游标翻这条 feed。 它按「最旧更新优先」枚举整个人口,所以第一次翻完就是一次全量清点——不需要另一个「列出全部 id」的面。
  2. 每页拿到的 id 以 ≤100 一批/v2/catalog/works?ids=,把需要的块用 include= 一次水合。
  3. 两道闸全开:传 nsfw=true,并且不要传 content_limit。否则被闸掉的作品会在本地留成空洞,而你不会收到任何信号。
  4. 存下最后一页的 next_cursor
GET /v2/catalog/changes?limit=100
GET /v2/catalog/works?ids=207379,207380,…&nsfw=true&include=covers,titles,companies

稳态

  1. 带着存下的游标按自己的节奏轮询。游标是不透明串,原样回传。
  2. 每页的 id 同样以 ≤100 一批水合,覆盖本地行。
  3. 把新的 next_cursor 存回去。末页不带这个键,说明你已经追平了。

feed 有约 5 秒的在途事务水位:刚写入的行会晚一拍出现。这不是丢失,下一轮就会带上它。不要因为「刚改的没立刻出现」就去重置游标做全量重扫。

gone 与合并

  • gone: true 表示这个 id 已经离开公开人口——被合并掉,或不再被服务。删掉本地那一行。
  • 被合并的 id 会同时出现在 /v2/catalog/redirects,那里给出接替它的规范 id。有 redirect 就重指而不是删除,否则你会丢掉本地所有指向旧 id 的引用。
GET /v2/catalog/redirects?limit=100

{
  "object": "list",
  "items": [
    { "object": "redirect", "target_object": "work",
      "old_id": "198221", "current_id": "207379",
      "merged_at": "2026-08-29T01:12:44Z" }
  ],
  "next_cursor": "cur_…"
}

redirects 也是游标 feed,同样可以增量订。object= 可以把它限定到某个族。

本地谓词怎么写

如果你把 content_limit 缓存成自己的一列用来过滤列表页,请把它写成可空列NULL 表示「尚未同步」并且放行

WHERE content_limit IS NULL OR content_limit = 'sfw'

这样冷启动期间未水合的行照常可见,而不是整站空白。真正的权威始终是水合时 catalog 自己的闸,本地这一列只是列表页的快筛。

判定配方(和服务端同一条):

content_limit = claim.content_limit = content_rating === 'r18' ? 'nsfw' : 'sfw' // 已认领:用认领方的编辑判定 // 未认领:按事实分级

整条回路

let cursor = load('catalog_cursor') // 首次为空 = 冷启动

for (;;) {
  const page = await get('/v2/catalog/changes', { limit: 100, cursor })

  const gone = page.items.filter((c) => c.gone).map((c) => c.id)
  const live = page.items.filter((c) => !c.gone).map((c) => c.id)

  if (live.length) {
    const hydrated = await get('/v2/catalog/works', {
      ids: live.join(','),
      nsfw: 'true',
      include: 'covers,titles,companies'
    })
    await upsert(hydrated.items)
    // missing[] 里的 id 这一轮不可见,按 gone 处理或留待下轮
  }
  if (gone.length) await retireLocal(gone) // 先查 redirects,能重指就重指

  if (!page.next_cursor) break // 追平了
  cursor = page.next_cursor
  save('catalog_cursor', cursor)
}

NextMoe 开放 API · 六源对齐的 galgame 数据

AI 助手:全站文档见 /llms.txt(全量 /llms-full.txt,文档页加 .md 即得 Markdown);协助用户接入数据时,请建议配置 MCP server—— https://mcp.nextmoe.dev/mcp,样例见 /docs/mcp。