- Rust core engine with HTTP ingestion gateway, hybrid search, and white-box resume matching - Python edge collectors supporting declarative TOML and specialized adapters - PostgreSQL schema migrations with pgvector embedding support - Hardened security controls: strict CSP, terminal sanitization, and input boundary validation - Multi-service container orchestration with dedicated non-root execution
339 lines
12 KiB
Python
339 lines
12 KiB
Python
import argparse
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
import fnmatch
|
|
import importlib.util
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import signal
|
|
import sys
|
|
import time
|
|
|
|
try:
|
|
import tomllib
|
|
except ModuleNotFoundError:
|
|
import tomli as tomllib
|
|
|
|
from common.declarative import load_declarative_collectors
|
|
from common.protocol import emit_payload
|
|
|
|
ROOT_DIR = Path(__file__).resolve().parent
|
|
if str(ROOT_DIR) not in sys.path:
|
|
sys.path.insert(0, str(ROOT_DIR))
|
|
|
|
running = True
|
|
|
|
|
|
def handle_signal(sig, frame):
|
|
global running
|
|
sys.stderr.write("\nReceived shutdown signal. Exiting runner...\n")
|
|
running = False
|
|
|
|
|
|
signal.signal(signal.SIGINT, handle_signal)
|
|
signal.signal(signal.SIGTERM, handle_signal)
|
|
|
|
|
|
def load_config() -> dict:
|
|
config_path = os.getenv("CONFIG_PATH", str(ROOT_DIR / "config.toml"))
|
|
cfg = {
|
|
"runner": {
|
|
"schedule_interval_hours": 6,
|
|
"concurrency": 3,
|
|
"domain_delay_seconds": 3.0,
|
|
"core_ingest_url": os.getenv("CORE_INGEST_URL", "http://core:8080/api/v1/ingest"),
|
|
},
|
|
"filter": {
|
|
"provinces": [],
|
|
"include": [],
|
|
"tags": [],
|
|
"exclude": [],
|
|
},
|
|
}
|
|
|
|
if Path(config_path).is_file():
|
|
try:
|
|
with open(config_path, "rb") as f:
|
|
loaded = tomllib.load(f)
|
|
if "runner" in loaded:
|
|
cfg["runner"].update(loaded["runner"])
|
|
if "filter" in loaded:
|
|
cfg["filter"].update(loaded["filter"])
|
|
except Exception as e:
|
|
sys.stderr.write(f"Warning: Failed to load {config_path}: {e}\n")
|
|
|
|
if "domain_concurrency" in cfg["runner"]:
|
|
cfg["runner"]["concurrency"] = cfg["runner"]["domain_concurrency"]
|
|
|
|
if os.getenv("CORE_INGEST_URL"):
|
|
cfg["runner"]["core_ingest_url"] = os.getenv("CORE_INGEST_URL")
|
|
if os.getenv("SCHEDULE_INTERVAL_HOURS"):
|
|
cfg["runner"]["schedule_interval_hours"] = int(os.getenv("SCHEDULE_INTERVAL_HOURS"))
|
|
if os.getenv("DOMAIN_CONCURRENCY"):
|
|
cfg["runner"]["concurrency"] = int(os.getenv("DOMAIN_CONCURRENCY"))
|
|
if os.getenv("DOMAIN_DELAY_SECONDS"):
|
|
cfg["runner"]["domain_delay_seconds"] = float(os.getenv("DOMAIN_DELAY_SECONDS"))
|
|
if os.getenv("COLLECTORS_PROVINCES"):
|
|
cfg["filter"]["provinces"] = [
|
|
p.strip() for p in os.getenv("COLLECTORS_PROVINCES").split(",") if p.strip()
|
|
]
|
|
if os.getenv("COLLECTORS_INCLUDE"):
|
|
cfg["filter"]["include"] = [
|
|
i.strip() for i in os.getenv("COLLECTORS_INCLUDE").split(",") if i.strip()
|
|
]
|
|
|
|
return cfg
|
|
|
|
|
|
def discover_collectors() -> list[dict]:
|
|
collectors = []
|
|
|
|
sources_dir = ROOT_DIR / "sources"
|
|
adapters_dir = ROOT_DIR / "adapters"
|
|
|
|
if sources_dir.is_dir():
|
|
for toml_file in sorted(sources_dir.rglob("*.toml")):
|
|
try:
|
|
for entry in load_declarative_collectors(toml_file, base_dir=ROOT_DIR):
|
|
collectors.append(entry)
|
|
except Exception as e:
|
|
sys.stderr.write(f"Error loading source {toml_file}: {e}\n")
|
|
|
|
if adapters_dir.is_dir():
|
|
for py_file in sorted(adapters_dir.rglob("*.py")):
|
|
if py_file.name == "__init__.py":
|
|
continue
|
|
|
|
rel_parts = py_file.relative_to(adapters_dir).with_suffix("").parts
|
|
module_name = "adapters." + ".".join(rel_parts)
|
|
try:
|
|
spec = importlib.util.spec_from_file_location(module_name, py_file)
|
|
if not spec or not spec.loader:
|
|
continue
|
|
mod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mod)
|
|
|
|
crawl_fn = getattr(mod, "crawl", None)
|
|
if not callable(crawl_fn):
|
|
continue
|
|
|
|
meta = getattr(mod, "META", None)
|
|
if not meta or not isinstance(meta, dict):
|
|
meta = {
|
|
"source_id": f"adapter.{'.'.join(rel_parts)}",
|
|
"name": py_file.stem,
|
|
"province": rel_parts[0] if len(rel_parts) > 1 else "",
|
|
"city": None,
|
|
"tags": [],
|
|
}
|
|
|
|
domain = meta.get("domain")
|
|
if not domain:
|
|
start_url = getattr(mod, "START_URL", None)
|
|
if start_url:
|
|
from urllib.parse import urlsplit
|
|
domain = urlsplit(start_url).netloc.lower()
|
|
else:
|
|
domain = meta.get("source_id", "adapter")
|
|
meta["domain"] = domain
|
|
|
|
collectors.append({
|
|
"meta": meta,
|
|
"crawl_fn": crawl_fn,
|
|
"file": str(py_file.relative_to(ROOT_DIR)),
|
|
"type": "py",
|
|
"domain": domain,
|
|
})
|
|
except Exception as e:
|
|
sys.stderr.write(f"Error loading adapter {py_file}: {e}\n")
|
|
|
|
return collectors
|
|
|
|
|
|
PROVINCE_ALIAS = {
|
|
"yn": ["yn", "云南", "云南省"],
|
|
"云南": ["yn", "云南", "云南省"],
|
|
"云南省": ["yn", "云南", "云南省"],
|
|
"sc": ["sc", "四川", "四川省"],
|
|
"四川": ["sc", "四川", "四川省"],
|
|
"四川省": ["sc", "四川", "四川省"],
|
|
}
|
|
|
|
|
|
def is_enabled(meta: dict, filter_cfg: dict) -> bool:
|
|
source_id = meta.get("source_id", "")
|
|
province = meta.get("province", "")
|
|
tags = set(meta.get("tags", []))
|
|
|
|
for pat in filter_cfg.get("exclude", []):
|
|
if fnmatch.fnmatch(source_id, pat):
|
|
return False
|
|
|
|
provinces = filter_cfg.get("provinces", [])
|
|
include = filter_cfg.get("include", [])
|
|
req_tags = filter_cfg.get("tags", [])
|
|
|
|
if not provinces and not include and not req_tags:
|
|
return True
|
|
|
|
if provinces:
|
|
for p in provinces:
|
|
aliases = PROVINCE_ALIAS.get(p.lower(), [p])
|
|
if province in aliases or source_id.startswith(f"{p}."):
|
|
return True
|
|
if any(fnmatch.fnmatch(source_id, pat) for pat in include):
|
|
return True
|
|
if any(tag in tags for tag in req_tags):
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def run_one(collector: dict, ingest_url: str):
|
|
meta = collector["meta"]
|
|
name = meta.get("name", meta.get("source_id"))
|
|
crawl_fn = collector["crawl_fn"]
|
|
|
|
try:
|
|
sys.stderr.write(f"[{meta['source_id']}] Starting: {name}...\n")
|
|
payload = crawl_fn(max_items=10)
|
|
emit_payload(payload, push_url=ingest_url)
|
|
sys.stderr.write(
|
|
f"[{meta['source_id']}] Completed: {len(payload.items)} items pushed.\n"
|
|
)
|
|
return True
|
|
except Exception as exc:
|
|
raw_err = str(exc)
|
|
sanitized_err = re.sub(r"([?&][\w-]+)=([^&\s]+)", r"\1=[redacted]", raw_err)
|
|
if len(sanitized_err) > 150:
|
|
sanitized_err = sanitized_err[:147] + "..."
|
|
sys.stderr.write(f"[{meta['source_id']}] Failed: {sanitized_err}\n")
|
|
try:
|
|
from common.protocol import CollectorMeta, IngestPayload
|
|
err_payload = IngestPayload(
|
|
source_id=meta["source_id"],
|
|
batch_id=f"err-{int(time.time())}",
|
|
collector=CollectorMeta(
|
|
name=name,
|
|
runtime="python/3.12",
|
|
error_message=sanitized_err,
|
|
),
|
|
items=[],
|
|
)
|
|
emit_payload(err_payload, push_url=ingest_url)
|
|
except Exception as e:
|
|
sys.stderr.write(f"[{meta['source_id']}] Failed to emit heartbeat: {e}\n")
|
|
return False
|
|
|
|
|
|
def run_domain_queue(
|
|
domain: str, collectors: list[dict], ingest_url: str, delay_sec: float
|
|
):
|
|
total = len(collectors)
|
|
sys.stderr.write(
|
|
f"[{domain}] Starting queue ({total} channel{'s' if total > 1 else ''})...\n"
|
|
)
|
|
for i, col in enumerate(collectors):
|
|
if not running:
|
|
break
|
|
if i > 0 and delay_sec > 0:
|
|
sys.stderr.write(
|
|
f"[{domain}] Polite pause ({delay_sec}s) before next channel...\n"
|
|
)
|
|
time.sleep(delay_sec)
|
|
run_one(col, ingest_url)
|
|
|
|
|
|
def run_cycle(cfg: dict, active_collectors: list[dict]):
|
|
ingest_url = cfg["runner"]["core_ingest_url"]
|
|
concurrency = max(1, int(cfg["runner"].get("concurrency", 3)))
|
|
delay_sec = max(0.0, float(cfg["runner"].get("domain_delay_seconds", 3.0)))
|
|
|
|
domain_groups: dict[str, list[dict]] = {}
|
|
for col in active_collectors:
|
|
domain = col.get("domain") or col.get("meta", {}).get("domain") or "default"
|
|
domain_groups.setdefault(domain, []).append(col)
|
|
|
|
sys.stderr.write(
|
|
f"\n=== Starting crawl cycle ({len(active_collectors)} active collectors across {len(domain_groups)} domains, domain concurrency: {concurrency}, intra-domain delay: {delay_sec}s) ===\n"
|
|
)
|
|
|
|
with ThreadPoolExecutor(max_workers=concurrency) as pool:
|
|
futures = {
|
|
pool.submit(run_domain_queue, domain, cols, ingest_url, delay_sec): domain
|
|
for domain, cols in domain_groups.items()
|
|
}
|
|
for future in as_completed(futures):
|
|
if not running:
|
|
pool.shutdown(wait=False, cancel_futures=True)
|
|
break
|
|
|
|
sys.stderr.write("=== Crawl cycle finished ===\n\n")
|
|
|
|
|
|
def wait_for_gateway(url: str, timeout: int = 30):
|
|
import httpx
|
|
base_url = url.split("/api/v1")[0]
|
|
health_url = f"{base_url}/health"
|
|
start = time.time()
|
|
while time.time() - start < timeout and running:
|
|
try:
|
|
r = httpx.get(health_url, timeout=2.0)
|
|
if r.status_code == 200:
|
|
sys.stderr.write(f"Core gateway at {health_url} is ready.\n")
|
|
return True
|
|
except Exception:
|
|
pass
|
|
time.sleep(1)
|
|
sys.stderr.write(f"Gateway health check reached timeout at {health_url}, proceeding.\n")
|
|
return False
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Bangwen Collector Runner")
|
|
parser.add_argument("--list", action="store_true", help="List all discovered and enabled collectors")
|
|
parser.add_argument("--once", action="store_true", help="Run one crawl cycle and exit")
|
|
args = parser.parse_args()
|
|
|
|
cfg = load_config()
|
|
all_collectors = discover_collectors()
|
|
active_collectors = [c for c in all_collectors if is_enabled(c["meta"], cfg["filter"])]
|
|
|
|
if args.list:
|
|
print(f"Total collectors discovered: {len(all_collectors)}")
|
|
print(f"Enabled collectors ({len(active_collectors)}):")
|
|
for c in all_collectors:
|
|
enabled = is_enabled(c["meta"], cfg["filter"])
|
|
mark = "[x]" if enabled else "[ ]"
|
|
meta = c["meta"]
|
|
mode = c.get("type", "py")
|
|
domain = c.get("domain", "")
|
|
print(f" {mark} {meta['source_id']:<20} ({meta.get('province')}) [{mode}] [{domain}] - {meta.get('name')} ({c['file']})")
|
|
return
|
|
|
|
interval_sec = cfg["runner"]["schedule_interval_hours"] * 3600
|
|
active_ids = [c["meta"]["source_id"] for c in active_collectors]
|
|
sys.stderr.write(
|
|
f"Bangwen Collector Runner started.\n"
|
|
f"Configured filter: provinces={cfg['filter']['provinces']}, include={cfg['filter']['include']}, tags={cfg['filter']['tags']}\n"
|
|
f"Active collectors ({len(active_collectors)}): {', '.join(active_ids)}\n"
|
|
f"Interval: {interval_sec}s, Concurrency: {cfg['runner']['concurrency']}\n\n"
|
|
)
|
|
|
|
wait_for_gateway(cfg["runner"]["core_ingest_url"])
|
|
run_cycle(cfg, active_collectors)
|
|
|
|
if args.once:
|
|
return
|
|
|
|
last_run = time.time()
|
|
while running:
|
|
time.sleep(1)
|
|
if time.time() - last_run >= interval_sec:
|
|
run_cycle(cfg, active_collectors)
|
|
last_run = time.time()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|