第 3 課 · 讓節點動手抓料——多來源工具與真實情報輸入
第 2 課,你的 graph 已經會跑了:START → 抓取 → 摘要 → END,state 在節點之間流動,LLM 節點被切在 seam 後面、測試時用 stub 假模型頂替。很漂亮。
但它有個不能上線的秘密:那個「抓取」節點吃的是你手貼進去的一段假料。 它根本沒去過網路。
真正的競品情報週報,第一件事就是伸手到真實世界——去抓 Acme Planner 的部落格 RSS、去讀它的 changelog、去看它的 pricing 頁,把「這週對手實際發生了什麼」抓回來。這一課,我們讓節點動手。
具體要回答四個問題,答完你就有一個「會自己去抓真料、抓不到也不會崩」的抓取節點:
- 在 LangGraph.js 的節點裡,怎麼呼叫「工具」去抓外部真實資料(RSS/changelog/pricing 頁),再把抓到的東西寫回 state?
- 怎麼把「抓取」設計成純邏輯、可離線測,並和「LLM 摘要」分離?
- 某個來源逾時或沒回應時,節點怎麼回報失敗、而不是讓整張 graph 崩掉?
- 多來源的原始料進到 state 後,該長什麼結構,後續(摘要、跨週比對)才好用?
💡 這一課的邊界(免得你等它教):去重(dedup) 和多競品平行是第 4 課;只報 diff 的跨週比對是第 7 課。這一課只做一件事——把「單一競品」的「多個來源」原料,乾淨地抓進 state。不跨週、不比對、不去重。把地基打平,後面才蓋得穩。
專案沿用第 2 課的設定(ESM、TypeScript、tsx、@langchain/langgraph)。這一課只多一個依賴:
npm i fast-xml-parser
一、先破一個誤會:workflow 裡的「工具」沒那麼神
你八成在 OpenAI 或 Anthropic 的 SDK 裡見過 tool calling / function calling:你把幾個函式的 schema 餵給模型,然後模型自己決定要不要呼叫、呼叫哪一個。這是 agent 的語意——大腦(LLM)臨場挑工具。如果你讀過 fe-admin-ai-chat,那正是它的核心:瀏覽器裡一個 ReAct loop,由 LLM 一輪一輪決定「下一步呼叫哪個工具」。
我們這裡不是那樣。 回指 ai-you-should-know 第 5 章:我們的產品骨子裡是 workflow,不是 agent——步驟大致固定(抓取 → 去重 → 摘要 → 比對 → 送出)。抓料這一步尤其固定:每個競品要追哪幾個來源,是使用者在 control panel 上設好的,不是 LLM 臨場猜的。
所以,把這句話刻在心裡,它會讓這一課的程式碼變得非常樸素:
在 workflow 裡,「呼叫工具」不是魔法——就是節點呼叫一個你自己寫的函式。要不要呼叫、呼叫哪個,是「你」(固定的步驟)決定的,不是 LLM。
一個「工具」,在我們的語境裡就是一句話:一個伸手到外部世界、把某個來源抓回來的函式。 沒有 schema、不用餵給模型、不用等模型點名。
這不只是「比較簡單」而已,它換來三個實打實的好處,而且每一個都對得上這門課後面的柱子:
- 可預測的成本:抓取完全不花 LLM 的錢。讓 LLM 去決定抓哪裡,等於每跑一次多燒幾輪 token;而 workflow 的固定抓取,成本是零、而且可預測(第 8 課算單位經濟時你會感謝現在這個決定)。
- 可離線測:函式的輸入輸出是死的,給定 input 就有確定 output——這正是我們接下來要大做文章的「純邏輯」。
- 可重現:這週抓 Acme 的三個來源,跟上週抓的是同三個來源。跨週要比對(第 7 課),前提就是每次抓的東西是同一組、可對齊的。
⚠️ 邊界講清楚:不是說 agent 式的工具呼叫一無是處。如果哪天你要做「使用者用自然語言問『幫我查一下這家新競品的融資狀況』」這種開放式、臨場探索的功能,讓 LLM 挑工具就是對的——那又回到 fe-admin-ai-chat 那套。只是我們產品的抓料步驟是固定的,用固定步驟就對了:能寫死的東西,別花錢讓模型猜。
二、先設計「料進 state 該長什麼樣」——資料形狀先行
在寫任何抓取程式之前,我們先決定資料的形狀。這不是拖延,是這一課最值錢的一段。
原因是:我們有三種長得完全不一樣的來源——RSS 是 XML、changelog 是 HTML、pricing 是另一種 HTML。如果讓它們各自用各自的形狀進 state,那麼下游的每一個節點(摘要、第 4 課的去重、第 7 課的比對)都得認得三種形狀、寫三套處理。這會像滾雪球一樣髒下去。
正確的做法:在抓取的當下,就把三種異質來源,全部正規化成同一種「原料筆」。 髒活只發生在一個地方(抓取節點內部),出了這道門,下游看到的永遠是乾淨、統一的形狀。
RawItem:一筆正規化原料
// src/state.ts
export type SourceType = "rss" | "changelog" | "pricing";
// 一筆正規化後的原料——不管來自哪種來源,都長這樣
export type RawItem = {
competitorId: string; // 屬於哪個競品("acme")
sourceType: SourceType; // 來自哪種來源
sourceUrl: string; // 來源網址(週報要標「來源:changelog」)
externalId: string; // 「跨週穩定」的識別碼——見下方說明
title: string; // 標題
body: string; // 內文純文字
publishedAt: string | null; // ISO 8601;狀態型來源(pricing)沒有發布時間 → null
fetchedAt: string; // 我們「什麼時候抓到的」(ISO 8601)
};
每一個欄位都不是隨手加的,它們各自在為後面的某一課服務。花三十秒看懂這張表,你就懂了整個產品的資料骨架:
| 欄位 | 現在(第 3 課)為什麼要 | 後面哪一課會靠它 |
|---|---|---|
externalId |
一筆料的穩定身分證 | 第 4 課去重靠它認出「同一則」;第 7 課跨週比對靠它把「這週的 Pro 方案」對上「上週的 Pro 方案」 |
publishedAt |
事件型來源有發布時間;狀態型(定價)沒有,設 null |
摘要排序、第 7 課判斷「這是不是新的一則」 |
fetchedAt |
我們抓到的時間戳 | 第 7 課判斷資料新鮮度(這份 pricing 是這週抓的還是上上週的殘留) |
sourceType / sourceUrl |
出處,週報要標「來源:他們的 changelog」 | 第 5 課串進 control panel 顯示、可點回原始出處 |
externalId 特別值得多講一句,因為它是跨週魔法的地基。回想第 1 課週報那句「上週還是 $12」——要說得出這句話,第 7 課得能把這週抓到的「Acme 的 Pro 方案」跟上週存下的「Acme 的 Pro 方案」認成同一個東西,才比得出「12 → 15」。靠什麼認?就靠一個穩定、跨週不變的 externalId(例如 plan:Pro)。所以我們在抓取的當下就決定好每一種來源的穩定 id,這一步偷懶,第 7 課就變不出戲法。
SourceOutcome:每個來源這次抓得怎樣
光有原料還不夠。我們還要記一件同樣重要的事:每個來源這次到底抓成功了沒。
為什麼這是情報而不只是 log?因為對使用者來說,「Acme 的定價頁這週抓不到」本身就是一則值得知道的事——可能對方改版了、可能擋爬蟲了。一個把失敗默默吞掉、假裝沒事的產品,比會老實說「這塊我這週沒看到」的產品,更不可信。
// src/state.ts(續)
// 一個來源這一次抓取的「成績單」
export type SourceOutcome = {
sourceType: SourceType;
sourceUrl: string;
status: "ok" | "empty" | "timeout" | "error"; // 四種結局
itemCount: number; // 抓到幾筆
error?: string; // 失敗時的原因(給人看)
fetchedAt: string;
};
四種 status 涵蓋了真實世界會發生的事:ok(抓到料)、empty(連上了但沒東西,例如空的 changelog)、timeout(逾時沒回應)、error(HTTP 4xx/5xx 或解析爆掉)。注意 empty 和 timeout 是分開的——「對方頁面真的沒更新」和「我們根本沒連上」是兩回事,混在一起你會做出錯誤判斷。
把兩者掛進 state
// src/state.ts(續)
import { Annotation } from "@langchain/langgraph";
export type SourceSpec = { type: SourceType; url: string };
export type Competitor = { id: string; name: string; sources: SourceSpec[] };
// 「附加」而非「覆蓋」的 reducer
function appendReducer<T>(current: T[], incoming: T[]): T[] {
return current.concat(incoming);
}
export const DigestState = Annotation.Root({
competitor: Annotation<Competitor>(), // 輸入:要追的競品(含來源清單)
rawItems: Annotation<RawItem[]>({ // 產出:所有來源的正規化原料
reducer: appendReducer,
default: () => [],
}),
fetchLog: Annotation<SourceOutcome[]>({ // 產出:每個來源的成績單
reducer: appendReducer,
default: () => [],
}),
summary: Annotation<string>(), // 第 2 課的摘要節點會寫這裡
});
rawItems 和 fetchLog 用的是 appendReducer(附加),不是預設的「覆蓋」。意思是:節點回傳 { rawItems: [...] } 時,LangGraph.js 會把它接在現有陣列後面,而不是整個換掉。
你可能想:現在只有一個節點在寫,附加跟覆蓋看起來沒差啊?沒錯,這是為第 4 課鋪路——那時候會有好幾個競品平行寫進同一個 rawItems,若是「覆蓋」語意,後寫的就會把先寫的蓋掉。現在先用附加,到第 4 課就免改。這是那種「現在多寫一行、以後省一次重構」的划算決定。
💡 這一節其實就答完了問題 4。先把料的形狀釘死,抓取程式才知道自己要生出什麼。 資料形狀是骨、抓取邏輯是肉——先有骨。
三、把「抓取」切成純邏輯:parser(text → items)
現在寫抓取。這裡有個關鍵的工程動作,是這門課的招牌:把「抓取」再切成兩半——會碰網路的、和不碰網路的。
- 會碰網路的(去 HTTP 抓那串文字回來):這是 I/O,有副作用、會逾時、測試時很煩。
- 不碰網路的(拿到那串文字,把它解析、正規化成
RawItem[]):這是純邏輯——給定同一串文字,永遠吐出同一組RawItem。不需要網路、不需要金鑰、可以離線測到爽。
這一節先做純邏輯那半:parser。一個 parser 的簽章長這樣——吃文字,吐 RawItem[],不碰網路、連時鐘都不碰(時間從外面注入):
// src/sources/parsers.ts
import { XMLParser } from "fast-xml-parser";
import type { RawItem, SourceSpec, SourceType } from "../state.js";
// 解析時需要的「外部事實」,全部從參數注入 → 純函式、測試可預測
export type ParseCtx = { competitorId: string; fetchedAt: string };
// parser 的合約:文字進、RawItem[] 出
export type Parser = (text: string, spec: SourceSpec, ctx: ParseCtx) => RawItem[];
// —— 幾個共用小工具 ——
function attr(attrs: string, name: string): string {
const m = new RegExp(`\\b${name}="([^"]*)"`).exec(attrs);
return m?.[1]?.trim() ?? "";
}
function firstGroup(s: string, re: RegExp): string {
return re.exec(s)?.[1] ?? "";
}
function stripTags(s: string): string {
return s.replace(/<[^>]*>/g, "").trim();
}
function toISO(dateStr: string | null): string | null {
if (!dateStr) return null;
const d = new Date(dateStr);
return isNaN(d.getTime()) ? null : d.toISOString();
}
注意 ParseCtx 把 fetchedAt(時鐘)當參數傳進來。為什麼不在函式裡直接 new Date()? 因為那樣函式就不純了——同一串輸入,跑兩次會得到不同的 fetchedAt,測試就得跟時間賽跑。把時鐘注入進來,測試就能塞一個固定時間,斷言得乾乾淨淨。這跟第 2 課「把 model 注入、測試塞 stub」是同一個念頭:凡是會讓函式變得不確定的東西(網路、時鐘、隨機),一律從外面注入。
三個 parser
RSS 是 XML,用 fast-xml-parser 解:
// src/sources/parsers.ts(續)
const xml = new XMLParser();
export const parseRss: Parser = (text, spec, ctx) => {
const doc = xml.parse(text);
const rawItems = doc?.rss?.channel?.item;
const list = Array.isArray(rawItems) ? rawItems : rawItems ? [rawItems] : [];
return list.map((it: any): RawItem => {
const title = String(it.title ?? "").trim();
const link = String(it.link ?? "").trim();
const guid = it.guid ? String(typeof it.guid === "object" ? it.guid["#text"] : it.guid) : "";
return {
competitorId: ctx.competitorId,
sourceType: "rss",
sourceUrl: spec.url,
externalId: guid || link || title, // 穩定 id:優先 guid,退而求其次用 link
title,
body: stripTags(String(it.description ?? "")),
publishedAt: toISO(it.pubDate ? String(it.pubDate) : null),
fetchedAt: ctx.fetchedAt,
};
});
};
changelog 是 HTML。真實 changelog 每家長得不一樣,這裡假設一種常見版型(每則是一個帶 data-date 的 <article class="entry">):
// src/sources/parsers.ts(續)
export const parseChangelog: Parser = (text, spec, ctx) => {
const items: RawItem[] = [];
const blockRe = /<article\b([^>]*)>([\s\S]*?)<\/article>/g;
let m: RegExpExecArray | null;
while ((m = blockRe.exec(text)) !== null) {
const attrs = m[1] ?? "";
const inner = m[2] ?? "";
if (!/class="entry"/.test(attrs)) continue;
const date = attr(attrs, "data-date");
const title = stripTags(firstGroup(inner, /<h3\b[^>]*>([\s\S]*?)<\/h3>/));
const body = stripTags(firstGroup(inner, /<p\b[^>]*>([\s\S]*?)<\/p>/));
items.push({
competitorId: ctx.competitorId,
sourceType: "changelog",
sourceUrl: spec.url,
externalId: `${date}|${title}`, // 日期+標題當穩定 id
title,
body,
publishedAt: toISO(date || null),
fetchedAt: ctx.fetchedAt,
});
}
return items;
};
pricing 也是 HTML,但它是狀態型(相對於 RSS/changelog 的事件型)——它不是「一串事件」,而是「此刻每個方案多少錢」的當下狀態。所以它一筆 = 一個方案,而且 publishedAt 是 null(定價沒有「發布時間」):
// src/sources/parsers.ts(續)
export const parsePricing: Parser = (text, spec, ctx) => {
const items: RawItem[] = [];
const tagRe = /<div\b([^>]*class="plan"[^>]*)>/g;
let m: RegExpExecArray | null;
while ((m = tagRe.exec(text)) !== null) {
const attrs = m[1] ?? "";
const plan = attr(attrs, "data-plan");
const price = attr(attrs, "data-price-usd");
if (!plan || !price) continue;
const period = attr(attrs, "data-period");
items.push({
competitorId: ctx.competitorId,
sourceType: "pricing",
sourceUrl: spec.url,
externalId: `plan:${plan}`, // 方案名當穩定 id → 第 7 課跨週比價靠它
title: `${plan} 方案`,
body: `US$${price}${period ? ` / ${period}` : ""}`,
publishedAt: null, // 狀態型,沒有發布時間
fetchedAt: ctx.fetchedAt,
});
}
return items;
};
// 一張「來源型別 → parser」的登記表,加新來源就加一行
export const PARSERS: Record<SourceType, Parser> = {
rss: parseRss,
changelog: parseChangelog,
pricing: parsePricing,
};
⚠️ 這三個 parser 是「示範等級」,不是「生產等級」,我得誠實講:真實的 RSS 有 Atom 變體(用 <entry> 不是 <item>、用 <updated> 不是 <pubDate>),生產上你會換成 rss-parser 這類專門函式庫;真實 changelog 版型五花八門,用正則解 HTML 出了名的脆;而pricing 頁是最難的——很多定價數字是 JS 算出來、或畫在圖片裡,這時你可能得動用無頭瀏覽器(Playwright)、甚至一個會讀圖的視覺模型。但請注意:一旦你用 LLM 去讀 pricing 頁,那一步就從「純邏輯」跨進了「LLM 呼叫」——它要花錢、要被算進單位經濟(第 8 課),而且它應該待在 seam 的另一側。 這一課我們把它保持在純邏輯側,讓地基乾淨。
這裡有個重點你可能沒察覺:HTML 解析很脆這件事,恰恰是我們「正規化先行」的最好理由。 對方改版害我的 changelog 正則失效時,壞掉的只有 parseChangelog 一個函式——它吐 empty 或錯的料,但下游完全不受影響,因為它們只認 RawItem。脆弱被關在一個小盒子裡,這就是把髒活集中在一處的回報。
純邏輯就該離線測
parser 是純函式,測試不需要網路、不需要金鑰、不需要跑 graph。用 Node 內建的測試器(node --test)就好:
// src/sources/parsers.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseRss, parsePricing } from "./parsers.js";
const ctx = { competitorId: "acme", fetchedAt: "2026-07-10T00:00:00.000Z" };
test("parseRss 取出 title/link/pubDate,並正規化時間", () => {
const feed = `<?xml version="1.0"?><rss><channel>
<item>
<title>We now support SSO</title>
<link>https://blog.acme.example/sso</link>
<pubDate>Mon, 06 Jul 2026 08:00:00 GMT</pubDate>
<description>Enterprise SSO is live.</description>
</item>
</channel></rss>`;
const items = parseRss(feed, { type: "rss", url: "https://blog.acme.example/feed.xml" }, ctx);
assert.equal(items.length, 1);
assert.equal(items[0].title, "We now support SSO");
assert.equal(items[0].externalId, "https://blog.acme.example/sso"); // 沒 guid → 退用 link
assert.equal(items[0].publishedAt, "2026-07-06T08:00:00.000Z");
assert.equal(items[0].fetchedAt, ctx.fetchedAt); // 時鐘是注入的,可預測
});
test("parsePricing 把每個方案變成一筆當下狀態(publishedAt 為 null)", () => {
const html = `<div class="plan" data-plan="Pro" data-price-usd="15" data-period="seat/mo"></div>`;
const items = parsePricing(html, { type: "pricing", url: "https://acme.example/pricing" }, ctx);
assert.equal(items.length, 1);
assert.equal(items[0].externalId, "plan:Pro"); // ← 第 7 課跨週比價就靠這個 id
assert.equal(items[0].body, "US$15 / seat/mo");
assert.equal(items[0].publishedAt, null); // 定價是當下狀態,沒有發布時間
});
跑(沿用第 2 課的 tsx):
node --import tsx --test src/sources/parsers.test.ts
沒有金鑰、沒有網路、毫秒級跑完。這就是「純邏輯」的紅利:解析的正確性,你可以測到有信心,而且它永遠不會因為別人的伺服器抽風而變紅。
四、把「網路」也用同一招切出去:注入一個 fetcher
parser 那半搞定了。現在處理會碰網路的那半——而且我們要用跟第 2 課一模一樣的手法對付它。
第 2 課你學過 seam:純邏輯和 LLM 呼叫之間的那條接縫,測試時用 stub 假模型頂替 LLM。seam 這個詞我們留給 LLM 邊界(沿用第 2 課的定義)。但**「把不確定的依賴注入進來、測試時換成假的」這個手法,不專屬於 LLM**——網路 I/O 一樣可以這樣切。
我們定義一個 Fetcher 型別:把「怎麼打 HTTP」抽象成一個可以被替換的函式。 生產環境傳真的、測試傳假的。
// src/sources/fetchOneSource.ts
import { PARSERS } from "./parsers.js";
import type { RawItem, SourceOutcome, SourceSpec } from "../state.js";
export type FetchResponse = { ok: boolean; status: number; text: string };
// 「怎麼打 HTTP」被抽成一個可替換的函式:真打 / 假打都符合這個型別
export type Fetcher = (url: string, opts: { timeoutMs: number }) => Promise<FetchResponse>;
export type FetchDeps = {
fetcher: Fetcher;
now: () => string; // 注入的時鐘(ISO 字串)
timeoutMs: number;
competitorId: string;
};
真的 fetcher ——用 Node 內建的 fetch 加 AbortSignal.timeout() 做逾時,零依賴:
// src/sources/fetchOneSource.ts(續)
// 真打網路的 fetcher(示範用;測試不會用到它)
export async function httpFetcher(url: string, opts: { timeoutMs: number }): Promise<FetchResponse> {
const res = await fetch(url, {
signal: AbortSignal.timeout(opts.timeoutMs), // 逾時自動 abort,丟出 TimeoutError
headers: { "user-agent": "ci-digest/1.0 (+https://your-product.example)" },
redirect: "follow",
});
const text = await res.text();
return { ok: res.ok, status: res.status, text };
}
現在寫抓單一來源的協調函式 fetchOneSource。它把「打網路(可能失敗)」和「解析(純邏輯)」串起來,而且——這是問題 3 的核心——它是一個永遠不丟例外的函式:不管網路怎麼爆,它都把結局包成一張 SourceOutcome 成績單回傳,絕不往外拋。
// src/sources/fetchOneSource.ts(續)
export async function fetchOneSource(
spec: SourceSpec,
deps: FetchDeps,
): Promise<{ outcome: SourceOutcome; items: RawItem[] }> {
const fetchedAt = deps.now();
const base = { sourceType: spec.type, sourceUrl: spec.url, fetchedAt };
try {
const res = await deps.fetcher(spec.url, { timeoutMs: deps.timeoutMs });
// 連上了,但對方回非 2xx → error(把狀態碼一起記下來給人看)
if (!res.ok) {
return { outcome: { ...base, status: "error", itemCount: 0, error: `HTTP ${res.status}` }, items: [] };
}
// 解析(純邏輯那半)
const items = PARSERS[spec.type](res.text, spec, { competitorId: deps.competitorId, fetchedAt });
// 連上了、也解析了,但沒東西 → empty(跟 timeout 是兩回事!)
if (items.length === 0) {
return { outcome: { ...base, status: "empty", itemCount: 0 }, items: [] };
}
return { outcome: { ...base, status: "ok", itemCount: items.length }, items };
} catch (err) {
// 逾時(AbortSignal.timeout)跟其他錯,分開標
const name = (err as { name?: string })?.name;
const isTimeout = name === "TimeoutError" || name === "AbortError";
const message = err instanceof Error ? err.message : String(err);
return {
outcome: { ...base, status: isTimeout ? "timeout" : "error", itemCount: 0, error: message },
items: [],
};
}
}
看懂 try/catch 那段,你就懂了「失敗不炸 graph」的第一層機制:失敗在這裡被「接住」、翻譯成 state 裡的一筆資料,而不是一個往上炸的例外。 timeout、error、empty——全都是正常的回傳值。這個函式沒有「失敗路徑」,只有「不同的結局」。
用假 fetcher 離線測所有壞掉的情況
因為 Fetcher 是注入的,我們可以在測試裡塞一個假 fetcher,精準模擬「逾時」「HTTP 500」「空頁」「正常」——完全不碰真網路:
// src/sources/fetchOneSource.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { fetchOneSource, type Fetcher, type FetchResponse } from "./fetchOneSource.js";
// 假 fetcher:用一張「網址 → 假回應」的表模擬各種結局
type Route = FetchResponse | { fail: "timeout" } | { fail: "http"; status: number };
function fakeFetcher(routes: Record<string, Route>): Fetcher {
return async (url) => {
const r = routes[url];
if (!r) return { ok: false, status: 404, text: "" };
if ("fail" in r) {
if (r.fail === "timeout") throw Object.assign(new Error("simulated timeout"), { name: "TimeoutError" });
return { ok: false, status: r.status, text: "" };
}
return r;
};
}
const deps = (fetcher: Fetcher) => ({
fetcher,
now: () => "2026-07-10T00:00:00.000Z",
timeoutMs: 1000,
competitorId: "acme",
});
const pricingSpec = { type: "pricing" as const, url: "https://acme.example/pricing" };
test("逾時 → status timeout,且不丟例外", async () => {
const f = fakeFetcher({ [pricingSpec.url]: { fail: "timeout" } });
const { outcome, items } = await fetchOneSource(pricingSpec, deps(f));
assert.equal(outcome.status, "timeout");
assert.equal(items.length, 0);
});
test("HTTP 500 → status error,並記下狀態碼", async () => {
const f = fakeFetcher({ [pricingSpec.url]: { fail: "http", status: 500 } });
const { outcome } = await fetchOneSource(pricingSpec, deps(f));
assert.equal(outcome.status, "error");
assert.equal(outcome.error, "HTTP 500");
});
test("200 但頁面沒方案 → status empty(不是 error)", async () => {
const f = fakeFetcher({ [pricingSpec.url]: { ok: true, status: 200, text: "<div>coming soon</div>" } });
const { outcome } = await fetchOneSource(pricingSpec, deps(f));
assert.equal(outcome.status, "empty");
});
test("200 有料 → status ok,且解析出正規化 items", async () => {
const html = `<div class="plan" data-plan="Pro" data-price-usd="15" data-period="seat/mo"></div>`;
const f = fakeFetcher({ [pricingSpec.url]: { ok: true, status: 200, text: html } });
const { outcome, items } = await fetchOneSource(pricingSpec, deps(f));
assert.equal(outcome.status, "ok");
assert.equal(outcome.itemCount, 1);
assert.equal(items[0].externalId, "plan:Pro");
});
node --import tsx --test src/sources/fetchOneSource.test.ts
四種真實世界會發生的壞情況,全部離線、毫秒級、可重複地測到了。你的 CI 從此不需要真網路,也就不會因為別人的伺服器抽風而無故變紅。 這就是把網路切出去的回報。
把這一課到目前為止的兩條接縫畫出來——網路那條(fetcher 注入)和 LLM 那條(seam),中間夾著純邏輯的 parser:
flowchart LR
subgraph SRC["三個真實來源"]
RSS["RSS 部落格"]
CL["changelog 頁"]
PR["pricing 頁"]
end
subgraph NODE["fetchSources 節點內部"]
FE["fetcher(碰網路,注入 → 可換假的)"]
PA["parser(純邏輯,可離線測)"]
end
RSS --> FE
CL --> FE
PR --> FE
FE --> PA
PA --> RI["state.rawItems(正規化原料)"]
PA --> FL["state.fetchLog(每來源成績單)"]
RI --> SUM["summarize 節點(LLM,seam 另一側)"]
五、把工具裝進節點:抓多來源,一個都別讓它拖垮全場
現在把 fetchOneSource 這個「工具」裝進一個真正的 LangGraph.js 節點。這一步要回答問題 1(怎麼在節點裡呼叫工具、寫回 state)和問題 3 的第二層(多個來源時,一個掛了怎麼不牽連其他)。
先講一個設計選擇,免得你困惑:我把「單一競品的多個來源」收在「一個節點」裡抓,而不是「每個來源做一個節點」。 為什麼?因為「每個來源一個節點」會把我們帶進 graph 結構與平行分支的討論——那是第 4 課(多競品 fan-out)的事。這一課聚焦「工具怎麼抓、料怎麼進 state」,所以把多來源收在單一節點內,用最樸素的 JS 並發處理就好。
關鍵在怎麼並發。我們用 Promise.allSettled(不是 Promise.all):
// src/nodes/fetchSources.ts
import { fetchOneSource, httpFetcher, type Fetcher } from "../sources/fetchOneSource.js";
import { DigestState } from "../state.js";
import type { RawItem, SourceOutcome } from "../state.js";
// 工廠函式:預設真打網路;測試時可注入假 fetcher / 假時鐘
export function makeFetchSourcesNode(deps?: {
fetcher?: Fetcher;
now?: () => string;
timeoutMs?: number;
}) {
const fetcher = deps?.fetcher ?? httpFetcher;
const now = deps?.now ?? (() => new Date().toISOString());
const timeoutMs = deps?.timeoutMs ?? 8000;
// 一個 LangGraph.js 節點:吃 state,回傳要合併進 state 的片段
return async function fetchSourcesNode(
state: typeof DigestState.State,
): Promise<Partial<typeof DigestState.State>> {
const { competitor } = state;
// allSettled:就算某個來源「意外」丟例外,也不會牽連其他來源
const settled = await Promise.allSettled(
competitor.sources.map((spec) =>
fetchOneSource(spec, { fetcher, now, timeoutMs, competitorId: competitor.id }),
),
);
const rawItems: RawItem[] = [];
const fetchLog: SourceOutcome[] = [];
settled.forEach((result, i) => {
if (result.status === "fulfilled") {
rawItems.push(...result.value.items);
fetchLog.push(result.value.outcome);
} else {
// 理論上到不了這裡(fetchOneSource 自己吞例外了),但當最後一道防線
const spec = competitor.sources[i];
fetchLog.push({
sourceType: spec.type,
sourceUrl: spec.url,
status: "error",
itemCount: 0,
error: String(result.reason),
fetchedAt: now(),
});
}
});
// 回傳片段 → 因為 reducer 是「附加」,這些料會被接進 state.rawItems / fetchLog
return { rawItems, fetchLog };
};
}
這個節點有三層「不炸掉 graph」的防線,一層比一層外,很值得逐層看:
- 最內層:
fetchOneSource自己就try/catch到底,把失敗變成SourceOutcome,正常回傳。 - 中間層:節點用
Promise.allSettled而不是Promise.all。差別是致命的——Promise.all只要一個 promise reject 就整批 reject(其他來源的成果全丟);allSettled等每一個都有結果,成功的照收、失敗的標記。一個來源逾時,不會拖垮另外兩個。 - 最外層:
else分支是純粹的偏執防禦——萬一哪天fetchOneSource有 bug 真的丟了例外,allSettled也接得住,我們還是記一筆error,節點照樣正常返回。
三層下來,結論只有一句:這個節點永遠成功返回、永遠不 throw。 失敗不是讓 graph 崩潰的例外,而是 fetchLog 裡的一筆資料。graph 照樣往下跑到摘要節點——只是摘要時手上少一個來源而已。
flowchart TB
N["fetchSources 節點:Promise.allSettled"]
N --> A["rss → ok(3 筆)"]
N --> B["changelog → ok(2 筆)"]
N --> C["pricing → timeout(0 筆,記進 fetchLog)"]
A --> R["節點照常回傳 partial state"]
B --> R
C --> R
R --> G["graph 繼續:summarize 照跑"]
⚠️ 但別把「吞掉一切」當成金科玉律——這裡有條重要的界線。「單一來源失敗、記錄後繼續」是對的,因為少一個來源,週報還是有價值(其他兩個來源的情報照樣送得出去)。但如果壞掉的是整張 graph 的前置條件——例如 state 裡根本沒有 competitor、或資料庫連不上——那你不該默默吞,該讓它大聲失敗,好讓上層的重試或告警機制接手(那是第 10、12 課的事)。判準是這句話:「這個失敗,會不會讓產出對使用者變成一份『看起來正常、其實是錯的』週報?」 會的話,寧可炸;不會的話(少一個來源),記錄後繼續。
六、接回第 2 課的 graph:讓摘要吃真料
抓取節點好了,把它接回第 2 課那張 graph。摘要節點是第 2 課的東西,我們不重寫——只做一件事:把它的輸入從「寫死的假料」換成「state.rawItems」。
這裡再次示範 seam:組 prompt 的部分是純邏輯(可測),真正 invoke 模型的那一下才是 seam:
// src/nodes/summarize.ts —— 沿用第 2 課,只換「輸入來源」
import { DigestState } from "../state.js";
import type { RawItem } from "../state.js";
// 純邏輯(seam 的純邏輯側):把 rawItems 組成給模型的輸入 → 可離線測
export function buildSummaryInput(competitorName: string, rawItems: RawItem[]): string {
const lines = rawItems.map((it) => {
const when = it.publishedAt ?? "(無日期)";
return `- [${it.sourceType}] ${it.title}|${when}\n ${it.body}`;
});
return [
`競品:${competitorName}`,
`以下是本週從各來源抓到的原始情報,請摘要成 3–5 條重點:`,
...lines,
].join("\n");
}
// 第 2 課接的是真正的 chat model(ChatOpenAI / ChatAnthropic 之類);這裡用最小介面表示,方便測試塞 stub 假模型
export type ChatModel = { invoke: (input: string) => Promise<{ content: string }> };
export function makeSummarizeNode(model: ChatModel) {
return async function summarizeNode(
state: typeof DigestState.State,
): Promise<Partial<typeof DigestState.State>> {
const input = buildSummaryInput(state.competitor.name, state.rawItems);
const res = await model.invoke(input); // ← 這一下才是 seam:真打 LLM/測試塞 stub
return { summary: res.content };
};
}
💡 摘要節點的完整模型接線(真的
ChatAnthropic/ChatOpenAI、prompt 細節)是第 2 課的內容,這裡不重講。本課的重點只有一個:摘要能吃真料,是因為fetchSources已經把三種異質來源都正規化成統一的RawItem——buildSummaryInput只認RawItem,它根本不知道、也不需要知道這筆料原本是 RSS 還是 pricing。這就是第二節「正規化先行」的紅利兌現。
組裝整張 graph(沿用第 2 課的 StateGraph):
// src/graph.ts
import { StateGraph, START, END } from "@langchain/langgraph";
import { DigestState } from "./state.js";
import { makeFetchSourcesNode } from "./nodes/fetchSources.js";
import { makeSummarizeNode, type ChatModel } from "./nodes/summarize.js";
export function buildGraph(model: ChatModel) {
return new StateGraph(DigestState)
.addNode("fetchSources", makeFetchSourcesNode()) // 預設真打網路
.addNode("summarize", makeSummarizeNode(model))
.addEdge(START, "fetchSources")
.addEdge("fetchSources", "summarize")
.addEdge("summarize", END)
.compile();
}
真打網路的示範(不進 CI)
前面所有測試都是離線的。這裡給一個真的去打網路的示範,讓你眼見為憑。這段是示範、不進 CI——它會碰真實網路,慢、而且會因為別人的伺服器狀態而時好時壞,正是我們前面費那麼大勁把它擋在 CI 外的東西。
// src/demo.ts —— 真打網路的示範;不要放進 CI
import { buildGraph } from "./graph.js";
import type { Competitor } from "./state.js";
const acme: Competitor = {
id: "acme",
name: "Acme Planner",
sources: [
{ type: "rss", url: "https://blog.acme.example/feed.xml" },
{ type: "changelog", url: "https://acme.example/changelog" },
{ type: "pricing", url: "https://acme.example/pricing" },
],
};
// 真模型接線見第 2 課;這裡先用一個 stub 假模型,讓 demo 不用金鑰也能跑
const model = {
invoke: async (input: string) => ({ content: `(摘要 stub)本週收到情報:\n${input.slice(0, 120)}…` }),
};
const graph = buildGraph(model);
const final = await graph.invoke({ competitor: acme });
console.log("=== fetchLog(每來源成績單)===");
console.table(final.fetchLog);
console.log(`=== rawItems 共 ${final.rawItems.length} 筆 ===`);
console.log("=== summary ===\n", final.summary);
node --import tsx src/demo.ts
⚠️ acme.example / blog.acme.example 這些是保留給文件用的示範網域,不會真的解析。所以你跑起來,fetchLog 裡多半會看到 status: "timeout" 或 "error"——而這恰好證明了整章的重點:三個來源全掛,graph.invoke 照樣成功回傳,summary 照樣產出(雖然沒料可摘),失敗全被乖乖記在 fetchLog 裡,沒有任何一個例外炸出來。把網址換成你真的要追的競品,ok 就會出現。
💡 那份
fetchLog,第 5 課會串進 control panel,讓使用者直接在儀表板上看到「這週哪個來源掛了」——這也是為什麼我們從一開始就把它設計成結構化的 state、而不是散在 console 的一堆console.log。你現在多存的這份成績單,後面每一課都撿得起來用。
這一課建立了什麼
把這一課收攏成你可以帶走的東西:
- 「工具」在 workflow 裡的定義:就是節點呼叫的一個函式,用來伸手抓一個外部來源。要不要抓、抓哪個,是固定步驟決定的,不是 LLM——這跟 fe-admin-ai-chat「LLM 挑工具」的 agent 做法相反(回指 ch5)。能寫死的,別花錢讓模型猜。
- 抓取=兩條接縫:一條是網路(用注入的
Fetcher切開,測試塞假 fetcher/假 HTTP),一條是 LLM(第 2 課的 seam,測試塞 stub 假模型)。中間夾著的 parser 是純函式——text → RawItem[],不碰網路、不碰時鐘(連時間都注入),可離線測到有信心。 - 正規化先行:三種異質來源(RSS/changelog/pricing)在抓取當下就被壓成同一種
RawItem。下游(摘要、第 4 課去重、第 7 課比對)只認RawItem,不必知道料的出身。HTML 解析很脆,但脆弱被關在單一 parser 裡,不外溢。 - 失敗是資料,不是例外:
fetchOneSource永不 throw,把結局包成SourceOutcome(ok/empty/timeout/error);節點用Promise.allSettled讓「一個來源掛掉不牽連其他」。整個抓取節點永遠成功返回——失敗變成state.fetchLog裡的一筆情報。但邊界要記得:單一來源失敗可以吞並繼續;整張 graph 的前置條件壞掉則該大聲失敗。 - 兩個新的 state channel:
rawItems(正規化原料)與fetchLog(每來源成績單),都用「附加」reducer——現在只有一個節點寫、看似多餘,其實是為第 4 課多競品平行寫入鋪路。RawItem的externalId(穩定 id)與fetchedAt(新鮮度)則是為第 7 課跨週 diff 埋的地基。
下一課,我們把鏡頭從「一個競品的多個來源」拉到「多個競品同時跑」:用 LangGraph.js 的平行 fan-out 把 N 個競品鋪開、用 子圖(subgraph)複用單一競品的抓取流程,並處理同一次執行裡冒出來的重複料——也就是去重(dedup)。你這一課設計的 RawItem 和那個「附加」reducer,到時候會原封不動地派上用場。