← ガイダンス一覧へ戻る

4原典プレビュー

Zettelkasten Inbox / ブロック詳細

Notion本文が空のときだけ動く代役。リンク先のHTMLをサーバーが読みに行って要約する。行き先が事前に決まっていない唯一の通信なので、安全検査がアプリ中でいちばん厚い。

抽象度 Lv.4(技術用語+システム上のやりとりまで/製品名・ライブラリ名は書かない)

1. 概要

Notion本文が空のノートに対してだけ動く、代役のブロック。 「URLだけ保存して中身は読んでいない」というノートが受信箱に溜まりがちなので、 リンク先を代わりに見に行って、題名・概要・画像・本文の要約を画面に出す

行き先によって道が3つに分かれる。 X(旧Twitter)の投稿ならブラウザがX公式の表示部品で描く。 日経・NewsPicksのような購読が要るサイトなら「ログイン前に公開されている範囲です」と断ってから出す。 それ以外は普通の記事として読みに行く。

このブロックは、アプリの中で唯一、行き先が事前に決まっていない相手へ通信する場所である。 だから安全側の検査がいちばん厚い。

2. 位置づけ

3. 本文の取り出し(本文が空だった) → 4. 原典プレビュー → 5. Tagの推薦(材料としては使われない)

3. 処理内容(Lv.4)

4. なぜこの位置にあるか(設計意図)

3番の後ろでなければならない。Notion本文があるならそちらが正しい。 自分でNotionに貼り付けた文章のほうが、外から機械的に切り出した文章より確実だから。 4番はあくまで、本文が無いときの代役である。

5番より前に置く必要はなかった。実際、ここで作った要約は推薦の点数に入っていない。 これは意図か抜けか判断が付かないが、結果として 「URLだけのノート」は、題名とコメントだけを頼りに推薦されることになる。

サーバー経由にしている理由。ブラウザから直接よそのサイトを読みに行くことはできない (別のサイトの中身を勝手に読めないという、ブラウザ側の安全の決まりがある)。 だからサーバーが代理で取りに行く。ただしその代わり、 「サーバーに好きな宛先へ通信させる」危険が生まれる。4-4 の検査はそのためにある。

5. 詳細プロセス分解

工程主体やりとりの内容方向
4-1ブラウザ(内部)URLの種類で行き先を4通りに分ける
4-2ブラウザ → Xの埋め込み配信(X投稿のとき)表示部品の読み込み
4-3ブラウザ → プレビューAPI(それ以外)対象のURL
4-4プレビューAPI(内部)宛先の安全検査と正規化
4-5プレビューAPI → 外部の記事サイトGET(転送は手動で最大4回)
4-6外部の記事サイト → プレビューAPIHTML(8秒/1.5MBで打ち切り)
4-7プレビューAPI(内部)メタ情報の抽出と本文の要約
4-8プレビューAPI → ブラウザ題名・概要・画像・要約と、取得可否

各行を文章にすると

4-1 まずブラウザが、URLの見た目だけで行き先を決める。 Xの投稿か、購読が要るサイトか、普通の記事か、そもそもURLが無いか。

技術的には URLを構造として解釈し、 ホスト名パスを見る。 x.comtwitter.commobile.twitter.com のいずれかで、 パスに /status/数字 が含まれていれば投稿番号を取り出す。 nikkei.comnewspicks.com(それぞれ下位のドメインも含む)なら購読サイトと判定して、 画面の見出しと注意書きを差し替える。 この判定はすべてブラウザの中で終わり、通信は一切発生しない

4-2 X投稿のときは、サーバーを通さず、ブラウザがX公式の表示部品を直接読み込んで貼り付ける。

技術的には platform.twitter.com のスクリプトを <script> として1度だけ読み込み、以後は使い回す(読み込み中の約束を保持して、二重読み込みを防ぐ)。 表示の指定は追跡拒否をON、返信は表示しない、明るい配色。 このやり方だと、X側から見て「誰がこのアプリでその投稿を見たか」が分かりうる。 追跡拒否の指定はその緩和策。削除済み・非公開の投稿や通信が塞がれている環境では 表示部品が返らないので、「Xで投稿を開く →」のリンクだけを出す。

4-3 普通の記事なら、サーバーに「このURLの中身を見てきて」と頼む。

技術的には POST /api/source-previewapplication/json{"url": "…"} を送る。 読み取りなのにPOSTを使っているのは、URLをそのままクエリ文字列に入れると 長さや記号の扱いで壊れやすいため。ブラウザ側は取り消し用の印を持っていて、 別のノートに切り替わったら結果を捨てる。

4-4 サーバーは、頼まれた宛先が「行っていい相手」かを念入りに調べる。 社内向けのアドレスや、自分自身を指すアドレスは断る。

