1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LLMに渡す前に整える:はてブランキングをMarkdown化する(GitHub Actions)

1
Posted at

はじめに

普段、情報収集にはてなブックマークのランキングなどを使っています。
取得には Claude Code の skill で WebFetch を使い、趣味嗜好に合わせてフィルタリングする、という流れです。

ただ、この方法だとHTMLごと渡す形になるため、対象サイトが増えるとどうしてもトークン消費が大きくなります。

そこで今回は、GitHub Actions で事前にランキングを取得し、必要な情報だけに整形して Markdown として出力する仕組みを作ります。

Markdown化しておくことで、Claude Code が必要な情報を扱いやすくなり、結果としてトークン消費も抑えやすくなります。

ポイントは「取得」と「判断」を分離することです。

全体像

目的

今回やることはシンプルです。

  • はてなブックマークのランキングを定期取得
  • 必要な項目だけ抽出
  • Markdownとして保存
  • LLMに渡す前処理として利用

スクレイピングではなく、「LLMに読ませるデータを作る」という視点で設計します。

構成

構成は以下です。

  • GitHub Actions
    • 定期実行
    • スクリプト実行
  • Pythonスクリプト
    • RSS取得
    • データ整形
  • Markdown
    • 中間データ
  • Claude Code
    • フィルタリング・判断

流れはこうなります。

  1. GitHub Actions がスケジュール実行
  2. RSSを取得
  3. データを整形
  4. 重複排除・ソート
  5. Markdownとして出力
  6. Claude Code がそれを読む

データ取得と整形

取得方針

はてなブックマークには、カテゴリ別のRSSが揃っていないため、以下の方法で取得します。

  • 総合ランキング(RSS)
  • キーワード検索(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出力

をまとめて行います。

scripts/fetch_hatebu.py
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更新
.github/workflows/hatebu.yml
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との相性が良くなります。

「取得」と「判断」を分離するだけで、情報収集の効率が大きく変わると感じました。

1
2
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
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?