sticker 表情包面接入

/v2/sticker 是 sticker.kungal.com 联邦进平台的只读面。

它不是第二份内容列表。 站上每一张表情都打了 infra catalog 的作品 id 与角色 id,所以这个面回答的是:这个 catalog 身份有哪些表情素材? 手里已经握着 catalog id 的调用方可以直接 join——/v2/sticker/characters/{character_id}/stickers 与 /v2/sticker/works/{work_id}/packs 是主车道,其余端点都是围着它们的脚手架。

契约由表情包站自己的仓库拥有,本站只镜像它的 OpenAPI 原文;端点清单见端点参考 · sticker 表情包面。

数据边界

只暴露已发布的表情包。 草稿、隐藏、已删除的包,评论正文,审核状态,以及全部创作端点都不在这个面里,将来也不会有。

名字是多语言映射,不是字符串。 键为 zh-cn、zh-tw、ja-jp、en-us、und,每一个都是可选的,任何一个都可能缺席;und 放的是 catalog 里没有语言标记的显示名。

{ "zh-cn": "夏日口袋", "ja-jp": "サマーポケッツ", "en-us": "Summer Pockets" }

回退链由你自己定——这个面永远不替你挑一个。title、description、Work.name、Character.name、Tag.name 全部是这个形状。

拿密钥

Base URL 是 https://api.nextmoe.dev,与 v2 同一个。凭据也是同一把:在控制台铸一把 nmk_live_ 应用密钥即可,流程见快速上手。

不需要任何 scope。 网关在请求到达表情包站之前跑 ForwardAuth,只查身份、速率与配额,不做授权——任意有效密钥都放行,永远不会 403。铸密钥时不要去找 sticker:read:这个 scope 字符串不存在,勾了会被拒。

curl "https://api.nextmoe.dev/v2/sticker/packs?limit=3" \
  -H "Authorization: Bearer nmk_live_<YOUR_KEY>"

X-API-Key: nmk_live_<YOUR_KEY> 是等价写法。

只能从你自己的服务端调用。 网关对包括 OPTIONS 在内的每个方法都验密钥,而 CORS 预检不带认证头,所以浏览器直连必然 401——不是你的 CORS 配置写错了。详见鉴权与凭据。

九个端点

方法与路径 做什么
GET /v2/sticker/packs 列出已发布的表情包
GET /v2/sticker/packs/{pack_id} 单个包,含其中的表情、作品与角色
GET /v2/sticker/stickers/{sticker_id} 单张表情
GET /v2/sticker/characters 该站有素材的 catalog 角色索引
GET /v2/sticker/characters/{character_id} 单个 catalog 角色在该站的样子
GET /v2/sticker/characters/{character_id}/stickers 主车道:某个角色的全部表情
GET /v2/sticker/works 该站有素材的 catalog 作品索引
GET /v2/sticker/works/{work_id}/packs 主车道:关于某部作品的表情包
GET /v2/sticker/tags 标签,用得最多的在前

翻页与 catalog /v2 完全相同:列表信封是 {object, items, next_cursor},把 next_cursor 原样作为 cursor 传回取下一页,末页直接不出现这个键(不是 null);limit 取 1–100,默认 20,超过 100 是 400 LIMIT_TOO_LARGE,不会被截断;total 默认不发,要就传 include_total=true。不是这个面签发的 cursor 是 400 INVALID_CURSOR。循环写法见集合与分页。

其余参数:sort 取 new(默认,按发布时间)或 hot(按下载数再按浏览数),两者都以 UUIDv7 的 id 收尾,所以是稳定的 tiebreaker;q 是不分大小写的子串匹配,同时跨全部语言——一个日文查询能命中只有日文标题匹配的包,超过 100 字符会被截断。

nsfw、official、linked 声明为字符串枚举 'true' / 'false',不是 JSON 布尔;照字面写 nsfw=true 即可。

nsfw 说的是表情包自己的分级,不是它所属游戏的。从一部 r18 游戏里剪出来的日常反应表情包是 all_ages,而它的 work.content_rating 仍然是 r18。这里关联的 95 部游戏里有 89 部是 r18,按游戏分级过滤会几乎清空结果。

例一:某个角色的全部表情

主车道。手里有 catalog 角色 id 就直接问:

