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

# スクレイピング編


スクレイピングの最終的な目的は、kindleの「メモとハイライト」のページから以下のようなcsvファイルを作成することです。

1冊目の本のタイトル,1冊目の本の著者名,1つ目のハイライトの内容,ロケーション
1冊目の本のタイトル,1冊目の本の著者名,2つ目のハイライトの内容,ロケーション
2冊目の本のタイトル,2冊目の本の著者名,1つ目のハイライトの内容,ロケーション
3冊目の本のタイトル,3冊目の本の著者名,1つ目のハイライトの内容,ロケーション
3冊目の本のタイトル,3冊目の本の著者名,2つ目のハイライトの内容,ロケーション
3冊目の本のタイトル,3冊目の本の著者名,3つ目のハイライトの内容,ロケーション
4冊目の本のタイトル,4冊目の本の著者名,1つ目のハイライトの内容,ロケーション

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4066587/22ed166a-badf-467d-8176-fc11dbffbd5f.png">


実際にはロケーションの取得がうまくできなかったので、csvに含まれるのは「タイトル」「著者名」「ハイライトの内容」の3つの要素だけになりました。


## 仮想環境の構築

ルートフォルダとして、ドキュメント内に「python_kindle」という名前のフォルダを作成しました。絶対パスは以下の通りです。

```
C:\Users\ユーザー名\Documents\python_kindle
```

python_kindleの内部は、次回のビューア編の最後まで作業を終えたとき、このような構成になります。run.batとrandom_search.pyはビューア編で作るファイルです。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4066587/e2db74e1-f3d4-47da-bf98-5c5f0cca3de3.png" width=50%>




このルートフォルダの中に仮想環境を構築して、必要なライブラリをインストールしていきます。まとめて作業できるように、必要なライブラリをまとめた「requirements.txt」というファイルを用意します。requirements.txtの中身は以下の通り、たった３行だけのファイルです。
```requirements.txt
pandas
selenium
chromedriver-binary
```
この3つはすべてライブラリの名前です。seleniumはスクレイピングに使うライブラリで、chromedriver-binaryはchromeを使うためのライブラリだそうです。pandasはよくわかりません。chatGPTが入れろって言うので入れました。

requirements.txtを用意したら、コマンドプロンプトで仮想環境を作り、ライブラリをインストールしていくことになるのですが、バッチファイルを使ってまとめて自動で作業してもらいます。バッチファイルとは、コマンドプロンプトへの命令をひとまとめにしたファイルです。

ルートフォルダ直下（requirements.txtと同じ場所）にenv_setup.batという名前で、以下の内容のファイルを作ります。windows標準のメモ帳に以下の内容を貼り付けて、env_setup.batという名前で保存すればOKです。

```env_setup.bat
@echo off
setlocal

REM 作業ディレクトリへ移動
cd /d C:\Users\ユーザー名\Documents\python_kindle

REM 1. （任意）仮想環境作成（既にあればコメントアウト可）
python -m venv .venv

REM 2. 仮想環境有効化（必ず call を付ける）
call .\.venv\Scripts\activate.bat

REM 3. pip のアップグレードと依存関係インストール
python -m pip install --upgrade pip
python -m pip install -r requirements.txt

REM 4. インストール済ライブラリ一覧を表示
echo ===== Installed packages =====
python -m pip list

REM 5. 出力確認のため一時停止
pause

endlocal
```
このenv_setup.batファイルをクリックして開くと、コマンドプロンプトが立ち上がって勝手にいろいろ動きます。インストールがうまくいけば、以下のような表示（こういうライブラリのこのバージョンのやつをインストールしたよって話）が出てきて終わります。

