ego (lite) 只是一個瀏覽器,ego 則是你跨裝置的個人 Agent。
加入候補名單
網頁抓取JavaScriptNode.jsPlaywrightCheerioego (lite)

用 JavaScript 做網頁抓取:靜態 HTML、渲染頁面與 Playwright

2026年9月15日13 分鐘閱讀
一塊 JS 積木與喜劇、悲劇面具平衡在藍色瀏覽器之眼上,下方是像素風山脈與花田

JavaScript 網頁抓取之所以經常失敗,往往不是程式碼寫錯,而是一開始就選錯了存取路線。如果資料本來就在初始 HTML 裡,用 Node.js 的 fetch 加上 HTML 解析器就足夠。只有當頁面必須先執行 JavaScript、翻頁、點擊或以其他方式互動,資料才會出現時,才需要動用瀏覽器,而這正是靜態 HTML 與渲染頁面之間的分界線。

對於事先已知、且會反覆執行的工作流程,Playwright 是很好的選擇:開啟頁面、等待元素、點擊控制項、擷取欄位,然後再跑一次同樣的路徑。問題往往從這裡開始:一旦頁面結構、翻頁方式或互動流程改變,固定的選擇器與動作順序就會開始失靈。

這時候,像 ego (lite) 這種由 Agent 驅動的瀏覽器就更合適。它不會假設原本的路徑依然存在,Agent 可以檢查目前渲染出來的頁面,判斷下一步該做什麼,並在發生跳轉或動態變化後繼續穩定地執行下去。

JavaScript 爬蟲能不能成功,真正取決於什麼?

存取路線決定結果。同一個目標網站,可能用 HTTP 就能輕鬆抓取,也可能對 HTTP 完全封閉,關鍵在於資料是隨初始 HTML 回應一起送達,還是由瀏覽器中的 JavaScript 事後取得並繪製出來。

所以第一步不是寫爬蟲,而是打開目標頁面,查看原始碼而不是渲染後的 DOM,找出你要的數值究竟住在哪裡。本指南後面的內容,都取決於這個答案。

你的目標網站需要三種存取路線中的哪一種?

三種路線幾乎涵蓋所有抓取工作,而且按成本排序。靜態 HTML 最便宜也最快;頁面本身已在呼叫的 JSON 端點,通常是你拿到最乾淨資料的方式;真實瀏覽器能力最強,但在 CPU、記憶體與脆弱程度上也最昂貴。

路線它能做什麼它不能做什麼
HTTP 請求 + HTML 解析器直接抓取任何 URL、讀取回應內容,並查詢回傳的標記。每個行程每分鐘可處理數千個頁面,也不需要瀏覽器執行檔。無法執行頁面指令碼、點擊、捲動或填寫表單。面對用戶端渲染的頁面,只會拿到空殼,因為資料從來不在回應裡。
直接呼叫 JSON 端點回傳結構化資料,不需解析標記,因此即使頁面版面改版,欄位名稱依然可用。資料量最小,解析最快。無法在網站更新後保持穩定。這類端點屬於內部使用、沒有正式文件,可能無預警變更或開始拒絕請求。
真實瀏覽器自動化執行頁面的 JavaScript、等待內容出現,並像真人一樣與渲染後的結果互動。無法低成本擴充。每個瀏覽器 context 都會佔用實際記憶體,大量 context 所需的基礎設施也遠超過單純的 HTTP 迴圈。
一個名為 laptop-research 的 ego (lite) Space,就開在本指南要抓取的 webscraper.io 筆電目錄旁邊
第三條路線,已經在運行:一個 Agent 在真實的瀏覽器 Space 裡工作,右側是同一份目標目錄。這條路線是主動選擇,而非預設做法,也是運行成本最高的一條。

在 Node.js 中如何抓取並解析靜態 HTML?

先從平台本身做起。Node.js 把 Fetch API 提供為全域物件,所以發出請求完全不需要額外依賴。下面這個模式就是第一條路線的全部:發出請求、檢查狀態、讀取文字,再把標記交給解析器。

