koishi-plugin-xlon 1.0.2 → 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.
@@ -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
@@ -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 import_koishi8 = require("koishi");
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
- await session.send(session.text("messages.checking"));
83
- await deps.checkOnce(session);
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
- await deps.handleTweetInput(session, input || "");
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 import_koishi2 = require("koishi");
93
- var logger2 = new import_koishi2.Logger("xlon/link-detector");
94
- var urlRe = /((https?:\/\/)?[^\s'"\)]+\.[^\s'"\)]+)/g;
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(/[\u3002\uFF0C\uFF1F\uFF01\.,!?,。?!、]+$/g, ""));
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.includes("://") ? urlStr : `https://${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(ctx, url) {
120
+ async function expandShortLink(url) {
111
121
  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")
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
- const location = res?.headers?.location;
118
- return location || url;
119
- } catch (err) {
120
- try {
121
- const location = err?.response?.headers?.location;
122
- return location || 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
- const normalized = c.startsWith("http") ? c : `https://${c}`;
140
- if (/^https?:\/\/t\.co\//i.test(normalized)) {
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(normalized)) {
144
- found.push(normalized);
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
- logger2.info("检测到 X/Twitter 链接:", found);
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
- logger2.error("处理检测到的 X/Twitter 链接时出错", e);
161
+ logger3.error("处理检测到的 X/Twitter 链接时出错", e);
156
162
  }
157
163
  }
158
164
  } catch (err) {
159
- logger2.error("X/Twitter 链接检测失败", err);
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 import_koishi3 = require("koishi");
168
- var logger3 = new import_koishi3.Logger("xlon/translator");
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
- 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);
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
- 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} 次` };
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) return text;
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) logger3.info("翻译通过退化检测");
272
+ if (config.outputLogs) logger4.info("翻译通过退化检测");
268
273
  return applyTermMap(translated, config.term);
269
274
  }
270
- logger3.warn(`翻译退化(${v1.reason}),使用简化 prompt 重试`);
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) logger3.info("重试翻译通过退化检测");
286
+ if (config.outputLogs) logger4.info("重试翻译通过退化检测");
282
287
  return applyTermMap(retried, config.term);
283
288
  }
284
- logger3.warn(`重试翻译仍退化(${v2.reason}),回退原文`);
289
+ logger4.warn(`重试翻译仍退化(${v2.reason}),回退原文`);
285
290
  return text;
286
291
  } catch (retryErr) {
287
- logger3.error("重试翻译 API 调用失败", retryErr);
292
+ logger4.error("重试翻译 API 调用失败", retryErr);
288
293
  return text;
289
294
  }
290
295
  } catch (err) {
291
- logger3.error("翻译 API 调用失败", err);
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 import_koishi4 = require("koishi");
299
- var logger4 = new import_koishi4.Logger("xlon/message-builder");
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
- "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
- }
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 isVideo = detail.mediaUrls?.some((u) => u.endsWith(".mp4"));
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}一条${isVideo ? "视频" : "图片"}推文:
335
+ let textMsg = `${prefixUser}${config.messagePrefix}一条${mediaLabel}推文:
325
336
  ${translated}${altOriginalText}
