codebee 0.1.66 → 0.1.68

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 (116) hide show
  1. package/CHANGELOG.md +543 -520
  2. package/LICENSE +21 -21
  3. package/README.md +11 -7
  4. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +88 -0
  5. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +126 -0
  6. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  7. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +210 -0
  8. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  9. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  10. package/app/.playwright-cli/run-detail-after.png +0 -0
  11. package/app/core/acceptance.py +100 -0
  12. package/app/core/aiflavor.py +197 -197
  13. package/app/core/artifacts.py +148 -148
  14. package/app/core/assets.py +110 -0
  15. package/app/core/attachments.py +623 -532
  16. package/app/core/automation.py +758 -758
  17. package/app/core/backup.py +470 -470
  18. package/app/core/beekeeper.py +183 -183
  19. package/app/core/board.py +209 -209
  20. package/app/core/bookmeta.py +930 -796
  21. package/app/core/bookmeta_catalog.py +180 -180
  22. package/app/core/branching.py +137 -137
  23. package/app/core/builtin_agent.py +1234 -1234
  24. package/app/core/capability.py +144 -144
  25. package/app/core/catalog.py +42 -0
  26. package/app/core/chaptersafe.py +32 -32
  27. package/app/core/cleanup.py +303 -303
  28. package/app/core/compaction.py +201 -201
  29. package/app/core/covergen.py +356 -356
  30. package/app/core/defectretro.py +43 -0
  31. package/app/core/diagnostics.py +114 -114
  32. package/app/core/dispatch.py +173 -173
  33. package/app/core/dispatch_log.py +128 -113
  34. package/app/core/env_scrub.py +85 -85
  35. package/app/core/error_codes.py +23 -23
  36. package/app/core/errorlog.py +137 -137
  37. package/app/core/evaluation.py +174 -174
  38. package/app/core/failure_notify.py +290 -290
  39. package/app/core/flows.py +48 -2
  40. package/app/core/goal_service.py +167 -167
  41. package/app/core/health.py +374 -374
  42. package/app/core/history.py +38 -38
  43. package/app/core/hooks.py +162 -162
  44. package/app/core/jobs.py +46 -26
  45. package/app/core/knowledge.py +57 -3
  46. package/app/core/manager.py +2122 -2115
  47. package/app/core/market.py +362 -362
  48. package/app/core/market_remote.py +124 -26
  49. package/app/core/mocks.py +64 -64
  50. package/app/core/modelhub.py +51 -3
  51. package/app/core/notify.py +106 -106
  52. package/app/core/operations.py +228 -0
  53. package/app/core/paihang.py +138 -109
  54. package/app/core/paths.py +2 -2
  55. package/app/core/pipeline.py +221 -12
  56. package/app/core/planner.py +6 -1
  57. package/app/core/portguard.py +111 -111
  58. package/app/core/preview.py +293 -293
  59. package/app/core/publish/__init__.py +7 -7
  60. package/app/core/publish/auto.py +367 -367
  61. package/app/core/publish/browser.py +539 -533
  62. package/app/core/publish/fanqie.py +159 -128
  63. package/app/core/publish/flow.py +440 -432
  64. package/app/core/publish/flows-fanqie-calibrated.json +332 -332
  65. package/app/core/publish/flows-qimao-calibrated.json +7 -3
  66. package/app/core/publish/ledger.py +203 -198
  67. package/app/core/publish/manager.py +692 -623
  68. package/app/core/publish/qimao.py +141 -141
  69. package/app/core/publish/ws.py +139 -139
  70. package/app/core/redact.py +38 -38
  71. package/app/core/repeat_guard.py +129 -129
  72. package/app/core/revisions.py +51 -0
  73. package/app/core/router.py +398 -398
  74. package/app/core/runner.py +2067 -2050
  75. package/app/core/selfupdate.py +384 -384
  76. package/app/core/session_log.py +161 -161
  77. package/app/core/sessions.py +312 -312
  78. package/app/core/settings.py +149 -149
  79. package/app/core/settings_schema.py +262 -262
  80. package/app/core/share_page.py +114 -114
  81. package/app/core/skill_scan.py +122 -114
  82. package/app/core/skillpacks/fanqie-novel.md +80 -80
  83. package/app/core/skillpacks/market/character-bible.md +66 -66
  84. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  85. package/app/core/skillpacks/market/git-workflow.md +57 -57
  86. package/app/core/skillpacks/market/release-notes.md +72 -72
  87. package/app/core/skillpacks/market/weekly-report.md +71 -71
  88. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  89. package/app/core/skills.py +109 -2
  90. package/app/core/step_runner.py +132 -132
  91. package/app/core/store.py +278 -41
  92. package/app/core/task_compile.py +199 -199
  93. package/app/core/telemetry.py +292 -292
  94. package/app/core/tlsctx.py +64 -64
  95. package/app/core/token_meter.py +147 -147
  96. package/app/core/tracing.py +144 -0
  97. package/app/core/upstream.py +41 -41
  98. package/app/core/usage.py +862 -858
  99. package/app/core/volumes.py +389 -355
  100. package/app/core/wxdigest.py +858 -858
  101. package/app/core/zentao.py +2465 -2447
  102. package/app/main.py +2981 -2838
  103. package/app/pick_dialog.py +110 -110
  104. package/app/ui/app.js +14399 -13994
  105. package/app/ui/artdiff.js +74 -0
  106. package/app/ui/board.html +162 -162
  107. package/app/ui/board.js +305 -305
  108. package/app/ui/i18n.js +2770 -2716
  109. package/app/ui/icons/bee.svg +79 -79
  110. package/app/ui/index.html +6 -0
  111. package/app/ui/manifest.json +16 -16
  112. package/app/ui/qrcode.js +2297 -2297
  113. package/app/ui/style.css +96 -24
  114. package/bin/tutti.js +147 -147
  115. package/package.json +46 -46
  116. package/tools/wx_reader.py +261 -261
