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
|
# Capture tuning
|
||||||
# SCREEN_LEADS_START_DELAY=5 # grace period to focus the browser (0 = off)
|
# SCREEN_LEADS_START_DELAY=5 # grace period to focus the browser (0 = off)
|
||||||
# SCREEN_LEADS_SCROLL_STEPS=6
|
# 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_AMOUNT=800
|
||||||
# SCREEN_LEADS_SCROLL_PAUSE=0.8
|
# SCREEN_LEADS_SCROLL_PAUSE=0.8
|
||||||
# SCREEN_LEADS_WATCH_INTERVAL=2.0
|
# SCREEN_LEADS_WATCH_INTERVAL=2.0
|
||||||
|
|||||||
@@ -40,3 +40,18 @@ def scroll_down(amount: int = SCROLL_AMOUNT) -> None:
|
|||||||
pg = _pyautogui()
|
pg = _pyautogui()
|
||||||
pg.scroll(-abs(amount))
|
pg.scroll(-abs(amount))
|
||||||
time.sleep(SCROLL_PAUSE)
|
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}},
|
output_config={"format": {"type": "json_schema", "schema": _CLASSIFY_SCHEMA}},
|
||||||
)
|
)
|
||||||
return _first_json(response)
|
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)
|
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)
|
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.
|
# What to do when the front tab is not an enabled site's target page.
|
||||||
# "stop" -> end the run (the requested behaviour)
|
# "stop" -> end the run (the requested behaviour)
|
||||||
# "wait" -> pause and keep polling until a valid page appears
|
# "wait" -> pause and keep polling until a valid page appears
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ class CaptureController:
|
|||||||
self.last_event = ""
|
self.last_event = ""
|
||||||
self.captured_urls: set[str] = set()
|
self.captured_urls: set[str] = set()
|
||||||
self.leads_captured = 0
|
self.leads_captured = 0
|
||||||
|
self._advances = 0
|
||||||
self._run_id: Optional[int] = None
|
self._run_id: Optional[int] = None
|
||||||
|
|
||||||
# --- introspection -------------------------------------------------------
|
# --- introspection -------------------------------------------------------
|
||||||
@@ -55,6 +56,8 @@ class CaptureController:
|
|||||||
"leads_captured": self.leads_captured,
|
"leads_captured": self.leads_captured,
|
||||||
"captured_urls": len(self.captured_urls),
|
"captured_urls": len(self.captured_urls),
|
||||||
"on_invalid_page": config.ON_INVALID_PAGE,
|
"on_invalid_page": config.ON_INVALID_PAGE,
|
||||||
|
"auto_next": config.AUTO_NEXT,
|
||||||
|
"advances": self._advances,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _set(self, state: State, event: str = "") -> None:
|
def _set(self, state: State, event: str = "") -> None:
|
||||||
@@ -71,6 +74,7 @@ class CaptureController:
|
|||||||
self._pause.clear()
|
self._pause.clear()
|
||||||
self.reason = ""
|
self.reason = ""
|
||||||
self.leads_captured = 0
|
self.leads_captured = 0
|
||||||
|
self._advances = 0
|
||||||
self.captured_urls.clear()
|
self.captured_urls.clear()
|
||||||
self._run_id = database.start_run()
|
self._run_id = database.start_run()
|
||||||
self._set(State.RUNNING, "started")
|
self._set(State.RUNNING, "started")
|
||||||
@@ -149,6 +153,9 @@ class CaptureController:
|
|||||||
if url:
|
if url:
|
||||||
self.captured_urls.add(url)
|
self.captured_urls.add(url)
|
||||||
self.last_event = f"saved: {lead.full_name or 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)
|
time.sleep(config.WATCH_INTERVAL)
|
||||||
|
|
||||||
@@ -181,6 +188,27 @@ class CaptureController:
|
|||||||
database.upsert_lead(lead)
|
database.upsert_lead(lead)
|
||||||
return 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
|
# module-level singleton shared by the API
|
||||||
controller = CaptureController()
|
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>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_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_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_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_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>
|
<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