Geonode logo
Geonode Team

Geonode Team

更新日:2026年10月16日

公開日:2026年10月16日

Python によるウェブサイトの変更検出:ドキュメントやポリシーの更新を監視する

ウェブサイトの変更検出とは、ベンダーが利用規約の条項を書き換えたり、ドキュメント内のエンドポイントを非推奨にしたり、あるいは誰かが発表する前にこっそりとプライバシーポリシーを編集したりしたことを、事前に把握する方法です。 このガイドを読み終える頃には、Geonode や Scraper API を通じてページのリストを Markdown 形式でスナップショット化し、不要な情報を除去し、すべてのバージョンを SQLite に保存し、読みやすい差分レポートを生成し、コンテンツが実際に変更された場合にのみ Slack に投稿する Python スクリプトが完成しているはずです。 このスクリプトはcronやGitHub Actionsから実行でき、不安定なフェッチにも耐えます。 これは意図的に価格監視ツールではありません。価格は既知の場所に存在する数値ですが、ドキュメントは文章であり、変更の場所や形態は事前に不明だからです。以下の設計上の選択は、この点に基づいています。

ウェブサイトの変更検出とは、ベンダーが利用規約の条項を書き換えたり、ドキュメント内のエンドポイントを非推奨にしたり、あるいは誰かが発表する前にこっそりとプライバシーポリシーを編集したりしたことを、事前に把握する方法です。 このガイドを読み終える頃には、Geonode や Scraper API を通じてページのリストを Markdown 形式でスナップショット化し、不要な情報を除去し、すべてのバージョンを SQLite に保存し、読みやすい差分レポートを生成し、コンテンツが実際に変更された場合にのみ Slack に投稿する Python スクリプトが完成しているはずです。 このスクリプトはcronやGitHub Actionsから実行でき、不安定なフェッチにも耐えます。

これは意図的に価格監視ツールではありません。価格は既知の場所に存在する数値ですが、ドキュメントは文章であり、変更の場所や形態は事前に不明だからです。以下の設計上の選択は、この点に基づいています。

監視対象とMarkdownを採用する理由

注目すべきページには、次のような共通点があります。公開されており、テキストが多く、更新頻度は低いが、更新された際には大きな影響を及ぼすものです。 APIリファレンスページ、認証ガイド、レートリミット表、利用規約、データ処理契約、利用規定、変更履歴、ステータスページのインシデント履歴などがこれに該当します。

これらのページの生のHTMLを比較するのは非常に面倒です。アセットURLに含まれるハッシュ、CSRFトークン、A/Bテストのクラス名、埋め込まれたアナリティクス情報は、読み込みのたびに変化します。 まずMarkdownに変換することで、こうした要素のほぼすべてが除去され、文章のみが残ります。エンドポイント「/v1/extract」はMarkdownを直接返すため、スクリプトはHTMLパーサーを一切使用しません。

「pip install requests」で唯一の依存関係をインストールしてください。その他はすべて標準ライブラリで対応可能です。

スナップショットの取得

フェッチャーはMarkdownを要求し、デフォルトではJavaScriptによるレンダリングを無効にしていますが、クライアントサイドでレンダリングを行うドキュメントサイトの場合は、ページごとにこれを有効にします。wait_configブロックは、ページが読み込み後にコンテンツを動的に生成する場合にのみ必要であり、これが存在するとレンダリングが自動的に有効になります。

import os
import requests

API = "https://scraper.geonode.io"
HEADERS = {"X-Api-Key": os.environ["GEONODE_API_KEY"]}

def snapshot(session: requests.Session, url: str, render_js: bool = False,
             wait_for: str | None = None) -> tuple[int, str]:
    """Return (origin_http_status, markdown). Raises on API-level errors."""
    body = {"url": url, "formats": ["markdown"], "render_js": render_js,
            "processing_mode": "sync", "timeout": 60000}
    if wait_for:
        body["wait_config"] = {"wait_until": "domcontentloaded", "wait_for": wait_for,
                               "wait_timeout": 2000}
        body.pop("render_js")          # wait_config implies rendering; sending false is rejected
    r = session.post(f"{API}/v1/extract", json=body, headers=HEADERS, timeout=90)
    if r.status_code == 429:
        err = r.json().get("error", r.json())
        raise RuntimeError(f"rate limited ({err.get('code')}): {err.get('message')}")
    r.raise_for_status()
    payload = r.json()
    return payload["metadata"]["http_status"], (payload.get("data") or {}).get("markdown") or ""

