11
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ZOZOAdvent Calendar 2022

Day 15

【GAS】Slack絵文字リアクションによるNotion Web Clipperへの保存

Last updated at Posted at 2022-12-14

はじめに

この記事は ZOZO Advent Calendar 2022 カレンダーVol.6の15日目の記事です。
こんにちは、初投稿です。新卒一年目で、普段はデータ基盤の運用を行なっております。

私は普段、Notionを用いて毎日のインプットを整理しています。
その中でも後述するWeb Clipperを用いて、日々参考となったWebページを管理しています。(便利)

また会社では気になるネタ記事を共有するSlackのチャンネルがあるのですが、「あとで読も〜」と思って絵文字リアクションのみしてしまい、見逃す事案が多発しています。
Web ClipperはChromeの拡張機能なので一度リンクを開けばいいのですが、それすらも億劫なのです。
なので特定の絵文字でリアクションをすると、Web Clipperのデータベースに登録されるBotを作ります。

今回作るもの

SlackでURLが含まれるメッセージに、 絵文字でリアクションすると、
random_-Slack-_Slack.png

NotionのWeb Clipper用のデータベースに登録されます。
Web_Clip.png

Notion Web Clipperについて

Notion Web ClipperはウェブページをNotionに保存する機能です。
こんな感じ。

スクリーンショット 2022-12-14 17.30.57.png

それだけです。ただそれでいいのです。Notionのデータベースが優秀だから。
Notionのデータベースの検索は全てのカラム情報に全文検索が走るため、自動保存である保存したwebページのタイトルでも検索にかかるし、任意に入力したタグやキーワードに対してもヒットします。
またビューを用いて任意のフィルター条件を登録しておけば、体験の良い参照ができます。(「高頻度アクセスビュー」や「Python関連ビュー」など)

その他にもデータベースのリレーション機能使ってタグをライブラリ化している人もいたりします。

最近では、とにかくバンバンWeb Clipperに保存しています。
そしてググる前にNotion内で検索し、それでもない場合はGoolge先生に聞きます。
「お、いい感じの記事〜登録〜」と思ってポチったら、数ヶ月前に既に登録されていることとかもめっちゃあります。

余談: Save to Notionが良さげ

この記事を書く前まで、Notionの公式が出しているWeb Clipperの拡張機能を用いてましたが、調べてみるとサードパーティ製のSave to Notionというものが出ていました。
Save to Notionではページ保存時にプロパティを設定できたり、ハイライト機能があったり、使い勝手が良かったので私もこちらに乗り換えました。

Slackの準備

Slack Appの準備は主に以下です。

  1. Events API を用いて、絵文字リアクションがある度にGAS のURLにイベントを送信する
  2. Conversations API を用いて、メッセージ内容を取得するので、権限を付与する
  3. 必要な情報を控える

Event APIの設定

適当なSlack Appを作成し、ワークスペースにインストールします。
また今回のAppは完全にプライベート用なので、Appの配布はスキップします。

Slack_API__Applications___Slack.png
Slack_API__Applications___Slack_Slack.png

次にEvent Subsriptionsの設定です。
まず、Request URLについては後述するGASのデプロイURLなので、GAS実装後に入力します。
次に、どのイベントに対してサブスクライブする設定ですが、今回は「絵文字のリアクションが追加された時」なので、下記画像のように reaction_added を選択します。
Slack_API__Applications___Slack_Slack.png

権限の設定

次にメッセージ内容を取得するための権限設定です。
今回は、 https://slack.com/api/conversations.replies (リファレンス)を用いるかつ、チャンネルへの投稿メッセージを対象としていますので、
channels:history 権限を与えます。

Features >> OAuth & PermissionsのScopeより設定できます。
Slack_API__Applications___Slack_Slack.png
reactions:read はEvent Subsriptionsの設定で自動追加されたものです)

必要な情報

以下の情報を控えます。

  • Bot User OAuth Token
    • Slack api画面の Features >> OAuth & Permissionsより取得できます
  • 自身のメンバーID
    • この記事を参考にしました
    • 自身のリアクションのみ保存する時の判別として使います

Notionの準備

Notionの準備は主に以下です。

  1. インテグレーションを作成する
  2. 対象のデータベースに対して、コネクトの追加を行う
  3. 必要な情報を控える

インテグレーションの作成

公式の通り進めていきます。
ブラウザからNotionにログインし、https://www.notion.so/my-integrations にアクセスします。
「新しいインテグレーションを作成する」を選択し、適当なNameを入力します。その他はデフォルト値のまま進めました。

インテグレーションを作成すると、SecretsのセクションにInternal Integration Tokenが表示されるの控えます。

コネクトの追加

こちらも公式通りに。
次に対象のデータベースに対して、作成したインテグレーションをコネクト追加します。
Web_Clip.png

必要な情報

  • 上述したInternal Integration Token
  • データベースID
    • こちらに取得方法が記載されています

GAS

