uriloop 0.3.1__py3-none-any.whl
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.
- uriloop/__init__.py +2 -0
- uriloop/__main__.py +5 -0
- uriloop/ai/__init__.py +0 -0
- uriloop/ai/decision.py +305 -0
- uriloop/ai/generator.py +153 -0
- uriloop/ai/http.py +35 -0
- uriloop/ai/router.py +129 -0
- uriloop/ai/vision.py +181 -0
- uriloop/analysis/__init__.py +0 -0
- uriloop/analysis/dom_probe.js +108 -0
- uriloop/analysis/file_transfer.js +53 -0
- uriloop/analysis/layers.py +211 -0
- uriloop/cli.py +247 -0
- uriloop/composites.py +150 -0
- uriloop/doctor.py +111 -0
- uriloop/drivers/__init__.py +0 -0
- uriloop/drivers/cdp.py +234 -0
- uriloop/drivers/vnc.py +227 -0
- uriloop/eventstore.py +236 -0
- uriloop/files.py +76 -0
- uriloop/loop.py +188 -0
- uriloop/mcp/__init__.py +0 -0
- uriloop/mcp/server.py +191 -0
- uriloop/nl.py +127 -0
- uriloop/privacy.py +55 -0
- uriloop/projector.py +147 -0
- uriloop/registry.py +93 -0
- uriloop/runtime.py +675 -0
- uriloop/scenarios.py +240 -0
- uriloop/shell.py +102 -0
- uriloop/suites.py +96 -0
- uriloop/tabs.py +102 -0
- uriloop/targeting.py +67 -0
- uriloop/todo.py +104 -0
- uriloop/uri.py +124 -0
- uriloop-0.3.1.dist-info/METADATA +307 -0
- uriloop-0.3.1.dist-info/RECORD +41 -0
- uriloop-0.3.1.dist-info/WHEEL +5 -0
- uriloop-0.3.1.dist-info/entry_points.txt +3 -0
- uriloop-0.3.1.dist-info/licenses/LICENSE +11 -0
- uriloop-0.3.1.dist-info/top_level.txt +1 -0
uriloop/ai/vision.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Warstwa L5 — detekcja YOLO na zrzutach (pulpit noVNC i strona CDP).
|
|
2
|
+
|
|
3
|
+
Backendy (router, rola `vision`):
|
|
4
|
+
ultralytics — `YOLO(model)(img)`; wagi .pt (np. yolo11n.pt; do GUI: detektor ikon/elementów UI)
|
|
5
|
+
onnxruntime — model wyeksportowany do ONNX (wyjście [1, 4+nc, N]: cx, cy, w, h, wyniki klas); bez torch
|
|
6
|
+
|
|
7
|
+
Każda detekcja dostaje URN (stabilny dla klasy i przybliżonej pozycji) i gotowe Action URI kliknięcia
|
|
8
|
+
w środek ramki — tym samym kanałem co elementy DOM, więc decyzja d1 i generator widzą je jednakowo.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ast
|
|
13
|
+
import functools
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from PIL import Image
|
|
18
|
+
|
|
19
|
+
from ..uri import stable_urn
|
|
20
|
+
from . import router
|
|
21
|
+
|
|
22
|
+
COCO = ("person bicycle car motorcycle airplane bus train truck boat traffic_light fire_hydrant stop_sign "
|
|
23
|
+
"parking_meter bench bird cat dog horse sheep cow elephant bear zebra giraffe backpack umbrella handbag "
|
|
24
|
+
"tie suitcase frisbee skis snowboard sports_ball kite baseball_bat baseball_glove skateboard surfboard "
|
|
25
|
+
"tennis_racket bottle wine_glass cup fork knife spoon bowl banana apple sandwich orange broccoli carrot "
|
|
26
|
+
"hot_dog pizza donut cake chair couch potted_plant bed dining_table toilet tv laptop mouse remote keyboard "
|
|
27
|
+
"cell_phone microwave oven toaster sink refrigerator book clock vase scissors teddy_bear hair_drier "
|
|
28
|
+
"toothbrush").split()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class VisionUnavailable(RuntimeError):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _profiles() -> list[dict]:
|
|
36
|
+
out = []
|
|
37
|
+
override = os.environ.get("URILOOP_YOLO_MODEL")
|
|
38
|
+
want = os.environ.get("URILOOP_YOLO_BACKEND")
|
|
39
|
+
for p in router.select("vision", router.Telemetry(), require_installed=False):
|
|
40
|
+
if want and p["backend"] != want:
|
|
41
|
+
continue
|
|
42
|
+
if override:
|
|
43
|
+
if (p["backend"] == "onnxruntime") != override.endswith(".onnx"):
|
|
44
|
+
continue
|
|
45
|
+
p = {**p, "model": override}
|
|
46
|
+
out.append(p)
|
|
47
|
+
if not out:
|
|
48
|
+
raise VisionUnavailable("brak profilu vision dla URILOOP_YOLO_BACKEND/URILOOP_YOLO_MODEL")
|
|
49
|
+
return out
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@functools.lru_cache(maxsize=2)
|
|
53
|
+
def _ultralytics(model: str):
|
|
54
|
+
try:
|
|
55
|
+
from ultralytics import YOLO
|
|
56
|
+
except ImportError as exc:
|
|
57
|
+
raise VisionUnavailable("pip install ultralytics (lub URILOOP_YOLO_BACKEND=onnxruntime)") from exc
|
|
58
|
+
return YOLO(model)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@functools.lru_cache(maxsize=2)
|
|
62
|
+
def _onnx(model: str):
|
|
63
|
+
try:
|
|
64
|
+
import onnxruntime as ort
|
|
65
|
+
except ImportError as exc:
|
|
66
|
+
raise VisionUnavailable("pip install onnxruntime") from exc
|
|
67
|
+
if not Path(model).exists():
|
|
68
|
+
raise VisionUnavailable(f"brak pliku modelu ONNX: {model}")
|
|
69
|
+
sess = ort.InferenceSession(model, providers=[p for p in ("CUDAExecutionProvider", "CPUExecutionProvider")
|
|
70
|
+
if p in ort.get_available_providers()])
|
|
71
|
+
meta = sess.get_modelmeta().custom_metadata_map or {}
|
|
72
|
+
names = COCO
|
|
73
|
+
if meta.get("names"):
|
|
74
|
+
try:
|
|
75
|
+
d = ast.literal_eval(meta["names"])
|
|
76
|
+
names = tuple(d[i] for i in sorted(d)) if isinstance(d, dict) else tuple(d)
|
|
77
|
+
except (ValueError, SyntaxError):
|
|
78
|
+
pass
|
|
79
|
+
return sess, names
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _letterbox(img: Image.Image, size: int):
|
|
83
|
+
import numpy as np
|
|
84
|
+
w, h = img.size
|
|
85
|
+
r = min(size / w, size / h)
|
|
86
|
+
nw, nh = round(w * r), round(h * r)
|
|
87
|
+
canvas = Image.new("RGB", (size, size), (114, 114, 114))
|
|
88
|
+
px, py = (size - nw) // 2, (size - nh) // 2
|
|
89
|
+
canvas.paste(img.convert("RGB").resize((nw, nh), Image.BILINEAR), (px, py))
|
|
90
|
+
arr = np.asarray(canvas, dtype=np.float32).transpose(2, 0, 1)[None] / 255.0
|
|
91
|
+
return arr, r, px, py
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _nms(boxes, scores, iou: float):
|
|
95
|
+
import numpy as np
|
|
96
|
+
order = scores.argsort()[::-1]
|
|
97
|
+
keep = []
|
|
98
|
+
while order.size:
|
|
99
|
+
i = order[0]
|
|
100
|
+
keep.append(i)
|
|
101
|
+
xx1 = np.maximum(boxes[i, 0], boxes[order[1:], 0])
|
|
102
|
+
yy1 = np.maximum(boxes[i, 1], boxes[order[1:], 1])
|
|
103
|
+
xx2 = np.minimum(boxes[i, 2], boxes[order[1:], 2])
|
|
104
|
+
yy2 = np.minimum(boxes[i, 3], boxes[order[1:], 3])
|
|
105
|
+
inter = np.clip(xx2 - xx1, 0, None) * np.clip(yy2 - yy1, 0, None)
|
|
106
|
+
area = lambda b: (b[..., 2] - b[..., 0]) * (b[..., 3] - b[..., 1]) # noqa: E731
|
|
107
|
+
o = inter / (area(boxes[i]) + area(boxes[order[1:]]) - inter + 1e-9)
|
|
108
|
+
order = order[1:][o < iou]
|
|
109
|
+
return keep
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def decode_yolo(out, conf: float, iou: float, r: float, px: int, py: int, names, size_wh):
|
|
113
|
+
"""Dekodowanie wyjścia YOLOv8/11 [1, 4+nc, N] → lista (x1, y1, x2, y2, score, cls) w pikselach obrazu."""
|
|
114
|
+
import numpy as np
|
|
115
|
+
pred = out[0]
|
|
116
|
+
nc4 = 4 + len(names)
|
|
117
|
+
if pred.shape[0] != nc4 and pred.shape[1] == nc4: # wariant [N, 4+nc]
|
|
118
|
+
pred = pred.T
|
|
119
|
+
boxes, cls_scores = pred[:4].T, pred[4:].T
|
|
120
|
+
cls = cls_scores.argmax(1)
|
|
121
|
+
score = cls_scores.max(1)
|
|
122
|
+
m = score >= conf
|
|
123
|
+
boxes, cls, score = boxes[m], cls[m], score[m]
|
|
124
|
+
if not len(score):
|
|
125
|
+
return []
|
|
126
|
+
xyxy = np.stack([boxes[:, 0] - boxes[:, 2] / 2, boxes[:, 1] - boxes[:, 3] / 2,
|
|
127
|
+
boxes[:, 0] + boxes[:, 2] / 2, boxes[:, 1] + boxes[:, 3] / 2], 1)
|
|
128
|
+
xyxy[:, [0, 2]] = (xyxy[:, [0, 2]] - px) / r
|
|
129
|
+
xyxy[:, [1, 3]] = (xyxy[:, [1, 3]] - py) / r
|
|
130
|
+
W, H = size_wh
|
|
131
|
+
xyxy[:, [0, 2]] = xyxy[:, [0, 2]].clip(0, W)
|
|
132
|
+
xyxy[:, [1, 3]] = xyxy[:, [1, 3]].clip(0, H)
|
|
133
|
+
out = []
|
|
134
|
+
for c in np.unique(cls):
|
|
135
|
+
idx = np.where(cls == c)[0]
|
|
136
|
+
for k in _nms(xyxy[idx], score[idx], iou):
|
|
137
|
+
j = idx[k]
|
|
138
|
+
out.append((*map(float, xyxy[j]), float(score[j]), int(c)))
|
|
139
|
+
return sorted(out, key=lambda d: -d[4])
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def detect(img: Image.Image, *, scope: str = "desktop", dpr: float = 1.0, conf: float | None = None,
|
|
143
|
+
iou: float = 0.45, max_det: int = 100) -> dict:
|
|
144
|
+
errors = []
|
|
145
|
+
for p in _profiles():
|
|
146
|
+
try:
|
|
147
|
+
return _detect_with(p, img, scope, dpr, conf, iou, max_det)
|
|
148
|
+
except VisionUnavailable as exc:
|
|
149
|
+
errors.append(f"{p['id']}: {exc}")
|
|
150
|
+
raise VisionUnavailable("; ".join(errors))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _detect_with(p: dict, img: Image.Image, scope: str, dpr: float, conf: float | None, iou: float, max_det: int) -> dict:
|
|
154
|
+
conf = conf if conf is not None else float(p.get("conf", 0.25))
|
|
155
|
+
raw = []
|
|
156
|
+
if p["backend"] == "ultralytics":
|
|
157
|
+
model = _ultralytics(p["model"])
|
|
158
|
+
res = model.predict(img.convert("RGB"), conf=conf, iou=iou, verbose=False, max_det=max_det)[0]
|
|
159
|
+
names = res.names
|
|
160
|
+
for b in res.boxes:
|
|
161
|
+
x1, y1, x2, y2 = (float(v) for v in b.xyxy[0].tolist())
|
|
162
|
+
raw.append((x1, y1, x2, y2, float(b.conf[0]), int(b.cls[0])))
|
|
163
|
+
else:
|
|
164
|
+
sess, names = _onnx(p["model"])
|
|
165
|
+
size = int(p.get("imgsz", 640))
|
|
166
|
+
arr, r, px, py = _letterbox(img, size)
|
|
167
|
+
out = sess.run(None, {sess.get_inputs()[0].name: arr})[0]
|
|
168
|
+
raw = decode_yolo(out, conf, iou, r, px, py, names, img.size)
|
|
169
|
+
dets = []
|
|
170
|
+
for x1, y1, x2, y2, s, c in raw[:max_det]:
|
|
171
|
+
label = names[c] if c < len(names) else str(c)
|
|
172
|
+
cx, cy = (x1 + x2) / 2, (y1 + y2) / 2
|
|
173
|
+
u = stable_urn("det", scope, label, round(cx / 24), round(cy / 24))
|
|
174
|
+
if scope == "desktop":
|
|
175
|
+
act = f"desktop://pointer/click?x={int(cx)}&y={int(cy)}"
|
|
176
|
+
else:
|
|
177
|
+
act = f"browser://page/click?x={round(cx / dpr, 1)}&y={round(cy / dpr, 1)}"
|
|
178
|
+
dets.append({"urn": u, "kind": "detection", "name": label, "label": label, "conf": round(s, 4),
|
|
179
|
+
"rect": {"x": int(x1), "y": int(y1), "w": int(x2 - x1), "h": int(y2 - y1)},
|
|
180
|
+
"center": {"x": int(cx), "y": int(cy)}, "in_viewport": True, "actions": [act]})
|
|
181
|
+
return {"profile": p["id"], "model": p["model"], "backend": p["backend"], "detections": dets}
|
|
File without changes
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Sonda DOM: wykrywa elementy interaktywne, formularze, zasoby i kandydatów żądań.
|
|
2
|
+
// Zwraca czysty JSON (returnByValue). Bez efektów ubocznych poza atrybutem data-uriloop-idx.
|
|
3
|
+
(() => {
|
|
4
|
+
const INTERACTIVE = 'a[href],button,input,select,textarea,summary,details,label[for],' +
|
|
5
|
+
'[role=button],[role=link],[role=checkbox],[role=radio],[role=tab],[role=menuitem],[role=switch],' +
|
|
6
|
+
'[role=option],[role=combobox],[role=textbox],[role=searchbox],[onclick],[contenteditable=""],' +
|
|
7
|
+
'[contenteditable=true],[tabindex]:not([tabindex="-1"])';
|
|
8
|
+
const out = { page: {}, elements: [], forms: [], headings: [], resources: [], text: '' };
|
|
9
|
+
const vw = innerWidth, vh = innerHeight;
|
|
10
|
+
|
|
11
|
+
const cssEsc = (s) => (window.CSS && CSS.escape) ? CSS.escape(s) : s.replace(/[^\w-]/g, '\\$&');
|
|
12
|
+
function selectorOf(el) {
|
|
13
|
+
if (el.id && document.querySelectorAll('#' + cssEsc(el.id)).length === 1) return '#' + cssEsc(el.id);
|
|
14
|
+
for (const a of ['data-testid', 'data-test', 'data-qa', 'name', 'aria-label']) {
|
|
15
|
+
const v = el.getAttribute(a);
|
|
16
|
+
if (v) {
|
|
17
|
+
const s = `${el.tagName.toLowerCase()}[${a}="${v.replace(/"/g, '\\"')}"]`;
|
|
18
|
+
try { if (document.querySelectorAll(s).length === 1) return s; } catch (e) {}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const path = [];
|
|
22
|
+
for (let n = el; n && n.nodeType === 1 && n !== document.documentElement; n = n.parentElement) {
|
|
23
|
+
let i = 1;
|
|
24
|
+
for (let s = n.previousElementSibling; s; s = s.previousElementSibling) if (s.tagName === n.tagName) i++;
|
|
25
|
+
path.unshift(`${n.tagName.toLowerCase()}:nth-of-type(${i})`);
|
|
26
|
+
if (n.id && document.querySelectorAll('#' + cssEsc(n.id)).length === 1) { path[0] = '#' + cssEsc(n.id); break; }
|
|
27
|
+
}
|
|
28
|
+
return path.join(' > ');
|
|
29
|
+
}
|
|
30
|
+
const clean = (s, n = 120) => (s || '').replace(/\s+/g, ' ').trim().slice(0, n);
|
|
31
|
+
function nameOf(el) {
|
|
32
|
+
const lbl = el.labels && el.labels[0] ? el.labels[0].innerText : '';
|
|
33
|
+
const by = el.getAttribute('aria-labelledby');
|
|
34
|
+
const byText = by ? by.split(/\s+/).map(id => (document.getElementById(id) || {}).innerText || '').join(' ') : '';
|
|
35
|
+
return clean(el.getAttribute('aria-label') || byText || lbl || el.innerText || (el.type === 'password' ? '' : el.value) ||
|
|
36
|
+
el.getAttribute('placeholder') || el.getAttribute('title') || el.getAttribute('alt') ||
|
|
37
|
+
(el.querySelector && el.querySelector('img[alt]') ? el.querySelector('img[alt]').alt : '') || el.name || '');
|
|
38
|
+
}
|
|
39
|
+
function kindOf(el) {
|
|
40
|
+
const t = el.tagName.toLowerCase(), role = el.getAttribute('role'), type = (el.getAttribute('type') || '').toLowerCase();
|
|
41
|
+
if (t === 'a') return 'navigate';
|
|
42
|
+
if (t === 'select' || role === 'combobox' || role === 'option') return 'select';
|
|
43
|
+
if (t === 'textarea' || el.isContentEditable || role === 'textbox' || role === 'searchbox') return 'input';
|
|
44
|
+
if (t === 'input') {
|
|
45
|
+
if (['checkbox', 'radio'].includes(type)) return 'toggle';
|
|
46
|
+
if (['submit', 'image'].includes(type)) return 'submit';
|
|
47
|
+
if (['button', 'reset'].includes(type)) return 'click';
|
|
48
|
+
if (type === 'file') return 'upload';
|
|
49
|
+
return 'input';
|
|
50
|
+
}
|
|
51
|
+
if (t === 'button') return (el.form && (type === 'submit' || type === '')) ? 'submit' : 'click';
|
|
52
|
+
if (['checkbox', 'radio', 'switch'].includes(role)) return 'toggle';
|
|
53
|
+
return 'click';
|
|
54
|
+
}
|
|
55
|
+
function visible(el, r) {
|
|
56
|
+
if (r.width < 1 || r.height < 1) return false;
|
|
57
|
+
const cs = getComputedStyle(el);
|
|
58
|
+
if (cs.visibility === 'hidden' || cs.display === 'none' || +cs.opacity === 0) return false;
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const seen = new Set();
|
|
63
|
+
const collect = (root) => {
|
|
64
|
+
root.querySelectorAll(INTERACTIVE).forEach((el) => {
|
|
65
|
+
if (seen.has(el)) return; seen.add(el);
|
|
66
|
+
const r = el.getBoundingClientRect();
|
|
67
|
+
const vis = visible(el, r);
|
|
68
|
+
const inView = vis && r.bottom > 0 && r.right > 0 && r.top < vh && r.left < vw;
|
|
69
|
+
const cx = Math.round(r.left + r.width / 2), cy = Math.round(r.top + r.height / 2);
|
|
70
|
+
let occluded = false;
|
|
71
|
+
if (inView) { const hit = document.elementFromPoint(cx, cy); occluded = !!hit && hit !== el && !el.contains(hit) && !hit.contains(el); }
|
|
72
|
+
const form = el.form || el.closest('form');
|
|
73
|
+
const idx = out.elements.length;
|
|
74
|
+
el.setAttribute('data-uriloop-idx', String(idx));
|
|
75
|
+
out.elements.push({
|
|
76
|
+
idx, tag: el.tagName.toLowerCase(), role: el.getAttribute('role') || null,
|
|
77
|
+
type: el.getAttribute('type') || null, kind: kindOf(el), name: nameOf(el),
|
|
78
|
+
selector: selectorOf(el), href: el.href || null,
|
|
79
|
+
download: el.hasAttribute('download') ? el.getAttribute('download') : null,
|
|
80
|
+
value: (el.type === 'password') ? (el.value ? '***' : '') : clean(el.value, 60) || null,
|
|
81
|
+
checked: ('checked' in el) ? !!el.checked : null,
|
|
82
|
+
disabled: !!(el.disabled || el.getAttribute('aria-disabled') === 'true'),
|
|
83
|
+
visible: vis, in_viewport: inView, occluded,
|
|
84
|
+
rect: { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) },
|
|
85
|
+
center: { x: cx, y: cy },
|
|
86
|
+
form: form ? { action: form.action || location.href, method: (form.method || 'get').toUpperCase(), selector: selectorOf(form) } : null,
|
|
87
|
+
handlers: { onclick: !!el.onclick || el.hasAttribute('onclick') },
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
root.querySelectorAll('*').forEach((n) => { if (n.shadowRoot) collect(n.shadowRoot); });
|
|
91
|
+
};
|
|
92
|
+
collect(document);
|
|
93
|
+
|
|
94
|
+
document.querySelectorAll('form').forEach((f) => out.forms.push({
|
|
95
|
+
selector: selectorOf(f), action: f.action || location.href, method: (f.method || 'get').toUpperCase(),
|
|
96
|
+
fields: Array.from(f.elements).filter(e => e.name).map(e => ({ name: e.name, type: e.type, required: !!e.required })),
|
|
97
|
+
}));
|
|
98
|
+
document.querySelectorAll('h1,h2,h3,[role=heading]').forEach((h) => {
|
|
99
|
+
const t = clean(h.innerText, 160); if (t) out.headings.push({ level: h.tagName.toLowerCase(), text: t });
|
|
100
|
+
});
|
|
101
|
+
(performance.getEntriesByType('resource') || []).slice(-300).forEach((e) => out.resources.push({
|
|
102
|
+
url: e.name, initiator: e.initiatorType, bytes: e.transferSize || 0, ms: Math.round(e.duration),
|
|
103
|
+
}));
|
|
104
|
+
out.page = { url: location.href, title: document.title, origin: location.origin, w: vw, h: vh,
|
|
105
|
+
dpr: devicePixelRatio, sx: scrollX, sy: scrollY, ready: document.readyState, lang: document.documentElement.lang || null };
|
|
106
|
+
out.text = clean(document.body ? document.body.innerText : '', 4000);
|
|
107
|
+
return out;
|
|
108
|
+
})()
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Transfer bytes through CDP: no shared host/Chromium filesystem required.
|
|
2
|
+
(() => ({
|
|
3
|
+
fetch: async ({selector, max_bytes, timeout_ms}) => {
|
|
4
|
+
const nodes = document.querySelectorAll(selector);
|
|
5
|
+
if (nodes.length !== 1 || nodes[0].tagName !== 'A') throw Error('link disappeared');
|
|
6
|
+
const link = nodes[0], url = new URL(link.href);
|
|
7
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.origin !== location.origin)
|
|
8
|
+
throw Error('same-origin HTTP link required');
|
|
9
|
+
const controller = new AbortController();
|
|
10
|
+
const timer = setTimeout(() => controller.abort(), timeout_ms);
|
|
11
|
+
try {
|
|
12
|
+
const response = await fetch(url, {mode: 'same-origin', credentials: 'same-origin',
|
|
13
|
+
redirect: 'error', signal: controller.signal});
|
|
14
|
+
if (!response.ok) throw Error('HTTP ' + response.status);
|
|
15
|
+
if (response.status === 206) throw Error('partial HTTP response');
|
|
16
|
+
if (+response.headers.get('content-length') > max_bytes) throw Error('file exceeds max_bytes');
|
|
17
|
+
const reader = response.body.getReader(), chunks = [];
|
|
18
|
+
let size = 0;
|
|
19
|
+
for (;;) {
|
|
20
|
+
const {done, value} = await reader.read();
|
|
21
|
+
if (done) break;
|
|
22
|
+
size += value.length;
|
|
23
|
+
if (size > max_bytes) { await reader.cancel(); throw Error('file exceeds max_bytes'); }
|
|
24
|
+
chunks.push(value);
|
|
25
|
+
}
|
|
26
|
+
let binary = '';
|
|
27
|
+
for (const chunk of chunks) {
|
|
28
|
+
for (let offset = 0; offset < chunk.length; offset += 8192)
|
|
29
|
+
binary += String.fromCharCode(...chunk.subarray(offset, offset + 8192));
|
|
30
|
+
}
|
|
31
|
+
return {data: btoa(binary), bytes: size, mime: (response.headers.get('content-type') ||
|
|
32
|
+
'application/octet-stream').split(';')[0].trim().toLowerCase(),
|
|
33
|
+
name: link.download || decodeURIComponent(url.pathname.split('/').pop()) || 'download.bin',
|
|
34
|
+
url: response.url, status: response.status};
|
|
35
|
+
} finally { clearTimeout(timer); controller.abort(); }
|
|
36
|
+
},
|
|
37
|
+
attach: ({selector, data, name, mime, clear}) => {
|
|
38
|
+
const nodes = document.querySelectorAll(selector);
|
|
39
|
+
if (nodes.length !== 1) throw Error('file input disappeared');
|
|
40
|
+
const input = nodes[0];
|
|
41
|
+
if (input.tagName !== 'INPUT' || input.type !== 'file' || input.disabled)
|
|
42
|
+
throw Error('active file input required');
|
|
43
|
+
const transfer = new DataTransfer();
|
|
44
|
+
if (!clear) {
|
|
45
|
+
const bytes = Uint8Array.from(atob(data), ch => ch.charCodeAt(0));
|
|
46
|
+
transfer.items.add(new File([bytes], name, {type: mime}));
|
|
47
|
+
}
|
|
48
|
+
input.files = transfer.files;
|
|
49
|
+
input.dispatchEvent(new Event('input', {bubbles: true}));
|
|
50
|
+
input.dispatchEvent(new Event('change', {bubbles: true}));
|
|
51
|
+
return {count: input.files.length};
|
|
52
|
+
}
|
|
53
|
+
}))()
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Warstwowa analiza ekranu.
|
|
2
|
+
|
|
3
|
+
L0 pixels — zrzut PNG/WebP, sha256, dHash, różnica względem poprzedniego zrzutu, siatka regionów
|
|
4
|
+
L1 dom — elementy interaktywne z sondy DOM (selektor, rect, rola, nazwa, stan)
|
|
5
|
+
L2 actions — każdy element => URN + zestaw Action URI (click/type/navigate/select/toggle)
|
|
6
|
+
L3 network — żądania CDP + zasoby performance + endpointy formularzy => URN, powiązania element→żądanie
|
|
7
|
+
L4 semantic — streszczenie: tytuł, nagłówki, formularze, tekst, liczniki; Set-of-Mark (adnotowany zrzut)
|
|
8
|
+
|
|
9
|
+
Wszystkie funkcje są czyste (dane wejściowe => słownik), więc testowalne bez przeglądarki.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import io
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
17
|
+
|
|
18
|
+
from PIL import Image, ImageDraw, ImageStat
|
|
19
|
+
|
|
20
|
+
from ..uri import ActionUri, stable_urn
|
|
21
|
+
|
|
22
|
+
PROBE_JS = (Path(__file__).with_name("dom_probe.js")).read_text(encoding="utf-8")
|
|
23
|
+
LAYERS = ("pixels", "dom", "actions", "network", "semantic")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------- L0: piksele ----------------
|
|
27
|
+
def encode(img: Image.Image, fmt: str = "png", quality: int = 80) -> bytes:
|
|
28
|
+
buf = io.BytesIO()
|
|
29
|
+
if fmt == "webp":
|
|
30
|
+
img.save(buf, "WEBP", quality=quality, method=4)
|
|
31
|
+
elif fmt == "jpeg":
|
|
32
|
+
img.convert("RGB").save(buf, "JPEG", quality=quality)
|
|
33
|
+
else:
|
|
34
|
+
img.save(buf, "PNG", optimize=True)
|
|
35
|
+
return buf.getvalue()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def dhash(img: Image.Image, size: int = 8) -> str:
|
|
39
|
+
g = img.convert("L").resize((size + 1, size), Image.BILINEAR)
|
|
40
|
+
px = g.tobytes()
|
|
41
|
+
bits = 0
|
|
42
|
+
for row in range(size):
|
|
43
|
+
for col in range(size):
|
|
44
|
+
bits = (bits << 1) | (px[row * (size + 1) + col] > px[row * (size + 1) + col + 1])
|
|
45
|
+
return f"{bits:0{size * size // 4}x}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def hamming(a: str, b: str) -> int:
|
|
49
|
+
return bin(int(a, 16) ^ int(b, 16)).count("1")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def grid_regions(img: Image.Image, n: int = 8, prev: Image.Image | None = None, scope: str = "screen") -> list[dict]:
|
|
53
|
+
"""Siatka n×n: jasność, wariancja (czy region ma treść), zmiana względem poprzedniej klatki."""
|
|
54
|
+
w, h = img.size
|
|
55
|
+
gray = img.convert("L")
|
|
56
|
+
pgray = prev.convert("L").resize((w, h)) if prev is not None else None
|
|
57
|
+
out = []
|
|
58
|
+
for gy in range(n):
|
|
59
|
+
for gx in range(n):
|
|
60
|
+
box = (gx * w // n, gy * h // n, (gx + 1) * w // n, (gy + 1) * h // n)
|
|
61
|
+
cell = gray.crop(box)
|
|
62
|
+
st = ImageStat.Stat(cell)
|
|
63
|
+
changed = None
|
|
64
|
+
if pgray is not None:
|
|
65
|
+
a = cell.resize((16, 16))
|
|
66
|
+
b = pgray.crop(box).resize((16, 16))
|
|
67
|
+
diff = sum(abs(x - y) for x, y in zip(a.tobytes(), b.tobytes())) / (256 * 255)
|
|
68
|
+
changed = round(diff, 4)
|
|
69
|
+
cx, cy = (box[0] + box[2]) // 2, (box[1] + box[3]) // 2
|
|
70
|
+
out.append({
|
|
71
|
+
"urn": stable_urn("region", scope, n, gx, gy), "gx": gx, "gy": gy,
|
|
72
|
+
"rect": {"x": box[0], "y": box[1], "w": box[2] - box[0], "h": box[3] - box[1]},
|
|
73
|
+
"center": {"x": cx, "y": cy}, "mean": round(st.mean[0], 1),
|
|
74
|
+
"stddev": round(st.stddev[0], 1), "content": st.stddev[0] > 12, "changed": changed,
|
|
75
|
+
})
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def pixel_layer(img: Image.Image, fmt: str, prev: Image.Image | None = None, grid: int = 8,
|
|
80
|
+
scope: str = "screen", quality: int = 80) -> tuple[dict, bytes]:
|
|
81
|
+
data = encode(img, fmt, quality)
|
|
82
|
+
regions = grid_regions(img, grid, prev, scope)
|
|
83
|
+
changes = [r["changed"] for r in regions if r["changed"] is not None]
|
|
84
|
+
layer = {
|
|
85
|
+
"format": fmt, "width": img.size[0], "height": img.size[1], "bytes": len(data),
|
|
86
|
+
"sha256": hashlib.sha256(data).hexdigest(), "dhash": dhash(img),
|
|
87
|
+
"change_ratio": round(sum(c > 0.02 for c in changes) / len(changes), 3) if changes else None,
|
|
88
|
+
"regions": regions,
|
|
89
|
+
}
|
|
90
|
+
return layer, data
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------- L1+L2: DOM i akcje ----------------
|
|
94
|
+
def _origin_path(url: str) -> str:
|
|
95
|
+
u = urlsplit(url or "")
|
|
96
|
+
return urlunsplit((u.scheme, u.netloc, u.path, "", ""))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def element_urn(page_url: str, el: dict, tab_urn: str | None = None) -> str:
|
|
100
|
+
"""Stabilny adres komponentu we wskazanej karcie i stronie (bez query)."""
|
|
101
|
+
return stable_urn("el", tab_urn or '', _origin_path(page_url), el.get("selector"), el.get("tag"),
|
|
102
|
+
el.get("role") or "", (el.get("name") or "")[:60])
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def actions_for(el: dict, scheme: str = "browser") -> list[str]:
|
|
106
|
+
u = el["urn"]
|
|
107
|
+
if el.get("disabled"):
|
|
108
|
+
return []
|
|
109
|
+
k = el["kind"]
|
|
110
|
+
acts = []
|
|
111
|
+
if k == "navigate" and el.get("href") and not el["href"].startswith("javascript:"):
|
|
112
|
+
acts.append(str(ActionUri(scheme, "page", "navigate", {"url": el["href"]})))
|
|
113
|
+
if el['href'].startswith(('http://', 'https://')):
|
|
114
|
+
acts.append(str(ActionUri(scheme, 'tabs', 'open', {'urn': u})))
|
|
115
|
+
if el.get('download') is not None:
|
|
116
|
+
acts.append(str(ActionUri(scheme, 'file', 'fetch', {'urn': u})))
|
|
117
|
+
if k == 'upload':
|
|
118
|
+
acts.append(str(ActionUri(scheme, 'page', 'attach', {'urn': u, 'clear': True})))
|
|
119
|
+
if k in ("input",):
|
|
120
|
+
if el.get('type') == 'password':
|
|
121
|
+
acts.append(str(ActionUri(scheme, 'page', 'secret', {'urn':u, 'env':'URILOOP_SECRET_PASSWORD'})))
|
|
122
|
+
else:
|
|
123
|
+
acts.append(str(ActionUri(scheme, "page", "type", {"urn": u, "text": ""})))
|
|
124
|
+
if k in ("navigate", "click", "submit", "toggle", "select", "upload", "input"):
|
|
125
|
+
acts.insert(0, str(ActionUri(scheme, "page", "click", {"urn": u})))
|
|
126
|
+
return acts
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def dom_layers(probe: dict) -> tuple[list[dict], list[dict]]:
|
|
130
|
+
page_url = probe.get("page", {}).get("url", "")
|
|
131
|
+
elements = []
|
|
132
|
+
for el in probe.get("elements", []):
|
|
133
|
+
e = dict(el)
|
|
134
|
+
e["urn"] = element_urn(page_url, el, probe.get('page', {}).get('tab_urn'))
|
|
135
|
+
e["actions"] = actions_for(e)
|
|
136
|
+
elements.append(e)
|
|
137
|
+
forms = []
|
|
138
|
+
for f in probe.get("forms", []):
|
|
139
|
+
forms.append({**f, "urn": stable_urn("form", probe.get('page', {}).get('tab_urn') or '', _origin_path(page_url), f["selector"]),
|
|
140
|
+
"endpoint": stable_urn("endpoint", f["method"], _origin_path(f["action"]))})
|
|
141
|
+
return elements, forms
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------- L3: sieć ----------------
|
|
145
|
+
def network_layer(requests: list[dict], probe: dict, elements: list[dict], forms: list[dict]) -> dict:
|
|
146
|
+
reqs = [dict(r) for r in requests]
|
|
147
|
+
by_url: dict[str, str] = {}
|
|
148
|
+
for r in reqs:
|
|
149
|
+
r.setdefault("urn", stable_urn("req", r.get("method", "GET"), r["url"].split("#")[0], r.get("id", "")))
|
|
150
|
+
r["endpoint"] = stable_urn("endpoint", r.get("method", "GET"), _origin_path(r["url"]))
|
|
151
|
+
by_url.setdefault(r["url"].split("#")[0], r["urn"])
|
|
152
|
+
resources = []
|
|
153
|
+
for res in probe.get("resources", []):
|
|
154
|
+
resources.append({**res, "urn": stable_urn("res", res["url"].split("#")[0]),
|
|
155
|
+
"request": by_url.get(res["url"].split("#")[0])})
|
|
156
|
+
links = []
|
|
157
|
+
for e in elements:
|
|
158
|
+
target = None
|
|
159
|
+
if e.get("href"):
|
|
160
|
+
target = stable_urn("endpoint", "GET", _origin_path(e["href"]))
|
|
161
|
+
elif e.get("kind") == "submit" and e.get("form"):
|
|
162
|
+
target = stable_urn("endpoint", e["form"]["method"], _origin_path(e["form"]["action"]))
|
|
163
|
+
if target:
|
|
164
|
+
e["triggers"] = target
|
|
165
|
+
links.append({"element": e["urn"], "endpoint": target})
|
|
166
|
+
endpoints = sorted({r["endpoint"] for r in reqs} | {f["endpoint"] for f in forms} | {lk["endpoint"] for lk in links})
|
|
167
|
+
return {"requests": reqs, "resources": resources, "links": links, "endpoints": endpoints,
|
|
168
|
+
"failed": [r["urn"] for r in reqs if r.get("failed") or (r.get("status") or 0) >= 400]}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---------------- L4: semantyka + Set-of-Mark ----------------
|
|
172
|
+
def semantic_layer(probe: dict, elements: list[dict], forms: list[dict]) -> dict:
|
|
173
|
+
vis = [e for e in elements if e.get("in_viewport") and not e.get("occluded")]
|
|
174
|
+
kinds: dict[str, int] = {}
|
|
175
|
+
for e in elements:
|
|
176
|
+
kinds[e["kind"]] = kinds.get(e["kind"], 0) + 1
|
|
177
|
+
page = probe.get("page", {})
|
|
178
|
+
return {
|
|
179
|
+
"title": page.get("title"), "url": page.get("url"), "lang": page.get("lang"),
|
|
180
|
+
"headings": probe.get("headings", [])[:20], "forms": len(forms), "kinds": kinds,
|
|
181
|
+
"visible_actions": [{"mark": i + 1, "urn": e["urn"], "kind": e["kind"], "name": e["name"]}
|
|
182
|
+
for i, e in enumerate(vis[:60])],
|
|
183
|
+
"text_excerpt": (probe.get("text") or "")[:800],
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def annotate(img: Image.Image, elements: list[dict], dpr: float = 1.0) -> Image.Image:
|
|
188
|
+
"""Set-of-Mark: numerowane ramki widocznych elementów (dla VLM i człowieka)."""
|
|
189
|
+
out = img.convert("RGB").copy()
|
|
190
|
+
d = ImageDraw.Draw(out)
|
|
191
|
+
palette = {"navigate": (37, 99, 235), "submit": (220, 38, 38), "input": (22, 163, 74),
|
|
192
|
+
"toggle": (202, 138, 4), "select": (147, 51, 234), "click": (234, 88, 12), "upload": (8, 145, 178)}
|
|
193
|
+
vis = [e for e in elements if e.get("in_viewport") and not e.get("occluded")]
|
|
194
|
+
for i, e in enumerate(vis[:60], 1):
|
|
195
|
+
r = e["rect"]
|
|
196
|
+
x0, y0 = r["x"] * dpr, r["y"] * dpr
|
|
197
|
+
x1, y1 = x0 + r["w"] * dpr, y0 + r["h"] * dpr
|
|
198
|
+
col = palette.get(e["kind"], (100, 100, 100))
|
|
199
|
+
d.rectangle([x0, y0, x1, y1], outline=col, width=2)
|
|
200
|
+
label = str(i)
|
|
201
|
+
d.rectangle([x0, max(0, y0 - 14), x0 + 7 * len(label) + 6, max(14, y0)], fill=col)
|
|
202
|
+
d.text((x0 + 3, max(0, y0 - 13)), label, fill=(255, 255, 255))
|
|
203
|
+
return out
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def summarize(sem: dict, net: dict | None) -> str:
|
|
207
|
+
k = ", ".join(f"{v} {n}" for n, v in sorted(sem.get("kinds", {}).items()))
|
|
208
|
+
s = f"{sem.get('title') or sem.get('url')}: {k or 'brak elementów'}"
|
|
209
|
+
if net:
|
|
210
|
+
s += f"; {len(net['requests'])} żądań, {len(net['failed'])} błędnych"
|
|
211
|
+
return s
|