flow-extension/content.js
type-two 82d5ff494c fix: promptEchoed matched a 60-char PREFIX — 13 prompts silently skipped
THE ROOT CAUSE of the djsim_phonewall run, and it was never Flow.

promptEchoed() answered "has this prompt already been submitted?" by searching
the chat pane for prompt.slice(0, 60). All 15 calendar prompts open with the
same 60 characters:

  "The illustrated top picture-panel of a cheap 1979 promotiona"

So once Alien's prompt was echoed in the chat, every subsequent calendar looked
already-submitted. The driver logged "Prompt already echoed in the session —
not re-typing", typed nothing, and waited out the full 3-minute image timeout.
Thirteen times, at a metronomic 3m30s apart. Flow was working the whole time.

No fixed slice is safe: djsim_phonewall needs 160 chars of PREFIX to
disambiguate its prompts; djsim_batch3 needs 190 chars of SUFFIX. So match the
whole normalized prompt, or (if the DOM mangles the middle) require both ends
in the same element. Whitespace-normalized, with the container guard kept.

test_prompt_echo.mjs pins it: asserts the old impl DOES false-positive, the new
one doesn't, that it still fires as the "it stuck" signal, survives DOM
whitespace reflow, ignores a page-sized container, and that every prompt in
every bank is distinguishable from all its neighbours (0 missed, 0 cross-matched).

The circuit breaker from the previous commit stands: it would have stopped this
after 3 instead of 13. But the driver should never have been silent in the
first place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:07:10 +10:00

