// 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 { 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 { 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 { 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, use browser_open then browser_read.", "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 a few seconds for it to load; then call browser_read. 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. AnyBot's browser runs on the person's computer, not on 「Bot 的電腦」: screenshots of 「Bot 的電腦」 never show it. If it shows a login page, ask the person to log in once in that window.", "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) { 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 { 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 { 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 + browser_read。")); } let title = between(&body, "").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 打開,再用 browser_read 讀。)"); } 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("&", "&") } /// 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) = lower.find(&format!("<{tag}")) else { break }; let end_tag = format!(""); 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", "tr", "section", "article", "header", "footer"].contains(&t.as_str()) { out.push('\n'); } } _ if in_tag => tag.push(c), _ => out.push(c), } } let text = decode_entities(&out); let lines: Vec = text.lines().map(|l| l.split_whitespace().collect::>().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 { 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 { 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 { 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 { 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 { 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::>().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 html_becomes_readable_text() { let html = "T

標題

第一段 文字 & 更多

中文
"; 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")); } }