koishi-plugin-xlon 1.0.3 → 1.1.1

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/config.d.ts CHANGED
@@ -22,5 +22,7 @@ export interface Config {
22
22
  subscriptions: SubscriptionItem[];
23
23
  outputLogs: boolean;
24
24
  detectXLinks: boolean;
25
+ /** 抓取失败时是否保存网页截图(保存至 {baseDir}/data/xlon/debug) */
26
+ debugScreenshot: boolean;
25
27
  }
26
28
  export declare const Config: Schema<Schemastery.ObjectS<{}>, {} & import("cosmokit").Dict>;
@@ -0,0 +1,2 @@
1
+ /** 对外请求统一使用的 User-Agent */
2
+ export declare const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36";
@@ -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, Logger } from 'koishi';
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
@@ -1,6 +1,8 @@
1
+ var __create = Object.create;
1
2
  var __defProp = Object.defineProperty;
2
3
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
3
4
  var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __getProtoOf = Object.getPrototypeOf;
4
6
  var __hasOwnProp = Object.prototype.hasOwnProperty;
5
7
  var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
6
8
  var __export = (target, all) => {
@@ -15,6 +17,14 @@ var __copyProps = (to, from, except, desc) => {
15
17
  }
16
18
  return to;
17
19
  };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
18
28
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
29
 
20
30
  // src/index.ts
@@ -23,17 +33,16 @@ __export(src_exports, {
23
33
  Config: () => Config,
24
34
  apply: () => apply,
25
35
  inject: () => inject,
26
- logger: () => logger7,
27
36
  name: () => name
28
37
  });
29
38
  module.exports = __toCommonJS(src_exports);
30
- var import_koishi8 = require("koishi");
39
+ var import_koishi9 = require("koishi");
31
40
 
32
41
  // 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} 失败(可能网络/用户名问题)。" } };
42
+ 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
43
 
35
44
  // 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)." } };
45
+ 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
46
 
38
47
  // src/db/model.ts
39
48
  var import_koishi = require("koishi");
@@ -51,7 +60,6 @@ function extendDatabase(ctx) {
51
60
  autoInc: false
52
61
  }
53
62
  );
54
- logger.info("数据库初始化成功");
55
63
  }
56
64
  __name(extendDatabase, "extendDatabase");
57
65
 
@@ -77,52 +85,61 @@ var XlonRepo = class {
77
85
  };
78
86
 
79
87
  // src/features/commands/index.ts
88
+ var import_koishi2 = require("koishi");
89
+ var logger2 = new import_koishi2.Logger("xlon/commands");
80
90
  function registerCommands(ctx, config, deps) {
81
91
  ctx.command("x.check").alias("check").action(async ({ session }) => {
82
- await session.send(session.text("messages.checking"));
83
- await deps.checkOnce(session);
92
+ try {
93
+ await session.send(session.text("messages.checking"));
94
+ await deps.checkOnce(session);
95
+ } catch (e) {
96
+ logger2.error("x.check 命令执行失败", e);
97
+ await session.send(session.text("messages.error"));
98
+ }
84
99
  });
85
100
  ctx.command("x.tweet <input:text>").alias("tweet").action(async ({ session }, input) => {
86
- await deps.handleTweetInput(session, input || "");
101
+ try {
102
+ await deps.handleTweetInput(session, input || "");
103
+ } catch (e) {
104
+ logger2.error("x.tweet 命令执行失败", e);
105
+ await session.send(session.text("messages.error"));
106
+ }
87
107
  });
88
108
  }
89
109
  __name(registerCommands, "registerCommands");
90
110
 
91
111
  // src/features/link-detector.ts
