3
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?

Google Books APIで漫画の新刊発売日と価格を自動取得した【演算子と日付フォーマット沼】

3
Last updated at Posted at 2026-03-22

はじめに

漫画本棚管理アプリを作った。

manga-shelf — 本棚に登録した漫画シリーズの次巻発売日と価格を自動取得して、出費グラフで見える化するWebアプリだ。

バックエンドなし、DBなし。React + TypeScript + localStorage のみ。外部APIは無料のものだけ使う、という制約で作った。

この記事では「漫画タイトルから次巻の発売日と価格をどう自動取得したか」を書く。Google Books APIの使い方と、ハマったポイントが誰かの役に立てば。


やりたかったこと

ユーザーが本棚に「ワンピース」を登録したとき、こういうことを自動でやりたかった:

  1. 「ワンピース」の最新刊を検索する
  2. ユーザーがまだ持っていない最大巻数の未来発売日を取得する
  3. その価格も一緒に取得する
  4. 新刊カレンダーに表示する

これを「サーバーなし・APIキーなし・無料」で実現するために、Google Books APIを使った。


Google Books API の基本

Google Books APIはAPIキー不要で使える(1日1,000リクエストの制限あり)。

基本的な検索クエリはこうだ:

GET https://www.googleapis.com/books/v1/volumes?q={query}&langRestrict=ja&maxResults=20

シンプルに「ワンピース」で検索するとこうなる:

const url = `https://www.googleapis.com/books/v1/volumes?q=ワンピース&langRestrict=ja&maxResults=10`;
const res = await fetch(url);
const data = await res.json();

でも、これだけだと問題がある。


ハマり①:普通に検索すると関係ない本が大量に混じる

q=ワンピース で検索すると、こんな結果が返ってくる:

  • ✅ ONE PIECE 108巻
  • ✅ ONE PIECE 107巻
  • ❌ 「ワンピースで学ぶビジネス思考」
  • ❌ 「ワンピースファンブック」
  • ❌ 「ワンピース 名言集」

ファンブックや解説本、名言集が普通に混入してくる。巻数の正規表現でフィルタリングしても、ノイズが多くて正確な最新巻が拾えない。

解決策:intitle: 演算子を使う

Google Books APIには検索演算子がある。intitle: を使うと、タイトルフィールドのみを対象に検索できる:

const url = `https://www.googleapis.com/books/v1/volumes?q=intitle:${encodeURIComponent(seriesTitle)}&langRestrict=ja&maxResults=20&orderBy=newest&printType=books`;

intitle:ワンピース にすると「ワンピース」という文字がタイトルに含まれる本だけが返ってくる。ノイズが大幅に減る。

他にも使える演算子:

演算子 用途
intitle: タイトルフィールドで検索
inauthor: 著者フィールドで検索
inpublisher: 出版社フィールドで検索
subject: カテゴリ/ジャンルで検索
isbn: ISBN番号で検索

ドキュメントには書いてあるが、検索演算子の存在に気づかず素のクエリで使っていた時間が長かった。


ハマり②:publishedDate のフォーマットが3種類ある

レスポンスのデータ構造はこんな感じだ:

{
  "volumeInfo": {
    "title": "ONE PIECE 108",
    "publishedDate": "2024-02-02",
    "imageLinks": {
      "thumbnail": "http://..."
    }
  },
  "saleInfo": {
    "listPrice": {
      "amount": 528,
      "currencyCode": "JPY"
    }
  }
}

問題は publishedDate だ。フォーマットが本によってバラバラで、3種類が混在している:

  • "2024-02-02" → YYYY-MM-DD(完全な日付)
  • "2024-02" → YYYY-MM(日が不明)
  • "2024" → YYYY(月も不明)

これをそのまま new Date() に渡すと、ブラウザによって挙動が変わってUTCとローカルタイムのズレが発生したりする。

対処として、すべて YYYY-MM-DD 形式に正規化した:

const raw = info.publishedDate ?? null;
const publishedDate = raw
  ? raw.length === 4
    ? `${raw}-01-01`   // "2024" → "2024-01-01"
    : raw.length === 7
    ? `${raw}-01`      // "2024-02" → "2024-02-01"
    : raw              // "2024-02-02" はそのまま
  : null;

これで文字列として "2024-01-01" の形式に揃えてから比較できるようになった。


未来の発売日だけを取り出すロジック

新刊カレンダーに表示したいのは「まだ発売されていない巻」だけだ。

const today = new Date().toISOString().slice(0, 10); // "2026-03-22"

const candidates = data.items.flatMap((item: any) => {
  const info = item.volumeInfo;

  // タイトルから巻数を抽出(例:"ONE PIECE 108" → 108)
  const volMatch = info.title?.match(/(\d+)[巻冊]?\s*$/);
  if (!volMatch) return []; // 巻数がなければスキップ

  const volumeNumber = parseInt(volMatch[1]);
  const raw = info.publishedDate ?? null;
  if (!raw) return [];

  const releaseDate =
    raw.length === 4 ? `${raw}-01-01` :
    raw.length === 7 ? `${raw}-01` : raw;

  // 未来の日付のみ
  if (releaseDate <= today) return [];

  // すでに所持している巻はスキップ
  if (ownedVolNums.includes(volumeNumber)) return [];

  const price: number | null = item.saleInfo?.listPrice?.amount ?? null;
  return [{ volumeNumber, releaseDate, price }];
});

// 最大巻数のものを最新刊として採用
candidates.sort((a, b) => b.volumeNumber - a.volumeNumber);
const latest = candidates[0];

ポイントは releaseDate <= today の比較だ。文字列の辞書順比較で日付の前後を判定している。YYYY-MM-DD 形式に揃えているからこれが成立する。


ハマり③:国内漫画の価格が取れないことが多い

saleInfo.listPrice.amount から価格を取ろうとしたが、日本の漫画だとこのフィールドが空になることが多かった。

電子書籍版は価格が入っていることがあるが、紙の本だと saleInfo.saleability"NOT_FOR_SALE" になっていて価格フィールドが存在しない、というケースが頻発した。

解決策:OpenBD APIを補助的に使う

OpenBD は日本の書籍情報をISBNで取得できる無料APIだ。APIキー不要で使えて、国内書籍の定価情報が比較的充実している。

export async function fetchPriceByIsbn(isbn: string): Promise<number | null> {
  try {
    const res = await fetch(`https://api.openbd.jp/v1/get?isbn=${isbn}`);
    if (!res.ok) return null;
    const data = await res.json();
    const item = data?.[0];
    if (!item) return null;

    // ONIX 3.0 形式: ProductSupply.SupplyDetail.Price[]
    const prices: any[] = item.onix?.ProductSupply?.SupplyDetail?.Price ?? [];
    const priceInfo = prices.find((p: any) => p.PriceType === '02') ?? prices[0];
    return priceInfo?.PriceAmount ? parseInt(priceInfo.PriceAmount) : null;
  } catch {
    return null;
  }
}

ONIX 3.0 の PriceType
'02' は「消費者向け定価(税込)」を意味する。'01' は税抜き定価。日本の書籍の場合は '02' を優先して取得すると税込価格が取れる。

ただし OpenBD も全書籍の価格が揃っているわけではなく、ISBNが必要なため「ISBNなし → Google Books APIのみで価格を試みる → 取れなければ null」というフォールバック構成になった。


localStorage をDB代わりに使う

バックエンドなしの制約があるので、データはすべてlocalStorageに保存している。

TypeScriptで型を定義しておくと、ストレージ操作が安全になる:

// types/index.ts
export interface Series {
  id: string;
  title: string;
  author: string;
  coverUrl: string;
  totalVolumes: number | null; // null = 連載中
  isCompleted: boolean;
  addedAt: string;
}

