commit fe1244ad0b2c3b58c52dc73c53145638f937e320 Author: Evan Reichard Date: Sun Apr 26 12:39:57 2026 -0400 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a5f87c7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +result diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..70630b7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,70 @@ +# Project Guidelines + +## Purpose + +This project provides small Firefox/Selenium browser utilities packaged by Nix: + +- `glimpse` - generic page utilities with subcommands, including provider-backed search + +Keep the tools simple, scriptable, and JSON-friendly. + +## Commands + +Prefer relevant checks after changes because the full suite can take time: + +```bash +npm run lint +npm run test:list +node test/smoke.js +``` + +For smoke testing without external network dependencies, use focused tags or scripts such as `npm run test:snapshot`, `npm run test:wait`, `npm run test:errors`, or `node test/smoke.js snapshot js`. Run `npm test` and `nix build .#default --no-link` when the change is broad, touches packaging, or needs full validation. Smoke tests require Firefox and geckodriver on `PATH` and use local `data:` HTML pages. + +Do not attempt a live Kagi test unless `KAGI_TOKEN` is available. + +## CLI Conventions + +- `glimpse` is the generic CLI and should use subcommands. +- Keep search as the `glimpse search` subcommand. +- Do not reintroduce a top-level `--search` flag to `glimpse`. +- Browser execution should be headless by default. +- Use `--no-headless` as the opt-out. +- Keep `--url=` support for connecting to an existing WebDriver server. +- `--timeout=` is a top-level option for command waits and defaults to `10000`. +- `--wait-js=` and `--wait-until=` are top-level wait options available to every `glimpse` subcommand. +- `--js=` and `--script=` are top-level options available to every `glimpse` subcommand and run before command-specific behavior. +- Prefer structured JSON output for objects/arrays. +- CLI errors should be structured JSON on stderr with `ok: false`, stable `error.code`, `error.message`, and `elapsedMs`. + +Current `glimpse` subcommands: + +- `snapshot ` - return an agent-friendly page snapshot as JSON +- `exec --js=` or `--script=` - execute JavaScript and return the result +- `screenshot --output=` - save a PNG screenshot +- `reader ` - open Firefox Reader View and output readable content as Markdown +- `search ` - search with a supported provider and output JSON results + +## Runtime Requirements + +The Nix package must ensure both `firefox` and `geckodriver` are available to installed binaries. The current wrapper does this by prefixing `PATH` for `glimpse`. + +If running outside Nix, document that Firefox and geckodriver must be on `PATH`. + +## Code Style + +- Use ES modules. +- Keep code direct and minimal; avoid abstractions until they are needed. +- Add short Title Case comments above cohesive logic blocks. +- Prefer exact, actionable error messages. +- Keep command-line parsing simple unless a real need for a parser library appears. + +## Nix Notes + +- Update `npmDepsHash` whenever `package-lock.json` changes. +- Keep the source filter excluding `.git`, `.direnv`, `node_modules`, and `result`. +- `packages.default` should contain `glimpse`. +- `apps.default` should run `glimpse`. + +## Kagi Notes + +Kagi search requires `--token=` or `KAGI_TOKEN`. The token is validated by the Kagi provider and passed to Kagi as the `token` query parameter. The agent may not have this token, so live search validation is best-effort only. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9269771 --- /dev/null +++ b/README.md @@ -0,0 +1,268 @@ +# glimpse + +Small Firefox/Selenium browser utilities packaged with Nix. + +The project provides a `glimpse` CLI with subcommands for page automation and provider-backed search. It runs Firefox headless by default. The Nix package wraps the binary so `firefox` and `geckodriver` are available on `PATH`. + +## Requirements + +### Nix Usage + +Nix is the easiest way to run this project. It provides Node.js, Firefox, and geckodriver. + +```bash +nix run .#glimpse -- exec https://example.com --js='return document.title' +``` + +### Local Node Usage + +If running directly with Node.js, install dependencies and make sure `firefox` and `geckodriver` are available on `PATH`. + +```bash +npm install +node index.js exec https://example.com --js='return document.title' +``` + +## Glimpse CLI + +```bash +glimpse [options] +``` + +Common options: + +- `--no-headless` - show Firefox instead of running headless +- `--url=` - connect to an existing WebDriver server +- `--timeout=` - maximum wait time in milliseconds for command waits (default: `10000`) +- `--wait-js=` - poll JavaScript until it returns a truthy value before command-specific behavior +- `--wait-until=` - wait for document readiness: `none`, `interactive`, or `complete` (default: `none`) +- `--js=` - execute inline JavaScript after loading the page and before command-specific behavior +- `--script=` - execute JavaScript from a file after loading the page and before command-specific behavior + +Running `glimpse` with no arguments or with `--help` prints human-readable help. Runtime and validation errors are emitted as structured JSON on stderr with `ok: false`, a stable error `code`, a human-readable `message`, and `elapsedMs`. + +### Snapshot A Page + +Return an agent-friendly structured view of a page. + +```bash +nix run .#glimpse -- snapshot https://example.com +``` + +Wait for asynchronous page state before extracting the snapshot: + +```bash +nix run .#glimpse -- snapshot https://example.com --wait-until=complete --wait-js='return document.body.innerText.length > 0' +``` + +Output: + +```json +{ + "ok": true, + "url": "https://example.com/", + "title": "Example Domain", + "result": { + "text": "Example Domain\nThis domain is for use in illustrative examples...", + "headings": [ + { + "level": 1, + "text": "Example Domain" + } + ], + "links": [ + { + "text": "More information...", + "href": "https://www.iana.org/domains/example" + } + ], + "buttons": [], + "inputs": [], + "forms": [] + } +} +``` + +Snapshot includes page text, headings, links, buttons, inputs, and forms. Heading extraction is best-effort and does not fail the snapshot if heading metadata cannot be extracted. + +### Reader View To Markdown + +Open a page with Firefox Reader View and print the readable article content as Markdown. + +```bash +nix run .#glimpse -- reader https://example.com/article +``` + +Write Markdown to a file: + +```bash +nix run .#glimpse -- reader https://example.com/article --output=article.md +``` + +Other formats are available: + +```bash +nix run .#glimpse -- reader https://example.com/article --format=json +nix run .#glimpse -- reader https://example.com/article --format=html +nix run .#glimpse -- reader https://example.com/article --format=text +``` + +Options: + +- `--format=` - output `markdown`, `html`, `text`, or `json` (default: `markdown`) +- `--output=` - write output to a file + +### Execute JavaScript + +JavaScript execution is available as a top-level option for every `glimpse` command. The script runs after loading the page and before command-specific behavior. + +Use the `exec` command when you only want to print the returned JavaScript value. + +Inline JavaScript: + +```bash +nix run .#glimpse -- exec https://example.com --js='return document.title' +``` + +JavaScript from a file: + +```bash +nix run .#glimpse -- exec https://example.com --script=extract.js +``` + +The script should explicitly return a value: + +```javascript +return { + title: document.title, + links: Array.from(document.querySelectorAll("a")).map((a) => a.href), +}; +``` + +Objects and arrays are printed as formatted JSON. Primitive values are printed directly. + +### Screenshot A Page + +Save a PNG screenshot after loading a page. + +```bash +nix run .#glimpse -- screenshot https://example.com --output=example.png +``` + +Run JavaScript before taking the screenshot: + +```bash +nix run .#glimpse -- screenshot https://example.com --js='document.body.style.zoom = "80%"' --output=example.png +``` + +If `--output` is omitted, the screenshot is saved to `screenshot.png`. + +Output: + +```json +{ + "ok": true, + "result": { + "path": "example.png" + }, + "elapsedMs": 842 +} +``` + +### Search With A Provider + +Search using a supported provider and print a JSON array of results. Currently only Kagi is supported. + +Kagi requires `--token=` or a `KAGI_TOKEN` environment variable. The token is validated by the Kagi provider and sent to Kagi as the `token` query parameter. + +```bash +KAGI_TOKEN=... nix run .#glimpse -- search --provider=kagi "nix flakes selenium webdriver" +``` + +Local usage: + +```bash +KAGI_TOKEN=... ./result/bin/glimpse search "nix flakes selenium webdriver" +``` + +Options: + +- `--provider=` - search provider: `kagi` (default: `kagi`) +- `--token=` - Kagi token (default: `KAGI_TOKEN`) +- `--no-headless` - show Firefox instead of running headless +- `--url=` - connect to an existing WebDriver server +- `--timeout=` - wait time for results before returning `[]` (default: `10000`) + +Output is a JSON array of search results: + +```json +[ + { + "title": "Result title", + "url": "https://example.com", + "description": "Result description" + } +] +``` + +## Build + +Build the default package, which contains `glimpse`: + +```bash +nix build .#default +``` + +Run the built tool: + +```bash +./result/bin/glimpse exec https://example.com --js='return document.title' +KAGI_TOKEN=... ./result/bin/glimpse search "example query" +``` + +## Development + +Enter the dev shell: + +```bash +nix develop +``` + +Run linting: + +```bash +npm run lint +``` + +Run smoke tests. These require Firefox and geckodriver on `PATH` and use local `data:` HTML pages. + +```bash +npm test +``` + +Run focused smoke tests by tag when iterating on a specific area: + +```bash +npm run test:list +npm run test:snapshot +npm run test:wait +npm run test:errors +node test/smoke.js snapshot js +``` + +Useful local commands: + +```bash +node index.js snapshot 'data:text/html,Hello