ボディと同様に、オリジンのステータスも重要です。503メンテナンスページは完全に有効なMarkdownに変換されますが、http_statusを確認しなければ、それを新しいバージョンとして保存し、アラートを発することになります。

ノイズの除去

2つの処理を順に適用します。まず、正規表現による処理で、変動しやすい行(日付、「最終更新日」のバナー、クッキーに関する通知、著作権表示のフッター、「このページを編集」リンクなど)を削除します。 次に、セクションの許可リストによって、必要な見出しのみを残します。これにより、Markdownコンバーターが「実際のテキスト」として保持していたナビゲーション、サイドバー、マーケティングブロックが除外されます。

import re

NOISE = [
    re.compile(r"(?im)^.*\b(last (updated|modified|reviewed)|effective date|updated on)\b.*$"),
    re.compile(r"(?im)^.*\b(cookie|cookies)\b.*\b(accept|consent|preferences)\b.*$"),
    re.compile(r"(?im)^.*(©|copyright|all rights reserved).*$"),
    re.compile(r"(?im)^.*\b(edit this page|was this page helpful|on this page)\b.*$"),
    re.compile(r"\b(?:\d{1,2}\s)?(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+\d{1,2},?\s+\d{4}\b", re.I),
    re.compile(r"\b\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2})?\S*)?\b"),
    re.compile(r"!\[[^\]]*\]\([^)]*\)"),                 # images
]

def strip_noise(md: str, keep_sections: list[str] | None = None) -> str:
    for rx in NOISE:
        md = rx.sub("", md)
    if keep_sections:
        kept, keep = [], False
        for line in md.splitlines():
            if line.startswith("#"):
                title = line.lstrip("#").strip().lower()
                keep = any(s.lower() in title for s in keep_sections)
            if keep:
                kept.append(line)
        md = "\n".join(kept)
    return normalize_ws(md)

def normalize_ws(text: str) -> str:
    lines = (re.sub(r"[ \t]+", " ", ln).strip() for ln in text.splitlines())
    return "\n".join(ln for ln in lines if ln)

keep_sectionsは見出しに対して照合される部分文字列のリストであるため、["rate limit", "authentication"]はそれらのH2/H3ブロックと、次の見出しまでのその下のすべてのコンテンツを保持します。条項のすべてが重要なポリシーページの場合は、Noneのままにしておいてください。

日付の正規表現は意図的に厳格に設定されています。ポリシーページの「2026年3月1日発効」といった行は、まさに変更が反映されるべき箇所です。そのため、これが重要である場合は、最初の NOISE エントリを削除し、差分表示に反映させてください。

ハッシュと差分比較

ハッシュは「何か変更があったか」という問いに対して定数時間で答えを出します。一方、差分比較は「何が変更されたか」という問いに対して、Slackのメッセージで人が読める形式で答えを出します。ほとんどのウェブサイトの変更検出ツールはハッシュの段階で処理を終了しますが、ハッシュでチェックを行い、差分比較でレポートを作成するというように、両方を併用することをお勧めします。

import difflib
import hashlib

