昨天講完三層 fallback 怎麼保住 AI 分類這一步,今天往前一步,看抓網頁內文那一關會遇到什麼狀況。Facebook、Instagram、Threads 這種平台,本來就會擋外部爬蟲,Jina Reader 也一樣抓不動。這時候整套流程該怎麼辦?
先看 fetchReadable 這支函式:
async function fetchReadable(url, env) {
const headers = { 'User-Agent': 'line-notion-bot' };
if (env.JINA_API_KEY) {
headers['Authorization'] = `Bearer ${env.JINA_API_KEY}`;
}
const res = await fetch(`https://r.jina.ai/${url}`, { headers });
if (!res.ok) throw new Error(`無法讀取網頁(${res.status})`);
const text = await res.text();
return text.slice(0, MAX_CONTENT_CHARS);
}
碰到會擋爬蟲的網站,Jina Reader 那邊回來的不會是 200,fetchReadable 就直接 throw 出去,不吃這個錯誤。真正決定「要不要讓整個流程死掉」的,是呼叫端 handleTextMessage 裡包的那層 try/catch:
try {
let content = '';
try {
content = await fetchReadable(url, env);
} catch (fetchErr) {
// Facebook / Instagram / Threads 等平台常擋爬蟲,抓不到內文時退化成只憑網址與備註分類,
// 不整段失敗,讓筆記至少能被存下來。
content = '(此網頁無法自動讀取內文,可能為需要登入的社群平台)';
}
const existingTags = await getExistingTags(env);
const result = await classify(env, { url, content, userNote, existingTags });
const saved = await createNotionPage(env, { ...result, url });
...
} catch (err) {
await reply(env, replyToken, `⚠️ 處理失敗:${err.message}\n原始連結已保留:${url}`);
}
注意這裡是兩層 try/catch 疊在一起,不是只有一層。外層那個是整個存筆記流程的最後防線,真的整段掛掉時,至少把原始連結印在回覆裡,讓我自己複製貼上手動處理。內層這個才是今天的重點:專門包住 fetchReadable 這一步,抓不到內文的話,不會讓例外往外拋、觸發外層的「處理失敗」,而是把 content 換成一句固定的說明文字,讓流程繼續往下走。
換句話說,抓不到內文對整條流程來說不是「失敗」,只是「這篇少一項資料」。classify 照樣會拿到 url、這句佔位文字、userNote、還有既有標籤,餵給 AI 去分類。AI 沒有內文可讀,但網址本身(例如網域是 facebook.com)加上我自己順手打的備註,通常還是能猜出個大概分類,標籤跟摘要品質會差一點,但至少這篇筆記進得了 Notion,不會整個消失在對話紀錄裡。
這個取捨背後的邏輯很單純:比起「這篇分類得不夠精準」,「這篇根本沒存到」才是真正的損失。分類下得不準,我還可以事後貼連結回去改分類、加註記;但如果因為抓不到內文就讓整個請求直接失敗,連進 Notion 的機會都沒有,這篇連結八成就這樣被我滑過去、再也找不回來了。所以這裡選的是「先求有、再求好」,把降級的門檻卡在抓內文這一步就好,不要往上蔓延到整個存筆記的流程。
也因為這樣,fetchReadable 本身完全不用處理「抓不到內文怎麼辦」這種邊界情況,它只要老實回報「這次抓失敗了、狀態碼多少」就好,複雜的容錯邏輯留在呼叫端一次處理。這樣分工的好處是 fetchReadable 未來如果要換掉 Jina Reader、改用別的抓取服務,也不用去動任何降級邏輯,兩件事完全切開。
明天要處理另一個常見情境:同一個網址如果被我重複傳進來,line-notion-bot 怎麼判斷該新增一筆還是更新既有那筆,避免 Notion 裡堆出一堆重複資料。