📖

【なろう小説API】PHPでなろうAPIを叩いて「小説家になろう」独自検索フォームを作成する

に公開

なろう小説APIとは

「小説家になろう」が提供している公式APIです。

なろう小説APIでは小説家になろうに掲載されている作品情報を取得できます。
なろうAPIはHTTPでのリクエストに対してJSON形式、JSONP形式又はYAML形式、PHPのserializeで応答します。
実際に作品のデータが修正されてからなろうAPIに反映されるまで平均5分程度(最大2時間)の誤差があります。
なろうデベロッパーより

公式の検索が提供しているものと同様の結果を返却してくれます。
今回は備忘として残すため、本記事を作成しています。

利用制限

利用制限は現在、休止しています。
負荷状況により今後、導入されることがあるためキャッシュの導入など、利用制限を想定したシステム設計をお願いします。ただし、古い情報を誤って掲載しないよう一定期間(長くても2週間)でキャッシュの更新・削除をお願いします。

現在APIの利用制限等は特に存在していないとのことですが、デベロッパーページには利用上限等の記載自体はあり、場合によっては復活する可能性もありますので注意が必要です。

IPアドレスごとに利用制限を行います。
1日の利用上限は80,000または転送量上限400MByteです。

成果物

小説を探そう

実際にAPIを利用して作成した独自検索フォームです。

作品情報の取得

index.php
<form action="index_test.php" method="GET">
  <div class="box">
    <div class="c-s">
      <div class="search-main">
        <div class="parts-01 search-keyword">
          <div class="input-plus">
            <div class="search-input">
              <span>検索</span>
              <input type="text" name="word" placeholder="作品名・キーワードを入力する" value="<?= htmlspecialchars($_GET['word'] ?? '') ?>">
            </div>
          </div>
          <div class="input-exclusion">
            <div class="search-input">
              <span>除外</span>
              <input type="text" name="notword" placeholder="除外キーワードを入力する" value="<?= htmlspecialchars($_GET['notword'] ?? '') ?>">
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
  <div class="search-tab">
    <div class="tab-controller">
      <ul>
        <li class="tab" data-target=".js-tab-cat">ジャンル<span><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 16L6 10H18L12 16Z"></path></svg></span></li>
        <li class="tab" data-target=".js-tab-detail">詳細条件<span><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 16L6 10H18L12 16Z"></path></svg></span></li>
      </ul>
    </div>
    <div class="tab-target">
      <!-- ジャンル検索 -->
      <div class="js-tab-cat">
        <div class="box">
          <dl>
            <div>
              <dt>恋愛</dt>
              <dd>
                <ul>
                  <li>
                    <label class="input-check">
                      <input type="checkbox" name="genre[]" value="101" <?= isset($_GET['genre']) && in_array('101', $_GET['genre']) ? 'checked' : '' ?>>
                      <span>異世界</span>
                    </label>
                  </li>
                  <li>
                    <label class="input-check">
                      <input type="checkbox" name="genre[]" value="102" <?= isset($_GET['genre']) && in_array('102', $_GET['genre']) ? 'checked' : '' ?>>
                      <span>現実世界</span>
                    </label>
                  </li>
                </ul>
              </dd>
            </div>
            <!-- 中略 -->
          </dl>
        </div>
      </div>
      <!-- 詳細条件 -->
      <div class="js-tab-detail">
        <div class="c-s">
          <div class="box">
            <div class="search-cat">
              <div class="title">1話平均文字数</div>
              <div class="search-input_multi">
                <input type="text" name="average_minlen" placeholder="最低" value="<?= htmlspecialchars($_GET['average_minlen'] ?? '') ?>">
                <span></span>
                <input type="text" name="average_maxlen" placeholder="最大" value="<?= htmlspecialchars($_GET['average_maxlen'] ?? '') ?>">
              </div>
            </div>
            <!-- 中略 -->
          </div>
        </div>
      </div>
    </div>
  </div>
  <div class="btns">
    <div class="parts-03 btn--01"><button type="submit">検索</button></div>
    <div class="parts-04 btn--01"><a href="/">リセット</a></div>
  </div>
</form>
index.php
<?php
// 外部ファイルの読み込み
require_once '_functions/functions.php';

// 入力パラメータを取得
$genres = isset($_GET['genre']) ? implode('-', $_GET['genre']) : null;
$word = filter_input(INPUT_GET, 'word', FILTER_SANITIZE_STRING);
$notword = filter_input(INPUT_GET, 'notword', FILTER_SANITIZE_STRING);

//-- 中略 --//

// 並び替え
$order = filter_input(INPUT_GET, 'order', FILTER_SANITIZE_STRING);

