# SaaS pricing page change tracker (`quietfetch/saas-pricing-tracker`) Actor

Snapshots public SaaS pricing pages and reports price, tier and feature changes since the last run. Public pages only; robots.txt respected; ≤1 request/second per host.

- **URL**: https://apify.com/quietfetch/saas-pricing-tracker.md
- **Developed by:** [Quietfetch](https://apify.com/quietfetch) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$3.00 / 1,000 pricing page trackeds

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

## SaaS pricing page change tracker (Stream A · Actor 2)

### Spec (10 lines)

1. **What:** give it a list of public SaaS pricing pages; every run stores a structured snapshot (plans, prices, periods, per-seat/billing hints, feature bullets) and reports exactly what changed since the last run.
2. **Buyer:** teams and analysts who pay for or track many tools and want to know the week a price, tier or limit moves — without re-reading twenty pricing pages.
3. **Input:** `pricingPages` (required), `renderJs` (default on), `onlyChanged`, `snapshotStoreName`, `maxFeaturesPerPlan`, `requestDelaySecs` — `.actor/input_schema.json`.
4. **Output:** one row per page: `vendor, url, pageTitle, fetchedAt, firstSeen, previousFetchedAt, changed, diff{addedPlans, removedPlans, priceChanges[], periodChanges[], featureChanges[], mentionsChanged}, plans[], priceMentions[], hash`.
5. **Pricing:** one PPE event `page-tracked` at $0.003 ($3 / 1,000 pages), charged after the row is saved; with `onlyChanged` on, unchanged pages cost nothing.
6. **How it parses:** structure, not vendor rules — repeated sibling "cards" with a heading and a price, header-labelled comparison tables, Free/Custom marker tiers, hidden monthly/annual toggles kept as `altPrices`; every numeric price on the page is also listed in `priceMentions`, so a change is caught even when a layout defeats the plan parser.
7. **History:** previous snapshots live in a named key-value store in the *buyer's* account (`snapshotStoreName`), keyed by URL hash; the actor never sees another buyer's data.
8. **Legal floor (from the template):** public logged-out pages, robots.txt honoured, ≤1 request/second per host, no cookies, no proxy/fingerprint rotation, no CAPTCHA handling.
9. **Tests (28, no network):** price parsing, period/billing detection, three golden fixtures (cards+toggle, table, prose), diff logic, snapshot truncation, a smoke test running the real crawler three times through a local server, an opt-in Playwright run, brand consistency against `ops/brands.json`, and site-owner opt-outs.
10. **Non-goals:** logged-in pricing, per-customer quotes, currency conversion, judging whether a change is "good" — it reports; interpretation is the reader's.

### Input example

```json
{
  "pricingPages": [
    { "url": "https://vendor-a.example/pricing" },
    { "url": "https://vendor-b.example/plans" }
  ],
  "renderJs": true,
  "onlyChanged": false,
  "snapshotStoreName": "ai-tools-watchlist",
  "maxFeaturesPerPlan": 12,
  "requestDelaySecs": 1
}
```

### Output example (one dataset row, second run, price moved)

```json
{
  "url": "https://vendor-a.example/pricing",
  "vendor": "vendor-a.example",
  "pageTitle": "Vendor A — Pricing",
  "fetchedAt": "2026-09-12T06:00:04.120Z",
  "firstSeen": false,
  "previousFetchedAt": "2026-09-11T06:00:03.900Z",
  "changed": true,
  "diff": {
    "changed": true,
    "addedPlans": [],
    "removedPlans": [],
    "priceChanges": [{ "plan": "Plus", "from": "$10", "to": "$11", "fromAmount": 10, "toAmount": 11, "currency": "USD", "deltaPct": 10 }],
    "periodChanges": [],
    "featureChanges": [],
    "mentionsChanged": true
  },
  "plans": [
    { "name": "Starter", "price": { "kind": "free", "amount": 0, "currency": null, "raw": "Free" }, "period": null, "perUnit": null, "billing": null, "altPrices": [], "features": ["Up to 3 notebooks", "Basic search"] },
    { "name": "Plus", "price": { "kind": "numeric", "amount": 11, "currency": "USD", "raw": "$11" }, "period": "month", "perUnit": "user", "billing": "annual", "altPrices": [{ "amount": 12, "currency": "USD", "raw": "$12" }], "features": ["Unlimited notebooks", "Advanced search & filters"] },
    { "name": "Enterprise", "price": { "kind": "custom", "amount": null, "currency": null, "raw": "Custom" }, "period": null, "perUnit": null, "billing": null, "altPrices": [], "features": ["Dedicated success manager"] }
  ],
  "priceMentions": ["$12", "$11", "$5", "$0.50"],
  "hash": "…sha256…",
  "scrapedAt": "2026-09-12T06:00:04.120Z"
}
```

`price.kind` is `numeric`, `free` (amount 0 or the word Free) or `custom` (Custom / Contact sales). `period` is `month`, `year`, `one-time` or null; `billing` says how a displayed per-month price is actually billed (`annual` / `monthly`). `RUN_SUMMARY` in the default key-value store holds `{ saved (pages processed), charged, pagesOpened, pagesFailed, stopReason }`.

### Pricing (pay-per-event)

| Event | When charged | Price |
|---|---|---|
| `page-tracked` | after a page's row is written to the dataset | $0.003 ($3 / 1,000 pages) |

A weekly watch-list of 60 tools costs about $0.18 per run — or less with `onlyChanged`, which charges only for pages whose pricing actually moved (and first sightings). PPE events are defined in Apify Console from `.actor/pay_per_event.json`.

### Lawful and responsible use

- Fetches **public pricing pages exactly as a logged-out visitor sees them**; it never logs in, never keeps cookies, and never works around a block, CAPTCHA or rate limit — a blocked page shows up as a failed request, not as a workaround.
- **robots.txt is fetched for every host and obeyed** (`QuietfetchBot`, then `*`). Pages a site disallows are skipped and reported in the log.
- **Rate:** at most one request per second per host, two per second overall, one page per vendor per run.
- **Data:** published list prices and plan descriptions only — factual, non-personal information. No personal data is collected. Buyers are responsible for their own use of the output and for the target sites' terms.
- **Identification:** the user agent names the bot and a contact address so site owners can reach the operator. Bot token `QuietfetchBot`; contact page https://quietfetch.com/bots, mailbox hello@quietfetch.com (from `ops/brands.json` → streams.A). Site owners who want the bot to stop can block `QuietfetchBot` in robots.txt (honoured on the next request) or email the address — the Builder adds the domain to `src/optout.json` in every actor, after which that host and its subdomains are never fetched, whatever a buyer's input says (`test/optout.test.js`).

### Local development

```bash
npm install
npm test                                  # 20 tests, no network
FOUNDRY_TEST_BROWSER=1 npm test           # + the Playwright path (21)
npm run start:ppe                         # local run with a simulated PPE charging log
```

Local input: `storage/key_value_stores/default/INPUT.json`. Snapshots land in `storage/key_value_stores/<snapshotStoreName>/`.

### Maintenance notes for the Sentinel

- The nightly health run fetches one page (`health.json`) and expects ≥1 row with `plans.length ≥ 1`. A row with `plans: []` but non-empty `priceMentions` means the layout changed and the card/table heuristics need a look — open a P1, not a P0, unless `priceMentions` is empty too.
- To add a layout case: save the page HTML into `test/fixtures/`, write the golden JSON, and extend `src/extract.js`. Never special-case a vendor by name.

*Built and maintained with AI assistance under human review.*

# Actor input Schema

## `pricingPages` (type: `array`):

Public pricing page URLs, one per vendor. Each entry is {"url": "https://vendor.example/pricing"}.

## `renderJs` (type: `boolean`):

On by default because most pricing pages build their plan cards in JavaScript. Turn off for plain HTML pages to run faster and cheaper.

## `onlyChanged` (type: `boolean`):

When on, a page is written to the dataset (and charged) only on its first sighting or when its pricing changed. Off = one row per page every run.

## `snapshotStoreName` (type: `string`):

Named key-value store (in your account) that keeps the previous snapshot of every page so runs can be diffed. Use different names to track separate watch-lists.

## `maxFeaturesPerPlan` (type: `integer`):

How many feature bullets to keep per plan (0 = none).

## `requestDelaySecs` (type: `integer`):

Floor is 1 second; the actor never makes more than 2 requests per second overall.

## Actor input object example

```json
{
  "pricingPages": [
    {
      "url": "https://vendor.example/pricing"
    }
  ],
  "renderJs": true,
  "onlyChanged": false,
  "snapshotStoreName": "saas-pricing-snapshots",
  "maxFeaturesPerPlan": 12,
  "requestDelaySecs": 1
}
```

# Actor output Schema

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

One row per pricing page: vendor, url, pageTitle, fetchedAt, firstSeen, previousFetchedAt, changed, diff{addedPlans, removedPlans, priceChanges, periodChanges, featureChanges, mentionsChanged}, plans\[], priceMentions\[], hash. The 'changes' dataset view shows the summary columns.

## `changesView` (type: `string`):

The same dataset, pre-filtered to the summary columns (vendor, changed, firstSeen, fetchedAt, previousFetchedAt, url).

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

RUN\_SUMMARY record in the default key-value store: { saved, charged, pagesOpened, pagesFailed, stopReason }.

# 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 = {
    "pricingPages": [
        {
            "url": "https://vendor.example/pricing"
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("quietfetch/saas-pricing-tracker").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 = { "pricingPages": [{ "url": "https://vendor.example/pricing" }] }

# Run the Actor and wait for it to finish
run = client.actor("quietfetch/saas-pricing-tracker").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 '{
  "pricingPages": [
    {
      "url": "https://vendor.example/pricing"
    }
  ]
}' |
apify call quietfetch/saas-pricing-tracker --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,quietfetch/saas-pricing-tracker"
        }
    }
}
```

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/lUFvjtdCsRsuQbLzJ/builds/W2eKuE5A486VbB5f0/openapi.json
