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:
+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'}).` }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user