@thincoder/core 0.10.5 → 0.10.6
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/CHANGELOG.md +8 -0
- package/browser/actions.mjs +163 -0
- package/browser/cdp.mjs +161 -0
- package/browser/clipboard.mjs +142 -0
- package/browser/input-actions.mjs +197 -0
- package/browser/input.mjs +256 -0
- package/browser/launch.mjs +143 -0
- package/browser/queue.mjs +171 -0
- package/browser/session.mjs +494 -0
- package/browser/snapshot.mjs +270 -0
- package/package.json +2 -1
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browser/snapshot.mjs — 页面侧快照脚本 ∥ 几何 ∥ 点 / 聚焦表达式 ∥ 结果归一 ∥ 引用表 ∥ 紧凑渲染 ∥ 页摘
|
|
3
|
+
* (BROWSER-TOOL.md §2.3 ∥ §2.7)。
|
|
4
|
+
*
|
|
5
|
+
* 页面侧脚本一律经 `Runtime.evaluate` **一次性执行、不驻留**(§3.3);每条表达式带一个块注释标识
|
|
6
|
+
* 前缀(`thincoder-browser:<种类>`)——回执/调试可辨出处,单元测试假传输按此前缀派发。
|
|
7
|
+
* 引用表:`key = tag + "|" + selector`,跨快照复用原号(KD-1 / F-BT4「微变不全失效」)。
|
|
8
|
+
* 几何(F-BT14):每条记录携 `geo`(视口 CSS 像素)与 `inViewport`;`[outside]` 仅标视口外行(KD-15)。
|
|
9
|
+
*/
|
|
10
|
+
export const DEFAULT_MAX = 100
|
|
11
|
+
export const MAX_ELEMENTS = 200
|
|
12
|
+
export const MAX_SNAPSHOT_CHARS = 20_000
|
|
13
|
+
export const COMPACT_ROWS = 30 // 失败回执的紧凑清单行数
|
|
14
|
+
|
|
15
|
+
const mark = (kind) => `/*thincoder-browser:${kind}*/`
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 页面侧快照函数源串(`(opts) => 记录集`)。
|
|
19
|
+
* 走交互元素(§2.3):`a[href]` / `button` / `input`(非 hidden) / `select` / `textarea` /
|
|
20
|
+
* `[role=button]` / `[onclick]` / `[tabindex]`;`selector` = 稳定寻址链(`#id` → `[data-testid]`
|
|
21
|
+
* → `[name]` → `[href]` → 结构路径);password 输入**不取 value**(N-BT4)。
|
|
22
|
+
* 几何随记录(§2.7):`geo = {x,y,w,h}` 取整 + `inViewport`(非零面积 ∩ 视口)。
|
|
23
|
+
*/
|
|
24
|
+
export const SNAPSHOT_EXPR = String.raw`function (opts) {
|
|
25
|
+
var SEL = 'a[href],button,input:not([type=hidden]),select,textarea,[role=button],[onclick],[tabindex]'
|
|
26
|
+
var norm = function (s) { return String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, 60) }
|
|
27
|
+
var esc = function (s) {
|
|
28
|
+
try { return (globalThis.CSS && CSS.escape) ? CSS.escape(String(s)) : String(s).replace(/[^a-zA-Z0-9_-]/g, function (c) { return '\\' + c }) }
|
|
29
|
+
catch (e) { return String(s) }
|
|
30
|
+
}
|
|
31
|
+
var unique = function (sel) { try { return document.querySelectorAll(sel).length === 1 } catch (e) { return false } }
|
|
32
|
+
var pathOf = function (el) {
|
|
33
|
+
var parts = []
|
|
34
|
+
var cur = el
|
|
35
|
+
while (cur && cur.nodeType === 1 && cur !== document.documentElement) {
|
|
36
|
+
if (cur.id && unique('#' + esc(cur.id))) { parts.unshift('#' + esc(cur.id)); break }
|
|
37
|
+
var tag = cur.tagName.toLowerCase()
|
|
38
|
+
var parent = cur.parentElement
|
|
39
|
+
if (!parent) { parts.unshift(tag); break }
|
|
40
|
+
var same = Array.prototype.filter.call(parent.children, function (c) { return c.tagName === cur.tagName })
|
|
41
|
+
parts.unshift(same.length > 1 ? tag + ':nth-of-type(' + (same.indexOf(cur) + 1) + ')' : tag)
|
|
42
|
+
cur = parent
|
|
43
|
+
}
|
|
44
|
+
return parts.join(' > ')
|
|
45
|
+
}
|
|
46
|
+
var selectorOf = function (el) {
|
|
47
|
+
if (el.id && unique('#' + esc(el.id))) return '#' + esc(el.id)
|
|
48
|
+
var testid = el.getAttribute('data-testid')
|
|
49
|
+
if (testid && unique('[data-testid="' + testid + '"]')) return '[data-testid="' + testid + '"]'
|
|
50
|
+
var name = el.getAttribute('name')
|
|
51
|
+
if (name && unique('[name="' + name + '"]')) return '[name="' + name + '"]'
|
|
52
|
+
var href = el.getAttribute('href')
|
|
53
|
+
if (href && el.tagName === 'A' && unique('[href="' + href + '"]')) return '[href="' + href + '"]'
|
|
54
|
+
return pathOf(el)
|
|
55
|
+
}
|
|
56
|
+
var roleOf = function (el, tag) {
|
|
57
|
+
var t = String(el.getAttribute('type') || '').toLowerCase()
|
|
58
|
+
if (tag === 'a') return 'link'
|
|
59
|
+
if (tag === 'select') return 'select'
|
|
60
|
+
if (tag === 'textarea') return 'textbox'
|
|
61
|
+
if (tag === 'button') return 'button'
|
|
62
|
+
if (tag === 'input') {
|
|
63
|
+
if (t === 'password') return 'password'
|
|
64
|
+
if (t === 'checkbox') return 'checkbox'
|
|
65
|
+
if (t === 'radio') return 'radio'
|
|
66
|
+
if (t === 'submit' || t === 'button' || t === 'reset' || t === 'image') return 'button'
|
|
67
|
+
return 'textbox'
|
|
68
|
+
}
|
|
69
|
+
if (el.getAttribute('role') === 'button' || el.hasAttribute('onclick')) return 'button'
|
|
70
|
+
return 'other'
|
|
71
|
+
}
|
|
72
|
+
var nameOf = function (el) {
|
|
73
|
+
// 可及名取序照 §2.3:aria-label ∥ 文本 ∥ placeholder ∥ title
|
|
74
|
+
return norm(el.getAttribute('aria-label') || el.innerText || el.textContent || el.getAttribute('placeholder') || el.getAttribute('title') || '')
|
|
75
|
+
}
|
|
76
|
+
var rectOf = function (el) {
|
|
77
|
+
var r = el.getBoundingClientRect()
|
|
78
|
+
return { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) }
|
|
79
|
+
}
|
|
80
|
+
var inViewportOf = function (g) {
|
|
81
|
+
return g.w > 0 && g.h > 0 && g.x < innerWidth && g.y < innerHeight && g.x + g.w > 0 && g.y + g.h > 0
|
|
82
|
+
}
|
|
83
|
+
var root = (opts && opts.selector) ? document.querySelector(opts.selector) : document
|
|
84
|
+
if (!root) return { missing: true, url: location.href, title: document.title, elements: [], total: 0 }
|
|
85
|
+
var nodes = []
|
|
86
|
+
if (root.nodeType === 1 && root.matches && root.matches(SEL)) nodes.push(root)
|
|
87
|
+
var found = root.querySelectorAll ? root.querySelectorAll(SEL) : []
|
|
88
|
+
for (var i = 0; i < found.length; i++) nodes.push(found[i])
|
|
89
|
+
var max = Math.max(1, (opts && opts.max) ? opts.max : 100)
|
|
90
|
+
var elements = []
|
|
91
|
+
for (var j = 0; j < nodes.length && elements.length < max; j++) {
|
|
92
|
+
var el = nodes[j]
|
|
93
|
+
var tag = el.tagName.toLowerCase()
|
|
94
|
+
var role = roleOf(el, tag)
|
|
95
|
+
var geo = rectOf(el)
|
|
96
|
+
var rec = {
|
|
97
|
+
tag: tag,
|
|
98
|
+
role: role,
|
|
99
|
+
name: nameOf(el),
|
|
100
|
+
selector: selectorOf(el),
|
|
101
|
+
disabled: Boolean(el.disabled) || el.getAttribute('aria-disabled') === 'true',
|
|
102
|
+
geo: geo,
|
|
103
|
+
inViewport: inViewportOf(geo),
|
|
104
|
+
}
|
|
105
|
+
if (role === 'textbox' || role === 'select') rec.value = norm(el.value)
|
|
106
|
+
elements.push(rec)
|
|
107
|
+
}
|
|
108
|
+
return { url: location.href, title: document.title, total: nodes.length, elements: elements }
|
|
109
|
+
}`
|
|
110
|
+
|
|
111
|
+
/** 快照表达式(页面侧一次执行)。 */
|
|
112
|
+
export function snapshotExpression({ selector = null, max = DEFAULT_MAX } = {}) {
|
|
113
|
+
return `${mark("snapshot")}(${SNAPSHOT_EXPR})(${JSON.stringify({ selector, max })})`
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 当前页信息表达式(URL ∥ title ∥ readyState)。 */
|
|
117
|
+
export function pageInfoExpression() {
|
|
118
|
+
return `${mark("pageinfo")}(function () { return { url: location.href, title: document.title, readyState: document.readyState } })()`
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 点击表达式(寻址 + tag 一致校验 + disabled 判;返回 `{found, disabled, tag?}`)。 */
|
|
122
|
+
export function clickExpression(selector, tag) {
|
|
123
|
+
return `${mark("click")}(function () {
|
|
124
|
+
var el = document.querySelector(${JSON.stringify(selector)})
|
|
125
|
+
if (!el) return { found: false }
|
|
126
|
+
var tag = el.tagName.toLowerCase()
|
|
127
|
+
if (tag !== ${JSON.stringify(tag)}) return { found: false, tag: tag }
|
|
128
|
+
if (el.disabled) return { found: true, disabled: true }
|
|
129
|
+
el.scrollIntoView({ block: 'center' })
|
|
130
|
+
el.click()
|
|
131
|
+
return { found: true, disabled: false }
|
|
132
|
+
})()`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 输入表达式(原生 value setter + input/change 事件;回执判据 = `password` 标记)。 */
|
|
136
|
+
export function typeExpression(selector, tag, text, clear) {
|
|
137
|
+
return `${mark("type")}(function () {
|
|
138
|
+
var el = document.querySelector(${JSON.stringify(selector)})
|
|
139
|
+
if (!el) return { found: false }
|
|
140
|
+
var tag = el.tagName.toLowerCase()
|
|
141
|
+
if (tag !== ${JSON.stringify(tag)}) return { found: false, tag: tag }
|
|
142
|
+
if (tag !== 'input' && tag !== 'textarea') return { found: true, disabled: false, password: false, fillable: false }
|
|
143
|
+
var password = tag === 'input' && String(el.getAttribute('type') || '').toLowerCase() === 'password'
|
|
144
|
+
if (el.disabled) return { found: true, disabled: true, password: password }
|
|
145
|
+
var proto = tag === 'textarea' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype
|
|
146
|
+
var desc = Object.getOwnPropertyDescriptor(proto, 'value')
|
|
147
|
+
var next = ${clear ? "true" : "false"} ? ${JSON.stringify(text)} : String(el.value == null ? '' : el.value) + ${JSON.stringify(text)}
|
|
148
|
+
if (desc && desc.set) desc.set.call(el, next); else el.value = next
|
|
149
|
+
el.dispatchEvent(new Event('input', { bubbles: true }))
|
|
150
|
+
el.dispatchEvent(new Event('change', { bubbles: true }))
|
|
151
|
+
return { found: true, disabled: false, password: password }
|
|
152
|
+
})()`
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 点表达式(ref → 真点——§2.7):`scrollIntoView({block:'center'})` → 重测 → 返回中心点与 `{found, tag?}`。 */
|
|
156
|
+
export function pointExpression(selector, tag) {
|
|
157
|
+
return `${mark("point")}(function () {
|
|
158
|
+
var el = document.querySelector(${JSON.stringify(selector)})
|
|
159
|
+
if (!el) return { found: false }
|
|
160
|
+
var tag = el.tagName.toLowerCase()
|
|
161
|
+
if (tag !== ${JSON.stringify(tag)}) return { found: false, tag: tag }
|
|
162
|
+
el.scrollIntoView({ block: 'center' })
|
|
163
|
+
var r = el.getBoundingClientRect()
|
|
164
|
+
return { found: true, x: r.left + r.width / 2, y: r.top + r.height / 2 }
|
|
165
|
+
})()`
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 聚焦表达式(§2.7):`scrollIntoView` + `focus()` → `{found, tag?, focused}`;不可聚焦 ⇒ `focused:false`。 */
|
|
169
|
+
export function focusExpression(selector, tag) {
|
|
170
|
+
return `${mark("focus")}(function () {
|
|
171
|
+
var el = document.querySelector(${JSON.stringify(selector)})
|
|
172
|
+
if (!el) return { found: false }
|
|
173
|
+
var tag = el.tagName.toLowerCase()
|
|
174
|
+
if (tag !== ${JSON.stringify(tag)}) return { found: false, tag: tag }
|
|
175
|
+
el.scrollIntoView({ block: 'center' })
|
|
176
|
+
try { el.focus() } catch (e) { /* 不可聚焦元素(div 等)——focused 读回自证 */ }
|
|
177
|
+
return { found: true, focused: document.activeElement === el }
|
|
178
|
+
})()`
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 等待谓词表达式(selector ∥ text ∥ url——networkIdle 走核侧事件计数)。 */
|
|
182
|
+
export function waitExpression(kind, value) {
|
|
183
|
+
const expr = kind === "selector" ? `!!document.querySelector(${JSON.stringify(value)})`
|
|
184
|
+
: kind === "text" ? `String((document.body && document.body.innerText) || '').includes(${JSON.stringify(value)})`
|
|
185
|
+
: `location.href.includes(${JSON.stringify(value)})`
|
|
186
|
+
return `${mark("wait")}(function () { return ${expr} })()`
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** 页面侧结果 → 形状归一的记录集(不可信输入:缺字段/异型一律归一,不抛)。 */
|
|
190
|
+
export function normalizeSnapshot(raw) {
|
|
191
|
+
const r = raw && typeof raw === "object" ? raw : {}
|
|
192
|
+
const num = (v) => (Number.isFinite(Number(v)) ? Math.round(Number(v)) : 0)
|
|
193
|
+
const elements = (Array.isArray(r.elements) ? r.elements : [])
|
|
194
|
+
.filter((e) => e && typeof e === "object" && e.selector)
|
|
195
|
+
.map((e) => {
|
|
196
|
+
const rec = {
|
|
197
|
+
tag: String(e.tag ?? "").toLowerCase(),
|
|
198
|
+
role: String(e.role ?? "other"),
|
|
199
|
+
name: String(e.name ?? ""),
|
|
200
|
+
selector: String(e.selector),
|
|
201
|
+
disabled: e.disabled === true,
|
|
202
|
+
}
|
|
203
|
+
if (e.value !== undefined && e.value !== null && String(e.value) !== "") rec.value = String(e.value)
|
|
204
|
+
if (e.geo && typeof e.geo === "object") {
|
|
205
|
+
rec.geo = { x: num(e.geo.x), y: num(e.geo.y), w: num(e.geo.w), h: num(e.geo.h) }
|
|
206
|
+
}
|
|
207
|
+
if (e.inViewport === false) rec.inViewport = false // 仅视口外落标记(在屏行文法零动——KD-15)
|
|
208
|
+
return rec
|
|
209
|
+
})
|
|
210
|
+
return {
|
|
211
|
+
url: String(r.url ?? ""),
|
|
212
|
+
title: String(r.title ?? ""),
|
|
213
|
+
elements,
|
|
214
|
+
total: Number.isInteger(r.total) ? Math.max(r.total, elements.length) : elements.length,
|
|
215
|
+
missing: r.missing === true,
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** 核侧引用表(会话级——`byRef` 反查、`byKey` 复用、`counter` 会话内单调)。 */
|
|
220
|
+
export function createRefTable() {
|
|
221
|
+
return { byRef: new Map(), byKey: new Map(), counter: 1 }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** 记录集 → 引用行(key 已在表 ⇒ 复用原号;未见 ⇒ 新号 + `isNew`)。 */
|
|
225
|
+
export function assignRefs(table, elements) {
|
|
226
|
+
const rows = []
|
|
227
|
+
for (const el of elements) {
|
|
228
|
+
const key = `${el.tag}|${el.selector}`
|
|
229
|
+
let entry = table.byKey.get(key)
|
|
230
|
+
const isNew = !entry
|
|
231
|
+
if (isNew) {
|
|
232
|
+
entry = { ref: `e${table.counter++}`, key, selector: el.selector, tag: el.tag, name: el.name }
|
|
233
|
+
table.byKey.set(key, entry)
|
|
234
|
+
table.byRef.set(entry.ref, entry)
|
|
235
|
+
} else {
|
|
236
|
+
entry.name = el.name // 名随页面更新;号不变(F-BT4)
|
|
237
|
+
}
|
|
238
|
+
rows.push({ ...el, ref: entry.ref, isNew })
|
|
239
|
+
}
|
|
240
|
+
return rows
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** 结果行文法:`<ref> <role> "<name>"` + `[value=…]`(password 除外)+ `[new]` + `[disabled]` + `[outside]`。 */
|
|
244
|
+
export function rowLine(row) {
|
|
245
|
+
const marks = []
|
|
246
|
+
if (row.role !== "password" && row.value !== undefined && String(row.value) !== "") marks.push(`[value="${row.value}"]`)
|
|
247
|
+
if (row.isNew) marks.push("[new]")
|
|
248
|
+
if (row.disabled) marks.push("[disabled]")
|
|
249
|
+
if (row.inViewport === false) marks.push("[outside]")
|
|
250
|
+
return `${row.ref} ${row.role} "${row.name}"${marks.length ? ` ${marks.join(" ")}` : ""}`
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 页摘行(内容与出处同行——N-BT2 来源标注)。 */
|
|
254
|
+
export function pageDigest(url, title) {
|
|
255
|
+
return `[page] ${url} — "${title}"`
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** 紧凑渲染:页摘 ∥ 计数行 ∥ 元素行 ∥ 截断标记;总文本 ≤ 20,000 字符(N-BT3)。 */
|
|
259
|
+
export function renderSnapshot({ url, title, rows = [], total = rows.length }) {
|
|
260
|
+
const lines = [pageDigest(url, title), `[${total} interactive elements (${rows.filter((r) => r.isNew).length} new)]`]
|
|
261
|
+
for (const row of rows) lines.push(rowLine(row))
|
|
262
|
+
if (total > rows.length) lines.push(`[truncated: showing ${rows.length} of ${total} — pass selector to narrow]`)
|
|
263
|
+
return truncateText(lines.join("\n"), MAX_SNAPSHOT_CHARS, "pass selector to narrow")
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** 截断(`truncate` 同款标记形态——`tools/shared.mjs:190-193`)。 */
|
|
267
|
+
export function truncateText(text, max, note = "narrow the request") {
|
|
268
|
+
if (text.length <= max) return text
|
|
269
|
+
return `${text.slice(0, max)}\n[... truncated: ${text.length - max} chars omitted — ${note}]`
|
|
270
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thincoder/core",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.6",
|
|
4
4
|
"description": "ThinCoder shared core — shared mechanism modules + shared prompt content (CLI ↔ VS Code extension).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"advisor/",
|
|
41
41
|
"agent/",
|
|
42
42
|
"agent-tools/",
|
|
43
|
+
"browser/",
|
|
43
44
|
"git/",
|
|
44
45
|
"mcp/",
|
|
45
46
|
"memory/",
|