Hello

' +node index.js exec 'data:text/html,Hello' --js='return document.title' +node index.js screenshot 'data:text/html,Hello' --output=/tmp/page.png +node index.js reader 'https://example.com/article' +``` + +## Project Structure + +- `index.js` - `glimpse` CLI with subcommands, including Firefox Reader View extraction and provider-backed search +- `driver.js` - Firefox WebDriver creation and geckodriver resolution +- `kagi.js` - reusable Kagi search provider implementation +- `flake.nix` - Nix dev shell, package, wrappers, and apps +- `KAGI.md` - Kagi-specific notes diff --git a/driver.js b/driver.js new file mode 100644 index 0000000..5532528 --- /dev/null +++ b/driver.js @@ -0,0 +1,50 @@ +import { execFileSync } from "node:child_process"; +import { Builder } from "selenium-webdriver"; +import firefox from "selenium-webdriver/firefox.js"; + +/** + * Resolve the geckodriver path from $PATH. + * + * @returns {string} + */ +function findGeckodriver() { + try { + return execFileSync("which", ["geckodriver"], { encoding: "utf-8" }).trim(); + } catch { + throw new Error( + "geckodriver not found on $PATH. Install it (e.g. via Nix or your package manager).", + ); + } +} + +/** + * Create a Firefox WebDriver instance. + * + * @param {object} opts + * @param {boolean} [opts.headless=false] - Run Firefox in headless mode. + * @param {string} [opts.existingUrl] - Connect to an already-running + * WebDriver server (e.g. "http://localhost:4444"). + * @returns {Promise} + */ +export async function createDriver({ headless = false, existingUrl } = {}) { + const options = new firefox.Options(); + + // Configure Headless + if (headless) { + options.addArguments("--headless"); + } + + const builder = new Builder().forBrowser("firefox").setFirefoxOptions(options); + + // Connect to Existing Server + if (existingUrl) { + builder.usingServer(existingUrl); + } else { + // Use System Geckodriver - Bypasses the bundled selenium-manager which + // is x86-64 only and doesn't work on aarch64. + const service = new firefox.ServiceBuilder(findGeckodriver()); + builder.setFirefoxService(service); + } + + return builder.build(); +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..d42bc35 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1776734388, + "narHash": "sha256-vl3dkhlE5gzsItuHoEMVe+DlonsK+0836LIRDnm6MXQ=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "10e7ad5bbcb421fe07e3a4ad53a634b0cd57ffac", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..4206703 --- /dev/null +++ b/flake.nix @@ -0,0 +1,72 @@ +{ + description = "Development Environment"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = + { self + , nixpkgs + , flake-utils + , + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + runtimePath = pkgs.lib.makeBinPath [ + pkgs.firefox + pkgs.geckodriver + ]; + source = builtins.path { + path = ./.; + name = "glimpse-source"; + filter = + path: type: + let + name = baseNameOf path; + in + !(builtins.elem name [ + ".direnv" + ".git" + "node_modules" + "result" + ]); + }; + in + { + packages.default = pkgs.buildNpmPackage { + pname = "glimpse"; + version = "1.0.0"; + + src = source; + npmDepsHash = "sha256-jtwQb8TDdvzyeMBN/ubUQWRtBIJuO/QDtKW9ep19N6Q="; + dontNpmBuild = true; + + nativeBuildInputs = [ pkgs.makeWrapper ]; + + postInstall = '' + wrapProgram $out/bin/glimpse \ + --prefix PATH : ${runtimePath} + ''; + }; + + apps.glimpse = { + type = "app"; + program = "${self.packages.${system}.default}/bin/glimpse"; + }; + + apps.default = self.apps.${system}.glimpse; + + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + nodejs_22 + firefox + geckodriver + ]; + }; + } + ); +} diff --git a/index.js b/index.js new file mode 100755 index 0000000..33463a0 --- /dev/null +++ b/index.js @@ -0,0 +1,555 @@ +#!/usr/bin/env node + +import { createDriver } from "./driver.js"; +import { searchKagi } from "./kagi.js"; +import { readFileSync, writeFileSync } from "node:fs"; +import TurndownService from "turndown"; + +const DEFAULT_TIMEOUT_MS = 10000; +const POLL_INTERVAL_MS = 200; +const startTime = Date.now(); +const runContext = {}; + +// Parse CLI Args +const [command, ...args] = process.argv.slice(2); +const headless = !args.includes("--no-headless"); +const existingUrl = getOption("--url"); +const inlineJs = getOption("--js"); +const scriptPath = getOption("--script"); +const waitJs = getOption("--wait-js"); +const waitUntil = getOption("--wait-until") ?? "none"; +let timeoutMs = DEFAULT_TIMEOUT_MS; + +function getOption(name) { + const prefix = `${name}=`; + return args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length); +} + +function getPositionalArgs() { + return args.filter((arg) => !arg.startsWith("--")); +} + +function elapsedMs() { + return Date.now() - startTime; +} + +function printResult(result) { + if (result === undefined) { + return; + } + + const outputValue = + result && typeof result === "object" && !Array.isArray(result) + ? { ...result, elapsedMs: result.elapsedMs ?? elapsedMs() } + : result; + const output = + typeof outputValue === "object" + ? JSON.stringify(outputValue, null, 2) + : String(outputValue); + process.stdout.write(output.endsWith("\n") ? output : `${output}\n`); +} + +class CliError extends Error { + constructor(code, message, details = {}) { + super(message); + this.code = code; + this.details = details; + } +} + +function cliError(code, message, details = {}) { + throw new CliError(code, message, details); +} + +function unknownCommand(name) { + cliError("UNKNOWN_COMMAND", `Unknown command: ${name}`); +} + +function helpText() { + return `Usage: glimpse [options] + +Commands: + snapshot [options] Return an agent-friendly page snapshot as JSON + exec [options] Execute JavaScript on a page and return the result + screenshot [options] Save a PNG screenshot of a page + reader [options] Extract Firefox Reader View content as Markdown + search [options] Search using a supported provider and return JSON results + +Common Options: + --help Show this help + --no-headless Show Firefox instead of running headless + --url= Connect to an existing WebDriver server + --timeout= Maximum wait time in milliseconds (default: 10000) + --wait-js= Poll JS until it returns a truthy value + --wait-until= Wait for readiness: none, interactive, complete (default: none) + --js= Execute inline JS before command logic + --script= Execute JS from a file before command logic + +Exec Options: + --js= Return the top-level JS result + --script= Return the top-level script result + +Screenshot Options: + --output= Output PNG path (default: screenshot.png) + +Reader Options: + --format= Output format: markdown, html, text, json (default: markdown) + --output= Write output to a file + +Search Options: + --provider= Search provider: kagi (default: kagi) + --token= Kagi token (default: KAGI_TOKEN) + +Examples: + glimpse snapshot https://example.com + glimpse exec https://example.com --js="return document.title" + glimpse exec https://example.com --script=extract.js + glimpse screenshot https://example.com --js="document.body.style.zoom = '80%'" --output=example.png + glimpse reader https://example.com/article --script=prepare.js --output=article.md + KAGI_TOKEN=... glimpse search --provider=kagi "node.js browser automation"`; +} + +function printHelp() { + process.stdout.write(`${helpText()}\n`); +} + +function usage() { + cliError("USAGE_ERROR", "Usage: glimpse [options]. Run glimpse --help for details."); +} + +function parseTimeout() { + const value = getOption("--timeout"); + if (value === undefined) { + return DEFAULT_TIMEOUT_MS; + } + + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed <= 0 || String(parsed) !== value) { + cliError("INVALID_OPTION", "--timeout must be a positive integer."); + } + + return parsed; +} + +function validateCommonOptions() { + if (inlineJs && scriptPath) { + cliError("INVALID_OPTION", "Use either --js or --script, not both."); + } + + // Validate Timeout + timeoutMs = parseTimeout(); + + // Validate Wait State + if (!["none", "interactive", "complete"].includes(waitUntil)) { + cliError( + "INVALID_OPTION", + `Unsupported --wait-until value: ${waitUntil}. Expected none, interactive, or complete.`, + ); + } +} + +function getPreludeScriptSource() { + if (scriptPath) { + return readFileSync(scriptPath, "utf-8"); + } + + return inlineJs; +} + +async function withDriver(action) { + let driver; + + try { + driver = await createDriver({ headless, existingUrl }); + } catch (err) { + cliError("BROWSER_START_FAILED", err.message); + } + + try { + return await action(driver); + } finally { + await driver.quit(); + } +} + +async function waitForReadyState(driver) { + if (waitUntil === "none") { + return; + } + + try { + await driver.wait(async () => { + const readyState = await driver.executeScript("return document.readyState"); + return waitUntil === "interactive" + ? ["interactive", "complete"].includes(readyState) + : readyState === "complete"; + }, timeoutMs); + } catch { + cliError( + "WAIT_TIMEOUT", + `Timed out after ${timeoutMs}ms waiting for --wait-until=${waitUntil}`, + ); + } +} + +async function waitForJs(driver) { + if (!waitJs) { + return; + } + + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + let result; + + try { + result = await driver.executeScript(waitJs); + } catch (err) { + cliError("SCRIPT_FAILED", `--wait-js failed: ${err.message}`); + } + + if (result) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + + cliError("WAIT_TIMEOUT", `Timed out after ${timeoutMs}ms waiting for --wait-js`); +} + +async function runPreludeScript(driver) { + const scriptSource = getPreludeScriptSource(); + if (!scriptSource) { + return undefined; + } + + try { + return await driver.executeScript(scriptSource); + } catch (err) { + cliError("SCRIPT_FAILED", `Prelude script failed: ${err.message}`); + } +} + +async function withPage(targetUrl, action) { + runContext.targetUrl = targetUrl; + + return withDriver(async (driver) => { + // Navigate To Page + try { + await driver.get(targetUrl); + runContext.currentUrl = await driver.getCurrentUrl(); + } catch (err) { + cliError("NAVIGATION_FAILED", err.message); + } + + // Wait For Page Readiness + await waitForReadyState(driver); + await waitForJs(driver); + + // Run Prelude Script + const scriptResult = await runPreludeScript(driver); + + return action(driver, scriptResult); + }); +} + +const snapshotScript = ` +const normalize = (value) => String(value || "").replace(/\\s+/g, " ").trim(); +const visibleText = (element) => normalize(element?.innerText || element?.textContent || ""); +const safeValue = (input) => ["password", "hidden"].includes(input.type) ? "" : input.value || ""; +const labelText = (input) => { + const labels = Array.from(input.labels || []).map((label) => visibleText(label)).filter(Boolean); + if (labels.length > 0) return labels.join(" "); + + if (input.id) { + const label = Array.from(document.querySelectorAll("label[for]")) + .find((candidate) => candidate.getAttribute("for") === input.id); + if (label) return visibleText(label); + } + + return ""; +}; +const inputSummary = (input) => ({ + type: input.type || input.tagName.toLowerCase(), + name: input.name || "", + id: input.id || "", + placeholder: input.placeholder || "", + value: safeValue(input), + label: labelText(input), +}); +const collectHeadings = () => { + try { + return Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6,[role='heading']")) + .map((heading) => { + const tagLevel = heading.tagName.match(/^H([1-6])$/i)?.[1]; + const ariaLevel = heading.getAttribute("aria-level"); + const level = Number.parseInt(tagLevel || ariaLevel || "0", 10); + const text = visibleText(heading); + + return text ? { level: level || null, text } : null; + }) + .filter(Boolean); + } catch { + return []; + } +}; + +return { + text: normalize(document.body?.innerText || ""), + headings: collectHeadings(), + links: Array.from(document.querySelectorAll("a[href]")) + .map((link) => ({ text: visibleText(link), href: link.href })) + .filter((link) => link.text || link.href), + buttons: Array.from(document.querySelectorAll("button,input[type='button'],input[type='submit'],input[type='reset'],[role='button']")) + .map((button) => ({ + text: visibleText(button) || button.value || button.getAttribute("aria-label") || "", + type: button.type || button.getAttribute("role") || "button", + name: button.name || "", + id: button.id || "", + })) + .filter((button) => button.text || button.name || button.id), + inputs: Array.from(document.querySelectorAll("input,textarea,select")) + .map(inputSummary), + forms: Array.from(document.querySelectorAll("form")) + .map((form) => ({ + action: form.action || "", + method: (form.method || "get").toLowerCase(), + text: visibleText(form), + inputs: Array.from(form.querySelectorAll("input,textarea,select")).map(inputSummary), + })), +}; +`; + +async function snapshotCommand() { + const [targetUrl] = getPositionalArgs(); + + if (!targetUrl) usage(); + + return withPage(targetUrl, async (driver) => { + // Capture Page Metadata + const [url, title, result] = await Promise.all([ + driver.getCurrentUrl(), + driver.getTitle(), + driver.executeScript(snapshotScript), + ]); + + return { + ok: true, + url, + title, + result, + }; + }); +} + +async function execCommand() { + const [targetUrl] = getPositionalArgs(); + + if (!targetUrl || (!inlineJs && !scriptPath)) usage(); + + return withPage(targetUrl, async (_driver, scriptResult) => scriptResult); +} + +async function screenshotCommand() { + const [targetUrl] = getPositionalArgs(); + const outputPath = getOption("--output") ?? "screenshot.png"; + + if (!targetUrl) usage(); + + return withPage(targetUrl, async (driver) => { + // Save Screenshot + const image = await driver.takeScreenshot(); + writeFileSync(outputPath, image, "base64"); + + return { + ok: true, + result: { + path: outputPath, + }, + }; + }); +} + +function markdownTitle(text) { + return text.replaceAll(/\s+/g, " ").trim(); +} + +function articleToMarkdown(article) { + const turndown = new TurndownService({ + headingStyle: "atx", + codeBlockStyle: "fenced", + }); + + // Convert Reader HTML + const body = turndown.turndown(article.html).trim(); + const parts = []; + + // Add Article Metadata + if (article.title) { + parts.push(`# ${markdownTitle(article.title)}`); + } + if (article.byline) { + parts.push(`_${markdownTitle(article.byline)}_`); + } + if (body) { + parts.push(body); + } + + return `${parts.join("\n\n").trim()}\n`; +} + +function renderReaderOutput(article, format) { + switch (format) { + case "markdown": + return article.markdown; + case "html": + return article.html; + case "text": + return article.text; + case "json": + return article; + default: + cliError( + "INVALID_OPTION", + `Unsupported reader format: ${format}. Expected markdown, html, text, or json.`, + ); + } +} + +async function searchCommand() { + const provider = getOption("--provider") ?? "kagi"; + const query = getPositionalArgs().join(" "); + + if (!query) usage(); + + // Run Provider Search + switch (provider) { + case "kagi": + return searchKagi({ + query, + token: getOption("--token"), + headless, + existingUrl, + timeoutMs, + }); + default: + cliError( + "UNSUPPORTED_SEARCH_PROVIDER", + `Unsupported search provider: ${provider}. Expected kagi.`, + ); + } +} + +async function readerCommand() { + const [targetUrl] = getPositionalArgs(); + const outputPath = getOption("--output"); + const format = getOption("--format") ?? "markdown"; + + if (!targetUrl) usage(); + + return withPage(targetUrl, async (driver) => { + // Capture Final Url + const finalUrl = await driver.getCurrentUrl(); + + // Open Firefox Reader View + const readerUrl = `about:reader?url=${encodeURIComponent(finalUrl)}`; + await driver.get(readerUrl); + + // Wait For Reader Content + let article; + try { + article = await driver.wait(async () => { + return driver.executeScript(` + const content = document.querySelector("#moz-reader-content, .moz-reader-content"); + const error = document.querySelector(".reader-error"); + const text = content?.innerText?.trim() || ""; + + if (text) { + return { + title: document.querySelector("h1.reader-title")?.textContent?.trim() || document.title || "", + byline: document.querySelector(".reader-byline, .reader-credits")?.textContent?.trim() || "", + siteName: document.querySelector(".reader-domain")?.textContent?.trim() || "", + html: content.innerHTML, + text, + readerUrl: location.href, + }; + } + + if (error?.textContent?.trim()) { + throw new Error(error.textContent.trim()); + } + + return null; + `); + }, timeoutMs, `No readable article content found for URL: ${targetUrl}`); + } catch (err) { + cliError("TIMEOUT", err.message); + } + + // Render Output + article.sourceUrl = targetUrl; + article.finalUrl = finalUrl; + article.markdown = articleToMarkdown(article); + const output = renderReaderOutput(article, format); + + if (outputPath) { + writeFileSync( + outputPath, + typeof output === "object" ? JSON.stringify(output, null, 2) : output, + ); + return { + ok: true, + result: { + path: outputPath, + }, + }; + } + + return output; + }); +} + +async function main() { + if (!command || command === "--help") { + printHelp(); + return undefined; + } + + validateCommonOptions(); + + switch (command) { + case "snapshot": + return snapshotCommand(); + case "exec": + return execCommand(); + case "screenshot": + return screenshotCommand(); + case "reader": + return readerCommand(); + case "search": + return searchCommand(); + default: + unknownCommand(command); + } +} + +main() + .then(printResult) + .catch((err) => { + const code = err.code || "COMMAND_FAILED"; + const output = { + ok: false, + error: { + code, + message: err.message, + }, + elapsedMs: elapsedMs(), + }; + + if (runContext.currentUrl || runContext.targetUrl) { + output.url = runContext.currentUrl || runContext.targetUrl; + } + + console.error(JSON.stringify(output, null, 2)); + process.exit(1); + }); diff --git a/kagi.js b/kagi.js new file mode 100644 index 0000000..3962c78 --- /dev/null +++ b/kagi.js @@ -0,0 +1,65 @@ +import { createDriver } from "./driver.js"; + +export class SearchProviderError extends Error { + constructor(code, message) { + super(message); + this.code = code; + } +} + +// Kagi Search Extraction +export const kagiSearchScript = `return Array.from(document.querySelectorAll("div > .__sri-title")) + .map((i) => i.parentElement) + .map((i) => ({ + title: i.children[0].querySelector("a").innerText, + url: i.children[0].querySelector("a").getAttribute("href"), + description: i.querySelector(".__sri-body").innerText, + }));`; + +// Build Kagi Search Url +export function buildKagiSearchUrl(query, token) { + return `https://kagi.com/search?token=${encodeURIComponent(token)}&q=${encodeURIComponent(query)}`; +} + +// Search Kagi +export async function searchKagi({ + query, + token = process.env.KAGI_TOKEN, + headless = true, + existingUrl, + timeoutMs = 5000, + intervalMs = 200, +} = {}) { + if (!query) { + throw new SearchProviderError("QUERY_REQUIRED", "query is required"); + } + + // Validate Kagi Token + if (!token) { + throw new SearchProviderError( + "KAGI_TOKEN_REQUIRED", + "Kagi search requires --token or the KAGI_TOKEN environment variable.", + ); + } + + const driver = await createDriver({ headless, existingUrl }); + + try { + // Navigate To Kagi + await driver.get(buildKagiSearchUrl(query, token)); + + // Poll For Results + let result = []; + const start = Date.now(); + + while (Date.now() - start < timeoutMs) { + result = await driver.executeScript(kagiSearchScript); + if (Array.isArray(result) && result.length > 0) break; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + return result; + } finally { + await driver.quit(); + } +} diff --git a/oxlintrc.json b/oxlintrc.json new file mode 100644 index 0000000..fe06e16 --- /dev/null +++ b/oxlintrc.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json", + "ignorePatterns": ["node_modules"] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2113407 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,548 @@ +{ + "name": "glimpse", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "glimpse", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "oxlint": "^1.61.0", + "selenium-webdriver": "^4.43.0", + "turndown": "^7.2.4" + }, + "bin": { + "glimpse": "index.js" + } + }, + "node_modules/@bazel/runfiles": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@bazel/runfiles/-/runfiles-6.5.0.tgz", + "integrity": "sha512-RzahvqTkfpY2jsDxo8YItPX+/iZ6hbiikw1YhE0bA9EKBR5Og8Pa6FHn9PO9M0zaXRVsr0GFQLKbB/0rzy9SzA==", + "license": "Apache-2.0" + }, + "node_modules/@mixmark-io/domino": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", + "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", + "license": "BSD-2-Clause" + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.61.0.tgz", + "integrity": "sha512-6eZBPgiigK5txqoVgRqxbaxiom4lM8AP8CyKPPvpzKnQ3iFRFOIDc+0AapF+qsUSwjOzr5SGk4SxQDpQhkSJMQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.61.0.tgz", + "integrity": "sha512-CkwLR69MUnyv5wjzebvbbtTSUwqLxM35CXE79bHqDIK+NtKmPEUpStTcLQRZMCo4MP0qRT6TXIQVpK0ZVScnMA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.61.0.tgz", + "integrity": "sha512-8JbefTkbmvqkqWjmQrHke+MdpgT2UghhD/ktM4FOQSpGeCgbMToJEKdl9zwhr/YWTl92i4QI1KiTwVExpcUN8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.61.0.tgz", + "integrity": "sha512-uWpoxDT47hTnDLcdEh5jVbso8rlTTu5o0zuqa9J8E0JAKmIWn7kGFEIB03Pycn2hd2vKxybPGLhjURy/9We5FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.61.0.tgz", + "integrity": "sha512-K/o4hEyW7flfMel0iBVznmMBt7VIMHGdjADocHKpK1DUF9erpWnJ+BSSWd2W0c8K3mPtpph+CuHzRU6CI3l9jQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.61.0.tgz", + "integrity": "sha512-P6040ZkcyweJ0Po9yEFqJCdvZnf3VNCGs1SIHgXDf8AAQNC6ID/heXQs9iSgo2FH7gKaKq32VWc59XZwL34C5Q==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.61.0.tgz", + "integrity": "sha512-bwxrGCzTZkuB+THv2TQ1aTkVEfv5oz8sl+0XZZCpoYzErJD8OhPQOTA0ENPd1zJz8QsVdSzSrS2umKtPq4/JXg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.61.0.tgz", + "integrity": "sha512-vkhb9/wKguMkLlrm3FoJW/Xmdv31GgYAE+x8lxxQ+7HeOxXUySI0q36a3NTVIuQUdLzxCI1zzMGsk1o37FOe3w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.61.0.tgz", + "integrity": "sha512-bl1dQh8LnVqsj6oOQAcxwbuOmNJkwc4p6o//HTBZhNTzJy21TLDwAviMqUFNUxDHkPGpmdKTSN4tWTjLryP8xg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.61.0.tgz", + "integrity": "sha512-QoOX6KB2IiEpyOj/HKqaxi+NQHPnOgNgnr22n9N4ANJCzXkUlj1UmeAbFb4PpqdlHIzvGDM5xZ0OKtcLq9RhiQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.61.0.tgz", + "integrity": "sha512-1TGcTerjY6p152wCof3oKElccq3xHljS/Mucp04gV/4ATpP6nO7YNnp7opEg6SHkv2a57/b4b8Ndm9znJ1/qAw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.61.0.tgz", + "integrity": "sha512-65wXEmZIrX2ADwC8i/qFL4EWLSbeuBpAm3suuX1vu4IQkKd+wLT/HU/BOl84kp91u2SxPkPDyQgu4yrqp8vwVA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.61.0.tgz", + "integrity": "sha512-TVvhgMvor7Qa6COeXxCJ7ENOM+lcAOGsQ0iUdPSCv2hxb9qSHLQ4XF1h50S6RE1gBOJ0WV3rNukg4JJJP1LWRA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.61.0.tgz", + "integrity": "sha512-SjpS5uYuFoDnDdZPwZE59ndF95AsY47R5MliuneTWR1pDm2CxGJaYXbKULI71t5TVfLQUWmrHEGRL9xvuq6dnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.61.0.tgz", + "integrity": "sha512-gGfAeGD4sNJGILZbc/yKcIimO9wQnPMoYp9swAaKeEtwsSQAbU+rsdQze5SBtIP6j0QDzeYd4XSSUCRCF+LIeQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.61.0.tgz", + "integrity": "sha512-OlVT0LrG/ct33EVtWRyR+B/othwmDWeRxfi13wUdPeb3lAT5TgTcFDcfLfarZtzB4W1nWF/zICMgYdkggX2WmQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.61.0.tgz", + "integrity": "sha512-vI//NZPJk6DToiovPtaiwD4iQ7kO1r5ReWQD0sOOyKRtP3E2f6jxin4uvwi3OvDzHA2EFfd7DcZl5dtkQh7g1w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.61.0.tgz", + "integrity": "sha512-0ySj4/4zd2XjePs3XAQq7IigIstN4LPQZgCyigX5/ERMLjdWAJfnxcTsrtxZxuij8guJW8foXuHmhGxW0H4dDA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.61.0.tgz", + "integrity": "sha512-0xgSiyeqDLDZxXoe9CVJrOx3TUVsfyoOY7cNi03JbItNcC9WCZqrSNdrAbHONxhSPaVh/lzfnDcON1RqSUMhHw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/oxlint": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.61.0.tgz", + "integrity": "sha512-ZC0ALuhDZ6ivOFG+sy0D0pEDN49EvsId98zVlmYdkcXHsEM14m/qTNUEsUpiFiCVbpIxYtVBmmLE87nsbUHohQ==", + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.61.0", + "@oxlint/binding-android-arm64": "1.61.0", + "@oxlint/binding-darwin-arm64": "1.61.0", + "@oxlint/binding-darwin-x64": "1.61.0", + "@oxlint/binding-freebsd-x64": "1.61.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.61.0", + "@oxlint/binding-linux-arm-musleabihf": "1.61.0", + "@oxlint/binding-linux-arm64-gnu": "1.61.0", + "@oxlint/binding-linux-arm64-musl": "1.61.0", + "@oxlint/binding-linux-ppc64-gnu": "1.61.0", + "@oxlint/binding-linux-riscv64-gnu": "1.61.0", + "@oxlint/binding-linux-riscv64-musl": "1.61.0", + "@oxlint/binding-linux-s390x-gnu": "1.61.0", + "@oxlint/binding-linux-x64-gnu": "1.61.0", + "@oxlint/binding-linux-x64-musl": "1.61.0", + "@oxlint/binding-openharmony-arm64": "1.61.0", + "@oxlint/binding-win32-arm64-msvc": "1.61.0", + "@oxlint/binding-win32-ia32-msvc": "1.61.0", + "@oxlint/binding-win32-x64-msvc": "1.61.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.18.0" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + } + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/selenium-webdriver": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.43.0.tgz", + "integrity": "sha512-dV4zBTT37or3Z3/8uD6rS8zvd4ZxPuG4EJVlqYIbZCGZCYttZm7xb9rlFLSk4rrsQHAeDYvudl7cquo0vWpHjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/SeleniumHQ" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/selenium" + } + ], + "license": "Apache-2.0", + "dependencies": { + "@bazel/runfiles": "^6.5.0", + "jszip": "^3.10.1", + "tmp": "^0.2.5", + "ws": "^8.20.0" + }, + "engines": { + "node": ">= 20.0.0" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/turndown": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz", + "integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==", + "license": "MIT", + "dependencies": { + "@mixmark-io/domino": "^2.2.0" + }, + "engines": { + "node": ">=18", + "npm": ">=9" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f272030 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "glimpse", + "version": "1.0.0", + "description": "", + "type": "module", + "main": "index.js", + "bin": { + "glimpse": "./index.js" + }, + "scripts": { + "lint": "oxlint --ignore-pattern node_modules --ignore-pattern .direnv .", + "start": "node index.js", + "test": "node test/smoke.js", + "test:smoke": "node test/smoke.js", + "test:list": "node test/smoke.js --list", + "test:help": "node test/smoke.js help", + "test:snapshot": "node test/smoke.js snapshot", + "test:exec": "node test/smoke.js exec", + "test:screenshot": "node test/smoke.js screenshot", + "test:wait": "node test/smoke.js wait", + "test:errors": "node test/smoke.js errors", + "test:js": "node test/smoke.js js" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "oxlint": "^1.61.0", + "selenium-webdriver": "^4.43.0", + "turndown": "^7.2.4" + } +} diff --git a/test/smoke.js b/test/smoke.js new file mode 100755 index 0000000..fc41f35 --- /dev/null +++ b/test/smoke.js @@ -0,0 +1,293 @@ +#!/usr/bin/env node + +import { mkdtempSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import assert from "node:assert/strict"; + +const cliPath = new URL("../index.js", import.meta.url).pathname; +const tempDir = mkdtempSync(join(tmpdir(), "glimpse-smoke-")); +const filters = process.argv.slice(2).filter((arg) => arg !== "--list"); +const shouldList = process.argv.includes("--list"); +const tests = []; + +function test(name, tags, fn) { + tests.push({ name, tags, fn }); +} + +function dataHtml(html) { + return `data:text/html,${html}`; +} + +function runCli(args, options = {}) { + return spawnSync(process.execPath, [cliPath, ...args], { + encoding: "utf-8", + env: options.env ?? process.env, + timeout: 30000, + }); +} + +function parseJson(text) { + try { + return JSON.parse(text); + } catch (err) { + throw new Error(`Failed to parse JSON: ${err.message}\n${text}`); + } +} + +function expectSuccess(args) { + const result = runCli(args); + assert.equal(result.status, 0, result.stderr || result.stdout); + return parseJson(result.stdout); +} + +function expectFailure(args) { + const result = runCli(args); + assert.notEqual(result.status, 0, result.stdout || result.stderr); + return parseJson(result.stderr); +} + +function matchesFilters(entry) { + if (filters.length === 0) { + return true; + } + + return filters.some((filter) => { + const normalized = filter.toLowerCase(); + return ( + entry.name.toLowerCase().includes(normalized) || + entry.tags.includes(normalized) + ); + }); +} + +test("no args prints help", ["help", "cli"], () => { + const result = runCli([]); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /Usage: glimpse \[options\]/); + assert.match(result.stdout, /snapshot /); + assert.equal(result.stderr, ""); +}); + +test("help flag prints help", ["help", "cli"], () => { + const result = runCli(["--help"]); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /Usage: glimpse \[options\]/); + assert.match(result.stdout, /--wait-js=/); + assert.equal(result.stderr, ""); +}); + +test("snapshot returns page metadata and content", ["snapshot"], () => { + const output = expectSuccess([ + "snapshot", + dataHtml('Hello

