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.
- package/CHANGELOG.md +12 -0
- package/README.md +4 -4
- package/app/core/analytics.py +38 -0
- package/app/core/bookmeta.py +0 -14
- package/app/core/contracts.py +255 -0
- package/app/core/error_codes.py +9 -0
- package/app/core/flows.py +1 -1
- package/app/core/health.py +14 -1
- package/app/core/jobs.py +54 -1
- package/app/core/manager.py +106 -17
- package/app/core/market.py +2 -2
- package/app/core/modelhub.py +104 -9
- package/app/core/openapi.py +154 -0
- package/app/core/paihang.py +8 -1
- package/app/core/pipeline.py +41 -0
- package/app/core/planner.py +2 -0
- package/app/core/plugins.py +34 -0
- package/app/core/presence.py +77 -0
- package/app/core/publish/auto.py +11 -0
- package/app/core/publish/flow.py +54 -3
- package/app/core/publish/manager.py +215 -28
- package/app/core/publish/qimao.py +30 -0
- package/app/core/retrieval.py +137 -0
- package/app/core/runner.py +85 -2
- package/app/core/skillpacks/fanqie-novel.md +66 -48
- package/app/core/skillpacks/qimao-signing.md +70 -59
- package/app/core/store.py +72 -0
- package/app/core/story_tracking.py +241 -0
- package/app/main.py +205 -48
- package/app/mcp_server.py +75 -6
- package/app/ui/app.js +390 -101
- package/app/ui/i18n.js +36 -7
- package/app/ui/index.html +8 -3
- package/app/ui/style.css +7059 -7040
- package/package.json +1 -1
package/app/core/paihang.py
CHANGED
|
@@ -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 "(用户未指定方向,按大盘热门分析)"))
|
package/app/core/pipeline.py
CHANGED
|
@@ -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
|
# 失败静默——账本是增强不是硬依赖。
|
package/app/core/planner.py
CHANGED
|
@@ -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
|
- 每章都写清“主角主动选择 → 阻碍 → 局面变化 → 章末钩子”,禁止水字数的日常流水账和硬旁白推剧情;
|
package/app/core/plugins.py
CHANGED
|
@@ -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
|
package/app/core/publish/auto.py
CHANGED
|
@@ -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)):
|
package/app/core/publish/flow.py
CHANGED
|
@@ -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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
"
|
|
669
|
+
"建书流程走完,但平台作品列表按书名未找到《%s》,书很可能未建成;"
|
|
670
|
+
"请到平台作品管理确认,若已存在可人工登记" % final_name,
|
|
500
671
|
metadata={"platform": plat, "action": "create_book"})
|
|
501
|
-
ledger.record(plat, "create_book", task_id=task_id, title=
|
|
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
|
-
"
|
|
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
|
-
|
|
510
|
-
|
|
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 "")
|