// クエリパラメータの組み立て
$queryParams = [
  'word' => $word,
  'notword' => $notword,
  'genre' => $genres,
  'length' => $length,
  'kaiwaritu' => $kaiwaritu,
  'lastup' => $lastup,
  'order' => $order
];

// APIデータを取得
$result = fetchNovelData($queryParams);
$totalCount = $result['totalCount'];
$data = $result['data'];
?>

条件を設定し、検索ボタンを押下することで該当の条件がGETで渡されます。
渡った条件を元に、公式APIから作品を取得する下記関数(fetchNovelData)を実行します。

functions.php
function fetchNovelData($queryParams) {
  $apiBaseUrl = 'https://api.syosetu.com/novelapi/api/';
  $apiParams = [
    'out' => 'json', // json形式で出力
    'gzip' => 5, // 圧縮率の設定
    'lim' => 500, // 一度に取得する件数
    'of' => 't-n-w-s-bg-g-k-ga-a-ah-l-gp-dp-wp-gl' // 取得する情報の制限
  ];

  foreach ($queryParams as $key => $value) {
    if (!empty($value)) {
      $apiParams[$key] = $value;
    }
  }

  // パラメータを渡してAPIを叩く
  $apiUrl = $apiBaseUrl . '?' . http_build_query($apiParams);
  $options = [
    'http' => [
      'header' => "User-Agent: PHP\r\n"
    ]
  ];
  $context = stream_context_create($options);

  $response = file_get_contents($apiUrl, false, $context);
  $response = gzdecode($response);
  $data = json_decode($response, true);

  $totalCount = intval($data[0]['allcount']);
  unset($data[0]); // [0]には取得件数が入っているので、破棄

  return [
    'totalCount' => $totalCount,
    'data' => $data
  ];
}

※一部不要な箇所は中略しています

取得した条件を元に検索を行う関数です。
$apiParamsは「出力する条件」を設定するもので、どういった形で作品リストを出力するかを設定できます。
出力GETパラメータにまとめられており、「作品リストの何番目から出力するか」や「何件出力するか」などの条件が設定されています。
ただし出力件数は500件が最大で、作品取得位置も2000番目までしか選べないため、それ以上の出力は作品の最終掲載日等を指定してずらす必要があります。

$queryParamsの内容は公式ガイドに沿ったもので、条件抽出GETパラメータに詳細がまとめられています。
ジャンルは数字のみが返却されるため、下記のようにマッピング用関数等を作る必要があります。

functions.php
function convertGenreCode($code, $type = 'biggenre') {
  static $bigGenreMapping = [
    '0' => '未選択',
    '1' => '恋愛',
    '2' => 'ファンタジー',
    '3' => '文芸',
    '4' => 'SF',
    '99' => 'その他',
    '98' => 'ノンジャンル'
  ];

  static $genreMapping = [
    '0' => '未選択〔未選択〕',
    '101' => '異世界〔恋愛〕',
    '102' => '現実世界〔恋愛〕',
    '201' => 'ハイファンタジー〔ファンタジー〕',
    '202' => 'ローファンタジー〔ファンタジー〕',
    '301' => '純文学〔文芸〕',
    '302' => 'ヒューマンドラマ〔文芸〕',
    '303' => '歴史〔文芸〕',
    '304' => '推理〔文芸〕',
    '305' => 'ホラー〔文芸〕',
    '306' => 'アクション〔文芸〕',
    '307' => 'コメディー〔文芸〕',
    '401' => 'VRゲーム〔SF〕',
    '402' => '宇宙〔SF〕',
    '403' => '空想科学〔SF〕',
    '404' => 'パニック〔SF〕',
    '9901' => '童話〔その他〕',
    '9902' => '詩〔その他〕',
    '9903' => 'エッセイ〔その他〕',
    '9904' => 'リプレイ〔その他〕',
    '9999' => 'その他〔その他〕',
    '9801' => 'ノンジャンル〔ノンジャンル〕'
  ];

  // マッピングを選択
  $mapping = ($type === 'biggenre') ? $bigGenreMapping : $genreMapping;

  // 対応するジャンルを返す
  return $mapping[$code] ?? '不明';
}

並び替え

公式検索と同様の並び替えを行うことが可能です。

index.php
<?php
// 並び替え
$order = filter_input(INPUT_GET, 'order', FILTER_SANITIZE_STRING);
?>

