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:
+254
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* QR-login state machine.
|
||||
*
|
||||
* The manager owns one pending login ticket, asks the injected `flow` for a QR
|
||||
* code and for status updates, persists a confirmed cookie through the injected
|
||||
* `store`, and points the provider at it. The AcFun endpoint contract lives in
|
||||
* the flow (`lib/qr-flow.js`), so tests and the panel can exercise the state
|
||||
* machine without touching the network.
|
||||
*/
|
||||
|
||||
import { AcfunError } from './provider.js'
|
||||
|
||||
/** Credential record key used to persist a QR-obtained session. */
|
||||
export const SESSION_RECORD_KEY = 'acfun-session'
|
||||
|
||||
/** How long a confirmed session is trusted before it is re-read from the store. */
|
||||
const IDLE = 'idle'
|
||||
|
||||
/**
|
||||
* Durable session store over the Host credential service.
|
||||
*
|
||||
* A `grant` record is the generic opaque-record kind, so the cookie round-trips
|
||||
* without a schema while staying out of the plugin config. With no credential
|
||||
* service the store degrades to memory-only for the lifetime of the plugin.
|
||||
*
|
||||
* @param {{readRecord: Function, modifyRecord: Function, deleteRecord: Function} | undefined} credentials
|
||||
*/
|
||||
export function createCredentialSessionStore(credentials) {
|
||||
let memory
|
||||
return {
|
||||
/** @returns {Promise<{cookie: string, savedAt: number} | undefined>} */
|
||||
async read() {
|
||||
if (credentials === undefined) return memory
|
||||
try {
|
||||
const record = await credentials.readRecord(SESSION_RECORD_KEY)
|
||||
if (record === undefined || record === null) return undefined
|
||||
if (record.kind !== 'grant') return undefined
|
||||
const payload = record.payload
|
||||
if (payload === null || typeof payload !== 'object') return undefined
|
||||
if (typeof payload.cookie !== 'string' || payload.cookie.length === 0) return undefined
|
||||
return { cookie: payload.cookie, savedAt: Number(payload.savedAt) || 0 }
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
/** @param {string} cookie */
|
||||
async write(cookie) {
|
||||
memory = { cookie, savedAt: Date.now() }
|
||||
if (credentials === undefined) return
|
||||
await credentials.modifyRecord(SESSION_RECORD_KEY, async () => ({
|
||||
kind: 'grant',
|
||||
payload: { cookie, savedAt: Date.now(), plugin: 'dsh-plugin-acfun' },
|
||||
}))
|
||||
},
|
||||
async clear() {
|
||||
memory = undefined
|
||||
if (credentials === undefined) return
|
||||
await credentials.deleteRecord(SESSION_RECORD_KEY)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One QR-login manager bound to a provider and a session store.
|
||||
*/
|
||||
export class AcfunLoginManager {
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {import('./provider.js').AcfunProvider} options.provider
|
||||
* @param {{read: Function, write: Function, clear: Function}} options.store
|
||||
* @param {{start: Function, poll: Function, cancel?: Function} | null} [options.flow]
|
||||
* @param {() => number} [options.now]
|
||||
*/
|
||||
constructor(options) {
|
||||
this.provider = options.provider
|
||||
this.store = options.store
|
||||
this.flow = options.flow ?? null
|
||||
this.now = options.now ?? (() => Date.now())
|
||||
this.ticket = ''
|
||||
this.qr = null
|
||||
this.expiresAt = 0
|
||||
this.state = IDLE
|
||||
this.message = ''
|
||||
this.authSource = 'none'
|
||||
this.aborter = null
|
||||
this.loop = null
|
||||
}
|
||||
|
||||
/** Whether a QR flow is wired in this Host. */
|
||||
get available() {
|
||||
return this.flow !== null
|
||||
}
|
||||
|
||||
/** Immutable view for the panel and tools. */
|
||||
snapshot() {
|
||||
return {
|
||||
available: this.available,
|
||||
state: this.state,
|
||||
ticket: this.ticket,
|
||||
qr: this.qr,
|
||||
expiresAt: this.expiresAt,
|
||||
message: this.message,
|
||||
authSource: this.authSource,
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset the pending ticket without touching the stored session. */
|
||||
#reset(state = IDLE, message = '') {
|
||||
this.ticket = ''
|
||||
this.qr = null
|
||||
this.expiresAt = 0
|
||||
this.state = state
|
||||
this.message = message
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a persisted QR session at activation.
|
||||
*
|
||||
* @returns {Promise<{loaded: boolean, cookie?: string}>}
|
||||
*/
|
||||
async restore() {
|
||||
const saved = await this.store.read()
|
||||
if (saved === undefined) return { loaded: false }
|
||||
// A cookie from the plugin config wins until the user explicitly logs in
|
||||
// again; a stored QR session fills an otherwise anonymous provider.
|
||||
if (!this.provider.loggedIn) {
|
||||
this.provider.setCookie(saved.cookie)
|
||||
this.authSource = 'qr'
|
||||
}
|
||||
return { loaded: true, cookie: saved.cookie }
|
||||
}
|
||||
|
||||
/** Start a fresh QR login, replacing any pending ticket. */
|
||||
async start() {
|
||||
if (this.flow === null) {
|
||||
throw new AcfunError(
|
||||
'AcFun QR login is unavailable: this Host did not detect the login endpoint contract.',
|
||||
{ code: 'QR_LOGIN_UNAVAILABLE' },
|
||||
)
|
||||
}
|
||||
await this.cancel()
|
||||
const started = await this.flow.start()
|
||||
this.ticket = String(started?.ticket ?? '')
|
||||
this.qr = started?.qr ?? null
|
||||
this.expiresAt = Number(started?.expiresAt) || 0
|
||||
this.state = 'waiting'
|
||||
this.message = ''
|
||||
// The QR service long-polls, so the exchange runs in the background and the
|
||||
// panel reads this in-memory state instead of blocking an HTTP response.
|
||||
this.#launchLoop()
|
||||
return this.snapshot()
|
||||
}
|
||||
|
||||
/** Run the waiting → scanned → confirmed loop in the background. */
|
||||
#launchLoop() {
|
||||
const aborter = new AbortController()
|
||||
this.aborter = aborter
|
||||
this.loop = (async () => {
|
||||
const signal = aborter.signal
|
||||
try {
|
||||
while (!signal.aborted) {
|
||||
if (this.expiresAt > 0 && this.now() > this.expiresAt) {
|
||||
this.state = 'expired'
|
||||
this.message = 'The QR code expired; start a new login.'
|
||||
return
|
||||
}
|
||||
const scanned = await this.flow.waitForScan({ signal })
|
||||
if (signal.aborted) return
|
||||
if (scanned.state === 'waiting') {
|
||||
if (this.expiresAt > 0 && this.now() > this.expiresAt) {
|
||||
this.state = 'expired'
|
||||
this.message = 'The QR code expired; start a new login.'
|
||||
return
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
continue
|
||||
}
|
||||
if (scanned.state !== 'scanned') {
|
||||
this.state = scanned.state === 'expired' ? 'expired' : 'error'
|
||||
this.message =
|
||||
scanned.message ||
|
||||
(this.state === 'expired' ? 'The QR code expired; start a new login.' : 'AcFun rejected this QR login.')
|
||||
return
|
||||
}
|
||||
this.state = 'scanned'
|
||||
this.message = 'Scanned; confirm the login on your phone.'
|
||||
const accepted = await this.flow.accept({ signal })
|
||||
if (signal.aborted) return
|
||||
if (accepted.state === 'confirmed') {
|
||||
try {
|
||||
await this.store.write(accepted.cookie)
|
||||
} catch (error) {
|
||||
this.state = 'error'
|
||||
this.message = `Login succeeded but the session could not be saved: ${error instanceof Error ? error.message : String(error)}`
|
||||
return
|
||||
}
|
||||
this.provider.setCookie(accepted.cookie)
|
||||
this.authSource = 'qr'
|
||||
this.qr = null
|
||||
this.state = 'confirmed'
|
||||
this.message = 'Signed in to AcFun; the session cookie is saved.'
|
||||
return
|
||||
}
|
||||
this.state = accepted.state === 'expired' ? 'expired' : 'error'
|
||||
this.message = accepted.message || 'AcFun login failed.'
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal.aborted) return
|
||||
this.state = error?.name === 'TimeoutError' ? 'expired' : 'error'
|
||||
this.message = error?.name === 'TimeoutError' ? 'The QR code expired; start a new login.' : String(error?.message ?? error)
|
||||
} finally {
|
||||
if (this.aborter === aborter) this.aborter = null
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot poll used by the panel and tools. The network work happens in the
|
||||
* background loop, so this never blocks on AcFun.
|
||||
*
|
||||
* @param {string} ticket - ticket returned by `start`; a stale ticket is ignored.
|
||||
*/
|
||||
async poll(ticket) {
|
||||
if (this.ticket !== '' && String(ticket ?? '') !== this.ticket) return this.snapshot()
|
||||
return this.snapshot()
|
||||
}
|
||||
|
||||
/** Cancel the pending ticket; the stored session is untouched. */
|
||||
async cancel() {
|
||||
if (this.aborter !== null) {
|
||||
this.aborter.abort()
|
||||
this.aborter = null
|
||||
}
|
||||
if (this.ticket !== '' && this.flow !== null && typeof this.flow.cancel === 'function') {
|
||||
try {
|
||||
await this.flow.cancel(this.ticket)
|
||||
} catch {
|
||||
// Cancelling is best-effort; the ticket expires on AcFun's side anyway.
|
||||
}
|
||||
}
|
||||
this.#reset('idle', '')
|
||||
return this.snapshot()
|
||||
}
|
||||
|
||||
/** Forget the QR session and return the provider to anonymous/config mode. */
|
||||
async logout() {
|
||||
await this.cancel()
|
||||
await this.store.clear()
|
||||
this.provider.setCookie('')
|
||||
this.authSource = 'none'
|
||||
return this.snapshot()
|
||||
}
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Host-side HTTP surface for the AcFun settings panel.
|
||||
*
|
||||
* Static Client plugins reach their Host half over plain same-origin HTTP: the
|
||||
* Web carrier serves these routes, and `client.js` calls them with `fetch`.
|
||||
* Responses are JSON and never contain the session cookie itself — only a
|
||||
* description of it.
|
||||
*
|
||||
* The panel is a local-management surface, so every route rejects a request
|
||||
* whose `Origin`/`Referer` host differs from the Host header, and nothing is
|
||||
* cached.
|
||||
*/
|
||||
|
||||
import { AcfunError } from './provider.js'
|
||||
|
||||
/** Mount point of the panel API under the Web origin. */
|
||||
export const PANEL_PATH = '/acfun-panel'
|
||||
|
||||
/** Largest JSON request body the panel accepts. */
|
||||
const MAX_BODY_BYTES = 64 * 1024
|
||||
|
||||
/** Write one JSON response. */
|
||||
function sendJson(res, status, payload) {
|
||||
const body = Buffer.from(JSON.stringify(payload), 'utf8')
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': body.length,
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
})
|
||||
res.end(body)
|
||||
}
|
||||
|
||||
/** Send one structured failure. */
|
||||
function sendError(res, error) {
|
||||
const known = error instanceof AcfunError
|
||||
sendJson(res, known ? 400 : 500, {
|
||||
ok: false,
|
||||
error: {
|
||||
code: known ? error.code : 'PANEL_ERROR',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Reject a cross-origin browser request; absent headers (curl) pass. */
|
||||
function trustedRequest(req) {
|
||||
const host = req.headers.host
|
||||
if (typeof host !== 'string' || host.length === 0) return false
|
||||
for (const header of ['origin', 'referer']) {
|
||||
const value = req.headers[header]
|
||||
if (typeof value !== 'string' || value.length === 0) continue
|
||||
try {
|
||||
if (new URL(value).host !== host) return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Read a small JSON request body, treating an empty body as `{}`. */
|
||||
async function readJsonBody(req) {
|
||||
const chunks = []
|
||||
let size = 0
|
||||
for await (const chunk of req) {
|
||||
size += chunk.length
|
||||
if (size > MAX_BODY_BYTES) throw new AcfunError('Request body is too large.', { code: 'BODY_TOO_LARGE' })
|
||||
chunks.push(chunk)
|
||||
}
|
||||
if (chunks.length === 0) return {}
|
||||
try {
|
||||
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
||||
return parsed !== null && typeof parsed === 'object' ? parsed : {}
|
||||
} catch {
|
||||
throw new AcfunError('Request body is not valid JSON.', { code: 'BAD_JSON' })
|
||||
}
|
||||
}
|
||||
|
||||
/** Summarize the provider session for the panel. */
|
||||
function authPayload(provider, context) {
|
||||
const described = provider.describeSession()
|
||||
return {
|
||||
configured: described.configured,
|
||||
length: described.length,
|
||||
hasAuthKey: described.hasAuthKey,
|
||||
hasDid: described.hasDid,
|
||||
source: context.cookieSource(),
|
||||
writesEnabled: context.allowWriteActions,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the panel routes for one plugin activation.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {import('./provider.js').AcfunProvider} options.provider - live client whose cookie may change.
|
||||
* @param {{snapshot: Function, start: Function, poll: Function, cancel: Function, logout: Function}} options.login - QR login manager.
|
||||
* @param {string} options.version - plugin version reported to the panel.
|
||||
* @param {boolean} options.allowWriteActions - whether write tools are enabled.
|
||||
* @param {() => string} options.cookieSource - current cookie source label.
|
||||
* @param {Array<{name: string, description: string}>} options.tools - registered tool metadata.
|
||||
* @returns {Array<{kind: 'prefix', path: string, handler: Function}>}
|
||||
*/
|
||||
export function createPanelRoutes(options) {
|
||||
const context = {
|
||||
allowWriteActions: options.allowWriteActions === true,
|
||||
cookieSource: typeof options.cookieSource === 'function' ? options.cookieSource : () => 'none',
|
||||
}
|
||||
|
||||
/** Report whether the Web carrier registered this package's client bundle. */
|
||||
const clientModule = () => {
|
||||
const service = options.clientModules
|
||||
if (service === undefined || typeof service.clientPath !== 'function') return { available: false, path: null }
|
||||
try {
|
||||
const path = service.clientPath('dsh-plugin-acfun')
|
||||
return { available: true, path: typeof path === 'string' ? path : null }
|
||||
} catch {
|
||||
return { available: true, path: null }
|
||||
}
|
||||
}
|
||||
|
||||
/** Diagnostics: which client module rows the carrier currently knows. */
|
||||
const clientDiagnostics = () => {
|
||||
const service = options.clientModules
|
||||
const out = { available: false, packageId: 'dsh-plugin-acfun', path: null, entries: [], error: '' }
|
||||
if (service === undefined || typeof service.clientPath !== 'function') return out
|
||||
out.available = true
|
||||
try {
|
||||
const path = service.clientPath('dsh-plugin-acfun')
|
||||
out.path = typeof path === 'string' ? path : null
|
||||
} catch (error) {
|
||||
out.error = String(error?.message ?? error)
|
||||
}
|
||||
try {
|
||||
const graph = typeof service.graph === 'function' ? service.graph() : undefined
|
||||
const entries = graph?.entries
|
||||
if (Array.isArray(entries)) {
|
||||
out.entries = entries.map((entry) => ({
|
||||
id: String(entry?.id ?? entry?.name ?? ''),
|
||||
module: String(entry?.module ?? entry?.moduleName ?? ''),
|
||||
platform: String(entry?.platform ?? ''),
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
out.error = out.error.length > 0 ? out.error : String(error?.message ?? error)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const state = async () => ({
|
||||
ok: true,
|
||||
plugin: { name: 'dsh-plugin-acfun', version: options.version },
|
||||
auth: authPayload(options.provider, context),
|
||||
login: options.login.snapshot(),
|
||||
client: clientModule(),
|
||||
tools: options.tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
summary: String(tool.description).split('.')[0].slice(0, 140),
|
||||
})),
|
||||
})
|
||||
|
||||
const handler = async (req, res) => {
|
||||
if (!trustedRequest(req)) {
|
||||
sendJson(res, 403, { ok: false, error: { code: 'FORBIDDEN', message: 'Cross-origin panel request rejected.' } })
|
||||
return
|
||||
}
|
||||
let path
|
||||
try {
|
||||
path = new URL(req.url ?? '/', 'http://localhost').pathname
|
||||
} catch {
|
||||
sendJson(res, 400, { ok: false, error: { code: 'BAD_URL', message: 'Malformed request URL.' } })
|
||||
return
|
||||
}
|
||||
if (path !== PANEL_PATH && !path.startsWith(`${PANEL_PATH}/`)) {
|
||||
sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: `Unknown AcFun panel path: ${path}` } })
|
||||
return
|
||||
}
|
||||
const route = path.slice(PANEL_PATH.length) || '/'
|
||||
const method = req.method ?? 'GET'
|
||||
|
||||
try {
|
||||
if (route === '/state' && method === 'GET') {
|
||||
sendJson(res, 200, await state())
|
||||
return
|
||||
}
|
||||
if (route === '/diagnose' && method === 'GET') {
|
||||
sendJson(res, 200, { ok: true, client: clientDiagnostics() })
|
||||
return
|
||||
}
|
||||
if (route === '/login/start' && method === 'POST') {
|
||||
await readJsonBody(req)
|
||||
sendJson(res, 200, { ok: true, login: await options.login.start() })
|
||||
return
|
||||
}
|
||||
if (route === '/login/poll' && method === 'GET') {
|
||||
const ticket = new URL(req.url ?? '/', 'http://localhost').searchParams.get('ticket') ?? ''
|
||||
sendJson(res, 200, { ok: true, login: await options.login.poll(ticket) })
|
||||
return
|
||||
}
|
||||
if (route === '/login/cancel' && method === 'POST') {
|
||||
await readJsonBody(req)
|
||||
sendJson(res, 200, { ok: true, login: await options.login.cancel() })
|
||||
return
|
||||
}
|
||||
if (route === '/logout' && method === 'POST') {
|
||||
await readJsonBody(req)
|
||||
sendJson(res, 200, { ok: true, login: await options.login.logout(), auth: authPayload(options.provider, context) })
|
||||
return
|
||||
}
|
||||
sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: `Unknown AcFun panel route: ${method} ${route}` } })
|
||||
} catch (error) {
|
||||
sendError(res, error)
|
||||
}
|
||||
}
|
||||
|
||||
return [{ kind: 'prefix', path: PANEL_PATH, handler }]
|
||||
}
|
||||
+464
-40
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* AcFun QR-login endpoint flow.
|
||||
*
|
||||
* Contract reverse-engineered from the current login page bundle
|
||||
* (`login/widget/login/login.*.js`) and verified live on 2026-10-27:
|
||||
*
|
||||
* GET https://scan.acfun.cn/rest/pc-direct/qr/start?type=WEB_LOGIN
|
||||
* -> { result: 0, next: "scanResult", expireTime: 120000,
|
||||
* qrLoginToken, qrLoginSignature, imageData (base64 PNG) }
|
||||
* GET .../qr/scanResult?qrLoginToken=..&qrLoginSignature=..
|
||||
* -> one long-poll, held until the phone scans or the code expires.
|
||||
* result 0 => scanned; the response carries a refreshed
|
||||
* qrLoginSignature used for acceptResult
|
||||
* result 100400002 => terminal "token expired"
|
||||
* result 10 => terminal "client error" (ticket already used)
|
||||
* other => error
|
||||
* GET .../qr/acceptResult?qrLoginToken=..&qrLoginSignature=..
|
||||
* -> result 0 issues the session cookies via Set-Cookie;
|
||||
* any other result fails (e.g. result 21 "sign error" for a stale or
|
||||
* not-yet-scanned ticket).
|
||||
*
|
||||
* The flow keeps its own cookie jar because the session may arrive on any of
|
||||
* the three responses and must be replayed on the next request.
|
||||
*/
|
||||
|
||||
import { AcfunError } from './provider.js'
|
||||
|
||||
/** QR service origin. */
|
||||
export const QR_SERVICE_URL = 'https://scan.acfun.cn'
|
||||
|
||||
/** Paths relative to the QR service origin. */
|
||||
export const QR_PATHS = Object.freeze({
|
||||
start: '/rest/pc-direct/qr/start',
|
||||
scanResult: '/rest/pc-direct/qr/scanResult',
|
||||
acceptResult: '/rest/pc-direct/qr/acceptResult',
|
||||
})
|
||||
|
||||
/** Default server-declared expiry, used when `expireTime` is absent. */
|
||||
export const DEFAULT_QR_TTL_MS = 120_000
|
||||
|
||||
/** How long one long-poll may hold before we treat it as expired. */
|
||||
const LONG_POLL_TIMEOUT_MS = 135_000
|
||||
|
||||
/** The "token expired" result code (terminal, not "keep waiting"). */
|
||||
const RESULT_EXPIRED = 100_400_002
|
||||
|
||||
/** The "client error" result code: the ticket is already terminal/in-flight. */
|
||||
const RESULT_CLIENT_ERROR = 10
|
||||
|
||||
/** Parse one Set-Cookie header into name/value pairs. */
|
||||
function parseSetCookie(header) {
|
||||
const pairs = []
|
||||
for (const raw of header) {
|
||||
const first = raw.split(';')[0]
|
||||
const index = first.indexOf('=')
|
||||
if (index <= 0) continue
|
||||
pairs.push([first.slice(0, index).trim(), first.slice(index + 1).trim()])
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
/**
|
||||
* One QR login flow bound to a service origin.
|
||||
*/
|
||||
export class AcfunQrFlow {
|
||||
/**
|
||||
* @param {{serviceUrl?: string, userAgent?: string, longPollTimeoutMs?: number}} [options]
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this.serviceUrl = (options.serviceUrl ?? QR_SERVICE_URL).replace(/\/$/, '')
|
||||
this.userAgent = options.userAgent ?? 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'
|
||||
this.longPollTimeoutMs = options.longPollTimeoutMs ?? LONG_POLL_TIMEOUT_MS
|
||||
this.jar = new Map()
|
||||
this.token = ''
|
||||
this.signature = ''
|
||||
}
|
||||
|
||||
/** Headers shared by every QR request. */
|
||||
#headers(signal) {
|
||||
const headers = {
|
||||
'User-Agent': this.userAgent,
|
||||
Referer: 'https://www.acfun.cn/login/',
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
}
|
||||
if (this.jar.size > 0) headers.Cookie = this.#cookieHeader()
|
||||
return headers
|
||||
}
|
||||
|
||||
/** Serialize the jar for a Cookie request header. */
|
||||
#cookieHeader() {
|
||||
return [...this.jar.entries()].map(([name, value]) => `${name}=${value}`).join('; ')
|
||||
}
|
||||
|
||||
/** Merge response cookies into the jar. */
|
||||
#absorb(response) {
|
||||
const list = typeof response.headers.getSetCookie === 'function' ? response.headers.getSetCookie() : []
|
||||
for (const [name, value] of parseSetCookie(list)) this.jar.set(name, value)
|
||||
}
|
||||
|
||||
/** GET one QR endpoint and return the parsed body plus response. */
|
||||
async #get(path, query, signal) {
|
||||
const url = new URL(this.serviceUrl + path)
|
||||
for (const [key, value] of Object.entries(query ?? {})) url.searchParams.set(key, String(value))
|
||||
let response
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
headers: this.#headers(),
|
||||
redirect: 'follow',
|
||||
signal,
|
||||
})
|
||||
} catch (cause) {
|
||||
if (cause?.name === 'AbortError' || cause?.name === 'TimeoutError') throw cause
|
||||
throw new AcfunError(`QR service request failed: ${String(cause?.message ?? cause)}`, { code: 'NETWORK', route: path })
|
||||
}
|
||||
this.#absorb(response)
|
||||
const text = await response.text()
|
||||
let body
|
||||
try {
|
||||
body = JSON.parse(text)
|
||||
} catch {
|
||||
throw new AcfunError(`QR service returned malformed JSON for ${path}.`, {
|
||||
code: 'MALFORMED_JSON',
|
||||
route: path,
|
||||
status: response.status,
|
||||
detail: text.slice(0, 160),
|
||||
})
|
||||
}
|
||||
return { body, status: response.status }
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a fresh QR ticket.
|
||||
*
|
||||
* @returns {Promise<{ticket: string, qr: {kind: 'data-url', value: string}, expiresAt: number}>}
|
||||
*/
|
||||
async start() {
|
||||
this.jar.clear()
|
||||
const { body } = await this.#get(QR_PATHS.start, { type: 'WEB_LOGIN' })
|
||||
if (body?.result !== 0 || typeof body?.qrLoginToken !== 'string' || body.qrLoginToken.length === 0) {
|
||||
throw new AcfunError(`AcFun did not issue a QR code (result ${body?.result ?? '?'}: ${body?.error_msg ?? 'no message'}).`, {
|
||||
code: 'QR_START_FAILED',
|
||||
route: QR_PATHS.start,
|
||||
})
|
||||
}
|
||||
if (typeof body.imageData !== 'string' || body.imageData.length === 0) {
|
||||
throw new AcfunError('AcFun issued a QR ticket without image data.', { code: 'QR_START_FAILED', route: QR_PATHS.start })
|
||||
}
|
||||
this.token = body.qrLoginToken
|
||||
this.signature = String(body.qrLoginSignature ?? '')
|
||||
const ttl = Number(body.expireTime)
|
||||
return {
|
||||
ticket: this.token,
|
||||
qr: { kind: 'data-url', value: `data:image/png;base64,${body.imageData}` },
|
||||
expiresAt: Date.now() + (Number.isFinite(ttl) && ttl > 0 ? ttl : DEFAULT_QR_TTL_MS),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the phone to scan the current ticket.
|
||||
*
|
||||
* Resolves `waiting` when the server reports no scan yet, `scanned` once the
|
||||
* phone authorised the login (with a refreshed signature), and `error`
|
||||
* otherwise. A caller abort throws `AbortError`.
|
||||
*
|
||||
* @param {{signal?: AbortSignal}} [options]
|
||||
* @returns {Promise<{state: 'waiting'|'scanned'|'error', message: string}>}
|
||||
*/
|
||||
async waitForScan(options = {}) {
|
||||
const timeout = AbortSignal.timeout(this.longPollTimeoutMs)
|
||||
const signal = options.signal === undefined ? timeout : AbortSignal.any([options.signal, timeout])
|
||||
let response
|
||||
try {
|
||||
response = await this.#get(QR_PATHS.scanResult, { qrLoginToken: this.token, qrLoginSignature: this.signature }, signal)
|
||||
} catch (cause) {
|
||||
if (cause?.name === 'AbortError') throw cause
|
||||
if (cause?.name === 'TimeoutError') return { state: 'waiting', message: 'QR code expired on the server.' }
|
||||
throw cause
|
||||
}
|
||||
const body = response.body ?? {}
|
||||
if (body.result === 0) {
|
||||
if (typeof body.qrLoginSignature === 'string' && body.qrLoginSignature.length > 0) this.signature = body.qrLoginSignature
|
||||
return { state: 'scanned', message: '' }
|
||||
}
|
||||
if (body.result === RESULT_EXPIRED) {
|
||||
return { state: 'expired', message: 'The QR code expired; refresh it.' }
|
||||
}
|
||||
if (body.result === RESULT_CLIENT_ERROR) {
|
||||
return { state: 'expired', message: 'This QR code is no longer valid; refresh it.' }
|
||||
}
|
||||
return { state: 'error', message: `AcFun QR scan failed (result ${body.result}: ${body.error_msg ?? 'unknown error'}).` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange an authorised ticket for the session cookies.
|
||||
*
|
||||
* @param {{signal?: AbortSignal}} [options]
|
||||
* @returns {Promise<{state: 'confirmed'|'expired'|'error', message: string, cookie?: string}>}
|
||||
*/
|
||||
async accept(options = {}) {
|
||||
const timeout = AbortSignal.timeout(60_000)
|
||||
const signal = options.signal === undefined ? timeout : AbortSignal.any([options.signal, timeout])
|
||||
let response
|
||||
try {
|
||||
response = await this.#get(QR_PATHS.acceptResult, { qrLoginToken: this.token, qrLoginSignature: this.signature }, signal)
|
||||
} catch (cause) {
|
||||
if (cause?.name === 'AbortError') throw cause
|
||||
return { state: 'error', message: `AcFun login exchange failed: ${String(cause?.message ?? cause)}` }
|
||||
}
|
||||
const body = response.body ?? {}
|
||||
if (body.result === 0) {
|
||||
const cookie = this.#cookieHeader()
|
||||
if (cookie.length === 0) return { state: 'error', message: 'AcFun confirmed the login but returned no session cookies.' }
|
||||
return { state: 'confirmed', message: 'Signed in to AcFun.', cookie }
|
||||
}
|
||||
if (body.result === RESULT_EXPIRED || body.result === RESULT_CLIENT_ERROR) {
|
||||
return { state: 'expired', message: 'The QR code expired; refresh it.' }
|
||||
}
|
||||
return { state: 'error', message: `AcFun login failed (result ${body.result}: ${body.error_msg ?? 'sign error'}).` }
|
||||
}
|
||||
}
|
||||
+338
-25
@@ -7,18 +7,20 @@
|
||||
* import and `node scripts/live-check.mjs` can execute it outside the harness.
|
||||
*/
|
||||
|
||||
import { AcfunError, acfunVideoUrl, DANMAKU_MAX_COUNT, normalizeAcId, RANK_PERIODS, SEARCH_SORTS } from './provider.js'
|
||||
import { AcfunError, acfunVideoUrl, DANMAKU_MAX_COUNT, normalizeAcId, normalizeMomentId, RANK_PERIODS, SEARCH_SORTS } from './provider.js'
|
||||
|
||||
/** The system-prompt guidance registered by the plugin. */
|
||||
export const ACFUN_GUIDANCE = [
|
||||
'AcFun (A站) tools: use acfun_search to find videos, acfun_video for one result\'s full metadata,',
|
||||
'acfun_comments for its comment thread, acfun_danmaku for the 弹幕 list, acfun_rank for the day/week',
|
||||
'ranking, and acfun_playurl for an m3u8 play URL. acfun_interact (like / unlike / banana /',
|
||||
'favorite / unfavorite / follow / unfollow) and acfun_post_comment are disabled unless the plugin',
|
||||
'config sets allowWriteActions: true and a login cookie; when a write tool reports WRITE_DISABLED or',
|
||||
'LOGIN_REQUIRED, tell the user which config key is missing instead of retrying. Cite the video URL',
|
||||
'as a markdown link when you use its content, and never repeat a mutating call just because the',
|
||||
'result was unclear.',
|
||||
'ranking, and acfun_playurl for an m3u8 play URL. For 动态 (moments) use acfun_moment,',
|
||||
'acfun_moment_comments and acfun_topic: AcFun exposes no public 广场 feed, so moment ids come only',
|
||||
'from a topic page (acfun_topic) or a share link. acfun_interact (like / unlike / banana /',
|
||||
'favorite / unfavorite / follow / unfollow, plus moment_like / moment_unlike / moment_banana) and',
|
||||
'acfun_post_comment are disabled unless the plugin config sets allowWriteActions: true and a login',
|
||||
'cookie; when a write tool reports WRITE_DISABLED or LOGIN_REQUIRED, tell the user which config key',
|
||||
'is missing instead of retrying. Publishing a moment is app-only and unavailable. Cite the share URL',
|
||||
'as a markdown link when you use content, and never repeat a mutating call because the result was unclear.',
|
||||
].join(' ')
|
||||
|
||||
/** One text content block. */
|
||||
@@ -191,12 +193,76 @@ export function formatPlayUrls(value) {
|
||||
|
||||
/** Format one interaction outcome. */
|
||||
export function formatInteract(value) {
|
||||
return `${value.message}\n\nAction: ${value.action} · acId: ${value.acId || '-'} · userId: ${value.targetUserId || '-'}`
|
||||
const target = value.momentId.length > 0 ? `moment ${value.momentId}` : value.acId.length > 0 ? `ac${value.acId}` : value.targetUserId.length > 0 ? `user ${value.targetUserId}` : '-'
|
||||
return `${value.message}\n\nAction: ${value.action} · target: ${target}`
|
||||
}
|
||||
|
||||
/** Format one posted comment. */
|
||||
export function formatPostComment(value) {
|
||||
return `${value.message}\n\nComment id: ${value.commentId || '(not returned)'} · video: ${value.url}`
|
||||
const target = value.momentId.length > 0 ? `moment ${value.momentId}` : value.acId.length > 0 ? `ac${value.acId}` : 'unknown'
|
||||
return `${value.message}\n\nComment id: ${value.commentId || '(not returned)'} · target: ${target} · ${value.url}`
|
||||
}
|
||||
|
||||
/** Format the account/session summary. */
|
||||
export function formatAccount(value) {
|
||||
return [
|
||||
`AcFun account: ${value.loggedIn ? 'signed in' : 'anonymous'} (source: ${value.authSource}${value.hasAuthKey ? ', auth_key present' : ''}).`,
|
||||
`Interaction tools: ${value.writesEnabled ? 'enabled' : 'disabled'}${value.writesEnabled ? '' : ' (allowWriteActions is false)'}.`,
|
||||
`QR login available in this Host: ${value.qrLoginAvailable ? 'yes' : 'no'}; login state: ${value.loginState}.`,
|
||||
value.hint,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Format one moment (动态). */
|
||||
export function formatMoment(value) {
|
||||
const lines = [
|
||||
`# AcFun 动态 ${value.momentId}`,
|
||||
`URL: ${value.shareUrl}`,
|
||||
`Author: ${value.authorName || 'unknown'}${value.authorId ? ` (id ${value.authorId})` : ''}${value.authorFollowers > 0 ? ` · ${value.authorFollowers} followers` : ''}`,
|
||||
`Published: ${value.createTime || '(unknown)'} · type ${value.momentType}`,
|
||||
`Stats: ${value.likeCount} likes · ${value.commentCount} comments · ${value.bananaCount} bananas · ${value.shareCount} shares`,
|
||||
`Interaction state: ${value.isLike ? 'liked' : 'not liked'} · ${value.isThrowBanana ? 'banana given' : 'no banana'}${value.visibleForFans ? ' · followers-only' : ''}`,
|
||||
]
|
||||
if (value.tags.length > 0) lines.push(`Topics: ${value.tags.map((tag) => `#${tag.name}#(${tag.id})`).join(' ')}`)
|
||||
if (value.text.length > 0) lines.push(`\nText:\n${value.text}`)
|
||||
if (value.images.length > 0) {
|
||||
lines.push(`\nImages (${value.images.length}):`)
|
||||
for (const image of value.images) lines.push(`- ${image.url}${image.width > 0 ? ` (${image.width}x${image.height})` : ''}`)
|
||||
}
|
||||
if (value.repostedFrom.length > 0) lines.push(`\nRepost of: ${value.repostedFrom}`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/** Format one moment comment page. */
|
||||
export function formatMomentComments(value) {
|
||||
const header = `Comments on moment ${value.momentId} (${value.url}) — page ${value.page}/${value.totalPages}, ${value.totalCount} comments total, ${value.returned} on this page.`
|
||||
if (value.items.length === 0) return `${header}\n\nNo comments on this page.`
|
||||
const lines = value.items.map((item) => {
|
||||
const head = meta(
|
||||
`#${item.floor}`,
|
||||
item.userName.length > 0 ? `${item.userName} (id ${item.userId})` : `user ${item.userId}`,
|
||||
item.postedAt,
|
||||
`cid ${item.commentId}`,
|
||||
)
|
||||
const body = item.isDeleted ? '[deleted]' : item.content.length > 0 ? item.content : '[empty]'
|
||||
return `- ${head}\n ${body.replace(/\n/g, '\n ')}`
|
||||
})
|
||||
const footer = value.hasMore
|
||||
? `More comments follow: call acfun_moment_comments with page: ${value.page + 1}.`
|
||||
: 'This is the last comment page.'
|
||||
return `${header}\n\n${lines.join('\n')}\n\n${footer}`
|
||||
}
|
||||
|
||||
/** Format one topic page. */
|
||||
export function formatTopic(value) {
|
||||
const header = `AcFun topic ${value.tagName} (tagId ${value.tagId}) — ${meta(`${value.followersText || '?'} followers`, `${value.contentsText || '?'} posts`)}; showing ${value.returned}. ${value.note}`
|
||||
if (value.items.length === 0) return `${header}\n\nNo items could be parsed from this topic page.`
|
||||
const lines = value.items.map((item, index) => {
|
||||
const label = item.momentId.length > 0 ? `moment ${item.momentId}` : `resource ${item.resourceId} (type ${item.resourceType})`
|
||||
const title = item.title.length > 0 ? item.title : item.body.slice(0, 80)
|
||||
return `${index + 1}. [${title || label}](${item.url}) — ${meta(item.authorName, item.publishedAt, label, item.tags.join(' '))}`
|
||||
})
|
||||
return `${header}\n\n${lines.join('\n')}\n\nUse acfun_moment with a momentId above to read a full 动态.`
|
||||
}
|
||||
|
||||
/** Resolve a requested quality label/height against the available ladder. */
|
||||
@@ -241,19 +307,20 @@ function requireInteger(args, name, { min = 1, max = Number.MAX_SAFE_INTEGER, fa
|
||||
return value
|
||||
}
|
||||
|
||||
/** Map one provider banana result to the interaction output shape. */
|
||||
function interactOutput(action, message, acId = '', targetUserId = '') {
|
||||
return { action, ok: true, message, acId, targetUserId }
|
||||
/** Map one provider result to the interaction output shape. */
|
||||
function interactOutput(action, message, acId = '', targetUserId = '', momentId = '') {
|
||||
return { action, ok: true, message, acId, targetUserId, momentId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the seven AcFun tool definitions for one provider instance.
|
||||
* Build the AcFun tool definitions for one provider instance.
|
||||
*
|
||||
* @param {import('./provider.js').AcfunProvider} provider - bound API client.
|
||||
* @param {{timeoutMs: number, allowWriteActions: boolean}} config - resolved plugin config.
|
||||
* @param {{sessionSource?: () => string, login?: {available: boolean, snapshot: () => object}}} [context] - runtime session/UI wiring.
|
||||
* @returns {object[]} registry-ready ToolDefinition objects.
|
||||
*/
|
||||
export function createAcfunTools(provider, config) {
|
||||
export function createAcfunTools(provider, config, context = {}) {
|
||||
const readTimeout = config.timeoutMs
|
||||
const writeTimeout = Math.max(config.timeoutMs, 60_000)
|
||||
|
||||
@@ -406,6 +473,153 @@ export function createAcfunTools(provider, config) {
|
||||
},
|
||||
}
|
||||
|
||||
const momentTool = {
|
||||
name: 'acfun_moment',
|
||||
description:
|
||||
'Read one AcFun 动态 (moment) by id: text, topics, images, author, like/comment/banana/share counts, and the canonical share URL. AcFun has no public 广场 feed API, so ids come from a share link or from acfun_topic. Works anonymously.',
|
||||
parameters: objectNode({
|
||||
momentId: required(string('Numeric moment id (for example 5093831) or a https://m.acfun.cn/communityCircle/moment/... URL.')),
|
||||
}),
|
||||
output: {
|
||||
schema: objectNode({
|
||||
momentId: required(string('The moment id.')),
|
||||
momentType: required(number('1 text-only, 2 image/text, 3 vote/other.')),
|
||||
text: required(string('Moment text, including #topic# markers.')),
|
||||
createTime: required(string('Publish date as YYYY-MM-DD (day precision).')),
|
||||
authorId: required(string('Author user id.')),
|
||||
authorName: required(string('Author display name.')),
|
||||
authorAvatar: required(string('Author avatar URL.')),
|
||||
authorSignature: required(string('Author signature, empty when absent.')),
|
||||
authorFollowers: required(number('Author follower count.')),
|
||||
images: required(
|
||||
arrayOf(
|
||||
objectNode({
|
||||
url: required(string('Original image URL.')),
|
||||
width: required(number('Image width, 0 when unknown.')),
|
||||
height: required(number('Image height, 0 when unknown.')),
|
||||
}),
|
||||
'Attached images.',
|
||||
),
|
||||
),
|
||||
tags: required(
|
||||
arrayOf(
|
||||
objectNode({
|
||||
id: required(string('Topic (tag) id usable with acfun_topic.')),
|
||||
name: required(string('Topic name.')),
|
||||
}),
|
||||
'Topics attached to the moment.',
|
||||
),
|
||||
),
|
||||
likeCount: required(number('Like count.')),
|
||||
commentCount: required(number('Comment count.')),
|
||||
bananaCount: required(number('Banana (投蕉) count.')),
|
||||
shareCount: required(number('Share count.')),
|
||||
isLike: required(boolean('Whether the configured account liked it; false when anonymous.')),
|
||||
isThrowBanana: required(boolean('Whether the configured account tipped it; false when anonymous.')),
|
||||
visibleForFans: required(boolean('Whether the moment is followers-only.')),
|
||||
repostedFrom: required(string('Text excerpt of the reposted moment, empty when not a repost.')),
|
||||
shareUrl: required(string('Canonical share URL.')),
|
||||
}),
|
||||
render: (_args, value) => text(formatMoment(value)),
|
||||
},
|
||||
timeoutMs: readTimeout,
|
||||
isConcurrencySafe: () => true,
|
||||
async execute(args, exec) {
|
||||
const momentId = requireText(args, 'momentId', 'numeric id or communityCircle/moment URL')
|
||||
return provider.moment(momentId, { signal: exec?.signal })
|
||||
},
|
||||
}
|
||||
|
||||
const momentCommentsTool = {
|
||||
name: 'acfun_moment_comments',
|
||||
description:
|
||||
'Read one page of the comment thread of an AcFun 动态 (moment), in floor order. Moments use their own comment enum, so this is separate from acfun_comments.',
|
||||
parameters: objectNode({
|
||||
momentId: required(string('Numeric moment id or a communityCircle/moment URL.')),
|
||||
page: number('1-based comment page. Defaults to 1.'),
|
||||
}),
|
||||
output: {
|
||||
schema: objectNode({
|
||||
momentId: required(string('The moment id.')),
|
||||
page: required(number('Current comment page.')),
|
||||
totalPages: required(number('Total number of comment pages.')),
|
||||
totalCount: required(number('Total comments on the moment.')),
|
||||
pageSize: required(number('Server page size for this response.')),
|
||||
returned: required(number('Comments returned on this page.')),
|
||||
hasMore: required(boolean('Whether a later page exists.')),
|
||||
url: required(string('Canonical share URL of the moment.')),
|
||||
items: required(
|
||||
arrayOf(
|
||||
objectNode({
|
||||
commentId: required(string('Numeric comment id usable as replyToCommentId.')),
|
||||
userId: required(string('Commenter user id.')),
|
||||
userName: required(string('Commenter display name.')),
|
||||
content: required(string('Plain-text comment content.')),
|
||||
floor: required(number('Floor number.')),
|
||||
postedAt: required(string('Server-formatted post time.')),
|
||||
timestamp: required(number('Post time as epoch milliseconds.')),
|
||||
isUp: required(boolean('Whether the commenter is the moment author.')),
|
||||
isDeleted: required(boolean('Whether AcFun reports the comment as deleted.')),
|
||||
}),
|
||||
'Comments in floor order.',
|
||||
),
|
||||
),
|
||||
}),
|
||||
render: (_args, value) => text(formatMomentComments(value)),
|
||||
},
|
||||
timeoutMs: readTimeout,
|
||||
isConcurrencySafe: () => true,
|
||||
async execute(args, exec) {
|
||||
const momentId = requireText(args, 'momentId', 'numeric id or communityCircle/moment URL')
|
||||
const page = requireInteger(args, 'page', { min: 1, max: 500, fallback: 1 })
|
||||
return provider.momentComments(momentId, { page, signal: exec?.signal })
|
||||
},
|
||||
}
|
||||
|
||||
const topicTool = {
|
||||
name: 'acfun_topic',
|
||||
description:
|
||||
'Read one AcFun 社区圈 topic page (best effort): the tag header and up to 15 newest items, including the moment ids of resourceType 3 items. This is the only public way to discover current moment ids, because AcFun exposes no square/recommend moment list API. No pagination is available.',
|
||||
parameters: objectNode({
|
||||
tagId: required(string('Numeric topic (tag) id, for example 488 for 教育. A moment\'s tagList carries these ids.')),
|
||||
}),
|
||||
output: {
|
||||
schema: objectNode({
|
||||
tagId: required(string('The requested topic id.')),
|
||||
tagName: required(string('Topic display name.')),
|
||||
followersText: required(string('Follower count as shown, empty when not found.')),
|
||||
contentsText: required(string('Post count as shown, empty when not found.')),
|
||||
returned: required(number('How many items were parsed.')),
|
||||
note: required(string('Parsing caveat for this surface.')),
|
||||
items: required(
|
||||
arrayOf(
|
||||
objectNode({
|
||||
momentId: required(string('Moment id, empty unless resourceType is 3.')),
|
||||
resourceType: required(number('1 article-style, 2 video-style, 3 moment.')),
|
||||
resourceId: required(string('Resource id for non-moment items.')),
|
||||
title: required(string('Item title, empty when absent.')),
|
||||
body: required(string('Item body excerpt.')),
|
||||
authorName: required(string('Author display name.')),
|
||||
publishedAt: required(string('Server-formatted date.')),
|
||||
tags: required(arrayOf(string('Topic label including # marks.'), 'Topic labels.')),
|
||||
coverUrl: required(string('First image URL on the item, empty when absent.')),
|
||||
url: required(string('Public URL of the item.')),
|
||||
statTexts: required(arrayOf(string('Numeric stat labels shown on the item.'), 'Stat texts.')),
|
||||
}),
|
||||
'Topic items in page order.',
|
||||
),
|
||||
),
|
||||
}),
|
||||
render: (_args, value) => text(formatTopic(value)),
|
||||
},
|
||||
timeoutMs: readTimeout,
|
||||
isConcurrencySafe: () => true,
|
||||
async execute(args, exec) {
|
||||
const tagId = requireText(args, 'tagId', 'numeric topic id such as 488')
|
||||
return provider.topicMoments(tagId, { signal: exec?.signal })
|
||||
},
|
||||
}
|
||||
|
||||
const danmakuTool = {
|
||||
name: 'acfun_danmaku',
|
||||
description:
|
||||
@@ -549,16 +763,28 @@ export function createAcfunTools(provider, config) {
|
||||
const interactTool = {
|
||||
name: 'acfun_interact',
|
||||
description:
|
||||
'Perform one AcFun interaction on a video or user: like, unlike, banana (投蕉), favorite, unfavorite, follow, or unfollow. Requires allowWriteActions: true and a login cookie in the plugin config; otherwise it returns a clear configuration error instead of acting. Do not repeat a call whose result was ambiguous.',
|
||||
'Perform one AcFun interaction: like, unlike, banana (投蕉), favorite, unfavorite, follow, unfollow on a video/user, or moment_like / moment_unlike / moment_banana on a 动态. Requires allowWriteActions: true and a login cookie in the plugin config; otherwise it returns a clear configuration error instead of acting. Moment writes are less verified than video writes — never repeat a call whose result was ambiguous.',
|
||||
parameters: objectNode({
|
||||
action: required(
|
||||
string('Interaction to perform.', {
|
||||
enum: ['like', 'unlike', 'banana', 'favorite', 'unfavorite', 'follow', 'unfollow'],
|
||||
enum: [
|
||||
'like',
|
||||
'unlike',
|
||||
'banana',
|
||||
'favorite',
|
||||
'unfavorite',
|
||||
'follow',
|
||||
'unfollow',
|
||||
'moment_like',
|
||||
'moment_unlike',
|
||||
'moment_banana',
|
||||
],
|
||||
}),
|
||||
),
|
||||
acId: string('Video identifier for like/unlike/banana/favorite/unfavorite.'),
|
||||
momentId: string('Moment identifier for moment_like / moment_unlike / moment_banana.'),
|
||||
userId: string('Target user id for follow/unfollow.'),
|
||||
count: number('Bananas to tip for action "banana", 1-10. Defaults to 1.'),
|
||||
count: number('Bananas to tip for action "banana" or "moment_banana", 1-10. Defaults to 1.'),
|
||||
folderId: string('Optional favorite folder id for action "favorite".'),
|
||||
}),
|
||||
output: {
|
||||
@@ -566,8 +792,9 @@ export function createAcfunTools(provider, config) {
|
||||
action: required(string('The interaction that ran.')),
|
||||
ok: required(boolean('Whether AcFun accepted the interaction.')),
|
||||
message: required(string('Human-readable outcome.')),
|
||||
acId: required(string('Video id the action targeted, empty for follow/unfollow.')),
|
||||
targetUserId: required(string('User id the action targeted, empty for video actions.')),
|
||||
acId: required(string('Video id the action targeted, empty for other targets.')),
|
||||
targetUserId: required(string('User id the action targeted, empty for content actions.')),
|
||||
momentId: required(string('Moment id the action targeted, empty for non-moment actions.')),
|
||||
}),
|
||||
render: (_args, value) => text(formatInteract(value)),
|
||||
},
|
||||
@@ -602,6 +829,18 @@ export function createAcfunTools(provider, config) {
|
||||
const outcome = await provider.setFollow(userId, action === 'follow', { signal })
|
||||
return interactOutput(action, outcome.message, '', userId)
|
||||
}
|
||||
case 'moment_like':
|
||||
case 'moment_unlike': {
|
||||
const momentId = requireText(args, 'momentId', 'required for moment_like/moment_unlike')
|
||||
const outcome = await provider.setMomentLike(momentId, action === 'moment_like', { signal })
|
||||
return interactOutput(action, outcome.message, '', '', momentId)
|
||||
}
|
||||
case 'moment_banana': {
|
||||
const momentId = requireText(args, 'momentId', 'required for moment_banana')
|
||||
const count = requireInteger(args, 'count', { min: 1, max: 10, fallback: 1 })
|
||||
const outcome = await provider.momentBanana(momentId, count, { signal })
|
||||
return interactOutput(action, outcome.message, '', '', momentId)
|
||||
}
|
||||
default:
|
||||
throw new AcfunError(`Unknown acfun_interact action: ${action}.`, { code: 'INVALID_ARGUMENT' })
|
||||
}
|
||||
@@ -611,33 +850,48 @@ export function createAcfunTools(provider, config) {
|
||||
const commentTool = {
|
||||
name: 'acfun_post_comment',
|
||||
description:
|
||||
'Post a comment (or a reply) on one acfun.cn video. Requires allowWriteActions: true and a login cookie in the plugin config; otherwise it returns a clear configuration error. Keep content short and on-topic, and do not retry after an unclear result — fetch acfun_comments to check whether it appeared.',
|
||||
'Post a comment (or a reply) on one acfun.cn video or 动态. Pass acId for a video or momentId for a moment. Requires allowWriteActions: true and a login cookie in the plugin config; otherwise it returns a clear configuration error. Keep content short and on-topic, and do not retry after an unclear result — re-read the thread to check whether it appeared.',
|
||||
parameters: objectNode({
|
||||
acId: required(string('Video identifier: numeric acId, ac-prefixed id, or acfun.cn /v/ac... URL.')),
|
||||
acId: string('Video identifier: numeric acId, ac-prefixed id, or acfun.cn /v/ac... URL.'),
|
||||
momentId: string('Moment identifier: numeric id or communityCircle/moment URL. Use instead of acId.'),
|
||||
content: required(string('Comment text, 1-2000 characters. AcFun UBB markup is allowed.')),
|
||||
replyToCommentId: string('Optional numeric comment id to reply to; omit for a top-level comment.'),
|
||||
}),
|
||||
output: {
|
||||
schema: objectNode({
|
||||
ok: required(boolean('Whether AcFun accepted the comment.')),
|
||||
acId: required(string('Bare numeric AcFun video id.')),
|
||||
acId: required(string('Bare numeric video id, empty when commenting on a moment.')),
|
||||
momentId: required(string('Bare numeric moment id, empty when commenting on a video.')),
|
||||
commentId: required(string('New comment id when AcFun returns one, otherwise empty.')),
|
||||
message: required(string('Human-readable outcome.')),
|
||||
url: required(string('Canonical acfun.cn video URL.')),
|
||||
url: required(string('Canonical video or moment URL.')),
|
||||
}),
|
||||
render: (_args, value) => text(formatPostComment(value)),
|
||||
},
|
||||
timeoutMs: writeTimeout,
|
||||
isConcurrencySafe: () => false,
|
||||
async execute(args, exec) {
|
||||
const acId = requireText(args, 'acId', 'numeric id, ac-prefixed id, or video URL')
|
||||
const content = requireText(args, 'content', 'the comment text')
|
||||
const replyToCommentId = typeof args?.replyToCommentId === 'string' ? args.replyToCommentId : undefined
|
||||
const momentArg = typeof args?.momentId === 'string' && args.momentId.trim().length > 0 ? args.momentId.trim() : ''
|
||||
if (momentArg.length > 0) {
|
||||
const outcome = await provider.postMomentComment(momentArg, content, { replyToCommentId, signal: exec?.signal })
|
||||
return {
|
||||
ok: outcome.ok,
|
||||
acId: '',
|
||||
momentId: normalizeMomentId(momentArg),
|
||||
commentId: outcome.commentId,
|
||||
message: outcome.message,
|
||||
url: outcome.url,
|
||||
}
|
||||
}
|
||||
const acId = requireText(args, 'acId', 'or pass momentId')
|
||||
const outcome = await provider.postComment(acId, content, { replyToCommentId, signal: exec?.signal })
|
||||
const normalized = normalizeAcId(acId)
|
||||
return {
|
||||
ok: outcome.ok,
|
||||
acId: normalized,
|
||||
momentId: '',
|
||||
commentId: outcome.commentId,
|
||||
message: outcome.message,
|
||||
url: acfunVideoUrl(normalized),
|
||||
@@ -645,5 +899,64 @@ export function createAcfunTools(provider, config) {
|
||||
},
|
||||
}
|
||||
|
||||
return [searchTool, videoTool, commentsTool, danmakuTool, rankTool, playUrlTool, interactTool, commentTool]
|
||||
const accountTool = {
|
||||
name: 'acfun_account',
|
||||
description:
|
||||
'Report the configured AcFun account state: whether a login cookie is configured, where it came from (plugin config, environment variable, or QR login), whether interaction tools are enabled, and how to sign in. QR login itself happens in the Harness Web UI (Settings → AcFun), not in this tool.',
|
||||
parameters: objectNode({}),
|
||||
output: {
|
||||
schema: objectNode({
|
||||
loggedIn: required(boolean('Whether any AcFun session cookie is configured.')),
|
||||
cookieConfigured: required(boolean('Whether the provider currently holds a cookie.')),
|
||||
hasAuthKey: required(boolean('Whether the cookie contains an auth_key entry.')),
|
||||
authSource: required(string('Where the session came from: config, env, qr, or none.')),
|
||||
writesEnabled: required(boolean('Whether interaction tools are allowed by config.')),
|
||||
qrLoginAvailable: required(boolean('Whether this Host exposes QR login.')),
|
||||
loginState: required(string('Current QR login state: idle, waiting, scanned, confirmed, expired, or error.')),
|
||||
hint: required(string('Next step for the user, in one sentence.')),
|
||||
}),
|
||||
render: (_args, value) => text(formatAccount(value)),
|
||||
},
|
||||
timeoutMs: readTimeout,
|
||||
isConcurrencySafe: () => true,
|
||||
async execute() {
|
||||
const described = provider.describeSession()
|
||||
const login = context.login ?? null
|
||||
const loginState = login !== null && typeof login.snapshot === 'function' ? String(login.snapshot().state ?? 'idle') : 'idle'
|
||||
const qrAvailable = login !== null && login.available === true
|
||||
const source = typeof context.sessionSource === 'function' ? context.sessionSource() : described.configured ? 'config' : 'none'
|
||||
const hint = described.configured
|
||||
? config.allowWriteActions
|
||||
? 'AcFun reads and interactions are ready.'
|
||||
: 'Add "allowWriteActions: true" to the acfun plugin config to enable interactions.'
|
||||
: qrAvailable
|
||||
? 'Open Settings → AcFun in this app and scan the QR code, or set a cookie in the plugin config.'
|
||||
: 'Set "cookie" (or the ACFUN_COOKIE environment variable) in the acfun plugin config.'
|
||||
return {
|
||||
loggedIn: described.configured,
|
||||
cookieConfigured: described.configured,
|
||||
hasAuthKey: described.hasAuthKey,
|
||||
authSource: source,
|
||||
writesEnabled: config.allowWriteActions === true,
|
||||
qrLoginAvailable: qrAvailable,
|
||||
loginState,
|
||||
hint,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return [
|
||||
searchTool,
|
||||
videoTool,
|
||||
commentsTool,
|
||||
momentTool,
|
||||
momentCommentsTool,
|
||||
topicTool,
|
||||
danmakuTool,
|
||||
rankTool,
|
||||
playUrlTool,
|
||||
interactTool,
|
||||
commentTool,
|
||||
accountTool,
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user