796 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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,
autoStart: false, // begin polling the moment the Flow tab loads (for scripted launch)
// 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
// Pacing — stay under Google's per-account rate/abuse flag ("unusual activity").
taskDelayMs: 20000, // base pause between tasks (jittered up to 2x)
restEvery: 15, // after this many gens, take a longer rest
restMs: 15 * 60 * 1000, // length of that scheduled rest
autoRefreshOnJam: true, // refresh WHEN RESUMING after the abuse flag (never during the freeze)
cooldownUntil: 0, // epoch ms; set on rate-limit/rest, persists across reload
reloadOnResume: false // set by the abuse flag: hard-refresh once the freeze ends, then resume
};
let pollTimeout = null;
let generationInProgress = false;
let successCount = 0;
let consecutiveRateLimits = 0;
let consecutiveFails = 0;
const FAIL_STREAK_STOP = 3; // when Flow goes quiet, it stays quiet. Stop; don't eat the bank.
function init() {
chrome.storage.local.get({ ...settings, pendingHarvest: null }, (data) => {
const ph = data.pendingHarvest; delete data.pendingHarvest;
settings = { ...settings, ...data };
if (settings.autoStart && !settings.isRunning) {
settings.isRunning = true;
chrome.storage.local.set({ isRunning: true }); // so the popup reflects it
}
log('system', `Content script connected (${settings.isRunning ? 'ACTIVE' : 'idle'}).`);
if (ph) { // a video finished right before the crucial post-gen refresh — harvest it now
runPostReloadHarvest(ph).then(() => { if (settings.isRunning) startPolling(); });
return;
}
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;
}
// John's by-hand ritual, step 1: after a (re)load, don't type the instant the box
// exists — wait for the agent sidebar to finish hydrating, then re-find it.
async function waitForAgentReady(maxMs = 30000) {
const el = await waitFor(findAgentInput, maxMs);
if (!el) return null;
await sleep(1500);
const el2 = findAgentInput();
return visible(el2) ? el2 : null;
}
// The square STOP control that replaces send while a generation is queued —
// the "it stuck" signal you watch for by hand.
function findStopSquare() {
return [...document.querySelectorAll('button, [role="button"]')].find(b => visible(b) && !b.disabled &&
/\b(stop|cancel)\b/i.test((b.getAttribute('aria-label') || '') + ' ' + (b.getAttribute('title') || '')));
}
// 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);
// ONLY a control explicitly labelled send/submit. No "last icon button" guess — that's what
// was clicking the + / menu and opening popups. If unsure, return null (trusted Enter handles it).
return btns.find(b => /\b(send|submit)\b/i.test((b.getAttribute('aria-label') || '') + ' ' + (b.getAttribute('title') || ''))) || null;
}
// 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 ok = (u) => u && !/^data:/.test(u) && (/^https?:/.test(u) || /^blob:/.test(u));
const push = (u) => { if (ok(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('video[poster]').forEach(v => push(v.poster));
scope.querySelectorAll('a[href]').forEach(a => {
if (a.hasAttribute('download') || /googleusercontent|media\.getMediaUrl|\/media\//i.test(a.href)) push(a.href);
});
// grid tiles are anchors to /project/<id>/edit/<uuid> — stable https keys that
// survive reloads (video blob: srcs don't). The most reliable "new asset" signal.
scope.querySelectorAll('a[href*="/edit/"]').forEach(a => push(a.href));
// images: match media hosts + the redirect API + blobs (diffed before/after, so only NEW ones count)
scope.querySelectorAll('img[src]').forEach(i => {
if (/googleusercontent|lh3|ggpht|storage|media\.getMediaUrl|^blob:/i.test(i.src)) push(i.src);
});
return urls;
}
// Flow's failure chatter — same lingering problem as the done-messages: old errors
// stay visible in the chat history forever. COUNT matches; only a NEW one (count
// above the pre-submit baseline) means THIS task errored.
function countErrMsgs() {
const m = (document.body.innerText || '').match(
/something went wrong|please try again|couldn'?t generate|failed to generate|generation failed/gi);
return m ? m.length : 0;
}
// The account-level abuse/rate flag ("unusual activity" / "Help Center"). Persistent —
// retrying makes it worse — so this triggers a long back-off, not a "Try again".
function persistentError() {
return [...document.querySelectorAll('*')].some(e => visible(e) &&
/unusual activity|help center|temporarily (blocked|unavailable)|too many requests|try again later/i.test(e.textContent || '') &&
(e.textContent || '').length < 180);
}
// The agent's end-of-generation chatter. John's law #1: this message LIES — "scheduled /
// waiting in the queue" usually means it's actually DONE (refresh to see it).
// John's law #2: OLD messages linger in the chat history, so never match absolutely —
// COUNT matches and only act when a NEW one appears after our submit.
function countDoneMsgs() {
const m = (document.body.innerText || '').match(
/has been scheduled|waiting in the queue|check back in a few|is ready|i'?ve (generated|created)|here('s| is) (your|the) video/gi);
return m ? m.length : 0;
}
// The chat panel echoes the submitted prompt — the strongest "it stuck" signal, and
// on a retry it means the PREVIOUS submit registered (so don't type it again).
//
// It must identify THIS prompt, not "a prompt that starts the same way". 2026-07-08: this
// matched prompt.slice(0,60), and 13 of the 15 djsim_phonewall prompts opened with an
// identical 60 chars ("The illustrated top picture-panel of a cheap 1979 promotiona"). The
// previous task's echo was still on screen, so the driver decided every one of them had
// already been submitted, never typed them, and sat out a 3-minute timeout. Thirteen times.
//
// No fixed slice is safe: djsim_phonewall needs 160 chars of PREFIX to disambiguate,
// djsim_batch3 needs 190 chars of SUFFIX. So match the whole thing — or, if the DOM has
// mangled the middle, require both ends in the same element.
const normText = (s) => String(s).replace(/\s+/g, ' ').trim();
function promptEchoed(prompt) {
const full = normText(prompt);
if (full.length < 12) return false;
const head = full.slice(0, 100), tail = full.slice(-100);
return [...document.querySelectorAll('div, p, span')].some(e => {
if (!visible(e) || e.closest('[contenteditable="true"], textarea')) return false;
const t = normText(e.textContent || '');
if (t.length > full.length + 500) return false; // that's a container, not the message
return t.includes(full) || (t.includes(head) && t.includes(tail));
});
}
// Flow's "Try again" button, shown beside the "Something went wrong" banner.
// Clicking it re-runs the SAME prompt (no re-injection needed) — the manual fix.
function findTryAgain() {
return [...document.querySelectorAll('button, [role="button"]')]
.find(b => visible(b) && !b.disabled && /^\s*try again\s*$/i.test(b.innerText || ''));
}
// Poll for: a new asset (win), or Flow's transient "Something went wrong". Flow
// errors constantly under load, so instead of failing we AUTO-CLICK "Try again"
// (what works by hand) up to maxRetries, with a cooldown so a lingering banner
// doesn't burn all the retries at once. Returns 'asset' | 'error' | null(timeout).
async function waitForResultOrError(baseCount, maxMs, errBase = 0, doneBase = 0, maxRetries = 1) {
const t0 = Date.now();
let retries = 0, cooldownUntil = 0;
while (Date.now() - t0 < maxMs) {
if (snapshotAssets().size > baseCount) return 'asset';
if (Date.now() - t0 > 6000 && persistentError()) return 'ratelimit'; // abuse flag: back off, don't retry
const now = Date.now();
const newError = countErrMsgs() > errBase; // lingering old banners don't count
if (now > cooldownUntil && newError) {
const btn = findTryAgain();
if (btn) {
if (retries >= maxRetries) return 'error'; // gave it maxRetries shots
// A "Something went wrong" banner can co-occur with a gen that ACTUALLY
// succeeded (Flow errors constantly under load). Clicking Try-again re-runs
// it for another 15 credits. So SETTLE and re-check: only retry if there is
// still no asset AND no NEW completion message. Each retry is real money —
// capped at maxRetries=1.
await sleep(2500);
if (snapshotAssets().size > baseCount || countDoneMsgs() > doneBase) return 'asset';
retries++;
log('info', `Flow hiccup — clicking "Try again" (${retries}/${maxRetries}).`);
btn.click();
cooldownUntil = Date.now() + 15000; // let it re-generate before re-checking
await sleep(4000);
continue;
}
if (now - t0 > 8000) return 'error'; // errored, no retry offered
}
await sleep(2500);
}
return null;
}
// Split a fresh-URL diff into direct media vs tile links, download the media.
async function harvestFresh(id, kind, category, fresh) {
const tiles = fresh.filter(u => /\/edit\//.test(u));
const media = fresh.filter(u => !/\/edit\//.test(u));
const ext = kind === 'video' ? 'mp4' : 'png';
const cat = (category || 'misc').replace(/[^a-z0-9_\-]/gi, '_');
let saved = 0;
for (let i = 0; i < media.length; i++) {
const r = await bg({ type: 'download', url: media[i], filename: `flowrinse/${cat}/${id}_${i}.${ext}` });
if (r && r.ok) saved++;
}
return { saved, media, tiles };
}
// One line describing the harvest surface — appended to failures so a miss tells
// us what the grid actually looked like instead of leaving us guessing.
function gridCensus() {
const q = s => document.querySelectorAll(s).length;
return `census: video=${q('video')} videoSrc=${q('video[src]')} poster=${q('video[poster]')} ` +
`img=${q('img[src]')} editTiles=${q('a[href*="/edit/"]')} dl=${q('a[download]')}`;
}
// ---- queue loop ----------------------------------------------------------
// Entry point (Start / reload). Kicks the first poll soon; the rest is paced.
function startPolling() {
if (pollTimeout) clearTimeout(pollTimeout);
if (settings.isRunning) scheduleNext(true);
}
// Schedule the next poll. Honours a persisted cooldown (rate-limit back-off or scheduled
// rest); otherwise waits a jittered human-ish gap so we don't machine-gun the account.
function scheduleNext(immediate = false) {
if (pollTimeout) clearTimeout(pollTimeout);
if (!settings.isRunning) return;
const now = Date.now();
let delay;
if (settings.cooldownUntil && now < settings.cooldownUntil) {
delay = settings.cooldownUntil - now;
log('system', `Cooling down ~${Math.max(1, Math.round(delay / 60000))} min before next…`);
} else if (immediate) {
delay = 2000;
} else {
const base = settings.taskDelayMs || 15000;
delay = base + Math.floor(Math.random() * base); // base .. 2*base, jittered
}
pollTimeout = setTimeout(pollOnce, delay);
}
async function pollOnce() {
if (!settings.isRunning) return;
if (generationInProgress) return scheduleNext();
if (settings.reloadOnResume) {
// Freeze is over — come back on a FRESH session before touching anything.
settings.reloadOnResume = false;
chrome.storage.local.set({ reloadOnResume: false });
log('system', 'Abuse-flag freeze over — hard refreshing before resuming.');
const r = await bg({ type: 'hardReload' });
if (!r || !r.ok) location.reload();
return;
}
try {
const res = await fetch(`${settings.serverUrl}/next-task`);
if (res.status === 204) return scheduleNext(); // queue drained
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 scheduleNext();
} catch (err) {
log('error', `Poll failed: ${err.message}`);
scheduleNext();
}
}
// John's by-hand recovery: the thing that actually clears a wedged Flow session is a
// HARD refresh (cache bypass), not "Try again". Keep the task PENDING server-side,
// count attempts in storage (survives the reload), and reload the tab.
async function requeueWithReload(task, why) {
if (persistentError()) return handleRateLimit(task); // abuse flag outranks any retry — freeze, don't reload
const key = 'requeue_' + task.id;
const n = ((await new Promise(r => chrome.storage.local.get({ [key]: 0 }, r)))[key] || 0) + 1;
if (n > 3) {
chrome.storage.local.remove(key);
log('error', `Task ${task.id}: ${why} — 3 hard-refresh retries burned, marking failed.`);
await reportCompletion(task.id, 'failed', `${why} (after 3 hard refreshes)`, []);
return;
}
chrome.storage.local.set({ [key]: n });
await reportCompletion(task.id, 'requeue', why, []); // server keeps it PENDING
log('system', `Task ${task.id}: ${why} — hard refresh ${n}/3, retrying after reload.`);
setCooldown(20000, 'Hard-refresh retry');
await sleep(800);
const r = await bg({ type: 'hardReload' });
if (!r || !r.ok) location.reload();
}
async function executeTask(task) {
if (task.kind === 'harvest') return executeHarvest(task); // library download pass, no generation
generationInProgress = true;
chrome.storage.local.set({ status: 'working' });
try {
const input = await waitForAgentReady(30000);
if (!input) return await requeueWithReload(task, 'agent input never became ready');
const before = snapshotAssets();
const doneBase = countDoneMsgs(); // old "finished" chatter lingers — only a NEW one counts
const errBase = countErrMsgs(); // ditto for old error banners
// Soft-detect first: a DURABLE marker (survives the requeue hard-reload) or a live
// prompt-echo means Google ALREADY started the paid generation for this task —
// re-typing would double-bill. Skip straight to waiting for the result.
const submittedKey = 'submitted_' + task.id;
const priorSubmit = await new Promise(r => chrome.storage.local.get({ [submittedKey]: false }, d => r(d[submittedKey])));
const alreadySubmitted = priorSubmit || promptEchoed(task.prompt);
if (alreadySubmitted) {
log('info', priorSubmit
? 'Task already submitted on a prior attempt (durable marker) — waiting for the result, NOT re-generating.'
: 'Prompt already echoed in the session (earlier submit registered) — not re-typing.');
} else {
const landed = await typeIntoEditor(input, task.prompt);
if (!landed) return await requeueWithReload(task, 'prompt did not register in the editor');
log('info', `Prompt set (${landed} chars). Submitting…`);
await sleep(600); // John's ritual: paste, wait a sec, THEN enter
await submitEditor(input); // trusted Enter via the DevTools Protocol — this submits
// CREDITS COMMITTED. Persist the marker BEFORE the stuck-check: the visual "stuck"
// signals lag under load, and a requeue+reload that trusts them to gate re-submit
// is exactly what double-billed. Conservative by design — if the submit somehow
// didn't take, the task times out and fails (retryable) rather than pays twice.
await new Promise(r => chrome.storage.local.set({ [submittedKey]: true }, r));
// "It stuck" = stop square appears, OR the box empties, OR the chat echoes the prompt.
const stuck = () => findStopSquare() || editorText(input).length === 0 || promptEchoed(task.prompt);
let took = await waitFor(stuck, 6000, 300);
if (!took) {
const submit = findSubmit(input);
if (submit) { submit.click(); log('info', 'Enter fallback → clicked submit.'); }
took = await waitFor(stuck, 6000, 300);
}
if (!took) return await requeueWithReload(task, 'submission never stuck (no stop square, no echo, prompt still in box)');
log('info', 'Submitted (soft-detect confirmed). Waiting for generation…');
}
const timeoutMs = task.kind === 'image' ? 3 * 60 * 1000 : settings.genTimeoutMs;
if (task.kind === 'video') {
// John's law: after a video gen the page is stale AND the agent's completion
// message lies ("queued due to high demand" = usually done). The post-gen HARD
// REFRESH is crucial. Wait for a done-ish signal, let it settle, persist the
// harvest state (so the reload can't re-submit and burn credits), then reload.
const t0 = Date.now();
let signal = null;
while (Date.now() - t0 < timeoutMs) {
if (snapshotAssets().size > before.size) { signal = 'asset in grid'; break; }
if (countDoneMsgs() > doneBase) { signal = 'NEW agent completion message'; break; }
if (Date.now() - t0 > 6000 && persistentError()) { await handleRateLimit(task); return; }
if (Date.now() - t0 > 8000 && countErrMsgs() > errBase)
return await requeueWithReload(task, 'Flow errored during video generation');
await sleep(2500);
}
if (!signal) throw new Error('Timed out — no completion signal for video.');
log('info', `Video done (${signal}). Settling 2s, then the crucial post-gen hard refresh…`);
await sleep(2500);
await new Promise(r => chrome.storage.local.set({ pendingHarvest: {
id: task.id, kind: task.kind, category: task.category, tries: 0, t: Date.now(),
before: [...before].filter(u => /^https?:/.test(u)), // blob: urls don't survive reloads
} }, r));
const r = await bg({ type: 'hardReload' });
if (!r || !r.ok) location.reload();
return; // the fresh page harvests via runPostReloadHarvest
}
// Images: the grid updates live — wait in-page for a new asset, error, or timeout.
const outcome = await waitForResultOrError(before.size, timeoutMs, errBase, doneBase);
if (outcome === 'ratelimit') { await handleRateLimit(task); return; } // long back-off, don't hammer
if (outcome === 'error') return await requeueWithReload(task, '"Something went wrong" persisted through Try-again');
if (outcome !== 'asset') {
const wall = [...document.querySelectorAll('*')].some(e => visible(e) &&
/out of credits|no credits|insufficient|quota exceeded|run out of|daily limit|reached your limit/i.test(e.textContent || '') &&
(e.textContent || '').length < 160);
throw new Error(wall ? 'Real credit/quota wall detected.' : 'Timed out — no new asset (harvest miss or Flow queue).');
}
await waitForStable(() => snapshotAssets().size, { min: before.size + 1 });
const after = snapshotAssets();
const fresh = [...after].filter(u => !before.has(u));
const { saved, media, tiles } = await harvestFresh(task.id, task.kind, task.category, fresh);
if (media.length) log('success', `Downloaded ${saved}/${media.length} asset(s) → Downloads/flowrinse/.`);
else if (tiles.length) log('info', `New tile detected but no direct media URL — reporting the tile link (recover later). ${gridCensus()}`);
await reportCompletion(task.id, 'success', null, fresh);
chrome.storage.local.remove('requeue_' + task.id);
successCount++; consecutiveRateLimits = 0; consecutiveFails = 0;
if (settings.restEvery && successCount % settings.restEvery === 0) {
setCooldown(settings.restMs || 15 * 60 * 1000, `Scheduled rest after ${successCount} gens`);
}
} catch (err) {
log('error', `Task ${task.id} failed: ${err.message}`);
await reportCompletion(task.id, 'failed', err.message, []);
// CIRCUIT BREAKER. 2026-07-08: Flow went quiet after two images and this loop
// politely fed it 13 more prompts over 45 minutes, marking every one failed.
// When Flow stops producing it does not spontaneously start again — STOP, and
// leave the queue intact so the run can be resumed by hand.
if (++consecutiveFails >= FAIL_STREAK_STOP) {
settings.isRunning = false;
chrome.storage.local.set({ isRunning: false });
log('system', `${consecutiveFails} failures in a row — STOPPING. `
+ `Flow isn't generating. Check the tab (credits? signed out? queue jammed?), `
+ `then re-queue the failed ids and press Start.`);
}
} finally {
generationInProgress = false;
chrome.storage.local.set({ status: settings.isRunning ? 'running' : 'idle' });
scheduleNext(); // no-ops when the breaker above cleared isRunning
}
}
// After the crucial post-gen refresh: diff the fresh grid against the persisted
// pre-submit snapshot, download what's new, report. Retries the refresh twice if
// the asset still hasn't surfaced; never re-submits (that would double-bill credits).
async function runPostReloadHarvest(ph) {
generationInProgress = true;
try {
if (Date.now() - (ph.t || 0) > 30 * 60 * 1000) { // stale (browser was closed?) — don't guess
chrome.storage.local.remove('pendingHarvest');
await reportCompletion(ph.id, 'failed', 'post-gen harvest state went stale (>30 min old)', []);
return;
}
await waitForAgentReady(30000);
const before = new Set(ph.before || []);
await waitForStable(() => [...snapshotAssets()].filter(u => /^https?:/.test(u)).length,
{ maxMs: 90000, min: before.size + 1 });
const fresh = [...snapshotAssets()].filter(u => /^https?:/.test(u) && !before.has(u));
if (!fresh.length) {
if ((ph.tries || 0) < 2) {
ph.tries = (ph.tries || 0) + 1;
await new Promise(r => chrome.storage.local.set({ pendingHarvest: ph }, r));
log('info', `Post-refresh grid shows nothing new yet — refresh ${ph.tries}/2 and re-check. ${gridCensus()}`);
await sleep(10000);
const r = await bg({ type: 'hardReload' });
if (!r || !r.ok) location.reload();
return;
}
chrome.storage.local.remove('pendingHarvest');
await reportCompletion(ph.id, 'failed', `video generated but never surfaced in the grid after 3 loads — ${gridCensus()}`, []);
return;
}
const { saved, media, tiles } = await harvestFresh(ph.id, ph.kind, ph.category, fresh);
if (media.length) log('success', `Post-refresh harvest: downloaded ${saved}/${media.length} → Downloads/flowrinse/.`);
else if (tiles.length) log('info', `Post-refresh: new tile found, no direct media URL — reporting tile link (recover later). ${gridCensus()}`);
chrome.storage.local.remove('pendingHarvest');
chrome.storage.local.remove('requeue_' + ph.id);
await reportCompletion(ph.id, 'success', null, fresh);
successCount++; consecutiveRateLimits = 0; consecutiveFails = 0;
} catch (err) {
log('error', `Post-refresh harvest failed: ${err.message}`);
chrome.storage.local.remove('pendingHarvest');
await reportCompletion(ph.id, 'failed', `post-refresh harvest: ${err.message}`, []);
} finally {
generationInProgress = false;
}
}
// ---- library harvest pass (queue a task with kind:"harvest" to trigger) ---------
// John's by-hand download: hover the tile → ⋮ → Download → 1K (original, instant).
// Reproduced with trusted CDP hover/clicks; background routes the downloads into
// Downloads/flowrinse/harvest/. Seen-list in storage → resumable, no double-dips.
const center = (el) => { const r = el.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; };
function findTileMenuButton(tile) {
// controls overlay lives on the tile's card ancestor; ⋮ is labelled more/options,
// or failing that the last icon button (screenshot order: ♡ ↻ ⋮)
let card = tile;
for (let i = 0; i < 3 && card.parentElement; i++) card = card.parentElement;
const btns = [...card.querySelectorAll('button, [role="button"]')].filter(visible);
const lbl = b => ((b.getAttribute('aria-label') || '') + (b.getAttribute('title') || '') + (b.textContent || '')).toLowerCase();
// SAFETY: never fall back onto ♥/↻/Animate — a stray click on Animate starts a PAID
// image→video generation. Prefer the explicit more/options control; else the last
// button that is NOT one of those.
return btns.find(b => /more|option|menu/.test(lbl(b)))
|| [...btns].reverse().find(b => !/animate|like|favou?rite|heart|redo|retry/.test(lbl(b)))
|| null;
}
function findMenuItem(re) {
const roled = [...document.querySelectorAll('[role="menu"] [role="menuitem"], [role="menuitem"], [role="menu"] button, [role="dialog"] button')]
.filter(visible).find(e => re.test((e.textContent || '').trim()));
if (roled) return roled;
// fallback for menus without ARIA roles: small visible leaf nodes with matching text
const leaf = [...document.querySelectorAll('div, span, li, button')].filter(visible)
.filter(e => re.test((e.textContent || '').trim()) && (e.textContent || '').trim().length < 30 &&
!e.querySelector('div, span, li, button'));
return leaf.length ? (leaf[0].closest('[role="menuitem"], li, button') || leaf[0]) : null;
}
// The "Download" menu ROW. NOTE: its size flyout may live inside/next to it so a naive
// textContent reads "Download1K Original size2K Upscaled…" — then /^download\b/ fails
// (no word boundary after 'download'), which silently skipped the whole step. Match
// "download" anywhere and take the SHORTEST visible candidate (the row, not a wrapper).
function findDownloadRow() {
const cands = [...document.querySelectorAll('[role="menuitem"], [role="menu"] button, button, [role="button"], div, span, li')]
.filter(visible)
.filter(e => /download/i.test(e.textContent || '') && (e.textContent || '').trim().length < 60);
if (!cands.length) return null;
cands.sort((a, b) => (a.textContent || '').length - (b.textContent || '').length);
return cands[0].closest('[role="menuitem"], button, [role="button"], li') || cands[0];
}
// The FREE "original size" download option. Images: "1K Original size". Videos:
// "720p Original Size". Both contain "original size" — that's the safe key.
// SAFETY: never return an "Upscaled" / "…credits" row. The 4K video upscale costs
// 50 credits; the 1080p upscale isn't free either. A stray click there spends money.
function findSizeOption() {
const cands = [...document.querySelectorAll('[role="menuitem"], [role="menu"] button, div, span, li')]
.filter(visible)
.filter(e => {
const t = (e.textContent || '').trim();
return t.length < 40 && /original\s*size/i.test(t) && !/upscal|credit/i.test(t);
});
if (!cands.length) return null;
// smallest matching node = the option leaf, not a container wrapping the whole flyout
cands.sort((a, b) => (a.textContent || '').length - (b.textContent || '').length);
return cands[0].closest('[role="menuitem"], li, button') || cands[0];
}
let harvestAbort = false;
// DL Mode from the popup: harvest what's on screen right now, no queue involved.
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'startHarvest') {
if (generationInProgress) { log('info', 'DL mode: busy with a task — stop Prompt Mode first.'); sendResponse({ ok: false }); return; }
log('system', 'DL mode ON: downloading everything on screen (1K originals)…');
executeHarvest({ id: 'manual_' + Date.now(), kind: 'harvest', category: 'harvest', manual: true });
sendResponse({ ok: true });
} else if (msg.type === 'stopHarvest') {
harvestAbort = true;
log('system', 'DL mode: stopping after the current tile…');
sendResponse({ ok: true });
}
});
async function executeHarvest(task) {
generationInProgress = true;
harvestAbort = false;
chrome.storage.local.set({ harvestActive: true, status: 'working' });
let got = 0, missed = 0, skipped = 0;
try {
await waitForAgentReady(30000);
const stored = await new Promise(r => chrome.storage.local.get({ harvested_urls_v2: [] }, d => r(d.harvested_urls_v2)));
const seen = new Set(stored); // CONFIRMED downloads — persisted, never re-fetched
const missedThisPass = new Set(); // transient misses — in-memory ONLY, retried next run
await bg({ type: 'harvestRouting', on: true });
window.scrollTo(0, 0); await sleep(800);
let idle = 0;
while (idle < 3 && !harvestAbort && got + missed < 500) {
const tile = [...document.querySelectorAll('a[href*="/edit/"]')]
.find(t => { const k = t.href.split('#')[0]; return visible(t) && !seen.has(k) && !missedThisPass.has(k); });
if (!tile) { // nothing new in view — page down (grid may virtualize)
window.scrollBy(0, innerHeight * 0.8); await sleep(1000); idle++; continue;
}
idle = 0;
const href = tile.href.split('#')[0];
tile.scrollIntoView({ block: 'center' }); await sleep(700);
// Stage 1/4: hover the tile until the ♡ ↻ ⋮ pill renders
await bg({ type: 'hoverAt', ...center(tile) });
const dots = await waitFor(() => findTileMenuButton(tile), 3000, 300);
if (!dots) { missed++; missedThisPass.add(href); log('info', `harvest: control pill never appeared — ${gridCensus()}`); continue; }
// Stage 2/4: click ⋮ ("More"), wait for the menu with Download in it
await bg({ type: 'clickAt', ...center(dots) });
const dl = await waitFor(findDownloadRow, 3000, 300);
if (!dl) { missed++; missedThisPass.add(href); log('info', `harvest: ⋮ clicked but no Download row found — ${gridCensus()}`); await bg({ type: 'pressEscape' }); await sleep(400); continue; }
const dc = center(dl);
log('info', `harvest: Download row "${(dl.textContent || '').trim().slice(0, 18)}" @ ${Math.round(dc.x)},${Math.round(dc.y)} — hovering for the size flyout…`);
// Stage 3/4: HOVER Download so the size flyout unfurls beside it. A single CDP jump
// can miss the hover-intent, so move in TWO steps (approach from the left, then
// settle on centre) to fire a real mouseenter, and re-query. We pick "original
// size" (1K image / 720p video) and NEVER the paid upscales (findSizeOption).
let oneK = null;
for (let j = 0; j < 4 && !oneK; j++) {
const c = center(dl);
await bg({ type: 'hoverAt', x: c.x - 28, y: c.y }); await sleep(130);
await bg({ type: 'hoverAt', x: c.x, y: c.y });
oneK = await waitFor(findSizeOption, 1400, 200);
}
if (!oneK) { // last resort: some builds expand the submenu on click
await bg({ type: 'clickAt', ...center(dl) });
oneK = await waitFor(findSizeOption, 1600, 250);
}
if (!oneK) { missed++; missedThisPass.add(href); log('info', `harvest: Download hovered but size flyout never appeared (no "original size"). ${gridCensus()}`); await bg({ type: 'pressEscape' }); await sleep(400); continue; }
log('info', `harvest: size option "${(oneK.textContent || '').trim().slice(0, 22)}" — downloading.`);
// Stage 4/4: walk the pointer INTO the flyout (keeps it open), then click 1K,
// then VERIFY a download event actually fired before counting it.
const dlBefore = ((await bg({ type: 'harvestStats' })) || {}).count || 0;
await bg({ type: 'hoverAt', ...center(oneK) }); await sleep(350);
await bg({ type: 'clickAt', ...center(oneK) });
let fired = false;
for (let j = 0; j < 12 && !fired; j++) {
await sleep(500);
fired = (((await bg({ type: 'harvestStats' })) || {}).count || 0) > dlBefore;
}
if (fired) {
// ONLY a confirmed download is persisted (never re-fetched). A transient miss
// stays in missedThisPass — skipped this pass, RETRIED on the next DL run, so a
// hover/menu race never permanently blacklists a never-downloaded asset.
got++; seen.add(href);
chrome.storage.local.set({ harvested_urls_v2: [...seen].slice(-20000) });
if (got % 10 === 0) log('info', `harvest: ${got} downloaded so far…`);
} else {
missed++; missedThisPass.add(href); log('info', 'harvest: clicked 1K but no download event fired.');
}
await bg({ type: 'pressEscape' }); await sleep(300); // ensure menus are closed before the next tile
await sleep(1200 + Math.random() * 1200); // pace — don't machine-gun the account
}
log('success', `Harvest ${harvestAbort ? 'stopped' : 'done'}: ${got} downloaded, ${missed} missed → flowrinse/harvest/ under the browser download dir.`);
if (!task.manual) await reportCompletion(task.id, 'success', null, [`downloaded:${got}`, `missed:${missed}`]);
} catch (err) {
log('error', `Harvest pass failed after ${got}: ${err.message}`);
if (!task.manual) await reportCompletion(task.id, 'failed', err.message, [`downloaded:${got}`]);
} finally {
await bg({ type: 'harvestRouting', on: false });
harvestAbort = false;
chrome.storage.local.set({ harvestActive: false, status: settings.isRunning ? 'running' : 'idle' });
window.scrollTo(0, 0);
generationInProgress = false;
scheduleNext();
}
}
async function reportCompletion(id, status, error, assets) {
// Terminal states clear the per-task markers; 'requeue'/'ratelimited' keep the task
// pending AND keep the submitted-marker so the reload never re-generates it.
if (status === 'success' || status === 'failed') {
chrome.storage.local.remove(['submitted_' + id, 'requeue_' + id]);
}
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}`);
}
}
// Persisted cooldown (survives a tab reload) — used for rate-limit back-off and rests.
function setCooldown(ms, why) {
settings.cooldownUntil = Date.now() + ms;
chrome.storage.local.set({ cooldownUntil: settings.cooldownUntil });
log('system', `${why} — pausing ${Math.round(ms / 60000)} min.`);
}
// Hit the account abuse flag ("unusual activity"). This is an ACTIVE account-level
// flag — worse than "Try again". Any activity while it's up (including reloads)
// feeds it, and it wears off on its own. So: report the task back as pending,
// then CEASE COMPLETELY for a solid 30 min (60/90 on repeat hits). The tab is
// hard-refreshed only when the freeze expires, right before resuming.
async function handleRateLimit(task) {
consecutiveRateLimits++;
const mins = Math.min(90, 30 * consecutiveRateLimits); // 30 → 60 → 90
await reportCompletion(task.id, 'ratelimited', 'unusual activity — ceasing all activity', []);
if (settings.autoRefreshOnJam) {
settings.reloadOnResume = true;
chrome.storage.local.set({ reloadOnResume: true });
}
setCooldown(mins * 60 * 1000, `⚠ Abuse flag ("unusual activity") ×${consecutiveRateLimits} — total freeze`);
}
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(-300) });
});
}
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();