Eight tools (search, video, comments, danmaku, rank, playurl, interact, post_comment) over acfun.cn's public web API, a model-facing skill, a bundle patch with a skill-filesystem row, and offline/live checks. Reads are anonymous; writes require allowWriteActions plus a login cookie.
650 lines
31 KiB
JavaScript
650 lines
31 KiB
JavaScript
/**
|
|
* Model-facing AcFun tools and their formatters.
|
|
*
|
|
* Deliberately dependency-free: every tool is a plain `ToolDefinition` object
|
|
* (`name`, `description`, `parameters` as raw JSON Schema, `output.schema` as
|
|
* raw JSON Schema, and `render`), so the bundle needs no `@deepseek-ai/*`
|
|
* 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'
|
|
|
|
/** 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.',
|
|
].join(' ')
|
|
|
|
/** One text content block. */
|
|
const text = (value) => [{ type: 'text', text: value }]
|
|
|
|
/**
|
|
* Compile an author-friendly property map into a raw JSON Schema object node.
|
|
* A property marked `required: true` contributes to the object's `required`
|
|
* array; the marker itself is removed from the emitted schema.
|
|
*/
|
|
function objectNode(properties) {
|
|
const required = []
|
|
const cleaned = {}
|
|
for (const [key, value] of Object.entries(properties)) {
|
|
const { required: isRequired, ...schema } = value
|
|
if (isRequired === true) required.push(key)
|
|
cleaned[key] = schema
|
|
}
|
|
const node = { type: 'object', additionalProperties: false, properties: cleaned }
|
|
if (required.length > 0) node.required = required
|
|
return node
|
|
}
|
|
|
|
/** A required scalar property. */
|
|
const required = (schema) => ({ ...schema, required: true })
|
|
|
|
const string = (description, extra = {}) => ({ type: 'string', description, ...extra })
|
|
const number = (description, extra = {}) => ({ type: 'number', description, ...extra })
|
|
const boolean = (description, extra = {}) => ({ type: 'boolean', description, ...extra })
|
|
const arrayOf = (items, description) => ({ type: 'array', description, items })
|
|
|
|
/** A compact one-line list entry: `- [title](url) — meta`. */
|
|
function bullet(title, url, meta) {
|
|
return meta.length > 0 ? `- [${title}](${url}) — ${meta}` : `- [${title}](${url})`
|
|
}
|
|
|
|
/** Join non-empty metadata fragments with ` · `. */
|
|
function meta(...fragments) {
|
|
return fragments.filter((fragment) => typeof fragment === 'string' && fragment.length > 0).join(' · ')
|
|
}
|
|
|
|
/** Format the search result for the model. */
|
|
export function formatSearch(value) {
|
|
const sortNote = value.sort === 'views' ? 'sorted by most-viewed' : 'sorted by relevance'
|
|
const header = `Found ${value.total} AcFun videos for "${value.keyword}" (${sortNote}); showing ${value.returned}. AcFun's search API ignores paging parameters and returns one fixed first page.`
|
|
if (value.items.length === 0) return `${header}\n\nNo videos matched this keyword.`
|
|
const lines = value.items.map((item) =>
|
|
bullet(
|
|
item.title,
|
|
item.url,
|
|
meta(
|
|
item.authorName.length > 0 ? `UP ${item.authorName}` : '',
|
|
`${item.viewCount} plays`,
|
|
item.danmakuCount > 0 ? `${item.danmakuCount} danmaku` : '',
|
|
item.commentCount > 0 ? `${item.commentCount} comments` : '',
|
|
item.duration,
|
|
item.publishedAt.slice(0, 10),
|
|
`acId ${item.acId}`,
|
|
),
|
|
),
|
|
)
|
|
return `${header}\n\n${lines.join('\n')}\n\nUse acfun_video with one of those acId values for full metadata.`
|
|
}
|
|
|
|
/** Format one video detail. */
|
|
export function formatVideo(value) {
|
|
const lines = [`# ${value.title}`, `URL: ${value.url}`, `acId: ${value.acId}`]
|
|
const author = meta(
|
|
`UP ${value.authorName || 'unknown'}${value.authorId ? ` (id ${value.authorId})` : ''}`,
|
|
value.authorFollowers > 0 ? `${value.authorFollowers} followers` : '',
|
|
value.authorSignature.length > 0 ? value.authorSignature : '',
|
|
)
|
|
lines.push(`Author: ${author}`)
|
|
lines.push(
|
|
`Channel: ${meta(value.channelParent, value.channel, `duration ${value.duration}`, `published ${value.publishedAt.slice(0, 10)}`)}`,
|
|
)
|
|
lines.push(
|
|
`Stats: ${meta(
|
|
`${value.viewCount} plays`,
|
|
`${value.likeCount} likes`,
|
|
`${value.favoriteCount} favorites`,
|
|
`${value.bananaCount} bananas`,
|
|
`${value.commentCount} comments`,
|
|
`${value.danmakuCount} danmaku`,
|
|
)}`,
|
|
)
|
|
if (value.tags.length > 0) lines.push(`Tags: ${value.tags.map((tag) => tag.name).join(', ')}`)
|
|
lines.push(
|
|
`Interaction state: ${meta(
|
|
value.loggedIn ? 'logged in' : 'anonymous (no cookie)',
|
|
`liked ${value.liked ? 'yes' : 'no'}`,
|
|
`favorited ${value.favorited ? 'yes' : 'no'}`,
|
|
`banana given ${value.bananaGiven ? 'yes' : 'no'}`,
|
|
)}`,
|
|
)
|
|
if (value.description.length > 0) lines.push(`\nDescription:\n${value.description}`)
|
|
return lines.join('\n')
|
|
}
|
|
|
|
/** Format one comment page. */
|
|
export function formatComments(value) {
|
|
const header = `Comments on ${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,
|
|
item.isUp ? 'UP主' : '',
|
|
`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_comments again with page: ${value.page + 1}.`
|
|
: 'This is the last comment page.'
|
|
return `${header}\n\n${lines.join('\n')}\n\n${footer}`
|
|
}
|
|
|
|
/** Format one danmaku page. */
|
|
export function formatDanmaku(value) {
|
|
const header = `Danmaku on ${acfunVideoUrl(value.acId)} — ${value.totalCount} total, ${value.returned} in this batch, ordered by in-video position.`
|
|
if (value.items.length === 0) return `${header}\n\nNo danmaku in this batch.`
|
|
const lines = value.items.map((item) => {
|
|
const metaText = meta(
|
|
item.time,
|
|
item.mode > 0 ? `mode ${item.mode}` : '',
|
|
item.color.length > 0 ? item.color : '',
|
|
item.userId.length > 0 ? `user ${item.userId}` : '',
|
|
item.likeCount > 0 ? `${item.likeCount} likes` : '',
|
|
)
|
|
return `- ${metaText}\n ${item.body.replace(/\n/g, '\n ')}`
|
|
})
|
|
const footer =
|
|
value.nextCursor.length > 0
|
|
? `More danmaku follow: call acfun_danmaku again with cursor: "${value.nextCursor}".`
|
|
: 'This is the last danmaku batch.'
|
|
return `${header}\n\n${lines.join('\n')}\n\n${footer}`
|
|
}
|
|
|
|
/** Format one ranking page. */
|
|
export function formatRank(value) {
|
|
const scope = value.channelId === 0 ? 'all channels' : `channel ${value.channelId}`
|
|
const header = `AcFun ${value.period} ranking (${scope}) — ${value.returned} entries.`
|
|
if (value.items.length === 0) return `${header}\n\nNo entries for this period/channel.`
|
|
const lines = value.items.map((item, index) => {
|
|
const suffix = meta(
|
|
item.authorName.length > 0 ? `UP ${item.authorName}` : '',
|
|
item.channelName,
|
|
item.duration,
|
|
item.danmakuCount > 0 ? `${item.danmakuCount} danmaku` : '',
|
|
item.likeCountText.length > 0 ? `${item.likeCountText} likes` : '',
|
|
item.commentCountText.length > 0 ? `${item.commentCountText} comments` : '',
|
|
`acId ${item.acId}`,
|
|
)
|
|
return `${index + 1}. [${item.title}](${item.url})${suffix.length > 0 ? ` — ${suffix}` : ''}`
|
|
})
|
|
return `${header}\n\n${lines.join('\n')}`
|
|
}
|
|
|
|
/** Format the playable quality ladder. */
|
|
export function formatPlayUrls(value) {
|
|
const header = `Playable streams for ${value.title} (${value.duration}) — ${acfunVideoUrl(value.acId)}`
|
|
const lines = value.qualities.map((quality) =>
|
|
`- ${quality.label} (${quality.width}x${quality.height}): ${quality.url}`,
|
|
)
|
|
return `${header}\n\n${lines.join('\n')}\n\n${value.chosenUrl.length > 0 ? `Selected ${value.chosenLabel}: ${value.chosenUrl}` : 'No quality was requested; the first (highest) entry is the default.'}\n\nThese are time-limited m3u8 URLs; re-run acfun_playurl if one expires.`
|
|
}
|
|
|
|
/** Format one interaction outcome. */
|
|
export function formatInteract(value) {
|
|
return `${value.message}\n\nAction: ${value.action} · acId: ${value.acId || '-'} · userId: ${value.targetUserId || '-'}`
|
|
}
|
|
|
|
/** Format one posted comment. */
|
|
export function formatPostComment(value) {
|
|
return `${value.message}\n\nComment id: ${value.commentId || '(not returned)'} · video: ${value.url}`
|
|
}
|
|
|
|
/** Resolve a requested quality label/height against the available ladder. */
|
|
function chooseQuality(qualities, requested) {
|
|
if (qualities.length === 0) return undefined
|
|
const wanted = typeof requested === 'string' ? requested.trim().toLowerCase() : ''
|
|
if (wanted.length === 0) return qualities[0]
|
|
const exact = qualities.find((quality) => quality.label.toLowerCase() === wanted)
|
|
if (exact !== undefined) return exact
|
|
const partial = qualities.find((quality) => quality.label.toLowerCase().includes(wanted))
|
|
if (partial !== undefined) return partial
|
|
const height = /^(\d{3,4})p?$/.exec(wanted)
|
|
if (height !== null) {
|
|
const target = Number(height[1])
|
|
const ranked = [...qualities].sort((left, right) => Math.abs(left.height - target) - Math.abs(right.height - target))
|
|
if (ranked[0] !== undefined) return ranked[0]
|
|
}
|
|
throw new AcfunError(
|
|
`No AcFun stream matches quality "${requested}". Available: ${qualities.map((quality) => quality.label).join(', ')}.`,
|
|
{ code: 'INVALID_ARGUMENT' },
|
|
)
|
|
}
|
|
|
|
/** Assert one required non-empty string argument; return its trimmed value. */
|
|
function requireText(args, name, description) {
|
|
const value = args?.[name]
|
|
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
throw new AcfunError(`${name} must be a non-empty string${description ? ` (${description})` : ''}.`, {
|
|
code: 'INVALID_ARGUMENT',
|
|
})
|
|
}
|
|
return value.trim()
|
|
}
|
|
|
|
/** Assert one positive integer argument with a bound. */
|
|
function requireInteger(args, name, { min = 1, max = Number.MAX_SAFE_INTEGER, fallback } = {}) {
|
|
const raw = args?.[name]
|
|
const value = raw === undefined || raw === null ? fallback : raw
|
|
if (!Number.isInteger(value) || value < min || value > max) {
|
|
throw new AcfunError(`${name} must be an integer between ${min} and ${max}.`, { code: 'INVALID_ARGUMENT' })
|
|
}
|
|
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 }
|
|
}
|
|
|
|
/**
|
|
* Build the seven 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.
|
|
* @returns {object[]} registry-ready ToolDefinition objects.
|
|
*/
|
|
export function createAcfunTools(provider, config) {
|
|
const readTimeout = config.timeoutMs
|
|
const writeTimeout = Math.max(config.timeoutMs, 60_000)
|
|
|
|
const searchTool = {
|
|
name: 'acfun_search',
|
|
description:
|
|
'Search acfun.cn (A站) for videos by keyword. Returns matching videos with acId, title, uploader, play/danmaku/comment counts, duration, and publish date (at most the first 30 results; AcFun ignores paging). sort "views" orders by play count, "relevance" keeps AcFun\'s default order. Follow up with acfun_video for full metadata, acfun_comments/acfun_danmaku for the thread and 弹幕, or acfun_playurl for a stream URL.',
|
|
parameters: objectNode({
|
|
keyword: required(string('The search keyword, for example "原神" or "动画".')),
|
|
sort: string('Result order: "relevance" (default) or "views" (most-viewed first).', {
|
|
enum: [...SEARCH_SORTS],
|
|
}),
|
|
}),
|
|
output: {
|
|
schema: objectNode({
|
|
keyword: required(string('The normalized search keyword.')),
|
|
sort: required(string('The requested result order.')),
|
|
total: required(number('How many results AcFun reports in total.')),
|
|
returned: required(number('How many items are in this response.')),
|
|
items: required(
|
|
arrayOf(
|
|
objectNode({
|
|
acId: required(string('AcFun video id used by the other acfun tools.')),
|
|
title: required(string('Video title.')),
|
|
authorId: required(string('Uploader user id.')),
|
|
authorName: required(string('Uploader display name.')),
|
|
viewCount: required(number('Play count.')),
|
|
danmakuCount: required(number('Danmaku count.')),
|
|
commentCount: required(number('Comment count.')),
|
|
duration: required(string('Duration as m:ss or h:mm:ss.')),
|
|
publishedAt: required(string('ISO-8601 publish time.')),
|
|
coverUrl: required(string('Cover image URL.')),
|
|
url: required(string('Canonical acfun.cn video URL.')),
|
|
}),
|
|
'Matching videos.',
|
|
),
|
|
),
|
|
}),
|
|
render: (_args, value) => text(formatSearch(value)),
|
|
},
|
|
timeoutMs: readTimeout,
|
|
isConcurrencySafe: () => true,
|
|
async execute(args, exec) {
|
|
const keyword = requireText(args, 'keyword', 'the search phrase')
|
|
const sort = typeof args?.sort === 'string' ? args.sort.trim() : 'relevance'
|
|
const result = await provider.search(keyword, { sort, signal: exec?.signal })
|
|
return result
|
|
},
|
|
}
|
|
|
|
const videoTool = {
|
|
name: 'acfun_video',
|
|
description:
|
|
'Read full metadata for one acfun.cn video: title, description, uploader, channel, tags, duration, publish time, play/like/favorite/banana/comment/danmaku counts, cover, and — when a login cookie is configured — whether you already liked/favorited/tipped it. Accepts a numeric acId (42588725), the ac-prefixed form (ac42588725), or a /v/ac... URL.',
|
|
parameters: objectNode({
|
|
acId: required(string('Video identifier: numeric acId, ac-prefixed id, or acfun.cn /v/ac... URL.')),
|
|
}),
|
|
output: {
|
|
schema: objectNode({
|
|
acId: required(string('Bare numeric AcFun video id.')),
|
|
videoId: required(number('Internal video (currentVideoId) id.')),
|
|
title: required(string('Video title.')),
|
|
description: required(string('Video description, empty when absent.')),
|
|
authorId: required(string('Uploader user id.')),
|
|
authorName: required(string('Uploader display name.')),
|
|
authorAvatar: required(string('Uploader avatar URL.')),
|
|
authorSignature: required(string('Uploader signature, empty when absent.')),
|
|
authorFollowers: required(number('Uploader follower count.')),
|
|
channel: required(string('AcFun channel name, for example 动画综合.')),
|
|
channelParent: required(string('Parent channel name, for example 动画.')),
|
|
channelId: required(number('AcFun channel id.')),
|
|
tags: required(
|
|
arrayOf(
|
|
objectNode({
|
|
name: required(string('Tag name.')),
|
|
id: required(string('Tag id.')),
|
|
}),
|
|
'Video tags.',
|
|
),
|
|
),
|
|
durationMillis: required(number('Duration in milliseconds.')),
|
|
duration: required(string('Duration as m:ss or h:mm:ss.')),
|
|
publishedAt: required(string('ISO-8601 publish time.')),
|
|
viewCount: required(number('Play count.')),
|
|
danmakuCount: required(number('Danmaku count.')),
|
|
commentCount: required(number('Comment count.')),
|
|
likeCount: required(number('Like count.')),
|
|
favoriteCount: required(number('Favorite count.')),
|
|
bananaCount: required(number('Banana (投蕉) count.')),
|
|
coverUrl: required(string('Cover image URL.')),
|
|
url: required(string('Canonical acfun.cn video URL.')),
|
|
loggedIn: required(boolean('Whether a login cookie is configured.')),
|
|
liked: required(boolean('Whether the configured account liked this video; false when anonymous.')),
|
|
favorited: required(boolean('Whether the configured account favorited this video; false when anonymous.')),
|
|
bananaGiven: required(boolean('Whether the configured account tipped this video; false when anonymous.')),
|
|
}),
|
|
render: (_args, value) => text(formatVideo(value)),
|
|
},
|
|
timeoutMs: readTimeout,
|
|
isConcurrencySafe: () => true,
|
|
async execute(args, exec) {
|
|
const acId = requireText(args, 'acId', 'numeric id, ac-prefixed id, or video URL')
|
|
return provider.video(acId, { signal: exec?.signal })
|
|
},
|
|
}
|
|
|
|
const commentsTool = {
|
|
name: 'acfun_comments',
|
|
description:
|
|
'Read one page of the comment thread (楼) of an acfun.cn video, in floor order. Returns comment ids, authors, floor numbers, post times, UP-main flags, and plain-text content. Use the returned commentId with acfun_post_comment replies.',
|
|
parameters: objectNode({
|
|
acId: required(string('Video identifier: numeric acId, ac-prefixed id, or acfun.cn /v/ac... URL.')),
|
|
page: number('1-based comment page. Defaults to 1.'),
|
|
}),
|
|
output: {
|
|
schema: objectNode({
|
|
acId: required(string('Bare numeric AcFun video id.')),
|
|
page: required(number('Current comment page.')),
|
|
totalPages: required(number('Total number of comment pages.')),
|
|
totalCount: required(number('Total comments on the video.')),
|
|
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 acfun.cn video URL.')),
|
|
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 with UBB markup stripped.')),
|
|
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 video uploader.')),
|
|
isDeleted: required(boolean('Whether AcFun reports the comment as deleted.')),
|
|
}),
|
|
'Comments in floor order.',
|
|
),
|
|
),
|
|
}),
|
|
render: (_args, value) => text(formatComments(value)),
|
|
},
|
|
timeoutMs: readTimeout,
|
|
isConcurrencySafe: () => true,
|
|
async execute(args, exec) {
|
|
const acId = requireText(args, 'acId', 'numeric id, ac-prefixed id, or video URL')
|
|
const page = requireInteger(args, 'page', { min: 1, max: 500, fallback: 1 })
|
|
return provider.comments(acId, { page, signal: exec?.signal })
|
|
},
|
|
}
|
|
|
|
const danmakuTool = {
|
|
name: 'acfun_danmaku',
|
|
description:
|
|
'Read one batch of the danmaku (弹幕) list of an acfun.cn video, ordered by in-video position. Returns each danmaku body, position (ms), mode, color, author and like count. Pass the returned nextCursor to continue with the next batch.',
|
|
parameters: objectNode({
|
|
acId: required(string('Video identifier: numeric acId, ac-prefixed id, or acfun.cn /v/ac... URL.')),
|
|
limit: number(`How many danmaku to request, 1-${DANMAKU_MAX_COUNT}. Defaults to ${DANMAKU_MAX_COUNT}.`),
|
|
cursor: string('Opaque continuation cursor from a previous call\'s nextCursor. Omit for the first batch.'),
|
|
}),
|
|
output: {
|
|
schema: objectNode({
|
|
acId: required(string('Bare numeric AcFun video id.')),
|
|
videoId: required(number('The part videoId AcFun addresses danmaku by.')),
|
|
title: required(string('Video title.')),
|
|
totalCount: required(number('Total danmaku AcFun reports for the video.')),
|
|
returned: required(number('Danmaku in this batch.')),
|
|
nextCursor: required(string('Cursor for the next batch, empty when there is no more.')),
|
|
items: required(
|
|
arrayOf(
|
|
objectNode({
|
|
danmakuId: required(string('Danmaku id.')),
|
|
body: required(string('Danmaku text.')),
|
|
position: required(number('In-video position in milliseconds.')),
|
|
time: required(string('In-video position as m:ss or h:mm:ss.')),
|
|
color: required(string('Text color as #rrggbb, empty when absent.')),
|
|
mode: required(number('AcFun danmaku mode: 1 scroll, 4 bottom, 5 top, and so on.')),
|
|
userId: required(string('Author user id.')),
|
|
createdAt: required(string('ISO-8601 creation time, empty when absent.')),
|
|
likeCount: required(number('Like count.')),
|
|
}),
|
|
'Danmaku ordered by in-video position.',
|
|
),
|
|
),
|
|
}),
|
|
render: (_args, value) => text(formatDanmaku(value)),
|
|
},
|
|
timeoutMs: readTimeout,
|
|
isConcurrencySafe: () => true,
|
|
async execute(args, exec) {
|
|
const acId = requireText(args, 'acId', 'numeric id, ac-prefixed id, or video URL')
|
|
const limit = requireInteger(args, 'limit', { min: 1, max: DANMAKU_MAX_COUNT, fallback: DANMAKU_MAX_COUNT })
|
|
const cursor = typeof args?.cursor === 'string' ? args.cursor : ''
|
|
return provider.danmaku(acId, { limit, cursor, signal: exec?.signal })
|
|
},
|
|
}
|
|
|
|
const rankTool = {
|
|
name: 'acfun_rank',
|
|
description:
|
|
'Read the AcFun video ranking (排行榜) for one period: day or week. channelId 0 means all channels; pass a channel id to narrow it. Returns ranked videos with title, uploader, channel, duration, and like/comment/danmaku hints.',
|
|
parameters: objectNode({
|
|
period: string('Ranking period: "day" (default) or "week".', { enum: [...RANK_PERIODS] }),
|
|
channelId: number('AcFun channel id, 0 (default) for all channels.'),
|
|
limit: number('How many entries to return, 1-50. Defaults to 10.'),
|
|
}),
|
|
output: {
|
|
schema: objectNode({
|
|
period: required(string('The requested ranking period.')),
|
|
channelId: required(number('The requested channel id.')),
|
|
returned: required(number('How many entries are in this response.')),
|
|
items: required(
|
|
arrayOf(
|
|
objectNode({
|
|
acId: required(string('AcFun video id.')),
|
|
title: required(string('Video title.')),
|
|
description: required(string('Short description, empty when absent.')),
|
|
authorId: required(string('Uploader user id.')),
|
|
authorName: required(string('Uploader display name.')),
|
|
channelName: required(string('AcFun channel name.')),
|
|
coverUrl: required(string('Cover image URL.')),
|
|
duration: required(string('Duration as m:ss or h:mm:ss.')),
|
|
danmakuCount: required(number('Danmaku count.')),
|
|
likeCountText: required(string('Server-formatted like count, for example "253" or "1.2万".')),
|
|
commentCountText: required(string('Server-formatted comment count.')),
|
|
publishedAt: required(string('ISO-8601 publish time.')),
|
|
url: required(string('Canonical acfun.cn video URL.')),
|
|
}),
|
|
'Ranked videos in rank order.',
|
|
),
|
|
),
|
|
}),
|
|
render: (_args, value) => text(formatRank(value)),
|
|
},
|
|
timeoutMs: readTimeout,
|
|
isConcurrencySafe: () => true,
|
|
async execute(args, exec) {
|
|
const period = args?.period === undefined ? 'day' : String(args.period).trim()
|
|
const channelId = requireInteger(args, 'channelId', { min: 0, max: 10_000, fallback: 0 })
|
|
const limit = requireInteger(args, 'limit', { min: 1, max: 50, fallback: 10 })
|
|
return provider.rank({ period, channelId, limit, signal: exec?.signal })
|
|
},
|
|
}
|
|
|
|
const playUrlTool = {
|
|
name: 'acfun_playurl',
|
|
description:
|
|
'Return the playable m3u8 stream URLs of one acfun.cn video across its quality ladder (for example 1080P+, 1080P, 720P, 540P, 360P). Optionally name a quality; otherwise the highest is selected. The URLs are time-limited, so re-run when one expires.',
|
|
parameters: objectNode({
|
|
acId: required(string('Video identifier: numeric acId, ac-prefixed id, or acfun.cn /v/ac... URL.')),
|
|
quality: string('Optional quality label such as "720P" or "1080P+". Defaults to the highest available.'),
|
|
}),
|
|
output: {
|
|
schema: objectNode({
|
|
acId: required(string('Bare numeric AcFun video id.')),
|
|
title: required(string('Video title.')),
|
|
duration: required(string('Duration as m:ss or h:mm:ss.')),
|
|
qualities: required(
|
|
arrayOf(
|
|
objectNode({
|
|
label: required(string('Quality label.')),
|
|
width: required(number('Video width in pixels.')),
|
|
height: required(number('Video height in pixels.')),
|
|
url: required(string('Time-limited m3u8 stream URL.')),
|
|
}),
|
|
'Playable qualities, highest first.',
|
|
),
|
|
),
|
|
chosenLabel: required(string('Label of the selected quality, empty when none applied.')),
|
|
chosenUrl: required(string('URL of the selected quality, empty when none applied.')),
|
|
}),
|
|
render: (_args, value) => text(formatPlayUrls(value)),
|
|
},
|
|
timeoutMs: readTimeout,
|
|
isConcurrencySafe: () => true,
|
|
async execute(args, exec) {
|
|
const acId = requireText(args, 'acId', 'numeric id, ac-prefixed id, or video URL')
|
|
const requested = typeof args?.quality === 'string' ? args.quality : ''
|
|
const result = await provider.playUrls(acId, { signal: exec?.signal })
|
|
const chosen = chooseQuality(result.qualities, requested)
|
|
return {
|
|
acId: result.acId,
|
|
title: result.title,
|
|
duration: result.duration,
|
|
qualities: result.qualities,
|
|
chosenLabel: chosen?.label ?? '',
|
|
chosenUrl: chosen?.url ?? '',
|
|
}
|
|
},
|
|
}
|
|
|
|
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.',
|
|
parameters: objectNode({
|
|
action: required(
|
|
string('Interaction to perform.', {
|
|
enum: ['like', 'unlike', 'banana', 'favorite', 'unfavorite', 'follow', 'unfollow'],
|
|
}),
|
|
),
|
|
acId: string('Video identifier for like/unlike/banana/favorite/unfavorite.'),
|
|
userId: string('Target user id for follow/unfollow.'),
|
|
count: number('Bananas to tip for action "banana", 1-10. Defaults to 1.'),
|
|
folderId: string('Optional favorite folder id for action "favorite".'),
|
|
}),
|
|
output: {
|
|
schema: objectNode({
|
|
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.')),
|
|
}),
|
|
render: (_args, value) => text(formatInteract(value)),
|
|
},
|
|
timeoutMs: writeTimeout,
|
|
isConcurrencySafe: () => false,
|
|
async execute(args, exec) {
|
|
const action = requireText(args, 'action')
|
|
const signal = exec?.signal
|
|
switch (action) {
|
|
case 'like':
|
|
case 'unlike': {
|
|
const acId = requireText(args, 'acId', 'required for like/unlike')
|
|
const outcome = await provider.setLike(acId, action === 'like', { signal })
|
|
return interactOutput(action, outcome.message, acId)
|
|
}
|
|
case 'banana': {
|
|
const acId = requireText(args, 'acId', 'required for banana')
|
|
const count = requireInteger(args, 'count', { min: 1, max: 10, fallback: 1 })
|
|
const outcome = await provider.throwBanana(acId, count, { signal })
|
|
return interactOutput(action, outcome.message, acId)
|
|
}
|
|
case 'favorite':
|
|
case 'unfavorite': {
|
|
const acId = requireText(args, 'acId', 'required for favorite/unfavorite')
|
|
const folderId = typeof args?.folderId === 'string' ? args.folderId : undefined
|
|
const outcome = await provider.setFavorite(acId, action === 'favorite', { folderId, signal })
|
|
return interactOutput(action, outcome.message, acId)
|
|
}
|
|
case 'follow':
|
|
case 'unfollow': {
|
|
const userId = requireText(args, 'userId', 'required for follow/unfollow')
|
|
const outcome = await provider.setFollow(userId, action === 'follow', { signal })
|
|
return interactOutput(action, outcome.message, '', userId)
|
|
}
|
|
default:
|
|
throw new AcfunError(`Unknown acfun_interact action: ${action}.`, { code: 'INVALID_ARGUMENT' })
|
|
}
|
|
},
|
|
}
|
|
|
|
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.',
|
|
parameters: objectNode({
|
|
acId: required(string('Video identifier: numeric acId, ac-prefixed id, or acfun.cn /v/ac... URL.')),
|
|
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.')),
|
|
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.')),
|
|
}),
|
|
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 outcome = await provider.postComment(acId, content, { replyToCommentId, signal: exec?.signal })
|
|
const normalized = normalizeAcId(acId)
|
|
return {
|
|
ok: outcome.ok,
|
|
acId: normalized,
|
|
commentId: outcome.commentId,
|
|
message: outcome.message,
|
|
url: acfunVideoUrl(normalized),
|
|
}
|
|
},
|
|
}
|
|
|
|
return [searchTool, videoTool, commentsTool, danmakuTool, rankTool, playUrlTool, interactTool, commentTool]
|
|
}
|