patchrome 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 +514 -0
- package/bin/patchrome.js +10 -0
- package/dist/build-id.d.ts +2 -0
- package/dist/build-id.js +21 -0
- package/dist/challenges.d.ts +22 -0
- package/dist/challenges.js +97 -0
- package/dist/chrome-profiles.d.ts +17 -0
- package/dist/chrome-profiles.js +141 -0
- package/dist/cli-options.d.ts +131 -0
- package/dist/cli-options.js +43 -0
- package/dist/cli.d.ts +48 -0
- package/dist/cli.js +572 -0
- package/dist/client.d.ts +16 -0
- package/dist/client.js +210 -0
- package/dist/commands.d.ts +58 -0
- package/dist/commands.js +1076 -0
- package/dist/completions.d.ts +1 -0
- package/dist/completions.js +114 -0
- package/dist/copy-guard.d.ts +75 -0
- package/dist/copy-guard.js +167 -0
- package/dist/daemon.d.ts +7 -0
- package/dist/daemon.js +313 -0
- package/dist/diagnostics.d.ts +44 -0
- package/dist/diagnostics.js +117 -0
- package/dist/engine.d.ts +51 -0
- package/dist/engine.js +257 -0
- package/dist/events.d.ts +41 -0
- package/dist/events.js +106 -0
- package/dist/extract.d.ts +27 -0
- package/dist/extract.js +62 -0
- package/dist/focus.d.ts +1 -0
- package/dist/focus.js +44 -0
- package/dist/glob.d.ts +4 -0
- package/dist/glob.js +63 -0
- package/dist/har.d.ts +105 -0
- package/dist/har.js +88 -0
- package/dist/history.d.ts +35 -0
- package/dist/history.js +277 -0
- package/dist/host-platform.d.ts +5 -0
- package/dist/host-platform.js +19 -0
- package/dist/host-prompts-macos.d.ts +2 -0
- package/dist/host-prompts-macos.js +102 -0
- package/dist/host-prompts-wsl.d.ts +6 -0
- package/dist/host-prompts-wsl.js +64 -0
- package/dist/host-prompts.d.ts +3 -0
- package/dist/host-prompts.js +25 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +47 -0
- package/dist/network.d.ts +54 -0
- package/dist/network.js +204 -0
- package/dist/origin-storage.d.ts +31 -0
- package/dist/origin-storage.js +82 -0
- package/dist/paths.d.ts +17 -0
- package/dist/paths.js +52 -0
- package/dist/pipe.d.ts +9 -0
- package/dist/pipe.js +73 -0
- package/dist/profile-mode.d.ts +10 -0
- package/dist/profile-mode.js +42 -0
- package/dist/protocol-help.d.ts +34 -0
- package/dist/protocol-help.js +66 -0
- package/dist/protocol.d.ts +49 -0
- package/dist/protocol.js +89 -0
- package/dist/refs.d.ts +9 -0
- package/dist/refs.js +46 -0
- package/dist/routes.d.ts +20 -0
- package/dist/routes.js +106 -0
- package/dist/runner.d.ts +20 -0
- package/dist/runner.js +81 -0
- package/dist/session-name.d.ts +9 -0
- package/dist/session-name.js +50 -0
- package/dist/session-store.d.ts +5 -0
- package/dist/session-store.js +58 -0
- package/dist/sessions.d.ts +47 -0
- package/dist/sessions.js +171 -0
- package/dist/tab-groups.d.ts +9 -0
- package/dist/tab-groups.js +13 -0
- package/dist/targets.d.ts +43 -0
- package/dist/targets.js +229 -0
- package/dist/validate.d.ts +3 -0
- package/dist/validate.js +31 -0
- package/dist/wait.d.ts +24 -0
- package/dist/wait.js +88 -0
- package/examples/go/go.mod +3 -0
- package/examples/go/main.go +104 -0
- package/examples/hn-front-page.sh +18 -0
- package/examples/hn-front-page.ts +24 -0
- package/examples/hn_front_page.py +56 -0
- package/extension/tab-groups/manifest.json +8 -0
- package/extension/tab-groups/service-worker.js +41 -0
- package/package.json +60 -0
- package/skills/patchrome/SKILL.md +74 -0
- package/skills/patchrome/references/commands.md +130 -0
- package/skills/patchrome/references/debugging.md +20 -0
- package/skills/patchrome/references/hard-pages.md +49 -0
- package/skills/patchrome/references/logins.md +46 -0
- package/skills/patchrome/references/scraping.md +51 -0
- package/skills/patchrome/references/scripting.md +79 -0
package/dist/wait.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Page } from "patchright";
|
|
2
|
+
import { type CommandArgs } from "./protocol.ts";
|
|
3
|
+
import { type ElementLocator } from "./targets.ts";
|
|
4
|
+
export declare const loadStates: readonly ["load", "domcontentloaded", "networkidle"];
|
|
5
|
+
export type LoadState = (typeof loadStates)[number];
|
|
6
|
+
export type WaitCondition = {
|
|
7
|
+
kind: "element";
|
|
8
|
+
locator: ElementLocator;
|
|
9
|
+
isGone: boolean;
|
|
10
|
+
} | {
|
|
11
|
+
kind: "url";
|
|
12
|
+
glob: string;
|
|
13
|
+
isGone: boolean;
|
|
14
|
+
} | {
|
|
15
|
+
kind: "title";
|
|
16
|
+
text: string;
|
|
17
|
+
isGone: boolean;
|
|
18
|
+
} | {
|
|
19
|
+
kind: "load";
|
|
20
|
+
state: LoadState;
|
|
21
|
+
};
|
|
22
|
+
export declare function parseWaitCondition(args: CommandArgs): WaitCondition;
|
|
23
|
+
export declare function describeWaitCondition(condition: WaitCondition): string;
|
|
24
|
+
export declare function waitForCondition(page: Page, condition: WaitCondition, timeoutMs: number): Promise<void>;
|
package/dist/wait.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { urlGlobMatches } from "./glob.js";
|
|
2
|
+
import { CommandError } from "./protocol.js";
|
|
3
|
+
import { describeElementLocator, elementLocator, parseElementLocator } from "./targets.js";
|
|
4
|
+
export const loadStates = ["load", "domcontentloaded", "networkidle"];
|
|
5
|
+
const titlePollMs = 250;
|
|
6
|
+
const waitHint = "wait --selector <css> | --role <role> [--name <name>] | --text <text> | --label <text> | --url <glob> | --title <text> [--gone], or wait --load load|domcontentloaded|networkidle";
|
|
7
|
+
export function parseWaitCondition(args) {
|
|
8
|
+
const isGone = args.gone === true;
|
|
9
|
+
const pageConditions = ["url", "title", "load"].filter((name) => typeof args[name] === "string");
|
|
10
|
+
const elementConditions = ["selector", "role", "text", "label"].filter((name) => typeof args[name] === "string");
|
|
11
|
+
const given = [...elementConditions, ...pageConditions];
|
|
12
|
+
if (given.length !== 1) {
|
|
13
|
+
throw new CommandError("bad_args", given.length === 0
|
|
14
|
+
? "wait needs a condition"
|
|
15
|
+
: `wait takes one condition, got ${given.map((option) => `--${option}`).join(" ")}`, waitHint);
|
|
16
|
+
}
|
|
17
|
+
const locator = parseElementLocator(args, waitHint);
|
|
18
|
+
if (locator !== undefined)
|
|
19
|
+
return { kind: "element", locator, isGone };
|
|
20
|
+
const [name] = pageConditions;
|
|
21
|
+
if (name === undefined)
|
|
22
|
+
throw new CommandError("bad_args", "wait needs a condition", waitHint);
|
|
23
|
+
const value = String(args[name]);
|
|
24
|
+
if (value === "")
|
|
25
|
+
throw new CommandError("bad_args", `wait --${name} needs a non-empty value`);
|
|
26
|
+
switch (name) {
|
|
27
|
+
case "url":
|
|
28
|
+
return { kind: "url", glob: value, isGone };
|
|
29
|
+
case "title":
|
|
30
|
+
return { kind: "title", text: value, isGone };
|
|
31
|
+
case "load":
|
|
32
|
+
if (isGone)
|
|
33
|
+
throw new CommandError("bad_args", "wait --load does not take --gone");
|
|
34
|
+
if (!loadStates.includes(value))
|
|
35
|
+
throw new CommandError("bad_args", `wait --load must be one of ${loadStates.join(", ")}, got ${value}`);
|
|
36
|
+
return { kind: "load", state: value };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function describeWaitCondition(condition) {
|
|
40
|
+
switch (condition.kind) {
|
|
41
|
+
case "element":
|
|
42
|
+
return `${condition.isGone ? "no visible" : "a visible"} ${describeElementLocator(condition.locator)}`;
|
|
43
|
+
case "url":
|
|
44
|
+
return `url ${condition.isGone ? "leaving" : "matching"} ${condition.glob}`;
|
|
45
|
+
case "title":
|
|
46
|
+
return `title ${condition.isGone ? "without" : "with"} "${condition.text}"`;
|
|
47
|
+
case "load":
|
|
48
|
+
return `load state ${condition.state}`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Element and URL conditions use Patchright's own waits, which query from an isolated world: a probe page that
|
|
52
|
+
// wrapped querySelector, getClientRects and requestAnimationFrame saw no calls from them. waitForFunction
|
|
53
|
+
// does run in the page's realm (the probe saw its requestAnimationFrame polling), so the title is polled
|
|
54
|
+
// from here instead. Throws Playwright's TimeoutError when the time runs out.
|
|
55
|
+
export async function waitForCondition(page, condition, timeoutMs) {
|
|
56
|
+
switch (condition.kind) {
|
|
57
|
+
case "load":
|
|
58
|
+
return page.waitForLoadState(condition.state, { timeout: timeoutMs });
|
|
59
|
+
case "element": {
|
|
60
|
+
const locator = elementLocator(page, condition.locator);
|
|
61
|
+
// On a macOS CI runner waitFor took over a second to report an element that was already visible.
|
|
62
|
+
// One direct read answers that case at once; a bad locator falls through so waitFor reports it.
|
|
63
|
+
const isVisible = await locator.isVisible().catch(() => undefined);
|
|
64
|
+
if (isVisible !== undefined && isVisible !== condition.isGone)
|
|
65
|
+
return;
|
|
66
|
+
return locator.waitFor({
|
|
67
|
+
state: condition.isGone ? "hidden" : "visible",
|
|
68
|
+
timeout: timeoutMs,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
case "url":
|
|
72
|
+
await page.waitForURL((url) => urlGlobMatches(condition.glob, url.href) !== condition.isGone, {
|
|
73
|
+
timeout: timeoutMs,
|
|
74
|
+
waitUntil: "commit",
|
|
75
|
+
});
|
|
76
|
+
return;
|
|
77
|
+
case "title": {
|
|
78
|
+
const deadlineMs = Date.now() + timeoutMs;
|
|
79
|
+
// A read that lands mid-navigation throws; it counts as not yet.
|
|
80
|
+
while ((await page.title().then((title) => title.includes(condition.text), () => condition.isGone)) === condition.isGone) {
|
|
81
|
+
if (Date.now() >= deadlineMs)
|
|
82
|
+
throw new CommandError("timeout", `no ${describeWaitCondition(condition)} within ${timeoutMs} ms`);
|
|
83
|
+
await new Promise((resolve) => setTimeout(resolve, titlePollMs));
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Top stories on Hacker News from Go, over one `patchrome pipe`. Run with `go run .`.
|
|
2
|
+
package main
|
|
3
|
+
|
|
4
|
+
import (
|
|
5
|
+
"bufio"
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"fmt"
|
|
8
|
+
"io"
|
|
9
|
+
"log"
|
|
10
|
+
"os"
|
|
11
|
+
"os/exec"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
type response struct {
|
|
15
|
+
ID int `json:"id"`
|
|
16
|
+
OK bool `json:"ok"`
|
|
17
|
+
Data json.RawMessage `json:"data"`
|
|
18
|
+
Stream json.RawMessage `json:"stream"`
|
|
19
|
+
Error *struct {
|
|
20
|
+
Code string `json:"code"`
|
|
21
|
+
Message string `json:"message"`
|
|
22
|
+
} `json:"error"`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type patchrome struct {
|
|
26
|
+
stdin io.WriteCloser
|
|
27
|
+
stdout *bufio.Scanner
|
|
28
|
+
nextID int
|
|
29
|
+
cmd *exec.Cmd
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func start(session string) (*patchrome, error) {
|
|
33
|
+
cmd := exec.Command("patchrome", "pipe")
|
|
34
|
+
cmd.Env = append(os.Environ(), "PATCHROME_SESSION="+session)
|
|
35
|
+
cmd.Stderr = os.Stderr
|
|
36
|
+
stdin, err := cmd.StdinPipe()
|
|
37
|
+
if err != nil {
|
|
38
|
+
return nil, err
|
|
39
|
+
}
|
|
40
|
+
stdout, err := cmd.StdoutPipe()
|
|
41
|
+
if err != nil {
|
|
42
|
+
return nil, err
|
|
43
|
+
}
|
|
44
|
+
scanner := bufio.NewScanner(stdout)
|
|
45
|
+
scanner.Buffer(make([]byte, 1<<20), 64<<20)
|
|
46
|
+
return &patchrome{stdin: stdin, stdout: scanner, cmd: cmd}, cmd.Start()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// run sends the words you would type after `patchrome` and decodes the response's data into out.
|
|
50
|
+
func (p *patchrome) run(out any, words ...string) error {
|
|
51
|
+
p.nextID++
|
|
52
|
+
request, _ := json.Marshal(map[string]any{"id": p.nextID, "argv": words})
|
|
53
|
+
if _, err := fmt.Fprintf(p.stdin, "%s\n", request); err != nil {
|
|
54
|
+
return err
|
|
55
|
+
}
|
|
56
|
+
for p.stdout.Scan() {
|
|
57
|
+
var message response
|
|
58
|
+
if err := json.Unmarshal(p.stdout.Bytes(), &message); err != nil {
|
|
59
|
+
return err
|
|
60
|
+
}
|
|
61
|
+
if message.ID != p.nextID || message.Stream != nil {
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
if !message.OK {
|
|
65
|
+
return fmt.Errorf("patchrome %s: %s", message.Error.Code, message.Error.Message)
|
|
66
|
+
}
|
|
67
|
+
if out == nil {
|
|
68
|
+
return nil
|
|
69
|
+
}
|
|
70
|
+
return json.Unmarshal(message.Data, out)
|
|
71
|
+
}
|
|
72
|
+
return fmt.Errorf("patchrome pipe exited: %v", p.stdout.Err())
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func (p *patchrome) close() {
|
|
76
|
+
_ = p.run(nil, "session", "close")
|
|
77
|
+
_ = p.stdin.Close()
|
|
78
|
+
_ = p.cmd.Wait()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
func main() {
|
|
82
|
+
browser, err := start(fmt.Sprintf("hn-go-%d", os.Getpid()))
|
|
83
|
+
if err != nil {
|
|
84
|
+
log.Fatal(err)
|
|
85
|
+
}
|
|
86
|
+
defer browser.close()
|
|
87
|
+
|
|
88
|
+
if err := browser.run(nil, "open", "https://news.ycombinator.com/"); err != nil {
|
|
89
|
+
log.Fatal(err)
|
|
90
|
+
}
|
|
91
|
+
schema := `{"rows": "tr.athing", "fields": {"rank": ".rank", "title": ".titleline > a"}, "limit": 10}`
|
|
92
|
+
var extracted struct {
|
|
93
|
+
Rows []struct {
|
|
94
|
+
Rank string `json:"rank"`
|
|
95
|
+
Title string `json:"title"`
|
|
96
|
+
} `json:"rows"`
|
|
97
|
+
}
|
|
98
|
+
if err := browser.run(&extracted, "extract", schema, "--inline"); err != nil {
|
|
99
|
+
log.Fatal(err)
|
|
100
|
+
}
|
|
101
|
+
for _, story := range extracted.Rows {
|
|
102
|
+
fmt.Println(story.Rank, story.Title)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Top stories on Hacker News as JSON rows, one patchrome command per step.
|
|
3
|
+
# Needs patchrome on PATH and jq for reading --json output.
|
|
4
|
+
set -eu
|
|
5
|
+
export PATCHROME_SESSION="${PATCHROME_SESSION:-hn-sh-$$}"
|
|
6
|
+
trap 'patchrome session close >/dev/null' EXIT
|
|
7
|
+
|
|
8
|
+
patchrome open https://news.ycombinator.com/ >/dev/null
|
|
9
|
+
patchrome wait --selector 'tr.athing' >/dev/null
|
|
10
|
+
patchrome --json extract '{
|
|
11
|
+
"rows": "tr.athing",
|
|
12
|
+
"fields": {
|
|
13
|
+
"rank": ".rank",
|
|
14
|
+
"title": ".titleline > a",
|
|
15
|
+
"url": {"selector": ".titleline > a", "attr": "href"}
|
|
16
|
+
},
|
|
17
|
+
"limit": 10
|
|
18
|
+
}' --inline | jq -c '.data.rows[]'
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Top stories on Hacker News with the Node library. Run with `node hn-front-page.ts` after
|
|
2
|
+
// `npm i patchrome` in your project, or from a checkout after `npm run build`.
|
|
3
|
+
import { CommandError, connect } from "patchrome";
|
|
4
|
+
|
|
5
|
+
const browser = connect({ session: `hn-ts-${process.pid}` });
|
|
6
|
+
try {
|
|
7
|
+
await browser.run("open", "https://news.ycombinator.com/");
|
|
8
|
+
const { rows } = (await browser.run(
|
|
9
|
+
"extract",
|
|
10
|
+
JSON.stringify({
|
|
11
|
+
rows: "tr.athing",
|
|
12
|
+
fields: { rank: ".rank", title: ".titleline > a", url: { selector: ".titleline > a", attr: "href" } },
|
|
13
|
+
limit: 10,
|
|
14
|
+
}),
|
|
15
|
+
"--inline",
|
|
16
|
+
)) as { rows: Array<{ rank: string; title: string; url: string }> };
|
|
17
|
+
for (const story of rows) console.log(story.rank, story.title, story.url);
|
|
18
|
+
} catch (err) {
|
|
19
|
+
if (err instanceof CommandError) console.error(`patchrome ${err.code}: ${err.message}`);
|
|
20
|
+
throw err;
|
|
21
|
+
} finally {
|
|
22
|
+
await browser.run("session", "close");
|
|
23
|
+
browser.close();
|
|
24
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Two pages of Hacker News stories, over one `patchrome pipe`."""
|
|
3
|
+
|
|
4
|
+
import itertools
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PatchromeError(Exception):
|
|
11
|
+
def __init__(self, error):
|
|
12
|
+
super().__init__(f"{error['code']}: {error['message']}")
|
|
13
|
+
self.code = error["code"]
|
|
14
|
+
self.hint = error.get("hint")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Patchrome:
|
|
18
|
+
"""Sends CLI words to `patchrome pipe` and returns the `data` of each response."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, session):
|
|
21
|
+
env = {**os.environ, "PATCHROME_SESSION": session}
|
|
22
|
+
self._process = subprocess.Popen(["patchrome", "pipe"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, env=env)
|
|
23
|
+
self._ids = itertools.count(1)
|
|
24
|
+
|
|
25
|
+
def run(self, *words):
|
|
26
|
+
request_id = next(self._ids)
|
|
27
|
+
self._process.stdin.write(json.dumps({"id": request_id, "argv": list(words)}) + "\n")
|
|
28
|
+
self._process.stdin.flush()
|
|
29
|
+
for line in self._process.stdout:
|
|
30
|
+
message = json.loads(line)
|
|
31
|
+
# Stream events from `watch` carry the same id and come before the response.
|
|
32
|
+
if message["id"] != request_id or "stream" in message:
|
|
33
|
+
continue
|
|
34
|
+
if not message["ok"]:
|
|
35
|
+
raise PatchromeError(message["error"])
|
|
36
|
+
return message["data"]
|
|
37
|
+
raise PatchromeError({"code": "daemon_unreachable", "message": "patchrome pipe exited"})
|
|
38
|
+
|
|
39
|
+
def close(self):
|
|
40
|
+
self.run("session", "close")
|
|
41
|
+
self._process.stdin.close()
|
|
42
|
+
self._process.wait()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
browser = Patchrome(session=f"hn-py-{os.getpid()}")
|
|
46
|
+
try:
|
|
47
|
+
browser.run("open", "https://news.ycombinator.com/")
|
|
48
|
+
schema = json.dumps({"rows": "tr.athing", "fields": {"rank": ".rank", "title": ".titleline > a"}})
|
|
49
|
+
for page in (1, 2):
|
|
50
|
+
if page > 1:
|
|
51
|
+
browser.run("click", "--role", "link", "--name", "More", "--exact")
|
|
52
|
+
browser.run("wait", "--url", f"*?p={page}")
|
|
53
|
+
for story in browser.run("extract", schema, "--inline")["rows"]:
|
|
54
|
+
print(story["rank"], story["title"])
|
|
55
|
+
finally:
|
|
56
|
+
browser.close()
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifest_version": 3,
|
|
3
|
+
"name": "patchrome tab groups",
|
|
4
|
+
"description": "Groups each patchrome session's tabs under the session name.",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"permissions": ["tabs", "tabGroups", "debugger"],
|
|
7
|
+
"background": { "service_worker": "service-worker.js" }
|
|
8
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// The daemon calls these through its service worker handle. The extension injects nothing into pages.
|
|
2
|
+
// chrome.debugger.getTargets only lists targets, it attaches to none, so Chrome shows no debugging bar.
|
|
3
|
+
|
|
4
|
+
async function tabsOfTargets(targetIds) {
|
|
5
|
+
const tabIdByTarget = new Map((await chrome.debugger.getTargets()).map((target) => [target.id, target.tabId]));
|
|
6
|
+
const tabIds = targetIds.map((targetId) => tabIdByTarget.get(targetId)).filter((tabId) => tabId !== undefined);
|
|
7
|
+
return Promise.all(tabIds.map((tabId) => chrome.tabs.get(tabId)));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// A group cannot span windows, and isolated sessions open in their own window, so each window gets a group.
|
|
11
|
+
// A tab already in a group keeps that group, so a new tab joins its session's group instead of starting one.
|
|
12
|
+
globalThis.patchromeGroupTabs = async ({ targetIds, title, color }) => {
|
|
13
|
+
const tabsByWindow = new Map();
|
|
14
|
+
for (const tab of await tabsOfTargets(targetIds))
|
|
15
|
+
tabsByWindow.set(tab.windowId, [...(tabsByWindow.get(tab.windowId) ?? []), tab]);
|
|
16
|
+
for (const tabs of tabsByWindow.values()) {
|
|
17
|
+
const existingGroupId = tabs.find((tab) => tab.groupId !== chrome.tabGroups.TAB_GROUP_ID_NONE)?.groupId;
|
|
18
|
+
const groupId = await chrome.tabs.group({
|
|
19
|
+
tabIds: tabs.map((tab) => tab.id),
|
|
20
|
+
...(existingGroupId === undefined ? {} : { groupId: existingGroupId }),
|
|
21
|
+
});
|
|
22
|
+
await chrome.tabGroups.update(groupId, { title, color });
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
globalThis.patchromeDescribeTabGroups = async ({ targetIds }) => {
|
|
27
|
+
const tabCountByGroup = new Map();
|
|
28
|
+
for (const tab of await tabsOfTargets(targetIds)) {
|
|
29
|
+
if (tab.groupId === chrome.tabGroups.TAB_GROUP_ID_NONE) continue;
|
|
30
|
+
tabCountByGroup.set(tab.groupId, (tabCountByGroup.get(tab.groupId) ?? 0) + 1);
|
|
31
|
+
}
|
|
32
|
+
return Promise.all(
|
|
33
|
+
[...tabCountByGroup].map(async ([groupId, tabCount]) => {
|
|
34
|
+
const group = await chrome.tabGroups.get(groupId);
|
|
35
|
+
return { title: group.title ?? "", color: group.color, tabCount };
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Tab events wake the worker if Chrome ever suspends it.
|
|
41
|
+
chrome.tabs.onCreated.addListener(() => {});
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "patchrome",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Stealth shared-browser CLI for coding agents: one Patchright Chrome, a tab set per agent session",
|
|
5
|
+
"homepage": "https://github.com/akaike-byob/patchrome#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/akaike-byob/patchrome/issues"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/akaike-byob/patchrome.git"
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"patchrome": "bin/patchrome.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"bin",
|
|
19
|
+
"dist",
|
|
20
|
+
"extension",
|
|
21
|
+
"skills",
|
|
22
|
+
"examples",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"default": "./dist/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./package.json": "./package.json"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
36
|
+
"prepack": "npm run build",
|
|
37
|
+
"typecheck": "tsc",
|
|
38
|
+
"test": "vitest run",
|
|
39
|
+
"test:unit": "vitest run test/unit",
|
|
40
|
+
"test:integration": "vitest run test/integration",
|
|
41
|
+
"lint": "oxlint --type-aware --deny-warnings",
|
|
42
|
+
"format": "oxfmt",
|
|
43
|
+
"format:check": "oxfmt --check"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"patchright": "^1.63.0",
|
|
47
|
+
"zod": "^4.6.4"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^26.5.1",
|
|
51
|
+
"oxfmt": "^0.67.0",
|
|
52
|
+
"oxlint": "^1.82.0",
|
|
53
|
+
"oxlint-tsgolint": "^7.0.2001",
|
|
54
|
+
"typescript": "^7.0.2",
|
|
55
|
+
"vitest": "^5.0.0"
|
|
56
|
+
},
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=24.2"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: patchrome
|
|
3
|
+
description: Drives a real, stealth, headed Google Chrome from the shell through the `patchrome` CLI - opens tabs, reads pages as accessibility snapshots with @refs, clicks, fills, extracts text and rows, reads network responses, signs in once and reuses the login, and exports a working flow as a script. Many agent sessions share one Chrome and one logged-in profile, each on its own tabs. Use whenever a task needs a real browser - a page behind a bot wall or login, JS-rendered content that curl cannot see, scraping, clicking through a web app, a repeatable scrape script, or checking what a site shows.
|
|
4
|
+
allowed-tools: Bash(patchrome:*)
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Browser from the CLI (`patchrome`)
|
|
8
|
+
|
|
9
|
+
One daemon owns one headed Chrome per profile. Every `patchrome` call is a short client; the first
|
|
10
|
+
starts Chrome. Other agent sessions browse the same Chrome at once, and you see only your own tabs.
|
|
11
|
+
If `patchrome` is not on PATH: `npm i -g patchrome`.
|
|
12
|
+
|
|
13
|
+
## The loop
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
patchrome session label "compare laptop prices" # first: the user sees it on your Chrome tab group
|
|
17
|
+
patchrome open https://example.com # new background tab, becomes your current tab
|
|
18
|
+
patchrome snapshot # accessibility tree to a file; prints its path
|
|
19
|
+
# grep the file for the node: - button "Sign in" [ref=f1e6]
|
|
20
|
+
patchrome fill @f1e5 "alice@example.com"
|
|
21
|
+
patchrome click @f1e6
|
|
22
|
+
patchrome wait --url '*/dashboard*' # click returns before the next page loads
|
|
23
|
+
patchrome snapshot # refs from before a navigation are stale
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Copy refs exactly from the latest snapshot, from a line that has `[ref=...]`. A locator works anywhere
|
|
27
|
+
a ref does and survives page changes: `--role button --name "Sign in"` (role and name as on the
|
|
28
|
+
snapshot line), `--text <text>`, `--label <form label>` or `--selector <css>`, with `--exact`,
|
|
29
|
+
`--nth <n>` and `--frame <iframe-css>`.
|
|
30
|
+
|
|
31
|
+
## Read the reference for the task
|
|
32
|
+
|
|
33
|
+
| Task | Read |
|
|
34
|
+
|---|---|
|
|
35
|
+
| Any flag or command not shown here | [references/commands.md](references/commands.md) |
|
|
36
|
+
| Scraping: JSON API responses, `extract` rows, globs, HAR, blocking or mocking requests | [references/scraping.md](references/scraping.md) |
|
|
37
|
+
| A repeatable script, `session history`, `pipe`, the Node library, sh/Python/Go callers | [references/scripting.md](references/scripting.md) |
|
|
38
|
+
| A site needs sign-in: `login`, `state import`, `state save`/`load`, a separate account | [references/logins.md](references/logins.md) |
|
|
39
|
+
| iframes, closed shadow roots, canvas, CAPTCHAs, bot-wall interstitials | [references/hard-pages.md](references/hard-pages.md) |
|
|
40
|
+
| Debugging your own localhost app: console, page errors, traces, CDP | [references/debugging.md](references/debugging.md) |
|
|
41
|
+
|
|
42
|
+
## Rules
|
|
43
|
+
|
|
44
|
+
- Never close, switch to or act on tabs you did not open. `tabs --all` is for looking only.
|
|
45
|
+
- Read snapshot and text files with your file tools and grep them; never `cat` a large one whole.
|
|
46
|
+
- Wait for a condition (`wait --url`, `wait --text`, `wait --selector`), never `sleep`. Quote globs:
|
|
47
|
+
zsh expands `*` and `?` itself.
|
|
48
|
+
- A bot wall's interstitial ("Just a moment") often clears by itself. Only a
|
|
49
|
+
`wait --title "<interstitial title>" --gone` that times out means blocked.
|
|
50
|
+
- Never solve a CAPTCHA yourself. When `challenge` reports `pending`, tell the user and run
|
|
51
|
+
`challenge --handoff`.
|
|
52
|
+
- Logins are shared by every session in the profile: never log out of a site another agent may use.
|
|
53
|
+
- Never run `daemon stop` or `session close <other session>` unless the user asks; both end other
|
|
54
|
+
agents' work.
|
|
55
|
+
- Keep request volume low on protected sites: no tight loops of `goto`.
|
|
56
|
+
- Session name comes from `--session`, `$PATCHROME_SESSION`, `$CLAUDE_CODE_SESSION_ID`, the tty, then
|
|
57
|
+
the parent process. If `patchrome session` prints a new name on each call, export
|
|
58
|
+
`PATCHROME_SESSION` for the whole task.
|
|
59
|
+
|
|
60
|
+
## Errors
|
|
61
|
+
|
|
62
|
+
Exit code 0 ok, 1 command error, 2 bad usage. `--json` errors carry a `code`:
|
|
63
|
+
|
|
64
|
+
| Code | Do |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `tab_gone` | current tab closed or none yet: `patchrome open <url>`; never grab another session's tab |
|
|
67
|
+
| `ref_stale` | page changed since the snapshot: `patchrome snapshot`, pick the ref again, or use a locator |
|
|
68
|
+
| `timeout` | snapshot to see the page state; raise `--timeout-ms` if the site is slow |
|
|
69
|
+
| `navigation_failed` | DNS, TLS or connection failure: check the URL, retry once |
|
|
70
|
+
| `daemon_unreachable` | `patchrome daemon logs`; the next command restarts the daemon |
|
|
71
|
+
| `daemon_outdated` | tell the user; `daemon stop` closes Chrome for every session |
|
|
72
|
+
| `bad_args` | wrong usage, a JS error in `eval`, a bad schema or selector: read the message and hint |
|
|
73
|
+
| `unsupported_in_stealth` | the command needs `--profile debug`, which is only for your own apps |
|
|
74
|
+
| `copy_denied` | the user refused a login copy: tell them, do not retry, never copy cookies another way |
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Command reference
|
|
2
|
+
|
|
3
|
+
## Contents
|
|
4
|
+
- Global flags and output
|
|
5
|
+
- Elements: refs and locators
|
|
6
|
+
- Tabs and navigation
|
|
7
|
+
- Reading the page
|
|
8
|
+
- Acting
|
|
9
|
+
- Waiting and watching
|
|
10
|
+
- Network
|
|
11
|
+
- Logins and state
|
|
12
|
+
- Scripting
|
|
13
|
+
- Debug profile
|
|
14
|
+
- Sessions, profiles and the daemon
|
|
15
|
+
|
|
16
|
+
## Global flags and output
|
|
17
|
+
|
|
18
|
+
Global flags go before the command: `--json` (one object, `{ok, data}` or `{ok: false, error}`),
|
|
19
|
+
`--timeout-ms <n>` (default 30000; `login`, `watch`, `challenge --handoff` and `console --follow`
|
|
20
|
+
default to 10 min), `--session <name>`, `--profile <name>` (default `stealth`).
|
|
21
|
+
|
|
22
|
+
Plain output is a few lines. A value over 2 KB goes to a file in the session folder and stdout carries
|
|
23
|
+
the path. `--inline` always prints the value; `--out <file>` always writes that file.
|
|
24
|
+
|
|
25
|
+
## Elements: refs and locators
|
|
26
|
+
|
|
27
|
+
`<element>` is one of:
|
|
28
|
+
|
|
29
|
+
- `@e12` or `@f1e12`, a ref from the latest snapshot (`--ref <ref>` on commands that read one)
|
|
30
|
+
- `--role <role> [--name <name>]`, the role and accessible name on a snapshot line
|
|
31
|
+
- `--text <text>`, visible text, case-insensitive substring
|
|
32
|
+
- `--label <text>`, a form field's label
|
|
33
|
+
- `--selector <css>`, which also pierces closed shadow roots
|
|
34
|
+
|
|
35
|
+
Modifiers: `--exact` matches the whole name or text, `--nth <n>` picks match n from 0 instead of the
|
|
36
|
+
first, `--frame <iframe-css>` looks inside that iframe.
|
|
37
|
+
|
|
38
|
+
## Tabs and navigation
|
|
39
|
+
|
|
40
|
+
| Command | Does |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `open [url] [--wait load\|domcontentloaded\|networkidle] [--isolated]` | new background tab, becomes current; `--isolated` on your first tab gives your session its own cookies |
|
|
43
|
+
| `goto <url> [--wait ...]` | navigate the current tab |
|
|
44
|
+
| `tabs [--all]` | your tabs, `*` marks current; `--all` lists every session's, read-only |
|
|
45
|
+
| `switch <tab>`, `close [tab]` | tab ids look like `t3`; only your own tabs |
|
|
46
|
+
|
|
47
|
+
## Reading the page
|
|
48
|
+
|
|
49
|
+
| Command | Does |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `snapshot [--inline \| --out <file>]` | accessibility tree with refs, iframes included; `--inline` only for small pages |
|
|
52
|
+
| `text [<element>] [--inline \| --out <file>]` | visible text of the page or one element |
|
|
53
|
+
| `screenshot [--full] [<element>] [--out <file>]` | PNG to a file |
|
|
54
|
+
| `eval <js> [--main-world] [--inline \| --out <file>]` | JS expression, prints JSON; `--json` puts it under `value` |
|
|
55
|
+
| `extract <schema> [<element>] [--inline \| --out <file>]` | CSS selectors to JSON rows: `{"rows": "li.item", "fields": {"name": "h2", "url": {"selector": "a", "attr": "href"}}}` |
|
|
56
|
+
|
|
57
|
+
`eval` runs in an isolated world: the DOM works, page globals (`window.myApp`) read as undefined.
|
|
58
|
+
`--main-world` sees them but runs inside the page's own realm, which a protected site can notice.
|
|
59
|
+
|
|
60
|
+
## Acting
|
|
61
|
+
|
|
62
|
+
| Command | Does |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `click <ref> \| <element>` | click |
|
|
65
|
+
| `click --at <x>,<y>` | trusted click at viewport pixels read off a screenshot |
|
|
66
|
+
| `fill <ref> \| <element> <text>` | replace a field's value |
|
|
67
|
+
| `type <text>` | trusted keystrokes into whatever has focus; click the field first |
|
|
68
|
+
| `press <key>` | Playwright key names: `Enter`, `Tab`, `Control+a` |
|
|
69
|
+
| `challenge [--handoff]` | CAPTCHA state (`none`, `pending`, `solved`); `--handoff` raises the tab for the user |
|
|
70
|
+
|
|
71
|
+
## Waiting and watching
|
|
72
|
+
|
|
73
|
+
| Command | Does |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `wait <element> \| --url <glob> \| --title <text> [--gone]` | block until the page shows it, or with `--gone` stops showing it; a condition already true returns at once |
|
|
76
|
+
| `wait --load load\|domcontentloaded\|networkidle` | block until the tab reaches a load state |
|
|
77
|
+
| `watch [--events navigation,load,response,console,error] [--url <glob>] [--count <n>]` | stream your tabs' events, one line each; `console` and `error` need a debug profile |
|
|
78
|
+
|
|
79
|
+
## Network
|
|
80
|
+
|
|
81
|
+
| Command | Does |
|
|
82
|
+
|---|---|
|
|
83
|
+
| `network list [--url <glob>] [--type xhr,fetch] [--status 4xx] [--inline \| --out <file>]` | your requests, newest last, ids like `n17`; last 1000 kept |
|
|
84
|
+
| `network get <id> \| --url <glob> [--body] [--inline \| --out <file>]` | headers, post data, and with `--body` the response body; `--url` takes the newest match |
|
|
85
|
+
| `network har start\|stop [--out <file>]` | record your requests; `stop` writes a HAR with text bodies |
|
|
86
|
+
| `route block <glob>`, `route mock <glob> <file>` | abort, or answer from a file, matching requests on your tabs |
|
|
87
|
+
| `route list\|clear` | your rules; the newest matching rule wins |
|
|
88
|
+
|
|
89
|
+
## Logins and state
|
|
90
|
+
|
|
91
|
+
| Command | Does |
|
|
92
|
+
|---|---|
|
|
93
|
+
| `login <url> [--until <url-glob>]` | a visible tab for a person to sign in |
|
|
94
|
+
| `cookies [--domain <domain>] [--inline \| --out <file>]` | cookies with values |
|
|
95
|
+
| `state save <file>`, `state load <file>` | cookies and localStorage, Playwright storageState format |
|
|
96
|
+
| `state import <site> [--from <chrome-profile>]` | one site's login from the user's everyday Chrome |
|
|
97
|
+
| `audit [--count <n>]` | recent login copies and who approved them |
|
|
98
|
+
|
|
99
|
+
## Scripting
|
|
100
|
+
|
|
101
|
+
| Command | Does |
|
|
102
|
+
|---|---|
|
|
103
|
+
| `session history [--format sh\|jsonl] [--out <file>]` | your session's working commands, refs rewritten as locators |
|
|
104
|
+
| `session history clear` | empty the recording |
|
|
105
|
+
| `pipe [--bail]` | JSON requests on stdin, one JSON response per line |
|
|
106
|
+
|
|
107
|
+
## Debug profile
|
|
108
|
+
|
|
109
|
+
| Command | Does |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `console [--level debug\|info\|warning\|error] [--follow]` | your tabs' console, that level and above |
|
|
112
|
+
| `errors` | uncaught page errors with stacks |
|
|
113
|
+
| `trace start\|stop [--out <file>]` | Playwright trace zip of every tab in the profile; one session at a time |
|
|
114
|
+
| `cdp <Domain.method> [params-json]` | raw CDP on your current tab |
|
|
115
|
+
| `cdp help [Domain\|Domain.method]` | the CDP domains, commands and params this Chrome implements |
|
|
116
|
+
| `devtools-url` | debugging URL for chrome-devtools, Lighthouse or heap snapshots |
|
|
117
|
+
|
|
118
|
+
## Sessions, profiles and the daemon
|
|
119
|
+
|
|
120
|
+
| Command | Does |
|
|
121
|
+
|---|---|
|
|
122
|
+
| `session`, `session close` | your session name, label and tabs; close all your tabs |
|
|
123
|
+
| `session label <text>` | what you are doing, shown on your Chrome tab group |
|
|
124
|
+
| `sessions [pattern]`, `session close <session\|pattern>` | list sessions; close others by name or glob (`'work-*'`), only when the user asks |
|
|
125
|
+
| `profile create <name> --mode stealth\|debug` | a profile's mode is fixed once made |
|
|
126
|
+
| `daemon status\|stop\|logs` | `stop` closes Chrome for every session |
|
|
127
|
+
| `completions zsh` | zsh completion script, for people |
|
|
128
|
+
|
|
129
|
+
If the daemon restarts, your tabs come back on your next command under the same ids, reloaded at
|
|
130
|
+
their last URL. Typed input and refs are gone: take a fresh snapshot.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Debugging your own app
|
|
2
|
+
|
|
3
|
+
For localhost apps you are building, use the debug profile: a separate Chrome with console capture,
|
|
4
|
+
page errors, tracing, raw CDP and a `127.0.0.1` debugging port. Never use it for third-party sites:
|
|
5
|
+
all of that is detectable. Stealth profiles refuse these commands with `unsupported_in_stealth`.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
patchrome --profile debug open http://localhost:3000
|
|
9
|
+
patchrome --profile debug console --level warning # c4 t1 error Failed to load user (http://localhost:3000/app.js:88)
|
|
10
|
+
patchrome --profile debug console --follow # streams until --timeout-ms (default 10 min)
|
|
11
|
+
patchrome --profile debug errors # uncaught exceptions with stacks
|
|
12
|
+
patchrome --profile debug trace start # covers every tab in the profile; one session at a time
|
|
13
|
+
patchrome --profile debug trace stop # prints the zip; npx playwright show-trace <file>
|
|
14
|
+
patchrome --profile debug cdp Performance.getMetrics
|
|
15
|
+
patchrome --profile debug cdp help Network # what this Chrome implements
|
|
16
|
+
patchrome --profile debug devtools-url # for chrome-devtools-mcp --browserUrl <url>, Lighthouse, heap snapshots
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`watch --events console,error` streams the same events alongside navigation and responses.
|
|
20
|
+
`profile create <name> --mode debug` makes another debug profile; `debug` is one by default.
|