- ベースパス:
/api - 全て JSON。リクエストボディも JSON(
application/json) - 認証は Cloudflare Access。Worker は
Cf-Access-Jwt-Assertionを検証する - 時刻は全て Unix 秒(整数)
- エラーレスポンス:
{ "error": { "code": "...", "message": "..." } }+ 適切な HTTP ステータス - 型定義は
shared/types.tsに置き、Worker と web の双方から import する
export interface Feed {
id: number;
url: string;
siteUrl: string | null;
title: string;
iconUrl: string | null;
rate: number; // 1..5
folder: string; // "" は未分類
readSeq: number; // 既読ウォーターマーク
unreadCount: number;
lastFetchedAt: number | null;
lastError: string | null;
disabled: boolean;
fullText: boolean; // 記事ページから本文を取ってくるか(M7)
fullTextSuggested: boolean; // 要約しか配信していないと見えた(勧めるだけ)
nsfw: boolean; // 表示を有効にした端末でしか出さない(issue #23)
}
export interface Entry {
id: number; // 全体単調増加。順序と未読判定の基準
feedId: number;
url: string | null;
title: string;
author: string | null;
body: string; // サニタイズ済み HTML
publishedAt: number | null;
storedAt: number;
}
// body には、全文取得(M7)が成功していればフィードの要約ではなく記事ページの本文が
// 入る。DB では別の列(entries.full_body)に持ち、読み出し時に COALESCE で 1 つに
// 畳んでいる。クライアントからどちらが入っているかは見えない
export interface Pin {
id: number;
entryId: number | null;
title: string;
url: string;
pinnedAt: number;
nsfw: boolean; // ピンを立てた時点のフィードの印を写したもの(issue #23)
}nsfw は絞り込みの条件ではない。 どの読み取り API も、印の有無に関わらず全件を返す。
出すかどうかは端末ごとの設定で、クライアントが決める(docs/DESIGN.md §6「NSFW」)。
起動時に 1 リクエストで必要なものを全て取得する。このエンドポイントのレスポンスだけで操作可能な状態になることが要件。
クエリパラメータ:
| 名前 | 既定値 | 説明 |
|---|---|---|
feeds |
5 | 記事本文をインラインで含めるフィード数(レート降順の上位から) |
entriesPerFeed |
50 | 1 フィードあたりの記事数上限 |
maxEntryId は以降の差分取得のカーソルになる。
バックグラウンドで残りの記事を引くための一括取得。
| 名前 | 既定値 | 説明 |
|---|---|---|
sinceId |
0 | この id より大きい記事を返す |
feedId |
— | 指定時はそのフィードに限定 |
unreadOnly |
true | 未読のみ(id > read_seq かつ手動未読を含む) |
limit |
500 | 最大 1000 |
{
"entries": [ /* Entry[]。id 昇順 */ ],
"nextSinceId": 12800, // 続きがある場合。無ければ null
"hasMore": true
}ページングは必ず sinceId で行う。 オフセットは使わない(取得中に新着が入ると重複・欠落する)。
複数端末間の差分同期。起動後の定期ポーリング(既定 5 分間隔)と、タブがフォアグラウンドに戻ったときに叩く。
| 名前 | 説明 |
|---|---|
entryCursor |
クライアントが持つ最大 entry id |
since |
前回同期時刻(Unix 秒)。現状は受け取るだけで応答に影響しない(後述) |
{
"serverTime": 1755000300,
"feeds": [ /* Feed[] 全件。readSeq / unreadCount を含む */ ],
"newEntries": [ /* entryCursor より大きい Entry[]。1 回あたり最大 1000 件 */ ],
"pins": [ /* Pin[] 全件 */ ],
"deletedPinIds": [],
"maxEntryId": 12900
}クライアントは受け取った readSeq を Math.max(local, remote) でマージする。上書きしない。
unreadCount はそのまま採用せず、当てた後に手元で数え直す(u で未読に戻した記事はサーバが知らず、保持期間で手元から消した記事はサーバにまだある)。
maxEntryId は次回の entryCursor に送り返す値であり、サーバが持つ最大 id とは限らない。newEntries が 1000 件で打ち切られた場合は、実際に返した最後の記事の id を返す。カーソルを返した範囲の先まで進めると、その間の記事を二度と取りに来られないため。続きは次の同期で返る。
since 以降に変化したものだけを返す方が転送量は小さいが、現在のスキーマには feeds の更新時刻も pins の削除記録も無い。read_seq を動かすのは他端末なので last_fetched_at では代用できず、変化の検出を諦めて絞り込むと複数端末同期そのものが壊れる。
そのため当面は全件返し、deletedPinIds は常に空を返す。想定規模(50〜150 フィード)なら数十 KB で収まる。変更追跡(feeds の更新時刻、削除済みピンの記録)は書き込みを実装する M4 / M6 で足し、そのときに since を有効化する。
既読ウォーターマークの更新。outbox からまとめて送られる。
{
"marks": [
{ "feedId": 1, "watermark": 12345 },
{ "feedId": 7, "watermark": 12290 }
]
}サーバ側の処理は UPDATE feeds SET read_seq = MAX(read_seq, ?) WHERE id = ? を batch() で実行する。
冪等。 同じリクエストを何度送っても、順序が入れ替わっても結果は同じ。
レスポンス:
{
"feeds": [ { "id": 1, "readSeq": 12345, "unreadCount": 0 } ]
}個別の記事を未読に戻す。entry_states に行を作る。ウォーターマークは動かさない。
未読に戻した記事を既読に戻す(entry_states の行を削除)。
{ "entryId": 123, "title": "...", "url": "https://...", "nsfw": false }url は必須。title と url は、記事が保持期間を過ぎて削除されてもピンが生き残るよう非正規化して保存する。
title は空でよい。 タイトルを配らないフィード(Bluesky のプロフィール RSS)の記事は題を持たないので、必須にするとその記事だけピンできない——しかもクライアントは 400 を「送り直しても通らない」として捨てるため、画面には出たままリロードで消える。空で届いたときは、記事がまだあれば本文の書き出しを見出しにして控える(「タイトルを配らないフィード」と同じ規則)。記事が既に無ければ空のまま控え、一覧は URL を出す。
nsfw はサーバが記事のフィードから写す。 要求の nsfw(省けば false)は、記事から引けないときの控えで、記事が残っていれば使わない。記事が既に無いときは、同じ url のピンが持っていた印と要求の nsfw のどちらかが立っていれば立てる。記事が消えた後の送り直しや、クライアントの手元にしか残っていない記事のピンで、隠すはずのピンを表に出さないため。
同一 URL への重複追加は INSERT OR REPLACE で吸収する(冪等)。
{ "url": "https://example.com/", "rate": 3, "folder": "tech" }url はサイト URL でもフィード URL でもよい。サイト URL の場合は <link rel="alternate" type="application/rss+xml"> を探してフィード URL を発見する(HTMLRewriter を使う)。<link> が 1 つも無ければ、同一サイト内のフィードらしい <a href>(.xml / .rdf / .atom / .rss で終わる、または /feed /rss /atom で終わる)を候補にする。それでも見つからなければ、パスを 1 段ずつ 3 段まで遡って同じことを試す(記事詳細ページにフィードの在処を書いていないサイトがある)。遡って見つけたものは、候補が 1 件でも 300 を返して選ばせる。 貼られた URL とは違う場所のフィードなので、黙って登録しない。
RSS を出していないが取り込み方を決め打ちで持っているサイト(docs/DESIGN.md の「RSS を出さないサイトを取り込む」)の URL は、ページを取らずにそのままフィードとして登録する。
登録後、同期的に初回クロールを実行してから返す。ユーザが登録直後に記事を読めるようにするため。
候補が複数あるか、上の階層まで遡って見つけた場合は 300 相当を返し、候補を提示する。応答には候補を見つけたページ(foundAt)を含める。貼られた URL と違うことがあるので、画面はそれを伝えたうえで選ばせる。
{ "candidates": [ { "url": "...", "title": "..." } ] }rate、folder、title、disabled、fullText、nsfw を更新する。title はフィードの提供する値を上書きするユーザ指定値。
nsfw を変えると、そのフィードの記事を指すピンの nsfw も同じ値に揃える(1 つの batch)。記事が保持期間で消えたピンは、どのフィードの記事だったかを引けないので動かさない。
fullText は「記事ページから本文を取ってくるか」。既定は false で、決めるのはユーザ。 相手のサーバに記事の数だけ取りに行く動作なので、こちらが勝手に始めない。クロール時に要約しか配信していないと見えたフィードは fullTextSuggested が立ち、購読管理画面がそれを勧める。
記事も CASCADE で削除される。ピンは残る。
手動での即時クロール。next_fetch_at を無視して実行し、更新後の Feed と新着 Entry を返す。
entries には新着に加えて、全文取得(M7)で本文が差し替わった記事も入る。 既にクライアントが持っている記事なので sinceId の差分には出てこず、これを返さないと fullText を入れた直後に手持ちの未読が要約のまま残る。クライアントは id で上書きマージする。
全フィードの即時クロール。サブリクエスト上限があるため、内部で分割して処理し、進捗は返さない(完了時にまとめて返す)。フィード数が多い場合はタイムアウトの可能性があるので、クライアント側は GET /api/sync でのフォローアップを前提にする。
Content-Type: text/x-opml で購読リストを返す。フォルダは <outline> の入れ子で表現し、レートは ratatoskr:rate 属性として出力する(他のリーダーからは無視される)。NSFW の印が付いたフィードには ratatoskr:nsfw="1" を付け、取り込み時に戻す。
multipart/form-data または text/x-opml の生ボディを受け付ける。
{
"imported": 42,
"skipped": 3, // 既に購読済み
"failed": [ { "url": "...", "reason": "..." } ]
}インポート時に初回クロールはしない。next_fetch_at = 0 を設定して通常の cron に拾わせる(大量登録で上限に当たるのを避けるため)。
参考までに、livedoor Reader API から意図的に変えた点。
| LDR | Ratatoskr | 理由 |
|---|---|---|
ApiKey をフォームパラメータで送る |
Cloudflare Access の JWT | 認証をアプリから追い出す |
POST /api/subs で全件取得 |
GET /api/bootstrap で記事本文まで一括 |
起動時のラウンドトリップを 1 回にする |
/api/unread をフィード単位で都度呼ぶ |
GET /api/entries で一括先落とし |
想定規模なら全件手元に置ける |
last_stored_on(タイムスタンプ) |
watermark(entry id) |
時刻は巻き戻りうる。採番 id なら単調性が保証される |
application/x-www-form-urlencoded |
JSON | 型定義を共有するため |
{ "serverTime": 1755000000, "schemaVersion": 1, "feeds": [ /* Feed[]。レート降順、同率は未読数降順 */ ], "entries": [ /* Entry[]。上位フィードの未読記事 */ ], "pins": [ /* Pin[] */ ], "maxEntryId": 12345 // この時点でサーバが持つ最大 entries.id }