@haseebeqx/pi-safari 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +100 -2
- package/package.json +40 -4
- package/src/extraction.ts +26 -0
- package/src/index.ts +59 -0
- package/src/mcp.ts +109 -0
- package/src/safari.ts +163 -0
- package/src/search.ts +51 -0
- package/src/version.ts +1 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Haseeb Annadamban
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,101 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-safari
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Six bounded Safari tools for Pi, backed by **Apple’s built-in local Safari MCP server**. The extension manages the MCP connection and launches `/usr/bin/safaridriver --mcp`, communicating over standard input/output.
|
|
4
|
+
|
|
5
|
+
Already using an MCP-capable client? [You can also use Apple’s Safari MCP server directly, without this extension](#use-safari-mcp-directly-without-this-extension).
|
|
6
|
+
|
|
7
|
+
## Requirements and installation
|
|
8
|
+
|
|
9
|
+
- macOS with **Safari 27 or later** and Node **22.19+**.
|
|
10
|
+
- In Safari → Settings → Advanced, enable **Show features for web developers**.
|
|
11
|
+
- In Safari → Settings → Developer, enable **Allow remote automation and external agents**. Complete any connection approval Safari presents yourself.
|
|
12
|
+
- Install from npm with `pi install npm:@haseebeqx/pi-safari`, then reload/restart Pi. For a local checkout, use `pi install /absolute/path/to/pi-safari` or try it temporarily with `pi -e ./src/index.ts` from this repository.
|
|
13
|
+
- The package ships TypeScript source; no build step or separate MCP configuration is needed. Pi supplies its host dependencies. Development is checked against Pi 1.2.0; compatibility with every Pi version is not guaranteed.
|
|
14
|
+
|
|
15
|
+
Enable Safari’s MCP automation permission as described above; the package leaves system settings unchanged. Fresh automation browsing state is supported and expected: do not rely on normal Safari tabs, profile cookies, or existing logins being available. Apple documents isolated windows for WebDriver; the exact scope exposed by MCP is managed by Safari and should not be inferred from a normal Safari window.
|
|
16
|
+
|
|
17
|
+
Apple documents the integration in [Connecting an AI agent to Safari](https://developer.apple.com/documentation/safari-developer-tools/connecting-an-ai-agent-to-safari) and describes the [Safari MCP server](https://webkit.org/blog/18136/introducing-the-safari-mcp-server-for-web-developers/). Safari MCP runs locally; page observations passed to Pi may subsequently be sent to the model provider configured in Pi.
|
|
18
|
+
|
|
19
|
+
## Tools
|
|
20
|
+
|
|
21
|
+
- `safari_tabs {}` lists at most 100 tabs exposed by the automation session, returning `targetId` and `epoch`, plus available URL/title metadata. No page bodies.
|
|
22
|
+
- `safari_open {"url":"https://example.com/"}` creates a **new automation tab**, waits up to approximately 12 seconds for the exact normalized URL to finish loading, and returns its target. Redirects and URL rewrites fail closed. Existing tabs are never navigated.
|
|
23
|
+
- `safari_search {"query":"Safari automation","maxResults":5,"region":"us-en","time":"m"}` searches DuckDuckGo HTML in a **new tab** and returns ranked, deduplicated `{rank,title,url,snippet}` results plus the search URL, `truncated` and `cleanup` outcome (no reusable target). Defaults: 5 results, worldwide (`wt-wt`), any time. Limits: 1–500 query characters (no control characters), 1–10 results.
|
|
24
|
+
- `safari_close {"targetId":"page-…","epoch":"…"}` closes exactly that automation tab, rejecting stale epochs. An absent tab returns `already-absent`; close is never retried.
|
|
25
|
+
- `safari_read {"targetId":"page-…","epoch":"…","maxChars":8000,"maxLinks":20}` extracts body `innerText` and visible link hrefs from that explicit tab. Defaults are 8000 characters/20 links; maxima are 12000/40. Includes truncation flags.
|
|
26
|
+
- `safari_follow_link {"targetId":"page-…","epoch":"…","expectedUrl":"https://example.com/","selector":"a.more"}` checks the exact source URL, extracts the href of exactly one visible matching `a[href]`, validates HTTP(S), and opens it in a **new tab**. It never invokes click handlers, submits forms, or navigates the source.
|
|
27
|
+
|
|
28
|
+
Use the exact target and epoch returned by tabs/open; do not invent them. Read a page before following a CSS-selected link, and supply its returned source URL. Output is capped at 48 KiB of JSON; reduce read limits if escaping causes an oversized-result error.
|
|
29
|
+
|
|
30
|
+
### Search controls and limitations
|
|
31
|
+
|
|
32
|
+
`region` is one of `wt-wt`, `us-en`, `uk-en`, `ca-en`, `au-en`, `de-de`, `fr-fr`, `in-en`, `jp-jp`. It sets DuckDuckGo's `kl` region preference, not a strict country/language restriction. `time` is `any`, `d`, `w`, `m`, or `y` (past day/week/month/year), using the provider's `df` filter. Ranking and filter enforcement belong to DuckDuckGo; no custom sorting, arbitrary provider parameters, strict site restriction, safe-search override, or pagination is promised. Query operators can be typed as query text but their semantics are provider-controlled.
|
|
33
|
+
|
|
34
|
+
The URL is constructed deterministically; no form is submitted and no result is clicked or fetched. Fixed selectors inspect the first 100 result containers, skip recognized ads/hidden or unsafe links, unwrap recognized DuckDuckGo result redirects, and deduplicate canonical URL strings. Ranks are contiguous provider DOM order after filtering, not independent relevance scores. Titles/snippets are limited to 300/600 characters, URLs to 2048. `truncated` means more eligible results or the 100-container scan bound was reached; text fields can also be shortened. Empty output does **not** prove no matches: challenges, blocked requests or changed provider markup may yield no recognized results. There is no challenge bypass or automatic retry. Redirects/URL rewrites fail closed, including provider changes. Search queries go to DuckDuckGo; returned content remains untrusted. No live provider test is part of the automated suite.
|
|
35
|
+
|
|
36
|
+
### Tab lifecycle
|
|
37
|
+
|
|
38
|
+
Open/follow operations leave tabs open until the agent calls `safari_close` (or you close them). Search automatically closes only the tab identified by its own create reply, after extracting results and on readiness/extraction failures. It returns `cleanup.status`: `closed`, `already-absent`, `failed`, `unidentified`, or `skipped-retired-epoch`. Search never returns a usable target. Errors retain the original failure as cause and append cleanup outcome.
|
|
39
|
+
|
|
40
|
+
Cleanup has a separate three-second budget and may run after cancellation only while the original connection epoch remains current. An interrupted driver request retires its epoch; cleanup never reconnects using that old handle. A lost/malformed create reply can leave an unidentified tab; no list-difference guessing, create replay, or unrelated-tab closure is attempted. Failed/unconfirmed cleanup and retired connections may leave residual tabs: relist for manual cleanup before retrying. Shutdown stops the driver, not a tab-closing routine; Safari controls session teardown.
|
|
41
|
+
|
|
42
|
+
Handles are scoped to a persistent driver connection. A disconnect, cancellation that interrupts a driver request, shutdown, or five minutes of inactivity retires that connection; relist tabs afterward. Do not reuse a saved epoch after reconnection.
|
|
43
|
+
|
|
44
|
+
## Safety and limits
|
|
45
|
+
|
|
46
|
+
The Pi-facing surface supports bounded read/extract/open/close. Internally the adapter allows just six Apple MCP tools: tab listing, tab creation, explicit tab switching, tab closing by handle, page information, and JavaScript evaluation with fixed package-generated read/readiness/link/search scripts. The model cannot supply JavaScript or invoke Apple’s broader interaction, navigation, screenshot, or network-inspection tools through this package. Extraction excludes frames and shadow roots. It does not modify source DOM, use identity markers, or claim ownership of pages.
|
|
47
|
+
|
|
48
|
+
Operations are serialized per Pi extension instance, with a 20-second work deadline (plus up to three seconds for search cleanup), cancellation, a 256-KiB transport response limit, protocol negotiation, required-tool discovery and a `close_tab` input-schema check, and session-scoped handles. A failed or cancelled create is **never retried automatically**. A tab may already exist when a timeout is reported; inspect tabs before retrying. Failed opens and smoke tests leave their tabs open for manual inspection. Shutdown stops the driver process, not a sequence of tab-close commands; Safari controls its session lifecycle.
|
|
49
|
+
|
|
50
|
+
Safari’s MCP extraction APIs operate on a current browsing context. The adapter selects an explicit handle before extraction and checks URLs and tab presence afterward. These are best-effort stale-read checks: same-URL reloads/replacements, a context switch to another page with the same URL, live DOM changes, or external interference may go undetected. Tab handles do not identify documents or authorize mutation. Concurrent independent Pi sessions are not coordinated by this package.
|
|
51
|
+
|
|
52
|
+
Web content is untrusted data. Opening a URL can have server-side effects, including in accounts signed into within the automation session. Safari’s isolation does not make every visited website or requested action safe.
|
|
53
|
+
|
|
54
|
+
## Development and verification
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
npm ci --ignore-scripts
|
|
58
|
+
npm run check
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`npm run check` runs the unit tests, TypeScript check and npm tarball dry-run assertions; it never starts Safari. The same checks run before `npm publish`. Host Pi and TypeBox packages are peer dependencies, also pinned as development dependencies for reproducible local checks. Automated tests mock the driver’s JSON-RPC stream and execute the actual extraction scripts in VM/DOM fixtures. They cover handshake compatibility, bounds, malformed replies, stale targets, cancellation, serialization, redirect rejection, and creation without replay.
|
|
62
|
+
|
|
63
|
+
An explicit real-Safari smoke test opens two disposable local fixture tabs, exercises `safari_tabs`, `safari_open`, `safari_read` and `safari_follow_link` (not `safari_close` or external search), checks visibility and preservation of the source URL, and leaves the tabs open:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
PI_SAFARI_LIVE_TEST=1 npm run test:safari
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Requires informed manual enablement of Safari remote automation. It does not change settings, sign into sites, or navigate/close existing tabs. The local fixture server stops when the test finishes.
|
|
70
|
+
|
|
71
|
+
### Validation boundaries
|
|
72
|
+
|
|
73
|
+
Automated fixtures do not establish live Safari or DuckDuckGo reliability. The opt-in smoke test does not validate search or tab closing. Manual checks still needed for a release include clean permission setup/denial, Safari restart/disconnect and idle expiry, tab replacement/reordering, concurrent Pi clients, authentication within automation state, and Safari updates. Required-tool discovery does not validate every Apple tool schema; changes to Safari APIs or provider markup can break operations.
|
|
74
|
+
|
|
75
|
+
## Use Safari MCP directly (without this extension)
|
|
76
|
+
|
|
77
|
+
If your AI client supports local MCP servers over **stdio**, you can connect to Apple’s Safari MCP server without installing Pi or this extension.
|
|
78
|
+
|
|
79
|
+
1. Use macOS with **Safari 27 or later** and enable both Safari developer settings listed in [Requirements and installation](#requirements-and-installation). Complete any Safari connection approval yourself.
|
|
80
|
+
2. Add a server to your client’s MCP configuration with command `/usr/bin/safaridriver` and argument `--mcp`. For clients using the common `mcpServers` JSON format:
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{
|
|
84
|
+
"mcpServers": {
|
|
85
|
+
"safari": {
|
|
86
|
+
"command": "/usr/bin/safaridriver",
|
|
87
|
+
"args": ["--mcp"]
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
3. Reload your client’s MCP configuration (or restart the client), then enable the Safari tools it discovers. Your client launches the server and manages the stdio connection; you do not need to run it separately in a terminal.
|
|
94
|
+
|
|
95
|
+
Configuration file locations and formats vary by client; consult your client’s MCP setup instructions. Node and this npm package are not required for direct use. See Apple’s [Connecting an AI agent to Safari](https://developer.apple.com/documentation/safari-developer-tools/connecting-an-ai-agent-to-safari) for the official setup guide.
|
|
96
|
+
|
|
97
|
+
Direct connections expose Apple’s tools, **not** this extension’s `safari_*` interface or its bounds and safety checks. Review your client’s tool permissions before allowing browser actions. Do not assume normal Safari tabs or logins are available; page content sent to your AI client may be forwarded to its model provider.
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
[MIT](LICENSE).
|
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@haseebeqx/pi-safari",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "Safari automation tools for Pi using Apple's built-in local MCP server",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package"
|
|
9
|
+
],
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"scripts/smoke.ts",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"pi": {
|
|
16
|
+
"extensions": [
|
|
17
|
+
"./src/index.ts"
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
22
|
+
"typebox": "*"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/node": "^24.0.0",
|
|
26
|
+
"typescript": "^5.9.0",
|
|
27
|
+
"@earendil-works/pi-coding-agent": "1.2.0",
|
|
28
|
+
"typebox": "1.3.27"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "node --experimental-strip-types --test test/*.test.ts",
|
|
32
|
+
"typecheck": "tsc --noEmit",
|
|
33
|
+
"check": "npm test && npm run typecheck",
|
|
34
|
+
"prepublishOnly": "npm run check"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=22.19"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export const MAX_OUTPUT = 48 * 1024;
|
|
2
|
+
export function bound(value: unknown): string {
|
|
3
|
+
const text = JSON.stringify(value);
|
|
4
|
+
if (Buffer.byteLength(text) > MAX_OUTPUT) throw new Error('Safari output exceeded 48 KiB; request smaller limits.');
|
|
5
|
+
return text;
|
|
6
|
+
}
|
|
7
|
+
export function webURL(url: string): string {
|
|
8
|
+
const u = new URL(url);
|
|
9
|
+
if (!['http:', 'https:'].includes(u.protocol) || u.username || u.password || url.length > 8192) throw new Error('Only HTTP(S) URLs without embedded credentials are allowed.');
|
|
10
|
+
return u.href;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function readyJS(expectedUrl: string): string {
|
|
14
|
+
return `location.href===${JSON.stringify(expectedUrl)} && document.readyState==='complete'`;
|
|
15
|
+
}
|
|
16
|
+
// This script only extracts data. It never clicks, dispatches events, sets markers,
|
|
17
|
+
// or navigates the source context, regardless of its provenance or replacement.
|
|
18
|
+
export function linkJS(selector: string, expectedUrl: string): string {
|
|
19
|
+
if (selector.length < 1 || selector.length > 500) throw new Error('CSS selector must be 1..500 characters.');
|
|
20
|
+
webURL(expectedUrl);
|
|
21
|
+
return `JSON.stringify((()=>{if(location.href!==${JSON.stringify(expectedUrl)})throw Error('Page URL changed; read again');const es=document.querySelectorAll(${JSON.stringify(selector)});if(es.length!==1)throw Error('Selector must match exactly one element');const e=es[0];if(!e.matches('a[href]'))throw Error('Selector must match a link');if(!e.getClientRects().length||getComputedStyle(e).visibility!=='visible'||getComputedStyle(e).display==='none')throw Error('Link hidden');const u=new URL(e.href);if(!/^https?:$/.test(u.protocol)||u.username||u.password)throw Error('Only HTTP(S) links without credentials are allowed');return {href:u.href}})())`;
|
|
22
|
+
}
|
|
23
|
+
export function readJS(chars: number, links: number): string {
|
|
24
|
+
if (!Number.isInteger(chars) || chars < 1 || chars > 12000 || !Number.isInteger(links) || links < 0 || links > 40) throw new Error('maxChars must be 1..12000; maxLinks must be 0..40.');
|
|
25
|
+
return `JSON.stringify((()=>{const text=document.body?.innerText||''; const links=Array.from(document.querySelectorAll('a[href]')).filter(a=>a.getClientRects().length&&getComputedStyle(a).visibility==='visible'&&getComputedStyle(a).display!=='none'); return {url:location.href.slice(0,2048),title:document.title.slice(0,300),readyState:document.readyState,text:text.slice(0,${chars}),textTruncated:text.length>${chars},links:links.slice(0,${links}).map((a,index)=>({index,text:(a.innerText||a.textContent||'').slice(0,120),href:a.href.slice(0,1024)})),linksTruncated:links.length>${links}}})())`;
|
|
26
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Type } from 'typebox';
|
|
2
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
import { Safari } from './safari.ts';
|
|
4
|
+
import { bound } from './extraction.ts';
|
|
5
|
+
import { REGIONS, TIMES } from './search.ts';
|
|
6
|
+
|
|
7
|
+
const Target = {
|
|
8
|
+
targetId: Type.String({ minLength: 1, maxLength: 200, description: 'Opaque Safari tab handle from safari_tabs/open' }),
|
|
9
|
+
epoch: Type.String({ minLength: 1, maxLength: 100, description: 'Connection epoch returned with the target; relist after disconnect' }),
|
|
10
|
+
};
|
|
11
|
+
function result(data: unknown) {
|
|
12
|
+
return { content: [{ type: 'text' as const, text: bound(data) }], details: undefined };
|
|
13
|
+
}
|
|
14
|
+
export default function(pi: ExtensionAPI) {
|
|
15
|
+
const safari = new Safari();
|
|
16
|
+
pi.on('session_shutdown', async () => { safari.close(); });
|
|
17
|
+
pi.registerTool({
|
|
18
|
+
name: 'safari_tabs', label: 'Safari tabs',
|
|
19
|
+
description: 'List up to 100 tabs visible to Apple Safari MCP in its automation session. Returns explicit targetId + epoch. Does not read page bodies. Normal browsing tabs/logins are not assumed available.',
|
|
20
|
+
parameters: Type.Object({}), annotations: { readOnlyHint: true },
|
|
21
|
+
async execute(_id, _args, signal) { return result(await safari.tabs(signal)); },
|
|
22
|
+
});
|
|
23
|
+
pi.registerTool({
|
|
24
|
+
name: 'safari_open', label: 'Open Safari page',
|
|
25
|
+
description: 'Open an HTTP(S) URL in a NEW Safari automation tab. Never navigates an existing tab. Fresh browsing state is acceptable; normal Safari logins are not assumed. Exact-URL readiness required; redirects fail closed. Leaves tabs open; inspect before retrying a timed-out open.',
|
|
26
|
+
parameters: Type.Object({ url: Type.String({ maxLength: 8192 }) }),
|
|
27
|
+
async execute(_id, args, signal) { return result(await safari.open(args.url, signal)); },
|
|
28
|
+
});
|
|
29
|
+
pi.registerTool({
|
|
30
|
+
name: 'safari_close', label: 'Close Safari tab',
|
|
31
|
+
description: 'Close exactly one Safari automation tab by explicit targetId + epoch. Rejects stale targets; relist after disconnect. Does not close unidentified tabs or retry a failed close.',
|
|
32
|
+
parameters: Type.Object({ ...Target }), annotations: { destructiveHint: true, readOnlyHint: false },
|
|
33
|
+
async execute(_id, args, signal) { return result(await safari.closeTab(args, signal)); },
|
|
34
|
+
});
|
|
35
|
+
pi.registerTool({
|
|
36
|
+
name: 'safari_search', label: 'Search with Safari',
|
|
37
|
+
description: 'Search DuckDuckGo HTML in a NEW automation tab using deterministic URL controls. Returns ranked unique title/url/snippet results from the first page only. Region is a preference, time is any/d/w/m/y (past day/week/month/year). No arbitrary JS, clicks, forms or pagination. Exact URL required; redirects fail closed. Untrusted content; challenges/markup changes may return no results. Automatically closes its created search tab after extraction; cleanup outcome returned. Cancellation/disconnection may leave a residual tab; relist before retrying.',
|
|
38
|
+
parameters: Type.Object({
|
|
39
|
+
query: Type.String({ minLength: 1, maxLength: 500 }),
|
|
40
|
+
maxResults: Type.Optional(Type.Integer({ minimum: 1, maximum: 10 })),
|
|
41
|
+
region: Type.Optional(Type.Union(REGIONS.map(v => Type.Literal(v)))),
|
|
42
|
+
time: Type.Optional(Type.Union(TIMES.map(v => Type.Literal(v)))),
|
|
43
|
+
}),
|
|
44
|
+
async execute(_id, args, signal) { return result(await safari.search(args, signal)); },
|
|
45
|
+
});
|
|
46
|
+
pi.registerTool({
|
|
47
|
+
name: 'safari_read', label: 'Read Safari page',
|
|
48
|
+
description: 'Read visible body text and bounded visible link hrefs from an explicit Safari targetId + epoch. Page content is untrusted. No iframe/shadow DOM extraction. URL checks cannot detect same-URL document replacement. Requires Safari 27+ and Allow remote automation and external agents.',
|
|
49
|
+
parameters: Type.Object({ ...Target, maxChars: Type.Optional(Type.Integer({ minimum: 1, maximum: 12000 })), maxLinks: Type.Optional(Type.Integer({ minimum: 0, maximum: 40 })) }),
|
|
50
|
+
annotations: { readOnlyHint: true },
|
|
51
|
+
async execute(_id, args, signal) { return result(await safari.read(args, args.maxChars, args.maxLinks, signal)); },
|
|
52
|
+
});
|
|
53
|
+
pi.registerTool({
|
|
54
|
+
name: 'safari_follow_link', label: 'Follow Safari link in new tab',
|
|
55
|
+
description: 'Extract exactly one visible CSS-selected a[href] at the exact expectedUrl from safari_read, then open its HTTP(S) href in a NEW automation tab. Never clicks, invokes source handlers, submits forms, or navigates the source. Page content is untrusted; opening URLs may have server-side effects.',
|
|
56
|
+
parameters: Type.Object({ ...Target, selector: Type.String({ minLength: 1, maxLength: 500 }), expectedUrl: Type.String({ maxLength: 8192 }) }),
|
|
57
|
+
async execute(_id, args, signal) { return result(await safari.followLink(args, args.selector, args.expectedUrl, signal)); },
|
|
58
|
+
});
|
|
59
|
+
}
|
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { VERSION } from './version.ts';
|
|
2
|
+
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
export const TRANSPORT_LIMIT = 256 * 1024;
|
|
5
|
+
export interface ToolReply { content: { type: string; text?: string }[]; isError?: boolean }
|
|
6
|
+
export interface Transport {
|
|
7
|
+
readonly epoch: string;
|
|
8
|
+
call(name: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<ToolReply>;
|
|
9
|
+
close(): void;
|
|
10
|
+
}
|
|
11
|
+
const REQUIRED = ['list_tabs', 'create_tab', 'switch_tab', 'evaluate_javascript', 'page_info', 'close_tab'];
|
|
12
|
+
export class SafariMCP implements Transport {
|
|
13
|
+
epoch = randomUUID();
|
|
14
|
+
private child?: ChildProcessWithoutNullStreams;
|
|
15
|
+
private starting?: Promise<void>;
|
|
16
|
+
private nextId = 0;
|
|
17
|
+
private buffer = Buffer.alloc(0);
|
|
18
|
+
private pending = new Map<number, { resolve: (value: any) => void; reject: (e: Error) => void }>();
|
|
19
|
+
private idle?: NodeJS.Timeout;
|
|
20
|
+
private queue: Promise<unknown> = Promise.resolve();
|
|
21
|
+
constructor(privateOptions: { spawn?: typeof spawn; platform?: string; timeout?: number } = {}) { this.options = privateOptions; }
|
|
22
|
+
private options: { spawn?: typeof spawn; platform?: string; timeout?: number };
|
|
23
|
+
private stop(error = Error('Safari connection closed; relist tabs. Inspect tabs before retrying an open.')) {
|
|
24
|
+
const child = this.child; this.child = undefined; this.starting = undefined;
|
|
25
|
+
clearTimeout(this.idle); this.buffer = Buffer.alloc(0); this.epoch = randomUUID();
|
|
26
|
+
for (const p of this.pending.values()) p.reject(error); this.pending.clear();
|
|
27
|
+
child?.kill('SIGTERM');
|
|
28
|
+
}
|
|
29
|
+
close() { this.stop(); }
|
|
30
|
+
private async start() {
|
|
31
|
+
if (this.starting) return this.starting;
|
|
32
|
+
this.starting = (async () => {
|
|
33
|
+
if ((this.options.platform ?? process.platform) !== 'darwin') throw Error('Safari MCP requires macOS and Safari 27+.');
|
|
34
|
+
const child = (this.options.spawn ?? spawn)('/usr/bin/safaridriver', ['--mcp'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
35
|
+
this.child = child;
|
|
36
|
+
// Keep the extension from holding Pi open; an idle connection is retired.
|
|
37
|
+
child.unref();
|
|
38
|
+
child.stdin.on('error', () => { if (this.child === child) this.stop(Error('Safari MCP input disconnected.')); });
|
|
39
|
+
child.stdout.on('error', () => { if (this.child === child) this.stop(Error('Safari MCP output disconnected.')); });
|
|
40
|
+
child.stderr.on('error', () => {});
|
|
41
|
+
child.stderr.on('data', () => {}); // Never log page or driver payloads.
|
|
42
|
+
child.on('error', () => { if (this.child === child) this.stop(Error('Cannot start Safari MCP. Requires Safari 27+ and Allow remote automation and external agents.')); });
|
|
43
|
+
child.on('exit', () => { if (this.child === child) this.stop(Error('Safari MCP disconnected; enable Allow remote automation and external agents, then relist tabs.')); });
|
|
44
|
+
child.stdout.on('data', (chunk: Buffer) => {
|
|
45
|
+
if (this.child !== child) return;
|
|
46
|
+
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
47
|
+
if (this.buffer.length > TRANSPORT_LIMIT) return this.stop(Error('Safari MCP response exceeded 256 KiB.'));
|
|
48
|
+
let end: number;
|
|
49
|
+
while ((end = this.buffer.indexOf(10)) !== -1) {
|
|
50
|
+
const line = this.buffer.subarray(0, end).toString('utf8'); this.buffer = this.buffer.subarray(end + 1);
|
|
51
|
+
if (!line.trim()) continue;
|
|
52
|
+
try {
|
|
53
|
+
const m = JSON.parse(line);
|
|
54
|
+
if (m.jsonrpc !== '2.0' || typeof m !== 'object') throw Error();
|
|
55
|
+
if (m.id === undefined) continue; // Server notifications.
|
|
56
|
+
const p = this.pending.get(m.id);
|
|
57
|
+
if (!p || (Object.hasOwn(m, 'result') === Object.hasOwn(m, 'error'))) throw Error();
|
|
58
|
+
this.pending.delete(m.id);
|
|
59
|
+
// Avoid echoing arbitrary driver/page error payloads into model output.
|
|
60
|
+
if (m.error) p.reject(Error('Safari MCP rejected the request. Check Safari permissions and session state.'));
|
|
61
|
+
else p.resolve(m.result);
|
|
62
|
+
} catch { this.stop(Error('Malformed or mismatched Safari MCP reply.')); return; }
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
const hello = await this.rpc('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'pi-safari', version: VERSION } });
|
|
66
|
+
if (hello?.protocolVersion !== '2024-11-05' || hello?.serverInfo?.name !== 'Safari' || !hello?.capabilities?.tools) throw Error('Incompatible Safari MCP protocol.');
|
|
67
|
+
this.write({ jsonrpc: '2.0', method: 'notifications/initialized' });
|
|
68
|
+
const tools = await this.rpc('tools/list', {});
|
|
69
|
+
if (!Array.isArray(tools?.tools) || REQUIRED.some(name => !tools.tools.some((t: { name: string }) => t.name === name))) throw Error('Safari MCP is missing required tools; update Safari.');
|
|
70
|
+
const close = tools.tools.find((t: any) => t.name === 'close_tab')?.inputSchema;
|
|
71
|
+
if (close?.type !== 'object' || close.properties?.handle?.type !== 'string' || !Array.isArray(close.required) || !close.required.includes('handle') || close.required.some((key: string) => key !== 'handle')) throw Error('Incompatible Safari close_tab schema.');
|
|
72
|
+
})();
|
|
73
|
+
try { await this.starting; } catch (e) { this.stop(); throw e; }
|
|
74
|
+
}
|
|
75
|
+
private write(message: unknown) {
|
|
76
|
+
if (!this.child) throw Error('Safari MCP unavailable');
|
|
77
|
+
const child = this.child;
|
|
78
|
+
child.stdin.write(JSON.stringify(message) + '\n', e => { if (e && this.child === child) this.stop(Error('Safari MCP write failed.')); });
|
|
79
|
+
}
|
|
80
|
+
private rpc(method: string, params: unknown): Promise<any> {
|
|
81
|
+
const id = ++this.nextId;
|
|
82
|
+
return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); try { this.write({ jsonrpc: '2.0', id, method, params }); } catch (e) { this.pending.delete(id); reject(e); } });
|
|
83
|
+
}
|
|
84
|
+
call(name: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<ToolReply> {
|
|
85
|
+
if (!REQUIRED.includes(name)) return Promise.reject(Error('Unsupported internal Safari operation.'));
|
|
86
|
+
const next = this.queue.then(async () => {
|
|
87
|
+
if (signal?.aborted) throw Error('Safari operation cancelled.');
|
|
88
|
+
clearTimeout(this.idle);
|
|
89
|
+
const abort = () => this.stop(Error('Safari deadline/cancellation; relist tabs and inspect before retrying an open.'));
|
|
90
|
+
const timer = setTimeout(abort, this.options.timeout ?? 20000);
|
|
91
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
92
|
+
try {
|
|
93
|
+
await this.start();
|
|
94
|
+
const reply = await this.rpc('tools/call', { name, arguments: args });
|
|
95
|
+
if (!reply || !Array.isArray(reply.content) || reply.content.some((c: any) => c?.type !== 'text' || typeof c.text !== 'string')) throw Error('Invalid Safari tool result.');
|
|
96
|
+
if (reply.isError) throw Error('Safari operation failed. Check remote automation permission and relist tabs; inspect before retrying an open.');
|
|
97
|
+
return reply as ToolReply;
|
|
98
|
+
} finally {
|
|
99
|
+
clearTimeout(timer); signal?.removeEventListener('abort', abort);
|
|
100
|
+
this.idle = setTimeout(() => this.close(), 5 * 60_000); this.idle.unref();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
this.queue = next.catch(() => {}); return next;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export function toolJSON(reply: ToolReply): any {
|
|
107
|
+
if (reply.content.length !== 1 || typeof reply.content[0].text !== 'string') throw Error('Expected a single JSON Safari result.');
|
|
108
|
+
try { return JSON.parse(reply.content[0].text); } catch { throw Error('Safari returned an unexpected result format.'); }
|
|
109
|
+
}
|
package/src/safari.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { searchRequest, searchJS, validateSearch, type SearchOptions } from './search.ts';
|
|
2
|
+
import { SafariMCP, toolJSON, type Transport } from './mcp.ts';
|
|
3
|
+
import { bound, webURL, readJS, linkJS, readyJS } from './extraction.ts';
|
|
4
|
+
export interface Target { targetId: string; epoch: string }
|
|
5
|
+
const HANDLE = /^[A-Za-z0-9_-]{1,200}$/;
|
|
6
|
+
export class Safari {
|
|
7
|
+
private queue: Promise<unknown> = Promise.resolve();
|
|
8
|
+
private transport: Transport;
|
|
9
|
+
constructor(transport: Transport = new SafariMCP()) { this.transport = transport; }
|
|
10
|
+
close() { this.transport.close(); }
|
|
11
|
+
serial<T>(fn: () => Promise<T>): Promise<T> { const next = this.queue.then(fn); this.queue = next.catch(() => {}); return next; }
|
|
12
|
+
private operation<T>(fn: (signal: AbortSignal) => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
13
|
+
return this.serial(async () => {
|
|
14
|
+
if (signal?.aborted) throw Error('Safari operation cancelled.');
|
|
15
|
+
const deadline = AbortSignal.timeout(20000);
|
|
16
|
+
const combined = signal ? AbortSignal.any([signal, deadline]) : deadline;
|
|
17
|
+
return fn(combined);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
private async call(name: string, args: Record<string, unknown>, signal: AbortSignal): Promise<any> {
|
|
21
|
+
if (signal.aborted) throw Error('Safari deadline/cancellation; inspect tabs before retrying an open.');
|
|
22
|
+
return toolJSON(await this.transport.call(name, args, signal));
|
|
23
|
+
}
|
|
24
|
+
private async list(signal: AbortSignal): Promise<{ handle: string; url?: string; title?: string }[]> {
|
|
25
|
+
const tabs = await this.call('list_tabs', {}, signal);
|
|
26
|
+
if (!Array.isArray(tabs) || tabs.some(t => !t || typeof t.handle !== 'string' || !HANDLE.test(t.handle) || (t.url !== undefined && typeof t.url !== 'string') || (t.title !== undefined && typeof t.title !== 'string')) || new Set(tabs.map(t => t.handle)).size !== tabs.length) throw Error('Invalid Safari tab list.');
|
|
27
|
+
return tabs;
|
|
28
|
+
}
|
|
29
|
+
private target(t: Target) {
|
|
30
|
+
if (typeof t?.targetId !== 'string' || !HANDLE.test(t.targetId) || typeof t.epoch !== 'string' || t.epoch !== this.transport.epoch) throw Error('Invalid or stale Safari target; use targetId and epoch from safari_tabs/open.');
|
|
31
|
+
}
|
|
32
|
+
private async select(t: Target, signal: AbortSignal) {
|
|
33
|
+
this.target(t);
|
|
34
|
+
if (!(await this.list(signal)).some(tab => tab.handle === t.targetId)) throw Error('Safari target closed or unavailable; relist tabs.');
|
|
35
|
+
this.target(t); // A reconnect invalidates the old epoch even if a handle is reused.
|
|
36
|
+
// Safari returns a plain-text acknowledgement for switch_tab.
|
|
37
|
+
// The transport still validates its envelope and isError flag.
|
|
38
|
+
await this.transport.call('switch_tab', { handle: t.targetId }, signal);
|
|
39
|
+
this.target(t);
|
|
40
|
+
}
|
|
41
|
+
private async closeTarget(t: Target, signal: AbortSignal) {
|
|
42
|
+
this.target(t);
|
|
43
|
+
const exists = (await this.list(signal)).some(tab => tab.handle === t.targetId);
|
|
44
|
+
this.target(t);
|
|
45
|
+
if (!exists) return { status: 'already-absent' as const };
|
|
46
|
+
if (signal.aborted) throw Error('Safari close cancelled.');
|
|
47
|
+
// Apple's close_tab takes a handle and returns an acknowledgement, not JSON.
|
|
48
|
+
await this.transport.call('close_tab', { handle: t.targetId }, signal);
|
|
49
|
+
this.target(t);
|
|
50
|
+
return { status: 'closed' as const };
|
|
51
|
+
}
|
|
52
|
+
closeTab(t: Target, signal?: AbortSignal) {
|
|
53
|
+
return this.operation(s => this.closeTarget(t, s), signal);
|
|
54
|
+
}
|
|
55
|
+
private async cleanupSearch(t?: Target) {
|
|
56
|
+
if (!t) return { status: 'unidentified' as const, note: 'No identified created tab; inspect tabs before retrying.' };
|
|
57
|
+
if (t.epoch !== this.transport.epoch) return { status: 'skipped-retired-epoch' as const, note: 'Connection retired; relist tabs for manual cleanup.' };
|
|
58
|
+
try {
|
|
59
|
+
// Bounded cleanup may finish after cancellation, but never reconnects a retired target.
|
|
60
|
+
return await this.closeTarget(t, AbortSignal.timeout(3000));
|
|
61
|
+
} catch {
|
|
62
|
+
return { status: 'failed' as const, note: 'Cleanup unconfirmed; relist tabs before manual cleanup or retrying.' };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
private async evaluate(expression: string, signal: AbortSignal): Promise<any> {
|
|
66
|
+
const value = await this.call('evaluate_javascript', { expression: `return (${expression});` }, signal);
|
|
67
|
+
if (typeof value !== 'string') throw Error('Unexpected Safari extraction result.');
|
|
68
|
+
try { return JSON.parse(value); } catch { throw Error('Invalid Safari extraction JSON.'); }
|
|
69
|
+
}
|
|
70
|
+
tabs(signal?: AbortSignal) {
|
|
71
|
+
return this.operation(async s => {
|
|
72
|
+
const tabs = await this.list(s);
|
|
73
|
+
const result = { tabs: tabs.slice(0, 100).map(t => ({ targetId: t.handle, epoch: this.transport.epoch, ...(t.url === undefined ? {} : { url: t.url.slice(0, 2048) }), ...(t.title === undefined ? {} : { title: t.title.slice(0, 300) }) })), truncated: tabs.length > 100 };
|
|
74
|
+
bound(result); return result;
|
|
75
|
+
}, signal);
|
|
76
|
+
}
|
|
77
|
+
private async create(url: string, signal: AbortSignal, identified?: (t: Target) => void) {
|
|
78
|
+
const normalized = webURL(url);
|
|
79
|
+
const epoch = this.transport.epoch;
|
|
80
|
+
const created = await this.call('create_tab', { url: normalized }, signal);
|
|
81
|
+
if (typeof created?.handle !== 'string' || !HANDLE.test(created.handle)) throw Error('Cannot identify created tab; inspect tabs before retrying.');
|
|
82
|
+
const t = { targetId: created.handle, epoch };
|
|
83
|
+
identified?.(t);
|
|
84
|
+
const deadline = Date.now() + 12000;
|
|
85
|
+
while (Date.now() < deadline) {
|
|
86
|
+
await this.select(t, signal);
|
|
87
|
+
const info = await this.call('page_info', {}, signal);
|
|
88
|
+
if (typeof info?.url !== 'string') throw Error('Invalid Safari page info.');
|
|
89
|
+
if (info.url !== normalized && info.url !== 'about:blank') throw Error('Redirect or URL rewrite rejected; tab left open. Inspect tabs before retrying.');
|
|
90
|
+
if (info.url === normalized) {
|
|
91
|
+
const ready = await this.call('evaluate_javascript', { expression: `return (${readyJS(normalized)});` }, signal);
|
|
92
|
+
if (ready === true) { this.target(t); return { ...t, url: normalized, note: 'New automation tab. No ownership or document identity guarantee. Tab left open.' }; }
|
|
93
|
+
}
|
|
94
|
+
await new Promise<void>((resolve, reject) => {
|
|
95
|
+
const abort = () => { clearTimeout(timer); reject(Error('Safari operation cancelled; tab may remain open.')); };
|
|
96
|
+
const timer = setTimeout(() => { signal.removeEventListener('abort', abort); resolve(); }, 200);
|
|
97
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
98
|
+
if (signal.aborted) abort();
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
throw Error('Requested URL did not become ready; tab left open. Inspect tabs before retrying.');
|
|
102
|
+
}
|
|
103
|
+
open(url: string, signal?: AbortSignal) { webURL(url); return this.operation(s => this.create(url, s), signal); }
|
|
104
|
+
private async observe(t: Target, expression: string, signal: AbortSignal) {
|
|
105
|
+
await this.select(t, signal);
|
|
106
|
+
const before = await this.call('page_info', {}, signal);
|
|
107
|
+
if (typeof before?.url !== 'string') throw Error('Invalid Safari page info.');
|
|
108
|
+
webURL(before.url);
|
|
109
|
+
const data = await this.evaluate(expression, signal);
|
|
110
|
+
const after = await this.call('page_info', {}, signal);
|
|
111
|
+
this.target(t);
|
|
112
|
+
if (before.url !== after?.url || !(await this.list(signal)).some(tab => tab.handle === t.targetId && tab.url === before.url)) throw Error('Page changed during extraction; read again.');
|
|
113
|
+
this.target(t); bound(data);
|
|
114
|
+
return { data, url: before.url };
|
|
115
|
+
}
|
|
116
|
+
read(t: Target, chars = 8000, links = 20, signal?: AbortSignal) {
|
|
117
|
+
const expression = readJS(chars, links);
|
|
118
|
+
return this.operation(async s => {
|
|
119
|
+
const { data, url } = await this.observe(t, expression, s);
|
|
120
|
+
if (!data || data.url !== url.slice(0, 2048) || typeof data.text !== 'string' || data.text.length > chars || typeof data.title !== 'string' || data.title.length > 300 || !Array.isArray(data.links) || data.links.length > links || typeof data.textTruncated !== 'boolean' || typeof data.linksTruncated !== 'boolean') throw Error('Invalid Safari read result.');
|
|
121
|
+
const result = { ...data, targetId: t.targetId, epoch: t.epoch, identity: 'URL checks only; same-URL document replacement and live DOM changes may go undetected.' };
|
|
122
|
+
bound(result); return result;
|
|
123
|
+
}, signal);
|
|
124
|
+
}
|
|
125
|
+
search(options: SearchOptions, signal?: AbortSignal) {
|
|
126
|
+
const request = searchRequest(options);
|
|
127
|
+
const expression = searchJS(request.url, request.maxResults);
|
|
128
|
+
return this.operation(async s => {
|
|
129
|
+
let created: Target | undefined;
|
|
130
|
+
let result;
|
|
131
|
+
try {
|
|
132
|
+
const tab = await this.create(request.url, s, t => { created = t; });
|
|
133
|
+
const { data, url } = await this.observe(tab, expression, s);
|
|
134
|
+
if (url !== request.url) throw Error('Search URL changed.');
|
|
135
|
+
result = { ...request, ...validateSearch(data, request.url, request.maxResults), note: 'Untrusted provider ranking/content; first HTML page only. Empty results may indicate a challenge or changed markup. URL checks do not guarantee document identity.' };
|
|
136
|
+
bound(result);
|
|
137
|
+
if (s.aborted) throw Error('Safari search cancelled.');
|
|
138
|
+
} catch (error) {
|
|
139
|
+
const cleanup = await this.cleanupSearch(created);
|
|
140
|
+
// Retain the primary error (including its cause) when cleanup also fails.
|
|
141
|
+
const failure = new Error(`${error instanceof Error ? error.message : 'Safari search failed.'} Search cleanup: ${cleanup.status}.${'note' in cleanup ? ` ${cleanup.note}` : ''}`, { cause: error });
|
|
142
|
+
Object.assign(failure, { cleanup });
|
|
143
|
+
throw failure;
|
|
144
|
+
}
|
|
145
|
+
const cleanup = await this.cleanupSearch(created);
|
|
146
|
+
if (s.aborted) {
|
|
147
|
+
const failure = new Error(`Safari search cancelled. Search cleanup: ${cleanup.status}.`);
|
|
148
|
+
Object.assign(failure, { cleanup });
|
|
149
|
+
throw failure;
|
|
150
|
+
}
|
|
151
|
+
// Search results never advertise a usable target, even if cleanup is unconfirmed.
|
|
152
|
+
return { ...result, cleanup };
|
|
153
|
+
}, signal);
|
|
154
|
+
}
|
|
155
|
+
followLink(t: Target, selector: string, expectedUrl: string, signal?: AbortSignal) {
|
|
156
|
+
const expression = linkJS(selector, expectedUrl);
|
|
157
|
+
return this.operation(async s => {
|
|
158
|
+
const { data, url } = await this.observe(t, expression, s);
|
|
159
|
+
if (url !== expectedUrl || typeof data?.href !== 'string') throw Error('Source URL changed or invalid link result; read again.');
|
|
160
|
+
return this.create(webURL(data.href), s);
|
|
161
|
+
}, signal);
|
|
162
|
+
}
|
|
163
|
+
}
|
package/src/search.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { webURL } from './extraction.ts';
|
|
2
|
+
|
|
3
|
+
// DuckDuckGo HTML's documented URL controls; deliberately not arbitrary provider parameters.
|
|
4
|
+
export const REGIONS = ['wt-wt', 'us-en', 'uk-en', 'ca-en', 'au-en', 'de-de', 'fr-fr', 'in-en', 'jp-jp'] as const;
|
|
5
|
+
export const TIMES = ['any', 'd', 'w', 'm', 'y'] as const;
|
|
6
|
+
export interface SearchOptions { query: string; maxResults?: number; region?: string; time?: string }
|
|
7
|
+
export function searchRequest(options: SearchOptions) {
|
|
8
|
+
const { query, maxResults = 5, region = 'wt-wt', time = 'any' } = options;
|
|
9
|
+
if (typeof query !== 'string' || !query.trim() || query.length > 500 || /[\u0000-\u001f\u007f]/.test(query)) throw Error('Search query must be 1..500 characters without control characters.');
|
|
10
|
+
if (!Number.isInteger(maxResults) || maxResults < 1 || maxResults > 10) throw Error('maxResults must be 1..10.');
|
|
11
|
+
if (!(REGIONS as readonly string[]).includes(region) || !(TIMES as readonly string[]).includes(time)) throw Error('Unsupported search region or time filter.');
|
|
12
|
+
const url = new URL('https://html.duckduckgo.com/html/');
|
|
13
|
+
url.searchParams.set('q', query.trim()); url.searchParams.set('kl', region);
|
|
14
|
+
if (time !== 'any') url.searchParams.set('df', time);
|
|
15
|
+
return { query: query.trim(), maxResults, region, time, url: url.href };
|
|
16
|
+
}
|
|
17
|
+
export interface SearchResult { rank: number; title: string; url: string; snippet: string }
|
|
18
|
+
export function validateSearch(data: any, expectedUrl: string, maxResults: number): { results: SearchResult[]; truncated: boolean } {
|
|
19
|
+
if (!data || data.url !== expectedUrl || !Array.isArray(data.results) || data.results.length > maxResults || typeof data.truncated !== 'boolean') throw Error('Invalid Safari search result.');
|
|
20
|
+
const seen = new Set<string>();
|
|
21
|
+
for (const [i, r] of data.results.entries()) {
|
|
22
|
+
if (!r || r.rank !== i + 1 || typeof r.title !== 'string' || !r.title.trim() || r.title.length > 300 || typeof r.snippet !== 'string' || r.snippet.length > 600 || typeof r.url !== 'string' || r.url.length > 2048 || webURL(r.url) !== r.url || seen.has(r.url)) throw Error('Invalid Safari search entry.');
|
|
23
|
+
seen.add(r.url);
|
|
24
|
+
}
|
|
25
|
+
return { results: data.results, truncated: data.truncated };
|
|
26
|
+
}
|
|
27
|
+
// Fixed, read-only selectors. No raw query, arbitrary selector, click, form or provider script execution.
|
|
28
|
+
export function searchJS(expectedUrl: string, maxResults: number): string {
|
|
29
|
+
webURL(expectedUrl);
|
|
30
|
+
if (!Number.isInteger(maxResults) || maxResults < 1 || maxResults > 10) throw Error('maxResults must be 1..10.');
|
|
31
|
+
return `JSON.stringify((()=>{
|
|
32
|
+
if(location.href!==${JSON.stringify(expectedUrl)})throw Error('Search URL changed');
|
|
33
|
+
const visible=e=>e&&e.getClientRects().length&&getComputedStyle(e).visibility==='visible'&&getComputedStyle(e).display!=='none';
|
|
34
|
+
const text=(e,n)=>(e?.innerText||'').replace(/\\s+/g,' ').trim().slice(0,n);
|
|
35
|
+
const nodes=document.querySelectorAll('.result'); const results=[]; const seen=new Set(); let truncated=nodes.length>100;
|
|
36
|
+
for(const node of Array.from(nodes).slice(0,100)){
|
|
37
|
+
if(!visible(node)||node.matches('.result--ad'))continue;
|
|
38
|
+
const a=node.querySelector('a.result__a[href]'); if(!visible(a))continue;
|
|
39
|
+
let u; try {
|
|
40
|
+
u=new URL(a.href,location.href);
|
|
41
|
+
if(u.protocol==='https:'&&['duckduckgo.com','html.duckduckgo.com'].includes(u.hostname)&&u.pathname==='/l/'&&u.searchParams.has('uddg'))u=new URL(u.searchParams.get('uddg'));
|
|
42
|
+
}catch{continue;}
|
|
43
|
+
if(!/^https?:$/.test(u.protocol)||u.username||u.password||u.href.length>2048||['duckduckgo.com','html.duckduckgo.com'].includes(u.hostname)||seen.has(u.href))continue;
|
|
44
|
+
const title=text(a,300); if(!title)continue; seen.add(u.href);
|
|
45
|
+
if(results.length===${maxResults}){truncated=true;break;}
|
|
46
|
+
const snippet=node.querySelector('.result__snippet');
|
|
47
|
+
results.push({rank:results.length+1,title,url:u.href,snippet:visible(snippet)?text(snippet,600):''});
|
|
48
|
+
}
|
|
49
|
+
return {url:location.href,results,truncated};
|
|
50
|
+
})())`;
|
|
51
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const VERSION = '0.1.0';
|