内部リンク管理をpillar構造で自前実装した設計と全記録

ブログ記事が30本を超えると、「この記事、前にも書いた気がする」「この記事、誰もリンクしてない」 が発生し始める。100本を超えると、全体像の把握はほぼ不可能になる。

記事管理の問題は大きく2つだ。

  1. トピックの網羅性 / 内部リンク: どの記事が孤立していて、どのテーマが未カバーか
  2. 鮮度: どの記事が古くなっていて、リライトすべきか

商用SEOツール(Ahrefs、SEMrushなど)はこれを解決する機能を持っている。ただし、まともに使うには月額$100以上かかり、個人ブログには過剰だ。content-factoryではこの2つをLLMも外部APIも使わずルールベースで自前実装した。

この記事はその設計と実装の記録だ。pillar + satelliteのクラスタ構造、freshnessの閾値選定、日次スキャン、UIからの手動トリガーまで、実際のコードとともに振り返る。

クラスタ構造: pillar + satellite

中央のハブから複数のノードが放射状に接続されたネットワーク構造図

SEO界隈で有名なpillar + satellite(hub-and-spoke)モデルをそのまま採用した。

  • Pillar(ピラー): そのトピックの網羅的ガイド記事。5000字以上、トピック全体を俯瞰する
  • Satellite(サテライト): 個別サブトピックの深掘り記事。短〜中尺でひとつの詳細にフォーカスする
  • 内部リンク: pillar ↔ satelliteの相互リンクでSEOシグナルを強化する

具体的な構成はこうなる:

Pillar: 「個人ブログを AI 自動化する全体ガイド」
├── Satellite: 「LLM でキーワードリサーチする方法」
├── Satellite: 「WordPress への自動投稿セットアップ」
├── Satellite: 「画像生成 API のコスト比較」
└── Satellite: 「記事構成を LLM で提案させるプロンプト」

DB設計

SQLiteで最小限の2テーブルを用意した。

