fix(harvest): robust Download-row finder + two-step hover + Copy Log button + 300-line log

Reported symptom: harvest opens the More menu then moves on without ever hovering
Download. Cause: the size flyout's text can glue onto the Download row (textContent
"Download1K Original size2K Upscaled..."), so the old /^download\b/ match — which needs a
word boundary right after "download" — returned null and the whole size step was skipped.

Fix: findDownloadRow() matches "download" anywhere and takes the SHORTEST visible
candidate (the row itself, not a wrapper that swallowed the flyout). Stage-3 hover now
moves in two steps (approach from the left, then settle on centre) to fire a real
mouseenter that unfurls the submenu, and re-queries. Added per-stage logging (Download
row text+coords, chosen size option) so a copied log shows exactly where any future miss
happens. Popup gains a Copy button (whole activity log to clipboard) and log retention is
raised 100 -> 300 so a full harvest pass is capturable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-08 11:33:18 +10:00
parent 4aa7e19825
commit 607b5ec2b5
3 changed files with 48 additions and 15 deletions

View File

@ -573,6 +573,19 @@ function findMenuItem(re) {
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
@ -636,25 +649,28 @@ async function executeHarvest(task) {
// 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++; missedThisPass.add(href); log('info', `harvest: ⋮ clicked but no Download item — ${gridCensus()}`); await bg({ type: 'pressEscape' }); await sleep(400); continue; }
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 — the size flyout unfurls beside it (side depends on
// where the tile sits; 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. We pick "original size" (1K image / 720p video) and
// NEVER the paid upscales — findSizeOption enforces that.
// 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 < 3 && !oneK; j++) {
for (let j = 0; j < 4 && !oneK; j++) {
const c = center(dl);
await bg({ type: 'hoverAt', x: c.x - 4 + j * 4, y: c.y });
oneK = await waitFor(findSizeOption, 1600, 250);
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 on click
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: no "original size" option after hover + click-expand.'); await bg({ type: 'pressEscape' }); await sleep(400); continue; }
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.
@ -737,7 +753,7 @@ async function handleRateLimit(task) {
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(-100) });
chrome.storage.local.set({ logs: [...r.logs, { level, text, timestamp: Date.now() }].slice(-300) });
});
}

View File

@ -46,7 +46,7 @@
<div class="console-section">
<div class="console-header">
<span>Activity Log</span>
<button id="clear-logs" class="btn-text">Clear</button>
<span><button id="copy-logs" class="btn-text">Copy</button> &nbsp; <button id="clear-logs" class="btn-text">Clear</button></span>
</div>
<div class="console-body" id="log-console">
<div class="log-entry system-log">[System] Extension loaded. Click Start to begin.</div>

View File

@ -129,6 +129,23 @@ clearLogsBtn.addEventListener('click', () => {
});
});
// Copy Logs — grab the whole activity log to the clipboard for pasting into chat.
const copyLogsBtn = document.getElementById('copy-logs');
copyLogsBtn.addEventListener('click', () => {
chrome.storage.local.get({ logs: [] }, (res) => {
const text = (res.logs || []).map(l =>
`[${new Date(l.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}] ${l.text}`
).join('\n');
const done = () => { copyLogsBtn.textContent = 'Copied!'; setTimeout(() => { copyLogsBtn.textContent = 'Copy'; }, 1200); };
navigator.clipboard.writeText(text).then(done).catch(() => {
const ta = document.createElement('textarea'); ta.value = text;
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); } catch (e) {}
ta.remove(); done();
});
});
});
// Toggle Run Loop
toggleBtn.addEventListener('click', () => {
chrome.storage.local.get('isRunning', (res) => {
@ -152,7 +169,7 @@ function addSystemLog(text) {
text: text,
timestamp: Date.now()
};
const updated = [...res.logs, newLog].slice(-100); // limit to 100 logs
const updated = [...res.logs, newLog].slice(-300); // keep enough for a harvest debug
chrome.storage.local.set({ logs: updated }, () => {
appendLogToUI(newLog);
});