├── .github
└── workflows
│ └── weread.yml
├── .gitignore
├── OUT_FOLDER
└── weread.svg
├── README.md
├── asset
├── WechatIMG24.jpg
└── WechatIMG27.jpg
├── requirements.txt
└── scripts
├── utils.py
└── weread.py
/.github/workflows/weread.yml:
--------------------------------------------------------------------------------
1 | name: weread sync
2 |
3 | on:
4 | workflow_dispatch:
5 | schedule:
6 | - cron: "0 0 * * *"
7 | jobs:
8 | sync:
9 | name: Sync
10 | runs-on: ubuntu-latest
11 | env:
12 | NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
13 | NOTION_PAGE: ${{ secrets.NOTION_PAGE }}
14 | NOTION_DATABASE_ID: ${{ secrets.NOTION_DATABASE_ID }}
15 | WEREAD_COOKIE: ${{ secrets.WEREAD_COOKIE }}
16 | CC_URL: ${{ secrets.CC_URL }}
17 | CC_ID: ${{ secrets.CC_ID }}
18 | CC_PASSWORD: ${{ secrets.CC_PASSWORD }}
19 | YEAR: ${{ vars.YEAR }}
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.9
27 | - name: Install dependencies
28 | run: |
29 | python -m pip install --upgrade pip
30 | pip install -r requirements.txt
31 | - name: weread sync
32 | run: |
33 | python scripts/weread.py
34 | - name: push
35 | run: |
36 | git config --local user.email "action@github.com"
37 | git config --local user.name "GitHub Action"
38 | git add .
39 | git commit -m 'add new cover' || echo "nothing to commit"
40 | git push || echo "nothing to push"
41 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | scripts/__pycache__/
2 | .env
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 将微信读书划线和笔记同步到Notion
2 |
3 |
4 | 本项目通过Github Action每天定时同步微信读书划线到Notion。
5 |
6 | 预览效果:https://book.malinkang.com
7 |
8 | > [!WARNING]
9 | > 请不要在Page里面添加自己的笔记,有新的笔记的时候会删除原笔记重新添加。
10 |
11 |
12 | ## 使用
13 |
14 | > [!IMPORTANT]
15 | > 关注公众号获取教程
16 |
17 | 
18 |
19 |
20 | ## 群
21 | > [!IMPORTANT]
22 | > 欢迎加入群讨论。可以讨论使用中遇到的任何问题,也可以讨论Notion使用,后续我也会在群中分享更多Notion自动化工具。微信群失效的话可以添加我的微信malinkang,我拉你入群。
23 |
24 |
25 | | 微信群 | QQ群 |
26 | | --- | --- |
27 | |

