はじめに
前回、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 出力
までをまとめています。
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 はカテゴリ単位で取得できるので、興味のある領域だけを分けて収集しやすいのも便利でした。