koishi-plugin-nitter 0.0.6 → 0.0.8

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
+ export declare function downloadImagesToBase64(imageUrls: any): Promise<any[]>;
2
+ export declare function downloadVideosToBase64(videoUrls: any, maxSize?: number): Promise<any[]>;
@@ -0,0 +1,7 @@
1
+ export declare class FixedSet<T> {
2
+ private maxSize;
3
+ private set;
4
+ constructor(maxSize: number);
5
+ add(element: T): void;
6
+ has(element: T): boolean;
7
+ }
package/lib/index.d.ts CHANGED
@@ -14,9 +14,17 @@ export interface Config {
14
14
  temperature: number;
15
15
  timeout?: number;
16
16
  app: string;
17
- sendPic: boolean;
18
17
  enableReTweet: boolean;
18
+ sendPic: boolean;
19
+ maxSize: number;
20
+ }
21
+ declare module 'koishi' {
22
+ interface Tables {
23
+ nitter_records: {
24
+ id: string;
25
+ createdAt: Date;
26
+ };
27
+ }
19
28
  }
20
29
  export declare const Config: Schema<Config, Dict>;
21
30
  export declare function apply(ctx: Context, config: Config): void;
22
- export declare function downloadVideosToBase64(videoUrls: any): Promise<any[]>;
package/lib/index.js CHANGED
@@ -32,7 +32,6 @@ var src_exports = {};
32
32
  __export(src_exports, {
33
33
  Config: () => Config,
34
34
  apply: () => apply,
35
- downloadVideosToBase64: () => downloadVideosToBase64,
36
35
  inject: () => inject,
37
36
  name: () => name
38
37
  });
@@ -109,6 +108,7 @@ json输出格式:["翻译结果1", "翻译结果2", ...]`
109
108
  });
110
109
  if (response.data && response.data.choices && response.data.choices[0]) {
111
110
  const content = response.data.choices[0].message.content;
111
+ console.log(content);
112
112
  data = JSON.parse(content);
113
113
  if (!Array.isArray(data)) {
114
114
  if (typeof data === "object" && Object.keys(data).length === 1) {
@@ -122,7 +122,7 @@ json输出格式:["翻译结果1", "翻译结果2", ...]`
122
122
  throw new Error("API返回数据格式异常");
123
123
  }
124
124
  });
125
- return data;
125
+ return data.map((content) => content.replace(/\\n/g, "\n"));
126
126
  }, "translate");
127
127
  }
128
128
  __name(setOpenAiTranslate, "setOpenAiTranslate");
@@ -154,13 +154,87 @@ __name(addTranslate, "addTranslate");
154
154
  // src/index.tsx
155
155
  var import_rettiwt_api = require("rettiwt-api");
156
156
  var import_node_cron = require("node-cron");
157
+
158
+ // src/download.ts
157
159
  var import_fluent_ffmpeg = __toESM(require("fluent-ffmpeg"));
158
160
  var import_path = __toESM(require("path"));
159
161
  var import_promises = __toESM(require("fs/promises"));
