codebee 0.1.72 → 0.1.74

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.
@@ -116,7 +116,10 @@ def fetch_rank_items(limit=40):
116
116
 
117
117
 
118
118
  def rank_scan_prompt(goal):
119
- """组装扫榜选材分析提示词。四源全空返回 None(调用方回落直连提示词)。"""
119
+ """组装扫榜选材分析提示词。四源全空返回 None(调用方回落直连提示词)。
120
+
121
+ 报告契约含逐条 A-E 跨平台证据分级(借鉴 career-ops 的分级报告交付形态):
122
+ 分级只认素材实际出现情况——抑制单源孤证被模型放大成「大热题材」。"""
120
123
  blocks, seen = [], set()
121
124
  for label, fname in _SOURCES:
122
125
  items = [t for t in globals()[fname](30) if t not in seen]
@@ -131,8 +134,12 @@ def rank_scan_prompt(goal):
131
134
  "## 榜单元素材\n%s\n\n"
132
135
  "## 用户想写的方向\n%s\n\n"
133
136
  "## 你的产出(Markdown 报告)\n"
137
+ "报告第一行给图例(一行):「证据分级:A=三平台及以上重合,B=两平台重合,"
138
+ "C=单平台多次出现,D=单平台仅一次,E=素材无直接证据、系推断」。\n"
134
139
  "1. **热门题材 Top3**:各自的共同特征与上榜代表书目(跨平台重合的题材单独点出——多平台都热说明是真风口)\n"
135
140
  "2. **高频人设/套路总结**:3-5 条,点名反复出现的元素\n"
136
141
  "3. **差异化切入建议**:2-3 个,结合用户方向给出「题材+人设」组合与一句话理由\n"
142
+ "以上每一项结论末尾标注【证据X】(X 为 A-E 之一)。分级只看素材里真实出现的"
143
+ "情况:组合条目按其组成元素的较低档取值,拿不准就降一档、不升级。\n"
137
144
  "直接输出分析报告,不要复述素材清单,不要输出与报告无关的内容。"
138
145
  % (material, goal or "(用户未指定方向,按大盘热门分析)"))
@@ -2862,6 +2862,27 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2862
2862
 
2863
2863
  refresh_actual_route(impl)
2864
2864
  workdir = task["workdir"]
2865
+ # Long-form state is a deterministic input to every chapter. Bootstrap
2866
+ # legacy serial tasks once, then refuse to write over manually edited
2867
+ # derived views until the author reconciles the source JSON.
2868
+ from . import story_tracking
2869
+ try:
2870
+ tracking_state = story_tracking.load(workdir)
2871
+ if tracking_state is None:
2872
+ story_tracking.init(workdir, task.get("id"), task.get("title"),
2873
+ premise=task.get("goal") or "")
2874
+ tracking_check = story_tracking.check(workdir)
2875
+ except Exception as exc:
2876
+ store.update_run(run_id, expected_status="running", status="failed",
2877
+ error="故事追踪状态不可用,请先修复:%s" % str(exc)[:500],
2878
+ ended_at=_now())
2879
+ return
2880
+ if not tracking_check.get("ok"):
2881
+ store.update_run(run_id, expected_status="running", status="failed",
2882
+ error="故事追踪状态存在漂移,请先修复:%s" %
2883
+ ";".join(tracking_check.get("errors") or [])[:500],
2884
+ ended_at=_now())
2885
+ return
2865
2886
  # 续会话步骤的 CLI 启动目录(稿件读写仍用 workdir)
2866
2887
  step_wd = _resume_workdir(resume_ctx, workdir) if resume_ctx else workdir
2867
2888
  serial = task.get("serial") or {}
@@ -3840,6 +3861,26 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
3840
3861
  chapter_scores.append(cs_new)
3841
3862
  # 每章即时持久化:长篇中断/超时后可断点续跑,不丢已完成章的分数
3842
3863
  store.update_run(run_id, chapter_scores=chapter_scores)
