Files
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

125 lines
5.1 KiB
JavaScript

/**
* Live post-install smoke test for the AcFun read tools.
*
* Exercises the same tool definitions the Harness registers, against the live
* acfun.cn API, validates every canonical value against the tool's declared
* output schema, and renders it. Write tools are expected to fail closed.
*
* Run with: node scripts/live-check.mjs
*/
import { AcfunProvider } from '../lib/provider.js'
import { resolveConfig } from '../lib/config.js'
import { createAcfunTools } from '../lib/tools.js'
import { AcfunQrFlow } from '../lib/qr-flow.js'
import { validateValue } from './schema-check.mjs'
const provider = new AcfunProvider({ requestTimeoutMs: 30_000 })
const config = resolveConfig({})
const tools = new Map(createAcfunTools(provider, config).map((tool) => [tool.name, tool]))
const exec = { signal: new AbortController().signal }
const failures = []
/** Execute one tool, validate its canonical output and render it. */
async function run(toolName, args, { expectError } = {}) {
const tool = tools.get(toolName)
if (tool === undefined) {
failures.push(`${toolName}: not registered`)
return undefined
}
console.log(`\n=== ${toolName} ${JSON.stringify(args)}`)
const started = Date.now()
let value
try {
value = await tool.execute(args, exec)
} catch (error) {
if (expectError !== undefined) {
if (error?.code === expectError) {
console.log(` ok failed closed with ${error.code}: ${error.message}`)
return undefined
}
failures.push(`${toolName}: expected ${expectError}, got ${error?.code ?? error?.name}: ${error?.message}`)
return undefined
}
failures.push(`${toolName}: threw ${error?.name}: ${error?.message}`)
return undefined
}
if (expectError !== undefined) {
failures.push(`${toolName}: expected ${expectError} but the call succeeded`)
return value
}
const schemaErrors = validateValue(value, tool.output.schema)
if (schemaErrors.length > 0) failures.push(`${toolName}: output schema violations: ${schemaErrors.join('; ')}`)
else console.log(' ok canonical value matches output schema')
let rendered
try {
rendered = tool.output.render(args, value)
} catch (error) {
failures.push(`${toolName}: render threw ${error?.message}`)
return value
}
const text = rendered.map((block) => block.text).join('\n')
console.log(` ok rendered ${text.length} chars in ${Date.now() - started} ms`)
console.log(
text
.split('\n')
.slice(0, 12)
.map((line) => ` | ${line}`)
.join('\n'),
)
return value
}
const search = await run('acfun_search', { keyword: '原神' })
await run('acfun_search', { keyword: '原神', sort: 'views' })
const acId = search?.items?.[0]?.acId ?? '42588725'
console.log(`\n(using acId ${acId} for the follow-up calls)`)
const video = await run('acfun_video', { acId })
await run('acfun_comments', { acId, page: 1 })
await run('acfun_danmaku', { acId, limit: 50 })
await run('acfun_rank', { period: 'day', limit: 5 })
await run('acfun_playurl', { acId })
await run('acfun_account', {})
await run('acfun_moment', { momentId: process.env.ACFUN_MOMENT_ID ?? '5093831' })
await run('acfun_moment_comments', { momentId: process.env.ACFUN_MOMENT_ID ?? '5093831', page: 1 })
await run('acfun_topic', { tagId: process.env.ACFUN_TOPIC_ID ?? '488' })
if (video !== undefined && video.title.length === 0) failures.push('acfun_video: empty title')
// Interactions must fail closed without allowWriteActions + cookie.
await run('acfun_interact', { action: 'like', acId }, { expectError: 'WRITE_DISABLED' })
await run('acfun_interact', { action: 'moment_like', momentId: process.env.ACFUN_MOMENT_ID ?? '5093831' }, { expectError: 'WRITE_DISABLED' })
await run('acfun_post_comment', { acId, content: 'live-check should not post this' }, { expectError: 'WRITE_DISABLED' })
// QR login: ask AcFun for a real ticket and check the waiting state. No phone
// scans it, so this proves issuance + the long-poll waiting path only.
console.log('\n=== QR login (live, never scanned)')
try {
const flow = new AcfunQrFlow({ longPollTimeoutMs: 8_000 })
const started = await flow.start()
console.log(` ok issued ticket ${String(started.ticket).slice(0, 12)}… with QR ${started.qr.kind} (${started.qr.value.length} chars), ttl ${Math.round((started.expiresAt - Date.now()) / 1000)}s`)
const aborter = new AbortController()
const scan = await flow.waitForScan({ signal: aborter.signal })
console.log(` ok unscanned ticket reports state=${scan.state}`)
if (scan.state === 'waiting') {
const pending = flow.waitForScan({ signal: aborter.signal })
setTimeout(() => aborter.abort(), 400)
try {
await pending
failures.push('QR wait did not abort promptly')
} catch (error) {
console.log(` ok abort cancels the long poll (${error?.name})`)
}
}
} catch (error) {
failures.push(`QR login live check failed: ${error?.name}: ${error?.message}`)
}
if (failures.length > 0) {
console.error(`\n${failures.length} live check(s) failed`)
for (const failure of failures) console.error(`- ${failure}`)
process.exitCode = 1
} else {
console.log('\nall live read checks passed; write tools failed closed as designed')
}