/**
* 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(/
/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} 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`, 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
}
/**
* Accept a numeric moment id, a `momentId=` query fragment, or a
* `m.acfun.cn/communityCircle/moment/` 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(/