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,28 @@
1
+ """ghostwriter — Markdown → Ghost → WeChat publishing pipeline.
2
+
3
+ Write Markdown, publish to a Ghost blog, and sync to WeChat drafts.
4
+ """
5
+
6
+ from .cli import main, sync_article, publish_md_to_ghost, list_posts
7
+ from .cleaner import clean_html_for_wechat, WECHAT_SAFE_TAGS
8
+ from .config import load_config, set_config_value, show_config, config_path
9
+ from .lexical import md_to_ghost_lexical
10
+ from .normalize import normalize_title
11
+ from .pipeline import process_html
12
+
13
+ __version__ = "0.1.0"
14
+ __all__ = [
15
+ "main",
16
+ "sync_article",
17
+ "publish_md_to_ghost",
18
+ "list_posts",
19
+ "clean_html_for_wechat",
20
+ "WECHAT_SAFE_TAGS",
21
+ "load_config",
22
+ "set_config_value",
23
+ "show_config",
24
+ "config_path",
25
+ "md_to_ghost_lexical",
26
+ "normalize_title",
27
+ "process_html",
28
+ ]
@@ -0,0 +1,5 @@
1
+ """Enable `python -m ghostwriter` invocation."""
2
+
3
+ from .cli import main
4
+
5
+ main()
ghostwriter/cleaner.py ADDED
@@ -0,0 +1,130 @@
1
+ """HTML cleaner for WeChat draft compatibility.
2
+
3
+ WeChat's rich-text editor only supports a limited subset of HTML tags,
4
+ attributes, and CSS properties. This module provides a three-level
5
+ whitelist filter that strips everything else while preserving text content.
6
+
7
+ Level 1: Tag whitelist — strip unknown tags, keep their inner text
8
+ Level 2: Attribute whitelist — keep only safe attributes
9
+ Level 3: Style whitelist — keep only safe CSS properties
10
+ """
11
+
12
+ import html.parser
13
+ import re
14
+ from html import escape as _html_escape
15
+
16
+
17
+ # ── 微信安全标签白名单 ──────────────────────────────────────
18
+ WECHAT_SAFE_TAGS = {
19
+ "p", "br", "strong", "em", "b", "i", "u", "a", "img", "span",
20
+ "div", "h2", "h3", "h4", "blockquote", "pre", "code", "ul", "ol", "li",
21
+ }
22
+ WECHAT_SAFE_ATTRS = {"href", "src", "alt", "title"}
23
+ WECHAT_SAFE_STYLES = {
24
+ "color", "font-size", "font-weight", "font-family", "text-align",
25
+ "line-height", "margin", "margin-bottom", "margin-left", "margin-right",
26
+ "padding", "padding-left", "padding-right", "background", "background-color",
27
+ "border", "border-left", "border-bottom", "border-right", "border-collapse",
28
+ "border-radius", "width", "height", "max-width",
29
+ "white-space", "word-break", "overflow", "vertical-align",
30
+ "display",
31
+ }
32
+
33
+
34
+ def _filter_style(style_value):
35
+ """Keep only whitelisted CSS properties from a style attribute value."""
36
+ props = []
37
+ for decl in style_value.split(";"):
38
+ decl = decl.strip()
39
+ if not decl or ":" not in decl:
40
+ continue
41
+ prop, val = decl.split(":", 1)
42
+ prop = prop.strip().lower()
43
+ if prop in WECHAT_SAFE_STYLES:
44
+ props.append(f"{prop}: {val.strip()}")
45
+ return "; ".join(props)
46
+
47
+
48
+ class _WeChatCleaner(html.parser.HTMLParser):
49
+ """HTMLParser-based three-level whitelist filter.
50
+
51
+ Strips non-whitelisted tags (preserving inner text), filters
52
+ attributes and inline styles to only what WeChat supports.
53
+ """
54
+
55
+ def __init__(self):
56
+ super().__init__(convert_charrefs=False)
57
+ self.out = []
58
+ self._skip_depth = 0
59
+
60
+ # HTML void elements (self-closing, no end tag)
61
+ _VOID_ELEMENTS = frozenset((
62
+ "area", "base", "br", "col", "embed", "hr", "img",
63
+ "input", "link", "meta", "param", "source", "track", "wbr",
64
+ ))
65
+
66
+ def handle_starttag(self, tag, attrs):
67
+ tag = tag.lower()
68
+ if self._skip_depth > 0:
69
+ if tag not in self._VOID_ELEMENTS:
70
+ self._skip_depth += 1
71
+ return
72
+ if tag not in WECHAT_SAFE_TAGS:
73
+ self._skip_depth = 1
74
+ return
75
+ keep = []
76
+ for name, val in attrs:
77
+ nl = name.lower().strip()
78
+ if nl in WECHAT_SAFE_ATTRS:
79
+ keep.append(f'{name}="{_html_escape(val)}"')
80
+ elif nl == "style" and val.strip():
81
+ filtered = _filter_style(val)
82
+ if filtered:
83
+ keep.append(f'style="{_html_escape(filtered)}"')
84
+ attr_str = " " + " ".join(keep) if keep else ""
85
+ self.out.append(f"<{tag}{attr_str}>")
86
+
87
+ def handle_endtag(self, tag):
88
+ tag = tag.lower()
89
+ if self._skip_depth > 0:
90
+ self._skip_depth -= 1
91
+ return
92
+ if tag in WECHAT_SAFE_TAGS:
93
+ self.out.append(f"</{tag}>")
94
+
95
+ def handle_data(self, data):
96
+ self.out.append(data)
97
+
98
+ def handle_entityref(self, name):
99
+ self.out.append(f"&{name};")
100
+
101
+ def handle_charref(self, name):
102
+ self.out.append(f"&#{name};")
103
+
104
+ def handle_comment(self, data):
105
+ pass # comments removed entirely
106
+
107
+
108
+ def clean_html_for_wechat(html):
109
+ """Apply three-level whitelist filtering for WeChat compatibility.
110
+
111
+ 1. Remove <script>/<style> tags and their content
112
+ 2. Replace data-src with src
113
+ 3. Run the tag/attribute/style whitelist filter
114
+ """
115
+ # Remove script/style
116
+ html = re.sub(
117
+ r'<(script|style)[^>]*>.*?</\1>', '', html,
118
+ flags=re.DOTALL | re.I
119
+ )
120
+ # Replace data-src with src (before parser, to avoid attr filtering)
121
+ html = re.sub(r'\s*data-src="([^"]+)"', r' src="\1"', html)
122
+ # Three-level filter
123
+ cleaner = _WeChatCleaner()
124
+ cleaner.feed(html)
125
+ return "".join(cleaner.out)
126
+
127
+
128
+ def clean_ghost_comments(html):
129
+ """Remove Ghost-specific HTML comments (<!--kg-card-*--> etc.)."""
130
+ return re.sub(r'<!--[\s\S]*?-->', '', html)
ghostwriter/cli.py ADDED
@@ -0,0 +1,500 @@
1
+ """CLI entry point and high-level command implementations.
2
+
3
+ Provides the `ghostwriter` console script and the three main commands:
4
+ - list: List Ghost posts
5
+ - publish: Publish Markdown to Ghost (and optionally sync to WeChat)
6
+ - sync: Sync a Ghost article to a WeChat draft
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import sys
12
+
13
+ import requests
14
+
15
+ from .config import load_config, set_config_value, show_config, config_path
16
+ from .ghost import (
17
+ ghost_api_get,
18
+ ghost_api_post,
19
+ get_ghost_article,
20
+ get_ghost_authors,
21
+ get_ghost_posts,
22
+ upload_image_to_ghost,
23
+ )
24
+ from .lexical import md_to_ghost_lexical
25
+ from .normalize import normalize_title
26
+ from .pipeline import extract_images, process_html
27
+ from .wechat import (
28
+ create_wechat_draft,
29
+ get_wechat_token,
30
+ upload_permanent_material,
31
+ )
32
+
33
+
34
+ # ── Sync: Ghost article → WeChat draft ───────────────────────
35
+
36
+ def sync_article(article_id, preview_only=False):
37
+ """Sync a Ghost article to a WeChat draft.
38
+
39
+ Args:
40
+ article_id: Ghost post ID.
41
+ preview_only: If True, save HTML to /tmp instead of creating a draft.
42
+
43
+ Returns:
44
+ True on success, False on failure.
45
+ """
46
+ config = load_config()
47
+
48
+ action = "预览" if preview_only else "同步"
49
+ print(f"[*] 开始{action} Ghost 文章 {article_id} → 微信草稿")
50
+
51
+ # 1. Get Ghost article
52
+ print("[*] 获取 Ghost 文章...")
53
+ ghost_data = get_ghost_article(article_id, config)
54
+ posts = ghost_data.get("posts", [])
55
+ if not posts:
56
+ print(f"[!] 未找到文章: {article_id}")
57
+ return False
58
+ article = posts[0]
59
+
60
+ title = normalize_title(article.get("title", "无标题"))
61
+ author = article.get("primary_author", {}).get("name", "")
62
+ html_content = article.get("html", "")
63
+ feature_image = article.get("feature_image")
64
+ custom_excerpt = (
65
+ article.get("custom_excerpt") or article.get("excerpt", "")
66
+ )
67
+
68
+ print(f"[+] 标题: {title}")
69
+ print(f"[+] 状态: {article.get('status')}")
70
+
71
+ # In preview mode, skip all WeChat API calls
72
+ if not preview_only:
73
+ wc = config["wechat"]
74
+ print("[*] 获取微信 access_token...")
75
+ token = get_wechat_token(wc["appid"], wc["secret"])
76
+ print(f"[+] token: {token[:20]}...")
77
+
78
+ # Author field: max 8 bytes for WeChat
79
+ author_for_wechat = "国冰"
80
+ if author and len(author.encode("utf-8")) <= 8:
81
+ author_for_wechat = author
82
+
83
+ # Upload cover image (permanent material)
84
+ thumb_media_id = ""
85
+ if feature_image:
86
+ print(f"[*] 上传封面图(永久素材)...")
87
+ thumb_media_id, _ = upload_permanent_material(token, feature_image)
88
+ if thumb_media_id:
89
+ print(f"[+] 封面图 media_id: {thumb_media_id}")
90
+ else:
91
+ print(f"[!] 封面上传失败,将创建无封面草稿")
92
+
93
+ # Extract and upload content images
94
+ images = extract_images(html_content)
95
+ image_map = {}
96
+ if images:
97
+ print(f"[*] 发现 {len(images)} 张内容图片,开始上传...")
98
+ for img_url in images:
99
+ media_id, wechat_url = upload_permanent_material(token, img_url)
100
+ if wechat_url:
101
+ image_map[img_url] = wechat_url
102
+ print(f" [+] {img_url[:60]}... → {wechat_url[:60]}...")
103
+ else:
104
+ # Preview: no WeChat token needed, keep original image URLs
105
+ author_for_wechat = author if author else "国冰"
106
+ thumb_media_id = ""
107
+ image_map = {}
108
+
109
+ # Run HTML processing pipeline
110
+ final_html = process_html(html_content, image_map)
111
+
112
+ # Output result
113
+ print(
114
+ f"[*] 标题字节: {len(title.encode('utf-8'))} | "
115
+ f"作者: {author_for_wechat!r}"
116
+ )
117
+ print(f"[*] 最终 HTML 预览:\n{final_html[:500]}...")
118
+ print(f"[*] HTML 总长: {len(final_html)} 字符")
119
+
120
+ if preview_only:
121
+ preview_path = f"/tmp/wechat_preview_{article_id}.html"
122
+ with open(preview_path, "w", encoding="utf-8") as f:
123
+ f.write(final_html)
124
+ print(f"\n[+] 完整 HTML 已保存到: {preview_path}")
125
+ print(f"[+] 用浏览器打开即可预览效果")
126
+ return True
127
+
128
+ print("[*] 创建微信草稿...")
129
+ digest = custom_excerpt
130
+ if not digest:
131
+ digest = article.get('excerpt', '')[:200]
132
+
133
+ success, msg = create_wechat_draft(
134
+ token, title, author_for_wechat, final_html,
135
+ thumb_media_id, digest,
136
+ )
137
+ print(f"[+] {msg}")
138
+ return success
139
+
140
+
141
+ # ── List posts ───────────────────────────────────────────────
142
+
143
+ def list_posts(limit=20):
144
+ """List Ghost posts via the Admin API.
145
+
146
+ Args:
147
+ limit: Max number of posts to show (default 20, use 'all' for unlimited).
148
+ """
149
+ config = load_config()
150
+ data = get_ghost_posts(config, limit=limit, status="all")
151
+ posts = data.get("posts", [])
152
+ print(f"共 {len(posts)} 篇:\n")
153
+ for p in posts:
154
+ tag = "📝" if p.get("status") == "published" else "📄"
155
+ print(
156
+ f"{tag} [{p.get('status')}] {p.get('title')} | "
157
+ f"id={p.get('id')}"
158
+ )
159
+
160
+
161
+ # ── Publish: Markdown → Ghost ────────────────────────────────
162
+
163
+ def publish_md_to_ghost(md_path, config,
164
+ title=None,
165
+ slug=None,
166
+ tags=None,
167
+ cover_image=None,
168
+ author_slug="xiaohei",
169
+ status="published"):
170
+ """Publish a Markdown file to a Ghost blog.
171
+
172
+ Args:
173
+ md_path: Path to the Markdown file.
174
+ config: Config dict from load_config().
175
+ title: Override title (default: first h1 in the file).
176
+ slug: Custom post slug.
177
+ tags: List of tag name strings.
178
+ cover_image: Local path to cover image (uploaded to Ghost).
179
+ author_slug: Ghost author slug (default "xiaohei").
180
+ status: "published" or "draft".
181
+
182
+ Returns:
183
+ (success: bool, result: str) — URL of the published post on
184
+ success, or an error message on failure.
185
+ """
186
+ if not os.path.exists(md_path):
187
+ return False, f"文件不存在: {md_path}"
188
+
189
+ with open(md_path, "r", encoding="utf-8") as f:
190
+ md_text = f.read()
191
+
192
+ extracted_title, lexical_json = md_to_ghost_lexical(md_text)
193
+ if not title:
194
+ title = (
195
+ extracted_title
196
+ or os.path.splitext(os.path.basename(md_path))[0]
197
+ )
198
+ if not title:
199
+ return False, "无法确定标题,请用 --title 指定"
200
+
201
+ # Extract first paragraph as excerpt
202
+ excerpt = ""
203
+ lex_data = json.loads(lexical_json)
204
+ for child in lex_data["root"]["children"]:
205
+ if child.get("type") == "paragraph":
206
+ texts = [
207
+ c.get("text", "")
208
+ for c in child.get("children", [])
209
+ if c.get("type") == "extended-text"
210
+ ]
211
+ excerpt = "".join(texts)[:200]
212
+ break
213
+
214
+ # Look up author (Content API → hardcoded fallback)
215
+ author_id = None
216
+ try:
217
+ authors_data = get_ghost_authors(config)
218
+ for a in authors_data.get("authors", []):
219
+ if a.get("slug") == author_slug:
220
+ author_id = a["id"]
221
+ break
222
+ if not author_id:
223
+ author_id = authors_data.get("authors", [{}])[0].get("id")
224
+ except Exception:
225
+ pass
226
+ # Fallback: authors map from config file
227
+ if not author_id:
228
+ authors_map = config.get("authors", {})
229
+ author_id = authors_map.get(author_slug)
230
+ if not author_id:
231
+ return False, f"无法找到作者: {author_slug}"
232
+
233
+ tag_objects = [{"name": t} for t in (tags or [])]
234
+
235
+ post = {
236
+ "title": title,
237
+ "lexical": lexical_json,
238
+ "status": status,
239
+ "visibility": "public",
240
+ "authors": [{"id": author_id}],
241
+ "tags": tag_objects,
242
+ "excerpt": excerpt,
243
+ }
244
+
245
+ # Upload cover image
246
+ if cover_image:
247
+ try:
248
+ feature_image_url = upload_image_to_ghost(config, cover_image)
249
+ post["feature_image"] = feature_image_url
250
+ print(f"[+] 封面图片已上传: {feature_image_url}")
251
+ except Exception as e:
252
+ print(f"[!] 封面图片上传失败: {e}")
253
+
254
+ if slug:
255
+ post["slug"] = slug
256
+ # Ghost API uses custom_excerpt, not excerpt
257
+ if excerpt:
258
+ post["custom_excerpt"] = excerpt
259
+ del post["excerpt"]
260
+
261
+ post_data = {"posts": [post]}
262
+
263
+ try:
264
+ result = ghost_api_post(
265
+ "/ghost/api/admin/posts/", post_data, config,
266
+ )
267
+ post = result.get("posts", [{}])[0]
268
+ post_url = (
269
+ f"{config['ghost']['api_url']}/{post.get('slug', '')}/"
270
+ )
271
+ return True, post_url
272
+ except requests.HTTPError as e:
273
+ try:
274
+ detail = e.response.json()
275
+ except Exception:
276
+ detail = str(e)
277
+ return False, f"Ghost API 错误: {detail}"
278
+
279
+
280
+ def _cmd_publish(args):
281
+ """Parse `publish` subcommand arguments and execute."""
282
+ # Check for help before loading config
283
+ if args and args[0] in ("--help", "-h", "help"):
284
+ print("""publish 用法:
285
+ ghostwriter publish <file.md> [选项]
286
+
287
+ 选项:
288
+ --title "标题" - 指定标题(默认取文件第一个 # 标题)
289
+ --slug "my-slug" - 指定 slug(默认从标题自动生成)
290
+ --cover "image.jpg" - 封面图片路径(本地文件,自动上传到 Ghost)
291
+ --tags tag1,tag2 - 逗号分隔的标签
292
+ --author slug - 作者 slug(默认 xiaohei)
293
+ --draft - 保存为草稿(默认直接发布)
294
+ --wechat - 发布后自动同步到微信草稿箱
295
+ --help, -h - 显示此帮助信息
296
+ """)
297
+ return True
298
+
299
+ config = load_config()
300
+
301
+ md_path = args[0]
302
+ title = None
303
+ tags = []
304
+ author = "xiaohei"
305
+ status = "published"
306
+ do_wechat = False
307
+ slug = None
308
+ cover_image = None
309
+
310
+ i = 1
311
+ while i < len(args):
312
+ if args[i] in ("--help", "-h", "help"):
313
+ print("""publish 用法:
314
+ ghostwriter publish <file.md> [选项]
315
+
316
+ 选项:
317
+ --title "标题" - 指定标题(默认取文件第一个 # 标题)
318
+ --slug "my-slug" - 指定 slug(默认从标题自动生成)
319
+ --cover "image.jpg" - 封面图片路径(本地文件,自动上传到 Ghost)
320
+ --tags tag1,tag2 - 逗号分隔的标签
321
+ --author slug - 作者 slug(默认 xiaohei)
322
+ --draft - 保存为草稿(默认直接发布)
323
+ --wechat - 发布后自动同步到微信草稿箱
324
+ --help, -h - 显示此帮助信息
325
+ """)
326
+ return True
327
+ elif args[i] == "--title" and i + 1 < len(args):
328
+ title = args[i + 1]
329
+ i += 2
330
+ elif args[i] == "--slug" and i + 1 < len(args):
331
+ slug = args[i + 1]
332
+ i += 2
333
+ elif args[i] == "--cover" and i + 1 < len(args):
334
+ cover_image = args[i + 1]
335
+ i += 2
336
+ elif args[i] == "--tags" and i + 1 < len(args):
337
+ tags = [t.strip() for t in args[i + 1].split(",")]
338
+ i += 2
339
+ elif args[i] == "--author" and i + 1 < len(args):
340
+ author = args[i + 1]
341
+ i += 2
342
+ elif args[i] == "--draft":
343
+ status = "draft"
344
+ i += 1
345
+ elif args[i] == "--wechat":
346
+ do_wechat = True
347
+ i += 1
348
+ else:
349
+ print(f"[!] 未知参数: {args[i]}")
350
+ return False
351
+
352
+ print(f"[*] 发布 {md_path} → Ghost...")
353
+ success, result = publish_md_to_ghost(
354
+ md_path, config,
355
+ title=title, slug=slug, tags=tags,
356
+ cover_image=cover_image,
357
+ author_slug=author, status=status,
358
+ )
359
+
360
+ if success:
361
+ print(f"[+] ✅ 发布成功: {result}")
362
+ if do_wechat:
363
+ print("[*] 开始同步到微信...")
364
+ post_slug = result.rstrip("/").split("/")[-1]
365
+ try:
366
+ posts_data = ghost_api_get(
367
+ f"/ghost/api/admin/posts/?filter=slug:{post_slug}",
368
+ config,
369
+ )
370
+ for p in posts_data.get("posts", []):
371
+ if p.get("slug") == post_slug:
372
+ return sync_article(p["id"])
373
+ except Exception as e:
374
+ print(f"[!] 微信同步失败: {e}")
375
+ return False
376
+ return True
377
+ else:
378
+ print(f"[!] ❌ 发布失败: {result}")
379
+ return False
380
+
381
+
382
+ # ── Config ───────────────────────────────────────────────────
383
+
384
+ def _cmd_config(args):
385
+ """Handle `ghostwriter config` subcommand."""
386
+ if not args or args[0] in ("show",):
387
+ show_config()
388
+ elif args[0] == "set" and len(args) >= 3:
389
+ set_config_value(args[1], " ".join(args[2:]))
390
+ elif args[0] == "path":
391
+ print(config_path())
392
+ elif args[0] in ("--help", "-h", "help"):
393
+ print("""config 用法:
394
+ ghostwriter config 显示当前配置(密钥已脱敏)
395
+ ghostwriter config set <key> <value>
396
+ 设置单个配置项
397
+ ghostwriter config path 显示配置文件路径
398
+
399
+ 有效的 <key>:
400
+ ghost.api_url Ghost 博客地址
401
+ ghost.admin_key_id Ghost Admin API Key ID
402
+ ghost.admin_key Ghost Admin API Key Secret
403
+ wechat.appid 微信公众号 AppID
404
+ wechat.secret 微信公众号 AppSecret
405
+ authors.<slug> (可选) 作者 slug → Ghost 作者 ID 映射
406
+ """)
407
+ else:
408
+ print(f"[!] 未知的 config 子命令: {' '.join(args)}")
409
+ print("[!] 使用 'ghostwriter config --help' 查看用法")
410
+
411
+
412
+ # ── Main ─────────────────────────────────────────────────────
413
+
414
+ def main(args=None):
415
+ """Main CLI entry point.
416
+
417
+ Args:
418
+ args: List of command-line arguments (default: sys.argv[1:]).
419
+ """
420
+ if args is None:
421
+ args = sys.argv[1:]
422
+
423
+ if not args or args[0] in ("--help", "-h", "help"):
424
+ print("""用法:
425
+ ghostwriter list [--limit <n>|all] - 列出 Ghost 文章
426
+ ghostwriter sync <article-id> - 同步 Ghost 文章到微信草稿
427
+ ghostwriter sync --preview <id> - 预览微信 HTML(不创建草稿)
428
+ ghostwriter publish <file.md> - 发布 Markdown 到 Ghost 博客
429
+ ghostwriter config - 查看/设置配置
430
+
431
+ publish 选项:
432
+ --title "标题" - 指定标题(默认取文件第一个 # 标题)
433
+ --slug "my-slug" - 指定 slug(默认从标题自动生成)
434
+ --cover "image.jpg" - 封面图片路径(本地文件,自动上传)
435
+ --tags tag1,tag2 - 标签
436
+ --author slug - 作者 slug(默认 xiaohei)
437
+ --draft - 保存为草稿(默认直接发布)
438
+ --wechat - 发布后同步到微信
439
+
440
+ 示例:
441
+ ghostwriter publish article.md
442
+ ghostwriter publish article.md --title "我的文章" --slug my-article --cover cover.png --tags Ghost,开源 --draft
443
+ ghostwriter publish article.md --wechat
444
+ ghostwriter sync 123abc
445
+ ghostwriter sync --preview 123abc
446
+ """)
447
+ sys.exit(1)
448
+
449
+ if args[0] == "list":
450
+ limit = 20
451
+ if len(args) > 1 and args[1] == "--limit" and len(args) > 2:
452
+ limit = args[2]
453
+ list_posts(limit=limit)
454
+ elif args[0] == "config":
455
+ _cmd_config(args[1:])
456
+ elif args[0] == "publish":
457
+ if len(args) < 2:
458
+ print("[!] 请指定 Markdown 文件路径")
459
+ sys.exit(1)
460
+ try:
461
+ _cmd_publish(args[1:])
462
+ except Exception as e:
463
+ print(f"[!] 错误: {e}")
464
+ import traceback
465
+ traceback.print_exc()
466
+ sys.exit(1)
467
+ elif args[0] == "sync":
468
+ preview_only = False
469
+ article_id = None
470
+ i = 1
471
+ while i < len(args):
472
+ if args[i] == "--preview" and i + 1 < len(args):
473
+ preview_only = True
474
+ article_id = args[i + 1]
475
+ i += 2
476
+ elif not args[i].startswith("--"):
477
+ article_id = args[i]
478
+ i += 1
479
+ else:
480
+ print(f"[!] 未知参数: {args[i]}")
481
+ sys.exit(1)
482
+ if not article_id:
483
+ print("[!] 请指定文章 ID")
484
+ print("[!] 用法: ghostwriter sync [--preview] <article-id>")
485
+ sys.exit(1)
486
+ try:
487
+ sync_article(article_id, preview_only=preview_only)
488
+ except Exception as e:
489
+ print(f"[!] 错误: {e}")
490
+ import traceback
491
+ traceback.print_exc()
492
+ sys.exit(1)
493
+ else:
494
+ print(f"[!] 未知命令: {args[0]}")
495
+ print("[!] 使用 --help 查看可用命令")
496
+ sys.exit(1)
497
+
498
+
499
+ if __name__ == "__main__":
500
+ main()