fix(harvest): four verified stages — pill wait, menu wait, jiggle-hover flyout, download-event confirmation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-08 09:03:25 +10:00
parent 2dfa752df3
commit 37a2ae8cb2
2 changed files with 47 additions and 19 deletions

View File

@ -29,10 +29,13 @@ 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/.
// While a harvest pass runs, route every browser download into flowrinse/harvest/
// and count them — the content script uses the count to VERIFY each 1K click fired.
let harvestRouting = false;
let harvestCount = 0;
chrome.downloads.onDeterminingFilename.addListener((item, suggest) => {
if (!harvestRouting) return;
harvestCount++;
suggest({ filename: `flowrinse/harvest/${(item.filename || 'asset').split(/[\\/]/).pop()}`, conflictAction: 'uniquify' });
});
@ -75,6 +78,8 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
} else if (msg.type === 'harvestRouting') {
harvestRouting = !!msg.on;
sendResponse({ ok: true });
} else if (msg.type === 'harvestStats') {
sendResponse({ ok: true, count: harvestCount });
} 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

@ -569,27 +569,50 @@ async function executeHarvest(task) {
}
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\b/i);
if (!dl) { missed++; seen.add(href); log('info', `harvest: menu open but no Download item — ${gridCensus()}`); await bg({ type: 'pressEscape' }); await sleep(400); continue; }
// "Download" is a submenu parent: HOVER unfurls the nested 1K/2K/4K flyout
// (clicking it closes the menu — the bug the first pass hit).
await bg({ type: 'hoverAt', ...center(dl) }); await sleep(800);
let oneK = findMenuItem(/\b1k\b|original/i);
if (!oneK) { // some builds expand on click instead — try once
await bg({ type: 'clickAt', ...center(dl) }); await sleep(800);
oneK = findMenuItem(/\b1k\b|original/i);
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++; seen.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(() => findMenuItem(/^\s*download\b/i), 3000, 300);
if (!dl) { missed++; seen.add(href); log('info', `harvest: ⋮ clicked but no Download item — ${gridCensus()}`); await bg({ type: 'pressEscape' }); await sleep(400); continue; }
// Stage 3/4: HOVER Download — the 1K/2K/4K flyout unfurls beside it (which side
// depends on where the tile sits on screen; irrelevant, coords are re-queried
// fresh). Jiggle the pointer a few px and retry — one mouseMoved sometimes
// isn't enough for the hover intent to register.
let oneK = null;
for (let j = 0; j < 3 && !oneK; j++) {
const c = center(dl);
await bg({ type: 'hoverAt', x: c.x - 4 + j * 4, y: c.y });
oneK = await waitFor(() => findMenuItem(/\b1k\b|original size/i), 1600, 250);
}
if (!oneK) { missed++; seen.add(href); log('info', 'harvest: Download hovered but no 1K flyout appeared.'); await bg({ type: 'pressEscape' }); await sleep(400); continue; }
if (!oneK) { // last resort: some builds expand on click
await bg({ type: 'clickAt', ...center(dl) });
oneK = await waitFor(() => findMenuItem(/\b1k\b|original size/i), 1600, 250);
}
if (!oneK) { missed++; seen.add(href); log('info', 'harvest: no 1K flyout after hover + click-expand.'); await bg({ type: 'pressEscape' }); await sleep(400); continue; }
// 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) });
got++; seen.add(href);
let fired = false;
for (let j = 0; j < 12 && !fired; j++) {
await sleep(500);
fired = (((await bg({ type: 'harvestStats' })) || {}).count || 0) > dlBefore;
}
if (fired) { got++; if (got % 10 === 0) log('info', `harvest: ${got} downloaded so far…`); }
else { missed++; log('info', 'harvest: clicked 1K but no download event fired.'); }
seen.add(href);
chrome.storage.local.set({ harvested_urls_v2: [...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
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 pass done: ${got} downloaded, ${missed} missed, ${skipped} skipped → Downloads/flowrinse/harvest/`);
await reportCompletion(task.id, 'success', null, [`downloaded:${got}`, `missed:${missed}`]);