92
- var import_koishi2 = require("koishi");
93
- var logger2 = new import_koishi2.Logger("xlon/link-detector");
94
- var urlRe = /((https?:\/\/)?[^\s'"\)]+\.[^\s'"\)]+)/g;
112
+ var import_koishi3 = require("koishi");
113
+ var logger3 = new import_koishi3.Logger("xlon/link-detector");
114
+ var urlRe = /https?:\/\/[^\s'")\]]+/g;
95
115
  function extractUrls(text) {
96
116
  const matches = text.match(urlRe) || [];
97
- return matches.map((m) => m.replace(/[\u3002\uFF0C\uFF1F\uFF01\.,!?,。?!、]+$/g, ""));
117
+ return matches.map((m) => m.replace(/[。,?!.,!?,。?!、]+$/g, ""));
98
118
  }
99
119
  __name(extractUrls, "extractUrls");
100
120
  function isXDomain(urlStr) {
101
121
  try {
102
- const u = new URL(urlStr.includes("://") ? urlStr : `https://${urlStr}`);
122
+ const u = new URL(urlStr);
103
123
  const hn = (u.hostname || "").toLowerCase();
104
124
  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 (_) {
125
+ } catch {
106
126
  return false;
107
127
  }
108
128
  }
109
129
  __name(isXDomain, "isXDomain");
110
- async function expandShortLink(ctx, url) {
130
+ async function expandShortLink(url) {
111
131
  try {
112
- const axios = ctx.http.axios ?? ctx.http;
113
- const res = await axios.get(url, {
114
- maxRedirects: 0,
115
- validateStatus: /* @__PURE__ */ __name(() => true, "validateStatus")
132
+ const controller = new AbortController();
133
+ const timer = setTimeout(() => controller.abort(), 5e3);
134
+ const res = await fetch(url, {
135
+ method: "HEAD",
136
+ redirect: "follow",
137
+ signal: controller.signal
116
138
  });
117
- const location2 = res?.headers?.location;
118
- return location2 || url;
119
- } catch (err) {
120
- try {
121
- const location2 = err?.response?.headers?.location;
122
- return location2 || url;
123
- } catch (_) {
124
- return url;
125
- }
139
+ clearTimeout(timer);
140
+ return res.url || url;
141
+ } catch {
142
+ return url;
126
143
  }
127
144
  }
128
145
  __name(expandShortLink, "expandShortLink");
@@ -136,27 +153,26 @@ function registerLinkDetector(ctx, config, handler) {
136
153
  if (!candidates.length) return next();
137
154
  const found = [];
138
155
  for (const c of candidates) {
139
- const normalized = c.startsWith("http") ? c : `https://${c}`;
140
- if (/^https?:\/\/t\.co\//i.test(normalized)) {
141
- const exp = await expandShortLink(ctx, normalized);
156
+ if (/^https?:\/\/t\.co\//i.test(c)) {
157
+ const exp = await expandShortLink(c);
142
158
  if (isXDomain(exp)) found.push(exp);
143
- } else if (isXDomain(normalized)) {
144
- found.push(normalized);
159
+ } else if (isXDomain(c)) {
160
+ found.push(c);
145
161
  }
146
162
  if (found.length >= 3) break;
147
163
  }
148
164
  if (found.length && config.outputLogs) {
149
- logger2.info("检测到 X/Twitter 链接:", found);
165
+ logger3.info("检测到 X/Twitter 链接:", found);
150
166
  }
151
167
  for (const link of found) {
152
168
  try {
153
169
  await handler(session, link);
154
170
  } catch (e) {
155
- logger2.error("处理检测到的 X/Twitter 链接时出错", e);
171
+ logger3.error("处理检测到的 X/Twitter 链接时出错", e);
156
172
  }
157
173
  }
158
174
  } catch (err) {
159
- logger2.error("X/Twitter 链接检测失败", err);
175
+ logger3.error("X/Twitter 链接检测失败", err);
160
176
  }
161
177
  return next();
162
178
  });
@@ -164,8 +180,8 @@ function registerLinkDetector(ctx, config, handler) {
164
180
  __name(registerLinkDetector, "registerLinkDetector");
165
181
 
166
182
  // src/features/translator.ts
167
- var import_koishi3 = require("koishi");
168
- var logger3 = new import_koishi3.Logger("xlon/translator");
183
+ var import_koishi4 = require("koishi");
184
+ var logger4 = new import_koishi4.Logger("xlon/translator");
169
185
  function buildGlossaryBlock(term) {
170
186
  const entries = Object.entries(term || {}).filter(([k]) => !!k);
171
187
  if (!entries.length) return "";
@@ -208,24 +224,20 @@ function validateTranslation(original, translated) {
208
224
  if (original.length > 30 && translated.length < original.length * 0.15) {
209
225
  return { ok: false, reason: `翻译结果疑似截断(原文 ${original.length} 字 → 译文 ${translated.length} 字)` };
210
226
  }
211
- for (let winLen = 8; winLen <= Math.min(30, Math.floor(translated.length / 3)); winLen++) {
212
- for (let i = 0; i <= translated.length - winLen * 3; i++) {
213
- const seg = translated.slice(i, i + winLen);
227
+ const sample = translated.length > 1500 ? translated.slice(0, 1500) : translated;
228
+ for (let winLen = 8; winLen <= Math.min(30, Math.floor(sample.length / 3)); winLen++) {
229
+ for (let i = 0; i + winLen * 3 <= sample.length; i++) {
230
+ if (sample.slice(i + winLen, i + winLen * 2) !== sample.slice(i, i + winLen)) continue;
231
+ if (sample.slice(i + winLen * 2, i + winLen * 3) !== sample.slice(i, i + winLen)) continue;
232
+ const seg = sample.slice(i, i + winLen);
214
233
  if (/^[\s\p{P}]+$/u.test(seg)) continue;
215
- const rest = translated.slice(i + winLen);
216
- let count = 1;
217
- let pos = 0;
218
- while (pos <= rest.length - winLen) {
219
- if (rest.slice(pos, pos + winLen) === seg) {
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} 次` };
234
+ let count = 3;
235
+ let j = i + winLen * 3;
236
+ while (j + winLen <= sample.length && sample.slice(j, j + winLen) === seg) {
237
+ count++;
238
+ j += winLen;
228
239
  }
240
+ return { ok: false, reason: `翻译结果包含连续重复片段:"${seg.slice(0, 20)}…" 重复 ${count} 次` };
229
241
  }
230
242
  }
231
243
  return { ok: true };
@@ -245,13 +257,16 @@ async function callTranslateAPI(ctx, config, opts) {
245
257
  ],
246
258
  stream: false
247
259
  };
248
- const response = await ctx.http.post(url, data, { headers });
260
+ const response = await ctx.http.post(url, data, { headers, timeout: 3e4 });
249
261
  return String(response?.choices?.[0]?.message?.content ?? "").trim();
250
262
  }
251
263
  __name(callTranslateAPI, "callTranslateAPI");
252
264
  async function translateIfEnabled(ctx, config, text) {
253
265
  if (!config.translateEnabled) return text;
254
- if (!config.apiKey) return text;
266
+ if (!config.apiKey) {
267
+ logger4.warn("translateEnabled 已开启,但 apiKey 未配置,跳过翻译");
268
+ return text;
269
+ }
255
270
  if (!text?.trim()) return text;
256
271
  const glossaryBlock = buildGlossaryBlock(config.term);
257
272
  const systemContent = injectGlossary(config.systemPrompt || "", glossaryBlock);
@@ -264,10 +279,10 @@ async function translateIfEnabled(ctx, config, text) {
264
279
  });
265
280
  const v1 = validateTranslation(text, translated);
266
281
  if (v1.ok) {
267
- if (config.outputLogs) logger3.info("翻译通过退化检测");
282
+ if (config.outputLogs) logger4.info("翻译通过退化检测");
268
283
  return applyTermMap(translated, config.term);
269
284
  }
270
- logger3.warn(`翻译退化(${v1.reason}),使用简化 prompt 重试`);
285
+ logger4.warn(`翻译退化(${v1.reason}),使用简化 prompt 重试`);
271
286
  const retrySystem = "你是翻译助手。直接输出简体中文翻译,不要添加任何额外内容。";
272
287
  const retryUser = text;
273
288
  try {
@@ -278,33 +293,37 @@ async function translateIfEnabled(ctx, config, text) {
278
293
  });
279
294
  const v2 = validateTranslation(text, retried);
280
295
  if (v2.ok) {
281
- if (config.outputLogs) logger3.info("重试翻译通过退化检测");
296
+ if (config.outputLogs) logger4.info("重试翻译通过退化检测");
282
297
  return applyTermMap(retried, config.term);
283
298
  }
284
- logger3.warn(`重试翻译仍退化(${v2.reason}),回退原文`);
299
+ logger4.warn(`重试翻译仍退化(${v2.reason}),回退原文`);
285
300
  return text;
286
301
  } catch (retryErr) {
287
- logger3.error("重试翻译 API 调用失败", retryErr);
302
+ logger4.error("重试翻译 API 调用失败", retryErr);
288
303
  return text;
289
304
  }
290
305
  } catch (err) {
291
- logger3.error("翻译 API 调用失败", err);
306
+ logger4.error("翻译 API 调用失败", err);
292
307
  return text;
293
308
  }
294
309
  }
295
310
  __name(translateIfEnabled, "translateIfEnabled");
296
311
 
297
312
  // src/service/message-builder.ts
298
- var import_koishi4 = require("koishi");
299
- var logger4 = new import_koishi4.Logger("xlon/message-builder");
313
+ var import_koishi5 = require("koishi");
314
+
315
+ // src/constants.ts
316
+ 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";
317
+
318
+ // src/service/message-builder.ts
319
+ var logger5 = new import_koishi5.Logger("xlon/message-builder");
300
320
  async function fetchBinary(ctx, url, maxRetries = 3) {
301
321
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
302
322
  try {
303
323
  return await ctx.http.get(url, {
304
324
  responseType: "arraybuffer",
305
- headers: {
306
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
307
- }
325
+ headers: { "User-Agent": UA },
326
+ timeout: 3e4
308
327
  });
309
328
  } catch (e) {
310
329
  if (attempt >= maxRetries) throw e;
@@ -318,13 +337,15 @@ async function buildTweetMessages(ctx, config, detail, translated, opts) {
318
337
  const altOriginalText = detail.altTexts && detail.altTexts.length ? "\n" + detail.altTexts.map(
319
338
  (alt, i) => `[图片${detail.altTexts.length > 1 ? i + 1 : ""}描述原文: ${alt}]`
320
339
  ).join("\n") : "";
321
- const isVideo = detail.mediaUrls?.some((u) => u.endsWith(".mp4"));
340
+ const hasMedia = (detail.mediaUrls?.length ?? 0) > 0;
341
+ const hasVideo = detail.mediaUrls?.some((u) => u.endsWith(".mp4")) ?? false;
342
+ const mediaLabel = !hasMedia ? "文字" : hasVideo ? "视频" : "图片";
322
343
  const prefixUser = opts?.username ? `【${opts.username}】 ` : "";
323
344
  const retweetNote = opts?.isRetweet ? "[转发推文]\n" : "";
324
- let textMsg = `${prefixUser}${config.messagePrefix}一条${isVideo ? "视频" : "图片"}推文:
345
+ let textMsg = `${prefixUser}${config.messagePrefix}一条${mediaLabel}推文:
325
346
  ${translated}${altOriginalText}
326
347
  ${retweetNote}`;
327
- if (detail.screenshotBuffer) textMsg += `${import_koishi4.h.image(detail.screenshotBuffer, "image/webp")}`;
348
+ if (detail.screenshotBuffer) textMsg += `${import_koishi5.h.image(detail.screenshotBuffer, "image/webp")}`;
328
349
  segments.push({ content: textMsg });
329
350
  const imageUrls = (detail.mediaUrls || []).filter((u) => !u.endsWith(".mp4"));
330
351
  if (imageUrls.length) {
@@ -332,20 +353,20 @@ ${retweetNote}`;
332
353
  for (const imgUrl of imageUrls) {
333
354
  try {
334
355
  const bin = await fetchBinary(ctx, imgUrl);
335
- images.push(import_koishi4.h.image(bin, "image/jpeg"));
356
+ images.push(String(import_koishi5.h.image(bin, "image/jpeg")));
336
357
  } catch (e) {
337
- logger4.error(`下载图片失败:${imgUrl}`, e);
358
+ logger5.error(`下载图片失败:${imgUrl}`, e);
338
359
  }
339
360
  }
340
- if (images.length) segments.push({ content: images.join("\n") });
361
+ if (images.length) segments.push({ content: images.join("") });
341
362
  }
342
363
  const videoUrl = (detail.mediaUrls || []).find((u) => u.endsWith(".mp4"));
343
364
  if (videoUrl) {
344
365
  try {
345
366
  const bin = await fetchBinary(ctx, videoUrl);
346
- segments.push({ content: import_koishi4.h.video(bin, "video/mp4") });
367
+ segments.push({ content: import_koishi5.h.video(bin, "video/mp4") });
347
368
  } catch (e) {
348
- logger4.error(`下载视频失败:${videoUrl}`, e);
369
+ logger5.error(`下载视频失败:${videoUrl}`, e);
349
370
  }
350
371
  }
351
372
  return segments;
@@ -353,28 +374,32 @@ ${retweetNote}`;
353
374
  __name(buildTweetMessages, "buildTweetMessages");
354
375
 
355
376
  // src/scheduler/poller.ts
356
- var import_koishi5 = require("koishi");
357
- var logger5 = new import_koishi5.Logger("xlon/poller");
377
+ var import_koishi6 = require("koishi");
378
+ var logger6 = new import_koishi6.Logger("xlon/poller");
358
379
  function createPoller(ctx, config, repo, fetcher, deps) {
359
380
  let checking = false;
360
381
  async function init() {
361
382
  const existingIds = new Set(await repo.listIds());
362
383
  const newSubs = config.subscriptions.filter((s) => !existingIds.has(s.id));
363
384
  if (config.outputLogs) {
364
- logger5.info(`[初始化] 已存在订阅:${Array.from(existingIds).join(", ")}`);
365
- logger5.info(`[初始化] 需要初始化:${newSubs.map((s) => s.id).join(", ")}`);
385
+ logger6.info(`[初始化] 已存在订阅:${Array.from(existingIds).join(", ")}`);
386
+ logger6.info(`[初始化] 需要初始化:${newSubs.map((s) => s.id).join(", ")}`);
366
387
  }
367
- for (const sub of newSubs) {
388
+ for (let i = 0; i < newSubs.length; i++) {
389
+ const sub = newSubs[i];
368
390
  try {
369
391
  const latest = await fetcher.getLatestTweet(sub.id);
370
392
  const link = latest.tweets[0]?.link;
371
393
  if (!link) continue;
372
394
  await repo.upsert({ id: sub.id, link, content: latest.wordContent || "" });
373
395
  } catch (e) {
374
- logger5.error(`[初始化] 获取 ${sub.id} 最新推文失败(请检查用户名/网络)`, e);
396
+ logger6.error(`[初始化] 获取 ${sub.id} 最新推文失败(请检查用户名/网络)`, e);
397
+ }
398
+ if (i < newSubs.length - 1) {
399
+ await ctx.sleep(1e3 + Math.random() * 2e3);
375
400
  }
376
401
  }
377
- logger5.info("初始化完成");
402
+ logger6.info("初始化完成");
378
403
  }
379
404
  __name(init, "init");
380
405
  async function checkOnce(session) {
@@ -384,7 +409,8 @@ function createPoller(ctx, config, repo, fetcher, deps) {
384
409
  }
385
410
  checking = true;
386
411
  try {
387
- for (const sub of config.subscriptions) {
412
+ for (let i = 0; i < config.subscriptions.length; i++) {
413
+ const sub = config.subscriptions[i];
388
414
  const username = sub.id;
389
415
  try {
390
416
  const latest = await fetcher.getLatestTweet(username);
@@ -392,24 +418,29 @@ function createPoller(ctx, config, repo, fetcher, deps) {
392
418
  if (!latestLink) continue;
393
419
  const stored = await repo.get(username);
394
420
  const storedLink = stored?.link || "";
395
- if (!storedLink || storedLink !== latestLink) {
396
- await repo.upsert({
397
- id: username,
398
- link: latestLink,
399
- content: latest.wordContent || ""
400
- });
421
+ await repo.upsert({
422
+ id: username,
423
+ link: latestLink,
424
+ content: latest.wordContent || ""
425
+ });
426
+ if (!storedLink) {
427
+ if (config.outputLogs) logger6.info(`[轮询] 首次记录基准:${username}`);
428
+ } else if (storedLink !== latestLink) {
401
429
  await deps.pushTweet({
402
430
  username,
403
431
  url: latestLink,
404
432
  isRetweet: latest.tweets[0]?.isRetweet || false
405
433
  });
406
434
  } else if (config.outputLogs) {
407
- logger5.info(`已是最新:${username}`);
435
+ logger6.info(`已是最新:${username}`);
408
436
  }
409
437
  } catch (e) {
410
- logger5.error(`轮询 ${username} 失败`, e);
438
+ logger6.error(`轮询 ${username} 失败`, e);
411
439
  if (session) await session.send(session.text("messages.poll-fail", [username]));
412
440
  }
441
+ if (i < config.subscriptions.length - 1) {
442
+ await ctx.sleep(1e3 + Math.random() * 2e3);
443
+ }
413
444
  }
414
445
  } finally {
415
446
  checking = false;
@@ -418,7 +449,7 @@ function createPoller(ctx, config, repo, fetcher, deps) {
418
449
  __name(checkOnce, "checkOnce");
419
450
  function start() {
420
451
  const ms = Math.max(1, config.updateInterval) * 60 * 1e3;
421
- ctx.setInterval(() => checkOnce().catch((e) => logger5.error("定时任务异常", e)), ms);
452
+ ctx.setInterval(() => checkOnce().catch((e) => logger6.error("定时任务异常", e)), ms);
422
453
  }
423
454
  __name(start, "start");
424
455
  return { init, checkOnce, start };
@@ -426,9 +457,42 @@ function createPoller(ctx, config, repo, fetcher, deps) {
426
457
  __name(createPoller, "createPoller");
427
458
 
428
459
  // src/service/fetcher.ts
429
- var import_koishi6 = require("koishi");
430
- var logger6 = new import_koishi6.Logger("xlon/fetcher");
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";
460
+ var fs = __toESM(require("fs"));
461
+ var path = __toESM(require("path"));
462
+ var import_koishi7 = require("koishi");
463
+ var logger7 = new import_koishi7.Logger("xlon/fetcher");
464
+ var DEBUG_SCREENSHOT_MAX = 3;
465
+ async function saveFailureScreenshot(page, username, attempt, dir) {
466
+ if (!dir || !page) return;
467
+ try {
468
+ await fs.promises.mkdir(dir, { recursive: true });
469
+ const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
470
+ const filename = `xlon-fail-${username}-attempt${attempt}-${ts}.png`;
471
+ const filepath = path.join(dir, filename);
472
+ const buf = await page.screenshot({ type: "png", fullPage: true });
473
+ await fs.promises.writeFile(filepath, buf);
474
+ logger7.info(`[debug] 已保存失败截图: ${filepath}`);
475
+ const entries = await fs.promises.readdir(dir);
476
+ const debugFiles = entries.filter(
477
+ (f) => f.startsWith("xlon-fail-") && f.endsWith(".png")
478
+ );
479
+ if (debugFiles.length > DEBUG_SCREENSHOT_MAX) {
480
+ const withStat = await Promise.all(
481
+ debugFiles.map(async (f) => ({
482
+ name: f,
483
+ mtime: (await fs.promises.stat(path.join(dir, f))).mtimeMs
484
+ }))
485
+ );
486
+ withStat.sort((a, b) => a.mtime - b.mtime).slice(0, debugFiles.length - DEBUG_SCREENSHOT_MAX).forEach(
487
+ ({ name: name2 }) => fs.promises.unlink(path.join(dir, name2)).catch(() => {
488
+ })
489
+ );
490
+ }
491
+ } catch (err) {
492
+ logger7.warn(`[debug] 保存失败截图时出错: ${err}`);
493
+ }
494
+ }
495
+ __name(saveFailureScreenshot, "saveFailureScreenshot");
432
496
  function toAbsoluteUrl(input) {
433
497
  const raw = (input || "").trim();
434
498
  if (!raw) return raw;
@@ -490,14 +554,13 @@ async function screenshotTweetArticle(page, selector = 'article[data-testid="twe
490
554
  return imgsLoaded && videosLoaded;
491
555
  },
492
556
  { timeout: 2e4 },
493
- // 增加到 20 秒
494
557
  selector
495
558
  ).catch((err) => {
496
- logger6.warn(`媒体加载超时: ${err.message}`);
559
+ logger7.warn(`媒体加载超时: ${err.message}`);
497
560
  return null;
498
561
  });
499
562
  if (!mediaLoadResult) {
500
- logger6.warn("部分媒体可能未完全加载,额外等待 2 秒");
563
+ logger7.warn("部分媒体可能未完全加载,额外等待 2 秒");
501
564
  await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 2e3)));
502
565
  }
503
566
  await page.evaluate(() => {
@@ -556,7 +619,6 @@ async function preparePage(page, cookies) {
556
619
  secure: true
557
620
  });
558
621
  await page.setUserAgent(UA);
559
- await page.setDefaultNavigationTimeout(6e4);
560
622
  await page.setDefaultTimeout(6e4);
561
623
  }
562
624
  __name(preparePage, "preparePage");
@@ -578,7 +640,7 @@ var XFetcher = class {
578
640
  */
579
641
  async getLatestTweet(username, maxRetries = 3) {
580
642
  if (!this.hasPuppeteer) {
581
- logger6.warn("puppeteer 不可用,无法获取最新推文");
643
+ logger7.warn("puppeteer 不可用,无法获取最新推文");
582
644
  return { tweets: [], wordContent: "" };
583
645
  }
584
646
  const url = `https://x.com/${username}`;
@@ -617,7 +679,7 @@ var XFetcher = class {
617
679
  const textEl = article.querySelector(
618
680
  'div[data-testid="tweetText"], div[lang]'
619
681
  );
620
- const word_content = (textEl && textEl.textContent ? textEl.textContent : "").trim();
682
+ const wordContent = (textEl && textEl.textContent ? textEl.textContent : "").trim();
621
683
  const timeEl = article.querySelector('a[href*="/status/"] time');
622
684
  const linkEl = timeEl && timeEl.parentElement || article.querySelector('a[href*="/status/"]');
623
685
  const href = linkEl && linkEl.getAttribute("href") || "";
@@ -634,7 +696,7 @@ var XFetcher = class {
634
696
  let absolute = href;
635
697
  if (absolute.startsWith("/")) absolute = "https://x.com" + absolute;
636
698
  if (!absolute.startsWith("http")) absolute = "https://x.com/" + absolute;
637
- collected.push({ link: absolute, isRetweet, isVideo, word_content });
699
+ collected.push({ link: absolute, isRetweet, isVideo, wordContent });
638
700
  }
639
701
  const latest = collected.slice(0, 1);
640
702
  return {
@@ -643,17 +705,22 @@ var XFetcher = class {
643
705
  isRetweet: t.isRetweet,
644
706
  isVideo: t.isVideo
645
707
  })),
646
- wordContent: latest.length ? latest[0].word_content : ""
708
+ wordContent: latest.length ? latest[0].wordContent : ""
647
709
  };
648
710
  });
649
711
  return result;
650
712
  } catch (error) {
651
- logger6.error(`抓取最新推文失败(第 ${attempt} 次): ${username}`, error);
713
+ logger7.error(`抓取最新推文失败(第 ${attempt} 次): ${username}`, error);
714
+ if (this.config.debugScreenshot && page) {
715
+ const debugDir = path.join(this.ctx.baseDir, "data", "xlon", "debug");
716
+ await saveFailureScreenshot(page, username, attempt, debugDir);
717
+ }
652
718
  if (attempt >= maxRetries) return { tweets: [], wordContent: "" };
653
719
  await this.ctx.sleep(2e3 * attempt);
654
720
  } finally {
655
721
  if (page) await page.close().catch(() => {
656
722
  });
723
+ page = void 0;
657
724
  }
658
725
  }
659
726
  return { tweets: [], wordContent: "" };
@@ -677,7 +744,7 @@ var XFetcher = class {
677
744
  else req.continue();
678
745
  });
679
746
  await preparePage(page, this.config.cookies);
680
- await page.goto(tweetUrl, { waitUntil: "networkidle2", timeout: 6e4 });
747
+ await page.goto(tweetUrl, { waitUntil: "domcontentloaded", timeout: 6e4 });
681
748
  await waitForArticle(page, 3e4);
682
749
  protectedAccount = await page.evaluate(() => {
683
750
  return !!(document.querySelector('[data-testid="icon-lock"]') || document.querySelector('[aria-label*="rotect"]') || document.querySelector('[aria-label*="保护"]') || document.querySelector('[aria-label*="保護"]'));
@@ -701,7 +768,8 @@ var XFetcher = class {
701
768
  }
702
769
  const apiUrl = buildVxApiUrl(tweetUrl);
703
770
  const apiResponse = await this.ctx.http.get(apiUrl, {
704
- headers: { "User-Agent": UA }
771
+ headers: { "User-Agent": UA },
772
+ timeout: 15e3
705
773
  });
706
774
  const mediaExt = apiResponse && apiResponse.media_extended || [];
707
775
  const altTexts = mediaExt.filter((m) => m && m.altText && String(m.altText).trim()).map((m) => String(m.altText).trim());
@@ -721,7 +789,7 @@ var XFetcher = class {
721
789
  protectedAccount
722
790
  };
723
791
  } catch (error) {
724
- logger6.error(`抓取推文详情失败(第 ${attempt} 次): ${tweetUrlInput}`, error);
792
+ logger7.error(`抓取推文详情失败(第 ${attempt} 次): ${tweetUrlInput}`, error);
725
793
  if (attempt >= maxRetries) {
726
794
  return {
727
795
  url: tweetUrlInput,
@@ -735,6 +803,7 @@ var XFetcher = class {
735
803
  } finally {
736
804
  if (page) await page.close().catch(() => {
737
805
  });
806
+ page = void 0;
738
807
  }
739
808
  }
740
809
  return {
@@ -748,43 +817,44 @@ var XFetcher = class {
748
817
  };
749
818
 
750
819
  // src/config.ts
751
- var import_koishi7 = require("koishi");
752
- var Config = import_koishi7.Schema.intersect([
753
- import_koishi7.Schema.object({
754
- cookies: import_koishi7.Schema.string().required(),
755
- updateInterval: import_koishi7.Schema.number().min(1).default(5),
756
- messagePrefix: import_koishi7.Schema.string().default("获取了"),
757
- screenshot: import_koishi7.Schema.boolean().default(true)
820
+ var import_koishi8 = require("koishi");
821
+ var Config = import_koishi8.Schema.intersect([
822
+ import_koishi8.Schema.object({
823
+ cookies: import_koishi8.Schema.string().required(),
824
+ updateInterval: import_koishi8.Schema.number().min(1).default(5),
825
+ messagePrefix: import_koishi8.Schema.string().default("获取了"),
826
+ screenshot: import_koishi8.Schema.boolean().default(true)
758
827
  }).description("基础设置"),
759
- import_koishi7.Schema.object({
760
- translateEnabled: import_koishi7.Schema.boolean().default(false)
828
+ import_koishi8.Schema.object({
829
+ translateEnabled: import_koishi8.Schema.boolean().default(false)
761
830
  }).description("翻译设置"),
762
- import_koishi7.Schema.union([
763
- import_koishi7.Schema.object({
764
- translateEnabled: import_koishi7.Schema.const(true).required(),
765
- apiKey: import_koishi7.Schema.string().required(),
766
- apiUrl: import_koishi7.Schema.string().default("https://api.deepseek.com"),
767
- model: import_koishi7.Schema.string().default("deepseek-chat"),
768
- systemPrompt: import_koishi7.Schema.string().role("textarea").default(
831
+ import_koishi8.Schema.union([
832
+ import_koishi8.Schema.object({
833
+ translateEnabled: import_koishi8.Schema.const(true).required(),
834
+ apiKey: import_koishi8.Schema.string().required(),
835
+ apiUrl: import_koishi8.Schema.string().default("https://api.deepseek.com"),
836
+ model: import_koishi8.Schema.string().default("deepseek-chat"),
837
+ systemPrompt: import_koishi8.Schema.string().role("textarea").default(
769
838
  "你是一名推文翻译专员,负责将推文翻译为简体中文。规则:\n1. 直接输出翻译结果,不添加任何前缀、解释或注释\n2. 保留原文中的 @用户名、#话题标签、URL 不翻译\n3. 保留原文的 emoji\n4. 不要修改标点符号的使用习惯\n{glossary}"
770
839
  ),
771
- prompt: import_koishi7.Schema.string().role("textarea").default("{text}"),
772
- term: import_koishi7.Schema.dict(String).role("table").default({})
840
+ prompt: import_koishi8.Schema.string().role("textarea").default("{text}"),
841
+ term: import_koishi8.Schema.dict(String).role("table").default({})
773
842
  }),
774
- import_koishi7.Schema.object({})
843
+ import_koishi8.Schema.object({})
775
844
  ]),
776
- import_koishi7.Schema.object({
777
- subscriptions: import_koishi7.Schema.array(
778
- import_koishi7.Schema.object({
779
- id: import_koishi7.Schema.string().required(),
780
- targets: import_koishi7.Schema.array(String).role("table").default([]),
781
- blacklist: import_koishi7.Schema.array(import_koishi7.Schema.string()).default([])
845
+ import_koishi8.Schema.object({
846
+ subscriptions: import_koishi8.Schema.array(
847
+ import_koishi8.Schema.object({
848
+ id: import_koishi8.Schema.string().required(),
849
+ targets: import_koishi8.Schema.array(String).role("table").default([]),
850
+ blacklist: import_koishi8.Schema.array(import_koishi8.Schema.string()).default([])
782
851
  })
783
852
  ).default([])
784
853
  }).description("订阅列表"),
785
- import_koishi7.Schema.object({
786
- outputLogs: import_koishi7.Schema.boolean().default(true),
787
- detectXLinks: import_koishi7.Schema.boolean().default(true)
854
+ import_koishi8.Schema.object({
855
+ outputLogs: import_koishi8.Schema.boolean().default(true),
856
+ detectXLinks: import_koishi8.Schema.boolean().default(true),
857
+ debugScreenshot: import_koishi8.Schema.boolean().default(false).description("抓取失败时保存网页截图(保存至 {baseDir}/data/xlon/debug,保留最近 3 张)")
788
858
  }).description("调试设置")
789
859
  ]).i18n({
790
860
  "en-US": en_US_default._config,
@@ -793,7 +863,7 @@ var Config = import_koishi7.Schema.intersect([
793
863
 
794
864
  // src/index.ts
795
865
  var name = "xlon";
796
- var logger7 = new import_koishi8.Logger("xlon");
866
+ var logger8 = new import_koishi9.Logger("xlon");
797
867
  var inject = {
798
868
  required: ["database"],
799
869
  optional: ["puppeteer"]
@@ -801,14 +871,14 @@ var inject = {
801
871
  async function sendToTarget(ctx, target, content) {
802
872
  const idx = target.indexOf(":");
803
873
  if (idx < 0) {
804
- logger7.warn(`无效 target 格式(应为 platform:channelId):${target}`);
874
+ logger8.warn(`无效 target 格式(应为 platform:channelId):${target}`);
805
875
  return;
806
876
  }
807
877
  const platform = target.slice(0, idx);
808
878
  const channelId = target.slice(idx + 1);
809
879
  const bot = ctx.bots.find((b) => b.platform === platform);
810
880
  if (!bot) {
811
- logger7.warn(`未找到平台 ${platform} 上的 bot,跳过 target: ${target}`);
881
+ logger8.warn(`未找到平台 ${platform} 上的 bot,跳过 target: ${target}`);
812
882
  return;
813
883
  }
814
884
  await bot.sendMessage(channelId, content);
@@ -841,7 +911,7 @@ function apply(ctx, config) {
841
911
  });
842
912
  if (hit.length) {
843
913
  if (config.outputLogs)
844
- logger7.info(`命中违禁词,跳过推送:${opts.username} -> ${hit.join(", ")}`);
914
+ logger8.info(`命中违禁词,跳过推送:${opts.username} -> ${hit.join(", ")}`);
845
915
  return;
846
916
  }
847
917
  }
@@ -858,7 +928,7 @@ function apply(ctx, config) {
858
928
  await session.send(session.text("messages.empty-url"));
859
929
  return;
860
930
  }
861
- if (config.outputLogs) logger7.info("处理推文链接:", u);
931
+ if (config.outputLogs) logger8.info("处理推文链接:", u);
862
932
  await session.send(session.text("messages.fetching"));
863
933
  await processAndSend((content) => session.send(content), u);
864
934
  }
@@ -878,7 +948,7 @@ function apply(ctx, config) {
878
948
  await session.send(session.text("messages.empty-url"));
879
949
  return;
880
950
  }
881
- if (config.outputLogs) logger7.info("按用户名处理最新推文:", username);
951
+ if (config.outputLogs) logger8.info("按用户名处理最新推文:", username);
882
952
  await session.send(session.text("messages.fetching"));
883
953
  const latest = await fetcher.getLatestTweet(username);
884
954
  const first = latest.tweets[0];
@@ -901,7 +971,7 @@ function apply(ctx, config) {
901
971
  try {
902
972
  await sendToTarget(ctx, target, content);
903
973
  } catch (e) {
904
- logger7.error(`推送到 ${target} 失败`, e);
974
+ logger8.error(`推送到 ${target} 失败`, e);
905
975
  }
906
976
  }
907
977
  }, "send");
@@ -925,6 +995,5 @@ __name(apply, "apply");
925
995
  Config,
926
996
  apply,
927
997
  inject,
928
- logger,
929
998
  name
930
999
  });
@@ -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.image> | ReturnType<typeof h.video>;
5
+ content: string | ReturnType<typeof h.video>;
6
6
  }
7
7
  export interface BuildOptions {
8
8
  username?: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-xlon",
3
3
  "description": "x subscriber",
4
- "version": "1.0.3",
4
+ "version": "1.1.1",
5
5
  "contributors": [
6
6
  "Logthm <logthm@outlook.com>"
7
7
  ],