Astro 블로그 구글 색인 누락 해결: Playwright로 서치 콘솔 자동 색인 요청 시스템 구축
SEO 자동화

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초 딜레이를 두면 일반적인 사용 패턴과 구별되지 않습니다. 단, 단시간에 대량 요청은 피하세요.


관련 글