閉じる

Pythonで取得したHTMLをファイルに保存してスクレイピング効率化する

スクレイピングでは、取得したHTMLをその場で解析すると試行錯誤のたびにネットワーク通信が発生しがちです。

HTMLをファイルに保存しておけば同じ内容で何度でも解析でき、ネットワークに依存せずに素早くデバッグできます。

ローカルに保存するだけで再現性・速度・オフライン対応が大きく向上します

HTMLをファイル保存する理由

スクレイピングの再現性向上

その時点のスナップショットを固定化できます

HTMLは時間とともに変化します。

取得直後に処理してしまうと、あとから同じ条件で再現することが難しくなります。

ファイルとして保存しておけば、同じHTMLを何度でも解析できるため、選択子の調整やロジックのバグ取りが安定して行えます

また、保存したスナップショットをチームで共有すれば、環境差による再現性問題も減ります。

Beautiful Soupのセレクタ調整がしやすくなります

オフラインの保存HTMLに対してfindselectの検証を繰り返すことで、実サイトに負荷をかけずに素早く要素の抽出方法を磨けます。

通信回数を減らし高速化

キャッシュとして使えば無駄な再取得を避けられます

一度保存したHTMLがあるなら、次回はローカルファイルを読み込むだけで解析を開始できます。

ネットワーク待ちが無くなることで、開発サイクルが短縮されます。

さらに、サイトへのアクセス回数も減るため、相手サイトへの負荷軽減にもつながります。

オフラインで解析・デバッグ

通勤中やネット不調時でも作業可能です

保存済みHTMLさえあれば、オフラインでもパースや正規表現の見直しができます。

通信に依存しないデバッグ環境は、時間と場所の自由度を高めます

注意

保存したHTMLはあくまで取得時点の静的な内容です。

JavaScriptで後から描画される要素はrequestsだけでは含まれない場合があります。

動的レンダリングが必要なサイトは別途Seleniumなどを検討してください。

PythonでHTMLを保存する手順

requestsで取得→.htmlで保存

最小の保存スクリプト(UTF-8で保存)

Python
# 最小構成: requestsで取得し、.htmlとして保存する例
# - 取得先: https://example.com (静的なHTML例)
# - 保存先: saved_pages/example.com/index.html

from pathlib import Path
import requests

def save_html_min(url: str, out_path: Path) -> None:
    out_path.parent.mkdir(parents=True, exist_ok=True)  # 保存先フォルダを作成
    headers = {
        # 最低限のUser-Agentを指定(拒否回避や識別のため)
        "User-Agent": "Mozilla/5.0 (compatible; HTML-Save-Demo/1.0; +https://example.com/)"
    }
    # タイムアウトは(接続, 読み込み)の秒数タプルで指定
    resp = requests.get(url, headers=headers, timeout=(5, 15))
    # サーバがエンコーディングを明示していないことがあるため、requestsの推定結果を採用
    resp.encoding = resp.apparent_encoding or "utf-8"
    html_text = resp.text  # テキストとして読み出す(ここでresp.encodingが使われる)

    # UTF-8で書き出し
    out_path.write_text(html_text, encoding="utf-8")

if __name__ == "__main__":
    url = "https://example.com"
    out = Path("saved_pages/example.com/index.html")
    save_html_min(url, out)
    print(f"Saved: {out.resolve()}")
実行結果
Saved: /absolute/path/to/saved_pages/example.com/index.html

HTML取得にはrequests.getを使い、レスポンスのapparent_encodingをUTF-8に再エンコードして保存します。

こうすることで、文字化けを避けつつ統一的に扱えます。

UTF-8で書き出す

文字化け防止の基本

保存ファイルをUTF-8に統一すると、エディタや後段の解析処理で扱いやすくなります。

以下のようにresp.apparent_encodingで一度テキスト化してからencoding="utf-8"で書き出すのが簡単です。

Python
# エンコーディングの扱いを明示化する例
import requests

def fetch_text_utf8(url: str) -> str:
    resp = requests.get(url, timeout=(5, 15))
    resp.encoding = resp.apparent_encoding or "utf-8"
    return resp.text  # ここで推定エンコーディングに従いデコード済みのテキストが得られる

