2万件のkindleハイライトを横断検索する.3

# ビューア編

スクレイピングが成功し、全ハイライトを含む夢のcsvファイルが出来上がりました。
あとはハイライトビューアとして横断検索可能なアプリケーションを仕上げるだけです。

ついでにハイライト1件ごとにランダム表示できるようにもしました。乱読家の私は、読んだとき「いいな」と思ってハイライトしてもすぐに忘れてしまうので、偶発的に思い出せるようにランダム表示もどうしてもほしかったのです。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4066587/e014d670-a5aa-4c2d-9f5e-b6d6a10d520e.png" width=50%>


1冊読むごとにハイライトは増えていくので、csvファイルは適宜更新される予定です。ですから、横断検索やランダム表示の元となるcsvファイルは、コード内部で指定するのではなく、アプリケーション上で任意に指定できるようにする必要がありました。
当初はアプリケーションを起動するたびにcsvファイルを毎回指定する必要があったのですが、後に改修を加えて、完成版では前回起動時に指定したcsvファイルがそのまま残るようになっています。スクレイピングして新しいcsvファイルが出来たときだけ、指定し直せばOKです。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4066587/8c5605ea-8a07-41e3-b730-cf1582aee14b.png" width=50%>


スクレイピングのコードが完成した時点では、ハイライトビューアにスクレイピング自体も組み込むつもりでいました。しかし、スクレイピング中は横断検索やランダム表示ができなくなってしまうので、独立したコードとして維持することにしました。
しかし、最終的にバッチファイルを使って、簡易ではあるものの統合して使いやすくしています。

ハイライトビューアは、システムダイアログか？っていうくらい無骨な見た目ですが、うまれてはじめて作ったアプリケーションなのでいっそ狂おしいほどに愛しいです。



## コード入力

スクレイピング編で作成したrun_idle.batをクリックして、仮想環境下でIDLEを起動します。
IDLEの画面でfile>new fileと進み、コードを入力する画面を表示したら、以下のコードを貼り付けます。
名前は「random_search.py」として、ルートフォルダ直下に保存します。

