/** * 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 `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(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/�*39;|'/g, "'") .replace(/ /g, ' ') .replace(/&/g, '&') } /** Turn AcFun UBB comment markup into readable plain text. */ export function normalizeUbb(value) { if (typeof value !== 'string') return '' return decodeEntities( value .replace(//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 ''. */ 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() } /** The canonical web URL of one AcFun video. */ export function acfunVideoUrl(acId) { return `https://www.acfun.cn/v/ac${acId}` } /** * Accept `42588725`, `ac42588725`, `AC42588725`, or any acfun.cn URL that * contains `/v/ac`, 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 | 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 } /** * 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 } /** * 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 } /** 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, form?: Record, jsonBody?: unknown, headers?: Record, 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>} 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) { 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 }, ) } 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>} */ 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>} */ 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 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, }) } const totalPages = Number(data.totalPage) || 1 const currentPage = Number(data.curPage) || page return { acId, page: currentPage, totalPages, totalCount: Number(data.totalCount) || items.length, pageSize: Number(data.pageSize) || items.length, returned: items.length, hasMore: currentPage < totalPages, url: acfunVideoUrl(acId), items, } } /** * 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>} */ 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>} */ 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 = 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' }) } const udid = this.cookies._did const headers = udid === undefined ? {} : { udid } const data = await this.#api('/rest/pc-direct/banana/throwBanana', { method: 'POST', form: { resourceId: acId, count: amount, resourceType: 2 }, headers, signal: options.signal, }) const total = Number(data.extData?.bananaRealCount) return { ok: true, message: `Tipped ${amount} banana(s) to ac${acId}.`, ...Number.isFinite(total) ? { bananaCount: total } : {}, } } /** * 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}.` } } /** * 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>} */ 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}.`, } } }