feat: add browser automation commands (v0.6.0)
Navigation: open-wait (open + block until loaded) DOM: key, hover, check/uncheck, clear, focus, submit, poll, scroll, select, eval, wait-for Tabs: pin/unpin, screenshot, watch-url (block until URL matches regex) New command groups: page info, storage get/set, cookies list/get/set Extension: add cookies permission
This commit is contained in:
+300
-6
@@ -127,6 +127,8 @@ async function dispatch(command, args) {
|
||||
case "navigate.back": return navBack(args);
|
||||
case "navigate.forward": return navForward(args);
|
||||
case "navigate.focus": return navFocus(args);
|
||||
case "navigate.wait": return navWait(args);
|
||||
case "navigate.open_wait": return navOpenWait(args);
|
||||
|
||||
// ── Tabs ──────────────────────────────────────────────────────────────
|
||||
case "tabs.list": return tabsList();
|
||||
@@ -144,6 +146,10 @@ async function dispatch(command, args) {
|
||||
case "tabs.merge_windows": return tabsMergeWindows();
|
||||
case "tabs.mute": return tabsMute(args);
|
||||
case "tabs.unmute": return tabsUnmute(args);
|
||||
case "tabs.pin": return tabsPin(args);
|
||||
case "tabs.unpin": return tabsUnpin(args);
|
||||
case "tabs.screenshot": return tabsScreenshot(args);
|
||||
case "tabs.watch_url": return tabsWatchUrl(args);
|
||||
|
||||
// ── Groups ────────────────────────────────────────────────────────────
|
||||
case "group.list": return groupList();
|
||||
@@ -162,12 +168,36 @@ async function dispatch(command, args) {
|
||||
case "windows.open": return windowsOpen(args);
|
||||
|
||||
// ── DOM ───────────────────────────────────────────────────────────────
|
||||
case "dom.query": return domOp("domQuery", args);
|
||||
case "dom.click": return domOp("domClick", args);
|
||||
case "dom.type": return domOp("domType", args);
|
||||
case "dom.attr": return domOp("domAttr", args);
|
||||
case "dom.text": return domOp("domText", args);
|
||||
case "dom.exists": return domOp("domExists", args);
|
||||
case "dom.query": return domOp("domQuery", args);
|
||||
case "dom.click": return domOp("domClick", args);
|
||||
case "dom.type": return domOp("domType", args);
|
||||
case "dom.attr": return domOp("domAttr", args);
|
||||
case "dom.text": return domOp("domText", args);
|
||||
case "dom.exists": return domOp("domExists", args);
|
||||
case "dom.scroll": return domOp("domScroll", args);
|
||||
case "dom.select": return domOp("domSelect", args);
|
||||
case "dom.key": return domOp("domKey", args);
|
||||
case "dom.hover": return domOp("domHover", args);
|
||||
case "dom.check": return domOp("domCheck", { ...args, checked: true });
|
||||
case "dom.uncheck": return domOp("domCheck", { ...args, checked: false });
|
||||
case "dom.clear": return domOp("domClear", args);
|
||||
case "dom.focus": return domOp("domFocus", args);
|
||||
case "dom.submit": return domOp("domSubmit", args);
|
||||
case "dom.eval": return domEval(args);
|
||||
case "dom.wait_for": return domWaitFor(args);
|
||||
case "dom.poll": return domPoll(args);
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────
|
||||
case "page.info": return domOp("pageInfo", {});
|
||||
|
||||
// ── Storage ───────────────────────────────────────────────────────────
|
||||
case "storage.get": return storageGet(args);
|
||||
case "storage.set": return storageSet(args);
|
||||
|
||||
// ── Cookies ───────────────────────────────────────────────────────────
|
||||
case "cookies.list": return cookiesList(args);
|
||||
case "cookies.get": return cookiesGet(args);
|
||||
case "cookies.set": return cookiesSet(args);
|
||||
|
||||
// ── Extract ───────────────────────────────────────────────────────────
|
||||
case "extract.links": return domOp("extractLinks", args);
|
||||
@@ -267,6 +297,25 @@ async function navFocus({ pattern }) {
|
||||
return { id: match.id, url: match.url || match.pendingUrl, title: match.title };
|
||||
}
|
||||
|
||||
async function navWait({ tabId, timeout = 30000, readyState = "complete" } = {}) {
|
||||
const tab = tabId ? { id: tabId } : await getActiveTab();
|
||||
const deadline = Date.now() + timeout;
|
||||
const interval = 200;
|
||||
while (Date.now() < deadline) {
|
||||
const t = await chrome.tabs.get(tab.id);
|
||||
if (readyState === "complete" ? t.status === "complete" : t.status !== "loading") {
|
||||
return tabInfo(t);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, interval));
|
||||
}
|
||||
throw new Error(`Tab ${tab.id} did not reach status '${readyState}' within ${timeout}ms`);
|
||||
}
|
||||
|
||||
async function navOpenWait({ url, timeout = 30000, background, window: windowName, group } = {}) {
|
||||
const opened = await navOpen({ url, background, window: windowName, group });
|
||||
return await navWait({ tabId: opened.id, timeout });
|
||||
}
|
||||
|
||||
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function tabsList() {
|
||||
@@ -442,6 +491,47 @@ async function tabsMergeWindows() {
|
||||
return { moved };
|
||||
}
|
||||
|
||||
async function tabsPin({ tabId }) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
await chrome.tabs.update(tab.id, { pinned: true });
|
||||
return { tabId: tab.id, pinned: true };
|
||||
}
|
||||
|
||||
async function tabsUnpin({ tabId }) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
await chrome.tabs.update(tab.id, { pinned: false });
|
||||
return { tabId: tab.id, pinned: false };
|
||||
}
|
||||
|
||||
async function tabsScreenshot({ tabId, format = "png", quality } = {}) {
|
||||
let windowId;
|
||||
if (tabId) {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
await chrome.tabs.update(tabId, { active: true });
|
||||
windowId = tab.windowId;
|
||||
} else {
|
||||
const tab = await getActiveTab();
|
||||
windowId = tab.windowId;
|
||||
}
|
||||
const opts = { format };
|
||||
if (format === "jpeg" && quality != null) opts.quality = quality;
|
||||
const dataUrl = await chrome.tabs.captureVisibleTab(windowId, opts);
|
||||
return { dataUrl, format };
|
||||
}
|
||||
|
||||
async function tabsWatchUrl({ pattern, timeout = 30000, tabId } = {}) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
const deadline = Date.now() + timeout;
|
||||
const regex = new RegExp(pattern);
|
||||
while (Date.now() < deadline) {
|
||||
const t = await chrome.tabs.get(tab.id);
|
||||
const url = t.url || t.pendingUrl || "";
|
||||
if (regex.test(url)) return tabInfo(t);
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
}
|
||||
throw new Error(`Tab ${tab.id} URL did not match '${pattern}' within ${timeout}ms`);
|
||||
}
|
||||
|
||||
async function tabsMute({ tabId }) {
|
||||
const tab = await resolveTabForDirectAction(tabId, "mute");
|
||||
await chrome.tabs.update(tab.id, { muted: true });
|
||||
@@ -616,6 +706,131 @@ async function domOp(funcName, args) {
|
||||
return results[0]?.result;
|
||||
}
|
||||
|
||||
async function domEval({ code, tabId } = {}) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
if (!isScriptableUrl(tab.url)) {
|
||||
throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`);
|
||||
}
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
world: "MAIN",
|
||||
func: (c) => (0, eval)(c),
|
||||
args: [code],
|
||||
});
|
||||
return results[0]?.result ?? null;
|
||||
}
|
||||
|
||||
async function domWaitFor({ selector, timeout = 10000, visible = false, hidden = false, tabId } = {}) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
if (!isScriptableUrl(tab.url)) {
|
||||
throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`);
|
||||
}
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: (sel, vis, hid) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (hid) return !el || el.offsetParent === null;
|
||||
if (!el) return false;
|
||||
if (vis) {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
args: [selector, visible, hidden],
|
||||
});
|
||||
if (results[0]?.result) return { selector, found: !hidden };
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
}
|
||||
throw new Error(`Selector '${selector}' condition not met within ${timeout}ms`);
|
||||
}
|
||||
|
||||
async function domPoll({ selector, pattern, attr, timeout = 30000, interval = 500, tabId } = {}) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
if (!isScriptableUrl(tab.url)) {
|
||||
throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`);
|
||||
}
|
||||
const deadline = Date.now() + timeout;
|
||||
const regex = new RegExp(pattern);
|
||||
while (Date.now() < deadline) {
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: (sel, a) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return null;
|
||||
if (a) return el.getAttribute(a) ?? el[a] ?? null;
|
||||
return el.value !== undefined ? el.value : el.textContent.trim();
|
||||
},
|
||||
args: [selector, attr || null],
|
||||
});
|
||||
const value = results[0]?.result;
|
||||
if (value != null && regex.test(String(value))) return { selector, value, pattern };
|
||||
await new Promise(r => setTimeout(r, interval));
|
||||
}
|
||||
throw new Error(`Selector '${selector}' did not match '${pattern}' within ${timeout}ms`);
|
||||
}
|
||||
|
||||
async function storageGet({ key, type = "local", tabId } = {}) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
if (!isScriptableUrl(tab.url)) {
|
||||
throw new Error(`Cannot access storage on ${tab.url} — navigate to a regular web page first`);
|
||||
}
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
world: "MAIN",
|
||||
func: (k, t) => {
|
||||
const store = t === "session" ? sessionStorage : localStorage;
|
||||
if (k) return store.getItem(k);
|
||||
return Object.fromEntries(Object.keys(store).map(key => [key, store.getItem(key)]));
|
||||
},
|
||||
args: [key || null, type],
|
||||
});
|
||||
return results[0]?.result ?? null;
|
||||
}
|
||||
|
||||
async function storageSet({ key, value, type = "local", tabId } = {}) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
if (!isScriptableUrl(tab.url)) {
|
||||
throw new Error(`Cannot access storage on ${tab.url} — navigate to a regular web page first`);
|
||||
}
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
world: "MAIN",
|
||||
func: (k, v, t) => {
|
||||
const store = t === "session" ? sessionStorage : localStorage;
|
||||
store.setItem(k, typeof v === "string" ? v : JSON.stringify(v));
|
||||
return true;
|
||||
},
|
||||
args: [key, value, type],
|
||||
});
|
||||
return results[0]?.result ?? false;
|
||||
}
|
||||
|
||||
async function cookiesList({ url, domain, name } = {}) {
|
||||
const details = {};
|
||||
if (url) details.url = url;
|
||||
if (domain) details.domain = domain;
|
||||
if (name) details.name = name;
|
||||
return await chrome.cookies.getAll(details);
|
||||
}
|
||||
|
||||
async function cookiesGet({ url, name }) {
|
||||
return await chrome.cookies.get({ url, name });
|
||||
}
|
||||
|
||||
async function cookiesSet({ url, name, value, domain, path, secure, httpOnly, expirationDate, sameSite } = {}) {
|
||||
const details = { url, name, value };
|
||||
if (domain != null) details.domain = domain;
|
||||
if (path != null) details.path = path;
|
||||
if (secure != null) details.secure = secure;
|
||||
if (httpOnly != null) details.httpOnly = httpOnly;
|
||||
if (expirationDate != null) details.expirationDate = expirationDate;
|
||||
if (sameSite != null) details.sameSite = sameSite;
|
||||
return await chrome.cookies.set(details);
|
||||
}
|
||||
|
||||
// This function is serialized and injected into the page by chrome.scripting
|
||||
function contentDispatch(funcName, args) {
|
||||
function domQuery({ selector }) {
|
||||
@@ -653,6 +868,83 @@ function contentDispatch(funcName, args) {
|
||||
function domExists({ selector }) {
|
||||
return document.querySelector(selector) !== null;
|
||||
}
|
||||
function domKey({ selector, key }) {
|
||||
const el = selector ? document.querySelector(selector) : document.activeElement;
|
||||
if (selector && !el) throw new Error(`No element: ${selector}`);
|
||||
const target = el || document.body;
|
||||
["keydown", "keypress", "keyup"].forEach(type => {
|
||||
target.dispatchEvent(new KeyboardEvent(type, { key, bubbles: true, cancelable: true }));
|
||||
});
|
||||
return true;
|
||||
}
|
||||
function domHover({ selector }) {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) throw new Error(`No element: ${selector}`);
|
||||
el.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
el.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
function domCheck({ selector, checked }) {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) throw new Error(`No element: ${selector}`);
|
||||
el.checked = checked;
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
function domClear({ selector }) {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) throw new Error(`No element: ${selector}`);
|
||||
el.value = "";
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
function domFocus({ selector }) {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) throw new Error(`No element: ${selector}`);
|
||||
el.focus();
|
||||
return true;
|
||||
}
|
||||
function domSubmit({ selector }) {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) throw new Error(`No element: ${selector}`);
|
||||
const form = el.tagName === "FORM" ? el : el.closest("form");
|
||||
if (!form) throw new Error(`No form found for: ${selector}`);
|
||||
form.submit();
|
||||
return true;
|
||||
}
|
||||
function pageInfo() {
|
||||
const metas = {};
|
||||
document.querySelectorAll("meta[name], meta[property]").forEach(m => {
|
||||
const k = m.getAttribute("name") || m.getAttribute("property");
|
||||
if (k) metas[k] = m.getAttribute("content") || "";
|
||||
});
|
||||
return {
|
||||
title: document.title,
|
||||
url: location.href,
|
||||
readyState: document.readyState,
|
||||
lang: document.documentElement.lang || null,
|
||||
meta: metas,
|
||||
};
|
||||
}
|
||||
function domScroll({ selector, x, y }) {
|
||||
if (selector) {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) throw new Error(`No element: ${selector}`);
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
return true;
|
||||
}
|
||||
window.scrollTo({ top: y || 0, left: x || 0, behavior: "smooth" });
|
||||
return true;
|
||||
}
|
||||
function domSelect({ selector, value }) {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) throw new Error(`No element: ${selector}`);
|
||||
el.value = value;
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
function extractLinks() {
|
||||
const seen = new Set();
|
||||
return Array.from(document.querySelectorAll("a[href]")).reduce((links, a) => {
|
||||
@@ -1090,6 +1382,8 @@ function contentDispatch(funcName, args) {
|
||||
}
|
||||
|
||||
const fns = { domQuery, domClick, domType, domAttr, domText, domExists,
|
||||
domScroll, domSelect, domKey, domHover, domCheck, domClear, domFocus, domSubmit,
|
||||
pageInfo,
|
||||
extractLinks, extractImages, extractText, extractJson, extractMarkdown };
|
||||
const fn = fns[funcName];
|
||||
if (!fn) throw new Error(`Unknown content function: ${funcName}`);
|
||||
|
||||
Reference in New Issue
Block a user