The two Tradeify automations clicked the 2captcha *extension's* solve button and waited for it to go green. That needed a second extension installed and signed in, put a real mouse click on a button whose markup is 2captcha's to change, and gave a run no way to tell "still solving" from "not installed". Replaced with one `solveCaptcha` step. No mouse: the extension reads the widget's sitekey out of the page, the dashboard buys a token from 2captcha, and the extension puts it in the response field and fires the page's own callback. captcha-hook.js is the part that earns its keep. A widget rendered programmatically puts nothing in the DOM — no sitekey, and no callback. Most pages only enable submit from that callback, so filling the hidden field alone leaves the page looking unsolved. The hook wraps turnstile/grecaptcha/hcaptcha `render` at document_start to hold on to what the page passed. Solves go through 2captcha's residential proxy. Its API rejects host names in proxyAddress, so na.proxy.2captcha.com is resolved to an IP first (cached); the gateway was confirmed to work addressed that way. Covers reCAPTCHA v2 (incl. invisible and Enterprise), hCaptcha and Turnstile. NOT reCAPTCHA v3: a v3 page carries an anchor iframe with k=, so it is detected as v2 and would be solved with the wrong task type. Everything else unsupported fails loudly instead. The API key and proxy credentials are hardcoded in the captcha route on request. Unlike the sqlite database, that file is in the repo, so they travel with it. TWOCAPTCHA_API_KEY / TWOCAPTCHA_PROXY and the settings rows still override; neither has a field on the Settings page. Runner VERSION and RUNNER_EXPECTED_VERSION both to 0.17.0 — they are a pair, and a mismatch shows as a red "restart your runner" banner that blames the wrong process. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
756 lines
32 KiB
JavaScript
756 lines
32 KiB
JavaScript
/* AutoFirmer Capture — MV3 service worker.
|
|
*
|
|
* Loop: every tick, scrape the tab you are looking at and POST it to
|
|
* /api/autobuyer/capture. The dashboard's AutoBuyer page renders whatever
|
|
* landed there.
|
|
*
|
|
* Nothing here has a switch. Capture is scoped instead: only the ACTIVE tab of
|
|
* the last-focused window, and only when that tab is one of the firm sites in
|
|
* host_permissions. Sit on any other page and nothing is read or sent.
|
|
*
|
|
* The locate queue is served whenever clicks are waiting. The AutoBuyer is
|
|
* ready at all times; a run happens because someone asked for one.
|
|
*/
|
|
|
|
const DEFAULTS = {
|
|
apiBase: 'http://localhost:3000',
|
|
pollSeconds: 3,
|
|
// Optional CSS selector — matching elements get their on-screen position
|
|
// measured alongside the HTML.
|
|
elementSelector: '',
|
|
};
|
|
|
|
let timer = null;
|
|
let pendingLocate = 0;
|
|
|
|
// ── Config / state ──────────────────────────────────────────────────────────
|
|
|
|
async function getConfig() {
|
|
const stored = await chrome.storage.local.get(Object.keys(DEFAULTS));
|
|
const cfg = { ...DEFAULTS, ...stored };
|
|
cfg.apiBase = String(cfg.apiBase).replace(/\/+$/, '');
|
|
return cfg;
|
|
}
|
|
|
|
/** Mirror the worker's status into storage so the popup can render it. */
|
|
async function setState(patch) {
|
|
const { state: prev } = await chrome.storage.local.get('state');
|
|
await chrome.storage.local.set({ state: { ...(prev || {}), ...patch, updatedAt: Date.now() } });
|
|
}
|
|
|
|
/** `!` when the dashboard is unreachable, blank otherwise. A blank badge is the
|
|
* working state — there is nothing to switch on, so nothing to advertise. */
|
|
function setBadge(connected) {
|
|
chrome.action.setBadgeText({ text: connected ? '' : '!' });
|
|
chrome.action.setBadgeBackgroundColor({ color: '#ef4444' });
|
|
}
|
|
|
|
// ── The injected scraper ────────────────────────────────────────────────────
|
|
|
|
/** Runs in the page's own world. Must be self-contained — nothing from this
|
|
* file's scope is available inside it. */
|
|
function pageCapture(selector) {
|
|
// getBoundingClientRect() measures from the top-left of the viewport, so
|
|
// rect.left / rect.top ARE the element's position relative to the window.
|
|
// + scrollX/scrollY -> position within the document
|
|
// + screenX/screenY and the chrome height -> absolute desktop coordinates,
|
|
// which is what an OS-level clicker needs.
|
|
const chromeHeight = window.outerHeight - window.innerHeight;
|
|
|
|
const elements = selector
|
|
? Array.from(document.querySelectorAll(selector)).slice(0, 200).map((el, index) => {
|
|
const r = el.getBoundingClientRect();
|
|
return {
|
|
index,
|
|
tag: el.tagName.toLowerCase(),
|
|
id: el.id || null,
|
|
text: (el.textContent || '').trim().slice(0, 80),
|
|
// Off-screen or display:none elements still return a rect — all zeroes.
|
|
visible: r.width > 0 && r.height > 0,
|
|
inViewport: r.top < window.innerHeight && r.bottom > 0 && r.left < window.innerWidth && r.right > 0,
|
|
viewport: { x: r.left, y: r.top, width: r.width, height: r.height },
|
|
page: { x: r.left + window.scrollX, y: r.top + window.scrollY },
|
|
// Centre of the element in desktop coordinates (CSS pixels —
|
|
// multiply by devicePixelRatio for physical pixels on HiDPI).
|
|
screen: {
|
|
x: window.screenX + r.left + r.width / 2,
|
|
y: window.screenY + chromeHeight + r.top + r.height / 2,
|
|
},
|
|
};
|
|
})
|
|
: [];
|
|
|
|
return {
|
|
url: location.href,
|
|
title: document.title,
|
|
html: document.documentElement.outerHTML,
|
|
viewport: {
|
|
scrollX: window.scrollX,
|
|
scrollY: window.scrollY,
|
|
innerWidth: window.innerWidth,
|
|
innerHeight: window.innerHeight,
|
|
outerWidth: window.outerWidth,
|
|
outerHeight: window.outerHeight,
|
|
screenX: window.screenX,
|
|
screenY: window.screenY,
|
|
chromeHeight,
|
|
devicePixelRatio: window.devicePixelRatio,
|
|
},
|
|
elements,
|
|
};
|
|
}
|
|
|
|
// ── Capture pipeline ────────────────────────────────────────────────────────
|
|
|
|
/** The sites this extension is allowed to touch, taken from the manifest rather
|
|
* than from a setting.
|
|
*
|
|
* These are the same hosts host_permissions grants, so they cannot drift out of
|
|
* sync with what the extension can actually read, and adding a firm — which
|
|
* means adding its host to the manifest anyway — scopes capture automatically.
|
|
* localhost is filtered out: that is the dashboard, and capturing it would just
|
|
* mirror our own output back. */
|
|
function firmPatterns() {
|
|
return chrome.runtime.getManifest().host_permissions
|
|
.filter((p) => !/\/\/(localhost|127\.0\.0\.1)/.test(p));
|
|
}
|
|
|
|
/** The firm tab you are actually looking at, or nothing.
|
|
*
|
|
* Deliberately no fallback to "some other firm tab that happens to be open":
|
|
* capture should report the page in front of you, not a stale background tab
|
|
* you left on a broker site hours ago. Chrome does the pattern matching, so a
|
|
* tab outside host_permissions cannot be selected here at all.
|
|
*/
|
|
async function pickTab() {
|
|
const [tab] = await chrome.tabs.query({
|
|
active: true,
|
|
lastFocusedWindow: true,
|
|
url: firmPatterns(),
|
|
});
|
|
return tab && /^https?:/.test(tab.url || '') ? tab : undefined;
|
|
}
|
|
|
|
async function captureAndSend(cfg) {
|
|
const tab = await pickTab();
|
|
if (!tab) {
|
|
await setState({ connected: true, error: null, waiting: 'Not on a firm page — nothing captured' });
|
|
return;
|
|
}
|
|
|
|
const [injection] = await chrome.scripting.executeScript({
|
|
target: { tabId: tab.id },
|
|
func: pageCapture,
|
|
args: [cfg.elementSelector || ''],
|
|
});
|
|
const result = injection?.result;
|
|
if (!result) throw new Error('Injection returned nothing');
|
|
|
|
const res = await fetch(`${cfg.apiBase}/api/autobuyer/capture`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
...result,
|
|
// Stamp the build so the dashboard side can tell which version of this
|
|
// worker is actually live — Chrome keeps running the old one until the
|
|
// extension is reloaded, which is invisible from the server otherwise.
|
|
viewport: { ...result.viewport, extVersion: chrome.runtime.getManifest().version },
|
|
capturedAt: Date.now(),
|
|
}),
|
|
});
|
|
if (!res.ok) throw new Error(`Capture POST failed: HTTP ${res.status}`);
|
|
|
|
await setState({
|
|
connected: true,
|
|
error: null,
|
|
waiting: null,
|
|
lastCapture: { url: result.url, title: result.title, bytes: result.html.length, at: Date.now() },
|
|
});
|
|
}
|
|
|
|
/** Which browser is hosting this extension.
|
|
*
|
|
* The clicker has to raise *this* browser before clicking, and picking by list
|
|
* order gets it wrong the moment two are installed — it would raise Edge while
|
|
* the coordinates came from a tab in Chrome, landing every click in the wrong
|
|
* window. So the answer travels with the measurement.
|
|
*/
|
|
function detectBrowser() {
|
|
const ua = navigator.userAgent || '';
|
|
if (/\bEdg\//.test(ua)) return 'edge';
|
|
if (/\bOPR\//.test(ua)) return 'opera';
|
|
if (/\bVivaldi\//.test(ua)) return 'vivaldi';
|
|
try {
|
|
if (navigator.brave) return 'brave'; // Brave otherwise reports as Chrome
|
|
} catch { /* not Brave */ }
|
|
return 'chrome';
|
|
}
|
|
|
|
// ── Locate: turn a CSS selector into desktop coordinates ────────────────────
|
|
|
|
/** Runs in the page. Scrolls the element into view, then reports where it ended up. */
|
|
function pageLocate(selector, index) {
|
|
const matches = document.querySelectorAll(selector);
|
|
if (matches.length === 0) return { error: `No element matches ${selector}` };
|
|
const el = matches[index];
|
|
if (!el) return { error: `Selector ${selector} matched ${matches.length} element(s), no index ${index}` };
|
|
|
|
// Centre it in the viewport first — an element scrolled off-screen has a rect,
|
|
// but clicking those coordinates would hit whatever is actually displayed there.
|
|
el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
|
|
|
|
const r = el.getBoundingClientRect();
|
|
if (r.width === 0 || r.height === 0) {
|
|
return { error: `Element ${selector} has zero size (hidden or display:none)` };
|
|
}
|
|
if (r.bottom <= 0 || r.top >= window.innerHeight || r.right <= 0 || r.left >= window.innerWidth) {
|
|
return { error: `Element ${selector} is outside the viewport even after scrolling` };
|
|
}
|
|
|
|
const chromeHeight = window.outerHeight - window.innerHeight;
|
|
const cx = r.left + r.width / 2;
|
|
const cy = r.top + r.height / 2;
|
|
|
|
// What sits at that point? If it's not our element (or a descendant), something
|
|
// is covering it — a cookie banner, a modal — and the click would hit that instead.
|
|
const atPoint = document.elementFromPoint(cx, cy);
|
|
const covered = atPoint && atPoint !== el && !el.contains(atPoint);
|
|
|
|
// Current contents, so the caller can confirm afterwards that what it typed
|
|
// actually landed — a field that silently ignored the keystrokes (masked,
|
|
// read-only, or never focused) is otherwise indistinguishable from success.
|
|
const isField = el.tagName === 'INPUT' || el.tagName === 'TEXTAREA';
|
|
const value = isField ? el.value : (el.isContentEditable ? el.innerText : null);
|
|
|
|
return {
|
|
tag: el.tagName.toLowerCase(),
|
|
text: (el.textContent || '').trim().slice(0, 80),
|
|
matchCount: matches.length,
|
|
value,
|
|
editable: (isField || el.isContentEditable) && !el.disabled && !el.readOnly,
|
|
inputType: isField ? (el.type || null) : null,
|
|
covered: !!covered,
|
|
coveredBy: covered ? `${atPoint.tagName.toLowerCase()}${atPoint.id ? '#' + atPoint.id : ''}` : null,
|
|
viewport: { x: r.left, y: r.top, width: r.width, height: r.height },
|
|
// Desktop coordinates of the element's centre, in CSS pixels.
|
|
screen: { x: window.screenX + cx, y: window.screenY + chromeHeight + cy },
|
|
// Lets the Python side work out whether the OS uses a different pixel
|
|
// scale than CSS does (Windows display scaling, some Linux setups).
|
|
screenSize: { width: window.screen.width, height: window.screen.height },
|
|
devicePixelRatio: window.devicePixelRatio,
|
|
chromeHeight,
|
|
url: location.href,
|
|
};
|
|
}
|
|
|
|
/** Is the tab already showing this page? Compares origin and path only —
|
|
* query strings and hashes shouldn't force a reload. */
|
|
function alreadyAt(current, target) {
|
|
try {
|
|
const a = new URL(current);
|
|
const b = new URL(target);
|
|
return a.origin === b.origin && a.pathname.replace(/\/$/, '') === b.pathname.replace(/\/$/, '');
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Resolve after the tab reports `complete`. A tab that has only just been
|
|
* created has no DOM to inject into yet. */
|
|
function waitForTabLoad(tabId, timeoutMs = 15000) {
|
|
return new Promise((resolve, reject) => {
|
|
const started = Date.now();
|
|
const check = async () => {
|
|
let tab;
|
|
try {
|
|
tab = await chrome.tabs.get(tabId);
|
|
} catch {
|
|
return reject(new Error('the tab was closed while loading'));
|
|
}
|
|
if (tab.status === 'complete') return resolve(tab);
|
|
if (Date.now() - started > timeoutMs) {
|
|
return reject(new Error(`the page did not finish loading within ${timeoutMs / 1000}s`));
|
|
}
|
|
setTimeout(check, 200);
|
|
};
|
|
check();
|
|
});
|
|
}
|
|
|
|
/** Runs in the page. Scrolls until the list stops growing, then reports what it
|
|
* ended up with.
|
|
*
|
|
* The scrolling element is often NOT the window — lists like this usually live
|
|
* in a div with its own overflow, and scrolling the document does nothing at
|
|
* all. So walk up from a matched item looking for the ancestor that actually
|
|
* scrolls, and let the caller name one outright when the guess is wrong.
|
|
*/
|
|
async function pageScrollToLoad(selector, containerSelector, maxScrolls, settleMs, stableRounds) {
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
const count = () => document.querySelectorAll(selector).length;
|
|
|
|
function scroller() {
|
|
if (containerSelector) {
|
|
const named = document.querySelector(containerSelector);
|
|
if (!named) return { error: `No element matches container ${containerSelector}` };
|
|
return { el: named };
|
|
}
|
|
|
|
// An ancestor that can actually scroll: overflow allows it, and there is
|
|
// more content than fits.
|
|
let node = document.querySelector(selector)?.parentElement ?? null;
|
|
while (node && node !== document.body) {
|
|
const overflow = getComputedStyle(node).overflowY;
|
|
if (/(auto|scroll)/.test(overflow) && node.scrollHeight > node.clientHeight + 4) {
|
|
return { el: node };
|
|
}
|
|
node = node.parentElement;
|
|
}
|
|
return { el: document.scrollingElement || document.documentElement, isDocument: true };
|
|
}
|
|
|
|
const found = scroller();
|
|
if (found.error) return { error: found.error };
|
|
const el = found.el;
|
|
|
|
const before = count();
|
|
let last = before;
|
|
let stable = 0;
|
|
let scrolls = 0;
|
|
|
|
while (scrolls < maxScrolls) {
|
|
const wasAtBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 4;
|
|
el.scrollTop = el.scrollHeight;
|
|
scrolls++;
|
|
await sleep(settleMs);
|
|
|
|
const now = count();
|
|
// Nothing new AND we were already pinned to the bottom: the list is done
|
|
// growing, not merely slow.
|
|
stable = (now > last) ? 0 : stable + (wasAtBottom ? 1 : 0);
|
|
last = now;
|
|
if (stable >= stableRounds) break;
|
|
}
|
|
|
|
return {
|
|
before,
|
|
after: last,
|
|
scrolls,
|
|
exhausted: stable >= stableRounds,
|
|
container: found.isDocument ? 'document' : (el.className || el.tagName || 'element').toString().slice(0, 60),
|
|
url: location.href,
|
|
};
|
|
}
|
|
|
|
// ── Captcha: read the widget, then hand a solved token back to the page ─────
|
|
|
|
/** Runs in the page's MAIN world. Describes the captcha widget well enough for
|
|
* 2captcha to solve it, or reports that there isn't one yet.
|
|
*
|
|
* Three sources, best first: the render hook (the only one that sees
|
|
* programmatically-passed parameters), the widget's own data- attributes, and
|
|
* finally the challenge iframe, whose URL carries the sitekey when nothing else
|
|
* does. */
|
|
function pageCaptchaInfo(scopeSelector, wanted) {
|
|
const root = scopeSelector ? document.querySelector(scopeSelector) : document;
|
|
if (!root) return { error: `No element matches ${scopeSelector}` };
|
|
|
|
const KINDS = {
|
|
turnstile: { cls: '.cf-turnstile', frame: 'challenges.cloudflare.com' },
|
|
recaptcha: { cls: '.g-recaptcha', frame: '/recaptcha/' },
|
|
hcaptcha: { cls: '.h-captcha', frame: 'hcaptcha.com' },
|
|
};
|
|
const want = (kind) => !wanted || wanted === 'auto' || wanted === kind;
|
|
const enterprise = !!document.querySelector('script[src*="recaptcha/enterprise"]');
|
|
|
|
// Cloudflare's iframe has no sitekey parameter — it is a path segment, and
|
|
// every Turnstile key starts with 0x.
|
|
const fromFrameUrl = (kind, src) => {
|
|
try {
|
|
const url = new URL(src, location.href);
|
|
if (kind === 'recaptcha') return url.searchParams.get('k');
|
|
if (kind === 'hcaptcha') return url.searchParams.get('sitekey');
|
|
return url.pathname.split('/').find((part) => /^0x[A-Za-z0-9_-]{8,}$/.test(part)) || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
for (const widget of (window.__autofirmerCaptcha?.widgets || [])) {
|
|
const params = widget.params || {};
|
|
if (!want(widget.kind) || !params.sitekey) continue;
|
|
return {
|
|
provider: widget.kind,
|
|
sitekey: params.sitekey,
|
|
pageurl: location.href,
|
|
action: params.action || '',
|
|
data: params.cData || '',
|
|
pagedata: params.chlPageData || '',
|
|
invisible: params.size === 'invisible',
|
|
enterprise,
|
|
userAgent: navigator.userAgent,
|
|
source: 'render hook',
|
|
};
|
|
}
|
|
|
|
for (const kind of Object.keys(KINDS)) {
|
|
if (!want(kind)) continue;
|
|
const el = root.querySelector(`${KINDS[kind].cls}[data-sitekey]`);
|
|
if (!el) continue;
|
|
return {
|
|
provider: kind,
|
|
sitekey: el.getAttribute('data-sitekey'),
|
|
pageurl: location.href,
|
|
action: el.getAttribute('data-action') || '',
|
|
data: el.getAttribute('data-cdata') || '',
|
|
pagedata: '',
|
|
invisible: el.getAttribute('data-size') === 'invisible',
|
|
enterprise,
|
|
userAgent: navigator.userAgent,
|
|
source: 'data-sitekey',
|
|
};
|
|
}
|
|
|
|
const frames = Array.from(root.querySelectorAll('iframe'));
|
|
for (const kind of Object.keys(KINDS)) {
|
|
if (!want(kind)) continue;
|
|
for (const frame of frames) {
|
|
const src = frame.getAttribute('src') || '';
|
|
if (!src.includes(KINDS[kind].frame)) continue;
|
|
const sitekey = fromFrameUrl(kind, src);
|
|
if (!sitekey) continue;
|
|
return {
|
|
provider: kind,
|
|
sitekey,
|
|
pageurl: location.href,
|
|
action: '',
|
|
data: '',
|
|
pagedata: '',
|
|
invisible: false,
|
|
enterprise,
|
|
userAgent: navigator.userAgent,
|
|
source: 'challenge iframe',
|
|
};
|
|
}
|
|
}
|
|
|
|
return { error: 'No captcha widget on the page yet' };
|
|
}
|
|
|
|
/** Runs in the page's MAIN world. Puts a solved token where the page looks for
|
|
* it, then tells the page it arrived.
|
|
*
|
|
* Both halves matter. The hidden response field is what the form posts, but
|
|
* most pages enable their submit button from the widget's callback and never
|
|
* read the field until then — so filling it alone leaves the page looking
|
|
* unsolved. Only the FIRST callback found is fired: they are the page's own
|
|
* submit handlers, and calling several would risk sending the form twice. */
|
|
function pageCaptchaSubmit(token, provider) {
|
|
const FIELDS = {
|
|
turnstile: ['cf-turnstile-response', 'g-recaptcha-response'],
|
|
recaptcha: ['g-recaptcha-response'],
|
|
hcaptcha: ['h-captcha-response', 'g-recaptcha-response'],
|
|
};
|
|
|
|
const targets = new Set();
|
|
for (const name of (FIELDS[provider] || FIELDS.recaptcha)) {
|
|
document.querySelectorAll(`[name="${name}"], #${name}`).forEach((el) => targets.add(el));
|
|
}
|
|
// Turnstile names its hidden input per widget: cf-chl-widget-<id>_response.
|
|
document.querySelectorAll('input[name^="cf-chl-widget-"]').forEach((el) => targets.add(el));
|
|
|
|
let filled = 0;
|
|
for (const el of targets) {
|
|
el.value = token;
|
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
filled++;
|
|
}
|
|
|
|
let called = null;
|
|
const invoke = (fn, where) => {
|
|
if (called || typeof fn !== 'function') return false;
|
|
try {
|
|
fn(token);
|
|
called = where;
|
|
return true;
|
|
} catch (err) {
|
|
// The page's own handler threw. That is its business, but the token
|
|
// did reach it, so don't go looking for another one to call.
|
|
called = `${where} (threw: ${err.message})`;
|
|
return true;
|
|
}
|
|
};
|
|
|
|
for (const widget of (window.__autofirmerCaptcha?.widgets || [])) {
|
|
if (called) break;
|
|
if (provider && provider !== 'auto' && widget.kind !== provider) continue;
|
|
const cb = (widget.params || {}).callback;
|
|
if (typeof cb === 'function') invoke(cb, `${widget.kind} render callback`);
|
|
else if (typeof cb === 'string') invoke(window[cb], cb);
|
|
}
|
|
|
|
if (!called) {
|
|
for (const el of document.querySelectorAll('[data-callback]')) {
|
|
const name = el.getAttribute('data-callback');
|
|
if (invoke(window[name], `data-callback ${name}`)) break;
|
|
}
|
|
}
|
|
|
|
// reCAPTCHA rendered declaratively keeps its callback in a private config
|
|
// object and nowhere else. The keys around it are minified; `callback` is not.
|
|
if (!called && window.___grecaptcha_cfg?.clients) {
|
|
const seen = new Set();
|
|
const queue = Object.values(window.___grecaptcha_cfg.clients);
|
|
let budget = 2000;
|
|
while (queue.length && !called && budget-- > 0) {
|
|
const node = queue.shift();
|
|
if (!node || typeof node !== 'object' || seen.has(node)) continue;
|
|
seen.add(node);
|
|
for (const [key, value] of Object.entries(node)) {
|
|
if (typeof value === 'function') {
|
|
if (/callback/i.test(key) && invoke(value, `grecaptcha ${key}`)) break;
|
|
} else if (value && typeof value === 'object') {
|
|
queue.push(value);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!filled && !called) {
|
|
return { error: 'nowhere to put the token — no response field and no callback on the page' };
|
|
}
|
|
return { filled, called, provider, url: location.href };
|
|
}
|
|
|
|
async function resolveLocateTab(cfg, request) {
|
|
// Every step carries its firm's pattern; the manifest hosts are the fallback
|
|
// for a bare request (the CLI's locate without --url).
|
|
const pattern = request.urlPattern || firmPatterns();
|
|
if (pattern) {
|
|
const tabs = await chrome.tabs.query({ url: pattern });
|
|
const tab = tabs.find((t) => /^https?:/.test(t.url || ''));
|
|
if (tab) return { tab, opened: false };
|
|
|
|
// Nothing matching is open. Open it rather than failing the run — but only
|
|
// to the URL the firm declared, never to something a request supplied that
|
|
// we have no host permission for.
|
|
if (request.openUrl) {
|
|
const created = await chrome.tabs.create({ url: request.openUrl, active: true });
|
|
await waitForTabLoad(created.id);
|
|
return { tab: await chrome.tabs.get(created.id), opened: true };
|
|
}
|
|
|
|
throw new Error(`No open tab matches ${pattern}, and no URL is configured to open`);
|
|
}
|
|
|
|
const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
|
|
if (!tab || !/^https?:/.test(tab.url || '')) throw new Error('No eligible active tab');
|
|
return { tab, opened: false };
|
|
}
|
|
|
|
/** Hand an answer — or a failure — back to whoever queued the request. */
|
|
async function postLocateResult(cfg, id, result, error) {
|
|
await fetch(`${cfg.apiBase}/api/autobuyer/locate/result`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id, result, error }),
|
|
});
|
|
}
|
|
|
|
/** Returns true if a request was handled, false if the queue was empty. */
|
|
async function serveLocateRequest(cfg) {
|
|
const claim = await fetch(`${cfg.apiBase}/api/autobuyer/locate/claim`, { method: 'POST' });
|
|
if (!claim.ok) throw new Error(`Claim failed: HTTP ${claim.status}`);
|
|
const { request } = await claim.json();
|
|
if (!request) return false;
|
|
|
|
let result = null;
|
|
let error = null;
|
|
try {
|
|
let { tab, opened } = await resolveLocateTab(cfg, request);
|
|
|
|
// A navigate step points the tab at a specific page first. Skip it when we
|
|
// are already there — reloading would throw away page state for nothing,
|
|
// and the common case is that the tab is on the right page already.
|
|
if (request.navigateUrl && !alreadyAt(tab.url, request.navigateUrl)) {
|
|
await chrome.tabs.update(tab.id, { url: request.navigateUrl });
|
|
await waitForTabLoad(tab.id);
|
|
tab = await chrome.tabs.get(tab.id);
|
|
opened = true; // treat as a cold load: the app still has to mount
|
|
}
|
|
|
|
// The reported coordinates are only worth anything if that tab is the one
|
|
// actually visible: raise its window and bring the tab to the front.
|
|
// Only un-minimise — passing state:'normal' unconditionally would drop a
|
|
// maximised or fullscreen window out of that state on every single click.
|
|
const win = await chrome.windows.get(tab.windowId);
|
|
const focus = win.state === 'minimized'
|
|
? { focused: true, state: 'normal' }
|
|
: { focused: true };
|
|
await chrome.windows.update(tab.windowId, focus);
|
|
await chrome.tabs.update(tab.id, { active: true });
|
|
await new Promise((r) => setTimeout(r, 250)); // let the OS finish raising it
|
|
|
|
// A scroll request is a different operation on the same channel: no
|
|
// element is measured, the page is just walked to the bottom.
|
|
if (request.options && request.options.op === 'scrollToLoad') {
|
|
const [scrolled] = await chrome.scripting.executeScript({
|
|
target: { tabId: tab.id },
|
|
func: pageScrollToLoad,
|
|
args: [
|
|
request.selector,
|
|
request.options.containerSelector || '',
|
|
Number(request.options.maxScrolls) || 25,
|
|
Number(request.options.settleMs) || 800,
|
|
Number(request.options.stableRounds) || 2,
|
|
],
|
|
});
|
|
const out = scrolled?.result;
|
|
if (!out) throw new Error('Scroll injection returned nothing');
|
|
if (out.error) throw new Error(out.error);
|
|
result = { ...out, tabId: tab.id, windowId: tab.windowId, browser: detectBrowser() };
|
|
|
|
await postLocateResult(cfg, request.id, result, null);
|
|
return true;
|
|
}
|
|
|
|
// Captcha ops are the same shape: no element is measured, the page is
|
|
// read for its widget parameters or handed a token 2captcha solved.
|
|
// Both run in the page's MAIN world — the isolated world cannot see the
|
|
// page's own `turnstile` / `grecaptcha` objects, nor the render hook's
|
|
// registry, and those hold the callbacks a token has to be given to.
|
|
// allFrames because the widget is often in a same-origin sub-frame;
|
|
// Chrome skips frames outside host_permissions, so the challenge iframe
|
|
// itself is never touched.
|
|
if (request.options && (request.options.op === 'captchaInfo' || request.options.op === 'captchaToken')) {
|
|
const isInfo = request.options.op === 'captchaInfo';
|
|
const injections = await chrome.scripting.executeScript({
|
|
target: { tabId: tab.id, allFrames: true },
|
|
world: 'MAIN',
|
|
func: isInfo ? pageCaptchaInfo : pageCaptchaSubmit,
|
|
args: isInfo
|
|
? [request.options.scopeSelector || '', request.options.provider || 'auto']
|
|
: [String(request.options.token || ''), request.options.provider || 'auto'],
|
|
});
|
|
const answers = injections.map((i) => i?.result).filter(Boolean);
|
|
const found = answers.find((a) => !a.error);
|
|
if (!found) {
|
|
throw new Error(answers[0]?.error || 'Captcha injection returned nothing');
|
|
}
|
|
result = { ...found, tabId: tab.id, windowId: tab.windowId, browser: detectBrowser() };
|
|
|
|
await postLocateResult(cfg, request.id, result, null);
|
|
return true;
|
|
}
|
|
|
|
// `complete` only means the document loaded — a React app still has to
|
|
// mount and paint. Retry briefly rather than declaring the element missing,
|
|
// with a longer budget when we just opened the page from cold.
|
|
const deadline = Date.now() + (opened ? 8000 : 2500);
|
|
let out;
|
|
for (;;) {
|
|
const [injection] = await chrome.scripting.executeScript({
|
|
target: { tabId: tab.id },
|
|
func: pageLocate,
|
|
args: [request.selector, request.index || 0],
|
|
});
|
|
out = injection?.result;
|
|
if (!out) throw new Error('Injection returned nothing');
|
|
if (!out.error) break;
|
|
if (Date.now() >= deadline) throw new Error(out.error);
|
|
await new Promise((r) => setTimeout(r, 350));
|
|
}
|
|
result = {
|
|
...out,
|
|
tabId: tab.id,
|
|
windowId: tab.windowId,
|
|
openedTab: opened,
|
|
browser: detectBrowser(),
|
|
};
|
|
} catch (err) {
|
|
error = err.message;
|
|
}
|
|
|
|
await postLocateResult(cfg, request.id, result, error);
|
|
return true;
|
|
}
|
|
|
|
async function tick() {
|
|
const cfg = await getConfig();
|
|
|
|
try {
|
|
const res = await fetch(`${cfg.apiBase}/api/autobuyer/status`, { cache: 'no-store' });
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const status = await res.json();
|
|
pendingLocate = Number(status.pendingLocate) || 0;
|
|
} catch (err) {
|
|
setBadge(false);
|
|
await setState({ connected: false, error: `Cannot reach ${cfg.apiBase} — ${err.message}` });
|
|
return;
|
|
}
|
|
|
|
setBadge(true);
|
|
await setState({ connected: true });
|
|
|
|
// Serve queued clicks before capturing — the Python side is waiting on these,
|
|
// and a capture of a megabyte page shouldn't sit in front of them.
|
|
// Failures inside a request are reported back to the caller; a failure of the
|
|
// queue plumbing itself lands here, and must not take the capture loop down.
|
|
try {
|
|
for (let i = 0; i < pendingLocate; i++) {
|
|
if (!(await serveLocateRequest(cfg))) break;
|
|
}
|
|
} catch (err) {
|
|
await setState({ connected: true, error: `Locate queue: ${err.message}` });
|
|
}
|
|
|
|
try {
|
|
await captureAndSend(cfg);
|
|
} catch (err) {
|
|
await setState({ connected: true, error: err.message });
|
|
}
|
|
}
|
|
|
|
// ── Scheduling ──────────────────────────────────────────────────────────────
|
|
|
|
async function restart() {
|
|
if (timer) clearInterval(timer);
|
|
const cfg = await getConfig();
|
|
// Re-check after the await: restart() is called from several places at startup,
|
|
// and without this a concurrent call leaks an untracked interval — two polling
|
|
// loops in one worker, capturing everything twice.
|
|
if (timer) clearInterval(timer);
|
|
const ms = Math.max(1, Number(cfg.pollSeconds) || DEFAULTS.pollSeconds) * 1000;
|
|
timer = setInterval(tick, ms);
|
|
tick();
|
|
}
|
|
|
|
chrome.runtime.onInstalled.addListener(restart);
|
|
chrome.runtime.onStartup.addListener(restart);
|
|
|
|
// The setInterval above only survives while the worker is alive. Each tick makes
|
|
// an extension API call, which resets the idle timer, so in practice the loop
|
|
// keeps itself running — this alarm is the safety net that revives it if Chrome
|
|
// tears the worker down anyway. 30s is the shortest period Chrome honours.
|
|
chrome.alarms.create('keepalive', { periodInMinutes: 0.5 });
|
|
chrome.alarms.onAlarm.addListener(() => {
|
|
if (timer) tick(); else restart();
|
|
});
|
|
|
|
chrome.storage.onChanged.addListener((changes, area) => {
|
|
if (area !== 'local') return;
|
|
if (Object.keys(DEFAULTS).some((k) => k in changes)) restart();
|
|
});
|
|
|
|
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|
if (msg?.type !== 'capture-now') return;
|
|
getConfig()
|
|
.then(captureAndSend)
|
|
.then(() => sendResponse({ ok: true }))
|
|
.catch((err) => sendResponse({ ok: false, error: err.message }));
|
|
return true; // keep the message channel open for the async response
|
|
});
|
|
|
|
restart();
|