168 lines
5.9 KiB
JavaScript
168 lines
5.9 KiB
JavaScript
// Injected into page context via CDP Runtime.evaluate
|
|
// Returns a compact, token-light flat-map of visible interactive elements and text
|
|
// This runs INSIDE V8 on the page — must be a self-contained IIFE
|
|
|
|
(function() {
|
|
const vw = window.innerWidth;
|
|
const vh = window.innerHeight;
|
|
const actions = [];
|
|
const texts = [];
|
|
let aid = 1;
|
|
let tid = 1;
|
|
|
|
const INTERACTIVE_TAGS = new Set(['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'DETAILS', 'SUMMARY']);
|
|
const INTERACTIVE_ROLES = new Set(['button', 'link', 'menuitem', 'tab', 'checkbox', 'radio', 'switch', 'option', 'combobox', 'textbox', 'searchbox']);
|
|
const TEXT_TAGS = new Set(['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'TD', 'TH', 'LABEL', 'BLOCKQUOTE', 'FIGCAPTION', 'ARTICLE', 'CAPTION', 'PRE', 'DD', 'DT']);
|
|
const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'SVG', 'PATH', 'META', 'LINK', 'BR', 'HR']);
|
|
|
|
function isVisible(el) {
|
|
const r = el.getBoundingClientRect();
|
|
if (r.width === 0 || r.height === 0) return false;
|
|
if (r.bottom < 0 || r.top > vh || r.right < 0 || r.left > vw) return false;
|
|
const s = window.getComputedStyle(el);
|
|
if (s.display === 'none' || s.visibility === 'hidden') return false;
|
|
return true;
|
|
}
|
|
|
|
function getLabel(el) {
|
|
// Priority: aria-label > innerText > placeholder > title > alt > name
|
|
return (
|
|
el.getAttribute('aria-label') ||
|
|
el.innerText?.trim().slice(0, 80) ||
|
|
el.getAttribute('placeholder') ||
|
|
el.getAttribute('title') ||
|
|
el.getAttribute('alt') ||
|
|
el.getAttribute('name') ||
|
|
''
|
|
).replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
function isInteractive(el) {
|
|
if (INTERACTIVE_TAGS.has(el.tagName)) return true;
|
|
const role = el.getAttribute('role');
|
|
if (role && INTERACTIVE_ROLES.has(role)) return true;
|
|
if (el.onclick || el.hasAttribute('onclick')) return true;
|
|
if (el.getAttribute('tabindex') && el.getAttribute('tabindex') !== '-1') return true;
|
|
if (el.__custom_listeners) return true;
|
|
// Check cursor style as heuristic for clickable divs
|
|
const cursor = window.getComputedStyle(el).cursor;
|
|
if (cursor === 'pointer') return true;
|
|
return false;
|
|
}
|
|
|
|
function getInputState(el) {
|
|
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
|
|
const t = el.getAttribute('type') || 'text';
|
|
const v = el.value || '';
|
|
const checked = el.checked;
|
|
let state = `type=${t}`;
|
|
if (v) state += ` value="${v.slice(0, 40)}"`;
|
|
if (checked) state += ' checked';
|
|
if (el.disabled) state += ' disabled';
|
|
if (el === document.activeElement) state += ' focused';
|
|
return state;
|
|
}
|
|
if (el.tagName === 'SELECT') {
|
|
const selected = el.options[el.selectedIndex]?.text || '';
|
|
return `selected="${selected.slice(0, 40)}"`;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function walk(root) {
|
|
const children = root.children;
|
|
if (!children) return;
|
|
|
|
for (let i = 0; i < children.length; i++) {
|
|
const el = children[i];
|
|
if (SKIP_TAGS.has(el.tagName)) continue;
|
|
if (!isVisible(el)) continue;
|
|
|
|
if (isInteractive(el)) {
|
|
if (el.hasAttribute('data-harvest-id')) continue; // already captured
|
|
const r = el.getBoundingClientRect();
|
|
const label = getLabel(el);
|
|
if (!label && el.tagName !== 'INPUT' && el.tagName !== 'TEXTAREA') {
|
|
walk(el);
|
|
continue;
|
|
}
|
|
const entry = {
|
|
id: aid,
|
|
tag: el.tagName.toLowerCase(),
|
|
label: label || '(empty)',
|
|
x: Math.round(r.left + r.width / 2),
|
|
y: Math.round(r.top + r.height / 2),
|
|
};
|
|
if (el.tagName === 'A' && el.href) entry.href = el.href;
|
|
const inputState = getInputState(el);
|
|
if (inputState) entry.state = inputState;
|
|
actions.push(entry);
|
|
// Mark element so we can find it later for interaction
|
|
el.setAttribute('data-harvest-id', String(aid));
|
|
aid++;
|
|
// Don't recurse into interactive elements (avoid double-counting children)
|
|
continue;
|
|
}
|
|
|
|
// Check if it's a meaningful text container (allow mixed content like spans/links inside)
|
|
if (TEXT_TAGS.has(el.tagName)) {
|
|
if (el.hasAttribute('data-harvest-tid')) continue; // already captured
|
|
const text = el.innerText?.trim();
|
|
if (text && text.length > 10) {
|
|
texts.push({ id: tid, tag: el.tagName.toLowerCase(), text: text.slice(0, 300) });
|
|
el.setAttribute('data-harvest-tid', String(tid));
|
|
tid++;
|
|
if (!el.querySelector('a,button,input,select,textarea,details,summary,[role],[onclick],[tabindex]')) {
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
walk(el);
|
|
}
|
|
}
|
|
|
|
// Walk shadow roots if accessible
|
|
function walkWithShadow(root) {
|
|
walk(root);
|
|
const allEls = root.querySelectorAll('*');
|
|
for (const el of allEls) {
|
|
if (el.shadowRoot) {
|
|
walk(el.shadowRoot);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clear previous harvest markers so re-runs work correctly
|
|
document.querySelectorAll('[data-harvest-id]').forEach(el => el.removeAttribute('data-harvest-id'));
|
|
document.querySelectorAll('[data-harvest-tid]').forEach(el => el.removeAttribute('data-harvest-tid'));
|
|
|
|
// Auto-scroll to capture below-fold content (up to 5 viewports)
|
|
const origScrollY = window.scrollY;
|
|
const maxScrolls = 5;
|
|
const scrollStep = Math.round(vh * 0.8);
|
|
|
|
for (let i = 0; i <= maxScrolls; i++) {
|
|
if (i > 0) {
|
|
window.scrollTo(0, i * scrollStep);
|
|
// Allow layout to settle
|
|
}
|
|
walkWithShadow(document.body);
|
|
|
|
// Stop if we've scrolled past the end
|
|
if (window.scrollY + vh >= document.body.scrollHeight) break;
|
|
}
|
|
|
|
// Restore original scroll position
|
|
window.scrollTo(0, origScrollY);
|
|
|
|
return JSON.stringify({
|
|
url: location.href,
|
|
title: document.title,
|
|
scroll: { x: window.scrollX, y: origScrollY, maxY: document.body.scrollHeight - vh },
|
|
viewport: { w: vw, h: vh },
|
|
actions: actions.slice(0, 150),
|
|
texts: texts.slice(0, 100),
|
|
});
|
|
})()
|