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.
- market_sources-0.1.0/.gitignore +9 -0
- market_sources-0.1.0/CHANGELOG.md +25 -0
- market_sources-0.1.0/LICENSE +21 -0
- market_sources-0.1.0/PKG-INFO +251 -0
- market_sources-0.1.0/README.md +215 -0
- market_sources-0.1.0/pyproject.toml +62 -0
- market_sources-0.1.0/src/market_sources/__init__.py +34 -0
- market_sources-0.1.0/src/market_sources/base.py +108 -0
- market_sources-0.1.0/src/market_sources/impersonate.py +103 -0
- market_sources-0.1.0/src/market_sources/kaspi/__init__.py +1 -0
- market_sources-0.1.0/src/market_sources/kaspi/client.py +133 -0
- market_sources-0.1.0/src/market_sources/kaspi/parse.py +286 -0
- market_sources-0.1.0/src/market_sources/kaspi/source.py +72 -0
- market_sources-0.1.0/src/market_sources/olx/__init__.py +1 -0
- market_sources-0.1.0/src/market_sources/olx/client.py +91 -0
- market_sources-0.1.0/src/market_sources/olx/parse.py +134 -0
- market_sources-0.1.0/src/market_sources/olx/source.py +107 -0
- market_sources-0.1.0/src/market_sources/ozon/__init__.py +1 -0
- market_sources-0.1.0/src/market_sources/ozon/browser.py +114 -0
- market_sources-0.1.0/src/market_sources/ozon/parse.py +272 -0
- market_sources-0.1.0/src/market_sources/ozon/source.py +61 -0
- market_sources-0.1.0/src/market_sources/playwright_session.py +424 -0
- market_sources-0.1.0/src/market_sources/polite_http.py +288 -0
- market_sources-0.1.0/src/market_sources/py.typed +0 -0
- market_sources-0.1.0/tests/fakes.py +132 -0
- market_sources-0.1.0/tests/test_base.py +81 -0
- market_sources-0.1.0/tests/test_browser_state.py +267 -0
- market_sources-0.1.0/tests/test_impersonate.py +95 -0
- market_sources-0.1.0/tests/test_kaspi.py +599 -0
- market_sources-0.1.0/tests/test_olx.py +289 -0
- market_sources-0.1.0/tests/test_ozon.py +442 -0
- market_sources-0.1.0/tests/test_playwright_session.py +345 -0
- market_sources-0.1.0/tests/test_polite_http.py +342 -0
|
@@ -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
|
+
]
|