```random_search.py
import ctypes
import tkinter as tk
from tkinter import ttk
import tkinter.font as tkfont
import pandas as pd
from tkinter import filedialog
import os
import json
import random
from tkinter import messagebox

df = None         # 全データ（DataFrame）を格納
CURRENT_CSV = ""  # 現在選択中の CSV パス
CONFIG_PATH = os.path.join(os.path.expanduser("~"), ".random_search_config.json")

def setup_dpi():
    """Windows の高 DPI 対応"""
    try:
        ctypes.windll.shcore.SetProcessDpiAwareness(1)
    except Exception:
        pass

def show_random(text_widget):
    """読み込まれたDataFrameからランダムに1件取り出し、Textウィジェットに表示"""
    global df
    if df is None or df.empty:
        messagebox.showwarning("データ未読み込み", "まずはCSVファイルを選択してください。")
        return

    # 既存の表示をクリア
    text_widget.config(state="normal")
    text_widget.delete("1.0", tk.END)

    # ランダム抽出
    row = df.sample(n=1).iloc[0]
    title, author, body = row['タイトル'], row['著者名'], row['ハイライト本文']

    # 見出し行
    header = f"【ランダム】 {title} — {author}\n"
    text_widget.insert("end", header, "header")

    # 本文行
    text_widget.insert("end", body + "\n")

    # 読み取り専用に戻す
    text_widget.config(state="disabled")


def perform_search(text_widget, entry_widget):
    """AND検索：Entryから複数キーワードを取得→DataFrameを横断検索→結果をTextに表示"""
    global df
    if df is None or df.empty:
        messagebox.showwarning("データ未読み込み", "まずはCSVファイルを選択してください。")
        return

    # 1. キーワードをスペースで分割（例："泉 日本列島" → ["泉","日本列島"]）
    keywords = entry_widget.get().strip().split()
    if not keywords:
        messagebox.showinfo("キーワード未入力", "検索ワードを入力してください。")
        return

    # 2. AND 条件のマスク作成
    mask = pd.Series(True, index=df.index)
    for kw in keywords:
        # 各列のいずれかに含まれる行を True に
        cond = (
            df['タイトル'].str.contains(kw, na=False) |
            df['著者名'].str.contains(kw, na=False) |
            df['ハイライト本文'].str.contains(kw, na=False)
        )
        mask &= cond

    results = df[mask]

    # 3. Text ウィジェットの初期化
    text_widget.config(state="normal")
    text_widget.delete("1.0", tk.END)

    if results.empty:
        text_widget.insert("end", "該当するハイライトがありません。")
    else:
        # 4. 結果表示
        for idx, row in results.iterrows():
            header = f"【{idx+1}】 {row['タイトル']} — {row['著者名']}\n"
            text_widget.insert("end", header, "header")
            text_widget.insert("end", row['ハイライト本文'] + "\n\n")

        # 5. キーワード強調（各キーワードごとに）
        for kw in keywords:
            start = "1.0"
            while True:
                pos = text_widget.search(kw, start, tk.END)
                if not pos:
                    break
                end_pos = f"{pos}+{len(kw)}c"
                text_widget.tag_add("highlight", pos, end_pos)
                start = end_pos

    text_widget.config(state="disabled")


def save_last_csv(path):
    """CURRENT_CSV を設定ファイルに保存"""
    try:
        with open(CONFIG_PATH, "w", encoding="utf-8") as f:
            json.dump({"last_csv": path}, f)
    except Exception as e:
        messagebox.showerror("保存エラー", f"設定ファイルの保存に失敗しました:\n{e}")

def load_last_csv(label_widget):
    """設定ファイルから前回 CSV パスを読み込み、自動で DataFrame にセット"""
    global df, CURRENT_CSV
    if not os.path.exists(CONFIG_PATH):
        return
    try:
        with open(CONFIG_PATH, "r", encoding="utf-8") as f:
            data = json.load(f)
        path = data.get("last_csv", "")
        if path and os.path.exists(path):
            # CSV 読み込み
            df = pd.read_csv(path, header=None, names=['タイトル','著者名','ハイライト本文'])
            CURRENT_CSV = path
            # ラベル更新
            label_widget.config(text=f"選択中：{path}")
    except Exception as e:
        # 読み込み失敗は警告程度に
        messagebox.showwarning("読み込みエラー", f"前回の設定ファイルの読み込みに失敗しました:\n{e}")

    

def select_csv(label_widget):
    """ファイル選択ダイアログを開き、CSVを読み込んでパスをラベルに表示"""
    global df, CURRENT_CSV
    path = filedialog.askopenfilename(
        title="ハイライトCSVを選択",
        filetypes=[("CSV ファイル", "*.csv"), ("すべてのファイル", "*.*")]
    )
    if not path:
        return
    # DataFrameに読み込む
    df = pd.read_csv(path, header=None, names=['タイトル','著者名','ハイライト本文'])
    CURRENT_CSV = path
    # 前回選択の保存
    save_last_csv(path)
    # ラベルに選択ファイル名を更新
    label_widget.config(text=f"選択中：{path}")
    

def _on_mousewheel(event):
    """マウスホイールを1ノッチで約3行分スクロール"""
    # event.widget でスクロール対象のウィジェットを自動取得
    widget = event.widget
    # delta/120 が1ノッチ分の回転量なので ×3行
    widget.yview_scroll(int(-1 * (event.delta / 120) * 3), "units")



def create_main_window():
    """メインウィンドウと共通設定の初期化"""
    # DPI 対応
    setup_dpi()

    # メインウィンドウ生成
    root = tk.Tk()
    root.title("Kindle ハイライトビューア")
    root.geometry("1000x1200")

    # ttk テーマ
    style = ttk.Style(root)
    style.theme_use("winnative")  # 環境に応じて"xpnative"等に変更可

    # フォント設定
    global HEADER_FONT, TEXT_FONT
    HEADER_FONT = tkfont.Font(family="Meiryo", size=13, weight="bold")
    TEXT_FONT   = tkfont.Font(family="Meiryo", size=11)

    # 未選択タブの背景色
    style.configure("TNotebook.Tab", background="#d9d9d9")
    # 選択タブの背景色
    style.map("TNotebook.Tab", background=[("selected", "#ffffff")])

    return root

def main():
    root = create_main_window()

    # Notebook（タブ）の設置
    notebook = ttk.Notebook(root)
    notebook.pack(fill="both", expand=True)

    # 各タブ用フレームを作成して notebook.add() で後続ステップに備える
    frame_random = ttk.Frame(notebook)
    frame_search = ttk.Frame(notebook)
    frame_file   = ttk.Frame(notebook)

    notebook.add(frame_random, text=" random ")
        # ── ランダム表示タブの UI ─────────────────────────────────────────────
    # ① 「次を表示」ボタンを上部に配置
    btn_random = ttk.Button(
        frame_random,
        text="次を表示",
        command=lambda: show_random(txt_random)
    )
    btn_random.pack(side="top", padx=10, pady=(10, 5))

    # ② Textウィジェット（スクロール付）を下に配置
    txt_random = tk.Text(frame_random, wrap="word", font=TEXT_FONT)
    txt_random.pack(side="left", fill="both", expand=True, padx=(10,0), pady=10)
    scroll_r = ttk.Scrollbar(frame_random, orient="vertical", command=txt_random.yview)
    txt_random.configure(yscrollcommand=scroll_r.set)
    scroll_r.pack(side="left", fill="y", pady=10)
    txt_random.bind("<MouseWheel>", _on_mousewheel)

    # ③ キーワードハイライト用のタグ（既に HEADER_FONT で定義）
    txt_random.tag_configure("header", font=HEADER_FONT)

    
    # ── 検索タブの UI ──────────────────────────────────────────────
    notebook.add(frame_search, text=" search ")

    # ① キーワード入力＆検索ボタンを先に配置（上部に横並び）
    entry_search = ttk.Entry(frame_search)
    entry_search.pack(side="top", padx=10, pady=(10, 5))
    entry_search.bind(
        "<Return>",
        lambda event: perform_search(txt_search, entry_search)
    )

    btn_search = ttk.Button(
        frame_search,
        text="検索",
        command=lambda: perform_search(txt_search, entry_search)
    )
    btn_search.pack(side="top", padx=10, pady=(0, 10))

    # ② 結果表示用 Text ＋ スクロールバー（下段に配置）
    txt_search = tk.Text(frame_search, wrap="word", font=TEXT_FONT, width=20)
    txt_search.pack(side="left", fill="both", expand=True, padx=(10,0), pady=10)

    scroll_s = ttk.Scrollbar(frame_search, orient="vertical", command=txt_search.yview)
    txt_search.configure(yscrollcommand=scroll_s.set)
    scroll_s.pack(side="left", fill="y", pady=10)

    # ③ タグ設定とホイールバインド
    txt_search.tag_configure("header", font=HEADER_FONT)
    txt_search.tag_configure("highlight", background="yellow")
    txt_search.bind("<MouseWheel>", _on_mousewheel)
    
    notebook.add(frame_file,   text=" file ")

    # ※ここから先はステップ 3 以降で実装

    # ── ファイル指定タブの UI ─────────────────────────────────────────────
    # １）ラベルを先に定義
    lbl_path = ttk.Label(frame_file, text="選択中：なし", wraplength=400)
    lbl_path.pack(padx=10, pady=5)

    # ２）そのラベルをコマンドに渡すボタンを定義
    btn_open = ttk.Button(
    frame_file,
    text="CSVファイルを選択",
    command=lambda: select_csv(lbl_path)
    )
    btn_open.pack(pady=10)
    # 起動時に前回指定の CSV ファイルを自動読み込み
    load_last_csv(lbl_path)


    root.mainloop()

if __name__ == "__main__":
    main()
```

