dsh-site-bridge 0.4.0 → 0.5.0
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 +14 -12
- package/README.ru.md +15 -13
- package/README.zh-CN.md +14 -11
- package/docs/listing/awesome-dsh-plugin.yml +3 -3
- package/extension/_locales/en/messages.json +6 -0
- package/extension/_locales/ru/messages.json +6 -0
- package/extension/_locales/zh_CN/messages.json +6 -0
- package/extension/manifest.json +1 -1
- package/extension/popup.html +15 -9
- package/extension/popup.js +7 -0
- package/extension/sw.js +180 -4
- package/lib/index.js +79 -5
- package/package.json +3 -2
- package/tools/preview-popup.mjs +8 -0
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ Two halves, one bridge:
|
|
|
12
12
|
|
|
13
13
|
| Half | What it does |
|
|
14
14
|
|---|---|
|
|
15
|
-
| **DSH plugin** (`lib/`) | Owns the loopback bridge and exposes
|
|
15
|
+
| **DSH plugin** (`lib/`) | Owns the loopback bridge and exposes eleven tools to the model. Writes the snapshot bundle into the session workspace. |
|
|
16
16
|
| **MV3 extension** (`extension/`) | Lives in your Chrome/Edge profile, works with your cookies, captures markup and network over CDP, drives the page with real input events. Localized: English, Русский, 中文. |
|
|
17
17
|
|
|
18
18
|
Neither half is useful alone: the plugin holds the model-facing surface and the files, the extension
|
|
@@ -70,9 +70,8 @@ candidates checked for uniqueness, and the site's own xhr/fetch calls with heade
|
|
|
70
70
|
written to files an agent can read. Automation exists so that "this selector works" and "this BFF call
|
|
71
71
|
passes with my cookies" are checked against the live page instead of assumed.
|
|
72
72
|
|
|
73
|
-
The practical consequence:
|
|
74
|
-
tools
|
|
75
|
-
that embeds into one specific site, you need the capture, and the input comes with it.
|
|
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.
|
|
76
75
|
|
|
77
76
|
## Requirements
|
|
78
77
|
|
|
@@ -124,11 +123,14 @@ The popup UI follows the browser language: English, Russian and Simplified Chine
|
|
|
124
123
|
## Usage
|
|
125
124
|
|
|
126
125
|
```text
|
|
127
|
-
site_status → bridge state, pairing path,
|
|
128
|
-
site_tabs {action:"list"} → tabs;
|
|
126
|
+
site_status → bridge state, pairing path, tabs
|
|
127
|
+
site_tabs {action:"list"} → tabs; claim/use/release/open/focus
|
|
129
128
|
site_network {action:"start"} → begin recording, then act on the page
|
|
130
129
|
site_capture {screenshot:true} → write the bundle into the workspace
|
|
131
|
-
|
|
130
|
+
site_snapshot {limit:40} → interactive elements with stable selectors
|
|
131
|
+
site_text {selector:"#cart"} → visible text, capped, for verification
|
|
132
|
+
site_act {steps:"[...]"} → click/fill/type/press/scroll/upload/wait/back/eval
|
|
133
|
+
site_pick {action:"wait"} → the human clicks an element and adds a note
|
|
132
134
|
site_cursor {x,y,click:true} → virtual cursor, real mouse event
|
|
133
135
|
site_shot {fullPage:true} → screenshot to captures/shots/*.png
|
|
134
136
|
site_inject {js:"..."} → run your script in the page to verify it
|
|
@@ -181,12 +183,12 @@ The wire protocol is small and documented in [docs/protocol.md](docs/protocol.md
|
|
|
181
183
|
puts a secret under an unusual key, redaction will not recognise it. Treat a capture bundle as
|
|
182
184
|
sensitive, exactly like any traffic dump.
|
|
183
185
|
|
|
184
|
-
###
|
|
186
|
+
### Trust model
|
|
185
187
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
188
|
+
The bridge accepts only requests that carry the pairing token. The extension asks for `debugger`
|
|
189
|
+
instead of `<all_urls>` and does not inject a content script into every page. The cost is a pairing
|
|
190
|
+
step and Chrome's debugging banner. In exchange the permission is granted per tab, for the duration
|
|
191
|
+
of a session, and requests are read where they are made — no credential is copied out of the browser.
|
|
190
192
|
|
|
191
193
|
## Limitations
|
|
192
194
|
|
package/README.ru.md
CHANGED
|
@@ -70,9 +70,8 @@ site_capture → captures/<host>/<timestamp>/
|
|
|
70
70
|
которые агент читает. Автоматизация нужна, чтобы «этот селектор работает» и «этот вызов BFF проходит
|
|
71
71
|
с моими куками» проверялись на живой странице, а не предполагались.
|
|
72
72
|
|
|
73
|
-
Практическое следствие: для
|
|
74
|
-
|
|
75
|
-
конкретный сайт, нужен снимок, а ввод идёт в комплекте.
|
|
73
|
+
Практическое следствие: плагин сделан для написания расширения под конкретный сайт, а инструменты
|
|
74
|
+
ввода нужны, чтобы проверять эту работу на живой странице, а не для браузинга.
|
|
76
75
|
|
|
77
76
|
## Требования
|
|
78
77
|
|
|
@@ -124,11 +123,14 @@ Chrome покажет плашку «началась отладка этого
|
|
|
124
123
|
## Использование
|
|
125
124
|
|
|
126
125
|
```text
|
|
127
|
-
site_status → состояние моста, путь к
|
|
128
|
-
site_tabs {action:"list"} → вкладки;
|
|
129
|
-
site_network {action:"start"} → начать запись, затем
|
|
130
|
-
site_capture {screenshot:true} → записать пакет в рабочее пространство
|
|
131
|
-
|
|
126
|
+
site_status → состояние моста, путь к спариванию, вкладки
|
|
127
|
+
site_tabs {action:"list"} → вкладки; claim/use/release/open/focus
|
|
128
|
+
site_network {action:"start"} → начать запись, затем действовать на странице
|
|
129
|
+
site_capture {screenshot:true} → записать пакет снимка в рабочее пространство
|
|
130
|
+
site_snapshot {limit:40} → интерактивные элементы с устойчивыми селекторами
|
|
131
|
+
site_text {selector:"#cart"} → видимый текст с лимитом, для проверки
|
|
132
|
+
site_act {steps:"[...]"} → click/fill/type/press/scroll/upload/wait/back/eval
|
|
133
|
+
site_pick {action:"wait"} → человек кликает элемент и пишет примечание
|
|
132
134
|
site_cursor {x,y,click:true} → виртуальный курсор, настоящее событие мыши
|
|
133
135
|
site_shot {fullPage:true} → скриншот в captures/shots/*.png
|
|
134
136
|
site_inject {js:"..."} → выполнить ваш скрипт в странице для проверки
|
|
@@ -180,12 +182,12 @@ site_inject {js:"..."} → выполнить ваш скрипт в с
|
|
|
180
182
|
сайт кладёт секрет под нестандартным ключом, редакция его не узнает. Считайте пакет снимка
|
|
181
183
|
чувствительным, как и любой дамп трафика.
|
|
182
184
|
|
|
183
|
-
###
|
|
185
|
+
### Модель доверия
|
|
184
186
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
187
|
+
Мост принимает только запросы с токеном сопряжения. Расширение просит `debugger`, а не
|
|
188
|
+
`<all_urls>`, и не внедряет content script в каждую страницу. Цена — шаг сопряжения и плашка отладки
|
|
189
|
+
Chrome. Взамен право выдаётся на вкладку и на время сессии, а запросы читаются там, где они сделаны:
|
|
190
|
+
учётные данные не покидают браузер.
|
|
189
191
|
|
|
190
192
|
## Ограничения
|
|
191
193
|
|
package/README.zh-CN.md
CHANGED
|
@@ -56,8 +56,8 @@ site_capture → captures/<host>/<timestamp>/
|
|
|
56
56
|
xhr/fetch(含请求头与请求体),全部写成智能体可读的文件。自动化的作用是让「这个选择器可用」和
|
|
57
57
|
「这个 BFF 调用能带着我的 Cookie 通过」在活页面上被验证,而不是被假设。
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
实际后果:本项目是为编写面向某个具体网站的扩展而做的,输入工具用来在活页面上验证这些工作,而不是
|
|
60
|
+
用来浏览网页。
|
|
61
61
|
|
|
62
62
|
## 环境要求
|
|
63
63
|
|
|
@@ -102,14 +102,17 @@ Chrome 会显示「已开始调试此浏览器」提示条:这正是全部功
|
|
|
102
102
|
## 用法
|
|
103
103
|
|
|
104
104
|
```text
|
|
105
|
-
site_status →
|
|
106
|
-
site_tabs {action:"list"} →
|
|
105
|
+
site_status → 网桥状态、配对路径、标签页
|
|
106
|
+
site_tabs {action:"list"} → 标签页;claim/use/release/open/focus
|
|
107
107
|
site_network {action:"start"} → 开始录制,然后在页面上操作
|
|
108
108
|
site_capture {screenshot:true} → 把快照包写入工作区
|
|
109
|
-
|
|
109
|
+
site_snapshot {limit:40} → 带稳定选择器的可交互元素
|
|
110
|
+
site_text {selector:"#cart"} → 可见文本(带上限),用于验证
|
|
111
|
+
site_act {steps:"[...]"} → click/fill/type/press/scroll/upload/wait/back/eval
|
|
112
|
+
site_pick {action:"wait"} → 由人点击元素并写备注
|
|
110
113
|
site_cursor {x,y,click:true} → 虚拟光标,真实鼠标事件
|
|
111
|
-
site_shot {fullPage:true} →
|
|
112
|
-
site_inject {js:"..."} →
|
|
114
|
+
site_shot {fullPage:true} → 截图保存到 captures/shots/*.png
|
|
115
|
+
site_inject {js:"..."} → 在页面里执行你的脚本以验证
|
|
113
116
|
```
|
|
114
117
|
|
|
115
118
|
编写页内扩展的典型循环:
|
|
@@ -147,11 +150,11 @@ site_inject {js:"..."} → 在页面中执行你的脚本以验证
|
|
|
147
150
|
- **仅回环。** 网桥只绑定 `127.0.0.1`,任何没有 32 字节配对令牌的请求都会被拒绝。`site_status` 打印的是令牌文件路径,而不是令牌本身。
|
|
148
151
|
- **残余风险。** 请求头与请求体仍会经过 DSH 进程的内存。如果站点把密钥放在非常规字段名下,脱敏无法识别。请像对待任何流量转储一样,把快照包视为敏感数据。
|
|
149
152
|
|
|
150
|
-
###
|
|
153
|
+
### 信任模型
|
|
151
154
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
+
网桥只接受携带配对令牌的请求。扩展申请 `debugger` 而不是 `<all_urls>`,也不会向每个页面注入
|
|
156
|
+
content script。代价是配对步骤与 Chrome 的调试提示条。换来的是:权限按标签页、按会话授予,请求在
|
|
157
|
+
产生的地方被读取,凭据不离开浏览器。
|
|
155
158
|
|
|
156
159
|
## 限制
|
|
157
160
|
|
|
@@ -10,12 +10,12 @@
|
|
|
10
10
|
# - real, working code (not a placeholder) ✔
|
|
11
11
|
# - repository at least 1 day old ⏳ created 2026-11-01, check before submitting
|
|
12
12
|
# - dsh-plugin topic on the repository — add with gh
|
|
13
|
-
# - description states only what the code does ✔ (
|
|
13
|
+
# - description states only what the code does ✔ (11 tools, MV3 extension)
|
|
14
14
|
|
|
15
15
|
url: https://github.com/kesha4/dsh-site-bridge
|
|
16
16
|
name: kesha4/dsh-site-bridge
|
|
17
17
|
category: browser
|
|
18
18
|
icon: https://raw.githubusercontent.com/kesha4/dsh-site-bridge/main/extension/icons/icon-128.png
|
|
19
19
|
description:
|
|
20
|
-
en: Browser bridge for DeepSeek Harness — captures page markup and BFF traffic into a workspace bundle, drives pages with trusted CDP input, and verifies injected scripts; ships a DSH plugin with
|
|
21
|
-
zh: 面向 DeepSeek Harness 的浏览器桥接——把页面结构与 BFF 流量抓取为工作区快照包,用可信 CDP
|
|
20
|
+
en: Browser bridge for DeepSeek Harness — captures page markup and BFF traffic into a workspace bundle, drives pages with trusted CDP input, and verifies injected scripts; ships a DSH plugin with eleven tools and an MV3 extension that uses the browser's own session.
|
|
21
|
+
zh: 面向 DeepSeek Harness 的浏览器桥接——把页面结构与 BFF 流量抓取为工作区快照包,用可信 CDP 输入驱动页面并验证注入脚本;包含带十一个工具的 DSH 插件与使用浏览器自身会话的 MV3 扩展。
|
|
@@ -143,5 +143,11 @@
|
|
|
143
143
|
},
|
|
144
144
|
"pickArmed": {
|
|
145
145
|
"message": "Click an element on the page — Enter adds a note, Esc cancels"
|
|
146
|
+
},
|
|
147
|
+
"attachAnother": {
|
|
148
|
+
"message": "Attach another tab"
|
|
149
|
+
},
|
|
150
|
+
"disconnectTab": {
|
|
151
|
+
"message": "Disconnect this tab"
|
|
146
152
|
}
|
|
147
153
|
}
|
|
@@ -143,5 +143,11 @@
|
|
|
143
143
|
},
|
|
144
144
|
"pickArmed": {
|
|
145
145
|
"message": "Кликните по элементу на странице — Enter добавит примечание, Esc отменит"
|
|
146
|
+
},
|
|
147
|
+
"attachAnother": {
|
|
148
|
+
"message": "Подключить ещё вкладку"
|
|
149
|
+
},
|
|
150
|
+
"disconnectTab": {
|
|
151
|
+
"message": "Отключить эту вкладку"
|
|
146
152
|
}
|
|
147
153
|
}
|
package/extension/manifest.json
CHANGED
package/extension/popup.html
CHANGED
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
58
58
|
@media (prefers-reduced-motion: reduce) { .spin { animation-duration: 2.4s; } }
|
|
59
59
|
|
|
60
|
-
.target { margin: 8px 0; padding: 7px 8px; background: #15181e; border: 1px solid var(--line); border-radius: 7px; }
|
|
60
|
+
.target { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin: 8px 0; padding: 7px 8px; background: #15181e; border: 1px solid var(--line); border-radius: 7px; }
|
|
61
61
|
.target-title { font-size: 12px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
62
62
|
.target-url { margin-top: 1px; color: var(--muted); font-size: 10.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
63
63
|
.target.empty .target-title { color: var(--muted); font-weight: 400; }
|
|
@@ -74,6 +74,7 @@
|
|
|
74
74
|
}
|
|
75
75
|
button:hover:not(:disabled) { border-color: #3a4150; background: #242935; }
|
|
76
76
|
button:disabled { opacity: 0.45; cursor: default; }
|
|
77
|
+
button.primary#claim-more { margin-top: 8px; font-size: 12px; padding: 7px 10px; }
|
|
77
78
|
button.primary { width: 100%; background: var(--accent); border-color: var(--accent); color: var(--accent-ink); font-weight: 600; }
|
|
78
79
|
button.primary:hover:not(:disabled) { background: #6b8aff; border-color: #6b8aff; }
|
|
79
80
|
button.primary:disabled { background: #20242d; border-color: var(--line); color: var(--muted); font-weight: 500; }
|
|
@@ -92,6 +93,7 @@
|
|
|
92
93
|
}
|
|
93
94
|
button.link:hover:not(:disabled) { color: #7e98ff; background: none; }
|
|
94
95
|
|
|
96
|
+
.target-text { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; }
|
|
95
97
|
.capture-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--line); }
|
|
96
98
|
.capture-row button { padding: 5px 9px; font-size: 11.5px; }
|
|
97
99
|
select {
|
|
@@ -139,22 +141,26 @@
|
|
|
139
141
|
<section class="card">
|
|
140
142
|
<div class="card-head">
|
|
141
143
|
<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>
|
|
142
|
-
<
|
|
143
|
-
<
|
|
144
|
-
|
|
145
|
-
</button>
|
|
146
|
-
<button class="ghost" id="release" hidden data-i18n="disconnect">Отключить</button>
|
|
147
|
-
</span>
|
|
144
|
+
<button class="icon" id="pick" data-i18n-title="pickHint" title="Pick an element">
|
|
145
|
+
<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>
|
|
146
|
+
</button>
|
|
148
147
|
</div>
|
|
149
148
|
|
|
150
149
|
<div class="target empty" id="target-block">
|
|
151
|
-
<div class="target-
|
|
152
|
-
|
|
150
|
+
<div class="target-text">
|
|
151
|
+
<div class="target-title" id="target-title" data-i18n="targetNoneTitle">Вкладка не подключена</div>
|
|
152
|
+
<div class="target-url" id="target-url" data-i18n="targetNoneUrl">Откройте нужную страницу и подключите её</div>
|
|
153
|
+
</div>
|
|
154
|
+
<button class="icon" id="release" hidden data-i18n-title="disconnectTab" title="Отключить вкладку">
|
|
155
|
+
<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="M2 2 L10 10 M10 2 L2 10" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
|
|
156
|
+
</button>
|
|
153
157
|
</div>
|
|
154
158
|
|
|
155
159
|
<button class="primary" id="claim" data-i18n="connectTab">Подключить эту вкладку</button>
|
|
156
160
|
<p class="status-note" id="status-note" hidden></p>
|
|
157
161
|
|
|
162
|
+
<button class="primary" id="claim-more" hidden data-i18n="attachAnother">Подключить ещё вкладку</button>
|
|
163
|
+
|
|
158
164
|
<div class="capture-row" id="tabs-row" hidden>
|
|
159
165
|
<span class="muted" data-i18n="tabsLabel">Вкладки</span>
|
|
160
166
|
<select id="tabs-select"></select>
|
package/extension/popup.js
CHANGED
|
@@ -101,6 +101,7 @@ function render(status) {
|
|
|
101
101
|
const targets = Array.isArray(status.targets) ? status.targets : []
|
|
102
102
|
const tabsRow = $('tabs-row')
|
|
103
103
|
const tabsSelect = $('tabs-select')
|
|
104
|
+
$('claim-more').hidden = !hasTarget
|
|
104
105
|
if (targets.length > 1) {
|
|
105
106
|
tabsRow.hidden = false
|
|
106
107
|
tabsSelect.textContent = ''
|
|
@@ -267,6 +268,12 @@ $('pick').addEventListener('click', async () => {
|
|
|
267
268
|
await refresh()
|
|
268
269
|
})
|
|
269
270
|
|
|
271
|
+
$('claim-more').addEventListener('click', async () => {
|
|
272
|
+
const result = await send({ type: 'claim' })
|
|
273
|
+
if (!result?.ok && result?.error) setNote(String(result.error), true)
|
|
274
|
+
await refresh()
|
|
275
|
+
})
|
|
276
|
+
|
|
270
277
|
$('tabs-select').addEventListener('change', async (event) => {
|
|
271
278
|
const tabId = Number(event.target.value)
|
|
272
279
|
if (!Number.isFinite(tabId)) return
|
package/extension/sw.js
CHANGED
|
@@ -665,6 +665,99 @@ async function readPick(tabId) {
|
|
|
665
665
|
return result?.value ?? null
|
|
666
666
|
}
|
|
667
667
|
|
|
668
|
+
/**
|
|
669
|
+
* Injected into the page: a compact list of what a person can act on, with a
|
|
670
|
+
* stable selector for each, so the agent can pick one without reading the
|
|
671
|
+
* whole snapshot bundle.
|
|
672
|
+
*/
|
|
673
|
+
function compactSnapshotInPage(options) {
|
|
674
|
+
const limit = Math.max(1, Math.min(Number(options?.limit ?? 120), 400))
|
|
675
|
+
const cssEsc = (value) => (window.CSS && CSS.escape ? CSS.escape(value) : String(value).replace(/[^a-zA-Z0-9_-]/g, '\\$&'))
|
|
676
|
+
const count = (selector) => {
|
|
677
|
+
try { return document.querySelectorAll(selector).length } catch { return -1 }
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function selectorFor(el) {
|
|
681
|
+
const candidates = []
|
|
682
|
+
if (el.id) candidates.push('#' + cssEsc(el.id))
|
|
683
|
+
for (const attr of ['data-testid', 'data-test', 'data-qa', 'data-cy']) {
|
|
684
|
+
const value = el.getAttribute(attr)
|
|
685
|
+
if (value) candidates.push('[' + attr + '="' + value.replace(/"/g, '\\"') + '"]')
|
|
686
|
+
}
|
|
687
|
+
if (el.getAttribute('name')) {
|
|
688
|
+
candidates.push(el.tagName.toLowerCase() + '[name="' + el.getAttribute('name').replace(/"/g, '\\"') + '"]')
|
|
689
|
+
}
|
|
690
|
+
if (el.getAttribute('aria-label')) {
|
|
691
|
+
candidates.push('[aria-label="' + el.getAttribute('aria-label').replace(/"/g, '\\"') + '"]')
|
|
692
|
+
}
|
|
693
|
+
const classes = [...el.classList].filter((name) => /^[a-zA-Z][\w-]{2,}$/.test(name)).slice(0, 2)
|
|
694
|
+
if (classes.length > 0) {
|
|
695
|
+
candidates.push(el.tagName.toLowerCase() + classes.map((name) => '.' + cssEsc(name)).join(''))
|
|
696
|
+
}
|
|
697
|
+
const parts = []
|
|
698
|
+
let current = el
|
|
699
|
+
while (current && current.nodeType === 1 && parts.length < 8) {
|
|
700
|
+
let part = current.tagName.toLowerCase()
|
|
701
|
+
if (current.id) { parts.unshift('#' + cssEsc(current.id)); break }
|
|
702
|
+
const parent = current.parentElement
|
|
703
|
+
if (parent) {
|
|
704
|
+
const siblings = [...parent.children].filter((node) => node.tagName === current.tagName)
|
|
705
|
+
if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(current) + 1) + ')'
|
|
706
|
+
}
|
|
707
|
+
parts.unshift(part)
|
|
708
|
+
current = parent
|
|
709
|
+
}
|
|
710
|
+
candidates.push(parts.join(' > '))
|
|
711
|
+
const unique = candidates.find((selector) => count(selector) === 1)
|
|
712
|
+
return unique ?? candidates[candidates.length - 1]
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const SELECTOR = [
|
|
716
|
+
'a[href]', 'button', 'input', 'select', 'textarea', 'summary',
|
|
717
|
+
'[role="button"]', '[role="link"]', '[role="tab"]', '[role="menuitem"]',
|
|
718
|
+
'[role="checkbox"]', '[role="radio"]', '[role="switch"]',
|
|
719
|
+
'[contenteditable=""]', '[contenteditable="true"]', '[onclick]'
|
|
720
|
+
].join(',')
|
|
721
|
+
|
|
722
|
+
const nodes = [...document.querySelectorAll(SELECTOR)]
|
|
723
|
+
const items = []
|
|
724
|
+
for (const el of nodes) {
|
|
725
|
+
if (el.closest('#__dsh_pick_overlay__')) continue
|
|
726
|
+
const style = getComputedStyle(el)
|
|
727
|
+
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) continue
|
|
728
|
+
const rect = el.getBoundingClientRect()
|
|
729
|
+
if (rect.width === 0 && rect.height === 0) continue
|
|
730
|
+
const label = (el.getAttribute('aria-label') ?? '') ||
|
|
731
|
+
(el.getAttribute('placeholder') ?? '') ||
|
|
732
|
+
(el.innerText ?? '').trim().replace(/\s+/g, ' ') ||
|
|
733
|
+
(el.getAttribute('title') ?? '') ||
|
|
734
|
+
(el.getAttribute('value') ?? '')
|
|
735
|
+
const item = {
|
|
736
|
+
tag: el.tagName.toLowerCase(),
|
|
737
|
+
name: String(label).slice(0, 80),
|
|
738
|
+
selector: selectorFor(el),
|
|
739
|
+
box: { x: Math.round(rect.left), y: Math.round(rect.top), w: Math.round(rect.width), h: Math.round(rect.height) }
|
|
740
|
+
}
|
|
741
|
+
if (el.getAttribute('type')) item.type = el.getAttribute('type')
|
|
742
|
+
if (el.getAttribute('role')) item.role = el.getAttribute('role')
|
|
743
|
+
if (el.disabled === true || el.getAttribute('aria-disabled') === 'true') item.disabled = true
|
|
744
|
+
if (el.checked === true) item.checked = true
|
|
745
|
+
if (typeof el.value === 'string' && el.value !== '') item.value = el.value.slice(0, 60)
|
|
746
|
+
if (el.href && el.tagName === 'A') item.href = String(el.href).slice(0, 200)
|
|
747
|
+
items.push(item)
|
|
748
|
+
if (items.length >= limit) break
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
return {
|
|
752
|
+
url: location.href,
|
|
753
|
+
title: document.title,
|
|
754
|
+
total: nodes.length,
|
|
755
|
+
returned: items.length,
|
|
756
|
+
truncated: nodes.length > items.length,
|
|
757
|
+
items
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
668
761
|
/* ------------------------------------------------------------------ cursor */
|
|
669
762
|
|
|
670
763
|
const CURSOR_ID = '__dsh_site_bridge_cursor__'
|
|
@@ -978,6 +1071,13 @@ async function runStep(op, step) {
|
|
|
978
1071
|
return { uploaded: files.length, to: step.selector }
|
|
979
1072
|
}
|
|
980
1073
|
case 'goto': {
|
|
1074
|
+
if (step.back === true || step.forward === true) {
|
|
1075
|
+
await cdp('Runtime.evaluate', {
|
|
1076
|
+
expression: step.back === true ? 'history.back(); true' : 'history.forward(); true',
|
|
1077
|
+
returnByValue: true
|
|
1078
|
+
})
|
|
1079
|
+
return { navigated: step.back === true ? 'back' : 'forward' }
|
|
1080
|
+
}
|
|
981
1081
|
await cdp('Page.navigate', { url: String(step.url) })
|
|
982
1082
|
return { navigated: step.url }
|
|
983
1083
|
}
|
|
@@ -990,19 +1090,53 @@ async function runStep(op, step) {
|
|
|
990
1090
|
await sleep(Math.min(Number(step.ms), 60000))
|
|
991
1091
|
return { waitedMs: Number(step.ms) }
|
|
992
1092
|
}
|
|
1093
|
+
if (step.state === 'settle') {
|
|
1094
|
+
const quietMs = Math.min(Number(step.quietMs ?? 400), 5000)
|
|
1095
|
+
const budget = Math.min(Number(step.timeoutMs ?? 15000), 60000)
|
|
1096
|
+
const { result } = await cdp('Runtime.evaluate', {
|
|
1097
|
+
expression: `new Promise((resolve) => {
|
|
1098
|
+
let timer = null;
|
|
1099
|
+
const done = () => { observer.disconnect(); resolve(true); };
|
|
1100
|
+
const restart = () => { clearTimeout(timer); timer = setTimeout(done, ${quietMs}); };
|
|
1101
|
+
const observer = new MutationObserver(restart);
|
|
1102
|
+
observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, characterData: true });
|
|
1103
|
+
restart();
|
|
1104
|
+
setTimeout(() => { observer.disconnect(); resolve(false); }, ${budget});
|
|
1105
|
+
})`,
|
|
1106
|
+
awaitPromise: true,
|
|
1107
|
+
returnByValue: true
|
|
1108
|
+
})
|
|
1109
|
+
if (result?.value !== true) throw new Error(`page did not settle within ${budget} ms`)
|
|
1110
|
+
return { settled: true, quietMs }
|
|
1111
|
+
}
|
|
1112
|
+
const wantHidden = step.state === 'hidden'
|
|
993
1113
|
const deadline = Date.now() + Math.min(Number(step.timeoutMs ?? 15000), 60000)
|
|
1114
|
+
let lastValue = null
|
|
994
1115
|
while (Date.now() < deadline) {
|
|
995
|
-
const { result } = await cdp('Runtime.evaluate', {
|
|
1116
|
+
const { result, exceptionDetails } = await cdp('Runtime.evaluate', {
|
|
996
1117
|
expression: `(() => {
|
|
997
1118
|
${PAGE_RESOLVER}
|
|
998
|
-
|
|
1119
|
+
const hit = __dshDeep(${JSON.stringify(step.selector)});
|
|
1120
|
+
if (!hit || !hit.el) return ${wantHidden ? 'true' : 'false'};
|
|
1121
|
+
if (${wantHidden ? 'true' : 'false'}) {
|
|
1122
|
+
const el = hit.el;
|
|
1123
|
+
const style = getComputedStyle(el);
|
|
1124
|
+
const rect = el.getBoundingClientRect();
|
|
1125
|
+
return style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0 ||
|
|
1126
|
+
(rect.width === 0 && rect.height === 0);
|
|
1127
|
+
}
|
|
1128
|
+
return true;
|
|
999
1129
|
})()`,
|
|
1000
1130
|
returnByValue: true
|
|
1001
1131
|
})
|
|
1002
|
-
if (
|
|
1132
|
+
if (exceptionDetails) throw new Error(`не удалось проверить ${step.selector}: ${exceptionDetails.exception?.description ?? exceptionDetails.text ?? 'ошибка в странице'}`)
|
|
1133
|
+
lastValue = result?.value
|
|
1134
|
+
if (lastValue === true) {
|
|
1135
|
+
return wantHidden ? { hidden: step.selector } : { appeared: step.selector }
|
|
1136
|
+
}
|
|
1003
1137
|
await sleep(200)
|
|
1004
1138
|
}
|
|
1005
|
-
throw new Error(`timed out waiting for ${step.selector}`)
|
|
1139
|
+
throw new Error(`timed out waiting for ${step.selector} to be ${wantHidden ? 'hidden' : 'visible'} (last value: ${JSON.stringify(lastValue)})`)
|
|
1006
1140
|
}
|
|
1007
1141
|
case 'eval': {
|
|
1008
1142
|
const { result, exceptionDetails } = await cdp('Runtime.evaluate', {
|
|
@@ -1353,6 +1487,48 @@ async function dispatch(op, args) {
|
|
|
1353
1487
|
const active = activeSession()
|
|
1354
1488
|
return { target: active?.target ?? null, attached: state.activeTabId, tabs: [...state.sessions.keys()] }
|
|
1355
1489
|
}
|
|
1490
|
+
case 'tab.open': {
|
|
1491
|
+
const tab = await chrome.tabs.create({ url: String(args.url ?? 'about:blank'), active: args.active !== false })
|
|
1492
|
+
await attach(tab.id)
|
|
1493
|
+
return { target: state.sessions.get(tab.id)?.target ?? { id: tab.id, url: tab.url, title: tab.title } }
|
|
1494
|
+
}
|
|
1495
|
+
case 'tab.focus': {
|
|
1496
|
+
const session = requireSession(args.tabId)
|
|
1497
|
+
await chrome.tabs.update(session.tabId, { active: true })
|
|
1498
|
+
const tab = await chrome.tabs.get(session.tabId)
|
|
1499
|
+
if (tab.windowId != null) await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {})
|
|
1500
|
+
return { focused: session.tabId }
|
|
1501
|
+
}
|
|
1502
|
+
case 'snapshot.compact': {
|
|
1503
|
+
const session = requireSession(args.tabId)
|
|
1504
|
+
const { result } = await cdp('Runtime.evaluate', {
|
|
1505
|
+
expression: `(${compactSnapshotInPage.toString()})(${JSON.stringify({ limit: args.limit })})`,
|
|
1506
|
+
returnByValue: true
|
|
1507
|
+
}, session.tabId)
|
|
1508
|
+
return result?.value ?? { items: [], total: 0, returned: 0, truncated: false }
|
|
1509
|
+
}
|
|
1510
|
+
case 'text.get': {
|
|
1511
|
+
const session = requireSession(args.tabId)
|
|
1512
|
+
const maxChars = Math.max(20, Math.min(Number(args.maxChars ?? 8000), 40000))
|
|
1513
|
+
const root = args.selector ? `__dshDeep(${JSON.stringify(String(args.selector))})` : 'document.body'
|
|
1514
|
+
const { result } = await cdp('Runtime.evaluate', {
|
|
1515
|
+
expression: `(() => {
|
|
1516
|
+
${PAGE_RESOLVER}
|
|
1517
|
+
const root = ${root};
|
|
1518
|
+
if (!root) return { error: 'element not found' };
|
|
1519
|
+
const text = (root.innerText ?? root.textContent ?? '').replace(/\\n{3,}/g, '\\n\\n').trim();
|
|
1520
|
+
return {
|
|
1521
|
+
text: text.slice(0, ${maxChars}),
|
|
1522
|
+
chars: text.length,
|
|
1523
|
+
truncated: text.length > ${maxChars},
|
|
1524
|
+
url: location.href,
|
|
1525
|
+
title: document.title
|
|
1526
|
+
};
|
|
1527
|
+
})()`,
|
|
1528
|
+
returnByValue: true
|
|
1529
|
+
}, session.tabId)
|
|
1530
|
+
return result?.value ?? { text: '', chars: 0, truncated: false }
|
|
1531
|
+
}
|
|
1356
1532
|
case 'net.start': {
|
|
1357
1533
|
const session = requireSession(args.tabId)
|
|
1358
1534
|
return { recording: await setRecording(session, true), tabId: session.tabId }
|
package/lib/index.js
CHANGED
|
@@ -42,8 +42,8 @@ const DOCS = {
|
|
|
42
42
|
'press {key}',
|
|
43
43
|
'scroll {selector?} | scroll {dx, dy}',
|
|
44
44
|
'upload {selector, files: ["C:\\\\abs\\\\path.pdf"]} — absolute paths on this machine',
|
|
45
|
-
'goto {url} | reload {hard?}',
|
|
46
|
-
'wait {selector, timeoutMs?} | wait {ms}',
|
|
45
|
+
'goto {url} | goto {back:true} | goto {forward:true} | reload {hard?}',
|
|
46
|
+
'wait {selector, state:"visible"|"hidden", timeoutMs?} | wait {state:"settle", quietMs?} | wait {ms}',
|
|
47
47
|
'exists {selector}',
|
|
48
48
|
'eval {expression}',
|
|
49
49
|
'screenshot {fullPage?} — лучше site_shot: он пишет файл, а не base64 в контекст'
|
|
@@ -258,14 +258,31 @@ export function apply(ctx, config) {
|
|
|
258
258
|
tool(
|
|
259
259
|
'site_tabs',
|
|
260
260
|
'Вкладки браузера и выбор рабочей вкладки. Можно держать подключёнными несколько вкладок: ' +
|
|
261
|
-
'claim добавляет ещё одну, use переключает активную, release
|
|
261
|
+
'claim добавляет ещё одну, use переключает активную, release отключает, open открывает новую и сразу ' +
|
|
262
|
+
'подключает её, focus выводит вкладку на передний план. Вкладка должна быть http(s) или file://.',
|
|
262
263
|
{
|
|
263
|
-
action: { type: 'string', description: 'list | claim | use | release' },
|
|
264
|
-
tabId: { type: 'number', description: 'id вкладки для claim / use / release (из action=list)' }
|
|
264
|
+
action: { type: 'string', description: 'list | claim | use | release | open | focus' },
|
|
265
|
+
tabId: { type: 'number', description: 'id вкладки для claim / use / release / focus (из action=list)' },
|
|
266
|
+
url: { type: 'string', description: 'адрес для action=open (по умолчанию about:blank)' }
|
|
265
267
|
},
|
|
266
268
|
async (args) => {
|
|
267
269
|
const instance = await ensureBridge()
|
|
268
270
|
const action = String(args.action ?? 'list')
|
|
271
|
+
if (action === 'open') {
|
|
272
|
+
const result = await instance.enqueue(
|
|
273
|
+
'tab.open',
|
|
274
|
+
{ url: String(args.url ?? 'about:blank') },
|
|
275
|
+
{ timeout: 30000 }
|
|
276
|
+
)
|
|
277
|
+
const target = result?.target ?? null
|
|
278
|
+
return ok(`Открыта и подключена вкладка: ${target?.title || ''} — ${target?.url || ''}`, { target })
|
|
279
|
+
}
|
|
280
|
+
if (action === 'focus') {
|
|
281
|
+
const tabId = Number(args.tabId ?? instance.state().activeTabId)
|
|
282
|
+
if (!Number.isFinite(tabId)) throw new Error('нечего выводить на передний план: нет подключённой вкладки')
|
|
283
|
+
await instance.enqueue('tab.focus', { tabId }, { timeout: 20000 })
|
|
284
|
+
return ok(`Вкладка ${tabId} на переднем плане.`, { tabId })
|
|
285
|
+
}
|
|
269
286
|
if (action === 'claim' || action === 'use' || action === 'release') {
|
|
270
287
|
const tabId = Number(args.tabId)
|
|
271
288
|
if (!Number.isFinite(tabId)) throw new Error(`tabId is required for action=${action}`)
|
|
@@ -653,6 +670,63 @@ export function apply(ctx, config) {
|
|
|
653
670
|
)
|
|
654
671
|
)
|
|
655
672
|
|
|
673
|
+
ctx.tools.register(
|
|
674
|
+
tool(
|
|
675
|
+
'site_snapshot',
|
|
676
|
+
'Компактный список интерактивных элементов страницы (кнопки, ссылки, поля, role=button и подобное): ' +
|
|
677
|
+
'подпись, тип, состояние и устойчивый CSS-селектор для каждого. Нужен, чтобы выбрать элемент для site_act, ' +
|
|
678
|
+
'не открывая пакет снимка целиком.',
|
|
679
|
+
{
|
|
680
|
+
limit: { type: 'number', description: 'Сколько элементов вернуть (по умолчанию 120, максимум 400)' },
|
|
681
|
+
tab: { type: 'number', description: 'id вкладки (по умолчанию активная)' }
|
|
682
|
+
},
|
|
683
|
+
async (args) => {
|
|
684
|
+
const instance = await ensureBridge()
|
|
685
|
+
pickTarget(instance, args.tab)
|
|
686
|
+
const data = await instance.enqueue(
|
|
687
|
+
'snapshot.compact',
|
|
688
|
+
{ limit: args.limit, ...tabArgs(args) },
|
|
689
|
+
{ timeout: 30000 }
|
|
690
|
+
)
|
|
691
|
+
const items = Array.isArray(data?.items) ? data.items : []
|
|
692
|
+
if (items.length === 0) return ok('Интерактивных элементов не найдено.', { snapshot: data })
|
|
693
|
+
const lines = items.map((item, index) => {
|
|
694
|
+
const kind = item.type ? `${item.tag}[${item.type}]` : item.tag
|
|
695
|
+
const flags = item.disabled ? ' — недоступен' : item.checked ? ' — отмечен' : ''
|
|
696
|
+
return `${index + 1}. ${kind} «${item.name || '(без подписи)'}» → ${item.selector}${flags}`
|
|
697
|
+
})
|
|
698
|
+
const head = `Интерактивные элементы: ${data?.returned ?? items.length} из ${data?.total ?? items.length}${data?.truncated ? ' (список обрезан)' : ''}`
|
|
699
|
+
return ok(`${head}\n${lines.join('\n')}`, { snapshot: data })
|
|
700
|
+
}
|
|
701
|
+
)
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
ctx.tools.register(
|
|
705
|
+
tool(
|
|
706
|
+
'site_text',
|
|
707
|
+
'Видимый текст страницы или её поддерева — проверить, что после действия появилось нужное сообщение. ' +
|
|
708
|
+
'Возвращает текст с лимитом и пометкой обрезки.',
|
|
709
|
+
{
|
|
710
|
+
selector: { type: 'string', description: 'Поддерево (по умолчанию вся страница)' },
|
|
711
|
+
maxChars: { type: 'number', description: 'Лимит символов (по умолчанию 8000, максимум 40000)' },
|
|
712
|
+
tab: { type: 'number', description: 'id вкладки (по умолчанию активная)' }
|
|
713
|
+
},
|
|
714
|
+
async (args) => {
|
|
715
|
+
const instance = await ensureBridge()
|
|
716
|
+
pickTarget(instance, args.tab)
|
|
717
|
+
const data = await instance.enqueue(
|
|
718
|
+
'text.get',
|
|
719
|
+
{ selector: args.selector, maxChars: args.maxChars, ...tabArgs(args) },
|
|
720
|
+
{ timeout: 30000 }
|
|
721
|
+
)
|
|
722
|
+
if (data?.error) throw new Error(String(data.error))
|
|
723
|
+
const text = String(data?.text ?? '')
|
|
724
|
+
const note = data?.truncated ? `\n… обрезано, всего ${data.chars} символов` : ''
|
|
725
|
+
return ok(`${text || '(пусто)'}${note}`, { text, chars: data?.chars ?? text.length, truncated: data?.truncated === true })
|
|
726
|
+
}
|
|
727
|
+
)
|
|
728
|
+
)
|
|
729
|
+
|
|
656
730
|
if (settings.autoStart) {
|
|
657
731
|
void ensureBridge().catch((error) => {
|
|
658
732
|
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.5.0",
|
|
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",
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
"version:check": "node tools/version.mjs check",
|
|
41
41
|
"version:bump": "node tools/version.mjs bump",
|
|
42
42
|
"stage": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/install.ps1 -StageOnly",
|
|
43
|
-
"test:picker": "node test/picker.mjs"
|
|
43
|
+
"test:picker": "node test/picker.mjs",
|
|
44
|
+
"test:ops": "node test/browser-ops.mjs"
|
|
44
45
|
},
|
|
45
46
|
"dsh": {
|
|
46
47
|
"bundle": {
|
package/tools/preview-popup.mjs
CHANGED
|
@@ -21,6 +21,14 @@ const chromePath = process.env.CHROME_PATH ?? 'C:\\Program Files\\Google\\Chrome
|
|
|
21
21
|
const sleep = (ms) => new Promise((done) => setTimeout(done, ms))
|
|
22
22
|
|
|
23
23
|
const statuses = {
|
|
24
|
+
'connected-one': {
|
|
25
|
+
ok: true, configured: true, connected: true, port: 47321, recording: false, captured: 0,
|
|
26
|
+
activeTabId: 1,
|
|
27
|
+
target: { id: 1, title: 'Example Store — Products', url: 'https://shop.example/products' },
|
|
28
|
+
targets: [
|
|
29
|
+
{ id: 1, title: 'Example Store — Products', url: 'https://shop.example/products', recording: false, captured: 0 }
|
|
30
|
+
]
|
|
31
|
+
},
|
|
24
32
|
connected: {
|
|
25
33
|
ok: true, configured: true, connected: true, port: 47321, recording: true, captured: 12,
|
|
26
34
|
activeTabId: 1,
|