Files
dsh-acfun/lib/qr-flow.js
T
tamakyi 97825f020d 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
2026-10-27 11:50:44 +08:00

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