1日ごとにScrapboxの更新を通知するbot
こんな感じの通知が流れます
https://gyazo.com/6fde83e374bd9d7333405dd024b7f10d
用途
通知がないと、自分から何度もStreamを開くようになってしまい、時間が溶ける
1日一回、常に更新通知が来るとわかれば、自分から開きに行く必要もなくなる
このくらいの頻度なら、依存症にはならないだろうtakker.icon
1日1回では長すぎて我慢できずに開いてしまいそうyosider.icon
かなしいなあ……takker.icon
通知が来るまで我慢しよう、と思える位の時間がいいと思うyosider.icon
/icons/たしかに.icon
ほしい機運が高まってきた…yosider.icon
使う環境
仕様を考える
Slack channelごとに通知を振り分ける
設定はjsonで書く
パターンが更新内容にマッチしたら通知する
パターンは正規表現を使う
除外検索もできるようにしてみたいなtakker.icon
include/excludeで2つの正規表現を設定できるようにする
通知頻度
通知内容のレイアウト案
本文の更新部分をすべて通知する
こっちを使いたいな
cons: 通知を読むだけで満足して、scrapboxを開かなくなる?
依存症対策なんだからそれでいいtakker.icon
URLだけ通知する
cons: 更新内容を確認するために、結局scrapboxを開いてしまう?
通知頻度を押さえれば問題ないか?takker.icon
更新箇所がわかりにくいのは問題だな
こちらのほうが可読性は高そうyosider.icon
更新の先頭行へのリンクにするとよりわかりやすいかも
両方作ってABテストすればいいかなtakker.icon /icons/よさそう.icon
参考にする実装
2021-02-02 22:02:10 作ってますtakker.icon
おお~~/icons/感謝.iconyosider.icon
https://gyazo.com/a10c85b1241a49840bb52ad28f9ea51f
実装できたこと
基準日時以降に更新されたpageの取得
基準日時以降に更新された行の抽出
SlackへのPOST
これから実装すること
/icons/done.icon22:57:56 投稿するSlack channelを複数指定できるようにする
/icons/done.icon22:57:59 正規表現によるfiltering
JSONファイルから設定を読み込む
Google Driveに置く
好きなURLに配置する
scrapoxやgithub gistにおいていつでも手直しできるので、こっちのほうが便利かも
URLだけ通知するモードを実装する
/icons/done.icon構文解析
一部の装飾記法は全部omitしたほうがよさそうだな……
2021-02-03 01:49:24 なぜか一部のblocksが正常にPOSTされない……
02:09:58 失敗したら解析していない生文字列を返すようにした
今後もう少し対処をかんがえたい
もし失敗したらcurlコマンドを出力するようにしてみるか
terminalでそれを実行すれば、なんで失敗したのかがわかる
2021-02-03 11:10:09 最終更新日時が正常に反映されていなかったのを直した
実装したいこと
タイトルのURLを行リンクにする
/icons/heroku.iconにしたいtakker.icon
ESModuleも使えないようなplatformを使うのはもうやだ
2021-06-04 08:48:43 repo作った
private projectへの対応
インデントを表示する
通知の順序を直す
更新の古い順から通知する
コード
だいぶ長くなったので、分割を考えている
titleListじゃなくてpageSummariesのほうが適切な命名かな
code: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();
// 最終更新日時を更新する
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のみ
設定に応じて、URLのみ通知できるようにしたい
code: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;
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: paramsi.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, tempi.originalBlocks); return {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
payload: JSON.stringify({ blocks: tempi.originalBlocks }), muteHttpExceptions: true,
};
}));
responses2.forEach((response, i) => {
if (response.getResponseCode() === 200) return;
console.info(response.getContentText(), '\n', tempi.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にマッチしない更新情報のみを流す
code:createDefaultSettings.gs(js)
function createDefaultSettings() {
return [
{
project: 'villagepump',
include: /コミュニケーション|communication/i,
},
{
project: 'villagepump',
include: /takker/,
},
既定では、指定したprojectの全ての更新が通知される
code:createDefaultSettings.gs(js)
{
project: 'villagepump',
},
]
}
parser.gs
sb2mrkdwn.gs
/icons/すごい.icon、動いたyosider.icon
トリガーのほうはまだやってみてないけど
通知が一度にたくさん来てしまうので、1メッセージにまとめてもいいかも
一つのメッセージに含められるblockの数に限度があるので難しいですtakker.icon
/icons/なるほど.icon まあ50もあれば大抵は1メッセージで済みそうですねyosider.icon
ちなみに50で済まないページは日記ページなどですtakker.icon
URLだけ通知するようにすれば、通知の数を大幅に減らせると思います