# Troostwijk Auction Scraper - Industrial Lots, Bids, Close Times (`scrapersdelight/troostwijk-lot-scraper`) Actor

From $1.70 per 1,000 lots, no start fee. Every live lot on Troostwijk Auctions (TBAuctions: Troostwijk, Surplex, Vavato) with its real per-lot closing time, current bid, bid count, city and country. Optional lot pages add specs, brand, year, photos and the inspection/collection address.

- **URL**: https://apify.com/scrapersdelight/troostwijk-lot-scraper.md
- **Developed by:** [Scrapers Delight](https://apify.com/scrapersdelight) (community)
- **Categories:** E-commerce, Business, Lead generation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$1.70 / 1,000 per lot row returneds

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

## Troostwijk Auction Scraper — industrial lots, bids and real closing times

Every live lot on **troostwijkauctions.com**, the TBAuctions storefront that also carries **Surplex**
and **Vavato** inventory: bankruptcy estates, plant clearances, CNC machinery, forklifts, trucks,
farm equipment, lab and IT hardware across 21 European countries.

**One row = one live lot.** $0.0017 per row — $1.70 per 1,000 lots. No start fee. Nothing you filter
out is charged.

Measured on the live board on 2026-09-18, by walking all of it: **506 open auctions carrying 77,868
lots**, across 21 countries (Belgium 879 collection locations, Netherlands 807, Germany 148,
France 103, Austria 28, Poland 21, and 15 more).

***

### The thing that makes this different

Every lot in this dataset carries a **real per-lot closing time**. That sounds unremarkable until you
look at where the site actually publishes it:

| Surface | `endDate` present |
|---|---|
| The auction's lot list (`/en/a/<slug>`) | **568 of 568 = 100.0%** |
| The lot's own detail page (`/en/l/<slug>`) | **0 of 48 = 0.0%** |

Those are literal counts over real captured bytes, both in `fixtures/`. A scraper that starts from lot
pages — which is the obvious way to build this, and what the store's most detailed Troostwijk rival
does with its 37-field dump — cannot produce a closing time at all. Substituting the *auction's*
closing time does not fix it either, because closes are **staggered within a single auction**: in one
sampled 48-lot page, all 48 lots had 48 different close times.

So this actor builds every row from the **auction lot list** and treats the lot page purely as
optional enrichment layered on top. `mergeDetail()` is explicitly forbidden from touching the close
time. That is the whole architectural decision, and it is why there is deliberately **no "paste a lot
URL" mode** — a lot URL alone cannot tell you when the lot closes.

The same asymmetry hits the price: the lot detail payload publishes `currentBidAmount` on only
**15 of 48** lots (31.3%), while the lot list carries it on **100%**.

***

### What you get

#### Always (one request per 48 lots)

| Field | Type | What it is | Fill |
|---|---|---|---|
| `lotUrl` | string | Direct link to the lot page. | 100.0% |
| `displayId` | string | The lot number the site shows, e.g. `A7-50158-6`. Unique, and the auction id is its prefix. | 100.0% |
| `title` | string | Lot title as listed. | 100.0% |
| `closesAt` | ISO 8601 UTC | **When this lot closes.** Per lot, not per auction. | 100.0% |
| `minutesToClose` | integer | Minutes from `scrapedAt` to `closesAt`. Negative if it closed while the run was in flight. | 100.0% |
| `opensAt` | ISO 8601 UTC | When bidding opened on this lot. | 100.0% |
| `currentBid` | number | Current bid in `currency` units, not cents. Before the first bid this is the opening price. | 100.0% |
| `currency` | string | EUR on all 568 captured rows. | 100.0% |
| `bidsCount` | integer | Number of bids. `0` is a real answer, not a missing value. | 100.0% |
| `hasBids` | boolean | `bidsCount > 0`, for filtering without a null check. | 100.0% |
| `followersCount` | integer | How many users are watching the lot. The demand signal that moves before the bids do. | 100.0% |
| `biddingStatus` | string | `BIDDING_OPEN`, `BIDDING_NOT_YET_OPENED`, `BIDDING_CLOSED`. | 100.0% |
| `saleTerm` | string | `GUARANTEED_SALE` (250 of 568), `OPEN_RESERVE_PRICE_NOT_ACHIEVED` (235), `OPEN_ALLOCATION_SET` (83). | 100.0% |
| `city` | string | City the lot is physically in. | 100.0% |
| `countryCode` | string | ISO-2, lower case. 21 countries on the live board. | 100.0% |
| `auctionDisplayId` | string | Parent auction number, e.g. `A7-50158`. | 100.0% |
| `auctionName` | string | Parent auction title. | 100.0% |
| `auctionUrl` | string | Link to the parent auction. | 100.0% |
| `auctionLotCount` | integer | Lots the board reports for that auction. See the note below on why this is not the lot-list total. | 100.0% |
| `auctionFirstLotClosesAt` | ISO 8601 UTC | The auction's `minEndDate` - the earliest close among its lots, and a true lower bound. | 100.0% |
| `imageUrl` | string | Primary thumbnail. | 100.0% |
| `platform` | string | `TWK` Troostwijk (448 of 568), `SPX` Surplex (103), `VAVATO` Vavato (17). | 100.0% |
Plus `lotId`, `itemId`, `urlSlug`, `auctionId`, `auctionOpensAt`, `closesAtUnix` and `scrapedAt` as
identifiers.

#### With **Fetch each lot page** on (one extra request per lot, ~65 KB)

Same rows, more columns — **at no extra charge**. There is one billing event and it is per row, so
enrichment costs you nothing beyond run time.

| Field | Type | What it is | Fill |
|---|---|---|---|
| `description` | string | Seller description, HTML as published. | 77.1% |
| `remarks` | string | Auctioneer notes on condition, wear and what is included. | 45.8% |
| `specs` | array | Structured specifications as `{name, value, unit}`. 97 distinct spec names across 48 lots; median 6 per lot, max 29. | 89.6% |
| `brand` | string | Pulled out of `specs` - the `Brand` attribute. | 72.9% |
| `modelType` | string | The `Type` or `Model` attribute. | 54.2% |
| `yearOfBuild` | string | The `Year of build` / `Construction date` attribute. | 56.3% |
| `serialNumber` | string | The `Serial number` / `Chassis number` attribute. | 14.6% |
| `category` | string | Top-level category, e.g. `Metalworking`, `Transport & Logistics`. | 100.0% |
| `subCategory` | string | Second level, e.g. `Lathes`. | 100.0% |
| `categoryPath` | string | The breadcrumb joined with `>`. | 100.0% |
| `startingBid` | number | The lot's opening amount (`initialAmount`), in currency units. | 100.0% |
| `reserveStatus` | string | `NO_MINIMUM_BID_AMOUNT` (25 of 48) or `MINIMUM_BID_AMOUNT_NOT_MET` (23) - whether a reserve exists and whether it is met. | 100.0% |
| `quantity` | integer | Units in the lot. | 100.0% |
| `condition` | string | Returned verbatim. **Not a condition grade** - see honest limits. | 100.0% |
| `marginScheme` | boolean | `true` = sold under the VAT margin scheme, so no reclaimable VAT on the invoice. `true` on 1 of 48. | 100.0% |
| `hasWarranty` | boolean | `false` on all 36 lots that publish it. | 75.0% |
| `imageUrls` | array | Every photo, in the site's own order. Median 10 per lot, max 119. | 100.0% |
| `imageCount` | integer | Length of `imageUrls`. | 100.0% |
| `documentUrls` | array | Attached PDFs - spec sheets, inspection reports. | 8.3% |
| `carbonFootprintKg` | number | The site's own carbon figure for the lot (its "LCA value"), kg CO2e. | 35.4% |
| `companyType` | string | `COMPANY` (40 of 48), `LIQUIDATOR` (4), `COMPANY_LIGHT` (4). | 100.0% |
| `companyId` | string | Numeric consignor-account id, stable across that consignor's lots. **Not a company name.** | 100.0% |
| `auctioneerId` | string | The selling TBAuctions entity: `TBA`, `SPX_DE`, `KDA`, `DECHOW`, `TWK_FR`, `TWK_VAVATO`, `TWK_REST_OF_WORLD`. | 100.0% |
| `regionRestriction` | string | `NONE` (44 of 48) or `EU` (4) - where the lot may be bid from. | 100.0% |
| `visiblePlatforms` | array | Which sibling storefronts also show this lot. | 75.0% |
| `otherLanguageUrls` | array | The same lot page in up to 11 languages, as `{locale, url}`. | 100.0% |
| `collectionStreet` | string | Street of the collection facility. | 91.7% |
| `collectionPostalCode` | string | Postcode of the collection facility. | 91.7% |
| `collectionCity` | string | City of the collection facility. | 100.0% |
| `collectionCountryCode` | string | Country of the collection facility. | 100.0% |
| `collectionFacilityName` | string | Facility name, e.g. `Amann Werkzeugmaschinen GmbH`. | 91.7% |
| `collectionWindows` | array | Collection slots as `{from, to, byAppointment, isDeliverySchedule, forkliftAvailable, forkliftCapacityKg, contactNote, emails}`. | 100.0% |
| `viewingStreet` | string | Street of the viewing/inspection location, when it differs from collection. | 50.0% |
| `viewingPostalCode` | string | Postcode of the viewing location. | 50.0% |
| `viewingCity` | string | City of the viewing location. | 100.0% |
| `viewingFacilityName` | string | Name of the viewing location. | 50.0% |
| `viewingWindows` | array | Inspection slots, same shape as `collectionWindows`. | 100.0% |
| `forkliftAvailable` | boolean | Whether any collection slot lists a forklift on site. | 100.0% |
| `forkliftCapacityKg` | integer | Largest forklift capacity offered, in kg. Null when the site records none. | 25.0% |
| `collectionContactEmails` | array | Email addresses extracted from the collection/viewing block. Logistics contacts, not the consignor. | 50.0% |
| `collectionNotes` | string | The prose left in that block once the addresses are extracted - collection deadlines, a contact name, a phone number. | 33.3% |
Every percentage in both tables above is a **literal count over real captured rows** — 568 lot-list
rows across 14 auctions for the core fields, 48 lot pages across those same 14 auctions for the
detail fields. `null`, `""`, `"-"`, `"N/A"` and an empty array are all counted as *not filled*.
`offline_validate.mjs` re-derives every one of them from `fixtures/` and fails if a number here
drifts from the code. Run `node offline_validate.mjs` yourself.

***

### A real row

This is not an illustrative example. It is an actual row this code produced from the captured bytes,
reproduced field-for-field by the validator. The long arrays (`specs`, `imageUrls`,
`otherLanguageUrls`, `collectionWindows`, `description`) are shown separately below only so the block
stays readable.

```json
{
  "lotUrl": "https://www.troostwijkauctions.com/en/l/mori-seiki-ntx1000-cnc-lathe-A7-50158-6",
  "displayId": "A7-50158-6",
  "title": "Mori Seiki - NTX1000 - CNC lathe",
  "lotId": "717bab5d-74f9-410e-a0bf-3fda05bf0023",
  "itemId": "72b38e6d-0fa2-4a4a-aa37-2b66e7d04393",
  "urlSlug": "mori-seiki-ntx1000-cnc-lathe-A7-50158-6",
  "closesAt": "2026-10-13T08:06:00.000Z",
  "closesAtUnix": 1791878760,
  "minutesToClose": 35406,
  "opensAt": "2026-09-17T08:00:00.000Z",
  "currentBid": 60000,
  "currency": "EUR",
  "bidsCount": 0,
  "hasBids": false,
  "followersCount": 3,
  "biddingStatus": "BIDDING_OPEN",
  "saleTerm": "OPEN_RESERVE_PRICE_NOT_ACHIEVED",
  "city": "Nürnberg",
  "countryCode": "de",
  "auctionDisplayId": "A7-50158",
  "auctionName": "CNC machinery from Deckel Maho, Hüller Hille, Emag, Mazak and other manufacturers",
  "auctionUrl": "https://www.troostwijkauctions.com/en/a/cnc-machinery-from-deckel-maho-huller-hille-emag-mazak-and-other-manufacturers-A7-50158",
  "auctionId": "eb232a3b-51b6-4165-99da-1584c11c609f",
  "auctionLotCount": 16,
  "auctionOpensAt": "2026-09-17T08:00:00.000Z",
  "auctionFirstLotClosesAt": "2026-10-13T08:00:00.000Z",
  "imageUrl": "https://media.tbauctions.com/image-media/43f2f32b-229a-48f3-bd90-19af9a4e51bf/file",
  "platform": "SPX",
  "scrapedAt": "2026-09-18T18:00:00.000Z",
  "detailFetched": true,
  "remarks": "Used machine - functional, without warranty",
  "brand": "Mori Seiki",
  "modelType": "NTX1000",
  "yearOfBuild": "2012",
  "serialNumber": "NTX10120608",
  "category": "Metalworking",
  "subCategory": "Lathes",
  "categoryPath": "Metalworking > Lathes",
  "startingBid": 60000,
  "reserveStatus": "MINIMUM_BID_AMOUNT_NOT_MET",
  "quantity": 1,
  "condition": "NOT_CHECKED",
  "marginScheme": false,
  "hasWarranty": null,
  "imageCount": 11,
  "carbonFootprintKg": 39413.88,
  "companyType": "COMPANY",
  "companyId": "16239",
  "auctioneerId": "SPX_DE",
  "regionRestriction": "NONE",
  "collectionStreet": "Elisenstraße 4",
  "collectionPostalCode": "90441",
  "collectionCity": "Nürnberg",
  "collectionCountryCode": "de",
  "collectionFacilityName": "Amann Werkzeugmaschinen GmbH",
  "viewingStreet": null,
  "viewingPostalCode": null,
  "viewingCity": "Nürnberg",
  "viewingFacilityName": null,
  "forkliftAvailable": false,
  "forkliftCapacityKg": null,
  "collectionContactEmails": [
    "j.berchner@amann-wzm.de",
    "project.de@surplex.com"
  ],
  "collectionNotes": "Julian Berchner, +49 171 5303 918,"
}
```

Its `specs` array, verbatim:

| Attribute | Value | Unit |
|---|---|---|
| Brand | Mori Seiki |  |
| Type | NTX1000 |  |
| Year of build | 2012 |  |
| Serial number | NTX10120608 |  |
| Turning Ø | 370 | mm |
| Turning length | 424 | mm |
| Spindle boring Ø | 65 | mm |
| Speed from n= | 0 | rpm |
| Speed till n= | 12000 | rpm |
| Number of tool changer stations | 10 |  |
| Coolant pump | true |  |
| Chip conveyor | true |  |
| CNC control, brand, type | MSX-711-IV |  |
| Engine power (kW) | 45 | kW |
| Machine weight | 10500 | kg |
| Dimension l | 2710 | mm |
| Dimension w | 2430 | mm |
| Dimension h | 2400 | mm |
| Terms of delivery | Loaded on truck |  |

And its `collectionWindows`, which is where the collection contact comes from:

```json
[
  {
    "from": null, "to": null, "byAppointment": true,
    "isDeliverySchedule": false, "forkliftAvailable": false, "forkliftCapacityKg": null,
    "contactNote": "Julian Berchner, +49 171 5303 918,",
    "emails": ["j.berchner@amann-wzm.de"]
  },
  {
    "from": "2026-10-23T06:00:00.000Z", "to": "2026-10-23T14:00:00.000Z", "byAppointment": false,
    "isDeliverySchedule": false, "forkliftAvailable": false, "forkliftCapacityKg": null,
    "contactNote": null, "emails": []
  }
]
```

A CNC lathe at EUR 60,000, closing on a known date, sitting at a named facility with a street
address, an inspection slot, and a contact for arranging collection. That is the row.

***

### Inputs

#### What to scrape

| Input | What it does |
|---|---|
| `auctionUrls` | Auction URLs, slugs or display ids (`A1-49582`). Empty = the whole live board. An auction that has already closed is reported, not billed. |

#### Filter the live board

| Input | What it does |
|---|---|
| `countryCodes` | Lot's own country, ISO-2 lower case. 21 available. |
| `platforms` | `TWK` / `SPX` / `VAVATO` — the storefront serves all three. |
| `keywords` | Case-insensitive match on the lot **title**. Title is the only text the list surface publishes. |
| `closingWithinHours` | Only lots closing inside the window. **The cheapest filter here** — see below. |
| `onlyWithBids` | Only lots that already have a bid (37.5% of live lots do). |
| `minCurrentBid` / `maxCurrentBid` | Current bid band, in EUR. Measured range across 568 rows: EUR 1 to EUR 60,000, median EUR 700. |
| `includeNotYetOpen` | Include the 14 pre-announced auctions. Off by default — they usually publish no lots yet. |

#### Order and depth

| Input | What it does |
|---|---|
| `auctionOrder` | `closingSoonest` (default), `newest`, `mostLots`, `siteOrder`. A capped run should hand you the lots about to close, not an arbitrary slice of 77,868. |
| `maxLots` | Hard cap on rows delivered — and therefore charged. Default 500, `0` = no cap. |
| `includeLotDetails` | Fetch each lot page for the detail columns. Off by default. |

#### Advanced

| Input | What it does |
|---|---|
| `concurrency` | 1–10, default 5. Measured: 5 gave 90/90 clean requests; 10 and 16 each dropped one or two to connection errors. |
| `useFastJsonRoute` | Use the site's cheaper JSON route where possible, with automatic HTML fallback. |
| `proxyConfiguration` | Apify proxy on by default. The site is clean through it — see below. |

***

### `closingWithinHours` is nearly free, and that is not an accident

You are charged per row delivered but we pay per row *examined*, so a filter applied after fetching
would decouple your cost from ours. Two things keep that honest here:

1. **The lot list returns 48 rows per request.** One 84 KB fetch yields 48 candidates, so a filter
   that rejects 47 of them still cost one request. Cost per examined row is ~1.75 KB.

2. **The site publishes a pre-filter index and we use it.** Every auction carries `minEndDate`, the
   earliest close among its lots — and it is a genuine lower bound (verified on 14 auctions, 14/14:
   `minEndDate <= min(lot endDate)`). So an auction whose *first* lot closes after your window is
   skipped **whole**, all 5,784 of its lots if need be, without a single request. In the validator's
   own run against the captured board, a 24-hour window pruned 499 of 506 auctions and ~69,794 lots
   that were never fetched.

The expensive surface — lot detail, one request and ~500 KB per row — is fetched **only** for rows
that already passed every filter *and* fit inside your cap. A run never pays detail cost for a row it
will not deliver, and the validator asserts that ordering in the source.

***

### How it reads the site, and the one thing that rots

The site is a Next.js app; every page server-renders its data into a `__NEXT_DATA__` script block.
There is no DOM scraping and no browser — this actor runs on plain Node at **512 MB**.

| Step | Request | Measured |
|---|---|---|
| 1. The board | `GET /en/auctions?page=N` | 11 pages, 506 auctions, closes exactly on the site's own `totalSize` |
| 2. Lots in an auction | `GET /en/a/<slug>?page=N` | 48 rows per page, terminates on `hasNext:false`, asserted against `lots.totalSize` |
| 3. Optional detail | `GET /en/l/<slug>` | ~65 KB on the wire, ~530 KB decoded |

The site serves the same rows a second way, as JSON at `/_next/data/<buildId>/…`, for 56 KB instead
of 88 KB. **That URL embeds a build id that changes on every site deploy.** The validator confirms
the two routes return page 2 element-for-element identically, so this actor:

- uses the **HTML route as primary** — it needs no build id and cannot rot;
- scrapes the build id fresh each run and **never hardcodes it** (the validator greps `src/` to prove
  the literal is absent);
- treats the JSON route as a bandwidth optimisation that **falls back to HTML automatically** on any
  failure. A build-id rotation mid-run is a slowdown, not an outage.

**`lotCount` is not the lot-list total.** The board's `lotCount` and the lot list's own `totalSize`
disagree slightly — measured across 14 auctions, 1,934 against 1,932, with one auction reporting
`lotCount` 116 against `totalSize` 114. The walk asserts against `totalSize`, because that is the
number the lot list will actually hand you.

***

### Reliability, measured rather than asserted

All on 2026-09-18, through the **Apify proxy** unless noted:

| Test | Result |
|---|---|
| 60 lot-detail pages, concurrency 5 | **60/60 HTTP 200** in 33 s (1.82 req/s), 65,030 B avg |
| 30 auction lot-list pages, concurrency 5 | **30/30 HTTP 200** in 20 s (1.50 req/s), 84,003 B avg |
| Same 60 at concurrency 10 | 58/60 — two connection failures |
| Same 60 at concurrency 16 | 59/60 — one connection failure |
| Apify proxy, `auto` pool | 3/3 HTTP 200 |
| Apify proxy, RESIDENTIAL + country NL | 3/3 HTTP 200, full `__NEXT_DATA__` |
| Full board walk, 11 pages | 11/11 HTTP 200 |
| 48 lot pages from a bare home IP | 47 first-try, **1 transient 403** that returned 200 on one retry |

That single 403 is the only anti-bot signal on this target, and its body was ordinary lot HTML with
the lot's own `<title>` — a rate-limit blip, not a challenge page. The actor therefore **retries 403**
on a fresh proxy session, which most builds do not. No browser, no cookie mint, no challenge solve is
needed anywhere.

#### What happens when something does go wrong

- A non-200, or a 200 carrying a block page, is a **transport failure**, never end-of-data. Retried on
  a fresh proxy session, up to 4 attempts.
- A body that arrives short is rejected before parsing. The `__NEXT_DATA__` block sits near the *end*
  of a 890 KB document, so a body truncated mid-stream parses to "no data" rather than to an error —
  the validator feeds it a real 120,003-byte truncation to prove the rejection fires.
- One unreadable auction does not abort the run; it is recorded in `RUN_SUMMARY` and the walk
  continues. The run only fails if **more than half** the auctions are unreadable — that is a block,
  not bad luck.
- Deliberate aborts use `Actor.fail()`, never a bare `throw` (which the runtime's uncaught-exception
  net swallows, leaving a broken run reading SUCCEEDED).
- Both walks are **floor-asserted** against the site's own published totals. A short read is refused
  rather than reported as a small site.

***

### Two real runs, start to finish

Both on 2026-09-18 against the live site, through the Apify proxy at concurrency 5.

**Run A — the default shape** (`maxLots: 40`, detail off, closing-soonest):

```
board: 504 auctions (site reported 504), 11 pages, 11/11 HTTP 200
482 of 504 auctions passed the auction-level filters
40 lots delivered and charged, from 48 lot rows examined (1.2 examined per delivered)
40/40 carry a real close time; every core field 100% filled
24 seconds wall clock, container boot included
```

**Run B — a deliberately selective filter** (`countryCodes: ["de"]`, `closingWithinHours: 72`,
detail on, `maxLots: 12`):

```
the closing-time pre-filter skipped 426 auctions outright, plus 47 on country and 8 empty
~75,279 lots were never fetched at all
6 auctions opened, 1,071 lot rows examined, 12 delivered and charged (89.3 examined per delivered)
12/12 carry a real close time; 12 lot pages enriched, 0 unreadable
one transient NGHTTP2_REFUSED_STREAM, retried on a fresh session and succeeded
```

Run B is the expensive case on purpose — a filter that matches about 1% of what it reads. Even
there the ratio is 89 examined rows per delivered row, against a break-even near **2,200**: one
lot-list request costs roughly $0.000037 of compute and returns 48 rows, so $0.0017 of revenue buys
about 46 requests. The ratio is reported in `RUN_SUMMARY` as `lotRowsExaminedPerDelivered` on every
run, so you can see it rather than take this on trust.

***

### Billing

One event, `lot-scraped`, **$0.0017 per row delivered**. No start fee. No separate detail event.

Not charged, and kept apart from each other in `RUN_SUMMARY` so you can tell them apart:

- lots rejected by your filters (never delivered),
- whole auctions skipped by the closing-time pre-filter (never even fetched),
- requested auctions that are no longer on the live board (reported by id),
- lot pages that could not be read after retries (reported with the URL).

`Actor.pushData(items, event)` bills per item and respects your charge cap, so **delivered equals
charged**. The cap is checked against rows *produced*, not rows flushed — a cap checked against
flushed batches lags by a whole chunk.

***

### `RUN_SUMMARY`

Written to the key-value store on every run. It reconciles what the site said was there against what
was actually read:

- live auctions the site reported vs. auctions collected vs. board pages read;
- auctions opened, auctions skipped and **why**, and how many lots sat behind the skipped ones;
- lot rows examined vs. delivered; `delivered == charged`;
- `everyOpenedAuctionWalkedToItsOwnTotal` — a per-auction completeness flag;
- `closeTimeIntegrity` — the share of delivered rows carrying a real close time, which is the claim
  this actor is built around;
- `fieldFillOnDeliveredRows` — per-field fill **counted on the rows this run actually produced**,
  not quoted from this README;
- the four kinds of nothing, listed above.

***

### Honest limits

**There is no consignor email, phone or company name, and there never will be.** Consignors here are
bankruptcy estates and liquidators, and the platform keeps them anonymous on purpose. Over 48 real
lot pages: **zero** email or phone *fields* anywhere on the lot object. `companyId` is a numeric
account id (`16239`), not a name — it is shipped as an id and labelled as one.

What *is* published, and what this actor ships instead, is the physical facility: street, postcode,
city, country and facility name on **91.7%** of lots, with inspection and collection windows, forklift
availability and — on **50.0%** — an email for arranging collection, plus **33.3%** carrying a contact
name, phone or collection deadline in `collectionNotes`. Those are logistics contacts at the handling
or consigning company, not the beneficial seller, and they are labelled that way. On **8.3%** of lots
an auctioneer has typed an address into the free-text `remarks`; it is left there in context rather
than promoted into a contact column.

**`condition` is not a condition grade.** The site publishes the field on 100% of lots, but across 48
real lot pages every value was either `NOT_CHECKED` (46) or `NOT_APPLICABLE` (2). It is returned
verbatim because both store rivals advertise it and you will look for it — but do not build a
condition filter on it. `packaging` and `appearance` carry exactly the same two placeholders and are
**cut** rather than shipped as constant columns.

**No all-in price estimate.** The buyer's premium and VAT are revealed only inside the authenticated
bid-confirmation flow — the site's own words are "before you confirm, you'll see a full overview of
all amounts related to your bid, including buyer's premium and VAT". They are in no public byte this
actor reads, so no premium, VAT or all-in figure is offered. `currentBid` is the hammer figure only.
Budget roughly 25–45% on top; confirm against the auction's own terms.

**No coordinates.** `lot.location` publishes `{city, countryCode}` and nothing else — 0 of 48 lot
pages carried a latitude or longitude. The collection street and postcode this actor ships geocode
precisely; a fabricated point would not.

**Closed and sold lots are out of scope.** This actor reads the *live* board. The site's sitemaps
carry ~205,000 mostly-closed lot URLs, and status query parameters do not reopen them on the auction
page — a realized-price archive is a genuinely different product with a different enumeration, and
pretending otherwise would ship a dataset whose close-time column is meaningless.

**Fields cut for being empty**, each with its count: `videos` (0 of 48), `packaging` and `appearance`
(placeholder-only), `onboarding` (`false` on 48 of 48), `bsc` (an undocumented float with no published
meaning), `assignedExplicitly` (internal auction-ops flag). `documentUrls` is only 8.3% full but is
kept, because when a spec sheet or inspection report is attached it is worth having.

**Bids arrive late.** The site soft-closes: a bid in the final 2 minutes extends the lot by 2 minutes.
A snapshot taken hours before close is inventory data, not a price. Schedule the run inside
`closingWithinHours` if you want prices that mean something.

**The board moves under you.** `totalSize` read 507, then 506, then 505 over about an hour of probing.
The walks tolerate 1% of drift and refuse anything worse.

***

### Source and legal

Public listing data from `www.troostwijkauctions.com`. No login, no paywall, no personal data beyond
the business collection contacts described above. This is an independent tool, not affiliated with or
endorsed by TBAuctions, Troostwijk Auctions, Surplex or Vavato.

`robots.txt` at the time of building, quoted verbatim (also captured in `fixtures/robots.txt`):

```
User-agent: *
Allow: /
Disallow: */search*
Disallow: *categoryLevel=*
Disallow: *totalSize=*
Disallow: *countries=*
Disallow: *brands=*
Disallow: *auctions=*
Disallow: *amounts=*
Disallow: */login?callbackUrl=*
Disallow: */signup?callbackUrl=*
```

This actor reads three paths — `/en/auctions?page=`, `/en/a/<slug>` and `/en/l/<slug>` — and none of
them matches a `Disallow` rule. Every filter in this actor is applied **on our side**, against rows
the plain default listing already returned, precisely so that no request ever carries `countries=`,
`brands=`, `amounts=`, `categoryLevel=`, `totalSize=` or the search path. The validator generates
seven representative URLs from the real URL builders and tests each against all nine rules.

***

### Verify it yourself

```
node offline_validate.mjs
```

No network, no Apify account, no `npm install`. It re-derives every number in this README from the
real bytes in `fixtures/` — the close-time asymmetry, every fill percentage, the route equivalence,
the truncation rejection, the billing ordering, the schema/code agreement, and the sample row above.

# Actor input Schema

## `auctionUrls` (type: `array`):

Auction URLs, slugs or display ids - e.g. "https://www.troostwijkauctions.com/en/a/vehicles-and-trailers-A1-49582", "vehicles-and-trailers-A1-49582" or just "A1-49582". Every lot in each named auction is returned (subject to your row cap). An auction that has already closed is reported in the run summary as not-on-the-live-board and costs you nothing. Note there is deliberately no "lot URL" input: a lot page does not publish its own closing time, so every row here is built from its auction's lot list instead - see the README.

## `countryCodes` (type: `array`):

Keep only lots physically located in these countries, matched on the lot's own location. Multiple entries are OR-ed. Leave empty for all 21.

## `platforms` (type: `array`):

The troostwijkauctions.com storefront also serves lots belonging to its sibling TBAuctions brands. Measured over 568 real lot rows: TWK 448, SPX 103, VAVATO 17. Leave empty for all three.

## `keywords` (type: `array`):

Keep only lots whose TITLE contains one of these, case-insensitively - "excavator", "Deutz", "CNC". Multiple entries are OR-ed. This matches the title only, because that is the only text the lot list publishes (lot descriptions live on the lot page and are 0% present on the list surface). For description matching, turn on "Fetch each lot page" and filter the dataset afterwards.

## `closingWithinHours` (type: `integer`):

0 = off. This is the cheapest filter in the actor and the one worth using: every auction publishes minEndDate, the earliest close among its lots, and that is a true lower bound (verified on 14 auctions, 14/14). So an auction whose first lot closes after your window is skipped whole - all of its lots, up to 5,784 of them, without a single request. Bids concentrate in the last minutes (the site soft-closes: a bid inside the final 2 minutes extends the lot by 2 minutes), so a 24-48h window is where the prices mean something.

## `onlyWithBids` (type: `boolean`):

Measured over 568 live lot rows: 213 (37.5%) carry at least one bid. The other 62.5% genuinely have none yet - that is not missing data, and a starting bid still shows in currentBid.

## `minCurrentBid` (type: `integer`):

Keep only lots at or above this current bid. Every captured lot was priced in EUR. Measured over 568 rows: min EUR 1, median EUR 700, max EUR 60,000.

## `maxCurrentBid` (type: `integer`):

Keep only lots at or below this current bid.

## `includeNotYetOpen` (type: `boolean`):

Measured on the live board: 492 auctions are BIDDING\_OPEN and 14 are BIDDING\_NOT\_YET\_OPENED. Off by default because a pre-announced auction usually publishes no lots yet.

## `auctionOrder` (type: `string`):

Default is closing-soonest, so a capped run hands you the lots that are about to close rather than an arbitrary slice of 77,868. Note that the site's own board order is RECOMMENDED, which pushes the very large long-running auctions to the front - useful to know if you pick "Site order".

## `maxLots` (type: `integer`):

Hard cap on rows delivered, and therefore on rows charged. 0 means no cap - the whole live board was 77,868 lots when this was written, which is roughly 1,640 requests and about 20 minutes without lot detail, or many hours with it. Rows rejected by your filters do not count against the cap and are not charged.

## `includeLotDetails` (type: `boolean`):

OFF (default): every row still carries closing time, current bid, bid count, followers, city, country, status, image and the parent auction - all at 100% fill - for one request per 48 lots. ON: one extra request per lot (about 65 KB) adds the description (77.1%), full specs (89.6%), brand (72.9%), year of build (56.3%), every photo (100%), the starting bid, the reserve status, the VAT margin flag, the collection/viewing street address (91.7%) and the collection contact email (50.0%). It costs you no extra charge - the price is per row either way - but it makes the run roughly 25x slower, so cap it. Detail is only ever fetched for rows that already passed your filters and fit inside the cap.

## `concurrency` (type: `integer`):

Measured through the Apify proxy on 2026-09-18: 60 lot pages at concurrency 5 returned 60/60 HTTP 200 in 33 s, and 30 auction pages returned 30/30 in 20 s. At concurrency 10 and 16 the same list dropped 2 and 1 requests to connection errors. 5 is the measured sweet spot; raising it trades reliability for speed.

## `useFastJsonRoute` (type: `boolean`):

The site serves the same rows twice: as HTML (88 KB) and as JSON behind a build-specific URL (56 KB). Verified identical element-for-element. The JSON URL embeds a build id that changes on every site deploy, so this actor reads the id fresh on each run and falls back to HTML automatically if it has rotated mid-run. Turn this off to use the HTML route only - slower, one less moving part.

## `proxyConfiguration` (type: `object`):

Optional. Measured 2026-09-18: the Apify proxy is clean on this target - the auto pool returned 3/3 HTTP 200 and RESIDENTIAL/NL 3/3, and a 90-request sustained load through the auto pool returned 90/90. The site does emit a rare transient 403 under load (1 in 48 from a bare home IP); it is a rate-limit blip, not a challenge, and the actor retries it on a fresh session. Residential works but costs far more per GB and is not needed here.

## Actor input object example

```json
{
  "auctionUrls": [],
  "countryCodes": [],
  "platforms": [],
  "keywords": [],
  "closingWithinHours": 0,
  "onlyWithBids": false,
  "includeNotYetOpen": false,
  "auctionOrder": "closingSoonest",
  "maxLots": 40,
  "includeLotDetails": false,
  "concurrency": 5,
  "useFastJsonRoute": true,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

## `items` (type: `string`):

One row per live lot: closing time, current bid, bid count, watchers, status, city, country, image and the parent auction - and, when lot detail is on, the description, full specs, brand, year, every photo, the starting bid, the reserve status and the inspection/collection address.

## `runSummary` (type: `string`):

RUN\_SUMMARY: how many auctions the site said were live against how many were walked, which auctions were skipped and why, lot rows examined against lot rows delivered, delivered == charged, per-field fill counted on the rows this run actually produced, and the three kinds of nothing kept apart.

# 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 = {
    "auctionOrder": "closingSoonest",
    "maxLots": 40,
    "includeLotDetails": false
};

// Run the Actor and wait for it to finish
const run = await client.actor("scrapersdelight/troostwijk-lot-scraper").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 = {
    "auctionOrder": "closingSoonest",
    "maxLots": 40,
    "includeLotDetails": False,
}

# Run the Actor and wait for it to finish
run = client.actor("scrapersdelight/troostwijk-lot-scraper").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 '{
  "auctionOrder": "closingSoonest",
  "maxLots": 40,
  "includeLotDetails": false
}' |
apify call scrapersdelight/troostwijk-lot-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,scrapersdelight/troostwijk-lot-scraper"
        }
    }
}
```

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/MRRLbjeR7kfWTbHnP/builds/8tfl6u0JnXq6ld9OG/openapi.json
