@1aboveio/skills 0.20.4 → 0.21.0

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.
Files changed (35) hide show
  1. package/README.md +8 -4
  2. package/package.json +1 -1
  3. package/runtime/skills/distribution/generated/recipes.json +136 -24
  4. package/runtime/skills/distribution/scripts/bundles.mjs +26 -25
  5. package/runtime/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +1 -1
  6. package/skills/compliance/doc-authenticity/SKILL.md +2 -1
  7. package/skills/compliance/doc-authenticity/agents/openai.yaml +8 -0
  8. package/skills/content-creator/article-review/LICENSE +3 -0
  9. package/skills/content-creator/article-review/SKILL.md +65 -0
  10. package/skills/content-creator/article-review/references/report-format.md +68 -0
  11. package/skills/content-creator/article-review/references/template-guide.md +79 -0
  12. package/skills/content-creator/article-review/references/template-wechat-payments.md +125 -0
  13. package/skills/content-creator/article-to-image/LICENSE +3 -0
  14. package/skills/content-creator/article-to-image/SKILL.md +90 -0
  15. package/skills/content-creator/article-to-image/references/formats.md +53 -0
  16. package/skills/content-creator/wechat-article-writer/LICENSE +3 -0
  17. package/skills/content-creator/wechat-article-writer/SKILL.md +50 -0
  18. package/skills/content-creator/wechat-article-writer/references/style-comparison.md +64 -0
  19. package/skills/content-creator/wechat-article-writer/references/style-howto.md +64 -0
  20. package/skills/content-creator/wechat-article-writer/references/style-insight.md +63 -0
  21. package/skills/content-creator/wechat-article-writer/references/style-pitfall.md +60 -0
  22. package/skills/content-creator/wechat-article-writer/references/style-selector.md +133 -0
  23. package/skills/content-creator/wechat-article-writer/references/writing-rules.md +150 -0
  24. package/skills/content-creator/wechat-publisher/LICENSE +3 -0
  25. package/skills/content-creator/wechat-publisher/SKILL.md +170 -0
  26. package/skills/content-creator/wechat-publisher/references/api.md +105 -0
  27. package/skills/content-creator/wechat-publisher/references/layout-rules.md +57 -0
  28. package/skills/content-creator/wechat-publisher/references/wechat-safe-template.html +86 -0
  29. package/skills/content-creator/wechat-publisher/scripts/md_to_wechat_html.py +194 -0
  30. package/skills/content-creator/wechat-publisher/scripts/publish.py +239 -0
  31. package/skills/content-creator/wechat-search/LICENSE +3 -0
  32. package/skills/content-creator/wechat-search/SKILL.md +210 -0
  33. package/skills/content-creator/wechat-search/scripts/start-chrome.sh +83 -0
  34. package/skills/engineering/engineering-runtime/coherence/workflow.json +13 -13
  35. package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +1 -1
