用 Python 把 Facebook 貼文存進 Obsidian
看到一篇想存的 Facebook 貼文,通常的流程是:複製內容、開 Obsidian、建新筆記、貼上、補 frontmatter、找圖片 URL……每次都要花好幾分鐘。
做了一個 Python script 解決這件事。輸入 URL,自動產生格式正確的 Clippings 筆記,包含作者、發文日期、圖片。
為什麼不用 Graph API?
第一個念頭是用官方 API,但查了之後放棄了。
/user/feed 需要 user_posts permission — 2018 年 Cambridge Analytica 事件後,Meta 大幅收緊這個 permission,現在需要通過 App Review 才能申請,且只能讀取自己的貼文,不能讀取其他人的。
沒有「URL → 內容」的 endpoint — Graph API 沒辦法直接輸入一個 post URL 拿到內容,需要知道 post_id,還需要對應 permission。
Access Token 有效期短 — User Access Token 只有 1-2 小時,Long-lived token 最多 60 天,還是需要定期更新。
相比之下,cookies 方案:
- 有效期幾個月
- 不需要 App Review
- 可以讀任何你有權限看到的貼文(public 或朋友貼文)
唯一缺點是 Facebook 改版可能讓 HTML 解析失效,但這個風險目前可以接受。
基本架構
Facebook 沒有公開 API,但 public post 可以用帶 cookies 的 requests 直接 GET。
import requests, http.cookiejar
jar = http.cookiejar.MozillaCookieJar()
jar.load("scripts/www.facebook.com_cookies.txt", ignore_discard=True, ignore_expires=True)
session = requests.Session()
session.cookies = jar
session.headers.update({
"User-Agent": "Mozilla/5.0 (Macintosh; ...) Chrome/124.0.0.0 Safari/537.36",
"Accept-Language": "zh-TW,zh;q=0.9",
})
resp = session.get(url, timeout=20)
cookies 用 Chrome 擴充套件「Get cookies.txt LOCALLY」在 facebook.com 匯出,存成 Netscape 格式。
踩坑一:選到錯誤的貼文
Facebook 頁面的 HTML 有 6MB+,裡面除了目標貼文,還有 feed 裡其他貼文的 JSON。如果直接用 re.search 找第一個 "message":{"text":"..."} 或 "text":"..." 超過 50 字的,很容易選到別人的貼文。
解法是用 og:description 或 <title> 作為 anchor:
# og:description 一定是目標貼文的開頭
og_desc_match = re.search(
r'<meta[^>]+property="og:description"[^>]+content="([^"]+)"', html
)
# og:description 不存在時,從 <title> 取
# 格式:「李思萱 - 【荷蘭觀察】務實的浪漫...」
page_title = re.search(r'<title>([^<]+)</title>', html)
if page_title:
parts = re.split(r'\s*-\s*', page_title.group(1).strip(), maxsplit=1)
title_anchor = parts[1].rstrip("...").strip() if len(parts) > 1 else ""
有了 anchor 之後,只接受包含 anchor 開頭的 message.text:
anchor_start = anchor[:40].lower().strip()
for m in re.finditer(r'"message"\s*:\s*\{"text"\s*:\s*"((?:[^"\\]|\\.)*)"\}', html):
decoded = json.loads(f'"{m.group(1)}"')
if anchor_start in decoded.lower()[:120]:
text = decoded
break
踩坑二:作者抓到登入者
Facebook JSON 裡的 "actor":{"name":"..."} 是登入者,不是貼文作者。
正確做法是從 og:title 或 <title> 取:
# <title> 格式:「Madeleine Cheng - 貼文內容...」
# 只取第一個 " - " 之前,且限制 60 字(人名不會更長)
first_part = re.split(r'\s*[-|]\s*', raw_title, maxsplit=1)[0].strip()
if first_part and len(first_part) <= 60:
author = first_part
踩坑三:發文時間找不到
publish_time 和 message.text 在 HTML 裡可能相距超過 100 萬字。用 URL 定位再往後找 3000 字完全沒用。
Facebook 的 routing config(URL)和實際貼文 JSON 放在 HTML 的不同位置,中間隔了大量其他資料。
解法:用已解析的 text 內容定位,找最近的 <script> 標籤內的時間戳:
def find_time_near(html, anchor_text):
needle = anchor_text[:20]
# 找所有出現位置,優先用 <script> 標籤裡的(JSON 資料)
positions = [m.start() for m in re.finditer(re.escape(needle), html)]
# JSON escaped 版本
needle_esc = json.dumps(needle)[1:-1]
positions += [m.start() for m in re.finditer(re.escape(needle_esc), html)]
script_positions = [
p for p in positions
if html[max(0,p-200):p].rfind('<script') > html[max(0,p-200):p].rfind('</script')
]
search_positions = script_positions or positions
for pos in sorted(search_positions):
# 往後找 100,000 字內的 creation_time
chunk = html[pos: pos + 100000]
for pattern in [r'"creation_time"\s*:\s*(\d{10})', r'\\"creation_time\\":\s*(\d{10})']:
m = re.search(pattern, chunk)
if m:
ts = int(m.group(1))
if 1262304000 < ts < 1893456000: # 2010~2030
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
踩坑四:圖片抓到頭像
直接掃 HTML 裡所有 fbcdn.net URL 會抓到幾十張圖,大部分是頭像、貼文縮圖、廣告圖。
貼文附加圖片的特徵是在 <link rel="preload"> 標籤裡,且 data-preloader 包含 CometSinglePost:
<link rel="preload"
href="https://scontent-tpe1-1.xx.fbcdn.net/v/t39.30808-6/680126602_..."
as="image"
data-preloader="adp_CometSinglePostDialogContentQueryRelayPreloader_{N}" />
用 lookahead 確保同一個 tag 裡有 CometSinglePost,並過濾 t39.30808-1(profile picture 的路徑格式):
for m in re.finditer(
r'<link\b(?=[^>]*data-preloader="[^"]*CometSinglePost[^"]*")[^>]*\bhref="([^"]+)"[^>]*/?>',
html,
):
raw_url = m.group(1).replace("&", "&")
if "fbcdn.net" in raw_url and "t39.30808-1" not in raw_url:
images.append(raw_url)
Frontmatter 格式
用 yaml.dump 搭配自訂 representer 確保字串用雙引號,跟現有 Clippings 格式一致:
import yaml
class QuotedStr(str): pass
def quoted_representer(dumper, data):
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style='"')
yaml.add_representer(QuotedStr, quoted_representer)
front = {
"title": QuotedStr(f"{author} on Facebook ({published})"),
"source": QuotedStr(post_url),
"author": [QuotedStr(f"[{author}]({author_url})")],
"published": published,
"created": created,
"description": QuotedStr(description),
"tags": [QuotedStr("clippings"), QuotedStr("facebook")],
}
輸出:
---
title: "Madeleine Cheng on Facebook (2026-04-27)"
source: "https://www.facebook.com/madeleine.cheng/posts/..."
author:
- "[Madeleine Cheng](https://www.facebook.com/madeleine.cheng)"
published: '2026-04-27'
created: '2026-04-28'
description: "美國的「愛的教育」是騙人的 主題有點歪掉..."
tags:
- "clippings"
- "facebook"
---
Obsidian Templater 整合
做成 Templater template,在 Obsidian 裡直接觸發,不用開 terminal:
<%*
const { execFile } = require("child_process");
const path = require("path");
const vaultPath = app.vault.adapter.basePath;
const scriptPath = path.join(vaultPath, "scripts", "facebook-to-clippings.py");
// python3 候選路徑(依序嘗試)
// 如果用 pyenv / asdf 管理 python,把實際 binary 路徑加在最前面
const pythonCandidates = [
"/usr/local/bin/python3",
"/opt/homebrew/bin/python3",
"/usr/bin/python3",
];
const url = await tp.system.prompt("貼上 Facebook 貼文 URL");
if (!url?.includes("facebook.com")) return;
new Notice("⏳ 抓取中...");
function tryPython(candidates, idx, callback) {
if (idx >= candidates.length) {
callback(new Error("找不到 python3"), null, null);
return;
}
execFile(candidates[idx], [scriptPath, url], { timeout: 60000, cwd: vaultPath, encoding: "utf8" },
(err, stdout, stderr) => {
if (err && err.code === "ENOENT") tryPython(candidates, idx + 1, callback);
else callback(err, stdout, stderr, candidates[idx]);
}
);
}
tryPython(pythonCandidates, 0, (err, stdout) => {
const match = stdout?.match(/✅ 已儲存: (.+)/);
if (match) {
new Notice(`✅ ${match[1].trim()}`);
app.workspace.openLinkText(match[1].trim().replace(/^Clippings\//, "").replace(/\.md$/, ""), "Clippings/", true);
} else {
new Notice(`❌ ${(err?.message || "").slice(0, 150)}`);
}
});
%>
使用
python3 scripts/facebook-to-clippings.py "https://www.facebook.com/..."
或在 Obsidian 用 Templater 觸發 facebook-clipping template。
完整程式碼: https://gist.github.com/ParinLL/e5f7869a9abf80c6e79313c78ddd5b93#file-facebook-to-clippings-py
Template: https://gist.github.com/ParinLL/f3b281868b601c9cd3f59a1d167497cf#file-template-facebook-clipping-md