Back to Blog

Lazada Data Scraping Price Data and Proxy Planning

Marcus Bennett

Sep 22, 2026 · Guides · 26 min read

TL;DR

Lazada data scraping starts with a defined market, seller, SKU, currency, and price type. Use Open Platform when an authorized endpoint covers the task; use a browser only for permitted public-page research. The offline example below converts fictional records, while the browser JSON-LD sample has not been tested against a live Lazada page. Validate price formats and business fields before marking any row as accepted. Evaluate Rola IP only where the workflow needs a compatible regional route, and measure proxy connectivity separately from target-page and data quality outcomes.

Which Parts of This Example Are Actually Validated?

The supplied material shows an offline conversion of fictional price and variant records. It does not establish successful live extraction from Lazada or a working Rola IP browser route. The normalizer must apply explicit validation rules before it can label a row valid. Record browser extraction, proxy connectivity, and business-data acceptance separately when an authorized live test is available.

Evidence layer Validated here Not established by this guide
Official documentation Lazada Open Platform onboarding, request/response shape, and the documented API access model A live account, seller authorization, or API response for this task
Offline fixture conversion Field normalization, price parsing, contract checks, rejection reasons, and generated local output Lazada access, current storefront content, or proxy performance
Local contract regression A complete fictional row returns valid; empty seller, missing currency, unknown price type, wrong market, HTTP error, and AggregateOffer produce errors and never valid No live URL or account result
Browser JSON-LD sample Code structure for approved URL checks, redirects, response classification, bounded waiting, and failure output A live Lazada page, online Product JSON-LD, or Playwright/Chromium run
Rola route checks Tool workflow and separate route-level fields are described A live route result, selected Rola product, target acceptance, or billing result

Before an authorized online run, record the Python, Playwright, and Chromium versions, proxy protocol and authentication mode, region/session settings, approved URL list, and the authorized three-URL result. These items remain pending rather than being inferred from the fixture or interface screenshots.

Before You Scrape Lazada

Confirm authorization and scope

Write down the exact storefronts, URL patterns, fields, frequency, retention period, and business purpose before sending a request. Review the current Lazada terms that apply to your market, official API options, privacy obligations, and contractual restrictions. Do not collect private buyer data, automate login flows without authorization, solve CAPTCHAs, or continue after a clear denial.

Lazada Open Platform should be the first option for data that belongs to an authorized seller or app. Its API model includes application credentials, signed requests, seller authorization where required, and market-specific endpoints. A public-page workflow is a different access path and should not be described as an unofficial replacement for seller authorization.

Determine how the page delivers data

Check one approved URL manually before choosing a library:

  • If the field exists in the initial HTML or structured JSON-LD, an HTTP client may be enough.
  • If JavaScript inserts price, stock, or variant state after load, use a browser such as Playwright and wait for a specific state.
  • If an authorized Open Platform endpoint exposes the data, use the API instead of parsing the storefront.
  • If the page returns a human-verification gate, stop. Record the event and review the access method rather than trying to defeat it.

Define which “price” you need

A product can expose list price, promotional price, voucher-adjusted price, member price, selected-variant price, quantity-tier price, shipping-inclusive total, and checkout price. Market, seller, buyer state, delivery address, campaign window, and variant selection can change the value.

Choose one definition for each dataset. Preserve the raw display string, store a separately parsed numeric value, and never label a page price as the final checkout price unless checkout is explicitly in scope and authorized.

Define the Data Contract First

Field Meaning Validation rule
marketplace Storefront such as lazada.sg Required; derive from approved hostname
source_url Canonical product or listing URL HTTPS and inside approved scope
product_id Item or SKU identifier Store as text; do not infer from title
title Displayed product title Non-empty after normalization
seller_name Seller attached to the offer Required for seller comparison
variant_id / variant_label Color, size, bundle, or SKU One row per material variant
raw_price Unmodified price token from the selected source Preserve source format; do not silently convert display text
numeric_price Decimal parsed from raw price Use Decimal, not binary float
currency Currency code such as SGD or MYR Must agree with page evidence
price_type Listed, promotional, member, voucher-adjusted, or another explicitly defined price Required; use unknown when evidence is insufficient
eligibility_context Conditions attached to a member, voucher, or other conditional price Nullable for an unconditional price; do not infer eligibility
delivery_context Relevant delivery market or coarse delivery setting Record only the minimum non-personal context needed for comparison
price_source API, JSON-LD, DOM, or fixture source Required for traceability
extraction_method API, JSON-LD, DOM, or fixture normalizer path Required for provenance
original_price Reference or struck-through price Nullable
discount_text Displayed campaign label Nullable; store verbatim
stock_status Availability at capture time Controlled values plus unknown
captured_at_utc Collection timestamp UTC ISO 8601
route_label Direct or named proxy route Never store credentials
status Parsing outcome such as live_parsed, aggregate_offer, or partial Separate from transport and field validation
transport_status HTTP or offline transport outcome Keep separate from parsing and field validation
validation_status Whether the row meets the selected field contract valid, partial, or invalid; do not infer from HTTP 200
validation_errors Machine-readable reasons a row is partial or invalid Empty only when validation_status is valid
retry_after Raw Retry-After response value Preserve for HTTP 429; null otherwise
error_type Stable failure category for an unsuccessful attempt Null on a successful parse
elapsed_ms Client-side duration for this attempt Record for success and failure

A parsed price is not yet a validated product observation. Confirm that the seller, selected variant, currency, and price type match the intended record. If JSON-LD contains only an aggregate or starting price, do not label it as the selected variant’s price. Preserve the source token, record the limitation, and mark the result as partial when required context is missing. This contract prevents a Singapore promotion for one seller and variant from being compared with a Malaysian list price for another seller and variant as if they were the same observation.

Choose the Access Method

