資格の講座や仕事を比べられる「シカクラベ」というサイトを、個人でちまちま作っています。
先日、GitHub を開いたら見覚えのない Issue が1件立っていました。
[autocheck] courses/(講座のID): 公式の記載と合わない項目があります
日付は3日前。自分で作った仕組みが立てた Issue に、3日間気づいていなかったわけです。
中身を見ると、ある通信講座の価格ページで、データに残していた一文が見つからなくなったという知らせでした。
この記事は、その Issue を立てた仕組みの話です。
なぜ作ったか
比較サイトをやっていて一番こわいのは、載せている情報が古くなることです。
受験料、受講料、給付金の対象かどうか。このあたりは公式サイトで予告なく変わります。気づかないまま古い数字を出し続けたら、見に来てくれた人が損をします。
かといって、毎日すべての公式ページを人の目で見て回るのは無理です。
なので、「サイトに載せている情報が、今も公式ページにそう書いてあるか」を毎日機械に確かめさせることにしました。リポジトリの中では autocheck と呼んでいます。
ざっくりした流れ
データ(JSON) 毎日 4:17(GitHub Actions)
├ 値(受験料など) │
└ 出典 url + quote ──→ Playwright で出典ページの本文を取る
本文と quote を正規化して照合
結果を _autocheck.json に記録(bot が commit)
合わなければ Issue
ビルドに反映(確認日の更新など)
照合しているのは値そのものではなく、出典から抜き出した「引用(quote)」です。ここが一番大事なところなので、先に説明します。
データに引用を残しておく
シカクラベのデータは JSON で、項目ごとに出典を持たせています。スキーマは Zod です。
export const Source = z.object({
url: HttpUrl,
title: z.string().optional(),
checkedAt: IsoDate,
/** 確認の証拠として残す、出典の該当部分の引用(原文のまま)。サイトには表示しない */
quote: z.string().min(1).optional(),
verifiedBy: z.enum(['ai', 'human']).optional(),
/** manual:毎日の自動照合の対象外 */
autocheck: z.literal('manual').optional(),
manualReason: z.string().min(1).optional(),
});
実際のデータだとこんな感じです。
"organizer": {
"url": "https://www.hokeniryo.metro.tokyo.lg.jp/anzen/iyaku/tourokushiken",
"title": "東京都保健医療局「登録販売者試験について」",
"checkedAt": "2026-09-27",
"quote": "「登録販売者」になるためには、都道府県の実施する試験に合格し、登録をする必要があります。",
"verifiedBy": "ai"
}
データを作るときに、Claude Code に公式ページを開かせて1項目ずつ照合させ、根拠にした一文を quote に書かせています。最後は自分でプレビューを見てから公開します。
この一文さえ残っていれば、毎日の確認は「公式ページにこの文字列がまだあるか」を調べるだけで済みます。
最初は AI に毎日「この情報まだ正しい?」と聞かせる案も考えました。でもお金がかかるし、日によって答えがぶれそうなのでやめました。文字列の比較ならタダみたいなものですし、結果も毎回同じです。
本文の取り方
公式ページは、JavaScript で描画されていたり、料金表がアコーディオンの中にしまわれていたりと、作りがばらばらです。素の fetch では取りきれないので、Playwright(chromium)で開いています。
const res = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: NAV_TIMEOUT_MS });
// ...
await page.waitForLoadState('networkidle', { timeout: IDLE_TIMEOUT_MS }).catch(() => {});
// 折りたたみ内の文字も拾うため、表示テキストと textContent の両方を使う
const raw = await page.evaluate(() => {
const body = document.body;
if (!body) return '';
const clone = body.cloneNode(true) as HTMLElement;
clone.querySelectorAll('script,style,noscript,template').forEach((e) => e.remove());
return `${body.innerText}\n${clone.textContent ?? ''}`;
});
innerText だけだと、閉じたアコーディオンの中身が取れませんでした。かといって textContent だけにすると、今度は <script> の中身まで混ざってきます。結局、script などを消したコピーの textContent と innerText を、両方くっつけて使うことにしました。雑ですが、照合は部分一致なので困っていません。
ほかに気をつけていることです。
- 試験の案内は PDF のことが多いので、PDF は
unpdfで本文を取り出して同じように照合しています。中身が画像だけの PDF は諦めて「取得失敗」扱いです - 取りに行く前に robots.txt を見ます。拒否されていたら取りません。robots.txt 自体が取れないときも、念のため取りに行きません
- User-Agent は
ShikakurabeAutocheck/1.0に運営者ページの URL を付けています。向こうのサーバー担当の人がログを見たとき、誰のアクセスか分かるように - 1つの URL は1日1回まで。次の URL までは5秒あけています
照合は「正規化してから部分一致」
見た目は同じ文字でも、中身が違うことがよくあります。全角と半角、改行の位置、〜 と ~。資格名だと ® が付いていたりいなかったりもします。
なので、本文と quote の両方を同じルールで正規化してから比べています。
export function normalizeText(text: string): string {
return (
text
// 商標記号は NFKC で「™ → TM」になるので、その前に取り除く
.replace(/[®™©]/g, '')
.normalize('NFKC')
.toLowerCase()
// 「(R)」「(TM)」と書くページがある。®・™ と同じく、有無の違いを吸収する
.replace(/\((r|tm)\)/g, '')
// ゼロ幅文字・BOM
.replace(/[\u200b-\u200d\u2060\ufeff]/g, '')
// 波ダッシュ・チルダ類
.replace(/[〜~∼]/g, '~')
// ハイフン・ダッシュ・マイナス類(長音「ー」は変えない)
.replace(/[‐‑‒–—―−﹣-]/g, '-')
// 空白・改行
.replace(/\s+/g, '')
);
}
® を NFKC より前に消しているのは、NFKC が ™ を TM に変えてしまい、後からでは消せなくなるからです。同じ資格名でも、ページによって ® だったり (R) だったりするので、両方消しています。
料金表みたいに途中が長い文は、quote に … を入れて省略できるようにしました。… で区切った断片が、本文に順番どおりに出てくれば一致とみなします。
export function quoteMatches(normalizedPage: string, quote: string): boolean {
const fragments = splitQuote(quote); // 省略記号で区切って、それぞれ正規化
if (fragments.length === 0) return false;
let from = 0;
for (const f of fragments) {
const i = normalizedPage.indexOf(f, from);
if (i < 0) return false;
from = i + f.length;
}
return true;
}
結果の記録と、サイトへの反映
照合結果は src/data/_autocheck.json に項目ごとに残しています。
"qualifications/chozai-jimu#fieldSources.summary": {
"url": "https://www.ginou.co.jp/qualifications/chozai-jimu.html",
"lastMatchedAt": "2026-10-11"
}
合わなくなった項目には missingSince、取れなかった URL には fetchFailedSince が付きます。
ビルドのときにこれを読んで、サイトに出す「確認日」を、最初に確認した日(checkedAt)と最後に一致した日(lastMatchedAt)の新しいほうにしています。毎日一致していれば、確認日も毎日新しくなります。地味ですが、読む側からすると安心材料になると思っています。
合わなくなった項目を本番から隠すモード(enforce)も作りました。ただ、既定は記録するだけの report にしています。
いきなり隠すようにすると、照合側のバグでサイトの情報が消えかねないからです。実際、この判断は正解でした(後で書きます)。
それと、取得に失敗しただけでは隠しません。公式サイトがたまたま落ちていただけかもしれないので。隠す対象は「本文は取れたのに quote がない」ときだけです。
GitHub Actions で毎日回す
毎日、日本時間 4:17 に動かしています。cron を毎時0分にすると混むと聞いたので、ずらしました。
on:
schedule:
- cron: '17 19 * * *'
workflow_dispatch:
permissions:
contents: write
issues: write
concurrency:
group: autocheck
cancel-in-progress: false
ここでつまずいたことをいくつか。
まず、GITHUB_TOKEN で push したコミットでは、ほかの workflow(ci.yml)が動きません。知らずに「CI が通ったら本番」のつもりでいたので、ちょっと焦りました。今は、照合結果が変わった日だけ、同じ workflow の中で検証とビルドを回してから push しています。
- name: 検証(変更があるときだけ)
if: steps.check.outputs.changed == 'true'
run: |
npm run validate:data
npm run build
npm run check:dist
次に、取得した本文を .autocheck/ に保存して artifact で30日残すようにしたら、artifact が空でした。先頭がドットのディレクトリは、既定では入らないそうです。include-hidden-files: true を付けて解決しました。
あとは再ビルドのタイミングです。結果が変わった日は push で Cloudflare Pages が勝手に再ビルドしてくれます。でも変わらない日も、終わったキャンペーン価格を消したいので再ビルドはしたい。そこで、変更がない日だけ Cloudflare のデプロイフックを叩いています。
合わなかったら Issue を立てる
合わなくなった項目は、講座や資格ごとに Issue を立てます。また一致すれば勝手に閉じます。
Issue には「なんで合わなかったのか」の見立ても書かせています。quote の先頭から何文字まで本文に見つかるかを二分探索で調べて、その割合で判断しています。
// 先頭から何割まで一致するか(二分探索。先頭の一部が一致すれば単調)
let lo = 0;
let hi = whole.length;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (normalizedPage.includes(whole.slice(0, mid))) lo = mid;
else hi = mid - 1;
}
const ratio = lo / whole.length;
if (ratio >= 0.5) {
return `quote の先頭 ${Math.round(ratio * 100)}% は一致(値・表記の一部が変わった可能性)`;
}
先頭の8割が見つかるなら、金額だけ変わったのかな、と当たりがつきます。先頭すら見つからないなら、ページごと移動したか、大きく書き換わったのを疑います。どこから見ればいいか分かるだけで、確認がだいぶ楽になりました。
自動で見られないものは、月1で人が見る
ログインしないと見られないページや、検索しないと出てこない情報(教室がどこで開講しているか、など)は、自動では照合できません。こういう出典には autocheck: "manual" と理由を書いて、対象から外しています。
ただ、外しっぱなしだと確実に忘れます。なので毎月1日に「manual 確認リスト」という Issue が立つようにしました。届いたら、Issue の番号を Claude Code に伝えて確認してもらっています。
10日ほど回してみて
今は、出典の URL が71件、照合している項目が292件です。manual にしているのが29件あります。
初回の実行では、いきなり12件が不一致になりました。全部、公式が変わったわけではなく、こちらの quote の取り方の問題でした。もし最初から enforce で動かしていたら、正しい情報を12件まとめて消していたことになります。(R) の正規化は、このあとに足したものです。
その後、資格を追加したときにも4件不一致が出ました。原因は、quote をページの <title> から取っていたことです。<title> は本文に出てこないので、本文と比べても見つかるわけがありません。今は「quote は画面に表示される本文から取る」をデータ作りのルールにしています。
本文が極端に短いページ(公式サイトのトップなど)を「取得失敗」とみなす下限が厳しすぎて、照合できないこともありました。下限を100文字まで下げて解決しています。
そして冒頭の Issue です。
ある通信講座の価格ページで、一部の地域だけキャンペーンをやっていた間、ほかの地域の価格は「その他各ブロック:一括33,000円」と書かれていました。キャンペーンが終わった2日後の朝、この一文が見つからなくなって Issue が立ちました。
公式ページを見に行くと、「各ブロック:一括33,000円」になっていました。「その他」が消えただけで、値段は同じです。
サイトの数字は正しいままでした。それでも、根拠にしていた一文が変わったことには気づけています。quote を今の書き方に直せば、次の照合で一致して Issue も閉じるはずです。
ただ、気づいたのは3日後です。GitHub の通知をちゃんと見ていませんでした。仕組みがせっかく検知しても、人に届かなければ意味がないですね。リポジトリの Watch で Issues を受け取るように設定して、メールで届くようにしておきます。
おわりに
振り返ると、一番効いたのは「AI に毎日判断させる」のではなく、「AI に一度だけ根拠の一文を残させる」設計にしたことだと思います。毎日の作業がただの文字列比較になるので、安いし、ぶれないし、結果を疑う必要もありません。
「AI で作ったサイトは情報が怪しい」と言われがちですが、根拠を残して毎日照合していれば、人が手で管理するより新しい状態を保てるんじゃないかな、と思っています。
資格選びで迷っている方がいたら、のぞいてみてください。
https://shikakurabe.com/