# Lotus's Scraper: Thailand Grocery Prices & Member Prices (`hexagone-data/lotuss-scraper`) Actor

Scrape lotuss.com (ex-Tesco Lotus) categories: price in THB, crossed-out regular price, member price, price per kg, stock, brand, every promotion incl. Buy More Save More tiers. Pay per result.

- **URL**: https://apify.com/hexagone-data/lotuss-scraper.md
- **Developed by:** [Hexagone Data](https://apify.com/hexagone-data) (community)
- **Categories:** E-commerce, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$1.50 / 1,000 products

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

## Lotus's Scraper — Thai Grocery Prices, Member Prices & Promotions

Collect product prices from **lotuss.com**, the online shop of Lotus's (formerly Tesco Lotus), one of Thailand's largest retailers. Paste category URLs and get one record per product with its brand, category, price in Thai baht, crossed-out regular price, member price, per-kilogram pricing for products sold by weight, and every promotion of the product page, including "Buy More Save More" tier prices.

Use dated exports to track an assortment, compare prices across categories, or monitor promotions over time.

### Try it with 20 products

1. Paste a category URL into **Category URLs**, for example `https://www.lotuss.com/en/category/chicken`.
2. Set **Maximum number of products** to `20`.
3. Start the Actor and open its dataset.
4. Read **Run summary** before exporting JSON, CSV or Excel: it says, per URL, what was listed, delivered or skipped, and why.

For the Apify API, a valid input for the steps above is:

```json
{
  "startUrls": [{"url": "https://www.lotuss.com/en/category/chicken"}],
  "maxItems": 20
}
```

The form's prefill is 20 products, the schema default is 1,000. Set `maxItems` explicitly in API calls; `0` removes the product-count limit.

### What to paste

| Input | What you get |
|---|---|
| A category page, e.g. `https://www.lotuss.com/en/category/milk-and-beverages-1` | Products exposed by the traversed category listings **and their subcategory listings**, each product once. |
| A full category path, e.g. `https://www.lotuss.com/en/category/meat/poultry` | The same, for a category whose short name is shared by several categories. |

Some short names are used by several categories (in the category tree of 25 October 2026, 77 names were shared by 209 categories, e.g. `poultry` exists under MEAT and under CP). Such a URL is rejected with the exact full paths to paste instead; the Actor never guesses. Product pages, search pages and other sites are rejected with a reason and never block the other URLs. Thai (`/th/`) category URLs are accepted; product names and categories are returned in English.

#### Why subcategories are read too

On this site a category's own listing can miss products of its subcategories. Measured on 25 October 2026: the WATER AND BEVERAGES listing showed 1,954 products while its 10 subcategories held 2,431 distinct products (477 missing from the parent listing, e.g. 150 of 190 in BIRD NEST AND CHICKEN ESSENCE). The Actor therefore lists every category of the subtree and delivers the union, deduplicated by product code. The run summary reports, per URL, how many products the start category listed itself and how many came only from its subcategories.

### Prices: what they mean

- `price` is the price shown to a **signed-out visitor**, with **no store selected** (the website's own seller). It is per **kilogram** when `priceUnit` is `"kg"` (products sold by weight), per item otherwise.
- `regularPrice` is the normal price. When it is higher than `price`, the page shows it crossed out: that is a public discount.
- `memberPrice` is the price shown as "Get this for ฿X as a member." — reserved to Lotus's members. It is `null` when the product has none. The matching promotion appears in `promotions` with `ruleType` `SAME_PRODUCT_GET_DISCOUNT` and no text.
- `estimatedPackWeight` (weight products only) is the default weight the page uses for its estimate: the page shows "1.00Kg/฿78.00" for a product at ฿78.00/Kg with an estimated pack weight of 1.
- "Buy More Save More" products carry their tier prices in `promotions[].tiers`, e.g. `[{"from": 1, "to": 4, "price": 78}, {"from": 5, "to": null, "price": 72}]` (per kg for weight products).

`priceContext` repeats this context in every record. These are not the prices of a store you chose, and not a national average.

### Output

| Field | Meaning |
|---|---|
| `sku` | Lotus's product code. |
| `name` | Product name as the site returns it (the product page title). |
| `brand`, `category`, `breadcrumb` | Brand and category as shown on the product page ("Brand: CP", "Category: CHICKEN"); `breadcrumb` is the page's category path joined by " > ". The brand can be the site's own value `NO BRAND`. |
| `price`, `regularPrice`, `memberPrice`, `currency` | Prices in Thai baht, `currency` is always `THB`. See the price section. |
| `priceUnit`, `estimatedPackWeight` | `kg` or `each`; default pack weight for weight products. |
| `promotions` | Every promotion of the product, in the order the product page shows them: `text` (the offer, e.g. "Buy 2 for 111.0 Baht"), `condition` (the second line some offers show, e.g. "Please add only buying quantity, free item will be adding to cart automatically", else null), `ruleType` (site code: `bxf`, `bxgx`, `BUY_X_TO_PRICE_GET_POINT`, `BUY_X_GET_DISCOUNT_TIER`, `SAME_PRODUCT_GET_DISCOUNT`…), `tiers` (tier prices or null). `[]` when the product has none. |
| `firstPromotionText`, `firstPromotionCondition`, `firstPromotionRuleType` | The same three values of `promotions[0]`, for spreadsheets. They always come from the same promotion. |
| `stockStatus` | The site's stock flag as returned (`IN_STOCK` on all 4,204 products measured on 25 October 2026): it does not prove that a store has the product on its shelf. |
| `url`, `imageUrl` | Product page and main image. |
| `priceContext`, `sourceUrl`, `scrapedAt` | Price context, the start URL that delivered the product, and the time its product page data was read. |
| `dataIssues` | Fields delivered as `null` because the site sent an unexpected value. Normally empty. |

A value the site does not give is `null`, never an invented `0`, `false` or empty text. `promotions` is a list of objects: account for it when choosing spreadsheet columns, or use the `firstPromotion…` fields.

#### Output example

Built by the Actor from the Lotus's API response captured on **25 October 2026** (offline replay); it shows the format, not today's price.

```json
{
  "sku": "75589353",
  "name": "CP FROZEN CHICKEN BL LEG SCRAP MEAT KG",
  "brand": "CP",
  "category": "CHICKEN",
  "breadcrumb": "MEAT > POULTRY > CHICKEN",
  "price": 78.0,
  "regularPrice": 78.0,
  "memberPrice": null,
  "priceUnit": "kg",
  "estimatedPackWeight": 1.0,
  "currency": "THB",
  "promotions": [
    {
      "text": "Buy More Save More",
      "condition": null,
      "ruleType": "BUY_X_GET_DISCOUNT_TIER",
      "tiers": [
        {"from": 1.0, "to": 4.0, "price": 78.0},
        {"from": 5.0, "to": null, "price": 72.0}
      ]
    }
  ],
  "firstPromotionText": "Buy More Save More",
  "firstPromotionCondition": null,
  "firstPromotionRuleType": "BUY_X_GET_DISCOUNT_TIER",
  "stockStatus": "IN_STOCK",
  "url": "https://www.lotuss.com/en/product/75589353",
  "imageUrl": "https://o2o-static.lotuss.com/products/107486/75589353.jpg",
  "priceContext": {"sellerId": 3, "storeSelected": false, "signedIn": false, "language": "en"},
  "sourceUrl": "https://www.lotuss.com/en/category/meat",
  "scrapedAt": "2026-10-25T18:40:00Z",
  "dataIssues": []
}
```

### Run summary and statuses

Open **Run summary** (the SUMMARY record of the run's key-value store; the OUTPUT record holds the same as JSON). For each URL it shows a status, the number of distinct products listed and delivered, and every warning with its count.

- `done`: every listing of the category and its subcategories was read to its end.
- `stopped_at_limit` / `not_processed`: your `maxItems` or maximum charge was reached first.
- `no_products`: the category and its subcategories list nothing. The site's API answers an empty list in the same way for an empty category, a wrong URL or a site outage, so each such URL gets a `NO_PRODUCTS` warning. If no start URL delivers any product, the run ends as **failed** with the message "the category is empty, the URL is wrong or the site is down"; an empty URL next to URLs that deliver products does not fail the run.
- `partial_collection`: products may be missing (a listing page failed, a page repeated an earlier one, an empty page was followed by more products, a listed product had no `urlKey` and was not delivered by another listing of the same URL, product pages could not be read…). The run ends as **failed** so it is never mistaken for a complete export; the dataset keeps what was delivered, and resurrecting the run retries the incomplete URLs without delivering anything twice.
- `failed`: the URL delivered no product because its product pages could not be read (or the category tree could not be read); the run ends as **failed**.

**Product pages that cannot be read.** A product page that fails (HTTP error after retries), or that answers something other than this product's data (an error code inside a normal answer, an HTML page instead of data, or another product's data), is counted per URL (`DETAIL_FAILED` or `PRODUCT_UNREADABLE`, with an example). The product is not delivered and not charged, and the URL becomes `partial_collection`. When at least **10** such failures in a row are seen at the end of a batch of product pages (batches of up to 20), the URL is abandoned (`DETAIL_BLOCKED`). If the URL delivered no product at all because of these failures, it is `failed` instead of `partial_collection`. A single field with an unexpected value does not count here: the product is delivered with that field as null and its name in `dataIssues`.

- `invalid_url`, `category_not_found`, `ambiguous_category`: the URL was not collected; the detail says why.

The site announces no product count, so completeness is checked against the end of each listing and against the start category's own listing.

**Products whose page answers "not found".** A product withdrawn between the listing and its product page is rare. Per URL, up to **2** such products, or up to **1 %** of the product pages requested, are reported as a warning (`PRODUCT_NOT_FOUND`) and the URL can still be `done`. Above both limits the URL is `partial_collection` (`DETAIL_NOT_FOUND_RATE`): that many "not found" answers point to an access or site problem, not to withdrawn products. If more than half of the first 50 or more product pages answer "not found", the URL is abandoned: `failed` if it delivered no product, otherwise `partial_collection`. These products are never delivered and never charged.

### Pricing

**$1.50 per 1,000 delivered products** ($0.03 for 20). Each product delivered to the dataset is one charged result; products skipped or not delivered are not charged. Set a maximum product count or a maximum charge for your run: when the charge limit is reached, the run stops cleanly and reports it.

### Limits

- Prices are the website's signed-out prices without a selected store; member and loyalty-card offers are reported as such, never as the public price.
- One extra request per delivered product reads its product page data (brand, category and tier prices exist only there), four at a time; large categories take accordingly longer than the listing alone.
- `stockStatus` is the site's flag, not a store inventory.

### Support

For a reproducible problem, open an issue with the category URL, the run ID and the expected result. Never include API tokens. Retailer changes can affect collection: check the run summary before using the results.

Lotus's is a trademark of its owner. This Actor is not affiliated with, endorsed by or sponsored by Lotus's or CP Axtra.

Coverage checks cannot prove catalogue completeness: Lotus’s exposes no authoritative total. A short listing, or a truncation shared by a parent and its children, may remain undetectable.

After migration, `previousAttempts` retains the request and failure counters of the interrupted attempt of each URL (kept per URL, even when another unfinished URL is collected again first); `previousAttemptCounters` aggregates them separately from the current traversal. A `RESUMED_ATTEMPT` warning, with the earlier warnings as `PREVIOUS_…`, is added only when the interrupted attempt had failures or warnings; an ordinary migration without incident leaves no warning. `requests` includes checkpointed earlier requests. Requests made after the last durable checkpoint may be lost in a hard crash. Delivered products and field-issue counts are always rebuilt from the dataset.

# Actor input Schema

## `startUrls` (type: `array`):

Lotus's category pages to scrape, e.g. https://www.lotuss.com/en/category/chicken. Every subcategory is included and products are deduplicated across the run. A name shared by several categories is rejected with the full paths to paste instead (e.g. .../category/meat/poultry). Product, search and other pages are rejected with a reason; they never block the other URLs.

## `maxItems` (type: `integer`):

Stops after this many unique products across all URLs (0 = no limit). Each delivered product is one charged result.

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

Not needed by default. If a request is refused (HTTP 403), the Actor retries it through this proxy; one proxy session is used for the whole run.

## Actor input object example

```json
{
  "startUrls": [
    {
      "url": "https://www.lotuss.com/en/category/chicken"
    }
  ],
  "maxItems": 20,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

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

All products, one record each, with every field.

## `prices` (type: `string`):

Price view: product, brand, price, regular price, member price, unit, first promotion, link.

## `summary` (type: `string`):

Readable per-URL summary: status, products listed and delivered, warnings and their counts.

## `summaryJson` (type: `string`):

The same summary as JSON (OUTPUT record).

# 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 = {
    "startUrls": [
        {
            "url": "https://www.lotuss.com/en/category/chicken"
        }
    ],
    "maxItems": 20
};

// Run the Actor and wait for it to finish
const run = await client.actor("hexagone-data/lotuss-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 = {
    "startUrls": [{ "url": "https://www.lotuss.com/en/category/chicken" }],
    "maxItems": 20,
}

# Run the Actor and wait for it to finish
run = client.actor("hexagone-data/lotuss-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 '{
  "startUrls": [
    {
      "url": "https://www.lotuss.com/en/category/chicken"
    }
  ],
  "maxItems": 20
}' |
apify call hexagone-data/lotuss-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,hexagone-data/lotuss-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/N6sArmUw6gKK29Fqi/builds/SdEJPrXtvyDI9vR7G/openapi.json