技術的には これは SSRF(サーバー側リクエスト強要。サーバーに、本来外から届かない内部の宛先へ通信させる攻撃)への備え。 拒否する条件は、http/https 以外の方式、URLに埋め込まれた利用者名・パスワード、 80・443以外のポート、0.0.0.0localhost/クラウドの内部情報用ホスト名、 .localhost.local で終わる名前、 そして私有IPv410.127.169.254.172.16〜31.192.168./先頭が224以上)と 私有IPv6::1fcfdで始まるもの等)。 通ったら断片指定(#以降)を落として正規化する。 ただしこの検査は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-Typecharset に従い、 その名前で解釈できなければUTF-8で読み直す(日本語のサイトはShift_JISやEUC-JPのこともあるため)。 そして401403 だけは失敗にしない。 購読サイトが「会員以外お断り」を返しながら冒頭を載せていることがあり、 そこを読み取るのがこのブロックの目的の1つだから。

4-7 取ってきたHTMLから、題名・概要・代表画像・正式なURLを拾い、 さらに本文らしい部分を切り出して要約する。

技術的には HTMLを構造として解析せず、 正規表現で必要なところだけを抜き出す方式。 メタ情報は og:titleog:descriptionog:site_nameog:imagetwitter:* を順に探し、 正式URLは rel="canonical" のリンクから取る。 画像とURLは、相対指定を絶対指定に直したうえでもう一度 4-4 の検査にかける (記事の中に内部アドレスを指す画像が仕込まれていても弾くため)。 本文は <article><main><body> の順に探し、 コメント・スクリプト・スタイル・SVG・メニュー・ヘッダ・フッタ・フォーム・脇の欄をまるごと除去してから タグを外し、20文字未満の行と定型の飾り行を落とし、重複を消して12,000文字まで。 そのあとは3番とまったく同じ要約の手順にかける。 本文が取れなければ、メタ情報の概要文をそのまま要約として使い、 画面には「サイト提供の概要」と「本文の自動要約」を区別して表示する。

4-8 結果を返す。うまく読めなかった場合も、失敗ではなく 「読めなかった」という結果として返す。

技術的には 200private, max-age=600private は「共有のキャッシュには置かず、この人のブラウザにだけ10分置いてよい」という意味。 取得に失敗しても 200 で返し、本文に available: false と失敗の理由 (fetch_timeoutresponse_too_largeunsafe_redirectunsupported_contentupstream_500 など)を入れる。 エラーをHTTPのコードで表さないのは、これが「あってもなくてもいい補助情報」だから。 サーバー側でも15分の控えを持ち、同じURLへの連続アクセスを抑える。

ワークフロー図

← 図は横にスクロールできます

ブラウザ
プレビューAPI
外部の記事サイト
Xの埋め込み配信
4-1 URLの種類で行き先を分ける
4-2 (X投稿のとき)表示部品の読み込み
4-3 (それ以外)POST /api/source-preview
4-4 宛先の安全検査(社内向けは拒否)
4-5 GET(転送は手動で最大4回)
4-6 HTML(8秒/1.5MBで打ち切り)
4-7 メタ情報の抽出+本文の要約
4-8 200 + private, max-age=600

6. この分解から見えること

  • 安全検査がアプリ中でいちばん厚い。方式・認証情報・ポート・ホスト名・私有アドレスの5系統を見て、 転送のたびに全部やり直す。「外から指定された宛先へサーバーが通信する」ことの危険を、 作者がきちんと認識していることが読み取れる。
  • それでも名前解決の抜け道は残っている。検査はURLの文字列に対して行われるので、 外向きの名前が内部アドレスに解決される場合は通ってしまう。 完全に塞ぐには通信の直前に解決結果を見る必要があり、そこまではしていない。
  • 401・403を成功扱いにするのが、この機能の肝。 普通なら失敗として捨てるところを、あえて中身を読む。 購読サイトの冒頭だけでも見えれば、Tagを決める材料になるという、 実際の使い方から出てきた判断に見える。
  • ここで作った要約が推薦に使われていない。3番の本文は推薦の材料になるのに、 4番の要約はならない。URLしか無いノートは、題名とコメントだけで推薦される。 つないでいれば精度が上がりそうな場所だが、つながっていない。
  • X投稿だけサーバーを通らない。他の3方向はサーバーが代理するのに、 Xだけはブラウザが直接X社の配信を読む。表示の忠実さと引き換えに、 閲覧が外部に伝わりうる唯一の経路になっている。
  • HTMLを正規表現で切っている。構造として解析していないので、 入れ子の <article> や壊れたHTMLでは切り出しがずれる。 軽さと引き換えの割り切りで、失敗しても「取得できませんでした」に落ちるだけなので実害は小さい。

7. 失敗時の挙動

  • 宛先が検査に落ちた:400 +「プレビューできないURLです。」
  • 8秒を超えた/1.5MBを超えた/HTML以外/転送5回目: 200 のまま available: false と理由を返す。 画面は「リンク先の情報を自動取得できませんでした」+原典を開くリンクに変わる。
  • 購読サイトの場合の表示:「◯◯の公開範囲を自動取得できませんでした」+ 「元サイトを開くと、ログイン前に読める範囲を確認し、そのままログインできます。」と、 次にどうすればよいかまで書かれた文言に切り替わる。
  • X投稿が表示できない:「削除済み・非公開の投稿、または通信制限により表示できません。」+ Xで開くリンク。サーバーは関与しない。
  • どこまで進んでから失敗すると何が残るか:読み取りのみなので、 Notionにも外部にも何も残らない。ただし失敗の結果も15分間控えられるので、 一時的な不調で失敗した直後に再読み込みしても、15分は同じ失敗表示が出続ける。

8. 未確認事項

  • 4番の要約を5番・6番の推薦材料に入れないのが、意図なのか実装の抜けなのか
  • 名前解決の結果が内部アドレスになる場合への対処を、今後入れる予定があるか
  • 失敗の結果まで15分控えるのが意図的か(成功だけ控える作りにもできる)
  • 購読サイトの判定が日経とNewsPicksの2つだけなのは、実際の使用実績に基づくものか
  • X以外の埋め込み(YouTube等)に対応する予定があるか
  • 外部サイト側から見て、このアプリの名乗りがどう扱われているか(拒否された実例は未確認)

※ 2026年8月14〜15日時点の zettelkasten-inbox のプログラムを読んで作成しました。過去の設計資料は参照していません。

※ 番号は全体図・この解説・シーケンス図で共通です。工程番号は「ブロック番号-連番」で書いています。

全体図へ戻る / ブロック目次へ

akuramochi1.com — personal tools for learning and thinking.