```
===== Installed packages =====
Package             Version
------------------- --------------
attrs               25.3.0
certifi             2025.1.31
cffi                1.17.1
chromedriver-binary 137.0.7132.0.0
h11                 0.14.0
idna                3.10
numpy               2.2.4
outcome             1.3.0.post0
pandas              2.2.3
pip                 25.0.1
pycparser           2.22
PySocks             1.7.1
python-dateutil     2.9.0.post0
pytz                2025.2
selenium            4.31.0
six                 1.17.0
sniffio             1.3.1
sortedcontainers    2.4.0
trio                0.29.0
trio-websocket      0.12.2
typing_extensions   4.13.2
tzdata              2025.2
urllib3             2.4.0
websocket-client    1.8.0
wsproto             1.2.0
```

エラーが出たらchatGPTに聞いてください。ちなみにこのバッチファイルは何度起動しても大丈夫っぽい（同じライブラリを何度もインストールしようとしてやっかいなことになったりしない）ので、「今このルートフォルダってなんのライブラリが適用になってるんだっけ？」「Seleniumインストールできてる？」とか確認したいときに改めて使ってもOKです。

このバッチファイルを使ったあとは、ルートフォルダに「.venv」というフォルダが増えているはずです。これが仮想環境を司るフォルダで、ライブラリはこの中に入ります。


## IDLEを仮想環境で起動する

仮想環境が整ったので、いよいよコードを書いて（貼り付けて）いきます。しかし、前回の【2万件のkindleハイライトを横断検索する.1　プロンプト編】で言ったように、普通にwindowsのアプリケーション一覧からpythonのIDLEを選んでも、仮想環境が適応になりません。

１．コマンドプロンプトを起動する
２．ルートフォルダに移動する
３．仮想環境をオンにする（アクティベートする）
４．仮想環境下でIDLEを起動する

以上の手順を踏む必要があります。非常にだるいですね。ここでバッチファイルです。
ルートフォルダ直下（requirements.txtやenv_setup.batと同じ場所）にrun_idle.batというファイルを作り、中身は以下の通りにします。

```run_idle.bat
@echo off
REM ----- Change to project folder -----
cd /d C:\Users\ユーザー名\Documents\python_kindle

REM ----- Activate virtual environment -----
call .venv\Scripts\activate.bat

python -m idlelib
```

これで、run_idle.batをクリックすれば、仮想環境下でIDLEを起動できるようになりました。
実際にはIDLEのウィンドウといっしょにコマンドプロンプトのウィンドウも立ち上がるのですが、コマンドプロンプトは影だと思って放置してください。コマンドプロンプトを閉じるとIDLEも閉じてしまうので、触らずにそのままにしておいて大丈夫です。

run_idle.batでIDLEを起動すると以下のような表示になっていると思います。
```
Python 3.13.3 (tags/v3.13.3:6280bb5, Apr  8 2025, 14:47:33)
[MSC v.1943 64 bit (AMD64)] on win32
Enter "help" below or click "Help" above for more information.
```
これは要するに「おはよ～」みたいなことなので無視していいです。


## コード入力

IDLEのウィンドウで、file>new fileと進み、コードを入力するためのウィンドウを開きます。ここにchatGPTが考えてくれたスクレイピングのコードを貼ります。このコードを貼り付けたら、file>save as…と進んで、ルートフォルダ直下に「scraper.py」という名前で保存してください。

