# Spanish Port Traffic Statistics Scraper (`steadfetch/puertos-estado-trafico-extractor`) Actor

Monthly Spanish port traffic statistics from Puertos del Estado as JSON: tonnes by cargo type, TEU, passengers and cruise calls, one item per port section and concept. Select a month or get the latest. Repeat runs of the same month emit zero records and charge nothing beyond the start event.

- **URL**: https://apify.com/steadfetch/puertos-estado-trafico-extractor.md
- **Developed by:** [Steadfetch](https://apify.com/steadfetch) (community)
- **Categories:** Business, Travel
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.50 / 1,000 result delivereds

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

## Spanish Port Traffic Scraper (Puertos del Estado Monthly Data)

Extract the official monthly Spanish port traffic summary published by [Puertos del Estado](https://www.puertos.es/datos/estadisticas/mensuales) (Ministry of Transport). Select a month and get one structured record per traffic concept: tonnes by cargo type, TEU, passengers, cruise calls, vessels, gross tonnage and vehicles. No browser, no login, no CAPTCHA.

### What it does

Puertos del Estado publishes a monthly workbook, `CuadrosResumen_YYYY_MM.xlsx`, linked from its public statistics listing. The Actor reads that listing, downloads the workbook for the requested month (or the latest published month) and extracts the `RESUMEN DEL TRAFICO PORTUARIO` (accented in the workbook) table from the `Resumen general` sheet.

- **Official source only:** the workbook linked from `puertos.es/datos/estadisticas/mensuales`. The Actor never uses third-party copies or scrapes the HTML tables.
- **One table in scope (v1):** the summary table with the `CONCEPTO | <month> | Acumulado desde Enero | Variacion` header. Extraction stops at the first row whose first column is only a numeric code, so the second table in the sheet is never emitted.
- **Hierarchy preserved:** `section` is column A filled down; `concept` is column B and is inherited when B is empty (for example `En contenedores` under `Mercancia general`); `breakdown` is column C.
- **Values as published:** tonnage rows carry `unit: "tonnes"`. Traffic rows that count other things keep their own unit (`TEU`, `persons`, `units`, `vehicles`, `vessels`, `GT`, `cruiseCalls`) and are never converted.
- **Monitor deduplication:** delivered `(period, section, concept, breakdown)` keys are stored in a named key-value store (`puertos-estado-trafico-state`), which persists between runs. A repeated run for the same month emits zero records and charges nothing beyond the run start.
- **Schema validation:** every record is validated against `.actor/dataset_schema.json` before it reaches the dataset. An invalid record fails the run instead of pushing malformed data.
- **Scope guard:** robots.txt is checked on every run. Only the listing and the matching `file-download` URL are requested. If the download redirects to a login page or returns a non-XLSX content type, the run aborts.

#### Data and personal information

The source is a public statistics workbook from a Spanish public administration. It contains aggregated port traffic figures only: **no personal data** is read, derived or added.

The Actor identifies itself with an honest User-Agent (`steadfetch-puertos-estado-trafico-extractor/0.1`) and makes at most three requests per run after the robots.txt check: the listing page and the XLSX file.

### Example output

```json
{
    "url": "https://www.puertos.es/file-download/download/private/6200",
    "section": "MERCANC\u00cdAS SEG\u00daN SU PRESENTACI\u00d3N",
    "concept": "Graneles l\u00edquidos",
    "breakdown": "Total",
    "unit": "tonnes",
    "period": "2026-08",
    "monthValue": 16078357,
    "monthValuePrevYear": 14913586,
    "accumValue": 123171330,
    "accumValuePrevYear": 118486641,
    "variationAbs": 4684689,
    "variationPct": 3.95,
    "publishedAt": "2026-09-22",
    "sourceUrl": "https://www.puertos.es/file-download/download/private/6200",
    "scrapedAt": "2026-10-21T03:00:00.000Z"
}
```

The Unicode escapes in this ASCII README decode to the exact accented Spanish labels published in the workbook. `url` and `sourceUrl` point at the official XLSX file. `variationPct` is rounded to two decimals. Missing source numbers are returned as `null`; no value is estimated or converted.

A full August 2026 run returns 28 records, from cargo categories to `Pasajeros (n)` and `Automoviles (uds.)`.

### Input

```json
{
    "period": "2026-08"
}
```

| Field | Type | Description |
| --- | --- | --- |
| `period` | string | Month to extract in `YYYY-MM` format. Optional: when omitted, the Actor uses the latest `CuadrosResumen_YYYY_MM.xlsx` in the listing. |

The period is cross-checked against the month label inside the workbook; a mismatch fails the run instead of labelling data with the wrong month.

### Project structure

```
.actor/actor.json                  Actor definition (payPerEvent, dataset schema, output schema)
.actor/input_schema.json           INPUT options (period)
.actor/dataset_schema.json         Item JSON Schema (fields) + table view for the Store
.actor/pay_per_event.json          PPE pricing (two synthetic platform events)
src/puertos-client.ts              Listing fetch/parse, period selection, guarded XLSX download
src/robots.ts                      robots.txt parser and per-run allow check
src/workbook.ts                    exceljs XLSX reader (isolated from pure extraction)
src/extract.ts                     Pure table extraction, hierarchy, units, cutoff (fixture-tested)
src/dedupe.ts                      Pure cross-run dedupe state for the key-value store
src/validate-output.ts             Ajv validator shared by the run and the tests
src/main.ts                        Entry point: robots, listing, download, parse, validate, push
test/                              Offline Vitest suites and the real XLSX/listing fixtures
```

### Pay-per-event pricing

The Actor uses Apify **pay-per-event** pricing with the two synthetic platform events:

| Event | Charged when | Price |
| --- | --- | --- |
| `apify-actor-start` | a run starts (synthetic, automatic) | 0.00005 USD per run (Apify-recommended default; includes 5 seconds of free compute) |
| `apify-default-dataset-item` | a new, validated concept-period record is saved to the dataset (synthetic, automatic) | 0.002 USD per record = **2.00 USD per 1,000 results** |

There is no custom per-item event and no `Actor.charge()` call: the platform already charges `apify-default-dataset-item` per pushed item, so adding another event would bill users twice. Listing and XLSX downloads, rows outside the v1 table, failed runs and records already delivered for the same period are not charged.

The 2.00 USD per 1,000 price follows the direct reference band for port and cruise data on the Apify Store (1.50-3.99 USD per 1,000) and stays inside Steadfetch's 1-10 USD band.

#### Cost per 1,000 results

Measured locally on 2026-10-21 with the real August 2026 workbook fixture, without any platform run:

- XLSX size: 1,225,444 bytes, downloaded in one request.
- Parse plus extraction: **0.59 s** total (0.59 s exceljs parse, 0.002 s pure extraction), 28 records emitted.
- Peak resident memory: **165 MB**, inside the 256 MB container limit.

The run-start event includes the first 5 seconds of compute. Even with a conservative bound of 10 seconds at 256 MB, the platform compute is a fraction of a cent; total cost is **below 0.01 USD per 1,000 records**. With 28 records per month, a month costs about 0.06 USD in result events at the 2.00 USD per 1,000 price. These are measured local figures, not a platform bill: no production run has been made before publication and the first approved smoke run will confirm them.

### Development

```bash
npm install
npm test                 # lint + typecheck + offline Vitest suites (no credit, no APIFY_TOKEN)
npm run publish:check    # Steadfetch publish gate
npm run schemas:validate # apify-cli schema validation
```

The default test suite runs entirely offline against the committed 1.2 MB XLSX and listing fixtures and never touches the Apify platform. It covers the real workbook values, the spec example row, section/concept hierarchy, per-family units, the numeric-code cutoff, Spanish number formats, month mismatch handling, listing parsing, download redirect/content-type guards, robots.txt decisions, dedupe state and output-schema validation.

### FAQ

**Does the Actor need a login or an API key?**
No. The listing and the XLSX download are public and anonymous. The `private` segment in the file-download path is the site's route name, not an authentication wall; the Actor still aborts if that route starts redirecting to a login page.

**Does it need a browser?**
No. The source is a server-rendered listing plus an XLSX file, so plain HTTP and an XLSX parser are far cheaper and faster than a browser.

**Which months are available?**
Every month published in the listing, currently January to August 2026. Ask for a `period` that is not published and the run fails with the list of available periods.

**Why do some records say `unit: "TEU"` or `"persons"` instead of tonnes?**
The summary mixes cargo weights with other traffic measures. The Actor labels each family with its own unit and never converts values. `GT` is gross tonnage, `cruiseCalls` is the number of cruise calls and `units` covers UTIS.

**How does deduplication work?**
The Actor stores the `(period, section, concept, breakdown)` key of every delivered record in the named key-value store `puertos-estado-trafico-state`, which persists between runs. The default store is per run and is purged on start, so it cannot hold monitor state. A second run for the same month finds those keys and pushes nothing, so it is not charged per record. A new month starts with an empty set.

**When am I charged?**
Once per run start (`apify-actor-start`) and once per new record saved to the dataset (`apify-default-dataset-item`). Failed or aborted runs, rows outside the v1 table and monitor duplicates are not charged.

**Is the output schema stable?**
Yes. New fields are additive and the required fields in `.actor/dataset_schema.json` do not change.

**Does it return personal data?**
No. The workbook contains aggregated port traffic statistics only, with no names, contact details or personal identifiers.

### Maintenance

This Actor is **tested daily**: automated runs check that the official Puertos del Estado listing and workbook are reachable and that the output still matches the published schema. Failures are reported as issues in the Steadfetch Actors repository. The table layout is validated on every run: if the summary header, publication date or data rows cannot be found, the run fails loudly instead of returning an empty dataset.

# Actor input Schema

## `period` (type: `string`):

Month to extract in YYYY-MM format, for example 2026-08. Defaults to the latest CuadrosResumen_YYYY_MM.xlsx published in the listing.

## Actor input object example

```json
{
  "period": "2026-09"
}
```

# Actor output Schema

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

Dataset items validated against the item schema in dataset_schema.json (fields).

# 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 = {
    "period": "2026-08"
};

// Run the Actor and wait for it to finish
const run = await client.actor("steadfetch/puertos-estado-trafico-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 = { "period": "2026-08" }

# Run the Actor and wait for it to finish
run = client.actor("steadfetch/puertos-estado-trafico-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 '{
  "period": "2026-08"
}' |
apify call steadfetch/puertos-estado-trafico-extractor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,steadfetch/puertos-estado-trafico-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/ynnKgsxagQBZW4l8e/builds/ufLSWC8vEO3M70iCb/openapi.json
