- 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
222 lines
8.7 KiB
JavaScript
222 lines
8.7 KiB
JavaScript
/**
|
|
* 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'}).` }
|
|
}
|
|
}
|