export interface Volume {
  id: string;
  seriesId: string;
  volumeNumber: number;
  status: 'reading' | 'owned' | 'want' | 'backlog';
  purchasePrice: number | null;
  purchaseDate: string | null; // YYYY-MM-DD
  releaseDate: string | null;
}

ストレージ操作を1つのオブジェクトにまとめておくと、呼び出し側がきれいになる:

// utils/storage.ts
const KEYS = { series: 'manga_series', volumes: 'manga_volumes' };

export const storage = {
  getSeries: (): Series[] =>
    JSON.parse(localStorage.getItem(KEYS.series) ?? '[]'),
  saveSeries: (data: Series[]) =>
    localStorage.setItem(KEYS.series, JSON.stringify(data)),

  getVolumes: (): Volume[] =>
    JSON.parse(localStorage.getItem(KEYS.volumes) ?? '[]'),
  saveVolumes: (data: Volume[]) =>
    localStorage.setItem(KEYS.volumes, JSON.stringify(data)),

  addSeries: (s: Series) => {
    const list = storage.getSeries();
    storage.saveSeries([...list, s]);
  },

  upsertVolume: (v: Volume) => {
    const list = storage.getVolumes();
    const idx = list.findIndex(x => x.id === v.id);
    if (idx >= 0) list[idx] = v; else list.push(v);
    storage.saveVolumes(list);
  },

  deleteSeries: (id: string) => {
    storage.saveSeries(storage.getSeries().filter(s => s.id !== id));
    storage.saveVolumes(storage.getVolumes().filter(v => v.seriesId !== id));
  },
};

upsertVolume が地味に便利で、「あれば更新、なければ追加」を1つのメソッドで済ませられる。


全体のデータフロー

ユーザーが「ワンピース」を検索
        ↓
Google Books API (q=intitle:ワンピース)
        ↓
タイトルから巻数を正規表現で抽出 /(\d+)[巻冊]?\s*$/
        ↓
publishedDate を YYYY-MM-DD に正規化
        ↓
未来の発売日 + 未所持巻 でフィルタリング
        ↓
price = saleInfo.listPrice.amount
      ↓ (nullなら)
OpenBD API (ISBN で価格取得)
        ↓
localStorage に保存
        ↓
新刊カレンダー・出費グラフに表示

限界と注意点

Google Books APIの精度問題

intitle: を使っても、完全一致ではなく部分一致なので、タイトルが短い漫画(「バガボンド」「帯』」など)は関係ない本がヒットすることがある。

また発売日情報がGoogleのデータ収集タイミングによって遅延することがある。公式の発売日より数日後にAPIに反映されるケースがあった。

価格の取得率

Google Books APIで取れる国内漫画の価格は体感30〜40%程度。OpenBDと組み合わせても100%には届かない。price: null のケースはユーザーが手入力できるようにUIで対応した。

localStorage の容量制限

通常5〜10MBまで。漫画100シリーズ・各10巻程度のデータなら問題ないが、大量のシリーズを登録すると圧迫する可能性がある。カバー画像のURLだけを保存しているので(画像データそのものは保存しない)、実際には問題が起きにくい。


まとめ

Google Books APIで漫画の新刊情報を自動取得するときのポイント:

  • intitle: 演算子を使う → 素の検索より格段にノイズが減る
  • publishedDate は3種フォーマットあるYYYY-MM-DD に正規化してから比較する
  • 国内書籍の価格は取れないことが多い → OpenBD APIをフォールバックとして使う
  • 文字列比較で日付の前後を判定YYYY-MM-DD 形式に揃えていれば辞書順比較が使える

漫画好きエンジニアが自分で使いたくて作ったツールなので、同じ悩み(何巻まで持ってるか分からない・月いくら使ってるか把握できていない)を持っている方がいれば、使ってみてほしい。

https://manga-shelf-blond.vercel.app


3
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
3
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?