pypararius 2.0.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.
- pypararius-2.0.0/.gitignore +26 -0
- pypararius-2.0.0/PKG-INFO +9 -0
- pypararius-2.0.0/README.md +314 -0
- pypararius-2.0.0/examples/export_to_csv.py +125 -0
- pypararius-2.0.0/examples/new_listings_alert.py +117 -0
- pypararius-2.0.0/examples/price_tracker.py +123 -0
- pypararius-2.0.0/pyproject.toml +21 -0
- pypararius-2.0.0/seen_pararius.json +1 -0
- pypararius-2.0.0/src/pypararius/__init__.py +20 -0
- pypararius-2.0.0/src/pypararius/listing.py +138 -0
- pypararius-2.0.0/src/pypararius/pararius.py +238 -0
- pypararius-2.0.0/src/pypararius/parser.py +328 -0
- pypararius-2.0.0/uv.lock +255 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*$py.class
|
|
4
|
+
*.so
|
|
5
|
+
.Python
|
|
6
|
+
build/
|
|
7
|
+
develop-eggs/
|
|
8
|
+
dist/
|
|
9
|
+
downloads/
|
|
10
|
+
eggs/
|
|
11
|
+
.eggs/
|
|
12
|
+
lib/
|
|
13
|
+
lib64/
|
|
14
|
+
parts/
|
|
15
|
+
sdist/
|
|
16
|
+
var/
|
|
17
|
+
wheels/
|
|
18
|
+
*.egg-info/
|
|
19
|
+
.installed.cfg
|
|
20
|
+
*.egg
|
|
21
|
+
.env
|
|
22
|
+
.venv
|
|
23
|
+
env/
|
|
24
|
+
venv/
|
|
25
|
+
.uv/
|
|
26
|
+
.python-version
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pypararius
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Python API wrapper for Pararius.com real estate listings
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: httpx>=0.27.0
|
|
7
|
+
Provides-Extra: dev
|
|
8
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
9
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
# pypararius
|
|
2
|
+
|
|
3
|
+
Python API for Pararius.com - the Netherlands' largest rental property platform.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install pypararius
|
|
9
|
+
# or with uv
|
|
10
|
+
uv add pypararius
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from pypararius import Pararius
|
|
17
|
+
|
|
18
|
+
p = Pararius()
|
|
19
|
+
|
|
20
|
+
# Search for listings
|
|
21
|
+
results = p.search_listing('amsterdam', price_max=2000)
|
|
22
|
+
for r in results[:3]:
|
|
23
|
+
print(r['title'], r['price'])
|
|
24
|
+
|
|
25
|
+
# Get full listing details
|
|
26
|
+
listing = p.get_listing(results[0]['url'])
|
|
27
|
+
print(listing['title'], listing['city'])
|
|
28
|
+
print(listing.summary())
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## How It Works
|
|
32
|
+
|
|
33
|
+
This library uses Pararius.com's internal endpoints. Pararius only has a website, so we use their frontend's AJAX endpoints.
|
|
34
|
+
|
|
35
|
+
### Discovery Process
|
|
36
|
+
|
|
37
|
+
The API was discovered by analyzing network traffic from the Pararius website:
|
|
38
|
+
|
|
39
|
+
1. Observed that search pages make XHR requests that return JSON instead of HTML
|
|
40
|
+
2. Identified the `X-Requested-With: XMLHttpRequest` header as the trigger for JSON responses
|
|
41
|
+
3. Mapped URL path segments to filter parameters
|
|
42
|
+
4. Found JSON-LD structured data embedded in listing detail pages
|
|
43
|
+
|
|
44
|
+
### API Architecture
|
|
45
|
+
|
|
46
|
+
| Endpoint | Method | Purpose |
|
|
47
|
+
|----------|--------|---------|
|
|
48
|
+
| `/apartments/{city}/{filters}` | GET | Search listings (returns JSON with XHR header) |
|
|
49
|
+
| `/apartment-for-rent/{city}/{id}/{street}` | GET | Listing details (HTML with JSON-LD) |
|
|
50
|
+
| `/api/suggest` | GET | Location autocomplete |
|
|
51
|
+
|
|
52
|
+
### Search Response
|
|
53
|
+
|
|
54
|
+
When the search endpoint receives the XHR header, it returns:
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"components": {
|
|
59
|
+
"results": "<html>...</html>"
|
|
60
|
+
},
|
|
61
|
+
"search_query": {
|
|
62
|
+
"filters": {...},
|
|
63
|
+
"view_options": {...}
|
|
64
|
+
},
|
|
65
|
+
"_meta": {
|
|
66
|
+
"canonical_url": "..."
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The `components.results` contains pre-rendered HTML listing cards that are parsed to extract listing data.
|
|
72
|
+
|
|
73
|
+
### Listing Details
|
|
74
|
+
|
|
75
|
+
Detail pages return full HTML with embedded JSON-LD:
|
|
76
|
+
|
|
77
|
+
```html
|
|
78
|
+
<script type="application/ld+json">
|
|
79
|
+
{
|
|
80
|
+
"@type": "Apartment",
|
|
81
|
+
"name": "Apartment Ridderspoorweg",
|
|
82
|
+
"address": {...},
|
|
83
|
+
"geo": {"latitude": 52.xxx, "longitude": 4.xxx},
|
|
84
|
+
"floorSize": {"value": 75},
|
|
85
|
+
"offers": {"price": 1850}
|
|
86
|
+
}
|
|
87
|
+
</script>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Additional data (bedrooms, deposit, energy rating, etc.) is extracted by parsing the HTML feature tables.
|
|
91
|
+
|
|
92
|
+
### Authentication
|
|
93
|
+
|
|
94
|
+
No authentication required. All listing data is publicly accessible.
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
## API Reference
|
|
98
|
+
|
|
99
|
+
### Pararius
|
|
100
|
+
|
|
101
|
+
Main entry point for the API.
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from pypararius import Pararius
|
|
105
|
+
|
|
106
|
+
p = Pararius(timeout=30)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
#### search_listing(location, ...)
|
|
110
|
+
|
|
111
|
+
Search for rental listings with filters.
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
results = p.search_listing(
|
|
115
|
+
location='amsterdam', # City name
|
|
116
|
+
price_min=1000, # Minimum rent
|
|
117
|
+
price_max=2000, # Maximum rent
|
|
118
|
+
area_min=50, # Minimum living area (m²)
|
|
119
|
+
bedrooms=2, # Minimum bedrooms
|
|
120
|
+
interior='furnished', # 'furnished', 'upholstered', 'shell'
|
|
121
|
+
sort='newest', # Sort order (see below)
|
|
122
|
+
page=0, # Page number (0-indexed)
|
|
123
|
+
)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**Sort options:**
|
|
127
|
+
|
|
128
|
+
| Sort Value | Description |
|
|
129
|
+
|------------|-------------|
|
|
130
|
+
| `newest` | Most recently published first |
|
|
131
|
+
| `price_asc` | Lowest price first |
|
|
132
|
+
| `price_desc` | Highest price first |
|
|
133
|
+
| `area_asc` | Smallest area first |
|
|
134
|
+
| `area_desc` | Largest area first |
|
|
135
|
+
|
|
136
|
+
#### get_listing(url)
|
|
137
|
+
|
|
138
|
+
Get full details for a listing by URL.
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
# By full URL
|
|
142
|
+
listing = p.get_listing('https://www.pararius.com/apartment-for-rent/amsterdam/abc123/street')
|
|
143
|
+
|
|
144
|
+
# By partial path
|
|
145
|
+
listing = p.get_listing('amsterdam/abc123/street')
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Listing
|
|
149
|
+
|
|
150
|
+
Listing objects support dict-like access with convenient aliases.
|
|
151
|
+
|
|
152
|
+
**Basic info:**
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
listing['title'] # Property title/address
|
|
156
|
+
listing['city'] # City name
|
|
157
|
+
listing['postcode'] # Postal code
|
|
158
|
+
listing['neighbourhood'] # Neighbourhood name
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
**Price:**
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
listing['price'] # Numeric price (monthly rent)
|
|
165
|
+
listing['price_formatted'] # Formatted price string
|
|
166
|
+
listing['deposit'] # Deposit amount
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
**Property details:**
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
listing['living_area'] # Living area in m²
|
|
173
|
+
listing['rooms'] # Total number of rooms
|
|
174
|
+
listing['bedrooms'] # Number of bedrooms
|
|
175
|
+
listing['interior'] # Interior type (furnished, etc.)
|
|
176
|
+
listing['energy_label'] # Energy rating
|
|
177
|
+
listing['description'] # Full description text
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
**Availability:**
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
listing['available'] # Available date
|
|
184
|
+
listing['offered_since'] # When listed
|
|
185
|
+
listing['rental_agreement'] # Contract type
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
**Location:**
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
listing['coordinates'] # (lat, lng) tuple
|
|
192
|
+
listing['latitude'] # Latitude
|
|
193
|
+
listing['longitude'] # Longitude
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
**Media:**
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
listing['photos'] # List of photo URLs
|
|
200
|
+
listing['photo_urls'] # Same as photos
|
|
201
|
+
listing['photo_count'] # Number of photos
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
**Rules:**
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
listing['smoking_allowed'] # Boolean
|
|
208
|
+
listing['pets_allowed'] # Boolean
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
**Broker:**
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
listing['broker'] # Agent name
|
|
215
|
+
listing['broker_url'] # Agent page URL
|
|
216
|
+
listing['broker_phone'] # Agent phone
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
**Metadata:**
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
listing['url'] # Full Pararius URL
|
|
223
|
+
listing['characteristics'] # Dict of all features
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
**Key aliases** - these all work:
|
|
227
|
+
|
|
228
|
+
| Alias | Canonical Key |
|
|
229
|
+
|-------|---------------|
|
|
230
|
+
| `name`, `address`, `street` | `title` |
|
|
231
|
+
| `location`, `locality` | `city` |
|
|
232
|
+
| `area`, `size`, `area_m2` | `living_area` |
|
|
233
|
+
| `images`, `pictures`, `media` | `photos` |
|
|
234
|
+
| `agent`, `realtor`, `makelaar` | `broker` |
|
|
235
|
+
| `zip`, `zipcode`, `postal_code` | `postcode` |
|
|
236
|
+
| `energy_rating` | `energy_label` |
|
|
237
|
+
|
|
238
|
+
#### Methods
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
listing.summary() # Text summary of the listing
|
|
242
|
+
listing.to_dict() # Convert to plain dictionary
|
|
243
|
+
listing.keys() # List available keys
|
|
244
|
+
listing.get('key') # Get with default (like dict.get)
|
|
245
|
+
listing.id # Get listing ID
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
## Examples
|
|
249
|
+
|
|
250
|
+
### Find apartments in Amsterdam under €2000/month
|
|
251
|
+
|
|
252
|
+
```python
|
|
253
|
+
from pypararius import Pararius
|
|
254
|
+
|
|
255
|
+
p = Pararius()
|
|
256
|
+
results = p.search_listing('amsterdam', price_max=2000)
|
|
257
|
+
|
|
258
|
+
for listing in results:
|
|
259
|
+
print(f"{listing['title']}")
|
|
260
|
+
print(f" Price: €{listing['price']}/month")
|
|
261
|
+
print(f" Area: {listing.get('living_area', 'N/A')} m²")
|
|
262
|
+
print(f" Bedrooms: {listing.get('bedrooms', 'N/A')}")
|
|
263
|
+
print()
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### Get detailed listing information
|
|
267
|
+
|
|
268
|
+
```python
|
|
269
|
+
from pypararius import Pararius
|
|
270
|
+
|
|
271
|
+
p = Pararius()
|
|
272
|
+
results = p.search_listing('rotterdam', price_max=1500)
|
|
273
|
+
|
|
274
|
+
if results:
|
|
275
|
+
listing = p.get_listing(results[0]['url'])
|
|
276
|
+
print(listing.summary())
|
|
277
|
+
|
|
278
|
+
# Access all characteristics
|
|
279
|
+
for key, value in listing['characteristics'].items():
|
|
280
|
+
print(f"{key}: {value}")
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### Search with filters
|
|
284
|
+
|
|
285
|
+
```python
|
|
286
|
+
from pypararius import Pararius
|
|
287
|
+
|
|
288
|
+
p = Pararius()
|
|
289
|
+
results = p.search_listing(
|
|
290
|
+
'amsterdam',
|
|
291
|
+
price_min=1500,
|
|
292
|
+
price_max=2500,
|
|
293
|
+
bedrooms=2,
|
|
294
|
+
interior='furnished',
|
|
295
|
+
sort='price_asc',
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
print(f"Found {len(results)} listings")
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
### Using context manager
|
|
302
|
+
|
|
303
|
+
```python
|
|
304
|
+
from pypararius import Pararius
|
|
305
|
+
|
|
306
|
+
with Pararius() as p:
|
|
307
|
+
results = p.search_listing('utrecht', price_max=1800)
|
|
308
|
+
for r in results[:5]:
|
|
309
|
+
print(f"{r['title']}: €{r['price']}")
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
## License
|
|
313
|
+
|
|
314
|
+
AGPL-3.0
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Export Pararius search results to CSV or Excel.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
# Export to CSV
|
|
6
|
+
uv run examples/export_to_csv.py --location amsterdam --output listings.csv
|
|
7
|
+
|
|
8
|
+
# Export to Excel
|
|
9
|
+
uv run examples/export_to_csv.py --location amsterdam --output listings.xlsx
|
|
10
|
+
|
|
11
|
+
# With filters
|
|
12
|
+
uv run examples/export_to_csv.py -l amsterdam --max-price 2000 --min-area 50 -o results.csv
|
|
13
|
+
|
|
14
|
+
# Multiple pages
|
|
15
|
+
uv run examples/export_to_csv.py -l amsterdam --pages 3 -o all_listings.csv
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import csv
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from pypararius import Pararius
|
|
23
|
+
|
|
24
|
+
COLUMNS = [
|
|
25
|
+
"title",
|
|
26
|
+
"city",
|
|
27
|
+
"postcode",
|
|
28
|
+
"neighbourhood",
|
|
29
|
+
"price",
|
|
30
|
+
"living_area",
|
|
31
|
+
"bedrooms",
|
|
32
|
+
"rooms",
|
|
33
|
+
"energy_label",
|
|
34
|
+
"interior",
|
|
35
|
+
"url",
|
|
36
|
+
"latitude",
|
|
37
|
+
"longitude",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def export_csv(listings: list[dict], output: Path) -> None:
|
|
42
|
+
with output.open("w", newline="", encoding="utf-8") as f:
|
|
43
|
+
writer = csv.DictWriter(f, fieldnames=COLUMNS)
|
|
44
|
+
writer.writeheader()
|
|
45
|
+
for listing in listings:
|
|
46
|
+
row = {col: listing.get(col, "") for col in COLUMNS}
|
|
47
|
+
writer.writerow(row)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def export_excel(listings: list[dict], output: Path) -> None:
|
|
51
|
+
try:
|
|
52
|
+
import openpyxl
|
|
53
|
+
except ImportError:
|
|
54
|
+
print("Excel export requires openpyxl: uv pip install openpyxl")
|
|
55
|
+
raise SystemExit(1)
|
|
56
|
+
|
|
57
|
+
wb = openpyxl.Workbook()
|
|
58
|
+
ws = wb.active
|
|
59
|
+
ws.title = "Pararius Listings"
|
|
60
|
+
|
|
61
|
+
# Header
|
|
62
|
+
ws.append(COLUMNS)
|
|
63
|
+
|
|
64
|
+
# Data
|
|
65
|
+
for listing in listings:
|
|
66
|
+
row = [listing.get(col, "") for col in COLUMNS]
|
|
67
|
+
ws.append(row)
|
|
68
|
+
|
|
69
|
+
# Auto-width columns
|
|
70
|
+
for col in ws.columns:
|
|
71
|
+
max_len = max(len(str(cell.value or "")) for cell in col)
|
|
72
|
+
ws.column_dimensions[col[0].column_letter].width = min(max_len + 2, 50)
|
|
73
|
+
|
|
74
|
+
wb.save(output)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def main():
|
|
78
|
+
parser = argparse.ArgumentParser(description="Export Pararius listings to CSV/Excel")
|
|
79
|
+
parser.add_argument("--location", "-l", required=True, help="City")
|
|
80
|
+
parser.add_argument("--output", "-o", required=True, help="Output file (.csv or .xlsx)")
|
|
81
|
+
parser.add_argument("--max-price", type=int, help="Maximum price")
|
|
82
|
+
parser.add_argument("--min-price", type=int, help="Minimum price")
|
|
83
|
+
parser.add_argument("--min-area", type=int, help="Minimum living area (m²)")
|
|
84
|
+
parser.add_argument("--bedrooms", type=int, help="Minimum bedrooms")
|
|
85
|
+
parser.add_argument("--pages", type=int, default=1, help="Number of pages to fetch")
|
|
86
|
+
args = parser.parse_args()
|
|
87
|
+
|
|
88
|
+
output = Path(args.output)
|
|
89
|
+
if output.suffix not in (".csv", ".xlsx"):
|
|
90
|
+
print("Output must be .csv or .xlsx")
|
|
91
|
+
raise SystemExit(1)
|
|
92
|
+
|
|
93
|
+
all_listings = []
|
|
94
|
+
|
|
95
|
+
with Pararius() as p:
|
|
96
|
+
for page in range(args.pages):
|
|
97
|
+
print(f"Fetching page {page + 1}...")
|
|
98
|
+
results = p.search_listing(
|
|
99
|
+
location=args.location,
|
|
100
|
+
price_min=args.min_price,
|
|
101
|
+
price_max=args.max_price,
|
|
102
|
+
area_min=args.min_area,
|
|
103
|
+
bedrooms=args.bedrooms,
|
|
104
|
+
page=page,
|
|
105
|
+
)
|
|
106
|
+
if not results:
|
|
107
|
+
break
|
|
108
|
+
all_listings.extend([r.to_dict() for r in results])
|
|
109
|
+
|
|
110
|
+
if not all_listings:
|
|
111
|
+
print("No listings found")
|
|
112
|
+
raise SystemExit(1)
|
|
113
|
+
|
|
114
|
+
print(f"Exporting {len(all_listings)} listings...")
|
|
115
|
+
|
|
116
|
+
if output.suffix == ".csv":
|
|
117
|
+
export_csv(all_listings, output)
|
|
118
|
+
else:
|
|
119
|
+
export_excel(all_listings, output)
|
|
120
|
+
|
|
121
|
+
print(f"Saved to {output}")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
if __name__ == "__main__":
|
|
125
|
+
main()
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Alert on new Pararius listings matching your search criteria.
|
|
3
|
+
|
|
4
|
+
Monitors a search and notifies when new listings appear.
|
|
5
|
+
Can send notifications via terminal, macOS notifications, or webhook.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
uv run examples/new_listings_alert.py --location amsterdam --max-price 2000
|
|
9
|
+
|
|
10
|
+
# With desktop notifications (macOS):
|
|
11
|
+
uv run examples/new_listings_alert.py --location amsterdam --notify
|
|
12
|
+
|
|
13
|
+
# With webhook (Discord, Slack, etc.):
|
|
14
|
+
uv run examples/new_listings_alert.py --location amsterdam --webhook "https://..."
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import subprocess
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
from pypararius import Pararius
|
|
26
|
+
|
|
27
|
+
SEEN_FILE = Path("seen_listings.json")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_seen() -> set:
|
|
31
|
+
if SEEN_FILE.exists():
|
|
32
|
+
return set(json.loads(SEEN_FILE.read_text()))
|
|
33
|
+
return set()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def save_seen(seen: set) -> None:
|
|
37
|
+
SEEN_FILE.write_text(json.dumps(list(seen)))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def notify_macos(title: str, message: str) -> None:
|
|
41
|
+
"""Send macOS notification."""
|
|
42
|
+
subprocess.run([
|
|
43
|
+
"osascript", "-e",
|
|
44
|
+
f'display notification "{message}" with title "{title}"'
|
|
45
|
+
], check=False)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def notify_webhook(webhook_url: str, listings: list) -> None:
|
|
49
|
+
"""Send listings to a webhook (Discord/Slack compatible)."""
|
|
50
|
+
content = "\n".join([
|
|
51
|
+
f"**{l['title']}** ({l['city']}) - €{l['price']}/month\n{l['url']}"
|
|
52
|
+
for l in listings
|
|
53
|
+
])
|
|
54
|
+
httpx.post(webhook_url, json={"content": content}, timeout=10)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main():
|
|
58
|
+
parser = argparse.ArgumentParser(description="Alert on new Pararius listings")
|
|
59
|
+
parser.add_argument("--location", "-l", required=True, help="City")
|
|
60
|
+
parser.add_argument("--max-price", type=int, help="Maximum price")
|
|
61
|
+
parser.add_argument("--min-price", type=int, help="Minimum price")
|
|
62
|
+
parser.add_argument("--min-area", type=int, help="Minimum living area (m²)")
|
|
63
|
+
parser.add_argument("--bedrooms", type=int, help="Minimum bedrooms")
|
|
64
|
+
parser.add_argument("--notify", action="store_true", help="Send macOS notification")
|
|
65
|
+
parser.add_argument("--webhook", help="Webhook URL for notifications")
|
|
66
|
+
args = parser.parse_args()
|
|
67
|
+
|
|
68
|
+
seen = load_seen()
|
|
69
|
+
new_listings = []
|
|
70
|
+
|
|
71
|
+
with Pararius() as p:
|
|
72
|
+
results = p.search_listing(
|
|
73
|
+
location=args.location,
|
|
74
|
+
price_min=args.min_price,
|
|
75
|
+
price_max=args.max_price,
|
|
76
|
+
area_min=args.min_area,
|
|
77
|
+
bedrooms=args.bedrooms,
|
|
78
|
+
sort="newest",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
for listing in results:
|
|
82
|
+
lid = str(listing.id)
|
|
83
|
+
if lid not in seen:
|
|
84
|
+
seen.add(lid)
|
|
85
|
+
new_listings.append({
|
|
86
|
+
"title": listing["title"],
|
|
87
|
+
"city": listing["city"],
|
|
88
|
+
"price": listing["price"],
|
|
89
|
+
"url": listing["url"],
|
|
90
|
+
"area": listing.get("living_area"),
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
save_seen(seen)
|
|
94
|
+
|
|
95
|
+
if not new_listings:
|
|
96
|
+
print(f"[{datetime.now():%H:%M}] No new listings")
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
print(f"[{datetime.now():%H:%M}] Found {len(new_listings)} new listing(s):\n")
|
|
100
|
+
for l in new_listings:
|
|
101
|
+
area = f", {l['area']}m²" if l.get("area") else ""
|
|
102
|
+
print(f" {l['title']} ({l['city']}{area})")
|
|
103
|
+
print(f" €{l['price']}/month")
|
|
104
|
+
print(f" {l['url']}\n")
|
|
105
|
+
|
|
106
|
+
if args.notify:
|
|
107
|
+
notify_macos(
|
|
108
|
+
"New Pararius Listings",
|
|
109
|
+
f"{len(new_listings)} new listing(s) in {args.location}"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if args.webhook:
|
|
113
|
+
notify_webhook(args.webhook, new_listings)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
main()
|