# Salta Tenders Scraper & Monitor (`stefano_seggio/salta-compras-monitor`) Actor

Extracts public tenders and contract awards (contrataciones, adjudicaciones) from the Province of Salta, Argentina official procurement portal, with organism, expediente and opening-date detail per process.

- **URL**: https://apify.com/stefano\_seggio/salta-compras-monitor.md
- **Developed by:** [Stefano Seggio](https://apify.com/stefano_seggio) (community)
- **Categories:** Business, Lead generation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per event

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

## Salta Tenders Scraper & Monitor — Apify Store Overview

**Actor ID:** `Tx9wBKZyySZa5WcsE` · **Store URL:** https://apify.com/stefano\_seggio/salta-compras-monitor · **Version:** 2.0

***

### Executive Summary & Business Use Case

Salta Tenders Scraper & Monitor extracts every public tender and contract award (*contrataciones*, *adjudicaciones*) listed on the Province of Salta, Argentina's official public procurement portal, `compras.salta.gob.ar`. It walks the portal's currently-open ("*vigentes*") publications listing — a plain server-rendered PHP page, no browser or proxy required — and, on request, follows each publication through to its own detail page for a richer field set: the buying organism, the *expediente* (file number), the exact opening date and time, where to consult or purchase the bid documents, and direct links to attached documents such as the *pliego* (bid specification) and *cotización* forms. Because the source portal has no historical changelog or amendment feed of its own, this actor also runs a delta engine that persists what it has seen across runs and reports which publications are genuinely new, which were amended, and which have quietly closed.

The data supports three concrete groups of buyers. First, **suppliers to provincial organisms** — construction, health, IT, and general-services vendors who sell into the Salta public sector — use the `organismo` and `objeto` fields to see who is buying what, and rely on `event_type=UPDATED` to catch a changed deadline or amended bid document before they submit an offer that is already out of date. Second, **bid consultants and *gestores*** who track tenders on behalf of several clients simultaneously use `event_type=CLOSED` and `event_type=UPDATED` as a daily monitoring feed, so they can tell a client a process has closed (rather than continuing to prepare an offer for something no longer open) or that terms changed since the last check. Third, **regional tender-data resellers and LATAM procurement-intelligence platforms** use the actor as a structured, change-aware substitute for building and maintaining their own screen-scraper against one of dozens of Argentine provincial procurement sites — the full envelope (`record_id`, `event_type`, `scraped_at`, `is_new`, `source_url`, `contentHash`) gives them exactly the fields needed to merge Salta into a larger multi-province feed. Note that this source portal exposes no monetary "amount" or "budget" field on the listing or detail page as a first-class field, so this data is best suited to tracking *who is buying what and when*, and *whether a tracked process has changed or closed* — not to value-based spend analytics.

### Technical Features & V2 Architecture Highlights

**Architecture.** The actor is a plain `fetch` + Cheerio scraper — no headless browser, no proxy. This was verified live against `compras.salta.gob.ar`: the homepage, a paginated listing page, and a detail page were all grepped for `ASPxGridView|dxgv|DevExpress|UpdatePanel|ScriptManager|__VIEWSTATE|react|angular|vue|id="root"` and returned zero matches — the tender data arrives fully formed in the initial server-rendered HTML, served as `Content-Type: text/html; charset=UTF-8` with correct round-tripping of accented Spanish characters. The only frontend JavaScript present drives the filter form's UX, not the data itself.

**Cross-run delta persistence.** When `onlyNew` is enabled, the actor opens a **named key-value store, `salta-compras-monitor-delta-state`** — deliberately not the run's own default key-value store, which is isolated per run and would not survive between scheduled runs. This store holds, per publication id, a fingerprint hash plus enough display fields (`titulo`, `tipoPublicacion`, `numeroPublicacion`, `organismo`) to describe a record even after it has left the source's *vigentes* list and can no longer be re-fetched.

**Event types are domain-specific, not the fleet's generic default.** This actor's `event_type` field takes one of **four** real values, per `dataset_schema.json`: `NEW_LISTING` (never seen before), `UPDATED` (a fingerprinted field changed since the last run), `UNCHANGED` (delivered only on a plain run with `onlyNew` off), and `CLOSED` (a previously-seen publication is absent from a complete walk this run — no longer *vigente*; the source does not say why). Notably, this actor's build history explicitly considered and **rejected** an HSE-style two-way split (e.g. a "SANCTION"-equivalent category driven off `tipoPublicacion`, which distinguishes *Licitación Pública*, *Contratación Abreviada*, and *Adjudicación Simple* publications) — reasoned out in `AGENTS.md` as an unjustified taxonomy, since every publication type here comes from the one listing endpoint and shares one schema; an *Adjudicación Simple* appearing in the currently-open feed is still an open, actionable listing, not a resolved legal outcome the way a closed conviction record is.

**What `onlyNew` actually does**, per the actor's own `input_schema.json`: "Delta mode for daily/recurring monitoring: returns only publications that are new, changed (a fingerprinted amendment — a new monto, document, date...) or closed since a previous run. Seen ids are persisted across runs in a named key-value store unique to this actor... Pagination is NOT stopped early once already-seen records are reached: this actor's listing is sorted by Fecha/Hora Apertura (the bid-opening deadline), not by when a publication was created, so a genuinely new publication can appear on any page — `onlyNew` filters after a full fetch up to `maxItems` instead of short-circuiting it." This is a deliberate, verified design choice: a live check found publications with *lower* source ids sitting on the *last* page of the walk simply because their bid-opening date falls years in the future, which rules out any early-stop optimization based on "N consecutive already-seen pages."

**`CLOSED` detection is gated on a complete walk.** Because a partial walk (one truncated by `maxItems`) cannot prove a missing id actually closed rather than simply sitting past where the walk stopped, `CLOSED` is only ever computed when this run's listing walk was not truncated — `maxItems` needs to be raised above the real register size (currently roughly 250–300 open publications; the actor's own description notes "the portal serves ~250 currently-open (vigentes) publications at a time, 5 per page") to enable it on a recurring monitor.