<!-- html部分 -->
<div class="search-sort">
  <p>並び替え</p>
  <div class="select">
    <select id="js-select-order" name="order">
      <option value="new" <?= $order === 'new' ? 'selected' : '' ?>>最新掲載順</option>
      <option value="weekly" <?= $order === 'weekly' ? 'selected' : '' ?>>週間ユニークアクセスが多い順</option>
      <option value="favnovelcnt" <?= $order === 'favnovelcnt' ? 'selected' : '' ?>>ブックマーク登録の多い順</option>
      <option value="reviewcnt" <?= $order === 'reviewcnt' ? 'selected' : '' ?>>レビューの多い順</option>
      <option value="hyoka" <?= $order === 'hyoka' ? 'selected' : '' ?>>総合ポイントの高い順</option>
      <option value="dailypoint" <?= $order === 'dailypoint' ? 'selected' : '' ?>>日間ポイントの高い順</option>
      <option value="weeklypoint" <?= $order === 'weeklypoint' ? 'selected' : '' ?>>週間ポイントの高い順</option>
      <option value="monthlypoint" <?= $order === 'monthlypoint' ? 'selected' : '' ?>>月間ポイントの高い順</option>
      <option value="quarterpoint" <?= $order === 'quarterpoint' ? 'selected' : '' ?>>四半期ポイントの高い順</option>
      <option value="yearlypoint" <?= $order === 'yearlypoint' ? 'selected' : '' ?>>年間ポイントの高い順</option>
      <option value="hyokacnt" <?= $order === 'hyokacnt' ? 'selected' : '' ?>>評価者数の多い順</option>
      <option value="lengthdesc" <?= $order === 'lengthdesc' ? 'selected' : '' ?>>文字数の多い順</option>
      <option value="generalfirstup" <?= $order === 'generalfirstup' ? 'selected' : '' ?>>初回掲載順</option>
      <option value="old" <?= $order === 'old' ? 'selected' : '' ?>>更新が古い順</option>
      </optgroup>
    </select>
  </div>
</div>
common.js
document.addEventListener('DOMContentLoaded', () => {
  const orderSelect = document.getElementById('js-select-order');

  orderSelect.addEventListener('change', () => {
    const searchParams = new URLSearchParams(window.location.search);
    searchParams.set('order', orderSelect.value);
    window.location.href = `${window.location.pathname}?${searchParams.toString()}`;
  });
});

クエリパラメータに取得したorder情報を渡せば、API側で処理してくれます。
jsはselectboxを変更した時、検索情報を引き継ぎながらページを更新するものになっています。

独自絞り込み&並び替え

ここからは当初から「公式検索にないけど追加したい」と思い独自に作成した箇所になります。
APIとは関係ない部分の処理になるため、

  1. 公式条件に適した作品群をAPIで取得
  2. 取得した$dataの中から、独自の絞り込みを行い適合しない作品を除外

という形を取っています。
そのため、500件取得しますが最終的には$dataの中には40件しかない、といったことになります。
(自分の頭が悪すぎてこの脳筋手法しか思いつかなかった)

index.php(独自条件絞り込み)
// フィルタリング
$filteredCount = 0;
if (!empty($averageMinLength) || !empty($averageMaxLength) || 
  !empty($averageHyoka) || !empty($averageHyokaMax) ||
  !empty($minGeneralAllNo) || !empty($maxGeneralAllNo) ||
  !empty($minHyokaCnt) || !empty($maxHyokaCnt)) {

  $filteredData = [];
  foreach ($data as $novel) {
    if (!isValidAverageLength($novel, $averageMinLength, $averageMaxLength) ||
      !isValidAverageRating($novel, $averageHyoka, $averageHyokaMax) ||
      !isValidGeneralAllNo($novel, $minGeneralAllNo, $maxGeneralAllNo) ||
      !isValidHyokaCnt($novel, $minHyokaCnt, $maxHyokaCnt)) {
      continue;
    }
    $filteredData[] = $novel;
  }
  $filteredCount = count($filteredData);
  $data = $filteredData;
} else {
  $filteredCount = count($data);
}
functions.php
// 平均文字数の条件を満たすか確認
function isValidAverageLength($novel, $min, $max) {
  $generalAllNo = intval($novel['general_all_no']);
  $length = intval($novel['length']);
  $averageLength = $generalAllNo > 0 ? intval($length / $generalAllNo) : 0;

  if (!empty($min) && $averageLength < $min) {
    return false;
  }
  if (!empty($max) && $averageLength > $max) {
    return false;
  }
  return true;
}

ただのPHPなので説明は省略します。
取得した$dataを平均文字数で絞り込んでいます。
同様の処理をそれぞれ用意しており、最終的に全部で絞り込んだ結果が$dataに格納されます。

ページ内には「検索結果:〇件を表示(全〇件中)」と表記がありますが、公式絞り込みの場合は500件を表示します。
ここが独自絞り込みになるため、30件などになります。

