3
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?

資格比較サイトの「公式にそう書いてあるか」を、Playwright で毎日照合している

3
Last updated at Posted at 2026-10-12

資格の講座や仕事を比べられる「シカクラベ」というサイトを、個人でちまちま作っています。

先日、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 です。

src/schemas/common.ts
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)で開いています。

scripts/autocheck/fetch.ts
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 の両方を同じルールで正規化してから比べています。

src/lib/autocheck-match.ts
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分にすると混むと聞いたので、ずらしました。

.github/workflows/autocheck.yml
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 の先頭から何文字まで本文に見つかるかを二分探索で調べて、その割合で判断しています。

scripts/autocheck/evaluate.ts
// 先頭から何割まで一致するか(二分探索。先頭の一部が一致すれば単調)
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/

3
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
3
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?