※本記事は、Zennに投稿した記事の転載です。元記事: https://zenn.dev/uekibachidan/articles/1203990154bdca
はじめに
数日で情勢が入れ替わる勢いのAI界隈、
日本語のAI・LLM周りの情報だけでも追いかけようとすると巡回先が多すぎる。
あちこちのサイトを見て回るのは厳しいし、かといって総合テックブログのRSSをそのまま読むと、今度はAIと関係ない記事まで大量に混ざってくる。
なので「自分が毎日ラクにチェックできるサイト」が欲しかった。
やはり無いなら作るべしということで、複数ソースを集約してAI関連だけ抜き出すサイトを作ってみた。
日本語AIテックまとめ — https://technews.uekibachidan.com/
この記事で書くのは、その「抽出」の中身。
やっていることは「LLMを使わないテキスト分類」そのもの。
コストをかけたくなくてLLMとか使わずにやったのでその辺をつらつらと。
作ったもの
- 企業テックブログ・ニュースメディアのRSS/Atom 24サイト(AI専門7+総合テックブログ17)を集約
- AIに関係する記事だけを抽出(ここが本題)
- 「実装・開発ツール」「LLM・生成AI」などのカテゴリで絞り込み
- 記事ごとのタグ表示、はてブ数を使った注目ランキングなども
どこから集めているか(入れたくても入れられないものも…)
集約元は、企業のテックブログとニュースメディアのRSSに絞っている。
本音を言うと、ZennやQiitaの個人記事もまとめて読みたかった。。。
というか最初はそれを作った。この手の情報、実際そこに一番集まってるし。
ただ、個人の投稿を別サイトで再構成して並べるのは、各サービスの利用規約やコンテンツの利用範囲を考えると規約違反になる可能性が高い(広告表示を視野に入れると特に)。
集約サイトでそこを踏み外したら元も子もないので、最初から「自分で確認して問題なさそうなソース」だけに絞った。
技術スタックと全体の形
Next.js 16(App Router)/ React 19 / TypeScript / Tailwind v4
デプロイ先はCloudflare(OpenNextでWorkersに載せている)。
※ドメインをCloudflareで取得しているのでサーバ面での追加費用なしで運用
実装はAntigravity任せ。レビューはClaudeも併用。
※ClaudeはAI秘書っぽい使い方がメインだけどせっかくなのでレビューしてもらってる
全体の形はこう。
- 収集:GitHub Actionsが30分ごとにRSSを巡回し、記事データをJSONにまとめてCloudflareのKVへ書き込む
- 配信:WorkerはKVを読んで返すだけ
RSSまわりは、取得(fetch)だけ自前でやって(User-Agentを付けたり、レート制限対策のため)、パースは rss-parser に任せている。
要は「アクセスのたびに配信元へRSSを取りに行かない」作り。
集約サイトは配信元に迷惑をかけたら終わりなので。
……とさらっと書いたけど、最初からこの形だったわけではなく、ここに落ち着くまでに散々転んでいる。長くなったのでその話は別記事に。
LLMは提案されたけど、コストで却下
AIと仕様策定の壁打ちの中で、「各記事をLLMに投げてAI関連か判定させれば精度が出る」という案も出た。
けど、費用をかけたくなかったので却下。
キュレーションの判定くらいなら、もっと軽い方法で十分だろうと判断。
結局、TypeScriptの文字列処理と正規表現でやっている。
面倒な実装も自分ではお断りだけど全部AIがやってくれるなら気軽に採用できる。
まず表記を正規化する
日本語の記事だと、AI関連の語も表記がバラバラで入ってくる。「AI」「ai」「AI」(全角)、「LLM」「llm」……。
なので判定の前に、テキストを正規化しておく。全角の英数字を半角に直して、小文字化するだけ。
function normalizeText(text: string): string {
return text
.replace(/[A-Za-z0-9]/g, s => String.fromCharCode(s.charCodeAt(0) - 0xFEE0)) // 全角英数→半角
.toLowerCase();
}
これで「生成AI」「生成AI」「generative AI」あたりは、ぜんぶ小文字半角の "ai" を含む文字列に揃う。以降の判定は、この正規化済みテキストに対してやる。
キーワード辞書で広く拾う
正規化したテキストを、技術キーワードの辞書と突き合わせる。
const text = normalizeText(title + " " + body);
const keywords = [
"llm", "生成ai", "rag", "prompt", "openai", "chatgpt", "gemini", "claude",
"機械学習", "nlp", "ベクトル検索", "embedding", "pytorch", "tensorflow",
"langchain", "llamaindex", "dify", "cursor", "github copilot", "ollama",
"function calling", "devin", "cline", "windsurf", "ローカルllm",
// ...実際はもっと多い
];
if (keywords.some(kw => text.includes(kw))) return true;
正規化済みなので、LLM と llm、全角と半角の揺れは辞書側で気にしなくていい。some + includes なので、フィードが増えても速度は問題にならない。
「AI」という語は単語境界で拾う
辞書で拾いきれないのが、「AI」という語そのもの。
これは正規化済みテキストに対して、単語境界で判定する。
return /\bai\b/i.test(text);
素朴に text.includes("ai") にすると、available / training / mail / domain みたいな英単語の一部にまで引っかかる(全部 "ai" を含む)。そこを \bai\b の単語境界で「独立した ai」だけに絞る。
ここで日本語だと都合がよくて、\b の境界は \w(英数字)基準なので、漢字やかなは境界扱いになる。だから「生成AIの活用」→ 正規化で「生成aiの活用」→ 前後がかな/漢字なので "ai" が1語として切り出せる。スペースを入れなくても効く。地味に気持ちいいポイント。
カテゴリは優先順位で振り分ける
抽出した記事はカテゴリに振り分ける。
ここは単純なマッチじゃなく、優先順位を付けたカスケードにしている(determineCategory)。
- 実装・開発ツール(LangChain, Dify, Cursor, OpenAI API ...)
- LLM・生成AI(LLM, RAG, GPT-4, ファインチューニング ...)
- 機械学習・データ(PyTorch, MLOps, データ基盤 ...)
- プロダクト・事例(導入, 業務効率化 ...)
- ニュース・トレンド(発表, リリース ...)
上から判定して、先に「開発に直結する語」に当たったら実装系カテゴリに振り分け。
そうしないと、実装の濃い記事が雑多な「ニュース」に埋もれる。
自分が読みたいのは前者なので、そこを優先した。
(どれにも当たらないものは「その他」に落ちる。)
タグも同じ辞書方式で付ける
カテゴリとは別に、記事には「Claude」「RAG」「Cursor」みたいなタグも付けている(カードに最大3つ表示して、検索の対象にもしている)。
仕組みはここまでと同じで、タグ名と検索パターンのペアを60個ほど並べた辞書を、正規化済みテキストに突き合わせるだけ。
const KEYWORD_RULES: KeywordRule[] = [
{ tag: "Claude", patterns: ["claude"] },
{ tag: "RAG", patterns: ["rag", "検索拡張生成"] },
{ tag: "ファインチューニング", patterns: ["ファインチューニング", "fine-tuning", "fine tuning"] },
// ...
];
表記揺れはパターン側に持たせる。判定用とタグ用で辞書が2つになってしまったのは、ちょっと気になっているところ。
紹介文はテンプレートで合成する
各記事に付ける短い紹介文は、最初は配信元のdescriptionをそのまま載せていた。
ただ、よその文章をそのまま並べるのは、規約的にもSEO的(複製コンテンツ扱い)にも筋が悪い。集約サイトはただでさえコピーサイトと紙一重なので、ここは早めに直した。
今は、ソース名・カテゴリ・タグ・はてブ数を材料に、複数のテンプレートから自前で合成している。
`${src}が公開した${cat}分野の記事。主に${tagsTxt}に関する話題を扱っています。`
どのテンプレを使うかは記事IDのハッシュ値で決める。乱数じゃなくハッシュなので、ページを再生成しても同じ記事には同じ紹介文が付く。
ここもLLMを使えばもっと自然な文になるだろうけど、例によってコスト優先で見送り。
集めたあとの地味な処理(重複・日本語)
抽出とは別に、実運用で必要になった処理がいくつか。
- 重複除去:同じ記事が別ソースに出ることがあるので、URL(小文字化)で dedup。
- 日本語だけ残す:ひらがな/カタカナが含まれるかを正規表現で見て、英語だけの記事は落としている。
人気度ははてブ数を借りる
評価の指標として、はてなブックマークの件数取得API(https://bookmark.hatenaapis.com/count/entries)にURLを50件ずつまとめて投げて、ブクマ数を出している。自前で人気を測る仕組みを持たずに済むのが楽。
SEO:集約サイトは「自前ページ」が少ない
集約サイトは記事を外部リンクで送り出すので、自分のサイトにインデックスされるページがトップくらいしかなく、検索の入り口が狭い。
対策として、上のカテゴリをそれぞれ独立URL(/category/llm/ など5カテゴリ)のページにして、SSRで一覧を返し、sitemapに載せた。
分類ロジックは元々あるので、ページにするだけで検索の受け皿が増える。手間のわりにリターンがある部分。
記事が5件未満のカテゴリは薄いページになるので noindex にして、各ページには自己canonicalを張っている。
同じ狙いで、注目ランキング(/ranking)と集約元の一覧(/sources)も独立ページにした。
限界
- 辞書のメンテが必要:今後新しいモデルやツールが出たら追記しないと拾えない(判定用とタグ用で辞書が2つあるのでなおさら)。「運用に手をかけない」が目標だけど、ここだけは手がかかる。
- 偽陰性:辞書に無い言い回しでAIを語る記事は取りこぼす。
-
\bai\bの偽陽性:単語境界で区切っても、Adobe Illustrator の.aiファイル(前が.で区切られる)や人名の "Ai" は独立した "ai" として拾ってしまう。完全じゃない。
それでも、LLMに毎回投げるより圧倒的に軽いし、実用上は十分機能している。
完璧を目指すより、自分が毎日ラクに読めればそれでいい、というスタンス。
おわりに
運用まわりで散々転んだ話は、パフォーマンスチューニング編として別記事にまとめた。
→ https://qiita.com/uekibachidan/items/e6afe1c4c896da7998f4
使ってみて「このソースが拾えてない」「ここ誤爆してる」みたいな気づきがあれば教えてもらえると嬉しい。
日本語AIテックまとめ — https://technews.uekibachidan.com/
この記事は AI(Claude)と共同執筆しました。