必要なライブラリはスクレイピング編でインストールできているはずなので、改めて付け足す必要はないはずです。



## 起動トリガー

scraper.pyを簡単に起動するためにrun_scraper.batを作ったように、random_search.pyにも簡単起動のためのバッチファイルを作成します。

ただし、今回のバッチファイルはただrandom_search.pyを簡易起動するだけにとどまらず、scraper.pyと統合させることにしました。絶対パスでファイルを指定したので、ビューアとスクレイパーの起動トリガーとして好きな場所に設置できます。私はとりあえず「run.bat」という名前のバッチファイルにして、デスクトップに置いています。

起動トリガーのコードは以下の通りです。

```run.bat
@echo off
setlocal

rem 1) 作業フォルダへ移動
cd /d C:\Users\ユーザー名\Documents\python_kindle

rem 2) 仮想環境をアクティベート
call .\.venv\Scripts\activate.bat

:MENU
echo.
echo   1: アプリケーション (random_search.py) を起動
echo   2: IDLE で scraper.py を開いて実行
echo.
choice /C 12 /M "実行したい番号を押してください (1 または 2): "

if errorlevel 2 goto RUN_IDLE
if errorlevel 1 goto RUN_RANDOM

goto :EOF

:RUN_RANDOM
rem --- 1 を選んだ場合: GUI アプリのみを非同期起動してバッチを即終了
start "" pythonw random_search.py
exit /B

:RUN_IDLE
rem --- 2 を選んだ場合: IDLE を起動して scraper.py を開き、自動実行
start "" python -m idlelib -r scraper.py
exit /B
```