162
+ var import_axios2 = __toESM(require("axios"));
163
+ var tempDir = import_path.default.join(process.cwd(), "tmp");
164
+ async function downloadVideosToBase64(videoUrls, maxSize = 20) {
165
+ await import_promises.default.mkdir(tempDir, { recursive: true });
166
+ const results = [];
167
+ for (const url of videoUrls) {
168
+ let outputPath = null;
169
+ try {
170
+ outputPath = import_path.default.join(tempDir, `video_${Date.now()}_${Math.random().toString(36).substr(2, 9)}.mp4`);
171
+ await new Promise((resolve, reject) => {
172
+ (0, import_fluent_ffmpeg.default)(url).inputOptions([
173
+ "-protocol_whitelist",
174
+ "file,http,https,tcp,tls,crypto"
175
+ ]).outputOptions([
176
+ "-c",
177
+ "copy"
178
+ ]).output(outputPath).on("end", () => resolve(outputPath)).on("error", reject).run();
179
+ });
180
+ const stats = await import_promises.default.stat(outputPath);
181
+ const fileSizeInMB = stats.size / (1024 * 1024);
182
+ if (fileSizeInMB > maxSize) {
183
+ console.log(`视频大小 ${fileSizeInMB.toFixed(2)}MB 超过${maxSize}MB限制,跳过转换`);
184
+ } else {
185
+ const fileBuffer = await import_promises.default.readFile(outputPath);
186
+ const base64String = fileBuffer.toString("base64");
187
+ const dataUrl = `data:video/mp4;base64,${base64String}`;
188
+ results.push(dataUrl);
189
+ }
190
+ } catch (error) {
191
+ console.error(`处理失败 (${url}):`, error.message);
192
+ results.push(null);
193
+ } finally {
194
+ if (outputPath) {
195
+ try {
196
+ await import_promises.default.unlink(outputPath);
197
+ } catch (deleteError) {
198
+ }
199
+ }
200
+ }
201
+ }
202
+ return results;
203
+ }
204
+ __name(downloadVideosToBase64, "downloadVideosToBase64");
205
+
206
+ // src/taskqueue.ts
207
+ var taskQueue = class {
208
+ static {
209
+ __name(this, "taskQueue");
210
+ }
211
+ queue = [];
212
+ consumer;
213
+ processing = false;
214
+ push(task) {
215
+ this.queue.push(task);
216
+ if (!this.processing) {
217
+ this.process();
218
+ }
219
+ }
220
+ onProcess(handler) {
221
+ this.consumer = handler;
222
+ }
223
+ async process() {
224
+ if (this.processing || !this.consumer) return;
225
+ this.processing = true;
226
+ while (this.queue.length > 0) {
227
+ const task = this.queue.shift();
228
+ await this.consumer(task);
229
+ }
230
+ this.processing = false;
231
+ }
232
+ };
233
+
234
+ // src/index.tsx
160
235
  var import_jsx_runtime = require("@satorijs/element/jsx-runtime");
161
236
  var name = "nitter";
162
- var logger = new import_koishi.Logger(name);
163
- var inject = ["puppeteer", "subscription"];
237
+ var inject = ["puppeteer", "subscription", "database"];
164
238
  var Config = import_koishi.Schema.intersect([
165
239
  import_koishi.Schema.object({
166
240
  apiKey: import_koishi.Schema.string().required().description("Twitter API Key"),
@@ -194,18 +268,31 @@ var Config = import_koishi.Schema.intersect([
194
268
  app: import_koishi.Schema.string().description("subscription配置中应用名")
195
269
  }).description("订阅配置"),
196
270
  import_koishi.Schema.object({
197
- sendPic: import_koishi.Schema.boolean().default(false).description("是否单独发送推文中的图片"),
198
- enableReTweet: import_koishi.Schema.boolean().default(false).description("是否发送转推")
199
- }).description("推送设置")
271
+ enableReTweet: import_koishi.Schema.boolean().default(false).description("是否发送转推"),
272
+ sendPic: import_koishi.Schema.boolean().default(false).description("是否单独发送推文中的图片")
273
+ }).description("推送设置"),
274
+ import_koishi.Schema.union([
275
+ import_koishi.Schema.object({
276
+ sendPic: import_koishi.Schema.const(true).required(),
277
+ maxSize: import_koishi.Schema.number().default(20).description("发送视频的最大大小,单位为mb")
278
+ })
279
+ ])
200
280
  ]);
