@sasasin/fetch-url 0.0.0-stage → 0.1.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/CHANGELOG.md +26 -0
- package/LICENSE +21 -0
- package/README.md +216 -2
- package/fetch-core.ts +411 -0
- package/index.ts +144 -0
- package/package.json +65 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
- `tests/` is no longer shipped in the npm tarball. Node refuses to strip TypeScript types for
|
|
6
|
+
files under `node_modules`, so `node tests/selftest.mjs` could never work from an `npm:`-installed
|
|
7
|
+
copy (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), and `tests/test-load.mjs` needs jiti, which
|
|
8
|
+
lives in `devDependencies`. The scripts stay in the repository and run from a git clone
|
|
9
|
+
(`~/.pi/agent/git/github.com/sasasin/fetch_url`) or the source tree.
|
|
10
|
+
- README: the self-test section now says where the scripts can run, and notes that the extension
|
|
11
|
+
itself is unaffected because pi loads it through jiti.
|
|
12
|
+
|
|
13
|
+
## 0.1.0
|
|
14
|
+
|
|
15
|
+
Initial public release.
|
|
16
|
+
|
|
17
|
+
- `fetch_url` tool: fetch up to 16 URLs in one call through a single Chromium
|
|
18
|
+
using one isolated `BrowserContext` per URL (default 4 concurrent workers, max 8).
|
|
19
|
+
- Output modes: `aria` (accessibility snapshot), `text` (rendered innerText), `html` (page source).
|
|
20
|
+
- Per-page headers with HTTP status, redirect target, title and character count.
|
|
21
|
+
- Retries transient navigation failures once in a fresh context; permanent failures
|
|
22
|
+
(DNS, invalid URL, certificate errors) stop after a single attempt.
|
|
23
|
+
- Browser pick order `chrome` → `msedge` → embedded `chromium`, overridable with
|
|
24
|
+
`PI_FETCH_URL_CHANNEL`.
|
|
25
|
+
- Idle browser reuse with a 3-minute auto-close, closed on `session_shutdown`.
|
|
26
|
+
- Requires Node 22.19+ (the same floor as `@earendil-works/pi-coding-agent`); Node 20 is end-of-life since 2026-04-30 and is not tested.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shinnosuke Suzuki
|
|
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,217 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @sasasin/fetch-url
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A [pi](https://github.com/earendil-works/pi) coding-agent extension that adds a `fetch_url` tool:
|
|
4
|
+
it fetches many web pages **at the same time** by driving Playwright directly —
|
|
5
|
+
one Chromium process, one isolated `BrowserContext` per URL.
|
|
6
|
+
No MCP, no API billing, no external service.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
Requires Node **22.19+** (the same floor as pi itself) and a browser: system Chrome/Edge, or
|
|
11
|
+
Playwright's Chromium — see [Browser requirement](#browser-requirement).
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# from npm
|
|
15
|
+
pi install npm:@sasasin/fetch-url
|
|
16
|
+
|
|
17
|
+
# or straight from this repository (updated by `pi update --extensions`)
|
|
18
|
+
pi install git:github.com/sasasin/fetch_url
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Both sources install the extension's `dependencies` (`playwright-core`) automatically.
|
|
22
|
+
Restart pi, or run `/reload` in a running session.
|
|
23
|
+
|
|
24
|
+
> If you previously dropped this folder into `~/.pi/agent/extensions/fetch-url`,
|
|
25
|
+
> remove that copy first — loading both registers the tool name `fetch_url` twice.
|
|
26
|
+
> The same applies to installing the npm and the git source side by side.
|
|
27
|
+
|
|
28
|
+
### Where it is installed
|
|
29
|
+
|
|
30
|
+
pi puts the package (and its `node_modules`) under its agent directory, or under `.pi/` for
|
|
31
|
+
project scope:
|
|
32
|
+
|
|
33
|
+
| Install command | Extension folder (the place to `cd` into for maintenance) |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `pi install npm:@sasasin/fetch-url` | `~/.pi/agent/npm/node_modules/@sasasin/fetch-url` |
|
|
36
|
+
| `pi install -l npm:@sasasin/fetch-url` (project) | `<project>/.pi/npm/node_modules/@sasasin/fetch-url` |
|
|
37
|
+
| `pi install git:github.com/sasasin/fetch_url` | `~/.pi/agent/git/github.com/sasasin/fetch_url` |
|
|
38
|
+
| `pi install -l git:github.com/sasasin/fetch_url` (project) | `<project>/.pi/git/github.com/sasasin/fetch_url` |
|
|
39
|
+
| `pi -e npm:…` / `pi -e git:…` (one run, not persisted) | `~/.pi/agent/tmp/extensions/npm-<hash>/node_modules/@sasasin/fetch-url` / `~/.pi/agent/tmp/extensions/git-github.com-<hash>` |
|
|
40
|
+
| manual local extension folder | `~/.pi/agent/extensions/fetch-url` |
|
|
41
|
+
|
|
42
|
+
`-e` installs are throwaway and get a fresh hash per run.
|
|
43
|
+
|
|
44
|
+
### Browser requirement
|
|
45
|
+
|
|
46
|
+
`playwright-core` ships **without** browser binaries. The extension launches, in order:
|
|
47
|
+
|
|
48
|
+
1. system Google Chrome (`channel: "chrome"`)
|
|
49
|
+
2. Microsoft Edge (`channel: "msedge"`)
|
|
50
|
+
3. Playwright's bundled Chromium (`channel: "chromium"`) — to add it, run the installer
|
|
51
|
+
**from the package folder**, so the build matches this extension's `playwright-core` version:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
cd ~/.pi/agent/git/github.com/sasasin/fetch_url # npm source: ~/.pi/agent/npm/node_modules/@sasasin/fetch-url
|
|
55
|
+
npx --no-install playwright-core install chromium # → ~/.cache/ms-playwright/chromium-<build>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
A bare `npx playwright install chromium` resolves `playwright` from the registry instead and
|
|
59
|
+
may install a different Chromium build that this extension will not use.
|
|
60
|
+
|
|
61
|
+
If none of the three is available you get `browser launch failed: could not launch a browser (tried …)`.
|
|
62
|
+
The download directory honours `PLAYWRIGHT_BROWSERS_PATH`; it is shared with other Playwright users
|
|
63
|
+
on the machine, not inside the extension folder.
|
|
64
|
+
|
|
65
|
+
## Usage
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
fetch_url({
|
|
69
|
+
urls: ["https://a.example", "https://b.example", "https://c.example"],
|
|
70
|
+
mode: "text", // default aria
|
|
71
|
+
concurrency: 6, // 1-8, default 4
|
|
72
|
+
max_chars_per_page: 12000, // default 8000
|
|
73
|
+
timeout_ms: 45000 // default 30000
|
|
74
|
+
})
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Up to **16 URLs per call**; extras are reported as skipped. Results come back in input order:
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
--- https://example.com/ ---
|
|
81
|
+
[HTTP 200 | title: Example Domain | 170 chars]
|
|
82
|
+
# Example Domain
|
|
83
|
+
...
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The header carries the HTTP status, the final URL when a redirect happened, the page title and
|
|
87
|
+
the character count.
|
|
88
|
+
|
|
89
|
+
## Behaviour
|
|
90
|
+
|
|
91
|
+
- **Permanent failures stop after one attempt** (`ERR_NAME_NOT_RESOLVED`, invalid URL,
|
|
92
|
+
certificate errors, …). Transient ones retry once in a brand-new context (2 attempts by default).
|
|
93
|
+
- **Aborting a tool call** stops every worker within ~1.5 s; untouched URLs are reported as
|
|
94
|
+
`cancelled (aborted)`.
|
|
95
|
+
- The Chromium instance is **reused** and closed after 3 idle minutes, on `session_shutdown`,
|
|
96
|
+
and after a crash it is relaunched on the next call.
|
|
97
|
+
- SIGINT/SIGTERM/SIGHUP handlers are not installed by Playwright, so Ctrl-C in pi does not
|
|
98
|
+
take the browser down with it.
|
|
99
|
+
|
|
100
|
+
## Configuration (environment variables)
|
|
101
|
+
|
|
102
|
+
| Variable | Effect |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `PI_FETCH_URL_HEADLESS=0` | run headed (default: headless) |
|
|
105
|
+
| `PI_FETCH_URL_CHANNEL=chrome\|msedge\|chromium` | force one channel; `""` = embedded Chromium only |
|
|
106
|
+
| `PI_FETCH_URL_NO_SANDBOX=1` | `chromiumSandbox: false` (some containers / distros) |
|
|
107
|
+
|
|
108
|
+
## Keeping `playwright-core` current
|
|
109
|
+
|
|
110
|
+
The published range is `^1.64.1` and `package-lock.json` records the resolved version. There is no
|
|
111
|
+
in-extension self-update command:
|
|
112
|
+
|
|
113
|
+
- **Renovate** opens PRs for the dependency range, the lockfile (weekly `lockFileMaintenance`) and
|
|
114
|
+
the Actions in `.github/workflows/`; **tagpr** turns merged work into a tagged release.
|
|
115
|
+
- Installed with `pi install npm:` or `git:` → **`pi update --extensions`** reinstalls this package,
|
|
116
|
+
so `playwright-core` is fetched again. Then restart pi or run `/reload` so the extension module is
|
|
117
|
+
re-imported. For a git source pi also runs `git clean -fdx` before reinstalling, which is exactly
|
|
118
|
+
why an in-place `npm install` from inside the extension would not survive the next update.
|
|
119
|
+
- If you keep an unpacked development copy at `~/.pi/agent/extensions/fetch-url`, pi never touches
|
|
120
|
+
its dependencies (`docs/packages.md`: local packages are not installed or modified). Update that
|
|
121
|
+
copy yourself:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
npm install --omit=dev --prefix ~/.pi/agent/extensions/fetch-url
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Self-test without pi
|
|
128
|
+
|
|
129
|
+
Run these from a git clone (the git source lives at `~/.pi/agent/git/github.com/sasasin/fetch_url`)
|
|
130
|
+
or from the source tree — **not** from the `npm:` installed copy: Node refuses to strip types for
|
|
131
|
+
files under `node_modules`, so `selftest.mjs` cannot import `fetch-core.ts` there. The extension
|
|
132
|
+
itself is unaffected, because pi loads it through jiti. Hence `tests/` is kept out of the npm
|
|
133
|
+
tarball.
|
|
134
|
+
|
|
135
|
+
Needs Node 22.19+ (the same floor as pi itself — `selftest.mjs` relies on native type stripping):
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
npm install --omit=dev
|
|
139
|
+
node tests/selftest.mjs --concurrency 4 --mode text https://news.ycombinator.com/ https://quotes.toscrape.com/js/
|
|
140
|
+
node tests/selftest.mjs --abort-after 1500 --concurrency 2 https://httpbin.org/delay/8 https://example.com/
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
To check tool registration without starting a browser (uses jiti from `devDependencies`):
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
npm install
|
|
147
|
+
npm run test:load
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Releases and automated updates (tagpr + Renovate + Actions lockfile)
|
|
151
|
+
|
|
152
|
+
This repository keeps its dependencies fresh with three mechanisms:
|
|
153
|
+
|
|
154
|
+
- **tagpr** (`.github/workflows/tagpr.yml` + `.tagpr`) watches `main`, keeps one open release pull
|
|
155
|
+
request with the unreleased changes, and tags it (plus creates a GitHub Release) when you merge.
|
|
156
|
+
`tagpr.versionFile = package.json`, so the git tag and the npm version stay in sync; the tag is
|
|
157
|
+
what you publish from. `tagpr.changelog = false` because `CHANGELOG.md` is written by hand.
|
|
158
|
+
- **Renovate** (`renovate.json`) updates `dependencies` / `devDependencies` in `package.json`,
|
|
159
|
+
runs `lockFileMaintenance` weekly for `package-lock.json`, and bumps the Actions in
|
|
160
|
+
`.github/workflows/`. Renovate itself has to be installed as a GitHub App on the repository
|
|
161
|
+
(`github.com/apps/renovate`); `renovate.json` is only its configuration, and
|
|
162
|
+
`.github/workflows/renovate-config.yml` validates that file.
|
|
163
|
+
- **GitHub Actions workflow lockfile** (`.github/workflows/actions.lock`, GA since 2026-10-28).
|
|
164
|
+
Workflows keep readable references such as `actions/checkout@v7.0.1`, while the lockfile records
|
|
165
|
+
the commit that actually runs — including transitive action dependencies.
|
|
166
|
+
It is generated by the `gh actions-lock` CLI extension and must **never** be edited by hand:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
gh extension install github/gh-actions-lock --pin v0.4.1
|
|
170
|
+
gh actions-lock # onboard / refresh after changing any uses:
|
|
171
|
+
gh actions-lock --verify # the same check CI runs
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
When Renovate bumps an action it regenerates the matching lockfile entry in the same PR, so the
|
|
175
|
+
workflow file and `actions.lock` cannot drift apart (the extension version is fixed with
|
|
176
|
+
`constraints.ghActionsLock`). `.github/workflows/actions-lockfile.yml` fails CI if the lockfile
|
|
177
|
+
is stale or a pin no longer verifies upstream.
|
|
178
|
+
|
|
179
|
+
## Development
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
npm install # devDependencies (typescript, pi types, jiti) are not published
|
|
183
|
+
npm run check # typecheck + jiti load test
|
|
184
|
+
npm run selftest # optional live fetch
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
CI (`.github/workflows/ci.yml`) runs typecheck, the jiti load test and the live self-test on
|
|
188
|
+
Node 22 and 24.
|
|
189
|
+
|
|
190
|
+
Layout: `index.ts` is the thin pi tool-registration layer, `fetch-core.ts` holds the Playwright
|
|
191
|
+
lifecycle and the worker pool, and `tests/` carries the two test scripts (`selftest.mjs` for a
|
|
192
|
+
live parallel fetch, `test-load.mjs` for loading the extension through jiti the way pi does).
|
|
193
|
+
|
|
194
|
+
## 日本語
|
|
195
|
+
|
|
196
|
+
pi に `fetch_url` ツールを追加する拡張です。
|
|
197
|
+
専用の Chromium を起動して **URL ごとに独立した BrowserContext** を切るため、最大 16 URL を
|
|
198
|
+
既定 4(最大 8)ワーカーで同時並列に取得できます。課金なし・外部サービスなし。
|
|
199
|
+
|
|
200
|
+
インストールは `pi install npm:@sasasin/fetch-url`、またはこのリポジトリを直接
|
|
201
|
+
`pi install git:github.com/sasasin/fetch_url`(`pi update --extensions` で追随)。
|
|
202
|
+
設置先は `npm:` なら `~/.pi/agent/npm/node_modules/@sasasin/fetch-url`、
|
|
203
|
+
`git:` なら `~/.pi/agent/git/github.com/sasasin/fetch_url`(上に一覧表あり)。
|
|
204
|
+
`playwright-core` にはブラウザバイナリが含まれないため、システムの Chrome / Edge も無い環境では、
|
|
205
|
+
パッケージフォルダで `npx --no-install playwright-core install chromium` を実行して
|
|
206
|
+
`~/.cache/ms-playwright/` にブラウザを追加してください。素の `npx playwright install chromium` は
|
|
207
|
+
別バージョンの Chromium を入れることがあるので注意。
|
|
208
|
+
出力は `aria`(アクセシビリティスナップショット)/ `text`(innerText)/ `html`(生 HTML)。
|
|
209
|
+
依存更新は Renovate(`renovate.json`)と GitHub Actions の workflow lockfile
|
|
210
|
+
(`.github/workflows/actions.lock`、`gh actions-lock` で生成)で自動化しています。
|
|
211
|
+
リリースは tagpr(`.github/workflows/tagpr.yml`)が行い、`main` への push で release PR が
|
|
212
|
+
更新され、マージでタグと GitHub Release が作られます(`versionFile = package.json`)。
|
|
213
|
+
挙動・環境変数・`playwright-core` の追随(Renovate と `pi update --extensions`)の詳細は上の各節を参照してください。
|
|
214
|
+
|
|
215
|
+
## License
|
|
216
|
+
|
|
217
|
+
MIT
|
package/fetch-core.ts
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fetch-url コア: MCP を使わず playwright-core を直接呼び、複数 URL を並列取得する。
|
|
3
|
+
*
|
|
4
|
+
* ── MCP 経由の URL 取得との違い ──
|
|
5
|
+
* Playwright MCP はブラウザ 1 個・タブ 1 個しか持たないため、navigation+snapshot を
|
|
6
|
+
* プロセス内で直列化するしかない(実測: 10 並列で 9 失敗、6 並列で 5 失敗)。
|
|
7
|
+
* ここでは自分自身で chromium.launch() し、URL ごとに BrowserContext を作るので
|
|
8
|
+
* 1 browser / N context / N page で並列 navigation できる。
|
|
9
|
+
* context は cookie・localStorage・cache を共有しない独立性を持つ
|
|
10
|
+
* (「1 タブを順番に使い回す」という制約がない)。
|
|
11
|
+
*
|
|
12
|
+
* ── 並列設計 ──
|
|
13
|
+
* - URL リストは worker プール(既定 4、最大 8)で回す。
|
|
14
|
+
* - navigation 中の待機は I/O 待ちなので worker 数だけ同時に進行する。
|
|
15
|
+
* - 直列化はしない。ブラウザの再利用のみ getBrowser() で共有し、
|
|
16
|
+
* launch 直後の二重 launch は launching プロミスで束ねる。
|
|
17
|
+
* - 使わない間は IDLE_CLOSE_MS でブラウザを閉じる(pi が TUI で生きていても
|
|
18
|
+
* Chromium を常駐させない)。unref 済みタイマーなので process を留めない。
|
|
19
|
+
*
|
|
20
|
+
* ── 失敗処理 ──
|
|
21
|
+
* 恒久的に失敗する URL(DNS 解決不可・不正 URL・証明書エラー等)は
|
|
22
|
+
* context を作り直しても無駄なので即打ち切る。
|
|
23
|
+
* 一時的な失敗のみ、新しい context で 1 回だけ再試行する(既定 2 試行)。
|
|
24
|
+
* ブラウザがクラッシュして切断されている場合は getBrowser() が再接続(launch し直し)する。
|
|
25
|
+
*
|
|
26
|
+
* ── ブラウザバイナリ ──
|
|
27
|
+
* channel "chrome" でシステムの Google Chrome を使う。
|
|
28
|
+
* playwright-core 単体はブラウザをダウンロードしないため、無い環境では
|
|
29
|
+
* msedge → 内蔵 chromium にフォールバックし、それも無ければ
|
|
30
|
+
* `npx playwright install chromium` を案内する。
|
|
31
|
+
* env で上書き: PI_FETCH_URL_CHANNEL=chrome|msedge|chromium|"" ("" = 内蔵 chromium),
|
|
32
|
+
* PI_FETCH_URL_HEADLESS=0 で headed,
|
|
33
|
+
* PI_FETCH_URL_NO_SANDBOX=1 で chromiumSandbox: false。
|
|
34
|
+
*/
|
|
35
|
+
import { dirname } from "node:path";
|
|
36
|
+
import { fileURLToPath } from "node:url";
|
|
37
|
+
import type { Browser, Page } from "playwright-core";
|
|
38
|
+
|
|
39
|
+
export type Mode = "aria" | "text" | "html";
|
|
40
|
+
|
|
41
|
+
export const MAX_URLS = 16;
|
|
42
|
+
export const MAX_CONCURRENCY = 8;
|
|
43
|
+
export const NAV_ATTEMPTS = 2;
|
|
44
|
+
export const IDLE_CLOSE_MS = 3 * 60 * 1000;
|
|
45
|
+
|
|
46
|
+
export const DEFAULTS = {
|
|
47
|
+
mode: "aria" as Mode,
|
|
48
|
+
maxCharsPerPage: 8000,
|
|
49
|
+
concurrency: 4,
|
|
50
|
+
navTimeoutMs: 30000,
|
|
51
|
+
settleMs: 600,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** 作り直しても解決しない失敗(即打ち切る) */
|
|
55
|
+
const PERMANENT_NAV_ERROR =
|
|
56
|
+
/net::ERR_(NAME_NOT_RESOLVED|INTERNET_DISCONNECTED|CONNECTION_REFUSED|CONNECTION_RESET|ADDRESS_UNREACHABLE|UNREACHABLE_NETWORK|UNKNOWN_URL_SCHEME|INVALID_URL|CERT_|BLOCKED_BY|FILE_NOT_FOUND|ACCESS_DENIED|SCHEME_NOT_SUPPORTED)|ERR_(INVALID_URL|UNKNOWN_URL_SCHEME)|Cannot navigate to invalid URL/i;
|
|
57
|
+
|
|
58
|
+
function isPermanentFailure(message: string): boolean {
|
|
59
|
+
return PERMANENT_NAV_ERROR.test(message);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function oneLine(message: string): string {
|
|
63
|
+
return message.replace(/\s+/g, " ").slice(0, 240);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function messageOf(error: unknown): string {
|
|
67
|
+
if (error instanceof Error) return error.message;
|
|
68
|
+
return String(error);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─────────────────────────── playwright / browser lifecycle ───────────────────────────
|
|
72
|
+
|
|
73
|
+
type PlaywrightModule = typeof import("playwright-core");
|
|
74
|
+
|
|
75
|
+
let pw: PlaywrightModule | null = null;
|
|
76
|
+
let browser: Browser | null = null;
|
|
77
|
+
let browserKey = "";
|
|
78
|
+
let launching: Promise<Browser> | null = null;
|
|
79
|
+
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* このパッケージ自身のルートディレクトリ(エラー案内の文言に出す)。
|
|
83
|
+
* ローカル拡張として ~/.pi/agent/extensions/ に置いても、
|
|
84
|
+
* pi install された npm / git パッケージでも、import 元の位置から求める。
|
|
85
|
+
*/
|
|
86
|
+
export function packageDir(): string {
|
|
87
|
+
try {
|
|
88
|
+
return dirname(fileURLToPath(import.meta.url));
|
|
89
|
+
} catch {
|
|
90
|
+
return process.cwd();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 重い依存の読み込みはツール実行時まで遅らせる(拡張の factory では何もしない) */
|
|
95
|
+
async function loadPlaywright(): Promise<PlaywrightModule> {
|
|
96
|
+
if (pw) return pw;
|
|
97
|
+
try {
|
|
98
|
+
pw = await import("playwright-core");
|
|
99
|
+
} catch (error) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`cannot import playwright-core (${oneLine(messageOf(error))}). ` +
|
|
102
|
+
`Fix: npm install --omit=dev --prefix ${packageDir()}`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
return pw;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function headlessDefault(): boolean {
|
|
109
|
+
return process.env.PI_FETCH_URL_HEADLESS !== "0";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function channelCandidates(): Array<string | undefined> {
|
|
113
|
+
const fromEnv = process.env.PI_FETCH_URL_CHANNEL;
|
|
114
|
+
if (fromEnv !== undefined) return [fromEnv === "" ? undefined : fromEnv];
|
|
115
|
+
// playwright-core にはブラウザバイナリが同梱されないため、内蔵 chromium は最後に試す。
|
|
116
|
+
return ["chrome", "msedge", "chromium"];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function launchBrowser(headless: boolean): Promise<Browser> {
|
|
120
|
+
const module = await loadPlaywright();
|
|
121
|
+
const options = {
|
|
122
|
+
headless,
|
|
123
|
+
// 共通の launch 引数。AutomationControlled を伏せて bot 判定の足場を減らす。
|
|
124
|
+
args: ["--disable-blink-features=AutomationControlled"],
|
|
125
|
+
chromiumSandbox: process.env.PI_FETCH_URL_NO_SANDBOX !== "1",
|
|
126
|
+
// pi プロセスの SIGINT/SIGTERM で Chromium を巻き込まない
|
|
127
|
+
handleSIGINT: false,
|
|
128
|
+
handleSIGTERM: false,
|
|
129
|
+
handleSIGHUP: false,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const tried: string[] = [];
|
|
133
|
+
let lastError = "";
|
|
134
|
+
for (const channel of channelCandidates()) {
|
|
135
|
+
tried.push(channel ?? "embedded chromium");
|
|
136
|
+
try {
|
|
137
|
+
return await module.chromium.launch({ ...options, channel });
|
|
138
|
+
} catch (error) {
|
|
139
|
+
lastError = oneLine(messageOf(error));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
throw new Error(
|
|
143
|
+
`could not launch a browser (tried ${tried.join(", ")}): ${lastError}. ` +
|
|
144
|
+
`Fallback: run \`npx playwright install chromium\` in ${packageDir()}, ` +
|
|
145
|
+
`or point PI_FETCH_URL_CHANNEL at an installed browser.`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function clearIdleTimer(): void {
|
|
150
|
+
if (!idleTimer) return;
|
|
151
|
+
clearTimeout(idleTimer);
|
|
152
|
+
idleTimer = null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function scheduleIdleClose(): void {
|
|
156
|
+
clearIdleTimer();
|
|
157
|
+
idleTimer = setTimeout(() => {
|
|
158
|
+
void closeBrowser();
|
|
159
|
+
}, IDLE_CLOSE_MS);
|
|
160
|
+
idleTimer.unref?.();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 冪等。reload / session_shutdown / cancel が同じ経路に converge しても安全 */
|
|
164
|
+
export async function closeBrowser(): Promise<void> {
|
|
165
|
+
clearIdleTimer();
|
|
166
|
+
const current = browser;
|
|
167
|
+
const pending = launching;
|
|
168
|
+
browser = null;
|
|
169
|
+
browserKey = "";
|
|
170
|
+
launching = null;
|
|
171
|
+
if (pending) {
|
|
172
|
+
try {
|
|
173
|
+
await pending;
|
|
174
|
+
} catch {
|
|
175
|
+
/* launch 失敗は握りつぶす */
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (current) {
|
|
179
|
+
try {
|
|
180
|
+
await current.close();
|
|
181
|
+
} catch {
|
|
182
|
+
/* もう閉じている */
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* ブラウザ共有。headless 設定が変わったら作り直す。
|
|
189
|
+
* クラッシュで切断されている場合は launch し直す(MCP 版での tab reset 相当)。
|
|
190
|
+
*/
|
|
191
|
+
async function getBrowser(headless: boolean): Promise<Browser> {
|
|
192
|
+
clearIdleTimer();
|
|
193
|
+
const key = headless ? "headless" : "headed";
|
|
194
|
+
if (browser && browserKey === key && browser.isConnected()) return browser;
|
|
195
|
+
if (browser) await closeBrowser();
|
|
196
|
+
if (!launching) {
|
|
197
|
+
const started = launchBrowser(headless).then(
|
|
198
|
+
(launched) => {
|
|
199
|
+
browser = launched;
|
|
200
|
+
browserKey = key;
|
|
201
|
+
launching = null;
|
|
202
|
+
scheduleIdleClose();
|
|
203
|
+
return launched;
|
|
204
|
+
},
|
|
205
|
+
(error) => {
|
|
206
|
+
launching = null;
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
);
|
|
210
|
+
launching = started;
|
|
211
|
+
}
|
|
212
|
+
return launching;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** headless Chromium の UA に HeadlessChrome と出て bot 判定されやすいので整える */
|
|
216
|
+
function userAgentFor(instance: Browser): string {
|
|
217
|
+
const version = instance.version?.() ?? "";
|
|
218
|
+
const major = version.split(".")[0] || "131";
|
|
219
|
+
const platform =
|
|
220
|
+
process.platform === "darwin"
|
|
221
|
+
? "Macintosh; Intel Mac OS X 10_15_7"
|
|
222
|
+
: process.platform === "win32"
|
|
223
|
+
? "Windows NT 10.0; Win64; x64"
|
|
224
|
+
: "X11; Linux x86_64";
|
|
225
|
+
return `Mozilla/5.0 (${platform}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ─────────────────────────── fetch ───────────────────────────
|
|
229
|
+
|
|
230
|
+
export type PageFetch = {
|
|
231
|
+
url: string;
|
|
232
|
+
ok: boolean;
|
|
233
|
+
status?: number;
|
|
234
|
+
finalUrl?: string;
|
|
235
|
+
title?: string;
|
|
236
|
+
body: string;
|
|
237
|
+
characters: number;
|
|
238
|
+
attempts: number;
|
|
239
|
+
note: string;
|
|
240
|
+
ms: number;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
type Resolved = typeof DEFAULTS & { signal?: AbortSignal };
|
|
244
|
+
|
|
245
|
+
async function extract(page: Page, mode: Mode): Promise<string> {
|
|
246
|
+
if (mode === "html") return await page.content();
|
|
247
|
+
if (mode === "text") {
|
|
248
|
+
return await page.evaluate(() => document.body?.innerText ?? document.documentElement?.innerText ?? "");
|
|
249
|
+
}
|
|
250
|
+
// aria: Playwright 内蔵の accessibility ツリー YAML。MCP の browser_snapshot とほぼ同じ形。
|
|
251
|
+
try {
|
|
252
|
+
return await page.locator("body").ariaSnapshot();
|
|
253
|
+
} catch {
|
|
254
|
+
return await page.evaluate(() => document.body?.innerText ?? "");
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
type SingleOutcome =
|
|
259
|
+
| { ok: true; status?: number; finalUrl: string; title: string; body: string }
|
|
260
|
+
| { ok: false; message: string };
|
|
261
|
+
|
|
262
|
+
/** 1 URL を独立した context で 1 回。context は最後に必ず閉じる(ページと storage が残らないようにする) */
|
|
263
|
+
async function fetchOnce(instance: Browser, url: string, o: Resolved): Promise<SingleOutcome> {
|
|
264
|
+
const context = await instance.newContext({
|
|
265
|
+
userAgent: userAgentFor(instance),
|
|
266
|
+
viewport: null,
|
|
267
|
+
});
|
|
268
|
+
const page = await context.newPage();
|
|
269
|
+
const signal = o.signal;
|
|
270
|
+
const onAbort = () => {
|
|
271
|
+
void page.close({ runBeforeUnload: false }).catch(() => {});
|
|
272
|
+
};
|
|
273
|
+
if (signal) {
|
|
274
|
+
if (signal.aborted) onAbort();
|
|
275
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: o.navTimeoutMs });
|
|
279
|
+
// SPA は domcontentloaded 時点では空なので load まで待つ(ハングしても落とさない)。
|
|
280
|
+
await page.waitForLoadState("load", { timeout: Math.min(o.navTimeoutMs, 10000) }).catch(() => {});
|
|
281
|
+
if (o.settleMs > 0) await page.waitForTimeout(o.settleMs).catch(() => {});
|
|
282
|
+
const body = await extract(page, o.mode);
|
|
283
|
+
const title = await page.title().catch(() => "");
|
|
284
|
+
return {
|
|
285
|
+
ok: true,
|
|
286
|
+
status: response?.status(),
|
|
287
|
+
finalUrl: page.url(),
|
|
288
|
+
title,
|
|
289
|
+
body,
|
|
290
|
+
};
|
|
291
|
+
} catch (error) {
|
|
292
|
+
return { ok: false, message: oneLine(messageOf(error)) };
|
|
293
|
+
} finally {
|
|
294
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
295
|
+
await context.close().catch(() => {});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function fetchOne(url: string, o: Resolved): Promise<PageFetch> {
|
|
300
|
+
const startedAt = Date.now();
|
|
301
|
+
const steps: string[] = [];
|
|
302
|
+
let lastError = "";
|
|
303
|
+
|
|
304
|
+
for (let attempt = 1; attempt <= NAV_ATTEMPTS; attempt++) {
|
|
305
|
+
let instance: Browser;
|
|
306
|
+
try {
|
|
307
|
+
instance = await getBrowser(headlessDefault());
|
|
308
|
+
} catch (error) {
|
|
309
|
+
return {
|
|
310
|
+
url,
|
|
311
|
+
ok: false,
|
|
312
|
+
body: "",
|
|
313
|
+
characters: 0,
|
|
314
|
+
attempts: attempt,
|
|
315
|
+
note: `browser launch failed: ${oneLine(messageOf(error))}`,
|
|
316
|
+
ms: Date.now() - startedAt,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const outcome = await fetchOnce(instance, url, o);
|
|
321
|
+
if (outcome.ok) {
|
|
322
|
+
const characters = outcome.body.length;
|
|
323
|
+
const body =
|
|
324
|
+
characters > o.maxCharsPerPage
|
|
325
|
+
? `${outcome.body.slice(0, o.maxCharsPerPage)}\n[trimmed: ${characters} characters total]`
|
|
326
|
+
: outcome.body || "(empty page)";
|
|
327
|
+
return {
|
|
328
|
+
url,
|
|
329
|
+
ok: true,
|
|
330
|
+
status: outcome.status,
|
|
331
|
+
finalUrl: outcome.finalUrl,
|
|
332
|
+
title: outcome.title,
|
|
333
|
+
body,
|
|
334
|
+
characters,
|
|
335
|
+
attempts: attempt,
|
|
336
|
+
note: steps.length ? `recovered on attempt ${attempt}: ${steps.join(", ")}` : "",
|
|
337
|
+
ms: Date.now() - startedAt,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const aborted = Boolean(o.signal?.aborted);
|
|
342
|
+
const permanent = isPermanentFailure(outcome.message) || aborted;
|
|
343
|
+
if (permanent) {
|
|
344
|
+
return {
|
|
345
|
+
url,
|
|
346
|
+
ok: false,
|
|
347
|
+
body: "",
|
|
348
|
+
characters: 0,
|
|
349
|
+
attempts: attempt,
|
|
350
|
+
note: aborted
|
|
351
|
+
? "cancelled (aborted)"
|
|
352
|
+
: `navigation failed (permanent, no retry): ${outcome.message}`,
|
|
353
|
+
ms: Date.now() - startedAt,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
lastError = outcome.message;
|
|
357
|
+
if (attempt < NAV_ATTEMPTS) steps.push(`fresh context (attempt ${attempt + 1})`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
url,
|
|
362
|
+
ok: false,
|
|
363
|
+
body: "",
|
|
364
|
+
characters: 0,
|
|
365
|
+
attempts: NAV_ATTEMPTS,
|
|
366
|
+
note: `navigation failed after ${NAV_ATTEMPTS} attempts (${steps.join(" -> ") || "no retry"}): ${lastError}`,
|
|
367
|
+
ms: Date.now() - startedAt,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** worker プール。結果は入力順のまま返す(完了順ではない) */
|
|
372
|
+
export async function fetchPages(
|
|
373
|
+
urls: string[],
|
|
374
|
+
options: Partial<typeof DEFAULTS> = {},
|
|
375
|
+
hooks: {
|
|
376
|
+
signal?: AbortSignal;
|
|
377
|
+
onPage?: (result: PageFetch, index: number, total: number) => void;
|
|
378
|
+
} = {}
|
|
379
|
+
): Promise<PageFetch[]> {
|
|
380
|
+
const o: Resolved = { ...DEFAULTS, ...options, signal: hooks.signal };
|
|
381
|
+
const total = urls.length;
|
|
382
|
+
const results: Array<PageFetch | undefined> = new Array(total).fill(undefined);
|
|
383
|
+
if (total === 0) return [];
|
|
384
|
+
|
|
385
|
+
const limit = Math.max(1, Math.min(o.concurrency, MAX_CONCURRENCY, total));
|
|
386
|
+
let cursor = 0;
|
|
387
|
+
const worker = async () => {
|
|
388
|
+
for (;;) {
|
|
389
|
+
if (hooks.signal?.aborted) return;
|
|
390
|
+
const index = cursor++;
|
|
391
|
+
if (index >= total) return;
|
|
392
|
+
const result = await fetchOne(urls[index], o);
|
|
393
|
+
results[index] = result;
|
|
394
|
+
hooks.onPage?.(result, index, total);
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
await Promise.all(Array.from({ length: limit }, worker));
|
|
398
|
+
|
|
399
|
+
return results.map(
|
|
400
|
+
(result, index) =>
|
|
401
|
+
result ?? {
|
|
402
|
+
url: urls[index],
|
|
403
|
+
ok: false,
|
|
404
|
+
body: "",
|
|
405
|
+
characters: 0,
|
|
406
|
+
attempts: 0,
|
|
407
|
+
note: "cancelled (aborted)",
|
|
408
|
+
ms: 0,
|
|
409
|
+
}
|
|
410
|
+
);
|
|
411
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fetch-url: 複数 URL を並列で取得する pi 拡張ツール。
|
|
3
|
+
*
|
|
4
|
+
* 実装は fetch-core.ts(playwright-core を直接操作)。このファイルは
|
|
5
|
+
* ツール登録と結果レンダリングだけの薄い層。
|
|
6
|
+
*
|
|
7
|
+
* MCP 経由の URL 取得(タブ 1 個を直列に使う)に対して、
|
|
8
|
+
* fetch_url は自分専用の Chromium を launch し、URL ごとに独立した
|
|
9
|
+
* BrowserContext を開くので同時並列で取得できる。
|
|
10
|
+
*/
|
|
11
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { Type } from "typebox";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
closeBrowser,
|
|
16
|
+
DEFAULTS,
|
|
17
|
+
MAX_CONCURRENCY,
|
|
18
|
+
MAX_URLS,
|
|
19
|
+
fetchPages,
|
|
20
|
+
type PageFetch,
|
|
21
|
+
} from "./fetch-core.ts";
|
|
22
|
+
|
|
23
|
+
function renderOne(result: PageFetch): string {
|
|
24
|
+
const header = `--- ${result.url} ---`;
|
|
25
|
+
if (!result.ok) {
|
|
26
|
+
return `${header}\nFETCH FAILED (${result.ms}ms) ${result.note}`;
|
|
27
|
+
}
|
|
28
|
+
const meta = [
|
|
29
|
+
result.status !== undefined ? `HTTP ${result.status}` : "",
|
|
30
|
+
result.finalUrl && result.finalUrl !== result.url ? `redirected to ${result.finalUrl}` : "",
|
|
31
|
+
result.title ? `title: ${result.title}` : "",
|
|
32
|
+
`${result.characters} chars`,
|
|
33
|
+
result.note,
|
|
34
|
+
]
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.join(" | ");
|
|
37
|
+
return `${header}\n[${meta}]\n${result.body}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export default function (pi: ExtensionAPI) {
|
|
41
|
+
pi.registerTool({
|
|
42
|
+
name: "fetch_url",
|
|
43
|
+
label: "Fetch URLs (parallel Playwright)",
|
|
44
|
+
description:
|
|
45
|
+
"Fetch several web pages at once by driving Playwright directly (no MCP, no API billing). " +
|
|
46
|
+
"One Chromium serves an isolated BrowserContext per URL, so navigation runs concurrently " +
|
|
47
|
+
"(default 4 workers, max 8) instead of being serialized through one shared browser tab. " +
|
|
48
|
+
"Returns an accessibility snapshot per page by default; mode \"text\" gives rendered inner text.",
|
|
49
|
+
promptSnippet: "Fetch multiple web pages concurrently via a local Playwright browser",
|
|
50
|
+
promptGuidelines: [
|
|
51
|
+
"Use fetch_url when you need several URLs at once: it fetches concurrently (up to 8 contexts), unlike single-tab MCP browser tools which serialize navigation.",
|
|
52
|
+
"mode \"text\" is best for article prose, \"aria\" keeps structure and links, \"html\" returns raw markup; raise max_chars_per_page when output is trimmed.",
|
|
53
|
+
"fetch_url launches its own Chromium and is independent of any Playwright MCP browser; set PI_FETCH_URL_HEADLESS=0 for headed browsing or PI_FETCH_URL_CHANNEL to pick another browser.",
|
|
54
|
+
],
|
|
55
|
+
parameters: Type.Object({
|
|
56
|
+
urls: Type.Array(Type.String(), {
|
|
57
|
+
description: `URLs to fetch concurrently. At most ${MAX_URLS} per call; extras are skipped.`,
|
|
58
|
+
}),
|
|
59
|
+
mode: Type.Optional(
|
|
60
|
+
Type.Union([Type.Literal("aria"), Type.Literal("text"), Type.Literal("html")], {
|
|
61
|
+
description:
|
|
62
|
+
`"aria" (default) = accessibility snapshot YAML (same shape as Playwright MCP browser_snapshot), "text" = rendered innerText, "html" = page source.`,
|
|
63
|
+
})
|
|
64
|
+
),
|
|
65
|
+
max_chars_per_page: Type.Optional(
|
|
66
|
+
Type.Number({
|
|
67
|
+
description: `Trim each page to this many characters (default ${DEFAULTS.maxCharsPerPage}).`,
|
|
68
|
+
})
|
|
69
|
+
),
|
|
70
|
+
concurrency: Type.Optional(
|
|
71
|
+
Type.Number({
|
|
72
|
+
description: `How many pages to load at once (1-${MAX_CONCURRENCY}, default ${DEFAULTS.concurrency}).`,
|
|
73
|
+
})
|
|
74
|
+
),
|
|
75
|
+
timeout_ms: Type.Optional(
|
|
76
|
+
Type.Number({
|
|
77
|
+
description: `Per-URL navigation timeout in milliseconds (default ${DEFAULTS.navTimeoutMs}).`,
|
|
78
|
+
})
|
|
79
|
+
),
|
|
80
|
+
}),
|
|
81
|
+
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
|
|
82
|
+
const requested: string[] = params.urls ?? [];
|
|
83
|
+
const urls = requested.slice(0, MAX_URLS);
|
|
84
|
+
const startedAt = Date.now();
|
|
85
|
+
let done = 0;
|
|
86
|
+
|
|
87
|
+
const results = await fetchPages(
|
|
88
|
+
urls,
|
|
89
|
+
{
|
|
90
|
+
mode: params.mode ?? DEFAULTS.mode,
|
|
91
|
+
maxCharsPerPage: params.max_chars_per_page ?? DEFAULTS.maxCharsPerPage,
|
|
92
|
+
concurrency: params.concurrency ?? DEFAULTS.concurrency,
|
|
93
|
+
navTimeoutMs: params.timeout_ms ?? DEFAULTS.navTimeoutMs,
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
signal,
|
|
97
|
+
onPage: (result) => {
|
|
98
|
+
done += 1;
|
|
99
|
+
onUpdate?.({
|
|
100
|
+
content: [
|
|
101
|
+
{
|
|
102
|
+
type: "text",
|
|
103
|
+
text: `${result.ok ? "ok" : "failed"} ${done}/${urls.length}: ${result.url} (${result.ms}ms)`,
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
details: {
|
|
107
|
+
provider: "playwright",
|
|
108
|
+
product: "chromium",
|
|
109
|
+
mode: params.mode ?? DEFAULTS.mode,
|
|
110
|
+
fetched: done,
|
|
111
|
+
total: urls.length,
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const sections = results.map(renderOne);
|
|
119
|
+
if (requested.length > urls.length) {
|
|
120
|
+
sections.push(`[skipped ${requested.length - urls.length} URLs: over the ${MAX_URLS} per-call limit]`);
|
|
121
|
+
}
|
|
122
|
+
const okCount = results.filter((result) => result.ok).length;
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
content: [{ type: "text", text: sections.join("\n\n") || "No URLs were fetched." }],
|
|
126
|
+
details: {
|
|
127
|
+
provider: "playwright",
|
|
128
|
+
product: "chromium",
|
|
129
|
+
mode: params.mode ?? DEFAULTS.mode,
|
|
130
|
+
concurrency: Math.min(params.concurrency ?? DEFAULTS.concurrency, MAX_CONCURRENCY),
|
|
131
|
+
urls,
|
|
132
|
+
ok: okCount,
|
|
133
|
+
failed: results.length - okCount,
|
|
134
|
+
ms: Date.now() - startedAt,
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// reload / session 終了 / プロセス終了で Chromium を残さない。冪等。
|
|
141
|
+
pi.on("session_shutdown", async () => {
|
|
142
|
+
await closeBrowser();
|
|
143
|
+
});
|
|
144
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,67 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sasasin/fetch-url",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Pi extension that fetches many web pages at once by driving Playwright directly: one Chromium, one BrowserContext per URL, concurrent navigation, no MCP and no API billing.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi",
|
|
10
|
+
"pi-extension",
|
|
11
|
+
"pi-coding-agent",
|
|
12
|
+
"fetch",
|
|
13
|
+
"web",
|
|
14
|
+
"url",
|
|
15
|
+
"scraping",
|
|
16
|
+
"playwright",
|
|
17
|
+
"parallel",
|
|
18
|
+
"accessibility-snapshot"
|
|
19
|
+
],
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/sasasin/fetch_url.git"
|
|
23
|
+
},
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/sasasin/fetch_url/issues"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/sasasin/fetch_url#readme",
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22.19.0"
|
|
30
|
+
},
|
|
31
|
+
"pi": {
|
|
32
|
+
"extensions": [
|
|
33
|
+
"./index.ts"
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"index.ts",
|
|
38
|
+
"fetch-core.ts",
|
|
39
|
+
"README.md",
|
|
40
|
+
"CHANGELOG.md",
|
|
41
|
+
"LICENSE"
|
|
42
|
+
],
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
48
|
+
"test:load": "node tests/test-load.mjs",
|
|
49
|
+
"selftest": "node tests/selftest.mjs --concurrency 4 --mode text https://example.com/ https://news.ycombinator.com/",
|
|
50
|
+
"test": "npm run typecheck && npm run test:load",
|
|
51
|
+
"check": "npm run test"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"playwright-core": "^1.64.1"
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
58
|
+
"typebox": "*"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@earendil-works/pi-coding-agent": "^1.4.0",
|
|
62
|
+
"@types/node": "^24.0.0",
|
|
63
|
+
"typebox": "^1.3.27",
|
|
64
|
+
"typescript": "^5.9.0",
|
|
65
|
+
"jiti": "2.7.0"
|
|
66
|
+
}
|
|
67
|
+
}
|