このバッチファイルは、コマンドプロンプトに日本語を表示させるので、文字化けしないように対処が必要です。具体的には、メモ帳でこのバッチファイルを名前をつけて保存するとき、エンコードを「UTF-8」から「ANSI」に変更してください。

このバッチファイルを起動すると以下のようなコマンドプロンプトが開き、1もしくは2を入力することで、ビューアを開くのかスクレイピングするのか選択することができます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4066587/a0de64bd-5227-44f8-9bb5-a0423f858efa.png" width=50%>



全ての作業終了後、ルートフォルダ直下は以下のような構成になっていることを確認してください。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4066587/40503b17-4bb5-4a84-bdc7-92d5fea2fd73.png" width=50%>
run.batだけは任意の場所に保存可能です。




## 今後の目標

スクレイピング編とビューア編では成果物を貼り付けただけでしたが、実際にはよくわからんエラーが出たり、chatGPTが10分前と全然違うこと言い出したり、修正する箇所を間違えて取り返しのつかないことになったり、5日間びっちりchatGPTと殴り合いを続けていました。

ただ、最初の段階でプロンプトをしっかり練っておき、chatGPTが迷子になっても目指すべきゴールが明確に設定された状態で走り始めたので、軌道修正は比較的容易だったように思います。
以前から横断検索のアプリケーションについて、何度もchatGPTに相談してコードを考えてもらっていたのですが、プロンプトが甘かったせいで失敗続きでした。そういった経緯もあって、今回は仕様やコードを考える前に、まずプロンプトそのものをchatGPTといっしょに考えるという工程を踏みました。

スクレイピングに成功した直後、「このcsvファイルを使って、横断検索とランダム表示以外になんか面白いことできる？」ってchatGPTに聞いたら、「品詞分解して2万件のハイライトを10個とか20個とかのトピックに分類できるよ」って言ってて、実際ちょっと試したんですけど、どうもノイズが多くてうまく分析できなかったので、後日そっち方面でもうちょっとpythonいじってみたいなって思いました。

今のところ、アプリケーションのUIを、もっとどうにか洒落た感じにできんか…？というのが当面の課題です。そもそも洒落たUIのアプリケーションはpythonで作れんのか…？pythonじゃなけりゃなにでやるんだ…？

次にスクレイピングしてみたいのは、amazonの欲しいものリストです。乱読家にとって真に重要なのは、実は既読の一覧ではなく未読の一覧なので、「なんかしらんがいきなりamazonバンされた」みたいな話を聞いて以来、欲しいものリストの喪失にずっと怯えています。

ド素人のみんなも私を踏み台にしてpythonで遊んでみてね。おわり。