@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.
- package/README.md +8 -4
- package/package.json +1 -1
- package/runtime/skills/distribution/generated/recipes.json +136 -24
- package/runtime/skills/distribution/scripts/bundles.mjs +26 -25
- package/runtime/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +1 -1
- package/skills/compliance/doc-authenticity/SKILL.md +2 -1
- package/skills/compliance/doc-authenticity/agents/openai.yaml +8 -0
- package/skills/content-creator/article-review/LICENSE +3 -0
- package/skills/content-creator/article-review/SKILL.md +65 -0
- package/skills/content-creator/article-review/references/report-format.md +68 -0
- package/skills/content-creator/article-review/references/template-guide.md +79 -0
- package/skills/content-creator/article-review/references/template-wechat-payments.md +125 -0
- package/skills/content-creator/article-to-image/LICENSE +3 -0
- package/skills/content-creator/article-to-image/SKILL.md +90 -0
- package/skills/content-creator/article-to-image/references/formats.md +53 -0
- package/skills/content-creator/wechat-article-writer/LICENSE +3 -0
- package/skills/content-creator/wechat-article-writer/SKILL.md +50 -0
- package/skills/content-creator/wechat-article-writer/references/style-comparison.md +64 -0
- package/skills/content-creator/wechat-article-writer/references/style-howto.md +64 -0
- package/skills/content-creator/wechat-article-writer/references/style-insight.md +63 -0
- package/skills/content-creator/wechat-article-writer/references/style-pitfall.md +60 -0
- package/skills/content-creator/wechat-article-writer/references/style-selector.md +133 -0
- package/skills/content-creator/wechat-article-writer/references/writing-rules.md +150 -0
- package/skills/content-creator/wechat-publisher/LICENSE +3 -0
- package/skills/content-creator/wechat-publisher/SKILL.md +170 -0
- package/skills/content-creator/wechat-publisher/references/api.md +105 -0
- package/skills/content-creator/wechat-publisher/references/layout-rules.md +57 -0
- package/skills/content-creator/wechat-publisher/references/wechat-safe-template.html +86 -0
- package/skills/content-creator/wechat-publisher/scripts/md_to_wechat_html.py +194 -0
- package/skills/content-creator/wechat-publisher/scripts/publish.py +239 -0
- package/skills/content-creator/wechat-search/LICENSE +3 -0
- package/skills/content-creator/wechat-search/SKILL.md +210 -0
- package/skills/content-creator/wechat-search/scripts/start-chrome.sh +83 -0
- package/skills/engineering/engineering-runtime/coherence/workflow.json +13 -13
- 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("&", "&").replace("<", "<").replace(">", ">")
|
|
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("&", "&").replace("<", "<").replace(">", ">")
|
|
140
|
+
|
|
141
|
+
# Images: 
|
|
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()
|