```scraper.py
import time
import os
import csv
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

def scroll_to_end(driver, pause_time=2, max_scrolls=50):
    """ページまたはパネルを下までスクロールし、動的読み込みを完了させる"""
    last_height = driver.execute_script("return document.body.scrollHeight")
    scrolls = 0
    while scrolls < max_scrolls:
        driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
        time.sleep(pause_time)
        new_height = driver.execute_script("return document.body.scrollHeight")
        if new_height == last_height:
            break
        last_height = new_height
        scrolls += 1

def get_available_filename(base_name="highlights", extension="csv", folder="."):
    """
    指定したフォルダ内でファイル名(base_name.extension)が存在する場合、
    番号付き（例：highlights2.csv, highlights3.csv）のファイル名を返す。
    """
    filename = f"{base_name}.{extension}"
    counter = 1
    file_path = os.path.join(folder, filename)
    while os.path.exists(file_path):
        counter += 1
        filename = f"{base_name}{counter}.{extension}"
        file_path = os.path.join(folder, filename)
    return filename

def main():
    options = Options()
    # キャッシュを利用してログイン状態を保持する場合は以下のコメントアウトを解除してください
    # options.add_argument("--user-data-dir=./chrome-data")
    options.add_argument("--remote-debugging-port=9222")
    
    try:
        driver = webdriver.Chrome(options=options)
    except Exception as e:
        print("ChromeDriverの起動に失敗しました:", e)
        return

    driver.get("https://read.amazon.co.jp/notebook")
    
    # ユーザーに手動でログインを完了してもらう
    input("ログインが完了したらEnterキーを押してください...")

    highlight_selector = (
    "div.a-row.kp-notebook-highlight.kp-notebook-selectable.kp-notebook-highlight-blue, "
    "div.a-row.kp-notebook-highlight.kp-notebook-selectable.kp-notebook-highlight-yellow, "
    "div.a-row.kp-notebook-highlight.kp-notebook-selectable.kp-notebook-highlight-orange, "
    "div.a-row.kp-notebook-highlight.kp-notebook-selectable.kp-notebook-highlight-pink"
    )
    
    # メインページ全体をスクロールして、書籍一覧が完全に読み込まれるのを待つ
    scroll_to_end(driver, pause_time=2, max_scrolls=50)
    
    # 左側の書籍一覧のリンクを取得（H2タグが書籍タイトルとして使用されており、親の<a>タグから詳細が表示される前提）
    book_elements = driver.find_elements(By.CSS_SELECTOR, "a h2")
    if not book_elements:
        print("書籍一覧が取得できませんでした。セレクタを確認してください。")
        driver.quit()
        return
    
    total_books = 0
    total_highlights = 0
    results = []  # 各ハイライト行は [書籍タイトル, 著者名, ハイライト本文] のリストとして保存


    # 最初の10冊までのループにするなら　for i in range(min(len(book_elements), 10)):
    # 全部読み込ませるなら　for i in range(len(book_elements)):
    for i in range(len(book_elements)):



        # DOM更新に対応するために、再取得
        book_elements = driver.find_elements(By.CSS_SELECTOR, "a h2")
        if i >= len(book_elements):
            break
        book_elem = book_elements[i]
        title = book_elem.text.strip()
        if not title:
            continue
        total_books += 1
        
        # 書籍リンク（親の<a>タグ）をクリックして右側の詳細パネルを更新
        try:
            a_elem = book_elem.find_element(By.XPATH, "./ancestor::a")
            a_elem.click()
        except Exception as e:
            print(f"■書籍 '{title}' のクリックに失敗しました: {e}")
            continue

        # クリック後、詳細パネルの更新が確実に反映されるように3秒待機
        time.sleep(3)

        
        # 右側パネルのハイライトが表示されるまで最大30秒待機
        try:
            WebDriverWait(driver, 30).until(
                EC.presence_of_all_elements_located(
                    (By.CSS_SELECTOR, highlight_selector)
                )
            )
        except Exception as e:
            print(f"■書籍 '{title}' のハイライトが見つかりませんでした。")
            continue
        
        time.sleep(2)  # パネル更新のための追加待機
        
        # 詳細パネルから著者名を取得（指定のpタグ）
        try:
            author_elem = driver.find_element(
                By.CSS_SELECTOR,
                "p.a-spacing-none.a-spacing-top-micro.a-size-base.a-color-secondary.kp-notebook-selectable.kp-notebook-metadata"
            )
            author = author_elem.text.strip()
        except Exception as e:
            author = ""
            print(f"■書籍 '{title}' の著者名の取得に失敗しました: {e}")
        
        # 詳細パネルをさらにスクロールして、全ハイライトを読み込む
        scroll_to_end(driver, pause_time=2, max_scrolls=30)
        
        # 詳細パネルからハイライト情報を取得（今回はロケーションは取得しない）
        highlight_elements = driver.find_elements(By.CSS_SELECTOR, highlight_selector)
        if not highlight_elements:
            print(f"■書籍 '{title}' にはハイライトがありません。")
        else:
            for hl in highlight_elements:
                hl_text = hl.text.strip()
                if hl_text:
                    results.append([title, author, hl_text])
                    total_highlights += 1
        
        time.sleep(1)  # 次の書籍へ移る前の短い待機
    
    # CSVファイルへの出力先フォルダを準備
    db_dir = os.path.join(os.path.dirname(__file__), "database")
    os.makedirs(db_dir, exist_ok=True)

    # ファイル名を取得し、database フォルダ以下のパスを作成
    # database フォルダ内で重複しないファイル名を取得
    csv_filename = get_available_filename("highlights", "csv", folder=db_dir)
    csv_path = os.path.join(db_dir, csv_filename)

    try:
        with open(csv_path, "w", newline="", encoding="utf-8") as csvfile:
            writer = csv.writer(csvfile)
            # ヘッダーが不要な場合はコメントアウト
            # writer.writerow(["書籍タイトル", "著者名", "ハイライト本文"])
            for row in results:
                writer.writerow(row)
        print(f"CSVファイルに結果が保存されました: {csv_path}")

    except Exception as e:
        print("CSVファイルへの書き出しに失敗しました:", e)
    
    print(f"done. {total_books}冊, {total_highlights}ハイライト")
    
    driver.quit()

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




このコードの挙動は以下の通りです。

１．このコードを実行すると、chromeが自動的に立ち上がり、amazonのログインを求められます。二段階認証にしている場合は数字の入力も求められるので、普通にamazonにログインします。この段階ではまだスクレイピングは始まっていません。IDLEのウィンドウには「ログインできたらエンターキーを押せよ」と表示されているはずです。コマンドプロンプトも同時に立ち上がりますが、無視してください。

２．ログインできたらchromeに「メモとハイライト」のページが表示されます。そうしたら、IDLEのウィンドウでエンターキーを押してください。これではじめて、スクレイピングが始まります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4066587/2e77555b-2b74-440b-ac42-1dd4612aa2d0.png">

３．多少時間がかかっても確実にスクレイピングしたかったので、ひとつずつゆっくりハイライトの詳細ページが切り替わっていく設定になっています。500冊20000件のハイライトの読み込みに1時間ちょっとかかるくらいのペースです。余裕を持たせて待機時間を設定しているので、遅すぎて腹立つひとはchatGPTに相談して待機時間を調整してください。

４．kindleのハイライトは４色ありますが、色の区別なく全てハイライトを取得します。

５．「昔ハイライトしてたけど消した」などの理由で、「メモとハイライト」にタイトルはあるけどハイライトは0件になっている場合があります。そのときは、ハイライトを取得できなかったとリアルタイムでIDLEに表示されます。

６．全てのハイライトを取得し終えると、IDLEの画面に、取得できたハイライトの数が表示されます。同時に、ルートフォルダ直下にdatabeseというフォルダが作成され、その中に「highlights.csv」というファイルが保存されます。highlights.csvがすでにある状態でスクレイピングすると、highlights1.csvという名前で保存され、以降はhighlights2.csv、highlights3.csv…と続いていくので、古いcsvファイルがスクレイピングのたびに上書きされて消えてしまう、ということにはなりません。常に新規のcsvファイルが作成されるようになっています。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4066587/c5995cac-e606-426a-82e2-3ad16f8d1ef8.png" width=50%>



このコードの最も重要な部分は、なにを目印にハイライト情報をスクレイピングしているのか、というところです。その目印はセレクタと言うらしいのですが、chatGPTではセレクタを正確に抽出することはできませんでした。仕方がないので自分で特定しました。

将来的にこのスクレイピング用コードが動かなくなることがあるとすれば、「メモとハイライト」のページが刷新されてセレクタが変更になってしまった場合が考えられます。
有事に備えて、このコードでセレクタに利用しているものを書き出しておきます。
以下は2025年4月現在の情報です。

```
タイトル：
<h2>

