koishi-plugin-xlon 1.0.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 +26 -0
- package/lib/db/model.d.ts +8 -0
- package/lib/db/repo.d.ts +9 -0
- package/lib/features/commands/index.d.ts +7 -0
- package/lib/features/link-detector.d.ts +3 -0
- package/lib/features/translator.d.ts +10 -0
- package/lib/index.d.ts +10 -0
- package/lib/index.js +883 -0
- package/lib/scheduler/poller.d.ts +15 -0
- package/lib/service/fetcher.d.ts +20 -0
- package/lib/service/message-builder.d.ts +15 -0
- package/lib/types.d.ts +29 -0
- package/package.json +40 -0
- package/readme.md +5 -0
package/lib/config.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Schema } from 'koishi';
|
|
2
|
+
export interface SubscriptionItem {
|
|
3
|
+
/** Twitter/X 用户名(@ 后面的部分) */
|
|
4
|
+
id: string;
|
|
5
|
+
/** 推送目标,格式为 platform:channelId(如 onebot:123456) */
|
|
6
|
+
targets: string[];
|
|
7
|
+
/** 违禁词(命中则跳过推送) */
|
|
8
|
+
blacklist: string[];
|
|
9
|
+
}
|
|
10
|
+
export interface Config {
|
|
11
|
+
cookies: string;
|
|
12
|
+
updateInterval: number;
|
|
13
|
+
messagePrefix: string;
|
|
14
|
+
screenshot: boolean;
|
|
15
|
+
translateEnabled: boolean;
|
|
16
|
+
apiKey?: string;
|
|
17
|
+
apiUrl: string;
|
|
18
|
+
model: string;
|
|
19
|
+
systemPrompt: string;
|
|
20
|
+
prompt: string;
|
|
21
|
+
term: Record<string, string>;
|
|
22
|
+
subscriptions: SubscriptionItem[];
|
|
23
|
+
outputLogs: boolean;
|
|
24
|
+
detectXLinks: boolean;
|
|
25
|
+
}
|
|
26
|
+
export declare const Config: Schema<Schemastery.ObjectS<{}>, {} & import("cosmokit").Dict>;
|
package/lib/db/repo.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Context } from 'koishi';
|
|
2
|
+
import { XlonRecord } from '../types';
|
|
3
|
+
export declare class XlonRepo {
|
|
4
|
+
private ctx;
|
|
5
|
+
constructor(ctx: Context);
|
|
6
|
+
get(id: string): Promise<XlonRecord | null>;
|
|
7
|
+
upsert(record: XlonRecord): Promise<void>;
|
|
8
|
+
listIds(): Promise<string[]>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Context, Session } from 'koishi';
|
|
2
|
+
import { Config } from '../../config';
|
|
3
|
+
export declare function registerCommands(ctx: Context, config: Config, deps: {
|
|
4
|
+
handleTweetUrl: (session: Session, url: string) => Promise<void>;
|
|
5
|
+
handleTweetInput: (session: Session, input: string) => Promise<void>;
|
|
6
|
+
checkOnce: (session?: Session) => Promise<void>;
|
|
7
|
+
}): void;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Context } from 'koishi';
|
|
2
|
+
import { Config } from '../config';
|
|
3
|
+
/**
|
|
4
|
+
* 翻译入口。完整流程:
|
|
5
|
+
* 1. 构建 system prompt(注入术语表)
|
|
6
|
+
* 2. 调用 LLM → 退化检测
|
|
7
|
+
* 3. 如退化 → 简化 prompt 重试一次
|
|
8
|
+
* 4. 事后兜底:术语表强制替换
|
|
9
|
+
*/
|
|
10
|
+
export declare function translateIfEnabled(ctx: Context, config: Config, text: string): Promise<string>;
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Context, Logger } from 'koishi';
|
|
2
|
+
import { Config } from './config';
|
|
3
|
+
export declare const name = "xlon";
|
|
4
|
+
export declare const logger: Logger;
|
|
5
|
+
export declare const inject: {
|
|
6
|
+
required: string[];
|
|
7
|
+
optional: string[];
|
|
8
|
+
};
|
|
9
|
+
export * from './config';
|
|
10
|
+
export declare function apply(ctx: Context, config: Config): void;
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
5
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name2 in all)
|
|
8
|
+
__defProp(target, name2, { get: all[name2], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var src_exports = {};
|
|
22
|
+
__export(src_exports, {
|
|
23
|
+
Config: () => Config,
|
|
24
|
+
apply: () => apply,
|
|
25
|
+
inject: () => inject,
|
|
26
|
+
logger: () => logger7,
|
|
27
|
+
name: () => name
|
|
28
|
+
});
|
|
29
|
+
module.exports = __toCommonJS(src_exports);
|
|
30
|
+
var import_koishi8 = require("koishi");
|
|
31
|
+
|
|
32
|
+
// 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} 失败(可能网络/用户名问题)。" } };
|
|
34
|
+
|
|
35
|
+
// 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)." } };
|
|
37
|
+
|
|
38
|
+
// src/db/model.ts
|
|
39
|
+
var import_koishi = require("koishi");
|
|
40
|
+
var logger = new import_koishi.Logger("xlon/db");
|
|
41
|
+
function extendDatabase(ctx) {
|
|
42
|
+
ctx.model.extend(
|
|
43
|
+
"xlon",
|
|
44
|
+
{
|
|
45
|
+
id: "string",
|
|
46
|
+
link: "string",
|
|
47
|
+
content: "string"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
primary: "id",
|
|
51
|
+
autoInc: false
|
|
52
|
+
}
|
|
53
|
+
);
|
|
54
|
+
logger.info("数据库初始化成功");
|
|
55
|
+
}
|
|
56
|
+
__name(extendDatabase, "extendDatabase");
|
|
57
|
+
|
|
58
|
+
// src/db/repo.ts
|
|
59
|
+
var XlonRepo = class {
|
|
60
|
+
constructor(ctx) {
|
|
61
|
+
this.ctx = ctx;
|
|
62
|
+
}
|
|
63
|
+
static {
|
|
64
|
+
__name(this, "XlonRepo");
|
|
65
|
+
}
|
|
66
|
+
async get(id) {
|
|
67
|
+
const rows = await this.ctx.database.get("xlon", { id });
|
|
68
|
+
return rows[0] ?? null;
|
|
69
|
+
}
|
|
70
|
+
async upsert(record) {
|
|
71
|
+
await this.ctx.database.upsert("xlon", [record]);
|
|
72
|
+
}
|
|
73
|
+
async listIds() {
|
|
74
|
+
const rows = await this.ctx.database.get("xlon", {}, ["id"]);
|
|
75
|
+
return rows.map((r) => r.id);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// src/features/commands/index.ts
|
|
80
|
+
function registerCommands(ctx, config, deps) {
|
|
81
|
+
ctx.command("x.check").alias("check").action(async ({ session }) => {
|
|
82
|
+
await session.send(session.text("messages.checking"));
|
|
83
|
+
await deps.checkOnce(session);
|
|
84
|
+
});
|
|
85
|
+
ctx.command("x.tweet <input:text>").alias("tweet").action(async ({ session }, input) => {
|
|
86
|
+
await deps.handleTweetInput(session, input || "");
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
__name(registerCommands, "registerCommands");
|
|
90
|
+
|
|
91
|
+
// 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;
|
|
95
|
+
function extractUrls(text) {
|
|
96
|
+
const matches = text.match(urlRe) || [];
|
|
97
|
+
return matches.map((m) => m.replace(/[\u3002\uFF0C\uFF1F\uFF01\.,!?,。?!、]+$/g, ""));
|
|
98
|
+
}
|
|
99
|
+
__name(extractUrls, "extractUrls");
|
|
100
|
+
function isXDomain(urlStr) {
|
|
101
|
+
try {
|
|
102
|
+
const u = new URL(urlStr.includes("://") ? urlStr : `https://${urlStr}`);
|
|
103
|
+
const hn = (u.hostname || "").toLowerCase();
|
|
104
|
+
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 (_) {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
__name(isXDomain, "isXDomain");
|
|
110
|
+
async function expandShortLink(ctx, url) {
|
|
111
|
+
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")
|
|
116
|
+
});
|
|
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
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
__name(expandShortLink, "expandShortLink");
|
|
129
|
+
function registerLinkDetector(ctx, config, handler) {
|
|
130
|
+
ctx.middleware(async (session, next) => {
|
|
131
|
+
try {
|
|
132
|
+
if (!config.detectXLinks) return next();
|
|
133
|
+
const text = session.content || "";
|
|
134
|
+
if (!text) return next();
|
|
135
|
+
const candidates = extractUrls(text);
|
|
136
|
+
if (!candidates.length) return next();
|
|
137
|
+
const found = [];
|
|
138
|
+
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);
|
|
142
|
+
if (isXDomain(exp)) found.push(exp);
|
|
143
|
+
} else if (isXDomain(normalized)) {
|
|
144
|
+
found.push(normalized);
|
|
145
|
+
}
|
|
146
|
+
if (found.length >= 3) break;
|
|
147
|
+
}
|
|
148
|
+
if (found.length && config.outputLogs) {
|
|
149
|
+
logger2.info("检测到 X/Twitter 链接:", found);
|
|
150
|
+
}
|
|
151
|
+
for (const link of found) {
|
|
152
|
+
try {
|
|
153
|
+
await handler(session, link);
|
|
154
|
+
} catch (e) {
|
|
155
|
+
logger2.error("处理检测到的 X/Twitter 链接时出错", e);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
} catch (err) {
|
|
159
|
+
logger2.error("X/Twitter 链接检测失败", err);
|
|
160
|
+
}
|
|
161
|
+
return next();
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
__name(registerLinkDetector, "registerLinkDetector");
|
|
165
|
+
|
|
166
|
+
// src/features/translator.ts
|
|
167
|
+
var import_koishi3 = require("koishi");
|
|
168
|
+
var logger3 = new import_koishi3.Logger("xlon/translator");
|
|
169
|
+
function buildGlossaryBlock(term) {
|
|
170
|
+
const entries = Object.entries(term || {}).filter(([k]) => !!k);
|
|
171
|
+
if (!entries.length) return "";
|
|
172
|
+
const lines = entries.map(([from, to]) => `- "${from}" → "${to}"`);
|
|
173
|
+
return `术语表(翻译时必须使用以下译法):
|
|
174
|
+
${lines.join("\n")}`;
|
|
175
|
+
}
|
|
176
|
+
__name(buildGlossaryBlock, "buildGlossaryBlock");
|
|
177
|
+
function injectGlossary(systemPrompt, glossaryBlock) {
|
|
178
|
+
if (!glossaryBlock) return (systemPrompt || "").replace("{glossary}", "");
|
|
179
|
+
if ((systemPrompt || "").includes("{glossary}")) {
|
|
180
|
+
return systemPrompt.replace("{glossary}", glossaryBlock);
|
|
181
|
+
}
|
|
182
|
+
return `${systemPrompt}
|
|
183
|
+
|
|
184
|
+
${glossaryBlock}`;
|
|
185
|
+
}
|
|
186
|
+
__name(injectGlossary, "injectGlossary");
|
|
187
|
+
function applyTermMap(text, term) {
|
|
188
|
+
let out = text;
|
|
189
|
+
for (const [from, to] of Object.entries(term || {})) {
|
|
190
|
+
if (!from) continue;
|
|
191
|
+
out = out.split(from).join(to ?? "");
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
__name(applyTermMap, "applyTermMap");
|
|
196
|
+
function validateTranslation(original, translated) {
|
|
197
|
+
if (!translated) return { ok: false, reason: "翻译结果为空" };
|
|
198
|
+
if (translated === original) {
|
|
199
|
+
return { ok: false, reason: "翻译结果与原文完全相同(回声)" };
|
|
200
|
+
}
|
|
201
|
+
const refusalRe = /作为AI|作为一个AI|I cannot|I'm sorry|I am sorry|无法翻译|I can't translate/i;
|
|
202
|
+
if (refusalRe.test(translated) && !refusalRe.test(original)) {
|
|
203
|
+
return { ok: false, reason: "翻译结果包含拒绝短语" };
|
|
204
|
+
}
|
|
205
|
+
if (original.length > 10 && translated.length > original.length * 3) {
|
|
206
|
+
return { ok: false, reason: `翻译结果异常膨胀(原文 ${original.length} 字 → 译文 ${translated.length} 字)` };
|
|
207
|
+
}
|
|
208
|
+
if (original.length > 30 && translated.length < original.length * 0.15) {
|
|
209
|
+
return { ok: false, reason: `翻译结果疑似截断(原文 ${original.length} 字 → 译文 ${translated.length} 字)` };
|
|
210
|
+
}
|
|
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);
|
|
214
|
+
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} 次` };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return { ok: true };
|
|
232
|
+
}
|
|
233
|
+
__name(validateTranslation, "validateTranslation");
|
|
234
|
+
async function callTranslateAPI(ctx, config, opts) {
|
|
235
|
+
const url = `${(config.apiUrl || "").replace(/\/$/, "")}/chat/completions`;
|
|
236
|
+
const headers = {
|
|
237
|
+
"Content-Type": "application/json",
|
|
238
|
+
Authorization: `Bearer ${config.apiKey}`
|
|
239
|
+
};
|
|
240
|
+
const data = {
|
|
241
|
+
model: opts.model,
|
|
242
|
+
messages: [
|
|
243
|
+
{ role: "system", content: opts.systemContent },
|
|
244
|
+
{ role: "user", content: opts.userContent }
|
|
245
|
+
],
|
|
246
|
+
stream: false
|
|
247
|
+
};
|
|
248
|
+
const response = await ctx.http.post(url, data, { headers });
|
|
249
|
+
return String(response?.choices?.[0]?.message?.content ?? "").trim();
|
|
250
|
+
}
|
|
251
|
+
__name(callTranslateAPI, "callTranslateAPI");
|
|
252
|
+
async function translateIfEnabled(ctx, config, text) {
|
|
253
|
+
if (!config.translateEnabled) return text;
|
|
254
|
+
if (!config.apiKey) return text;
|
|
255
|
+
if (!text?.trim()) return text;
|
|
256
|
+
const glossaryBlock = buildGlossaryBlock(config.term);
|
|
257
|
+
const systemContent = injectGlossary(config.systemPrompt || "", glossaryBlock);
|
|
258
|
+
const userContent = (config.prompt || "{text}").replace("{text}", text);
|
|
259
|
+
try {
|
|
260
|
+
const translated = await callTranslateAPI(ctx, config, {
|
|
261
|
+
systemContent,
|
|
262
|
+
userContent,
|
|
263
|
+
model: config.model
|
|
264
|
+
});
|
|
265
|
+
const v1 = validateTranslation(text, translated);
|
|
266
|
+
if (v1.ok) {
|
|
267
|
+
if (config.outputLogs) logger3.info("翻译通过退化检测");
|
|
268
|
+
return applyTermMap(translated, config.term);
|
|
269
|
+
}
|
|
270
|
+
logger3.warn(`翻译退化(${v1.reason}),使用简化 prompt 重试`);
|
|
271
|
+
const retrySystem = "你是翻译助手。直接输出简体中文翻译,不要添加任何额外内容。";
|
|
272
|
+
const retryUser = text;
|
|
273
|
+
try {
|
|
274
|
+
const retried = await callTranslateAPI(ctx, config, {
|
|
275
|
+
systemContent: retrySystem,
|
|
276
|
+
userContent: retryUser,
|
|
277
|
+
model: config.model
|
|
278
|
+
});
|
|
279
|
+
const v2 = validateTranslation(text, retried);
|
|
280
|
+
if (v2.ok) {
|
|
281
|
+
if (config.outputLogs) logger3.info("重试翻译通过退化检测");
|
|
282
|
+
return applyTermMap(retried, config.term);
|
|
283
|
+
}
|
|
284
|
+
logger3.warn(`重试翻译仍退化(${v2.reason}),回退原文`);
|
|
285
|
+
return text;
|
|
286
|
+
} catch (retryErr) {
|
|
287
|
+
logger3.error("重试翻译 API 调用失败", retryErr);
|
|
288
|
+
return text;
|
|
289
|
+
}
|
|
290
|
+
} catch (err) {
|
|
291
|
+
logger3.error("翻译 API 调用失败", err);
|
|
292
|
+
return text;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
__name(translateIfEnabled, "translateIfEnabled");
|
|
296
|
+
|
|
297
|
+
// src/service/message-builder.ts
|
|
298
|
+
var import_koishi4 = require("koishi");
|
|
299
|
+
var logger4 = new import_koishi4.Logger("xlon/message-builder");
|
|
300
|
+
async function fetchBinary(ctx, url, maxRetries = 3) {
|
|
301
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
302
|
+
try {
|
|
303
|
+
return await ctx.http.get(url, {
|
|
304
|
+
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
|
+
}
|
|
308
|
+
});
|
|
309
|
+
} catch (e) {
|
|
310
|
+
if (attempt >= maxRetries) throw e;
|
|
311
|
+
await ctx.sleep(1200 * attempt);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
__name(fetchBinary, "fetchBinary");
|
|
316
|
+
async function buildTweetMessages(ctx, config, detail, translated, opts) {
|
|
317
|
+
const segments = [];
|
|
318
|
+
const altOriginalText = detail.altTexts && detail.altTexts.length ? "\n" + detail.altTexts.map(
|
|
319
|
+
(alt, i) => `[图片${detail.altTexts.length > 1 ? i + 1 : ""}描述原文: ${alt}]`
|
|
320
|
+
).join("\n") : "";
|
|
321
|
+
const isVideo = detail.mediaUrls?.some((u) => u.endsWith(".mp4"));
|
|
322
|
+
const prefixUser = opts?.username ? `【${opts.username}】 ` : "";
|
|
323
|
+
const retweetNote = opts?.isRetweet ? "[转发推文]\n" : "";
|
|
324
|
+
let textMsg = `${prefixUser}${config.messagePrefix}一条${isVideo ? "视频" : "图片"}推文:
|
|
325
|
+
${translated}${altOriginalText}
|
|
326
|
+
${retweetNote}`;
|
|
327
|
+
if (detail.screenshotBuffer) textMsg += `${import_koishi4.h.image(detail.screenshotBuffer, "image/webp")}`;
|
|
328
|
+
segments.push({ content: textMsg });
|
|
329
|
+
const imageUrls = (detail.mediaUrls || []).filter((u) => !u.endsWith(".mp4"));
|
|
330
|
+
if (imageUrls.length) {
|
|
331
|
+
const images = [];
|
|
332
|
+
for (const imgUrl of imageUrls) {
|
|
333
|
+
try {
|
|
334
|
+
const bin = await fetchBinary(ctx, imgUrl);
|
|
335
|
+
images.push(import_koishi4.h.image(bin, "image/jpeg"));
|
|
336
|
+
} catch (e) {
|
|
337
|
+
logger4.error(`下载图片失败:${imgUrl}`, e);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (images.length) segments.push({ content: images.join("\n") });
|
|
341
|
+
}
|
|
342
|
+
const videoUrl = (detail.mediaUrls || []).find((u) => u.endsWith(".mp4"));
|
|
343
|
+
if (videoUrl) {
|
|
344
|
+
try {
|
|
345
|
+
const bin = await fetchBinary(ctx, videoUrl);
|
|
346
|
+
segments.push({ content: import_koishi4.h.video(bin, "video/mp4") });
|
|
347
|
+
} catch (e) {
|
|
348
|
+
logger4.error(`下载视频失败:${videoUrl}`, e);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return segments;
|
|
352
|
+
}
|
|
353
|
+
__name(buildTweetMessages, "buildTweetMessages");
|
|
354
|
+
|
|
355
|
+
// src/scheduler/poller.ts
|
|
356
|
+
var import_koishi5 = require("koishi");
|
|
357
|
+
var logger5 = new import_koishi5.Logger("xlon/poller");
|
|
358
|
+
function createPoller(ctx, config, repo, fetcher, deps) {
|
|
359
|
+
let checking = false;
|
|
360
|
+
async function init() {
|
|
361
|
+
const existingIds = new Set(await repo.listIds());
|
|
362
|
+
const newSubs = config.subscriptions.filter((s) => !existingIds.has(s.id));
|
|
363
|
+
if (config.outputLogs) {
|
|
364
|
+
logger5.info(`[初始化] 已存在订阅:${Array.from(existingIds).join(", ")}`);
|
|
365
|
+
logger5.info(`[初始化] 需要初始化:${newSubs.map((s) => s.id).join(", ")}`);
|
|
366
|
+
}
|
|
367
|
+
for (const sub of newSubs) {
|
|
368
|
+
try {
|
|
369
|
+
const latest = await fetcher.getLatestTweet(sub.id);
|
|
370
|
+
const link = latest.tweets[0]?.link;
|
|
371
|
+
if (!link) continue;
|
|
372
|
+
await repo.upsert({ id: sub.id, link, content: latest.wordContent || "" });
|
|
373
|
+
} catch (e) {
|
|
374
|
+
logger5.error(`[初始化] 获取 ${sub.id} 最新推文失败(请检查用户名/网络)`, e);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
logger5.info("初始化完成");
|
|
378
|
+
}
|
|
379
|
+
__name(init, "init");
|
|
380
|
+
async function checkOnce(session) {
|
|
381
|
+
if (checking) {
|
|
382
|
+
if (session) await session.send(session.text("messages.busy"));
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
checking = true;
|
|
386
|
+
try {
|
|
387
|
+
for (const sub of config.subscriptions) {
|
|
388
|
+
const username = sub.id;
|
|
389
|
+
try {
|
|
390
|
+
const latest = await fetcher.getLatestTweet(username);
|
|
391
|
+
const latestLink = latest.tweets[0]?.link;
|
|
392
|
+
if (!latestLink) continue;
|
|
393
|
+
const stored = await repo.get(username);
|
|
394
|
+
const storedLink = stored?.link || "";
|
|
395
|
+
if (!storedLink || storedLink !== latestLink) {
|
|
396
|
+
await repo.upsert({
|
|
397
|
+
id: username,
|
|
398
|
+
link: latestLink,
|
|
399
|
+
content: latest.wordContent || ""
|
|
400
|
+
});
|
|
401
|
+
await deps.pushTweet({
|
|
402
|
+
username,
|
|
403
|
+
url: latestLink,
|
|
404
|
+
isRetweet: latest.tweets[0]?.isRetweet || false
|
|
405
|
+
});
|
|
406
|
+
} else if (config.outputLogs) {
|
|
407
|
+
logger5.info(`已是最新:${username}`);
|
|
408
|
+
}
|
|
409
|
+
} catch (e) {
|
|
410
|
+
logger5.error(`轮询 ${username} 失败`, e);
|
|
411
|
+
if (session) await session.send(session.text("messages.poll-fail", [username]));
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
} finally {
|
|
415
|
+
checking = false;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
__name(checkOnce, "checkOnce");
|
|
419
|
+
function start() {
|
|
420
|
+
const ms = Math.max(1, config.updateInterval) * 60 * 1e3;
|
|
421
|
+
ctx.setInterval(() => checkOnce().catch((e) => logger5.error("定时任务异常", e)), ms);
|
|
422
|
+
}
|
|
423
|
+
__name(start, "start");
|
|
424
|
+
return { init, checkOnce, start };
|
|
425
|
+
}
|
|
426
|
+
__name(createPoller, "createPoller");
|
|
427
|
+
|
|
428
|
+
// 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";
|
|
432
|
+
function toAbsoluteUrl(input) {
|
|
433
|
+
const raw = (input || "").trim();
|
|
434
|
+
if (!raw) return raw;
|
|
435
|
+
return raw.includes("://") ? raw : `https://${raw}`;
|
|
436
|
+
}
|
|
437
|
+
__name(toAbsoluteUrl, "toAbsoluteUrl");
|
|
438
|
+
function buildVxApiUrl(tweetUrl) {
|
|
439
|
+
return tweetUrl.replace(/(twitter\.com|x\.com)/i, "api.vxtwitter.com");
|
|
440
|
+
}
|
|
441
|
+
__name(buildVxApiUrl, "buildVxApiUrl");
|
|
442
|
+
async function screenshotTweetArticle(page, selector = 'article[data-testid="tweet"]') {
|
|
443
|
+
const element = await page.waitForSelector(selector, { timeout: 15e3 });
|
|
444
|
+
if (!element) throw new Error("未能找到推文容器");
|
|
445
|
+
await page.waitForFunction(
|
|
446
|
+
(sel) => {
|
|
447
|
+
const a = document.querySelector(sel);
|
|
448
|
+
if (!a) return false;
|
|
449
|
+
const imgs = Array.from(a.querySelectorAll("img"));
|
|
450
|
+
return imgs.every((img) => img.complete && img.naturalWidth > 0);
|
|
451
|
+
},
|
|
452
|
+
{ timeout: 8e3 },
|
|
453
|
+
selector
|
|
454
|
+
).catch(() => {
|
|
455
|
+
});
|
|
456
|
+
await page.evaluate(() => {
|
|
457
|
+
const column = document.querySelector('div[data-testid="primaryColumn"]');
|
|
458
|
+
if (!column) return;
|
|
459
|
+
for (const child of Array.from(column.querySelectorAll(":scope > div > div"))) {
|
|
460
|
+
if (!child.querySelector("section") && !child.querySelector("article")) {
|
|
461
|
+
;
|
|
462
|
+
child.style.display = "none";
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}).catch(() => {
|
|
466
|
+
});
|
|
467
|
+
await page.evaluate((sel) => {
|
|
468
|
+
const article = document.querySelector(sel);
|
|
469
|
+
if (!article) return;
|
|
470
|
+
const group = article.querySelector('[role="group"]');
|
|
471
|
+
if (!group) return;
|
|
472
|
+
let node = group;
|
|
473
|
+
while (node && node !== article) {
|
|
474
|
+
let sibling = node.nextElementSibling;
|
|
475
|
+
while (sibling) {
|
|
476
|
+
;
|
|
477
|
+
sibling.style.display = "none";
|
|
478
|
+
sibling = sibling.nextElementSibling;
|
|
479
|
+
}
|
|
480
|
+
node = node.parentElement;
|
|
481
|
+
}
|
|
482
|
+
}, selector).catch(() => {
|
|
483
|
+
});
|
|
484
|
+
const box = await element.boundingBox();
|
|
485
|
+
if (!box) return await element.screenshot({ type: "webp" });
|
|
486
|
+
try {
|
|
487
|
+
return await page.screenshot({
|
|
488
|
+
clip: {
|
|
489
|
+
x: Math.max(0, Math.floor(box.x)),
|
|
490
|
+
y: Math.max(0, Math.floor(box.y)),
|
|
491
|
+
width: Math.ceil(box.width + 16),
|
|
492
|
+
height: Math.ceil(box.height + 12)
|
|
493
|
+
},
|
|
494
|
+
type: "webp"
|
|
495
|
+
});
|
|
496
|
+
} catch {
|
|
497
|
+
return await element.screenshot({ type: "webp" });
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
__name(screenshotTweetArticle, "screenshotTweetArticle");
|
|
501
|
+
async function preparePage(page, cookies) {
|
|
502
|
+
await page.setViewport({ width: 650, height: 900 });
|
|
503
|
+
await page.setCookie({
|
|
504
|
+
name: "auth_token",
|
|
505
|
+
value: cookies,
|
|
506
|
+
domain: ".x.com",
|
|
507
|
+
path: "/",
|
|
508
|
+
httpOnly: true,
|
|
509
|
+
secure: true
|
|
510
|
+
});
|
|
511
|
+
await page.setUserAgent(UA);
|
|
512
|
+
await page.setDefaultNavigationTimeout(6e4);
|
|
513
|
+
await page.setDefaultTimeout(6e4);
|
|
514
|
+
}
|
|
515
|
+
__name(preparePage, "preparePage");
|
|
516
|
+
var XFetcher = class {
|
|
517
|
+
constructor(ctx, config) {
|
|
518
|
+
this.ctx = ctx;
|
|
519
|
+
this.config = config;
|
|
520
|
+
}
|
|
521
|
+
static {
|
|
522
|
+
__name(this, "XFetcher");
|
|
523
|
+
}
|
|
524
|
+
/** puppeteer 是否可用 */
|
|
525
|
+
get hasPuppeteer() {
|
|
526
|
+
return !!this.ctx.puppeteer;
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* 获取博主最新推文链接(用于轮询判重)。
|
|
530
|
+
* 依赖 puppeteer 打开博主主页抓取。
|
|
531
|
+
*/
|
|
532
|
+
async getLatestTweet(username, maxRetries = 3) {
|
|
533
|
+
if (!this.hasPuppeteer) {
|
|
534
|
+
logger6.warn("puppeteer 不可用,无法获取最新推文");
|
|
535
|
+
return { tweets: [], wordContent: "" };
|
|
536
|
+
}
|
|
537
|
+
const url = `https://x.com/${username}`;
|
|
538
|
+
let page;
|
|
539
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
540
|
+
try {
|
|
541
|
+
page = await this.ctx.puppeteer.page();
|
|
542
|
+
await page.setRequestInterception(true);
|
|
543
|
+
page.on("request", (req) => {
|
|
544
|
+
const t = req.resourceType();
|
|
545
|
+
if (["image", "stylesheet", "font"].includes(t)) req.abort();
|
|
546
|
+
else req.continue();
|
|
547
|
+
});
|
|
548
|
+
await preparePage(page, this.config.cookies);
|
|
549
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 6e4 });
|
|
550
|
+
await page.waitForSelector("article", { timeout: 3e4 });
|
|
551
|
+
const result = await page.evaluate(() => {
|
|
552
|
+
const articles = Array.from(document.querySelectorAll("article"));
|
|
553
|
+
const collected = [];
|
|
554
|
+
for (const article of articles) {
|
|
555
|
+
const socialContext = article.querySelector('[data-testid="socialContext"]');
|
|
556
|
+
const cell = article.closest('div[data-testid="cellInnerDiv"]');
|
|
557
|
+
const pinnedText = [
|
|
558
|
+
(socialContext || {}).textContent || "",
|
|
559
|
+
(article.previousElementSibling || {}).textContent || "",
|
|
560
|
+
(cell || {}).textContent || ""
|
|
561
|
+
].join("\n");
|
|
562
|
+
const isPinned = !!(article.querySelector('svg[aria-label="Pinned"]') || article.querySelector('svg[aria-label*="Pin"]') || Array.from(article.querySelectorAll("span, div")).some(
|
|
563
|
+
(n) => /pinned|pin(ned)?\s*tweet|置顶|置頂|釘選|已置顶|已置頂/i.test(
|
|
564
|
+
n.textContent || ""
|
|
565
|
+
)
|
|
566
|
+
) || /pinned|pin(ned)?\s*tweet|置顶|置頂|釘選|已置顶|已置頂/i.test(
|
|
567
|
+
pinnedText
|
|
568
|
+
));
|
|
569
|
+
if (isPinned) continue;
|
|
570
|
+
const textEl = article.querySelector(
|
|
571
|
+
'div[data-testid="tweetText"], div[lang]'
|
|
572
|
+
);
|
|
573
|
+
const word_content = (textEl && textEl.textContent ? textEl.textContent : "").trim();
|
|
574
|
+
const timeEl = article.querySelector('a[href*="/status/"] time');
|
|
575
|
+
const linkEl = timeEl && timeEl.parentElement || article.querySelector('a[href*="/status/"]');
|
|
576
|
+
const href = linkEl && linkEl.getAttribute("href") || "";
|
|
577
|
+
if (!href) continue;
|
|
578
|
+
const headerText = ((article.previousElementSibling || {}).textContent || "") + ((article.parentElement || {}).textContent || "") + ((socialContext || {}).textContent || "");
|
|
579
|
+
const isRetweet = /retweeted|转推|轉推/i.test(headerText);
|
|
580
|
+
const isVideo = !!(article.querySelector('div[data-testid="videoPlayer"]') || article.querySelector("video") || Array.from(
|
|
581
|
+
article.querySelectorAll("svg[aria-label], div[aria-label]")
|
|
582
|
+
).some(
|
|
583
|
+
(n) => /video|播放|影片|视频/i.test(
|
|
584
|
+
n.getAttribute("aria-label") || ""
|
|
585
|
+
)
|
|
586
|
+
));
|
|
587
|
+
let absolute = href;
|
|
588
|
+
if (absolute.startsWith("/")) absolute = "https://x.com" + absolute;
|
|
589
|
+
if (!absolute.startsWith("http")) absolute = "https://x.com/" + absolute;
|
|
590
|
+
collected.push({ link: absolute, isRetweet, isVideo, word_content });
|
|
591
|
+
}
|
|
592
|
+
const latest = collected.slice(0, 1);
|
|
593
|
+
return {
|
|
594
|
+
tweets: latest.map((t) => ({
|
|
595
|
+
link: t.link,
|
|
596
|
+
isRetweet: t.isRetweet,
|
|
597
|
+
isVideo: t.isVideo
|
|
598
|
+
})),
|
|
599
|
+
wordContent: latest.length ? latest[0].word_content : ""
|
|
600
|
+
};
|
|
601
|
+
});
|
|
602
|
+
return result;
|
|
603
|
+
} catch (error) {
|
|
604
|
+
logger6.error(`抓取最新推文失败(第 ${attempt} 次): ${username}`, error);
|
|
605
|
+
if (attempt >= maxRetries) return { tweets: [], wordContent: "" };
|
|
606
|
+
await this.ctx.sleep(2e3 * attempt);
|
|
607
|
+
} finally {
|
|
608
|
+
if (page) await page.close().catch(() => {
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return { tweets: [], wordContent: "" };
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* 获取推文详情(截图 + 媒体 + ALT + 正文)。
|
|
616
|
+
* puppeteer 可用时截图,否则仅走 vxtwitter API。
|
|
617
|
+
*/
|
|
618
|
+
async getTweetDetail(tweetUrlInput, maxRetries = 3) {
|
|
619
|
+
const tweetUrl = toAbsoluteUrl(tweetUrlInput);
|
|
620
|
+
let page;
|
|
621
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
622
|
+
try {
|
|
623
|
+
let screenshotBuffer = null;
|
|
624
|
+
let protectedAccount = false;
|
|
625
|
+
if (this.config.screenshot && this.hasPuppeteer) {
|
|
626
|
+
page = await this.ctx.puppeteer.page();
|
|
627
|
+
await page.setRequestInterception(true);
|
|
628
|
+
page.on("request", (req) => {
|
|
629
|
+
if (req.resourceType() === "font") req.abort();
|
|
630
|
+
else req.continue();
|
|
631
|
+
});
|
|
632
|
+
await preparePage(page, this.config.cookies);
|
|
633
|
+
await page.goto(tweetUrl, { waitUntil: "domcontentloaded", timeout: 6e4 });
|
|
634
|
+
await page.waitForSelector("article", { timeout: 3e4 });
|
|
635
|
+
protectedAccount = await page.evaluate(() => {
|
|
636
|
+
return !!(document.querySelector('[data-testid="icon-lock"]') || document.querySelector('[aria-label*="rotect"]') || document.querySelector('[aria-label*="保护"]') || document.querySelector('[aria-label*="保護"]'));
|
|
637
|
+
});
|
|
638
|
+
screenshotBuffer = await screenshotTweetArticle(page);
|
|
639
|
+
if (protectedAccount) {
|
|
640
|
+
const wordContent = await page.evaluate(() => {
|
|
641
|
+
const el = document.querySelector('div[data-testid="tweetText"]');
|
|
642
|
+
return el ? (el.textContent || "").trim() : "";
|
|
643
|
+
});
|
|
644
|
+
return {
|
|
645
|
+
url: tweetUrl,
|
|
646
|
+
wordContent: `${wordContent}
|
|
647
|
+
(注:此账号为受保护账号,故不提供具体媒体内容)`,
|
|
648
|
+
altTexts: [],
|
|
649
|
+
mediaUrls: [],
|
|
650
|
+
screenshotBuffer,
|
|
651
|
+
protectedAccount: true
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
const apiUrl = buildVxApiUrl(tweetUrl);
|
|
656
|
+
const apiResponse = await this.ctx.http.get(apiUrl, {
|
|
657
|
+
headers: { "User-Agent": UA }
|
|
658
|
+
});
|
|
659
|
+
const mediaExt = apiResponse && apiResponse.media_extended || [];
|
|
660
|
+
const altTexts = mediaExt.filter((m) => m && m.altText && String(m.altText).trim()).map((m) => String(m.altText).trim());
|
|
661
|
+
let wordContentForTranslation = String(apiResponse.text || "");
|
|
662
|
+
if (altTexts.length > 0) {
|
|
663
|
+
wordContentForTranslation += "\n\n" + altTexts.map(
|
|
664
|
+
(alt, i) => `[图片${altTexts.length > 1 ? i + 1 : ""}描述: ${alt}]`
|
|
665
|
+
).join("\n");
|
|
666
|
+
}
|
|
667
|
+
const mediaUrls = mediaExt.map((m) => String(m.url)).filter(Boolean);
|
|
668
|
+
return {
|
|
669
|
+
url: tweetUrl,
|
|
670
|
+
wordContent: wordContentForTranslation,
|
|
671
|
+
altTexts,
|
|
672
|
+
mediaUrls,
|
|
673
|
+
screenshotBuffer,
|
|
674
|
+
protectedAccount
|
|
675
|
+
};
|
|
676
|
+
} catch (error) {
|
|
677
|
+
logger6.error(`抓取推文详情失败(第 ${attempt} 次): ${tweetUrlInput}`, error);
|
|
678
|
+
if (attempt >= maxRetries) {
|
|
679
|
+
return {
|
|
680
|
+
url: tweetUrlInput,
|
|
681
|
+
wordContent: "",
|
|
682
|
+
altTexts: [],
|
|
683
|
+
mediaUrls: [],
|
|
684
|
+
screenshotBuffer: null
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
await this.ctx.sleep(2e3 * attempt);
|
|
688
|
+
} finally {
|
|
689
|
+
if (page) await page.close().catch(() => {
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return {
|
|
694
|
+
url: tweetUrlInput,
|
|
695
|
+
wordContent: "",
|
|
696
|
+
altTexts: [],
|
|
697
|
+
mediaUrls: [],
|
|
698
|
+
screenshotBuffer: null
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
// src/config.ts
|
|
704
|
+
var import_koishi7 = require("koishi");
|
|
705
|
+
var Config = import_koishi7.Schema.intersect([
|
|
706
|
+
import_koishi7.Schema.object({
|
|
707
|
+
cookies: import_koishi7.Schema.string().required(),
|
|
708
|
+
updateInterval: import_koishi7.Schema.number().min(1).default(5),
|
|
709
|
+
messagePrefix: import_koishi7.Schema.string().default("获取了"),
|
|
710
|
+
screenshot: import_koishi7.Schema.boolean().default(true)
|
|
711
|
+
}).description("基础设置"),
|
|
712
|
+
import_koishi7.Schema.object({
|
|
713
|
+
translateEnabled: import_koishi7.Schema.boolean().default(false)
|
|
714
|
+
}).description("翻译设置"),
|
|
715
|
+
import_koishi7.Schema.union([
|
|
716
|
+
import_koishi7.Schema.object({
|
|
717
|
+
translateEnabled: import_koishi7.Schema.const(true).required(),
|
|
718
|
+
apiKey: import_koishi7.Schema.string().required(),
|
|
719
|
+
apiUrl: import_koishi7.Schema.string().default("https://api.deepseek.com"),
|
|
720
|
+
model: import_koishi7.Schema.string().default("deepseek-chat"),
|
|
721
|
+
systemPrompt: import_koishi7.Schema.string().role("textarea").default(
|
|
722
|
+
"你是一名推文翻译专员,负责将推文翻译为简体中文。规则:\n1. 直接输出翻译结果,不添加任何前缀、解释或注释\n2. 保留原文中的 @用户名、#话题标签、URL 不翻译\n3. 保留原文的 emoji\n4. 不要修改标点符号的使用习惯\n{glossary}"
|
|
723
|
+
),
|
|
724
|
+
prompt: import_koishi7.Schema.string().role("textarea").default("{text}"),
|
|
725
|
+
term: import_koishi7.Schema.dict(String).role("table").default({})
|
|
726
|
+
}),
|
|
727
|
+
import_koishi7.Schema.object({})
|
|
728
|
+
]),
|
|
729
|
+
import_koishi7.Schema.object({
|
|
730
|
+
subscriptions: import_koishi7.Schema.array(
|
|
731
|
+
import_koishi7.Schema.object({
|
|
732
|
+
id: import_koishi7.Schema.string().required(),
|
|
733
|
+
targets: import_koishi7.Schema.array(String).role("table").default([]),
|
|
734
|
+
blacklist: import_koishi7.Schema.array(import_koishi7.Schema.string()).default([])
|
|
735
|
+
})
|
|
736
|
+
).default([])
|
|
737
|
+
}).description("订阅列表"),
|
|
738
|
+
import_koishi7.Schema.object({
|
|
739
|
+
outputLogs: import_koishi7.Schema.boolean().default(true),
|
|
740
|
+
detectXLinks: import_koishi7.Schema.boolean().default(true)
|
|
741
|
+
}).description("调试设置")
|
|
742
|
+
]).i18n({
|
|
743
|
+
"en-US": en_US_default._config,
|
|
744
|
+
"zh-CN": zh_CN_default._config
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
// src/index.ts
|
|
748
|
+
var name = "xlon";
|
|
749
|
+
var logger7 = new import_koishi8.Logger("xlon");
|
|
750
|
+
var inject = {
|
|
751
|
+
required: ["database"],
|
|
752
|
+
optional: ["puppeteer"]
|
|
753
|
+
};
|
|
754
|
+
async function sendToTarget(ctx, target, content) {
|
|
755
|
+
const idx = target.indexOf(":");
|
|
756
|
+
if (idx < 0) {
|
|
757
|
+
logger7.warn(`无效 target 格式(应为 platform:channelId):${target}`);
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
const platform = target.slice(0, idx);
|
|
761
|
+
const channelId = target.slice(idx + 1);
|
|
762
|
+
const bot = ctx.bots.find((b) => b.platform === platform);
|
|
763
|
+
if (!bot) {
|
|
764
|
+
logger7.warn(`未找到平台 ${platform} 上的 bot,跳过 target: ${target}`);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
await bot.sendMessage(channelId, content);
|
|
768
|
+
}
|
|
769
|
+
__name(sendToTarget, "sendToTarget");
|
|
770
|
+
function isLikelyTweetUrl(input) {
|
|
771
|
+
const value = (input || "").trim();
|
|
772
|
+
if (!value) return false;
|
|
773
|
+
return /^(https?:\/\/)?(www\.)?(x\.com|twitter\.com)\//i.test(value);
|
|
774
|
+
}
|
|
775
|
+
__name(isLikelyTweetUrl, "isLikelyTweetUrl");
|
|
776
|
+
function apply(ctx, config) {
|
|
777
|
+
ctx.i18n.define("zh-CN", zh_CN_default);
|
|
778
|
+
ctx.i18n.define("en-US", en_US_default);
|
|
779
|
+
extendDatabase(ctx);
|
|
780
|
+
const repo = new XlonRepo(ctx);
|
|
781
|
+
const fetcher = new XFetcher(ctx, config);
|
|
782
|
+
async function processAndSend(send, url, opts) {
|
|
783
|
+
const detail = await fetcher.getTweetDetail(url);
|
|
784
|
+
const translated = await translateIfEnabled(ctx, config, detail.wordContent);
|
|
785
|
+
if (opts?.username) {
|
|
786
|
+
const sub = config.subscriptions.find((s) => s.id === opts.username);
|
|
787
|
+
const blacklist = sub?.blacklist || [];
|
|
788
|
+
if (blacklist.length) {
|
|
789
|
+
const lowerTranslated = translated.toLowerCase();
|
|
790
|
+
const lowerOriginal = detail.wordContent.toLowerCase();
|
|
791
|
+
const hit = blacklist.filter((w) => {
|
|
792
|
+
const lw = String(w).toLowerCase();
|
|
793
|
+
return lw && (lowerTranslated.includes(lw) || lowerOriginal.includes(lw));
|
|
794
|
+
});
|
|
795
|
+
if (hit.length) {
|
|
796
|
+
if (config.outputLogs)
|
|
797
|
+
logger7.info(`命中违禁词,跳过推送:${opts.username} -> ${hit.join(", ")}`);
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
const segments = await buildTweetMessages(ctx, config, detail, translated, opts);
|
|
803
|
+
for (const seg of segments) {
|
|
804
|
+
await send(seg.content);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
__name(processAndSend, "processAndSend");
|
|
808
|
+
async function handleTweetUrl(session, url) {
|
|
809
|
+
const u = (url || "").trim();
|
|
810
|
+
if (!u) {
|
|
811
|
+
await session.send(session.text("messages.empty-url"));
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
if (config.outputLogs) logger7.info("处理推文链接:", u);
|
|
815
|
+
await session.send(session.text("messages.fetching"));
|
|
816
|
+
await processAndSend((content) => session.send(content), u);
|
|
817
|
+
}
|
|
818
|
+
__name(handleTweetUrl, "handleTweetUrl");
|
|
819
|
+
async function handleTweetInput(session, input) {
|
|
820
|
+
const raw = (input || "").trim();
|
|
821
|
+
if (!raw) {
|
|
822
|
+
await session.send(session.text("messages.empty-url"));
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
if (isLikelyTweetUrl(raw)) {
|
|
826
|
+
await handleTweetUrl(session, raw);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
const username = raw.replace(/^@+/, "").trim();
|
|
830
|
+
if (!username) {
|
|
831
|
+
await session.send(session.text("messages.empty-url"));
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
if (config.outputLogs) logger7.info("按用户名处理最新推文:", username);
|
|
835
|
+
await session.send(session.text("messages.fetching"));
|
|
836
|
+
const latest = await fetcher.getLatestTweet(username);
|
|
837
|
+
const first = latest.tweets[0];
|
|
838
|
+
if (!first?.link) {
|
|
839
|
+
await session.send(session.text("messages.no-latest-tweet", [username]));
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
await processAndSend((content) => session.send(content), first.link, {
|
|
843
|
+
username,
|
|
844
|
+
isRetweet: first.isRetweet
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
__name(handleTweetInput, "handleTweetInput");
|
|
848
|
+
const poller = createPoller(ctx, config, repo, fetcher, {
|
|
849
|
+
pushTweet: /* @__PURE__ */ __name(async ({ username, url, isRetweet }) => {
|
|
850
|
+
const sub = config.subscriptions.find((s) => s.id === username);
|
|
851
|
+
if (!sub || !sub.targets?.length) return;
|
|
852
|
+
const send = /* @__PURE__ */ __name(async (content) => {
|
|
853
|
+
for (const target of sub.targets) {
|
|
854
|
+
try {
|
|
855
|
+
await sendToTarget(ctx, target, content);
|
|
856
|
+
} catch (e) {
|
|
857
|
+
logger7.error(`推送到 ${target} 失败`, e);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}, "send");
|
|
861
|
+
await processAndSend(send, url, { username, isRetweet });
|
|
862
|
+
}, "pushTweet")
|
|
863
|
+
});
|
|
864
|
+
registerCommands(ctx, config, {
|
|
865
|
+
handleTweetUrl,
|
|
866
|
+
handleTweetInput,
|
|
867
|
+
checkOnce: poller.checkOnce
|
|
868
|
+
});
|
|
869
|
+
registerLinkDetector(ctx, config, handleTweetUrl);
|
|
870
|
+
ctx.on("ready", async () => {
|
|
871
|
+
await poller.init();
|
|
872
|
+
poller.start();
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
__name(apply, "apply");
|
|
876
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
877
|
+
0 && (module.exports = {
|
|
878
|
+
Config,
|
|
879
|
+
apply,
|
|
880
|
+
inject,
|
|
881
|
+
logger,
|
|
882
|
+
name
|
|
883
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Context, Session } from 'koishi';
|
|
2
|
+
import { Config } from '../config';
|
|
3
|
+
import { XlonRepo } from '../db/repo';
|
|
4
|
+
import { XFetcher } from '../service/fetcher';
|
|
5
|
+
export declare function createPoller(ctx: Context, config: Config, repo: XlonRepo, fetcher: XFetcher, deps: {
|
|
6
|
+
pushTweet: (args: {
|
|
7
|
+
username: string;
|
|
8
|
+
url: string;
|
|
9
|
+
isRetweet: boolean;
|
|
10
|
+
}) => Promise<void>;
|
|
11
|
+
}): {
|
|
12
|
+
init: () => Promise<void>;
|
|
13
|
+
checkOnce: (session?: Session) => Promise<void>;
|
|
14
|
+
start: () => void;
|
|
15
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Context } from 'koishi';
|
|
2
|
+
import { Config } from '../config';
|
|
3
|
+
import { LatestResult, TweetDetail } from '../types';
|
|
4
|
+
export declare class XFetcher {
|
|
5
|
+
private ctx;
|
|
6
|
+
private config;
|
|
7
|
+
constructor(ctx: Context, config: Config);
|
|
8
|
+
/** puppeteer 是否可用 */
|
|
9
|
+
private get hasPuppeteer();
|
|
10
|
+
/**
|
|
11
|
+
* 获取博主最新推文链接(用于轮询判重)。
|
|
12
|
+
* 依赖 puppeteer 打开博主主页抓取。
|
|
13
|
+
*/
|
|
14
|
+
getLatestTweet(username: string, maxRetries?: number): Promise<LatestResult>;
|
|
15
|
+
/**
|
|
16
|
+
* 获取推文详情(截图 + 媒体 + ALT + 正文)。
|
|
17
|
+
* puppeteer 可用时截图,否则仅走 vxtwitter API。
|
|
18
|
+
*/
|
|
19
|
+
getTweetDetail(tweetUrlInput: string, maxRetries?: number): Promise<TweetDetail>;
|
|
20
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Context, h } from 'koishi';
|
|
2
|
+
import { Config } from '../config';
|
|
3
|
+
import { TweetDetail } from '../types';
|
|
4
|
+
export interface MessageSegment {
|
|
5
|
+
content: string | ReturnType<typeof h.image> | ReturnType<typeof h.video>;
|
|
6
|
+
}
|
|
7
|
+
export interface BuildOptions {
|
|
8
|
+
username?: string;
|
|
9
|
+
isRetweet?: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* 根据推文详情 + 翻译结果构建消息段列表。
|
|
13
|
+
* 不负责发送,由调用方决定发送方式(session.send / bot.sendMessage)。
|
|
14
|
+
*/
|
|
15
|
+
export declare function buildTweetMessages(ctx: Context, config: Config, detail: TweetDetail, translated: string, opts?: BuildOptions): Promise<MessageSegment[]>;
|
package/lib/types.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface XlonRecord {
|
|
2
|
+
/** 订阅的博主用户名 */
|
|
3
|
+
id: string;
|
|
4
|
+
/** 最新推文链接(用于判重) */
|
|
5
|
+
link: string;
|
|
6
|
+
/** 最新推文正文(用于判重/辅助) */
|
|
7
|
+
content: string;
|
|
8
|
+
}
|
|
9
|
+
export interface LatestResult {
|
|
10
|
+
tweets: Array<{
|
|
11
|
+
link: string;
|
|
12
|
+
isRetweet: boolean;
|
|
13
|
+
isVideo: boolean;
|
|
14
|
+
}>;
|
|
15
|
+
wordContent: string;
|
|
16
|
+
}
|
|
17
|
+
export interface TweetDetail {
|
|
18
|
+
url: string;
|
|
19
|
+
/** 供展示/翻译的正文(可能包含图片描述拼接) */
|
|
20
|
+
wordContent: string;
|
|
21
|
+
/** 图片 ALT 原文(单独展示用) */
|
|
22
|
+
altTexts: string[];
|
|
23
|
+
/** 媒体 URL(图片/视频) */
|
|
24
|
+
mediaUrls: string[];
|
|
25
|
+
/** 推文截图(webp),可能为空 */
|
|
26
|
+
screenshotBuffer: Buffer | null;
|
|
27
|
+
/** 是否为受保护账号(protected tweet 只返回部分内容) */
|
|
28
|
+
protectedAccount?: boolean;
|
|
29
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "koishi-plugin-xlon",
|
|
3
|
+
"description": "x subscriber",
|
|
4
|
+
"version": "1.0.1",
|
|
5
|
+
"contributors": [
|
|
6
|
+
"Logthm <logthm@outlook.com>"
|
|
7
|
+
],
|
|
8
|
+
"main": "lib/index.js",
|
|
9
|
+
"typings": "lib/index.d.ts",
|
|
10
|
+
"files": [
|
|
11
|
+
"lib",
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"keywords": [
|
|
16
|
+
"chatbot",
|
|
17
|
+
"koishi",
|
|
18
|
+
"plugin"
|
|
19
|
+
],
|
|
20
|
+
"koishi": {
|
|
21
|
+
"service": {
|
|
22
|
+
"required": [
|
|
23
|
+
"database"
|
|
24
|
+
],
|
|
25
|
+
"optional": [
|
|
26
|
+
"puppeteer"
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"koishi": "^4.18.10"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"koishi-plugin-puppeteer": "^3.9.0",
|
|
35
|
+
"typescript": "^5.9.3"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"yml-register": "^1.2.5"
|
|
39
|
+
}
|
|
40
|
+
}
|