1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

書影ダウンローダーを作ってみた

Last updated at Posted at 2025-07-04

1.国立国会図書館の書影APIを利用してjpg形式で一括保存するGUIアプリ

一言で言えば、「国立国会図書館の書影APIを利用してjpg形式で一括保存するGUIアプリ」です。

APIの利用を前提としているため、オープンソースソフトウェアやWEBツールとしては公開しないほうが良いだろうと考え、あくまでも実装サンプルとして公開することにします。

APIの利用については国立国会図書館のウェブサイトを参照してください。

2.画面デザイン

あまりセンスはないのですが、以下のようなデザインになりました。

image.png

3.コード

詳細は次項で解説します。

sample.py
import re
import os
import time
import subprocess
import requests
from PIL import Image
from io import BytesIO
import flet as ft

# サムネイル画像の取得・保存処理
def fetch_and_save_thumbnails(isbn_list, save_dir, prefix, log_output=None, preview_output=None):
    os.makedirs(save_dir, exist_ok=True)

    for isbn in isbn_list:
        isbn = isbn.strip()
        if not isbn:
            continue

        file_name = f"{prefix}{isbn}.jpg"
        save_path = os.path.join(save_dir, file_name)
        thumbnail_url = f"https://ndlsearch.ndl.go.jp/thumbnail/{isbn}.jpg"

        # 既に保存済みならスキップ
        if os.path.exists(save_path):
            msg = f"⚠️ {isbn}: 既に保存済み -> {save_path}"
            if log_output:
                log_output.controls.append(ft.Text(msg))
            if preview_output:
                preview_output.controls.append(ft.Image(src=save_path, width=100))
            continue  # 保存処理は行わない

        try:
            response = requests.get(thumbnail_url, timeout=5)
            response.raise_for_status()

            # 画像検証と保存処理
            img = Image.open(BytesIO(response.content))
            img.verify()
            img = Image.open(BytesIO(response.content))  # verify後は再読み込み
            img.save(save_path, format="JPEG")

            msg = f"{isbn}: 保存完了 -> {save_path}"
            if log_output:
                log_output.controls.append(ft.Text(msg))
            if preview_output:
                preview_output.controls.append(ft.Image(src=save_path, width=100))

        except requests.exceptions.HTTPError as http_err:
            msg = f"{isbn}: HTTPエラー - {http_err.response.status_code if http_err.response else '不明'} - {http_err}"
            if log_output:
                log_output.controls.append(ft.Text(msg))
        except requests.exceptions.RequestException as req_err:
            msg = f"{isbn}: リクエストエラー - {req_err}"
            if log_output:
                log_output.controls.append(ft.Text(msg))
        except IOError:
            msg = f"{isbn}: 画像保存エラー"
            if log_output:
                log_output.controls.append(ft.Text(msg))
        except Exception as e:
            msg = f"{isbn}: 未知のエラー - {str(e)}"
            if log_output:
                log_output.controls.append(ft.Text(msg))

        # 描画の更新は各ISBNごとに1回のみ
        if log_output:
            log_output.update()
        if preview_output:
            preview_output.update()

        time.sleep(1.0) # 最大で1.0秒あたり

