initial commit

This commit is contained in:
2026-04-26 12:39:57 -04:00
commit fe1244ad0b
12 changed files with 2020 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules
result

70
AGENTS.md Normal file
View File

@@ -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 <tag-or-name>
```
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=<server>` support for connecting to an existing WebDriver server.
- `--timeout=<ms>` is a top-level option for command waits and defaults to `10000`.
- `--wait-js=<code>` and `--wait-until=<state>` are top-level wait options available to every `glimpse` subcommand.
- `--js=<code>` and `--script=<file>` 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 <url>` - return an agent-friendly page snapshot as JSON
- `exec <url> --js=<code>` or `--script=<file>` - execute JavaScript and return the result
- `screenshot <url> --output=<file>` - save a PNG screenshot
- `reader <url>` - open Firefox Reader View and output readable content as Markdown
- `search <query>` - 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=<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.

268
README.md Normal file
View File

@@ -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 <command> [options]
```
Common options:
- `--no-headless` - show Firefox instead of running headless
- `--url=<server>` - connect to an existing WebDriver server
- `--timeout=<ms>` - maximum wait time in milliseconds for command waits (default: `10000`)
- `--wait-js=<code>` - poll JavaScript until it returns a truthy value before command-specific behavior
- `--wait-until=<state>` - wait for document readiness: `none`, `interactive`, or `complete` (default: `none`)
- `--js=<code>` - execute inline JavaScript after loading the page and before command-specific behavior
- `--script=<file>` - 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=<format>` - output `markdown`, `html`, `text`, or `json` (default: `markdown`)
- `--output=<file>` - 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=<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=<provider>` - search provider: `kagi` (default: `kagi`)
- `--token=<token>` - Kagi token (default: `KAGI_TOKEN`)
- `--no-headless` - show Firefox instead of running headless
- `--url=<server>` - connect to an existing WebDriver server
- `--timeout=<ms>` - 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,<title>Hello</title><h1>Hello</h1>'
node index.js exec 'data:text/html,<title>Hello</title>' --js='return document.title'
node index.js screenshot 'data:text/html,<title>Hello</title>' --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

50
driver.js Normal file
View File

@@ -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<import("selenium-webdriver").WebDriver>}
*/
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();
}

61
flake.lock generated Normal file
View File

@@ -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
}

72
flake.nix Normal file
View File

@@ -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
];
};
}
);
}

555
index.js Executable file
View File

@@ -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 <command> <url> [options]
Commands:
snapshot <url> [options] Return an agent-friendly page snapshot as JSON
exec <url> [options] Execute JavaScript on a page and return the result
screenshot <url> [options] Save a PNG screenshot of a page
reader <url> [options] Extract Firefox Reader View content as Markdown
search <query> [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=<server> Connect to an existing WebDriver server
--timeout=<ms> Maximum wait time in milliseconds (default: 10000)
--wait-js=<code> Poll JS until it returns a truthy value
--wait-until=<state> Wait for readiness: none, interactive, complete (default: none)
--js=<code> Execute inline JS before command logic
--script=<file> Execute JS from a file before command logic
Exec Options:
--js=<code> Return the top-level JS result
--script=<file> Return the top-level script result
Screenshot Options:
--output=<file> Output PNG path (default: screenshot.png)
Reader Options:
--format=<format> Output format: markdown, html, text, json (default: markdown)
--output=<file> Write output to a file
Search Options:
--provider=<provider> Search provider: kagi (default: kagi)
--token=<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 <command> <url> [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);
});

65
kagi.js Normal file
View File

@@ -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();
}
}

4
oxlintrc.json Normal file
View File

@@ -0,0 +1,4 @@
{
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json",
"ignorePatterns": ["node_modules"]
}

548
package-lock.json generated Normal file
View File

@@ -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
}
}
}
}
}

32
package.json Normal file
View File

@@ -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"
}
}

293
test/smoke.js Executable file
View File

@@ -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 <command> <url> \[options\]/);
assert.match(result.stdout, /snapshot <url>/);
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 <command> <url> \[options\]/);
assert.match(result.stdout, /--wait-js=<code>/);
assert.equal(result.stderr, "");
});
test("snapshot returns page metadata and content", ["snapshot"], () => {
const output = expectSuccess([
"snapshot",
dataHtml('<title>Hello</title><h1>Main</h1><a href="/x">X</a><button>Go</button>'),
]);
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('<title>Hello</title><div role="heading" aria-level="2">ARIA</div>'),
]);
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("<title>Hello</title><h1>Old</h1>"),
"--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("<title>Hello</title>"),
"--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("<title>Hello</title>"),
`--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("<title>Hello</title>")]);
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("<title>Hello</title>"),
"--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("<title>Hello</title>"),
"--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("<title>Hello</title>"),
'--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("<title>Hello</title>"),
"--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("<title>Hello</title>"),
'--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("<title>Hello</title>"),
'--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);
});