dp-cli 0.7.0__tar.gz → 0.8.0__tar.gz
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.
- dp_cli-0.8.0/MANIFEST.in +1 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/PKG-INFO +18 -1
- {dp_cli-0.7.0 → dp_cli-0.8.0}/README.md +12 -0
- dp_cli-0.8.0/dp_cli/catalog.py +109 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli/commands/__init__.py +4 -2
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli/commands/_utils.py +222 -0
- dp_cli-0.8.0/dp_cli/commands/assert_cmd.py +454 -0
- dp_cli-0.8.0/dp_cli/commands/batch.py +178 -0
- dp_cli-0.8.0/dp_cli/commands/browser.py +866 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli/commands/element.py +32 -5
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli/commands/emulate.py +62 -3
- dp_cli-0.8.0/dp_cli/commands/extract.py +124 -0
- dp_cli-0.8.0/dp_cli/commands/hook.py +437 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli/commands/keyboard.py +16 -3
- dp_cli-0.8.0/dp_cli/commands/meta.py +111 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli/commands/misc.py +79 -13
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli/commands/network.py +146 -41
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli/commands/page.py +52 -21
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli/commands/record.py +21 -5
- dp_cli-0.8.0/dp_cli/commands/rpc.py +204 -0
- dp_cli-0.8.0/dp_cli/commands/serve.py +162 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli/commands/snapshot_cmd.py +138 -30
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli/commands/storage.py +87 -4
- dp_cli-0.8.0/dp_cli/human.py +116 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli/locators/__init__.py +2 -1
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli/locators/pw_js.py +26 -6
- dp_cli-0.8.0/dp_cli/locators/suggest.py +214 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli/main.py +14 -2
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli/output.py +34 -2
- dp_cli-0.8.0/dp_cli/pw_ts.py +105 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli/recorder.py +185 -14
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli/session.py +96 -9
- dp_cli-0.8.0/dp_cli/skill_dist.py +217 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli/snapshot/a11y.py +100 -33
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli/stealth.py +208 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli.egg-info/PKG-INFO +18 -1
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli.egg-info/SOURCES.txt +31 -1
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli.egg-info/requires.txt +7 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/pyproject.toml +8 -1
- dp_cli-0.8.0/setup.py +24 -0
- dp_cli-0.8.0/skills/dp-cli/SKILL.md +553 -0
- dp_cli-0.8.0/skills/dp-cli/references/auto-connect-gnome.sh +165 -0
- dp_cli-0.8.0/skills/dp-cli/references/commands.md +443 -0
- dp_cli-0.8.0/skills/dp-cli/references/extraction.md +82 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/tests/test_a11y.py +141 -0
- dp_cli-0.8.0/tests/test_batch.py +218 -0
- dp_cli-0.8.0/tests/test_browser_cdp.py +563 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/tests/test_commands.py +914 -10
- dp_cli-0.8.0/tests/test_compact.py +136 -0
- dp_cli-0.8.0/tests/test_docs.py +224 -0
- dp_cli-0.8.0/tests/test_event_wait.py +263 -0
- dp_cli-0.8.0/tests/test_expect_gen.py +345 -0
- dp_cli-0.8.0/tests/test_hook.py +384 -0
- dp_cli-0.8.0/tests/test_human.py +324 -0
- dp_cli-0.8.0/tests/test_pw_ts.py +194 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/tests/test_recorder.py +193 -1
- dp_cli-0.8.0/tests/test_rpc.py +443 -0
- dp_cli-0.8.0/tests/test_serve.py +173 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/tests/test_session.py +124 -1
- dp_cli-0.8.0/tests/test_skill_dist.py +314 -0
- dp_cli-0.8.0/tests/test_trim.py +215 -0
- dp_cli-0.7.0/dp_cli/commands/assert_cmd.py +0 -172
- dp_cli-0.7.0/dp_cli/commands/browser.py +0 -336
- {dp_cli-0.7.0 → dp_cli-0.8.0}/LICENSE +0 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli/__init__.py +0 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli/bridge.py +0 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli/bridge_manager.py +0 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli/commands/observe.py +0 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli/commands/tab.py +0 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli/locators/playwright.py +0 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli/logs.py +0 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli/snapshot/__init__.py +0 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli/snapshot/clickable.py +0 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli/snapshot/clickable_js.py +0 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli/snapshot/extract.py +0 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli/snapshot/js_scripts.py +0 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli/snapshot/utils.py +0 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli.egg-info/dependency_links.txt +0 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli.egg-info/entry_points.txt +0 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/dp_cli.egg-info/top_level.txt +0 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/setup.cfg +0 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/tests/test_assert_cmd.py +0 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/tests/test_bridge_integration.py +0 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/tests/test_bridge_manager.py +0 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/tests/test_browser_smoke.py +0 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/tests/test_clickable.py +0 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/tests/test_pw_locator.py +0 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/tests/test_resolve_locator.py +0 -0
- {dp_cli-0.7.0 → dp_cli-0.8.0}/tests/test_snapshot_small.py +0 -0
dp_cli-0.8.0/MANIFEST.in
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
graft skills
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dp-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: A powerful CLI for DrissionPage — browser automation, structured data extraction, network listening and more.
|
|
5
5
|
License: BSD-3-Clause
|
|
6
6
|
Project-URL: Homepage, https://github.com/mofanx/dp-cli
|
|
@@ -19,6 +19,11 @@ Requires-Dist: click>=8.0
|
|
|
19
19
|
Requires-Dist: aiohttp>=3.9
|
|
20
20
|
Requires-Dist: websockets>=12
|
|
21
21
|
Requires-Dist: requests>=2.28
|
|
22
|
+
Provides-Extra: tls
|
|
23
|
+
Requires-Dist: curl_cffi>=0.7.0; extra == "tls"
|
|
24
|
+
Provides-Extra: extract
|
|
25
|
+
Requires-Dist: trafilatura>=1.6.0; extra == "extract"
|
|
26
|
+
Requires-Dist: readability-lxml>=0.8.1; extra == "extract"
|
|
22
27
|
Provides-Extra: test
|
|
23
28
|
Requires-Dist: pytest>=7.4.0; extra == "test"
|
|
24
29
|
Requires-Dist: pytest-cov>=4.1.0; extra == "test"
|
|
@@ -54,6 +59,18 @@ pip install dp-cli
|
|
|
54
59
|
dp --help
|
|
55
60
|
```
|
|
56
61
|
|
|
62
|
+
### Agent skill(可选,推荐)
|
|
63
|
+
|
|
64
|
+
为 AI agent 安装完整使用指南(全部命令参考 + 工作流 playbook):
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
dp install --skills # 装到 agent 的 skill 目录
|
|
68
|
+
npx skills add mofanx/dp-cli # 或走 skills CLI 渠道
|
|
69
|
+
claude plugin marketplace add mofanx/dp-cli # 或 Claude Code 插件市场
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
未安装也能用——skill 只是文档层;agent 可直接读 `dp --help` 或 `python -m dp_cli.catalog` 自举。
|
|
73
|
+
|
|
57
74
|
## Quick Start
|
|
58
75
|
|
|
59
76
|
```bash
|
|
@@ -26,6 +26,18 @@ pip install dp-cli
|
|
|
26
26
|
dp --help
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
+
### Agent skill(可选,推荐)
|
|
30
|
+
|
|
31
|
+
为 AI agent 安装完整使用指南(全部命令参考 + 工作流 playbook):
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
dp install --skills # 装到 agent 的 skill 目录
|
|
35
|
+
npx skills add mofanx/dp-cli # 或走 skills CLI 渠道
|
|
36
|
+
claude plugin marketplace add mofanx/dp-cli # 或 Claude Code 插件市场
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
未安装也能用——skill 只是文档层;agent 可直接读 `dp --help` 或 `python -m dp_cli.catalog` 自举。
|
|
40
|
+
|
|
29
41
|
## Quick Start
|
|
30
42
|
|
|
31
43
|
```bash
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# -*- coding:utf-8 -*-
|
|
2
|
+
"""从 click 命令树机械导出命令元数据。
|
|
3
|
+
|
|
4
|
+
两类消费方:
|
|
5
|
+
- ``python -m dp_cli.catalog --md`` → 生成 commands.md 风格的命令表
|
|
6
|
+
- ``python -m dp_cli.catalog`` → 输出 MCP 风格 tool schema JSON
|
|
7
|
+
(未来 mcp-serve 薄壳的上游;schema 由代码生成,不手写,不腐化)
|
|
8
|
+
|
|
9
|
+
文档腐化守卫见 tests/test_docs.py:commands.md 必须覆盖全部注册命令。
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
|
|
19
|
+
from dp_cli.main import cli
|
|
20
|
+
|
|
21
|
+
# 模块名 → 命令表类别(按模块机械归类,新增模块时在此补一行)
|
|
22
|
+
CATEGORY = {
|
|
23
|
+
'browser': '浏览器', 'misc': '导航/页面', 'snapshot_cmd': '快照',
|
|
24
|
+
'element': '提取/交互', 'keyboard': '键盘/滚动', 'network': '网络',
|
|
25
|
+
'assert_cmd': '断言', 'emulate': '模拟/下载/拦截', 'observe': '观测',
|
|
26
|
+
'tab': '标签页', 'storage': '存储', 'record': '录制',
|
|
27
|
+
'page': '页面', 'batch': '批量', 'rpc': '逆向',
|
|
28
|
+
'extract': '提取', 'hook': '逆向', 'serve': '批量', 'sh': '批量',
|
|
29
|
+
'meta': '元信息',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _param_schema(p):
|
|
34
|
+
"""click.Param → JSON Schema 片段。"""
|
|
35
|
+
if isinstance(p, click.Option) and p.is_flag:
|
|
36
|
+
t = {'type': 'boolean'}
|
|
37
|
+
elif isinstance(p.type, click.types.IntParamType):
|
|
38
|
+
t = {'type': 'integer'}
|
|
39
|
+
elif isinstance(p.type, click.types.FloatParamType):
|
|
40
|
+
t = {'type': 'number'}
|
|
41
|
+
elif isinstance(p.type, click.types.BoolParamType):
|
|
42
|
+
t = {'type': 'boolean'}
|
|
43
|
+
elif isinstance(p.type, click.types.Choice):
|
|
44
|
+
t = {'type': 'string', 'enum': list(p.type.choices)}
|
|
45
|
+
else:
|
|
46
|
+
t = {'type': 'string'}
|
|
47
|
+
if getattr(p, 'multiple', False):
|
|
48
|
+
t = {'type': 'array', 'items': t}
|
|
49
|
+
if getattr(p, 'help', None):
|
|
50
|
+
t['description'] = p.help
|
|
51
|
+
return t
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def iter_commands():
|
|
55
|
+
"""yield (name, help, input_schema) —— MCP tool 风格。"""
|
|
56
|
+
for name in sorted(cli.commands):
|
|
57
|
+
cmd = cli.commands[name]
|
|
58
|
+
props, required = {}, []
|
|
59
|
+
for p in cmd.params:
|
|
60
|
+
props[p.name] = _param_schema(p)
|
|
61
|
+
if p.required:
|
|
62
|
+
required.append(p.name)
|
|
63
|
+
yield {
|
|
64
|
+
'name': name,
|
|
65
|
+
'description': (cmd.help or '').strip().splitlines()[0] if cmd.help else '',
|
|
66
|
+
'inputSchema': {'type': 'object', 'properties': props,
|
|
67
|
+
'required': required},
|
|
68
|
+
'_category': CATEGORY.get(
|
|
69
|
+
getattr(cmd.callback, '__module__', '').rsplit('.', 1)[-1],
|
|
70
|
+
'其他'),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def to_markdown(cmds):
|
|
75
|
+
"""生成按类别分组的 markdown 表(含 options 摘要)。"""
|
|
76
|
+
by_cat = {}
|
|
77
|
+
for c in cmds:
|
|
78
|
+
by_cat.setdefault(c['_category'], []).append(c)
|
|
79
|
+
lines = ['<!-- GENERATED by `python -m dp_cli.catalog --md` —— 勿手改 -->',
|
|
80
|
+
'', '| 类别 | 命令 | 参数 |', '|------|------|------|']
|
|
81
|
+
for cat in CATEGORY.values():
|
|
82
|
+
for c in by_cat.pop(cat, []):
|
|
83
|
+
params = ', '.join(
|
|
84
|
+
(f'<{k}>' if k in c['inputSchema']['required'] else f'[{k}]')
|
|
85
|
+
for k in c['inputSchema']['properties'])
|
|
86
|
+
lines.append(f"| {cat} | `{c['name']}` | {params} |")
|
|
87
|
+
for cat, cs in sorted(by_cat.items()):
|
|
88
|
+
for c in cs:
|
|
89
|
+
params = ', '.join(c['inputSchema']['properties'])
|
|
90
|
+
lines.append(f"| {cat} | `{c['name']}` | {params} |")
|
|
91
|
+
return '\n'.join(lines) + '\n'
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def main(argv=None):
|
|
95
|
+
ap = argparse.ArgumentParser(prog='dp-cli-catalog')
|
|
96
|
+
ap.add_argument('--md', action='store_true', help='输出 markdown 命令表')
|
|
97
|
+
args = ap.parse_args(argv)
|
|
98
|
+
cmds = list(iter_commands())
|
|
99
|
+
if args.md:
|
|
100
|
+
sys.stdout.write(to_markdown(cmds))
|
|
101
|
+
else:
|
|
102
|
+
tools = [{k: v for k, v in c.items() if not k.startswith('_')}
|
|
103
|
+
for c in cmds]
|
|
104
|
+
json.dump({'tools': tools}, sys.stdout, ensure_ascii=False, indent=2)
|
|
105
|
+
sys.stdout.write('\n')
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == '__main__':
|
|
109
|
+
main()
|
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
from dp_cli.commands import (
|
|
3
3
|
browser, snapshot_cmd, element, keyboard,
|
|
4
4
|
page, tab, storage, network, record, misc,
|
|
5
|
-
assert_cmd, emulate, observe,
|
|
5
|
+
assert_cmd, emulate, observe, batch, rpc, extract, hook, serve,
|
|
6
|
+
meta,
|
|
6
7
|
)
|
|
7
8
|
|
|
8
9
|
_MODULES = [browser, snapshot_cmd, element, keyboard, page, tab, storage, network, record, misc,
|
|
9
|
-
assert_cmd, emulate, observe
|
|
10
|
+
assert_cmd, emulate, observe, batch, rpc, extract, hook, serve,
|
|
11
|
+
meta]
|
|
10
12
|
|
|
11
13
|
|
|
12
14
|
def register_all(cli):
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import io
|
|
4
4
|
import csv
|
|
5
5
|
import re
|
|
6
|
+
import threading
|
|
6
7
|
from time import perf_counter, sleep as _sleep
|
|
7
8
|
|
|
8
9
|
import click
|
|
@@ -25,6 +26,34 @@ def session_option(f):
|
|
|
25
26
|
help='会话名称,默认 default', show_default=True)(f)
|
|
26
27
|
|
|
27
28
|
|
|
29
|
+
def trim_options(f):
|
|
30
|
+
"""--fields/--head 输出裁剪选项组(记录列表类命令共用)。"""
|
|
31
|
+
f = click.option('--head', type=int, default=None,
|
|
32
|
+
help='只输出前 N 条记录(附 total 字段说明截断)')(f)
|
|
33
|
+
f = click.option('--fields', default=None,
|
|
34
|
+
help='字段白名单,逗号分隔;非法字段名报错')(f)
|
|
35
|
+
return f
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def shape_records(records, fields, head, valid_fields):
|
|
39
|
+
"""应用 --fields/--head 裁剪,返回 (shaped, total);参数非法经 error() 退出。"""
|
|
40
|
+
total = len(records)
|
|
41
|
+
if fields is not None:
|
|
42
|
+
keys = [k.strip() for k in fields.split(',') if k.strip()]
|
|
43
|
+
bad = [k for k in keys if k not in valid_fields]
|
|
44
|
+
if not keys or bad:
|
|
45
|
+
error(f'--fields 非法字段: {", ".join(bad) or "(空)"}'
|
|
46
|
+
f'(可用: {", ".join(sorted(valid_fields))})',
|
|
47
|
+
code='INVALID_ARGS')
|
|
48
|
+
records = [{k: (r.get(k) if isinstance(r, dict) else None)
|
|
49
|
+
for k in keys} for r in records]
|
|
50
|
+
if head is not None:
|
|
51
|
+
if head < 0:
|
|
52
|
+
error('--head 需为非负整数', code='INVALID_ARGS')
|
|
53
|
+
records = records[:head]
|
|
54
|
+
return records, total
|
|
55
|
+
|
|
56
|
+
|
|
28
57
|
def frame_option(f):
|
|
29
58
|
return click.option('--frame', default=None,
|
|
30
59
|
help='限定在 iframe 内查找元素(值为 iframe 的定位器,'
|
|
@@ -121,10 +150,106 @@ def _get_page(session: str, raw: bool = False, inject_recording: bool = True):
|
|
|
121
150
|
_apply_intercept(target, intercept_cfg)
|
|
122
151
|
except Exception as e:
|
|
123
152
|
debug('intercept 重放失败', e)
|
|
153
|
+
browser_cdp_cfg = sess.get('browser_cdp')
|
|
154
|
+
if browser_cdp_cfg:
|
|
155
|
+
try:
|
|
156
|
+
from dp_cli.commands.browser import _apply_browser_cdp
|
|
157
|
+
_apply_browser_cdp(target, browser_cdp_cfg)
|
|
158
|
+
except Exception as e:
|
|
159
|
+
debug('browser_cdp 重放失败', e)
|
|
160
|
+
_replay_persist_scripts(target, session, sess)
|
|
124
161
|
|
|
125
162
|
return target
|
|
126
163
|
|
|
127
164
|
|
|
165
|
+
def _replay_persist_scripts(target, session, sess, only_ids=None):
|
|
166
|
+
"""重放会话持久化 init-js:rpc/hooks 的 N 个脚本合并为单个 bundle
|
|
167
|
+
一次 addScriptToEvaluateOnNewDocument 注册,省 N-1 个 CDP RTT。
|
|
168
|
+
|
|
169
|
+
每段 try/catch 包裹隔离运行时异常(原独立注册语义);各段均为
|
|
170
|
+
生成器模板 + json.dumps 插值,语法安全由生成器保证。
|
|
171
|
+
js_id 共享 bundle id 并落盘:跨进程 unregister/clear 拿到当前
|
|
172
|
+
生效 id 才能移除整包(否则只见注册时单 id,bundle 残留无法清除)。
|
|
173
|
+
|
|
174
|
+
:param only_ids: 只重放 js_id 命中该集合的 spec——用于 unregister/
|
|
175
|
+
clear 移除整包后精准补回其中剩余段;其它 js_id 的
|
|
176
|
+
脚本仍在 target 上存活,不得重复注册。None 表示
|
|
177
|
+
全量重放(_get_page 路径)。
|
|
178
|
+
"""
|
|
179
|
+
def _wanted(spec):
|
|
180
|
+
return only_ids is None or spec.get('js_id') in only_ids
|
|
181
|
+
|
|
182
|
+
bundle_parts = []
|
|
183
|
+
|
|
184
|
+
rpc_cfg = sess.get('rpc')
|
|
185
|
+
if rpc_cfg:
|
|
186
|
+
try:
|
|
187
|
+
from dp_cli.commands.rpc import _register_js
|
|
188
|
+
for rpc_name, spec in rpc_cfg.items():
|
|
189
|
+
if not _wanted(spec):
|
|
190
|
+
continue
|
|
191
|
+
try:
|
|
192
|
+
bundle_parts.append(_register_js(rpc_name, spec['body']))
|
|
193
|
+
except Exception as e:
|
|
194
|
+
debug(f'rpc 重放失败 name={rpc_name}', e)
|
|
195
|
+
except Exception as e:
|
|
196
|
+
debug('rpc 重放失败', e)
|
|
197
|
+
hooks_cfg = sess.get('hooks')
|
|
198
|
+
if hooks_cfg:
|
|
199
|
+
try:
|
|
200
|
+
from dp_cli.commands.hook import _hook_js
|
|
201
|
+
for hid, spec in hooks_cfg.items():
|
|
202
|
+
if not _wanted(spec):
|
|
203
|
+
continue
|
|
204
|
+
try:
|
|
205
|
+
bundle_parts.append(_hook_js(hid, spec['type'], spec['spec']))
|
|
206
|
+
except Exception as e:
|
|
207
|
+
debug(f'hook 重放失败 id={hid}', e)
|
|
208
|
+
except Exception as e:
|
|
209
|
+
debug('hook 重放失败', e)
|
|
210
|
+
|
|
211
|
+
if not bundle_parts:
|
|
212
|
+
return
|
|
213
|
+
try:
|
|
214
|
+
bundle_js = ';'.join(
|
|
215
|
+
f'try{{{s}}}catch(e){{}}' for s in bundle_parts)
|
|
216
|
+
bundle_id = target.add_init_js(bundle_js)
|
|
217
|
+
for spec in (rpc_cfg or {}).values():
|
|
218
|
+
if _wanted(spec):
|
|
219
|
+
spec['js_id'] = bundle_id
|
|
220
|
+
for spec in (hooks_cfg or {}).values():
|
|
221
|
+
if _wanted(spec):
|
|
222
|
+
spec['js_id'] = bundle_id
|
|
223
|
+
try:
|
|
224
|
+
save_session(session, sess)
|
|
225
|
+
except Exception as e:
|
|
226
|
+
debug('bundle js_id 回写失败', e)
|
|
227
|
+
except Exception as e:
|
|
228
|
+
debug('persist 脚本打包注册失败', e)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _remove_init_js(page, js_id):
|
|
232
|
+
"""移除 init-js,绕开 DrissionPage remove_init_js 的 _init_jss 门控。
|
|
233
|
+
|
|
234
|
+
DP 的 remove_init_js 只在 script_id in self._init_jss(本进程
|
|
235
|
+
实例属性,跨进程为空)时执行 CDP 调用——跨进程 unregister 静默
|
|
236
|
+
失效。直接调 Page.removeScriptToEvaluateOnNewDocument 绕过;
|
|
237
|
+
identifier 失效(session 已死/从未注册)吞异常返回 False。
|
|
238
|
+
"""
|
|
239
|
+
if not js_id:
|
|
240
|
+
return False
|
|
241
|
+
try:
|
|
242
|
+
page.run_cdp('Page.removeScriptToEvaluateOnNewDocument',
|
|
243
|
+
identifier=js_id)
|
|
244
|
+
# 同步清理 DP 内部注册表(若本进程注册过)
|
|
245
|
+
jss = getattr(page, '_init_jss', None)
|
|
246
|
+
if isinstance(jss, list) and js_id in jss:
|
|
247
|
+
jss.remove(js_id)
|
|
248
|
+
return True
|
|
249
|
+
except Exception:
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
|
|
128
253
|
_KNOWN_PREFIX = re.compile(
|
|
129
254
|
r'^(css[:=]|xpath[:=]|text[:=^$]|tag[:=^$]|@@?[\w]|ref:|pw:)', re.IGNORECASE)
|
|
130
255
|
_CSS_ID_CLASS = re.compile(r'^[#.][\w-]') # #id .class
|
|
@@ -296,6 +421,103 @@ def resolve_locator(locator: str, session: str = 'default', page=None) -> str:
|
|
|
296
421
|
raise SystemExit(1)
|
|
297
422
|
|
|
298
423
|
|
|
424
|
+
class CdpWaitGate:
|
|
425
|
+
"""CDP 事件驱动的等待唤醒门:事件到达即唤醒重检,无事件按 poll 节拍回落。
|
|
426
|
+
|
|
427
|
+
事件源:
|
|
428
|
+
- Runtime.bindingCalled:页内 watcher(MutationObserver + 低频 interval)
|
|
429
|
+
检测到条件变真后经 __dp_wait binding 上报——JS 侧探测零 CDP 轮询流量
|
|
430
|
+
- Page.frameNavigated/domContentEventFired/loadEventFired:导航唤醒
|
|
431
|
+
重检,并在新文档重注入 watcher(导航后 JS 上下文销毁)
|
|
432
|
+
|
|
433
|
+
arm() 任一步失败(旧内核不支持 addBinding / driver 不可用)或调用方
|
|
434
|
+
强制轮询 → armed=False,调用方走纯 _sleep 路径,语义完全等价。
|
|
435
|
+
"""
|
|
436
|
+
BIND_NAME = '__dp_wait'
|
|
437
|
+
_WATCHER = (
|
|
438
|
+
'(function(){if(window.__dp_wait_armed)return"armed";'
|
|
439
|
+
'window.__dp_wait_armed=1;var done=false;'
|
|
440
|
+
'function chk(){if(done)return;var v=false;'
|
|
441
|
+
'try{v=!!(__COND__)}catch(e){}'
|
|
442
|
+
'if(v){done=true;try{window.__dp_wait("hit")}catch(e){}}}'
|
|
443
|
+
'try{new MutationObserver(chk).observe('
|
|
444
|
+
'document.documentElement||document,'
|
|
445
|
+
'{subtree:true,childList:true,attributes:true,characterData:true})}'
|
|
446
|
+
'catch(e){}'
|
|
447
|
+
'window.__dp_wait_iv=setInterval(chk,250);chk();return "ok"})()')
|
|
448
|
+
_PAGE_EVENTS = ('Page.frameNavigated', 'Page.domContentEventFired',
|
|
449
|
+
'Page.loadEventFired')
|
|
450
|
+
_EVENTS = ('Runtime.bindingCalled',) + _PAGE_EVENTS
|
|
451
|
+
|
|
452
|
+
def __init__(self, page):
|
|
453
|
+
self.page = page
|
|
454
|
+
self.wake = threading.Event()
|
|
455
|
+
self.nav = threading.Event()
|
|
456
|
+
self.armed = False
|
|
457
|
+
self._driver = None
|
|
458
|
+
self._cond = None
|
|
459
|
+
|
|
460
|
+
def arm(self, cond_js=None, force_poll=False) -> bool:
|
|
461
|
+
"""注册 binding + Page 事件 + 注入页内 watcher。返回是否成功武装。"""
|
|
462
|
+
if force_poll:
|
|
463
|
+
return False
|
|
464
|
+
self._cond = cond_js
|
|
465
|
+
try:
|
|
466
|
+
self._driver = self.page.driver
|
|
467
|
+
self.page.run_cdp('Runtime.enable')
|
|
468
|
+
self.page.run_cdp('Runtime.addBinding', name=self.BIND_NAME)
|
|
469
|
+
self.page.run_cdp('Page.enable')
|
|
470
|
+
self._driver.set_callback('Runtime.bindingCalled', self._on_binding)
|
|
471
|
+
for ev in self._PAGE_EVENTS:
|
|
472
|
+
self._driver.set_callback(ev, self._on_nav)
|
|
473
|
+
self.armed = True
|
|
474
|
+
if cond_js:
|
|
475
|
+
self.reinject()
|
|
476
|
+
except Exception as e:
|
|
477
|
+
debug('事件驱动等待不可用,回落轮询', e)
|
|
478
|
+
self.disarm()
|
|
479
|
+
return self.armed
|
|
480
|
+
|
|
481
|
+
def reinject(self):
|
|
482
|
+
"""(导航后的新文档)重新注入页内条件 watcher。"""
|
|
483
|
+
if not (self.armed and self._cond):
|
|
484
|
+
return
|
|
485
|
+
try:
|
|
486
|
+
self.page.run_js(
|
|
487
|
+
self._WATCHER.replace('__COND__', f'({self._cond})'))
|
|
488
|
+
except Exception as e:
|
|
489
|
+
# 导航瞬断期注入失败无碍:后续 Page 事件/轮询节拍会再触发重试
|
|
490
|
+
debug('watcher 注入失败', e)
|
|
491
|
+
|
|
492
|
+
def wait(self, seconds: float) -> bool:
|
|
493
|
+
"""阻塞至事件唤醒或超时;返回 True=被事件唤醒。自动处理导航重注入。"""
|
|
494
|
+
fired = self.wake.wait(max(0.0, seconds))
|
|
495
|
+
self.wake.clear()
|
|
496
|
+
if self.nav.is_set():
|
|
497
|
+
self.nav.clear()
|
|
498
|
+
self.reinject()
|
|
499
|
+
return fired
|
|
500
|
+
|
|
501
|
+
def disarm(self):
|
|
502
|
+
"""摘除全部回调(binding 留在会话内无副作用)。"""
|
|
503
|
+
if self._driver is not None:
|
|
504
|
+
for ev in self._EVENTS:
|
|
505
|
+
try:
|
|
506
|
+
self._driver.set_callback(ev, None)
|
|
507
|
+
except Exception:
|
|
508
|
+
pass
|
|
509
|
+
self._driver = None
|
|
510
|
+
self.armed = False
|
|
511
|
+
|
|
512
|
+
def _on_binding(self, **kw):
|
|
513
|
+
if kw.get('name') == self.BIND_NAME:
|
|
514
|
+
self.wake.set()
|
|
515
|
+
|
|
516
|
+
def _on_nav(self, **kw):
|
|
517
|
+
self.nav.set()
|
|
518
|
+
self.wake.set()
|
|
519
|
+
|
|
520
|
+
|
|
299
521
|
def wait_network_idle(page, idle_time: float = 2.0, timeout: float = 30) -> bool:
|
|
300
522
|
"""等待网络空闲:通过 CDP 监听,直到无活跃请求持续 idle_time 秒。
|
|
301
523
|
|