market-sources 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. market_sources-0.1.0/.gitignore +9 -0
  2. market_sources-0.1.0/CHANGELOG.md +25 -0
  3. market_sources-0.1.0/LICENSE +21 -0
  4. market_sources-0.1.0/PKG-INFO +251 -0
  5. market_sources-0.1.0/README.md +215 -0
  6. market_sources-0.1.0/pyproject.toml +62 -0
  7. market_sources-0.1.0/src/market_sources/__init__.py +34 -0
  8. market_sources-0.1.0/src/market_sources/base.py +108 -0
  9. market_sources-0.1.0/src/market_sources/impersonate.py +103 -0
  10. market_sources-0.1.0/src/market_sources/kaspi/__init__.py +1 -0
  11. market_sources-0.1.0/src/market_sources/kaspi/client.py +133 -0
  12. market_sources-0.1.0/src/market_sources/kaspi/parse.py +286 -0
  13. market_sources-0.1.0/src/market_sources/kaspi/source.py +72 -0
  14. market_sources-0.1.0/src/market_sources/olx/__init__.py +1 -0
  15. market_sources-0.1.0/src/market_sources/olx/client.py +91 -0
  16. market_sources-0.1.0/src/market_sources/olx/parse.py +134 -0
  17. market_sources-0.1.0/src/market_sources/olx/source.py +107 -0
  18. market_sources-0.1.0/src/market_sources/ozon/__init__.py +1 -0
  19. market_sources-0.1.0/src/market_sources/ozon/browser.py +114 -0
  20. market_sources-0.1.0/src/market_sources/ozon/parse.py +272 -0
  21. market_sources-0.1.0/src/market_sources/ozon/source.py +61 -0
  22. market_sources-0.1.0/src/market_sources/playwright_session.py +424 -0
  23. market_sources-0.1.0/src/market_sources/polite_http.py +288 -0
  24. market_sources-0.1.0/src/market_sources/py.typed +0 -0
  25. market_sources-0.1.0/tests/fakes.py +132 -0
  26. market_sources-0.1.0/tests/test_base.py +81 -0
  27. market_sources-0.1.0/tests/test_browser_state.py +267 -0
  28. market_sources-0.1.0/tests/test_impersonate.py +95 -0
  29. market_sources-0.1.0/tests/test_kaspi.py +599 -0
  30. market_sources-0.1.0/tests/test_olx.py +289 -0
  31. market_sources-0.1.0/tests/test_ozon.py +442 -0
  32. market_sources-0.1.0/tests/test_playwright_session.py +345 -0
  33. market_sources-0.1.0/tests/test_polite_http.py +342 -0
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[co]
3
+ .pytest_cache/
4
+ .venv/
5
+ *.egg-info/
6
+ .ruff_cache/
7
+ dist/
8
+ build/
9
+ .claude/
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses [Semantic Versioning](https://semver.org/).
5
+
6
+ ## [0.1.0] - 2026-10-13
7
+
8
+ First public release.
9
+
10
+ ### Added
11
+
12
+ - `ProductSource` protocol with source-shaped Pydantic models: `RawProduct`, `RawOffer`, `RawReview`, `RawReviewStats`.
13
+ - `KaspiSource`: search, seller offers (price, delivery type, delivery date and cost, merchant rating), reviews with
14
+ rating stats, specs. Optional proxy via `KASPI_PROXY`.
15
+ - `OlxSource`: search, the listing as a single offer, and specs (condition, seller type, city) over OLX.kz's public
16
+ JSON API. OLX has no product reviews, so `reviews()` returns an empty list.
17
+ - `OzonSource`: search, offers, reviews and specs through Ozon.kz's composer API in a real browser, with a
18
+ persisted session (`OZON_STATE_PATH`).
19
+ - `PoliteClient`: one pooled `httpx.AsyncClient` with a small semaphore, jittered pacing, browser headers, proxy,
20
+ exponential backoff that honors `Retry-After`, and retries only for idempotent methods by default.
21
+ - `BrowserSession`: lazy persistent headless Chromium with serialized navigation, a `prepare_page` hook,
22
+ relaunch on a dead session and idle teardown.
23
+ - `ImpersonateTransport`: an httpx transport over `curl_cffi` with a Chrome TLS fingerprint; `OlxClient` uses it
24
+ automatically when the `impersonate` extra is installed, because OLX.kz's CloudFront WAF rejects httpx.
25
+ - Optional extras: `browser` (playwright), `impersonate` (curl_cffi).
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniil Petrov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,251 @@
1
+ Metadata-Version: 2.5
2
+ Name: market-sources
3
+ Version: 0.1.0
4
+ Summary: Async Python scrapers for Kaspi.kz, OLX.kz and Ozon.kz behind one ProductSource protocol: search, offers, reviews
5
+ Project-URL: Homepage, https://github.com/octrow/market-sources
6
+ Project-URL: Issues, https://github.com/octrow/market-sources/issues
7
+ Project-URL: Changelog, https://github.com/octrow/market-sources/blob/main/CHANGELOG.md
8
+ Author: Daniil Petrov
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: asyncio,httpx,kaspi,kazakhstan,marketplace,olx,ozon,playwright,price-monitoring,scraper
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Framework :: AsyncIO
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Internet :: WWW/HTTP
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.12
24
+ Requires-Dist: httpx>=0.27
25
+ Requires-Dist: pydantic>=2
26
+ Requires-Dist: tzdata; sys_platform == 'win32'
27
+ Provides-Extra: browser
28
+ Requires-Dist: playwright>=1.44; extra == 'browser'
29
+ Provides-Extra: dev
30
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
31
+ Requires-Dist: pytest>=8; extra == 'dev'
32
+ Requires-Dist: ruff>=0.6; extra == 'dev'
33
+ Provides-Extra: impersonate
34
+ Requires-Dist: curl-cffi>=0.11; extra == 'impersonate'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # market-sources
38
+
39
+ <p align="center">
40
+ <a href="https://github.com/octrow/market-sources/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/octrow/market-sources/actions/workflows/ci.yml/badge.svg"></a>
41
+ <a href="https://pypi.org/project/market-sources/"><img alt="PyPI" src="https://img.shields.io/pypi/v/market-sources"></a>
42
+ <img alt="Python 3.12+" src="https://img.shields.io/badge/python-3.12%2B-blue">
43
+ <a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-green"></a>
44
+ </p>
45
+
46
+ <p align="center"><b>English</b> | <a href="README.ru.md">Русский</a></p>
47
+
48
+ Async Python scrapers for **Kaspi.kz**, **OLX.kz** and **Ozon.kz** behind one `ProductSource` protocol.
49
+ Search a product, get every seller's offer with price and delivery, read reviews and specs. Built for people
50
+ who track prices and offers on Kazakhstan marketplaces: price monitors, deal bots, purchase research.
51
+
52
+ Under the adapters sit two reusable layers: `PoliteClient`, a paced, retrying async HTTP client, and
53
+ `BrowserSession`, a persistent headless Chromium for sites that need a real browser.
54
+
55
+ ```python
56
+ import asyncio
57
+ from market_sources import KaspiSource
58
+
59
+ async def main():
60
+ kaspi = KaspiSource()
61
+ products = await kaspi.search("iphone 15")
62
+ offers = await kaspi.offers(products[0].product_id)
63
+ cheapest = min(offers, key=lambda o: o.price)
64
+ print(products[0].title, "|", len(offers), "offers, from", cheapest.price, cheapest.currency)
65
+ await kaspi.aclose()
66
+
67
+ asyncio.run(main())
68
+ ```
69
+
70
+ A real run on 2026-10-13 (Kaspi search took 1.4 s and returned 12 products):
71
+
72
+ ```text
73
+ Смартфон Apple iPhone 15 128Gb NanoSIM+eSIM черный | 32 offers, from 379500.0 KZT
74
+ ```
75
+
76
+ Each offer is a plain Pydantic model:
77
+
78
+ ```python
79
+ RawOffer(source='kaspi', product_id='113137790', seller='Kaz_Phones', price=379500.0, currency='KZT',
80
+ delivery='EXPRESS', in_stock=True, merchant_rating=4.8, merchant_review_count=32,
81
+ delivery_date='2026-10-13', delivery_cost=995.0, ...)
82
+ ```
83
+
84
+ ## Install
85
+
86
+ ```bash
87
+ pip install market-sources # Kaspi; OLX needs the impersonate extra (below)
88
+ pip install "market-sources[impersonate]" # + curl_cffi, so OLX.kz's WAF lets requests through
89
+ pip install "market-sources[browser]" # + playwright, for Ozon.kz
90
+ playwright install chromium # once, for the browser extra
91
+ ```
92
+
93
+ With uv: `uv add "market-sources[impersonate,browser]"`. Latest code from GitHub:
94
+ `pip install "git+https://github.com/octrow/market-sources"`.
95
+
96
+ Python 3.12+. Runtime dependencies are `httpx` and `pydantic`. Optional extras are imported lazily, so
97
+ `import market_sources` works without them.
98
+
99
+ ## Sources
100
+
101
+ | source | how it gets data | needs | notes |
102
+ |---|---|---|---|
103
+ | `KaspiSource` | Kaspi's internal JSON API over `PoliteClient` | base install | `search`, `offers`, `reviews`, `reviews_with_stats`, `specs`; `KASPI_PROXY` env var for a proxy |
104
+ | `OlxSource` | OLX's public JSON API | `impersonate` extra | CloudFront rejects httpx's TLS fingerprint; with `curl_cffi` installed the client impersonates Chrome automatically (a search took 1.9 s and returned 63 listings in the run above) |
105
+ | `OzonSource` | Ozon's composer API, read through a real browser | `browser` extra | session saved to `OZON_STATE_PATH` (default `~/.config/buysmarter/ozon_state.json`); `OZON_BASE_URL` overrides the host |
106
+
107
+ Every source implements the same protocol, so consumer code doesn't care which site it talks to:
108
+
109
+ ```python
110
+ class ProductSource(Protocol):
111
+ async def search(self, query: str, city: str | None = None) -> list[RawProduct]: ...
112
+ async def offers(self, product_id: str) -> list[RawOffer]: ...
113
+ async def reviews(self, product_id: str) -> list[RawReview]: ...
114
+ async def specs(self, product_id: str) -> dict[str, str]: ...
115
+ ```
116
+
117
+ ## Modules
118
+
119
+ | module | what it is |
120
+ |---|---|
121
+ | `market_sources.base` | `ProductSource` Protocol + `RawProduct` / `RawOffer` / `RawReview` / `RawReviewStats` |
122
+ | `market_sources.polite_http` | `PoliteClient`: semaphore + jittered delay + browser headers + proxy + retry/backoff |
123
+ | `market_sources.impersonate` | `ImpersonateTransport`: an httpx transport that sends through `curl_cffi` with a Chrome TLS fingerprint |
124
+ | `market_sources.playwright_session` | `BrowserSession`: lazy persistent Chromium, serialized navigation, ready hook, relaunch-retry |
125
+ | `market_sources.kaspi` | `KaspiSource` / `KaspiClient` / pure `parse_*`: internal JSON API |
126
+ | `market_sources.olx` | `OlxSource` / `OlxClient` / pure `parse_*`: public JSON API |
127
+ | `market_sources.ozon` | `OzonSource` / `OzonBrowser` / pure `parse_*`: composer API via browser |
128
+
129
+ ## How it differs from a plain httpx script or Scrapy
130
+
131
+ - **A bare httpx request gets a 403.** Each site has its own gate (Kaspi checks the referer, OLX fingerprints
132
+ TLS, Ozon serves a JavaScript challenge). The adapters already pass them, and the reasons are written down
133
+ below, so you don't rediscover them by trial.
134
+ - **Scrapy is a crawler framework** with its own runtime, pipelines and settings. This is a small async library
135
+ you call from your own code (a bot, a FastAPI app, a cron script) and get Pydantic models back.
136
+ - **Pacing is built in and hard to forget.** Concurrency is capped inside the client, delays are jittered, and
137
+ `Retry-After` is honored, so `asyncio.gather` over 40 pages won't hammer a site.
138
+ - **Raw data, not opinions.** The models keep what the site said. Normalization, currency conversion, dedup and
139
+ ranking are left to you.
140
+
141
+ Please use it responsibly: keep the default pacing, cache what you can, and respect each marketplace's terms.
142
+
143
+ ## Design rules
144
+
145
+ **Every adapter is three files, and only one of them does I/O.** `client.py` (or
146
+ `browser.py`) knows endpoints and nothing about shapes; `parse.py` is pure
147
+ functions over already-decoded `dict`s and never imports the client; `source.py`
148
+ wires them into a `ProductSource`. That split is what makes the parsers testable
149
+ against saved payloads with no network and no mocking.
150
+
151
+ **Raw in, raw out.** These models are deliberately source-shaped, not canonical.
152
+ Normalization, currency conversion, dedup and ranking belong to the consumer —
153
+ the library's job ends at "the site said this".
154
+
155
+ ### polite_http
156
+
157
+ * **A bare `python-httpx` User-Agent gets 403'd by every site in scope.** The
158
+ browser UA is not politeness theatre, it is the price of a 200. Kaspi
159
+ additionally gates on `referer` — a page-matching referer is its *entire*
160
+ anti-bot check, which is why `request()` takes `referer` as a first-class
161
+ argument rather than making callers hand-roll the header.
162
+ * **Pacing is per logical request, not per attempt.** One jittered
163
+ `min_delay_s..max_delay_s` sleep inside the semaphore, before the first
164
+ attempt; retry backoff is additional sleep on top. `max_delay_s=0` disables
165
+ pacing entirely (that is how the tests stay fast).
166
+ * **The semaphore is the flood guard, and it is small (2).** `asyncio.gather`
167
+ over 40 pages is one line of consumer code away at all times; the concurrency
168
+ cap lives in the client so no consumer can forget it.
169
+ * **Retries never replay a side effect.** `POST`/`PATCH` are excluded by
170
+ default; a caller who knows their POST is a read (Kaspi's offers endpoint)
171
+ passes `retry=True` explicitly. This is opt-in, one call at a time.
172
+ * **A 404 is an answer, not a hiccup.** Only `429` and `5xx` (plus transport
173
+ errors) retry. Everything else raises through `raise_for_status()` on the
174
+ first attempt.
175
+ * **`Retry-After` wins over the backoff formula** (seconds *or* HTTP-date),
176
+ clamped to `backoff_max_s` so a hostile `Retry-After: 86400` cannot park the
177
+ process for a day.
178
+ * **Backoff is exponential with jitter, capped**: `backoff_base_s * 2**attempt`
179
+ plus up to `backoff_jitter` of itself, `min`'d with `backoff_max_s`.
180
+ * **`sleep` and `rng` are injectable.** Every pacing and backoff decision is
181
+ therefore assertable without a clock — which is the only reason the retry
182
+ schedule has tests at all.
183
+ * **One `AsyncClient`, reused.** The four originals built a fresh
184
+ `httpx.AsyncClient` per request and threw away the connection pool every
185
+ time. Call `aclose()` (or use the async context manager).
186
+
187
+ ### playwright_session
188
+
189
+ * **Importing must never require playwright.** `playwright` is imported inside
190
+ the launch path, so `import market_sources` works on a machine with only the
191
+ base deps — a consumer that only wants Kaspi and OLX installs nothing extra.
192
+ There is a test that blocks the import to keep this true.
193
+ * **One page, therefore one lock.** Every navigation mutates shared state
194
+ (cookies, history, the DOM the JSON is read off), so `fetch_text` serializes
195
+ behind an `asyncio.Lock`. Concurrency here is a correctness bug, not a
196
+ speedup.
197
+ * **Do NOT route-block images/css/fonts.** The anti-bot challenge loads its
198
+ assets through them; blocking earns a 403. This is the single most expensive
199
+ lesson encoded in this module, and the reason `LAUNCH_ARGS` has no blocking.
200
+ * **The challenge is passed once per page, then cached.** `prepare_page` is the
201
+ hook (goto home, dwell, verify the title is not the anti-bot wall); its
202
+ success is remembered until the session is torn down.
203
+ * **Stale session → full relaunch, not a retry in place.** A `403`/`307` or a
204
+ "target closed" error means the cookie jar is dead; only a fresh
205
+ browser+context+page fixes it, and only once per call.
206
+ * **Idle teardown takes the lock and detaches first.** A headless Chromium
207
+ costs ~300 MB, so it closes after `idle_timeout_s` and relaunches lazily.
208
+ Two bugs from the original are fixed here: the idle task used to `cancel()`
209
+ *itself* and then `await` the teardown (so the `CancelledError` landed
210
+ mid-close and left the browser process alive), and it tore the session down
211
+ without the nav lock (so it could yank the page out from under an in-flight
212
+ navigation).
213
+
214
+ ## Origin
215
+
216
+ Extracted from the author's `buy-smarter` project (`src/buysmarter/sources/`). The polite-HTTP and
217
+ browser-session design is informed by three more independent implementations of the same pattern in
218
+ `olx-almaty-phone-analyzer` and `get_cool_event`.
219
+
220
+ ### Changes from the original code
221
+
222
+ | change | reason |
223
+ |---|---|
224
+ | `PoliteClient` replaces per-request `httpx.AsyncClient()` | reuses the connection pool instead of discarding it per call; same headers/pacing |
225
+ | retry/backoff is exponential + jittered, honors `Retry-After` | originals used a flat `1.0 * (attempt+1)` and ignored `Retry-After` — the audit's named gap |
226
+ | Kaspi's `offers` POST passes `retry=True` | preserves the original's retry-any-method behavior under the new "idempotent only" default |
227
+ | `KASPI_PROXY` / `OZON_BASE_URL` read from the environment | dropped the dependency on buy-smarter's `config.get_settings()`; still overridable per instance |
228
+ | `BrowserSession` + `prepare_page` hook | `OzonBrowser` is now site knowledge only (challenge + composer URL); the session machinery is shared with any future playwright source |
229
+ | dead-browser detection is message-based | it no longer requires a `playwright.Error` instance, so the state machine is testable with a fake page |
230
+ | `BrowserSessionError` message is `HTTP {status} for {url}` | was `Ozon returned HTTP {status} for {path}`; the class (`OzonError`) still carries the site identity |
231
+ | every adapter grew `aclose()` | there is now a pool/browser to close; the originals had nothing to release except Ozon's browser |
232
+
233
+ ## Tests
234
+
235
+ `pytest` — no network, no browser, no clock. HTTP is driven through an
236
+ `httpx.AsyncBaseTransport` double (not `respx` — one fewer dependency), the
237
+ browser through an injected `page_factory`, and time through injected `sleep` /
238
+ `rng`. Parser fixtures are minimal dicts built from the branches `parse.py`
239
+ actually reads.
240
+
241
+ ```bash
242
+ uv run pytest
243
+ ```
244
+
245
+ ## Contributing
246
+
247
+ See [CONTRIBUTING.md](CONTRIBUTING.md). Report vulnerabilities privately: [SECURITY.md](SECURITY.md).
248
+
249
+ ## License
250
+
251
+ [MIT](LICENSE)
@@ -0,0 +1,215 @@
1
+ # market-sources
2
+
3
+ <p align="center">
4
+ <a href="https://github.com/octrow/market-sources/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/octrow/market-sources/actions/workflows/ci.yml/badge.svg"></a>
5
+ <a href="https://pypi.org/project/market-sources/"><img alt="PyPI" src="https://img.shields.io/pypi/v/market-sources"></a>
6
+ <img alt="Python 3.12+" src="https://img.shields.io/badge/python-3.12%2B-blue">
7
+ <a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-green"></a>
8
+ </p>
9
+
10
+ <p align="center"><b>English</b> | <a href="README.ru.md">Русский</a></p>
11
+
12
+ Async Python scrapers for **Kaspi.kz**, **OLX.kz** and **Ozon.kz** behind one `ProductSource` protocol.
13
+ Search a product, get every seller's offer with price and delivery, read reviews and specs. Built for people
14
+ who track prices and offers on Kazakhstan marketplaces: price monitors, deal bots, purchase research.
15
+
16
+ Under the adapters sit two reusable layers: `PoliteClient`, a paced, retrying async HTTP client, and
17
+ `BrowserSession`, a persistent headless Chromium for sites that need a real browser.
18
+
19
+ ```python
20
+ import asyncio
21
+ from market_sources import KaspiSource
22
+
23
+ async def main():
24
+ kaspi = KaspiSource()
25
+ products = await kaspi.search("iphone 15")
26
+ offers = await kaspi.offers(products[0].product_id)
27
+ cheapest = min(offers, key=lambda o: o.price)
28
+ print(products[0].title, "|", len(offers), "offers, from", cheapest.price, cheapest.currency)
29
+ await kaspi.aclose()
30
+
31
+ asyncio.run(main())
32
+ ```
33
+
34
+ A real run on 2026-10-13 (Kaspi search took 1.4 s and returned 12 products):
35
+
36
+ ```text
37
+ Смартфон Apple iPhone 15 128Gb NanoSIM+eSIM черный | 32 offers, from 379500.0 KZT
38
+ ```
39
+
40
+ Each offer is a plain Pydantic model:
41
+
42
+ ```python
43
+ RawOffer(source='kaspi', product_id='113137790', seller='Kaz_Phones', price=379500.0, currency='KZT',
44
+ delivery='EXPRESS', in_stock=True, merchant_rating=4.8, merchant_review_count=32,
45
+ delivery_date='2026-10-13', delivery_cost=995.0, ...)
46
+ ```
47
+
48
+ ## Install
49
+
50
+ ```bash
51
+ pip install market-sources # Kaspi; OLX needs the impersonate extra (below)
52
+ pip install "market-sources[impersonate]" # + curl_cffi, so OLX.kz's WAF lets requests through
53
+ pip install "market-sources[browser]" # + playwright, for Ozon.kz
54
+ playwright install chromium # once, for the browser extra
55
+ ```
56
+
57
+ With uv: `uv add "market-sources[impersonate,browser]"`. Latest code from GitHub:
58
+ `pip install "git+https://github.com/octrow/market-sources"`.
59
+
60
+ Python 3.12+. Runtime dependencies are `httpx` and `pydantic`. Optional extras are imported lazily, so
61
+ `import market_sources` works without them.
62
+
63
+ ## Sources
64
+
65
+ | source | how it gets data | needs | notes |
66
+ |---|---|---|---|
67
+ | `KaspiSource` | Kaspi's internal JSON API over `PoliteClient` | base install | `search`, `offers`, `reviews`, `reviews_with_stats`, `specs`; `KASPI_PROXY` env var for a proxy |
68
+ | `OlxSource` | OLX's public JSON API | `impersonate` extra | CloudFront rejects httpx's TLS fingerprint; with `curl_cffi` installed the client impersonates Chrome automatically (a search took 1.9 s and returned 63 listings in the run above) |
69
+ | `OzonSource` | Ozon's composer API, read through a real browser | `browser` extra | session saved to `OZON_STATE_PATH` (default `~/.config/buysmarter/ozon_state.json`); `OZON_BASE_URL` overrides the host |
70
+
71
+ Every source implements the same protocol, so consumer code doesn't care which site it talks to:
72
+
73
+ ```python
74
+ class ProductSource(Protocol):
75
+ async def search(self, query: str, city: str | None = None) -> list[RawProduct]: ...
76
+ async def offers(self, product_id: str) -> list[RawOffer]: ...
77
+ async def reviews(self, product_id: str) -> list[RawReview]: ...
78
+ async def specs(self, product_id: str) -> dict[str, str]: ...
79
+ ```
80
+
81
+ ## Modules
82
+
83
+ | module | what it is |
84
+ |---|---|
85
+ | `market_sources.base` | `ProductSource` Protocol + `RawProduct` / `RawOffer` / `RawReview` / `RawReviewStats` |
86
+ | `market_sources.polite_http` | `PoliteClient`: semaphore + jittered delay + browser headers + proxy + retry/backoff |
87
+ | `market_sources.impersonate` | `ImpersonateTransport`: an httpx transport that sends through `curl_cffi` with a Chrome TLS fingerprint |
88
+ | `market_sources.playwright_session` | `BrowserSession`: lazy persistent Chromium, serialized navigation, ready hook, relaunch-retry |
89
+ | `market_sources.kaspi` | `KaspiSource` / `KaspiClient` / pure `parse_*`: internal JSON API |
90
+ | `market_sources.olx` | `OlxSource` / `OlxClient` / pure `parse_*`: public JSON API |
91
+ | `market_sources.ozon` | `OzonSource` / `OzonBrowser` / pure `parse_*`: composer API via browser |
92
+
93
+ ## How it differs from a plain httpx script or Scrapy
94
+
95
+ - **A bare httpx request gets a 403.** Each site has its own gate (Kaspi checks the referer, OLX fingerprints
96
+ TLS, Ozon serves a JavaScript challenge). The adapters already pass them, and the reasons are written down
97
+ below, so you don't rediscover them by trial.
98
+ - **Scrapy is a crawler framework** with its own runtime, pipelines and settings. This is a small async library
99
+ you call from your own code (a bot, a FastAPI app, a cron script) and get Pydantic models back.
100
+ - **Pacing is built in and hard to forget.** Concurrency is capped inside the client, delays are jittered, and
101
+ `Retry-After` is honored, so `asyncio.gather` over 40 pages won't hammer a site.
102
+ - **Raw data, not opinions.** The models keep what the site said. Normalization, currency conversion, dedup and
103
+ ranking are left to you.
104
+
105
+ Please use it responsibly: keep the default pacing, cache what you can, and respect each marketplace's terms.
106
+
107
+ ## Design rules
108
+
109
+ **Every adapter is three files, and only one of them does I/O.** `client.py` (or
110
+ `browser.py`) knows endpoints and nothing about shapes; `parse.py` is pure
111
+ functions over already-decoded `dict`s and never imports the client; `source.py`
112
+ wires them into a `ProductSource`. That split is what makes the parsers testable
113
+ against saved payloads with no network and no mocking.
114
+
115
+ **Raw in, raw out.** These models are deliberately source-shaped, not canonical.
116
+ Normalization, currency conversion, dedup and ranking belong to the consumer —
117
+ the library's job ends at "the site said this".
118
+
119
+ ### polite_http
120
+
121
+ * **A bare `python-httpx` User-Agent gets 403'd by every site in scope.** The
122
+ browser UA is not politeness theatre, it is the price of a 200. Kaspi
123
+ additionally gates on `referer` — a page-matching referer is its *entire*
124
+ anti-bot check, which is why `request()` takes `referer` as a first-class
125
+ argument rather than making callers hand-roll the header.
126
+ * **Pacing is per logical request, not per attempt.** One jittered
127
+ `min_delay_s..max_delay_s` sleep inside the semaphore, before the first
128
+ attempt; retry backoff is additional sleep on top. `max_delay_s=0` disables
129
+ pacing entirely (that is how the tests stay fast).
130
+ * **The semaphore is the flood guard, and it is small (2).** `asyncio.gather`
131
+ over 40 pages is one line of consumer code away at all times; the concurrency
132
+ cap lives in the client so no consumer can forget it.
133
+ * **Retries never replay a side effect.** `POST`/`PATCH` are excluded by
134
+ default; a caller who knows their POST is a read (Kaspi's offers endpoint)
135
+ passes `retry=True` explicitly. This is opt-in, one call at a time.
136
+ * **A 404 is an answer, not a hiccup.** Only `429` and `5xx` (plus transport
137
+ errors) retry. Everything else raises through `raise_for_status()` on the
138
+ first attempt.
139
+ * **`Retry-After` wins over the backoff formula** (seconds *or* HTTP-date),
140
+ clamped to `backoff_max_s` so a hostile `Retry-After: 86400` cannot park the
141
+ process for a day.
142
+ * **Backoff is exponential with jitter, capped**: `backoff_base_s * 2**attempt`
143
+ plus up to `backoff_jitter` of itself, `min`'d with `backoff_max_s`.
144
+ * **`sleep` and `rng` are injectable.** Every pacing and backoff decision is
145
+ therefore assertable without a clock — which is the only reason the retry
146
+ schedule has tests at all.
147
+ * **One `AsyncClient`, reused.** The four originals built a fresh
148
+ `httpx.AsyncClient` per request and threw away the connection pool every
149
+ time. Call `aclose()` (or use the async context manager).
150
+
151
+ ### playwright_session
152
+
153
+ * **Importing must never require playwright.** `playwright` is imported inside
154
+ the launch path, so `import market_sources` works on a machine with only the
155
+ base deps — a consumer that only wants Kaspi and OLX installs nothing extra.
156
+ There is a test that blocks the import to keep this true.
157
+ * **One page, therefore one lock.** Every navigation mutates shared state
158
+ (cookies, history, the DOM the JSON is read off), so `fetch_text` serializes
159
+ behind an `asyncio.Lock`. Concurrency here is a correctness bug, not a
160
+ speedup.
161
+ * **Do NOT route-block images/css/fonts.** The anti-bot challenge loads its
162
+ assets through them; blocking earns a 403. This is the single most expensive
163
+ lesson encoded in this module, and the reason `LAUNCH_ARGS` has no blocking.
164
+ * **The challenge is passed once per page, then cached.** `prepare_page` is the
165
+ hook (goto home, dwell, verify the title is not the anti-bot wall); its
166
+ success is remembered until the session is torn down.
167
+ * **Stale session → full relaunch, not a retry in place.** A `403`/`307` or a
168
+ "target closed" error means the cookie jar is dead; only a fresh
169
+ browser+context+page fixes it, and only once per call.
170
+ * **Idle teardown takes the lock and detaches first.** A headless Chromium
171
+ costs ~300 MB, so it closes after `idle_timeout_s` and relaunches lazily.
172
+ Two bugs from the original are fixed here: the idle task used to `cancel()`
173
+ *itself* and then `await` the teardown (so the `CancelledError` landed
174
+ mid-close and left the browser process alive), and it tore the session down
175
+ without the nav lock (so it could yank the page out from under an in-flight
176
+ navigation).
177
+
178
+ ## Origin
179
+
180
+ Extracted from the author's `buy-smarter` project (`src/buysmarter/sources/`). The polite-HTTP and
181
+ browser-session design is informed by three more independent implementations of the same pattern in
182
+ `olx-almaty-phone-analyzer` and `get_cool_event`.
183
+
184
+ ### Changes from the original code
185
+
186
+ | change | reason |
187
+ |---|---|
188
+ | `PoliteClient` replaces per-request `httpx.AsyncClient()` | reuses the connection pool instead of discarding it per call; same headers/pacing |
189
+ | retry/backoff is exponential + jittered, honors `Retry-After` | originals used a flat `1.0 * (attempt+1)` and ignored `Retry-After` — the audit's named gap |
190
+ | Kaspi's `offers` POST passes `retry=True` | preserves the original's retry-any-method behavior under the new "idempotent only" default |
191
+ | `KASPI_PROXY` / `OZON_BASE_URL` read from the environment | dropped the dependency on buy-smarter's `config.get_settings()`; still overridable per instance |
192
+ | `BrowserSession` + `prepare_page` hook | `OzonBrowser` is now site knowledge only (challenge + composer URL); the session machinery is shared with any future playwright source |
193
+ | dead-browser detection is message-based | it no longer requires a `playwright.Error` instance, so the state machine is testable with a fake page |
194
+ | `BrowserSessionError` message is `HTTP {status} for {url}` | was `Ozon returned HTTP {status} for {path}`; the class (`OzonError`) still carries the site identity |
195
+ | every adapter grew `aclose()` | there is now a pool/browser to close; the originals had nothing to release except Ozon's browser |
196
+
197
+ ## Tests
198
+
199
+ `pytest` — no network, no browser, no clock. HTTP is driven through an
200
+ `httpx.AsyncBaseTransport` double (not `respx` — one fewer dependency), the
201
+ browser through an injected `page_factory`, and time through injected `sleep` /
202
+ `rng`. Parser fixtures are minimal dicts built from the branches `parse.py`
203
+ actually reads.
204
+
205
+ ```bash
206
+ uv run pytest
207
+ ```
208
+
209
+ ## Contributing
210
+
211
+ See [CONTRIBUTING.md](CONTRIBUTING.md). Report vulnerabilities privately: [SECURITY.md](SECURITY.md).
212
+
213
+ ## License
214
+
215
+ [MIT](LICENSE)
@@ -0,0 +1,62 @@
1
+ [project]
2
+ name = "market-sources"
3
+ version = "0.1.0"
4
+ description = "Async Python scrapers for Kaspi.kz, OLX.kz and Ozon.kz behind one ProductSource protocol: search, offers, reviews"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "Daniil Petrov" }]
9
+ requires-python = ">=3.12"
10
+ keywords = ["kaspi", "olx", "ozon", "kazakhstan", "marketplace", "scraper", "price-monitoring", "httpx", "playwright", "asyncio"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Framework :: AsyncIO",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Operating System :: OS Independent",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Topic :: Internet :: WWW/HTTP",
21
+ "Topic :: Software Development :: Libraries :: Python Modules",
22
+ "Typing :: Typed",
23
+ ]
24
+ dependencies = [
25
+ "httpx>=0.27",
26
+ "pydantic>=2",
27
+ # Windows ships no tz database; kaspi.parse needs Asia/Almaty.
28
+ "tzdata; sys_platform == 'win32'",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ # Only the Ozon adapter (and `playwright_session`'s launch path) needs a browser.
33
+ # Both modules import fine without it — playwright is imported lazily.
34
+ browser = ["playwright>=1.44"]
35
+ # OLX's WAF blocks httpx's TLS fingerprint; curl_cffi impersonates Chrome (impersonate.py).
36
+ impersonate = ["curl_cffi>=0.11"]
37
+ dev = ["pytest>=8", "pytest-asyncio>=0.24", "ruff>=0.6"]
38
+
39
+ [project.urls]
40
+ Homepage = "https://github.com/octrow/market-sources"
41
+ Issues = "https://github.com/octrow/market-sources/issues"
42
+ Changelog = "https://github.com/octrow/market-sources/blob/main/CHANGELOG.md"
43
+
44
+ [build-system]
45
+ requires = ["hatchling"]
46
+ build-backend = "hatchling.build"
47
+
48
+ [tool.hatch.build.targets.sdist]
49
+ include = ["src", "tests", "README.md", "LICENSE", "CHANGELOG.md"]
50
+
51
+ [tool.hatch.build.targets.wheel]
52
+ packages = ["src/market_sources"]
53
+
54
+ [tool.pytest.ini_options]
55
+ testpaths = ["tests"]
56
+ asyncio_mode = "auto"
57
+
58
+ [tool.ruff]
59
+ line-length = 100
60
+
61
+ [tool.ruff.lint]
62
+ select = ["E4", "E7", "E9", "F", "I"]
@@ -0,0 +1,34 @@
1
+ """market_sources — marketplace adapters plus the polite-HTTP layer under them.
2
+
3
+ * :mod:`market_sources.base` — the ``ProductSource`` Protocol and the
4
+ ``RawProduct`` / ``RawOffer`` / ``RawReview`` models every adapter returns.
5
+ * :mod:`market_sources.polite_http` — ``PoliteClient``: semaphore + jittered
6
+ delay + browser headers + optional proxy + retry/backoff.
7
+ * :mod:`market_sources.playwright_session` — ``BrowserSession``: lazy persistent
8
+ headless Chromium with serialized navigation and relaunch-retry (needs the
9
+ ``browser`` extra to *run*, never to import).
10
+ * :mod:`market_sources.kaspi` / ``.olx`` (JSON APIs over ``PoliteClient``) and
11
+ :mod:`market_sources.ozon` (composer-api over ``BrowserSession``).
12
+ """
13
+
14
+ from .base import ProductSource, RawOffer, RawProduct, RawReview, RawReviewStats
15
+ from .kaspi.source import KaspiSource
16
+ from .olx.source import OlxSource
17
+ from .ozon.source import OzonSource
18
+ from .playwright_session import BrowserSession, BrowserSessionError
19
+ from .polite_http import PoliteClient, parse_retry_after
20
+
21
+ __all__ = [
22
+ "BrowserSession",
23
+ "BrowserSessionError",
24
+ "KaspiSource",
25
+ "OlxSource",
26
+ "OzonSource",
27
+ "PoliteClient",
28
+ "ProductSource",
29
+ "RawOffer",
30
+ "RawProduct",
31
+ "RawReview",
32
+ "RawReviewStats",
33
+ "parse_retry_after",
34
+ ]