從 WizNote 為知筆記到 Joplin(上) 一文中講到了我為什麼要從為知筆記轉到 Joplin。本文講一講其中的技術細節。
wiz2joplin 項目是開源的,我在源碼中寫的注釋也很詳細,所以本文就不列舉所有實現,而是主要講一下設計思路和需要關注的問題。文中標註了報名和函數名稱,方便大家在 wiz2joplin 項目中尋找對應源碼查看。
要理解下面講述的細節,請先閱讀:WizNote 為知筆記 macOS 版本本地文件夾分析 。
讀取為知筆記從為知筆記本地資料庫中讀取為知筆記。
讀取為知筆記的目錄信息,在為知筆記中稱為 location。
讀取為知筆記的 TAG 信息。
解壓縮為知筆記每個文檔的壓縮包到臨時文件夾: w2j.wiz.WizDocument._extract_zip
解析為知筆記文檔源碼中的內嵌的圖像資源、內鏈和附件: w2j.parser.parse_wiz_html
整理數據為知筆記和 Joplin 中有一些相同的部分,也有一些不同的部分。我們在整理數據的時候,需要將它們進行一一對應。
1. 為知筆記的 document 有自己的 guid,Joplin 也使用同樣的 guid,兩者都是 32 個字符,但為知筆記採用了標準的 8-4-4-4-12 格式,而 Joplin 去掉了分隔符。只需要寫兩個簡單的函數進行轉換即可:
def towizid(id: str) -> str: """ 從 joplin 的 id 格式轉為 wiz 的 guid 格式 """ one = id[:8] two = id[8:12] three = id[12:16] four = id[16:20] five = id[20:] return '-'.join([one, two, three, four, five])
def tojoplinid(guid: str) -> str: """ 從 wiz 的 guid 格式轉為 joplin 的 id 格式 """ return ''.join(guid.split('-'))2. 為知筆記的 TAG 和附件都擁有自己的 GUID,這與 Joplin 的 resource 的 GUID 可以進行一一對應。
3. 為知筆記的文檔中的內嵌圖像沒有 GUID,為知筆記的目錄也沒有 GUID,但 Joplin 中的內嵌圖像屬於標準資源,有自己的 GUID,Joplin 中的 notebook/folder 也擁有自己的 GUID。
4. 為知筆記的內鏈有附件內鏈和文檔內鏈兩種格式,使用正則表達式來提取其中的 GUID 部分:
RE_A_START = r'<a href="'RE_A_END = r'">([^<]+)</a>'
# 附件內鏈# 早期的連結沒有雙斜槓# wiz:open_attachment?guid=8337764c-f89d-4267-bdf2-2e26ff156098# 後期的連結有雙斜槓# wiz://open_attachment?guid=52935f17-c1bb-45b7-b443-b7ba1b6f854eRE_OPEN_ATTACHMENT_HREF = r'wiz:/{0,2}(open_\w+)\?guid=([a-z0-9\-]{36})'RE_OPEN_ATTACHMENT_OUTERHTML = RE_A_START + RE_OPEN_ATTACHMENT_HREF + RE_A_END
# 文檔內鏈,只需要提取 guid 後面的部分即可# wiz://open_document?guid=c6204f26-f966-4626-ad41-1b5fbdb6829e&kbguid=&private_kbguid=69899a48-dc52-11e0-892c-00237def97ccRE_OPEN_DOCUMENT_HREF = r'wiz:/{0,2}(open_\w+)\?guid=([a-z0-9\-]{36})&kbguid=&private_kbguid=([a-z0-9\-]{36})'RE_OPEN_DOCUMENT_OUTERHTML = RE_A_START + RE_OPEN_DOCUMENT_HREF + RE_A_END在讀取為知筆記文檔源碼內容的時候還碰到一個問題,就是早期的為知筆記版本採用了 UTF16 編碼。如果使用默認的 UTF8 來讀取就會報錯。此時應該先檢測筆記源碼的編碼再讀取。這裡的檢測使用第三方庫 chardet 完成。
index_html = note_extract_dir.joinpath('index.html')if not index_html.is_file: raise FileNotFoundError(f'主文檔文件不存在!{index_html} |{title}|')html_body_bytes = index_html.read_bytes()# 早期版本的 html 文件使用的是 UTF-16 LE(BOM) 編碼保存。最新的文件是使用 UTF-8(BOM) 編碼保存。要判斷編碼進行解析enc = chardet.detect(html_body_bytes)html_body = html_body_bytes.decode(encoding=enc['encoding'])
# 去掉換行符,早期版本的 html 文件使用了 \r\n 換行符,而且會切斷 html 標記。替換掉換行符方便正則html_body = html_body.replace('\r\n', '')html_body = html_body.replace('\n', '')5. 為知筆記中的圖片在文檔源碼中使用的是 img 標籤,使用正則表達式提取:
# 圖像文件在 body 中存在的形式,即使是在 .md 文件中,也依然使用這種形式存在RE_IMAGE_OUTERHTML = r'<img .*?src="(index_files/[^"]+)"[^>]*>'6. 上面解析出來的內鏈資源和附件資源,都會在 Joplin 中轉換成同一種形式: [Title](:/GUID),image 資源則會轉換成  形式。
臨時資料庫由於部分的為知筆記資源在 Joplin 中沒有對應的 GUID,必須將這些資源上傳到 Joplin 才能取得 GUID,為了避免整個轉換過程的中斷導致重頭來過(畢竟有 3000 篇),我在轉換過程中建立了一個臨時資料庫,將轉換過程寫入到資料庫中,下次中斷的時候,就可以從資料庫中取得轉換狀態了。
下面是資料庫的定義:
CREATE_SQL: dict[str, str] = { # 保存 Location 和 Folder 的關係 'l2f': """CREATE TABLE l2f ( location TEXT NOT NULL, id TEXT, title TEXT NOT NULL, parent_location TEXT, parent_id TEXT, level INTEGER NOT NULL, PRIMARY KEY (location) );""", # 處理過的文檔會保存在這裡,在這個表中能找到的文檔說明已經轉換成功了 'note': """CREATE TABLE note ( note_id TEXT not NULL, title TEXT not NULL, joplin_folder TEXT NOT NULL, markup_language INTEGER NOT NULL, wiz_location TEXT NOT NULL, PRIMARY KEY (note_id) );""", # 處理過的資源保存在這裡,包括 image 和 attachment 資源 'resource': """CREATE TABLE resource ( resource_id TEXT not NULL, title TEXT NOT NULL, filename TEXT NOT NULL, created_time INTEGER not NULL, resource_type INTEGER NOT NULL, PRIMARY KEY (resource_id) );""", # 保存為知筆記中的內鏈,也就是 resource 與 note 的關係,使用 文檔 guid 和 連接目標 guid 同時作為主鍵。連結目標 guid 為 joplin 格式 'internal_link': """ CREATE TABLE internal_link ( note_id TEXT not NULL, resource_id TEXT not NULL, title TEXT not NULL, link_type TEXT NOT NULL, PRIMARY KEY (note_id, resource_id) ); CREATE INDEX idx_link_type ON internal_link (link_type); CREATE INDEX idx_resource_id ON internal_link (resource_id); """, # 保存為知筆記中的 tag 'tag': """ CREATE TABLE tag ( tag_id TEXT not NULL, title TEXT not NULL, created_time INTEGER not NULL, updated_time INTEGER not NULL, PRIMARY KEY (tag_id) ); CREATE UNIQUE INDEX idx_title ON tag (title); """, # 保存tag 與note 的關係 'note_tag': """CREATE TABLE note_tag ( note_id TEXT not NULL, tag_id TEXT not NULL, title TEXT not NULL, created_time INTEGER not NULL, PRIMARY KEY (note_id, tag_id) );""",}使用 Python 自帶的 sqlite3 來創建臨時資料庫。
上傳到 Jopin1. 同步為知筆記的目錄到 Joplin: w2j.adapter.Adapter.sync_folders 以及 w2j.joplin.JoplinDataAPI.post_folder。
2. 同步為知筆記的附件和內嵌圖像: w2j.adapter.Adapter._upload_wiz_attachment 以及 w2j.adapter.Adapter._upload_wiz_image。
3. 同步筆記正文內容到 Joplin: w2j.adapter.Adapter.sync_all 以及 w2j.adapter.Adapter._sync_note。
為知筆記的文檔有兩種,一種標題以 .md 結尾的,為知筆記會將其作為 Markdown 格式來渲染,另一種不帶 .md 後綴的就作為 HTML 來渲染。
這裡說點題外話:
使用 .md 作為標題後綴,我不知道老魏是處於一個什麼樣的考量,但我肯定這不是一個優雅的解決方案。
儘管 Markdown 是在為知筆記出現之後才流行起來的,儘管為知筆記運行這麼多年可能有一些歷史包袱,但面對一個已經如此流行的技術,採用了這樣一種「近乎於無釐頭」的解決方案,反映出為知筆記團隊「懶於深入思考」的現狀。
在我分析為知筆記本地數據的時候,經常會碰到這種「無釐頭」的折衷方案。例如:
前後不一的筆記文本編碼,之前用 UTF16,後面改為 UTF8.
設計混亂的內鏈方式, wiz:open_attachment 和 wiz://open_attachment。
拼寫錯誤的資料庫列名。
其實只要多花一些思考的時間,這些問題都很容易被優雅地解決。
在同步到 Joplin 的時候,需要區分這兩種情況。為知筆記中保存的 .md 文章是一種很奇怪的格式:既不是純 Markdown,也不是純 HTML,而是使用 HTML 作為排版,包含純 Markdown 內容。
需要調用 HTML 渲染引擎來處理,將其中用于格式分隔(一般是 div/p/br)等等渲染成實際在 HTML 中的表現,但保持 Markdown 源碼不變。
我找到的最好的 Python 渲染引擎 :inscriptis 。
下面的 get_text 方法就是這套渲染引擎中提供的。
def gen_ilstr(is_markdown: bool, jil: JoplinInternalLink) -> str: """ 返回被替換的內鏈 ilstr = internal link str """ if is_markdown: body = f'[{jil.title}](:/{jil.resource_id})' if jil.link_type == 'image': return '!' + body return body if jil.link_type == 'image': return f'<img src=":/{jil.resource_id}" alt="{jil.title}">' return f'<a href=":/{jil.resource_id}">{jil.title}</a>'
def gen_end_ilstr(is_markdown: bool, jils: list[JoplinInternalLink]): """ 返回 body 底部要加入的內容 ilstr = internal link str """ if is_markdown: return '\n\n# 附件連結\n\n' + '\n'.join([ '- ' + gen_ilstr(is_markdown, jil) for jil in jils]) body = ''.join([ f'<li>{gen_ilstr(is_markdown, jil)}</li>' for jil in jils]) return f'<br><br><h1>附件連結</h1><ul>{body}</ul>'
def convert_joplin_body(body: str, is_markdown: bool, internal_links: list[JoplinInternalLink]) -> str: """ 將為知筆記中的 body 轉換成 Joplin 內鏈 """ insert_to_end: list[JoplinInternalLink] = [] for jil in internal_links: if jil.outertext: body = body.replace(jil.outertext, gen_ilstr(is_markdown, jil)) if jil.link_type == 'open_attachment': insert_to_end.append(jil) if is_markdown: body = get_text(body) if insert_to_end: body += gen_end_ilstr(is_markdown, insert_to_end) return body最後,關於同步到 JoplinDataAPI 的正文內容,Joplin 文檔講解得並不詳細。我通過抓包 Joplin WebClipper 得到了隱藏的參數。
在將正文提交到 Joplin 的時候,通過這樣的參數配置,就能讓 Joplin 自動轉換 HTML 到 Markdown。效果還挺不錯的。
下面是更詳細的說明。
def post_note(self, id: str, title: str, body: str, is_markdown: bool, parent_id: str, source_url: str) -> JoplinNote: """ 創建一個新的 Note 隱藏的 Joplin 參數:通過抓包 Joplin WebClipper complete Page Html source_command { 'name': 'completePageHtml', 'preProcessFor': 'html' } convert_to = html
simplified Page Html source_command { 'name': 'simplifiedPageHtml', } convert_to = markdown
complete page source_command = markdown { 'name': 'completePageHtml', 'preProcessFor': 'markdown' } convert_to = markdown """ kwargs = { 'id': id, 'title': title, 'parent_id': parent_id, 'markup_language': 1, } if source_url: kwargs['source_url'] = source_url if is_markdown: kwargs['body'] = body else: kwargs['body_html'] = body kwargs['convert_to'] = 'markdown' kwargs['source_command'] = { 'name': 'simplifiedPageHtml', }
query = self._build_query() logger.info(f'向 Joplin 增加 note {kwargs}') resp = self.client.post('/notes', params=query, json=kwargs) data = resp.json() if data.get('error'): logger.error(data['error']) raise ValueError(data['error']) return JoplinNote(**data)1
全部的重點就在這裡了,希望對你有所幫助。
更多細節在源碼中,歡迎訪問 wiz2joplin 項目以了解更多信息。
設置 Joplin 同步下面兩篇文章詳細介紹了 Joplin 同步配置。有了同步功能,筆記軟體才完整。建議非程式設計師使用騰訊雲 COS 同步的方式,配置簡單,穩定性更有保證。
引用曾老師的 Python 課沒事找曾嶸胡扯一下