여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다. - ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제 - 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다 - eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다 파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이). site·frontend·admin tsc, site vitest 105 passed Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
130 lines
5.1 KiB
Python
130 lines
5.1 KiB
Python
"""발행 감지 → 사이트맵 제출 → 색인 조회 → 알림."""
|
|
import asyncio
|
|
import xml.etree.ElementTree as ET
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import httpx
|
|
|
|
from common.database.db_session_manager import DB_SESSION_MNG
|
|
from common.enums import DBType
|
|
from common.logger import LOG
|
|
from crud import search_console_crud as store
|
|
from services import site_payload
|
|
from services.search_console_alerts import alert_reason, send_alert
|
|
from services.search_console_client import SearchConsoleClient, SearchConsoleError
|
|
from services.search_console_settings import load_settings, belongs_to_property
|
|
|
|
|
|
async def run_scheduled_check():
|
|
try:
|
|
settings = load_settings()
|
|
if settings is None:
|
|
return
|
|
await DB_SESSION_MNG.execute_lambda_write(
|
|
DBType.MAIN.value, lambda session: run_batch(session, settings),
|
|
)
|
|
except Exception:
|
|
# 크론 실패가 API/발행에 전파되지 않고 다음 주기에 재시도된다.
|
|
LOG.w("[search-console] BATCH_FAILED — 설정 및 DB 마이그레이션 확인 필요")
|
|
|
|
|
|
async def run_batch(session, settings):
|
|
if not await store.lock_batch(session):
|
|
return
|
|
if not belongs_to_property(site_payload.publish_origin() + "/sitemap.xml", settings.property_url):
|
|
raise SearchConsoleError("PROPERTY_MISMATCH")
|
|
await register_publications(session, settings)
|
|
rows = await store.due_sites(session, settings.property_url)
|
|
if not rows:
|
|
return
|
|
async with SearchConsoleClient(settings.credentials_file) as client:
|
|
submitted = set()
|
|
for row in rows:
|
|
await check_site(client, row, settings, submitted)
|
|
await session.flush()
|
|
|
|
|
|
async def register_publications(session, settings):
|
|
for site, place, version in await store.new_publications(session, settings.property_url):
|
|
slug = site_payload.publish_slug(place, site)
|
|
page_url = f"{site_payload.publish_origin()}/s/{slug}"
|
|
if not belongs_to_property(page_url, settings.property_url):
|
|
LOG.w("[search-console] PROPERTY_MISMATCH")
|
|
continue
|
|
await store.register(session, {
|
|
"site_id": site.site_id, "site_version_id": version.site_version_id,
|
|
"property_url": settings.property_url, "page_url": page_url,
|
|
"published_at": site.published_at,
|
|
})
|
|
|
|
|
|
async def read_sitemap(sitemap_url: str) -> set[str]:
|
|
try:
|
|
async with httpx.AsyncClient(timeout=20, follow_redirects=False) as client:
|
|
async with client.stream("GET", sitemap_url) as response:
|
|
response.raise_for_status()
|
|
body = bytearray()
|
|
async for chunk in response.aiter_bytes():
|
|
body.extend(chunk)
|
|
if len(body) > 5_000_000:
|
|
raise SearchConsoleError("SITEMAP_TOO_LARGE")
|
|
root = ET.fromstring(body)
|
|
return {(item.text or "").strip() for item in root.iter(
|
|
"{http://www.sitemaps.org/schemas/sitemap/0.9}loc"
|
|
)}
|
|
except (httpx.HTTPError, ET.ParseError):
|
|
raise SearchConsoleError("SITEMAP_UNAVAILABLE") from None
|
|
|
|
|
|
async def submit_sitemap(client, row, submitted: set):
|
|
if row.sitemap_submitted_at:
|
|
return
|
|
sitemap_url = site_payload.publish_origin() + "/sitemap.xml"
|
|
# 디스크가 아니라 실제 공개 URL을 확인한다.
|
|
urls = await read_sitemap(sitemap_url)
|
|
if row.page_url not in urls:
|
|
raise SearchConsoleError("URL_NOT_IN_SITEMAP")
|
|
if sitemap_url not in submitted:
|
|
await client.submit_sitemap(row.property_url, sitemap_url)
|
|
submitted.add(sitemap_url)
|
|
row.sitemap_submitted_at = datetime.now(timezone.utc)
|
|
|
|
|
|
def record_inspection(row, inspection: dict, now):
|
|
row.inspection = inspection
|
|
row.inspected_at = now
|
|
row.error_code = None
|
|
row.failures = 0
|
|
if inspection.get("verdict") == "PASS":
|
|
row.first_indexed_at = row.first_indexed_at or now
|
|
row.next_check_at = now + timedelta(days=1)
|
|
|
|
|
|
def record_error(row, code: str, now):
|
|
row.error_code = code
|
|
row.failures = (row.failures or 0) + 1
|
|
hours = min(24, 2 ** min(row.failures - 1, 5))
|
|
row.next_check_at = now + timedelta(hours=hours)
|
|
|
|
|
|
async def check_site(client, row, settings, submitted: set):
|
|
now = datetime.now(timezone.utc)
|
|
try:
|
|
if not belongs_to_property(row.page_url, settings.property_url):
|
|
raise SearchConsoleError("PROPERTY_MISMATCH")
|
|
async with asyncio.timeout(90):
|
|
await submit_sitemap(client, row, submitted)
|
|
inspection = await client.inspect_url(row.property_url, row.page_url)
|
|
record_inspection(row, inspection, now)
|
|
except SearchConsoleError as ex:
|
|
record_error(row, ex.code, now)
|
|
except TimeoutError:
|
|
record_error(row, "CHECK_TIMEOUT", now)
|
|
except Exception:
|
|
record_error(row, "CHECK_FAILED", now)
|
|
reason = alert_reason(row, now, settings.alert_days)
|
|
if reason:
|
|
LOG.w(f"[search-console] site={row.site_id} {reason}")
|
|
if await send_alert(settings.alert_url, row.page_url, reason):
|
|
row.alerted_at = now
|