跳至内容
返回博客

如何使用 JavaScript 抓取 HTML 表格并导出干净的 CSV 文件

Mihai Maxim最后更新于 2 min read
如何使用 JavaScript 抓取 HTML 表格并导出干净的 CSV 文件

开始提取

免费试用 WebScrapingAPI

领取 1,000 个免费 API 积分。无需信用卡。

开始使用
简而言之:首先确定表格行是否存在于 HTTP 响应中、是否通过数据请求获取,还是需要浏览器渲染。然后使用静态的 Axios 和 Cheerio 脚本,或下文介绍的针对性更强的 Puppeteer 方案,在 JavaScript 中抓取 HTML 表格,验证基于表头的记录,去除页面重复项,并安全地写入 CSV 文件。 HTML 表格将信息组织为行和列,通常使用 <table>, <tr>, <th><td> 元素。在 JavaScript 中抓取 HTML 表格,意味着检索或渲染该标记,将每行数据转换为 JavaScript 对象,验证结果,并将其序列化为 CSV 等格式。

遍历行通常并非难点。大多数失败是因为抓取工具读取服务器响应时,开发者却检查了浏览器渲染的 DOM;或是目标表选错、假设单元格位置固定,抑或导出了未转义的值。可靠的工作流程应选择能够实际识别数据且最轻量级的提取方法。

本 Node.js 网页抓取教程首先使用 Axios 和 Cheerio 处理静态标记,随后针对页面加载后生成的行,补充了专门的 Puppeteer 表格抓取路径。 该解析器从表格标题中推导键值,而非预设单一数据集。它还会报告格式错误的行,在未进行有意转换前将值保留为字符串,并检查输出是否可用。最终形成了一种实用的 JavaScript HTML 表格抓取方法,无需假装 rowspan, colspan、嵌套表格或所有自定义网格都能自动展平。

编写代码前请选择合适的提取路径

在 JavaScript 中抓取 HTML 表格之前,请先确定行数据的位置。在标准标记中, <tr> 表示一行, <th> 通常表示列,而 <td> 则包含常规值。当不熟悉结构时,MDN 的 HTML 表格元素参考文档是进行语义检查的有用资源。

请按照以下顺序进行判断:

  1. 响应 HTML 中的行:使用 Axios 请求页面,并用 Cheerio 进行解析。
  2. 由 Fetch 或 XHR 返回的行:当数据端点稳定且合适时,请使用该端点。
  3. 仅在脚本执行或用户交互后生成的行:使用浏览器自动化技术。

遵循此顺序,JavaScript 表格抓取工具的实现会比针对每个页面都从浏览器开始抓取更为简单且成本更低。

确认响应 HTML 中是否存在这些行

打开“查看源代码”(而非仅打开“元素”面板),并搜索一个独特的单元格值。您还可以保存 response.data 并直接搜索该值。如果该值和一个作用域内的行选择器均存在,即使页面显示了分页控件,也无需使用浏览器自动化。

在发送重复请求之前,先在开发者工具中测试选择器,然后根据获取的标记重新测试它们。当 table tr 捕获导航、嵌套表格或页脚行时,CSS 选择器速查表会很有帮助。

检查通过网络获取或由浏览器渲染的行

如果源代码中不存在该值,请在“网络”面板中按“Fetch/XHR”过滤,然后刷新页面、更改页面或过滤条件,并检查响应。一个被允许且稳定的数据端点通常比渲染后的单元格更容易验证,但请确认使用它是被允许的。

当 JavaScript、登录状态、点击或滚动操作不可或缺时,请选择无头浏览器。Cheerio 与 Puppeteer 的对比分析有助于记录二者之间的权衡。避免将正则表达式用作通用 HTML 解析器;仅将其用于已选中单元格内的特定模式。

构建可重用的 Axios 和 Cheerio 抓取器,用于在 JavaScript 中抓取 HTML 表格

对于静态页面,处理流程非常简单:Axios 获取响应,Cheerio 加载标记,然后一个作用域限定解析器将行转换为对象。关键的设计选择在于配置。接受 URL、表格选择器和输出路径作为输入,然后根据所选表格的表头推导出记录键。

