codebee 0.1.95 → 0.1.96

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 (138) hide show
  1. package/CHANGELOG.md +725 -720
  2. package/LICENSE +21 -21
  3. package/README.md +515 -509
  4. package/app/core/acceptance.py +100 -100
  5. package/app/core/agent_context.py +100 -0
  6. package/app/core/aiflavor.py +197 -197
  7. package/app/core/analytics.py +38 -38
  8. package/app/core/artifacts.py +148 -148
  9. package/app/core/assets.py +110 -110
  10. package/app/core/attachments.py +623 -623
  11. package/app/core/audit.py +30 -30
  12. package/app/core/backup.py +470 -470
  13. package/app/core/beekeeper.py +183 -183
  14. package/app/core/benchstore.py +102 -95
  15. package/app/core/board.py +209 -209
  16. package/app/core/bookmeta.py +1036 -1036
  17. package/app/core/bookmeta_catalog.py +180 -180
  18. package/app/core/branching.py +137 -137
  19. package/app/core/builtin_agent.py +1680 -1680
  20. package/app/core/capability.py +144 -144
  21. package/app/core/chaptersafe.py +32 -32
  22. package/app/core/checkpoints.py +143 -0
  23. package/app/core/cleanup.py +303 -303
  24. package/app/core/compaction.py +201 -201
  25. package/app/core/contracts.py +255 -255
  26. package/app/core/covergen.py +356 -356
  27. package/app/core/defectretro.py +43 -43
  28. package/app/core/diagnostics.py +114 -114
  29. package/app/core/dispatch.py +198 -191
  30. package/app/core/dispatch_log.py +128 -128
  31. package/app/core/env_scrub.py +85 -85
  32. package/app/core/errorlog.py +137 -137
  33. package/app/core/eval_matrix.py +82 -0
  34. package/app/core/evalbench.py +184 -91
  35. package/app/core/evaluation.py +174 -174
  36. package/app/core/failure_notify.py +290 -290
  37. package/app/core/flow_graph.py +33 -0
  38. package/app/core/goal_service.py +167 -167
  39. package/app/core/health.py +389 -389
  40. package/app/core/history.py +38 -38
  41. package/app/core/hooks.py +162 -162
  42. package/app/core/jobs.py +25 -4
  43. package/app/core/knowledge_pipeline.py +86 -0
  44. package/app/core/manager.py +2232 -2232
  45. package/app/core/mocks.py +64 -64
  46. package/app/core/notify.py +201 -201
  47. package/app/core/novel_quality.py +181 -189
  48. package/app/core/openapi.py +177 -154
  49. package/app/core/operations.py +228 -228
  50. package/app/core/paihang.py +145 -145
  51. package/app/core/pipeline.py +43 -19
  52. package/app/core/planner.py +17 -11
  53. package/app/core/policy.py +52 -0
  54. package/app/core/portguard.py +111 -111
  55. package/app/core/presence.py +77 -77
  56. package/app/core/preview.py +293 -293
  57. package/app/core/prompt_guard.py +78 -78
  58. package/app/core/publish/__init__.py +7 -7
  59. package/app/core/publish/auto.py +26 -3
  60. package/app/core/publish/browser.py +655 -655
  61. package/app/core/publish/chaprows.py +138 -0
  62. package/app/core/publish/fanqie.py +210 -165
  63. package/app/core/publish/flow.py +753 -640
  64. package/app/core/publish/flows-fanqie-calibrated.json +349 -336
  65. package/app/core/publish/ledger.py +13 -1
  66. package/app/core/publish/manager.py +1150 -1118
  67. package/app/core/publish/qimao.py +176 -176
  68. package/app/core/publish/volumes.py +108 -0
  69. package/app/core/publish/ws.py +139 -139
  70. package/app/core/quality_gate.py +194 -194
  71. package/app/core/redact.py +38 -38
  72. package/app/core/repeat_guard.py +129 -129
  73. package/app/core/retrieval.py +137 -137
  74. package/app/core/router.py +398 -398
  75. package/app/core/runner.py +2238 -2238
  76. package/app/core/selfupdate.py +384 -384
  77. package/app/core/session_log.py +161 -161
  78. package/app/core/sessions.py +312 -312
  79. package/app/core/settings.py +20 -0
  80. package/app/core/settings_schema.py +270 -270
  81. package/app/core/share_page.py +114 -114
  82. package/app/core/skill_scan.py +139 -139
  83. package/app/core/skillpacks/fanqie-novel.md +8 -6
  84. package/app/core/skillpacks/market/character-bible.md +66 -66
  85. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  86. package/app/core/skillpacks/market/git-workflow.md +57 -57
  87. package/app/core/skillpacks/market/release-notes.md +72 -72
  88. package/app/core/skillpacks/market/weekly-report.md +71 -71
  89. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  90. package/app/core/skillpacks/qimao-signing.md +14 -11
  91. package/app/core/skills.py +32 -21
  92. package/app/core/step_runner.py +132 -132
  93. package/app/core/store.py +9 -0
  94. package/app/core/story_tracking.py +275 -275
  95. package/app/core/task_compile.py +199 -199
  96. package/app/core/telemetry.py +292 -292
  97. package/app/core/tlsctx.py +64 -64
  98. package/app/core/token_meter.py +147 -147
  99. package/app/core/tracing.py +207 -144
  100. package/app/core/upstream.py +41 -41
  101. package/app/core/usage.py +1038 -1038
  102. package/app/core/volumes.py +389 -389
  103. package/app/core/webhooks.py +89 -0
  104. package/app/core/wxdigest.py +858 -858
  105. package/app/core/zentao.py +2692 -2692
  106. package/app/main.py +104 -9
  107. package/app/mcp_server.py +244 -244
  108. package/app/pick_dialog.py +110 -110
  109. package/app/ui/app.js +286 -18
  110. package/app/ui/artdiff.js +74 -74
  111. package/app/ui/assets/community-qr.png +0 -0
  112. package/app/ui/assets/donate-alipay.jpg +0 -0
  113. package/app/ui/assets/donate-qr.jpg +0 -0
  114. package/app/ui/board.html +162 -162
  115. package/app/ui/board.js +305 -305
  116. package/app/ui/i18n.js +33 -0
  117. package/app/ui/icons/bee.svg +79 -79
  118. package/app/ui/index.html +32 -3
  119. package/app/ui/manifest.json +16 -16
  120. package/app/ui/qrcode.js +2297 -2297
  121. package/app/ui/style.css +196 -2
  122. package/bin/tutti.js +147 -147
  123. package/package.json +48 -48
  124. package/tools/wx_reader.py +261 -261
  125. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  126. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  127. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  128. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  129. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  130. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  131. package/app/.playwright-cli/run-detail-after.png +0 -0
  132. package/app/output/_createbook_body.json +0 -1
  133. package/app/output/_probe_fq_click.py +0 -50
  134. package/app/output/_probe_fq_form.py +0 -42
  135. package/app/output/_probe_fq_modal.py +0 -55
  136. package/app/output/_probe_fq_points.py +0 -71
  137. package/app/output/_probe_fq_submit.py +0 -34
  138. package/app/output/_probe_fq_watch.py +0 -62
