diff --git a/.env.example b/.env.example index c234456..75c66a3 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/agent/scroller.py b/agent/scroller.py index 75931d2..7f9dd6a 100644 --- a/agent/scroller.py +++ b/agent/scroller.py @@ -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() diff --git a/ai/extractor.py b/ai/extractor.py index 09d5342..af70e62 100644 --- a/ai/extractor.py +++ b/ai/extractor.py @@ -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) diff --git a/config.py b/config.py index b19bdc1..0b3ea19 100644 --- a/config.py +++ b/config.py @@ -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 diff --git a/core/controller.py b/core/controller.py index 5eb7ed2..c5f2cf5 100644 --- a/core/controller.py +++ b/core/controller.py @@ -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() diff --git a/dashboard/support.html b/dashboard/support.html index 2b99839..2c56472 100644 --- a/dashboard/support.html +++ b/dashboard/support.html @@ -147,6 +147,9 @@ python main.py
ANTHROPIC_API_KEYSCREEN_LEADS_MODELSCREEN_LEADS_START_DELAYSCREEN_LEADS_AUTO_NEXTSCREEN_LEADS_NEXT_LOAD_PAUSESCREEN_LEADS_MAX_AUTO_NEXTSCREEN_LEADS_SCROLL_STEPSSCREEN_LEADS_SCROLL_AMOUNTSCREEN_LEADS_SCROLL_PAUSE