ghostwriter-cli 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.
@@ -0,0 +1,386 @@
1
+ """HTML processing pipeline for Ghost → WeChat conversion.
2
+
3
+ Transforms Ghost article HTML into WeChat-draft-compatible HTML through
4
+ a fixed sequence of transformations. Order matters — many steps must
5
+ run before or after the whitelist filter to work correctly.
6
+ """
7
+
8
+ import re
9
+ from html import escape as _html_escape
10
+
11
+ from .cleaner import clean_ghost_comments, clean_html_for_wechat
12
+
13
+ # Code block placeholder marker
14
+ _CODE_PLACEHOLDER = "__CODE_BLOCK_PLACEHOLDER__"
15
+ _code_blocks_cache = []
16
+
17
+
18
+ # ── Code block protection ────────────────────────────────────
19
+
20
+ def convert_code_blocks(html):
21
+ """Extract <pre><code> blocks to placeholders to protect from the whitelist filter.
22
+
23
+ Returns HTML with placeholders inserted. Call restore_code_blocks()
24
+ after filtering to restore the code blocks with WeChat-safe styling.
25
+ """
26
+ global _code_blocks_cache
27
+ _code_blocks_cache = []
28
+
29
+ def _extract(match):
30
+ full = match.group(0)
31
+ lang_match = re.search(r'class="language-(\w+)"', full)
32
+ lang = lang_match.group(1) if lang_match else ""
33
+ code_match = re.search(
34
+ r'<code[^>]*>(.*?)</code>', full, re.DOTALL
35
+ )
36
+ code_content = code_match.group(1) if code_match else match.group(1)
37
+ idx = len(_code_blocks_cache)
38
+ _code_blocks_cache.append({"lang": lang, "content": code_content})
39
+ return f"{_CODE_PLACEHOLDER}{idx}{_CODE_PLACEHOLDER}"
40
+
41
+ html = re.sub(
42
+ r'<pre><code[^>]*>(.*?)</code></pre>', _extract, html,
43
+ flags=re.DOTALL,
44
+ )
45
+ html = re.sub(
46
+ r'<pre>(.*?)</pre>', _extract, html,
47
+ flags=re.DOTALL,
48
+ )
49
+ return html
50
+
51
+
52
+ def restore_code_blocks(html):
53
+ """Restore code block placeholders with WeChat-safe styled HTML."""
54
+ global _code_blocks_cache
55
+
56
+ def _restore(match):
57
+ idx = int(match.group(1))
58
+ block = _code_blocks_cache[idx]
59
+ content = block["content"]
60
+ lang = block["lang"]
61
+
62
+ lang_html = ""
63
+ if lang:
64
+ lang_html = (
65
+ f'<div style="font-size: 12px; color: #999; '
66
+ f'margin-bottom: 6px; line-height: 1.4;">'
67
+ f'{_html_escape(lang)}'
68
+ f'</div>'
69
+ )
70
+
71
+ # WeChat doesn't reliably support white-space: pre-wrap,
72
+ # so we explicitly convert newlines to <br>
73
+ content_with_br = content.replace('\n', '<br>')
74
+
75
+ return (
76
+ '<pre style="background: #f5f5f5; padding: 12px 16px; '
77
+ 'border-radius: 4px; font-size: 14px; line-height: 1.7; '
78
+ 'overflow-x: auto; margin-bottom: 16px; '
79
+ 'word-break: break-all; border: 1px solid #e0e0e0;">'
80
+ f'{lang_html}'
81
+ '<code style="font-family: Consolas, Monaco, \'Courier New\', '
82
+ 'monospace; color: #333;">'
83
+ f'{content_with_br}'
84
+ '</code></pre>'
85
+ )
86
+
87
+ return re.sub(
88
+ rf'{_CODE_PLACEHOLDER}(\d+){_CODE_PLACEHOLDER}',
89
+ _restore,
90
+ html,
91
+ )
92
+
93
+
94
+ # ── Element transformers ─────────────────────────────────────
95
+
96
+ def convert_hr(html):
97
+ """Convert <hr> to a styled divider <div>."""
98
+ return re.sub(
99
+ r'<hr[^>]*>',
100
+ r'<div style="border-top: 1px solid #ddd; margin: 24px 0;"></div>',
101
+ html,
102
+ )
103
+
104
+
105
+ def flatten_nested_blockquotes(html):
106
+ """Flatten nested <blockquote> tags (WeChat doesn't handle them)."""
107
+ while "<blockquote><blockquote>" in html:
108
+ html = html.replace("<blockquote><blockquote>", "<blockquote>")
109
+ while "</blockquote></blockquote>" in html:
110
+ html = html.replace("</blockquote></blockquote>", "</blockquote>")
111
+ return html
112
+
113
+
114
+ def convert_table_to_div(html):
115
+ """Convert <table> to WeChat-compatible inline-block layout.
116
+
117
+ WeChat drafts don't support <table>, <tr>, <td>, flex, or
118
+ table-cell display. This uses <span> + display:inline-block +
119
+ percentage widths to simulate a table. Must run before the
120
+ whitelist filter since <table> is not in WECHAT_SAFE_TAGS.
121
+ """
122
+ def _convert(match):
123
+ table_html = match.group(0)
124
+ rows = re.findall(
125
+ r'<tr[^>]*>(.*?)</tr>', table_html, re.DOTALL
126
+ )
127
+ if not rows:
128
+ return ''
129
+
130
+ max_cols = max(
131
+ len(re.findall(
132
+ r'<t[dh][^>]*>(.*?)</t[dh]>', row, re.DOTALL
133
+ ))
134
+ for row in rows
135
+ )
136
+ if max_cols == 0:
137
+ return ''
138
+
139
+ cell_width_pct = f"{100.0 / max_cols:.2f}%"
140
+
141
+ parts = []
142
+ parts.append(
143
+ '<div style="margin-bottom: 16px; border-top: 1px solid #e0e0e0; '
144
+ 'border-bottom: 1px solid #e0e0e0; overflow: hidden; '
145
+ 'font-size: 14px; line-height: 1.6;">'
146
+ )
147
+
148
+ for row_idx, row in enumerate(rows):
149
+ cells = re.findall(
150
+ r'<t[dh][^>]*>(.*?)</t[dh]>', row, re.DOTALL
151
+ )
152
+ if not cells:
153
+ continue
154
+
155
+ is_header = '<th' in row or row_idx == 0
156
+ bg = '#f5f5f5' if is_header else 'transparent'
157
+ fw = 'bold' if is_header else 'normal'
158
+ border_bottom = (
159
+ '1px solid #e0e0e0' if row_idx < len(rows) - 1
160
+ else 'none'
161
+ )
162
+
163
+ row_cells = []
164
+ for ci, cell in enumerate(cells):
165
+ cell_content = cell.strip()
166
+ row_cells.append(
167
+ f'<span style="display:inline-block; width:{cell_width_pct}; '
168
+ f'padding: 8px 12px; font-weight: {fw}; '
169
+ f'box-sizing:border-box; vertical-align:middle; '
170
+ f'color: #333;">{cell_content}</span>'
171
+ )
172
+
173
+ parts.append(
174
+ f'<p style="margin:0; padding:0; line-height:1.6; '
175
+ f'overflow:hidden; background:{bg}; border-bottom:{border_bottom}">'
176
+ f'{"".join(row_cells)}'
177
+ f'</p>'
178
+ )
179
+
180
+ parts.append('</div>')
181
+ return '\n'.join(parts)
182
+
183
+ return re.sub(
184
+ r'<table[^>]*>.*?</table>', _convert, html,
185
+ flags=re.DOTALL,
186
+ )
187
+
188
+
189
+ def apply_wechat_styles(html):
190
+ """Add default WeChat-compatible styles to various elements.
191
+
192
+ Handles: h2/h3/h4 font sizes, inline code background, blockquote
193
+ border, and code font styles. Only applies to elements without
194
+ existing style attributes.
195
+ """
196
+ # h2
197
+ html = re.sub(
198
+ r'<h2(\b(?!\s+[^>]*style=)[^>]*)>',
199
+ r'<h2 style="font-size: 20px; font-weight: bold; '
200
+ r'margin-bottom: 12px; margin-top: 24px;"\1>',
201
+ html,
202
+ )
203
+ # h3
204
+ html = re.sub(
205
+ r'<h3(\b(?!\s+[^>]*style=)[^>]*)>',
206
+ r'<h3 style="font-size: 18px; font-weight: bold; '
207
+ r'margin-bottom: 10px; margin-top: 20px;"\1>',
208
+ html,
209
+ )
210
+ # h4
211
+ html = re.sub(
212
+ r'<h4(\b(?!\s+[^>]*style=)[^>]*)>',
213
+ r'<h4 style="font-size: 16px; font-weight: bold; '
214
+ r'margin-bottom: 8px; margin-top: 16px;"\1>',
215
+ html,
216
+ )
217
+ # inline code
218
+ html = re.sub(
219
+ r'<code(\b(?!\s+[^>]*style=)[^>]*)>',
220
+ r'<code style="background: #f0f0f0; padding: 2px 4px; '
221
+ r'border-radius: 3px; font-size: 14px; '
222
+ r'font-family: Consolas, Monaco, \'Courier New\', monospace; '
223
+ r'color: #333;"\1>',
224
+ html,
225
+ )
226
+ # blockquote
227
+ html = re.sub(
228
+ r'<blockquote(\b(?!\s+[^>]*style=)[^>]*)>',
229
+ r'<blockquote style="border-left: 4px solid #ddd; '
230
+ r'padding: 8px 16px; margin: 16px 0; '
231
+ r'color: #666; background: #fafafa;"\1>',
232
+ html,
233
+ )
234
+ return html
235
+
236
+
237
+ def extract_images(html):
238
+ """Return all image src URLs found in the HTML."""
239
+ return re.findall(r'<img[^>]+src="([^"]+)"', html)
240
+
241
+
242
+ def replace_images(html, image_map):
243
+ """Replace original image URLs with WeChat CDN URLs."""
244
+ for old_url, wechat_url in image_map.items():
245
+ html = html.replace(f'src="{old_url}"', f'src="{wechat_url}"')
246
+ return html
247
+
248
+
249
+ def convert_links(html):
250
+ """Convert <a href="url">text</a> to text [url] format.
251
+
252
+ WeChat drafts may strip or lose styling on <a> tags.
253
+ Converting to plain text + URL is more reliable.
254
+ """
255
+ def _replace_link(match):
256
+ href = match.group(1)
257
+ text = match.group(2).strip()
258
+ if href == text or href.endswith(text):
259
+ return text
260
+ return f'{text} [{href}]'
261
+
262
+ html = re.sub(
263
+ r'<a\s+[^>]*href="([^"]+)"[^>]*>(.*?)</a>',
264
+ _replace_link, html, flags=re.DOTALL,
265
+ )
266
+ html = re.sub(
267
+ r"<a\s+[^>]*href='([^']+)'[^>]*>(.*?)</a>",
268
+ _replace_link, html, flags=re.DOTALL,
269
+ )
270
+ return html
271
+
272
+
273
+ def convert_ordered_list(html):
274
+ """Convert <ol> to <p> + number prefix paragraphs.
275
+
276
+ WeChat doesn't render <ol> list-style — convert to explicit numbering.
277
+ """
278
+ def _convert(match):
279
+ items = re.findall(
280
+ r'<li>(.*?)</li>', match.group(0), re.DOTALL
281
+ )
282
+ lines = []
283
+ for i, item in enumerate(items, 1):
284
+ item = item.strip()
285
+ lines.append(
286
+ f'<p style="margin-bottom: 8px; padding-left: 16px;">'
287
+ f'{i}. {item}</p>'
288
+ )
289
+ return '\n'.join(lines)
290
+
291
+ return re.sub(r'<ol>.*?</ol>', _convert, html, flags=re.DOTALL)
292
+
293
+
294
+ def convert_unordered_list(html):
295
+ """Convert <ul> to <p> + bullet prefix paragraphs.
296
+
297
+ WeChat doesn't render <ul> list-style — convert to explicit bullets.
298
+ """
299
+ def _convert(match):
300
+ items = re.findall(
301
+ r'<li>(.*?)</li>', match.group(0), re.DOTALL
302
+ )
303
+ lines = []
304
+ for item in items:
305
+ item = item.strip()
306
+ lines.append(
307
+ f'<p style="margin-bottom: 8px; padding-left: 16px;">'
308
+ f'• {item}</p>'
309
+ )
310
+ return '\n'.join(lines)
311
+
312
+ return re.sub(r'<ul>.*?</ul>', _convert, html, flags=re.DOTALL)
313
+
314
+
315
+ # ── Main pipeline ────────────────────────────────────────────
316
+
317
+ def process_html(html_content, image_map):
318
+ """Full HTML processing pipeline: Ghost HTML → WeChat-compatible HTML.
319
+
320
+ Pipeline stages (order is critical):
321
+ 1. Replace image URLs with WeChat CDN URLs
322
+ 2. Remove Ghost-specific HTML comments (<!--kg-card-*-->)
323
+ 3. Convert <hr> to styled <div> (before whitelist filter)
324
+ 4. Convert <table> to inline-block layout (before whitelist filter)
325
+ 5. Protect code blocks with placeholders
326
+ 6. Three-level whitelist filter (tags → attrs → styles)
327
+ 7. Restore code blocks with WeChat-safe styling
328
+ 8. Apply default styles (headings, code, blockquote)
329
+ 9. Flatten nested blockquotes
330
+ 10. Add paragraph spacing
331
+ 11. Add image spacing
332
+ 12. Convert links to text [url] format
333
+ 13. Convert ordered lists to numbered <p>
334
+ 14. Convert unordered lists to bulleted <p>
335
+
336
+ Returns HTML suitable for WeChat draft creation.
337
+ """
338
+ # 1. Replace image URLs
339
+ html = replace_images(html_content, image_map)
340
+
341
+ # 2. Remove Ghost comments
342
+ html = clean_ghost_comments(html)
343
+
344
+ # 3. Convert <hr> (before whitelist — <hr> not in safe tags)
345
+ html = convert_hr(html)
346
+
347
+ # 4. Convert <table> (before whitelist — <table> not in safe tags)
348
+ html = convert_table_to_div(html)
349
+
350
+ # 5. Protect code blocks
351
+ html = convert_code_blocks(html)
352
+
353
+ # 6. Three-level whitelist filter
354
+ html = clean_html_for_wechat(html)
355
+
356
+ # 7. Restore code blocks
357
+ html = restore_code_blocks(html)
358
+
359
+ # 8. Default styles
360
+ html = apply_wechat_styles(html)
361
+
362
+ # 9. Flatten nested blockquotes
363
+ html = flatten_nested_blockquotes(html)
364
+
365
+ # 10. Paragraph spacing
366
+ html = re.sub(
367
+ r'<p\b(?!\s+[^>]*style=)([^>]*)>',
368
+ r'<p style="margin-bottom: 16px;">', html,
369
+ )
370
+
371
+ # 11. Image spacing
372
+ html = re.sub(
373
+ r'<img([^>]*)>',
374
+ r'<img style="margin-bottom: 16px;"\1>', html,
375
+ )
376
+
377
+ # 12. Links → text [url]
378
+ html = convert_links(html)
379
+
380
+ # 13. Ordered lists → numbered <p>
381
+ html = convert_ordered_list(html)
382
+
383
+ # 14. Unordered lists → bullet <p>
384
+ html = convert_unordered_list(html)
385
+
386
+ return html
ghostwriter/wechat.py ADDED
@@ -0,0 +1,125 @@
1
+ """WeChat Official Account API client.
2
+
3
+ Handles access token management (with disk caching), permanent material
4
+ upload for images, and draft creation.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import re
10
+ import time
11
+
12
+ import requests
13
+
14
+
15
+ TOKEN_CACHE = "/tmp/wechat_token.json"
16
+
17
+
18
+ def get_wechat_token(appid, secret):
19
+ """Get a WeChat access token, caching it to disk.
20
+
21
+ Returns a cached token if it has more than 60 seconds remaining.
22
+ Otherwise requests a new one from the WeChat API.
23
+ """
24
+ cached = None
25
+ if os.path.exists(TOKEN_CACHE):
26
+ with open(TOKEN_CACHE, encoding="utf-8") as f:
27
+ cached = json.load(f)
28
+ if cached.get("expires_at", 0) > time.time() + 60:
29
+ return cached["access_token"]
30
+
31
+ url = (
32
+ f"https://api.weixin.qq.com/cgi-bin/token"
33
+ f"?grant_type=client_credential&appid={appid}&secret={secret}"
34
+ )
35
+ r = requests.get(url, timeout=10)
36
+ r.raise_for_status()
37
+ data = r.json()
38
+ if "access_token" not in data:
39
+ raise Exception(f"获取token失败: {data}")
40
+
41
+ token = data["access_token"]
42
+ with open(TOKEN_CACHE, "w", encoding="utf-8") as f:
43
+ json.dump({
44
+ "access_token": token,
45
+ "expires_at": data.get("expires_in", 7200) + time.time(),
46
+ }, f)
47
+ return token
48
+
49
+
50
+ def upload_permanent_material(token, image_url, material_type="image"):
51
+ """Upload an image to WeChat permanent material storage.
52
+
53
+ Returns (media_id, url) tuple. Both are None on failure.
54
+ """
55
+ try:
56
+ img_data = requests.get(image_url, timeout=30)
57
+ img_data.raise_for_status()
58
+ except Exception as e:
59
+ print(f" [警告] 下载图片失败 {image_url}: {e}")
60
+ return None, None
61
+
62
+ ext = re.search(r'\.(jpg|jpeg|png|gif|webp)', image_url, re.I)
63
+ ext = ext.group(1) if ext else "jpg"
64
+ mime = f"image/{ext.replace('jpg', 'jpeg')}"
65
+
66
+ files = {"media": (f"image.{ext}", img_data.content, mime)}
67
+ url = (
68
+ f"https://api.weixin.qq.com/cgi-bin/material/add_material"
69
+ f"?access_token={token}&type={material_type}"
70
+ )
71
+ r = requests.post(url, files=files, timeout=60)
72
+ r.raise_for_status()
73
+ data = r.json()
74
+ if "media_id" in data:
75
+ return data["media_id"], data.get("url")
76
+ print(f" [警告] 上传永久素材失败: {data}")
77
+ return None, None
78
+
79
+
80
+ def create_wechat_draft(token, title, author, content, thumb_media_id,
81
+ digest=""):
82
+ """Create a draft in WeChat's draft box.
83
+
84
+ Args:
85
+ token: WeChat access token.
86
+ title: Article title.
87
+ author: Author name (max 8 bytes).
88
+ content: HTML content for the article body.
89
+ thumb_media_id: Cover image media_id (empty string if none).
90
+ digest: Article summary (max 120 bytes, auto-truncated).
91
+
92
+ Returns:
93
+ (success: bool, message: str)
94
+ """
95
+ if not digest:
96
+ digest = "查看全文"
97
+ elif len(digest.encode("utf-8")) > 120:
98
+ digest = digest.encode("utf-8")[:119].decode("utf-8", errors="ignore")
99
+
100
+ articles = [{
101
+ "title": title,
102
+ "author": author,
103
+ "content": content,
104
+ "content_source_url": "",
105
+ "digest": digest,
106
+ "thumb_media_id": thumb_media_id,
107
+ "need_open_comment": 1,
108
+ "only_fans_can_comment": 0,
109
+ }]
110
+
111
+ url = f"https://api.weixin.qq.com/cgi-bin/draft/add?access_token={token}"
112
+ payload_bytes = json.dumps(
113
+ {"articles": articles}, ensure_ascii=False
114
+ ).encode("utf-8")
115
+ r = requests.post(
116
+ url, data=payload_bytes,
117
+ headers={"Content-Type": "application/json"},
118
+ timeout=30,
119
+ )
120
+ r.raise_for_status()
121
+ data = r.json()
122
+
123
+ if data.get("errcode") == 0 or "media_id" in data:
124
+ return True, f"草稿创建成功,media_id={data.get('media_id')}"
125
+ return False, f"创建草稿失败: {data}"
@@ -0,0 +1,172 @@
1
+ Metadata-Version: 2.4
2
+ Name: ghostwriter-cli
3
+ Version: 0.1.0
4
+ Summary: Markdown → Ghost → WeChat publishing pipeline
5
+ Project-URL: Homepage, https://github.com/yinguobing/ghostwriter
6
+ Author: yinguobing
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.9
10
+ Requires-Dist: pyjwt>=2.0
11
+ Requires-Dist: requests>=2.25
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=7.0; extra == 'dev'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # ghostwriter
17
+
18
+ Markdown → Ghost → WeChat 发布管道。写一次,多处发布。
19
+
20
+ ```
21
+ markdown → Ghost 博客 → 微信公众号
22
+ ```
23
+
24
+ ## 功能
25
+
26
+ - **发布**:Markdown 文件直接发布到 Ghost 博客(Lexical 格式,编辑器可编辑)
27
+ - **同步**:已发布的 Ghost 文章同步到微信公众号草稿箱
28
+ - **全自动**:发布后一步同步到微信
29
+
30
+ ## 安装
31
+
32
+ ```bash
33
+ # 从 PyPI 安装(推荐)
34
+ pip install ghostwriter-cli
35
+
36
+ # 或从源码安装(开发模式)
37
+ git clone https://github.com/yinguobing/ghostwriter.git
38
+ cd ghostwriter
39
+ pip install -e ".[dev]"
40
+ ```
41
+
42
+ ## 配置
43
+
44
+ 支持两种配置方式(环境变量优先):
45
+
46
+ ### 方式 1:环境变量(推荐用于 CI/Docker)
47
+
48
+ ```bash
49
+ export GHOSTWRITER_GHOST_API_URL="https://yinguobing.com"
50
+ export GHOSTWRITER_GHOST_ADMIN_KEY_ID="your_key_id"
51
+ export GHOSTWRITER_GHOST_ADMIN_KEY="your_hex_secret"
52
+ export GHOSTWRITER_WECHAT_APPID="your_wechat_appid"
53
+ export GHOSTWRITER_WECHAT_SECRET="your_wechat_secret"
54
+ ```
55
+
56
+ ### 方式 2:配置文件(适合本地使用)
57
+
58
+ ```bash
59
+ # 交互式设置
60
+ ghostwriter config set ghost.api_url https://yinguobing.com
61
+ ghostwriter config set ghost.admin_key_id your_key_id
62
+ ghostwriter config set ghost.admin_key your_hex_secret
63
+ ghostwriter config set wechat.appid your_wechat_appid
64
+ ghostwriter config set wechat.secret your_wechat_secret
65
+
66
+ # 查看当前配置(密钥已脱敏)
67
+ ghostwriter config
68
+ ```
69
+
70
+ 配置文件保存在 `~/.config/ghostwriter/config.json`。
71
+
72
+ 可选字段:
73
+ ```bash
74
+ # 作者映射(Ghost Content API 不可用时的离线回退)
75
+ ghostwriter config set authors.<slug> <ghost_author_id>
76
+ ```
77
+
78
+ **Ghost Admin API Key 获取:**
79
+ Ghost 后台 → Settings → Advanced → Integrations → Add custom integration → 复制 `Admin API Key`(格式为 `key_id:hex_secret`,拆成两段填入)
80
+
81
+ **微信 AppID/Secret 获取:**
82
+ [微信公众平台](https://mp.weixin.qq.com) → 设置与开发 → 基本配置
83
+
84
+ ## 用法
85
+
86
+ ### 列出 Ghost 文章
87
+
88
+ ```bash
89
+ ghostwriter list
90
+ ```
91
+
92
+ ### 发布 Markdown 到 Ghost
93
+
94
+ ```bash
95
+ # 直接发布
96
+ ghostwriter publish article.md
97
+
98
+ # 指定标题和标签
99
+ ghostwriter publish article.md --title "我的文章" --tags Ghost,开源
100
+
101
+ # 指定作者(slug,默认 xiaohei)
102
+ ghostwriter publish article.md --author guobing
103
+
104
+ # 先存草稿
105
+ ghostwriter publish article.md --draft
106
+
107
+ # 发布后自动同步到微信
108
+ ghostwriter publish article.md --wechat
109
+ ```
110
+
111
+ ### 同步 Ghost 文章到微信草稿
112
+
113
+ ```bash
114
+ # 先列出文章获取 ID
115
+ ghostwriter list
116
+
117
+ # 同步指定文章
118
+ ghostwriter <article-id>
119
+
120
+ # 预览 HTML(不创建草稿)
121
+ ghostwriter --preview <article-id>
122
+ ```
123
+
124
+ ## 管道说明
125
+
126
+ ### Markdown → Ghost(`publish` 命令)
127
+
128
+ 将 Markdown 文件转换为 Ghost 的 **Lexical 格式**(基于 `@tryghost/kg-lexical-html-renderer`),支持:
129
+
130
+ - 标题(h1~h6)
131
+ - 段落、粗体、斜体、行内代码、链接
132
+ - 围栏代码块(带语言标记)
133
+ - 表格(以 HTML card 渲染)
134
+ - 有序/无序列表
135
+ - 分割线
136
+
137
+ 转换后的文章在 Ghost 编辑器里可以正常编辑。
138
+
139
+ ### Ghost → 微信(`sync` 命令)
140
+
141
+ 从 Ghost API 获取文章 HTML,经过多层处理管道后推送到微信公众号草稿箱:
142
+
143
+ 1. 图片上传到微信永久素材,替换为 CDN 地址
144
+ 2. 白名单三层过滤(标签/属性/样式)
145
+ 3. 代码块保护与恢复
146
+ 4. 微信不支持的标签转换(table → div, ol/ul → 前缀段落, hr → 分隔线)
147
+ 5. 默认样式补全
148
+
149
+ ## 项目结构
150
+
151
+ ```
152
+ ghostwriter/
153
+ ├── pyproject.toml # 项目元数据、构建配置
154
+ ├── src/ghostwriter/ # 源码包
155
+ │ ├── cli.py # CLI 入口与命令分发
156
+ │ ├── config.py # 配置文件加载
157
+ │ ├── ghost.py # Ghost Admin API 客户端
158
+ │ ├── wechat.py # 微信公众号 API 客户端
159
+ │ ├── cleaner.py # HTML 白名单过滤器
160
+ │ ├── pipeline.py # Ghost → 微信 HTML 处理管道
161
+ │ ├── lexical.py # Markdown → Ghost Lexical 转换器
162
+ │ └── normalize.py # Unicode 标题规范化
163
+ ├── tests/ # 单元测试(pytest,74个)
164
+ └── docs/ # 项目页面
165
+ ```
166
+
167
+ ## 注意事项
168
+
169
+ - 微信草稿标题限制:Unicode 特殊字符(弯引号、破折号等)会触发 45003 错误,脚本会自动处理
170
+ - 作者字段在微信公众号中限制 8 字节
171
+ - 代码块使用 `<pre>` + 语言标签的样式方案,微信中可用
172
+ - `--wechat` 参数需要在 Ghost API 中能通过 slug 查到刚发布的文章
@@ -0,0 +1,15 @@
1
+ ghostwriter/__init__.py,sha256=oiqgFypfHGASO9U5w1Jl_Gzl83HJfp23Z5tY9noclHU,783
2
+ ghostwriter/__main__.py,sha256=SKnIFjN8fSkBuc-cf0yMPR1P5DJlqSuKmO7K4Shtr5M,80
3
+ ghostwriter/cleaner.py,sha256=2HJWz6nlK2M7iDC4pf97BZXuvuglUzaE8MKN4crg-X4,4484
4
+ ghostwriter/cli.py,sha256=NiHpTV-paK_cz-ey08JV87aK3H47UUDLEDKyhYoZ8Fw,17224
5
+ ghostwriter/config.py,sha256=K2mKWw5jtNGTp0zxVNG2gu-l6ytqV0nvO5WD-f4CWVg,7403
6
+ ghostwriter/ghost.py,sha256=YJ4hvVd0wmHK3qT0p8-93NnvEW0bcu8kS59SwR_WgPk,3872
7
+ ghostwriter/lexical.py,sha256=X3Iv8ph6LWYhjLMMs0KnqRQvvevwMPhFwwJJ_3I-oTc,7315
8
+ ghostwriter/normalize.py,sha256=b_DJ7FIjJoVjSoO4h6tjQ7VCfTZXC1F3OQ_MNpSnGUw,717
9
+ ghostwriter/pipeline.py,sha256=fjepSpZ0G159TgNPwevM-XEMEEEIfGB0oMmxS_64FYo,12341
10
+ ghostwriter/wechat.py,sha256=i5lF2MC7LYJozM2eKZWRxEz2UwxEQqj6-IFy1oSkGcI,3838
11
+ ghostwriter_cli-0.1.0.dist-info/METADATA,sha256=P977GXZtDF96fMBpeXSjmePUdNEtXap2hN7enVf4NYw,5035
12
+ ghostwriter_cli-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
13
+ ghostwriter_cli-0.1.0.dist-info/entry_points.txt,sha256=5SeX1H_RAr9qTBoQRTNHsunsoySv0UehKHBH910KE5M,53
14
+ ghostwriter_cli-0.1.0.dist-info/licenses/LICENSE,sha256=TVSrdg_sEHsFfUhD3OfRxbTWDRCNs0rb9Za1XmOcZVM,1067
15
+ ghostwriter_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any