md2wx-cli 0.2.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.
- md2wx/__init__.py +3 -0
- md2wx/cli.py +299 -0
- md2wx/clipboard.py +169 -0
- md2wx/config.py +83 -0
- md2wx/containers.py +177 -0
- md2wx/converter.py +206 -0
- md2wx/fetcher.py +211 -0
- md2wx/gallery.py +199 -0
- md2wx/publisher.py +136 -0
- md2wx/styler.py +288 -0
- md2wx/theme.py +117 -0
- md2wx/theme_extractor.py +295 -0
- md2wx/themes/bauhaus.yaml +207 -0
- md2wx/themes/bold-green.yaml +198 -0
- md2wx/themes/bold-navy.yaml +197 -0
- md2wx/themes/default.yaml +217 -0
- md2wx/themes/github-tech.yaml +104 -0
- md2wx/validator.py +166 -0
- md2wx/wechat_api.py +141 -0
- md2wx_cli-0.2.0.dist-info/METADATA +184 -0
- md2wx_cli-0.2.0.dist-info/RECORD +25 -0
- md2wx_cli-0.2.0.dist-info/WHEEL +5 -0
- md2wx_cli-0.2.0.dist-info/entry_points.txt +2 -0
- md2wx_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- md2wx_cli-0.2.0.dist-info/top_level.txt +1 -0
md2wx/__init__.py
ADDED
md2wx/cli.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""md2wx CLI 入口点 —— Markdown to WeChat HTML 排版与生产力套件."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .clipboard import copy_html_to_clipboard
|
|
10
|
+
from .config import get_wechat_credentials
|
|
11
|
+
from .converter import WeChatConverter, make_paste_safe, preview_html
|
|
12
|
+
from .fetcher import fetch_and_convert_article
|
|
13
|
+
from .gallery import launch_gallery
|
|
14
|
+
from .publisher import publish_to_wechat
|
|
15
|
+
from .theme import Theme, list_themes, load_theme
|
|
16
|
+
from .theme_extractor import extract_from_dir, extract_from_file, extract_from_url, save_theme_yaml
|
|
17
|
+
from .validator import print_diagnostic_report, validate_wechat_article
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load_effective_theme(theme_name: str, theme_file: Optional[str]) -> Theme:
|
|
21
|
+
if theme_file:
|
|
22
|
+
import yaml
|
|
23
|
+
|
|
24
|
+
path = Path(theme_file)
|
|
25
|
+
if not path.exists():
|
|
26
|
+
raise FileNotFoundError(f"未找到外部主题文件: {theme_file}")
|
|
27
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8-sig"))
|
|
28
|
+
if not isinstance(data, dict):
|
|
29
|
+
raise ValueError(f"外部主题 YAML 格式无效 (必须为字典映射): {theme_file}")
|
|
30
|
+
theme = Theme(
|
|
31
|
+
name=data.get("name", path.stem),
|
|
32
|
+
description=data.get("description", ""),
|
|
33
|
+
base_css=data.get("base_css", ""),
|
|
34
|
+
colors=data.get("colors", {}) if isinstance(data.get("colors"), dict) else {},
|
|
35
|
+
_raw_data=data,
|
|
36
|
+
)
|
|
37
|
+
return theme
|
|
38
|
+
return load_theme(theme_name)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def cmd_convert(args: argparse.Namespace) -> int:
|
|
42
|
+
in_path = None
|
|
43
|
+
if args.input == "-" or (not args.input and not sys.stdin.isatty()):
|
|
44
|
+
input_text = sys.stdin.read()
|
|
45
|
+
input_filename = "document"
|
|
46
|
+
elif args.input:
|
|
47
|
+
in_path = Path(args.input)
|
|
48
|
+
if not in_path.exists():
|
|
49
|
+
print(f"错误: 输入文件未找到: {args.input}", file=sys.stderr)
|
|
50
|
+
return 1
|
|
51
|
+
input_text = in_path.read_text(encoding="utf-8-sig")
|
|
52
|
+
input_filename = in_path.stem
|
|
53
|
+
else:
|
|
54
|
+
print("错误: 请指定输入的 Markdown 文件或使用子命令 (运行 'md2wx --help' 查看帮助)", file=sys.stderr)
|
|
55
|
+
return 1
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
theme = _load_effective_theme(args.theme, args.theme_file)
|
|
59
|
+
except Exception as e:
|
|
60
|
+
print(f"加载主题失败: {e}", file=sys.stderr)
|
|
61
|
+
return 1
|
|
62
|
+
|
|
63
|
+
converter = WeChatConverter(theme=theme)
|
|
64
|
+
base_dir = in_path.parent if in_path else None
|
|
65
|
+
result = converter.convert(input_text, base_dir=base_dir)
|
|
66
|
+
body_html = result.html
|
|
67
|
+
|
|
68
|
+
if not getattr(args, "no_paste_safe", False):
|
|
69
|
+
body_html = make_paste_safe(body_html)
|
|
70
|
+
|
|
71
|
+
final_output = preview_html(body_html, theme) if getattr(args, "full_page", False) else body_html
|
|
72
|
+
|
|
73
|
+
out_file = args.output
|
|
74
|
+
if out_file:
|
|
75
|
+
out_path = Path(out_file)
|
|
76
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
out_path.write_text(final_output, encoding="utf-8")
|
|
78
|
+
print(f"已生成文件: {out_path}", file=sys.stderr)
|
|
79
|
+
|
|
80
|
+
if getattr(args, "clipboard", False):
|
|
81
|
+
plain = input_text
|
|
82
|
+
ok = copy_html_to_clipboard(body_html, plain)
|
|
83
|
+
if ok:
|
|
84
|
+
print("已成功复制富文本 HTML 到系统剪贴板!可直接去微信公众号后台 Ctrl+V 粘贴。", file=sys.stderr)
|
|
85
|
+
else:
|
|
86
|
+
print("写入剪贴板失败,请使用 -o 保存为文件后手动复制。", file=sys.stderr)
|
|
87
|
+
|
|
88
|
+
if getattr(args, "publish", False):
|
|
89
|
+
creds = get_wechat_credentials(
|
|
90
|
+
cli_appid=getattr(args, "appid", None),
|
|
91
|
+
cli_secret=getattr(args, "secret", None),
|
|
92
|
+
cli_author=getattr(args, "author", None),
|
|
93
|
+
)
|
|
94
|
+
if not creds.appid or not creds.secret:
|
|
95
|
+
print(
|
|
96
|
+
"错误: 发布至微信草稿箱需要提供公众号凭据 (AppID 与 AppSecret)。\n"
|
|
97
|
+
"请通过命令行参数 --appid/--secret,或配置环境变量 WECHAT_APPID 与 WECHAT_SECRET,"
|
|
98
|
+
"或在 ~/.md2wx/config.yaml 中配置。",
|
|
99
|
+
file=sys.stderr,
|
|
100
|
+
)
|
|
101
|
+
return 1
|
|
102
|
+
|
|
103
|
+
title = getattr(args, "title", "") or result.title or input_filename
|
|
104
|
+
digest = getattr(args, "digest", "") or result.digest
|
|
105
|
+
cover = getattr(args, "cover", None)
|
|
106
|
+
|
|
107
|
+
print(f"正在发布到微信公众号草稿箱: 《{title}》...")
|
|
108
|
+
try:
|
|
109
|
+
media_id = publish_to_wechat(
|
|
110
|
+
html=body_html,
|
|
111
|
+
title=title,
|
|
112
|
+
digest=digest,
|
|
113
|
+
images=result.images,
|
|
114
|
+
appid=creds.appid,
|
|
115
|
+
secret=creds.secret,
|
|
116
|
+
cover=cover,
|
|
117
|
+
author=creds.author,
|
|
118
|
+
base_dir=base_dir,
|
|
119
|
+
)
|
|
120
|
+
print(f"[OK] 成功发布至微信公众号草稿箱!media_id: {media_id}")
|
|
121
|
+
print("可登录 https://mp.weixin.qq.com 后台在「草稿箱」中查看并群发。")
|
|
122
|
+
return 0
|
|
123
|
+
except Exception as e:
|
|
124
|
+
print(f"[ERROR] 发布失败: {e}", file=sys.stderr)
|
|
125
|
+
return 1
|
|
126
|
+
|
|
127
|
+
if not out_file and not getattr(args, "clipboard", False) and not getattr(args, "publish", False):
|
|
128
|
+
print(final_output)
|
|
129
|
+
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def cmd_gallery(argv: List[str]) -> int:
|
|
134
|
+
parser = argparse.ArgumentParser(prog="md2wx gallery", description="生成 19 套主题的 Shadcn 交互式画廊并在浏览器中打开")
|
|
135
|
+
parser.add_argument("input", help="输入的 Markdown 文件路径")
|
|
136
|
+
parser.add_argument("-o", "--output", help="输出的画廊 HTML 路径(可选)")
|
|
137
|
+
args = parser.parse_args(argv)
|
|
138
|
+
|
|
139
|
+
p = Path(args.input)
|
|
140
|
+
if not p.exists():
|
|
141
|
+
print(f"错误: 输入文件未找到: {args.input}", file=sys.stderr)
|
|
142
|
+
return 1
|
|
143
|
+
|
|
144
|
+
out_p = Path(args.output) if args.output else None
|
|
145
|
+
target = launch_gallery(p, output_html=out_p)
|
|
146
|
+
print(f"[OK] 已成功生成主题画廊并已在默认浏览器中打开: {target}")
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def cmd_learn_theme(argv: List[str]) -> int:
|
|
151
|
+
parser = argparse.ArgumentParser(
|
|
152
|
+
prog="md2wx learn-theme",
|
|
153
|
+
description="从微信在线文章或本地设计目录 (如 test_html2json) 逆向学习并生成新主题",
|
|
154
|
+
)
|
|
155
|
+
group = parser.add_mutually_exclusive_group(required=True)
|
|
156
|
+
group.add_argument("--url", help="微信公众号文章在线 URL")
|
|
157
|
+
group.add_argument("--dir", help="本地设计快照目录(包含 DESIGN.md 或 HTML 资源)")
|
|
158
|
+
group.add_argument("--file", help="单个本地 HTML 或 DESIGN.md 文件路径")
|
|
159
|
+
parser.add_argument("-n", "--name", required=True, help="要生成的主题名称(如 my-brand)")
|
|
160
|
+
parser.add_argument("-o", "--output", help="保存主题的目标文件路径(默认存入全局 ~/.md2wx/themes/)")
|
|
161
|
+
args = parser.parse_args(argv)
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
if args.url:
|
|
165
|
+
yaml_str = extract_from_url(args.url, theme_name=args.name)
|
|
166
|
+
elif args.dir:
|
|
167
|
+
yaml_str = extract_from_dir(Path(args.dir), theme_name=args.name)
|
|
168
|
+
elif args.file:
|
|
169
|
+
yaml_str = extract_from_file(Path(args.file), theme_name=args.name)
|
|
170
|
+
else:
|
|
171
|
+
print("错误: 请指定 --url, --dir 或 --file", file=sys.stderr)
|
|
172
|
+
return 1
|
|
173
|
+
|
|
174
|
+
out_path = Path(args.output) if args.output else None
|
|
175
|
+
target_path = save_theme_yaml(yaml_str, args.name, target_path=out_path)
|
|
176
|
+
print(f"[OK] 成功提取并生成新主题: {args.name}")
|
|
177
|
+
print(f"[PATH] 已保存至: {target_path}")
|
|
178
|
+
print(f"[TIP] 立即使用: md2wx article.md -t {args.name} -c")
|
|
179
|
+
return 0
|
|
180
|
+
except Exception as e:
|
|
181
|
+
print(f"提取主题失败: {e}", file=sys.stderr)
|
|
182
|
+
return 1
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def cmd_fetch(argv: List[str]) -> int:
|
|
186
|
+
parser = argparse.ArgumentParser(prog="md2wx fetch", description="抓取微信公众号文章并逆向提取为本地 Markdown与图片")
|
|
187
|
+
parser.add_argument("url", help="微信推文 URL")
|
|
188
|
+
parser.add_argument("-o", "--output", help="输出的 Markdown 文件路径(默认按标题生成)")
|
|
189
|
+
args = parser.parse_args(argv)
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
out_p = Path(args.output) if args.output else None
|
|
193
|
+
target_md, _ = fetch_and_convert_article(args.url, output_file=out_p)
|
|
194
|
+
print(f"[OK] 微信推文已成功抓取并转为 Markdown: {target_md}")
|
|
195
|
+
return 0
|
|
196
|
+
except Exception as e:
|
|
197
|
+
print(f"抓取文章失败: {e}", file=sys.stderr)
|
|
198
|
+
return 1
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def cmd_check(argv: List[str]) -> int:
|
|
202
|
+
parser = argparse.ArgumentParser(prog="md2wx check", description="对 Markdown 或 HTML 进行微信排版兼容性与合规体检")
|
|
203
|
+
parser.add_argument("file", help="要检查的 Markdown 或 HTML 文件路径")
|
|
204
|
+
parser.add_argument("-t", "--theme", default="default", help="排版主题名称 (默认: default)")
|
|
205
|
+
args = parser.parse_args(argv)
|
|
206
|
+
|
|
207
|
+
p = Path(args.file)
|
|
208
|
+
if not p.exists():
|
|
209
|
+
print(f"错误: 文件未找到: {args.file}", file=sys.stderr)
|
|
210
|
+
return 1
|
|
211
|
+
|
|
212
|
+
diagnostics = validate_wechat_article(p, theme_name=args.theme)
|
|
213
|
+
print_diagnostic_report(p, diagnostics)
|
|
214
|
+
return 1 if any(d.level == "ERROR" for d in diagnostics) else 0
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def cmd_themes_list(argv: List[str]) -> int:
|
|
218
|
+
names = list_themes()
|
|
219
|
+
print(f"已安装的排版主题列表 (共 {len(names)} 个):")
|
|
220
|
+
for n in names:
|
|
221
|
+
try:
|
|
222
|
+
t = load_theme(n)
|
|
223
|
+
print(f" - {n:<20} {t.description}")
|
|
224
|
+
except Exception:
|
|
225
|
+
print(f" - {n:<20}")
|
|
226
|
+
return 0
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
230
|
+
epilog_text = """常用功能与子命令:
|
|
231
|
+
md2wx article.md -c # 转换并一键复制富文本到剪贴板
|
|
232
|
+
md2wx article.md -t impeccable -p # 原生直传公众号草稿箱(自动换取微信 CDN 图床)
|
|
233
|
+
md2wx gallery article.md # 生成 19 套主题的 Shadcn 交互式对比画廊
|
|
234
|
+
md2wx learn-theme --dir test_html2json -n my # 从本地设计快照提取新主题
|
|
235
|
+
md2wx learn-theme --url <url> -n tech # 从微信在线推文提取新主题
|
|
236
|
+
md2wx fetch <url> -o my-article.md # 抓取微信推文转为 Markdown 与本地图片
|
|
237
|
+
md2wx check article.md # 对文章执行微信排版兼容性诊断体检
|
|
238
|
+
md2wx themes # 查看全部已安装主题
|
|
239
|
+
"""
|
|
240
|
+
parser = argparse.ArgumentParser(
|
|
241
|
+
prog="md2wx",
|
|
242
|
+
description="Markdown to WeChat HTML CLI tool (支持画廊对比、主题提取、公众号草稿直传、推文抓取、排版体检)",
|
|
243
|
+
epilog=epilog_text,
|
|
244
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
245
|
+
)
|
|
246
|
+
parser.add_argument("-v", "-V", "--version", action="version", version=f"md2wx {__version__}", help="显示版本号并退出")
|
|
247
|
+
parser.add_argument("input", nargs="?", help="输入的 Markdown 文件路径(传入 - 或省略时支持管道标准输入)")
|
|
248
|
+
parser.add_argument("-o", "--output", help="输出的 HTML 文件路径")
|
|
249
|
+
parser.add_argument("-t", "--theme", default="default", help="排版主题名称 (默认: default)")
|
|
250
|
+
parser.add_argument("--theme-file", help="外部自定义 YAML 主题文件路径")
|
|
251
|
+
parser.add_argument("-c", "--clipboard", action="store_true", help="转换后直接将富文本 HTML 写入系统剪贴板")
|
|
252
|
+
parser.add_argument("--full-page", action="store_true", help="生成包含完整 <html> 结构的独立预览页面")
|
|
253
|
+
parser.add_argument("-p", "--publish", action="store_true", help="原生直传并发布至微信公众号草稿箱(需 AppID/Secret)")
|
|
254
|
+
parser.add_argument("--appid", help="微信公众号 AppID")
|
|
255
|
+
parser.add_argument("--secret", help="微信公众号 AppSecret")
|
|
256
|
+
parser.add_argument("--cover", help="图文封面图片路径")
|
|
257
|
+
parser.add_argument("--title", help="文章标题(覆盖 Markdown 一级标题)")
|
|
258
|
+
parser.add_argument("--author", help="文章作者名称")
|
|
259
|
+
parser.add_argument("--digest", help="文章摘要简介")
|
|
260
|
+
parser.add_argument("--no-paste-safe", action="store_true", help="关闭 span[leaf] 粘贴防护")
|
|
261
|
+
return parser
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
265
|
+
if argv is None:
|
|
266
|
+
argv = sys.argv[1:]
|
|
267
|
+
|
|
268
|
+
if not argv:
|
|
269
|
+
parser = build_parser()
|
|
270
|
+
parser.print_help()
|
|
271
|
+
return 0
|
|
272
|
+
|
|
273
|
+
cmd = argv[0].lower()
|
|
274
|
+
|
|
275
|
+
if cmd in ("gallery", "preview-all"):
|
|
276
|
+
return cmd_gallery(argv[1:])
|
|
277
|
+
elif cmd in ("learn-theme", "extract-theme"):
|
|
278
|
+
return cmd_learn_theme(argv[1:])
|
|
279
|
+
elif cmd == "fetch":
|
|
280
|
+
return cmd_fetch(argv[1:])
|
|
281
|
+
elif cmd in ("check", "validate", "doctor"):
|
|
282
|
+
return cmd_check(argv[1:])
|
|
283
|
+
elif cmd in ("themes", "list-themes"):
|
|
284
|
+
return cmd_themes_list(argv[1:])
|
|
285
|
+
elif cmd in ("version", "--version", "-v", "-V"):
|
|
286
|
+
print(f"md2wx {__version__}")
|
|
287
|
+
return 0
|
|
288
|
+
elif cmd in ("help", "--help", "-h"):
|
|
289
|
+
parser = build_parser()
|
|
290
|
+
parser.print_help()
|
|
291
|
+
return 0
|
|
292
|
+
|
|
293
|
+
parser = build_parser()
|
|
294
|
+
args = parser.parse_args(argv)
|
|
295
|
+
return cmd_convert(args)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
if __name__ == "__main__":
|
|
299
|
+
sys.exit(main())
|
md2wx/clipboard.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""跨平台剪贴板处理模块 —— 支持富文本 HTML 与纯文本写入。"""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _format_windows_cf_html(html_fragment: str) -> bytes:
|
|
11
|
+
"""构建 Windows 标准的 CF_HTML 数据包格式。"""
|
|
12
|
+
header_template = (
|
|
13
|
+
"Version:0.9\r\n"
|
|
14
|
+
"StartHTML:{start_html:08d}\r\n"
|
|
15
|
+
"EndHTML:{end_html:08d}\r\n"
|
|
16
|
+
"StartFragment:{start_fragment:08d}\r\n"
|
|
17
|
+
"EndFragment:{end_fragment:08d}\r\n"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
prefix = (
|
|
21
|
+
'<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">\r\n'
|
|
22
|
+
'<HTML><HEAD><META http-equiv="content-type" content="text/html; charset=utf-8"></HEAD><BODY>\r\n'
|
|
23
|
+
"<!--StartFragment-->"
|
|
24
|
+
)
|
|
25
|
+
suffix = "<!--EndFragment--></BODY></HTML>"
|
|
26
|
+
|
|
27
|
+
dummy_header = header_template.format(start_html=0, end_html=0, start_fragment=0, end_fragment=0)
|
|
28
|
+
header_len = len(dummy_header.encode("utf-8"))
|
|
29
|
+
prefix_len = len(prefix.encode("utf-8"))
|
|
30
|
+
body_len = len(html_fragment.encode("utf-8"))
|
|
31
|
+
suffix_len = len(suffix.encode("utf-8"))
|
|
32
|
+
|
|
33
|
+
start_html = header_len
|
|
34
|
+
start_fragment = header_len + prefix_len
|
|
35
|
+
end_fragment = start_fragment + body_len
|
|
36
|
+
end_html = end_fragment + suffix_len
|
|
37
|
+
|
|
38
|
+
header = header_template.format(
|
|
39
|
+
start_html=start_html,
|
|
40
|
+
end_html=end_html,
|
|
41
|
+
start_fragment=start_fragment,
|
|
42
|
+
end_fragment=end_fragment,
|
|
43
|
+
)
|
|
44
|
+
return (header + prefix + html_fragment + suffix).encode("utf-8")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _copy_windows(html_str: str, plain_text: str) -> bool:
|
|
48
|
+
"""Windows 原生 Win32 API 写入 CF_HTML 与 CF_UNICODETEXT。"""
|
|
49
|
+
try:
|
|
50
|
+
import ctypes
|
|
51
|
+
from ctypes import wintypes
|
|
52
|
+
|
|
53
|
+
user32 = ctypes.windll.user32
|
|
54
|
+
kernel32 = ctypes.windll.kernel32
|
|
55
|
+
|
|
56
|
+
# 必须显式声明 64 位指针类型,避免 64 位内存地址被截断为 32 位 int
|
|
57
|
+
kernel32.GlobalAlloc.restype = wintypes.HGLOBAL
|
|
58
|
+
kernel32.GlobalAlloc.argtypes = [wintypes.UINT, ctypes.c_size_t]
|
|
59
|
+
|
|
60
|
+
kernel32.GlobalLock.restype = wintypes.LPVOID
|
|
61
|
+
kernel32.GlobalLock.argtypes = [wintypes.HGLOBAL]
|
|
62
|
+
|
|
63
|
+
kernel32.GlobalUnlock.restype = wintypes.BOOL
|
|
64
|
+
kernel32.GlobalUnlock.argtypes = [wintypes.HGLOBAL]
|
|
65
|
+
|
|
66
|
+
user32.RegisterClipboardFormatW.restype = wintypes.UINT
|
|
67
|
+
user32.RegisterClipboardFormatW.argtypes = [wintypes.LPCWSTR]
|
|
68
|
+
|
|
69
|
+
user32.OpenClipboard.restype = wintypes.BOOL
|
|
70
|
+
user32.OpenClipboard.argtypes = [wintypes.HWND]
|
|
71
|
+
|
|
72
|
+
user32.EmptyClipboard.restype = wintypes.BOOL
|
|
73
|
+
user32.EmptyClipboard.argtypes = []
|
|
74
|
+
|
|
75
|
+
user32.SetClipboardData.restype = wintypes.HANDLE
|
|
76
|
+
user32.SetClipboardData.argtypes = [wintypes.UINT, wintypes.HANDLE]
|
|
77
|
+
|
|
78
|
+
user32.CloseClipboard.restype = wintypes.BOOL
|
|
79
|
+
user32.CloseClipboard.argtypes = []
|
|
80
|
+
|
|
81
|
+
GMEM_MOVEABLE = 0x0002
|
|
82
|
+
CF_UNICODETEXT = 13
|
|
83
|
+
|
|
84
|
+
cf_html = user32.RegisterClipboardFormatW("HTML Format")
|
|
85
|
+
if not cf_html:
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
if not user32.OpenClipboard(None):
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
user32.EmptyClipboard()
|
|
93
|
+
|
|
94
|
+
# 1. 写入 CF_HTML
|
|
95
|
+
cf_html_bytes = _format_windows_cf_html(html_str) + b"\x00"
|
|
96
|
+
h_html = kernel32.GlobalAlloc(GMEM_MOVEABLE, len(cf_html_bytes))
|
|
97
|
+
if h_html:
|
|
98
|
+
p_html = kernel32.GlobalLock(h_html)
|
|
99
|
+
if p_html:
|
|
100
|
+
ctypes.memmove(p_html, cf_html_bytes, len(cf_html_bytes))
|
|
101
|
+
kernel32.GlobalUnlock(h_html)
|
|
102
|
+
user32.SetClipboardData(cf_html, h_html)
|
|
103
|
+
|
|
104
|
+
# 2. 写入 CF_UNICODETEXT (备用纯文本)
|
|
105
|
+
text_utf16 = (plain_text + "\x00").encode("utf-16le")
|
|
106
|
+
h_text = kernel32.GlobalAlloc(GMEM_MOVEABLE, len(text_utf16))
|
|
107
|
+
if h_text:
|
|
108
|
+
p_text = kernel32.GlobalLock(h_text)
|
|
109
|
+
if p_text:
|
|
110
|
+
ctypes.memmove(p_text, text_utf16, len(text_utf16))
|
|
111
|
+
kernel32.GlobalUnlock(h_text)
|
|
112
|
+
user32.SetClipboardData(CF_UNICODETEXT, h_text)
|
|
113
|
+
|
|
114
|
+
return True
|
|
115
|
+
finally:
|
|
116
|
+
user32.CloseClipboard()
|
|
117
|
+
except Exception:
|
|
118
|
+
# PowerShell Set-Clipboard 作为纯文本后备方案
|
|
119
|
+
try:
|
|
120
|
+
subprocess.run(
|
|
121
|
+
["powershell", "-NoProfile", "-Command", "Set-Clipboard", "-Value", plain_text],
|
|
122
|
+
check=True,
|
|
123
|
+
capture_output=True,
|
|
124
|
+
)
|
|
125
|
+
return True
|
|
126
|
+
except Exception:
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _copy_macos(html_str: str, plain_text: str) -> bool:
|
|
131
|
+
try:
|
|
132
|
+
script = f'set the clipboard to "{html_str.replace("\"", "\\\"")}"'
|
|
133
|
+
proc = subprocess.run(["osascript", "-e", script], capture_output=True)
|
|
134
|
+
return proc.returncode == 0
|
|
135
|
+
except Exception:
|
|
136
|
+
try:
|
|
137
|
+
proc = subprocess.run(["pbcopy"], input=plain_text.encode("utf-8"), capture_output=True)
|
|
138
|
+
return proc.returncode == 0
|
|
139
|
+
except Exception:
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _copy_linux(html_str: str, plain_text: str) -> bool:
|
|
144
|
+
try:
|
|
145
|
+
proc = subprocess.run(["xclip", "-selection", "clipboard", "-t", "text/html"], input=html_str.encode("utf-8"), capture_output=True)
|
|
146
|
+
if proc.returncode == 0:
|
|
147
|
+
return True
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
try:
|
|
151
|
+
proc = subprocess.run(["wl-copy", "--type", "text/html"], input=html_str.encode("utf-8"), capture_output=True)
|
|
152
|
+
return proc.returncode == 0
|
|
153
|
+
except Exception:
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def copy_html_to_clipboard(html_str: str, plain_text: Optional[str] = None) -> bool:
|
|
158
|
+
"""将 HTML 富文本写入系统剪贴板。"""
|
|
159
|
+
if plain_text is None:
|
|
160
|
+
import re
|
|
161
|
+
plain_text = re.sub(r"<[^>]+>", "", html_str).strip()
|
|
162
|
+
|
|
163
|
+
system = platform.system()
|
|
164
|
+
if system == "Windows":
|
|
165
|
+
return _copy_windows(html_str, plain_text)
|
|
166
|
+
elif system == "Darwin":
|
|
167
|
+
return _copy_macos(html_str, plain_text)
|
|
168
|
+
else:
|
|
169
|
+
return _copy_linux(html_str, plain_text)
|
md2wx/config.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""md2wx 配置与微信凭据管理模块。"""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class WeChatConfig:
|
|
14
|
+
appid: str = ""
|
|
15
|
+
secret: str = ""
|
|
16
|
+
author: str = ""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _get_registry_env(key: str) -> str:
|
|
20
|
+
"""在 Windows 下尝试从用户注册表 HKCU\\Environment 动态读取环境变量。"""
|
|
21
|
+
if platform.system() != "Windows":
|
|
22
|
+
return ""
|
|
23
|
+
try:
|
|
24
|
+
import winreg
|
|
25
|
+
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Environment", 0, winreg.KEY_READ) as k:
|
|
26
|
+
val, _ = winreg.QueryValueEx(k, key)
|
|
27
|
+
return str(val).strip()
|
|
28
|
+
except Exception:
|
|
29
|
+
return ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_config_dir() -> Path:
|
|
33
|
+
p = Path.home() / ".md2wx"
|
|
34
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
return p
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_config() -> dict:
|
|
39
|
+
cfg_file = get_config_dir() / "config.yaml"
|
|
40
|
+
if cfg_file.exists():
|
|
41
|
+
try:
|
|
42
|
+
data = yaml.safe_load(cfg_file.read_text(encoding="utf-8"))
|
|
43
|
+
return data if isinstance(data, dict) else {}
|
|
44
|
+
except Exception:
|
|
45
|
+
return {}
|
|
46
|
+
return {}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_wechat_credentials(
|
|
50
|
+
cli_appid: Optional[str] = None,
|
|
51
|
+
cli_secret: Optional[str] = None,
|
|
52
|
+
cli_author: Optional[str] = None,
|
|
53
|
+
) -> WeChatConfig:
|
|
54
|
+
"""按优先级解析微信公众号凭据:CLI 参数 > 环境变量/注册表 > ~/.md2wx/config.yaml。"""
|
|
55
|
+
cfg = load_config()
|
|
56
|
+
wechat_cfg = cfg.get("wechat", {}) if isinstance(cfg.get("wechat"), dict) else {}
|
|
57
|
+
|
|
58
|
+
appid = (
|
|
59
|
+
cli_appid
|
|
60
|
+
or os.getenv("WECHAT_APPID")
|
|
61
|
+
or os.getenv("WECHAT_APP_ID")
|
|
62
|
+
or _get_registry_env("WECHAT_APPID")
|
|
63
|
+
or _get_registry_env("WECHAT_APP_ID")
|
|
64
|
+
or wechat_cfg.get("appid", "")
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
secret = (
|
|
68
|
+
cli_secret
|
|
69
|
+
or os.getenv("WECHAT_SECRET")
|
|
70
|
+
or os.getenv("WECHAT_APP_SECRET")
|
|
71
|
+
or _get_registry_env("WECHAT_SECRET")
|
|
72
|
+
or _get_registry_env("WECHAT_APP_SECRET")
|
|
73
|
+
or wechat_cfg.get("secret", "")
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
author = (
|
|
77
|
+
cli_author
|
|
78
|
+
or os.getenv("WECHAT_AUTHOR")
|
|
79
|
+
or _get_registry_env("WECHAT_AUTHOR")
|
|
80
|
+
or wechat_cfg.get("author", "")
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
return WeChatConfig(appid=appid.strip(), secret=secret.strip(), author=author.strip())
|