@ai-welopc/opc-content-factory 0.1.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.
@@ -0,0 +1,92 @@
1
+ # 公众号提取与草稿箱接口
2
+
3
+ ## 公众号链接提取逻辑
4
+
5
+ 收到 `mp.weixin.qq.com/s/...` 或 `mp.weixin.qq.com/s?...` 链接时,先自动提取,不要默认要求用户补截图。
6
+
7
+ ```powershell
8
+ python "D:\Agent\codex\skills\opc-content-factory\scripts\extract_mp_article.py" `
9
+ "https://mp.weixin.qq.com/s/..." `
10
+ --out "D:\path\article\source"
11
+ ```
12
+
13
+ 脚本输出:
14
+
15
+ ```text
16
+ source/raw.html 原始页面
17
+ source/article.html #js_content 正文 HTML
18
+ source/article.txt 正文纯文本
19
+ source/images.json 正文图片 URL 列表
20
+ source/metadata.json 标题、摘要、作者、封面、字数、图片数
21
+ ```
22
+
23
+ 解析字段:
24
+
25
+ - `#js_content`:正文主体。
26
+ - `msg_title`:文章标题。
27
+ - `msg_desc`:分享摘要。
28
+ - `msg_cdn_url`:封面图。
29
+ - `nickname`:账号名。
30
+ - `ct`:发布时间戳。
31
+ - `img[data-src]` / `img[src]`:正文图片。
32
+
33
+ 如果脚本失败,不要直接说提取不了。必须保留:
34
+
35
+ - HTTP 状态码。
36
+ - 错误 JSON。
37
+ - `source/raw.html` 或 `source/extract_error.json`。
38
+ - 下一步建议:浏览器态提取、Cookie、截图 OCR 或用户补图。
39
+
40
+ ## 草稿箱一键导入逻辑
41
+
42
+ “一键导入”实际分两步:先 dry-run 生成请求预览,再在用户填好密钥并确认后执行。脚本只创建草稿,不发布、不群发。
43
+
44
+ 需要环境变量或 env 文件:
45
+
46
+ ```text
47
+ WECHAT_APPID=
48
+ WECHAT_SECRET=
49
+ WECHAT_AUTHOR=
50
+ ```
51
+
52
+ 推荐把 `assets/scene-package/wechat_draft.env.example` 复制为文章目录下的 `draft/wechat_draft.env`,用户只填值。不要把 `WECHAT_SECRET` 写进文章、日志或最终回复。
53
+
54
+ dry-run:
55
+
56
+ ```powershell
57
+ python "D:\Agent\codex\skills\opc-content-factory\scripts\push_wechat_draft.py" `
58
+ --title "文章标题" `
59
+ --digest "分享摘要" `
60
+ --html-file "D:\path\article\wechat_draft.html" `
61
+ --cover-image "D:\path\article\images\cover.png" `
62
+ --env-file "D:\path\article\draft\wechat_draft.env" `
63
+ --out "D:\path\article\draft\wechat_draft_payload.json"
64
+ ```
65
+
66
+ 真实创建草稿:
67
+
68
+ ```powershell
69
+ python "D:\Agent\codex\skills\opc-content-factory\scripts\push_wechat_draft.py" `
70
+ --title "文章标题" `
71
+ --digest "分享摘要" `
72
+ --html-file "D:\path\article\wechat_draft.html" `
73
+ --cover-image "D:\path\article\images\cover.png" `
74
+ --env-file "D:\path\article\draft\wechat_draft.env" `
75
+ --out "D:\path\article\draft\wechat_draft_result.json" `
76
+ --execute
77
+ ```
78
+
79
+ 接口步骤:
80
+
81
+ 1. 用 `WECHAT_APPID` 和 `WECHAT_SECRET` 获取 `access_token`。
82
+ 2. 上传正文里的本地图片到图文图片接口,替换为返回的图片 URL。
83
+ 3. 上传封面图为永久 `thumb` 素材,得到 `thumb_media_id`。
84
+ 4. 调用草稿箱新增接口创建草稿,返回草稿 `media_id`。
85
+
86
+ 常见错误:
87
+
88
+ - `40013`:appid 无效。
89
+ - `40001`:secret 或 token 无效。
90
+ - `40164` / `40165`:IP 白名单问题。
91
+ - `40007`:封面 `thumb_media_id` 无效。
92
+ - `48001`:账号没有接口权限。
@@ -0,0 +1,102 @@
1
+ # 交付规范
2
+
3
+ ## 标准目录
4
+
5
+ 每篇文章创建一个独立文件夹:
6
+
7
+ ```text
8
+ articles/<slug>/
9
+ ├── source/
10
+ │ ├── raw.html
11
+ │ ├── article.html
12
+ │ ├── article.txt
13
+ │ ├── images.json
14
+ │ └── metadata.json
15
+ ├── article_preview.html
16
+ ├── wechat_draft.html
17
+ ├── article.md # 可选,只作辅助源文件
18
+ ├── images/
19
+ ├── media/
20
+ ├── prompts/
21
+ │ └── article_image_prompts.md
22
+ ├── draft/
23
+ │ ├── wechat_draft.env # 用户自行填入,不提交密钥
24
+ │ ├── wechat_draft.env.example
25
+ │ ├── wechat_draft_payload.json
26
+ │ └── wechat_draft_result.json
27
+ └── review-screenshot.png # 可选浏览器截图
28
+ ```
29
+
30
+ ## HTML 主交付
31
+
32
+ HTML 始终是主交付,不以用户是否明确说明为条件。每次输出至少准备:
33
+
34
+ - `article_preview.html`:本地审稿版,用来让用户直接看图文、音频、视频、折叠提示词和脚本。
35
+ - `wechat_draft.html`:公众号草稿箱版,用来进入接口导入链路。
36
+
37
+ `article_preview.html` 可以使用本地相对路径、页面级 CSS、`<audio>` / `<video>` 控件和 `<details>` 折叠区。正文排版按移动端阅读优化,图文要能直接检查效果。
38
+
39
+ `wechat_draft.html` 要按公众号编辑器友好的结构输出:
40
+
41
+ - 使用简单 HTML 标签和行内样式。
42
+ - 不依赖外部 JS、外部 CSS、字体 CDN 或运行时脚本。
43
+ - 图片使用 `<img>`,本地 `src` 在调用草稿箱脚本时由图文图片上传接口替换为返回 URL。
44
+ - 视频、音频样例可在本地审稿页展示;草稿箱正文里优先放封面图、说明、外链或占位说明,避免把本地媒体控件当成最终发布形态。
45
+ - 长提示词可做折叠区或“提示词模板”章节,但不要把密钥、Cookie、token 写进正文。
46
+
47
+ ## 公众号草稿箱接口
48
+
49
+ 导入公众号草稿箱前先跑 dry-run:
50
+
51
+ ```powershell
52
+ python "D:\Agent\codex\skills\opc-content-factory\scripts\push_wechat_draft.py" `
53
+ --title "文章标题" `
54
+ --digest "分享摘要" `
55
+ --html-file "D:\path\article\wechat_draft.html" `
56
+ --cover-image "D:\path\article\images\cover.png" `
57
+ --env-file "D:\path\article\draft\wechat_draft.env" `
58
+ --out "D:\path\article\draft\wechat_draft_payload.json"
59
+ ```
60
+
61
+ 用户填好 `WECHAT_APPID`、`WECHAT_SECRET` 后,且明确要创建草稿时,再执行:
62
+
63
+ ```powershell
64
+ python "D:\Agent\codex\skills\opc-content-factory\scripts\push_wechat_draft.py" `
65
+ --title "文章标题" `
66
+ --digest "分享摘要" `
67
+ --html-file "D:\path\article\wechat_draft.html" `
68
+ --cover-image "D:\path\article\images\cover.png" `
69
+ --env-file "D:\path\article\draft\wechat_draft.env" `
70
+ --out "D:\path\article\draft\wechat_draft_result.json" `
71
+ --execute
72
+ ```
73
+
74
+ 接口边界只到“草稿箱”:不自动群发、不发布、不定时发布。
75
+
76
+ ## QA 清单
77
+
78
+ 最终回复前检查:
79
+
80
+ - `article_preview.html` 和 `wechat_draft.html` 都存在。
81
+ - 所有本地 `src` / `href` 路径都存在。
82
+ - `wechat_draft.html` 没有外链脚本、外链 CSS、泄露密钥或平台 Cookie。
83
+ - 需要导入草稿箱时,先运行 dry-run 并检查 `draft/wechat_draft_payload.json`。
84
+ - 生成图片已复制到文章目录,不只留在默认生图输出目录。
85
+ - 包含视频或音频时,用 `ffprobe` 检查媒体文件。
86
+ - 搜索用户要求避免的品牌残留、平台名、水印文字。
87
+ - 可用 Browser / Playwright 时,通过本地服务打开页面并截图检查。
88
+ - 最终回复提供审稿 URL 和关键文件路径。
89
+
90
+ ## 标题模式
91
+
92
+ 标题要有冲突感和明确收益:
93
+
94
+ - `别再赌一键成片:一张商品图,怎么拆成一条女装带货 AI 流水线`
95
+ - `女装带货视频为什么越做越慢?问题不在模型,在你的流程`
96
+ - `从商品图到试穿视频:一个人怎么搭一套可复用 OPC 内容工厂`
97
+
98
+ 避免空泛标题:
99
+
100
+ - `AI 赋能女装带货`
101
+ - `女装视频生成全流程`
102
+ - `如何使用 AI 做电商内容`
@@ -0,0 +1,89 @@
1
+ # 公众号格式与内容方法框架
2
+
3
+ ## 核心目标
4
+
5
+ 这个 reference 用来沉淀“公众号文章怎么写、怎么排、怎么复盘”,不是沉淀某个历史 session 的具体内容。历史会话只作为写作方法的来源,不在公开文章或交付物里暴露会话 ID、内部截图、Cookie、token、密钥或后台原始数据。
6
+
7
+ ## 公众号文章格式
8
+
9
+ 文章默认按移动端公众号阅读体验设计:
10
+
11
+ - 首屏要让读者立刻知道“这篇和我有什么关系”,不要先堆工具名、模型名、参数。
12
+ - 标题要有明确收益或冲突,避免“AI 赋能”“全流程教程”这类泛标题。
13
+ - 开头 3 段完成:痛点、判断、读者收益。
14
+ - 正文用短段落,单段只讲一个判断。
15
+ - 每个大章节都要有一句可转发的观点句。
16
+ - 数据、流程、提示词、避坑经验要做成独立模块,方便读者截图或收藏。
17
+ - 结尾不是口号,而是把一次案例收束成可复用模板、Skill、工作流或场景包。
18
+
19
+ ## 推荐文章结构
20
+
21
+ 默认使用这条结构链:
22
+
23
+ ```text
24
+ 标题钩子 -> 首屏痛点 -> 核心判断 -> 案例过程 -> 失败点 -> 解决方案 -> 提示词/脚本公开 -> 方法论升维 -> 读者可复用清单 -> 行动引导
25
+ ```
26
+
27
+ 如果是账号定位/品牌方法论文章,可以用:
28
+
29
+ ```text
30
+ 概念解释 -> 读者点名 -> 当前问题 -> 新判断 -> 真实案例 -> 方法论升维 -> 社区/产品定位 -> 行动召唤
31
+ ```
32
+
33
+ 如果是工具/工作流实战文章,可以用:
34
+
35
+ ```text
36
+ 结果先行 -> 原始素材 -> 拆解逻辑 -> 关键提示词 -> 生成过程 -> 翻车点 -> 修正策略 -> 成本/时间 -> 可复用模板
37
+ ```
38
+
39
+ ## 内容判断
40
+
41
+ OPC 公众号内容不要写成单纯工具教程,更适合这个公式:
42
+
43
+ ```text
44
+ 一个具体人群的痛点 -> 当前做法为什么累 -> AI/Agent 怎么进入业务链路 -> 哪些地方必须人工判断 -> 最后沉淀成 Skill / 工作流 / 场景包
45
+ ```
46
+
47
+ 有效文章通常具备:
48
+
49
+ - 人群清楚:一人电商、内容运营、独立开发者、传统企业流程负责人、外贸业务员等。
50
+ - 痛点具体:素材混乱、流程断裂、提示词不可复用、生成结果不稳定、需要人工守任务。
51
+ - 业务链路真实:不要只讲模型能力,要写资料、表格、商品图、客户消息、报价、选品、审核、发布这些真实环节。
52
+ - 反差句强:不是“多一个工具”,而是“少守一个流程”;不是“一键生成”,而是“把失败也沉淀进流程”。
53
+ - 结尾有资产:读者看完能拿走模板、提示词结构、检查清单或一套可照抄的流程。
54
+
55
+ ## 图文模块
56
+
57
+ 每篇文章建议准备 4-5 类图:
58
+
59
+ - 封面图:表达文章冲突和结果,不要只做抽象科技感。
60
+ - 流程图:展示从素材到成品的完整链路。
61
+ - 证据图:截图、分镜、对比、参数、结果局部。
62
+ - 提示词模板卡:把长提示词拆成结构,方便复用。
63
+ - 避坑清单图:把失败点和解决方式列出来。
64
+
65
+ 图片说明要写“这张图在文章里证明什么”,不要只写“效果图”。
66
+
67
+ ## 数据复盘
68
+
69
+ 如果用户提供公众号后台数据、截图或导出表,优先拆这些指标:
70
+
71
+ - 阅读、平均停留、完读率、阅读后关注。
72
+ - 点赞、在看、收藏、评论。
73
+ - 分享人数、分享产生阅读、分享率。
74
+ - 推荐、聊天会话、朋友圈、公众号主页、搜一搜、公众号消息等来源占比。
75
+ - 发布时间后的二次峰值和长尾推荐。
76
+
77
+ 复盘时不要只报数,要给出判断:
78
+
79
+ - 老粉推送型,还是陌生人推荐型。
80
+ - 读者是收藏学习,还是转发给具体人。
81
+ - 标题、首屏、案例、结尾哪个环节贡献最大。
82
+ - 后续该复制结构,还是换选题角度。
83
+
84
+ ## 写作口径
85
+
86
+ - 写得像业务复盘,不像工具说明书。
87
+ - 公开提示词和流程,但不公开密钥、私有 Cookie、后台敏感数据。
88
+ - 可以写失败和翻车,因为这会提高可信度。
89
+ - 不要把“模型很强”当结论,要写“这套流程下次怎么复用”。
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env python
2
+ import argparse
3
+ import json
4
+ import re
5
+ import shutil
6
+ import sys
7
+ from pathlib import Path
8
+
9
+
10
+ def slugify(value: str) -> str:
11
+ value = value.strip().lower()
12
+ value = re.sub(r"[^a-z0-9\u4e00-\u9fff]+", "-", value)
13
+ value = re.sub(r"-+", "-", value).strip("-")
14
+ return value or "opc-article"
15
+
16
+
17
+ def main() -> int:
18
+ if hasattr(sys.stdout, "reconfigure"):
19
+ sys.stdout.reconfigure(encoding="utf-8")
20
+ parser = argparse.ArgumentParser(description="创建 OPC 文章工作区。")
21
+ parser.add_argument("title", help="文章标题或工作名。")
22
+ parser.add_argument("--root", default="articles", help="输出根目录。")
23
+ parser.add_argument("--slug", default="", help="可选的文章目录 slug。")
24
+ args = parser.parse_args()
25
+
26
+ slug = slugify(args.slug or args.title)
27
+ root = Path(args.root).resolve()
28
+ out = root / slug
29
+ for subdir in ["source", "images", "media", "prompts", "draft"]:
30
+ (out / subdir).mkdir(parents=True, exist_ok=True)
31
+
32
+ notes = out / "article_notes.md"
33
+ if not notes.exists():
34
+ notes.write_text(
35
+ f"# {args.title}\n\n"
36
+ "## 起点素材\n\n"
37
+ "- 链接 / 截图:\n"
38
+ "- 用户目标:\n\n"
39
+ "## 拆解记录\n\n"
40
+ "- 标题钩子:\n"
41
+ "- 图片角色:\n"
42
+ "- 可复用工作流:\n\n"
43
+ "## 输出计划\n\n"
44
+ "- HTML 审稿页:\n"
45
+ "- 配图:\n"
46
+ "- 媒体文件:\n"
47
+ "- 公众号草稿箱:\n",
48
+ encoding="utf-8",
49
+ )
50
+
51
+ prompts = out / "prompts" / "article_image_prompts.md"
52
+ if not prompts.exists():
53
+ prompts.write_text(
54
+ "# 文章配图提示词\n\n"
55
+ "## 封面图\n\n"
56
+ "插入位置:\n\n"
57
+ "提示词:\n\n"
58
+ "## 流程图\n\n"
59
+ "插入位置:\n\n"
60
+ "提示词:\n",
61
+ encoding="utf-8",
62
+ )
63
+
64
+ skill_root = Path(__file__).resolve().parents[1]
65
+ scene_assets = skill_root / "assets" / "scene-package"
66
+ if scene_assets.exists():
67
+ for name in [
68
+ "package_manifest.json",
69
+ "wechat_draft.env.example",
70
+ "article_preview_template.html",
71
+ "wechat_draft_template.html",
72
+ ]:
73
+ src = scene_assets / name
74
+ dst = out / "draft" / name if name.endswith(".env.example") else out / name
75
+ if src.exists() and not dst.exists():
76
+ shutil.copy2(src, dst)
77
+ env_example = out / "draft" / "wechat_draft.env.example"
78
+ env_file = out / "draft" / "wechat_draft.env"
79
+ if env_example.exists() and not env_file.exists():
80
+ shutil.copy2(env_example, env_file)
81
+
82
+ manifest = out / "asset_manifest.json"
83
+ manifest.write_text(
84
+ json.dumps(
85
+ {
86
+ "title": args.title,
87
+ "slug": slug,
88
+ "paths": {
89
+ "root": str(out),
90
+ "source": str(out / "source"),
91
+ "images": str(out / "images"),
92
+ "media": str(out / "media"),
93
+ "prompts": str(out / "prompts"),
94
+ "draft": str(out / "draft"),
95
+ "notes": str(notes),
96
+ "wechat_draft_env": str(out / "draft" / "wechat_draft.env"),
97
+ },
98
+ },
99
+ ensure_ascii=False,
100
+ indent=2,
101
+ ),
102
+ encoding="utf-8",
103
+ )
104
+
105
+ print(out)
106
+ return 0
107
+
108
+
109
+ if __name__ == "__main__":
110
+ raise SystemExit(main())
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env python
2
+ import argparse
3
+ import html
4
+ import json
5
+ import re
6
+ import sys
7
+ import time
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ from html.parser import HTMLParser
12
+ from pathlib import Path
13
+
14
+
15
+ DIRECT_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
16
+
17
+
18
+ class ContentParser(HTMLParser):
19
+ def __init__(self):
20
+ super().__init__(convert_charrefs=False)
21
+ self.in_js_content = False
22
+ self.depth = 0
23
+ self.html_parts = []
24
+ self.text_parts = []
25
+ self.images = []
26
+
27
+ def handle_starttag(self, tag, attrs):
28
+ attrs_dict = dict(attrs)
29
+ if tag == "div" and attrs_dict.get("id") == "js_content":
30
+ self.in_js_content = True
31
+ self.depth = 1
32
+ elif self.in_js_content:
33
+ self.depth += 1
34
+
35
+ if self.in_js_content:
36
+ if tag == "img":
37
+ src = attrs_dict.get("data-src") or attrs_dict.get("src") or ""
38
+ if src:
39
+ self.images.append({
40
+ "src": html.unescape(src),
41
+ "alt": attrs_dict.get("alt", ""),
42
+ "class": attrs_dict.get("class", ""),
43
+ })
44
+ attr_text = "".join(
45
+ f' {name}="{html.escape(value, quote=True)}"' for name, value in attrs if value is not None
46
+ )
47
+ self.html_parts.append(f"<{tag}{attr_text}>")
48
+
49
+ def handle_endtag(self, tag):
50
+ if self.in_js_content:
51
+ self.html_parts.append(f"</{tag}>")
52
+ self.depth -= 1
53
+ if self.depth <= 0:
54
+ self.in_js_content = False
55
+
56
+ def handle_data(self, data):
57
+ if self.in_js_content:
58
+ self.html_parts.append(html.escape(data))
59
+ text = data.strip()
60
+ if text:
61
+ self.text_parts.append(text)
62
+
63
+ def handle_entityref(self, name):
64
+ if self.in_js_content:
65
+ value = f"&{name};"
66
+ self.html_parts.append(value)
67
+ self.text_parts.append(html.unescape(value))
68
+
69
+ def handle_charref(self, name):
70
+ if self.in_js_content:
71
+ value = f"&#{name};"
72
+ self.html_parts.append(value)
73
+ self.text_parts.append(html.unescape(value))
74
+
75
+
76
+ def fetch(url: str) -> tuple[int, str]:
77
+ request = urllib.request.Request(
78
+ url,
79
+ headers={
80
+ "User-Agent": (
81
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
82
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36"
83
+ ),
84
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
85
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.7",
86
+ },
87
+ )
88
+ try:
89
+ with DIRECT_OPENER.open(request, timeout=45) as response:
90
+ raw = response.read()
91
+ charset = response.headers.get_content_charset() or "utf-8"
92
+ return response.status, raw.decode(charset, errors="replace")
93
+ except urllib.error.HTTPError as exc:
94
+ body = exc.read().decode("utf-8", errors="replace")
95
+ return exc.code, body
96
+
97
+
98
+ def js_string(page: str, name: str) -> str:
99
+ patterns = [
100
+ rf'var\s+{re.escape(name)}\s*=\s*"((?:\\.|[^"\\])*)"',
101
+ rf"{re.escape(name)}\s*=\s*'((?:\\.|[^'\\])*)'",
102
+ ]
103
+ for pattern in patterns:
104
+ match = re.search(pattern, page)
105
+ if match:
106
+ raw = match.group(1)
107
+ try:
108
+ value = json.loads(f'"{raw}"')
109
+ except json.JSONDecodeError:
110
+ value = raw
111
+ return html.unescape(value).strip()
112
+ return ""
113
+
114
+
115
+ def tag_text(page: str, pattern: str) -> str:
116
+ match = re.search(pattern, page, flags=re.S | re.I)
117
+ if not match:
118
+ return ""
119
+ value = re.sub(r"<[^>]+>", "", match.group(1))
120
+ return html.unescape(value).strip()
121
+
122
+
123
+ def parse_article(page: str, url: str) -> dict:
124
+ parser = ContentParser()
125
+ parser.feed(page)
126
+ title = js_string(page, "msg_title") or tag_text(page, r'<h1[^>]+id=["\']activity-name["\'][^>]*>(.*?)</h1>')
127
+ digest = js_string(page, "msg_desc")
128
+ cover = js_string(page, "msg_cdn_url")
129
+ nickname = js_string(page, "nickname") or tag_text(page, r'<strong[^>]+id=["\']profileBt["\'][^>]*>(.*?)</strong>')
130
+ biz = re.search(r"__biz=([^&]+)", url)
131
+ ct = re.search(r"[?&]ct=(\d+)", url) or re.search(r"var\s+ct\s*=\s*['\"]?(\d+)", page)
132
+ publish_time = ""
133
+ if ct:
134
+ try:
135
+ publish_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(int(ct.group(1))))
136
+ except ValueError:
137
+ publish_time = ""
138
+
139
+ images = []
140
+ seen = set()
141
+ for item in parser.images:
142
+ src = item["src"]
143
+ if src in seen:
144
+ continue
145
+ seen.add(src)
146
+ images.append(item)
147
+
148
+ return {
149
+ "url": url,
150
+ "title": title,
151
+ "digest": digest,
152
+ "nickname": nickname,
153
+ "biz": urllib.parse.unquote(biz.group(1)) if biz else "",
154
+ "publish_time": publish_time,
155
+ "cover": cover,
156
+ "html": "".join(parser.html_parts).strip(),
157
+ "text": "\n".join(parser.text_parts).strip(),
158
+ "images": images,
159
+ }
160
+
161
+
162
+ def main() -> int:
163
+ if hasattr(sys.stdout, "reconfigure"):
164
+ sys.stdout.reconfigure(encoding="utf-8")
165
+ if hasattr(sys.stderr, "reconfigure"):
166
+ sys.stderr.reconfigure(encoding="utf-8")
167
+ argp = argparse.ArgumentParser(description="提取公众号文章正文、图片和元数据。")
168
+ argp.add_argument("url", help="公众号文章链接。")
169
+ argp.add_argument("--out", required=True, help="输出目录,通常是文章工作区的 source/。")
170
+ args = argp.parse_args()
171
+
172
+ out = Path(args.out)
173
+ out.mkdir(parents=True, exist_ok=True)
174
+ status, page = fetch(args.url)
175
+ (out / "raw.html").write_text(page, encoding="utf-8")
176
+ if status >= 400:
177
+ error = {"ok": False, "status": status, "url": args.url, "snippet": page[:1000]}
178
+ (out / "extract_error.json").write_text(json.dumps(error, ensure_ascii=False, indent=2), encoding="utf-8")
179
+ print(json.dumps(error, ensure_ascii=False), file=sys.stderr)
180
+ return 1
181
+
182
+ article = parse_article(page, args.url)
183
+ if not article["html"] and not article["text"]:
184
+ error = {
185
+ "ok": False,
186
+ "status": status,
187
+ "url": args.url,
188
+ "error": "未解析到 #js_content 正文,请检查页面是否需要浏览器态或 Cookie。",
189
+ "snippet": page[:1000],
190
+ }
191
+ (out / "extract_error.json").write_text(json.dumps(error, ensure_ascii=False, indent=2), encoding="utf-8")
192
+ print(json.dumps(error, ensure_ascii=False), file=sys.stderr)
193
+ return 2
194
+
195
+ (out / "article.html").write_text(article["html"], encoding="utf-8")
196
+ (out / "article.txt").write_text(article["text"], encoding="utf-8")
197
+ (out / "images.json").write_text(json.dumps(article["images"], ensure_ascii=False, indent=2), encoding="utf-8")
198
+ metadata = {k: v for k, v in article.items() if k not in {"html", "text", "images"}}
199
+ metadata["image_count"] = len(article["images"])
200
+ metadata["char_count"] = len(article["text"])
201
+ (out / "metadata.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
202
+ print(json.dumps({"ok": True, **metadata}, ensure_ascii=False, indent=2))
203
+ return 0
204
+
205
+
206
+ if __name__ == "__main__":
207
+ raise SystemExit(main())