--- name: hong-kong-port-throughput-scraper description: Read Hong Kong's official monthly port statistics as structured records via the Apify Actor arman-bd/hong-kong-port-throughput-scraper. Returns one record per dimension/period combination, merging the source's level figure and year-on-year percentage change into a single row, for container throughput (split by Kwai Tsing terminals vs mid-stream, direction, laden/empty) and the companion seaborne and river cargo tonnage series, with optional frequency and period-range filtering. Use for Hong Kong port competitiveness tracking, laden/empty and cargo-mode analysis, or joining against other trade-statistics sources on period. Not for daily vessel calls, berth-level detail, or countries other than Hong Kong. --- # Hong Kong Port Throughput Scraper Apify Actor `arman-bd/hong-kong-port-throughput-scraper`. Name the table ids you want, get one dataset record per dimension/period combination, with the level figure and its year-on-year change already merged into one row. It runs without credentials, and with no input at all it returns recent monthly data across all three built-in tables. ## When to use it - Tracking how much of Hong Kong's container volume moves through the main Kwai Tsing terminals versus mid-stream and river handling. - Building a Hong Kong port competitiveness time series, level and year-on-year change together, for container throughput. - Comparing laden vs empty container flows by direction, or seaborne vs river cargo tonnage. - Watching a provisional figure firm up over a few months by re-running and keying on `figureFlag`. ## When not to use it - Vessel-level or berth-level detail. This is aggregate monthly/annual statistics, not port-call records. - Ports outside Hong Kong. Use a source's own equivalent Actor for other ports. - Real-time or daily figures. The source publishes monthly and annual observations only, typically two to three months behind the reference period. - A table id outside the source's `id=NNN-NNNNN` shape. Anything else needs a different Actor. ## Call it ```js import { ApifyClient } from 'apify-client'; const client = new ApifyClient({ token: process.env.APIFY_TOKEN }); const run = await client.actor('arman-bd/hong-kong-port-throughput-scraper').call({ tableIds: ['410-55294', '410-55111A', '410-55112A'], frequency: 'M', periodFrom: '', periodTo: '', maxRecordsPerTable: 300, lang: 'en', }); 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~hong-kong-port-throughput-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"tableIds":["410-55294"],"frequency":"M","maxRecordsPerTable":200}' ``` The Actor is also exposed through Apify's MCP server as `arman-bd/hong-kong-port-throughput-scraper`, so an MCP-capable agent can call it with no extra wiring. ## Input | Field | Type | Required | Default | Notes | |---|---|---|---|---| | `tableIds` | string[] | no | all three built-in ids | `410-55294` (container throughput), `410-55111A` (seaborne cargo), `410-55112A` (river cargo) are documented by name. Any other `id=NNN-NNNNN` table on the same source is still read, with generic `series`/dimension labelling if it uses dimension names outside the ones mapped by name. | | `frequency` | string | no | `M` | `M` for monthly, `Y` for annual, `ALL` for both. | | `periodFrom` | string | no | `""` | Earliest period to keep, as `YYYY-MM`, `YYYYMM` or `YYYY`. Applied after fetching. Empty means the full history. | | `periodTo` | string | no | `""` | Latest period to keep, same format. Empty means the most recent published period. | | `maxRecordsPerTable` | integer | no | `150` | Cap on saved rows per table, most recent periods first. The source has no server-side pagination or date filter, so every run downloads a table's complete history regardless of this setting; only what gets saved and charged is capped. | | `lang` | string | no | `en` | `en`, `tc` or `sc`. Only affects text labels; the `*Code` fields are language-independent. | **`maxRecordsPerTable` bounds what is saved, not what is fetched.** The source answers every request with a table's complete history in one payload; there is no smaller-payload mode. `periodFrom`/`periodTo` and `frequency` narrow which of those rows survive to the dataset, and `maxRecordsPerTable` then caps the survivors, keeping the most recent periods. ## Output One record per dimension/period combination, per requested table, with the level figure and year-on-year change merged. | Field | Type | Notes | |---|---|---| | `tableId` | string | The source table id, e.g. `410-55294`. | | `series` | string | `container-throughput`, `seaborne-cargo`, `river-cargo`, or a title-derived label for any other table id. | | `tableTitle` | string | The table's own title, in the requested language. | | `measureCode` | string \| null | The source's internal measure code. | | `locationCode`, `location` | string \| null | Kwai Tsing terminals vs mid-stream, or `null` on the cargo tables, which don't carry this split. | | `cargoModeCode`, `cargoMode` | string \| null | `Seaborne` or `River`, or `null` on the container table, which doesn't carry this split. | | `directionCode`, `direction` | string \| null | Inward, outward or the aggregate total. | | `containerTypeCode`, `containerType` | string \| null | Laden, empty or the aggregate total. Container table only. | | `shipmentTypeCode`, `shipmentType` | string \| null | Direct shipment, transhipment or the aggregate total. Cargo tables only. | | `freq` | string | `M` or `Y`. | | `period` | string | `YYYYMM` for monthly, `YYYY` for annual. | | `unit` | string \| null | The unit `figure` is in, e.g. `('000 TEUs)` or `('000 tonnes)`. | | `figure` | number \| null | The level value. `null` if the source has no value for this combination. | | `figureFlag` | string | The source's own footnote on the level figure: `p`, `[§3]`, `N.A.` or empty. | | `yoyChangePct` | number \| null | Year-on-year percentage change for the same dimensions and period. | | `yoyChangeFlag` | string | Footnote on the year-on-year figure, same vocabulary as `figureFlag`. | | `provisional` | boolean | `true` if either flag contains `p`. | | `scrapedAt` | string | Run timestamp, ISO 8601. | A real record from the container table: ```json { "tableId": "410-55294", "series": "container-throughput", "tableTitle": "Port container throughput by main cargo handling location", "locationCode": "NCT", "location": "Other than Kwai Tsing Container Terminals", "cargoModeCode": null, "cargoMode": null, "directionCode": "Out", "direction": "Outward", "containerTypeCode": "Laden", "containerType": "Laden", "freq": "M", "period": "202605", "unit": "('000 TEUs)", "figure": 98, "figureFlag": "p", "yoyChangePct": -4.4, "yoyChangeFlag": "p", "provisional": true, "scrapedAt": "2026-08-21T22:49:51.330Z" } ``` A real record from the seaborne cargo table, showing the cargo-mode dimension instead: ```json { "tableId": "410-55111A", "series": "seaborne-cargo", "tableTitle": "Seaborne cargo throughput by month", "cargoModeCode": "S", "cargoMode": "Seaborne", "locationCode": null, "location": null, "shipmentTypeCode": "TS", "shipmentType": "Transhipment", "freq": "M", "period": "202605", "unit": "('000 tonnes)" } ``` ## 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, and where each table's own title and footnotes live, since they aren't repeated on every record. ```json { "tablesRequested": ["410-55294", "410-55111A", "410-55112A"], "tablesFailed": 0, "failures": [], "recordsSaved": 450, "perTable": [ { "tableId": "410-55294", "series": "container-throughput", "rawObservations": 13566, "groupedSeries": 6909, "saved": 150, "capped": true } ], "tableMeta": { "410-55294": { "title": "Port container throughput by main cargo handling location", "source": "...", "tablenote": ["..."] } }, "filters": { "frequency": "M", "periodFrom": "", "periodTo": "", "maxRecordsPerTable": 150, "lang": "en", "tableIds": ["410-55294", "410-55111A", "410-55112A"] }, "finishedAt": "2026-08-21T22:49:51.361Z" } ``` `perTable[].rawObservations` is the number of raw source rows after the `frequency`/period filters, before pairing; `groupedSeries` is how many dimension/period combinations that produced, which is what `maxRecordsPerTable` actually caps. `capped: true` means more periods exist than were saved; widen `periodFrom`/`periodTo` or raise `maxRecordsPerTable` to reach them. `tableMeta` carries each table's full title, source line and footnote text once, rather than repeating a multi-paragraph footnote on every one of hundreds of records. ## Behaviour to plan around - **The source has no pagination or date-range parameter.** Every run downloads each requested table's complete history (roughly 5-7 MB), and all filtering happens after the fact. `maxRecordsPerTable` bounds what is saved and charged, not what is fetched. - **A level figure and its year-on-year change arrive from the source as two separate rows** sharing the same dimensions, frequency and period. This Actor pairs them into one output row. The first 12 months of a monthly series (or the first year of an annual one) can be missing the year-on-year side entirely, since there is no prior-year baseline yet; that side is `null` in the merged record. - **`location` and `cargoMode` are mutually exclusive across the built-in tables.** The container table has a location split (Kwai Tsing vs mid-stream) and no cargo-mode split; the cargo tables have a cargo-mode split (seaborne vs river) and no location split. Every record carries both fields regardless, `null` on whichever doesn't apply, so combining tables in one run still yields one dataset shape. - **`maxRecordsPerTable` keeps the most recent periods, not the first ones.** Rows are ranked by period before capping, so a small cap on a wide `periodFrom` still returns current data rather than truncating from 1997 forward. - **One bad table id is recorded in `RUN_SUMMARY.failures`** and does not stop the rest of the run. The Actor only throws when every requested table id failed. - **Transient errors are retried** three times with linear backoff. A table id or language code the source rejects is permanent for that table and ends there. - **`figureFlag`/`yoyChangeFlag` are the source's own footnote codes**, not this Actor's invention: `p` for provisional, `[§3]` for a change under 0.05%, `N.A.` for not available, or an empty string for a settled figure with no footnote. ## Recipes **Kwai Tsing vs mid-stream share.** Container throughput only, current months. ```json { "tableIds": ["410-55294"], "frequency": "M", "maxRecordsPerTable": 200 } ``` Group by `period`, sum `figure` where `location` is `Kwai Tsing Container Terminals` vs `Other than Kwai Tsing Container Terminals`, both restricted to `direction: "Total"` and `containerType: "Total"` to avoid double counting. **Multi-year annual trend.** All three series, a specific decade. ```json { "tableIds": ["410-55294", "410-55111A", "410-55112A"], "frequency": "Y", "periodFrom": "2015", "periodTo": "2025", "maxRecordsPerTable": 2000 } ``` Join the three `series` values on `period` and `direction` for a combined container plus cargo-tonnage view. **Laden/empty balance by direction.** Container table, current window, filtered client-side after the run. ```json { "tableIds": ["410-55294"], "frequency": "M", "maxRecordsPerTable": 500 } ``` Keep rows where `containerType` is `Laden` or `Empty` (drop the `Total` aggregate rows) and pivot on `direction` to compare import-heavy vs export-heavy months.