Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
# `npm install` instead of `npm ci` — works around npm bug
# https://github.com/npm/cli/issues/4828 where lockfiles
# generated on one OS omit the optional platform-specific
# binaries another OS needs (rollup native bindings, etc).
# Reproducibility is slightly worse but cross-platform CI works.
- run: npm install --no-audit --no-fund
- run: npm run build
- run: npm test

Expand All @@ -34,7 +39,12 @@ jobs:
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
- run: npm ci
# `npm install` instead of `npm ci` — works around npm bug
# https://github.com/npm/cli/issues/4828 where lockfiles
# generated on one OS omit the optional platform-specific
# binaries another OS needs (rollup native bindings, etc).
# Reproducibility is slightly worse but cross-platform CI works.
- run: npm install --no-audit --no-fund
- run: npm run build
- run: npm publish --provenance --access public
env:
Expand Down
14 changes: 13 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
# npm — we don't commit a lockfile for this library because:
# 1. We publish to npm; downstream consumers resolve their own deps
# 2. Cross-platform optional native deps (rollup, esbuild, swc bindings)
# get omitted from the lockfile when generated on one OS, breaking
# install on others — known npm bug:
# https://github.com/npm/cli/issues/4828
node_modules/
package-lock.json
yarn.lock
pnpm-lock.yaml

dist/
*.tsbuildinfo
.DS_Store
*.log
.env
.env.local
10 changes: 6 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@thinkfleet/agentmark",
"version": "0.1.0",
"version": "0.2.0",
"description": "Reference implementation of the agentmark spec — convert any web page into an AI-friendly Markdown representation. Spec: docs/specs/agentmark-v0.1.md",
"type": "commonjs",
"main": "./dist/src/index.js",
Expand Down Expand Up @@ -40,14 +40,16 @@
"tslib": "2.6.2"
},
"peerDependencies": {
"playwright": ">=1.40.0"
"playwright-core": ">=1.40.0"
},
"peerDependenciesMeta": {
"playwright": { "optional": false }
"playwright-core": { "optional": false }
},
"devDependencies": {
"vitest": "3.0.8",
"@types/node": "20.19.9",
"@types/js-yaml": "4.0.9"
"@types/js-yaml": "4.0.9",
"playwright-core": "^1.40.0",
"typescript": "^5.4.0"
}
}
2 changes: 1 addition & 1 deletion src/converter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Page } from 'playwright'
import type { Page } from 'playwright-core'
import { AGENTMARK_VERSION, type Snapshot, type ConversionResult, type ActionDefinition, type MediaDefinition } from './types'
import { EXTRACTOR_SCRIPT, type RawExtraction, type RawAction } from './extractors/dom-extractor'
import { buildBody } from './extractors/body-builder'
Expand Down
30 changes: 30 additions & 0 deletions src/extractors/body-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,36 @@ export function buildBody(segments: BodySegment[]): string {
lastWasBlock = true
break
}
case 'table': {
if (lines.length > 0 && !lastWasBlock) lines.push('')
if (seg.caption) {
lines.push('**' + escapeBodyText(seg.caption) + '**')
lines.push('')
}
// Determine column count from headers OR widest row.
const colCount = seg.headers
? seg.headers.length
: seg.rows.reduce((max, row) => Math.max(max, row.length), 0)
if (colCount > 0) {
const headers =
seg.headers ?? Array.from({ length: colCount }, (_, i) => `Col ${i + 1}`)
// GFM table: pipe-delimited cells with a `---` separator row.
// Pipes inside cells must be escaped (GFM-standard); newlines
// become spaces so each cell stays on one line.
const fmt = (v: string): string =>
escapeBodyText(v).replace(/\|/g, '\\|').replace(/\n/g, ' ')
lines.push('| ' + headers.map(fmt).join(' | ') + ' |')
lines.push('|' + headers.map(() => '---').join('|') + '|')
for (const row of seg.rows) {
const padded = [...row]
while (padded.length < colCount) padded.push('')
lines.push('| ' + padded.slice(0, colCount).map(fmt).join(' | ') + ' |')
}
}
lines.push('')
lastWasBlock = true
break
}
}
}

Expand Down
141 changes: 141 additions & 0 deletions src/extractors/dom-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export type BodySegment =
| { kind: 'list'; ordered: boolean; items: string[] }
| { kind: 'tag'; tag: string; ref?: string }
| { kind: 'separator' }
| { kind: 'table'; headers?: string[]; rows: string[][]; caption?: string }

