> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zenrows.com/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> The Zenrows API (Fetch, Extract via extract=auto, and Batch) is described in OpenAPI 3.1 at https://www.zenrows.com/openapi.json and listed in the RFC 9727 API catalog at https://www.zenrows.com/.well-known/api-catalog. Use the spec for exact parameter names and types.

# Pull tables and links from any page

> Get every table, link and heading from any page with Zenrows, and save the tables as CSV files.

Add `outputs=tables` to a Fetch request and Zenrows returns every table on the page as JSON rows keyed by column heading. A few lines of Python or Node.js turn those rows into CSV files you can open in a spreadsheet. The same parameter returns links, headings, images and metadata, so one request gives you the page's structure with no selectors to write. This uses Fetch's `outputs` parameter, not Extract's `extract=auto`.

**Uses:** [Fetch](/fetch/api-reference) with [`outputs`](/fetch/features/output-filters) and [Adaptive Stealth Mode](/fetch/features/adaptive-stealth-mode) (`mode=auto`)

## Ask your AI agent

With the [Zenrows MCP server](/mcp/overview) connected to Claude, Cursor or any MCP client, paste this prompt. Your agent picks the right Zenrows tools and settings on its own.

```text theme={"dark"}
Use Zenrows to read every table on https://en.wikipedia.org/wiki/List_of_tallest_buildings
and save each table that has a header row as its own CSV file.
```

The same prompt works across hundreds of pages: list the URLs and ask for "every table from each page, as CSV".

## Get tables, links and more in one request

`outputs` takes a comma-separated list. This request asks for tables, links, headings, images and metadata at once. The response is JSON with one key per filter.

<CodeGroup>
  ```python Python (Requests) theme={"dark"}
  import json
  import requests

  params = {
      "apikey": "YOUR_ZENROWS_API_KEY",
      "url": "https://www.scrapingcourse.com/table-parsing",
      "mode": "auto",
      "outputs": "tables,links,headings,images,metadata",
  }

  response = requests.get("https://api.zenrows.com/v1/", params=params, timeout=180)
  response.raise_for_status()
  print(json.dumps(response.json(), indent=2))
  ```

  ```javascript Node.js theme={"dark"}
  const params = new URLSearchParams({
    apikey: "YOUR_ZENROWS_API_KEY",
    url: "https://www.scrapingcourse.com/table-parsing",
    mode: "auto",
    outputs: "tables,links,headings,images,metadata",
  });

  const response = await fetch(`https://api.zenrows.com/v1/?${params}`, {
    signal: AbortSignal.timeout(180_000),
  });
  if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`);

  console.log(JSON.stringify(await response.json(), null, 2));
  ```

  ```bash cURL theme={"dark"}
  curl "https://api.zenrows.com/v1/?apikey=YOUR_ZENROWS_API_KEY&url=https%3A%2F%2Fwww.scrapingcourse.com%2Ftable-parsing&mode=auto&outputs=tables,links,headings,images,metadata"
  ```

  ```bash CLI theme={"dark"}
  zenrows extract https://www.scrapingcourse.com/table-parsing --outputs tables,headings --out tables.json
  ```
</CodeGroup>

In the CLI, `zenrows extract --outputs` sends the `outputs` parameter, see the [CLI guide](/cli/extract/introduction).

