コンテンツ管理 2026年8月28日

タグは自由入力にした時点で壊れていた。76記事に194種類、142種類は1記事にしか付いていない

XECIN Astro品質保証技術検証

先週、このTech Blogに前に書いたテスト関連の記事を探そうとして、一覧ページの上にあるタグフィルターを使いました。「品質保証」で絞ればすぐだろう、と思ったんですね。

見つからないんです。

バーは横にずっと続いていて、しかもスクロールバーが出ていない。トラックパッドで横に流し続けて、10秒くらいかけてようやく「品質保証」に着きました。目的の記事は3秒で見つかったのに、そこに至るまでが一番遅い。

私はバックエンド寄りの人間なので、この手の「一覧が使い物にならない」現象を見ると、まず表示側ではなく入力側のデータを疑う癖があります。だいたいは、入れるときに何の制約もかけていないデータが、出すときになって一気に破綻している。今回もそのパターンでした。

まず、いま何種類あるのかを数えた

犯人捜しの前に、実装がどうなっているかを確認しました。タグフィルターバーを作っているのはこの1行です。

// src/components/sections/NewsList.astro
// タグ一覧を収集(重複排除)
const allTags = [...new Set(news.flatMap((n) => n.data.tags ?? []))];

このあと allTags.map() で全部をボタンにしています。条件も上限もありません。つまり バーの長さ=コンテンツ側の語彙の数 です。ここまでは実装の意図として素直で、語彙が20〜30種類に収まっている前提なら何も問題がない書き方だと思います。

では実際に何種類あるのか。記事のフロントマターを直接数えました。

# scripts/tmp/count_tags.py(調査用の使い捨て。リポジトリには入れていない)
import re, glob, collections

counts = collections.Counter()
articles = 0

for path in glob.glob('src/content/news/*.mdx'):
    # BOM対策で utf-8-sig。理由は後述
    text = open(path, 'rb').read().decode('utf-8-sig').replace('\r\n', '\n')
    front = re.search(r'^---\n(.*?)\n---', text, re.S).group(1)

    state = re.search(r'^status:\s*(\S+)', front, re.M).group(1).strip('"')
    if state != 'published':
        continue

    articles += 1
    line = re.search(r'^tags:\s*\[(.*?)\]', front, re.M)
    if line:
        counts.update(t.strip().strip('"') for t in line.group(1).split(',') if t.strip())

assigned = sum(counts.values())
solo = sum(1 for v in counts.values() if v == 1)
print(f'公開記事 {articles} / 延べ付与 {assigned} / ユニーク {len(counts)} / 1記事だけ {solo}')
print(counts.most_common(10))

# 実行結果:
#   公開記事 76 / 延べ付与 374 / ユニーク 194 / 1記事だけ 142
#   [('やってみた', 21), ('技術検証', 20), ('Astro', 9), ('Python', 9), ('AIエージェント', 8),
#    ('セキュリティ', 8), ('TypeScript', 8), ('AWS', 7), ('CloudFront', 7), ('プロジェクト管理', 7)]
測ったもの実測値
公開記事数76
タグの延べ付与数374(平均 4.92 個/記事)
ユニークなタグ194 種類
1記事にしか付いていないタグ142 種類(全語彙の 73.2%)
記事1本あたりの新語出現数平均 2.55 個
上位30タグでカバーできる付与数187 / 374 = 50.0%

一番効いているのは最後から2番目の行です。記事を1本足すたびに、平均で2.55個の新しいタグが生まれている。直近15本を個別に見ても 1〜3個ずつ増えていて、0個だった記事は1本しかありませんでした。つまりこの語彙は、記事が増えるかぎり収束しません。194という数字は途中経過にすぎない、ということです。

ところが、最初の集計は間違っていた

ここが今回いちばん想定外だったところです。

上のスクリプトの最初の版は open(path, encoding='utf-8') で読んでいて、公開記事を 69件 と報告してきました。ところが一覧ページのバー左端にある「すべて」のカウントは 76。7件合いません。

7件ぶん、私のスクリプトが黙って捨てていました。

