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/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