# 개요

새로운 문서를 추가하고 git push하면 git의 pre-push 훅이 자동으로 Google Indexing API에 색인 등록 요청을 보내도록 자동화한 과정이다.

흐름 요약:

git push origin main
    → .git/hooks/pre-push 실행 (git 자체 훅)
    → index_new_pages.py: 새 .md 파일 감지 → Indexing API 호출

Claude Code 훅과의 차이

Claude Code PostToolUse 훅은 Claude가 Bash 도구로 실행한 명령에만 반응한다. 터미널에서 직접 git push해도 동작하게 하려면 git 자체 훅(.git/hooks/pre-push)을 사용해야 한다.

# 사전 준비

# 1. Google Cloud — API 활성화 및 OAuth2 클라이언트 발급

  1. Google Cloud Console (opens new window) 프로젝트 생성
  2. API 및 서비스 → 라이브러리 에서 아래 두 API 활성화
    • Web Search Indexing API — 색인 등록 요청용
    • Google Search Console API — 색인 상태 조회용
  3. API 및 서비스 → OAuth 동의 화면 구성
    • 앱 유형: 외부
    • 테스트 사용자에 본인 Google 계정 이메일 추가
    • 앱 게시 (테스트 모드는 refresh token이 7일 후 만료됨)
  4. 사용자 인증 정보 → OAuth 2.0 클라이언트 ID 생성
    • 애플리케이션 유형: 데스크톱 앱
    • 생성된 JSON 파일 다운로드

서비스 계정 방식 불가

Search Console의 사용자 및 권한 페이지는 일반 Google 계정만 지원하고, 소유권 확인 페이지에서도 서비스 계정 추가 기능이 현재 UI에서 제거된 상태다.

OAuth2 + 소유자 계정 방식으로 우회한다.

# 2. refresh token 발급

아래 스크립트를 실행하면 브라우저가 열리고 Google 계정으로 로그인 후 indexing_token.json이 생성된다.

# docs/.vuepress/get_token.py
import json, os
from google_auth_oauthlib.flow import InstalledAppFlow

CLIENT_SECRET_FILE = "client_secret_xxx.json"  # 다운로드한 파일명으로 변경
TOKEN_FILE = os.path.join(os.path.dirname(__file__), "indexing_token.json")
SCOPES = [
    "https://www.googleapis.com/auth/indexing",
    "https://www.googleapis.com/auth/webmasters.readonly",
]

flow = InstalledAppFlow.from_client_secrets_file(CLIENT_SECRET_FILE, SCOPES)
creds = flow.run_local_server(port=0)

with open(TOKEN_FILE, "w") as f:
    json.dump({
        "refresh_token": creds.refresh_token,
        "client_id": creds.client_id,
        "client_secret": creds.client_secret,
    }, f, indent=2)
pip3 install google-auth-oauthlib google-auth-httplib2
python3 docs/.vuepress/get_token.py

앱 게시 후 토큰 재발급 필요

앱을 테스트 모드에서 게시(프로덕션)로 전환한 뒤 get_token.py를 다시 실행해야 영구 토큰이 발급된다.

# 색인 스크립트

# index_new_pages.py

직전 색인 처리 커밋 이후 새로 추가된 .md 파일을 감지해 Indexing API에 등록한다.

# docs/.vuepress/index_new_pages.py
import json, subprocess, sys, os, urllib.request
import google.auth.transport.requests
from google.oauth2.credentials import Credentials

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
REPO_DIR = os.path.join(SCRIPT_DIR, "..", "..")
TOKEN_FILE = os.path.join(SCRIPT_DIR, "indexing_token.json")
LAST_COMMIT_FILE = os.path.join(SCRIPT_DIR, ".last_indexed_commit")
BASE_URL = "https://<username>.github.io/<repo>"
INDEXING_API = "https://indexing.googleapis.com/v3/urlNotifications:publish"


def get_new_md_files(since_commit):
    range_arg = f"{since_commit}..HEAD" if since_commit else "HEAD~1..HEAD"
    result = subprocess.run(
        ["git", "diff", "--name-only", "--diff-filter=AR", range_arg],
        capture_output=True, text=True, cwd=REPO_DIR
    )
    return [
        f.strip() for f in result.stdout.strip().split("\n")
        if f.strip().endswith(".md") and f.strip().startswith("docs/")
    ]


def md_path_to_url(path):
    return f"{BASE_URL}/{path.removeprefix('docs/').replace('.md', '.html')}"


def get_access_token():
    with open(TOKEN_FILE) as f:
        data = json.load(f)
    creds = Credentials(
        token=None, refresh_token=data["refresh_token"],
        token_uri="https://oauth2.googleapis.com/token",
        client_id=data["client_id"], client_secret=data["client_secret"],
        scopes=["https://www.googleapis.com/auth/indexing"],
    )
    creds.refresh(google.auth.transport.requests.Request())
    return creds.token


