generated at
1日ごとにScrapboxの更新を通知するbot
こんな感じの通知が流れます

用途
ScrapboxのSlack通知ScrapboxのRSSは更新頻度が高すぎる
通知がないと、自分から何度もStreamを開くようになってしまい、時間が溶ける
1日一回、常に更新通知が来るとわかれば、自分から開きに行く必要もなくなる
このくらいの頻度なら、依存症にはならないだろうtakker
1日1回では長すぎて我慢できずに開いてしまいそうyosider
かなしいなあ……takker
通知が来るまで我慢しよう、と思える位の時間がいいと思うyosider
個人差ありそうなので自分で設定できるとよさそう
たしかに
ほしい機運が高まってきた…yosider

使う環境

仕様を考える
Slack channelごとに通知を振り分ける
/villagepump/Takanawaのような機能をイメージしてる
設定はjsonで書く
パターンマッチと通知先のwebhook URLとのペア
パターンが更新内容にマッチしたら通知する
パターンは正規表現を使う
除外検索もできるようにしてみたいなtakker
include/excludeで2つの正規表現を設定できるようにする
通知頻度
通知内容のレイアウト案
本文の更新部分をすべて通知する
こっちを使いたいな
cons: 通知を読むだけで満足して、scrapboxを開かなくなる?
依存症対策なんだからそれでいいtakker
URLだけ通知する
cons: 更新内容を確認するために、結局scrapboxを開いてしまう?
通知頻度を押さえれば問題ないか?takker
更新箇所がわかりにくいのは問題だな
こちらのほうが可読性は高そうyosider
更新の先頭行へのリンクにするとよりわかりやすいかも
両方作ってABテストすればいいかなtakker
よさそう
レイアウトにはBlock kitを使う

参考にする実装

2021-02-02 22:02:10 作ってますtakker
おお~~感謝yosider
実装できたこと
基準日時以降に更新されたpageの取得
基準日時以降に更新された行の抽出
SlackへのPOST
これから実装すること
done22:57:56 投稿するSlack channelを複数指定できるようにする
done22:57:59 正規表現によるfiltering
JSONファイルから設定を読み込む
Google Driveに置く
好きなURLに配置する
scrapoxやgithub gistにおいていつでも手直しできるので、こっちのほうが便利かも
URLだけ通知するモードを実装する
done構文解析
23:35:17 mrkdwnへの変換がめんどくさい……
一部の装飾記法は全部omitしたほうがよさそうだな……
2021-02-03 01:49:24 なぜか一部のblocksが正常にPOSTされない……
02:09:58 失敗したら解析していない生文字列を返すようにした
今後もう少し対処をかんがえたい
もし失敗したら curl コマンドを出力するようにしてみるか
terminalでそれを実行すれば、なんで失敗したのかがわかる
2021-02-03 11:10:09 最終更新日時が正常に反映されていなかったのを直した

実装したいこと
タイトルのURLを行リンクにする
herokuにしたいtakker
ESModuleも使えないようなplatformを使うのはもうやだ
/customize/scrapbox-duplicatorみたく、Button一つで使えるようにしたい
Deploy to Heroku Buttonから通知頻度をいじれないのが面倒だな
2021-06-04 08:48:43 repo作った
private projectへの対応
インデントを表示する
通知の順序を直す
更新の古い順から通知する

