swiggy-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.
- swiggy_scraper-0.1.0/LICENSE +21 -0
- swiggy_scraper-0.1.0/MANIFEST.in +3 -0
- swiggy_scraper-0.1.0/PKG-INFO +130 -0
- swiggy_scraper-0.1.0/README.md +108 -0
- swiggy_scraper-0.1.0/examples/cheapest_dish.py +19 -0
- swiggy_scraper-0.1.0/pyproject.toml +34 -0
- swiggy_scraper-0.1.0/setup.cfg +4 -0
- swiggy_scraper-0.1.0/src/swiggy_scraper/__init__.py +36 -0
- swiggy_scraper-0.1.0/src/swiggy_scraper/cli.py +118 -0
- swiggy_scraper-0.1.0/src/swiggy_scraper/client.py +164 -0
- swiggy_scraper-0.1.0/src/swiggy_scraper/parser.py +279 -0
- swiggy_scraper-0.1.0/src/swiggy_scraper.egg-info/PKG-INFO +130 -0
- swiggy_scraper-0.1.0/src/swiggy_scraper.egg-info/SOURCES.txt +20 -0
- swiggy_scraper-0.1.0/src/swiggy_scraper.egg-info/dependency_links.txt +1 -0
- swiggy_scraper-0.1.0/src/swiggy_scraper.egg-info/entry_points.txt +2 -0
- swiggy_scraper-0.1.0/src/swiggy_scraper.egg-info/requires.txt +1 -0
- swiggy_scraper-0.1.0/src/swiggy_scraper.egg-info/top_level.txt +1 -0
- swiggy_scraper-0.1.0/tests/fixtures/listing.json +156 -0
- swiggy_scraper-0.1.0/tests/fixtures/menu.json +323 -0
- swiggy_scraper-0.1.0/tests/fixtures/search_dishes.json +201 -0
- swiggy_scraper-0.1.0/tests/fixtures/search_restaurants.json +174 -0
- swiggy_scraper-0.1.0/tests/test_parser.py +126 -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,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: swiggy-scraper
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Scrape Swiggy dishes, restaurants and full menus (prices, variants, ratings, delivery time, FSSAI licence) with Python.
|
|
5
|
+
Author: Buğrahan Saka
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/bugrahansss/swiggy-scraper
|
|
8
|
+
Project-URL: Source, https://github.com/bugrahansss/swiggy-scraper
|
|
9
|
+
Project-URL: Issues, https://github.com/bugrahansss/swiggy-scraper/issues
|
|
10
|
+
Project-URL: Scrape.do, https://scrape.do/?utm_source=pypi&utm_medium=package&utm_campaign=swiggy-scraper
|
|
11
|
+
Keywords: swiggy,web scraping,food delivery,restaurant menu,scraper,aws waf,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
|
+
# swiggy-scraper
|
|
24
|
+
|
|
25
|
+
[](https://pypi.org/project/swiggy-scraper/)
|
|
26
|
+
[](https://pypi.org/project/swiggy-scraper/)
|
|
27
|
+
[](LICENSE)
|
|
28
|
+
|
|
29
|
+
A Python scraper for [Swiggy](https://www.swiggy.com), India's food delivery app. Three jobs:
|
|
30
|
+
|
|
31
|
+
- **Dish search:** search a dish ("biryani") and get every matching dish from the restaurants that deliver to your location: price, veg/non-veg, bestseller flag, rating, rating count, every variant price, and the restaurant with its delivery time. One request, ~50 restaurants.
|
|
32
|
+
- **Restaurant search and nearby list:** name, area, cuisines, rating, rating count, cost for two, delivery time, distance, open/serviceable, and whether the listing is a paid (promoted) placement.
|
|
33
|
+
- **Full menus:** every dish of a restaurant with price, variants, veg flag, stock and rating, plus the restaurant's **FSSAI licence** and full address.
|
|
34
|
+
|
|
35
|
+
Output as Python dicts, JSON or CSV. One dependency (`requests`), no browser.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install swiggy-scraper
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Why a plain request fails
|
|
42
|
+
|
|
43
|
+
Swiggy sits behind AWS WAF on CloudFront. A script gets no data:
|
|
44
|
+
|
|
45
|
+
| Request | Result |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| `requests` with a Chrome user agent | HTTP 202, empty body, `x-amzn-waf-action: challenge` |
|
|
48
|
+
| `requests` with its default user agent | HTTP 403, an error page |
|
|
49
|
+
| Through Scrape.do (`geoCode=in`) | 200 in ~3 s, the JSON, every try |
|
|
50
|
+
|
|
51
|
+
Requests go through the [Scrape.do](https://scrape.do/?utm_source=github&utm_medium=readme&utm_campaign=swiggy-scraper) API from India. Each request costs 1 credit; the free plan includes 1,000 credits a month.
|
|
52
|
+
|
|
53
|
+
## Quick start
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from swiggy_scraper import SwiggyScraper
|
|
57
|
+
|
|
58
|
+
scraper = SwiggyScraper(token="YOUR_SCRAPEDO_TOKEN", city="mumbai") # or lat=..., lng=...
|
|
59
|
+
|
|
60
|
+
dishes = scraper.search_dishes("biryani") # 1 request
|
|
61
|
+
for d in sorted(dishes, key=lambda d: d["price_inr"])[:3]:
|
|
62
|
+
print(d["price_inr"], d["name"], d["restaurant_name"], d["delivery_time_min"])
|
|
63
|
+
|
|
64
|
+
menu = scraper.get_menu("https://www.swiggy.com/city/bangalore/starbucks-coffee-ashok-nagar-rest124178")
|
|
65
|
+
print(menu["restaurant"]["fssai_license"], len(menu["items"]))
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Command line
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
export SCRAPEDO_TOKEN=your_token
|
|
72
|
+
|
|
73
|
+
# every biryani on Swiggy for a Mumbai address
|
|
74
|
+
swiggy-scraper dishes biryani --city mumbai -f csv -o swiggy-biryani.csv
|
|
75
|
+
|
|
76
|
+
# pizza places that deliver to central Bengaluru
|
|
77
|
+
swiggy-scraper restaurants pizza -f csv -o swiggy-pizza.csv
|
|
78
|
+
|
|
79
|
+
# restaurants on the home page for any address
|
|
80
|
+
swiggy-scraper nearby --lat 12.9352 --lng 77.6245 -f csv -o swiggy-nearby.csv
|
|
81
|
+
|
|
82
|
+
# full menus of those restaurants
|
|
83
|
+
swiggy-scraper menu -i swiggy-nearby.csv --limit 5 -f csv -o swiggy-menus.csv
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
City presets: `bengaluru` (default), `chennai`, `delhi`, `gurugram`, `hyderabad`, `kolkata`, `mumbai`, `pune`.
|
|
87
|
+
|
|
88
|
+
### Output
|
|
89
|
+
|
|
90
|
+
Dish row (from the fictional test data in [`tests/fixtures`](tests/fixtures/menu.json)):
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{"item_id": "9001", "name": "Paneer Dum Biryani", "category": "Biryani", "price_inr": 249, "is_veg": true,
|
|
94
|
+
"in_stock": true, "bestseller": true, "rating": 4.4, "rating_count": 210,
|
|
95
|
+
"variant_prices_inr": "Half 249; Full 389", "restaurant_id": "5001", "restaurant_name": "Example Biryani House"}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Restaurant columns: `restaurant_id, name, area, locality, cuisines, rating, rating_count, cost_for_two_inr, delivery_time_min, distance_km, serviceable, is_open, promoted, veg_only, url` (menus add `fssai_license` and `address`).
|
|
99
|
+
|
|
100
|
+
## How it works
|
|
101
|
+
|
|
102
|
+
**The page is a shell.** A restaurant page (`/city/bangalore/…-rest124178`) is 78 KB of HTML with no menu in it. The browser fills it from JSON endpoints under `/dapi`, and this package calls those directly:
|
|
103
|
+
|
|
104
|
+
| Data | Endpoint |
|
|
105
|
+
| --- | --- |
|
|
106
|
+
| Dish / restaurant search | `/dapi/restaurants/search/v3?lat&lng&str=biryani&selectedPLTab=DISH` (or `RESTAURANT`) |
|
|
107
|
+
| Home page restaurants | `/dapi/restaurants/list/v5?lat&lng` |
|
|
108
|
+
| Full menu | `/dapi/menu/pl?page-type=REGULAR_MENU&complete-menu=true&lat&lng&restaurantId=…` |
|
|
109
|
+
|
|
110
|
+
Every response is a list of widget cards with an `@type`; the parsers keep the restaurant, dish and licence cards and skip banners, filters and SEO blocks.
|
|
111
|
+
|
|
112
|
+
**Money is in paise.** `"price": 42000` is Rs 420. Some dishes have no `price`, only `defaultPrice`. Variant prices come either as absolute prices (`variantsV2.pricingModels`) or as a surcharge on the base price (`variants`); both are turned into rupees.
|
|
113
|
+
|
|
114
|
+
**The menu repeats itself.** The "Recommended" section lists dishes that also appear in their own sections: Starbucks in Bengaluru has 168 dish cards and 148 dishes. Each dish is kept once.
|
|
115
|
+
|
|
116
|
+
**Location decides the restaurants.** `lat`/`lng` decide which restaurants deliver, how far they are and how long delivery takes. The same "biryani" search returned 50 restaurants in Bengaluru and 50 in Mumbai with none in common. A menu still loads from a far-away location, with `serviceable: false`.
|
|
117
|
+
|
|
118
|
+
**Some results are ads.** Search mixes in paid placements; they carry `promoted: true` so you can drop them or keep them.
|
|
119
|
+
|
|
120
|
+
## Responsible use
|
|
121
|
+
|
|
122
|
+
The package calls the JSON endpoints Swiggy's own web pages use for search, the home page and menus. Swiggy's robots.txt allows these paths and disallows account, cart, checkout, order tracking and payment pages, which this package never touches. It collects restaurant and menu data, not personal data. Read Swiggy's terms for your use case and keep request rates modest.
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
|
|
126
|
+
MIT. Not affiliated with Swiggy.
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
Built with [Scrape.do](https://scrape.do/?utm_source=github&utm_medium=readme&utm_campaign=swiggy-scraper). [Get 1000 free credits](https://dashboard.scrape.do/sign-up?utm_source=github&utm_medium=readme&utm_campaign=swiggy-scraper).
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# swiggy-scraper
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/swiggy-scraper/)
|
|
4
|
+
[](https://pypi.org/project/swiggy-scraper/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
A Python scraper for [Swiggy](https://www.swiggy.com), India's food delivery app. Three jobs:
|
|
8
|
+
|
|
9
|
+
- **Dish search:** search a dish ("biryani") and get every matching dish from the restaurants that deliver to your location: price, veg/non-veg, bestseller flag, rating, rating count, every variant price, and the restaurant with its delivery time. One request, ~50 restaurants.
|
|
10
|
+
- **Restaurant search and nearby list:** name, area, cuisines, rating, rating count, cost for two, delivery time, distance, open/serviceable, and whether the listing is a paid (promoted) placement.
|
|
11
|
+
- **Full menus:** every dish of a restaurant with price, variants, veg flag, stock and rating, plus the restaurant's **FSSAI licence** and full address.
|
|
12
|
+
|
|
13
|
+
Output as Python dicts, JSON or CSV. One dependency (`requests`), no browser.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install swiggy-scraper
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Why a plain request fails
|
|
20
|
+
|
|
21
|
+
Swiggy sits behind AWS WAF on CloudFront. A script gets no data:
|
|
22
|
+
|
|
23
|
+
| Request | Result |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| `requests` with a Chrome user agent | HTTP 202, empty body, `x-amzn-waf-action: challenge` |
|
|
26
|
+
| `requests` with its default user agent | HTTP 403, an error page |
|
|
27
|
+
| Through Scrape.do (`geoCode=in`) | 200 in ~3 s, the JSON, every try |
|
|
28
|
+
|
|
29
|
+
Requests go through the [Scrape.do](https://scrape.do/?utm_source=github&utm_medium=readme&utm_campaign=swiggy-scraper) API from India. Each request costs 1 credit; the free plan includes 1,000 credits a month.
|
|
30
|
+
|
|
31
|
+
## Quick start
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from swiggy_scraper import SwiggyScraper
|
|
35
|
+
|
|
36
|
+
scraper = SwiggyScraper(token="YOUR_SCRAPEDO_TOKEN", city="mumbai") # or lat=..., lng=...
|
|
37
|
+
|
|
38
|
+
dishes = scraper.search_dishes("biryani") # 1 request
|
|
39
|
+
for d in sorted(dishes, key=lambda d: d["price_inr"])[:3]:
|
|
40
|
+
print(d["price_inr"], d["name"], d["restaurant_name"], d["delivery_time_min"])
|
|
41
|
+
|
|
42
|
+
menu = scraper.get_menu("https://www.swiggy.com/city/bangalore/starbucks-coffee-ashok-nagar-rest124178")
|
|
43
|
+
print(menu["restaurant"]["fssai_license"], len(menu["items"]))
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Command line
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
export SCRAPEDO_TOKEN=your_token
|
|
50
|
+
|
|
51
|
+
# every biryani on Swiggy for a Mumbai address
|
|
52
|
+
swiggy-scraper dishes biryani --city mumbai -f csv -o swiggy-biryani.csv
|
|
53
|
+
|
|
54
|
+
# pizza places that deliver to central Bengaluru
|
|
55
|
+
swiggy-scraper restaurants pizza -f csv -o swiggy-pizza.csv
|
|
56
|
+
|
|
57
|
+
# restaurants on the home page for any address
|
|
58
|
+
swiggy-scraper nearby --lat 12.9352 --lng 77.6245 -f csv -o swiggy-nearby.csv
|
|
59
|
+
|
|
60
|
+
# full menus of those restaurants
|
|
61
|
+
swiggy-scraper menu -i swiggy-nearby.csv --limit 5 -f csv -o swiggy-menus.csv
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
City presets: `bengaluru` (default), `chennai`, `delhi`, `gurugram`, `hyderabad`, `kolkata`, `mumbai`, `pune`.
|
|
65
|
+
|
|
66
|
+
### Output
|
|
67
|
+
|
|
68
|
+
Dish row (from the fictional test data in [`tests/fixtures`](tests/fixtures/menu.json)):
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{"item_id": "9001", "name": "Paneer Dum Biryani", "category": "Biryani", "price_inr": 249, "is_veg": true,
|
|
72
|
+
"in_stock": true, "bestseller": true, "rating": 4.4, "rating_count": 210,
|
|
73
|
+
"variant_prices_inr": "Half 249; Full 389", "restaurant_id": "5001", "restaurant_name": "Example Biryani House"}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Restaurant columns: `restaurant_id, name, area, locality, cuisines, rating, rating_count, cost_for_two_inr, delivery_time_min, distance_km, serviceable, is_open, promoted, veg_only, url` (menus add `fssai_license` and `address`).
|
|
77
|
+
|
|
78
|
+
## How it works
|
|
79
|
+
|
|
80
|
+
**The page is a shell.** A restaurant page (`/city/bangalore/…-rest124178`) is 78 KB of HTML with no menu in it. The browser fills it from JSON endpoints under `/dapi`, and this package calls those directly:
|
|
81
|
+
|
|
82
|
+
| Data | Endpoint |
|
|
83
|
+
| --- | --- |
|
|
84
|
+
| Dish / restaurant search | `/dapi/restaurants/search/v3?lat&lng&str=biryani&selectedPLTab=DISH` (or `RESTAURANT`) |
|
|
85
|
+
| Home page restaurants | `/dapi/restaurants/list/v5?lat&lng` |
|
|
86
|
+
| Full menu | `/dapi/menu/pl?page-type=REGULAR_MENU&complete-menu=true&lat&lng&restaurantId=…` |
|
|
87
|
+
|
|
88
|
+
Every response is a list of widget cards with an `@type`; the parsers keep the restaurant, dish and licence cards and skip banners, filters and SEO blocks.
|
|
89
|
+
|
|
90
|
+
**Money is in paise.** `"price": 42000` is Rs 420. Some dishes have no `price`, only `defaultPrice`. Variant prices come either as absolute prices (`variantsV2.pricingModels`) or as a surcharge on the base price (`variants`); both are turned into rupees.
|
|
91
|
+
|
|
92
|
+
**The menu repeats itself.** The "Recommended" section lists dishes that also appear in their own sections: Starbucks in Bengaluru has 168 dish cards and 148 dishes. Each dish is kept once.
|
|
93
|
+
|
|
94
|
+
**Location decides the restaurants.** `lat`/`lng` decide which restaurants deliver, how far they are and how long delivery takes. The same "biryani" search returned 50 restaurants in Bengaluru and 50 in Mumbai with none in common. A menu still loads from a far-away location, with `serviceable: false`.
|
|
95
|
+
|
|
96
|
+
**Some results are ads.** Search mixes in paid placements; they carry `promoted: true` so you can drop them or keep them.
|
|
97
|
+
|
|
98
|
+
## Responsible use
|
|
99
|
+
|
|
100
|
+
The package calls the JSON endpoints Swiggy's own web pages use for search, the home page and menus. Swiggy's robots.txt allows these paths and disallows account, cart, checkout, order tracking and payment pages, which this package never touches. It collects restaurant and menu data, not personal data. Read Swiggy's terms for your use case and keep request rates modest.
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT. Not affiliated with Swiggy.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
Built with [Scrape.do](https://scrape.do/?utm_source=github&utm_medium=readme&utm_campaign=swiggy-scraper). [Get 1000 free credits](https://dashboard.scrape.do/sign-up?utm_source=github&utm_medium=readme&utm_campaign=swiggy-scraper).
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Cheapest delivered biryani in two cities, from one search request each.
|
|
2
|
+
|
|
3
|
+
export SCRAPEDO_TOKEN=your_token
|
|
4
|
+
python examples/cheapest_dish.py biryani
|
|
5
|
+
"""
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from swiggy_scraper import SwiggyScraper
|
|
9
|
+
|
|
10
|
+
query = sys.argv[1] if len(sys.argv) > 1 else "biryani"
|
|
11
|
+
for city in ("bengaluru", "mumbai"):
|
|
12
|
+
scraper = SwiggyScraper(city=city)
|
|
13
|
+
# search also returns sides (raita, pappadam): keep dishes that carry the query in their name
|
|
14
|
+
dishes = [d for d in scraper.search_dishes(query) if d["in_stock"] and d["price_inr"] and query.lower() in d["name"].lower()]
|
|
15
|
+
restaurants = {d["restaurant_id"] for d in dishes}
|
|
16
|
+
cheapest = sorted(dishes, key=lambda d: d["price_inr"])[:3]
|
|
17
|
+
print(f"\n{city}: {len(dishes)} dishes from {len(restaurants)} restaurants")
|
|
18
|
+
for d in cheapest:
|
|
19
|
+
print(f" Rs {d['price_inr']:>6} {d['name']} ({d['restaurant_name']}, {d['delivery_time_min']} min)")
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "swiggy-scraper"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Scrape Swiggy dishes, restaurants and full menus (prices, variants, ratings, delivery time, 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 = ["swiggy", "web scraping", "food delivery", "restaurant menu", "scraper", "aws waf", "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/swiggy-scraper"
|
|
26
|
+
Source = "https://github.com/bugrahansss/swiggy-scraper"
|
|
27
|
+
Issues = "https://github.com/bugrahansss/swiggy-scraper/issues"
|
|
28
|
+
"Scrape.do" = "https://scrape.do/?utm_source=pypi&utm_medium=package&utm_campaign=swiggy-scraper"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
swiggy-scraper = "swiggy_scraper.cli:main"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["src"]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Scrape Swiggy dishes, restaurants and full menus with Python, through the Scrape.do API."""
|
|
2
|
+
|
|
3
|
+
from .client import CITIES, ScrapeError, SwiggyScraper
|
|
4
|
+
from .parser import (
|
|
5
|
+
DISH_FIELDS,
|
|
6
|
+
RESTAURANT_FIELDS,
|
|
7
|
+
parse_count,
|
|
8
|
+
parse_item,
|
|
9
|
+
parse_listing,
|
|
10
|
+
parse_menu,
|
|
11
|
+
parse_restaurant,
|
|
12
|
+
parse_search_dishes,
|
|
13
|
+
parse_search_restaurants,
|
|
14
|
+
restaurant_id_from_url,
|
|
15
|
+
rupees,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"CITIES",
|
|
22
|
+
"DISH_FIELDS",
|
|
23
|
+
"RESTAURANT_FIELDS",
|
|
24
|
+
"ScrapeError",
|
|
25
|
+
"SwiggyScraper",
|
|
26
|
+
"parse_count",
|
|
27
|
+
"parse_item",
|
|
28
|
+
"parse_listing",
|
|
29
|
+
"parse_menu",
|
|
30
|
+
"parse_restaurant",
|
|
31
|
+
"parse_search_dishes",
|
|
32
|
+
"parse_search_restaurants",
|
|
33
|
+
"restaurant_id_from_url",
|
|
34
|
+
"rupees",
|
|
35
|
+
"__version__",
|
|
36
|
+
]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Command line entry point: ``swiggy-scraper dishes|restaurants|nearby|menu``."""
|
|
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 CITIES, TOKEN_ENV, ScrapeError, SwiggyScraper
|
|
13
|
+
from .parser import DISH_FIELDS, RESTAURANT_FIELDS
|
|
14
|
+
|
|
15
|
+
MENU_FIELDS = DISH_FIELDS
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
19
|
+
parser = argparse.ArgumentParser(
|
|
20
|
+
prog="swiggy-scraper",
|
|
21
|
+
description="Scrape Swiggy dishes, restaurants and full menus (prices, ratings, delivery time) into JSON or CSV.",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
24
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
25
|
+
|
|
26
|
+
dishes = sub.add_parser("dishes", help="Search dishes: every matching dish with price and restaurant")
|
|
27
|
+
dishes.add_argument("query", help='What to search, e.g. "biryani"')
|
|
28
|
+
rest = sub.add_parser("restaurants", help="Search restaurants that deliver to the location")
|
|
29
|
+
rest.add_argument("query", help='What to search, e.g. "pizza"')
|
|
30
|
+
sub.add_parser("nearby", help="Restaurants on the Swiggy home page for the location")
|
|
31
|
+
menu = sub.add_parser("menu", help="Full menus: every dish with price, variants, veg, stock, rating")
|
|
32
|
+
menu.add_argument("urls", nargs="*", help="Swiggy restaurant URLs or ids")
|
|
33
|
+
menu.add_argument("-i", "--input", help="Text file with one URL per line, or a CSV with a 'url' column")
|
|
34
|
+
menu.add_argument("--limit", type=int, default=0, help="Stop after this many restaurants (0 = all)")
|
|
35
|
+
|
|
36
|
+
for p in (dishes, rest, sub.choices["nearby"], menu):
|
|
37
|
+
p.add_argument("-t", "--token", help=f"Scrape.do API token (default: ${TOKEN_ENV})")
|
|
38
|
+
p.add_argument("--city", choices=sorted(CITIES), help="Delivery location preset (default: bengaluru)")
|
|
39
|
+
p.add_argument("--lat", type=float, help="Delivery latitude (with --lng)")
|
|
40
|
+
p.add_argument("--lng", type=float, help="Delivery longitude (with --lat)")
|
|
41
|
+
p.add_argument("-f", "--format", choices=("json", "csv"), default="json")
|
|
42
|
+
p.add_argument("-o", "--output", help="Write to this file instead of stdout")
|
|
43
|
+
return parser
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _read_urls(args: argparse.Namespace) -> List[str]:
|
|
47
|
+
urls = list(args.urls)
|
|
48
|
+
if args.input:
|
|
49
|
+
with open(args.input, encoding="utf-8", newline="") as handle:
|
|
50
|
+
if args.input.endswith(".csv"):
|
|
51
|
+
urls.extend(row["url"] for row in csv.DictReader(handle) if row.get("url"))
|
|
52
|
+
else:
|
|
53
|
+
urls.extend(line.strip() for line in handle if line.strip() and not line.startswith("#"))
|
|
54
|
+
return urls[: args.limit] if args.limit else urls
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _write(rows: List[dict], fields: List[str], fmt: str, stream) -> None:
|
|
58
|
+
if fmt == "csv":
|
|
59
|
+
writer = csv.DictWriter(stream, fieldnames=fields, extrasaction="ignore")
|
|
60
|
+
writer.writeheader()
|
|
61
|
+
writer.writerows(rows)
|
|
62
|
+
else:
|
|
63
|
+
json.dump(rows, stream, indent=2, ensure_ascii=False)
|
|
64
|
+
stream.write("\n")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
68
|
+
args = build_parser().parse_args(argv)
|
|
69
|
+
if (args.lat is None) != (args.lng is None):
|
|
70
|
+
print("Pass both --lat and --lng.", file=sys.stderr)
|
|
71
|
+
return 2
|
|
72
|
+
try:
|
|
73
|
+
scraper = SwiggyScraper(token=args.token, lat=args.lat, lng=args.lng, city=args.city)
|
|
74
|
+
except ValueError as exc:
|
|
75
|
+
print(exc, file=sys.stderr)
|
|
76
|
+
return 2
|
|
77
|
+
|
|
78
|
+
failures = 0
|
|
79
|
+
rows: List[dict] = []
|
|
80
|
+
try:
|
|
81
|
+
if args.command == "dishes":
|
|
82
|
+
fields, rows = DISH_FIELDS + ["restaurant_rating", "delivery_time_min", "restaurant_promoted"], scraper.search_dishes(args.query)
|
|
83
|
+
elif args.command == "restaurants":
|
|
84
|
+
fields, rows = RESTAURANT_FIELDS, scraper.search_restaurants(args.query)
|
|
85
|
+
elif args.command == "nearby":
|
|
86
|
+
fields, rows = RESTAURANT_FIELDS, scraper.get_restaurants()
|
|
87
|
+
else:
|
|
88
|
+
fields = MENU_FIELDS
|
|
89
|
+
urls = _read_urls(args)
|
|
90
|
+
if not urls:
|
|
91
|
+
print("No URLs given. Pass restaurant URLs or --input FILE.", file=sys.stderr)
|
|
92
|
+
return 2
|
|
93
|
+
for url in urls:
|
|
94
|
+
try:
|
|
95
|
+
menu = scraper.get_menu(url)
|
|
96
|
+
except (ScrapeError, ValueError) as exc:
|
|
97
|
+
failures += 1
|
|
98
|
+
print(f"fail {url}: {exc}", file=sys.stderr)
|
|
99
|
+
continue
|
|
100
|
+
rows.extend(menu["items"])
|
|
101
|
+
r = menu["restaurant"]
|
|
102
|
+
print(f"ok {r['name']} ({r['area']}): {len(menu['items'])} dishes, FSSAI {r['fssai_license']}", file=sys.stderr)
|
|
103
|
+
except (ScrapeError, ValueError) as exc:
|
|
104
|
+
print(f"fail {exc}", file=sys.stderr)
|
|
105
|
+
return 1
|
|
106
|
+
|
|
107
|
+
print(f"{len(rows)} rows, {scraper.credits_used} credits", file=sys.stderr)
|
|
108
|
+
if args.output:
|
|
109
|
+
with open(args.output, "w", encoding="utf-8", newline="" if args.format == "csv" else None) as handle:
|
|
110
|
+
_write(rows, fields, args.format, handle)
|
|
111
|
+
print(f"Saved {len(rows)} rows to {args.output}", file=sys.stderr)
|
|
112
|
+
else:
|
|
113
|
+
_write(rows, fields, args.format, sys.stdout)
|
|
114
|
+
return 1 if failures and not rows else 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Fetch Swiggy's JSON APIs through the Scrape.do API.
|
|
2
|
+
|
|
3
|
+
Swiggy sits behind AWS WAF. A script with a browser user agent gets HTTP 202
|
|
4
|
+
with an empty body and ``x-amzn-waf-action: challenge``; with the default
|
|
5
|
+
``requests`` user agent it gets a 403 page. Through Scrape.do a plain request
|
|
6
|
+
from India (``geoCode=in``) returns the JSON, for 1 credit.
|
|
7
|
+
|
|
8
|
+
Everything depends on the delivery location (``lat``/``lng`` in the query):
|
|
9
|
+
which restaurants deliver, how far they are and how long delivery takes.
|
|
10
|
+
Without a location this package uses central Bengaluru.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import time
|
|
18
|
+
from typing import Any, Dict, List, Optional
|
|
19
|
+
from urllib.parse import urlencode
|
|
20
|
+
|
|
21
|
+
import requests
|
|
22
|
+
|
|
23
|
+
from .parser import (
|
|
24
|
+
SWIGGY, parse_listing, parse_menu, parse_search_dishes, parse_search_restaurants, restaurant_id_from_url,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
API_URL = "https://api.scrape.do/"
|
|
28
|
+
TOKEN_ENV = "SCRAPEDO_TOKEN"
|
|
29
|
+
|
|
30
|
+
CITIES = {
|
|
31
|
+
"bengaluru": (12.9715987, 77.5945627),
|
|
32
|
+
"mumbai": (19.0760, 72.8777),
|
|
33
|
+
"delhi": (28.6315, 77.2167),
|
|
34
|
+
"gurugram": (28.4595, 77.0266),
|
|
35
|
+
"hyderabad": (17.3850, 78.4867),
|
|
36
|
+
"pune": (18.5204, 73.8567),
|
|
37
|
+
"chennai": (13.0827, 80.2707),
|
|
38
|
+
"kolkata": (22.5726, 88.3639),
|
|
39
|
+
}
|
|
40
|
+
DEFAULT_CITY = "bengaluru"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ScrapeError(RuntimeError):
|
|
44
|
+
"""A request that did not return usable data. ``from_target`` is True when the status came from Swiggy."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, message: str, status_code: Optional[int] = None, from_target: bool = False) -> None:
|
|
47
|
+
super().__init__(message)
|
|
48
|
+
self.status_code = status_code
|
|
49
|
+
self.from_target = from_target
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SwiggyScraper:
|
|
53
|
+
"""Swiggy search, restaurant lists and full menus, for one delivery location.
|
|
54
|
+
|
|
55
|
+
>>> scraper = SwiggyScraper(token="...", city="mumbai")
|
|
56
|
+
>>> dishes = scraper.search_dishes("biryani")
|
|
57
|
+
>>> menu = scraper.get_menu("https://www.swiggy.com/city/bangalore/starbucks-coffee-ashok-nagar-rest124178")
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
token: Optional[str] = None,
|
|
63
|
+
lat: Optional[float] = None,
|
|
64
|
+
lng: Optional[float] = None,
|
|
65
|
+
city: Optional[str] = None,
|
|
66
|
+
timeout: float = 60,
|
|
67
|
+
retries: int = 2,
|
|
68
|
+
session: Optional[requests.Session] = None,
|
|
69
|
+
) -> None:
|
|
70
|
+
self.token = (token or os.getenv(TOKEN_ENV) or "").strip()
|
|
71
|
+
if not self.token:
|
|
72
|
+
raise ValueError(f"A Scrape.do API token is required (pass token= or set {TOKEN_ENV}).")
|
|
73
|
+
if lat is None or lng is None:
|
|
74
|
+
key = (city or DEFAULT_CITY).strip().lower()
|
|
75
|
+
if key in ("bangalore", "bengaluru"):
|
|
76
|
+
key = "bengaluru"
|
|
77
|
+
if key not in CITIES:
|
|
78
|
+
raise ValueError(f"Unknown city {city!r}. Known: {', '.join(sorted(CITIES))}. Or pass lat and lng.")
|
|
79
|
+
lat, lng = CITIES[key]
|
|
80
|
+
self.lat = float(lat)
|
|
81
|
+
self.lng = float(lng)
|
|
82
|
+
self.timeout = timeout
|
|
83
|
+
self.retries = max(0, retries)
|
|
84
|
+
self.session = session or requests.Session()
|
|
85
|
+
self.credits_used = 0
|
|
86
|
+
|
|
87
|
+
# ------------------------------------------------------------ transport
|
|
88
|
+
|
|
89
|
+
def fetch_json(self, path: str, **query: Any) -> Dict[str, Any]:
|
|
90
|
+
"""GET a Swiggy ``/dapi`` endpoint through Scrape.do and return its ``data`` object."""
|
|
91
|
+
url = f"{SWIGGY}{path}?{urlencode(query)}"
|
|
92
|
+
params = {"token": self.token, "url": url, "geoCode": "in"}
|
|
93
|
+
last: Optional[ScrapeError] = None
|
|
94
|
+
for attempt in range(self.retries + 1):
|
|
95
|
+
if attempt:
|
|
96
|
+
time.sleep(1.5 * attempt)
|
|
97
|
+
try:
|
|
98
|
+
response = self.session.get(API_URL, params=params, timeout=self.timeout)
|
|
99
|
+
except requests.RequestException as exc:
|
|
100
|
+
last = ScrapeError(f"Request failed: {str(exc).replace(self.token, '***')}")
|
|
101
|
+
continue
|
|
102
|
+
from_target = "scrape.do-initial-status-code" in {k.lower() for k in response.headers}
|
|
103
|
+
try:
|
|
104
|
+
self.credits_used += int(response.headers.get("scrape.do-request-cost") or 0)
|
|
105
|
+
except ValueError:
|
|
106
|
+
pass
|
|
107
|
+
if response.status_code == 401 and not from_target:
|
|
108
|
+
raise ScrapeError("Scrape.do rejected the token (HTTP 401).", 401, False)
|
|
109
|
+
if response.status_code == 200:
|
|
110
|
+
try:
|
|
111
|
+
body = response.json()
|
|
112
|
+
except ValueError:
|
|
113
|
+
body = None
|
|
114
|
+
if isinstance(body, dict) and body.get("statusCode") == 0 and isinstance(body.get("data"), dict):
|
|
115
|
+
return body["data"]
|
|
116
|
+
message = (body or {}).get("statusMessage") if isinstance(body, dict) else None
|
|
117
|
+
last = ScrapeError(f"Swiggy returned no data for {url}" + (f": {message}" if message else ""), 200, True)
|
|
118
|
+
if isinstance(body, dict) and body.get("statusCode") not in (None, 0):
|
|
119
|
+
break
|
|
120
|
+
continue
|
|
121
|
+
last = ScrapeError(f"HTTP {response.status_code} for {url}", response.status_code, from_target)
|
|
122
|
+
if response.status_code not in (429, 500, 502, 503, 504):
|
|
123
|
+
break
|
|
124
|
+
raise last or ScrapeError(f"Request failed: {url}")
|
|
125
|
+
|
|
126
|
+
def _search(self, query: str, tab: str) -> Dict[str, Any]:
|
|
127
|
+
if not str(query or "").strip():
|
|
128
|
+
raise ValueError("Search query is empty.")
|
|
129
|
+
return self.fetch_json(
|
|
130
|
+
"/dapi/restaurants/search/v3", lat=self.lat, lng=self.lng, str=query.strip(),
|
|
131
|
+
trackingId="undefined", submitAction="ENTER", selectedPLTab=tab,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# ------------------------------------------------------------- scraping
|
|
135
|
+
|
|
136
|
+
def search_dishes(self, query: str) -> List[Dict[str, Any]]:
|
|
137
|
+
"""Dishes matching ``query`` from restaurants that deliver here: price, veg, rating, variants,
|
|
138
|
+
restaurant. One request (about 50 restaurants, every matching dish of each)."""
|
|
139
|
+
return parse_search_dishes(self._search(query, "DISH"))
|
|
140
|
+
|
|
141
|
+
def search_restaurants(self, query: str) -> List[Dict[str, Any]]:
|
|
142
|
+
"""Restaurants matching ``query`` that deliver here. One request, ~100 restaurants."""
|
|
143
|
+
return parse_search_restaurants(self._search(query, "RESTAURANT"))
|
|
144
|
+
|
|
145
|
+
def get_restaurants(self) -> List[Dict[str, Any]]:
|
|
146
|
+
"""The restaurants on Swiggy's home page for this location (top brands and the main grid)."""
|
|
147
|
+
return parse_listing(self.fetch_json(
|
|
148
|
+
"/dapi/restaurants/list/v5", lat=self.lat, lng=self.lng,
|
|
149
|
+
**{"is-seo-homepage-enabled": "true", "page_type": "DESKTOP_WEB_LISTING"},
|
|
150
|
+
))
|
|
151
|
+
|
|
152
|
+
def get_menu(self, url_or_id: Any) -> Dict[str, Any]:
|
|
153
|
+
"""Full menu of a restaurant: ``{"restaurant": {...}, "items": [...]}``. The restaurant row includes
|
|
154
|
+
the FSSAI licence, address, and whether it delivers to this location."""
|
|
155
|
+
restaurant_id = restaurant_id_from_url(str(url_or_id))
|
|
156
|
+
if not restaurant_id:
|
|
157
|
+
raise ValueError("Expected a Swiggy restaurant URL (…-rest<ID> or /restaurants/…-<ID>) or a restaurant id.")
|
|
158
|
+
menu = parse_menu(self.fetch_json(
|
|
159
|
+
"/dapi/menu/pl", **{"page-type": "REGULAR_MENU", "complete-menu": "true"},
|
|
160
|
+
lat=self.lat, lng=self.lng, restaurantId=restaurant_id,
|
|
161
|
+
))
|
|
162
|
+
if not menu["restaurant"].get("restaurant_id"):
|
|
163
|
+
raise ScrapeError(f"No restaurant {restaurant_id} on Swiggy.", 200, True)
|
|
164
|
+
return menu
|