中級者向け No.34

Webスクレイパー UI

URLを入力してWebページのデータを抽出・CSV保存できるスクレイピングツール。BeautifulSoupの活用を学びます。

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

1. アプリ概要

URLを入力してWebページのデータを抽出・CSV保存できるスクレイピングツール。BeautifulSoupの活用を学びます。

このアプリは中級カテゴリに分類される実践的なGUIアプリです。使用ライブラリは tkinter(標準ライブラリ)・requests・beautifulsoup4 で、難易度は ★★☆ です。

Pythonでは tkinter を使うことで、クロスプラットフォームなGUIアプリを簡単に作成できます。このアプリを通じて、ウィジェットの配置・イベント処理・データ管理など、GUI開発の実践的なスキルを習得できます。

ソースコードは完全な動作状態で提供しており、コピーしてそのまま実行できます。まずは実行して動作を確認し、その後コードを読んで仕組みを理解していきましょう。カスタマイズセクションでは機能拡張のアイデアも紹介しています。

GUIアプリ開発は、プログラミングの楽しさを実感できる最も効果的な学習方法のひとつです。アプリを作ることで、変数・関数・クラス・イベント処理など、プログラミングの重要な概念が自然と身についていきます。このアプリをきっかけに、オリジナルアプリの開発にも挑戦してみてください。

2. 機能一覧

  • Webスクレイパー UIのメイン機能
  • 直感的なGUIインターフェース
  • 入力値のバリデーション
  • エラーハンドリング
  • 結果の見やすい表示
  • キーボードショートカット対応

3. 事前準備・環境

ℹ️
動作確認環境

Python 3.10 以上 / Windows・Mac・Linux すべて対応

以下の環境で動作確認しています。

  • Python 3.10 以上
  • OS: Windows 10/11・macOS 12+・Ubuntu 20.04+

4. 完全なソースコード

💡
コードのコピー方法

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

app34.py
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import urllib.request
import urllib.error
import threading
import csv
import io
import re
import os

try:
    from bs4 import BeautifulSoup
    BS4_AVAILABLE = True
except ImportError:
    BS4_AVAILABLE = False


class App34:
    """Webスクレイパー UI"""

    SAMPLE_URLS = [
        "https://quotes.toscrape.com/",
        "https://books.toscrape.com/",
        "https://httpbin.org/html",
    ]

    def __init__(self, root):
        self.root = root
        self.root.title("Webスクレイパー UI")
        self.root.geometry("920x640")
        self.root.configure(bg="#f8f9fc")
        self._results = []
        self._build_ui()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#e65100", pady=8)
        header.pack(fill=tk.X)
        tk.Label(header, text="🕷️ Webスクレイパー UI",
                 font=("Noto Sans JP", 13, "bold"),
                 bg="#e65100", fg="white").pack(side=tk.LEFT, padx=12)

        if not BS4_AVAILABLE:
            tk.Label(self.root,
                     text="⚠ BeautifulSoup4が未インストールです "
                          "(pip install beautifulsoup4 lxml)。"
                          "urllib のみの簡易スクレイピングになります。",
                     bg="#fff3cd", fg="#856404", font=("Arial", 9),
                     anchor="w", padx=8).pack(fill=tk.X)

        # URL入力
        url_f = tk.Frame(self.root, bg="#f8f9fc", pady=6)
        url_f.pack(fill=tk.X, padx=8)
        tk.Label(url_f, text="URL:", bg="#f8f9fc",
                 font=("Arial", 10)).pack(side=tk.LEFT)
        self.url_var = tk.StringVar(value="https://quotes.toscrape.com/")
        ttk.Entry(url_f, textvariable=self.url_var,
                  font=("Arial", 11), width=55).pack(side=tk.LEFT, padx=6,
                                                      fill=tk.X, expand=True)
        self.fetch_btn = ttk.Button(url_f, text="🌐 取得",
                                     command=self._fetch)
        self.fetch_btn.pack(side=tk.LEFT, padx=4)

        # サンプルURL
        sample_f = tk.Frame(self.root, bg="#f8f9fc")
        sample_f.pack(fill=tk.X, padx=8, pady=2)
        tk.Label(sample_f, text="サンプル:", bg="#f8f9fc",
                 font=("Arial", 9), fg="#666").pack(side=tk.LEFT)
        for url in self.SAMPLE_URLS:
            ttk.Button(sample_f, text=url[:40],
                       command=lambda u=url: (self.url_var.set(u), self._fetch())
                       ).pack(side=tk.LEFT, padx=4)

        # メインエリア
        paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        paned.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)

        # 左: 抽出設定
        left = ttk.LabelFrame(paned, text="抽出設定", padding=8)
        paned.add(left, weight=2)
        self._build_settings(left)

        # 右: 結果
        right = tk.Frame(paned, bg="#f8f9fc")
        paned.add(right, weight=3)
        self._build_results(right)

        # プログレス
        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="#dde", font=("Arial", 9), anchor="w", padx=8
                 ).pack(fill=tk.X, side=tk.BOTTOM)

    def _build_settings(self, parent):
        lbl_s = {"bg": parent.cget("background"), "font": ("Arial", 10)}

        # 抽出対象
        ttk.LabelFrame(parent, text="").pack()  # ダミー
        tk.Label(parent, text="抽出対象:", **lbl_s).pack(anchor="w", pady=2)
        self.extract_var = tk.StringVar(value="すべてのテキスト")
        for val in ["すべてのテキスト", "リンク一覧", "画像URL一覧",
                    "テーブルデータ", "見出し一覧", "CSSセレクタ指定"]:
            ttk.Radiobutton(parent, text=val, variable=self.extract_var,
                            value=val,
                            command=self._on_extract_change).pack(anchor="w")

        # CSSセレクタ
        self.selector_frame = tk.Frame(parent, bg=parent.cget("background"))
        self.selector_frame.pack(fill=tk.X, pady=4)
        tk.Label(self.selector_frame, text="CSSセレクタ:", **lbl_s,
                 bg=self.selector_frame.cget("bg")).pack(anchor="w")
        self.selector_var = tk.StringVar(value=".text")
        ttk.Entry(self.selector_frame, textvariable=self.selector_var,
                  width=22).pack(fill=tk.X, padx=2)
        tk.Label(self.selector_frame,
                 text="例: h1, .title, #main p, a[href]",
                 bg=self.selector_frame.cget("bg"),
                 fg="#666", font=("Arial", 8)).pack(anchor="w")
        self.selector_frame.pack_forget()

        # 最大件数
        row = tk.Frame(parent, bg=parent.cget("background"))
        row.pack(fill=tk.X, pady=6)
        tk.Label(row, text="最大件数:", **lbl_s,
                 bg=row.cget("bg")).pack(side=tk.LEFT)
        self.max_var = tk.IntVar(value=100)
        ttk.Spinbox(row, from_=1, to=9999,
                    textvariable=self.max_var, width=8).pack(side=tk.LEFT, padx=4)

        # 文字コード
        enc_row = tk.Frame(parent, bg=parent.cget("background"))
        enc_row.pack(fill=tk.X, pady=2)
        tk.Label(enc_row, text="エンコード:", **lbl_s,
                 bg=enc_row.cget("bg")).pack(side=tk.LEFT)
        self.enc_var = tk.StringVar(value="utf-8")
        ttk.Combobox(enc_row, textvariable=self.enc_var,
                     values=["utf-8", "shift_jis", "euc-jp", "iso-8859-1",
                              "自動検出"],
                     state="readonly", width=12).pack(side=tk.LEFT, padx=4)

        # ユーザーエージェント
        ua_row = tk.Frame(parent, bg=parent.cget("background"))
        ua_row.pack(fill=tk.X, pady=2)
        tk.Label(ua_row, text="UA:", **lbl_s,
                 bg=ua_row.cget("bg")).pack(side=tk.LEFT)
        self.ua_var = tk.StringVar(
            value="Mozilla/5.0 (compatible; PythonScraper/1.0)")
        ttk.Entry(ua_row, textvariable=self.ua_var, width=22).pack(
            side=tk.LEFT, padx=4, fill=tk.X, expand=True)

        ttk.Button(parent, text="▶ 抽出実行",
                   command=self._extract).pack(pady=8, fill=tk.X)
        ttk.Button(parent, text="💾 CSV保存",
                   command=self._save_csv).pack(fill=tk.X)

    def _build_results(self, parent):
        notebook = ttk.Notebook(parent)
        notebook.pack(fill=tk.BOTH, expand=True)

        # 抽出結果タブ
        result_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(result_tab, text="抽出結果")
        cols = ("no", "value", "extra")
        self.result_tree = ttk.Treeview(result_tab, columns=cols,
                                         show="headings", height=18)
        for c, h, w in [("no", "#", 40), ("value", "値", 300), ("extra", "追加情報", 200)]:
            self.result_tree.heading(c, text=h)
            self.result_tree.column(c, width=w, minwidth=30)
        sb = ttk.Scrollbar(result_tab, command=self.result_tree.yview)
        self.result_tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.result_tree.pack(fill=tk.BOTH, expand=True)

        # HTMLソースタブ
        src_tab = tk.Frame(notebook, bg="#0d1117")
        notebook.add(src_tab, text="HTMLソース")
        self.source_text = tk.Text(src_tab, bg="#0d1117", fg="#c9d1d9",
                                    font=("Courier New", 10), relief=tk.FLAT,
                                    state=tk.DISABLED, wrap=tk.NONE)
        h_sb = ttk.Scrollbar(src_tab, orient=tk.HORIZONTAL,
                             command=self.source_text.xview)
        v_sb = ttk.Scrollbar(src_tab, orient=tk.VERTICAL,
                             command=self.source_text.yview)
        self.source_text.configure(xscrollcommand=h_sb.set,
                                   yscrollcommand=v_sb.set)
        v_sb.pack(side=tk.RIGHT, fill=tk.Y)
        h_sb.pack(side=tk.BOTTOM, fill=tk.X)
        self.source_text.pack(fill=tk.BOTH, expand=True)

    def _on_extract_change(self):
        if self.extract_var.get() == "CSSセレクタ指定":
            self.selector_frame.pack(fill=tk.X, pady=4)
        else:
            self.selector_frame.pack_forget()

    def _fetch(self):
        url = self.url_var.get().strip()
        if not url:
            messagebox.showwarning("警告", "URLを入力してください")
            return
        self.fetch_btn.config(state=tk.DISABLED)
        self.progress.start(10)
        self.status_var.set("取得中...")
        threading.Thread(target=self._do_fetch, args=(url,), daemon=True).start()

    def _do_fetch(self, url):
        try:
            req = urllib.request.Request(url, headers={
                "User-Agent": self.ua_var.get()})
            with urllib.request.urlopen(req, timeout=15) as resp:
                raw = resp.read()
                enc = self.enc_var.get()
                if enc == "自動検出":
                    # Content-Typeから推測
                    ct = resp.headers.get("Content-Type", "")
                    m = re.search(r"charset=([^\s;]+)", ct, re.IGNORECASE)
                    enc = m.group(1) if m else "utf-8"
                html = raw.decode(enc, errors="replace")
            self.root.after(0, self._show_source, html)
        except Exception as e:
            self.root.after(0, self.status_var.set, f"エラー: {e}")
            self.root.after(0, self.progress.stop)
            self.root.after(0, self.fetch_btn.config, {"state": tk.NORMAL})

    def _show_source(self, html):
        self._html = html
        self.source_text.config(state=tk.NORMAL)
        self.source_text.delete("1.0", tk.END)
        self.source_text.insert("1.0", html[:200000])  # 上限200KB
        self.source_text.config(state=tk.DISABLED)
        self.progress.stop()
        self.fetch_btn.config(state=tk.NORMAL)
        self.status_var.set(f"取得完了: {len(html)} 文字")

    def _extract(self):
        if not hasattr(self, "_html") or not self._html:
            messagebox.showwarning("警告", "先にページを取得してください")
            return
        mode = self.extract_var.get()
        html = self._html
        max_n = self.max_var.get()
        results = []

        if not BS4_AVAILABLE:
            # 簡易版: 正規表現
            if mode == "リンク一覧":
                for m in re.finditer(r'href=["\']([^"\']+)["\']', html):
                    results.append((m.group(1), ""))
            elif mode == "画像URL一覧":
                for m in re.finditer(r'src=["\']([^"\']+\.(?:png|jpg|jpeg|gif|webp|svg))["\']',
                                     html, re.IGNORECASE):
                    results.append((m.group(1), ""))
            else:
                # タグを除去してテキスト抽出
                text = re.sub(r"<[^>]+>", "", html)
                lines = [l.strip() for l in text.splitlines() if l.strip()]
                results = [(l, "") for l in lines]
        else:
            soup = BeautifulSoup(html, "lxml" if self._try_lxml() else "html.parser")
            if mode == "すべてのテキスト":
                for tag in soup.find_all(text=True):
                    t = tag.strip()
                    if t and tag.parent.name not in ["script", "style", "meta"]:
                        results.append((t, tag.parent.name))
            elif mode == "リンク一覧":
                for a in soup.find_all("a", href=True):
                    results.append((a["href"], a.get_text(strip=True)))
            elif mode == "画像URL一覧":
                for img in soup.find_all("img"):
                    src = img.get("src", "")
                    alt = img.get("alt", "")
                    results.append((src, alt))
            elif mode == "テーブルデータ":
                for table in soup.find_all("table"):
                    for row in table.find_all("tr"):
                        cells = [td.get_text(strip=True)
                                 for td in row.find_all(["td", "th"])]
                        if cells:
                            results.append((" | ".join(cells), ""))
            elif mode == "見出し一覧":
                for h in soup.find_all(["h1", "h2", "h3", "h4", "h5", "h6"]):
                    results.append((h.get_text(strip=True), h.name))
            elif mode == "CSSセレクタ指定":
                selector = self.selector_var.get().strip()
                if selector:
                    for elem in soup.select(selector):
                        results.append((elem.get_text(strip=True),
                                        elem.name))

        self._results = results[:max_n]
        self._show_results()

    def _try_lxml(self):
        try:
            import lxml
            return True
        except ImportError:
            return False

    def _show_results(self):
        self.result_tree.delete(*self.result_tree.get_children())
        for i, (val, extra) in enumerate(self._results, 1):
            self.result_tree.insert("", "end", values=(i, val[:200], extra[:100]))
        self.status_var.set(f"抽出完了: {len(self._results)} 件")

    def _save_csv(self):
        if not self._results:
            messagebox.showwarning("警告", "抽出結果がありません")
            return
        path = filedialog.asksaveasfilename(
            defaultextension=".csv",
            filetypes=[("CSV", "*.csv"), ("すべて", "*.*")])
        if path:
            try:
                with open(path, "w", newline="", encoding="utf-8-sig") as f:
                    writer = csv.writer(f)
                    writer.writerow(["No", "値", "追加情報"])
                    for i, (val, extra) in enumerate(self._results, 1):
                        writer.writerow([i, val, extra])
                self.status_var.set(f"CSV保存: {path}")
            except Exception as e:
                messagebox.showerror("エラー", str(e))


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