text = fetch_text_utf8("https://example.com")
# 以降はUTF-8で保存すればOK
with open("example.html", "w", encoding="utf-8") as f:
    f.write(text)
print("Saved as UTF-8")
実行結果
Saved as UTF-8
メモ

オリジナルのバイト列をそのまま保存したい場合はresp.contentを使ってバイナリ保存します。

保存フォルダを用意

フォルダ作成は最初に済ませておきます

保存先が無いとエラーになるためPath(...).mkdir(parents=True, exist_ok=True)で事前に作っておきます。

ドメインごとに分けると管理がしやすくなります。

Python
from pathlib import Path

base_dir = Path("saved_pages")
(base_dir / "example.com").mkdir(parents=True, exist_ok=True)
print("Prepared:", (base_dir / "example.com").resolve())
実行結果
Prepared: /absolute/path/to/saved_pages/example.com

スクレイピング向けファイル名と保存場所のコツ

タイムスタンプで履歴管理

過去取得との比較を簡単にします

ファイル名にタイムスタンプを付けてスナップショットを蓄積すると、サイトの変更点を比較できます。

index_20250115_101530.html

URLを安全な名前に変換

OSに優しいファイル名に正規化します

URLの文字はそのままだとファイル名として不適切な記号を含むことがあります。

英数字と-_中心に整形し、長すぎるときはハッシュを添えると安全です。

Python
# URLから安全なファイル名を作る関数
from urllib.parse import urlparse
import re
import hashlib

def slugify(text: str, limit: int = 120) -> str:
    # 非英数字をハイフンに
    s = re.sub(r"[^0-9A-Za-z._-]+", "-", text)
    # 連続ハイフンを1つに
    s = re.sub(r"-{2,}", "-", s).strip("-._")
    # 空ならindex
    if not s:
        s = "index"
    # 長すぎる場合は末尾にハッシュを付けて詰める
    if len(s) > limit:
        digest = hashlib.md5(s.encode("utf-8")).hexdigest()[:8]
        s = s[:limit] + "__" + digest
    return s

def safe_stem_from_url(url: str) -> str:
    p = urlparse(url)
    # パスの最後の要素をファイル名のベースに
    last = p.path.rstrip("/").split("/")[-1] if p.path and p.path != "/" else "index"
    base = slugify(last or "index")
    if p.query:
        # クエリを短く要約(先頭部分 + ハッシュ)
        q_hash = hashlib.md5(p.query.encode("utf-8")).hexdigest()[:8]
        base = f"{base}_{q_hash}"
    return base  # 拡張子は別で付与(.html)

ページごとにフォルダ分け

ドメイン/パス階層をそのままミラーします

ドメイン単位でフォルダを作り、URLパスをサブフォルダにします。

これにより、大量のページでも衝突しにくく、整理が容易です。

Python
# URLからローカルの保存パスを作る(ドメイン/パスをミラー)
from urllib.parse import urlparse
from pathlib import Path

def url_to_local_paths(url: str, base_dir: Path, with_timestamp: bool = True) -> tuple[Path, Path | None]:
    """
    戻り値:
      (安定ファイル(stable).html, スナップショット(snapshot_YYYYMMDD_HHMMSS).html or None)
    """
    import datetime as dt
    p = urlparse(url)
    netloc = p.netloc.replace(":", "_")
    parts = [seg for seg in p.path.split("/") if seg]
    if not parts:
        parts = ["index"]

    # ディレクトリはパスの全要素(最後はファイル名になる)
    dir_parts = [slugify(seg) for seg in parts[:-1]]
    stem = safe_stem_from_url(url)  # ファイル名のベース
    stable_dir = base_dir / netloc / Path(*dir_parts) if dir_parts else base_dir / netloc
    stable_path = stable_dir / f"{stem}.html"

    snapshot_path = None
    if with_timestamp:
        ts = dt.datetime.now().strftime("%Y%m%d_%H%M%S")
        snapshot_path = stable_dir / f"{stem}_{ts}.html"

    return stable_path, snapshot_path

実際の構造例:

目的例のパスメリット
安定ファイル(上書き)saved_pages/example.com/news/index.html解析の入口を固定化できる
スナップショット(履歴)saved_pages/example.com/news/index_20250115_101530.html変更点の比較が容易
クエリ区別saved_pages/example.com/search/result_1a2b3c4d.htmlクエリの違いで衝突しない

