# PDF Text Extractor API - Text, Tables & Page Ranges (`stas_k/pdf-text-extractor`) Actor

A 200-page report, one broken link and a scan with no text layer: one of them kills the whole run, or you pay for an empty page. Here every link comes back as a row with text and tables per page, page ranges like 1-3,7, and a plain reason when it could not be read. Unreadable files are free.

- **URL**: https://apify.com/stas_k/pdf-text-extractor.md
- **Developed by:** [Stas K](https://apify.com/stas_k) (community)
- **Categories:** Developer tools, Automation
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 0.0% runs succeeded, 1 bookmarks
- **User rating**: No ratings yet

## Pricing

$10.00 / 1,000 document extracteds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

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

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
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.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## 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.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## PDF Text Extractor API: text, tables and page ranges

Give it a list of PDF links and get back the text of every document, one row per PDF,
with the page numbers you asked for and the tables found on each page.

Built for the two things that break most PDF jobs: a scan that has no text layer, and a
document that quietly costs you a run while returning nothing. Here a scan comes back as
`NO_TEXT_LAYER` with a plain reason and costs nothing, and so does a broken link, a locked
file or a document cut by the spending cap.
If every link in the list is a typo or a page that is not a PDF, the run ends as `FAILED`
with the status of each link instead of a green run with zero documents, so a successful
run always means at least one document came back. A dataset that refuses a row is retried
once with the text already extracted: one transient refusal does not cost you the whole
document.

### What you get for each document

- `status`: `EXTRACTED`, `NO_TEXT_LAYER`, `ENCRYPTED`, `INVALID_PDF`, `UNREACHABLE`,
  `TOO_LARGE`, `PRIVATE_TARGET`, `ERROR`, `NOT_CHECKED_LIMIT`, `NOT_CHECKED_CAP`,
  `NOT_CHECKED_BILLING` or `NOT_CHECKED_ERROR`.
- `pages`: one entry per page with its number, the text and the tables found on it.
- `pageCount`, `pagesRequested`, `pagesRead`, `chars`, `truncated`.
- `pagesDropped` (first 20 page numbers you asked for that are outside the document),
  `pagesDroppedCount` (how many such pages there were) and `pagesOverLimit` (how many
  pages sit above the 500-page read limit, so nothing is cut off silently).
- `reason`: a plain sentence for every status that is not `EXTRACTED`.
- The run summary keeps the first 200 charge errors in `chargeErrors`, so a broken billing
  setup is visible without flooding the store.
- `billable`: `true` when this document qualifies for the `document-extracted` event,
  i.e. its text was delivered. What the run actually charged is in the summary:
  `chargedEvents`, `deliveredUncharged` and `billingActive`.
- `billingInactive`: `true` on a row that was delivered while the run already knew it
  could not be billed (no pay-per-event pricing, or no `document-extracted` event in the
  price list), so that row cost nothing. A row whose charge call itself failed is already
  stored by then and cannot carry the flag retroactively: those rows are counted by
  `deliveredUncharged` in the summary.

### What this actor does not do

- **No OCR.** A scan without a text layer is reported, not guessed. Saying so is cheaper
  than pretending the text was read.
- **No Office files.** PDF only. DOCX, XLSX and images are out of scope here.
- **No layout reconstruction into Markdown or HTML.** You get text per page plus tables
  as rows of cells, which is what most pipelines actually feed into a model or a database.
- **Tables are detected on lined or well spaced tables.** A page with no table comes back
  with an empty list; nothing is invented.

### A bad batch is an answer, not a crash

If every link in the run is a scan or a locked file, the run still succeeds: each link has
a row saying what happened and nothing is charged. The run is marked failed only when no
document could be opened at all (for example every link returned HTTP 404), and then the
message names every status the links ended with.

### Price

You are charged once per document whose text was delivered (`document-extracted`, $0.01).
Scans, locked PDFs, broken links, files over the size limit, documents cut by
`maxDocuments`, documents stopped by the spending cap and rows whose charge failed are free.

If the platform's charge call fails twice in a row, the run stops charging: the documents
already read stay delivered and unpaid, the rest come back as `NOT_CHECKED_BILLING`. Every
row is counted once, so `requested` always equals `processed` plus `notChecked`:

- `NOT_CHECKED_CAP`: the spending cap was reached, the document was not read;
- `NOT_CHECKED_BILLING`: billing itself stopped the run before this document was read;
- `NOT_CHECKED_ERROR`: the dataset refused the row even after one retry, so that document is
  answered instead of disappearing. A single refusal does not stop the list: the run keeps
  reading the remaining links and stops only after three refusals in a row;
- `NOT_CHECKED_LIMIT`: the link sits beyond `maxDocuments`.

All of them are free. Delivery is judged by the dataset row count, never by the absence of
an exception: `deliveredUncharged` counts every row delivered without a charge,
`datasetWriteErrors` counts refused writes, `datasetWriteUnverified` counts rows whose write
could not be checked (the dataset would not report its size), and `datasetItemCount` is how
many rows this run wrote. The dataset metadata is read too and reported as
`datasetItemCountApi`; when that number was still behind at the end of the run,
`datasetItemCountLag` is true. The count of rows we wrote is never taken from a reader that
can lag behind, so a finished run never claims zero rows while the dataset holds them. A run that stored nothing fails with
`stopReason: dataset-write-error`.

A run that cannot be billed is not a stopped run: it delivers every document for free,
reports `billingActive: false` and leaves `stopReason` empty.

#### Spending cap

Apify puts a default cap of about $4.63 on a run unless you set `maxTotalChargeUsd` in the
run options. That is about 460 documents at $0.01. For bigger lists set a higher cap,
otherwise the tail comes back as `NOT_CHECKED_CAP` instead of being read.

### Limits

- 100 documents per run by default (`maxDocuments`); 0 means no limit, and the summary
  reports the number actually applied. The rest come back as `NOT_CHECKED_LIMIT` with their
  own link in `source`.
- 500 pages per document are read at most; `pages` narrows it, for example `1-3,7`.
- 50 MB per file, 45 seconds per download.
- `maxCharsPerDocument` caps the text per document and sets `truncated: true`.

### Input example

```json
{
  "urls": ["https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"],
  "pages": "1-5,8",
  "includeTables": true,
  "maxCharsPerDocument": 200000
}
```

A link that is not a URL is treated as the name of a file you uploaded to this run's
key-value store.

### Private addresses

Links that resolve to loopback, private, link-local or reserved addresses are refused
with `PRIVATE_TARGET` before any request is made, and every redirect hop is checked the
same way.

# Actor input Schema

## `urls` (type: `array`):

One PDF per line: a direct http(s) link, or the name of a file you uploaded to this run's key-value store. Up to 100 documents per run.

## `pages` (type: `string`):

Leave empty for every page. Use ranges and single pages, for example 1-3,7. At most 500 pages are read per document: pages above that are counted in pagesOverLimit, pages outside the document are counted in pagesDroppedCount and the first 20 of them are listed in pagesDropped.

## `includeTables` (type: `boolean`):

Also return tables found on each page, as rows of cells. Table detection works on lined and well-spaced tables; a page with no tables returns an empty list.

## `maxCharsPerDocument` (type: `integer`):

Text is cut at this many characters and the row is marked truncated, so one huge PDF cannot break your dataset.

## `password` (type: `string`):

Applied to every document in the run. A PDF that stays locked comes back as ENCRYPTED with a reason and is not charged.

## `maxDocuments` (type: `integer`):

The list is cut here. Documents beyond the limit come back as rows with status NOT_CHECKED_LIMIT, their own link in source, and are not charged. 0 means no limit, and the summary reports the number actually applied.

## Actor input object example

```json
{
  "urls": [
    "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
  ],
  "includeTables": true,
  "maxCharsPerDocument": 200000,
  "maxDocuments": 100
}
```

# Actor output Schema

## `dataset` (type: `string`):

One row per document: source, status, page count, per-page text, tables, characters, truncation marker, pagesDropped (first 20 page numbers outside the document), pagesDroppedCount, pagesOverLimit, the plain reason for a reject, billable (true when the text was delivered and the row qualifies for the document-extracted event), and billingInactive (true only on a row written while the run already knew it could not bill: no pay-per-event pricing or no document-extracted event in the price list). A row whose charge call itself failed is stored before the failure, so it cannot carry that flag: those rows are counted by deliveredUncharged in the summary. Statuses: EXTRACTED, NO_TEXT_LAYER, ENCRYPTED, INVALID_PDF, UNREACHABLE, PRIVATE_TARGET, TOO_LARGE, ERROR, NOT_CHECKED_CAP (the spending cap was reached), NOT_CHECKED_BILLING (billing stopped the run), NOT_CHECKED_LIMIT (beyond maxDocuments), NOT_CHECKED_ERROR (the dataset refused the row after a retry). Only EXTRACTED rows are charged.

## `summary` (type: `string`):

Run summary: requested (links sent), processed, delivered (documents whose text was delivered), rowsDelivered (every row written, one per input link), notChecked, cutByMaxDocuments, chargedEvents, billingActive, stopReason (null, spending-cap, charge-error or dataset-write-error; a run that cannot bill is not a stopped run, it reports billingActive=false with stopReason null), byStatus and chargeErrors. datasetWriteErrors counts rows the dataset refused after one retry, datasetWriteUnverified counts rows whose write could not be checked because the dataset would not report its size, datasetItemCount is how many rows the dataset holds at the end (delivery is judged by it, so a refused row is never reported as delivered). A dataset refusal does not stop the list: the run keeps reading the remaining links and stops only after three refusals in a row. A named store keeps the same object per run under RUN_SUMMARY-<runId>. deliveredUncharged counts every row delivered without a charge: the charge call failed, the platform charged nothing, or the run already knew it could not bill. requested always equals processed plus notChecked. maxDocuments is the limit actually applied (0 or less means no limit). datasetItemCount is the number of rows this run wrote (our own count, so it cannot lag); datasetItemCountApi is the same value as the platform reported it when the run finished, and datasetItemCountLag says that the platform number was still behind our own.

# 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 = {
    "urls": [
        "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("stas_k/pdf-text-extractor").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 = { "urls": ["https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"] }

# Run the Actor and wait for it to finish
run = client.actor("stas_k/pdf-text-extractor").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 '{
  "urls": [
    "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
  ]
}' |
apify call stas_k/pdf-text-extractor --silent --output-dataset

```

## MCP server setup

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

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/XITCtxP821q8CVszj/builds/HqZFLWYVnVaVfgtka/openapi.json