請求本身除了平台之外什麼都不需要,因為 Fetch API 是 Node.js 的全域能力,所以一次單純的 GET 不需要安裝任何 HTTP 函式庫。

狀態檢查是最常被拿掉、卻也最容易漏掉的環節。404 或被機器人防護擋下的頁面同樣會回傳內容,而那段內容可以被順利解析成零個符合的元素,看起來就跟選擇器寫錯一模一樣。

const res = await fetch(url, {
  headers: { "user-agent": "my-scraper/1.0 (+contact@example.com)" },
});

if (!res.ok) {
  throw new Error(`${res.status} ${res.statusText} for ${url}`);
}

const html = await res.text();

頻率限制應該直接寫在同一個迴圈裡,而不是事後才補上。在請求之間加入一次 await 延遲,能讓小規模工作保持禮貌,也讓你的位址不會被列入封鎖名單:

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

for (const url of urls) {
  const html = await getHtml(url);
  await parse(html);
  await sleep(1000);
}

以下是第一條路線實際回傳的結果。下方表格是真實輸出,來自對一份公開筆電測試目錄發出單純 HTTP 請求,再搭配選擇器函式庫:沒有瀏覽器、沒有渲染步驟,三頁就是三個靜態回應。

一個終端正在執行 Node.js 指令碼,用內建的 fetch API 加上 Cheerio 處理這份筆電目錄,畫面上能看到 base URL、$500 的價格上限、在 ?page=N 上的分頁檢查,以及以產品 URL 為鍵的去重判斷
第一條路線的完整過程:Node 內建的 fetch 加 Cheerio,沒有瀏覽器自動化,即使裝了 axios 也沒用它。分頁就是一個普通的 ?page= 參數,去重以 URL 為鍵,而不是顯示名稱。
ID名稱價格規格評論
32Aspire E1-510$306.9915.6", Pentium N3520 2.16GHz, 4GB, 500GB, Linux2
45Asus VivoBook Max$39915.6" HD, Pentium N4200 1.1GHz, 4GB, 500GB, Windows 10 Home4
31Packard 255 G2$416.9915.6", AMD E2-3800 1.3GHz, 4GB, 500GB, Windows 8.12
46Dell Vostro 15$488.7815.6" FHD, Core i5-7200U, 4GB, 128GB SSD, Radeon R5 M420 2GB, Linux14

十八項產品裡有四項低於 $500。這就是第一條路線在這份目錄上的全部成果:三次請求、沒有渲染、沒有瀏覽器行程。但誠實的部分也從這裡開始,因為這張表少了一個讀者預期會看到的東西。

Cheerio、DOMParser 還是 jsdom:該用哪個解析器?

這三者常被當成可互換的函式庫來比較,但它們並不是,因為各自承諾的事情不同:其中兩個是解析標記供查詢之用,另一個則是帶有指令碼執行能力的 DOM 實作。

本指南所依賴的解析器,文件在 cheerio.js.org,其中明確指出 Cheerio 只解析並查詢標記,不會執行頁面指令碼。

一份涵蓋 117 個產品、20 個頁面的目錄終端報告,隨後標出兩個解析陷阱,並附上有關語意化 microdata 選擇器與 ?page= 分頁實作方式的說明
真實輸出裡能看到兩個陷阱:顯示名稱 ThinkPad Yoga 被兩台不同的機器重複使用,所以去重必須以產品 ID 為鍵;另外兩個產品名稱被網站自己的 CSS 截斷,完整值只存在於連結的 title 屬性裡。
選項它能做什麼它不能做什麼
Cheerio能快速解析 HTML 字串,並用 jQuery 風格的選擇器查詢。依賴小、不需瀏覽器,很適合從一段回應內容中取出幾百個欄位。無法執行頁面指令碼、渲染元件或計算版面。它只解析標記,行為不像瀏覽器。
jsdom在 Node.js 中提供 DOM 實作,包含 document、window 與指令碼執行,因此針對瀏覽器 API 撰寫的程式碼可以原封不動地執行。無法在渲染或還原度上與真實瀏覽器相比,而且每頁的負擔高得多。它是 DOM 的替代品,不是 Chrome。
DOMParser用內建的瀏覽器 API 把字串轉成可查詢的文件,完全不必為專案新增任何依賴。無法被 await:它是同步且會阻塞的,而且在 Node.js 中直到近期版本才成為全域物件。