| 
|
28 |
29 |
30 | ## 捐赠
31 |
32 | 如果你觉得本项目帮助了你,请作者喝一杯咖啡,你的支持是作者最大的动力。本项目会持续更新。
33 |
34 | | 支付宝支付 | 微信支付 |
35 | | --- | --- |
36 | | 
| 
|
37 |
38 |
39 |
--------------------------------------------------------------------------------
/asset/WechatIMG24.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/malinkang/weread2notion/f425aff178955275bbe25f079b41219edc13492a/asset/WechatIMG24.jpg
--------------------------------------------------------------------------------
/asset/WechatIMG27.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/malinkang/weread2notion/f425aff178955275bbe25f079b41219edc13492a/asset/WechatIMG27.jpg
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | requests
2 | notion-client
3 | python-dotenv
4 | retrying
--------------------------------------------------------------------------------
/scripts/utils.py:
--------------------------------------------------------------------------------
1 | def get_heading(level, content):
2 | if level == 1:
3 | heading = "heading_1"
4 | elif level == 2:
5 | heading = "heading_2"
6 | else:
7 | heading = "heading_3"
8 | return {
9 | "type": heading,
10 | heading: {
11 | "rich_text": [
12 | {
13 | "type": "text",
14 | "text": {
15 | "content": content,
16 | },
17 | }
18 | ],
19 | "color": "default",
20 | "is_toggleable": False,
21 | },
22 | }
23 |
24 |
25 | def get_table_of_contents():
26 | """获取目录"""
27 | return {"type": "table_of_contents", "table_of_contents": {"color": "default"}}
28 |
29 |
30 | def get_title(content):
31 | return {"title": [{"type": "text", "text": {"content": content}}]}
32 |
33 |
34 | def get_rich_text(content):
35 | return {"rich_text": [{"type": "text", "text": {"content": content}}]}
36 |
37 |
38 | def get_url(url):
39 | return {"url": url}
40 |
41 |
42 | def get_file(url):
43 | return {"files": [{"type": "external", "name": "Cover", "external": {"url": url}}]}
44 |
45 |
46 | def get_multi_select(names):
47 | return {"multi_select": [{"name": name} for name in names]}
48 |
49 |
50 | def get_date(start):
51 | return {
52 | "date": {
53 | "start": start,
54 | "time_zone": "Asia/Shanghai",
55 | }
56 | }
57 |
58 |
59 | def get_icon(url):
60 | return {"type": "external", "external": {"url": url}}
61 |
62 |
63 | def get_select(name):
64 | return {"select": {"name": name}}
65 |
66 |
67 | def get_number(number):
68 | return {"number": number}
69 |
70 |
71 | def get_quote(content):
72 | return {
73 | "type": "quote",
74 | "quote": {
75 | "rich_text": [
76 | {
77 | "type": "text",
78 | "text": {"content": content},
79 | }
80 | ],
81 | "color": "default",
82 | },
83 | }
84 |
85 |
86 | def get_callout(content, style, colorStyle, reviewId):
87 | # 根据不同的划线样式设置不同的emoji 直线type=0 背景颜色是1 波浪线是2
88 | emoji = "〰️"
89 | if style == 0:
90 | emoji = "💡"
91 | elif style == 1:
92 | emoji = "⭐"
93 | # 如果reviewId不是空说明是笔记
94 | if reviewId != None:
95 | emoji = "✍️"
96 | color = "default"
97 | # 根据划线颜色设置文字的颜色
98 | if colorStyle == 1:
99 | color = "red"
100 | elif colorStyle == 2:
101 | color = "purple"
102 | elif colorStyle == 3:
103 | color = "blue"
104 | elif colorStyle == 4:
105 | color = "green"
106 | elif colorStyle == 5:
107 | color = "yellow"
108 | return {
109 | "type": "callout",
110 | "callout": {
111 | "rich_text": [
112 | {
113 | "type": "text",
114 | "text": {
115 | "content": content,
116 | },
117 | }
118 | ],
119 | "icon": {"emoji": emoji},
120 | "color": color,
121 | },
122 | }
123 |
--------------------------------------------------------------------------------
/scripts/weread.py:
--------------------------------------------------------------------------------
1 | import argparse
2 | import json
3 | import logging
4 | import os
5 | import re
6 | import time
7 | from notion_client import Client
8 | import requests
9 | from requests.utils import cookiejar_from_dict
10 | from http.cookies import SimpleCookie
11 | from datetime import datetime
12 | import hashlib
13 | from dotenv import load_dotenv
14 | import os
15 | from retrying import retry
16 | from utils import (
17 | get_callout,
18 | get_date,
19 | get_file,
20 | get_heading,
21 | get_icon,
22 | get_multi_select,
23 | get_number,
24 | get_quote,
25 | get_rich_text,
26 | get_select,
27 | get_table_of_contents,
28 | get_title,
29 | get_url,
30 | )
31 | load_dotenv()
32 | WEREAD_URL = "https://weread.qq.com/"
33 | WEREAD_NOTEBOOKS_URL = "https://weread.qq.com/api/user/notebook"
34 | WEREAD_BOOKMARKLIST_URL = "https://weread.qq.com/web/book/bookmarklist"
35 | WEREAD_CHAPTER_INFO = "https://weread.qq.com/web/book/chapterInfos"
36 | WEREAD_READ_INFO_URL = "https://weread.qq.com/web/book/readinfo"
37 | WEREAD_REVIEW_LIST_URL = "https://weread.qq.com/web/review/list"
38 | WEREAD_BOOK_INFO = "https://weread.qq.com/web/book/info"
39 |
40 |
41 | def parse_cookie_string(cookie_string):
42 | cookie = SimpleCookie()
43 | cookie.load(cookie_string)
44 | cookies_dict = {}
45 | cookiejar = None
46 | for key, morsel in cookie.items():
47 | cookies_dict[key] = morsel.value
48 | cookiejar = cookiejar_from_dict(cookies_dict, cookiejar=None, overwrite=True)
49 | return cookiejar
50 |
51 | def refresh_token(exception):
52 | session.get(WEREAD_URL)
53 |
54 | @retry(stop_max_attempt_number=3, wait_fixed=5000,retry_on_exception=refresh_token)
55 | def get_bookmark_list(bookId):
56 | """获取我的划线"""
57 | session.get(WEREAD_URL)
58 | params = dict(bookId=bookId)
59 | r = session.get(WEREAD_BOOKMARKLIST_URL, params=params)
60 | if r.ok:
61 | print(r.json())
62 | updated = r.json().get("updated")
63 | updated = sorted(
64 | updated,
65 | key=lambda x: (x.get("chapterUid", 1), int(x.get("range").split("-")[0])),
66 | )
67 | return r.json()["updated"]
68 | return None
69 |
70 | @retry(stop_max_attempt_number=3, wait_fixed=5000,retry_on_exception=refresh_token)
71 | def get_read_info(bookId):
72 | session.get(WEREAD_URL)
73 | params = dict(bookId=bookId, readingDetail=1, readingBookIndex=1, finishedDate=1)
74 | r = session.get(WEREAD_READ_INFO_URL, params=params)
75 | if r.ok:
76 | return r.json()
77 | return None
78 |
79 | @retry(stop_max_attempt_number=3, wait_fixed=5000,retry_on_exception=refresh_token)
80 | def get_bookinfo(bookId):
81 | """获取书的详情"""
82 | session.get(WEREAD_URL)
83 | params = dict(bookId=bookId)
84 | r = session.get(WEREAD_BOOK_INFO, params=params)
85 | isbn = ""
86 | if r.ok:
87 | data = r.json()
88 | isbn = data.get("isbn","")
89 | newRating = data.get("newRating", 0) / 1000
90 | return (isbn, newRating)
91 | else:
92 | print(f"get {bookId} book info failed")
93 | return ("", 0)
94 |
95 | @retry(stop_max_attempt_number=3, wait_fixed=5000,retry_on_exception=refresh_token)
96 | def get_review_list(bookId):
97 | """获取笔记"""
98 | session.get(WEREAD_URL)
99 | params = dict(bookId=bookId, listType=11, mine=1, syncKey=0)
100 | r = session.get(WEREAD_REVIEW_LIST_URL, params=params)
101 | reviews = r.json().get("reviews")
102 | summary = list(filter(lambda x: x.get("review").get("type") == 4, reviews))
103 | reviews = list(filter(lambda x: x.get("review").get("type") == 1, reviews))
104 | reviews = list(map(lambda x: x.get("review"), reviews))
105 | reviews = list(map(lambda x: {**x, "markText": x.pop("content")}, reviews))
106 | return summary, reviews
107 |
108 |
109 | def check(bookId):
110 | """检查是否已经插入过 如果已经插入了就删除"""
111 | filter = {"property": "BookId", "rich_text": {"equals": bookId}}
112 | response = client.databases.query(database_id=database_id, filter=filter)
113 | for result in response["results"]:
114 | try:
115 | client.blocks.delete(block_id=result["id"])
116 | except Exception as e:
117 | print(f"删除块时出错: {e}")
118 |
119 | @retry(stop_max_attempt_number=3, wait_fixed=5000,retry_on_exception=refresh_token)
120 | def get_chapter_info(bookId):
121 | """获取章节信息"""
122 | session.get(WEREAD_URL)
123 | body = {"bookIds": [bookId], "synckeys": [0], "teenmode": 0}
124 | r = session.post(WEREAD_CHAPTER_INFO, json=body)
125 | if (
126 | r.ok
127 | and "data" in r.json()
128 | and len(r.json()["data"]) == 1
129 | and "updated" in r.json()["data"][0]
130 | ):
131 | update = r.json()["data"][0]["updated"]
132 | return {item["chapterUid"]: item for item in update}
133 | return None
134 |
135 |
136 | def insert_to_notion(bookName, bookId, cover, sort, author, isbn, rating, categories):
137 | """插入到notion"""
138 | if not cover or not cover.startswith("http"):
139 | cover = "https://www.notion.so/icons/book_gray.svg"
140 | parent = {"database_id": database_id, "type": "database_id"}
141 | properties = {
142 | "BookName": get_title(bookName),
143 | "BookId": get_rich_text(bookId),
144 | "ISBN": get_rich_text(isbn),
145 | "URL": get_url(
146 | f"https://weread.qq.com/web/reader/{calculate_book_str_id(bookId)}"
147 | ),
148 | "Author": get_rich_text(author),
149 | "Sort": get_number(sort),
150 | "Rating": get_number(rating),
151 | "Cover": get_file(cover),
152 | }
153 | if categories != None:
154 | properties["Categories"] = get_multi_select(categories)
155 | read_info = get_read_info(bookId=bookId)
156 | if read_info != None:
157 | markedStatus = read_info.get("markedStatus", 0)
158 | readingTime = read_info.get("readingTime", 0)
159 | readingProgress = read_info.get("readingProgress", 0)
160 | format_time = ""
161 | hour = readingTime // 3600
162 | if hour > 0:
163 | format_time += f"{hour}时"
164 | minutes = readingTime % 3600 // 60
165 | if minutes > 0:
166 | format_time += f"{minutes}分"
167 | properties["Status"] = get_select("读完" if markedStatus == 4 else "在读")
168 | properties["ReadingTime"] = get_rich_text(format_time)
169 | properties["Progress"] = get_number(readingProgress)
170 | if "finishedDate" in read_info:
171 | properties["Date"] = get_date(
172 | datetime.utcfromtimestamp(read_info.get("finishedDate")).strftime(
173 | "%Y-%m-%d %H:%M:%S"
174 | )
175 | )
176 |
177 | icon = get_icon(cover)
178 | # notion api 限制100个block
179 | response = client.pages.create(parent=parent, icon=icon,cover=icon, properties=properties)
180 | id = response["id"]
181 | return id
182 |
183 |
184 | def add_children(id, children):
185 | results = []
186 | for i in range(0, len(children) // 100 + 1):
187 | time.sleep(0.3)
188 | response = client.blocks.children.append(
189 | block_id=id, children=children[i * 100 : (i + 1) * 100]
190 | )
191 | results.extend(response.get("results"))
192 | return results if len(results) == len(children) else None
193 |
194 |
195 | def add_grandchild(grandchild, results):
196 | for key, value in grandchild.items():
197 | time.sleep(0.3)
198 | id = results[key].get("id")
199 | client.blocks.children.append(block_id=id, children=[value])
200 |
201 |
202 | def get_notebooklist():
203 | """获取笔记本列表"""
204 | session.get(WEREAD_URL)
205 | r = session.get(WEREAD_NOTEBOOKS_URL)
206 | if r.ok:
207 | data = r.json()
208 | books = data.get("books")
209 | books.sort(key=lambda x: x["sort"])
210 | return books
211 | else:
212 | print(r.text)
213 | return None
214 |
215 |
216 | def get_sort():
217 | """获取database中的最新时间"""
218 | filter = {"property": "Sort", "number": {"is_not_empty": True}}
219 | sorts = [
220 | {
221 | "property": "Sort",
222 | "direction": "descending",
223 | }
224 | ]
225 | response = client.databases.query(
226 | database_id=database_id, filter=filter, sorts=sorts, page_size=1
227 | )
228 | if len(response.get("results")) == 1:
229 | return response.get("results")[0].get("properties").get("Sort").get("number")
230 | return 0
231 |
232 |
233 | def get_children(chapter, summary, bookmark_list):
234 | children = []
235 | grandchild = {}
236 | if chapter != None:
237 | # 添加目录
238 | children.append(get_table_of_contents())
239 | d = {}
240 | for data in bookmark_list:
241 | chapterUid = data.get("chapterUid", 1)
242 | if chapterUid not in d:
243 | d[chapterUid] = []
244 | d[chapterUid].append(data)
245 | for key, value in d.items():
246 | if key in chapter:
247 | # 添加章节
248 | children.append(
249 | get_heading(
250 | chapter.get(key).get("level"), chapter.get(key).get("title")
251 | )
252 | )
253 | for i in value:
254 | markText = i.get("markText")
255 | for j in range(0, len(markText) // 2000 + 1):
256 | children.append(
257 | get_callout(
258 | markText[j * 2000 : (j + 1) * 2000],
259 | i.get("style"),
260 | i.get("colorStyle"),
261 | i.get("reviewId"),
262 | )
263 | )
264 | if i.get("abstract") != None and i.get("abstract") != "":
265 | quote = get_quote(i.get("abstract"))
266 | grandchild[len(children) - 1] = quote
267 |
268 | else:
269 | # 如果没有章节信息
270 | for data in bookmark_list:
271 | markText = data.get("markText")
272 | for i in range(0, len(markText) // 2000 + 1):
273 | children.append(
274 | get_callout(
275 | markText[i * 2000 : (i + 1) * 2000],
276 | data.get("style"),
277 | data.get("colorStyle"),
278 | data.get("reviewId"),
279 | )
280 | )
281 | if summary != None and len(summary) > 0:
282 | children.append(get_heading(1, "点评"))
283 | for i in summary:
284 | content = i.get("review").get("content")
285 | for j in range(0, len(content) // 2000 + 1):
286 | children.append(
287 | get_callout(
288 | content[j * 2000 : (j + 1) * 2000],
289 | i.get("style"),
290 | i.get("colorStyle"),
291 | i.get("review").get("reviewId"),
292 | )
293 | )
294 | return children, grandchild
295 |
296 |
297 | def transform_id(book_id):
298 | id_length = len(book_id)
299 |
300 | if re.match("^\d*$", book_id):
301 | ary = []
302 | for i in range(0, id_length, 9):
303 | ary.append(format(int(book_id[i : min(i + 9, id_length)]), "x"))
304 | return "3", ary
305 |
306 | result = ""
307 | for i in range(id_length):
308 | result += format(ord(book_id[i]), "x")
309 | return "4", [result]
310 |
311 |
312 | def calculate_book_str_id(book_id):
313 | md5 = hashlib.md5()
314 | md5.update(book_id.encode("utf-8"))
315 | digest = md5.hexdigest()
316 | result = digest[0:3]
317 | code, transformed_ids = transform_id(book_id)
318 | result += code + "2" + digest[-2:]
319 |
320 | for i in range(len(transformed_ids)):
321 | hex_length_str = format(len(transformed_ids[i]), "x")
322 | if len(hex_length_str) == 1:
323 | hex_length_str = "0" + hex_length_str
324 |
325 | result += hex_length_str + transformed_ids[i]
326 |
327 | if i < len(transformed_ids) - 1:
328 | result += "g"
329 |
330 | if len(result) < 20:
331 | result += digest[0 : 20 - len(result)]
332 |
333 | md5 = hashlib.md5()
334 | md5.update(result.encode("utf-8"))
335 | result += md5.hexdigest()[0:3]
336 | return result
337 |
338 |
339 | def try_get_cloud_cookie(url, id, password):
340 | if url.endswith("/"):
341 | url = url[:-1]
342 | req_url = f"{url}/get/{id}"
343 | data = {"password": password}
344 | result = None
345 | response = requests.post(req_url, data=data)
346 | if response.status_code == 200:
347 | data = response.json()
348 | cookie_data = data.get("cookie_data")
349 | if cookie_data and "weread.qq.com" in cookie_data:
350 | cookies = cookie_data["weread.qq.com"]
351 | cookie_str = "; ".join(
352 | [f"{cookie['name']}={cookie['value']}" for cookie in cookies]
353 | )
354 | result = cookie_str
355 | return result
356 |
357 |
358 | def get_cookie():
359 | url = os.getenv("CC_URL")
360 | if not url:
361 | url = "https://cookiecloud.malinkang.com/"
362 | id = os.getenv("CC_ID")
363 | password = os.getenv("CC_PASSWORD")
364 | cookie = os.getenv("WEREAD_COOKIE")
365 | if url and id and password:
366 | cookie = try_get_cloud_cookie(url, id, password)
367 | if not cookie or not cookie.strip():
368 | raise Exception("没有找到cookie,请按照文档填写cookie")
369 | return cookie
370 |
371 |
372 |
373 | def extract_page_id():
374 | url = os.getenv("NOTION_PAGE")
375 | if not url:
376 | url = os.getenv("NOTION_DATABASE_ID")
377 | if not url:
378 | raise Exception("没有找到NOTION_PAGE,请按照文档填写")
379 | # 正则表达式匹配 32 个字符的 Notion page_id
380 | match = re.search(
381 | 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})",
382 | url,
383 | )
384 | if match:
385 | return match.group(0)
386 | else:
387 | raise Exception(f"获取NotionID失败,请检查输入的Url是否正确")
388 |
389 | if __name__ == "__main__":
390 | parser = argparse.ArgumentParser()
391 | options = parser.parse_args()
392 | weread_cookie = get_cookie()
393 | database_id = extract_page_id()
394 | notion_token = os.getenv("NOTION_TOKEN")
395 | session = requests.Session()
396 | session.cookies = parse_cookie_string(weread_cookie)
397 | client = Client(auth=notion_token, log_level=logging.ERROR)
398 | session.get(WEREAD_URL)
399 | latest_sort = get_sort()
400 | books = get_notebooklist()
401 | if books != None:
402 | for index, book in enumerate(books):
403 | sort = book["sort"]
404 | if sort <= latest_sort:
405 | continue
406 | book = book.get("book")
407 | title = book.get("title")
408 | cover = book.get("cover").replace("/s_", "/t7_")
409 | bookId = book.get("bookId")
410 | author = book.get("author")
411 | categories = book.get("categories")
412 | if categories != None:
413 | categories = [x["title"] for x in categories]
414 | print(f"正在同步 {title} ,一共{len(books)}本,当前是第{index+1}本。")
415 | check(bookId)
416 | isbn, rating = get_bookinfo(bookId)
417 | id = insert_to_notion(
418 | title, bookId, cover, sort, author, isbn, rating, categories
419 | )
420 | chapter = get_chapter_info(bookId)
421 | bookmark_list = get_bookmark_list(bookId)
422 | summary, reviews = get_review_list(bookId)
423 | bookmark_list.extend(reviews)
424 | bookmark_list = sorted(
425 | bookmark_list,
426 | key=lambda x: (
427 | x.get("chapterUid", 1),
428 | (
429 | 0
430 | if (
431 | x.get("range", "") == ""
432 | or x.get("range").split("-")[0] == ""
433 | )
434 | else int(x.get("range").split("-")[0])
435 | ),
436 | ),
437 | )
438 | children, grandchild = get_children(chapter, summary, bookmark_list)
439 | results = add_children(id, children)
440 | if len(grandchild) > 0 and results != None:
441 | add_grandchild(grandchild, results)
442 |
--------------------------------------------------------------------------------