The response is JSON with one key per filter, and a filter with nothing to return on a page, such as `metadata` here, is left out. Each table carries `Dimensions` (row and column counts, and whether it has a header row), `Headings` (the column names in page order) and `Content` (one object per row keyed by heading). See the returned table in [Get common data with outputs](/academy/how-to-extract-data#get-common-data-with-outputs).

## Save every table as CSV

Loop over `tables`, skip layout tables that have no header row, and write each data table to its own file. Using `Headings` as the column order keeps the columns as they appear on the page.

<CodeGroup>
  ```python Python (Requests) theme={"dark"}
  import csv
  import requests

  params = {
      "apikey": "YOUR_ZENROWS_API_KEY",
      "url": "https://www.scrapingcourse.com/table-parsing",
      "mode": "auto",
      "outputs": "tables",
  }

  response = requests.get("https://api.zenrows.com/v1/", params=params, timeout=180)
  response.raise_for_status()
  tables = response.json()["tables"]

  saved = 0
  for table in tables:
      rows = table["Content"]
      if not table["Dimensions"]["Headings"] or len(rows) < 2:
          continue  # skip layout tables with no header row
      columns = list(dict.fromkeys(table["Headings"] + [key for row in rows for key in row]))
      saved += 1
      with open(f"table-{saved}.csv", "w", newline="", encoding="utf-8") as f:
          writer = csv.DictWriter(f, fieldnames=columns)
          writer.writeheader()
          writer.writerows(rows)
      print(f"table-{saved}.csv: {len(rows)} rows, columns {columns}")
  ```

  ```javascript Node.js theme={"dark"}
  import { writeFile } from "node:fs/promises";

  const params = new URLSearchParams({
    apikey: "YOUR_ZENROWS_API_KEY",
    url: "https://www.scrapingcourse.com/table-parsing",
    mode: "auto",
    outputs: "tables",
  });

  const response = await fetch(`https://api.zenrows.com/v1/?${params}`, {
    signal: AbortSignal.timeout(180_000),
  });
  if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`);
  const { tables } = await response.json();

  // Quote a CSV cell when it holds a comma, a quote or a line break
  const cell = (value) => {
    const text = String(value ?? "");
    return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
  };

  let saved = 0;
  for (const table of tables) {
    const rows = table.Content;
    if (!table.Dimensions.Headings || rows.length < 2) continue; // skip layout tables
    const columns = [...new Set([...table.Headings, ...rows.flatMap((row) => Object.keys(row))])];
    const lines = [columns, ...rows.map((row) => columns.map((c) => row[c]))].map((r) => r.map(cell).join(","));
    saved += 1;
    await writeFile(`table-${saved}.csv`, lines.join("\n") + "\n");
    console.log(`table-${saved}.csv: ${rows.length} rows, columns ${columns.join(", ")}`);
  }
  ```
</CodeGroup>

## Example output

The script prints one line per saved file and writes the table with its page column order:

```text Output (Python) theme={"dark"}
table-1.csv: 15 rows, columns ['Product ID', 'Name', 'Category', 'Price', 'In Stock']
```

```csv table-1.csv (first rows) theme={"dark"}
Product ID,Name,Category,Price,In Stock
001,Laptop,Electronics,$999.99,Yes
002,Smartphone,Electronics,$599.99,Yes
003,Headphones,Audio,$149.99,No
```

### Pages with many tables

The same script handles pages with dozens of tables. Pointed at `https://en.wikipedia.org/wiki/List_of_tallest_buildings`, it skips the legend and navigation tables and saves each data table:

```text Output (Python, trimmed) theme={"dark"}
table-1.csv: 43 rows, columns ['Rank', 'Building', 'City', 'Country', 'Height', 'Floors', 'Built']
table-2.csv: 20 rows, columns ['Building', 'City', 'Country', 'Height', 'Floors', 'Built']
table-3.csv: 64 rows, columns ['Rank', 'Building', 'City', 'Country', 'Height (m)', 'Height (ft)', 'Floors', 'Built', 'Reference']
table-4.csv: 46 rows, columns ['Building', 'Country', 'City', 'Planned architectural height', 'Floors', 'Planned completion', 'Ref.']
```

A table without a header row comes back with `"Headings": false` in `Dimensions` and generic keys such as `column_1`, `column_2`. Keep those too when you want them: drop the `Headings` check.

## Turn links into a clean list

`outputs=links` returns every `href` on the page, as written in the HTML: relative paths, `#` anchors and repeats included. Resolve each against the page URL, drop the fragment and deduplicate. Here the catalog page's 59 links become 29 unique URLs.

<CodeGroup>
  ```python Python (Requests) theme={"dark"}
  from urllib.parse import urljoin, urldefrag, urlparse
  import requests

  PAGE = "https://www.scrapingcourse.com/ecommerce/"

  params = {
      "apikey": "YOUR_ZENROWS_API_KEY",
      "url": PAGE,
      "mode": "auto",
      "outputs": "links,images,metadata",
  }

  response = requests.get("https://api.zenrows.com/v1/", params=params, timeout=180)
  response.raise_for_status()
  data = response.json()

  links = []
  for href in data["links"]:
      url, _ = urldefrag(urljoin(PAGE, href))  # absolute, no #fragment
      if urlparse(url).scheme in {"http", "https"}:
          links.append(url)
  unique = sorted(set(links))

  site = urlparse(PAGE).netloc
  internal = [u for u in unique if urlparse(u).netloc == site]
  print(f"{len(data['links'])} links on the page, {len(unique)} unique, {len(internal)} on {site}")
  print("\n".join(internal[:8]))
  print(f"{len(set(data['images']))} images")
  print(data["metadata"])

  with open("links.txt", "w") as f:
      f.write("\n".join(unique) + "\n")
  ```

  ```javascript Node.js theme={"dark"}
  import { writeFile } from "node:fs/promises";

  const PAGE = "https://www.scrapingcourse.com/ecommerce/";
  const params = new URLSearchParams({
    apikey: "YOUR_ZENROWS_API_KEY",
    url: PAGE,
    mode: "auto",
    outputs: "links,images,metadata",
  });

  const response = await fetch(`https://api.zenrows.com/v1/?${params}`, {
    signal: AbortSignal.timeout(180_000),
  });
  if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`);
  const data = await response.json();

  const unique = new Set();
  for (const href of data.links) {
    const url = new URL(href, PAGE); // absolute
    url.hash = ""; // no #fragment
    if (url.protocol === "http:" || url.protocol === "https:") unique.add(url.href);
  }
  const links = [...unique].sort();
  const internal = links.filter((u) => new URL(u).host === new URL(PAGE).host);

  console.log(`${data.links.length} links on the page, ${links.length} unique, ${internal.length} on ${new URL(PAGE).host}`);
  await writeFile("links.txt", links.join("\n") + "\n");
  ```
