第 3 課 · 讓節點動手抓料——多來源工具與真實情報輸入

第 3 課 · 讓節點動手抓料——多來源工具與真實情報輸入

第 2 課,你的 graph 已經會跑了:START → 抓取 → 摘要 → END,state 在節點之間流動,LLM 節點被切在 seam 後面、測試時用 stub 假模型頂替。很漂亮。

但它有個不能上線的秘密:那個「抓取」節點吃的是你手貼進去的一段假料。 它根本沒去過網路。

真正的競品情報週報,第一件事就是伸手到真實世界——去抓 Acme Planner 的部落格 RSS、去讀它的 changelog、去看它的 pricing 頁,把「這週對手實際發生了什麼」抓回來。這一課,我們讓節點動手

具體要回答四個問題,答完你就有一個「會自己去抓真料、抓不到也不會崩」的抓取節點:

  1. 在 LangGraph.js 的節點裡,怎麼呼叫「工具」去抓外部真實資料(RSS/changelog/pricing 頁),再把抓到的東西寫回 state
  2. 怎麼把「抓取」設計成純邏輯、可離線測,並和「LLM 摘要」分離?
  3. 某個來源逾時或沒回應時,節點怎麼回報失敗、而不是讓整張 graph 崩掉?
  4. 多來源的原始料進到 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 或解析爆掉)。注意 emptytimeout 是分開的——「對方頁面真的沒更新」和「我們根本沒連上」是兩回事,混在一起你會做出錯誤判斷。

把兩者掛進 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 課的摘要節點會寫這裡
});

rawItemsfetchLog 用的是 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();
}

注意 ParseCtxfetchedAt(時鐘)當參數傳進來。為什麼不在函式裡直接 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 的事件型)——它不是「一串事件」,而是「此刻每個方案多少錢」的當下狀態。所以它一筆 = 一個方案,而且 publishedAtnull(定價沒有「發布時間」):

// 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 內建的 fetchAbortSignal.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 裡的一筆資料,而不是一個往上炸的例外。 timeouterrorempty——全都是正常的回傳值。這個函式沒有「失敗路徑」,只有「不同的結局」。

用假 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」的防線,一層比一層外,很值得逐層看:

  1. 最內層fetchOneSource 自己就 try/catch 到底,把失敗變成 SourceOutcome,正常回傳。
  2. 中間層:節點用 Promise.allSettled 而不是 Promise.all。差別是致命的——Promise.all 只要一個 promise reject 就整批 reject(其他來源的成果全丟);allSettled 等每一個都有結果,成功的照收、失敗的標記。一個來源逾時,不會拖垮另外兩個。
  3. 最外層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,把結局包成 SourceOutcomeokemptytimeouterror);節點用 Promise.allSettled 讓「一個來源掛掉不牽連其他」。整個抓取節點永遠成功返回——失敗變成 state.fetchLog 裡的一筆情報。但邊界要記得:單一來源失敗可以吞並繼續;整張 graph 的前置條件壞掉則該大聲失敗。
  • 兩個新的 state channelrawItems(正規化原料)與 fetchLog(每來源成績單),都用「附加」reducer——現在只有一個節點寫、看似多餘,其實是為第 4 課多競品平行寫入鋪路。RawItemexternalId(穩定 id)與 fetchedAt(新鮮度)則是為第 7 課跨週 diff 埋的地基。

下一課,我們把鏡頭從「一個競品的多個來源」拉到「多個競品同時跑」:用 LangGraph.js 的平行 fan-out 把 N 個競品鋪開、用 子圖(subgraph)複用單一競品的抓取流程,並處理同一次執行裡冒出來的重複料——也就是去重(dedup)。你這一課設計的 RawItem 和那個「附加」reducer,到時候會原封不動地派上用場。