koishi-plugin-xlon 1.0.3 → 1.1.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/constants.d.ts +2 -0
- package/lib/features/translator.d.ts +1 -7
- package/lib/index.d.ts +1 -2
- package/lib/index.js +163 -143
- package/lib/service/message-builder.d.ts +1 -1
- package/package.json +1 -1
|
@@ -1,10 +1,4 @@
|
|
|
1
1
|
import { Context } from 'koishi';
|
|
2
2
|
import { Config } from '../config';
|
|
3
|
-
/**
|
|
4
|
-
* 翻译入口。完整流程:
|
|
5
|
-
* 1. 构建 system prompt(注入术语表)
|
|
6
|
-
* 2. 调用 LLM → 退化检测
|
|
7
|
-
* 3. 如退化 → 简化 prompt 重试一次
|
|
8
|
-
* 4. 事后兜底:术语表强制替换
|
|
9
|
-
*/
|
|
3
|
+
/** 翻译入口:注入术语表 → 调用 LLM → 退化检测 → 失败重试 → 术语表兜底。 */
|
|
10
4
|
export declare function translateIfEnabled(ctx: Context, config: Config, text: string): Promise<string>;
|
package/lib/index.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { Context
|
|
1
|
+
import { Context } from 'koishi';
|
|
2
2
|
import { Config } from './config';
|
|
3
3
|
export declare const name = "xlon";
|
|
4
|
-
export declare const logger: Logger;
|
|
5
4
|
export declare const inject: {
|
|
6
5
|
required: string[];
|
|
7
6
|
optional: string[];
|
package/lib/index.js
CHANGED
|
@@ -23,17 +23,16 @@ __export(src_exports, {
|
|
|
23
23
|
Config: () => Config,
|
|
24
24
|
apply: () => apply,
|
|
25
25
|
inject: () => inject,
|
|
26
|
-
logger: () => logger7,
|
|
27
26
|
name: () => name
|
|
28
27
|
});
|
|
29
28
|
module.exports = __toCommonJS(src_exports);
|
|
30
|
-
var
|
|
29
|
+
var import_koishi9 = require("koishi");
|
|
31
30
|
|
|
32
31
|
// src/locales/zh-CN.yml
|
|
33
|
-
var zh_CN_default = { _config: { cookies: "X 的 auth_token Cookie", updateInterval: "检查更新间隔(分钟)", messagePrefix: "推送消息前缀", screenshot: "是否启用推文截图(需要 puppeteer)", translateEnabled: "是否启用翻译", apiKey: "API Key(OpenAI 兼容)", apiUrl: "API 地址", model: "模型名称", systemPrompt: "系统提示词({glossary} 会被术语表自动替换)", prompt: "用户消息模板({text} 代表待翻译文本)", term: "术语表(注入 prompt + 翻译后兜底替换)", subscriptions: { id: "博主用户名(@ 后面的部分,不要加 @)", targets: "推送目标(格式 platform:channelId,如 onebot:123456)", blacklist: "违禁词(命中则跳过推送)" }, outputLogs: "日志调试模式", detectXLinks: "是否启用 X/Twitter 链接检测" }, commands: { "xlon.check": { description: "检查一次订阅更新" }, "xlon.tweet": { description: "根据 URL 或用户名获取推文内容" } }, messages: { fetching: "正在获取推文内容...", "empty-url": "请输入推文链接。", "no-latest-tweet": "未找到用户 {0} 的最新推文,请检查用户名是否正确。", checking: "正在检查更新...", busy: "上一轮检查尚未完成,请稍候。", "poll-fail": "轮询 {0} 失败(可能网络/用户名问题)。" } };
|
|
32
|
+
var zh_CN_default = { _config: { cookies: "X 的 auth_token Cookie", updateInterval: "检查更新间隔(分钟)", messagePrefix: "推送消息前缀", screenshot: "是否启用推文截图(需要 puppeteer)", translateEnabled: "是否启用翻译", apiKey: "API Key(OpenAI 兼容)", apiUrl: "API 地址", model: "模型名称", systemPrompt: "系统提示词({glossary} 会被术语表自动替换)", prompt: "用户消息模板({text} 代表待翻译文本)", term: "术语表(注入 prompt + 翻译后兜底替换)", subscriptions: { id: "博主用户名(@ 后面的部分,不要加 @)", targets: "推送目标(格式 platform:channelId,如 onebot:123456)", blacklist: "违禁词(命中则跳过推送)" }, outputLogs: "日志调试模式", detectXLinks: "是否启用 X/Twitter 链接检测" }, commands: { "xlon.check": { description: "检查一次订阅更新" }, "xlon.tweet": { description: "根据 URL 或用户名获取推文内容" } }, messages: { fetching: "正在获取推文内容...", "empty-url": "请输入推文链接。", "no-latest-tweet": "未找到用户 {0} 的最新推文,请检查用户名是否正确。", checking: "正在检查更新...", busy: "上一轮检查尚未完成,请稍候。", "poll-fail": "轮询 {0} 失败(可能网络/用户名问题)。", error: "处理请求时发生错误,请稍后重试。" } };
|
|
34
33
|
|
|
35
34
|
// src/locales/en-US.yml
|
|
36
|
-
var en_US_default = { _config: { cookies: "X auth_token cookie", updateInterval: "Poll interval (minutes)", messagePrefix: "Message prefix", screenshot: "Enable tweet screenshot (requires puppeteer)", translateEnabled: "Enable translation", apiKey: "API key (OpenAI-compatible)", apiUrl: "API base URL", model: "Model name", systemPrompt: "System prompt ({glossary} is replaced by glossary)", prompt: "User message template ({text} is replaced by source text)", term: "Glossary (injected into prompt + post-process fallback)", subscriptions: { id: "Username (without @)", targets: "Targets (format platform:channelId, e.g. onebot:123456)", blacklist: "Block words (skip when hit)" }, outputLogs: "Verbose logs", detectXLinks: "Detect X/Twitter links in messages" }, commands: { "xlon.check": { description: "Check subscriptions for updates" }, "xlon.tweet": { description: "Fetch tweet content by URL or username" } }, messages: { fetching: "Fetching tweet content...", "empty-url": "Please provide a tweet URL.", "no-latest-tweet": "No latest tweet found for user {0}. Please check the username.", checking: "Checking for updates...", busy: "Previous check is still running, please wait.", "poll-fail": "Failed to poll {0} (possible network/username issue)." } };
|
|
35
|
+
var en_US_default = { _config: { cookies: "X auth_token cookie", updateInterval: "Poll interval (minutes)", messagePrefix: "Message prefix", screenshot: "Enable tweet screenshot (requires puppeteer)", translateEnabled: "Enable translation", apiKey: "API key (OpenAI-compatible)", apiUrl: "API base URL", model: "Model name", systemPrompt: "System prompt ({glossary} is replaced by glossary)", prompt: "User message template ({text} is replaced by source text)", term: "Glossary (injected into prompt + post-process fallback)", subscriptions: { id: "Username (without @)", targets: "Targets (format platform:channelId, e.g. onebot:123456)", blacklist: "Block words (skip when hit)" }, outputLogs: "Verbose logs", detectXLinks: "Detect X/Twitter links in messages" }, commands: { "xlon.check": { description: "Check subscriptions for updates" }, "xlon.tweet": { description: "Fetch tweet content by URL or username" } }, messages: { fetching: "Fetching tweet content...", "empty-url": "Please provide a tweet URL.", "no-latest-tweet": "No latest tweet found for user {0}. Please check the username.", checking: "Checking for updates...", busy: "Previous check is still running, please wait.", "poll-fail": "Failed to poll {0} (possible network/username issue).", error: "An error occurred while processing the request. Please try again later." } };
|
|
37
36
|
|
|
38
37
|
// src/db/model.ts
|
|
39
38
|
var import_koishi = require("koishi");
|
|
@@ -51,7 +50,6 @@ function extendDatabase(ctx) {
|
|
|
51
50
|
autoInc: false
|
|
52
51
|
}
|
|
53
52
|
);
|
|
54
|
-
logger.info("数据库初始化成功");
|
|
55
53
|
}
|
|
56
54
|
__name(extendDatabase, "extendDatabase");
|
|
57
55
|
|
|
@@ -77,52 +75,61 @@ var XlonRepo = class {
|
|
|
77
75
|
};
|
|
78
76
|
|
|
79
77
|
// src/features/commands/index.ts
|
|
78
|
+
var import_koishi2 = require("koishi");
|
|
79
|
+
var logger2 = new import_koishi2.Logger("xlon/commands");
|
|
80
80
|
function registerCommands(ctx, config, deps) {
|
|
81
81
|
ctx.command("x.check").alias("check").action(async ({ session }) => {
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
try {
|
|
83
|
+
await session.send(session.text("messages.checking"));
|
|
84
|
+
await deps.checkOnce(session);
|
|
85
|
+
} catch (e) {
|
|
86
|
+
logger2.error("x.check 命令执行失败", e);
|
|
87
|
+
await session.send(session.text("messages.error"));
|
|
88
|
+
}
|
|
84
89
|
});
|
|
85
90
|
ctx.command("x.tweet <input:text>").alias("tweet").action(async ({ session }, input) => {
|
|
86
|
-
|
|
91
|
+
try {
|
|
92
|
+
await deps.handleTweetInput(session, input || "");
|
|
93
|
+
} catch (e) {
|
|
94
|
+
logger2.error("x.tweet 命令执行失败", e);
|
|
95
|
+
await session.send(session.text("messages.error"));
|
|
96
|
+
}
|
|
87
97
|
});
|
|
88
98
|
}
|
|
89
99
|
__name(registerCommands, "registerCommands");
|
|
90
100
|
|
|
91
101
|
// src/features/link-detector.ts
|
|
92
|
-
var
|
|
93
|
-
var
|
|
94
|
-
var urlRe = /
|
|
102
|
+
var import_koishi3 = require("koishi");
|
|
103
|
+
var logger3 = new import_koishi3.Logger("xlon/link-detector");
|
|
104
|
+
var urlRe = /https?:\/\/[^\s'")\]]+/g;
|
|
95
105
|
function extractUrls(text) {
|
|
96
106
|
const matches = text.match(urlRe) || [];
|
|
97
|
-
return matches.map((m) => m.replace(/[
|
|
107
|
+
return matches.map((m) => m.replace(/[。,?!.,!?,。?!、]+$/g, ""));
|
|
98
108
|
}
|
|
99
109
|
__name(extractUrls, "extractUrls");
|
|
100
110
|
function isXDomain(urlStr) {
|
|
101
111
|
try {
|
|
102
|
-
const u = new URL(urlStr
|
|
112
|
+
const u = new URL(urlStr);
|
|
103
113
|
const hn = (u.hostname || "").toLowerCase();
|
|
104
114
|
return hn === "t.co" || hn === "x.com" || hn.endsWith(".x.com") || hn === "twitter.com" || hn.endsWith(".twitter.com") || hn === "m.twitter.com" || hn === "mobile.twitter.com";
|
|
105
|
-
} catch
|
|
115
|
+
} catch {
|
|
106
116
|
return false;
|
|
107
117
|
}
|
|
108
118
|
}
|
|
109
119
|
__name(isXDomain, "isXDomain");
|
|
110
|
-
async function expandShortLink(
|
|
120
|
+
async function expandShortLink(url) {
|
|
111
121
|
try {
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
122
|
+
const controller = new AbortController();
|
|
123
|
+
const timer = setTimeout(() => controller.abort(), 5e3);
|
|
124
|
+
const res = await fetch(url, {
|
|
125
|
+
method: "HEAD",
|
|
126
|
+
redirect: "follow",
|
|
127
|
+
signal: controller.signal
|
|
116
128
|
});
|
|
117
|
-
|
|
118
|
-
return
|
|
119
|
-
} catch
|
|
120
|
-
|
|
121
|
-
const location2 = err?.response?.headers?.location;
|
|
122
|
-
return location2 || url;
|
|
123
|
-
} catch (_) {
|
|
124
|
-
return url;
|
|
125
|
-
}
|
|
129
|
+
clearTimeout(timer);
|
|
130
|
+
return res.url || url;
|
|
131
|
+
} catch {
|
|
132
|
+
return url;
|
|
126
133
|
}
|
|
127
134
|
}
|
|
128
135
|
__name(expandShortLink, "expandShortLink");
|
|
@@ -136,27 +143,26 @@ function registerLinkDetector(ctx, config, handler) {
|
|
|
136
143
|
if (!candidates.length) return next();
|
|
137
144
|
const found = [];
|
|
138
145
|
for (const c of candidates) {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const exp = await expandShortLink(ctx, normalized);
|
|
146
|
+
if (/^https?:\/\/t\.co\//i.test(c)) {
|
|
147
|
+
const exp = await expandShortLink(c);
|
|
142
148
|
if (isXDomain(exp)) found.push(exp);
|
|
143
|
-
} else if (isXDomain(
|
|
144
|
-
found.push(
|
|
149
|
+
} else if (isXDomain(c)) {
|
|
150
|
+
found.push(c);
|
|
145
151
|
}
|
|
146
152
|
if (found.length >= 3) break;
|
|
147
153
|
}
|
|
148
154
|
if (found.length && config.outputLogs) {
|
|
149
|
-
|
|
155
|
+
logger3.info("检测到 X/Twitter 链接:", found);
|
|
150
156
|
}
|
|
151
157
|
for (const link of found) {
|
|
152
158
|
try {
|
|
153
159
|
await handler(session, link);
|
|
154
160
|
} catch (e) {
|
|
155
|
-
|
|
161
|
+
logger3.error("处理检测到的 X/Twitter 链接时出错", e);
|
|
156
162
|
}
|
|
157
163
|
}
|
|
158
164
|
} catch (err) {
|
|
159
|
-
|
|
165
|
+
logger3.error("X/Twitter 链接检测失败", err);
|
|
160
166
|
}
|
|
161
167
|
return next();
|
|
162
168
|
});
|
|
@@ -164,8 +170,8 @@ function registerLinkDetector(ctx, config, handler) {
|
|
|
164
170
|
__name(registerLinkDetector, "registerLinkDetector");
|
|
165
171
|
|
|
166
172
|
// src/features/translator.ts
|
|
167
|
-
var
|
|
168
|
-
var
|
|
173
|
+
var import_koishi4 = require("koishi");
|
|
174
|
+
var logger4 = new import_koishi4.Logger("xlon/translator");
|
|
169
175
|
function buildGlossaryBlock(term) {
|
|
170
176
|
const entries = Object.entries(term || {}).filter(([k]) => !!k);
|
|
171
177
|
if (!entries.length) return "";
|
|
@@ -208,24 +214,20 @@ function validateTranslation(original, translated) {
|
|
|
208
214
|
if (original.length > 30 && translated.length < original.length * 0.15) {
|
|
209
215
|
return { ok: false, reason: `翻译结果疑似截断(原文 ${original.length} 字 → 译文 ${translated.length} 字)` };
|
|
210
216
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
217
|
+
const sample = translated.length > 1500 ? translated.slice(0, 1500) : translated;
|
|
218
|
+
for (let winLen = 8; winLen <= Math.min(30, Math.floor(sample.length / 3)); winLen++) {
|
|
219
|
+
for (let i = 0; i + winLen * 3 <= sample.length; i++) {
|
|
220
|
+
if (sample.slice(i + winLen, i + winLen * 2) !== sample.slice(i, i + winLen)) continue;
|
|
221
|
+
if (sample.slice(i + winLen * 2, i + winLen * 3) !== sample.slice(i, i + winLen)) continue;
|
|
222
|
+
const seg = sample.slice(i, i + winLen);
|
|
214
223
|
if (/^[\s\p{P}]+$/u.test(seg)) continue;
|
|
215
|
-
|
|
216
|
-
let
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
count++;
|
|
221
|
-
pos += winLen;
|
|
222
|
-
} else {
|
|
223
|
-
break;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
if (count >= 3) {
|
|
227
|
-
return { ok: false, reason: `翻译结果包含连续重复片段:"${seg.slice(0, 20)}…" 重复 ${count} 次` };
|
|
224
|
+
let count = 3;
|
|
225
|
+
let j = i + winLen * 3;
|
|
226
|
+
while (j + winLen <= sample.length && sample.slice(j, j + winLen) === seg) {
|
|
227
|
+
count++;
|
|
228
|
+
j += winLen;
|
|
228
229
|
}
|
|
230
|
+
return { ok: false, reason: `翻译结果包含连续重复片段:"${seg.slice(0, 20)}…" 重复 ${count} 次` };
|
|
229
231
|
}
|
|
230
232
|
}
|
|
231
233
|
return { ok: true };
|
|
@@ -245,13 +247,16 @@ async function callTranslateAPI(ctx, config, opts) {
|
|
|
245
247
|
],
|
|
246
248
|
stream: false
|
|
247
249
|
};
|
|
248
|
-
const response = await ctx.http.post(url, data, { headers });
|
|
250
|
+
const response = await ctx.http.post(url, data, { headers, timeout: 3e4 });
|
|
249
251
|
return String(response?.choices?.[0]?.message?.content ?? "").trim();
|
|
250
252
|
}
|
|
251
253
|
__name(callTranslateAPI, "callTranslateAPI");
|
|
252
254
|
async function translateIfEnabled(ctx, config, text) {
|
|
253
255
|
if (!config.translateEnabled) return text;
|
|
254
|
-
if (!config.apiKey)
|
|
256
|
+
if (!config.apiKey) {
|
|
257
|
+
logger4.warn("translateEnabled 已开启,但 apiKey 未配置,跳过翻译");
|
|
258
|
+
return text;
|
|
259
|
+
}
|
|
255
260
|
if (!text?.trim()) return text;
|
|
256
261
|
const glossaryBlock = buildGlossaryBlock(config.term);
|
|
257
262
|
const systemContent = injectGlossary(config.systemPrompt || "", glossaryBlock);
|
|
@@ -264,10 +269,10 @@ async function translateIfEnabled(ctx, config, text) {
|
|
|
264
269
|
});
|
|
265
270
|
const v1 = validateTranslation(text, translated);
|
|
266
271
|
if (v1.ok) {
|
|
267
|
-
if (config.outputLogs)
|
|
272
|
+
if (config.outputLogs) logger4.info("翻译通过退化检测");
|
|
268
273
|
return applyTermMap(translated, config.term);
|
|
269
274
|
}
|
|
270
|
-
|
|
275
|
+
logger4.warn(`翻译退化(${v1.reason}),使用简化 prompt 重试`);
|
|
271
276
|
const retrySystem = "你是翻译助手。直接输出简体中文翻译,不要添加任何额外内容。";
|
|
272
277
|
const retryUser = text;
|
|
273
278
|
try {
|
|
@@ -278,33 +283,37 @@ async function translateIfEnabled(ctx, config, text) {
|
|
|
278
283
|
});
|
|
279
284
|
const v2 = validateTranslation(text, retried);
|
|
280
285
|
if (v2.ok) {
|
|
281
|
-
if (config.outputLogs)
|
|
286
|
+
if (config.outputLogs) logger4.info("重试翻译通过退化检测");
|
|
282
287
|
return applyTermMap(retried, config.term);
|
|
283
288
|
}
|
|
284
|
-
|
|
289
|
+
logger4.warn(`重试翻译仍退化(${v2.reason}),回退原文`);
|
|
285
290
|
return text;
|
|
286
291
|
} catch (retryErr) {
|
|
287
|
-
|
|
292
|
+
logger4.error("重试翻译 API 调用失败", retryErr);
|
|
288
293
|
return text;
|
|
289
294
|
}
|
|
290
295
|
} catch (err) {
|
|
291
|
-
|
|
296
|
+
logger4.error("翻译 API 调用失败", err);
|
|
292
297
|
return text;
|
|
293
298
|
}
|
|
294
299
|
}
|
|
295
300
|
__name(translateIfEnabled, "translateIfEnabled");
|
|
296
301
|
|
|
297
302
|
// src/service/message-builder.ts
|
|
298
|
-
var
|
|
299
|
-
|
|
303
|
+
var import_koishi5 = require("koishi");
|
|
304
|
+
|
|
305
|
+
// src/constants.ts
|
|
306
|
+
var UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36";
|
|
307
|
+
|
|
308
|
+
// src/service/message-builder.ts
|
|
309
|
+
var logger5 = new import_koishi5.Logger("xlon/message-builder");
|
|
300
310
|
async function fetchBinary(ctx, url, maxRetries = 3) {
|
|
301
311
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
302
312
|
try {
|
|
303
313
|
return await ctx.http.get(url, {
|
|
304
314
|
responseType: "arraybuffer",
|
|
305
|
-
headers: {
|
|
306
|
-
|
|
307
|
-
}
|
|
315
|
+
headers: { "User-Agent": UA },
|
|
316
|
+
timeout: 3e4
|
|
308
317
|
});
|
|
309
318
|
} catch (e) {
|
|
310
319
|
if (attempt >= maxRetries) throw e;
|
|
@@ -318,13 +327,15 @@ async function buildTweetMessages(ctx, config, detail, translated, opts) {
|
|
|
318
327
|
const altOriginalText = detail.altTexts && detail.altTexts.length ? "\n" + detail.altTexts.map(
|
|
319
328
|
(alt, i) => `[图片${detail.altTexts.length > 1 ? i + 1 : ""}描述原文: ${alt}]`
|
|
320
329
|
).join("\n") : "";
|
|
321
|
-
const
|
|
330
|
+
const hasMedia = (detail.mediaUrls?.length ?? 0) > 0;
|
|
331
|
+
const hasVideo = detail.mediaUrls?.some((u) => u.endsWith(".mp4")) ?? false;
|
|
332
|
+
const mediaLabel = !hasMedia ? "文字" : hasVideo ? "视频" : "图片";
|
|
322
333
|
const prefixUser = opts?.username ? `【${opts.username}】 ` : "";
|
|
323
334
|
const retweetNote = opts?.isRetweet ? "[转发推文]\n" : "";
|
|
324
|
-
let textMsg = `${prefixUser}${config.messagePrefix}一条${
|
|
335
|
+
let textMsg = `${prefixUser}${config.messagePrefix}一条${mediaLabel}推文:
|
|
325
336
|
${translated}${altOriginalText}
|
|
326
337
|
${retweetNote}`;
|
|
327
|
-
if (detail.screenshotBuffer) textMsg += `${
|
|
338
|
+
if (detail.screenshotBuffer) textMsg += `${import_koishi5.h.image(detail.screenshotBuffer, "image/webp")}`;
|
|
328
339
|
segments.push({ content: textMsg });
|
|
329
340
|
const imageUrls = (detail.mediaUrls || []).filter((u) => !u.endsWith(".mp4"));
|
|
330
341
|
if (imageUrls.length) {
|
|
@@ -332,20 +343,20 @@ ${retweetNote}`;
|
|
|
332
343
|
for (const imgUrl of imageUrls) {
|
|
333
344
|
try {
|
|
334
345
|
const bin = await fetchBinary(ctx, imgUrl);
|
|
335
|
-
images.push(
|
|
346
|
+
images.push(String(import_koishi5.h.image(bin, "image/jpeg")));
|
|
336
347
|
} catch (e) {
|
|
337
|
-
|
|
348
|
+
logger5.error(`下载图片失败:${imgUrl}`, e);
|
|
338
349
|
}
|
|
339
350
|
}
|
|
340
|
-
if (images.length) segments.push({ content: images.join("
|
|
351
|
+
if (images.length) segments.push({ content: images.join("") });
|
|
341
352
|
}
|
|
342
353
|
const videoUrl = (detail.mediaUrls || []).find((u) => u.endsWith(".mp4"));
|
|
343
354
|
if (videoUrl) {
|
|
344
355
|
try {
|
|
345
356
|
const bin = await fetchBinary(ctx, videoUrl);
|
|
346
|
-
segments.push({ content:
|
|
357
|
+
segments.push({ content: import_koishi5.h.video(bin, "video/mp4") });
|
|
347
358
|
} catch (e) {
|
|
348
|
-
|
|
359
|
+
logger5.error(`下载视频失败:${videoUrl}`, e);
|
|
349
360
|
}
|
|
350
361
|
}
|
|
351
362
|
return segments;
|
|
@@ -353,28 +364,32 @@ ${retweetNote}`;
|
|
|
353
364
|
__name(buildTweetMessages, "buildTweetMessages");
|
|
354
365
|
|
|
355
366
|
// src/scheduler/poller.ts
|
|
356
|
-
var
|
|
357
|
-
var
|
|
367
|
+
var import_koishi6 = require("koishi");
|
|
368
|
+
var logger6 = new import_koishi6.Logger("xlon/poller");
|
|
358
369
|
function createPoller(ctx, config, repo, fetcher, deps) {
|
|
359
370
|
let checking = false;
|
|
360
371
|
async function init() {
|
|
361
372
|
const existingIds = new Set(await repo.listIds());
|
|
362
373
|
const newSubs = config.subscriptions.filter((s) => !existingIds.has(s.id));
|
|
363
374
|
if (config.outputLogs) {
|
|
364
|
-
|
|
365
|
-
|
|
375
|
+
logger6.info(`[初始化] 已存在订阅:${Array.from(existingIds).join(", ")}`);
|
|
376
|
+
logger6.info(`[初始化] 需要初始化:${newSubs.map((s) => s.id).join(", ")}`);
|
|
366
377
|
}
|
|
367
|
-
for (
|
|
378
|
+
for (let i = 0; i < newSubs.length; i++) {
|
|
379
|
+
const sub = newSubs[i];
|
|
368
380
|
try {
|
|
369
381
|
const latest = await fetcher.getLatestTweet(sub.id);
|
|
370
382
|
const link = latest.tweets[0]?.link;
|
|
371
383
|
if (!link) continue;
|
|
372
384
|
await repo.upsert({ id: sub.id, link, content: latest.wordContent || "" });
|
|
373
385
|
} catch (e) {
|
|
374
|
-
|
|
386
|
+
logger6.error(`[初始化] 获取 ${sub.id} 最新推文失败(请检查用户名/网络)`, e);
|
|
387
|
+
}
|
|
388
|
+
if (i < newSubs.length - 1) {
|
|
389
|
+
await ctx.sleep(1e3 + Math.random() * 2e3);
|
|
375
390
|
}
|
|
376
391
|
}
|
|
377
|
-
|
|
392
|
+
logger6.info("初始化完成");
|
|
378
393
|
}
|
|
379
394
|
__name(init, "init");
|
|
380
395
|
async function checkOnce(session) {
|
|
@@ -384,7 +399,8 @@ function createPoller(ctx, config, repo, fetcher, deps) {
|
|
|
384
399
|
}
|
|
385
400
|
checking = true;
|
|
386
401
|
try {
|
|
387
|
-
for (
|
|
402
|
+
for (let i = 0; i < config.subscriptions.length; i++) {
|
|
403
|
+
const sub = config.subscriptions[i];
|
|
388
404
|
const username = sub.id;
|
|
389
405
|
try {
|
|
390
406
|
const latest = await fetcher.getLatestTweet(username);
|
|
@@ -392,24 +408,29 @@ function createPoller(ctx, config, repo, fetcher, deps) {
|
|
|
392
408
|
if (!latestLink) continue;
|
|
393
409
|
const stored = await repo.get(username);
|
|
394
410
|
const storedLink = stored?.link || "";
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
411
|
+
await repo.upsert({
|
|
412
|
+
id: username,
|
|
413
|
+
link: latestLink,
|
|
414
|
+
content: latest.wordContent || ""
|
|
415
|
+
});
|
|
416
|
+
if (!storedLink) {
|
|
417
|
+
if (config.outputLogs) logger6.info(`[轮询] 首次记录基准:${username}`);
|
|
418
|
+
} else if (storedLink !== latestLink) {
|
|
401
419
|
await deps.pushTweet({
|
|
402
420
|
username,
|
|
403
421
|
url: latestLink,
|
|
404
422
|
isRetweet: latest.tweets[0]?.isRetweet || false
|
|
405
423
|
});
|
|
406
424
|
} else if (config.outputLogs) {
|
|
407
|
-
|
|
425
|
+
logger6.info(`已是最新:${username}`);
|
|
408
426
|
}
|
|
409
427
|
} catch (e) {
|
|
410
|
-
|
|
428
|
+
logger6.error(`轮询 ${username} 失败`, e);
|
|
411
429
|
if (session) await session.send(session.text("messages.poll-fail", [username]));
|
|
412
430
|
}
|
|
431
|
+
if (i < config.subscriptions.length - 1) {
|
|
432
|
+
await ctx.sleep(1e3 + Math.random() * 2e3);
|
|
433
|
+
}
|
|
413
434
|
}
|
|
414
435
|
} finally {
|
|
415
436
|
checking = false;
|
|
@@ -418,7 +439,7 @@ function createPoller(ctx, config, repo, fetcher, deps) {
|
|
|
418
439
|
__name(checkOnce, "checkOnce");
|
|
419
440
|
function start() {
|
|
420
441
|
const ms = Math.max(1, config.updateInterval) * 60 * 1e3;
|
|
421
|
-
ctx.setInterval(() => checkOnce().catch((e) =>
|
|
442
|
+
ctx.setInterval(() => checkOnce().catch((e) => logger6.error("定时任务异常", e)), ms);
|
|
422
443
|
}
|
|
423
444
|
__name(start, "start");
|
|
424
445
|
return { init, checkOnce, start };
|
|
@@ -426,9 +447,8 @@ function createPoller(ctx, config, repo, fetcher, deps) {
|
|
|
426
447
|
__name(createPoller, "createPoller");
|
|
427
448
|
|
|
428
449
|
// src/service/fetcher.ts
|
|
429
|
-
var
|
|
430
|
-
var
|
|
431
|
-
var UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36";
|
|
450
|
+
var import_koishi7 = require("koishi");
|
|
451
|
+
var logger7 = new import_koishi7.Logger("xlon/fetcher");
|
|
432
452
|
function toAbsoluteUrl(input) {
|
|
433
453
|
const raw = (input || "").trim();
|
|
434
454
|
if (!raw) return raw;
|
|
@@ -490,14 +510,13 @@ async function screenshotTweetArticle(page, selector = 'article[data-testid="twe
|
|
|
490
510
|
return imgsLoaded && videosLoaded;
|
|
491
511
|
},
|
|
492
512
|
{ timeout: 2e4 },
|
|
493
|
-
// 增加到 20 秒
|
|
494
513
|
selector
|
|
495
514
|
).catch((err) => {
|
|
496
|
-
|
|
515
|
+
logger7.warn(`媒体加载超时: ${err.message}`);
|
|
497
516
|
return null;
|
|
498
517
|
});
|
|
499
518
|
if (!mediaLoadResult) {
|
|
500
|
-
|
|
519
|
+
logger7.warn("部分媒体可能未完全加载,额外等待 2 秒");
|
|
501
520
|
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 2e3)));
|
|
502
521
|
}
|
|
503
522
|
await page.evaluate(() => {
|
|
@@ -556,7 +575,6 @@ async function preparePage(page, cookies) {
|
|
|
556
575
|
secure: true
|
|
557
576
|
});
|
|
558
577
|
await page.setUserAgent(UA);
|
|
559
|
-
await page.setDefaultNavigationTimeout(6e4);
|
|
560
578
|
await page.setDefaultTimeout(6e4);
|
|
561
579
|
}
|
|
562
580
|
__name(preparePage, "preparePage");
|
|
@@ -578,7 +596,7 @@ var XFetcher = class {
|
|
|
578
596
|
*/
|
|
579
597
|
async getLatestTweet(username, maxRetries = 3) {
|
|
580
598
|
if (!this.hasPuppeteer) {
|
|
581
|
-
|
|
599
|
+
logger7.warn("puppeteer 不可用,无法获取最新推文");
|
|
582
600
|
return { tweets: [], wordContent: "" };
|
|
583
601
|
}
|
|
584
602
|
const url = `https://x.com/${username}`;
|
|
@@ -617,7 +635,7 @@ var XFetcher = class {
|
|
|
617
635
|
const textEl = article.querySelector(
|
|
618
636
|
'div[data-testid="tweetText"], div[lang]'
|
|
619
637
|
);
|
|
620
|
-
const
|
|
638
|
+
const wordContent = (textEl && textEl.textContent ? textEl.textContent : "").trim();
|
|
621
639
|
const timeEl = article.querySelector('a[href*="/status/"] time');
|
|
622
640
|
const linkEl = timeEl && timeEl.parentElement || article.querySelector('a[href*="/status/"]');
|
|
623
641
|
const href = linkEl && linkEl.getAttribute("href") || "";
|
|
@@ -634,7 +652,7 @@ var XFetcher = class {
|
|
|
634
652
|
let absolute = href;
|
|
635
653
|
if (absolute.startsWith("/")) absolute = "https://x.com" + absolute;
|
|
636
654
|
if (!absolute.startsWith("http")) absolute = "https://x.com/" + absolute;
|
|
637
|
-
collected.push({ link: absolute, isRetweet, isVideo,
|
|
655
|
+
collected.push({ link: absolute, isRetweet, isVideo, wordContent });
|
|
638
656
|
}
|
|
639
657
|
const latest = collected.slice(0, 1);
|
|
640
658
|
return {
|
|
@@ -643,17 +661,18 @@ var XFetcher = class {
|
|
|
643
661
|
isRetweet: t.isRetweet,
|
|
644
662
|
isVideo: t.isVideo
|
|
645
663
|
})),
|
|
646
|
-
wordContent: latest.length ? latest[0].
|
|
664
|
+
wordContent: latest.length ? latest[0].wordContent : ""
|
|
647
665
|
};
|
|
648
666
|
});
|
|
649
667
|
return result;
|
|
650
668
|
} catch (error) {
|
|
651
|
-
|
|
669
|
+
logger7.error(`抓取最新推文失败(第 ${attempt} 次): ${username}`, error);
|
|
652
670
|
if (attempt >= maxRetries) return { tweets: [], wordContent: "" };
|
|
653
671
|
await this.ctx.sleep(2e3 * attempt);
|
|
654
672
|
} finally {
|
|
655
673
|
if (page) await page.close().catch(() => {
|
|
656
674
|
});
|
|
675
|
+
page = void 0;
|
|
657
676
|
}
|
|
658
677
|
}
|
|
659
678
|
return { tweets: [], wordContent: "" };
|
|
@@ -677,7 +696,7 @@ var XFetcher = class {
|
|
|
677
696
|
else req.continue();
|
|
678
697
|
});
|
|
679
698
|
await preparePage(page, this.config.cookies);
|
|
680
|
-
await page.goto(tweetUrl, { waitUntil: "
|
|
699
|
+
await page.goto(tweetUrl, { waitUntil: "domcontentloaded", timeout: 6e4 });
|
|
681
700
|
await waitForArticle(page, 3e4);
|
|
682
701
|
protectedAccount = await page.evaluate(() => {
|
|
683
702
|
return !!(document.querySelector('[data-testid="icon-lock"]') || document.querySelector('[aria-label*="rotect"]') || document.querySelector('[aria-label*="保护"]') || document.querySelector('[aria-label*="保護"]'));
|
|
@@ -701,7 +720,8 @@ var XFetcher = class {
|
|
|
701
720
|
}
|
|
702
721
|
const apiUrl = buildVxApiUrl(tweetUrl);
|
|
703
722
|
const apiResponse = await this.ctx.http.get(apiUrl, {
|
|
704
|
-
headers: { "User-Agent": UA }
|
|
723
|
+
headers: { "User-Agent": UA },
|
|
724
|
+
timeout: 15e3
|
|
705
725
|
});
|
|
706
726
|
const mediaExt = apiResponse && apiResponse.media_extended || [];
|
|
707
727
|
const altTexts = mediaExt.filter((m) => m && m.altText && String(m.altText).trim()).map((m) => String(m.altText).trim());
|
|
@@ -721,7 +741,7 @@ var XFetcher = class {
|
|
|
721
741
|
protectedAccount
|
|
722
742
|
};
|
|
723
743
|
} catch (error) {
|
|
724
|
-
|
|
744
|
+
logger7.error(`抓取推文详情失败(第 ${attempt} 次): ${tweetUrlInput}`, error);
|
|
725
745
|
if (attempt >= maxRetries) {
|
|
726
746
|
return {
|
|
727
747
|
url: tweetUrlInput,
|
|
@@ -735,6 +755,7 @@ var XFetcher = class {
|
|
|
735
755
|
} finally {
|
|
736
756
|
if (page) await page.close().catch(() => {
|
|
737
757
|
});
|
|
758
|
+
page = void 0;
|
|
738
759
|
}
|
|
739
760
|
}
|
|
740
761
|
return {
|
|
@@ -748,43 +769,43 @@ var XFetcher = class {
|
|
|
748
769
|
};
|
|
749
770
|
|
|
750
771
|
// src/config.ts
|
|
751
|
-
var
|
|
752
|
-
var Config =
|
|
753
|
-
|
|
754
|
-
cookies:
|
|
755
|
-
updateInterval:
|
|
756
|
-
messagePrefix:
|
|
757
|
-
screenshot:
|
|
772
|
+
var import_koishi8 = require("koishi");
|
|
773
|
+
var Config = import_koishi8.Schema.intersect([
|
|
774
|
+
import_koishi8.Schema.object({
|
|
775
|
+
cookies: import_koishi8.Schema.string().required(),
|
|
776
|
+
updateInterval: import_koishi8.Schema.number().min(1).default(5),
|
|
777
|
+
messagePrefix: import_koishi8.Schema.string().default("获取了"),
|
|
778
|
+
screenshot: import_koishi8.Schema.boolean().default(true)
|
|
758
779
|
}).description("基础设置"),
|
|
759
|
-
|
|
760
|
-
translateEnabled:
|
|
780
|
+
import_koishi8.Schema.object({
|
|
781
|
+
translateEnabled: import_koishi8.Schema.boolean().default(false)
|
|
761
782
|
}).description("翻译设置"),
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
translateEnabled:
|
|
765
|
-
apiKey:
|
|
766
|
-
apiUrl:
|
|
767
|
-
model:
|
|
768
|
-
systemPrompt:
|
|
783
|
+
import_koishi8.Schema.union([
|
|
784
|
+
import_koishi8.Schema.object({
|
|
785
|
+
translateEnabled: import_koishi8.Schema.const(true).required(),
|
|
786
|
+
apiKey: import_koishi8.Schema.string().required(),
|
|
787
|
+
apiUrl: import_koishi8.Schema.string().default("https://api.deepseek.com"),
|
|
788
|
+
model: import_koishi8.Schema.string().default("deepseek-chat"),
|
|
789
|
+
systemPrompt: import_koishi8.Schema.string().role("textarea").default(
|
|
769
790
|
"你是一名推文翻译专员,负责将推文翻译为简体中文。规则:\n1. 直接输出翻译结果,不添加任何前缀、解释或注释\n2. 保留原文中的 @用户名、#话题标签、URL 不翻译\n3. 保留原文的 emoji\n4. 不要修改标点符号的使用习惯\n{glossary}"
|
|
770
791
|
),
|
|
771
|
-
prompt:
|
|
772
|
-
term:
|
|
792
|
+
prompt: import_koishi8.Schema.string().role("textarea").default("{text}"),
|
|
793
|
+
term: import_koishi8.Schema.dict(String).role("table").default({})
|
|
773
794
|
}),
|
|
774
|
-
|
|
795
|
+
import_koishi8.Schema.object({})
|
|
775
796
|
]),
|
|
776
|
-
|
|
777
|
-
subscriptions:
|
|
778
|
-
|
|
779
|
-
id:
|
|
780
|
-
targets:
|
|
781
|
-
blacklist:
|
|
797
|
+
import_koishi8.Schema.object({
|
|
798
|
+
subscriptions: import_koishi8.Schema.array(
|
|
799
|
+
import_koishi8.Schema.object({
|
|
800
|
+
id: import_koishi8.Schema.string().required(),
|
|
801
|
+
targets: import_koishi8.Schema.array(String).role("table").default([]),
|
|
802
|
+
blacklist: import_koishi8.Schema.array(import_koishi8.Schema.string()).default([])
|
|
782
803
|
})
|
|
783
804
|
).default([])
|
|
784
805
|
}).description("订阅列表"),
|
|
785
|
-
|
|
786
|
-
outputLogs:
|
|
787
|
-
detectXLinks:
|
|
806
|
+
import_koishi8.Schema.object({
|
|
807
|
+
outputLogs: import_koishi8.Schema.boolean().default(true),
|
|
808
|
+
detectXLinks: import_koishi8.Schema.boolean().default(true)
|
|
788
809
|
}).description("调试设置")
|
|
789
810
|
]).i18n({
|
|
790
811
|
"en-US": en_US_default._config,
|
|
@@ -793,7 +814,7 @@ var Config = import_koishi7.Schema.intersect([
|
|
|
793
814
|
|
|
794
815
|
// src/index.ts
|
|
795
816
|
var name = "xlon";
|
|
796
|
-
var
|
|
817
|
+
var logger8 = new import_koishi9.Logger("xlon");
|
|
797
818
|
var inject = {
|
|
798
819
|
required: ["database"],
|
|
799
820
|
optional: ["puppeteer"]
|
|
@@ -801,14 +822,14 @@ var inject = {
|
|
|
801
822
|
async function sendToTarget(ctx, target, content) {
|
|
802
823
|
const idx = target.indexOf(":");
|
|
803
824
|
if (idx < 0) {
|
|
804
|
-
|
|
825
|
+
logger8.warn(`无效 target 格式(应为 platform:channelId):${target}`);
|
|
805
826
|
return;
|
|
806
827
|
}
|
|
807
828
|
const platform = target.slice(0, idx);
|
|
808
829
|
const channelId = target.slice(idx + 1);
|
|
809
830
|
const bot = ctx.bots.find((b) => b.platform === platform);
|
|
810
831
|
if (!bot) {
|
|
811
|
-
|
|
832
|
+
logger8.warn(`未找到平台 ${platform} 上的 bot,跳过 target: ${target}`);
|
|
812
833
|
return;
|
|
813
834
|
}
|
|
814
835
|
await bot.sendMessage(channelId, content);
|
|
@@ -841,7 +862,7 @@ function apply(ctx, config) {
|
|
|
841
862
|
});
|
|
842
863
|
if (hit.length) {
|
|
843
864
|
if (config.outputLogs)
|
|
844
|
-
|
|
865
|
+
logger8.info(`命中违禁词,跳过推送:${opts.username} -> ${hit.join(", ")}`);
|
|
845
866
|
return;
|
|
846
867
|
}
|
|
847
868
|
}
|
|
@@ -858,7 +879,7 @@ function apply(ctx, config) {
|
|
|
858
879
|
await session.send(session.text("messages.empty-url"));
|
|
859
880
|
return;
|
|
860
881
|
}
|
|
861
|
-
if (config.outputLogs)
|
|
882
|
+
if (config.outputLogs) logger8.info("处理推文链接:", u);
|
|
862
883
|
await session.send(session.text("messages.fetching"));
|
|
863
884
|
await processAndSend((content) => session.send(content), u);
|
|
864
885
|
}
|
|
@@ -878,7 +899,7 @@ function apply(ctx, config) {
|
|
|
878
899
|
await session.send(session.text("messages.empty-url"));
|
|
879
900
|
return;
|
|
880
901
|
}
|
|
881
|
-
if (config.outputLogs)
|
|
902
|
+
if (config.outputLogs) logger8.info("按用户名处理最新推文:", username);
|
|
882
903
|
await session.send(session.text("messages.fetching"));
|
|
883
904
|
const latest = await fetcher.getLatestTweet(username);
|
|
884
905
|
const first = latest.tweets[0];
|
|
@@ -901,7 +922,7 @@ function apply(ctx, config) {
|
|
|
901
922
|
try {
|
|
902
923
|
await sendToTarget(ctx, target, content);
|
|
903
924
|
} catch (e) {
|
|
904
|
-
|
|
925
|
+
logger8.error(`推送到 ${target} 失败`, e);
|
|
905
926
|
}
|
|
906
927
|
}
|
|
907
928
|
}, "send");
|
|
@@ -925,6 +946,5 @@ __name(apply, "apply");
|
|
|
925
946
|
Config,
|
|
926
947
|
apply,
|
|
927
948
|
inject,
|
|
928
|
-
logger,
|
|
929
949
|
name
|
|
930
950
|
});
|
|
@@ -2,7 +2,7 @@ import { Context, h } from 'koishi';
|
|
|
2
2
|
import { Config } from '../config';
|
|
3
3
|
import { TweetDetail } from '../types';
|
|
4
4
|
export interface MessageSegment {
|
|
5
|
-
content: string | ReturnType<typeof h.
|
|
5
|
+
content: string | ReturnType<typeof h.video>;
|
|
6
6
|
}
|
|
7
7
|
export interface BuildOptions {
|
|
8
8
|
username?: string;
|