def request_indexing(url, token):
    body = json.dumps({"url": url, "type": "URL_UPDATED"}).encode()
    req = urllib.request.Request(
        INDEXING_API, data=body,
        headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
        method="POST",
    )
    with urllib.request.urlopen(req) as resp:
        return json.loads(resp.read())


def main():
    last_commit = open(LAST_COMMIT_FILE).read().strip() if os.path.exists(LAST_COMMIT_FILE) else None
    current_commit = subprocess.run(
        ["git", "rev-parse", "HEAD"], capture_output=True, text=True, cwd=REPO_DIR
    ).stdout.strip()

    if last_commit == current_commit:
        print("[색인] 변경사항 없음"); return

    new_files = get_new_md_files(last_commit)
    if not new_files:
        open(LAST_COMMIT_FILE, "w").write(current_commit)
        print("[색인] 새로 추가된 문서 없음"); return

    token = get_access_token()
    success = 0
    for path in new_files:
        url = md_path_to_url(path)
        try:
            request_indexing(url, token)
            print(f"[색인] 등록 완료: {url}"); success += 1
        except Exception as e:
            print(f"[색인] 실패: {url}{e}", file=sys.stderr)

    open(LAST_COMMIT_FILE, "w").write(current_commit)
    print(f"[색인] {success}/{len(new_files)}개 처리 완료")


if __name__ == "__main__":
    main()

# git hook 설정

.git/hooks/pre-push 파일을 생성하고 실행 권한을 부여한다.

# .git/hooks/pre-push
#!/bin/sh
/path/to/python3 /path/to/your/repo/docs/.vuepress/index_new_pages.py
chmod +x .git/hooks/pre-push

python3 경로는 which python3으로 확인한다.

which python3
# 예: /opt/homebrew/bin/python3

git 훅 환경의 PATH 제한

git 훅은 셸 환경보다 PATH가 좁아 python3만 쓰면 패키지가 설치된 Python을 못 찾을 수 있다. 반드시 which python3로 확인한 전체 경로를 사용해야 한다.

pre-push vs post-push

git 클라이언트 훅에는 post-push가 존재하지 않는다. push 전에 실행되는 pre-push를 사용한다. 커밋은 이미 로컬에 존재하므로 색인 스크립트가 새 파일을 정상적으로 감지할 수 있다.

.git/hooks는 git으로 관리되지 않음

.git/ 디렉토리는 git 추적 대상이 아니므로 저장소를 새로 클론하면 훅이 사라진다. 팀 프로젝트라면 scripts/ 등에 훅 파일을 두고 설치 스크립트로 복사하는 방식을 권장한다.

# 매일 색인 상태 검사

# check_index_status.py

최근 2주 내 추가/변경된 문서를 URL Inspection API로 조회하고 결과를 출력한다. 검사 완료 후 macOS 알림 센터로 결과를 알려준다.

# docs/.vuepress/check_index_status.py
import json, os, subprocess, time, urllib.request
from datetime import datetime
import google.auth.transport.requests
from google.oauth2.credentials import Credentials

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
REPO_DIR = os.path.abspath(os.path.join(SCRIPT_DIR, "..", ".."))
TOKEN_FILE = os.path.join(SCRIPT_DIR, "indexing_token.json")
SITE_URL = "https://<username>.github.io/<repo>/"
BASE_URL = "https://<username>.github.io/<repo>"
INSPECTION_API = "https://searchconsole.googleapis.com/v1/urlInspection/index:inspect"
SINCE = "2 weeks ago"


def get_access_token():
    with open(TOKEN_FILE) as f:
        data = json.load(f)
    creds = Credentials(
        token=None, refresh_token=data["refresh_token"],
        token_uri="https://oauth2.googleapis.com/token",
        client_id=data["client_id"], client_secret=data["client_secret"],
        scopes=[
            "https://www.googleapis.com/auth/indexing",
            "https://www.googleapis.com/auth/webmasters.readonly",
        ],
    )
    creds.refresh(google.auth.transport.requests.Request())
    return creds.token


def get_recent_urls():
    result = subprocess.run(
        ["git", "log", f"--since={SINCE}", "--name-only", "--diff-filter=AR", "--format="],
        capture_output=True, text=True, cwd=REPO_DIR
    )
    files = sorted(set(
        f.strip() for f in result.stdout.strip().split("\n")
        if f.strip().endswith(".md") and f.strip().startswith("docs/")
    ))
    return [f"{BASE_URL}/{f.removeprefix('docs/').replace('.md', '.html')}" for f in files]


def check_url(url, token):
    body = json.dumps({"inspectionUrl": url, "siteUrl": SITE_URL}).encode()
    req = urllib.request.Request(
        INSPECTION_API, data=body,
        headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
        method="POST",
    )
    with urllib.request.urlopen(req) as resp:
        return json.loads(resp.read())


def is_indexed(state):
    s = state.lower()
    return "indexed" in s and "not indexed" not in s


