diff --git a/packages/core/src/index.d.ts b/packages/core/src/index.d.ts index 5978242..3c1fa97 100644 --- a/packages/core/src/index.d.ts +++ b/packages/core/src/index.d.ts @@ -2,6 +2,39 @@ import createGoogleClient from '@microlink/google' type GoogleClient = ReturnType +/** Mirrors `@microlink/mql` ActionLocator / Action (kept local to avoid ESM/CJS type import issues). */ +type ActionLocator = + | { selector: string } + | { role: string; name?: string } + | { text: string } + | { label: string } + | { placeholder: string } + | { testId: string } + | { alt: string } + +export type Action = + | { type: 'inject'; styles?: string[]; scripts?: string[]; modules?: string[] } + | ({ type: 'click' } & ActionLocator) + | ({ + type: 'wait' + timeout?: string | number + text?: string + request?: string + visible?: boolean + hidden?: boolean + } & Partial) + | ({ type: 'scroll'; x?: number; y?: number } & Partial) + | ({ type: 'fill'; value: string } & ActionLocator) + | { type: 'evaluate'; expression: string } + | ({ type: 'screenshot'; fullPage?: boolean } & Partial) + | { + type: 'pdf' + format?: string + scale?: number + margin?: string | Record + printBackground?: boolean + } + /** * Transport & top-level API query params. Unknown keys fall through * to the API query string, so an index signature is provided. @@ -10,6 +43,7 @@ interface Options { apiKey?: string endpoint?: string headers?: Record + actions?: Action[] adblock?: boolean animations?: boolean audio?: boolean diff --git a/packages/core/test/index.test-d.ts b/packages/core/test/index.test-d.ts index b987a18..7cc2273 100644 --- a/packages/core/test/index.test-d.ts +++ b/packages/core/test/index.test-d.ts @@ -21,11 +21,19 @@ expectType>(client.audios('https://example.com')) async function assertions (): Promise { const screenshot = await client.screenshot('https://example.com', { fullPage: true, - device: 'iPhone 11' + device: 'iPhone 11', + actions: [ + { type: 'fill', label: 'Email', value: 'user@example.com' }, + { type: 'click', role: 'button', name: 'Sign in' }, + { type: 'wait', text: 'Dashboard' } + ] }) expectType(screenshot.url) - const pdf = await client.pdf('https://example.com', { format: 'A4' }) + const pdf = await client.pdf('https://example.com', { + format: 'A4', + actions: [{ type: 'scroll', selector: '#pricing' }] + }) expectType(pdf.url) const logo = await client.logo('https://example.com', { square: true }) diff --git a/packages/mcp/src/schemas.js b/packages/mcp/src/schemas.js index 06d749f..2d3bfc6 100644 --- a/packages/mcp/src/schemas.js +++ b/packages/mcp/src/schemas.js @@ -228,10 +228,88 @@ const fullShape = { ping: toggledObjectSchema.optional() } +// Shared locator fields for interaction actions (exactly one strategy preferred; +// the API enforces mutual exclusivity — agents may omit and use CSS `selector`). +const locatorFields = { + selector: z.string().min(1).optional(), + role: z.string().min(1).optional(), + name: z.string().min(1).optional(), + text: z.string().min(1).optional(), + label: z.string().min(1).optional(), + placeholder: z.string().min(1).optional(), + testId: z.string().min(1).optional(), + alt: z.string().min(1).optional() +} + +const actionSchema = z.discriminatedUnion('type', [ + z + .object({ + type: z.literal('inject'), + styles: stringOrStringArraySchema.optional(), + scripts: stringOrStringArraySchema.optional(), + modules: stringOrStringArraySchema.optional() + }) + .strict(), + z + .object({ + type: z.literal('click'), + ...locatorFields + }) + .strict(), + z + .object({ + type: z.literal('wait'), + timeout: stringOrNumberSchema.optional(), + request: z.string().min(1).optional(), + visible: booleanSchema.optional(), + hidden: booleanSchema.optional(), + ...locatorFields + }) + .strict(), + z + .object({ + type: z.literal('scroll'), + x: z.number().optional(), + y: z.number().optional(), + ...locatorFields + }) + .strict(), + z + .object({ + type: z.literal('fill'), + value: z.string(), + ...locatorFields + }) + .strict(), + z + .object({ + type: z.literal('evaluate'), + expression: z.string().min(1) + }) + .strict(), + z + .object({ + type: z.literal('screenshot'), + fullPage: booleanSchema.optional(), + ...locatorFields + }) + .strict(), + z + .object({ + type: z.literal('pdf'), + format: z.string().min(1).optional(), + scale: z.number().optional(), + margin: pdfMarginSchema.optional(), + printBackground: booleanSchema.optional() + }) + .strict() +]) + // Shared Microlink API query parameters (see microlink.io/docs/api/parameters). // Product tools layer their own fields on top; these apply to any URL fetch. // `data` is separate: content/collection helpers overwrite it with their field rule. const browserSchema = { + actions: z.array(actionSchema).min(1).optional(), adblock: booleanSchema.optional(), animations: booleanSchema.optional(), cacheKey: z.string().min(1).optional(), diff --git a/packages/mcp/src/tools/function.js b/packages/mcp/src/tools/function.js index ecbe53c..8fd135e 100644 --- a/packages/mcp/src/tools/function.js +++ b/packages/mcp/src/tools/function.js @@ -8,7 +8,8 @@ export function fn (server) { [ 'Run a JavaScript function against any public URL inside Microlink’s server-side browser sandbox.', 'Pass `code` as the function source (e.g. "async ({ page }) => page.title()"); it receives `{ page, response, ...args }` and its return value comes back in `value`.', - 'Combine with browser options such as `javascript`, `waitUntil`, `waitForSelector`, `click`, `scroll`, `headers`, and `proxy`.', + 'Prefer `actions` for ordered interactions before the function runs; legacy `waitForSelector`, `click`, and `scroll` still work.', + 'Also combine with `javascript`, `waitUntil`, `headers`, and `proxy`.', 'Also returns `isFulfilled`, `profiling`, and `logging`. Mirrors the `microlink.function(url, code)` library method.' ].join(' '), functionInputSchema, diff --git a/packages/mcp/src/tools/html.js b/packages/mcp/src/tools/html.js index 55f1d8b..c4370c9 100644 --- a/packages/mcp/src/tools/html.js +++ b/packages/mcp/src/tools/html.js @@ -8,7 +8,8 @@ export function html (server) { [ 'Extract the HTML content of any public URL via Microlink.', 'Returns the page HTML as a string. Pass `selector` to scope it to part of the page.', - 'Combine with browser options such as `javascript`, `waitUntil`, `waitForSelector`, `headers`, and `proxy`.', + 'Prefer `actions` for ordered interactions (click, wait, fill, …); legacy `waitForSelector` still works.', + 'Also combine with `javascript`, `waitUntil`, `headers`, and `proxy`.', 'Mirrors the `microlink.html(url)` library method.' ].join(' '), htmlInputSchema, diff --git a/packages/mcp/src/tools/pdf.js b/packages/mcp/src/tools/pdf.js index 8b07222..30517c0 100644 --- a/packages/mcp/src/tools/pdf.js +++ b/packages/mcp/src/tools/pdf.js @@ -9,7 +9,8 @@ export function pdf (server) { 'Generate a PDF of any public URL via Microlink and return the asset object (`url`, `type`, `size`, ...) with a permanent CDN URL.', 'Pass `pdf: true` for defaults or `pdf: { ... }` for options; `pdf: {}` is treated as `true`.', 'Use `pdf.format` ("A4" default, "Letter", "Legal", ...), `pdf.landscape`, `pdf.margin` (string or top/bottom/left/right object), `pdf.scale` (0.1-2.0), `pdf.pageRanges` ("1-5"), or `pdf.width`/`pdf.height`.', - 'Combine with `styles`, `scripts`, `modules`, `mediaType`, `waitForSelector`, and `waitUntil` for full control.', + 'Prefer `actions` (ordered browser steps: inject, click, wait, scroll, fill, pdf, …) with semantic locators or CSS `selector`.', + 'Legacy `styles`, `scripts`, `modules`, `waitForSelector`, and `waitUntil` still work; also `mediaType`.', 'Mirrors the `microlink.pdf(url, options)` library method.' ].join(' '), pdfInputSchema, diff --git a/packages/mcp/src/tools/screenshot.js b/packages/mcp/src/tools/screenshot.js index 05c15a6..3d64a5a 100644 --- a/packages/mcp/src/tools/screenshot.js +++ b/packages/mcp/src/tools/screenshot.js @@ -11,7 +11,8 @@ export function screenshot (server) { 'Use `screenshot.fullPage` to capture the whole scrollable page.', 'Use `screenshot.animated` to capture an animated screenshot (GIF/MP4) instead of a still image.', 'Use `screenshot.element` (CSS selector) to capture a specific element, `screenshot.type` for format ("jpeg", default "png"), `screenshot.omitBackground` for transparency, `screenshot.overlay` for browser chrome, `screenshot.palette` to also extract dominant colors, or `screenshot.codeScheme` to theme code pages.', - 'Combine with `device`, `viewport`, `click`, `scroll`, `styles`, `scripts`, `modules`, `waitForSelector`, `waitForTimeout`, `waitUntil`, `colorScheme`, and `mediaType`.', + 'Prefer `actions` (ordered browser steps: inject, click, wait, scroll, fill, screenshot, …) with semantic locators (`role`+`name`, `label`, `text`, `testId`) or CSS `selector` — e.g. `actions: [{ type: "click", role: "button", name: "Accept" }, { type: "wait", timeout: "1s" }]`.', + 'Legacy `click`, `scroll`, `styles`, `scripts`, `modules`, `waitForSelector`, and `waitForTimeout` still work; also `device`, `viewport`, `waitUntil`, `colorScheme`, and `mediaType`.', 'Mirrors the `microlink.screenshot(url, options)` library method.' ].join(' '), screenshotInputSchema, diff --git a/packages/mcp/test/schemas.test.js b/packages/mcp/test/schemas.test.js index 2d81415..3e324a0 100644 --- a/packages/mcp/test/schemas.test.js +++ b/packages/mcp/test/schemas.test.js @@ -557,6 +557,48 @@ test('function schema accepts code with shared browser options', () => { assert.equal(result.data.click, '#accept') }) +test('screenshot schema accepts actions with semantic locators', () => { + const result = screenshotInputSchema.safeParse({ + url: 'https://app.example.com/login', + screenshot: true, + actions: [ + { type: 'fill', label: 'Email', value: 'user@example.com' }, + { type: 'click', role: 'button', name: 'Sign in' }, + { type: 'wait', text: 'Dashboard' }, + { type: 'screenshot', fullPage: true } + ] + }) + + assert.equal(result.success, true) + assert.equal(result.data.actions.length, 4) + assert.equal(result.data.actions[0].type, 'fill') + assert.equal(result.data.actions[1].role, 'button') +}) + +test('screenshot schema rejects actions with unknown type', () => { + const result = screenshotInputSchema.safeParse({ + url: 'https://microlink.io', + actions: [{ type: 'drag', selector: '#box' }] + }) + + assert.equal(result.success, false) +}) + +test('pdf schema accepts actions with inject and wait', () => { + const result = pdfInputSchema.safeParse({ + url: 'https://microlink.io', + pdf: true, + actions: [ + { type: 'inject', styles: ['.banner { display: none }'] }, + { type: 'wait', timeout: '1s' }, + { type: 'pdf', format: 'A4' } + ] + }) + + assert.equal(result.success, true) + assert.equal(result.data.actions[0].type, 'inject') +}) + test('metadata schema accepts palette and waitUntil', () => { const result = metadataInputSchema.safeParse({ url: 'https://microlink.io', diff --git a/packages/mql/dist/index.d.ts b/packages/mql/dist/index.d.ts index ab2e165..41c6a4a 100644 --- a/packages/mql/dist/index.d.ts +++ b/packages/mql/dist/index.d.ts @@ -40,6 +40,38 @@ type ScreenshotOptions = { type?: 'jpeg' | 'png' } +export type ActionLocator = + | { selector: string } + | { role: string; name?: string } + | { text: string } + | { label: string } + | { placeholder: string } + | { testId: string } + | { alt: string } + +export type Action = + | { type: 'inject'; styles?: string[]; scripts?: string[]; modules?: string[] } + | ({ type: 'click' } & ActionLocator) + | ({ + type: 'wait' + timeout?: string | number + text?: string + request?: string + visible?: boolean + hidden?: boolean + } & Partial) + | ({ type: 'scroll'; x?: number; y?: number } & Partial) + | ({ type: 'fill'; value: string } & ActionLocator) + | { type: 'evaluate'; expression: string } + | ({ type: 'screenshot'; fullPage?: boolean } & Partial) + | { + type: 'pdf' + format?: string + scale?: number + margin?: string | PdfMargin + printBackground?: boolean + } + type MqlClientOptions = { apiKey?: string endpoint?: string @@ -78,6 +110,7 @@ type MqlQueryOptions = { } export type MicrolinkApiOptions = { + actions?: Action[] adblock?: boolean animations?: boolean audio?: boolean diff --git a/packages/mql/test/get-api-url.mjs b/packages/mql/test/get-api-url.mjs index edec0c1..1531097 100644 --- a/packages/mql/test/get-api-url.mjs +++ b/packages/mql/test/get-api-url.mjs @@ -50,3 +50,19 @@ test('undefined', t => { }) ) }) + +test('actions flatten to dotted keys', t => { + t.snapshot( + mql.getApiUrl('https://app.example.com/login', { + meta: false, + screenshot: true, + actions: [ + { type: 'fill', label: 'Email', value: 'user@example.com' }, + { type: 'fill', label: 'Password', value: 'secret' }, + { type: 'click', role: 'button', name: 'Sign in' }, + { type: 'wait', text: 'Dashboard' }, + { type: 'screenshot', fullPage: true } + ] + }) + ) +}) diff --git a/packages/mql/test/index.test-d.ts b/packages/mql/test/index.test-d.ts index 65a2158..79a1a84 100644 --- a/packages/mql/test/index.test-d.ts +++ b/packages/mql/test/index.test-d.ts @@ -125,6 +125,26 @@ mql('https://example.com', { } }) +/** actions */ + +mql('https://example.com', { + meta: false, + screenshot: true, + actions: [ + { type: 'inject', styles: ['.banner { display: none }'] }, + { type: 'fill', label: 'Email', value: 'user@example.com' }, + { type: 'fill', label: 'Password', value: 'secret' }, + { type: 'click', role: 'button', name: 'Sign in' }, + { type: 'wait', text: 'Dashboard' }, + { type: 'wait', timeout: '3s' }, + { type: 'wait', request: '*api.example.com/user*' }, + { type: 'scroll', selector: '#pricing' }, + { type: 'screenshot', fullPage: true }, + { type: 'pdf', format: 'A4' }, + { type: 'evaluate', expression: 'window.ready === true' } + ] +}) + /** others */ mql('https://example.com', { click: ['div'] }) diff --git a/packages/mql/test/snapshots/get-api-url.mjs.md b/packages/mql/test/snapshots/get-api-url.mjs.md index 83f7435..59fc72c 100644 --- a/packages/mql/test/snapshots/get-api-url.mjs.md +++ b/packages/mql/test/snapshots/get-api-url.mjs.md @@ -152,296 +152,12 @@ Generated by [AVA](https://avajs.dev). }, ] -## node » url without query params +## actions flatten to dotted keys > Snapshot 1 [ - 'https://api.microlink.io/?url=https%3A%2F%2Fkikobeats.com', - { - headers: {}, - responseType: 'json', - }, - ] - -## node » apiKey - -> Snapshot 1 - - [ - 'https://pro.microlink.io/?url=https%3A%2F%2Fkikobeats.com', - { - headers: { - 'x-api-key': 'foobar', - }, - responseType: 'json', - }, - ] - -## node » flatten options - -> Snapshot 1 - - [ - 'https://api.microlink.io/?url=https%3A%2F%2Fkikobeats.com&overlay.browser=dark', - { - headers: {}, - responseType: 'json', - }, - ] - -## node » don't pass null - -> Snapshot 1 - - [ - 'https://api.microlink.io/?url=https%3A%2F%2Fkikobeats.com', - { - headers: {}, - responseType: 'json', - }, - ] - -> Snapshot 2 - - [ - 'https://api.microlink.io/?url=https%3A%2F%2Fkikobeats.com', - { - headers: {}, - responseType: 'json', - }, - ] - -## node » don't pass undefined - -> Snapshot 1 - - [ - 'https://api.microlink.io/?url=https%3A%2F%2Fkikobeats.com', - { - headers: {}, - responseType: 'json', - }, - ] - -> Snapshot 2 - - [ - 'https://api.microlink.io/?url=https%3A%2F%2Fkikobeats.com', - { - headers: {}, - responseType: 'json', - }, - ] - -## node » timeout - -> Snapshot 1 - - [ - 'https://api.microlink.io/?url=https%3A%2F%2Fkikobeats.com&timeout=15000', - { - headers: {}, - responseType: 'json', - }, - ] - -> Snapshot 2 - - [ - 'https://api.microlink.io/?url=https%3A%2F%2Fkikobeats.com&timeout=28000', - { - headers: {}, - responseType: 'json', - }, - ] - -## node » waitUntil - -> Snapshot 1 - - [ - 'https://api.microlink.io/?url=https%3A%2F%2Fkikobeats.com&waitUntil=load', - { - headers: {}, - responseType: 'json', - }, - ] - -> Snapshot 2 - - [ - 'https://api.microlink.io/?url=https%3A%2F%2Fkikobeats.com&waitUntil.0=load&waitUntil.1=networkidle0', - { - headers: {}, - responseType: 'json', - }, - ] - -## node » undefined - -> Snapshot 1 - - [ - 'https://api.microlink.io/?url=https%3A%2F%2Fkikobeats.com', - { - headers: {}, - responseType: 'json', - }, - ] - -> Snapshot 2 - - [ - 'https://api.microlink.io/?url=https%3A%2F%2Fkikobeats.com&screenshot.element=%23screenshot', - { - headers: {}, - responseType: 'json', - }, - ] - -## lightweight » url without query params - -> Snapshot 1 - - [ - 'https://api.microlink.io/?url=https%3A%2F%2Fkikobeats.com', - { - headers: {}, - responseType: 'json', - }, - ] - -## lightweight » apiKey - -> Snapshot 1 - - [ - 'https://pro.microlink.io/?url=https%3A%2F%2Fkikobeats.com', - { - headers: { - 'x-api-key': 'foobar', - }, - responseType: 'json', - }, - ] - -## lightweight » flatten options - -> Snapshot 1 - - [ - 'https://api.microlink.io/?url=https%3A%2F%2Fkikobeats.com&overlay.browser=dark', - { - headers: {}, - responseType: 'json', - }, - ] - -## lightweight » don't pass null - -> Snapshot 1 - - [ - 'https://api.microlink.io/?url=https%3A%2F%2Fkikobeats.com', - { - headers: {}, - responseType: 'json', - }, - ] - -> Snapshot 2 - - [ - 'https://api.microlink.io/?url=https%3A%2F%2Fkikobeats.com', - { - headers: {}, - responseType: 'json', - }, - ] - -## lightweight » don't pass undefined - -> Snapshot 1 - - [ - 'https://api.microlink.io/?url=https%3A%2F%2Fkikobeats.com', - { - headers: {}, - responseType: 'json', - }, - ] - -> Snapshot 2 - - [ - 'https://api.microlink.io/?url=https%3A%2F%2Fkikobeats.com', - { - headers: {}, - responseType: 'json', - }, - ] - -## lightweight » timeout - -> Snapshot 1 - - [ - 'https://api.microlink.io/?url=https%3A%2F%2Fkikobeats.com&timeout=15000', - { - headers: {}, - responseType: 'json', - }, - ] - -> Snapshot 2 - - [ - 'https://api.microlink.io/?url=https%3A%2F%2Fkikobeats.com&timeout=28000', - { - headers: {}, - responseType: 'json', - }, - ] - -## lightweight » waitUntil - -> Snapshot 1 - - [ - 'https://api.microlink.io/?url=https%3A%2F%2Fkikobeats.com&waitUntil=load', - { - headers: {}, - responseType: 'json', - }, - ] - -> Snapshot 2 - - [ - 'https://api.microlink.io/?url=https%3A%2F%2Fkikobeats.com&waitUntil.0=load&waitUntil.1=networkidle0', - { - headers: {}, - responseType: 'json', - }, - ] - -## lightweight » undefined - -> Snapshot 1 - - [ - 'https://api.microlink.io/?url=https%3A%2F%2Fkikobeats.com', - { - headers: {}, - responseType: 'json', - }, - ] - -> Snapshot 2 - - [ - 'https://api.microlink.io/?url=https%3A%2F%2Fkikobeats.com&screenshot.element=%23screenshot', + 'https://api.microlink.io/?url=https%3A%2F%2Fapp.example.com%2Flogin&meta=false&screenshot=true&actions.0.type=fill&actions.0.label=Email&actions.0.value=user%40example.com&actions.1.type=fill&actions.1.label=Password&actions.1.value=secret&actions.2.type=click&actions.2.role=button&actions.2.name=Sign+in&actions.3.type=wait&actions.3.text=Dashboard&actions.4.type=screenshot&actions.4.fullPage=true', { headers: {}, responseType: 'json', diff --git a/packages/mql/test/snapshots/get-api-url.mjs.snap b/packages/mql/test/snapshots/get-api-url.mjs.snap index c9de761..a4d5c96 100644 Binary files a/packages/mql/test/snapshots/get-api-url.mjs.snap and b/packages/mql/test/snapshots/get-api-url.mjs.snap differ