这种基于 Axios 和 Cheerio 的网页抓取方法避免了“第零个单元格是名称,第一个单元格是价格”这类脆弱的映射关系。它适用于标题和行宽保持一致的常规表格,同时仍能识别出结构上的异常情况。

设置 Node.js 项目并安全地请求页面

创建项目并安装静态示例中使用的三个包:

mkdir table-scraper
cd table-scraper
npm init -y
npm install axios cheerio objects-to-csv

创建 scrape-table.js。完整的脚本始终采用 CommonJS 规范,设置了请求超时,仅接受成功的 HTTP 状态码,并将请求、解析、验证和导出操作封装在一个错误边界内。它不会盲目重试客户端错误,因为重复请求无法修复缺失的选择器、访问被拒或无效的 URL 等问题。

包的 API 可能会发生变化。请在您的环境中测试脚本后锁定版本,并在将此示例作为生产环境依赖项使用前,仔细查阅官方包文档。

选择表并推导出稳定的列名

尽可能将选择器限定为一张表,例如 #results 而非 table。解析器会刻意选择第一个匹配项,然后在 <thead>中查找最后一行。如果不存在 <thead>,则将其视为包含 <th> 单元格的行作为表头。如果仍无法获取标签,则生成 column_1, column_2,以此类推。

标题文本将规范化为小写蛇形命名法。空标题将采用位置性备选方案,而重复项则会生成诸如 priceprice_2。这可防止后续单元格覆盖先前属性。对于多语言标题或公共数据契约,请用显式标题映射替换自动规范化处理。

多行标题, rowspancolspan 需要针对表格的特定逻辑,因为视觉网格可能与 DOM 单元格数量不匹配。请勿进行隐式猜测。请检查标记代码,定义预期列,并在语义重要时添加网格扩展步骤。

将每行映射到一个对象,且不要使用硬编码的字段名

读取每行的直接子单元格,而非所有后代单元格。这种区分可防止嵌套表格向其父行添加意外值。脚本首先构建一个由清理后的单元格字符串组成的矩阵,跳过不包含有效文本的行,并计算出观察到的最宽行。

随后,它为每列创建一个键,并按索引将键与值配对。缺失的值将变为空字符串。当初始扫描发现多余单元格时,会为其生成标题。解析器还会在行单元格数量与计算出的宽度不一致时记录警告,以确保列位移不会被忽略。

这是在 JavaScript 中抓取 HTML 表格时可复用的核心逻辑:目标数组位于行迭代之外,每行被接受后都会贡献一个完整的对象。检查警告仍然非常重要。宽度不匹配可能表示合法的跨单元格、隐藏的控制列、格式错误的标记,或者选定器越界到了另一个表格部分。

规范化和验证提取的值

规范化的是呈现上的干扰,而非含义。该示例将不换行空格转换为普通空格,截去两端空格,并合并重复的空白。它将日期、货币、百分比、标识符和前导零保留为字符串,因为诸如 01/02/031,234 等值在缺乏区域设置和模式规则的情况下存在歧义。

当缺少表格选择器或不再存在非空记录时,验证应明确报错。此外,还需验证必填列、检查样本对象、审查不匹配警告,并在下游任务使用该文件之前确认最终文件是否存在。 如果需要带数据类型的值,请在提取后应用显式的按列转换器,并保留原始值以供调试。当这些转换成为独立的管道阶段时,数据解析指南将是一个有用的参考。

安全地将记录序列化为 CSV

请勿使用 row.join(",")。单元格内的逗号、双引号、回车符和换行符都需要转义和加引号。请使用经过您测试且持续维护的序列化工具。示例中使用 objects-to-csv,该工具会按相同的表头顺序输出每个对象的属性,并将生成的 JavaScript 数组写入 CSV。

