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:
tamakyi
2026-10-27 11:50:44 +08:00
parent f9bedde19e
commit 97825f020d
19 changed files with 3723 additions and 189 deletions
+338 -25
View File
@@ -7,18 +7,20 @@
* import and `node scripts/live-check.mjs` can execute it outside the harness.
*/
import { AcfunError, acfunVideoUrl, DANMAKU_MAX_COUNT, normalizeAcId, RANK_PERIODS, SEARCH_SORTS } from './provider.js'
import { AcfunError, acfunVideoUrl, DANMAKU_MAX_COUNT, normalizeAcId, normalizeMomentId, RANK_PERIODS, SEARCH_SORTS } from './provider.js'
/** The system-prompt guidance registered by the plugin. */
export const ACFUN_GUIDANCE = [
'AcFun (A站) tools: use acfun_search to find videos, acfun_video for one result\'s full metadata,',
'acfun_comments for its comment thread, acfun_danmaku for the 弹幕 list, acfun_rank for the day/week',
'ranking, and acfun_playurl for an m3u8 play URL. acfun_interact (like / unlike / banana /',
'favorite / unfavorite / follow / unfollow) and acfun_post_comment are disabled unless the plugin',
'config sets allowWriteActions: true and a login cookie; when a write tool reports WRITE_DISABLED or',
'LOGIN_REQUIRED, tell the user which config key is missing instead of retrying. Cite the video URL',
'as a markdown link when you use its content, and never repeat a mutating call just because the',
'result was unclear.',
'ranking, and acfun_playurl for an m3u8 play URL. For 动态 (moments) use acfun_moment,',
'acfun_moment_comments and acfun_topic: AcFun exposes no public 广场 feed, so moment ids come only',
'from a topic page (acfun_topic) or a share link. acfun_interact (like / unlike / banana /',
'favorite / unfavorite / follow / unfollow, plus moment_like / moment_unlike / moment_banana) and',
'acfun_post_comment are disabled unless the plugin config sets allowWriteActions: true and a login',
'cookie; when a write tool reports WRITE_DISABLED or LOGIN_REQUIRED, tell the user which config key',
'is missing instead of retrying. Publishing a moment is app-only and unavailable. Cite the share URL',
'as a markdown link when you use content, and never repeat a mutating call because the result was unclear.',
].join(' ')
/** One text content block. */
@@ -191,12 +193,76 @@ export function formatPlayUrls(value) {
/** Format one interaction outcome. */
export function formatInteract(value) {
return `${value.message}\n\nAction: ${value.action} · acId: ${value.acId || '-'} · userId: ${value.targetUserId || '-'}`
const target = value.momentId.length > 0 ? `moment ${value.momentId}` : value.acId.length > 0 ? `ac${value.acId}` : value.targetUserId.length > 0 ? `user ${value.targetUserId}` : '-'
return `${value.message}\n\nAction: ${value.action} · target: ${target}`
}
/** Format one posted comment. */
export function formatPostComment(value) {
return `${value.message}\n\nComment id: ${value.commentId || '(not returned)'} · video: ${value.url}`
const target = value.momentId.length > 0 ? `moment ${value.momentId}` : value.acId.length > 0 ? `ac${value.acId}` : 'unknown'
return `${value.message}\n\nComment id: ${value.commentId || '(not returned)'} · target: ${target} · ${value.url}`
}
/** Format the account/session summary. */
export function formatAccount(value) {
return [
`AcFun account: ${value.loggedIn ? 'signed in' : 'anonymous'} (source: ${value.authSource}${value.hasAuthKey ? ', auth_key present' : ''}).`,
`Interaction tools: ${value.writesEnabled ? 'enabled' : 'disabled'}${value.writesEnabled ? '' : ' (allowWriteActions is false)'}.`,
`QR login available in this Host: ${value.qrLoginAvailable ? 'yes' : 'no'}; login state: ${value.loginState}.`,
value.hint,
].join('\n')
}
/** Format one moment (动态). */
export function formatMoment(value) {
const lines = [
`# AcFun 动态 ${value.momentId}`,
`URL: ${value.shareUrl}`,
`Author: ${value.authorName || 'unknown'}${value.authorId ? ` (id ${value.authorId})` : ''}${value.authorFollowers > 0 ? ` · ${value.authorFollowers} followers` : ''}`,
`Published: ${value.createTime || '(unknown)'} · type ${value.momentType}`,
`Stats: ${value.likeCount} likes · ${value.commentCount} comments · ${value.bananaCount} bananas · ${value.shareCount} shares`,
`Interaction state: ${value.isLike ? 'liked' : 'not liked'} · ${value.isThrowBanana ? 'banana given' : 'no banana'}${value.visibleForFans ? ' · followers-only' : ''}`,
]
if (value.tags.length > 0) lines.push(`Topics: ${value.tags.map((tag) => `#${tag.name}#(${tag.id})`).join(' ')}`)
if (value.text.length > 0) lines.push(`\nText:\n${value.text}`)
if (value.images.length > 0) {
lines.push(`\nImages (${value.images.length}):`)
for (const image of value.images) lines.push(`- ${image.url}${image.width > 0 ? ` (${image.width}x${image.height})` : ''}`)
}
if (value.repostedFrom.length > 0) lines.push(`\nRepost of: ${value.repostedFrom}`)
return lines.join('\n')
}
/** Format one moment comment page. */
export function formatMomentComments(value) {
const header = `Comments on moment ${value.momentId} (${value.url}) — page ${value.page}/${value.totalPages}, ${value.totalCount} comments total, ${value.returned} on this page.`
if (value.items.length === 0) return `${header}\n\nNo comments on this page.`
const lines = value.items.map((item) => {
const head = meta(
`#${item.floor}`,
item.userName.length > 0 ? `${item.userName} (id ${item.userId})` : `user ${item.userId}`,
item.postedAt,
`cid ${item.commentId}`,
)
const body = item.isDeleted ? '[deleted]' : item.content.length > 0 ? item.content : '[empty]'
return `- ${head}\n ${body.replace(/\n/g, '\n ')}`
})
const footer = value.hasMore
? `More comments follow: call acfun_moment_comments with page: ${value.page + 1}.`
: 'This is the last comment page.'
return `${header}\n\n${lines.join('\n')}\n\n${footer}`
}
/** Format one topic page. */
export function formatTopic(value) {
const header = `AcFun topic ${value.tagName} (tagId ${value.tagId}) — ${meta(`${value.followersText || '?'} followers`, `${value.contentsText || '?'} posts`)}; showing ${value.returned}. ${value.note}`
if (value.items.length === 0) return `${header}\n\nNo items could be parsed from this topic page.`
const lines = value.items.map((item, index) => {
const label = item.momentId.length > 0 ? `moment ${item.momentId}` : `resource ${item.resourceId} (type ${item.resourceType})`
const title = item.title.length > 0 ? item.title : item.body.slice(0, 80)
return `${index + 1}. [${title || label}](${item.url}) — ${meta(item.authorName, item.publishedAt, label, item.tags.join(' '))}`
})
return `${header}\n\n${lines.join('\n')}\n\nUse acfun_moment with a momentId above to read a full 动态.`
}
/** Resolve a requested quality label/height against the available ladder. */
@@ -241,19 +307,20 @@ function requireInteger(args, name, { min = 1, max = Number.MAX_SAFE_INTEGER, fa
return value
}
/** Map one provider banana result to the interaction output shape. */
function interactOutput(action, message, acId = '', targetUserId = '') {
return { action, ok: true, message, acId, targetUserId }
/** Map one provider result to the interaction output shape. */
function interactOutput(action, message, acId = '', targetUserId = '', momentId = '') {
return { action, ok: true, message, acId, targetUserId, momentId }
}
/**
* Build the seven AcFun tool definitions for one provider instance.
* Build the AcFun tool definitions for one provider instance.
*
* @param {import('./provider.js').AcfunProvider} provider - bound API client.
* @param {{timeoutMs: number, allowWriteActions: boolean}} config - resolved plugin config.
* @param {{sessionSource?: () => string, login?: {available: boolean, snapshot: () => object}}} [context] - runtime session/UI wiring.
* @returns {object[]} registry-ready ToolDefinition objects.
*/
export function createAcfunTools(provider, config) {
export function createAcfunTools(provider, config, context = {}) {
const readTimeout = config.timeoutMs
const writeTimeout = Math.max(config.timeoutMs, 60_000)
@@ -406,6 +473,153 @@ export function createAcfunTools(provider, config) {
},
}
const momentTool = {
name: 'acfun_moment',
description:
'Read one AcFun 动态 (moment) by id: text, topics, images, author, like/comment/banana/share counts, and the canonical share URL. AcFun has no public 广场 feed API, so ids come from a share link or from acfun_topic. Works anonymously.',
parameters: objectNode({
momentId: required(string('Numeric moment id (for example 5093831) or a https://m.acfun.cn/communityCircle/moment/... URL.')),
}),
output: {
schema: objectNode({
momentId: required(string('The moment id.')),
momentType: required(number('1 text-only, 2 image/text, 3 vote/other.')),
text: required(string('Moment text, including #topic# markers.')),
createTime: required(string('Publish date as YYYY-MM-DD (day precision).')),
authorId: required(string('Author user id.')),
authorName: required(string('Author display name.')),
authorAvatar: required(string('Author avatar URL.')),
authorSignature: required(string('Author signature, empty when absent.')),
authorFollowers: required(number('Author follower count.')),
images: required(
arrayOf(
objectNode({
url: required(string('Original image URL.')),
width: required(number('Image width, 0 when unknown.')),
height: required(number('Image height, 0 when unknown.')),
}),
'Attached images.',
),
),
tags: required(
arrayOf(
objectNode({
id: required(string('Topic (tag) id usable with acfun_topic.')),
name: required(string('Topic name.')),
}),
'Topics attached to the moment.',
),
),
likeCount: required(number('Like count.')),
commentCount: required(number('Comment count.')),
bananaCount: required(number('Banana (投蕉) count.')),
shareCount: required(number('Share count.')),
isLike: required(boolean('Whether the configured account liked it; false when anonymous.')),
isThrowBanana: required(boolean('Whether the configured account tipped it; false when anonymous.')),
visibleForFans: required(boolean('Whether the moment is followers-only.')),
repostedFrom: required(string('Text excerpt of the reposted moment, empty when not a repost.')),
shareUrl: required(string('Canonical share URL.')),
}),
render: (_args, value) => text(formatMoment(value)),
},
timeoutMs: readTimeout,
isConcurrencySafe: () => true,
async execute(args, exec) {
const momentId = requireText(args, 'momentId', 'numeric id or communityCircle/moment URL')
return provider.moment(momentId, { signal: exec?.signal })
},
}
const momentCommentsTool = {
name: 'acfun_moment_comments',
description:
'Read one page of the comment thread of an AcFun 动态 (moment), in floor order. Moments use their own comment enum, so this is separate from acfun_comments.',
parameters: objectNode({
momentId: required(string('Numeric moment id or a communityCircle/moment URL.')),
page: number('1-based comment page. Defaults to 1.'),
}),
output: {
schema: objectNode({
momentId: required(string('The moment id.')),
page: required(number('Current comment page.')),
totalPages: required(number('Total number of comment pages.')),
totalCount: required(number('Total comments on the moment.')),
pageSize: required(number('Server page size for this response.')),
returned: required(number('Comments returned on this page.')),
hasMore: required(boolean('Whether a later page exists.')),
url: required(string('Canonical share URL of the moment.')),
items: required(
arrayOf(
objectNode({
commentId: required(string('Numeric comment id usable as replyToCommentId.')),
userId: required(string('Commenter user id.')),
userName: required(string('Commenter display name.')),
content: required(string('Plain-text comment content.')),
floor: required(number('Floor number.')),
postedAt: required(string('Server-formatted post time.')),
timestamp: required(number('Post time as epoch milliseconds.')),
isUp: required(boolean('Whether the commenter is the moment author.')),
isDeleted: required(boolean('Whether AcFun reports the comment as deleted.')),
}),
'Comments in floor order.',
),
),
}),
render: (_args, value) => text(formatMomentComments(value)),
},
timeoutMs: readTimeout,
isConcurrencySafe: () => true,
async execute(args, exec) {
const momentId = requireText(args, 'momentId', 'numeric id or communityCircle/moment URL')
const page = requireInteger(args, 'page', { min: 1, max: 500, fallback: 1 })
return provider.momentComments(momentId, { page, signal: exec?.signal })
},
}
const topicTool = {
name: 'acfun_topic',
description:
'Read one AcFun 社区圈 topic page (best effort): the tag header and up to 15 newest items, including the moment ids of resourceType 3 items. This is the only public way to discover current moment ids, because AcFun exposes no square/recommend moment list API. No pagination is available.',
parameters: objectNode({
tagId: required(string('Numeric topic (tag) id, for example 488 for 教育. A moment\'s tagList carries these ids.')),
}),
output: {
schema: objectNode({
tagId: required(string('The requested topic id.')),
tagName: required(string('Topic display name.')),
followersText: required(string('Follower count as shown, empty when not found.')),
contentsText: required(string('Post count as shown, empty when not found.')),
returned: required(number('How many items were parsed.')),
note: required(string('Parsing caveat for this surface.')),
items: required(
arrayOf(
objectNode({
momentId: required(string('Moment id, empty unless resourceType is 3.')),
resourceType: required(number('1 article-style, 2 video-style, 3 moment.')),
resourceId: required(string('Resource id for non-moment items.')),
title: required(string('Item title, empty when absent.')),
body: required(string('Item body excerpt.')),
authorName: required(string('Author display name.')),
publishedAt: required(string('Server-formatted date.')),
tags: required(arrayOf(string('Topic label including # marks.'), 'Topic labels.')),
coverUrl: required(string('First image URL on the item, empty when absent.')),
url: required(string('Public URL of the item.')),
statTexts: required(arrayOf(string('Numeric stat labels shown on the item.'), 'Stat texts.')),
}),
'Topic items in page order.',
),
),
}),
render: (_args, value) => text(formatTopic(value)),
},
timeoutMs: readTimeout,
isConcurrencySafe: () => true,
async execute(args, exec) {
const tagId = requireText(args, 'tagId', 'numeric topic id such as 488')
return provider.topicMoments(tagId, { signal: exec?.signal })
},
}
const danmakuTool = {
name: 'acfun_danmaku',
description:
@@ -549,16 +763,28 @@ export function createAcfunTools(provider, config) {
const interactTool = {
name: 'acfun_interact',
description:
'Perform one AcFun interaction on a video or user: like, unlike, banana (投蕉), favorite, unfavorite, follow, or unfollow. Requires allowWriteActions: true and a login cookie in the plugin config; otherwise it returns a clear configuration error instead of acting. Do not repeat a call whose result was ambiguous.',
'Perform one AcFun interaction: like, unlike, banana (投蕉), favorite, unfavorite, follow, unfollow on a video/user, or moment_like / moment_unlike / moment_banana on a 动态. Requires allowWriteActions: true and a login cookie in the plugin config; otherwise it returns a clear configuration error instead of acting. Moment writes are less verified than video writes — never repeat a call whose result was ambiguous.',
parameters: objectNode({
action: required(
string('Interaction to perform.', {
enum: ['like', 'unlike', 'banana', 'favorite', 'unfavorite', 'follow', 'unfollow'],
enum: [
'like',
'unlike',
'banana',
'favorite',
'unfavorite',
'follow',
'unfollow',
'moment_like',
'moment_unlike',
'moment_banana',
],
}),
),
acId: string('Video identifier for like/unlike/banana/favorite/unfavorite.'),
momentId: string('Moment identifier for moment_like / moment_unlike / moment_banana.'),
userId: string('Target user id for follow/unfollow.'),
count: number('Bananas to tip for action "banana", 1-10. Defaults to 1.'),
count: number('Bananas to tip for action "banana" or "moment_banana", 1-10. Defaults to 1.'),
folderId: string('Optional favorite folder id for action "favorite".'),
}),
output: {
@@ -566,8 +792,9 @@ export function createAcfunTools(provider, config) {
action: required(string('The interaction that ran.')),
ok: required(boolean('Whether AcFun accepted the interaction.')),
message: required(string('Human-readable outcome.')),
acId: required(string('Video id the action targeted, empty for follow/unfollow.')),
targetUserId: required(string('User id the action targeted, empty for video actions.')),
acId: required(string('Video id the action targeted, empty for other targets.')),
targetUserId: required(string('User id the action targeted, empty for content actions.')),
momentId: required(string('Moment id the action targeted, empty for non-moment actions.')),
}),
render: (_args, value) => text(formatInteract(value)),
},
@@ -602,6 +829,18 @@ export function createAcfunTools(provider, config) {
const outcome = await provider.setFollow(userId, action === 'follow', { signal })
return interactOutput(action, outcome.message, '', userId)
}
case 'moment_like':
case 'moment_unlike': {
const momentId = requireText(args, 'momentId', 'required for moment_like/moment_unlike')
const outcome = await provider.setMomentLike(momentId, action === 'moment_like', { signal })
return interactOutput(action, outcome.message, '', '', momentId)
}
case 'moment_banana': {
const momentId = requireText(args, 'momentId', 'required for moment_banana')
const count = requireInteger(args, 'count', { min: 1, max: 10, fallback: 1 })
const outcome = await provider.momentBanana(momentId, count, { signal })
return interactOutput(action, outcome.message, '', '', momentId)
}
default:
throw new AcfunError(`Unknown acfun_interact action: ${action}.`, { code: 'INVALID_ARGUMENT' })
}
@@ -611,33 +850,48 @@ export function createAcfunTools(provider, config) {
const commentTool = {
name: 'acfun_post_comment',
description:
'Post a comment (or a reply) on one acfun.cn video. Requires allowWriteActions: true and a login cookie in the plugin config; otherwise it returns a clear configuration error. Keep content short and on-topic, and do not retry after an unclear result — fetch acfun_comments to check whether it appeared.',
'Post a comment (or a reply) on one acfun.cn video or 动态. Pass acId for a video or momentId for a moment. Requires allowWriteActions: true and a login cookie in the plugin config; otherwise it returns a clear configuration error. Keep content short and on-topic, and do not retry after an unclear result — re-read the thread to check whether it appeared.',
parameters: objectNode({
acId: required(string('Video identifier: numeric acId, ac-prefixed id, or acfun.cn /v/ac... URL.')),
acId: string('Video identifier: numeric acId, ac-prefixed id, or acfun.cn /v/ac... URL.'),
momentId: string('Moment identifier: numeric id or communityCircle/moment URL. Use instead of acId.'),
content: required(string('Comment text, 1-2000 characters. AcFun UBB markup is allowed.')),
replyToCommentId: string('Optional numeric comment id to reply to; omit for a top-level comment.'),
}),
output: {
schema: objectNode({
ok: required(boolean('Whether AcFun accepted the comment.')),
acId: required(string('Bare numeric AcFun video id.')),
acId: required(string('Bare numeric video id, empty when commenting on a moment.')),
momentId: required(string('Bare numeric moment id, empty when commenting on a video.')),
commentId: required(string('New comment id when AcFun returns one, otherwise empty.')),
message: required(string('Human-readable outcome.')),
url: required(string('Canonical acfun.cn video URL.')),
url: required(string('Canonical video or moment URL.')),
}),
render: (_args, value) => text(formatPostComment(value)),
},
timeoutMs: writeTimeout,
isConcurrencySafe: () => false,
async execute(args, exec) {
const acId = requireText(args, 'acId', 'numeric id, ac-prefixed id, or video URL')
const content = requireText(args, 'content', 'the comment text')
const replyToCommentId = typeof args?.replyToCommentId === 'string' ? args.replyToCommentId : undefined
const momentArg = typeof args?.momentId === 'string' && args.momentId.trim().length > 0 ? args.momentId.trim() : ''
if (momentArg.length > 0) {
const outcome = await provider.postMomentComment(momentArg, content, { replyToCommentId, signal: exec?.signal })
return {
ok: outcome.ok,
acId: '',
momentId: normalizeMomentId(momentArg),
commentId: outcome.commentId,
message: outcome.message,
url: outcome.url,
}
}
const acId = requireText(args, 'acId', 'or pass momentId')
const outcome = await provider.postComment(acId, content, { replyToCommentId, signal: exec?.signal })
const normalized = normalizeAcId(acId)
return {
ok: outcome.ok,
acId: normalized,
momentId: '',
commentId: outcome.commentId,
message: outcome.message,
url: acfunVideoUrl(normalized),
@@ -645,5 +899,64 @@ export function createAcfunTools(provider, config) {
},
}
return [searchTool, videoTool, commentsTool, danmakuTool, rankTool, playUrlTool, interactTool, commentTool]
const accountTool = {
name: 'acfun_account',
description:
'Report the configured AcFun account state: whether a login cookie is configured, where it came from (plugin config, environment variable, or QR login), whether interaction tools are enabled, and how to sign in. QR login itself happens in the Harness Web UI (Settings → AcFun), not in this tool.',
parameters: objectNode({}),
output: {
schema: objectNode({
loggedIn: required(boolean('Whether any AcFun session cookie is configured.')),
cookieConfigured: required(boolean('Whether the provider currently holds a cookie.')),
hasAuthKey: required(boolean('Whether the cookie contains an auth_key entry.')),
authSource: required(string('Where the session came from: config, env, qr, or none.')),
writesEnabled: required(boolean('Whether interaction tools are allowed by config.')),
qrLoginAvailable: required(boolean('Whether this Host exposes QR login.')),
loginState: required(string('Current QR login state: idle, waiting, scanned, confirmed, expired, or error.')),
hint: required(string('Next step for the user, in one sentence.')),
}),
render: (_args, value) => text(formatAccount(value)),
},
timeoutMs: readTimeout,
isConcurrencySafe: () => true,
async execute() {
const described = provider.describeSession()
const login = context.login ?? null
const loginState = login !== null && typeof login.snapshot === 'function' ? String(login.snapshot().state ?? 'idle') : 'idle'
const qrAvailable = login !== null && login.available === true
const source = typeof context.sessionSource === 'function' ? context.sessionSource() : described.configured ? 'config' : 'none'
const hint = described.configured
? config.allowWriteActions
? 'AcFun reads and interactions are ready.'
: 'Add "allowWriteActions: true" to the acfun plugin config to enable interactions.'
: qrAvailable
? 'Open Settings → AcFun in this app and scan the QR code, or set a cookie in the plugin config.'
: 'Set "cookie" (or the ACFUN_COOKIE environment variable) in the acfun plugin config.'
return {
loggedIn: described.configured,
cookieConfigured: described.configured,
hasAuthKey: described.hasAuthKey,
authSource: source,
writesEnabled: config.allowWriteActions === true,
qrLoginAvailable: qrAvailable,
loginState,
hint,
}
},
}
return [
searchTool,
videoTool,
commentsTool,
momentTool,
momentCommentsTool,
topicTool,
danmakuTool,
rankTool,
playUrlTool,
interactTool,
commentTool,
accountTool,
]
}