5. コード解説

Webスクレイパー UIのコードを詳しく解説します。クラスベースの設計で各機能を整理して実装しています。

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

App34クラスにアプリの全機能をまとめています。__init__メソッドでウィンドウの基本設定を行い、_build_ui()でUI構築、process()でメイン処理を担当します。この分離により、各メソッドの責任が明確になりコードが読みやすくなります。

import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import urllib.request
import urllib.error
import threading
import csv
import io
import re
import os

try:
    from bs4 import BeautifulSoup
    BS4_AVAILABLE = True
except ImportError:
    BS4_AVAILABLE = False


class App34:
    """Webスクレイパー UI"""

    SAMPLE_URLS = [
        "https://quotes.toscrape.com/",
        "https://books.toscrape.com/",
        "https://httpbin.org/html",
    ]

    def __init__(self, root):
        self.root = root
        self.root.title("Webスクレイパー UI")
        self.root.geometry("920x640")
        self.root.configure(bg="#f8f9fc")
        self._results = []
        self._build_ui()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#e65100", pady=8)
        header.pack(fill=tk.X)
        tk.Label(header, text="🕷️ Webスクレイパー UI",
                 font=("Noto Sans JP", 13, "bold"),
                 bg="#e65100", fg="white").pack(side=tk.LEFT, padx=12)

        if not BS4_AVAILABLE:
            tk.Label(self.root,
                     text="⚠ BeautifulSoup4が未インストールです "
                          "(pip install beautifulsoup4 lxml)。"
                          "urllib のみの簡易スクレイピングになります。",
                     bg="#fff3cd", fg="#856404", font=("Arial", 9),
                     anchor="w", padx=8).pack(fill=tk.X)

        # URL入力
        url_f = tk.Frame(self.root, bg="#f8f9fc", pady=6)
        url_f.pack(fill=tk.X, padx=8)
        tk.Label(url_f, text="URL:", bg="#f8f9fc",
                 font=("Arial", 10)).pack(side=tk.LEFT)
        self.url_var = tk.StringVar(value="https://quotes.toscrape.com/")
        ttk.Entry(url_f, textvariable=self.url_var,
                  font=("Arial", 11), width=55).pack(side=tk.LEFT, padx=6,
                                                      fill=tk.X, expand=True)
        self.fetch_btn = ttk.Button(url_f, text="🌐 取得",
                                     command=self._fetch)
        self.fetch_btn.pack(side=tk.LEFT, padx=4)

        # サンプルURL
        sample_f = tk.Frame(self.root, bg="#f8f9fc")
        sample_f.pack(fill=tk.X, padx=8, pady=2)
        tk.Label(sample_f, text="サンプル:", bg="#f8f9fc",
                 font=("Arial", 9), fg="#666").pack(side=tk.LEFT)
        for url in self.SAMPLE_URLS:
            ttk.Button(sample_f, text=url[:40],
                       command=lambda u=url: (self.url_var.set(u), self._fetch())
                       ).pack(side=tk.LEFT, padx=4)

        # メインエリア
        paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        paned.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)

        # 左: 抽出設定
        left = ttk.LabelFrame(paned, text="抽出設定", padding=8)
        paned.add(left, weight=2)
        self._build_settings(left)

        # 右: 結果
        right = tk.Frame(paned, bg="#f8f9fc")
        paned.add(right, weight=3)
        self._build_results(right)

        # プログレス
        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="#dde", font=("Arial", 9), anchor="w", padx=8
                 ).pack(fill=tk.X, side=tk.BOTTOM)

    def _build_settings(self, parent):
        lbl_s = {"bg": parent.cget("background"), "font": ("Arial", 10)}

        # 抽出対象
        ttk.LabelFrame(parent, text="").pack()  # ダミー
        tk.Label(parent, text="抽出対象:", **lbl_s).pack(anchor="w", pady=2)
        self.extract_var = tk.StringVar(value="すべてのテキスト")
        for val in ["すべてのテキスト", "リンク一覧", "画像URL一覧",
                    "テーブルデータ", "見出し一覧", "CSSセレクタ指定"]:
            ttk.Radiobutton(parent, text=val, variable=self.extract_var,
                            value=val,
                            command=self._on_extract_change).pack(anchor="w")

        # CSSセレクタ
        self.selector_frame = tk.Frame(parent, bg=parent.cget("background"))
        self.selector_frame.pack(fill=tk.X, pady=4)
        tk.Label(self.selector_frame, text="CSSセレクタ:", **lbl_s,
                 bg=self.selector_frame.cget("bg")).pack(anchor="w")
        self.selector_var = tk.StringVar(value=".text")
        ttk.Entry(self.selector_frame, textvariable=self.selector_var,
                  width=22).pack(fill=tk.X, padx=2)
        tk.Label(self.selector_frame,
                 text="例: h1, .title, #main p, a[href]",
                 bg=self.selector_frame.cget("bg"),
                 fg="#666", font=("Arial", 8)).pack(anchor="w")
        self.selector_frame.pack_forget()

        # 最大件数
        row = tk.Frame(parent, bg=parent.cget("background"))
        row.pack(fill=tk.X, pady=6)
        tk.Label(row, text="最大件数:", **lbl_s,
                 bg=row.cget("bg")).pack(side=tk.LEFT)
        self.max_var = tk.IntVar(value=100)
        ttk.Spinbox(row, from_=1, to=9999,
                    textvariable=self.max_var, width=8).pack(side=tk.LEFT, padx=4)

        # 文字コード
        enc_row = tk.Frame(parent, bg=parent.cget("background"))
        enc_row.pack(fill=tk.X, pady=2)
        tk.Label(enc_row, text="エンコード:", **lbl_s,
                 bg=enc_row.cget("bg")).pack(side=tk.LEFT)
        self.enc_var = tk.StringVar(value="utf-8")
        ttk.Combobox(enc_row, textvariable=self.enc_var,
                     values=["utf-8", "shift_jis", "euc-jp", "iso-8859-1",
                              "自動検出"],
                     state="readonly", width=12).pack(side=tk.LEFT, padx=4)

        # ユーザーエージェント
        ua_row = tk.Frame(parent, bg=parent.cget("background"))
        ua_row.pack(fill=tk.X, pady=2)
        tk.Label(ua_row, text="UA:", **lbl_s,
                 bg=ua_row.cget("bg")).pack(side=tk.LEFT)
        self.ua_var = tk.StringVar(
            value="Mozilla/5.0 (compatible; PythonScraper/1.0)")
        ttk.Entry(ua_row, textvariable=self.ua_var, width=22).pack(
            side=tk.LEFT, padx=4, fill=tk.X, expand=True)

        ttk.Button(parent, text="▶ 抽出実行",
                   command=self._extract).pack(pady=8, fill=tk.X)
        ttk.Button(parent, text="💾 CSV保存",
                   command=self._save_csv).pack(fill=tk.X)

    def _build_results(self, parent):
        notebook = ttk.Notebook(parent)
        notebook.pack(fill=tk.BOTH, expand=True)

        # 抽出結果タブ
        result_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(result_tab, text="抽出結果")
        cols = ("no", "value", "extra")
        self.result_tree = ttk.Treeview(result_tab, columns=cols,
                                         show="headings", height=18)
        for c, h, w in [("no", "#", 40), ("value", "値", 300), ("extra", "追加情報", 200)]:
            self.result_tree.heading(c, text=h)
            self.result_tree.column(c, width=w, minwidth=30)
        sb = ttk.Scrollbar(result_tab, command=self.result_tree.yview)
        self.result_tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.result_tree.pack(fill=tk.BOTH, expand=True)

        # HTMLソースタブ
        src_tab = tk.Frame(notebook, bg="#0d1117")
        notebook.add(src_tab, text="HTMLソース")
        self.source_text = tk.Text(src_tab, bg="#0d1117", fg="#c9d1d9",
                                    font=("Courier New", 10), relief=tk.FLAT,
                                    state=tk.DISABLED, wrap=tk.NONE)
        h_sb = ttk.Scrollbar(src_tab, orient=tk.HORIZONTAL,
                             command=self.source_text.xview)
        v_sb = ttk.Scrollbar(src_tab, orient=tk.VERTICAL,
                             command=self.source_text.yview)
        self.source_text.configure(xscrollcommand=h_sb.set,
                                   yscrollcommand=v_sb.set)
        v_sb.pack(side=tk.RIGHT, fill=tk.Y)
        h_sb.pack(side=tk.BOTTOM, fill=tk.X)
        self.source_text.pack(fill=tk.BOTH, expand=True)

    def _on_extract_change(self):
        if self.extract_var.get() == "CSSセレクタ指定":
            self.selector_frame.pack(fill=tk.X, pady=4)
        else:
            self.selector_frame.pack_forget()

    def _fetch(self):
        url = self.url_var.get().strip()
        if not url:
            messagebox.showwarning("警告", "URLを入力してください")
            return
        self.fetch_btn.config(state=tk.DISABLED)
        self.progress.start(10)
        self.status_var.set("取得中...")
        threading.Thread(target=self._do_fetch, args=(url,), daemon=True).start()

    def _do_fetch(self, url):
        try:
            req = urllib.request.Request(url, headers={
                "User-Agent": self.ua_var.get()})
            with urllib.request.urlopen(req, timeout=15) as resp:
                raw = resp.read()
                enc = self.enc_var.get()
                if enc == "自動検出":
                    # Content-Typeから推測
                    ct = resp.headers.get("Content-Type", "")
                    m = re.search(r"charset=([^\s;]+)", ct, re.IGNORECASE)
                    enc = m.group(1) if m else "utf-8"
                html = raw.decode(enc, errors="replace")
            self.root.after(0, self._show_source, html)
        except Exception as e:
            self.root.after(0, self.status_var.set, f"エラー: {e}")
            self.root.after(0, self.progress.stop)
            self.root.after(0, self.fetch_btn.config, {"state": tk.NORMAL})

    def _show_source(self, html):
        self._html = html
        self.source_text.config(state=tk.NORMAL)
        self.source_text.delete("1.0", tk.END)
        self.source_text.insert("1.0", html[:200000])  # 上限200KB
        self.source_text.config(state=tk.DISABLED)
        self.progress.stop()
        self.fetch_btn.config(state=tk.NORMAL)
        self.status_var.set(f"取得完了: {len(html)} 文字")

    def _extract(self):
        if not hasattr(self, "_html") or not self._html:
            messagebox.showwarning("警告", "先にページを取得してください")
            return
        mode = self.extract_var.get()
        html = self._html
        max_n = self.max_var.get()
        results = []

        if not BS4_AVAILABLE:
            # 簡易版: 正規表現
            if mode == "リンク一覧":
                for m in re.finditer(r'href=["\']([^"\']+)["\']', html):
                    results.append((m.group(1), ""))
            elif mode == "画像URL一覧":
                for m in re.finditer(r'src=["\']([^"\']+\.(?:png|jpg|jpeg|gif|webp|svg))["\']',
                                     html, re.IGNORECASE):
                    results.append((m.group(1), ""))
            else:
                # タグを除去してテキスト抽出
                text = re.sub(r"<[^>]+>", "", html)
                lines = [l.strip() for l in text.splitlines() if l.strip()]
                results = [(l, "") for l in lines]
        else:
            soup = BeautifulSoup(html, "lxml" if self._try_lxml() else "html.parser")
            if mode == "すべてのテキスト":
                for tag in soup.find_all(text=True):
                    t = tag.strip()
                    if t and tag.parent.name not in ["script", "style", "meta"]:
                        results.append((t, tag.parent.name))
            elif mode == "リンク一覧":
                for a in soup.find_all("a", href=True):
                    results.append((a["href"], a.get_text(strip=True)))
            elif mode == "画像URL一覧":
                for img in soup.find_all("img"):
                    src = img.get("src", "")
                    alt = img.get("alt", "")
                    results.append((src, alt))
            elif mode == "テーブルデータ":
                for table in soup.find_all("table"):
                    for row in table.find_all("tr"):
                        cells = [td.get_text(strip=True)
                                 for td in row.find_all(["td", "th"])]
                        if cells:
                            results.append((" | ".join(cells), ""))
            elif mode == "見出し一覧":
                for h in soup.find_all(["h1", "h2", "h3", "h4", "h5", "h6"]):
                    results.append((h.get_text(strip=True), h.name))
            elif mode == "CSSセレクタ指定":
                selector = self.selector_var.get().strip()
                if selector:
                    for elem in soup.select(selector):
                        results.append((elem.get_text(strip=True),
                                        elem.name))

        self._results = results[:max_n]
        self._show_results()

    def _try_lxml(self):
        try:
            import lxml
            return True
        except ImportError:
            return False

    def _show_results(self):
        self.result_tree.delete(*self.result_tree.get_children())
        for i, (val, extra) in enumerate(self._results, 1):
            self.result_tree.insert("", "end", values=(i, val[:200], extra[:100]))
        self.status_var.set(f"抽出完了: {len(self._results)} 件")

    def _save_csv(self):
        if not self._results:
            messagebox.showwarning("警告", "抽出結果がありません")
            return
        path = filedialog.asksaveasfilename(
            defaultextension=".csv",
            filetypes=[("CSV", "*.csv"), ("すべて", "*.*")])
        if path:
            try:
                with open(path, "w", newline="", encoding="utf-8-sig") as f:
                    writer = csv.writer(f)
                    writer.writerow(["No", "値", "追加情報"])
                    for i, (val, extra) in enumerate(self._results, 1):
                        writer.writerow([i, val, extra])
                self.status_var.set(f"CSV保存: {path}")
            except Exception as e:
                messagebox.showerror("エラー", str(e))


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