Requirement Start with Main limitation
Authorized seller, order, or app data Lazada Open Platform Requires the correct app and authorization
A few permitted public product pages Playwright JavaScript and markup change
Static public HTML HTTP client plus parser Misses browser-rendered state
Large managed collection Compliant provider or internal platform Cost and data-contract review
Managed third-party scraping API Provider endpoint after capability review It is separate from Lazada Open Platform; BYO Rola proxy support must be verified
Regional public-price verification Browser plus measured regional route Route validity does not guarantee acceptance

Scope: Product Pages, Lists, and Follow-up Data

Data type Page or access surface Treatment in this guide
Product detail Authorized API response or permitted product page Covered: prices, sellers, variants, stock, currency, and provenance
Search and category lists Search or category pages with pagination/infinite scroll Scope boundary: list discovery and deduplication need a separate adapter
Reviews and ratings Review modules or review endpoints Scope boundary: not extracted here; treat as a separate data model
Seller and variant context Offer, SKU, option, and seller state Covered when the source exposes it; otherwise mark the row partial

This keeps the article focused on a single approved product observation and its price/variant context. It does not imply that the same selectors or fields work for search results, category pagination, reviews, or every Lazada market.

A managed third-party scraping API is not the same thing as Lazada Open Platform. The provider may own the collection request, may not support a user-supplied Rola route, and may return a different schema. A proxy configured in your API client does not prove that the provider’s own crawler used that exit. Confirm proxy injection, target scope, billing, and returned fields with the provider before treating it as an alternative.
Do not select a proxy merely because a request failed. First reproduce one URL directly, record the response and page type, and determine whether the problem is rendering, authorization, localization, rate limiting, or connectivity.

Start with Lazada Open Platform When Authorized

Before automating a storefront page, open Lazada Open Platform and check whether an authorized API covers the required seller or application data. The official Getting Started documentation lays out the operational sequence: create an Open Platform account, select a role, create an app, configure app parameters, complete seller authorization, review the API call flow, and use the available testing tools.

lazada-open-platform-getting-started

The official API endpoint documentation and Requests and responses documentation add a second operational check: each request URL includes an API path, the platform recommends HTTPS for API calls, and API responses are JSON documents that report success or error. The capture below is a real documentation page, not a live account response. Use it to verify the response format before writing an adapter; do not present documentation text as extracted Lazada product data.

lazada-api-request-response

Follow the relevant documentation link for the market and app type, confirm the required authorization, and inspect the documented response fields. If the official API returns the fields in your data contract, use it instead of parsing the public storefront. If it does not and public-page research is permitted, continue with the bounded Playwright workflow below. The screenshot proves the documented workflow exists; it does not imply that every API is available to every account.

A self-contained route when live credentials are unavailable

The fixture demonstrates how fictional input values can be mapped into the proposed output schema. It does not test Lazada page parsing, Open Platform authentication, proxy connectivity, or the full business-validation contract. Those checks require separate implementations and evidence before the workflow can be treated as validated.

Set Up the Offline Normalizer and Browser JSON-LD Sample

Create a lazada-pilot/examples directory and save the two Python files and the fixture JSON under the filenames shown below. Run commands from lazada-pilot. The normalized JSON block illustrates generated output; it is not an additional input file. The offline normalizer uses Python’s standard library. Install Playwright and Chromium only for the separate browser example.

Validation environment and scope

Check Recorded result
Offline syntax and fixture validation September 20, 2026, Asia/Shanghai; Windows 10 build 19045; Python 3.12.14
Browser JSON-LD target sample Not run against a live Lazada page, Open Platform app, or Rola credential
Playwright and Chromium Required for a future authorized run; versions were not claimed because the browser path was not executed
Online evidence No end-to-end Lazada extraction result is claimed

The browser JSON-LD sample is a future authorized integration point. The offline fixture is the runnable path here: it validates the data contract and normalization logic, but it is not a target-site result.

Create an isolated environment before an authorized run:

mkdir -p lazada-pilot/examples
cd lazada-pilot
python -m venv .venv
. .venv/bin/activate
python -m pip install playwright
python -m playwright install chromium

On Windows PowerShell:

New-Item -ItemType Directory -Force .\lazada-pilot\examples
Set-Location .\lazada-pilot
py -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install playwright
python -m playwright install chromium

examples/lazada_playwright_sample.py

The following Playwright sample illustrates a single-page JSON-LD extraction path. It is not an Open Platform API client and does not enumerate product variants or parse reviews and search results. It has not been validated against a live Lazada page. Before using it as a collection workflow, add and test response classification, bounded waits, approved-URL checks, locale-aware price handling, and explicit business-validation rules.

"""Single-URL Playwright sample for an authorized public product page.

This sample intentionally stops on human-verification pages and does not solve
CAPTCHAs. It emits one JSON row per JSON-LD offer when possible. The adapter
keeps price meaning, extraction method, transport status, parsing status, and
field validation status separate. It is a design sample and has not been run
against a live Lazada page. It performs no automatic retries; a 429 Retry-After value is recorded for later scheduling. It requires a pre-approved URL list, rechecks the final URL after redirects, and writes no screenshots or raw JSON-LD by default.
"""

from __future__ import annotations

import argparse
import json
import os
import re
from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation
from pathlib import Path
from time import perf_counter
from urllib.parse import urlparse

from playwright.sync_api import Page, sync_playwright


MARKETPLACE_HOSTS = {
    "lazada.sg": ("lazada.sg",),
    "lazada.com.my": ("lazada.com.my",),
    "lazada.co.th": ("lazada.co.th",),
    "lazada.vn": ("lazada.vn",),
    "lazada.com.ph": ("lazada.com.ph",),
    "lazada.co.id": ("lazada.co.id",),
}
ALLOWED_HOST_SUFFIXES = tuple(
    suffix for hosts in MARKETPLACE_HOSTS.values() for suffix in hosts
)


class SampleFailure(RuntimeError):
    def __init__(self, error_type: str, message: str):
        super().__init__(message)
        self.error_type = error_type