使用包含逗号、引号、嵌入换行符、空值和非 ASCII 文本的测试数据,对所选版本进行测试。在将读取该文件的应用程序中,确认其标题和 UTF-8 处理行为。 正确的 CSV 序列化无法修复不规范的源表;请在导出前解决跨单元格、语义重复和行不匹配等问题。

运行完整的静态表抓取程序

将以下内容保存为 scrape-table.js。该脚本涵盖请求、数据筛选、表头规范化、行映射、校验及 CSV 导出。编辑 requiredColumns 当下游数据需要保证字段时。

const axios = require("axios");
const cheerio = require("cheerio");
const ObjectsToCsv = require("objects-to-csv");

const [url, tableSelector = "table", outputPath = "table.csv"] =
  process.argv.slice(2);

const requiredColumns = [];

function clean(value) {
  return value
    .replace(/\u00a0/g, " ")
    .replace(/\s+/g, " ")
    .trim();
}

function uniqueHeaders(labels, width) {
  const seen = new Map();

  return Array.from({ length: width }, (_, index) => {
    const normalized = clean(labels[index] || "")
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, "_")
      .replace(/^_+|_+$/g, "");

    const base = normalized || `column_${index + 1}`;
    const count = (seen.get(base) || 0) + 1;
    seen.set(base, count);

    return count === 1 ? base : `${base}_${count}`;
  });
}

function parseTable(html) {
  const $ = cheerio.load(html);
  const table = $(tableSelector).first();

  if (!table.length) {
    throw new Error(`No table matched selector: ${tableSelector}`);
  }

  let headerRow = table.find("thead tr").last();

  if (!headerRow.length) {
    const firstRow = table.find("tr").first();
    if (firstRow.children("th").length) {
      headerRow = firstRow;
    }
  }

  const headerLabels = headerRow
    .children("th, td")
    .map((_, cell) => clean($(cell).text()))
    .get();

  let dataRows = table.children("tbody").children("tr");
  if (!dataRows.length) {
    dataRows = table.children("tr");
  }

  const headerNode = headerRow.get(0);
  const matrix = dataRows
    .toArray()
    .filter((row) => row !== headerNode)
    .map((row) =>
      $(row)
        .children("th, td")
        .map((_, cell) => clean($(cell).text()))
        .get()
    )
    .filter((cells) => cells.some(Boolean));

  const width = Math.max(
    0,
    headerLabels.length,
    ...matrix.map((cells) => cells.length)
  );

  if (!width) {
    throw new Error("The selected table has no readable cells.");
  }

  const headers = uniqueHeaders(headerLabels, width);
  const warnings = [];

  if (headerLabels.length && headerLabels.length !== width) {
    warnings.push(
      `Header has ${headerLabels.length} cells; widest row has ${width}.`
    );
  }

  const records = matrix.map((cells, rowIndex) => {
    if (cells.length !== width) {
      warnings.push(
        `Row ${rowIndex + 1} has ${cells.length} cells; expected ${width}.`
      );
    }

    return Object.fromEntries(
      headers.map((header, cellIndex) => [
        header,
        cells[cellIndex] ?? "",
      ])
    );
  });

  return { headers, records, warnings };
}

async function main() {
  if (!url) {
    throw new Error(
      'Usage: node scrape-table.js "<url>" "<table-selector>" "<output.csv>"'
    );
  }

  const response = await axios.get(url, {
    timeout: 15000,
    responseType: "text",
    maxRedirects: 5,
    validateStatus: (status) => status >= 200 && status < 300,
    headers: {
      "User-Agent": "TableScraper/1.0 (+contact@example.com)",
    },
  });

  const { headers, records, warnings } = parseTable(response.data);

  if (!records.length) {
    throw new Error("The table matched, but no nonempty rows were parsed.");
  }

  for (const column of requiredColumns) {
    if (!headers.includes(column)) {
      throw new Error(`Required column is missing: ${column}`);
    }
  }

  if (warnings.length) {
    console.warn(warnings.slice(0, 10).join("\n"));
  }

  await new ObjectsToCsv(records).toDisk(outputPath);

  console.log(`Wrote ${records.length} rows to ${outputPath}`);
  console.log(`Columns: ${headers.join(", ")}`);
  console.log("Sample record:", records[0]);
}

