feat: library harvest pass + error-baseline fix

- 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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-07 22:50:37 +10:00
parent 78b3ac85b0
commit d3281bc251
2 changed files with 111 additions and 10 deletions

View File

@ -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); }

View File

@ -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`, {