Add scrollToLoad and an absent variant of waitFor

scrollToLoad walks a progressively-loading list to the bottom before the steps
that act on its items run. Stopping is two-part: no new matches appeared AND the
container was already pinned to the bottom — counting alone stops early on a slow
fetch. Hitting the scroll cap is reported rather than passed off as done, so a
later step never works quietly on a partial list.

The scrolling element is usually not the window. Lists like this live in a div
with its own overflow, and scrolling the document does nothing at all, so the
step walks up from a matched item to the ancestor that actually scrolls —
overflow allows it and there is more content than fits — with containerSelector
to name one outright when the guess is wrong. Verified against a page whose
document also scrolls, which is the case that tells the two apart: it found the
inner div and pulled 12 items up to 60 in 7 scrolls.

waitFor gains `absent`, for waiting on something to go rather than arrive — a
modal closing after a reset. It only accepts a genuine "selector matched
nothing"; an unreachable extension looks the same from a distance and would
otherwise satisfy the gate for the wrong reason, sending the next iteration into
a page that still has the modal open.

The locate queue carries a free-form options blob now, so a new kind of request
stops meaning a new column each time.

Also fixes a missing comma in the reset flow that broke the build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-08-30 14:31:36 -05:00
co-authored by Claude Opus 5
parent 3ac9fe060f
commit 3cc7ddcc5c
8 changed files with 190 additions and 23 deletions
+92
View File
@@ -263,6 +263,71 @@ function waitForTabLoad(tabId, timeoutMs = 15000) {
});
}
/** Runs in the page. Scrolls until the list stops growing, then reports what it
* ended up with.
*
* The scrolling element is often NOT the window — lists like this usually live
* in a div with its own overflow, and scrolling the document does nothing at
* all. So walk up from a matched item looking for the ancestor that actually
* scrolls, and let the caller name one outright when the guess is wrong.
*/
async function pageScrollToLoad(selector, containerSelector, maxScrolls, settleMs, stableRounds) {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const count = () => document.querySelectorAll(selector).length;
function scroller() {
if (containerSelector) {
const named = document.querySelector(containerSelector);
if (!named) return { error: `No element matches container ${containerSelector}` };
return { el: named };
}
// An ancestor that can actually scroll: overflow allows it, and there is
// more content than fits.
let node = document.querySelector(selector)?.parentElement ?? null;
while (node && node !== document.body) {
const overflow = getComputedStyle(node).overflowY;
if (/(auto|scroll)/.test(overflow) && node.scrollHeight > node.clientHeight + 4) {
return { el: node };
}
node = node.parentElement;
}
return { el: document.scrollingElement || document.documentElement, isDocument: true };
}
const found = scroller();
if (found.error) return { error: found.error };
const el = found.el;
const before = count();
let last = before;
let stable = 0;
let scrolls = 0;
while (scrolls < maxScrolls) {
const wasAtBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 4;
el.scrollTop = el.scrollHeight;
scrolls++;
await sleep(settleMs);
const now = count();
// Nothing new AND we were already pinned to the bottom: the list is done
// growing, not merely slow.
stable = (now > last) ? 0 : stable + (wasAtBottom ? 1 : 0);
last = now;
if (stable >= stableRounds) break;
}
return {
before,
after: last,
scrolls,
exhausted: stable >= stableRounds,
container: found.isDocument ? 'document' : (el.className || el.tagName || 'element').toString().slice(0, 60),
url: location.href,
};
}
async function resolveLocateTab(cfg, request) {
// Every step carries its firm's pattern; the manifest hosts are the fallback
// for a bare request (the CLI's locate without --url).
@@ -323,6 +388,33 @@ async function serveLocateRequest(cfg) {
await chrome.tabs.update(tab.id, { active: true });
await new Promise((r) => setTimeout(r, 250)); // let the OS finish raising it
// A scroll request is a different operation on the same channel: no
// element is measured, the page is just walked to the bottom.
if (request.options && request.options.op === 'scrollToLoad') {
const [scrolled] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: pageScrollToLoad,
args: [
request.selector,
request.options.containerSelector || '',
Number(request.options.maxScrolls) || 25,
Number(request.options.settleMs) || 800,
Number(request.options.stableRounds) || 2,
],
});
const out = scrolled?.result;
if (!out) throw new Error('Scroll injection returned nothing');
if (out.error) throw new Error(out.error);
result = { ...out, tabId: tab.id, windowId: tab.windowId, browser: detectBrowser() };
await fetch(`${cfg.apiBase}/api/autobuyer/locate/result`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: request.id, result, error: null }),
});
return true;
}
// `complete` only means the document loaded — a React app still has to
// mount and paint. Retry briefly rather than declaring the element missing,
// with a longer budget when we just opened the page from cold.