保存HTMLの活用でPythonスクレイピングを効率化

以下は、保存・再利用・履歴化・簡易デバッグを1つにまとめた実用スクリプトです。

まず保存、次にローカルで解析という流れを徹底することで、開発効率が上がります。

Python
# 実用スクリプト: 取得→UTF-8保存→キャッシュ活用→スナップショット履歴→ローカル解析
# 依存: requests, beautifulsoup4
from __future__ import annotations

from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
import requests
import hashlib
import re
import datetime as dt

# ===== ファイル名・パス生成ユーティリティ =====

def slugify(text: str, limit: int = 120) -> str:
    s = re.sub(r"[^0-9A-Za-z._-]+", "-", text)
    s = re.sub(r"-{2,}", "-", s).strip("-._")
    if not s:
        s = "index"
    if len(s) > limit:
        digest = hashlib.md5(s.encode("utf-8")).hexdigest()[:8]
        s = s[:limit] + "__" + digest
    return s

def safe_stem_from_url(url: str) -> str:
    p = urlparse(url)
    last = p.path.rstrip("/").split("/")[-1] if p.path and p.path != "/" else "index"
    base = slugify(last or "index")
    if p.query:
        q_hash = hashlib.md5(p.query.encode("utf-8")).hexdigest()[:8]
        base = f"{base}_{q_hash}"
    return base

def url_to_local_paths(url: str, base_dir: Path, with_timestamp: bool = True) -> tuple[Path, Optional[Path]]:
    p = urlparse(url)
    netloc = p.netloc.replace(":", "_")
    parts = [seg for seg in p.path.split("/") if seg]
    if not parts:
        parts = ["index"]
    dir_parts = [slugify(seg) for seg in parts[:-1]]
    stem = safe_stem_from_url(url)
    stable_dir = base_dir / netloc / Path(*dir_parts) if dir_parts else base_dir / netloc
    stable_path = stable_dir / f"{stem}.html"
    snapshot_path = None
    if with_timestamp:
        ts = dt.datetime.now().strftime("%Y%m%d_%H%M%S")
        snapshot_path = stable_dir / f"{stem}_{ts}.html"
    return stable_path, snapshot_path

# ===== 取得・保存のメイン関数 =====

def fetch_and_save_html(
    url: str,
    base_dir: Path = Path("saved_pages"),
    use_cache: bool = True,
    make_snapshot: bool = True,
    headers: Optional[dict] = None,
    timeout: tuple[int, int] = (5, 15),
) -> Path:
    """
    URLのHTMLをUTF-8で保存し、安定ファイルのパスを返す。
    - use_cache=True なら、既存の安定ファイルがあれば再取得をスキップ
    - make_snapshot=True なら、タイムスタンプ付きの履歴ファイルも保存
    """
    stable_path, snapshot_path = url_to_local_paths(url, base_dir=base_dir, with_timestamp=make_snapshot)
    stable_path.parent.mkdir(parents=True, exist_ok=True)

    if use_cache and stable_path.exists():
        print(f"[CACHE] Skip fetch: {url}")
        return stable_path

    if headers is None:
        headers = {
            "User-Agent": "Mozilla/5.0 (compatible; HTML-Save/1.0; +https://example.com/)",
            "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
        }

    print(f"[GET] {url}")
    try:
        resp = requests.get(url, headers=headers, timeout=timeout)
    except requests.RequestException as e:
        # ネットワークエラーは安定ファイルにエラー情報を残す(解析時のヒントになる)
        stable_path.write_text(f"<!-- FETCH ERROR: {e} -->", encoding="utf-8")
        print(f"[ERROR] Network error: {e}")
        return stable_path

    # ステータスコードを表示(200以外でも本文がある場合、デバッグ用に保存しておく)
    print(f"[STATUS] {resp.status_code}")
    resp.encoding = resp.apparent_encoding or "utf-8"
    text = resp.text

    # 安定ファイルをUTF-8で保存(上書き)
    stable_path.write_text(text, encoding="utf-8")
    print(f"[SAVE] Stable: {stable_path}")

    # 任意: スナップショットも保存(履歴管理)
    if make_snapshot and snapshot_path is not None:
        snapshot_path.write_text(text, encoding="utf-8")
        print(f"[SAVE] Snapshot: {snapshot_path}")

    return stable_path

