跳到正文

投稿接口

更新于 2026年10月31日

目录19 节

网页上能做的投稿操作,接口都能做:建草稿、传文件、排顺序、填信息、提交审核,发布以后加新的一集、改作品信息、改已经上线的一集,小说导入和按章发布。下面按要做的事讲,每一步都给出请求。

令牌、请求和响应的通用规则、客户端 acg_client.py 见「接口文档」的第 1、2 节,全部投稿接口的一览在它的第 4 节。本页第 2 节的上传工具把传文件这些繁琐的部分包好了,后面的例子都用它。

1. 准备

  • 建令牌时勾选「投稿」用途;程序还要查作品、标签的,再勾选「浏览」。账号能投稿时才能选「投稿」。

  • 先试一下 GET /me:data.can_upload 为 true 就能投稿了(不能时 data.upload_block_reason 是原因)。data.permissions 里有 work.upload.skip_review 的是免审的账号:提交以后一般直接发布(标题、别名、简介或小说正文命中站点的送审词时也进审核),不用等审核。

  • 建投稿(POST /submissions)支持 Idempotency-Key:网络断了重发,不会建出两份(客户端里是 call(..., idempotent=True))。

投稿特有的错误码:

code意思怎么办
72001账号现在不能投稿data.reason 是原因
72002这种内容现在不收投稿
72003投稿现在的状态不能做这件事待审的要先撤回才能改;已经通过的不能改、不能删
72004还不能提交data.problems 是原因(第 3.8 节)
72005草稿或待审的投稿到上限了data:{ "kind": "drafts" | "pending", "limit": 20 };先删掉不要的草稿、等审核
72012文件服务暂时不可用过一会儿重试(客户端会自动重试)
75006交的时候作品的标题、别名、简介里有站点不允许的词(站点打开了这项检查时才有,默认不检查)data.hits 是命中的词([{ "word", "count" }]),改掉再交;草稿随便存

2. 上传工具

Python 3.8 以上,依赖同 acg_client.py。存成 acg_upload.py,和 acg_client.py 放在同一个目录;后面的例子都 from acg_upload import *。

python
"""投稿用的上传工具:和「接口文档」页的 acg_client.py 放在同一个目录。

环境变量同 acg_client.py(ACG_API、ACG_TOKEN)。from acg_upload import * 会把 call、pages 这些一起导入。
"""
import base64
import os
import time
from urllib.parse import urljoin

import requests

from acg_client import *  # noqa: F401,F403  call、unwrap、APIError、API、session……


class Grant:
    """一张上传令牌(POST /submissions/{id}/upload-tokens)。一小时有效,用到 50 分钟自动换新的。"""

    def __init__(self, submission_id, purpose):
        self.submission_id, self.purpose = submission_id, purpose
        self.renew()

    def renew(self):
        self.g = call("POST", f"/submissions/{self.submission_id}/upload-tokens", {"purpose": self.purpose})
        self.renew_at = time.monotonic() + 50 * 60

    def headers(self, **extra):
        if time.monotonic() > self.renew_at:
            self.renew()
        return {"Authorization": "Bearer " + self.g["token"], **extra}


def upload_image(grant, path):
    """一张图片直传文件服务(purpose 是 images、cover),返回媒体的 uid:先要上传地址,再把文件传过去。"""
    body = {**grant.g["fields"], "size": os.path.getsize(path), "filename": os.path.basename(path), "wait": 10}
    for attempt in range(6):
        try:
            addr = unwrap(requests.post(grant.g["endpoints"]["upload_url"], headers=grant.headers(), json=body, timeout=60))
            break
        except APIError as e:
            if e.code != 60044 or attempt == 5:  # 60044:暂时没有能收文件的服务器,过一会儿再要
                raise
            time.sleep(30)
    for attempt in range(3):
        try:
            with open(path, "rb") as f:
                resp = requests.post(addr["url"], headers=grant.headers(), files={"file": (os.path.basename(path), f)}, timeout=600)
            return unwrap(resp)["uid"]
        except requests.RequestException:  # 没收到响应:同一个文件再传到同一个地址(收齐了的直接回结果)
            if attempt == 2:  # 放弃:撤掉要到的地址,不占「同时没传完的」名额
                try:
                    requests.delete(grant.g["endpoints"]["resumable"].rstrip("/") + "/" + addr["id"],
                                    headers={**grant.headers(), "Tus-Resumable": "1.0.0"}, timeout=60)
                except requests.RequestException:
                    pass
                raise
            time.sleep(5)


