# PDF Extractor: Bulk PDF to Text, Tables & Markdown from a CSV (`nerolabs/dataset-pdf-extract`) Actor

Extracts text, tables and metadata from every PDF linked in an Apify dataset, CSV, Excel or Google Sheet, keeping your original columns. Inputs: datasetId or fileUrl or pdfUrls, urlField. Scans with no text layer are flagged, not guessed. Charged per document. Agent-ready: x402, MCP.

- **URL**: https://apify.com/nerolabs/dataset-pdf-extract.md
- **Developed by:** [Adam Pearce](https://apify.com/nerolabs) (community)
- **Categories:** Developer tools, Automation, Agents
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $7.00 / 1,000 documents

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Dataset PDF Extract: text and tables from every PDF in your list

Got a spreadsheet of links to invoices, statements, contracts, reports or filings, and no way to get what is inside them into columns?

Point this Actor at an **Apify dataset, a CSV or Excel file, or a Google Sheet**. For every row it downloads the PDF, reads the text, rebuilds the tables, pulls the document's own metadata, and hands you back **every original column with the extracted fields added alongside**. No copying, no pasting, no re-joining results to your own data afterwards.

Every other PDF extractor on the Store takes a typed-in list of URLs and gives you back a bare list of text. If your links live in a spreadsheet, you have to pull the column out, paste it in, and stitch the answers back together by hand. This one takes the whole sheet.

### What you get per document

| Field | What it is |
|---|---|
| `text` | The document's text in reading order, or Markdown with the tables appended |
| `tables` | Each table rebuilt as rows of cells, with its page number |
| `pageCount`, `wordCount`, `charCount`, `tableCount` | Size of what was read |
| `hasTextLayer`, `ocrRequired` | Whether there was any text to read at all |
| `pdfTitle`, `pdfAuthor`, `pdfSubject`, `pdfCreator`, `pdfProducer` | The PDF's own metadata |
| `pdfCreatedOn`, `pdfModifiedOn` | When the document was made and last changed |
| `extractStatus`, `statusDetail` | What happened, in plain English, when it did not work |
| `fileSizeKb`, `resolvedUrl`, `textTruncated` | Useful when a link redirects or a document is enormous |

Plus every column that was already in your row.

### Tables are the point

Plain text extraction destroys table structure. A line-items table comes out as a stream of words with no idea which number belonged to which row.

This Actor reads the **position of every piece of text on the page** and rebuilds the grid: cells clustered into rows by their baseline, columns found by where cells actually line up across several rows. You get real rows of cells back, and with `textFormat: markdown` you also get proper Markdown tables appended to the text, which is the friendliest possible shape to hand to an LLM.

It is a best-effort reading of the printed layout, not a recovery of the original spreadsheet, and the README says that rather than pretending otherwise. Two deliberate decisions keep the output trustworthy:

- **Dot leaders are ignored.** A printed form joins a label to its box with a run of full stops, and each one arrives as a separate piece of text. Left alone they invent a column per dot: one real tax form produced a 27-column "table" made almost entirely of punctuation.
- **Columns of prose are not a table.** A two-column page layout is geometrically identical to a two-column table. The difference is that table cells hold values and prose holds sentences, so cell length is used to tell them apart. On a real government form this cut 35 reported "tables" down to 13 genuine ones without touching the real tables in an academic paper.

Set `minTableRows` to 2 if your invoices have a single line item, or higher to report only substantial tables.

### The honest limit: scanned documents

**A scanned or photographed PDF has no text layer. There is nothing to read, and this Actor will not pretend otherwise.**

When that happens the row comes back with `extractStatus: "no_text_layer"`, `hasTextLayer: false` and `ocrRequired: true`, so the fact travels with your data instead of living only on this page. Reading those documents needs OCR, which this Actor does not do.

Those documents are charged at the low scanned-document rate, because downloading and parsing them is real work and "this one needs OCR" is a real answer. Everything that returned nothing usable is **not charged at all**: a broken link, a 404, an HTML page instead of a file, a password-protected document, a corrupt file, a document over your size limit, or a blank cell.

### Inputs

Give it any one of these:

- **Dataset** picked from your account, for example a scraper's output
- **File or Google Sheet URL**: CSV, TSV, Excel, JSON or JSON Lines. A normal Google Sheets link works, shared as "Anyone with the link can view"
- **PDF URLs**: a plain list, for a quick one-off
- **Rows**: inline JSON

The link column is detected automatically, preferring a column whose values end in `.pdf` over one merely named `url`. Set `urlField` if you would rather be explicit.

Useful options: `firstPage` and `lastPage` to read only the front of long reports, `maxCharsPerPdf` to cap enormous documents, `includePageText` for per-page text you can cite a page number from, `keep: "problems"` to find every broken link in a large list, and `keep: "needs_ocr"` to pull out exactly the documents that need scanning elsewhere.

### What it costs

$0.01 per document that returned text, $0.002 per scanned document reported as needing OCR, $0.01 per export file, $0.02 per webhook delivery. Bronze, Silver and Gold Store discounts apply from day one.

- 500 invoices a month: **$5.00**
- 2,000 documents a month with 10% scans: **$18.40**
- A nightly run over 50 new filings: about **$15 a month**

A run costs nothing for the documents it could not read.

### Exports and pipelines

- `exportFormats` writes a real downloadable **CSV or Excel** file. Tables and per-page text are JSON-encoded into single cells so they fit a spreadsheet.
- `outputDatasetName` appends every run into one named dataset, building a growing document archive. Not charged again.
- `webhookUrl` POSTs the run summary to Slack, Zapier, Make, n8n or your own API the moment the run finishes. Charged only on a confirmed 2xx.

### FAQ

**Does it work on scanned documents?** No, and it tells you which ones they are rather than returning empty text and letting you assume it worked. See the limit above.

**Will it read a password-protected PDF?** No. Those come back as `encrypted` and are not charged.

**My links point at a landing page, not the file.** You will get `not_a_pdf` with a note saying the URL returned HTML. The row is not charged. Google Drive share links are converted to direct downloads automatically.

**Can I feed this straight into an LLM?** Yes, and `textFormat: markdown` is the shape to use: text plus proper Markdown tables. Pair it with [Dataset AI Enrich](https://apify.com/nerolabs/dataset-ai-enrich) to turn the extracted text into typed columns with one plain-English instruction.

**Does filtering make a run cheaper?** No. The document has to be fetched and read before we know whether it matches, so `keep` changes what is written, not what is charged. The input schema says so too.

**Is my data safe?** The Actor reads the documents you point it at and writes the results to your own dataset. Nothing is stored anywhere else. If you set a webhook, the run summary goes to the URL you chose and nowhere else.

### The rest of the toolkit

Nine sibling Actors that fit together as a pipeline:

- [Dataset PDF Extract](https://apify.com/nerolabs/dataset-pdf-extract) (this one) text and tables out of documents
- [Dataset Cleaner & Exporter](https://apify.com/nerolabs/dataset-cleaner-exporter) dedupe, flatten, clean, export
- [Dataset Filter & Transform](https://apify.com/nerolabs/dataset-filter-transform) filter by rule, rename, compute, cast
- [Dataset Join & Merge](https://apify.com/nerolabs/dataset-join-merge) VLOOKUP for datasets
- [Dataset Aggregate, Group By & Pivot](https://apify.com/nerolabs/dataset-aggregate-pivot) SQL GROUP BY and pivot tables
- [Dataset Diff & Change Detector](https://apify.com/nerolabs/dataset-diff-detector) what changed since last time
- [Dataset AI Enrich](https://apify.com/nerolabs/dataset-ai-enrich) one instruction, typed output columns
- [Dataset Charts & Report](https://apify.com/nerolabs/dataset-charts-report) chart images and a PDF report
- [Dataset to Postgres, Supabase & MySQL](https://apify.com/nerolabs/dataset-to-database) push rows into a real database
- [Dataset to REST API](https://apify.com/nerolabs/dataset-to-rest-api) one HTTP request per row
- [Actor Pipeline Runner](https://apify.com/nerolabs/actor-pipeline-runner) chain any of these in one call

### Notes

Text extraction uses [unpdf](https://github.com/unjs/unpdf) (MIT), a wrapper around Mozilla's [pdf.js](https://github.com/mozilla/pdf.js) (Apache-2.0). No browser, no OCR service, no API key, and no third-party service sees your documents.

If this saved you a manual copy-and-paste out of a pile of PDFs, a review on the Apify Store helps a lot. Found a document it read badly? Open an issue with the link and I will look at it.

# Actor input Schema

## `datasetId` (type: `string`):

An Apify dataset holding one row per PDF, for example the output of a scraper that collected document links. Every original column is kept and the extracted text, tables and metadata are added alongside. Use the picker rather than typing an ID.

## `fileUrl` (type: `string`):

A public link to a CSV, TSV, Excel, JSON or JSON Lines file holding one row per PDF. A normal Google Sheets link works: share it as 'Anyone with the link can view'. Used when no dataset is given.

## `fileFormat` (type: `string`):

Leave on 'Detect automatically' unless the link has no file extension and the server reports the wrong content type.

## `sheetName` (type: `string`):

Which sheet to read from an Excel workbook. Defaults to the first sheet.

## `pdfUrls` (type: `array`):

A plain list of PDF links, for a quick one-off run with no spreadsheet. Use the dataset, file or Google Sheet inputs above to keep your own columns alongside the results.

## `data` (type: `array`):

Rows as inline JSON, an alternative to a dataset or file. Each object needs a field holding the PDF link.

## `urlField` (type: `string`):

The column holding the PDF link. Left empty it is detected automatically, preferring a column whose values end in .pdf over one merely named 'url'.

## `textFormat` (type: `string`):

'Plain text' returns the reading-order text. 'Markdown' appends any detected tables as Markdown tables after the text, which is the friendliest shape for feeding an LLM. 'None' skips the text entirely, for runs that only want page counts, metadata or tables.

## `extractTables` (type: `boolean`):

Reconstruct tables from the position of the text on the page and return them as rows of cells. Plain text extraction loses table structure completely, so this is where most of the value is for invoices, statements and reports.

## `minTableRows` (type: `integer`):

Minimum rows before a grid counts as a table. The default of 3 is a deliberate noise floor, because printed forms are built from aligned two-line boxes and report dozens of meaningless two-row 'tables'. Lower it to 2 for invoices whose line items are one header plus one row.

## `includePageText` (type: `boolean`):

Add a 'pages' array with each page's text separately, on top of the combined text. Useful for citing a page number, and it makes rows considerably larger.

## `firstPage` (type: `integer`):

Start reading at this page (1 is the first). Leave empty to read from the beginning.

## `lastPage` (type: `integer`):

Stop reading after this page. Leave empty to read to the end. Handy for taking only the first few pages of long reports.

## `maxCharsPerPdf` (type: `integer`):

Cuts the returned text at this many characters so one very long document cannot produce an unusable dataset row. The row records whether it was cut.

## `keep` (type: `string`):

Filtering happens after the document has been fetched and read, so it does not make a run cheaper. 'Problems only' is the quick way to find broken links in a large list.

## `keepOriginalFields` (type: `boolean`):

Keep every column from the input row next to the extracted fields, so results line up with your own data. Turn off for text and metadata only.

## `concurrency` (type: `integer`):

How many documents to download and parse in parallel. Keep it modest when every link points at the same server.

## `requestTimeoutSecs` (type: `integer`):

How long to wait for one document before giving up on it. A timed-out document is never charged.

## `maxDocumentMb` (type: `integer`):

Documents bigger than this are skipped and not charged, rather than spending the run's memory on one enormous file.

## `maxItems` (type: `integer`):

A hard ceiling on how many rows are read from the input, as a safety net on a large dataset.

## `exportFormats` (type: `array`):

Also write the results as a real downloadable CSV or Excel file, linked from the run's output. Tables and per-page text are JSON-encoded into single cells so they fit a spreadsheet.

## `outputDatasetName` (type: `string`):

Also append every kept row to a named dataset that persists across runs, building one growing document archive. Not charged again.

## `webhookUrl` (type: `string`):

POST the run summary to this URL when the run finishes, for Slack, Zapier, Make, n8n or your own API. Charged only on a confirmed 2xx response.

## Actor input object example

```json
{
  "fileFormat": "auto",
  "pdfUrls": [
    "https://www.irs.gov/pub/irs-pdf/fw9.pdf",
    "https://www.irs.gov/pub/irs-pdf/f1040.pdf",
    "https://arxiv.org/pdf/1706.03762"
  ],
  "textFormat": "plain",
  "extractTables": true,
  "minTableRows": 3,
  "includePageText": false,
  "maxCharsPerPdf": 200000,
  "keep": "all",
  "keepOriginalFields": true,
  "concurrency": 3,
  "requestTimeoutSecs": 60,
  "maxDocumentMb": 50,
  "exportFormats": []
}
```

# Actor output Schema

## `results` (type: `string`):

Every original row with the document's text, reconstructed tables, page count and PDF metadata added.

## `pdfSummary` (type: `string`):

Statuses, page, word and table totals, how many documents need OCR, and the data note.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "fileUrl": "",
    "pdfUrls": [
        "https://www.irs.gov/pub/irs-pdf/fw9.pdf",
        "https://www.irs.gov/pub/irs-pdf/f1040.pdf",
        "https://arxiv.org/pdf/1706.03762"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("nerolabs/dataset-pdf-extract").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "fileUrl": "",
    "pdfUrls": [
        "https://www.irs.gov/pub/irs-pdf/fw9.pdf",
        "https://www.irs.gov/pub/irs-pdf/f1040.pdf",
        "https://arxiv.org/pdf/1706.03762",
    ],
}

# Run the Actor and wait for it to finish
run = client.actor("nerolabs/dataset-pdf-extract").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "fileUrl": "",
  "pdfUrls": [
    "https://www.irs.gov/pub/irs-pdf/fw9.pdf",
    "https://www.irs.gov/pub/irs-pdf/f1040.pdf",
    "https://arxiv.org/pdf/1706.03762"
  ]
}' |
apify call nerolabs/dataset-pdf-extract --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,nerolabs/dataset-pdf-extract"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/U5LveOEI8dAlRbeUC/builds/kGe1PJd323iIZ4ebf/openapi.json