@@ -0,0 +1,86 @@
1
+ <section style="max-width:720px;margin:0 auto;padding:0;color:#2c2c2c;line-height:1.75;font-size:16px;">
2
+ <!-- Intro: do not repeat the article title here, WeChat draft title is set separately -->
3
+ <div style="margin:0 0 10px;">开头导语第一段,直接讲清楚这篇文章要解决什么问题。</div>
4
+ <div style="margin:0 0 12px;">开头导语第二段,补充使用场景、适合谁读,以及读完后能得到什么。</div>
5
+
6
+ <!-- Screenshot wrapper: use for white UI screenshots -->
7
+ <div style="margin:10px 0;padding:6px;background:#fff;border:1px solid #e5e7eb;border-radius:12px;line-height:0;">
8
+ <img src="https://example.com/hero.png" alt="示意图" style="width:100%;height:auto;border-radius:8px;display:block;">
9
+ </div>
10
+
11
+ <!-- Section -->
12
+ <h2 style="font-size:22px;line-height:1.45;margin:40px 0 12px;padding-left:10px;border-left:4px solid #07c160;color:#111;">开始前先确认</h2>
13
+ <div style="background:#f7faf7;border-radius:10px;padding:12px 14px;margin:0 0 10px;">
14
+ <strong>1.</strong> 前提条件 A<br>
15
+ <strong>2.</strong> 前提条件 B
16
+ </div>
17
+ <div style="margin:0 0 12px;">补一句解释,告诉读者顺序或范围。</div>
18
+
19
+ <!-- Section + subsection spacing -->
20
+ <h2 style="font-size:22px;line-height:1.45;margin:40px 0 12px;padding-left:10px;border-left:4px solid #07c160;color:#111;">安装与配置</h2>
21
+ <h3 style="font-size:18px;margin:24px 0 10px;color:#111;">1)先完成基础环境</h3>
22
+ <div style="margin:0 0 8px;">这里放一行解释性正文。</div>
23
+ <pre style="background:#f6f6f6;border-radius:10px;padding:12px 14px;overflow:auto;font-size:14px;line-height:1.6;margin:0 0 10px;"><code>command --help</code></pre>
24
+ <div style="margin:0 0 12px;">这里放一行解释性补充。</div>
25
+
26
+ <h3 style="font-size:18px;margin:24px 0 10px;color:#111;">2)再完成工具安装</h3>
27
+ <div style="margin:0 0 8px;">这里放安装说明。</div>
28
+
29
+ <!-- Steps: use one block + br, not multiple p/div children inside a card -->
30
+ <h2 style="font-size:22px;line-height:1.45;margin:40px 0 12px;padding-left:10px;border-left:4px solid #07c160;color:#111;">第一次上手</h2>
31
+ <div style="background:#fafafa;border-radius:10px;padding:12px 14px;margin:0 0 10px;">
32
+ <strong>第一步:</strong>做动作 A<br>
33
+ <strong>第二步:</strong>做动作 B<br>
34
+ <strong>第三步:</strong>做动作 C<br>
35
+ <strong>第四步:</strong>完成一个最小任务
36
+ </div>
37
+
38
+ <!-- Side-by-side phone screenshots if proportions match -->
39
+ <div style="display:flex;gap:12px;align-items:flex-start;justify-content:center;margin:10px 0;">
40
+ <div style="flex:0 0 48%;max-width:48%;padding:6px;background:#fff;border:1px solid #e5e7eb;border-radius:12px;line-height:0;box-sizing:border-box;">
41
+ <img src="https://example.com/phone-1.png" alt="手机截图 1" style="width:100%;height:auto;border-radius:8px;display:block;">
42
+ </div>
43
+ <div style="flex:0 0 48%;max-width:48%;padding:6px;background:#fff;border:1px solid #e5e7eb;border-radius:12px;line-height:0;box-sizing:border-box;">
44
+ <img src="https://example.com/phone-2.png" alt="手机截图 2" style="width:100%;height:auto;border-radius:8px;display:block;">
45
+ </div>
46
+ </div>
47
+
48
+ <!-- Bullets: use manual bullets, not ul/li -->
49
+ <div style="margin:0 0 8px;">完成后,你就可以:</div>
50
+ <div style="margin:0 0 10px;line-height:1.8;">
51
+ • 做事情 A<br>
52
+ • 做事情 B<br>
53
+ • 做事情 C
54
+ </div>
55
+
56
+ <h3 style="font-size:18px;margin:24px 0 10px;color:#111;">第一次建议跑这类任务</h3>
57
+ <div style="margin:0 0 10px;line-height:1.8;">
58
+ • 先让它总结项目是做什么的<br>
59
+ • 先让它找最值得看的几个文件<br>
60
+ • 先让它运行测试并回报结果
61
+ </div>
62
+
63
+ <!-- Single centered screenshot -->
64
+ <h2 style="font-size:22px;line-height:1.45;margin:40px 0 12px;padding-left:10px;border-left:4px solid #07c160;color:#111;">在手机上继续跟进</h2>
65
+ <div style="margin:0 0 8px;">当任务已经跑起来后,你可以在手机上继续跟进:</div>
66
+ <div style="margin:0 0 10px;line-height:1.8;">
67
+ • 查看进度<br>
68
+ • 补一句要求<br>
69
+ • 决定下一步
70
+ </div>
71
+ <div style="margin:10px 0;padding:6px;background:#fff;border:1px solid #e5e7eb;border-radius:12px;line-height:0;max-width:432px;margin-left:auto;margin-right:auto;">
72
+ <img src="https://example.com/mobile-task.png" alt="手机端截图" style="width:100%;height:auto;border-radius:8px;display:block;">
73
+ </div>
74
+ <div style="margin:6px 0 10px;text-align:center;color:#666;font-size:14px;">这里放一句简短图注。</div>
75
+
76
+ <!-- Closing card -->
77
+ <h2 style="font-size:22px;line-height:1.45;margin:40px 0 12px;padding-left:10px;border-left:4px solid #07c160;color:#111;">最后提醒</h2>
78
+ <div style="background:#fff8f0;border-radius:10px;padding:12px 14px;margin:0 0 10px;">
79
+ <strong>提醒一:</strong>一句提醒<br>
80
+ <strong>提醒二:</strong>一句提醒<br>
81
+ <strong>提醒三:</strong>一句提醒
82
+ </div>
83
+
84
+ <div style="margin:0 0 8px;"><strong>最后一句总结。</strong></div>
85
+ <div style="margin:0;">最后一句行动引导。</div>
86
+ </section>
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Convert Markdown to WeChat-compatible HTML.
4
+
5
+ WeChat requirements:
6
+ - All styles must be INLINE (no <style> tags, no external CSS)
7
+ - Images must reference WeChat CDN URLs (not local paths)
8
+ - No <html>/<head>/<body> wrapper needed (WeChat wraps it)
9
+ - Default body block is <div>, not <p>, to avoid phantom editable blank lines in WeChat editor
10
+ - Keep output flat and avoid nested block wrappers where possible
11
+
12
+ Usage:
13
+ python3 md_to_wechat_html.py input.md > output.html
14
+ python3 md_to_wechat_html.py input.md --output output.html
15
+ """
16
+
17
+ import argparse
18
+ import re
19
+ import sys
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Inline style constants
24
+ # ---------------------------------------------------------------------------
25
+
26
+ STYLES = {
27
+ "h1": 'font-size:22px;font-weight:bold;color:#1a1a1a;margin:24px 0 12px;line-height:1.4;',
28
+ "h2": 'font-size:18px;font-weight:bold;color:#1a1a1a;margin:20px 0 10px;line-height:1.4;',
29
+ "h3": 'font-size:16px;font-weight:bold;color:#333;margin:16px 0 8px;line-height:1.4;',
30
+ "div": 'font-size:15px;color:#333;line-height:1.8;margin:0 0 12px;',
31
+ "li": 'font-size:15px;color:#333;line-height:1.8;margin:4px 0;',
32
+ "code_inline": (
33
+ 'background:#f5f5f5;border-radius:3px;padding:2px 5px;'
34
+ 'font-family:monospace;font-size:13px;color:#e83e8c;'
35
+ ),
36
+ "code_block": (
37
+ 'background:#f5f5f5;border-radius:6px;padding:14px 16px;margin:12px 0;'
38
+ 'font-family:monospace;font-size:13px;color:#333;'
39
+ 'white-space:pre-wrap;word-break:break-all;overflow-x:auto;display:block;'
40
+ ),
41
+ "hr": 'border:none;border-top:1px solid #e0e0e0;margin:24px 0;',
42
+ "strong": 'font-weight:bold;color:#1a1a1a;',
43
+ "em": 'font-style:italic;',
44
+ "img": 'max-width:100%;height:auto;display:block;margin:12px auto;border-radius:4px;',
45
+ }
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Converter
50
+ # ---------------------------------------------------------------------------
51
+
52
+ def md_to_html(md: str) -> str:
53
+ lines = md.split("\n")
54
+ html_parts = []
55
+ i = 0
56
+
57
+ while i < len(lines):
58
+ line = lines[i]
59
+
60
+ # --- Fenced code block ---
61
+ if line.startswith("```"):
62
+ code_lines = []
63
+ i += 1
64
+ while i < len(lines) and not lines[i].startswith("```"):
65
+ code_lines.append(lines[i])
66
+ i += 1
67
+ code = "\n".join(code_lines)
68
+ code = code.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
69
+ html_parts.append(f'<code style="{STYLES["code_block"]}">{code}</code>')
70
+ i += 1
71
+ continue
72
+
73
+ # --- Headings ---
74
+ m = re.match(r'^(#{1,3})\s+(.*)', line)
75
+ if m:
76
+ level = len(m.group(1))
77
+ text = inline(m.group(2))
78
+ tag = f"h{level}"
79
+ html_parts.append(f'<{tag} style="{STYLES[tag]}">{text}</{tag}>')
80
+ i += 1
81
+ continue
82
+
83
+ # --- Horizontal rule ---
84
+ if re.match(r'^[-*_]{3,}\s*$', line):
85
+ html_parts.append(f'<hr style="{STYLES["hr"]}">')
86
+ i += 1
87
+ continue
88
+
89
+ # --- Blockquote lines become normal body blocks ---
90
+ if line.startswith("> "):
91
+ bq_lines = []
92
+ while i < len(lines) and lines[i].startswith("> "):
93
+ bq_lines.append(lines[i][2:])
94
+ i += 1
95
+ content = inline(" ".join(bq_lines))
96
+ html_parts.append(f'<div style="{STYLES["div"]}">{content}</div>')
97
+ continue
98
+
99
+ # --- Unordered list: flatten into normal body blocks to avoid editor quirks ---
100
+ if re.match(r'^[-*+]\s+', line):
101
+ while i < len(lines) and re.match(r'^[-*+]\s+', lines[i]):
102
+ item = inline(re.sub(r'^[-*+]\s+', '', lines[i]))
103
+ html_parts.append(f'<div style="{STYLES["div"]}">• {item}</div>')
104
+ i += 1
105
+ continue
106
+
107
+ # --- Ordered list: flatten into normal body blocks to avoid editor quirks ---
108
+ if re.match(r'^\d+\.\s+', line):
109
+ n = 1
110
+ while i < len(lines) and re.match(r'^\d+\.\s+', lines[i]):
111
+ item = inline(re.sub(r'^\d+\.\s+', '', lines[i]))
112
+ html_parts.append(f'<div style="{STYLES["div"]}">{n}. {item}</div>')
113
+ i += 1
114
+ n += 1
115
+ continue
116
+
117
+ # --- Blank line ---
118
+ if line.strip() == "":
119
+ i += 1
120
+ continue
121
+
122
+ # --- Paragraph ---
123
+ para_lines = []
124
+ while i < len(lines) and lines[i].strip() != "" and not lines[i].startswith("#") \
125
+ and not lines[i].startswith("```") and not lines[i].startswith("> ") \
126
+ and not re.match(r'^[-*+]\s+', lines[i]) and not re.match(r'^\d+\.\s+', lines[i]) \
127
+ and not re.match(r'^[-*_]{3,}\s*$', lines[i]):
128
+ para_lines.append(lines[i])
129
+ i += 1
130
+ text = inline(" ".join(para_lines))
131
+ html_parts.append(f'<div style="{STYLES["div"]}">{text}</div>')
132
+
133
+ return "".join(html_parts)
134
+
135
+
136
+ def inline(text: str) -> str:
137
+ """Apply inline formatting: bold, italic, code, links, images."""
138
+ # Escape HTML first (preserve already-escaped entities)
139
+ text = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
140
+
141
+ # Images: ![alt](url)
142
+ text = re.sub(
143
+ r'!\[([^\]]*)\]\(([^)]+)\)',
144
+ lambda m: f'<img src="{m.group(2)}" alt="{m.group(1)}" style="{STYLES["img"]}">',
145
+ text
146
+ )
147
+ # Links: [text](url)
148
+ text = re.sub(
149
+ r'\[([^\]]+)\]\(([^)]+)\)',
150
+ lambda m: f'<a href="{m.group(2)}" style="color:#07c160;">{m.group(1)}</a>',
151
+ text
152
+ )
153
+ # Bold: **text** or __text__
154
+ text = re.sub(
155
+ r'\*\*(.+?)\*\*|__(.+?)__',
156
+ lambda m: f'<strong style="{STYLES["strong"]}">{m.group(1) or m.group(2)}</strong>',
157
+ text
158
+ )
159
+ # Italic: *text* or _text_
160
+ text = re.sub(
161
+ r'\*(.+?)\*|_(.+?)_',
162
+ lambda m: f'<em style="{STYLES["em"]}">{m.group(1) or m.group(2)}</em>',
163
+ text
164
+ )
165
+ # Inline code: `code`
166
+ text = re.sub(
167
+ r'`([^`]+)`',
168
+ lambda m: f'<code style="{STYLES["code_inline"]}">{m.group(1)}</code>',
169
+ text
170
+ )
171
+ return text
172
+
173
+
174
+ def main():
175
+ parser = argparse.ArgumentParser()
176
+ parser.add_argument("input", help="Input Markdown file")
177
+ parser.add_argument("--output", "-o", help="Output HTML file (default: stdout)")
178
+ args = parser.parse_args()
179
+
180
+ with open(args.input, "r", encoding="utf-8") as f:
181
+ md = f.read()
182
+
183
+ html = md_to_html(md)
184
+
185
+ if args.output:
186
+ with open(args.output, "w", encoding="utf-8") as f:
187
+ f.write(html)
188
+ print(f"Written to {args.output}", file=sys.stderr)
189
+ else:
190
+ print(html)
191
+
192
+
193
+ if __name__ == "__main__":
194
+ main()
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ WeChat Official Account Publisher
4
+ Publishes an article to a WeChat Official Account draft box (and optionally publishes it live).
5
+
6
+ Usage:
7
+ python3 publish.py \
8
+ --appid wx1234567890 \
9
+ --appsecret <secret> \
10
+ --title "文章标题" \
11
+ --html article.html \
12
+ --thumb-image cover.jpg \
13
+ [--author "作者"] \
14
+ [--digest "摘要(最多120字)"] \
15
+ [--publish] # push draft live immediately (skips manual review)
16
+
17
+ Outputs JSON: { "ok": true, "draft_media_id": "...", "publish_id": "..." }
18
+ """
19
+
20
+ import argparse
21
+ import json
22
+ import os
23
+ import sys
24
+ import time
25
+ import requests
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Token management (cached in /tmp to avoid hitting the 2000/day limit)
30
+ # ---------------------------------------------------------------------------
31
+
32
+ TOKEN_CACHE = "/tmp/wechat_access_token.json"
33
+
34
+
35
+ def get_access_token(appid: str, appsecret: str) -> str:
36
+ """Return a valid access_token, refreshing from cache if still valid.
37
+
38
+ Uses WeChat stable access token API (POST /cgi-bin/stable_token) instead of
39
+ the legacy GET /cgi-bin/token endpoint. This is required for Agentier and
40
+ newer WeChat Official Account integrations.
41
+ """
42
+ if os.path.exists(TOKEN_CACHE):
43
+ with open(TOKEN_CACHE) as f:
44
+ cached = json.load(f)
45
+ if cached.get("appid") == appid and cached.get("expires_at", 0) > time.time() + 60:
46
+ return cached["access_token"]
47
+
48
+ # Use stable token API (POST) instead of legacy GET endpoint
49
+ url = "https://api.weixin.qq.com/cgi-bin/stable_token"
50
+ payload = {
51
+ "grant_type": "client_credential",
52
+ "appid": appid,
53
+ "secret": appsecret,
54
+ "force_refresh": False
55
+ }
56
+ resp = requests.post(
57
+ url,
58
+ json=payload,
59
+ headers={"Content-Type": "application/json"},
60
+ timeout=10
61
+ )
62
+ resp.raise_for_status()
63
+ data = resp.json()
64
+
65
+ if "errcode" in data and data["errcode"] != 0:
66
+ raise RuntimeError(f"get_access_token failed: {data}")
67
+
68
+ token = data["access_token"]
69
+ expires_at = time.time() + data.get("expires_in", 7200) - 300 # 5-min buffer
70
+
71
+ with open(TOKEN_CACHE, "w") as f:
72
+ json.dump({"appid": appid, "access_token": token, "expires_at": expires_at}, f)
73
+
74
+ return token
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Media upload (cover image → permanent thumb material)
79
+ # ---------------------------------------------------------------------------
80
+
81
+ def upload_thumb(token: str, image_path: str) -> str:
82
+ """Upload a cover image as a permanent material and return media_id."""
83
+ url = f"https://api.weixin.qq.com/cgi-bin/material/add_material?access_token={token}&type=thumb"
84
+ with open(image_path, "rb") as f:
85
+ resp = requests.post(url, files={"media": f}, timeout=30)
86
+ resp.raise_for_status()
87
+ data = resp.json()
88
+ if data.get("errcode", 0) != 0:
89
+ raise RuntimeError(f"upload_thumb failed: {data}")
90
+ return data["media_id"]
91
+
92
+
93
+ def upload_article_image(token: str, image_path: str) -> str:
94
+ """Upload an image embedded inside an article and return its CDN URL."""
95
+ url = f"https://api.weixin.qq.com/cgi-bin/media/uploadimg?access_token={token}"
96
+ with open(image_path, "rb") as f:
97
+ resp = requests.post(url, files={"media": f}, timeout=30)
98
+ resp.raise_for_status()
99
+ data = resp.json()
100
+ if "url" not in data:
101
+ raise RuntimeError(f"upload_article_image failed: {data}")
102
+ return data["url"]
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Draft creation
107
+ # ---------------------------------------------------------------------------
108
+
109
+ def add_draft(token: str, title: str, html_content: str, thumb_media_id: str,
110
+ author: str = "", digest: str = "") -> str:
111
+ """Create a draft and return its media_id."""
112
+ url = f"https://api.weixin.qq.com/cgi-bin/draft/add?access_token={token}"
113
+ payload = {
114
+ "articles": [
115
+ {
116
+ "title": title,
117
+ "author": author,
118
+ "digest": digest,
119
+ "content": html_content,
120
+ "thumb_media_id": thumb_media_id,
121
+ "need_open_comment": 0,
122
+ "only_fans_can_comment": 0,
123
+ }
124
+ ]
125
+ }
126
+ # Use ensure_ascii=False so Chinese characters are sent as UTF-8, not \uXXXX escapes.
127
+ # WeChat stores the raw JSON bytes; ensure_ascii=True causes it to show literal \\uXXXX.
128
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
129
+ resp = requests.post(url, data=body, headers={"Content-Type": "application/json; charset=utf-8"}, timeout=15)
130
+ resp.raise_for_status()
131
+ data = resp.json()
132
+ if data.get("errcode", 0) != 0:
133
+ raise RuntimeError(f"add_draft failed: {data}")
134
+ return data["media_id"]
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # Free-publish (optional: push draft live without manual review)
139
+ # ---------------------------------------------------------------------------
140
+
141
+ def submit_publish(token: str, draft_media_id: str) -> str:
142
+ """Submit a draft for publishing and return publish_id."""
143
+ url = f"https://api.weixin.qq.com/cgi-bin/freepublish/submit?access_token={token}"
144
+ body = json.dumps({"media_id": draft_media_id}, ensure_ascii=False).encode("utf-8")
145
+ resp = requests.post(url, data=body, headers={"Content-Type": "application/json; charset=utf-8"}, timeout=15)
146
+ resp.raise_for_status()
147
+ data = resp.json()
148
+ if data.get("errcode", 0) != 0:
149
+ raise RuntimeError(f"submit_publish failed: {data}")
150
+ return str(data.get("publish_id", ""))
151
+
152
+
153
+ def poll_publish_status(token: str, publish_id: str, max_wait: int = 60) -> dict:
154
+ """Poll until publish completes or times out. Returns status dict."""
155
+ url = f"https://api.weixin.qq.com/cgi-bin/freepublish/get?access_token={token}"
156
+ deadline = time.time() + max_wait
157
+ while time.time() < deadline:
158
+ body = json.dumps({"publish_id": publish_id}, ensure_ascii=False).encode("utf-8")
159
+ resp = requests.post(url, data=body, headers={"Content-Type": "application/json; charset=utf-8"}, timeout=10)
160
+ resp.raise_for_status()
161
+ data = resp.json()
162
+ status = data.get("publish_status", -1)
163
+ # 0 = success, 1 = publishing, 2 = failed, 4 = deleted
164
+ if status == 0:
165
+ return {"status": "success", "article_id": data.get("article_id", "")}
166
+ if status in (2, 4):
167
+ return {"status": "failed", "detail": data}
168
+ time.sleep(3)
169
+ return {"status": "timeout"}
170
+
171
+
172
+ # ---------------------------------------------------------------------------
173
+ # Main
174
+ # ---------------------------------------------------------------------------
175
+
176
+ def main():
177
+ parser = argparse.ArgumentParser(description="WeChat Official Account Publisher")
178
+ parser.add_argument("--appid", required=True)
179
+ parser.add_argument("--appsecret", required=True)
180
+ parser.add_argument("--title", required=True)
181
+ parser.add_argument("--html", required=True, help="Path to HTML article body file")
182
+ parser.add_argument("--thumb-image", required=True, help="Path to cover image (JPEG/PNG, ≤64KB for thumb)")
183
+ parser.add_argument("--author", default="")
184
+ parser.add_argument("--digest", default="", help="Article summary (≤120 chars)")
185
+ parser.add_argument("--publish", action="store_true", help="Also submit draft for live publishing")
186
+ args = parser.parse_args()
187
+
188
+ result = {}
189
+
190
+ try:
191
+ # 1. Get token
192
+ token = get_access_token(args.appid, args.appsecret)
193
+
194
+ # 2. Upload cover image
195
+ print(f"Uploading cover image: {args.thumb_image}", file=sys.stderr)
196
+ thumb_media_id = upload_thumb(token, args.thumb_image)
197
+
198
+ # 3. Read HTML content
199
+ with open(args.html, "r", encoding="utf-8") as f:
200
+ html_content = f.read()
201
+
202
+ # 3b. Validate title and digest byte limits (WeChat uses UTF-8 byte count, not char count)
203
+ title_bytes = len(args.title.encode("utf-8"))
204
+ digest_bytes = len(args.digest.encode("utf-8"))
205
+ if title_bytes > 64:
206
+ raise ValueError(f"Title exceeds 64-byte limit ({title_bytes} bytes / ~{title_bytes//3} Chinese chars). Shorten the title.")
207
+ if digest_bytes > 120:
208
+ raise ValueError(f"Digest exceeds 120-byte limit ({digest_bytes} bytes / ~{digest_bytes//3} Chinese chars). Shorten the digest.")
209
+
210
+ # 4. Create draft
211
+ print(f"Creating draft: {args.title}", file=sys.stderr)
212
+ draft_media_id = add_draft(
213
+ token, args.title, html_content, thumb_media_id,
214
+ author=args.author, digest=args.digest
215
+ )
216
+ result["ok"] = True
217
+ result["draft_media_id"] = draft_media_id
218
+ print(f"Draft created: {draft_media_id}", file=sys.stderr)
219
+
220
+ # 5. Optionally publish live
221
+ if args.publish:
222
+ print("Submitting for publishing...", file=sys.stderr)
223
+ publish_id = submit_publish(token, draft_media_id)
224
+ result["publish_id"] = publish_id
225
+ status = poll_publish_status(token, publish_id)
226
+ result["publish_status"] = status
227
+ print(f"Publish status: {status}", file=sys.stderr)
228
+
229
+ except Exception as e:
230
+ result["ok"] = False
231
+ result["error"] = str(e)
232
+ print(json.dumps(result))
233
+ sys.exit(1)
234
+
235
+ print(json.dumps(result))
236
+
237
+
238
+ if __name__ == "__main__":
239
+ main()
@@ -0,0 +1,3 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 1AboveIO