読書メーターの記録をエクスポートする

はじめに

読書メーター(https://bookmeter.com)というサービスを知っていますか?読んだ本を記録することができるWebサービスです。わたしは2009年9月4日から2025年いっぱいまで、本を読んだら欠かさず読書メーターで記録を付けていました!読んだ本の数は合計2,586冊!すごい!といってもほとんどが漫画なので……つまりはただの漫画オタクなのですが……

読書メーターの記録によると、日平均0.43冊、週平均3.03冊、月平均12.96冊……こう見ると結構読んでる気がします。感想を付けたり、円グラフでよく読む著者を可視化することができたりなど、記録大好き人間としてはとても好きなサービスです。他のユーザーの感想も見れるし、ユーザーをお気に入り(フォローのようなもの)にすることでSNS的な楽しさもあります。Twitterと連携して読んだ本を登録した際にツイートしてくれる機能もあり、自分が読書メーターを知ったのも当時のフォロワーやTLからだったと思う

似たようなWebサービスやアプリもあるのですが、自分は乗り換えることはしませんでした。なぜなら……読書メーターにはエクスポート機能がないから!!!!!(クソデカ大声)なんで……なんでないんだよ……!!!乗り換えとか退会とか関係なしに、自分のデータはエクスポートしたいよ~~!!!させてよ~~!!!不便!不便すぎ!!!しかし、読書メーターに対する不満がその一点のみだったので、難しいことを考えるのはやめて、読書記録を続けたのです16年間わたしは(16年って改めて考えるとすごい)

しかし!ちょっといろいろな考えがありわたしはこの度、読書メーターの退会をすることに決めたのです!!!なんかもう言語化するのがだるくなってきたからアレなんだけど、自分に関するデータはできるだけ自分で持っていたい、同じ場所でまとめて管理したい、みたいな考えからです。はい

ということで、読書メーターから必要なデータをエクスポートするスクリプトを作りました。手動でコピペしていくのは厳しい件数なので……(エクスポート機能さえあればなあ!?)

読書メーターのデータをエクスポートするやつ

前置き

  • 自分用のスクリプトについて自分用の覚書や記録として載せています
  • このスクリプト群は、個人利用を目的としているものです
  • 読書メーターはエクスポート機能を提供していないため、個人(ユーザー本人)の記録情報を保存するために使用します
  • Webサービス側に負担を掛けないよう、小規模かつ最小限のアクセス・スクレイピングを心掛けています
  • サイト構造の変更などにより動作しなくなる可能性があります
  • 自分用なので雑な部分が多々あります(本当に)

やりたいこと

読書メーターの自分のアカウントから“読んだ本”の情報を取得し、CSVとしてエクスポートする

  • 取得情報
    • 作品タイトル
    • 読了日
    • 作者
    • Amazonの商品ページURL
    • 感想・レビュー本文
    • コメント
      • コメント日付 ※年は含まれない
      • ユーザー名
      • ユーザーページURL
      • コメント本文

CSVは、他のスクリプト等で使用する際に改めて整形する前提です。特に作品タイトルや作者などは表記の統一などはせず、そのまま出力しています

環境

  • Windows 11
  • Python 3.10
  • Google Chrome
  • ChromeDriver(Chromeのバージョンに合わせたもの)

ディレクトリ構成

bookmeter_export/
├─ main.py # 全体実行用スクリプト
├─ export_books.py # 読んだ本一覧を取得してCSV出力
├─ export_comments.js # コメント取得用JavaScript
├─ marge_comments.py # CSV合成スクリプト
├─ config.py # 設定ファイル(要作成)
├─ venv/ # 仮想環境
└─ output/ # 出力先フォルダ

venv/は、後述で作成

output/は、なければ作成するようにすればいいのだけれど、後述のスクリプトでその処理をしたかどうか忘れたしなんか今もう考えるのがだるくなってきたので、とりあえず手動で作っておくべし

仮想環境の準備

ターミナルとかでやるやつ。仮想環境と依存ライブラリをご用意します

# bookmeter_export/ 内で実行

python -m venv venv                   # 仮想環境を作成
.\venv\Scripts\activate               # 仮想環境を有効化
python -m pip install requests        # Webページ取得するやつ
python -m pip install beautifulsoup4  # スクレイピングするやつ
python -m pip install selenium        # ブラウザ操作するやつ

どうして仮想環境を使用しているかは、あんま深く考えなくて……(Python普段使わないので、なんか仮想環境使った方がいいみたいに見かけたのでやってみただけなとこある)

config.py

大事な情報を隔離してます

from pathlib import Path

EMAIL = "your_email@example.com"      # ログインメールアドレス
PASSWORD = "your_password"            # ログインパスワード
USER_ID = "xxxxx"                     # https://bookmeter.com/users/xxxxx

# DOWNLOAD_PATH = Path("C:/Users/xxx/Downloads")         # JascriptでCSV作成する時用
CHROMEDRIVER_PATH = Path("C:/path/to/chromedriver.exe")  # Selenium用Chromeドライバ

読書メーターの記録は未ログイン状態でも見れますが、なんとなくログインしている。勉強がてら

USER_IDが手打ちなのは、当初Seleniumを使う予定がなく自動取得が難しいと判断した時に作った部分であるためです。結果的にはここも簡単に自動取得できるね……(面倒なのでそのまま)

DOWNLOAD_PATHは、当初予定していた構造で使用していましたが、結果的に使用しなくてよい方向に進んだためコメントアウトしています。削除していいです

CHROMEDRIVER_PATHは大事

export_books.py

  1. ログイン
  2. 読んだ本一覧ページに移動
  3. htmlから必要な情報を取得し、次のページへ移動
  4. 3を繰り返す
  5. CSVとして出力
# export_books.py
import requests
from bs4 import BeautifulSoup
from config import EMAIL, PASSWORD, USER_ID
import csv
import re
import time
import json

LOOT_URL = "https://bookmeter.com"
LOGIN_URL = f"{LOOT_URL}/login"
READ_BOOKS_URL = f"{LOOT_URL}/users/{USER_ID}/books/read"
OUTPUT_CSV = "output/books.csv"

session = requests.Session()    # requests.get(url)と違い、requests.Session(url)は、同一人物のHTTPリクエストとしてCookieを保持することができる
session.headers.update({        # User-Agent偽装
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                  "AppleWebKit/537.36 (KHTML, like Gecko) "
                  "Chrome/120.0.0.0 Safari/537.36"
})

# ----------------------
# ログイン
# ----------------------
res = session.get(LOGIN_URL)
res.raise_for_status()  # raise_for_statusメソッドは、HTTPリクエストの結果をチェックし、成功なら続行、失敗(400,500番台)ならエラーとして例外を返す
# print(res)  # <Response [200]>

soup = BeautifulSoup(res.text, "html.parser")

# CSRFトークンを取得
csrf_token = soup.find("input", {"name": "authenticity_token"})
token = csrf_token["value"] if csrf_token else ""

# ログイン情報をPOST
payload = {
    "session[email_address]": EMAIL,    # name="session[email_address]"のformに値を入力
    "session[password]": PASSWORD,      # name="session[password]"のformに値を入力
    "authenticity_token": token,
}

login_res = session.post(LOGIN_URL, data=payload)
login_res.raise_for_status()

time.sleep(3)   # ログイン後少し待機(人間らしく振る舞うため)

# ----------------------
# CSV初期化(ヘッダ)
# ----------------------
with open(OUTPUT_CSV, "w", newline="", encoding="utf-8") as f:  # "w"は書き込み(上書き)
    writer = csv.writer(f)
    writer.writerow([
        "post_title",
        "post_content",
        "post_date",
        "post_category",
        "book_author",
        "book_publisher",
        "book_read_date",
        "amazon_url",
        "review_text",
        "comments"
    ])

# ----------------------
# ページング処理
# ----------------------

page = 1
total_count = 0
total_review_count = 0

while True:
    # ログイン状態で読んだ本ページにアクセス
    url = f"{READ_BOOKS_URL}?page={page}"
    res = session.get(url)
    res.raise_for_status()

    soup = BeautifulSoup(res.text, "html.parser")
    books = soup.find_all('li', class_="group__book")

    if not books:
        print("これ以上本がありません。終了します。")
        break

    print(f"{page}ページ目: {len(books)}冊")

    # CSVに追記
    with open(OUTPUT_CSV, "a", newline="", encoding="utf-8") as f:  # "a"は追記
        writer = csv.writer(f)

        for book in books:
            # 初期化
            read_date = ""
            asin = ""
            amazon_url = ""
            title = ""
            author = ""
            detail_url = ""
            review_text = ""

            # 読了日
            date_div = book.find("div", class_="detail__date")
            if date_div:
                text = date_div.get_text(strip=True)
                # 取得が YYYY/MM/DD なので YYYY-MM-DD に変換
                m = re.search(r"(\d{4})/(\d{2})/(\d{2})", text)
                if m:
                    y, mth, d = m.groups()
                    read_date = f"{y}-{int(mth):02d}-{int(d):02d}"
                else:
                    read_date = text

            # data-modalの情報
            data_modal_div = book.find("div", {"data-modal": True})
            if data_modal_div:
                data = json.loads(data_modal_div["data-modal"])
                book_data = data.get("book", {})

                # asin・AmazonURL
                asin = book_data.get("asin", "")
                if asin:
                    amazon_url = f"https://www.amazon.co.jp/dp/{asin}"

                # タイトル
                title = book_data.get("title", "")
                if not title:
                    title_tag = book.find("img")
                    title = title_tag["alt"] if title_tag else ""

                # 著者
                author = book_data.get("author", "")
                if not author:
                    authors_ul = book.find("ul", class_="detail__authors")
                    if authors_ul:
                        a = authors_ul.find("a")
                        if a:
                            author = a.get_text(strip=True)

                # 詳細ページURL
                p = book_data.get("book_path", "")
                if p:
                    detail_url = f"{LOOT_URL}{p}"

            # 感想(コメントはここでは不可)
            review_icon_a = book.find("a", class_="icon__review")
            if review_icon_a:
                # 感想・コメントがある場合
                time.sleep(1)

                # 感想ページを取得
                r = session.get(detail_url)
                r.raise_for_status()
                rsoup = BeautifulSoup(r.text, "html.parser")

                # 感想本文
                review_div = rsoup.find("div", class_="read-book__content")
                if review_div:
                    review_p = review_div.find("p")
                    if review_p:
                        review_text = review_p.get_text(strip=True)
                        total_review_count += 1

                # コメント
                # コメントの取得は対象ページの仕組み上このスクリプトでは難しいため、(件数が少ない場合は)後から別で取得・追記する
            
            # 追記
            writer.writerow([
                title,
                "",
                read_date,  # post_date
                "読書",
                author,
                "",         # book_publisher
                read_date,  # book_read_date
                amazon_url,
                review_text,
                ""
            ])
            
            # print(f"[{read_date}] {asin}")
            # print(f"\ttitle  : {title}")
            # print(f"\tauthor : {author}")
            # if review_text:
            #     print(f"\tcomment: {review_text}")

            total_count += 1

    page += 1
    time.sleep(2)   # アクセス感覚 ※botと判断されないよう速くしすぎない

print(f"合計 {total_count} 冊(感想あり {total_review_count} 冊)をCSVに出力しました。")

ブログに載せるにしては不要なコメントがありますが、これが自分用のスクリプトと念を押している理由です。わざわざ消すの面倒なので……恥ずかしさより面倒が勝つ

当初はそのままWordPressにインポートを考えていたとか、全体像を考えずに作り始めたからとか、そんな感じなので不要な要素が多いです。CSVの列構成とかそれでいいのか?という。まあとりあえず動けばいいんですよ動けば……

htmlに変更があると情報が取得できなくなるかもしれません。ソースを確認して適宜変更するのだ

export_comments.js

ブラウザ上、ユーザーの感想一覧ページで実行する

  1. ページ最下部までスクロールする
  2. コメントを取得
  3. コメント情報を返す
// export_comments.js

// ページ最下部までスクロールして遅延読み込みを完了させる 遅延時間(delay)は環境に合わせて調整
async function scrollToBottom(delay = 1000) {
    let lastHeight = document.body.scrollHeight;
    while (true) {
        window.scrollTo(0, document.body.scrollHeight);
        await new Promise(r => setTimeout(r, delay));
        let newHeight = document.body.scrollHeight;
        if (newHeight === lastHeight) break;
        lastHeight = newHeight;
    }
    // console.log("スクロール完了");
}

return (async () => {
    await scrollToBottom();

    // コメント情報を取得する
    let comments = [];
    document.querySelectorAll(".frame__comments__comment__main").forEach((el, idx) => {
        // コメントユーザー
        let userEl = el.querySelector(".frame__comments__comment__header a");
        let user = userEl ? userEl.innerText.trim() : "";
        let user_path = userEl ? userEl.getAttribute("href").trim() : "";       // "/users/xxxxx"

        // コメントテキスト
        let textEl = el.querySelector(".frame__content__text p");
        let text = textEl ? textEl.innerText.trim() : "";

        // コメント日付
        let dateEl = el.querySelector(".frame__details__date");
        let date = dateEl ? dateEl.innerText.trim() : "";                       // "MM/DD hh:mm"

        // 親要素を取得
        let reviewCard = el.closest(".frame__main");

        // 感想年
        let review_dateEl = reviewCard ? reviewCard.querySelector(".frame__details__date--link") : null;
        let review_date = review_dateEl ? review_dateEl.innerText.replace(/\//g, "-").trim() : "";  // "YYYY-MM-DD"

        // 作品タイトル
        let titleEl = reviewCard ? reviewCard.querySelector(".frame__content__book__detail__item") : null;
        let title = titleEl ? titleEl.innerText.trim() : "";

        comments.push({
            idx: idx + 1,
            user: user,
            user_path: user_path,
            title: title,
            review_date: review_date,
            date: date,
            text: text
        });
    });

    // 以下、Javascript単体でCSV出力する用
    // // CSVエスケープ
    // function csvEscape(v) {
    //     // ダブルクオーテーションは、カンマ等が含まれる文字列をCSVの1セルとして認識するために使われる
    //     // 故に、文字列としてダブルクオーテーションを使用する場合はエスケープする必要がある
    //     return `"${String(v ?? "").replace(/"/g, '""')}"`;
    // }
    // // CSV生成
    // let csv = "idx,user,user_path,title,review_date,date,text\n";
    // comments.forEach(c => {
    //     csv += [
    //         c.idx,
    //         csvEscape(c.user),
    //         csvEscape(c.user_path),
    //         csvEscape(c.title),
    //         csvEscape(c.review_date),
    //         csvEscape(c.date),
    //         csvEscape(c.text),
    //     ].join(",") + "\n";
    // })
    // // CSVダウンロード
    // const blob = new Blob([csv], { type: "text/csv" })  // メモリ上にCSVファイル作成
    // const url = URL.createObjectURL(blob);
    // const a = document.createElement("a");              // <a>要素作成
    // a.href = url;
    // a.download = "bookmeter_comments.csv";
    // a.click();                                          // 擬似クリック
    // URL.revokeObjectURL(url);                           // メモリ解放
    // console.log(`CSV出力完了: ${comments.length}件 / ${a.download}`);

    return comments;
})();

このコメントアウトしてるところは全然消していい部分です。コメント取得は当初、ブラウザ上でJavaScriptを手動実行し、ブラウザで指定しているダウンロード先フォルダにCSV出力をする、という予定でした。最終的に、全ての機能をまとめるためにコメント取得も自動実行できるように変更したため、CSV関連は別のところにいきました。後半のコメントアウトは変更前の名残

Selenium使うなら全部そっちでやれば?という感じなのですが、折角作ったのでそのまま使用することにしたのです。あとなんか、なんとなくSeleniumの使用は最小限にしたくて(何故なら使うのが初めてだったから)

marge_comments.py

  1. 読んだ本一覧CSVにコメント一覧CSVの内容をつっこむ!
  2. 使用済みCSVはバックアップファイルとして1回分のみ残しておく
#marge_comments.py
import csv
from pathlib import Path
from collections import defaultdict
from config import DOWNLOAD_PATH

BOOKS_CSV = Path("output/books.csv")
BOOKS_BACKUP_CSV = Path("output/books.bak.csv")
COMMENTS_CSV = Path("output/comments.csv") if Path("output/comments.csv").exists() else DOWNLOAD_PATH / "bookmeter_comments.csv"
COMMENTS_BACKUP_CSV = Path("output/comments.bak.csv")
OUTPUT_CSV = Path("output/bookmeter_export.csv")
BOOKMETER_BASE = "https://bookmeter.com"

# ----------------------
# コメントCSVを読み込んでまとめる
# ----------------------
comments_map = defaultdict(list)
# key = (title, review_date)

with COMMENTS_CSV.open(encoding="utf-8") as f:
    reader = csv.DictReader(f)
    for row in reader:
        key = (row["title"], row["review_date"])

        user_url = f"{BOOKMETER_BASE}{row['user_path']}"
        line = f"{row['date']}|{row['user']}({user_url})|{row['text']}"

        comments_map[key].append({
            "idx": int(row["idx"]),
            "date": row["date"],
            "line": line
        })

# 並び順の再確認 (idx → date)
for key in comments_map:
    comments_map[key].sort(key=lambda x: (x["idx"], x["date"]))

# ----------------------
# 読書記録CSVを読み込んで合成
# ----------------------
marged_rows = []

with BOOKS_CSV.open(encoding="utf-8") as f:
    reader = csv.DictReader(f)
    fieldnames = reader.fieldnames

    for row in reader:
        key = (row["post_title"], row["book_read_date"])
        print(key)

        if key in comments_map:
            # 複数コメントの区切りは"||""
            marged_text = "||".join(
                c["line"] for c in comments_map[key]
            )
            row["comments"] = marged_text

        marged_rows.append(row)

# ----------------------
# 出力
# ----------------------
with OUTPUT_CSV.open("w", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=fieldnames)
    writer.writeheader()
    writer.writerows(marged_rows)

print(f"マージ完了: {OUTPUT_CSV}")

# ----------------------
# 使用済みCSVをバックアップ化(古いバックアップは削除)
# ----------------------
if BOOKS_BACKUP_CSV.exists():
    BOOKS_BACKUP_CSV.unlink()
BOOKS_CSV.rename(BOOKS_BACKUP_CSV)
if COMMENTS_BACKUP_CSV.exists():
    COMMENTS_BACKUP_CSV.unlink()
COMMENTS_CSV.rename(COMMENTS_BACKUP_CSV)

見ればわかると思うけどめちゃくちゃ。なんかこう……自分の美学にはだいぶ反する感じなのだけれど、なんかもうこの辺作ってるとき疲れてて……早く終わらせてえ……って感じで……ね!

main.py

  1. export_books.py(読んだ本一覧CSV出力)
  2. ブラウザ起動
  3. export_comments.js(コメント取得)
  4. コメントCSVを出力
  5. marge_comments.py(読んだ本+コメントCSV出力)
# main.py
import sys
if "venv" not in sys.prefix:
    raise RuntimeError("venv を有効化して実行してください")
import subprocess
import csv
from pathlib import Path
from config import USER_ID, DOWNLOAD_PATH, CHROMEDRIVER_PATH
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

# ----------------------
# 設定
# ----------------------
BOOKS_SCRIPT = Path("export_books.py")                  # 読んだ本一覧CSV作成スクリプト
COMMENTS_JS = Path("export_comments.js")                # ブラウザで実行するJS
MARGE_SCRIPT = Path("marge_comments.py")                # CSV合成スクリプト
OUTPUT_CSV = Path("output/comments.csv")
BOOKS_URL = f"https://bookmeter.com/users/{USER_ID}/reviews"  # コメント一覧ページURL
VENV_PYTHON = Path("venv/Scripts/python.exe")           # venv使用

OUTPUT_CSV.parent.mkdir(parents=True, exist_ok=True)

# ----------------------
# 読んだ本一覧をCSV出力
# ----------------------
if BOOKS_SCRIPT.exists():
    # subprocess.run(["python", str(BOOKS_SCRIPT)], check=True)
    subprocess.run([str(VENV_PYTHON), str(BOOKS_SCRIPT)], check=True)
else:
    print(f"{BOOKS_SCRIPT} が見つかりません")

# ----------------------
# ブラウザでコメントページを開く
# ----------------------
# Selenium
service = Service(CHROMEDRIVER_PATH)
driver = webdriver.Chrome(service=service)

driver.get(BOOKS_URL)
# ページが読み込まれるまで最大10秒待つ
WebDriverWait(driver, 10).until(
    lambda d: d.execute_script("return document.readyState") == "complete"
)

# JS読み込み
with open(COMMENTS_JS, "r", encoding="utf-8") as f:
    js_code = f.read()

# JS実行、結果取得
comments = driver.execute_script(js_code)

# CSV出力
with open(OUTPUT_CSV, "w", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=["idx","user","user_path","title","review_date","date","text"])
    writer.writeheader()
    writer.writerows(comments)
print(f"CSV出力完了: {len(comments)} 件")

driver.quit()

# ----------------------
# CSV合成
# ----------------------
if MARGE_SCRIPT.exists():
    subprocess.run(["python", str(MARGE_SCRIPT)], check=True)
else:
    print(f"{MARGE_SCRIPT} が見つかりません")

もうやけくそなんだよね

実行と結果

ターミナルとかで、仮想環境を有効化した状態で実行します

python .\main.py

エラーが起きなければ、output/に以下のファイルが出来ている……と思います

bookmeter_export/
└─ output/
├─ bookmeter_export.csv # 読んだ本 + コメント一覧CSV
├─ books.bak.csv # バックアップ用
└─ comments.bak.csv # バックアップ用

bookmeter_export.csvが目的の物です!中身を確認しましょう。自分が書いたスクリプトなんて全く信用できないからめっちゃ確認してしまいます

バックアップ用ファイルは、このスクリプト群ではそのまま利用することはできません。なんかミスったときに上手いことやる用に残しています

2026年1月21日に自分の環境で実行した際は動作しましたが、エラーが起きたり最終CSVの中身がおかしい場合はスクリプトを見直す必要があります

おわりに

ひっっっっっっさしぶりにプログラミングっぽいことをしたのでめちゃくちゃ疲れました。おれはアマチュアのド素人なので……。でも動くものが作れてHappy!達成感ある!

時間に余裕があれば、もっとスマートにまとめたり機能拡張したい部分が多々あるのですが、もう疲れてて……頭が回らないのですこちとら。この記事書いてる間も本当ずっと頭がめちゃくちゃ痛いやばーい!ないと思いますが、気が向いたらアップデートしておきます……ないと思いますが……(だってこれ使用が一回限りのスクリプトを想定しているし、さっき読書メーター退会したから取得するデータがないし)

おれは次に読書メーターの代わりに読書記録をWordPressにそれはもうと~~っても楽に投稿できる仕組みを完成させなければならないんだ……これができなきゃ新しく漫画を読めないので……ね!(記録厨)そのフォーマットが定まったら、今回のCSVを利用して読書記録のインポートもしたいよね。ただその時はCSVの中身を目視と手動で変更しなければならない部分があって……だるぽん……自分の記憶にしかないデータはどうあがいても自動化できないから……

最後の最後に。読書メーターさん、長い間本当にお世話になりました!!エクスポート機能付けて!!