コード
だいぶ長くなったので、分割を考えている
titleList じゃなくて pageSummaries のほうが適切な命名かな
main.gs(js)
function main() { const scriptProperties = PropertiesService.getScriptProperties(); const lastUpdated = (() => { const value = scriptProperties.getProperty('LAST_UPDATED'); return !isNaN(value) && yesterday() < value ? value : yesterday(); })(); const settigns = getSettings(); const updatedTitleList = getModifiedScrapboxTitle({ projects: [...new Set(settigns.map(({ project }) => project))], from: lastUpdated }); // 最終更新日時を更新する scriptProperties.setProperty('LAST_UPDATED', Math.max(...updatedTitleList.map(({ updated }) => parseInt(updated)), isNaN(lastUpdated) ? yesterday() : lastUpdated)); //更新された行のみを抽出する const pageDataList = getScrapboxPages(...updatedTitleList) .map(({ project, title, lines }) => { return { project, title, lineBlocks: getModifiedLines({ lines, from: lastUpdated }) } }); // 送り先を振り分ける const params = settigns.flatMap(({ webhook, project, include, exclude }) => pageDataList.flatMap(({ project: project_, title, lineBlocks }) => { if (project_ !== project) return []; const lines = lineBlocks.flat(); if (include && !lines.some(({ text }) => include.test(text))) return []; if (exclude && lines.some(({ text }) => exclude.test(text))) return []; // データを変換しておく return [{ url: webhook, ...convertLinesToBlocks({ project, title, lineBlocks }), }]; }) ); // データを整形してpostする postToSlack(...params); } const yesterday = () => { let now = new Date(); now.setDate(now.getDate() - 1); return Math.floor(now.getTime() / 1000); } function convertLinesToBlocks({ project, title, lineBlocks }) { return { blocks: [ { type: 'section', text: { type: 'mrkdwn', text: `*<https://scrapbox.io/${project}/${title}|${title}>*`, }, }, ...lineBlocks.flatMap(lines => [ ...sb2mrkdwn(lines.map(line => line.text).join('\n'), { project }), { type: 'divider', }, ]), ],

POSTに失敗した場合に送るやつ
[] を外すくらいはしておきたいかな
失敗の段階に応じていくつか用意したい
1. 完全な構文解析
2. 構文を全部外したもの
3. URLのみ
Scrapboxの更新をタイトルだけ通知するbotの機能を取り込みこみたい
設定に応じて、URLのみ通知できるようにしたい
main.gs(js)
originalBlocks: [ { type: 'section', text: { type: 'mrkdwn', text: `*<https://scrapbox.io/${project}/${title}|${title}>*`, }, }, ...lineBlocks.flatMap(lines => [ { type: 'section', text: { type: 'plain_text', text: lines.map(({ text }) => text).join('\n'), } }, { type: 'divider', }, ]), ], }; } function getModifiedLines({ lines, from }) { const result = []; let chunk = []; // 更新された行を、連続した部分ごとに分割する for (const line of lines) { if (line.updated <= from) { if (chunk.length === 0) continue; result.push([...chunk]); chunk = []; continue; } chunk.push(line); } if (chunk.length > 0) result.push([...chunk]); return result; } function getSettings() { return createDefaultSettings(); } function getScrapboxPages(...params) { console.log(`Start fetching scrapbox pages: `, params); const responses = UrlFetchApp.fetchAll(params .map(({ project, title }) => { return { url: `https://scrapbox.io/api/pages/${project}/${encodeURIComponent(title)}`, }; })); const jsons = responses.map(response => JSON.parse(response.getContentText())) console.log(`Finish fetching.`); return jsons.map(({ title, lines }, i) => { return { project: params[i].project, title, lines }; }); } // 各projectで最大1000件まで取得する // 一日に1000件以上ページが更新されるなんてことは無いだろうからこれで大丈夫だとは思うが………… // もしそういう状況が起きるのであれば、skipパラメータを使う function getModifiedScrapboxTitle({ projects, from }) { console.log(`Start searching ${projects.length} scrapbox projects for pages which are updated from ${toYYYYMMDD_HHMMSS(from)}: `, projects); const responses = UrlFetchApp.fetchAll(projects .map((project) => { return { url: `https://scrapbox.io/api/pages/${project}?limit=1000`, }; })); const jsons = responses.map(response => JSON.parse(response.getContentText())); console.log(`Finish fetching.`); // 更新されたページのタイトルだけ取得する return jsons.flatMap(({ projectName: project, pages }) => pages.flatMap(({ title, updated }) => updated > from ? [{ project, title, updated }] : [])); } const MAX_BLOCK_NUM = 50; function postToSlack(...params) { // blocksが長いときは分割する // MAX_BLOCK_NUM * 3以上長いと対処できない const temp = []; for (const { url, blocks, originalBlocks } of params) { if (blocks.length > MAX_BLOCK_NUM) { temp.push({ url, blocks: blocks.slice(0, MAX_BLOCK_NUM - 1), originalBlocks: originalBlocks.slice(0, MAX_BLOCK_NUM - 1) }, { url, blocks: blocks.slice(MAX_BLOCK_NUM - 1), originalBlocks: originalBlocks.slice(MAX_BLOCK_NUM - 1) }); } temp.push({ url, blocks, originalBlocks }); } const responses = UrlFetchApp.fetchAll(temp.map(({ url, blocks }) => { return { url, method: 'POST', headers: { 'Content-Type': 'application/json' }, payload: JSON.stringify({ blocks }), muteHttpExceptions: true, }; })); const responses2 = UrlFetchApp.fetchAll(responses.flatMap((response, i) => { if (response.getResponseCode() === 200) return []; // 記法のparseを飛ばして送り直す console.log(`Retry to post it to ${params[i].url}\n`, temp[i].originalBlocks); return { url: temp[i].url, method: 'POST', headers: { 'Content-Type': 'application/json' }, payload: JSON.stringify({ blocks: temp[i].originalBlocks }), muteHttpExceptions: true, }; })); responses2.forEach((response, i) => { if (response.getResponseCode() === 200) return; console.info(response.getContentText(), '\n', temp[i].originalBlocks); }) } const zero = n => String(n).padStart(2, '0'); const toYYYYMMDD = seconds => { const d = new Date(seconds * 1000); return `${d.getFullYear()}-${zero(d.getMonth() + 1)}-${zero(d.getDate())}`; }; const toHHMMSS = seconds => { const d = new Date(seconds * 1000); return `${zero(d.getHours())}:${zero(d.getMinutes())}:${zero(d.getSeconds())}`; }; const toYYYYMMDD_HHMMSS = seconds => `${toYYYYMMDD(seconds)} ${toHHMMSS(seconds)}`;

通知対象のprojectと通知先のwebhook URLを設定する
include exclude に正規表現を渡すと、各webhook URLに渡す更新情報を絞り込むことができる
include にマッチして、 exclude にマッチしない更新情報のみを流す
createDefaultSettings.gs(js)
function createDefaultSettings() { return [ { webhook: 'https://hooks.slack.com/services/xxxxx', project: 'villagepump', include: /コミュニケーション|communication/i, }, { webhook: 'https://hooks.slack.com/services/yyyyyy', project: 'villagepump', include: /takker/, },

既定では、指定したprojectの全ての更新が通知される
createDefaultSettings.gs(js)
{ webhook: 'https://hooks.slack.com/services/zzzzzz', project: 'villagepump', }, ] }

parser.gs
/takker/scrapbox-parser.min.jsから export をとったやつを使ってください

sb2mrkdwn.gs

すごい、動いたyosider
トリガーのほうはまだやってみてないけど
通知が一度にたくさん来てしまうので、1メッセージにまとめてもいいかも
一つのメッセージに含められるblockの数に限度があるので難しいですtakker
なるほど まあ50もあれば大抵は1メッセージで済みそうですねyosider
ちなみに50で済まないページは日記ページなどですtakker
URLだけ通知するようにすれば、通知の数を大幅に減らせると思います