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 +116 -0
- telepress/auth.py +57 -0
- telepress/cli.py +26 -0
- telepress/converter.py +36 -0
- telepress/core.py +329 -0
- telepress/exceptions.py +27 -0
- telepress/interfaces.py +38 -0
- telepress/server.py +132 -0
- telepress/uploader.py +43 -0
- telepress/utils.py +67 -0
- telepress-0.1.0.dist-info/METADATA +156 -0
- telepress-0.1.0.dist-info/RECORD +16 -0
- telepress-0.1.0.dist-info/WHEEL +5 -0
- telepress-0.1.0.dist-info/entry_points.txt +3 -0
- telepress-0.1.0.dist-info/licenses/LICENSE +21 -0
- telepress-0.1.0.dist-info/top_level.txt +1 -0
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 ""
|
telepress/exceptions.py
ADDED
|
@@ -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
|
telepress/interfaces.py
ADDED
|
@@ -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,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
|