worknex 4.2.1__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.
Files changed (59) hide show
  1. worknex/__init__.py +21 -0
  2. worknex/__main__.py +4 -0
  3. worknex/cli.py +86 -0
  4. worknex/commands/__init__.py +0 -0
  5. worknex/commands/build_playbook.py +198 -0
  6. worknex/commands/content_eval.py +104 -0
  7. worknex/commands/diagnose.py +399 -0
  8. worknex/commands/extract_exemplar.py +400 -0
  9. worknex/commands/fetch_article.py +381 -0
  10. worknex/commands/fetch_hotspots.py +212 -0
  11. worknex/commands/fetch_stats.py +185 -0
  12. worknex/commands/humanness_score.py +581 -0
  13. worknex/commands/learn_edits.py +528 -0
  14. worknex/commands/learn_theme.py +640 -0
  15. worknex/commands/llm_write.py +128 -0
  16. worknex/commands/run_manager.py +105 -0
  17. worknex/commands/search_articles.py +235 -0
  18. worknex/commands/seo_keywords.py +119 -0
  19. worknex/commands/similarity_check.py +66 -0
  20. worknex/commands/source_ledger.py +56 -0
  21. worknex/commands/validate_html.py +130 -0
  22. worknex/data/anti-ai-writing-system.md +6 -0
  23. worknex/history.py +74 -0
  24. worknex/migrate.py +75 -0
  25. worknex/paths.py +71 -0
  26. worknex/runs.py +399 -0
  27. worknex/sources.py +96 -0
  28. worknex/toolkit/__init__.py +0 -0
  29. worknex/toolkit/cli.py +432 -0
  30. worknex/toolkit/config.py +90 -0
  31. worknex/toolkit/converter.py +786 -0
  32. worknex/toolkit/image_gen.py +1025 -0
  33. worknex/toolkit/publisher.py +185 -0
  34. worknex/toolkit/theme.py +208 -0
  35. worknex/toolkit/themes/bauhaus.yaml +207 -0
  36. worknex/toolkit/themes/bold-green.yaml +198 -0
  37. worknex/toolkit/themes/bold-navy.yaml +197 -0
  38. worknex/toolkit/themes/bytedance.yaml +199 -0
  39. worknex/toolkit/themes/elegant-rose.yaml +198 -0
  40. worknex/toolkit/themes/focus-red.yaml +197 -0
  41. worknex/toolkit/themes/github.yaml +198 -0
  42. worknex/toolkit/themes/impeccable.yaml +220 -0
  43. worknex/toolkit/themes/ink.yaml +204 -0
  44. worknex/toolkit/themes/lobster-notes.yaml +216 -0
  45. worknex/toolkit/themes/midnight.yaml +197 -0
  46. worknex/toolkit/themes/minimal-gold.yaml +202 -0
  47. worknex/toolkit/themes/minimal.yaml +195 -0
  48. worknex/toolkit/themes/newspaper.yaml +206 -0
  49. worknex/toolkit/themes/professional-clean.yaml +197 -0
  50. worknex/toolkit/themes/sspai.yaml +198 -0
  51. worknex/toolkit/themes/tech-modern.yaml +205 -0
  52. worknex/toolkit/themes/warm-editorial.yaml +197 -0
  53. worknex/toolkit/wechat_api.py +140 -0
  54. worknex-4.2.1.dist-info/METADATA +383 -0
  55. worknex-4.2.1.dist-info/RECORD +59 -0
  56. worknex-4.2.1.dist-info/WHEEL +5 -0
  57. worknex-4.2.1.dist-info/entry_points.txt +2 -0
  58. worknex-4.2.1.dist-info/licenses/LICENSE +22 -0
  59. worknex-4.2.1.dist-info/top_level.txt +1 -0
