dsh-site-bridge 0.4.1 → 0.5.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 +7 -4
- package/README.ru.md +8 -5
- package/README.zh-CN.md +8 -5
- 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/icon.svg +27 -0
- package/lib/index.js +79 -5
- package/package.json +6 -3
- 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
|
|
@@ -123,11 +123,14 @@ The popup UI follows the browser language: English, Russian and Simplified Chine
|
|
|
123
123
|
## Usage
|
|
124
124
|
|
|
125
125
|
```text
|
|
126
|
-
site_status → bridge state, pairing path,
|
|
127
|
-
site_tabs {action:"list"} → tabs;
|
|
126
|
+
site_status → bridge state, pairing path, tabs
|
|
127
|
+
site_tabs {action:"list"} → tabs; claim/use/release/open/focus
|
|
128
128
|
site_network {action:"start"} → begin recording, then act on the page
|
|
129
129
|
site_capture {screenshot:true} → write the bundle into the workspace
|
|
130
|
-
|
|
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
|
|
131
134
|
site_cursor {x,y,click:true} → virtual cursor, real mouse event
|
|
132
135
|
site_shot {fullPage:true} → screenshot to captures/shots/*.png
|
|
133
136
|
site_inject {js:"..."} → run your script in the page to verify it
|
package/README.ru.md
CHANGED
|
@@ -123,11 +123,14 @@ Chrome покажет плашку «началась отладка этого
|
|
|
123
123
|
## Использование
|
|
124
124
|
|
|
125
125
|
```text
|
|
126
|
-
site_status → состояние моста, путь к
|
|
127
|
-
site_tabs {action:"list"} → вкладки;
|
|
128
|
-
site_network {action:"start"} → начать запись, затем
|
|
129
|
-
site_capture {screenshot:true} → записать пакет в рабочее пространство
|
|
130
|
-
|
|
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"} → человек кликает элемент и пишет примечание
|
|
131
134
|
site_cursor {x,y,click:true} → виртуальный курсор, настоящее событие мыши
|
|
132
135
|
site_shot {fullPage:true} → скриншот в captures/shots/*.png
|
|
133
136
|
site_inject {js:"..."} → выполнить ваш скрипт в странице для проверки
|
package/README.zh-CN.md
CHANGED
|
@@ -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
|
编写页内扩展的典型循环:
|
|
@@ -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/icon.svg
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="DSH Site Bridge">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#5b7cff"/>
|
|
5
|
+
<stop offset="1" stop-color="#2a45e0"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<linearGradient id="ink" x1="0" y1="0" x2="0" y2="1">
|
|
8
|
+
<stop offset="0" stop-color="#3f5cf0"/>
|
|
9
|
+
<stop offset="1" stop-color="#2a3fd6"/>
|
|
10
|
+
</linearGradient>
|
|
11
|
+
</defs>
|
|
12
|
+
|
|
13
|
+
<rect width="128" height="128" rx="26" fill="url(#bg)"/>
|
|
14
|
+
|
|
15
|
+
<!-- optical size: inverted tile keeps contrast at 16px -->
|
|
16
|
+
<rect x="13" y="15" width="102" height="98" rx="20" fill="#ffffff"/>
|
|
17
|
+
|
|
18
|
+
<!-- the browser bar, so the tile reads as a page and not a card -->
|
|
19
|
+
<line x1="13" y1="41" x2="115" y2="41" stroke="url(#ink)" stroke-width="7"/>
|
|
20
|
+
<circle cx="27" cy="28" r="3.4" fill="url(#ink)"/>
|
|
21
|
+
<circle cx="39" cy="28" r="3.4" fill="url(#ink)" opacity="0.65"/>
|
|
22
|
+
|
|
23
|
+
<!-- whale cut out of the page -->
|
|
24
|
+
<path d="M72 60 L 96 43 L 88 65 L 96 87 L 70 76 Z" fill="url(#ink)"/>
|
|
25
|
+
<path d="M24 86 C 24 67, 42 53, 63 55 C 82 57, 91 68, 90 80 C 89 94, 72 101, 53 97 C 37 94, 24 93, 24 86 Z"
|
|
26
|
+
fill="url(#ink)"/>
|
|
27
|
+
</svg>
|
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,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-site-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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
|
+
"icon": "./icon.svg",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"main": "./lib/index.js",
|
|
7
8
|
"exports": {
|
|
@@ -15,7 +16,8 @@
|
|
|
15
16
|
"docs",
|
|
16
17
|
"scripts",
|
|
17
18
|
"tools",
|
|
18
|
-
"cordis.patch.yml"
|
|
19
|
+
"cordis.patch.yml",
|
|
20
|
+
"icon.svg"
|
|
19
21
|
],
|
|
20
22
|
"keywords": [
|
|
21
23
|
"deepseek-harness",
|
|
@@ -40,7 +42,8 @@
|
|
|
40
42
|
"version:check": "node tools/version.mjs check",
|
|
41
43
|
"version:bump": "node tools/version.mjs bump",
|
|
42
44
|
"stage": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/install.ps1 -StageOnly",
|
|
43
|
-
"test:picker": "node test/picker.mjs"
|
|
45
|
+
"test:picker": "node test/picker.mjs",
|
|
46
|
+
"test:ops": "node test/browser-ops.mjs"
|
|
44
47
|
},
|
|
45
48
|
"dsh": {
|
|
46
49
|
"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,
|