# メイン画面の定義
def main(page: ft.Page):
    page.title = "書影ダウンローダー"
    page.scroll = "auto"
    page.window_width = 700

    # 保存先ディレクトリパスの表示
    selected_dir = ft.Text(value="保存先: 未選択", selectable=True)
    save_dir = None  # 実際の保存先パス(後に更新)

    # 保存先ディレクトリ選択結果の処理
    def get_directry_result(e: ft.FilePickerResultEvent):
        nonlocal save_dir
        save_dir = e.path
        selected_directry.value = f"保存先: {e.path}" if e.path else "キャンセルされました。"
        selected_directry.update()

    get_directry_dialog = ft.FilePicker(on_result=get_directry_result)
    selected_directry = ft.Text()

    # ISBN入力欄
    isbn_input = ft.TextField(
        label="ISBN(改行またはカンマ区切りで複数指定可)",
        multiline=True,
        min_lines=3,
        max_lines=8,
        width=500,
    )

    # 保存ファイル名の接頭語のバリデーション
    def filter_prefix(e):
        value = re.sub(r'[^a-zA-Z0-9_-]', '', e.control.value)
        if value != e.control.value:
            e.control.value = value
            page.update()

    # 接頭語入力欄
    prefix_input = ft.TextField(
        label="ファイル名の接頭語(半角英数・_・- のみ)",
        value="img",
        width=300,
        on_change=filter_prefix,
    )

    # ダウンロード処理
    def on_click_download(e):
        log_output.controls.clear()
        preview_output.controls.clear()
        progress.visible = True
        page.update()

        raw_input = isbn_input.value.replace(",", "\n")
        isbn_list = raw_input.strip().splitlines()
        prefix = prefix_input.value.strip()

        if not save_dir:
            log_output.controls.append(ft.Text("❌ 保存先フォルダを選択してください"))
            log_output.update()
            progress.visible = False
            page.update()
            return

        fetch_and_save_thumbnails(
            isbn_list,
            save_dir=save_dir,
            prefix=prefix,
            log_output=log_output,
            preview_output=preview_output
        )

        progress.visible = False
        page.update()

    download_button = ft.ElevatedButton("画像をダウンロード", on_click=on_click_download)

    # ログ表示
    log_output = ft.Column(scroll="auto")

    # プレビュー表示
    preview_output = ft.Row(wrap=True, spacing=10, run_spacing=10)

    # プログレスリング
    progress = ft.ProgressRing(visible=False)

    # 保存先フォルダを開くボタン
    def open_folder(e):
        if save_dir and os.path.exists(save_dir):
            if os.name == "nt":
                subprocess.Popen(f'explorer "{save_dir}"')
            elif os.name == "posix":
                subprocess.Popen(["open" if os.uname().sysname == "Darwin" else "xdg-open", save_dir])
        else:
            log_output.controls.append(ft.Text("❌ フォルダが未選択または存在しません"))
            log_output.update()

    open_button = ft.ElevatedButton("エクスプローラーでフォルダを開く", on_click=open_folder)

    # オーバーレイにファイルピッカー追加
    page.overlay.extend([get_directry_dialog])

    # UIの追加
    page.add(
        ft.Row([ft.Text("書影ダウンローダー", size=16, weight="bold")]),
        ft.Row([
            ft.Text("1.保存フォルダを指定してください。", size=16, weight="bold"),
            ft.ElevatedButton(
                "保存フォルダを指定",
                on_click=lambda _: get_directry_dialog.get_directory_path(),
                disabled=getattr(page, "web", False)  # Web版では無効化
            ),
            selected_directry,
        ]),
        ft.Row([ft.Text("2.接頭語の指定(ファイル名が以下の接頭語+ISBNになります)", size=16, weight="bold")]),
        ft.Row([prefix_input]),
        ft.Row([ft.Text("3.ISBNの入力 ※日本語入力はオフにしてバーコードリーダー等で読み取りを行ってください。", size=16, weight="bold")]),
        ft.Row([isbn_input]),
        ft.Row([download_button]),
        ft.Row([ft.Text("4.ログ", size=16, weight="bold"), log_output]),
        ft.Row([ft.Text("5.プレビュー", size=16, weight="bold"), preview_output]),
        ft.Row([ft.Text("6.進捗状況:", size=16, weight="bold"), progress]),
        ft.Row([open_button]),
    )

# アプリの起動
ft.app(target=main)

4.おもな関数についての説明

4.1 fetch_and_save_thumbnails

ボタンを押した直後の動作であるon_click_downloadから呼び出される形で動作します。

isbnのリスト等の必要な情報を受け取り、ダウンロード処理を順次処理していきます。
APIの方で指定されているurl文字列を変数として定義し、isbnの部分だけ変更してループ処理する感じです。

その時に、すでに保存されている場合の再ダウンロードは行わなかったり、1秒の待ち時間を置いたりするなどして、国立国会図書館側のサーバーへの負荷を軽減します。

また、何らかの原因でダウンロードができなかった場合はその内容もログへ表示できるようにしておきます。

4.2 def get_directry_result

保存先ディレクトリを決める際に、windowsの機能であるファイルダイアログをfletの画面から呼び出します。
その際に、fletの画面上ではなく、別ウインドウとしてファイルダイアログが開きます。

そのため(# UIの追加)では、画面を定義する際に、あるボタンを押したときに「画面(ファイルダイアログ)が重なって表示される」という設定が必要になり、その結果を表示する「ファイルダイアログからファイルパスを受け取って画面上に反映させる」ということをやっています。

それに対応して、#UIの追加ではElevatedButtonの作成時にファイルダイアログを呼び出す動作を設定しています。
この動作は関数を定義するか一時関数として記述する必要があるため、lambda文という少々特殊な書き方をしています。

4.3 on_click_download

ダウンロードボタンが押された直後の処理になります。

具体的には「保存先フォルダーが選択されているか」「isbnのリストから保存ファイル名を決める」などダウンロードの実行前に確定すべきことをのことを処理しています。

ファイル名が決まったらfetch_and_save_thumbnailsに必要な情報を投げます。

まとめ

何かの参考になれば幸いです。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?