Open19

Notion API + Deno deploy + chrome extensions

nikogolinikogoli

モチベ

web ページを見ていて「いいな」と思った瞬間に1アクションで内容を notion に記録したい

  • その web ページだけで完結させたいので、chrome 拡張を使う
  • リンク貼り付けてもどうせ読まないので、web ページの中身を notion に転写する

要するに

アイコンを押すと「その web ページの HTML から notion API 用の block を作成して pages.create する chrome 拡張」を作る

Deno deploy

notion API は CORS に対応していないのでブラウザから呼んでも動かない。対策として Deno deploy に仲介させる
なので『ブラウザ → extention → Deno deploy → notion』という流れになる

nikogolinikogoli

Zenn を notion に転写した際の問題

  • notion はわりと行間が狭い
  • details の枠がなくなり、見辛くなる
  • 埋め込み系はだいたい上手く転写できない (marmeid も不可)
  • notion が拡張子のない画像を拒否するので対策として embed で転写しているが、かなり微妙
  • bookmark に タイトルも含めた preview が追加されないので、中身がわからない
  • code block が diff に対応していない
  • 注釈のリンクがそのまま残っている
nikogolinikogoli

chrome 拡張

https://github.com/nikogoli/notion-helper/tree/main/extension

  • html も CSS もやりたくないので、UI が存在しないストロングスタイル
  • document.bodyを投げるだけ。後はひたすら response のハンドリング
  • Deno deploy の URL などの情報は、環境変数っぽく別ファイルから読み込ませる
nikogolinikogoli

Manifest

{
  "name": "test",
  "description": "test",
  "version": "0.0.0",
  "manifest_version": 3,
  "background": {
    "service_worker": "background.js",
    "type": "module"
  },
  "action": {
    "default_title": "test"
  },
  "permissions": ["activeTab", "scripting"],
  "web_accessible_resources": [
    {
      "resources": [ ".env" ],
      "matches": [ "https://zenn.dev/*", "https://note.com/*" ]
    }
  ]
}
  • 拡張のアイコンを押したときに、そのタブの web ページで指定した関数を実行させるために、"permissions": ["activeTab", "scripting"]background.js内の ↓ を組み合わせる
    chrome.action.onClicked.addListener((tab) => {
      chrome.scripting.executeScript({
        target: { tabId: tab.id },
        function: arrange_scrap,
      })
    })
    
  • Token 系を別ファイル[1]から読み込ませるために、web_accessible_resourcesを設定する
脚注
  1. .envになっているけれど、dotenv 型の処理は上手くいかなかったので実態は json ファイル ↩︎

nikogolinikogoli

Client 側の CORS 対応

fetch API に必要な設定を与えれば上手くやってくれるので、自分で"Access-Control-Request-Headers"などを書く必要は無いっぽい

nikogolinikogoli

Deno Deploy その1

https://github.com/nikogoli/notion-helper/blob/main/deno_deploy/server.ts

本来はまとめて deploy するべきだと思うけど、playground 形式で別個に立てていたものを使う
通信関連はよくわからないのでかなり雑

nikogolinikogoli

ルーティングに sift を使ってみた。ルーティングだけだと恩恵はいまいちかも

https://deno.land/x/sift

パスをパラメータとして使う場合について、ドキュメントに結構ひどいミスがある

  • 間違い: (request, params) => ...
  • 正しい: (request, _connect-info, params) => ...

ドキュメントそのままの書き方だと、paramという変数名で違う値を取得してしまう

nikogolinikogoli

Server 側の CORS 対応

  • 'Access-Control-Allow-Method''Access-Control-Allow-Headers'に欲しい物を書く
  • Authorizationを使うなら 'Access-Control-Allow-Credentials'も true にする

https://developer.mozilla.org/ja/docs/Web/HTTP/CORS

CORS 関連は他にも色々情報が転がっているが、以下の2つ以外は時間の無駄
特に、やってみた系はだいたい Qiita の記事の劣化なので本当に無駄。
https://qiita.com/att55/items/2154a8aad8bf1409db2b
https://zenn.dev/qnighy/articles/6ff23c47018380

nikogolinikogoli

Preflighted requests

ルーティングでは、↑の扱いに非常に困った[1]

  • Preflighted requests では、レスポンスヘッダーに関係なく response.ok = falseの場合は CORS が拒否されたものとして扱われる
  • CORS が拒否された場合のエラーは、response.ok = false ではなく fetch の失敗として扱われ、(たぶん) fetch そのものを try {}していないと処理できない

その結果、存在しない URL へのアクセスに対して 404 エラーを返す設定にすると、

  1. Preflighted requests に 404 が返る
  2. response.ok = falseなので CORS 拒否扱いになる
  3. fetch 失敗扱いになり、404 用の処理に移行しない

となって、処理が止まってしまう。

なのでどこへのアクセスだろうがとにかく「request.method == 'OPTIONS'にはヘッダーを設定して 200 を返す」処理を差し込むことになり、ルーティングとは?という感じになってしまった

