dsh-site-bridge 0.3.3 → 0.4.1
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/README.md +27 -0
- package/README.ru.md +27 -0
- package/README.zh-CN.md +24 -0
- package/extension/_locales/en/messages.json +119 -40
- package/extension/_locales/ru/messages.json +119 -40
- package/extension/_locales/zh_CN/messages.json +119 -40
- package/extension/manifest.json +1 -1
- package/extension/popup.html +13 -1
- package/extension/popup.js +24 -0
- package/extension/sw.js +309 -2
- package/lib/index.js +58 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -59,6 +59,20 @@ that code in the live page, so "it should work" becomes "it works here".
|
|
|
59
59
|
focused (Chrome throttles only timers and animation there).
|
|
60
60
|
- **No stored credentials** — see [Security](#security).
|
|
61
61
|
|
|
62
|
+
### How this differs from browser automation plugins
|
|
63
|
+
|
|
64
|
+
There are plugins that hand an agent `browser_*` tools: navigate, click, type, read the page. This is
|
|
65
|
+
not one of them, even though it can do those things — input here is the instrument that verifies a
|
|
66
|
+
capture, not the product.
|
|
67
|
+
|
|
68
|
+
The product is the capture: the DOM with boxes and computed styles, theme CSS variables, selector
|
|
69
|
+
candidates checked for uniqueness, and the site's own xhr/fetch calls with headers and bodies, all
|
|
70
|
+
written to files an agent can read. Automation exists so that "this selector works" and "this BFF call
|
|
71
|
+
passes with my cookies" are checked against the live page instead of assumed.
|
|
72
|
+
|
|
73
|
+
The practical consequence: this plugin is built for writing an extension for one specific site, and
|
|
74
|
+
the input tools exist to verify that work against the live page rather than to browse the web.
|
|
75
|
+
|
|
62
76
|
## Requirements
|
|
63
77
|
|
|
64
78
|
- DeepSeek Harness with the `dsh` CLI and a profile (Desktop or Web).
|
|
@@ -69,6 +83,12 @@ that code in the live page, so "it should work" becomes "it works here".
|
|
|
69
83
|
|
|
70
84
|
### 1. Host half
|
|
71
85
|
|
|
86
|
+
```powershell
|
|
87
|
+
dsh plugin --profile desktop add dsh-site-bridge
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
That pulls the published package. From a local checkout instead:
|
|
91
|
+
|
|
72
92
|
```powershell
|
|
73
93
|
# from the repository root
|
|
74
94
|
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install.ps1
|
|
@@ -160,6 +180,13 @@ The wire protocol is small and documented in [docs/protocol.md](docs/protocol.md
|
|
|
160
180
|
puts a secret under an unusual key, redaction will not recognise it. Treat a capture bundle as
|
|
161
181
|
sensitive, exactly like any traffic dump.
|
|
162
182
|
|
|
183
|
+
### Trust model
|
|
184
|
+
|
|
185
|
+
The bridge accepts only requests that carry the pairing token. The extension asks for `debugger`
|
|
186
|
+
instead of `<all_urls>` and does not inject a content script into every page. The cost is a pairing
|
|
187
|
+
step and Chrome's debugging banner. In exchange the permission is granted per tab, for the duration
|
|
188
|
+
of a session, and requests are read where they are made — no credential is copied out of the browser.
|
|
189
|
+
|
|
163
190
|
## Limitations
|
|
164
191
|
|
|
165
192
|
Cross-origin iframes, closed shadow roots and body size caps are analysed in
|
package/README.ru.md
CHANGED
|
@@ -59,6 +59,20 @@ site_capture → captures/<host>/<timestamp>/
|
|
|
59
59
|
фокусе (Chrome тротлит там только таймеры и анимации).
|
|
60
60
|
- **Ничего не хранится из авторизации** — см. [Безопасность](#безопасность).
|
|
61
61
|
|
|
62
|
+
### Чем это отличается от плагинов автоматизации браузера
|
|
63
|
+
|
|
64
|
+
Есть плагины, которые дают агенту инструменты `browser_*`: навигация, клик, ввод, чтение страницы.
|
|
65
|
+
Это не один из них, хотя всё перечисленное умеет: ввод здесь — инструмент проверки снимка, а не
|
|
66
|
+
продукт.
|
|
67
|
+
|
|
68
|
+
Продукт — снимок: DOM с боксами и computed-стилями, CSS-переменные темы, кандидаты селекторов с
|
|
69
|
+
проверкой уникальности и собственные xhr/fetch сайта с заголовками и телами, разложенные по файлам,
|
|
70
|
+
которые агент читает. Автоматизация нужна, чтобы «этот селектор работает» и «этот вызов BFF проходит
|
|
71
|
+
с моими куками» проверялись на живой странице, а не предполагались.
|
|
72
|
+
|
|
73
|
+
Практическое следствие: плагин сделан для написания расширения под конкретный сайт, а инструменты
|
|
74
|
+
ввода нужны, чтобы проверять эту работу на живой странице, а не для браузинга.
|
|
75
|
+
|
|
62
76
|
## Требования
|
|
63
77
|
|
|
64
78
|
- DeepSeek Harness с CLI `dsh` и профилем (Desktop или Web).
|
|
@@ -69,6 +83,12 @@ site_capture → captures/<host>/<timestamp>/
|
|
|
69
83
|
|
|
70
84
|
### 1. Host-половина
|
|
71
85
|
|
|
86
|
+
```powershell
|
|
87
|
+
dsh plugin --profile desktop add dsh-site-bridge
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Это ставит опубликованный пакет. Из локального клона вместо этого:
|
|
91
|
+
|
|
72
92
|
```powershell
|
|
73
93
|
# из корня репозитория
|
|
74
94
|
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install.ps1
|
|
@@ -159,6 +179,13 @@ site_inject {js:"..."} → выполнить ваш скрипт в с
|
|
|
159
179
|
сайт кладёт секрет под нестандартным ключом, редакция его не узнает. Считайте пакет снимка
|
|
160
180
|
чувствительным, как и любой дамп трафика.
|
|
161
181
|
|
|
182
|
+
### Модель доверия
|
|
183
|
+
|
|
184
|
+
Мост принимает только запросы с токеном сопряжения. Расширение просит `debugger`, а не
|
|
185
|
+
`<all_urls>`, и не внедряет content script в каждую страницу. Цена — шаг сопряжения и плашка отладки
|
|
186
|
+
Chrome. Взамен право выдаётся на вкладку и на время сессии, а запросы читаются там, где они сделаны:
|
|
187
|
+
учётные данные не покидают браузер.
|
|
188
|
+
|
|
162
189
|
## Ограничения
|
|
163
190
|
|
|
164
191
|
Кросс-доменные iframe, закрытые shadow-корни и лимиты тела разобраны в
|
package/README.zh-CN.md
CHANGED
|
@@ -47,6 +47,18 @@ site_capture → captures/<host>/<timestamp>/
|
|
|
47
47
|
- **后台标签页可用** —— 标签页不在前台时,读取、点击、输入和截图仍然可用(Chrome 只在后台限制定时器与动画)。
|
|
48
48
|
- **不保存任何凭据** —— 见[安全](#安全)。
|
|
49
49
|
|
|
50
|
+
### 与浏览器自动化插件的区别
|
|
51
|
+
|
|
52
|
+
有些插件给智能体提供 `browser_*` 工具:导航、点击、输入、读取页面。本项目不是其中之一,虽然这些都
|
|
53
|
+
能做——这里的输入是用来验证抓取结果的工具,而不是产品本身。
|
|
54
|
+
|
|
55
|
+
产品是抓取:带盒子与计算样式的 DOM、主题 CSS 变量、经过唯一性校验的选择器候选,以及站点自身的
|
|
56
|
+
xhr/fetch(含请求头与请求体),全部写成智能体可读的文件。自动化的作用是让「这个选择器可用」和
|
|
57
|
+
「这个 BFF 调用能带着我的 Cookie 通过」在活页面上被验证,而不是被假设。
|
|
58
|
+
|
|
59
|
+
实际后果:本项目是为编写面向某个具体网站的扩展而做的,输入工具用来在活页面上验证这些工作,而不是
|
|
60
|
+
用来浏览网页。
|
|
61
|
+
|
|
50
62
|
## 环境要求
|
|
51
63
|
|
|
52
64
|
- 带 `dsh` CLI 与配置档(Desktop 或 Web)的 DeepSeek Harness。
|
|
@@ -57,6 +69,12 @@ site_capture → captures/<host>/<timestamp>/
|
|
|
57
69
|
|
|
58
70
|
### 1. 宿主部分
|
|
59
71
|
|
|
72
|
+
```powershell
|
|
73
|
+
dsh plugin --profile desktop add dsh-site-bridge
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
以上会安装已发布的 npm 包。从本地克隆安装则用:
|
|
77
|
+
|
|
60
78
|
```powershell
|
|
61
79
|
# 在仓库根目录
|
|
62
80
|
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install.ps1
|
|
@@ -129,6 +147,12 @@ site_inject {js:"..."} → 在页面中执行你的脚本以验证
|
|
|
129
147
|
- **仅回环。** 网桥只绑定 `127.0.0.1`,任何没有 32 字节配对令牌的请求都会被拒绝。`site_status` 打印的是令牌文件路径,而不是令牌本身。
|
|
130
148
|
- **残余风险。** 请求头与请求体仍会经过 DSH 进程的内存。如果站点把密钥放在非常规字段名下,脱敏无法识别。请像对待任何流量转储一样,把快照包视为敏感数据。
|
|
131
149
|
|
|
150
|
+
### 信任模型
|
|
151
|
+
|
|
152
|
+
网桥只接受携带配对令牌的请求。扩展申请 `debugger` 而不是 `<all_urls>`,也不会向每个页面注入
|
|
153
|
+
content script。代价是配对步骤与 Chrome 的调试提示条。换来的是:权限按标签页、按会话授予,请求在
|
|
154
|
+
产生的地方被读取,凭据不离开浏览器。
|
|
155
|
+
|
|
132
156
|
## 限制
|
|
133
157
|
|
|
134
158
|
跨域 iframe、封闭的 shadow root 与响应体大小上限在 [docs/limitations.md](docs/limitations.md) 中逐条分析 —— 包含代码位置、消除方案与预估成本。
|
|
@@ -1,50 +1,112 @@
|
|
|
1
1
|
{
|
|
2
|
-
"appName": {
|
|
2
|
+
"appName": {
|
|
3
|
+
"message": "DSH Site Bridge"
|
|
4
|
+
},
|
|
3
5
|
"appDesc": {
|
|
4
6
|
"message": "Hands and eyes for DeepSeek Harness in your own browser: page markup, BFF traffic, trusted input and a virtual cursor. Credentials stay in the browser."
|
|
5
7
|
},
|
|
6
|
-
"tagline": {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
"
|
|
10
|
-
|
|
8
|
+
"tagline": {
|
|
9
|
+
"message": "DeepSeek Harness in your browser"
|
|
10
|
+
},
|
|
11
|
+
"statusConnecting": {
|
|
12
|
+
"message": "Checking…"
|
|
13
|
+
},
|
|
14
|
+
"statusConnected": {
|
|
15
|
+
"message": "Bridge connected"
|
|
16
|
+
},
|
|
17
|
+
"statusNoBridge": {
|
|
18
|
+
"message": "Bridge not responding"
|
|
19
|
+
},
|
|
11
20
|
"statusRetrying": {
|
|
12
21
|
"message": "Waiting for the bridge… attempt $COUNT$",
|
|
13
|
-
"placeholders": {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"
|
|
22
|
+
"placeholders": {
|
|
23
|
+
"count": {
|
|
24
|
+
"content": "$1"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"statusNotConfigured": {
|
|
29
|
+
"message": "Not configured"
|
|
30
|
+
},
|
|
31
|
+
"statusWorkerDown": {
|
|
32
|
+
"message": "Extension service is not responding"
|
|
33
|
+
},
|
|
34
|
+
"errorToken": {
|
|
35
|
+
"message": "Token does not match the bridge — import bridge.json again"
|
|
36
|
+
},
|
|
37
|
+
"errorNoBridge": {
|
|
38
|
+
"message": "DSH is not running, or the bridge is not listening on this port"
|
|
39
|
+
},
|
|
40
|
+
"disconnect": {
|
|
41
|
+
"message": "Disconnect"
|
|
42
|
+
},
|
|
43
|
+
"targetNoneTitle": {
|
|
44
|
+
"message": "No tab connected"
|
|
45
|
+
},
|
|
46
|
+
"targetNoneUrl": {
|
|
47
|
+
"message": "Open the page you need and connect it"
|
|
48
|
+
},
|
|
49
|
+
"connectTab": {
|
|
50
|
+
"message": "Connect this tab"
|
|
51
|
+
},
|
|
52
|
+
"tabConnected": {
|
|
53
|
+
"message": "Tab connected"
|
|
54
|
+
},
|
|
55
|
+
"tabsLabel": {
|
|
56
|
+
"message": "Tabs"
|
|
57
|
+
},
|
|
58
|
+
"connectionTitle": {
|
|
59
|
+
"message": "Pairing"
|
|
60
|
+
},
|
|
61
|
+
"importBridge": {
|
|
62
|
+
"message": "Import bridge.json"
|
|
63
|
+
},
|
|
64
|
+
"portLabel": {
|
|
65
|
+
"message": "Bridge port"
|
|
66
|
+
},
|
|
67
|
+
"tokenLabel": {
|
|
68
|
+
"message": "Token"
|
|
69
|
+
},
|
|
70
|
+
"tokenPlaceholder": {
|
|
71
|
+
"message": "paste the token or import the file"
|
|
72
|
+
},
|
|
32
73
|
"tokenLength": {
|
|
33
74
|
"message": "$COUNT$ characters",
|
|
34
|
-
"placeholders": {
|
|
75
|
+
"placeholders": {
|
|
76
|
+
"count": {
|
|
77
|
+
"content": "$1"
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
"hintAutosave": {
|
|
82
|
+
"message": "Saved automatically, no buttons. Token file:"
|
|
83
|
+
},
|
|
84
|
+
"hintFile": {
|
|
85
|
+
"message": "~/.dsh/site-bridge/bridge.json"
|
|
86
|
+
},
|
|
87
|
+
"captureTitle": {
|
|
88
|
+
"message": "API recording"
|
|
35
89
|
},
|
|
36
|
-
"hintAutosave": { "message": "Saved automatically, no buttons. Token file:" },
|
|
37
|
-
"hintFile": { "message": "~/.dsh/site-bridge/bridge.json" },
|
|
38
|
-
|
|
39
|
-
"captureTitle": { "message": "API recording" },
|
|
40
90
|
"records": {
|
|
41
91
|
"message": "$COUNT$ requests",
|
|
42
|
-
"placeholders": {
|
|
92
|
+
"placeholders": {
|
|
93
|
+
"count": {
|
|
94
|
+
"content": "$1"
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
"captureStart": {
|
|
99
|
+
"message": "Start recording"
|
|
100
|
+
},
|
|
101
|
+
"captureStop": {
|
|
102
|
+
"message": "Stop recording"
|
|
103
|
+
},
|
|
104
|
+
"captureShortOn": {
|
|
105
|
+
"message": "API capture: on"
|
|
106
|
+
},
|
|
107
|
+
"captureShortOff": {
|
|
108
|
+
"message": "API capture: off"
|
|
43
109
|
},
|
|
44
|
-
"captureStart": { "message": "Start recording" },
|
|
45
|
-
"captureStop": { "message": "Stop recording" },
|
|
46
|
-
"captureShortOn": { "message": "API capture: on" },
|
|
47
|
-
"captureShortOff": { "message": "API capture: off" },
|
|
48
110
|
"bannerHintShort": {
|
|
49
111
|
"message": "The “started debugging” banner in Chrome is expected: input and network capture run over it."
|
|
50
112
|
},
|
|
@@ -54,15 +116,32 @@
|
|
|
54
116
|
"bannerHint": {
|
|
55
117
|
"message": "Chrome will show a “started debugging this browser” banner — that is expected: input and network capture run over it."
|
|
56
118
|
},
|
|
57
|
-
|
|
58
|
-
|
|
119
|
+
"imported": {
|
|
120
|
+
"message": "Settings imported"
|
|
121
|
+
},
|
|
59
122
|
"importFailed": {
|
|
60
123
|
"message": "Import failed: $ERROR$",
|
|
61
|
-
"placeholders": {
|
|
124
|
+
"placeholders": {
|
|
125
|
+
"error": {
|
|
126
|
+
"content": "$1"
|
|
127
|
+
}
|
|
128
|
+
}
|
|
62
129
|
},
|
|
63
130
|
"claimFailed": {
|
|
64
131
|
"message": "Could not connect: $ERROR$",
|
|
65
|
-
"placeholders": {
|
|
132
|
+
"placeholders": {
|
|
133
|
+
"error": {
|
|
134
|
+
"content": "$1"
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
"claimFailedUnknown": {
|
|
139
|
+
"message": "unknown error"
|
|
140
|
+
},
|
|
141
|
+
"pickHint": {
|
|
142
|
+
"message": "Pick an element on the page"
|
|
66
143
|
},
|
|
67
|
-
"
|
|
144
|
+
"pickArmed": {
|
|
145
|
+
"message": "Click an element on the page — Enter adds a note, Esc cancels"
|
|
146
|
+
}
|
|
68
147
|
}
|
|
@@ -1,50 +1,112 @@
|
|
|
1
1
|
{
|
|
2
|
-
"appName": {
|
|
2
|
+
"appName": {
|
|
3
|
+
"message": "DSH Site Bridge"
|
|
4
|
+
},
|
|
3
5
|
"appDesc": {
|
|
4
6
|
"message": "Глаза и руки DeepSeek Harness в вашем браузере: разметка страницы, трафик BFF, доверенный ввод и виртуальный курсор. Авторизация остаётся в браузере."
|
|
5
7
|
},
|
|
6
|
-
"tagline": {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
"
|
|
10
|
-
|
|
8
|
+
"tagline": {
|
|
9
|
+
"message": "DeepSeek Harness в вашем браузере"
|
|
10
|
+
},
|
|
11
|
+
"statusConnecting": {
|
|
12
|
+
"message": "Проверяю…"
|
|
13
|
+
},
|
|
14
|
+
"statusConnected": {
|
|
15
|
+
"message": "Мост подключён"
|
|
16
|
+
},
|
|
17
|
+
"statusNoBridge": {
|
|
18
|
+
"message": "Мост не отвечает"
|
|
19
|
+
},
|
|
11
20
|
"statusRetrying": {
|
|
12
21
|
"message": "Подключаюсь к мосту… попытка $COUNT$",
|
|
13
|
-
"placeholders": {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"
|
|
22
|
+
"placeholders": {
|
|
23
|
+
"count": {
|
|
24
|
+
"content": "$1"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"statusNotConfigured": {
|
|
29
|
+
"message": "Не настроен"
|
|
30
|
+
},
|
|
31
|
+
"statusWorkerDown": {
|
|
32
|
+
"message": "Служба расширения не отвечает"
|
|
33
|
+
},
|
|
34
|
+
"errorToken": {
|
|
35
|
+
"message": "Токен не совпал с мостом — импортируйте bridge.json заново"
|
|
36
|
+
},
|
|
37
|
+
"errorNoBridge": {
|
|
38
|
+
"message": "DSH не запущен или мост не слушает этот порт"
|
|
39
|
+
},
|
|
40
|
+
"disconnect": {
|
|
41
|
+
"message": "Отключить"
|
|
42
|
+
},
|
|
43
|
+
"targetNoneTitle": {
|
|
44
|
+
"message": "Вкладка не подключена"
|
|
45
|
+
},
|
|
46
|
+
"targetNoneUrl": {
|
|
47
|
+
"message": "Откройте нужную страницу и подключите её"
|
|
48
|
+
},
|
|
49
|
+
"connectTab": {
|
|
50
|
+
"message": "Подключить эту вкладку"
|
|
51
|
+
},
|
|
52
|
+
"tabConnected": {
|
|
53
|
+
"message": "Вкладка подключена"
|
|
54
|
+
},
|
|
55
|
+
"tabsLabel": {
|
|
56
|
+
"message": "Вкладки"
|
|
57
|
+
},
|
|
58
|
+
"connectionTitle": {
|
|
59
|
+
"message": "Подключение"
|
|
60
|
+
},
|
|
61
|
+
"importBridge": {
|
|
62
|
+
"message": "Импорт bridge.json"
|
|
63
|
+
},
|
|
64
|
+
"portLabel": {
|
|
65
|
+
"message": "Порт моста"
|
|
66
|
+
},
|
|
67
|
+
"tokenLabel": {
|
|
68
|
+
"message": "Токен"
|
|
69
|
+
},
|
|
70
|
+
"tokenPlaceholder": {
|
|
71
|
+
"message": "вставьте токен или импортируйте файл"
|
|
72
|
+
},
|
|
32
73
|
"tokenLength": {
|
|
33
74
|
"message": "$COUNT$ символов",
|
|
34
|
-
"placeholders": {
|
|
75
|
+
"placeholders": {
|
|
76
|
+
"count": {
|
|
77
|
+
"content": "$1"
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
"hintAutosave": {
|
|
82
|
+
"message": "Сохраняется само, без кнопок. Файл с токеном:"
|
|
83
|
+
},
|
|
84
|
+
"hintFile": {
|
|
85
|
+
"message": "~/.dsh/site-bridge/bridge.json"
|
|
86
|
+
},
|
|
87
|
+
"captureTitle": {
|
|
88
|
+
"message": "Запись API"
|
|
35
89
|
},
|
|
36
|
-
"hintAutosave": { "message": "Сохраняется само, без кнопок. Файл с токеном:" },
|
|
37
|
-
"hintFile": { "message": "~/.dsh/site-bridge/bridge.json" },
|
|
38
|
-
|
|
39
|
-
"captureTitle": { "message": "Запись API" },
|
|
40
90
|
"records": {
|
|
41
91
|
"message": "$COUNT$ запросов",
|
|
42
|
-
"placeholders": {
|
|
92
|
+
"placeholders": {
|
|
93
|
+
"count": {
|
|
94
|
+
"content": "$1"
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
"captureStart": {
|
|
99
|
+
"message": "Включить запись"
|
|
100
|
+
},
|
|
101
|
+
"captureStop": {
|
|
102
|
+
"message": "Остановить запись"
|
|
103
|
+
},
|
|
104
|
+
"captureShortOn": {
|
|
105
|
+
"message": "Запись API: вкл"
|
|
106
|
+
},
|
|
107
|
+
"captureShortOff": {
|
|
108
|
+
"message": "Запись API: выкл"
|
|
43
109
|
},
|
|
44
|
-
"captureStart": { "message": "Включить запись" },
|
|
45
|
-
"captureStop": { "message": "Остановить запись" },
|
|
46
|
-
"captureShortOn": { "message": "Запись API: вкл" },
|
|
47
|
-
"captureShortOff": { "message": "Запись API: выкл" },
|
|
48
110
|
"bannerHintShort": {
|
|
49
111
|
"message": "Плашка «началась отладка» в Chrome — ожидаема: через неё идёт ввод и чтение сети."
|
|
50
112
|
},
|
|
@@ -54,15 +116,32 @@
|
|
|
54
116
|
"bannerHint": {
|
|
55
117
|
"message": "Chrome покажет плашку «началась отладка» — это ожидаемо: через неё работает ввод и чтение сети."
|
|
56
118
|
},
|
|
57
|
-
|
|
58
|
-
|
|
119
|
+
"imported": {
|
|
120
|
+
"message": "Настройки импортированы"
|
|
121
|
+
},
|
|
59
122
|
"importFailed": {
|
|
60
123
|
"message": "Импорт не удался: $ERROR$",
|
|
61
|
-
"placeholders": {
|
|
124
|
+
"placeholders": {
|
|
125
|
+
"error": {
|
|
126
|
+
"content": "$1"
|
|
127
|
+
}
|
|
128
|
+
}
|
|
62
129
|
},
|
|
63
130
|
"claimFailed": {
|
|
64
131
|
"message": "Не удалось подключить: $ERROR$",
|
|
65
|
-
"placeholders": {
|
|
132
|
+
"placeholders": {
|
|
133
|
+
"error": {
|
|
134
|
+
"content": "$1"
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
"claimFailedUnknown": {
|
|
139
|
+
"message": "неизвестная ошибка"
|
|
140
|
+
},
|
|
141
|
+
"pickHint": {
|
|
142
|
+
"message": "Выбрать элемент на странице"
|
|
66
143
|
},
|
|
67
|
-
"
|
|
144
|
+
"pickArmed": {
|
|
145
|
+
"message": "Кликните по элементу на странице — Enter добавит примечание, Esc отменит"
|
|
146
|
+
}
|
|
68
147
|
}
|
|
@@ -1,50 +1,112 @@
|
|
|
1
1
|
{
|
|
2
|
-
"appName": {
|
|
2
|
+
"appName": {
|
|
3
|
+
"message": "DSH Site Bridge"
|
|
4
|
+
},
|
|
3
5
|
"appDesc": {
|
|
4
6
|
"message": "在您自己的浏览器中为 DeepSeek Harness 提供眼睛和双手:页面结构、BFF 流量、可信输入和虚拟光标。凭据始终留在浏览器中。"
|
|
5
7
|
},
|
|
6
|
-
"tagline": {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
"
|
|
10
|
-
|
|
8
|
+
"tagline": {
|
|
9
|
+
"message": "DeepSeek Harness 在您的浏览器中"
|
|
10
|
+
},
|
|
11
|
+
"statusConnecting": {
|
|
12
|
+
"message": "正在检查…"
|
|
13
|
+
},
|
|
14
|
+
"statusConnected": {
|
|
15
|
+
"message": "网桥已连接"
|
|
16
|
+
},
|
|
17
|
+
"statusNoBridge": {
|
|
18
|
+
"message": "网桥无响应"
|
|
19
|
+
},
|
|
11
20
|
"statusRetrying": {
|
|
12
21
|
"message": "正在连接网桥… 第 $COUNT$ 次",
|
|
13
|
-
"placeholders": {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"
|
|
22
|
+
"placeholders": {
|
|
23
|
+
"count": {
|
|
24
|
+
"content": "$1"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"statusNotConfigured": {
|
|
29
|
+
"message": "尚未配置"
|
|
30
|
+
},
|
|
31
|
+
"statusWorkerDown": {
|
|
32
|
+
"message": "扩展服务未响应"
|
|
33
|
+
},
|
|
34
|
+
"errorToken": {
|
|
35
|
+
"message": "令牌与网桥不匹配——请重新导入 bridge.json"
|
|
36
|
+
},
|
|
37
|
+
"errorNoBridge": {
|
|
38
|
+
"message": "DSH 未运行,或网桥未监听该端口"
|
|
39
|
+
},
|
|
40
|
+
"disconnect": {
|
|
41
|
+
"message": "断开"
|
|
42
|
+
},
|
|
43
|
+
"targetNoneTitle": {
|
|
44
|
+
"message": "未连接标签页"
|
|
45
|
+
},
|
|
46
|
+
"targetNoneUrl": {
|
|
47
|
+
"message": "打开需要的页面并连接它"
|
|
48
|
+
},
|
|
49
|
+
"connectTab": {
|
|
50
|
+
"message": "连接此标签页"
|
|
51
|
+
},
|
|
52
|
+
"tabConnected": {
|
|
53
|
+
"message": "标签页已连接"
|
|
54
|
+
},
|
|
55
|
+
"tabsLabel": {
|
|
56
|
+
"message": "标签页"
|
|
57
|
+
},
|
|
58
|
+
"connectionTitle": {
|
|
59
|
+
"message": "配对"
|
|
60
|
+
},
|
|
61
|
+
"importBridge": {
|
|
62
|
+
"message": "导入 bridge.json"
|
|
63
|
+
},
|
|
64
|
+
"portLabel": {
|
|
65
|
+
"message": "网桥端口"
|
|
66
|
+
},
|
|
67
|
+
"tokenLabel": {
|
|
68
|
+
"message": "令牌"
|
|
69
|
+
},
|
|
70
|
+
"tokenPlaceholder": {
|
|
71
|
+
"message": "粘贴令牌或导入文件"
|
|
72
|
+
},
|
|
32
73
|
"tokenLength": {
|
|
33
74
|
"message": "$COUNT$ 个字符",
|
|
34
|
-
"placeholders": {
|
|
75
|
+
"placeholders": {
|
|
76
|
+
"count": {
|
|
77
|
+
"content": "$1"
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
"hintAutosave": {
|
|
82
|
+
"message": "自动保存,无需按钮。令牌文件:"
|
|
83
|
+
},
|
|
84
|
+
"hintFile": {
|
|
85
|
+
"message": "~/.dsh/site-bridge/bridge.json"
|
|
86
|
+
},
|
|
87
|
+
"captureTitle": {
|
|
88
|
+
"message": "API 录制"
|
|
35
89
|
},
|
|
36
|
-
"hintAutosave": { "message": "自动保存,无需按钮。令牌文件:" },
|
|
37
|
-
"hintFile": { "message": "~/.dsh/site-bridge/bridge.json" },
|
|
38
|
-
|
|
39
|
-
"captureTitle": { "message": "API 录制" },
|
|
40
90
|
"records": {
|
|
41
91
|
"message": "$COUNT$ 条请求",
|
|
42
|
-
"placeholders": {
|
|
92
|
+
"placeholders": {
|
|
93
|
+
"count": {
|
|
94
|
+
"content": "$1"
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
"captureStart": {
|
|
99
|
+
"message": "开始录制"
|
|
100
|
+
},
|
|
101
|
+
"captureStop": {
|
|
102
|
+
"message": "停止录制"
|
|
103
|
+
},
|
|
104
|
+
"captureShortOn": {
|
|
105
|
+
"message": "接口录制:开"
|
|
106
|
+
},
|
|
107
|
+
"captureShortOff": {
|
|
108
|
+
"message": "接口录制:关"
|
|
43
109
|
},
|
|
44
|
-
"captureStart": { "message": "开始录制" },
|
|
45
|
-
"captureStop": { "message": "停止录制" },
|
|
46
|
-
"captureShortOn": { "message": "接口录制:开" },
|
|
47
|
-
"captureShortOff": { "message": "接口录制:关" },
|
|
48
110
|
"bannerHintShort": {
|
|
49
111
|
"message": "Chrome 的「已开始调试」提示条是正常的:输入与网络抓取都通过它进行。"
|
|
50
112
|
},
|
|
@@ -54,15 +116,32 @@
|
|
|
54
116
|
"bannerHint": {
|
|
55
117
|
"message": "Chrome 会显示「已开始调试此浏览器」提示条——这是正常的:输入和网络抓取都通过它进行。"
|
|
56
118
|
},
|
|
57
|
-
|
|
58
|
-
|
|
119
|
+
"imported": {
|
|
120
|
+
"message": "设置已导入"
|
|
121
|
+
},
|
|
59
122
|
"importFailed": {
|
|
60
123
|
"message": "导入失败:$ERROR$",
|
|
61
|
-
"placeholders": {
|
|
124
|
+
"placeholders": {
|
|
125
|
+
"error": {
|
|
126
|
+
"content": "$1"
|
|
127
|
+
}
|
|
128
|
+
}
|
|
62
129
|
},
|
|
63
130
|
"claimFailed": {
|
|
64
131
|
"message": "连接失败:$ERROR$",
|
|
65
|
-
"placeholders": {
|
|
132
|
+
"placeholders": {
|
|
133
|
+
"error": {
|
|
134
|
+
"content": "$1"
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
"claimFailedUnknown": {
|
|
139
|
+
"message": "未知错误"
|
|
140
|
+
},
|
|
141
|
+
"pickHint": {
|
|
142
|
+
"message": "在页面上选择元素"
|
|
66
143
|
},
|
|
67
|
-
"
|
|
144
|
+
"pickArmed": {
|
|
145
|
+
"message": "点击页面上的元素 — Enter 添加备注,Esc 取消"
|
|
146
|
+
}
|
|
68
147
|
}
|
package/extension/manifest.json
CHANGED
package/extension/popup.html
CHANGED
|
@@ -78,6 +78,13 @@
|
|
|
78
78
|
button.primary:hover:not(:disabled) { background: #6b8aff; border-color: #6b8aff; }
|
|
79
79
|
button.primary:disabled { background: #20242d; border-color: var(--line); color: var(--muted); font-weight: 500; }
|
|
80
80
|
button.ghost { padding: 3px 8px; font-size: 11px; background: transparent; color: var(--muted); }
|
|
81
|
+
button.icon {
|
|
82
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
83
|
+
padding: 4px; width: 26px; height: 26px; background: transparent;
|
|
84
|
+
border: 1px solid var(--line); border-radius: 6px; color: var(--muted);
|
|
85
|
+
}
|
|
86
|
+
button.icon:hover:not(:disabled) { color: var(--text); border-color: #3a4150; }
|
|
87
|
+
button.icon.active { color: var(--accent); border-color: var(--accent); }
|
|
81
88
|
button.ghost:hover:not(:disabled) { color: var(--text); }
|
|
82
89
|
button.link {
|
|
83
90
|
padding: 0; border: none; background: none; color: var(--accent);
|
|
@@ -132,7 +139,12 @@
|
|
|
132
139
|
<section class="card">
|
|
133
140
|
<div class="card-head">
|
|
134
141
|
<span class="pill"><i class="dot" id="dot"></i><i class="spin" id="spin" hidden></i><span id="connection-text" data-i18n="statusConnecting">Проверяю…</span></span>
|
|
135
|
-
<
|
|
142
|
+
<span style="display:flex;gap:6px;align-items:center">
|
|
143
|
+
<button class="icon" id="pick" data-i18n-title="pickHint" title="Pick an element">
|
|
144
|
+
<svg width="14" height="14" viewBox="0 0 20 20" aria-hidden="true"><path d="M3 2 L3 16 L7.2 12.2 L9.6 17.6 L12.2 16.4 L9.8 11.2 L15 11.2 Z" fill="currentColor"/></svg>
|
|
145
|
+
</button>
|
|
146
|
+
<button class="ghost" id="release" hidden data-i18n="disconnect">Отключить</button>
|
|
147
|
+
</span>
|
|
136
148
|
</div>
|
|
137
149
|
|
|
138
150
|
<div class="target empty" id="target-block">
|
package/extension/popup.js
CHANGED
|
@@ -19,6 +19,10 @@ function applyLocale() {
|
|
|
19
19
|
const message = t(node.dataset.i18n)
|
|
20
20
|
if (message && message !== node.dataset.i18n) node.textContent = message
|
|
21
21
|
}
|
|
22
|
+
for (const node of document.querySelectorAll('[data-i18n-title]')) {
|
|
23
|
+
const message = t(node.dataset.i18nTitle)
|
|
24
|
+
if (message && message !== node.dataset.i18nTitle) node.title = message
|
|
25
|
+
}
|
|
22
26
|
for (const node of document.querySelectorAll('[data-i18n-placeholder]')) {
|
|
23
27
|
const message = t(node.dataset.i18nPlaceholder)
|
|
24
28
|
if (message && message !== node.dataset.i18nPlaceholder) node.placeholder = message
|
|
@@ -117,6 +121,10 @@ function render(status) {
|
|
|
117
121
|
claim.textContent = hasTarget ? t('tabConnected') : t('connectTab')
|
|
118
122
|
release.hidden = !hasTarget
|
|
119
123
|
|
|
124
|
+
const pick = $('pick')
|
|
125
|
+
pick.classList.toggle('active', status.picking === true)
|
|
126
|
+
pick.disabled = !connected || !hasTarget
|
|
127
|
+
|
|
120
128
|
capture.disabled = !connected
|
|
121
129
|
capture.textContent = status.recording ? t('captureShortOn') : t('captureShortOff')
|
|
122
130
|
$('captured').textContent = t('records', [String(status.captured || 0)])
|
|
@@ -243,6 +251,22 @@ $('release').addEventListener('click', async () => {
|
|
|
243
251
|
await refresh()
|
|
244
252
|
})
|
|
245
253
|
|
|
254
|
+
$('pick').addEventListener('click', async () => {
|
|
255
|
+
const status = await send({ type: 'status' })
|
|
256
|
+
if (status?.picking) {
|
|
257
|
+
await send({ type: 'release' }).catch(() => {})
|
|
258
|
+
await refresh()
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
const result = await send({ type: 'pick-arm' })
|
|
262
|
+
if (!result?.ok) {
|
|
263
|
+
setNote(String(result?.error ?? 'pick failed'), true)
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
setNote(t('pickArmed'))
|
|
267
|
+
await refresh()
|
|
268
|
+
})
|
|
269
|
+
|
|
246
270
|
$('tabs-select').addEventListener('change', async (event) => {
|
|
247
271
|
const tabId = Number(event.target.value)
|
|
248
272
|
if (!Number.isFinite(tabId)) return
|
package/extension/sw.js
CHANGED
|
@@ -194,9 +194,17 @@ async function handleMessageInner(message) {
|
|
|
194
194
|
captured: session.entries.length
|
|
195
195
|
})),
|
|
196
196
|
recording: active?.recording ?? false,
|
|
197
|
-
captured: active?.entries.length ?? 0
|
|
197
|
+
captured: active?.entries.length ?? 0,
|
|
198
|
+
picking: active?.picking === true,
|
|
199
|
+
picks: active?.picks?.length ?? 0
|
|
198
200
|
}
|
|
199
201
|
}
|
|
202
|
+
if (message?.type === 'pick-arm') {
|
|
203
|
+
const session = requireSession(message.tabId)
|
|
204
|
+
await armPicker(session.tabId)
|
|
205
|
+
session.picking = true
|
|
206
|
+
return { ok: true, picking: true }
|
|
207
|
+
}
|
|
200
208
|
if (message?.type === 'save') {
|
|
201
209
|
await chrome.storage.local.set({ port: Number(message.port) || 47321, token: String(message.token || '') })
|
|
202
210
|
state.lastPollOk = 0
|
|
@@ -252,7 +260,9 @@ async function attach(tabId) {
|
|
|
252
260
|
tabId,
|
|
253
261
|
target: { id: tab.id, url: tab.url, title: tab.title },
|
|
254
262
|
recording: false,
|
|
255
|
-
entries: []
|
|
263
|
+
entries: [],
|
|
264
|
+
picking: false,
|
|
265
|
+
picks: []
|
|
256
266
|
}
|
|
257
267
|
state.sessions.set(tabId, session)
|
|
258
268
|
state.activeTabId = tabId
|
|
@@ -426,6 +436,235 @@ async function setRecording(session, on) {
|
|
|
426
436
|
return session.recording
|
|
427
437
|
}
|
|
428
438
|
|
|
439
|
+
/* ------------------------------------------------------------------ picker */
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Injected into the page: highlights the element under the cursor, and on click
|
|
443
|
+
* asks for a note, then publishes the result on `window.__dshPickResult`.
|
|
444
|
+
* Enter saves, Shift+Enter adds a line, Escape cancels the whole pick.
|
|
445
|
+
*/
|
|
446
|
+
function pickerInPage() {
|
|
447
|
+
const HOST_ID = '__dsh_pick_overlay__'
|
|
448
|
+
document.getElementById(HOST_ID)?.remove()
|
|
449
|
+
window.__dshPickResult = null
|
|
450
|
+
|
|
451
|
+
const box = document.createElement('div')
|
|
452
|
+
box.id = HOST_ID
|
|
453
|
+
box.style.cssText = [
|
|
454
|
+
'position:fixed', 'left:0', 'top:0', 'width:0', 'height:0', 'z-index:2147483646',
|
|
455
|
+
'pointer-events:none', 'border:2px solid #5b7cff', 'border-radius:4px',
|
|
456
|
+
'background:rgba(91,124,255,.12)'
|
|
457
|
+
].join(';')
|
|
458
|
+
|
|
459
|
+
const tip = document.createElement('div')
|
|
460
|
+
tip.style.cssText = [
|
|
461
|
+
'position:fixed', 'z-index:2147483647', 'pointer-events:none', 'background:#14161b',
|
|
462
|
+
'color:#e8eaf0', 'border:1px solid #2a2f3a', 'border-radius:4px', 'padding:2px 6px',
|
|
463
|
+
'font:11px/1.5 monospace', 'max-width:70vw', 'white-space:nowrap', 'overflow:hidden',
|
|
464
|
+
'text-overflow:ellipsis'
|
|
465
|
+
].join(';')
|
|
466
|
+
tip.hidden = true
|
|
467
|
+
document.documentElement.append(box, tip)
|
|
468
|
+
|
|
469
|
+
const cssEsc = (value) => (window.CSS && CSS.escape ? CSS.escape(value) : String(value).replace(/[^a-zA-Z0-9_-]/g, '\\$&'))
|
|
470
|
+
const count = (selector) => {
|
|
471
|
+
try { return document.querySelectorAll(selector).length } catch { return -1 }
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function selectorFor(el) {
|
|
475
|
+
const candidates = []
|
|
476
|
+
if (el.id) candidates.push('#' + cssEsc(el.id))
|
|
477
|
+
for (const attr of ['data-testid', 'data-test', 'data-qa', 'data-cy']) {
|
|
478
|
+
const value = el.getAttribute(attr)
|
|
479
|
+
if (value) candidates.push('[' + attr + '="' + value.replace(/"/g, '\\"') + '"]')
|
|
480
|
+
}
|
|
481
|
+
if (el.getAttribute('name')) {
|
|
482
|
+
candidates.push(el.tagName.toLowerCase() + '[name="' + el.getAttribute('name').replace(/"/g, '\\"') + '"]')
|
|
483
|
+
}
|
|
484
|
+
if (el.getAttribute('aria-label')) {
|
|
485
|
+
candidates.push('[aria-label="' + el.getAttribute('aria-label').replace(/"/g, '\\"') + '"]')
|
|
486
|
+
}
|
|
487
|
+
const classes = [...el.classList].filter((name) => /^[a-zA-Z][\w-]{2,}$/.test(name)).slice(0, 2)
|
|
488
|
+
if (classes.length > 0) {
|
|
489
|
+
candidates.push(el.tagName.toLowerCase() + classes.map((name) => '.' + cssEsc(name)).join(''))
|
|
490
|
+
}
|
|
491
|
+
const parts = []
|
|
492
|
+
let current = el
|
|
493
|
+
while (current && current.nodeType === 1 && parts.length < 8) {
|
|
494
|
+
let part = current.tagName.toLowerCase()
|
|
495
|
+
if (current.id) { parts.unshift('#' + cssEsc(current.id)); break }
|
|
496
|
+
const parent = current.parentElement
|
|
497
|
+
if (parent) {
|
|
498
|
+
const siblings = [...parent.children].filter((node) => node.tagName === current.tagName)
|
|
499
|
+
if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(current) + 1) + ')'
|
|
500
|
+
}
|
|
501
|
+
parts.unshift(part)
|
|
502
|
+
current = parent
|
|
503
|
+
}
|
|
504
|
+
candidates.push(parts.join(' > '))
|
|
505
|
+
const unique = candidates.find((selector) => count(selector) === 1)
|
|
506
|
+
return unique ?? candidates[candidates.length - 1]
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function xpathFor(el) {
|
|
510
|
+
if (el.id && count('#' + cssEsc(el.id)) === 1) return '//*[@id="' + el.id + '"]'
|
|
511
|
+
const parts = []
|
|
512
|
+
let current = el
|
|
513
|
+
while (current && current.nodeType === 1) {
|
|
514
|
+
let index = 1
|
|
515
|
+
let sibling = current.previousElementSibling
|
|
516
|
+
while (sibling) {
|
|
517
|
+
if (sibling.tagName === current.tagName) index += 1
|
|
518
|
+
sibling = sibling.previousElementSibling
|
|
519
|
+
}
|
|
520
|
+
const total = current.parentElement
|
|
521
|
+
? [...current.parentElement.children].filter((node) => node.tagName === current.tagName).length
|
|
522
|
+
: 1
|
|
523
|
+
parts.unshift(current.tagName.toLowerCase() + (total > 1 ? '[' + index + ']' : ''))
|
|
524
|
+
current = current.parentElement
|
|
525
|
+
}
|
|
526
|
+
return '/' + parts.join('/')
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
let panel = null
|
|
530
|
+
let hovered = null
|
|
531
|
+
|
|
532
|
+
function highlight(el) {
|
|
533
|
+
const rect = el.getBoundingClientRect()
|
|
534
|
+
box.style.left = rect.left + 'px'
|
|
535
|
+
box.style.top = rect.top + 'px'
|
|
536
|
+
box.style.width = rect.width + 'px'
|
|
537
|
+
box.style.height = rect.height + 'px'
|
|
538
|
+
tip.textContent = el.tagName.toLowerCase() + (el.id ? '#' + el.id : '')
|
|
539
|
+
tip.hidden = false
|
|
540
|
+
tip.style.left = Math.max(4, Math.min(rect.left, window.innerWidth - 40)) + 'px'
|
|
541
|
+
tip.style.top = Math.max(2, rect.top - 20) + 'px'
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function hideBox() {
|
|
545
|
+
box.style.width = '0'
|
|
546
|
+
box.style.height = '0'
|
|
547
|
+
tip.hidden = true
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function onMove(event) {
|
|
551
|
+
if (panel) return
|
|
552
|
+
const el = document.elementFromPoint(event.clientX, event.clientY)
|
|
553
|
+
if (!el || el === box || el === tip || (panel && panel.contains(el))) return
|
|
554
|
+
hovered = el
|
|
555
|
+
highlight(el)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function onClick(event) {
|
|
559
|
+
if (panel) return
|
|
560
|
+
event.preventDefault()
|
|
561
|
+
event.stopPropagation()
|
|
562
|
+
event.stopImmediatePropagation()
|
|
563
|
+
if (hovered) askNote(hovered)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function onKey(event) {
|
|
567
|
+
if (event.key === 'Escape' && !panel) finish(null, true)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function askNote(el) {
|
|
571
|
+
const rect = el.getBoundingClientRect()
|
|
572
|
+
panel = document.createElement('div')
|
|
573
|
+
panel.style.cssText = [
|
|
574
|
+
'position:fixed', 'z-index:2147483647', 'background:#14161b', 'color:#e8eaf0',
|
|
575
|
+
'border:1px solid #5b7cff', 'border-radius:8px', 'padding:8px', 'width:260px',
|
|
576
|
+
'font:12px/1.45 "Segoe UI",system-ui,sans-serif', 'box-shadow:0 8px 24px rgba(0,0,0,.45)'
|
|
577
|
+
].join(';')
|
|
578
|
+
panel.style.left = Math.max(8, Math.min(rect.left, window.innerWidth - 272)) + 'px'
|
|
579
|
+
panel.style.top = Math.min(window.innerHeight - 120, Math.max(8, rect.bottom + 8)) + 'px'
|
|
580
|
+
|
|
581
|
+
const hint = document.createElement('div')
|
|
582
|
+
hint.textContent = 'Note — Enter to save, Esc to cancel'
|
|
583
|
+
hint.style.cssText = 'color:#99a1ae;font-size:11px;margin-bottom:4px'
|
|
584
|
+
|
|
585
|
+
const input = document.createElement('textarea')
|
|
586
|
+
input.placeholder = 'what is this element for?'
|
|
587
|
+
input.style.cssText = 'width:100%;height:46px;resize:none;background:#15181e;color:#e8eaf0;border:1px solid #2a2f3a;border-radius:6px;padding:5px;font:inherit'
|
|
588
|
+
|
|
589
|
+
panel.append(hint, input)
|
|
590
|
+
document.documentElement.appendChild(panel)
|
|
591
|
+
input.focus()
|
|
592
|
+
|
|
593
|
+
input.addEventListener('mousedown', (event) => event.stopPropagation())
|
|
594
|
+
input.addEventListener('click', (event) => event.stopPropagation())
|
|
595
|
+
input.addEventListener('keydown', (event) => {
|
|
596
|
+
event.stopPropagation()
|
|
597
|
+
if (event.key === 'Escape') {
|
|
598
|
+
event.preventDefault()
|
|
599
|
+
closePanel()
|
|
600
|
+
}
|
|
601
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
602
|
+
event.preventDefault()
|
|
603
|
+
finish(el, false, input.value.trim())
|
|
604
|
+
}
|
|
605
|
+
})
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function closePanel() {
|
|
609
|
+
panel?.remove()
|
|
610
|
+
panel = null
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function cleanup() {
|
|
614
|
+
document.removeEventListener('mousemove', onMove, true)
|
|
615
|
+
document.removeEventListener('click', onClick, true)
|
|
616
|
+
document.removeEventListener('keydown', onKey, true)
|
|
617
|
+
box.remove()
|
|
618
|
+
tip.remove()
|
|
619
|
+
closePanel()
|
|
620
|
+
window.__dshPicker = null
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function finish(el, cancelled, note) {
|
|
624
|
+
const rect = el ? el.getBoundingClientRect() : null
|
|
625
|
+
closePanel()
|
|
626
|
+
cleanup()
|
|
627
|
+
window.__dshPickResult = cancelled || !el
|
|
628
|
+
? { cancelled: true }
|
|
629
|
+
: {
|
|
630
|
+
selector: selectorFor(el),
|
|
631
|
+
xpath: xpathFor(el),
|
|
632
|
+
text: (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 160),
|
|
633
|
+
tag: el.tagName.toLowerCase(),
|
|
634
|
+
note: note || '',
|
|
635
|
+
rect: { x: Math.round(rect.left), y: Math.round(rect.top), w: Math.round(rect.width), h: Math.round(rect.height) },
|
|
636
|
+
url: location.href,
|
|
637
|
+
at: Date.now()
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
hideBox()
|
|
642
|
+
document.addEventListener('mousemove', onMove, true)
|
|
643
|
+
document.addEventListener('click', onClick, true)
|
|
644
|
+
document.addEventListener('keydown', onKey, true)
|
|
645
|
+
window.__dshPicker = { stop: () => finish(null, true) }
|
|
646
|
+
return true
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
async function armPicker(tabId) {
|
|
650
|
+
await cdp('Runtime.evaluate', { expression: `(${pickerInPage.toString()})()`, returnByValue: true }, tabId)
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async function stopPicker(tabId) {
|
|
654
|
+
await cdp('Runtime.evaluate', {
|
|
655
|
+
expression: 'window.__dshPicker && window.__dshPicker.stop ? window.__dshPicker.stop() : null',
|
|
656
|
+
returnByValue: true
|
|
657
|
+
}, tabId).catch(() => {})
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async function readPick(tabId) {
|
|
661
|
+
const { result } = await cdp('Runtime.evaluate', {
|
|
662
|
+
expression: 'window.__dshPickResult || null',
|
|
663
|
+
returnByValue: true
|
|
664
|
+
}, tabId)
|
|
665
|
+
return result?.value ?? null
|
|
666
|
+
}
|
|
667
|
+
|
|
429
668
|
/* ------------------------------------------------------------------ cursor */
|
|
430
669
|
|
|
431
670
|
const CURSOR_ID = '__dsh_site_bridge_cursor__'
|
|
@@ -877,6 +1116,25 @@ function snapshotInPage(options) {
|
|
|
877
1116
|
return parts.join(' > ')
|
|
878
1117
|
}
|
|
879
1118
|
|
|
1119
|
+
function xpathFor(el) {
|
|
1120
|
+
const parts = []
|
|
1121
|
+
let current = el
|
|
1122
|
+
while (current && current.nodeType === 1) {
|
|
1123
|
+
let index = 1
|
|
1124
|
+
let sibling = current.previousElementSibling
|
|
1125
|
+
while (sibling) {
|
|
1126
|
+
if (sibling.tagName === current.tagName) index += 1
|
|
1127
|
+
sibling = sibling.previousElementSibling
|
|
1128
|
+
}
|
|
1129
|
+
const total = current.parentElement
|
|
1130
|
+
? [...current.parentElement.children].filter((node) => node.tagName === current.tagName).length
|
|
1131
|
+
: 1
|
|
1132
|
+
parts.unshift(current.tagName.toLowerCase() + (total > 1 ? '[' + index + ']' : ''))
|
|
1133
|
+
current = current.parentElement
|
|
1134
|
+
}
|
|
1135
|
+
return '/' + parts.join('/')
|
|
1136
|
+
}
|
|
1137
|
+
|
|
880
1138
|
// Everything is resolved inside `scope` (document or shadow root), and the
|
|
881
1139
|
// result is prefixed with the chain that reaches that scope.
|
|
882
1140
|
function candidates(el, scope, prefix) {
|
|
@@ -931,6 +1189,7 @@ function snapshotInPage(options) {
|
|
|
931
1189
|
vis: visible,
|
|
932
1190
|
d: depth,
|
|
933
1191
|
frame: prefix === '' ? null : prefix.replace(/ >>> $/, ''),
|
|
1192
|
+
xpath: xpathFor(el),
|
|
934
1193
|
sel: candidates(el, scope, prefix)
|
|
935
1194
|
}
|
|
936
1195
|
nodes.push(node)
|
|
@@ -1109,6 +1368,54 @@ async function dispatch(op, args) {
|
|
|
1109
1368
|
await evt('netclear', { tabId: session.tabId })
|
|
1110
1369
|
return { captured: 0 }
|
|
1111
1370
|
}
|
|
1371
|
+
case 'pick.arm': {
|
|
1372
|
+
const session = requireSession(args.tabId)
|
|
1373
|
+
await armPicker(session.tabId)
|
|
1374
|
+
session.picking = true
|
|
1375
|
+
return { picking: true, tabId: session.tabId }
|
|
1376
|
+
}
|
|
1377
|
+
case 'pick.wait': {
|
|
1378
|
+
const session = requireSession(args.tabId)
|
|
1379
|
+
await armPicker(session.tabId)
|
|
1380
|
+
session.picking = true
|
|
1381
|
+
const deadline = Date.now() + Math.min(Number(args.timeoutMs ?? 60000), 120000)
|
|
1382
|
+
while (Date.now() < deadline) {
|
|
1383
|
+
const pick = await readPick(session.tabId)
|
|
1384
|
+
if (pick) {
|
|
1385
|
+
session.picking = false
|
|
1386
|
+
if (pick.cancelled) throw new Error('выбор элемента отменён')
|
|
1387
|
+
session.picks = [...(session.picks ?? []), pick].slice(-20)
|
|
1388
|
+
await evt('pick', { tabId: session.tabId, pick })
|
|
1389
|
+
return { pick }
|
|
1390
|
+
}
|
|
1391
|
+
await sleep(250)
|
|
1392
|
+
}
|
|
1393
|
+
session.picking = false
|
|
1394
|
+
await stopPicker(session.tabId)
|
|
1395
|
+
throw new Error('элемент не выбран за отведённое время')
|
|
1396
|
+
}
|
|
1397
|
+
case 'pick.last': {
|
|
1398
|
+
const session = requireSession(args.tabId)
|
|
1399
|
+
// A pick armed from the popup lands in the page, not in the queue: pull it
|
|
1400
|
+
// in the first time someone asks for it.
|
|
1401
|
+
const fresh = await readPick(session.tabId).catch(() => null)
|
|
1402
|
+
if (fresh && !fresh.cancelled) {
|
|
1403
|
+
session.picks = [...(session.picks ?? []), fresh].slice(-20)
|
|
1404
|
+
session.picking = false
|
|
1405
|
+
await cdp('Runtime.evaluate', { expression: 'window.__dshPickResult = null', returnByValue: true }, session.tabId).catch(() => {})
|
|
1406
|
+
}
|
|
1407
|
+
return { pick: session.picks?.[session.picks.length - 1] ?? null }
|
|
1408
|
+
}
|
|
1409
|
+
case 'pick.list': {
|
|
1410
|
+
const session = requireSession(args.tabId)
|
|
1411
|
+
return { picks: session.picks ?? [] }
|
|
1412
|
+
}
|
|
1413
|
+
case 'pick.stop': {
|
|
1414
|
+
const session = requireSession(args.tabId)
|
|
1415
|
+
session.picking = false
|
|
1416
|
+
await stopPicker(session.tabId)
|
|
1417
|
+
return { picking: false }
|
|
1418
|
+
}
|
|
1112
1419
|
case 'net.list': {
|
|
1113
1420
|
const session = requireSession(args.tabId)
|
|
1114
1421
|
const query = String(args.query ?? '').toLowerCase()
|
package/lib/index.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { homedir } from 'node:os'
|
|
19
19
|
import { isAbsolute, join, resolve } from 'node:path'
|
|
20
|
-
import { mkdir, writeFile } from 'node:fs/promises'
|
|
20
|
+
import { appendFile, mkdir, writeFile } from 'node:fs/promises'
|
|
21
21
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
22
22
|
import { Bridge, loadOrCreateCredentials } from './bridge.js'
|
|
23
23
|
import { writeBundle } from './artifacts.js'
|
|
@@ -596,6 +596,63 @@ export function apply(ctx, config) {
|
|
|
596
596
|
)
|
|
597
597
|
)
|
|
598
598
|
|
|
599
|
+
ctx.tools.register(
|
|
600
|
+
tool(
|
|
601
|
+
'site_pick',
|
|
602
|
+
'Выбор элемента человеком: в странице включается подсветка под курсором, клик по элементу просит примечание ' +
|
|
603
|
+
'и возвращает устойчивый CSS-селектор, XPath, текст и бокс. Результат дописывается в captures/picks.jsonl. ' +
|
|
604
|
+
'Полезно, когда агенту нужно понять, о каком элементе говорит пользователь.',
|
|
605
|
+
{
|
|
606
|
+
action: { type: 'string', description: 'wait (по умолчанию) | last | list' },
|
|
607
|
+
timeoutMs: { type: 'number', description: 'Сколько ждать клика для action=wait (по умолчанию 90000)' },
|
|
608
|
+
tab: { type: 'number', description: 'id вкладки (по умолчанию активная)' }
|
|
609
|
+
},
|
|
610
|
+
async (args, exec) => {
|
|
611
|
+
const instance = await ensureBridge()
|
|
612
|
+
const target = pickTarget(instance, args.tab)
|
|
613
|
+
const action = String(args.action ?? 'wait')
|
|
614
|
+
|
|
615
|
+
if (action === 'last' || action === 'list') {
|
|
616
|
+
const result = await instance.enqueue(action === 'last' ? 'pick.last' : 'pick.list', { ...tabArgs(args) }, { timeout: 20000 })
|
|
617
|
+
const picks = action === 'last' ? (result?.pick ? [result.pick] : []) : result?.picks ?? []
|
|
618
|
+
if (picks.length === 0) return ok('Пока ничего не выбрано.', { picks: [] })
|
|
619
|
+
const lines = picks.map(
|
|
620
|
+
(pick) => `· ${pick.selector} → ${pick.xpath}${pick.note ? ` («${pick.note}»)` : ''}`
|
|
621
|
+
)
|
|
622
|
+
return ok(`Выбранные элементы (${picks.length}):\n${lines.join('\n')}`, { picks })
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const timeoutMs = Math.min(Number(args.timeoutMs ?? 90000), 120000)
|
|
626
|
+
const result = await instance.enqueue(
|
|
627
|
+
'pick.wait',
|
|
628
|
+
{ timeoutMs, ...tabArgs(args) },
|
|
629
|
+
{ timeout: timeoutMs + 15000 }
|
|
630
|
+
)
|
|
631
|
+
const pick = result?.pick
|
|
632
|
+
if (!pick) throw new Error('выбор не вернул результат')
|
|
633
|
+
|
|
634
|
+
const root = await captureRoot('', exec)
|
|
635
|
+
await mkdir(root, { recursive: true })
|
|
636
|
+
await appendFile(
|
|
637
|
+
join(root, 'picks.jsonl'),
|
|
638
|
+
`${JSON.stringify({ at: new Date().toISOString(), url: target?.url ?? pick.url, ...pick })}\n`,
|
|
639
|
+
'utf8'
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
return ok(
|
|
643
|
+
[
|
|
644
|
+
`Выбран элемент «${pick.tag}»: ${pick.selector}`,
|
|
645
|
+
`XPath: ${pick.xpath}`,
|
|
646
|
+
`Текст: ${truncate(pick.text || '—', 160)}`,
|
|
647
|
+
`Примечание: ${pick.note || '—'}`,
|
|
648
|
+
`Записано в ${join(root, 'picks.jsonl')}`
|
|
649
|
+
].join('\n'),
|
|
650
|
+
{ pick }
|
|
651
|
+
)
|
|
652
|
+
}
|
|
653
|
+
)
|
|
654
|
+
)
|
|
655
|
+
|
|
599
656
|
if (settings.autoStart) {
|
|
600
657
|
void ensureBridge().catch((error) => {
|
|
601
658
|
ctx.logger?.warn?.(`[dsh-site-bridge] bridge did not start: ${String(error?.message ?? error)}`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-site-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Chrome/Edge MV3 bridge for DeepSeek Harness: capture page markup (DOM, styles, selectors) and BFF traffic (xhr/fetch with headers and bodies) into a workspace snapshot bundle, drive the page with trusted CDP input, and verify injected scripts. Authorization never leaves the browser and is never written to disk.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -39,7 +39,8 @@
|
|
|
39
39
|
"icons": "node tools/make-icons.mjs",
|
|
40
40
|
"version:check": "node tools/version.mjs check",
|
|
41
41
|
"version:bump": "node tools/version.mjs bump",
|
|
42
|
-
"stage": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/install.ps1 -StageOnly"
|
|
42
|
+
"stage": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/install.ps1 -StageOnly",
|
|
43
|
+
"test:picker": "node test/picker.mjs"
|
|
43
44
|
},
|
|
44
45
|
"dsh": {
|
|
45
46
|
"bundle": {
|