326
337
  ${retweetNote}`;
327
- if (detail.screenshotBuffer) textMsg += `${import_koishi4.h.image(detail.screenshotBuffer, "image/webp")}`;
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(import_koishi4.h.image(bin, "image/jpeg"));
346
+ images.push(String(import_koishi5.h.image(bin, "image/jpeg")));
336
347
  } catch (e) {
337
- logger4.error(`下载图片失败:${imgUrl}`, e);
348
+ logger5.error(`下载图片失败:${imgUrl}`, e);
338
349
  }
339
350
  }
340
- if (images.length) segments.push({ content: images.join("\n") });
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: import_koishi4.h.video(bin, "video/mp4") });
357
+ segments.push({ content: import_koishi5.h.video(bin, "video/mp4") });
347
358
  } catch (e) {
348
- logger4.error(`下载视频失败:${videoUrl}`, e);
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 import_koishi5 = require("koishi");
357
- var logger5 = new import_koishi5.Logger("xlon/poller");
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
- logger5.info(`[初始化] 已存在订阅:${Array.from(existingIds).join(", ")}`);
365
- logger5.info(`[初始化] 需要初始化:${newSubs.map((s) => s.id).join(", ")}`);
375
+ logger6.info(`[初始化] 已存在订阅:${Array.from(existingIds).join(", ")}`);
376
+ logger6.info(`[初始化] 需要初始化:${newSubs.map((s) => s.id).join(", ")}`);
366
377
  }
367
- for (const sub of newSubs) {
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
- logger5.error(`[初始化] 获取 ${sub.id} 最新推文失败(请检查用户名/网络)`, e);
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
- logger5.info("初始化完成");
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 (const sub of config.subscriptions) {
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
- if (!storedLink || storedLink !== latestLink) {
396
- await repo.upsert({
397
- id: username,
398
- link: latestLink,
399
- content: latest.wordContent || ""
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
- logger5.info(`已是最新:${username}`);
425
+ logger6.info(`已是最新:${username}`);
408
426
  }
409
427
  } catch (e) {
410
- logger5.error(`轮询 ${username} 失败`, e);
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) => logger5.error("定时任务异常", e)), ms);
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 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";
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;
@@ -439,6 +459,43 @@ function buildVxApiUrl(tweetUrl) {
439
459
  return tweetUrl.replace(/(twitter\.com|x\.com)/i, "api.vxtwitter.com");
440
460
  }
441
461
  __name(buildVxApiUrl, "buildVxApiUrl");
462
+ async function diagnosePage(page) {
463
+ return await page.evaluate(() => {
464
+ const url = location.href;
465
+ const title = document.title || "";
466
+ const bodyText = (document.body && document.body.innerText || "").slice(0, 600);
467
+ let reason = "页面未渲染出推文(可能网络缓慢或 X 页面结构变化)";
468
+ const isLoginWall = /\/i\/flow\/login|\/login|\/i\/flow\/single-sign-on/i.test(url) || !!document.querySelector('input[autocomplete="username"]') || /^(log in|登录|登入|sign in)/i.test(title.trim());
469
+ if (isLoginWall) {
470
+ reason = "Cookie(auth_token) 可能已失效,被重定向到登录页,请更新 cookies 配置";
471
+ } else if (/rate limit|too many requests|请求过于频繁|尝试次数过多/i.test(bodyText)) {
472
+ reason = "触发限流(Rate limit),请调大 updateInterval 或更换账号";
473
+ } else if (/account doesn.?t exist|账号不存在|這個帳號不存在|user not found/i.test(bodyText)) {
474
+ reason = "账号不存在或用户名拼写有误";
475
+ } else if (/suspended|账号已被冻结|帳號已遭凍結/i.test(bodyText)) {
476
+ reason = "账号已被冻结(suspended)";
477
+ } else if (/something went wrong|出错了|发生了一些错误|重试/i.test(bodyText)) {
478
+ reason = "X 返回错误页(Something went wrong),通常为临时故障或限流";
479
+ }
480
+ return { url, reason };
481
+ });
482
+ }
483
+ __name(diagnosePage, "diagnosePage");
484
+ async function waitForArticle(page, timeout = 3e4) {
485
+ const outcome = await Promise.race([
486
+ page.waitForSelector("article", { timeout }).then(() => "article").catch(() => null),
487
+ page.waitForFunction(
488
+ () => /\/i\/flow\/login|\/login|\/i\/flow\/single-sign-on/i.test(location.href) || !!document.querySelector('input[autocomplete="username"]'),
489
+ { timeout }
490
+ ).then(() => "login").catch(() => null)
491
+ ]);
492
+ if (outcome === "article") return;
493
+ const diag = await diagnosePage(page).catch(() => null);
494
+ throw new Error(
495
+ diag ? `未加载到推文(${diag.reason})| URL: ${diag.url}` : "未加载到推文(页面状态未知)"
496
+ );
497
+ }
498
+ __name(waitForArticle, "waitForArticle");
442
499
  async function screenshotTweetArticle(page, selector = 'article[data-testid="tweet"]') {
443
500
  const element = await page.waitForSelector(selector, { timeout: 15e3 });
444
501
  if (!element) throw new Error("未能找到推文容器");
@@ -453,14 +510,13 @@ async function screenshotTweetArticle(page, selector = 'article[data-testid="twe
453
510
  return imgsLoaded && videosLoaded;
454
511
  },
455
512
  { timeout: 2e4 },
456
- // 增加到 20 秒
457
513
  selector
458
514
  ).catch((err) => {
459
- logger6.warn(`媒体加载超时: ${err.message}`);
515
+ logger7.warn(`媒体加载超时: ${err.message}`);
460
516
  return null;
461
517
  });
462
518
  if (!mediaLoadResult) {
463
- logger6.warn("部分媒体可能未完全加载,额外等待 2 秒");
519
+ logger7.warn("部分媒体可能未完全加载,额外等待 2 秒");
464
520
  await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 2e3)));
465
521
  }
466
522
  await page.evaluate(() => {
@@ -519,7 +575,6 @@ async function preparePage(page, cookies) {
519
575
  secure: true
520
576
  });
521
577
  await page.setUserAgent(UA);
522
- await page.setDefaultNavigationTimeout(6e4);
523
578
  await page.setDefaultTimeout(6e4);
524
579
  }
525
580
  __name(preparePage, "preparePage");
@@ -541,7 +596,7 @@ var XFetcher = class {
541
596
  */
542
597
  async getLatestTweet(username, maxRetries = 3) {
543
598
  if (!this.hasPuppeteer) {
544
- logger6.warn("puppeteer 不可用,无法获取最新推文");
599
+ logger7.warn("puppeteer 不可用,无法获取最新推文");
545
600
  return { tweets: [], wordContent: "" };
546
601
  }
547
602
  const url = `https://x.com/${username}`;