main().catch((error) => {
  console.error(`Scrape failed: ${error.message}`);
  process.exitCode = 1;
});

运行时需提供目标 URL、带引号的表选择器以及输出路径:

node scrape-table.js "https://example.com/page" "#results" "results.csv"

在信任 CSV 文件之前,请检查报告的列和样本记录。将每个宽度警告视为需要调查的数据质量问题。

处理由 JavaScript 渲染的分页表格

当响应中缺少行时,若需在 JavaScript 中抓取 HTML 表格,请在排除合适的数据端点后使用浏览器自动化。无头浏览器可执行页面脚本并点击控件,但与 HTTP 请求相比,它会消耗更多内存并引入更多失败状态。

下文重点介绍的方案使用 Puppeteer 以及可配置的 Next 控件。请将其安装在同一项目中:

npm install puppeteer

此方案适用于常规的渲染表格。它不尝试解决以下问题:循环利用 DOM 节点的虚拟化网格、身份验证挑战,以及在不更改选择器的情况下处理任意组件框架。

等待行数据并收集每页或每次滚动批次

等待行选择器,而不是睡眠任意时长。在每页中,提取单元格矩阵,添加其记录,点击“下一步”,并等待直到矩阵发生变化。Puppeteer的官方 waitForSelector 文档中描述了用于处理第一批数据的选择器等待机制。

将此保存为 scrape-rendered.js。该代码复用了表头策略,在列位移时会报错,并在分页似乎卡住时拒绝写入文件。

const puppeteer = require("puppeteer");
const ObjectsToCsv = require("objects-to-csv");

const [
  url,
  tableSelector = "#results",
  nextSelector = 'button[aria-label="Next"]',
  outputPath = "rendered.csv",
] = process.argv.slice(2);

function clean(value) {
  return value.replace(/\u00a0/g, " ").replace(/\s+/g, " ").trim();
}

function uniqueHeaders(labels, width) {
  const seen = new Map();

  return Array.from({ length: width }, (_, index) => {
    const normalized = clean(labels[index] || "")
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, "_")
      .replace(/^_+|_+$/g, "");

    const base = normalized || `column_${index + 1}`;
    const count = (seen.get(base) || 0) + 1;
    seen.set(base, count);
    return count === 1 ? base : `${base}_${count}`;
  });
}