LabelFrameによるセクション分け

ttk.LabelFrame を使うことで、入力エリアと結果エリアを視覚的に分けられます。padding引数でフレーム内の余白を設定し、見やすいレイアウトを実現しています。

import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import urllib.request
import urllib.error
import threading
import csv
import io
import re
import os

try:
    from bs4 import BeautifulSoup
    BS4_AVAILABLE = True
except ImportError:
    BS4_AVAILABLE = False


class App34:
    """Webスクレイパー UI"""

    SAMPLE_URLS = [
        "https://quotes.toscrape.com/",
        "https://books.toscrape.com/",
        "https://httpbin.org/html",
    ]

    def __init__(self, root):
        self.root = root
        self.root.title("Webスクレイパー UI")
        self.root.geometry("920x640")
        self.root.configure(bg="#f8f9fc")
        self._results = []
        self._build_ui()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#e65100", pady=8)
        header.pack(fill=tk.X)
        tk.Label(header, text="🕷️ Webスクレイパー UI",
                 font=("Noto Sans JP", 13, "bold"),
                 bg="#e65100", fg="white").pack(side=tk.LEFT, padx=12)

        if not BS4_AVAILABLE:
            tk.Label(self.root,
                     text="⚠ BeautifulSoup4が未インストールです "
                          "(pip install beautifulsoup4 lxml)。"
                          "urllib のみの簡易スクレイピングになります。",
                     bg="#fff3cd", fg="#856404", font=("Arial", 9),
                     anchor="w", padx=8).pack(fill=tk.X)

        # URL入力
        url_f = tk.Frame(self.root, bg="#f8f9fc", pady=6)
        url_f.pack(fill=tk.X, padx=8)
        tk.Label(url_f, text="URL:", bg="#f8f9fc",
                 font=("Arial", 10)).pack(side=tk.LEFT)
        self.url_var = tk.StringVar(value="https://quotes.toscrape.com/")
        ttk.Entry(url_f, textvariable=self.url_var,
                  font=("Arial", 11), width=55).pack(side=tk.LEFT, padx=6,
                                                      fill=tk.X, expand=True)
        self.fetch_btn = ttk.Button(url_f, text="🌐 取得",
                                     command=self._fetch)
        self.fetch_btn.pack(side=tk.LEFT, padx=4)

        # サンプルURL
        sample_f = tk.Frame(self.root, bg="#f8f9fc")
        sample_f.pack(fill=tk.X, padx=8, pady=2)
        tk.Label(sample_f, text="サンプル:", bg="#f8f9fc",
                 font=("Arial", 9), fg="#666").pack(side=tk.LEFT)
        for url in self.SAMPLE_URLS:
            ttk.Button(sample_f, text=url[:40],
                       command=lambda u=url: (self.url_var.set(u), self._fetch())
                       ).pack(side=tk.LEFT, padx=4)

        # メインエリア
        paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        paned.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)

        # 左: 抽出設定
        left = ttk.LabelFrame(paned, text="抽出設定", padding=8)
        paned.add(left, weight=2)
        self._build_settings(left)

        # 右: 結果
        right = tk.Frame(paned, bg="#f8f9fc")
        paned.add(right, weight=3)
        self._build_results(right)

        # プログレス
        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="#dde", font=("Arial", 9), anchor="w", padx=8
                 ).pack(fill=tk.X, side=tk.BOTTOM)

    def _build_settings(self, parent):
        lbl_s = {"bg": parent.cget("background"), "font": ("Arial", 10)}

        # 抽出対象
        ttk.LabelFrame(parent, text="").pack()  # ダミー
        tk.Label(parent, text="抽出対象:", **lbl_s).pack(anchor="w", pady=2)
        self.extract_var = tk.StringVar(value="すべてのテキスト")
        for val in ["すべてのテキスト", "リンク一覧", "画像URL一覧",
                    "テーブルデータ", "見出し一覧", "CSSセレクタ指定"]:
            ttk.Radiobutton(parent, text=val, variable=self.extract_var,
                            value=val,
                            command=self._on_extract_change).pack(anchor="w")

        # CSSセレクタ
        self.selector_frame = tk.Frame(parent, bg=parent.cget("background"))
        self.selector_frame.pack(fill=tk.X, pady=4)
        tk.Label(self.selector_frame, text="CSSセレクタ:", **lbl_s,
                 bg=self.selector_frame.cget("bg")).pack(anchor="w")
        self.selector_var = tk.StringVar(value=".text")
        ttk.Entry(self.selector_frame, textvariable=self.selector_var,
                  width=22).pack(fill=tk.X, padx=2)
        tk.Label(self.selector_frame,
                 text="例: h1, .title, #main p, a[href]",
                 bg=self.selector_frame.cget("bg"),
                 fg="#666", font=("Arial", 8)).pack(anchor="w")
        self.selector_frame.pack_forget()

        # 最大件数
        row = tk.Frame(parent, bg=parent.cget("background"))
        row.pack(fill=tk.X, pady=6)
        tk.Label(row, text="最大件数:", **lbl_s,
                 bg=row.cget("bg")).pack(side=tk.LEFT)
        self.max_var = tk.IntVar(value=100)
        ttk.Spinbox(row, from_=1, to=9999,
                    textvariable=self.max_var, width=8).pack(side=tk.LEFT, padx=4)

        # 文字コード
        enc_row = tk.Frame(parent, bg=parent.cget("background"))
        enc_row.pack(fill=tk.X, pady=2)
        tk.Label(enc_row, text="エンコード:", **lbl_s,
                 bg=enc_row.cget("bg")).pack(side=tk.LEFT)
        self.enc_var = tk.StringVar(value="utf-8")
        ttk.Combobox(enc_row, textvariable=self.enc_var,
                     values=["utf-8", "shift_jis", "euc-jp", "iso-8859-1",
                              "自動検出"],
                     state="readonly", width=12).pack(side=tk.LEFT, padx=4)

        # ユーザーエージェント
        ua_row = tk.Frame(parent, bg=parent.cget("background"))
        ua_row.pack(fill=tk.X, pady=2)
        tk.Label(ua_row, text="UA:", **lbl_s,
                 bg=ua_row.cget("bg")).pack(side=tk.LEFT)
        self.ua_var = tk.StringVar(
            value="Mozilla/5.0 (compatible; PythonScraper/1.0)")
        ttk.Entry(ua_row, textvariable=self.ua_var, width=22).pack(
            side=tk.LEFT, padx=4, fill=tk.X, expand=True)

        ttk.Button(parent, text="▶ 抽出実行",
                   command=self._extract).pack(pady=8, fill=tk.X)
        ttk.Button(parent, text="💾 CSV保存",
                   command=self._save_csv).pack(fill=tk.X)

    def _build_results(self, parent):
        notebook = ttk.Notebook(parent)
        notebook.pack(fill=tk.BOTH, expand=True)

        # 抽出結果タブ
        result_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(result_tab, text="抽出結果")
        cols = ("no", "value", "extra")
        self.result_tree = ttk.Treeview(result_tab, columns=cols,
                                         show="headings", height=18)
        for c, h, w in [("no", "#", 40), ("value", "値", 300), ("extra", "追加情報", 200)]:
            self.result_tree.heading(c, text=h)
            self.result_tree.column(c, width=w, minwidth=30)
        sb = ttk.Scrollbar(result_tab, command=self.result_tree.yview)
        self.result_tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.result_tree.pack(fill=tk.BOTH, expand=True)

        # HTMLソースタブ
        src_tab = tk.Frame(notebook, bg="#0d1117")
        notebook.add(src_tab, text="HTMLソース")
        self.source_text = tk.Text(src_tab, bg="#0d1117", fg="#c9d1d9",
                                    font=("Courier New", 10), relief=tk.FLAT,
                                    state=tk.DISABLED, wrap=tk.NONE)
        h_sb = ttk.Scrollbar(src_tab, orient=tk.HORIZONTAL,
                             command=self.source_text.xview)
        v_sb = ttk.Scrollbar(src_tab, orient=tk.VERTICAL,
                             command=self.source_text.yview)
        self.source_text.configure(xscrollcommand=h_sb.set,
                                   yscrollcommand=v_sb.set)
        v_sb.pack(side=tk.RIGHT, fill=tk.Y)
        h_sb.pack(side=tk.BOTTOM, fill=tk.X)
        self.source_text.pack(fill=tk.BOTH, expand=True)

    def _on_extract_change(self):
        if self.extract_var.get() == "CSSセレクタ指定":
            self.selector_frame.pack(fill=tk.X, pady=4)
        else:
            self.selector_frame.pack_forget()

    def _fetch(self):
        url = self.url_var.get().strip()
        if not url:
            messagebox.showwarning("警告", "URLを入力してください")
            return
        self.fetch_btn.config(state=tk.DISABLED)
        self.progress.start(10)
        self.status_var.set("取得中...")
        threading.Thread(target=self._do_fetch, args=(url,), daemon=True).start()

    def _do_fetch(self, url):
        try:
            req = urllib.request.Request(url, headers={
                "User-Agent": self.ua_var.get()})
            with urllib.request.urlopen(req, timeout=15) as resp:
                raw = resp.read()
                enc = self.enc_var.get()
                if enc == "自動検出":
                    # Content-Typeから推測
                    ct = resp.headers.get("Content-Type", "")
                    m = re.search(r"charset=([^\s;]+)", ct, re.IGNORECASE)
                    enc = m.group(1) if m else "utf-8"
                html = raw.decode(enc, errors="replace")
            self.root.after(0, self._show_source, html)
        except Exception as e:
            self.root.after(0, self.status_var.set, f"エラー: {e}")
            self.root.after(0, self.progress.stop)
            self.root.after(0, self.fetch_btn.config, {"state": tk.NORMAL})

    def _show_source(self, html):
        self._html = html
        self.source_text.config(state=tk.NORMAL)
        self.source_text.delete("1.0", tk.END)
        self.source_text.insert("1.0", html[:200000])  # 上限200KB
        self.source_text.config(state=tk.DISABLED)
        self.progress.stop()
        self.fetch_btn.config(state=tk.NORMAL)
        self.status_var.set(f"取得完了: {len(html)} 文字")

    def _extract(self):
        if not hasattr(self, "_html") or not self._html:
            messagebox.showwarning("警告", "先にページを取得してください")
            return
        mode = self.extract_var.get()
        html = self._html
        max_n = self.max_var.get()
        results = []

        if not BS4_AVAILABLE:
            # 簡易版: 正規表現
            if mode == "リンク一覧":
                for m in re.finditer(r'href=["\']([^"\']+)["\']', html):
                    results.append((m.group(1), ""))
            elif mode == "画像URL一覧":
                for m in re.finditer(r'src=["\']([^"\']+\.(?:png|jpg|jpeg|gif|webp|svg))["\']',
                                     html, re.IGNORECASE):
                    results.append((m.group(1), ""))
            else:
                # タグを除去してテキスト抽出
                text = re.sub(r"<[^>]+>", "", html)
                lines = [l.strip() for l in text.splitlines() if l.strip()]
                results = [(l, "") for l in lines]
        else:
            soup = BeautifulSoup(html, "lxml" if self._try_lxml() else "html.parser")
            if mode == "すべてのテキスト":
                for tag in soup.find_all(text=True):
                    t = tag.strip()
                    if t and tag.parent.name not in ["script", "style", "meta"]:
                        results.append((t, tag.parent.name))
            elif mode == "リンク一覧":
                for a in soup.find_all("a", href=True):
                    results.append((a["href"], a.get_text(strip=True)))
            elif mode == "画像URL一覧":
                for img in soup.find_all("img"):
                    src = img.get("src", "")
                    alt = img.get("alt", "")
                    results.append((src, alt))
            elif mode == "テーブルデータ":
                for table in soup.find_all("table"):
                    for row in table.find_all("tr"):
                        cells = [td.get_text(strip=True)
                                 for td in row.find_all(["td", "th"])]
                        if cells:
                            results.append((" | ".join(cells), ""))
            elif mode == "見出し一覧":
                for h in soup.find_all(["h1", "h2", "h3", "h4", "h5", "h6"]):
                    results.append((h.get_text(strip=True), h.name))
            elif mode == "CSSセレクタ指定":
                selector = self.selector_var.get().strip()
                if selector:
                    for elem in soup.select(selector):
                        results.append((elem.get_text(strip=True),
                                        elem.name))

        self._results = results[:max_n]
        self._show_results()

    def _try_lxml(self):
        try:
            import lxml
            return True
        except ImportError:
            return False

    def _show_results(self):
        self.result_tree.delete(*self.result_tree.get_children())
        for i, (val, extra) in enumerate(self._results, 1):
            self.result_tree.insert("", "end", values=(i, val[:200], extra[:100]))
        self.status_var.set(f"抽出完了: {len(self._results)} 件")

    def _save_csv(self):
        if not self._results:
            messagebox.showwarning("警告", "抽出結果がありません")
            return
        path = filedialog.asksaveasfilename(
            defaultextension=".csv",
            filetypes=[("CSV", "*.csv"), ("すべて", "*.*")])
        if path:
            try:
                with open(path, "w", newline="", encoding="utf-8-sig") as f:
                    writer = csv.writer(f)
                    writer.writerow(["No", "値", "追加情報"])
                    for i, (val, extra) in enumerate(self._results, 1):
                        writer.writerow([i, val, extra])
                self.status_var.set(f"CSV保存: {path}")
            except Exception as e:
                messagebox.showerror("エラー", str(e))


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

