1日ごとにScrapboxの更新を通知するbot
こんな感じの通知が流れます
用途
通知がないと、自分から何度もStreamを開くようになってしまい、時間が溶ける
1日一回、常に更新通知が来るとわかれば、自分から開きに行く必要もなくなる
このくらいの頻度なら、依存症にはならないだろう
1日1回では長すぎて我慢できずに開いてしまいそう
かなしいなあ……
通知が来るまで我慢しよう、と思える位の時間がいいと思う
ほしい機運が高まってきた…
使う環境
仕様を考える
Slack channelごとに通知を振り分ける
設定はjsonで書く
パターンが更新内容にマッチしたら通知する
パターンは正規表現を使う
除外検索もできるようにしてみたいな
include/excludeで2つの正規表現を設定できるようにする
通知頻度
通知内容のレイアウト案
本文の更新部分をすべて通知する
こっちを使いたいな
cons: 通知を読むだけで満足して、scrapboxを開かなくなる?
依存症対策なんだからそれでいい
URLだけ通知する
cons: 更新内容を確認するために、結局scrapboxを開いてしまう?
通知頻度を押さえれば問題ないか?
更新箇所がわかりにくいのは問題だな
こちらのほうが可読性は高そう
更新の先頭行へのリンクにするとよりわかりやすいかも
参考にする実装
2021-02-02 22:02:10 作ってます
おお~~
実装できたこと
基準日時以降に更新されたpageの取得
基準日時以降に更新された行の抽出
SlackへのPOST
これから実装すること
22:57:56 投稿するSlack channelを複数指定できるようにする
22:57:59 正規表現によるfiltering
JSONファイルから設定を読み込む
Google Driveに置く
好きなURLに配置する
scrapoxやgithub gistにおいていつでも手直しできるので、こっちのほうが便利かも
URLだけ通知するモードを実装する
構文解析
一部の装飾記法は全部omitしたほうがよさそうだな……
2021-02-03 01:49:24 なぜか一部のblocksが正常にPOSTされない……
02:09:58 失敗したら解析していない生文字列を返すようにした
今後もう少し対処をかんがえたい
もし失敗したら curl
コマンドを出力するようにしてみるか
terminalでそれを実行すれば、なんで失敗したのかがわかる
2021-02-03 11:10:09 最終更新日時が正常に反映されていなかったのを直した
実装したいこと
タイトルのURLを行リンクにする
にしたい
ESModuleも使えないようなplatformを使うのはもうやだ
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のみ
設定に応じて、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の全ての更新が通知される
parser.gs
sb2mrkdwn.gs
、動いた
トリガーのほうはまだやってみてないけど
通知が一度にたくさん来てしまうので、1メッセージにまとめてもいいかも
一つのメッセージに含められるblockの数に限度があるので難しいです
まあ50もあれば大抵は1メッセージで済みそうですね
ちなみに50で済まないページは日記ページなどです
URLだけ通知するようにすれば、通知の数を大幅に減らせると思います