def proxy_from_environment() -> dict[str, str] | None:
    server = os.getenv("ROLA_PROXY_SERVER")
    username = os.getenv("ROLA_PROXY_USERNAME")
    password = os.getenv("ROLA_PROXY_PASSWORD")
    supplied = [server, username, password]
    if not any(supplied):
        return None
    if not all(supplied):
        raise RuntimeError("Set all three ROLA_PROXY_* variables or none of them.")
    return {"server": server, "username": username, "password": password}


def normalize_url(url: str) -> str:
    parsed = urlparse(url)
    return parsed._replace(fragment="").geturl()


def host_matches_marketplace(host: str, marketplace: str) -> bool:
    allowed_hosts = MARKETPLACE_HOSTS.get(marketplace.lower())
    return bool(allowed_hosts) and any(
        host == suffix or host.endswith("." + suffix) for suffix in allowed_hosts
    )


def validate_approved_urls(approved_urls: list[str]) -> None:
    if not approved_urls:
        raise ValueError("Provide at least one pre-approved Lazada URL.")
    for approved_url in approved_urls:
        parsed = urlparse(approved_url)
        host = (parsed.hostname or "").lower()
        if parsed.scheme != "https" or not host or not any(
            host == suffix or host.endswith("." + suffix)
            for suffix in ALLOWED_HOST_SUFFIXES
        ):
            raise ValueError(
                "Every approved URL must be an HTTPS Lazada storefront URL."
            )


def validate_target(url: str, approved_urls: list[str]) -> None:
    parsed = urlparse(url)
    host = (parsed.hostname or "").lower()
    if parsed.scheme != "https" or not host or not any(
        host == suffix or host.endswith("." + suffix)
        for suffix in ALLOWED_HOST_SUFFIXES
    ):
        raise ValueError("Use an approved HTTPS Lazada storefront URL.")
    approved_set = {normalize_url(item) for item in approved_urls}
    if normalize_url(url) not in approved_set:
        raise ValueError("URL is outside the pre-approved URL set.")


def validate_marketplace(url: str, marketplace: str) -> None:
    parsed = urlparse(url)
    host = (parsed.hostname or "").lower()
    marketplace = marketplace.lower()
    if marketplace not in MARKETPLACE_HOSTS:
        raise ValueError("Use a marketplace with a verified Lazada host mapping.")
    if not host_matches_marketplace(host, marketplace):
        raise ValueError("Marketplace does not match the URL host.")

def classify_blocking_page(page: Page) -> None:
    """Best-effort check of the main document and accessible frames."""
    texts = []
    try:
        texts.append(page.locator("body").inner_text(timeout=10_000).lower())
        for frame in page.frames[1:]:
            try:
                texts.append(frame.locator("body").inner_text(timeout=2_000).lower())
            except Exception:
                continue
    except Exception as exc:
        raise SampleFailure(
            "page_classification_error",
            "Could not inspect the main document for verification or login state.",
        ) from exc

    combined = "\n".join(texts)
    verification_signals = (
        "verify you are human",
        "complete the verification",
        "human verification",
        "完成验证",
        "人机验证",
        "验证您是人类",
    )
    if any(signal in combined for signal in verification_signals):
        raise SampleFailure(
            "verification",
            "Human verification detected. Stop; do not automate CAPTCHA solving.",
        )

    login_signals = (
        "log in to continue",
        "login to continue",
        "sign in to continue",
        "please log in",
        "请登录",
        "登入",
    )
    page_url = getattr(page, "url", "").lower()
    login_url = any(token in page_url for token in ("/login", "/signin", "/sign-in"))
    if any(signal in combined for signal in login_signals) and (
        login_url or "password" in combined
    ):
        raise SampleFailure(
            "login_required",
            "A login page was detected. Stop; do not automate login without authorization.",
        )

def wait_for_product_json_ld(page: Page) -> None:
    try:
        page.locator('script[type="application/ld+json"]').first.wait_for(
            state="attached",
            timeout=15_000,
        )
    except Exception as exc:
        raise SampleFailure(
            "json_ld_timeout",
            "Product JSON-LD did not appear within 15 seconds.",
        ) from exc


def product_json_ld(page: Page) -> dict:
    """Return one Product node, or fail when the page is ambiguous."""

    def candidates(data: object):
        if isinstance(data, list):
            for item in data:
                yield from candidates(item)
        elif isinstance(data, dict):
            node_type = data.get("@type")
            if node_type == "Product" or (
                isinstance(node_type, list) and "Product" in node_type
            ):
                yield data
            if "@graph" in data:
                yield from candidates(data["@graph"])

    products = []
    for node in page.locator('script[type="application/ld+json"]').all_text_contents():
        try:
            data = json.loads(node)
        except json.JSONDecodeError:
            continue
        products.extend(candidates(data))

    if not products:
        raise SampleFailure(
            "json_ld_missing",
            "No Product JSON-LD found; inspect the current authorized page.",
        )
    if len(products) > 1:
        raise SampleFailure(
            "multiple_products",
            f"Found {len(products)} Product JSON-LD nodes; target selection is not implemented.",
        )
    return products[0]

def offer_list(product: dict) -> list[dict]:
    offers = product.get("offers") or []
    if isinstance(offers, dict):
        offers = [offers]
    return [offer for offer in offers if isinstance(offer, dict)]


def price_value(offer: dict) -> object | None:
    raw = offer.get("price")
    if raw is None:
        raw = offer.get("lowPrice")
    return raw


