Astro 블로그 구글 색인 누락 해결: Playwright로 서치 콘솔 자동 색인 요청 시스템 구축
TL;DR
- 구글 Indexing API는 잡 사이트 등 특정 스키마에만 할당량이 주어져 일반 블로그에는 사실상 무용지물입니다.
- Playwright의
launch_persistent_context+ 실제 크롬 채널을 사용하면 “안전하지 않은 브라우저” 차단을 우회할 수 있습니다. - GSC 검사 페이지 직접 이동이 실패할 때를 대비한 상단 검색창 백업 로직이 안정성을 크게 높여줍니다.
- 하나의 브라우저 세션에서 여러 URL을 배치 처리하면 URL당 약 20초를 단축하고 캡차 유발 확률도 줄어듭니다.
Astro 블로그에 새 글을 올리면 구글이 언제 잡아갈지 알 수 없습니다. 서치 콘솔에 들어가 “URL 검사 → 색인 생성 요청”을 매번 수동으로 클릭하는 건 번거롭고, 구글 Indexing API를 써보면 일반 블로그 포스트에는 할당량이 제대로 주어지지 않는다는 걸 금방 알게 됩니다.
이 글에서는 Playwright로 서치 콘솔 UI 자체를 자동화해 포스트 발행 직후 색인 요청을 날리는 시스템을 구축하는 방법을 설명합니다. 구글의 자동화 감지 차단을 실제로 우회한 코드를 그대로 공개합니다.
왜 일반적인 자동 색인 API는 실패하는가?
구글 Indexing API(indexing.googleapis.com)는 문서에 JobPosting이나 BroadcastEvent 스키마가 있는 URL에 대해서만 정상적인 할당량(하루 200건)을 제공합니다. 일반 블로그 포스트 URL을 이 API로 요청하면 요청 자체는 200 OK를 반환하지만 실제 크롤링으로 이어지지 않는 경우가 많습니다.
결국 서치 콘솔 UI에서 직접 “색인 생성 요청” 버튼을 누르는 것이 가장 확실한 방법이고, 이걸 자동화하는 게 이 가이드의 핵심입니다.
문제 1: “안전하지 않은 브라우저” 차단 우회
Playwright 기본 실행(p.chromium.launch())으로 구글에 로그인하면 “브라우저 또는 앱이 안전하지 않을 수 있습니다” 메시지와 함께 차단됩니다. 구글이 자동화 플래그(navigator.webdriver)를 감지하기 때문입니다.
해결책은 실제 크롬 프로필 모드(launch_persistent_context)를 사용하는 것입니다.
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
# 실제 크롬 채널 + 영구 프로필 + 자동화 감지 플래그 제거
context = p.chromium.launch_persistent_context(
user_data_dir="./temp_profile", # 프로필 재사용으로 로그인 상태 유지
headless=False,
channel="chrome", # 시스템에 설치된 실제 구글 크롬 사용
args=["--disable-blink-features=AutomationControlled"],
)
page = context.new_page()
user_data_dir에 지정한 경로는 최초 실행 시 직접 구글 로그인을 완료하면 이후 세션에서 재사용됩니다. headless=False로 유지하는 이유는 headless 크롬은 여전히 감지될 가능성이 높기 때문입니다.
문제 2: 검사 페이지 이동 실패 시 백업 로직
서치 콘솔 URL 검사 페이지(/search-console/inspect/query/...)로 직접 이동하면 resource_id 인코딩 문제나 속성 불일치로 인해 개요 페이지로 튕겨나가는 현상이 생깁니다. 이를 대비해 GSC 상단 통합 검색창에 직접 URL을 입력하는 백업 로직을 구현했습니다.
# GSC 상단 검색창 정확한 CSS 셀렉터 (2025년 기준)
EXACT_SEARCH_SELECTOR = (
"#gb > div.gb_Kd.gb_Nd.gb_Zd > div.gb_Sd.gb_1d.gb_Le.gb_Ve.gb_1e "
"> div.gb_Ke > form > div > div > div > div > div > div.d1dlne "
"> input.Ax4B8.ZAGvjd"
)
def navigate_to_url_inspection(page, target_url: str) -> bool:
"""직접 이동 실패 시 상단 검색창으로 폴백"""
# 1순위: GSC URL 검사 페이지 직접 이동
encoded = urllib.parse.quote(target_url, safe="")
inspection_url = f"https://search.google.com/search-console/inspect?resource_id=...&id={encoded}"
page.goto(inspection_url, wait_until="networkidle")
# 정상 이동 확인
if "inspect" in page.url:
return True
# 2순위: 상단 검색창 백업
try:
search_input = page.wait_for_selector(EXACT_SEARCH_SELECTOR, timeout=5000)
if search_input:
search_input.fill(target_url)
page.keyboard.press("Enter")
page.wait_for_load_state("networkidle")
return True
except Exception as e:
print(f"상단 검색창 입력 실패: {e}")
return False
CSS 셀렉터는 구글 UI 업데이트 시 바뀔 수 있으므로 DevTools에서 주기적으로 확인하시기 바랍니다.
문제 3: Strict Mode Violation 대응
“색인 생성 요청됨” 텍스트가 팝업 내부에 숨겨진 요소로 중복 존재해 Locator.is_visible() 호출 시 strict mode violation 에러가 발생합니다.
두 가지로 해결했습니다: .first로 첫 번째 요소만 참조하고, “확인(Got it)” 버튼 출현을 성공의 보조 지표로 활용합니다.
import time
def wait_for_indexing_result(page, timeout_sec=150) -> str:
"""색인 생성 요청 완료 대기. 'success' 또는 'timeout' 반환"""
start_time = time.time()
while time.time() - start_time < timeout_sec:
# 중복 텍스트 중 첫 번째만 확인 (Strict Mode Violation 방지)
if page.get_by_text("색인 생성 요청됨", exact=False).first.is_visible():
return "success"
# '확인' 버튼이 보이면 텍스트 확인 없이 성공으로 간주
confirm_btn = page.locator(
"button:has-text('Got it'), button:has-text('확인')"
).first
if confirm_btn.is_visible():
confirm_btn.click()
return "success"
time.sleep(3)
return "timeout"
배치 처리로 속도 최적화
URL마다 브라우저를 껐다 켜면 시작 시간이 누적되고 짧은 간격의 요청이 캡차를 유발합니다. 하나의 세션에서 URL 목록을 순차 처리하면 URL당 약 20초를 단축할 수 있습니다.
def process_urls_batch(urls: list[str], delay_sec=30):
"""단일 세션에서 여러 URL 배치 처리"""
with sync_playwright() as p:
context = p.chromium.launch_persistent_context(
user_data_dir="./temp_profile",
headless=False,
channel="chrome",
args=["--disable-blink-features=AutomationControlled"],
)
page = context.new_page()
results = {}
for url in urls:
print(f"처리 중: {url}")
if not navigate_to_url_inspection(page, url):
results[url] = "navigation_failed"
continue
# 색인 생성 요청 버튼 클릭
try:
request_btn = page.locator("text=색인 생성 요청").first
request_btn.wait_for(state="visible", timeout=10000)
request_btn.click()
except Exception:
results[url] = "button_not_found"
continue
status = wait_for_indexing_result(page)
results[url] = status
print(f" → {status}")
# URL 간 딜레이 (캡차 방지)
if url != urls[-1]:
time.sleep(delay_sec)
context.close()
return results
if __name__ == "__main__":
target_urls = [
"https://tech.dokyungja.us/blog/my-post-slug-1",
"https://tech.dokyungja.us/blog/my-post-slug-2",
]
results = process_urls_batch(target_urls)
for url, status in results.items():
print(f"{status}: {url}")
자동화 예약 (crontab)
포스트 배포 후 자동 실행하려면 crontab에 등록하면 됩니다.
# 매일 오전 9시에 실행
0 9 * * * /usr/bin/python3 /path/to/gsc_indexer.py >> /var/log/gsc_indexer.log 2>&1
혹은 GitHub Actions나 Astro 배포 파이프라인의 on: push 트리거에 연결하는 방법도 있습니다.
자주 묻는 질문 (FAQ)
Playwright headless 모드는 왜 안 되나요?
구글은 headless 크롬을 여전히 자동화로 감지합니다. headless=False + channel="chrome"으로 실제 크롬을 사용하는 것이 현재까지 가장 안정적인 방법입니다.
CSS 셀렉터가 갑자기 작동하지 않습니다. 구글 서치 콘솔 UI가 업데이트되면 셀렉터가 바뀝니다. DevTools에서 해당 요소를 우클릭 → “Copy selector”로 최신 셀렉터를 확인하고 상수를 업데이트하세요.
한 번에 몇 개 URL까지 처리할 수 있나요? 서치 콘솔의 “색인 생성 요청”은 하루에 속성당 최대 10건 제한이 있습니다. URL 목록 크기를 그에 맞게 관리하세요.
구글 계정이 잠길 수 있지 않나요?
temp_profile에 저장된 기존 로그인 세션을 재사용하고, URL 간 30초 딜레이를 두면 일반적인 사용 패턴과 구별되지 않습니다. 단, 단시간에 대량 요청은 피하세요.