--- name: singapore-port-statistics-scraper description: Read the Maritime and Port Authority of Singapore's monthly port statistics as structured records via the Apify Actor arman-bd/singapore-port-statistics-scraper. Returns one record per month (or per vessel type, for the breakdown series) with container throughput in thousand TEUs, vessel arrival counts and gross tonnage, and Singapore Registry of Ships counts and tonnage, back to 1995. Use for trade-flow proxies, port-equity comparables, bunkering and commodity desks, macro dashboards, and flag-state/registry research. Not for daily or weekly figures, non-Singapore ports, or non-MPA metrics like berth occupancy or waiting times. --- # Singapore Port Statistics Scraper Apify Actor `arman-bd/singapore-port-statistics-scraper`. Pick which MPA series you want, get one dataset record per month (or per vessel type on the breakdown series). The Port of Singapore is the world's second-largest container port and its largest bunkering port, and this series runs monthly with no gaps back to 1995. It runs without credentials, and with no input at all it returns the full history of all four built-in series. ## When to use it - A trade-flow or transhipment proxy: container throughput is a standard Asia-Pacific trade indicator. - Port-equity or comparables research: PSA Singapore volumes against other major container ports. - Bunker and commodity desks tracking vessel activity at the world's largest bunkering port. - Macro dashboards needing a long, gap-free monthly series back to 1995. - Flag-state or maritime-registry research using Singapore Registry of Ships counts and tonnage. ## When not to use it - Daily or weekly figures. MPA publishes monthly; there is no finer granularity at the source. - Any port other than Singapore. - Berth occupancy, waiting times, port calls by individual vessel/IMO number, or freight rates. This Actor covers only the four MPA series named below. - Real-time or near-real-time tracking. The latest month available typically lags the calendar by 2-3 months. ## Call it ```js import { ApifyClient } from 'apify-client'; const client = new ApifyClient({ token: process.env.APIFY_TOKEN }); const run = await client.actor('arman-bd/singapore-port-statistics-scraper').call({ datasets: ['container_throughput', 'vessel_arrivals_total'], fromMonth: '2015-01', toMonth: '2025-12', maxRecordsPerDataset: 0, }); const { items } = await client.dataset(run.defaultDatasetId).listItems(); const { value: summary } = await client .keyValueStore(run.defaultKeyValueStoreId) .getRecord('RUN_SUMMARY'); ``` One-shot over HTTP, when you want the rows back in the same request: ```bash curl -X POST "https://api.apify.com/v2/acts/arman-bd~singapore-port-statistics-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"datasets":["container_throughput"],"fromMonth":"2015-01"}' ``` The Actor is also exposed through Apify's MCP server as `arman-bd/singapore-port-statistics-scraper`, so an MCP-capable agent can call it with no extra wiring. ## Input | Field | Type | Required | Default | Notes | |---|---|---|---|---| | `datasets` | string[] | no | all four | `container_throughput`, `vessel_arrivals_total`, `vessel_arrivals_by_type`, `registered_vessels_tonnage`. An empty list means all four. | | `resourceIds` | string[] | no | `[]` | Any other data.gov.sg CKAN datastore resource id (a `d_...` string), read the same way and labelled `Custom` in the output. Field recognition is best-effort: only `month`, `container_throughput`, `number_of_vessels`, `gross_tonnage` and `vessel_type` are mapped, matching the four built-in series' conventions. | | `fromMonth` | string | no | none | Earliest month to keep, `YYYY-MM`. Applied client-side after fetching; the source has no date-range query parameter. | | `toMonth` | string | no | none | Latest month to keep, `YYYY-MM`. | | `maxRecordsPerDataset` | integer | no | `0` (no limit) | Caps rows saved per dataset. When the cap is smaller than the full series, the **most recent** months are kept, not the oldest. | **`fromMonth`/`toMonth` and `maxRecordsPerDataset` compose.** The month window is applied first, then the cap trims from the end of whatever the window returned. A window that matches no published months returns zero rows for that dataset without being treated as a failure. ## Output One record per month, except `vessel_arrivals_by_type` which is several rows per month (one per vessel category). Every dataset shares the same record shape; fields that don't apply to a given series are `null` rather than omitted, so the shape is uniform for CSV and Excel export. | Field | Type | Notes | |---|---|---| | `dataset` | string | `container_throughput`, `vessel_arrivals_total`, `vessel_arrivals_by_type`, `registered_vessels_tonnage`, or the caller-supplied resource id for a custom dataset. | | `datasetLabel` | string | Human-readable series name (`Custom` for a caller-supplied resource id). | | `resourceId` | string | The underlying data.gov.sg resource id. | | `id` | number | The source's own row id. Stable across runs; safe to diff on. | | `month` | string | Reporting month, `YYYY-MM`. No timezone; it names a calendar month, not an instant. | | `vesselType` | string \| null | Vessel category. Set only on `vessel_arrivals_by_type` rows. | | `containerThroughputThousandTeu` | number \| null | Container throughput, thousand TEUs. Set only on `container_throughput` rows. | | `numberOfVessels` | number \| null | Vessel count for the month. Set on every series except `container_throughput`. | | `grossTonnageThousandGt` | number \| null | Gross tonnage, thousand GT. Set on every series except `container_throughput`. | | `scrapedAt` | string | Run timestamp, ISO 8601. | A real record from `container_throughput`: ```json { "dataset": "container_throughput", "datasetLabel": "Container throughput", "resourceId": "d_da030f7028200d19ffcbe4a2d71af39c", "id": 377, "month": "2026-05", "vesselType": null, "containerThroughputThousandTeu": 3942.74, "numberOfVessels": null, "grossTonnageThousandGt": null, "scrapedAt": "2026-08-21T22:47:54.897Z" } ``` A real record from `vessel_arrivals_by_type`, same shape: ```json { "dataset": "vessel_arrivals_by_type", "datasetLabel": "Vessel arrivals, by vessel type", "resourceId": "d_8f264219109e61fffa87ac64dd5a9a65", "id": 3016, "month": "2026-05", "vesselType": "Miscellaneous", "containerThroughputThousandTeu": null, "numberOfVessels": 883, "grossTonnageThousandGt": 13723.56, "scrapedAt": "2026-08-21T22:47:54.897Z" } ``` ## RUN_SUMMARY Written to the run's key-value store under the key `RUN_SUMMARY`. **Read it.** It is where a partial run admits that it was partial. ```json { "datasetsRequested": ["container_throughput", "vessel_arrivals_total"], "datasetsFailed": 0, "failures": [], "perDataset": [ { "dataset": "container_throughput", "resourceId": "d_da030f7028200d19ffcbe4a2d71af39c", "totalAvailableInSource": 377, "saved": 377 }, { "dataset": "vessel_arrivals_total", "resourceId": "d_d48c5a038904f6da3c603cd854b6c191", "totalAvailableInSource": 377, "saved": 377 } ], "recordsSaved": 754, "filters": { "datasets": ["container_throughput", "vessel_arrivals_total"], "resourceIds": [], "fromMonth": "", "toMonth": "", "maxRecordsPerDataset": 0 }, "finishedAt": "2026-08-21T22:48:04.525Z" } ``` `perDataset[].totalAvailableInSource` is the full row count MPA has published for that series, independent of any filter or cap you applied, so it is the number to compare `saved` against to confirm you got everything. `datasetsFailed` counts entries in `failures`, which includes both an unrecognised dataset key and a resource id that failed to fetch. `recordsSaved` equals the dataset item count for the run. ## Behaviour to plan around - **Every series shares one record shape.** Fields a given series doesn't produce are `null`, not omitted, specifically so a run mixing series still exports cleanly to CSV or Excel. - **The month filter runs after fetching, not as a source-side query.** The source has no date-range parameter, so `fromMonth`/`toMonth` are applied to what was already downloaded. This costs nothing extra at these series' sizes (a few hundred to a few thousand rows) but means a narrow window is not a cheaper fetch. - **`maxRecordsPerDataset` keeps the newest rows, not the oldest,** once any month window has already been applied. If you want the oldest months instead, set `fromMonth` and leave the cap at `0`. - **`vessel_arrivals_by_type` is not one row per month.** It carries roughly 8 rows per month, one per vessel category, so its row count and the meaning of `maxRecordsPerDataset` differ from the other three series. - **A caller-supplied resource id is mapped best-effort.** Only field names matching the four built-in series (`month`, `container_throughput`, `number_of_vessels`, `gross_tonnage`, `vessel_type`) are recognised; a table with different field names comes through with mostly `null` values outside `dataset`, `resourceId`, `id` and `month`. - **One bad dataset key or resource id does not sink the whole run.** It is recorded in `RUN_SUMMARY.failures` and the rest proceed. The run only throws when every requested dataset failed. - **Transient fetch errors are retried automatically** with backoff before being recorded as a failure. A dataset that truly doesn't exist (404) fails immediately rather than retrying. ## Recipes **Full historical archive, once.** Every series, no filter, no cap. ```json {} ``` About 4,100 rows total. Store it and re-run monthly to pick up new months rather than re-downloading the whole history each time. **Trade-cycle dashboard.** Just the headline throughput number, recent history. ```json { "datasets": ["container_throughput"], "fromMonth": "2015-01" } ``` **Bunkering desk view.** Vessel activity and tonnage, total and by type. ```json { "datasets": ["vessel_arrivals_total", "vessel_arrivals_by_type"], "fromMonth": "2022-01" } ``` Join `vessel_arrivals_total` and the sum of `vessel_arrivals_by_type` for the same month as a sanity check; they should reconcile. **Registry research, capped for a quick look.** The most recent two years of the Singapore-flagged fleet. ```json { "datasets": ["registered_vessels_tonnage"], "maxRecordsPerDataset": 24 } ```