やっと本題です。
こういった自動化Botを作るときはGAS (Google Apps Script) を用いるのが定番らしいので、GASで作りました。

// Slack Setting
const SLACK_APP_TOKEN = "Bot User OAuth Token入れる"
const MEMBER_ID = "自身のメンバーID入れる"

// Notion Setting
const DATABASE_ID = "データベースID入れる"
const NOTION_TOKEN = "Internal Integration Token入れる"

function doPost(e) {
  // Event APIの検証時はchallengeを返す
  try {
    const json = JSON.parse(e.postData.getDataAsString());
    if (json.type == "url_verification") {
      return ContentService.createTextOutput(json.challenge);
    }
  }
  catch (ex) {
    Logger.log(ex);
  }

  const json = JSON.parse(e.postData.getDataAsString());
  const channel = json.event.item.channel;
  const ts = json.event.item.ts;
  const reaction = json.event.reaction;
  const memberId = json.event.user

  if (reaction != "linked_paperclips" || memberId != MEMBER_ID) {
    return
  }


  const message = getMessage(ts, channel);
  const urls = extractUrlslFromMessage(message);
  const permalink = getPermalink(ts, channel);

  for (url of urls) {
    postNotionDB(url.title, url.url, permalink)
  }

  const output = ContentService.createTextOutput(JSON.stringify({ result: "Ok" }));
  output.setMimeType(ContentService.MimeType.JSON);
  return output;
}

function getMessage(ts, channel) {
  const url = "https://slack.com/api/conversations.replies";
  const options = {
    "method": "get",
    "contentType": "application/x-www-form-urlencoded",
    "payload": {
      "token": SLACK_APP_TOKEN,
      "channel": channel,
      "ts": ts
    }
  };
  const response = UrlFetchApp.fetch(url, options);
  const json = JSON.parse(response);
  // スレッド返信は考慮しないので最初のmessageのみ
  const message = json.messages[0]
  return message
}

function getPermalink(ts, channel) {
  const url = "https://slack.com/api/chat.getPermalink"
  const options = {
    "method": "get",
    "contentType": "application/x-www-form-urlencoded",
    "payload": {
      "token": SLACK_APP_TOKEN,
      "channel": channel,
      "message_ts": ts
    }
  };
  const response = UrlFetchApp.fetch(url, options);
  const json = JSON.parse(response);
  const permalink = json.permalink
  return permalink
}

function extractUrlslFromMessage(message) {
  const urls = []

  // attachments
  if ("attachments" in message) {
    for (attachment of message.attachments) {
      urls.push({
        title: attachment.title,
        url: attachment.original_url
      })
    }
  }


  // blockのelements見る。attachmentsにあればskipする
  for (element of message.blocks[0].elements[0].elements) {
    if (element.type != "link") {
      continue
    }

    if (urls.some(url => url.url != element.url) || urls.length == 0) {
      urls.push({
        title: element.url,
        url: element.url
      })
    }
  }

  return urls
}

function postNotionDB(title, url, permalink) {
  const post_url = 'https://api.notion.com/v1/pages';

  const json = {
    parent: {
      database_id: DATABASE_ID,
    },
    properties: {
      "Name": {
        "title": [{
          "text": {
            "content": title
          }
        }]
      },
      "URL": {
        "url": url
      },
      "SlackURL": {
        "url": permalink
      }
    }
  }

  const options = {
    "method": "POST",
    "headers": {
      "Content-type": "application/json",
      "Authorization": "Bearer " + NOTION_TOKEN,
      "Notion-Version": '2022-06-28',
    },
    "payload": JSON.stringify(json),
  };

  UrlFetchApp.fetch(post_url, options);
}


とくに難しいことはしていないので詳しい解説は省略します。

若干、工夫した点は絵文字リアクションを通してWeb Clipperのデータベースに登録するときは、
SlackURL というカラムに、該当スレのパーマリンクも一緒に登録します。
スレッド内で議論などがあった場合、Notionからそのパーマリンクを参照することで、あとで見返すときに便利そうだなと思いつけました。

注意点と修正予定箇所

勢いで作ったため、いくつか注意点があります。
まず、Slack上でのWebページのプレビューがない場合は、タイトルがURLになります。
上記コードの attachments がプレビューの情報を含まれるのですが、これがない場合はURLの文字列の情報しか取得できなかったので、タイトルをURLにしました。

またプレビューがない場合はblocksの値を取得して、URLを取得しています。
しかし、この中身はメッセージの書式によって再帰的な構造をもつ仕様になっており、現在のコードだとリストなどの書式が入ると上手く取得できません。
こちら (多分)修正予定です。(レスポンス仕様のリファレンスが探しても無いのだが、どこにあるのやら、、、)

さいごに

今回は初投稿で本来、データエンジニアリング業務の小ネタを書こうと思ってたのですが、
あんまりネタが無かったのと、今回作ったBotは数ヶ月前から構想はあったので、勢いでこの題材にしました。
次回からはそっちのネタ書けるといいなぁ〜

参考URL

11
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?