feat(site): bb-sites adapters — turn any site into a structured-data CLI
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
Add `chrome-use site` — run community bb-sites adapters over chrome-use's stealth transport. An adapter is a per-command JS function that calls a site's own JSON API from inside your logged-in tab (your cookies, same-origin fetch, the site's modules) and returns clean JSON — no clicking/scraping. - site update fetch the upstream bb-sites pack into ~/.chrome-use/sites - site list list installed adapters (name/cmd) - site info show an adapter's @meta (args, domain, capabilities) - site <name>/<cmd> [args] navigate to its domain (reuse tab if already there) + eval, return JSON chrome-use ships zero adapter code; `site update` fetches epiral/bb-sites at runtime (like a package manager). Adapters remain their authors' property. cli/src/site.rs (load/parse/build_eval/list/update/map_args + tests), wired via commands.rs (parse), actions.rs (handle_site), main.rs (CLI dispatch). Docs in README, README.zh, skill-data/core. Verified live: github/issues returned 30 real issues as JSON over the relay.
This commit is contained in:
@@ -205,6 +205,34 @@ chrome-use --launch --profile auto open https://x.com/home
|
|||||||
|
|
||||||
In CI environments, standalone mode is used automatically.
|
In CI environments, standalone mode is used automatically.
|
||||||
|
|
||||||
|
## Site adapters — turn a website into a structured-data CLI
|
||||||
|
|
||||||
|
Most "read GitHub issues" / "search Reddit" / "get my Bilibili feed" tasks don't
|
||||||
|
need clicking and screenshotting at all — the site already has a JSON API behind
|
||||||
|
its own login. A **site adapter** is a tiny JS function that calls that API *from
|
||||||
|
inside your logged-in tab* (your cookies, same-origin `fetch`, the site's own
|
||||||
|
modules) and returns clean JSON. The site can't tell it apart from you, because it
|
||||||
|
*is* you.
|
||||||
|
|
||||||
|
chrome-use ships none of these adapters — `site update` fetches the community
|
||||||
|
[**bb-sites**](https://github.com/epiral/bb-sites) pack at runtime (like a package
|
||||||
|
manager pulling a dependency), then runs them over chrome-use's stealth transport:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chrome-use site update # fetch the adapter pack (~145 commands)
|
||||||
|
chrome-use site list # github/issues, reddit/search, bilibili/feed, …
|
||||||
|
chrome-use site info github/issues # see an adapter's args + domain
|
||||||
|
|
||||||
|
# Run one — navigates to the site (reusing the tab if you're already there) and returns JSON
|
||||||
|
chrome-use site github/issues epiral/bb-browser --json
|
||||||
|
chrome-use site reddit/search "rust async" --json
|
||||||
|
chrome-use site bilibili/feed --json # works because it's your logged-in session
|
||||||
|
```
|
||||||
|
|
||||||
|
Positional args fill the adapter's declared args in order; `--key value` overrides
|
||||||
|
by name. Adapters are authored by the bb-sites community and remain their authors'
|
||||||
|
property — chrome-use just runs them.
|
||||||
|
|
||||||
## Automated testing (`chrome-use test`)
|
## Automated testing (`chrome-use test`)
|
||||||
|
|
||||||
Turn the repetitive "open it, click around, check it's right" work into a
|
Turn the repetitive "open it, click around, check it's right" work into a
|
||||||
|
|||||||
@@ -163,6 +163,31 @@ chrome-use --launch --profile auto open https://x.com/home
|
|||||||
# 或显式指定:--profile Default / --profile "Profile 1"
|
# 或显式指定:--profile Default / --profile "Profile 1"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 站点适配器 —— 把一个网站变成「结构化数据 CLI」
|
||||||
|
|
||||||
|
大多数「读 GitHub issue」「搜 Reddit」「拉我的 B 站动态」这类任务,根本不需要点击 +
|
||||||
|
截图 —— 网站登录态背后本来就有 JSON 接口。**站点适配器**就是一小段 JS 函数,它在你
|
||||||
|
**已登录的标签页内**调用那个接口(用你的 cookie、同源 `fetch`、网站自己的模块),返回
|
||||||
|
干净的 JSON。网站分辨不出它和你的区别,因为它**就是你**。
|
||||||
|
|
||||||
|
chrome-use 本身不附带任何适配器 —— `site update` 会在运行时拉取社区的
|
||||||
|
[**bb-sites**](https://github.com/epiral/bb-sites) 适配器包(就像包管理器拉依赖),
|
||||||
|
然后在 chrome-use 的隐身通道上运行它们:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chrome-use site update # 拉取适配器包(约 145 条命令)
|
||||||
|
chrome-use site list # github/issues、reddit/search、bilibili/feed…
|
||||||
|
chrome-use site info github/issues # 查看某个适配器的参数 + 域名
|
||||||
|
|
||||||
|
# 运行一个 —— 会导航到对应站点(已在该站点则复用当前标签页)并返回 JSON
|
||||||
|
chrome-use site github/issues epiral/bb-browser --json
|
||||||
|
chrome-use site reddit/search "rust async" --json
|
||||||
|
chrome-use site bilibili/feed --json # 能用,因为走的是你的登录态
|
||||||
|
```
|
||||||
|
|
||||||
|
位置参数按适配器声明的参数顺序填入;`--key value` 按名覆盖。适配器由 bb-sites 社区编写、
|
||||||
|
版权归各自作者所有 —— chrome-use 只负责运行它们。
|
||||||
|
|
||||||
## 自动化测试(`chrome-use test`)
|
## 自动化测试(`chrome-use test`)
|
||||||
|
|
||||||
把反复的「打开它、点一圈、看对不对」变成**可重跑的测试套件** —— 前端的单元测试。用 YAML 写用例;步骤复用 chrome-use 自己的命令,断言编译成一次检查:
|
把反复的「打开它、点一圈、看对不对」变成**可重跑的测试套件** —— 前端的单元测试。用 YAML 写用例;步骤复用 chrome-use 自己的命令,断言编译成一次检查:
|
||||||
|
|||||||
Generated
+1
-1
@@ -290,7 +290,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrome-use"
|
name = "chrome-use"
|
||||||
version = "1.5.18"
|
version = "1.5.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "chrome-use"
|
name = "chrome-use"
|
||||||
version = "1.5.18"
|
version = "1.5.19"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Fast browser automation CLI for AI agents"
|
description = "Fast browser automation CLI for AI agents"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ const KNOWN_COMMANDS: &[&str] = &[
|
|||||||
"drag",
|
"drag",
|
||||||
"dialog",
|
"dialog",
|
||||||
"upload",
|
"upload",
|
||||||
|
"site",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Levenshtein distance, capped — small inputs only (command names).
|
/// Levenshtein distance, capped — small inputs only (command names).
|
||||||
@@ -1193,6 +1194,47 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
|||||||
Ok(json!({ "id": id, "action": "evaluate", "script": script }))
|
Ok(json!({ "id": id, "action": "evaluate", "script": script }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"site" => {
|
||||||
|
// `site <name>/<command> [positional...] [--key value]`. The
|
||||||
|
// `update`/`list`/`info` subcommands are handled CLI-side (main.rs)
|
||||||
|
// and never reach here — by this point `rest[0]` is a `name/cmd`
|
||||||
|
// adapter spec. Load it, map the args onto the adapter's declared
|
||||||
|
// `args`, and emit a `site` action: the daemon navigates to the
|
||||||
|
// adapter's @meta.domain (reusing the tab if already there) and evals
|
||||||
|
// the adapter function in the site's own logged-in page.
|
||||||
|
let spec = rest.first().ok_or(ParseError::InvalidValue {
|
||||||
|
message: "site requires <name>/<command> (run `chrome-use site list`)".to_string(),
|
||||||
|
usage: "site <name>/<command> [args]",
|
||||||
|
})?;
|
||||||
|
let adapter =
|
||||||
|
crate::site::load_adapter(spec).map_err(|e| ParseError::InvalidValue {
|
||||||
|
message: e,
|
||||||
|
usage: "site <name>/<command> [args]",
|
||||||
|
})?;
|
||||||
|
let domain = adapter
|
||||||
|
.domain()
|
||||||
|
.ok_or(ParseError::InvalidValue {
|
||||||
|
message: format!("site: adapter `{spec}` @meta is missing a \"domain\""),
|
||||||
|
usage: "site <name>/<command>",
|
||||||
|
})?
|
||||||
|
.to_string();
|
||||||
|
// Split remaining args: `--key value` → named, everything else → positional.
|
||||||
|
let mut positional: Vec<String> = Vec::new();
|
||||||
|
let mut named: Vec<(String, String)> = Vec::new();
|
||||||
|
let mut it = rest[1..].iter();
|
||||||
|
while let Some(a) = it.next() {
|
||||||
|
if let Some(key) = a.strip_prefix("--") {
|
||||||
|
let val = it.next().map(|s| s.to_string()).unwrap_or_default();
|
||||||
|
named.push((key.to_string(), val));
|
||||||
|
} else {
|
||||||
|
positional.push(a.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mapped = crate::site::map_args(&adapter, &positional, &named);
|
||||||
|
let script = crate::site::build_eval(&adapter, &mapped);
|
||||||
|
Ok(json!({ "id": id, "action": "site", "domain": domain, "script": script }))
|
||||||
|
}
|
||||||
|
|
||||||
// === Stealth self-check ===
|
// === Stealth self-check ===
|
||||||
"stealth" => {
|
"stealth" => {
|
||||||
// `stealth [status]` — local stealth self-check: mode, live probes
|
// `stealth [status]` — local stealth self-check: mode, live probes
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ mod flags;
|
|||||||
mod install;
|
mod install;
|
||||||
mod native;
|
mod native;
|
||||||
mod output;
|
mod output;
|
||||||
|
mod site;
|
||||||
mod skills;
|
mod skills;
|
||||||
mod test_runner;
|
mod test_runner;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -834,6 +835,84 @@ fn main() {
|
|||||||
exit(test_runner::run_test(suite, &flags));
|
exit(test_runner::run_test(suite, &flags));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle `site`: site adapters — turn a website into a structured-data CLI by
|
||||||
|
// running a per-command JS adapter inside your logged-in tab. `update`/`list`/
|
||||||
|
// `info` are CLI-side (download/filesystem); `site <name>/<cmd> [args]` falls
|
||||||
|
// through to the daemon dispatch below (navigate to the adapter's domain + eval).
|
||||||
|
if clean.first().map(|s| s.as_str()) == Some("site") {
|
||||||
|
match clean.get(1).map(|s| s.as_str()) {
|
||||||
|
Some("update") => {
|
||||||
|
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
|
||||||
|
match rt.block_on(site::update()) {
|
||||||
|
Ok(n) if flags.json => {
|
||||||
|
println!("{}", json!({ "success": true, "adapters": n }))
|
||||||
|
}
|
||||||
|
Ok(n) => println!(
|
||||||
|
"{} synced {} site adapters → ~/.chrome-use/sites (run `chrome-use site list`)",
|
||||||
|
color::success_indicator(),
|
||||||
|
n
|
||||||
|
),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("{} {}", color::error_indicator(), e);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Some("list") => {
|
||||||
|
match site::list_adapters() {
|
||||||
|
Ok(list) if flags.json => {
|
||||||
|
println!("{}", json!({ "success": true, "adapters": list }))
|
||||||
|
}
|
||||||
|
Ok(list) if list.is_empty() => {
|
||||||
|
println!("no site adapters installed — run `chrome-use site update`")
|
||||||
|
}
|
||||||
|
Ok(list) => {
|
||||||
|
for a in &list {
|
||||||
|
println!("{a}");
|
||||||
|
}
|
||||||
|
eprintln!(
|
||||||
|
"{}",
|
||||||
|
color::dim(&format!(
|
||||||
|
"{} adapters · run: chrome-use site <name>/<cmd> [args]",
|
||||||
|
list.len()
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("{} {}", color::error_indicator(), e);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Some("info") => {
|
||||||
|
let spec = clean.get(2).cloned().unwrap_or_default();
|
||||||
|
match site::load_adapter(&spec) {
|
||||||
|
Ok(a) => println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string_pretty(&a.meta).unwrap_or_default()
|
||||||
|
),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("{} {}", color::error_indicator(), e);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// `site <name>/<cmd> [args]` → fall through to the daemon dispatch.
|
||||||
|
Some(spec) if spec.contains('/') => {}
|
||||||
|
_ => {
|
||||||
|
eprintln!(
|
||||||
|
"{} usage: chrome-use site <name>/<cmd> [args] | site update | site list | \
|
||||||
|
site info <name>/<cmd>",
|
||||||
|
color::error_indicator()
|
||||||
|
);
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle skills command (doesn't need daemon)
|
// Handle skills command (doesn't need daemon)
|
||||||
if clean.first().map(|s| s.as_str()) == Some("skills") {
|
if clean.first().map(|s| s.as_str()) == Some("skills") {
|
||||||
skills::run_skills(&clean, flags.json);
|
skills::run_skills(&clean, flags.json);
|
||||||
|
|||||||
@@ -1315,6 +1315,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
|||||||
"title" => handle_title(state).await,
|
"title" => handle_title(state).await,
|
||||||
"content" => handle_content(state).await,
|
"content" => handle_content(state).await,
|
||||||
"evaluate" => handle_evaluate(cmd, state).await,
|
"evaluate" => handle_evaluate(cmd, state).await,
|
||||||
|
"site" => handle_site(cmd, state).await,
|
||||||
"close" => handle_close(state).await,
|
"close" => handle_close(state).await,
|
||||||
"stealth_status" => handle_stealth_status(state).await,
|
"stealth_status" => handle_stealth_status(state).await,
|
||||||
"snapshot" => handle_snapshot(cmd, state).await,
|
"snapshot" => handle_snapshot(cmd, state).await,
|
||||||
@@ -2698,6 +2699,47 @@ async fn handle_evaluate(cmd: &Value, state: &DaemonState) -> Result<Value, Stri
|
|||||||
Ok(json!({ "result": result, "origin": url }))
|
Ok(json!({ "result": result, "origin": url }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run a site adapter: navigate to its `@meta.domain` (only if we're not already
|
||||||
|
/// there — the point is to run as you, in the page that's already open) and eval
|
||||||
|
/// the adapter function in the site's own logged-in page. The CLI/commands.rs has
|
||||||
|
/// already loaded the adapter and built the `script`; here we just place the page
|
||||||
|
/// and evaluate. Never disrupts the user's foreground tab — navigation happens on
|
||||||
|
/// the daemon's own tab (same as every other command on the relay).
|
||||||
|
async fn handle_site(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||||
|
let domain = cmd
|
||||||
|
.get("domain")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or("site: missing 'domain'")?
|
||||||
|
.to_string();
|
||||||
|
let script = cmd
|
||||||
|
.get("script")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or("site: missing 'script'")?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let current = match state.browser.as_ref() {
|
||||||
|
Some(mgr) => mgr.get_url().await.unwrap_or_default(),
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
let on_domain = url::Url::parse(¤t)
|
||||||
|
.ok()
|
||||||
|
.and_then(|u| u.host_str().map(|h| h.to_string()))
|
||||||
|
.map(|h| h == domain || h.ends_with(&format!(".{domain}")))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !on_domain {
|
||||||
|
let nav = json!({ "url": format!("https://{domain}/") });
|
||||||
|
handle_navigate(&nav, state).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let eval_cmd = json!({ "script": script });
|
||||||
|
let out = handle_evaluate(&eval_cmd, state).await?;
|
||||||
|
Ok(json!({
|
||||||
|
"result": out.get("result").cloned().unwrap_or(Value::Null),
|
||||||
|
"origin": out.get("origin").cloned().unwrap_or(Value::Null),
|
||||||
|
"domain": domain,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/// Local stealth self-check: reports the active mode, live fingerprint probes,
|
/// Local stealth self-check: reports the active mode, live fingerprint probes,
|
||||||
/// and the list of applied overrides — so an agent (or human) can confirm
|
/// and the list of applied overrides — so an agent (or human) can confirm
|
||||||
/// stealth is working without driving an external detector, and audit exactly
|
/// stealth is working without driving an external detector, and audit exactly
|
||||||
|
|||||||
@@ -3366,6 +3366,14 @@ Batch:
|
|||||||
batch [--bail] ["cmd" ...] Execute multiple commands sequentially (args or stdin)
|
batch [--bail] ["cmd" ...] Execute multiple commands sequentially (args or stdin)
|
||||||
--bail stops on first error (default: continue all)
|
--bail stops on first error (default: continue all)
|
||||||
|
|
||||||
|
Site adapters: turn a website into a structured-data CLI (runs as you, in your tab)
|
||||||
|
site update Fetch the community adapter pack into ~/.chrome-use/sites
|
||||||
|
site list List installed adapters (name/cmd)
|
||||||
|
site info <name>/<cmd> Show an adapter's @meta (args, domain, capabilities)
|
||||||
|
site <name>/<cmd> [args] Run an adapter: navigate to its site + return JSON
|
||||||
|
e.g. site github/issues epiral/repo, site reddit/search rust
|
||||||
|
Positional args fill declared args in order; --key value overrides
|
||||||
|
|
||||||
Auth Vault:
|
Auth Vault:
|
||||||
auth save <name> [opts] Save auth profile (--url, --username, --password/--password-stdin)
|
auth save <name> [opts] Save auth profile (--url, --username, --password/--password-stdin)
|
||||||
auth login <name> Login using saved credentials (waits for form fields)
|
auth login <name> Login using saved credentials (waits for form fields)
|
||||||
|
|||||||
+267
@@ -0,0 +1,267 @@
|
|||||||
|
//! Site adapters: turn any website into a structured-data CLI by running a small
|
||||||
|
//! per-command JS adapter inside your real, logged-in browser tab (it reuses the
|
||||||
|
//! site's cookies / same-origin fetch / its own webpack modules — the site thinks
|
||||||
|
//! it's you, because it is).
|
||||||
|
//!
|
||||||
|
//! The adapter format is the community **bb-sites** convention
|
||||||
|
//! (<https://github.com/epiral/bb-sites>): one `.js` file per command, a
|
||||||
|
//! `/* @meta {...} */` JSON header (name, description, domain, args), then an
|
||||||
|
//! `async function(args){ ... return {...} }`. chrome-use ships none of those
|
||||||
|
//! adapters — `chrome-use site update` fetches the upstream repo at runtime into
|
||||||
|
//! `~/.chrome-use/sites` (like a package manager pulling a dependency), so the
|
||||||
|
//! adapters stay the property of their authors. Running an adapter navigates to
|
||||||
|
//! its `@meta.domain` and `eval`s the function in the site's own logged-in page.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
const SITES_ZIP_URL: &str = "https://github.com/epiral/bb-sites/archive/refs/heads/main.zip";
|
||||||
|
|
||||||
|
/// `~/.chrome-use/sites` — where synced adapters live.
|
||||||
|
pub fn sites_dir() -> Option<PathBuf> {
|
||||||
|
dirs_home().map(|h| h.join(".chrome-use").join("sites"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dirs_home() -> Option<PathBuf> {
|
||||||
|
std::env::var_os("HOME").map(PathBuf::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed adapter: its `@meta` JSON and the raw `async function(args){...}` source.
|
||||||
|
pub struct Adapter {
|
||||||
|
pub meta: Value,
|
||||||
|
pub func_src: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Adapter {
|
||||||
|
pub fn domain(&self) -> Option<&str> {
|
||||||
|
self.meta.get("domain").and_then(|v| v.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load `<sites>/<name>/<cmd>.js`, splitting the `/* @meta {...} */` header from
|
||||||
|
/// the function body. `spec` is `name/cmd`.
|
||||||
|
pub fn load_adapter(spec: &str) -> Result<Adapter, String> {
|
||||||
|
let (name, cmd) = spec
|
||||||
|
.split_once('/')
|
||||||
|
.ok_or_else(|| format!("site: expected <name>/<command>, got `{spec}`"))?;
|
||||||
|
if name.is_empty()
|
||||||
|
|| cmd.is_empty()
|
||||||
|
|| name.contains("..")
|
||||||
|
|| cmd.contains("..")
|
||||||
|
|| name.contains('/')
|
||||||
|
|| cmd.contains('/')
|
||||||
|
{
|
||||||
|
return Err(format!("site: invalid adapter spec `{spec}`"));
|
||||||
|
}
|
||||||
|
let dir = sites_dir().ok_or("site: cannot resolve home dir")?;
|
||||||
|
let path = dir.join(name).join(format!("{cmd}.js"));
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(format!(
|
||||||
|
"site: adapter `{spec}` not found. Run `chrome-use site update` to sync adapters, \
|
||||||
|
or `chrome-use site list` to see what's installed."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let raw = std::fs::read_to_string(&path).map_err(|e| format!("site: read {spec}: {e}"))?;
|
||||||
|
parse_adapter(&raw, spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split the `@meta` JSON block and the function source from an adapter file.
|
||||||
|
pub fn parse_adapter(raw: &str, spec: &str) -> Result<Adapter, String> {
|
||||||
|
let start = raw
|
||||||
|
.find("@meta")
|
||||||
|
.and_then(|i| raw[i..].find('{').map(|j| i + j))
|
||||||
|
.ok_or_else(|| format!("site: {spec} missing /* @meta {{...}} */ header"))?;
|
||||||
|
// Find the matching close brace for the @meta object (brace-count, string-aware).
|
||||||
|
let bytes = raw.as_bytes();
|
||||||
|
let mut depth = 0i32;
|
||||||
|
let mut in_str = false;
|
||||||
|
let mut esc = false;
|
||||||
|
let mut end = None;
|
||||||
|
for (k, &b) in bytes.iter().enumerate().skip(start) {
|
||||||
|
if in_str {
|
||||||
|
if esc {
|
||||||
|
esc = false;
|
||||||
|
} else if b == b'\\' {
|
||||||
|
esc = true;
|
||||||
|
} else if b == b'"' {
|
||||||
|
in_str = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match b {
|
||||||
|
b'"' => in_str = true,
|
||||||
|
b'{' => depth += 1,
|
||||||
|
b'}' => {
|
||||||
|
depth -= 1;
|
||||||
|
if depth == 0 {
|
||||||
|
end = Some(k + 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let end = end.ok_or_else(|| format!("site: {spec} @meta header has no closing brace"))?;
|
||||||
|
let meta: Value = serde_json::from_str(&raw[start..end])
|
||||||
|
.map_err(|e| format!("site: {spec} @meta is not valid JSON: {e}"))?;
|
||||||
|
// The function is everything after the meta comment's closing `*/`.
|
||||||
|
let after = raw[end..].find("*/").map(|i| end + i + 2).unwrap_or(end);
|
||||||
|
let func_src = raw[after..].trim().to_string();
|
||||||
|
if func_src.is_empty() {
|
||||||
|
return Err(format!("site: {spec} has no function body after @meta"));
|
||||||
|
}
|
||||||
|
Ok(Adapter { meta, func_src })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the JS to eval: `(<adapter function>)(<args JSON>)`. The adapter's
|
||||||
|
/// `async function(args)` returns a promise; chrome-use's eval awaits it.
|
||||||
|
pub fn build_eval(adapter: &Adapter, args: &Value) -> String {
|
||||||
|
let args_json = serde_json::to_string(args).unwrap_or_else(|_| "{}".to_string());
|
||||||
|
format!("({})({})", adapter.func_src, args_json)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List installed adapters as `name/cmd` strings (sorted).
|
||||||
|
pub fn list_adapters() -> Result<Vec<String>, String> {
|
||||||
|
let dir = sites_dir().ok_or("site: cannot resolve home dir")?;
|
||||||
|
if !dir.exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for site in std::fs::read_dir(&dir)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
if !site.path().is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = site.file_name().to_string_lossy().to_string();
|
||||||
|
for cmd in std::fs::read_dir(site.path())
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
let p = cmd.path();
|
||||||
|
if p.extension().and_then(|e| e.to_str()) == Some("js") {
|
||||||
|
if let Some(stem) = p.file_stem().and_then(|s| s.to_str()) {
|
||||||
|
out.push(format!("{name}/{stem}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort();
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download the bb-sites repo zip and extract its adapters into `~/.chrome-use/sites`.
|
||||||
|
pub async fn update() -> Result<usize, String> {
|
||||||
|
let dir = sites_dir().ok_or("site: cannot resolve home dir")?;
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.user_agent("chrome-use")
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let bytes = client
|
||||||
|
.get(SITES_ZIP_URL)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("site update: download failed: {e}"))?
|
||||||
|
.error_for_status()
|
||||||
|
.map_err(|e| format!("site update: {e}"))?
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("site update: read body: {e}"))?;
|
||||||
|
|
||||||
|
let cursor = std::io::Cursor::new(bytes);
|
||||||
|
let mut zip = zip::ZipArchive::new(cursor).map_err(|e| format!("site update: bad zip: {e}"))?;
|
||||||
|
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||||
|
let mut count = 0usize;
|
||||||
|
for i in 0..zip.len() {
|
||||||
|
let mut f = zip.by_index(i).map_err(|e| e.to_string())?;
|
||||||
|
let Some(enclosed) = f.enclosed_name() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// Strip the top-level `bb-sites-main/` component from the archive path.
|
||||||
|
let rel: PathBuf = enclosed.components().skip(1).collect();
|
||||||
|
if rel.as_os_str().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let out = dir.join(&rel);
|
||||||
|
if f.is_dir() {
|
||||||
|
let _ = std::fs::create_dir_all(&out);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(parent) = out.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
std::io::copy(&mut f, &mut buf).map_err(|e| e.to_string())?;
|
||||||
|
std::fs::write(&out, &buf).map_err(|e| e.to_string())?;
|
||||||
|
if out.extension().and_then(|e| e.to_str()) == Some("js") {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map CLI args to the adapter's `args` object. Positional args fill the adapter's
|
||||||
|
/// declared `args` keys in order; `--key value` overrides by name. The adapter
|
||||||
|
/// validates required args itself.
|
||||||
|
pub fn map_args(adapter: &Adapter, positional: &[String], named: &[(String, String)]) -> Value {
|
||||||
|
let mut obj = serde_json::Map::new();
|
||||||
|
let keys: Vec<String> = adapter
|
||||||
|
.meta
|
||||||
|
.get("args")
|
||||||
|
.and_then(|a| a.as_object())
|
||||||
|
.map(|m| m.keys().cloned().collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
for (i, val) in positional.iter().enumerate() {
|
||||||
|
if let Some(k) = keys.get(i) {
|
||||||
|
obj.insert(k.clone(), Value::String(val.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (k, v) in named {
|
||||||
|
obj.insert(k.clone(), Value::String(v.clone()));
|
||||||
|
}
|
||||||
|
Value::Object(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const SAMPLE: &str = r#"/* @meta
|
||||||
|
{
|
||||||
|
"name": "github/issues",
|
||||||
|
"domain": "github.com",
|
||||||
|
"args": { "repo": {"required": true}, "state": {"required": false} }
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
async function(args) { return { repo: args.repo }; }"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_meta_and_function() {
|
||||||
|
let a = parse_adapter(SAMPLE, "github/issues").unwrap();
|
||||||
|
assert_eq!(a.domain(), Some("github.com"));
|
||||||
|
assert!(a.func_src.starts_with("async function(args)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_eval_wraps_and_passes_args() {
|
||||||
|
let a = parse_adapter(SAMPLE, "github/issues").unwrap();
|
||||||
|
let args = map_args(
|
||||||
|
&a,
|
||||||
|
&["owner/repo".into()],
|
||||||
|
&[("state".into(), "closed".into())],
|
||||||
|
);
|
||||||
|
let js = build_eval(&a, &args);
|
||||||
|
assert!(js.contains("async function(args)"));
|
||||||
|
assert!(js.contains("\"repo\":\"owner/repo\""));
|
||||||
|
assert!(js.contains("\"state\":\"closed\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_bad_spec() {
|
||||||
|
assert!(load_adapter("noslash").is_err());
|
||||||
|
assert!(load_adapter("../etc/passwd").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "chrome-use",
|
"name": "chrome-use",
|
||||||
"version": "1.5.18",
|
"version": "1.5.19",
|
||||||
"description": "chrome-use — drive your real, logged-in Chrome from any AI agent, stealth by default",
|
"description": "chrome-use — drive your real, logged-in Chrome from any AI agent, stealth by default",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@11.1.3",
|
"packageManager": "pnpm@11.1.3",
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ need a **real, logged-in browser** — not for reading text off a public page.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Discover what exists / find sources | `WebSearch` |
|
| Discover what exists / find sources | `WebSearch` |
|
||||||
| Specific facts from a static or public page | `WebFetch` or `curl` (no browser) |
|
| Specific facts from a static or public page | `WebFetch` or `curl` (no browser) |
|
||||||
|
| **Structured data from a known site** (GitHub issues, Reddit/HN search, Bilibili/Twitter feed, …) — esp. behind login | `chrome-use site <name>/<cmd>` (see below) — skip snapshot+click entirely |
|
||||||
| Login state, interaction, JS-rendered or anti-bot pages | **chrome-use** (this skill) |
|
| Login state, interaction, JS-rendered or anti-bot pages | **chrome-use** (this skill) |
|
||||||
| A page the user saved before / an internal system | `chrome-use find-url <keywords>` (their bookmarks), then open it |
|
| A page the user saved before / an internal system | `chrome-use find-url <keywords>` (their bookmarks), then open it |
|
||||||
| The user's **own already-open, logged-in** Chrome window | the **extension connect** flow (below) |
|
| The user's **own already-open, logged-in** Chrome window | the **extension connect** flow (below) |
|
||||||
@@ -196,6 +197,27 @@ chrome-use eval "[...document.forms[0].elements].filter(e=>!e.validity.valid).ma
|
|||||||
chrome-use eval "document.querySelector('#stubborn').click()" # direct DOM click, bypasses overlays
|
chrome-use eval "document.querySelector('#stubborn').click()" # direct DOM click, bypasses overlays
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Site adapters — the cheapest path for "read structured data from site X"
|
||||||
|
|
||||||
|
Before you `open` + `snapshot` + click your way through GitHub/Reddit/Bilibili/etc.,
|
||||||
|
check whether a **site adapter** already exists. An adapter is a community-written JS
|
||||||
|
function that hits the site's own JSON API *from inside your logged-in tab* and returns
|
||||||
|
clean structured data — no clicking, no scraping, no screenshots. It's the same idea as
|
||||||
|
`eval`, packaged per-site.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chrome-use site update # one-time: fetch the adapter pack (~145 cmds)
|
||||||
|
chrome-use site list # what's installed (github/issues, reddit/search, …)
|
||||||
|
chrome-use site info github/issues # an adapter's args + which domain it runs on
|
||||||
|
chrome-use site github/issues owner/repo --json # run it → JSON (navigates there for you)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Positional args fill the adapter's declared args **in order**; `--key value` overrides by name.
|
||||||
|
- It navigates to the adapter's domain (reusing the current tab if you're already on it), so
|
||||||
|
login-gated feeds (`bilibili/feed`, `twitter/...`) work because they run as *you*.
|
||||||
|
- If no adapter fits, fall back to the normal `snapshot`/`eval` loop. Adapters come from the
|
||||||
|
[bb-sites](https://github.com/epiral/bb-sites) community pack; chrome-use fetches & runs them.
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
Reference in New Issue
Block a user