DOM automation for Google Labs Flow — background worker injects trusted input via the DevTools Protocol (Flow's editor ignores synthetic events), polls a local queue, harvests generated asset URLs. Includes the generated game+promo prompt bank. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
269 lines
11 KiB
JavaScript
269 lines
11 KiB
JavaScript
// Flow Auto-Pilot v2 — drives the Google Flow AGENT panel and HARVESTS outputs.
|
|
//
|
|
// Reuses DEALGOD's "poll until the SPA settles" discipline (waitForStable), NOT its
|
|
// full selector-discovery engine — Flow is one known page, not the open web, so
|
|
// auto-recipe/variance scoring would be over-engineering here.
|
|
//
|
|
// Loop: poll local queue -> type prompt into the agent box -> submit ->
|
|
// wait for a NEW asset to appear + the media grid to stop growing ->
|
|
// harvest the new video/image URL(s) -> report them back to the queue.
|
|
//
|
|
// Prereq you set ONCE by hand in Flow: Agent settings -> Confirm before generating
|
|
// = "Never", model + x4 defaults. The driver still clicks a confirm button if one
|
|
// shows up (covers both modes), but "Never" is what makes it truly unattended.
|
|
|
|
let settings = {
|
|
serverUrl: 'http://localhost:8017',
|
|
isRunning: false,
|
|
// Optional CSS overrides — leave blank to use the resilient generic finders.
|
|
promptSel: '',
|
|
submitSel: '',
|
|
resultSel: '', // scope for harvesting (e.g. the media library container)
|
|
pollInterval: 4000,
|
|
genTimeoutMs: 8 * 60 * 1000 // Veo clips can take minutes
|
|
};
|
|
|
|
let pollTimeout = null;
|
|
let generationInProgress = false;
|
|
|
|
function init() {
|
|
chrome.storage.local.get(settings, (data) => {
|
|
settings = { ...settings, ...data };
|
|
log('system', `Content script connected (${settings.isRunning ? 'ACTIVE' : 'idle'}).`);
|
|
if (settings.isRunning) startPolling();
|
|
});
|
|
}
|
|
|
|
// ---- helpers -------------------------------------------------------------
|
|
|
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
const visible = (el) => !!el && el.offsetWidth > 0 && el.offsetHeight > 0;
|
|
|
|
// Poll a predicate until truthy or timeout. Returns the truthy value or null.
|
|
async function waitFor(pred, maxMs = 15000, step = 400) {
|
|
const t0 = Date.now();
|
|
while (Date.now() - t0 < maxMs) {
|
|
let v; try { v = pred(); } catch (_) { v = null; }
|
|
if (v) return v;
|
|
await sleep(step);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// DEALGOD discipline: call countFn() every `step`ms; resolve once the value is
|
|
// the SAME for `stableFor` consecutive polls (the grid stopped growing), or cap.
|
|
async function waitForStable(countFn, { maxMs = settings.genTimeoutMs, step = 1500, stableFor = 2, min = 1 } = {}) {
|
|
const t0 = Date.now();
|
|
let prev = -1, stable = 0;
|
|
while (Date.now() - t0 < maxMs) {
|
|
const c = countFn();
|
|
if (c >= min && c === prev) {
|
|
if (++stable >= stableFor) return c;
|
|
} else {
|
|
stable = 0;
|
|
}
|
|
prev = c;
|
|
await sleep(step);
|
|
}
|
|
return prev;
|
|
}
|
|
|
|
// React-aware value injection (kept from v1 — this part was correct).
|
|
function setElementValue(el, value) {
|
|
if (!el) return false;
|
|
el.focus();
|
|
if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
|
|
const setter = Object.getOwnPropertyDescriptor(el.__proto__, 'value')?.set;
|
|
setter ? setter.call(el, value) : (el.value = value); // bypass React's value shadowing
|
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
} else {
|
|
el.innerText = value;
|
|
el.dispatchEvent(new InputEvent('input', { bubbles: true, cancelable: true, inputType: 'insertText', data: value }));
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Read whatever text is currently in the editor (textarea value or contenteditable text).
|
|
function editorText(el) {
|
|
const t = (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') ? el.value : (el.innerText || el.textContent || '');
|
|
return (t || '').trim();
|
|
}
|
|
|
|
// Ask the background worker for a TRUSTED action via the DevTools Protocol.
|
|
// Flow's editor ignores synthetic DOM events (isTrusted=false): the text shows but
|
|
// the app's state stays empty ("no prompt" on send). CDP Input.* events are trusted.
|
|
function bg(msg) {
|
|
return new Promise((resolve) => {
|
|
try { chrome.runtime.sendMessage(msg, (r) => resolve(chrome.runtime.lastError ? null : r)); }
|
|
catch (_) { resolve(null); }
|
|
});
|
|
}
|
|
|
|
// Focus, clear, then insert the prompt as trusted input. Falls back to DOM injection
|
|
// if the debugger path is unavailable. Returns chars that landed (0 = failed).
|
|
async function typeIntoEditor(el, text) {
|
|
el.focus();
|
|
await sleep(120);
|
|
try { document.execCommand('selectAll', false, null); document.execCommand('delete', false, null); } catch (_) {}
|
|
const r = await bg({ type: 'insertText', text });
|
|
if (!r || !r.ok) {
|
|
try { document.execCommand('insertText', false, text); } catch (_) {} // fallback
|
|
if (!editorText(el).length) setElementValue(el, text);
|
|
}
|
|
await sleep(200);
|
|
return editorText(el).length;
|
|
}
|
|
|
|
// Submit via a trusted Enter (what works by hand). Falls back to synthetic keys.
|
|
async function submitEditor(el) {
|
|
el.focus();
|
|
const r = await bg({ type: 'pressEnter' });
|
|
if (!r || !r.ok) {
|
|
const o = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true };
|
|
el.dispatchEvent(new KeyboardEvent('keydown', o));
|
|
el.dispatchEvent(new KeyboardEvent('keypress', o));
|
|
el.dispatchEvent(new KeyboardEvent('keyup', o));
|
|
}
|
|
}
|
|
|
|
// ---- element finders (few, known targets; overrides win) -----------------
|
|
|
|
function findAgentInput() {
|
|
if (settings.promptSel) { const el = document.querySelector(settings.promptSel); if (visible(el)) return el; }
|
|
// Flow's agent box is a visible contenteditable[role=textbox] or textarea.
|
|
const boxes = [
|
|
...document.querySelectorAll('div[contenteditable="true"][role="textbox"]'),
|
|
...document.querySelectorAll('div[contenteditable="true"]'),
|
|
...document.querySelectorAll('textarea')
|
|
];
|
|
return boxes.find(visible) || null;
|
|
}
|
|
|
|
// The submit control is the unlabeled arrow near the input. Find the nearest
|
|
// enabled button after the input, preferring aria-labels that read like "send".
|
|
function findSubmit(inputEl) {
|
|
if (settings.submitSel) { const el = document.querySelector(settings.submitSel); if (visible(el) && !el.disabled) return el; }
|
|
const scope = inputEl?.closest('form, [class*="composer"], [class*="session"], [class*="input"]') || document;
|
|
const btns = [...scope.querySelectorAll('button, [role="button"]')].filter(b => visible(b) && !b.disabled);
|
|
const byLabel = btns.find(b => /send|submit|create|generate|run/i.test((b.getAttribute('aria-label') || '') + b.innerText));
|
|
if (byLabel) return byLabel;
|
|
// else: the last enabled icon-only button in the composer (the → arrow)
|
|
const iconOnly = btns.filter(b => !b.innerText.trim() && b.querySelector('svg'));
|
|
return iconOnly[iconOnly.length - 1] || btns[btns.length - 1] || null;
|
|
}
|
|
|
|
// If a confirm/generate dialog appears (Always mode), click it. No-op under Never.
|
|
function clickConfirmIfPresent() {
|
|
const b = [...document.querySelectorAll('button, [role="button"]')]
|
|
.find(x => visible(x) && !x.disabled && /^(generate|create|confirm|continue|yes)\b/i.test(x.innerText.trim()));
|
|
if (b) { b.click(); return true; }
|
|
return false;
|
|
}
|
|
|
|
// Snapshot real media URLs currently on the page (the harvest surface).
|
|
function snapshotAssets() {
|
|
const scope = (settings.resultSel && document.querySelector(settings.resultSel)) || document;
|
|
const urls = new Set();
|
|
const push = (u) => { if (u && /^https?:/.test(u) && !/^data:/.test(u)) urls.add(u.split('#')[0]); };
|
|
scope.querySelectorAll('video[src]').forEach(v => push(v.src));
|
|
scope.querySelectorAll('video source[src]').forEach(s => push(s.src));
|
|
scope.querySelectorAll('a[href*="googleusercontent"], a[download][href]').forEach(a => push(a.href));
|
|
scope.querySelectorAll('img[src]').forEach(i => { if (/googleusercontent|lh3|storage/.test(i.src)) push(i.src); });
|
|
return urls;
|
|
}
|
|
|
|
// ---- queue loop ----------------------------------------------------------
|
|
|
|
function startPolling() {
|
|
if (pollTimeout) clearTimeout(pollTimeout);
|
|
if (!settings.isRunning) return;
|
|
pollTimeout = setTimeout(async () => {
|
|
if (generationInProgress) return startPolling();
|
|
try {
|
|
const res = await fetch(`${settings.serverUrl}/next-task`);
|
|
if (res.status === 204) return startPolling();
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const task = await res.json();
|
|
if (task && task.id) { log('info', `Task ${task.id}: ${String(task.prompt).slice(0, 60)}…`); await executeTask(task); }
|
|
else startPolling();
|
|
} catch (err) {
|
|
log('error', `Poll failed: ${err.message}`);
|
|
startPolling();
|
|
}
|
|
}, settings.pollInterval);
|
|
}
|
|
|
|
async function executeTask(task) {
|
|
generationInProgress = true;
|
|
chrome.storage.local.set({ status: 'working' });
|
|
try {
|
|
const input = await waitFor(findAgentInput, 20000);
|
|
if (!input) throw new Error('Agent input box not found — are you on the Flow project page?');
|
|
|
|
const before = snapshotAssets();
|
|
|
|
const landed = await typeIntoEditor(input, task.prompt);
|
|
if (!landed) throw new Error('Prompt did not register in the editor (injection failed) — nothing submitted.');
|
|
log('info', `Prompt set (${landed} chars). Submitting…`);
|
|
await sleep(300);
|
|
|
|
await submitEditor(input); // trusted Enter via the DevTools Protocol
|
|
await sleep(700);
|
|
const submit = findSubmit(input); // arrow button, as a fallback
|
|
if (submit) submit.click();
|
|
clickConfirmIfPresent(); // harmless under "Never"
|
|
|
|
await sleep(1500);
|
|
log('info', 'Submitted. Waiting for generation…');
|
|
|
|
// Wait until a NEW asset appears AND the asset count stops growing.
|
|
const appeared = await waitFor(() => snapshotAssets().size > before.size, settings.genTimeoutMs, 2500);
|
|
if (!appeared) {
|
|
// last-chance: a quota/credit wall?
|
|
const wall = [...document.querySelectorAll('*')].some(e => visible(e) && /credit|quota|limit|run out/i.test(e.textContent || '') && (e.textContent || '').length < 120);
|
|
throw new Error(wall ? 'Stopped: credits/quota wall detected.' : 'Timed out waiting for output.');
|
|
}
|
|
await waitForStable(() => snapshotAssets().size, { min: before.size + 1 });
|
|
|
|
const after = snapshotAssets();
|
|
const fresh = [...after].filter(u => !before.has(u));
|
|
log('success', `Harvested ${fresh.length} asset(s).`);
|
|
await reportCompletion(task.id, 'success', null, fresh);
|
|
} catch (err) {
|
|
log('error', `Task ${task.id} failed: ${err.message}`);
|
|
await reportCompletion(task.id, 'failed', err.message, []);
|
|
} finally {
|
|
generationInProgress = false;
|
|
chrome.storage.local.set({ status: settings.isRunning ? 'running' : 'idle' });
|
|
startPolling();
|
|
}
|
|
}
|
|
|
|
async function reportCompletion(id, status, error, assets) {
|
|
try {
|
|
await fetch(`${settings.serverUrl}/task-completed`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id, status, error, assets }) // assets = harvested URLs
|
|
});
|
|
} catch (err) {
|
|
log('error', `Report failed: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
function log(level, text) {
|
|
console.log(`[Flow][${level}] ${text}`);
|
|
chrome.storage.local.get({ logs: [] }, (r) => {
|
|
chrome.storage.local.set({ logs: [...r.logs, { level, text, timestamp: Date.now() }].slice(-100) });
|
|
});
|
|
}
|
|
|
|
chrome.storage.onChanged.addListener((c, area) => {
|
|
if (area !== 'local') return;
|
|
for (const k of Object.keys(c)) if (k in settings) settings[k] = c[k].newValue;
|
|
if (c.isRunning) { c.isRunning.newValue ? startPolling() : (pollTimeout && clearTimeout(pollTimeout)); }
|
|
});
|
|
|
|
init();
|