/**
* The browser-side extractor function, as a string. We pass this to
Expand Down Expand Up @@ -293,6 +294,107 @@ export const EXTRACTOR_SCRIPT = `
segments.push({ kind: 'tag', tag: tag, ref: ref });
}

function cellText(td) {
// Skip nested actions: cells often contain links/buttons whose
// accessible name is captured separately as actions. Text-only
// cell content is what the LLM needs to read tabular data.
return (td.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 512);
}

function emitTable(table) {
// Caption: <caption> or aria-label.
var caption;
var captionEl = table.querySelector(':scope > caption');
if (captionEl && captionEl.textContent) {
caption = captionEl.textContent.trim().slice(0, 256);
} else if (table.getAttribute('aria-label')) {
caption = (table.getAttribute('aria-label') || '').slice(0, 256);
}

// Headers: prefer <thead><tr><th>, fallback to first row if it's
// all <th>, else no headers.
var headers;
var theadRow = table.querySelector(':scope > thead > tr');
if (theadRow) {
var hCells = Array.from(theadRow.querySelectorAll(':scope > th, :scope > td'));
if (hCells.length > 0) {
headers = hCells.map(cellText);
}
}

// Body rows: tbody > tr if present; otherwise direct tr children.
// Cap at 200 rows + 30 cols to keep snapshot size sane on huge tables.
var bodyRows = table.querySelectorAll(':scope > tbody > tr');
if (bodyRows.length === 0) {
bodyRows = table.querySelectorAll(':scope > tr');
}
var rowList = Array.from(bodyRows).slice(0, 200);

// If we don't have explicit headers AND the first row is all <th>,
// promote it to headers and skip it from body rows.
if (!headers && rowList.length > 0) {
var firstRowCells = Array.from(rowList[0].children);
var allTh = firstRowCells.length > 0 &&
firstRowCells.every(function(c) { return c.tagName === 'TH'; });
if (allTh) {
headers = firstRowCells.map(cellText);
rowList = rowList.slice(1);
}
}

var rows = rowList.map(function(tr) {
return Array.from(tr.querySelectorAll(':scope > td, :scope > th'))
.slice(0, 30)
.map(cellText);
}).filter(function(row) {
return row.some(function(c) { return c.length > 0; });
});

if (rows.length === 0 && !headers) return;
var seg = { kind: 'table', rows: rows };
if (headers) seg.headers = headers;
if (caption) seg.caption = caption;
segments.push(seg);
}

function emitIframe(el) {
var src = el.getAttribute('src') || '';
var title = el.getAttribute('title') || el.getAttribute('aria-label') || '';
var id = nextId('frame');
// Treat iframes like media for the binding/registry — the
// host can later resolve them into action targets via Frame
// dispatch in the runner.
media[id] = {
type: 'image', // best fit in current type vocab; spec extension TBD
alt: title || src.slice(0, 200),
preview_url: src || undefined,
};
// Attempt same-origin walk. cross-origin throws SecurityError
// and we fall through to emitting just the tag reference.
var inlineWalked = false;
try {
if (el.contentDocument && el.contentDocument.body) {
var doc = el.contentDocument;
emitTag('IFRAME_OPEN', id);
if (title) emitParagraph('Iframe: ' + title);
else if (src) emitParagraph('Iframe: ' + src);
for (var c of Array.from(doc.body.children)) {
processNode(c);
}
emitTag('IFRAME_CLOSE', id);
inlineWalked = true;
}
}
catch (_e) {
// Cross-origin — script can't pierce. Fall through.
}
if (!inlineWalked) {
emitTag('IFRAME', id);
if (title) emitParagraph('Cross-origin iframe: ' + title + (src ? ' (' + src + ')' : ''));
else if (src) emitParagraph('Cross-origin iframe: ' + src);
}
}

if (cookieBannerActionId) {
emitTag('ACTION', cookieBannerActionId);
}
Expand All @@ -311,6 +413,21 @@ export const EXTRACTOR_SCRIPT = `
// Skip invisible, scripts, styles, etc.
if (['script', 'style', 'noscript', 'template', 'svg'].includes(tag)) return;

// Shadow DOM piercing. Modern web components (Salesforce
// Lightning, MS 365, every-Lit-app, custom elements) hide
// their interactive content behind a shadow root. Without
// piercing them the LLM sees an empty <my-button> with no
// text or actions. We only get OPEN shadow roots — closed
// ones are intentionally inaccessible to scripts (browser
// limitation, not fixable). Process before tag-specific
// handling so shadow content gets an action ID even if the
// host element is also tagged.
if (el.shadowRoot) {
for (const sChild of Array.from(el.shadowRoot.children)) {
processNode(sChild);
}
}

// Headings
if (/^h[1-6]$/.test(tag)) {
emitHeading(el, parseInt(tag[1], 10));
Expand Down Expand Up @@ -400,6 +517,30 @@ export const EXTRACTOR_SCRIPT = `
return;
}

// Iframes. Two strategies:
// 1. Same-origin: walk contentDocument.body inline so the LLM
// sees iframe content as if part of the parent page.
// Many embedded forms / docs / maps fit this case.
// 2. Cross-origin: contentDocument access throws; fall back
// to emitting an IFRAME tag with the src URL so the LLM
// at least knows the iframe is there. Clicking elements
// inside cross-origin iframes requires runner-side
// frame-aware dispatch — a separate change.
if (tag === 'iframe' || tag === 'frame') {
emitIframe(el);
return;
}

// Tables — extract structurally so the LLM sees a real markdown
// table instead of a flat sequence of cell text. This is the
// key win for data-extraction tasks (pricing pages, comparison
// matrices, dashboards). Uses table headers (thead or first row
// of th cells) as column labels when present.
if (tag === 'table') {
emitTable(el);
return;
}

// Images
if (tag === 'img') {
const id = nextId('img');
Expand Down
2 changes: 1 addition & 1 deletion src/wait/challenge-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* In most cases, clicking the checkbox in a non-headless session passes.
*/

