zepto-scraper 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.
- zepto_scraper-0.1.0/LICENSE +21 -0
- zepto_scraper-0.1.0/MANIFEST.in +3 -0
- zepto_scraper-0.1.0/PKG-INFO +154 -0
- zepto_scraper-0.1.0/README.md +132 -0
- zepto_scraper-0.1.0/examples/category_to_products.py +44 -0
- zepto_scraper-0.1.0/pyproject.toml +34 -0
- zepto_scraper-0.1.0/setup.cfg +4 -0
- zepto_scraper-0.1.0/src/zepto_scraper/__init__.py +26 -0
- zepto_scraper-0.1.0/src/zepto_scraper/cli.py +104 -0
- zepto_scraper-0.1.0/src/zepto_scraper/client.py +94 -0
- zepto_scraper-0.1.0/src/zepto_scraper/parser.py +182 -0
- zepto_scraper-0.1.0/src/zepto_scraper.egg-info/PKG-INFO +154 -0
- zepto_scraper-0.1.0/src/zepto_scraper.egg-info/SOURCES.txt +18 -0
- zepto_scraper-0.1.0/src/zepto_scraper.egg-info/dependency_links.txt +1 -0
- zepto_scraper-0.1.0/src/zepto_scraper.egg-info/entry_points.txt +2 -0
- zepto_scraper-0.1.0/src/zepto_scraper.egg-info/requires.txt +1 -0
- zepto_scraper-0.1.0/src/zepto_scraper.egg-info/top_level.txt +1 -0
- zepto_scraper-0.1.0/tests/fixtures/product_page.html +1 -0
- zepto_scraper-0.1.0/tests/fixtures/subcategory_page.html +1 -0
- zepto_scraper-0.1.0/tests/test_parser.py +136 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Buğrahan Saka
|
|
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,154 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zepto-scraper
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Scrape Zepto category listings (price, MRP, discount, stock) and product details (seller, shelf life, FSSAI licence) with Python.
|
|
5
|
+
Author: Buğrahan Saka
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/bugrahansss/zepto-scraper
|
|
8
|
+
Project-URL: Source, https://github.com/bugrahansss/zepto-scraper
|
|
9
|
+
Project-URL: Issues, https://github.com/bugrahansss/zepto-scraper/issues
|
|
10
|
+
Project-URL: Scrape.do, https://scrape.do/?utm_source=pypi&utm_medium=package&utm_campaign=zepto-scraper
|
|
11
|
+
Keywords: zepto,web scraping,quick commerce,grocery,scraper,nextjs,india
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: requests>=2.25
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# zepto-scraper
|
|
24
|
+
|
|
25
|
+
[](https://pypi.org/project/zepto-scraper/)
|
|
26
|
+
[](https://pypi.org/project/zepto-scraper/)
|
|
27
|
+
[](LICENSE)
|
|
28
|
+
|
|
29
|
+
A Python scraper for [Zepto](https://www.zepto.com), India's quick-commerce grocery app. Two jobs:
|
|
30
|
+
|
|
31
|
+
- **Category listings:** start from one subcategory URL, walk its sibling subcategories and get every product card with price, MRP, discount, in-stock flag, **units in stock**, rating and the dark store it came from.
|
|
32
|
+
- **Product details:** turn product pages into a catalog sheet with seller entity, shelf life, FSSAI licence number, country of origin, category path, order limit and the full description.
|
|
33
|
+
|
|
34
|
+
Output as Python dicts, JSON or CSV. One dependency (`requests`), no browser.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install zepto-scraper
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Why a plain request fails
|
|
41
|
+
|
|
42
|
+
Zepto sits behind AWS WAF on CloudFront:
|
|
43
|
+
|
|
44
|
+
| Request | Status | Body |
|
|
45
|
+
| --- | --- | --- |
|
|
46
|
+
| `requests`, default user agent | 429 | 0 bytes |
|
|
47
|
+
| `requests`, Chrome user agent | 202, `x-amzn-waf-action: challenge` | 0 bytes |
|
|
48
|
+
| Through Scrape.do, no parameters | 200 | full page |
|
|
49
|
+
|
|
50
|
+
**Watch the 202.** It means "Accepted", so `raise_for_status()` treats it as success and a scraper that only checks for errors writes empty pages without a single failure. This package treats anything but a 200 as an error.
|
|
51
|
+
|
|
52
|
+
Pages are fetched through the [Scrape.do](https://scrape.do/?utm_source=github&utm_medium=readme&utm_campaign=zepto-scraper) API with a plain request: no `render`, no `super`, no `geoCode`, 1 credit per page. You need a Scrape.do API token; the free plan includes 1,000 credits a month.
|
|
53
|
+
|
|
54
|
+
## Quick start
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from zepto_scraper import ZeptoScraper
|
|
58
|
+
|
|
59
|
+
scraper = ZeptoScraper(token="YOUR_SCRAPEDO_TOKEN") # or set SCRAPEDO_TOKEN
|
|
60
|
+
|
|
61
|
+
# Any subcategory URL; the scraper walks its siblings in the same category
|
|
62
|
+
rows = scraper.get_category(
|
|
63
|
+
"https://www.zepto.com/cn/munchies/chips-crisps/cid/d2c2a144-43cd-43e5-b308-92628fa68596/scid/df4f5100-c02f-4906-83b8-ddb744081a7a"
|
|
64
|
+
)
|
|
65
|
+
for row in rows[:3]:
|
|
66
|
+
print(row["name"], row["price_inr"], row["mrp_inr"], row["in_stock"], row["available_qty"])
|
|
67
|
+
|
|
68
|
+
product = scraper.get_product(rows[0]["url"])
|
|
69
|
+
print(product["seller"], product["shelf_life"], product["fssai_licence"])
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Start from a **subcategory** URL (`/cn/<category>/<subcategory>/cid/.../scid/...`). The category link on Zepto's homepage is not a listing.
|
|
73
|
+
|
|
74
|
+
### Command line
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
export SCRAPEDO_TOKEN=your_token
|
|
78
|
+
|
|
79
|
+
# every product in a category, CSV
|
|
80
|
+
zepto-scraper category "<subcategory-url>" -f csv -o zepto-category.csv
|
|
81
|
+
|
|
82
|
+
# catalog sheet for the first 10 products from that file
|
|
83
|
+
zepto-scraper product -i zepto-category.csv --limit 10 -f csv -o zepto-products.csv
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The two commands chain: `product` reads the `url` column of the listing CSV.
|
|
87
|
+
|
|
88
|
+
### Output
|
|
89
|
+
|
|
90
|
+
Listing row (from the fictional test page in [`tests/fixtures`](tests/fixtures/subcategory_page.html)):
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"variant_id": "44444444-4444-4444-8444-444444444440",
|
|
95
|
+
"name": "Example Chilli Chips",
|
|
96
|
+
"brand": "Examplo",
|
|
97
|
+
"pack_size": "1 pack (41 g)",
|
|
98
|
+
"price_inr": 19.0,
|
|
99
|
+
"mrp_inr": 20.0,
|
|
100
|
+
"discount_pct": 5,
|
|
101
|
+
"in_stock": true,
|
|
102
|
+
"available_qty": 30,
|
|
103
|
+
"rating": 4.9,
|
|
104
|
+
"rating_count": 401062,
|
|
105
|
+
"subcategory": "Chips & Crisps",
|
|
106
|
+
"store_id": "33333333-3333-4333-8333-333333333333",
|
|
107
|
+
"url": "https://www.zepto.com/pn/example-snack-0/pvid/44444444-4444-4444-8444-444444444440"
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Product sheet columns: `variant_id, name, brand, category, pack_size, mrp_inr, max_per_order, rating, rating_count, shelf_life, country_of_origin, seller, fssai_licence, image_count, description, url`.
|
|
112
|
+
|
|
113
|
+
## How it works
|
|
114
|
+
|
|
115
|
+
**The Next.js stream.** Zepto runs on the Next.js App Router. A subcategory page makes no separate product API call; every product card is streamed into the HTML as `self.__next_f.push([1, "..."])` chunks. Each chunk is a JavaScript string literal, and JSON strings use the same escaping, so `json.loads('"' + chunk + '"')` decodes it. Joined, the chunks are one text with one JSON object per product card.
|
|
116
|
+
|
|
117
|
+
**Out-of-stock cards.** Card keys are sorted alphabetically. In-stock cards start with `availableQuantity`; out-of-stock cards drop that key and start with `baseProductId`. A parser that anchors only on `availableQuantity` silently skips every out-of-stock product. This one anchors on both, reads each object with `json.JSONDecoder.raw_decode`, and dedupes on the variant id.
|
|
118
|
+
|
|
119
|
+
**Prices are in paise.** `"discountedSellingPrice": 1900` is ₹19. The scraper divides by 100.
|
|
120
|
+
|
|
121
|
+
**Siblings.** Every subcategory page links its siblings, all sharing the category `cid`. The same subcategory can appear under a second category slug that renders no products, so only paths under the start URL's own slug are kept.
|
|
122
|
+
|
|
123
|
+
**Product pages.** The catalog fields come from the schema.org `Product` JSON-LD block (Zepto writes `id` before `type` on the script tag, so the regex does not depend on attribute order). MRP and the order limit come from the stream's `productVariant` object for the exact variant in the URL.
|
|
124
|
+
|
|
125
|
+
## Limits
|
|
126
|
+
|
|
127
|
+
- **32 products per subcategory.** Zepto's web pages stop at 32 cards (`endOfPage: true`) and ignore `?page=2`. For wider coverage, walk more subcategories and categories.
|
|
128
|
+
- **One dark store.** Each price and stock count belongs to one dark store. With no location, Zepto serves its default store (east Bengaluru); the `store_id` column records which one. Changing the location through Scrape.do did not move the store in our tests.
|
|
129
|
+
- **Use the listing for price and stock.** Product pages cap the stock count and can lag the listing, so `get_product()` leaves price and stock out on purpose.
|
|
130
|
+
- **No search.** Search results need a browser-computed `request-signature` header, and robots.txt disallows `/search`. Walk categories instead.
|
|
131
|
+
- **Errors.** Non-200 responses (including the 202 challenge) raise `ScrapeError` with the status code. Server errors and 429s are retried twice; client errors are not.
|
|
132
|
+
|
|
133
|
+
## Responsible use
|
|
134
|
+
|
|
135
|
+
The scraper reads category (`/cn/`) and product (`/pn/`) pages, which Zepto's robots.txt allows; it does not touch `/search`, `/api/`, cart or account paths. Product data is not personal data. Read Zepto's terms for your use case and keep request rates modest. This is not legal advice.
|
|
136
|
+
|
|
137
|
+
## Development
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
git clone https://github.com/bugrahansss/zepto-scraper
|
|
141
|
+
cd zepto-scraper
|
|
142
|
+
pip install -e .
|
|
143
|
+
python -m unittest discover -s tests -v
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The tests run offline against fictional fixture pages.
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
MIT
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
Built and tested with [Scrape.do](https://scrape.do/?utm_source=github&utm_medium=readme&utm_campaign=zepto-scraper), October 2026.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# zepto-scraper
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/zepto-scraper/)
|
|
4
|
+
[](https://pypi.org/project/zepto-scraper/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
A Python scraper for [Zepto](https://www.zepto.com), India's quick-commerce grocery app. Two jobs:
|
|
8
|
+
|
|
9
|
+
- **Category listings:** start from one subcategory URL, walk its sibling subcategories and get every product card with price, MRP, discount, in-stock flag, **units in stock**, rating and the dark store it came from.
|
|
10
|
+
- **Product details:** turn product pages into a catalog sheet with seller entity, shelf life, FSSAI licence number, country of origin, category path, order limit and the full description.
|
|
11
|
+
|
|
12
|
+
Output as Python dicts, JSON or CSV. One dependency (`requests`), no browser.
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install zepto-scraper
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Why a plain request fails
|
|
19
|
+
|
|
20
|
+
Zepto sits behind AWS WAF on CloudFront:
|
|
21
|
+
|
|
22
|
+
| Request | Status | Body |
|
|
23
|
+
| --- | --- | --- |
|
|
24
|
+
| `requests`, default user agent | 429 | 0 bytes |
|
|
25
|
+
| `requests`, Chrome user agent | 202, `x-amzn-waf-action: challenge` | 0 bytes |
|
|
26
|
+
| Through Scrape.do, no parameters | 200 | full page |
|
|
27
|
+
|
|
28
|
+
**Watch the 202.** It means "Accepted", so `raise_for_status()` treats it as success and a scraper that only checks for errors writes empty pages without a single failure. This package treats anything but a 200 as an error.
|
|
29
|
+
|
|
30
|
+
Pages are fetched through the [Scrape.do](https://scrape.do/?utm_source=github&utm_medium=readme&utm_campaign=zepto-scraper) API with a plain request: no `render`, no `super`, no `geoCode`, 1 credit per page. You need a Scrape.do API token; the free plan includes 1,000 credits a month.
|
|
31
|
+
|
|
32
|
+
## Quick start
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from zepto_scraper import ZeptoScraper
|
|
36
|
+
|
|
37
|
+
scraper = ZeptoScraper(token="YOUR_SCRAPEDO_TOKEN") # or set SCRAPEDO_TOKEN
|
|
38
|
+
|
|
39
|
+
# Any subcategory URL; the scraper walks its siblings in the same category
|
|
40
|
+
rows = scraper.get_category(
|
|
41
|
+
"https://www.zepto.com/cn/munchies/chips-crisps/cid/d2c2a144-43cd-43e5-b308-92628fa68596/scid/df4f5100-c02f-4906-83b8-ddb744081a7a"
|
|
42
|
+
)
|
|
43
|
+
for row in rows[:3]:
|
|
44
|
+
print(row["name"], row["price_inr"], row["mrp_inr"], row["in_stock"], row["available_qty"])
|
|
45
|
+
|
|
46
|
+
product = scraper.get_product(rows[0]["url"])
|
|
47
|
+
print(product["seller"], product["shelf_life"], product["fssai_licence"])
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Start from a **subcategory** URL (`/cn/<category>/<subcategory>/cid/.../scid/...`). The category link on Zepto's homepage is not a listing.
|
|
51
|
+
|
|
52
|
+
### Command line
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
export SCRAPEDO_TOKEN=your_token
|
|
56
|
+
|
|
57
|
+
# every product in a category, CSV
|
|
58
|
+
zepto-scraper category "<subcategory-url>" -f csv -o zepto-category.csv
|
|
59
|
+
|
|
60
|
+
# catalog sheet for the first 10 products from that file
|
|
61
|
+
zepto-scraper product -i zepto-category.csv --limit 10 -f csv -o zepto-products.csv
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The two commands chain: `product` reads the `url` column of the listing CSV.
|
|
65
|
+
|
|
66
|
+
### Output
|
|
67
|
+
|
|
68
|
+
Listing row (from the fictional test page in [`tests/fixtures`](tests/fixtures/subcategory_page.html)):
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"variant_id": "44444444-4444-4444-8444-444444444440",
|
|
73
|
+
"name": "Example Chilli Chips",
|
|
74
|
+
"brand": "Examplo",
|
|
75
|
+
"pack_size": "1 pack (41 g)",
|
|
76
|
+
"price_inr": 19.0,
|
|
77
|
+
"mrp_inr": 20.0,
|
|
78
|
+
"discount_pct": 5,
|
|
79
|
+
"in_stock": true,
|
|
80
|
+
"available_qty": 30,
|
|
81
|
+
"rating": 4.9,
|
|
82
|
+
"rating_count": 401062,
|
|
83
|
+
"subcategory": "Chips & Crisps",
|
|
84
|
+
"store_id": "33333333-3333-4333-8333-333333333333",
|
|
85
|
+
"url": "https://www.zepto.com/pn/example-snack-0/pvid/44444444-4444-4444-8444-444444444440"
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Product sheet columns: `variant_id, name, brand, category, pack_size, mrp_inr, max_per_order, rating, rating_count, shelf_life, country_of_origin, seller, fssai_licence, image_count, description, url`.
|
|
90
|
+
|
|
91
|
+
## How it works
|
|
92
|
+
|
|
93
|
+
**The Next.js stream.** Zepto runs on the Next.js App Router. A subcategory page makes no separate product API call; every product card is streamed into the HTML as `self.__next_f.push([1, "..."])` chunks. Each chunk is a JavaScript string literal, and JSON strings use the same escaping, so `json.loads('"' + chunk + '"')` decodes it. Joined, the chunks are one text with one JSON object per product card.
|
|
94
|
+
|
|
95
|
+
**Out-of-stock cards.** Card keys are sorted alphabetically. In-stock cards start with `availableQuantity`; out-of-stock cards drop that key and start with `baseProductId`. A parser that anchors only on `availableQuantity` silently skips every out-of-stock product. This one anchors on both, reads each object with `json.JSONDecoder.raw_decode`, and dedupes on the variant id.
|
|
96
|
+
|
|
97
|
+
**Prices are in paise.** `"discountedSellingPrice": 1900` is ₹19. The scraper divides by 100.
|
|
98
|
+
|
|
99
|
+
**Siblings.** Every subcategory page links its siblings, all sharing the category `cid`. The same subcategory can appear under a second category slug that renders no products, so only paths under the start URL's own slug are kept.
|
|
100
|
+
|
|
101
|
+
**Product pages.** The catalog fields come from the schema.org `Product` JSON-LD block (Zepto writes `id` before `type` on the script tag, so the regex does not depend on attribute order). MRP and the order limit come from the stream's `productVariant` object for the exact variant in the URL.
|
|
102
|
+
|
|
103
|
+
## Limits
|
|
104
|
+
|
|
105
|
+
- **32 products per subcategory.** Zepto's web pages stop at 32 cards (`endOfPage: true`) and ignore `?page=2`. For wider coverage, walk more subcategories and categories.
|
|
106
|
+
- **One dark store.** Each price and stock count belongs to one dark store. With no location, Zepto serves its default store (east Bengaluru); the `store_id` column records which one. Changing the location through Scrape.do did not move the store in our tests.
|
|
107
|
+
- **Use the listing for price and stock.** Product pages cap the stock count and can lag the listing, so `get_product()` leaves price and stock out on purpose.
|
|
108
|
+
- **No search.** Search results need a browser-computed `request-signature` header, and robots.txt disallows `/search`. Walk categories instead.
|
|
109
|
+
- **Errors.** Non-200 responses (including the 202 challenge) raise `ScrapeError` with the status code. Server errors and 429s are retried twice; client errors are not.
|
|
110
|
+
|
|
111
|
+
## Responsible use
|
|
112
|
+
|
|
113
|
+
The scraper reads category (`/cn/`) and product (`/pn/`) pages, which Zepto's robots.txt allows; it does not touch `/search`, `/api/`, cart or account paths. Product data is not personal data. Read Zepto's terms for your use case and keep request rates modest. This is not legal advice.
|
|
114
|
+
|
|
115
|
+
## Development
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
git clone https://github.com/bugrahansss/zepto-scraper
|
|
119
|
+
cd zepto-scraper
|
|
120
|
+
pip install -e .
|
|
121
|
+
python -m unittest discover -s tests -v
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The tests run offline against fictional fixture pages.
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
MIT
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
Built and tested with [Scrape.do](https://scrape.do/?utm_source=github&utm_medium=readme&utm_campaign=zepto-scraper), October 2026.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Walk a Zepto category, then build a catalog sheet for its products.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
export SCRAPEDO_TOKEN=your_token
|
|
5
|
+
python category_to_products.py "<subcategory-url>" 10
|
|
6
|
+
|
|
7
|
+
Writes zepto-category.csv (price, MRP, discount, stock per product) and
|
|
8
|
+
zepto-products.csv (seller, shelf life, FSSAI licence for the first N products).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import csv
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from zepto_scraper import LISTING_FIELDS, PRODUCT_FIELDS, ScrapeError, ZeptoScraper
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def write(path, fields, rows):
|
|
18
|
+
with open(path, "w", newline="", encoding="utf-8") as handle:
|
|
19
|
+
writer = csv.DictWriter(handle, fieldnames=fields)
|
|
20
|
+
writer.writeheader()
|
|
21
|
+
writer.writerows(rows)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main(start_url: str, max_products: int = 10) -> None:
|
|
25
|
+
scraper = ZeptoScraper() # reads SCRAPEDO_TOKEN
|
|
26
|
+
listing = scraper.get_category(start_url)
|
|
27
|
+
write("zepto-category.csv", LISTING_FIELDS, listing)
|
|
28
|
+
out_of_stock = sum(1 for row in listing if not row["in_stock"])
|
|
29
|
+
print(f"Saved {len(listing)} products ({out_of_stock} out of stock) to zepto-category.csv")
|
|
30
|
+
|
|
31
|
+
products = []
|
|
32
|
+
for row in [r for r in listing if r["url"]][:max_products]:
|
|
33
|
+
try:
|
|
34
|
+
products.append(scraper.get_product(row["url"]))
|
|
35
|
+
except (ScrapeError, ValueError) as exc:
|
|
36
|
+
print(f"skipped {row['url']}: {exc}")
|
|
37
|
+
continue
|
|
38
|
+
print(f"{products[-1]['name']} | {products[-1]['seller']} | shelf life {products[-1]['shelf_life']}")
|
|
39
|
+
write("zepto-products.csv", PRODUCT_FIELDS, products)
|
|
40
|
+
print(f"Saved {len(products)} products to zepto-products.csv")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
main(sys.argv[1], int(sys.argv[2]) if len(sys.argv) > 2 else 10)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "zepto-scraper"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Scrape Zepto category listings (price, MRP, discount, stock) and product details (seller, shelf life, FSSAI licence) with Python."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Buğrahan Saka" }]
|
|
14
|
+
keywords = ["zepto", "web scraping", "quick commerce", "grocery", "scraper", "nextjs", "india"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Internet :: WWW/HTTP :: Indexing/Search",
|
|
21
|
+
]
|
|
22
|
+
dependencies = ["requests>=2.25"]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/bugrahansss/zepto-scraper"
|
|
26
|
+
Source = "https://github.com/bugrahansss/zepto-scraper"
|
|
27
|
+
Issues = "https://github.com/bugrahansss/zepto-scraper/issues"
|
|
28
|
+
"Scrape.do" = "https://scrape.do/?utm_source=pypi&utm_medium=package&utm_campaign=zepto-scraper"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
zepto-scraper = "zepto_scraper.cli:main"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["src"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Scrape Zepto category listings and product details with Python."""
|
|
2
|
+
|
|
3
|
+
from .client import ScrapeError, ZeptoScraper
|
|
4
|
+
from .parser import (
|
|
5
|
+
LISTING_FIELDS,
|
|
6
|
+
PRODUCT_FIELDS,
|
|
7
|
+
decode_rsc,
|
|
8
|
+
extract_items,
|
|
9
|
+
parse_listing,
|
|
10
|
+
parse_product,
|
|
11
|
+
subcategory_paths,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"LISTING_FIELDS",
|
|
16
|
+
"PRODUCT_FIELDS",
|
|
17
|
+
"ScrapeError",
|
|
18
|
+
"ZeptoScraper",
|
|
19
|
+
"decode_rsc",
|
|
20
|
+
"extract_items",
|
|
21
|
+
"parse_listing",
|
|
22
|
+
"parse_product",
|
|
23
|
+
"subcategory_paths",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Command line entry point: ``zepto-scraper category URL`` / ``zepto-scraper product URL ...``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import csv
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .client import TOKEN_ENV, ScrapeError, ZeptoScraper
|
|
13
|
+
from .parser import LISTING_FIELDS, PRODUCT_FIELDS
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
prog="zepto-scraper",
|
|
19
|
+
description="Scrape Zepto category listings and product details into JSON or CSV.",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
22
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
23
|
+
|
|
24
|
+
cat = sub.add_parser("category", help="Every product in a category, starting from one subcategory URL")
|
|
25
|
+
cat.add_argument("url", help="A Zepto subcategory URL (.../cn/<category>/<subcategory>/cid/.../scid/...)")
|
|
26
|
+
cat.add_argument("--max-subcategories", type=int, default=20)
|
|
27
|
+
|
|
28
|
+
prod = sub.add_parser("product", help="Catalog sheet for product pages")
|
|
29
|
+
prod.add_argument("urls", nargs="*", help="Zepto product URLs (.../pn/<slug>/pvid/<id>)")
|
|
30
|
+
prod.add_argument("-i", "--input", help="Text file with one URL per line, or a CSV with a 'url' column")
|
|
31
|
+
prod.add_argument("--limit", type=int, default=0, help="Stop after this many products (0 = all)")
|
|
32
|
+
|
|
33
|
+
for p in (cat, prod):
|
|
34
|
+
p.add_argument("-t", "--token", help=f"Scrape.do API token (default: ${TOKEN_ENV})")
|
|
35
|
+
p.add_argument("-f", "--format", choices=("json", "csv"), default="json")
|
|
36
|
+
p.add_argument("-o", "--output", help="Write to this file instead of stdout")
|
|
37
|
+
return parser
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _read_urls(args: argparse.Namespace) -> List[str]:
|
|
41
|
+
urls = list(args.urls)
|
|
42
|
+
if args.input:
|
|
43
|
+
with open(args.input, encoding="utf-8", newline="") as handle:
|
|
44
|
+
if args.input.endswith(".csv"):
|
|
45
|
+
urls.extend(row["url"] for row in csv.DictReader(handle) if row.get("url"))
|
|
46
|
+
else:
|
|
47
|
+
urls.extend(line.strip() for line in handle if line.strip() and not line.startswith("#"))
|
|
48
|
+
return urls[: args.limit] if args.limit else urls
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _write(rows: List[dict], fields: List[str], fmt: str, stream) -> None:
|
|
52
|
+
if fmt == "csv":
|
|
53
|
+
writer = csv.DictWriter(stream, fieldnames=fields)
|
|
54
|
+
writer.writeheader()
|
|
55
|
+
writer.writerows(rows)
|
|
56
|
+
else:
|
|
57
|
+
json.dump(rows, stream, indent=2, ensure_ascii=False)
|
|
58
|
+
stream.write("\n")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
62
|
+
args = build_parser().parse_args(argv)
|
|
63
|
+
try:
|
|
64
|
+
scraper = ZeptoScraper(token=args.token)
|
|
65
|
+
except ValueError as exc:
|
|
66
|
+
print(exc, file=sys.stderr)
|
|
67
|
+
return 2
|
|
68
|
+
|
|
69
|
+
failures = 0
|
|
70
|
+
if args.command == "category":
|
|
71
|
+
fields = LISTING_FIELDS
|
|
72
|
+
try:
|
|
73
|
+
rows = scraper.get_category(args.url, args.max_subcategories)
|
|
74
|
+
except (ScrapeError, ValueError) as exc:
|
|
75
|
+
print(f"fail {args.url}: {exc}", file=sys.stderr)
|
|
76
|
+
return 1
|
|
77
|
+
out_of_stock = sum(1 for r in rows if not r["in_stock"])
|
|
78
|
+
print(f"{len(rows)} products, {out_of_stock} out of stock", file=sys.stderr)
|
|
79
|
+
else:
|
|
80
|
+
fields = PRODUCT_FIELDS
|
|
81
|
+
urls = _read_urls(args)
|
|
82
|
+
if not urls:
|
|
83
|
+
print("No URLs given. Pass product URLs or --input FILE.", file=sys.stderr)
|
|
84
|
+
return 2
|
|
85
|
+
rows = []
|
|
86
|
+
for url in urls:
|
|
87
|
+
try:
|
|
88
|
+
rows.append(scraper.get_product(url))
|
|
89
|
+
print(f"ok {url}", file=sys.stderr)
|
|
90
|
+
except (ScrapeError, ValueError) as exc:
|
|
91
|
+
failures += 1
|
|
92
|
+
print(f"fail {url}: {exc}", file=sys.stderr)
|
|
93
|
+
|
|
94
|
+
if args.output:
|
|
95
|
+
with open(args.output, "w", encoding="utf-8", newline="" if args.format == "csv" else None) as handle:
|
|
96
|
+
_write(rows, fields, args.format, handle)
|
|
97
|
+
print(f"Saved {len(rows)} rows to {args.output}", file=sys.stderr)
|
|
98
|
+
else:
|
|
99
|
+
_write(rows, fields, args.format, sys.stdout)
|
|
100
|
+
return 1 if failures and not rows else 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Fetch Zepto pages through the Scrape.do API.
|
|
2
|
+
|
|
3
|
+
Zepto sits behind AWS WAF on CloudFront. A plain ``requests`` call gets HTTP 429;
|
|
4
|
+
with a browser user agent it gets HTTP 202, an empty body and
|
|
5
|
+
``x-amzn-waf-action: challenge``. 202 counts as success to
|
|
6
|
+
``raise_for_status()``, so this client checks for ``status_code != 200``.
|
|
7
|
+
|
|
8
|
+
Through Scrape.do a plain request (no parameters, 1 credit) returns the full page.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import time
|
|
15
|
+
from typing import List, Optional
|
|
16
|
+
|
|
17
|
+
import requests
|
|
18
|
+
|
|
19
|
+
from .parser import ZEPTO, parse_listing, parse_product, subcategory_paths
|
|
20
|
+
|
|
21
|
+
API_URL = "https://api.scrape.do/"
|
|
22
|
+
TOKEN_ENV = "SCRAPEDO_TOKEN"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ScrapeError(RuntimeError):
|
|
26
|
+
"""Raised when a page cannot be fetched."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, message: str, status_code: Optional[int] = None):
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.status_code = status_code
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ZeptoScraper:
|
|
34
|
+
"""Fetch and parse Zepto category and product pages.
|
|
35
|
+
|
|
36
|
+
>>> scraper = ZeptoScraper(token="YOUR_SCRAPEDO_TOKEN")
|
|
37
|
+
>>> rows = scraper.get_category("https://www.zepto.com/cn/munchies/chips-crisps/cid/.../scid/...")
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
token: Optional[str] = None,
|
|
43
|
+
timeout: float = 120.0,
|
|
44
|
+
retries: int = 2,
|
|
45
|
+
session: Optional[requests.Session] = None,
|
|
46
|
+
):
|
|
47
|
+
self.token = token or os.environ.get(TOKEN_ENV, "")
|
|
48
|
+
if not self.token:
|
|
49
|
+
raise ValueError(f"A Scrape.do API token is required. Pass token=... or set {TOKEN_ENV}.")
|
|
50
|
+
self.timeout = timeout
|
|
51
|
+
self.retries = max(0, retries)
|
|
52
|
+
self.session = session or requests.Session()
|
|
53
|
+
|
|
54
|
+
def fetch(self, url: str) -> str:
|
|
55
|
+
"""Return the raw HTML of ``url``. Anything but a 200 is an error."""
|
|
56
|
+
params = {"token": self.token, "url": url}
|
|
57
|
+
last_error: Optional[ScrapeError] = None
|
|
58
|
+
for attempt in range(self.retries + 1):
|
|
59
|
+
try:
|
|
60
|
+
response = self.session.get(API_URL, params=params, timeout=self.timeout)
|
|
61
|
+
except requests.RequestException as exc:
|
|
62
|
+
last_error = ScrapeError(f"Request failed: {exc}")
|
|
63
|
+
else:
|
|
64
|
+
if response.status_code == 200 and response.text:
|
|
65
|
+
return response.text
|
|
66
|
+
last_error = ScrapeError(
|
|
67
|
+
f"Request for {url} failed with status {response.status_code}",
|
|
68
|
+
status_code=response.status_code,
|
|
69
|
+
)
|
|
70
|
+
# Client errors (bad token, bad URL) will not fix themselves
|
|
71
|
+
if 400 <= response.status_code < 500 and response.status_code != 429:
|
|
72
|
+
break
|
|
73
|
+
if attempt < self.retries:
|
|
74
|
+
time.sleep(2 * (attempt + 1))
|
|
75
|
+
assert last_error is not None
|
|
76
|
+
raise last_error
|
|
77
|
+
|
|
78
|
+
def get_subcategory(self, url: str) -> List[dict]:
|
|
79
|
+
"""Every product card on one subcategory page (at most 32 on Zepto's web pages)."""
|
|
80
|
+
return parse_listing(self.fetch(url))
|
|
81
|
+
|
|
82
|
+
def get_category(self, start_url: str, max_subcategories: int = 20) -> List[dict]:
|
|
83
|
+
"""Walk the start subcategory and its siblings; one row per unique product."""
|
|
84
|
+
first = self.fetch(start_url)
|
|
85
|
+
rows = {}
|
|
86
|
+
for path in subcategory_paths(first, start_url)[:max_subcategories]:
|
|
87
|
+
html = first if path in start_url else self.fetch(ZEPTO + path)
|
|
88
|
+
for row in parse_listing(html):
|
|
89
|
+
rows.setdefault(row["variant_id"], row)
|
|
90
|
+
return list(rows.values())
|
|
91
|
+
|
|
92
|
+
def get_product(self, url: str) -> dict:
|
|
93
|
+
"""Catalog sheet for one product page (seller, shelf life, FSSAI licence...)."""
|
|
94
|
+
return parse_product(self.fetch(url), url)
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Parse Zepto category and product pages.
|
|
2
|
+
|
|
3
|
+
Zepto runs on the Next.js App Router. Every product card on a subcategory page
|
|
4
|
+
is streamed into the HTML as React Server Components payload, a series of
|
|
5
|
+
``self.__next_f.push([1, "..."])`` script chunks. Product pages carry the same
|
|
6
|
+
stream plus a schema.org ``Product`` block in JSON-LD.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from typing import Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
ZEPTO = "https://www.zepto.com"
|
|
16
|
+
|
|
17
|
+
PUSH_RE = re.compile(r'self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)')
|
|
18
|
+
# Card keys are sorted alphabetically. In-stock cards start with availableQuantity;
|
|
19
|
+
# out-of-stock cards drop that key and start with baseProductId.
|
|
20
|
+
ITEM_START_RE = re.compile(r'\{"(?:availableQuantity|baseProductId)":')
|
|
21
|
+
PRODUCT_URL_RE = re.compile(r"https://www\.zepto\.com/pn/[a-z0-9-]+/pvid/[0-9a-f-]{36}")
|
|
22
|
+
STORE_RE = re.compile(r'"storeServiceableResponse":\{[^}]*?"storeId":"([0-9a-f-]{36})"')
|
|
23
|
+
LD_RE = re.compile(r'<script[^>]*type="application/ld\+json"[^>]*>(.*?)</script>', re.S)
|
|
24
|
+
|
|
25
|
+
LISTING_FIELDS = [
|
|
26
|
+
"variant_id", "name", "brand", "pack_size", "price_inr", "mrp_inr", "discount_pct",
|
|
27
|
+
"in_stock", "available_qty", "rating", "rating_count", "subcategory", "store_id", "url",
|
|
28
|
+
]
|
|
29
|
+
PRODUCT_FIELDS = [
|
|
30
|
+
"variant_id", "name", "brand", "category", "pack_size", "mrp_inr", "max_per_order",
|
|
31
|
+
"rating", "rating_count", "shelf_life", "country_of_origin", "seller", "fssai_licence",
|
|
32
|
+
"image_count", "description", "url",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
_decoder = json.JSONDecoder()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def decode_rsc(html: str) -> str:
|
|
39
|
+
"""Join the Next.js stream chunks back into one text.
|
|
40
|
+
|
|
41
|
+
Each chunk is a JavaScript string literal; JSON strings use the same escaping,
|
|
42
|
+
so ``json.loads('"' + chunk + '"')`` gives back the original text.
|
|
43
|
+
"""
|
|
44
|
+
return "".join(json.loads('"' + chunk + '"') for chunk in PUSH_RE.findall(html))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def rupees(paise) -> object:
|
|
48
|
+
"""Zepto stores every price in paise: 1900 means Rs 19."""
|
|
49
|
+
return paise / 100 if isinstance(paise, (int, float)) and not isinstance(paise, bool) else ""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def extract_items(flat: str) -> List[dict]:
|
|
53
|
+
"""Return every product card in a decoded stream, in-stock and out-of-stock."""
|
|
54
|
+
items: Dict[str, dict] = {}
|
|
55
|
+
for match in ITEM_START_RE.finditer(flat):
|
|
56
|
+
try:
|
|
57
|
+
obj, _ = _decoder.raw_decode(flat, match.start())
|
|
58
|
+
except json.JSONDecodeError:
|
|
59
|
+
continue
|
|
60
|
+
variant = obj.get("productVariant") or {}
|
|
61
|
+
# Look-alike objects elsewhere in the stream have no product block
|
|
62
|
+
if variant.get("id") and obj.get("product"):
|
|
63
|
+
items.setdefault(variant["id"], obj)
|
|
64
|
+
return list(items.values())
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def product_urls(flat: str) -> Dict[str, str]:
|
|
68
|
+
"""The page's own product links, keyed by variant id."""
|
|
69
|
+
return {link.rsplit("/", 1)[1]: link for link in PRODUCT_URL_RE.findall(flat)}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def store_id(flat: str) -> str:
|
|
73
|
+
match = STORE_RE.search(flat)
|
|
74
|
+
return match.group(1) if match else ""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def to_row(item: dict, store: str, urls: Dict[str, str]) -> dict:
|
|
78
|
+
product = item.get("product") or {}
|
|
79
|
+
variant = item.get("productVariant") or {}
|
|
80
|
+
rating = variant.get("ratingSummary") or {}
|
|
81
|
+
return {
|
|
82
|
+
"variant_id": variant.get("id", ""),
|
|
83
|
+
"name": product.get("name", ""),
|
|
84
|
+
"brand": product.get("brand", ""),
|
|
85
|
+
"pack_size": variant.get("formattedPacksize", ""),
|
|
86
|
+
"price_inr": rupees(item.get("discountedSellingPrice")),
|
|
87
|
+
"mrp_inr": rupees(item.get("mrp")),
|
|
88
|
+
"discount_pct": item.get("discountPercent", 0),
|
|
89
|
+
"in_stock": not item.get("outOfStock", False),
|
|
90
|
+
"available_qty": item.get("availableQuantity", 0),
|
|
91
|
+
"rating": rating.get("averageRating", ""),
|
|
92
|
+
"rating_count": rating.get("totalRatings", ""),
|
|
93
|
+
"subcategory": item.get("primarySubcategoryName", ""),
|
|
94
|
+
"store_id": store,
|
|
95
|
+
"url": urls.get(variant.get("id", ""), ""),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parse_listing(html: str) -> List[dict]:
|
|
100
|
+
"""Every product card on one subcategory page, as flat rows."""
|
|
101
|
+
flat = decode_rsc(html)
|
|
102
|
+
store = store_id(flat)
|
|
103
|
+
urls = product_urls(flat)
|
|
104
|
+
return [to_row(item, store, urls) for item in extract_items(flat)]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def subcategory_paths(html: str, start_url: str) -> List[str]:
|
|
108
|
+
"""The start subcategory plus its siblings, as site paths, deduped on scid.
|
|
109
|
+
|
|
110
|
+
The same subcategory can appear under a second category slug that renders no
|
|
111
|
+
products, so only paths under the start URL's own category slug are kept.
|
|
112
|
+
"""
|
|
113
|
+
cid_match = re.search(r"/cid/([0-9a-f-]{36})", start_url)
|
|
114
|
+
if not cid_match or "/cn/" not in start_url:
|
|
115
|
+
raise ValueError("Start from a Zepto subcategory URL (.../cn/<category>/<subcategory>/cid/.../scid/...)")
|
|
116
|
+
cid = cid_match.group(1)
|
|
117
|
+
sub_re = re.compile(r"/cn/[a-z0-9-]+/[a-z0-9-]+/cid/" + cid + r"/scid/[0-9a-f-]{36}")
|
|
118
|
+
category_slug = start_url.split("/cn/")[1].split("/")[0]
|
|
119
|
+
start_path = "/cn/" + start_url.split("/cn/", 1)[1].split("?")[0]
|
|
120
|
+
found: Dict[str, str] = {}
|
|
121
|
+
for path in [start_path] + sub_re.findall(decode_rsc(html)):
|
|
122
|
+
if path.split("/")[2] == category_slug:
|
|
123
|
+
found.setdefault(path.rsplit("/", 1)[1], path)
|
|
124
|
+
return list(found.values())
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def product_jsonld(html: str) -> dict:
|
|
128
|
+
"""The schema.org Product block (the page has several JSON-LD blocks)."""
|
|
129
|
+
for block in LD_RE.findall(html):
|
|
130
|
+
try:
|
|
131
|
+
data = json.loads(block)
|
|
132
|
+
except json.JSONDecodeError:
|
|
133
|
+
continue
|
|
134
|
+
if isinstance(data, dict) and data.get("@type") == "Product":
|
|
135
|
+
return data
|
|
136
|
+
return {}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def variant_data(flat: str, variant_id: str) -> dict:
|
|
140
|
+
"""productVariant carries the printed MRP and order limit for this pack size."""
|
|
141
|
+
for match in re.finditer(r'"productVariant":\{', flat):
|
|
142
|
+
try:
|
|
143
|
+
obj, _ = _decoder.raw_decode(flat, match.end() - 1)
|
|
144
|
+
except json.JSONDecodeError:
|
|
145
|
+
continue
|
|
146
|
+
if obj.get("id") == variant_id:
|
|
147
|
+
return obj
|
|
148
|
+
return {}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def parse_product(html: str, url: str) -> dict:
|
|
152
|
+
"""Catalog sheet for one product page.
|
|
153
|
+
|
|
154
|
+
Price and stock are left out on purpose: the product page caps the stock
|
|
155
|
+
count and can lag the listing. Use the listing for those.
|
|
156
|
+
"""
|
|
157
|
+
ld = product_jsonld(html)
|
|
158
|
+
if not ld:
|
|
159
|
+
raise ValueError("No Product JSON-LD on the page; it is probably not a Zepto product page")
|
|
160
|
+
variant_id = url.rstrip("/").rsplit("/", 1)[1]
|
|
161
|
+
variant = variant_data(decode_rsc(html), variant_id)
|
|
162
|
+
rating = ld.get("aggregateRating") or {}
|
|
163
|
+
props = {p.get("name"): p.get("value") for p in ld.get("additionalProperty", []) if isinstance(p, dict)}
|
|
164
|
+
images = ld.get("image") or []
|
|
165
|
+
return {
|
|
166
|
+
"variant_id": variant_id,
|
|
167
|
+
"name": ld.get("name", ""),
|
|
168
|
+
"brand": (ld.get("brand") or {}).get("name", "") if isinstance(ld.get("brand"), dict) else ld.get("brand", ""),
|
|
169
|
+
"category": ld.get("category", ""),
|
|
170
|
+
"pack_size": ld.get("size", ""),
|
|
171
|
+
"mrp_inr": rupees(variant.get("mrp")),
|
|
172
|
+
"max_per_order": variant.get("maxAllowedQuantity", ""),
|
|
173
|
+
"rating": rating.get("ratingValue", ""),
|
|
174
|
+
"rating_count": rating.get("ratingCount", ""),
|
|
175
|
+
"shelf_life": props.get("Shelf life", ""),
|
|
176
|
+
"country_of_origin": props.get("Country of origin", ""),
|
|
177
|
+
"seller": props.get("Seller", ""),
|
|
178
|
+
"fssai_licence": props.get("FSSAI licence number", ""),
|
|
179
|
+
"image_count": len(images) if isinstance(images, list) else 1,
|
|
180
|
+
"description": ld.get("description", ""),
|
|
181
|
+
"url": url,
|
|
182
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zepto-scraper
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Scrape Zepto category listings (price, MRP, discount, stock) and product details (seller, shelf life, FSSAI licence) with Python.
|
|
5
|
+
Author: Buğrahan Saka
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/bugrahansss/zepto-scraper
|
|
8
|
+
Project-URL: Source, https://github.com/bugrahansss/zepto-scraper
|
|
9
|
+
Project-URL: Issues, https://github.com/bugrahansss/zepto-scraper/issues
|
|
10
|
+
Project-URL: Scrape.do, https://scrape.do/?utm_source=pypi&utm_medium=package&utm_campaign=zepto-scraper
|
|
11
|
+
Keywords: zepto,web scraping,quick commerce,grocery,scraper,nextjs,india
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: requests>=2.25
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# zepto-scraper
|
|
24
|
+
|
|
25
|
+
[](https://pypi.org/project/zepto-scraper/)
|
|
26
|
+
[](https://pypi.org/project/zepto-scraper/)
|
|
27
|
+
[](LICENSE)
|
|
28
|
+
|
|
29
|
+
A Python scraper for [Zepto](https://www.zepto.com), India's quick-commerce grocery app. Two jobs:
|
|
30
|
+
|
|
31
|
+
- **Category listings:** start from one subcategory URL, walk its sibling subcategories and get every product card with price, MRP, discount, in-stock flag, **units in stock**, rating and the dark store it came from.
|
|
32
|
+
- **Product details:** turn product pages into a catalog sheet with seller entity, shelf life, FSSAI licence number, country of origin, category path, order limit and the full description.
|
|
33
|
+
|
|
34
|
+
Output as Python dicts, JSON or CSV. One dependency (`requests`), no browser.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install zepto-scraper
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Why a plain request fails
|
|
41
|
+
|
|
42
|
+
Zepto sits behind AWS WAF on CloudFront:
|
|
43
|
+
|
|
44
|
+
| Request | Status | Body |
|
|
45
|
+
| --- | --- | --- |
|
|
46
|
+
| `requests`, default user agent | 429 | 0 bytes |
|
|
47
|
+
| `requests`, Chrome user agent | 202, `x-amzn-waf-action: challenge` | 0 bytes |
|
|
48
|
+
| Through Scrape.do, no parameters | 200 | full page |
|
|
49
|
+
|
|
50
|
+
**Watch the 202.** It means "Accepted", so `raise_for_status()` treats it as success and a scraper that only checks for errors writes empty pages without a single failure. This package treats anything but a 200 as an error.
|
|
51
|
+
|
|
52
|
+
Pages are fetched through the [Scrape.do](https://scrape.do/?utm_source=github&utm_medium=readme&utm_campaign=zepto-scraper) API with a plain request: no `render`, no `super`, no `geoCode`, 1 credit per page. You need a Scrape.do API token; the free plan includes 1,000 credits a month.
|
|
53
|
+
|
|
54
|
+
## Quick start
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from zepto_scraper import ZeptoScraper
|
|
58
|
+
|
|
59
|
+
scraper = ZeptoScraper(token="YOUR_SCRAPEDO_TOKEN") # or set SCRAPEDO_TOKEN
|
|
60
|
+
|
|
61
|
+
# Any subcategory URL; the scraper walks its siblings in the same category
|
|
62
|
+
rows = scraper.get_category(
|
|
63
|
+
"https://www.zepto.com/cn/munchies/chips-crisps/cid/d2c2a144-43cd-43e5-b308-92628fa68596/scid/df4f5100-c02f-4906-83b8-ddb744081a7a"
|
|
64
|
+
)
|
|
65
|
+
for row in rows[:3]:
|
|
66
|
+
print(row["name"], row["price_inr"], row["mrp_inr"], row["in_stock"], row["available_qty"])
|
|
67
|
+
|
|
68
|
+
product = scraper.get_product(rows[0]["url"])
|
|
69
|
+
print(product["seller"], product["shelf_life"], product["fssai_licence"])
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Start from a **subcategory** URL (`/cn/<category>/<subcategory>/cid/.../scid/...`). The category link on Zepto's homepage is not a listing.
|
|
73
|
+
|
|
74
|
+
### Command line
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
export SCRAPEDO_TOKEN=your_token
|
|
78
|
+
|
|
79
|
+
# every product in a category, CSV
|
|
80
|
+
zepto-scraper category "<subcategory-url>" -f csv -o zepto-category.csv
|
|
81
|
+
|
|
82
|
+
# catalog sheet for the first 10 products from that file
|
|
83
|
+
zepto-scraper product -i zepto-category.csv --limit 10 -f csv -o zepto-products.csv
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The two commands chain: `product` reads the `url` column of the listing CSV.
|
|
87
|
+
|
|
88
|
+
### Output
|
|
89
|
+
|
|
90
|
+
Listing row (from the fictional test page in [`tests/fixtures`](tests/fixtures/subcategory_page.html)):
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"variant_id": "44444444-4444-4444-8444-444444444440",
|
|
95
|
+
"name": "Example Chilli Chips",
|
|
96
|
+
"brand": "Examplo",
|
|
97
|
+
"pack_size": "1 pack (41 g)",
|
|
98
|
+
"price_inr": 19.0,
|
|
99
|
+
"mrp_inr": 20.0,
|
|
100
|
+
"discount_pct": 5,
|
|
101
|
+
"in_stock": true,
|
|
102
|
+
"available_qty": 30,
|
|
103
|
+
"rating": 4.9,
|
|
104
|
+
"rating_count": 401062,
|
|
105
|
+
"subcategory": "Chips & Crisps",
|
|
106
|
+
"store_id": "33333333-3333-4333-8333-333333333333",
|
|
107
|
+
"url": "https://www.zepto.com/pn/example-snack-0/pvid/44444444-4444-4444-8444-444444444440"
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Product sheet columns: `variant_id, name, brand, category, pack_size, mrp_inr, max_per_order, rating, rating_count, shelf_life, country_of_origin, seller, fssai_licence, image_count, description, url`.
|
|
112
|
+
|
|
113
|
+
## How it works
|
|
114
|
+
|
|
115
|
+
**The Next.js stream.** Zepto runs on the Next.js App Router. A subcategory page makes no separate product API call; every product card is streamed into the HTML as `self.__next_f.push([1, "..."])` chunks. Each chunk is a JavaScript string literal, and JSON strings use the same escaping, so `json.loads('"' + chunk + '"')` decodes it. Joined, the chunks are one text with one JSON object per product card.
|
|
116
|
+
|
|
117
|
+
**Out-of-stock cards.** Card keys are sorted alphabetically. In-stock cards start with `availableQuantity`; out-of-stock cards drop that key and start with `baseProductId`. A parser that anchors only on `availableQuantity` silently skips every out-of-stock product. This one anchors on both, reads each object with `json.JSONDecoder.raw_decode`, and dedupes on the variant id.
|
|
118
|
+
|
|
119
|
+
**Prices are in paise.** `"discountedSellingPrice": 1900` is ₹19. The scraper divides by 100.
|
|
120
|
+
|
|
121
|
+
**Siblings.** Every subcategory page links its siblings, all sharing the category `cid`. The same subcategory can appear under a second category slug that renders no products, so only paths under the start URL's own slug are kept.
|
|
122
|
+
|
|
123
|
+
**Product pages.** The catalog fields come from the schema.org `Product` JSON-LD block (Zepto writes `id` before `type` on the script tag, so the regex does not depend on attribute order). MRP and the order limit come from the stream's `productVariant` object for the exact variant in the URL.
|
|
124
|
+
|
|
125
|
+
## Limits
|
|
126
|
+
|
|
127
|
+
- **32 products per subcategory.** Zepto's web pages stop at 32 cards (`endOfPage: true`) and ignore `?page=2`. For wider coverage, walk more subcategories and categories.
|
|
128
|
+
- **One dark store.** Each price and stock count belongs to one dark store. With no location, Zepto serves its default store (east Bengaluru); the `store_id` column records which one. Changing the location through Scrape.do did not move the store in our tests.
|
|
129
|
+
- **Use the listing for price and stock.** Product pages cap the stock count and can lag the listing, so `get_product()` leaves price and stock out on purpose.
|
|
130
|
+
- **No search.** Search results need a browser-computed `request-signature` header, and robots.txt disallows `/search`. Walk categories instead.
|
|
131
|
+
- **Errors.** Non-200 responses (including the 202 challenge) raise `ScrapeError` with the status code. Server errors and 429s are retried twice; client errors are not.
|
|
132
|
+
|
|
133
|
+
## Responsible use
|
|
134
|
+
|
|
135
|
+
The scraper reads category (`/cn/`) and product (`/pn/`) pages, which Zepto's robots.txt allows; it does not touch `/search`, `/api/`, cart or account paths. Product data is not personal data. Read Zepto's terms for your use case and keep request rates modest. This is not legal advice.
|
|
136
|
+
|
|
137
|
+
## Development
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
git clone https://github.com/bugrahansss/zepto-scraper
|
|
141
|
+
cd zepto-scraper
|
|
142
|
+
pip install -e .
|
|
143
|
+
python -m unittest discover -s tests -v
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The tests run offline against fictional fixture pages.
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
MIT
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
Built and tested with [Scrape.do](https://scrape.do/?utm_source=github&utm_medium=readme&utm_campaign=zepto-scraper), October 2026.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.md
|
|
4
|
+
pyproject.toml
|
|
5
|
+
examples/category_to_products.py
|
|
6
|
+
src/zepto_scraper/__init__.py
|
|
7
|
+
src/zepto_scraper/cli.py
|
|
8
|
+
src/zepto_scraper/client.py
|
|
9
|
+
src/zepto_scraper/parser.py
|
|
10
|
+
src/zepto_scraper.egg-info/PKG-INFO
|
|
11
|
+
src/zepto_scraper.egg-info/SOURCES.txt
|
|
12
|
+
src/zepto_scraper.egg-info/dependency_links.txt
|
|
13
|
+
src/zepto_scraper.egg-info/entry_points.txt
|
|
14
|
+
src/zepto_scraper.egg-info/requires.txt
|
|
15
|
+
src/zepto_scraper.egg-info/top_level.txt
|
|
16
|
+
tests/test_parser.py
|
|
17
|
+
tests/fixtures/product_page.html
|
|
18
|
+
tests/fixtures/subcategory_page.html
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
requests>=2.25
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
zepto_scraper
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<!doctype html><html><head><title>Example</title><script type="application/ld+json">{"@type":"Organization","name":"Example"}</script><script id="productSchema" type="application/ld+json">{"@context": "https://schema.org", "@type": "Product", "name": "Example Chilli Chips", "brand": {"@type": "Brand", "name": "Examplo"}, "category": "Munchies > Chips & Crisps", "size": "1 pack (41 g)", "description": "Fictional chips for tests. Ingredients: potato, oil, salt.", "image": ["https://example.com/1.jpg", "https://example.com/2.jpg", "https://example.com/3.jpg"], "aggregateRating": {"@type": "AggregateRating", "ratingValue": 4.9, "ratingCount": 401062}, "additionalProperty": [{"@type": "PropertyValue", "name": "Shelf life", "value": "180 days"}, {"@type": "PropertyValue", "name": "Country of origin", "value": "India"}, {"@type": "PropertyValue", "name": "Seller", "value": "Example Groceries Private Limited"}, {"@type": "PropertyValue", "name": "FSSAI licence number", "value": "10000000000001"}]}</script></head><body><script>self.__next_f.push([1,"1:{\"productVariant\":{\"id\":\"44444444-4444-4444-8444-444444444441\",\"mrp\":2500,\"maxAllowedQuantity\":5}}\n"])</script><script>self.__next_f.push([1,"2:{\"storeProduct\":{\"availableQuantity\":3,\"productVariant\":{\"formattedPacksize\":\"1 pack (41 g)\",\"id\":\"44444444-4444-4444-8444-444444444440\",\"maxAllowedQuantity\":30,\"mrp\":2000}}}\n"])</script></body></html>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<!doctype html><html><head><title>Example</title></head><body><script>self.__next_f.push([1,"1:[\"$\",\"div\",null,{\"children\":\"layout\"}]\n"])</script><script>self.__next_f.push([1,"2:{\"storeServiceableResponse\":{\"serviceable\":true,\"storeId\":\"33333333-3333-4333-8333-333333333333\"}}\n\"/cn/munchies/chips-crisps/cid/11111111-1111-4111-8111-111111111111/scid/22222222-2222-4222-8222-222222222220\" \"/cn/munchies/namkeens/cid/11111111-1111-4111-8111-111111111111/scid/22222222-2222-4222-8222-222222222221\" \"/cn/snacks/top-picks/cid/11111111-1111-4111-8111-111111111111/scid/22222222-2222-4222-8222-222222222222\" \"/cn/munchies/top-picks/cid/11111111-1111-4111-8111-111111111111/scid/22222222-2222-4222-8222-222222222222\"\n"])</script><script>self.__next_f.push([1,"3:[{\"availableQuantity\":30,\"baseProductId\":\"b-4440\",\"discountPercent\":5,\"discountedSellingPrice\":1900,\"mrp\":2000,\"primarySubcategoryName\":\"Chips & Crisps\",\"product\":{\"brand\":\"Examplo\",\"name\":\"Example Chilli Chips\"},\"productVariant\":{\"formattedPacksize\":\"1 pack (41 g)\",\"id\":\"44444444-4444-4444-8444-444444444440\",\"ratingSummary\":{\"averageRating\":4.9,\"totalRatings\":401062}}},"])</script><script>self.__next_f.push([1,"{\"availableQuantity\":115,\"baseProductId\":\"b-4441\",\"discountPercent\":16,\"discountedSellingPrice\":2100,\"mrp\":2500,\"primarySubcategoryName\":\"Chips & Crisps\",\"product\":{\"brand\":\"Examplo\",\"name\":\"Example Cream & Onion Chips\"},\"productVariant\":{\"formattedPacksize\":\"1 pack (50 g)\",\"id\":\"44444444-4444-4444-8444-444444444441\",\"ratingSummary\":{\"averageRating\":4.5,\"totalRatings\":1200}}},{\"baseProductId\":\"b-4442\",\"discountPercent\":10,\"discountedSellingPrice\":4500,\"mrp\":5000,\"outOfStock\":true,\"primarySubcategoryName\":\"Chips & Crisps\",\"product\":{\"brand\":\"Snackly\",\"name\":\"Example Salted Chips\"},\"productVariant\":{\"formattedPacksize\":\"1 pack (90 g)\",\"id\":\"44444444-4444-4444-8444-444444444442\",\"ratingSummary\":{\"averageRating\":4.5,\"totalRatings\":1200}}},{\"availableQuantity\":9,\"productVariant\":{\"id\":\"44444444-4444-4444-8444-444444444443\"}}]\n\"https://www.zepto.com/pn/example-snack-0/pvid/44444444-4444-4444-8444-444444444440\" \"https://www.zepto.com/pn/example-snack-1/pvid/44444444-4444-4444-8444-444444444441\" \"https://www.zepto.com/pn/example-snack-2/pvid/44444444-4444-4444-8444-444444444442\"\n4:{\"endOfPage\":true}\n"])</script></body></html>
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from unittest import mock
|
|
4
|
+
|
|
5
|
+
from zepto_scraper import (
|
|
6
|
+
LISTING_FIELDS, PRODUCT_FIELDS, ScrapeError, ZeptoScraper,
|
|
7
|
+
decode_rsc, extract_items, parse_listing, parse_product, subcategory_paths,
|
|
8
|
+
)
|
|
9
|
+
from zepto_scraper import cli
|
|
10
|
+
|
|
11
|
+
FIX = Path(__file__).parent / "fixtures"
|
|
12
|
+
LISTING = (FIX / "subcategory_page.html").read_text(encoding="utf-8")
|
|
13
|
+
PRODUCT = (FIX / "product_page.html").read_text(encoding="utf-8")
|
|
14
|
+
CID = "11111111-1111-4111-8111-111111111111"
|
|
15
|
+
START = f"https://www.zepto.com/cn/munchies/chips-crisps/cid/{CID}/scid/22222222-2222-4222-8222-222222222220"
|
|
16
|
+
V0 = "44444444-4444-4444-8444-444444444440"
|
|
17
|
+
PRODUCT_URL = f"https://www.zepto.com/pn/example-snack-0/pvid/{V0}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FakeResponse:
|
|
21
|
+
def __init__(self, status, text=""):
|
|
22
|
+
self.status_code, self.text = status, text
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ListingTests(unittest.TestCase):
|
|
26
|
+
def test_decode_joins_chunks(self):
|
|
27
|
+
flat = decode_rsc(LISTING)
|
|
28
|
+
self.assertIn('"endOfPage":true', flat)
|
|
29
|
+
self.assertIn("storeServiceableResponse", flat)
|
|
30
|
+
|
|
31
|
+
def test_out_of_stock_cards_are_kept(self):
|
|
32
|
+
items = extract_items(decode_rsc(LISTING))
|
|
33
|
+
self.assertEqual(len(items), 3) # 2 in stock + 1 out of stock, look-alike skipped
|
|
34
|
+
self.assertEqual(sum(1 for i in items if i.get("outOfStock")), 1)
|
|
35
|
+
|
|
36
|
+
def test_prices_are_converted_from_paise(self):
|
|
37
|
+
row = next(r for r in parse_listing(LISTING) if r["variant_id"] == V0)
|
|
38
|
+
self.assertEqual((row["price_inr"], row["mrp_inr"], row["discount_pct"]), (19.0, 20.0, 5))
|
|
39
|
+
|
|
40
|
+
def test_row_fields(self):
|
|
41
|
+
row = next(r for r in parse_listing(LISTING) if r["variant_id"] == V0)
|
|
42
|
+
self.assertEqual(list(row), LISTING_FIELDS)
|
|
43
|
+
self.assertEqual(row["available_qty"], 30)
|
|
44
|
+
self.assertEqual((row["rating"], row["rating_count"]), (4.9, 401062))
|
|
45
|
+
self.assertEqual(row["store_id"], "33333333-3333-4333-8333-333333333333")
|
|
46
|
+
self.assertEqual(row["url"], PRODUCT_URL)
|
|
47
|
+
|
|
48
|
+
def test_out_of_stock_row(self):
|
|
49
|
+
row = next(r for r in parse_listing(LISTING) if r["name"] == "Example Salted Chips")
|
|
50
|
+
self.assertFalse(row["in_stock"])
|
|
51
|
+
self.assertEqual(row["available_qty"], 0)
|
|
52
|
+
|
|
53
|
+
def test_subcategories_dedupe_and_skip_second_slug(self):
|
|
54
|
+
paths = subcategory_paths(LISTING, START)
|
|
55
|
+
self.assertEqual(len(paths), 3)
|
|
56
|
+
self.assertTrue(all(p.startswith("/cn/munchies/") for p in paths))
|
|
57
|
+
self.assertEqual(paths[0], START.split("zepto.com")[1])
|
|
58
|
+
|
|
59
|
+
def test_subcategories_need_a_subcategory_url(self):
|
|
60
|
+
with self.assertRaises(ValueError):
|
|
61
|
+
subcategory_paths(LISTING, "https://www.zepto.com/pn/x/pvid/y")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ProductTests(unittest.TestCase):
|
|
65
|
+
def test_product_sheet(self):
|
|
66
|
+
row = parse_product(PRODUCT, PRODUCT_URL)
|
|
67
|
+
self.assertEqual(list(row), PRODUCT_FIELDS)
|
|
68
|
+
self.assertEqual(row["name"], "Example Chilli Chips")
|
|
69
|
+
self.assertEqual(row["brand"], "Examplo")
|
|
70
|
+
self.assertEqual(row["seller"], "Example Groceries Private Limited")
|
|
71
|
+
self.assertEqual(row["shelf_life"], "180 days")
|
|
72
|
+
self.assertEqual(row["fssai_licence"], "10000000000001")
|
|
73
|
+
self.assertEqual(row["image_count"], 3)
|
|
74
|
+
|
|
75
|
+
def test_mrp_and_limit_come_from_the_matching_variant(self):
|
|
76
|
+
row = parse_product(PRODUCT, PRODUCT_URL)
|
|
77
|
+
self.assertEqual((row["mrp_inr"], row["max_per_order"]), (20.0, 30))
|
|
78
|
+
|
|
79
|
+
def test_jsonld_with_id_before_type_is_found(self):
|
|
80
|
+
self.assertIn('id="productSchema" type="application/ld+json"', PRODUCT)
|
|
81
|
+
self.assertEqual(parse_product(PRODUCT, PRODUCT_URL)["category"], "Munchies > Chips & Crisps")
|
|
82
|
+
|
|
83
|
+
def test_non_product_page_raises(self):
|
|
84
|
+
with self.assertRaises(ValueError):
|
|
85
|
+
parse_product(LISTING, PRODUCT_URL)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ClientTests(unittest.TestCase):
|
|
89
|
+
def scraper(self, *responses):
|
|
90
|
+
session = mock.Mock()
|
|
91
|
+
session.get.side_effect = list(responses)
|
|
92
|
+
return ZeptoScraper(token="t", session=session, retries=1), session
|
|
93
|
+
|
|
94
|
+
def test_202_challenge_is_an_error(self):
|
|
95
|
+
s, _ = self.scraper(FakeResponse(202), FakeResponse(202))
|
|
96
|
+
with mock.patch("time.sleep"), self.assertRaises(ScrapeError) as ctx:
|
|
97
|
+
s.fetch(START)
|
|
98
|
+
self.assertEqual(ctx.exception.status_code, 202)
|
|
99
|
+
|
|
100
|
+
def test_client_error_is_not_retried(self):
|
|
101
|
+
s, session = self.scraper(FakeResponse(401))
|
|
102
|
+
with self.assertRaises(ScrapeError):
|
|
103
|
+
s.fetch(START)
|
|
104
|
+
self.assertEqual(session.get.call_count, 1)
|
|
105
|
+
|
|
106
|
+
def test_plain_request_no_extra_parameters(self):
|
|
107
|
+
s, session = self.scraper(FakeResponse(200, LISTING))
|
|
108
|
+
s.fetch(START)
|
|
109
|
+
self.assertEqual(session.get.call_args.kwargs["params"], {"token": "t", "url": START})
|
|
110
|
+
|
|
111
|
+
def test_category_walk_dedupes(self):
|
|
112
|
+
s, session = self.scraper(*[FakeResponse(200, LISTING)] * 3)
|
|
113
|
+
rows = s.get_category(START)
|
|
114
|
+
self.assertEqual(len(rows), 3)
|
|
115
|
+
self.assertEqual(session.get.call_count, 3) # start page reused, two siblings fetched
|
|
116
|
+
|
|
117
|
+
def test_token_required(self):
|
|
118
|
+
with mock.patch.dict("os.environ", {}, clear=True), self.assertRaises(ValueError):
|
|
119
|
+
ZeptoScraper()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class CliTests(unittest.TestCase):
|
|
123
|
+
def test_product_csv_input(self):
|
|
124
|
+
import tempfile, os, csv
|
|
125
|
+
with tempfile.TemporaryDirectory() as d:
|
|
126
|
+
inp, out = os.path.join(d, "in.csv"), os.path.join(d, "out.csv")
|
|
127
|
+
with open(inp, "w", newline="") as f:
|
|
128
|
+
w = csv.DictWriter(f, fieldnames=["url"]); w.writeheader(); w.writerow({"url": PRODUCT_URL})
|
|
129
|
+
with mock.patch.object(ZeptoScraper, "fetch", return_value=PRODUCT):
|
|
130
|
+
code = cli.main(["product", "-t", "x", "-i", inp, "-f", "csv", "-o", out])
|
|
131
|
+
self.assertEqual(code, 0)
|
|
132
|
+
self.assertIn("Example Groceries", open(out).read())
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
if __name__ == "__main__":
|
|
136
|
+
unittest.main()
|