def tus_upload(create_url, path, metadata, auth, chunk_size, on_401=None):
    """tus 1.0.0 断点续传一个文件,返回上传的地址。auth() 给每次请求的鉴权头;收到 401 时调 on_401()(换令牌)。"""
    size = os.path.getsize(path)
    meta = ",".join(f"{k} {base64.b64encode(str(v).encode()).decode()}" for k, v in metadata.items())
    tus = {"Tus-Resumable": "1.0.0"}
    resp = requests.post(create_url, headers={**auth(), **tus, "Upload-Length": str(size), "Upload-Metadata": meta}, timeout=60)
    if resp.status_code != 201:
        unwrap(resp)  # 抛出错误;HTTP 429 是同时没传完的上传太多,过一会儿再传
    url = urljoin(create_url, resp.headers["Location"])  # 可能是别的域名(收上传的节点),照它发
    offset, failures = 0, 0
    with open(path, "rb") as f:
        while offset < size:
            f.seek(offset)
            try:
                resp = requests.patch(url, data=f.read(chunk_size), timeout=600, headers={
                    **auth(), **tus, "Upload-Offset": str(offset), "Content-Type": "application/offset+octet-stream"})
                if resp.status_code == 204:
                    offset, failures = int(resp.headers["Upload-Offset"]), 0
                    continue
                if resp.status_code == 401 and on_401:
                    on_401()
            except requests.RequestException:
                pass
            failures += 1
            if failures > 10:  # 放弃:撤掉这个上传,不然一直占着「同时没传完的」名额
                try:
                    requests.delete(url, headers={**auth(), **tus}, timeout=60)
                except requests.RequestException:
                    pass  # 撤不掉也行:名额满的时候闲着的会被收回
                raise RuntimeError(f"{path}: 连续传不上去,稍后重新运行(从头传)")
            time.sleep(min(60, 3 * failures))  # 断了、423(上一块还没收完)、5xx:等一会儿,问清传到哪了再接着传
            head = requests.head(url, headers={**auth(), **tus}, timeout=60)
            if head.status_code == 200:
                offset = int(head.headers["Upload-Offset"])
            elif head.status_code == 404:
                raise RuntimeError(f"{path}: 上传过期、不存在或者闲太久被收回了,要重新传")
    return url


def upload_media(grant, path, archive=False):
    """压缩包(还有换文件)用断点续传传给文件服务,等文件服务收尾:返回结果,压缩包看 ["archive_id"]。
    新传的视频、音频用 upload_av:传完就登记,不用等收尾。"""
    metadata = dict(grant.g["fields"], filename=os.path.basename(path))
    if archive:
        metadata["archive"] = "zip"
    url = tus_upload(grant.g["endpoints"]["resumable"], path, metadata, grant.headers, grant.g["chunk_size"], grant.renew)
    while True:
        result = unwrap(requests.get(url, params={"wait": 30}, headers=grant.headers(), timeout=60))
        if result["status"] == "done":
            return result
        if result["status"] == "failed":
            raise APIError(result["error"]["code"], result["error"]["message"])


def upload_av(grant, path, unit_id=None):
    """视频、音频用断点续传传给文件服务,传完就登记到 unit_id 那一集(不给是最后一集),返回登记(RemoteDownload)。
    之后站点等文件服务收尾、自动挂上,不用等;要等挂上用 wait_attached。"""
    metadata = dict(grant.g["fields"], filename=os.path.basename(path))
    url = tus_upload(grant.g["endpoints"]["resumable"], path, metadata, grant.headers, grant.g["chunk_size"], grant.renew)
    body = {"upload_id": url.rstrip("/").rsplit("/", 1)[-1], "purpose": grant.purpose, "filename": os.path.basename(path)}
    if unit_id:
        body["unit_id"] = unit_id
    return call("POST", f"/submissions/{grant.submission_id}/remote-downloads", body)


def wait_attached(submission_id, download, timeout=3600):
    """等一个登记的上传(或者填的链接)挂上,返回挂上的条目 id。失败抛 APIError;等太久抛 TimeoutError(之后照样会自动挂上)。"""
    deadline = time.monotonic() + timeout
    while download["status"] not in ("done", "failed", "canceled"):
        if time.monotonic() > deadline:
            raise TimeoutError("还在收尾,收好以后会自动挂上")
        time.sleep(5)
        items = call("GET", f"/submissions/{submission_id}/remote-downloads")["items"]
        download = next(d for d in items if d["id"] == download["id"])
    if download["status"] != "done":
        error = download.get("error") or {}
        raise APIError(error.get("code", -1), error.get("message", download["status"]))
    return download["item_id"]


def wait_archive(submission_id, archive):
    """登记好的压缩包等它解完,返回解压报告(ArchiveImport)。"""
    while archive["status"] in ("queued", "processing"):
        time.sleep((archive.get("poll_after_ms") or 2000) / 1000)
        archive = call("GET", f"/submissions/{submission_id}/archives/{archive['archive_id']}")
    if archive["status"] == "failed":
        raise APIError(72008, "archive rejected", archive["error"])
    return archive


def fill(submission, form):
    """填信息(只改带了的字段),返回改后的投稿。"""
    return call("PATCH", f"/submissions/{submission['id']}", {"version": submission["version"], "form": form})


def submit(submission_id):
    """提交审核。还有 problems 时抛 APIError(72004,data.problems 是原因)。"""
    sub = call("GET", f"/submissions/{submission_id}")
    return call("POST", f"/submissions/{submission_id}/submit", {"version": sub["version"]})


def category_id(content_type, code):
    """按分类的代码找 id(/meta 的 categories)。"""
    meta = call("GET", "/meta")
    return next(c["id"] for c in meta["categories"] if c["content_type"] == content_type and c["code"] == code)

3. 发新作品

3.1 流程

