# Lazada Singapore Geo-Price Scraper (`xavierfok/lazada-sg-geoprice`) Actor

Landed cost for Lazada Singapore: the auto-applied voucher price, shipping fee, guaranteed delivery date and stock. Every page is fetched through a real Singapore mobile line and every row carries the IP it came from. Run-over-run change detection is free.

- **URL**: https://apify.com/xavierfok/lazada-sg-geoprice.md
- **Developed by:** [Xavier Fok](https://apify.com/xavierfok) (community)
- **Categories:** E-commerce
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.00 / 1,000 products

This Actor is paid per event and usage. You are charged both the fixed price for specific events and for Apify platform usage.

Learn more: https://docs.apify.com/platform/actors/running/actors-in-store#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Lazada Singapore Scraper — real SG mobile IPs, real landed cost

Most marketplace scrapers give you a price. A price alone doesn't tell a Singapore buyer
what the thing actually costs, or whether it arrives this week.

This actor returns **landed cost and availability truth**: price **plus the auto-applied
voucher, plus shipping fee, plus the guaranteed delivery date, plus stock, plus seller
trust metrics** — captured from a genuine Singapore mobile-carrier IP. Vouchers, shipping
fees and delivery estimates are computed by Lazada from the requesting network. A
datacenter or geo-parameter scraper either can't see them or sees the wrong ones. We run
on real SG mobile lines, so what you get is what a Singapore shopper gets. Every single row
carries `egress_ip` and `egress_geo`, so the Singapore claim is checkable on every record —
no screenshot needed.

### Record types

The dataset mixes four record types, each tagged with `record_type` — plus uncharged
`error` rows naming any input that produced no data.

**`product`** — one row per product, flat and spreadsheet-ready:

`position`, `item_id`, `nid`, `name`, `url`, `image`, `price`, `original_price`,
`price_text`, `discount_pct`, `currency`, `rating`, `reviews`, `sold`, `in_stock`,
`brand_name`, `brand_id`, `seller_name`, `seller_id`, `sku`, `sku_id`, `cheapest_sku`,
`category_ids`, `location`, `is_lazmall`, `is_sponsored`, `traffic_type` (organic vs ad),
`free_shipping`, `restricted_age`, `description` (the seller's feature bullets) — and
every row carries `search_term`, `fetched_at`, and `egress_ip` / `egress_geo` proving
Singapore egress.

Plus the **promo layer**, which is where the SG price actually gets decided:

| Field | Example | Why it matters |
|---|---|---|
| `voucher_text` | `"Voucher save 58%"` | auto-applied at checkout |
| `voucher_discount_pct` | `58` | |
| **`voucher_price`** | **`4.20`** on a `10.00` listing | the real price to rank on |
| `coin_cashback` | `0.14` | LazCoin rebate |
| `in_campaign` | `true` | LazFlash / PAYDAY / campaign badge |
| `is_new_arrival` | `false` | |
| `badges` | `["campaign","coins","lazmall","voucherapplied"]` | every badge, so a new promo type is never silently dropped |

In a live 80-result Singapore capture, **79 of 80 products carried a voucher badge.**
Sorting that page by `price` and sorting it by `voucher_price` do not produce the same
ranking. A scraper that only returns `price` is telling you the sticker, not the sale.

**`product_detail`** — opt-in via `enrichProductDetails`. This is the differentiated data:

| Field | Example |
|---|---|
| `price` | `103.0` |
| `shipping_fee` | `0.49` |
| **`landed_cost`** | **`103.49`** — what a Singapore buyer actually pays |
| `shipping_method` | `"Standard"` |
| `delivery_from` / `delivery_to` | `"2026-07-31"` / `"2026-08-01"` |
| `delivery_guaranteed` | `true` |
| `stock_hint` | `"Almost sold out, buy now!"` |
| `cash_on_delivery` | `false` |
| `gtin` | `"194644216283"` — barcode, for matching against any other catalogue |
| `seller_positive_rating` | `"99%"` |
| `seller_ship_on_time` | `"100%"` |
| `seller_chat_response` | `"93%"` |
| `warranty` | `["100% Authentic", "Local Seller Warranty 18 months"]` |
| `variants` | per-SKU price matrix (`sale_price` + `original_price` + image) |
| `max_order_qty` | `5` |
| `rating_histogram` | `[206, 5, 3, 0, 1]` — 5★ down to 1★ |
| `specifications` | full spec key/value map |
| **`flash_sale_remaining`** | **`13`** — units left in the LazFlash allocation (`total - sold`) |
| `flash_sale_status` | `"started"` / `"notStart"` / `"soldOut"` |
| `flash_sale_ends_at` | `"2026-07-31T02:00:00Z"` |
| `price_labels` | `["Lowest Price Guarantee"]` |
| `top_reviews` | the reviews Lazada embeds — text, rating, reviewer, photo URLs |

`flash_sale_remaining` is hard inventory: Lazada allocates a fixed unit count to a flash
deal, so `total - sold` is the actual number left, not a "selling fast" string.
`top_reviews` is a sample of `review_count`, not the whole corpus — it rides along in the
payload we already fetch, so it costs nothing extra.

**`change`** — opt-in via `detectChanges`, and **free**. Set it once and every later run
tells you what moved since the last one, instead of handing you another full snapshot to
diff yourself:

| `change_type` | Meaning |
|---|---|
| `price_drop` / `price_rise` | sticker price moved — with `before`, `after`, `delta`, `delta_pct` |
| `voucher_price_drop` / `voucher_price_rise` | the *effective* price moved, even when the sticker didn't |
| `out_of_stock` / `back_in_stock` | availability flipped |
| `entered_results` / `left_results` | opt-in via `reportResultChurn` — see below |

Every row also carries `position_before` / `position_after`, so you get rank movement
without a rank-churn row for every product on every run. The first run of a keyword stores
a baseline and reports nothing — there is no signal to report yet. Baselines live in a
named key-value store (`changeStateStore`), so you can track the same keywords as separate
series.

**Why membership changes are off by default.** Lazada rotates and personalises search
results. Two runs of the same keyword ten minutes apart shared only 22 of 40 results in a
measured pair — so `entered_results` / `left_results` would have fired 36 times while zero
prices actually moved. Reporting that as "change" would bury the signal it exists to
surface. Turn `reportResultChurn` on when *you* fixed the item set via `productUrls`, where
a product going missing genuinely means something.

**`listing_meta`** — market structure for the keyword: `total_results` (how many products
compete for it), `brands` (the brand facet Lazada shows), `services` (LazFlash, Choice,
store pick-up), `ship_from` (Singapore / China / overseas), `categories`, `breadcrumb`.

### Example

```json
{ "record_type": "product_detail", "item_id": "3392809159",
  "price": 103.0, "shipping_fee": 0.49, "landed_cost": 103.49,
  "delivery_from": "2026-07-31", "delivery_to": "2026-08-01",
  "delivery_guaranteed": true, "stock_hint": "Almost sold out, buy now!",
  "gtin": "194644216283", "seller_positive_rating": "99%",
  "seller_ship_on_time": "100%", "rating_histogram": [206, 5, 3, 0, 1] }
```

Two products on the same keyword, from one real run — the reason price alone
isn't enough: `$103.00 + $0.49` guaranteed by 1 Aug, versus `$2.79 + $1.99`
arriving 4–8 Aug. Shipping is 71% of the second item's cost.

### Track a fixed SKU list

Pass `productUrls` to skip search entirely and fetch detail records straight
from product pages — for monitoring a known catalogue daily. You are billed
only for the detail records, not for listing pages you didn't need.

### Good for

SG price and MAP monitoring where vouchers and shipping change the ranking ·
landed-cost comparison · promo and campaign tracking (who is running vouchers, how deep) ·
stock and delivery-promise tracking · seller vetting before you buy or partner ·
keyword competition sizing via `total_results` and the brand facet.

### Inputs

`searchTerms` and/or `categoryUrls` drive it. `maxPages` walks past the 80-per-page limit,
`maxProductsPerTerm` caps the total, `sortBy` picks Lazada's own ordering, and
`enrichProductDetails` + `maxDetailsPerTerm` control the detail pass. `detectChanges`
turns on run-over-run deltas, `changeStateStore` names the baseline store, and
`reportResultChurn` adds result-membership rows.

`captureScreenshot` is **off by default**. It saves a PNG of the results grid, which is
useful as a shareable artifact but adds nothing analytically — `egress_ip` / `egress_geo`
already prove Singapore egress on every row — while costing storage and up to ~20s of
compute per keyword waiting for the grid and its thumbnails to paint. Turn it on when you
want the picture.

### Pricing

Pay-per-event, so you only pay for data that actually arrived:

| Record | Price | Per 1,000 |
|---|---|---|
| `product` | $0.001 | **$1.00** |
| `product_detail` (landed cost, GTIN, seller trust) | $0.005 | $5.00 |
| `listing_meta` (one per keyword) | $0.002 | $2.00 |

Plain product rows are the cheapest in the category — and they already carry the voucher
layer, so you get the effective SG price at $1.00 / 1,000. The enrichment tier adds the
shipping-and-delivery half of landed cost, and it's opt-in.

**You are never charged for a blocked page, a non-Singapore egress, or a page that
returned zero products.** Enrichment is off by default.

### The lines this runs on

Every row above came off a Singtel SIM in a dongle on a rack in Singapore. Not a datacenter
range wearing an SG label. That is the entire reason the voucher prices and shipping fees
are the ones a local actually gets.

We sell those lines. If you're building your own scraper and the Singapore part is what
keeps breaking, dedicated SG mobile proxies are at **singaporemobileproxy.com**. Code
**APIFY15** takes 15% off your first payment.

Worth saying plainly: it's a line, not an unblocker. Shopee still blocks us on a clean SG
mobile IP, because that one is app-layer fingerprinting and no proxy fixes it. Lazada works
because it serves its listing JSON to an ordinary browser session.

# Actor input Schema

## `searchTerms` (type: `array`):

One Lazada SG search keyword per line, e.g. 'wireless earbuds'.

## `categoryUrls` (type: `array`):

Full lazada.sg listing URLs to scrape instead of / in addition to searchTerms.

## `maxProductsPerTerm` (type: `integer`):

Maximum number of products to return per search term or category URL (1-200).

## `sortBy` (type: `string`):

How Lazada should sort the listing before scraping.

## `maxRetriesPerPage` (type: `integer`):

How many times to retry a page (on a fresh SG mobile IP) if Lazada blocks the first attempt.

## `productUrls` (type: `array`):

Full lazada.sg product page URLs to fetch detail data for directly, with no search. Use this to monitor a fixed list of SKUs - price, shipping fee, delivery date and stock - without paying for listing pages.

## `maxConcurrency` (type: `integer`):

How many product pages to load at once. Automatically capped at the number of Singapore mobile lines available so two pages never share one IP.

## `maxPages` (type: `integer`):

How many listing pages (80 products each) to walk per search term. Raise this to collect more than 80 products for a term.

## `enrichProductDetails` (type: `boolean`):

Open each product page from a real Singapore mobile IP to capture shipping fee, guaranteed delivery date, seller ratings, barcode/GTIN and variants. Slower, and billed per detail record.

## `maxDetailsPerTerm` (type: `integer`):

How many products per search term to enrich with full product-detail data.

## `includeListingMeta` (type: `boolean`):

Emit one extra record per term with the total result count and the brand, service and shipped-from facets Lazada shows for that keyword.

## `captureScreenshot` (type: `boolean`):

Save a PNG of the Singapore mobile results grid for each keyword, as a shareable artifact. OFF by default: every product row already carries egress\_ip and egress\_geo proving Singapore egress, so the image adds no analytical value while costing storage plus up to ~20s of compute per keyword waiting for the grid and its thumbnails to render. Turn it on when you need the visual proof itself.

## `detectChanges` (type: `boolean`):

Compare this run against the previous run of the same search term and emit 'change' records: price\_drop / price\_rise, voucher\_price\_drop / voucher\_price\_rise, out\_of\_stock / back\_in\_stock, new\_listing and delisted - each with before, after, delta and delta\_pct. The first run of a term only stores a baseline and reports nothing. Change records are FREE: they are derived from data you already paid for.

## `changeStateStore` (type: `string`):

Name of the key-value store that keeps the previous run's baseline. Use a different name to track the same keywords as independent series. Only used when 'Detect changes since the last run' is on.

## `reportResultChurn` (type: `boolean`):

Add 'entered\_results' and 'left\_results' change records. OFF by default on purpose: Lazada rotates and personalises search results, so two runs of the same keyword minutes apart can share only about half their results - in one measured pair, 22 of 40. Those rows would bury the price and stock moves that are reliable. Turn this on when you fixed the item set yourself via 'productUrls', where absence really does mean the product went away.

## Actor input object example

```json
{
  "searchTerms": [],
  "categoryUrls": [],
  "maxProductsPerTerm": 40,
  "sortBy": "relevance",
  "maxRetriesPerPage": 3,
  "productUrls": [],
  "maxConcurrency": 4,
  "maxPages": 1,
  "enrichProductDetails": false,
  "maxDetailsPerTerm": 5,
  "includeListingMeta": true,
  "captureScreenshot": false,
  "detectChanges": false,
  "changeStateStore": "lazada-sg-state",
  "reportResultChurn": false
}
```

# Actor output Schema

## `products` (type: `string`):

Every product row. Sort on `voucher_price`, not `price`: a voucher is auto-applied at Lazada checkout, so `price` is the sticker rather than what a Singapore buyer pays.

## `landedCost` (type: `string`):

Enriched rows (requires `enrichProductDetails`): price + shipping\_fee = landed\_cost, guaranteed delivery window, flash-sale units remaining, seller trust metrics, GTIN and per-SKU variants.

## `changes` (type: `string`):

Requires `detectChanges`. Price and voucher moves with before/after/delta/delta\_pct, stock flips, and products entering or leaving the results. Empty on a keyword's first run, which only stores a baseline.

## `allRecords` (type: `string`):

The unfiltered dataset, including `listing_meta` and any `error` rows.

## `proofScreenshots` (type: `string`):

PNG of the Singapore mobile results grid for each keyword, keyed by the `screenshot_key` carried on every product row.

# 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 = {};

// Run the Actor and wait for it to finish
const run = await client.actor("xavierfok/lazada-sg-geoprice").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 = {}

# Run the Actor and wait for it to finish
run = client.actor("xavierfok/lazada-sg-geoprice").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print("💾 Check your data here: https://console.apify.com/storage/datasets/" + run["defaultDatasetId"])
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{}' |
apify call xavierfok/lazada-sg-geoprice --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "command": "npx",
            "args": [
                "mcp-remote",
                "https://mcp.apify.com/?tools=xavierfok/lazada-sg-geoprice",
                "--header",
                "Authorization: Bearer <YOUR_API_TOKEN>"
            ]
        }
    }
}

```

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/acts/L20Rf6rqT5cgj40ck/builds/mvvGjy9MFsCwQVtRb/openapi.json