著者名：
<p class="a-spacing-none a-spacing-top-micro a-size-base a-color-secondary kp-notebook-selectable kp-notebook-metadata">

ハイライト本文：
<div class="a-row kp-notebook-highlight kp-notebook-selectable kp-notebook-highlight-blue">
<div class="a-row kp-notebook-highlight kp-notebook-selectable kp-notebook-highlight-yellow">
<div class="a-row kp-notebook-highlight kp-notebook-selectable kp-notebook-highlight-orange">
<div class="a-row kp-notebook-highlight kp-notebook-selectable kp-notebook-highlight-pink">
```

現在の「メモとハイライト」は左側にタイトル一覧、右側に書籍ごとの詳細ページが表示される構造になっています。左側のタイトル一覧はh2タグで指定され、その親要素としてaタグで詳細ページへのリンクが張ってあります。
スクレイピングでは、まずページを1番下まで自動スクロール処理し、全てのタイトルを表示させます。
そのあとは、こんな感じです。

1. 1箇所目のH2で1冊目の書籍タイトルを取得する。
2. 1箇所目のH2を含むaタグから、1冊目の書籍ハイライトのページを表示する。
3. 表示した1冊目の書籍ハイライトページから、複数のハイライトを全て取得する。
4. 1冊目の書籍ハイライトページから全てのハイライトを取得したら、2箇所目のH2を探す。
5. 2箇所目のH2で2冊目の書籍タイトルを取得する…

もし将来、このコードでスクレイピングができなくなることがあったら、ここまでの情報を参考に手直ししてみてください。



## テスト用コード

ハイライト件数が多い場合、処理が終わってcsvが作成されるまで長々と待つハメになります。毎回そんなことやってられないので、コードの真ん中らへんにテスト用として、ハイライトを10冊分取得したら処理を終了させるコードをコメントに残してあります。
以下の部分です。
```
# 最初の10冊までのループにするなら　for i in range(min(len(book_elements), 10)):
# 全部読み込ませるなら　for i in range(len(book_elements)):
for i in range(len(book_elements)):
```
これは完成コードなので、全部読み込ませるためにfor i in range(len(book_elements)):となっていますが、テスト用にするときはfor i in range(len(book_elements)):を消して、代わりにfor i in range(min(len(book_elements), 10)):としてください。

#ではじまる行はコメントで、コードの実際の挙動に影響しません。



## scraper.pyの起動

本来のscraper.pyの起動の手順は以下のとおりです。

１．コマンドプロンプトを起動
２．ルートフォルダに移動
３．仮想環境を有効化する
４．仮想環境下でIDLEを起動する
５．IDLEのfile>openからscraper.pyを開く
６．scraper.pyの画面のrun>run moduleから起動

やってらんねえよな？バッチファイルです。
以下の内容のバッチファイルを作成し、ルートフォルダ直下に「run_scraper.bat」という名前で保存します。

```run_scraper.bat
@echo off
setlocal

REM 作業ディレクトリへ移動
cd /d C:\Users\ユーザー名\Documents\python_kindle

REM 2. 仮想環境有効化（必ず call を付ける）
call .\.venv\Scripts\activate.bat

chcp 65001

REM 3. IDLE を起動して scraper.py を開き、さらに自動実行
python -m idlelib.idle -r scraper.py

endlocal
```
「run_scraper.bat」をクリックで開くと、コマンドプロンプトとIDLEとchromeのウィンドウ（amazonログイン画面）が同時に開きます。その後は、amazonにログインし、IDLEのウィンドウでエンターキーを押すだけです。

スクレイピングしてcsvファイルを生成できれば十分だ、という場合には、ここで作業終了です。あとは、出来上がったcsvファイルを煮るなり焼くなり、どうぞご自由に。




次回、ビューア編に続きます。