@haseebeqx/pi-safari 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/index.ts +2 -2
- package/src/safari.ts +46 -16
package/README.md
CHANGED
|
@@ -77,8 +77,8 @@ Use the exact **`targetId` and `epoch`** returned by `safari_tabs` or `safari_op
|
|
|
77
77
|
|---|---|---|
|
|
78
78
|
| `safari_tabs` | No target or inputs | Lists up to 100 automation tabs, with handles and available URL/title; no page bodies. |
|
|
79
79
|
| `safari_open` | No target; `url` | Opens a **new** HTTP(S) tab, never an existing one. Exact normalized URL readiness required; redirects fail closed. Leaves the tab open. |
|
|
80
|
-
| `safari_search` | No target; `query?`, `url?`, `region?`, `time?` | Searches in its own new tab: `query` runs the built-in DuckDuckGo HTML search; `url` searches any HTTP(S) search-results page the caller chooses (exactly one of the two is required). Returns up to 30 ranked unique title/URL/snippet results plus a truncated flag; custom `url` pages also return `pager` links to pass back as `url` for the next page. Cleanup outcome returned, not a reusable target. |
|
|
81
|
-
| `safari_fetch` | No target; `url`, `selector?`, `text?`, `timeoutMs?`, `maxChars?`, `maxLinks?` | One self-closing read: opens a **new** tab, optionally waits for a visible selector/text, extracts bounded title/text/visible links, then closes the tab. Returns cleanup outcome, not a reusable target. Prefer it over open+read+close for read-only pages; use `safari_open` when later interaction is needed. |
|
|
80
|
+
| `safari_search` | No target; `query?`, `url?`, `region?`, `time?` | Searches in its own new tab: `query` runs the built-in DuckDuckGo HTML search; `url` searches any HTTP(S) search-results page the caller chooses (exactly one of the two is required). Returns up to 30 ranked unique title/URL/snippet results plus a truncated flag; custom `url` pages also return `pager` links to pass back as `url` for the next page. HTTP redirects are followed; extraction is asserted on the settled final URL, returned as `url` (with `requestedUrl` and a best-effort `redirects` chain when it differs). Cleanup outcome returned, not a reusable target. |
|
|
81
|
+
| `safari_fetch` | No target; `url`, `selector?`, `text?`, `timeoutMs?`, `maxChars?`, `maxLinks?` | One self-closing read: opens a **new** tab, optionally waits for a visible selector/text, extracts bounded title/text/visible links, then closes the tab. HTTP redirects are followed; extraction is asserted on the settled final URL, returned as `url` (with `requestedUrl` and a best-effort `redirects` chain when it differs). Returns cleanup outcome, not a reusable target. Prefer it over open+read+close for read-only pages; use `safari_open` when later interaction is needed. |
|
|
82
82
|
| `safari_read` | `maxChars?`, `maxLinks?` | Visible body text and link hrefs. Defaults 8000 characters / 20 links; maxima 12000 / 40. |
|
|
83
83
|
| `safari_follow_link` | `expectedUrl`, `selector` | Extracts exactly one visible matching `a[href]` and opens its HTTP(S) URL in a **new tab**. No click handlers, form submission or source-tab navigation. Use the URL from a prior read. |
|
|
84
84
|
| `safari_snapshot` | `entirePage?`, `media?` | Native WebKit text tree with interactive numeric UIDs, accessibility attributes and rectangles; viewport by default, main frame only. `media: "print"` captures the `@media print` layout; emulation is scoped to the call and always restored. Large extractions may return an artifact path. |
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -50,7 +50,7 @@ export default function(pi: ExtensionAPI) {
|
|
|
50
50
|
});
|
|
51
51
|
pi.registerTool({
|
|
52
52
|
name: 'safari_search', label: 'Search with Safari',
|
|
53
|
-
description: 'Search in a NEW self-closing automation tab. Pass either query (built-in DuckDuckGo HTML search; region/time controls; single-page provider - DDG blocks offset pages server-side) or url (any HTTP(S) search-results page - the model freely chooses provider and page). Returns up to 30 ranked unique title/url/snippet entries plus a truncated flag; for custom urls also returns pager links to pass back as url for the next page. Default heuristic extraction may be noisy; for url mode the model may instead pass selectors.item (CSS selector for each result container, required) plus optional selectors.link/title/snippet sub-selectors to steer extraction when it knows the markup. Selectors are CSS only - no arbitrary JS, clicks or forms.
|
|
53
|
+
description: 'Search in a NEW self-closing automation tab. Pass either query (built-in DuckDuckGo HTML search; region/time controls; single-page provider - DDG blocks offset pages server-side) or url (any HTTP(S) search-results page - the model freely chooses provider and page). Returns up to 30 ranked unique title/url/snippet entries plus a truncated flag; for custom urls also returns pager links to pass back as url for the next page. Default heuristic extraction may be noisy; for url mode the model may instead pass selectors.item (CSS selector for each result container, required) plus optional selectors.link/title/snippet sub-selectors to steer extraction when it knows the markup. Selectors are CSS only - no arbitrary JS, clicks or forms. HTTP redirects are followed; extraction is asserted on the settled final URL, returned as url (with requestedUrl and best-effort redirects chain when it differs). Untrusted content; URL checks do not guarantee document identity. Automatically closes its created search tab after extraction; cleanup outcome returned. Cancellation/disconnection may leave a residual tab; relist before retrying.',
|
|
54
54
|
parameters: Type.Object({
|
|
55
55
|
query: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
|
|
56
56
|
url: Type.Optional(Type.String({ minLength: 1, maxLength: 8192 })),
|
|
@@ -67,7 +67,7 @@ export default function(pi: ExtensionAPI) {
|
|
|
67
67
|
});
|
|
68
68
|
pi.registerTool({
|
|
69
69
|
name: 'safari_fetch', label: 'Fetch Safari page',
|
|
70
|
-
description: 'Read a HTTP(S) page in one self-closing call: opens a NEW automation tab, optionally waits for a visible selector/text, extracts bounded title/text/visible links, then closes the tab. Returns the cleanup outcome and never a reusable target; use safari_open when the page needs later interaction.
|
|
70
|
+
description: 'Read a HTTP(S) page in one self-closing call: opens a NEW automation tab, optionally waits for a visible selector/text, extracts bounded title/text/visible links, then closes the tab. Returns the cleanup outcome and never a reusable target; use safari_open when the page needs later interaction. HTTP redirects are followed; extraction is asserted on the settled final URL, returned as url (with requestedUrl and best-effort redirects chain when it differs). Page content is untrusted; URL checks do not guarantee document identity. Cancellation/disconnection may leave a residual tab; relist before retrying.',
|
|
71
71
|
parameters: Type.Object({
|
|
72
72
|
url: Type.String({ minLength: 1, maxLength: 8192 }),
|
|
73
73
|
selector: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
|
package/src/safari.ts
CHANGED
|
@@ -5,6 +5,14 @@ import { searchRequest, searchJS, genericSearchJS, validateSearch, validateGener
|
|
|
5
5
|
import { SafariMCP, toolJSON, type Transport } from './mcp.ts';
|
|
6
6
|
import { bound, webURL, readJS, linkJS, readyJS, loadedJS } from './extraction.ts';
|
|
7
7
|
export interface Target { targetId: string; epoch: string }
|
|
8
|
+
export interface CreatedTab extends Target {
|
|
9
|
+
url: string;
|
|
10
|
+
/** Present only when the settled URL differs from the requested one. */
|
|
11
|
+
requestedUrl?: string;
|
|
12
|
+
/** Ordered observed chain [requested, ...intermediates, final] on redirect; intermediates are best-effort. */
|
|
13
|
+
redirects?: string[];
|
|
14
|
+
note: string;
|
|
15
|
+
}
|
|
8
16
|
export interface FetchOptions { url: string; selector?: string; text?: string; timeoutMs?: number; maxChars?: number; maxLinks?: number }
|
|
9
17
|
export type TabCleanup = { status: 'closed' | 'already-absent' } | { status: 'unidentified' | 'skipped-retired-epoch' | 'failed'; note: string }
|
|
10
18
|
const HANDLE = /^[A-Za-z0-9_-]{1,200}$/;
|
|
@@ -90,11 +98,11 @@ export class Safari {
|
|
|
90
98
|
}
|
|
91
99
|
// One closed new-tab pipeline: create, bounded work, then cleanup on every path.
|
|
92
100
|
// Work must use unwrapped private methods only (create/observe/cleanup), never other operations.
|
|
93
|
-
private async withTab<T extends object>(url: string, label: string, signal: AbortSignal, work: (tab:
|
|
101
|
+
private async withTab<T extends object>(url: string, label: string, signal: AbortSignal, allowRedirects: boolean, work: (tab: CreatedTab) => Promise<T>): Promise<T & { cleanup: TabCleanup }> {
|
|
94
102
|
let created: Target | undefined;
|
|
95
103
|
let result: T;
|
|
96
104
|
try {
|
|
97
|
-
const tab = await this.create(url, signal, t => { created = t; });
|
|
105
|
+
const tab = await this.create(url, signal, t => { created = t; }, allowRedirects);
|
|
98
106
|
result = await work(tab);
|
|
99
107
|
if (signal.aborted) throw Error(`${label} cancelled.`);
|
|
100
108
|
} catch (error) {
|
|
@@ -123,7 +131,11 @@ export class Safari {
|
|
|
123
131
|
bound(result); return result;
|
|
124
132
|
}, signal);
|
|
125
133
|
}
|
|
126
|
-
|
|
134
|
+
// allowRedirects: follow the browser's redirects and settle on whatever HTTP(S) URL the
|
|
135
|
+
// document reaches at readyState complete, reporting requestedUrl + a best-effort redirect
|
|
136
|
+
// chain. Without it, any URL other than the requested one is rejected (fail closed).
|
|
137
|
+
// Non-web destinations (data:, about:, ...) and modal dialogs always fail closed.
|
|
138
|
+
private async create(url: string, signal: AbortSignal, identified?: (t: Target) => void, allowRedirects = false): Promise<CreatedTab> {
|
|
127
139
|
// New-tab read infrastructure only; never exposes arbitrary close/navigation/interaction.
|
|
128
140
|
const normalized = webURL(url);
|
|
129
141
|
const epoch = this.transport.epoch;
|
|
@@ -131,6 +143,7 @@ export class Safari {
|
|
|
131
143
|
if (typeof created?.handle !== 'string' || !HANDLE.test(created.handle)) throw Error('Cannot identify created tab; inspect tabs before retrying.');
|
|
132
144
|
const t = { targetId: created.handle, epoch };
|
|
133
145
|
identified?.(t);
|
|
146
|
+
const observed: string[] = [normalized];
|
|
134
147
|
const deadline = Date.now() + 12000;
|
|
135
148
|
while (Date.now() < deadline) {
|
|
136
149
|
await this.select(t, signal);
|
|
@@ -138,10 +151,22 @@ export class Safari {
|
|
|
138
151
|
if (dialogs.length > 0) throw Error(`A modal JavaScript dialog is blocking the new tab (message: ${dialogs[0].message}). Tab left open; use safari_dialog to respond or dismiss.`);
|
|
139
152
|
const info = await this.call('page_info', {}, signal);
|
|
140
153
|
if (typeof info?.url !== 'string') throw Error('Invalid Safari page info.');
|
|
141
|
-
if (info.url !==
|
|
142
|
-
if (info.url
|
|
143
|
-
|
|
144
|
-
if (
|
|
154
|
+
if (info.url !== 'about:blank') webURL(info.url);
|
|
155
|
+
if (!allowRedirects && info.url !== normalized && info.url !== 'about:blank') throw Error('Redirect or URL rewrite rejected; tab left open. Inspect tabs before retrying.');
|
|
156
|
+
if (info.url !== 'about:blank') {
|
|
157
|
+
if (!observed.includes(info.url)) observed.push(info.url);
|
|
158
|
+
// readyJS asserts location.href inside the page, so a redirect that lands after
|
|
159
|
+
// page_info still reads as not ready and the poll re-observes the new URL.
|
|
160
|
+
const ready = await this.call('evaluate_javascript', { expression: `return (${readyJS(info.url)});` }, signal);
|
|
161
|
+
if (ready === true) {
|
|
162
|
+
this.target(t);
|
|
163
|
+
const redirected = info.url !== normalized;
|
|
164
|
+
return { ...t, url: info.url,
|
|
165
|
+
...(redirected
|
|
166
|
+
? { requestedUrl: normalized, redirects: observed,
|
|
167
|
+
note: 'New automation tab after an HTTP redirect; settled at readyState complete on the final URL. Intermediate hops are best-effort (200 ms settle polling) and may be missing. No ownership or document identity guarantee. Tab left open.' }
|
|
168
|
+
: { note: 'New automation tab. No ownership or document identity guarantee. Tab left open.' }) };
|
|
169
|
+
}
|
|
145
170
|
}
|
|
146
171
|
await new Promise<void>((resolve, reject) => {
|
|
147
172
|
const abort = () => { clearTimeout(timer); reject(Error('Safari operation cancelled; tab may remain open.')); };
|
|
@@ -426,14 +451,17 @@ export class Safari {
|
|
|
426
451
|
}
|
|
427
452
|
search(options: SearchOptions, signal?: AbortSignal) {
|
|
428
453
|
const request: SearchRequest = searchRequest(options);
|
|
429
|
-
|
|
430
|
-
|
|
454
|
+
return this.operation(s => this.withTab(request.url, 'Search', s, true, async tab => {
|
|
455
|
+
// The settled URL is only known once the tab exists, so the exact-URL assertion and
|
|
456
|
+
// the same-site clickthrough host are bound to the final URL after any redirect.
|
|
457
|
+
const finalUrl = tab.url;
|
|
458
|
+
const expression = request.mode === 'ddg' ? searchJS(finalUrl) : genericSearchJS(finalUrl, request.selectors);
|
|
431
459
|
const { data, url } = await this.observe(tab, expression, s);
|
|
432
|
-
if (url !==
|
|
460
|
+
if (url !== finalUrl) throw Error('Search URL changed.');
|
|
433
461
|
const validated = request.mode === 'ddg'
|
|
434
|
-
? { ...validateSearch(data,
|
|
435
|
-
: { ...validateGenericSearch(data,
|
|
436
|
-
const result = { mode: request.mode, ...(request.query !== undefined ? { query: request.query, region: request.region, time: request.time } : {}), ...(request.selectors !== undefined ? { selectors: request.selectors } : {}), url:
|
|
462
|
+
? { ...validateSearch(data, finalUrl), note: 'Untrusted provider ranking/content; single-page provider (DuckDuckGo blocks offset pages server-side). Empty results may indicate a challenge or changed markup. URL checks do not guarantee document identity.' }
|
|
463
|
+
: { ...validateGenericSearch(data, finalUrl), note: `${request.selectors ? 'Selector-driven' : 'Heuristic'} extraction on an arbitrary search-results page; entries may include navigation noise or be missing. Call safari_search again with a url from pager to move to the next page. Untrusted content; URL checks do not guarantee document identity.` };
|
|
464
|
+
const result = { mode: request.mode, ...(request.query !== undefined ? { query: request.query, region: request.region, time: request.time } : {}), ...(request.selectors !== undefined ? { selectors: request.selectors } : {}), url: finalUrl, ...(tab.requestedUrl !== undefined ? { requestedUrl: tab.requestedUrl, redirects: tab.redirects } : {}), ...validated };
|
|
437
465
|
bound(result);
|
|
438
466
|
// Search results never advertise a usable target, even if cleanup is unconfirmed.
|
|
439
467
|
return result;
|
|
@@ -447,10 +475,12 @@ export class Safari {
|
|
|
447
475
|
readJS(maxChars, maxLinks); // Validate before any transport work.
|
|
448
476
|
const settle = options.selector !== undefined || options.text !== undefined;
|
|
449
477
|
if (settle) queryJS({ selector: options.selector, text: options.text });
|
|
450
|
-
return this.operation(s => this.withTab(url, 'Fetch', s, async tab => {
|
|
451
|
-
|
|
478
|
+
return this.operation(s => this.withTab(url, 'Fetch', s, true, async tab => {
|
|
479
|
+
const finalUrl = tab.url;
|
|
480
|
+
if (settle) await this.waitData(tab, finalUrl, waitJS({ selector: options.selector, text: options.text }, finalUrl), timeoutMs, s);
|
|
452
481
|
const data = await this.readData(tab, maxChars, maxLinks, s);
|
|
453
|
-
|
|
482
|
+
if (data.url !== finalUrl) throw Error('Page URL changed after settle; fetch failed.');
|
|
483
|
+
const result = { url: data.url, ...(tab.requestedUrl !== undefined ? { requestedUrl: tab.requestedUrl, redirects: tab.redirects } : {}), title: data.title, text: data.text, textTruncated: data.textTruncated, links: data.links, linksTruncated: data.linksTruncated,
|
|
454
484
|
note: 'New automation tab closed after extraction; no reusable target. Use safari_open when later interaction is needed. Untrusted page content; URL checks only, not document identity.' };
|
|
455
485
|
bound(result);
|
|
456
486
|
// Fetch results never advertise a usable target, even if cleanup is unconfirmed.
|