From d3281bc251ee9471c2c8942f9ae2dac259078364 Mon Sep 17 00:00:00 2001 From: type-two Date: Tue, 7 Jul 2026 22:50:37 +1000 Subject: [PATCH] feat: library harvest pass + error-baseline fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - countErrMsgs baseline: lingering 'Something went wrong' chat history no longer fails every task (the 20:33-21:01 massacre — gens succeeded, all marked failed) - Try-again only clicked when a NEW error appears (old buttons re-ran old prompts) - kind:'harvest' task = automated John-ritual per tile: trusted CDP hover -> ⋮ -> Download -> 1K; background routes downloads to flowrinse/harvest/; seen-list in storage makes it resumable; paced + capped at 500/pass - queued harvest_all_001 to recover tonight's generated-but-not-downloaded batch Co-Authored-By: Claude Fable 5 --- background.js | 27 +++++++++++++++ content.js | 94 +++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 111 insertions(+), 10 deletions(-) diff --git a/background.js b/background.js index a912619..57d3e9a 100644 --- a/background.js +++ b/background.js @@ -27,6 +27,14 @@ async function ensure(tabId) { } const ENTER = { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 }; +const ESC = { key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27, nativeVirtualKeyCode: 27 }; + +// While a harvest pass runs, route every browser download into flowrinse/harvest/. +let harvestRouting = false; +chrome.downloads.onDeterminingFilename.addListener((item, suggest) => { + if (!harvestRouting) return; + suggest({ filename: `flowrinse/harvest/${(item.filename || 'asset').split(/[\\/]/).pop()}`, conflictAction: 'uniquify' }); +}); chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { const tabId = sender.tab && sender.tab.id; @@ -48,6 +56,25 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { { url: msg.url, filename: msg.filename, conflictAction: 'uniquify', saveAs: false }, (id) => chrome.runtime.lastError ? rej(chrome.runtime.lastError) : res(id))); sendResponse({ ok: dlId != null, id: dlId }); + } else if (msg.type === 'hoverAt') { + await ensure(tabId); + await cmd(tabId, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x: msg.x, y: msg.y }); + sendResponse({ ok: true }); + } else if (msg.type === 'clickAt') { + // Trusted click at viewport coords — Flow's menus ignore synthetic .click(). + await ensure(tabId); + await cmd(tabId, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x: msg.x, y: msg.y }); + await cmd(tabId, 'Input.dispatchMouseEvent', { type: 'mousePressed', x: msg.x, y: msg.y, button: 'left', clickCount: 1 }); + await cmd(tabId, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x: msg.x, y: msg.y, button: 'left', clickCount: 1 }); + sendResponse({ ok: true }); + } else if (msg.type === 'pressEscape') { + await ensure(tabId); + await cmd(tabId, 'Input.dispatchKeyEvent', { type: 'keyDown', ...ESC }); + await cmd(tabId, 'Input.dispatchKeyEvent', { type: 'keyUp', ...ESC }); + sendResponse({ ok: true }); + } else if (msg.type === 'harvestRouting') { + harvestRouting = !!msg.on; + sendResponse({ ok: true }); } else if (msg.type === 'hardReload') { // Cache-bypass reload — the by-hand fix for a wedged Flow session. if (attached.has(tabId)) { chrome.debugger.detach({ tabId }, () => {}); attached.delete(tabId); } diff --git a/content.js b/content.js index e30fc3d..d078b16 100644 --- a/content.js +++ b/content.js @@ -209,11 +209,13 @@ function snapshotAssets() { return urls; } -// Detect Flow's own failure banner so we can skip fast instead of waiting the full timeout. -function flowError() { - return [...document.querySelectorAll('*')].some(e => visible(e) && - /something went wrong|please try again|couldn'?t generate|failed to generate|generation failed/i.test(e.textContent || '') && - (e.textContent || '').length < 90); +// 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 — @@ -255,14 +257,15 @@ function findTryAgain() { // 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, maxRetries = 3) { +async function waitForResultOrError(baseCount, maxMs, errBase = 0, maxRetries = 3) { 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(); - if (now > cooldownUntil) { + 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 @@ -273,7 +276,7 @@ async function waitForResultOrError(baseCount, maxMs, maxRetries = 3) { await sleep(4000); continue; } - if (now - t0 > 8000 && flowError()) return 'error'; // errored, no retry offered + if (now - t0 > 8000) return 'error'; // errored, no retry offered } await sleep(2500); } @@ -377,6 +380,7 @@ async function requeueWithReload(task, why) { } 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 { @@ -385,6 +389,7 @@ async function executeTask(task) { 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: if the chat already echoes this prompt, the PREVIOUS attempt's // submit registered before its reload — re-typing would double-generate. Skip to waiting. @@ -423,7 +428,7 @@ async function executeTask(task) { 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 && (findTryAgain() || flowError())) + if (Date.now() - t0 > 8000 && countErrMsgs() > errBase) return await requeueWithReload(task, 'Flow errored during video generation'); await sleep(2500); } @@ -440,7 +445,7 @@ async function executeTask(task) { } // Images: the grid updates live — wait in-page for a new asset, error, or timeout. - const outcome = await waitForResultOrError(before.size, timeoutMs); + const outcome = await waitForResultOrError(before.size, timeoutMs, errBase); 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') { @@ -518,6 +523,75 @@ async function runPostReloadHarvest(ph) { } } +// ---- 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); + return btns.find(b => /more|option|menu/i.test((b.getAttribute('aria-label') || '') + (b.getAttribute('title') || ''))) + || btns[btns.length - 1] || null; +} + +function findMenuItem(re) { + return [...document.querySelectorAll('[role="menu"] [role="menuitem"], [role="menuitem"], [role="menu"] button, [role="dialog"] button')] + .filter(visible).find(e => re.test((e.textContent || '').trim())); +} + +async function executeHarvest(task) { + generationInProgress = true; + let got = 0, missed = 0, skipped = 0; + try { + await waitForAgentReady(30000); + const stored = await new Promise(r => chrome.storage.local.get({ harvested_urls: [] }, d => r(d.harvested_urls))); + const seen = new Set(stored); + await bg({ type: 'harvestRouting', on: true }); + window.scrollTo(0, 0); await sleep(800); + let idle = 0; + while (idle < 3 && got + missed < 500) { + const tile = [...document.querySelectorAll('a[href*="/edit/"]')] + .find(t => visible(t) && !seen.has(t.href.split('#')[0])); + 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(600); + await bg({ type: 'hoverAt', ...center(tile) }); await sleep(600); + const dots = findTileMenuButton(tile); + if (!dots) { missed++; seen.add(href); log('info', `harvest: no ⋮ on a tile — ${gridCensus()}`); continue; } + await bg({ type: 'clickAt', ...center(dots) }); await sleep(800); + const dl = findMenuItem(/^\s*download\s*$/i); + if (!dl) { missed++; seen.add(href); await bg({ type: 'pressEscape' }); await sleep(400); continue; } + await bg({ type: 'clickAt', ...center(dl) }); await sleep(800); + const oneK = findMenuItem(/1k|original/i); + if (!oneK) { missed++; seen.add(href); await bg({ type: 'pressEscape' }); await sleep(400); continue; } + await bg({ type: 'clickAt', ...center(oneK) }); + got++; seen.add(href); + chrome.storage.local.set({ harvested_urls: [...seen].slice(-3000) }); + if (got % 10 === 0) log('info', `harvest: ${got} downloaded so far…`); + await sleep(1800 + Math.random() * 1500); // pace — don't machine-gun the account + } + log('success', `Harvest pass done: ${got} downloaded, ${missed} missed, ${skipped} skipped → Downloads/flowrinse/harvest/`); + await reportCompletion(task.id, 'success', null, [`downloaded:${got}`, `missed:${missed}`]); + } catch (err) { + log('error', `Harvest pass failed after ${got}: ${err.message}`); + await reportCompletion(task.id, 'failed', err.message, [`downloaded:${got}`]); + } finally { + await bg({ type: 'harvestRouting', on: false }); + window.scrollTo(0, 0); + generationInProgress = false; + scheduleNext(); + } +} + async function reportCompletion(id, status, error, assets) { try { await fetch(`${settings.serverUrl}/task-completed`, {