SPB Git

spb/zyquo-atlas Public License

The AI-native macOS web browser — every surface, intelligent.

Swift 75.2% JavaScript 22% Shell 2% Makefile 0.9%
8.0 KB · 226 lines javascript
Raw Blame History
1//2//  AtlasExtractor.js3//  Zyquo Atlas4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  Content-extraction driver injected into an isolated WKContentWorld alongside9//  Mozilla Readability.js + Readability-readerable.js. Turns the live page into10//  clean, model-ready markdown with metadata and the user's selection, using11//  the fallback ladder from docs/AI-BROWSER-RESEARCH.md §2:12//    1. Readability (article) → markdown13//    2. visible structured-text walk (app pages)14//    3. title + url + meta description15//  SECURITY: only visible, rendered content is emitted — hidden/off-screen and16//  aria-hidden nodes are dropped so injected instructions in invisible text17//  never reach the model. Every entry point returns a value (never undefined)18//  so the Swift bridge never crashes.19//2021(function () {22  "use strict";23  if (window.__zyquoAtlas) return;2425  const MAX_BYTES_DEFAULT = 600000;2627  function meta(name) {28    const el = document.querySelector(29      'meta[property="' + name + '"],meta[name="' + name + '"]'30    );31    return el ? el.getAttribute("content") : null;32  }3334  function canonicalURL() {35    const l = document.querySelector('link[rel="canonical"]');36    return (l && l.href) || location.href;37  }3839  function faviconURL() {40    const l = document.querySelector('link[rel~="icon"],link[rel="apple-touch-icon"]');41    try {42      return l ? new URL(l.getAttribute("href"), location.href).href43               : new URL("/favicon.ico", location.origin).href;44    } catch (_) { return null; }45  }4647  // Is a node visible to the user (drops the injection surface)?48  function isVisible(el) {49    if (!el || el.nodeType !== 1) return false;50    if (el.getAttribute && el.getAttribute("aria-hidden") === "true") return false;51    const s = window.getComputedStyle(el);52    if (!s || s.display === "none" || s.visibility === "hidden" || parseFloat(s.opacity) === 0) {53      return false;54    }55    const r = el.getBoundingClientRect();56    if (r.width === 0 && r.height === 0) return false;57    return true;58  }5960  // Walk visible DOM emitting lightweight markdown (headings, lists, links,61  // code, paragraphs). Used both to render a Readability article DOM and as the62  // app-page fallback.63  function toMarkdown(root) {64    const out = [];65    const SKIP = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "SVG", "CANVAS", "IFRAME"]);66    function walk(node, listDepth) {67      for (const child of node.childNodes) {68        if (child.nodeType === 3) {69          const t = child.textContent.replace(/\s+/g, " ");70          if (t.trim()) out.push(t);71          continue;72        }73        if (child.nodeType !== 1) continue;74        const tag = child.tagName;75        if (SKIP.has(tag)) continue;76        if (!isVisible(child)) continue;77        if (/^H[1-6]$/.test(tag)) {78          const level = "#".repeat(parseInt(tag[1], 10));79          out.push("\n" + level + " " + child.textContent.trim() + "\n");80        } else if (tag === "P") {81          out.push("\n" + child.textContent.trim() + "\n");82        } else if (tag === "LI") {83          out.push("\n" + "  ".repeat(listDepth) + "- " + child.textContent.trim());84        } else if (tag === "PRE") {85          out.push("\n```\n" + child.textContent.replace(/\n+$/, "") + "\n```\n");86        } else if (tag === "A" && child.getAttribute("href")) {87          const txt = child.textContent.trim();88          if (txt) out.push("[" + txt + "](" + child.href + ")");89        } else if (tag === "BR") {90          out.push("\n");91        } else if (tag === "UL" || tag === "OL") {92          walk(child, listDepth + 1);93          out.push("\n");94        } else {95          walk(child, listDepth);96        }97      }98    }99    walk(root, 0);100    return out.join(" ").replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim();101  }102103  // Index of heading offsets in the produced markdown for chunking + citations.104  function headingIndex(markdown) {105    const headings = [];106    const re = /^(#{1,6})\s+(.*)$/gm;107    let m;108    while ((m = re.exec(markdown)) !== null) {109      headings.push({ level: m[1].length, title: m[2].trim(), offset: m.index });110    }111    return headings;112  }113114  function currentSelection() {115    const sel = window.getSelection();116    const text = sel ? String(sel).trim() : "";117    if (!text) return null;118    let context = null;119    try {120      const node = sel.anchorNode && sel.anchorNode.parentElement;121      const block = node && node.closest("p,li,section,article,div");122      if (block) context = block.textContent.replace(/\s+/g, " ").trim().slice(0, 800);123    } catch (_) {}124    return { text: text, context: context };125  }126127  function truncateAtBoundary(s, maxBytes) {128    if (s.length <= maxBytes) return s;129    const cut = s.lastIndexOf("\n", maxBytes);130    return s.slice(0, cut > maxBytes * 0.5 ? cut : maxBytes) + "\n\n…[truncated]";131  }132133  function extract(mode, maxBytes) {134    maxBytes = maxBytes || MAX_BYTES_DEFAULT;135    try {136      let markdown = "";137      let quality = "reader";138      let title = document.title;139      let byline = null;140      let published = null;141      let readerable = false;142143      try {144        readerable = typeof isProbablyReaderable === "function" &&145                     isProbablyReaderable(document);146      } catch (_) {}147148      if (mode !== "rawText" && readerable && typeof Readability === "function") {149        try {150          const clone = document.cloneNode(true); // never mutate the live page151          const article = new Readability(clone).parse();152          if (article) {153            title = article.title || title;154            byline = article.byline || null;155            published = article.publishedTime || null;156            const holder = document.createElement("div");157            holder.innerHTML = article.content || "";158            markdown = toMarkdown(holder);159          }160        } catch (_) {}161      }162163      if (!markdown || markdown.length < 400) {           // fallback ladder164        markdown = toMarkdown(document.body || document.documentElement);165        quality = readerable ? "reader" : "rawText";166      }167      if (!markdown) {                                     // last resort168        markdown = (document.body ? document.body.innerText : "").trim();169        quality = "rawText";170      }171172      markdown = truncateAtBoundary(markdown, maxBytes);173174      return {175        ok: true,176        context: {177          url: location.href,178          canonical: canonicalURL(),179          title: (title || location.host || "").trim(),180          byline: byline,181          description: meta("description") || meta("og:description"),182          siteName: meta("og:site_name"),183          lang: document.documentElement.lang || null,184          published: published,185          favicon: faviconURL(),186          markdown: markdown,187          quality: quality,188          headings: headingIndex(markdown),189          selection: currentSelection(),190          wordCount: markdown ? markdown.split(/\s+/).length : 0,191          truncated: markdown.indexOf("…[truncated]") !== -1192        }193      };194    } catch (e) {195      return { ok: false, error: String((e && e.stack) || e) };196    }197  }198199  window.__zyquoAtlas = { extract: extract };200201  // Selection observer → native floating toolbar. Debounced; posts the trimmed202  // selection text + its viewport rect, or an empty text on collapse.203  var selTimer = null, lastSel = "";204  function reportSelection() {205    try {206      var sel = window.getSelection();207      var text = sel ? String(sel).trim() : "";208      if (text === lastSel) return;209      lastSel = text;210      var payload = { text: text };211      if (text && sel.rangeCount) {212        var r = sel.getRangeAt(0).getBoundingClientRect();213        payload.x = r.x; payload.y = r.y; payload.w = r.width; payload.h = r.height;214      }215      if (window.webkit && window.webkit.messageHandlers &&216          window.webkit.messageHandlers.atlasSelection) {217        window.webkit.messageHandlers.atlasSelection.postMessage(payload);218      }219    } catch (e) { /* ignore */ }220  }221  document.addEventListener("selectionchange", function () {222    if (selTimer) clearTimeout(selTimer);223    selTimer = setTimeout(reportSelection, 220);224  });225})();226