codebee 0.1.102 → 0.1.103

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 (121) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +21 -21
  3. package/README.md +4 -8
  4. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +88 -0
  5. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +126 -0
  6. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  7. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +210 -0
  8. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  9. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  10. package/app/.playwright-cli/run-detail-after.png +0 -0
  11. package/app/core/acceptance.py +100 -100
  12. package/app/core/acp_client.py +457 -457
  13. package/app/core/aiflavor.py +197 -197
  14. package/app/core/analytics.py +38 -38
  15. package/app/core/artifacts.py +148 -148
  16. package/app/core/assets.py +110 -110
  17. package/app/core/attachments.py +623 -623
  18. package/app/core/audit.py +30 -30
  19. package/app/core/backend_profiles.py +188 -188
  20. package/app/core/backup.py +470 -470
  21. package/app/core/beekeeper.py +183 -183
  22. package/app/core/benchstore.py +102 -102
  23. package/app/core/bookmeta.py +1036 -1036
  24. package/app/core/bookmeta_catalog.py +180 -180
  25. package/app/core/branching.py +137 -137
  26. package/app/core/capability.py +144 -144
  27. package/app/core/chaptersafe.py +32 -32
  28. package/app/core/cleanup.py +303 -303
  29. package/app/core/command_auth.py +302 -302
  30. package/app/core/compaction.py +201 -201
  31. package/app/core/contracts.py +255 -255
  32. package/app/core/covergen.py +356 -356
  33. package/app/core/defectretro.py +43 -43
  34. package/app/core/diagnostics.py +114 -114
  35. package/app/core/dispatch.py +1 -1
  36. package/app/core/dispatch_log.py +128 -128
  37. package/app/core/env_scrub.py +85 -85
  38. package/app/core/errorlog.py +137 -137
  39. package/app/core/eval_matrix.py +91 -91
  40. package/app/core/evaluation.py +174 -174
  41. package/app/core/failure_notify.py +290 -290
  42. package/app/core/flow_graph.py +33 -33
  43. package/app/core/goal_service.py +167 -167
  44. package/app/core/health.py +389 -389
  45. package/app/core/history.py +38 -38
  46. package/app/core/hooks.py +162 -162
  47. package/app/core/knowledge_pipeline.py +86 -86
  48. package/app/core/manager.py +2385 -2385
  49. package/app/core/mocks.py +64 -64
  50. package/app/core/notify.py +201 -201
  51. package/app/core/novel_quality.py +181 -181
  52. package/app/core/openapi.py +217 -217
  53. package/app/core/operations.py +228 -228
  54. package/app/core/paihang.py +145 -145
  55. package/app/core/pipeline.py +2 -2
  56. package/app/core/portguard.py +111 -111
  57. package/app/core/presence.py +77 -77
  58. package/app/core/preview.py +293 -293
  59. package/app/core/project_memory.py +206 -206
  60. package/app/core/prompt_guard.py +78 -78
  61. package/app/core/publish/__init__.py +7 -7
  62. package/app/core/publish/auto.py +659 -659
  63. package/app/core/publish/chaprows.py +138 -138
  64. package/app/core/publish/flows-fanqie-calibrated.json +442 -442
  65. package/app/core/publish/manager.py +1185 -1185
  66. package/app/core/publish/qimao.py +193 -193
  67. package/app/core/publish/volumes.py +108 -108
  68. package/app/core/publish/ws.py +139 -139
  69. package/app/core/quality_gate.py +201 -201
  70. package/app/core/redact.py +38 -38
  71. package/app/core/repeat_guard.py +129 -129
  72. package/app/core/retrieval.py +137 -137
  73. package/app/core/sandbox_runtime.py +95 -95
  74. package/app/core/selfupdate.py +384 -384
  75. package/app/core/session_log.py +161 -161
  76. package/app/core/sessions.py +312 -312
  77. package/app/core/settings.py +215 -215
  78. package/app/core/settings_schema.py +282 -282
  79. package/app/core/share_page.py +114 -114
  80. package/app/core/skill_scan.py +139 -139
  81. package/app/core/skillpacks/market/character-bible.md +66 -66
  82. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  83. package/app/core/skillpacks/market/git-workflow.md +57 -57
  84. package/app/core/skillpacks/market/release-notes.md +72 -72
  85. package/app/core/skillpacks/market/weekly-report.md +71 -71
  86. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  87. package/app/core/step_runner.py +132 -132
  88. package/app/core/story_tracking.py +275 -275
  89. package/app/core/task_compile.py +199 -199
  90. package/app/core/telemetry.py +292 -292
  91. package/app/core/tlsctx.py +64 -64
  92. package/app/core/token_meter.py +147 -147
  93. package/app/core/tool_outputs.py +107 -107
  94. package/app/core/tracing.py +207 -207
  95. package/app/core/upstream.py +41 -41
  96. package/app/core/usage.py +1038 -1038
  97. package/app/core/volumes.py +389 -389
  98. package/app/core/webhooks.py +89 -89
  99. package/app/core/wxdigest.py +865 -865
  100. package/app/core/zentao.py +2692 -2692
  101. package/app/mcp_server.py +244 -244
  102. package/app/output/_createbook_body.json +1 -0
  103. package/app/output/_probe_fq_click.py +50 -0
  104. package/app/output/_probe_fq_form.py +42 -0
  105. package/app/output/_probe_fq_modal.py +55 -0
  106. package/app/output/_probe_fq_points.py +71 -0
  107. package/app/output/_probe_fq_submit.py +34 -0
  108. package/app/output/_probe_fq_watch.py +62 -0
  109. package/app/output/workbench-clean-preview.jpg +0 -0
  110. package/app/output/workbench-reference-render.png +0 -0
  111. package/app/output/workbench-shell.png +0 -0
  112. package/app/pick_dialog.py +110 -110
  113. package/app/ui/app.js +2 -3
  114. package/app/ui/artdiff.js +74 -74
  115. package/app/ui/i18n.js +0 -1
  116. package/app/ui/icons/bee.svg +79 -79
  117. package/app/ui/manifest.json +16 -16
  118. package/app/ui/qrcode.js +2297 -2297
  119. package/bin/tutti.js +147 -147
  120. package/package.json +1 -1
  121. package/tools/wx_reader.py +261 -261
