From 9aba95a9caff2e6eac1cb54c5f18f89e34e792c7 Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Sun, 9 Aug 2026 19:44:18 +0200 Subject: [PATCH] feat: add actions option to mql, core, and mcp Type and validate the upcoming browser actions API surface so clients can pass ordered interaction steps with semantic locators before the engine ships. Co-authored-by: Cursor --- packages/core/src/index.d.ts | 34 +++ packages/core/test/index.test-d.ts | 12 +- packages/mcp/src/schemas.js | 78 +++++ packages/mcp/src/tools/function.js | 3 +- packages/mcp/src/tools/html.js | 3 +- packages/mcp/src/tools/pdf.js | 3 +- packages/mcp/src/tools/screenshot.js | 3 +- packages/mcp/test/schemas.test.js | 42 +++ packages/mql/dist/index.d.ts | 33 ++ packages/mql/test/get-api-url.mjs | 16 + packages/mql/test/index.test-d.ts | 20 ++ .../mql/test/snapshots/get-api-url.mjs.md | 288 +----------------- .../mql/test/snapshots/get-api-url.mjs.snap | Bin 877 -> 894 bytes 13 files changed, 243 insertions(+), 292 deletions(-) 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 c9de761afa10f06ba71386a51475a47d477fb4f5..a4d5c965040cdda63f01d39e667e54e952d79ab4 100644 GIT binary patch literal 894 zcmV-^1A+WORzV1klOAf3Az<{IXZ+@OSk!nvKdeX zsn%`dL?4R?00000000B+m(ObxK@`V#Hn#Og+qBe!2hm(o#nNq(R#9P*f>uG$LRHY) zWcMYRW_D+too$kvdJ{ptc+ivWQM~HOqh9pnLG&UB6%YOa9@UpklU*f95*nzGkjFm0 zJIuWO?04tKdLhq+Rrc?0K=nW%d8)!xNdzi&GXPmjc~nxzXSdgFn<{#p9872poGnAGsSFo{$WyriqQI$A z;HHG97!Dlq&bH(IsB7MrdiLkESOdvv&B#kp@j;q4mF4gyJdIw$J51kWeDCxnOp!jC zR@lNlqVQAq{gfMU&h8Ap_71=eAa&#}6?0eM+E8FF*aM3VEVg|XD=Ovy?pY#N9cu^d ze%HXx_r45lm1Jg*XEK=(-{T11YfRr_Y<7xow14ye&O??vxx@IvnZTSb{%P&*{}BqI0*)B48aYo`Rq`fs|WcFdl2&CJ>cW=1BY78A0j3#uYy znb{l~hI|p>{DSE>j9;DNJla=74wly59B8(KwB9wO8@)Twwr|{?&9nEyrj`SzeC29R3=>|3R zJOirK@i^3Bqrydzxzi3PY8EK>A;#ZSGJxr==P@#dLKtR&aURn+%>y^jIh4iKHOd1p z13d4g7c#N&T1~QTI?HZ#wv1b2oM!CUjYfYyU}>r)=Nf%joLS|Vh|dpZK2S=yG1a9G zm@BO4&agrM#fADi#`zBRsJ?1w1A$(;|e#LW6FSmO;mL^c%*k U-l0LS>3uWkZ*T4P;olMf0GsHxJ^%m! literal 877 zcmV-z1CsnfRzVbIdkGDiroqB zy*C5MEFX&q00000000B+n7?ZiQ5?X(FR?$=*0j{2L(v0?6-(1JMN#39E>#e8PzT*! zaxcwmF7I96y=#(9T?L&4b+V4)s*6sdi%t&WBnXOwe}JPua^{j6Pf21fjqvRdk{9TG z`MeMJE}zYO)|R=Vdwcpm(_GuEGVLkbD05wqo;KD!rmBWR6)m~k71VJHQoC0-EsJV; ziOj@d1Zsa|l*H2jaP-HAD*!M>h9}9;vQo54NboL^NC4RdBnD&*rbrC-k~ol|NpifP zwd0;xSfCD1m3U4`n~P$K%Y~DkvQ7Wnl(td?Izo6cwcA(BSLE5HNC|(PgpF^1+!2% z6fP>^Ry8j%>FE&14u|2p(J{VL-5(hB+04Zw>2%tMd8Y~UIRLx_fDi4$9E&BTT;vCdAG^)1pw3@SU$GPb3A$lSHhS* z=$Kiq2h37wpW2p%MN=?cma51toBh=DQ4{BT0Qdv|-`d4_D0)5d5dKFW%`ixt9Yea@ zy^n5AF(ztTmtr<6F`><5W~sUAQ+(K@_!a=(0l=4bDejM4!-^rK#f)c942%^$1uFus z7%loAEy@9Wj2Dp!-e8Oo1AkNY>84tl36TyTq|m&`&69z6ST(R}Y-80}4?M)WfpueW z>qbLUJe;v|3_#_WwfSm6ms#xVdM$XddGx<|RQ9!O0=AFn?kBCRv4P+((S9|M z1Alf8pSDoI$OYVJ@ehj#7Ljc%B9-YDAF+&J85!I%@|&)BW@91Q#zOKP7s*!rw=w_# D>(`*R