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.
@@ -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 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
- }
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;
@@ -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
- logger6.warn(`媒体加载超时: ${err.message}`);
515
+ logger7.warn(`媒体加载超时: ${err.message}`);
497
516
  return null;
498
517
  });
499
518
  if (!mediaLoadResult) {
500
- logger6.warn("部分媒体可能未完全加载,额外等待 2 秒");
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
- logger6.warn("puppeteer 不可用,无法获取最新推文");
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 word_content = (textEl && textEl.textContent ? textEl.textContent : "").trim();
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, word_content });
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].word_content : ""
664
+ wordContent: latest.length ? latest[0].wordContent : ""
647
665
  };
648
666
  });
649
667
  return result;
650
668
  } catch (error) {
651
- logger6.error(`抓取最新推文失败(第 ${attempt} 次): ${username}`, error);
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: "networkidle2", timeout: 6e4 });
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
- logger6.error(`抓取推文详情失败(第 ${attempt} 次): ${tweetUrlInput}`, error);
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 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)
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
- import_koishi7.Schema.object({
760
- translateEnabled: import_koishi7.Schema.boolean().default(false)
780
+ import_koishi8.Schema.object({
781
+ translateEnabled: import_koishi8.Schema.boolean().default(false)
761
782
  }).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(
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: import_koishi7.Schema.string().role("textarea").default("{text}"),
772
- 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({})
773
794
  }),
774
- import_koishi7.Schema.object({})
795
+ import_koishi8.Schema.object({})
775
796
  ]),
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([])
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
- import_koishi7.Schema.object({
786
- outputLogs: import_koishi7.Schema.boolean().default(true),
787
- 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)
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 logger7 = new import_koishi8.Logger("xlon");
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
- logger7.warn(`无效 target 格式(应为 platform:channelId):${target}`);
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
- logger7.warn(`未找到平台 ${platform} 上的 bot,跳过 target: ${target}`);
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
- logger7.info(`命中违禁词,跳过推送:${opts.username} -> ${hit.join(", ")}`);
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) logger7.info("处理推文链接:", u);
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) logger7.info("按用户名处理最新推文:", username);
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
- logger7.error(`推送到 ${target} 失败`, e);
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.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.0",
5
5
  "contributors": [
6
6
  "Logthm <logthm@outlook.com>"
7
7
  ],