koishi-plugin-share-links-analysis 0.7.3 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/core.d.ts +1 -1
- package/lib/core.js +2 -1
- package/lib/index.js +4 -1
- package/lib/parsers/youtube.d.ts +10 -0
- package/lib/parsers/youtube.js +298 -0
- package/lib/types.d.ts +1 -0
- package/package.json +1 -1
package/lib/core.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Context, Session } from 'koishi';
|
|
2
2
|
import { Link, PluginConfig, ParsedInfo } from './types';
|
|
3
|
-
export declare const parsers_str: ("bilibili" | "xiaohongshu" | "twitter" | "xiaoheihe")[];
|
|
3
|
+
export declare const parsers_str: ("bilibili" | "xiaohongshu" | "twitter" | "xiaoheihe" | "youtube")[];
|
|
4
4
|
/**
|
|
5
5
|
* 从文本中解析出所有支持的链接
|
|
6
6
|
* @param content 消息内容
|
package/lib/core.js
CHANGED
|
@@ -42,8 +42,9 @@ const Bilibili = __importStar(require("./parsers/bilibili"));
|
|
|
42
42
|
const Xiaohongshu = __importStar(require("./parsers/xiaohongshu"));
|
|
43
43
|
const Twitter = __importStar(require("./parsers/twitter"));
|
|
44
44
|
const Xiaoheihe = __importStar(require("./parsers/xiaoheihe"));
|
|
45
|
+
const Youtube = __importStar(require("./parsers/youtube"));
|
|
45
46
|
// 定义所有支持的解析器
|
|
46
|
-
const parsers = [Bilibili, Xiaohongshu, Twitter, Xiaoheihe];
|
|
47
|
+
const parsers = [Bilibili, Xiaohongshu, Twitter, Xiaoheihe, Youtube];
|
|
47
48
|
exports.parsers_str = parsers.map(p => p.name);
|
|
48
49
|
/**
|
|
49
50
|
* 从文本中解析出所有支持的链接
|
package/lib/index.js
CHANGED
|
@@ -93,6 +93,9 @@ exports.Config = koishi_1.Schema.intersect([
|
|
|
93
93
|
useNumeral: koishi_1.Schema.boolean().default(true).description("使用格式化数字 (如 10000 -> 1万)"),
|
|
94
94
|
showError: koishi_1.Schema.boolean().default(false).description("当链接被阻止时提醒发送者"),
|
|
95
95
|
}).description("高级解析设置"),
|
|
96
|
+
koishi_1.Schema.object({
|
|
97
|
+
youtubeCookie: koishi_1.Schema.string().role('textarea').description("[YouTube 专用] 手动填入 Cookie 字符串。使用已登录账号的 Cookie 可大幅降低被拦截概率。<br>获取方式:浏览器F12 -> 网络 -> 刷新YouTube首页 -> 查看请求头中的 `cookie` 字段。"),
|
|
98
|
+
}).description("YouTube 设置"),
|
|
96
99
|
koishi_1.Schema.object({
|
|
97
100
|
proxy: koishi_1.Schema.string().description("代理设置"),
|
|
98
101
|
proxy_settings: koishi_1.Schema.object(Object.fromEntries(core_1.parsers_str.map(parser => [parser, koishi_1.Schema.boolean().default(false).description(`对${parser}使用代理`)]))),
|
|
@@ -111,7 +114,7 @@ exports.Config = koishi_1.Schema.intersect([
|
|
|
111
114
|
}).description("调试设置"),
|
|
112
115
|
]);
|
|
113
116
|
function apply(ctx, config) {
|
|
114
|
-
// 数据库模型定义
|
|
117
|
+
// 数据库模型定义
|
|
115
118
|
ctx.model.extend('sla_cookie_cache', {
|
|
116
119
|
platform: 'string', // 平台名称,如 'xiaohongshu'
|
|
117
120
|
cookie: 'text', // 存储的 cookie 字符串
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Context, Session } from 'koishi';
|
|
2
|
+
import { Link, ParsedInfo, PluginConfig } from '../types';
|
|
3
|
+
export declare const name = "youtube";
|
|
4
|
+
export declare function match(content: string): Link[];
|
|
5
|
+
/**
|
|
6
|
+
* 初始化:尝试获取 Cookie
|
|
7
|
+
* 策略:优先 Puppeteer (最稳),失败则回退到 HTTP 请求 (利用插件自身的代理设置)
|
|
8
|
+
*/
|
|
9
|
+
export declare function init(ctx: Context, config: PluginConfig): Promise<boolean>;
|
|
10
|
+
export declare function process(ctx: Context, config: PluginConfig, link: Link, session: Session): Promise<ParsedInfo | null>;
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// src/parsers/youtube.ts
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.name = void 0;
|
|
8
|
+
exports.match = match;
|
|
9
|
+
exports.init = init;
|
|
10
|
+
exports.process = process;
|
|
11
|
+
const utils_1 = require("../utils");
|
|
12
|
+
const ytdl_core_1 = __importDefault(require("@distube/ytdl-core"));
|
|
13
|
+
exports.name = "youtube";
|
|
14
|
+
// 匹配规则:支持普通视频、短链接 (youtu.be)、Shorts 以及 Embed 链接
|
|
15
|
+
const linkRules = [
|
|
16
|
+
{
|
|
17
|
+
pattern: /https?:\/\/(?:www\.|m\.)?youtube\.com\/watch\?v=([\w-]{11})/gi,
|
|
18
|
+
type: "video",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
pattern: /https?:\/\/youtu\.be\/([\w-]{11})/gi,
|
|
22
|
+
type: "video",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
pattern: /https?:\/\/(?:www\.|m\.)?youtube\.com\/shorts\/([\w-]{11})/gi,
|
|
26
|
+
type: "shorts",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
pattern: /https?:\/\/(?:www\.|m\.)?youtube\.com\/(?:v|embed)\/([\w-]{11})/gi,
|
|
30
|
+
type: "video",
|
|
31
|
+
}
|
|
32
|
+
];
|
|
33
|
+
function match(content) {
|
|
34
|
+
const results = [];
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
for (const rule of linkRules) {
|
|
37
|
+
let match;
|
|
38
|
+
while ((match = rule.pattern.exec(content)) !== null) {
|
|
39
|
+
const id = match[1];
|
|
40
|
+
const url = `https://www.youtube.com/watch?v=${id}`;
|
|
41
|
+
if (seen.has(url))
|
|
42
|
+
continue;
|
|
43
|
+
seen.add(url);
|
|
44
|
+
results.push({
|
|
45
|
+
platform: exports.name,
|
|
46
|
+
type: rule.type,
|
|
47
|
+
id,
|
|
48
|
+
url,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return results;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* 初始化:尝试获取 Cookie
|
|
56
|
+
* 策略:优先 Puppeteer (最稳),失败则回退到 HTTP 请求 (利用插件自身的代理设置)
|
|
57
|
+
*/
|
|
58
|
+
async function init(ctx, config) {
|
|
59
|
+
const logger = ctx.logger('share-links-analysis:youtube');
|
|
60
|
+
// 1. 检查是否已配置手动 Cookie
|
|
61
|
+
if (config.youtubeCookie && config.youtubeCookie.trim().length > 0) {
|
|
62
|
+
logger.info('检测到配置文件中已填写手动 Cookie,将优先使用该 Cookie,跳过自动刷新。');
|
|
63
|
+
// 可选:将配置的 Cookie 同步到数据库,或者在 process 中直接读取配置
|
|
64
|
+
// 这里我们选择不写入数据库,而是每次 process 时直接读配置,方便用户随时修改
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
// 2. 自动获取逻辑 (仅当未配置手动 Cookie 时执行)
|
|
68
|
+
let cookieString = '';
|
|
69
|
+
// --- 策略 A: 尝试 Puppeteer (受全局 puppeteer 配置影响) ---
|
|
70
|
+
if (ctx.puppeteer) {
|
|
71
|
+
logger.info('尝试通过 Puppeteer 获取 Cookie...');
|
|
72
|
+
let page = null;
|
|
73
|
+
try {
|
|
74
|
+
page = await ctx.puppeteer.page();
|
|
75
|
+
await page.setUserAgent(config.userAgent);
|
|
76
|
+
// Puppeteer 只能走全局代理或 Chrome 参数代理,无法在这里动态设置
|
|
77
|
+
await page.goto('https://www.youtube.com', {
|
|
78
|
+
waitUntil: 'domcontentloaded',
|
|
79
|
+
timeout: 15000
|
|
80
|
+
});
|
|
81
|
+
// 尝试点击同意 (针对欧盟IP)
|
|
82
|
+
try {
|
|
83
|
+
const consentButton = await page.$('button[aria-label*="Accept"]');
|
|
84
|
+
if (consentButton)
|
|
85
|
+
await consentButton.click();
|
|
86
|
+
}
|
|
87
|
+
catch (e) { }
|
|
88
|
+
const cookies = await page.cookies();
|
|
89
|
+
if (cookies.length > 0) {
|
|
90
|
+
cookieString = cookies.map((c) => `${c.name}=${c.value}`).join('; ');
|
|
91
|
+
logger.info(`Puppeteer 成功: 获取到 ${cookies.length} 个 Cookie`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
logger.warn(`Puppeteer 获取失败: ${error.message}`);
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
if (page)
|
|
99
|
+
await page.close();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// --- 策略 B: 如果 Puppeteer 失败,尝试 HTTP 回退 (使用插件独立代理) ---
|
|
103
|
+
if (!cookieString) {
|
|
104
|
+
logger.info('尝试通过 HTTP 请求回退获取 Cookie...');
|
|
105
|
+
try {
|
|
106
|
+
// 构造请求头
|
|
107
|
+
const headers = {
|
|
108
|
+
'User-Agent': config.userAgent,
|
|
109
|
+
'Accept-Language': 'en-US,en;q=0.9',
|
|
110
|
+
};
|
|
111
|
+
// 创建一个专用的 http 实例,显式应用插件配置的 proxy
|
|
112
|
+
// 这样才能确保 request 走你在插件里填写的代理,而不是 Koishi 全局代理
|
|
113
|
+
const http = config.proxy
|
|
114
|
+
? ctx.http.extend({ proxy: config.proxy })
|
|
115
|
+
: ctx.http;
|
|
116
|
+
const res = await http('https://www.youtube.com', {
|
|
117
|
+
method: 'HEAD',
|
|
118
|
+
headers: headers,
|
|
119
|
+
redirect: 'manual'
|
|
120
|
+
});
|
|
121
|
+
let setCookie = [];
|
|
122
|
+
// 兼容处理 Headers: 标准 API (Node 18+) 或 Polyfill
|
|
123
|
+
if (res.headers && typeof res.headers.getSetCookie === 'function') {
|
|
124
|
+
setCookie = res.headers.getSetCookie();
|
|
125
|
+
}
|
|
126
|
+
else if (res.headers && typeof res.headers.get === 'function') {
|
|
127
|
+
// 降级:部分环境可能没有 getSetCookie
|
|
128
|
+
const raw = res.headers.get('set-cookie');
|
|
129
|
+
if (raw)
|
|
130
|
+
setCookie = [raw];
|
|
131
|
+
}
|
|
132
|
+
else if (res.headers && Array.isArray(res.headers['set-cookie'])) {
|
|
133
|
+
// 兼容旧版 axios 风格返回
|
|
134
|
+
setCookie = res.headers['set-cookie'];
|
|
135
|
+
}
|
|
136
|
+
if (setCookie && setCookie.length > 0) {
|
|
137
|
+
cookieString = setCookie.map(str => str.split(';')[0]).join('; ');
|
|
138
|
+
logger.info(`HTTP 回退成功: 获取到 Cookie 字符串`);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
logger.warn('HTTP 请求未返回 Set-Cookie 头');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
logger.warn(`HTTP 回退失败: ${error.message}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// --- 写入数据库 ---
|
|
149
|
+
if (cookieString) {
|
|
150
|
+
await ctx.database.upsert('sla_cookie_cache', [{ platform: exports.name, cookie: cookieString }]);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
logger.error('所有策略均失败,无法获取 YouTube Cookie。');
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// 辅助函数:解析 Cookie 字符串
|
|
159
|
+
function parseCookieString(cookieString) {
|
|
160
|
+
if (!cookieString)
|
|
161
|
+
return [];
|
|
162
|
+
return cookieString.split(';').map(pair => {
|
|
163
|
+
const parts = pair.split('=');
|
|
164
|
+
// 处理 value 中可能包含 = 的情况
|
|
165
|
+
const name = parts.shift();
|
|
166
|
+
const value = parts.join('=');
|
|
167
|
+
if (name && value)
|
|
168
|
+
return { name: name.trim(), value: value.trim() };
|
|
169
|
+
return null;
|
|
170
|
+
}).filter((c) => c !== null);
|
|
171
|
+
}
|
|
172
|
+
async function process(ctx, config, link, session) {
|
|
173
|
+
const logger = ctx.logger(`share-links-analysis:${exports.name}`);
|
|
174
|
+
const videoUrl = `https://www.youtube.com/watch?v=${link.id}`;
|
|
175
|
+
// 修复 2: 提升变量作用域,确保 catch 块能访问
|
|
176
|
+
let cookieString = '';
|
|
177
|
+
try {
|
|
178
|
+
// 1. 准备 Cookie
|
|
179
|
+
// 优先级:配置的手动 Cookie > 数据库缓存的自动 Cookie
|
|
180
|
+
if (config.youtubeCookie && config.youtubeCookie.trim().length > 0) {
|
|
181
|
+
cookieString = config.youtubeCookie.trim();
|
|
182
|
+
logger.debug('使用配置文件中的手动 Cookie。');
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
const dbCache = await ctx.database.get('sla_cookie_cache', exports.name);
|
|
186
|
+
cookieString = (dbCache && dbCache.length > 0) ? dbCache[0].cookie : '';
|
|
187
|
+
if (cookieString)
|
|
188
|
+
logger.debug('使用数据库缓存的自动 Cookie。');
|
|
189
|
+
}
|
|
190
|
+
if (!cookieString) {
|
|
191
|
+
logger.debug('无 Cookie,将尝试裸连解析(风险较高)。');
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
logger.debug('已加载 Cookie,准备解析。');
|
|
195
|
+
}
|
|
196
|
+
const cookies = parseCookieString(cookieString);
|
|
197
|
+
// 2. 创建 Agent (ytdl 专用)
|
|
198
|
+
// 无论 Puppeteer 是否有代理,这里必须使用插件配置的代理来请求视频流
|
|
199
|
+
let agent;
|
|
200
|
+
if (config.proxy) {
|
|
201
|
+
// 使用 createProxyAgent 同时处理 代理 和 Cookie
|
|
202
|
+
// 注意:@distube/ytdl-core 4.x+ 支持此方法
|
|
203
|
+
if (typeof ytdl_core_1.default.createProxyAgent === 'function') {
|
|
204
|
+
logger.debug(`使用代理: ${config.proxy}`);
|
|
205
|
+
agent = ytdl_core_1.default.createProxyAgent({ uri: config.proxy }, cookies);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
// 如果版本较旧没有 createProxyAgent,回退到普通 Agent (代理会失效)
|
|
209
|
+
logger.warn('ytdl.createProxyAgent 不存在,将忽略代理设置。请更新 @distube/ytdl-core');
|
|
210
|
+
agent = ytdl_core_1.default.createAgent(cookies);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
// 无代理,直接使用 createAgent
|
|
215
|
+
agent = ytdl_core_1.default.createAgent(cookies);
|
|
216
|
+
}
|
|
217
|
+
// 3. 获取视频信息
|
|
218
|
+
logger.debug(`正在解析 YouTube 视频: ${link.id}`);
|
|
219
|
+
// 尝试获取信息
|
|
220
|
+
// 为了提高成功率,我们允许 ytdl 尝试使用不同的内置客户端 (如 Android, iOS)
|
|
221
|
+
// 注意:@distube/ytdl-core 会自动处理客户端回退,但我们可以显式传参
|
|
222
|
+
const info = await ytdl_core_1.default.getInfo(videoUrl, {
|
|
223
|
+
agent, // 将正确的 Agent 传递给顶层选项
|
|
224
|
+
requestOptions: {
|
|
225
|
+
headers: {
|
|
226
|
+
'User-Agent': config.userAgent,
|
|
227
|
+
// 确保 headers 里也带上 Cookie,增加成功率
|
|
228
|
+
'Cookie': cookieString
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
const details = info.videoDetails;
|
|
233
|
+
// 4. 提取元数据
|
|
234
|
+
const title = details.title;
|
|
235
|
+
const authorName = details.author.name;
|
|
236
|
+
const description = details.description || '';
|
|
237
|
+
// 获取最高分辨率的封面
|
|
238
|
+
const thumbnails = details.thumbnails;
|
|
239
|
+
const coverUrl = thumbnails.length > 0 ? thumbnails[thumbnails.length - 1].url : undefined;
|
|
240
|
+
const views = (0, utils_1.numeral)(parseInt(details.viewCount), config);
|
|
241
|
+
const likes = details.likes ? (0, utils_1.numeral)(details.likes, config) : '未知';
|
|
242
|
+
const statsString = `观看: ${views} | 点赞: ${likes}`;
|
|
243
|
+
// 5. 选择最佳视频流
|
|
244
|
+
// 优先寻找 MP4 封装且包含音视频的格式 (兼容性最好)
|
|
245
|
+
let format = ytdl_core_1.default.chooseFormat(info.formats, {
|
|
246
|
+
quality: 'highest',
|
|
247
|
+
filter: (f) => f.container === 'mp4' && f.hasAudio && f.hasVideo
|
|
248
|
+
});
|
|
249
|
+
// 如果找不到 MP4 合流,尝试任意音视频合流
|
|
250
|
+
if (!format) {
|
|
251
|
+
logger.debug('未找到 MP4 合流格式,尝试查找任意音视频合流...');
|
|
252
|
+
try {
|
|
253
|
+
format = ytdl_core_1.default.chooseFormat(info.formats, {
|
|
254
|
+
quality: 'highest',
|
|
255
|
+
filter: 'audioandvideo'
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
catch (e) { }
|
|
259
|
+
}
|
|
260
|
+
// 6. 构建文件列表
|
|
261
|
+
const files = [];
|
|
262
|
+
if (format && format.url) {
|
|
263
|
+
// 只有当存在有效链接时才添加
|
|
264
|
+
files.push({ type: 'video', url: format.url });
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
logger.warn('未找到合适的视频流格式。');
|
|
268
|
+
}
|
|
269
|
+
// 7. 构建正文
|
|
270
|
+
const mainbody = (0, utils_1.escapeHtml)(description);
|
|
271
|
+
return {
|
|
272
|
+
platform: exports.name,
|
|
273
|
+
title: title,
|
|
274
|
+
authorName: authorName,
|
|
275
|
+
mainbody: mainbody,
|
|
276
|
+
coverUrl: coverUrl,
|
|
277
|
+
files: files,
|
|
278
|
+
sourceUrl: videoUrl,
|
|
279
|
+
stats: statsString,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
if (error.message.includes('Sign in') || error.message.includes('429')) {
|
|
284
|
+
const tip = cookieString
|
|
285
|
+
? '当前 Cookie 可能已失效,请更新配置中的 Cookie。'
|
|
286
|
+
: '请在插件配置中填入已登录账号的 YouTube Cookie。';
|
|
287
|
+
await session.send(`YouTube 解析被拦截:${tip}`);
|
|
288
|
+
logger.warn(`解析拦截: ${error.message}`);
|
|
289
|
+
}
|
|
290
|
+
else if (error.message.includes('Private video')) {
|
|
291
|
+
await session.send('解析失败:私享视频。');
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
logger.error(`解析异常: ${error.message}`);
|
|
295
|
+
}
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
}
|
package/lib/types.d.ts
CHANGED