def numeric_price(raw_price: object) -> str | None:
    """Parse machine numbers and unambiguous currency display strings."""

    if raw_price is None:
        return None
    if isinstance(raw_price, (int, float, Decimal)):
        try:
            value = Decimal(str(raw_price))
            if not value.is_finite() or value <= 0:
                return None
            return str(value.quantize(Decimal("0.01")))
        except InvalidOperation:
            return None

    text = str(raw_price).strip()
    if not text or re.search(r"\d\s*[-–—]\s*\d|\bto\b", text, re.IGNORECASE):
        return None

    cleaned = re.sub(r"[^0-9.,]", "", text)
    if not cleaned:
        return None

    if "," in cleaned and "." in cleaned:
        decimal_separator = "," if cleaned.rfind(",") > cleaned.rfind(".") else "."
        grouping_separator = "." if decimal_separator == "," else ","
        integer_part, fractional_part = cleaned.rsplit(decimal_separator, 1)
        groups = integer_part.split(grouping_separator)
        if len(groups) > 1 and (
            not groups[0] or any(len(group) != 3 for group in groups[1:])
        ):
            return None
        if not fractional_part.isdigit() or len(fractional_part) not in (1, 2):
            return None
        cleaned = "".join(groups) + "." + fractional_part
    elif "," in cleaned or "." in cleaned:
        separator = "," if "," in cleaned else "."
        if cleaned.count(separator) > 1:
            groups = cleaned.split(separator)
            if not groups[0] or any(len(group) != 3 for group in groups[1:]):
                return None
            cleaned = "".join(groups)
        else:
            whole, fraction = cleaned.split(separator)
            if len(fraction) == 3:
                return None
            if not whole or not fraction or len(fraction) not in (1, 2):
                return None
            cleaned = whole + "." + fraction

    try:
        value = Decimal(cleaned)
        if not value.is_finite() or value <= 0:
            return None
        return str(value.quantize(Decimal("0.01")))
    except InvalidOperation:
        return None
def seller_name(offer: dict) -> str | None:
    seller = offer.get("seller")
    if isinstance(seller, dict):
        return seller.get("name")
    return seller if isinstance(seller, str) else None


def context_value(value: object) -> str | None:
    if value is None:
        return None
    if isinstance(value, str):
        return value
    return json.dumps(value, ensure_ascii=False, sort_keys=True)


def stock_status(offer: dict) -> str:
    availability = str(offer.get("availability") or "").lower()
    if "instock" in availability:
        return "in_stock"
    if "outofstock" in availability:
        return "out_of_stock"
    if "preorder" in availability:
        return "preorder"
    return "unknown"


def offer_type(offer: dict) -> str:
    value = offer.get("@type")
    if isinstance(value, list):
        return ",".join(str(item) for item in value)
    return str(value or "Offer")


def transport_status(http_status: int | None) -> str:
    if http_status is None:
        return "no_response"
    return "http_2xx" if 200 <= http_status < 300 else "http_error"


def retry_after_value(response) -> str | None:
    if response is None:
        return None
    return response.headers.get("retry-after")


def classify_response(response) -> None:
    if response is None:
        raise SampleFailure("no_response", "Navigation returned no HTTP response.")
    if not 200 <= response.status < 300:
        detail = f"Target returned HTTP {response.status}."
        retry_after = retry_after_value(response)
        if response.status == 429 and retry_after:
            detail += f" Retry-After: {retry_after}."
        raise SampleFailure("http_error", detail)


def error_type_for(error: Exception) -> str:
    if isinstance(error, SampleFailure):
        return error.error_type
    if isinstance(error, ValueError):
        return "invalid_target"
    error_name = type(error).__name__.lower()
    error_text = str(error).lower()
    if "timeout" in error_name or "timeout" in error_text:
        return "timeout"
    if any(
        token in error_text
        for token in (
            "dns",
            "name not resolved",
            "ssl",
            "tls",
            "certificate",
            "net::err_",
        )
    ):
        return "network_error"
    return "runtime_error"


def failure_row(args, response, error: Exception, started_at: float) -> dict:
    http_status = response.status if response is not None else None
    error_type = error_type_for(error)
    return {
        "marketplace": args.marketplace,
        "source_url": args.url,
        "product_id": None,
        "title": None,
        "seller_name": None,
        "variant_id": None,
        "variant_label": None,
        "raw_price": None,
        "numeric_price": None,
        "currency": None,
        "price_type": "unknown",
        "eligibility_context": None,
        "delivery_context": None,
        "price_source": "json_ld",
        "extraction_method": "json_ld",
        "original_price": None,
        "discount_text": None,
        "stock_status": "unknown",
        "captured_at_utc": datetime.now(timezone.utc).isoformat(),
        "route_label": args.route_label,
        "http_status": http_status,
        "retry_after": retry_after_value(response),
        "transport_status": transport_status(http_status),
        "status": error_type,
        "validation_status": "invalid",
        "validation_errors": [f"{error_type}: {str(error)}"],
        "error_type": error_type,
        "elapsed_ms": int((perf_counter() - started_at) * 1000),
    }


def validation_result(row: dict) -> tuple[str, list[str]]:
    """Separate transport/parse evidence from business-record acceptance."""

    errors: list[str] = []
    hard_failure = False

    if row.get("transport_status") != "http_2xx":
        errors.append("transport_status: only http_2xx can be a valid product record")
        hard_failure = True

    marketplace = str(row.get("marketplace") or "").lower()
    parsed_url = urlparse(str(row.get("source_url") or ""))
    host = (parsed_url.hostname or "").lower()
    if parsed_url.scheme != "https" or not host:
        errors.append("source_url: expected an HTTPS URL with a hostname")
        hard_failure = True
    elif marketplace not in MARKETPLACE_HOSTS or not host_matches_marketplace(
        host, marketplace
    ):
        errors.append("marketplace: unsupported or does not match source_url host")
        hard_failure = True

    required = (
        "marketplace",
        "product_id",
        "title",
        "seller_name",
        "raw_price",
        "numeric_price",
        "currency",
        "price_type",
        "delivery_context",
    )
    for field in required:
        if row.get(field) in (None, ""):
            errors.append(f"{field}: required")

    currency = row.get("currency")
    if not isinstance(currency, str) or not re.fullmatch(r"[A-Z]{3}", currency):
        errors.append("currency: expected a three-letter uppercase code")

    valid_price_types = {
        "listed",
        "promotional",
        "member",
        "voucher-adjusted",
        "quantity-tier",
        "shipping-inclusive",
        "checkout",
    }
    conditional_price_types = {"member", "voucher-adjusted"}
    price_type = row.get("price_type")
    if price_type == "unknown" or price_type not in valid_price_types:
        errors.append("price_type: insufficient or unsupported evidence")
    elif price_type in conditional_price_types and not str(
        row.get("eligibility_context") or ""
    ).strip():
        errors.append("eligibility_context: required for conditional price")

    if row.get("status") == "aggregate_offer":
        errors.append("status: AggregateOffer is not selected SKU evidence")
    if not row.get("variant_id"):
        errors.append("variant_id: selected SKU evidence is missing")
    if row.get("numeric_price") in (None, ""):
        errors.append("numeric_price: selected price is missing")

    if hard_failure:
        return "invalid", errors
    if errors:
        return "partial", errors
    return "valid", []


