"""발행을 **밖에서 알아채서** 알린다. solution/backend/.venv/bin/python geo/scripts/watch.py (계속 돈다) solution/backend/.venv/bin/python geo/scripts/watch.py --once (한 번만) solution/backend/.venv/bin/python geo/scripts/watch.py --interval 300 ★ **어떻게 알아채나 — 루트 사이트맵의 `` 를 본다.** 지난번에 본 것과 달라진 사이트만 통보한다. 우리 내부(DB·잡 큐·볼륨)를 들여다보지 않는다 — **크롤러가 발행을 알아채는 방식과 같다.** 그래서 `solution` 을 한 줄도 고치지 않고 끼어들 수 있고, "밖에서 본다" 는 이 모듈의 성격도 유지된다. ★ 대가: **즉시가 아니다.** 다음 확인 때 알린다(기본 5분). 프리렌더도 2초 폴링으로 도니 같은 종류의 지연이고, 색인은 어차피 분 단위가 아니다. 더 빨라야 하면 `site-out` 볼륨의 `payloads/.status/` 를 감시하는 방법이 있는데, 그러면 내부 파일 구조에 묶여 "밖에서 본다" 가 깨진다. 그 값이 지금은 없다고 봤다. ★ 첫 실행은 **전부 새 것으로 보인다.** 그대로 두면 사이트 1,000개를 한꺼번에 통보한다 — `--seed` 로 "지금 상태를 이미 아는 것으로" 기록만 하고 넘어갈 수 있다. """ import argparse import asyncio import os import re import sys from datetime import datetime, timezone _ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) for _p in (_ROOT, os.path.join(_ROOT, "solution", "backend")): if _p not in sys.path: sys.path.insert(0, _p) os.environ.setdefault("APP_ENV", "local") import httpx # noqa: E402 from geo import state # noqa: E402 from geo.naver._http import TIMEOUT_SEC, get # noqa: E402 from services.site_payload import publish_origin # noqa: E402 sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from postflight import run as postflight_run # noqa: E402 STATE_NAME = "seen-sitemap" DEFAULT_INTERVAL = 300.0 # `……` 한 덩이씩. lastmod 는 없을 수 있다. _URL_BLOCK = re.compile(r"(.*?)", re.S) _LOC = re.compile(r"\s*([^<\s]+)\s*") _LASTMOD = re.compile(r"\s*([^<\s]+)\s*") async def snapshot(client: httpx.AsyncClient, origin: str) -> dict[str, str]: """`{slug: lastmod}`. lastmod 가 없으면 loc 자체를 값으로 둔다(있고 없고만 본다).""" res = await get(client, origin.rstrip("/") + "/sitemap.xml") if res is None or res.status_code != 200: return {} out: dict[str, str] = {} for block in _URL_BLOCK.findall(res.text): loc_m = _LOC.search(block) if not loc_m or "/s/" not in loc_m.group(1): continue slug = loc_m.group(1).split("/s/", 1)[1].split("/", 1)[0].split("?")[0] if not slug: continue mod = _LASTMOD.search(block) out[slug] = mod.group(1) if mod else loc_m.group(1) return out def changed(previous: dict, current: dict[str, str]) -> list[str]: """새로 생겼거나 lastmod 가 달라진 slug.""" return sorted(s for s, mod in current.items() if previous.get(s) != mod) async def tick(origin: str, *, seed: bool = False, verbose: bool = True) -> list[str]: seen = state.load(STATE_NAME) async with httpx.AsyncClient(timeout=TIMEOUT_SEC, follow_redirects=True) as client: current = await snapshot(client, origin) if not current: if verbose: print(f"[{_now()}] 사이트맵을 읽지 못했다 — 다음 차례에 다시 본다") return [] todo = changed(seen, current) if seed: state.save(STATE_NAME, current) if verbose: print(f"[{_now()}] 현재 {len(current)}개를 '이미 아는 것'으로 기록했다 (통보하지 않음)") return [] if not todo: if verbose: print(f"[{_now()}] 바뀐 것 없음 (사이트 {len(current)}개)") return [] if verbose: print(f"[{_now()}] 바뀐 사이트 {len(todo)}개 → 통보: {', '.join(todo[:10])}" + (" …" if len(todo) > 10 else "")) results = await postflight_run(origin, todo) # ★ 통보에 성공한 것만 '봤다' 고 기록한다. 실패를 기록하면 영영 다시 안 보낸다. for r in results: if r.get("ok") and r.get("slug") in current: seen[r["slug"]] = current[r["slug"]] if verbose: mark = " ✓" if r.get("ok") else " ✗" detail = r.get("error") or f"URL {len(r.get('urls') or [])}개" print(f"{mark} {r.get('slug')} — {detail}") state.save(STATE_NAME, seen) return [r["slug"] for r in results if r.get("ok")] def _now() -> str: return datetime.now(timezone.utc).astimezone().strftime("%H:%M:%S") async def main() -> int: parser = argparse.ArgumentParser(description="발행을 알아채서 알린다") parser.add_argument("--origin", default=None) parser.add_argument("--interval", type=float, default=DEFAULT_INTERVAL, help="초 (기본 300)") parser.add_argument("--once", action="store_true", help="한 번만 보고 끝낸다") parser.add_argument("--seed", action="store_true", help="지금 상태를 '이미 아는 것'으로 기록만 한다 — 첫 실행의 대량 통보를 막는다") args = parser.parse_args() origin = args.origin or publish_origin() print(f"[watch] {origin} · {args.interval:.0f}초마다") if args.once or args.seed: await tick(origin, seed=args.seed) return 0 while True: try: await tick(origin) except Exception as ex: # 한 번 실패로 감시가 멈추면 안 된다 print(f"[{_now()}] 예외를 삼킨다 — {type(ex).__name__}: {ex}") await asyncio.sleep(args.interval) if __name__ == "__main__": try: sys.exit(asyncio.run(main())) except KeyboardInterrupt: print("\n[watch] 종료")