Entryウィジェットとイベントバインド

ttk.Entryで入力フィールドを作成します。bind('', ...)でEnterキー押下時に処理を実行できます。これにより、マウスを使わずキーボードだけで操作できるUXが実現できます。

import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import urllib.request
import urllib.error
import threading
import csv
import io
import re
import os

try:
    from bs4 import BeautifulSoup
    BS4_AVAILABLE = True
except ImportError:
    BS4_AVAILABLE = False


class App34:
    """Webスクレイパー UI"""

    SAMPLE_URLS = [
        "https://quotes.toscrape.com/",
        "https://books.toscrape.com/",
        "https://httpbin.org/html",
    ]

    def __init__(self, root):
        self.root = root
        self.root.title("Webスクレイパー UI")
        self.root.geometry("920x640")
        self.root.configure(bg="#f8f9fc")
        self._results = []
        self._build_ui()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#e65100", pady=8)
        header.pack(fill=tk.X)
        tk.Label(header, text="🕷️ Webスクレイパー UI",
                 font=("Noto Sans JP", 13, "bold"),
                 bg="#e65100", fg="white").pack(side=tk.LEFT, padx=12)

        if not BS4_AVAILABLE:
            tk.Label(self.root,
                     text="⚠ BeautifulSoup4が未インストールです "
                          "(pip install beautifulsoup4 lxml)。"
                          "urllib のみの簡易スクレイピングになります。",
                     bg="#fff3cd", fg="#856404", font=("Arial", 9),
                     anchor="w", padx=8).pack(fill=tk.X)

        # URL入力
        url_f = tk.Frame(self.root, bg="#f8f9fc", pady=6)
        url_f.pack(fill=tk.X, padx=8)
        tk.Label(url_f, text="URL:", bg="#f8f9fc",
                 font=("Arial", 10)).pack(side=tk.LEFT)
        self.url_var = tk.StringVar(value="https://quotes.toscrape.com/")
        ttk.Entry(url_f, textvariable=self.url_var,
                  font=("Arial", 11), width=55).pack(side=tk.LEFT, padx=6,
                                                      fill=tk.X, expand=True)
        self.fetch_btn = ttk.Button(url_f, text="🌐 取得",
                                     command=self._fetch)
        self.fetch_btn.pack(side=tk.LEFT, padx=4)

        # サンプルURL
        sample_f = tk.Frame(self.root, bg="#f8f9fc")
        sample_f.pack(fill=tk.X, padx=8, pady=2)
        tk.Label(sample_f, text="サンプル:", bg="#f8f9fc",
                 font=("Arial", 9), fg="#666").pack(side=tk.LEFT)
        for url in self.SAMPLE_URLS:
            ttk.Button(sample_f, text=url[:40],
                       command=lambda u=url: (self.url_var.set(u), self._fetch())
                       ).pack(side=tk.LEFT, padx=4)

        # メインエリア
        paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        paned.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)

        # 左: 抽出設定
        left = ttk.LabelFrame(paned, text="抽出設定", padding=8)
        paned.add(left, weight=2)
        self._build_settings(left)

        # 右: 結果
        right = tk.Frame(paned, bg="#f8f9fc")
        paned.add(right, weight=3)
        self._build_results(right)

        # プログレス
        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="#dde", font=("Arial", 9), anchor="w", padx=8
                 ).pack(fill=tk.X, side=tk.BOTTOM)

    def _build_settings(self, parent):
        lbl_s = {"bg": parent.cget("background"), "font": ("Arial", 10)}

        # 抽出対象
        ttk.LabelFrame(parent, text="").pack()  # ダミー
        tk.Label(parent, text="抽出対象:", **lbl_s).pack(anchor="w", pady=2)
        self.extract_var = tk.StringVar(value="すべてのテキスト")
        for val in ["すべてのテキスト", "リンク一覧", "画像URL一覧",
                    "テーブルデータ", "見出し一覧", "CSSセレクタ指定"]:
            ttk.Radiobutton(parent, text=val, variable=self.extract_var,
                            value=val,
                            command=self._on_extract_change).pack(anchor="w")

        # CSSセレクタ
        self.selector_frame = tk.Frame(parent, bg=parent.cget("background"))
        self.selector_frame.pack(fill=tk.X, pady=4)
        tk.Label(self.selector_frame, text="CSSセレクタ:", **lbl_s,
                 bg=self.selector_frame.cget("bg")).pack(anchor="w")
        self.selector_var = tk.StringVar(value=".text")
        ttk.Entry(self.selector_frame, textvariable=self.selector_var,
                  width=22).pack(fill=tk.X, padx=2)
        tk.Label(self.selector_frame,
                 text="例: h1, .title, #main p, a[href]",
                 bg=self.selector_frame.cget("bg"),
                 fg="#666", font=("Arial", 8)).pack(anchor="w")
        self.selector_frame.pack_forget()

        # 最大件数
        row = tk.Frame(parent, bg=parent.cget("background"))
        row.pack(fill=tk.X, pady=6)
        tk.Label(row, text="最大件数:", **lbl_s,
                 bg=row.cget("bg")).pack(side=tk.LEFT)
        self.max_var = tk.IntVar(value=100)
        ttk.Spinbox(row, from_=1, to=9999,
                    textvariable=self.max_var, width=8).pack(side=tk.LEFT, padx=4)

        # 文字コード
        enc_row = tk.Frame(parent, bg=parent.cget("background"))
        enc_row.pack(fill=tk.X, pady=2)
        tk.Label(enc_row, text="エンコード:", **lbl_s,
                 bg=enc_row.cget("bg")).pack(side=tk.LEFT)
        self.enc_var = tk.StringVar(value="utf-8")
        ttk.Combobox(enc_row, textvariable=self.enc_var,
                     values=["utf-8", "shift_jis", "euc-jp", "iso-8859-1",
                              "自動検出"],
                     state="readonly", width=12).pack(side=tk.LEFT, padx=4)

        # ユーザーエージェント
        ua_row = tk.Frame(parent, bg=parent.cget("background"))
        ua_row.pack(fill=tk.X, pady=2)
        tk.Label(ua_row, text="UA:", **lbl_s,
                 bg=ua_row.cget("bg")).pack(side=tk.LEFT)
        self.ua_var = tk.StringVar(
            value="Mozilla/5.0 (compatible; PythonScraper/1.0)")
        ttk.Entry(ua_row, textvariable=self.ua_var, width=22).pack(
            side=tk.LEFT, padx=4, fill=tk.X, expand=True)

        ttk.Button(parent, text="▶ 抽出実行",
                   command=self._extract).pack(pady=8, fill=tk.X)
        ttk.Button(parent, text="💾 CSV保存",
                   command=self._save_csv).pack(fill=tk.X)

    def _build_results(self, parent):
        notebook = ttk.Notebook(parent)
        notebook.pack(fill=tk.BOTH, expand=True)

        # 抽出結果タブ
        result_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(result_tab, text="抽出結果")
        cols = ("no", "value", "extra")
        self.result_tree = ttk.Treeview(result_tab, columns=cols,
                                         show="headings", height=18)
        for c, h, w in [("no", "#", 40), ("value", "値", 300), ("extra", "追加情報", 200)]:
            self.result_tree.heading(c, text=h)
            self.result_tree.column(c, width=w, minwidth=30)
        sb = ttk.Scrollbar(result_tab, command=self.result_tree.yview)
        self.result_tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.result_tree.pack(fill=tk.BOTH, expand=True)

        # HTMLソースタブ
        src_tab = tk.Frame(notebook, bg="#0d1117")
        notebook.add(src_tab, text="HTMLソース")
        self.source_text = tk.Text(src_tab, bg="#0d1117", fg="#c9d1d9",
                                    font=("Courier New", 10), relief=tk.FLAT,
                                    state=tk.DISABLED, wrap=tk.NONE)
        h_sb = ttk.Scrollbar(src_tab, orient=tk.HORIZONTAL,
                             command=self.source_text.xview)
        v_sb = ttk.Scrollbar(src_tab, orient=tk.VERTICAL,
                             command=self.source_text.yview)
        self.source_text.configure(xscrollcommand=h_sb.set,
                                   yscrollcommand=v_sb.set)
        v_sb.pack(side=tk.RIGHT, fill=tk.Y)
        h_sb.pack(side=tk.BOTTOM, fill=tk.X)
        self.source_text.pack(fill=tk.BOTH, expand=True)

    def _on_extract_change(self):
        if self.extract_var.get() == "CSSセレクタ指定":
            self.selector_frame.pack(fill=tk.X, pady=4)
        else:
            self.selector_frame.pack_forget()

    def _fetch(self):
        url = self.url_var.get().strip()
        if not url:
            messagebox.showwarning("警告", "URLを入力してください")
            return
        self.fetch_btn.config(state=tk.DISABLED)
        self.progress.start(10)
        self.status_var.set("取得中...")
        threading.Thread(target=self._do_fetch, args=(url,), daemon=True).start()

    def _do_fetch(self, url):
        try:
            req = urllib.request.Request(url, headers={
                "User-Agent": self.ua_var.get()})
            with urllib.request.urlopen(req, timeout=15) as resp:
                raw = resp.read()
                enc = self.enc_var.get()
                if enc == "自動検出":
                    # Content-Typeから推測
                    ct = resp.headers.get("Content-Type", "")
                    m = re.search(r"charset=([^\s;]+)", ct, re.IGNORECASE)
                    enc = m.group(1) if m else "utf-8"
                html = raw.decode(enc, errors="replace")
            self.root.after(0, self._show_source, html)
        except Exception as e:
            self.root.after(0, self.status_var.set, f"エラー: {e}")
            self.root.after(0, self.progress.stop)
            self.root.after(0, self.fetch_btn.config, {"state": tk.NORMAL})

    def _show_source(self, html):
        self._html = html
        self.source_text.config(state=tk.NORMAL)
        self.source_text.delete("1.0", tk.END)
        self.source_text.insert("1.0", html[:200000])  # 上限200KB
        self.source_text.config(state=tk.DISABLED)
        self.progress.stop()
        self.fetch_btn.config(state=tk.NORMAL)
        self.status_var.set(f"取得完了: {len(html)} 文字")

    def _extract(self):
        if not hasattr(self, "_html") or not self._html:
            messagebox.showwarning("警告", "先にページを取得してください")
            return
        mode = self.extract_var.get()
        html = self._html
        max_n = self.max_var.get()
        results = []

        if not BS4_AVAILABLE:
            # 簡易版: 正規表現
            if mode == "リンク一覧":
                for m in re.finditer(r'href=["\']([^"\']+)["\']', html):
                    results.append((m.group(1), ""))
            elif mode == "画像URL一覧":
                for m in re.finditer(r'src=["\']([^"\']+\.(?:png|jpg|jpeg|gif|webp|svg))["\']',
                                     html, re.IGNORECASE):
                    results.append((m.group(1), ""))
            else:
                # タグを除去してテキスト抽出
                text = re.sub(r"<[^>]+>", "", html)
                lines = [l.strip() for l in text.splitlines() if l.strip()]
                results = [(l, "") for l in lines]
        else:
            soup = BeautifulSoup(html, "lxml" if self._try_lxml() else "html.parser")
            if mode == "すべてのテキスト":
                for tag in soup.find_all(text=True):
                    t = tag.strip()
                    if t and tag.parent.name not in ["script", "style", "meta"]:
                        results.append((t, tag.parent.name))
            elif mode == "リンク一覧":
                for a in soup.find_all("a", href=True):
                    results.append((a["href"], a.get_text(strip=True)))
            elif mode == "画像URL一覧":
                for img in soup.find_all("img"):
                    src = img.get("src", "")
                    alt = img.get("alt", "")
                    results.append((src, alt))
            elif mode == "テーブルデータ":
                for table in soup.find_all("table"):
                    for row in table.find_all("tr"):
                        cells = [td.get_text(strip=True)
                                 for td in row.find_all(["td", "th"])]
                        if cells:
                            results.append((" | ".join(cells), ""))
            elif mode == "見出し一覧":
                for h in soup.find_all(["h1", "h2", "h3", "h4", "h5", "h6"]):
                    results.append((h.get_text(strip=True), h.name))
            elif mode == "CSSセレクタ指定":
                selector = self.selector_var.get().strip()
                if selector:
                    for elem in soup.select(selector):
                        results.append((elem.get_text(strip=True),
                                        elem.name))

        self._results = results[:max_n]
        self._show_results()

    def _try_lxml(self):
        try:
            import lxml
            return True
        except ImportError:
            return False

    def _show_results(self):
        self.result_tree.delete(*self.result_tree.get_children())
        for i, (val, extra) in enumerate(self._results, 1):
            self.result_tree.insert("", "end", values=(i, val[:200], extra[:100]))
        self.status_var.set(f"抽出完了: {len(self._results)} 件")

    def _save_csv(self):
        if not self._results:
            messagebox.showwarning("警告", "抽出結果がありません")
            return
        path = filedialog.asksaveasfilename(
            defaultextension=".csv",
            filetypes=[("CSV", "*.csv"), ("すべて", "*.*")])
        if path:
            try:
                with open(path, "w", newline="", encoding="utf-8-sig") as f:
                    writer = csv.writer(f)
                    writer.writerow(["No", "値", "追加情報"])
                    for i, (val, extra) in enumerate(self._results, 1):
                        writer.writerow([i, val, extra])
                self.status_var.set(f"CSV保存: {path}")
            except Exception as e:
                messagebox.showerror("エラー", str(e))


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

