記事を増やしていると、新しい記事から過去の記事へはリンクを張っていても、過去の記事から新しい記事への案内は後回しになりがちです。

当サイトのように記事が2,000本を超えるブログでは、関連する過去記事をすべて思い出すだけでも手間がかかります。そこで役立つのが、リンク元の記事数を集計し、AIに追加候補を絞ってもらう方法です。

この記事では、WordPressの公開投稿から本文内のリンクを調べ、リンク元が少ない記事を見つける手順を紹介します。集計結果をそのまま「SEOの問題」と決めつけず、読者に役立つリンクを追加するところまで進めます。


WordPressで記事販売ができる「YOMERU」
記事を、そのまま収益に。
WordPressサイトで記事の有料販売ができる
無料のプラグイン「YOMERU」。

調べるのは「ほかの投稿本文からのリンク」

今回確認するのは、公開中の投稿が、ほかの公開投稿の本文内から何記事にリンクされているかです。同じ記事から同じページへ3回リンクしていても、リンク元記事数は1件と数えます。自分自身へのリンクは含めません。

トップページ、カテゴリー一覧、メニュー、サイドバーなどの導線は調査対象外です。したがって、リンク元が0件でも「サイト内のどこからもたどれない」とは限りません。

0件という結果は、関係のある記事から案内を追加できないか検討するための手がかりです。単独で読んで完結する記事など、無理にリンクを増やす必要がないものもあります。

Googleは、リンクをページの発見や関連性の理解に利用すると説明しています。ただし、リンクを増やせば順位が上がるという保証はありません。まずは「この先を知りたい読者が、次の記事へ進めるか」を基準に考えます。

参考:Googleのリンクに関するベストプラクティス

公開投稿のリンク元記事数をCSVにする

以下のコードは、WordPressのREST APIから公開投稿を読み取り、本文のHTMLに含まれるリンクタグを集計します。記事や設定を書き換える処理はなく、ログイン情報も送信しません。

ただし、すべてのWordPressサイトで同じ結果になるわけではありません。実行前に、対象と制限を確認してください。

  • 対象はREST APIで取得できる公開中の「投稿」。固定ページやカスタム投稿タイプは含みません。
  • APIが返す本文HTMLを調べます。ショートコード等がAPI上でもリンクに展開される場合は、そのリンクも含まれます。
  • テーマの画面にだけ出る関連記事や、JavaScriptで後から追加されるリンクは拾えません。
  • パスワード保護された投稿の本文はリンク元として調べられません。CSVの備考に示します。
  • 相対URLにも対応しますが、旧URLからの転送、別ドメイン、HTTPとHTTPSの違い、短縮URLの別名までは照合しません。
この集計は、公開画面全体を巡回するSEO監査ではありません。APIに独自の本文制限があるサイトや、記事が非常に多いサイトでは、管理者・開発担当者と方法を相談してください。APIが制限されていても、集計のためだけに公開サイトのセキュリティ設定を解除する必要はありません。

1. 自分のサイトの公開ページを開く

コードの内容を確認できる人向けの方法です。先にローカル環境や検証サイトで動作を確かめてください。

Chromeで自分が管理するWordPressサイトの公開ページを開き、右クリックの「検証」から「Console」を選びます。管理画面で実行する必要はありません。

2. 集計コードを実行する

次のコードを確認し、Consoleに貼り付けて実行します。貼り付けを止める警告が出た場合は、その場で手を止め、コードを確認できる担当者に相談してください。警告の解除を前提とした手順にはしていません。