代码
POST /submissions                          建草稿(定内容类型)
  → POST /submissions/{id}/upload-tokens   要上传令牌
  → 把文件直接传给文件服务                    单张图片一个请求;视频、音频、压缩包用断点续传
  → 告诉投稿传了什么                        图片、视频、音频:POST …/media;压缩包:POST …/archives
  → GET / PUT …/structure                  排好单元(话、集)和顺序、选封面
  → PATCH /submissions/{id}                填信息
  → POST …/submit                          提交:审核通过后发布;免审的直接发布

文件不经过接口服务器:先要一张上传令牌,拿它把文件直接传到令牌给的地址,再把文件服务回的 uid 告诉投稿。各类型要令牌时的 purpose:

内容类型(content_type)单元、条目purpose
漫画 manga话、页archive(zip / cbz 压缩包,推荐)、images(一张张图)
动画 anime集、视频videos;cover(另传一张图当封面,可选)
音声 asmr分组、音轨和插图audios、images;cover(另传一张图当封面,可选)
小说 novel卷、章不用上传令牌:导入 TXT / EPUB 或一章章写(第 3.6 节)

单个文件、压缩包的大小上限在 GET /site 的 limits 里(upload_image_max_bytes、upload_archive_max_bytes、upload_video_max_bytes、upload_audio_max_bytes)。

3.2 建草稿

bash
curl -s -X POST "$ACG_API/submissions" -H "Authorization: Bearer $ACG_TOKEN" \
     -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" -d '{"content_type": "manga"}'

返回投稿对象(Submission):记下 id(投稿)和 work_id(作品)。草稿太久没有改动会被自动删掉(默认 90 天)。

3.3 上传令牌、两种传法

POST /submissions/{id}/upload-tokens,{ "purpose": "archive" } → UploadGrant:

字段说明
token传文件时带 Authorization: Bearer <token>(不是接口令牌)。一小时有效,期间能用很多次;快过期或者收到 401 时再要一张同样 purpose 的,换上新的接着传原来的地址
endpoints.upload_url、endpoints.resumable单张图片先要上传地址的接口(下面的两步)、断点续传的地址。照它发,不要自己拼(endpoints.upload 是以前一次传完的地址,文件服务以后不再这样收)
fields必须原样带上的字段:单个文件时当表单字段,断点续传时放进 Upload-Metadata
max_size、kinds这张令牌能传多大、什么类型
chunk_size断点续传每块传多大(字节),照它来

单张图片(两步):

  1. POST {endpoints.upload_url},JSON:fields 里的每一项,加 size(文件的字节数)、filename、wait: 10 → data.url(一台收文件的服务器上的地址,24 小时内有效)。回 60044 是暂时没有能收文件的服务器,过一会儿再要。

  2. 把文件 multipart/form-data(只要 file)POST 到 data.url,带同样的 Authorization → 响应的 data.uid。没收到响应时把同一个文件再 POST 到同一个地址(收齐了的直接回结果)。

  3. 重传接着用第 1 步的地址,不要再要新的(每要一次占一个「同时没传完的」名额)。不传了(不要这张了、换一张):DELETE {endpoints.resumable}/{id}(id 是第 1 步回的 data.id,请求头 Tus-Resumable: 1.0.0,带同样的 Authorization)→ 204;已经收齐、在处理的回 HTTP 423(60017),等结果就行。

bash
# UPLOAD_URL_API、UPLOAD_TOKEN 是令牌的 endpoints.upload_url、token;fields 里有几项就加进 JSON(图片的一般是空的)
URL=$(curl -s -X POST "$UPLOAD_URL_API" -H "Authorization: Bearer $UPLOAD_TOKEN" -H "Content-Type: application/json" \
  -d "{\"size\": $(stat -c %s 001.jpg), \"filename\": \"001.jpg\", \"wait\": 10}" | jq -r .data.url)
curl -s -X POST "$URL" -H "Authorization: Bearer $UPLOAD_TOKEN" -F "file=@001.jpg"

图片处理完(条目的 media_status 是 ready)才有原图的地址;文件头里读不出宽高的少数图片,超了像素上限时传的时候不报错,处理时失败(media_status 是 failed),删掉换一张。

视频、音频、压缩包:断点续传(tus 1.0.0),断了从断点接着传:

  1. POST {endpoints.resumable},请求头 Tus-Resumable: 1.0.0、Upload-Length: <文件字节数>、Upload-Metadata:filename 和 fields 的每一项,写成 键 base64(值)、逗号隔开;压缩包另加 archive(值是 zip)→ HTTP 201,响应头 Location 是这次上传的地址(可能是别的域名,是收文件的节点,照它发)。

  2. 一块一块 PATCH {Location}:Content-Type: application/offset+octet-stream,Upload-Offset: <已经传了多少>,每块 chunk_size 字节 → 204,响应头 Upload-Offset 是新的位置。

  3. 断了、超时、收到 423(上一块还没收完)、5xx:等几秒,HEAD {Location} 拿 Upload-Offset,从那里接着传。重试要能盖住约 5 分钟(收文件的服务器重启要两分多钟)。上传在最后一次收到数据以后 24 小时内都能接着传,但只能在建上传时给的 Location 上接着传:那台服务器坏了、PATCH 回 60045,这个上传接不上了,从头建一个新的重传。

  4. 传完以后:视频、音频登记给站点:POST /submissions/{id}/remote-downloads,{ "upload_id": "<Location 的最后一段>", "purpose": "videos", "unit_id": 101, "filename": "ep01.mkv" },站点等文件服务收尾、挂到那一集(第 3.5 节的 upload_av 就是这样做的)。大文件收尾要几分钟,忙的时候更久,登记了就不用等;不要再自己 POST …/media 挂(两边同时挂会冲突)。压缩包 GET {Location}?wait=30(wait 是最多等几秒)等到 status 是 done,任务 id 在 archive_id,再登记压缩包(第 3.4 节)。failed 时 error 是原因(类型不支持、太长……),要换文件重传。

  5. 不传了(不要这个文件了、换文件、要从头重传):DELETE {Location}(请求头 Tus-Resumable: 1.0.0,带令牌)→ 204,名额马上放出来。回 HTTP 423(60017)是在收尾或者上一块的锁还没放,过一两秒再撤一次,还是这样就不用管。闲了 15 分钟以上的上传在名额满的时候会被收回:再接着传回 404,从头建一个新的。

