koztv-blog-tools 1.2.0 → 1.2.2

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/README.md ADDED
@@ -0,0 +1,215 @@
1
+ # koztv-blog-tools
2
+
3
+ Shared utilities for Telegram-based blog sites. Export posts from Telegram channels, translate with LLM APIs, and generate markdown for static site generators.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install koztv-blog-tools
9
+ ```
10
+
11
+ ## Features
12
+
13
+ - **Telegram Export** — Export full channel history via MTProto (gramjs)
14
+ - **Translation** — Translate posts using OpenAI-compatible APIs (GLM, OpenAI, etc.)
15
+ - **Multi-language** — Export to multiple languages simultaneously
16
+ - **Markdown Generation** — Create markdown files with YAML frontmatter
17
+ - **Media Download** — Download photos, videos, and documents
18
+ - **Incremental Export** — Track processed posts, skip duplicates
19
+
20
+ ## Quick Start
21
+
22
+ ### Export + Translate
23
+
24
+ ```javascript
25
+ const { exportAndTranslate } = require('koztv-blog-tools');
26
+
27
+ const result = await exportAndTranslate({
28
+ // Telegram credentials (from https://my.telegram.org)
29
+ apiId: 12345678,
30
+ apiHash: 'your_api_hash',
31
+ session: 'saved_session_string', // from previous auth
32
+
33
+ // Target channel
34
+ channel: '@channelname',
35
+
36
+ // Output
37
+ outputDir: './content/posts',
38
+ mediaDir: './public/media',
39
+
40
+ // Translation (optional)
41
+ translate: {
42
+ apiKey: 'your_llm_api_key',
43
+ apiUrl: 'https://api.openai.com/v1', // or GLM, etc.
44
+ model: 'gpt-4',
45
+ sourceLang: 'ru',
46
+ targetLangs: ['en', 'de'], // translate to multiple languages
47
+ keepOriginal: true, // also save original language
48
+ },
49
+
50
+ // Callbacks
51
+ onProgress: (msg) => console.log(msg),
52
+ onSession: (s) => saveSession(s), // save for future use
53
+ });
54
+
55
+ console.log(`Exported: ${result.exported}, Processed: ${result.processed}`);
56
+ ```
57
+
58
+ ### Export Only (no translation)
59
+
60
+ ```javascript
61
+ const { exportTelegramChannel } = require('koztv-blog-tools');
62
+
63
+ const result = await exportTelegramChannel({
64
+ apiId: 12345678,
65
+ apiHash: 'your_api_hash',
66
+ session: 'saved_session_string',
67
+ target: '@channelname',
68
+ outputDir: './export',
69
+ downloadMedia: true,
70
+ limit: 100, // optional: limit number of posts
71
+ since: new Date('2024-01-01'), // optional: filter by date
72
+ });
73
+ ```
74
+
75
+ ### Translate Text
76
+
77
+ ```javascript
78
+ const { translateContent, translateTitle } = require('koztv-blog-tools');
79
+
80
+ const translated = await translateContent('Привет мир', {
81
+ apiKey: 'your_api_key',
82
+ apiUrl: 'https://api.openai.com/v1',
83
+ model: 'gpt-4',
84
+ sourceLang: 'ru',
85
+ targetLang: 'en',
86
+ });
87
+ // => "Hello world"
88
+ ```
89
+
90
+ ## Authentication
91
+
92
+ First-time authentication requires QR code login. See `scripts/qr-login.js` in your project:
93
+
94
+ ```javascript
95
+ // Example QR login script
96
+ const { TelegramClient } = require('telegram');
97
+ const { StringSession } = require('telegram/sessions');
98
+
99
+ const client = new TelegramClient(
100
+ new StringSession(''),
101
+ API_ID,
102
+ API_HASH,
103
+ { connectionRetries: 5 }
104
+ );
105
+
106
+ await client.start({
107
+ phoneNumber: async () => prompt('Phone: '),
108
+ password: async () => prompt('2FA: '),
109
+ phoneCode: async () => prompt('Code: '),
110
+ onError: console.error,
111
+ });
112
+
113
+ console.log('Session:', client.session.save());
114
+ ```
115
+
116
+ ## Output Structure
117
+
118
+ With multi-language enabled (`targetLangs` + `keepOriginal`):
119
+
120
+ ```
121
+ content/posts/
122
+ en/
123
+ my-post-slug/
124
+ index.md
125
+ ru/
126
+ my-post-slug/
127
+ index.md
128
+ public/media/
129
+ 000123/
130
+ image1.jpg
131
+ image2.jpg
132
+ ```
133
+
134
+ Markdown format:
135
+
136
+ ```yaml
137
+ ---
138
+ title: "Post Title"
139
+ date: 2024-01-15
140
+ lang: en
141
+ original_link: "https://t.me/channel/123"
142
+ translated_from: "ru"
143
+ ---
144
+
145
+ Post content here...
146
+
147
+ ![](/media/000123/image1.jpg)
148
+ ```
149
+
150
+ ## Environment Variables
151
+
152
+ For GitHub Actions / CI:
153
+
154
+ ```bash
155
+ TELEGRAM_API_ID=12345678
156
+ TELEGRAM_API_HASH=your_api_hash
157
+ TELEGRAM_SESSION=base64_session_string
158
+ TELEGRAM_CHANNEL=@channelname
159
+
160
+ LLM_API_KEY=your_llm_key
161
+ LLM_API_URL=https://api.openai.com/v1
162
+ LLM_MODEL=gpt-4
163
+
164
+ TARGET_LANGS=en,de # comma-separated
165
+ KEEP_ORIGINAL=true # keep source language
166
+ ```
167
+
168
+ ## API Reference
169
+
170
+ ### exportAndTranslate(options)
171
+
172
+ Main function for export + translation workflow.
173
+
174
+ **Options:**
175
+ - `apiId`, `apiHash`, `session` — Telegram credentials
176
+ - `channel` — Target channel (@username or ID)
177
+ - `outputDir` — Where to save markdown files
178
+ - `mediaDir` — Where to save media (optional)
179
+ - `limit` — Max posts to export (optional)
180
+ - `since` — Export posts after this date (optional)
181
+ - `downloadMedia` — Download media files (default: true)
182
+ - `translate` — Translation config (optional)
183
+ - `onProgress` — Progress callback
184
+ - `onSession` — Session save callback
185
+ - `processedLog` — Object tracking processed posts
186
+ - `onProcessedLog` — Callback to save processed log
187
+
188
+ ### exportTelegramChannel(options)
189
+
190
+ Low-level Telegram export.
191
+
192
+ ### translateContent(text, options)
193
+
194
+ Translate text content.
195
+
196
+ ### translateTitle(title, options)
197
+
198
+ Translate title (optimized prompt for short text).
199
+
200
+ ### generateEnglishSlug(title, options)
201
+
202
+ Generate URL-friendly slug from any language title.
203
+
204
+ ## Used By
205
+
206
+ - [koz.tv](https://koz.tv) — Personal blog with Telegram sync
207
+ - [staskoz.com](https://staskoz.com) — Another blog using this package
208
+
209
+ ## Related
210
+
211
+ - [k-engine](https://github.com/Koz-TV/k-engine) — Static site generator with multi-language support
212
+
213
+ ## License
214
+
215
+ MIT
package/dist/index.js CHANGED
@@ -329,24 +329,36 @@ Do not add any explanations or notes.`
329
329
  }