def digest(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8")).hexdigest()

def unified(old: str, new: str, url: str, context: int = 2, max_lines: int = 120) -> str:
    diff = difflib.unified_diff(old.splitlines(), new.splitlines(),
                                fromfile=f"{url} (previous)", tofile=f"{url} (current)",
                                lineterm="", n=context)
    lines = list(diff)
    if len(lines) > max_lines:
        lines = lines[:max_lines] + [f"... {len(lines) - max_lines} more lines"]
    return "\n".join(lines)

difflib.unified_diffは標準ライブラリに含まれており、docs.python.orgにドキュメントが掲載されています。 n引数でコンテキスト行数を設定します。文章であれば2行で十分です。ページ全体が書き換えられても5,000行ものアラートが出ないよう、出力行数に上限を設けてください。

スナップショットの保存

SQLiteは、最新のバージョンだけでなく、完全な履歴を保持します。これにより、直近の2つのスナップショット(次のセクション)との比較が可能になり、数ヶ月後に「この句が最初に登場したのはいつか」という問いにも答えることができます。

import sqlite3
import time

SCHEMA = """
CREATE TABLE IF NOT EXISTS snapshots (
  id          INTEGER PRIMARY KEY,
  url         TEXT NOT NULL,
  taken_at    REAL NOT NULL,
  http_status INTEGER,
  hash        TEXT,
  content     TEXT
);
CREATE INDEX IF NOT EXISTS idx_url_time ON snapshots(url, taken_at DESC);
"""

def open_db(path: str = "changes.sqlite") -> sqlite3.Connection:
    db = sqlite3.connect(path)
    db.executescript(SCHEMA)
    return db

def last_snapshots(db: sqlite3.Connection, url: str, n: int = 2) -> list[tuple[str, str]]:
    """Most recent first: [(hash, content), ...] for successful (200) fetches only."""
    return db.execute("SELECT hash, content FROM snapshots WHERE url=? AND http_status=200 "
                      "ORDER BY taken_at DESC LIMIT ?", (url, n)).fetchall()

def save(db: sqlite3.Connection, url: str, status: int, h: str | None, content: str | None) -> None:
    db.execute("INSERT INTO snapshots (url, taken_at, http_status, hash, content) VALUES (?,?,?,?,?)",
               (url, time.time(), status, h, content))
    db.commit()

ポリシーページはサイズが小さいため、実行ごとにコンテンツ全体を保存しても負荷は軽微です。長文のドキュメントを数百件、1時間ごとに監視する場合は、ハッシュ値が変更されたときのみコンテンツを保存し、それ以外はハッシュ値の行のみを保持するようにしてください。

不安定なフェッチへの対処

不安定なフェッチは、ウェブサイトの変更検出における誤検知の主な原因であるため、3つの対策があります。あきらめる前に、バックオフを適用してフェッチを再試行します。200以外のステータスコードはすべて「データなし」として扱い、そのステータスをレコードとして保存し、比較対象から除外します。 また、新しいハッシュは直近の1つだけでなく、直近の2つの正常なスナップショットと比較します。例えば、ページのステータスが「A、B、A」と変化した場合、「B」は一時的な変動(200を返したエラーページや、古いビルドを配信するキャッシュなど)である可能性が高く、アラートを発行する価値はありません。

def fetch_with_retry(session: requests.Session, url: str, attempts: int = 3, **kw) -> tuple[int, str]:
    delay = 5
    for i in range(attempts):
        try:
            status, md = snapshot(session, url, **kw)
            if status < 500 and md.strip():
                return status, md
        except (requests.RequestException, RuntimeError) as exc:
            if i == attempts - 1:
                raise
        time.sleep(delay)
        delay *= 3
    return status, md

def check(db: sqlite3.Connection, session: requests.Session, url: str,
          keep_sections: list[str] | None = None, **kw) -> str | None:
    """Returns a unified diff if a real change was detected, else None."""
    status, raw = fetch_with_retry(session, url, **kw)
    if status != 200:
        save(db, url, status, None, None)
        return None
    clean = strip_noise(raw, keep_sections)
    h = digest(clean)
    previous = last_snapshots(db, url, n=2)
    save(db, url, status, h, clean)
    if not previous:
        return None                                   # first run, nothing to compare
    if any(h == old_hash for old_hash, _ in previous):
        return None                                   # unchanged, or a revert of a blip
    return unified(previous[0][1], clean, url)

2番目のスナップショットルールには1つの代償があります。それは、1回の実行内で元に戻された正当な変更が報告されないことです。しかし、ドキュメントやポリシーの監視においては、ほとんどの場合、これが適切なトレードオフとなります。

通知

Slackの受信Webhookは、textフィールドを含むJSONボディを受け付けます。差分(diff)をコードブロックで囲むことで、等幅フォントで表示されます。smtplib経由のメール送信は、数行多くなりますが、Slackが利用できない場合に便利です。

import json
import smtplib
from email.message import EmailMessage

SLACK_WEBHOOK = os.environ.get("SLACK_WEBHOOK_URL")

def notify_slack(url: str, diff: str) -> None:
    if not SLACK_WEBHOOK:
        return
    fence = "`" * 3                          # literal ``` would close the Markdown block
    text = f"*Change detected:* {url}\n{fence}\n{diff[:3500]}\n{fence}"
    requests.post(SLACK_WEBHOOK, data=json.dumps({"text": text}),
                  headers={"Content-Type": "application/json"}, timeout=15).raise_for_status()

def notify_email(url: str, diff: str, to: str) -> None:
    msg = EmailMessage()
    msg["Subject"] = f"Change detected: {url}"
    msg["From"] = os.environ["SMTP_FROM"]
    msg["To"] = to
    msg.set_content(diff)
    with smtplib.SMTP(os.environ["SMTP_HOST"], int(os.environ.get("SMTP_PORT", "587"))) as s:
        s.starttls()
        s.login(os.environ["SMTP_USER"], os.environ["SMTP_PASS"])
        s.send_message(msg)

Slackでは長いメッセージが切り捨てられるため、差分には3,500文字の上限が設けられています。差分が長い場合は、最初の画面分を投稿し、全文を別の場所に添付するか、保存済みのスナップショットIDをメッセージ内に記載してください。

ウォッチリストとランナー

設定はリストとして管理されるため、リポジトリに保存し、コードと同様に変更内容をレビューすることができます。各エントリには、URL、レンダリングの要否、待機対象のオプションのセレクタ、およびオプションのセクション許可リストが含まれます。

WATCHLIST = [
    {"url": "https://example.com/legal/terms"},
    {"url": "https://example.com/legal/privacy"},
    {"url": "https://example.com/docs/api/authentication",
     "keep_sections": ["authentication", "rate limit"]},
    {"url": "https://example.com/changelog", "render_js": True, "wait_for": "main article"},
]

def main() -> None:
    db = open_db()
    session = requests.Session()
    for item in WATCHLIST:
        url = item["url"]
        try:
            diff = check(db, session, url, keep_sections=item.get("keep_sections"),
                         render_js=item.get("render_js", False), wait_for=item.get("wait_for"))
        except Exception as exc:
            print(f"[skip] {url}: {exc}")
            continue
        if diff:
            print(f"[changed] {url}")
            notify_slack(url, diff)
        else:
            print(f"[ok] {url}")

if __name__ == "__main__":
    main()

初回の実行ではデータベースの初期化が行われ、何も報告されません。2回目以降の実行では、実際の変更があるたびに、差分情報を含むSlackメッセージが1件送信されます。

ウェブサイトの変更検出のスケジュール設定

インフラを必要としない2つの方法があります。Pythonと環境変数が設定されている任意のマシン上で、cronジョブを実行する方法です:

# daily at 06:10, log to a file
10 6 * * * cd /opt/change-monitor && /usr/bin/python3 monitor.py >> monitor.log 2>&1

あるいは、GitHub Actionsのワークフローも便利です。SQLiteファイルを状態ストアとしてリポジトリにコミットし、シークレットをリポジトリ設定に格納できるためです:

name: change-monitor
on:
  schedule:
    - cron: "10 6 * * *"
  workflow_dispatch:
jobs:
  run:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install requests
      - run: python monitor.py
        env:
          GEONODE_API_KEY: ${{ secrets.GEONODE_API_KEY }}
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
      - run: |
          git config user.name "change-monitor"
          git config user.email "bot@users.noreply.github.com"
          git add changes.sqlite && git commit -m "snapshot $(date -u +%F)" || true
          git push

変更履歴については1時間ごとのスケジュールで十分です。法務関連のページについては、1日1回でもほとんどのチームにとって必要十分です。以下の表は、妥当な出発点となります。

ページの種類推奨頻度render_jskeep_sections
利用規約、プライバシーポリシー、利用規約 (AUP)毎日なしなし (ページ全体)
API リファレンス6時間ごとドキュメントエンジンによるはい、使用するエンドポイント
変更履歴 / リリースノート1時間ごと頻繁なし
ステータスページのインシデント履歴1時間ごと通常なし

利用規約とロボットに関する注意事項

公開されているドキュメントやポリシーページを1日1回閲覧することは、ウェブの通常の利用方法であり、ブラウザが行うことと同じです。とはいえ、サイトをウォッチリストに追加する前に、そのサイトの利用規約とrobots.txtを確認し、頻度を適切な範囲に抑え、許可されていないログインが必要なページを監視することは絶対に避けてください。 サイトがRSSフィードや変更履歴APIを公開している場合は、そちらを優先してください。双方にとって負担が少なく、ページのレイアウトが変更されても機能し続けます。

よくある間違い

生のHTMLを比較する

最新のドキュメントサイトでは、ビルドのたびにアセットのハッシュやクラス名が書き換えられます。そのため、差分が判読不能になり、デプロイのたびにアラートが発生してしまいます。snapshot()のように、まず最初にMarkdownに変換してください。

最初の200以外のステータスコードに対するアラート

単発の503エラーや、「Please verify you are human」というメッセージと共に200を返すボット対策ページは、保存版やアラートの対象にしてはなりません。 http_statusを確認し、コンテンツが空でないことを要件とし、直近の2つの正常なスナップショットと比較してください。

削除範囲が少なすぎる、または多すぎる場合

差分(diff)に日付のみが表示される場合は、NOISEに追加してください。keep_sectionsが、その条項が属していた見出しを削除したために条項の変更が見落とされた場合は、許可リストの範囲を広げてください。モニターを1週間実行し、生成された差分に基づいて2つのリストを調整してから、信頼するようにしてください。

ウォッチリストが実際の状況から乖離してしまう

新しいパスにリダイレクトされる URL や、バージョン管理されたプレフィックスの下に移動したドキュメントページは、何の前触れもなく 404 エラーや別のページに変わってしまいます。200 以外のステータスコードは明確にログに記録し、出力結果の「skip」行をこまめに確認してください。

次のステップ

上記のすべては、Pythonの標準ライブラリと、ページごとに1回のHTTP呼び出しだけで実現されており、これがドキュメントのウェブサイト変更検出に必要なすべてです。 セルフホスティングが困難な部分は、フェッチそのものです。つまり、JavaScriptでレンダリングされたドキュメントサイトに対応し、オリジンの実際のhttp_statusを返し、毎朝40のベンダーページをチェックしてもブロックされない、クリーンなMarkdown変換が必要です。 Geonode Scraper APIはまさにそのリクエストを提供しており、クライアント側でレンダリングされるページ向けにはrender_jsとwait_configが用意されています。また、月額1.5Kリクエストの無料枠があり、毎日のウォッチリストを余裕を持ってカバーできます。 プランについてはScraper APIのページを、関連するパターンについてはウェブスクレイピングのユースケースをご覧ください。

よくある質問

ウェブサイトの変更検知とは何ですか?また、どのような場合に役立ちますか?

ウェブサイトの変更検知とは、スケジュールに従ってページを取得し、前回のスナップショットと比較して、重要なコンテンツに違いがある場合にアラートを発する手法のことです。 これは、依存しているものの管理権限を持たないページ――APIドキュメント、利用規約、プライバシーポリシー、価格条件、ベンダーの変更履歴など――において特に有用です。こうしたページでは、黙って行われた編集によって、統合機能やコンプライアンス上の前提が破綻する恐れがあるからです。

なぜ生のHTMLではなくMarkdownを比較するのですか?

HTMLには、テキストが変わらなくても変更されるレイアウト、スクリプト、トラッキングパラメータ、ビルドハッシュなどが含まれています。まずMarkdownに変換することで、ページがコンテンツのみに集約されるため、差分表示では、100行にも及ぶ属性の変動ではなく、実際に編集された文が明確に示されます。 Geonode Scraper APIはMarkdownを直接返すため、パイプラインを短く保つことができます。

日付、クッキーバナー、ナビゲーションによる誤検知を回避するには?

少数の正規表現(日付、「最終更新日」の行、クッキー通知、フッター)を使って既知のノイズを除去し、見出しの許可リストを使用して関心のあるセクションのみを残します。ハッシュ化する前に空白を正規化します。最後に、直近の2つのスナップショットと比較することで、一時的なエラーページが単独でアラートを発しないようにします。

ページをハッシュ化すべきか、それとも差分を取るべきか?

両方です。ハッシュは、何か処理を行うかどうかを決定するための高速な等価性チェックです。 difflib による統一差分(unified diff)は、アラート内で人間が読む内容となります。まずハッシュを計算し、ハッシュが異なる場合にのみ差分を取得し、その差分を通知と共に送信することで、読者はページを開かずに変更内容を判断できるようになります。

ウェブサイトの変更検出はどのくらいの頻度で実行すべきか?

その頻度を、ページの重要度と更新頻度に合わせて調整してください。 法規制やポリシーのページは、年に数回以上変更されることはめったにないため、毎日実行すれば十分です。頻繁に更新されるAPIドキュメントや変更履歴については、1時間ごとのチェックが妥当です。cronやGitHub Actionsのスケジュールから実行し、フェッチに失敗した場合でも、報告される前に再試行が行われるよう、間隔を十分に長く設定してください。

コンプライアンスや制限に関する考慮事項は?

公開ドキュメントやポリシーページを適度な頻度で監視することは、ウェブの通常の利用方法ですが、サイトの利用規約やrobots.txtをよく読み、それらを遵守してください。また、アクセス権限のない認証が必要なページを監視してはいけません。 API に関しては、Scraper API の無料プランでは月間 1.5K リクエストまでカバーされており、1 日 40 ページのチェックであればこの範囲内に収まります。