Files
dsh-acfun/lib/provider.js
T
tamakyi 97825f020d feat: QR login, AcFun settings panel, and 动态 (moment) tools
- scan.acfun.cn QR flow (start -> long-poll scanResult -> acceptResult) with
  rotated-signature handling, background poller, durable session in the Host
  credential service, and cookie handoff to the provider
- Client half: Settings -> AcFun page (status, QR code, poll, logout) over a
  same-origin /acfun-panel JSON API with cross-origin rejection
- acfun_moment / acfun_moment_comments / acfun_topic read tools, moment_like /
  moment_unlike / moment_banana actions, and comment posting on moments
- acfun_account status tool; docs for the QR contract, the 广场/动态 research
  verdict, and the client-composition restart requirement
2026-10-27 11:50:44 +08:00

1501 lines
58 KiB
JavaScript

/**
* AcFun (acfun.cn) web API provider.
*
* Zero dependencies: this module only uses the global `fetch`, `URL`,
* `URLSearchParams`, and `AbortSignal`. No `@deepseek-ai/*` import appears in
* the package, so a workspace-linked bundle activates without installing a
* second copy of the harness core.
*
* Verified surface (2026-10, anonymous):
* GET /rest/pc-direct/search/video?keyword= keyword video search
* GET /v/ac{id} page-embedded videoInfo + play URLs
* GET /rest/pc-direct/comment/listByFloor comment list (floor order)
* POST /rest/pc-direct/rank/channel day/week ranking
* Cookie-authenticated surface (routes verified, responses verified as
* "user not login" without a session):
* POST /rest/pc-direct/relation/follow follow / unfollow
* POST /rest/pc-direct/banana/throwBanana tip bananas
* POST /rest/zt/interact/add|delete like / unlike (signed token)
* POST /rest/pc-direct/favorite/... favorite / unfavorite
* POST /rest/pc-direct/comment/add post a comment
*/
/** Default origin for every acfun.cn web page and pc-direct API. */
export const DEFAULT_BASE_URL = 'https://www.acfun.cn'
/** Origin of the token service used by the like flow. */
export const DEFAULT_TOKEN_BASE_URL = 'https://id.app.acfun.cn'
/** Origin of the signed interaction API (like / unlike). */
export const DEFAULT_INTERACT_BASE_URL = 'https://kuaishouzt.com'
/** Desktop Chrome UA; AcFun rejects some obviously scripted user agents. */
export const DEFAULT_USER_AGENT =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'
/** The `sourceType` AcFun uses for video (douga) comment threads. */
export const COMMENT_SOURCE_TYPE = 3
/** The `sourceType` AcFun uses for moment (动态) comment threads. */
export const MOMENT_COMMENT_SOURCE_TYPE = 4
/** The `resourceType` AcFun uses for moment likes / bananas. */
export const MOMENT_RESOURCE_TYPE = 10
/** Canonical public URL of one AcFun moment (user-facing share page). */
export function momentShareUrl(momentId) {
return `https://m.acfun.cn/communityCircle/moment/${momentId}`
}
/** The `resourceType` AcFun uses for video favorites. */
export const FAVORITE_RESOURCE_TYPE = 9
/** The `resourceType` AcFun uses for the danmaku list of one part. */
export const DANMAKU_RESOURCE_TYPE = 9
/** Largest danmaku page the plugin requests. */
export const DANMAKU_MAX_COUNT = 200
/** Search sort keys accepted by `acfun_search`. */
export const SEARCH_SORTS = Object.freeze(['relevance', 'views'])
/** `sid` required by id.app.acfun.cn for a like token. */
export const MIDGROUND_TOKEN_SID = 'acfun.midground.api'
/** Ranking periods the rank/channel endpoint accepts. */
export const RANK_PERIODS = Object.freeze(['day', 'week'])
/** The marker that precedes the video metadata JSON on a /v/ac page. */
const VIDEO_INFO_MARKER = 'window.pageInfo = window.videoInfo = '
/** Comment UBB emot token, for example `[emot=acfun,2789/]`. */
const UBB_EMOT = /\[emot=[^\]]*\]/gi
/**
* One AcFun failure with a stable code and a model-facing message.
*/
export class AcfunError extends Error {
/**
* @param {string} message - human-readable, actionable message.
* @param {{code?: string, status?: number, route?: string, detail?: string}} [info]
*/
constructor(message, info = {}) {
super(message)
this.name = 'AcfunError'
this.code = info.code ?? 'ACFUN_ERROR'
this.status = info.status
this.route = info.route
this.detail = info.detail
}
}
/** Trim a trailing slash so path joins stay predictable. */
function withoutTrailingSlash(value) {
return value.endsWith('/') ? value.slice(0, -1) : value
}
/** Parse one Cookie header into a plain name → value map. */
export function parseCookieHeader(header) {
const out = Object.create(null)
if (typeof header !== 'string') return out
for (const part of header.split(';')) {
const index = part.indexOf('=')
if (index <= 0) continue
const name = part.slice(0, index).trim()
if (name.length === 0) continue
out[name] = part.slice(index + 1).trim()
}
return out
}
/** Decode the small set of HTML entities AcFun uses in text fields. */
export function decodeEntities(value) {
if (typeof value !== 'string' || value.indexOf('&') < 0) return value ?? ''
return value
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#0*39;|&apos;/g, "'")
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
}
/** Turn AcFun UBB comment markup into readable plain text. */
export function normalizeUbb(value) {
if (typeof value !== 'string') return ''
return decodeEntities(
value
.replace(/<br\s*\/?>/gi, '\n')
.replace(UBB_EMOT, '[表情]')
.replace(/\[\/?[a-z]+(?:=[^\]]*)?\]/gi, ''),
)
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
/** Format a millisecond duration as `h:mm:ss` or `m:ss`. */
export function formatDuration(milliseconds) {
const total = Math.max(0, Math.round((Number(milliseconds) || 0) / 1000))
const hours = Math.floor(total / 3600)
const minutes = Math.floor((total % 3600) / 60)
const seconds = total % 60
const pad = (n) => String(n).padStart(2, '0')
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`
}
/**
* Format an epoch-millisecond timestamp as an ISO date-time, or ''.
*
* @param {unknown} milliseconds
* @returns {string}
*/
export function formatTimestamp(milliseconds) {
const value = Number(milliseconds)
if (!Number.isFinite(value) || value <= 0) return ''
const date = new Date(value)
return Number.isNaN(date.getTime()) ? '' : date.toISOString()
}
/**
* Validate a banana (投蕉) amount.
*
* @param {unknown} count
* @returns {number} an integer between 1 and 10.
*/
function validateBananaCount(count) {
const amount = Number(count ?? 1)
if (!Number.isInteger(amount) || amount < 1 || amount > 10) {
throw new AcfunError('count must be an integer between 1 and 10.', { code: 'INVALID_ARGUMENT' })
}
return amount
}
/** The canonical web URL of one AcFun video. */
export function acfunVideoUrl(acId) {
return `https://www.acfun.cn/v/ac${acId}`
}
/**
* Normalize a `comment/listByFloor` payload (videos and moments share it).
*
* @param {Record<string, any>} data - the API body.
* @param {number} fallbackPage - page used when the body omits `curPage`.
* @returns {{items: object[], currentPage: number, totalPages: number, totalCount: number, pageSize: number}}
*/
export function mapCommentPage(data, fallbackPage = 1) {
const map = data.commentsMap !== null && typeof data.commentsMap === 'object' ? data.commentsMap : {}
const orderedIds = Array.isArray(data.commentIds)
? data.commentIds.map((id) => String(id))
: Object.keys(map).map((key) => key.replace(/^c/, ''))
const items = []
for (const id of orderedIds) {
const entry = map[`c${id}`] ?? map[id]
if (entry === null || typeof entry !== 'object') continue
items.push({
commentId: String(entry.cid ?? id),
userId: String(entry.userId ?? ''),
userName: String(entry.userName ?? ''),
content: normalizeUbb(entry.content),
floor: Number(entry.floor) || 0,
postedAt: String(entry.postDate ?? ''),
timestamp: Number(entry.timestamp) || 0,
isUp: entry.isUp === true,
isDeleted: entry.isDelete === true,
})
}
return {
items,
currentPage: Number(data.curPage) || fallbackPage,
totalPages: Number(data.totalPage) || 1,
totalCount: Number(data.totalCount) || items.length,
pageSize: Number(data.pageSize) || items.length,
}
}
/**
* Accept `42588725`, `ac42588725`, `AC42588725`, or any acfun.cn URL that
* contains `/v/ac<digits>`, and return the bare numeric ac id.
*
* @param {unknown} input - model-supplied video identifier.
* @returns {string} bare numeric ac id.
*/
export function normalizeAcId(input) {
if (typeof input !== 'string') {
throw new AcfunError('acId must be a string such as "42588725" or "ac42588725".', {
code: 'INVALID_ARGUMENT',
})
}
const value = input.trim()
if (/^\d+$/.test(value)) return value
const prefixed = /^ac(\d+)$/i.exec(value)
if (prefixed !== null) return prefixed[1]
const fromUrl = /\/v\/ac(\d+)/i.exec(value) ?? /[?&](?:ac|acId|videoId)=(\d+)/i.exec(value)
if (fromUrl !== null) return fromUrl[1]
throw new AcfunError(
`Cannot read an AcFun video id from "${value}". Pass the numeric id (42588725), the "ac" form (ac42588725), or a https://www.acfun.cn/v/ac... URL.`,
{ code: 'INVALID_ARGUMENT' },
)
}
/**
* Scan a document for `marker` followed by a balanced JSON value and parse it.
*
* AcFun embeds `window.pageInfo = window.videoInfo = {...}` inside an inline
* script, so a plain JSON.parse of the page is impossible.
*
* @param {string} html - full page source.
* @param {string} marker - exact prefix to find.
* @returns {Record<string, any> | undefined} parsed value, or undefined.
*/
export function extractAssignedJson(html, marker) {
if (typeof html !== 'string') return undefined
const start = html.indexOf(marker)
if (start < 0) return undefined
const from = start + marker.length
let depth = 0
let inString = false
let escaped = false
for (let index = from; index < html.length; index += 1) {
const char = html[index]
if (inString) {
if (escaped) escaped = false
else if (char === '\\') escaped = true
else if (char === '"') inString = false
continue
}
if (char === '"') {
inString = true
continue
}
if (char === '{') depth += 1
else if (char === '}') {
depth -= 1
if (depth === 0) {
try {
return JSON.parse(html.slice(from, index + 1))
} catch {
return undefined
}
}
}
}
return undefined
}
/**
* Accept a numeric moment id, a `momentId=` query fragment, or a
* `m.acfun.cn/communityCircle/moment/<id>` share URL.
*
* @param {unknown} input - model-supplied moment identifier.
* @returns {string} bare numeric moment id.
*/
export function normalizeMomentId(input) {
if (typeof input !== 'string') {
throw new AcfunError('momentId must be a string such as "5093831" or a communityCircle/moment URL.', {
code: 'INVALID_ARGUMENT',
})
}
const value = input.trim()
if (/^\d+$/.test(value)) return value
const fromUrl = /\/moment\/(\d+)/i.exec(value) ?? /[?&]momentId=(\d+)/i.exec(value)
if (fromUrl !== null) return fromUrl[1]
throw new AcfunError(
`Cannot read an AcFun moment id from "${value}". Pass the numeric momentId or a https://m.acfun.cn/communityCircle/moment/... URL.`,
{ code: 'INVALID_ARGUMENT' },
)
}
/**
* Read the H.264 representation ladder out of a `ksPlayJson` string.
*
* @param {unknown} playJson - the JSON string (or object) AcFun embeds.
* @returns {Array<{label: string, width: number, height: number, url: string}>}
*/
export function readPlayQualities(playJson) {
let parsed = playJson
if (typeof playJson === 'string') {
try {
parsed = JSON.parse(playJson)
} catch {
return []
}
}
if (parsed === null || typeof parsed !== 'object') return []
const sets = Array.isArray(parsed.adaptationSet) ? parsed.adaptationSet : []
const qualities = []
for (const set of sets) {
const representations = Array.isArray(set?.representation) ? set.representation : []
for (const representation of representations) {
if (typeof representation?.url !== 'string' || representation.url.length === 0) continue
const height = Number(representation.height) || 0
const width = Number(representation.width) || 0
const label =
typeof representation.qualityLabel === 'string' && representation.qualityLabel.length > 0
? representation.qualityLabel
: height > 0
? `${height}P`
: typeof representation.name === 'string'
? representation.name
: 'auto'
qualities.push({ label, width, height, url: representation.url })
}
}
qualities.sort((left, right) => right.height - left.height)
return qualities
}
/** Strip HTML tags/scripts and collapse whitespace into one readable line. */
function stripHtml(value) {
return decodeEntities(
String(value ?? '')
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<[^>]*>/g, ' '),
)
.replace(/\s+/g, ' ')
.trim()
}
/** First capture group of `pattern` in `html`, HTML-stripped, or ''. */
function firstGroup(html, pattern) {
const match = pattern.exec(html)
return match === null ? '' : stripHtml(match[1])
}
/**
* Parse one server-rendered `m.acfun.cn/communityCircle/{tagId}` page.
*
* @param {string} html - full mobile topic page.
* @param {string} tagId - requested topic id (fallback for the link column).
* @returns {Record<string, any>}
*/
export function parseTopicPage(html, tagId) {
const pageText = stripHtml(html)
const tagName =
/【(.+?)】/.exec(firstGroup(html, /<title[^>]*>([\s\S]*?)<\/title>/i))?.[1] ??
(firstGroup(html, /class="tag-name"[^>]*>([\s\S]*?)<\//i) || `话题 ${tagId}`)
const followersText = /(\d[\d,.]*\s*万?)\s*关注/.exec(pageText)?.[1] ?? ''
const contentsText = /(\d[\d,.]*\s*万?)\s*内容/.exec(pageText)?.[1] ?? ''
const items = []
const chunkPattern = /<li[^>]*class="[^"]*resources-item[^"]*"([\s\S]*?)(?=<li[^>]*class="[^"]*resources-item|<\/ul>|$)/gi
let match
while ((match = chunkPattern.exec(html)) !== null) {
const chunk = match[1]
const momentId = /moment-id="(\d*)"/.exec(chunk)?.[1] ?? ''
const resourceType = Number(/resource-type="(\d*)"/.exec(chunk)?.[1] ?? 0) || 0
const resourceId = /resource-id="(\d*)"/.exec(chunk)?.[1] ?? ''
if (momentId.length === 0 && resourceId.length === 0) continue
const url =
momentId.length > 0
? momentShareUrl(momentId)
: resourceType === 2 && resourceId.length > 0
? acfunVideoUrl(resourceId)
: resourceId.length > 0
? `https://www.acfun.cn/a/ac${resourceId}`
: `https://m.acfun.cn/communityCircle/${tagId}`
items.push({
momentId,
resourceType,
resourceId,
title: firstGroup(chunk, /class="article-title"[^>]*>([\s\S]*?)<\/p>/i),
body: firstGroup(chunk, /class="article-body"[^>]*>([\s\S]*?)<\/p>/i),
authorName: firstGroup(chunk, /class="user-name"[^>]*>([\s\S]*?)<\/(?:p|span)>/i),
publishedAt: firstGroup(chunk, /class="release-time"[^>]*>([\s\S]*?)<\/(?:p|span)>/i),
tags: [...chunk.matchAll(/class="tag-item"[^>]*>([\s\S]*?)<\/a>/gi)].map((entry) => stripHtml(entry[1])).filter(Boolean),
coverUrl: /<img[^>]+src="([^"]+)"/i.exec(chunk)?.[1] ?? '',
statTexts: [...chunk.matchAll(/class="base-text"[^>]*>([\s\S]*?)<\//gi)].map((entry) => stripHtml(entry[1])).filter(Boolean),
url,
})
}
return {
tagId,
tagName,
followersText,
contentsText,
returned: items.length,
items,
note: 'HTML topic page: at most 15 newest items, no pagination; only resourceType 3 items carry a momentId.',
}
}
/**
* One AcFun API client bound to a cookie and timeout budget.
*/
export class AcfunProvider {
/**
* @param {object} [options]
* @param {string} [options.baseUrl] - acfun.cn origin.
* @param {string} [options.tokenBaseUrl] - token service origin.
* @param {string} [options.cookie] - full Cookie header value; empty means anonymous.
* @param {string} [options.userAgent] - User-Agent header.
* @param {number} [options.requestTimeoutMs] - per-HTTP-request timeout.
* @param {boolean} [options.allowWriteActions] - gate for every mutating call.
*/
constructor(options = {}) {
this.baseUrl = withoutTrailingSlash(options.baseUrl ?? DEFAULT_BASE_URL)
this.tokenBaseUrl = withoutTrailingSlash(options.tokenBaseUrl ?? DEFAULT_TOKEN_BASE_URL)
this.interactBaseUrl = withoutTrailingSlash(options.interactBaseUrl ?? DEFAULT_INTERACT_BASE_URL)
this.cookie = typeof options.cookie === 'string' ? options.cookie.trim() : ''
this.userAgent = options.userAgent ?? DEFAULT_USER_AGENT
this.requestTimeoutMs = options.requestTimeoutMs ?? 30_000
this.allowWriteActions = options.allowWriteActions === true
this.cookies = parseCookieHeader(this.cookie)
}
/** Whether a login cookie is configured. */
get loggedIn() {
return this.cookie.length > 0
}
/**
* Replace the session cookie (used after QR login and logout).
*
* @param {string} cookie - full Cookie header value; empty clears it.
*/
setCookie(cookie) {
this.cookie = typeof cookie === 'string' ? cookie.trim() : ''
this.cookies = parseCookieHeader(this.cookie)
}
/**
* Describe the configured session without exposing it.
*
* @returns {{configured: boolean, length: number, hasAuthKey: boolean, hasDid: boolean}}
*/
describeSession() {
return {
configured: this.cookie.length > 0,
length: this.cookie.length,
hasAuthKey: typeof this.cookies.auth_key === 'string' && this.cookies.auth_key.length > 0,
hasDid: typeof this.cookies._did === 'string' && this.cookies._did.length > 0,
}
}
/** Whether mutating calls are permitted by configuration and login state. */
get writable() {
return this.allowWriteActions && this.loggedIn
}
/** Reject a mutating call with a specific, actionable reason. */
assertWritable(action = 'this action') {
if (!this.allowWriteActions) {
throw new AcfunError(
`${action} is disabled: set "allowWriteActions: true" in this plugin's config to enable AcFun interactions.`,
{ code: 'WRITE_DISABLED' },
)
}
if (!this.loggedIn) {
throw new AcfunError(
`${action} requires an AcFun login cookie: set "cookie" in this plugin's config (or the ${'ACFUN_COOKIE'} environment variable).`,
{ code: 'LOGIN_REQUIRED' },
)
}
}
/** Headers shared by every request. */
#headers(extra = {}) {
const headers = {
'User-Agent': this.userAgent,
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.6',
Referer: `${this.baseUrl}/`,
...extra,
}
if (this.cookie.length > 0) headers.Cookie = this.cookie
return headers
}
/** Combine the caller signal with the request timeout. */
#signal(signal) {
const timeout = AbortSignal.timeout(this.requestTimeoutMs)
if (!signal) return timeout
return typeof AbortSignal.any === 'function' ? AbortSignal.any([signal, timeout]) : timeout
}
/** Build one absolute URL with an optional query object. */
#url(pathOrUrl, query) {
const absolute = /^https?:\/\//i.test(pathOrUrl) ? pathOrUrl : `${this.baseUrl}${pathOrUrl}`
if (query === undefined) return absolute
const url = new URL(absolute)
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null) continue
url.searchParams.set(key, String(value))
}
return url.toString()
}
/**
* Perform one HTTP request and return the raw text plus response metadata.
*
* @param {string} pathOrUrl - path on baseUrl, or absolute URL.
* @param {{method?: string, query?: Record<string, unknown>, form?: Record<string, unknown>, jsonBody?: unknown, headers?: Record<string, string>, signal?: AbortSignal, accept?: string}} [options]
* @returns {Promise<{status: number, contentType: string, text: string, url: string}>}
*/
async #http(pathOrUrl, options = {}) {
const method = options.method ?? 'GET'
const headers = this.#headers({ Accept: options.accept ?? 'application/json, text/plain, */*', ...options.headers })
let body
if (options.form !== undefined) {
headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8'
headers['X-Requested-With'] = 'XMLHttpRequest'
body = new URLSearchParams()
for (const [key, value] of Object.entries(options.form)) {
if (value === undefined || value === null) continue
body.set(key, String(value))
}
} else if (options.jsonBody !== undefined) {
headers['Content-Type'] = 'application/json;charset=UTF-8'
headers['X-Requested-With'] = 'XMLHttpRequest'
body = JSON.stringify(options.jsonBody)
}
const url = this.#url(pathOrUrl, options.query)
let response
try {
response = await fetch(url, { method, headers, body, signal: this.#signal(options.signal), redirect: 'follow' })
} catch (cause) {
if (cause?.name === 'AbortError' || cause?.name === 'TimeoutError') {
throw new AcfunError(
`AcFun request timed out after ${this.requestTimeoutMs} ms (${method} ${url}).`,
{ code: 'TIMEOUT', route: url, detail: String(cause) },
)
}
throw new AcfunError(`Network error talking to AcFun (${method} ${url}): ${String(cause?.message ?? cause)}`, {
code: 'NETWORK',
route: url,
detail: String(cause),
})
}
const text = await response.text()
return {
status: response.status,
contentType: response.headers.get('content-type') ?? '',
text,
url: response.url || url,
}
}
/**
* Perform one JSON API call and unwrap the AcFun `{result: 0, ...}` envelope.
*
* @param {string} path - pc-direct path.
* @param {object} [options] - same options as #http.
* @returns {Promise<Record<string, any>>} the parsed body when `result === 0`.
*/
async #api(path, options = {}) {
const response = await this.#http(path, options)
if (!response.contentType.includes('json')) {
if (response.status === 404) {
throw new AcfunError(`AcFun endpoint not found: ${path}`, { code: 'NOT_FOUND', route: path, status: 404 })
}
throw new AcfunError(
`AcFun returned a non-JSON response for ${path} (HTTP ${response.status}). The endpoint may have moved.`,
{ code: 'UNEXPECTED_RESPONSE', route: path, status: response.status, detail: response.text.slice(0, 200) },
)
}
let payload
try {
payload = JSON.parse(response.text)
} catch {
throw new AcfunError(`AcFun returned malformed JSON for ${path}.`, {
code: 'MALFORMED_JSON',
route: path,
status: response.status,
detail: response.text.slice(0, 200),
})
}
if (payload !== null && typeof payload === 'object' && typeof payload.result === 'number' && payload.result !== 0) {
const accepted = Array.isArray(options.okResults) ? options.okResults : []
if (!accepted.includes(payload.result)) throw this.#apiError(path, payload, response.status)
}
return payload
}
/** Map an AcFun `result` code to a stable error. */
#apiError(path, payload, status) {
const code = payload.result
const serverMessage = typeof payload.error_msg === 'string' ? payload.error_msg : ''
const base = `AcFun API ${path} failed with result ${code}${serverMessage ? `: ${serverMessage}` : ''}`
if (code === -401 || code === 401) {
return new AcfunError(`${base}. The configured AcFun login cookie is missing or has expired.`, {
code: 'LOGIN_REQUIRED',
route: path,
status,
detail: serverMessage,
})
}
if (code >= 400_001 && code <= 410_999) {
return new AcfunError(
`${base}. AcFun asks for an identity-verification/CAPTCHA step for this account, which this plugin cannot complete; finish the action once in a browser and retry later.`,
{ code: 'CAPTCHA_REQUIRED', route: path, status, detail: serverMessage },
)
}
if (code === 140_002 || code === 140_005) {
return new AcfunError(
`${base}. ${code === 140_002 ? 'The moment id is outside the current id range.' : 'The moment does not exist or was deleted.'}`,
{ code: 'NOT_FOUND', route: path, status, detail: serverMessage },
)
}
return new AcfunError(base, { code: 'API_ERROR', route: path, status, detail: serverMessage })
}
/**
* Search AcFun videos by keyword.
*
* The current pc-direct search endpoint ignores paging parameters and always
* returns its first page (server default `pageSize: 30`), so this returns at
* most 30 items. `sortType` is honoured: 0/omitted is relevance and 2 is
* most-viewed.
*
* @param {string} keyword - non-empty search phrase.
* @param {{sort?: string, signal?: AbortSignal}} [options]
* @returns {Promise<{keyword: string, sort: string, total: number, returned: number, items: object[]}>}
*/
async search(keyword, options = {}) {
const phrase = typeof keyword === 'string' ? keyword.trim() : ''
if (phrase.length === 0) {
throw new AcfunError('keyword must be a non-empty string.', { code: 'INVALID_ARGUMENT' })
}
const sort = typeof options.sort === 'string' ? options.sort.trim() : 'relevance'
if (!SEARCH_SORTS.includes(sort)) {
throw new AcfunError(`sort must be one of ${SEARCH_SORTS.join(', ')}.`, { code: 'INVALID_ARGUMENT' })
}
const data = await this.#api('/rest/pc-direct/search/video', {
query: { keyword: phrase, ...sort === 'views' ? { sortType: 2 } : {} },
signal: options.signal,
})
const list = Array.isArray(data.videoList) ? data.videoList : []
const items = []
for (const entry of list) {
if (entry === null || typeof entry !== 'object') continue
const acId = entry.contentId ?? entry.id
if (acId === undefined || acId === null || !/^\d+$/.test(String(acId))) continue
items.push({
acId: String(acId),
title: decodeEntities(String(entry.title ?? '')).trim(),
authorId: String(entry.userId ?? ''),
authorName: decodeEntities(String(entry.userName ?? '')),
viewCount: Number(entry.viewCount) || 0,
danmakuCount: Number(entry.danmuCount) || 0,
commentCount: Number(entry.commentCount) || 0,
duration: typeof entry.playDuration === 'string' ? entry.playDuration : '',
publishedAt: formatTimestamp(entry.ctime),
coverUrl: typeof entry.coverUrl === 'string' ? entry.coverUrl : '',
url: acfunVideoUrl(String(acId)),
})
}
return {
keyword: phrase,
sort,
total: Number(data.totalNum) || items.length,
returned: items.length,
items,
}
}
/**
* Read one video's full metadata from its `/v/ac{id}` page.
*
* @param {string|number} input - ac id, "ac" form, or acfun URL.
* @param {{signal?: AbortSignal}} [options]
* @returns {Promise<Record<string, any>>}
*/
async video(input, options = {}) {
const acId = normalizeAcId(String(input))
const response = await this.#http(`/v/ac${acId}`, {
accept: 'text/html,application/xhtml+xml',
signal: options.signal,
})
if (response.status === 404 || response.status === 502) {
throw new AcfunError(
`AcFun video ${acId} is unavailable (HTTP ${response.status}). It may have been deleted or restricted.`,
{ code: 'NOT_FOUND', route: `/v/ac${acId}`, status: response.status },
)
}
if (response.status !== 200) {
throw new AcfunError(`AcFun returned HTTP ${response.status} for video ${acId}.`, {
code: 'HTTP_ERROR',
route: `/v/ac${acId}`,
status: response.status,
})
}
const info = extractAssignedJson(response.text, VIDEO_INFO_MARKER)
if (info === undefined || info === null) {
if (response.text.includes('bangumiData') || response.text.includes('articleInfo')) {
throw new AcfunError(
`AcFun ${acId} resolves to a bangumi or article page, not a video page; only video pages are supported.`,
{ code: 'UNSUPPORTED_RESOURCE', route: `/v/ac${acId}` },
)
}
throw new AcfunError(
`Could not read metadata from AcFun video ${acId}; the page layout may have changed.`,
{ code: 'PARSE_ERROR', route: `/v/ac${acId}` },
)
}
const dougaId = String(info.dougaId ?? '')
if (!/^\d+$/.test(dougaId)) {
throw new AcfunError(`AcFun video ${acId} did not expose a usable video id; it may be region-locked or deleted.`, {
code: 'NOT_FOUND',
route: `/v/ac${acId}`,
})
}
const user = info.user !== null && typeof info.user === 'object' ? info.user : {}
const channel = info.channel !== null && typeof info.channel === 'object' ? info.channel : {}
const tags = Array.isArray(info.tagList)
? info.tagList
.filter((tag) => tag !== null && typeof tag === 'object' && typeof tag.name === 'string')
.map((tag) => ({ name: tag.name, id: String(tag.id ?? '') }))
: []
const current = info.currentVideoInfo !== null && typeof info.currentVideoInfo === 'object' ? info.currentVideoInfo : {}
const durationMillis = Number(current.durationMillis) || Number(info.durationMillis) || 0
const description = typeof info.description === 'string' && info.description !== 'null' ? info.description : ''
const coverUrl =
typeof info.coverUrl === 'string' && info.coverUrl.length > 0
? info.coverUrl
: Array.isArray(info.coverCdnUrls) && typeof info.coverCdnUrls[0]?.url === 'string'
? info.coverCdnUrls[0].url
: ''
return {
acId: dougaId,
videoId: Number(info.currentVideoId) || 0,
title: String(info.title ?? '').trim(),
description: description.trim(),
authorId: String(user.id ?? ''),
authorName: String(user.name ?? ''),
authorAvatar: typeof user.headUrl === 'string' ? user.headUrl : '',
authorSignature: typeof user.signature === 'string' ? user.signature : '',
authorFollowers: Number(user.fanCountValue) || 0,
channel: String(channel.name ?? ''),
channelParent: String(channel.parentName ?? ''),
channelId: Number(channel.id) || 0,
tags,
durationMillis,
duration: formatDuration(durationMillis),
publishedAt: formatTimestamp(info.createTimeMillis),
viewCount: Number(info.viewCount) || 0,
danmakuCount: Number(info.danmakuCount) || 0,
commentCount: Number(info.commentCountRealValue ?? info.commentCount) || 0,
likeCount: Number(info.likeCount) || 0,
favoriteCount: Number(info.stowCount) || 0,
bananaCount: Number(info.bananaCount) || 0,
coverUrl,
url: acfunVideoUrl(dougaId),
loggedIn: this.loggedIn,
liked: info.isLike === true,
favorited: info.isFavorite === true,
bananaGiven: info.isThrowBanana === true,
}
}
/**
* Read the playable quality ladder of one video.
*
* @param {string|number} input - ac id, "ac" form, or acfun URL.
* @param {{signal?: AbortSignal}} [options]
* @returns {Promise<{acId: string, title: string, duration: string, qualities: Array<{label: string, width: number, height: number, url: string}>}>}
*/
async playUrls(input, options = {}) {
const acId = normalizeAcId(String(input))
const response = await this.#http(`/v/ac${acId}`, {
accept: 'text/html,application/xhtml+xml',
signal: options.signal,
})
if (response.status !== 200) {
throw new AcfunError(`AcFun returned HTTP ${response.status} for video ${acId}.`, {
code: response.status === 404 ? 'NOT_FOUND' : 'HTTP_ERROR',
route: `/v/ac${acId}`,
status: response.status,
})
}
const info = extractAssignedJson(response.text, VIDEO_INFO_MARKER)
if (info === undefined || info === null) {
throw new AcfunError(`Could not read play information for AcFun video ${acId}; the page layout may have changed.`, {
code: 'PARSE_ERROR',
route: `/v/ac${acId}`,
})
}
const current = info.currentVideoInfo !== null && typeof info.currentVideoInfo === 'object' ? info.currentVideoInfo : {}
let qualities = readPlayQualities(current.ksPlayJson)
if (qualities.length === 0) qualities = readPlayQualities(current.ksPlayJsonHevc)
if (qualities.length === 0) {
// Fallback: the page omitted ksPlayJson, so ask the play API directly.
// It addresses the part by `videoId` (currentVideoId), not by ac id.
const videoId = Number(info.currentVideoId) || Number(current.id) || 0
if (videoId > 0) {
try {
const data = await this.#api('/rest/pc-direct/play/playInfo/ksPlayJson', {
query: { videoId },
signal: options.signal,
})
qualities = readPlayQualities(data.playInfo?.ksPlayJson ?? data.ksPlayJson)
} catch (error) {
if (!(error instanceof AcfunError)) throw error
}
}
}
if (qualities.length === 0) {
throw new AcfunError(
`AcFun exposed no playable stream for video ${acId}. It may be a paid/paid-preview video, region-locked, or deleted.`,
{ code: 'NO_PLAY_URL', route: `/v/ac${acId}` },
)
}
return {
acId: String(info.dougaId ?? acId),
title: String(info.title ?? '').trim(),
duration: formatDuration(Number(current.durationMillis) || Number(info.durationMillis) || 0),
qualities,
}
}
/**
* Read one page of a video's comment list.
*
* @param {string|number} input - ac id, "ac" form, or acfun URL.
* @param {{page?: number, signal?: AbortSignal}} [options]
* @returns {Promise<Record<string, any>>}
*/
async comments(input, options = {}) {
const acId = normalizeAcId(String(input))
const page = Number(options.page ?? 1)
const data = await this.#api('/rest/pc-direct/comment/listByFloor', {
query: {
sourceId: acId,
sourceType: COMMENT_SOURCE_TYPE,
page: Number.isInteger(page) && page > 0 ? page : 1,
pivotCommentId: '',
newPivotCommentId: '',
t: '',
supportZtEmot: 'true',
},
signal: options.signal,
})
const pageData = mapCommentPage(data, page)
return {
acId,
page: pageData.currentPage,
totalPages: pageData.totalPages,
totalCount: pageData.totalCount,
pageSize: pageData.pageSize,
returned: pageData.items.length,
hasMore: pageData.currentPage < pageData.totalPages,
url: acfunVideoUrl(acId),
items: pageData.items,
}
}
/**
* Read one AcFun moment (动态) by id.
*
* `moment/detail` works anonymously for both the legacy (≤7525) and current
* (7-digit) id spaces; ids can only be discovered from a topic page or a
* following feed.
*
* @param {string|number} input - numeric moment id or communityCircle/moment URL.
* @param {{signal?: AbortSignal}} [options]
* @returns {Promise<Record<string, any>>}
*/
async moment(input, options = {}) {
const momentId = normalizeMomentId(String(input))
const data = await this.#api('/rest/pc-direct/moment/detail', {
query: { momentId },
signal: options.signal,
})
const raw = data.moment
if (raw === null || typeof raw !== 'object') {
throw new AcfunError(`AcFun did not return a moment for id ${momentId}.`, {
code: 'NOT_FOUND',
route: '/rest/pc-direct/moment/detail',
})
}
const user = raw.user !== null && typeof raw.user === 'object' ? raw.user : {}
const images = []
if (Array.isArray(raw.imgs)) {
for (const image of raw.imgs) {
if (image === null || typeof image !== 'object') continue
const url = image.originUrl || image.expandedUrl || image.url
if (typeof url === 'string' && url.length > 0) {
images.push({ url, width: Number(image.w ?? image.width) || 0, height: Number(image.h ?? image.height) || 0 })
}
}
}
if (images.length === 0 && Array.isArray(raw.imgInfos)) {
for (const info of raw.imgInfos) {
const urls = info?.originImage?.cdnUrls
const url = Array.isArray(urls) ? urls.map((cdn) => cdn?.url).find((candidate) => typeof candidate === 'string' && candidate.length > 0) : undefined
if (typeof url === 'string') images.push({ url, width: 0, height: 0 })
}
}
const tags = Array.isArray(raw.tagList)
? raw.tagList
.filter((tag) => tag !== null && typeof tag === 'object' && typeof tag.tagName === 'string')
.map((tag) => ({ id: String(tag.tagId ?? ''), name: tag.tagName }))
: []
const shareUrl = typeof raw.shareUrl === 'string' && raw.shareUrl.length > 0 ? raw.shareUrl : momentShareUrl(momentId)
const repost = data.repostSource ?? raw.repostSource
const repostedFrom = repost !== null && typeof repost === 'object' ? String(repost.text ?? '').slice(0, 300) : ''
return {
momentId: String(raw.momentId ?? momentId),
momentType: Number(raw.momentType) || 0,
text: String(raw.text ?? raw.replaceUbbText ?? '').trim(),
createTime: String(raw.createTime ?? ''),
authorId: String(user.id ?? ''),
authorName: String(user.name ?? ''),
authorAvatar: typeof user.headUrl === 'string' ? user.headUrl : '',
authorSignature: typeof user.signature === 'string' ? user.signature : '',
authorFollowers: Number(user.fanCountValue ?? user.fanCount) || 0,
images,
tags,
likeCount: Number(raw.likeCount) || 0,
commentCount: Number(raw.commentCount) || 0,
bananaCount: Number(raw.bananaCount) || 0,
shareCount: Number(raw.shareCount) || 0,
isLike: raw.isLike === true,
isThrowBanana: raw.isThrowBanana === true,
visibleForFans: raw.visibleForFans === true,
repostedFrom,
shareUrl,
}
}
/**
* Read one page of a moment's comment thread.
*
* Moments use the same `comment/listByFloor` route as videos, with
* `sourceType: 4` (the moment enum — likes/bananas use `resourceType: 10`).
*
* @param {string|number} input - numeric moment id or communityCircle/moment URL.
* @param {{page?: number, signal?: AbortSignal}} [options]
* @returns {Promise<Record<string, any>>}
*/
async momentComments(input, options = {}) {
const momentId = normalizeMomentId(String(input))
const page = Number(options.page ?? 1)
const data = await this.#api('/rest/pc-direct/comment/listByFloor', {
query: {
sourceId: momentId,
sourceType: MOMENT_COMMENT_SOURCE_TYPE,
page: Number.isInteger(page) && page > 0 ? page : 1,
pivotCommentId: '',
newPivotCommentId: '',
t: '',
supportZtEmot: 'true',
},
signal: options.signal,
})
const pageData = mapCommentPage(data, page)
return {
momentId,
page: pageData.currentPage,
totalPages: pageData.totalPages,
totalCount: pageData.totalCount,
pageSize: pageData.pageSize,
returned: pageData.items.length,
hasMore: pageData.currentPage < pageData.totalPages,
url: momentShareUrl(momentId),
items: pageData.items,
}
}
/**
* Best-effort read of one AcFun topic ("社区圈") page: the only public feed
* that lists current moment ids for anonymous clients.
*
* The page is server-rendered HTML (15 newest items, no pagination) and only
* resource-type 3 items carry a `moment-id`.
*
* @param {string|number} tagId - numeric topic id, for example 488 (教育).
* @param {{signal?: AbortSignal}} [options]
* @returns {Promise<Record<string, any>>}
*/
async topicMoments(tagId, options = {}) {
const id = String(tagId ?? '').trim()
if (!/^\d+$/.test(id)) {
throw new AcfunError('tagId must be a numeric topic id, for example "488".', { code: 'INVALID_ARGUMENT' })
}
const response = await this.#http(`https://m.acfun.cn/communityCircle/${id}`, {
// The mobile page switches to a JS-only shell when a navigation Accept is
// sent; the default wildcard keeps the server-rendered list.
accept: '*/*',
signal: options.signal,
})
if (response.status !== 200) {
throw new AcfunError(`AcFun topic ${id} returned HTTP ${response.status}.`, {
code: response.status === 404 ? 'NOT_FOUND' : 'HTTP_ERROR',
route: `/communityCircle/${id}`,
status: response.status,
})
}
return parseTopicPage(response.text, id)
}
/**
* Read one page of a video's danmaku (弹幕) list.
*
* AcFun addresses danmaku by the part's `videoId` (currentVideoId), not by
* the ac/douga id, so this first resolves the video page.
*
* @param {string|number} input - ac id, "ac" form, or acfun URL.
* @param {{limit?: number, cursor?: string, signal?: AbortSignal}} [options]
* @returns {Promise<Record<string, any>>}
*/
async danmaku(input, options = {}) {
const acId = normalizeAcId(String(input))
const limit = Number(options.limit ?? DANMAKU_MAX_COUNT)
if (!Number.isInteger(limit) || limit < 1 || limit > DANMAKU_MAX_COUNT) {
throw new AcfunError(`limit must be an integer between 1 and ${DANMAKU_MAX_COUNT}.`, {
code: 'INVALID_ARGUMENT',
})
}
const cursor = typeof options.cursor === 'string' && options.cursor.trim().length > 0 ? options.cursor.trim() : '1'
const video = await this.video(acId, { signal: options.signal })
if (!(video.videoId > 0)) {
throw new AcfunError(`AcFun video ${acId} did not expose a part videoId, so danmaku cannot be requested.`, {
code: 'NO_VIDEO_PART',
route: `/v/ac${acId}`,
})
}
const data = await this.#api('/rest/pc-direct/new-danmaku/list', {
method: 'POST',
form: {
resourceId: video.videoId,
resourceType: DANMAKU_RESOURCE_TYPE,
enableAdvanced: 'true',
pcursor: cursor,
count: limit,
sortType: 1,
asc: 'false',
},
signal: options.signal,
})
const list = Array.isArray(data.danmakus) ? data.danmakus : []
const items = []
for (const entry of list) {
if (entry === null || typeof entry !== 'object') continue
if (typeof entry.body !== 'string') continue
const position = Number(entry.position)
items.push({
danmakuId: String(entry.danmakuId ?? ''),
body: decodeEntities(entry.body).trim(),
position: Number.isFinite(position) ? position : 0,
time: formatDuration(Number.isFinite(position) ? position : 0),
color: typeof entry.color === 'number' ? `#${entry.color.toString(16).padStart(6, '0')}` : '',
mode: Number(entry.mode) || 0,
userId: String(entry.userId ?? ''),
createdAt: formatTimestamp(entry.createTime),
likeCount: Number(entry.likeCount) || 0,
})
}
items.sort((left, right) => left.position - right.position)
const nextCursor = typeof data.pcursor === 'string' && data.pcursor !== 'no_more' ? data.pcursor : ''
return {
acId,
videoId: video.videoId,
title: video.title,
totalCount: Number(data.totalCount) || items.length,
returned: items.length,
nextCursor,
items,
}
}
/**
* Read the day/week ranking of one channel (`channelId: 0` means all).
*
* @param {{period?: string, channelId?: number, limit?: number, signal?: AbortSignal}} [options]
* @returns {Promise<Record<string, any>>}
*/
async rank(options = {}) {
const period = typeof options.period === 'string' ? options.period.trim() : 'day'
if (!RANK_PERIODS.includes(period)) {
throw new AcfunError(`period must be one of ${RANK_PERIODS.join(', ')}.`, { code: 'INVALID_ARGUMENT' })
}
const channelId = Number(options.channelId ?? 0)
if (!Number.isInteger(channelId) || channelId < 0) {
throw new AcfunError('channelId must be a non-negative integer (0 means all channels).', {
code: 'INVALID_ARGUMENT',
})
}
const limit = Number(options.limit ?? 10)
if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
throw new AcfunError('limit must be an integer between 1 and 50.', { code: 'INVALID_ARGUMENT' })
}
const data = await this.#api('/rest/pc-direct/rank/channel', {
method: 'POST',
form: { rankPeriod: period, channelId, rankLimit: limit },
signal: options.signal,
})
const list = Array.isArray(data.rankList) ? data.rankList : []
const items = []
for (const entry of list) {
if (entry === null || typeof entry !== 'object') continue
const acId = String(entry.dougaId ?? entry.contentId ?? '')
if (!/^\d+$/.test(acId)) continue
items.push({
acId,
title: decodeEntities(String(entry.contentTitle ?? '')).trim(),
description: decodeEntities(String(entry.contentDesc ?? '')).trim(),
authorId: String(entry.userId ?? ''),
authorName: decodeEntities(String(entry.userName ?? '')),
channelName: String(entry.channelName ?? ''),
coverUrl: typeof entry.videoCover === 'string' ? entry.videoCover : '',
duration: formatDuration(Number(entry.durationMillis) || Number(entry.duration) || 0),
danmakuCount: Number(entry.danmuCount) || 0,
likeCountText: String(entry.likeCountShow ?? ''),
commentCountText: String(entry.commentCountShow ?? ''),
publishedAt: formatTimestamp(entry.contributeTime),
url: acfunVideoUrl(acId),
})
}
return { period, channelId, returned: items.length, items }
}
/**
* Follow or unfollow one AcFun user.
*
* @param {string|number} userId - target user id.
* @param {boolean} follow - true to follow, false to unfollow.
* @param {{signal?: AbortSignal}} [options]
* @returns {Promise<{ok: true, message: string}>}
*/
async setFollow(userId, follow, options = {}) {
this.assertWritable(follow ? 'Following a user' : 'Unfollowing a user')
const target = String(userId ?? '').trim()
if (!/^\d+$/.test(target)) {
throw new AcfunError('userId must be a numeric AcFun user id.', { code: 'INVALID_ARGUMENT' })
}
await this.#api('/rest/pc-direct/relation/follow', {
method: 'POST',
form: { toUserId: target, action: follow ? 1 : 2, groupId: 0 },
signal: options.signal,
})
return { ok: true, message: follow ? `Followed AcFun user ${target}.` : `Unfollowed AcFun user ${target}.` }
}
/**
* Tip bananas (AcFun's投蕉) to one video.
*
* @param {string|number} input - ac id.
* @param {number} count - bananas to tip.
* @param {{signal?: AbortSignal}} [options]
* @returns {Promise<{ok: true, message: string, bananaCount?: number}>}
*/
async throwBanana(input, count, options = {}) {
this.assertWritable('Tipping bananas (投蕉)')
const acId = normalizeAcId(String(input))
const amount = validateBananaCount(count)
const total = await this.#tipBanana(acId, amount, 2, options)
return {
ok: true,
message: `Tipped ${amount} banana(s) to ac${acId}.`,
...total === undefined ? {} : { bananaCount: total },
}
}
/**
* Tip bananas to one moment (动态). Moments use `resourceType: 10`.
*
* @param {string|number} input - numeric moment id or communityCircle URL.
* @param {number} count - bananas to tip, 1-10.
* @param {{signal?: AbortSignal}} [options]
* @returns {Promise<{ok: true, message: string, bananaCount?: number}>}
*/
async momentBanana(input, count, options = {}) {
this.assertWritable('Tipping bananas to a moment')
const momentId = normalizeMomentId(String(input))
const amount = validateBananaCount(count)
const total = await this.#tipBanana(momentId, amount, MOMENT_RESOURCE_TYPE, options)
return {
ok: true,
message: `Tipped ${amount} banana(s) to moment ${momentId}.`,
...total === undefined ? {} : { bananaCount: total },
}
}
/** Shared banana request for videos (resourceType 2) and moments (10). */
async #tipBanana(resourceId, amount, resourceType, options) {
const udid = this.cookies._did
const headers = udid === undefined ? {} : { udid }
const data = await this.#api('/rest/pc-direct/banana/throwBanana', {
method: 'POST',
form: { resourceId, count: amount, resourceType },
headers,
signal: options.signal,
})
const total = Number(data.extData?.bananaRealCount)
return Number.isFinite(total) ? total : undefined
}
/**
* Fetch the short-lived signed token the like endpoint requires.
*
* @param {{signal?: AbortSignal}} [options]
* @returns {Promise<{key: string, value: string, userId: string}>}
*/
async midgroundToken(options = {}) {
const data = await this.#api(`${this.tokenBaseUrl}/rest/web/token/get`, {
method: 'POST',
form: { sid: MIDGROUND_TOKEN_SID },
signal: options.signal,
})
const key = `${MIDGROUND_TOKEN_SID}_st`
const value = data[key]
if (typeof value !== 'string' || value.length === 0) {
const fallback = Object.keys(data).find((name) => name.endsWith('_st') && typeof data[name] === 'string')
if (fallback === undefined) {
throw new AcfunError('AcFun token service did not return a like token for this session.', {
code: 'LOGIN_REQUIRED',
route: '/rest/web/token/get',
})
}
return { key: fallback, value: data[fallback], userId: String(data.userId ?? '') }
}
return { key, value, userId: String(data.userId ?? '') }
}
/**
* Like or unlike one video.
*
* @param {string|number} input - ac id.
* @param {boolean} like - true to like, false to remove the like.
* @param {{signal?: AbortSignal}} [options]
* @returns {Promise<{ok: true, message: string}>}
*/
async setLike(input, like, options = {}) {
this.assertWritable(like ? 'Liking a video' : 'Unliking a video')
const acId = normalizeAcId(String(input))
const token = await this.midgroundToken({ signal: options.signal })
const form = {
kpn: 'ACFUN_APP',
kpf: 'PC_WEB',
subBiz: 'mainApp',
interactType: 1,
objectType: 2,
objectId: acId,
userId: token.userId,
[token.key]: token.value,
'extParams[isPlaying]': 'false',
'extParams[showCount]': '0',
'extParams[otherBtnClickedCount]': '0',
}
await this.#api(`${this.interactBaseUrl}${like ? '/rest/zt/interact/add' : '/rest/zt/interact/delete'}`, {
method: 'POST',
form,
signal: options.signal,
})
return { ok: true, message: like ? `Liked ac${acId}.` : `Removed the like from ac${acId}.` }
}
/**
* Like or unlike one moment (动态).
*
* Moments are addressed by `objectType: 10` on the same signed-token
* interaction endpoint as videos. The contract is derived from the site's own
* store (`resourceType = 10`); the success envelope is `result: 1`, not 0, so
* both codes are accepted.
*
* @param {string|number} input - numeric moment id or communityCircle URL.
* @param {boolean} like - true to like, false to remove the like.
* @param {{signal?: AbortSignal}} [options]
* @returns {Promise<{ok: true, message: string}>}
*/
async setMomentLike(input, like, options = {}) {
this.assertWritable(like ? 'Liking a moment' : 'Unliking a moment')
const momentId = normalizeMomentId(String(input))
const token = await this.midgroundToken({ signal: options.signal })
const form = {
kpn: 'ACFUN_APP',
kpf: 'PC_WEB',
subBiz: 'mainApp',
interactType: 1,
objectType: MOMENT_RESOURCE_TYPE,
objectId: momentId,
userId: token.userId,
[token.key]: token.value,
}
await this.#api(`${this.interactBaseUrl}${like ? '/rest/zt/interact/add' : '/rest/zt/interact/delete'}`, {
method: 'POST',
form,
okResults: [0, 1],
signal: options.signal,
})
return { ok: true, message: like ? `Liked moment ${momentId}.` : `Removed the like from moment ${momentId}.` }
}
/**
* List the signed-in user's favorite folders for one resource.
*
* @param {string} acId - bare ac id.
* @param {{token?: {key: string, value: string, userId: string}, signal?: AbortSignal}} [options]
* @returns {Promise<Array<{folderId: string, name: string, inFolder: boolean, type: number}>>}
*/
async favoriteFolders(acId, options = {}) {
const token = options.token ?? (await this.midgroundToken({ signal: options.signal }))
const data = await this.#api('/rest/pc-direct/favorite/folder/list', {
method: 'POST',
form: { resourceId: acId, [token.key]: token.value, userId: token.userId },
signal: options.signal,
})
const list = Array.isArray(data.dataList) ? data.dataList : []
return list
.filter((folder) => folder !== null && typeof folder === 'object')
.map((folder) => ({
folderId: String(folder.folderId ?? ''),
name: String(folder.name ?? ''),
inFolder: folder.inFolder === true,
type: Number(folder.type) || 0,
}))
.filter((folder) => folder.folderId.length > 0)
}
/**
* Add one video to a favorite folder, or remove it from the folders it is in.
*
* @param {string|number} input - ac id.
* @param {boolean} favorite - true to favorite, false to unfavorite.
* @param {{folderId?: string, signal?: AbortSignal}} [options]
* @returns {Promise<{ok: true, message: string, folderName: string}>}
*/
async setFavorite(input, favorite, options = {}) {
this.assertWritable(favorite ? 'Favoriting a video' : 'Unfavoriting a video')
const acId = normalizeAcId(String(input))
const token = await this.midgroundToken({ signal: options.signal })
const folders = await this.favoriteFolders(acId, { token, signal: options.signal })
if (favorite) {
const explicit = typeof options.folderId === 'string' ? options.folderId.trim() : ''
const target =
explicit.length > 0
? folders.find((folder) => folder.folderId === explicit) ??
{ folderId: explicit, name: explicit, inFolder: false, type: 0 }
: folders.find((folder) => folder.inFolder) ??
folders.find((folder) => folder.type === 1) ??
folders[0]
if (target === undefined) {
throw new AcfunError(
'No AcFun favorite folder was returned for this account; open the favorite dialog once in a browser to create the default folder, then retry.',
{ code: 'NO_FOLDER', route: '/rest/pc-direct/favorite/folder/list' },
)
}
await this.#api('/rest/pc-direct/favorite/resource/add', {
method: 'POST',
form: {
resourceId: acId,
resourceType: FAVORITE_RESOURCE_TYPE,
addFolderIds: target.folderId,
[token.key]: token.value,
userId: token.userId,
},
signal: options.signal,
})
return { ok: true, message: `Favorited ac${acId} into "${target.name || target.folderId}".`, folderName: target.name }
}
const containing = folders.filter((folder) => folder.inFolder)
if (containing.length === 0) {
return { ok: true, message: `ac${acId} is not in any favorite folder; nothing to remove.`, folderName: '' }
}
await this.#api('/rest/pc-direct/favorite/resource/remove', {
method: 'POST',
form: {
resourceId: acId,
resourceType: FAVORITE_RESOURCE_TYPE,
delFolderIds: containing.map((folder) => folder.folderId).join(','),
[token.key]: token.value,
userId: token.userId,
},
signal: options.signal,
})
return {
ok: true,
message: `Removed ac${acId} from favorite folder(s): ${containing.map((folder) => folder.name || folder.folderId).join(', ')}.`,
folderName: containing.map((folder) => folder.name).join(','),
}
}
/**
* Post a top-level comment, or a reply when `replyToCommentId` is set.
*
* @param {string|number} input - ac id.
* @param {string} content - plain text or AcFun UBB content.
* @param {{replyToCommentId?: string, signal?: AbortSignal}} [options]
* @returns {Promise<{ok: true, commentId: string, message: string}>}
*/
async postComment(input, content, options = {}) {
this.assertWritable('Posting a comment')
const acId = normalizeAcId(String(input))
const body = typeof content === 'string' ? content.trim() : ''
if (body.length === 0) {
throw new AcfunError('content must be a non-empty string.', { code: 'INVALID_ARGUMENT' })
}
if (body.length > 2000) {
throw new AcfunError('content is longer than 2000 characters.', { code: 'INVALID_ARGUMENT' })
}
const replyTo = String(options.replyToCommentId ?? '').trim()
if (replyTo.length > 0 && !/^\d+$/.test(replyTo)) {
throw new AcfunError('replyToCommentId must be a numeric comment id.', { code: 'INVALID_ARGUMENT' })
}
const data = await this.#api('/rest/pc-direct/comment/add', {
method: 'POST',
jsonBody: {
sourceId: acId,
sourceType: COMMENT_SOURCE_TYPE,
content: body,
replyToCommentId: replyTo.length > 0 ? Number(replyTo) : 0,
},
signal: options.signal,
})
const commentId = String(data.commentId ?? data.cid ?? '')
return {
ok: true,
commentId,
message: replyTo.length > 0 ? `Replied to comment ${replyTo} on ac${acId}.` : `Commented on ac${acId}.`,
}
}
/**
* Post a comment (or reply) on one moment (动态).
*
* The mobile comment widget builds itself with `resourceType: 4`, which is the
* same enum the verified read path uses as `sourceType: 4`. Both `objectId`
* and `sourceId` are sent because the two client variants disagree; the
* request is documented as UNVERIFIED until an account can exercise it.
*
* @param {string|number} input - numeric moment id or communityCircle URL.
* @param {string} content - plain text or AcFun UBB content.
* @param {{replyToCommentId?: string, signal?: AbortSignal}} [options]
* @returns {Promise<{ok: true, commentId: string, message: string, url: string}>}
*/
async postMomentComment(input, content, options = {}) {
this.assertWritable('Commenting on a moment')
const momentId = normalizeMomentId(String(input))
const body = typeof content === 'string' ? content.trim() : ''
if (body.length === 0) {
throw new AcfunError('content must be a non-empty string.', { code: 'INVALID_ARGUMENT' })
}
if (body.length > 2000) {
throw new AcfunError('content is longer than 2000 characters.', { code: 'INVALID_ARGUMENT' })
}
const replyTo = String(options.replyToCommentId ?? '').trim()
if (replyTo.length > 0 && !/^\d+$/.test(replyTo)) {
throw new AcfunError('replyToCommentId must be a numeric comment id.', { code: 'INVALID_ARGUMENT' })
}
const data = await this.#api('/rest/pc-direct/comment/add', {
method: 'POST',
jsonBody: {
objectId: momentId,
sourceId: momentId,
sourceType: MOMENT_COMMENT_SOURCE_TYPE,
content: body,
replyToCommentId: replyTo.length > 0 ? Number(replyTo) : 0,
},
signal: options.signal,
})
return {
ok: true,
commentId: String(data.commentId ?? data.cid ?? ''),
message: replyTo.length > 0 ? `Replied to comment ${replyTo} on moment ${momentId}.` : `Commented on moment ${momentId}.`,
url: momentShareUrl(momentId),
}
}
}