@@ -557,7 +612,7 @@ var XFetcher = class {
557
612
  });
558
613
  await preparePage(page, this.config.cookies);
559
614
  await page.goto(url, { waitUntil: "domcontentloaded", timeout: 6e4 });
560
- await page.waitForSelector("article", { timeout: 3e4 });
615
+ await waitForArticle(page, 3e4);
561
616
  const result = await page.evaluate(() => {
562
617
  const articles = Array.from(document.querySelectorAll("article"));
563
618
  const collected = [];
@@ -580,7 +635,7 @@ var XFetcher = class {
580
635
  const textEl = article.querySelector(
581
636
  'div[data-testid="tweetText"], div[lang]'
582
637
  );
583
- const word_content = (textEl && textEl.textContent ? textEl.textContent : "").trim();
638
+ const wordContent = (textEl && textEl.textContent ? textEl.textContent : "").trim();
584
639
  const timeEl = article.querySelector('a[href*="/status/"] time');
585
640
  const linkEl = timeEl && timeEl.parentElement || article.querySelector('a[href*="/status/"]');
586
641
  const href = linkEl && linkEl.getAttribute("href") || "";
@@ -597,7 +652,7 @@ var XFetcher = class {
597
652
  let absolute = href;
598
653
  if (absolute.startsWith("/")) absolute = "https://x.com" + absolute;
599
654
  if (!absolute.startsWith("http")) absolute = "https://x.com/" + absolute;
600
- collected.push({ link: absolute, isRetweet, isVideo, word_content });
655
+ collected.push({ link: absolute, isRetweet, isVideo, wordContent });
601
656
  }
602
657
  const latest = collected.slice(0, 1);
603
658
  return {
@@ -606,17 +661,18 @@ var XFetcher = class {
606
661
  isRetweet: t.isRetweet,
607
662
  isVideo: t.isVideo
608
663
  })),
609
- wordContent: latest.length ? latest[0].word_content : ""
664
+ wordContent: latest.length ? latest[0].wordContent : ""
610
665
  };
611
666
  });
612
667
  return result;
613
668
  } catch (error) {
614
- logger6.error(`抓取最新推文失败(第 ${attempt} 次): ${username}`, error);
669
+ logger7.error(`抓取最新推文失败(第 ${attempt} 次): ${username}`, error);
615
670
  if (attempt >= maxRetries) return { tweets: [], wordContent: "" };
616
671
  await this.ctx.sleep(2e3 * attempt);
617
672
  } finally {
618
673
  if (page) await page.close().catch(() => {
619
674
  });
675
+ page = void 0;
620
676
  }
621
677
  }
622
678
  return { tweets: [], wordContent: "" };
@@ -640,8 +696,8 @@ var XFetcher = class {
640
696
  else req.continue();
641
697
  });
642
698
  await preparePage(page, this.config.cookies);
