- 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
1501 lines
58 KiB
JavaScript
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(/</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(/<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),
|
|
}
|
|
}
|
|
}
|