この記事の要点(TL;DR)
- 外部の公開APIからデータを取って、1レコード=1ページでサイトを作りました。
- あとで見たら、同じ補助金のページが4枚できていました。
- 原因は、APIの側が「公募の回ごと」にレコードを分けていたからです。API側の1件と、ユーザーから見た1件は同じとは限りません。
- 「重複ならcanonicalタグでまとめればいい」と思ったのですが、今回はそれが不適切でした。その理由を書きます。
- 結論は「まとめる」ではなく「関係があることを明示して繋ぐ」でした。
個人開発で 補助金ナビ(hojokin-db.jp) という、国や自治体の補助金・助成金を検索できるサイトを作っています。データはデジタル庁が公開している jGrants という認証不要のAPIから取っていて、自前のデータベースは持っていません。
作ったときの設計
やったことは単純で、こういう形です。
APIから取れる1件(id: a0WJ200000CDX4vMAH)
↓
/subsidies/a0WJ200000CDX4vMAH というページ
APIが返すレコードのIDを、そのままURLにしています。よくある作りだと思います。
一覧ページから詳細ページに飛べて、詳細ページには補助金額・補助率・締切・対象者が並ぶ。ここまでは想定どおり動いていました。
気づいたきっかけ
Search Console(自分のサイトが何の検索で何位に出ているかを見る無料ツール)を、「キーワード×ページ」の組み合わせで出してみたときです。
1表示 0click 89位 ものづくり補助金 2026年 -> /subsidies/a0WJ200000CDX4vMAH
1表示 0click 93位 ものづくり補助金 2026年 -> /subsidies/a0WJ200000CDYimMAH
同じ検索に、自分のサイトの2ページが出ています。
念のため両方のタイトルを見てみると、こうでした。
【経済産業省】ものづくり・商業・サービス生産性向上促進補助金(21次締切)
【経済産業省】ものづくり・商業・サービス生産性向上促進補助金(22次締切)
末尾の「21次」「22次」以外、完全に同じです。
さらに調べたら、19次・20次も生きていました。同じ制度のページが4枚あったことになります。
なぜこうなったのか
jGrantsは、公募の「回」ごとに別レコードを持っています。
補助金というのは、1つの制度が年に何度か締切を設けて募集することがよくあります。「今回は3月締切、次は6月締切」という具合です。API側はこれを別々の申請案件として扱っているので、レコードも別。
私はそれを知らずに「1レコード=1ページ」にしていたので、そのまま4枚に分裂しました。
これは jGrants に限った話ではなくて、外部データを扱うときの一般的な落とし穴だと思います。
API側の「1件」と、ユーザーが思う「1件」は一致しない。
- 商品APIなら「色違い・サイズ違い」が別レコードかもしれない
- 求人APIなら「同じ求人の掲載更新」が別レコードかもしれない
- イベントAPIなら「同じ公演の別日程」が別レコードかもしれない
作る前に「このAPIは何を1件として数えているか」を確認しておくべきでした。
canonicalでまとめる、をやめた理由
重複ページの定番の対処は canonical(カノニカル)タグです。
<link rel="canonical" href="https://example.com/正規のURL">
これは「このページの正式版はこっちです」と検索エンジンに伝えるタグで、複数の似たページがあるときに評価を1枚に集約できます。
私も最初はこれで21次→22次に寄せようとしました。でもやめました。
理由は、どの回もまだ募集中だったからです。
21次締切 → 2027年3月23日まで(あと235日)
22次締切 → 2027年6月30日まで(あと334日)
締切が違うだけで、どちらも実際に申請できる別の機会です。ここで片方を「正式版じゃない」と宣言してしまうと、
- 3月締切を探している人が22次のページに送られる
- 逆に「間に合わないから次の回で」という人が21次に固定される
という、ユーザーにとって間違った案内になります。
canonicalは「実質同じページ」に使うものです。見た目が似ているだけで中身の意味が違うものに使ってはいけない、というのが今回の学びでした。
もし締切が過ぎた回であれば、話は別です。申請できない回のページを検索に出しても仕方ないので、そのときは noindex(検索結果に出さない指定)が妥当だと思います。今回はたまたま全部まだ生きていました。
やったこと:消さずに繋ぐ
結局こうしました。詳細ページに「同じ制度の別の公募回」というセクションを足して、お互いにリンクする。
実装は、制度名から「(21次締切)」のような回の表記を取り除いて、同じ名前のものを探すだけです。
/** 「【経済産業省】ものづくり…補助金(21次締切)」→「ものづくり…補助金」 */
export function baseProgramName(name: string): string {
return name
.replace(/^【[^】]*】/, "") // 実施機関の接頭辞を落とす
.replace(/[((][^))]*[))]\s*$/, "") // 末尾の(21次締切)を落とす
.trim();
}
/** 末尾が「(21次締切)」「(第3回)」のように公募回を表しているか */
export function hasRoundSuffix(name: string): boolean {
return /[((][^))]*[次回期][^))]*[))]\s*$/.test(name);
}
そして探す部分です。
export async function findOtherRounds(cur: { id: string; name: string }) {
// ★公募回の表記が無い制度では、そもそもAPIを叩かない
if (!hasRoundSuffix(cur.name)) return [];
const base = baseProgramName(cur.name);
if (base.length < 2) return [];
const { items } = await searchSubsidies({ keyword: base, acceptance: "1" });
return items
.filter((s) => s.id !== cur.id && baseProgramName(s.name) === base)
.sort((a, b) => a.days - b.days) // 締切が近い順
.slice(0, 5);
}
ここで気をつけたこと
最初の1行の early return が地味に大事です。
これが無いと、すべての詳細ページで毎回1回ずつ余分にAPIを叩くことになります。公募回が分かれている制度なんて全体の一部なのに、全ページで検索を走らせるのは無駄ですし、公開APIに余計な負荷をかけます。
「公募回の表記があるものだけ調べる」という条件を先に置くだけで、大半のページでは追加のリクエストがゼロになります。
実際に本番で確認しました。
- ものづくり補助金(21次)→ 19次・20次・22次の3件が表示される ✅
- グリーンボンドの補助金(回の表記なし)→ セクション自体が出ない ✅
まとめ
外部APIをそのままページにするときは、この2つを先に確認するといいと思います。
- APIは何を「1件」として数えているか。 ユーザーが思う1件と一致しているか
- 一致していないとき、まとめるべきか/分けたまま繋ぐべきか。ユーザーにとってそれぞれが別の意味を持つなら、まとめてはいけない
「重複=悪。canonicalで潰す」と反射的に考えていたのですが、重複に見えて重複でないものがあると分かったのが一番の収穫でした。
なお、そもそも「キーワード×ページ」で見なければこれには気づけませんでした。Search Consoleをキーワード別・ページ別で別々に見ていると、自分のページ同士が同じ検索で並んでいることは見えません。定期的に組み合わせで見ることをおすすめします。