# 先頭3バイトが EF BB BF のファイルを洗い出す
$ for f in src/content/news/*.mdx; do
    head -c 3 "$f" | od -An -tx1 | grep -q 'ef bb bf' && echo "$f"
  done
src/content/news/2026-04-22-ai-agent-cms-replacement.mdx
src/content/news/2026-05-06-corporate-site-static-comparison.mdx
src/content/news/2026-05-12-wp-php-eol-options.mdx
src/content/news/2026-05-20-wp-plugin-incident-structural.mdx
src/content/news/2026-05-24-wp-to-astro-migration-timeline.mdx
src/content/news/2026-05-30-plugin-cve-cost-reality.mdx
src/content/news/2026-06-01-headless-cms-comparison-2026.mdx

UTF-8のBOMです。7ファイルとも4月下旬から6月頭という、ひとかたまりの時期に書かれたもの。おそらく当時はWindows側のエディタなりシェルなりから書き出していて、いまとは経路が違ったのだと思います。

厄介なのは、これがサイト側では一切問題を起こしていないことでした。Astroのフロントマターのパーサはこの3バイトを許容するので、ビルドは通るしページも普通に出ている。壊れるのは、あとから素朴に ^--- で正規表現を当てた自前のスクリプトだけです。しかもエラーは出ません。マッチしなかったファイルが静かに集計から抜けるだけ。

utf-8-sig で読み直したら 69→76記事、176→194種類になりました。この記事に出てくる数字は全部その修正後のものです。

正直なところ、タグというデータの汚れを測るつもりで書いたスクリプトが、別種のデータの汚れで壊れていた、というのは笑えませんでした。普段はエッジケースを先に潰しておくほうなんですが、今回は最後まで気づかず、一覧ページのカウントと突き合わせて初めて分かった。測定結果は、それを測った道具と一緒に疑う、というだけの教訓です。

出力側でも測ってみた

語彙の数が分かったので、それがビルド成果物にどう出ているかも測りました。

# ビルド済みHTMLから、タグフィルターバーだけを切り出して測る
import re

html = open('dist/news/index.html', encoding='utf-8').read()
bar = re.search(r'<div class="tag-filter"[^>]*>.*?</div>\s*<div class="list-body"', html, re.S).group(0)

buttons = re.findall(
    r'<button class="tag-filter-btn"[^>]*data-tag="([^"]*)"[^>]*>\s*(.*?)'
    r'<span class="tag-filter-count"[^>]*>(\d+)</span>\s*</button>', bar, re.S)

tags = [(name, int(n)) for name, _, n in buttons if name != 'all']
solo_bytes = sum(
    len(re.search(r'<button class="tag-filter-btn"[^>]*data-tag="%s".*?</button>' % re.escape(t), bar, re.S)
        .group(0).encode('utf-8'))
    for t, n in tags if n == 1)

print('ボタン数        :', len(tags) + 1)
print('バー            :', len(bar.encode('utf-8')), 'bytes')
print('うち1件タグ分   :', solo_bytes, 'bytes')
print('ページ全体      :', len(html.encode('utf-8')), 'bytes')

# 実行結果:
#   ボタン数        : 195
#   バー            : 32674 bytes
#   うち1件タグ分   : 23818 bytes
#   ページ全体      : 137444 bytes

一覧ページ137KBのうち、タグフィルターバーが32.6KB。ページの 23.8% がフィルターバーです。しかもそのバーの 72.9% は、押すと記事が1本だけ出てくるボタンで占められている。

並び順も見ました。allTags は日付降順の記事配列から作られるので、ボタンの並びは頻度順ではなく 最新記事のタグ順 になります。結果として、

  • 「Astro」(9記事・最多タイ)は左から 67番目
  • 「プロジェクト管理」(7記事)は左から 85番目
  • 「技術検証」(20記事)は10番目、と、ここだけはたまたま前のほう

CSSは overflow-x: auto で横スクロール、scrollbar-width: none でスクロールバーを消し、position: sticky で上に貼り付いています。デザインとしては綺麗なんですが、85番目のボタンに到達する手段が「見えないスクロール」しかない。私が「品質保証」を見つけられなかった理由はこれでした。

表記ゆれのほうが実害は大きい

数だけなら「多いね」で終わる話です。中身を見ると、もう少し厄介でした。

実質的に同じ棚実際に使われているタグ(付与数)語彙数 / 延べ
AI・エージェントAIエージェント(8) / Claude(5) / MCP(4) / AI(3) / AI活用(2) / AI社員(1) / LLM(1) / プロンプトエンジニアリング(1)8 種 / 25 件
サイト・CMSAstro(9) / WordPress(5) / 静的サイト(4) / コーポレートサイト(3) / ヘッドレスCMS(1) / microCMS(1)6 種 / 23 件
現場・改善現場(6) / 業務改善(4) / 自動化(4) / 業務効率化(1) / 運用改善(1) / 保守(1) / 保守運用(1)7 種 / 18 件
テスト・品質品質保証(6) / QA(2) / テスト戦略(2) / テスト自動化(2) / テスト設計(1) / E2Eテスト(1)6 種 / 14 件
セキュリティセキュリティ(8) / CVE(2) / 脆弱性(1) / 認証認可(1) / サプライチェーン(1)5 種 / 13 件
CI・デプロイCI/CD(3) / GitHub Actions(3) / CI(1) / デプロイ(1)4 種 / 8 件

「AIエージェント」で絞ると8件出ますが、AI関連で書いた記事は延べ25件ぶんのタグが付いている。「品質保証」で絞ると6件で、テスト系14件のうち半分以上が別のボタンの下に隠れます。「CI」と「CI/CD」は、書いた人間にとっては同じ意味でも、Set にとっては完全に別の値です。

フィルターが役に立たない本当の理由は、ボタンが多いことではなく、同じ棚のものが複数のボタンに分かれていることでした。

なぜ誰も止められなかったのか

スキーマを見ると理由は明快でした。

コンテンツのスキーマ(src/content.config.ts)では、タグの定義は tags: z.array(z.string()).optional().default([]) の1行だけです。

型は完全に守られています。文字列の配列でないものは通らない。ただ、中身については何も言っていない。新しい値を作ってよいか、既存の語彙と重複していないか、そもそも何種類まで許すのか。そこは誰も検査していません。

CIも見にいきましたが、astro check(型チェック)と npm run build だけでした。ビルドが通る=タグが健全、ではないので、194種類はここを素通りしてきたわけです。ドキュメントには「タグは3〜5個」と書いてあるんですが、書いてあるだけでは守られない、という当たり前の話でもあります。

ここは好みが分かれるところですが、私は自由入力のタグそのものが悪いとは思っていません。書き手がその場で語彙を作れるのは、書きやすさとしてはっきり利点があります。問題は、作りっぱなしで誰も畳まないことのほうです。

直す順番を間違えかけた

最初に考えたのは、表示側で「2件以上のタグだけボタンにする」というやり方でした。1行の filter で終わるし、195ボタンは53ボタンまで減ります。所要5分。

やめました。

隠しても、142種類はデータとして存在し続けます。集計は分断されたまま、表記ゆれもそのまま。将来タグごとのページを作ろうとしたら、1記事しかない薄いページが142枚できる状態も変わりません。しきい値で隠すのは、測定値を見えなくして問題が消えたことにするやり方で、今回いちばんやってはいけないことでした。

順番としては、入口を締めるのが先です。まず「登録済みの語彙以外を新規に増やせない」状態を作る。

# scripts/check-tags.py(案)
# content-plan/tag-allowlist.json に載っていないタグを検出して落とす
import json, re, glob, sys

allow = set(json.load(open('content-plan/tag-allowlist.json', encoding='utf-8'))['tags'])
violations = []

for path in glob.glob('src/content/**/*.mdx', recursive=True):
    text = open(path, 'rb').read().decode('utf-8-sig').replace('\r\n', '\n')
    front = re.search(r'^---\n(.*?)\n---', text, re.S)
    if not front:
        violations.append((path, '<フロントマターを読めません>'))
        continue
    line = re.search(r'^tags:\s*\[(.*?)\]', front.group(1), re.M)
    if not line:
        continue
    for tag in (t.strip().strip('"') for t in line.group(1).split(',') if t.strip()):
        if tag not in allow:
            violations.append((path, tag))

