NDLサーチのSRUレスポンスを初めて眺めた夜、属性の名前空間が雪崩のように押し寄せてきて視界が真っ白になった。焦った。Open Library時代のJSONパースでは一度も考えなかったrdf:datatype="...NDLBibID"の扱いを外すたびにURLが404へ飛び、WebView2の中で真っ白な画面を見つめ続けた。
この文章は、dotnetProjectForWindows_8で国立国会図書館サーチAPI対応のBookSearchAppを作り直し、SRU/XML解析→NDLBibID抽出→書影API→WebView2→ReadyToRun三兄弟まで組み直した制作エッセイ。Open Library版を振り返ったmidori_new009とはまったく違うAPIの癖と正面から向き合った記録です。
対象読者
- SRU + dcndlをWPFアプリに取り込みたい.NETエンジニア
- NDLサーチAPIの書誌ID/書影APIルールを実装レベルで把握したい人
- WebView2とMVVMイベント連携、実行時コンパイルの一部をビルド時に済ませるオプションを有効にした単一EXEの運用感を知りたい方
記事に書いてあること
- SearchTypeごとにSRUクエリをどう組み直し、1.48s/1.32s/0.82sのレイテンシーを許容したか
rdf:datatype="NDLBibID"やrdf:resourceを舐め尽くして書誌IDを拾い、NdlSearchUrlを安全に生成する方法
- お気に入り保存処理で
%LocalAppData%固定保存、WebView2の読み込み状態制御、API利用注意UIの組み込み
- ビルドスクリプトでwin-x64/x86/arm64の実行時コンパイルの一部をビルド時に済ませるオプションを有効にした単一EXEを151MB/142MB/167MBで揃えた工程
前提知識
- SRU (Search/Retrieve via URL) の基本パラメータとdcndlスキーマ
- .NET 10 WPF + MVVM + WebView2を触ったことがあること
- 国立国会図書館サーチAPIの利用規約と書影APIの扱い(公式ガイド)
今回整えたもの
NDLサーチAPIに絞ったWPFアプリ。タイトル/著者検索はSRU、ISBNはidentifier検索に切り替え、検索結果→詳細→お気に入り→Web表示→利用規約タブの5枚構成。UIはMainWindow.xamlで色とアイコンを一新し、WebView2のリロードや404の注意書きも載せた。BookSearchApp-x64.exeは実行時コンパイルの一部をビルド時に済ませるオプション込みで151MB、x86とarm64もdistに並べた。
SRUクエリとXML分解の汗
タイトル検索は平均1480ミリ秒、著者検索は1320ミリ秒、ISBN検索は820ミリ秒(すべてStopwatchで10回実測)。最大レコード数を500にするとXMLが巨大化してUIが固まるので20件を標準にし、ページングは開始レコード = オフセット + 1で切り替えています。HTTP通信のタイムアウトは30秒。粘りすぎるとUIスレッドが不安になるのでキャンセルはViewModelで扱わず、例外をメッセージボックスに出す方針にした。これだ。
検索処理は、タイトルや著者名を受け取ってSRUクエリを組み立て、XMLレスポンスを解析します。maximumRecordsを20に制限したのは、500件だとXMLが重すぎてUIが固まるからです。ページングはstartRecordをoffset + 1に設定して、次のページを取得します。
タイトル検索の実装(NdlSearchApiService.cs 39-67行目):
public async Task<BookSearchResult> SearchByTitleAsync(string title, int limit = 20, int offset = 0)
{
if (string.IsNullOrWhiteSpace(title))
{
return new BookSearchResult();
}
var query = $"title=\"{Uri.EscapeDataString(title)}\"";
var url = $"{BaseUrl}?operation=searchRetrieve&query={query}" +
$"&maximumRecords={Math.Min(limit, 500)}&startRecord={offset + 1}" +
$"&recordSchema=dcndl&recordPacking=xml";
var response = await _httpClient.GetStringAsync(url);
return ParseSearchResponse(response);
}
SRUのrecordDataにはnamespace違いのdc:titleやdcterms:issuedがバラバラに入っていて、Descendants().Where(e => e.Name.LocalName == "title")のようにローカル名で走査しないと永遠にタイトルが取れない。XMLの一部をUIで確認できるよう、demo2とdemo3を使って「どのidentifierを信用しているか」を視覚化しておきました。
identifierとNDLBibIDの拾い方
書誌IDを落とすとWebView2が404に転がる。これが一番マズい。そこでidentifier→属性→値の優先順位をがっちり決めた。具体的にはrdf:datatype=".../NDLBibID"を最優先に、その次にrdf:resourceやrdf:about、それでも見つからないときは/books/を含むURLを切り出す。
最初はdcndl:bibId要素だけを探していました。でも、これだけでは足りない。XMLの構造がバラバラで、identifier要素の属性を見ないと書誌IDが取れないことが分かった。そこで、属性を順番にチェックする処理を追加しました。
書誌ID抽出の実装(NdlSearchApiService.cs 354-523行目):
var bibIdElement = metadata.Descendants(dcndl + "bibId").FirstOrDefault();
if (bibIdElement != null)
{
bookId = bibIdElement.Value;
}
...
foreach (var identifier in identifierElements)
{
var id = identifier.Value;
foreach (var attr in identifier.Attributes())
{
if (attr.Name.LocalName == "datatype" && attr.Value.Contains("NDLBibID"))
{
bookId = id.Trim();
break;
}
if (attr.Name.LocalName == "resource" && attr.Value.Contains("/books/"))
{
bookId = attr.Value.Substring(attr.Value.IndexOf("/books/", StringComparison.OrdinalIgnoreCase) + 7).TrimEnd('/');
}
}
if (string.IsNullOrWhiteSpace(bookId) && id.StartsWith("R") && id.Contains("-I"))
{
bookId = id;
}
}
NDLBibIDが数字だけならR100000002-Iを前置してNdlSearchUrlを生成。Book.csにフォールバックでGUIDを入れ、お気に入り保存処理が空Keyで落ちないようにもしてあります。意外だ。単純な条件分岐ですが、見落とすとViewModel側でIsFavoriteが常にfalseになってしまう。
お気に入り保存処理とLocalAppData
NDLサーチの結果をローカルで読めるよう、お気に入り保存処理は%LocalAppData%\BookSearchApp\favorites.jsonを必ず作る。Open Library版と違い、書影APIのURLも一緒に保持したかったのでBook全体をJSON化しています。AddFavorite/RemoveFavoriteの挙動はdemo4でそのまま追えるようにしました。
アプリ起動時に保存フォルダを作ります。ログは最大80件まで保存します。古いものから自動で削除されます。全ログを世界標準時に揃えて、時差の問題を防ぎます。お気に入りに追加する処理は、重複を弾き、メモだけ書き換えられるようにしました。ViewModel側ではお気に入り書籍をデータ変更を自動で通知するコレクションにしておき、お気に入りを更新するコマンドを叩けばUIが最新化されます。
お気に入り保存先の初期化処理(FavoriteService.cs 21-105行目):
var appDataPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"BookSearchApp"
);
if (!Directory.Exists(appDataPath))
{
Directory.CreateDirectory(appDataPath);
}
_favoritesFilePath = Path.Combine(appDataPath, "favorites.json");
_favorites = LoadFavorites();
WebView2と404の揺らぎ
NDLサーチの詳細ページは、電子資料だと存在しないことが多い。WebView2で404を食らったときにユーザーを混乱させないよう、Webページを開くコマンドでURLを更新するときは必ず読み込み状態をtrueにし、ナビゲーション完了でfalseに戻す。失敗。これを忘れていた頃はローディングインジケーターが消えず、WebView2が固まったように見えた。
URLを更新する処理では、まず読み込み状態をtrueにします。これでローディングインジケーターが表示されます。WebView2のナビゲーション完了イベントでfalseに戻すことで、ローディングが終わったことをUIに伝えます。404エラーでも同じ処理をすることで、ユーザーを混乱させません。
最初は、ナビゲーション完了だけを監視していましたが、ページ遷移の開始時にもローディング状態を更新したほうが自然でした。ナビゲーション開始とコンテンツ読み込み中の両方で読み込み状態をtrueにすることで、ユーザーが迷わなくなりました。
Webページを開く処理(BookSearchViewModel.cs 626-644行目):
private void OpenWebPage()
{
if (SelectedBook?.NdlSearchUrl != null)
{
if (WebPageUrl == SelectedBook.NdlSearchUrl)
{
RequestNavigateToWebTab?.Invoke(this, EventArgs.Empty);
return;
}
WebPageUrl = SelectedBook.NdlSearchUrl;
OnPropertyChanged(nameof(WebPageUrl));
OnPropertyChanged(nameof(HasWebPageUrl));
RequestNavigateToWebTab?.Invoke(this, EventArgs.Empty);
}
}
MainWindow.xaml.cs側ではNavigationStarting/ContentLoading/NavigationCompletedですべて_viewModel.IsWebViewLoadingを切り替え、XAMLでオーバーレイを出す。Loading状態をUIで見ておけば、404でも慌てずリンクを開き直せる。
利用規約のリマインダーをUIに載せた理由
NDLサーチAPIは非営利なら申請不要だが、営利利用や書影再配布には申請が必要。間違えるとアプリ全体の信用が飛ぶ。そこでMainWindowの「ℹ️ 利用規約」タブにAPI利用に関する注意事項.mdの要約を貼り、非営利/営利/書影API/データプロバイダの違いをカード形式で並べた。demo6でそのままチェックリスト化している。
最初は利用規約をREADMEに書くだけにしていました。でも、ユーザーがアプリを起動した時にすぐ確認できるようにしたかった。そこで、MainWindowに専用のタブを追加して、利用規約を表示するようにしました。非営利と営利の違い、書影APIの制約、データプロバイダごとの条件を分かりやすく整理しています。
実行時コンパイルの一部をビルド時に済ませるオプションを有効にした三兄弟のサイズ管理
ビルドスクリプトはOpen Library版と同じく3ターゲットをPublish。違うのはEXEサイズが少し肥大化したこと(x64: 151.3MB、x86: 142.4MB、arm64: 167.0MB)。NDLサーチのXML解析でSystem.Xml.Linqを広く使っているせいで実行時コンパイルの一部をビルド時に済ませるオプションのメタデータが膨らみ、初回起動を1.1秒短縮するまで何度も publish をやり直した。ヨシ。
ビルドスクリプトは、x64、x86、arm64の3つのターゲットで単一EXEを作成します。実行時コンパイルの一部をビルド時に済ませるオプションを有効にすることで、初回起動時間を短縮します。XML解析でSystem.Xml.Linqを使っているため、メタデータが大きくなり、EXEサイズが増えました。でも、起動時間の短縮を優先しました。
最初は、実行時コンパイルの一部をビルド時に済ませるオプションを有効にしていませんでした。でも、起動速度が遅いことに気づき、有効にしてみました。結果、起動時間が約2.1秒から約1.0秒に短縮されました。ファイルサイズは約10MB増えましたが、起動速度の向上は明らかでした。
ビルドスクリプトの抜粋(build-package.ps1 12-33行目):
dotnet publish -c Release -r win-x64 --self-contained true `
-p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true `
-p:PublishReadyToRun=true -p:DebugType=None -p:DebugSymbols=false `
-o "$distDir\win-x64"
...
Copy-Item "$distDir\win-x64\BookSearchApp.exe" -Destination "$distDir\BookSearchApp-x64.exe" -Force
dist直下にBookSearchApp-*.exeを集め、README.txtに注意事項をまとめる流れも前回から継承。midori_new008で培った配布手順書のおかげで手戻りが減ったのは救いでした。
画面全体の流れを整える
Searchタブ→詳細タブ→お気に入りタブ→Web表示タブ→利用規約タブ。Dispatcher経由でRequestNavigateToDetailsTabやRequestNavigateToWebTabを飛ばし、UIの流れをFlow図にしておくと、「今どのタブを選ぶべきか」を迷わず実装できた。
タブ間の遷移は、Dispatcher経由でイベントを発火させます。検索結果から詳細を表示する時はRequestNavigateToDetailsTab、Webページを表示する時はRequestNavigateToWebTabを使います。この流れを図にしておくと、実装がスムーズになりました。
使ってみて
実際に試してみてください:
SRUコンソールをブラウザで試す(検索タイプごとのURLとレイテンシーが確認できます)
BookSearchApp-x64.exe をダウンロード(ReadyToRun済みの自己完結型EXE)
ポイントは以下の3つ:
maximumRecords=20かつstartRecord=offset+1でSRUレスポンスの遅延を抑え、1.48s/1.32s/0.82sの計測値に落ち着かせた
rdf:datatype="NDLBibID"→rdf:resource→rdf:about→URL抽出の順で書誌IDを勝ち取り、NdlSearchUrlを確実に生成した
- WebView2の
NavigationStarting/CompletedでIsWebViewLoadingを必ず切り替え、404が混ざってもUIを崩さないようにした
SRUレスポンスがタイムアウトしたら、著者検索やISBN検索に切り替えて比較してみると違いが見えてきます。同じようなNDLサーチAPI実装を考えている方の参考になれば嬉しいです。
まとめ
今回は、NDLサーチAPI対応のBookSearchAppを実装しました。
ポイントは以下の4つ:
maximumRecords=20かつstartRecord=offset+1でSRUレスポンスの遅延を抑え、1.48s/1.32s/0.82sの計測値に落ち着かせた
rdf:datatype="NDLBibID"→rdf:resource→rdf:about→URL抽出の順で書誌IDを勝ち取り、NdlSearchUrlを確実に生成した
- お気に入り保存処理で
%LocalAppData%\BookSearchApp\favorites.jsonを守り、Newtonsoft.Jsonで丸ごと保存しても破綻しない構造にした
- ビルドスクリプトでx64/x86/arm64の単一EXEを151MB/142MB/167MBにまとめ、実行時コンパイルの一部をビルド時に済ませるオプションで初回起動を1.1秒短縮した
SRUの気まぐれなXMLと格闘した時間が、誰かのNDLサーチ実装の近道になれば嬉しいです。
さらに深く学ぶには
この記事で興味を持った方におすすめのリンク:
自分の関連記事:
最後まで読んでくださり、ありがとうございました。まだ改善の余地はあるので、また改造したらここで共有します。焦っていた過去の自分に届け。