def rows_from_product(product: dict, args, http_status: int | None) -> list[dict]:
    offers = offer_list(product) or [{}]
    rows = []
    for offer in offers:
        raw_value = price_value(offer)
        raw_price = None if raw_value is None else str(raw_value)
        currency = offer.get("priceCurrency") or product.get("priceCurrency")
        kind = offer_type(offer)
        is_aggregate = "AggregateOffer" in kind
        status = "parsed_without_offer"
        if offer:
            if is_aggregate:
                status = "aggregate_offer"
            elif raw_price is None:
                status = "parsed_without_price"
            else:
                status = "live_parsed"
        row = {
            "marketplace": args.marketplace,
            "source_url": args.url,
            "product_id": product.get("sku") or product.get("productID"),
            "title": product.get("name"),
            "seller_name": seller_name(offer),
            "variant_id": offer.get("sku"),
            "variant_label": offer.get("name") if offer.get("name") != product.get("name") else None,
            "raw_price": raw_price,
            "numeric_price": numeric_price(raw_value),
            "currency": currency,
            "price_type": str(offer.get("priceType") or product.get("priceType") or "unknown"),
            "eligibility_context": context_value(offer.get("eligibleCustomerType")),
            "delivery_context": context_value(offer.get("areaServed")),
            "price_source": "json_ld",
            "extraction_method": "json_ld",
            "original_price": None,
            "discount_text": None,
            "stock_status": stock_status(offer),
            "captured_at_utc": datetime.now(timezone.utc).isoformat(),
            "route_label": args.route_label,
            "http_status": http_status,
            "transport_status": transport_status(http_status),
            "status": status,
            "validation_status": "not_run",
            "validation_errors": [],
            "retry_after": None,
            "error_type": None,
            "elapsed_ms": None,
        }
        if args.include_raw_json_ld:
            row["raw_product_json_ld"] = product
        row["validation_status"], row["validation_errors"] = validation_result(row)
        rows.append(row)
    return rows


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--url", required=True)
    parser.add_argument("--marketplace", required=True)
    parser.add_argument("--approved-url", dest="approved_urls", action="append", required=True)
    parser.add_argument("--include-raw-json-ld", action="store_true")
    parser.add_argument("--output", default="lazada_product.json")
    parser.add_argument("--route-label", default=None)
    args = parser.parse_args()
    args.route_label = args.route_label or (
        "rola" if os.getenv("ROLA_PROXY_SERVER") else "direct"
    )
    args.marketplace = args.marketplace.lower()
    started_at = perf_counter()
    response = None

    try:
        validate_approved_urls(args.approved_urls)
        validate_target(args.url, args.approved_urls)
        validate_marketplace(args.url, args.marketplace)
        with sync_playwright() as playwright:
            browser = playwright.chromium.launch(
                headless=True,
                proxy=proxy_from_environment(),
            )
            try:
                page = browser.new_page(locale="en-US")
                response = page.goto(
                    args.url,
                    wait_until="domcontentloaded",
                    timeout=30_000,
                )
                classify_response(response)
                final_url = page.url
                validate_target(final_url, args.approved_urls)
                validate_marketplace(final_url, args.marketplace)
                args.url = final_url
                classify_blocking_page(page)
                wait_for_product_json_ld(page)
                product = product_json_ld(page)
                rows = rows_from_product(
                    product,
                    args,
                    response.status if response else None,
                )
                elapsed_ms = int((perf_counter() - started_at) * 1000)
                for row in rows:
                    row["retry_after"] = retry_after_value(response)
                    row["elapsed_ms"] = elapsed_ms
                Path(args.output).write_text(
                    json.dumps(rows, indent=2),
                    encoding="utf-8",
                )
            finally:
                browser.close()
    except Exception as exc:
        failure = failure_row(args, response, exc, started_at)
        Path(args.output).write_text(
            json.dumps([failure], indent=2),
            encoding="utf-8",
        )
        raise
if __name__ == "__main__":
    main()

examples/lazada_fixture.json

{
  "marketplace": "lazada.sg",
  "source_url": "https://www.lazada.sg/example-authorized-product",
  "product_id": "DEMO-1001",
  "title": "Demo Wireless Mouse",
  "seller_name": "Example Seller",
  "currency": "SGD",
  "delivery_context": "SG",
  "variants": [
    {
      "variant_id": "DEMO-1001-BLK",
      "variant_label": "Black",
      "raw_price": "S$29.90",
      "original_price": "S$39.90",
      "discount_text": "25% off",
      "price_type": "promotional",
      "eligibility_context": null,
      "delivery_context": "SG",
      "stock_status": "in_stock"
    },
    {
      "variant_id": "DEMO-1001-WHT",
      "variant_label": "White",
      "raw_price": "S$31.90",
      "original_price": null,
      "discount_text": null,
      "price_type": "listed",
      "eligibility_context": null,
      "delivery_context": "SG",
      "stock_status": "in_stock"
    }
  ]
}

examples/normalize_lazada_fixture.py

"""Normalize and validate a fictional Lazada-shaped fixture without network traffic."""

from __future__ import annotations

import json
import re
from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Any
from urllib.parse import urlparse