Main

X'), + ]); + + assert.equal(output.ok, true); + assert.equal(output.title, "Hello"); + assert.equal(typeof output.elapsedMs, "number"); + assert.deepEqual(output.result.headings, [{ level: 1, text: "Main" }]); + assert.deepEqual(output.result.links, [{ href: "/x", text: "X" }]); + assert.equal(output.result.buttons[0].text, "Go"); + assert.match(output.result.text, /Main/); +}); + +test("snapshot extracts aria headings", ["snapshot"], () => { + const output = expectSuccess([ + "snapshot", + dataHtml('Hello
ARIA
'), + ]); + + assert.equal(output.ok, true); + assert.deepEqual(output.result.headings, [{ level: 2, text: "ARIA" }]); +}); + +test("snapshot runs top-level javascript before extraction", ["snapshot", "js"], () => { + const output = expectSuccess([ + "snapshot", + dataHtml("Hello

Old

"), + "--js=document.querySelector('h1').textContent = 'New'", + ]); + + assert.equal(output.ok, true); + assert.deepEqual(output.result.headings, [{ level: 1, text: "New" }]); + assert.equal(output.result.text, "New"); +}); + +test("exec returns javascript result", ["exec", "js"], () => { + const result = runCli([ + "exec", + dataHtml("Hello"), + "--js=return document.title", + ]); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(result.stdout.trim(), "Hello"); +}); + +test("screenshot returns standard success envelope and writes file", ["screenshot"], () => { + const outputPath = join(tempDir, "page.png"); + const output = expectSuccess([ + "screenshot", + dataHtml("Hello"), + `--output=${outputPath}`, + ]); + + assert.equal(output.ok, true); + assert.equal(output.result.path, outputPath); + assert.equal(typeof output.elapsedMs, "number"); + assert.equal(existsSync(outputPath), true); +}); + +test("search validates kagi token in provider", ["search", "errors"], () => { + const env = { ...process.env }; + delete env.KAGI_TOKEN; + const result = runCli(["search", "example query"], { env }); + const output = parseJson(result.stderr); + + assert.notEqual(result.status, 0, result.stdout || result.stderr); + assert.equal(output.ok, false); + assert.equal(output.error.code, "KAGI_TOKEN_REQUIRED"); + assert.match(output.error.message, /Kagi search requires/); + assert.equal(typeof output.elapsedMs, "number"); +}); + +test("unknown command returns structured error", ["errors", "cli"], () => { + const output = expectFailure(["nope", dataHtml("Hello")]); + + assert.equal(output.ok, false); + assert.equal(output.error.code, "UNKNOWN_COMMAND"); + assert.match(output.error.message, /Unknown command: nope/); + assert.equal(typeof output.elapsedMs, "number"); +}); + +test("invalid timeout returns invalid option before browser startup", ["errors", "timeout"], () => { + const output = expectFailure([ + "snapshot", + dataHtml("Hello"), + "--timeout=abc", + ]); + + assert.equal(output.ok, false); + assert.equal(output.error.code, "INVALID_OPTION"); + assert.match(output.error.message, /--timeout must be a positive integer/); + assert.equal(typeof output.elapsedMs, "number"); +}); + +test("invalid wait-until returns invalid option", ["errors", "wait"], () => { + const output = expectFailure([ + "snapshot", + dataHtml("Hello"), + "--wait-until=loaded", + ]); + + assert.equal(output.ok, false); + assert.equal(output.error.code, "INVALID_OPTION"); + assert.match(output.error.message, /Unsupported --wait-until value: loaded/); +}); + +test("wait-js succeeds when condition is true", ["wait"], () => { + const output = expectSuccess([ + "snapshot", + dataHtml("Hello"), + '--wait-js=return document.title === "Hello"', + ]); + + assert.equal(output.ok, true); + assert.equal(output.title, "Hello"); +}); + +test("wait-js timeout returns wait timeout", ["wait", "errors"], () => { + const output = expectFailure([ + "snapshot", + dataHtml("Hello"), + "--wait-js=return false", + "--timeout=1", + ]); + + assert.equal(output.ok, false); + assert.equal(output.error.code, "WAIT_TIMEOUT"); + assert.match(output.error.message, /waiting for --wait-js/); + assert.equal(typeof output.elapsedMs, "number"); + assert.match(output.url, /^data:text\/html,/); +}); + +test("wait-js exception returns script failed", ["wait", "errors", "js"], () => { + const output = expectFailure([ + "snapshot", + dataHtml("Hello"), + '--wait-js=throw new Error("boom")', + ]); + + assert.equal(output.ok, false); + assert.equal(output.error.code, "SCRIPT_FAILED"); + assert.match(output.error.message, /--wait-js failed/); + assert.match(output.error.message, /boom/); +}); + +test("top-level javascript exception returns script failed", ["errors", "js"], () => { + const output = expectFailure([ + "snapshot", + dataHtml("Hello"), + '--js=throw new Error("boom")', + ]); + + assert.equal(output.ok, false); + assert.equal(output.error.code, "SCRIPT_FAILED"); + assert.match(output.error.message, /Prelude script failed/); + assert.match(output.error.message, /boom/); +}); + +function listTests() { + const tags = [...new Set(tests.flatMap((entry) => entry.tags))].sort(); + console.log(`Tags: ${tags.join(", ")}`); + for (const entry of tests) { + console.log(`${entry.name} [${entry.tags.join(", ")}]`); + } +} + +async function main() { + if (shouldList) { + listTests(); + return; + } + + const selectedTests = tests.filter(matchesFilters); + if (selectedTests.length === 0) { + console.error(`No tests matched: ${filters.join(", ")}`); + process.exit(1); + } + + let failed = 0; + + // Run Tests + for (const { name, fn } of selectedTests) { + try { + await fn(); + console.log(`ok - ${name}`); + } catch (err) { + failed += 1; + console.error(`not ok - ${name}`); + console.error(err.stack || err.message); + } + } + + // Clean Temporary Files + rmSync(tempDir, { recursive: true, force: true }); + + if (failed > 0) { + console.error(`${failed} test(s) failed`); + process.exit(1); + } + + console.log(`${selectedTests.length} test(s) passed`); +} + +main().catch((err) => { + rmSync(tempDir, { recursive: true, force: true }); + console.error(err.stack || err.message); + process.exit(1); +});