複数サイトの新着情報を GitHub Actions で定期取得し、GitHub Pages で一覧表示するフィード集約ツール。 表示側でフィルタ(mute / unmute / forceMute)をかけられる。hateb-tycoon と同じ構成。
- 取得: Python(標準ライブラリのみ、3.9 以上)。Actions が RSS / Atom を取得し、
data/feed.json/data/feed.xml/data/meta.jsonを main にコミット - 表示: 素の JS の静的サイト(ビルド工程なし)。フィルタ条件は
localStorageに保存し、Secret Gist で端末間同期 - 公開前提のため、秘密情報は扱わない
設計の詳細は docs/requirements.md を参照。
feed-tycoon/
├── sources.json # 取得元の初期値・フォールバック(id / name / type / url / tags)。通常は Gist で管理
├── index.html, css/, js/ # ビューア(filters.js: 判定, rules-io.js: CSV, gist.js / filter-sync.js: Gist同期, sources-sync.js: 取得元一覧, visited.js: 既読, hatena-api.js: コメント取得, app.js: 画面)
├── favicon.png, apple-touch-icon.png # ファビコン(64px)と iOS ホーム画面用アイコン(180px)
├── service-worker.js # オフライン対応(アプリ本体と data/*.json をキャッシュ)
├── data/ # Actions が更新する生成物(feed.json, feed.xml, meta.json)
├── scripts/
│ ├── fetch_feeds.py # 取得・蓄積スクリプト
│ ├── feed-tycoon-dispatch.sh # ローカルMacから workflow_dispatch を叩く(正本)
│ └── com.gosyujin.feed-tycoon-dispatch.plist # launchd 用テンプレート
├── tests/ # 取得スクリプトの単体テスト
└── .github/workflows/
├── feed-sync.yml # 取得して data/ をコミットし、deploy-pages.yml を起動
└── deploy-pages.yml # main への push で Pages にデプロイ
- 取得元(RSS 2.0 / RSS 1.0 / Atom は自動判別)は、設定画面(⚙)の「取得元」で URL を追加・削除する。
nameはsource、tagsはtagフィルタの対象になる。一覧の保存先と初回設定は「技術的な注意点」の取得元の項を参照。 - ローカルで取得:
python3 scripts/fetch_feeds.py - ローカルで確認:
python3 -m http.serverで配信してindex.htmlを開く(file://ではfetchが使えない)。ブラウザやサーバーが静的ファイルをキャッシュすることがあるので、古い表示が出たら別ポートで起動し直す。 - テスト:
python3 -m unittest discover -s tests - 「n users」は、自前のブックマークページ(はてなブックマークのコメント一覧)へ移動する。一覧に戻るとスクロール位置を復元する。ブックマークページ下部の「前の記事へ / 次の記事へ」は、一覧(取得元タブ・検索・既読非表示を適用済み)の並びで前後の記事へ移動する。
- ヘッダーの ⬇️ で、オフライン用キャッシュ(一覧の上位 n 件のコメント。件数は設定画面の値)を今すぐ取得できる。進捗はポップオーバーに出る。
- フィルタは右上の ⚙ から。記事カードの「×」でその記事だけ(URL 完全一致)をミュート、取得元名・ドメインのクリックでそのルール追加を開ける。
- フィードは直近の数十件しか返さないため、取得結果は「蓄積」する。記事ごとに
firstSeenAt/lastSeenAtを記録し、firstSeenAtの新しい順に並べ、14 日・1 ソース 100 件まで保持する。取得直後に大量の過去分が手に入るわけではない。 - 並びは
firstSeenAt基準、同時刻ならpublishedAt。新規取り込み時にpublishedAtが保持期間より古い記事は捨てる。 - ETag / Last-Modified を
data/meta.jsonに保存し、次回は条件付きで取得する(304 なら既存を維持)。 - 取得に失敗したソースは既存データを維持し、
meta.jsonにok: falseを記録する(画面のフッターに表示)。0 件だった場合は Actions のログに警告が出る。全ソース失敗時のみ Actions を失敗させる。 - 取得元一覧は Gist(
feed-tycoon-sources.json)で管理する。 フィルタと同じ Gist に置き、設定画面の「取得元」が読み書きする(書き込みにはトークンが必要)。取得は Actions が行うので、Actions の変数FEED_GIST_ID(Settings > Secrets and variables > Actions > Variables)に同じ Gist ID を登録しておく。リポジトリは public なので、ID をファイルには書かない。fetch_feeds.pyの読み込み順は、Gist > 前回 Gist から読めた一覧(data/sources-resolved.json)>sources.json。Gist が一時的に読めなくても、追加したフィードとその記事を落とさないため。Gist にファイルがまだ無い間はsources.jsonを使う(設定画面もこの一覧を初期値として出し、最初の追加・削除で Gist に書き込む)。data/は公開リポジトリにコミットされるため、トークン付きURL(?token=…など)が漏れないようにしている。meta.jsonのurlはクエリ・ユーザー情報を落とした形(https://…/feed.xml?…)で書き、sources-resolved.jsonではクエリ・ユーザー情報を含む取得元のURLを伏せ、redacted: trueを付けて保存する(Gist が読めない間は、その取得元は取得せず記事と状態だけ維持する)。設定画面の取得元一覧でも同様にクエリを伏せて表示する(SourcesSync.displayUrl)。Gist には URL がそのまま入る。- Gist の読み込みは、まず GitHub API、
403(レート制限。Actions の共有IPは未認証だと1時間60回)・429・5xxのときはgist.githubusercontent.comの raw URL(オーナーは Actions のGITHUB_REPOSITORY_OWNER)に切り替える。 - フィルタ CSV と違い和集合ではなく、保存のたびに Gist を読み直して変更し上書きする(削除も同期するため)。同時編集は後勝ち。
- 反映は次回の取得から(最大 30 分ほど)。画面には
meta.jsonにまだ無いフィードを「反映待ち」と出す。タブは記事から動的に作るので、増減に自動で追随する(追加直後は記事が入るまでタブが出ない)。取得元を消すとその記事も次回の更新で消える。 - 取得側は http(s) 以外、localhost・IP アドレス・
.local宛の URL を拒否する(normalize_source。ビューア側のparseFeedUrlも同条件)。id は URL から自動採番。名前や URL が変わったソースは ETag を捨てて取り直す。
- フィルタの種別(
title/domain/url/source/tag/description)はjs/filters.jsのTYPE_LABELSが唯一の定義。増やすときはmatchRuleのcaseも足す。UI・CSV の検証は自動で追随する。 - CSV(
type,value)は hateb-tycoon と共通。自アプリが持たないtypeの行は、エラーにせず無視する(取り込み結果に「未対応の種別N件は無視」と出す)。value が空・列数不正の行があると、その CSV は全体を取り込まない。 - フィルタはシークレット Gist で端末間・hateb-tycoon と共有する(仕様は hateb-tycoon の
docs/gist-sync-spec.md)。設定するのは Gist ID(URL でも可)だけ。1 つの Gist にtycoon-filter-<kind>.csv(mute / unmute / forceMute、アプリ名は含めない)を置く。実装はjs/gist.js(API・トークン)とjs/filter-sync.js(同期)。- 「保存して同期」= リモートを取り込み(マージ)→ トークンがあれば、差分のあるファイルだけ和集合で上書き。削除は同期されない(消すときは Gist 側の CSV を編集)。壊れた CSV はその種類を取り込まず、上書きもしない。
- 共有ファイルのため、リモートにあった未対応 type の行は上書き時にそのまま書き戻す(
ignoredRules)。 - 起動時は取り込みのみ(保存済み ETag で
If-None-Match、304 なら何もしない。最大 3 秒待ち、失敗しても通常起動)。読み取りにトークンは不要で、トークンが空の端末は読み取り専用。 - トークンは classic PAT の
gistスコープ(fine-grained は Gist 非対応)。設定画面の独立欄に入れ、localStorage['feed-tycoon:gistToken']に保存する。 - 旧仕様(種類ごとの Raw URL 登録と起動時の自動取り込み)は廃止済み。旧 URL の内容は、新しい Gist へ「保存して同期」で積み上げ直す。
- はてなブックマークへのリンクは
<a href>にしてはいけない。 iPhone の Safari では、hrefがb.hatena.ne.jp/entry/...の<a>がdisplay: none(0×0)になり、画面に出ない。コンテンツブロッカー(広告ブロック用フィルター)が、はてなブックマークのボタン類を隠すルールに当たっていると考えられる。Mac の Chrome では表示されるため気づきにくい。クラス名(users→bm-count)を変えても直らず、別の URL の<a>や<button>は同じ環境で表示された。そのため、一覧の「n users」は自前のブックマークページへの内部リンク(#/entry?url=…)にし、ブックマークページ内の「はてなブックマークページ →」は<button>にしてクリックでwindow.open()している(js/app.jsのrenderCard/renderEntryView)。副作用として、後者は長押し・右クリックによる「リンクをコピー」などが使えない。 - 上の切り分けは、
?debug=1で診断行を出す一時的な変更(JS の版、データの件数、要素のdisplay、<a>/<button>のプローブ)で行った(コミット1682b47参照)。同様に「要素はあるのに見えない」表示差が出たときは、getComputedStyleでdisplayとサイズを端末側から取ると原因を絞れる。 - ブックマークページのコメントは、
https://b.hatena.ne.jp/entry/jsonlite/?url=…を JSONP(<script>挿入)で取得する(js/hatena-api.js)。callback 名は呼び出しごとにランダムでなければならない(固定名にするとはてな API が無応答になる。hateb-tycoon で実験確認済み)。取得に成功するたび解析済みデータをlocalStorageにキャッシュし、オフライン時などはそちらへフォールバックする(最大 300 件)。コメント側のフィルタはtitle/domain/user/commentで評価し、urlは含めない(含めると URL ルールが全コメントに当たる)。 - 自動継ぎ足しは
IntersectionObserver(rootMargin 600px)で末尾の sentinel を監視する。「交差状態が変わった時」しか発火しないため、継ぎ足しのたびにunobserve→observeで再判定させている。この再監視を外すと、画面が縦に長いときに継ぎ足しが止まる。 - オフライン対応は
service-worker.js(hateb-tycoon が元)。アプリ本体とdata/*.jsonを先読みし、「まずネットワーク、失敗したらキャッシュ」で返す。handleNavigate()は実 URL ではなく固定キー'index.html'で読み書きする(実 URL に戻すと、完全終了後に機内モードで起動したときアプリ全体が落ちる)。data/*.jsonはビューアが CDN 回避のため?t=<時刻>を付けて取得するので、キャッシュキーからクエリを外している(外さないと開くたびにキャッシュが増える)。CACHE_VERSIONはデプロイ時(deploy-pages.yml)に__BUILD_SHA__が SHA に置換される。設定画面の「オフライン用キャッシュ」で、一覧の上位 n 件のコメントを事前取得できる。 - Service Worker は、サンドボックス化された検証用ブラウザでは
register()自体が失敗し、動作確認できない。SW の変更は本番オリジン(https://note.gosyujin.com/feed-tycoon/)で、caches.keys()/caches.open()などをコンソールから叩いて確認する。 - フッターは
ビルドSHA (日時) / 次回更新: HH:MM頃の形式で、次回更新は更新ワークフロー(feed-sync.yml)のページへのリンク。次回更新はdata/meta.jsonのnextEstimate(取得時刻 + 30 分)。取得に失敗したソースがあると、末尾に「取得失敗: …」を出す。 - 対象外: X(Twitter)、JS 描画が必要なサイト、push 通知。
html型ソースは未実装。
GitHub Actions の schedule は発火が不定期になりやすい。hateb-tycoon と同様に、ローカル Mac の launchd から workflow_dispatch を叩いて feed-sync.yml を起動する。schedule も補助として残してある。
- Fine-grained PAT を作る。対象は
gosyujin/feed-tycoonのみ、権限は Actions: Read and write だけ(Contents 権限は不要で、付けると 403 になった実績が hateb-tycoon にある)。 - Keychain に保存する(サービス名
feed-tycoon-dispatch-for-local-token)。security add-generic-password -a "$USER" -s feed-tycoon-dispatch-for-local-token -w - Dropbox 配下のスクリプトを launchd から直接実行すると TCC で
Operation not permittedになるため、実体を非保護パスにコピーする。正本(scripts/feed-tycoon-dispatch.sh)を変更したらコピーし直す。mkdir -p ~/scripts && cp scripts/feed-tycoon-dispatch.sh ~/scripts/ && chmod +x ~/scripts/feed-tycoon-dispatch.sh
- plist をコピーして登録する(実行時刻は plist の
StartCalendarIntervalで調整)。再登録はcp scripts/com.gosyujin.feed-tycoon-dispatch.plist ~/Library/LaunchAgents/ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.gosyujin.feed-tycoon-dispatch.plist
launchctl bootout gui/$(id -u)/com.gosyujin.feed-tycoon-dispatchの後にbootstrap。 - ログは
~/Library/Logs/feed-tycoon-dispatch.log。
トークンはリポジトリに一切含めない(リポジトリは public)。