Textウィジェットでの結果表示

結果表示にはtk.Textウィジェットを使います。state=tk.DISABLEDでユーザーが直接編集できないようにし、表示前にNORMALに切り替えてからinsert()で内容を更新します。

import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import urllib.request
import urllib.error
import threading
import csv
import io
import re
import os

try:
    from bs4 import BeautifulSoup
    BS4_AVAILABLE = True
except ImportError:
    BS4_AVAILABLE = False


class App34:
    """Webスクレイパー UI"""

    SAMPLE_URLS = [
        "https://quotes.toscrape.com/",
        "https://books.toscrape.com/",
        "https://httpbin.org/html",
    ]

    def __init__(self, root):
        self.root = root
        self.root.title("Webスクレイパー UI")
        self.root.geometry("920x640")
        self.root.configure(bg="#f8f9fc")
        self._results = []
        self._build_ui()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#e65100", pady=8)
        header.pack(fill=tk.X)
        tk.Label(header, text="🕷️ Webスクレイパー UI",
                 font=("Noto Sans JP", 13, "bold"),
                 bg="#e65100", fg="white").pack(side=tk.LEFT, padx=12)

        if not BS4_AVAILABLE:
            tk.Label(self.root,
                     text="⚠ BeautifulSoup4が未インストールです "
                          "(pip install beautifulsoup4 lxml)。"
                          "urllib のみの簡易スクレイピングになります。",
                     bg="#fff3cd", fg="#856404", font=("Arial", 9),
                     anchor="w", padx=8).pack(fill=tk.X)

        # URL入力
        url_f = tk.Frame(self.root, bg="#f8f9fc", pady=6)
        url_f.pack(fill=tk.X, padx=8)
        tk.Label(url_f, text="URL:", bg="#f8f9fc",
                 font=("Arial", 10)).pack(side=tk.LEFT)
        self.url_var = tk.StringVar(value="https://quotes.toscrape.com/")
        ttk.Entry(url_f, textvariable=self.url_var,
                  font=("Arial", 11), width=55).pack(side=tk.LEFT, padx=6,
                                                      fill=tk.X, expand=True)
        self.fetch_btn = ttk.Button(url_f, text="🌐 取得",
                                     command=self._fetch)
        self.fetch_btn.pack(side=tk.LEFT, padx=4)

        # サンプルURL
        sample_f = tk.Frame(self.root, bg="#f8f9fc")
        sample_f.pack(fill=tk.X, padx=8, pady=2)
        tk.Label(sample_f, text="サンプル:", bg="#f8f9fc",
                 font=("Arial", 9), fg="#666").pack(side=tk.LEFT)
        for url in self.SAMPLE_URLS:
            ttk.Button(sample_f, text=url[:40],
                       command=lambda u=url: (self.url_var.set(u), self._fetch())
                       ).pack(side=tk.LEFT, padx=4)

        # メインエリア
        paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        paned.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)

        # 左: 抽出設定
        left = ttk.LabelFrame(paned, text="抽出設定", padding=8)
        paned.add(left, weight=2)
        self._build_settings(left)

        # 右: 結果
        right = tk.Frame(paned, bg="#f8f9fc")
        paned.add(right, weight=3)
        self._build_results(right)

        # プログレス
        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="#dde", font=("Arial", 9), anchor="w", padx=8
                 ).pack(fill=tk.X, side=tk.BOTTOM)

    def _build_settings(self, parent):
        lbl_s = {"bg": parent.cget("background"), "font": ("Arial", 10)}

        # 抽出対象
        ttk.LabelFrame(parent, text="").pack()  # ダミー
        tk.Label(parent, text="抽出対象:", **lbl_s).pack(anchor="w", pady=2)
        self.extract_var = tk.StringVar(value="すべてのテキスト")
        for val in ["すべてのテキスト", "リンク一覧", "画像URL一覧",
                    "テーブルデータ", "見出し一覧", "CSSセレクタ指定"]:
            ttk.Radiobutton(parent, text=val, variable=self.extract_var,
                            value=val,
                            command=self._on_extract_change).pack(anchor="w")

        # CSSセレクタ
        self.selector_frame = tk.Frame(parent, bg=parent.cget("background"))
        self.selector_frame.pack(fill=tk.X, pady=4)
        tk.Label(self.selector_frame, text="CSSセレクタ:", **lbl_s,
                 bg=self.selector_frame.cget("bg")).pack(anchor="w")
        self.selector_var = tk.StringVar(value=".text")
        ttk.Entry(self.selector_frame, textvariable=self.selector_var,
                  width=22).pack(fill=tk.X, padx=2)
        tk.Label(self.selector_frame,
                 text="例: h1, .title, #main p, a[href]",
                 bg=self.selector_frame.cget("bg"),
                 fg="#666", font=("Arial", 8)).pack(anchor="w")
        self.selector_frame.pack_forget()

        # 最大件数
        row = tk.Frame(parent, bg=parent.cget("background"))
        row.pack(fill=tk.X, pady=6)
        tk.Label(row, text="最大件数:", **lbl_s,
                 bg=row.cget("bg")).pack(side=tk.LEFT)
        self.max_var = tk.IntVar(value=100)
        ttk.Spinbox(row, from_=1, to=9999,
                    textvariable=self.max_var, width=8).pack(side=tk.LEFT, padx=4)

        # 文字コード
        enc_row = tk.Frame(parent, bg=parent.cget("background"))
        enc_row.pack(fill=tk.X, pady=2)
        tk.Label(enc_row, text="エンコード:", **lbl_s,
                 bg=enc_row.cget("bg")).pack(side=tk.LEFT)
        self.enc_var = tk.StringVar(value="utf-8")
        ttk.Combobox(enc_row, textvariable=self.enc_var,
                     values=["utf-8", "shift_jis", "euc-jp", "iso-8859-1",
                              "自動検出"],
                     state="readonly", width=12).pack(side=tk.LEFT, padx=4)

        # ユーザーエージェント
        ua_row = tk.Frame(parent, bg=parent.cget("background"))
        ua_row.pack(fill=tk.X, pady=2)
        tk.Label(ua_row, text="UA:", **lbl_s,
                 bg=ua_row.cget("bg")).pack(side=tk.LEFT)
        self.ua_var = tk.StringVar(
            value="Mozilla/5.0 (compatible; PythonScraper/1.0)")
        ttk.Entry(ua_row, textvariable=self.ua_var, width=22).pack(
            side=tk.LEFT, padx=4, fill=tk.X, expand=True)

        ttk.Button(parent, text="▶ 抽出実行",
                   command=self._extract).pack(pady=8, fill=tk.X)
        ttk.Button(parent, text="💾 CSV保存",
                   command=self._save_csv).pack(fill=tk.X)

    def _build_results(self, parent):
        notebook = ttk.Notebook(parent)
        notebook.pack(fill=tk.BOTH, expand=True)

        # 抽出結果タブ
        result_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(result_tab, text="抽出結果")
        cols = ("no", "value", "extra")
        self.result_tree = ttk.Treeview(result_tab, columns=cols,
                                         show="headings", height=18)
        for c, h, w in [("no", "#", 40), ("value", "値", 300), ("extra", "追加情報", 200)]:
            self.result_tree.heading(c, text=h)
            self.result_tree.column(c, width=w, minwidth=30)
        sb = ttk.Scrollbar(result_tab, command=self.result_tree.yview)
        self.result_tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.result_tree.pack(fill=tk.BOTH, expand=True)

        # HTMLソースタブ
        src_tab = tk.Frame(notebook, bg="#0d1117")
        notebook.add(src_tab, text="HTMLソース")
        self.source_text = tk.Text(src_tab, bg="#0d1117", fg="#c9d1d9",
                                    font=("Courier New", 10), relief=tk.FLAT,
                                    state=tk.DISABLED, wrap=tk.NONE)
        h_sb = ttk.Scrollbar(src_tab, orient=tk.HORIZONTAL,
                             command=self.source_text.xview)
        v_sb = ttk.Scrollbar(src_tab, orient=tk.VERTICAL,
                             command=self.source_text.yview)
        self.source_text.configure(xscrollcommand=h_sb.set,
                                   yscrollcommand=v_sb.set)
        v_sb.pack(side=tk.RIGHT, fill=tk.Y)
        h_sb.pack(side=tk.BOTTOM, fill=tk.X)
        self.source_text.pack(fill=tk.BOTH, expand=True)

    def _on_extract_change(self):
        if self.extract_var.get() == "CSSセレクタ指定":
            self.selector_frame.pack(fill=tk.X, pady=4)
        else:
            self.selector_frame.pack_forget()

    def _fetch(self):
        url = self.url_var.get().strip()
        if not url:
            messagebox.showwarning("警告", "URLを入力してください")
            return
        self.fetch_btn.config(state=tk.DISABLED)
        self.progress.start(10)
        self.status_var.set("取得中...")
        threading.Thread(target=self._do_fetch, args=(url,), daemon=True).start()

    def _do_fetch(self, url):
        try:
            req = urllib.request.Request(url, headers={
                "User-Agent": self.ua_var.get()})
            with urllib.request.urlopen(req, timeout=15) as resp:
                raw = resp.read()
                enc = self.enc_var.get()
                if enc == "自動検出":
                    # Content-Typeから推測
                    ct = resp.headers.get("Content-Type", "")
                    m = re.search(r"charset=([^\s;]+)", ct, re.IGNORECASE)
                    enc = m.group(1) if m else "utf-8"
                html = raw.decode(enc, errors="replace")
            self.root.after(0, self._show_source, html)
        except Exception as e:
            self.root.after(0, self.status_var.set, f"エラー: {e}")
            self.root.after(0, self.progress.stop)
            self.root.after(0, self.fetch_btn.config, {"state": tk.NORMAL})

    def _show_source(self, html):
        self._html = html
        self.source_text.config(state=tk.NORMAL)
        self.source_text.delete("1.0", tk.END)
        self.source_text.insert("1.0", html[:200000])  # 上限200KB
        self.source_text.config(state=tk.DISABLED)
        self.progress.stop()
        self.fetch_btn.config(state=tk.NORMAL)
        self.status_var.set(f"取得完了: {len(html)} 文字")

    def _extract(self):
        if not hasattr(self, "_html") or not self._html:
            messagebox.showwarning("警告", "先にページを取得してください")
            return
        mode = self.extract_var.get()
        html = self._html
        max_n = self.max_var.get()
        results = []

        if not BS4_AVAILABLE:
            # 簡易版: 正規表現
            if mode == "リンク一覧":
                for m in re.finditer(r'href=["\']([^"\']+)["\']', html):
                    results.append((m.group(1), ""))
            elif mode == "画像URL一覧":
                for m in re.finditer(r'src=["\']([^"\']+\.(?:png|jpg|jpeg|gif|webp|svg))["\']',
                                     html, re.IGNORECASE):
                    results.append((m.group(1), ""))
            else:
                # タグを除去してテキスト抽出
                text = re.sub(r"<[^>]+>", "", html)
                lines = [l.strip() for l in text.splitlines() if l.strip()]
                results = [(l, "") for l in lines]
        else:
            soup = BeautifulSoup(html, "lxml" if self._try_lxml() else "html.parser")
            if mode == "すべてのテキスト":
                for tag in soup.find_all(text=True):
                    t = tag.strip()
                    if t and tag.parent.name not in ["script", "style", "meta"]:
                        results.append((t, tag.parent.name))
            elif mode == "リンク一覧":
                for a in soup.find_all("a", href=True):
                    results.append((a["href"], a.get_text(strip=True)))
            elif mode == "画像URL一覧":
                for img in soup.find_all("img"):
                    src = img.get("src", "")
                    alt = img.get("alt", "")
                    results.append((src, alt))
            elif mode == "テーブルデータ":
                for table in soup.find_all("table"):
                    for row in table.find_all("tr"):
                        cells = [td.get_text(strip=True)
                                 for td in row.find_all(["td", "th"])]
                        if cells:
                            results.append((" | ".join(cells), ""))
            elif mode == "見出し一覧":
                for h in soup.find_all(["h1", "h2", "h3", "h4", "h5", "h6"]):
                    results.append((h.get_text(strip=True), h.name))
            elif mode == "CSSセレクタ指定":
                selector = self.selector_var.get().strip()
                if selector:
                    for elem in soup.select(selector):
                        results.append((elem.get_text(strip=True),
                                        elem.name))

        self._results = results[:max_n]
        self._show_results()

    def _try_lxml(self):
        try:
            import lxml
            return True
        except ImportError:
            return False

    def _show_results(self):
        self.result_tree.delete(*self.result_tree.get_children())
        for i, (val, extra) in enumerate(self._results, 1):
            self.result_tree.insert("", "end", values=(i, val[:200], extra[:100]))
        self.status_var.set(f"抽出完了: {len(self._results)} 件")

    def _save_csv(self):
        if not self._results:
            messagebox.showwarning("警告", "抽出結果がありません")
            return
        path = filedialog.asksaveasfilename(
            defaultextension=".csv",
            filetypes=[("CSV", "*.csv"), ("すべて", "*.*")])
        if path:
            try:
                with open(path, "w", newline="", encoding="utf-8-sig") as f:
                    writer = csv.writer(f)
                    writer.writerow(["No", "値", "追加情報"])
                    for i, (val, extra) in enumerate(self._results, 1):
                        writer.writerow([i, val, extra])
                self.status_var.set(f"CSV保存: {path}")
            except Exception as e:
                messagebox.showerror("エラー", str(e))


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

