
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 迴圈。 |

在 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 請求,再搭配選擇器函式庫:沒有瀏覽器、沒有渲染步驟,三頁就是三個靜態回應。

| ID | 名稱 | 價格 | 規格 | 評論 |
|---|---|---|---|---|
| 32 | Aspire E1-510 | $306.99 | 15.6", Pentium N3520 2.16GHz, 4GB, 500GB, Linux | 2 |
| 45 | Asus VivoBook Max | $399 | 15.6" HD, Pentium N4200 1.1GHz, 4GB, 500GB, Windows 10 Home | 4 |
| 31 | Packard 255 G2 | $416.99 | 15.6", AMD E2-3800 1.3GHz, 4GB, 500GB, Windows 8.1 | 2 |
| 46 | Dell Vostro 15 | $488.78 | 15.6" FHD, Core i5-7200U, 4GB, 128GB SSD, Radeon R5 M420 2GB, Linux | 14 |
十八項產品裡有四項低於 $500。這就是第一條路線在這份目錄上的全部成果:三次請求、沒有渲染、沒有瀏覽器行程。但誠實的部分也從這裡開始,因為這張表少了一個讀者預期會看到的東西。
Cheerio、DOMParser 還是 jsdom:該用哪個解析器?
這三者常被當成可互換的函式庫來比較,但它們並不是,因為各自承諾的事情不同:其中兩個是解析標記供查詢之用,另一個則是帶有指令碼執行能力的 DOM 實作。
本指南所依賴的解析器,文件在 cheerio.js.org,其中明確指出 Cheerio 只解析並查詢標記,不會執行頁面指令碼。

| 選項 | 它能做什麼 | 它不能做什麼 |
|---|---|---|
| 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 的權威參考。

下面這個取用模式會先等待某個選擇器,再透過 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 呼叫退回用它處理,這樣上游悄悄變更時,只會讓這次執行品質下降,而不是整批變空。

什麼時候真的必須出動真實瀏覽器?
當任務需要只有瀏覽器才給得出的東西時,真實瀏覽器才是正確答案:你機器上既有的已驗證工作階段、互動之後才會出現的內容、需要有人中途介入的流程,或是必須邊執行邊觀察、而非直接抓取的結果。除此之外,用 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 爬蟲這兩篇文章會從頭到尾走過兩個具體的實際案例。