用 Cheerio 讀取時用的是熟悉的選擇器 API。注意擷取出的文字在邊界處做了 trim,因為抓來的標記帶著縮排與換行,不處理就會出現在你的資料集裡:

import * as cheerio from "cheerio";

const $ = cheerio.load(html);
const items = $(".product-card").map((i, el) => ({
  name: $(el).find(".name").text().trim(),
  price: $(el).find(".price").text().trim(),
})).get();

同樣的擷取改用 DOMParser 會像這樣,而且只在那個全域物件存在時才能運作:

const doc = new DOMParser().parseFromString(html, "text/html");
const rows = [...doc.querySelectorAll("table tbody tr")].map((tr) => ({
  cells: [...tr.querySelectorAll("td")].map((td) => td.textContent.trim()),
}));

如何找出頁面本身已在使用的 JSON 端點?

在動手寫任何選擇器之前,先打開瀏覽器的網路面板,篩選 fetch 與 XHR,重新載入頁面,看看回了什麼。許多資料驅動的網站是靠少數幾個 JSON 回應拼出你看見的頁面,而這些 JSON 遠比標記容易處理。

找到之後,那個請求通常需要頁面送出時的相同標頭,有時還需要工作階段 Cookie。直接用網路面板的 copy-as-fetch 輸出重播,不要自己手動重組。

const res = await fetch("https://example.com/api/listings?page=1", {
  headers: { accept: "application/json" },
});

if (!res.ok) throw new Error(`${res.status} for listings page 1`);

const { items } = await res.json();

為什麼用戶端渲染的頁面只回傳空殼?

用戶端渲染的頁面送出的標記幾乎不含內容。初始回應只有一個根元素、一堆指令碼,也許還有一個載入狀態。使用者在螢幕上看到的文字,是回應抵達之後才由 JavaScript 產生,所以只讀取回應的 HTTP 請求根本沒有東西可讀。

這可以診斷,一點都不神秘。兩項檢查就能抓到大多數情況:回應中的可見文字只是瀏覽器顯示內容的一小部分,而且標記以 script 標籤為主:

const text = html.replace(/<script[\s\S]*?<\/script>/g, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();

console.log({
  textLength: text.length,
  scriptCount: (html.match(/<script\b/g) ?? []).length,
  hasRoot: /id="(root|app|__next)"/.test(html),
});

文字很短、指令碼很多、根層級 div 又是空的,加起來就代表第三條路線才是誠實的答案。如果只有幾十個字的文字、完全沒有 script 標籤,通常代表更單純的問題:請求被封鎖了,或是你要錯了 URL。

實際上的差異長這樣:

HTTP 回應中的訊號通常代表什麼下一步
完整內容、真正的標記伺服器已經渲染好頁面,不需要再做其他事。用選擇器函式庫解析即可。
根層級 div 加上大量指令碼用戶端渲染。內容在回應之後才會抵達。尋找 JSON 端點,或渲染頁面。
文字極短、沒有指令碼被封鎖、被重新導向,或根本就弄錯 URL。解析前先記錄狀態碼、最終 URL 與標頭。

Playwright 如何抓取真實瀏覽器頁面?

Playwright 驅動的是真實瀏覽器,所以頁面的執行方式與訪客所見完全相同。爬蟲的骨架比多數人以為的還小:開啟一個 context、前往 URL、等待你需要的那個特定東西,然後把它讀出來。

本文用到的 API 文件在 playwright.dev,這是啟動瀏覽器、context 與 locator 的權威參考。

一次 Playwright 執行,在真實的 Chromium 視窗中開啟目標頁面,左側終端顯示驅動它的指令
第三條路線,瞄準同樣的目標:由 Playwright 開啟的真實瀏覽器。多出來的成本換來指令碼執行與渲染後的 DOM,這正是一個用戶端渲染頁面所需要的,也是單靠 fetch 做不到的。

下面這個取用模式會先等待某個選擇器,再透過 page.evaluate 擷取;page.evaluate 會在你的頁面內執行函式,並回傳可序列化的結果:

import { chromium } from "playwright";

const browser = await chromium.launch();
const context = await browser.newContext();

try {
  const page = await context.newPage();
  await page.goto(url, { waitUntil: "domcontentloaded" });
  await page.waitForSelector(".product-card");

  const rows = await page.evaluate(() =>
    [...document.querySelectorAll(".product-card")].map((el) => ({
      name: el.querySelector(".name")?.textContent?.trim() ?? null,
      price: el.querySelector(".price")?.textContent?.trim() ?? null,
    })),
  );

  console.log(rows);
} finally {
  // contexts hold the memory; close it even when the scrape throws
  await context.close();
  await browser.close();
}

有兩個生命週期細節比選擇器更重要。第一,context 才是便宜、可拋棄的單位,所以一個瀏覽器可以服務多次彼此隔離的執行,而且每次都以 close 收尾。第二,在頁面上擷取文字拿到的是文字節點,不是渲染後的值:被 CSS 隱藏的內容仍在 DOM 中,也仍會出現在你的輸出裡。

當目標是某個特定元素而非整份清單時,locator 是更乾淨的取用方式。Playwright 自己的 locator 文件指出,按鈕、連結與輸入框不需要等待就能點擊;在把每個互動都包上明確等待之前,這點值得記住:

const title = await page.getByRole("heading", { level: 1 }).innerText();
const price = await page.locator("[data-testid=price]").innerText();

工作階段、封鎖風險與 CAPTCHA 會如何改變做法?

目標一旦需要登入,爬蟲就不再只是處理資料,而變成工作階段管理的問題。Playwright 直接支援這種做法:驗證一次、把瀏覽器的儲存狀態存成檔案,之後執行時重複使用,不必每次都寫一段輸入憑證的流程。

// once, interactively
await context.storageState({ path: "auth.json" });

// later runs
const context = await browser.newContext({ storageState: "auth.json" });

由此可推出兩個實務上的事實。儲存下來的工作階段狀態本身就是憑證,所以應該放進機密儲存區,而不是放進儲存庫。另外,儲存的工作階段會過期,所以某次執行突然開始回傳登入頁面時,那是工作階段問題,不是選擇器問題。

讓這個狀態在多次啟動之間保持存活是另一個主題: 跨 Agent 執行重用持久瀏覽器工作階段 有詳細說明。

就自動化流量而言,有三項限制決定你是在做一份抓取工作,還是在打一場必輸的仗:網站的 robots 指令與條款允許什麼、網站公布或容忍的頻率是多少,以及你拿回的回應究竟是內容還是驗證挑戰頁。這些都是該在寫程式之前先確定的政策問題,更深入的說明請見我們的 登入牆後方的抓取指南

JavaScript 爬蟲在正式環境中會因什麼而壞掉?

爬蟲很少是因為選擇器寫錯而失敗。它們通常是在第二次執行、面對第一百個 URL 時失敗:某個頁面變慢了、某個回應是重新導向,或網站開始限流。修法既不炫目,也很具體。

async function getWithRetry(url, attempt = 0) {
  const res = await fetch(url);
  if (res.status === 429 || res.status >= 500) {
    if (attempt >= 3) throw new Error(`giving up on ${url}`);
    const retryAfter = Number(res.headers.get("retry-after"));
    const waitMs = Number.isFinite(retryAfter)
      ? retryAfter * 1000
      : 2 ** attempt * 1000;
    await new Promise((r) => setTimeout(r, waitMs));
    return getWithRetry(url, attempt + 1);
  }
  if (!res.ok) throw new Error(`${res.status} for ${url}`);
  return res.text();
}

並行是第二個陷阱。同一台機器上十個平行的瀏覽器 context,大多只是在搶同一顆 CPU,所以吞吐量的提升小於記憶體成本,而網站看到的是一次爆量,而不是細水長流。先從循序開始、量測數據,確認目標網站能承受之後,再把數字往上調。

第三個是:任何讀取沒有承諾端點的爬蟲都需要後備方案。在程式庫中保留以選擇器解析可見頁面的做法,讓失敗的 JSON 呼叫退回用它處理,這樣上游悄悄變更時,只會讓這次執行品質下降,而不是整批變空。

一個終端顯示第 2 頁和第 3 頁回傳了與第 1 頁相同的過期記錄,原因在於等待條件綁在表示啟用狀態的類別上,而不是綁在內容上
只在第二頁才會出現的失敗:等待以類別名稱的變化為準,而不是以內容為準,於是第 2 頁和第 3 頁交回了第 1 頁的資料列。沒有任何例外被拋出,資料看起來也完全正常。

什麼時候真的必須出動真實瀏覽器?

當任務需要只有瀏覽器才給得出的東西時,真實瀏覽器才是正確答案:你機器上既有的已驗證工作階段、互動之後才會出現的內容、需要有人中途介入的流程,或是必須邊執行邊觀察、而非直接抓取的結果。除此之外,用 HTTP 搭配解析器更快、更便宜,也更容易長期運作。

無頭瀏覽器與真實瀏覽器之間的取捨,見 面向 AI Agent 的無頭瀏覽器與真實瀏覽器比較

這正是值得導入任務驅動瀏覽器 Agent 的情境。 ego (lite) 是一款為 Agent 驅動工作而打造的瀏覽器:你描述目標,它就在真實瀏覽器中操作,包含你已經開著的登入工作階段,並提供看得見的介面,讓你在某個步驟需要人類判斷時接手。對於需要真實登入狀態、動態頁面、可見執行或人工接手的抓取任務,它省去了前面描述的工作階段串接工程。它不是 Playwright 的替代品,也不對每一個網站做出承諾:會回以驗證挑戰、或禁止自動化存取的頁面,對它而言同樣是不可觸及的,就跟其他任何路線一樣。如果單純的 HTTP 請求或官方 API 就能回傳你需要的東西,再加上瀏覽器 Agent 只會讓工作更慢。

當瀏覽器確實是必要答案時,若想更深入比較各種瀏覽器工具,我們的 Playwright 與 Puppeteer 抓取比較 說明函式庫本身的選擇,而 AI 網頁爬蟲工作流程則說明 Agent 在爬蟲流程中適合放在哪裡。

主要的挑戰與限制有哪些?

這套框架中的每一條路線都有無法靠工程手段消除的失效模式;事先認識它們,就是能跑一年的爬蟲與只能跑一週的爬蟲之間的差別。

當網站重新設計標記時,靜態解析就會失效。它沒辦法察覺,所以欄位默默變成 null 比直接崩潰更常見。每次執行都要驗證一份樣本,不要完全信任流程。

內部 JSON 端點完全不會預警就壞掉,它們是任何網站中最沒有契約保障的部分。今天執行成功,並不代表下個月還能成功。

瀏覽器自動化最貼近真實,但大規模運行時也最脆弱。記憶體隨並行數增加,工作階段會過期,而防機器人系統看的是行為模式而不是你的意圖,所以在筆電上可行的技巧,到了資料中心未必管用。

而最大的限制並非技術面的。你可以蒐集什麼、多久蒐集一次,以及之後能用它做什麼,是由網站條款、robots 指令與你所在司法管轄區的法律決定,這些都不會因為程式跑得動而改變。

由這一切推導出的排查順序很短。當一次抓取什麼都沒拿到時,先檢查狀態碼,再確認內容到底有沒有在回應裡,然後檢查選擇器比對的是渲染後的 DOM 還是原始碼。這三步都做完,才該考慮動用瀏覽器。

常見問題

在 Node.js 中發出 HTTP 請求需要安裝函式庫嗎?

不需要。Node.js 把 Fetch API 提供為全域物件,所以不必安裝任何東西就能使用 fetch,連同 response 物件及其 ok、status、text() 成員。真正需要的是解析器,因為 fetch 回傳的是一個字串,而對 HTML 做字串比對,只要標記一改就會壞掉。

為什麼我的爬蟲在瀏覽器看起來正常的頁面上,卻回傳空清單?

最可能的原因是該頁面屬於用戶端渲染,資料從來就不在 HTTP 回應中。你可以比較原始回應中的可見文字與瀏覽器顯示的內容,並計算 script 標籤相對於內容的比例來確認。如果回應只是空殼,就改走網站的 JSON 端點,或用真實瀏覽器渲染頁面。

Cheerio 能取代無頭瀏覽器嗎?

不能。Cheerio 解析 HTML 並讓你用選擇器查詢,但它不執行 JavaScript,所以無法產生頁面在載入後才生成的內容。它是第一條路線的正確工具,也是第三條路線的錯誤工具;明明 Cheerio 就夠用卻動用瀏覽器,是爬蟲程式碼中最常見的不必要成本。

要怎麼判斷一個頁面是伺服器渲染還是用戶端渲染?

發出請求,然後看原始回應,而不是渲染後的 DOM。如果你要的數值出現在回應內容中,就代表是伺服器渲染,便宜的路線就行得通。如果內容裡只有一個根元素、一堆 script 打包檔和很少的文字,那就是在瀏覽器裡組出來的。

需要登入的頁面要怎麼抓取?

在瀏覽器 context 中登入一次,把儲存狀態存成檔案,之後執行時載入該狀態。把這個檔案當成機密看待,預期它會過期,並優先使用你有權使用的工作階段。如果目標要求繞過 CAPTCHA 或所有權驗證,請停下來,改用官方路線。

用 Playwright 還是 Puppeteer 抓取比較好?

兩者都驅動真實瀏覽器,也都能抓取同樣的頁面。這個決定取決於函式庫層面的細節,例如 locator 支援、等待行為與各語言的綁定,而不是存取路線;我們的 Playwright 與 Puppeteer 比較。不論你選哪一個,本文談的路線決策永遠排在前面。

一次可以同時抓取多少頁面?

先從循序開始,量測之後再提高並行數。HTTP 請求的擴充成本遠低於瀏覽器 context,而同一台機器上的平行瀏覽器大多只是在搶同一顆 CPU,同時給目標網站一波爆量流量。以 HTTP 抓取來說,同時進行四到八個並加上延遲,會比一次幾十個更安全的起點。

收到 429 回應時該怎麼做?

讀取 Retry-After 標頭,至少等待那麼久,然後以指數退避重試,次數上限設小一點,超過就明確報錯。429 是頻率訊號,不是讓你在緊湊的重試迴圈裡硬闖的錯誤;忽視它,就是把位址送進封鎖名單的方式。

網頁抓取合法嗎?

這取決於網站、資料、司法管轄區,以及你拿結果做什麼。robots 指令與服務條款說明網站允許的範圍,而個人資料與資料庫權利的規範則因國家而異。本文不構成法律建議;在大規模蒐集任何資料之前,請先確認條款與適用法律。

什麼時候該用官方 API 而不是自行抓取?

只要它存在,而且涵蓋你需要的欄位,就該優先使用。有文件記載的 API 是穩定的契約,通常有明確的頻率限制,也不會因為頁面版面改變而壞掉。抓取只是在瀏覽器中公開、卻沒有官方存取途徑的資料的後備手段,不是預設做法。

爬蟲需要用到瀏覽器 Agent 嗎?

只有在任務需要真實登入狀態、互動後才出現的內容、可見的執行過程,或中途由人接手時才需要。對於能透過 HTTP 回傳內容的頁面,或已有官方 API 的網站,瀏覽器 Agent 只會增加成本,不會增加能力。

如果你想在確實需要瀏覽器 Agent 的抓取任務上試試這條路線,ego (lite) 可免費下載,而價格爬蟲SERP 爬蟲這兩篇文章會從頭到尾走過兩個具體的實際案例。