/** * 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'}).` } } }