SPB Git

Repository Public Research preview

page.js 23.6 KB 330 lines javascript

1/* In-page helper for safari-mcp. Injected with every call; defines window.__smcp once per document.2   Plain ES2017, no dependencies. Everything it returns is JSON-serialisable. */3(function () {4  if (window.__smcp && window.__smcp.v === 3) return;5  var S = { v: 3, refs: new Map(), next: 1, dialogs: [], jobs: {}, bufs: {} };67  /* ---------- traversal: light DOM + open shadow roots + same-origin iframes ---------- */8  function roots(doc, out, path) {9    out.push({ root: doc, path: path });10    var all = doc.querySelectorAll("*");11    for (var i = 0; i < all.length; i++) {12      var el = all[i];13      if (el.shadowRoot) roots(el.shadowRoot, out, path);14      if (el.tagName === "IFRAME" || el.tagName === "FRAME") {15        try { if (el.contentDocument && el.contentDocument.documentElement) roots(el.contentDocument, out, path.concat([i])); } catch (e) { /* cross-origin */ }16      }17    }18    return out;19  }20  function deepAll(selector) {21    var out = [], rs = roots(document, [], []);22    for (var i = 0; i < rs.length; i++) { try { var f = rs[i].root.querySelectorAll(selector); for (var j = 0; j < f.length; j++) out.push(f[j]); } catch (e) { if (i === 0) throw new Error("invalid selector: " + selector); } }23    return out;24  }25  function crossOriginFrames() {26    var out = [], fr = document.querySelectorAll("iframe,frame");27    for (var i = 0; i < fr.length; i++) { var ok = false; try { ok = !!fr[i].contentDocument; } catch (e) {} if (!ok && fr[i].src) out.push(fr[i].src); }28    return out;29  }3031  /* ---------- element description ---------- */32  function visible(el) {33    if (!el.isConnected) return false;34    var w = el.ownerDocument.defaultView, st = w.getComputedStyle(el);35    if (st.visibility === "hidden" || st.display === "none" || Number(st.opacity) === 0) return false;36    var r = el.getBoundingClientRect();37    return r.width > 0 && r.height > 0;38  }39  function clean(s, n) { s = String(s == null ? "" : s).replace(/\s+/g, " ").trim(); return n && s.length > n ? s.slice(0, n) + "…" : s; }40  function labelFor(el) {41    var d = el.ownerDocument, a = el.getAttribute("aria-label");42    if (a) return clean(a, 120);43    var lb = el.getAttribute("aria-labelledby");44    if (lb) { var t = lb.split(/\s+/).map(function (id) { var x = d.getElementById(id); return x ? x.textContent : ""; }).join(" "); if (clean(t)) return clean(t, 120); }45    if (el.labels && el.labels.length) return clean(el.labels[0].textContent, 120);46    if (el.id) { try { var l = d.querySelector('label[for="' + CSS.escape(el.id) + '"]'); if (l) return clean(l.textContent, 120); } catch (e) {} }47    var p = el.closest("label"); if (p) return clean(p.textContent, 120);48    return "";49  }50  function roleOf(el) {51    var r = el.getAttribute("role"); if (r) return r;52    var t = el.tagName.toLowerCase();53    if (t === "a") return "link";54    if (t === "button" || t === "summary") return "button";55    if (t === "select") return "combobox";56    if (t === "textarea") return "textbox";57    if (t === "input") { var ty = (el.type || "text").toLowerCase(); return ty === "checkbox" ? "checkbox" : ty === "radio" ? "radio" : ty === "submit" || ty === "button" || ty === "reset" || ty === "image" ? "button" : ty === "range" ? "slider" : ty === "file" ? "file" : "textbox"; }58    if (el.isContentEditable) return "textbox";59    return t;60  }61  function nameOf(el) {62    var t = el.tagName.toLowerCase(), l = labelFor(el);63    if (l) return l;64    if (t === "input") { var ty = (el.type || "").toLowerCase(); if (ty === "submit" || ty === "button" || ty === "reset") return clean(el.value || ty, 120); return clean(el.placeholder || el.name || el.title || "", 120); }65    if (t === "img") return clean(el.alt || el.title, 120);66    var txt = clean(el.innerText || el.textContent, 120);67    if (txt) return txt;68    var img = el.querySelector && el.querySelector("img[alt]"); if (img) return clean(img.alt, 120);69    return clean(el.title || el.getAttribute("name") || el.placeholder || "", 120);70  }71  function sensitive(el) {72    var ty = (el.type || "").toLowerCase(), ac = (el.getAttribute && el.getAttribute("autocomplete") || "").toLowerCase(), nm = ((el.name || "") + " " + (el.id || "")).toLowerCase();73    return ty === "password" || /cc-|current-password|new-password|one-time-code/.test(ac) || /passw|card.?num|cvv|cvc|ssn|\bnas\b|secret|token/.test(nm);74  }75  function refOf(el) {76    var found = null;77    S.refs.forEach(function (v, k) { if (v === el) found = k; });78    if (found) return found;79    var k = "e" + S.next++; S.refs.set(k, el); return k;80  }81  function describe(el) {82    var t = el.tagName.toLowerCase(), role = roleOf(el), r = el.getBoundingClientRect(), w = el.ownerDocument.defaultView;83    var o = { ref: refOf(el), role: role, tag: t, name: nameOf(el) };84    if (t === "a" && el.href) o.href = el.href;85    if (t === "input" || t === "textarea" || t === "select") {86      if (el.type) o.type = el.type;87      if (role === "checkbox" || role === "radio") o.checked = !!el.checked;88      else if (role === "file") o.files = el.files ? el.files.length : 0;89      else o.value = sensitive(el) ? (el.value ? "[redacted]" : "") : clean(t === "select" && el.selectedOptions[0] ? el.selectedOptions[0].textContent : el.value, 200);90      if (t === "select") o.options = Array.prototype.slice.call(el.options, 0, 50).map(function (x) { return clean(x.textContent, 60); });91      if (el.required) o.required = true;92      if (sensitive(el)) o.sensitive = true;93    }94    if (el.disabled || el.getAttribute("aria-disabled") === "true") o.disabled = true;95    if (el.getAttribute("aria-expanded")) o.expanded = el.getAttribute("aria-expanded") === "true";96    o.in_viewport = r.bottom > 0 && r.right > 0 && r.top < w.innerHeight && r.left < w.innerWidth;97    if (el.ownerDocument !== document) o.in_frame = true;98    return o;99  }100  var INTERACTIVE = 'a[href],button,input:not([type="hidden"]),select,textarea,summary,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[role="checkbox"],[role="radio"],[role="switch"],[role="option"],[role="combobox"],[contenteditable=""],[contenteditable="true"],[onclick],[tabindex]:not([tabindex="-1"])';101102  /* ---------- target resolution ---------- */103  function resolve(target, opts) {104    opts = opts || {};105    var list;106    if (!target || typeof target !== "object") throw new Error("target is required: {ref} | {selector} | {text, role?} | {label}");107    if (target.ref) {108      var el = S.refs.get(target.ref);109      if (!el || !el.isConnected) throw new Error("ref " + target.ref + " is stale (the page changed or reloaded). Take a new snapshot.");110      return el;111    }112    if (target.selector) list = deepAll(target.selector);113    else if (target.label != null) {114      var want = clean(target.label).toLowerCase();115      list = deepAll("input,select,textarea,[contenteditable=''],[contenteditable='true']").filter(function (e) {116        var n = (labelFor(e) || e.placeholder || e.name || e.getAttribute("aria-label") || "").toLowerCase();117        return target.exact ? clean(n) === want : n.indexOf(want) !== -1;118      });119    } else if (target.text != null) {120      var wt = clean(target.text).toLowerCase(), sel = target.role === "link" ? 'a[href],[role="link"]' : target.role === "button" ? 'button,input[type="submit"],input[type="button"],[role="button"],summary' : INTERACTIVE;121      list = deepAll(sel).filter(function (e) { var n = nameOf(e).toLowerCase(); return target.exact ? n === wt : n.indexOf(wt) !== -1; });122      // Prefer exact matches when a loose search returns several.123      var ex = list.filter(function (e) { return nameOf(e).toLowerCase() === wt; }); if (ex.length) list = ex;124    } else throw new Error("target needs ref, selector, text or label");125    var vis = list.filter(visible);126    if (opts.allowHidden && vis.length === 0) vis = list;127    if (vis.length === 0) throw new Error(list.length ? "target matched " + list.length + " element(s) but none is visible" : "no element matches the target");128    if (typeof target.index === "number") { if (!vis[target.index]) throw new Error("index " + target.index + " out of range (" + vis.length + " matches)"); return vis[target.index]; }129    if (vis.length > 1 && !opts.first) {130      var e2 = new Error("target is ambiguous: " + vis.length + " visible matches. Add index, or use a ref.");131      e2.candidates = vis.slice(0, 8).map(describe); throw e2;132    }133    return vis[0];134  }135136  /* ---------- actions ---------- */137  function fire(el, type, init) {138    var w = el.ownerDocument.defaultView, r = el.getBoundingClientRect();139    var base = { bubbles: true, cancelable: true, composed: true, view: w, clientX: r.left + r.width / 2, clientY: r.top + r.height / 2, button: 0 };140    for (var k in init || {}) base[k] = init[k];141    var Ev = /^pointer/.test(type) && w.PointerEvent ? w.PointerEvent : w.MouseEvent;142    return el.dispatchEvent(new Ev(type, base));143  }144  function setValue(el, value) {145    var w = el.ownerDocument.defaultView, proto = el.tagName === "TEXTAREA" ? w.HTMLTextAreaElement.prototype : el.tagName === "SELECT" ? w.HTMLSelectElement.prototype : w.HTMLInputElement.prototype;146    var d = Object.getOwnPropertyDescriptor(proto, "value");147    if (d && d.set) d.set.call(el, value); else el.value = value; // native setter: frameworks that patch `value` (React) still see the change148    el.dispatchEvent(new w.Event("input", { bubbles: true, composed: true }));149    el.dispatchEvent(new w.Event("change", { bubbles: true, composed: true }));150  }151  S.api = {152    info: function () {153      return { url: location.href, title: document.title, ready_state: document.readyState, scroll: { x: Math.round(scrollX), y: Math.round(scrollY), max_y: Math.max(0, document.documentElement.scrollHeight - innerHeight) }, viewport: { width: innerWidth, height: innerHeight }, dialogs: S.dialogs.splice(0), cross_origin_frames: crossOriginFrames().slice(0, 10) };154    },155    snapshot: function (a) {156      var max = a.limit || 150, offset = a.offset || 0;157      var els = deepAll(INTERACTIVE).filter(visible);158      if (a.viewport_only) els = els.filter(function (e) { var r = e.getBoundingClientRect(); return r.bottom > 0 && r.top < innerHeight; });159      var heads = deepAll("h1,h2,h3").filter(visible).slice(0, 40).map(function (h) { return { level: Number(h.tagName[1]), text: clean(h.textContent, 120) }; });160      return { url: location.href, title: document.title, total_interactive: els.length, offset: offset, elements: els.slice(offset, offset + max).map(describe), headings: heads, truncated: els.length > offset + max, cross_origin_frames: crossOriginFrames().slice(0, 10) };161    },162    text: function (a) {163      var el = a.target ? resolve(a.target, { first: true, allowHidden: true }) : document.body;164      var t = (el.innerText || el.textContent || "").replace(/\n{3,}/g, "\n\n").trim(), max = a.max_chars || 20000, off = a.offset || 0;165      return { url: location.href, title: document.title, length: t.length, offset: off, text: t.slice(off, off + max), truncated: t.length > off + max };166    },167    html: function (a) {168      var el = a.target ? resolve(a.target, { first: true, allowHidden: true }) : document.documentElement, h = el.outerHTML, max = a.max_chars || 20000;169      return { length: h.length, html: h.slice(0, max), truncated: h.length > max };170    },171    query: function (a) {172      var list = deepAll(a.selector), max = a.limit || 50;173      return { count: list.length, elements: list.slice(0, max).map(function (e) {174        var o = { ref: refOf(e), tag: e.tagName.toLowerCase(), text: clean(e.innerText || e.textContent, a.text_chars || 200), visible: visible(e) };175        if (a.attributes) { o.attributes = {}; a.attributes.forEach(function (n) { var v = e.getAttribute(n); if (v != null) o.attributes[n] = /^(value)$/.test(n) && sensitive(e) ? "[redacted]" : clean(v, 300); }); }176        if (e.href) o.href = e.href; if (e.src) o.src = e.src;177        return o;178      }), truncated: list.length > max };179    },180    links: function (a) {181      var seen = {}, out = [], f = a.filter ? a.filter.toLowerCase() : null;182      deepAll("a[href]").forEach(function (e) {183        if (!/^https?:/.test(e.href) || seen[e.href]) return;184        var t = clean(e.innerText || e.textContent || (e.querySelector("img[alt]") || {}).alt, 100);185        if (f && e.href.toLowerCase().indexOf(f) === -1 && t.toLowerCase().indexOf(f) === -1) return;186        if (a.same_origin && new URL(e.href).origin !== location.origin) return;187        seen[e.href] = 1; out.push({ text: t, href: e.href });188      });189      var max = a.limit || 200; return { count: out.length, links: out.slice(0, max), truncated: out.length > max };190    },191    tables: function (a) {192      var ts = deepAll(a.selector || "table").filter(function (t) { return t.tagName === "TABLE"; }), max = a.max_rows || 200;193      return { count: ts.length, tables: ts.slice(0, a.limit || 5).map(function (t) {194        var rows = Array.prototype.map.call(t.rows, function (r) { return Array.prototype.map.call(r.cells, function (c) { return clean(c.innerText || c.textContent, 300); }); });195        var hasHead = t.tHead || (t.rows[0] && t.rows[0].querySelector("th"));196        return { caption: t.caption ? clean(t.caption.textContent, 120) : null, headers: hasHead ? rows[0] : null, rows: rows.slice(hasHead ? 1 : 0, (hasHead ? 1 : 0) + max), total_rows: rows.length - (hasHead ? 1 : 0), truncated: rows.length - (hasHead ? 1 : 0) > max };197      }) };198    },199    forms: function () {200      return { forms: deepAll("form").slice(0, 20).map(function (f, i) {201        return { index: i, ref: refOf(f), action: f.action || null, method: (f.method || "get").toLowerCase(), name: f.getAttribute("name") || f.id || null,202          fields: Array.prototype.filter.call(f.elements, function (e) { return e.type !== "hidden" && e.tagName !== "FIELDSET"; }).slice(0, 80).map(describe) };203      }) };204    },205    click: function (a) {206      var el = resolve(a.target), before = location.href;207      if (el.disabled) throw new Error("element is disabled");208      el.scrollIntoView({ block: "center", inline: "center" });209      var d = describe(el);210      fire(el, "pointerdown"); fire(el, "mousedown");211      if (el.focus) try { el.focus({ preventScroll: true }); } catch (e) {}212      fire(el, "pointerup"); fire(el, "mouseup");213      if (a.double) { el.click(); fire(el, "dblclick"); } else el.click();214      return { clicked: d, url_before: before, opens_new_tab: el.tagName === "A" && el.target === "_blank" };215    },216    hover: function (a) { var el = resolve(a.target); el.scrollIntoView({ block: "center" }); fire(el, "pointerover"); fire(el, "mouseover"); fire(el, "pointerenter", { bubbles: false }); fire(el, "mouseenter", { bubbles: false }); fire(el, "pointermove"); fire(el, "mousemove"); return { hovered: describe(el) }; },217    fill: function (a) {218      var el = resolve(a.target), t = el.tagName.toLowerCase(), value = String(a.value);219      if (el.disabled || el.readOnly) throw new Error("field is disabled or read-only");220      if (sensitive(el) && !a.confirm_sensitive) { var e = new Error("this looks like a password, payment or secret field. Ask the user, then retry with confirm_sensitive: true."); e.code = "sensitive_field"; throw e; }221      el.scrollIntoView({ block: "center" }); try { el.focus({ preventScroll: true }); } catch (e3) {}222      if (t === "select") return S.api.select(a);223      if (el.isContentEditable) { el.textContent = a.append ? el.textContent + value : value; el.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: value })); }224      else if (t === "input" || t === "textarea") {225        var ty = (el.type || "text").toLowerCase();226        if (ty === "checkbox" || ty === "radio") throw new Error("use set_checked for checkboxes and radio buttons");227        if (ty === "file") throw new Error("use upload_file for file inputs");228        setValue(el, a.append ? el.value + value : value);229      } else throw new Error("element is not a fillable field (" + t + ")");230      var d = describe(el); return { filled: d, value_length: value.length };231    },232    select: function (a) {233      var el = resolve(a.target); if (el.tagName !== "SELECT") throw new Error("target is not a <select>");234      var want = String(a.value).toLowerCase(), opt = null, opts = el.options;235      for (var i = 0; i < opts.length; i++) if (opts[i].value.toLowerCase() === want || clean(opts[i].textContent).toLowerCase() === want) { opt = opts[i]; break; }236      if (!opt) for (var j = 0; j < opts.length; j++) if (clean(opts[j].textContent).toLowerCase().indexOf(want) !== -1) { opt = opts[j]; break; }237      if (!opt) throw new Error("no option matches \"" + a.value + "\". Options: " + Array.prototype.slice.call(opts, 0, 30).map(function (x) { return clean(x.textContent, 40); }).join(" | "));238      setValue(el, opt.value); return { selected: clean(opt.textContent, 80), value: opt.value, field: describe(el) };239    },240    check: function (a) {241      var el = resolve(a.target), ty = (el.type || "").toLowerCase(), role = el.getAttribute("role");242      var cur = ty === "checkbox" || ty === "radio" ? el.checked : el.getAttribute("aria-checked") === "true";243      if (!(ty === "checkbox" || ty === "radio" || role === "checkbox" || role === "switch" || role === "radio")) throw new Error("target is not a checkbox, radio or switch");244      if (cur !== !!a.checked) { el.scrollIntoView({ block: "center" }); el.click(); }245      return { field: describe(el), changed: cur !== !!a.checked };246    },247    key: function (a) {248      var el = a.target ? resolve(a.target) : (document.activeElement || document.body), w = el.ownerDocument.defaultView;249      var init = { key: a.key, code: a.code || a.key, bubbles: true, cancelable: true, composed: true, shiftKey: !!a.shift, metaKey: !!a.meta, ctrlKey: !!a.ctrl, altKey: !!a.alt };250      var ok = el.dispatchEvent(new w.KeyboardEvent("keydown", init));251      el.dispatchEvent(new w.KeyboardEvent("keypress", init));252      el.dispatchEvent(new w.KeyboardEvent("keyup", init));253      var submitted = false;254      // Synthetic key events never trigger default actions, so Enter-to-submit is done explicitly.255      if (ok && a.key === "Enter" && el.form && el.tagName !== "TEXTAREA") { if (el.form.requestSubmit) el.form.requestSubmit(); else el.form.submit(); submitted = true; }256      if (ok && a.key === "Escape" && el.blur) el.blur();257      return { key: a.key, on: describe(el), default_prevented: !ok, form_submitted: submitted };258    },259    submit: function (a) {260      var el = resolve(a.target, { first: true, allowHidden: true }), f = el.tagName === "FORM" ? el : el.form || el.closest("form");261      if (!f) throw new Error("no form found for the target");262      var before = location.href; if (f.requestSubmit) f.requestSubmit(); else f.submit();263      return { submitted: true, action: f.action || null, url_before: before };264    },265    scroll: function (a) {266      if (a.target) { resolve(a.target, { first: true }).scrollIntoView({ block: a.block || "center" }); }267      else if (a.to === "top") scrollTo(0, 0);268      else if (a.to === "bottom") scrollTo(0, document.documentElement.scrollHeight);269      else scrollBy(a.dx || 0, a.dy != null ? a.dy : Math.round(innerHeight * 0.85));270      return { scroll: { x: Math.round(scrollX), y: Math.round(scrollY), max_y: Math.max(0, document.documentElement.scrollHeight - innerHeight) } };271    },272    check_condition: function (a) {273      var r = { url: location.href, ready_state: document.readyState };274      if (a.selector) { var l = deepAll(a.selector); r.selector_count = l.length; r.selector_visible = l.filter(visible).length; }275      if (a.text != null) r.text_found = (document.body ? document.body.innerText : "").toLowerCase().indexOf(String(a.text).toLowerCase()) !== -1;276      if (a.text_gone != null) r.text_gone = (document.body ? document.body.innerText : "").toLowerCase().indexOf(String(a.text_gone).toLowerCase()) === -1;277      if (a.url_contains != null) r.url_matches = location.href.indexOf(a.url_contains) !== -1;278      r.pending_images = Array.prototype.filter.call(document.images, function (i) { return !i.complete; }).length;279      return r;280    },281    /* ---------- chunked binary transfer (uploads to a file input, downloads through fetch) ---------- */282    buf_append: function (a) { S.bufs[a.id] = (S.bufs[a.id] || "") + a.chunk; return { length: S.bufs[a.id].length }; },283    upload_commit: function (a) {284      var el = resolve(a.target, { allowHidden: true }); if (el.tagName !== "INPUT" || el.type !== "file") throw new Error("target is not a file input");285      var bin = atob(S.bufs[a.id] || ""); delete S.bufs[a.id];286      var bytes = new Uint8Array(bin.length); for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);287      var dt = new DataTransfer(); if (a.append) for (var k = 0; k < el.files.length; k++) dt.items.add(el.files[k]);288      dt.items.add(new File([bytes], a.name, { type: a.type || "application/octet-stream" }));289      el.files = dt.files;290      el.dispatchEvent(new Event("input", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true }));291      return { files: Array.prototype.map.call(el.files, function (f) { return { name: f.name, size: f.size, type: f.type }; }) };292    },293    fetch_start: function (a) {294      var u = new URL(a.url, location.href);295      if (!a.allow_cross_origin && u.origin !== location.origin) throw new Error("cross-origin fetch refused (" + u.origin + "). Navigate to that site first, or pass allow_cross_origin: true.");296      S.jobs[a.id] = { done: false };297      fetch(u.href, { credentials: "include", method: a.method || "GET", headers: a.headers || {}, body: a.body == null ? undefined : a.body }).then(function (res) {298        var meta = { status: res.status, ok: res.ok, url: res.url, type: res.headers.get("content-type") || "", disposition: res.headers.get("content-disposition") || "" };299        if (a.as === "text") return res.text().then(function (t) { S.jobs[a.id] = { done: true, meta: meta, text: t }; });300        return res.arrayBuffer().then(function (buf) {301          var b = new Uint8Array(buf), s = "", CH = 0x8000;302          if (b.length > (a.max_bytes || 52428800)) throw new Error("response is larger than the limit (" + b.length + " bytes)");303          for (var i = 0; i < b.length; i += CH) s += String.fromCharCode.apply(null, b.subarray(i, i + CH));304          S.jobs[a.id] = { done: true, meta: meta, b64: btoa(s), size: b.length };305        });306      }).catch(function (e) { S.jobs[a.id] = { done: true, error: String(e && e.message || e) }; });307      return { started: true };308    },309    job_poll: function (a) {310      var j = S.jobs[a.id]; if (!j) throw new Error("unknown job (the page reloaded?)");311      if (!j.done) return { done: false };312      if (j.error) { delete S.jobs[a.id]; return { done: true, error: j.error }; }313      if (j.text != null) { var t = j.text, max = a.max_chars || 200000; delete S.jobs[a.id]; return { done: true, meta: j.meta, length: t.length, text: t.slice(0, max), truncated: t.length > max }; }314      var off = a.offset || 0, CHUNK = 262144, part = j.b64.slice(off, off + CHUNK), last = off + CHUNK >= j.b64.length;315      if (last) delete S.jobs[a.id];316      return { done: true, meta: j.meta, size: j.size, chunk: part, next_offset: off + part.length, last: last };317    },318    eval: function (a) { var v = (0, eval)(a.code); return { result: v === undefined ? null : v }; },319  };320321  /* Page dialogs block every script, including ours: record them and answer without blocking. */322  try {323    var mode = window.__smcp_dialog_mode || "accept";324    window.alert = function (m) { S.dialogs.push({ type: "alert", message: clean(m, 500) }); };325    window.confirm = function (m) { var r = (window.__smcp_dialog_mode || mode) !== "dismiss"; S.dialogs.push({ type: "confirm", message: clean(m, 500), answered: r }); return r; };326    window.prompt = function (m, d) { S.dialogs.push({ type: "prompt", message: clean(m, 500), answered: null }); return null; };327  } catch (e) {}328  window.__smcp = S;329})();330
Files main
Menu

Theme