diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cc70bce..7771f48 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
@@ -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:
diff --git a/.gitignore b/.gitignore
index 62ccde4..42637cb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/package.json b/package.json
index 6e69109..745e8bd 100644
--- a/package.json
+++ b/package.json
@@ -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",
@@ -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"
}
}
diff --git a/src/converter.ts b/src/converter.ts
index b1df7f5..fa803c6 100644
--- a/src/converter.ts
+++ b/src/converter.ts
@@ -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'
diff --git a/src/extractors/body-builder.ts b/src/extractors/body-builder.ts
index d72ce1e..0e21ceb 100644
--- a/src/extractors/body-builder.ts
+++ b/src/extractors/body-builder.ts
@@ -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
+ }
}
}
diff --git a/src/extractors/dom-extractor.ts b/src/extractors/dom-extractor.ts
index 5654e05..cc10c30 100644
--- a/src/extractors/dom-extractor.ts
+++ b/src/extractors/dom-extractor.ts
@@ -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
@@ -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:
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 | , fallback to first row if it's
+ // all | , 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 | ,
+ // 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);
}
@@ -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 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));
@@ -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');
diff --git a/src/wait/challenge-resolver.ts b/src/wait/challenge-resolver.ts
index b3ad9b2..c545cdc 100644
--- a/src/wait/challenge-resolver.ts
+++ b/src/wait/challenge-resolver.ts
@@ -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 */
diff --git a/src/wait/mutation-observer.ts b/src/wait/mutation-observer.ts
index c683a3c..fb4f2c0 100644
--- a/src/wait/mutation-observer.ts
+++ b/src/wait/mutation-observer.ts
@@ -1,4 +1,4 @@
-import type { Page } from 'playwright'
+import type { Page } from 'playwright-core'
export interface ObservedMutation {
timestamp: number
diff --git a/src/wait/wait-strategy.ts b/src/wait/wait-strategy.ts
index e077e29..f0a437e 100644
--- a/src/wait/wait-strategy.ts
+++ b/src/wait/wait-strategy.ts
@@ -1,4 +1,4 @@
-import type { Page } from 'playwright'
+import type { Page } from 'playwright-core'
export type WaitMode = 'fast' | 'smart' | 'aggressive'
@@ -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
@@ -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(() => {})
@@ -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, {
diff --git a/test/body-builder.test.ts b/test/body-builder.test.ts
index 9b7b355..0433b81 100644
--- a/test/body-builder.test.ts
+++ b/test/body-builder.test.ts
@@ -70,4 +70,66 @@ describe('buildBody', () => {
const body = buildBody(segs)
expect(body).toBe(body.trim())
})
+
+ describe('tables', () => {
+ it('renders a basic table with headers as GFM markdown', () => {
+ const segs: BodySegment[] = [
+ {
+ kind: 'table',
+ headers: ['Name', 'Price'],
+ rows: [
+ ['Free', '$0/mo'],
+ ['Pro', '$29/mo'],
+ ],
+ },
+ ]
+ const body = buildBody(segs)
+ expect(body).toContain('| Name | Price |')
+ expect(body).toContain('|---|---|')
+ expect(body).toContain('| Free | $0/mo |')
+ expect(body).toContain('| Pro | $29/mo |')
+ })
+
+ it('synthesizes Col N headers when no headers provided', () => {
+ const segs: BodySegment[] = [
+ { kind: 'table', rows: [['a', 'b'], ['c', 'd']] },
+ ]
+ const body = buildBody(segs)
+ expect(body).toContain('| Col 1 | Col 2 |')
+ })
+
+ it('renders an optional caption above the table', () => {
+ const segs: BodySegment[] = [
+ {
+ kind: 'table',
+ caption: 'Pricing tiers',
+ headers: ['Tier'],
+ rows: [['Free']],
+ },
+ ]
+ const body = buildBody(segs)
+ expect(body).toContain('**Pricing tiers**')
+ })
+
+ it('escapes pipe characters and newlines inside cells', () => {
+ const segs: BodySegment[] = [
+ {
+ kind: 'table',
+ headers: ['Note'],
+ rows: [['has|pipe'], ['has\nnewline']],
+ },
+ ]
+ const body = buildBody(segs)
+ expect(body).toContain('| has\\|pipe |')
+ expect(body).toContain('| has newline |')
+ })
+
+ it('pads short rows to the column count', () => {
+ const segs: BodySegment[] = [
+ { kind: 'table', headers: ['a', 'b', 'c'], rows: [['x']] },
+ ]
+ const body = buildBody(segs)
+ expect(body).toContain('| x | | |')
+ })
+ })
})
diff --git a/tsconfig.lib.json b/tsconfig.lib.json
index 73f9d3c..a43309a 100644
--- a/tsconfig.lib.json
+++ b/tsconfig.lib.json
@@ -2,7 +2,7 @@
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
- "lib": ["ES2022"],
+ "lib": ["ES2022", "dom"],
"outDir": "./dist",
"rootDir": ".",
"declaration": true,
|
|---|