ROOT = Path(__file__).resolve().parent
SOURCE = ROOT / "lazada_fixture.json"
OUTPUT = ROOT / "normalized_output.json"

VALID_PRICE_TYPES = {
    "listed",
    "promotional",
    "member",
    "voucher-adjusted",
    "quantity-tier",
    "shipping-inclusive",
    "checkout",
    "unknown",
}
CONDITIONAL_PRICE_TYPES = {"member", "voucher-adjusted"}
VALID_STOCK_STATUSES = {"in_stock", "out_of_stock", "preorder", "unknown"}
REQUIRED_FIELDS = (
    "marketplace",
    "source_url",
    "product_id",
    "title",
    "seller_name",
    "variant_id",
    "variant_label",
    "raw_price",
    "numeric_price",
    "currency",
    "price_type",
    "delivery_context",
    "price_source",
    "extraction_method",
    "stock_status",
    "captured_at_utc",
    "route_label",
    "transport_status",
)


def numeric_price(raw_price: object) -> str | None:
    """Parse machine numbers and unambiguous currency display strings."""

    if raw_price is None:
        return None
    if isinstance(raw_price, (int, float, Decimal)):
        try:
            value = Decimal(str(raw_price))
            if not value.is_finite() or value <= 0:
                return None
            return str(value.quantize(Decimal("0.01")))
        except InvalidOperation:
            return None

    text = str(raw_price).strip()
    if not text or re.search(r"\d\s*[-–—]\s*\d|\bto\b", text, re.IGNORECASE):
        return None

    cleaned = re.sub(r"[^0-9.,]", "", text)
    if not cleaned:
        return None

    if "," in cleaned and "." in cleaned:
        decimal_separator = "," if cleaned.rfind(",") > cleaned.rfind(".") else "."
        grouping_separator = "." if decimal_separator == "," else ","
        integer_part, fractional_part = cleaned.rsplit(decimal_separator, 1)
        groups = integer_part.split(grouping_separator)
        if len(groups) > 1 and (
            not groups[0] or any(len(group) != 3 for group in groups[1:])
        ):
            return None
        if not fractional_part.isdigit() or len(fractional_part) not in (1, 2):
            return None
        cleaned = "".join(groups) + "." + fractional_part
    elif "," in cleaned or "." in cleaned:
        separator = "," if "," in cleaned else "."
        if cleaned.count(separator) > 1:
            groups = cleaned.split(separator)
            if not groups[0] or any(len(group) != 3 for group in groups[1:]):
                return None
            cleaned = "".join(groups)
        else:
            whole, fraction = cleaned.split(separator)
            if len(fraction) == 3:
                return None
            if not whole or not fraction or len(fraction) not in (1, 2):
                return None
            cleaned = whole + "." + fraction

    try:
        value = Decimal(cleaned)
        if not value.is_finite() or value <= 0:
            return None
        return str(value.quantize(Decimal("0.01")))
    except InvalidOperation:
        return None

def validate_record(row: dict[str, Any]) -> list[str]:
    """Return contract failures instead of assuming a normalized row is valid."""

    errors: list[str] = []

    for field in REQUIRED_FIELDS:
        value = row.get(field)
        if value is None or (isinstance(value, str) and not value.strip()):
            errors.append(f"{field}: required")

    marketplace = str(row.get("marketplace") or "").lower()
    parsed_url = urlparse(str(row.get("source_url") or ""))
    host = (parsed_url.hostname or "").lower()
    if parsed_url.scheme != "https":
        errors.append("source_url: must use https")
    if marketplace and host and not (host == marketplace or host.endswith(f".{marketplace}")):
        errors.append("source_url: hostname does not match marketplace")

    currency = row.get("currency")
    if not isinstance(currency, str) or not re.fullmatch(r"[A-Z]{3}", currency):
        errors.append("currency: expected a three-letter uppercase code")

    price_type = str(row.get("price_type") or "")
    if price_type not in VALID_PRICE_TYPES:
        errors.append("price_type: unsupported value")
    elif price_type == "unknown":
        errors.append("price_type: insufficient evidence")
    elif price_type in CONDITIONAL_PRICE_TYPES and not str(
        row.get("eligibility_context") or ""
    ).strip():
        errors.append("eligibility_context: required for conditional price")

    try:
        amount = Decimal(str(row.get("numeric_price")))
        if amount <= 0:
            errors.append("numeric_price: must be greater than zero")
    except Exception:
        errors.append("numeric_price: invalid decimal")

    if row.get("stock_status") not in VALID_STOCK_STATUSES:
        errors.append("stock_status: unsupported value")

    transport_status = str(row.get("transport_status") or "")
    http_status = row.get("http_status")
    if transport_status.startswith("http_") and not (
        isinstance(http_status, int) and 200 <= http_status < 300
    ):
        errors.append("transport_status: HTTP error cannot be valid")

    return errors


def main() -> None:
    source = json.loads(SOURCE.read_text(encoding="utf-8"))
    captured_at = datetime.now(timezone.utc).isoformat()
    rows = []

    for variant in source["variants"]:
        row: dict[str, Any] = {
            "marketplace": source["marketplace"],
            "source_url": source["source_url"],
            "product_id": source["product_id"],
            "title": source["title"],
            "seller_name": source["seller_name"],
            "variant_id": variant["variant_id"],
            "variant_label": variant["variant_label"],
            "raw_price": variant["raw_price"],
            "numeric_price": numeric_price(variant["raw_price"]),
            "currency": source["currency"],
            "price_type": variant["price_type"],
            "eligibility_context": variant["eligibility_context"],
            "delivery_context": variant["delivery_context"],
            "price_source": "fixture",
            "extraction_method": "fixture_normalizer",
            "original_price": variant["original_price"],
            "discount_text": variant["discount_text"],
            "stock_status": variant["stock_status"],
            "captured_at_utc": captured_at,
            "route_label": "fixture-no-network",
            "http_status": None,
            "transport_status": "offline_no_network",
            "status": "fixture_candidate",
            "validation_status": "pending",
            "validation_errors": [],
        }
        errors = validate_record(row)
        row["validation_errors"] = errors
        row["validation_status"] = "valid" if not errors else "invalid"
        row["status"] = "fixture_validated" if not errors else "fixture_rejected"
        rows.append(row)

    OUTPUT.write_text(json.dumps(rows, indent=2), encoding="utf-8")
    valid_count = sum(row["validation_status"] == "valid" for row in rows)
    print(f"Validated {valid_count} variant rows")
    print(f"Contract validation: {valid_count}/{len(rows)} passed")
    for row in rows:
        if row["validation_errors"]:
            print(
                f"Rejected {row['variant_id']}: "
                + "; ".join(row["validation_errors"])
            )
    print(f"Output: {OUTPUT}")