CREATE TABLE content_clusters (
  id          INTEGER PRIMARY KEY AUTOINCREMENT,
  name        TEXT NOT NULL,                -- 例: "AI ブログ自動化"
  description TEXT DEFAULT '',
  created_at  TEXT NOT NULL DEFAULT (datetime('now')),
  updated_at  TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE cluster_articles (
  id          INTEGER PRIMARY KEY AUTOINCREMENT,
  cluster_id  INTEGER NOT NULL REFERENCES content_clusters(id) ON DELETE CASCADE,
  article_id  INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
  role        TEXT NOT NULL CHECK(role IN ('pillar', 'satellite')),
  created_at  TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE UNIQUE INDEX uniq_cluster_articles_cluster_article
  ON cluster_articles(cluster_id, article_id);

設計のポイントは3つある。

  • 1つのクラスタにpillarとsatelliteを複数紐づけられる
  • 記事は複数クラスタに所属可能(ただしroleはclusterごとに1つ)
  • 同じ (cluster_id, article_id) の重複はUNIQUEで防ぐ

このシンプルな構成で、pillar ↔ satelliteの関係を十分に表現できる。

Repoの代表的なクエリ

クラスタメンバーを取得するクエリ:

async findMembers(clusterId: number): Promise<ClusterMember[]> {
  const rows = await this.db
    .select({
      articleId: articles.id,
      role: clusterArticles.role,
      title: articles.title,
      status: articles.status,
      publishedAt: articles.publishedAt,
    })
    .from(clusterArticles)
    .innerJoin(articles, eq(articles.id, clusterArticles.articleId))
    .where(eq(clusterArticles.clusterId, clusterId));

  // pillar を先頭にソート
  return rows.sort((a, b) => (a.role === "pillar" ? -1 : 1));
}

「pillarを先頭、satelliteを後ろ」の並びで返す。UIでそのまま表示できるので、フロント側で並び替えが不要になる。

フレッシュネス評価

コンテンツの新鮮度が時間経過とともに3段階で変化するプロセスを表すタイムラインのイメージ

3段階のステータス

公開からの経過日数で3つの状態に分類する。

ステータス 経過日数 スコア 推奨アクション
fresh 0〜90日 80〜100 none(何もしない)
stale 91〜365日 40〜80 review(見直し)
outdated 366日〜 10〜40 rewrite(リライト)

閾値を決めた理由は以下のとおりだ。

  • 90日: Googleの鮮度シグナル(QDF)はクエリ種別によって変わり、固定閾値は存在しない。ただしSEO実務界隈では「3ヶ月超えたら見直し検討」という目安がよく使われるため、それを採用した
  • 365日: 1年超で「かなり古い」というのは実務界隈の共通認識だ。AI・プログラミング系は半年で状況が変わるので、技術ブログでは短めに見積もってもよい

スコア計算

連続値でスコアを算出し、ソートに対応させた。

private evaluate(publishedAt: string | null) {
  if (!publishedAt) {
    return { score: 100, status: "fresh", action: "none",
             days: 0, reason: "未公開記事のため評価対象外" };
  }

  const days = Math.floor(
    (Date.now() - new Date(publishedAt).getTime()) / (1000 * 60 * 60 * 24)
  );

  if (days <= 90) {
    // fresh: 100 → 80 へ線形に低下
    return {
      score: Math.round(100 - (days / 90) * 20),
      status: "fresh",
      action: "none",
      days,
      reason: `公開から ${days} 日(90日以内)`,
    };
  }

  if (days <= 365) {
    // stale: 80 → 40 へ線形に低下
    const ratio = (days - 90) / (365 - 90);
    return {
      score: Math.round(80 - ratio * 40),
      status: "stale",
      action: "review",
      days,
      reason: `公開から ${days} 日(90〜365日)。内容の見直しを推奨`,
    };
  }

  // outdated: 40 → 10 へ線形に低下(下限 10)
  const ratio = Math.min(1, (days - 365) / 365);
  return {
    score: Math.max(10, Math.round(40 - ratio * 30)),
    status: "outdated",
    action: "rewrite",
    days,
    reason: `公開から ${days} 日(365日超)。リライトを推奨`,
  };
}

設計上のこだわりを3点押さえておこう。

  • 決定論的: LLMを使わず経過日数だけで計算する。同じ記事を何度評価しても同じ値になる
  • 連続値: 91日目と90日目でスコアが急変しないよう線形補間している
  • 下限10: どんなに古くてもゼロにしない。リスト最下位でも存在感を残す

日次スキャンと手動トリガー

スケジューラ

node-cronで毎日1回スキャンを走らせる。

import { CronJob } from "cron";

export function registerFreshnessScanTask(
  scheduler: Scheduler,
  freshnessService: FreshnessService,
  logger: Logger,
) {
  scheduler.register({
    name: "freshness_scan",
    cronExpression: "0 3 * * *",  // 毎日 3:00 AM
    handler: async () => {
      logger.info("フレッシュネススキャン開始");
      const result = await freshnessService.scanAll();
      logger.info({ result }, "フレッシュネススキャン完了");
    },
  });
}

深夜3時にしたのは、LLM生成や画像処理といった重い処理と時間帯が被らないようにするためだ。

scanAll() の実装

公開済み記事を最大1000件取得し、1本ずつ評価する。

async scanAll(): Promise<FreshnessScanResult> {
  const { items } = await this.articleRepo.findMany({
    page: 1, perPage: 1000, status: "published",
  });

  let fresh = 0, stale = 0, outdated = 0;

  for (const article of items) {
    try {
      const evaluation = this.evaluate(article.publishedAt);
      await this.freshnessRepo.upsert({
        articleId: article.id,
        score: evaluation.score,
        status: evaluation.status,
        daysSincePublished: evaluation.days,
        recommendedAction: evaluation.action,
        reason: evaluation.reason,
      });
      // カウント更新
    } catch (error) {
      // 個別の失敗は次の記事に進む(全体を止めない)
      this.logger.error({ err: error, articleId: article.id }, "評価失敗");
    }
  }

  return { scanned: items.length, fresh, stale, outdated };
}

1記事でエラーが出ても残りの評価を止めない設計にした。部分失敗を許容することで、スキャン全体の信頼性が上がる。

UIからの手動スキャン

「フレッシュネス」タブに「今すぐスキャン」ボタンを置き、結果をscore昇順でリスト表示する。

[🔴 outdated]  2024年版 Next.js セットアップガイド  score: 15  387日前
[🔴 outdated]  Python 3.10 新機能まとめ           score: 18  401日前
[🟡 stale]     Hono で REST API を作る            score: 55  214日前
[🟡 stale]     WordPress テーマ比較                score: 62  156日前
[🟢 fresh]     Claude Code の使い方               score: 95  12日前
...

上から順にリライトすればよい、という直感的なUXだ。recommendedActionに従って「リライトする」ボタンを押せば、AIリライトフローに直接流せる。

LLMを使わない利点

高速で安定したシステム処理

このシステムは意図的にLLMを使わない。その理由は明確だ。

使わないことで得られるもの:

  1. ゼロコスト: API呼び出しなし。日次スキャンも完全無料
  2. 決定論的: 同じ記事は同じスコア。テストが容易になる
  3. 高速: 1000記事のスキャンが1秒以下で完了する
  4. 障害耐性: LLM APIが落ちていても動き続ける

代わりに失うもの:

  • コンテンツ自体が古い情報かどうかの判定(経過日数しか見ない)
  • 読者エンゲージメントを反映したリライト優先度

「何日経ったか」でまず絞り込み、その後ユーザーが手動で判断する2段階ワークフローなら、LLMなしで十分実用的だ。

次の進化

複数の分析メトリクスを表示するダッシュボード画面による、段階的な成長指標の可視化

現時点ではルールベースで止めているが、拡張の候補はすでにある。

  • Analytics連携: Google AnalyticsのPV・滞在時間が低い記事を優先してリライト対象に上げる
  • LLMによる内容鮮度判定: 「この記事の情報は最新か?」を年1回だけLLMでチェックする
  • サイト内検索ログ分析: 検索されたが記事がない、またはクリックされていないクエリから「カバーされていないトピック」を抽出する
  • pillarの推奨: satellite5本以上のテーマにpillar記事がない場合にサジェストする

これらはLLMが必要になる。とはいえ、まずルールベースで「古い記事リスト」が出るだけで運用は格段に楽になる。段階的に進化させる方針だ。

まとめ

ルールベースのコンテンツ鮮度評価と自動スケジューリング

  • pillar + satellite のcluster構造をSQLiteの2テーブルで表現した
  • 鮮度は公開日からの経過日数だけで評価(90日・365日閾値、連続スコア)
  • 日次スケジューラでscan → 低スコアから順にリライト候補が浮上する
  • LLMを使わないことでゼロコスト・決定論・高速を確保した
  • 後からLLMベースの判定やAnalytics連携を足す余地を残している

個人ブログで100記事を超えて管理負荷を感じているなら、まずルールベースで十分だ。SaaS SEOツールに月$100以上払う前に、30行ほどの経過日数計算とcron1本で「何を直すべきか」リストが作れる。

古典的なSEOの課題ほど、AIやLLMを持ち込む前に素朴なdecision ruleで80点を取っておくと、その後の改善が楽になる。

コメント