curl "https://api.nextmoe.dev/v2/sticker/characters/12345/stickers?limit=2" \
  -H "Authorization: Bearer nmk_live_<YOUR_KEY>"
{
  "object": "list",
  "items": [
    {
      "object": "sticker",
      "id": "0193f2a1-…",
      "pack_id": "0193f29c-…",
      "position": 1,
      "image": {
        "hash": "3f7a…",
        "url": "https://…/full.webp",
        "thumb_url": "https://…/320.webp",
        "width": 512,
        "height": 512
      },
      "note": "…",
      "work": { "object": "work", "id": "61311", "name": { "zh-cn": "…" } },
      "character": {
        "object": "character",
        "id": "12345",
        "name": { "ja-jp": "…" }
      }
    }
  ],
  "next_cursor": "cur_Mg"
}
  • 最新在前,只含已发布的包。
  • 空页与 404 含义不同:角色没有素材时这里返回空列表,绝不会仅因为这个 id 就 404。会 404 的是 GET /v2/sticker/characters/{character_id}——那表示该站没有任何已发布的表情打了这个角色,它不说明 catalog 认不认识这个 id。
  • image.hash 是 infra 图床服务的内容寻址,也是这里唯一稳定的标识符:同样的字节由生态内另一个站点存下来,hash 相同。表情包封面上没有这个字段,要稳定身份用 cover_sticker_id。
  • position 从 1 开始,在同一个包内唯一。

例二:关于某部作品的表情包

curl "https://api.nextmoe.dev/v2/sticker/works/61311/packs?sort=hot&nsfw=true" \
  -H "Authorization: Bearer nmk_live_<YOUR_KEY>"
{
  "object": "list",
  "items": [
    {
      "object": "pack",
      "id": "0193f29c-…",
      "title": { "zh-cn": "…", "ja-jp": "…" },
      "description": { "zh-cn": "…" },
      "official": true,
      "content_rating": "all_ages",
      "sticker_count": 24,
      "view_count": 0,
      "download_count": 0,
      "cover": {
        "url": "https://…/full.webp",
        "thumb_url": "https://…/320.webp"
      },
      "cover_sticker_id": "0193f2a1-…",
      "work": {
        "object": "work",
        "id": "61311",
        "name": { "zh-cn": "…" },
        "content_rating": "r18"
      },
      "tags": [
        {
          "object": "tag",
          "slug": "…",
          "name": { "zh-cn": "…" },
          "pack_count": 0
        }
      ],
      "author": {
        "object": "author",
        "id": "1",
        "name": "…",
        "avatar_url": "https://…"
      },
      "created_at": "2026-08-29T02:51:07Z",
      "updated_at": "2026-08-29T02:51:07Z",
      "published_at": "2026-08-29T02:51:07Z"
    }
  ]
}

「关于」是宽口径:一个包只要声明了某部游戏,或含有该游戏的表情,就算是关于它。站方预置的官方包一个游戏都不声明,却各自取材自几十部游戏——按窄口径读,它们什么都答不出来。/v2/sticker/packs?work=61311 用的是同一条关系。

pack.work 只在作者声明过时出现;混合包不声明任何游戏,但每一张表情上仍带各自的 work 与 character。author.id 是 NextMoe 账号 id,与生态内每个站点用的是同一个。

例三:单个包,含全部表情

curl "https://api.nextmoe.dev/v2/sticker/packs/0193f29c-…" \
  -H "Authorization: Bearer nmk_live_<YOUR_KEY>"

详情比列表多三个数组:stickers(按展示顺序)、works(这些表情覆盖到的去重后游戏)、characters(去重后角色)。works 与 characters 就是这个包能 join 回 catalog 的全部身份,用它们做一次批量水合最省事。

pack_id 与 sticker_id 是 UUIDv7 字符串;work.id、character.id、author.id 是十进制字符串,与 catalog /v2 的 id 同形,可以直接拿去拼 catalog 的路径。

和 catalog 拼起来

这个面给素材,catalog 给身份内容。两种方向:

# 从 catalog 往这边:我这部作品有没有表情素材
curl "https://api.nextmoe.dev/v2/sticker/works/61311/packs" \
  -H "Authorization: Bearer nmk_live_<YOUR_KEY>"

# 从这边往 catalog:拿角色 id 取 catalog 的权威资料
curl "https://api.nextmoe.dev/v2/catalog/characters/12345" \
  -H "Authorization: Bearer nmk_live_<YOUR_KEY>"

Work.id 拿去 /v2/catalog/works/{id} 解析,Character.id 拿去 /v2/catalog/characters/{id} 解析——同一把密钥就能读。

要同步一份索引而不是逐个查,用 GET /v2/sticker/characters:它列出每个在已发布包中至少有一张表情的 catalog 角色 id,按数量排序。catalog 认识但该站没有素材的角色按设计不会出现在这里,所以这个索引就是「有素材的全集」。GET /v2/sticker/works 同理,并在作品索引上额外下发 sticker_count。

