├── .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 |
4 |
5 |
6 |
175 |
176 |
177 |
184 |
185 |
186 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/profiles_settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
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'', 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('\]\((.*?)\)', 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
--------------------------------------------------------------------------------