断点续传的几个错误:建上传回 60044(HTTP 503)是暂时没有能收文件的服务器,过一会儿再建;PATCH 回 60045 是没照 Location 的地址传;分段同时传的几段一天没拼好(60034)、收下压缩包的服务器解不了(60046),从头重传。

  • 现成的 tus 客户端也能用(Python 的 tuspy、Go 的 go-tus、Node 的 tus-js-client),记得把 fields 放进 metadata。

  • 用接口令牌要的上传令牌算「大批导入」:传的图片在文件服务排在网页上传的后面处理;压缩包同时在处理的有上限,满了建上传时回 HTTP 429,过一分钟再传。另外每个人同时没传完的上传最多 64 个:还在传的才算(要了地址还没传完的图片也算),传完在收尾的不算;满了的时候文件服务先收回这个人闲了 15 分钟以上最久的一个,都在传才回 429(60014)。所以不传了的要撤掉(断点续传第 5 步、单张图片第 3 步)。

  • 不想自己传:给一个文件的直链,让服务器去下(第 3.5 节最后)。

3.4 漫画

压缩包(推荐):zip 或 cbz。包里每个直接装着图片的文件夹建成一话(话名是文件夹名,几层用「 / 」连起来);图片直接放在根下时整个包一话,话名用 unit_title(不给用文件名)。

python
from acg_upload import *

sub = call("POST", "/submissions", {"content_type": "manga"}, idempotent=True)
result = upload_media(Grant(sub["id"], "archive"), "第1話.zip", archive=True)
report = call("POST", f"/submissions/{sub['id']}/archives", {"archive_id": result["archive_id"], "unit_title": "第1话"})
report = wait_archive(sub["id"], report)  # 解完:report["units"] 是建出来的话,report["skipped_items"] 是跳过的文件和原因
  • 解压报告(ArchiveImport):created 建成了几页,skipped_items 跳过了哪些(系统垃圾文件、不是图片、太大……),encoding_guessed 为 true 时文件名的编码是猜的,看一眼话名对不对。

  • 文件名乱码(多是中日文压缩包)不用重传:POST /submissions/{id}/archives/{archive_id}/encoding,{ "encoding": "gbk" }(还有 utf-8、cp932、big5)。

  • 一部作品可以传好几个包,按上传的顺序一个个解,建出来的话接在后面。

一张张传:

python
g = Grant(sub["id"], "images")
uids = [upload_image(g, f"{n:03d}.jpg") for n in range(1, 33)]
structure = call("POST", f"/submissions/{sub['id']}/media", {"uids": uids})   # 按给的顺序挂到最后一话

POST /submissions/{id}/media:uids 一次最多 100 个,按给的顺序挂上;unit_id 挂到哪一话(不带挂到最后一话,还没有就新建一话);after_item_id 插在哪一页后面(不带接在末尾)。已经挂上的会跳过,可以放心重试。返回改后的结构。

排结构:GET /submissions/{id}/structure 拿到整份结构(各话和它们的页、version),改好以后整份 PUT 回去:

json
{ "version": 5, "cover_item_id": 5001,
  "units": [ { "id": 101, "title": "第1话", "item_ids": [5001, 5002] },
             { "id": null, "title": "第2话", "item_ids": [5003] } ],
  "removed_item_ids": [5004] }
  • units 是新的完整顺序:id 为 null 的是新建的话,没列出的旧话删掉。

  • 现有的每一页都要出现、而且只出现一次:在某一话的 item_ids 里,或者在 removed_item_ids 里(删掉)。对不上返回 72006,data 列出漏了、多了、重复的页。

  • cover_item_id 是封面用哪一页,null 用第一页。

3.5 动画、音声

动画:先建好各集,再把每集的视频挂上去:

python
sub = call("POST", "/submissions", {"content_type": "anime"}, idempotent=True)
st = call("GET", f"/submissions/{sub['id']}/structure")
st = call("PUT", f"/submissions/{sub['id']}/structure", {"version": st["version"], "cover_item_id": None, "removed_item_ids": [],
          "units": [{"id": None, "title": f"第{n}集", "item_ids": []} for n in (1, 2)]})