3864
+ # Structured story state is committed alongside the chapter. The
3865
+ # pipeline remains compatible with legacy workdirs: a tracking failure
3866
+ # is reported in the run evidence but never deletes a valid chapter.
3867
+ try:
3868
+ from . import story_tracking
3869
+ story_tracking.commit_chapter(
3870
+ workdir, i, outline_txt, _read_chapter(workdir, i),
3871
+ facts=[str(x.get("note") or "") for x in issues_all
3872
+ if isinstance(x, dict) and x.get("chapter") == i and x.get("note")],
3873
+ foreshadowing=[{"id": "F-%04d-%02d" % (i, n + 1),
3874
+ "text": str(line)[:400], "status": "active",
3875
+ "planted_chapter": i}
3876
+ for n, line in enumerate(ledger_lines[:20])],
3877
+ next_promises=[ch.get("hook") or ch.get("highlight") or ""],
3878
+ )
3879
+ except Exception as exc:
3880
+ try:
3881
+ store.update_run(run_id, story_tracking_warning=str(exc)[:300])
3882
+ except Exception:
3883
+ pass
3843
3884
  # 资源账本(借鉴角色资源账本):评审提出的道具/伤情/承诺/伏笔增量
3844
3885
  # 追加到 .codebee/resource-ledger.md,下一章起草时注入,防跨章穿帮。
3845
3886
  # 失败静默——账本是增强不是硬依赖。
@@ -256,6 +256,8 @@ __SKILLS__
256
256
  只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
257
257
  {"book_title": "书名", "style_anchor": "全书文风锚(80字内,具体可执行:叙述视角与人称、句长基调、语域、对白风格、禁用表达)", "chapters": [{"title": "章节标题", "beats": "本章剧情要点(50-120字:事件/冲突/推进)", "hook": "章末钩子(一句话)", "highlight": "本章爽点/情绪爆点(一句话:谁在哪里完成什么反转或释放什么情绪)"}]__VOLUMES_KEY__}
258
258
  硬性要求:
259
+ - book_title 4-15 字,必须考虑平台书名全站唯一(撞名建不了书):避开「我的XX藏不住」
260
+ 「开局XX」等高频套路组合,书名里带本书独有的专名(人名/金手指/设定词),独有专名越多越不易撞名;
259
261
  - 第 1-3 章是黄金三章:第 1 章前 100 字出现主角、前 200 字出现异常或欲望、前 600 字进入具体困境,
260
262
  第 3 章末留大钩子;不要用世界观或环境描写拖延切入;
261
263
  - 每章都写清“主角主动选择 → 阻碍 → 局面变化 → 章末钩子”,禁止水字数的日常流水账和硬旁白推剧情;
