中級者向け No.068

Webスクレイピングツール

URLを入力してBeautifulSoupでページのテーブル・リンク・テキストを抽出しCSV保存するスクレイピングGUI。

🎯 難易度: ★★★ 📦 ライブラリ: tkinter(標準ライブラリ), requests, beautifulsoup4 ⏱️ 制作時間: 30〜90分

1. アプリ概要

URLを入力してBeautifulSoupでページのテーブル・リンク・テキストを抽出しCSV保存するスクレイピングGUI。

このアプリはnetworkカテゴリの実践的なPythonアプリです。使用ライブラリは tkinter(標準ライブラリ)・requests・beautifulsoup4、難易度は ★★★ です。

このアプリは「ネットワーク」カテゴリです。ネットワーク連携は Python の代表的な活用領域で、I/O 待ちと UI 更新の関係や非同期処理の考え方が他の Web API 連携にも直接活きます。tkinter(標準ライブラリ)・requests・beautifulsoup4 を活かして実装するこの構造は、他のアプリにも応用が効きます。

処理の流れは「URL 入力 → robots.txt を確認 → requests で HTML を取得 → BeautifulSoup で解析 → 抽出結果を一覧表示 → CSV/JSON に保存」の6段です。抽出モードはタイトル・リンク・見出し・テーブル・テキスト・メタタグ・CSSセレクタの7種類で、いずれも BeautifulSoup の find_all()select()get_text() で取り出します。正規表現で HTML を切り出すのではなく、パーサーが組み立てたツリーをたどるので、属性の順序や改行位置が変わっても壊れにくくなります。

⚠️
このアプリは外部通信あり — 動かす前に規約と robots.txt を確認

このアプリは指定した URL へ実際に HTTP リクエストを送ります(他のアプリの多くと違い、外部サーバーに影響します)。既定の URL は当サイトの https://pythonland.tech/libraries.html にしてあります。他サイトを対象にする前に、①利用規約が自動取得を禁止していないか ②robots.txt が対象パスを許可しているかを必ず確認してください。規約が禁止していれば robots.txt が許可していても行ってはいけません。判断の手順はPython株価スクレイピングは禁止?規約とrobots.txtの確認方法で詳しく解説しています。

動かしながら読むことが理解の最短経路です。まずはコードをコピーして実行し、想定どおりに動くことを確認したうえで解説と照らし合わせてください。起動しただけでは通信しません(自動取得はせず、「▶ 取得」を押したときだけリクエストを送ります)。

カスタマイズでは「機能追加」「UI 改善」「エラー耐性」の三方向で考えると視野が広がります。練習問題にもそれぞれの方向の具体例を用意しています。

Webスクレイピングツール 実行画面(Windows)
実行画面(Windows)
Webスクレイピングツール 実行画面(Linux Mint)
実行画面(Linux Mint)