@@ -0,0 +1,138 @@
1
+ # -*- coding: utf-8 -*-
2
+ """章节管理页的行收集与分桶(校准的数据面,manager 只做编排)。
3
+
4
+ 从 manager.py 拆出(manager 超 1200 行行数基线,2026-10-08):收集器只吃
5
+ page + 平台 CONFIG,不依赖任何内部模块——放 L0 层无依赖。
6
+ 状态关键词口径与台账一致:已发布 / 待审核·审核中·排队 / 未通过·驳回。
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import time
12
+
13
+ _STATUS_PUB = "已发布"
14
+ _STATUS_REVIEW = ("待审核", "审核中", "排队")
15
+ _STATUS_BAD = ("未通过", "驳回")
16
+
17
+ _CH_NO_RE = re.compile(r"第\s*(\d+)\s*章")
18
+
19
+
20
+ def bucket_chapter_rows(rows):
21
+ """章节管理页的行文本 → {total, published, review, rejected, published_nos}。
22
+
23
+ 行里含状态关键词才计入对应桶;total=识别出的章节数据行数。
24
+ published_nos=已发布行里解析出的章号(第N章,阿拉伯数字)——本地台账
25
+ 只记得 CodeBee 自己发的章,用户手工补交的只有平台知道,校准把它带
26
+ 回来供幂等/待发清单对账。"""
27
+ out = {"total": 0, "published": 0, "review": 0, "rejected": 0,
28
+ "published_nos": []}
29
+ nos = set()
30
+ for t in rows or []:
31
+ t = str(t)
32
+ if not t:
33
+ continue
34
+ if "章节名称" in t: # 表头兜底(正常到不了这)
35
+ continue
36
+ out["total"] += 1
37
+ if any(k in t for k in _STATUS_BAD):
38
+ out["rejected"] += 1
39
+ elif _STATUS_PUB in t:
40
+ out["published"] += 1
41
+ m = _CH_NO_RE.search(t)
42
+ if m:
43
+ nos.add(int(m.group(1)))
44
+ elif any(k in t for k in _STATUS_REVIEW):
45
+ out["review"] += 1
46
+ out["published_nos"] = sorted(nos)
47
+ return out
48
+
49
+
50
+ def collect_manage_rows(page, mod):
51
+ """读章节管理页的全部行:跨页翻页收齐(番茄表每页 15 行,只数当前页
52
+ 会把 30 章已发报成 15——2026-10-08 校准少计实案)。平台没配翻页 JS
53
+ (七猫)或翻页无进展时按单页收。行文本去重保序,重复页不会翻倍。"""
54
+ js_rows = mod.CONFIG.get("count_rows_js")
55
+ next_js = mod.CONFIG.get("pager_next_js")
56
+ settle = float(mod.CONFIG.get("count_page_settle") or 1.2)
57
+ rows, seen = [], set()
58
+ for _ in range(int(mod.CONFIG.get("count_max_pages") or 20)):
59
+ fresh = 0
60
+ for t in (page.call(js_rows) or []):
61
+ t = str(t)
62
+ if t and t not in seen:
63
+ seen.add(t)
64
+ rows.append(t)
65
+ fresh += 1
66
+ if not next_js or fresh == 0:
67
+ break
68
+ r = page.call(next_js)
69
+ if not isinstance(r, dict) or r.get("done"):
70
+ break
71
+ time.sleep(settle)
72
+ return rows
73
+
74
+
75
+ def collect_all_volumes(page, mod):
76
+ """跨分卷收行:章节管理页带分卷筛选(番茄「第一卷:…」下拉,无「全部」
77
+ 项),列表只显示选中卷的章节——卷二发布后只数卷一会继续少计。弹层在
78
+ click 后下一帧才渲染,open→读/点之间必须隔 sleep(同步 JS 一口气查必
79
+ 落空,2026-10-08 真机实案)。收完恢复进入时的卷;一卷都没选成时按原
80
+ 筛选兜底收一次,不让分卷自动化失败拖死整次校准。
81
+
82
+ 返回 (rows, vols):vols=读到的平台分卷显示名清单(读不到为 None),
83
+ 供 books.json 缓存平台卷表。"""
84
+ vj = mod.CONFIG.get("volume_js") or {}
85
+ if not all(vj.get(k) for k in ("open", "options", "pick")):
86
+ return collect_manage_rows(page, mod), None
87
+ settle = float(mod.CONFIG.get("count_page_settle") or 1.2)
88
+
89
+ def open_popup():
90
+ try:
91
+ page.call(vj["open"])
92
+ except Exception:
93
+ pass
94
+ time.sleep(0.8)
95
+
96
+ orig = ""
97
+ try:
98
+ orig = str(page.call(vj.get("current")) or "").strip()
99
+ except Exception:
100
+ pass
101
+ open_popup()
102
+ try:
103
+ vols = page.call(vj["options"]) or []
104
+ except Exception:
105
+ vols = []
106
+ if not isinstance(vols, list) or len(vols) < 2:
107
+ return collect_manage_rows(page, mod), None # 单卷:当前筛选就是全量
108
+ rows, seen, picked_any, misses = [], set(), False, 0
109
+ for v in vols:
110
+ v = str(v or "").strip()
111
+ if not v:
112
+ continue
113
+ open_popup()
114
+ try:
115
+ r = page.call(vj["pick"], v)
116
+ except Exception:
117
+ r = None
118
+ if not isinstance(r, dict) or not r.get("ok"):
119
+ misses += 1
120
+ if misses >= 3:
121
+ break # 弹层压根点不动:别把选项清单烧完
122
+ continue
123
+ misses = 0
124
+ picked_any = True
125
+ time.sleep(settle)
126
+ for t in collect_manage_rows(page, mod):
127
+ if t not in seen:
128
+ seen.add(t)
129
+ rows.append(t)
130
+ if orig: # 还原进入时的卷筛选
131
+ open_popup()
132
+ try:
133
+ page.call(vj["pick"], orig)
134
+ except Exception:
135
+ pass
136
+ if not picked_any:
137
+ return collect_manage_rows(page, mod), list(vols)
138
+ return rows, [str(v) for v in vols if str(v or "").strip()]
@@ -1,165 +1,210 @@
1
- # -*- coding: utf-8 -*-
2
- """番茄作家后台的发布流程定义(默认表:选择器为合理推测,待实测校准)。
3
-
4
- 校准方式(不改代码):把真实步骤写进 data/publish/flows-fanqie.json:
5
- {"create_book": [ {"do":"navigate","url":"..."}, {"do":"fill","sel":"...","key":"title"} ... ],
6
- "upload_chapter": [ ... ]}
7
- 存在即整体覆盖本文件的 FLOWS;用「探测表单」按钮(probe 流程)dump 出
8
- 页面真实元素清单后照着写即可。每次失败的截图也会指出卡在哪一步。
9
-
10
- URL 依据:tools/fanqie_bookmeta.json 的 source 记录了 2026-09-17 实抓
11
- fanqienovel.com/main/writer/create 建书弹层;入口走作家后台首页点
12
- 「创建作品」,不硬编码深链(深链随改版漂移,入口按钮最稳)。
13
- """
14
- from __future__ import annotations
15
-
16
- CONFIG = {
17
- "id": "fanqie",
18
- "label": "番茄",
19
- "home": "https://fanqienovel.com/main/writer/", # 实测 writer. 子域不存在(DNS 000);快照实抓为主站路径
20
- # 导航后 URL 含任一标记 → 未登录(跳到了登录/通行证页)
21
- "login_url_marks": ["login", "passport", "sso", "account/signin"],
22
- # 章节管理页计数(2026-09-28 实机校准):Arco 表格,数据行=含 ≥4 个 td 的 tr
23
- # (表头行是 th,天然排除)。逐行 innerText 交 manager 按状态关键词分桶。
24
- "count_rows_js":
25
- "()=>[...document.querySelectorAll('tr')]"
26
- ".filter(tr => tr.querySelectorAll(':scope > td').length >= 4)"
27
- ".map(tr => tr.innerText.replace(/\\s+/g, ' ').trim()).filter(Boolean)",
28
- }
29
-
30
- # 建书:入口 → 弹层/页面 → 文本字段直填;分类/签约模式/标签走文本点击。
31
- # 文本输入类的选择器优先用 placeholder 模糊匹配(改版后 id/class 易变,
32
- # 「书名」「简介」这类 placeholder 语料最稳定)。
33
- CREATE_BOOK = [
34
- {"do": "navigate", "url": "{home}"},
35
- {"do": "url_any", "any": ["fanqienovel.com"]},
36
- {"do": "click_text", "text": "创建作品", "contains": True, "scope": "button,a,[role=button],span"},
37
- {"do": "probe", "note": "建书表单"},
38
- {"do": "wait", "sel": "input[placeholder*='书名'],input[placeholder*='作品名'],input[maxlength]", "timeout": 10},
39
- {"do": "fill", "sel": "input[placeholder*='书名'],input[placeholder*='作品名']", "key": "title"},
40
- {"do": "fill", "sel": "textarea[placeholder*='简介'],textarea", "key": "summary"},
41
- {"do": "fill", "sel": "input[placeholder*='主角']", "key": "protagonist"},
42
- {"do": "click_text", "text": "{signing_mode}", "contains": False,
43
- "scope": "[class*=mode] label,label,span,div"},
44
- {"do": "click_text", "text": "{category}", "contains": False,
45
- "scope": "[class*=categor] li,span,div"},
46
- {"do": "shot", "name": "create-book-filled"},
47
- {"do": "submit", "sel": "button[class*=submit],button[class*=primary]"},
48
- ]
49
-
50
- # 发章:后台首页 → 按书名点进作品 → 新建章节 → 填标题与正文。
51
- # book_name 由 manager 从作品登记(books.json)带入 values,click_text 复用。
52
- UPLOAD_CHAPTER = [
53
- {"do": "navigate", "url": "{home}"},
54
- {"do": "url_any", "any": ["fanqienovel.com"]},
55
- {"do": "click_text", "text": "{book_name}", "contains": True,
56
- "scope": "a,span,div[class*=title],div[class*=book]"},
57
- {"do": "click_text", "text": "新建章节", "contains": True, "scope": "button,a,[role=button],span"},
58
- {"do": "probe", "note": "章节编辑器"},
59
- {"do": "fill", "sel": "input[placeholder*='章节'],input[placeholder*='标题']", "key": "chapter_title"},
60
- {"do": "wait", "sel": "[contenteditable=true],textarea[class*=content],div[class*=editor]", "timeout": 10},
61
- {"do": "fill", "sel": "[contenteditable=true],textarea[class*=content]", "key": "chapter_body"},
62
- {"do": "shot", "name": "chapter-filled"},
63
- {"do": "submit", "sel": "button[class*=publish],button[class*=submit]"},
64
- ]
65
-
66
- # 登录态探测:打开后台首页,URL 被踢到登录页 → 未登录
67
- CHECK_LOGIN = [
68
- {"do": "navigate", "url": "{home}"},
69
- {"do": "url_any", "any": ["fanqienovel.com"]},
70
- ]
71
-
72
- # 表单探测(校准辅助):开建书入口后 dump 全部可交互元素
73
- PROBE_FORM = [
74
- {"do": "navigate", "url": "{home}"},
75
- {"do": "url_any", "any": ["fanqienovel.com"]},
76
- {"do": "click_text", "text": "创建作品", "contains": True, "scope": "button,a,[role=button],span"},
77
- {"do": "probe", "note": "建书表单"},
78
- ]
79
-
80
- FLOWS = {"create_book": CREATE_BOOK, "upload_chapter": UPLOAD_CHAPTER,
81
- "check_login": CHECK_LOGIN, "probe_form": PROBE_FORM}
82
-
83
-
84
- def _first(meta, key):
85
- v = meta.get(key)
86
- return str(v[0]).strip() if isinstance(v, list) and v else str(v or "").strip()
87
-
88
-
89
- def values_create_book(meta):
90
- """bookmeta 字段 → 建书表单值。标签弹层的分区点选用单值字段
91
- (每组选 1 个最稳:番茄主题/角色/情节各≤2、内容组各有上限),
92
- 数组全量点选由 tag_groups 路径兼容。"""
93
- return {
94
- "title": (meta.get("book_name") or "").strip(),
95
- "summary": (meta.get("summary") or "").strip(),
96
- "protagonist": (meta.get("protagonist_1") or "").strip(),
97
- "protagonist2": (meta.get("protagonist_2") or "").strip(),
98
- "signing_mode": (meta.get("signing_mode") or "连载模式").strip(),
99
- "category": (meta.get("category") or "").strip(),
100
- "target_reader": (meta.get("target_reader") or "男频").strip(),
101
- "tag_theme": _first(meta, "tags_theme"),
102
- "tag_role": _first(meta, "tags_role"),
103
- "tag_plot": _first(meta, "tags_plot"),
104
- "tag_content_emotion": _first(meta, "content_emotion"),
105
- "tag_content_character": _first(meta, "content_character"),
106
- "tag_content_world": _first(meta, "content_world"),
107
- }
108
-
109
-
110
- def tag_groups(meta):
111
- """标签字段名 → 值列表(manager 用来生成逐个 click_text 步骤)。"""
112
- out = []
113
- for key in ("tags_theme", "tags_role", "tags_plot",
114
- "content_plot", "content_emotion", "content_character", "content_world"):
115
- v = meta.get(key)
116
- if isinstance(v, list) and v:
117
- out.append((key, [str(x) for x in v]))
118
- return out
119
-
120
-
121
- def editor_url(book):
122
- """章节编辑器直达(真机实测):/main/writer/<book_id>/publish/。"""
123
- bid = str((book or {}).get("book_id") or "")
124
- if bid:
125
- return "https://fanqienovel.com/main/writer/%s/publish/" % bid
126
- return CONFIG["home"]
127
-
128
-
129
- def chapter_manage_url(book):
130
- """章节管理页(上线验证用)。"""
131
- bid = str((book or {}).get("book_id") or "")
132
- if bid:
133
- return "https://fanqienovel.com/main/writer/chapter-manage/" + bid
134
- return CONFIG["home"] + "book-manage"
135
-
136
-
137
- def resolve_book_id(page, book):
138
- """登记缺 book_id 时按书名在作家后台找回:点书卡进详情,从 URL 提取。
139
-
140
- 建书成功但 id 提取落空(跳转链没走完/auto_submit=false 人工提交)的书,
141
- 发章前经 manager._resolve_book_id 调到这里补账;找不回返回空串不拦死。"""
142
- import re
143
- import time
144
- from . import flow as _flow
145
- title = str((book or {}).get("title") or "").strip()
146
- if not title:
147
- return ""
148
- try:
149
- page.navigate(CONFIG["home"], timeout=30)
150
- except Exception:
151
- return ""
152
- r = None
153
- for _try in range(6): # 书列表慢渲染
154
- r = page.call(_flow._click_match_js(), [title], 300)
155
- if (r or {}).get("ok"):
156
- break
157
- time.sleep(1.0)
158
- if not (r or {}).get("ok"):
159
- return ""
160
- time.sleep(2.5) # 详情页跳转收尾
161
- m = re.search(r"book-info/(\d+)|chapter-manage/(\d+)|writer/(\d+)/publish",
162
- str(page.url() or ""))
163
- if not m:
164
- return ""
165
- return m.group(1) or m.group(2) or m.group(3) or ""
1
+ # -*- coding: utf-8 -*-
2
+ """番茄作家后台的发布流程定义(默认表:选择器为合理推测,待实测校准)。
3
+
4
+ 校准方式(不改代码):把真实步骤写进 data/publish/flows-fanqie.json:
5
+ {"create_book": [ {"do":"navigate","url":"..."}, {"do":"fill","sel":"...","key":"title"} ... ],
6
+ "upload_chapter": [ ... ]}
7
+ 存在即整体覆盖本文件的 FLOWS;用「探测表单」按钮(probe 流程)dump 出
8
+ 页面真实元素清单后照着写即可。每次失败的截图也会指出卡在哪一步。
9
+
10
+ URL 依据:tools/fanqie_bookmeta.json 的 source 记录了 2026-09-17 实抓
11
+ fanqienovel.com/main/writer/create 建书弹层;入口走作家后台首页点
12
+ 「创建作品」,不硬编码深链(深链随改版漂移,入口按钮最稳)。
13
+ """
14
+ from __future__ import annotations
15
+
16
+ CONFIG = {
17
+ "id": "fanqie",
18
+ "label": "番茄",
19
+ "home": "https://fanqienovel.com/main/writer/", # 实测 writer. 子域不存在(DNS 000);快照实抓为主站路径
20
+ # 导航后 URL 含任一标记 → 未登录(跳到了登录/通行证页)
21
+ "login_url_marks": ["login", "passport", "sso", "account/signin"],
22
+ # 章节管理页计数(2026-09-28 实机校准;2026-10-08 补跨页/跨卷):
23
+ # Arco 表格,数据行=含 ≥4 个 td 的 tr(表头行是 th,天然排除)。
24
+ # 逐行 innerText 交 manager 按状态关键词分桶。表每页 15 行且带分卷筛选,
25
+ # 只数当前页会把 30 章已发报成 15(校准少计实案)——pager_next_js 翻页、
26
+ # volume_js 换卷,manager._collect_all_volumes 负责循环。
27
+ "count_rows_js":
28
+ "()=>[...document.querySelectorAll('tr')]"
29
+ ".filter(tr => tr.querySelectorAll(':scope > td').length >= 4)"
30
+ ".map(tr => tr.innerText.replace(/\\s+/g, ' ').trim()).filter(Boolean)",
31
+ "pager_next_js":
32
+ "()=>{const n=document.querySelector('.arco-pagination-item-next');"
33
+ "if(!n)return{done:true};"
34
+ "if(/item-disabled/.test(n.className))return{done:true};"
35
+ "n.click();return{done:false};}",
36
+ # 分卷下拉是自绘 byte-select(非 arco-select),弹层在 click 后下一帧才
37
+ # 渲染——四段式由 manager 用 sleep 隔开:current 读当前卷、open 开弹层、
38
+ # options 读选项(弹层开着读)、pick 在已开的弹层里点选某卷(选中即收起)。
39
+ # 无「全部」项,逐卷收齐全量;收完恢复进入时的卷。
40
+ "volume_js": {
41
+ "current":
42
+ "()=>{const v=document.querySelector('.chapter-select-left .byte-select-view');"
43
+ "return v?(v.innerText||'').trim():'';}",
44
+ "open":
45
+ "()=>{const v=document.querySelector('.chapter-select-left .byte-select-view');"
46
+ "if(!v)return{ok:false};v.click();return{ok:true};}",
47
+ "options":
48
+ "()=>{const dd=[...document.querySelectorAll('.byte-select-popup,"
49
+ "[class*=select-dropdown]')].find(e=>e.getBoundingClientRect().width>0);"
50
+ "return dd?[...dd.querySelectorAll('li,[class*=option]')]"
51
+ ".map(e=>(e.innerText||'').trim()).filter(Boolean):[];}",
52
+ "pick":
53
+ "(t)=>{const dd=[...document.querySelectorAll('.byte-select-popup,"
54
+ "[class*=select-dropdown]')].find(e=>e.getBoundingClientRect().width>0);"
55
+ "if(!dd)return{ok:false};"
56
+ "const hit=[...dd.querySelectorAll('li,[class*=option]')]"
57
+ ".find(e=>((e.innerText||'').trim())===t);"
58
+ "if(!hit)return{ok:false};hit.click();return{ok:true};}",
59
+ },
60
+ }
61
+
62
+ # 建书:入口 → 弹层/页面 → 文本字段直填;分类/签约模式/标签走文本点击。
63
+ # 文本输入类的选择器优先用 placeholder 模糊匹配(改版后 id/class 易变,
64
+ # 「书名」「简介」这类 placeholder 语料最稳定)。
65
+ CREATE_BOOK = [
66
+ {"do": "navigate", "url": "{home}"},
67
+ {"do": "url_any", "any": ["fanqienovel.com"]},
68
+ {"do": "click_text", "text": "创建作品", "contains": True, "scope": "button,a,[role=button],span"},
69
+ {"do": "probe", "note": "建书表单"},
70
+ {"do": "wait", "sel": "input[placeholder*='书名'],input[placeholder*='作品名'],input[maxlength]", "timeout": 10},
71
+ {"do": "fill", "sel": "input[placeholder*='书名'],input[placeholder*='作品名']", "key": "title"},
72
+ {"do": "fill", "sel": "textarea[placeholder*='简介'],textarea", "key": "summary"},
73
+ {"do": "fill", "sel": "input[placeholder*='主角']", "key": "protagonist"},
74
+ {"do": "click_text", "text": "{signing_mode}", "contains": False,
75
+ "scope": "[class*=mode] label,label,span,div"},
76
+ {"do": "click_text", "text": "{category}", "contains": False,
77
+ "scope": "[class*=categor] li,span,div"},
78
+ {"do": "shot", "name": "create-book-filled"},
79
+ {"do": "submit", "sel": "button[class*=submit],button[class*=primary]"},
80
+ ]
81
+
82
+ # 发章:后台首页 → 按书名点进作品 → 新建章节 → 填标题与正文。
83
+ # book_name 由 manager 从作品登记(books.json)带入 values,click_text 复用。
84
+ # 番茄编辑器「第 [序号] 章 [标题]」三件套分开填:chapter_no=阿拉伯章号
85
+ # (序号框只认数字,空着被平台打回)、chapter_name=正题、chapter_body=正文
86
+ # (标题行已在 read_chapter 剥掉)。volume 步骤按 values.volume_name 选卷/
87
+ # 建卷(自动分卷,无计划时空值跳过)。内置默认表选择器为推测,真机以
88
+ # flows-fanqie-calibrated.json / data 覆盖表为准。
89
+ UPLOAD_CHAPTER = [
90
+ {"do": "navigate", "url": "{home}"},
91
+ {"do": "url_any", "any": ["fanqienovel.com"]},
92
+ {"do": "click_text", "text": "{book_name}", "contains": True,
93
+ "scope": "a,span,div[class*=title],div[class*=book]"},
94
+ {"do": "click_text", "text": "新建章节", "contains": True, "scope": "button,a,[role=button],span"},
95
+ {"do": "probe", "note": "章节编辑器"},
96
+ {"do": "wait", "sel": "[contenteditable=true],textarea[class*=content],div[class*=editor]", "timeout": 10},
97
+ {"do": "volume", "key": "volume_name", "optional": True,
98
+ "open_sel": "input[type=number],input[class*=serial],div[class*=volume]",
99
+ "modal_sel": "[class*=volume][class*=modal],[class*=modal][class*=volume]",
100
+ "item_sel": "[class*=volume] li,[class*=volume-item]",
101
+ "add_sel": "[class*=add-volume],[class*=volume] button",
102
+ "confirm_sel": "[class*=confirm]", "settle": 1.5},
103
+ {"do": "fill", "sel": "input[type=number],input[class*=serial],input[class*=byte]", "key": "chapter_no"},
104
+ {"do": "fill", "sel": "input[placeholder*='章节'],input[placeholder*='标题']", "key": "chapter_name"},
105
+ {"do": "wait", "sel": "[contenteditable=true],textarea[class*=content],div[class*=editor]", "timeout": 10},
106
+ {"do": "fill", "sel": "[contenteditable=true],textarea[class*=content]", "key": "chapter_body"},
107
+ {"do": "shot", "name": "chapter-filled"},
108
+ {"do": "submit", "sel": "button[class*=publish],button[class*=submit]"},
109
+ ]
110
+
111
+ # 登录态探测:打开后台首页,URL 被踢到登录页 → 未登录
112
+ CHECK_LOGIN = [
113
+ {"do": "navigate", "url": "{home}"},
114
+ {"do": "url_any", "any": ["fanqienovel.com"]},
115
+ ]
116
+
117
+ # 表单探测(校准辅助):开建书入口后 dump 全部可交互元素
118
+ PROBE_FORM = [
119
+ {"do": "navigate", "url": "{home}"},
120
+ {"do": "url_any", "any": ["fanqienovel.com"]},
121
+ {"do": "click_text", "text": "创建作品", "contains": True, "scope": "button,a,[role=button],span"},
122
+ {"do": "probe", "note": "建书表单"},
123
+ ]
124
+
125
+ FLOWS = {"create_book": CREATE_BOOK, "upload_chapter": UPLOAD_CHAPTER,
126
+ "check_login": CHECK_LOGIN, "probe_form": PROBE_FORM}
127
+
128
+
129
+ def _first(meta, key):
130
+ v = meta.get(key)
131
+ return str(v[0]).strip() if isinstance(v, list) and v else str(v or "").strip()
132
+
133
+
134
+ def values_create_book(meta):
135
+ """bookmeta 字段 → 建书表单值。标签弹层的分区点选用单值字段
136
+ (每组选 1 个最稳:番茄主题/角色/情节各≤2、内容组各有上限),
137
+ 数组全量点选由 tag_groups 路径兼容。"""
138
+ return {
139
+ "title": (meta.get("book_name") or "").strip(),
140
+ "summary": (meta.get("summary") or "").strip(),
141
+ "protagonist": (meta.get("protagonist_1") or "").strip(),
142
+ "protagonist2": (meta.get("protagonist_2") or "").strip(),
143
+ "signing_mode": (meta.get("signing_mode") or "连载模式").strip(),
144
+ "category": (meta.get("category") or "").strip(),
145
+ "target_reader": (meta.get("target_reader") or "男频").strip(),
146
+ "tag_theme": _first(meta, "tags_theme"),
147
+ "tag_role": _first(meta, "tags_role"),
148
+ "tag_plot": _first(meta, "tags_plot"),
149
+ "tag_content_emotion": _first(meta, "content_emotion"),
150
+ "tag_content_character": _first(meta, "content_character"),
151
+ "tag_content_world": _first(meta, "content_world"),
152
+ }
153
+
154
+
155
+ def tag_groups(meta):
156
+ """标签字段名 → 值列表(manager 用来生成逐个 click_text 步骤)。"""
157
+ out = []
158
+ for key in ("tags_theme", "tags_role", "tags_plot",
159
+ "content_plot", "content_emotion", "content_character", "content_world"):
160
+ v = meta.get(key)
161
+ if isinstance(v, list) and v:
162
+ out.append((key, [str(x) for x in v]))
163
+ return out
164
+
165
+
166
+ def editor_url(book):
167
+ """章节编辑器直达(真机实测):/main/writer/<book_id>/publish/。"""
168
+ bid = str((book or {}).get("book_id") or "")
169
+ if bid:
170
+ return "https://fanqienovel.com/main/writer/%s/publish/" % bid
171
+ return CONFIG["home"]
172
+
173
+
174
+ def chapter_manage_url(book):
175
+ """章节管理页(上线验证用)。"""
176
+ bid = str((book or {}).get("book_id") or "")
177
+ if bid:
178
+ return "https://fanqienovel.com/main/writer/chapter-manage/" + bid
179
+ return CONFIG["home"] + "book-manage"
180
+
181
+
182
+ def resolve_book_id(page, book):
183
+ """登记缺 book_id 时按书名在作家后台找回:点书卡进详情,从 URL 提取。
184
+
185
+ 建书成功但 id 提取落空(跳转链没走完/auto_submit=false 人工提交)的书,
186
+ 发章前经 manager._resolve_book_id 调到这里补账;找不回返回空串不拦死。"""
187
+ import re
188
+ import time
189
+ from . import flow as _flow
190
+ title = str((book or {}).get("title") or "").strip()
191
+ if not title:
192
+ return ""
193
+ try:
194
+ page.navigate(CONFIG["home"], timeout=30)
195
+ except Exception:
196
+ return ""
197
+ r = None
198
+ for _try in range(6): # 书列表慢渲染
199
+ r = page.call(_flow._click_match_js(), [title], 300)
200
+ if (r or {}).get("ok"):
201
+ break
202
+ time.sleep(1.0)
203
+ if not (r or {}).get("ok"):
204
+ return ""
205
+ time.sleep(2.5) # 详情页跳转收尾
206
+ m = re.search(r"book-info/(\d+)|chapter-manage/(\d+)|writer/(\d+)/publish",
207
+ str(page.url() or ""))
208
+ if not m:
209
+ return ""
210
+ return m.group(1) or m.group(2) or m.group(3) or ""