@@ -1,141 +1,141 @@
1
- # -*- coding: utf-8 -*-
2
- """七猫作者后台的发布流程定义(默认表:选择器为合理推测,待实测校准)。
3
-
4
- 校准方式同番茄:data/publish/flows-qimao.json 覆盖 + 「探测表单」dump。
5
- URL 依据:tools/qimao_bookmeta.json 的 source 记录 2026-09-17 登录态实抓
6
- zuozhe.qimao.com/api/pc/v1/book/book-option——作者后台 PC 端在 zuozhe.qimao.com。
7
-
8
- 七猫建书表单结构(同次实抓):频道级联(男/女生 → 一级 → 二级)+ 四组标签
9
- (风格/角色/情节/背景,每组 1-3 个)+ 文本字段。
10
- """
11
- from __future__ import annotations
12
-
13
- CONFIG = {
14
- "id": "qimao",
15
- "label": "七猫",
16
- "home": "https://zuozhe.qimao.com/",
17
- # 作品管理页:建书入口(「新建小说」按钮所在);建书向导第二步表单在其下
18
- "book_manage": "https://zuozhe.qimao.com/front/book-manage",
19
- # 编辑器顶栏明示「正文字数最少 1000 字」——不足时「立即发布」被静默拦截
20
- "min_chapter_chars": 1000,
21
- "login_url_marks": ["login", "signin", "passport", "sso"],
22
- }
23
-
24
- CREATE_BOOK = [
25
- {"do": "navigate", "url": "{home}"},
26
- {"do": "url_any", "any": ["qimao.com"]},
27
- {"do": "click_text", "text": "创建作品", "contains": True, "scope": "button,a,[role=button],span"},
28
- {"do": "probe", "note": "建书表单"},
29
- {"do": "wait", "sel": "input[placeholder*='作品'],input[placeholder*='书名'],input[maxlength]", "timeout": 10},
30
- {"do": "fill", "sel": "input[placeholder*='作品'],input[placeholder*='书名']", "key": "title"},
31
- {"do": "fill", "sel": "textarea[placeholder*='简介'],textarea", "key": "summary"},
32
- {"do": "fill", "sel": "input[placeholder*='主角']", "key": "protagonist"},
33
- # 频道级联:目标读者 → 一级分类 → 二级分类(值来自 bookmeta 的级联字段)
34
- {"do": "click_text", "text": "{target_reader}", "contains": False,
35
- "scope": "[class*=channel] label,label,span"},
36
- {"do": "click_text", "text": "{category_main}", "contains": False,
37
- "scope": "[class*=categor] li,option,span"},
38
- {"do": "click_text", "text": "{category_sub}", "contains": False,
39
- "scope": "[class*=categor] li,option,span"},
40
- {"do": "shot", "name": "create-book-filled"},
41
- {"do": "submit", "sel": "button[class*=submit],button[class*=primary],button[class*=confirm]"},
42
- ]
43
-
44
- UPLOAD_CHAPTER = [
45
- {"do": "navigate", "url": "{home}"},
46
- {"do": "url_any", "any": ["qimao.com"]},
47
- {"do": "click_text", "text": "{book_name}", "contains": True,
48
- "scope": "a,span,div[class*=title],div[class*=book]"},
49
- {"do": "click_text", "text": "新建章节", "contains": True, "scope": "button,a,[role=button],span"},
50
- {"do": "probe", "note": "章节编辑器"},
51
- {"do": "fill", "sel": "input[placeholder*='章节'],input[placeholder*='标题'],input[placeholder*='章名']", "key": "chapter_title"},
52
- {"do": "wait", "sel": "[contenteditable=true],textarea[class*=content],iframe", "timeout": 10},
53
- {"do": "fill", "sel": "[contenteditable=true],textarea[class*=content]", "key": "chapter_body"},
54
- {"do": "shot", "name": "chapter-filled"},
55
- {"do": "submit", "sel": "button[class*=publish],button[class*=submit]"},
56
- ]
57
-
58
- CHECK_LOGIN = [
59
- {"do": "navigate", "url": "{home}"},
60
- {"do": "url_any", "any": ["qimao.com"]},
61
- ]
62
-
63
- PROBE_FORM = [
64
- {"do": "navigate", "url": "{home}"},
65
- {"do": "url_any", "any": ["qimao.com"]},
66
- {"do": "click_text", "text": "创建作品", "contains": True, "scope": "button,a,[role=button],span"},
67
- {"do": "probe", "note": "建书表单"},
68
- ]
69
-
70
- FLOWS = {"create_book": CREATE_BOOK, "upload_chapter": UPLOAD_CHAPTER,
71
- "check_login": CHECK_LOGIN, "probe_form": PROBE_FORM}
72
-
73
-
74
- # 标签字段名 → 弹层左侧组显示名(tags 步骤按组切换后点选;每组必选 1-3 个)
75
- TAG_GROUP_LABELS = {"tags_style": "风格", "tags_role": "角色",
76
- "tags_plot": "情节", "tags_bg": "背景"}
77
-
78
-
79
- def values_create_book(meta):
80
- from .. import bookmeta_catalog as cat
81
- target_reader = (meta.get("target_reader") or "").strip()
82
- category_main = (meta.get("category_main") or "").strip()
83
- category_sub = (meta.get("category_sub") or "").strip()
84
- # 旧数据兜底:二级有值但一级空/与二级不自洽(如女生专属的「现实故事」
85
- # 配了男生频道)会让一级 click_text 静默跳过、死在流程中段——按官方
86
- # 目录跨频道反查对齐,频道与一级跟着二级走
87
- if category_sub and not (category_main and
88
- category_sub in (cat.QIMAO_CATS.get(target_reader)
89
- or {}).get(category_main, [])):
90
- loc = cat.qimao_locate_sub(category_sub)
91
- if loc:
92
- target_reader, category_main = loc
93
- else:
94
- category_sub = ""
95
- return {
96
- "title": (meta.get("book_name") or "").strip(),
97
- "summary": (meta.get("summary") or "").strip(),
98
- "protagonist": (meta.get("protagonist_1") or "").strip(),
99
- "target_reader": target_reader,
100
- "category_main": category_main,
101
- "category_sub": category_sub,
102
- "status": (meta.get("status") or "连载中").strip(),
103
- }
104
-
105
-
106
- def tag_groups(meta):
107
- out = []
108
- for key in ("tags_style", "tags_role", "tags_plot", "tags_bg"):
109
- v = meta.get(key)
110
- if isinstance(v, list) and v:
111
- out.append((key, [str(x) for x in v]))
112
- return out
113
-
114
-
115
- def chapter_manage_url(book):
116
- """章节管理页(上线验证用):有 book_id 直达,否则回作品管理页。"""
117
- bid = str((book or {}).get("book_id") or "")
118
- if bid: # 实测 .../front/book-manage/manage?id=
119
- return CONFIG["book_manage"] + "/manage?id=" + bid
120
- return CONFIG["book_manage"]
121
-
122
-
123
- def draft_url(book):
124
- """草稿箱页(发章真发布链中转站)。"""
125
- bid = str((book or {}).get("book_id") or "")
126
- if bid:
127
- return CONFIG["book_manage"] + "/draft?id=" + bid
128
- return CONFIG["book_manage"]
129
-
130
-
131
- def editor_url(book):
132
- """章节编辑器直达(绕开会开新 tab 的「上传章节」点击)。
133
-
134
- 缺 title 参数会被平台重定向回首页(真机实测),必须带上。"""
135
- bid = str((book or {}).get("book_id") or "")
136
- title = str((book or {}).get("title") or "")
137
- if bid:
138
- from urllib.parse import quote
139
- return (CONFIG["book_manage"].rsplit("/", 1)[0]
140
- + "/book-upload?id=" + bid + "&title=" + quote(title))
141
- return CONFIG["book_manage"]
1
+ # -*- coding: utf-8 -*-
2
+ """七猫作者后台的发布流程定义(默认表:选择器为合理推测,待实测校准)。
3
+
4
+ 校准方式同番茄:data/publish/flows-qimao.json 覆盖 + 「探测表单」dump。
5
+ URL 依据:tools/qimao_bookmeta.json 的 source 记录 2026-09-17 登录态实抓
6
+ zuozhe.qimao.com/api/pc/v1/book/book-option——作者后台 PC 端在 zuozhe.qimao.com。
7
+
8
+ 七猫建书表单结构(同次实抓):频道级联(男/女生 → 一级 → 二级)+ 四组标签
9
+ (风格/角色/情节/背景,每组 1-3 个)+ 文本字段。
10
+ """
11
+ from __future__ import annotations
12
+
13
+ CONFIG = {
14
+ "id": "qimao",
15
+ "label": "七猫",
16
+ "home": "https://zuozhe.qimao.com/",
17
+ # 作品管理页:建书入口(「新建小说」按钮所在);建书向导第二步表单在其下
18
+ "book_manage": "https://zuozhe.qimao.com/front/book-manage",
19
+ # 编辑器顶栏明示「正文字数最少 1000 字」——不足时「立即发布」被静默拦截
20
+ "min_chapter_chars": 1000,
21
+ "login_url_marks": ["login", "signin", "passport", "sso"],
22
+ }
23
+
24
+ CREATE_BOOK = [
25
+ {"do": "navigate", "url": "{home}"},
26
+ {"do": "url_any", "any": ["qimao.com"]},
27
+ {"do": "click_text", "text": "创建作品", "contains": True, "scope": "button,a,[role=button],span"},
28
+ {"do": "probe", "note": "建书表单"},
29
+ {"do": "wait", "sel": "input[placeholder*='作品'],input[placeholder*='书名'],input[maxlength]", "timeout": 10},
30
+ {"do": "fill", "sel": "input[placeholder*='作品'],input[placeholder*='书名']", "key": "title"},
31
+ {"do": "fill", "sel": "textarea[placeholder*='简介'],textarea", "key": "summary"},
32
+ {"do": "fill", "sel": "input[placeholder*='主角']", "key": "protagonist"},
33
+ # 频道级联:目标读者 → 一级分类 → 二级分类(值来自 bookmeta 的级联字段)
34
+ {"do": "click_text", "text": "{target_reader}", "contains": False,
35
+ "scope": "[class*=channel] label,label,span"},
36
+ {"do": "click_text", "text": "{category_main}", "contains": False,
37
+ "scope": "[class*=categor] li,option,span"},
38
+ {"do": "click_text", "text": "{category_sub}", "contains": False,
39
+ "scope": "[class*=categor] li,option,span"},
40
+ {"do": "shot", "name": "create-book-filled"},
41
+ {"do": "submit", "sel": "button[class*=submit],button[class*=primary],button[class*=confirm]"},
42
+ ]
43
+
44
+ UPLOAD_CHAPTER = [
45
+ {"do": "navigate", "url": "{home}"},
46
+ {"do": "url_any", "any": ["qimao.com"]},
47
+ {"do": "click_text", "text": "{book_name}", "contains": True,
48
+ "scope": "a,span,div[class*=title],div[class*=book]"},
49
+ {"do": "click_text", "text": "新建章节", "contains": True, "scope": "button,a,[role=button],span"},
50
+ {"do": "probe", "note": "章节编辑器"},
51
+ {"do": "fill", "sel": "input[placeholder*='章节'],input[placeholder*='标题'],input[placeholder*='章名']", "key": "chapter_title"},
52
+ {"do": "wait", "sel": "[contenteditable=true],textarea[class*=content],iframe", "timeout": 10},
53
+ {"do": "fill", "sel": "[contenteditable=true],textarea[class*=content]", "key": "chapter_body"},
54
+ {"do": "shot", "name": "chapter-filled"},
55
+ {"do": "submit", "sel": "button[class*=publish],button[class*=submit]"},
56
+ ]
57
+
58
+ CHECK_LOGIN = [
59
+ {"do": "navigate", "url": "{home}"},
60
+ {"do": "url_any", "any": ["qimao.com"]},
61
+ ]
62
+
63
+ PROBE_FORM = [
64
+ {"do": "navigate", "url": "{home}"},
65
+ {"do": "url_any", "any": ["qimao.com"]},
66
+ {"do": "click_text", "text": "创建作品", "contains": True, "scope": "button,a,[role=button],span"},
67
+ {"do": "probe", "note": "建书表单"},
68
+ ]
69
+
70
+ FLOWS = {"create_book": CREATE_BOOK, "upload_chapter": UPLOAD_CHAPTER,
71
+ "check_login": CHECK_LOGIN, "probe_form": PROBE_FORM}
72
+
73
+
74
+ # 标签字段名 → 弹层左侧组显示名(tags 步骤按组切换后点选;每组必选 1-3 个)
75
+ TAG_GROUP_LABELS = {"tags_style": "风格", "tags_role": "角色",
76
+ "tags_plot": "情节", "tags_bg": "背景"}
77
+
78
+
79
+ def values_create_book(meta):
80
+ from .. import bookmeta_catalog as cat
81
+ target_reader = (meta.get("target_reader") or "").strip()
82
+ category_main = (meta.get("category_main") or "").strip()
83
+ category_sub = (meta.get("category_sub") or "").strip()
84
+ # 旧数据兜底:二级有值但一级空/与二级不自洽(如女生专属的「现实故事」
85
+ # 配了男生频道)会让一级 click_text 静默跳过、死在流程中段——按官方
86
+ # 目录跨频道反查对齐,频道与一级跟着二级走
87
+ if category_sub and not (category_main and
88
+ category_sub in (cat.QIMAO_CATS.get(target_reader)
89
+ or {}).get(category_main, [])):
90
+ loc = cat.qimao_locate_sub(category_sub)
91
+ if loc:
92
+ target_reader, category_main = loc
93
+ else:
94
+ category_sub = ""
95
+ return {
96
+ "title": (meta.get("book_name") or "").strip(),
97
+ "summary": (meta.get("summary") or "").strip(),
98
+ "protagonist": (meta.get("protagonist_1") or "").strip(),
99
+ "target_reader": target_reader,
100
+ "category_main": category_main,
101
+ "category_sub": category_sub,
102
+ "status": (meta.get("status") or "连载中").strip(),
103
+ }
104
+
105
+
106
+ def tag_groups(meta):
107
+ out = []
108
+ for key in ("tags_style", "tags_role", "tags_plot", "tags_bg"):
109
+ v = meta.get(key)
110
+ if isinstance(v, list) and v:
111
+ out.append((key, [str(x) for x in v]))
112
+ return out
113
+
114
+
115
+ def chapter_manage_url(book):
116
+ """章节管理页(上线验证用):有 book_id 直达,否则回作品管理页。"""
117
+ bid = str((book or {}).get("book_id") or "")
118
+ if bid: # 实测 .../front/book-manage/manage?id=
119
+ return CONFIG["book_manage"] + "/manage?id=" + bid
120
+ return CONFIG["book_manage"]
121
+
122
+
123
+ def draft_url(book):
124
+ """草稿箱页(发章真发布链中转站)。"""
125
+ bid = str((book or {}).get("book_id") or "")
126
+ if bid:
127
+ return CONFIG["book_manage"] + "/draft?id=" + bid
128
+ return CONFIG["book_manage"]
129
+
130
+
131
+ def editor_url(book):
132
+ """章节编辑器直达(绕开会开新 tab 的「上传章节」点击)。
133
+
134
+ 缺 title 参数会被平台重定向回首页(真机实测),必须带上。"""
135
+ bid = str((book or {}).get("book_id") or "")
136
+ title = str((book or {}).get("title") or "")
137
+ if bid:
138
+ from urllib.parse import quote
139
+ return (CONFIG["book_manage"].rsplit("/", 1)[0]
140
+ + "/book-upload?id=" + bid + "&title=" + quote(title))
141
+ return CONFIG["book_manage"]
@@ -1,139 +1,139 @@
1
- # -*- coding: utf-8 -*-
2
- """最小 WebSocket 客户端(RFC 6455 子集)——CDP 驱动专用。
3
-
4
- 只实现客户端角色:HTTP Upgrade 握手 → 文本帧收发。CDP 的用法非常受限,
5
- 所以刻意不做完整协议:
6
- - 客户端只发文本帧(JSON 命令),服务端回文本帧(响应/事件)+ 偶尔 ping;
7
- - 截图等大响应可能分片(continuation 帧),必须重组;
8
- - 不协商 permessage-deflate 等扩展,服务端帧按规范不带掩码(仍兼容读取)。
9
-
10
- 不引第三方 websocket 库:32 位 Python + npm 分发是零依赖策略,
11
- 标准库 socket 手写两百行即可覆盖上述用法,行为对着真 Edge 验证。
12
- """
13
- from __future__ import annotations
14
-
15
- import base64
16
- import os
17
- import socket
18
- import struct
19
-
20
- OP_CONT, OP_TEXT, OP_BINARY, OP_CLOSE, OP_PING, OP_PONG = 0x0, 0x1, 0x2, 0x8, 0x9, 0xA
21
-
22
-
23
- class WebSocketError(Exception):
24
- """连接层错误(握手失败/对端关闭/帧损坏)。业务超时抛 socket.timeout。"""
25
-
26
-
27
- class MiniWS:
28
- """一次性连接:不做重连(CDP 页面会话由上层 browser.py 决定重开)。"""
29
-
30
- def __init__(self, host, port, path, timeout=30.0):
31
- self._timeout = timeout
32
- self._sock = socket.create_connection((host, int(port)), timeout=timeout)
33
- self._buf = b""
34
- self._handshake(host, int(port), path)
35
-
36
- # ------------------------------------------------------------ 握手
37
- def _handshake(self, host, port, path):
38
- key = base64.b64encode(os.urandom(16)).decode("ascii")
39
- req = ("GET %s HTTP/1.1\r\nHost: %s:%d\r\nUpgrade: websocket\r\n"
40
- "Connection: Upgrade\r\nSec-WebSocket-Key: %s\r\n"
41
- "Sec-WebSocket-Version: 13\r\n\r\n") % (path, host, port, key)
42
- self._sock.sendall(req.encode("ascii"))
43
- while b"\r\n\r\n" not in self._buf:
44
- chunk = self._sock.recv(4096)
45
- if not chunk:
46
- raise WebSocketError("握手失败:连接被关闭")
47
- self._buf += chunk
48
- head, _, self._buf = self._buf.partition(b"\r\n\r\n")
49
- status = head.split(b"\r\n", 1)[0].decode("latin-1", "replace")
50
- if " 101 " not in status and not status.endswith(" 101"):
51
- raise WebSocketError("握手失败:%s" % status[:120])
52
-
53
- # ------------------------------------------------------------ 发送
54
- def _send_frame(self, opcode, payload):
55
- mask = os.urandom(4) # 客户端→服务端必须掩码
56
- n = len(payload)
57
- header = bytearray([0x80 | opcode]) # FIN + opcode
58
- if n < 126:
59
- header.append(0x80 | n)
60
- elif n < 65536:
61
- header.append(0x80 | 126)
62
- header += struct.pack(">H", n)
63
- else:
64
- header.append(0x80 | 127)
65
- header += struct.pack(">Q", n)
66
- header += mask
67
- masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
68
- self._sock.sendall(bytes(header) + masked)
69
-
70
- def send_text(self, text):
71
- self._send_frame(OP_TEXT, text.encode("utf-8"))
72
-
73
- # ------------------------------------------------------------ 接收
74
- def _recv_exact(self, n):
75
- while len(self._buf) < n:
76
- chunk = self._sock.recv(max(4096, n - len(self._buf)))
77
- if not chunk:
78
- raise WebSocketError("连接被关闭")
79
- self._buf += chunk
80
- out, self._buf = self._buf[:n], self._buf[n:]
81
- return out
82
-
83
- def recv_message(self, timeout=None):
84
- """收一条完整消息:自动应答 ping、重组分片。
85
-
86
- 返回 str(文本帧)或 bytes(二进制帧)。超时抛 socket.timeout,
87
- 对端关闭抛 WebSocketError。"""
88
- if timeout is not None:
89
- self._sock.settimeout(timeout)
90
- elif self._timeout:
91
- self._sock.settimeout(self._timeout)
92
- opcode_msg = None
93
- payload = bytearray()
94
- while True:
95
- b1, b2 = self._recv_exact(2)
96
- fin, opcode, masked = b1 & 0x80, b1 & 0x0F, b2 & 0x80
97
- n = b2 & 0x7F
98
- if n == 126:
99
- (n,) = struct.unpack(">H", self._recv_exact(2))
100
- elif n == 127:
101
- (n,) = struct.unpack(">Q", self._recv_exact(8))
102
- data = self._recv_exact(n)
103
- if masked:
104
- mkey = self._recv_exact(4)
105
- data = bytes(b ^ mkey[i % 4] for i, b in enumerate(data))
106
- if opcode == OP_CLOSE:
107
- raise WebSocketError("对端发送关闭帧")
108
- if opcode == OP_PING:
109
- self._send_frame(OP_PONG, data)
110
- continue
111
- if opcode == OP_PONG:
112
- continue
113
- if opcode in (OP_TEXT, OP_BINARY):
114
- opcode_msg, payload = opcode, bytearray(data)
115
- elif opcode == OP_CONT:
116
- if opcode_msg is None:
117
- raise WebSocketError("收到无起始帧的续帧")
118
- payload += data
119
- else:
120
- raise WebSocketError("未知 opcode:%d" % opcode)
121
- if fin:
122
- return bytes(payload) if opcode_msg == OP_BINARY else \
123
- payload.decode("utf-8", "replace")
124
-
125
- def close(self):
126
- try:
127
- self._send_frame(OP_CLOSE, b"")
128
- except Exception:
129
- pass
130
- try:
131
- self._sock.close()
132
- except Exception:
133
- pass
134
-
135
- def __enter__(self):
136
- return self
137
-
138
- def __exit__(self, *exc):
139
- self.close()
1
+ # -*- coding: utf-8 -*-
2
+ """最小 WebSocket 客户端(RFC 6455 子集)——CDP 驱动专用。
3
+
4
+ 只实现客户端角色:HTTP Upgrade 握手 → 文本帧收发。CDP 的用法非常受限,
5
+ 所以刻意不做完整协议:
6
+ - 客户端只发文本帧(JSON 命令),服务端回文本帧(响应/事件)+ 偶尔 ping;
7
+ - 截图等大响应可能分片(continuation 帧),必须重组;
8
+ - 不协商 permessage-deflate 等扩展,服务端帧按规范不带掩码(仍兼容读取)。
9
+
10
+ 不引第三方 websocket 库:32 位 Python + npm 分发是零依赖策略,
11
+ 标准库 socket 手写两百行即可覆盖上述用法,行为对着真 Edge 验证。
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import os
17
+ import socket
18
+ import struct
19
+
20
+ OP_CONT, OP_TEXT, OP_BINARY, OP_CLOSE, OP_PING, OP_PONG = 0x0, 0x1, 0x2, 0x8, 0x9, 0xA
21
+
22
+
23
+ class WebSocketError(Exception):
24
+ """连接层错误(握手失败/对端关闭/帧损坏)。业务超时抛 socket.timeout。"""
25
+
26
+
27
+ class MiniWS:
28
+ """一次性连接:不做重连(CDP 页面会话由上层 browser.py 决定重开)。"""
29
+
30
+ def __init__(self, host, port, path, timeout=30.0):
31
+ self._timeout = timeout
32
+ self._sock = socket.create_connection((host, int(port)), timeout=timeout)
33
+ self._buf = b""
34
+ self._handshake(host, int(port), path)
35
+
36
+ # ------------------------------------------------------------ 握手
37
+ def _handshake(self, host, port, path):
38
+ key = base64.b64encode(os.urandom(16)).decode("ascii")
39
+ req = ("GET %s HTTP/1.1\r\nHost: %s:%d\r\nUpgrade: websocket\r\n"
40
+ "Connection: Upgrade\r\nSec-WebSocket-Key: %s\r\n"
41
+ "Sec-WebSocket-Version: 13\r\n\r\n") % (path, host, port, key)
42
+ self._sock.sendall(req.encode("ascii"))
43
+ while b"\r\n\r\n" not in self._buf:
44
+ chunk = self._sock.recv(4096)
45
+ if not chunk:
46
+ raise WebSocketError("握手失败:连接被关闭")
47
+ self._buf += chunk
48
+ head, _, self._buf = self._buf.partition(b"\r\n\r\n")
49
+ status = head.split(b"\r\n", 1)[0].decode("latin-1", "replace")
50
+ if " 101 " not in status and not status.endswith(" 101"):
51
+ raise WebSocketError("握手失败:%s" % status[:120])
52
+
53
+ # ------------------------------------------------------------ 发送
54
+ def _send_frame(self, opcode, payload):
55
+ mask = os.urandom(4) # 客户端→服务端必须掩码
56
+ n = len(payload)
57
+ header = bytearray([0x80 | opcode]) # FIN + opcode
58
+ if n < 126:
59
+ header.append(0x80 | n)
60
+ elif n < 65536:
61
+ header.append(0x80 | 126)
62
+ header += struct.pack(">H", n)
63
+ else:
64
+ header.append(0x80 | 127)
65
+ header += struct.pack(">Q", n)
66
+ header += mask
67
+ masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
68
+ self._sock.sendall(bytes(header) + masked)
69
+
70
+ def send_text(self, text):
71
+ self._send_frame(OP_TEXT, text.encode("utf-8"))
72
+
73
+ # ------------------------------------------------------------ 接收
74
+ def _recv_exact(self, n):
75
+ while len(self._buf) < n:
76
+ chunk = self._sock.recv(max(4096, n - len(self._buf)))
77
+ if not chunk:
78
+ raise WebSocketError("连接被关闭")
79
+ self._buf += chunk
80
+ out, self._buf = self._buf[:n], self._buf[n:]
81
+ return out
82
+
83
+ def recv_message(self, timeout=None):
84
+ """收一条完整消息:自动应答 ping、重组分片。
85
+
86
+ 返回 str(文本帧)或 bytes(二进制帧)。超时抛 socket.timeout,
87
+ 对端关闭抛 WebSocketError。"""
88
+ if timeout is not None:
89
+ self._sock.settimeout(timeout)
90
+ elif self._timeout:
91
+ self._sock.settimeout(self._timeout)
92
+ opcode_msg = None
93
+ payload = bytearray()
94
+ while True:
95
+ b1, b2 = self._recv_exact(2)
96
+ fin, opcode, masked = b1 & 0x80, b1 & 0x0F, b2 & 0x80
97
+ n = b2 & 0x7F
98
+ if n == 126:
99
+ (n,) = struct.unpack(">H", self._recv_exact(2))
100
+ elif n == 127:
101
+ (n,) = struct.unpack(">Q", self._recv_exact(8))
102
+ data = self._recv_exact(n)
103
+ if masked:
104
+ mkey = self._recv_exact(4)
105
+ data = bytes(b ^ mkey[i % 4] for i, b in enumerate(data))
106
+ if opcode == OP_CLOSE:
107
+ raise WebSocketError("对端发送关闭帧")
108
+ if opcode == OP_PING:
109
+ self._send_frame(OP_PONG, data)
110
+ continue
111
+ if opcode == OP_PONG:
112
+ continue
113
+ if opcode in (OP_TEXT, OP_BINARY):
114
+ opcode_msg, payload = opcode, bytearray(data)
115
+ elif opcode == OP_CONT:
116
+ if opcode_msg is None:
117
+ raise WebSocketError("收到无起始帧的续帧")
118
+ payload += data
119
+ else:
120
+ raise WebSocketError("未知 opcode:%d" % opcode)
121
+ if fin:
122
+ return bytes(payload) if opcode_msg == OP_BINARY else \
123
+ payload.decode("utf-8", "replace")
124
+
125
+ def close(self):
126
+ try:
127
+ self._send_frame(OP_CLOSE, b"")
128
+ except Exception:
129
+ pass
130
+ try:
131
+ self._sock.close()
132
+ except Exception:
133
+ pass
134
+
135
+ def __enter__(self):
136
+ return self
137
+
138
+ def __exit__(self, *exc):
139
+ self.close()
@@ -1,38 +1,38 @@
1
- # -*- coding: utf-8 -*-
2
- """自由文本脱敏基础函数;不依赖业务模块,供各类本地台账复用。"""
3
- from __future__ import annotations
4
-
5
- import re
6
-
7
-
8
- _KEY_PATTERNS = (
9
- (re.compile(r"\bsk-[A-Za-z0-9_-]{8,}"), "[key]"),
10
- (re.compile(r"\b(?:Bearer|bearer)\s+\S+"), "Bearer [key]"),
11
- (re.compile(r"(?i)\b((?:api[_-]?|access[_-]?|secret[_-]?|auth[_-]?)(?:key|token|secret))"
12
- r"""["']?\s*[:=,,]\s*["']?[A-Za-z0-9._~+/=-]{8,}"""), r"\1[key]"),
13
- (re.compile(r"\b[0-9a-fA-F]{40,}\b"), "[token]"),
14
- )
15
-
16
- _PATH_PATTERNS = (
17
- (re.compile(r"(?i)\b[A-Z]:\\(?:[^\\/:*?\"<>|\r\n]+\\)*[^\\/:*?\"<>|\r\n]*"),
18
- lambda m: "[path]" + m.group(0).split("\\")[-1]),
19
- (re.compile(r"(?i)\b(?:\\\\[^\\\s]+\\[^\s]+)"), "[path]"),
20
- (re.compile(r"(?:/Users/|/home/|~)[^\s\"':]+"),
21
- lambda m: "[path]" + m.group(0).rsplit("/", 1)[-1]),
22
- )
23
-
24
-
25
- def scrub_text(text, limit=600):
26
- """剥离常见密钥、令牌和绝对路径,再按字符上限截断。"""
27
- if not isinstance(text, str):
28
- text = "" if text is None else str(text)
29
- out = text
30
- for pattern, replacement in _KEY_PATTERNS:
31
- out = pattern.sub(replacement, out)
32
- for pattern, replacement in _PATH_PATTERNS:
33
- try:
34
- out = pattern.sub(replacement, out)
35
- except Exception:
36
- pass
37
- out = out.strip()
38
- return out[:limit] + "…" if len(out) > limit else out
1
+ # -*- coding: utf-8 -*-
2
+ """自由文本脱敏基础函数;不依赖业务模块,供各类本地台账复用。"""
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+
8
+ _KEY_PATTERNS = (
9
+ (re.compile(r"\bsk-[A-Za-z0-9_-]{8,}"), "[key]"),
10
+ (re.compile(r"\b(?:Bearer|bearer)\s+\S+"), "Bearer [key]"),
11
+ (re.compile(r"(?i)\b((?:api[_-]?|access[_-]?|secret[_-]?|auth[_-]?)(?:key|token|secret))"
12
+ r"""["']?\s*[:=,,]\s*["']?[A-Za-z0-9._~+/=-]{8,}"""), r"\1[key]"),
13
+ (re.compile(r"\b[0-9a-fA-F]{40,}\b"), "[token]"),
14
+ )
15
+
16
+ _PATH_PATTERNS = (
17
+ (re.compile(r"(?i)\b[A-Z]:\\(?:[^\\/:*?\"<>|\r\n]+\\)*[^\\/:*?\"<>|\r\n]*"),
18
+ lambda m: "[path]" + m.group(0).split("\\")[-1]),
19
+ (re.compile(r"(?i)\b(?:\\\\[^\\\s]+\\[^\s]+)"), "[path]"),
20
+ (re.compile(r"(?:/Users/|/home/|~)[^\s\"':]+"),
21
+ lambda m: "[path]" + m.group(0).rsplit("/", 1)[-1]),
22
+ )
23
+
24
+
25
+ def scrub_text(text, limit=600):
26
+ """剥离常见密钥、令牌和绝对路径,再按字符上限截断。"""
27
+ if not isinstance(text, str):
28
+ text = "" if text is None else str(text)
29
+ out = text
30
+ for pattern, replacement in _KEY_PATTERNS:
31
+ out = pattern.sub(replacement, out)
32
+ for pattern, replacement in _PATH_PATTERNS:
33
+ try:
34
+ out = pattern.sub(replacement, out)
35
+ except Exception:
36
+ pass
37
+ out = out.strip()
38
+ return out[:limit] + "…" if len(out) > limit else out