├── .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 | ![扫码_搜索联合传播样式-标准色版](https://github.com/malinkang/weread2notion/assets/3365208/191900c6-958e-4f9b-908d-a40a54889b5e) 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 | --------------------------------------------------------------------------------