여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다. - 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>
89 lines
2.8 KiB
Python
89 lines
2.8 KiB
Python
"""발행본 URL 을 IndexNow 로 알린다 — 크롤러가 찾아올 때까지 기다리지 않는다."""
|
|
|
|
import os
|
|
import xml.etree.ElementTree as ET
|
|
from pathlib import Path
|
|
from urllib.parse import urlsplit
|
|
|
|
import httpx
|
|
|
|
from common.logger import LOG
|
|
|
|
ENDPOINT = "https://api.indexnow.org/indexnow"
|
|
SITEMAP_NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}"
|
|
TIMEOUT_SEC = 10.0
|
|
# 규격 상한은 한 번에 10,000 개다.
|
|
MAX_URLS = 10_000
|
|
|
|
|
|
def key() -> str:
|
|
return os.environ.get("INDEXNOW_KEY", "").strip()
|
|
|
|
|
|
def is_configured() -> bool:
|
|
return bool(key())
|
|
|
|
|
|
def output_dir() -> Path:
|
|
return Path(os.environ.get("SITE_OUTPUT_DIR", "/app/solution/site/out"))
|
|
|
|
|
|
def site_urls(slug: str) -> list[str]:
|
|
"""이 사이트가 실제로 발행한 URL 목록(사이트맵의 `<loc>`)."""
|
|
sitemap = output_dir() / "s" / slug / "sitemap.xml"
|
|
if not sitemap.is_file():
|
|
return []
|
|
try:
|
|
root = ET.parse(sitemap).getroot()
|
|
except ET.ParseError as ex:
|
|
LOG.w(f"[indexnow] 사이트맵을 읽지 못했다 — {sitemap}: {ex}")
|
|
return []
|
|
urls = [(node.text or "").strip() for node in root.iter(f"{SITEMAP_NS}loc")]
|
|
return [url for url in urls if url][:MAX_URLS]
|
|
|
|
|
|
def _payload(urls: list[str]) -> dict | None:
|
|
"""IndexNow 요청 본문."""
|
|
host = urlsplit(urls[0]).netloc
|
|
if not host:
|
|
return None
|
|
same_host = [url for url in urls if urlsplit(url).netloc == host]
|
|
return {
|
|
"host": host,
|
|
"key": key(),
|
|
# 키 파일은 오리진 루트에 있다(프리렌더가 굽고 azure_static 이 올린다).
|
|
"keyLocation": f"https://{host}/{key()}.txt",
|
|
"urlList": same_host,
|
|
}
|
|
|
|
|
|
async def submit(slug: str) -> dict | None:
|
|
"""설정된 경우에만 통보한다."""
|
|
if not is_configured():
|
|
return None
|
|
|
|
urls = site_urls(slug)
|
|
if not urls:
|
|
LOG.w(f"[indexnow] 보낼 URL 이 없다 — 사이트맵이 없거나 비었다: {slug}")
|
|
return None
|
|
|
|
body = _payload(urls)
|
|
if body is None:
|
|
LOG.w(f"[indexnow] URL 에서 호스트를 못 읽었다: {urls[0]}")
|
|
return None
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=TIMEOUT_SEC) as client:
|
|
res = await client.post(ENDPOINT, json=body)
|
|
except httpx.HTTPError as ex:
|
|
LOG.w(f"[indexnow] 통보 실패 {slug}: {type(ex).__name__}: {ex}")
|
|
return {"ok": False, "error": f"{type(ex).__name__}: {ex}", "urls": len(body['urlList'])}
|
|
|
|
# 200 OK · 202 Accepted 가 정상이다.
|
|
ok = res.status_code in (200, 202)
|
|
if ok:
|
|
LOG.i(f"[indexnow] {slug} — URL {len(body['urlList'])}개 통보 (HTTP {res.status_code})")
|
|
else:
|
|
LOG.w(f"[indexnow] {slug} 거절됨 HTTP {res.status_code}: {res.text[:200]}")
|
|
return {"ok": ok, "status": res.status_code, "urls": len(body["urlList"])}
|