330
330
  ];
331
331
  const endpoint = apiUrl.endsWith("/") ? `${apiUrl}chat/completions` : `${apiUrl}/chat/completions`;
332
- const response = await fetch(endpoint, {
333
- method: "POST",
334
- headers: {
335
- "Content-Type": "application/json",
336
- "Authorization": `Bearer ${apiKey}`
337
- },
338
- body: JSON.stringify({
339
- model,
340
- messages,
341
- temperature: 0.3
342
- })
343
- });
344
- if (!response.ok) {
332
+ const maxRetries = 3;
333
+ let lastError = null;
334
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
335
+ if (attempt > 0) {
336
+ const delay = 3e3 * Math.pow(2, attempt - 1);
337
+ await new Promise((r) => setTimeout(r, delay));
338
+ }
339
+ const response = await fetch(endpoint, {
340
+ method: "POST",
341
+ headers: {
342
+ "Content-Type": "application/json",
343
+ "Authorization": `Bearer ${apiKey}`
344
+ },
345
+ body: JSON.stringify({
346
+ model,
347
+ messages,
348
+ temperature: 0.3
349
+ })
350
+ });
351
+ if (response.ok) {
352
+ const data = await response.json();
353
+ return data.choices[0]?.message?.content || "";
354
+ }
345
355
  const error = await response.text();
346
- throw new Error(`API error: ${response.status} - ${error}`);
356
+ lastError = new Error(`API error: ${response.status} - ${error}`);
357
+ if (response.status !== 429 && response.status < 500) {
358
+ throw lastError;
359
+ }
347
360
  }
348
- const data = await response.json();
349
- return data.choices[0]?.message?.content || "";
361
+ throw lastError;
350
362
  }
351
363
  async function translateTitle(title, options) {
352
364
  const translated = await translateContent(title, options);
@@ -685,7 +697,7 @@ async function processPost(post, options, exportDir) {
685
697
  } catch (error) {
686
698
  onProgress?.(` Error translating to ${targetLang}: ${error.message}`);
687
699
  }
688
- await new Promise((r) => setTimeout(r, 300));
700
+ await new Promise((r) => setTimeout(r, 2e3));
689
701
  }