def main():
    now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    sep = "=" * 60
    print(f"\n{sep}\n[{now}] 색인 상태 검사 시작\n{sep}")

    try:
        token = get_access_token()
    except Exception as e:
        print(f"토큰 발급 실패: {e}"); return

    urls = get_recent_urls()
    if not urls:
        print("최근 2주 내 추가/변경된 문서 없음")
        subprocess.run(["osascript", "-e", 'display notification "최근 2주 내 변경 문서 없음" with title "색인 상태 검사 완료"'])
        return
    print(f"최근 2주 내 변경 문서 {len(urls)}개 검사\n")

    not_indexed, errors = [], []

    for url in urls:
        try:
            result = check_url(url, token)
            state = result.get("inspectionResult", {}).get("indexStatusResult", {}).get("coverageState", "Unknown")
            if is_indexed(state):
                print(f"[등록됨] {url}")
            else:
                not_indexed.append((url, state))
                print(f"[미등록] {url}{state}")
        except Exception as e:
            errors.append((url, str(e)))
            print(f"[오류]   {url}{e}")
        time.sleep(0.2)

    print(f"\n{sep}")
    print(f"완료: 전체 {len(urls)}개 | 미등록 {len(not_indexed)}개 | 오류 {len(errors)}개")

    if not_indexed:
        print("\n[미등록 목록]")
        for url, state in not_indexed:
            print(f"  {url}  ({state})")

    if errors:
        print("\n[오류 목록]")
        for url, err in errors:
            print(f"  {url}  ({err})")

    msg = f"미등록 {len(not_indexed)}개 발견 (전체 {len(urls)}개)" if not_indexed else f"전체 {len(urls)}개 모두 색인 등록됨"
    subprocess.run(["osascript", "-e", f'display notification "{msg}" with title "색인 상태 검사 완료"'])


if __name__ == "__main__":
    main()

# launchd 등록 (macOS)

crontab은 잠자기 상태에서 스케줄을 스킵하지만, launchd는 잠자기 중 놓친 작업을 깨어난 직후 실행한다.

~/Library/LaunchAgents/com.<name>.index-check.plist 파일을 생성한다.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.<name>.index-check</string>
    <key>ProgramArguments</key>
    <array>
        <string>/path/to/python3</string>
        <string>-u</string>
        <string>/path/to/your/repo/docs/.vuepress/check_index_status.py</string>
    </array>
    <key>EnvironmentVariables</key>
    <dict>
        <key>PYTHONUNBUFFERED</key>
        <string>1</string>
    </dict>
    <key>StartCalendarInterval</key>
    <dict>
        <key>Hour</key>
        <integer>9</integer>
        <key>Minute</key>
        <integer>0</integer>
    </dict>
    <key>StandardOutPath</key>
    <string>/path/to/your/repo/docs/.vuepress/index_check.log</string>
    <key>StandardErrorPath</key>
    <string>/path/to/your/repo/docs/.vuepress/index_check.log</string>
</dict>
</plist>

등록 및 확인:

launchctl load ~/Library/LaunchAgents/com.<name>.index-check.plist
launchctl list | grep index-check

출력에 - 0 com.<name>.index-check가 보이면 등록 완료다. (앞의 -는 현재 실행 중이 아님을 의미)

Python 출력 버퍼링

launchd는 터미널이 없는 환경에서 실행되므로 Python이 출력을 버퍼링한다. -u 플래그와 PYTHONUNBUFFERED=1을 설정해야 로그 파일에 실시간으로 기록된다.

launchd vs crontab

StartCalendarInterval을 사용하는 launchd는 Mac이 잠자기 상태여서 스케줄을 놓쳤더라도 다음 부팅/깨어남 시 즉시 실행된다. crontab은 해당 시각을 그냥 스킵한다.

# 결과 확인

로그 파일에 누적 기록된다.

tail -100 docs/.vuepress/index_check.log

검사 완료 시 macOS 알림 센터로 결과가 전달된다.

  • 최근 변경 문서 없을 때: 최근 2주 내 변경 문서 없음
  • 미등록 있을 때: 미등록 N개 발견 (전체 N개)
  • 전부 등록됐을 때: 전체 N개 모두 색인 등록됨

# .gitignore 설정

credential 파일들이 커밋에 포함되지 않도록 추가한다.

docs/.vuepress/*.json
docs/.vuepress/get_token.py
docs/.vuepress/.last_indexed_commit
docs/.vuepress/index_check.log

# 동작 확인

git push origin main 실행 후 .last_indexed_commit 파일이 생성/갱신되면 훅이 정상 동작한 것이다.

새로 추가된 .md 파일이 있으면 다음과 같이 출력된다.

[색인] 등록 완료: https://<username>.github.io/<repo>/database/some-doc.html
[색인] 1/1개 처리 완료

색인 반영은 수 시간 ~ 1일 후 Search Console에서 확인 가능하다.