import type { Page, Frame } from 'playwright'
import type { Page, Frame } from 'playwright-core'

export interface ChallengeResolverOptions {
/** Max time to wait for JS challenges to self-resolve. Default: 10000ms */
Expand Down
2 changes: 1 addition & 1 deletion src/wait/mutation-observer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Page } from 'playwright'
import type { Page } from 'playwright-core'

export interface ObservedMutation {
timestamp: number
Expand Down
23 changes: 20 additions & 3 deletions src/wait/wait-strategy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Page } from 'playwright'
import type { Page } from 'playwright-core'

export type WaitMode = 'fast' | 'smart' | 'aggressive'

Expand Down Expand Up @@ -30,6 +30,15 @@ export async function waitForPageReady(page: Page, options: WaitOptions = {}): P
// Always wait at least for domcontentloaded
await page.waitForLoadState('domcontentloaded', { timeout: maxWaitMs }).catch(() => {})

// domcontentloaded fires before `document.body` is attached on
// some navigations (especially mid-flight redirects + frames).
// Wait for body to actually exist — otherwise the extractor will
// crash on `document.body.children`. Cheap check, ~10ms typical.
const remainingForBody = Math.max(500, maxWaitMs - (Date.now() - startedAt))
await page
.waitForFunction(() => document.body !== null, { timeout: remainingForBody })
.catch(() => {})

if (mode === 'fast') return

// Smart + Aggressive: wait for network idle, then DOM mutation stability
Expand All @@ -40,8 +49,10 @@ export async function waitForPageReady(page: Page, options: WaitOptions = {}): P
await waitForMutationStability(page, stabilityMs, remainingForMutations)

if (mode === 'aggressive') {
// Trigger lazy loads: scroll to bottom, wait for stability again
await page.evaluate(`window.scrollTo(0, document.body.scrollHeight)`).catch(() => {})
// Trigger lazy loads: scroll to bottom, wait for stability again.
// Body-existence guarded — same null-safety story as the
// mutation observer.
await page.evaluate(`document.body && window.scrollTo(0, document.body.scrollHeight)`).catch(() => {})
const remainingForLazyLoad = Math.max(stabilityMs * 2, maxWaitMs - (Date.now() - startedAt))
await waitForMutationStability(page, stabilityMs, remainingForLazyLoad)
await page.evaluate(`window.scrollTo(0, 0)`).catch(() => {})
Expand All @@ -57,6 +68,12 @@ async function waitForMutationStability(page: Page, stabilityMs: number, maxWait

const script = `
new Promise(resolve => {
// Defensive: if body still isn't here, resolve immediately
// — caller already gave up the maxWait budget.
if (!document.body) {
resolve(false);
return;
}
let lastMutation = Date.now();
const observer = new MutationObserver(() => { lastMutation = Date.now(); });
observer.observe(document.body, {
Expand Down
Loading
Loading