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,