4.6 KB · 纯文本展示
#!/usr/bin/env python3
"""把 Chrome 默认 Profile 的书签摊平并入 Safari Bookmarks.plist(Safari 必须先退出)。
- 删除标题以「Chrome 书签」开头的残留文件夹(递归,整棵树)
- Chrome roots.bookmark_bar 的每一项直接成为 Safari BookmarksBar 的子项(文件夹仍是同级文件夹)
- Chrome roots.other 同样摊进 BookmarksMenu
- 按 URLString 去重
"""
import json
import os
import plistlib
import shutil
import sys
import time
import uuid
SAFARI_PLIST = os.path.expanduser("~/Library/Safari/Bookmarks.plist")
CHROME_BOOKMARKS = os.path.expanduser(
"~/Library/Application Support/Google/Chrome/Default/Bookmarks"
)
LEGACY_PREFIX = "Chrome 书签"
def new_uuid():
return str(uuid.uuid4()).upper()
def load_chrome():
with open(CHROME_BOOKMARKS, encoding="utf-8") as fh:
return json.load(fh)
def make_leaf(node):
return {
"URIDictionary": {"title": node.get("name") or node.get("url")},
"URLString": node["url"],
"WebBookmarkType": "WebBookmarkTypeLeaf",
"WebBookmarkUUID": new_uuid(),
}
def convert(node, seen, stats):
"""把 Chrome 节点转成 Safari 节点;url 已存在则返回 None。"""
if node.get("type") == "url":
url = node.get("url")
if not url or url in seen:
stats["skipped"] += 1
return None
seen.add(url)
stats["added"] += 1
return make_leaf(node)
children = []
for child in node.get("children") or []:
converted = convert(child, seen, stats)
if converted is not None:
children.append(converted)
if not children:
return None
return {
"Title": node.get("name") or "未命名",
"WebBookmarkType": "WebBookmarkTypeList",
"WebBookmarkUUID": new_uuid(),
"Children": children,
}
def collect_urls(node, acc):
if node.get("WebBookmarkType") == "WebBookmarkTypeLeaf":
url = node.get("URLString")
if url:
acc.add(url)
for child in node.get("Children") or []:
collect_urls(child, acc)
return acc
def strip_legacy(node, removed):
"""递归删除标题以「Chrome 书签」开头的列表,返回保留下来的 children。"""
kept = []
for child in node.get("Children") or []:
if (
child.get("WebBookmarkType") == "WebBookmarkTypeList"
and str(child.get("Title") or "").startswith(LEGACY_PREFIX)
):
removed.append(child.get("Title"))
continue
strip_legacy(child, removed)
kept.append(child)
if "Children" in node or kept:
node["Children"] = kept
return removed
def find_root(bookmarks, server_id):
for child in bookmarks.get("Children") or []:
sync = child.get("Sync") or {}
if sync.get("ServerID") == server_id:
return child
for child in bookmarks.get("Children") or []:
if str(child.get("Title") or "") == server_id:
return child
return None
def main():
if "Safari" in os.popen("pgrep -x Safari").read():
raise SystemExit("Safari 仍在运行,先退出再改 plist")
stats = {"added": 0, "skipped": 0}
removed = []
with open(SAFARI_PLIST, "rb") as fh:
bookmarks = plistlib.load(fh)
strip_legacy(bookmarks, removed)
bar = find_root(bookmarks, "Favorites Bar")
menu = find_root(bookmarks, "Bookmarks Menu")
if bar is None or menu is None:
raise SystemExit("找不到 BookmarksBar / BookmarksMenu 节点")
seen = collect_urls(bookmarks, set())
chrome = load_chrome()
roots = chrome.get("roots", {})
for key, target in (("bookmark_bar", bar), ("other", menu)):
root = roots.get(key) or {}
converted = []
for child in root.get("children") or []:
item = convert(child, seen, stats)
if item is not None:
converted.append(item)
target.setdefault("Children", [])
target["Children"].extend(converted)
tmp = SAFARI_PLIST + ".tmp-flat"
with open(tmp, "wb") as fh:
plistlib.dump(bookmarks, fh, fmt=plistlib.FMT_BINARY)
os.chmod(tmp, 0o644)
os.replace(tmp, SAFARI_PLIST)
print(
json.dumps(
{
"added": stats["added"],
"skipped_duplicate": stats["skipped"],
"removed_legacy_folders": removed,
"bar_children": len(bar.get("Children") or []),
"menu_children": len(menu.get("Children") or []),
"written_at": time.strftime("%Y-%m-%d %H:%M:%S"),
},
ensure_ascii=False,
)
)
if __name__ == "__main__":
sys.exit(main())