koztv-blog-tools 1.0.5 → 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.
package/dist/index.d.mts CHANGED
@@ -115,22 +115,22 @@ declare function trackLearnMore(service: string): void;
115
115
  declare function trackServiceClick(service: string): void;
116
116
 
117
117
  /**
118
- * Translation utilities using various AI APIs
118
+ * Translation utilities using OpenAI-compatible APIs
119
119
  */
120
120
  interface TranslateOptions {
121
- /** API key for the translation service */
121
+ /** API key */
122
122
  apiKey: string;
123
+ /** API base URL (e.g., https://api.openai.com/v1, https://open.bigmodel.cn/api/paas/v4) */
124
+ apiUrl: string;
125
+ /** Model name (e.g., gpt-4o-mini, GLM-4.7-Flash) */
126
+ model: string;
123
127
  /** Target language (default: 'en') */
124
128
  targetLang?: string;
125
129
  /** Source language (default: 'ru') */
126
130
  sourceLang?: string;
127
- /** API provider (default: 'glm') */
128
- provider?: 'glm' | 'openai';
129
- /** Model to use (default depends on provider) */
130
- model?: string;
131
131
  }
132
132
  /**
133
- * Translate text content
133
+ * Translate text using any OpenAI-compatible API
134
134
  */
135
135
  declare function translateContent(text: string, options: TranslateOptions): Promise<string>;
136
136
  /**
@@ -142,4 +142,78 @@ declare function translateTitle(title: string, options: TranslateOptions): Promi
142
142
  */
143
143
  declare function generateEnglishSlug(title: string): string;
144
144
 
145
- export { type AnalyticsConfig, type GoalName, type GoalParams, type GroupedPost, type ParsePostOptions, type Post, type TranslateOptions, categorizePost, cleanContent, configureAnalytics, deduplicatePosts, extractAttachments, extractExcerpt, extractTitle, generateEnglishSlug, generateSlug, groupPosts, parsePost, trackBookAppointment, trackGoal, trackLearnMore, trackServiceClick, trackTelegramClick, translateContent, translateTitle };
145
+ /**
146
+ * Telegram channel export utilities using gramjs (MTProto)
147
+ */
148
+
149
+ interface TelegramExportOptions {
150
+ /** Telegram API ID from https://my.telegram.org */
151
+ apiId: number;
152
+ /** Telegram API Hash from https://my.telegram.org */
153
+ apiHash: string;
154
+ /** Session string (for re-authentication). If empty, will prompt for login */
155
+ session?: string;
156
+ /** Target channel username, link or ID */
157
+ target: string;
158
+ /** Output directory for exported data */
159
+ outputDir: string;
160
+ /** Maximum number of posts to export (0 = all) */
161
+ limit?: number;
162
+ /** Only export posts since this date */
163
+ since?: Date;
164
+ /** Only export posts until this date */
165
+ until?: Date;
166
+ /** Download media files */
167
+ downloadMedia?: boolean;
168
+ /** Number of concurrent media downloads */
169
+ mediaWorkers?: number;
170
+ /** Callback for progress updates */
171
+ onProgress?: (current: number, total: number, message: string) => void;
172
+ /** Callback to get phone number for login */
173
+ onPhoneNumber?: () => Promise<string>;
174
+ /** Callback to get verification code */
175
+ onCode?: () => Promise<string>;
176
+ /** Callback to get 2FA password */
177
+ onPassword?: () => Promise<string>;
178
+ /** Callback when session string is generated (save this for future use) */
179
+ onSession?: (session: string) => void;
180
+ }
181
+ interface ExportedPost {
182
+ msgId: number;
183
+ date: Date;
184
+ content: string;
185
+ hasMedia: boolean;
186
+ mediaFiles: string[];
187
+ views?: number;
188
+ forwards?: number;
189
+ link: string;
190
+ channelUsername: string;
191
+ channelTitle: string;
192
+ }
193
+ interface ExportResult {
194
+ channelMeta: {
195
+ id: number;
196
+ username: string;
197
+ title: string;
198
+ description?: string;
199
+ participantsCount?: number;
200
+ };
201
+ posts: ExportedPost[];
202
+ session: string;
203
+ }
204
+ /**
205
+ * Export messages from a Telegram channel
206
+ */
207
+ declare function exportTelegramChannel(options: TelegramExportOptions): Promise<ExportResult>;
208
+ /**
209
+ * Format a post as markdown with YAML frontmatter
210
+ */
211
+ declare function formatPostMarkdown(post: ExportedPost): string;
212
+ /**
213
+ * Resume export from a saved session
214
+ */
215
+ declare function resumeExport(options: Omit<TelegramExportOptions, 'onPhoneNumber' | 'onCode' | 'onPassword'> & {
216
+ session: string;
217
+ }): Promise<ExportResult>;
218
+
219
+ export { type AnalyticsConfig, type ExportResult, type ExportedPost, type GoalName, type GoalParams, type GroupedPost, type ParsePostOptions, type Post, type TelegramExportOptions, type TranslateOptions, categorizePost, cleanContent, configureAnalytics, deduplicatePosts, exportTelegramChannel, extractAttachments, extractExcerpt, extractTitle, formatPostMarkdown, generateEnglishSlug, generateSlug, groupPosts, parsePost, resumeExport, trackBookAppointment, trackGoal, trackLearnMore, trackServiceClick, trackTelegramClick, translateContent, translateTitle };
package/dist/index.d.ts CHANGED
@@ -115,22 +115,22 @@ declare function trackLearnMore(service: string): void;
115
115
  declare function trackServiceClick(service: string): void;
