はじめに
普段、情報収集にはてなブックマークのランキングなどを使っています。
取得には Claude Code の skill で WebFetch を使い、趣味嗜好に合わせてフィルタリングする、という流れです。
ただ、この方法だとHTMLごと渡す形になるため、対象サイトが増えるとどうしてもトークン消費が大きくなります。
そこで今回は、GitHub Actions で事前にランキングを取得し、必要な情報だけに整形して Markdown として出力する仕組みを作ります。
Markdown化しておくことで、Claude Code が必要な情報を扱いやすくなり、結果としてトークン消費も抑えやすくなります。
ポイントは「取得」と「判断」を分離することです。
全体像
目的
今回やることはシンプルです。
- はてなブックマークのランキングを定期取得
- 必要な項目だけ抽出
- Markdownとして保存
- LLMに渡す前処理として利用
スクレイピングではなく、「LLMに読ませるデータを作る」という視点で設計します。
構成
構成は以下です。
- GitHub Actions
- 定期実行
- スクリプト実行
- Pythonスクリプト
- RSS取得
- データ整形
- Markdown
- 中間データ
- Claude Code
- フィルタリング・判断
流れはこうなります。
- GitHub Actions がスケジュール実行
- RSSを取得
- データを整形
- 重複排除・ソート
- Markdownとして出力
- Claude Code がそれを読む
データ取得と整形
取得方針
はてなブックマークには、カテゴリ別のRSSが揃っていないため、以下の方法で取得します。
- 総合ランキング(RSS)
- キーワード検索(RSS)
例:
- https://b.hatena.ne.jp/hotentry/it.rss
- https://b.hatena.ne.jp/q/AI?sort=hot&mode=rss
- https://b.hatena.ne.jp/q/セキュリティ?sort=hot&mode=rss
取得項目
LLMに渡す前提で、取得する項目を絞ります。
- title
- link
- description
- date
- subject(複数タグ)
- bookmarkcount
- imageurl
- sources(取得元ラベル)
整形処理
複数ソースから取得するため、以下の処理を行います。
- URLベースで重複排除
- ソースラベルの統合
- ブックマーク数が多い方を採用
フィルタリング
ノイズを減らすため、以下の条件で絞ります。
- ブックマーク数 30 未満は除外
ディレクトリ構成
.
├─ .github/
│ └─ workflows/ # 実行設定
│ └─ hatebu.yml
├─ scripts/ # 処理ロジック
│ └─ fetch_hatebu.py
└─ sources/ # 取得データ
└─ hatebu/
└─ latest.md
実装
ここから実際に動く構成を見ていきます。
Pythonスクリプト
scripts/fetch_hatebu.py を作成します。
ここでは、
- RSS取得
- データ整形
- 重複排除
- ソート
- Markdown出力
をまとめて行います。
from pathlib import Path
from datetime import datetime, timezone, timedelta
import feedparser
SOURCES = [
{
"label": "総合",
"url": "https://b.hatena.ne.jp/hotentry/it.rss",
},
{
"label": "AI",
"url": "https://b.hatena.ne.jp/q/AI?sort=hot&mode=rss",
},
{
"label": "セキュリティ",
"url": "https://b.hatena.ne.jp/q/%E3%82%BB%E3%82%AD%E3%83%A5%E3%83%AA%E3%83%86%E3%82%A3?sort=hot&mode=rss",
},
]
OUTPUT_PATH = Path("sources/hatebu/latest.md")
JST = timezone(timedelta(hours=9))
MIN_BOOKMARK = 30
def clean_text(value: str) -> str:
return str(value or "").replace("\n", " ").strip()
def to_int(value, default=0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def get_subjects(entry) -> list[str]:
subjects = []
if "tags" in entry:
subjects.extend(
clean_text(tag.get("term"))
for tag in entry.tags
if tag.get("term")
)
subject = entry.get("dc_subject") or entry.get("subject")
if subject:
if isinstance(subject, list):
subjects.extend(clean_text(s) for s in subject)
else:
subjects.append(clean_text(subject))
return sorted(set(s for s in subjects if s))
def get_unique_key(entry) -> str:
# はてブRSSでは rdf:about が entry.id 側に入ることがあるため優先
return (
entry.get("id")
or entry.get("rdf_about")
or entry.get("link")
or ""
)
def get_bookmark_count(entry) -> int:
return to_int(
entry.get("hatena_bookmarkcount")
or entry.get("bookmarkcount")
or entry.get("hatena_bookmark_count")
)
def normalize_entry(entry, source_label: str) -> dict:
return {
"key": get_unique_key(entry),
"title": clean_text(entry.get("title")),
"link": clean_text(entry.get("link")),
"description": clean_text(
entry.get("description")
or entry.get("summary")
),
"date": clean_text(
entry.get("dc_date")
or entry.get("published")
or entry.get("updated")
),
"subjects": get_subjects(entry),
"bookmarkcount": get_bookmark_count(entry),
"imageurl": clean_text(
entry.get("hatena_imageurl")
or entry.get("imageurl")
),
"sources": [source_label],
}
def main():
entries_by_key = {}
for source in SOURCES:
feed = feedparser.parse(source["url"])
for entry in feed.entries:
item = normalize_entry(entry, source["label"])
key = item["key"] or item["link"]
if not key:
continue
if key in entries_by_key:
existing = entries_by_key[key]
existing["sources"] = sorted(
set(existing["sources"] + item["sources"])
)
# 同一記事でブクマ数が取れた方を優先
if item["bookmarkcount"] > existing["bookmarkcount"]:
existing["bookmarkcount"] = item["bookmarkcount"]
# subject も統合
existing["subjects"] = sorted(
set(existing["subjects"] + item["subjects"])
)
else:
entries_by_key[key] = item
entries = [
e for e in entries_by_key.values()
if e["bookmarkcount"] >= MIN_BOOKMARK
]
entries = sorted(
entries,
key=lambda x: x["bookmarkcount"],
reverse=True,
)
now = datetime.now(JST).strftime("%Y-%m-%d %H:%M:%S JST")
lines = [
"# はてなブックマーク 人気エントリー",
"",
f"取得日時: {now}",
"",
f"取得件数: {len(entries)}",
"",
]
for i, entry in enumerate(entries, start=1):
lines.append(f"## {i}. {entry['title']}")
lines.append("")
lines.append(f"- label: {', '.join(entry['sources'])}")
lines.append(f"- URL: {entry['link']}")
lines.append(f"- ブックマーク数: {entry['bookmarkcount']}")
if entry["date"]:
lines.append(f"- 日付: {entry['date']}")
if entry["subjects"]:
lines.append(f"- タグ: {', '.join(entry['subjects'])}")
if entry["imageurl"]:
lines.append(f"- 画像: {entry['imageurl']}")
if entry["description"]:
lines.append(f"- 説明: {entry['description']}")
lines.append("")
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
OUTPUT_PATH.write_text("\n".join(lines), encoding="utf-8")
if __name__ == "__main__":
main()
GitHub Actions
.github/workflows/hatebu.yml を作成します。
- スケジュール実行
- 手動実行(デバッグ用)
- Python実行
- Markdown更新
name: Fetch Hatena Bookmark Ranking
on:
workflow_dispatch:
schedule:
- cron: "0 22 * * *"
permissions:
contents: write
jobs:
fetch:
runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install feedparser
- run: python scripts/fetch_hatebu.py
- name: Commit markdown
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add sources/hatebu/latest.md
git diff --cached --quiet || git commit -m "Update hatebu ranking"
git push
出力
出力フォーマット
最終的に生成されるファイルは以下の形式になります。
## 1. 記事タイトル
- label: 総合, AI
- URL: ...
- ブックマーク数: 123
- 日付: ...
- タグ: ...
- 画像: ...
- 説明: ...
この形式にすることで、
- 人間が読みやすい
- Claude Code でも扱いやすい
というバランスになります。
LLM前処理としてのメリット
今回の構成は、単なる自動取得ではなく「LLMに渡す前の整形」を目的としています。
取得と判断の分離
役割を分けることで、処理がシンプルになります。
- GitHub Actions → 取得・整形
- Claude Code → 判断・選別
この分離により、LLM側で不要な処理を行わなくて済みます。
トークン消費の削減
HTMLページをそのまま渡す場合、
- ナビゲーション
- レイアウト用のHTML
- 不要なテキスト
なども含まれます。
今回の構成では、必要な情報だけに絞ることで、トークン消費を抑えられます。
処理の安定化
LLMに「ページを解析させる」処理を減らすことで、
- 出力のブレ
- 解析ミス
を抑えやすくなります。
データの再利用
一度整形したMarkdownは、そのまま再利用できます。
- フィルタリング
- 要約
- ネタ出し
など、複数の用途に流用できます。
まとめ
はてなブックマークのランキングをそのまま使うのではなく、
一度整形してから使うことで、LLMとの相性が良くなります。
「取得」と「判断」を分離するだけで、情報収集の効率が大きく変わると感じました。