**Fingerprinting.** `contentHash` is a sha1 fingerprint (computed in `src/fingerprint.ts`) over every listing-card field, plus — when `fetchDetail` is on — the detail page's field bag, `pdfUrl`, and attachments. This is what makes `UPDATED` detection possible, and it comes at a real, disclosed cost: detecting an amendment on an already-known publication requires re-fetching that publication's detail page again, not just a new one's — so `fetchDetail: true` re-fetches detail for *every* walked item this run (new and previously seen), bounded by `maxItems`, not only the items that ultimately get delivered after `onlyNew`/`eventTypes`/`dateRange` filtering. `fetchDetail: false` remains a lighter, listing-only path that never pays this cost.

**Engineering rigor worth noting for a technical buyer:** the CHANGELOG records a double-charge bug caught and fixed *before* shipping v2.0 — an earlier draft called both `Actor.pushData(record, eventName)` (which performs the PPE charge itself) and a separate `Actor.charge(...)` afterward, which would have billed every delivered record twice. This was caught by checking the installed `apify` SDK's own `.d.ts` rather than assumed from memory.

The dataset schema itself defines **17 top-level fields** (`record_id`, `titulo`, `tipoPublicacion`, `numeroPublicacion`, `fechaApertura`, `horaApertura`, `objeto`, `organismo`, `expediente`, `consultaPliego`, `consultas`, `event_type`, `is_new`, `contentHash`, `source_url`, `detail`, `scraped_at`), where `detail` is itself a nested object (`fields`, `pdfUrl`, `archivosAdjuntos`) carrying the richer, per-publication detail-page data when `fetchDetail` is enabled.