脚注
  1. あと、Preflighted requests で Auth のチェックはできないよ、っていうのも読み落としていて大変だった ↩︎

nikogolinikogoli

セキュリティっぽい何か

Deno depoly に notion のトークンを保持させることになるが、この辺のセキュリティをどう処理するのが正しいのか全然わかんない

とりあえず、notion のやつとは別のトークンを extention から渡して deploy 側で突き合わせるようにしてみたものの、なんか真似事感がすごい

nikogolinikogoli

Deno Deploy その2

block 作成自体は関数に切り出して、deploy 自体は notion への POST に集中する
といっても API 自体は sdk が呼び出すので、ひたすらエラーのハンドリング

なんかもう疲れたので、sdk を信じてエラーはそのまま chrome拡張に返すことにした

nikogolinikogoli

Notion API

なぜか change log には書いていないっぽいのだが、「自分+子要素+孫要素」の2段階のネスト付きで blocks.children.append が可能になった[1]

  • この Update によって
    • 例えばこのようなリストでもまるごと
      • 一度の API call で追加することができる

何よりも最高なのがこれが pages.create における children にも適用されることで、
これによって、「新規のページ + その内容である 100個以上の 2段階のネスト付きの block」を 1回の API call で作成できるようになった

ネスト付きの block の追加は table しか許されなかったベータ版を思えば大きな進歩で、かなり使えるようになったのではと思う

脚注
  1. そしておそらくそれにともなって append の100個上限がなくなった ↩︎

nikogolinikogoli

3段以上のネストがある block をどう append するか

  1. 「自分ー子ー孫」を追加する → その後「孫」に「曾孫ーその下」を追加する
      利点:直感的でわかりやすく、ネストの深さによらず逐次的に処理できる
      問題:「孫」の notion id を調べるのが面倒で、2回目の追加までに手間がかかる

  2. 「自分」だけを append する → その後「自分」に「子ー孫ー曾孫」を追加する
      利点:「自分」の notion id はすぐに調べられるので、2回目の追加までが早い
      問題:4段以上のネストになると、手間が増える

2の方法を採用した上で、4段以上のネストは拒否することにした

nikogolinikogoli

Deploy 側の流れ

  1. html を block 化処理に渡す
  2. block 化処理から「block の情報の record」と「1回目に追加する block の index」と「2回目に追加する block の index」と「その他諸々」が返される
  3. 「1回目に追加する block の index」を使って block を取り出し、これを"children"として新規のページを作成する
  4. 「2回目に追加する block の index」が空でない場合
    1. 作成したページに対して blocks.children.list() を実行し、追加した一番上の階層の block の notion 上での id を得る[1]
    2. 「2回目に追加する block の index」を使って、親 block ごとに children をまとめる
    3. 親 block ごとに上で取得した notion 上の id と children をセットにして、それぞれで blocks.append.children を実行する
  5. 成功/失敗にかかわらず、とりあえず結果をまとめて適当な status とともに extention 側に response を返す
脚注
  1. children.list() は相変わらず 100個しか返せないので、cursor を使って複数回呼ぶ ↩︎

nikogolinikogoli

block 化処理

基本の流れ

  1. title やら author やらの element をクラス名指定経由で取得し、データを得る
  2. 記事のメイン構成部分 (<p>が並列に並ぶところ)をクラス名指定経由で取得し、逐次 block 化する
  3. block 情報の辞書、1回目に追加する block のindex のリスト、2回目に追加する block の index のリストを準備する
  4. 空白線や区切り線の block を作成し、適当な index で辞書に登録しておく
  5. block ごとに
    1. crypto.randomUUID()で識別 index を作成し、1回目の index のリストに push する
    2. またこの index をキーとして、辞書に block の情報を登録する
    3. ネストの深さを調べ、2以下なら何もせず、3以上なら children を取り除く
    4. 取り除いた children は親の識別 index を情報として持たせつつ、別の block として識別 index を作成して辞書に登録する。この index は2回目の index のリストに push する
  6. ↑のなかで、適宜必要なところで空白行や区切り線の index を1回目のリストに push しておく
  7. 「tltile などのページの情報、1回目のリスト、2回目のリスト、block 情報の辞書、ネストの最大値」を返す
nikogolinikogoli

block 化

基本は以下の2つを組み合わせる

  • 与えられた材料と type 指定から block を作る関数
  • element の nodeName を見て block 作成の指示を決める関数

block 作成関数は仕事が単純なので汎用化できたが、指示出し関数は属ドメイン化した

nikogolinikogoli

平文の装飾

このような aaabbbccccodedddeeefffggg な装飾タグがネストされている平文において

  • <a> や <code> を独立 block とは別の要素だと判定し
  • 単語ごとに適切な annotation を設定した rich text を作成し
  • どれだけタグがあっても 1つの paragraph block にまとめる

のはかなり面倒で、いろいろやってみたが、結局は

  1. text node を基準に、親を辿って annotation を判断して rich text を作る
  2. rich text は貯めておき、パラグラフが終わったと判断されたときにそこから paragraph block を作る

という方針にした