メール一括送信ツール
CSVで宛先リストを取り込みsmtplibで差し込みメールを一括送信するツール。送信前の件数確認と結果一覧表示付き。
1. アプリ概要
CSVで宛先リストを取り込みsmtplibで差し込みメールを一括送信するツール。送信前の件数確認と結果一覧表示付き。
このアプリはutilカテゴリの実践的なPythonアプリです。使用ライブラリは tkinter(標準ライブラリ)、難易度は ★★★ です。
このアプリは「ユーティリティ」カテゴリです。小回りの効くユーティリティは Python の真骨頂で、汎用的なユーザー操作を組み合わせる発想が他のアプリにもそのまま流用できます。tkinter(標準ライブラリ) を活かして実装するこの構造は、他のアプリにも応用が効きます。
コードを読む前に実行することをおすすめします。動いている挙動を先に把握しておくと、解説で出てくる関数や処理がどこに対応するかが頭に入りやすくなります。
応用のヒントは、機能を 1 つ増やす・見た目を整える・例外を一つでも丁寧に扱う、のいずれかから始めるのがおすすめです。
2. 機能一覧
- 「+ 追加」ボタン(
_add_recipient())・「📂 CSVインポート」ボタン(_import_csv())・「🗑 削除」ボタン(_remove_recipient())・「全クリア」ボタン(_clear_recipients())・「ファイル選択」ボタン(_pick_attachment())・「クリア」ボタン(lambda: (self.att_var.set('(なし)'), setattr(self, '_attachment', None)))・「📧 一括送信開始」ボタン(_send_all())・「🔧 接続テスト」ボタン(_test_connection())・「追加」ボタンで操作 ttk.Treeviewによる表形式の一覧表示filedialog.askopenfilename()によるファイル選択ダイアログmessagebox.askyesno()・messagebox.showerror()・messagebox.showwarning()によるダイアログ通知- ウィンドウはタイトル「メール一括送信ツール」・サイズ 960x645 で起動
3. 事前準備・環境
Python 3.10 以上 / Windows 11(実機)で起動・基本操作を確認 / Linux Mint 22.3(仮想マシン)で起動・画面表示を確認(macOS は未検証)
Windows 11(実機)で起動と基本操作を確認しています(全機能の網羅テストではありません)。Linux Mint 22.3(仮想マシン)では起動と画面表示を確認しました。macOS は標準ライブラリの範囲で動作する想定ですが、未検証です。
- Python 3.10 以上
- OS: Windows 11(実機で起動・基本操作を確認)・Linux Mint 22.3(仮想マシンで起動・画面表示を確認)
4. 完全なソースコード
右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import smtplib
import threading
import csv
import os
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders
from datetime import datetime
class App093:
"""メール一括送信ツール"""
def __init__(self, root):
self.root = root
self.root.title("メール一括送信ツール")
self.root.geometry("960x645")
self.root.minsize(960, 645)
self.root.configure(bg="#1e1e1e")
self._recipients = [] # [{"name": ..., "email": ..., "status": ...}]
self._attachment = None
self._build_ui()
def _build_ui(self):
header = tk.Frame(self.root, bg="#252526", pady=6)
header.pack(fill=tk.X)
tk.Label(header, text="📧 メール一括送信ツール",
font=("Noto Sans JP", 12, "bold"),
bg="#252526", fg="#4fc3f7").pack(side=tk.LEFT, padx=12)
tk.Label(self.root,
text="⚠ 送信テストは必ずダミーメールサービス (Mailtrap等) で行ってください。",
bg="#fff3cd", fg="#856404", font=("Arial", 9),
anchor="w", padx=8).pack(fill=tk.X)
# メインPaned
paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
paned.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)
# 左: SMTP + 宛先
left = tk.Frame(paned, bg="#1e1e1e", width=420)
paned.add(left, weight=0)
# SMTP設定
smtp_f = tk.LabelFrame(left, text="SMTP設定", bg="#252526",
fg="#ccc", font=("Arial", 9), padx=6, pady=4)
smtp_f.pack(fill=tk.X, padx=4, pady=4)
fields = [
("SMTPサーバー", "smtp_host", "smtp.gmail.com"),
("ポート", "smtp_port", "587"),
("ユーザー名", "smtp_user", "your@email.com"),
("パスワード", "smtp_pass", ""),
("差出人名", "from_name", "送信者名"),
]
self._smtp_vars = {}
for i, (label, key, default) in enumerate(fields):
tk.Label(smtp_f, text=f"{label}:", bg="#252526", fg="#ccc",
font=("Arial", 9), width=12, anchor="e").grid(
row=i, column=0, sticky="e", pady=1)
v = tk.StringVar(value=default)
self._smtp_vars[key] = v
show = "*" if key == "smtp_pass" else ""
ttk.Entry(smtp_f, textvariable=v, width=22,
show=show).grid(row=i, column=1, padx=4, pady=1, sticky="w")
self.tls_var = tk.BooleanVar(value=True)
tk.Checkbutton(smtp_f, text="TLS/STARTTLS を使用",
variable=self.tls_var,
bg="#252526", fg="#ccc", selectcolor="#3c3c3c",
activebackground="#252526").grid(
row=len(fields), column=0, columnspan=2, sticky="w")
# 宛先リスト
tk.Label(left, text="宛先一覧", bg="#1e1e1e", fg="#888",
font=("Arial", 9)).pack(anchor="w", padx=4, pady=(8, 0))
btn_row = tk.Frame(left, bg="#1e1e1e")
btn_row.pack(fill=tk.X, padx=4)
ttk.Button(btn_row, text="+ 追加",
command=self._add_recipient).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_row, text="📂 CSVインポート",
command=self._import_csv).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_row, text="🗑 削除",
command=self._remove_recipient).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_row, text="全クリア",
command=self._clear_recipients).pack(side=tk.LEFT, padx=2)
cols = ("status", "name", "email")
self.rec_tree = ttk.Treeview(left, columns=cols, show="headings", height=10)
self.rec_tree.heading("status", text="状態")
self.rec_tree.heading("name", text="名前")
self.rec_tree.heading("email", text="メールアドレス")
self.rec_tree.column("status", width=60, anchor="center")
self.rec_tree.column("name", width=100, anchor="w")
self.rec_tree.column("email", width=200, anchor="w")
rsb = ttk.Scrollbar(left, command=self.rec_tree.yview)
self.rec_tree.configure(yscrollcommand=rsb.set)
rsb.pack(side=tk.RIGHT, fill=tk.Y)
self.rec_tree.pack(fill=tk.BOTH, expand=True, padx=(4, 0))
self.rec_tree.tag_configure("sent", foreground="#26a69a")
self.rec_tree.tag_configure("error", foreground="#ef5350")
# 右: メール本文
right = tk.Frame(paned, bg="#1e1e1e")
paned.add(right, weight=1)
tk.Label(right, text="件名:", bg="#1e1e1e", fg="#ccc",
font=("Arial", 9)).pack(anchor="w", padx=4)
self.subject_var = tk.StringVar(value="お知らせ")
ttk.Entry(right, textvariable=self.subject_var).pack(
fill=tk.X, padx=4, pady=2)
tk.Label(right, text="本文 ({name} で宛先名を差し込み):",
bg="#1e1e1e", fg="#ccc", font=("Arial", 9)).pack(
anchor="w", padx=4)
self.body_text = tk.Text(right, bg="#0d1117", fg="#c9d1d9",
font=("Arial", 10), relief=tk.FLAT,
insertbackground="white", wrap=tk.WORD)
bsb = ttk.Scrollbar(right, command=self.body_text.yview)
self.body_text.configure(yscrollcommand=bsb.set)
bsb.pack(side=tk.RIGHT, fill=tk.Y)
self.body_text.pack(fill=tk.BOTH, expand=True, padx=(4, 0))
self.body_text.insert("1.0",
"{name} 様\n\nいつもお世話になっております。\n\nよろしくお願いいたします。")
# 添付ファイル
att_row = tk.Frame(right, bg="#1e1e1e")
att_row.pack(fill=tk.X, padx=4, pady=2)
tk.Label(att_row, text="添付:", bg="#1e1e1e", fg="#ccc",
font=("Arial", 9)).pack(side=tk.LEFT)
self.att_var = tk.StringVar(value="(なし)")
tk.Label(att_row, textvariable=self.att_var, bg="#1e1e1e",
fg="#888", font=("Arial", 9)).pack(side=tk.LEFT, padx=4)
ttk.Button(att_row, text="ファイル選択",
command=self._pick_attachment).pack(side=tk.LEFT)
ttk.Button(att_row, text="クリア",
command=lambda: (self.att_var.set("(なし)"),
setattr(self, "_attachment", None))
).pack(side=tk.LEFT, padx=2)
# 送信ボタン
btn_row2 = tk.Frame(right, bg="#1e1e1e", pady=4)
btn_row2.pack(fill=tk.X, padx=4)
tk.Button(btn_row2, text="📧 一括送信開始", command=self._send_all,
bg="#1565c0", fg="white", relief=tk.FLAT,
font=("Arial", 12, "bold"), padx=20, pady=6,
activebackground="#0d47a1", bd=0).pack(side=tk.LEFT)
ttk.Button(btn_row2, text="🔧 接続テスト",
command=self._test_connection).pack(side=tk.LEFT, padx=8)
self.progress = ttk.Progressbar(self.root, mode="determinate")
self.progress.pack(fill=tk.X, padx=8)
self.status_var = tk.StringVar(value="SMTP設定と宛先を入力してください")
tk.Label(self.root, textvariable=self.status_var,
bg="#252526", fg="#858585", font=("Arial", 9),
anchor="w", padx=8).pack(fill=tk.X, side=tk.BOTTOM)
# ── 宛先操作 ─────────────────────────────────────────────
def _add_recipient(self):
dlg = tk.Toplevel(self.root)
dlg.title("宛先追加")
dlg.configure(bg="#252526")
dlg.grab_set()
name_var = tk.StringVar()
email_var = tk.StringVar()
for label, var in [("名前:", name_var), ("メール:", email_var)]:
row = tk.Frame(dlg, bg="#252526")
row.pack(fill=tk.X, padx=12, pady=4)
tk.Label(row, text=label, bg="#252526", fg="#ccc",
width=8, anchor="w").pack(side=tk.LEFT)
ttk.Entry(row, textvariable=var, width=26).pack(side=tk.LEFT)
def _ok():
n = name_var.get().strip()
e = email_var.get().strip()
if not e:
return
self._recipients.append({"name": n, "email": e, "status": "未送信"})
self.rec_tree.insert("", tk.END, values=("─", n, e))
dlg.destroy()
ttk.Button(dlg, text="追加", command=_ok).pack(pady=8)
def _import_csv(self):
path = filedialog.askopenfilename(
filetypes=[("CSV", "*.csv"), ("すべて", "*.*")])
if not path:
return
try:
with open(path, encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f)
for row in reader:
name = row.get("name", row.get("名前", ""))
email = row.get("email", row.get("メール", row.get("mail", "")))
if email:
self._recipients.append(
{"name": name, "email": email, "status": "未送信"})
self.rec_tree.insert("", tk.END,
values=("─", name, email))
self.status_var.set(f"インポート完了: {len(self._recipients)} 件")
except Exception as e:
messagebox.showerror("エラー", str(e))
def _remove_recipient(self):
for sel in self.rec_tree.selection():
idx = self.rec_tree.index(sel)
if idx < len(self._recipients):
del self._recipients[idx]
self.rec_tree.delete(sel)
def _clear_recipients(self):
self._recipients.clear()
self.rec_tree.delete(*self.rec_tree.get_children())
def _pick_attachment(self):
path = filedialog.askopenfilename()
if path:
self._attachment = path
self.att_var.set(os.path.basename(path))
# ── SMTP ────────────────────────────────────────────────
def _get_smtp(self):
host = self._smtp_vars["smtp_host"].get()
port = int(self._smtp_vars["smtp_port"].get())
user = self._smtp_vars["smtp_user"].get()
pwd = self._smtp_vars["smtp_pass"].get()
tls = self.tls_var.get()
s = smtplib.SMTP(host, port, timeout=10)
if tls:
s.starttls()
if user and pwd:
s.login(user, pwd)
return s
def _test_connection(self):
self.status_var.set("接続テスト中...")
def _do():
try:
s = self._get_smtp()
s.quit()
self.root.after(0, self.status_var.set, "接続テスト: 成功")
except Exception as e:
self.root.after(0, self.status_var.set, f"接続テスト: 失敗 - {e}")
threading.Thread(target=_do, daemon=True).start()
def _build_msg(self, recipient):
from_name = self._smtp_vars["from_name"].get()
from_email = self._smtp_vars["smtp_user"].get()
subject = self.subject_var.get()
body_tpl = self.body_text.get("1.0", tk.END)
body = body_tpl.replace("{name}", recipient["name"])
msg = MIMEMultipart()
msg["From"] = f"{from_name} <{from_email}>"
msg["To"] = recipient["email"]
msg["Subject"] = subject
msg.attach(MIMEText(body, "plain", "utf-8"))
if self._attachment:
with open(self._attachment, "rb") as f:
part = MIMEBase("application", "octet-stream")
part.set_payload(f.read())
encoders.encode_base64(part)
part.add_header("Content-Disposition",
f'attachment; filename="{os.path.basename(self._attachment)}"')
msg.attach(part)
return msg
def _send_all(self):
if not self._recipients:
messagebox.showwarning("警告", "宛先を追加してください")
return
if not messagebox.askyesno("確認",
f"{len(self._recipients)} 件にメールを送信しますか?"):
return
self.progress["maximum"] = len(self._recipients)
self.progress["value"] = 0
threading.Thread(target=self._do_send, daemon=True).start()
def _do_send(self):
ok = err = 0
try:
smtp = self._get_smtp()
except Exception as e:
self.root.after(0, self.status_var.set, f"SMTP接続失敗: {e}")
return
iids = self.rec_tree.get_children()
for i, (rec, iid) in enumerate(zip(self._recipients, iids)):
try:
msg = self._build_msg(rec)
smtp.sendmail(self._smtp_vars["smtp_user"].get(),
[rec["email"]], msg.as_string())
rec["status"] = "送信済"
self.root.after(0, self.rec_tree.item, iid,
{"values": ("✔", rec["name"], rec["email"]),
"tags": ("sent",)})
ok += 1
except Exception as e:
rec["status"] = f"エラー: {e}"
self.root.after(0, self.rec_tree.item, iid,
{"values": (f"✘", rec["name"], rec["email"]),
"tags": ("error",)})
err += 1
self.root.after(0, self.progress.configure, {"value": i + 1})
smtp.quit()
self.root.after(0, self.status_var.set,
f"送信完了: {ok} 件成功 / {err} 件失敗")
if __name__ == "__main__":
root = tk.Tk()
app = App093(root)
root.mainloop()
5. コード解説
メール一括送信ツールのコードを、実際に書かれている実装に沿って解説します。
クラス設計とコンストラクタ
App093 クラスにアプリの全機能をまとめています(メソッド12個・全316行)。__init__ ではタイトル「メール一括送信ツール」とウィンドウサイズ 960x645 を設定します。あわせて状態を保持する変数(self._attachment)を初期化し、最後に _build_ui() で画面を組み立てます。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
ウィジェット構成
画面は tk.Frame×6(ほかにループでも生成)・tk.Label×8(ほかにループでも生成)・ttk.PanedWindow・tk.LabelFrame・ttk.Entry(ループで生成)・tk.Checkbutton・ttk.Button×8・ttk.Treeview・ttk.Scrollbar×2・tk.Text・tk.Button・ttk.Progressbar・tk.Toplevel で構成しています。見出し付きの枠(LabelFrame)は「SMTP設定」のラベルでエリアを区切っています。PanedWindow を使っているので、ペインの境界をマウスでドラッグして幅を変えられます。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
イベント処理とボタンの接続
「+ 追加」ボタン(_add_recipient())・「📂 CSVインポート」ボタン(_import_csv())・「🗑 削除」ボタン(_remove_recipient())・「全クリア」ボタン(_clear_recipients())・「ファイル選択」ボタン(_pick_attachment())・「クリア」ボタン(lambda: (self.att_var.set('(なし)'), setattr(self, '_attachment', None)))・「📧 一括送信開始」ボタン(_send_all())・「🔧 接続テスト」ボタン(_test_connection())・「追加」ボタンを command= で接続しています。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
例外処理
try-except で Exception を捕捉しています。あわせて messagebox.askyesno()・messagebox.showerror()・messagebox.showwarning() のダイアログで、ユーザーへの通知・確認を行います。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
6. ステップバイステップガイド
このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。
-
1ファイルを作成する
新しいファイルを作成して app093.py と保存します。使うのは
csv・datetime・email・os・smtplib・threading・tkinterだけなので、追加インストールは不要です。 -
2クラスの骨格を作る
App093クラスを定義し、__init__と、末尾のroot = tk.Tk()~mainloop()の最小構成を書いて、まず空のウィンドウが出ることを確認します。 -
3ウィンドウを設定する
title("メール一括送信ツール")・geometry("960x645")・configure(bg="#1e1e1e")・minsize(960, 645)をコンストラクタで設定します。 -
4画面部品を並べる
_build_ui()の中で、tk.Frame×6(ほかにループでも生成)・tk.Label×8(ほかにループでも生成)・ttk.PanedWindow・tk.LabelFrame・ttk.Entry(ループで生成)・tk.Checkbutton・ttk.Button×8・ttk.Treeview・ttk.Scrollbar×2・tk.Text・tk.Button・ttk.Progressbar・tk.Toplevelを作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。 -
5イベントを接続する
「2. 機能一覧」で挙げた各ボタンを
command=で対応するメソッド(_add_recipient()・_import_csv()・_remove_recipient()・_clear_recipients()・_pick_attachment()・lambda: (self.att_var.set('(なし)'), setattr(self, '_attachment', None))・_send_all()・_test_connection()・_ok)につなぎます。 -
6中心になるメソッドを実装する
アプリの本体である
_add_recipient()(22行)・_import_csv()(19行)・_test_connection()(10行)など を実装します。 -
7動作確認する
python app093.pyで起動し、各ボタンが反応することを確認します。
7. カスタマイズアイデア
基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。
💡 ダークモードを追加する
bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。
💡 結果をファイルに保存する
このコードに保存処理はないため、表示中の数値や結果はアプリを閉じると消えます。テキストファイルへ書き出して、次回起動時に読み込む機能を追加してみましょう。
💡 設定ダイアログ
フォントサイズや色などの設定をユーザーが変更できるオプションダイアログを追加しましょう。
8. よくある問題と解決法
❌ 文字のフォントが崩れる・見た目が違う
原因:このコードは font=("Noto Sans JP", ...) のようにフォント名を直接指定しています。環境にこのフォントが入っていないと、OS が代わりのフォントで表示するため、見本と見た目が変わることがあります。
解決法:動作には支障ありません。気になる場合は font 引数の名前を自分の OS に入っているフォントへ書き換えるか、font 引数を省略してください。
❌ ウィンドウの大きさが画面に合わない
原因:geometry("960x645") の固定サイズで起動するためです。
解決法:geometry() と minsize() の両方の数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。ただし minsize(960, 645) を指定しているため、起動時の大きさより小さくは縮められません(縮めると画面部品が表示されなくなるのを防ぐためです)。
9. 練習問題
アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。試すときの宛先は自分のアドレスかダミーの受信サービスだけにし、実在の相手へ一斉送信しないでください。SMTP のパスワードはコードや CSV に書かず、画面の入力欄に都度入れる今の作りのままにします。保存する機能は足さない前提で、下の2問に取り組んでください。なお、広告や宣伝を含むメールを実際に一斉送信する場合は、特定電子メール法の規制対象になります。同意を得た相手にだけ送ること、送信者の氏名・連絡先を書くこと、受信拒否の連絡先を書くことが求められます。
-
課題1:送信に失敗した理由を画面で確かめられるようにする
_do_send()は失敗したときrec["status"]へ理由を入れますが、一覧に出るのは✘だけです。理由はどこにも表示されません。行を選んだら、その理由を下の帯に出してください。期待結果:宛先を追加した直後に行をクリックすると帯に「未送信」と出る。送信に失敗した行をクリックすると「エラー: ...」と理由に変わる(SMTPの設定が無くても、前半だけで配線の確認はできます)。
合格条件
- 送信済みの行を選ぶと「送信済」と出る
- 「全クリア」のあとに一覧を触っても落ちない
- 送信し直すと、帯に出る理由も新しいものに変わる
ヒント①(どこを触るか): 触るのは2か所です。
_build_ui()でself.rec_treeに<<TreeviewSelect>>のバインドを1つ足し、選ばれた行の状態を帯に出すメソッドを1つ書きます。
ヒント②(使うもの): 何番目かはself.rec_tree.index(sel)で分かります(_remove_recipient()と同じ書き方です)。理由はself._recipients[idx]["status"]に入っています。表示はself.status_var.set(...)です。
つまずきやすい点:self.rec_tree.selection()は空のタプルを返すことがあるため、先頭を取り出す前に中身の有無を確かめてください。_do_send()は別スレッドで動きますが、行を選んだときの処理はメインスレッドなのでafter()は要りません。状態列の✔・✘は見た目だけで、理由はself._recipients側にしかありません。 -
課題2:宛先アドレスの形を確かめてから追加する
「+ 追加」は
@の無い文字列でもそのまま一覧に入ります。CSV インポートも、email列に何か入っていれば通します。形の違う宛先は入れず、いくつ弾いたかを帯に出してください。期待結果:「+ 追加」で
abcと入れると一覧に入らず、案内が出てダイアログは開いたまま残る。合格条件
name@example.comはこれまでどおり追加できる- CSV インポートで形の違う行を飛ばし、帯に取り込んだ件数と飛ばした件数が出る
- 「🗑 削除」「全クリア」の動きは変わらない
ヒント①(どこを触るか): 触るのは2か所です。
_add_recipient()の中の_ok()にあるif not e:と、_import_csv()のif email:の判定を厳しくします。
ヒント②(使うもの): 先頭にimport reを足し、re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", e)のような形で確かめます。案内はmessagebox.showwarning(...)が使えます(_send_all()と同じ書き方です)。
つまずきやすい点:_ok()の中でreturnせずに進むとdlg.destroy()が走り、ダイアログが閉じます。self._recipientsへの追加とself.rec_tree.insert(...)は必ずセットで飛ばしてください。片方だけ飛ばすと_do_send()のzip(self._recipients, iids)がずれ、別の人の名前で送られます。 -
課題3:保存機能の追加
処理結果をファイルに保存する機能を追加しましょう。
写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。
このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。