例外処理とmessagebox

try-except で ValueError と Exception を捕捉し、messagebox.showerror() でユーザーにわかりやすいエラーメッセージを表示します。入力バリデーションは必ず実装しましょう。

import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import urllib.request
import urllib.error
import threading
import csv
import io
import re
import os

try:
    from bs4 import BeautifulSoup
    BS4_AVAILABLE = True
except ImportError:
    BS4_AVAILABLE = False


class App34:
    """Webスクレイパー UI"""

    SAMPLE_URLS = [
        "https://quotes.toscrape.com/",
        "https://books.toscrape.com/",
        "https://httpbin.org/html",
    ]

    def __init__(self, root):
        self.root = root
        self.root.title("Webスクレイパー UI")
        self.root.geometry("920x640")
        self.root.configure(bg="#f8f9fc")
        self._results = []
        self._build_ui()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#e65100", pady=8)
        header.pack(fill=tk.X)
        tk.Label(header, text="🕷️ Webスクレイパー UI",
                 font=("Noto Sans JP", 13, "bold"),
                 bg="#e65100", fg="white").pack(side=tk.LEFT, padx=12)

        if not BS4_AVAILABLE:
            tk.Label(self.root,
                     text="⚠ BeautifulSoup4が未インストールです "
                          "(pip install beautifulsoup4 lxml)。"
                          "urllib のみの簡易スクレイピングになります。",
                     bg="#fff3cd", fg="#856404", font=("Arial", 9),
                     anchor="w", padx=8).pack(fill=tk.X)

        # URL入力
        url_f = tk.Frame(self.root, bg="#f8f9fc", pady=6)
        url_f.pack(fill=tk.X, padx=8)
        tk.Label(url_f, text="URL:", bg="#f8f9fc",
                 font=("Arial", 10)).pack(side=tk.LEFT)
        self.url_var = tk.StringVar(value="https://quotes.toscrape.com/")
        ttk.Entry(url_f, textvariable=self.url_var,
                  font=("Arial", 11), width=55).pack(side=tk.LEFT, padx=6,
                                                      fill=tk.X, expand=True)
        self.fetch_btn = ttk.Button(url_f, text="🌐 取得",
                                     command=self._fetch)
        self.fetch_btn.pack(side=tk.LEFT, padx=4)

        # サンプルURL
        sample_f = tk.Frame(self.root, bg="#f8f9fc")
        sample_f.pack(fill=tk.X, padx=8, pady=2)
        tk.Label(sample_f, text="サンプル:", bg="#f8f9fc",
                 font=("Arial", 9), fg="#666").pack(side=tk.LEFT)
        for url in self.SAMPLE_URLS:
            ttk.Button(sample_f, text=url[:40],
                       command=lambda u=url: (self.url_var.set(u), self._fetch())
                       ).pack(side=tk.LEFT, padx=4)

        # メインエリア
        paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        paned.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)

        # 左: 抽出設定
        left = ttk.LabelFrame(paned, text="抽出設定", padding=8)
        paned.add(left, weight=2)
        self._build_settings(left)

        # 右: 結果
        right = tk.Frame(paned, bg="#f8f9fc")
        paned.add(right, weight=3)
        self._build_results(right)

        # プログレス
        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="#dde", font=("Arial", 9), anchor="w", padx=8
                 ).pack(fill=tk.X, side=tk.BOTTOM)

    def _build_settings(self, parent):
        lbl_s = {"bg": parent.cget("background"), "font": ("Arial", 10)}

        # 抽出対象
        ttk.LabelFrame(parent, text="").pack()  # ダミー
        tk.Label(parent, text="抽出対象:", **lbl_s).pack(anchor="w", pady=2)
        self.extract_var = tk.StringVar(value="すべてのテキスト")
        for val in ["すべてのテキスト", "リンク一覧", "画像URL一覧",
                    "テーブルデータ", "見出し一覧", "CSSセレクタ指定"]:
            ttk.Radiobutton(parent, text=val, variable=self.extract_var,
                            value=val,
                            command=self._on_extract_change).pack(anchor="w")

        # CSSセレクタ
        self.selector_frame = tk.Frame(parent, bg=parent.cget("background"))
        self.selector_frame.pack(fill=tk.X, pady=4)
        tk.Label(self.selector_frame, text="CSSセレクタ:", **lbl_s,
                 bg=self.selector_frame.cget("bg")).pack(anchor="w")
        self.selector_var = tk.StringVar(value=".text")
        ttk.Entry(self.selector_frame, textvariable=self.selector_var,
                  width=22).pack(fill=tk.X, padx=2)
        tk.Label(self.selector_frame,
                 text="例: h1, .title, #main p, a[href]",
                 bg=self.selector_frame.cget("bg"),
                 fg="#666", font=("Arial", 8)).pack(anchor="w")
        self.selector_frame.pack_forget()

        # 最大件数
        row = tk.Frame(parent, bg=parent.cget("background"))
        row.pack(fill=tk.X, pady=6)
        tk.Label(row, text="最大件数:", **lbl_s,
                 bg=row.cget("bg")).pack(side=tk.LEFT)
        self.max_var = tk.IntVar(value=100)
        ttk.Spinbox(row, from_=1, to=9999,
                    textvariable=self.max_var, width=8).pack(side=tk.LEFT, padx=4)

        # 文字コード
        enc_row = tk.Frame(parent, bg=parent.cget("background"))
        enc_row.pack(fill=tk.X, pady=2)
        tk.Label(enc_row, text="エンコード:", **lbl_s,
                 bg=enc_row.cget("bg")).pack(side=tk.LEFT)
        self.enc_var = tk.StringVar(value="utf-8")
        ttk.Combobox(enc_row, textvariable=self.enc_var,
                     values=["utf-8", "shift_jis", "euc-jp", "iso-8859-1",
                              "自動検出"],
                     state="readonly", width=12).pack(side=tk.LEFT, padx=4)

        # ユーザーエージェント
        ua_row = tk.Frame(parent, bg=parent.cget("background"))
        ua_row.pack(fill=tk.X, pady=2)
        tk.Label(ua_row, text="UA:", **lbl_s,
                 bg=ua_row.cget("bg")).pack(side=tk.LEFT)
        self.ua_var = tk.StringVar(
            value="Mozilla/5.0 (compatible; PythonScraper/1.0)")
        ttk.Entry(ua_row, textvariable=self.ua_var, width=22).pack(
            side=tk.LEFT, padx=4, fill=tk.X, expand=True)

        ttk.Button(parent, text="▶ 抽出実行",
                   command=self._extract).pack(pady=8, fill=tk.X)
        ttk.Button(parent, text="💾 CSV保存",
                   command=self._save_csv).pack(fill=tk.X)

    def _build_results(self, parent):
        notebook = ttk.Notebook(parent)
        notebook.pack(fill=tk.BOTH, expand=True)

        # 抽出結果タブ
        result_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(result_tab, text="抽出結果")
        cols = ("no", "value", "extra")
        self.result_tree = ttk.Treeview(result_tab, columns=cols,
                                         show="headings", height=18)
        for c, h, w in [("no", "#", 40), ("value", "値", 300), ("extra", "追加情報", 200)]:
            self.result_tree.heading(c, text=h)
            self.result_tree.column(c, width=w, minwidth=30)
        sb = ttk.Scrollbar(result_tab, command=self.result_tree.yview)
        self.result_tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.result_tree.pack(fill=tk.BOTH, expand=True)

        # HTMLソースタブ
        src_tab = tk.Frame(notebook, bg="#0d1117")
        notebook.add(src_tab, text="HTMLソース")
        self.source_text = tk.Text(src_tab, bg="#0d1117", fg="#c9d1d9",
                                    font=("Courier New", 10), relief=tk.FLAT,
                                    state=tk.DISABLED, wrap=tk.NONE)
        h_sb = ttk.Scrollbar(src_tab, orient=tk.HORIZONTAL,
                             command=self.source_text.xview)
        v_sb = ttk.Scrollbar(src_tab, orient=tk.VERTICAL,
                             command=self.source_text.yview)
        self.source_text.configure(xscrollcommand=h_sb.set,
                                   yscrollcommand=v_sb.set)
        v_sb.pack(side=tk.RIGHT, fill=tk.Y)
        h_sb.pack(side=tk.BOTTOM, fill=tk.X)
        self.source_text.pack(fill=tk.BOTH, expand=True)

    def _on_extract_change(self):
        if self.extract_var.get() == "CSSセレクタ指定":
            self.selector_frame.pack(fill=tk.X, pady=4)
        else:
            self.selector_frame.pack_forget()

    def _fetch(self):
        url = self.url_var.get().strip()
        if not url:
            messagebox.showwarning("警告", "URLを入力してください")
            return
        self.fetch_btn.config(state=tk.DISABLED)
        self.progress.start(10)
        self.status_var.set("取得中...")
        threading.Thread(target=self._do_fetch, args=(url,), daemon=True).start()

    def _do_fetch(self, url):
        try:
            req = urllib.request.Request(url, headers={
                "User-Agent": self.ua_var.get()})
            with urllib.request.urlopen(req, timeout=15) as resp:
                raw = resp.read()
                enc = self.enc_var.get()
                if enc == "自動検出":
                    # Content-Typeから推測
                    ct = resp.headers.get("Content-Type", "")
                    m = re.search(r"charset=([^\s;]+)", ct, re.IGNORECASE)
                    enc = m.group(1) if m else "utf-8"
                html = raw.decode(enc, errors="replace")
            self.root.after(0, self._show_source, html)
        except Exception as e:
            self.root.after(0, self.status_var.set, f"エラー: {e}")
            self.root.after(0, self.progress.stop)
            self.root.after(0, self.fetch_btn.config, {"state": tk.NORMAL})

    def _show_source(self, html):
        self._html = html
        self.source_text.config(state=tk.NORMAL)
        self.source_text.delete("1.0", tk.END)
        self.source_text.insert("1.0", html[:200000])  # 上限200KB
        self.source_text.config(state=tk.DISABLED)
        self.progress.stop()
        self.fetch_btn.config(state=tk.NORMAL)
        self.status_var.set(f"取得完了: {len(html)} 文字")

    def _extract(self):
        if not hasattr(self, "_html") or not self._html:
            messagebox.showwarning("警告", "先にページを取得してください")
            return
        mode = self.extract_var.get()
        html = self._html
        max_n = self.max_var.get()
        results = []

        if not BS4_AVAILABLE:
            # 簡易版: 正規表現
            if mode == "リンク一覧":
                for m in re.finditer(r'href=["\']([^"\']+)["\']', html):
                    results.append((m.group(1), ""))
            elif mode == "画像URL一覧":
                for m in re.finditer(r'src=["\']([^"\']+\.(?:png|jpg|jpeg|gif|webp|svg))["\']',
                                     html, re.IGNORECASE):
                    results.append((m.group(1), ""))
            else:
                # タグを除去してテキスト抽出
                text = re.sub(r"<[^>]+>", "", html)
                lines = [l.strip() for l in text.splitlines() if l.strip()]
                results = [(l, "") for l in lines]
        else:
            soup = BeautifulSoup(html, "lxml" if self._try_lxml() else "html.parser")
            if mode == "すべてのテキスト":
                for tag in soup.find_all(text=True):
                    t = tag.strip()
                    if t and tag.parent.name not in ["script", "style", "meta"]:
                        results.append((t, tag.parent.name))
            elif mode == "リンク一覧":
                for a in soup.find_all("a", href=True):
                    results.append((a["href"], a.get_text(strip=True)))
            elif mode == "画像URL一覧":
                for img in soup.find_all("img"):
                    src = img.get("src", "")
                    alt = img.get("alt", "")
                    results.append((src, alt))
            elif mode == "テーブルデータ":
                for table in soup.find_all("table"):
                    for row in table.find_all("tr"):
                        cells = [td.get_text(strip=True)
                                 for td in row.find_all(["td", "th"])]
                        if cells:
                            results.append((" | ".join(cells), ""))
            elif mode == "見出し一覧":
                for h in soup.find_all(["h1", "h2", "h3", "h4", "h5", "h6"]):
                    results.append((h.get_text(strip=True), h.name))
            elif mode == "CSSセレクタ指定":
                selector = self.selector_var.get().strip()
                if selector:
                    for elem in soup.select(selector):
                        results.append((elem.get_text(strip=True),
                                        elem.name))

        self._results = results[:max_n]
        self._show_results()

    def _try_lxml(self):
        try:
            import lxml
            return True
        except ImportError:
            return False

    def _show_results(self):
        self.result_tree.delete(*self.result_tree.get_children())
        for i, (val, extra) in enumerate(self._results, 1):
            self.result_tree.insert("", "end", values=(i, val[:200], extra[:100]))
        self.status_var.set(f"抽出完了: {len(self._results)} 件")

    def _save_csv(self):
        if not self._results:
            messagebox.showwarning("警告", "抽出結果がありません")
            return
        path = filedialog.asksaveasfilename(
            defaultextension=".csv",
            filetypes=[("CSV", "*.csv"), ("すべて", "*.*")])
        if path:
            try:
                with open(path, "w", newline="", encoding="utf-8-sig") as f:
                    writer = csv.writer(f)
                    writer.writerow(["No", "値", "追加情報"])
                    for i, (val, extra) in enumerate(self._results, 1):
                        writer.writerow([i, val, extra])
                self.status_var.set(f"CSV保存: {path}")
            except Exception as e:
                messagebox.showerror("エラー", str(e))


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

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

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

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

    新しいファイルを作成して app34.py と保存します。

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

    App34クラスを定義し、__init__とmainloop()の最小構成を作ります。

  3. 3
    タイトルバーを作る

    Frameを使ってカラーバー付きのタイトルエリアを作ります。

  4. 4
    入力フォームを実装する

    LabelFrameとEntryウィジェットで入力エリアを作ります。

  5. 5
    処理ロジックを実装する

    _calculate()メソッドに計算・処理ロジックを実装します。

  6. 6
    結果表示を実装する

    TextウィジェットかLabelに結果を表示する_show_result()を実装します。

  7. 7
    エラー処理を追加する

    try-exceptとmessageboxでエラーハンドリングを追加します。

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