### Input Schema & JSON Configuration Example

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `fetchDetail` | boolean | `true` | "When enabled (default), fetches each publication's detail page for the full field set (organismo gestor, costo pliego, lugar de entrega, when present) plus attached document links. Disable for a faster, listing-only run." |
| `maxItems` | integer | `100` | "Hard cap on the number of publications returned this run. The portal serves ~250 currently-open (vigentes) publications at a time, 5 per page." (minimum: `1`) |
| `onlyNew` | boolean | `false` | "Delta mode for daily/recurring monitoring: returns only publications that are new, changed (a fingerprinted amendment - a new monto, document, date...) or closed since a previous run. Seen ids are persisted across runs in a named key-value store unique to this actor ("salta-compras-monitor-delta-state"), not the run's own default store (which is isolated per run and would not survive between scheduled runs). Every record still carries `is_new`, so a normal (onlyNew=false) run also tells you which of its results are new. Pagination is NOT stopped early once already-seen records are reached..." |
| `eventTypes` | array (enum, multi-select) | `["NEW_LISTING", "UPDATED", "CLOSED"]` | "Which kinds of change to deliver when 'Only new' is on (ignored, everything delivered, when it is off). NEW\_LISTING = never seen before. UPDATED = seen before, a field changed (fingerprinted). CLOSED = a previously-seen publication is absent from a complete walk this run - no longer vigente. CLOSED detection only runs when maxItems does not truncate the walk..." |
| `dateRange` | string (enum: `"24h"` | `"7d"` | `"30d"`) | *(none)* | "Filters results to publications whose own Fecha/Hora Apertura (bid-opening deadline - the only date field this source exposes anywhere, listing or detail; there is no separate publication date) falls within the given window. For the "vigentes" (currently open) publications this actor covers, that date is almost always in the FUTURE relative to when this actor runs, so this is an upcoming-deadline filter ("opens within the next 24h/7d/30d"), not a "published in the last N" filter. Independent of onlyNew." |

A full recurring-monitor configuration, valid against `input_schema.json`:

```json
{
  "fetchDetail": true,
  "maxItems": 300,
  "onlyNew": true,
  "eventTypes": ["NEW_LISTING", "UPDATED", "CLOSED"],
  "dateRange": "30d"
}
```

A minimal one-off pull (all defaults except a lower cap):

```json
{
  "fetchDetail": true,
  "maxItems": 100
}
```

### Output Dataset Sample & Data Dictionary