if __name__ == "__main__":
    main()

Run the browser sample only after replacing placeholders with an approved URL and a provider configuration whose protocol and authentication combination has been verified. Repeat --approved-url for every URL in the approved set, including any expected canonical redirect target; the sample fails closed when the final URL is not listed:

export ROLA_PROXY_SERVER="http://gateway.example:port"
export ROLA_PROXY_USERNAME="generated_username"
export ROLA_PROXY_PASSWORD="generated_password"
export TARGET_URL="https://www.lazada.sg/approved-product-url"
export LAZADA_MARKETPLACE="lazada.sg"
python examples/lazada_playwright_sample.py \
  --url "$TARGET_URL" \
  --approved-url "$TARGET_URL" \
  --marketplace "$LAZADA_MARKETPLACE" \
  --route-label "sg-residential-sticky-a" \
  --output lazada_product.json

The following is a shell syntax template, not a validated proxy configuration. Replace placeholders only after checking the selected product and browser support. Inject passwords through a protected environment; do not write real values into public scripts or logs.

On Windows PowerShell, use the equivalent environment-variable and command form:

$env:ROLA_PROXY_SERVER = "http://gateway.example:port"
$env:ROLA_PROXY_USERNAME = "generated_username"
$env:ROLA_PROXY_PASSWORD = "generated_password"
$env:TARGET_URL = "https://www.lazada.sg/approved-product-url"
$env:LAZADA_MARKETPLACE = "lazada.sg"
python .\examples\lazada_playwright_sample.py --url $env:TARGET_URL --approved-url $env:TARGET_URL --marketplace $env:LAZADA_MARKETPLACE --route-label "sg-residential-sticky-a" --output .\lazada_product.json

For a direct baseline, unset all three ROLA_PROXY_* variables; in PowerShell, use Remove-Item Env:ROLA_PROXY_SERVER, Env:ROLA_PROXY_USERNAME, Env:ROLA_PROXY_PASSWORD -ErrorAction SilentlyContinue. Keep the exact Python, Playwright, Chromium, operating-system, proxy protocol, authentication, URL scope, and command in the validation record. Region and session settings must use the actual generated rules; do not infer effective behavior from route_label. Do not treat live_parsed, HTTP 200, or a working proxy as business-data success; check validation_status and manually compare the same seller, SKU, market, currency, and price type.

Verify a Rola IP Route Before Playwright

Generate the host, port, username, password, location, and session settings in your Rola IP account. Store them in a secret manager or protected environment; do not paste them into screenshots, repositories, analytics events, or support tickets.

For permitted regional catalog checks, a residential proxy may fit when the page genuinely needs a consumer-network route in a selected market. Do not label rotating datacenter routes “lower cost” without a common accounting basis: record the provider billing unit, requested traffic, retry volume, and cost per valid deduplicated record. Mobile IP is for a real mobile-network requirement, not a universal “stronger” route.

Before the first target request, record the pilot controls:

Control Record before execution
Target market Storefront, delivery market, currency, and approved URL scope
Exit region Rola country/region selection; do not infer the product price from IP alone
Session One browser context for a product and its variants; use only verified session settings
Concurrency Start with one context and one URL at a time
Request budget Three approved URLs for functional checks; set any larger budget separately
Stop conditions Verification page, explicit denial, 403, 429, 407, DNS/TLS failure, timeout, or partial field validation
Cost measure Provider charge and traffic divided by valid, deduplicated records; keep maintenance cost separate

Rola product and billing procurement checklist

No live Rola product tier, account quote, invoice, region selection, session duration, or billing result is included here. Complete the short checklist from the Rola account, pricing/order records, product terms, or written support confirmation; leave a field pending rather than inferring a product conclusion.

Field Record before the pilot
Product type Residential, datacenter, mobile, or static option selected for the approved task
Region Country/state/city selection and matching Lazada market
Protocol and authentication Verified protocol, host/port format, and authentication mode
Session settings Session ID, duration, rotation rule, and concurrency
Billing unit Exact unit from the current pricing, quote, checkout, or invoice
Minimum purchase Exact minimum or pending confirmation
Traffic validity Exact validity or expiry window from the product terms/account
Failure/retry billing Whether failed and retried traffic is billed
Budget ceiling Maximum URLs, requests, traffic, and currency amount
Official source and date Source URL or account reference, exact section, and last-verified date

Do not treat placeholder endpoints or tool screenshots as connection evidence. Publish only a redacted configuration; keep credentials in a protected secret workflow.

1. Check connectivity, protocol, exit, and status

Open the Rola IP proxy checker, enter the generated endpoint, choose its protocol, and run a check. Record whether it is reachable, its exit region, latency, anonymity classification, and target status when an authorized target URL is used.

rola-proxy-checker-redacted

The capture proves that the checker interface exists. It does not prove that Advanced Options, an arbitrary target URL, or a Lazada request has been tested successfully; record those capabilities only from the selected account and an authorized run.

A successful neutral check proves that the route can connect through the test path. It does not prove Lazada will return a product page, that a price is visible, or that the workflow is authorized.

2. Measure speed separately

Use the Rola IP proxy speed test to compare latency, time to first byte, throughput, and failures across repeated runs. Keep protocol and endpoint consistent. Test near the intended time and region because routes vary.