@@ -9,6 +9,7 @@ bundles are never executed by this module.
9
9
  """
10
10
  from __future__ import annotations
11
11
 
12
+ import hashlib
12
13
  import json
13
14
  import os
14
15
  import re
@@ -523,3 +524,36 @@ def active_mcp_servers():
523
524
  continue
524
525
  return [x for x in out if isinstance(x, dict) and x.get("command")]
525
526
 
527
+
528
+ def merged_servers_text(user_text):
529
+ """用户显式 MCP 清单(JSON 文本)+ 已启用插件的声明 → 合成后的 JSON 文本。
530
+
531
+ 插件服务器名加 ``plg_`` 前缀+短哈希隔离,防与用户配置撞名;用户清单
532
+ 非法或非数组时原样返回,坏插件绝不连累用户显式配置的服务器。
533
+ 逻辑原先长在 main() 启动闭包里、测试够不着(ae9bcb8 半交付时两个
534
+ 用例即因此恒错),2026-09-30 随 /api/plugins 路由补齐一并下沉。"""
535
+ raw = str(user_text or "")
536
+ try:
537
+ configured = json.loads(raw) if raw.strip() else []
538
+ except (ValueError, TypeError):
539
+ return raw
540
+ if not isinstance(configured, list):
541
+ return raw
542
+ try:
543
+ plugin_servers = active_mcp_servers()
544
+ except Exception:
545
+ plugin_servers = []
546
+ for item in plugin_servers or []:
547
+ if not isinstance(item, dict):
548
+ continue
549
+ plugin_id = str(item.pop("plugin_id", "plugin"))
550
+ server_name = str(item.get("name") or "server")
551
+ digest = hashlib.sha256(
552
+ (plugin_id + ":" + server_name).encode("utf-8")).hexdigest()[:6]
553
+ safe = re.sub(r"[^a-z0-9_-]", "-", plugin_id.lower())
554
+ item["name"] = ("plg_" + safe[:11] + "_" + digest)[:24]
555
+ if not any(x.get("name") == item["name"] for x in configured
556
+ if isinstance(x, dict)):
557
+ configured.append(item)
558
+ return json.dumps(configured, ensure_ascii=False)
559
+
@@ -0,0 +1,77 @@
1
+ """Small local agent presence registry for the operator dashboard."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import threading
6
+ import time
7
+
8
+ from . import paths
9
+
10
+ _LOCK = threading.RLock()
11
+ _FILE = paths.DATA_DIR / "agent_presence.json"
12
+ _STALE_S = 180
13
+
14
+
15
+ def _load():
16
+ try:
17
+ value = json.loads(_FILE.read_text(encoding="utf-8"))
18
+ return value if isinstance(value, dict) else {}
19
+ except Exception:
20
+ return {}
21
+
22
+
23
+ def _save(value):
24
+ _FILE.parent.mkdir(parents=True, exist_ok=True)
25
+ tmp = _FILE.with_suffix(".tmp")
26
+ tmp.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
27
+ tmp.replace(_FILE)
28
+
29
+
30
+ def heartbeat(agent_id, label="", capabilities=None, task_id="", status="idle", metadata=None):
31
+ aid = str(agent_id or "").strip()[:100]
32
+ if not aid:
33
+ raise ValueError("agent_id 必填")
34
+ if capabilities is not None and not isinstance(capabilities, list):
35
+ raise ValueError("capabilities 必须是列表")
36
+ if status is not None and not isinstance(status, str):
37
+ raise ValueError("status 必须是文本")
38
+ now = time.time()
39
+ with _LOCK:
40
+ data = _load()
41
+ safe_meta = metadata if isinstance(metadata, dict) else {}
42
+ # Presence is a hot, user-controlled endpoint; keep the durable
43
+ # registry bounded even when an agent sends oversized diagnostics.
44
+ safe_meta = {str(k)[:80]: str(v)[:500] for k, v in list(safe_meta.items())[:30]}
45
+ data[aid] = {"agent_id": aid, "label": str(label or aid)[:120],
46
+ "capabilities": [str(x)[:80] for x in (capabilities or [])][:30],
47
+ "task_id": str(task_id or "")[:100], "status": str(status or "idle")[:30],
48
+ "metadata": safe_meta,
49
+ "last_seen": now}
50
+ _save(data)
51
+ return data[aid]
52
+
53
+
54
+ def list_agents():
55
+ now = time.time()
56
+ with _LOCK:
57
+ rows = []
58
+ for row in _load().values():
59
+ item = dict(row)
60
+ try:
61
+ last_seen = float(item.get("last_seen") or 0)
62
+ except (TypeError, ValueError):
63
+ last_seen = 0.0
64
+ age = max(0, now - last_seen)
65
+ item["age_s"] = int(age)
66
+ item["online"] = age <= _STALE_S
67
+ rows.append(item)
68
+ return sorted(rows, key=lambda x: (not x["online"], x.get("label") or ""))
69
+
70
+
71
+ def remove(agent_id):
72
+ with _LOCK:
73
+ data = _load()
74
+ existed = str(agent_id or "") in data
75
+ data.pop(str(agent_id or ""), None)
76
+ _save(data)
77
+ return existed
@@ -186,6 +186,11 @@ def publish_pending_async(task_id, platform, auto_submit=False, force=False,
186
186
  task = store.get_task(task_id)
187
187
  if not task:
188
188
  return False, "任务不存在"
189
+ from .. import contracts
190
+ contract = contracts.get(task_id)
191
+ if ((contract and contract.get("approval_required")) or task.get("approval_required")) \
192
+ and not contracts.release_allowed(task_id):
193
+ return False, "任务需要先完成审批,才能发布产物"
189
194
  book = ledger.book_for(task_id, platform)
190
195
  if not book:
191
196
  return False, "该任务尚未在此平台建书,请先「创建作品」"
@@ -359,6 +364,12 @@ def due_tasks(now=None):
359
364
  ap = task.get("auto_publish")
360
365
  if not isinstance(ap, dict) or not ap.get("enabled"):
361
366
  continue
367
+ if ap.get("auto_submit"):
368
+ from .. import contracts
369
+ contract = contracts.get(task["id"])
370
+ if ((contract and contract.get("approval_required")) or
371
+ task.get("approval_required")) and not contracts.release_allowed(task["id"]):
372
+ continue
362
373
  if str(ap.get("time") or "") <= hhmm_now and fired.get(task["id"]) != today:
363
374
  plat = ap.get("platform")
364
375
  if plat and _book_ready(ledger.book_for(task["id"], plat)):
@@ -32,6 +32,47 @@ class FlowError(Exception):
32
32
  """流程失败:message 面向用户(含步骤序号与截图路径线索)。"""
33
33
 
34
34
 
35
+ class TitleDupError(FlowError):
36
+ """平台拒绝书名:重名(全平台书名唯一)。message 带平台提示原文。
37
+
38
+ 与普通流程失败分开成独立类型:manager 建书要按它触发「对账复用 →
39
+ 自动换名重试」,普通失败不能换名(换了也建不成)。"""
40
+
41
+ def __init__(self, hint):
42
+ super().__init__("平台拒绝书名(已存在同名作品):%s" % hint)
43
+ self.hint = hint
44
+
45
+
46
+ # 重名提示识别词(番茄/七猫建书页校验文案口径:「书名已存在」「该名称已被
47
+ # 使用」等)。核心词「已存在/已被使用/已被注册/重名」足够特异——建书页上
48
+ # 出现这些词几乎必然指向书名撞车;不加宽泛的「重复」,防误伤其它字段校验。
49
+ DUP_HINT_WORDS = ("已存在", "已被使用", "已被注册", "重名", "已被占用")
50
+
51
+
52
+ def _dup_probe_js():
53
+ # 抓页面上可见提示里命中重名关键词的那条(toast/表单校验/弹窗)。
54
+ # 选择器与 _page_error_text_js 同源;只回命中词的那条,空串=页面没喊重名。
55
+ return ("(words)=>{const sels='.arco-message,.arco-notification,"
56
+ "[class*=message],[class*=toast],[class*=error],[class*=alert],"
57
+ "[class*=tip],[class*=tooltip],[class*=valid]';"
58
+ "for(const e of document.querySelectorAll(sels)){"
59
+ "const r=e.getBoundingClientRect();"
60
+ "if(r.width<=0||r.height<=0)continue;"
61
+ "const x=(e.innerText||'').trim();"
62
+ "if(!x||x.length>160)continue;"
63
+ "for(const w of words){if(x.includes(w))return x;}}"
64
+ "return '';}")
65
+
66
+
67
+ def dup_hint(page, timeout=5):
68
+ """读页面上的重名提示文本;无/读失败返回空串。预检与事后探测共用。"""
69
+ try:
70
+ v = page.call(_dup_probe_js(), list(DUP_HINT_WORDS), timeout=timeout)
71
+ return v.strip() if isinstance(v, str) else ""
72
+ except Exception:
73
+ return ""
74
+
75
+
35
76
  def _click_match_js():
36
77
  # 点同时包含所有关键词的最小可见元素(长度最小者=最内层卡片)
37
78
  return ("(keys,maxLen)=>{"
@@ -212,11 +253,14 @@ def _tags_select_grouped(page, note, i, pairs):
212
253
 
213
254
 
214
255
  def run_flow(page, steps, values=None, config=None, auto_submit=False,
215
- shot=None, log=None):
256
+ shot=None, log=None, dup_check=False):
216
257
  """跑一个流程。values:fill 取值字典;config:平台 URL 等占位符来源。
217
258
 
218
259
  shot(name) → 截图落盘函数(manager 注入,路径含任务/平台维度);
219
- log(line) → 步骤日志函数(进发布记录的 log 字段)。返回执行到的步数。"""
260
+ log(line) → 步骤日志函数(进发布记录的 log 字段)。返回执行到的步数。
261
+ dup_check → 提交前探测页面重名提示(建书专用):平台实时校验喊「已
262
+ 存在」时点提交必然被拒、流程只会误报「未登录或改版」——在点击前抛
263
+ TitleDupError 让上层走换名重试,别白点(2026-09-30 马甲案)。"""
220
264
  values = values or {}
221
265
  config = config or {}
222
266
  log = log or (lambda s: None)
@@ -440,7 +484,7 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
440
484
  if sections:
441
485
  break
442
486
  time.sleep(1.0)
443
- if sections:
487
+ if sections and isinstance(sections, list):
444
488
  missing = {g for g, _ in pairs} - {s["g"] for s in sections}
445
489
  if missing:
446
490
  # 弹层是分组结构但组名对不上=平台目录/组名改了——
@@ -495,6 +539,13 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
495
539
  if shot:
496
540
  shot("ready-manual-submit")
497
541
  return i + 1
542
+ if dup_check:
543
+ # 事前预检:填表阶段平台实时校验(fill 派发过 change 事件,
544
+ # blur/输入即触发)喊了重名就别点了,点了也必被拒
545
+ hint = dup_hint(page)
546
+ if hint:
547
+ _fail_shot(shot, "step%d-title-dup" % i)
548
+ raise TitleDupError(hint)
498
549
  if st.get("text"): # 按按钮文本提交
499
550
  text = str(st["text"])
500
551
  note(i, "提交「%s」(真实鼠标事件)" % text)
@@ -382,6 +382,108 @@ def probe_form_async(plat):
382
382
 
383
383
 
384
384
  # ---------------------------------------------------------------- 动作:建书
385
+ _ALT_COUNT = 3 # 撞名后自动换名最多试 3 个候选
386
+ _ALT_TIMEOUT = 90 # 候选书名一次轻调用的墙钟(发布线程内,不能久等)
387
+ _TITLE_MAX = 15 # 两平台书名统一截 15 字(bookmeta._norm 同口径)
388
+
389
+
390
+ def _mech_titles(base):
391
+ """机械变体兜底(模型不可用时的最后手段):最小改动、不重构词——
392
+ 同音字(了→啦)、去「我的」前缀、加「纪事」尾。丑但能建成书,
393
+ 烧在发布线程里的几万字稿费比书名门面贵得多。"""
394
+ out, seen = [], {base}
395
+ for v in (base.replace("了", "啦", 1),
396
+ base[2:] if base.startswith("我的") else "",
397
+ (base + "纪事") if len(base) <= 13 else ""):
398
+ v = str(v or "").strip()[:_TITLE_MAX]
399
+ if len(v) >= 4 and v not in seen:
400
+ out.append(v)
401
+ seen.add(v)
402
+ return out
403
+
404
+
405
+ def _alt_titles(plat, data, task, logs):
406
+ """撞名候选队列:编排者基于原名+简介一次轻调用重出书名,失败静默
407
+ 降级机械变体——候选生成绝不能阻断建书主流程。"""
408
+ base = str(data.get("book_name") or "").strip()
409
+ if not base:
410
+ return []
411
+ out = []
412
+ try:
413
+ from .. import modelhub, planner, runner
414
+ orch = modelhub.resolve_orchestrator()
415
+ if orch:
416
+ prov, model = orch
417
+ prompt = (
418
+ "你是网文编辑。原书名《%s》在平台重名被拒,请为同一本书重起"
419
+ " %d 个新书名:每个 15 字内;保持原题材气质与钩子感;避开原名"
420
+ "的核心词组合与「我的XX藏不住」式高频套路组合;书名里带一个本"
421
+ "书独有的专名(人名/金手指/设定词)最不易再撞。只输出一个 "
422
+ "```json 字符串数组,如 [\"书名1\",\"书名2\"],不要输出其它内容。"
423
+ "\n\n## 简介(截选)\n%s"
424
+ % (base, _ALT_COUNT + 2, str(data.get("summary") or "")[:300]))
425
+ res = modelhub.chat(prov["id"], model, prompt,
426
+ max_tokens=600, timeout=_ALT_TIMEOUT)
427
+ planner._log_usage("publish", "create_book", task or {"id": ""}, res,
428
+ model=model,
429
+ provider=prov.get("name", prov.get("id", "")),
430
+ provider_id=prov.get("id", ""))
431
+ if res["ok"]:
432
+ arr = runner.extract_json(res.get("text") or "")
433
+ if isinstance(arr, dict):
434
+ arr = arr.get("titles") or arr.get("candidates") or []
435
+ if isinstance(arr, list):
436
+ for x in arr:
437
+ t = str(x or "").strip().replace("\n", " ")[:_TITLE_MAX]
438
+ if len(t) >= 2 and t != base and t not in out:
439
+ out.append(t)
440
+ if out:
441
+ logs.append("候选书名(编排者 %s):%s"
442
+ % (model, "、".join(out[:5])))
443
+ except Exception as e:
444
+ logs.append("候选书名生成降级(%s)" % str(e)[:100])
445
+ for v in _mech_titles(base):
446
+ if v not in out:
447
+ out.append(v)
448
+ return out[:_ALT_COUNT + 2]
449
+
450
+
451
+ def _sync_renamed_book(task_id, plat, task, data, new_name, logs):
452
+ """建书撞名自动换名后的全链路同步。
453
+
454
+ 只换建书表单里的字、bookmeta/大纲/归档还留旧名的话:下轮发章按旧书名
455
+ 对账、用户点「重生成作品信息」又把旧名改回去——马甲案同款错位复发。
456
+ 这里把 bookmeta data、归档 Markdown、最新批次大纲的 book_title 统一到
457
+ 新名(台账 title 由调用方 save_book 落)。任一环节失败只记日志,
458
+ 不拦建书成功主流程。"""
459
+ try:
460
+ from pathlib import Path as _Path
461
+ from .. import bookmeta as _bm
462
+ from .. import store as _store
463
+ data["book_name"] = new_name
464
+ cur = _store.get_task(task_id)
465
+ prev = ((cur or {}).get("book_meta") or {}).get(plat) or {}
466
+ if isinstance(prev.get("data"), dict):
467
+ payload = dict(prev)
468
+ payload["data"] = dict(prev["data"], book_name=new_name)
469
+ _store.set_book_meta(task_id, plat, payload)
470
+ fp = _Path(task.get("workdir") or "") / _bm.PLATFORMS[plat]["file"]
471
+ if str(fp.parent) and fp.parent.is_dir():
472
+ fp.write_text(_bm.render_markdown(cur, plat, payload["data"]),
473
+ encoding="utf-8")
474
+ logs.append("作品信息与归档已同步新书名《%s》" % new_name)
475
+ # 大纲书名:续写链继承与「一键重生成」素材都读最新批次大纲,
476
+ # 不同步会让下次重生成把书名改回旧名
477
+ for r in _store.task_runs(task_id):
478
+ o = r.get("outline")
479
+ if isinstance(o, dict) and o.get("chapters") and o.get("book_title"):
480
+ _store.update_run(r["id"], outline=dict(o, book_title=new_name))
481
+ logs.append("大纲书名已同步为《%s》" % new_name)
482
+ break
483
+ except Exception as e:
484
+ logs.append("书名同步部分失败(不拦建书):%s" % str(e)[:120])
485
+
486
+
385
487
  def _create_preflight(plat, data):
386
488
  """建书前置闸:平台表单对必填字段有硬校验(番茄简介 50-500 字),过不了
387
489
  校验时页面不跳转、流程只能误报「未登录或改版」——开浏览器之前先拦下,
@@ -462,33 +564,101 @@ def create_book_async(task_id, plat, auto_submit=False, force=False,
462
564
  metadata={"platform": plat, "action": "create_book"})
463
565
 
464
566
  def run():
567
+ final_name = book_name
465
568
  try:
466
569
  b, page = _open_page(plat)
467
- values = mod.values_create_book(data)
468
- steps = _with_tag_steps(load_flow(plat, "create_book"),
469
- mod.tag_groups(data), values, mod)
470
- flow.run_flow(page, steps, values=values, config=mod.CONFIG,
471
- auto_submit=auto_submit,
472
- shot=lambda n: page.screenshot(ledger.shot_path(plat, task_id, n)),
473
- log=logs.append)
474
- # book_id:创建成功后平台跳书籍详情/编辑器,从 URL 提取
475
- # (番茄 book-info/<id>;七猫 information?id=<id>)。url_any 过了
476
- # 但页面还在跳转链上时再等几轮;提取落空把最终 URL 记进日志,
477
- # 别再静默登记空 id(发章只能兜底找书,直达编辑器就废了)。
570
+ # 书名全平台唯一(2026-09-30 马甲案):撞名是建书常见死法,撞了
571
+ # 原样报「疑似未建成」会白烧几万字稿费。试名队列=原名+自动候选
572
+ # (编排者重出→机械变体兜底);同名书恰在本账号下时对账复用不换
573
+ # 名。人工提交模式(auto_submit=false)不自动换名——用户在页面
574
+ # 上自己点提交,撞名他自己看得见。
575
+ names = [book_name]
576
+ cands_ready = False
478
577
  book_id = ""
479
- try:
480
- for _try in range(6):
481
- m_url = re.search(r"book-info/(\d+)|information\?id=(\d+)",
482
- str(page.url() or ""))
483
- if m_url:
484
- book_id = m_url.group(1) or m_url.group(2)
578
+ i = 0
579
+ while i < len(names) and i < 1 + _ALT_COUNT + 2:
580
+ name = names[i]
581
+ i += 1
582
+ final_name = name
583
+ if i > 1:
584
+ logs.append("第 %d 次尝试,书名《%s》" % (i, name))
585
+ values = mod.values_create_book(dict(data, book_name=name))
586
+ steps = _with_tag_steps(load_flow(plat, "create_book"),
587
+ mod.tag_groups(data), values, mod)
588
+ try:
589
+ flow.run_flow(page, steps, values=values, config=mod.CONFIG,
590
+ auto_submit=auto_submit, dup_check=auto_submit,
591
+ shot=lambda n: page.screenshot(
592
+ ledger.shot_path(plat, task_id, n)),
593
+ log=logs.append)
594
+ except flow.TitleDupError as e:
595
+ # 事前预检命中:平台实时校验已喊重名,点提交必被拒
596
+ logs.append(str(e))
597
+ bid = _resolve_book_id(plat, page, {"title": name})
598
+ if bid:
599
+ book_id, final_name = bid, name
600
+ logs.append("同名书就在本账号下,已复用 book_id=%s" % bid)
485
601
  break
486
- time.sleep(0.8)
487
- if not book_id:
488
- logs.append("book_id 提取落空,最终页面:%s"
489
- % str(page.url() or "")[:120])
490
- except Exception:
491
- pass
602
+ if not cands_ready:
603
+ names = names + _alt_titles(plat, data, task, logs)
604
+ cands_ready = True
605
+ if i >= len(names):
606
+ break # 候选没准备出来(模型+机械全空)
607
+ continue
608
+ # book_id:创建成功后平台跳书籍详情/编辑器,从 URL 提取
609
+ # (番茄 book-info/<id>;七猫 information?id=<id>)。url_any 过了
610
+ # 但页面还在跳转链上时再等几轮;提取落空把最终 URL 记进日志,
611
+ # 别再静默登记空 id(发章只能兜底找书,直达编辑器就废了)。
612
+ try:
613
+ for _try in range(6):
614
+ m_url = re.search(r"book-info/(\d+)|information\?id=(\d+)",
615
+ str(page.url() or ""))
616
+ if m_url:
617
+ book_id = m_url.group(1) or m_url.group(2)
618
+ break
619
+ time.sleep(0.8)
620
+ if not book_id:
621
+ logs.append("book_id 提取落空,最终页面:%s"
622
+ % str(page.url() or "")[:120])
623
+ except Exception:
624
+ pass
625
+ if book_id:
626
+ break
627
+ if not auto_submit:
628
+ # 人工提交模式:按书名对账兜底即止,不自动换名
629
+ bid = _resolve_book_id(plat, page, {"title": name})
630
+ if bid:
631
+ book_id = bid
632
+ logs.append("book_id 提取落空,已按书名在平台对账找回 %s" % bid)
633
+ break
634
+ # 自动提交被平台静默拒绝(不跳转)时,toast 常喊重名——抓
635
+ # 回来判定,别落进「疑似未建成」的糊涂账
636
+ hint = ""
637
+ for _try in range(3):
638
+ hint = flow.dup_hint(page)
639
+ if hint:
640
+ break
641
+ time.sleep(1.0)
642
+ if hint:
643
+ logs.append("页面提示:%s" % hint[:120])
644
+ bid = _resolve_book_id(plat, page, {"title": name})
645
+ if bid:
646
+ book_id, final_name = bid, name
647
+ logs.append("同名书就在本账号下,已复用 book_id=%s" % bid)
648
+ break
649
+ if not cands_ready:
650
+ names = names + _alt_titles(plat, data, task, logs)
651
+ cands_ready = True
652
+ continue
653
+ # 非撞名失败:按书名对账兜底(原有逻辑),找不回就明说未建成
654
+ bid = _resolve_book_id(plat, page, {"title": name})
655
+ if bid:
656
+ book_id = bid
657
+ logs.append("book_id 提取落空,已按书名在平台对账找回 %s" % bid)
658
+ break
659
+ if book_id and final_name != book_name:
660
+ # 换名成功(或复用同名旧书):书名全链路同步到新名
661
+ _sync_renamed_book(task_id, plat, task, data, final_name, logs)
492
662
  operation_status = "confirmed" if book_id else "unknown"
493
663
  if book_id:
494
664
  operations.confirm(operation_id, remote_receipt=book_id,
@@ -496,18 +666,23 @@ def create_book_async(task_id, plat, auto_submit=False, force=False,
496
666
  else:
497
667
  operations.mark_unknown(
498
668
  operation_id,
499
- "建书流程完成但未取得远端 book_id,请先在平台核对后再重试",
669
+ "建书流程走完,但平台作品列表按书名未找到《%s》,书很可能未建成;"
670
+ "请到平台作品管理确认,若已存在可人工登记" % final_name,
500
671
  metadata={"platform": plat, "action": "create_book"})
501
- ledger.record(plat, "create_book", task_id=task_id, title=book_name,
672
+ ledger.record(plat, "create_book", task_id=task_id, title=final_name,
502
673
  book_id=book_id, ok=bool(book_id),
503
674
  error=((("" if book_id else
504
- "建书结果未知:未取得远端 book_id;请先对账\n")
675
+ "建书流程走完但未取得 book_id,且平台按书名未找到"
676
+ "《%s》,疑似未建成\n" % final_name)
505
677
  + "\n".join(logs))[:2000] or None),
506
678
  shot=str(ledger.shot_path(plat, task_id, "")),
507
679
  operation_id=operation_id, operation_status=operation_status,
508
680
  remote_receipt=book_id)
509
- ledger.save_book(task_id, plat, {"book_id": book_id, "title": book_name,
510
- "source": "create"})
681
+ if book_id:
682
+ # 未取得 id 不落台账:空条目会让界面亮「已建书」、校准盲跑,
683
+ # 而发章/下次建书各自有按书名对账的兜底,空条目只剩害处。
684
+ ledger.save_book(task_id, plat, {"book_id": book_id, "title": final_name,
685
+ "source": "create"})
511
686
  _set(plat, status="connected", error="")
512
687
  except Exception as e:
513
688
  operations.finish_exception(operation_id, e)
@@ -856,6 +1031,18 @@ def sync_published(task_id, plat, manual=False):
856
1031
  page = b.first_page(create=True)
857
1032
  except Exception as e:
858
1033
  return False, "浏览器未连接,点「连接平台」后再校准"
1034
+ if not str(book.get("book_id") or "").strip():
1035
+ # book_id 空的登记(人工登记/未验证建书结果)别拿去拼 URL 盲跑:
1036
+ # 空 id 会落到作品列表页,数章节必然 0 行,报一句误导人的「可能改版」。
1037
+ # 先按书名对账,找回补账再校准;找不回就明说书可能没建成。
1038
+ bid = _resolve_book_id(plat, page, book)
1039
+ if bid:
1040
+ book = dict(book, book_id=bid)
1041
+ ledger.save_book(task_id, plat, book) # 合并语义:保留 remote_*
1042
+ else:
1043
+ return False, ("平台作品列表按书名未找到《%s》,这本书可能尚未建成;"
1044
+ "请到平台作品管理确认,若已存在可人工登记"
1045
+ % (book.get("title") or ""))
859
1046
  try:
860
1047
  page.navigate(mod.chapter_manage_url(book), timeout=40)
861
1048
  time.sleep(4.0) # SPA 表格慢渲染
@@ -125,6 +125,36 @@ def chapter_manage_url(book):
125
125
  return CONFIG["book_manage"]
126
126
 
127
127
 
128
+ def resolve_book_id(page, book):
129
+ """登记缺 book_id 时按书名在作品管理页找回:点书名卡进详情,从 URL 提取。
130
+
131
+ 与番茄同款对账通道(fanqie.resolve_book_id);找不回返回空串不拦死。"""
132
+ import re
133
+ import time
134
+ from . import flow as _flow
135
+ title = str((book or {}).get("title") or "").strip()
136
+ if not title:
137
+ return ""
138
+ try:
139
+ page.navigate(CONFIG["book_manage"], timeout=30)
140
+ except Exception:
141
+ return ""
142
+ r = None
143
+ for _try in range(6): # 列表慢渲染
144
+ r = page.call(_flow._click_match_js(), [title], 300)
145
+ if (r or {}).get("ok"):
146
+ break
147
+ time.sleep(1.0)
148
+ if not (r or {}).get("ok"):
149
+ return ""
150
+ time.sleep(2.5) # 跳转收尾
151
+ m = re.search(r"manage\?id=(\d+)|draft\?id=(\d+)|information\?id=(\d+)",
152
+ str(page.url() or ""))
153
+ if not m:
154
+ return ""
155
+ return m.group(1) or m.group(2) or m.group(3) or ""
156
+
157
+
128
158
  def draft_url(book):
129
159
  """草稿箱页(发章真发布链中转站)。"""
130
160
  bid = str((book or {}).get("book_id") or "")