stock-scraper / workers.py
sbasu2512's picture
Change it to only a scrapper
2a09d06
Raw
History Blame Contribute Delete
12 kB
import os
import signal
import time
from threading import Thread, Lock, Event
from logger import logger
from models import StockSources, MacroSource, NEWS, STOCK
from db_helpers import (
read_sources,
update_last_fetched,
record_failure,
store_raw_html,
get_sources_due_for_market,
update_source_table,
)
from config import (
SCRAPER_TICK_SECONDS,
SCRAPE_INTERVAL_MS,
MAX_CONSECUTIVE_FAILURES,
WORKER_ERROR_BACKOFF_SECONDS,
MARKET_CONFIG,
)
from utils import (
fetch_html,
seconds_until_next_market_close,
market_already_closed_today,
)
# =========================
# WORKER LIFECYCLE STATE
# =========================
workers_lock = Lock()
# Maps worker name → {"thread": Thread, "event": Event}
_workers: dict[str, dict] = {}
# =========================
# WORKER LOOP HARNESS
# =========================
# Each worker is split into a public function (entered by the Thread) and a
# private *_iteration that runs exactly one pass. The harness loops the
# iteration, catching unhandled exceptions so a surprise error in any one
# pass logs + backs off + continues, rather than killing the thread silently.
def _safe_worker_loop(name: str, iteration_fn, stop_event: Event):
logger.info(f"{name} loop started")
while not stop_event.is_set():
try:
iteration_fn(stop_event)
except Exception:
logger.exception(f"{name} crashed")
stop_event.wait(WORKER_ERROR_BACKOFF_SECONDS)
logger.info(f"{name} loop exiting (shutdown signaled)")
# =========================
# SCRAPER WORKER (news)
# =========================
def _scraping_iteration(stop_event: Event):
"""Scrape news sources and store their RAW HTML into the database."""
logger.info("scraper tick: reading source list")
sources = read_sources("news")
now_ms = int(time.time() * 1000)
logger.info(f"scraper tick: evaluating {len(sources)} sources")
skipped_disabled = 0
skipped_recent = 0
attempted = 0
for source in sources:
if stop_event.is_set():
logger.info("scraper tick: shutdown signaled mid-loop, returning")
return
if (source.failure_count or 0) >= MAX_CONSECUTIVE_FAILURES:
logger.info(
f"scraper: skipping disabled source {source.name} "
f"(failures={source.failure_count})"
)
skipped_disabled += 1
continue
if (
source.last_attempted_at is not None
and now_ms - source.last_attempted_at < SCRAPE_INTERVAL_MS
):
age_min = (now_ms - source.last_attempted_at) // 60000
logger.info(
f"scraper: skipping {source.name}, last attempt {age_min}min ago "
f"(interval {SCRAPE_INTERVAL_MS // 60000}min)"
)
skipped_recent += 1
continue
source_name = source.name
url = source.website
attempted += 1
logger.info(f"scraper: fetching {source_name} ({url})")
try:
html, error_reason = fetch_html(url)
if html is None:
logger.error(f"{source_name} fetch failed")
logger.error(f"due to {error_reason}")
record_failure(source.id, str(error_reason))
continue
raw_id = store_raw_html(source_name, html, NEWS)
update_last_fetched(source.id)
logger.info(f"Stored {source_name} -> {raw_id}")
except Exception as e:
logger.exception(f"{source_name} fetch failed")
record_failure(source.id, str(e))
logger.info(
f"scraper tick done: attempted={attempted} "
f"skipped_recent={skipped_recent} "
f"skipped_disabled={skipped_disabled}; "
f"sleeping {SCRAPER_TICK_SECONDS}s"
)
stop_event.wait(SCRAPER_TICK_SECONDS)
def scraping_worker(stop_event: Event):
_safe_worker_loop("scraping_worker", _scraping_iteration, stop_event)
# =========================
# SHARED FETCH HELPER (stock + macro)
# =========================
# Both the stock and macro workers fetch a pre-built URL and store the raw HTML;
# only the source label and raw_html.type differ. update_source_table stamps
# last_fetched_at so the row drops out of "due" for the rest of the market day.
def _fetch_and_store(row, model, source_label: str, type_: str):
"""Fetch row.url and store it as raw_html of `type_`. Stamps the source row."""
ident = getattr(row, "name", None) or getattr(row, "ticker", None) or row.id
country = getattr(row, "country", None)
url = row.url
if not url:
logger.error(f"{ident} has no pre-built url; skipping")
update_source_table(model, row.id, "missing url")
return
logger.info(f"fetching {ident} ({type_})")
try:
html, error = fetch_html(url)
if not html:
update_source_table(model, row.id, error)
logger.error(f"fetch failed for {ident}: {error}")
return
store_raw_html(source_label, html, type_, country=country)
update_source_table(model, row.id)
logger.info(f"{ident}: raw html stored ({type_})")
except Exception:
logger.exception(f"fetch failed for {ident}")
update_source_table(model, row.id, "fetch exception")
def _sleep_until_next_close(stop_event: Event, label: str):
"""Sleep until the soonest market close across all markets (weekend-aware)."""
sleep_s = min(seconds_until_next_market_close(m) for m in MARKET_CONFIG)
logger.info(
f"{label} tick done; sleeping {sleep_s / 3600:.1f}h until next market close"
)
stop_event.wait(sleep_s)
# =========================
# STOCK ENHANCER WORKER (fetch-only)
# =========================
# Once per market close (weekday), fetch every stock for that market that hasn't
# been fetched today, store its Google Finance HTML (type=stock), then sleep to
# the next close. All metric extraction happens on the LLM side.
def _stock_market_iteration(stop_event: Event):
for market in MARKET_CONFIG:
if not market_already_closed_today(market):
logger.info(f"stock: {market} not closed yet today (or weekend); skipping")
continue
due = get_sources_due_for_market(StockSources, market)
logger.info(f"stock: {market} closed; {len(due)} stock(s) due")
for stock in due:
if stop_event.is_set():
logger.info("stock tick: shutdown signaled mid-loop, returning")
return
_fetch_and_store(stock, StockSources, "Google Finance", STOCK)
_sleep_until_next_close(stop_event, "stock")
def stock_enrichment_worker(stop_event: Event):
_safe_worker_loop("stock_enrichment_worker", _stock_market_iteration, stop_event)
# =========================
# MACRO WORKER (fetch-only)
# =========================
# Same once-per-close cadence as the stock worker, but over MacroSource rows
# (FII/DII, VIX, commodities, indices, FX, yields). The raw_html.type is taken
# from each row so one worker covers every macro family.
def _macro_iteration(stop_event: Event):
for market in MARKET_CONFIG:
if not market_already_closed_today(market):
logger.info(f"macro: {market} not closed yet today (or weekend); skipping")
continue
due = get_sources_due_for_market(MacroSource, market)
logger.info(f"macro: {market} closed; {len(due)} series due")
for src in due:
if stop_event.is_set():
logger.info("macro tick: shutdown signaled mid-loop, returning")
return
_fetch_and_store(src, MacroSource, src.name, src.type)
_sleep_until_next_close(stop_event, "macro")
def macro_worker(stop_event: Event):
_safe_worker_loop("macro_worker", _macro_iteration, stop_event)
# =========================
# WORKER REGISTRY
# =========================
# Maps a stable name → the worker's entry-point function.
# Registered actions:
# START_SCRAPER / STOP_SCRAPER / RESTART_SCRAPER
# START_STOCK_ENHANCER / STOP_STOCK_ENHANCER / RESTART_STOCK_ENHANCER
# START_MACRO / STOP_MACRO / RESTART_MACRO
_WORKER_REGISTRY: dict[str, callable] = {
"scraper": scraping_worker,
"stock_enhancer": stock_enrichment_worker,
"macro": macro_worker,
}
# =========================
# INTERNAL HELPERS
# =========================
def _launch_worker(name: str):
"""Spin up a single named worker and register it in _workers.
Caller must hold workers_lock."""
if name not in _WORKER_REGISTRY:
raise ValueError(
f"Unknown worker: {name!r}. Valid names: {list(_WORKER_REGISTRY)}"
)
stop_event = Event()
thread = Thread(
target=_WORKER_REGISTRY[name],
args=(stop_event,),
name=name,
daemon=True,
)
_workers[name] = {"thread": thread, "event": stop_event}
thread.start()
logger.info(f"_launch_worker: '{name}' started (tid={thread.ident})")
def _signal_and_join(name: str, stop_event: Event, thread: Thread, timeout: float):
"""Signal a worker's event and wait for its thread to exit."""
logger.info(f"stop: signaling '{name}'")
stop_event.set()
thread.join(timeout=timeout)
if thread.is_alive():
logger.warning(f"stop: '{name}' did not exit within {timeout}s")
else:
logger.info(f"stop: '{name}' exited cleanly")
# =========================
# PUBLIC CONTROL API
# =========================
def start_workers(names: list[str] | None = None):
"""Start workers. If *names* is omitted, all registered workers are started.
Already-running workers are skipped with a warning."""
targets = names or list(_WORKER_REGISTRY.keys())
with workers_lock:
for name in targets:
entry = _workers.get(name)
if entry and entry["thread"].is_alive():
logger.warning(f"start_workers: '{name}' is already running — skipping")
continue
_launch_worker(name)
logger.info(f"start_workers: launched {targets}")
def stop_worker(name: str, timeout: float = 5.0):
"""Stop a single named worker and remove it from the registry."""
with workers_lock:
entry = _workers.get(name)
if not entry:
logger.warning(f"stop_worker: '{name}' is not running — nothing to do")
return
_signal_and_join(name, entry["event"], entry["thread"], timeout)
del _workers[name]
def stop_workers(names: list[str] | None = None, timeout: float = 5.0):
"""Stop workers. If *names* is omitted, all running workers are stopped."""
targets = names or list(_workers.keys())
for name in targets:
stop_worker(name, timeout=timeout)
logger.info(f"stop_workers: done — {targets}")
def restart_worker(name: str, timeout: float = 5.0):
"""Stop a worker (if running) then immediately restart it."""
with workers_lock:
entry = _workers.get(name)
if entry:
_signal_and_join(name, entry["event"], entry["thread"], timeout)
del _workers[name]
_launch_worker(name)
logger.info(f"restart_worker: '{name}' restarted")
def worker_status() -> dict[str, bool]:
"""Return a snapshot of {worker_name: is_alive} for every running worker."""
with workers_lock:
return {name: entry["thread"].is_alive() for name, entry in _workers.items()}
# =========================
# PROCESS SHUTDOWN
# =========================
def shutdown_server():
logger.info("shutdown_server: stopping all workers then sending SIGTERM")
stop_workers()
# SIGTERM is the semantically correct "please exit" signal; friendlier to
# process managers (systemd, supervisord, docker) than SIGINT.
os.kill(os.getpid(), signal.SIGTERM)