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 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": { "message": "DSH Site Bridge" },
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": { "message": "DeepSeek Harness in your browser" },
7
-
8
- "statusConnecting": { "message": "Checking…" },
9
- "statusConnected": { "message": "Bridge connected" },
10
- "statusNoBridge": { "message": "Bridge not responding" },
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": { "count": { "content": "$1" } }
14
- },
15
- "statusNotConfigured": { "message": "Not configured" },
16
- "statusWorkerDown": { "message": "Extension service is not responding" },
17
- "errorToken": { "message": "Token does not match the bridge — import bridge.json again" },
18
- "errorNoBridge": { "message": "DSH is not running, or the bridge is not listening on this port" },
19
-
20
- "disconnect": { "message": "Disconnect" },
21
- "targetNoneTitle": { "message": "No tab connected" },
22
- "targetNoneUrl": { "message": "Open the page you need and connect it" },
23
- "connectTab": { "message": "Connect this tab" },
24
- "tabConnected": { "message": "Tab connected" },
25
- "tabsLabel": { "message": "Tabs" },
26
-
27
- "connectionTitle": { "message": "Pairing" },
28
- "importBridge": { "message": "Import bridge.json" },
29
- "portLabel": { "message": "Bridge port" },
30
- "tokenLabel": { "message": "Token" },
31
- "tokenPlaceholder": { "message": "paste the token or import the file" },
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": { "count": { "content": "$1" } }
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": { "count": { "content": "$1" } }
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
- "imported": { "message": "Settings imported" },
119
+ "imported": {
120
+ "message": "Settings imported"
121
+ },
59
122
  "importFailed": {
60
123
  "message": "Import failed: $ERROR$",
61
- "placeholders": { "error": { "content": "$1" } }
124
+ "placeholders": {
125
+ "error": {
126
+ "content": "$1"
127
+ }
128
+ }
62
129
  },
63
130
  "claimFailed": {
64
131
  "message": "Could not connect: $ERROR$",
65
- "placeholders": { "error": { "content": "$1" } }
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
- "claimFailedUnknown": { "message": "unknown error" }
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": { "message": "DSH Site Bridge" },
2
+ "appName": {
3
+ "message": "DSH Site Bridge"
4
+ },
3
5
  "appDesc": {
4
6
  "message": "Глаза и руки DeepSeek Harness в вашем браузере: разметка страницы, трафик BFF, доверенный ввод и виртуальный курсор. Авторизация остаётся в браузере."
5
7
  },
6
- "tagline": { "message": "DeepSeek Harness в вашем браузере" },
7
-
8
- "statusConnecting": { "message": "Проверяю…" },
9
- "statusConnected": { "message": "Мост подключён" },
10
- "statusNoBridge": { "message": "Мост не отвечает" },
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": { "count": { "content": "$1" } }
14
- },
15
- "statusNotConfigured": { "message": "Не настроен" },
16
- "statusWorkerDown": { "message": "Служба расширения не отвечает" },
17
- "errorToken": { "message": "Токен не совпал с мостом — импортируйте bridge.json заново" },
18
- "errorNoBridge": { "message": "DSH не запущен или мост не слушает этот порт" },
19
-
20
- "disconnect": { "message": "Отключить" },
21
- "targetNoneTitle": { "message": "Вкладка не подключена" },
22
- "targetNoneUrl": { "message": "Откройте нужную страницу и подключите её" },
23
- "connectTab": { "message": "Подключить эту вкладку" },
24
- "tabConnected": { "message": "Вкладка подключена" },
25
- "tabsLabel": { "message": "Вкладки" },
26
-
27
- "connectionTitle": { "message": "Подключение" },
28
- "importBridge": { "message": "Импорт bridge.json" },
29
- "portLabel": { "message": "Порт моста" },
30
- "tokenLabel": { "message": "Токен" },
31
- "tokenPlaceholder": { "message": "вставьте токен или импортируйте файл" },
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": { "count": { "content": "$1" } }
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": { "count": { "content": "$1" } }
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
- "imported": { "message": "Настройки импортированы" },
119
+ "imported": {
120
+ "message": "Настройки импортированы"
121
+ },
59
122
  "importFailed": {
60
123
  "message": "Импорт не удался: $ERROR$",
61
- "placeholders": { "error": { "content": "$1" } }
124
+ "placeholders": {
125
+ "error": {
126
+ "content": "$1"
127
+ }
128
+ }
62
129
  },