| Field | Type | Description |
| --- | --- | --- |
| `record_id` | string | Publication id — the source portal's own id, reused as-is. |
| `titulo` | string | Full header as shown on the portal (e.g. `"Adjudicación Simple N° 98/2026"`). |
| `tipoPublicacion` | string | Publication type, parsed from the header (e.g. `"Adjudicación Simple"`, `"Contratación Abreviada"`, `"Licitación Pública"`). |
| `numeroPublicacion` | string | Number as published — raw free text, not a structured number/year pair. |
| `fechaApertura` | string | Bid-opening date (`DD/MM/YYYY` as printed by the source). |
| `horaApertura` | string | Bid-opening time. |
| `objeto` | string | Subject / short description of the process. |
| `organismo` | string | Organismo Originante y Destino — the buying organism. |
| `expediente` | string | File number (*expediente*). |
| `consultaPliego` | string | Where to consult or purchase the bid documents (Consulta y Adquisición Pliego). |
| `consultas` | string | Contact information for questions about the process. |
| `event_type` | string | `NEW_LISTING`, `UPDATED`, `UNCHANGED` (only when `onlyNew` is off), or `CLOSED` (no longer *vigente*). |
| `is_new` | boolean | `true` if `record_id` was not seen by a previous run — computed even when `onlyNew` is off. |
| `contentHash` | string | sha1 fingerprint of this record's changeable fields, used to detect `UPDATED` between runs. |
| `source_url` | string | Link to the official detail page for this publication. |
| `detail` | object | null | Present when `fetchDetail` is on. Contains `fields` (an object of labeled detail-page fields — e.g. Organismo Gestor, Costo Pliego — whose exact set varies per publication), `pdfUrl` (the portal's own auto-generated PDF of the publication, or `null`), and `archivosAdjuntos` (an array of `{ nombre, url }` uploaded attachments). |
| `scraped_at` | string | ISO timestamp of this run's extraction — the same value for every record from one run. |

A realistic dataset record (illustrative values; every field name above is drawn directly from `dataset_schema.json`):

```json
{
  "record_id": "148437",
  "titulo": "Adjudicación Simple N° 98/2026",
  "tipoPublicacion": "Adjudicación Simple",
  "numeroPublicacion": "98/2026",
  "fechaApertura": "12/09/2026",
  "horaApertura": "10:00",
  "objeto": "Adquisición de insumos de librería para Hospital Público Materno Infantil",
  "organismo": "Ministerio de Salud Pública",
  "expediente": "EXP-2026-148437",
  "consultaPliego": "Dirección General de Contrataciones, Av. Belgrano 1349, Salta Capital",
  "consultas": "compras@salta.gob.ar",
  "event_type": "UPDATED",
  "is_new": false,
  "contentHash": "7f3a9c1e5b8d2046f9a3c7e1b5d9f204a8c6e3b1",
  "source_url": "https://compras.salta.gob.ar/publico/publicacionactual/verpublicacion1/148437/0",
  "detail": {
    "fields": {
      "Organismo Gestor": "Ministerio de Salud Pública",
      "Costo Pliego": "$0,00",
      "Expediente": "EXP-2026-148437"
    },
    "pdfUrl": "https://compras.salta.gob.ar/publico/publicacionactual/verpublicacionpdf/148437",
    "archivosAdjuntos": [
      { "nombre": "Pliego de Condiciones.pdf", "url": "https://compras.salta.gob.ar/adjuntos/148437/pliego.pdf" }
    ]
  },
  "scraped_at": "2026-09-08T14:32:10.000Z"
}
```

### Multi-language Integration Snippets

**cURL**

```bash
curl "https://api.apify.com/v2/acts/stefano_seggio~salta-compras-monitor/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "fetchDetail": true,
        "maxItems": 300,
        "onlyNew": true,
        "eventTypes": ["NEW_LISTING", "UPDATED", "CLOSED"],
        "dateRange": "30d"
      }'
```

**Python (`apify-client`)**

```python
import os
from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])

run = client.actor("stefano_seggio/salta-compras-monitor").call(
    run_input={
        "fetchDetail": True,
        "maxItems": 300,
        "onlyNew": True,
        "eventTypes": ["NEW_LISTING", "UPDATED", "CLOSED"],
        "dateRange": "30d",
    }
)

for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(item["record_id"], item["event_type"], item["organismo"], item["objeto"])
```

**Node.js (`apify-client`)**

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

const client = new ApifyClient({ token: process.env.APIFY_TOKEN });

const run = await client.actor('stefano_seggio/salta-compras-monitor').call({
    fetchDetail: true,
    maxItems: 300,
    onlyNew: true,
    eventTypes: ['NEW_LISTING', 'UPDATED', 'CLOSED'],
    dateRange: '30d',
});

const { items } = await client.dataset(run.defaultDatasetId).listItems();
for (const item of items) {
    console.log(item.record_id, item.event_type, item.organismo, item.objeto);
}
```

### Pricing Model Explanation

This actor bills per event ("pay-per-event"), with platform usage included in the event price — there is no separate compute charge on top.

| Event | Price | When it fires on this actor |
| --- | --- | --- |
| `result` | **$0.003** per record | A record this run fetched **fresh detail** for — either a brand-new publication or an amended one, whenever `fetchDetail: true`. |
| `result-summary` | **$0.001** per record | A **listing-only** record (run with `fetchDetail: false`), **or** a `CLOSED` record — a `CLOSED` publication has nothing left to re-fetch (it's gone from the source), so it is always billed at the lower tier regardless of the `fetchDetail` setting. |
| `apify-actor-start` | **$0.00005** | Once per run. |

The two-tier split here is specifically about **how much work went into producing that record**, not about "new vs. old": a record only bills at the higher $0.003 `result` tier when this run actually reached out to that publication's own detail page and pulled its full field set (organism, *expediente*, attached documents). A record produced from the listing page alone — because `fetchDetail` was turned off, or because the publication was determined `CLOSED` and there is no detail page left to fetch — bills at the lower $0.001 `result-summary` tier. A daily monitor of the roughly 250-publication register that finds 5 changes costs about $0.02/day (~$0.60/month); a one-off full pull with detail costs about $0.75, per the actor's own documented pricing example.

**On `onlyNew` and unchanged records:** when `onlyNew: true`, a publication that is still open and has not changed since the last run is classified `UNCHANGED` internally — and `UNCHANGED` is only ever a deliverable `event_type` when `onlyNew` is **off**. In delta mode, an unchanged record is simply never pushed to the dataset at all. Concretely, that means it is never billed: not as a $0.001 `result-summary` "unchanged" event, not at any reduced rate — it is filtered out before `Actor.pushData()` is ever called for it, so no chargeable event is created for it in the first place. You only pay for records this run actually delivers: new listings, real amendments, and confirmed closures (narrowed further by whichever subset of `eventTypes` you selected).

# Actor input Schema

## `fetchDetail` (type: `boolean`):

When enabled (default), fetches each publication's detail page for the full field set (organismo gestor, costo pliego, lugar de entrega, when present) plus attached document links. Disable for a faster, listing-only run.

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

Hard cap on the number of publications returned this run. The portal serves ~250 currently-open (vigentes) publications at a time, 5 per page.

## `onlyNew` (type: `boolean`):

Delta mode for daily/recurring monitoring: returns only publications that are new, changed (a fingerprinted amendment - a new monto, document, date...) or closed since a previous run. Seen ids are persisted across runs in a named key-value store unique to this actor ("salta-compras-monitor-delta-state"), not the run's own default store (which is isolated per run and would not survive between scheduled runs). Every record still carries is\_new, so a normal (onlyNew=false) run also tells you which of its results are new. Pagination is NOT stopped early once already-seen records are reached: this actor's listing is sorted by Fecha/Hora Apertura (the bid-opening deadline), not by when a publication was created, so a genuinely new publication can appear on any page - onlyNew filters after a full fetch up to maxItems instead of short-circuiting it. See AGENTS.md for the live evidence behind this.

## `eventTypes` (type: `array`):

Which kinds of change to deliver when 'Only new' is on (ignored, everything delivered, when it is off). NEW\_LISTING = never seen before. UPDATED = seen before, a field changed (fingerprinted). CLOSED = a previously-seen publication is absent from a complete walk this run - no longer vigente. CLOSED detection only runs when maxItems does not truncate the walk (a partial walk cannot prove absence) - see AGENTS.md.

## `dateRange` (type: `string`):

Filters results to publications whose own Fecha/Hora Apertura (bid-opening deadline - the only date field this source exposes anywhere, listing or detail; there is no separate publication date) falls within the given window. For the "vigentes" (currently open) publications this actor covers, that date is almost always in the FUTURE relative to when this actor runs, so this is an upcoming-deadline filter ("opens within the next 24h/7d/30d"), not a "published in the last N" filter. Independent of onlyNew.

## Actor input object example

```json
{
  "fetchDetail": true,
  "maxItems": 100,
  "onlyNew": false,
  "eventTypes": [
    "NEW_LISTING",
    "UPDATED",
    "CLOSED"
  ]
}
```

# Actor output Schema

## `results` (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 = {};

// Run the Actor and wait for it to finish
const run = await client.actor("stefano_seggio/salta-compras-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 = {}

# Run the Actor and wait for it to finish
run = client.actor("stefano_seggio/salta-compras-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 '{}' |
apify call stefano_seggio/salta-compras-monitor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,stefano_seggio/salta-compras-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/Tx9wBKZyySZa5WcsE/builds/5zTExOsfam1mLBxH2/openapi.json
