feat: AcFun tools plugin for DeepSeek Harness
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.
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Plugin config resolution.
|
||||
*
|
||||
* The plugin deliberately uses no runtime imports (no schemastery, no
|
||||
* dsh-tools): a workspace-linked bundle resolves bare `@deepseek-ai/*`
|
||||
* specifiers from its own directory, so it cannot see the dsh installation's
|
||||
* packages. Everything here is plain ESM so `node scripts/live-check.mjs` can
|
||||
* exercise it before the bundle is installed.
|
||||
*/
|
||||
|
||||
/** Environment variable read when `cookie` is empty. */
|
||||
export const DEFAULT_COOKIE_ENV = 'ACFUN_COOKIE'
|
||||
|
||||
/** Per-request timeout in milliseconds. */
|
||||
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000
|
||||
|
||||
/** Cooperative per-tool-call budget in milliseconds. */
|
||||
export const DEFAULT_TOOL_TIMEOUT_MS = 45_000
|
||||
|
||||
/** Cooperative budget for a write call (login + token + action). */
|
||||
export const DEFAULT_WRITE_TIMEOUT_MS = 60_000
|
||||
|
||||
/** Largest list size the rank tool accepts (server caps around this). */
|
||||
export const RANK_MAX_LIMIT = 50
|
||||
|
||||
const DEFAULTS = Object.freeze({
|
||||
cookie: '',
|
||||
cookieEnv: DEFAULT_COOKIE_ENV,
|
||||
allowWriteActions: false,
|
||||
requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
timeoutMs: DEFAULT_TOOL_TIMEOUT_MS,
|
||||
})
|
||||
|
||||
/** A non-empty trimmed string, or undefined. */
|
||||
function text(value) {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined
|
||||
}
|
||||
|
||||
/** A positive finite number, or undefined. */
|
||||
function positive(value) {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the row config into a plain settings object.
|
||||
*
|
||||
* Unknown keys are ignored on purpose: the profile patch may carry comments or
|
||||
* keys from an older version, and a stale key must not block activation.
|
||||
*
|
||||
* @param {unknown} raw - the Loader row `config` object, possibly undefined.
|
||||
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env] - environment for `cookieEnv`.
|
||||
* @returns {{cookie: string, cookieEnv: string, allowWriteActions: boolean, requestTimeoutMs: number, timeoutMs: number, warnings: string[]}}
|
||||
*/
|
||||
export function resolveConfig(raw, env = process.env) {
|
||||
const source = raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}
|
||||
const warnings = []
|
||||
|
||||
if (raw !== undefined && (raw === null || typeof raw !== 'object' || Array.isArray(raw))) {
|
||||
warnings.push('config must be an object; using defaults')
|
||||
}
|
||||
|
||||
const cookieEnv = text(source.cookieEnv) ?? DEFAULT_COOKIE_ENV
|
||||
const literalCookie = text(source.cookie)
|
||||
const envCookie = text(env?.[cookieEnv])
|
||||
const cookie = literalCookie ?? envCookie ?? ''
|
||||
|
||||
if (source.cookie !== undefined && source.cookie !== '' && literalCookie === undefined) {
|
||||
warnings.push('config.cookie is set but is not a non-empty string; ignoring it')
|
||||
}
|
||||
if (typeof source.allowWriteActions !== 'undefined' && typeof source.allowWriteActions !== 'boolean') {
|
||||
warnings.push('config.allowWriteActions is not a boolean; using false')
|
||||
}
|
||||
|
||||
return {
|
||||
cookie,
|
||||
cookieEnv,
|
||||
cookieSource: literalCookie !== undefined ? 'config' : envCookie !== undefined ? 'env' : 'none',
|
||||
allowWriteActions: source.allowWriteActions === true,
|
||||
requestTimeoutMs: positive(source.requestTimeoutMs) ?? DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
timeoutMs: positive(source.timeoutMs) ?? DEFAULT_TOOL_TIMEOUT_MS,
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
+1076
File diff suppressed because it is too large
Load Diff
+649
@@ -0,0 +1,649 @@
|
||||
/**
|
||||
* 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]
|
||||
}
|
||||
Reference in New Issue
Block a user