63
130
  "claimFailed": {
64
131
  "message": "Не удалось подключить: $ERROR$",
65
- "placeholders": { "error": { "content": "$1" } }
132
+ "placeholders": {
133
+ "error": {
134
+ "content": "$1"
135
+ }
136
+ }
137
+ },
138
+ "claimFailedUnknown": {
139
+ "message": "неизвестная ошибка"
140
+ },
141
+ "pickHint": {
142
+ "message": "Выбрать элемент на странице"
66
143
  },
67
- "claimFailedUnknown": { "message": "неизвестная ошибка" }
144
+ "pickArmed": {
145
+ "message": "Кликните по элементу на странице — Enter добавит примечание, Esc отменит"
146
+ }
68
147
  }
@@ -1,50 +1,112 @@
1
1
  {
2
- "appName": { "message": "DSH Site Bridge" },
2
+ "appName": {
3
+ "message": "DSH Site Bridge"
4
+ },
3
5
  "appDesc": {
4
6
  "message": "在您自己的浏览器中为 DeepSeek Harness 提供眼睛和双手:页面结构、BFF 流量、可信输入和虚拟光标。凭据始终留在浏览器中。"
5
7
  },
6
- "tagline": { "message": "DeepSeek Harness 在您的浏览器中" },
7
-
8
- "statusConnecting": { "message": "正在检查…" },
9
- "statusConnected": { "message": "网桥已连接" },
10
- "statusNoBridge": { "message": "网桥无响应" },
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": { "count": { "content": "$1" } }
14
- },
15
- "statusNotConfigured": { "message": "尚未配置" },
16
- "statusWorkerDown": { "message": "扩展服务未响应" },
17
- "errorToken": { "message": "令牌与网桥不匹配——请重新导入 bridge.json" },
18
- "errorNoBridge": { "message": "DSH 未运行,或网桥未监听该端口" },
19
-
20
- "disconnect": { "message": "断开" },
21
- "targetNoneTitle": { "message": "未连接标签页" },
22
- "targetNoneUrl": { "message": "打开需要的页面并连接它" },
23
- "connectTab": { "message": "连接此标签页" },
24
- "tabConnected": { "message": "标签页已连接" },
25
- "tabsLabel": { "message": "标签页" },
26
-
27
- "connectionTitle": { "message": "配对" },
28
- "importBridge": { "message": "导入 bridge.json" },
29
- "portLabel": { "message": "网桥端口" },
30
- "tokenLabel": { "message": "令牌" },
31
- "tokenPlaceholder": { "message": "粘贴令牌或导入文件" },
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": { "count": { "content": "$1" } }
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": { "count": { "content": "$1" } }
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
- "imported": { "message": "设置已导入" },
119
+ "imported": {
120
+ "message": "设置已导入"
121
+ },
59
122
  "importFailed": {
60
123
  "message": "导入失败:$ERROR$",
61
- "placeholders": { "error": { "content": "$1" } }
124
+ "placeholders": {
125
+ "error": {
126
+ "content": "$1"
127
+ }
128
+ }
62
129
  },
63
130
  "claimFailed": {
64
131
  "message": "连接失败:$ERROR$",
65
- "placeholders": { "error": { "content": "$1" } }
132
+ "placeholders": {
133
+ "error": {
134
+ "content": "$1"
135
+ }
136
+ }
137
+ },
138
+ "claimFailedUnknown": {
139
+ "message": "未知错误"
140
+ },
141
+ "pickHint": {
142
+ "message": "在页面上选择元素"
66
143
  },
67
- "claimFailedUnknown": { "message": "未知错误" }
144
+ "pickArmed": {
145
+ "message": "点击页面上的元素 — Enter 添加备注,Esc 取消"
146
+ }
68
147
  }
@@ -2,7 +2,7 @@
2
2
  "manifest_version": 3,
3
3
  "default_locale": "en",
4
4
  "name": "__MSG_appName__",
5
- "version": "0.3.3",
5
+ "version": "0.4.1",
6
6
  "description": "__MSG_appDesc__",
7
7
  "minimum_chrome_version": "116",
8
8
  "permissions": [
@@ -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
- <button class="ghost" id="release" hidden data-i18n="disconnect">Отключить</button>
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">
@@ -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.3",
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": {