--- name: nga-world-port-index-scraper description: Read the full NGA World Port Index (Pub 150) as structured records via the Apify Actor arman-bd/nga-world-port-index-scraper. Returns one record per port for all 2,951 ports and terminals worldwide, with over 110 fields covering identity (UN/LOCODE, NGA port number), position, channel/anchorage/pier depths, crane and repair capability, pilotage and tug availability, ISPS-related fields and communications, with optional filtering by port name, country, region, harbor type and minimum channel depth. Use for port risk pricing, voyage/routing planning, reference data sync, and UN/LOCODE enrichment of shipment or AIS data. Not for berth-level detail, historical editions, or live port operational status (arrivals, congestion, tariffs). --- # NGA World Port Index Scraper Apify Actor `arman-bd/nga-world-port-index-scraper`. One call returns the current World Port Index edition: 2,951 ports and terminals, one dataset record each, over 110 fields per record. It runs without credentials, and with no input at all it returns the complete dataset. ## When to use it - Port risk pricing: berth depth, shelter rating and repair capability per port for marine insurance and P&I underwriting. - Voyage and routing planning: checking max vessel length/beam/draft and pilotage requirements before nominating a port. - Reference data sync: keeping a ports table current against the weekly-corrected Pub 150 edition. - UN/LOCODE enrichment: joining shipment, AIS or schedule data on `unloCode` to attach depth, crane and facility detail a bare LOCODE list doesn't carry. - Trade-lane or country coverage audits: filtering to one country, region or harbor type to compare terminal capability across a lane. ## When not to use it - Berth-level or terminal-level detail. Pub 150 is a port-level index; it does not break a port into individual berths. - Live port operations: vessel arrivals, congestion, waiting times, berth occupancy or tariffs are not part of the World Port Index. - Historical editions. Only the current edition is served; there is no versioned or dated snapshot to query. ## Call it ```js import { ApifyClient } from 'apify-client'; const client = new ApifyClient({ token: process.env.APIFY_TOKEN }); const run = await client.actor('arman-bd/nga-world-port-index-scraper').call({ countries: ['NL'], minChannelDepth: 10, maxPorts: 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~nga-world-port-index-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"harborTypes":["RB","CN"],"minChannelDepth":12}' ``` The Actor is also exposed through Apify's MCP server as `arman-bd/nga-world-port-index-scraper`, so an MCP-capable agent can call it with no extra wiring. ## Input | Field | Type | Required | Default | Notes | |---|---|---|---|---| | `portNames` | string[] | no | `[]` | Keep ports whose name contains any of these, case-insensitive substring match. Empty means every port. | | `countries` | string[] | no | `[]` | A 2-letter ISO 3166 country code (exact match) or a country name (substring match). | | `regionNames` | string[] | no | `[]` | Substring match against NGA's own World Port Index region grouping: mostly country names, sometimes a coastline (`ALASKA`, `BRAZIL NE COAST`). | | `harborTypes` | string[] | no | `[]` | Exact Pub 150 harbor type code(s): `CN`, `CB`, `CT`, `RN`, `RB`, `RT`, `LC`, `OR`, `TH`. | | `minChannelDepth` | integer | no | `0` | Drop ports with a reported channel depth (meters) below this, or with no reported depth. `0` keeps everything. | | `maxPorts` | integer | no | `0` | Cap on saved ports after filtering. `0` means no limit (up to all 2,951). | Nothing is required. All filters combine with AND, applied client-side against the full dataset read in one request. **A `maxPorts` outside 0..2,951 is refused, not treated as unlimited.** A negative or non-numeric value is recorded as a failure in `RUN_SUMMARY` and the run saves nothing rather than falling through to the full 2,951-port pull. ## Output One record per port, ~118 fields: all 112 fields the source publishes, plus six derived fields. A representative subset: | Field | Type | Notes | |---|---|---| | `portNumber` | number | NGA's stable numeric port identifier. | | `portName` | string | Port name. | | `unloCode` | string \| null | UN/LOCODE. Present on about 87% of ports. | | `countryCode`, `countryName` | string | ISO country code and name. | | `regionName`, `regionNumber` | string, number | NGA's World Port Index region grouping. | | `latitude`, `longitude` | string | The source's own DMS position strings, e.g. `51°54'00"N`. | | `decimalLatitude`, `decimalLongitude` | number | Signed decimal coordinates: the source's own decimal fields (`ycoord`/`xcoord`), not a DMS conversion. | | `harborSize`, `harborSizeLabel` | string | Pub 150 code (`L`/`M`/`S`/`V`) and decoded label. | | `harborType`, `harborTypeLabel` | string | Pub 150 code (e.g. `RB`) and decoded label. | | `shelter`, `shelterLabel` | string | Shelter afforded, code and decoded label. | | `chDepth`, `anDepth`, `cpDepth`, `otDepth` | string \| null | Channel, anchorage, cargo pier and oil terminal depths, meters, as numeric strings. | | `crFixed`, `crMobile`, `crFloating`, `cranesContainer` | string | Crane availability by type: `Y`/`N`/`U`. | | `ptCompulsory`, `ptAvailable`, `tugsAssist`, `tugsSalvage` | string \| null | Pilotage and tug service flags: `Y`/`N`/`U`. | | `repairCode`, `drydock`, `railway` | string | Repair capability codes. | | `portSecurity`, `etaMessage` | string | ISPS-related fields as published by NGA. | | `globalId` | string | NGA's persistent GUID for the port. | | `scrapedAt` | string | Run timestamp, ISO 8601. | Every other field the source publishes (roughly 90 more) is passed through unchanged, mostly `Y`/`N`/`U` flags for cargo handling by type, communications, supplies, medical facilities and environmental exposure. See the README's **Output example** for the full record shape. A real record: ```json { "portNumber": 31140, "portName": "Rotterdam", "unloCode": "NL RTM", "countryCode": "NL", "countryName": "Netherlands", "harborType": "RB", "harborTypeLabel": "River basin", "chDepth": "11", "decimalLatitude": 51.900000000000034, "decimalLongitude": 4.483333000000073, "repairCode": "A", "drydock": "M", "globalId": "{05C7AD68-A575-481D-90C0-FD17AD560F42}", "scrapedAt": "2026-08-21T22:47:49.786Z" } ``` ## RUN_SUMMARY Written to the run's key-value store under the key `RUN_SUMMARY`. **Read it.** It is where a partial or empty run explains itself. ```json { "portsAvailable": 2951, "portsMatched": 39, "portsSaved": 5, "failures": [], "filters": { "portNames": [], "countries": ["nl"], "regionNames": [], "harborTypes": [], "minChannelDepth": 0, "maxPorts": 5 }, "finishedAt": "2026-08-21T22:47:49.793Z" } ``` `portsAvailable` is the full dataset size read this run (2,951 in a healthy run). `portsMatched` is what your filters kept before `maxPorts` truncated it; `portsSaved` is what was actually written and charged. `failures` carries either a source-fetch error (`{source, error}`) or a rejected `maxPorts` (`{field, value, error}`). An empty array means the run completed cleanly. ## Behaviour to plan around - **One request reads everything.** There is no pagination; every filter is applied in-memory after a single ~6 MB fetch. `portNames`, `countries`, `regionNames` and `harborTypes` all combine with AND against the same 2,951-port list. - **`maxPorts` bounds what is charged, and a bad value never falls through to "unlimited."** A negative or non-numeric `maxPorts` is refused: it is recorded in `RUN_SUMMARY.failures` and the run saves zero rows rather than guessing. - **A filter matching nothing is not an error.** `portsSaved: 0` with an empty `failures` array means the filters were valid and simply matched no port, not a failure to read the source. - **Depth fields are numeric strings, and about 12% of ports have no `chDepth` at all.** `minChannelDepth` drops both the too-shallow and the unreported. - **`Y`/`N`/`U` are three distinct values, not two.** `U` means Pub 150 reports the fact as unknown, which is different from `N` (reported and absent). Every flag field passes both through unchanged. - **The source is corrected weekly.** There is no dated or versioned snapshot; every run reflects NGA's current edition, so re-running later can surface NGA's own corrections rather than a change your side made. - **Transient errors are retried** three times with linear backoff before the run gives up on the source entirely. ## Recipes **Full reference pull.** Every port, for a local sync job. ```json {} ``` Key on `portNumber` (stable) or `globalId` (also stable, and unique even across any future portNumber reassignment) to diff against your last sync. **One country, minimum depth.** Port risk screening for a trade lane. ```json { "countries": ["BR"], "minChannelDepth": 10 } ``` Sort on `chDepth` descending to rank candidate ports by draft capacity. **Container-capable river ports.** Harbor type plus a name fragment. ```json { "harborTypes": ["RB", "RN"], "portNames": ["port"] } ``` Filter further on `cranesContainer === "Y"` client-side for container-crane confirmation, since that facet isn't part of the input schema. **Single-port lookup.** UN/LOCODE enrichment for one shipment. ```json { "portNames": ["Santos"] } ``` A distinctive name fragment is usually enough for a one-row result; a common fragment (like `"port"`) returns every match, so check `portsMatched` in `RUN_SUMMARY` before assuming you got exactly one.