iArt.ai
AI motion graphics software — the After Effects alternative anyone can use. Create animations and explainer videos from a text prompt in minutes.
AI要約つきの個人用RSSリーダー
Translation pending
MediaTypeScript · JavaScriptGitHub @shin1007OnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
AI要約つきの個人用RSSリーダー。Next.js 16 / Supabase / Gemini API
Translation pending
Taken from the repository README
Loading the full README. The saved excerpt is shown below.
AI要約つきの個人用RSSリーダー。大量の記事を素早く捌き、深掘りしたい記事は NotebookLM に投げてすぐ音声にできる状態まで用意する。 • PC: フォルダ / 記事リスト / 本文 の三ペイン。設定などの画面でもサイドバーは残る • スマホ: 単カラム+下部タブ、左右スワイプで既読・スター • 記事は自動で本文を取得し、Gemini で要点3行とタグを付ける • 本文は画像・動画・リンクごと出す(消毒済みHTML) • 「NotebookLM へ」で複数記事を1つの Markdown にまとめて渡す。Google Drive にも置ける • 毎朝のダイジェストを自動生成し、できたら Web Push で知らせる • アプリ内だけで音声とスライドも作れる(/listen・/watch)。 2人の対話か1人の語りを選べ、MP3 で手元に落とせるRead the full README ↗
AI要約つきの個人用RSSリーダー。大量の記事を素早く捌き、深掘りしたい記事は NotebookLM に投げてすぐ音声にできる状態まで用意する。
/listen・/watch)。
2人の対話か1人の語りを選べ、MP3 で手元に落とせる| 用途 | 無料枠 | |
|---|---|---|
| Supabase | DB / 認証 / スケジューラ | あり |
| Vercel | ホスティング | あり(Hobby) |
| Gemini API | 要約 | あり |
docs/status.mddocs/owner-todo.mddocs/quotas.mddocs/site-compat.mddocs/tts-options.md.env.local に SUPABASE_DB_URL(ダッシュボード上部の
「Connect」→ Session pooler の URI)と SUPABASE_DB_PASSWORD を入れて
npm run db:migrate。パスワードは記号もそのままでよく、URL 側は
[YOUR-PASSWORD] のままで構わない(スクリプトがエンコードして埋める)。
ダッシュボードの SQL Editor に手で貼ってもよい(supabase/migrations/ を番号順に)OWNER_USER_ID.env.example を .env.local にコピーして埋める。
CRON_SECRET は適当な長いランダム文字列でよい(openssl rand -hex 32 など)。
キーの名前は Supabase ダッシュボードの Project Settings > API Keys の表記に合わせている。
| ダッシュボードの表記 | 環境変数 | 旧称 |
|---|---|---|
Publishable key(sb_publishable_…) | NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY | anon key |
Secret key(sb_secret_…) | SUPABASE_SECRET_KEY | service_role key |
.env ではなく .env.local に書くこと。両方あると .env.local が優先されるので、
片方を直したつもりでもう片方が効き続ける事故が起きる。
npm install
npm run dev
http://localhost:3000 → ログイン → 設定画面から OPML を取り込むかフィードを追加。
開発中は手で叩ける:
curl -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/cron/poll
curl -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/jobs/run
毎朝ダイジェストも同じように叩ける。dry=1 は何も書かずに選抜結果だけ返すので、
「今日の8件」がどう選ばれるかを確かめてから本番で作れる:
curl -H "Authorization: Bearer $CRON_SECRET" 'http://localhost:3000/api/cron/digest?dry=1'
curl -H "Authorization: Bearer $CRON_SECRET" 'http://localhost:3000/api/cron/digest?force=1'
force=1 は生成時刻を待たずに作るが、その日のぶんが既にあれば作らない。
書き出した記事には exported_at が付いて選抜から外れるため、作り直しのつもりで
叩くと中身の違う2本目ができてしまう。本当に作り直すときは digests の当日行を
消してから叩くこと。
Vercel にデプロイして URL が決まったら、supabase/scheduler.sql の
__APP_URL__ と __CRON_SECRET__ を置き換えて Supabase の SQL Editor で流す。
Vercel Hobby の cron は1日1回までなので、1時間毎の巡回と5分毎のワーカーは
Supabase の pg_cron 側に持たせている。毎朝ダイジェストも同様で、Vercel Cron は
実行時刻が±59分ずれる(6時のダイジェストが7時前にできる)ため pg_cron に寄せた。
Supabase を東京以外に作ったときは vercel.json の regions も合わせること。
既定のままだと関数が米国東部で動き、クエリのたびに太平洋を往復する(後述)。
設定画面の入力欄は、次のどれを入れても動く。フィードのURLを知っている必要はない。
| 入れるもの | 動き |
|---|---|
| サイト名(「ナゾロジー」など) | Feedly の検索で探す |
| サイトのURL | ページの <link rel="alternate"> を見る。無ければ /feed などを当たる |
| フィードのURL | そのまま使う |
URL に見えるかどうかで振り分ける(空白があれば名前、example.com の形ならURL)。
URL として辿れなかった場合も、最後に名前として検索し直す。
見つかったものは登録する前に中身(タイトル・最新3件・購読者数)を出す。 登録した瞬間に記事も取り込むので、次の巡回を待たなくていい。
名前で探すときは打った語が Feedly に送られる(URL を貼った場合はそのサイトを 直接見に行くだけ)。検索の索引には死んだフィードが3件に1件ほど混じるので、 上位を並行で読みに行って生きているものだけを出す。応答が遅いものは 「死んでいる」と区別して、中身なしのまま候補に残す(捨てると拾えなくなるため)。
フィードの死に方は2通りあって、見え方が違う。
| 型 | 症状 | 見つけ方 |
|---|---|---|
| 取得に失敗する | 404・DNS切れ・タイムアウト | error_count が積み上がる |
| 更新が止まる | 取得は 200 で成功し続ける | feeds.last_article_at を見るしかない |
後者が厄介で、失敗回数は 0 のままなので一覧を眺めても分からない (実データでも MDN Blog が60日新着なしだった)。そのために 「最後に新しい記事が入った時刻」をフィードに持たせてある(0013、取り込み時に更新)。
フィードのURLは変わる。ドメイン移転や CMS の入れ替えで配信先が動く。
巡回はリダイレクトを自分で追って、恒久的なもの(301 / 308)だけ覚え直す。
redirect: 'follow' に任せると読めはするが移転先を覚えないので、古いURLを叩き続け、
それが消えた日に「取得に失敗するフィード」として死ぬ。移転していただけなのに。
一時的なもの(302 / 307)は追うだけで覚えない(相手が一時的と言っているものを
固定すると、戻ったときに追随できない)。
実際に NHK が www.nhk.or.jp から news.web.nhk へ移転していて、この仕組みで
自動的に付け替わった。
実際にはこちらのほうが多い。CMS を入れ替えて古いパスがいきなり 404 になる、 フィードのパスだけ変わる、旧パスが 200 を返すが中身が HTML になっている—— どれもリダイレクトが無いので追えない。
残っている手がかりはサイトのURLだけなので、巡回が自動でそこから探し直す。 3回続けて失敗した時点で1回試し、その後は10回ごと(相手が直ることもあるので 諦めきらない程度に)。1〜2回の失敗では動かない——落ちているだけの相手に 余計なリクエストを重ねないため。
設定画面の「気になるフィード」からは**手で「探し直す」**こともできる。 中身は自動のものと同じ。
どちらの経路でも feeds.url を差し替えるだけなので、購読・フォルダ・既読・スターは
フィードの id に紐づいていて何も失わない(購読をやめて登録し直すと全部消える)。
ドメインごと消えていれば探しようが無いので、「壊れている」として設定画面に出る。
対策は3か所に入れてある:
判定は lib/feeds/health.ts の純粋な関数(閾値: 失敗3回で不調、10回で壊れている、
新着60日で更新なし。登録から14日は様子を見る)。数回の失敗で騒がないのは、
相手の一時的な不調が珍しくないため。
止まったフィードを放置しても実害は小さい(巡回のたびに条件付きGETが1回走るだけ)。 消すかどうかは気になったときに決めればいい。
一覧が「これから読むもの」を捌く場所なのに対して、/library は「前に読んだあれ」を
掘り返す場所。既読も込みで全部を対象に、タグ・スター・書き出し済み・期間で絞れる。
絞り込みは全部 URL に載るので、よく使う組み合わせはブックマークできる。
検索は既定でタイトルだけを見る。「本文も探す」を入れると本文まで見る。 どちらも PGroonga の索引が効く(0015)。
もともと日本語の検索は弱かった。simple 辞書の tsvector は空白で区切るだけなので
日本語の文が丸ごと1語になり、0001 で索引を張ったまま一度も使えていない。
本文の ilike は索引が無く順スキャンだった。PGroonga は日本語の索引を張れるので、
「不整脈」のように文の途中にある語も索引で引ける。
索引を張るだけで既存の ilike が 80.8ms → 9.9ms(8.2倍)になった(記事843件で実測)。
クエリの書き換えは要らない。PGroonga は ilike も肩代わりするため。
さらに速くしたいときは PGroonga の &@~ 演算子を使うと 0.39ms(さらに25倍)になるが、
PostgREST から演算子を呼べないので RPC を立てて絞り込みを SQL に書き直す必要がある。
クエリ経路が二重になるので、検索が体感で遅くなってから考えればいい。
articles_title_trgm_idx と articles_fts_idx は PGroonga が肩代わりするので落とした
(プランナが選ばなくなり、外しても速度は同じだった)。articles の総容量は 6.5MB → 4.5MB。
スマホのホーム画面に追加すると、URLバー無しの単独アプリとして起動する。
マニフェストは src/app/manifest.ts、サービスワーカーは public/sw.js。
サービスワーカーはほとんど何もキャッシュしない。記事一覧も本文もログイン済みの
動的なページで、既読・スターの状態が刻々と変わるので、HTML や API の応答を握ると
「昨日の未読一覧」を見せる事故になる。やるのは圏外での画面遷移に offline.html を
出すことと、Web Push の受け口だけ。開発中は登録しない(Turbopack の差し替えと
噛み合わず、直したものが出てこない原因になる)。
アイコンは画像ライブラリを足さずに生成している:
npm run icons # public/icon-*.png を作り直す
生成物は git に入れてある。ビルドのたびに作る必要が無いのと、手元に無いと
本番でアイコンだけ欠けるため。図柄や色を変えるときは scripts/make-icons.mjs を直す。
NotebookLM に投げる経路(書き出し)はそのまま残しつつ、アプリの中だけで完結する 音声も作れる。記事の「音声にする」か、書き出し一覧の「アプリ内で音声にする」から。
script → 2話者の対話台本とスライドを1回の生成でまとめて作る
(分けて2回呼ぶと切れ目が合わず、同期再生が成立しない)
tts → スライド1枚ぶんずつ合成 → MP3 → Storage
話し方は2人の対話と1人の語りから選べる(設定画面)。対話は NotebookLM の
音声概要と同じ形で耳に入りやすく、1人の語りは相槌が無いぶん同じ中身でも短い。
モードは media にも焼く。設定を変えても作成済みの音声の素性は変わらない
(台本が生成時の話者数に縛られているので、後から声だけ替えることはできない)。
サーバー上の音声は14日で消える(settings.media_retention_days、設定画面から変えられる)。残したいものは
「聴く」か再生画面の「保存」から MP3 で落とせる(/api/media/[id]/download)。
セグメントは独立した MP3 なので連結するだけでよく、再エンコードは要らない。
声は聴いて選ぶ。
npm run voices # samples/voices/*.wav に6声ぶん出す
気に入ったものを GEMINI_TTS_VOICE_A(1人語りと対話の進行役)/ _B(聞き手)に入れる。
毎分の上限があるので続けて叩くと途中から 429 になる(20秒空ければ通る)。
音声はセグメント(クリップ)単位で作る。 切れ目がそのままスライドの 切り替わりになるので、再生側は時刻からスライドを逆算しなくてよい。TTS の1回も 短く済むので、失敗しても安く作り直せるし、途中から聴くのも自然にできる。 長いスライドは700字ごとにさらに割る(1枚しかスライドが返らないことがあるため)。
mp4 は作らない。スライドは HTML/CSS で描くので、サーバー側の合成が要らず
スマホでも即出る。ロック画面やイヤホンからの操作は MediaSession で効く。
容量: TTS が返すのは生PCM(24kHz モノラル)で1秒48KB。64kbps の MP3 にすると
約6分の1で、実測 473KB/分(10分で4.6MB)。Storage の無料枠1GB に約220本入る。
それでも溜まるので、settings.media_retention_days(既定14日)を過ぎたものは
/api/cron/purge が消す。行だけでなく Storage のファイルも消す必要があるので、
これだけ SQL ではなくルートにある。
設定画面に直近7日の呼び出し回数とトークンが出る。無料枠の上限はモデルごとの 1日あたり回数(RPD)で決まるので、フィードを増やしたらここが伸びていないか見る。 失敗が並んでいるときは上限に当たっている可能性が高い(429 でジョブがバックオフし、 要約が静かに遅れる)。
無料枠と上限は docs/quotas.md にまとめてある (何が先に詰まるか、詰まったらどこをいじるか、詰まりの見分けかた)。
記録は日×モデルの集計(ai_usage)。呼び出しごとに行を作ると掃除が要るため。
日付の区切りは日本時間なので、Google 側のリセット(太平洋時間)とは半日ほどずれる。
失敗した呼び出しも RPD を1回ぶん食うので、failures として別に数えている。
書き出した Markdown を Google Docs として Drive に置ける。NotebookLM はドライブ上の ファイルを直接ソースに選べるので、落として上げ直す手間が2つ減る。
設定画面の「Google Drive に接続」から繋ぐ。要求するスコープは drive.file だけで、
このアプリが作ったファイルにしか触れない(既存のドライブの中身は読まない)。
置き先は Drive 上の RSSTube フォルダで、無ければ自分で作る。
OAuth クライアントは設定画面から入れる(0033)。Google Cloud Console で
OAuth クライアント(ウェブアプリケーション)を作り、その ID とシークレットを
設定画面の「Google の認証情報」に入れる。アプリ全体で1つあれば足りる——
ユーザーごとに作るものではなく、各ユーザーは「接続」を押すだけ。
環境変数(GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET)も読むが、設定画面の値が
優先される。環境変数はデプロイし直さないと変えられないので、そちらを主にはしない。
**戻り先(redirect_uri)は設定しない。**いま開いている URL から組み立てる
(redirectUriFor)。以前は GOOGLE_REDIRECT_URI という環境変数だったが、手元と本番で
別々に要る値なので、.env.local の localhost をそのまま本番へ写すと、同意画面まで
行ってから Google 側で redirect_uri_mismatch になる(こちらには何も戻ってこない)。
Google Cloud Console の「承認済みのリダイレクト URI」には
http://localhost:3000/api/auth/google/callback と本番のURLの両方を登録しておくこと
(登録すべき文字列は設定画面にそのまま出している)。
シークレットは app_config に置く。このテーブルにも RLS ポリシーは無いので、
Secret キー以外からは読めない。画面へも返さない(設定済みかどうかだけ出す)。
バックアップにも入れていない(scripts/db-tables.mjs の注記)。
リフレッシュトークンは google_accounts に置く。このテーブルには RLS の
ポリシーを1つも作っていないので、Secret キー以外からは読めも書けもしない
(本人にも API 越しには見せない)。接続しているかどうかはサーバー側で確かめて画面に返す。
朝のダイジェストができたときに端末へ通知する。アプリを開いていなくても届くので、 これが「起きたら聴く」の起点になる。
npm run vapid # VAPID 鍵を作る。出た3行を .env.local に貼る
本番では Vercel の環境変数にも同じ3つ(NEXT_PUBLIC_VAPID_PUBLIC_KEY /
VAPID_PRIVATE_KEY / VAPID_SUBJECT)を入れる。鍵を作り直すと、登録済みの端末には
二度と届かなくなる(購読が公開鍵に紐づくため)。作り直したときは
push_subscriptions を空にして、端末ごとに登録し直すこと。
設定画面の「通知をオンにする」で端末を登録する。端末ごとに1回ずつ要る。 登録できたら「テスト送信」で確かめられる(通知は届かないことが分かりにくいので、 朝を待たずに試せるようにしてある)。
iPhone / iPad は、Safari のタブで開いている間は通知を登録できない。 共有メニューから「ホーム画面に追加」して、そこから開いたときだけ購読できる。
使えなくなった購読先(ブラウザの再インストールや通知の拒否)は、送信時に 404/410 が 返った時点で自動的に消える。放っておくと毎朝失敗し続けるため。
npm test # 1回だけ実行
npm run test:watch
npm run typecheck # next typegen + tsc --noEmit
PageProps / LayoutProps は next typegen が生成する global 型なので、
素の tsc --noEmit は clone 直後だと落ちる。npm run typecheck を使うこと。
PR を作ると GitHub Actions で鍵の混入チェック・型・Lint・テスト・ビルドが回る
(.github/workflows/ci.yml)。
npm run check:secrets
.env.example は追跡対象なので、ここに実値を書くと Public リポジトリに出る。
sb_secret_… や AQ.… のような鍵の形を追跡ファイル全体から探して、
見つかれば CI を落とす(scripts/check-secrets.sh)。鍵は .env.local に置くこと。
外部依存のない純関数だけを対象にしている(URL正規化・OPML の読み書き・ NotebookLM 用 Markdown の生成)。特に URL 正規化は記事の重複判定キーそのものなので、 ここが変わると既読の記事が未読で再登場する。
npm run samples # samples/ に音声(MP3)と書き出し(Markdown)を出す
samples/ は .gitignore 済み。ダイジェストの Markdown には他サイトの記事本文が
丸ごと入るので、公開リポジトリに置くと再配布になる。音声も書き出しも
DB と Storage から何度でも作り直せるので、追跡する利点が無い。
npm run db:backup # backups/<日時>.json に保存
npm run db:restore -- backups/<日時>.json # 消えた行を埋め戻す
npm run db:restore -- backups/<日時>.json --replace # 全部消してから戻す
Supabase の無料プランには自動バックアップも PITR も無い。記事と要約は消えても 巡回し直せばよいが、購読一覧・フォルダ構成・スター・書き出し履歴は 作り直しが効かないので、たまに走らせておくこと。
**テーブルを足したら scripts/db-tables.mjs にも足すこと。**忘れても誰も気づかない。
実際、0005 で購読を subscriptions に切り出したときに入れ忘れていて、
「購読一覧は作り直しが効かないから取っておく」と書いてあるファイルがその購読一覧を
取っていなかった(復元してもフィードだけあって購読ゼロ=何も読めない)。
音声の実体(Storage の mp3)は取れない。media / media_segments は行だけ戻るので、
ファイルが失われていれば音声化し直すことになる(記事と要約が残っていれば作れる)。
スキーマは supabase/migrations/ が git にあるので取らない。落とすのはデータだけで、
戻すときは先に npm run db:migrate を済ませておく。jobs は巡回のたびに作られる
一時的なキューなので対象外(戻すと古い仕事が動き出す)。
backups/ は .gitignore 済み。購読内容が入るので追跡してはいけない。10世代まで保持する。
pg_cron(1時間毎) → /api/cron/poll
フィード巡回(ETag/Last-Modified で条件付きGET)→ articles に追加
→ jobs に extract を積む
pg_cron(5分毎) → /api/jobs/run
extract 記事URLを取得して Readability で本文抽出
失敗したら RSS の抜粋にフォールバック(画面上で区別表示)
summarize 複数記事を1リクエストにまとめて Gemini へ
→ 要点3行 / タグ
pg_cron(1時間毎) → /api/cron/digest
設定した時刻(日本時間)の回だけ、過去24時間の未読から新しい順に選抜
→ NotebookLM 用の Markdown を1本作って /exports に置く
ダイジェストの選抜はフィードごとに上限を設けてあり、更新の速いフィードだけで
埋まらないようにしている。上限はその日に記事があったフィードの数で枠を割った数
(ceil(件数 / フィード数))で、静かな日は緩く、賑やかな日は厳しくなる。
それでも枠が余ったら上限を外して新しい順に埋める。
上限をフォルダではなくフィードに掛けているのは、フォルダ分けをするかどうかが 読む人の趣味だから。分けても分けなくても朝のまとめの中身は同じであるべきで、 実測でも偏りはフィード単位で起きていた(ある日の8件のうち5件が1フィードから)。
重要度スコアは廃止した(0037)。同じ記事でも職業や立場が違えば重さは違うので、
読み手との関係で決まるものを記事の側に1つの数値として持たせたのが誤りだった。
選抜は新しい順、一覧の並びも新着順だけになっている。
無料枠のレート制限に当たらないよう、1回の実行で処理する件数を絞ってある。
429 が返ったジョブは指数バックオフで再キューされる(fail_job)。
| キー | 動作 |
|---|---|
j / k | 次 / 前の記事 |
o / Enter | 開く |
m | 既読・未読を切り替え |
s | スター |
v | 元記事を新しいタブで開く |
Shift+A | 表示中をすべて既読 |
購読一覧は設定画面の「書き出す」から OPML で保存できる(/api/opml)。
110サイト(日本の省庁20・自治体20・海外政府20・人気サイト50)を実際に
extractArticle に通して調べた。90件で本文が取れ、取れなかったのは9件。
取れない9件はすべて 403 / 401 / 429 で、本文が読めないのではなく接続を拒否される。 ブラウザと同じ User-Agent にしても結果は変わらないので、UA ではなく IP か TLS 指紋で弾かれている。UA を偽装しても意味が無いので正直な UA を名乗る。
Shift_JIS・EUC-JP のサイトは問題なく読める(総務省・ITmedia・ライブドアニュース)。 詳細は docs/site-compat.md。
関数の実行リージョンを Supabase と揃えること。 vercel.json の
"regions": ["hnd1"] がそれで、消すと既定の iad1(ワシントン)に戻る。
Supabase は東京にあるので、関数が米国東部で動くとクエリ1回ごとに太平洋を往復する。 ページ1枚に4〜5回のクエリと認証の検証が要るため、これがそのまま積み上がる。
実測では、クエリ自体の実行時間は Execution Time: 0.354ms(未読一覧60件)に対して
往復は33ms。遅さの原因はクエリの重さではなく往復の回数と距離なので、
速くしたいときはまず「何回 Supabase を叩いているか」を数えるとよい。
認証の検証をローカルに寄せるのは、いまは見送っている。
proxy は毎リクエスト auth.getUser() で Supabase に問い合わせる。署名鍵は ES256
(非対称)で JWKS も出ているので getClaims() によるローカル検証に替えられるが、
測ったら割に合わなかった:
| 経路 | 中央値 |
|---|---|
proxy を通らない(/api/debug/*) | 53ms |
| proxy を通る・未ログイン(認証の往復なし) | 129ms |
| proxy を通る・ログイン済み(認証+ページのクエリ) | 416ms |
認証1往復ぶんは数十msで、全体を支配していない。ローカル検証にすると ログアウトや失効が即座に効かなくなる(アクセストークンの寿命ぶん、最大1時間は 通ってしまう)。その代償に見合う速さではない。ページの表示が数百ms単位で 気になるようになったら、まず「何回 Supabase を叩いているか」を数え直すこと。
同じ理由で、未読件数は SQL 側で数える(unread_counts()、0020)。
以前は未読の記事を最大5000行取ってきて JS で数えており、実測1171行を運んで
18個の数字にしていた。サイドバーは全ページに出るので、常時かかる費用だった。