worknex/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """WorkNex runtime — 公众号内容管道的确定性工具层(CLI: `worknex`)。"""
2
+
3
+ # 版本单一真源 = 仓库根 VERSION 文件(pyproject 经 tool.setuptools.dynamic 同源读取);
4
+ # 已安装的包读打包时写入的 metadata,源码运行(PYTHONPATH=src)回退读 VERSION
5
+ def _resolve_version() -> str:
6
+ try:
7
+ from pathlib import Path
8
+ source_version = Path(__file__).resolve().parents[2] / "VERSION"
9
+ if source_version.exists():
10
+ return source_version.read_text().strip()
11
+ except Exception:
12
+ pass
13
+ try:
14
+ from importlib.metadata import version
15
+ return version("worknex")
16
+ except Exception:
17
+ pass
18
+ return "0.0.0+unknown"
19
+
20
+
21
+ __version__ = _resolve_version()
worknex/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
worknex/cli.py ADDED
@@ -0,0 +1,86 @@
1
+ """`worknex` CLI 调度器 —— 把子命令分发到 commands/ 与 toolkit/ 的既有 main()。
2
+
3
+ 设计约束:各命令模块保持独立 argparse(历史上是独立脚本),调度器只做
4
+ 「子命令名 → 模块」映射并透传其余参数,不重复定义参数。
5
+ """
6
+
7
+ import importlib
8
+ import sys
9
+
10
+ from . import __version__
11
+ from .paths import home
12
+
13
+ # 子命令 → (模块, 一句话说明)。模块须提供 main(argv=None) 或 main()。
14
+ _COMMANDS = {
15
+ "diagnose": ("worknex.commands.diagnose", "环境 + 配置自检(降级标记 JSON)"),
16
+ "score": ("worknex.commands.humanness_score", "写作质量评分(11 项检测)"),
17
+ "content-eval": ("worknex.commands.content_eval", "汇总编辑判断与初稿修改幅度"),
18
+ "hotspots": ("worknex.commands.fetch_hotspots", "多平台热点抓取"),
19
+ "search-articles": ("worknex.commands.search_articles", "搜狗微信搜索公众号文章"),
20
+ "seo": ("worknex.commands.seo_keywords", "SEO 关键词分析"),
21
+ "stats": ("worknex.commands.fetch_stats", "微信文章数据回填 history"),
22
+ "learn-edits": ("worknex.commands.learn_edits", "学习人工修改(diff → lessons)"),
23
+ "learn-theme": ("worknex.commands.learn_theme", "从公众号文章 URL 学排版主题"),
24
+ "exemplar": ("worknex.commands.extract_exemplar", "范文风格库(导入 / --list)"),
25
+ "fetch-article": ("worknex.commands.fetch_article", "公众号文章 URL → Markdown"),
26
+ "llm-write": ("worknex.commands.llm_write", "混合路由写作(DeepSeek 等出稿)"),
27
+ "similarity": ("worknex.commands.similarity_check", "多平台版本原创度检查"),
28
+ "run": ("worknex.commands.run_manager", "独立文章任务:开始 / 恢复 / 完成"),
29
+ "sources": ("worknex.commands.source_ledger", "记录文章事实来源"),
30
+ "build-playbook": ("worknex.commands.build_playbook", "从历史语料生成 playbook"),
31
+ "image-gen": ("worknex.toolkit.image_gen", "AI 图片生成(多 provider fallback)"),
32
+ "validate": ("worknex.commands.validate_html", "HTML 微信兼容性校验"),
33
+ }
34
+
35
+ # toolkit/cli.py 自带子命令(preview/publish/gallery/themes/image-post/learn-theme),
36
+ # 这些名字直接整体透传给它。
37
+ _TOOLKIT_PASSTHROUGH = {"preview", "publish", "gallery", "themes", "image-post"}
38
+
39
+
40
+ def _usage() -> str:
41
+ lines = [f"worknex {__version__} — 公众号内容管道 CLI(状态目录: {home()})", "", "用法: worknex <命令> [参数…]", "", "命令:"]
42
+ for name, (_, desc) in _COMMANDS.items():
43
+ lines.append(f" {name:<16}{desc}")
44
+ for name in sorted(_TOOLKIT_PASSTHROUGH):
45
+ lines.append(f" {name:<16}排版工具链(toolkit)")
46
+ lines += [
47
+ " home 输出状态目录路径",
48
+ " migrate 迁移旧版仓库内状态到状态目录",
49
+ "",
50
+ "任意命令加 --help 看详细参数。",
51
+ ]
52
+ return "\n".join(lines)
53
+
54
+
55
+ def main() -> None:
56
+ argv = sys.argv[1:]
57
+ if not argv or argv[0] in {"-h", "--help"}:
58
+ print(_usage())
59
+ return
60
+ if argv[0] in {"-V", "--version"}:
61
+ print(__version__)
62
+ return
63
+
64
+ cmd, rest = argv[0], argv[1:]
65
+
66
+ if cmd == "home":
67
+ print(home())
68
+ return
69
+ if cmd == "migrate":
70
+ from .migrate import main as migrate_main
71
+ migrate_main(rest)
72
+ return
73
+ if cmd in _TOOLKIT_PASSTHROUGH:
74
+ from .toolkit import cli as toolkit_cli
75
+ sys.argv = ["worknex", cmd, *rest]
76
+ toolkit_cli.main()
77
+ return
78
+ if cmd in _COMMANDS:
79
+ module_name, _ = _COMMANDS[cmd]
80
+ module = importlib.import_module(module_name)
81
+ sys.argv = [f"worknex {cmd}", *rest]
82
+ module.main()
83
+ return
84
+
85
+ print(f"未知命令: {cmd}\n\n{_usage()}", file=sys.stderr)
86
+ sys.exit(2)
File without changes
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Build a writing playbook from historical articles.
4
+
5
+ Reads all .md files in corpus/, analyzes writing patterns
6
+ in batches via LLM, and outputs a structured playbook.md.
7
+
8
+ Usage:
9
+ worknex build-playbook
10
+ worknex build-playbook --batch-size 10
11
+
12
+ Requires: ANTHROPIC_API_KEY or ARK API key in environment/config.
13
+ This script outputs analysis prompts to stdout for the Agent (LLM) to process.
14
+ The Agent reads the output and generates playbook.md.
15
+ """
16
+
17
+ import argparse
18
+ import json
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from ..paths import corpus_dir as _corpus_dir
23
+
24
+
25
+ def load_corpus() -> list[dict]:
26
+ """Load all markdown files from corpus directory ($WORKNEX_HOME/corpus)."""
27
+ corpus_dir = _corpus_dir()
28
+ if not corpus_dir.exists():
29
+ print(f"Error: corpus directory not found: {corpus_dir}", file=sys.stderr)
30
+ sys.exit(1)
31
+
32
+ articles = []
33
+ for md_file in sorted(corpus_dir.glob("*.md")):
34
+ text = md_file.read_text(encoding="utf-8")
35
+ if not text.strip():
36
+ continue
37
+
38
+ # Extract title (first H1)
39
+ title = ""
40
+ for line in text.split("\n"):
41
+ if line.strip().startswith("# ") and not line.strip().startswith("## "):
42
+ title = line.strip()[2:].strip()
43
+ break
44
+
45
+ # Basic stats
46
+ lines = [l for l in text.split("\n") if l.strip()]
47
+ paragraphs = text.split("\n\n")
48
+ h2_count = sum(1 for l in text.split("\n") if l.strip().startswith("## "))
49
+ char_count = len(text.replace("\n", "").replace(" ", ""))
50
+
51
+ articles.append({
52
+ "filename": md_file.name,
53
+ "title": title,
54
+ "char_count": char_count,
55
+ "paragraph_count": len([p for p in paragraphs if p.strip()]),
56
+ "h2_count": h2_count,
57
+ "text": text,
58
+ })
59
+
60
+ return articles
61
+
62
+
63
+ def compute_corpus_stats(articles: list[dict]) -> dict:
64
+ """Compute aggregate statistics from the corpus."""
65
+ if not articles:
66
+ return {}
67
+
68
+ titles = [a["title"] for a in articles if a["title"]]
69
+ title_lengths = [len(t) for t in titles]
70
+ char_counts = [a["char_count"] for a in articles]
71
+ para_counts = [a["paragraph_count"] for a in articles]
72
+ h2_counts = [a["h2_count"] for a in articles]
73
+
74
+ return {
75
+ "total_articles": len(articles),
76
+ "avg_char_count": round(sum(char_counts) / len(char_counts)),
77
+ "avg_title_length": round(sum(title_lengths) / len(title_lengths), 1) if title_lengths else 0,
78
+ "title_length_range": f"{min(title_lengths)}-{max(title_lengths)}" if title_lengths else "N/A",
79
+ "avg_paragraphs": round(sum(para_counts) / len(para_counts), 1),
80
+ "avg_h2_count": round(sum(h2_counts) / len(h2_counts), 1),
81
+ }
82
+
83
+
84
+ def build_analysis_batches(articles: list[dict], batch_size: int) -> list[list[dict]]:
85
+ """Split articles into batches for LLM analysis."""
86
+ batches = []
87
+ for i in range(0, len(articles), batch_size):
88
+ batch = articles[i:i + batch_size]
89
+ batches.append(batch)
90
+ return batches
91
+
92
+
93
+ def output_analysis_prompt(articles: list[dict], stats: dict, batch_idx: int, total_batches: int):
94
+ """Output a structured analysis prompt for the Agent to process."""
95
+ print(f"\n{'='*60}")
96
+ print(f"BATCH {batch_idx + 1}/{total_batches} — {len(articles)} articles")
97
+ print(f"{'='*60}\n")
98
+
99
+ for i, article in enumerate(articles):
100
+ print(f"--- Article {i+1}: {article['title']} ({article['char_count']}字) ---")
101
+ # Truncate very long articles to first 2000 chars for analysis
102
+ text = article["text"]
103
+ if len(text) > 3000:
104
+ text = text[:3000] + "\n\n[...truncated...]"
105
+ print(text)
106
+ print()
107
+
108
+
109
+ def main():
110
+ parser = argparse.ArgumentParser(description="Build writing playbook from corpus")
111
+ parser.add_argument("--batch-size", type=int, default=10, help="Articles per batch")
112
+ parser.add_argument("--stats-only", action="store_true", help="Only show corpus stats")
113
+ args = parser.parse_args()
114
+
115
+ # Load corpus
116
+ articles = load_corpus()
117
+ if not articles:
118
+ print("Error: no articles found in corpus/", file=sys.stderr)
119
+ sys.exit(1)
120
+
121
+ # Compute stats
122
+ stats = compute_corpus_stats(articles)
123
+
124
+ print("=" * 60)
125
+ print("CORPUS ANALYSIS")
126
+ print("=" * 60)
127
+ print(json.dumps(stats, ensure_ascii=False, indent=2))
128
+
129
+ if args.stats_only:
130
+ return
131
+
132
+ # Build batches
133
+ batches = build_analysis_batches(articles, args.batch_size)
134
+
135
+ print(f"\nTotal: {stats['total_articles']} articles in {len(batches)} batch(es)")
136
+ print(f"Average: {stats['avg_char_count']} chars, {stats['avg_title_length']} char titles, {stats['avg_h2_count']} H2s")
137
+
138
+ # Output analysis instructions
139
+ print(f"""
140
+ {'='*60}
141
+ ANALYSIS INSTRUCTIONS FOR AGENT
142
+ {'='*60}
143
+
144
+ Read all articles below, then generate playbook.md with these sections:
145
+
146
+ ## 标题模式
147
+ - 平均字数和范围
148
+ - 常用策略分布(数字/反直觉/痛点/疑问/陈述,给百分比)
149
+ - 标点习惯(逗号断句?问号?感叹号?)
150
+ - 示例:列出 3 个最典型的标题
151
+
152
+ ## 开头模式
153
+ - 最常用的开头方式(场景/数据/反问/新闻引述/个人经历)
154
+ - 第一段平均长度
155
+ - 从不出现的开头方式
156
+ - 示例:列出 3 个典型开头的第一段
157
+
158
+ ## 段落节奏
159
+ - 平均段落长度(字数)
160
+ - 短段(<30字)占比
161
+ - 最长段落上限
162
+ - 长短交替规律
163
+
164
+ ## 用词指纹
165
+ - 高频标志词/口头禅(出现 3 次以上的特征性表达)
166
+ - 禁用词(从未使用的常见 AI 用语)
167
+ - 英文/专业术语使用习惯
168
+ - 语气词偏好
169
+
170
+ ## H2 命名习惯
171
+ - 用问句?短语?数字编号?
172
+ - 平均长度
173
+ - 示例
174
+
175
+ ## 结尾模式
176
+ - 收尾方式(个人观点/开放提问/行动建议/金句)
177
+ - CTA 风格
178
+ - 示例
179
+
180
+ ## 情绪基调
181
+ - 理性 vs 感性的比例
182
+ - 幽默频率
183
+ - 批判性强度
184
+
185
+ ## 配图风格(如果历史文章有配图描述)
186
+ - 色调偏好
187
+ - 风格关键词
188
+
189
+ 请用量化数据(百分比、平均值、范围)支撑每个结论,不要只做定性描述。
190
+ """)
191
+
192
+ # Output article batches
193
+ for i, batch in enumerate(batches):
194
+ output_analysis_prompt(batch, stats, i, len(batches))
195
+
196
+
197
+ if __name__ == "__main__":
198
+ main()
@@ -0,0 +1,104 @@
1
+ """Create a deterministic editorial report from an editor assessment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import difflib
7
+ import json
8
+ import re
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ import yaml
13
+
14
+
15
+ DIMENSIONS = ("accuracy", "viewpoint", "usefulness", "voice", "readability")
16
+ DECISIONS = {"pass", "revise", "needs_input"}
17
+
18
+
19
+ def _load_mapping(path: str) -> dict:
20
+ data = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
21
+ if not isinstance(data, dict):
22
+ raise ValueError("assessment must be a YAML or JSON object")
23
+ return data
24
+
25
+
26
+ def _headings(text: str) -> list[str]:
27
+ return re.findall(r"^##\s+(.+)$", text, re.MULTILINE)
28
+
29
+
30
+ def build_report(draft: str, final: str, assessment: dict) -> dict:
31
+ if not draft.strip() or not final.strip():
32
+ raise ValueError("draft and final must both be non-empty")
33
+ decision = assessment.get("decision")
34
+ if decision not in DECISIONS:
35
+ raise ValueError("decision must be pass, revise, or needs_input")
36
+
37
+ dimensions = assessment.get("dimensions")
38
+ if not isinstance(dimensions, dict):
39
+ raise ValueError("assessment.dimensions must contain five scores")
40
+ normalized = {}
41
+ for name in DIMENSIONS:
42
+ score = dimensions.get(name)
43
+ if not isinstance(score, (int, float)) or isinstance(score, bool) or not 1 <= score <= 5:
44
+ raise ValueError(f"dimension {name} must be between 1 and 5")
45
+ normalized[name] = float(score)
46
+
47
+ blockers = assessment.get("blockers", [])
48
+ major_issues = assessment.get("major_issues", [])
49
+ if not isinstance(blockers, list) or not isinstance(major_issues, list):
50
+ raise ValueError("blockers and major_issues must be lists")
51
+
52
+ pass_number = assessment.get("pass_number", 1)
53
+ if not isinstance(pass_number, int) or isinstance(pass_number, bool) or pass_number not in {1, 2}:
54
+ raise ValueError("pass_number must be 1 or 2")
55
+
56
+ ratio = difflib.SequenceMatcher(None, draft, final, autojunk=False).ratio()
57
+ average = round(sum(normalized.values()) / len(normalized), 2)
58
+ minimum = min(normalized.values())
59
+ publishable = decision == "pass" and not blockers and minimum >= 3 and average >= 4
60
+
61
+ return {
62
+ "version": 1,
63
+ "decision": decision,
64
+ "pass_number": pass_number,
65
+ "publishable": publishable,
66
+ "dimensions": normalized,
67
+ "dimension_average": average,
68
+ "blockers": blockers,
69
+ "major_issues": major_issues,
70
+ "notes": assessment.get("notes", ""),
71
+ "draft_chars": len(draft),
72
+ "final_chars": len(final),
73
+ "edit_ratio": round(1 - ratio, 4),
74
+ "structure_changed": _headings(draft) != _headings(final),
75
+ }
76
+
77
+
78
+ def main(argv=None):
79
+ parser = argparse.ArgumentParser(description="汇总文章编辑判断与修改幅度")
80
+ parser.add_argument("--draft", required=True, help="初稿 Markdown")
81
+ parser.add_argument("--final", required=True, help="终稿 Markdown")
82
+ parser.add_argument("--assessment", required=True, help="编辑判断 YAML/JSON")
83
+ parser.add_argument("--output", help="报告 JSON 输出路径")
84
+ parser.add_argument("--json", action="store_true", help="在标准输出打印 JSON")
85
+ args = parser.parse_args(argv)
86
+
87
+ try:
88
+ draft = Path(args.draft).read_text(encoding="utf-8")
89
+ final = Path(args.final).read_text(encoding="utf-8")
90
+ report = build_report(draft, final, _load_mapping(args.assessment))
91
+ rendered = json.dumps(report, ensure_ascii=False, indent=2)
92
+ if args.output:
93
+ output = Path(args.output)
94
+ output.parent.mkdir(parents=True, exist_ok=True)
95
+ output.write_text(rendered + "\n", encoding="utf-8")
96
+ if args.json or not args.output:
97
+ print(rendered)
98
+ except (OSError, ValueError, yaml.YAMLError) as exc:
99
+ print(f"Error: {exc}", file=sys.stderr)
100
+ raise SystemExit(2)
101
+
102
+
103
+ if __name__ == "__main__":
104
+ main()