1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GitHub ActionsでTechFeed RSSを収集してMarkdown化する

1
Posted at

はじめに

前回、Qiita の RSS を GitHub Actions で取得して、Markdown 化する仕組みを作りました。

今回はその続きで、TechFeed を追加します。

TechFeed はカテゴリ単位で RSS を持っているので、興味のある分野ごとに情報を集めやすいです。

今回は、

  • 総合
  • AI / ビッグデータ
  • ネットワーク / セキュリティ

を取得対象にしました。

今回やること

前回と同じように、

  • GitHub Actions
  • Python スクリプト
  • Markdown 出力

の構成で動かします。

追加したのは主にこのあたりです。

  • TechFeed RSS の取得
  • 複数カテゴリ対応
  • 重複エントリーの統合

ディレクトリ構成

前回と同じ構成で、TechFeed 用のスクリプトと workflow を追加します。

.
├── .github
│   └── workflows
│       └── techfeed.yml   # GitHub Actions
├── scripts
│   └── fetch_techfeed.py  # RSS取得スクリプト
└── sources
    └── techfeed
        └── latest.md      # 生成されたMarkdown

RSS 定義

TechFeed はカテゴリごとに RSS URL を持っています。

今回は配列で管理しています。

SOURCES = [
    {
        "label": "総合",
        "url": "https://techfeed.io/feeds/categories/all",
    },
    {
        "label": "AI / ビッグデータ",
        "url": "https://techfeed.io/feeds/categories/AI%20%2F%20BigData",
    },
    {
        "label": "ネットワーク / セキュリティ",
        "url": "https://techfeed.io/feeds/categories/Network%20%2F%20Security",
    },
]

RSS を取得して Markdown 化する

全体のスクリプトです。

  • RSS 取得
  • タグ抽出
  • 重複除去
  • Markdown 出力

までをまとめています。

scripts/fetch_techfeed.py
from pathlib import Path
from datetime import datetime, timezone, timedelta
from html import unescape
import re
import feedparser

SOURCES = [
    {
        "label": "総合",
        "url": "https://techfeed.io/feeds/categories/all",
    },
    {
        "label": "AI / ビッグデータ",
        "url": "https://techfeed.io/feeds/categories/AI%20%2F%20BigData",
    },
    {
        "label": "ネットワーク / セキュリティ",
        "url": "https://techfeed.io/feeds/categories/Network%20%2F%20Security",
    },
]

OUTPUT_PATH = Path("sources/techfeed/latest.md")
JST = timezone(timedelta(hours=9))


def clean_text(value: str) -> str:
    return str(value or "").replace("\n", " ").strip()


def html_to_text(value: str) -> str:
    text = str(value or "")
    text = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE)
    text = re.sub(r"</p\s*>", "\n", text, flags=re.IGNORECASE)
    text = re.sub(r"<[^>]+>", " ", text)
    text = unescape(text)
    text = re.sub(r"\s+", " ", text)
    return text.strip()


def is_ranking_entry(entry) -> bool:
    title = clean_text(entry.get("title"))
    link = clean_text(entry.get("link"))

    if re.match(r"^【\d{1,2}月\d{1,2}日ランキング】", title):
        return True

    if re.match(r"^【\d{1,2}月\d{1,2}週ランキング】", title):
        return True

    if "/selections/daily/" in link or "/selections/weekly/" in link:
        return True

    return False


def get_unique_key(entry) -> str:
    return entry.get("id") or entry.get("link") or entry.get("title") or ""


def normalize_entry(entry, source_label: str) -> dict:
    thumbnail = ""
    media_thumbnail = entry.get("media_thumbnail")

    if media_thumbnail and isinstance(media_thumbnail, list):
        thumbnail = clean_text(media_thumbnail[0].get("url"))

    return {
        "key": get_unique_key(entry),
        "title": clean_text(entry.get("title")),
        "link": clean_text(entry.get("link")),
        "summary": html_to_text(
            entry.get("summary") or entry.get("description")
        ),
        "published": clean_text(
            entry.get("published")
            or entry.get("updated")
            or entry.get("pubDate")
        ),
        "thumbnail": thumbnail,
        "sources": [source_label],
    }


def main():
    entries_by_key = {}

    for source in SOURCES:
        feed = feedparser.parse(source["url"])

        for entry in feed.entries:
            if is_ranking_entry(entry):
                continue

            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 not existing["summary"] and item["summary"]:
                    existing["summary"] = item["summary"]
                if not existing["published"] and item["published"]:
                    existing["published"] = item["published"]
                if not existing["thumbnail"] and item["thumbnail"]:
                    existing["thumbnail"] = item["thumbnail"]
            else:
                entries_by_key[key] = item

    entries = sorted(
        entries_by_key.values(),
        key=lambda x: (x["published"], x["title"]),
        reverse=True,
    )

    now = datetime.now(JST).strftime("%Y-%m-%d %H:%M:%S JST")

    lines = [
        "# TechFeed 注目エントリー",
        "",
        f"取得日時: {now}",
        "",
        f"取得件数: {len(entries)}",
        "",
    ]

    for i, entry in enumerate(entries, start=1):
        lines.append(f"## {i}. {entry['title']}")
        lines.append("")
        lines.append(f"- ラベル: {', '.join(entry['sources'])}")
        lines.append(f"- URL: {entry['link']}")

        if entry["published"]:
            lines.append(f"- 日付: {entry['published']}")

        if entry["thumbnail"]:
            lines.append(f"- サムネイル: {entry['thumbnail']}")

        if entry["summary"]:
            lines.append(f"- 説明: {entry['summary']}")

        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()

重複エントリーを統合する

TechFeed は複数カテゴリに同じ記事が含まれることがあります。

例えば、

  • 総合
  • AI / ビッグデータ

の両方に同じ記事が出ることがあります。

そのままだと Markdown 側でも重複するので、

  • id
  • link
  • title

をキーにして統合しています。

if key in entries_by_key:
    existing = entries_by_key[key]
    existing["sources"] = sorted(
        set(existing["sources"] + item["sources"])
    )

出力結果

出力は Markdown 形式です。

# TechFeed 注目エントリー

取得日時: 2026-05-10 12:00:00 JST

取得件数: 50

## 1. 記事タイトル

- ラベル: AI / ビッグデータ, ネットワーク / セキュリティ, 総合
- URL: https://example.com
- 日付: 2026-05-09T21:02:25.444Z
- 説明: ...

GitHub Actions

workflow は前回とほぼ同じです。

定期実行で Python スクリプトを実行し、生成した Markdown を commit しています。

name: Fetch TechFeed 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_techfeed.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/techfeed/latest.md
          git diff --cached --quiet || git commit -m "chore: update techfeed ranking"
          git push

おわりに

Qiita に加えて、TechFeed も同じ形式で収集できるようになりました。

HTML をそのまま渡すより、Markdown 化しておいたほうが扱いやすいです。

TechFeed はカテゴリ単位で取得できるので、興味のある領域だけを分けて収集しやすいのも便利でした。

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?