# ===== ローカルHTMLでのパース(最小限) =====

def parse_local_html(html_path: Path) -> None:
    """
    ローカルのHTMLから<title>と<h1>の数を確認する簡易解析。
    Beautiful Soupの詳しい使い方は別記事で扱います。
    """
    from bs4 import BeautifulSoup

    html = html_path.read_text(encoding="utf-8", errors="ignore")
    soup = BeautifulSoup(html, "html.parser")

    title = soup.title.get_text(strip=True) if soup.title else "(no title)"
    h1_count = len(soup.find_all("h1"))

    print(f"[PARSE] title='{title}', h1_count={h1_count}")

if __name__ == "__main__":
    base = Path("saved_pages")

    # 1回目: 取得して保存
    p1 = fetch_and_save_html("https://example.com", base_dir=base, use_cache=True, make_snapshot=True)
    # 2回目: 既存があればスキップ(キャッシュヒット)
    p2 = fetch_and_save_html("https://example.com", base_dir=base, use_cache=True, make_snapshot=True)

    # ローカルHTMLでパーステスト
    parse_local_html(p1)

    # ステータスが200でない例(デバッグ用保存)
    p3 = fetch_and_save_html("https://httpstat.us/404", base_dir=base, use_cache=False, make_snapshot=False)
    print(f"[INFO] 404 body saved to: {p3}")
実行結果抜粋
[GET] https://example.com
[STATUS] 200
[SAVE] Stable: saved_pages/example.com/index.html
[SAVE] Snapshot: saved_pages/example.com/index_20250115_101530.html
[CACHE] Skip fetch: https://example.com
[PARSE] title='Example Domain', h1_count=1
[GET] https://httpstat.us/404
[STATUS] 404
[SAVE] Stable: saved_pages/httpstat.us/404.html
[INFO] 404 body saved to: saved_pages/httpstat.us/404.html

ローカルHTMLでパースをテスト

まずはローカルで選択子を固めます

上記のparse_local_htmlのように、保存済みHTMLをBeautiful Soupで読み込んで検証します。

選択子が固まってからネットワーク越しの本番フローに組み込みましょう。

これにより、通信待ちがなく、失敗時の反復が高速です。

既存ファイルなら再取得をスキップ

キャッシュを使って素早く再実行します

安定ファイル(stable).htmlが存在するなら取得をスキップします。

APIやページのレート制限にも優しく、再現性の高い開発が可能です。

必要に応じてuse_cache=Falseで強制再取得します。

エラー時に保存内容で原因特定

ステータス非200でも本文を残します

エラー画面にも有益な情報が含まれることがあります。

ステータスが200以外でも本文を保存しておけば、ブロック理由やWAFメッセージなどから対処の手掛かりが得られます。

ネットワーク例外時は簡易的なコメントを保存しておくと、失敗の痕跡が残せます。

法的・倫理的注意: スクレイピングは必ずサイトの利用規約やrobots.txt、著作権、アクセス間隔に配慮してください。

HTMLの保存・二次利用可否はサイトポリシーに従いましょう。

まとめ

HTMLをファイルに保存するだけで、再現性・速度・オフライン対応・デバッグ容易性が一度に手に入ります。

実装の要点は次のとおりです。

まずrequestsで取得しUTF-8で保存URLを安全なファイル名に正規化してドメイン/パスをフォルダでミラーします。

安定ファイルでキャッシュし、必要に応じてタイムスタンプ付きスナップショットも残せば履歴比較が容易です。

保存済みHTMLを使ってローカルでパースを詰め、問題が出たら保存内容から原因を特定しましょう。

これらの工夫で、スクレイピングの開発効率は大きく向上します。

この記事を書いた人
エーテリア編集部
エーテリア編集部

人気のPythonを初めて学ぶ方向けに、文法の基本から小さな自動化まで、実際に手を動かして理解できる記事を書いています。

クラウドSSLサイトシールは安心の証です。

URLをコピーしました!