# Changelog of Product Price & Stock Monitor (`al_mansouri/product-price-monitor`) Actor

- **URL**: https://apify.com/al\_mansouri/product-price-monitor/changelog.md
- **Full Actor documentation**: https://apify.com/al\_mansouri/product-price-monitor.md

## Changelog

All notable changes to the Product Price & Stock Monitor are recorded here.

The record shape, the error codes, and the limits quoted in the README are a published contract.
Adding, renaming, or restatusing any of them is a breaking change and must reach the README, the
dataset schema, and this file together.

### \[0.1.0] — unreleased

#### Fixed

- **A run whose baseline store cannot be opened no longer dies.** It used to call `Actor.fail()`,
  so a customer — or Apify's automated quality test, which runs this Actor on *their* account —
  saw a failed run with no output at all. That is what flagged the Website Change Monitor under
  maintenance on 2026-08-22 and the App Store Review Monitor on 2026-08-28, and this Actor's public
  runs show the same signature. It now falls back to the run's own key-value store, publishes every
  product as a first sighting, puts the new `STATE_STORE_UNAVAILABLE` warning on every row, and says
  so in the run's status message. It never claims a change. `STATE_UNAVAILABLE` is still raised
  when the named store *and* the run's own store both refuse.

- **A first run now produces rows.** With `onlyReportChanges` on — the default — a first sighting
  was treated as "nothing changed" and nothing was pushed, so a customer's first run ended with an
  empty dataset. That contradicted this Actor's README ("every product is a `baseline` row") and its
  charge tiering, which prices a baseline as a check because "the customer got a starting point out
  of it". The Store's daily auto-test is the sharper consequence: it flags an Actor whose default
  input yields an empty dataset three days running as under maintenance. A first sighting is now
  published and charged as a check; a genuinely quiet check still publishes nothing and still
  advances the baseline.

- **`robots.txt` rules written with wildcards are now obeyed.** The shared policy used Python's
  `urllib.robotparser`, which implements neither `*` nor `$` in path patterns, so every rule of the
  form `Disallow: /path/*` — an entirely ordinary way to write one — was read as permission. It is
  replaced by a spec-correct matcher (`agent_tools_core.robots`) supporting `*`, `$`,
  longest-pattern-wins precedence with `Allow` breaking ties, and per-user-agent groups.

  **This can change what a run reads.** Addresses that were fetched before may now be skipped, on
  sites whose robots.txt asked for that all along.

First working version. Free; charge events are declared but no prices are set.

#### Added

- Per-product price and stock monitoring across **Shopify**, **Salla (سلة)**, and **Zid (زد)**,
  behind one adapter interface, so a row means the same thing whichever platform served it.
- Typed comparison rather than text diff: `price-increased`, `price-decreased`, `sale-started`,
  `sale-ended`, `back-in-stock`, `out-of-stock`, `variant-stock-changed`, and `baseline`.
- Comparison on the **effective price** — what a shopper pays — so a product moving from
  "100, no sale" to "100, on sale for 60" is reported as a price drop.
- A per-URL baseline in a **named** key-value store on the customer's own account, persisting
  across runs. Only the compared fields are stored, never the page.
- Charge events `product-checked` and `change-detected`, declared but not priced.

#### Decisions worth knowing

- **The baseline is advanced only after the row is published.** Advancing first and failing to
  publish loses a change silently and forever; publishing first and failing to save repeats an
  alert, which is visible and chaseable. The second is the trade this product exists to make.
- **A first sighting is charged as a check, not as a change.** Billing it at the alert price would
  make adding a product to a watch list feel like a trap.
- **Shopify is read through `/products/<handle>.js`, not `.json`.** The two carry different
  fields: `.json` publishes decimal prices but omits `available` entirely, so every product read
  through it reported unknown stock — half of what this Actor is for. `.js` carries stock at
  product and variant level and states money in minor units, which is why prices are divided by
  100\. The endpoint is undocumented and one measured store already refuses it, so it is an
  optimisation with a page fallback, never a requirement.
- **Salla is read from the product page, not the catalogue API.** The API distinguishes list price
  from sale price, but has no single-product query — `/products/<id>` answers 410, and
  `product_id=` and `ids[]=` filters are ignored. Reading one watched product through it would mean
  paginating a catalogue on every check. The page publishes what a shopper pays, which is the
  number a monitor is for, so Salla rows report price moves and never claim a sale started.

#### Measured

- **0 false alerts** across 21 live products from 12 stores on all three platforms, read twice on
  2026-08-26 a minute apart with nothing edited between the passes. A 22nd product published
  neither a storefront object nor JSON-LD and is counted as a failure rather than dropped.
  Reproduce with `scripts/run_false_alert_sweep.py`.
- Shopify `/products/<handle>.js` served by 5 of 6 live stores; `shop.tesla.com` answers 403.
  One product with 13 variants is 12.5 KB, which is what makes a scheduled per-product check cheap.

#### Fixed

- **The alert charge tier could never fire.** `event_for_record()` compared `record.event` to the
  enum member with `is`. `PublicModel` sets `use_enum_values=True`, so an enum field passed
  explicitly to the constructor holds the plain string — and the runner always passes `event`
  explicitly — making the comparison false for every row this Actor can produce. A real price change was
  therefore charged at the cheap check tier. No customer was affected, because this Actor is free
  and no prices are set; pricing it without this fix would have set a tier that silently earned
  nothing, which is exactly the failure that importing the event names into
  `scripts/set_pricing.py` is meant to prevent and cannot see.
- Hardened the same comparison in `runner.py`, which tested `published.status` against
  `ManifestStatus.FAILED` with `is not`. That one was **not** live: the failure manifests are built
  with `model_copy`, which skips validation and so keeps the enum member, and the check behaved
  correctly. It was one refactor away from not doing — any path that re-validates the record yields
  the string instead — and a baseline that advances after a failed publish loses the change
  permanently.
- Added `tests/test_storage.py`, which drives `event_for_record()` with real rows rather than
  asserting the `CHARGED_EVENTS` tuple, and `tests/test_runner_ordering.py`, which pins the
  publish-then-advance ordering against both representations. Asserting the tuple is what let this
  survive: it proves the two names exist, not that either is reachable.

#### Known gaps

- No sale detection on Salla, for the reason above. Price moves and stock are reported.
- Variant-level stock needs the platform to publish it; the page fallbacks do not.
- The Actor watches the products it is given and does not discover more from a store.