643
- await page.goto(tweetUrl, { waitUntil: "networkidle2", timeout: 6e4 });
644
- await page.waitForSelector("article", { timeout: 3e4 });
699
+ await page.goto(tweetUrl, { waitUntil: "domcontentloaded", timeout: 6e4 });
700
+ await waitForArticle(page, 3e4);
645
701
  protectedAccount = await page.evaluate(() => {
646
702
  return !!(document.querySelector('[data-testid="icon-lock"]') || document.querySelector('[aria-label*="rotect"]') || document.querySelector('[aria-label*="保护"]') || document.querySelector('[aria-label*="保護"]'));
647
703
  });
@@ -664,7 +720,8 @@ var XFetcher = class {
664
720
  }
665
721
  const apiUrl = buildVxApiUrl(tweetUrl);
666
722
  const apiResponse = await this.ctx.http.get(apiUrl, {
667
- headers: { "User-Agent": UA }
723
+ headers: { "User-Agent": UA },
724
+ timeout: 15e3
668
725
  });
669
726
  const mediaExt = apiResponse && apiResponse.media_extended || [];
670
727
  const altTexts = mediaExt.filter((m) => m && m.altText && String(m.altText).trim()).map((m) => String(m.altText).trim());
@@ -684,7 +741,7 @@ var XFetcher = class {
684
741
  protectedAccount
685
742
  };
