# Changelog of Salla Store Scraper — Products, Prices & Stock (`al_mansouri/salla-store-scraper`) Actor

- **URL**: https://apify.com/al\_mansouri/salla-store-scraper/changelog.md
- **Full Actor documentation**: https://apify.com/al\_mansouri/salla-store-scraper.md

## Changelog

All notable changes to the Salla Store Scraper are recorded here.

### \[0.1.0] — unreleased

First working version, not yet published to the Store. The launch scorecard has been run — 31 live
stores and 855 products on 2026-08-22 — and its numbers are in the README and under **Measured**
below.

#### Added

- `platform` on every row, always `"salla"`. The Zid Store Scraper publishes the same field with
  its own value and is otherwise field-for-field identical, so the two datasets concatenate into
  one sheet and each row still says which storefront platform it came from. A column present in
  one export and missing from the other would not survive that join, which is why this Actor
  carries a field whose value never changes.

- Reads Salla storefronts on both address shapes — a store on `salla.sa/<store>` and a store on
  its own domain — from a store home page, a category page, or a single product page.

- Two published row shapes, told apart by `event`: one `product-scraped` row per product and one
  `store-analyzed` row per store. Every row is self-contained.

- Discovery via the storefront catalogue service, with the store's sitemap as a fallback and a
  direct read for a single product address. Which one was used is published as `discoveryMethod`.

- Arabic and English product names kept in separate fields, assigned by the script the store
  actually wrote them in. Nothing is machine-translated.

- `maxItems`, `includeOutOfStock`, `includeVariants`, `language`, `respectRobots`, and
  `proxyConfiguration` inputs.

- `ErrorCode` and `HTTP_STATUS_BY_ERROR` — the stable, published failure contract, including
  `NOT_A_SALLA_STORE`, `ROBOTS_DISALLOWED`, `STORE_UNREACHABLE`, `STORE_TIMEOUT`, and
  `PRODUCT_PARSE_FAILED`.

- Bounded scheduler with deterministic, migration-safe row IDs, so a resurrected run never
  republishes or recharges a product it has already delivered.

- Pay-per-event commit binding on the shared protocol with `product-scraped` and `store-analyzed`
  events, declared but not priced. The Actor is free; only Apify's platform usage applies.

- Docker build on the plain Python base — this Actor needs no browser.

- `hasOptions` on each product row, so a product whose variants Salla will not publish is
  distinguishable from a product that genuinely has none.

- A bounded sample of each store's products is read a second time, from the product's own page,
  and the two are compared. A disagreement about price or stock degrades that row to `warning`
  with `PRICE_MISMATCH` or `AVAILABILITY_MISMATCH`; the store row publishes `pricesCrossChecked`
  and `pricesDisagreed`.

- Brand assets drawn from code (`scripts/prepare_brand_assets.py`), a pricing script that refuses
  to publish an unmeasured tier (`scripts/set_pricing.py`), and the launch scorecard sweep
  (`scripts/run_store_scorecard.py`).

#### Fixed

- **`salePrice` no longer misreports a third of rows.** Salla's catalogue uses `0` as its
  *no-discount* sentinel rather than null, and its `price` field is the list price on some rows
  and what the shopper actually pays on others — `price=290 sale_price=145 regular_price=290` and
  `price=749 sale_price=749 regular_price=1099` are both real rows from live stores. Passing those
  numbers through published `salePrice: 0.0` on products that cost money, and `salePrice` equal to
  `price` on products that were not discounted at all. Measured across 480 products on 8 live
  stores: 67 rows (14%) advertised as free, a further 91 (19%) carrying a discount to their own
  price, and every one of them `status: "verified"` with no warning.

  `price` is now read from `regular_price` and a sale is claimed only when `sale_price` is a real
  number below it, which is what the dataset schema has always labelled the two fields. The same
  480 products now produce no zero sale prices and no self-discounts, and 117 genuine reductions
  are visible where 26 were before — the `regular_price` the old mapping discarded was the one
  that made a markdown legible.

  The store cross-check could not have caught this: it accepts a page price matching *either*
  published number, and the number it matched was the one that was right.

- **robots.txt is now honoured for every address, not only the one submitted.** The start URL
  was checked and everything discovered afterwards was not, so a store that welcomed crawlers to
  its front page and closed its product pages had those pages read anyway — by sitemap discovery
  and by the price cross-check alike. Both now consult the policy before fetching. Skipped
  product addresses raise `ROBOTS_LIMITED_DISCOVERY`, spelled as the Zid Actor spells it, so the
  catalogue is never quietly short. A cross-check skipped this way is silent: not checking is not
  a claim, and `pricesCrossChecked` already reports the smaller number.

#### Decisions worth knowing

- **A closed store is a warning, not an error.** Salla serves a maintenance page, with HTTP 200,
  for a store its owner has switched off. The store is real, so `NOT_A_SALLA_STORE` would be
  wrong and silence would imply an empty catalogue.

- **`maxItems` is applied per store, not per run.** A shared counter would make a row's identity
  depend on how far another store had got when the container migrated, which breaks the
  deterministic ID that stops double-charging. For a single-store run the two readings are
  identical.

- **A store publishing no product options is counted, not warned about.** Measured on live stores,
  most Salla catalogues publish none; warning about it would make `warning` the usual status and
  drain the field of meaning. The count is on the store row as `productsWithOptions`.

- **A URL slug is never published as a SKU.** Salla fills the product page's structured `sku` with
  the product's own address tail when the merchant set no SKU. No SKU stays `null`.

- **A disagreement is only claimed when the page matches neither price.** Salla's product-page
  structured data publishes the *regular* price even while the shop shows a sale price, so a
  discounted product legitimately carries two different numbers. Comparing against the effective
  price alone reported a mismatch on every discounted product on a live store — a warning that
  fires on normal operation is worse than no warning at all.

- **Only a sample of each store is cross-checked.** Checking every product would double the
  requests made of the store. The store row publishes how many were checked, so the strength of
  the claim is visible rather than implied.

- **The catalogue service has its own byte ceiling.** One page of 15 products from a live store
  measured 2.15 MB, over the shared 2 MB page bound. Exceeding the ceiling is reported rather than
  truncated: a JSON body cut short would either fail to parse or, worse, parse into a partial
  catalogue that looks complete.

- An output preview in the README built from rows a live run actually produced
  (`scripts/capture_output_rows.py` + `scripts/render_output_preview.py`). No sample data is
  written by hand.

#### Measured

Run against 31 live stores and 855 products on 2026-08-22
(`scripts/run_store_scorecard.py`): name, price and availability 100%, images 99.4%, categories
89.8%, SKU 13.7%, variants 0%. Of 153 products also read from their own product page, 11
disagreed — 92.8% agreement.

#### Known gaps

- **Variants cannot be read.** 26% of products across the measured stores declare options like
  colour or size, and Salla publishes the option *values* nowhere reachable over HTTP: the
  listing exposes no `options` payload under any parameter, the product page renders the option
  group with an empty `values` array, and `products/options` prices a selection rather than
  enumerating one. Those products carry `hasOptions: true` so the gap is visible instead of
  looking like a product with no variants. Closing it means adding a browser, which this Actor
  otherwise does not need.
- `language` reports whether a store publishes the requested language and warns when it does not.
  It does not yet switch the catalogue, because the catalogue service answers in the store's own
  default language.
