├── .github └── workflows │ └── backup.yml ├── .gitignore ├── README.md ├── config.json ├── notify.py ├── notion_backup.py ├── run_job.py └── wget_notion_zip.py /.github/workflows/backup.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | # Controls when the action will run. 6 | # on: 7 | # # Triggers the workflow on push or pull request events but only for the main branch 8 | # push: 9 | # branches: [main] 10 | # pull_request: 11 | # branches: [main] 12 | on: 13 | # schedule: 14 | # - cron: "0 7 * * *" # 每天7点执行 , 去掉此处注释开启触发 15 | # Allows you to run this workflow manually from the Actions tab 16 | workflow_dispatch: 17 | 18 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 19 | jobs: 20 | # This workflow contains a single job called "build" 21 | build: 22 | # The type of runner that the job will run on 23 | runs-on: ubuntu-latest 24 | 25 | # Steps represent a sequence of tasks that will be executed as part of the job 26 | steps: 27 | - uses: actions/checkout@v3 28 | - uses: actions/setup-python@v4.5.0 29 | with: 30 | python-version: "3.10" 31 | - name: install dependency 32 | run: pip3 install requests 33 | - name: run notion_backup.py 34 | env: 35 | NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} # 在后台配置好token,或者用NOTION_EMAIL和NOTION_PASSWORD也可以 36 | run: | 37 | echo '开始执行' 38 | python -u notion_backup.py 39 | echo '执行完成' 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | log.txt 2 | notion-backup-dev.py 3 | __pycache__ 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # notion-backup 2 | 3 | ## notion 自动备份脚本 4 | 5 | ## Automatic Notion workspace backup to git and local 6 | 7 | 基于`python3`,利用 notion 官方 api,自动导出所有工作空间内数据为 markdown 格式,虽然官方 API 导出的为 zip,但是脚本会解压,然后一起上传至 github,因为在 github,所以也拥有了版本历史功能。 8 | 9 | ### Debian/Ubuntu 简单安装 python3 及 pip3 10 | 11 | ``` 12 | apt-get update 13 | apt-get install python3 -y 14 | apt-get install python3-pip -y 15 | ``` 16 | 17 | ### Centos 简单安装 python3 及 pip3 18 | 19 | ``` 20 | yum update 21 | yum install python3 -y 22 | yum install python3-pip -y 23 | ``` 24 | 25 | ### 验证安装结果 26 | 27 | ``` 28 | python3 -v 29 | pip3 -V 30 | ``` 31 | 32 | 需要安装相关依赖包 33 | 34 | ``` 35 | pip3 install requests 36 | ``` 37 | 38 | ### 使用方法 39 | 40 | ### 1. 使用用户名密码方式 41 | 42 | 设置系统环境变量 43 | `NOTION_EMAIL` 为账户 44 | `NOTION_PASSWORD`为密码 45 | 或者直接修改变量为 46 | `NOTION_EMAIL='yourEmail@email.com'` 47 | `NOTION_PASSWORD='your Password'` 48 | 49 | ### 2. 使用 token 方式 50 | 51 | 浏览器开发者工具,选择 Network,查看 notion 页面请求中的 cookie 52 | `token_v2=xxxxxxxxxxx` 53 | 然后设置环境变量`NOTION_TOKEN`为上面的 token_v2 后的值 54 | 或者直接设置变量`NOTION_TOKEN='your Token'` 55 | 56 | ### 3. 存储位置 57 | 58 | 默认存储到程序目录`backup`文件夹,如需修改,修改脚本中变量`SAVE_DIR`的值 59 | 60 | ### 4. 上传到远程存储 61 | 62 | 修改以下信息 63 | 64 | ``` 65 | REPOSITORY_URL = "https://github.com/LoneKingCode/xxx.git" 66 | REPOSITORY_BRANCH = "main" 67 | GIT_USERNAME ='111' 68 | GIT_EMAIL ='111@111.com' 69 | ``` 70 | 71 | 默认上传到 github,如果使用,需要自己新建一个私有仓库,然后 notion-backup.py 提交至该新仓库,然后在该仓库目录下运行 notion-backup.py 即可 72 | 注:如果不用ci执行,在服务器本地执行的话 73 | 记得修改`.git`文件夹中的`config`文件,把用户名和密码配置到仓库地址 上,防止脚本自动 push 代码时,需要输入用户名密码 74 | 或者手动先push一次,并且设置credentials 75 | 76 | 记得设置保存备份文件仓库的.gitignore为这样 77 | 78 | ``` 79 | *.zip 80 | /backup/*.zip 81 | log.txt 82 | __pycache__ 83 | ``` 84 | 85 | ### 5. 备份配置 86 | 87 | notion-backup.py顶部的`DEFAULT_BACKUP_CONFIG`变量 88 | 主要`block_id`是`-`分开的注意位数 89 | {'spaces':[]} 则备份所有空间 'space_blocks':[] 则备份整个空间 90 | 91 | ```python 92 | # 1.备份所有空间 93 | {'spaces': []} 94 | # 2.备份指定空间所有block 95 | {'spaces': [ 96 | {'space_name': 'space_name', 'space_blocks': []} 97 | ] 98 | } 99 | # 2.1两种方式都可以 100 | {'spaces': [ 101 | {'space_name': 'space_name'} 102 | ] 103 | } 104 | # 3.备份指定空间指定block及block的子页面 105 | {'spaces': [ 106 | {'space_name': 'space_name', 'space_blocks': [ 107 | {'block_id': '12345678-1234-1234-1234-123456789123', 'block_name': 'Home'} 108 | ] 109 | } 110 | ] 111 | } 112 | # 4.也可以修改config.json 113 | config.json为上面备份配置的json格式数据,注意里面符号为#双引号# 114 | ``` 115 | 116 | ### 6. 执行脚本 117 | 118 | ```shell 119 | python3 notion-backup.py 120 | ``` 121 | 122 | ```shell 123 | config.json为上面备份配置的json格式数据,注意里面符号为#双引号# 124 | python3 notion-backup.py -c /your_dir/config.json 125 | ``` 126 | 127 | ```python 128 | run_job.py是利用python的schedule定时执行的,有需要可以用 129 | ``` 130 | 131 | ### 7. 导出其他格式 132 | 133 | 修改代码中`exportTask`方法中的`exportType`为要导出的类型 134 | markdown/html/pdf 135 | 136 | ### 8. 注意事项 137 | 138 | #### 8.1 windows 路径最大长度 255 字符,notion 的文件夹、文件末尾都带了类似 md5 的东西,所以文件树过深时,windows 系统上解压会报错,目录树过深时请于 linux 系统使用 139 | 140 | #### 8.2 如果不需要提交到git注释代码中initGit() pull() push() 141 | 142 | ```python 143 | # 初始化git仓库 144 | #initGit() 145 | # git 146 | #print('开始提交代码') 147 | #pull() 148 | #push() 149 | ``` 150 | 151 | ### 9. 使用效果 152 | 153 | ![image](https://user-images.githubusercontent.com/11244921/212226093-773c7c7d-3020-4bb8-825f-e9459452301a.png) 154 | ![image](https://user-images.githubusercontent.com/11244921/212226257-8b64b5fa-07a9-4eb6-b912-6d20e34c8c80.png) 155 | 156 | ### 10. 消息推送 157 | 158 | 修改`notify.py`中顶部的推送参数即可,比如TG推送就设置`push_config`的 `TG_BOT_TOKEN`和`TG_USER_ID` 159 | ![image](https://user-images.githubusercontent.com/11244921/212223591-e44c678c-d391-4108-9a62-c74cb79e16f8.png) 160 | 161 | ### 11. 其他使用方法 162 | 163 | 搭配`Github`的`CI/DI`以及`Schedule`,可以实现全自动定时打包,上传,而且不需要在自己的服务器上执行。 164 | 参考文件.github/workflows/backup.yml 165 | 需要配置secret,在项目主页菜单的settings/secrets and variables/Actions中配置 166 | 需要定时执行的话配置schedule,修改cron表达式 167 | 168 | ```yaml 169 | on: 170 | schedule: 171 | - cron: "0 7 * * *" # 每天7点执行 172 | ``` 173 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "spaces": [ 3 | { 4 | "space_name": "XXXXX", 5 | "space_blocks": [ 6 | { 7 | "block_id": "12345678-1234-1234-1234-123456789123", 8 | "block_name": "TEST_CONFIG_HOME" 9 | } 10 | ] 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /notify.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # _*_ coding:utf-8 _*_ 3 | import base64 4 | import hashlib 5 | import hmac 6 | import json 7 | import os 8 | import re 9 | import threading 10 | import time 11 | import urllib.parse 12 | from config import TELEGRAM, WXPUSHER 13 | import requests 14 | 15 | # 原先的 print 函数和主线程的锁 16 | _print = print 17 | mutex = threading.Lock() 18 | 19 | 20 | # 定义新的 print 函数 21 | def print(text, *args, **kw): 22 | """ 23 | 使输出有序进行,不出现多线程同一时间输出导致错乱的问题。 24 | """ 25 | with mutex: 26 | _print(text, *args, **kw) 27 | 28 | 29 | # 通知服务 30 | # fmt: off 31 | push_config = { 32 | 'HITOKOTO': False, # 启用一言(随机句子) 33 | 'BARK_PUSH': '', # bark IP 或设备码,例:https://api.day.app/DxHcxxxxxRxxxxxxcm/ 34 | 'BARK_ARCHIVE': '', # bark 推送是否存档 35 | 'BARK_GROUP': '', # bark 推送分组 36 | 'BARK_SOUND': '', # bark 推送声音 37 | 'CONSOLE': True, # 控制台输出 38 | 'DD_BOT_SECRET': '', # 钉钉机器人的 DD_BOT_SECRET 39 | 'DD_BOT_TOKEN': '', # 钉钉机器人的 DD_BOT_TOKEN 40 | 'FSKEY': '', # 飞书机器人的 FSKEY 41 | 'GOBOT_URL': '', # go-cqhttp 42 | # 推送到个人QQ:http://127.0.0.1/send_private_msg 43 | # 群:http://127.0.0.1/send_group_msg 44 | 'GOBOT_QQ': '', # go-cqhttp 的推送群或用户 45 | # GOBOT_URL 设置 /send_private_msg 时填入 user_id=个人QQ 46 | # /send_group_msg 时填入 group_id=QQ群 47 | 'GOBOT_TOKEN': '', # go-cqhttp 的 access_token 48 | 'GOTIFY_URL': '', # gotify地址,如https://push.example.de:8080 49 | 'GOTIFY_TOKEN': '', # gotify的消息应用token 50 | 'GOTIFY_PRIORITY': 0, # 推送消息优先级,默认为0 51 | 'IGOT_PUSH_KEY': '', # iGot 聚合推送的 IGOT_PUSH_KEY 52 | 'PUSH_KEY': '', # server 酱的 PUSH_KEY,兼容旧版与 Turbo 版 53 | 'PUSH_PLUS_TOKEN': '', # push+ 微信推送的用户令牌 54 | 'PUSH_PLUS_USER': '', # push+ 微信推送的群组编码 55 | 'QMSG_KEY': '', # qmsg 酱的 QMSG_KEY 56 | 'QMSG_TYPE': '', # qmsg 酱的 QMSG_TYPE 57 | 'QYWX_AM': '', # 企业微信应用 58 | 'QYWX_KEY': '', # 企业微信机器人 59 | 'TG_BOT_TOKEN': '', # 必填! tg 机器人的 TG_BOT_TOKEN,例:1407203283:AAG9rt-6RDaaX0HBLZQq0laNOh898iFYaRQ 60 | 'TG_USER_ID': '', # 必填! tg 机器人的 TG_USER_ID,例:1434078534 61 | 'WXPUSHER_URL': '', 62 | 'WXPUSHER_TOKEN': '', 63 | 'WXPUSHER_TOPICS': [], 64 | 'TG_API_HOST': '', # tg 代理 api 65 | 'TG_PROXY_AUTH': '', # tg 代理认证参数 66 | 'TG_PROXY_HOST': '', # tg 机器人的 TG_PROXY_HOST 67 | 'TG_PROXY_PORT': '', # tg 机器人的 TG_PROXY_PORT 68 | } 69 | notify_function = [] 70 | # fmt: on 71 | 72 | # 首先读取 面板变量 或者 github action 运行变量 73 | for k in push_config: 74 | if os.getenv(k): 75 | v = os.getenv(k) 76 | push_config[k] = v 77 | 78 | 79 | def bark(title: str, content: str) -> None: 80 | """ 81 | 使用 bark 推送消息。 82 | """ 83 | if not push_config.get("BARK_PUSH"): 84 | print("bark 服务的 BARK_PUSH 未设置!!\n取消推送") 85 | return 86 | print("bark 服务启动") 87 | 88 | if push_config.get("BARK_PUSH").startswith("http"): 89 | url = f'{push_config.get("BARK_PUSH")}/{urllib.parse.quote_plus(title)}/{urllib.parse.quote_plus(content)}' 90 | else: 91 | url = f'https://api.day.app/{push_config.get("BARK_PUSH")}/{urllib.parse.quote_plus(title)}/{urllib.parse.quote_plus(content)}' 92 | 93 | bark_params = { 94 | "BARK_ARCHIVE": "isArchive", 95 | "BARK_GROUP": "group", 96 | "BARK_SOUND": "sound", 97 | } 98 | params = "" 99 | for pair in filter( 100 | lambda pairs: pairs[0].startswith("BARK_") and pairs[0] != "BARK_PUSH" and pairs[1] and bark_params.get(pairs[0]), 101 | push_config.items(), 102 | ): 103 | params += f"{bark_params.get(pair[0])}={pair[1]}&" 104 | if params: 105 | url = url + "?" + params.rstrip("&") 106 | response = requests.get(url).json() 107 | 108 | if response["code"] == 200: 109 | print("bark 推送成功!") 110 | else: 111 | print("bark 推送失败!") 112 | 113 | 114 | def console(title: str, content: str) -> None: 115 | """ 116 | 使用 控制台 推送消息。 117 | """ 118 | print(f"{title}\n\n{content}") 119 | 120 | 121 | def dingding_bot(title: str, content: str) -> None: 122 | """ 123 | 使用 钉钉机器人 推送消息。 124 | """ 125 | if not push_config.get("DD_BOT_SECRET") or not push_config.get("DD_BOT_TOKEN"): 126 | print("钉钉机器人 服务的 DD_BOT_SECRET 或者 DD_BOT_TOKEN 未设置!!\n取消推送") 127 | return 128 | print("钉钉机器人 服务启动") 129 | 130 | timestamp = str(round(time.time() * 1000)) 131 | secret_enc = push_config.get("DD_BOT_SECRET").encode("utf-8") 132 | string_to_sign = "{}\n{}".format(timestamp, push_config.get("DD_BOT_SECRET")) 133 | string_to_sign_enc = string_to_sign.encode("utf-8") 134 | hmac_code = hmac.new(secret_enc, string_to_sign_enc, digestmod=hashlib.sha256).digest() 135 | sign = urllib.parse.quote_plus(base64.b64encode(hmac_code)) 136 | url = f'https://oapi.dingtalk.com/robot/send?access_token={push_config.get("DD_BOT_TOKEN")}×tamp={timestamp}&sign={sign}' 137 | headers = {"Content-Type": "application/json;charset=utf-8"} 138 | data = {"msgtype": "text", "text": {"content": f"{title}\n\n{content}"}} 139 | response = requests.post(url=url, data=json.dumps(data), headers=headers, timeout=15).json() 140 | 141 | if not response["errcode"]: 142 | print("钉钉机器人 推送成功!") 143 | else: 144 | print("钉钉机器人 推送失败!") 145 | 146 | 147 | def feishu_bot(title: str, content: str) -> None: 148 | """ 149 | 使用 飞书机器人 推送消息。 150 | """ 151 | if not push_config.get("FSKEY"): 152 | print("飞书 服务的 FSKEY 未设置!!\n取消推送") 153 | return 154 | print("飞书 服务启动") 155 | 156 | url = f'https://open.feishu.cn/open-apis/bot/v2/hook/{push_config.get("FSKEY")}' 157 | data = {"msg_type": "text", "content": {"text": f"{title}\n\n{content}"}} 158 | response = requests.post(url, data=json.dumps(data)).json() 159 | 160 | if response.get("StatusCode") == 0: 161 | print("飞书 推送成功!") 162 | else: 163 | print("飞书 推送失败!错误信息如下:\n", response) 164 | 165 | 166 | def go_cqhttp(title: str, content: str) -> None: 167 | """ 168 | 使用 go_cqhttp 推送消息。 169 | """ 170 | if not push_config.get("GOBOT_URL") or not push_config.get("GOBOT_QQ"): 171 | print("go-cqhttp 服务的 GOBOT_URL 或 GOBOT_QQ 未设置!!\n取消推送") 172 | return 173 | print("go-cqhttp 服务启动") 174 | 175 | url = f'{push_config.get("GOBOT_URL")}?access_token={push_config.get("GOBOT_TOKEN")}&{push_config.get("GOBOT_QQ")}&message=标题:{title}\n内容:{content}' 176 | response = requests.get(url).json() 177 | 178 | if response["status"] == "ok": 179 | print("go-cqhttp 推送成功!") 180 | else: 181 | print("go-cqhttp 推送失败!") 182 | 183 | 184 | def gotify(title: str, content: str) -> None: 185 | """ 186 | 使用 gotify 推送消息。 187 | """ 188 | if not push_config.get("GOTIFY_URL") or not push_config.get("GOTIFY_TOKEN"): 189 | print("gotify 服务的 GOTIFY_URL 或 GOTIFY_TOKEN 未设置!!\n取消推送") 190 | return 191 | print("gotify 服务启动") 192 | 193 | url = f'{push_config.get("GOTIFY_URL")}/message?token={push_config.get("GOTIFY_TOKEN")}' 194 | data = {"title": title, "message": content, "priority": push_config.get("GOTIFY_PRIORITY")} 195 | response = requests.post(url, data=data).json() 196 | 197 | if response.get("id"): 198 | print("gotify 推送成功!") 199 | else: 200 | print("gotify 推送失败!") 201 | 202 | 203 | def iGot(title: str, content: str) -> None: 204 | """ 205 | 使用 iGot 推送消息。 206 | """ 207 | if not push_config.get("IGOT_PUSH_KEY"): 208 | print("iGot 服务的 IGOT_PUSH_KEY 未设置!!\n取消推送") 209 | return 210 | print("iGot 服务启动") 211 | 212 | url = f'https://push.hellyw.com/{push_config.get("IGOT_PUSH_KEY")}' 213 | data = {"title": title, "content": content} 214 | headers = {"Content-Type": "application/x-www-form-urlencoded"} 215 | response = requests.post(url, data=data, headers=headers).json() 216 | 217 | if response["ret"] == 0: 218 | print("iGot 推送成功!") 219 | else: 220 | print(f'iGot 推送失败!{response["errMsg"]}') 221 | 222 | 223 | def serverJ(title: str, content: str) -> None: 224 | """ 225 | 通过 serverJ 推送消息。 226 | """ 227 | if not push_config.get("PUSH_KEY"): 228 | print("serverJ 服务的 PUSH_KEY 未设置!!\n取消推送") 229 | return 230 | print("serverJ 服务启动") 231 | 232 | data = {"text": title, "desp": content.replace("\n", "\n\n")} 233 | if push_config.get("PUSH_KEY").index("SCT") != -1: 234 | url = f'https://sctapi.ftqq.com/{push_config.get("PUSH_KEY")}.send' 235 | else: 236 | url = f'https://sc.ftqq.com/${push_config.get("PUSH_KEY")}.send' 237 | response = requests.post(url, data=data).json() 238 | 239 | if response.get("errno") == 0 or response.get("code") == 0: 240 | print("serverJ 推送成功!") 241 | else: 242 | print(f'serverJ 推送失败!错误码:{response["message"]}') 243 | 244 | 245 | def pushplus_bot(title: str, content: str) -> None: 246 | """ 247 | 通过 push+ 推送消息。 248 | """ 249 | if not push_config.get("PUSH_PLUS_TOKEN"): 250 | print("PUSHPLUS 服务的 PUSH_PLUS_TOKEN 未设置!!\n取消推送") 251 | return 252 | print("PUSHPLUS 服务启动") 253 | 254 | url = "http://www.pushplus.plus/send" 255 | data = { 256 | "token": push_config.get("PUSH_PLUS_TOKEN"), 257 | "title": title, 258 | "content": content, 259 | "topic": push_config.get("PUSH_PLUS_USER"), 260 | } 261 | body = json.dumps(data).encode(encoding="utf-8") 262 | headers = {"Content-Type": "application/json"} 263 | response = requests.post(url=url, data=body, headers=headers).json() 264 | 265 | if response["code"] == 200: 266 | print("PUSHPLUS 推送成功!") 267 | 268 | else: 269 | 270 | url_old = "http://pushplus.hxtrip.com/send" 271 | response = requests.post(url=url_old, data=body, headers=headers).json() 272 | 273 | if response["code"] == 200: 274 | print("PUSHPLUS(hxtrip) 推送成功!") 275 | 276 | else: 277 | print("PUSHPLUS 推送失败!") 278 | 279 | 280 | def qmsg_bot(title: str, content: str) -> None: 281 | """ 282 | 使用 qmsg 推送消息。 283 | """ 284 | if not push_config.get("QMSG_KEY") or not push_config.get("QMSG_TYPE"): 285 | print("qmsg 的 QMSG_KEY 或者 QMSG_TYPE 未设置!!\n取消推送") 286 | return 287 | print("qmsg 服务启动") 288 | 289 | url = f'https://qmsg.zendee.cn/{push_config.get("QMSG_TYPE")}/{push_config.get("QMSG_KEY")}' 290 | payload = {"msg": f'{title}\n\n{content.replace("----", "-")}'.encode("utf-8")} 291 | response = requests.post(url=url, params=payload).json() 292 | 293 | if response["code"] == 0: 294 | print("qmsg 推送成功!") 295 | else: 296 | print(f'qmsg 推送失败!{response["reason"]}') 297 | 298 | 299 | def wecom_app(title: str, content: str) -> None: 300 | """ 301 | 通过 企业微信 APP 推送消息。 302 | """ 303 | if not push_config.get("QYWX_AM"): 304 | print("QYWX_AM 未设置!!\n取消推送") 305 | return 306 | QYWX_AM_AY = re.split(",", push_config.get("QYWX_AM")) 307 | if 4 < len(QYWX_AM_AY) > 5: 308 | print("QYWX_AM 设置错误!!\n取消推送") 309 | return 310 | print("企业微信 APP 服务启动") 311 | 312 | corpid = QYWX_AM_AY[0] 313 | corpsecret = QYWX_AM_AY[1] 314 | touser = QYWX_AM_AY[2] 315 | agentid = QYWX_AM_AY[3] 316 | try: 317 | media_id = QYWX_AM_AY[4] 318 | except IndexError: 319 | media_id = "" 320 | wx = WeCom(corpid, corpsecret, agentid) 321 | # 如果没有配置 media_id 默认就以 text 方式发送 322 | if not media_id: 323 | message = title + "\n\n" + content 324 | response = wx.send_text(message, touser) 325 | else: 326 | response = wx.send_mpnews(title, content, media_id, touser) 327 | 328 | if response == "ok": 329 | print("企业微信推送成功!") 330 | else: 331 | print("企业微信推送失败!错误信息如下:\n", response) 332 | 333 | 334 | class WeCom: 335 | def __init__(self, corpid, corpsecret, agentid): 336 | self.CORPID = corpid 337 | self.CORPSECRET = corpsecret 338 | self.AGENTID = agentid 339 | 340 | def get_access_token(self): 341 | url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken" 342 | values = { 343 | "corpid": self.CORPID, 344 | "corpsecret": self.CORPSECRET, 345 | } 346 | req = requests.post(url, params=values) 347 | data = json.loads(req.text) 348 | return data["access_token"] 349 | 350 | def send_text(self, message, touser="@all"): 351 | send_url = ("https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=" + self.get_access_token()) 352 | send_values = { 353 | "touser": touser, 354 | "msgtype": "text", 355 | "agentid": self.AGENTID, 356 | "text": { 357 | "content": message 358 | }, 359 | "safe": "0", 360 | } 361 | send_msges = bytes(json.dumps(send_values), "utf-8") 362 | respone = requests.post(send_url, send_msges) 363 | respone = respone.json() 364 | return respone["errmsg"] 365 | 366 | def send_mpnews(self, title, message, media_id, touser="@all"): 367 | send_url = ("https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=" + self.get_access_token()) 368 | send_values = { 369 | "touser": touser, 370 | "msgtype": "mpnews", 371 | "agentid": self.AGENTID, 372 | "mpnews": { 373 | "articles": [{ 374 | "title": title, 375 | "thumb_media_id": media_id, 376 | "author": "Author", 377 | "content_source_url": "", 378 | "content": message.replace("\n", "
"), 379 | "digest": message, 380 | }] 381 | }, 382 | } 383 | send_msges = bytes(json.dumps(send_values), "utf-8") 384 | respone = requests.post(send_url, send_msges) 385 | respone = respone.json() 386 | return respone["errmsg"] 387 | 388 | 389 | def wecom_bot(title: str, content: str) -> None: 390 | """ 391 | 通过 企业微信机器人 推送消息。 392 | """ 393 | if not push_config.get("QYWX_KEY"): 394 | print("企业微信机器人 服务的 QYWX_KEY 未设置!!\n取消推送") 395 | return 396 | print("企业微信机器人服务启动") 397 | 398 | url = f"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key={push_config.get('QYWX_KEY')}" 399 | headers = {"Content-Type": "application/json;charset=utf-8"} 400 | data = {"msgtype": "text", "text": {"content": f"{title}\n\n{content}"}} 401 | response = requests.post(url=url, data=json.dumps(data), headers=headers, timeout=15).json() 402 | 403 | if response["errcode"] == 0: 404 | print("企业微信机器人推送成功!") 405 | else: 406 | print("企业微信机器人推送失败!") 407 | 408 | 409 | def wxpusher(title, content) -> None: 410 | print("wxpusher服务启动") 411 | headers = {'content-type': 'application/json'} 412 | data = {"appToken": push_config.get("WXPUSHER_TOKEN"), "content": content, "summary": title, "contentType": 1, "topicIds": push_config.get("WXPUSHER_TOPICS"), "verifyPay": False} 413 | 414 | response = requests.post(url=push_config.get("WXPUSHER_URL"), data=json.dumps(data), headers=headers, timeout=15).json() 415 | if response["success"]: 416 | print("wxpusher推送成功!") 417 | else: 418 | print("wxpusher推送失败!") 419 | 420 | 421 | def telegram_bot(title: str, content: str) -> None: 422 | """ 423 | 使用 telegram 机器人 推送消息。 424 | """ 425 | if not push_config.get("TG_BOT_TOKEN") or not push_config.get("TG_USER_ID"): 426 | print("tg 服务的 bot_token 或者 user_id 未设置!!\n取消推送") 427 | return 428 | print("tg 服务启动") 429 | 430 | if push_config.get("TG_API_HOST"): 431 | url = f"https://{push_config.get('TG_API_HOST')}/bot{push_config.get('TG_BOT_TOKEN')}/sendMessage" 432 | else: 433 | url = (f"https://api.telegram.org/bot{push_config.get('TG_BOT_TOKEN')}/sendMessage") 434 | headers = {"Content-Type": "application/x-www-form-urlencoded"} 435 | payload = { 436 | "chat_id": str(push_config.get("TG_USER_ID")), 437 | "text": f"{title}\n\n{content}", 438 | "disable_web_page_preview": "true", 439 | } 440 | proxies = None 441 | if push_config.get("TG_PROXY_HOST") and push_config.get("TG_PROXY_PORT"): 442 | if push_config.get("TG_PROXY_AUTH") is not None and "@" not in push_config.get("TG_PROXY_HOST"): 443 | push_config["TG_PROXY_HOST"] = (push_config.get("TG_PROXY_AUTH") + "@" + push_config.get("TG_PROXY_HOST")) 444 | proxyStr = "http://{}:{}".format(push_config.get("TG_PROXY_HOST"), push_config.get("TG_PROXY_PORT")) 445 | proxies = {"http": proxyStr, "https": proxyStr} 446 | response = requests.post(url=url, headers=headers, params=payload, proxies=proxies).json() 447 | 448 | if response["ok"]: 449 | print("tg 推送成功!") 450 | else: 451 | print("tg 推送失败!") 452 | 453 | 454 | def one() -> str: 455 | """ 456 | 获取一条一言。 457 | :return: 458 | """ 459 | url = "https://v1.hitokoto.cn/" 460 | res = requests.get(url).json() 461 | return res["hitokoto"] + " ----" + res["from"] 462 | 463 | 464 | if push_config.get("BARK_PUSH"): 465 | notify_function.append(bark) 466 | if push_config.get("CONSOLE"): 467 | notify_function.append(console) 468 | if push_config.get("DD_BOT_TOKEN") and push_config.get("DD_BOT_SECRET"): 469 | notify_function.append(dingding_bot) 470 | if push_config.get("FSKEY"): 471 | notify_function.append(feishu_bot) 472 | if push_config.get("GOBOT_URL") and push_config.get("GOBOT_QQ"): 473 | notify_function.append(go_cqhttp) 474 | if push_config.get("GOTIFY_URL") and push_config.get("GOTIFY_TOKEN"): 475 | notify_function.append(gotify) 476 | if push_config.get("IGOT_PUSH_KEY"): 477 | notify_function.append(iGot) 478 | if push_config.get("PUSH_KEY"): 479 | notify_function.append(serverJ) 480 | if push_config.get("PUSH_PLUS_TOKEN"): 481 | notify_function.append(pushplus_bot) 482 | if push_config.get("QMSG_KEY") and push_config.get("QMSG_TYPE"): 483 | notify_function.append(qmsg_bot) 484 | if push_config.get("QYWX_AM"): 485 | notify_function.append(wecom_app) 486 | if push_config.get("QYWX_KEY"): 487 | notify_function.append(wecom_bot) 488 | if push_config.get("TG_BOT_TOKEN") and push_config.get("TG_USER_ID"): 489 | notify_function.append(telegram_bot) 490 | if push_config.get('WXPUSHER_URL') and push_config.get('WXPUSHER_TOKEN') and push_config.get('WXPUSHER_TOPICS'): 491 | notify_function.append(wxpusher) 492 | 493 | 494 | def send(title: str, content: str) -> None: 495 | if not content: 496 | print(f"{title} 推送内容为空!") 497 | return 498 | 499 | hitokoto = push_config.get("HITOKOTO") 500 | 501 | text = one() if hitokoto else "" 502 | content += "\n\n" + text 503 | 504 | ts = [threading.Thread(target=mode, args=(title, content), name=mode.__name__) for mode in notify_function] 505 | [t.start() for t in ts] 506 | [t.join() for t in ts] 507 | 508 | 509 | def main(): 510 | send("title", "content") 511 | 512 | 513 | if __name__ == "__main__": 514 | main() 515 | -------------------------------------------------------------------------------- /notion_backup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import time 4 | import json 5 | import zipfile 6 | import requests 7 | import argparse 8 | import subprocess 9 | import re 10 | from datetime import datetime 11 | from notify import send 12 | 13 | # ={'spaces':[]} 则备份所有空间 'space_blocks':[] 则备份整个空间 14 | # block id格式切记为-隔开!!! 15 | DEFAULT_BACKUP_CONFIG = { 16 | "spaces": [ 17 | { 18 | "space_name": "space_name", 19 | "space_blocks": [ 20 | { 21 | "block_id": "12345678-1234-1234-1234-123456789123", 22 | "block_name": "Home1", 23 | }, 24 | { 25 | "block_id": "12345678-1234-1234-1234-123456789123", 26 | "block_name": "Home2", 27 | }, 28 | ], 29 | } 30 | ] 31 | } 32 | 33 | # 是否去除所有文件和文件夹的id 34 | REMOVE_FILES_ID = False 35 | 36 | # 默认配置无需更改 37 | NOTION_TIMEZONE = os.getenv("NOTION_TIMEZONE", "Asia/Shanghai") 38 | NOTION_LOCALE = os.getenv("NOTION_TIMEZONE", "en") 39 | NOTION_API = os.getenv("NOTION_API", "https://www.notion.so/api/v3") 40 | # 邮箱和用户名 41 | NOTION_EMAIL = os.getenv("NOTION_EMAIL", "") 42 | NOTION_PASSWORD = os.getenv("NOTION_PASSWORD", "") 43 | # 修改为浏览器内获取到的token 44 | NOTION_TOKEN = os.getenv("NOTION_TOKEN", "YOUR TOKEN") 45 | # 登录后获取 下载文件需要 46 | NOTION_FILE_TOKEN = "" 47 | NOTION_EXPORT_TYPE = os.getenv("NOTION_EXPORT_TYPE", "markdown") # html pdf 48 | # 备份文件保存目录 49 | SAVE_DIR = "backup" 50 | # git相关信息 51 | REPOSITORY_URL = "https://github.com/git_user_name/xxx.git" 52 | REPOSITORY_BRANCH = "main" 53 | GIT_USERNAME = "git_user_name" 54 | GIT_EMAIL = "git@git.com" 55 | 56 | 57 | def run_command(cmd): 58 | try: 59 | proc = subprocess.run( 60 | cmd, 61 | shell=True, 62 | stdout=subprocess.PIPE, 63 | stderr=subprocess.PIPE, 64 | encoding="utf-8", 65 | ) 66 | # flag,stdout,stderr 67 | if proc.stderr != "": 68 | print("cmd:{} stdout:{} stderr:{}".format(cmd, proc.stdout, proc.stderr)) 69 | return proc.stderr == "", proc.stdout, proc.stderr 70 | except Exception as e: 71 | return False, "", str(e) 72 | 73 | 74 | def writeLog(s): 75 | with open("log.txt", "a") as log: 76 | msg = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + " " + s 77 | print(msg) 78 | send("notion备份", msg) 79 | log.write(msg + "\n") 80 | 81 | 82 | def unzip(filename: str, saveDir: str = ""): 83 | try: 84 | file = zipfile.ZipFile(filename) 85 | dirname = filename.replace(".zip", "") 86 | if saveDir != "": 87 | dirname = saveDir 88 | # 如果存在与压缩包同名文件夹 提示信息并跳过 89 | if os.path.exists(dirname): 90 | print(f"{dirname} 已存在,将被覆盖") 91 | shutil.rmtree(dirname) 92 | # 创建文件夹,并解压 93 | os.mkdir(dirname) 94 | file.extractall(dirname) 95 | file.close() 96 | return dirname 97 | except Exception as e: 98 | print(f"{filename} unzip fail,{str(e)}") 99 | 100 | 101 | def zip_dir(dirpath, outFullName): 102 | """ 103 | 压缩指定文件夹 104 | :param dirpath: 目标文件夹路径 105 | :param outFullName: 压缩文件保存路径+xxxx.zip 106 | :return: 无 107 | """ 108 | zip = zipfile.ZipFile(outFullName, "w", zipfile.ZIP_DEFLATED) 109 | for path, dirnames, filenames in os.walk(dirpath): 110 | # 去掉目标跟路径,只对目标文件夹下边的文件及文件夹进行压缩 111 | fpath = path.replace(dirpath, "") 112 | for filename in filenames: 113 | zip.write(os.path.join(path, filename), os.path.join(fpath, filename)) 114 | zip.close() 115 | 116 | 117 | def initNotionToken(): 118 | global NOTION_TOKEN 119 | if not NOTION_EMAIL and not NOTION_PASSWORD: 120 | print("使用已设置的token:{}".format(NOTION_TOKEN)) 121 | return NOTION_TOKEN 122 | loginData = {"email": NOTION_EMAIL, "password": NOTION_PASSWORD} 123 | headers = { 124 | # Notion obviously check this as some kind of (bad) test of CSRF 125 | "host": "www.notion.so" 126 | } 127 | response = requests.post( 128 | NOTION_API + "/loginWithEmail", json=loginData, headers=headers 129 | ) 130 | response.raise_for_status() 131 | 132 | NOTION_TOKEN = response.cookies["token_v2"] 133 | print("使用账户获取到的token:{}".format(NOTION_TOKEN)) 134 | return response.cookies["token_v2"] 135 | 136 | 137 | def exportSpace(spaceId): 138 | return { 139 | "task": { 140 | "eventName": "exportSpace", 141 | "request": { 142 | "spaceId": spaceId, 143 | "exportOptions": { 144 | "exportType": NOTION_EXPORT_TYPE, 145 | "timeZone": NOTION_TIMEZONE, 146 | "locale": NOTION_LOCALE, 147 | "flattenExportFiletree": False, 148 | }, 149 | }, 150 | } 151 | } 152 | 153 | 154 | # { 155 | # "task": { 156 | # "eventName": "exportBlock", 157 | # "request": { 158 | # "block": { 159 | # "id": "c093243a-a553-45ae-954f-4bf80d995167", 160 | # "spaceId": "38d3bbb5-37de-4891-86cc-9dcfbafc30d0" 161 | # }, 162 | # "recursive": true, 163 | # "exportOptions": { 164 | # "exportType": "markdown", 165 | # "timeZone": "Asia/Shanghai", 166 | # "locale": "en", 167 | # "flattenExportFiletree": false 168 | # } 169 | # } 170 | # } 171 | # } 172 | 173 | 174 | def exportSpaceBlock(spaceId, blockId): 175 | return { 176 | "task": { 177 | "eventName": "exportBlock", 178 | "request": { 179 | "block": {"id": blockId, "spaceId": spaceId}, 180 | "recursive": True, 181 | "exportOptions": { 182 | "exportType": NOTION_EXPORT_TYPE, 183 | "timeZone": NOTION_TIMEZONE, 184 | "locale": NOTION_LOCALE, 185 | "flattenExportFiletree": False, 186 | }, 187 | }, 188 | } 189 | } 190 | 191 | 192 | def request_post(endpoint: str, params: object, max_retries=3, retry_time_seconds=10): 193 | global NOTION_FILE_TOKEN 194 | print( 195 | "request post:{} max_retries:{} retry_time_seconds:{}".format( 196 | endpoint, max_retries, retry_time_seconds 197 | ) 198 | ) 199 | attempt = 0 200 | while attempt < max_retries: 201 | try: 202 | response = requests.post( 203 | f"{NOTION_API}/{endpoint}", 204 | data=json.dumps(params).encode("utf8"), 205 | headers={ 206 | "content-type": "application/json", 207 | "cookie": f"token_v2={NOTION_TOKEN};", 208 | }, 209 | timeout=60, 210 | ) 211 | if response.status_code in [504, 500, 503]: 212 | raise Exception("504 Gateway Time-out,尝试重新连接") 213 | if response: 214 | # 使用 get 方法避免 keyError 215 | file_token = response.cookies.get("file_token") 216 | if not NOTION_FILE_TOKEN and file_token: 217 | NOTION_FILE_TOKEN = file_token 218 | return response.json() 219 | else: 220 | print(f"Request url:{endpoint} error response: {response}") 221 | except Exception as e: 222 | print(f"Request url:{endpoint} error {e}") 223 | attempt += 1 224 | time.sleep(20) 225 | 226 | print( 227 | f"Failed to get a valid response from {endpoint} after {max_retries} attempts." 228 | ) 229 | return None 230 | 231 | 232 | def getUserContent(): 233 | return request_post("loadUserContent", {})["recordMap"] 234 | 235 | 236 | def exportUrl(taskId): 237 | url = False 238 | print("Polling for export task: {}".format(taskId)) 239 | while True: 240 | res = request_post( 241 | "getTasks", {"taskIds": [taskId]}, max_retries=5, retry_time_seconds=15 242 | ) 243 | tasks = res.get("results") 244 | task = next(t for t in tasks if t["id"] == taskId) 245 | if task["state"] == "success": 246 | url = task["status"]["exportURL"] 247 | print("download url:" + url) 248 | break 249 | elif task["state"] == "failure": 250 | print(task["error"]) 251 | else: 252 | print("{}.".format(task["state"]), end="", flush=True) 253 | time.sleep(20) 254 | return url 255 | 256 | 257 | def remove_files_id(): 258 | if not REMOVE_FILES_ID: 259 | return 260 | for root, dirs, files in os.walk(SAVE_DIR): 261 | for file in files: 262 | path = os.path.join(root, file) 263 | filename_id = re.compile(r"[a-fA-F\d]{32}").findall(file) 264 | if filename_id: 265 | new_filename = file.replace(" " + filename_id[0], "") 266 | new_path = os.path.join(root, new_filename) 267 | os.rename(path, new_path) 268 | while True: 269 | rename_dir_flag = False 270 | for root, dirs, files in os.walk(SAVE_DIR): 271 | for dir in dirs: 272 | path = os.path.join(root, dir) 273 | dir_id = re.compile( 274 | r"[a-fA-F\d]{8}-[a-fA-F\d]{4}-[a-fA-F\d]{4}-[a-fA-F\d]{4}-[a-fA-F\d]{12}" 275 | ).findall(dir) 276 | if dir_id: 277 | new_dirname = dir.replace("-" + dir_id[0], "") 278 | new_path = os.path.join(root, new_dirname) 279 | os.rename(path, new_path) 280 | rename_dir_flag = True 281 | break 282 | if not rename_dir_flag: 283 | break 284 | 285 | 286 | def downloadAndUnzip(url, filename): 287 | os.makedirs(SAVE_DIR, exist_ok=True) 288 | savePath = SAVE_DIR + "/" + filename 289 | with requests.get( 290 | url, stream=True, headers={"cookie": f"file_token={NOTION_FILE_TOKEN}"} 291 | ) as r: 292 | with open(savePath, "wb") as f: 293 | shutil.copyfileobj(r.raw, f) 294 | unzip(savePath) 295 | if os.path.exists(savePath): 296 | print("保存文件:" + savePath) 297 | else: 298 | print("保存文件:" + savePath + "失败") 299 | 300 | save_dir = savePath.replace(".zip", "") 301 | for file in os.listdir(save_dir): 302 | file_path = os.path.join(save_dir, file) 303 | if ".zip" in file_path: 304 | unzip(file_path) 305 | os.remove(file_path) 306 | if REMOVE_FILES_ID: 307 | remove_files_id() 308 | os.remove(savePath) 309 | zip_dir(save_dir, savePath) 310 | 311 | 312 | def initGit(): 313 | run_command(f"git config --global user.name {GIT_USERNAME}") 314 | run_command(f"git config --global user.email {GIT_EMAIL}") 315 | run_command(f"git config pull.ff false") 316 | run_command(f"git init") 317 | run_command(f"git remote add origin {REPOSITORY_URL}") 318 | run_command(f"git branch -M {REPOSITORY_BRANCH}") 319 | run_command(f"git fetch --all && git reset --hard origin/{REPOSITORY_BRANCH}") 320 | run_command(f"git pull origin {REPOSITORY_BRANCH}") 321 | 322 | 323 | def pull(): 324 | run_command(f"git pull origin {REPOSITORY_BRANCH}") 325 | 326 | 327 | def push(): 328 | run_command( 329 | f'git add . && git commit -m "backup" && git push origin {REPOSITORY_BRANCH}' 330 | ) 331 | 332 | 333 | def wait_seconds(seconds=60): 334 | print("wait {} seconds".format(seconds)) 335 | time.sleep(seconds) 336 | 337 | 338 | def executeBackup(): 339 | 340 | # 初始化Token 341 | initNotionToken() 342 | 343 | # 获取用户信息 344 | userContent = getUserContent() 345 | time.sleep(3) 346 | 347 | # userId = list(userContent['notion_user'].keys())[0] 348 | # print(f'User id: {userId}') 349 | if "space" not in userContent: 350 | writeLog("notion备份失败:获取信息失败,userContent:{}".format(userContent)) 351 | return 352 | 353 | spaces = [ 354 | (space_id, space_details["value"]["name"]) 355 | for (space_id, space_details) in userContent["space"].items() 356 | ] 357 | backup_space_names = [] 358 | backup_space_config = {} 359 | for backup_config_item in DEFAULT_BACKUP_CONFIG["spaces"]: 360 | if backup_config_item["space_name"]: 361 | backup_space_names.append(backup_config_item["space_name"]) 362 | backup_space_config[backup_config_item["space_name"]] = backup_config_item 363 | print(f"Available spaces total:{len(spaces)}") 364 | for spaceId, spaceName in spaces: 365 | print(f"\t- {spaceId}:{spaceName}") 366 | taskId = "" 367 | 368 | # 备份所有空间 369 | if len(backup_space_names) == 0: 370 | taskId = request_post("enqueueTask", exportSpace(spaceId)).get("taskId") 371 | url = exportUrl(taskId) 372 | downloadAndUnzip(url, f"{spaceName}.zip") 373 | 374 | wait_seconds(60) 375 | 376 | elif spaceName in backup_space_names: 377 | # 指定了space下的block 378 | if ( 379 | "space_blocks" in backup_space_config[spaceName] 380 | and backup_space_config[spaceName]["space_blocks"] 381 | ): 382 | for space_block in backup_space_config[spaceName]["space_blocks"]: 383 | block_id = space_block["block_id"] 384 | block_name = space_block["block_name"] 385 | res = request_post( 386 | "enqueueTask", exportSpaceBlock(spaceId, block_id) 387 | ) 388 | if res == None: 389 | print("enqueueTask get task id failed") 390 | raise Exception("enqueueTask get task id failed") 391 | taskId = res.get("taskId") 392 | url = exportUrl(taskId) 393 | downloadAndUnzip(url, f"{spaceName}-{block_name}.zip") 394 | 395 | wait_seconds(60) 396 | else: 397 | # 没指定space block则备份整个空间 398 | taskId = request_post("enqueueTask", exportSpace(spaceId)).get("taskId") 399 | url = exportUrl(taskId) 400 | downloadAndUnzip(url, f"{spaceName}.zip") 401 | 402 | wait_seconds(60) 403 | 404 | else: 405 | print("space:{}跳过 不在备份列表".format(spaceName)) 406 | 407 | 408 | def main(): 409 | initGit() 410 | 411 | timestamp = datetime.now().strftime("%Y%m%d%H%M%S") 412 | new_name = f"backup_{timestamp}" 413 | if os.path.exists(SAVE_DIR): 414 | try: 415 | shutil.move(SAVE_DIR, new_name) 416 | print(f"目录{SAVE_DIR}已重命名为: {new_name}") 417 | except Exception as e: 418 | print(f"重命名{new_name}失败: {e}") 419 | raise e 420 | try: 421 | executeBackup() 422 | print("开始提交代码") 423 | pull() 424 | push() 425 | 426 | writeLog("notion备份完成") 427 | # 删除重命名后的目录 428 | try: 429 | shutil.rmtree(new_name) 430 | print(f"目录 {new_name} 已删除") 431 | except Exception as e: 432 | print(f"删除 {new_name} 失败: {e}") 433 | except Exception as e: 434 | print(f"备份失败: {e}") 435 | # 恢复目录名字 436 | shutil.move(new_name, SAVE_DIR) 437 | raise e 438 | 439 | 440 | def run_retry(): 441 | count = 0 442 | while True: 443 | try: 444 | main() 445 | break 446 | except Exception as e: 447 | count += 1 448 | writeLog("notion备份执行出错:" + str(e)) 449 | print("执行出错:", str(e)) 450 | if count > 3: 451 | writeLog("notion备份尝试{}次出错".format(count)) 452 | print("尝试{}次出错".format(count)) 453 | break 454 | time.sleep(15) 455 | 456 | 457 | if __name__ == "__main__": 458 | writeLog("开始执行notion备份") 459 | parser = argparse.ArgumentParser(description="ArgUtils") 460 | parser.add_argument( 461 | "-c", 462 | type=str, 463 | default="", 464 | required=False, 465 | help='配置文件路径,内容格式为 {"spaces": [{"space_name": "xxx", "space_blocks": [{"block_id": "12345678-1234-1234-1234-123456789123", "block_name": "xx"}]}]}', 466 | ) 467 | 468 | args = parser.parse_args() 469 | if args.c: 470 | try: 471 | with open(args.c, "r") as f: 472 | c = f.read() 473 | print("读取文件:{}内容为:\n{}".format(args.c, c)) 474 | DEFAULT_BACKUP_CONFIG = json.loads(c) 475 | print("使用参数 DEFAULT_BACKUP_CONFIG:{}".format(DEFAULT_BACKUP_CONFIG)) 476 | except Exception as e: 477 | print("参数格式错误,请检查是否为合法的json字符串") 478 | print( 479 | '{"spaces": [{"space_name": "xxx", "space_blocks": [{"block_id": "12345678-1234-1234-1234-123456789123", "block_name": "xx"}]}]}' 480 | ) 481 | raise Exception("参数格式错误,请检查是否为合法的json字符串:" + str(e)) 482 | else: 483 | print("使用默认配置 DEFAULT_BACKUP_CONFIG:{}".format(DEFAULT_BACKUP_CONFIG)) 484 | 485 | run_retry() 486 | 487 | # 定时定点执行 488 | # nohup python3 notion-backup.py -u 2>&1 >> /tmp/notion-backup.log 489 | # if __name__ == '__main__': 490 | # print('开始执行') 491 | # running = False 492 | # while True and not running: 493 | # now = datetime.datetime.now() 494 | # if now.hour == 3 and now.minute == 0: 495 | # running = True 496 | # run_retry() 497 | # running = False 498 | 499 | # time.sleep(30) 500 | -------------------------------------------------------------------------------- /run_job.py: -------------------------------------------------------------------------------- 1 | import schedule 2 | from schedule import every, repeat 3 | import functools 4 | import time 5 | import notion_backup 6 | 7 | 8 | def catch_exceptions(cancel_on_failure=False): 9 | def catch_exceptions_decorator(job_func): 10 | @functools.wraps(job_func) 11 | def wrapper(*args, **kwargs): 12 | try: 13 | return job_func(*args, **kwargs) 14 | except: 15 | import traceback 16 | print(traceback.format_exc()) 17 | if cancel_on_failure: 18 | return schedule.CancelJob 19 | 20 | return wrapper 21 | 22 | return catch_exceptions_decorator 23 | 24 | 25 | @catch_exceptions(cancel_on_failure=True) 26 | @repeat(every().day.at("03:00")) 27 | def notion_job(): 28 | print("start notion_job") 29 | notion_backup.run_retry() 30 | 31 | 32 | if __name__ == '__main__': 33 | print('run_job start') 34 | 35 | while True: 36 | schedule.run_pending() 37 | time.sleep(3) -------------------------------------------------------------------------------- /wget_notion_zip.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import os 3 | from tqdm import tqdm 4 | import schedule 5 | from schedule import every, repeat 6 | import functools 7 | import time 8 | from notify import send 9 | 10 | #下载git仓库文件到指定位置,这个位置可以设置百度网盘或者其他网盘的自动备份 11 | 12 | TOKEN = 'github_pat_xxxxxxxxxxx' 13 | SAVE_PATH = "C:/backup/notion/lk-notion-backup.zip" 14 | COPY_PATH = "H:/backup/notion/lk-notion-backup.zip" # 为空则不拷贝 15 | URL = "https://codeload.github.com/LoneKingCode/xxxxxxxxxxx/zip/refs/heads/main" 16 | HEADERS = {"Authorization": "token " + TOKEN} 17 | 18 | 19 | def catch_exceptions(cancel_on_failure=False): 20 | def catch_exceptions_decorator(job_func): 21 | @functools.wraps(job_func) 22 | def wrapper(*args, **kwargs): 23 | try: 24 | return job_func(*args, **kwargs) 25 | except: 26 | import traceback 27 | print(traceback.format_exc()) 28 | if cancel_on_failure: 29 | return schedule.CancelJob 30 | 31 | return wrapper 32 | 33 | return catch_exceptions_decorator 34 | 35 | 36 | @catch_exceptions(cancel_on_failure=True) 37 | @repeat(every().day.at("09:00")) 38 | def run(): 39 | if os.path.exists(SAVE_PATH): 40 | os.remove(SAVE_PATH) 41 | print(f"{SAVE_PATH} 文件已删除") 42 | else: 43 | print(f"{SAVE_PATH} 文件不存在") 44 | 45 | print('开始下载文件,保存到:{}'.format(SAVE_PATH)) 46 | response = requests.get(URL, headers=HEADERS, stream=True) 47 | 48 | file_size = int(response.headers.get('Content-Length', 0)) 49 | block_size = 8192 50 | progress_bar = tqdm(total=file_size, unit='iB', unit_scale=True) 51 | 52 | with open(SAVE_PATH, 'wb') as file: 53 | for data in response.iter_content(block_size): 54 | progress_bar.update(len(data)) 55 | file.write(data) 56 | 57 | progress_bar.close() 58 | 59 | if file_size != 0 and progress_bar.n != file_size: 60 | print("下载失败") 61 | send('notion_zip_download 下载失败', '下载失败') 62 | return 63 | else: 64 | print("下载完成") 65 | if COPY_PATH == '': 66 | return 67 | print('开始拷贝文件:{}到:{}'.format(SAVE_PATH, COPY_PATH)) 68 | 69 | # 获取文件大小 70 | total_size = os.path.getsize(SAVE_PATH) 71 | # 拷贝文件并显示进度条 72 | with open(SAVE_PATH, 'rb') as fsrc: 73 | with open(COPY_PATH, 'wb') as fdst: 74 | with tqdm(total=total_size, unit='B', unit_scale=True, desc=SAVE_PATH.split('\\')[-1]) as pbar: 75 | while True: 76 | buf = fsrc.read(1024 * 1024) 77 | if not buf: 78 | break 79 | fdst.write(buf) 80 | pbar.update(len(buf)) 81 | 82 | send('notion_zip_download 完成', '完成') 83 | 84 | 85 | if __name__ == '__main__': 86 | print('开始等待执行...') 87 | try: 88 | while True: 89 | schedule.run_pending() 90 | time.sleep(1) 91 | except Exception as e: 92 | send('notion_zip_download 出错', str(e)) 93 | --------------------------------------------------------------------------------