536 lines
28 KiB
Rust
536 lines
28 KiB
Rust
// Websites: read a public page, open a link in the person's browser, and read the tab they have
|
||
// open there (where they're already logged in: Threads, Instagram, Gmail…). The original reaches
|
||
// sites through its own browser; its old "copy logins from Chrome" import is retired (SAND-E0801..
|
||
// E0812 all say "removed"), so nothing is copied here either. AnyBot's own browser (botbrowser.rs)
|
||
// is used when a Chrome-family browser is installed; AppleScript on the person's browser otherwise.
|
||
|
||
use serde_json::{json, Value};
|
||
use std::process::{Command, Stdio};
|
||
use std::sync::atomic::{AtomicBool, Ordering};
|
||
use std::time::{Duration, Instant};
|
||
|
||
pub const NAMES: [&str; 4] = ["web_fetch", "browser_open", "browser_read", "browser_profiles"];
|
||
/// Longest page text handed to the model (estimated).
|
||
pub const MAX_PAGE_CHARS: usize = 20_000;
|
||
/// Largest download read for web_fetch (estimated).
|
||
const MAX_FETCH_BYTES: u64 = 3_000_000;
|
||
/// AppleScript can wait on macOS's "allow AnyBot to control Chrome?" prompt; give the person time.
|
||
const SCRIPT_TIMEOUT_SECS: u64 = 90;
|
||
/// Time for a page to load after opening it (estimated).
|
||
const OPEN_SETTLE_SECS: u64 = 4;
|
||
|
||
/// Browsers in the order they're tried (Chromium-style ones share the same AppleScript).
|
||
const BROWSERS: [(&str, bool); 6] = [("Google Chrome", true), ("Arc", true), ("Microsoft Edge", true), ("Brave Browser", true), ("Chromium", true), ("Safari", false)];
|
||
|
||
pub fn is_web_tool(name: &str) -> bool {
|
||
NAMES.contains(&name) || crate::browser::is_tool(name)
|
||
}
|
||
|
||
pub fn label(name: &str) -> &'static str {
|
||
match name {
|
||
"web_fetch" => "讀取網頁",
|
||
"browser_open" => "在瀏覽器打開",
|
||
"browser_read" => "讀取瀏覽器分頁",
|
||
"browser_profiles" => "瀏覽器設定檔",
|
||
n if crate::browser::is_tool(n) => crate::browser::label(n),
|
||
_ => "網頁",
|
||
}
|
||
}
|
||
|
||
/// Does this call use the person's own browser ("你的瀏覽器", or the fallback when no
|
||
/// Chrome-family browser is installed)?
|
||
pub fn uses_person_browser(args: &str) -> bool {
|
||
// The box has one browser, the bot's own; the person's browser is never in there.
|
||
if crate::botbrowser::using_box() {
|
||
return false;
|
||
}
|
||
if !crate::botbrowser::available() {
|
||
return true;
|
||
}
|
||
let v: Value = serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or(Value::Null);
|
||
crate::botbrowser::resolve(Some(arg(&v, "profile").as_str())).map(|p| p == crate::botbrowser::PERSON_PROFILE).unwrap_or(false)
|
||
}
|
||
|
||
/// browser_open / browser_read on the person's own browser run on this Mac.
|
||
pub fn uses_this_computer(name: &str, args: &str) -> bool {
|
||
// AnyBot's own browser is the bot's, like Grok Bot's; only the person's own browser counts
|
||
// as working on their computer. Profiles are the bot's browser's, never the person's.
|
||
(name == "browser_open" || name == "browser_read") && uses_person_browser(args)
|
||
}
|
||
|
||
/// Reading a tab asks first only when it's the person's own browser.
|
||
pub fn read_needs_permission(args: &str) -> bool {
|
||
uses_person_browser(args)
|
||
}
|
||
|
||
/// The browser tools. `browsers`: the computers with an AnyBot browser this bot can use (its own,
|
||
/// this one, the account's other computers), by name; each has its own logins.
|
||
pub fn specs_for(browsers: &[String], default: &str) -> Vec<Value> {
|
||
let mut v = specs_inner(!browsers.is_empty());
|
||
if !browsers.is_empty() {
|
||
let desc = format!("Which computer's AnyBot browser to use, by the computer's name. Each computer's browser has its own logins (a site logged in on one isn't on another). Default: {default}. Use another only when the person names it.");
|
||
for t in v.iter_mut().filter(|t| t["name"].as_str().map(|n| n.starts_with("browser_")).unwrap_or(false)) {
|
||
t["parameters"]["properties"]["computer"] = json!({"type": "string", "enum": browsers, "description": desc});
|
||
}
|
||
}
|
||
v
|
||
}
|
||
|
||
fn specs_inner(browser: bool) -> Vec<Value> {
|
||
let (names, current) = if crate::botbrowser::available() {
|
||
(crate::botbrowser::choices().join(", "), crate::botbrowser::current())
|
||
} else {
|
||
(crate::botbrowser::DEFAULT_PROFILE.to_string(), crate::botbrowser::DEFAULT_PROFILE.to_string())
|
||
};
|
||
let profile = json!({"type": "string", "description": format!("Which AnyBot browser profile (設定檔) to use; each has its own logins (e.g. a different account). Profiles now: {names}. \"{}\" is the person's own everyday browser (read through accessibility), in whichever Chrome profile they have open there. Leave out to use the current one ({current}).", crate::botbrowser::PERSON_PROFILE)});
|
||
let mut v = vec![
|
||
json!({"name": "web_fetch", "description": "Download a public web page and return its text (no login). For pages that need the person's login, or that only show text after scripts run, use browser_open.",
|
||
"parameters": {"type": "object", "properties": {"url": {"type": "string"}}, "required": ["url"]}}),
|
||
json!({"name": "browser_open", "description": "Open a link in AnyBot's own browser window (a separate browser profile the person logs in to once, for sites like Threads, Instagram, Facebook, Gmail). Waits until the page has loaded and returns what it shows, like browser_read (title, address, text and the numbered links, buttons and fields), so there is no need to call browser_read right after. Never ask the person to change browser settings.",
|
||
"parameters": {"type": "object", "properties": {"url": {"type": "string"}, "profile": profile}, "required": ["url"]}}),
|
||
json!({"name": "browser_read", "description": "Read the title, address and visible text of the current tab in AnyBot's browser (logged-in pages included), plus a numbered list of its links (with their addresses), buttons, fields and dropdowns. Use those numbers with browser_click / browser_type / browser_select instead of guessing URLs. Each computer has its own AnyBot browser with its own logins (on a bot's own computer it is the Chromium on its desktop). For anything on a web page, read it here and act by these numbers; never guess screen coordinates on a web page. If it shows a login page, hand the computer to the person (hand_over_computer) to log in once.",
|
||
"parameters": {"type": "object", "properties": {"max_chars": {"type": "integer", "description": "Longest text to return (default 20000)."}, "profile": profile}}}),
|
||
json!({"name": "browser_profiles", "description": "List, add or switch AnyBot browser profiles (設定檔/profile). Use when the person wants another profile or account: 'list' shows them, 'add' makes a new empty one (they log in once there) and switches to it, 'use' switches. Switching changes which profile browser_open/browser_read use.",
|
||
"parameters": {"type": "object", "properties": {"action": {"type": "string", "enum": ["list", "add", "use"]}, "name": {"type": "string", "description": "Profile name for add/use."}}, "required": ["action"]}}),
|
||
];
|
||
if browser {
|
||
v.extend(crate::browser::specs(&profile));
|
||
}
|
||
v
|
||
}
|
||
|
||
fn arg(v: &Value, k: &str) -> String {
|
||
v.get(k).and_then(Value::as_str).unwrap_or("").trim().to_string()
|
||
}
|
||
|
||
pub fn describe(name: &str, args: &str) -> String {
|
||
let v: Value = serde_json::from_str(args).unwrap_or(Value::Null);
|
||
match name {
|
||
"browser_read" => match arg(&v, "profile") {
|
||
p if p.is_empty() => "目前的分頁".into(),
|
||
p => format!("目前的分頁(設定檔「{p}」)"),
|
||
},
|
||
"browser_profiles" => match (arg(&v, "action").as_str(), arg(&v, "name")) {
|
||
("add" | "create", n) => format!("新增「{n}」"),
|
||
("use" | "switch", n) => format!("切換到「{n}」"),
|
||
_ => "列出設定檔".into(),
|
||
},
|
||
n if crate::browser::is_tool(n) => crate::browser::describe(n, args),
|
||
_ => arg(&v, "url"),
|
||
}
|
||
}
|
||
|
||
/// Run a web tool, with a picture when it took one (browser_screenshot).
|
||
pub fn run_full(name: &str, args: &str, cancel: &AtomicBool) -> (String, bool, Option<String>) {
|
||
if crate::browser::is_tool(name) {
|
||
if !crate::botbrowser::available() {
|
||
return ("這些瀏覽器工具需要 AnyBot 自己的瀏覽器(Chrome、Brave、Edge 或 Chromium)。".into(), false, None);
|
||
}
|
||
if uses_person_browser(args) {
|
||
return ("點擊、輸入和其他操作只能在 AnyBot 自己的瀏覽器裡做。請用 browser_open 在 AnyBot 瀏覽器打開網頁(第一次要請使用者在那個視窗登入)。".into(), false, None);
|
||
}
|
||
let v: Value = serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or(Value::Null);
|
||
return match crate::browser::run(name, &v) {
|
||
Ok((t, img)) => (t, true, img),
|
||
Err(e) => (e, false, None),
|
||
};
|
||
}
|
||
let (t, ok) = run(name, args, cancel);
|
||
(t, ok, None)
|
||
}
|
||
|
||
/// Run a web tool. Returns (text, ok).
|
||
pub fn run(name: &str, args: &str, cancel: &AtomicBool) -> (String, bool) {
|
||
let v: Value = serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or(Value::Null);
|
||
let r = match name {
|
||
"web_fetch" => fetch(&arg(&v, "url")),
|
||
"browser_open" if crate::botbrowser::available() && uses_person_browser(args) => browser_open(&arg(&v, "url"), cancel),
|
||
"browser_read" if crate::botbrowser::available() && uses_person_browser(args) => browser_read(v.get("max_chars").and_then(Value::as_u64).map(|n| n as usize).unwrap_or(MAX_PAGE_CHARS).min(MAX_PAGE_CHARS * 2), cancel),
|
||
"browser_open" if crate::botbrowser::available() => normalize_url(&arg(&v, "url")).and_then(|u| crate::botbrowser::open(&u, Some(arg(&v, "profile").as_str()))),
|
||
"browser_read" if crate::botbrowser::available() => crate::botbrowser::read(v.get("max_chars").and_then(Value::as_u64).map(|n| n as usize).unwrap_or(MAX_PAGE_CHARS).min(MAX_PAGE_CHARS * 2), Some(arg(&v, "profile").as_str())),
|
||
"browser_profiles" if crate::botbrowser::available() => crate::botbrowser::profiles_tool(&arg(&v, "action"), &arg(&v, "name")),
|
||
"browser_profiles" => Err("設定檔要 AnyBot 自己的瀏覽器(需要 Chrome、Brave、Edge 或 Chromium)。現在用的是你自己的 Safari。".into()),
|
||
"browser_open" => browser_open(&arg(&v, "url"), cancel),
|
||
"browser_read" => browser_read(v.get("max_chars").and_then(Value::as_u64).map(|n| n as usize).unwrap_or(MAX_PAGE_CHARS).min(MAX_PAGE_CHARS * 2), cancel),
|
||
other => Err(format!("沒有「{other}」這個工具")),
|
||
};
|
||
match r {
|
||
Ok(s) => (s, true),
|
||
Err(e) => (e, false),
|
||
}
|
||
}
|
||
|
||
pub fn normalize_url(u: &str) -> Result<String, String> {
|
||
let u = u.trim();
|
||
if u.is_empty() {
|
||
return Err("請給網址".into());
|
||
}
|
||
let full = if u.starts_with("http://") || u.starts_with("https://") { u.to_string() } else { format!("https://{u}") };
|
||
// Check the host part after the scheme (a fixed [8..] cut one byte short for http://).
|
||
let rest = full.split_once("://").map(|(_, r)| r).unwrap_or(&full);
|
||
if full.contains(char::is_whitespace) || !rest.contains('.') && !rest.starts_with("localhost") {
|
||
return Err(format!("「{u}」看起來不是網址"));
|
||
}
|
||
Ok(full)
|
||
}
|
||
|
||
// ---------- web_fetch ----------
|
||
|
||
fn fetch(url: &str) -> Result<String, String> {
|
||
let url = normalize_url(url)?;
|
||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||
.http_status_as_error(false)
|
||
.timeout_global(Some(Duration::from_secs(20)))
|
||
.user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15")
|
||
.build()
|
||
.into();
|
||
let resp = agent.get(&url).call().map_err(|e| format!("連不上 {url}:{e}"))?;
|
||
let status = resp.status().as_u16();
|
||
let body = resp.into_body().with_config().limit(MAX_FETCH_BYTES).read_to_string().map_err(|e| format!("讀取失敗:{e}"))?;
|
||
if status >= 400 {
|
||
return Err(format!("網站回應 {status}。如果這個頁面要登入,請改用 browser_open。"));
|
||
}
|
||
let title = between(&body, "<title", "</title>").map(|t| t.split_once('>').map(|x| x.1).unwrap_or(t).trim().to_string()).unwrap_or_default();
|
||
let text = html_to_text(&body);
|
||
let thin = text.chars().count() < 200;
|
||
let mut out = format!("網址:{url}\n標題:{}\n\n{}", decode_entities(&title), clip(&text, MAX_PAGE_CHARS));
|
||
if thin {
|
||
out.push_str("\n\n(頁面幾乎沒有文字,可能要登入或靠程式載入。可以改用 browser_open 打開,它會等頁面載入再回傳內容。)");
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
fn between<'a>(s: &'a str, a: &str, b: &str) -> Option<&'a str> {
|
||
let lower = s.to_ascii_lowercase();
|
||
let i = lower.find(a)?;
|
||
let j = lower[i..].find(b)? + i;
|
||
s.get(i + a.len()..j)
|
||
}
|
||
|
||
fn clip(s: &str, n: usize) -> String {
|
||
let mut t: String = s.chars().take(n).collect();
|
||
if s.chars().count() > n {
|
||
t.push_str("\n…(後面還有,已截斷)");
|
||
}
|
||
t
|
||
}
|
||
|
||
pub fn decode_entities(s: &str) -> String {
|
||
let mut out = s.replace(" ", " ").replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("'", "'");
|
||
// Numeric entities like 中
|
||
while let Some(i) = out.find("&#") {
|
||
let Some(j) = out[i..].find(';').map(|j| j + i) else { break };
|
||
let code = &out[i + 2..j];
|
||
let ch = if let Some(h) = code.strip_prefix('x').or_else(|| code.strip_prefix('X')) { u32::from_str_radix(h, 16).ok() } else { code.parse().ok() };
|
||
match ch.and_then(char::from_u32) {
|
||
Some(c) => out.replace_range(i..=j, &c.to_string()),
|
||
None => break,
|
||
}
|
||
}
|
||
out.replace("&", "&")
|
||
}
|
||
|
||
/// Where `<tag` opens as that very tag: `<head>` and `<head lang=…>`, never `<header>` (taking
|
||
/// `<header>` for `<head>` cut every page with a header off there — articles came back as their
|
||
/// menus).
|
||
fn open_tag(lower: &str, tag: &str) -> Option<usize> {
|
||
let pat = format!("<{tag}");
|
||
let mut from = 0;
|
||
while let Some(i) = lower[from..].find(&pat).map(|i| i + from) {
|
||
match lower[i + pat.len()..].chars().next() {
|
||
Some(c) if c == '>' || c == '/' || c.is_whitespace() => return Some(i),
|
||
None => return Some(i),
|
||
_ => from = i + pat.len(),
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Visible text of an HTML page: drop scripts, styles and tags, keep line breaks for blocks.
|
||
pub fn html_to_text(html: &str) -> String {
|
||
let mut s = html.to_string();
|
||
for tag in ["script", "style", "noscript", "svg", "head"] {
|
||
loop {
|
||
let lower = s.to_ascii_lowercase();
|
||
let Some(a) = open_tag(&lower, tag) else { break };
|
||
let end_tag = format!("</{tag}>");
|
||
match lower[a..].find(&end_tag) {
|
||
Some(b) => s.replace_range(a..a + b + end_tag.len(), " "),
|
||
None => {
|
||
s.truncate(a);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let mut out = String::new();
|
||
let mut in_tag = false;
|
||
let mut tag = String::new();
|
||
for c in s.chars() {
|
||
match c {
|
||
'<' => {
|
||
in_tag = true;
|
||
tag.clear();
|
||
}
|
||
'>' if in_tag => {
|
||
in_tag = false;
|
||
let t = tag.trim_start_matches('/').split(|c: char| c.is_whitespace()).next().unwrap_or("").to_ascii_lowercase();
|
||
if ["p", "div", "br", "li", "h1", "h2", "h3", "h4", "h5", "h6", "tr", "section", "article", "header", "footer", "main", "nav", "aside", "ul", "ol", "table", "blockquote", "pre", "dt", "dd", "figcaption"].contains(&t.as_str()) {
|
||
out.push('\n');
|
||
} else if t == "td" || t == "th" {
|
||
out.push(' ');
|
||
}
|
||
}
|
||
_ if in_tag => tag.push(c),
|
||
_ => out.push(c),
|
||
}
|
||
}
|
||
let text = decode_entities(&out);
|
||
let lines: Vec<String> = text.lines().map(|l| l.split_whitespace().collect::<Vec<_>>().join(" ")).filter(|l| !l.is_empty()).collect();
|
||
lines.join("\n")
|
||
}
|
||
|
||
// ---------- the person's browser ----------
|
||
|
||
fn applescript_string(s: &str) -> String {
|
||
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
|
||
}
|
||
|
||
/// Run AppleScript and wait (macOS may first ask the person to allow AnyBot to control the browser).
|
||
fn osascript(script: &str, cancel: &AtomicBool) -> Result<String, String> {
|
||
if !cfg!(target_os = "macos") {
|
||
return Err("在 Linux 上讀不到你自己瀏覽器裡的分頁。請改用 AnyBot 的瀏覽器(profile 用「預設」或其他設定檔)打開那個網址再讀;要登入的網站,請先在 AnyBot 的瀏覽器登入一次。".into());
|
||
}
|
||
let mut child = Command::new("osascript").arg("-e").arg(script).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| e.to_string())?;
|
||
let start = Instant::now();
|
||
loop {
|
||
if let Ok(Some(_)) = child.try_wait() {
|
||
let out = child.wait_with_output().map_err(|e| e.to_string())?;
|
||
let so = String::from_utf8_lossy(&out.stdout).trim_end().to_string();
|
||
let se = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||
return if out.status.success() { Ok(so) } else { Err(se) };
|
||
}
|
||
if cancel.load(Ordering::Relaxed) {
|
||
let _ = child.kill();
|
||
return Err("已停止".into());
|
||
}
|
||
if start.elapsed() > Duration::from_secs(SCRIPT_TIMEOUT_SECS) {
|
||
let _ = child.kill();
|
||
return Err("等瀏覽器回應太久了(macOS 可能在問你要不要允許,請看看螢幕)".into());
|
||
}
|
||
std::thread::sleep(Duration::from_millis(100));
|
||
}
|
||
}
|
||
|
||
fn is_running(app: &str, cancel: &AtomicBool) -> bool {
|
||
osascript(&format!("application {} is running", applescript_string(app)), cancel).map(|s| s == "true").unwrap_or(false)
|
||
}
|
||
|
||
/// On-screen windows, front to back, the way the original's `listWindows` (sand_window_capture.node
|
||
/// 0x52c0) reads them: `CGWindowListCopyWindowInfo(0x11 = on-screen only | exclude desktop, 0)`.
|
||
const WINDOW_LIST_JXA: &str = "ObjC.import('CoreGraphics');var a=ObjC.deepUnwrap(ObjC.castRefToObject($.CGWindowListCopyWindowInfo(0x11,0)))||[];JSON.stringify(a.map(function(w){var b=w.kCGWindowBounds||{};return {owner:w.kCGWindowOwnerName||'',layer:w.kCGWindowLayer||0,alpha:(w.kCGWindowAlpha===undefined?1:w.kCGWindowAlpha),w:b.Width||0,h:b.Height||0};}));";
|
||
|
||
/// App names of the windows that can be seen, front first. Same filter as `listWindows`: skip
|
||
/// fully transparent windows and ones with no width or height; plus only normal app windows
|
||
/// (layer 0), so menu-bar items and overlays don't count.
|
||
pub fn visible_window_owners(json: &str) -> Vec<String> {
|
||
let v: Value = serde_json::from_str(json).unwrap_or(Value::Null);
|
||
v.as_array()
|
||
.map(|a| {
|
||
a.iter()
|
||
.filter(|w| w["alpha"].as_f64().unwrap_or(1.0) > 0.0 && w["w"].as_f64().unwrap_or(0.0) > 0.0 && w["h"].as_f64().unwrap_or(0.0) > 0.0 && w["layer"].as_i64().unwrap_or(0) == 0)
|
||
.filter_map(|w| w["owner"].as_str().map(str::to_string))
|
||
.collect()
|
||
})
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
fn front_window_owners(cancel: &AtomicBool) -> Vec<String> {
|
||
if !cfg!(target_os = "macos") {
|
||
return vec![];
|
||
}
|
||
let Ok(mut child) = Command::new("osascript").args(["-l", "JavaScript", "-e", WINDOW_LIST_JXA]).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn() else { return vec![] };
|
||
let start = Instant::now();
|
||
while start.elapsed() < Duration::from_secs(10) && !cancel.load(Ordering::Relaxed) {
|
||
if let Ok(Some(_)) = child.try_wait() {
|
||
return child.wait_with_output().map(|o| visible_window_owners(&String::from_utf8_lossy(&o.stdout))).unwrap_or_default();
|
||
}
|
||
std::thread::sleep(Duration::from_millis(50));
|
||
}
|
||
let _ = child.kill();
|
||
vec![]
|
||
}
|
||
|
||
/// The browser whose window is closest to the front: the one the person is looking at.
|
||
pub fn frontmost_browser(owners: &[String]) -> Option<(&'static str, bool)> {
|
||
owners.iter().find_map(|o| BROWSERS.iter().find(|(name, _)| name == o).copied())
|
||
}
|
||
|
||
/// The browser to use: the front-most browser window, else the first one that's open, else
|
||
/// Chrome if installed, else Safari.
|
||
fn pick_browser(cancel: &AtomicBool) -> (&'static str, bool) {
|
||
if let Some(b) = frontmost_browser(&front_window_owners(cancel)) {
|
||
return b;
|
||
}
|
||
for (name, chromium) in BROWSERS {
|
||
if is_running(name, cancel) {
|
||
return (name, chromium);
|
||
}
|
||
}
|
||
if std::path::Path::new("/Applications/Google Chrome.app").exists() {
|
||
return ("Google Chrome", true);
|
||
}
|
||
("Safari", false)
|
||
}
|
||
|
||
/// macOS refused to let AnyBot control the browser (-1743): open the Automation settings pane
|
||
/// right away so the switch is in front of the person, and say what to turn on.
|
||
fn explain(err: &str, browser: &str) -> String {
|
||
let lower = err.to_lowercase();
|
||
if err.contains("-1743") || lower.contains("not authorized") || lower.contains("不允許") {
|
||
let _ = Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Automation").spawn();
|
||
format!("macOS 擋下了 AnyBot 控制 {browser}。我已經幫你打開「系統設定 → 隱私權與安全性 → 自動化」:在 AnyBot(或「終端機」)底下打開「{browser}」,然後再叫我試一次。")
|
||
} else if lower.contains("javascript") {
|
||
if browser == "Safari" {
|
||
format!("Safari 還沒允許讀取網頁內容:請在 Safari 選單「開發 → 允許來自 Apple 事件的 JavaScript」打開(沒有「開發」選單的話,先到 Safari 設定 → 進階 → 勾選「顯示網頁開發者功能」),然後再叫我試一次。")
|
||
} else {
|
||
format!("{browser} 還沒允許讀取網頁內容:請在 {browser} 選單「檢視 → 開發人員 → 允許來自 Apple 事件的 JavaScript」打開,然後再叫我試一次。")
|
||
}
|
||
} else {
|
||
format!("{browser} 沒有回應:{err}")
|
||
}
|
||
}
|
||
|
||
fn browser_open(url: &str, cancel: &AtomicBool) -> Result<String, String> {
|
||
let url = normalize_url(url)?;
|
||
if !cfg!(target_os = "macos") {
|
||
// Linux: the person's default browser.
|
||
crate::preview::system_open(&url).status().map_err(|e| format!("打不開瀏覽器:{e}"))?;
|
||
return Ok(format!("已在你的預設瀏覽器打開 {url}。"));
|
||
}
|
||
let (browser, chromium) = pick_browser(cancel);
|
||
// A Chromium browser started without a folder hands the link to the person's own running
|
||
// window (their normal profile), never to AnyBot's own window, which `open -a` might pick.
|
||
let exe = app_exe(browser);
|
||
let handed = chromium
|
||
&& std::path::Path::new(&exe).exists()
|
||
&& {
|
||
let mut c = Command::new(&exe);
|
||
c.arg(&url).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::process::CommandExt;
|
||
c.process_group(0);
|
||
}
|
||
c.spawn().is_ok()
|
||
};
|
||
if !handed {
|
||
let st = Command::new("open").arg("-a").arg(browser).arg(&url).status().map_err(|e| e.to_string())?;
|
||
if !st.success() {
|
||
Command::new("open").arg(&url).status().map_err(|e| e.to_string())?;
|
||
}
|
||
}
|
||
let until = Instant::now() + Duration::from_secs(OPEN_SETTLE_SECS);
|
||
while Instant::now() < until {
|
||
if cancel.load(Ordering::Relaxed) {
|
||
return Err("已停止".into());
|
||
}
|
||
std::thread::sleep(Duration::from_millis(100));
|
||
}
|
||
Ok(format!("已在 {browser} 打開 {url}。接著可以用 browser_read 讀這個分頁。"))
|
||
}
|
||
|
||
/// The main executable of a browser app in /Applications.
|
||
pub fn app_exe(browser: &str) -> String {
|
||
format!("/Applications/{browser}.app/Contents/MacOS/{browser}")
|
||
}
|
||
|
||
fn browser_read(max_chars: usize, cancel: &AtomicBool) -> Result<String, String> {
|
||
let (browser, chromium) = pick_browser(cancel);
|
||
// First through accessibility, like Grok Bot's Computer Use helper: no browser setting, and
|
||
// it reads whichever profile's window the person has in front.
|
||
match crate::axread::read_person_browser(&app_exe(browser), max_chars) {
|
||
Ok(page) if !page.text.trim().is_empty() => {
|
||
return Ok(format!("瀏覽器:{browser}(你的瀏覽器)\n標題:{}\n網址:{}\n\n{}", page.title, page.url, clip(&page.text, max_chars)));
|
||
}
|
||
Err(e) if e.contains("輔助使用") => return Err(e),
|
||
_ => {}
|
||
}
|
||
let app = applescript_string(browser);
|
||
let (title_s, url_s, text_s) = if chromium {
|
||
(
|
||
format!("tell application {app} to get title of active tab of front window"),
|
||
format!("tell application {app} to get URL of active tab of front window"),
|
||
format!("tell application {app} to execute active tab of front window javascript \"document.body ? document.body.innerText : ''\""),
|
||
)
|
||
} else {
|
||
(
|
||
format!("tell application {app} to get name of current tab of front window"),
|
||
format!("tell application {app} to get URL of current tab of front window"),
|
||
format!("tell application {app} to do JavaScript \"document.body ? document.body.innerText : ''\" in current tab of front window"),
|
||
)
|
||
};
|
||
let title = osascript(&title_s, cancel).map_err(|e| explain(&e, browser))?;
|
||
let url = osascript(&url_s, cancel).unwrap_or_default();
|
||
match osascript(&text_s, cancel) {
|
||
Ok(text) => {
|
||
let text: String = text.lines().map(str::trim).filter(|l| !l.is_empty()).collect::<Vec<_>>().join("\n");
|
||
Ok(format!("瀏覽器:{browser}\n標題:{title}\n網址:{url}\n\n{}", clip(&text, max_chars)))
|
||
}
|
||
// Title and address still help; say how to allow the page text.
|
||
Err(e) => Ok(format!("瀏覽器:{browser}\n標題:{title}\n網址:{url}\n\n(讀不到頁面文字){}", explain(&e, browser))),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn a_page_with_a_header_keeps_its_article() {
|
||
// Taking <header> for <head> cut pages off at their header (WordPress sites, news sites).
|
||
let html = "<!doctype html><html><head><title>T</title></head><body><header class=site-header><nav>首頁 課程</nav></header><main><article><h1>預售策略</h1><p>先賣再錄</p><table><tr><td>方案</td><td>價格</td></tr></table></article></main><footer>©</footer></body></html>";
|
||
let t = html_to_text(html);
|
||
assert!(t.contains("首頁 課程") && t.contains("預售策略") && t.contains("先賣再錄") && t.contains("©"), "{t}");
|
||
assert!(t.contains("方案 價格"), "{t}");
|
||
// A page with no <head> at all, only a <header>.
|
||
assert!(html_to_text("<header>選單</header><p>內文</p>").contains("內文"));
|
||
// <head lang=…> is still the head.
|
||
assert!(!html_to_text("<head lang=zh><title>不要</title></head><p>要</p>").contains("不要"));
|
||
}
|
||
|
||
#[test]
|
||
fn html_becomes_readable_text() {
|
||
let html = "<html><head><title>T</title><style>.a{}</style></head><body><script>var x=1;</script><h1>標題</h1><p>第一段 文字 & 更多</p><div>中文</div></body></html>";
|
||
assert_eq!(html_to_text(html), "標題\n第一段 文字 & 更多\n中文");
|
||
}
|
||
|
||
#[test]
|
||
fn urls_are_checked() {
|
||
assert_eq!(normalize_url("threads.net/@me").unwrap(), "https://threads.net/@me");
|
||
assert!(normalize_url("not a url").is_err());
|
||
assert!(normalize_url("").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn front_browser_follows_window_order() {
|
||
let json = r#"[{"owner":"Window Server","layer":24,"alpha":1,"w":1920,"h":30},
|
||
{"owner":"Ghost","layer":0,"alpha":0,"w":800,"h":600},
|
||
{"owner":"Safari","layer":0,"alpha":1,"w":0,"h":600},
|
||
{"owner":"Finder","layer":0,"alpha":1,"w":800,"h":600},
|
||
{"owner":"Google Chrome","layer":0,"alpha":1,"w":1200,"h":800},
|
||
{"owner":"Safari","layer":0,"alpha":1,"w":1200,"h":800}]"#;
|
||
let owners = visible_window_owners(json);
|
||
assert_eq!(owners, vec!["Finder", "Google Chrome", "Safari"]);
|
||
assert_eq!(frontmost_browser(&owners), Some(("Google Chrome", true)));
|
||
assert_eq!(frontmost_browser(&["Finder".to_string()]), None);
|
||
}
|
||
|
||
#[test]
|
||
fn automation_refusal_is_explained() {
|
||
assert!(explain("execution error: Not authorized to send Apple events to Google Chrome. (-1743)", "Google Chrome").contains("自動化"));
|
||
assert!(explain("Executing JavaScript through AppleScript is turned off.", "Google Chrome").contains("允許來自 Apple 事件的 JavaScript"));
|
||
}
|
||
}
|
||
|