telepress 0.1.0__py3-none-any.whl

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.
telepress/__init__.py ADDED
@@ -0,0 +1,116 @@
1
+ """
2
+ TelePress - Publish content to Telegraph easily.
3
+
4
+ Basic usage:
5
+ >>> from telepress import publish, TelegraphPublisher
6
+ >>>
7
+ >>> # Quick publish (one-liner)
8
+ >>> url = publish("article.md", title="My Article")
9
+ >>>
10
+ >>> # Or use the class for more control
11
+ >>> publisher = TelegraphPublisher()
12
+ >>> url = publisher.publish("article.md")
13
+ >>> url = publisher.publish_text("# Hello\n\nWorld!", title="Test")
14
+ """
15
+
16
+ from .core import TelegraphPublisher
17
+ from .exceptions import (
18
+ TelePressError,
19
+ ValidationError,
20
+ UploadError,
21
+ AuthenticationError,
22
+ SecurityError,
23
+ DependencyError,
24
+ ConversionError
25
+ )
26
+ from .utils import (
27
+ MAX_FILE_SIZE,
28
+ MAX_PAGES,
29
+ MAX_TOTAL_IMAGES,
30
+ MAX_IMAGES_PER_PAGE,
31
+ ALLOWED_TEXT_EXTENSIONS,
32
+ ALLOWED_IMAGE_EXTENSIONS,
33
+ ALLOWED_ARCHIVE_EXTENSIONS
34
+ )
35
+
36
+ __version__ = "0.1.0"
37
+ __all__ = [
38
+ # Main class
39
+ 'TelegraphPublisher',
40
+
41
+ # Convenience function
42
+ 'publish',
43
+ 'publish_text',
44
+
45
+ # Exceptions
46
+ 'TelePressError',
47
+ 'ValidationError',
48
+ 'UploadError',
49
+ 'AuthenticationError',
50
+ 'SecurityError',
51
+ 'DependencyError',
52
+ 'ConversionError',
53
+
54
+ # Constants
55
+ 'MAX_FILE_SIZE',
56
+ 'MAX_PAGES',
57
+ 'MAX_TOTAL_IMAGES',
58
+ 'MAX_IMAGES_PER_PAGE',
59
+ 'ALLOWED_TEXT_EXTENSIONS',
60
+ 'ALLOWED_IMAGE_EXTENSIONS',
61
+ 'ALLOWED_ARCHIVE_EXTENSIONS',
62
+ ]
63
+
64
+ # Singleton publisher for convenience functions
65
+ _default_publisher = None
66
+
67
+ def _get_publisher(token=None):
68
+ """Get or create default publisher instance."""
69
+ global _default_publisher
70
+ if token:
71
+ return TelegraphPublisher(token=token)
72
+ if _default_publisher is None:
73
+ _default_publisher = TelegraphPublisher()
74
+ return _default_publisher
75
+
76
+
77
+ def publish(file_path: str, title: str = None, token: str = None) -> str:
78
+ """
79
+ Convenience function to publish a file to Telegraph.
80
+
81
+ Args:
82
+ file_path: Path to file (.md, .txt, .jpg, .png, .zip, etc.)
83
+ title: Optional title (defaults to filename)
84
+ token: Optional Telegraph token (uses cached token if not provided)
85
+
86
+ Returns:
87
+ str: URL of the published Telegraph page
88
+
89
+ Example:
90
+ >>> from telepress import publish
91
+ >>> url = publish("article.md", title="My Article")
92
+ >>> print(url)
93
+ https://telegra.ph/My-Article-12-07
94
+ """
95
+ return _get_publisher(token).publish(file_path, title=title)
96
+
97
+
98
+ def publish_text(content: str, title: str, token: str = None) -> str:
99
+ """
100
+ Convenience function to publish text content directly to Telegraph.
101
+
102
+ Args:
103
+ content: Markdown or plain text content
104
+ title: Page title (required)
105
+ token: Optional Telegraph token
106
+
107
+ Returns:
108
+ str: URL of the published Telegraph page
109
+
110
+ Example:
111
+ >>> from telepress import publish_text
112
+ >>> url = publish_text("# Hello\n\nThis is my article.", title="Hello World")
113
+ >>> print(url)
114
+ https://telegra.ph/Hello-World-12-07
115
+ """
116
+ return _get_publisher(token).publish_text(content, title=title)
telepress/auth.py ADDED
@@ -0,0 +1,57 @@
1
+ import os
2
+ from typing import Optional
3
+ from .exceptions import DependencyError, AuthenticationError
4
+
5
+ try:
6
+ from telegraph import Telegraph
7
+ except ImportError:
8
+ Telegraph = None
9
+
10
+ DEFAULT_TOKEN_FILE = os.path.expanduser("~/.telegraph_token")
11
+
12
+ class TelegraphAuth:
13
+ def __init__(self, token_file: str = DEFAULT_TOKEN_FILE):
14
+ if Telegraph is None:
15
+ raise DependencyError("telegraph library is required")
16
+ self.token_file = token_file
17
+
18
+ def get_client(self, token: Optional[str] = None, short_name: str = "TelegraphClient") -> Telegraph:
19
+ """
20
+ Returns an authenticated Telegraph client.
21
+ Priorities:
22
+ 1. Explicit token argument
23
+ 2. Stored token in file
24
+ 3. Create new account and store token
25
+ """
26
+ # 1. Explicit token
27
+ if token:
28
+ return Telegraph(access_token=token)
29
+
30
+ # 2. Stored token
31
+ if os.path.exists(self.token_file):
32
+ try:
33
+ with open(self.token_file, 'r') as f:
34
+ stored_token = f.read().strip()
35
+ client = Telegraph(access_token=stored_token)
36
+ # Verify token
37
+ client.get_account_info(['short_name'])
38
+ return client
39
+ except Exception:
40
+ # Log or just proceed to create new
41
+ pass
42
+
43
+ # 3. Create new
44
+ try:
45
+ client = Telegraph()
46
+ response = client.create_account(short_name=short_name)
47
+ new_token = response['access_token']
48
+
49
+ # Ensure directory exists
50
+ os.makedirs(os.path.dirname(self.token_file), exist_ok=True)
51
+
52
+ with open(self.token_file, 'w') as f:
53
+ f.write(new_token)
54
+
55
+ return Telegraph(access_token=new_token)
56
+ except Exception as e:
57
+ raise AuthenticationError(f"Failed to create new Telegraph account: {e}")
telepress/cli.py ADDED
@@ -0,0 +1,26 @@
1
+ import argparse
2
+ import sys
3
+ from .core import TelegraphPublisher
4
+ from .exceptions import TelePressError
5
+
6
+ def main():
7
+ parser = argparse.ArgumentParser(description="TelePress: Publish files (txt, md, images, zip) to Telegraph.")
8
+ parser.add_argument("file", help="Path to the file to convert")
9
+ parser.add_argument("--title", help="Custom title for the page", default=None)
10
+ parser.add_argument("--token", help="Telegraph access token (optional)", default=None)
11
+
12
+ args = parser.parse_args()
13
+
14
+ try:
15
+ publisher = TelegraphPublisher(token=args.token)
16
+ url = publisher.publish(args.file, title=args.title)
17
+ print(f"\n✅ Success! Page created: {url}")
18
+ except TelePressError as e:
19
+ print(f"\n❌ Error: {e}")
20
+ sys.exit(1)
21
+ except Exception as e:
22
+ print(f"\n❌ Unexpected Error: {e}")
23
+ sys.exit(1)
24
+
25
+ if __name__ == "__main__":
26
+ main()
telepress/converter.py ADDED
@@ -0,0 +1,36 @@
1
+ import re
2
+ from typing import List, Dict, Union
3
+ from .utils import sanitize_nodes
4
+ from .exceptions import DependencyError
5
+
6
+ try:
7
+ import markdown
8
+ from telegraph.utils import html_to_nodes
9
+ except ImportError:
10
+ markdown = None
11
+ html_to_nodes = None
12
+
13
+ class MarkdownConverter:
14
+ def __init__(self):
15
+ if markdown is None:
16
+ raise DependencyError("markdown library is required")
17
+
18
+ def convert(self, md_content: str) -> List[Dict]:
19
+ """Converts Markdown content to Telegraph DOM nodes."""
20
+ html_content = markdown.markdown(md_content)
21
+
22
+ # Pre-process HTML string for headers
23
+ # Telegraph doesn't support h1/h2
24
+ html_content = re.sub(r'<h1', '<h3', html_content)
25
+ html_content = re.sub(r'</h1>', '</h3>', html_content)
26
+ html_content = re.sub(r'<h2', '<h4', html_content)
27
+ html_content = re.sub(r'</h2>', '</h4>', html_content)
28
+ html_content = re.sub(r'<h[56]', '<h4', html_content)
29
+ html_content = re.sub(r'</h[56]>', '</h4>', html_content)
30
+
31
+ if html_to_nodes:
32
+ nodes = html_to_nodes(html_content)
33
+ return sanitize_nodes(nodes)
34
+ else:
35
+ # Fallback (though utils should be present if telegraph is installed)
36
+ return [{'tag': 'p', 'children': [md_content]}]
telepress/core.py ADDED
@@ -0,0 +1,329 @@
1
+ import os
2
+ import tempfile
3
+ import zipfile
4
+ from typing import Optional, List, Dict
5
+ from .auth import TelegraphAuth
6
+ from .converter import MarkdownConverter
7
+ from .uploader import ImageUploader
8
+ from .utils import (
9
+ natural_sort_key, safe_extract_zip, validate_file_size,
10
+ MAX_TEXT_SIZE, MAX_IMAGES_PER_PAGE, MAX_PAGES, MAX_TOTAL_IMAGES, MAX_FILE_SIZE,
11
+ ALLOWED_TEXT_EXTENSIONS, ALLOWED_ARCHIVE_EXTENSIONS, ALLOWED_IMAGE_EXTENSIONS
12
+ )
13
+ from .exceptions import UploadError, ValidationError
14
+ from .interfaces import IPublisher
15
+
16
+ class TelegraphPublisher(IPublisher):
17
+ """
18
+ Main interface for publishing content to Telegraph.
19
+ """
20
+ IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'}
21
+
22
+ def __init__(self, token: Optional[str] = None, short_name: str = "TelegraphPublisher"):
23
+ self.auth = TelegraphAuth()
24
+ self.client = self.auth.get_client(token, short_name)
25
+ self.converter = MarkdownConverter()
26
+ self.uploader = ImageUploader()
27
+
28
+ def publish(self, file_path: str, title: Optional[str] = None) -> str:
29
+ """
30
+ Publishes a file (md, txt, image, zip) to Telegraph.
31
+
32
+ Supported formats:
33
+ - Text: .txt, .md, .markdown, .rst, .text
34
+ - Images: .jpg, .jpeg, .png, .gif, .webp, .bmp
35
+ - Archives: .zip (containing images)
36
+
37
+ Raises:
38
+ FileNotFoundError: If file doesn't exist
39
+ ValidationError: If file type not supported or file too large
40
+ """
41
+ if not os.path.exists(file_path):
42
+ raise FileNotFoundError(f"File not found: {file_path}")
43
+
44
+ file_name = os.path.basename(file_path)
45
+ if not title:
46
+ title = os.path.splitext(file_name)[0]
47
+ ext = os.path.splitext(file_name)[1].lower()
48
+
49
+ # Validate file size
50
+ file_size = os.path.getsize(file_path)
51
+ if file_size > MAX_FILE_SIZE:
52
+ raise ValidationError(
53
+ f"File too large: {file_size / 1024 / 1024:.1f}MB. "
54
+ f"Maximum allowed: {MAX_FILE_SIZE / 1024 / 1024:.0f}MB"
55
+ )
56
+
57
+ # Validate file type and route to appropriate handler
58
+ if ext in ALLOWED_ARCHIVE_EXTENSIONS:
59
+ return self.publish_zip_gallery(file_path, title)
60
+ elif ext in self.IMAGE_EXTENSIONS:
61
+ return self.publish_image(file_path, title)
62
+ elif ext in ALLOWED_TEXT_EXTENSIONS:
63
+ return self.publish_markdown(file_path, title)
64
+ else:
65
+ # Unsupported file type
66
+ supported = sorted(ALLOWED_TEXT_EXTENSIONS | self.IMAGE_EXTENSIONS | ALLOWED_ARCHIVE_EXTENSIONS)
67
+ raise ValidationError(
68
+ f"Unsupported file type: '{ext}'. "
69
+ f"Supported formats: {', '.join(supported)}"
70
+ )
71
+
72
+ def _link_pages(self, pages_info: List[Dict]):
73
+ """
74
+ Helper to add navigation links (Prev/Next/Index) to a list of pages.
75
+ """
76
+ total_parts = len(pages_info)
77
+ if total_parts <= 1:
78
+ return
79
+
80
+ print(f"Linking {total_parts} pages...")
81
+ for i, info in enumerate(pages_info):
82
+ nav_nodes = []
83
+
84
+ # 1. Navigation Links (Prev/Next)
85
+ nav_links = []
86
+ if i > 0:
87
+ prev_url = pages_info[i-1]['url']
88
+ nav_links.append({
89
+ 'tag': 'a',
90
+ 'attrs': {'href': prev_url},
91
+ 'children': [f'◀ Previous / 上一页']
92
+ })
93
+ nav_links.append(" | ")
94
+
95
+ if i < total_parts - 1:
96
+ next_url = pages_info[i+1]['url']
97
+ nav_links.append({
98
+ 'tag': 'a',
99
+ 'attrs': {'href': next_url},
100
+ 'children': [f'Next / 下一页 ▶']
101
+ })
102
+
103
+ if nav_links:
104
+ nav_nodes.append({'tag': 'p', 'children': nav_links})
105
+
106
+ # 2. Pagination Index (Page Numbers)
107
+ # Limit index if too many pages to avoid hitting node limits on the index itself
108
+ # Show: [1] ... [current-2] [current-1] [current] [current+1] [current+2] ... [last]
109
+ page_index_nodes = ["Pages: "]
110
+
111
+ # Simple full list for now, optimized for < 100 pages
112
+ if total_parts < 50:
113
+ for p_idx, p_info in enumerate(pages_info):
114
+ label = str(p_idx + 1)
115
+ if p_idx == i:
116
+ page_index_nodes.append({'tag': 'b', 'children': [f"[{label}]"]})
117
+ else:
118
+ page_index_nodes.append({'tag': 'a', 'attrs': {'href': p_info['url']}, 'children': [f"[{label}]"]})
119
+ page_index_nodes.append(" ")
120
+ else:
121
+ # Simplified index for very large sets
122
+ page_index_nodes.append(f"{i+1} / {total_parts}")
123
+
124
+ nav_nodes.append({'tag': 'p', 'children': page_index_nodes})
125
+
126
+ if nav_nodes:
127
+ new_content = info['content'] + [{'tag': 'hr'}] + nav_nodes
128
+ try:
129
+ self.client.edit_page(
130
+ path=info['path'],
131
+ title=info['title'],
132
+ content=new_content
133
+ )
134
+ except Exception as e:
135
+ print(f"Warning: Failed to add navigation to Part {i+1}: {e}")
136
+
137
+ def publish_markdown(self, file_path: str, title: str) -> str:
138
+ """
139
+ Publish a markdown/text file to Telegraph.
140
+ Large files are automatically split into multiple pages.
141
+
142
+ Limits:
143
+ - Maximum ~4 million characters (100 pages × 40000 chars)
144
+ - Files exceeding this will be truncated with a warning
145
+ """
146
+ # Validate file can be read as text
147
+ try:
148
+ with open(file_path, 'r', encoding='utf-8') as f:
149
+ content = f.read()
150
+ except UnicodeDecodeError:
151
+ raise ValidationError(
152
+ f"Cannot read file as text. File may be binary (PDF, DOCX, etc.). "
153
+ f"Only plain text files (.txt, .md) are supported."
154
+ )
155
+
156
+ # Split content if too large
157
+ # Telegraph safe limit is around 60KB JSON, so ~30-40KB text is safe.
158
+ SAFE_CHUNK_SIZE = 40000
159
+
160
+ chunks = []
161
+ if len(content) > SAFE_CHUNK_SIZE:
162
+ print(f"Text too large ({len(content)} chars). Splitting...")
163
+ current_chunk = []
164
+ current_len = 0
165
+ # Split by lines to preserve markdown structure somewhat
166
+ for line in content.splitlines(keepends=True):
167
+ if current_len + len(line) > SAFE_CHUNK_SIZE:
168
+ chunks.append("".join(current_chunk))
169
+ current_chunk = []
170
+ current_len = 0
171
+ current_chunk.append(line)
172
+ current_len += len(line)
173
+ if current_chunk:
174
+ chunks.append("".join(current_chunk))
175
+ else:
176
+ chunks = [content]
177
+
178
+ # Enforce page limit
179
+ if len(chunks) > MAX_PAGES:
180
+ original_count = len(chunks)
181
+ chunks = chunks[:MAX_PAGES]
182
+ print(f"Warning: Content truncated from {original_count} to {MAX_PAGES} pages (max limit).")
183
+
184
+ total_parts = len(chunks)
185
+ pages_info = []
186
+
187
+ for i, chunk_text in enumerate(chunks):
188
+ part_num = i + 1
189
+ page_title = title
190
+ if total_parts > 1:
191
+ page_title = f"{title} ({part_num}/{total_parts})"
192
+
193
+ print(f"Publishing Part {part_num}/{total_parts} ({len(chunk_text)} chars)...")
194
+ nodes = self.converter.convert(chunk_text)
195
+
196
+ try:
197
+ response = self.client.create_page(title=page_title, content=nodes)
198
+ pages_info.append({
199
+ 'path': response['path'],
200
+ 'url': response['url'],
201
+ 'title': page_title,
202
+ 'content': nodes
203
+ })
204
+ except Exception as e:
205
+ raise RuntimeError(f"Failed to publish Part {part_num}: {e}")
206
+
207
+ # Link pages if multiple
208
+ self._link_pages(pages_info)
209
+
210
+ return pages_info[0]['url'] if pages_info else ""
211
+
212
+ def publish_image(self, image_path: str, title: str) -> str:
213
+ """Publish a single image to Telegraph."""
214
+ url = self.uploader.upload(image_path)
215
+ content = [{'tag': 'img', 'attrs': {'src': url}}]
216
+ response = self.client.create_page(title=title, content=content)
217
+ return response['url']
218
+
219
+ def publish_text(self, content: str, title: str) -> str:
220
+ """
221
+ Publish text/markdown content directly to Telegraph.
222
+
223
+ This is useful for programmatic publishing without creating a temp file.
224
+
225
+ Args:
226
+ content: Markdown or plain text content
227
+ title: Page title
228
+
229
+ Returns:
230
+ str: URL of the published Telegraph page
231
+
232
+ Example:
233
+ >>> publisher = TelegraphPublisher()
234
+ >>> url = publisher.publish_text("# Hello\n\nWorld!", title="Test")
235
+ """
236
+ import tempfile
237
+ import os
238
+
239
+ # Write content to temp file and use existing publish_markdown logic
240
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False, encoding='utf-8') as f:
241
+ f.write(content)
242
+ tmp_path = f.name
243
+
244
+ try:
245
+ return self.publish_markdown(tmp_path, title)
246
+ finally:
247
+ os.unlink(tmp_path)
248
+
249
+ def publish_zip_gallery(self, zip_path: str, title: str) -> str:
250
+ """
251
+ Publish a zip file containing images as a gallery.
252
+
253
+ Limits:
254
+ - Maximum 5000 images (50 pages × 100 images)
255
+ - Images exceeding this will be truncated with a warning
256
+ """
257
+ with tempfile.TemporaryDirectory() as temp_dir:
258
+ try:
259
+ safe_extract_zip(zip_path, temp_dir)
260
+ except zipfile.BadZipFile:
261
+ raise ValidationError("Invalid zip file")
262
+
263
+ images = []
264
+ for root, _, files in os.walk(temp_dir):
265
+ for file in files:
266
+ if os.path.splitext(file)[1].lower() in self.IMAGE_EXTENSIONS:
267
+ images.append(os.path.join(root, file))
268
+
269
+ images.sort(key=lambda x: natural_sort_key(os.path.basename(x)))
270
+
271
+ if not images:
272
+ raise ValidationError("No images found in zip file")
273
+
274
+ # Enforce image limit
275
+ if len(images) > MAX_TOTAL_IMAGES:
276
+ print(f"Warning: Gallery truncated from {len(images)} to {MAX_TOTAL_IMAGES} images (max limit).")
277
+ images = images[:MAX_TOTAL_IMAGES]
278
+
279
+ # Pagination logic
280
+ chunk_size = MAX_IMAGES_PER_PAGE
281
+ chunks = [images[i:i + chunk_size] for i in range(0, len(images), chunk_size)]
282
+ total_parts = len(chunks)
283
+
284
+ if total_parts > 1:
285
+ print(f"Gallery has {len(images)} images. Splitting into {total_parts} pages.")
286
+
287
+ # Two-pass approach to support Prev/Next links
288
+ # Pass 1: Create all pages
289
+ pages_info = [] # Stores {'path': str, 'url': str, 'title': str, 'content': list}
290
+
291
+ for i, chunk_images in enumerate(chunks):
292
+ part_num = i + 1
293
+ page_title = title
294
+ if total_parts > 1:
295
+ page_title = f"{title} ({part_num}/{total_parts})"
296
+
297
+ print(f"Uploading Part {part_num}/{total_parts} ({len(chunk_images)} images)...")
298
+
299
+ content = []
300
+ for img_path in chunk_images:
301
+ try:
302
+ url = self.uploader.upload(img_path)
303
+ content.append({'tag': 'img', 'attrs': {'src': url}})
304
+ except UploadError as e:
305
+ print(f"Warning: Failed to upload {os.path.basename(img_path)}: {e}")
306
+
307
+ if not content and len(chunk_images) > 0:
308
+ print(f"Warning: Part {part_num} resulted in empty content.")
309
+
310
+ try:
311
+ # Create initial page
312
+ response = self.client.create_page(
313
+ title=page_title,
314
+ html_content=None,
315
+ content=content if content else [{'tag': 'p', 'children': ['(Empty Page)']}]
316
+ )
317
+ pages_info.append({
318
+ 'path': response['path'],
319
+ 'url': response['url'],
320
+ 'title': page_title,
321
+ 'content': content
322
+ })
323
+ except Exception as e:
324
+ raise RuntimeError(f"Failed to publish Part {part_num}: {e}")
325
+
326
+ # Pass 2: Update pages with navigation
327
+ self._link_pages(pages_info)
328
+
329
+ return pages_info[0]['url'] if pages_info else ""
@@ -0,0 +1,27 @@
1
+ class TelePressError(Exception):
2
+ """Base exception for TelePress."""
3
+ pass
4
+
5
+ class DependencyError(TelePressError):
6
+ """Raised when required dependencies are missing."""
7
+ pass
8
+
9
+ class AuthenticationError(TelePressError):
10
+ """Raised when authentication fails."""
11
+ pass
12
+
13
+ class ConversionError(TelePressError):
14
+ """Raised when file conversion fails."""
15
+ pass
16
+
17
+ class UploadError(TelePressError):
18
+ """Raised when file upload fails."""
19
+ pass
20
+
21
+ class SecurityError(TelePressError):
22
+ """Raised when a security violation is detected (e.g. Zip Slip, huge file)."""
23
+ pass
24
+
25
+ class ValidationError(TelePressError):
26
+ """Raised when input data validation fails (e.g. invalid format, limits exceeded)."""
27
+ pass
@@ -0,0 +1,38 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Optional, Dict, Any
3
+
4
+ class IPublisher(ABC):
5
+ """
6
+ Abstract Base Class defining the Publisher interface.
7
+ This allows for different implementations (e.g. Telegraph, Medium, Ghost)
8
+ to be interchangeable.
9
+ """
10
+
11
+ @abstractmethod
12
+ def publish(self, source: Any, title: Optional[str] = None) -> str:
13
+ """
14
+ Publish content from the source.
15
+
16
+ Args:
17
+ source: The content to publish (file path, raw text, etc.)
18
+ title: Optional title for the publication
19
+
20
+ Returns:
21
+ str: The URL of the published content
22
+ """
23
+ pass
24
+
25
+ class IConverter(ABC):
26
+ """Interface for content converters (e.g. Markdown -> Node, RST -> Node)."""
27
+
28
+ @abstractmethod
29
+ def convert(self, content: str) -> Any:
30
+ pass
31
+
32
+ class IUploader(ABC):
33
+ """Interface for asset uploaders (e.g. Images)."""
34
+
35
+ @abstractmethod
36
+ def upload(self, source: Any) -> str:
37
+ """Uploads the asset and returns a public URL."""
38
+ pass
telepress/server.py ADDED
@@ -0,0 +1,132 @@
1
+ from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Depends
2
+ from pydantic import BaseModel
3
+ from typing import Optional
4
+ import os
5
+ import shutil
6
+ import tempfile
7
+ from .core import TelegraphPublisher
8
+ from .exceptions import TelePressError
9
+
10
+ app = FastAPI(
11
+ title="TelePress API",
12
+ description="REST API to convert text, markdown, images, and zips to Telegraph pages.",
13
+ version="0.1.0"
14
+ )
15
+
16
+ # Request Models
17
+ class TextPublishRequest(BaseModel):
18
+ content: str
19
+ title: str
20
+ token: Optional[str] = None
21
+
22
+ class PublishResponse(BaseModel):
23
+ url: str
24
+ status: str = "success"
25
+
26
+ def get_publisher(token: Optional[str] = None):
27
+ try:
28
+ return TelegraphPublisher(token=token)
29
+ except Exception as e:
30
+ raise HTTPException(status_code=500, detail=str(e))
31
+
32
+ @app.get("/")
33
+ def health_check():
34
+ return {"status": "ok", "service": "telepress"}
35
+
36
+ @app.post("/publish/text", response_model=PublishResponse)
37
+ async def publish_text(request: TextPublishRequest):
38
+ """
39
+ Publish raw Markdown/Text content directly.
40
+ """
41
+ try:
42
+ # We need to save to a temp file because our core logic expects files
43
+ # Alternatively, we could refactor core to accept string, but file is safer for large content
44
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False, encoding='utf-8') as tmp:
45
+ tmp.write(request.content)
46
+ tmp_path = tmp.name
47
+
48
+ publisher = get_publisher(request.token)
49
+ url = publisher.publish(tmp_path, title=request.title)
50
+
51
+ os.unlink(tmp_path)
52
+ return PublishResponse(url=url)
53
+
54
+ except TelePressError as e:
55
+ raise HTTPException(status_code=400, detail=str(e))
56
+ except Exception as e:
57
+ raise HTTPException(status_code=500, detail=str(e))
58
+
59
+ @app.post("/publish/file", response_model=PublishResponse)
60
+ async def publish_file(
61
+ file: UploadFile = File(...),
62
+ title: Optional[str] = Form(None),
63
+ token: Optional[str] = Form(None)
64
+ ):
65
+ """
66
+ Upload a file (md, txt, zip, image) to be processed and published.
67
+ """
68
+ try:
69
+ suffix = os.path.splitext(file.filename)[1]
70
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
71
+ shutil.copyfileobj(file.file, tmp)
72
+ tmp_path = tmp.name
73
+
74
+ publisher = get_publisher(token)
75
+
76
+ # If no title provided, use filename from upload
77
+ pub_title = title if title else file.filename
78
+
79
+ url = publisher.publish(tmp_path, title=pub_title)
80
+
81
+ os.unlink(tmp_path)
82
+ return PublishResponse(url=url)
83
+
84
+ except TelePressError as e:
85
+ raise HTTPException(status_code=400, detail=str(e))
86
+ except Exception as e:
87
+ # Clean up if exists
88
+ if 'tmp_path' in locals() and os.path.exists(tmp_path):
89
+ os.unlink(tmp_path)
90
+ raise HTTPException(status_code=500, detail=str(e))
91
+
92
+ def start_server(host="0.0.0.0", port=8000):
93
+ """Start the TelePress API server."""
94
+ import uvicorn
95
+ uvicorn.run(app, host=host, port=port)
96
+
97
+
98
+ def main():
99
+ """
100
+ CLI entry point for telepress-server command.
101
+
102
+ Usage:
103
+ telepress-server
104
+ telepress-server --host 127.0.0.1 --port 9000
105
+ """
106
+ import argparse
107
+
108
+ parser = argparse.ArgumentParser(
109
+ description="Start the TelePress REST API server."
110
+ )
111
+ parser.add_argument(
112
+ "--host",
113
+ default="0.0.0.0",
114
+ help="Host to bind (default: 0.0.0.0)"
115
+ )
116
+ parser.add_argument(
117
+ "--port",
118
+ type=int,
119
+ default=8000,
120
+ help="Port to listen on (default: 8000)"
121
+ )
122
+
123
+ args = parser.parse_args()
124
+
125
+ print(f"Starting TelePress API server at http://{args.host}:{args.port}")
126
+ print("API docs available at: http://localhost:{}/docs".format(args.port))
127
+
128
+ start_server(host=args.host, port=args.port)
129
+
130
+
131
+ if __name__ == "__main__":
132
+ main()
telepress/uploader.py ADDED
@@ -0,0 +1,43 @@
1
+ import os
2
+ import time
3
+ from typing import Optional
4
+ from .exceptions import UploadError, DependencyError
5
+ from .utils import validate_file_size, MAX_IMAGE_SIZE
6
+
7
+ try:
8
+ from telegraph import upload_file
9
+ except ImportError:
10
+ upload_file = None
11
+
12
+ class ImageUploader:
13
+ def __init__(self):
14
+ if upload_file is None:
15
+ raise DependencyError("telegraph library is required")
16
+
17
+ def upload(self, path: str, retries: int = 3) -> str:
18
+ """
19
+ Uploads an image to Telegraph and returns the URL.
20
+ Includes validation and retries.
21
+ """
22
+ if not os.path.exists(path):
23
+ raise FileNotFoundError(f"Image not found: {path}")
24
+
25
+ validate_file_size(path, MAX_IMAGE_SIZE, "Image file too large")
26
+
27
+ attempt = 0
28
+ while attempt < retries:
29
+ try:
30
+ with open(path, 'rb') as f:
31
+ response = upload_file(f)
32
+
33
+ if isinstance(response, list) and len(response) > 0 and 'src' in response[0]:
34
+ return "https://telegra.ph" + response[0]['src']
35
+
36
+ # If response format is unexpected but no exception, maybe server issue
37
+ raise UploadError(f"Invalid response from upload server: {response}")
38
+
39
+ except Exception as e:
40
+ attempt += 1
41
+ if attempt >= retries:
42
+ raise UploadError(f"Failed to upload image {path} after {retries} attempts: {e}")
43
+ time.sleep(1) # Wait before retry
telepress/utils.py ADDED
@@ -0,0 +1,67 @@
1
+ import re
2
+ import os
3
+ import zipfile
4
+ from typing import List, Dict, Union
5
+ from .exceptions import SecurityError, ValidationError
6
+
7
+ # Constants for limits
8
+ MAX_TEXT_SIZE = 60 * 1024 # ~60KB safe limit for text content
9
+ MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5MB limit per image (Telegraph limit)
10
+ MAX_IMAGES_PER_PAGE = 100 # Avoid browser crashes
11
+ MAX_PAGES = 100 # Maximum pages to prevent excessive API calls
12
+ MAX_TOTAL_IMAGES = 5000 # Maximum images in a single gallery
13
+ MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB max file size
14
+ ALLOWED_IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'}
15
+ ALLOWED_TEXT_EXTENSIONS = {'.txt', '.md', '.markdown', '.rst', '.text'}
16
+ ALLOWED_ARCHIVE_EXTENSIONS = {'.zip'}
17
+
18
+ def natural_sort_key(s: str) -> List[Union[int, str]]:
19
+ """
20
+ Sorts strings containing numbers naturally.
21
+ e.g. ['1.png', '10.png', '2.png'] -> ['1.png', '2.png', '10.png']
22
+ """
23
+ return [int(text) if text.isdigit() else text.lower() for text in re.split('([0-9]+)', s)]
24
+
25
+ def validate_file_size(path: str, max_size: int, error_msg: str):
26
+ """Checks if file size is within limits."""
27
+ if os.path.getsize(path) > max_size:
28
+ raise ValidationError(f"{error_msg} (Size: {os.path.getsize(path)/1024/1024:.2f}MB, Max: {max_size/1024/1024}MB)")
29
+
30
+ def safe_extract_zip(zip_path: str, extract_to: str):
31
+ """
32
+ Safely extracts a zip file, preventing Zip Slip vulnerabilities.
33
+ """
34
+ with zipfile.ZipFile(zip_path, 'r') as zf:
35
+ for member in zf.namelist():
36
+ # Resolve the path to ensure it doesn't escape the target directory
37
+ member_path = os.path.join(extract_to, member)
38
+ abs_member_path = os.path.abspath(member_path)
39
+ abs_target_path = os.path.abspath(extract_to)
40
+
41
+ if not abs_member_path.startswith(abs_target_path):
42
+ raise SecurityError(f"Zip Slip attempt detected: {member}")
43
+
44
+ # Extract only if safe
45
+ zf.extract(member, extract_to)
46
+
47
+ def sanitize_nodes(nodes: List[Dict]) -> List[Dict]:
48
+ """
49
+ Recursively downgrades headers h1->h3, h2->h4 because Telegraph
50
+ only supports h3 and h4.
51
+ """
52
+ if not isinstance(nodes, list):
53
+ return nodes
54
+
55
+ for node in nodes:
56
+ if isinstance(node, dict):
57
+ tag = node.get('tag')
58
+ if tag == 'h1':
59
+ node['tag'] = 'h3'
60
+ elif tag == 'h2':
61
+ node['tag'] = 'h4'
62
+ elif tag in ['h5', 'h6']:
63
+ node['tag'] = 'h4'
64
+
65
+ if 'children' in node:
66
+ sanitize_nodes(node['children'])
67
+ return nodes
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.4
2
+ Name: telepress
3
+ Version: 0.1.0
4
+ Summary: Publish Markdown, images and zip archives to Telegraph
5
+ Home-page: https://github.com/zoidberg-xgd/telepress
6
+ Author: zoidberg-xgd
7
+ Author-email:
8
+ Project-URL: Bug Reports, https://github.com/zoidberg-xgd/telepress/issues
9
+ Project-URL: Source, https://github.com/zoidberg-xgd/telepress
10
+ Keywords: telegraph,markdown,publishing,gallery
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.7
16
+ Classifier: Programming Language :: Python :: 3.8
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Requires-Python: >=3.7
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: telegraph
25
+ Requires-Dist: markdown
26
+ Requires-Dist: requests
27
+ Provides-Extra: api
28
+ Requires-Dist: fastapi; extra == "api"
29
+ Requires-Dist: uvicorn; extra == "api"
30
+ Requires-Dist: python-multipart; extra == "api"
31
+ Provides-Extra: dev
32
+ Requires-Dist: pytest; extra == "dev"
33
+ Requires-Dist: pytest-cov; extra == "dev"
34
+ Requires-Dist: httpx; extra == "dev"
35
+ Dynamic: author
36
+ Dynamic: classifier
37
+ Dynamic: description
38
+ Dynamic: description-content-type
39
+ Dynamic: home-page
40
+ Dynamic: keywords
41
+ Dynamic: license-file
42
+ Dynamic: project-url
43
+ Dynamic: provides-extra
44
+ Dynamic: requires-dist
45
+ Dynamic: requires-python
46
+ Dynamic: summary
47
+
48
+ # TelePress
49
+
50
+ [中文文档](README_CN.md)
51
+
52
+ Publish Markdown, images and zip archives to [Telegraph](https://telegra.ph). Handles large files by auto-splitting into multiple linked pages.
53
+
54
+ ## Install
55
+
56
+ ```bash
57
+ pip install telepress
58
+
59
+ # with REST API
60
+ pip install telepress[api]
61
+ ```
62
+
63
+ ## Usage
64
+
65
+ ```python
66
+ from telepress import publish, publish_text
67
+
68
+ url = publish("article.md")
69
+ url = publish_text("# Hello\n\nWorld!", title="Test")
70
+ ```
71
+
72
+ CLI:
73
+ ```bash
74
+ telepress article.md --title "My Post"
75
+ telepress photos.zip --title "Album"
76
+ ```
77
+
78
+ REST API:
79
+ ```bash
80
+ telepress-server --port 8000
81
+
82
+ curl -X POST localhost:8000/publish/text \
83
+ -H "Content-Type: application/json" \
84
+ -d '{"content": "# Title\n\nBody", "title": "Test"}'
85
+ ```
86
+
87
+ ## How it works
88
+
89
+ Text files are converted to Telegraph format (Markdown supported). Large content is split at ~40KB boundaries into multiple pages with prev/next navigation.
90
+
91
+ Zip files are treated as image galleries. Images are sorted naturally (1, 2, 10 not 1, 10, 2) and paginated at 100 per page.
92
+
93
+ Token is auto-created on first run and saved to `~/.telegraph_token`.
94
+
95
+ ## Limits
96
+
97
+ - 100MB max file size
98
+ - 100 pages max (~4M chars text, or 5000 images)
99
+ - 5MB per image (Telegraph limit)
100
+
101
+ Supported: `.txt` `.md` `.markdown` `.rst` `.jpg` `.png` `.gif` `.webp` `.zip`
102
+
103
+ Not supported: PDF, DOCX (convert first)
104
+
105
+ ## Project structure
106
+
107
+ ```
108
+ telepress/
109
+ ├── core.py # TelegraphPublisher
110
+ ├── auth.py # token management
111
+ ├── converter.py # markdown to telegraph nodes
112
+ ├── uploader.py # image upload with retry
113
+ ├── server.py # FastAPI service
114
+ └── cli.py # command line
115
+ ```
116
+
117
+ ## Error handling
118
+
119
+ ```python
120
+ from telepress import publish, ValidationError, TelePressError
121
+
122
+ try:
123
+ url = publish("file.md")
124
+ except ValidationError as e:
125
+ # bad input: wrong format, too large, etc
126
+ print(e)
127
+ except TelePressError as e:
128
+ # other errors: upload failed, auth failed, etc
129
+ print(e)
130
+ ```
131
+
132
+ ## Integration
133
+
134
+ ```python
135
+ # Flask
136
+ @app.route('/publish', methods=['POST'])
137
+ def api_publish():
138
+ url = publish_text(request.json['content'], title=request.json['title'])
139
+ return {'url': url}
140
+
141
+ # async
142
+ async def async_publish(content, title):
143
+ return await asyncio.to_thread(publish_text, content, title)
144
+ ```
145
+
146
+ ## Dev
147
+
148
+ ```bash
149
+ git clone https://github.com/zoidberg-xgd/telepress
150
+ cd telepress && pip install -e .[dev]
151
+ pytest tests/ -v
152
+ ```
153
+
154
+ ## License
155
+
156
+ MIT
@@ -0,0 +1,16 @@
1
+ telepress/__init__.py,sha256=lmyGBlMFmrVIcl6ycZtCHYQ3RxkTvspYHkAbsvyJUlM,3080
2
+ telepress/auth.py,sha256=FvwOOwizblK0UkFL5gXOeRm_kTSr4SLbAWPVre2_YZs,1941
3
+ telepress/cli.py,sha256=8J3CWc_Oy9UqptlTErew6Hc01WJPgg5ggYG7Skec4TU,908
4
+ telepress/converter.py,sha256=CPPYXyEnS8c_Ix-jWzXqLlQR1qbmJPJo4W2YSxYxeu0,1316
5
+ telepress/core.py,sha256=-ymrFS4s5AgCruvoe2wo9lkeuTcK07wQNu9uTnJt_1U,13245
6
+ telepress/exceptions.py,sha256=t8MI4atvKYQdhSYUR-s3jiuXmqrc9870DjsWAm9TXyE,734
7
+ telepress/interfaces.py,sha256=6lErqHgC5d1RctIkHYBh9xD__yJJxhVaqPoMdo6kzS0,1084
8
+ telepress/server.py,sha256=fwxvMD0DTZnShKLXnUQBARJs-QmYna36clfPU5-mvN8,3956
9
+ telepress/uploader.py,sha256=lBTAI_emNa2tNXIr47ynrGPcR9onoVCYWwcD7WSK2_o,1563
10
+ telepress/utils.py,sha256=RFbSwhyfEPGOTCEBZjuqbOKXcwoPxMGrdhUyaD8L89Q,2634
11
+ telepress-0.1.0.dist-info/licenses/LICENSE,sha256=o_ZmC5f8PrW9PNmJ0phiUkjz9OBZcJMVe4wo3l-59Ac,1069
12
+ telepress-0.1.0.dist-info/METADATA,sha256=IDYgml3x9SdeOM3tuS3u2XCEI_jvzhIOOV4Aypex8G4,3997
13
+ telepress-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
14
+ telepress-0.1.0.dist-info/entry_points.txt,sha256=SENLejDh7RKvraukU5goVTxAuhx96PVV8m8_qOr65aA,90
15
+ telepress-0.1.0.dist-info/top_level.txt,sha256=Dh5UrvGpGy_ZBcI7YC-0xBRFbHe9A5Ae94fjbhe02IU,10
16
+ telepress-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ telepress = telepress.cli:main
3
+ telepress-server = telepress.server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 zoidberg-xgd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ telepress