</CodeGroup>

```text Output (Python, trimmed) theme={"dark"}
59 links on the page, 29 unique, 28 on www.scrapingcourse.com
https://www.scrapingcourse.com/ecommerce/
https://www.scrapingcourse.com/ecommerce/?add-to-cart=2740
https://www.scrapingcourse.com/ecommerce/?add-to-cart=2765
https://www.scrapingcourse.com/ecommerce/cart/
https://www.scrapingcourse.com/ecommerce/checkout/
https://www.scrapingcourse.com/ecommerce/my-account/
https://www.scrapingcourse.com/ecommerce/page/10/
https://www.scrapingcourse.com/ecommerce/page/11/
16 images
['viewport: width=device-width, initial-scale=1', 'robots: max-image-preview:large', 'generator: WordPress 6.4.3', 'generator: WooCommerce 8.6.1']
```

`metadata` returns each `<meta>` tag as a `name: content` string. Split on the first `: ` to get a dictionary.

## Customize it

| Change | How |
| - | - |
| Every filter at once | `outputs=*`, see [Output filters](/fetch/features/output-filters) |
| Typed numbers instead of `"$999.99"` strings | Validate the rows with a schema, see [Validate extracted data with a schema](/academy/extract/validate-with-a-schema) |
| One specific table or column | Use `css_extractor` with a selector such as `table#prices td:nth-child(2)`, see [Extract data with CSS selectors](/academy/how-to-extract-data) |
| Tables that load after the page | Use `js_render=true` with `wait_for=table` instead of `mode=auto`, see [Wait for selector](/fetch/features/wait-for) |
| Tables from thousands of pages | Put `"outputs": "tables"` in a Batch job's `zenrows_params`, see [Scrape a list of URLs](/academy/scrape-from-a-list-of-urls) |
| Every URL on a whole site | Use Crawl, see [Map every URL on a website](/academy/crawl/map-a-website) |

## Troubleshooting

* **`tables` is missing from the response**: the page has no `<table>` elements in its final HTML. Data shown in a grid of `<div>`s is read with [`css_extractor`](/academy/how-to-extract-data) instead.
* **A CSV has columns named `column_1`, `column_2`**: that table has no header row. Rename the columns in your script, or skip it with the `Headings` check above.
* **A table repeats a column heading, such as two "Height" columns in different units**: rows are keyed by heading. Read that table with `css_extractor` to get every column by position.
* **Links look like `/cart/` or `#content`**: `links` returns each `href` as written. Resolve them against the page URL as shown in [Turn links into a clean list](#turn-links-into-a-clean-list).
* **The request takes long to return**: set your HTTP client timeout to 180 seconds or more so `mode=auto` has time to escalate.

## Related recipes

<CardGroup cols={2}>
  <Card title="Extract data with CSS selectors" icon="code" href="/academy/how-to-extract-data">
    Pick exact elements and attributes with css\_extractor.
  </Card>

  <Card title="Validate extracted data with a schema" icon="checklist" href="/academy/extract/validate-with-a-schema">
    Turn price strings into numbers and Yes/No into booleans.
  </Card>

  <Card title="Map every URL on a website" icon="sitemap" href="/academy/crawl/map-a-website">
    Collect every page URL of a site with Crawl.
  </Card>

  <Card title="Turn search and listing pages into JSON" icon="list-details" href="/academy/extract/listing-pages">
    Get every item on search and category pages as JSON.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.