投稿接口
更新于 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 *。
"""投稿用的上传工具:和「接口文档」页的 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 建草稿
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 | 断点续传每块传多大(字节),照它来 |
单张图片(两步):
POST {endpoints.upload_url},JSON:fields里的每一项,加size(文件的字节数)、filename、wait: 10→data.url(一台收文件的服务器上的地址,24 小时内有效)。回60044是暂时没有能收文件的服务器,过一会儿再要。把文件
multipart/form-data(只要file)POST 到data.url,带同样的Authorization→ 响应的data.uid。没收到响应时把同一个文件再 POST 到同一个地址(收齐了的直接回结果)。重传接着用第 1 步的地址,不要再要新的(每要一次占一个「同时没传完的」名额)。不传了(不要这张了、换一张):
DELETE {endpoints.resumable}/{id}(id是第 1 步回的data.id,请求头Tus-Resumable: 1.0.0,带同样的Authorization)→204;已经收齐、在处理的回 HTTP423(60017),等结果就行。
# 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),断了从断点接着传:
POST {endpoints.resumable},请求头Tus-Resumable: 1.0.0、Upload-Length: <文件字节数>、Upload-Metadata:filename和fields的每一项,写成键 base64(值)、逗号隔开;压缩包另加archive(值是zip)→ HTTP201,响应头Location是这次上传的地址(可能是别的域名,是收文件的节点,照它发)。一块一块
PATCH {Location}:Content-Type: application/offset+octet-stream,Upload-Offset: <已经传了多少>,每块chunk_size字节 →204,响应头Upload-Offset是新的位置。断了、超时、收到
423(上一块还没收完)、5xx:等几秒,HEAD {Location}拿Upload-Offset,从那里接着传。重试要能盖住约 5 分钟(收文件的服务器重启要两分多钟)。上传在最后一次收到数据以后 24 小时内都能接着传,但只能在建上传时给的Location上接着传:那台服务器坏了、PATCH 回60045,这个上传接不上了,从头建一个新的重传。传完以后:视频、音频登记给站点:
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是原因(类型不支持、太长……),要换文件重传。不传了(不要这个文件了、换文件、要从头重传):
DELETE {Location}(请求头Tus-Resumable: 1.0.0,带令牌)→204,名额马上放出来。回 HTTP423(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(不给用文件名)。
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)。一部作品可以传好几个包,按上传的顺序一个个解,建出来的话接在后面。
一张张传:
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 回去:
{ "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 动画、音声
动画:先建好各集,再把每集的视频挂上去:
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是上限)。pythonwith 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 }。
填链接让服务器去下(漫画的图、动画、音声都能用,压缩包、封面不行):文件在网盘、图床上有直链的,不用自己下了再传:
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:
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把书名、简介、作者这些填进还空着的信息。
一章章写:
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": { … } }:只改带了的字段(数组整个替换),返回改后的投稿。
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"}],
})| 字段 | 说明 |
|---|---|
titles | 1 ~ 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 加新的一集(一话)
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 改作品信息、封面
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 改已经上线的集
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 | 转码在排队,长视频要一阵子;不用等,可以先提交 |