@@ -1,193 +1,193 @@
1
- # -*- coding: utf-8 -*-
2
- """七猫作者后台的发布流程定义(默认表:选择器为合理推测,待实测校准)。
3
-
4
- 校准方式同番茄:data/publish/flows-qimao.json 覆盖 + 「探测表单」dump。
5
- URL 依据:tools/qimao_bookmeta.json 的 source 记录 2026-09-17 登录态实抓
6
- zuozhe.qimao.com/api/pc/v1/book/book-option——作者后台 PC 端在 zuozhe.qimao.com。
7
-
8
- 七猫建书表单结构(同次实抓):频道级联(男/女生 → 一级 → 二级)+ 四组标签
9
- (风格/角色/情节/背景,每组 1-3 个)+ 文本字段。
10
- """
11
- from __future__ import annotations
12
-
13
- CONFIG = {
14
- "id": "qimao",
15
- "label": "七猫",
16
- "home": "https://zuozhe.qimao.com/",
17
- # 作品管理页:建书入口(「新建小说」按钮所在);建书向导第二步表单在其下
18
- "book_manage": "https://zuozhe.qimao.com/front/book-manage",
19
- # 编辑器顶栏明示「正文字数最少 1000 字」——不足时「立即发布」被静默拦截
20
- "min_chapter_chars": 1000,
21
- "login_url_marks": ["login", "signin", "passport", "sso"],
22
- # 章节管理页计数(2026-09-28 实机校准):Element UI 表格,.el-table__row
23
- # 只中数据行(表头在独立的 header-wrapper 表里),与发章流程的等待选择器同源
24
- "count_rows_js":
25
- "()=>[...document.querySelectorAll('.el-table__row')]"
26
- ".map(tr => tr.innerText.replace(/\\s+/g, ' ').trim()).filter(Boolean)",
27
- }
28
-
29
- CREATE_BOOK = [
30
- {"do": "navigate", "url": "{home}"},
31
- {"do": "url_any", "any": ["qimao.com"]},
32
- {"do": "click_text", "text": "创建作品", "contains": True, "scope": "button,a,[role=button],span"},
33
- {"do": "probe", "note": "建书表单"},
34
- {"do": "wait", "sel": "input[placeholder*='作品'],input[placeholder*='书名'],input[maxlength]", "timeout": 10},
35
- {"do": "fill", "sel": "input[placeholder*='作品'],input[placeholder*='书名']", "key": "title"},
36
- {"do": "fill", "sel": "textarea[placeholder*='简介'],textarea", "key": "summary"},
37
- {"do": "fill", "sel": "input[placeholder*='主角']", "key": "protagonist"},
38
- # 频道级联:目标读者 → 一级分类 → 二级分类(值来自 bookmeta 的级联字段)
39
- {"do": "click_text", "text": "{target_reader}", "contains": False,
40
- "scope": "[class*=channel] label,label,span"},
41
- {"do": "click_text", "text": "{category_main}", "contains": False,
42
- "scope": "[class*=categor] li,option,span"},
43
- {"do": "click_text", "text": "{category_sub}", "contains": False,
44
- "scope": "[class*=categor] li,option,span"},
45
- {"do": "shot", "name": "create-book-filled"},
46
- {"do": "submit", "sel": "button[class*=submit],button[class*=primary],button[class*=confirm]"},
47
- ]
48
-
49
- UPLOAD_CHAPTER = [
50
- {"do": "navigate", "url": "{home}"},
51
- {"do": "url_any", "any": ["qimao.com"]},
52
- {"do": "click_text", "text": "{book_name}", "contains": True,
53
- "scope": "a,span,div[class*=title],div[class*=book]"},
54
- {"do": "click_text", "text": "新建章节", "contains": True, "scope": "button,a,[role=button],span"},
55
- {"do": "probe", "note": "章节编辑器"},
56
- {"do": "fill", "sel": "input[placeholder*='章节'],input[placeholder*='标题'],input[placeholder*='章名']", "key": "chapter_title"},
57
- {"do": "wait", "sel": "[contenteditable=true],textarea[class*=content],iframe", "timeout": 10},
58
- {"do": "fill", "sel": "[contenteditable=true],textarea[class*=content]", "key": "chapter_body"},
59
- {"do": "shot", "name": "chapter-filled"},
60
- {"do": "submit", "sel": "button[class*=publish],button[class*=submit]"},
61
- ]
62
-
63
- # 发章存草稿(2026-10-09 全部发草稿):内置推测表——真机校准表在
64
- # flows-qimao-calibrated.json(按其 upload_chapter 的「存草稿」段拆出:
65
- # 填好→存草稿→草稿箱验证行在场即成)。草稿不上线;提交发布由用户在
66
- # 草稿箱手工点(立即发布→更正序号→确认发布三层弹窗照旧人工走)。
67
- UPLOAD_CHAPTER_DRAFT = [
68
- {"do": "navigate", "url": "{editor_url}"},
69
- {"do": "url_any", "any": ["qimao.com"]},
70
- {"do": "fill", "sel": "textarea[placeholder*='章节名称']", "key": "chapter_title"},
71
- {"do": "wait", "sel": "[contenteditable=true],textarea[class*=content],iframe", "timeout": 10},
72
- {"do": "fill", "sel": "[contenteditable=true],textarea[class*=content]", "key": "chapter_body"},
73
- {"do": "shot", "name": "chapter-filled-draft"},
74
- {"do": "click_real", "text": "存草稿", "tries": 25},
75
- {"do": "sleep", "s": 2},
76
- {"do": "verify", "url": "{draft_url}", "any": ["{chapter_title}"], "settle": 5},
77
- ]
78
-
79
- CHECK_LOGIN = [
80
- {"do": "navigate", "url": "{home}"},
81
- {"do": "url_any", "any": ["qimao.com"]},
82
- ]
83
-
84
- PROBE_FORM = [
85
- {"do": "navigate", "url": "{home}"},
86
- {"do": "url_any", "any": ["qimao.com"]},
87
- {"do": "click_text", "text": "创建作品", "contains": True, "scope": "button,a,[role=button],span"},
88
- {"do": "probe", "note": "建书表单"},
89
- ]
90
-
91
- FLOWS = {"create_book": CREATE_BOOK, "upload_chapter": UPLOAD_CHAPTER,
92
- "upload_chapter_draft": UPLOAD_CHAPTER_DRAFT,
93
- "check_login": CHECK_LOGIN, "probe_form": PROBE_FORM}
94
-
95
-
96
- # 标签字段名 → 弹层左侧组显示名(tags 步骤按组切换后点选;每组必选 1-3 个)
97
- TAG_GROUP_LABELS = {"tags_style": "风格", "tags_role": "角色",
98
- "tags_plot": "情节", "tags_bg": "背景"}
99
-
100
-
101
- def values_create_book(meta):
102
- from .. import bookmeta_catalog as cat
103
- target_reader = (meta.get("target_reader") or "").strip()
104
- category_main = (meta.get("category_main") or "").strip()
105
- category_sub = (meta.get("category_sub") or "").strip()
106
- # 旧数据兜底:二级有值但一级空/与二级不自洽(如女生专属的「现实故事」
107
- # 配了男生频道)会让一级 click_text 静默跳过、死在流程中段——按官方
108
- # 目录跨频道反查对齐,频道与一级跟着二级走
109
- if category_sub and not (category_main and
110
- category_sub in (cat.QIMAO_CATS.get(target_reader)
111
- or {}).get(category_main, [])):
112
- loc = cat.qimao_locate_sub(category_sub)
113
- if loc:
114
- target_reader, category_main = loc
115
- else:
116
- category_sub = ""
117
- return {
118
- "title": (meta.get("book_name") or "").strip(),
119
- "summary": (meta.get("summary") or "").strip(),
120
- "protagonist": (meta.get("protagonist_1") or "").strip(),
121
- "target_reader": target_reader,
122
- "category_main": category_main,
123
- "category_sub": category_sub,
124
- "status": (meta.get("status") or "连载中").strip(),
125
- }
126
-
127
-
128
- def tag_groups(meta):
129
- out = []
130
- for key in ("tags_style", "tags_role", "tags_plot", "tags_bg"):
131
- v = meta.get(key)
132
- if isinstance(v, list) and v:
133
- out.append((key, [str(x) for x in v]))
134
- return out
135
-
136
-
137
- def chapter_manage_url(book):
138
- """章节管理页(上线验证用):有 book_id 直达,否则回作品管理页。"""
139
- bid = str((book or {}).get("book_id") or "")
140
- if bid: # 实测 .../front/book-manage/manage?id=
141
- return CONFIG["book_manage"] + "/manage?id=" + bid
142
- return CONFIG["book_manage"]
143
-
144
-
145
- def resolve_book_id(page, book):
146
- """登记缺 book_id 时按书名在作品管理页找回:点书名卡进详情,从 URL 提取。
147
-
148
- 与番茄同款对账通道(fanqie.resolve_book_id);找不回返回空串不拦死。"""
149
- import re
150
- import time
151
- from . import flow as _flow
152
- title = str((book or {}).get("title") or "").strip()
153
- if not title:
154
- return ""
155
- try:
156
- page.navigate(CONFIG["book_manage"], timeout=30)
157
- except Exception:
158
- return ""
159
- r = None
160
- for _try in range(6): # 列表慢渲染
161
- r = page.call(_flow._click_match_js(), [title], 300)
162
- if (r or {}).get("ok"):
163
- break
164
- time.sleep(1.0)
165
- if not (r or {}).get("ok"):
166
- return ""
167
- time.sleep(2.5) # 跳转收尾
168
- m = re.search(r"manage\?id=(\d+)|draft\?id=(\d+)|information\?id=(\d+)",
169
- str(page.url() or ""))
170
- if not m:
171
- return ""
172
- return m.group(1) or m.group(2) or m.group(3) or ""
173
-
174
-
175
- def draft_url(book):
176
- """草稿箱页(发章真发布链中转站)。"""
177
- bid = str((book or {}).get("book_id") or "")
178
- if bid:
179
- return CONFIG["book_manage"] + "/draft?id=" + bid
180
- return CONFIG["book_manage"]
181
-
182
-
183
- def editor_url(book):
184
- """章节编辑器直达(绕开会开新 tab 的「上传章节」点击)。
185
-
186
- 缺 title 参数会被平台重定向回首页(真机实测),必须带上。"""
187
- bid = str((book or {}).get("book_id") or "")
188
- title = str((book or {}).get("title") or "")
189
- if bid:
190
- from urllib.parse import quote
191
- return (CONFIG["book_manage"].rsplit("/", 1)[0]
192
- + "/book-upload?id=" + bid + "&title=" + quote(title))
193
- return CONFIG["book_manage"]
1
+ # -*- coding: utf-8 -*-
2
+ """七猫作者后台的发布流程定义(默认表:选择器为合理推测,待实测校准)。
3
+
4
+ 校准方式同番茄:data/publish/flows-qimao.json 覆盖 + 「探测表单」dump。
5
+ URL 依据:tools/qimao_bookmeta.json 的 source 记录 2026-09-17 登录态实抓
6
+ zuozhe.qimao.com/api/pc/v1/book/book-option——作者后台 PC 端在 zuozhe.qimao.com。
7
+
8
+ 七猫建书表单结构(同次实抓):频道级联(男/女生 → 一级 → 二级)+ 四组标签
9
+ (风格/角色/情节/背景,每组 1-3 个)+ 文本字段。
10
+ """
11
+ from __future__ import annotations
12
+
13
+ CONFIG = {
14
+ "id": "qimao",
15
+ "label": "七猫",
16
+ "home": "https://zuozhe.qimao.com/",
17
+ # 作品管理页:建书入口(「新建小说」按钮所在);建书向导第二步表单在其下
18
+ "book_manage": "https://zuozhe.qimao.com/front/book-manage",
19
+ # 编辑器顶栏明示「正文字数最少 1000 字」——不足时「立即发布」被静默拦截
20
+ "min_chapter_chars": 1000,
21
+ "login_url_marks": ["login", "signin", "passport", "sso"],
22
+ # 章节管理页计数(2026-09-28 实机校准):Element UI 表格,.el-table__row
23
+ # 只中数据行(表头在独立的 header-wrapper 表里),与发章流程的等待选择器同源
24
+ "count_rows_js":
25
+ "()=>[...document.querySelectorAll('.el-table__row')]"
26
+ ".map(tr => tr.innerText.replace(/\\s+/g, ' ').trim()).filter(Boolean)",
27
+ }
28
+
29
+ CREATE_BOOK = [
30
+ {"do": "navigate", "url": "{home}"},
31
+ {"do": "url_any", "any": ["qimao.com"]},
32
+ {"do": "click_text", "text": "创建作品", "contains": True, "scope": "button,a,[role=button],span"},
33
+ {"do": "probe", "note": "建书表单"},
34
+ {"do": "wait", "sel": "input[placeholder*='作品'],input[placeholder*='书名'],input[maxlength]", "timeout": 10},
35
+ {"do": "fill", "sel": "input[placeholder*='作品'],input[placeholder*='书名']", "key": "title"},
36
+ {"do": "fill", "sel": "textarea[placeholder*='简介'],textarea", "key": "summary"},
37
+ {"do": "fill", "sel": "input[placeholder*='主角']", "key": "protagonist"},
38
+ # 频道级联:目标读者 → 一级分类 → 二级分类(值来自 bookmeta 的级联字段)
39
+ {"do": "click_text", "text": "{target_reader}", "contains": False,
40
+ "scope": "[class*=channel] label,label,span"},
41
+ {"do": "click_text", "text": "{category_main}", "contains": False,
42
+ "scope": "[class*=categor] li,option,span"},
43
+ {"do": "click_text", "text": "{category_sub}", "contains": False,
44
+ "scope": "[class*=categor] li,option,span"},
45
+ {"do": "shot", "name": "create-book-filled"},
46
+ {"do": "submit", "sel": "button[class*=submit],button[class*=primary],button[class*=confirm]"},
47
+ ]
48
+
49
+ UPLOAD_CHAPTER = [
50
+ {"do": "navigate", "url": "{home}"},
51
+ {"do": "url_any", "any": ["qimao.com"]},
52
+ {"do": "click_text", "text": "{book_name}", "contains": True,
53
+ "scope": "a,span,div[class*=title],div[class*=book]"},
54
+ {"do": "click_text", "text": "新建章节", "contains": True, "scope": "button,a,[role=button],span"},
55
+ {"do": "probe", "note": "章节编辑器"},
56
+ {"do": "fill", "sel": "input[placeholder*='章节'],input[placeholder*='标题'],input[placeholder*='章名']", "key": "chapter_title"},
57
+ {"do": "wait", "sel": "[contenteditable=true],textarea[class*=content],iframe", "timeout": 10},
58
+ {"do": "fill", "sel": "[contenteditable=true],textarea[class*=content]", "key": "chapter_body"},
59
+ {"do": "shot", "name": "chapter-filled"},
60
+ {"do": "submit", "sel": "button[class*=publish],button[class*=submit]"},
61
+ ]
62
+
63
+ # 发章存草稿(2026-10-09 全部发草稿):内置推测表——真机校准表在
64
+ # flows-qimao-calibrated.json(按其 upload_chapter 的「存草稿」段拆出:
65
+ # 填好→存草稿→草稿箱验证行在场即成)。草稿不上线;提交发布由用户在
66
+ # 草稿箱手工点(立即发布→更正序号→确认发布三层弹窗照旧人工走)。
67
+ UPLOAD_CHAPTER_DRAFT = [
68
+ {"do": "navigate", "url": "{editor_url}"},
69
+ {"do": "url_any", "any": ["qimao.com"]},
70
+ {"do": "fill", "sel": "textarea[placeholder*='章节名称']", "key": "chapter_title"},
71
+ {"do": "wait", "sel": "[contenteditable=true],textarea[class*=content],iframe", "timeout": 10},
72
+ {"do": "fill", "sel": "[contenteditable=true],textarea[class*=content]", "key": "chapter_body"},
73
+ {"do": "shot", "name": "chapter-filled-draft"},
74
+ {"do": "click_real", "text": "存草稿", "tries": 25},
75
+ {"do": "sleep", "s": 2},
76
+ {"do": "verify", "url": "{draft_url}", "any": ["{chapter_title}"], "settle": 5},
77
+ ]
78
+
79
+ CHECK_LOGIN = [
80
+ {"do": "navigate", "url": "{home}"},
81
+ {"do": "url_any", "any": ["qimao.com"]},
82
+ ]
83
+
84
+ PROBE_FORM = [
85
+ {"do": "navigate", "url": "{home}"},
86
+ {"do": "url_any", "any": ["qimao.com"]},
87
+ {"do": "click_text", "text": "创建作品", "contains": True, "scope": "button,a,[role=button],span"},
88
+ {"do": "probe", "note": "建书表单"},
89
+ ]
90
+
91
+ FLOWS = {"create_book": CREATE_BOOK, "upload_chapter": UPLOAD_CHAPTER,
92
+ "upload_chapter_draft": UPLOAD_CHAPTER_DRAFT,
93
+ "check_login": CHECK_LOGIN, "probe_form": PROBE_FORM}
94
+
95
+
96
+ # 标签字段名 → 弹层左侧组显示名(tags 步骤按组切换后点选;每组必选 1-3 个)
97
+ TAG_GROUP_LABELS = {"tags_style": "风格", "tags_role": "角色",
98
+ "tags_plot": "情节", "tags_bg": "背景"}
99
+
100
+
101
+ def values_create_book(meta):
102
+ from .. import bookmeta_catalog as cat
103
+ target_reader = (meta.get("target_reader") or "").strip()
104
+ category_main = (meta.get("category_main") or "").strip()
105
+ category_sub = (meta.get("category_sub") or "").strip()
106
+ # 旧数据兜底:二级有值但一级空/与二级不自洽(如女生专属的「现实故事」
107
+ # 配了男生频道)会让一级 click_text 静默跳过、死在流程中段——按官方
108
+ # 目录跨频道反查对齐,频道与一级跟着二级走
109
+ if category_sub and not (category_main and
110
+ category_sub in (cat.QIMAO_CATS.get(target_reader)
111
+ or {}).get(category_main, [])):
112
+ loc = cat.qimao_locate_sub(category_sub)
113
+ if loc:
114
+ target_reader, category_main = loc
115
+ else:
116
+ category_sub = ""
117
+ return {
118
+ "title": (meta.get("book_name") or "").strip(),
119
+ "summary": (meta.get("summary") or "").strip(),
120
+ "protagonist": (meta.get("protagonist_1") or "").strip(),
121
+ "target_reader": target_reader,
122
+ "category_main": category_main,
123
+ "category_sub": category_sub,
124
+ "status": (meta.get("status") or "连载中").strip(),
125
+ }
126
+
127
+
128
+ def tag_groups(meta):
129
+ out = []
130
+ for key in ("tags_style", "tags_role", "tags_plot", "tags_bg"):
131
+ v = meta.get(key)
132
+ if isinstance(v, list) and v:
133
+ out.append((key, [str(x) for x in v]))
134
+ return out
135
+
136
+
137
+ def chapter_manage_url(book):
138
+ """章节管理页(上线验证用):有 book_id 直达,否则回作品管理页。"""
139
+ bid = str((book or {}).get("book_id") or "")
140
+ if bid: # 实测 .../front/book-manage/manage?id=
141
+ return CONFIG["book_manage"] + "/manage?id=" + bid
142
+ return CONFIG["book_manage"]
143
+
144
+
145
+ def resolve_book_id(page, book):
146
+ """登记缺 book_id 时按书名在作品管理页找回:点书名卡进详情,从 URL 提取。
147
+
148
+ 与番茄同款对账通道(fanqie.resolve_book_id);找不回返回空串不拦死。"""
149
+ import re
150
+ import time
151
+ from . import flow as _flow
152
+ title = str((book or {}).get("title") or "").strip()
153
+ if not title:
154
+ return ""
155
+ try:
156
+ page.navigate(CONFIG["book_manage"], timeout=30)
157
+ except Exception:
158
+ return ""
159
+ r = None
160
+ for _try in range(6): # 列表慢渲染
161
+ r = page.call(_flow._click_match_js(), [title], 300)
162
+ if (r or {}).get("ok"):
163
+ break
164
+ time.sleep(1.0)
165
+ if not (r or {}).get("ok"):
166
+ return ""
167
+ time.sleep(2.5) # 跳转收尾
168
+ m = re.search(r"manage\?id=(\d+)|draft\?id=(\d+)|information\?id=(\d+)",
169
+ str(page.url() or ""))
170
+ if not m:
171
+ return ""
172
+ return m.group(1) or m.group(2) or m.group(3) or ""
173
+
174
+
175
+ def draft_url(book):
176
+ """草稿箱页(发章真发布链中转站)。"""
177
+ bid = str((book or {}).get("book_id") or "")
178
+ if bid:
179
+ return CONFIG["book_manage"] + "/draft?id=" + bid
180
+ return CONFIG["book_manage"]
181
+
182
+
183
+ def editor_url(book):
184
+ """章节编辑器直达(绕开会开新 tab 的「上传章节」点击)。
185
+
186
+ 缺 title 参数会被平台重定向回首页(真机实测),必须带上。"""
187
+ bid = str((book or {}).get("book_id") or "")
188
+ title = str((book or {}).get("title") or "")
189
+ if bid:
190
+ from urllib.parse import quote
191
+ return (CONFIG["book_manage"].rsplit("/", 1)[0]
192
+ + "/book-upload?id=" + bid + "&title=" + quote(title))
193
+ return CONFIG["book_manage"]
@@ -1,108 +1,108 @@
1
- # -*- coding: utf-8 -*-
2
- """分卷计划与平台分卷对齐(2026-10-08 批量自动发布·自动分卷)。
3
-
4
- 卷计划来源(前者优先):
5
- 1. <workdir>/分卷.json(或 volumes.json):{"volumes":[{"name":"县里有旧账",
6
- "from":49,"to":104}, ...]}——手工维护的权威表;
7
- 2. <workdir>/大纲/series-outline.md:`## 第一卷:卷名` 标题给顺序与卷名,
8
- 正文里「八卷依次48、56、60…章」给各卷章数(章号全书连续,从头累加)。
9
-
10
- 平台侧卷以显示名带前缀(「第二卷:县里有旧账」);匹配一律走 norm_volume_name
11
- 去前缀比较,平台是第几卷由平台自己定。没有计划(解析不出/文件不在)= 功能
12
- 关闭,发章保持平台默认卷——绝不瞎猜卷名。
13
- """
14
- from __future__ import annotations
15
-
16
- import json
17
- import re
18
- from pathlib import Path
19
-
20
- # 平台卷显示名前缀:第一卷: / 第12卷:
21
- _PREFIX_RE = re.compile(
22
- r"^第\s*[0-90-9一二三四五六七八九十百千零两]+\s*卷\s*[::]\s*")
23
- _HEAD_RE = re.compile(
24
- r"^##\s*第\s*([0-90-9一二三四五六七八九十百千零两]+)\s*卷\s*[::]\s*(.+?)\s*$",
25
- re.M)
26
- # 「八卷依次48、56、60、64、64、64、64、60章」
27
- _COUNTS_RE = re.compile(r"依次\s*([0-90-9]+(?:[、,,]\s*[0-90-9]+)*)\s*章")
28
-
29
-
30
- def norm_volume_name(s):
31
- """「第二卷:县里有旧账」→「县里有旧账」(本地计划名与平台显示名对齐)。"""
32
- return _PREFIX_RE.sub("", str(s or "").strip()).strip()
33
-
34
-
35
- def load_plan(workdir):
36
- """工作目录 → 卷计划 [{no, name, from, to}](from/to 为全书连续章号,
37
- 0 表示不设界)。解析不出返回 [],调用方按「无计划」处理。"""
38
- wd = Path(workdir or "")
39
- if not wd.is_dir():
40
- return []
41
- for name in ("分卷.json", "volumes.json"):
42
- fp = wd / name
43
- if not fp.is_file():
44
- continue
45
- try:
46
- d = json.loads(fp.read_text(encoding="utf-8"))
47
- except Exception:
48
- continue
49
- vols = d.get("volumes") if isinstance(d, dict) else d
50
- out = []
51
- if isinstance(vols, list):
52
- for k, v in enumerate(vols, 1):
53
- if isinstance(v, dict) and str(v.get("name") or "").strip():
54
- out.append({"no": int(v.get("no") or k),
55
- "name": str(v["name"]).strip(),
56
- "from": int(v.get("from") or 0),
57
- "to": int(v.get("to") or 0)})
58
- if out:
59
- return out
60
- return _parse_outline(wd / "大纲" / "series-outline.md")
61
-
62
-
63
- def _parse_outline(fp):
64
- """series-outline.md → 卷计划。标题给卷名,章数行给范围;章数缺失或与
65
- 标题数对不上时宁可放弃(不猜)。"""
66
- try:
67
- text = Path(fp).read_text(encoding="utf-8")
68
- except Exception:
69
- return []
70
- heads = _HEAD_RE.findall(text)
71
- if not heads:
72
- return []
73
- m = _COUNTS_RE.search(text)
74
- if not m:
75
- return []
76
- try:
77
- counts = [int(x) for x in re.split(r"[、,,]", m.group(1).strip())]
78
- except ValueError:
79
- return []
80
- if len(counts) != len(heads) or any(c <= 0 for c in counts):
81
- return []
82
- out, start = [], 1
83
- for k, ((_cn, name), cnt) in enumerate(zip(heads, counts), 1):
84
- out.append({"no": k, # 卷号以顺序为准,标题汉字号仅展示
85
- "name": name.strip(),
86
- "from": start,
87
- "to": start + cnt - 1})
88
- start += cnt
89
- return out
90
-
91
-
92
- def volume_for(ch_no, plan):
93
- """章号 → 卷条目(from<=ch<=to;0 边界=不设界)。无匹配返回 None。"""
94
- if ch_no <= 0:
95
- return None
96
- for v in plan or []:
97
- lo, hi = int(v.get("from") or 0), int(v.get("to") or 0)
98
- if (lo and ch_no < lo) or (hi and ch_no > hi):
99
- continue
100
- if lo or hi:
101
- return v
102
- return None
103
-
104
-
105
- def name_for(workdir, ch_no):
106
- """章号 → 目标卷名(裸名,如「县里有旧账」);无计划/无匹配返回空串。"""
107
- v = volume_for(ch_no, load_plan(workdir))
108
- return str(v.get("name") or "").strip() if v else ""
1
+ # -*- coding: utf-8 -*-
2
+ """分卷计划与平台分卷对齐(2026-10-08 批量自动发布·自动分卷)。
3
+
4
+ 卷计划来源(前者优先):
5
+ 1. <workdir>/分卷.json(或 volumes.json):{"volumes":[{"name":"县里有旧账",
6
+ "from":49,"to":104}, ...]}——手工维护的权威表;
7
+ 2. <workdir>/大纲/series-outline.md:`## 第一卷:卷名` 标题给顺序与卷名,
8
+ 正文里「八卷依次48、56、60…章」给各卷章数(章号全书连续,从头累加)。
9
+
10
+ 平台侧卷以显示名带前缀(「第二卷:县里有旧账」);匹配一律走 norm_volume_name
11
+ 去前缀比较,平台是第几卷由平台自己定。没有计划(解析不出/文件不在)= 功能
12
+ 关闭,发章保持平台默认卷——绝不瞎猜卷名。
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+ from pathlib import Path
19
+
20
+ # 平台卷显示名前缀:第一卷: / 第12卷:
21
+ _PREFIX_RE = re.compile(
22
+ r"^第\s*[0-90-9一二三四五六七八九十百千零两]+\s*卷\s*[::]\s*")
23
+ _HEAD_RE = re.compile(
24
+ r"^##\s*第\s*([0-90-9一二三四五六七八九十百千零两]+)\s*卷\s*[::]\s*(.+?)\s*$",
25
+ re.M)
26
+ # 「八卷依次48、56、60、64、64、64、64、60章」
27
+ _COUNTS_RE = re.compile(r"依次\s*([0-90-9]+(?:[、,,]\s*[0-90-9]+)*)\s*章")
28
+
29
+
30
+ def norm_volume_name(s):
31
+ """「第二卷:县里有旧账」→「县里有旧账」(本地计划名与平台显示名对齐)。"""
32
+ return _PREFIX_RE.sub("", str(s or "").strip()).strip()
33
+
34
+
35
+ def load_plan(workdir):
36
+ """工作目录 → 卷计划 [{no, name, from, to}](from/to 为全书连续章号,
37
+ 0 表示不设界)。解析不出返回 [],调用方按「无计划」处理。"""
38
+ wd = Path(workdir or "")
39
+ if not wd.is_dir():
40
+ return []
41
+ for name in ("分卷.json", "volumes.json"):
42
+ fp = wd / name
43
+ if not fp.is_file():
44
+ continue
45
+ try:
46
+ d = json.loads(fp.read_text(encoding="utf-8"))
47
+ except Exception:
48
+ continue
49
+ vols = d.get("volumes") if isinstance(d, dict) else d
50
+ out = []
51
+ if isinstance(vols, list):
52
+ for k, v in enumerate(vols, 1):
53
+ if isinstance(v, dict) and str(v.get("name") or "").strip():
54
+ out.append({"no": int(v.get("no") or k),
55
+ "name": str(v["name"]).strip(),
56
+ "from": int(v.get("from") or 0),
57
+ "to": int(v.get("to") or 0)})
58
+ if out:
59
+ return out
60
+ return _parse_outline(wd / "大纲" / "series-outline.md")
61
+
62
+
63
+ def _parse_outline(fp):
64
+ """series-outline.md → 卷计划。标题给卷名,章数行给范围;章数缺失或与
65
+ 标题数对不上时宁可放弃(不猜)。"""
66
+ try:
67
+ text = Path(fp).read_text(encoding="utf-8")
68
+ except Exception:
69
+ return []
70
+ heads = _HEAD_RE.findall(text)
71
+ if not heads:
72
+ return []
73
+ m = _COUNTS_RE.search(text)
74
+ if not m:
75
+ return []
76
+ try:
77
+ counts = [int(x) for x in re.split(r"[、,,]", m.group(1).strip())]
78
+ except ValueError:
79
+ return []
80
+ if len(counts) != len(heads) or any(c <= 0 for c in counts):
81
+ return []
82
+ out, start = [], 1
83
+ for k, ((_cn, name), cnt) in enumerate(zip(heads, counts), 1):
84
+ out.append({"no": k, # 卷号以顺序为准,标题汉字号仅展示
85
+ "name": name.strip(),
86
+ "from": start,
87
+ "to": start + cnt - 1})
88
+ start += cnt
89
+ return out
90
+
91
+
92
+ def volume_for(ch_no, plan):
93
+ """章号 → 卷条目(from<=ch<=to;0 边界=不设界)。无匹配返回 None。"""
94
+ if ch_no <= 0:
95
+ return None
96
+ for v in plan or []:
97
+ lo, hi = int(v.get("from") or 0), int(v.get("to") or 0)
98
+ if (lo and ch_no < lo) or (hi and ch_no > hi):
99
+ continue
100
+ if lo or hi:
101
+ return v
102
+ return None
103
+
104
+
105
+ def name_for(workdir, ch_no):
106
+ """章号 → 目标卷名(裸名,如「县里有旧账」);无计划/无匹配返回空串。"""
107
+ v = volume_for(ch_no, load_plan(workdir))
108
+ return str(v.get("name") or "").strip() if v else ""