2. 機能一覧

  • 取得前に urllib.robotparser で robots.txt を自動判定し、Disallow なら通信せず中止(_check_robots()
  • 連続リクエストは MIN_INTERVAL_SEC = 3.0 秒以上(robots.txt に Crawl-delay があればその秒数以上)空ける(_wait_interval()
  • requests.get() で HTML を取得し、BeautifulSoup(html, "html.parser") で解析(取得は別スレッド・UI 更新は root.after()
  • 抽出は タイトル/リンク/見出し/テーブル/テキスト/メタタグ/CSSセレクタ の7モード(_extract_*()
  • 「▶ 取得」ボタン(_fetch())・「🔍 抽出」ボタン(_extract())・「📋 コピー」ボタン(_copy())・「💾 CSV保存」ボタン(_save_csv())・「JSON保存」ボタン(_save_json())で操作
  • イベント <<ListboxSelect>> から _on_result_select() を実行
  • tk.Listbox による一覧表示(スクロールバー付き)
  • JSON ファイルの保存(「JSON保存」ボタン)
  • CSV ファイルへの書き出し
  • filedialog.asksaveasfilename() によるファイル選択ダイアログ
  • messagebox.showerror()messagebox.showinfo()messagebox.showwarning() によるダイアログ通知
  • ウィンドウはタイトル「Webスクレイピングツール」・サイズ 1100x680 で起動

3. 事前準備・環境

ℹ️
動作確認環境

Windows 11 / Python 3.12.10 / requests 2.34.2 / beautifulsoup4 4.15.0(2026-09-09 に当サイトの libraries.html を対象に実行して確認)/Linux Mint 22.3 は 2026-09-10 に起動を確認(macOS は未検証)

Windows 11(実機)で、当サイトの https://pythonland.tech/libraries.html を対象に「取得 → 抽出 → CSV/JSON 保存」まで確認しています(全機能の網羅テストではありません)。Linux Mint 22.3(仮想マシン)では起動して画面が表示されるところまで確認しました(取得は Windows 11 での確認です)。macOS は未検証です。

  • Python 3.10 以上
  • OS: Windows 11(実機で起動・基本操作を確認)/Linux Mint 22.3(仮想マシンで起動を確認)
  • インターネット接続(「取得」時のみ使用)

インストールが必要なライブラリ

pip install requests beautifulsoup4

requests が HTML の取得、beautifulsoup4(import 名は bs4)が HTML の解析を担当します。パーサーは Python 標準の html.parser を使うので、lxml の追加インストールは不要です。ライブラリの位置づけはよく使うPythonライブラリ51選もあわせてどうぞ。

スクレイピングを始める前に確認する3点

⚖️
規約 → robots.txt → 法令の順で確認する

① 利用規約: 「自動取得」「スクレイピング」「クローリング」を禁止していないか。禁止なら robots.txt が許可していても行いません(規約は運営者との契約で、robots.txt より上位です)。
② robots.txt: 対象パスが Disallow になっていないか。このアプリは取得前に urllib.robotparser で自動判定し、Disallow のときは通信せずに中止します。Crawl-delay の指定があればその秒数以上を待ちます。「robots.txt を確認する」のチェックは、自分が管理するサイトやローカルの開発サーバー(robots.txt を置いていない環境)で試すためのものです。他人のサイトに対して外さないでください
③ 法令・マナー: 取得したデータの複製・再配布には著作権法上の制限があります。高頻度アクセスはサーバーに負荷をかけ、業務妨害と評価されるおそれもあります。判断に迷うなら取得しないでください。

実装側の作法も3つあります。アクセス間隔を空ける(このコードは MIN_INTERVAL_SEC = 3.0 秒以上、robots.txt に Crawl-delay があればその秒数以上を待ちます)、素性を名乗るUSER_AGENT にツール名と連絡先を書く。ブラウザのふりをしない)、必要最小限だけ取る(何ページも巡回せず、必要な1ページに絞る)。コード冒頭の USER_AGENT はツール名・URL・連絡先のすべてがプレースホルダのままなので、使う前に3つとも自分のものに書き換えてください(当サイトの URL を名乗ったまま使うと、苦情やアクセス遮断が当サイトに向いてしまいます)。詳しい判断基準はPython株価スクレイピングは禁止?規約とrobots.txtの確認方法にまとめています。

4. 完全なソースコード

💡
コードのコピー方法

右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。

📦 必要なライブラリをインストール(初回のみ)
pip install requests beautifulsoup4
app068.py
"""Webスクレイピングツール(requests + BeautifulSoup4)

【スクレイピング前に必ず確認すること】
  1. 利用規約: 対象サイトが自動取得(スクレイピング・クローリング)を
     禁止していないか。禁止されていれば robots.txt が許可していても
     行わない(規約は運営者との契約で、robots.txt より上位)。
  2. robots.txt: 対象パスが Disallow されていないか。本アプリは取得前に
     urllib.robotparser で自動判定し、Disallow のときは取得を中止する。
  3. アクセス間隔: 連続取得の間隔を空けてサーバーに負荷をかけない。
     本アプリは既定 3 秒以上、robots.txt に Crawl-delay があればその
     秒数以上を待ってから次のリクエストを送る。
  4. User-Agent: ブラウザのふりをせず素性を名乗る。下の USER_AGENT を
     自分の名前・連絡先に必ず書き換えてから使うこと。
  取得したデータの複製・再配布には著作権法上の制限があります。判断に
  迷うときは取得しないのが安全です。
"""
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import csv
import json
import threading
import time
import urllib.parse
import urllib.robotparser

import requests
from bs4 import BeautifulSoup

# 既定URLは自サイト。第三者サイトを既定値にしない(誤って負荷をかけないため)
DEFAULT_URL = "https://pythonland.tech/libraries.html"
# ツール名・URL・連絡先の3つを必ず自分のものに書き換える(ブラウザのふりをしない)
USER_AGENT = ("MyScrapingTool/1.0 "
              "(+https://example.com/your-page; contact: you@example.com)")
MIN_INTERVAL_SEC = 3.0   # 連続リクエストの最小間隔
TIMEOUT_SEC = 10
MAX_PREVIEW_CHARS = 100000
MAX_RESULTS = 2000


class App068:
    """Webスクレイピングツール"""

    def __init__(self, root):
        self.root = root
        self.root.title("Webスクレイピングツール")
        self.root.geometry("1100x680")
        self.root.configure(bg="#0d1117")
        self._results = []
        self._raw_html = ""
        self._soup = None
        self._base_url = ""
        self._last_request_at = 0.0
        self._fetching = False
        self._build_ui()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#161b22", pady=6)
        header.pack(fill=tk.X)
        tk.Label(header, text="🕷 Webスクレイピングツール",
                 font=("Noto Sans JP", 12, "bold"),
                 bg="#161b22", fg="#f0f6fc").pack(side=tk.LEFT, padx=12)
        tk.Label(header, text="規約とrobots.txtを確認してから取得します",
                 font=("Arial", 9), bg="#161b22",
                 fg="#8b949e").pack(side=tk.LEFT, padx=8)

        # URL入力
        url_f = tk.Frame(self.root, bg="#0d1117", pady=4)
        url_f.pack(fill=tk.X, padx=8)
        tk.Label(url_f, text="URL:", bg="#0d1117", fg="#c9d1d9",
                 font=("Arial", 9)).pack(side=tk.LEFT)
        self.url_var = tk.StringVar(value=DEFAULT_URL)
        ttk.Entry(url_f, textvariable=self.url_var,
                  width=55).pack(side=tk.LEFT, padx=4)
        ttk.Button(url_f, text="▶ 取得",
                   command=self._fetch).pack(side=tk.LEFT)
        self.robots_var = tk.BooleanVar(value=True)
        tk.Checkbutton(url_f, text="robots.txt を確認する",
                       variable=self.robots_var, bg="#0d1117", fg="#c9d1d9",
                       selectcolor="#161b22", activebackground="#0d1117",
                       activeforeground="#c9d1d9",
                       font=("Arial", 9)).pack(side=tk.LEFT, padx=6)

        # 抽出設定
        extract_f = tk.LabelFrame(self.root, text="抽出設定",
                                  bg="#161b22", fg="#c9d1d9",
                                  font=("Arial", 9), padx=8, pady=4)
        extract_f.pack(fill=tk.X, padx=8, pady=4)

        tk.Label(extract_f, text="モード:", bg="#161b22", fg="#c9d1d9",
                 font=("Arial", 9)).grid(row=0, column=0, sticky="w")
        self.mode_var = tk.StringVar(value="links")
        modes = [("タイトル", "title"), ("リンク", "links"),
                 ("見出し", "headings"), ("テーブル", "tables"),
                 ("テキスト", "text"), ("メタタグ", "meta"),
                 ("CSSセレクタ", "css")]
        for i, (label, val) in enumerate(modes):
            tk.Radiobutton(extract_f, text=label, variable=self.mode_var,
                           value=val, bg="#161b22", fg="#c9d1d9",
                           selectcolor="#0d1117",
                           activebackground="#161b22").grid(
                row=0, column=i + 1, padx=4)

        tk.Label(extract_f, text="CSSセレクタ:", bg="#161b22", fg="#c9d1d9",
                 font=("Arial", 9)).grid(row=1, column=0, sticky="w", pady=2)
        self.css_var = tk.StringVar(value="h2")
        ttk.Entry(extract_f, textvariable=self.css_var,
                  width=30).grid(row=1, column=1, columnspan=3,
                                 sticky="w", padx=4)
        tk.Label(extract_f, text="属性:", bg="#161b22", fg="#c9d1d9",
                 font=("Arial", 9)).grid(row=1, column=4, sticky="w")
        self.attr_var = tk.StringVar(value="")
        ttk.Entry(extract_f, textvariable=self.attr_var,
                  width=10).grid(row=1, column=5, sticky="w")
        ttk.Button(extract_f, text="🔍 抽出",
                   command=self._extract).grid(row=1, column=6, padx=8)

        # 結果エリア
        paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        paned.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)

        # 左: 結果リスト
        left = tk.Frame(paned, bg="#0d1117")
        paned.add(left, weight=1)
        tk.Label(left, text="抽出結果", bg="#0d1117", fg="#8b949e",
                 font=("Arial", 9)).pack(anchor="w")
        self.result_list = tk.Listbox(
            left, bg="#161b22", fg="#c9d1d9",
            selectbackground="#1f6feb", font=("Arial", 9),
            relief=tk.FLAT, activestyle="none")
        lsb = ttk.Scrollbar(left, command=self.result_list.yview)
        self.result_list.configure(yscrollcommand=lsb.set)
        lsb.pack(side=tk.RIGHT, fill=tk.Y)
        self.result_list.pack(fill=tk.BOTH, expand=True)
        self.result_list.bind("<<ListboxSelect>>", self._on_result_select)

        btn_f = tk.Frame(left, bg="#0d1117", pady=2)
        btn_f.pack(fill=tk.X)
        ttk.Button(btn_f, text="📋 コピー",
                   command=self._copy).pack(side=tk.LEFT, padx=2)
        ttk.Button(btn_f, text="💾 CSV保存",
                   command=self._save_csv).pack(side=tk.LEFT, padx=2)
        ttk.Button(btn_f, text="JSON保存",
                   command=self._save_json).pack(side=tk.LEFT, padx=2)

        # 右: HTMLプレビュー
        right = tk.Frame(paned, bg="#0d1117")
        paned.add(right, weight=1)
        tk.Label(right, text="HTML ソース", bg="#0d1117", fg="#8b949e",
                 font=("Arial", 9)).pack(anchor="w")
        self.html_text = tk.Text(right, bg="#161b22", fg="#6e7681",
                                 font=("Courier New", 8), relief=tk.FLAT,
                                 wrap=tk.NONE, state=tk.DISABLED)
        hxsb = ttk.Scrollbar(right, orient=tk.HORIZONTAL,
                             command=self.html_text.xview)
        hysb = ttk.Scrollbar(right, command=self.html_text.yview)
        self.html_text.configure(xscrollcommand=hxsb.set,
                                 yscrollcommand=hysb.set)
        hysb.pack(side=tk.RIGHT, fill=tk.Y)
        self.html_text.pack(fill=tk.BOTH, expand=True)
        hxsb.pack(fill=tk.X)

        self.progress = ttk.Progressbar(self.root, mode="indeterminate")
        self.progress.pack(fill=tk.X, padx=8)
        self.status_var = tk.StringVar(
            value="利用規約を確認したURLを入力して「取得」してください")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#21262d", fg="#8b949e", font=("Arial", 9),
                 anchor="w", padx=8).pack(fill=tk.X, side=tk.BOTTOM)

    # ------------------------------------------------------------------
    # 取得(ネットワーク)
    # ------------------------------------------------------------------
    def _fetch(self):
        """「▶ 取得」ボタン。入力を検証してワーカースレッドを起動する。"""
        url = self.url_var.get().strip()
        parsed = urllib.parse.urlparse(url)
        if parsed.scheme not in ("http", "https") or not parsed.netloc:
            messagebox.showerror(
                "エラー", "http:// または https:// で始まるURLを入力してください")
            return
        if self._fetching:
            self.status_var.set("取得中です。完了までお待ちください")
            return
        self._fetching = True
        self.progress.start()
        self.status_var.set("確認中: robots.txt")
        threading.Thread(target=self._do_fetch, args=(url,),
                         daemon=True).start()

    def _check_robots(self, url):
        """robots.txt を読んで (取得可否, Crawl-delay, 説明) を返す。"""
        parts = urllib.parse.urlparse(url)
        robots_url = f"{parts.scheme}://{parts.netloc}/robots.txt"
        parser = urllib.robotparser.RobotFileParser()
        parser.set_url(robots_url)
        try:
            parser.read()
        except Exception as e:
            # 読めなかったときは「許可された」とみなさない
            return False, None, f"robots.txt を確認できませんでした({e})"
        if not parser.can_fetch(USER_AGENT, url):
            return False, None, "robots.txt が Disallow のため中止しました"
        delay = parser.crawl_delay(USER_AGENT)
        return True, delay, "robots.txt: 許可"

    def _wait_interval(self, crawl_delay):
        """前回リクエストからの間隔を空ける(サーバー負荷対策)。"""
        need = MIN_INTERVAL_SEC
        if crawl_delay:
            need = max(need, float(crawl_delay))
        elapsed = time.monotonic() - self._last_request_at
        if self._last_request_at and elapsed < need:
            time.sleep(need - elapsed)

    def _do_fetch(self, url):
        """ワーカースレッド本体。UI 更新は after() でメインスレッドに戻す。"""
        try:
            note = "robots.txt: 未確認(利用規約は自分で確認すること)"
            crawl_delay = None
            if self.robots_var.get():
                allowed, crawl_delay, note = self._check_robots(url)
                if not allowed:
                    self.root.after(0, self._on_fetch_error, note)
                    return
            self._wait_interval(crawl_delay)
            self._last_request_at = time.monotonic()
            res = requests.get(url, headers={"User-Agent": USER_AGENT},
                               timeout=TIMEOUT_SEC)
            res.raise_for_status()
            if not res.encoding or "charset" not in \
                    res.headers.get("content-type", "").lower():
                res.encoding = res.apparent_encoding or res.encoding
            self.root.after(0, self._on_fetched, res.text, url, note)
        except Exception as e:
            self.root.after(0, self._on_fetch_error, f"取得エラー: {e}")

    def _on_fetched(self, html_text, url, note):
        """取得したHTMLを BeautifulSoup で解析し、プレビューに表示する。"""
        self._fetching = False
        self.progress.stop()
        self._raw_html = html_text
        self._base_url = url
        self._soup = BeautifulSoup(html_text, "html.parser")
        self.html_text.configure(state=tk.NORMAL)
        self.html_text.delete("1.0", tk.END)
        self.html_text.insert("1.0", html_text[:MAX_PREVIEW_CHARS])
        self.html_text.configure(state=tk.DISABLED)
        self.status_var.set(
            f"取得完了: {len(html_text):,} 文字 / {note}")
        self._extract()

    def _on_fetch_error(self, message):
        self._fetching = False
        self.progress.stop()
        self.status_var.set(message)

    # ------------------------------------------------------------------
    # 抽出(BeautifulSoup)
    # ------------------------------------------------------------------
    def _extract(self):
        """選択中のモードで解析結果を取り出し、リストに表示する。"""
        if self._soup is None:
            messagebox.showwarning("警告", "先にHTMLを取得してください")
            return
        mode = self.mode_var.get()
        extractors = {
            "title": self._extract_title,
            "links": self._extract_links,
            "headings": self._extract_headings,
            "tables": self._extract_tables,
            "text": self._extract_text,
            "meta": self._extract_meta,
            "css": self._extract_css,
        }
        try:
            results = extractors[mode]()
        except Exception as e:
            messagebox.showerror("エラー", f"抽出に失敗しました: {e}")
            return
        self._results = results[:MAX_RESULTS]
        self.result_list.delete(0, tk.END)
        for kind, value in self._results:
            self.result_list.insert(tk.END, f"[{kind}] {value[:120]}")
        note = ""
        if len(results) > MAX_RESULTS:
            note = f"(全 {len(results)} 件のうち先頭 {MAX_RESULTS} 件を表示)"
        self.status_var.set(
            f"抽出完了: {len(self._results)} 件(モード: {mode}){note}")

    def _extract_title(self):
        """<title> と代表的なメタ情報を取り出す。"""
        results = []
        if self._soup.title:
            results.append(("title", self._soup.title.get_text(strip=True)))
        desc = self._soup.find("meta", attrs={"name": "description"})
        if desc and desc.get("content"):
            results.append(("description", desc["content"]))
        h1 = self._soup.find("h1")
        if h1:
            results.append(("h1", h1.get_text(strip=True)))
        return results

    def _extract_links(self):
        """<a href> を絶対URLに直して取り出す。"""
        results = []
        for a in self._soup.find_all("a", href=True):
            href = urllib.parse.urljoin(self._base_url, a["href"])
            text = a.get_text(strip=True) or "(テキストなし)"
            results.append((text[:40], href))
        return results

    def _extract_headings(self):
        """h1〜h6 を文書順に取り出す。"""
        results = []
        for tag in self._soup.find_all(["h1", "h2", "h3", "h4", "h5", "h6"]):
            text = tag.get_text(strip=True)
            if text:
                results.append((tag.name.upper(), text))
        return results

    def _extract_tables(self):
        """<table> の行をセル区切りのテキストにして取り出す。"""
        results = []
        for t_no, table in enumerate(self._soup.find_all("table"), start=1):
            for row in table.find_all("tr"):
                cells = [c.get_text(" ", strip=True)
                         for c in row.find_all(["th", "td"])]
                line = " | ".join(cells).strip()
                if line:
                    results.append((f"表{t_no}", line))
        return results

    def _extract_text(self):
        """script・style を除いた本文テキストを行単位で取り出す。"""
        soup = BeautifulSoup(self._raw_html, "html.parser")
        for tag in soup(["script", "style", "noscript"]):
            tag.decompose()
        text = soup.get_text("\n", strip=True)
        return [("text", line) for line in text.split("\n") if line.strip()]

    def _extract_meta(self):
        """<meta> の name / property と content を取り出す。"""
        results = []
        for m in self._soup.find_all("meta"):
            key = m.get("name") or m.get("property") or m.get("http-equiv")
            if key and m.get("content"):
                results.append((key, m["content"]))
            elif m.get("charset"):
                results.append(("charset", m["charset"]))
        return results

    def _extract_css(self):
        """CSSセレクタで要素を選び、テキストまたは属性値を取り出す。"""
        selector = self.css_var.get().strip()
        if not selector:
            return [("エラー", "CSSセレクタを入力してください")]
        attr = self.attr_var.get().strip()
        results = []
        for el in self._soup.select(selector):
            if attr:
                if el.has_attr(attr):
                    results.append((f"{el.name}[{attr}]", str(el[attr])))
            else:
                text = el.get_text(" ", strip=True)
                if text:
                    results.append((el.name, text))
        return results

    # ------------------------------------------------------------------
    # 結果の利用
    # ------------------------------------------------------------------
    def _on_result_select(self, event=None):
        """選択行の全文をステータスバーに表示する(一覧は120文字で省略)。"""
        sel = self.result_list.curselection()
        if not sel or sel[0] >= len(self._results):
            return
        kind, value = self._results[sel[0]]
        self.status_var.set(f"[{kind}] {value}")

    def _copy(self):
        text = "\n".join(f"{k}\t{v}" for k, v in self._results)
        self.root.clipboard_clear()
        self.root.clipboard_append(text)
        self.status_var.set(f"{len(self._results)} 件をコピーしました")

    def _save_csv(self):
        path = filedialog.asksaveasfilename(
            defaultextension=".csv", filetypes=[("CSV", "*.csv")])
        if not path:
            return
        try:
            with open(path, "w", newline="", encoding="utf-8-sig") as f:
                writer = csv.writer(f)
                writer.writerow(["種別", "内容"])
                writer.writerows(self._results)
            messagebox.showinfo("完了", f"保存: {path}")
        except Exception as e:
            messagebox.showerror("エラー", str(e))

    def _save_json(self):
        path = filedialog.asksaveasfilename(
            defaultextension=".json", filetypes=[("JSON", "*.json")])
        if not path:
            return
        try:
            with open(path, "w", encoding="utf-8") as f:
                json.dump({"url": self._base_url or self.url_var.get(),
                           "mode": self.mode_var.get(),
                           "results": [{"kind": k, "value": v}
                                       for k, v in self._results]},
                          f, ensure_ascii=False, indent=2)
            messagebox.showinfo("完了", f"保存: {path}")
        except Exception as e:
            messagebox.showerror("エラー", str(e))


if __name__ == "__main__":
    root = tk.Tk()
    app = App068(root)
    root.mainloop()

このコードで使っている BeautifulSoup の要点

取得は requests、解析は BeautifulSoup と役割が分かれています。_do_fetch() で HTML を受け取り、_on_fetched() で1回だけツリーを組み立て、以降の抽出モードはそのツリー(self._soup)を使い回します。

res = requests.get(url, headers={"User-Agent": USER_AGENT},
                   timeout=TIMEOUT_SEC)
res.raise_for_status()                       # 4xx/5xx なら例外
...
self._soup = BeautifulSoup(html_text, "html.parser")

※ 上の2つは掲載コードからの抜粋です(_do_fetch()_on_fetched())。動かすときは「4. 完全なソースコード」をそのままコピーしてください。

パーサーには追加インストールの要らない html.parser(Python標準)を指定しています。抽出モードごとに、次の BeautifulSoup の API を使い分けています。

抽出モード使っている API取り出すもの
タイトルsoup.title / find("meta", attrs={"name": "description"})ページタイトル・description・最初の h1
リンクfind_all("a", href=True)urllib.parse.urljoin()リンク文字列と絶対URL
見出しfind_all(["h1", … , "h6"])tag.nameget_text(strip=True)
テーブルfind_all("table")find_all("tr")find_all(["th", "td"])表の行を | 区切りにしたテキスト
テキストdecompose() で script/style を除去 → get_text("\n", strip=True)本文テキスト(行単位)
メタタグfind_all("meta")namepropertyhttp-equivcontent
CSSセレクタselect(selector)has_attr(attr)選んだ要素のテキストまたは属性値

リンク抽出だけコードを見ておきます。href"index.html" のような相対パスで書かれていることが多いので、urljoin() で取得元URLと合成して絶対URLに直しています。

for a in self._soup.find_all("a", href=True):
    href = urllib.parse.urljoin(self._base_url, a["href"])
    text = a.get_text(strip=True) or "(テキストなし)"
    results.append((text[:40], href))

タグを正規表現で切り出す方法もありますが、属性の順序・改行・入れ子が少し変わるだけでパターンが外れます。BeautifulSoup はパーサーが組み立てたツリーをたどるため、同じ HTML でも壊れにくく、select() で CSS セレクタをそのまま使えるのが利点です。

🕒
通信部分で守っていること

_check_robots()urllib.robotparser で robots.txt を読み、can_fetch()False なら通信せずに中止します。robots.txt 自体を読めなかったときも「許可された」とはみなさず中止します。_wait_interval() は前回リクエストからの経過を見て、3秒(robots.txt に Crawl-delay があればその秒数)以上あくまで待ちます。通信は threading.Thread で別スレッドに逃がし、UI 更新は root.after() でメインスレッドに戻しています(tkinter のウィジェットを別スレッドから直接触らないため)。

5. コード解説

Webスクレイピングツールのコードを、実際に書かれている実装に沿って解説します。

クラス設計とコンストラクタ

App068 クラスにアプリの全機能をまとめています(メソッド20個・全420行)。__init__ ではタイトル「Webスクレイピングツール」とウィンドウサイズ 1100x680 を設定します。あわせて状態を保持する変数(self._raw_htmlself._soupself._base_urlself._last_request_at)を初期化し、最後に _build_ui() で画面を組み立てます。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

ウィジェット構成

画面は tk.Frame×5・tk.Label×9・ttk.Entry×3・ttk.Button×5・tk.Checkbuttontk.LabelFrametk.Radiobutton(ループで生成)・ttk.PanedWindowtk.Listboxttk.Scrollbar×3・tk.Textttk.Progressbar で構成しています。見出し付きの枠(LabelFrame)は「抽出設定」のラベルでエリアを区切っています。PanedWindow を使っているので、ペインの境界をマウスでドラッグして幅を変えられます。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

イベント処理とボタンの接続

「▶ 取得」ボタン(_fetch())・「🔍 抽出」ボタン(_extract())・「📋 コピー」ボタン(_copy())・「💾 CSV保存」ボタン(_save_csv())・「JSON保存」ボタン(_save_json())を command= で接続しています。また bind("<<ListboxSelect>>", ...) から _on_result_select() を呼べるようにイベントも登録しています。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

JSON への保存と読み込み

保存は「JSON保存」ボタンから _save_json() が呼ばれ、json.dump() で保存ダイアログで選んだファイルへ書き出します。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

例外処理

try-except で Exception を捕捉しています。あわせて messagebox.showerror()messagebox.showinfo()messagebox.showwarning() のダイアログで、ユーザーへの通知・確認を行います。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

6. ステップバイステップガイド

このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。

  1. 1
    ファイルを作成する

    新しいファイルを作成して app068.py と保存します。外部ライブラリ bs4requests を使います。先に pip install beautifulsoup4 requests を実行してください。(import 名と pip のパッケージ名が異なる点に注意:bs4beautifulsoup4 でインストールします)

  2. 2
    クラスの骨格を作る

    App068 クラスを定義し、__init__ と、末尾の root = tk.Tk()mainloop() の最小構成を書いて、まず空のウィンドウが出ることを確認します。

  3. 3
    ウィンドウを設定する

    title("Webスクレイピングツール")geometry("1100x680")configure(bg="#0d1117") をコンストラクタで設定します。

  4. 4
    画面部品を並べる

    _build_ui() の中で、tk.Frame×5・tk.Label×9・ttk.Entry×3・ttk.Button×5・tk.Checkbuttontk.LabelFrametk.Radiobutton(ループで生成)・ttk.PanedWindowtk.Listboxttk.Scrollbar×3・tk.Textttk.Progressbar を作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。

  5. 5
    イベントを接続する

    「2. 機能一覧」で挙げた各ボタンを command= で対応するメソッド(_fetch()_extract()_copy()_save_csv()_save_json())につなぎます。bind("<<ListboxSelect>>", ...) の登録も忘れずに。

  6. 6
    中心になるメソッドを実装する

    アプリの本体である _on_fetched()(14行)・_on_fetch_error()(4行)・_extract()(29行)など を実装します。

  7. 7
    動作確認する

    python app068.py で起動し、各ボタンが反応することを確認します。

7. カスタマイズアイデア

基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。

💡 ダークモードを追加する

bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。

💡 配色をまとめて管理する

背景色 #0d1117 をはじめ、色コードが各所に直書きされています。色を1つの辞書にまとめると、テーマの切り替えが1か所の変更で済むようになります。

💡 設定ダイアログ

フォントサイズや色などの設定をユーザーが変更できるオプションダイアログを追加しましょう。

8. よくある問題と解決法

❌ 文字のフォントが崩れる・見た目が違う

原因:このコードは font=("Noto Sans JP", ...) のようにフォント名を直接指定しています。環境にこのフォントが入っていないと、OS が代わりのフォントで表示するため、見本と見た目が変わることがあります。

解決法:動作には支障ありません。気になる場合は font 引数の名前を自分の OS に入っているフォントへ書き換えるか、font 引数を省略してください。

❌ ウィンドウの大きさが画面に合わない

原因:geometry("1100x680") の固定サイズで起動するためです。

解決法:この数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。

❌ ModuleNotFoundError: No module named 'bs4'

原因:BeautifulSoup が未インストールです。import 名(bs4)と pip のパッケージ名(beautifulsoup4)が違うので、pip install bs4 と打つと別のパッケージが入ってしまいます。

解決法:pip install requests beautifulsoup4 を実行してください。No module named 'requests' の場合も同じコマンドで解決します。

❌「robots.txt が Disallow のため中止しました」と出て取得できない

原因:対象サイトの robots.txt が、そのパスへの自動アクセスを拒否しています。このアプリは _check_robots() で判定し、拒否されていれば HTTP リクエストを送らずに止まります。robots.txt 自体を読めなかったとき(DNS 失敗・接続不可など)も「許可された」とはみなさず中止します。

解決法:この表示を回避するための改造はしないでください。対象サイトが自動取得を望んでいないというサインです。公式 API や配布データが無いかを探し、無ければその取得はあきらめる——それが安全な判断です。判断の手順はPython株価スクレイピングは禁止?規約とrobots.txtの確認方法を参照してください。

❌「取得エラー」が出る/抽出結果が 0 件になる

原因:取得エラーは URL の打ち間違い・接続不可・HTTP エラー(raise_for_status() が 4xx/5xx で例外を投げます)が主な原因です。取得は成功するのに 0 件になるときは、①CSS セレクタがそのページに存在しない ②内容が JavaScript で描画されている、のどちらかがよくあります。requests は JavaScript を実行しないため、ブラウザで見える要素が HTML には無い場合があります。

解決法:まず右側の「HTML ソース」で、狙う要素が本当に含まれているか検索してください。含まれていなければ、そのサイトの公式 API や JSON エンドポイントを探すほうが確実です。セレクタは「見出し」「リンク」など単純なモードで取得できるか確かめてから、CSS セレクタを詰めていくと切り分けが早く済みます。

9. 練習問題

アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。どちらの課題も、取得先は既定のまま(当サイトのページ)で確かめられます。練習のために他人のサイトを新しく対象にしないでください。robots.txt の確認と、次の取得まで3秒以上あける待機は外さないでください。課題を試すには requestsbeautifulsoup4 が要ります。

  1. 課題1:USER_AGENT がサンプルのままなら取得しない

    コードの USER_AGENT は、ツール名も連絡先もサンプルのままです。この状態で取りにいくと、相手のサーバーに素性を名乗れていません。書き換えるまで取得を止めるようにしてください。

    期待結果USER_AGENT を配布のままにして「▶ 取得」を押す。いまはそのまま通信し、帯が 取得完了: で始まり robots.txt: 許可 で終わる表示に変わる。直したあとは通信せず、「USER_AGENT を自分のものに書き換えてください」のようなエラーが出る。

    合格条件

    • 3か所とも自分のものに書き換えたあとは、これまでどおり取得できる
    • robots.txt の確認と待機の処理は、これまでどおり動く
    • 止めたときは帯が 取得完了: に変わらない(通信していない)

    ヒント①(どこを触るか): 触るのは _fetch() の1か所です。URLの形を確かめている if parsed.scheme not in ("http", "https") or not parsed.netloc: の下に足します。
    ヒント②(使うもの): サンプルのままかどうかは "example.com" in USER_AGENT で判定できます。すぐ上と同じ messagebox.showerror("エラー", ...) を出してから return します。
    つまずきやすい点: USER_AGENT はクラスの外にあるモジュール変数なので、self.USER_AGENT ではなくそのまま書きます。この値は _check_robots()parser.can_fetch(USER_AGENT, url) にも渡されます。名乗る名前を変えると robots.txt の判定結果も変わることがあり、名乗った途端に取得できなくなるサイトもあります(それが正しい動きです)。

  2. 課題2:間隔を空けて待っていることを帯に出す

    続けて取得すると、次の通信まで3秒以上待ちます。その間、帯は 確認中: robots.txt のままで、固まったように見えます。待っている理由を表示してください。

    期待結果:既定のURLのまま「▶ 取得」を押し、終わったらすぐもう一度押す。いまは帯が 確認中: robots.txt のまま数秒動かない。直したあとは「前回の取得から3秒空けています」のような表示に変わり、待ち終わってから取得に進む。

    合格条件

    • 1回目の取得は、これまでどおり待たずに始まる
    • 待つ長さは短くしない(MIN_INTERVAL_SECCrawl-delay の大きいほうを守る)
    • 待ち終わったあとは、これまでどおり 取得完了: の表示に変わる

    ヒント①(どこを触るか): 触るのは _wait_interval() の1か所です。time.sleep(need - elapsed) の直前に足します。
    ヒント②(使うもの): この関数は別スレッドから呼ばれるので、画面は self.root.after(0, self.status_var.set, "…") の形で更新します。_do_fetch()self.root.after(0, self._on_fetch_error, note) と同じ、関数と引数を渡す書き方です。
    つまずきやすい点: self._last_request_at は最初 0.0 で、if self._last_request_at and elapsed < need: の条件により1回目は待ちません。だから表示が出るのは2回目からです。待ち時間そのものを縮めたり MIN_INTERVAL_SEC を小さくしたりはしないでください。相手のサーバーに負荷をかけます。

  3. 課題3:新しいボタンを追加する

    既存のボタンと同じ書き方で新しいボタンを1つ足し、押されたときに動くメソッドを自作してみましょう。ボタンの作成と command= の接続がセットで身に付きます。

🚀
次に挑戦するアプリ

このアプリをマスターしたら、次のアプリに挑戦しましょう。

🐛
エラーが出て動かないときは

写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。

📖
次のレベルへ進む参考書

このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。