- 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>
644 lines
31 KiB
JavaScript
644 lines
31 KiB
JavaScript
// 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/<id>/edit/<uuid> — 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, 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();
|
||
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
|
||
retries++;
|
||
log('info', `Flow hiccup — clicking "Try again" (${retries}/${maxRetries}).`);
|
||
btn.click();
|
||
cooldownUntil = 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: if the chat already echoes this prompt, the PREVIOUS attempt's
|
||
// submit registered before its reload — re-typing would double-generate. Skip to waiting.
|
||
const alreadySubmitted = promptEchoed(task.prompt);
|
||
if (alreadySubmitted) {
|
||
log('info', '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
|
||
// "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);
|
||
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);
|
||
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`, {
|
||
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(-100) });
|
||
});
|
||
}
|
||
|
||
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();
|