async function main() {
  if (!url) {
    throw new Error(
      'Usage: node scrape-rendered.js "<url>" "<table>" "<next>" "<output.csv>"'
    );
  }

  const rowSelector = `${tableSelector} tbody tr`;
  const headerSelector =
    `${tableSelector} thead tr:last-child th, ` +
    `${tableSelector} thead tr:last-child td`;

  const browser = await puppeteer.launch({ headless: true });

  try {
    const page = await browser.newPage();
    await page.goto(url, {
      waitUntil: "domcontentloaded",
      timeout: 30000,
    });
    await page.waitForSelector(rowSelector, { timeout: 15000 });

    const labels = await page.$$eval(headerSelector, (cells) =>
      cells.map((cell) => cell.textContent.replace(/\s+/g, " ").trim())
    );

    const recordsByValue = new Map();
    let headers;
    let pageNumber = 0;

    while (true) {
      pageNumber += 1;

      if (pageNumber > 1000) {
        throw new Error("Safety limit reached before pagination completed.");
      }

      const matrix = await page.$$eval(rowSelector, (rows) =>
        rows.map((row) =>
          Array.from(row.children)
            .filter((cell) => cell.matches("th, td"))
            .map((cell) => cell.textContent.replace(/\s+/g, " ").trim())
        )
      );

      if (!matrix.length) {
        throw new Error(`No rows found on rendered page ${pageNumber}.`);
      }

      if (!headers) {
        const width = Math.max(
          labels.length,
          ...matrix.map((cells) => cells.length)
        );
        headers = uniqueHeaders(labels, width);
      }

      for (const cells of matrix) {
        if (cells.length !== headers.length) {
          throw new Error(
            `Column mismatch on page ${pageNumber}: ` +
              `${cells.length} cells, expected ${headers.length}.`
          );
        }

        const record = Object.fromEntries(
          headers.map((header, index) => [header, cells[index]])
        );

        recordsByValue.set(JSON.stringify(record), record);
      }

      const next = await page.$(nextSelector);
      if (!next) break;

      const disabled = await next.evaluate(
        (element) =>
          element.disabled ||
          element.getAttribute("aria-disabled") === "true" ||
          element.classList.contains("disabled")
      );

      if (disabled) break;

      const previous = JSON.stringify(matrix);
      await next.click();

      try {
        await page.waitForFunction(
          ({ rowSelector, previous }) => {
            const current = Array.from(
              document.querySelectorAll(rowSelector)
            ).map((row) =>
              Array.from(row.children)
                .filter((cell) => cell.matches("th, td"))
                .map((cell) =>
                  cell.textContent.replace(/\s+/g, " ").trim()
                )
            );

            return current.length > 0 && JSON.stringify(current) !== previous;
          },
          { timeout: 10000 },
          { rowSelector, previous }
        );
      } catch {
        throw new Error(
          `Pagination did not advance after page ${pageNumber}; ` +
            "refusing to save a possibly partial file."
        );
      }
    }

    const records = [...recordsByValue.values()];

    if (!records.length) {
      throw new Error("No rendered records were collected.");
    }

    await new ObjectsToCsv(records).toDisk(outputPath);
    console.log(`Wrote ${records.length} unique rows to ${outputPath}`);
  } finally {
    await browser.close();
  }
}

main().catch((error) => {
  console.error(`Rendered scrape failed: ${error.message}`);
  process.exitCode = 1;
});

对于无限滚动,将“下一步”点击替换为滚动操作,并在每个批次后比较行数或行签名。仅在行数和滚动高度经过多次检查后保持不变时才停止,并设置最大通过次数的安全上限。

安全停止并去除重复记录

当“下一页”不存在或被禁用时停止,并保持页面限制。如果控件仍处于启用状态但行签名未发生变化,则报告部分分页,而不是静默保存。

该示例使用 Map。如有可用,请优先使用稳定的 ID 或规范 URL,因为基于整条记录的键可能会将合法的重复行合并。一份关于使用 JavaScript 和 Node.js 进行网页抓取的指南可帮助区分导航、提取和存储。

排查常见的表格抓取故障

在 JavaScript 中抓取 HTML 表格时,应按检索、选择、分页或导出等类别对故障进行分类。

症状

实用解决方案

Axios 解析到零行

在保存的响应中搜索已知值;若不存在,请检查网络请求或使用浏览器

未找到匹配的表格

限定一个稳定的 ID 或容器,并在错误中包含失败的选择器

列位移

检查直接单元格,并针对跨行或嵌套结构添加自定义处理

仅保存第一页

等待行签名发生变化,记录页面进度,并在控件卡顿时报错

单元格文本为空

读取相关的链接、输入或数据属性,而非仅读取文本

请求返回 403 或 429 状态码

停止请求,审查访问权限,降低并发量,并在适当情况下使用限额回退机制

CSV 列出现错误

使用经过测试的序列化器,并确保测试数据包含引号、逗号、换行符和 UTF-8 文本

添加负责任的生产环境保护措施

若在 JavaScript 中抓取 HTML 表格且超出一次性测试范围,请使用简明的生产环境检查清单:

  • 在采集前审查条款、访问规则和机器人指南。对于敏感或受监管的使用场景,需获得相应的审核批准。
  • 保持保守的并发策略,缓存未更改的页面,在 Retry-After (如适用),并通过退避机制限制重试次数。没有任何延迟是绝对安全的。
  • 仅采集必要的字段,限制访问权限,保护保留的数据,并在保留期结束后将其删除。
  • 记录状态、选择器、页码、行数和警告信息,同时排除凭据和敏感值。

