Parin's Space
返回文章列表

用 Python 把 Facebook 貼文存進 Obsidian

· 閱讀時間 4 分鐘

看到一篇想存的 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_timemessage.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("&amp;", "&")
    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