codebee 0.1.14 → 0.1.15
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 +10 -0
- package/README.md +21 -8
- package/app/core/automation.py +7 -0
- package/app/core/catalog.py +9 -1
- package/app/core/env_scrub.py +5 -3
- package/app/core/jobs.py +33 -11
- package/app/core/modelhub.py +4 -4
- package/app/core/paihang.py +62 -21
- package/app/core/pipeline.py +0 -3
- package/app/core/publish/browser.py +44 -1
- package/app/core/publish/fanqie.py +16 -0
- package/app/core/publish/flow.py +23 -1
- package/app/core/publish/flows-fanqie-calibrated.json +37 -0
- package/app/core/share_page.py +111 -0
- package/app/core/telemetry.py +3 -2
- package/app/core/tlsctx.py +16 -0
- package/app/core/zentao.py +1302 -0
- package/app/main.py +67 -4
- package/app/ui/app.js +142 -1
- package/app/ui/i18n.js +53 -0
- package/app/ui/index.html +49 -0
- package/app/ui/style.css +17 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,16 @@ CodeBee 的用户可感知变更记录。发布新版时:最上面加一节,
|
|
|
4
4
|
`<!-- relnotes:start -->…<!-- relnotes:end -->` 段(那段会被 `npm view` 的
|
|
5
5
|
README 元数据带回,供老版本在「发现新版本」时展示新版更新内容)。
|
|
6
6
|
|
|
7
|
+
## v0.1.15(2026-09-19)
|
|
8
|
+
|
|
9
|
+
- 禅道集成:定时扫描激活 Bug → 自动建修复任务 → 跑完自动合并 + resolve(fixed) + 报告回写 + 群通知(REST v1,SSRF 守卫;设置页「禅道」子页)
|
|
10
|
+
- 扫榜双源聚合:七猫+番茄 Web 版排行榜合并分析,跨平台重合题材单独标记(信号更强)
|
|
11
|
+
- 可分享报告页:运行报告导出为自包含单文件 HTML,发给任何人浏览器直接打开
|
|
12
|
+
- 出站 HTTPS 加固:统一证书校验上下文,修复 macOS python.org 发行版缺 CA 导致的全部外联失败(校验只增不减)
|
|
13
|
+
- 引擎调参卡:设置页可直接调压缩阈值/尾部保留/goal 轮数等引擎参数(schema 驱动,revision CAS 防并发覆盖)
|
|
14
|
+
- 发布浏览器 profile 迁至 ~/.codebee(登录态自动迁移),不再占用仓库目录与拖慢扫描
|
|
15
|
+
- 连载发布真机校准续:番茄编辑器结构校准、发章导航超时放宽、真实鼠标事件提交(合成点击被平台检测)
|
|
16
|
+
|
|
7
17
|
## v0.1.14(2026-09-19)
|
|
8
18
|
|
|
9
19
|
- 新任务类型「扫榜选材」:抓取七猫排行榜公开数据,AI 提炼热门题材 Top3/高频人设套路/差异化切入建议,直出选题洞察报告
|
package/README.md
CHANGED
|
@@ -21,14 +21,14 @@ Kimi Code、MiMo Code、Grok Build、Pi、DeepSeek Harness……),提供
|
|
|
21
21
|
不会把你的任务内容交给任何第三方。
|
|
22
22
|
|
|
23
23
|
<!-- relnotes:start -->
|
|
24
|
-
### 最新版更新内容(v0.1.
|
|
25
|
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
31
|
-
-
|
|
24
|
+
### 最新版更新内容(v0.1.15)
|
|
25
|
+
|
|
26
|
+
- **禅道集成**:定时扫描激活 Bug → 自动建修复任务 → 跑完自动合并 + resolve + 报告回写 + 群通知
|
|
27
|
+
- **扫榜选材(双源)**:七猫+番茄排行榜抓取聚合,AI 提炼热门题材/人设套路/差异化切入,跨平台重合题材单独标记
|
|
28
|
+
- **可分享报告页**:运行报告一键导出自包含单文件 HTML
|
|
29
|
+
- **引擎调参卡**:设置页直接调压缩阈值/goal 轮数等引擎参数(防并发覆盖)
|
|
30
|
+
- 出站 HTTPS 证书校验加固(修复部分 Mac 上的全外联失败);发布浏览器 profile 迁出仓库目录
|
|
31
|
+
- 群推送(钉钉/飞书/企微自动适配)与「扫榜选材」任务类型见 v0.1.14
|
|
32
32
|
<!-- relnotes:end -->
|
|
33
33
|
|
|
34
34
|
---
|
|
@@ -157,6 +157,19 @@ npm install -g codebee
|
|
|
157
157
|
用 Homebrew 装的 Node(prefix 在 `/opt/homebrew`)没有这个问题,可直接安装。
|
|
158
158
|
验证 `which codebee` 指向 `~/.npm-global/bin/codebee` 即配置成功。
|
|
159
159
|
|
|
160
|
+
### macOS:HTTPS 请求报 SSL 证书错误(CERTIFICATE_VERIFY_FAILED)
|
|
161
|
+
|
|
162
|
+
python.org 安装的 Python 不读 macOS 系统钥匙串,本机又没有可用的 CA 束时,
|
|
163
|
+
「获取模型列表 / 适配测试 / 插件市场」等所有 HTTPS 检测会报
|
|
164
|
+
`certificate verify failed: unable to get local issuer certificate`。
|
|
165
|
+
CodeBee 已内置兜底:出站请求自动加载 certifi 与 macOS 系统证书束
|
|
166
|
+
(`/etc/ssl/cert.pem`),多数机器无需任何操作。若仍报错,补齐证书后重试:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
python3 -m pip install --upgrade certifi # CodeBee 自动采用,装完即生效
|
|
170
|
+
# 或:Finder → 应用程序 → Python 3.x → 双击 Install Certificates.command
|
|
171
|
+
```
|
|
172
|
+
|
|
160
173
|
**方式二:源码运行(开发者)**
|
|
161
174
|
|
|
162
175
|
```bat
|
package/app/core/automation.py
CHANGED
|
@@ -387,6 +387,13 @@ def _tick():
|
|
|
387
387
|
log.info("automation: 定时发布触发 %d 个任务", n)
|
|
388
388
|
except Exception:
|
|
389
389
|
log.debug("automation: 定时发布检查跳过", exc_info=True)
|
|
390
|
+
# 禅道 Bug 扫描联动:fire_due 内部自节流(未启用/没到点零开销),
|
|
391
|
+
# 对账回写也在同一入口,这里只当调度宿主(同定时发布模式)。
|
|
392
|
+
try:
|
|
393
|
+
from . import zentao as _zentao
|
|
394
|
+
_zentao.fire_due()
|
|
395
|
+
except Exception:
|
|
396
|
+
log.debug("automation: 禅道扫描跳过", exc_info=True)
|
|
390
397
|
|
|
391
398
|
|
|
392
399
|
def _loop():
|
package/app/core/catalog.py
CHANGED
|
@@ -248,7 +248,7 @@ def npm_pkg_name(cmd):
|
|
|
248
248
|
|
|
249
249
|
|
|
250
250
|
def derive_uninstall(cmd):
|
|
251
|
-
"""从安装命令推导卸载命令(npm / winget / pip
|
|
251
|
+
"""从安装命令推导卸载命令(npm / winget / pip / uv / brew 本机渠道)。
|
|
252
252
|
|
|
253
253
|
卸载命令不单独维护一份,避免与安装命令不同步;认不出渠道返回 None,
|
|
254
254
|
此时条目可显式配置 uninstall 字段覆盖。
|
|
@@ -275,6 +275,14 @@ def derive_uninstall(cmd):
|
|
|
275
275
|
pkgs = [t for t in m.group(1).split() if not t.startswith("-")]
|
|
276
276
|
if pkgs:
|
|
277
277
|
return "uv tool uninstall %s" % pkgs[-1]
|
|
278
|
+
m = re.search(r"brew\s+install\b(.*)$", c)
|
|
279
|
+
if m:
|
|
280
|
+
# brew uninstall 按名字自动识别 formula/cask,--cask 这类 flag 丢了也
|
|
281
|
+
# 能卸对(Mac 渠道,AI 修复白名单已放行 brew install,编辑安装命令
|
|
282
|
+
# 也会填这种形态)
|
|
283
|
+
pkgs = [t for t in m.group(1).split() if not t.startswith("-")]
|
|
284
|
+
if pkgs:
|
|
285
|
+
return "brew uninstall %s" % pkgs[0]
|
|
278
286
|
return None
|
|
279
287
|
|
|
280
288
|
|
package/app/core/env_scrub.py
CHANGED
|
@@ -16,9 +16,10 @@ _DENY_RE = re.compile(r"(KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL)", re.IGNOR
|
|
|
16
16
|
|
|
17
17
|
# 白名单:精确匹配(不分大小写),用于覆盖黑名单。
|
|
18
18
|
_ALLOW_EXACT = frozenset({
|
|
19
|
-
#
|
|
20
|
-
|
|
21
|
-
"
|
|
19
|
+
# 系统/环境(TEMP/TMP/PATHEXT 等是 Windows 名,TMPDIR 是 macOS 每用户
|
|
20
|
+
# 临时目录——三边各留各的,多出的名字在别的平台上不存在、无副作用)
|
|
21
|
+
"PATH", "PATHEXT", "SYSTEMROOT", "TEMP", "TMP", "TMPDIR",
|
|
22
|
+
"USERPROFILE", "USERDNSDOMAIN", "HOMEPATH", "HOMEDRIVE",
|
|
22
23
|
"LANG", "LC_ALL", "LC_CTYPE",
|
|
23
24
|
"HOME", "SHELL", "TERM", "PYTHONIOENCODING",
|
|
24
25
|
"PYTHONPATH", "PYTHONUNBUFFERED",
|
|
@@ -38,6 +39,7 @@ _ALLOW_PREFIX = (
|
|
|
38
39
|
"TUTTI_", "ALLINONE_", "DSH_",
|
|
39
40
|
"LANG", "LC_", # locale 系列
|
|
40
41
|
"PYTHON", # PYTHONPATH / PYTHONIOENCODING 等
|
|
42
|
+
"XDG_", # XDG_CONFIG_HOME 等(macOS/Linux CLI 认它找配置/缓存落点)
|
|
41
43
|
"CHROME", "EDGE", "PLAYWRIGHT", # 本机测试工具
|
|
42
44
|
)
|
|
43
45
|
|
package/app/core/jobs.py
CHANGED
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
隔离,store 层有全局锁。目标并发数可在设置页调整;调小后多余线程在取到
|
|
6
6
|
新任务前自行退出,调大即时补齐。安装/升级失败时自动触发 AI 诊断修复:
|
|
7
7
|
由真实智能体读取失败日志与本机环境给出修正命令;仅当命令命中白名单前缀
|
|
8
|
-
(npm/winget/pip
|
|
8
|
+
(npm/winget/brew/pip 安装类,按平台取对应渠道)才自动执行,否则把建议命令
|
|
9
|
+
记录在运行记录里等人工确认。
|
|
9
10
|
"""
|
|
10
11
|
from __future__ import annotations
|
|
11
12
|
|
|
12
13
|
import queue
|
|
14
|
+
import sys
|
|
13
15
|
import threading
|
|
14
16
|
import traceback
|
|
15
17
|
|
|
@@ -27,10 +29,11 @@ WATCHDOG_INTERVAL_S = 60 # 队列看门狗巡检周期
|
|
|
27
29
|
WATCHDOG_STALE_S = 120 # queued 超过该秒数视为掉队(正常入队到被拿起 ≤5s)
|
|
28
30
|
_watchdog_started = False
|
|
29
31
|
|
|
30
|
-
AI_REPAIR_PROMPT = """你是环境工程师。在
|
|
32
|
+
AI_REPAIR_PROMPT = """你是环境工程师。在 __OS__ 上执行下面的安装命令失败了,请诊断原因并给出修正命令。
|
|
31
33
|
只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
|
|
32
34
|
{"diagnosis": "失败原因(一句话)", "command": "修正后的完整安装命令", "safe": true/false}
|
|
33
|
-
硬性约束:command 只能是本机包管理器的安装命令,前缀必须是 __ALLOW__
|
|
35
|
+
硬性约束:command 只能是本机包管理器的安装命令,前缀必须是 __ALLOW__ 之一,且不要带 sudo
|
|
36
|
+
(命令非交互执行,sudo 会挂死;brew/npm 本身也拒绝 sudo)。
|
|
34
37
|
给不出符合约束的安全命令时,safe 设为 false 且 command 留空。
|
|
35
38
|
|
|
36
39
|
## 失败的命令
|
|
@@ -43,14 +46,29 @@ __LOG__
|
|
|
43
46
|
__ENV__"""
|
|
44
47
|
|
|
45
48
|
# AI 修复命令白名单:只放行包管理器的安装类命令(提示词里的前缀清单由它生成,
|
|
46
|
-
#
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
# 两处永远不会漂移)。按平台分组:winget/py 启动器是 Windows 独有,brew/python3
|
|
50
|
+
# 是 macOS/Linux 渠道——Mac 上不放行 brew 的话,AI 给出正确的修正命令也会被拒。
|
|
51
|
+
_AI_REPAIR_ALLOW_WIN = ("npm install ", "winget install", "py -3.13 -m pip install",
|
|
52
|
+
"uv tool install")
|
|
53
|
+
_AI_REPAIR_ALLOW_UNIX = ("npm install ", "brew install", "python3 -m pip install",
|
|
54
|
+
"uv tool install")
|
|
55
|
+
AI_REPAIR_ALLOW = _AI_REPAIR_ALLOW_WIN # 兼容旧引用:Windows 本机即此表
|
|
49
56
|
|
|
50
57
|
|
|
51
|
-
def
|
|
58
|
+
def _repair_os_label(platform=None):
|
|
59
|
+
return {"win32": "Windows", "darwin": "macOS"}.get(platform or sys.platform, "Linux")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _repair_allow(platform=None):
|
|
63
|
+
if (platform or sys.platform) == "win32":
|
|
64
|
+
return _AI_REPAIR_ALLOW_WIN
|
|
65
|
+
return _AI_REPAIR_ALLOW_UNIX
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _repair_command_allowed(cmd, platform=None):
|
|
52
69
|
cmd = (cmd or "").strip()
|
|
53
|
-
return cmd.startswith(
|
|
70
|
+
return (cmd.startswith(_repair_allow(platform)) and "|" not in cmd
|
|
71
|
+
and "&" not in cmd and ">" not in cmd)
|
|
54
72
|
|
|
55
73
|
|
|
56
74
|
def configure(max_workers):
|
|
@@ -592,17 +610,21 @@ def _ai_repair(run_id, entry, ev, failed_cmd, orig_log):
|
|
|
592
610
|
except Exception:
|
|
593
611
|
log_tail = "(日志不可读)"
|
|
594
612
|
import shutil
|
|
613
|
+
is_win = sys.platform == "win32"
|
|
595
614
|
env_lines = [
|
|
596
|
-
"OS:
|
|
615
|
+
"OS: %s" % _repair_os_label(),
|
|
597
616
|
"node: %s" % (shutil.which("node") or "缺失"),
|
|
598
617
|
"npm: %s" % (shutil.which("npm") or "缺失"),
|
|
599
618
|
"pnpm: %s" % (shutil.which("pnpm") or "缺失"),
|
|
600
|
-
"python: %s" % (shutil.which("python") or "缺失"),
|
|
619
|
+
"python: %s" % (shutil.which("python3" if not is_win else "python") or "缺失"),
|
|
601
620
|
]
|
|
621
|
+
if not is_win:
|
|
622
|
+
env_lines.append("brew: %s" % (shutil.which("brew") or "缺失"))
|
|
602
623
|
prompt = (AI_REPAIR_PROMPT.replace("__CMD__", failed_cmd or "(未知)")
|
|
603
624
|
.replace("__LOG__", log_tail)
|
|
604
625
|
.replace("__ENV__", "\n".join(env_lines))
|
|
605
|
-
.replace("
|
|
626
|
+
.replace("__OS__", _repair_os_label())
|
|
627
|
+
.replace("__ALLOW__", " / ".join(p.strip() for p in _repair_allow())))
|
|
606
628
|
step, log_abs = store.add_step(run_id, "ai-repair", agent["id"], agent.get("label"),
|
|
607
629
|
note="自动诊断修复")
|
|
608
630
|
res = runner.run_agent(agent, prompt, readonly=True, timeout=300,
|
package/app/core/modelhub.py
CHANGED
|
@@ -251,9 +251,9 @@ def _fetch_models_http(base_url, api_key, protocol, allow_private=False):
|
|
|
251
251
|
last_err = (url + " 无模型列表接口(HTTP 404)——"
|
|
252
252
|
"模型以导入/手动添加为准,不影响对话调用")
|
|
253
253
|
else:
|
|
254
|
-
last_err = "%s → HTTP %s" % (url, e.code)
|
|
254
|
+
last_err = tlsctx.humanize("%s → HTTP %s" % (url, e.code))
|
|
255
255
|
except Exception as e:
|
|
256
|
-
last_err = "%s → %r" % (url, e)
|
|
256
|
+
last_err = tlsctx.humanize("%s → %r" % (url, e))
|
|
257
257
|
return None, last_err
|
|
258
258
|
|
|
259
259
|
|
|
@@ -2405,7 +2405,7 @@ def _post_json_http(url, headers, body, allow_private, timeout=20):
|
|
|
2405
2405
|
raw = resp.read(1024 * 1024)
|
|
2406
2406
|
return resp.status, json.loads(raw.decode("utf-8", "replace")), ""
|
|
2407
2407
|
except Exception as e:
|
|
2408
|
-
return 0, None, repr(e)[:300]
|
|
2408
|
+
return 0, None, tlsctx.humanize(repr(e)[:300])
|
|
2409
2409
|
|
|
2410
2410
|
|
|
2411
2411
|
def _sse_parse(proto, obj):
|
|
@@ -2506,7 +2506,7 @@ def _post_sse_http(url, headers, body, allow_private, timeout, proto, on_delta):
|
|
|
2506
2506
|
if sum(len(s) for s in parts) > 2 * 1024 * 1024: # 防失控
|
|
2507
2507
|
break
|
|
2508
2508
|
except Exception as e:
|
|
2509
|
-
return 0, "", None, repr(e)[:300]
|
|
2509
|
+
return 0, "", None, tlsctx.humanize(repr(e)[:300])
|
|
2510
2510
|
text = "".join(parts)
|
|
2511
2511
|
if not usage.get("total"):
|
|
2512
2512
|
usage["total"] = usage.get("input", 0) + usage.get("output", 0) + usage.get("cached", 0)
|
package/app/core/paihang.py
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
"""
|
|
2
|
+
"""扫榜选材:抓取公开排行榜页,产出选题分析素材。
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
数据源(双源聚合,任一失败不影响另一源):
|
|
5
|
+
- 七猫:www.qimao.com/paihang/(公开可抓,2026-09-19 实测 200/106KB)
|
|
6
|
+
- 番茄 Web 版:fanqienovel.com/rank(公开可抓,题材分类+书名在
|
|
7
|
+
中文 a 标签里;App 端接口有 SecuritySign/X-Argus 签名——那才是难路,
|
|
8
|
+
Web 版榜单页不需要签名,别走弯路)
|
|
9
|
+
|
|
10
|
+
下载走 curl 子进程(与 covergen 同款),解析用宽松正则——页面结构变了
|
|
11
|
+
宁可返回空(调用方回落普通直连提示词),不做脆弱的强解析。
|
|
8
12
|
"""
|
|
9
13
|
from __future__ import annotations
|
|
10
14
|
|
|
@@ -13,31 +17,37 @@ import re
|
|
|
13
17
|
|
|
14
18
|
log = logging.getLogger(__name__)
|
|
15
19
|
|
|
16
|
-
|
|
20
|
+
QIMAO_RANK_URL = "https://www.qimao.com/paihang/"
|
|
21
|
+
FANQIE_RANK_URL = "https://fanqienovel.com/rank"
|
|
17
22
|
_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
18
23
|
"(KHTML, like Gecko) Chrome/126 Safari/537.36")
|
|
19
24
|
|
|
20
25
|
# 页面 UI 噪音词(非书目/分类)
|
|
21
26
|
_NOISE = {"加入书架", "立即阅读", "开始阅读", "免费阅读", "全部", "分类", "排行",
|
|
22
|
-
"排行榜", "完本", "连载中", "阅读", "书城", "男生", "女生"
|
|
27
|
+
"排行榜", "完本", "连载中", "阅读", "书城", "男生", "女生",
|
|
28
|
+
"帮助中心", "作家助手", "客服", "登录", "注册"}
|
|
23
29
|
|
|
30
|
+
_CJK_RE = re.compile(r">([\u4e00-\u9fa5]{2,12})<")
|
|
24
31
|
|
|
25
|
-
def fetch_rank_items(limit=40):
|
|
26
|
-
"""抓七猫排行榜页并提取中文元素材(书名/分类混排,去 UI 噪音)。
|
|
27
32
|
|
|
28
|
-
|
|
29
|
-
|
|
33
|
+
def _curl_text(url):
|
|
34
|
+
"""curl 子进程抓页面文本;失败返回空串。"""
|
|
30
35
|
from . import runner
|
|
31
36
|
try:
|
|
32
37
|
r = runner.run_process(
|
|
33
|
-
argv=["curl", "-sS", "--max-time", "20", "-A", _UA,
|
|
38
|
+
argv=["curl", "-sS", "--max-time", "20", "-A", _UA, url],
|
|
34
39
|
timeout=30)
|
|
35
40
|
except Exception:
|
|
36
|
-
return
|
|
41
|
+
return ""
|
|
37
42
|
if not r.get("ok"):
|
|
38
|
-
return
|
|
43
|
+
return ""
|
|
44
|
+
return r.get("stdout") or ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _extract_cjk(html, limit):
|
|
48
|
+
"""从 HTML 提取中文元素材(去 UI 噪音、保序去重)。"""
|
|
39
49
|
items, seen = [], set()
|
|
40
|
-
for t in
|
|
50
|
+
for t in _CJK_RE.findall(html or ""):
|
|
41
51
|
if t in _NOISE or t in seen:
|
|
42
52
|
continue
|
|
43
53
|
seen.add(t)
|
|
@@ -47,18 +57,49 @@ def fetch_rank_items(limit=40):
|
|
|
47
57
|
return items
|
|
48
58
|
|
|
49
59
|
|
|
60
|
+
def fetch_qimao_rank(limit=30):
|
|
61
|
+
"""七猫排行榜:返回元素列表,抓取失败返回 []。"""
|
|
62
|
+
return _extract_cjk(_curl_text(QIMAO_RANK_URL), limit)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def fetch_fanqie_rank(limit=30):
|
|
66
|
+
"""番茄 Web 版榜单:返回元素列表,抓取失败返回 []。"""
|
|
67
|
+
return _extract_cjk(_curl_text(FANQIE_RANK_URL), limit)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def fetch_rank_items(limit=40):
|
|
71
|
+
"""双源聚合:七猫+番茄各抓一份(去重合并,七猫在前)。
|
|
72
|
+
|
|
73
|
+
两源都失败才返回 []——单源挂了另一源仍可用,扫榜永不因单点挡任务。"""
|
|
74
|
+
qm = fetch_qimao_rank(limit)
|
|
75
|
+
fq = fetch_fanqie_rank(limit)
|
|
76
|
+
merged, seen = [], set(qm)
|
|
77
|
+
for t in fq:
|
|
78
|
+
if t not in seen:
|
|
79
|
+
seen.add(t)
|
|
80
|
+
merged.append(t)
|
|
81
|
+
return qm + merged
|
|
82
|
+
|
|
83
|
+
|
|
50
84
|
def rank_scan_prompt(goal):
|
|
51
|
-
"""
|
|
52
|
-
|
|
53
|
-
|
|
85
|
+
"""组装扫榜选材分析提示词。双源都空返回 None(调用方回落直连提示词)。"""
|
|
86
|
+
qm = fetch_qimao_rank(30)
|
|
87
|
+
fq = fetch_fanqie_rank(30)
|
|
88
|
+
qm = [t for t in qm if t not in fq] # 双源重合的只留七猫份
|
|
89
|
+
if not qm and not fq:
|
|
54
90
|
return None
|
|
55
|
-
|
|
91
|
+
blocks = []
|
|
92
|
+
if qm:
|
|
93
|
+
blocks.append("### 七猫排行榜素材\n" + "、".join(qm))
|
|
94
|
+
if fq:
|
|
95
|
+
blocks.append("### 番茄排行榜素材\n" + "、".join(fq))
|
|
96
|
+
material = "\n\n".join(blocks)
|
|
56
97
|
return (
|
|
57
|
-
"
|
|
98
|
+
"你是网文选题分析师。以下是刚刚抓取的两个平台排行榜页面的书目与分类素材:\n\n"
|
|
58
99
|
"## 榜单元素材\n%s\n\n"
|
|
59
100
|
"## 用户想写的方向\n%s\n\n"
|
|
60
101
|
"## 你的产出(Markdown 报告)\n"
|
|
61
|
-
"1. **热门题材 Top3
|
|
102
|
+
"1. **热门题材 Top3**:各自的共同特征与上榜代表书目(跨平台重合的题材单独点出——双平台都热说明是真风口)\n"
|
|
62
103
|
"2. **高频人设/套路总结**:3-5 条,点名反复出现的元素\n"
|
|
63
104
|
"3. **差异化切入建议**:2-3 个,结合用户方向给出「题材+人设」组合与一句话理由\n"
|
|
64
105
|
"直接输出分析报告,不要复述素材清单,不要输出与报告无关的内容。"
|
package/app/core/pipeline.py
CHANGED
|
@@ -1472,9 +1472,6 @@ def _plot_modules(workdir):
|
|
|
1472
1472
|
"""剧情模块库(oh-story 拆文沉淀式):工作目录里的 plot-modules.md
|
|
1473
1473
|
(可复用的桥段/冲突/爽点/名场面素材模块),作者手工维护,每章起草与
|
|
1474
1474
|
评审前自动注入。不存在/为空返回 ""——约定式功能,零配置零噪音。"""
|
|
1475
|
-
p = os.path.abspath(os.path.join(str(workdir or ""), MODULES_FILE))
|
|
1476
|
-
if not _inside(workdir, p) or not os.path.isfile(p):
|
|
1477
|
-
return ""
|
|
1478
1475
|
try:
|
|
1479
1476
|
txt = _read_text_any_enc(p)[:_MODULES_MAX_CHARS].strip()
|
|
1480
1477
|
except OSError:
|
|
@@ -78,6 +78,39 @@ def _free_port():
|
|
|
78
78
|
return port
|
|
79
79
|
|
|
80
80
|
|
|
81
|
+
def _debug_ports_for_profile(user_data_dir):
|
|
82
|
+
"""扫描本机浏览器进程命令行,返回使用该 profile 的主实例的调试端口。
|
|
83
|
+
|
|
84
|
+
跨平台:Windows 走 wmic(PS 启动太慢),POSIX 走 ps。只认主进程
|
|
85
|
+
(--type= 子进程没有调试端口)。路径按小写+正反斜杠归一后比对。"""
|
|
86
|
+
import subprocess as _sp
|
|
87
|
+
try:
|
|
88
|
+
if sys.platform == "win32":
|
|
89
|
+
out = _sp.check_output(
|
|
90
|
+
["wmic", "process", "where", "Name='msedge.exe'", "get", "CommandLine"],
|
|
91
|
+
stderr=_sp.DEVNULL, timeout=12).decode("utf-8", "replace")
|
|
92
|
+
lines = out.splitlines()
|
|
93
|
+
else:
|
|
94
|
+
out = _sp.check_output(["ps", "-axo", "command"], timeout=12).decode()
|
|
95
|
+
lines = [l for l in out.splitlines()
|
|
96
|
+
if "msedge" in l or "chrome" in l or "chromium" in l]
|
|
97
|
+
except Exception:
|
|
98
|
+
return []
|
|
99
|
+
key = str(user_data_dir).replace("\\", "/").strip("/").lower()
|
|
100
|
+
ports = []
|
|
101
|
+
for ln in lines:
|
|
102
|
+
ln = ln.strip()
|
|
103
|
+
if not ln or "--type=" in ln or "--remote-debugging-port=" not in ln:
|
|
104
|
+
continue
|
|
105
|
+
norm = ln.replace("\\\\", "/").replace("\\", "/").lower()
|
|
106
|
+
if key not in norm:
|
|
107
|
+
continue
|
|
108
|
+
m = re.search(r"--remote-debugging-port=(\d+)", ln)
|
|
109
|
+
if m:
|
|
110
|
+
ports.append(int(m.group(1)))
|
|
111
|
+
return ports
|
|
112
|
+
|
|
113
|
+
|
|
81
114
|
class Browser:
|
|
82
115
|
"""一个浏览器子进程(一个 profile 一个实例)+ 它的调试端口。"""
|
|
83
116
|
|
|
@@ -106,7 +139,17 @@ class Browser:
|
|
|
106
139
|
except OSError as e:
|
|
107
140
|
raise BrowserError("浏览器启动失败:%s" % e)
|
|
108
141
|
if not self._wait_ready(15.0):
|
|
109
|
-
|
|
142
|
+
# Edge 对已有实例的 profile:新进程转交参数后秒退,调试端口是
|
|
143
|
+
# 老实例自己的(甚至自选的,真机实测指定 59852 实际跑 56394)。
|
|
144
|
+
# 等自己指定的端口必然超时——扫进程命令行找该 profile 的真端口接管。
|
|
145
|
+
for p in _debug_ports_for_profile(self.user_data_dir):
|
|
146
|
+
if p != self.port and port_alive(p, timeout=2.0):
|
|
147
|
+
self.port = p
|
|
148
|
+
self.proc = None # 老实例不是本对象起的:close 不杀它
|
|
149
|
+
return
|
|
150
|
+
raise BrowserError(
|
|
151
|
+
"浏览器调试端口 15 秒内没就绪:该平台可能已有浏览器实例在跑但"
|
|
152
|
+
"端口探测不到——关掉它的所有窗口后重试,或重启电脑后首次连接")
|
|
110
153
|
|
|
111
154
|
# ------------------------------------------------------------ 生命周期
|
|
112
155
|
def _wait_ready(self, timeout):
|
|
@@ -96,3 +96,19 @@ def tag_groups(meta):
|
|
|
96
96
|
if isinstance(v, list) and v:
|
|
97
97
|
out.append((key, [str(x) for x in v]))
|
|
98
98
|
return out
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def editor_url(book):
|
|
102
|
+
"""章节编辑器直达(真机实测):/main/writer/<book_id>/publish/。"""
|
|
103
|
+
bid = str((book or {}).get("book_id") or "")
|
|
104
|
+
if bid:
|
|
105
|
+
return "https://fanqienovel.com/main/writer/%s/publish/" % bid
|
|
106
|
+
return CONFIG["home"]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def chapter_manage_url(book):
|
|
110
|
+
"""章节管理页(上线验证用)。"""
|
|
111
|
+
bid = str((book or {}).get("book_id") or "")
|
|
112
|
+
if bid:
|
|
113
|
+
return "https://fanqienovel.com/main/writer/chapter-manage/" + bid
|
|
114
|
+
return CONFIG["home"] + "book-manage"
|
package/app/core/publish/flow.py
CHANGED
|
@@ -109,7 +109,8 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
|
|
|
109
109
|
for k, v in (values or {}).items(): # editor_url/draft_url 等任务级占位
|
|
110
110
|
url = url.replace("{%s}" % k, str(v))
|
|
111
111
|
note(i, "打开 %s" % url)
|
|
112
|
-
|
|
112
|
+
# 重页(编辑器/管理列表)可按步放宽;缺省 45s(30s 对慢网偏紧)
|
|
113
|
+
page.navigate(url, timeout=float(st.get("timeout") or 45))
|
|
113
114
|
elif act == "wait":
|
|
114
115
|
note(i, "等待 %s" % st["sel"])
|
|
115
116
|
page.wait_for(st["sel"], timeout=float(st.get("timeout") or 12))
|
|
@@ -194,6 +195,27 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
|
|
|
194
195
|
time.sleep(0.9)
|
|
195
196
|
if not (r or {}).get("ok"):
|
|
196
197
|
raise FlowError((r or {}).get("err") or "click_in 失败")
|
|
198
|
+
elif act == "click_arrow":
|
|
199
|
+
# 展开下拉按钮组(番茄「下一步▾」):点目标按钮组右缘 10px
|
|
200
|
+
note(i, "展开下拉箭头")
|
|
201
|
+
r = page.call(
|
|
202
|
+
"(t)=>{"
|
|
203
|
+
"const vis=e=>e.getBoundingClientRect().width>0;"
|
|
204
|
+
"const btn=[...document.querySelectorAll('button')].find(e=>"
|
|
205
|
+
"vis(e)&&(e.innerText||'').trim().includes(t));"
|
|
206
|
+
"if(!btn)return{ok:false,err:'按钮未找到'};"
|
|
207
|
+
"const host=btn.closest('[class*=group],[class*=dropdown]')||btn.parentElement;"
|
|
208
|
+
"const r=host.getBoundingClientRect();"
|
|
209
|
+
"return{ok:true,x:Math.round(r.x+r.width-10),y:Math.round(r.y+r.height/2)};}", str(st.get("target") or "下一步"))
|
|
210
|
+
if not (r or {}).get("ok"):
|
|
211
|
+
raise FlowError((r or {}).get("err") or "箭头定位失败")
|
|
212
|
+
page.send("Input.dispatchMouseEvent",
|
|
213
|
+
{"type": "mousePressed", "x": r["x"], "y": r["y"],
|
|
214
|
+
"button": "left", "clickCount": 1}, timeout=8.0)
|
|
215
|
+
page.send("Input.dispatchMouseEvent",
|
|
216
|
+
{"type": "mouseReleased", "x": r["x"], "y": r["y"],
|
|
217
|
+
"button": "left", "clickCount": 1}, timeout=8.0)
|
|
218
|
+
time.sleep(float(st.get("settle") or 1.2))
|
|
197
219
|
elif act == "click_match":
|
|
198
220
|
# 关键词选块(站点卡片等):点同时包含所有关键词的最小元素
|
|
199
221
|
keys = [str(x) for x in (st.get("any") or []) if str(x).strip()]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_note": "番茄真机校准(2026-09-19 凌晨实测编辑器全结构;登录态随浏览器崩溃丢失,发布按钮链最后一步待登录后复验):编辑器=/main/writer/<book_id>/publish/ 直达;章节号=input.serial-input(纯数字);标题=div.serial-editor-input-hint input(≥5字,30字上限);正文=.ProseMirror(ProseMirror 编辑器,≥1000字);「下一步」=button(保存+校验,右侧箭头展开下拉菜单含「定时发布」);建书入口=book-manage 页「创建新书」(表单未实测)。",
|
|
3
|
+
"check_login": [
|
|
4
|
+
{"do": "navigate", "url": "{home}"},
|
|
5
|
+
{"do": "url_any", "any": ["fanqienovel.com"]}
|
|
6
|
+
],
|
|
7
|
+
"upload_chapter": [
|
|
8
|
+
{"do": "navigate", "url": "about:blank"},
|
|
9
|
+
{"do": "navigate", "url": "{editor_url}"},
|
|
10
|
+
{"do": "url_any", "any": ["fanqienovel.com"]},
|
|
11
|
+
{"do": "wait", "sel": ".ProseMirror", "timeout": 20},
|
|
12
|
+
{"do": "fill", "sel": "input.serial-input, input.byte-input", "key": "chapter_no"},
|
|
13
|
+
{"do": "fill", "sel": "div.serial-editor-input-hint input", "key": "chapter_title"},
|
|
14
|
+
{"do": "fill", "sel": ".ProseMirror", "key": "chapter_body"},
|
|
15
|
+
{"do": "shot", "name": "chapter-filled"},
|
|
16
|
+
{"do": "click_real", "text": "下一步", "scope": "button", "tries": 15},
|
|
17
|
+
{"do": "click_arrow", "note": "展开下一步下拉(箭头在按钮组右缘)"},
|
|
18
|
+
{"do": "click_real", "text": "定时发布", "tries": 12, "optional": True},
|
|
19
|
+
{"do": "click_real", "text": "发布", "tries": 12, "optional": True},
|
|
20
|
+
{"do": "verify", "url": "{chapter_manage_url}", "any": ["{chapter_title}"], "settle": 6}
|
|
21
|
+
],
|
|
22
|
+
"probe_form": [
|
|
23
|
+
{"do": "navigate", "url": "about:blank"},
|
|
24
|
+
{"do": "navigate", "url": "https://fanqienovel.com/main/writer/book-manage"},
|
|
25
|
+
{"do": "url_any", "any": ["fanqienovel.com"]},
|
|
26
|
+
{"do": "click_text", "text": "创建新书", "contains": true},
|
|
27
|
+
{"do": "probe", "note": "建书表单(待登录后校准)"}
|
|
28
|
+
],
|
|
29
|
+
"create_book": [
|
|
30
|
+
{"do": "navigate", "url": "about:blank"},
|
|
31
|
+
{"do": "navigate", "url": "https://fanqienovel.com/main/writer/book-manage"},
|
|
32
|
+
{"do": "url_any", "any": ["fanqienovel.com"]},
|
|
33
|
+
{"do": "click_text", "text": "创建新书", "contains": true},
|
|
34
|
+
{"do": "probe", "note": "建书表单"},
|
|
35
|
+
{"do": "shot", "name": "create-book-form"}
|
|
36
|
+
]
|
|
37
|
+
}
|