116
116
 
117
117
  /**
118
- * Translation utilities using various AI APIs
118
+ * Translation utilities using OpenAI-compatible APIs
119
119
  */
120
120
  interface TranslateOptions {
121
- /** API key for the translation service */
121
+ /** API key */
122
122
  apiKey: string;
123
+ /** API base URL (e.g., https://api.openai.com/v1, https://open.bigmodel.cn/api/paas/v4) */
124
+ apiUrl: string;
125
+ /** Model name (e.g., gpt-4o-mini, GLM-4.7-Flash) */
126
+ model: string;
123
127
  /** Target language (default: 'en') */
124
128
  targetLang?: string;
125
129
  /** Source language (default: 'ru') */
126
130
  sourceLang?: string;
127
- /** API provider (default: 'glm') */
128
- provider?: 'glm' | 'openai';
129
- /** Model to use (default depends on provider) */
130
- model?: string;
131
131
  }
132
132
  /**
133
- * Translate text content
133
+ * Translate text using any OpenAI-compatible API
134
134
  */
135
135
  declare function translateContent(text: string, options: TranslateOptions): Promise<string>;
136
136
  /**
@@ -142,4 +142,78 @@ declare function translateTitle(title: string, options: TranslateOptions): Promi
142
142
  */
143
143
  declare function generateEnglishSlug(title: string): string;
144
144
 