for path, tag in violations:
    print(f'未登録のタグ: {tag}  ({path})')
sys.exit(1 if violations else 0)

このスクリプトはまだ改善の余地があります。いまの形だと「許可リストにない=即エラー」なので、新しい領域の記事を書くたびに許可リストの更新PRが先に必要になり、書き手の手が止まります。本来は「新語は警告どまり、ただし1回のPRで増やせるのは1語まで」くらいの緩さが要るはずで、そのしきい値の根拠はまだ持っていません。CIにも組み込んでいません。フロントマターの読み取りを正規表現でやっているのも、BOMで一度やられた身としては本当は避けたいところです。

そして移行のコストが本丸でした。上位30タグを許可リストにすると、延べ付与374件のうちカバーできるのは187件、ちょうど50%です。裏を返すと、76記事のうち 73記事 が何らかの貼り替え対象になる。この数字を見ずに「許可リストを入れましょう」と提案していたら、たぶん初日で頓挫していたと思います。

なので、実際に着手するときは既存記事には手を付けず、新規記事だけ入口を通すところから始めるつもりです。既存の名寄せは、貼り替えの単位(AI系8種を何種に畳むか)を先に決めてから、別の作業として見積もる。

振り返って

いちばん学びが大きかったのは、フィルターが使えなくなった原因が、UIでもタグの数でもなく、語彙を畳む役割が誰にも割り当てられていなかったことだった、という点です。スキーマは型を守る係で、CIはビルドが通るかを見る係で、書き手はその記事に合う言葉を選ぶ係。全体の語彙が194種類になっていることに責任を持つ係だけが、最初からいませんでした。

今後はまず tag-allowlist.json のv0を作って、新規記事のぶんだけ入口を通すところから始めたいと思っています。あわせて、BOMの7ファイルも直したい。サイトは動いているので緊急ではないのですが、「読み込むスクリプトごとに個別対応する」状態を放置すると、次に誰かが書く集計スクリプトがまた静かに7件を落とします。

ちなみにこの記事のタグは、既存の語彙だけを使って付けました。新語を1個も増やさないのが、いまいちばん安く踏める最初の一歩なので。