Initial commit: import Operit protocol plugins from xiaoli-cli
This commit is contained in:
commit
c6600fc47d
52 files changed
+31492
No files matched your search
@@ -0,0 +1,768 @@
|
||||
/* METADATA
|
||||
{
|
||||
"name": "bilibili_tools",
|
||||
"display_name": {
|
||||
"zh": "Bilibili 工具集",
|
||||
"en": "Bilibili Toolkit"
|
||||
},
|
||||
"description": { "zh": "提供B站视频信息分析功能,包括获取字幕、弹幕、评论和搜索视频。", "en": "Analyze Bilibili video information, including subtitles, danmaku, comments, and video search." },
|
||||
"enabledByDefault": true,
|
||||
"category": "Media",
|
||||
"tools": [
|
||||
{
|
||||
"name": "get_subtitles",
|
||||
"description": { "zh": "从Bilibili视频中获取字幕。", "en": "Get subtitles from a Bilibili video." },
|
||||
"parameters": [
|
||||
{ "name": "url", "description": { "zh": "Bilibili视频URL,例如:https://www.bilibili.com/video/BV1x341177NN", "en": "Bilibili video URL, e.g. https://www.bilibili.com/video/BV1x341177NN" }, "type": "string", "required": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "get_danmaku",
|
||||
"description": { "zh": "从Bilibili视频中获取弹幕。", "en": "Get danmaku (bullet comments) from a Bilibili video." },
|
||||
"parameters": [
|
||||
{ "name": "url", "description": { "zh": "Bilibili视频URL,例如:https://www.bilibili.com/video/BV1x341177NN", "en": "Bilibili video URL, e.g. https://www.bilibili.com/video/BV1x341177NN" }, "type": "string", "required": true },
|
||||
{ "name": "count", "description": { "zh": "要获取的弹幕数量,默认500,最多1000", "en": "Number of danmaku items to fetch (default: 500, max: 1000)." }, "type": "number", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "get_comments",
|
||||
"description": { "zh": "从Bilibili视频中获取热门评论。", "en": "Get hot comments from a Bilibili video." },
|
||||
"parameters": [
|
||||
{ "name": "url", "description": { "zh": "Bilibili视频URL,例如:https://www.bilibili.com/video/BV1x341177NN", "en": "Bilibili video URL, e.g. https://www.bilibili.com/video/BV1x341177NN" }, "type": "string", "required": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "search_videos",
|
||||
"description": { "zh": "在Bilibili上搜索视频。", "en": "Search videos on Bilibili." },
|
||||
"parameters": [
|
||||
{ "name": "keyword", "description": { "zh": "要搜索的关键词", "en": "Keyword to search for." }, "type": "string", "required": true },
|
||||
{ "name": "page", "description": { "zh": "页码,默认为1", "en": "Page number (default: 1)." }, "type": "number", "required": false },
|
||||
{ "name": "count", "description": { "zh": "返回结果的数量,默认10,最多20", "en": "Number of results to return (default: 10, max: 20)." }, "type": "number", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "get_user_info",
|
||||
"description": { "zh": "获取B站用户的基本信息。", "en": "Get basic information for a Bilibili user." },
|
||||
"parameters": [
|
||||
{ "name": "mid", "description": { "zh": "用户的数字ID (UID)", "en": "User numeric ID (UID)." }, "type": "number", "required": true }
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
//参考了https://github.com/vruses/bili-api-interceptor/blob/master/dist/bili-api-interceptor.user.js 特此感谢
|
||||
|
||||
const BilibiliVideoAnalysis = (function () {
|
||||
|
||||
interface ToolResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
interface Subtitle {
|
||||
lan: string;
|
||||
content: string[];
|
||||
}
|
||||
|
||||
interface Comment {
|
||||
user: string;
|
||||
content: string;
|
||||
likes: number;
|
||||
time: string; // 添加评论时间
|
||||
sub_comments: Comment[]; // 添加子评论列表
|
||||
}
|
||||
|
||||
interface SearchResultVideo {
|
||||
title: string;
|
||||
author: string;
|
||||
bvid: string;
|
||||
play: number;
|
||||
danmaku: number;
|
||||
like: number;
|
||||
duration: string;
|
||||
pubdate: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface UserInfo {
|
||||
name: string;
|
||||
sex: string;
|
||||
face: string;
|
||||
sign: string;
|
||||
level: number;
|
||||
birthday: string;
|
||||
follower: number;
|
||||
following: number;
|
||||
mid: number;
|
||||
}
|
||||
|
||||
// Bilibili API endpoints
|
||||
const API_GET_VIEW_INFO = "https://api.bilibili.com/x/web-interface/view";
|
||||
const API_GET_SUBTITLE_LIST = "https://api.bilibili.com/x/player/v2";
|
||||
const API_GET_DANMAKU = "https://api.bilibili.com/x/v1/dm/list.so";
|
||||
const API_GET_COMMENTS = "https://api.bilibili.com/x/v2/reply/wbi/main";
|
||||
const API_SEARCH = "https://api.bilibili.com/x/web-interface/search/all/v2";
|
||||
const API_NAV = "https://api.bilibili.com/x/web-interface/nav";
|
||||
const API_GET_USER_INFO = "https://api.bilibili.com/x/space/wbi/acc/info";
|
||||
const API_GET_RELATION_STAT = "https://api.bilibili.com/x/relation/stat";
|
||||
|
||||
const client = OkHttp.newClient();
|
||||
let SESSDATA: string | undefined;
|
||||
let wbiKeys: { img_key: string; sub_key: string; } | null = null;
|
||||
let buvid3: string | null = null;
|
||||
|
||||
function generateBuvid3(): string {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (SESSDATA === undefined) {
|
||||
// SESSDATA 是B站的Cookie,用于访问一些需要登录的API。
|
||||
// 为了简单起见,这里我们不作强制要求。
|
||||
// 如果遇到请求失败的情况,可以尝试手动在这里设置你的SESSDATA值。
|
||||
// 例如: SESSDATA = "your_sessdata_cookie_value";
|
||||
SESSDATA = "";
|
||||
if (!SESSDATA) {
|
||||
console.log("SESSDATA 未设置,部分需要登录的请求可能会失败。");
|
||||
}
|
||||
}
|
||||
// Pre-fetch WBI keys if not already available.
|
||||
if (!wbiKeys) {
|
||||
wbiKeys = await get_wbi_keys();
|
||||
}
|
||||
}
|
||||
|
||||
function getHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36',
|
||||
'Referer': 'https://www.bilibili.com/'
|
||||
};
|
||||
|
||||
if (!buvid3) {
|
||||
buvid3 = generateBuvid3();
|
||||
}
|
||||
|
||||
const cookies = [`buvid3=${buvid3}`];
|
||||
if (SESSDATA) {
|
||||
cookies.push(`SESSDATA=${SESSDATA}`);
|
||||
}
|
||||
headers['Cookie'] = cookies.join('; ');
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function unescapeXml(text: string): string {
|
||||
return text.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
const mixinKeyEncTab = [
|
||||
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35,
|
||||
27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13,
|
||||
37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4,
|
||||
22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52
|
||||
];
|
||||
|
||||
function getMixinKey(orig: string): string {
|
||||
return mixinKeyEncTab.map(n => orig[n]).join('').slice(0, 32);
|
||||
}
|
||||
|
||||
function encWbi(params: Record<string, any>, img_key: string, sub_key: string): string {
|
||||
const mixin_key = getMixinKey(img_key + sub_key);
|
||||
const curr_time = Math.round(Date.now() / 1000);
|
||||
const chr_filter = /[!'()*]/g;
|
||||
|
||||
const new_params = { ...params, wts: curr_time };
|
||||
|
||||
const query = Object.keys(new_params).sort().map(key => {
|
||||
const value = String(new_params[key]).replace(chr_filter, "");
|
||||
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
|
||||
}).join('&');
|
||||
|
||||
const w_rid = CryptoJS.MD5(query + mixin_key).toString();
|
||||
return `${query}&w_rid=${w_rid}`;
|
||||
}
|
||||
|
||||
async function get_wbi_keys(): Promise<{ img_key: string, sub_key: string } | null> {
|
||||
try {
|
||||
const response = await client.get(API_NAV, getHeaders());
|
||||
if (!response.isSuccessful()) {
|
||||
console.error("Failed to get WBI keys, status: " + response.statusCode);
|
||||
return null;
|
||||
}
|
||||
const navData = response.json();
|
||||
|
||||
// The API may return a non-zero code for non-logged-in users, but still provide the WBI keys.
|
||||
if (!navData.data?.wbi_img?.img_url || !navData.data?.wbi_img?.sub_url) {
|
||||
console.error("Failed to get WBI keys: " + (navData.message || "No wbi_img in response"));
|
||||
return null;
|
||||
}
|
||||
|
||||
const imgUrl = navData.data.wbi_img.img_url;
|
||||
const subUrl = navData.data.wbi_img.sub_url;
|
||||
|
||||
const img_key = imgUrl.substring(imgUrl.lastIndexOf('/') + 1, imgUrl.lastIndexOf('.'));
|
||||
const sub_key = subUrl.substring(subUrl.lastIndexOf('/') + 1, subUrl.lastIndexOf('.'));
|
||||
|
||||
return { img_key, sub_key };
|
||||
} catch (e: any) {
|
||||
console.error(`Error fetching WBI keys: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function extract_bvid(url: string): Promise<string | null> {
|
||||
let match = url.match(/BV[a-zA-Z0-9_]+/);
|
||||
if (match) {
|
||||
return match[0];
|
||||
}
|
||||
|
||||
if (url.includes('b23.tv')) {
|
||||
try {
|
||||
const request = client.newRequest().url(url).method("HEAD").build();
|
||||
const response = await request.execute();
|
||||
const finalUrl = response.raw.url;
|
||||
if (finalUrl) {
|
||||
match = finalUrl.match(/BV[a-zA-Z0-9_]+/);
|
||||
if (match) {
|
||||
return match[0];
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error resolving short URL: ${e}`);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function get_video_basic_info(bvid: string): Promise<{ aid: number | null, cid: number | null, error: string | null }> {
|
||||
try {
|
||||
const url = `${API_GET_VIEW_INFO}?bvid=${bvid}`;
|
||||
const response = await client.get(url, getHeaders());
|
||||
if (!response.isSuccessful()) {
|
||||
return { aid: null, cid: null, error: `Failed to get video info, status: ${response.statusCode}` };
|
||||
}
|
||||
const data = response.json();
|
||||
if (data.code !== 0) {
|
||||
return { aid: null, cid: null, error: `Failed to get video info: ${data.message}` };
|
||||
}
|
||||
const videoData = data.data;
|
||||
return { aid: videoData.aid, cid: videoData.cid, error: null };
|
||||
} catch (e: any) {
|
||||
return { aid: null, cid: null, error: `Failed to fetch video details: ${e.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
async function get_subtitles_from_api(aid: number, cid: number): Promise<{ subtitles: Subtitle[], error: string | null }> {
|
||||
const subtitles: Subtitle[] = [];
|
||||
try {
|
||||
const url = `${API_GET_SUBTITLE_LIST}?aid=${aid}&cid=${cid}`;
|
||||
const response = await client.get(url, getHeaders());
|
||||
if (!response.isSuccessful()) {
|
||||
return { subtitles: [], error: `Could not fetch subtitles list, status: ${response.statusCode}` };
|
||||
}
|
||||
const subtitleListData = response.json();
|
||||
if (subtitleListData.code === 0 && subtitleListData.data?.subtitle?.subtitles) {
|
||||
for (const sub_meta of subtitleListData.data.subtitle.subtitles) {
|
||||
if (sub_meta.subtitle_url) {
|
||||
try {
|
||||
const subtitle_json_url = `https:${sub_meta.subtitle_url}`;
|
||||
const sub_content_response = await client.get(subtitle_json_url, getHeaders());
|
||||
if (sub_content_response.isSuccessful()) {
|
||||
const sub_content = await sub_content_response.json();
|
||||
const subtitle_body = sub_content.body || [];
|
||||
const content_list = subtitle_body.map((item: any) => item.content || '');
|
||||
subtitles.push({
|
||||
lan: sub_meta.lan,
|
||||
content: content_list
|
||||
});
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(`Could not fetch or parse subtitle content from ${sub_meta.subtitle_url}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { subtitles, error: null };
|
||||
} catch (e: any) {
|
||||
return { subtitles: [], error: `Could not fetch subtitles: ${e.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
async function get_danmaku_from_api(cid: number, count: number): Promise<{ danmaku: string[], error: string | null }> {
|
||||
const danmaku_list: string[] = [];
|
||||
try {
|
||||
const url = `${API_GET_DANMAKU}?oid=${cid}`;
|
||||
const response = await client.get(url, getHeaders());
|
||||
|
||||
if (!response.isSuccessful()) {
|
||||
return { danmaku: [], error: `Failed to get danmaku, status: ${response.statusCode}` };
|
||||
}
|
||||
|
||||
const compressedData = response.bodyAsBase64();
|
||||
console.log("base64" + compressedData);
|
||||
if (!compressedData) {
|
||||
return { danmaku: [], error: null };
|
||||
}
|
||||
|
||||
const danmaku_content = pako.inflate(compressedData, { to: 'string' });
|
||||
|
||||
const regex = /<d p=".*?">(.*?)<\/d>/g;
|
||||
let match;
|
||||
while ((match = regex.exec(danmaku_content)) !== null) {
|
||||
if (danmaku_list.length >= count) {
|
||||
break;
|
||||
}
|
||||
danmaku_list.push(unescapeXml(match[1]));
|
||||
}
|
||||
|
||||
return { danmaku: danmaku_list, error: null };
|
||||
} catch (e: any) {
|
||||
return { danmaku: [], error: `Failed to get or parse danmaku: ${e.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
async function get_comments_from_api(aid: number): Promise<{ comments: string, count: number, error: string | null }> {
|
||||
const all_comments: Comment[] = [];
|
||||
|
||||
if (!wbiKeys) {
|
||||
return { comments: "", count: 0, error: "获取 WBI keys 失败,无法请求评论 API" };
|
||||
}
|
||||
|
||||
let page_num = 1;
|
||||
let total_pages = 1;
|
||||
|
||||
try {
|
||||
while (page_num <= total_pages) {
|
||||
const params = {
|
||||
type: 1,
|
||||
oid: aid,
|
||||
sort: 2, // sort=2 for hot
|
||||
pn: page_num
|
||||
};
|
||||
|
||||
const signed_query = encWbi(params, wbiKeys.img_key, wbiKeys.sub_key);
|
||||
const url = `${API_GET_COMMENTS}?${signed_query}`;
|
||||
|
||||
const response = await client.get(url, getHeaders());
|
||||
|
||||
if (!response.isSuccessful()) {
|
||||
console.error(`Failed to get comments page ${page_num}, status: ${response.statusCode}`);
|
||||
page_num++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const comments_data = response.json();
|
||||
|
||||
if (comments_data.code !== 0) {
|
||||
console.error(`API error on page ${page_num}: ${comments_data.message}`);
|
||||
page_num++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (page_num === 1 && comments_data.data?.page) {
|
||||
total_pages = Math.ceil(comments_data.data.page.count / comments_data.data.page.size);
|
||||
}
|
||||
|
||||
if (comments_data.data?.replies) {
|
||||
for (const comment of comments_data.data.replies) {
|
||||
const formatted_comment = format_comment(comment);
|
||||
if (formatted_comment) {
|
||||
all_comments.push(formatted_comment);
|
||||
}
|
||||
}
|
||||
}
|
||||
page_num++;
|
||||
}
|
||||
const comment_summary = format_comments_to_string(all_comments);
|
||||
return { comments: comment_summary, count: all_comments.length, error: null };
|
||||
} catch (e: any) {
|
||||
const comment_summary = format_comments_to_string(all_comments);
|
||||
return { comments: comment_summary, count: all_comments.length, error: `Failed to get comments: ${e.message}` }; // 返回已获取的部分数据
|
||||
}
|
||||
}
|
||||
|
||||
function format_comment(comment_item: any): Comment {
|
||||
const sub_comments = (comment_item.replies || []).map(format_comment).filter(Boolean);
|
||||
return {
|
||||
user: comment_item.member.uname,
|
||||
content: comment_item.content.message,
|
||||
likes: comment_item.like || 0,
|
||||
time: new Date(comment_item.ctime * 1000).toLocaleDateString(),
|
||||
sub_comments: sub_comments
|
||||
};
|
||||
}
|
||||
|
||||
function format_comments_to_string(comments: Comment[]): string {
|
||||
const result: string[] = [];
|
||||
function format_single(comment: Comment, indent: string) {
|
||||
result.push(`${indent}- ${comment.user} (👍${comment.likes}) [${comment.time}]: ${comment.content}`);
|
||||
if (comment.sub_comments && comment.sub_comments.length > 0) {
|
||||
for (const sub of comment.sub_comments) {
|
||||
format_single(sub, indent + " ");
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const comment of comments) {
|
||||
format_single(comment, "");
|
||||
}
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
|
||||
async function get_subtitles(params: { url: string }): Promise<ToolResponse> {
|
||||
await init();
|
||||
const bvid = await extract_bvid(params.url);
|
||||
if (!bvid) {
|
||||
return { success: false, message: `错误: 无法从 URL 提取 BV 号: ${params.url}` };
|
||||
}
|
||||
|
||||
const { aid, cid, error: infoError } = await get_video_basic_info(bvid);
|
||||
if (infoError) {
|
||||
return { success: false, message: `获取视频信息失败: ${infoError}` };
|
||||
}
|
||||
if (!aid || !cid) {
|
||||
return { success: false, message: '获取视频 aid 或 cid 失败' };
|
||||
}
|
||||
|
||||
const { subtitles, error: subtitlesError } = await get_subtitles_from_api(aid, cid);
|
||||
if (subtitlesError) {
|
||||
return { success: false, message: `获取字幕失败: ${subtitlesError}` };
|
||||
}
|
||||
|
||||
if (subtitles.length === 0) {
|
||||
return { success: true, message: "该视频没有字幕", data: [] };
|
||||
}
|
||||
|
||||
return { success: true, message: `成功获取 ${subtitles.length} 门语言的字幕`, data: subtitles };
|
||||
}
|
||||
|
||||
async function get_danmaku(params: { url: string, count?: number }): Promise<ToolResponse> {
|
||||
await init();
|
||||
const { url, count = 500 } = params;
|
||||
const bvid = await extract_bvid(url);
|
||||
if (!bvid) {
|
||||
return { success: false, message: `错误: 无法从 URL 提取 BV 号: ${url}` };
|
||||
}
|
||||
|
||||
if (count > 1000) {
|
||||
return { success: false, message: "参数 'count' 不能超过 1000" };
|
||||
}
|
||||
|
||||
const { aid, cid, error: infoError } = await get_video_basic_info(bvid);
|
||||
if (infoError) {
|
||||
return { success: false, message: `获取视频信息失败: ${infoError}` };
|
||||
}
|
||||
if (!cid) {
|
||||
return { success: false, message: '获取视频 cid 失败' };
|
||||
}
|
||||
|
||||
const { danmaku, error: danmakuError } = await get_danmaku_from_api(cid, count);
|
||||
if (danmakuError) {
|
||||
return { success: false, message: `获取弹幕失败: ${danmakuError}` };
|
||||
}
|
||||
|
||||
if (danmaku.length === 0) {
|
||||
return { success: true, message: "该视频没有弹幕", data: [] };
|
||||
}
|
||||
|
||||
return { success: true, message: `成功获取 ${danmaku.length} 条弹幕`, data: danmaku };
|
||||
}
|
||||
|
||||
async function get_comments(params: { url: string }): Promise<ToolResponse> {
|
||||
await init();
|
||||
const bvid = await extract_bvid(params.url);
|
||||
if (!bvid) {
|
||||
return { success: false, message: `错误: 无法从 URL 提取 BV 号: ${params.url}` };
|
||||
}
|
||||
|
||||
const { aid, error: infoError } = await get_video_basic_info(bvid);
|
||||
if (infoError) {
|
||||
return { success: false, message: `获取视频信息失败: ${infoError}` };
|
||||
}
|
||||
if (!aid) {
|
||||
return { success: false, message: '获取视频 aid 失败' };
|
||||
}
|
||||
|
||||
const { comments, count, error: commentsError } = await get_comments_from_api(aid);
|
||||
|
||||
if (commentsError && count === 0) {
|
||||
return { success: false, message: `获取评论失败: ${commentsError}` };
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
return { success: true, message: "该视频没有热门评论", data: "" };
|
||||
}
|
||||
|
||||
const message = commentsError
|
||||
? `成功获取 ${count} 条热门评论,但过程中发生错误: ${commentsError}`
|
||||
: `成功获取 ${count} 条热门评论`;
|
||||
|
||||
return { success: true, message: message, data: comments };
|
||||
}
|
||||
|
||||
async function search_videos_from_api(keyword: string, page: number): Promise<{ results: any, error: string | null }> {
|
||||
if (!wbiKeys) {
|
||||
return { results: null, error: "获取 WBI keys 失败,无法请求搜索 API" };
|
||||
}
|
||||
|
||||
try {
|
||||
const params = {
|
||||
keyword: keyword,
|
||||
page: page,
|
||||
search_type: "video"
|
||||
};
|
||||
|
||||
const signed_query = encWbi(params, wbiKeys.img_key, wbiKeys.sub_key);
|
||||
const url = `${API_SEARCH}?${signed_query}`;
|
||||
|
||||
const response = await client.get(url, getHeaders());
|
||||
|
||||
if (!response.isSuccessful()) {
|
||||
return { results: null, error: `搜索失败, status: ${response.statusCode}` };
|
||||
}
|
||||
|
||||
const search_data = response.json();
|
||||
if (search_data.code !== 0) {
|
||||
return { results: null, error: `搜索 API 错误: ${search_data.message}` };
|
||||
}
|
||||
|
||||
return { results: search_data.data, error: null };
|
||||
} catch (e: any) {
|
||||
return { results: null, error: `搜索时发生异常: ${e.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
function format_search_results_to_string(data: any, count: number): { formatted: string, result_count: number, total_count: number } {
|
||||
if (!data.result) {
|
||||
return { formatted: "没有找到相关结果。", result_count: 0, total_count: 0 };
|
||||
}
|
||||
|
||||
const videoResults = data.result
|
||||
.find((item: any) => item.result_type === "video")
|
||||
?.data;
|
||||
|
||||
if (!videoResults || videoResults.length === 0) {
|
||||
return { formatted: "没有找到相关视频结果。", result_count: 0, total_count: data.numResults || 0 };
|
||||
}
|
||||
|
||||
const slicedResults = videoResults.slice(0, count);
|
||||
|
||||
const formattedResults = slicedResults
|
||||
.map((video: SearchResultVideo, index: number) => {
|
||||
const cleanTitle = video.title.replace(/<em class="keyword">|<\/em>/g, "");
|
||||
const cleanDescription = video.description.replace(/<em class="keyword">|<\/em>/g, "");
|
||||
return [
|
||||
`${index + 1}. "${cleanTitle}" - ${video.author}`,
|
||||
` BV ID: ${video.bvid}`,
|
||||
` 播放: ${video.play?.toLocaleString()}`,
|
||||
` 弹幕: ${video.danmaku?.toLocaleString()}`,
|
||||
` 点赞: ${video.like?.toLocaleString()}`,
|
||||
` 时长: ${video.duration}`,
|
||||
` 发布于: ${new Date(video.pubdate * 1000).toLocaleDateString()}`,
|
||||
` 简介: ${cleanDescription?.substring(0, 100)}${cleanDescription?.length > 100 ? "..." : ""}`,
|
||||
].join("\n");
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
return { formatted: formattedResults, result_count: slicedResults.length, total_count: data.numResults || 0 };
|
||||
}
|
||||
|
||||
|
||||
async function search_videos(params: { keyword: string, page?: number, count?: number }): Promise<ToolResponse> {
|
||||
await init();
|
||||
const { keyword, page = 1, count = 10 } = params;
|
||||
|
||||
if (count > 20) {
|
||||
return { success: false, message: "参数 'count' 不能超过 20" };
|
||||
}
|
||||
|
||||
const { results, error } = await search_videos_from_api(keyword, page);
|
||||
if (error) {
|
||||
return { success: false, message: `搜索失败: ${error}` };
|
||||
}
|
||||
|
||||
const { formatted, result_count, total_count } = format_search_results_to_string(results, count);
|
||||
|
||||
if (result_count === 0) {
|
||||
return { success: true, message: "没有找到相关视频。", data: "" };
|
||||
}
|
||||
|
||||
const message = `成功为 "${keyword}" 找到 ${total_count} 个相关视频,当前显示第 ${page} 页的 ${result_count} 个结果。`;
|
||||
return { success: true, message, data: formatted };
|
||||
}
|
||||
|
||||
function format_user_info_to_string(user: UserInfo): string {
|
||||
return [
|
||||
`昵称: ${user.name} (UID: ${user.mid})`,
|
||||
`性别: ${user.sex}`,
|
||||
`等级: LV${user.level}`,
|
||||
`生日: ${user.birthday || '未设置'}`,
|
||||
`粉丝数: ${user.follower.toLocaleString()}`,
|
||||
`关注数: ${user.following.toLocaleString()}`,
|
||||
`个人简介: ${user.sign || '这个UP主很懒,什么都没有写...'}`,
|
||||
`头像链接: ${user.face}`
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function get_user_info_from_api(mid: number): Promise<{ user_info: UserInfo | null, error: string | null }> {
|
||||
if (!wbiKeys) {
|
||||
return { user_info: null, error: "获取 WBI keys 失败,无法请求用户信息 API" };
|
||||
}
|
||||
|
||||
try {
|
||||
// Get user account info (WBI signed)
|
||||
const params = { mid };
|
||||
const signed_query = encWbi(params, wbiKeys.img_key, wbiKeys.sub_key);
|
||||
const url = `${API_GET_USER_INFO}?${signed_query}`;
|
||||
const response = await client.get(url, getHeaders());
|
||||
|
||||
if (!response.isSuccessful()) {
|
||||
return { user_info: null, error: `获取用户信息失败, status: ${response.statusCode}` };
|
||||
}
|
||||
|
||||
const userData = response.json();
|
||||
if (userData.code !== 0) {
|
||||
return { user_info: null, error: `用户信息 API 错误: ${userData.message}` };
|
||||
}
|
||||
|
||||
// Get user relation stat (not WBI signed)
|
||||
const statUrl = `${API_GET_RELATION_STAT}?vmid=${mid}`;
|
||||
const statResponse = await client.get(statUrl, getHeaders());
|
||||
let follower = -1, following = -1;
|
||||
if (statResponse.isSuccessful()) {
|
||||
const statData = statResponse.json();
|
||||
if (statData.code === 0) {
|
||||
follower = statData.data.follower;
|
||||
following = statData.data.following;
|
||||
}
|
||||
}
|
||||
|
||||
const userInfo: UserInfo = {
|
||||
mid: userData.data.mid,
|
||||
name: userData.data.name,
|
||||
sex: userData.data.sex,
|
||||
face: userData.data.face,
|
||||
sign: userData.data.sign,
|
||||
level: userData.data.level,
|
||||
birthday: userData.data.birthday,
|
||||
follower: follower,
|
||||
following: following
|
||||
};
|
||||
|
||||
return { user_info: userInfo, error: null };
|
||||
} catch (e: any) {
|
||||
return { user_info: null, error: `获取用户信息时发生异常: ${e.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
async function get_user_info(params: { mid: number }): Promise<ToolResponse> {
|
||||
await init();
|
||||
const { mid } = params;
|
||||
|
||||
if (!mid || typeof mid !== 'number' || mid <= 0) {
|
||||
return { success: false, message: "参数 'mid' 必须是一个正整数" };
|
||||
}
|
||||
|
||||
const { user_info, error } = await get_user_info_from_api(mid);
|
||||
if (error) {
|
||||
return { success: false, message: `获取用户信息失败: ${error}` };
|
||||
}
|
||||
|
||||
if (!user_info) {
|
||||
return { success: false, message: "未能获取到用户信息" };
|
||||
}
|
||||
|
||||
const formatted_info = format_user_info_to_string(user_info);
|
||||
const message = `成功获取 UID:${mid} 的用户信息。`;
|
||||
|
||||
return { success: true, message, data: formatted_info };
|
||||
}
|
||||
|
||||
async function wrapToolExecution<P>(func: (params: P) => Promise<ToolResponse>, params: P) {
|
||||
try {
|
||||
const result = await func(params);
|
||||
complete(result);
|
||||
} catch (error: any) {
|
||||
console.error(`Tool ${func.name} failed unexpectedly`, error);
|
||||
complete({
|
||||
success: false,
|
||||
message: `工具执行时发生意外错误: ${error.message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("--- Bilibili Video Analysis Tool Test ---");
|
||||
const testUrl = "https://www.bilibili.com/video/BV1NRE5zmE3W";
|
||||
const testKeyword = "OpenAI";
|
||||
|
||||
console.log("\n[1/4] Testing search_videos...");
|
||||
const searchResult = await search_videos({ keyword: testKeyword, count: 10 });
|
||||
if (searchResult.success) {
|
||||
console.log(`Success: ${searchResult.message}`);
|
||||
console.log(`---------- Search Results for "${testKeyword}" ----------`);
|
||||
console.log(searchResult.data);
|
||||
console.log("------------------------------------------");
|
||||
} else {
|
||||
console.error(`Failure: ${searchResult.message}`);
|
||||
}
|
||||
|
||||
console.log("\n[2/4] Testing get_subtitles...");
|
||||
const subtitlesResult = await get_subtitles({ url: testUrl });
|
||||
console.log(JSON.stringify(subtitlesResult, null, 2));
|
||||
|
||||
console.log("\n[3/4] Testing get_danmaku with count limit...");
|
||||
const danmakuResult = await get_danmaku({ url: testUrl, count: 20 });
|
||||
console.log(JSON.stringify(danmakuResult, null, 2));
|
||||
|
||||
console.log("\n[4/4] Testing get_comments...");
|
||||
const commentsResult = await get_comments({ url: testUrl });
|
||||
// For compact string format, we don't need JSON.stringify and can print directly
|
||||
if (commentsResult.success) {
|
||||
console.log(`Success: ${commentsResult.message}`);
|
||||
console.log("---------- Comments ----------");
|
||||
console.log(commentsResult.data);
|
||||
console.log("----------------------------");
|
||||
} else {
|
||||
console.error(`Failure: ${commentsResult.message}`);
|
||||
}
|
||||
|
||||
console.log("\n[5/5] Testing get_user_info...");
|
||||
const userInfoResult = await get_user_info({ mid: 2 }); // 2 is B站创始人之一的UID
|
||||
if (userInfoResult.success) {
|
||||
console.log(`Success: ${userInfoResult.message}`);
|
||||
console.log("---------- User Info ----------");
|
||||
console.log(userInfoResult.data);
|
||||
console.log("-----------------------------");
|
||||
} else {
|
||||
console.error(`Failure: ${userInfoResult.message}`);
|
||||
}
|
||||
|
||||
complete({ success: true, message: "Test finished." });
|
||||
}
|
||||
|
||||
return {
|
||||
get_subtitles: (params: { url: string }) => wrapToolExecution(get_subtitles, params),
|
||||
get_danmaku: (params: { url: string, count?: number }) => wrapToolExecution(get_danmaku, params),
|
||||
get_comments: (params: { url: string }) => wrapToolExecution(get_comments, params),
|
||||
search_videos: (params: { keyword: string, page?: number, count?: number }) => wrapToolExecution(search_videos, params),
|
||||
get_user_info: (params: { mid: number }) => wrapToolExecution(get_user_info, params),
|
||||
main,
|
||||
};
|
||||
})();
|
||||
|
||||
exports.get_subtitles = BilibiliVideoAnalysis.get_subtitles;
|
||||
exports.get_danmaku = BilibiliVideoAnalysis.get_danmaku;
|
||||
exports.get_comments = BilibiliVideoAnalysis.get_comments;
|
||||
exports.search_videos = BilibiliVideoAnalysis.search_videos;
|
||||
exports.get_user_info = BilibiliVideoAnalysis.get_user_info;
|
||||
exports.main = BilibiliVideoAnalysis.main;
|
||||
Reference in new issue
Block a user