// 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; 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//edit/ — 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). function promptEchoed(prompt) { const needle = String(prompt).slice(0, 60).trim(); if (!needle) return false; return [...document.querySelectorAll('div, p, span')].some(e => visible(e) && !e.closest('[contenteditable="true"], textarea') && (e.textContent || '').includes(needle) && (e.textContent || '').length < needle.length + 500); } // 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; 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, []); } finally { generationInProgress = false; chrome.storage.local.set({ status: settings.isRunning ? 'running' : 'idle' }); scheduleNext(); } } // 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; } 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();