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.
- ghostwriter/__init__.py +28 -0
- ghostwriter/__main__.py +5 -0
- ghostwriter/cleaner.py +130 -0
- ghostwriter/cli.py +500 -0
- ghostwriter/config.py +242 -0
- ghostwriter/ghost.py +126 -0
- ghostwriter/lexical.py +252 -0
- ghostwriter/normalize.py +23 -0
- ghostwriter/pipeline.py +386 -0
- ghostwriter/wechat.py +125 -0
- ghostwriter_cli-0.1.0.dist-info/METADATA +172 -0
- ghostwriter_cli-0.1.0.dist-info/RECORD +15 -0
- ghostwriter_cli-0.1.0.dist-info/WHEEL +4 -0
- ghostwriter_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ghostwriter_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
ghostwriter/__init__.py
ADDED
|
@@ -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
|
+
]
|
ghostwriter/__main__.py
ADDED
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()
|