rola-proxy-speed-test

The speed test runs from Rola IP servers, so its latency and throughput describe the test path rather than the local or worker path. Use it for route-level comparison only; measure end-to-end collection time separately, and set any p95 threshold only after defining the permitted performance sample.

3. Keep credentials out of code

The environment variables shown in the setup section are placeholders. Read the host, port, username, password, location, and session settings from a protected secret workflow; do not paste them into source, logs, screenshots, analytics events, or support tickets.

Configure a proxy protocol and authentication method supported by both the provider and the selected browser. HTTP proxy authentication settings should not be assumed to work unchanged with every SOCKS5 setup. Document the exact provider, browser, protocol, and authentication combination you tested; the live SOCKS5-with-auth combination remains unverified here.

Match Rotation to the Task

Use a stable session for a short sequence that must preserve market and variant state: open one product, select a permitted variant, capture the price, and save evidence. Use a fresh route for independent stateless samples only when rotation is allowed and necessary. Never switch countries or identities mid-session merely to retry a denial.

Record a non-secret route_label, such as sg-residential-sticky-a, beside each row. This enables outcome comparison without logging the gateway password or authenticated proxy URL. A stable session can preserve market and variant state for one short workflow; independent tasks may use independent routes only when the target rules and approved budget allow it.

Run the Self-Contained Price and Variant Replay

The inline normalize_lazada_fixture.py file is the executable part of the self-contained route. It replays a Lazada-shaped response without sending network traffic, runs explicit data-contract checks before assigning valid, preserves raw and numeric prices, records price_type, eligibility_context, delivery_context, and keeps validation errors visible when a row is rejected.

python examples/normalize_lazada_fixture.py

The command prints Validated 2 variant rows and Contract validation: 2/2 passed after the contract checks pass, then writes examples/normalized_output.json when run from lazada-pilot. The 2/2 result means both fictional rows passed the local contract; it does not mean a live Lazada page was reached or accepted. A malformed fixture is written with validation_status: invalid and visible validation_errors, rather than being counted as passed. The output is a fixture result, not live Lazada data or a proxy benchmark. The screenshot below is retained as a visual aid only; the reproducible evidence is the inline code, command, and output block above.

fixture-validation-output

Validate a Three-URL Pilot

Use the three-URL pilot as a functional check:

  1. One simple product with one visible price.
  2. One product with multiple variants.
  3. One product with a visible promotion or original price.

Record each run’s duration and outcome individually. Compare each stored row with a manual observation in the same market and time window. A row is not valid merely because the page loaded: seller, selected variant, currency, price_type, delivery context, and price token must match the intended observation. These three URLs are a small-sample acceptance check, not a reliable performance benchmark.

Functional metric Definition Small-sample target
Transport outcome Expected page type or explicit denial returned Record every attempt
Parsing outcome Product/offer data parsed without a verification template Record every attempt
Field validation Required contract fields and price meaning agree 3 of 3 before scaling
Price agreement Manual and stored definitions match 3 of 3
Variant coverage Each material variant has a row or documented exclusion 100% of pilot scope
Verification-gate rate Verification pages divided by attempts Record separately; do not generalize

Plan a separate performance sample

Do not calculate or present p95 from the three functional URLs. After functional checks pass, define a separate permitted performance sample with a written request budget, time window, concurrency, timeout, retry policy, route/session policy, and cleanup rule. Record each duration and failure before calculating p95. A practical starting design is 30 approved page loads at concurrency 1, with the final sample size reduced if the target policy or budget requires it; disclose the actual N. Never turn the result into a general success-rate or latency promise.

Performance field Record before execution
Sample size and window N, start/end time, timezone, and target market
Request budget Maximum requests, traffic, cost, and valid-record definition
Client controls Concurrency, timeout, bounded retries, and Retry-After handling
Success rule Transport, parsing, and field-validation criteria kept separate
Cleanup Temporary credentials, output files, and browser data handling

For ongoing competitive pricing, document the alert rule and assess whether proxies for price monitoring fit the approved markets and scale.

Troubleshooting Lazada Scraping

Symptom Check first Safe next step
Empty list Challenge, login, locale selector, or changed template? Classify page before parsing
Missing price JavaScript, variant state, or allowed network response? Wait for a specific state
Wrong currency Host, delivery location, cookies, and exit agree? Reset profile; test one market
HTTP 403 Authorization and known-good baseline Reduce scope; review API option
HTTP 429 Request frequency and Retry-After header Honor the header when valid; otherwise use bounded backoff within the approved budget
HTTP 407 Proxy host, port, protocol, username, password Recopy endpoint; never log secrets
DNS failure Resolver, hostname, and proxy endpoint Test name resolution from the same runtime; stop before changing selectors
Connection refused Host, port, protocol, and allowlist Test the proxy endpoint separately; do not rotate automatically
Connection timeout Timeout value, route, target, and request budget Record elapsed time, apply bounded retry only if approved, then stop
Proxy passes but target blocks Tests measure different paths Separate route and target outcomes
Price changes Seller, variant, promotion, market, or time changed? Compare identical dimensions

For every failure, record the exact environment, URL scope, route label, HTTP status, parsing status, validation status, and elapsed time. A retest is successful only when the same approved market and product state returns the expected page type and the required fields pass validation. Do not disable TLS verification, do not solve verification challenges, and do not turn every denial into automatic IP rotation.

A Measurable Next Step

Run the offline fixture first. Then use the three approved URL cases—single price, multiple variants, and a promotion—to record transport, parsing, field validation, and cost outcomes separately. Stop on a denial, verification page, 403, 429, timeout, or partial record; expand only when all three approved cases meet the contract and budget.

Conclusion

Permission and data identity come before selectors. The fixture demonstrates normalization only; the browser JSON-LD and Rola route remain scoped, unverified live paths. Report only records whose seller, variant, market, currency, price meaning, transport, and validation evidence agree.

Frequently Asked Questions