145
- export { type AnalyticsConfig, type GoalName, type GoalParams, type GroupedPost, type ParsePostOptions, type Post, type TranslateOptions, categorizePost, cleanContent, configureAnalytics, deduplicatePosts, extractAttachments, extractExcerpt, extractTitle, generateEnglishSlug, generateSlug, groupPosts, parsePost, trackBookAppointment, trackGoal, trackLearnMore, trackServiceClick, trackTelegramClick, translateContent, translateTitle };
145
+ /**
146
+ * Telegram channel export utilities using gramjs (MTProto)
147
+ */
148
+
149
+ interface TelegramExportOptions {
150
+ /** Telegram API ID from https://my.telegram.org */
151
+ apiId: number;
152
+ /** Telegram API Hash from https://my.telegram.org */
153
+ apiHash: string;
154
+ /** Session string (for re-authentication). If empty, will prompt for login */
155
+ session?: string;
156
+ /** Target channel username, link or ID */
157
+ target: string;
158
+ /** Output directory for exported data */
159
+ outputDir: string;
160
+ /** Maximum number of posts to export (0 = all) */
161
+ limit?: number;
162
+ /** Only export posts since this date */
163
+ since?: Date;
164
+ /** Only export posts until this date */
165
+ until?: Date;
166
+ /** Download media files */
167
+ downloadMedia?: boolean;
168
+ /** Number of concurrent media downloads */
169
+ mediaWorkers?: number;
170
+ /** Callback for progress updates */
171
+ onProgress?: (current: number, total: number, message: string) => void;
172
+ /** Callback to get phone number for login */
173
+ onPhoneNumber?: () => Promise<string>;
174
+ /** Callback to get verification code */
175
+ onCode?: () => Promise<string>;
176
+ /** Callback to get 2FA password */
177
+ onPassword?: () => Promise<string>;
178
+ /** Callback when session string is generated (save this for future use) */
179
+ onSession?: (session: string) => void;
180
+ }
181
+ interface ExportedPost {
182
+ msgId: number;
183
+ date: Date;
184
+ content: string;
185
+ hasMedia: boolean;
186
+ mediaFiles: string[];
187
+ views?: number;
188
+ forwards?: number;
189
+ link: string;
190
+ channelUsername: string;
191
+ channelTitle: string;
192
+ }
193
+ interface ExportResult {
194
+ channelMeta: {
195
+ id: number;
196
+ username: string;
197
+ title: string;
198
+ description?: string;
199
+ participantsCount?: number;
200
+ };
201
+ posts: ExportedPost[];
202
+ session: string;
203
+ }
204
+ /**
205
+ * Export messages from a Telegram channel
206
+ */
207
+ declare function exportTelegramChannel(options: TelegramExportOptions): Promise<ExportResult>;
208
+ /**
209
+ * Format a post as markdown with YAML frontmatter
210
+ */
211
+ declare function formatPostMarkdown(post: ExportedPost): string;
212
+ /**
213
+ * Resume export from a saved session
214
+ */
215
+ declare function resumeExport(options: Omit<TelegramExportOptions, 'onPhoneNumber' | 'onCode' | 'onPassword'> & {
216
+ session: string;
217
+ }): Promise<ExportResult>;
218
+
219
+ export { type AnalyticsConfig, type ExportResult, type ExportedPost, type GoalName, type GoalParams, type GroupedPost, type ParsePostOptions, type Post, type TelegramExportOptions, type TranslateOptions, categorizePost, cleanContent, configureAnalytics, deduplicatePosts, exportTelegramChannel, extractAttachments, extractExcerpt, extractTitle, formatPostMarkdown, generateEnglishSlug, generateSlug, groupPosts, parsePost, resumeExport, trackBookAppointment, trackGoal, trackLearnMore, trackServiceClick, trackTelegramClick, translateContent, translateTitle };
package/dist/index.js CHANGED
@@ -34,13 +34,16 @@ __export(index_exports, {
34
34
  cleanContent: () => cleanContent,
35
35
  configureAnalytics: () => configureAnalytics,
36
36
  deduplicatePosts: () => deduplicatePosts,
37
+ exportTelegramChannel: () => exportTelegramChannel,
37
38
  extractAttachments: () => extractAttachments,
38
39
  extractExcerpt: () => extractExcerpt,
39
40
  extractTitle: () => extractTitle,
41
+ formatPostMarkdown: () => formatPostMarkdown,
40
42
  generateEnglishSlug: () => generateEnglishSlug,
41
43
  generateSlug: () => generateSlug,
42
44
  groupPosts: () => groupPosts,
43
45
  parsePost: () => parsePost,
46
+ resumeExport: () => resumeExport,
44
47
  trackBookAppointment: () => trackBookAppointment,
45
48
  trackGoal: () => trackGoal,
46
49
  trackLearnMore: () => trackLearnMore,
@@ -309,10 +312,8 @@ function trackServiceClick(service) {
309
312
  }
310
313
 
311
314
  // src/translate.ts
312
- async function translateWithGLM(text, options) {
313
- const model = options.model || "GLM-4.7";
314
- const targetLang = options.targetLang || "en";
315
- const sourceLang = options.sourceLang || "ru";
315
+ async function translateContent(text, options) {
316
+ const { apiKey, apiUrl, model, targetLang = "en", sourceLang = "ru" } = options;
316
317
  const messages = [
317
318
  {
318
319
  role: "system",
@@ -326,11 +327,12 @@ Do not add any explanations or notes.`
326
327
  content: text
327
328
  }
328
329
  ];
329
- const response = await fetch("https://open.bigmodel.cn/api/paas/v4/chat/completions", {
330
+ const endpoint = apiUrl.endsWith("/") ? `${apiUrl}chat/completions` : `${apiUrl}/chat/completions`;
331
+ const response = await fetch(endpoint, {
330
332
  method: "POST",
331
333
  headers: {
332
334
  "Content-Type": "application/json",
333
- "Authorization": `Bearer ${options.apiKey}`
335
+ "Authorization": `Bearer ${apiKey}`
334
336
  },
335
337
  body: JSON.stringify({
336
338
  model,
@@ -340,56 +342,11 @@ Do not add any explanations or notes.`
340
342
  });
341
343
  if (!response.ok) {
342
344
  const error = await response.text();
343
- throw new Error(`GLM API error: ${response.status} - ${error}`);
345
+ throw new Error(`API error: ${response.status} - ${error}`);
344
346
  }
345
347
  const data = await response.json();
346
348
  return data.choices[0]?.message?.content || "";
347
349
  }
348
- async function translateWithOpenAI(text, options) {
349
- const model = options.model || "gpt-4o-mini";
350
- const targetLang = options.targetLang || "en";
351
- const sourceLang = options.sourceLang || "ru";
352
- const response = await fetch("https://api.openai.com/v1/chat/completions", {
353
- method: "POST",
354
- headers: {
355
- "Content-Type": "application/json",
356
- "Authorization": `Bearer ${options.apiKey}`
357
- },
358
- body: JSON.stringify({
359
- model,
360
- messages: [
361
- {
362
- role: "system",
363
- content: `You are a professional translator. Translate the following text from ${sourceLang} to ${targetLang}.
364
- Keep the markdown formatting intact.
365
- Only output the translated text, nothing else.`
366
- },
367
- {
368
- role: "user",
369
- content: text
370
- }
371
- ],
372
- temperature: 0.3
373
- })
374
- });
375
- if (!response.ok) {
376
- const error = await response.text();
377
- throw new Error(`OpenAI API error: ${response.status} - ${error}`);
378
- }
379
- const data = await response.json();
380
- return data.choices[0]?.message?.content || "";
381
- }
382
- async function translateContent(text, options) {
383
- const provider = options.provider || "glm";
384
- switch (provider) {
385
- case "glm":
386
- return translateWithGLM(text, options);
387
- case "openai":
388
- return translateWithOpenAI(text, options);
389
- default:
390
- throw new Error(`Unknown provider: ${provider}`);
391
- }
392
- }
393
350
  async function translateTitle(title, options) {
394
351
  const translated = await translateContent(title, options);
395
352
  return translated.replace(/^["']|["']$/g, "").trim();
@@ -397,19 +354,254 @@ async function translateTitle(title, options) {
397
354
  function generateEnglishSlug(title) {
398
355
  return title.toLowerCase().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").substring(0, 60);
399
356
  }
357
+
358
+ // src/telegram.ts
359
+ var import_telegram = require("telegram");
360
+ var import_sessions = require("telegram/sessions");
361
+ var fs = __toESM(require("fs"));
362
+ var path = __toESM(require("path"));
363
+ var readline = __toESM(require("readline"));
364
+ async function defaultReadline(prompt) {
365
+ const rl = readline.createInterface({
366
+ input: process.stdin,
367
+ output: process.stdout
368
+ });
369
+ return new Promise((resolve) => {
370
+ rl.question(prompt, (answer) => {
371
+ rl.close();
372
+ resolve(answer);
373
+ });
374
+ });
375
+ }
376
+ async function exportTelegramChannel(options) {
377
+ const {
378
+ apiId,
379
+ apiHash,
380
+ session = "",
381
+ target,
382
+ outputDir,
383
+ limit = 0,
384
+ since,
385
+ until,
386
+ downloadMedia = true,
387
+ mediaWorkers = 3,
388
+ onProgress,
389
+ onPhoneNumber = () => defaultReadline("Phone number: "),
390
+ onCode = () => defaultReadline("Verification code: "),
391
+ onPassword = () => defaultReadline("2FA Password: "),
392
+ onSession
393
+ } = options;
394
+ const postsDir = path.join(outputDir, "posts");
395
+ const mediaDir = path.join(outputDir, "media");
396
+ fs.mkdirSync(postsDir, { recursive: true });
397
+ fs.mkdirSync(mediaDir, { recursive: true });
398
+ const stringSession = new import_sessions.StringSession(session);
399
+ const client = new import_telegram.TelegramClient(stringSession, apiId, apiHash, {
400
+ connectionRetries: 5
401
+ });
402
+ await client.start({
403
+ phoneNumber: onPhoneNumber,
404
+ phoneCode: onCode,
405
+ password: onPassword,
406
+ onError: (err) => console.error("Auth error:", err)
407
+ });
408
+ const newSession = client.session.save();
409
+ if (onSession) {
410
+ onSession(newSession);
411
+ }
412
+ const entity = await client.getEntity(target);
413
+ if (!(entity instanceof import_telegram.Api.Channel)) {
414
+ throw new Error(`Target "${target}" is not a channel`);
415
+ }
416
+ const channelMeta = {
417
+ id: entity.id.toJSNumber(),
418
+ username: entity.username || "",
419
+ title: entity.title,
420
+ description: void 0,
421
+ participantsCount: void 0
422
+ };
423
+ try {
424
+ const fullChannel = await client.invoke(
425
+ new import_telegram.Api.channels.GetFullChannel({ channel: entity })
426
+ );
427
+ if (fullChannel.fullChat instanceof import_telegram.Api.ChannelFull) {
428
+ channelMeta.description = fullChannel.fullChat.about;
429
+ channelMeta.participantsCount = fullChannel.fullChat.participantsCount;
430
+ }
431
+ } catch (e) {
432
+ }
433
+ fs.writeFileSync(
434
+ path.join(outputDir, "channel_meta.json"),
435
+ JSON.stringify(channelMeta, null, 2)
436
+ );
437
+ const posts = [];
438
+ let processedCount = 0;
439
+ const iterParams = {
440
+ entity,
441
+ reverse: true
442
+ // Oldest first
443
+ };
444
+ if (limit > 0) {
445
+ iterParams.limit = limit;
446
+ }
447
+ if (since) {
448
+ iterParams.offsetDate = Math.floor(since.getTime() / 1e3);
449
+ }
450
+ let totalMessages = limit || 0;
451
+ if (!limit) {
452
+ try {
453
+ const history = await client.invoke(
454
+ new import_telegram.Api.messages.GetHistory({
455
+ peer: entity,
456
+ limit: 1,
457
+ offsetId: 0,
458
+ offsetDate: 0,
459
+ addOffset: 0,
460
+ maxId: 0,
461
+ minId: 0,
462
+ hash: 0n
463
+ })
464
+ );
465
+ if ("count" in history) {
466
+ totalMessages = history.count;
467
+ }
468
+ } catch (e) {
469
+ }
470
+ }
471
+ for await (const message of client.iterMessages(entity, iterParams)) {
472
+ if (until && message.date && message.date * 1e3 > until.getTime()) {
473
+ continue;
474
+ }
475
+ if (since && message.date && message.date * 1e3 < since.getTime()) {
476
+ break;
477
+ }
478
+ processedCount++;
479
+ if (onProgress) {
480
+ onProgress(processedCount, totalMessages, `Processing message ${message.id}`);
481
+ }
482
+ const msgId = message.id;
483
+ const paddedId = String(msgId).padStart(6, "0");
484
+ const postMediaDir = path.join(mediaDir, paddedId);
485
+ const mediaFiles = [];
486
+ if (downloadMedia && message.media) {
487
+ fs.mkdirSync(postMediaDir, { recursive: true });
488
+ try {
489
+ const buffer = await client.downloadMedia(message.media, {});
490
+ if (buffer) {
491
+ let ext = ".bin";
492
+ if (message.media instanceof import_telegram.Api.MessageMediaPhoto) {
493
+ ext = ".jpg";
494
+ } else if (message.media instanceof import_telegram.Api.MessageMediaDocument) {
495
+ const doc = message.media.document;
496
+ if (doc instanceof import_telegram.Api.Document) {
497
+ const mimeExt = doc.mimeType?.split("/")[1];
498
+ if (mimeExt) {
499
+ ext = "." + mimeExt.replace("jpeg", "jpg");
500
+ }
501
+ for (const attr of doc.attributes) {
502
+ if (attr instanceof import_telegram.Api.DocumentAttributeVideo) {
503
+ ext = ".mp4";
504
+ }
505
+ if (attr instanceof import_telegram.Api.DocumentAttributeFilename) {
506
+ ext = path.extname(attr.fileName) || ext;
507
+ }
508
+ }
509
+ }
510
+ }
511
+ const mediaFileName = `media${ext}`;
512
+ const mediaPath = path.join(postMediaDir, mediaFileName);
513
+ fs.writeFileSync(mediaPath, buffer);
514
+ mediaFiles.push(`media/${paddedId}/${mediaFileName}`);
515
+ }
516
+ } catch (e) {
517
+ console.error(`Error downloading media for message ${msgId}:`, e);
518
+ }
519
+ }
520
+ const content = message.message || "";
521
+ const link = channelMeta.username ? `https://t.me/${channelMeta.username}/${msgId}` : "";
522
+ const post = {
523
+ msgId,
524
+ date: new Date(message.date * 1e3),
525
+ content,
526
+ hasMedia: mediaFiles.length > 0 || !!message.media,
527
+ mediaFiles,
528
+ views: message.views,
529
+ forwards: message.forwards,
530
+ link,
531
+ channelUsername: channelMeta.username,
532
+ channelTitle: channelMeta.title
533
+ };
534
+ posts.push(post);
535
+ const markdown = formatPostMarkdown(post);
536
+ fs.writeFileSync(path.join(postsDir, `${paddedId}.md`), markdown);
537
+ }
538
+ const ndjsonPath = path.join(outputDir, "posts.ndjson");
539
+ const ndjsonContent = posts.map((p) => JSON.stringify(p)).join("\n");
540
+ fs.writeFileSync(ndjsonPath, ndjsonContent);
541
+ await client.disconnect();
542
+ return {
543
+ channelMeta,
544
+ posts,
545
+ session: newSession
546
+ };
547
+ }
548
+ function formatPostMarkdown(post) {
549
+ const dateStr = post.date.toISOString();
550
+ const dateOnly = dateStr.split("T")[0];
551
+ let frontmatter = `---
552
+ msg_id: ${post.msgId}
553
+ date: ${dateStr}
554
+ channel_username: "${post.channelUsername}"
555
+ channel_title: "${post.channelTitle.replace(/"/g, '\\"')}"
556
+ link: "${post.link}"
557
+ has_media: ${post.hasMedia}`;
558
+ if (post.views !== void 0) {
559
+ frontmatter += `
560
+ views: ${post.views}`;
561
+ }
562
+ if (post.forwards !== void 0) {
563
+ frontmatter += `
564
+ forwards: ${post.forwards}`;
565
+ }
566
+ frontmatter += "\n---\n\n";
567
+ let body = post.content || "";
568
+ if (post.mediaFiles.length > 0) {
569
+ body += "\n\n## Attachments\n\n";
570
+ for (const file of post.mediaFiles) {
571
+ const ext = path.extname(file).toLowerCase();
572
+ if ([".jpg", ".jpeg", ".png", ".gif", ".webp"].includes(ext)) {
573
+ body += `![](${file})
574
+ `;
575
+ } else {
576
+ body += `- ${file}
577
+ `;
578
+ }
579
+ }
580
+ }
581
+ return frontmatter + body;
582
+ }
583
+ async function resumeExport(options) {
584
+ if (!options.session) {
585
+ throw new Error("Session string is required for resumeExport");
586
+ }
587
+ return exportTelegramChannel(options);
588
+ }
400
589
  // Annotate the CommonJS export names for ESM import in node:
401
590
  0 && (module.exports = {
402
591
  categorizePost,
403
592
  cleanContent,
404
593
  configureAnalytics,
405
594
  deduplicatePosts,
595
+ exportTelegramChannel,
406
596
  extractAttachments,
407
597
  extractExcerpt,
408
598
  extractTitle,
599
+ formatPostMarkdown,
409
600
  generateEnglishSlug,
410
601
  generateSlug,
411
602
  groupPosts,
412
603
  parsePost,
604
+ resumeExport,
413
605
  trackBookAppointment,
414
606
  trackGoal,
415
607
  trackLearnMore,
package/dist/index.mjs CHANGED
@@ -256,10 +256,8 @@ function trackServiceClick(service) {
256
256
  }
257
257
 
258
258
  // src/translate.ts
259
- async function translateWithGLM(text, options) {
260
- const model = options.model || "GLM-4.7";
261
- const targetLang = options.targetLang || "en";
262
- const sourceLang = options.sourceLang || "ru";
259
+ async function translateContent(text, options) {
260
+ const { apiKey, apiUrl, model, targetLang = "en", sourceLang = "ru" } = options;
263
261
  const messages = [
264
262
  {
265
263
  role: "system",
@@ -273,11 +271,12 @@ Do not add any explanations or notes.`
273
271
  content: text
274
272
  }
275
273
  ];
276
- const response = await fetch("https://open.bigmodel.cn/api/paas/v4/chat/completions", {
274
+ const endpoint = apiUrl.endsWith("/") ? `${apiUrl}chat/completions` : `${apiUrl}/chat/completions`;
275
+ const response = await fetch(endpoint, {
277
276
  method: "POST",
278
277
  headers: {
279
278
  "Content-Type": "application/json",
280
- "Authorization": `Bearer ${options.apiKey}`
279
+ "Authorization": `Bearer ${apiKey}`
281
280
  },
282
281
  body: JSON.stringify({
283
282
  model,
@@ -287,56 +286,11 @@ Do not add any explanations or notes.`
287
286
  });
288
287
  if (!response.ok) {
289
288
  const error = await response.text();
290
- throw new Error(`GLM API error: ${response.status} - ${error}`);
289
+ throw new Error(`API error: ${response.status} - ${error}`);
291
290
  }
292
291
  const data = await response.json();
293
292
  return data.choices[0]?.message?.content || "";
294
293
  }
295
- async function translateWithOpenAI(text, options) {
296
- const model = options.model || "gpt-4o-mini";
297
- const targetLang = options.targetLang || "en";
298
- const sourceLang = options.sourceLang || "ru";
299
- const response = await fetch("https://api.openai.com/v1/chat/completions", {
300
- method: "POST",
301
- headers: {
302
- "Content-Type": "application/json",
303
- "Authorization": `Bearer ${options.apiKey}`
304
- },
305
- body: JSON.stringify({
306
- model,
307
- messages: [
308
- {
309
- role: "system",
310
- content: `You are a professional translator. Translate the following text from ${sourceLang} to ${targetLang}.
311
- Keep the markdown formatting intact.
312
- Only output the translated text, nothing else.`
313
- },
314
- {
315
- role: "user",
316
- content: text
317
- }
318
- ],
319
- temperature: 0.3
320
- })
321
- });
322
- if (!response.ok) {
323
- const error = await response.text();
324
- throw new Error(`OpenAI API error: ${response.status} - ${error}`);
325
- }
326
- const data = await response.json();
327
- return data.choices[0]?.message?.content || "";
328
- }
329
- async function translateContent(text, options) {
330
- const provider = options.provider || "glm";
331
- switch (provider) {
332
- case "glm":
333
- return translateWithGLM(text, options);
334
- case "openai":
335
- return translateWithOpenAI(text, options);
336
- default:
337
- throw new Error(`Unknown provider: ${provider}`);
338
- }
339
- }
340
294
  async function translateTitle(title, options) {
341
295
  const translated = await translateContent(title, options);
342
296
  return translated.replace(/^["']|["']$/g, "").trim();
@@ -344,18 +298,253 @@ async function translateTitle(title, options) {
344
298
  function generateEnglishSlug(title) {
345
299
  return title.toLowerCase().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").substring(0, 60);
346
300
  }
301
+
302
+ // src/telegram.ts
303
+ import { TelegramClient, Api } from "telegram";
304
+ import { StringSession } from "telegram/sessions";
305
+ import * as fs from "fs";
306
+ import * as path from "path";
307
+ import * as readline from "readline";
308
+ async function defaultReadline(prompt) {
309
+ const rl = readline.createInterface({
310
+ input: process.stdin,
311
+ output: process.stdout
312
+ });
313
+ return new Promise((resolve) => {
314
+ rl.question(prompt, (answer) => {
315
+ rl.close();
316
+ resolve(answer);
317
+ });
318
+ });
319
+ }
320
+ async function exportTelegramChannel(options) {
321
+ const {
322
+ apiId,
323
+ apiHash,
324
+ session = "",
325
+ target,
326
+ outputDir,
327
+ limit = 0,
328
+ since,
329
+ until,
330
+ downloadMedia = true,
331
+ mediaWorkers = 3,
332
+ onProgress,
333
+ onPhoneNumber = () => defaultReadline("Phone number: "),
334
+ onCode = () => defaultReadline("Verification code: "),
335
+ onPassword = () => defaultReadline("2FA Password: "),
336
+ onSession
337
+ } = options;
338
+ const postsDir = path.join(outputDir, "posts");
339
+ const mediaDir = path.join(outputDir, "media");
340
+ fs.mkdirSync(postsDir, { recursive: true });
341
+ fs.mkdirSync(mediaDir, { recursive: true });
342
+ const stringSession = new StringSession(session);
343
+ const client = new TelegramClient(stringSession, apiId, apiHash, {
344
+ connectionRetries: 5
345
+ });
346
+ await client.start({
347
+ phoneNumber: onPhoneNumber,
348
+ phoneCode: onCode,
349
+ password: onPassword,
350
+ onError: (err) => console.error("Auth error:", err)
351
+ });
352
+ const newSession = client.session.save();
353
+ if (onSession) {
354
+ onSession(newSession);
355
+ }
356
+ const entity = await client.getEntity(target);
357
+ if (!(entity instanceof Api.Channel)) {
358
+ throw new Error(`Target "${target}" is not a channel`);
359
+ }
360
+ const channelMeta = {
361
+ id: entity.id.toJSNumber(),
362
+ username: entity.username || "",
363
+ title: entity.title,
364
+ description: void 0,
365
+ participantsCount: void 0
366
+ };
367
+ try {
368
+ const fullChannel = await client.invoke(
369
+ new Api.channels.GetFullChannel({ channel: entity })
370
+ );
371
+ if (fullChannel.fullChat instanceof Api.ChannelFull) {
372
+ channelMeta.description = fullChannel.fullChat.about;
373
+ channelMeta.participantsCount = fullChannel.fullChat.participantsCount;
374
+ }
375
+ } catch (e) {
376
+ }
377
+ fs.writeFileSync(
378
+ path.join(outputDir, "channel_meta.json"),
379
+ JSON.stringify(channelMeta, null, 2)
380
+ );
381
+ const posts = [];
382
+ let processedCount = 0;
383
+ const iterParams = {
384
+ entity,
385
+ reverse: true
386
+ // Oldest first
387
+ };
388
+ if (limit > 0) {
389
+ iterParams.limit = limit;
390
+ }
391
+ if (since) {
392
+ iterParams.offsetDate = Math.floor(since.getTime() / 1e3);
393
+ }
394
+ let totalMessages = limit || 0;
395
+ if (!limit) {
396
+ try {
397
+ const history = await client.invoke(
398
+ new Api.messages.GetHistory({
399
+ peer: entity,
400
+ limit: 1,
401
+ offsetId: 0,
402
+ offsetDate: 0,
403
+ addOffset: 0,
404
+ maxId: 0,
405
+ minId: 0,
406
+ hash: 0n
407
+ })
408
+ );
409
+ if ("count" in history) {
410
+ totalMessages = history.count;
411
+ }
412
+ } catch (e) {
413
+ }
414
+ }
415
+ for await (const message of client.iterMessages(entity, iterParams)) {
416
+ if (until && message.date && message.date * 1e3 > until.getTime()) {
417
+ continue;
418
+ }
419
+ if (since && message.date && message.date * 1e3 < since.getTime()) {
420
+ break;
421
+ }
422
+ processedCount++;
423
+ if (onProgress) {
424
+ onProgress(processedCount, totalMessages, `Processing message ${message.id}`);
425
+ }
426
+ const msgId = message.id;
427
+ const paddedId = String(msgId).padStart(6, "0");
428
+ const postMediaDir = path.join(mediaDir, paddedId);
429
+ const mediaFiles = [];
430
+ if (downloadMedia && message.media) {
431
+ fs.mkdirSync(postMediaDir, { recursive: true });
432
+ try {
433
+ const buffer = await client.downloadMedia(message.media, {});
434
+ if (buffer) {
435
+ let ext = ".bin";
436
+ if (message.media instanceof Api.MessageMediaPhoto) {
437
+ ext = ".jpg";
438
+ } else if (message.media instanceof Api.MessageMediaDocument) {
439
+ const doc = message.media.document;
440
+ if (doc instanceof Api.Document) {
441
+ const mimeExt = doc.mimeType?.split("/")[1];
442
+ if (mimeExt) {
443
+ ext = "." + mimeExt.replace("jpeg", "jpg");
444
+ }
445
+ for (const attr of doc.attributes) {
446
+ if (attr instanceof Api.DocumentAttributeVideo) {
447
+ ext = ".mp4";
448
+ }
449
+ if (attr instanceof Api.DocumentAttributeFilename) {
450
+ ext = path.extname(attr.fileName) || ext;
451
+ }
452
+ }
453
+ }
454
+ }
455
+ const mediaFileName = `media${ext}`;
456
+ const mediaPath = path.join(postMediaDir, mediaFileName);
457
+ fs.writeFileSync(mediaPath, buffer);
458
+ mediaFiles.push(`media/${paddedId}/${mediaFileName}`);
459
+ }
460
+ } catch (e) {
461
+ console.error(`Error downloading media for message ${msgId}:`, e);
462
+ }
463
+ }
464
+ const content = message.message || "";
465
+ const link = channelMeta.username ? `https://t.me/${channelMeta.username}/${msgId}` : "";
466
+ const post = {
467
+ msgId,
468
+ date: new Date(message.date * 1e3),
469
+ content,
470
+ hasMedia: mediaFiles.length > 0 || !!message.media,
471
+ mediaFiles,
472
+ views: message.views,
473
+ forwards: message.forwards,
474
+ link,
475
+ channelUsername: channelMeta.username,
476
+ channelTitle: channelMeta.title
477
+ };
478
+ posts.push(post);
479
+ const markdown = formatPostMarkdown(post);
480
+ fs.writeFileSync(path.join(postsDir, `${paddedId}.md`), markdown);
481
+ }
482
+ const ndjsonPath = path.join(outputDir, "posts.ndjson");
483
+ const ndjsonContent = posts.map((p) => JSON.stringify(p)).join("\n");
484
+ fs.writeFileSync(ndjsonPath, ndjsonContent);
485
+ await client.disconnect();
486
+ return {
487
+ channelMeta,
488
+ posts,
489
+ session: newSession
490
+ };
491
+ }
492
+ function formatPostMarkdown(post) {
493
+ const dateStr = post.date.toISOString();
494
+ const dateOnly = dateStr.split("T")[0];
495
+ let frontmatter = `---
496
+ msg_id: ${post.msgId}
497
+ date: ${dateStr}
498
+ channel_username: "${post.channelUsername}"
499
+ channel_title: "${post.channelTitle.replace(/"/g, '\\"')}"
500
+ link: "${post.link}"
501
+ has_media: ${post.hasMedia}`;
502
+ if (post.views !== void 0) {
503
+ frontmatter += `
504
+ views: ${post.views}`;
505
+ }
506
+ if (post.forwards !== void 0) {
507
+ frontmatter += `
508
+ forwards: ${post.forwards}`;
509
+ }
510
+ frontmatter += "\n---\n\n";
511
+ let body = post.content || "";
512
+ if (post.mediaFiles.length > 0) {
513
+ body += "\n\n## Attachments\n\n";
514
+ for (const file of post.mediaFiles) {
515
+ const ext = path.extname(file).toLowerCase();
516
+ if ([".jpg", ".jpeg", ".png", ".gif", ".webp"].includes(ext)) {
517
+ body += `![](${file})
518
+ `;
519
+ } else {
520
+ body += `- ${file}
521
+ `;
522
+ }
523
+ }
524
+ }
525
+ return frontmatter + body;
526
+ }
527
+ async function resumeExport(options) {
528
+ if (!options.session) {
529
+ throw new Error("Session string is required for resumeExport");
530
+ }
531
+ return exportTelegramChannel(options);
532
+ }
347
533
  export {
348
534
  categorizePost,
349
535
  cleanContent,
350
536
  configureAnalytics,
351
537
  deduplicatePosts,
538
+ exportTelegramChannel,
352
539
  extractAttachments,
353
540
  extractExcerpt,
354
541
  extractTitle,
542
+ formatPostMarkdown,
355
543
  generateEnglishSlug,
356
544
  generateSlug,
357
545
  groupPosts,
358
546
  parsePost,
547
+ resumeExport,
359
548
  trackBookAppointment,
360
549
  trackGoal,
361
550
  trackLearnMore,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koztv-blog-tools",
3
- "version": "1.0.5",
3
+ "version": "1.1.0",
4
4
  "description": "Shared utilities for Telegram-based blog sites",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -23,23 +23,31 @@
23
23
  "blog",
24
24
  "telegram",
25
25
  "markdown",
26
- "static-site"
26
+ "static-site",
27
+ "telegram-export",
28
+ "mtproto"
27
29
  ],
28
30
  "author": "Koz TV",
29
31
  "license": "MIT",
30
32
  "devDependencies": {
33
+ "@types/node": "^20.0.0",
31
34
  "tsup": "^8.0.0",
32
35
  "typescript": "^5.0.0"
33
36
  },
34
37
  "dependencies": {
35
- "gray-matter": "^4.0.3"
38
+ "gray-matter": "^4.0.3",
39
+ "telegram": "^2.26.22"
36
40
  },
37
41
  "peerDependencies": {
38
- "gray-matter": "^4.0.0"
42
+ "gray-matter": "^4.0.0",
43
+ "telegram": "^2.0.0"
39
44
  },
40
45
  "peerDependenciesMeta": {
41
46
  "gray-matter": {
42
47
  "optional": true
48
+ },
49
+ "telegram": {
50
+ "optional": true
43
51
  }
44
52
  }
45
53
  }