201
281
  function apply(ctx, config) {
282
+ ctx.model.extend("nitter_records", {
283
+ id: "string",
284
+ createdAt: "timestamp"
285
+ }, {
286
+ primary: "id",
287
+ autoInc: false
288
+ });
202
289
  config.nitterUrl = config.nitterUrl.replace(/\/+$/, "");
203
290
  const twitterClient = new import_rettiwt_api.Rettiwt({
204
291
  apiKey: config.apiKey,
205
292
  proxyUrl: config.proxy ? new URL(config.proxy) : void 0
206
293
  });
207
- let latestTweetId;
208
294
  let cronJob;
295
+ const queue = new taskQueue();
209
296
  (async () => {
210
297
  if (config.enableTranslate == "google") {
211
298
  setGoogleTranslate(config.googleApiKey, config.proxy);
@@ -213,12 +300,14 @@ function apply(ctx, config) {
213
300
  setOpenAiTranslate(config.baseurl, config.openaiApiKey, config.model, config.prompt, config.temperature, config.timeout);
214
301
  }
215
302
  const tweetList = await getFollowedFeed();
216
- for (const data of tweetList) {
217
- latestTweetId ||= "";
218
- if (latestTweetId < data.id)
219
- latestTweetId = data.id;
220
- }
221
- cronJob = (0, import_node_cron.schedule)("15 * * * * *", checkForUpdates);
303
+ await ctx.database.upsert("nitter_records", tweetList.map((data) => {
304
+ return {
305
+ id: data.id,
306
+ createdAt: new Date(data.createdAt)
307
+ };
308
+ }));
309
+ queue.onProcess(broadcast);
310
+ cronJob = (0, import_node_cron.schedule)("15 */5 * * * *", checkForUpdates);
222
311
  ctx.on("dispose", () => {
223
312
  cronJob.stop();
224
313
  });
@@ -244,10 +333,10 @@ function apply(ctx, config) {
244
333
  return "请输入推文ID";
245
334
  }
246
335
  try {
247
- const [screenshot, imageUrls, hlsUrls] = await renderTweetScreenshot(ctx, tweetId, config);
336
+ const [screenshot, imageUrls, hlsUrls] = await renderTweetScreenshot(tweetId);
248
337
  let msg = /* @__PURE__ */ (0, import_jsx_runtime.jsx)("img", { src: "data:image/png;base64," + screenshot.toString("base64") });
249
338
  let forwardMsg = [];
250
- const videoUrls = await downloadVideosToBase64(hlsUrls);
339
+ const videoUrls = await downloadVideosToBase64(hlsUrls, config.maxSize);
251
340
  if (imageUrls.length > 0) {
252
341
  forwardMsg.push(...imageUrls.map((url) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("message", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("img", { src: url }) })));
253
342
  }
@@ -264,15 +353,16 @@ function apply(ctx, config) {
264
353
  if (!tweetId) {
265
354
  return "请输入推文ID";
266
355
  }
267
- broadcast(account, tweetId);
356
+ queue.push({ account, tweetId });
268
357
  });
269
- async function broadcast(account, tweetId) {
358
+ async function broadcast(task) {
359
+ const { account, tweetId } = task;
270
360
  try {
271
- const [screenshot, imageUrls, hlsUrls] = await renderTweetScreenshot(ctx, tweetId, config);
361
+ const [screenshot, imageUrls, hlsUrls] = await renderTweetScreenshot(tweetId);
272
362
  const screenshotMsg = /* @__PURE__ */ (0, import_jsx_runtime.jsx)("img", { src: "data:image/png;base64," + screenshot.toString("base64") });
273
363
  ctx.subscription.broadcast(config.app, account, screenshotMsg);
274
364
  let forwardMsg = [];
275
- const videoUrls = await downloadVideosToBase64(hlsUrls);
365
+ const videoUrls = await downloadVideosToBase64(hlsUrls, config.maxSize);
276
366
  if (imageUrls.length > 0) {
277
367
  forwardMsg.push(...imageUrls.map((url) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("message", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("img", { src: url }) })));
278
368
  }
@@ -300,117 +390,105 @@ function apply(ctx, config) {
300
390
  for (const data of tweetList) {
301
391
  if (!config.enableReTweet && data.retweetedTweet)
302
392
  continue;
303
- if (data.id <= latestTweetId)
393
+ if ((/* @__PURE__ */ new Date()).getTime() - new Date(data.createdAt).getTime() > 60 * 60 * 1e3)
394
+ continue;
395
+ if ((await ctx.database.get("nitter_records", { id: data.id })).length > 0)
304
396
  continue;
397
+ await ctx.database.upsert("nitter_records", [{
398
+ id: data.id,
399
+ createdAt: new Date(data.createdAt)
400
+ }]);
305
401
  if (!ctx.subscription.getAvailableAccounts(config.app).includes(data.tweetBy.userName))
306
402
  continue;
307
- latestTweetId = data.id;
308
403
  ctx.logger("nitter").info(`检测到推文id:${data.id},开始推送`);
309
- await broadcast(data.tweetBy.userName, data.id);
404
+ queue.push({ account: data.tweetBy.userName, tweetId: data.id });
310
405
  }
311
406
  }
312
407
  __name(checkForUpdates, "checkForUpdates");
313
- }
314
- __name(apply, "apply");
315
- var tempDir = import_path.default.join(process.cwd(), "tmp");
316
- async function downloadVideosToBase64(videoUrls) {
317
- await import_promises.default.mkdir(tempDir, { recursive: true });
318
- const results = [];
319
- for (const url of videoUrls) {
320
- let outputPath = null;
408
+ async function renderTweetScreenshot(tweetId) {
409
+ const puppeteer = ctx.puppeteer;
410
+ if (!puppeteer) {
411
+ throw new Error("Puppeteer 服务未找到,请安装 koishi-plugin-puppeteer");
412
+ }
413
+ const page = await puppeteer.page();
321
414
  try {
322
- outputPath = import_path.default.join(tempDir, `video_${Date.now()}_${Math.random().toString(36).substr(2, 9)}.mp4`);
323
- await new Promise((resolve, reject) => {
324
- (0, import_fluent_ffmpeg.default)(url).inputOptions([
325
- "-protocol_whitelist",
326
- "file,http,https,tcp,tls,crypto"
327
- ]).outputOptions([
328
- "-c",
329
- "copy"
330
- ]).output(outputPath).on("end", () => resolve(outputPath)).on("error", reject).run();
331
- });
332
- const stats = await import_promises.default.stat(outputPath);
333
- const fileSizeInMB = stats.size / (1024 * 1024);
334
- if (fileSizeInMB > 10) {
335
- logger.info(`视频大小 ${fileSizeInMB.toFixed(2)}MB 超过10MB限制,跳过转换`);
336
- } else {
337
- const fileBuffer = await import_promises.default.readFile(outputPath);
338
- const base64String = fileBuffer.toString("base64");
339
- const dataUrl = `data:video/mp4;base64,${base64String}`;
340
- results.push(dataUrl);
341
- }
342
- } catch (error) {
343
- console.error(`处理失败 (${url}):`, error.message);
344
- results.push(null);
345
- } finally {
346
- if (outputPath) {
415
+ const tweetUrl = `${config.nitterUrl}/i/status/${tweetId}`;
416
+ await retry(3, async () => {
417
+ await page.goto(tweetUrl);
418
+ const element2 = await page.$(".main-thread");
419
+ if (!element2) throw new Error("Rate Limited");
420
+ }, 2e3);
421
+ if (config.enableTranslate) {
347
422
  try {
348
- await import_promises.default.unlink(outputPath);
349
- } catch (deleteError) {
423
+ await addTranslate(page, ".main-thread .tweet-content, .main-thread .quote-text");
424
+ } catch (e) {
425
+ ctx.logger("nitter").info("翻译失败", e);
350
426
  }
351
427
  }
352
- }
353
- }
354
- return results;
355
- }
356
- __name(downloadVideosToBase64, "downloadVideosToBase64");
357
- async function renderTweetScreenshot(ctx, tweetId, config) {
358
- const puppeteer = ctx.puppeteer;
359
- if (!puppeteer) {
360
- throw new Error("Puppeteer 服务未找到,请安装 koishi-plugin-puppeteer");
361
- }
362
- const page = await puppeteer.page();
363
- try {
364
- const tweetUrl = `${config.nitterUrl}/i/status/${tweetId}`;
365
- await retry(3, async () => {
366
- await page.goto(tweetUrl);
367
- const element2 = await page.$(".main-thread");
368
- if (!element2) throw new Error("Rate Limited");
369
- }, 2e3);
370
- if (config.enableTranslate) {
371
- try {
372
- await addTranslate(page, ".main-thread .tweet-content, .main-thread .quote-text");
373
- } catch (e) {
374
- ctx.logger("nitter").info("翻译失败", e);
375
- }
376
- }
377
- await new Promise((resolve) => setTimeout(resolve, 200));
378
- await page.evaluate(() => {
379
- const nav = document.querySelector("nav");
380
- if (nav) {
381
- nav.style.visibility = "hidden";
428
+ await page.evaluate(() => {
429
+ const nav = document.querySelector("nav");
430
+ if (nav) {
431
+ nav.style.visibility = "hidden";
432
+ }
433
+ });
434
+ await new Promise((resolve) => setTimeout(resolve, 1e3));
435
+ await page.evaluate(() => {
436
+ const element2 = document.querySelector(".main-thread");
437
+ if (!element2) return;
438
+ Object.assign(element2.style, {
439
+ border: "1px solid #1DA1F2",
440
+ borderRadius: "8px",
441
+ boxShadow: "0px 1px 9px 12px rgba(29, 161, 242, 0.2)",
442
+ margin: "20px",
443
+ boxSizing: "border-box",
444
+ overflow: "hidden",
445
+ width: "100%",
446
+ padding: "10px 10px 0 10px",
447
+ backgroundColor: "#fff"
448
+ });
449
+ });
450
+ await new Promise((resolve) => setTimeout(resolve, 100));
451
+ const element = await page.$(".main-thread");
452
+ const boundingBox = await page.evaluate((el) => {
453
+ const rect = el.getBoundingClientRect();
454
+ const margin = 20;
455
+ return {
456
+ x: rect.left - margin,
457
+ y: rect.top - margin,
458
+ width: rect.width + margin * 2,
459
+ height: rect.height + margin * 2
460
+ };
461
+ }, element);
462
+ const buffer = await page.screenshot({
463
+ type: "png",
464
+ omitBackground: false,
465
+ clip: boundingBox
466
+ });
467
+ if (config.sendPic) {
468
+ const originalImages = await page.$$eval(
469
+ ".main-tweet a.still-image",
470
+ (links, baseUrl) => links.map((link) => link.getAttribute("href")).filter((href) => href).map((href) => `${baseUrl}${href}`),
471
+ config.nitterUrl
472
+ );
473
+ const hlsUrls = await page.$$eval(".main-tweet video", (videos, baseUrl) => {
474
+ return videos.map((video) => video.getAttribute("data-url")).filter((dataUrl) => dataUrl).map((dataUrl) => `${baseUrl}${dataUrl}`);
475
+ }, config.nitterUrl);
476
+ return [buffer, originalImages, hlsUrls];
382
477
  }
383
- });
384
- const element = await page.$(".main-thread");
385
- const buffer = await element.screenshot({
386
- // 不指定path参数,直接返回buffer
387
- type: "png",
388
- omitBackground: false
389
- });
390
- if (config.sendPic) {
391
- const originalImages = await page.$$eval(
392
- ".main-tweet a.still-image",
393
- (links, baseUrl) => links.map((link) => link.getAttribute("href")).filter((href) => href).map((href) => `${baseUrl}${href}`),
394
- config.nitterUrl
395
- );
396
- const hlsUrls = await page.$$eval(".main-tweet video", (videos, baseUrl) => {
397
- return videos.map((video) => video.getAttribute("data-url")).filter((dataUrl) => dataUrl).map((dataUrl) => `${baseUrl}${dataUrl}`);
398
- }, config.nitterUrl);
399
- return [buffer, originalImages, hlsUrls];
478
+ return [buffer, [], []];
479
+ } catch (e) {
480
+ throw e;
481
+ } finally {
482
+ await page.close();
400
483
  }
401
- return [buffer, [], []];
402
- } catch (e) {
403
- throw e;
404
- } finally {
405
- await page.close();
406
484
  }
485
+ __name(renderTweetScreenshot, "renderTweetScreenshot");
407
486
  }
408
- __name(renderTweetScreenshot, "renderTweetScreenshot");
487
+ __name(apply, "apply");
409
488
  // Annotate the CommonJS export names for ESM import in node:
410
489
  0 && (module.exports = {
411
490
  Config,
412
491
  apply,
413
- downloadVideosToBase64,
414
492
  inject,
415
493
  name
416
494
  });
@@ -0,0 +1,8 @@
1
+ export declare class taskQueue<T> {
2
+ private queue;
3
+ private consumer?;
4
+ private processing;
5
+ push(task: T): void;
6
+ onProcess(handler: (task: T) => Promise<void> | void): void;
7
+ private process;
8
+ }
@@ -1,6 +1,5 @@
1
1
  import { Page } from 'puppeteer-core';
2
2
  export declare function retry(retries: number, fn: () => any, delay?: number): any;
3
3
  export declare function setGoogleTranslate(key: string, proxy: string): void;
4
- export declare function setSiliconTranslate(key: string, model: string, prompt: string, timeout?: number): void;
5
4
  export declare function setOpenAiTranslate(url: string, key: string, model: string, prompt: string, temperature: number, timeout?: number): void;
6
5
  export declare function addTranslate(page: Page, className: string): Promise<void>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-nitter",
3
3
  "description": "使用Rettiwt-API订阅推文,并使用nitter渲染",
4
- "version": "0.0.6",
4
+ "version": "0.0.8",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [
@@ -22,8 +22,8 @@
22
22
  "koishi-plugin-subscription": "^0.0.5"
23
23
  },
24
24
  "dependencies": {
25
+ "fluent-ffmpeg": "^2.1.3",
25
26
  "node-cron": "^4.2.1",
26
- "rettiwt-api": "^6.0.6",
27
- "fluent-ffmpeg": "^2.1.3"
27
+ "rettiwt-api": "^6.1.3"
28
28
  }
29
29
  }