0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

壊れても止まらないスクレイピングの実装 ― TTLキャッシュ・多段フォールバック・CI週次更新

0
Last updated at Posted at 2026-08-16

対象読者と動作環境

  • 公式APIが無いページをHTMLスクレイピングで取得する処理を、業務ツールやCLIに組み込んでいる方
  • スクレイピングが壊れたときにアプリを落とさないためのキャッシュ・フォールバック設計を実装例で見たい方
  • GitHub Actionsで定期実行するスクレイパーに、失敗検知とPR自動作成を組み込みたい方
  • 動作確認環境: macOS / Node.js v24(package.jsonengines.node>=24.0.0)/ TypeScript ^5.8.3 / @tanishi-z/jin 0.2.0 / Ollama

題材は将棋の駒をモチーフにしたローカルLLM専用マルチエージェントCLI「Jin」(npm: @tanishi-z/jin / GitHub: Tanishi-z/Jin)です。0.1.1時点では ollama.com のモデル一覧ページを正規表現でスクレイピングして推奨モデルを提示していましたが、ollama.com側のHTML構造が変わり、このスクレイピングが機能停止していました。0.2.0ではこの障害対応として、パーサの分離・TTLキャッシュ・4段フォールバック・手動更新コマンド・GitHub Actionsでの週次自動更新・モデルカタログのコード生成化・strengthベース推奨の追加を行っています。この記事はコード引用も含めてすべて実際にリポジトリ(main ブランチ、タグ v0.2.0 = コミット 0a15328)から読んだ実装をそのまま追います。「なぜこの設計にしたか」は末尾のZenn版で扱っているため、ここでは繰り返しません。

全体構成

責務ごとに次のようにファイルを分割しています。

