Compare commits
18 Commits
72a793932f
...
18790a7e4e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18790a7e4e | ||
|
|
9974b93379 | ||
|
|
84fa103125 | ||
|
|
82d5ff494c | ||
|
|
3cff97df93 | ||
|
|
8464cb5e21 | ||
|
|
607b5ec2b5 | ||
|
|
4aa7e19825 | ||
|
|
20941c007c | ||
|
|
fc3e668504 | ||
|
|
37a2ae8cb2 | ||
|
|
2dfa752df3 | ||
|
|
d3281bc251 | ||
|
|
78b3ac85b0 | ||
|
|
39d133ddfb | ||
|
|
7fbde35e47 | ||
|
|
c66cc79e90 | ||
|
|
c21ea2b22d |
@ -27,6 +27,17 @@ 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/
|
||||
// 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' });
|
||||
});
|
||||
|
||||
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
const tabId = sender.tab && sender.tab.id;
|
||||
@ -48,6 +59,32 @@ 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 === '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); }
|
||||
chrome.tabs.reload(tabId, { bypassCache: true });
|
||||
sendResponse({ ok: true });
|
||||
} else if (msg.type === 'detach') {
|
||||
if (attached.has(tabId)) { chrome.debugger.detach({ tabId }, () => {}); attached.delete(tabId); }
|
||||
sendResponse({ ok: true });
|
||||
|
||||
18
banks/djsim_batch3.jsonl
Normal file
18
banks/djsim_batch3.jsonl
Normal file
@ -0,0 +1,18 @@
|
||||
{"id": "djsim_b3_001", "kind": "image", "model": "Nano Banana 2", "category": "djsim_batch3", "title": "dig backdrop — op shop", "prompt": "Interior background art for a cluttered 1990s Australian op shop: racks of secondhand clothes, shelves of bric-a-brac, board games and mismatched crockery, a cardboard box of vinyl records on the worn carpet, hand-written price signs, buzzing fluorescent tubes, donation bags piled by the counter. 1990s point-and-click adventure game background art, VGA 256-color pixel art with visible dithering, warm dingy interior light, single scene, no characters, 16:9."}
|
||||
{"id": "djsim_b3_002", "kind": "image", "model": "Nano Banana 2", "category": "djsim_batch3", "title": "dig backdrop — pawn shop", "prompt": "Interior background art for a 1990s Australian pawn shop: glass display cabinets with watches and gold chains, electric guitars hanging on the wall, stacked VCRs, amplifiers and car stereos, a barred front window, hand-written lay-by signs, harsh fluorescent light. 1990s point-and-click adventure game background art, VGA 256-color pixel art with visible dithering, single scene, no characters, 16:9."}
|
||||
{"id": "djsim_b3_003", "kind": "image", "model": "Nano Banana 2", "category": "djsim_batch3", "title": "dig backdrop — flea market", "prompt": "Background art for an outdoor Australian weekend flea market at dawn: trestle tables with milk crates of vinyl records, tarpaulins strung overhead, cardboard boxes of paperbacks and tools, pale early golden light, sausage sizzle smoke drifting in the background. 1990s point-and-click adventure game background art, VGA 256-color pixel art with visible dithering, single scene, no characters, 16:9."}
|
||||
{"id": "djsim_b3_004", "kind": "image", "model": "Nano Banana 2", "category": "djsim_batch3", "title": "dig backdrop — video shop", "prompt": "Interior background art for a 1990s suburban Australian video rental shop: shelves of VHS tapes with faded covers, a wire ex-rental bargain bin, a CRT television playing static on the counter, movie posters on the walls, weekly specials whiteboard. 1990s point-and-click adventure game background art, VGA 256-color pixel art with visible dithering, single scene, no characters, 16:9."}
|
||||
{"id": "djsim_b3_005", "kind": "image", "model": "Nano Banana 2", "category": "djsim_batch3", "title": "dig backdrop — department store", "prompt": "Interior background art for an old Australian department store record bar in 1990: carpeted floor, a listening booth with headphones on hooks, formica counters with racks of LPs and cassettes, slightly dated grandeur, warm tungsten downlights. 1990s point-and-click adventure game background art, VGA 256-color pixel art with visible dithering, single scene, no characters, 16:9."}
|
||||
{"id": "djsim_b3_006", "kind": "image", "model": "Nano Banana 2", "category": "djsim_batch3", "title": "dig backdrop — import record shop", "prompt": "Interior background art for an underground import record shop, 1990s: milk crates of 12-inch singles on trestle tables, road flight cases, one wall completely covered in rave flyers, a single neon strip light, dim basement atmosphere. 1990s point-and-click adventure game background art, VGA 256-color pixel art with visible dithering, single scene, no characters, 16:9."}
|
||||
{"id": "djsim_b3_007", "kind": "image", "model": "Nano Banana 2", "category": "djsim_batch3", "title": "venue — dive bar", "prompt": "Interior of a grimy Australian dive bar with a tiny corner DJ booth: beer-stained patterned carpet, one pool table under a low hanging light, three regulars hunched at the bar, faded band posters, moody tungsten lighting. Low-poly flat-shaded 3D diorama, blocky matte geometry, un-photoreal early-3D look, muted 90s palette, warm cinematic lighting, 16:9."}
|
||||
{"id": "djsim_b3_008", "kind": "image", "model": "Nano Banana 2", "category": "djsim_batch3", "title": "venue — sports bar", "prompt": "Interior of a 1990s suburban Australian sports bar: TAB screens on the walls, a small DJ setup squeezed next to a trophy cabinet, patrons in fluoro polo shirts, keno slips on the tables, harsh fluorescent light over the bar. Low-poly flat-shaded 3D diorama, blocky matte geometry, un-photoreal early-3D look, muted 90s palette, 16:9."}
|
||||
{"id": "djsim_b3_009", "kind": "image", "model": "Nano Banana 2", "category": "djsim_batch3", "title": "venue — cocktail lounge", "prompt": "Interior of a velvet-and-chrome 1990s cocktail lounge: small parquet dancefloor, mirror ball throwing spots of light, cigarette haze drifting through coloured uplights, curved booth seating, a long low bar. Low-poly flat-shaded 3D diorama, blocky matte geometry, un-photoreal early-3D look, muted 90s palette, warm cinematic lighting, 16:9."}
|
||||
{"id": "djsim_b3_010", "kind": "image", "model": "Nano Banana 2", "category": "djsim_batch3", "title": "venue — warehouse rave", "prompt": "Interior of an illegal warehouse rave in 1994: corrugated iron walls, huge stacked speaker systems, green lasers cutting through fog, a packed crowd with hands up, strobe flashes, DJ silhouetted behind decks on road cases. Low-poly flat-shaded 3D diorama, blocky matte geometry, un-photoreal early-3D look, neon green #3dff8b and hot pink #ff5cad accents in darkness, 16:9."}
|
||||
{"id": "djsim_b3_011", "kind": "image", "model": "Nano Banana 2", "category": "djsim_batch3", "title": "venue — house party", "prompt": "Australian backyard house party at night: a Hills Hoist clothesline strung with fairy lights, DJ decks set up on a ping-pong table, kids dancing on the lawn, an esky full of ice, weatherboard house with the kitchen light on. Low-poly flat-shaded 3D diorama, blocky matte geometry, un-photoreal early-3D look, muted 90s palette, warm string-light glow against deep blue night, 16:9."}
|
||||
{"id": "djsim_b3_012", "kind": "image", "model": "Nano Banana 2", "category": "djsim_batch3", "title": "NPC — pub booker", "prompt": "Character bust portrait of a gruff Australian pub booker in his 50s: flannel shirt, cigarette tucked behind one ear, skeptical squint, beer gut. Low-poly flat-shaded 3D character, blocky matte geometry, un-photoreal early-3D look, muted 90s palette, isolated on a plain solid mid-gray #808080 background, centered, 1:1 square."}
|
||||
{"id": "djsim_b3_013", "kind": "image", "model": "Nano Banana 2", "category": "djsim_batch3", "title": "NPC — club promoter", "prompt": "Character bust portrait of a sharp-dressed 1990s nightclub promoter: slicked-back hair, open collar, gold chain, brick mobile phone in hand, confident smirk. Low-poly flat-shaded 3D character, blocky matte geometry, un-photoreal early-3D look, muted 90s palette, isolated on a plain solid mid-gray #808080 background, centered, 1:1 square."}
|
||||
{"id": "djsim_b3_014", "kind": "image", "model": "Nano Banana 2", "category": "djsim_batch3", "title": "NPC — milk bar owner", "prompt": "Character bust portrait of an elderly Australian milk bar owner in an apron: kind but unimpressed expression, reading glasses on a chain around the neck, arms crossed. Low-poly flat-shaded 3D character, blocky matte geometry, un-photoreal early-3D look, muted 90s palette, isolated on a plain solid mid-gray #808080 background, centered, 1:1 square."}
|
||||
{"id": "djsim_b3_015", "kind": "video", "model": "Veo 3.1 - Fast", "category": "djsim_batch3", "title": "vignette — bus ride (bus.mp4)", "prompt": "Interior of a 1990s Australian bus at golden hour, camera at seat level looking out the window as suburban shopfronts and telegraph poles slide past, a milk crate of vinyl records on the seat beside the window, gentle engine sway, warm afternoon light flickering through trees. Low-poly flat-shaded 3D animation, blocky matte geometry, un-photoreal early-3D look, muted 90s palette, 8 seconds, 16:9."}
|
||||
{"id": "djsim_b3_016", "kind": "video", "model": "Veo 3.1 - Fast", "category": "djsim_batch3", "title": "vignette — warehouse walk-up (warehouse.mp4)", "prompt": "POV walking along train tracks at night toward an illegal warehouse party, muffled bass growing louder with each step, a corrugated iron door cracks open and green laser light spills out with fog. Low-poly flat-shaded 3D animation, blocky matte geometry, un-photoreal early-3D look, neon green #3dff8b glow against deep blue-black night, 8 seconds, 16:9."}
|
||||
{"id": "djsim_b3_017", "kind": "video", "model": "Veo 3.1 - Fast", "category": "djsim_batch3", "title": "vignette — NYE 1999 finale (nye99.mp4)", "prompt": "New Year's Eve 1999: fireworks bursting over an Australian beachside town, silhouetted crowd cheering on the foreshore, DJ booth in the foreground with hands in the air, euphoric gold and pink bursts reflecting on the water. Low-poly flat-shaded 3D animation, blocky matte geometry, un-photoreal early-3D look, 8 seconds, 16:9."}
|
||||
{"id": "djsim_b3_018", "kind": "video", "model": "Veo 3.1 - Fast", "category": "djsim_batch3", "title": "vignette — rain on the strip (rain.mp4)", "prompt": "Rain falling on a neon-lit 1990s Australian shopping strip at night, empty wet street reflecting pink #ff5cad and green #3dff8b shop neon, one figure hurrying past with a record bag held under their jacket, puddle ripples. Low-poly flat-shaded 3D animation, blocky matte geometry, un-photoreal early-3D look, 8 seconds, 16:9."}
|
||||
15
banks/djsim_phonewall.jsonl
Normal file
15
banks/djsim_phonewall.jsonl
Normal file
@ -0,0 +1,15 @@
|
||||
{"id": "cal79_skylab", "kind": "image", "model": "Nano Banana 2", "category": "djsim_phonewall", "title": "1979 calendar — Skylab", "prompt": "The illustrated top picture-panel of a cheap 1979 Australian promotional wall calendar (picture only, NO date grid, NO numbers), printed on aged sun-faded cheap paper with two staple holes along the top edge and a faint coffee-ring stain, muted washed-out 1979 print colours. Scene: the SKYLAB space station breaking apart in streaks of fire over the flat red Western Australian desert at dusk, a lone farmer in a hat looking up in alarm, bold tabloid headline banner 'IT'S COMING DOWN'. Flat period-print poster look, 4:3."}
|
||||
{"id": "cal79_alien", "kind": "image", "model": "Nano Banana 2", "category": "djsim_phonewall", "title": "1979 calendar — Alien", "prompt": "The illustrated top picture-panel of a cheap 1979 promotional wall calendar (picture only, NO date grid), aged sun-faded cheap paper, two staple holes along the top edge, faint water stain, muted 1979 print colours. Scene: a dripping biomechanical H.R. Giger-style alien egg cracking open with eerie green light spilling out in cold darkness, ominous tagline 'IN SPACE NO ONE CAN HEAR YOU SCREAM'. Flat film-poster print look, 4:3."}
|
||||
{"id": "cal79_joydivision", "kind": "image", "model": "Nano Banana 2", "category": "djsim_phonewall", "title": "1979 calendar — Joy Division", "prompt": "The illustrated top picture-panel of a cheap 1979 promotional wall calendar (picture only, NO date grid), aged cheap paper, two staple holes along the top edge, faint stain. Scene: the iconic austere pulsar radio-wave image — rows of stacked white jagged peaks on a solid black background (Joy Division Unknown Pleasures style), bleak and minimal, no other text. Flat print look, 4:3."}
|
||||
{"id": "cal79_disco", "kind": "image", "model": "Nano Banana 2", "category": "djsim_phonewall", "title": "1979 calendar — Disco Demolition", "prompt": "The illustrated top picture-panel of a cheap 1979 promotional wall calendar (picture only, NO date grid), aged sun-faded paper, staple holes top edge, muted print colours. Scene: Disco Demolition Night 1979 — a huge crate of vinyl records exploding in a fireball on a floodlit baseball field at night, a rowdy crowd surging, a hand-painted 'DISCO SUCKS' banner. Flat gritty print look, 4:3."}
|
||||
{"id": "cal79_tmi", "kind": "image", "model": "Nano Banana 2", "category": "djsim_phonewall", "title": "1979 calendar — Three Mile Island", "prompt": "The illustrated top picture-panel of a cheap 1979 promotional wall calendar (picture only, NO date grid), aged paper, staple holes top edge. Scene: a deliberately cheerful power-company promotional illustration of the Three Mile Island nuclear cooling towers under a bright blue sky with a smiling cartoon sun, wholesome corporate slogan banner 'SAFE · CLEAN · YOUR ENERGY FUTURE'. Flat wholesome-but-sinister print look, 4:3."}
|
||||
{"id": "cal79_iran", "kind": "image", "model": "Nano Banana 2", "category": "djsim_phonewall", "title": "1979 calendar — Iran hostage crisis", "prompt": "The illustrated top picture-panel of a cheap 1979 promotional wall calendar (picture only, NO date grid), aged paper, staple holes top edge, muted grim print colours. Scene: a stark 1979 news-desk graphic of the Iran hostage crisis — a blindfolded silhouetted figure, an American flag, and a large ominous tally counter reading 'DAY ▢ OF 444'. Flat grim news-graphic print look, 4:3."}
|
||||
{"id": "cal79_walkman", "kind": "image", "model": "Nano Banana 2", "category": "djsim_phonewall", "title": "1979 calendar — Sony Walkman", "prompt": "The illustrated top picture-panel of a cheap 1979 promotional wall calendar (picture only, NO date grid), aged sun-faded paper, staple holes top edge, cheery muted 1979 print colours. Scene: a beaming 1979 jogger in a bright tracksuit wearing orange foam-cushioned headphones with a silver Sony Walkman clipped to the belt, upbeat advertising slogan 'TAKE YOUR MUSIC ANYWHERE'. Flat retro-advert print look, 4:3."}
|
||||
{"id": "cal79_thewall", "kind": "image", "model": "Nano Banana 2", "category": "djsim_phonewall", "title": "1979 calendar — The Wall", "prompt": "The illustrated top picture-panel of a cheap 1979 promotional wall calendar (picture only, NO date grid), aged paper, staple holes top edge, bleak muted print colours. Scene: Pink Floyd The Wall style — rows of faceless marching cartoon hammers and a towering grey brick wall against a cold sky, isolation and dread, banner 'ANOTHER BRICK'. Flat album-art print look, 4:3."}
|
||||
{"id": "cal79_phantasm", "kind": "image", "model": "Nano Banana 2", "category": "djsim_phonewall", "title": "1979 calendar — Phantasm", "prompt": "The illustrated top picture-panel of a cheap 1979 promotional wall calendar (picture only, NO date grid), aged paper, staple holes top edge, lurid muted horror print colours. Scene: a floating polished chrome sphere with a protruding spike dripping, and a tall gaunt undertaker in black (the Tall Man) looming in a cold marble mausoleum, video-nasty horror, single word 'BOOOOY'. Flat lurid poster print look, 4:3."}
|
||||
{"id": "cal79_thatcher", "kind": "image", "model": "Nano Banana 2", "category": "djsim_phonewall", "title": "1979 calendar — Thatcher", "prompt": "The illustrated top picture-panel of a cheap 1979 promotional wall calendar (picture only, NO date grid), aged paper, staple holes top edge, cold grey muted print colours. Scene: a stern formal 1979 political portrait of a British prime minister in a blue suit at a podium, cold and severe, a grim slogan banner 'THERE IS NO ALTERNATIVE'. Flat austere political-print look, 4:3."}
|
||||
{"id": "cal79_apocalypse", "kind": "image", "model": "Nano Banana 2", "category": "djsim_phonewall", "title": "1979 calendar — Apocalypse Now", "prompt": "The illustrated top picture-panel of a cheap 1979 promotional wall calendar (picture only, NO date grid), aged paper, staple holes top edge, muted print colours. Scene: military helicopter gunships silhouetted against a blazing orange napalm dawn over a jungle river, Apocalypse Now style, tagline 'CHARLIE DON'T SURF'. Flat war-film-poster print look, 4:3."}
|
||||
{"id": "cal79_kenner", "kind": "image", "model": "Nano Banana 2", "category": "djsim_phonewall", "title": "1979 calendar — Kenner Alien toy", "prompt": "The illustrated top picture-panel of a cheap 1979 promotional wall calendar (picture only, NO date grid), aged paper, staple holes top edge, bright-but-eerie toy-advert print colours. Scene: a 1979 toy-shop advertisement for a large sinister 18-inch black alien action figure standing in a cardboard box stamped 'THE TOY THEY PULLED', a small child crying and reaching away. Flat retro toy-catalogue print look, 4:3."}
|
||||
{"id": "cal79_throbbing", "kind": "image", "model": "Nano Banana 2", "category": "djsim_phonewall", "title": "1979 calendar — Throbbing Gristle", "prompt": "The illustrated top picture-panel of a cheap 1979 promotional wall calendar (picture only, NO date grid), aged faded paper, staple holes top edge, washed-out unsettling print colours. Scene: a deceptively pretty faded holiday-postcard photo of a grassy green clifftop overlooking a calm grey sea under a pale sky — quietly wrong and cold, no people, industrial-album-cover irony. Flat faded-postcard print look, 4:3."}
|
||||
{"id": "cal79_microvision", "kind": "image", "model": "Nano Banana 2", "category": "djsim_phonewall", "title": "1979 calendar — Microvision", "prompt": "The illustrated top picture-panel of a cheap 1979 promotional wall calendar (picture only, NO date grid), aged paper, staple holes top edge, muted electronics-catalogue print colours. Scene: a chunky primitive 1979 handheld LCD game console held in two hands, its blocky low-res pixel-matrix screen glowing faintly, breathless slogan 'THE FUTURE OF FUN'. Flat retro-electronics-catalogue print look, 4:3."}
|
||||
{"id": "cal79_afghanistan", "kind": "image", "model": "Nano Banana 2", "category": "djsim_phonewall", "title": "1979 calendar — Afghanistan / Doomsday", "prompt": "The illustrated top picture-panel of a cheap 1979 promotional wall calendar (picture only, NO date grid), aged paper, staple holes top edge, cold-war muted print colours. Scene: a column of Soviet tanks rolling forward under a blood-red sky, and a large Doomsday Clock in the corner with its hands near 'MINUTES TO MIDNIGHT'. Flat grim cold-war-graphic print look, 4:3."}
|
||||
594
content.js
594
content.js
@ -21,20 +21,36 @@ let settings = {
|
||||
submitSel: '',
|
||||
resultSel: '', // scope for harvesting (e.g. the media library container)
|
||||
pollInterval: 4000,
|
||||
genTimeoutMs: 8 * 60 * 1000 // Veo clips can take minutes
|
||||
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;
|
||||
let consecutiveFails = 0;
|
||||
const FAIL_STREAK_STOP = 3; // when Flow goes quiet, it stays quiet. Stop; don't eat the bank.
|
||||
|
||||
function init() {
|
||||
chrome.storage.local.get(settings, (data) => {
|
||||
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();
|
||||
});
|
||||
}
|
||||
@ -145,25 +161,32 @@ function findAgentInput() {
|
||||
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);
|
||||
const byLabel = btns.find(b => /send|submit|create|generate|run/i.test((b.getAttribute('aria-label') || '') + b.innerText));
|
||||
if (byLabel) return byLabel;
|
||||
// else: the last enabled icon-only button in the composer (the → arrow)
|
||||
const iconOnly = btns.filter(b => !b.innerText.trim() && b.querySelector('svg'));
|
||||
return iconOnly[iconOnly.length - 1] || btns[btns.length - 1] || null;
|
||||
}
|
||||
|
||||
// If a confirm/generate dialog appears (Always mode), click it. No-op under Never.
|
||||
function clickConfirmIfPresent() {
|
||||
const b = [...document.querySelectorAll('button, [role="button"]')]
|
||||
.find(x => visible(x) && !x.disabled && /^(generate|create|confirm|continue|yes)\b/i.test(x.innerText.trim()));
|
||||
if (b) { b.click(); return true; }
|
||||
return false;
|
||||
// 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).
|
||||
@ -178,6 +201,9 @@ function snapshotAssets() {
|
||||
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);
|
||||
@ -185,11 +211,57 @@ function snapshotAssets() {
|
||||
return urls;
|
||||
}
|
||||
|
||||
// Detect Flow's own failure banner so we can skip fast instead of waiting the full timeout.
|
||||
function flowError() {
|
||||
// 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) &&
|
||||
/something went wrong|please try again|couldn'?t generate|failed to generate|generation failed/i.test(e.textContent || '') &&
|
||||
(e.textContent || '').length < 90);
|
||||
/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).
|
||||
//
|
||||
// It must identify THIS prompt, not "a prompt that starts the same way". 2026-07-08: this
|
||||
// matched prompt.slice(0,60), and 13 of the 15 djsim_phonewall prompts opened with an
|
||||
// identical 60 chars ("The illustrated top picture-panel of a cheap 1979 promotiona"). The
|
||||
// previous task's echo was still on screen, so the driver decided every one of them had
|
||||
// already been submitted, never typed them, and sat out a 3-minute timeout. Thirteen times.
|
||||
//
|
||||
// No fixed slice is safe: djsim_phonewall needs 160 chars of PREFIX to disambiguate,
|
||||
// djsim_batch3 needs 190 chars of SUFFIX. So match the whole thing — or, if the DOM has
|
||||
// mangled the middle, require both ends in the same element.
|
||||
const normText = (s) => String(s).replace(/\s+/g, ' ').trim();
|
||||
|
||||
function promptEchoed(prompt) {
|
||||
const full = normText(prompt);
|
||||
if (full.length < 12) return false;
|
||||
const head = full.slice(0, 100), tail = full.slice(-100);
|
||||
return [...document.querySelectorAll('div, p, span')].some(e => {
|
||||
if (!visible(e) || e.closest('[contenteditable="true"], textarea')) return false;
|
||||
const t = normText(e.textContent || '');
|
||||
if (t.length > full.length + 500) return false; // that's a container, not the message
|
||||
return t.includes(full) || (t.includes(head) && t.includes(tail));
|
||||
});
|
||||
}
|
||||
|
||||
// Flow's "Try again" button, shown beside the "Something went wrong" banner.
|
||||
@ -203,78 +275,214 @@ 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, doneBase = 0, maxRetries = 1) {
|
||||
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
|
||||
// A "Something went wrong" banner can co-occur with a gen that ACTUALLY
|
||||
// succeeded (Flow errors constantly under load). Clicking Try-again re-runs
|
||||
// it for another 15 credits. So SETTLE and re-check: only retry if there is
|
||||
// still no asset AND no NEW completion message. Each retry is real money —
|
||||
// capped at maxRetries=1.
|
||||
await sleep(2500);
|
||||
if (snapshotAssets().size > baseCount || countDoneMsgs() > doneBase) return 'asset';
|
||||
retries++;
|
||||
log('info', `Flow hiccup — clicking "Try again" (${retries}/${maxRetries}).`);
|
||||
btn.click();
|
||||
cooldownUntil = now + 15000; // let it re-generate before re-checking
|
||||
cooldownUntil = Date.now() + 15000; // let it re-generate before re-checking
|
||||
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);
|
||||
}
|
||||
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;
|
||||
pollTimeout = setTimeout(async () => {
|
||||
if (generationInProgress) return startPolling();
|
||||
try {
|
||||
const res = await fetch(`${settings.serverUrl}/next-task`);
|
||||
if (res.status === 204) return startPolling();
|
||||
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 startPolling();
|
||||
} catch (err) {
|
||||
log('error', `Poll failed: ${err.message}`);
|
||||
startPolling();
|
||||
}
|
||||
}, settings.pollInterval);
|
||||
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 waitFor(findAgentInput, 20000);
|
||||
if (!input) throw new Error('Agent input box not found — are you on the Flow project page?');
|
||||
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
|
||||
|
||||
const landed = await typeIntoEditor(input, task.prompt);
|
||||
if (!landed) throw new Error('Prompt did not register in the editor (injection failed) — nothing submitted.');
|
||||
log('info', `Prompt set (${landed} chars). Submitting…`);
|
||||
await sleep(300);
|
||||
// Soft-detect first: a DURABLE marker (survives the requeue hard-reload) or a live
|
||||
// prompt-echo means Google ALREADY started the paid generation for this task —
|
||||
// re-typing would double-bill. Skip straight to waiting for the result.
|
||||
const submittedKey = 'submitted_' + task.id;
|
||||
const priorSubmit = await new Promise(r => chrome.storage.local.get({ [submittedKey]: false }, d => r(d[submittedKey])));
|
||||
const alreadySubmitted = priorSubmit || promptEchoed(task.prompt);
|
||||
if (alreadySubmitted) {
|
||||
log('info', priorSubmit
|
||||
? 'Task already submitted on a prior attempt (durable marker) — waiting for the result, NOT re-generating.'
|
||||
: '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
|
||||
await sleep(700);
|
||||
const submit = findSubmit(input); // arrow button, as a fallback
|
||||
if (submit) submit.click();
|
||||
clickConfirmIfPresent(); // harmless under "Never"
|
||||
await submitEditor(input); // trusted Enter via the DevTools Protocol — this submits
|
||||
// CREDITS COMMITTED. Persist the marker BEFORE the stuck-check: the visual "stuck"
|
||||
// signals lag under load, and a requeue+reload that trusts them to gate re-submit
|
||||
// is exactly what double-billed. Conservative by design — if the submit somehow
|
||||
// didn't take, the task times out and fails (retryable) rather than pays twice.
|
||||
await new Promise(r => chrome.storage.local.set({ [submittedKey]: true }, r));
|
||||
// "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…');
|
||||
}
|
||||
|
||||
await sleep(1500);
|
||||
log('info', 'Submitted. Waiting for generation…');
|
||||
|
||||
// Wait for a new asset, a Flow error (fast-fail), or timeout. Images should be quick.
|
||||
const timeoutMs = task.kind === 'image' ? 3 * 60 * 1000 : settings.genTimeoutMs;
|
||||
const outcome = await waitForResultOrError(before.size, timeoutMs);
|
||||
if (outcome === 'error') throw new Error('Flow errored ("Something went wrong") after retries — skipping.');
|
||||
|
||||
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, doneBase);
|
||||
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 || '') &&
|
||||
@ -285,29 +493,258 @@ async function executeTask(task) {
|
||||
|
||||
const after = snapshotAssets();
|
||||
const fresh = [...after].filter(u => !before.has(u));
|
||||
log('success', `Harvested ${fresh.length} asset(s).`);
|
||||
|
||||
// Browser download — uses your session, no CSP/CORS. Lands in Downloads/flowrinse/<category>/.
|
||||
const ext = task.kind === 'video' ? 'mp4' : 'png';
|
||||
const cat = (task.category || 'misc').replace(/[^a-z0-9_\-]/gi, '_');
|
||||
let saved = 0;
|
||||
for (let i = 0; i < fresh.length; i++) {
|
||||
const r = await bg({ type: 'download', url: fresh[i], filename: `flowrinse/${cat}/${task.id}_${i}.${ext}` });
|
||||
if (r && r.ok) saved++;
|
||||
}
|
||||
if (fresh.length) log(saved === fresh.length ? 'success' : 'info', `Downloaded ${saved}/${fresh.length} → Downloads/flowrinse/${cat}/`);
|
||||
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; consecutiveFails = 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, []);
|
||||
// CIRCUIT BREAKER. 2026-07-08: Flow went quiet after two images and this loop
|
||||
// politely fed it 13 more prompts over 45 minutes, marking every one failed.
|
||||
// When Flow stops producing it does not spontaneously start again — STOP, and
|
||||
// leave the queue intact so the run can be resumed by hand.
|
||||
if (++consecutiveFails >= FAIL_STREAK_STOP) {
|
||||
settings.isRunning = false;
|
||||
chrome.storage.local.set({ isRunning: false });
|
||||
log('system', `⛔ ${consecutiveFails} failures in a row — STOPPING. `
|
||||
+ `Flow isn't generating. Check the tab (credits? signed out? queue jammed?), `
|
||||
+ `then re-queue the failed ids and press Start.`);
|
||||
}
|
||||
} finally {
|
||||
generationInProgress = false;
|
||||
chrome.storage.local.set({ status: settings.isRunning ? 'running' : 'idle' });
|
||||
startPolling();
|
||||
scheduleNext(); // no-ops when the breaker above cleared isRunning
|
||||
}
|
||||
}
|
||||
|
||||
// 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; consecutiveFails = 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);
|
||||
const lbl = b => ((b.getAttribute('aria-label') || '') + (b.getAttribute('title') || '') + (b.textContent || '')).toLowerCase();
|
||||
// SAFETY: never fall back onto ♥/↻/Animate — a stray click on Animate starts a PAID
|
||||
// image→video generation. Prefer the explicit more/options control; else the last
|
||||
// button that is NOT one of those.
|
||||
return btns.find(b => /more|option|menu/.test(lbl(b)))
|
||||
|| [...btns].reverse().find(b => !/animate|like|favou?rite|heart|redo|retry/.test(lbl(b)))
|
||||
|| null;
|
||||
}
|
||||
|
||||
function findMenuItem(re) {
|
||||
const roled = [...document.querySelectorAll('[role="menu"] [role="menuitem"], [role="menuitem"], [role="menu"] button, [role="dialog"] button')]
|
||||
.filter(visible).find(e => re.test((e.textContent || '').trim()));
|
||||
if (roled) return roled;
|
||||
// fallback for menus without ARIA roles: small visible leaf nodes with matching text
|
||||
const leaf = [...document.querySelectorAll('div, span, li, button')].filter(visible)
|
||||
.filter(e => re.test((e.textContent || '').trim()) && (e.textContent || '').trim().length < 30 &&
|
||||
!e.querySelector('div, span, li, button'));
|
||||
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
|
||||
// 50 credits; the 1080p upscale isn't free either. A stray click there spends money.
|
||||
function findSizeOption() {
|
||||
const cands = [...document.querySelectorAll('[role="menuitem"], [role="menu"] button, div, span, li')]
|
||||
.filter(visible)
|
||||
.filter(e => {
|
||||
const t = (e.textContent || '').trim();
|
||||
return t.length < 40 && /original\s*size/i.test(t) && !/upscal|credit/i.test(t);
|
||||
});
|
||||
if (!cands.length) return null;
|
||||
// smallest matching node = the option leaf, not a container wrapping the whole flyout
|
||||
cands.sort((a, b) => (a.textContent || '').length - (b.textContent || '').length);
|
||||
return cands[0].closest('[role="menuitem"], li, button') || cands[0];
|
||||
}
|
||||
|
||||
let harvestAbort = false;
|
||||
|
||||
// DL Mode from the popup: harvest what's on screen right now, no queue involved.
|
||||
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
if (msg.type === 'startHarvest') {
|
||||
if (generationInProgress) { log('info', 'DL mode: busy with a task — stop Prompt Mode first.'); sendResponse({ ok: false }); return; }
|
||||
log('system', 'DL mode ON: downloading everything on screen (1K originals)…');
|
||||
executeHarvest({ id: 'manual_' + Date.now(), kind: 'harvest', category: 'harvest', manual: true });
|
||||
sendResponse({ ok: true });
|
||||
} else if (msg.type === 'stopHarvest') {
|
||||
harvestAbort = true;
|
||||
log('system', 'DL mode: stopping after the current tile…');
|
||||
sendResponse({ ok: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function executeHarvest(task) {
|
||||
generationInProgress = true;
|
||||
harvestAbort = false;
|
||||
chrome.storage.local.set({ harvestActive: true, status: 'working' });
|
||||
let got = 0, missed = 0, skipped = 0;
|
||||
try {
|
||||
await waitForAgentReady(30000);
|
||||
const stored = await new Promise(r => chrome.storage.local.get({ harvested_urls_v2: [] }, d => r(d.harvested_urls_v2)));
|
||||
const seen = new Set(stored); // CONFIRMED downloads — persisted, never re-fetched
|
||||
const missedThisPass = new Set(); // transient misses — in-memory ONLY, retried next run
|
||||
await bg({ type: 'harvestRouting', on: true });
|
||||
window.scrollTo(0, 0); await sleep(800);
|
||||
let idle = 0;
|
||||
while (idle < 3 && !harvestAbort && got + missed < 500) {
|
||||
const tile = [...document.querySelectorAll('a[href*="/edit/"]')]
|
||||
.find(t => { const k = t.href.split('#')[0]; return visible(t) && !seen.has(k) && !missedThisPass.has(k); });
|
||||
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(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++; missedThisPass.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(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 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 < 4 && !oneK; j++) {
|
||||
const c = center(dl);
|
||||
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 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: 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.
|
||||
const dlBefore = ((await bg({ type: 'harvestStats' })) || {}).count || 0;
|
||||
await bg({ type: 'hoverAt', ...center(oneK) }); await sleep(350);
|
||||
await bg({ type: 'clickAt', ...center(oneK) });
|
||||
let fired = false;
|
||||
for (let j = 0; j < 12 && !fired; j++) {
|
||||
await sleep(500);
|
||||
fired = (((await bg({ type: 'harvestStats' })) || {}).count || 0) > dlBefore;
|
||||
}
|
||||
if (fired) {
|
||||
// ONLY a confirmed download is persisted (never re-fetched). A transient miss
|
||||
// stays in missedThisPass — skipped this pass, RETRIED on the next DL run, so a
|
||||
// hover/menu race never permanently blacklists a never-downloaded asset.
|
||||
got++; seen.add(href);
|
||||
chrome.storage.local.set({ harvested_urls_v2: [...seen].slice(-20000) });
|
||||
if (got % 10 === 0) log('info', `harvest: ${got} downloaded so far…`);
|
||||
} else {
|
||||
missed++; missedThisPass.add(href); log('info', 'harvest: clicked 1K but no download event fired.');
|
||||
}
|
||||
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 ${harvestAbort ? 'stopped' : 'done'}: ${got} downloaded, ${missed} missed → flowrinse/harvest/ under the browser download dir.`);
|
||||
if (!task.manual) await reportCompletion(task.id, 'success', null, [`downloaded:${got}`, `missed:${missed}`]);
|
||||
} catch (err) {
|
||||
log('error', `Harvest pass failed after ${got}: ${err.message}`);
|
||||
if (!task.manual) await reportCompletion(task.id, 'failed', err.message, [`downloaded:${got}`]);
|
||||
} finally {
|
||||
await bg({ type: 'harvestRouting', on: false });
|
||||
harvestAbort = false;
|
||||
chrome.storage.local.set({ harvestActive: false, status: settings.isRunning ? 'running' : 'idle' });
|
||||
window.scrollTo(0, 0);
|
||||
generationInProgress = false;
|
||||
scheduleNext();
|
||||
}
|
||||
}
|
||||
|
||||
async function reportCompletion(id, status, error, assets) {
|
||||
// Terminal states clear the per-task markers; 'requeue'/'ratelimited' keep the task
|
||||
// pending AND keep the submitted-marker so the reload never re-generates it.
|
||||
if (status === 'success' || status === 'failed') {
|
||||
chrome.storage.local.remove(['submitted_' + id, 'requeue_' + id]);
|
||||
}
|
||||
try {
|
||||
await fetch(`${settings.serverUrl}/task-completed`, {
|
||||
method: 'POST',
|
||||
@ -319,10 +756,33 @@ async function reportCompletion(id, status, error, assets) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.local.set({ logs: [...r.logs, { level, text, timestamp: Date.now() }].slice(-300) });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -21,6 +21,13 @@
|
||||
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||
</dict>
|
||||
|
||||
<!-- launch.sh backgrounds the queue server, then exits. Without this key launchd
|
||||
SIGKILLs the whole process group on exit, taking the server with it — the
|
||||
"self-heal" then restarts a server that dies a second later, forever. The
|
||||
server you had running was a MANUAL ./launch.sh; the agent never kept one up. -->
|
||||
<key>AbandonProcessGroup</key>
|
||||
<true/>
|
||||
|
||||
<!-- self-healing: idempotent launch.sh every 10 min (nc check + tab reuse
|
||||
make repeat runs no-ops), so a dead server or closed tab comes back -->
|
||||
<key>RunAtLoad</key>
|
||||
|
||||
0
install.sh
Normal file → Executable file
0
install.sh
Normal file → Executable file
@ -32,10 +32,13 @@ fi
|
||||
|
||||
# 2. open or focus ONE Flow tab (reuses an existing one so it can't double-run).
|
||||
# Only `activate` (steal system focus) when opening fresh — on the 10-min launchd
|
||||
# tick an existing tab is re-selected inside Brave without yanking you out of
|
||||
# tick an existing tab is re-selected inside the browser without yanking you out of
|
||||
# whatever you're doing. Selecting the tab also keeps Memory Saver from discarding it.
|
||||
#
|
||||
# Must name the browser the EXTENSION is loaded into. ultra = Brave, m3ultra = Chrome.
|
||||
BROWSER="${FLOW_BROWSER:-Brave Browser}"
|
||||
osascript <<OSA
|
||||
tell application "Brave Browser"
|
||||
tell application "$BROWSER"
|
||||
set found to false
|
||||
repeat with w in windows
|
||||
set i to 0
|
||||
@ -54,4 +57,4 @@ tell application "Brave Browser"
|
||||
end if
|
||||
end tell
|
||||
OSA
|
||||
echo "Brave focused on Flow. With Auto-start ticked, the rinse begins on load."
|
||||
echo "$BROWSER focused on Flow. With Auto-start ticked, the rinse begins on load."
|
||||
|
||||
@ -82,13 +82,24 @@ def load_queue():
|
||||
return rows
|
||||
|
||||
def done_ids():
|
||||
"""Ids that must NOT be handed out again.
|
||||
|
||||
'failed' IS counted: a timeout may have burned credits invisibly, so nothing is retried
|
||||
automatically. To re-run a failed task, delete its line from results.jsonl (requeue.py).
|
||||
|
||||
'ratelimited'/'requeue' are skipped belt-and-braces: /task-completed already refuses to
|
||||
persist those rows (they stay PENDING by design), so they can't reach this file. This is
|
||||
defence in depth against someone appending a row by hand — not a bug fix.
|
||||
"""
|
||||
ids = set()
|
||||
if os.path.exists(RESULTS_FILE):
|
||||
with open(RESULTS_FILE) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
ids.add(json.loads(line).get("id"))
|
||||
row = json.loads(line)
|
||||
if row.get("status") != "ratelimited":
|
||||
ids.add(row.get("id"))
|
||||
return ids
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
@ -110,7 +121,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return self._send(404, {"error": "not found"})
|
||||
done = done_ids()
|
||||
for t in load_queue():
|
||||
if t["id"] not in done and t["id"] not in INFLIGHT:
|
||||
# in-flight entries expire after 15 min: a hard-refreshed tab that never
|
||||
# reported back must get its task re-served, not stranded
|
||||
if t["id"] not in done and (t["id"] not in INFLIGHT or time.time() - INFLIGHT[t["id"]] > 900):
|
||||
INFLIGHT[t["id"]] = time.time()
|
||||
remaining = sum(1 for x in load_queue() if x["id"] not in done)
|
||||
print(f"[SERVER] -> {t['id']} ({t.get('kind')}) | {remaining} left")
|
||||
@ -122,10 +135,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
rec = json.loads(self.rfile.read(n).decode() or "{}")
|
||||
|
||||
if self.path == "/task-completed":
|
||||
rec["ts"] = time.time()
|
||||
INFLIGHT.pop(rec.get("id"), None)
|
||||
with open(RESULTS_FILE, "a") as f:
|
||||
f.write(json.dumps(rec) + "\n")
|
||||
if rec.get("status") not in ("ratelimited", "requeue"): # both stay PENDING, retried after cooldown/reload
|
||||
rec["ts"] = time.time()
|
||||
with open(RESULTS_FILE, "a") as f:
|
||||
f.write(json.dumps(rec) + "\n")
|
||||
print(f"[SERVER] <- {rec.get('id')} {str(rec.get('status')).upper()} | {len(rec.get('assets') or [])} asset(s)")
|
||||
return self._send(200, {"ok": True})
|
||||
|
||||
@ -159,10 +173,17 @@ def selftest():
|
||||
print("selftest ok")
|
||||
|
||||
def run(port=8017):
|
||||
print(f"--- Flow queue on http://localhost:{port} ---")
|
||||
# ponytail: loopback ONLY. /save base64-writes to an attacker-supplied absolute `dest`
|
||||
# with no auth — bound to 0.0.0.0 that is arbitrary file write as this user (drop a
|
||||
# ~/Library/LaunchAgents plist → code execution) for anyone on the same wifi.
|
||||
# The extension only ever calls localhost. FLOW_BIND=<tailscale-ip> if a remote box
|
||||
# must enqueue — a tailnet IP is routed over utun, so the LAN still can't reach it.
|
||||
# Never set it back to "" / 0.0.0.0.
|
||||
host = os.environ.get("FLOW_BIND", "127.0.0.1")
|
||||
print(f"--- Flow queue on http://{host}:{port} ---")
|
||||
print(f"queue: {QUEUE_FILE if os.path.exists(QUEUE_FILE) else '(demo — no queue.jsonl)'}")
|
||||
print(f"pending: {sum(1 for t in load_queue() if t['id'] not in done_ids())}")
|
||||
HTTPServer(("", port), Handler).serve_forever()
|
||||
HTTPServer((host, port), Handler).serve_forever()
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
@ -32,9 +32,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Button -->
|
||||
<div class="actions">
|
||||
<button id="toggle-btn" class="btn btn-primary start-state">Start Automation</button>
|
||||
<!-- Action Buttons: Prompt Mode runs the queue; DL Mode grabs what's on screen -->
|
||||
<div class="actions" style="display:flex;gap:8px">
|
||||
<button id="toggle-btn" class="btn btn-primary start-state" style="flex:1">▶ Prompt Mode</button>
|
||||
<button id="dl-btn" class="btn btn-primary start-state" style="flex:1">⬇ DL Mode</button>
|
||||
</div>
|
||||
|
||||
<label class="form-group" style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
@ -45,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> <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>
|
||||
|
||||
44
popup.js
44
popup.js
@ -67,14 +67,32 @@ function renderLogs(logs) {
|
||||
// Update Toggle Button Style
|
||||
function updateToggleUI(isRunning) {
|
||||
if (isRunning) {
|
||||
toggleBtn.textContent = 'Stop Automation';
|
||||
toggleBtn.textContent = '⏹ Stop Prompt Mode';
|
||||
toggleBtn.className = 'btn btn-primary stop-state';
|
||||
} else {
|
||||
toggleBtn.textContent = 'Start Automation';
|
||||
toggleBtn.textContent = '▶ Prompt Mode';
|
||||
toggleBtn.className = 'btn btn-primary start-state';
|
||||
}
|
||||
}
|
||||
|
||||
// DL Mode — one-shot harvest of everything on screen (1K originals), click again to stop.
|
||||
const dlBtn = document.getElementById('dl-btn');
|
||||
function updateDlUI(active) {
|
||||
dlBtn.textContent = active ? '⏹ Stop DL' : '⬇ DL Mode';
|
||||
dlBtn.className = 'btn btn-primary ' + (active ? 'stop-state' : 'start-state');
|
||||
}
|
||||
dlBtn.addEventListener('click', () => {
|
||||
chrome.storage.local.get({ harvestActive: false }, (r) => {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
if (!tabs[0]) return;
|
||||
chrome.tabs.sendMessage(tabs[0].id, { type: r.harvestActive ? 'stopHarvest' : 'startHarvest' }, () => {
|
||||
if (chrome.runtime.lastError) addSystemLog('DL mode: no Flow tab responding — open the Flow project tab first.');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
chrome.storage.local.get({ harvestActive: false }, (r) => updateDlUI(r.harvestActive));
|
||||
|
||||
// Update Global Status indicator
|
||||
function updateStatusUI(status) {
|
||||
globalStatus.className = 'status-indicator';
|
||||
@ -111,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) => {
|
||||
@ -134,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);
|
||||
});
|
||||
@ -151,6 +186,9 @@ chrome.storage.onChanged.addListener((changes, area) => {
|
||||
if (changes.status) {
|
||||
updateStatusUI(changes.status.newValue);
|
||||
}
|
||||
if (changes.harvestActive) {
|
||||
updateDlUI(changes.harvestActive.newValue);
|
||||
}
|
||||
if (changes.logs) {
|
||||
// Re-render logs if they changed (e.g. from background content script)
|
||||
renderLogs(changes.logs.newValue);
|
||||
|
||||
83
requeue.py
Normal file
83
requeue.py
Normal file
@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Make finished tasks pending again by removing their rows from results.jsonl.
|
||||
|
||||
The queue server treats any id in results.jsonl as done (except 'ratelimited'), so a task
|
||||
that failed is never retried automatically — a timeout may have burned credits invisibly
|
||||
and we don't re-bill by accident. This is the deliberate, explicit way to retry.
|
||||
|
||||
python3 requeue.py --list # what's failed, grouped
|
||||
python3 requeue.py --dry cal79_ # what WOULD be requeued (prefix match)
|
||||
python3 requeue.py cal79_ # do it (writes a .bak first)
|
||||
python3 requeue.py --failed # every failed task
|
||||
python3 requeue.py cal79_disco cal79_tmi # exact ids
|
||||
python3 requeue.py --force cal79_throbbing # re-run a SUCCESS (it drew the wrong thing)
|
||||
|
||||
Successes are protected: re-running one spends credits for an asset you already have.
|
||||
--force is the deliberate override, for when Flow succeeded at the wrong picture.
|
||||
|
||||
Stop the queue server first, or it may rewrite the file under you.
|
||||
"""
|
||||
import collections
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
HERE = os.path.expanduser('~/Library/Application Support/flowrinse')
|
||||
RESULTS = os.path.join(HERE, 'results.jsonl')
|
||||
|
||||
|
||||
def rows():
|
||||
with open(RESULTS) as f:
|
||||
return [json.loads(l) for l in f if l.strip()]
|
||||
|
||||
|
||||
def main():
|
||||
args = [a for a in sys.argv[1:]]
|
||||
if not args:
|
||||
print(__doc__)
|
||||
return 1
|
||||
dry = '--dry' in args
|
||||
force = '--force' in args
|
||||
only_failed = '--failed' in args
|
||||
pats = [a for a in args if not a.startswith('--')]
|
||||
rs = rows()
|
||||
|
||||
if '--list' in args:
|
||||
c = collections.Counter((r['status'], r['id'].rsplit('_', 1)[0]) for r in rs)
|
||||
for (st, grp), n in sorted(c.items()):
|
||||
print(f'{st:<12} {grp:<24} {n}')
|
||||
return 0
|
||||
|
||||
def hit(r):
|
||||
if r['status'] == 'success' and not force:
|
||||
return False # never re-run a success; that's just burning credits
|
||||
if only_failed:
|
||||
return r['status'] == 'failed'
|
||||
return any(r['id'] == p or r['id'].startswith(p) for p in pats)
|
||||
|
||||
doomed = [r for r in rs if hit(r)]
|
||||
if not doomed:
|
||||
print('nothing matched (successes are never requeued — pass --force if Flow drew the wrong thing)')
|
||||
return 1
|
||||
if force and any(r['status'] == 'success' for r in doomed):
|
||||
print('--force: re-running SUCCESSES. This spends credits for assets you already have.')
|
||||
for r in doomed:
|
||||
print(f" requeue {r['id']:<24} {r['status']:<10} {str(r.get('error',''))[:46]}")
|
||||
print(f"\n{len(doomed)} task(s){' [DRY RUN]' if dry else ''}")
|
||||
if dry:
|
||||
return 0
|
||||
|
||||
shutil.copy2(RESULTS, RESULTS + '.bak')
|
||||
keep = [r for r in rs if r not in doomed]
|
||||
with open(RESULTS + '.tmp', 'w') as f:
|
||||
for r in keep:
|
||||
f.write(json.dumps(r) + '\n')
|
||||
os.replace(RESULTS + '.tmp', RESULTS) # a kill mid-write can't truncate the real file
|
||||
print(f'wrote {len(keep)} rows (backup: results.jsonl.bak). Restart the queue server; '
|
||||
'they are pending again.')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
91
test_prompt_echo.mjs
Normal file
91
test_prompt_echo.mjs
Normal file
@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env node
|
||||
// Regression test for promptEchoed(). Run: node test_prompt_echo.mjs
|
||||
//
|
||||
// 2026-07-08: promptEchoed matched prompt.slice(0,60). 13 of the 15 djsim_phonewall prompts
|
||||
// open with an identical 60 chars, so once Alien's prompt was echoed in the chat pane, every
|
||||
// later calendar looked "already submitted". The driver never typed them and sat out a 3-minute
|
||||
// timeout, thirteen times. This test pins the fix and documents the bug it replaced.
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const BANK = path.join(HERE, 'banks', 'djsim_phonewall.jsonl');
|
||||
const prompts = Object.fromEntries(fs.readFileSync(BANK, 'utf8').trim().split('\n')
|
||||
.map(l => { const r = JSON.parse(l); return [r.id, r.prompt]; }));
|
||||
|
||||
const makeDoc = (msgs) => ({ querySelectorAll: () => msgs.map(m => ({ textContent: m, _editor: false })) });
|
||||
const visible = () => true;
|
||||
|
||||
// The shipped implementation, kept in sync with content.js by hand (no bundler here).
|
||||
const normText = (s) => String(s).replace(/\s+/g, ' ').trim();
|
||||
function promptEchoed(prompt, document) {
|
||||
const full = normText(prompt);
|
||||
if (full.length < 12) return false;
|
||||
const head = full.slice(0, 100), tail = full.slice(-100);
|
||||
return [...document.querySelectorAll('div, p, span')].some(e => {
|
||||
if (!visible(e) || e._editor) return false;
|
||||
const t = normText(e.textContent || '');
|
||||
if (t.length > full.length + 500) return false; // that's a container, not the message
|
||||
return t.includes(full) || (t.includes(head) && t.includes(tail));
|
||||
});
|
||||
}
|
||||
|
||||
// What it replaced — kept only to assert the bug is real, and stays fixed.
|
||||
function promptEchoedBuggy(prompt, document) {
|
||||
const needle = String(prompt).slice(0, 60).trim();
|
||||
if (!needle) return false;
|
||||
return [...document.querySelectorAll('div, p, span')].some(e => visible(e) && !e._editor &&
|
||||
(e.textContent || '').includes(needle) && (e.textContent || '').length < needle.length + 500);
|
||||
}
|
||||
|
||||
let fails = 0;
|
||||
const check = (name, got, want) => {
|
||||
if (got !== want) { fails++; console.log(` FAIL ${name} (got ${got}, want ${want})`); }
|
||||
else console.log(` ok ${name}`);
|
||||
};
|
||||
|
||||
// The exact scene: skylab and alien have been sent; their prompts sit in the chat pane.
|
||||
const chat = makeDoc([prompts.cal79_skylab, prompts.cal79_alien,
|
||||
"I've created that 1979 Alien calendar illustration for you."]);
|
||||
|
||||
console.log('the bug is real (old impl mistakes a shared 60-char opening for a match):');
|
||||
check('old impl false-positives joydivision', promptEchoedBuggy(prompts.cal79_joydivision, chat), true);
|
||||
check('old impl false-positives afghanistan', promptEchoedBuggy(prompts.cal79_afghanistan, chat), true);
|
||||
|
||||
console.log('\nthe fix:');
|
||||
check('alien reads as echoed', promptEchoed(prompts.cal79_alien, chat), true);
|
||||
check('skylab reads as echoed', promptEchoed(prompts.cal79_skylab, chat), true);
|
||||
const unsent = Object.keys(prompts).filter(k => !['cal79_skylab', 'cal79_alien'].includes(k));
|
||||
const falsePos = unsent.filter(k => promptEchoed(prompts[k], chat));
|
||||
check(`all ${unsent.length} unsent prompts read as NOT echoed`, falsePos.length, 0);
|
||||
if (falsePos.length) console.log(' false positives:', falsePos);
|
||||
|
||||
console.log('\nit still works as the "it stuck" signal:');
|
||||
const chat2 = makeDoc([prompts.cal79_alien, prompts.cal79_disco]);
|
||||
check('disco echoed once present', promptEchoed(prompts.cal79_disco, chat2), true);
|
||||
check('tmi still not echoed', promptEchoed(prompts.cal79_tmi, chat2), false);
|
||||
|
||||
console.log('\nrobustness:');
|
||||
check('survives DOM whitespace reflow',
|
||||
promptEchoed(prompts.cal79_disco, makeDoc([prompts.cal79_disco.replace(/ /g, '\n ')])), true);
|
||||
check('a page-sized container does not count',
|
||||
promptEchoed(prompts.cal79_tmi, makeDoc([Object.values(prompts).join(' ') + ' '.repeat(2000)])), false);
|
||||
check('empty prompt is never echoed', promptEchoed('', chat), false);
|
||||
|
||||
// every bank must be self-distinguishable under the new rule
|
||||
console.log('\nevery bank:');
|
||||
for (const f of fs.readdirSync(path.join(HERE, 'banks')).filter(f => f.endsWith('.jsonl'))) {
|
||||
const ps = fs.readFileSync(path.join(HERE, 'banks', f), 'utf8').trim().split('\n')
|
||||
.map(l => JSON.parse(l).prompt);
|
||||
const doc = makeDoc(ps); // pretend the whole bank was sent
|
||||
const missed = ps.filter(p => !promptEchoed(p, doc)).length;
|
||||
// and each prompt must NOT match a chat containing only the OTHER prompts
|
||||
let cross = 0;
|
||||
ps.forEach((p, i) => { if (promptEchoed(p, makeDoc(ps.filter((_, j) => j !== i)))) cross++; });
|
||||
check(`${f}: ${ps.length} prompts, ${missed} missed, ${cross} cross-matched`, missed + cross, 0);
|
||||
}
|
||||
|
||||
console.log(fails ? `\n${fails} FAILURE(S)` : '\nALL GREEN');
|
||||
process.exit(fails ? 1 : 0);
|
||||
Loading…
Reference in New Issue
Block a user