scrapbox-sync
同期元projectで特定のタグがついたページを、同期先projectにexport/importする
同期元projectで削除されたページを同期先projectでも削除する
用途
関連
実装
非対応
無料だと10秒の実行時間制限がある
いけそう
登録しなくても使えます
登録すると無料枠が増えるみたいです
Please verify your account to install this add-on plan (please enter a credit card)とのことなので、add-on(
Heroku Scheduler)を使っているから?
そゆこと
Google Cloud Functions + Google Apps Scriptによる実装
GAS
トリガー機能を利用して定期的にGCFを実行する
導入方法
以下のGASのコードを適宜変更し貼り付ける
1日1回とか
手動で実行したい場合は、エディタから main
を実行する
GCF
GASからのリクエストに応じてページをimport・削除する
ローカル環境作成が楽だったので
セキュリティのためにキーを設定する
リクエストのヘッダにこのキーがついてないとエラーになる
(気休めです、もっといい方法ありそう)
導入方法
クレカ登録が必要
このスクリプトくらいなら無料範囲内のはず
月当たり1円とか2円とかかかってしまっている
うわー……
Cloud Functionをデプロイする
関数名、リージョンは任意
トリガーをHTTP、未認証の呼び出しを許可にチェック
「変数、ネットワーク、詳細設定」欄
詳細→メモリ1GB、タイムアウト60秒
環境変数→ランタイム環境変数に上記のキーを設定する
名前: X_INTERNAL_KEY
値: 任意のキー
以下のGCFのコードを作成・貼り付ける
ランタイム:Python3.8
エントリポイント:main
Cloud Build APIの有効化を求められるので有効化する
デプロイ
以降、GASからのリクエストに応じて動くはず
既知の問題・TODO
page取得の間隔をあけてサーバーの負荷を減らす?
たくさんAPIを叩くわけじゃないし、そんなに気にしなくてもいいと思う
GASのコード
メイン
main.gs(js)function main() {
const configs = getConfigs();
configs.forEach(c => {
const response = UrlFetchApp.fetch(
'https://***.cloudfunctions.net/***', // GCFで作成した関数のトリガーURL
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Internal-Key': '***', // GCF側で設定したキー
},
payload: JSON.stringify(c)
}
);
console.log(JSON.stringify(response.getContentText("Shift_JIS"), null, 2));
})
}
設定
const configs
を消しました
top levelに書いた変数・関数は全てglobal scopeに属してしまう
config.gs(js)function getConfigs() {
return [
{
src_project: '同期元プロジェクト名',
dst_project: '同期先プロジェクト名',
public_tag: 'not-private', // このタグが付いているページがimportされる
private_tag: 'private', // このタグが付いているページのpublic_tagは無視される
whitelist: ['settings', 'my_page'], // 同期元になくても削除されないページ名
sid: '***', // connect.sid(取り扱い注意)
},
];
}
GCFのコード
依存パッケージ
メイン
pyppeteerでscrapboxを操作する部分を別のmoduleに切り出したほうが見通しが良くなりそう
関数内で関数定義してるとこですかね、確かに読みにくいと思ってる…
そうそう>
関数内で関数定義してるとこ関数の整理・分割案
scrapbox.io <=> GCFとの通信
scrapbox-sync固有の処理は入っていない
main.py
/ import_pages
同期先プロジェクトへ対象ページをimportする
utils.py
/ get_all_titles
特定のscrapbox projectの全ページタイトルを取得する
空ページは除外する
main.py
/ delete_pages
指定したproject内の指定したページを削除する
この関数群は scrapbox.py
とかでmoduleに切り出せるな
scrapbox-sync関連
main.py
/ main
外部との窓口
POSTされたデータをもとに処理を行う
main.py
/ get_public_pages
scrapbox.io
同期元プロジェクトから同期対象のページを取得する
やること
utils.py
/ get_all_titles
で取得したページリストから絞り込む
main.py
/ get_deleted_titles
同期元プロジェクトで削除されたページを取得する
src_pages
は何を示している?
未分類
utils.py
/ get_all
main.py
/ get_public_pages
でのみ使っている関数
/api/pages/:projectname/:pagetitle
を叩くのにしか使っていないみたい
それなら処理を絞り込んで、指定したページ情報を取得する関数にしたほうが、関数と機能とが一致するからわかりやすくなりそう
関数名は get_page_data
とかどうだろう
utils.py
/ get
utils.py
/ get_all
用
main.pyimport os
import re
import json
import urllib
import requests
import asyncio
import traceback
from pyppeteer import launch
from flask import request, abort, jsonify
from flask import current_app as app
from utils import get_all_titles, get_all
root = 'https://scrapbox.io'
app.config['JSON_AS_ASCII'] = False # responseに日本語を含めるため
def get_public_pages(src_project, public_tag, private_tag, sid):
'''同期元プロジェクトから同期対象のページを取得'''
tag_page = f'{root}/api/pages/{src_project}/{public_tag}'
tagged_pages_metadata = requests.get(tag_page, headers={'Cookie': f'connect.sid={sid}'}).json()['relatedPages']['links1hop']
tagged_pages_urls = [f'{root}/api/pages/{src_project}/{urllib.parse.quote(page["title"], safe="")}' for page in tagged_pages_metadata]
tagged_pages = asyncio.run(get_all(tagged_pages_urls, cookies={'connect.sid': sid}))
public_pages = []
while tagged_pages:
page = tagged_pages.pop()
texts = [line['text'] for line in page['lines']]
# cancel if private tag exists
if f'#{private_tag}' in texts:
continue
# remove public-tag line
tag_idx = texts.index(f'#{public_tag}') # NOTE: only the first one found
# remove line next to tag line if it's empty
if tag_idx < len(texts) - 1 and not texts[tag_idx + 1]:
del page['lines'][tag_idx + 1]
del page['lines'][tag_idx]
# remove unneccesary attributions
public_pages.append({key: page[key] for key in ['title', 'created', 'updated', 'id', 'lines']})
print(f'Found {len(public_pages)} pages to import.')
return public_pages
def import_pages(pages, dst_project, sid):
'''同期先プロジェクトへ対象ページをimportする'''
import_json = json.dumps({'pages': pages})
token = requests.get(
f'{root}/api/users/me',
headers={'Cookie': f'connect.sid={sid}'},
).json()['csrfToken']
response = requests.post(
f'{root}/api/page-data/import/{dst_project}.json',
headers={'Cookie': f'connect.sid={sid}', 'X-CSRF-TOKEN': token},
files={'import-file': ('import.json', import_json, 'application/json')},
)
print(f'from {root}: {response.json()["message"]}')
return response.status_code
def get_deleted_titles(src_pages, dst_project, whitelist):
'''同期元プロジェクトで削除されたページを取得'''
src_titles = [page['title'] for page in src_pages]
dst_titles = get_all_titles(dst_project)
to_delete_titles = list(filter(
lambda title: (title not in src_titles) and (title not in whitelist),
dst_titles
))
print(f'Found {len(to_delete_titles)} pages to delete.')
return to_delete_titles
def delete_pages(titles, dst_project, sid):
'''同期先プロジェクトで対象ページを削除'''
enc_urls = [f'{root}/{dst_project}/{urllib.parse.quote(title, safe="")}' for title in titles]
cookie = {
'name': 'connect.sid',
'value': sid,
'domain': 'scrapbox.io',
}
async def delete_page(enc_url, browser):
page = await browser.newPage()
await page.setCookie(cookie)
await page.goto(enc_url)
await page.waitForSelector('a[role="menuitem"][title="Delete"]')
await page.evaluate('() => window.confirm = (nope) => true')
await page.evaluate('''() => document.querySelector('a[role="menuitem"][title="Delete"]').click()''')
await page.waitForSelector('.quick-launch.layout-list')
return True
async def run():
browser = await launch(
handleSIGINT=False,
handleSIGTERM=False,
handleSIGHUP=False,
)
results = await asyncio.gather(*[delete_page(url, browser) for url in enc_urls], return_exceptions=True)
await browser.close()
return results
results = asyncio.run(run())
succeeded, failed = [], []
for title, res in zip(titles, results):
if res == True:
succeeded.append(f'{dst_project}/{title}')
else:
print(f'{dst_project}/{title} could not deleted due to {res}')
failed.append(f'{dst_project}/{title}')
result = dict(succeeded=succeeded, failed=failed)
print(f'delete_pages: {result}')
return result
def main(request):
if request.method != 'POST':
abort(404, 'not found')
if request.headers['X-Internal-Key'] != os.environ.get('X_INTERNAL_KEY'):
abort(403, 'forbidden')
content_type = request.headers['content-type']
if content_type != 'application/json':
abort(400, 'bad request')
request_json = request.get_json(silent=True)
keys = ['src_project', 'dst_project', 'public_tag', 'private_tag', 'whitelist', 'sid']
if not request_json or any(key not in request_json for key in keys):
abort(400, 'bad request')
src_project = request_json['src_project']
dst_project = request_json['dst_project']
public_tag = request_json['public_tag']
private_tag = request_json['private_tag']
whitelist = request_json['whitelist']
sid = request_json['sid']
to_import_pages = get_public_pages(src_project, public_tag, private_tag, sid)
import_response_code = import_pages(to_import_pages, dst_project, sid)
if import_response_code != 200:
abort(500, 'import error')
to_delete_titles = get_deleted_titles(to_import_pages, dst_project, whitelist)
delete_result = delete_pages(to_delete_titles, dst_project, sid)
return jsonify(number_of_imported_pages=f'{len(to_import_pages)}', delete=delete_result), 200
# debug
import yaml
from flask import Flask
if __name__ == '__main__':
with open('./envs.yaml') as f:
os.environ.update(yaml.load(f, Loader=yaml.FullLoader))
app = Flask(__name__)
@app.route('/', methods=['GET', 'POST'])
def index():
return main(request)
app.run('127.0.0.1', 8000, debug=True)
utils.pyimport requests
import asyncio
import aiohttp
utils.pydef get_all_titles(project):
''' cf: https://scrapbox.io/takker/ScrapboxのAPI取得支援関数を作る#5fd11f561280f00000e5693e '''
titles = []
following_id = None
while 1:
params = f'?followingId={following_id}' if following_id else ''
response = requests.get(
f'https://scrapbox.io/api/pages/{project}/search/titles{params}')
titles.extend([page['title'] for page in response.json()])
following_id = response.headers['X-Following-Id']
if not following_id:
break
return titles
async def get(session, url):
async with session.get(url) as response:
return await response.json()
async def get_all(urls, **session_kwargs):
async with aiohttp.ClientSession(**session_kwargs) as session:
tasks = [get(session, url) for url in urls]
return await asyncio.gather(*tasks)