690
702
  } else {
691
703
  languages.push({
@@ -725,9 +737,13 @@ async function processPost(post, options, exportDir) {
725
737
  }
726
738
  }
727
739
  if (mediaFiles.length > 0) {
728
- const imageMarkdown = mediaFiles.filter((f) => !f.match(/\.(mp4|mov)$/i)).map((f) => `![](/media/${paddedId}/${f})`).join("\n\n");
729
- if (imageMarkdown) {
730
- finalBody = finalBody + "\n\n" + imageMarkdown;
740
+ const images = mediaFiles.filter((f) => !f.match(/\.(mp4|mov|webm|m4v)$/i));
741
+ const videos = mediaFiles.filter((f) => f.match(/\.(mp4|mov|webm|m4v)$/i));
742
+ const imageMarkdown = images.map((f) => `![](/media/${paddedId}/${f})`).join("\n\n");
743
+ const videoMarkdown = videos.map((f) => `<video src="/media/${paddedId}/${f}" controls></video>`).join("\n\n");
744
+ const mediaMarkdown = [imageMarkdown, videoMarkdown].filter(Boolean).join("\n\n");
745
+ if (mediaMarkdown) {
746
+ finalBody = finalBody + "\n\n" + mediaMarkdown;
731
747
  }
732
748
  }
733
749
  }
package/dist/index.mjs CHANGED
@@ -272,24 +272,36 @@ Do not add any explanations or notes.`
272
272
  }
273
273
  ];
274
274
  const endpoint = apiUrl.endsWith("/") ? `${apiUrl}chat/completions` : `${apiUrl}/chat/completions`;
275
- const response = await fetch(endpoint, {
276
- method: "POST",
277
- headers: {
278
- "Content-Type": "application/json",
279
- "Authorization": `Bearer ${apiKey}`
280
- },
281
- body: JSON.stringify({
282
- model,
283
- messages,
284
- temperature: 0.3
285
- })
286
- });
287
- if (!response.ok) {
275
+ const maxRetries = 3;
276
+ let lastError = null;
277
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
278
+ if (attempt > 0) {
279
+ const delay = 3e3 * Math.pow(2, attempt - 1);
280
+ await new Promise((r) => setTimeout(r, delay));
281
+ }
282
+ const response = await fetch(endpoint, {
283
+ method: "POST",
284
+ headers: {
285
+ "Content-Type": "application/json",
286
+ "Authorization": `Bearer ${apiKey}`
287
+ },
288
+ body: JSON.stringify({
289
+ model,
290
+ messages,
291
+ temperature: 0.3
292
+ })
293
+ });
294
+ if (response.ok) {
295
+ const data = await response.json();
296
+ return data.choices[0]?.message?.content || "";
297
+ }
288
298
  const error = await response.text();
289
- throw new Error(`API error: ${response.status} - ${error}`);
299
+ lastError = new Error(`API error: ${response.status} - ${error}`);
300
+ if (response.status !== 429 && response.status < 500) {
301
+ throw lastError;
302
+ }
290
303
  }
291
- const data = await response.json();
292
- return data.choices[0]?.message?.content || "";
304
+ throw lastError;
293
305
  }
294
306
  async function translateTitle(title, options) {
295
307
  const translated = await translateContent(title, options);
@@ -628,7 +640,7 @@ async function processPost(post, options, exportDir) {
628
640
  } catch (error) {
629
641
  onProgress?.(` Error translating to ${targetLang}: ${error.message}`);
630
642
  }
631
- await new Promise((r) => setTimeout(r, 300));
643
+ await new Promise((r) => setTimeout(r, 2e3));
632
644
  }
633
645
  } else {
634
646
  languages.push({
@@ -668,9 +680,13 @@ async function processPost(post, options, exportDir) {
668
680
  }
669
681
  }
670
682
  if (mediaFiles.length > 0) {
671
- const imageMarkdown = mediaFiles.filter((f) => !f.match(/\.(mp4|mov)$/i)).map((f) => `![](/media/${paddedId}/${f})`).join("\n\n");
672
- if (imageMarkdown) {
673
- finalBody = finalBody + "\n\n" + imageMarkdown;
683
+ const images = mediaFiles.filter((f) => !f.match(/\.(mp4|mov|webm|m4v)$/i));
684
+ const videos = mediaFiles.filter((f) => f.match(/\.(mp4|mov|webm|m4v)$/i));
685
+ const imageMarkdown = images.map((f) => `![](/media/${paddedId}/${f})`).join("\n\n");
686
+ const videoMarkdown = videos.map((f) => `<video src="/media/${paddedId}/${f}" controls></video>`).join("\n\n");
687
+ const mediaMarkdown = [imageMarkdown, videoMarkdown].filter(Boolean).join("\n\n");
688
+ if (mediaMarkdown) {
689
+ finalBody = finalBody + "\n\n" + mediaMarkdown;
674
690
  }
675
691
  }
676
692
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koztv-blog-tools",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Shared utilities for Telegram-based blog sites",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",