# PDF Change Monitor & Linked Document Tracker (`automa-flow/pdf-link-change-monitor`) Actor

Monitor public PDFs even when their download URL changes. Give a stable webpage or a direct PDF URL and get typed link, content, page and metadata changes across runs, with last-good state that failures never overwrite.

- **URL**: https://apify.com/automa-flow/pdf-link-change-monitor.md
- **Developed by:** [Vadim Bezrukov](https://apify.com/automa-flow) (community)
- **Categories:** Automation, Developer tools
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $5.00 / 1,000 document checkeds

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?

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

## PDF Change Monitor & Linked Document Tracker

Monitor public PDFs even when their download URL changes. Give the Actor a stable
webpage or a direct PDF URL and receive structured link, content, page and
metadata changes across runs. Every target keeps its own last-good state, so a
temporary error never becomes a false "document removed" alert.

### Who needs it

- **Compliance and regulatory teams** watching guidance, forms, policies and
  standards that are republished under new file names.
- **Procurement and operations** tracking supplier price lists, manuals,
  specifications and tender documents.
- **Legal and risk** monitoring terms, privacy policies and public reports
  published as PDF.
- **Data and RAG pipelines** that must re-ingest a document only when its text
  actually changed.

### The stable page, changing PDF problem

The IRS "About Form 1040" page always links to the *current* Form 1040 PDF.
When a new revision is published the page stays put while the file, and often
the file URL, changes. A monitor keyed on the PDF URL loses the document; a
monitor keyed on the page cannot tell you what changed inside the PDF.

This Actor resolves the intended PDF from the page on every run using your
selection criteria (`linkText`, `hrefRegex`, `cssSelector`), fetches and parses
it, and compares it with the last successful observation of the **same logical
document** (`targetId`). A new URL with identical content is exactly one event:
`PDF_LINK_CHANGED`.

### Change types

| `changeTypes` value | Meaning |
| --- | --- |
| `BASELINE` | First successful observation of this `targetId` (stored; emitted unless `baselineMode=silentBaseline`). |
| `PDF_ADDED` | The page previously had no matching link (deterministic `LINK_NOT_FOUND`) and now has one. |
| `PDF_LINK_CHANGED` | The resolved PDF URL differs from the last good observation. Not a content claim. |
| `FILE_CHANGED` | The PDF bytes differ (SHA-256). Emitted alone when the text layer is identical. |
| `CONTENT_CHANGED` | The normalized text differs; `changedPages` and `textDiff` carry evidence. |
| `PAGE_ADDED` / `PAGE_REMOVED` | Page count increased / decreased. |
| `METADATA_CHANGED` | Title, author, subject, keywords, creator, producer or dates differ. |

Several change types can appear on one row. A URL-only change never produces
`CONTENT_CHANGED`; a byte-only change (re-saved file, new producer stamp) never
fabricates a semantic change.

### 30-second example

```json
{
  "monitorKey": "policy-watch",
  "targets": [
    {
      "targetId": "irs-form-1040",
      "label": "Current IRS Form 1040",
      "pageUrl": "https://www.irs.gov/forms-pubs/about-form-1040",
      "linkText": "Form 1040 PDF"
    },
    {
      "targetId": "supplier-price-list",
      "directPdfUrl": "https://supplier.example.com/files/price-list-2026.pdf"
    }
  ],
  "mode": "changesOnly"
}
```

First run: one `BASELINE` row per target. Later runs with the default
`mode: "all"`: one row per target, including `NO_CHANGE`, so every scheduled
run documents what was verified. `mode: "changesOnly"` emits rows only for
changed or unresolved targets (best for alert webhooks). Keep `monitorKey` and
`targetId` stable; that pair is the document's identity. Last-good state lives
in the named key-value store `pdf-link-change-monitor-state` in your account,
not in the run's temporary store, so scheduled runs and Tasks share it.

#### Selecting the right link

Criteria intersect. With `pageUrl` and no criteria, the page must contain exactly
one `.pdf` link.

- `linkText`: exact visible anchor text, case and whitespace insensitive.
- `hrefRegex`: regular expression against the absolute link URL, e.g.
  `"f1040\\.pdf$"`.
- `cssSelector`: descendant selector subset (`tag`, `#id`, `.class`,
  `[attr=value]`, `[attr$=value]`) that must contain or match the anchor, e.g.
  `"div.current-products a"`. Combinators `>`, `+`, `~`, `,` and pseudo-classes
  are rejected explicitly.

Zero matches is `LINK_NOT_FOUND`; more than one distinct URL is
`AMBIGUOUS_LINK`. Both rows list the `candidates` seen on the page so you can fix
the criteria. The Actor never guesses.

### Dataset output

One row per target per run. Key fields (see `examples/sample_output.json`):

| Field | Notes |
| --- | --- |
| `recordType` | `OBSERVATION` (verified document) or `UNRESOLVED` |
| `status` | `BASELINE`, `NO_CHANGE`, `CHANGED`, `LINK_NOT_FOUND`, `AMBIGUOUS_LINK`, `FAILED`, `INVALID_INPUT` |
| `targetId`, `label`, `monitorKey` | Your identity for the document |
| `parentPageUrl`, `previousPdfUrl`, `currentPdfUrl`, `finalPdfUrl`, `matchedLinkText` | Link resolution provenance; `currentPdfUrl` is the compared link, `finalPdfUrl` the redirect target that served the bytes |
| `pageCount`, `previousPageCount`, `fileSizeBytes`, `contentType`, `httpEtag`, `httpLastModified`, `pdfVersion` | Document facts |
| `metadata` | PDF info dictionary fields when present |
| `fileHash`, `contentHash`, `metadataHash`, `fingerprint` | SHA-256 fingerprints; `fingerprint` covers URL + file + content + metadata |
| `textLayerAvailable` | `false` for scanned/image-only PDFs (file-level monitoring continues, no OCR) |
| `changeTypes`, `changes`, `changedPages`, `textDiff` | Typed changes, field-level before/after, up to 50 changed pages with excerpts, bounded unified diff |
| `candidates`, `error`, `warnings` | Why a target is unresolved; non-fatal notes such as `MIME_MISMATCH` |
| `observedAt`, `previousObservedAt`, `source`, `sourceId`, `sourceUrl`, `schemaVersion` | History-ready provenance |

Dataset views: **Changes**, **Current observations**, **Errors / unresolved
targets**. `RUN_SUMMARY` and `CHECKS` in the run's key-value store carry the
per-run counts and per-target receipts.

### Scheduling and webhooks

Create a Task from your input, schedule it (daily for regulatory sources, weekly
for manuals) and add a webhook on `ACTOR.RUN.SUCCEEDED` that reads the default
Dataset. In `changesOnly` mode an empty Dataset means "verified, nothing
changed". Filter on `status == "CHANGED"` for alerts and on
`recordType == "UNRESOLVED"` for configuration or source problems.

```bash
curl -X POST "https://api.apify.com/v2/acts/automa-flow~pdf-link-change-monitor/runs?token=$APIFY_TOKEN" \
  -H "Content-Type: application/json" -d @input.json
```

### Pricing

Pay per event, charged only for verified work:

| Event | Price | When |
| --- | --- | --- |
| `apify-actor-start` | $0.005 | Platform start fee, once per GB of run memory (1 event at the default 1024 MB) |
| `document-checked` | $0.005 | One logical document fetched, validated, parsed and compared, including an unchanged check |
| `pdf-page-processed` | $0.0001 | Each page whose text was extracted and fingerprinted (text extraction is the real compute cost) |
| `pdf-mib-processed` | $0.0003 | Each started MiB downloaded for a successfully checked document (minimum 1) |

Examples at 1024 MB: one 2-page tax form is $0.0105 per run; a 492-page,
6 MiB standard is $0.0611; 10 policy PDFs of 20 pages checked daily cost about
$0.078 per run; 100 supplier documents averaging 20 pages and 3 MiB cost about
$0.80 per run. Estimate before running:
`$0.005 x GB + documents x $0.005 + pages x $0.0001 + MiB x $0.0003`,
and set `maxTotalChargeUsd` accordingly. A run that cannot pay for one document
check is rejected before any request as `BUDGET_EXCEEDED`.

Never charged: `LINK_NOT_FOUND`, `AMBIGUOUS_LINK`, failed fetches, invalid PDFs,
oversized files, invalid input, retries and restarted runs. When the platform
accepts fewer events than delivered, the run ends `BILLING_LIMIT_REACHED` with
results and state retained and no rebilling.

### Failure semantics

| Situation | Row status | State |
| --- | --- | --- |
| Page fetched, deterministic zero match | `LINK_NOT_FOUND` | untouched (remembered as absent only before any document was ever seen) |
| Several distinct links match | `AMBIGUOUS_LINK` | untouched |
| PDF URL returns 404/410 | `FAILED` (`PDF_HTTP_404`) | untouched; not a removal |
| Timeout, 429/5xx after 3 attempts, network error | `FAILED` (`HTTP_503`, `NETWORK_ERROR`, ...) | untouched |
| HTML or challenge page served as PDF | `FAILED` (`NOT_A_PDF`) | untouched |
| `pageUrl` itself serves a PDF | `FAILED` (`PAGE_IS_PDF`, use `directPdfUrl`) | untouched |
| Malformed, encrypted, oversized or too-long PDF | `FAILED` (`PDF_PARSE_FAILED`, `PDF_ENCRYPTED`, `PDF_TOO_LARGE`, `PDF_PAGE_LIMIT`) | untouched |
| Unchanged document | `NO_CHANGE` | untouched, still a billed check |

One failed target never affects the others. The run fails only when every target
failed (`SOURCE_FAILED`) or was invalid (`INVALID_INPUT`), when state could not be committed after rows were
delivered (`STATE_PERSISTENCE_FAILED`, nothing billed, next run re-emits) or on
a billing problem (`BILLING_UNCERTAIN`, `BILLING_LIMIT_REACHED`). A restarted run
never repeats Dataset rows or charges.

### Limitations, security and legal

- HTTP only: no browser, no proxy, no CAPTCHA or login handling. Sources that
  require them are reported as `FAILED` (`HTTP_403` and similar), never bypassed.
- No OCR: scanned PDFs are monitored by bytes, page count and metadata with
  `textLayerAvailable=false`.
- Retries: at most 3 attempts for 408/429/5xx and network errors with
  exponential backoff, `Retry-After` honoured, 200 retries per run.
- Bounds: 100 targets, `maxPdfSizeMb` 1-100, `maxPagesPerPdf` 1-2000, 8 MiB
  parent pages, 5 redirects, diff of 400 lines / 60 kB, 50 changed pages. Text
  above 2 MiB per document is compared by hash only (no unified diff).
- Compute: text extraction is CPU-bound and Apify grants about one CPU core per
  4096 MB. A 492-page standard took 152 s at the default 1024 MB (measured
  2026-09-14). For baskets of long documents run with 4096 MB (same cost per
  page, four times faster) or raise the run timeout; parallelism is bounded
  automatically by memory and `maxPdfSizeMb`.
- SSRF protection: only public http(s) URLs on ports 80/443; loopback, private,
  link-local, multicast, reserved and cloud metadata addresses are rejected at
  input, after DNS resolution and on every redirect.
- You are responsible for having the right to access and process each source;
  public availability does not override a publisher's terms. The Actor stores
  normalized text only to compute diffs for you and does not redistribute
  documents as a catalog. It collects no personal data beyond what the PDF
  metadata already exposes.

### API and MCP

Run it from the Apify API, the JavaScript or Python clients, or from an AI agent
through MCP: https://mcp.apify.com?tools=automa-flow/pdf-link-change-monitor.
Example agent request: "Watch the PDF linked as 'Form 1040 PDF' on
https://www.irs.gov/forms-pubs/about-form-1040 under targetId irs-form-1040 and
tell me when its content or link changes." The agent passes that as one
`targets` entry, reads `status` and `changeTypes` from the Dataset and
`RUN_SUMMARY.status` from the key-value store; the input schema, Dataset schema
and this README use the same vocabulary, so no extra mapping is needed. Runs
require the caller's own Apify authentication and are billed to that account.

# Actor input Schema

## `targets` (type: `array`):

1-100 logical documents. Each needs a stable targetId plus either pageUrl (the webpage that links to the PDF, with linkText, hrefRegex and/or cssSelector selecting exactly one link) or directPdfUrl. Zero matches is LINK\_NOT\_FOUND, several matches is AMBIGUOUS\_LINK; the Actor never guesses.

## `monitorKey` (type: `string`):

Namespace for saved state. Keep it stable for a scheduled monitor; use a different key for an independent watchlist.

## `mode` (type: `string`):

all emits one row per target per run, including NO\_CHANGE, so a scheduled run always documents what was verified. changesOnly emits only baselines, changes and unresolved targets (use for alert webhooks).

## `baselineMode` (type: `string`):

How a target seen for the first time is reported. The baseline is always stored.

## `includeTextDiff` (type: `boolean`):

Add a bounded unified diff of the normalized text on CONTENT\_CHANGED rows.

## `maxPdfSizeMb` (type: `integer`):

Larger downloads fail explicitly as PDF\_TOO\_LARGE and never touch stored state.

## `maxPagesPerPdf` (type: `integer`):

Documents with more pages fail explicitly as PDF\_PAGE\_LIMIT.

## Actor input object example

```json
{
  "targets": [
    {
      "targetId": "irs-form-1040",
      "label": "Current IRS Form 1040",
      "pageUrl": "https://www.irs.gov/forms-pubs/about-form-1040",
      "linkText": "Form 1040 PDF"
    },
    {
      "targetId": "irs-form-1040-sr",
      "label": "Current IRS Form 1040-SR (direct URL)",
      "directPdfUrl": "https://www.irs.gov/pub/irs-pdf/f1040s.pdf"
    }
  ],
  "monitorKey": "default",
  "mode": "all",
  "baselineMode": "emitBaseline",
  "includeTextDiff": true,
  "maxPdfSizeMb": 25,
  "maxPagesPerPdf": 500
}
```

# Actor output Schema

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

No description

## `runSummary` (type: `string`):

No description

## `checks` (type: `string`):

No description

# 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 = {
    "targets": [
        {
            "targetId": "irs-form-1040",
            "label": "Current IRS Form 1040",
            "pageUrl": "https://www.irs.gov/forms-pubs/about-form-1040",
            "linkText": "Form 1040 PDF"
        },
        {
            "targetId": "irs-form-1040-sr",
            "label": "Current IRS Form 1040-SR (direct URL)",
            "directPdfUrl": "https://www.irs.gov/pub/irs-pdf/f1040s.pdf"
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("automa-flow/pdf-link-change-monitor").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 = { "targets": [
        {
            "targetId": "irs-form-1040",
            "label": "Current IRS Form 1040",
            "pageUrl": "https://www.irs.gov/forms-pubs/about-form-1040",
            "linkText": "Form 1040 PDF",
        },
        {
            "targetId": "irs-form-1040-sr",
            "label": "Current IRS Form 1040-SR (direct URL)",
            "directPdfUrl": "https://www.irs.gov/pub/irs-pdf/f1040s.pdf",
        },
    ] }

# Run the Actor and wait for it to finish
run = client.actor("automa-flow/pdf-link-change-monitor").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 '{
  "targets": [
    {
      "targetId": "irs-form-1040",
      "label": "Current IRS Form 1040",
      "pageUrl": "https://www.irs.gov/forms-pubs/about-form-1040",
      "linkText": "Form 1040 PDF"
    },
    {
      "targetId": "irs-form-1040-sr",
      "label": "Current IRS Form 1040-SR (direct URL)",
      "directPdfUrl": "https://www.irs.gov/pub/irs-pdf/f1040s.pdf"
    }
  ]
}' |
apify call automa-flow/pdf-link-change-monitor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,automa-flow/pdf-link-change-monitor"
        }
    }
}
```

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/lp6HSSHqjNcqOaKmf/builds/fVlhZAQaMxjSPX2wJ/openapi.json
