Add auto-advance: click Next, scroll to top, capture next page
After each capture the controller asks the vision model to locate a 'Next' control (returned as normalized screen coordinates), clicks it via OS input, scrolls to top, and continues the loop. Configurable via SCREEN_LEADS_AUTO_NEXT / _NEXT_LOAD_PAUSE / _MAX_AUTO_NEXT, with a per-run safety cap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,11 @@ ANTHROPIC_API_KEY=sk-ant-...
|
||||
# Capture tuning
|
||||
# SCREEN_LEADS_START_DELAY=5 # grace period to focus the browser (0 = off)
|
||||
# SCREEN_LEADS_SCROLL_STEPS=6
|
||||
|
||||
# Auto-advance through paginated results
|
||||
# SCREEN_LEADS_AUTO_NEXT=true # click a "Next" control after each capture
|
||||
# SCREEN_LEADS_NEXT_LOAD_PAUSE=2.5 # seconds to wait for the next page to load
|
||||
# SCREEN_LEADS_MAX_AUTO_NEXT=25 # safety cap on auto-advances per run
|
||||
# SCREEN_LEADS_SCROLL_AMOUNT=800
|
||||
# SCREEN_LEADS_SCROLL_PAUSE=0.8
|
||||
# SCREEN_LEADS_WATCH_INTERVAL=2.0
|
||||
|
||||
@@ -40,3 +40,18 @@ def scroll_down(amount: int = SCROLL_AMOUNT) -> None:
|
||||
pg = _pyautogui()
|
||||
pg.scroll(-abs(amount))
|
||||
time.sleep(SCROLL_PAUSE)
|
||||
|
||||
|
||||
def click_at_fraction(x_frac: float, y_frac: float) -> None:
|
||||
"""Click at a position given as fractions (0..1) of the logical screen.
|
||||
|
||||
Fractions are resolution- and Retina-independent: pyautogui reports the
|
||||
logical screen size, and the capture covers the same primary display, so a
|
||||
fraction of the screenshot maps directly to a fraction of the screen.
|
||||
"""
|
||||
pg = _pyautogui()
|
||||
w, h = pg.size()
|
||||
x = int(max(0.0, min(1.0, x_frac)) * w)
|
||||
y = int(max(0.0, min(1.0, y_frac)) * h)
|
||||
pg.moveTo(x, y, duration=0.2)
|
||||
pg.click()
|
||||
|
||||
@@ -88,3 +88,39 @@ def classify_page(screenshot_path: str) -> dict[str, Any]:
|
||||
output_config={"format": {"type": "json_schema", "schema": _CLASSIFY_SCHEMA}},
|
||||
)
|
||||
return _first_json(response)
|
||||
|
||||
|
||||
_NEXT_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"found": {"type": "boolean"},
|
||||
"x": {"type": "number"}, # 0..1 fraction of image width (button centre)
|
||||
"y": {"type": "number"}, # 0..1 fraction of image height
|
||||
"label": {"type": "string"},
|
||||
},
|
||||
"required": ["found", "x", "y", "label"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
_NEXT_PROMPT = (
|
||||
"Look at this screenshot for a control that advances to the NEXT item or page — "
|
||||
"for example a button or link labelled 'Next', 'Next result', 'See next profile', "
|
||||
"or a right-facing pagination arrow ('>' / '›' / '→'). Ignore 'Back'/'Previous' "
|
||||
"and unrelated arrows. If such a control is clearly visible, set found=true and give "
|
||||
"its CENTRE position as fractions of the image: x = left→right (0.0–1.0), "
|
||||
"y = top→bottom (0.0–1.0), plus its visible label. If none is visible, "
|
||||
"return found=false, x=0, y=0, label=\"\"."
|
||||
)
|
||||
|
||||
|
||||
def find_next_button(screenshot_path: str) -> dict[str, Any]:
|
||||
response = _client().messages.create(
|
||||
model=ANTHROPIC_MODEL,
|
||||
max_tokens=256,
|
||||
messages=[{
|
||||
"role": "user",
|
||||
"content": [_image_block(screenshot_path), {"type": "text", "text": _NEXT_PROMPT}],
|
||||
}],
|
||||
output_config={"format": {"type": "json_schema", "schema": _NEXT_SCHEMA}},
|
||||
)
|
||||
return _first_json(response)
|
||||
|
||||
@@ -45,6 +45,12 @@ SCROLL_AMOUNT = int(_env("SCREEN_LEADS_SCROLL_AMOUNT", "800")) # pyautogui scro
|
||||
SCROLL_PAUSE = float(_env("SCREEN_LEADS_SCROLL_PAUSE", "0.8")) # settle time (s)
|
||||
WATCH_INTERVAL = float(_env("SCREEN_LEADS_WATCH_INTERVAL", "2.0")) # loop tick (s)
|
||||
|
||||
# Auto-advance: after capturing a profile, look for a "Next" control, click it,
|
||||
# scroll back to the top, and keep going — walking paginated results hands-free.
|
||||
AUTO_NEXT = _env("SCREEN_LEADS_AUTO_NEXT", "true").lower() in ("1", "true", "yes", "on")
|
||||
NEXT_LOAD_PAUSE = float(_env("SCREEN_LEADS_NEXT_LOAD_PAUSE", "2.5")) # wait after click (s)
|
||||
MAX_AUTO_NEXT = int(_env("SCREEN_LEADS_MAX_AUTO_NEXT", "25")) # safety cap per run
|
||||
|
||||
# What to do when the front tab is not an enabled site's target page.
|
||||
# "stop" -> end the run (the requested behaviour)
|
||||
# "wait" -> pause and keep polling until a valid page appears
|
||||
|
||||
@@ -44,6 +44,7 @@ class CaptureController:
|
||||
self.last_event = ""
|
||||
self.captured_urls: set[str] = set()
|
||||
self.leads_captured = 0
|
||||
self._advances = 0
|
||||
self._run_id: Optional[int] = None
|
||||
|
||||
# --- introspection -------------------------------------------------------
|
||||
@@ -55,6 +56,8 @@ class CaptureController:
|
||||
"leads_captured": self.leads_captured,
|
||||
"captured_urls": len(self.captured_urls),
|
||||
"on_invalid_page": config.ON_INVALID_PAGE,
|
||||
"auto_next": config.AUTO_NEXT,
|
||||
"advances": self._advances,
|
||||
}
|
||||
|
||||
def _set(self, state: State, event: str = "") -> None:
|
||||
@@ -71,6 +74,7 @@ class CaptureController:
|
||||
self._pause.clear()
|
||||
self.reason = ""
|
||||
self.leads_captured = 0
|
||||
self._advances = 0
|
||||
self.captured_urls.clear()
|
||||
self._run_id = database.start_run()
|
||||
self._set(State.RUNNING, "started")
|
||||
@@ -149,6 +153,9 @@ class CaptureController:
|
||||
if url:
|
||||
self.captured_urls.add(url)
|
||||
self.last_event = f"saved: {lead.full_name or url}"
|
||||
# Auto-advance: click a Next control, scroll to top, capture again.
|
||||
if config.AUTO_NEXT and not self._stop.is_set() and self._advance_next():
|
||||
continue
|
||||
|
||||
time.sleep(config.WATCH_INTERVAL)
|
||||
|
||||
@@ -181,6 +188,27 @@ class CaptureController:
|
||||
database.upsert_lead(lead)
|
||||
return lead
|
||||
|
||||
def _advance_next(self) -> bool:
|
||||
"""Look for a 'Next' control on screen; if found, click it and scroll to
|
||||
top so the loop captures the next page. Returns True if it advanced."""
|
||||
if self._advances >= config.MAX_AUTO_NEXT:
|
||||
self.last_event = f"auto-next cap ({config.MAX_AUTO_NEXT}) reached"
|
||||
return False
|
||||
try:
|
||||
shot = screenshot.capture(prefix="next")
|
||||
info = extractor.find_next_button(shot)
|
||||
if not info.get("found"):
|
||||
return False
|
||||
self.last_event = f"clicking '{info.get('label') or 'Next'}' → next page"
|
||||
scroller.click_at_fraction(float(info.get("x", 0)), float(info.get("y", 0)))
|
||||
time.sleep(config.NEXT_LOAD_PAUSE)
|
||||
scroller.scroll_to_top()
|
||||
self._advances += 1
|
||||
return True
|
||||
except Exception as e:
|
||||
self.last_event = f"next-button step skipped: {e}"
|
||||
return False
|
||||
|
||||
|
||||
# module-level singleton shared by the API
|
||||
controller = CaptureController()
|
||||
|
||||
@@ -147,6 +147,9 @@ python main.py</code></pre>
|
||||
<tr><td><code>ANTHROPIC_API_KEY</code></td><td>—</td><td>Required. Claude API key.</td></tr>
|
||||
<tr><td><code>SCREEN_LEADS_MODEL</code></td><td>claude-opus-4-8</td><td>Vision model (use claude-sonnet-5 to cut cost).</td></tr>
|
||||
<tr><td><code>SCREEN_LEADS_START_DELAY</code></td><td>5</td><td>Grace seconds before first capture (0 = off).</td></tr>
|
||||
<tr><td><code>SCREEN_LEADS_AUTO_NEXT</code></td><td>true</td><td>After each capture, click a "Next" control, scroll to top, and continue.</td></tr>
|
||||
<tr><td><code>SCREEN_LEADS_NEXT_LOAD_PAUSE</code></td><td>2.5</td><td>Seconds to wait for the next page to load after clicking Next.</td></tr>
|
||||
<tr><td><code>SCREEN_LEADS_MAX_AUTO_NEXT</code></td><td>25</td><td>Safety cap on auto-advances per run.</td></tr>
|
||||
<tr><td><code>SCREEN_LEADS_SCROLL_STEPS</code></td><td>6</td><td>Screenshots per profile.</td></tr>
|
||||
<tr><td><code>SCREEN_LEADS_SCROLL_AMOUNT</code></td><td>800</td><td>Scroll distance per step.</td></tr>
|
||||
<tr><td><code>SCREEN_LEADS_SCROLL_PAUSE</code></td><td>0.8</td><td>Settle time (s) after each scroll.</td></tr>
|
||||
|
||||
Reference in New Issue
Block a user