Webスクレイパー UI
URLを入力してWebページのデータを抽出・CSV保存できるスクレイピングツール。BeautifulSoupの活用を学びます。
1. アプリ概要
URLを入力してWebページのデータを抽出・CSV保存できるスクレイピングツール。BeautifulSoupの活用を学びます。
このアプリは中級カテゴリに分類される実践的なGUIアプリです。使用ライブラリは tkinter(標準ライブラリ)・beautifulsoup4 で、難易度は ★★☆ です。
このアプリは「ネットワーク」カテゴリです。ネットワーク連携は Python の代表的な活用領域で、I/O 待ちと UI 更新の関係や非同期処理の考え方が他の Web API 連携にも直接活きます。tkinter(標準ライブラリ)・beautifulsoup4 を活かして実装するこの構造は、他のアプリにも応用が効きます。
完成形を見てから細部の解説に進む流れが効果的です。実行→気になる箇所を解説で確認→自分の手で改造、というサイクルで定着が早まります。
カスタマイズ章では具体的な拡張アイデアを示しています。一つ実装するごとに動作確認することで、変更が予期せぬ副作用を起こさないかも体感できます。
2. 機能一覧
- ループで生成するボタン群(
SAMPLE_URLS定数「https://quotes.toscrape.com/」「https://books.toscrape.com/」「https://httpbin.org/html」から生成)をlambda u=url: (self.url_var.set(u), self._fetch())で接続 - 「🌐 取得」ボタン(
_fetch())・「▶ 抽出実行」ボタン(_extract())・「💾 CSV保存」ボタン(_save_csv())で操作 ttk.Treeviewによる表形式の一覧表示- CSV ファイルへの書き出し
filedialog.asksaveasfilename()によるファイル選択ダイアログmessagebox.showerror()・messagebox.showwarning()によるダイアログ通知- ウィンドウはタイトル「Webスクレイパー UI」・サイズ 920x640 で起動
3. 事前準備・環境
Python 3.10 以上 / Windows 11(実機)で起動・基本操作を確認 / Linux Mint 22.3(仮想マシン)で起動・画面表示を確認(macOS は未検証)
Windows 11(実機)で起動と基本操作を確認しています(全機能の網羅テストではありません)。Linux Mint 22.3(仮想マシン)では起動と画面表示を確認しました。macOS は必要なライブラリ(pip install)を入れれば動作する想定ですが、未検証です。
- Python 3.10 以上
- OS: Windows 11(実機で起動・基本操作を確認)・Linux Mint 22.3(仮想マシンで起動・画面表示を確認)
このアプリは入力した URL へ実際に通信します。起動直後の URL 欄には、スクレイピング練習用の公開サイトが入っています。サンプルボタンを押すと quotes.toscrape.com・books.toscrape.com・httpbin.org へすぐに取得が始まります。よそのサイトを相手にするときは、そのサイトの利用規約と robots.txt を先に確認し、取得の間隔をあけてください。ログインが必要なページや個人情報が載ったページは対象にしないでください(詳しくは9章)。
4. 完全なソースコード
右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。
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": ttk.Style().lookup("TLabelframe", "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=ttk.Style().lookup("TLabelframe", "background"))
self.selector_frame.pack(fill=tk.X, pady=4)
tk.Label(self.selector_frame, text="CSSセレクタ:",
**lbl_s).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=ttk.Style().lookup("TLabelframe", "background"))
row.pack(fill=tk.X, pady=6)
tk.Label(row, text="最大件数:", **lbl_s).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=ttk.Style().lookup("TLabelframe", "background"))
enc_row.pack(fill=tk.X, pady=2)
tk.Label(enc_row, text="エンコード:", **lbl_s).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=ttk.Style().lookup("TLabelframe", "background"))
ua_row.pack(fill=tk.X, pady=2)
tk.Label(ua_row, text="UA:", **lbl_s).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 クラスにアプリの全機能をまとめています(メソッド12個・全327行)。__init__ ではタイトル「Webスクレイパー UI」とウィンドウサイズ 920x640 を設定します。最後に _build_ui() で画面を組み立てます。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
ウィジェット構成
画面は tk.Frame×10・tk.Label×11・ttk.Entry×3・ttk.Button×6(ループで生成)・ttk.PanedWindow・ttk.LabelFrame×2・ttk.Progressbar・ttk.Style×5・ttk.Radiobutton(ループで生成)・ttk.Spinbox・ttk.Combobox・ttk.Notebook・ttk.Treeview・ttk.Scrollbar×3・tk.Text で構成しています。見出し付きの枠(LabelFrame)は「抽出設定」のラベルでエリアを区切っています。PanedWindow を使っているので、ペインの境界をマウスでドラッグして幅を変えられます。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
イベント処理とボタンの接続
「🌐 取得」ボタン(_fetch())・「▶ 抽出実行」ボタン(_extract())・「💾 CSV保存」ボタン(_save_csv())を command= で接続しています。ボタンはループ内で command=lambda u=url: (self.url_var.set(u), self._fetch()) の形で生成しています。既定引数でループ変数を固定するのがポイントで、これを書かないと全ボタンが最後の値で動いてしまいます。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
例外処理
try-except で Exception・ImportError を捕捉しています。あわせて messagebox.showerror()・messagebox.showwarning() のダイアログで、ユーザーへの通知・確認を行います。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
6. ステップバイステップガイド
このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。
-
1ファイルを作成する
新しいファイルを作成して app34.py と保存します。外部ライブラリ
bs4・lxmlを使います。先にpip install beautifulsoup4 lxmlを実行してください。(import 名と pip のパッケージ名が異なる点に注意:bs4はbeautifulsoup4でインストールします) -
2クラスの骨格を作る
App34クラスを定義し、__init__と、末尾のroot = tk.Tk()~mainloop()の最小構成を書いて、まず空のウィンドウが出ることを確認します。 -
3ウィンドウを設定する
title("Webスクレイパー UI")・geometry("920x640")・configure(bg="#f8f9fc")をコンストラクタで設定します。 -
4画面部品を並べる
_build_ui()の中で、tk.Frame×10・tk.Label×11・ttk.Entry×3・ttk.Button×6(ループで生成)・ttk.PanedWindow・ttk.LabelFrame×2・ttk.Progressbar・ttk.Style×5・ttk.Radiobutton(ループで生成)・ttk.Spinbox・ttk.Combobox・ttk.Notebook・ttk.Treeview・ttk.Scrollbar×3・tk.Textを作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。 -
5イベントを接続する
「2. 機能一覧」で挙げた各ボタンを
command=で対応するメソッド(_fetch()・_extract()・_save_csv())につなぎます。ループ生成のボタンはcommand=lambda u=url: (self.url_var.set(u), self._fetch())の形で接続します。 -
6中心になるメソッドを実装する
アプリの本体である
_show_source()(9行)・_extract()(57行)・_save_csv()(17行)・_fetch()(9行) を実装します。 -
7動作確認する
python app34.pyで起動し、各ボタンが反応することを確認します。
7. カスタマイズアイデア
基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。少しずつ機能を追加することで、Pythonのスキルが飛躍的に向上します。
💡 ダークモードを追加する
bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。
💡 クラス定数を書き換えて動きを変える
データはクラス定数(SAMPLE_URLS)にまとまっています。中身を書き換えて動きが変わることを確かめましょう。複数の定数が対応関係にある場合は、セットで整合させる必要がある点に注意してください。
💡 配色をまとめて管理する
背景色 #f8f9fc をはじめ、色コードが各所に直書きされています。色を1つの辞書にまとめると、テーマの切り替えが1か所の変更で済むようになります。
8. よくある問題と解決法
❌ 文字のフォントが崩れる・見た目が違う
原因:このコードは font=("Noto Sans JP", ...) のようにフォント名を直接指定しています。環境にこのフォントが入っていないと、OS が代わりのフォントで表示するため、見本と見た目が変わることがあります。
解決法:動作には支障ありません。気になる場合は font 引数の名前を自分の OS に入っているフォントへ書き換えるか、font 引数を省略してください。
❌ 写経したら全部のボタンが同じ動きになる
原因:ループでボタンを作るとき、このコードは command=lambda u=url: (self.url_var.set(u), self._fetch()) のように既定引数でループ変数を固定しています。これを省略してループ変数を直接書くと、ループ終了後の最後の値が全ボタンで共有されてしまいます(Python のクロージャの有名な罠です)。
解決法:掲載コードのとおり、lambda の既定引数でその時点の値を渡してください。
❌ ウィンドウの大きさが画面に合わない
原因:geometry("920x640") の固定サイズで起動するためです。
解決法:この数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。
9. 練習問題
アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。「🌐 取得」はインターネットへ接続し、入力した URL のページをそのまま読み込みます(205-217行)。よそのサイトを相手にするときは、そのサイトの利用規約と robots.txt を先に確認し、禁止されている取得はしないでください。取得したデータの保存や公開にも、著作権や規約の制限がかかります。「🌐 取得」を続けて押すと相手のサーバーに負担をかけるので、間隔をあけて回数を抑えてください。ログインが必要なページや、個人情報が載ったページは対象にしないでください。下の課題1・課題2は、自分で作ったローカルの HTML ファイルだけで確かめられます。urllib.request.urlopen() は file:/// で始まるパスも開けるので、URL 欄にそのまま入れれば通信は起きません。
-
課題1:取得に失敗しても、「▶ 抽出実行」が前のページの結果を出す
_extract()が読んでいるのはself._htmlです(235-239行)。この値を入れているのは、取得に成功したときの_show_source()だけです(224-225行)。失敗した回はステータスを書き換えて終わるので(219-222行)、前のページの中身が残ったままになります。期待結果:メモ帳で
<html><body><h1>ページA</h1></body></html>と1行だけ書き、page_a.htmlという名前で保存する。URL 欄にfile:///C:/Users/自分の名前/Desktop/page_a.htmlのように入れて「🌐 取得」を押すと、取得完了: 39 文字と出る(改行を入れると文字数は変わる)。抽出対象を「見出し一覧」にして「▶ 抽出実行」を押すとページAが1件出る。次に URL の末尾だけpage_zzz.htmlという無いファイルに変えて取得すると、エラー: <urlopen error [WinError 2] 指定されたファイルが見つかりません。...>のような文が出る。そのまま「▶ 抽出実行」を押すと、警告は出ずにページAがまた1件表示される。直したあとは先にページを取得してくださいと出る。合格条件
- 取得に失敗したあとに「▶ 抽出実行」を押すと、警告が出て古い結果は表示されない
- 取得に成功したときの抽出は、いままでどおり動く
- 失敗したときのエラー表示そのものは、いままでどおり残る
ヒント①(どこを触るか): 触るのは1か所です。
_fetch()で取得を始める直前(200-203行)です。_do_fetch()のexcept(219-222行)へ同じ1行を置く書き方でも直せます。
ヒント②(使うもの):_extract()の入口はhasattr(self, "_html")と中身が空かどうかで判断しています(235-236行)。取得を始めるときにself._html = ""を入れておけば、失敗した回はそのまま警告側へ落ちます。成功したときは_show_source()が入れ直してくれます(225行)。
つまずきやすい点:_do_fetch()は別のスレッドで動くので(203行)、画面に触る処理はself.root.after(0, ...)を通しています(218行・220-222行)。self._htmlはウィジェットではないため、そのまま代入して構いません。_htmlは__init__に無い属性なので(27-33行)、一度も取得していないうちはhasattr()の側で弾かれます。 -
課題2:エンコードの「自動検出」は
<meta charset>を見ないので、名乗らないページで文字化けする「自動検出」を選ぶと、
Content-Typeヘッダーの中のcharset=だけを探します(212-216行)。見つからなければutf-8と決め打ちします。HTML の中に書かれた<meta charset>のほうは読んでいません。期待結果:メモ帳で
<html><head><meta charset="shift_jis"></head><body><h1>日本語の見出し</h1></body></html>と書く。「名前を付けて保存」で文字コードを ANSI にしてsjis_test.htmlとして保存する(日本語環境の Windows では Shift_JIS になる)。エンコードを「自動検出」にしてこのファイルを取得すると、「HTMLソース」タブに<h1>���{��̌��o��</h1>のような記号が並ぶ。エンコードをshift_jisに選び直して取得すると、正しく表示される。直したあとは「自動検出」のままでも正しく読める。合格条件
- サーバーが文字コードを名乗らないページでも、
<meta charset>の指定どおりに読める - ヘッダーに
charset=が書かれているときは、そちらが優先される - エンコードを手で選んだときの動きは、いままでどおり
ヒント①(どこを触るか): 触るのは1か所です。
_do_fetch()の自動検出の部分(211-217行)。
ヒント②(使うもの):rawはまだ文字に直していないバイト列です(210行)。バイト列のままre.search(rb"charset=[\"']?([\w-]+)", raw[:2048], re.IGNORECASE)のように探せば、<meta charset>の指定を拾えます。取り出した名前はbytesなので、decode("ascii")で文字列に直してから 217行へ渡します。
つまずきやすい点: 217行にはerrors="replace"が付いているので、文字コードを間違えても例外は出ません。読めなかった場所が記号に置き換わるだけです。存在しない文字コード名を渡したときはLookupErrorになるため、tryで囲んでutf-8に戻す道を用意しておくと安全です。 - サーバーが文字コードを名乗らないページでも、
-
課題3:キー操作に対応させる
ボタンから呼んでいる
_fetch()を、キーボードのキーからも呼べるようにしてみましょう。tkinter では bind を使ってキーイベントとメソッドを結び付けられます。
写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。
このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。