├── .github └── workflows │ └── flomo2notion_snyc.yml ├── .gitignore ├── .idea ├── .gitignore ├── inspectionProfiles │ ├── Project_Default.xml │ └── profiles_settings.xml ├── misc.xml ├── modules.xml ├── notion-flomo.iml └── vcs.xml ├── LICENSE ├── README.md ├── __pycache__ ├── main.cpython-310.pyc └── utils.cpython-310.pyc ├── flomo ├── __pycache__ │ ├── flomo_api.cpython-310.pyc │ └── flomo_sign.cpython-310.pyc ├── flomo_api.py └── flomo_sign.py ├── flomo2notion.py ├── main.py ├── notion2flomo.py ├── notionify ├── Parser │ ├── __pycache__ │ │ └── md2block.cpython-310.pyc │ └── md2block.py ├── __pycache__ │ ├── md2notion.cpython-310.pyc │ ├── notion_cover_list.cpython-310.pyc │ ├── notion_helper.cpython-310.pyc │ └── notion_utils.cpython-310.pyc ├── md2notion.py ├── notion_cover_list.py ├── notion_helper.py └── notion_utils.py ├── requirements.txt ├── test_main.http └── utils.py /.github/workflows/flomo2notion_snyc.yml: -------------------------------------------------------------------------------- 1 | name: flomo2notion sync 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 */3 * * *" 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.ref }} 9 | cancel-in-progress: true 10 | jobs: 11 | sync: 12 | name: Sync 13 | runs-on: ubuntu-latest 14 | env: 15 | NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} 16 | NOTION_PAGE: ${{ secrets.NOTION_PAGE }} 17 | FLOMO_TOKEN: ${{ secrets.FLOMO_TOKEN }} 18 | REF: ${{ github.ref }} 19 | REPOSITORY: ${{ github.repository }} 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v3 23 | - name: Set up Python 24 | uses: actions/setup-python@v4 25 | with: 26 | python-version: 3.11 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install -r requirements.txt 31 | - name: flomo2notion sync 32 | run: | 33 | python -u flomo2notion.py 34 | 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | scripts/__pycache__/ 2 | .env 3 | flomo/__pycache__/ 4 | .idea -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 186 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/notion-flomo.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Ewing 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 官网开启一键同步 2 | 3 | 如果你觉得部署繁琐,可以直接使用NotesToNotion 4 | NotesToNotion能同步图片,并且删除和更新的也可以同步 5 | [NotesToNotion](https://notes2notion.notionify.net) 6 | 7 | # 将flomo同步到Notion 8 | 9 | 本项目通过Github Action每天定时同步flomo到Notion。 10 | 11 | 预览效果: 12 | 13 | [flomo2notion列表页面](https://www.notion.so/image/https%3A%2F%2Fprod-files-secure.s3.us-west-2.amazonaws.com%2Fd01f9e1b-37be-4e62-ba09-3e4835a67760%2F7d8e606e-2bb2-48e0-84fb-e8fe4f70ae5b%2FUntitled.png?table=block&id=df77b666-0f2b-4d96-848e-a0193759c0e3&t=df77b666-0f2b-4d96-848e-a0193759c0e3&width=840.6771240234375&cache=v2) 14 | 15 | [flomo2notion详情页面](https://www.notion.so/image/https%3A%2F%2Fprod-files-secure.s3.us-west-2.amazonaws.com%2Fd01f9e1b-37be-4e62-ba09-3e4835a67760%2F8daf2284-aedf-4e04-8f55-9f1fe409e4cc%2FUntitled.png?table=block&id=31fb72fd-0b40-4ae1-82f5-9de52e1aeed1&t=31fb72fd-0b40-4ae1-82f5-9de52e1aeed1&width=2078&cache=v2) 16 | 17 | ## 使用教程 18 | 19 | [flomo2notion教程](https://blog.notionedu.com/article/0d91c395-d74a-4ce4-a219-afdca8e90c92#52ef8ad045d84e0c900ecbe529ce3653) 20 | -------------------------------------------------------------------------------- /__pycache__/main.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EwingYangs/notion-flomo/000f6fe8a4df3602d052dbb6aa513a96e141779f/__pycache__/main.cpython-310.pyc -------------------------------------------------------------------------------- /__pycache__/utils.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EwingYangs/notion-flomo/000f6fe8a4df3602d052dbb6aa513a96e141779f/__pycache__/utils.cpython-310.pyc -------------------------------------------------------------------------------- /flomo/__pycache__/flomo_api.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EwingYangs/notion-flomo/000f6fe8a4df3602d052dbb6aa513a96e141779f/flomo/__pycache__/flomo_api.cpython-310.pyc -------------------------------------------------------------------------------- /flomo/__pycache__/flomo_sign.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EwingYangs/notion-flomo/000f6fe8a4df3602d052dbb6aa513a96e141779f/flomo/__pycache__/flomo_sign.cpython-310.pyc -------------------------------------------------------------------------------- /flomo/flomo_api.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import requests 4 | 5 | from flomo.flomo_sign import getSign 6 | 7 | FLOMO_DOMAIN = "https://flomoapp.com" 8 | MEMO_LIST_URL = FLOMO_DOMAIN + "/api/v1/memo/updated/" 9 | 10 | HEADERS = { 11 | 'accept': 'application/json, text/plain, */*', 12 | 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8', 13 | 'origin': 'https://v.flomoapp.com', 14 | 'priority': 'u=1, i', 15 | 'referer': 'https://v.flomoapp.com/', 16 | 'sec-ch-ua': '"Google Chrome";v="125", "Chromium";v="125", "Not.A/Brand";v="24"', 17 | 'sec-ch-ua-mobile': '?0', 18 | 'sec-ch-ua-platform': '"macOS"', 19 | 'sec-fetch-dest': 'empty', 20 | 'sec-fetch-mode': 'cors', 21 | 'sec-fetch-site': 'same-site', 22 | 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' 23 | } 24 | 25 | 26 | class FlomoApi: 27 | def __int__(self): 28 | pass 29 | 30 | def get_memo_list(self, user_authorization, latest_updated_at="0"): 31 | # 获取当前时间 32 | current_timestamp = int(time.time()) 33 | 34 | latest_updated_at = str(int(latest_updated_at) + 1) 35 | 36 | # 构造参数 37 | params = { 38 | 'limit': '200', 39 | 'latest_updated_at': latest_updated_at, 40 | 'tz': '8:0', 41 | 'timestamp': current_timestamp, 42 | 'api_key': 'flomo_web', 43 | 'app_version': '4.0', 44 | 'platform': 'web', 45 | 'webp': '1' 46 | } 47 | 48 | # 获取签名 49 | params['sign'] = getSign(params) 50 | HEADERS['authorization'] = f'Bearer {user_authorization}' 51 | 52 | response = requests.get(MEMO_LIST_URL, headers=HEADERS, params=params) 53 | 54 | if response.status_code != 200: 55 | # 网络或者服务器错误 56 | print('get_memo_list http error:' + response.text) 57 | return 58 | 59 | response_json = response.json() 60 | if response_json['code'] != 0: 61 | print("get_memo_list business error:" + response_json['message']) 62 | return 63 | 64 | return response_json['data'] 65 | 66 | def get_login_wechat_qrcode(self): 67 | pass 68 | 69 | def get_user_auth(self): 70 | pass 71 | 72 | 73 | if __name__ == "__main__": 74 | flomo_api = FlomoApi() 75 | authorization = 'Bearer 7505209|Lf9wvt5JKIFBS4zfayw61X3MuoH1nS5xcPMB3fqS' 76 | memo_list = flomo_api.get_memo_list(authorization) 77 | print(memo_list) 78 | -------------------------------------------------------------------------------- /flomo/flomo_sign.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | 3 | 4 | def _ksort(d): 5 | return dict(sorted(d.items())) 6 | 7 | 8 | def getSign(e): 9 | e = _ksort(e) 10 | t = "" 11 | for i in e: 12 | o = e[i] 13 | if o is not None and (o or o == 0): 14 | if isinstance(o, list): 15 | o.sort(key=lambda x: x if x else '') 16 | for item in o: 17 | t += f"{i}[]={item}&" 18 | else: 19 | t += f"{i}={o}&" 20 | t = t[:-1] 21 | return c(t + "dbbc3dd73364b4084c3a69346e0ce2b2") 22 | 23 | 24 | def c(t): 25 | return hashlib.md5(t.encode('utf-8')).hexdigest() 26 | 27 | 28 | # 测试数据 29 | # e = { 30 | # "api_key": "flomo_web", 31 | # "app_version": "4.0", 32 | # "platform": "web", 33 | # "timestamp": 1720147723, 34 | # "webp": "1" 35 | # } 36 | 37 | e = { 38 | "limit": 200, 39 | "latest_updated_at": 0, 40 | "tz": "8:0", 41 | "timestamp": 1720075310, 42 | "api_key": "flomo_web", 43 | "app_version": "4.0", 44 | "platform": "web", 45 | "webp": "1" 46 | } 47 | 48 | print(getSign(e)) 49 | -------------------------------------------------------------------------------- /flomo2notion.py: -------------------------------------------------------------------------------- 1 | import os 2 | import random 3 | import time 4 | 5 | import html2text 6 | from markdownify import markdownify 7 | 8 | from flomo.flomo_api import FlomoApi 9 | from notionify import notion_utils 10 | from notionify.md2notion import Md2NotionUploader 11 | from notionify.notion_cover_list import cover 12 | from notionify.notion_helper import NotionHelper 13 | from utils import truncate_string, is_within_n_days 14 | 15 | 16 | class Flomo2Notion: 17 | def __init__(self): 18 | self.flomo_api = FlomoApi() 19 | self.notion_helper = NotionHelper() 20 | self.uploader = Md2NotionUploader() 21 | 22 | def insert_memo(self, memo): 23 | print("insert_memo:", memo) 24 | content_md = markdownify(memo['content']) 25 | parent = {"database_id": self.notion_helper.page_id, "type": "database_id"} 26 | content_text = html2text.html2text(memo['content']) 27 | properties = { 28 | "标题": notion_utils.get_title( 29 | truncate_string(content_text) 30 | ), 31 | "标签": notion_utils.get_multi_select( 32 | memo['tags'] 33 | ), 34 | "是否置顶": notion_utils.get_select("否" if memo['pin'] == 0 else "是"), 35 | # 文件的处理方式待定 36 | # "文件": notion_utils.get_file(""), 37 | # slug是文章唯一标识 38 | "slug": notion_utils.get_rich_text(memo['slug']), 39 | "创建时间": notion_utils.get_date(memo['created_at']), 40 | "更新时间": notion_utils.get_date(memo['updated_at']), 41 | "来源": notion_utils.get_select(memo['source']), 42 | "链接数量": notion_utils.get_number(memo['linked_count']), 43 | } 44 | 45 | random_cover = random.choice(cover) 46 | print(f"Random element: {random_cover}") 47 | 48 | page = self.notion_helper.client.pages.create( 49 | parent=parent, 50 | icon=notion_utils.get_icon("https://www.notion.so/icons/target_red.svg"), 51 | cover=notion_utils.get_icon(random_cover), 52 | properties=properties, 53 | ) 54 | 55 | # 在page里面添加content 56 | self.uploader.uploadSingleFileContent(self.notion_helper.client, content_md, page['id']) 57 | 58 | def update_memo(self, memo, page_id): 59 | print("update_memo:", memo) 60 | 61 | content_md = markdownify(memo['content']) 62 | # 只更新内容 63 | content_text = html2text.html2text(memo['content']) 64 | properties = { 65 | "标题": notion_utils.get_title( 66 | truncate_string(content_text) 67 | ), 68 | "更新时间": notion_utils.get_date(memo['updated_at']), 69 | "链接数量": notion_utils.get_number(memo['linked_count']), 70 | "标签": notion_utils.get_multi_select( 71 | memo['tags'] 72 | ), 73 | "是否置顶": notion_utils.get_select("否" if memo['pin'] == 0 else "是"), 74 | } 75 | page = self.notion_helper.client.pages.update(page_id=page_id, properties=properties) 76 | 77 | # 先清空page的内容,再重新写入 78 | self.notion_helper.clear_page_content(page["id"]) 79 | 80 | self.uploader.uploadSingleFileContent(self.notion_helper.client, content_md, page['id']) 81 | 82 | # 具体步骤: 83 | # 1. 调用flomo web端的api从flomo获取数据 84 | # 2. 轮询flomo的列表数据,调用notion api将数据同步写入到database中的page 85 | def sync_to_notion(self): 86 | # 1. 调用flomo web端的api从flomo获取数据 87 | authorization = os.getenv("FLOMO_TOKEN") 88 | memo_list = [] 89 | latest_updated_at = "0" 90 | 91 | while True: 92 | new_memo_list = self.flomo_api.get_memo_list(authorization, latest_updated_at) 93 | if not new_memo_list: 94 | break 95 | memo_list.extend(new_memo_list) 96 | latest_updated_at = str(int(time.mktime(time.strptime(new_memo_list[-1]['updated_at'], "%Y-%m-%d %H:%M:%S")))) 97 | 98 | # 2. 调用notion api获取数据库存在的记录,用slug标识唯一,如果存在则更新,不存在则写入 99 | notion_memo_list = self.notion_helper.query_all(self.notion_helper.page_id) 100 | slug_map = {} 101 | for notion_memo in notion_memo_list: 102 | slug_map[notion_utils.get_rich_text_from_result(notion_memo, "slug")] = notion_memo.get("id") 103 | 104 | # 3. 轮询flomo的列表数据 105 | for memo in memo_list: 106 | # 3.1 判断memo的slug是否存在,不存在则写入 107 | # 3.2 防止大批量更新,只更新更新时间为制定时间的数据(默认为7天) 108 | if memo['slug'] in slug_map.keys(): 109 | # 是否全量更新,默认否 110 | full_update = os.getenv("FULL_UPDATE", False) 111 | interval_day = os.getenv("UPDATE_INTERVAL_DAY", 7) 112 | if not full_update and not is_within_n_days(memo['updated_at'], interval_day): 113 | print("is_within_n_days slug:", memo['slug']) 114 | continue 115 | 116 | page_id = slug_map[memo['slug']] 117 | self.update_memo(memo, page_id) 118 | else: 119 | self.insert_memo(memo) 120 | 121 | 122 | if __name__ == "__main__": 123 | # flomo同步到notion入口 124 | flomo2notion = Flomo2Notion() 125 | flomo2notion.sync_to_notion() 126 | 127 | # notionify key 128 | # secret_IHWKSLUTqUh3A8TIKkeXWePu3PucwHiRwDEcqNp5uT3 129 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | 3 | app = FastAPI() 4 | 5 | 6 | @app.get("/") 7 | async def root(): 8 | return {"message": "Hello World"} 9 | 10 | 11 | @app.get("/hello/{name}") 12 | async def say_hello(name: str): 13 | return {"message": f"Hello {name}"} 14 | -------------------------------------------------------------------------------- /notion2flomo.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EwingYangs/notion-flomo/000f6fe8a4df3602d052dbb6aa513a96e141779f/notion2flomo.py -------------------------------------------------------------------------------- /notionify/Parser/__pycache__/md2block.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EwingYangs/notion-flomo/000f6fe8a4df3602d052dbb6aa513a96e141779f/notionify/Parser/__pycache__/md2block.cpython-310.pyc -------------------------------------------------------------------------------- /notionify/Parser/md2block.py: -------------------------------------------------------------------------------- 1 | from mistletoe.block_token import BlockToken, tokenize 2 | import itertools 3 | from mistletoe import span_token 4 | from md2notion.NotionPyRenderer import NotionPyRenderer 5 | 6 | 7 | class Document(BlockToken): 8 | """ 9 | Document token. 10 | """ 11 | def __init__(self, lines): 12 | if isinstance(lines, str):lines = lines.splitlines(keepends=True) 13 | lines = [line if line.endswith('\n') else '{}\n'.format(line) for line in lines] 14 | 15 | # add new line above and below '$$\n' 16 | new_lines = [] 17 | temp_line = None 18 | triggered = False 19 | for line in lines: 20 | #if line.strip().replace('\n',"") =='':continue 21 | if not triggered and '$$\n' in line: 22 | temp_line = [None, line, None] 23 | triggered = True 24 | elif triggered: 25 | temp_line[1] += line 26 | if '$$\n' in line: 27 | temp_line[2] = '\n' 28 | new_lines.append(temp_line) 29 | temp_line = None 30 | triggered = False 31 | 32 | else: 33 | new_lines.append([None, line, None]) 34 | 35 | if temp_line is not None: 36 | new_lines.append(temp_line) 37 | 38 | 39 | new_lines = list(itertools.chain(*new_lines)) 40 | new_lines = list(filter(lambda x: x is not None, new_lines)) 41 | new_lines = ''.join(new_lines) 42 | lines = new_lines.splitlines(keepends=True) 43 | lines = [line if line.endswith('\n') else '{}\n'.format(line) for line in lines] 44 | #lines = [[t[1]] for t in new_lines] 45 | 46 | 47 | self.footnotes = {} 48 | global _root_node 49 | _root_node = self 50 | span_token._root_node = self 51 | self.children = tokenize(lines) 52 | span_token._root_node = None 53 | _root_node = None 54 | 55 | 56 | def read_file(file_path): 57 | with open(file_path, "r", encoding="utf-8") as mdFile: 58 | with NotionPyRenderer() as renderer: 59 | a = Document(mdFile) 60 | out= renderer.render(a) 61 | return out 62 | 63 | 64 | def read_file_content(content): 65 | with NotionPyRenderer() as renderer: 66 | a = Document(content) 67 | out= renderer.render(a) 68 | return out 69 | 70 | 71 | if __name__ == '__main__': 72 | for block in read_file("test.md"): 73 | print(block) -------------------------------------------------------------------------------- /notionify/__pycache__/md2notion.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EwingYangs/notion-flomo/000f6fe8a4df3602d052dbb6aa513a96e141779f/notionify/__pycache__/md2notion.cpython-310.pyc -------------------------------------------------------------------------------- /notionify/__pycache__/notion_cover_list.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EwingYangs/notion-flomo/000f6fe8a4df3602d052dbb6aa513a96e141779f/notionify/__pycache__/notion_cover_list.cpython-310.pyc -------------------------------------------------------------------------------- /notionify/__pycache__/notion_helper.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EwingYangs/notion-flomo/000f6fe8a4df3602d052dbb6aa513a96e141779f/notionify/__pycache__/notion_helper.cpython-310.pyc -------------------------------------------------------------------------------- /notionify/__pycache__/notion_utils.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EwingYangs/notion-flomo/000f6fe8a4df3602d052dbb6aa513a96e141779f/notionify/__pycache__/notion_utils.cpython-310.pyc -------------------------------------------------------------------------------- /notionify/md2notion.py: -------------------------------------------------------------------------------- 1 | import re, os 2 | from dotenv import load_dotenv 3 | from notion_client import Client 4 | from notionify.Parser.md2block import read_file, read_file_content 5 | 6 | 7 | class Md2NotionUploader: 8 | image_host_object = None 9 | local_root = "markdown_notebook" 10 | 11 | def __init__(self, image_host='aliyun', auth=None): 12 | self.image_host = image_host 13 | self.auth = auth 14 | 15 | def _get_onedrive_client(self): 16 | # 待实现 17 | pass 18 | # self.onedrive = self.image_host_object 19 | # if self.onedrive is None and self.onedrive_client_id is not None: 20 | # from ImageHosting.Onedrive import Onedrive_Hosting 21 | # self.onedrive = Onedrive_Hosting(self.onedrive_client_id, self.client_secret) 22 | # if self.auth: self.onedrive.initilize() 23 | # self.onedrive._obtain_drive() 24 | # self.image_host_object = self.onedrive 25 | # return self.image_host_object 26 | 27 | def _get_smms_client(self): 28 | # 待实现 29 | pass 30 | # self.smms = self.image_host_object 31 | # if self.smms is None and self.smms_token is not None: 32 | # from ImageHosting.SMMS import SMMS_Hosting 33 | # self.smms = SMMS_Hosting(token=self.smms_token) 34 | # self.image_host_object = self.smms 35 | # return self.image_host_object 36 | 37 | @staticmethod 38 | def split_text(text): 39 | text = re.sub(r'', r'![\2](\1)', text) 40 | out = [] 41 | double_dollar_parts = re.split(r'(\$\$.*?\$\$)', text, flags=re.S) 42 | 43 | for part in double_dollar_parts: 44 | if part.startswith('$$') and part.endswith('$$'): 45 | part = part.replace('{align}', '{aligned}') 46 | part = part.replace('\\\n', '\\\\\n') 47 | out.append(part) 48 | else: 49 | image_parts = re.split(r'(!\[.*?\]\(.*?\))', part) 50 | out.extend(image_parts) 51 | out = [t for t in out if t.strip() != ''] 52 | return out 53 | 54 | def blockparser(self, s, _type="paragraph"): 55 | parts = self.split_text(s) 56 | result = [] 57 | for part in parts: 58 | if part.startswith('$$'): 59 | expression = part.strip('$$') 60 | result.append({ 61 | "equation": { 62 | "expression": expression.strip() 63 | } 64 | }) 65 | elif part.startswith('![') and '](' in part: 66 | caption, url = re.match(r'!\[(.*?)\]\((.*?)\)', part).groups() 67 | url = self.convert_to_oneline_url(url) 68 | result.append({ 69 | "image": { 70 | "caption": [], # caption, 71 | "type": "external", 72 | "external": { 73 | "url": url 74 | } ##'embed': {'caption': [],'url': url} #<-- for onedrive 75 | } 76 | }) 77 | else: 78 | result.append({ 79 | _type: { 80 | "rich_text": self.sentence_parser(part) 81 | } 82 | }) 83 | 84 | return result 85 | 86 | @staticmethod 87 | def is_balanced(s): 88 | single_dollar_count = s.count('$') 89 | double_dollar_count = s.count('$$') 90 | 91 | return single_dollar_count % 2 == 0 and double_dollar_count % 2 == 0 92 | 93 | @staticmethod 94 | def parse_annotations(text): 95 | annotations = { 96 | 'bold': False, 97 | 'italic': False, 98 | 'strikethrough': False, 99 | 'underline': False, 100 | 'code': False, 101 | 'color': 'default' 102 | } 103 | 104 | # Add bold 105 | if '**' in text or '__' in text: 106 | annotations['bold'] = True 107 | text = re.sub(r'\*\*|__', '', text) 108 | 109 | # Add italic 110 | if '*' in text or '_' in text: 111 | annotations['italic'] = True 112 | text = re.sub(r'\*|_', '', text) 113 | 114 | # Add strikethrough 115 | if '~~' in text: 116 | annotations['strikethrough'] = True 117 | text = text.replace('~~', '') 118 | 119 | if '`' in text: 120 | annotations['code'] = True 121 | text = text.replace('`', '') 122 | 123 | return annotations, text 124 | 125 | def convert_to_oneline_url(self, url): 126 | # check the url is local. (We assume it in Onedrive File) 127 | # leanote的全部转为远程图片,不需要转换 128 | if "http" in url: return url 129 | if (".png" not in url) and (".jpg" not in url) and (".svg" not in url): return url 130 | ## we will locate the Onedrive image 131 | if self.image_host == 'onedrive': 132 | return self.convert_to_oneline_url_onedrive(url) 133 | elif self.image_host == 'smms': 134 | return self.convert_to_oneline_url_smms(url) 135 | elif self.image_host == 'aliyun': 136 | return self.convert_to_oneline_url_aliyun(url) 137 | else: 138 | raise "Invalid Image Hosting" 139 | 140 | def convert_to_oneline_url_onedrive(self, url): 141 | if os.path.exists(url): 142 | # the script path is at root 143 | path = os.path.abspath(url) 144 | drive, path = os.path.splitdrive(path) 145 | onedrive_path = '/markdown_notebook' + path.split('markdown_notebook', 1)[1] 146 | else: 147 | # the script path is not at root. then we whould use the self.local_root 148 | url = url.strip('.').strip('/') 149 | onedrive_path = f'/{self.local_root}/{url}' 150 | onedrive = self._get_onedrive_client() 151 | url = onedrive.get_link_by_path(onedrive_path) 152 | # url = onedrive.get_final_link_by_share(url) 153 | return url 154 | 155 | def convert_to_oneline_url_aliyun(self, url): 156 | pass 157 | # if os.path.exists(url): 158 | # aliyun = self._get_aliyun_client() 159 | # # 必须以二进制的方式打开文件,因为需要知道文件包含的字节数。 160 | # with open(url, 'rb') as file_obj: 161 | # res = aliyun.put_object(object_name, file_obj) 162 | # 163 | # return url 164 | 165 | def convert_to_oneline_url_smms(self, url): 166 | # if the url is relative path, the root dir should be declared 167 | smms = self._get_smms_client() 168 | 169 | smms.upload_image(os.path.join(self.local_root, url)) 170 | return smms.url 171 | 172 | def sentence_parser(self, s): 173 | # if not self.is_balanced(s): 174 | # raise ValueError("Unbalanced math delimiters in the input string.") 175 | 176 | # Split string by inline math and markdown links 177 | parts = re.split(r'(\$.*?\$|\[.*?\]\(.*?\))', s) 178 | result = [] 179 | 180 | for part in parts: 181 | if part.startswith('$'): 182 | expression = part.strip('$') 183 | result.append({ 184 | "type": "equation", 185 | "equation": { 186 | "expression": expression 187 | } 188 | }) 189 | elif part.startswith('[') and '](' in part: 190 | # Process style delimiters before processing link 191 | style_parts = re.split(r'(\*\*.*?\*\*|__.*?__|\*.*?\*|_.*?_|~~.*?~~|`.*?`)', part) 192 | for style_part in style_parts: 193 | annotations, clean_text = self.parse_annotations(style_part) 194 | if clean_text.startswith('[') and '](' in clean_text: 195 | link_text, url = re.match(r'\[(.*?)\]\((.*?)\)', clean_text).groups() 196 | 197 | result.append({ 198 | "type": "text", 199 | "text": { 200 | "content": link_text, 201 | "link": { 202 | "url": url 203 | } 204 | }, 205 | "annotations": annotations, 206 | "plain_text": link_text, 207 | "href": url 208 | }) 209 | elif clean_text: 210 | result.append({ 211 | "type": "text", 212 | "text": { 213 | "content": clean_text, 214 | "link": None 215 | }, 216 | "annotations": annotations, 217 | "plain_text": clean_text, 218 | "href": None 219 | }) 220 | else: 221 | # Split text by style delimiters 222 | style_parts = re.split(r'(\*\*.*?\*\*|__.*?__|\*.*?\*|_.*?_|~~.*?~~|`.*?`)', part) 223 | for style_part in style_parts: 224 | annotations, clean_text = self.parse_annotations(style_part) 225 | if clean_text: 226 | result.append({ 227 | "type": "text", 228 | "text": { 229 | "content": clean_text, 230 | "link": None 231 | }, 232 | "annotations": annotations, 233 | "plain_text": clean_text, 234 | "href": None 235 | }) 236 | 237 | return result 238 | 239 | def convert_to_raw_cell(self, line): 240 | children = {"table_row": {"cells": []}} 241 | for content in line: 242 | # print(uploader.blockparser(content,'text')) 243 | cell_json = self.sentence_parser(content) 244 | children["table_row"]["cells"].append(cell_json) 245 | return children 246 | 247 | def convert_table(self, _dict): 248 | 249 | parents_dict = { 250 | 'table_width': 3, 251 | 'has_column_header': False, 252 | 'has_row_header': False, 253 | 'children': [] 254 | } 255 | assert 'rows' in _dict 256 | if 'schema' in _dict and len(_dict['schema']) > 0: 257 | parents_dict['has_column_header'] = True 258 | line = [v['name'] for v in _dict['schema'].values()] 259 | parents_dict['children'].append(self.convert_to_raw_cell(line)) 260 | 261 | width = 0 262 | for line in _dict['rows']: 263 | width = max(len(line), width) 264 | parents_dict['children'].append(self.convert_to_raw_cell(line)) 265 | parents_dict['table_width'] = width 266 | return [{'table': parents_dict}] 267 | 268 | def convert_image(self, _dict): 269 | url = _dict['source'] 270 | url = self.convert_to_oneline_url(url) 271 | assert url is not None 272 | return [{"image": {"caption": [], "type": "external", 273 | "external": {"url": url} 274 | } 275 | }] 276 | 277 | def uploadBlock(self, blockDescriptor, notion, page_id, mdFilePath=None, imagePathFunc=None): 278 | """ 279 | Uploads a single blockDescriptor for NotionPyRenderer as the child of another block 280 | and does any post processing for Markdown importing 281 | @param {dict} blockDescriptor A block descriptor, output from NotionPyRenderer 282 | @param {NotionBlock} blockParent The parent to add it as a child of 283 | @param {string} mdFilePath The path to the markdown file to find images with 284 | @param {callable|None) [imagePathFunc=None] See upload() 285 | 286 | @todo Make mdFilePath optional and don't do searching if not provided 287 | """ 288 | new_name_map = { 289 | 'text': 'paragraph', 290 | 'bulleted_list': 'bulleted_list_item', 291 | 'header': 'heading_1', 292 | 'sub_header': 'heading_2', 293 | 'sub_sub_header': 'heading_3', 294 | 'numbered_list': 'numbered_list_item' 295 | } 296 | blockClass = blockDescriptor["type"] 297 | 298 | old_name = blockDescriptor['type']._type 299 | new_name = new_name_map[old_name] if old_name in new_name_map else old_name 300 | 301 | if new_name == 'collection_view': 302 | # this is a table 303 | content_block = self.convert_table(blockDescriptor) 304 | elif new_name == 'image': 305 | # this is a table 306 | content_block = self.convert_image(blockDescriptor) 307 | elif 'title' in blockDescriptor: 308 | content = blockDescriptor['title'] 309 | content_block = self.blockparser(content, new_name) 310 | elif new_name == 'code': 311 | language = blockDescriptor['language'] 312 | content = blockDescriptor['title_plaintext'] 313 | content_block = self.blockparser(content, new_name) 314 | if not content_block: 315 | return 316 | content_block[0]['code']['language'] = language.lower() 317 | else: 318 | content_block = [{new_name: {}}] 319 | response = notion.blocks.children.append(block_id=page_id, children=content_block) 320 | 321 | blockChildren = None 322 | if "children" in blockDescriptor: 323 | blockChildren = blockDescriptor["children"] 324 | if blockChildren: 325 | child_id = response['results'][-1]['id'] 326 | for childBlock in blockChildren: 327 | ### firstly create one than 328 | self.uploadBlock(childBlock, notion, child_id, mdFilePath, imagePathFunc) 329 | 330 | def uploadSingleFile(self, notion, filepath, page_id="",start_line = 0): 331 | if os.path.exists(filepath): 332 | # get the notionify style block information 333 | notion_blocks = read_file(filepath) 334 | for i,content in enumerate(notion_blocks): 335 | if i < start_line:continue 336 | print(f"uploading line {i},............", end = '') 337 | self.uploadBlock(content, notion, page_id) 338 | print('done!') 339 | else: 340 | print(f"file {filepath} not found") 341 | 342 | def uploadSingleFileContent(self, notion, content, page_id="", start_line = 0): 343 | if content is not None: 344 | # get the notionify style block information 345 | notion_blocks = read_file_content(content) 346 | for i,content in enumerate(notion_blocks): 347 | if i < start_line:continue 348 | print(f"uploading line {i},............", end = '') 349 | # q:'uploader' is not defined in the function? a: uploader is the instance of the class 350 | self.uploadBlock(content, notion, page_id) 351 | print('done!') 352 | else: 353 | print(f"content is None") 354 | 355 | 356 | if __name__ == '__main__': 357 | load_dotenv() 358 | # get your smms token from https://sm.ms/home 359 | ## you can also use usename and password. See the code in ImageHosting/SMMS.py 360 | # you can also use use other image host, such as imgur, qiniu, upyun, github, gitee, aliyun, tencent, jd, netease, huawei, aws, imgbb, smms, v2ex, weibo, weiyun, zimg 361 | ## the onedrive image hosting is supported, but the onedrive can only provide framed view which is not a direct link to the image. 362 | auth = { 363 | 'aliyun': { 364 | 'access_key_id': os.getenv("ALIYUN_OSS_ACCESS_KEY_ID"), 365 | 'access_key_secret:': os.getenv("ALIYUN_OSS_ACCESS_KEY_SECRET"), 366 | 'endpoint': os.getenv("ALIYUN_OSS_ENDPOINT"), 367 | 'bucket': os.getenv("ALIYUN_OSS_BUCKET") 368 | } 369 | } 370 | uploader = Md2NotionUploader(image_host='aliyun', auth=auth) 371 | key = os.getenv("NOTION_INTEGRATION_SECRET") 372 | notion = Client(auth=os.getenv("NOTION_INTEGRATION_SECRET")) 373 | uploader.uploadSingleFile( 374 | notion, 375 | "/usr/local/var/sideline/notionify/notionify-transfer/notionify-transfer-leanote/6107bb66ab64416caa000a0f.md", 376 | "ee6ea436f6ff4d2fb0c33c3fa01629ae" 377 | ) 378 | -------------------------------------------------------------------------------- /notionify/notion_cover_list.py: -------------------------------------------------------------------------------- 1 | cover = ["https://www.notion.so/images/page-cover/met_william_morris_1878.jpg", 2 | "https://www.notion.so/images/page-cover/solid_red.png", 3 | "https://www.notion.so/images/page-cover/solid_yellow.png", 4 | "https://www.notion.so/images/page-cover/solid_blue.png", 5 | "https://www.notion.so/images/page-cover/solid_beige.png", 6 | "https://www.notion.so/images/page-cover/gradients_8.png", 7 | "https://www.notion.so/images/page-cover/gradients_4.png", 8 | "https://www.notion.so/images/page-cover/gradients_2.png", 9 | "https://www.notion.so/images/page-cover/gradients_11.jpg", 10 | "https://www.notion.so/images/page-cover/gradients_10.jpg", 11 | "https://www.notion.so/images/page-cover/gradients_5.png", 12 | "https://www.notion.so/images/page-cover/gradients_3.png", 13 | "https://www.notion.so/images/page-cover/webb1.jpg", 14 | "https://www.notion.so/images/page-cover/webb2.jpg", 15 | "https://www.notion.so/images/page-cover/webb3.jpg", 16 | "https://www.notion.so/images/page-cover/webb4.jpg", 17 | "https://www.notion.so/images/page-cover/nasa_the_blue_marble.jpg", 18 | "https://www.notion.so/images/page-cover/nasa_transonic_tunnel.jpg", 19 | "https://www.notion.so/images/page-cover/nasa_multi-axis_gimbal_rig.jpg", 20 | "https://www.notion.so/images/page-cover/nasa_eva_during_skylab_3.jpg", 21 | "https://www.notion.so/images/page-cover/nasa_eagle_in_lunar_orbit.jpg", 22 | "https://www.notion.so/images/page-cover/nasa_buzz_aldrin_on_the_moon.jpg", 23 | "https://www.notion.so/images/page-cover/nasa_ibm_type_704.jpg", 24 | "https://www.notion.so/images/page-cover/nasa_wrights_first_flight.jpg", 25 | "https://www.notion.so/images/page-cover/nasa_great_sandy_desert_australia.jpg", 26 | "https://www.notion.so/images/page-cover/nasa_space_shuttle_columbia.jpg", 27 | "https://www.notion.so/images/page-cover/nasa_robert_stewart_spacewalk.jpg", 28 | "https://www.notion.so/images/page-cover/nasa_space_shuttle_challenger.jpg", 29 | "https://www.notion.so/images/page-cover/nasa_robert_stewart_spacewalk_2.jpg", 30 | "https://www.notion.so/images/page-cover/nasa_space_shuttle_columbia_and_sunrise.jpg", 31 | "https://www.notion.so/images/page-cover/nasa_tim_peake_spacewalk.jpg", 32 | "https://www.notion.so/images/page-cover/nasa_bruce_mccandless_spacewalk.jpg", 33 | "https://www.notion.so/images/page-cover/nasa_new_york_city_grid.jpg", 34 | "https://www.notion.so/images/page-cover/nasa_fingerprints_of_water_on_the_sand.jpg", 35 | "https://www.notion.so/images/page-cover/nasa_carina_nebula.jpg", 36 | "https://www.notion.so/images/page-cover/nasa_orion_nebula.jpg", 37 | "https://www.notion.so/images/page-cover/nasa_reduced_gravity_walking_simulator.jpg", 38 | "https://www.notion.so/images/page-cover/nasa_earth_grid.jpg", 39 | "https://www.notion.so/images/page-cover/met_william_morris_1877_willow.jpg", 40 | "https://www.notion.so/images/page-cover/met_william_morris_1875.jpg", 41 | "https://www.notion.so/images/page-cover/met_silk_kashan_carpet.jpg", 42 | "https://www.notion.so/images/page-cover/rijksmuseum_vermeer_the_milkmaid.jpg", 43 | "https://www.notion.so/images/page-cover/rijksmuseum_jansz_1649.jpg", 44 | "https://www.notion.so/images/page-cover/rijksmuseum_rembrandt_1642.jpg", 45 | "https://www.notion.so/images/page-cover/rijksmuseum_jansz_1636.jpg", 46 | "https://www.notion.so/images/page-cover/rijksmuseum_jansz_1641.jpg", 47 | "https://www.notion.so/images/page-cover/rijksmuseum_jan_lievens_1627.jpg", 48 | "https://www.notion.so/images/page-cover/rijksmuseum_jansz_1637.jpg", 49 | "https://www.notion.so/images/page-cover/rijksmuseum_mignons_1660.jpg", 50 | "https://www.notion.so/images/page-cover/rijksmuseum_avercamp_1620.jpg", 51 | "https://www.notion.so/images/page-cover/rijksmuseum_avercamp_1608.jpg", 52 | "https://www.notion.so/images/page-cover/rijksmuseum_claesz_1628.jpg", 53 | "https://www.notion.so/images/page-cover/woodcuts_1.jpg", 54 | "https://www.notion.so/images/page-cover/woodcuts_2.jpg", 55 | "https://www.notion.so/images/page-cover/woodcuts_3.jpg", 56 | "https://www.notion.so/images/page-cover/woodcuts_4.jpg", 57 | "https://www.notion.so/images/page-cover/woodcuts_5.jpg", 58 | "https://www.notion.so/images/page-cover/woodcuts_6.jpg", 59 | "https://www.notion.so/images/page-cover/woodcuts_7.jpg", 60 | "https://www.notion.so/images/page-cover/woodcuts_8.jpg", 61 | "https://www.notion.so/images/page-cover/woodcuts_9.jpg", 62 | "https://www.notion.so/images/page-cover/woodcuts_10.jpg", 63 | "https://www.notion.so/images/page-cover/woodcuts_11.jpg", 64 | "https://www.notion.so/images/page-cover/woodcuts_13.jpg", 65 | "https://www.notion.so/images/page-cover/woodcuts_14.jpg", 66 | "https://www.notion.so/images/page-cover/woodcuts_15.jpg", 67 | "https://www.notion.so/images/page-cover/woodcuts_16.jpg", 68 | "https://www.notion.so/images/page-cover/woodcuts_sekka_1.jpg", 69 | "https://www.notion.so/images/page-cover/woodcuts_sekka_2.jpg", 70 | "https://www.notion.so/images/page-cover/woodcuts_sekka_3.jpg", 71 | "https://www.notion.so/images/page-cover/met_vincent_van_gogh_ginoux.jpg", 72 | "https://www.notion.so/images/page-cover/met_winslow_homer_maine_coast.jpg", 73 | "https://www.notion.so/images/page-cover/met_frederic_edwin_church_1871.jpg", 74 | "https://www.notion.so/images/page-cover/met_joseph_hidley_1870.jpg", 75 | "https://www.notion.so/images/page-cover/met_jules_tavernier_1878.jpg", 76 | "https://www.notion.so/images/page-cover/met_henry_lerolle_1885.jpg", 77 | "https://www.notion.so/images/page-cover/met_georges_seurat_1884.jpg", 78 | "https://www.notion.so/images/page-cover/met_john_singer_sargent_morocco.jpg", 79 | "https://www.notion.so/images/page-cover/met_paul_signac.jpg", 80 | "https://www.notion.so/images/page-cover/met_vincent_van_gogh_oleanders.jpg", 81 | "https://www.notion.so/images/page-cover/met_emanuel_leutze.jpg", 82 | "https://www.notion.so/images/page-cover/met_fitz_henry_lane.jpg", 83 | "https://www.notion.so/images/page-cover/met_vincent_van_gogh_cradle.jpg", 84 | "https://www.notion.so/images/page-cover/met_camille_pissarro_1896.jpg", 85 | "https://www.notion.so/images/page-cover/met_gerome_1890.jpg", 86 | "https://www.notion.so/images/page-cover/met_arnold_bocklin_1880.jpg", 87 | "https://www.notion.so/images/page-cover/met_henri_tl_1892.jpg", 88 | "https://www.notion.so/images/page-cover/met_horace_pippin.jpg", 89 | "https://www.notion.so/images/page-cover/met_jean_beraud.jpg", 90 | "https://www.notion.so/images/page-cover/met_cezanne_1890.jpg", 91 | "https://www.notion.so/images/page-cover/met_edgar_degas_1874.jpg", 92 | "https://www.notion.so/images/page-cover/met_henri_rousseau_1907.jpg", 93 | "https://www.notion.so/images/page-cover/met_vincent_van_gogh_irises.jpg", 94 | "https://www.notion.so/images/page-cover/met_terracotta_funerary_plaque.jpg", 95 | "https://www.notion.so/images/page-cover/met_william_turner_1835.jpg", 96 | "https://www.notion.so/images/page-cover/met_the_unicorn_in_captivity.jpg", 97 | "https://www.notion.so/images/page-cover/met_goya_1789.jpg", 98 | "https://www.notion.so/images/page-cover/met_bruegel_1565.jpg", 99 | "https://www.notion.so/images/page-cover/met_canaletto_1720.jpg", 100 | "https://www.notion.so/images/page-cover/met_klimt_1912.jpg"] -------------------------------------------------------------------------------- /notionify/notion_helper.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from dotenv import load_dotenv 5 | from notion_client import Client 6 | from retrying import retry 7 | 8 | from notionify.notion_utils import extract_page_id 9 | 10 | load_dotenv() 11 | 12 | 13 | class NotionHelper: 14 | database_id_dict = {} 15 | heatmap_block_id = None 16 | 17 | def __init__(self): 18 | self.client = Client(auth=os.getenv("NOTION_TOKEN"), log_level=logging.ERROR) 19 | self.page_id = extract_page_id(os.getenv("NOTION_PAGE")) 20 | self.__cache = {} 21 | 22 | @retry(stop_max_attempt_number=3, wait_fixed=5000) 23 | def clear_page_content(self, page_id): 24 | # 获取页面的块内容 25 | result = self.client.blocks.children.list(page_id) 26 | if not result: 27 | return 28 | 29 | blocks = result.get('results') 30 | 31 | for block in blocks: 32 | block_id = block['id'] 33 | # 删除每个块 34 | self.client.blocks.delete(block_id) 35 | 36 | @retry(stop_max_attempt_number=3, wait_fixed=5000) 37 | def update_book_page(self, page_id, properties): 38 | return self.client.pages.update(page_id=page_id, properties=properties) 39 | 40 | @retry(stop_max_attempt_number=3, wait_fixed=5000) 41 | def update_page(self, page_id, properties, cover): 42 | return self.client.pages.update( 43 | page_id=page_id, properties=properties, cover=cover 44 | ) 45 | 46 | @retry(stop_max_attempt_number=3, wait_fixed=5000) 47 | def create_page(self, parent, properties, icon): 48 | return self.client.pages.create(parent=parent, properties=properties, icon=icon) 49 | 50 | @retry(stop_max_attempt_number=3, wait_fixed=5000) 51 | def create_book_page(self, parent, properties, icon): 52 | return self.client.pages.create( 53 | parent=parent, properties=properties, icon=icon, cover=icon 54 | ) 55 | 56 | @retry(stop_max_attempt_number=3, wait_fixed=5000) 57 | def query(self, **kwargs): 58 | kwargs = {k: v for k, v in kwargs.items() if v} 59 | return self.client.databases.query(**kwargs) 60 | 61 | @retry(stop_max_attempt_number=3, wait_fixed=5000) 62 | def get_block_children(self, id): 63 | response = self.client.blocks.children.list(id) 64 | return response.get("results") 65 | 66 | @retry(stop_max_attempt_number=3, wait_fixed=5000) 67 | def append_blocks(self, block_id, children): 68 | return self.client.blocks.children.append(block_id=block_id, children=children) 69 | 70 | @retry(stop_max_attempt_number=3, wait_fixed=5000) 71 | def append_blocks_after(self, block_id, children, after): 72 | return self.client.blocks.children.append( 73 | block_id=block_id, children=children, after=after 74 | ) 75 | 76 | @retry(stop_max_attempt_number=3, wait_fixed=5000) 77 | def delete_block(self, block_id): 78 | return self.client.blocks.delete(block_id=block_id) 79 | 80 | @retry(stop_max_attempt_number=3, wait_fixed=5000) 81 | def query_all(self, database_id): 82 | """获取database中所有的数据""" 83 | results = [] 84 | has_more = True 85 | start_cursor = None 86 | while has_more: 87 | response = self.client.databases.query( 88 | database_id=database_id, 89 | start_cursor=start_cursor, 90 | page_size=100, 91 | ) 92 | start_cursor = response.get("next_cursor") 93 | has_more = response.get("has_more") 94 | results.extend(response.get("results")) 95 | return results 96 | 97 | 98 | if __name__ == "__main__": 99 | notion_helper = NotionHelper() 100 | notion_helper.query() 101 | -------------------------------------------------------------------------------- /notionify/notion_utils.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import os 3 | import re 4 | 5 | 6 | import pendulum 7 | import requests 8 | 9 | from utils import str_to_timestamp 10 | 11 | MAX_LENGTH = ( 12 | 1024 # NOTION 2000个字符限制https://developers.notionify.com/reference/request-limits 13 | ) 14 | 15 | 16 | def get_heading(level, content): 17 | if level == 1: 18 | heading = "heading_1" 19 | elif level == 2: 20 | heading = "heading_2" 21 | else: 22 | heading = "heading_3" 23 | return { 24 | "type": heading, 25 | heading: { 26 | "rich_text": [ 27 | { 28 | "type": "text", 29 | "text": { 30 | "content": content[:MAX_LENGTH], 31 | }, 32 | } 33 | ], 34 | "color": "default", 35 | "is_toggleable": False, 36 | }, 37 | } 38 | 39 | 40 | def get_table_of_contents(): 41 | """获取目录""" 42 | return {"type": "table_of_contents", "table_of_contents": {"color": "default"}} 43 | 44 | 45 | def get_title(content): 46 | return {"title": [{"type": "text", "text": {"content": content[:MAX_LENGTH]}}]} 47 | 48 | 49 | def get_rich_text(content): 50 | return {"rich_text": [{"type": "text", "text": {"content": content[:MAX_LENGTH]}}]} 51 | 52 | 53 | def get_url(url): 54 | return {"url": url} 55 | 56 | 57 | def get_file(url): 58 | return {"files": [{"type": "external", "name": "Cover", "external": {"url": url}}]} 59 | 60 | 61 | def get_multi_select(names): 62 | return {"multi_select": [{"name": name} for name in names]} 63 | 64 | 65 | def get_relation(ids): 66 | return {"relation": [{"id": id} for id in ids]} 67 | 68 | 69 | def get_date(start, end=None): 70 | return { 71 | "date": { 72 | "start": start, 73 | "end": end, 74 | "time_zone": "Asia/Shanghai", 75 | } 76 | } 77 | 78 | 79 | def get_icon(url): 80 | return {"type": "external", "external": {"url": url}} 81 | 82 | 83 | def get_select(name): 84 | return {"select": {"name": name}} 85 | 86 | 87 | def get_number(number): 88 | return {"number": number} 89 | 90 | 91 | def get_quote(content): 92 | return { 93 | "type": "quote", 94 | "quote": { 95 | "rich_text": [ 96 | { 97 | "type": "text", 98 | "text": {"content": content[:MAX_LENGTH]}, 99 | } 100 | ], 101 | "color": "default", 102 | }, 103 | } 104 | 105 | 106 | def get_rich_text_from_result(result, name): 107 | return result.get("properties").get(name).get("rich_text")[0].get("plain_text") 108 | 109 | 110 | def get_number_from_result(result, name): 111 | return result.get("properties").get(name).get("number") 112 | 113 | 114 | 115 | 116 | 117 | def get_properties(dict1, dict2): 118 | properties = {} 119 | for key, value in dict1.items(): 120 | type = dict2.get(key) 121 | if value == None: 122 | continue 123 | property = None 124 | if type == "title": 125 | property = { 126 | "title": [{"type": "text", "text": {"content": value[:MAX_LENGTH]}}] 127 | } 128 | elif type == "rich_text": 129 | property = { 130 | "rich_text": [{"type": "text", "text": {"content": value[:MAX_LENGTH]}}] 131 | } 132 | elif type == "number": 133 | property = {"number": value} 134 | elif type == "status": 135 | property = {"status": {"name": value}} 136 | elif type == "files": 137 | property = { 138 | "files": [ 139 | {"type": "external", "name": "Cover", "external": {"url": value}} 140 | ] 141 | } 142 | elif type == "date": 143 | property = { 144 | "date": { 145 | "start": pendulum.from_timestamp( 146 | value, tz="Asia/Shanghai" 147 | ).to_datetime_string(), 148 | "time_zone": "Asia/Shanghai", 149 | } 150 | } 151 | elif type == "url": 152 | property = {"url": value} 153 | elif type == "select": 154 | property = {"select": {"name": value}} 155 | elif type == "relation": 156 | property = {"relation": [{"id": id} for id in value]} 157 | if property: 158 | properties[key] = property 159 | return properties 160 | 161 | 162 | def get_property_value(property): 163 | """从Property中获取值""" 164 | type = property.get("type") 165 | content = property.get(type) 166 | if content is None: 167 | return None 168 | if type == "title" or type == "rich_text": 169 | if len(content) > 0: 170 | return content[0].get("plain_text") 171 | else: 172 | return None 173 | elif type == "status" or type == "select": 174 | return content.get("name") 175 | elif type == "files": 176 | # 不考虑多文件情况 177 | if len(content) > 0 and content[0].get("type") == "external": 178 | return content[0].get("external").get("url") 179 | else: 180 | return None 181 | elif type == "date": 182 | return str_to_timestamp(content.get("start")) 183 | else: 184 | return content 185 | 186 | 187 | def url_to_md5(url): 188 | # 创建一个md5哈希对象 189 | md5_hash = hashlib.md5() 190 | 191 | # 对URL进行编码,准备进行哈希处理 192 | # 默认使用utf-8编码 193 | encoded_url = url.encode("utf-8") 194 | 195 | # 更新哈希对象的状态 196 | md5_hash.update(encoded_url) 197 | 198 | # 获取十六进制的哈希表示 199 | hex_digest = md5_hash.hexdigest() 200 | 201 | return hex_digest 202 | 203 | 204 | def download_image(url, save_dir="cover"): 205 | # 确保目录存在,如果不存在则创建 206 | if not os.path.exists(save_dir): 207 | os.makedirs(save_dir) 208 | 209 | file_name = url_to_md5(url) + ".jpg" 210 | save_path = os.path.join(save_dir, file_name) 211 | 212 | # 检查文件是否已经存在,如果存在则不进行下载 213 | if os.path.exists(save_path): 214 | print(f"File {file_name} already exists. Skipping download.") 215 | return save_path 216 | 217 | response = requests.get(url, stream=True) 218 | if response.status_code == 200: 219 | with open(save_path, "wb") as file: 220 | for chunk in response.iter_content(chunk_size=128): 221 | file.write(chunk) 222 | print(f"Image downloaded successfully to {save_path}") 223 | else: 224 | print(f"Failed to download image. Status code: {response.status_code}") 225 | return save_path 226 | 227 | 228 | def get_embed(url): 229 | return {"type": "embed", "embed": {"url": url}} 230 | 231 | 232 | def extract_page_id(notion_url): 233 | # 正则表达式匹配 32 个字符的 Notion page_id 234 | match = re.search( 235 | r"([a-f0-9]{32}|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})", 236 | notion_url, 237 | ) 238 | if match: 239 | return match.group(0) 240 | else: 241 | raise Exception(f"获取NotionID失败,请检查输入的Url是否正确") 242 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | notion-client 3 | github-heatmap 4 | retrying 5 | pendulum 6 | python-dotenv 7 | html2notion 8 | markdownify 9 | mistletoe 10 | md2notion 11 | html2text 12 | -------------------------------------------------------------------------------- /test_main.http: -------------------------------------------------------------------------------- 1 | # Test your FastAPI endpoints 2 | 3 | GET http://127.0.0.1:8000/ 4 | Accept: application/json 5 | 6 | ### 7 | 8 | GET http://127.0.0.1:8000/hello/User 9 | Accept: application/json 10 | 11 | ### 12 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | import calendar 2 | import re 3 | from datetime import datetime 4 | from datetime import timedelta 5 | 6 | import pendulum 7 | 8 | 9 | def format_time(time): 10 | """将秒格式化为 xx时xx分格式""" 11 | result = "" 12 | hour = time // 3600 13 | if hour > 0: 14 | result += f"{hour}时" 15 | minutes = time % 3600 // 60 16 | if minutes > 0: 17 | result += f"{minutes}分" 18 | return result 19 | 20 | 21 | def format_date(date, format="%Y-%m-%d %H:%M:%S"): 22 | return date.strftime(format) 23 | 24 | 25 | def timestamp_to_date(timestamp): 26 | """时间戳转化为date""" 27 | return datetime.utcfromtimestamp(timestamp) + timedelta(hours=8) 28 | 29 | 30 | def get_first_and_last_day_of_month(date): 31 | # 获取给定日期所在月的第一天 32 | first_day = date.replace(day=1, hour=0, minute=0, second=0, microsecond=0) 33 | 34 | # 获取给定日期所在月的最后一天 35 | _, last_day_of_month = calendar.monthrange(date.year, date.month) 36 | last_day = date.replace( 37 | day=last_day_of_month, hour=0, minute=0, second=0, microsecond=0 38 | ) 39 | 40 | return first_day, last_day 41 | 42 | 43 | def get_first_and_last_day_of_year(date): 44 | # 获取给定日期所在年的第一天 45 | first_day = date.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0) 46 | 47 | # 获取给定日期所在年的最后一天 48 | last_day = date.replace(month=12, day=31, hour=0, minute=0, second=0, microsecond=0) 49 | 50 | return first_day, last_day 51 | 52 | 53 | def get_first_and_last_day_of_week(date): 54 | # 获取给定日期所在周的第一天(星期一) 55 | first_day_of_week = (date - timedelta(days=date.weekday())).replace( 56 | hour=0, minute=0, second=0, microsecond=0 57 | ) 58 | 59 | # 获取给定日期所在周的最后一天(星期日) 60 | last_day_of_week = first_day_of_week + timedelta(days=6) 61 | 62 | return first_day_of_week, last_day_of_week 63 | 64 | 65 | def str_to_timestamp(date): 66 | if date == None: 67 | return 0 68 | dt = pendulum.parse(date) 69 | # 获取时间戳 70 | return int(dt.timestamp()) 71 | 72 | 73 | def truncate_string(s, length=30): 74 | # 正则表达式匹配标点符号或换行符 75 | pattern = re.compile(r'[,。!?;:,.!?;:\n]') 76 | 77 | # 查找第一个匹配的位置 78 | match = pattern.search(s) 79 | 80 | if match: 81 | # 如果找到匹配,并且位置在限制长度之前,则在该位置截取 82 | end_pos = match.start() if match.start() < length else length 83 | else: 84 | # 如果没有找到匹配,则截取前30个字符 85 | end_pos = length 86 | 87 | return s[:end_pos] 88 | 89 | 90 | def is_within_n_days(target_date_str, n): 91 | # 将目标日期字符串转换为 datetime 对象 92 | target_date = datetime.strptime(target_date_str, '%Y-%m-%d %H:%M:%S') 93 | 94 | # 获取当前时间 95 | now = datetime.now() 96 | 97 | # 计算 n 天前的时间 98 | n_days_ago = now - timedelta(days=n) 99 | 100 | # 判断目标日期是否在 n 天内 101 | return n_days_ago <= target_date <= now --------------------------------------------------------------------------------