Work.content_rating 与 Character.image_url 都是 catalog 的值原样透传;catalog 标记角色立绘为限制级时 image_url 不下发。要严格的分级判定仍以 catalog 为准,见镜像到自己的库。

错误

这个面上的错误全部是 RFC 9457 application/problem+json,code 取自平台那份封闭注册表。先按 HTTP status 分支,再看 code。

网关写的(401、429)——请求还没到表情包站就被拦下。instance 是你请求的那条路径:

HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json
WWW-Authenticate: Bearer realm="nextmoe", error="invalid_token"

{
  "type": "https://developer.nextmoe.dev/problems/platform/invalid-credential",
  "title": "Invalid credential",
  "status": 401,
  "detail": "The application key is invalid, revoked or expired.",
  "instance": "/v2/sticker/packs?limit=20",
  "code": "INVALID_CREDENTIAL",
  "request_id": "req_01JBQ7X4M2K9P3W5T8ZVN6HRDC",
  "errors": []
}

401 的 code 是 MISSING_CREDENTIAL(没带密钥)或 INVALID_CREDENTIAL(密钥无效、已吊销或已过期);429 的是 RATE_LIMITED(每分钟速率)或 QUOTA_EXCEEDED(每日配额),两者都带 Retry-After(秒),并带 X-RateLimit-* 与 X-Quota-*。

2026-09-18 之前,网关的 401 与 429 回的是平台旧信封 {"code":10001,"message":"…"}。按 HTTP status 分支的代码不受这次统一影响;按 body 里 code 数值判断的代码需要改看 status 或新的字符串 code。

表情包站写的(其余全部)——RFC 9457 application/problem+json,字段名与平台 /v2 的 problem 文档一致:

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{
  "type": "https://developer.nextmoe.dev/problems/platform/limit-too-large",
  "title": "Limit too large",
  "status": 400,
  "detail": "limit must be at most 100; the value is not clamped",
  "instance": "/v2/sticker/packs?limit=500",
  "code": "LIMIT_TOO_LARGE",
  "request_id": "req_01JBQ7X4M2K9P3W5T8ZVN6HRDC",
  "errors": [
    {
      "parameter": "limit",
      "reason": "OUT_OF_RANGE",
      "detail": "limit must be at most 100; the value is not clamped"
    }
  ]
}

type URI 解析到本站的错误码注册表。code 取自平台那份封闭注册表,原样照搬,因此一套客户端解码逻辑同时覆盖这个面与 catalog;这个面会出现的是 INVALID_PARAMETER、LIMIT_TOO_LARGE、INVALID_CURSOR、NOT_FOUND、METHOD_NOT_ALLOWED、INTERNAL_ERROR、SERVICE_UNAVAILABLE。detail、request_id(与 X-Request-ID 响应头同值)和 errors 恒在;400 时 errors[0].parameter 指出要改的是哪个参数。instance 是失败的那条请求的路径与查询串。

未知 slug 是个例外:tag= 传一个不存在的 slug匹配不到任何东西,返回空列表,而不是报错。

一个分支写法:

const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } })
if (res.status === 401) throw new Error('key missing or revoked')
if (res.status === 429) {
  await sleep(Number(res.headers.get('Retry-After') ?? 60) * 1000)
  return retry()
}
if (!res.ok) {
  const problem = await res.json() // application/problem+json
  throw new Error(`${problem.code}: ${problem.detail ?? problem.title}`)
}

字段含义与分支顺序见错误处理。

限流

限流在网关按密钥所属应用计数,与 v2 共池:free 档 60 次/分、50,000 次/日。超限是 429 加 Retry-After,并带 X-RateLimit-* 与 X-Quota-* 响应头。分档与退避写法见限流与配额。

同步索引时用满 limit=100 而不是默认 20,请求数直接降到五分之一。

缓存

每个 200 的 GET 都带 ETag。把它原样放进下一次请求的 If-None-Match,没变就是 304,不计入响应体传输:

curl -i "https://api.nextmoe.dev/v2/sticker/works/61311/packs" \
  -H "Authorization: Bearer nmk_live_<YOUR_KEY>" \
  -H 'If-None-Match: "9f2a1c…"'

这个面声明的是 Cache-Control: public, max-age=300, s-maxage=1800, stale-while-revalidate=3600——可共享缓存,与 moyu 补丁面一致;错误文档是 no-store。放一层自己的 CDN 或反向代理是安全的。完整契约见缓存与条件请求。

真正该缓存的是图片:image.url 与 image.thumb_url 指向图床 CDN,直接引用即可,既不经过这个面也不消耗 API 配额。thumb_url 是 320px 变体,列表页用它。image.hash 是内容寻址,可以直接当本地缓存键。

接下来

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

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