ファイル 役割
src/system/ollamaScrape.ts ollama.com/search のHTMLパーサ。実行時とCI生成スクリプトの両方が共有する
src/system/modelCache.ts ~/.jin/model-cache.json へのTTL付きキャッシュ読み書き
src/system/ollamaRegistry.ts 4段フォールバックのオーケストレーション(fetchOllamaModels
src/system/modelCatalog.ts スクレイプ結果をRAM要件でフィルタし ModelRecommendation へ変換
src/system/modelMeta.ts 手書きの日本語説明・強み分類の上書きデータ MODEL_META
src/system/modelCatalog.generated.ts CIが生成する内蔵スナップショット(最終フォールバック用)
scripts/updateModelCatalog.ts CIから呼ばれる、内蔵スナップショットの再生成スクリプト
.github/workflows/model-catalog.yml 週次cronでの自動更新ワークフロー
src/roles/modelRecommendations.ts 駒(役割)ごとのモデル推奨。keyword指名 + strengthベースの安全網

ollamaScrape.tsmodelCache.ts の分離は副作用の種類で切られています。パーサはHTML文字列を受け取って構造体を返すだけの純粋関数群、キャッシュは fs への読み書きだけを担当します。以降、この並び順でコードを見ていきます。

新HTML構造に対応したパーサの実装(ollamaScrape.ts)

assertHtmlShapeによるセレクタ検査

パーサの入口には、抽出処理より先にHTML構造の前提が崩れていないかを検査する関数があります。

// src/system/ollamaScrape.ts:49-59
export function assertHtmlShape(html: string): void {
  const checks: Array<[string, RegExp]> = [
    ['サイズタグ (bg-[#ddf4ff])', /bg-\[#ddf4ff\]/],
    ['能力タグ (bg-indigo-50)', /bg-indigo-50/],
    ['Pullsラベル (&nbsp;Pulls)', /&nbsp;Pulls/],
  ];
  const missing = checks.filter(([, re]) => !re.test(html)).map(([label]) => label);
  if (missing.length > 0) {
    throw new Error(`ollama.com のHTML構造が変化した可能性があります。見つからないセレクタ: ${missing.join(', ')}`);
  }
}

サイズタグ・能力タグ・Pullsラベルという、抽出処理が依存する3つのCSSクラス/文字列が1つでもHTML全体から見つからなければ、抽出処理に入る前に throw します。エラーメッセージに「どのセレクタが見つからなかったか」を含めているため、後述するCIのログにそのまま出力され、実際に構造が変わったときの原因特定を速くしています。

parseSizeTagの変則パターン対応

0.1.1時点では ^(\d+(?:\.\d+)?)([bm])$ という単一の正規表現でサイズタグを判定していましたが、これは 27b0.8b のような通常表記にしか一致しません。0.2.0では parseSizeTag として切り出し、3種類の表記を扱います。

// src/system/ollamaScrape.ts:24-43
export function parseSizeTag(tagRaw: string): number | null {
  const tag = tagRaw.toLowerCase();

  // 通常表記: '27b' '0.8b' '335m'
  const plain = tag.match(/^(\d+(?:\.\d+)?)([bm])$/);
  if (plain) {
    return plain[2] === 'm' ? Number(plain[1]) / 1000 : Number(plain[1]);
  }

  // Gemma系のエフェクティブパラメータ表記: 'e2b' 'e4b'
  const effective = tag.match(/^e(\d+(?:\.\d+)?)b$/);
  if (effective) return Number(effective[1]);

  // MoE表記: '8x7b' '16x17b'(RAM見積り用に総パラメータ数を採用。pull名にはタグ原文を使う)
  const moe = tag.match(/^(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)b$/);
  if (moe) return Number(moe[1]) * Number(moe[2]);

  // その他の記法(意図的に除外)
  return null;
}

Gemma系の e2b e4b(Effective Parameterの意)と、MoEモデルの 8x7b(8エキスパート×7B)のような表記が新たに追加されています。MoE表記はコメントのとおり、RAM見積り用には 8 × 7 = 56 のような総パラメータ数を採用し、実際にpullするモデル名(name:8x7b)にはタグ原文をそのまま使う設計です。どの表記にも一致しない場合は null を返し、呼び出し側でそのタグを黙って除外します。

parseSearchHtmlのチャンク分割とcloudタグ

モデル一覧の抽出本体です。0.1.1では <li x-test-model で分割していましたが、この属性自体がHTMLから無くなっていたため、新しいマーカーに切り替えています。

// src/system/ollamaScrape.ts:65-108
export function parseSearchHtml(html: string): ScrapedModel[] {
  assertHtmlShape(html);

  const chunks = html.split(/<li\s+class="flex items-baseline border-b border-neutral-200 py-6">/).slice(1);
  const models: ScrapedModel[] = [];

  for (const chunk of chunks) {
    // ユーザー名前空間モデル(/JXW67/TGAI_NB 等)は /library/ を持たないため除外
    const name = chunk.match(/href="\/library\/([a-z0-9._-]+)" class="group w-full"/)?.[1];
    if (!name) continue;

    const description = decodeEntities(
      chunk.match(/<p class="max-w-lg break-words[^"]*">\s*([^<]*?)\s*<\/p>/)?.[1] ?? '',
    );

    // サイズタグ: 必ずクラス bg-[#ddf4ff] で特定する。
    // クラスを見ずに全spanを走査すると Pulls の '5.7M' が '5.7m' と誤認される。
    const sizesB: number[] = [];
    const sizeTags: string[] = [];
    for (const m of chunk.matchAll(/<span[^>]*bg-\[#ddf4ff\][^>]*>\s*([^<]+?)\s*<\/span>/g)) {
      const tag = m[1].toLowerCase();
      const value = parseSizeTag(tag);
      if (value === null) continue;
      sizesB.push(value);
      sizeTags.push(tag);
    }
    // 昇順に整列(タグも同順を維持)
    const order = sizesB.map((_, i) => i).sort((a, b) => sizesB[a] - sizesB[b]);
    const sortedSizes = order.map((i) => sizesB[i]);
    const sortedTags  = order.map((i) => sizeTags[i]);

    const capabilities = [...chunk.matchAll(/<span[^>]*bg-indigo-50[^>]*>\s*([^<]+?)\s*<\/span>/g)]
      .map((m) => m[1].toLowerCase());

    const cloud = /<span[^>]*bg-cyan-50[^>]*>\s*cloud\s*<\/span>/.test(chunk);

    const pulls = chunk.match(/<span\s*>\s*([^<]+?)\s*<\/span>\s*<span class="hidden sm:flex">&nbsp;Pulls<\/span>/)?.[1] ?? '';

    models.push({ name, description, sizesB: sortedSizes, sizeTags: sortedTags, capabilities, pulls, cloud });
  }

  if (models.length === 0) throw new Error('モデル情報を抽出できませんでした');
  return models;
}

旧実装(x-test-* 属性)からの変更点は3つです。

1. チャンク分割の目印がテスト用属性からCSSクラスの並びに変わった。 <li class="flex items-baseline border-b border-neutral-200 py-6"> という、レイアウト用クラスをそのまま連結した文字列で split しています。テスト用フックより変わりやすいセレクタですが、現状はこれがモデル1件ごとの境界です。

2. hrefclass="group w-full" という条件を追加している。 コメントのとおり、ollama.com の検索結果には /JXW67/TGAI_NB のようなユーザー名前空間のモデルも混じるようになっており、これらは /library/ 配下のパスを持ちません。href="/library/..." にマッチしても、直後に class="group w-full" が続くものだけを公式ライブラリのモデルとして採用しています。

3. サイズタグの誤検出を防ぐため、クラス名 bg-[#ddf4ff] を必須条件にした。 コメントに明記されているとおり、クラスを見ずに <span> 全体を走査すると、Pull数の表記(5.7M)がサイズタグ(5.7m)と誤認されるケースがあったための対策です。同様に能力タグは bg-indigo-50、クラウド専用タグは bg-cyan-50 というクラス名で個別に判定しています。

cloud フラグと isUsableLocally の関係も変わっています。

// src/system/ollamaScrape.ts:131-138
export function isUsableLocally(m: ScrapedModel): boolean {
  if (m.capabilities.includes('embedding')) return false;
  if (/\bembed(ding)?\b/i.test(`${m.name} ${m.description}`)) return false;
  // クラウド専用モデル: cloudタグがあり、かつローカル実行可能なサイズが1件も無いもの。
  // cloudタグ単体は「ローカル不可」を意味しない(gemma4 等は cloud+サイズタグを両方持つ)。
  if (m.cloud && m.sizeTags.length === 0) return false;
  return true;
}

コメントにあるとおり、cloud タグが付いているだけではローカル実行不可と判定しません。gemma4 のように cloud タグとサイズタグ(e2b など)を両方持つモデルもあるため、「cloud タグがあり、かつサイズタグが1件も無い」場合だけをクラウド専用として除外しています。

パーサ自体は ollamaRegistry.ts(実行時)と scripts/updateModelCatalog.ts(CI生成)の両方から import されており、コード上は1箇所しかありません。冒頭のコメントにも「HTML構造が変わったときに両者が同時に壊れることで、CIの週次実行が先に気付ける関係を作っている」と書かれています。

TTLキャッシュの実装(modelCache.ts)

スクレイピング結果は ~/.jin/model-cache.json にTTL付きで保存します。ファイル全体は61行です。ファイル削除用の clearModelCache(キャッシュクリア用のユーティリティ)を除いた主要部分を掲載します。

// src/system/modelCache.ts
const CONFIG_DIR = path.join(os.homedir(), '.jin');
const CACHE_FILE = path.join(CONFIG_DIR, 'model-cache.json');

/** キャッシュのスキーマバージョン。互換性を壊す変更をした場合はこの数値を上げる */
const CACHE_VERSION = 1;

/** キャッシュの有効期限(24時間) */
export const CACHE_TTL_MS = 24 * 60 * 60 * 1000;

export type ModelCacheSource = 'featured' | 'full';

export interface ModelCache {
  version:   number;
  fetchedAt: string;
  source:    ModelCacheSource;
  models:    ScrapedModel[];
}

/** モデルキャッシュを読み込む。ファイルが無い・壊れている・バージョン不一致の場合は null */
export function loadModelCache(): ModelCache | null {
  try {
    const raw = fs.readFileSync(CACHE_FILE, 'utf-8');
    const parsed = JSON.parse(raw) as ModelCache;
    if (parsed.version !== CACHE_VERSION) return null;
    return parsed;
  } catch {
    return null;
  }
}

/** モデルキャッシュを保存する */
export function saveModelCache(models: ScrapedModel[], source: ModelCacheSource): void {
  const cache: ModelCache = {
    version:   CACHE_VERSION,
    fetchedAt: new Date().toISOString(),
    source,
    models,
  };
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
  fs.writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2), 'utf-8');
}

/** キャッシュが TTL 内かどうかを判定する */
export function isFresh(cache: ModelCache): boolean {
  const age = Date.now() - Date.parse(cache.fetchedAt);
  return Number.isFinite(age) && age < CACHE_TTL_MS;
}

loadModelCache は3系統のエラー(ファイル不在、JSONパース失敗、version 不一致)をすべて null に正規化して返します。versionCACHE_VERSION という定数1つで管理していて、ScrapedModel の型を変えるような互換性のない変更をした場合はこの数値を上げるだけで、旧バージョンのキャッシュファイルを持つユーザー環境でも安全に再取得へフォールバックさせられます。isFreshfetchedAt(ISO文字列)を Date.parse して現在時刻との差分を CACHE_TTL_MS(24時間)と比較するだけの単純な関数です。ModelCacheSource'featured' | 'full' の2値で、fetchOllamaModels が保存する自動取得は 'featured'、後述する jin model update の手動取得は 'full' を記録します(ただしこの記事の範囲では source フィールド自体を読んで挙動を分岐している箇所はなく、キャッシュの由来を記録する目的にとどまっています)。

多段フォールバックの実装(ollamaRegistry.ts)

0.1.1時点で251行あった ollamaRegistry.ts は、パーサ(ollamaScrape.ts)・キャッシュ(modelCache.ts)・推奨ロジック(modelCatalog.ts)を切り出した結果、44行のオーケストレーション層だけになりました。全文を掲載します。

// src/system/ollamaRegistry.ts
import type { SystemSpecs, ModelRecommendation } from './specs.js';
import { buildRecommendations, builtinRecommendations } from './modelCatalog.js';
import { fetchSearch } from './ollamaScrape.js';
import { loadModelCache, saveModelCache, isFresh } from './modelCache.js';
import { isDemoMode } from '../demo/state.js';

/** モデル情報の取得元。UI表示の文言切替に使う */
export type ModelSource = 'web' | 'cache' | 'stale-cache' | 'builtin';

/**
 * Ollama ライブラリから最新の注目モデルを取得し、スペックでフィルタして返す。
 * 取得順: 新鮮なキャッシュ → ウェブ取得(成功時キャッシュ更新) → 期限切れキャッシュ → 内蔵スナップショット。
 * デモモード・JIN_NO_NETWORK=1 時はネットワーク・キャッシュ書き込みを一切行わず内蔵スナップショットを返す。
 */
export async function fetchOllamaModels(
  specs: SystemSpecs,
): Promise<{ source: ModelSource; fetchedAt?: string; models: ModelRecommendation[] }> {
  if (isDemoMode() || process.env.JIN_NO_NETWORK === '1') {
    return { source: 'builtin', models: builtinRecommendations(specs) };
  }

  const cache = loadModelCache();
  if (cache && isFresh(cache)) {
    return { source: 'cache', fetchedAt: cache.fetchedAt, models: buildRecommendations(cache.models, specs) };
  }

  try {
    const scraped = await fetchSearch();
    saveModelCache(scraped, 'featured');
    const models = buildRecommendations(scraped, specs);
    if (models.length === 0) throw new Error('RAM要件を満たすモデルがありません');
    return { source: 'web', models };
  } catch {
    // ウェブ取得失敗時: 期限切れでもキャッシュがあればそれを使う
    if (cache) {
      const models = buildRecommendations(cache.models, specs);
      if (models.length > 0) {
        return { source: 'stale-cache', fetchedAt: cache.fetchedAt, models };
      }
    }
    // 最終フォールバック:内蔵スナップショット
    return { source: 'builtin', models: builtinRecommendations(specs) };
  }
}

デモモード・JIN_NO_NETWORK=1 のときはネットワークとキャッシュ読み書きを一切行わず即座に内蔵スナップショットを返す早期リターンが先頭にありますが、通常の実行フローは次の4段です。

  1. 新鮮なキャッシュisFresh(cache) が真)があれば、それをそのまま buildRecommendations に通して返す。ネットワークには触れない
  2. キャッシュが無い・期限切れの場合は fetchSearch() でウェブ取得を試み、成功したら saveModelCache でキャッシュを更新してから返す。フィルタ後のモデル数が0件だった場合も、throw して次の段に落ちる
  3. ウェブ取得が失敗(HTTPエラー・タイムアウト・assertHtmlShape の例外・0件による throw 含む)した場合、catch ブロックで期限切れキャッシュを拾い直す。RAMフィルタ後に1件でも残れば 'stale-cache' として返す
  4. 期限切れキャッシュも無い、またはフィルタ後0件だった場合は、modelCatalog.generated.ts 由来の内蔵スナップショット(builtinRecommendations)を返す

戻り値の source: ModelSource'web' | 'cache' | 'stale-cache' | 'builtin')は、呼び出し側の src/screens/localLLMSetup.ts でそのままUIの文言分岐に使われています。

// src/screens/localLLMSetup.ts:99-117
  const [installed, { source, fetchedAt, models: recommended }] = await Promise.all([
    listInstalledModels(),
    fetchOllamaModels(specs),
  ]);
  const sourceMessages: Record<typeof source, string> = {
    web:          isJa ? 'ウェブから最新モデル情報を取得しました' : 'Fetched latest models from web',
    cache:        isJa ? `キャッシュのモデル情報を使用します(${relativeTime(fetchedAt, isJa)})` : `Using cached model list (${relativeTime(fetchedAt, isJa)})`,
    'stale-cache': isJa ? 'オフライン:前回取得した情報を使用します' : 'Offline: using previously fetched list',
    builtin:      isJa ? 'オフライン:組み込みリストを使用します' : 'Offline: using built-in model list',
  };
  s3.stop(sourceMessages[source]);
  if (source === 'stale-cache' || source === 'builtin') {
    note(
      isJa
        ? '最新のモデル情報を取得するには `jin model update` を実行してください。'
        : 'Run `jin model update` to fetch the latest model information.',
      isJa ? 'ヒント' : 'Tip',
    );
  }

Record<typeof source, string> という型注釈のおかげで、ModelSource に新しい値を増やした場合はこのマップの更新漏れがコンパイルエラーになります。'stale-cache''builtin' のときだけ jin model update の実行を促す note を追加で出す、という分岐もここに集約されています。

生成データと手書きメタの分離(modelCatalog.generated.ts / modelMeta.ts)

内蔵スナップショットである modelCatalog.generated.ts は、ファイル冒頭のコメントで手編集を明示的に禁止しています。

// src/system/modelCatalog.generated.ts:1-21
/**
 * このファイルは scripts/updateModelCatalog.ts が生成します。手で編集しないでください。
 * GitHub Actions の週次 cron(.github/workflows/model-catalog.yml)が ollama.com の
 * featured + 検索結果から再生成し、差分があればPRを自動作成します。
 *
 * 生成日時: 2026-08-15T07:30:56.157Z
 *
 * 日本語の説明・強み分類の上書きは src/system/modelMeta.ts に書いてください。
 */
import type { ScrapedModel } from './catalogTypes.js';

export const MODEL_CATALOG: ScrapedModel[] = [
  {
    name: "codegemma",
    description: "CodeGemma is a collection of powerful, lightweight models that can perform a variety of coding tasks like fill-in-the-middle code completion, code generation, natural language understanding, mathematical reasoning, and instruction following.",
    sizesB: [2,7],
    sizeTags: ["2b","7b"],
    capabilities: [],
    pulls: "3.1M",
    cloud: false,
  },
  // ...(以下、取得できた全モデルが続く。この時点で670行)

内容は ScrapedModel[] そのもので、description はollama.com掲載の英語説明文がそのまま入っています。日本語化や「軽量」「バランス」といった強み分類の手動上書きは、対になる modelMeta.ts 側が担当します。こちらは逆に、CIが一切触らないことをコメントで明示しています。

// src/system/modelMeta.ts:3-14
/**
 * Ollama ライブラリに掲載されているモデルの補足メタデータ。
 * フェッチ結果にこのデータを重ねて ModelRecommendation を生成する。
 * 日本語説明と強み分類の上書きが主な役割(無いモデルはスクレイプ値+ヒューリスティック)。
 *
 * 手書き専用ファイル。CI(scripts/updateModelCatalog.ts)はこのファイルを一切変更しない。
 * 新しいモデル世代が出た際は、下部に追記する(旧世代のエントリは既にpull済みのユーザーの
 * 表示が壊れるため削除しない)。
 */
export const MODEL_META: Record<string, { description: string; requiredRamGB: number; label: string; strength?: ModelStrength }> = {
  // ── Llama ──
  'llama3.2':         { label: 'Llama 3.2 3B',       requiredRamGB: 4,  description: '軽量・高速。RAM 4GB 以上で動作 / Lightweight and fast, 4GB+ RAM', strength: 'light' },
  // ...(101行、Llama / Phi / Gemma / Qwen / DeepSeek / Mistral / Granite / GPT-OSS 等が続く)

両者を合成しているのが modelCatalog.tsbuildRecommendations です。優先順位は「タグ付き完全一致 → ベース名一致 → スクレイプ値そのもの」の3段階で、これは0.1.1時点から変わっていません。

// src/system/modelCatalog.ts:112-123
    // メタ上書き: タグ付き名 → ベース名(説明・強みのみ) → スクレイプ値
    const tagMeta  = MODEL_META[name];
    const baseMeta = MODEL_META[m.name];
    models.push({
      name,
      label:         tagMeta?.label ?? (selectedTag ? `${m.name} ${selectedTag.toUpperCase()}` : baseMeta?.label ?? m.name),
      requiredRamGB: tagMeta?.requiredRamGB ?? requiredRamGB,
      description:   tagMeta?.description ?? baseMeta?.description ?? m.description,
      strength:      tagMeta?.strength ?? baseMeta?.strength ?? classifyStrength(m, selectedSizeB),
      capabilities:  m.capabilities,
      pulls:         m.pulls || undefined,
    });

MODEL_META に無いモデルは、classifyStrengthsrc/system/modelCatalog.ts:25-38)が説明文から自動分類します。単語の含有ではなく用途を明言する言い回し(agentic coding coding[- ]focused など)だけをシグナルにしている点は0.1.1と同じ設計です。

// src/system/modelCatalog.ts:25-38
export function classifyStrength(m: ScrapedModel, selectedSizeB: number | null): ModelStrength {
  const name = m.name.toLowerCase();
  const desc = m.description.toLowerCase();

  if (/code|coder/.test(name)) return 'coding';
  if (/agentic coding|coding[- ]focused|code generation|for coding|coding &|model for coding/.test(desc)) return 'coding';

  if (/(^|[-_.])r1\b/.test(name) || /\bqwq\b/.test(name)) return 'reasoning';
  if (/reasoning[- ](model|specialized|focused)|built for [^.]*reasoning|strong reasoning/.test(desc)) return 'reasoning';

  if (selectedSizeB !== null && selectedSizeB <= 4) return 'light';
  if (selectedSizeB !== null && selectedSizeB >= 27) return 'large';
  return 'balanced';
}

CIでの週次自動更新の実装

modelCatalog.generated.ts を再生成するのが scripts/updateModelCatalog.ts です。fetchSearch() に加えて coder reasoning small の3クエリでも検索し、featuredだけでは痩せがちなコード特化・軽量帯を補います。

// scripts/updateModelCatalog.ts:20-53(以下、抜粋)
// featured だけではコード特化・軽量帯が痩せるため、検索クエリで補完する
const SUPPLEMENT_QUERIES = ['coder', 'reasoning', 'small'];

const MIN_TOTAL_MODELS = 15;
const MIN_LOCAL_MODELS = 5;

async function main(): Promise<void> {
  const dryRun = process.argv.includes('--dry-run');

  const results = await Promise.all([
    fetchSearch(),
    ...SUPPLEMENT_QUERIES.map((q) => fetchSearch(q)),
  ]);

  // ベース名でマージ(featured を優先。先勝ち)
  const merged = new Map<string, ScrapedModel>();
  for (const list of results) {
    for (const m of list) {
      if (!merged.has(m.name)) merged.set(m.name, m);
    }
  }

  const all = [...merged.values()].sort((a, b) => a.name.localeCompare(b.name));
  const localCount = all.filter(isUsableLocally).length;

  console.log(`取得件数: ${all.length} 件(うちローカル実行可: ${localCount} 件)`);

  if (all.length < MIN_TOTAL_MODELS || localCount < MIN_LOCAL_MODELS) {
    console.error(
      `健全性チェックに失敗しました(総数 >= ${MIN_TOTAL_MODELS} かつ ローカル実行可 >= ${MIN_LOCAL_MODELS} が必要)。\n` +
      'ollama.com のHTML構造が変わった可能性があります。src/system/ollamaScrape.ts のセレクタを確認してください。',
    );
    process.exit(1);
  }
  // ...(この後 renderFile(all) の結果を OUT_FILE に書き込んで main() は終わる)

Promise.all を使っているため、4本のクエリのうち1本でも fetchSearch が例外を投げると全体が失敗します(Promise.allSettled ではありません)。マージは Map のキー未登録時のみ set する「先勝ち」方式で、fetchSearch()(featured)を配列の先頭に置くことで featured の情報が補完クエリより優先されます。MIN_TOTAL_MODELS(15件)と MIN_LOCAL_MODELS(5件)という閾値を下回った場合は process.exit(1) でスクリプト自体を失敗させ、CIジョブを失敗扱いにします。

このスクリプトを呼び出すワークフローです。

# .github/workflows/model-catalog.yml
name: Update model catalog

on:
  schedule:
    - cron: '0 3 * * 1' # 毎週月曜 03:00 UTC
  workflow_dispatch:

permissions:
  contents: write
  pull-requests: write
  issues: write

concurrency:
  group: model-catalog
  cancel-in-progress: false

jobs:
  update:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Set up Node.js
        uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Update model catalog
        run: npx tsx scripts/updateModelCatalog.ts

      - name: Check types
        run: npm run check

      - name: Create pull request
        uses: peter-evans/create-pull-request@v7
        with:
          branch: chore/model-catalog
          title: 'chore: モデルカタログを更新'
          commit-message: 'chore: モデルカタログを自動更新'
          body: |
            ollama.com の featured / 検索結果からモデルカタログを自動再生成しました。
            日本語説明が必要な新モデルは `src/system/modelMeta.ts` に追記してください。

      - name: Notify on failure
        if: failure()
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          gh issue list --state open --search "モデルカタログの自動更新に失敗" --json number \
            | grep -q '"number"' || gh issue create \
              --title 'モデルカタログの自動更新に失敗しました(HTML構造の変更の可能性)' \
              --body 'scripts/updateModelCatalog.ts が失敗しました。ollama.com のHTML構造が変わった可能性があります。ワークフローログのセレクタ名を確認してください。'

ポイントは3つです。

毎週月曜03:00 UTCのcronに加えて workflow_dispatch で手動起動もできる。 HTML構造の変化に週次以内に気づきたい場合、GitHub上の「Run workflow」ボタンから即座に再実行できます。

カタログ再生成後に npm run check(型チェック)を挟んでからPRを作る。 updateModelCatalog.ts が生成する .ts ファイルは静的なオブジェクトリテラルの配列なので構文エラーの余地は小さいですが、ScrapedModel 型との不整合はここで検出できます。

peter-evans/create-pull-request@v7 は差分が無ければ何もしない。 モデル一覧に変化が無かった週は、コミットもPRも作られません。branch: chore/model-catalog を固定しているため、複数週にまたがって差分が無い場合は同じブランチが使い回されます。

失敗時の通知は gh issue list --search "..." --json number | grep -q '"number"' || gh issue create ... という1行に集約されています。gh issue list が該当タイトルを含むオープンなIssueの number フィールドを返すかどうかを grep -q で判定し、1件でも見つかれば(=終了コード0)|| の右側は実行されません。見つからなければ新しいIssueを作成します。cronが毎週失敗し続けても、Issueが毎週複製されることはありません。

手動更新コマンド jin model update

CIとは別に、ユーザー環境から直接キャッシュを更新するコマンドが jin model update です。サブコマンドのルーティングは src/index.ts で行っています。

// src/index.ts:1-18
#!/usr/bin/env node
import { cli, agentInit, skillInit, hookInit, modelUpdate } from './cli.js';

const args = process.argv.slice(2);
const demo = args.includes('--demo');

// サブコマンド分岐
if (args[0] === 'agent' && args[1] === 'init') {
  agentInit().catch((err: unknown) => { console.error(err); process.exit(1); });
} else if (args[0] === 'skill' && args[1] === 'init') {
  skillInit().catch((err: unknown) => { console.error(err); process.exit(1); });
} else if (args[0] === 'hook' && args[1] === 'init') {
  hookInit().catch((err: unknown) => { console.error(err); process.exit(1); });
} else if (args[0] === 'model' && args[1] === 'update') {
  modelUpdate(args.includes('--force')).catch((err: unknown) => { console.error(err); process.exit(1); });
} else {
  cli({ demo }).catch((err: unknown) => { console.error(err); process.exit(1); });
}

Commanderのようなコマンドラインパーサは使わず、process.argv を直接見て if で分岐する素朴な実装です。model update の実体は src/cli.tsmodelUpdate です。

// src/cli.ts:129-176
export async function modelUpdate(force: boolean): Promise<void> {
  const cache = force ? null : loadModelCache();
  if (cache && isFresh(cache)) {
    note(
      [
        'キャッシュはまだ新しい(24時間以内)ため取得をスキップしました。',
        '強制的に取得する場合は `jin model update --force` を実行してください。',
      ].join('\n'),
      'jin model update',
    );
    return;
  }

  console.log(chalk.dim('  ollama.com から最新モデル情報を取得しています...'));

  const queries = [undefined, 'coder', 'reasoning', 'small'];
  const results = await Promise.allSettled(queries.map((q) => fetchSearch(q)));

  const merged = new Map<string, Awaited<ReturnType<typeof fetchSearch>>[number]>();
  let anySucceeded = false;
  for (const r of results) {
    if (r.status !== 'fulfilled') continue;
    anySucceeded = true;
    for (const m of r.value) {
      if (!merged.has(m.name)) merged.set(m.name, m);
    }
  }

  if (!anySucceeded) {
    note(
      'ollama.com への接続に失敗しました。ネットワーク接続を確認してください。',
      'jin model update',
    );
    return;
  }

  const models = [...merged.values()];
  const localCount = models.filter(isUsableLocally).length;
  saveModelCache(models, 'full');

  note(
    [
      `取得 ${models.length} 件 / ローカル実行可 ${localCount} 件`,
      `保存先: ${path.join(homedir(), '.jin', 'model-cache.json')}`,
    ].join('\n'),
    'jin model update',
  );
}

--force フラグの有無は loadModelCache() を呼ぶかどうかに直結していて、force が真のときは既存キャッシュの新鮮さを見ずに常に再取得します。クエリは updateModelCatalog.ts と同じ4種類(featured + coder reasoning small)ですが、こちらは Promise.all ではなく Promise.allSettled を使っている点が異なります。4本のうち1本が失敗しても他の成功分だけをマージして使い、anySucceeded が偽(=全滅)のときだけユーザーへ接続失敗を伝えます。保存する ModelCacheSource'full' で、fetchOllamaModels が自動保存する 'featured' と区別されます。

strengthベース推奨tier2の実装(modelRecommendations.ts)

駒(役割)ごとの推奨は src/roles/modelRecommendations.ts にあります。0.1.1時点は特定モデル名の前方一致(keyword)だけで推奨を決めていましたが、0.2.0では「keywordに一致しない新モデルでも、strengthが合えば安全網として推奨対象にする」tier2が追加されています。

// src/roles/modelRecommendations.ts:9-31
export interface RoleModelEntry {
  /** Ollama モデル名のキーワード(前方一致で照合) */
  keyword: string;
  /** この駒にこのモデルが適している理由 */
  reasonJa: string;
  reasonEn: string;
}

export interface RoleRecommendation {
  roleId:  RoleId;
  nameJa:  string;
  nameEn:  string;
  descJa:  string;
  descEn:  string;
  /** 優先順位順の推奨モデルキーワード一覧(tier1: 名指し) */
  models:  RoleModelEntry[];
  /**
   * 強みベースの優先順位(tier2: 安全網)。
   * keyword に一致しない新モデルでも、strengths[0] に一致すれば駒に推奨される。
   * モデル世代が進んでも keyword の手動追記なしに追随できるようにするための仕組み。
   */
  strengths: ModelStrength[];
}

たとえば「飛車」(技術実装・アーキテクチャ計画担当)の定義は次のとおりです。

// src/roles/modelRecommendations.ts:110-122
  hisha: {
    roleId: 'hisha',
    nameJa: '飛車', nameEn: 'Hisha',
    descJa: '技術実装・アーキテクチャ計画を担う',
    descEn: 'Plans technical implementation and architecture',
    strengths: ['coding', 'reasoning', 'balanced'],
    models: [
      { keyword: 'qwen3-coder-30',   reasonJa: 'コード特化30Bで最も詳細な実装計画',    reasonEn: 'Code-specialized 30B for the most detailed plans' },
      { keyword: 'qwen3-coder-next', reasonJa: 'コード特化・軽量版で高速に実装計画',    reasonEn: 'Lightweight code-specialized model for fast planning' },
      { keyword: 'qwen2.5-coder-14', reasonJa: '速度と精度のベストバランス',            reasonEn: 'Best balance of speed and accuracy' },
      { keyword: 'qwen2.5-coder-7',  reasonJa: '高速・軽量で手順分解に十分',           reasonEn: 'Fast and lightweight, sufficient for step breakdown' },
    ],
  },

models(tier1)は名指しの4件だけですが、strengths: ['coding', 'reasoning', 'balanced'] があるため、ここに載っていない新しいコード特化モデルがインストールされていても推奨候補になります。判定本体が rankModelsForRole です。

// src/roles/modelRecommendations.ts:190-229
export function rankModelsForRole(
  roleId:         RoleId,
  installedNames: string[],
): Array<{ name: string; reason: { ja: string; en: string } | null; rank: number; matchKind: 'keyword' | 'strength' | null }> {
  const rec = ROLE_RECOMMENDATIONS[roleId];

  return installedNames.map((name) => {
    // keyword は 'qwen3.5-27' 形式なので、'qwen3.5:27b' のようなタグ区切りも
    // マッチするようコロンをハイフンに正規化して照合する
    const normalized = name.toLowerCase().replace(/:/g, '-');
    const idx = rec.models.findIndex((m) =>
      normalized.includes(m.keyword.toLowerCase()),
    );

    if (idx >= 0) {
      const entry = rec.models[idx]!;
      return {
        name,
        reason: { ja: entry.reasonJa, en: entry.reasonEn },
        rank: idx,
        matchKind: 'keyword' as const,
      };
    }

    // tier2: strengthベースの安全網
    const strength = strengthOfModelName(name);
    const strengthIdx = strength ? rec.strengths.indexOf(strength) : -1;
    if (strengthIdx >= 0) {
      const reason = STRENGTH_REASONS[roleId][strength!];
      return {
        name,
        reason: reason ?? null,
        rank: 100 + strengthIdx * 10,
        matchKind: 'strength' as const,
      };
    }

    return { name, reason: null, rank: 999, matchKind: null };
  }).sort((a, b) => a.rank - b.rank);
}

tier1(keyword 一致)は rank に配列内のインデックスをそのまま使うため0〜数十の範囲に収まりますが、tier2(strength 一致)は 100 + strengthIdx * 10 というオフセットを足しています。これにより、tier1で1件でもマッチしたモデルは常にtier2のモデルより上位に並びます。strengthOfModelNamesrc/system/modelCatalog.ts:46-68)はモデル名だけから MODEL_META → 生成カタログ → classifyStrength ヒューリスティックの順で強みを解決する関数で、ollamaRegistry.ts を経由せずインストール済みモデル名から直接呼び出せます。

tier2を自動割り当てに使う computeRecommendedRoleModels は、tier1より条件を絞っています。

// src/roles/modelRecommendations.ts:243-266
export function computeRecommendedRoleModels(
  installedNames: string[],
  defaultModel:   string,
): Partial<Record<RoleId, RecommendedAssignment>> {
  const result: Partial<Record<RoleId, RecommendedAssignment>> = {};

  for (const roleId of ROLE_ORDER) {
    const ranked = rankModelsForRole(roleId, installedNames);
    const rec    = ROLE_RECOMMENDATIONS[roleId];
    const best   = ranked.find((r) => {
      if (r.matchKind === 'keyword') return true;
      if (r.matchKind === 'strength') {
        const strength = strengthOfModelName(r.name);
        return strength === rec.strengths[0];
      }
      return false;
    });
    if (best && best.name !== defaultModel) {
      result[roleId] = { name: best.name, reason: best.reason };
    }
  }

  return result;
}

tier1のマッチはそのまま採用しますが、tier2のマッチは strength === rec.strengths[0](その駒にとって最優先の強みと一致する場合のみ)に絞っています。「飛車」であれば strengths[0]'coding' なので、'reasoning''balanced' に分類されたモデルはランキング(rankModelsForRole)には登場してもデフォルトモデルからの自動置き換えの対象にはなりません。

ハマりどころ・リスク

自動テストが無い。 ollamaScrape.ts modelCache.ts ollamaRegistry.ts のいずれにも、固定HTMLフィクスチャに対するユニットテストは存在しません(find でリポジトリ全体を検索しても *.test.ts は1件もヒットしません)。assertHtmlShape によるセレクタ検査と、updateModelCatalog.tsMIN_TOTAL_MODELS / MIN_LOCAL_MODELS チェックが実質的な回帰検知の役割を担っていますが、どちらも「実際にネットワーク越しに ollama.com を叩いて初めて分かる」検査です。CIのcronが失敗して初めて壊れたことに気づく構造は変わっていません。

gh issue list --search はタイトルの部分一致であって完全一致ではない。 --search "モデルカタログの自動更新に失敗" は検索クエリなので、将来別の目的でこのフレーズを含むIssueを作ると、意図せず「既存Issueあり」と誤判定されて新規Issueが作られなくなる可能性があります。現状はこのワークフロー専用の固定文言なので実害は出ていません。

Promise.allPromise.allSettled の使い分けが2つのスクリプトで異なる。 scripts/updateModelCatalog.tsPromise.all で4クエリのうち1本でも失敗すれば全体を失敗させる一方、jin model updatecli.ts)は Promise.allSettled で部分的な成功を許容します。CI側は「健全なカタログでなければ書き込まない」という厳しめの基準、手動コマンド側は「多少欠けてもキャッシュを更新できた方がユーザー体験として良い」という基準で、意図的に非対称になっています。

キャッシュの CACHE_VERSION を上げたときの体験。 loadModelCacheversion 不一致を検出すると黙って null を返し再取得に回るだけで、ユーザーに「スキーマが変わったので再取得します」といった通知は出ません。次回起動時に自動でウェブ取得へフォールバックするため実害はありませんが、オフライン環境でバージョンアップ直後に起動すると、期限内だったはずのキャッシュが無視されて内蔵スナップショットまで落ちる、という挙動にはなり得ます。

Jin / Kurari の関連記事

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?