#!/usr/bin/env python3 """公開したページを、IndexNow で検索エンジン(Bing など。ChatGPT の検索は Bing を土台にしている)にすぐ知らせる(2026-10-28)。 使い方(.github/workflows/indexnow.yml が main への push のたびに動かす) python3 tools/indexnow.py <前の commit> <今の commit> … その間に増えた・変わったページだけ知らせる python3 tools/indexnow.py --all … 公開中のサイトマップの全ページを知らせる(最初の1回・まとめて出し直すとき) どちらも --dry-run を付けると、送らずに一覧だけ出す 鍵は public/{KEY}.txt に置いた公開の文字列(IndexNow の決まりで、誰でも見られる場所に置く。秘密ではない)。 """ import json, re, subprocess, sys, urllib.request, xml.etree.ElementTree as ET SITE = 'https://pixelstomotors.com' HOST = 'pixelstomotors.com' KEY = '' ENDPOINT = 'https://api.indexnow.org/indexnow' # 名乗りがないと Cloudflare の守り・IndexNow に断られる(python の既定の名乗りは 403 になった) UA = {'User-Agent': 'p2m-indexnow/1.0 (+https://pixelstomotors.com)'} def changed_files(a, b): out = subprocess.run(['git', 'diff', '--name-only', '--diff-filter=AM', a, b], capture_output=True, text=True, check=True).stdout return [l for l in out.splitlines() if l] def front_slug(path): try: text = open(path, encoding='utf-8').read() except OSError: return None m = re.search(r'^slug:\s*"?([\w.-]+)"?\s*$', text, re.M) return m.group(1) if m else None def urls_from_diff(a, b): urls, record_changed = set(), False for f in changed_files(a, b): if f.startswith('src/content/records/') and f.endswith('.md'): s = front_slug(f) if s: urls.add(f'{SITE}/record/{s}'); record_changed = True elif f.startswith('src/data/records/') and f.endswith('.json'): try: s = json.load(open(f, encoding='utf-8')).get('slug') except (OSError, ValueError): s = None if s: urls.add(f'{SITE}/record/{s}'); record_changed = True elif re.fullmatch(r'src/pages/[\w-]+\.astro', f): name = f.split('/')[-1][:-6] urls.add(SITE + ('/' if name == 'index' else f'/{name}')) elif f.startswith('src/data/issues/'): urls.add(f'{SITE}/issues') if record_changed: urls.update({SITE + '/', SITE + '/llms.txt'}) return sorted(urls) def urls_from_sitemap(): with urllib.request.urlopen(urllib.request.Request(SITE + '/sitemap-0.xml', headers=UA), timeout=30) as r: root = ET.fromstring(r.read()) ns = {'s': 'http://www.sitemaps.org/schemas/sitemap/0.9'} return [e.text.strip() for e in root.findall('.//s:loc', ns)] + [SITE + '/llms.txt'] def submit(urls): body = json.dumps({'host': HOST, 'key': KEY, 'keyLocation': f'{SITE}/{KEY}.txt', 'urlList': urls[:10000]}).encode() req = urllib.request.Request(ENDPOINT, data=body, headers={'Content-Type': 'application/json; charset=utf-8', **UA}, method='POST') try: with urllib.request.urlopen(req, timeout=30) as r: return r.status, '' except urllib.error.HTTPError as e: return e.code, e.read()[:300].decode('utf-8', 'replace') def main(): args = [a for a in sys.argv[1:] if a != '--dry-run'] dry = '--dry-run' in sys.argv urls = urls_from_sitemap() if args[:1] == ['--all'] else urls_from_diff(args[0], args[1]) print(f'{len(urls)} 件') for u in urls[:50]: print(' ', u) if not urls or dry: return status, detail = submit(urls) # 200=受け付けた/202=受け付けたが鍵の確認待ち。それ以外は失敗(最初の数回は SiteVerificationNotCompleted の 403 が返ることがある) print('IndexNow の返事:', status, detail) if status not in (200, 202): sys.exit(1) if __name__ == '__main__': main()