3737import random
3838import threading
3939import time
40+ from concurrent .futures import InvalidStateError
4041from dataclasses import dataclass , field
4142from datetime import datetime , timezone
4243from typing import Callable
5253
5354from .browser import new_context , profile_dir_for
5455from .cooldown import EngineCooldownTracker
56+ from .keepalive import DEFAULT_QUERIES , KeepAliveHeartbeat
5557from .saturation import SharedEngineState
56- from .scraper import scrape_topic
57- from .sources import SearchSource
58+ from .scraper import scrape_pages , scrape_topic
59+ from .sources import SearchSource , SearchVertical
60+ from .webqueue import WebSearchQueue
5861
5962logger = logging .getLogger (__name__ )
6063
64+ # When a web-search queue is attached, cap idle waits to this slice so a freshly
65+ # submitted (user-facing) web search is picked up promptly rather than slept
66+ # through. The deferred cross-process bridge can replace this poll with a proper
67+ # wakeup; in-process it's a cheap 1 Hz check while the worker is otherwise idle.
68+ _WEB_POLL_SLICE = 1.0
69+
6170
6271def _naive_utc_now () -> datetime :
6372 return datetime .now (timezone .utc ).replace (tzinfo = None )
@@ -232,18 +241,63 @@ def _active_topic_names() -> set[str]:
232241 return {topic .name for topic in db .get_topics ()}
233242
234243
244+ def _serve_oneoff (
245+ context ,
246+ source : SearchSource ,
247+ query : str ,
248+ vertical : SearchVertical ,
249+ * ,
250+ pacer : _Pacer ,
251+ stop_event : threading .Event ,
252+ cooldown : EngineCooldownTracker | None ,
253+ shared_state : SharedEngineState ,
254+ max_pages : int | None ,
255+ ) -> tuple [list [NewsEntry ], list [ScraperLog ]]:
256+ """Pace, then run one ad-hoc query on the engine's live context for a
257+ non-tracked vertical: an on-demand WEB search, or a NEWS keep-alive warm-up.
258+
259+ Shares the engine's pace floor and feeds any block signal into the same
260+ cooldown tracker as a normal scrape (these requests ride one budget), but
261+ deliberately does NOT touch the metrics window — they aren't tracked-topic
262+ content. Returns the parsed entries and the per-page logs.
263+ """
264+ pacer .wait (stop_event )
265+ if stop_event .is_set ():
266+ return [], []
267+ page = context .new_page ()
268+ try :
269+ entries , logs = scrape_pages (
270+ page , source , query , vertical = vertical , max_result_pages = max_pages
271+ )
272+ finally :
273+ page .close ()
274+ if cooldown is not None :
275+ cooldown .record (source .name , logs )
276+ for snap in cooldown .snapshot ():
277+ shared_state .update (snap )
278+ return entries , logs
279+
280+
235281def run_engine_worker (
236282 source : SearchSource ,
237283 profile : FingerprintProfile ,
238284 proxy : dict | None ,
239285 shared_state : SharedEngineState ,
240286 stop_event : threading .Event ,
287+ web_queue : WebSearchQueue | None = None ,
241288) -> None :
242289 """Run one engine's scheduler loop until ``stop_event`` is set.
243290
244291 Owns its own Playwright instance and browser context (so the sync API stays
245292 single-threaded per worker) and its own cooldown tracker and topic schedule
246293 (single writer, so no locking needed around them).
294+
295+ The one context services three kinds of work on one shared pace+cooldown
296+ budget, in priority order: an on-demand WEB search from ``web_queue``
297+ (user-facing, preempts news) → a due news topic → an idle NEWS keep-alive
298+ warm-up. Web search and keep-alive are off unless wired: pass a ``web_queue``
299+ to enable web search, and set ``scraper.keepalive.enabled`` to fire the
300+ heartbeat.
247301 """
248302 interval = scraper_config .scrape_interval
249303 jitter = scraper_config .pacing_jitter_ratio
@@ -258,9 +312,20 @@ def run_engine_worker(
258312 if scraper_config .cooldown_enabled
259313 else None
260314 )
315+ heartbeat = (
316+ KeepAliveHeartbeat (
317+ interval = scraper_config .keepalive_interval_seconds ,
318+ jitter_ratio = scraper_config .keepalive_jitter_ratio ,
319+ queries = scraper_config .keepalive_queries or DEFAULT_QUERIES ,
320+ )
321+ if scraper_config .keepalive_enabled
322+ else None
323+ )
261324 logger .info (
262325 f"[{ source .name } ] worker starting (topic interval { interval } s, pace floor "
263- f"{ min_interval :.1f} s/request, cooldown { 'on' if cooldown else 'off' } )"
326+ f"{ min_interval :.1f} s/request, cooldown { 'on' if cooldown else 'off' } , "
327+ f"keep-alive { 'on' if heartbeat else 'off' } , "
328+ f"web search { 'on' if web_queue is not None else 'off' } )"
264329 )
265330
266331 try :
@@ -294,49 +359,122 @@ def run_engine_worker(
294359 next_tick = time .monotonic () + interval
295360
296361 # Engine-level cooldown gate: while benched, schedule nothing —
297- # sleep until the backoff window allows a probe (or the next tick).
362+ # sleep until the backoff window allows a probe (or the next
363+ # tick). This also defers web/keep-alive: a benched session would
364+ # only CAPTCHA, and (once wired) the dispatcher routes the web
365+ # request to a healthy engine instead.
298366 if cooldown is not None and cooldown .decide (source .name ) == "skip" :
299367 wait = min (
300368 cooldown .remaining (source .name ), next_tick - time .monotonic ()
301369 )
302370 stop_event .wait (max (wait , 0.0 ))
303371 continue
304372
305- topic , sleep_for = schedule .next_due ()
306- if topic is None :
307- # Nothing due: wait for the head to come due, but never past
308- # the next housekeeping tick so flush/reconcile stay on time.
309- bound = next_tick - time .monotonic ()
310- wait = bound if sleep_for is None else min (sleep_for , bound )
311- stop_event .wait (max (wait , 0.0 ))
373+ # Pick exactly one action this turn, in priority order, then
374+ # converge on the shared post-scrape (recycle) handling. The
375+ # idle branch is the only one that sleeps and skips it.
376+ job = web_queue .poll () if web_queue is not None else None
377+ topic , sleep_for = (None , None )
378+ if job is None :
379+ topic , sleep_for = schedule .next_due ()
380+
381+ if job is not None :
382+ # Priority 1 — on-demand web search preempts news. Serve one
383+ # queued job per turn (FCFS) and hand its results back.
384+ entries , err = [], None
385+ try :
386+ entries , _logs = _serve_oneoff (
387+ context ,
388+ source ,
389+ job .query ,
390+ SearchVertical .WEB ,
391+ pacer = pacer ,
392+ stop_event = stop_event ,
393+ cooldown = cooldown ,
394+ shared_state = shared_state ,
395+ max_pages = scraper_config .max_pages ,
396+ )
397+ except Exception as e :
398+ err = e
399+ logger .exception (
400+ "[%s] web search '%s' failed" , source .name , job .query
401+ )
402+ try : # never leave the caller's future hung
403+ if err is not None :
404+ job .future .set_exception (err )
405+ else :
406+ job .future .set_result (entries )
407+ except InvalidStateError :
408+ pass # caller already cancelled/timed out
409+ if heartbeat is not None :
410+ heartbeat .record_activity ()
411+ scrapes_since_recycle += 1
412+
413+ elif topic is not None :
414+ # Priority 2 — a due news topic.
415+ pacer .wait (stop_event )
416+ if stop_event .is_set ():
417+ break
418+ try :
419+ entries , logs = scrape_topic (
420+ context .new_page ,
421+ [source ],
422+ topic ,
423+ strategy = "all" ,
424+ max_result_pages = scraper_config .max_pages ,
425+ cooldown = cooldown ,
426+ )
427+ window .add (entries , logs )
428+ except Exception as e :
429+ window .error = f"{ type (e ).__name__ } : { e } "
430+ window .success = False
431+ logger .exception (
432+ "[%s] scrape of '%s' failed" , source .name , topic
433+ )
434+ finally :
435+ schedule .reschedule (topic )
436+ if cooldown is not None :
437+ for snap in cooldown .snapshot ():
438+ shared_state .update (snap )
439+ if heartbeat is not None :
440+ heartbeat .record_activity ()
441+ scrapes_since_recycle += 1
442+
443+ elif heartbeat is not None and heartbeat .due ():
444+ # Priority 3 — nothing due; fire an idle keep-alive warm-up.
445+ query = heartbeat .next_query ()
446+ logger .info (f"[{ source .name } ] keep-alive warm-up: '{ query } '" )
447+ _serve_oneoff (
448+ context ,
449+ source ,
450+ query ,
451+ SearchVertical .NEWS ,
452+ pacer = pacer ,
453+ stop_event = stop_event ,
454+ cooldown = cooldown ,
455+ shared_state = shared_state ,
456+ max_pages = 1 , # one page is enough to warm the session
457+ )
458+ heartbeat .record_activity ()
459+ scrapes_since_recycle += 1
460+
461+ else :
462+ # Idle: wait for the head topic to come due, but never past
463+ # the next housekeeping tick, the next keep-alive, or (with
464+ # web search enabled) a short web-poll slice.
465+ waits = [next_tick - time .monotonic ()]
466+ if sleep_for is not None :
467+ waits .append (sleep_for )
468+ if heartbeat is not None :
469+ waits .append (heartbeat .seconds_until_due ())
470+ if web_queue is not None :
471+ waits .append (_WEB_POLL_SLICE )
472+ stop_event .wait (max (min (waits ), 0.0 ))
312473 continue
313474
314- pacer .wait (stop_event )
315475 if stop_event .is_set ():
316476 break
317477
318- try :
319- entries , logs = scrape_topic (
320- context .new_page ,
321- [source ],
322- topic ,
323- strategy = "all" ,
324- max_result_pages = scraper_config .max_pages ,
325- cooldown = cooldown ,
326- )
327- window .add (entries , logs )
328- except Exception as e :
329- window .error = f"{ type (e ).__name__ } : { e } "
330- window .success = False
331- logger .exception ("[%s] scrape of '%s' failed" , source .name , topic )
332- finally :
333- schedule .reschedule (topic )
334-
335- if cooldown is not None :
336- for snap in cooldown .snapshot ():
337- shared_state .update (snap )
338-
339- scrapes_since_recycle += 1
340478 if scrapes_since_recycle >= scraper_config .browser_recycle_cycles :
341479 logger .info (
342480 f"[{ source .name } ] recycling context after "
0 commit comments