Zettelkasten Inbox / ブロック詳細
Notion本文が空のときだけ動く代役。リンク先のHTMLをサーバーが読みに行って要約する。行き先が事前に決まっていない唯一の通信なので、安全検査がアプリ中でいちばん厚い。
抽象度 Lv.4(技術用語+システム上のやりとりまで/製品名・ライブラリ名は書かない)Notion本文が空のノートに対してだけ動く、代役のブロック。 「URLだけ保存して中身は読んでいない」というノートが受信箱に溜まりがちなので、 リンク先を代わりに見に行って、題名・概要・画像・本文の要約を画面に出す。
行き先によって道が3つに分かれる。 X(旧Twitter)の投稿ならブラウザがX公式の表示部品で描く。 日経・NewsPicksのような購読が要るサイトなら「ログイン前に公開されている範囲です」と断ってから出す。 それ以外は普通の記事として読みに行く。
このブロックは、アプリの中で唯一、行き先が事前に決まっていない相手へ通信する場所である。 だから安全側の検査がいちばん厚い。
3. 本文の取り出し(本文が空だった) → 4. 原典プレビュー → 5. Tagの推薦(材料としては使われない)
POST /api/source-preview。X投稿の道だけはサーバーを通らないhttp/https のみ、URLに利用者名やパスワードが埋まっていないこと、
ポートは80か443のみ、localhost・.local・社内向けIPアドレス・クラウドの内部情報用アドレスは拒否text/html・application/xhtml+xml・text/plain 以外は拒否private, max-age=600 で10分持たせる3番の後ろでなければならない。Notion本文があるならそちらが正しい。 自分でNotionに貼り付けた文章のほうが、外から機械的に切り出した文章より確実だから。 4番はあくまで、本文が無いときの代役である。
5番より前に置く必要はなかった。実際、ここで作った要約は推薦の点数に入っていない。 これは意図か抜けか判断が付かないが、結果として 「URLだけのノート」は、題名とコメントだけを頼りに推薦されることになる。
サーバー経由にしている理由。ブラウザから直接よそのサイトを読みに行くことはできない (別のサイトの中身を勝手に読めないという、ブラウザ側の安全の決まりがある)。 だからサーバーが代理で取りに行く。ただしその代わり、 「サーバーに好きな宛先へ通信させる」危険が生まれる。4-4 の検査はそのためにある。
| 工程 | 主体 | やりとりの内容 | 方向 |
|---|---|---|---|
| 4-1 | ブラウザ(内部) | URLの種類で行き先を4通りに分ける | ↻ |
| 4-2 | ブラウザ → Xの埋め込み配信 | (X投稿のとき)表示部品の読み込み | → |
| 4-3 | ブラウザ → プレビューAPI | (それ以外)対象のURL | → |
| 4-4 | プレビューAPI(内部) | 宛先の安全検査と正規化 | ↻ |
| 4-5 | プレビューAPI → 外部の記事サイト | GET(転送は手動で最大4回) | → |
| 4-6 | 外部の記事サイト → プレビューAPI | HTML(8秒/1.5MBで打ち切り) | ← |
| 4-7 | プレビューAPI(内部) | メタ情報の抽出と本文の要約 | ↻ |
| 4-8 | プレビューAPI → ブラウザ | 題名・概要・画像・要約と、取得可否 | ← |
4-1 まずブラウザが、URLの見た目だけで行き先を決める。 Xの投稿か、購読が要るサイトか、普通の記事か、そもそもURLが無いか。
技術的には URLを構造として解釈し、
ホスト名とパスを見る。
x.com/twitter.com/mobile.twitter.com のいずれかで、
パスに /status/数字 が含まれていれば投稿番号を取り出す。
nikkei.com と newspicks.com(それぞれ下位のドメインも含む)なら購読サイトと判定して、
画面の見出しと注意書きを差し替える。
この判定はすべてブラウザの中で終わり、通信は一切発生しない。
4-2 X投稿のときは、サーバーを通さず、ブラウザがX公式の表示部品を直接読み込んで貼り付ける。
技術的には platform.twitter.com のスクリプトを
<script> として1度だけ読み込み、以後は使い回す(読み込み中の約束を保持して、二重読み込みを防ぐ)。
表示の指定は追跡拒否をON、返信は表示しない、明るい配色。
このやり方だと、X側から見て「誰がこのアプリでその投稿を見たか」が分かりうる。
追跡拒否の指定はその緩和策。削除済み・非公開の投稿や通信が塞がれている環境では
表示部品が返らないので、「Xで投稿を開く →」のリンクだけを出す。
4-3 普通の記事なら、サーバーに「このURLの中身を見てきて」と頼む。
技術的には POST /api/source-preview に
application/json で {"url": "…"} を送る。
読み取りなのにPOSTを使っているのは、URLをそのままクエリ文字列に入れると
長さや記号の扱いで壊れやすいため。ブラウザ側は取り消し用の印を持っていて、
別のノートに切り替わったら結果を捨てる。
4-4 サーバーは、頼まれた宛先が「行っていい相手」かを念入りに調べる。 社内向けのアドレスや、自分自身を指すアドレスは断る。
技術的には これは
SSRF(サーバー側リクエスト強要。サーバーに、本来外から届かない内部の宛先へ通信させる攻撃)への備え。
拒否する条件は、http/https 以外の方式、URLに埋め込まれた利用者名・パスワード、
80・443以外のポート、0.0.0.0/localhost/クラウドの内部情報用ホスト名、
.localhost・.local で終わる名前、
そして私有IPv4(10./127./169.254./
172.16〜31./192.168./先頭が224以上)と
私有IPv6(::1、fc/fdで始まるもの等)。
通ったら断片指定(#以降)を落として正規化する。
ただしこの検査はURLの文字列に対するもので、
名前解決の結果が内部アドレスを指す場合は素通りする(DNSリバインディングと呼ばれる抜け道)。
4-5 実際に読みに行く。転送されたら、自動で追いかけずに、 もう一度同じ検査をしてから次へ進む。
技術的には redirect: "manual" を指定して
自動追従を止めている。300番台が返ったら Location ヘッダの宛先を取り出し、
4-4 と同じ検査を通してから次の1回を投げる。これを最大4回。
自動追従に任せると、最初の宛先は安全でも転送先が内部アドレスという抜け道が通ってしまう。
送るヘッダは、受け付ける形式・日本語優先・
このアプリの名前と連絡先URLを名乗る User-Agent の3つ。
ブラウザのふりをせず正直に名乗っているので、拒否したいサイトは拒否できる。
4-6 返ってきた中身を、時間と量の両方で区切りながら読み込む。
技術的には 3重の歯止め。
(1) 時間:8秒で打ち切る合図を最初にかけておく。
(2) 量:宣言された長さが1.5MBを超えていたらその場で拒否し、
宣言が無い場合も少しずつ読みながら合計を数え、超えたら読み取りを中止する。
(3) 種類:Content-Type が html/xhtml/plain で始まらなければ拒否。
文字コードは Content-Type の charset に従い、
その名前で解釈できなければUTF-8で読み直す(日本語のサイトはShift_JISやEUC-JPのこともあるため)。
そして401 と 403 だけは失敗にしない。
購読サイトが「会員以外お断り」を返しながら冒頭を載せていることがあり、
そこを読み取るのがこのブロックの目的の1つだから。
4-7 取ってきたHTMLから、題名・概要・代表画像・正式なURLを拾い、 さらに本文らしい部分を切り出して要約する。
技術的には HTMLを構造として解析せず、
正規表現で必要なところだけを抜き出す方式。
メタ情報は og:title/og:description/og:site_name/
og:image/twitter:* を順に探し、
正式URLは rel="canonical" のリンクから取る。
画像とURLは、相対指定を絶対指定に直したうえでもう一度 4-4 の検査にかける
(記事の中に内部アドレスを指す画像が仕込まれていても弾くため)。
本文は <article> → <main> → <body> の順に探し、
コメント・スクリプト・スタイル・SVG・メニュー・ヘッダ・フッタ・フォーム・脇の欄をまるごと除去してから
タグを外し、20文字未満の行と定型の飾り行を落とし、重複を消して12,000文字まで。
そのあとは3番とまったく同じ要約の手順にかける。
本文が取れなければ、メタ情報の概要文をそのまま要約として使い、
画面には「サイト提供の概要」と「本文の自動要約」を区別して表示する。
4-8 結果を返す。うまく読めなかった場合も、失敗ではなく 「読めなかった」という結果として返す。
技術的には 200 + private, max-age=600。
private は「共有のキャッシュには置かず、この人のブラウザにだけ10分置いてよい」という意味。
取得に失敗しても 200 で返し、本文に available: false と失敗の理由
(fetch_timeout/response_too_large/unsafe_redirect/
unsupported_content/upstream_500 など)を入れる。
エラーをHTTPのコードで表さないのは、これが「あってもなくてもいい補助情報」だから。
サーバー側でも15分の控えを持ち、同じURLへの連続アクセスを抑える。
← 図は横にスクロールできます
<article> や壊れたHTMLでは切り出しがずれる。
軽さと引き換えの割り切りで、失敗しても「取得できませんでした」に落ちるだけなので実害は小さい。400 +「プレビューできないURLです。」200 のまま available: false と理由を返す。
画面は「リンク先の情報を自動取得できませんでした」+原典を開くリンクに変わる。※ 2026年8月14〜15日時点の zettelkasten-inbox のプログラムを読んで作成しました。過去の設計資料は参照していません。
※ 番号は全体図・この解説・シーケンス図で共通です。工程番号は「ブロック番号-連番」で書いています。