index.php(並び替え)
// 並び替えの実行
if (!empty($order)) {
  switch ($order) {
      case 'averageLength': // 平均文字数が多い順
        usort($data, function ($a, $b) {
          $avgA = $a['general_all_no'] > 0 ? $a['length'] / $a['general_all_no'] : 0;
          $avgB = $b['general_all_no'] > 0 ? $b['length'] / $b['general_all_no'] : 0;
          return $avgB <=> $avgA;
        });
        break;
      case 'averageRating': // 平均評価が多い順
        usort($data, function ($a, $b) {
          $ratingA = $a['all_hyoka_cnt'] > 0 ? $a['all_point'] / $a['all_hyoka_cnt'] : 0;
          $ratingB = $b['all_hyoka_cnt'] > 0 ? $b['all_point'] / $b['all_hyoka_cnt'] : 0;
          return $ratingB <=> $ratingA;
        });
        break;
      case 'episodeCount': // 話数が多い順
        usort($data, function ($a, $b) {
          return $b['general_all_no'] <=> $a['general_all_no'];
        });
        break;
      case 'ratingCount': // 評価件数が多い順
        usort($data, function ($a, $b) {
          return $b['all_hyoka_cnt'] <=> $a['all_hyoka_cnt'];
        });
        break;
      default:
        break;
  }
}

独自の並び替えについても絞り込み同様、$dataの中身を条件に合うようPHPで処理しています。
こちらも挙動が公式とは異なり、本来は「100万件取得した場合、100万件を並び替える」処理になりますが、$dataの中身だけ入れ替えるので「100万件中取得した30件を並び替える」だけの機能になります。

ページネーション

ページネーションは付けていません。
しかし独自条件による絞り込みを行うと取得できる件数が30件などになってしまうため、「更に読み込む」機能は実装しています。

<?php
// 現在の最終更新日をUNIXタイムスタンプに変換(-1秒を追加する)
$lastItemLastup = isset($data[count($data) - 1]['general_lastup']) 
  ? strtotime($data[count($data) - 1]['general_lastup']) - 1 
  : null;

// URLパラメータを生成
$nextParams = array_merge($_GET, ['lastupdate_max' => $lastItemLastup]);
?>

<div class="search-more">
  <div class="js-search-next"><button>次の20件を見る</button></div>
  <div class="js-search-next_date" style="display:none">
    <a href="index.php?<?= http_build_query($nextParams) ?>" class="btn">これより前の作品を読み込む</a>
  </div>
</div>

無理やりな実装になっているのは承知の上で、$dataの一番後ろの作品の時間からマイナス1秒したUNIX時間で再度絞り込みを行います。
例えば$dataの最後の作品が「2024/11/12 10:50:10」であれば、「2024/11/12 10:50:09」をUNIX変換した値になります。

小説家になろう・ハーメルン等のサイトでは毎時0分0秒に予約投稿する人が多いので、その場合はその作品を通り越す可能性があります。

注意点

公式APIを使うにあたっての注意点です。

「時刻」に関する項目

「小説家になろう」では、時刻関連の項目が複数存在しており、それぞれが異なる役割を持っています。

  • general_firstup
  • general_lastup
  • novelupdated_at
  • updated_at

この4種であり、今回主に使っているのはgeneral_lastup(作品最終掲載日)、つまり作品が更新された時刻になります。
初回が掲載されたタイミングと、最新話が掲載されたタイミングで更新されます。

似たようなものにnovelupdated_atがありますが、こちらは「更新時刻」になります。
小説家になろうでは各話で文字の再編集などが可能となっており、最新話が公開されたあとに編集したタイミングでもnovelupdated_atの時刻は更新されます。

また、updated_atは「評価ポイントが更新された時刻」になります。
全体のデータ更新時刻となるので、作品リストなどとはまた別の使い道になる認識です。

最後に

2000件以上をページングで出力する際にはちょっとした工夫が必要になりますが、非常に使いやすいAPIだと思いました。
独自に何かするにしても必要なものは揃っているうえ、PHPぐらいしかできない私向けにもサンプルプログラムがPHPで書かれていたりと、何かと親切でした…。

ハーメルンの平均文字数検索が好きすぎて「小説家になろうでも平均文字検索がしたい!」と思い作成しましたが、分かりやすい作りになっていてありがたかったです。

まだ適当にαと付けており、独自要素を追加する予定です。
ほぼ個人利用目的で作ったものなので需要はあんまりなさそうですが、なろう小説API自体は非常に良い作りなので、ぜひ皆さんも遊んでみてほしいです。
昔はかなりAPIを使ったツールがあったみたいなんですが、ドメイン切れ等で見れなくなっているものも多いです。
(昔は平均文字数検索を導入している外部ツールもあったようですが…)

作れるかどうかは別として、小説家になろうで小説を書いてる人も作品傾向のチェックとかが上手いことできそうな気もするので、分析サイトを作るのもありだと思います。

Discussion