686
743
  } catch (error) {
687
- logger6.error(`抓取推文详情失败(第 ${attempt} 次): ${tweetUrlInput}`, error);
744
+ logger7.error(`抓取推文详情失败(第 ${attempt} 次): ${tweetUrlInput}`, error);
688
745
  if (attempt >= maxRetries) {
689
746
  return {
690
747
  url: tweetUrlInput,
@@ -698,6 +755,7 @@ var XFetcher = class {
698
755
  } finally {
699
756
  if (page) await page.close().catch(() => {
700
757
  });
758
+ page = void 0;
701
759
  }
702
760
  }
703
761
  return {
@@ -711,43 +769,43 @@ var XFetcher = class {
711
769
  };
712
770
 
713
771
  // src/config.ts
714
- var import_koishi7 = require("koishi");
715
- var Config = import_koishi7.Schema.intersect([
716
- import_koishi7.Schema.object({
717
- cookies: import_koishi7.Schema.string().required(),
718
- updateInterval: import_koishi7.Schema.number().min(1).default(5),
719
- messagePrefix: import_koishi7.Schema.string().default("获取了"),
720
- screenshot: import_koishi7.Schema.boolean().default(true)
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)
721
779
  }).description("基础设置"),
722
- import_koishi7.Schema.object({
723
- translateEnabled: import_koishi7.Schema.boolean().default(false)
780
+ import_koishi8.Schema.object({
781
+ translateEnabled: import_koishi8.Schema.boolean().default(false)
724
782
  }).description("翻译设置"),
725
- import_koishi7.Schema.union([
726
- import_koishi7.Schema.object({
727
- translateEnabled: import_koishi7.Schema.const(true).required(),
728
- apiKey: import_koishi7.Schema.string().required(),
729
- apiUrl: import_koishi7.Schema.string().default("https://api.deepseek.com"),
730
- model: import_koishi7.Schema.string().default("deepseek-chat"),
731
- systemPrompt: import_koishi7.Schema.string().role("textarea").default(
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(
732
790
  "你是一名推文翻译专员,负责将推文翻译为简体中文。规则:\n1. 直接输出翻译结果,不添加任何前缀、解释或注释\n2. 保留原文中的 @用户名、#话题标签、URL 不翻译\n3. 保留原文的 emoji\n4. 不要修改标点符号的使用习惯\n{glossary}"
733
791
  ),
734
- prompt: import_koishi7.Schema.string().role("textarea").default("{text}"),
735
- term: import_koishi7.Schema.dict(String).role("table").default({})
792
+ prompt: import_koishi8.Schema.string().role("textarea").default("{text}"),
793
+ term: import_koishi8.Schema.dict(String).role("table").default({})
736
794
  }),
737
- import_koishi7.Schema.object({})
795
+ import_koishi8.Schema.object({})
738
796
  ]),
739
- import_koishi7.Schema.object({
740
- subscriptions: import_koishi7.Schema.array(
741
- import_koishi7.Schema.object({
742
- id: import_koishi7.Schema.string().required(),
743
- targets: import_koishi7.Schema.array(String).role("table").default([]),
744
- blacklist: import_koishi7.Schema.array(import_koishi7.Schema.string()).default([])
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([])
745
803
  })
746
804
  ).default([])
747
805
  }).description("订阅列表"),
748
- import_koishi7.Schema.object({
749
- outputLogs: import_koishi7.Schema.boolean().default(true),
750
- detectXLinks: import_koishi7.Schema.boolean().default(true)
806
+ import_koishi8.Schema.object({
807
+ outputLogs: import_koishi8.Schema.boolean().default(true),
808
+ detectXLinks: import_koishi8.Schema.boolean().default(true)
751
809
  }).description("调试设置")
752
810
  ]).i18n({
753
811
  "en-US": en_US_default._config,
@@ -756,7 +814,7 @@ var Config = import_koishi7.Schema.intersect([
756
814
 
757
815
  // src/index.ts
758
816
  var name = "xlon";
759
- var logger7 = new import_koishi8.Logger("xlon");
817
+ var logger8 = new import_koishi9.Logger("xlon");
760
818
  var inject = {
761
819
  required: ["database"],
762
820
  optional: ["puppeteer"]
@@ -764,14 +822,14 @@ var inject = {
764
822
  async function sendToTarget(ctx, target, content) {
765
823
  const idx = target.indexOf(":");
766
824
  if (idx < 0) {
767
- logger7.warn(`无效 target 格式(应为 platform:channelId):${target}`);
825
+ logger8.warn(`无效 target 格式(应为 platform:channelId):${target}`);
768
826
  return;
769
827
  }
770
828
  const platform = target.slice(0, idx);
771
829
  const channelId = target.slice(idx + 1);
772
830
  const bot = ctx.bots.find((b) => b.platform === platform);
773
831
  if (!bot) {
774
- logger7.warn(`未找到平台 ${platform} 上的 bot,跳过 target: ${target}`);
832
+ logger8.warn(`未找到平台 ${platform} 上的 bot,跳过 target: ${target}`);
775
833
  return;
776
834
  }
777
835
  await bot.sendMessage(channelId, content);
@@ -804,7 +862,7 @@ function apply(ctx, config) {
804
862
  });
805
863
  if (hit.length) {
806
864
  if (config.outputLogs)
807
- logger7.info(`命中违禁词,跳过推送:${opts.username} -> ${hit.join(", ")}`);
865
+ logger8.info(`命中违禁词,跳过推送:${opts.username} -> ${hit.join(", ")}`);
808
866
  return;
809
867
  }
810
868
  }
@@ -821,7 +879,7 @@ function apply(ctx, config) {
821
879
  await session.send(session.text("messages.empty-url"));
822
880
  return;
823
881
  }
824
- if (config.outputLogs) logger7.info("处理推文链接:", u);
882
+ if (config.outputLogs) logger8.info("处理推文链接:", u);
825
883
  await session.send(session.text("messages.fetching"));
826
884
  await processAndSend((content) => session.send(content), u);
827
885
  }
@@ -841,7 +899,7 @@ function apply(ctx, config) {
841
899
  await session.send(session.text("messages.empty-url"));
842
900
  return;
843
901
  }
844
- if (config.outputLogs) logger7.info("按用户名处理最新推文:", username);
902
+ if (config.outputLogs) logger8.info("按用户名处理最新推文:", username);
845
903
  await session.send(session.text("messages.fetching"));
846
904
  const latest = await fetcher.getLatestTweet(username);
847
905
  const first = latest.tweets[0];
@@ -864,7 +922,7 @@ function apply(ctx, config) {
864
922
  try {
865
923
  await sendToTarget(ctx, target, content);
866
924
  } catch (e) {
867
- logger7.error(`推送到 ${target} 失败`, e);
925
+ logger8.error(`推送到 ${target} 失败`, e);
868
926
  }
869
927
  }
870
928
  }, "send");
@@ -888,6 +946,5 @@ __name(apply, "apply");
888
946
  Config,
889
947
  apply,
890
948
  inject,
891
- logger,
892
949
  name
893
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.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.2",
4
+ "version": "1.1.0",
5
5
  "contributors": [
6
6
  "Logthm <logthm@outlook.com>"
7
7
  ],