基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。少しずつ機能を追加することで、Pythonのスキルが飛躍的に向上します。

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

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

💡 データのエクスポート機能

計算結果をCSV・TXTファイルに保存するエクスポート機能を追加しましょう。filedialog.asksaveasfilename()でファイル保存ダイアログが使えます。

💡 入力履歴機能

以前の入力値を覚えておいてComboboxのドロップダウンで再選択できる履歴機能を追加しましょう。

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

❌ 日本語フォントが表示されない

原因:システムに日本語フォントが見つからない場合があります。

解決法:font引数を省略するかシステムに合ったフォントを指定してください。

❌ ウィンドウのサイズが変更できない

原因:resizable(False, False)が設定されています。

解決法:resizable(True, True)に変更してください。

9. 練習問題

アプリの理解を深めるための練習問題です。難易度順に挑戦してみてください。

  1. 課題1:機能拡張

    Webスクレイパー UIに新しい機能を1つ追加してみましょう。どんな機能があると便利か考えてから実装してください。

  2. 課題2:UIの改善

    色・フォント・レイアウトを変更して、より使いやすいUIにカスタマイズしてみましょう。

  3. 課題3:保存機能の追加

    入力値や計算結果をファイルに保存する機能を追加しましょう。jsonやcsvモジュールを使います。

🚀
次に挑戦するアプリ

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