法律后果可能因网站、数据、方法和管辖权而异。请将此视为一般性工程指南,而非法律建议。在权限和范围确定后,可针对“避免被封锁的网络爬取”操作手册添加运维检查机制。

关键要点

  • 在 JavaScript 中抓取 HTML 表格的最快方法是先检查响应,仅在确实需要脚本或交互时才使用浏览器。
  • 从表头中提取唯一键值,生成明确的备用方案,并标记单元格数量不匹配的情况,而不是硬编码某个网站的列数。
  • 在明确转换规则之前保留原始字符串,随后验证必需列、非空输出、警告以及 CSV 边界情况。
  • 对于渲染的分页,应等待可测量的行变化,在收到明确信号时停止,对循环设置上限,并使用有意义且稳定的键进行去重。

常见问题

为什么 DevTools 能显示 Axios 和 Cheerio 无法找到的表格行?

开发者工具显示的是浏览器执行完脚本并规范化标记后的实时 DOM,而 Axios 接收的是服务器的响应字节。客户端代码可能在加载后获取数据、插入行或替换占位符。浏览器还可能插入诸如 <tbody>,因此测试选择器时,请比较具有代表性的值并考虑 DOM 规范化因素。

当表格显示分页控件时,是否需要使用 Puppeteer?

不需要。分页控件可能会隐藏 DOM 中已存在的行、切换内存中数组的切片,或从数据端点请求另一页。仅当切换页面需要 JavaScript 交互且没有合适的端点可用时,才使用浏览器。在添加浏览器自动化之前,请检查源代码、DOM 行数和网络响应。

如何从表格单元格中提取链接或数据属性,而不是可见文本?

选择单元格内的元素并读取相关属性。使用 Cheerio 时,针对链接请使用 .attr("href") 用于链接, .attr("data-id") 用于数据属性,以及 .val() 用于表单控件。使用 new URL(relativeHref, pageUrl).href。当可见文本和属性都包含有用信息时,请将它们分别存储在不同的字段中。

我可以将解析后的记录保存为 JSON 或发送至数据库,而不是 CSV 吗?

可以。使用 JSON.stringify(records, null, 2)生成 JSON,将记录发送至 API,或插入数据库。请先验证相同的模式。对于数据库,请在适当情况下使用参数化操作、批处理或事务,并采用稳定的唯一键,以便重跑时能对记录进行“插入或更新”(upsert),避免意外生成重复记录。

结论

在 JavaScript 中可靠地抓取 HTML 表格,关键在于问题诊断,而非库的选择。 确认行数据是否存在于响应中、是否通过网络请求获取,还是需要浏览器渲染。对于静态标记,Axios 和 Cheerio 提供了一种轻量级的解决方案;而基于表头的解析器则使结果可在常规表格中复用,而非仅限于单一的固定列布局。

导出前,仅规范化展示性空格,将模棱两可的值保留为字符串,验证必填字段,检查不匹配警告,并使用恶意测试数据验证 CSV 引号处理。 对于动态表格,需等待选择器和行数据的变化,使用明确的停止条件,记录分页进度,并使用能反映数据特征的键值进行去重。正是这些检查,才将演示代码与可调试的爬虫区分开来。

请从静态脚本开始,仅在页面确实需要时才增加复杂度。如果请求层阻塞、验证码挑战或代理轮换成为瓶颈,WebScrapingAPI 的 Scraper API 可以在处理这些问题的同时返回原始 HTML,这样您就可以保留相同的 Cheerio 解析和验证代码。

关于作者

Mihai Maxim, 全栈开发工程师 @ WebScrapingAPI

Mihai Maxim

全栈开发工程师

米海·马克西姆(Mihai Maxim)是 WebScrapingAPI 的全栈开发工程师,他在产品各领域均有贡献,并协助为该平台构建可靠的工具和功能。

开始构建

准备好扩展您的数据收集规模了吗?

加入2,000多家企业,使用WebScrapingAPI在无需任何基础设施开销的情况下,以企业级规模提取网络数据。