iArt.ai
AI 모션 그래픽 소프트웨어, 누구나 사용할 수 있는 After Effects 대안, 텍스트 프롬프트로 몇 분 안에 애니메이션·설명 영상 제작
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기AI要約つきの個人用RSSリーダー。Next.js 16 / Supabase / Gemini API
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
AI要約つきの個人用RSSリーダー。大量の記事を素早く捌き、深掘りしたい記事は NotebookLM に投げてすぐ音声にできる状態まで用意する。 • PC: フォルダ / 記事リスト / 本文 の三ペイン。設定などの画面でもサイドバーは残る • スマホ: 単カラム+下部タブ、左右スワイプで既読・スター • 記事は自動で本文を取得し、Gemini で要点3行とタグを付ける • 本文は画像・動画・リンクごと出す(消毒済みHTML) • 「NotebookLM へ」で複数記事を1つの Markdown にまとめて渡す。Google Drive にも置ける • 毎朝のダイジェストを自動生成し、できたら Web Push で知らせる • アプリ内だけで音声とスライドも作れる(/listen・/watch)。 2人の対話か1人の語りを選べ、MP3 で手元に落とせる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個の数字にしていた。サイドバーは全ページに出るので、常時かかる費用だった。