(async () => {
  try {
    const root = document.querySelector('link[rel="https://api.w.org/"]')?.href;
    if (!root) throw new Error('REST APIの案内が見つかりません。公開ページで実行してください。');
    const endpoint = new URL(root);
    if (endpoint.origin !== location.origin) throw new Error('別ドメインのAPIには対応していません。');
    if (endpoint.searchParams.has('rest_route')) {
      endpoint.searchParams.set('rest_route', '/wp/v2/posts');
    } else {
      endpoint.pathname = endpoint.pathname.replace(/\/$/, '') + '/wp/v2/posts';
    }
    const posts = [];
    let total, pages;
    for (let page = 1; ; page++) {
      const url = new URL(endpoint);
      Object.entries({ per_page: 100, page, status: 'publish', context: 'view',
        orderby: 'id', order: 'asc', _fields: 'id,link,date,title,content' })
        .forEach(([k, v]) => url.searchParams.set(k, v));
      const res = await fetch(url, {
        credentials: 'omit', signal: AbortSignal.timeout(30000)
      });
      if (!res.ok) throw new Error(`${page}ページ目の取得に失敗しました(HTTP ${res.status})。`);
      const n = res.headers.get('X-WP-Total');
      const m = res.headers.get('X-WP-TotalPages');
      if (n === null || m === null) throw new Error('総件数を確認できないため中止しました。');
      const t = Number(n), p = Number(m);
      if (!Number.isInteger(t) || t < 0 || !Number.isInteger(p) || p < 0) {
        throw new Error('総件数の形式が不正です。');
      }
      if (page === 1) { total = t; pages = p; }
      if (t !== total || p !== pages) throw new Error('取得中に投稿件数が変わりました。再実行してください。');
      const items = await res.json();
      if (!Array.isArray(items)) throw new Error('投稿一覧を取得できませんでした。');
      posts.push(...items);
      console.log(`取得中:${posts.length} / ${total}`);
      if (page >= pages) break;
    }
    if (posts.length !== total || new Set(posts.map(p => p.id)).size !== total) {
      throw new Error('取得件数または投稿IDに不整合があるため中止しました。');
    }
    if (!total) throw new Error('取得できる公開投稿はありません。');
    const parser = new DOMParser();
    const key = (href, base) => {
      const u = new URL(href, base);
      if (!/^https?:$/.test(u.protocol)) return null;
      u.hash = '';
      for (const name of [...u.searchParams.keys()]) {
        if (/^utm_/i.test(name) || /^(fbclid|gclid)$/i.test(name)) u.searchParams.delete(name);
      }
      u.searchParams.sort();
      u.pathname = u.pathname.replace(/\/$/, '') || '/';
      return u.href;
    };
    const targets = new Map(posts.map(p => [key(p.link), p.id]));
    if (targets.size !== posts.length) throw new Error('投稿URLが重複しているため中止しました。');
    const incoming = new Map(posts.map(p => [p.id, new Set()]));
    let protectedCount = 0;
    for (const post of posts) {
      if (post.content?.protected) { protectedCount++; continue; }
      if (typeof post.content?.rendered !== 'string') throw new Error('本文を取得できない投稿があります。');
      const doc = parser.parseFromString(post.content.rendered, 'text/html');
      for (const anchor of doc.querySelectorAll('a[href]')) {
        // コード例として表示されたHTMLは集計しない
        if (anchor.closest('pre,code,script,style,template')) continue;
        const href = anchor.getAttribute('href').trim();
        if (!href) continue;
        let id;
        try { id = targets.get(key(href, post.link)); } catch { continue; }
        if (id !== undefined && id !== post.id) incoming.get(id).add(post.id);
      }
    }
    const byId = new Map(posts.map(p => [p.id, p]));
    const rows = posts.map(p => {
      const sources = [...incoming.get(p.id)];
      const title = parser.parseFromString(p.title.rendered, 'text/html').body.textContent;
      return [title, p.link, p.date.slice(0, 10), sources.length,
        sources.slice(0, 5).map(id => byId.get(id).link).join(' | '),
        p.content.protected ? '本文は集計対象外(保護あり)' : ''];
    });
    const cell = value => {
      let s = String(value);
      if (/^[\s]*[=+@-]/.test(s)) s = "'" + s;
      return '"' + s.replace(/"/g, '""') + '"';
    };
    const csv = '\uFEFF' + [['タイトル', 'URL', '公開日', 'リンク元記事数',
      'リンク元URL(最大5件)', '備考'], ...rows]
      .map(row => row.map(cell).join(',')).join('\r\n');
    const blobURL = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
    const a = document.createElement('a');
    a.href = blobURL;
    a.download = 'internal-links-' + new Date().toISOString().slice(0, 10) + '.csv';
    a.click();
    setTimeout(() => URL.revokeObjectURL(blobURL), 60000);
    console.log(`集計完了:${total}記事/リンク元0件:${rows.filter(r => r[3] === 0).length}記事/本文保護で除外:${protectedCount}記事`);
  } catch (error) {
    console.error('集計中止:' + error.message);
  }
})();

実行が完了すると、日付付きの「internal-links-YYYY-MM-DD.csv」が保存され、Consoleに取得記事数とリンク元0件の記事数が表示されます。ダウンロードが見当たらないときは、ブラウザのダウンロード一覧も確認してください。

コードは100件ずつ順番に取得し、REST APIが返す総件数と照合します。途中の取得失敗、件数の変化、投稿IDの重複を検出した場合は、CSVを出力せず中止します。「集計中止」と表示されたら、その結果を全記事の調査結果として使わないでください。

検証用の合成データ101件を使ったローカルテストでは、ページ分割、相対URL、同一記事からの重複リンク、取得エラー時の中止を確認しました。実際のサイトでは、次の手順でCSVと本文を照合してください。

参考:WordPress REST APIの投稿仕様/ページ分割と総件数の仕様

3. CSVの数字と実際のリンクを照合する

CSVには、次の項目が入ります。

項目 確認できること
タイトル・URL・公開日 点検する記事の情報
リンク元記事数 ほかの何記事の本文内からリンクされているか
リンク元URL(最大5件) 数えたリンクを実際に確認するための例
備考 本文のパスワード保護による集計対象外の表示

まず、自分がリンクを張ったことを覚えている記事を数本選びます。リンク元URLを開き、本文に該当リンクがあるか確かめてください。コード例や自動出力された枠のリンクが混ざっていないかも確認します。

リンクがあるのに0件になっている場合は、固定ページからのリンク、転送前のURL、APIでは展開されないショートコードなど、今回の調査範囲から外れていないか調べます。実際の表示と合わない場合は、数字を使う前に集計条件を見直します。

AIでリンクを追加する候補を絞る

CSVと実際の表示が大きく食い違っていないことを確認したら、リンク元記事数が0件、または少ない記事から点検します。

全件を一度に直す必要はありません。内容が新しく役に立つ記事、読者の疑問を詳しく解決できる記事、問い合わせや商品紹介につながる記事など、案内を増やしたいものから選びます。情報が古い記事は、先に本文の更新が必要です。

1. タイトル一覧から関連する記事を探す

ClaudeやChatGPTなど、利用しているAIにCSVを渡し、次のように依頼します。公開記事の一覧だけで候補を探せるため、この段階で管理画面のログイン情報や非公開原稿を渡す必要はありません。

添付CSVは、ブログの公開投稿の一覧です。
私が指定した記事について、リンク元にできそうな記事を一覧から最大3本選んでください。

・「リンク元記事数」は、別の投稿本文内からリンクされている記事数です。
・タイトルから判断できる関連性と、読者にとって読む意味を説明してください。
・タイトルだけでは分からないことは推測で断定しないでください。
・適切な候補がなければ「候補なし」としてください。
・一覧にない記事やURLを作らないでください。
・リンク先の記事とリンク元の候補を区別して、表で出してください。

たとえば、予約投稿の不具合を調べている読者には、WP-Cronの仕組みを説明する記事が役立つかもしれません。一方、同じWordPressの記事でも、テーマのデザイン比較へつなぐ必然性は薄いでしょう。

同じキーワードが入っているかより、次の疑問に答えられるかで候補を判断します。

2. 本文を読んで挿入位置を決める

候補が見つかったら、リンク元とリンク先の本文を確認します。AIに手伝ってもらう場合は、その2記事の公開本文を渡して、次のように頼みます。

次の2記事の本文を読み、リンク元の記事にリンク先を案内する価値があるか判断してください。

リンク元:[タイトル・URL・本文]
リンク先:[タイトル・URL・本文]

関連性がある場合は、次の項目を示してください。
1. 読者のどんな疑問に役立つか
2. リンクを追加する位置(元の文章を短く引用)
3. 追加する1〜2文と、内容を表すアンカーテキスト

すでに同じ記事へのリンクがある場合は、その位置を示してください。
無理につなぐ必要がある場合は、追加不要としてください。
保存・公開や、記事の直接編集は行わないでください。

「こちら」だけをリンクにするより、「WordPressの予約投稿が公開されない原因と対処法」のように、リンク先の内容が分かる言葉を使います。周囲の説明と重複する場合は短く整え、本文の読みやすさを保ちます。

3. 必要なリンクだけ追加する

提案を採用する前に、リンク先の情報が現在も正しいか、同じ説明が重複していないか、URLに誤りがないかを確かめます。

すべての記事を0件から脱出させることや、1記事に必ず3本リンクを足すことを目標にする必要はありません。読者が自然に読み進められる箇所にだけ追加します。

保存後はプレビューや公開画面からリンクをクリックし、意図したページが開くことも確認してください。

追加した後に確認すること

リンクを足したら、同じ条件で再集計し、意図したリンク元がCSVに反映されたか確認します。これで分かるのは、今回の調査範囲で導線が増えたことです。

効果を見る際は、アクセス解析での関連記事への移動や、Search Consoleの表示回数・クリック数なども確認します。検索結果はさまざまな要因で変わるため、変化をすべて内部リンクの効果と決めつけないようにします。

また、新しく公開した記事だから一律に検索順位で優遇されるわけではありません。Googleの鮮度に関する評価は、最新情報が求められる検索などに応じて働く仕組みです。新旧にかかわらず、本文の内容と関連する導線を整えましょう。

参考:Google検索ランキングシステムのガイド

新しい記事を公開するときに見直す

公開時には、新しい記事から過去記事へリンクするだけでなく、過去記事から新しい記事へ案内できる場所がないかも確認すると、後からの点検が楽になります。

毎回すべての過去記事を読み直す必要はありません。関連する記事を検索し、読者の疑問に答える箇所があれば更新します。投稿の編集画面で過去記事の候補を探す方法もあります。

AIに任せられるブログ運営の作業は、内部リンクのほかにもあります。こちらでまとめています。

まとめ

内部リンクの点検では、まず何を数えているかを揃えることが大切です。今回の方法では、REST APIが返す公開投稿の本文HTMLから、ほかの投稿へ向けたリンクを集計します。

リンク元が少ない記事を見つけたら、AIで候補を絞り、本文を読んで追加の必要性を判断します。数字と実際の表示を照合し、読者が次に知りたいことへ進める導線を少しずつ整えていきましょう。

テンプレートの領域を超えた
ウェブサイトがつくれる
WordPressテーマ「REHUB」
WordPressテーマ「REHUB」
クチコミ評価サイトがつくれる高機能テーマ。
WordPressテーマ「RIKYU」
WordPressテーマ「RIKYU」
あらゆるジャンルのネットショップを構築。
WordPressテーマ「GAIA」
WordPressテーマ「GAIA」
高機能なイベントカレンダーを搭載。
WordPressテーマ「HORIZON」
WordPressテーマ「HORIZON」
横スクロールデザインの個性派ギャラリー。