feat: QR login, AcFun settings panel, and 动态 (moment) tools

- scan.acfun.cn QR flow (start -> long-poll scanResult -> acceptResult) with
  rotated-signature handling, background poller, durable session in the Host
  credential service, and cookie handoff to the provider
- Client half: Settings -> AcFun page (status, QR code, poll, logout) over a
  same-origin /acfun-panel JSON API with cross-origin rejection
- acfun_moment / acfun_moment_comments / acfun_topic read tools, moment_like /
  moment_unlike / moment_banana actions, and comment posting on moments
- acfun_account status tool; docs for the QR contract, the 广场/动态 research
  verdict, and the client-composition restart requirement
This commit is contained in:
tamakyi
2026-10-27 11:50:44 +08:00
parent f9bedde19e
commit 97825f020d
19 changed files with 3723 additions and 189 deletions
+464 -40
View File
@@ -36,6 +36,17 @@ export const DEFAULT_USER_AGENT =
/** 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
@@ -133,7 +144,12 @@ export function formatDuration(milliseconds) {
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`
}
/** Format an epoch-millisecond timestamp as an ISO date-time, or ''. */
/**
* 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 ''
@@ -141,11 +157,62 @@ export function formatTimestamp(milliseconds) {
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.
@@ -216,6 +283,29 @@ export function extractAssignedJson(html, marker) {
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.
*
@@ -255,6 +345,80 @@ export function readPlayQualities(playJson) {
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.
*/
@@ -284,6 +448,30 @@ export class AcfunProvider {
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
@@ -417,7 +605,8 @@ export class AcfunProvider {
})
}
if (payload !== null && typeof payload === 'object' && typeof payload.result === 'number' && payload.result !== 0) {
throw this.#apiError(path, payload, response.status)
const accepted = Array.isArray(options.okResults) ? options.okResults : []
if (!accepted.includes(payload.result)) throw this.#apiError(path, payload, response.status)
}
return payload
}
@@ -441,6 +630,12 @@ export class AcfunProvider {
{ 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 })
}
@@ -674,41 +869,165 @@ export class AcfunProvider {
},
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
const pageData = mapCommentPage(data, page)
return {
acId,
page: currentPage,
totalPages,
totalCount: Number(data.totalCount) || items.length,
pageSize: Number(data.pageSize) || items.length,
returned: items.length,
hasMore: currentPage < totalPages,
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,
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.
*
@@ -863,24 +1182,47 @@ export class AcfunProvider {
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 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: acId, count: amount, resourceType: 2 },
form: { resourceId, count: amount, resourceType },
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 } : {},
}
return Number.isFinite(total) ? total : undefined
}
/**
@@ -943,6 +1285,42 @@ export class AcfunProvider {
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.
*
@@ -1073,4 +1451,50 @@ export class AcfunProvider {
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),
}
}
}