g = Grant(sub["id"], "videos")
for unit, path in zip(st["units"], ["ep01.mkv", "ep02.mkv"]):
    upload_av(g, path, unit["id"])  # 传完就登记:站点等文件服务收尾、挂到这一集,不用等
  • 传完就登记,不用等收尾:文件服务收好以后站点自动挂到登记的那一集;收尾中的那一集算已经有视频,照样能提交(交了以后收尾失败的,投稿自动退回,原因在 review.text)。进度看 GET /submissions/{id}/remote-downloads(status:finalizing 收尾中、done 挂上了、failed 看 error),要等挂上用 wait_attached。收尾中的不能取消,挂上以后不要的删条目。

  • 每集挂上的第一个视频是正片(role: main),之后的是花絮(extra)。改名字、用途:PATCH /submissions/{id}/items/{item_id},{ "title": "PV 第二弹", "role": "pv" }(用途的代码在 /meta 的 content_types[].item_roles)。集的种类(正片、SP、OVA)在 PUT …/structure 的 units[].kind。

  • 转码要一会儿,不用等:结构里视频的 status 是 processing、progress 是进度,转完是 ready。转码中照样能设字幕、章节、填信息、提交;转码失败(failed)的要删掉重传。

  • 字幕、歌词:POST /submissions/{id}/items/{item_id}/subtitles,multipart/form-data:file、language(如 zh-Hans、ja)、可选的 label、default(true / false)。视频收 srt、ass、ssa、vtt,还收蓝光的 PGS 图形字幕(.sup,大小上限另算,见 GET /site 的 limits.pgs_max_bytes);音声的音轨收 LRC 歌词(01.flac 的歌词 01.lrc 就传到这条音轨)。文字字幕不是 UTF-8 的,Shift_JIS、GBK、Big5 会自动转成 UTF-8。一个视频、音频最多几条字幕看 GET /site 的 limits.subtitles_per_item,到了上限是 72017(data.reason 是 too_many_subtitles,data.limit 是上限)。

    python
    with open("ep01.zh.ass", "rb") as f:
        call("POST", f"/submissions/{sub['id']}/items/{item_id}/subtitles", {"language": "zh-Hans", "default": "true"},
             files={"file": ("ep01.zh.ass", f)})
  • ASS 用的字体:视频文件(MKV)里附带的字体转码时自动抽出来;ASS 字幕用到、文件里又没带的字体另传:POST /submissions/{id}/items/{item_id}/fonts,multipart/form-data:file(TTF、OTF、TTC、WOFF2,单个最大 GET /site 的 limits.font_max_bytes)。已有的 GET …/fonts(families 是字体里写的名字,ASS 的样式按它找字体),删 DELETE …/fonts/{font_id}。

  • 各集都用的字体传一次:POST /submissions/{id}/fonts(作品的字体,multipart 同上),作品里的各集都带上(各集的字体列表里 scope 是 work;那一集自己有同名的,用它自己的);列 GET /submissions/{id}/fonts,删 DELETE /submissions/{id}/fonts/{font_id}。媒体库要几秒到几分钟才复制到各集。作品发布以后在「我的作品」里改:/me/works/{work_id}/fonts(直接生效,不用审核)。

  • 章节和片头片尾:PUT /submissions/{id}/items/{item_id}/chapters,整体替换({} 清掉)。时间是秒,不能超出视频的时长;片头 intro、片尾 outro、前情回顾 recap 互不重叠。

    json
    { "default_lang": "zh-Hans",
      "chapters": [ { "start": 0, "titles": { "zh-Hans": "开头" } }, { "start": 92, "titles": { "zh-Hans": "正片" } } ],
      "skips": [ { "kind": "intro", "start": 0, "end": 89.5 }, { "kind": "outro", "start": 1350, "end": 1440 } ] }
  • 封面:动画不设时用第一集视频的封面帧,改截第几秒:PATCH …/items/{item_id},{ "poster_at": 12.5 }(要等转完;已经是、正在截、上一次没截成的那一秒再发不会重截,要重截换一秒);音声不设时用第一张插图,没有插图就没有封面。动画、音声都能另传一张图当封面:Grant(sub["id"], "cover") 传一张图拿 uid,再 PUT /submissions/{id}/cover,{ "uid": "…" }({ "uid": null } 去掉,回到默认的)。

  • 视频已经设好字幕、章节以后发现文件不对:原地换,不用删了重传(POST …/items/{item_id}/replace 要一张换文件的令牌,照断点续传传新文件,传完自动换上,换好之前旧文件照常能播)。

  • 重新转码:站点的转码设置调整以后(比如画质调高),以前转好的视频、音频要重新转一遍才用上新设置:POST /submissions/{id}/items/{item_id}/reprocess(草稿、被退回时);已经发布的作品用 POST /me/works/{work_id}/items/{item_id}/reprocess(直接生效,不用审核)。转的时候旧版本照常能播,进度同换文件看条目的 replacing。同一个文件 7 天内只能重新转码一次(72017,data.reason 是 too_soon,data.retry_at 是能再来的时间)。

音声:音轨用 audios、插图用 images 传(音轨的用途是 track,插图是 illustration);分组同动画的集。剧本、台本这类文本:POST /submissions/{id}/texts,{ "name": "script.txt", "content": "(全文)", "unit_id": 301 }。

填链接让服务器去下(漫画的图、动画、音声都能用,压缩包、封面不行):文件在网盘、图床上有直链的,不用自己下了再传:

python
d = call("POST", f"/submissions/{sub['id']}/remote-downloads", {"url": "https://files.example.com/ep03.mkv", "purpose": "videos",
         "unit_id": unit_id})
  • 下载在服务器上跑,下完自动挂到 unit_id 那一集(不带挂到最后一集),程序关了也不影响。GET /submissions/{id}/remote-downloads 看进度(status:queued / downloading / finalizing / done / failed,offset、length 是下了多少),失败的 error 是原因;DELETE …/remote-downloads/{download_id} 取消。

  • 只认直链(打开就是文件本身),不解析网页。

  • 没下完也能提交:交了以后下完照样挂进这份投稿(免审的下完、转完自动发布,要审核的等下完才能通过)。交了以后下载失败的,投稿自动退回(review.text 写哪个链接、什么原因),已经下好的留在投稿里,改好再交。交了以后不能再填链接、不能取消,要取消先撤回。

3.6 小说

小说不用上传令牌、结构这些接口,用写作的接口(/me/works/{work_id}/…,work_id 是投稿的 work_id)。两种写法:

导入 TXT / EPUB:

python
sub = call("POST", "/submissions", {"content_type": "novel"}, idempotent=True)
work = sub["work_id"]
chunk = call("GET", "/site")["limits"]["novel_import_chunk_bytes"]
url = tus_upload(f"{API}/me/works/{work}/import-uploads", "星海旅人.txt", {"filename": "星海旅人.txt"},
                 lambda: {"Authorization": session.headers["Authorization"]}, chunk)
imp = call("POST", f"/me/works/{work}/imports", {"upload_id": url.rsplit("/", 1)[1]})
# imp["outline"] 是切出来的卷和章,看一眼对不对;不对就 PATCH 改切章规则或大纲
imp = call("POST", f"/me/works/{work}/imports/{imp['id']}/apply", {"fill_form": True})
while imp["status"] == "applying":
    time.sleep(1)
    imp = call("GET", f"/me/works/{work}/imports/{imp['id']}")
  • 上传同样是 tus 断点续传,但发给接口服务器、带接口令牌,每块不超过 /site 的 limits.novel_import_chunk_bytes。

  • TXT 自动识别编码,按「第…章」「第…卷」「序章」「番外」这类标题切章(章名里有逗号这类标点、前面带【】这类括号的也认;认得不对就 PATCH 改切章规则或者改大纲);EPUB 按书里的目录切(目录里的一项一章,指着文件中间的从那里切开;书里没有能用的目录时一个文件一章)。注音、插图、原书的样式(居中、字号、粗体、颜色、缩进这些)都导进来,书里的脚注、尾注写成注释放在原处,原书给了大小的小图照原来的大小;封面和卷首只有插图的页(彩页)单独成一章「彩页」,书中间、末尾只有插图的页并进前一章,切得不对就改大纲。fill_form: true 把书名、简介、作者这些填进还空着的信息。

一章章写:

python
ov = call("GET", f"/me/works/{work}/chapters")                         # 卷和章的概览,带 version
made = call("POST", f"/me/works/{work}/chapters", {"version": ov["version"], "title": "第一章 星海"})
chapter = made["chapter"]
call("PUT", f"/me/works/{work}/chapters/{chapter['id']}/body", {"version": 0, "body": "那一年,星海还没有名字。"})

正文的写法和网站写作页里的一样,是纯文本加少量标记:

写法意思
一行一个段落;空行是段落之间的空白
|漢字《かんじ》注音
《《文字》》着重号
整行 [[image:uid]]、[[image:uid|说明]]插图(这部作品登记过的插图);行前加样式定对齐和大小:[[center width=3]][[image:uid]](width、height 单位 em)
整行 [[hr]]分隔线
行首 [[center 1.5x bold]]这一段的样式
[[#c03030 bold|文字]]这几个字的样式(里面可以有注音、着重号)
[[note:注释的文字]]注释:读者那边是一个记号,点了弹出;不算字数
||、|[[、|]]竖线、两个方括号本身

样式词用空格分开:left / center / right 对齐(段落、插图),1.5x 相对字号(0.5 ~ 3),bold / regular 粗细,italic / upright 斜体,serif / sans / mono 字体,#c03030 颜色,indent=2 首行缩进、before=1 / after=1 段前段后(段落,单位 em),spacing=0.1 字距,sup / sub 上下标、underline 下划线、strike 删除线(几个字的样式)。写错的标记按普通文字显示。写完可以 POST /me/works/{work}/preview({ "body": "…" })看解析出来的样子。卷:POST /me/works/{work}/units。

作品还没发布时,卷章跟着整部作品一起提交审核(第 3.8 节),不用一章章发布。

3.7 填信息

PATCH /submissions/{id},{ "version": 7, "form": { … } }:只改带了的字段(数组整个替换),返回改后的投稿。

python
sub = call("GET", f"/submissions/{sub['id']}")
sub = fill(sub, {
    "titles": [{"text": "夏日的约定", "lang": "zh"}, {"text": "夏の約束", "lang": "ja", "original": True}],
    "category_id": category_id("manga", "doujinshi"),
    "rating": "r18", "language": "zh", "translation_status": "translated",
    "description": "简介,可以用 **Markdown**。",
    "tags": [{"namespace": "female", "name": "双马尾"}, {"namespace": "artist", "name": "某某"}],
    "creators": [{"name": "某某", "role": "author"}],
})
字段说明
titles1 ~ 10 个,第一个是主标题;lang 是语言(如 zh、ja),original: true 标原名(最多一个)。必填
category_id分类 id(这个内容类型的)。必填
rating、language分级、作品语言的代码。必填
translation_status、serial_status翻译状态;连载状态(ongoing 连载中 / completed 已完结)
description简介,Markdown,最长 20000 个字符
aliases、codes、links、released_on别名;作品号([{ "kind": "rj", "value": "RJ01234567" }]);链接([{ "kind", "url", "title" }]);发行日期 YYYY-MM-DD
tags最多 200 个。写 { "namespace", "name" },名字可以是任何语言的名字、别名,自动换成正式的标签;还没有的标签在允许新提的命名空间里随投稿一起审核。写在作者这类命名空间里的(artist:某某)自动挪进创作者
creators最多 50 个。写 { "name", "role" },找不到的新建、随投稿一起审核
extra动画的放送时间:{ "broadcast": { "weekday": 6, "time": "23:30", "timezone": "Asia/Tokyo" } }(星期一是 1)
price定价:{ "mode": "default" } 用站点的公式,{ "mode": "custom", "amount": 30 } 自己定(0 是免费,上限见 /meta);这种内容不卖时为 null

代码从 GET /meta 取(不用登录):分类 categories(按 content_type 和 code 找 id)、分级 ratings、语言 languages、翻译状态 translation_statuses、标签命名空间 tag_namespaces、创作者角色 creator_roles、作品号种类 work_code_kinds。这些站长可以改,程序里不要写死。

字段不对返回 10001,data.errors 的键是字段路径(如 form.tags[2]),值是原因(required 必填、too_long、too_many、invalid、unknown 不存在的代码、new_tags_not_allowed 这个命名空间不让新提标签……)。

3.8 提交、看结果

GET /submissions/{id} 的 problems 是现在还不能提交的原因,空数组就能交:

code意思
required必填的信息没填(path 是哪个字段)
no_items、empty_unit一页(一个文件、一章)都没有;有空的话(集、卷)
media_failed有文件处理失败了(data.item_ids):删掉或重传
archive_processing压缩包还没解完
no_main_video动画有一集没有正片
no_tracks音声一条音轨都没有
empty_chapter、sensitive_words小说有空的章;正文里有敏感词(data.hits)

提交:POST /submissions/{id}/submit,{ "version": 7 }(客户端里的 submit(id))。

  • 一般变成 pending(待审),审核通过变 approved、作品发布;被退回是 rejected,原因在 review(text 是审核员写的,template 是选的模板),改好再提交就行。

  • 图片还在处理、视频还在转码、填的链接没下完(第 3.5 节)都能交。免审的账号交的时候文件还在处理或者在下:先 pending、publish_when_ready: true,都好了自动发布;下载失败的自动退回,原因在 review.text。有文件处理失败的先不退回:站点定期找文件服务核对,重试好了照常自动发布;auto_return_at 是到期的时间,到时候还没好才自动退回(原因在 review.text)。等不及可以先撤回,换掉、删掉失败的文件再交。

  • 待审的想改:POST /submissions/{id}/withdraw({ "version" })撤回成草稿。草稿、被退回的投稿不要了:DELETE /submissions/{id}(传上去的文件一起删掉)。

  • 审核结果会发站内通知(GET /me/notifications?kind=review);我的投稿:GET /submissions?status=pending,rejected(cursor 翻页)。

  • 草稿、待审的数量有上限(默认各 20、10,站长可以调),到了是 72005。

4. 更新作品

作品发布以后不能直接修改,要提交一份修改:接口上它也是一份投稿,建的时候带上作品的 work_id 和要改什么(kind:new_units 加新的一集、work_edit 改作品信息和封面、unit_edit 改已经上线的集,几集可以放在一份里),像发新作品一样准备好、提交审核。审核通过以后改动才生效,在那之前读者看到的还是原来的样子;免审的账号提交即生效。小说的章节不用另建投稿:新章和改过的正文在作品上一章一章提交,同样要审核、通过前读者看到的不变(第 4.5 节)。

作品的创建者和共创者都能做这些(改价格只有创建者;共创者的申请、邀请见「接口文档」第 4 节的「作品共创」)。

4.1 加新的一集(一话)

python
sub = call("POST", "/submissions", {"kind": "new_units", "work_id": 12}, idempotent=True)
st = call("GET", f"/submissions/{sub['id']}/structure")
st = call("PUT", f"/submissions/{sub['id']}/structure", {"version": st["version"], "cover_item_id": None, "removed_item_ids": [],
          "units": [{"id": None, "title": "第3集", "item_ids": []}]})
upload_av(Grant(sub["id"], "videos"), "ep03.mkv", st["units"][0]["id"])  # 传完就登记,收好以后自动挂上
sub = call("GET", f"/submissions/{sub['id']}")
sub = call("PATCH", f"/submissions/{sub['id']}", {"version": sub["version"], "publish_at": "2026-10-20T23:30:00+09:00"})  # 定时,可选
submit(sub["id"])
  • 传文件、挂文件、排顺序、字幕章节都同新作品;结构里只有这次要加的(作品里已有的看不到、也动不了)。漫画传压缩包同样能用,建出来的话就是这次加的。

  • publish_at 定时上线:审核通过以后到点公开(默认最远 60 天以后);不定时就是通过马上公开;通过时定的时间已经过了的马上公开。同一部作品可以同时准备好几份,通过的顺序就是接上去的顺序。

  • 小说不用这个,见第 4.5 节。

4.2 改作品信息、封面

python
sub = call("POST", "/submissions", {"kind": "work_edit", "work_id": 12})
sub = fill(sub, {"description": "新的简介", "serial_status": "completed"})
submit(sub["id"])
  • 同一部作品同时只有一份没完成的修改:自己的就返回那一份;别人(创建者或别的共创者)正在改时是 72025(data.account 是谁在改)。价格只有创建者能改(共创者带了 price 是 10003)。form 里没改的字段一直是作品现在的值;changes 是改了哪些。

  • tags 是作品显示的那份标签整个换掉([] 是一个都不要)。

  • 换封面:PUT /submissions/{id}/cover,{ "item_id": 5003 }(选作品里的一页、一个视频、一张插图);动画、音声也能另传一张:{ "uid": "…" }(先 Grant(sub["id"], "cover") 传一张图)。

4.3 改已经上线的集

python
full = call("GET", "/me/works/12/structure")              # 作品现在的全部单元(各自的上线状态)
eps = [u["id"] for u in full["units"][:3]]                  # 要改的几集
sub = call("POST", "/submissions", {"kind": "unit_edit", "work_id": 12, "unit_ids": eps})
st = call("GET", f"/submissions/{sub['id']}/structure")   # 这几集的副本:在这里改
  • 能改标题、顺序,去掉条目(removed_item_ids),传新的文件挂进来(同新作品)。复制来的条目(origin_item_id 不为空)能改名字、用途;字幕、默认音轨、章节、封面帧也能改(接口同新作品,第 3.5 节),改动记在这份修改上、通过以后才换到线上:这时条目带 staged(改了什么),新传的字幕 status 是 pending、id 是 draft.{n},通过以后才转换。文件本身换不了(线上正在用):要换就传一个新的放到原位置、去掉旧的。只改了这些也能提交。字体改不了(72003,要换找站务);重新转码不用建修改,直接用 POST /me/works/{work_id}/items/{item_id}/reprocess。

  • 一部作品同时只有一份能改的改集修改:之后再改别的集,POST /submissions 带上那一集(unit_id 或 unit_ids)还是返回这一份、把它加进来;也可以 POST /submissions/{id}/units({ "unit_ids": [...] })加几集,DELETE /submissions/{id}/units/{unit_id} 把一集移出(这一集的改动不要了)。结构里每一集的 origin_unit_id 是原作品里的哪一集;PUT …/structure 要带着这几集、顺序不变,复制来的条目不能挪到别的集。提交以后在审核中的不能再加集,要先撤回(withdraw)。

  • 改完 submit,几集一起审核;通过以后改了的几集一起换成这份的样子,没改的不动。

4.4 定时的一集改时间、马上上线

PUT /me/works/{work_id}/units/{unit_id}/schedule:{ "status": "scheduled", "publish_at": "…" } 改时间,{ "status": "published" } 马上上线。直接生效,不用审核。

4.5 小说:发新章、改已发布的章

  • 新章照第 3.6 节写好,再发布:POST /me/works/{work_id}/chapters/{item_id}/publish,{ "publish_at": null }(马上)或定时;一次发好几章:POST /me/works/{work_id}/chapters/publish,{ "items": [ { "id": 5014, "publish_at": null } ] }(最多 50 章,各自返回结果)。不免审的进审核,通过以后公开。

  • 已发布的章改正文:先 PUT …/chapters/{item_id}/body 改工作稿(读者看到的不变),改好 POST …/chapters/{item_id}/body/publish({ "version" })更新。

  • 撤回审核中的章、取消定时:POST …/chapters/{item_id}/withdraw。

  • 发布时查敏感词(73003,data.hits)和空的正文(73005)。

4.6 要找站务的

自己下架一集、调整已上线的集的顺序、把定时的一集改回暂不公开、删掉已经发布的作品:在网站上联系站务。换已上线的一话里的页用第 4.3 节。

5. 常见问题

遇到怎么办
传文件时 401上传令牌过期了:再要一张同样 purpose 的,换上它接着传原来的地址(客户端自动换)
建上传时 HTTP 429同时在传、在处理的太多:等一分钟再传
断点续传断了HEAD {Location} 拿 Upload-Offset 接着传;24 小时内都行
10005别处先改了:data 是最新的,换上它的 version 再来
提交返回 72004看 data.problems(第 3.8 节):处理失败的文件删掉重传,压缩包没解完的等一会儿(链接没下完、文件还在转码的不拦提交)
压缩包解出来话名、页名乱码换编码重解(第 3.4 节),不用重传
漫画图片少了几页解压报告的 skipped_items 写了跳过的原因(太大、像素太多、不是图片……)
视频一直 processing转码在排队,长视频要一阵子;不用等,可以先提交