Compare commits
13 Commits
0.04
...
63663e4b6b
| Author | SHA1 | Date | |
|---|---|---|---|
| 63663e4b6b | |||
| 8c052840cd | |||
| 39e4b9ce2a | |||
|
|
fd3991f8d6 | ||
|
|
27a7834eb6 | ||
| 57a9f6f21a | |||
| 0ea31d3013 | |||
|
|
4304a42776 | ||
| 23d208a5b7 | |||
|
|
00c9144010 | ||
|
|
44c02b45b4 | ||
|
|
5fb3b984b6 | ||
| 6f49533c94 |
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"WebFetch(domain:github.com)",
|
|
||||||
"WebFetch(domain:www.sreality.cz)",
|
|
||||||
"WebFetch(domain:webscraping.pro)",
|
|
||||||
"WebFetch(domain:raw.githubusercontent.com)",
|
|
||||||
"Bash(python3:*)",
|
|
||||||
"Bash(open:*)",
|
|
||||||
"WebFetch(domain:www.realingo.cz)",
|
|
||||||
"WebFetch(domain:api.realingo.cz)",
|
|
||||||
"Bash(curl:*)",
|
|
||||||
"Bash(grep:*)",
|
|
||||||
"WebFetch(domain:www.realitni-pes.cz)",
|
|
||||||
"WebFetch(domain:www.bezrealitky.cz)",
|
|
||||||
"WebFetch(domain:apify.com)",
|
|
||||||
"WebFetch(domain:www.bezrealitky.com)",
|
|
||||||
"WebFetch(domain:reality.idnes.cz)",
|
|
||||||
"Bash(# Final checks: robots.txt and response time for rate limiting clues curl -s -L -H \"\"User-Agent: Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36\"\" \"\"https://reality.idnes.cz/robots.txt\"\")",
|
|
||||||
"WebFetch(domain:www.cityhome.cz)",
|
|
||||||
"WebFetch(domain:www.psn.cz)",
|
|
||||||
"WebFetch(domain:www.city-home.cz)",
|
|
||||||
"WebFetch(domain:psn.cz)",
|
|
||||||
"WebFetch(domain:api.psn.cz)",
|
|
||||||
"Bash(done)",
|
|
||||||
"Bash(# Final summary: count total units across all projects\n# Get the total count from the unitsCountData we already extracted\necho \"\"From unitsCountData on /prodej page:\"\"\necho \"\" type_id 0 \\(Prodej bytů a ateliérů\\): 146\"\"\necho \"\" type_id 1 \\(Prodej komerčních nemovitostí\\): 14\"\"\necho \"\" type_id 2 \\(Pronájem bytů\\): 3\"\"\necho \"\" type_id 3 \\(Pronájem komerčních nemovitostí\\): 48\"\"\necho \"\"\"\"\necho \"\"Total for-sale projects: 19\"\"\necho \"\"\"\"\necho \"\"Disposition counts from the data:\"\"\npython3 << 'PYEOF'\n# Extract disposition counts from prodej page\nimport re\n\nwith open\\('/tmp/psn_prodej_p1.html', 'r', encoding='utf-8'\\) as f:\n html = f.read\\(\\)\n\n# Find disposition data\nidx = html.find\\('\\\\\\\\\"disposition\\\\\\\\\":['\\)\nif idx >= 0:\n chunk = html[idx:idx+2000].replace\\('\\\\\\\\\"', '\"'\\)\n # Extract name and count pairs\n import re\n pairs = re.findall\\(r'\"name\":\"\\([^\"]+\\)\",\"count\":\\(\\\\d+\\)', chunk\\)\n for name, count in pairs:\n print\\(f\" {name}: {count}\"\\)\nPYEOF)",
|
|
||||||
"Bash(ls:*)",
|
|
||||||
"Bash(chmod:*)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,3 +1,8 @@
|
|||||||
.vscode/
|
.vscode/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
.DS_Store
|
||||||
byty_*.json
|
byty_*.json
|
||||||
|
*.json
|
||||||
|
*.log
|
||||||
|
mapa_bytu.html
|
||||||
|
|
||||||
|
|||||||
38
Makefile
38
Makefile
@@ -3,9 +3,13 @@ CONTAINER_NAME := maru-hleda-byt
|
|||||||
VOLUME_NAME := maru-hleda-byt-data
|
VOLUME_NAME := maru-hleda-byt-data
|
||||||
VALIDATION_CONTAINER := maru-hleda-byt-validation
|
VALIDATION_CONTAINER := maru-hleda-byt-validation
|
||||||
VALIDATION_VOLUME := maru-hleda-byt-validation-data
|
VALIDATION_VOLUME := maru-hleda-byt-validation-data
|
||||||
|
DEBUG_CONTAINER := maru-hleda-byt-debug
|
||||||
|
DEBUG_VOLUME := maru-hleda-byt-debug-data
|
||||||
|
DEBUG_PORT ?= 8082
|
||||||
PORT := 8080
|
PORT := 8080
|
||||||
|
SERVER_PORT ?= 8080
|
||||||
|
|
||||||
.PHONY: build run stop logs scrape restart clean help validation validation-local validation-stop validation-local-debug serve serve-debug
|
.PHONY: build run stop logs scrape restart clean help serve validation validation-local validation-stop validation-local-debug debug debug-stop
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "Available targets:"
|
@echo "Available targets:"
|
||||||
@@ -20,8 +24,9 @@ help:
|
|||||||
@echo " validation-local-debug - Run validation locally with DEBUG logging"
|
@echo " validation-local-debug - Run validation locally with DEBUG logging"
|
||||||
@echo " restart - Restart the container (stop and run again)"
|
@echo " restart - Restart the container (stop and run again)"
|
||||||
@echo " clean - Stop container and remove the Docker image"
|
@echo " clean - Stop container and remove the Docker image"
|
||||||
@echo " serve - Run server.py locally (DATA_DIR=., port $(PORT))"
|
@echo " serve - Start server.py locally on port 8080"
|
||||||
@echo " serve-debug - Run server.py locally with DEBUG logging"
|
@echo " debug - Build and run debug Docker container with limited scrape (port $(DEBUG_PORT))"
|
||||||
|
@echo " debug-stop - Stop and remove the debug Docker container"
|
||||||
@echo " help - Show this help message"
|
@echo " help - Show this help message"
|
||||||
|
|
||||||
build:
|
build:
|
||||||
@@ -61,18 +66,33 @@ validation-stop:
|
|||||||
@docker rm $(VALIDATION_CONTAINER) 2>/dev/null || true
|
@docker rm $(VALIDATION_CONTAINER) 2>/dev/null || true
|
||||||
@echo "Validation container stopped and removed"
|
@echo "Validation container stopped and removed"
|
||||||
|
|
||||||
|
debug: build
|
||||||
|
@docker stop $(DEBUG_CONTAINER) 2>/dev/null || true
|
||||||
|
@docker rm $(DEBUG_CONTAINER) 2>/dev/null || true
|
||||||
|
docker run -d --name $(DEBUG_CONTAINER) \
|
||||||
|
-p $(DEBUG_PORT):8080 \
|
||||||
|
-v $(DEBUG_VOLUME):/app/data \
|
||||||
|
-e LOG_LEVEL=DEBUG \
|
||||||
|
$(IMAGE_NAME)
|
||||||
|
@sleep 2
|
||||||
|
docker exec $(DEBUG_CONTAINER) bash /app/run_all.sh --max-pages 1 --max-properties 10
|
||||||
|
@echo "Debug app at http://localhost:$(DEBUG_PORT)/mapa_bytu.html"
|
||||||
|
@echo "Debug status at http://localhost:$(DEBUG_PORT)/scrapers-status"
|
||||||
|
|
||||||
|
debug-stop:
|
||||||
|
@docker stop $(DEBUG_CONTAINER) 2>/dev/null || true
|
||||||
|
@docker rm $(DEBUG_CONTAINER) 2>/dev/null || true
|
||||||
|
@echo "Debug container stopped and removed"
|
||||||
|
|
||||||
|
serve:
|
||||||
|
DATA_DIR=. SERVER_PORT=$(SERVER_PORT) python3 server.py
|
||||||
|
|
||||||
validation-local:
|
validation-local:
|
||||||
./run_all.sh --max-pages 1 --max-properties 10
|
./run_all.sh --max-pages 1 --max-properties 10
|
||||||
|
|
||||||
validation-local-debug:
|
validation-local-debug:
|
||||||
./run_all.sh --max-pages 1 --max-properties 10 --log-level DEBUG
|
./run_all.sh --max-pages 1 --max-properties 10 --log-level DEBUG
|
||||||
|
|
||||||
serve:
|
|
||||||
DATA_DIR=. PORT=$(PORT) python server.py
|
|
||||||
|
|
||||||
serve-debug:
|
|
||||||
DATA_DIR=. PORT=$(PORT) python server.py --verbose
|
|
||||||
|
|
||||||
restart: stop run
|
restart: stop run
|
||||||
|
|
||||||
clean: stop
|
clean: stop
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -83,10 +83,6 @@ Merges all `byty_*.json` files into `byty_merged.json` and generates `mapa_bytu.
|
|||||||
|
|
||||||
**Deduplication logic:** Two listings are considered duplicates if they share the same normalized street name + price + area. PSN and CityHome have priority during dedup (loaded first), so their listings are kept over duplicates from other portals.
|
**Deduplication logic:** Two listings are considered duplicates if they share the same normalized street name + price + area. PSN and CityHome have priority during dedup (loaded first), so their listings are kept over duplicates from other portals.
|
||||||
|
|
||||||
### `regen_map.py`
|
|
||||||
|
|
||||||
Regenerates the map from existing `byty_sreality.json` data without re-scraping. Fetches missing area values from the Sreality API, fixes URLs, and re-applies the area filter. Useful for tweaking map output after data has already been collected.
|
|
||||||
|
|
||||||
## Interactive map (`mapa_bytu.html`)
|
## Interactive map (`mapa_bytu.html`)
|
||||||
|
|
||||||
The generated map is a standalone HTML file using Leaflet.js with CARTO basemap tiles. Features:
|
The generated map is a standalone HTML file using Leaflet.js with CARTO basemap tiles. Features:
|
||||||
@@ -151,7 +147,7 @@ The project includes a Docker setup for unattended operation with a cron-based s
|
|||||||
│ PID 1: python3 -m http.server :8080 │
|
│ PID 1: python3 -m http.server :8080 │
|
||||||
│ serves /app/data/ │
|
│ serves /app/data/ │
|
||||||
│ │
|
│ │
|
||||||
│ crond: runs run_all.sh at 06:00/18:00 │
|
│ crond: runs run_all.sh every 4 hours │
|
||||||
│ Europe/Prague timezone │
|
│ Europe/Prague timezone │
|
||||||
│ │
|
│ │
|
||||||
│ /app/ -- scripts (.py, .sh) │
|
│ /app/ -- scripts (.py, .sh) │
|
||||||
@@ -160,7 +156,7 @@ The project includes a Docker setup for unattended operation with a cron-based s
|
|||||||
└─────────────────────────────────────────┘
|
└─────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
On startup, the HTTP server starts immediately. The initial scrape runs in the background. Subsequent cron runs update data in-place twice daily at 06:00 and 18:00 CET/CEST.
|
On startup, the HTTP server starts immediately. The initial scrape runs in the background. Subsequent cron runs update data in-place every 4 hours.
|
||||||
|
|
||||||
### Quick start
|
### Quick start
|
||||||
|
|
||||||
@@ -201,14 +197,13 @@ Validation targets run scrapers with `--max-pages 1 --max-properties 10` for a f
|
|||||||
├── scrape_psn.py # PSN scraper
|
├── scrape_psn.py # PSN scraper
|
||||||
├── scrape_cityhome.py # CityHome scraper
|
├── scrape_cityhome.py # CityHome scraper
|
||||||
├── merge_and_map.py # Merge all sources + generate final map
|
├── merge_and_map.py # Merge all sources + generate final map
|
||||||
├── regen_map.py # Regenerate map from cached Sreality data
|
|
||||||
├── run_all.sh # Orchestrator script (runs all scrapers + merge)
|
├── run_all.sh # Orchestrator script (runs all scrapers + merge)
|
||||||
├── mapa_bytu.html # Generated interactive map (output)
|
├── mapa_bytu.html # Generated interactive map (output)
|
||||||
├── Makefile # Docker management + validation shortcuts
|
├── Makefile # Docker management + validation shortcuts
|
||||||
├── build/
|
├── build/
|
||||||
│ ├── Dockerfile # Container image definition (python:3.13-alpine)
|
│ ├── Dockerfile # Container image definition (python:3.13-alpine)
|
||||||
│ ├── entrypoint.sh # Container entrypoint (HTTP server + cron + initial scrape)
|
│ ├── entrypoint.sh # Container entrypoint (HTTP server + cron + initial scrape)
|
||||||
│ ├── crontab # Cron schedule (06:00 and 18:00 CET)
|
│ ├── crontab # Cron schedule (every 4 hours)
|
||||||
│ └── CONTAINER.md # Container-specific documentation
|
│ └── CONTAINER.md # Container-specific documentation
|
||||||
└── .gitignore # Ignores byty_*.json, __pycache__, .vscode
|
└── .gitignore # Ignores byty_*.json, __pycache__, .vscode
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ RUN apk add --no-cache curl bash tzdata \
|
|||||||
&& echo "Europe/Prague" > /etc/timezone
|
&& echo "Europe/Prague" > /etc/timezone
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV DATA_DIR=/app/data
|
||||||
RUN pip install --no-cache-dir flask
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY scrape_and_map.py scrape_realingo.py scrape_bezrealitky.py \
|
COPY scrape_and_map.py scrape_realingo.py scrape_bezrealitky.py \
|
||||||
scrape_idnes.py scrape_psn.py scrape_cityhome.py \
|
scrape_idnes.py scrape_psn.py scrape_cityhome.py \
|
||||||
merge_and_map.py regen_map.py run_all.sh server.py ./
|
merge_and_map.py generate_status.py scraper_stats.py \
|
||||||
|
run_all.sh server.py ./
|
||||||
|
|
||||||
COPY build/crontab /etc/crontabs/root
|
COPY build/crontab /etc/crontabs/root
|
||||||
COPY build/entrypoint.sh /entrypoint.sh
|
COPY build/entrypoint.sh /entrypoint.sh
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0 6,18 * * * cd /app && bash /app/run_all.sh >> /proc/1/fd/1 2>> /proc/1/fd/2
|
0 */4 * * * cd /app && bash /app/run_all.sh >> /proc/1/fd/1 2>> /proc/1/fd/2
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
DATA_DIR="/app/data"
|
export DATA_DIR="/app/data"
|
||||||
|
|
||||||
# Create symlinks so scripts (which write to /app/) persist data to the volume
|
# Create symlinks so scripts (which write to /app/) persist data to the volume
|
||||||
for f in byty_sreality.json byty_realingo.json byty_bezrealitky.json \
|
for f in byty_sreality.json byty_realingo.json byty_bezrealitky.json \
|
||||||
@@ -18,6 +18,5 @@ crond -b -l 2
|
|||||||
echo "[entrypoint] Starting initial scrape in background..."
|
echo "[entrypoint] Starting initial scrape in background..."
|
||||||
bash /app/run_all.sh &
|
bash /app/run_all.sh &
|
||||||
|
|
||||||
echo "[entrypoint] Starting combined server on port 8080..."
|
echo "[entrypoint] Starting server on port 8080..."
|
||||||
export DATA_DIR
|
|
||||||
exec python3 /app/server.py
|
exec python3 /app/server.py
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"hash_id": 981278,
|
|
||||||
"name": "Prodej bytu 3+kk 70 m²",
|
|
||||||
"price": 11890000,
|
|
||||||
"price_formatted": "11 890 000 Kč",
|
|
||||||
"locality": "Argentinská, Praha - Holešovice",
|
|
||||||
"lat": 50.1026043,
|
|
||||||
"lon": 14.4435365,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 3,
|
|
||||||
"area": 70,
|
|
||||||
"building_type": "Cihlová",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.bezrealitky.cz/nemovitosti-byty-domy/981278-nabidka-prodej-bytu-argentinska-praha",
|
|
||||||
"source": "bezrealitky",
|
|
||||||
"image": "",
|
|
||||||
"first_seen": "2026-02-15",
|
|
||||||
"last_updated": "2026-02-15"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 991217,
|
|
||||||
"name": "Prodej bytu 3+kk 71 m²",
|
|
||||||
"price": 11490000,
|
|
||||||
"price_formatted": "11 490 000 Kč",
|
|
||||||
"locality": "Kolbenova, Praha - Vysočany",
|
|
||||||
"lat": 50.1113213,
|
|
||||||
"lon": 14.5106858,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 3,
|
|
||||||
"area": 71,
|
|
||||||
"building_type": "Cihlová",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.bezrealitky.cz/nemovitosti-byty-domy/991217-nabidka-prodej-bytu-kolbenova-praha",
|
|
||||||
"source": "bezrealitky",
|
|
||||||
"image": "",
|
|
||||||
"last_updated": "2026-02-15",
|
|
||||||
"first_seen": "2026-02-15"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"hash_id": "cityhome_na-vaclavce-34_Byt A2.3",
|
|
||||||
"name": "Prodej bytu 3+1, 99 m² — Na Václavce 34",
|
|
||||||
"price": 13490000,
|
|
||||||
"price_formatted": "13 490 000 Kč",
|
|
||||||
"locality": "Na Václavce 34, Praha 5",
|
|
||||||
"lat": 50.0652858,
|
|
||||||
"lon": 14.3931318,
|
|
||||||
"disposition": "3+1",
|
|
||||||
"floor": 2,
|
|
||||||
"area": 99.1,
|
|
||||||
"building_type": "Cihlová",
|
|
||||||
"ownership": "neuvedeno",
|
|
||||||
"url": "https://www.city-home.cz/projekty/na-vaclavce-34/nabidka-nemovitosti/byt-a23",
|
|
||||||
"source": "cityhome",
|
|
||||||
"image": "",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": "cityhome_na-vaclavce-34_Byt A3.2",
|
|
||||||
"name": "Prodej bytu 3+1, 95 m² — Na Václavce 34",
|
|
||||||
"price": 13490000,
|
|
||||||
"price_formatted": "13 490 000 Kč",
|
|
||||||
"locality": "Na Václavce 34, Praha 5",
|
|
||||||
"lat": 50.0652858,
|
|
||||||
"lon": 14.3931318,
|
|
||||||
"disposition": "3+1",
|
|
||||||
"floor": 3,
|
|
||||||
"area": 95.6,
|
|
||||||
"building_type": "Cihlová",
|
|
||||||
"ownership": "neuvedeno",
|
|
||||||
"url": "https://www.city-home.cz/projekty/na-vaclavce-34/nabidka-nemovitosti/byt-a32",
|
|
||||||
"source": "cityhome",
|
|
||||||
"image": "",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
290
byty_idnes.json
290
byty_idnes.json
@@ -1,290 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"hash_id": "699ed0af74468ff4c2079aa1",
|
|
||||||
"name": "Prodej bytu 3+1 86 m²",
|
|
||||||
"price": 4600000,
|
|
||||||
"price_formatted": "4 600 000 Kč",
|
|
||||||
"locality": "Hynka Puce, Praha 5 - Stodůlky",
|
|
||||||
"lat": 50.049168412058556,
|
|
||||||
"lon": 14.302095927878957,
|
|
||||||
"disposition": "3+1",
|
|
||||||
"floor": 8,
|
|
||||||
"area": 86,
|
|
||||||
"building_type": "Cihlová",
|
|
||||||
"ownership": "družstevní",
|
|
||||||
"url": "https://reality.idnes.cz/detail/prodej/byt/praha-13-hynka-puce/699ed0af74468ff4c2079aa1/",
|
|
||||||
"source": "idnes",
|
|
||||||
"image": "",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": "699ecf47513757ba150e0c74",
|
|
||||||
"name": "Prodej bytu 3+kk 83 m²",
|
|
||||||
"price": 11390000,
|
|
||||||
"price_formatted": "11 390 000 Kč",
|
|
||||||
"locality": "Kytlická, Praha 9 - Prosek",
|
|
||||||
"lat": 50.1251431182,
|
|
||||||
"lon": 14.5077027612,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 8,
|
|
||||||
"area": 83,
|
|
||||||
"building_type": "2011",
|
|
||||||
"ownership": "osobní",
|
|
||||||
"url": "https://reality.idnes.cz/detail/prodej/byt/praha-9-kytlicka/699ecf47513757ba150e0c74/",
|
|
||||||
"source": "idnes",
|
|
||||||
"image": "",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": "699c09d25d571b3c7b026d3e",
|
|
||||||
"name": "Prodej bytu 3+kk 93 m²",
|
|
||||||
"price": 11890000,
|
|
||||||
"price_formatted": "11 890 000 Kč",
|
|
||||||
"locality": "Kříženeckého náměstí, Praha 5 - Hlubočepy",
|
|
||||||
"lat": 50.03137852,
|
|
||||||
"lon": 14.39175816,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 3,
|
|
||||||
"area": 93,
|
|
||||||
"building_type": "Cihlová",
|
|
||||||
"ownership": "osobní",
|
|
||||||
"url": "https://reality.idnes.cz/detail/prodej/byt/praha-5-krizeneckeho-namesti/699c09d25d571b3c7b026d3e/",
|
|
||||||
"source": "idnes",
|
|
||||||
"image": "",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": "693690e98418631b48025208",
|
|
||||||
"name": "Prodej bytu 3+1 87 m²",
|
|
||||||
"price": 11323000,
|
|
||||||
"price_formatted": "11 323 000 Kč",
|
|
||||||
"locality": "Libušská, Praha 4 - Libuš",
|
|
||||||
"lat": 50.009743674736,
|
|
||||||
"lon": 14.460835345662,
|
|
||||||
"disposition": "3+1",
|
|
||||||
"floor": 2,
|
|
||||||
"area": 87,
|
|
||||||
"building_type": "Cihlová",
|
|
||||||
"ownership": "družstevní",
|
|
||||||
"url": "https://reality.idnes.cz/detail/prodej/byt/praha-12-libusska/693690e98418631b48025208/",
|
|
||||||
"source": "idnes",
|
|
||||||
"image": "",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": "699487a84abe8029bd065570",
|
|
||||||
"name": "Prodej bytu 3+1 96 m²",
|
|
||||||
"price": 13490000,
|
|
||||||
"price_formatted": "13 490 000 Kč",
|
|
||||||
"locality": "Na Václavce, Praha 5 - Smíchov",
|
|
||||||
"lat": 50.0652882346,
|
|
||||||
"lon": 14.3931192571,
|
|
||||||
"disposition": "3+1",
|
|
||||||
"floor": 4,
|
|
||||||
"area": 96,
|
|
||||||
"building_type": "Cihlová",
|
|
||||||
"ownership": "osobní",
|
|
||||||
"url": "https://reality.idnes.cz/detail/prodej/byt/praha-5-na-vaclavce/699487a84abe8029bd065570/",
|
|
||||||
"source": "idnes",
|
|
||||||
"image": "",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": "697c7e54d08e16f19902d777",
|
|
||||||
"name": "Prodej bytu 3+kk 76 m²",
|
|
||||||
"price": 11590040,
|
|
||||||
"price_formatted": "11 590 040 Kč",
|
|
||||||
"locality": "Žilinská, Praha 4 - Záběhlice",
|
|
||||||
"lat": 50.04710645755815,
|
|
||||||
"lon": 14.473057214055794,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 5,
|
|
||||||
"area": 76,
|
|
||||||
"building_type": "Cihlová",
|
|
||||||
"ownership": "osobní",
|
|
||||||
"url": "https://reality.idnes.cz/detail/prodej/byt/praha-4-zilinska/697c7e54d08e16f19902d777/",
|
|
||||||
"source": "idnes",
|
|
||||||
"image": "",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": "68f5f1e873fec2e50c0cc20e",
|
|
||||||
"name": "Prodej bytu 3+kk 85 m²",
|
|
||||||
"price": 13499900,
|
|
||||||
"price_formatted": "13 499 900 Kč",
|
|
||||||
"locality": "Hořejší nábřeží, Praha 5 - Smíchov",
|
|
||||||
"lat": 50.0724036111,
|
|
||||||
"lon": 14.4103030556,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 3,
|
|
||||||
"area": 85,
|
|
||||||
"building_type": "Cihlová",
|
|
||||||
"ownership": "osobní",
|
|
||||||
"url": "https://reality.idnes.cz/detail/prodej/byt/praha-5-horejsi-nabrezi/68f5f1e873fec2e50c0cc20e/",
|
|
||||||
"source": "idnes",
|
|
||||||
"image": "",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": "6941cf632ff10124be08ce19",
|
|
||||||
"name": "Prodej bytu 4+kk 94 m²",
|
|
||||||
"price": 13249900,
|
|
||||||
"price_formatted": "13 249 900 Kč",
|
|
||||||
"locality": "V dolině, Praha 10 - Michle",
|
|
||||||
"lat": 50.0579944444,
|
|
||||||
"lon": 14.4682905556,
|
|
||||||
"disposition": "4+kk",
|
|
||||||
"floor": 14,
|
|
||||||
"area": 94,
|
|
||||||
"building_type": "Cihlová",
|
|
||||||
"ownership": "osobní",
|
|
||||||
"url": "https://reality.idnes.cz/detail/prodej/byt/praha-10-v-doline/6941cf632ff10124be08ce19/",
|
|
||||||
"source": "idnes",
|
|
||||||
"image": "",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": "690c2cba1c264f9f43027912",
|
|
||||||
"name": "Prodej bytu 3+kk 74 m²",
|
|
||||||
"price": 10631123,
|
|
||||||
"price_formatted": "10 631 123 Kč",
|
|
||||||
"locality": "Voskovcova, Praha 5 - Hlubočepy",
|
|
||||||
"lat": 50.0290438889,
|
|
||||||
"lon": 14.3641566667,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 6,
|
|
||||||
"area": 74,
|
|
||||||
"building_type": "Cihlová",
|
|
||||||
"ownership": "osobní",
|
|
||||||
"url": "https://reality.idnes.cz/detail/prodej/byt/praha-5-voskovcova/690c2cba1c264f9f43027912/",
|
|
||||||
"source": "idnes",
|
|
||||||
"image": "",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": "68404b3d8178bbed020f1742",
|
|
||||||
"name": "Prodej bytu 3+kk 71 m²",
|
|
||||||
"price": 10990000,
|
|
||||||
"price_formatted": "10 990 000 Kč",
|
|
||||||
"locality": "Praha 10 - Uhříněves",
|
|
||||||
"lat": 50.026899,
|
|
||||||
"lon": 14.613713,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 5,
|
|
||||||
"area": 71,
|
|
||||||
"building_type": "Skeletová",
|
|
||||||
"ownership": "osobní",
|
|
||||||
"url": "https://reality.idnes.cz/detail/prodej/byt/praha-22/68404b3d8178bbed020f1742/",
|
|
||||||
"source": "idnes",
|
|
||||||
"image": "",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": "6932bf9dc9442dc194054416",
|
|
||||||
"name": "Prodej bytu 3+kk 71 m²",
|
|
||||||
"price": 8100000,
|
|
||||||
"price_formatted": "8 100 000 Kč",
|
|
||||||
"locality": "Štětínská, Praha 8 - Bohnice, okres Praha",
|
|
||||||
"lat": 50.1297302,
|
|
||||||
"lon": 14.4286652,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 5,
|
|
||||||
"area": 71,
|
|
||||||
"building_type": "1974",
|
|
||||||
"ownership": "osobní",
|
|
||||||
"url": "https://reality.idnes.cz/detail/prodej/byt/praha-8-stetinska/6932bf9dc9442dc194054416/",
|
|
||||||
"source": "idnes",
|
|
||||||
"image": "",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": "699eacc0a56ec9b4a80069b6",
|
|
||||||
"name": "Prodej bytu 3+kk 81 m²",
|
|
||||||
"price": 13000000,
|
|
||||||
"price_formatted": "13 000 000 Kč",
|
|
||||||
"locality": "Hlučkova, Praha 9 - Letňany",
|
|
||||||
"lat": 50.141739,
|
|
||||||
"lon": 14.522086,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 17,
|
|
||||||
"area": 81,
|
|
||||||
"building_type": "Smíšená",
|
|
||||||
"ownership": "osobní",
|
|
||||||
"url": "https://reality.idnes.cz/detail/prodej/byt/praha-18-hluckova/699eacc0a56ec9b4a80069b6/",
|
|
||||||
"source": "idnes",
|
|
||||||
"image": "",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": "682b20ae5fcffc3dc8072856",
|
|
||||||
"name": "Prodej bytu 3+kk 78 m²",
|
|
||||||
"price": 12463000,
|
|
||||||
"price_formatted": "12 463 000 Kč",
|
|
||||||
"locality": "Kubelíkova, Praha 3 - Žižkov",
|
|
||||||
"lat": 50.0823325029164,
|
|
||||||
"lon": 14.451052236466976,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 5,
|
|
||||||
"area": 78,
|
|
||||||
"building_type": "Cihlová",
|
|
||||||
"ownership": "osobní",
|
|
||||||
"url": "https://reality.idnes.cz/detail/prodej/byt/praha-3-kubelikova/682b20ae5fcffc3dc8072856/",
|
|
||||||
"source": "idnes",
|
|
||||||
"image": "",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": "68f0b7b4263df471cb050df9",
|
|
||||||
"name": "Prodej bytu 4+kk 75 m²",
|
|
||||||
"price": 10363000,
|
|
||||||
"price_formatted": "10 363 000 Kč",
|
|
||||||
"locality": "Karla Guta, Praha 10 - Uhříněves",
|
|
||||||
"lat": 50.030382258,
|
|
||||||
"lon": 14.5931238354,
|
|
||||||
"disposition": "4+kk",
|
|
||||||
"floor": 4,
|
|
||||||
"area": 75,
|
|
||||||
"building_type": "Cihlová",
|
|
||||||
"ownership": "osobní",
|
|
||||||
"url": "https://reality.idnes.cz/detail/prodej/byt/praha-22-karla-guta/68f0b7b4263df471cb050df9/",
|
|
||||||
"source": "idnes",
|
|
||||||
"image": "",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": "695cdf5113e97880200d9e62",
|
|
||||||
"name": "Prodej bytu 3+kk 82 m²",
|
|
||||||
"price": 11133000,
|
|
||||||
"price_formatted": "11 133 000 Kč",
|
|
||||||
"locality": "K Vinoři, Praha 9 - Kbely",
|
|
||||||
"lat": 50.132835725,
|
|
||||||
"lon": 14.5613326001,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 3,
|
|
||||||
"area": 82,
|
|
||||||
"building_type": "2026",
|
|
||||||
"ownership": "osobní",
|
|
||||||
"url": "https://reality.idnes.cz/detail/prodej/byt/praha-19-k-vinori/695cdf5113e97880200d9e62/",
|
|
||||||
"source": "idnes",
|
|
||||||
"image": "",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": "69930de7098209b20e066a6c",
|
|
||||||
"name": "Prodej bytu 3+kk 91 m²",
|
|
||||||
"price": 11000000,
|
|
||||||
"price_formatted": "11 000 000 Kč",
|
|
||||||
"locality": "Formanská, Praha 4 - Újezd u Průhonic, okres Praha",
|
|
||||||
"lat": 50.0114383,
|
|
||||||
"lon": 14.5469,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 3,
|
|
||||||
"area": 91,
|
|
||||||
"building_type": "2017",
|
|
||||||
"ownership": "osobní",
|
|
||||||
"url": "https://reality.idnes.cz/detail/prodej/byt/praha-11-formanska/69930de7098209b20e066a6c/",
|
|
||||||
"source": "idnes",
|
|
||||||
"image": "",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
1014
byty_merged.json
1014
byty_merged.json
File diff suppressed because it is too large
Load Diff
@@ -1,20 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"hash_id": "8941",
|
|
||||||
"name": "Prodej bytu 3+kk, 102 m² — JITRO",
|
|
||||||
"price": 13994000,
|
|
||||||
"price_formatted": "13 994 000 Kč",
|
|
||||||
"locality": "Litevská 1174/8, Praha 10",
|
|
||||||
"lat": 50.0729,
|
|
||||||
"lon": 14.4767,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 2,
|
|
||||||
"area": 102.7,
|
|
||||||
"building_type": "neuvedeno",
|
|
||||||
"ownership": "osobní",
|
|
||||||
"url": "https://psn.cz/prodej/ubytovaci-jednotka-3-kk-litevska-praha-10-vrsovice-lit4219",
|
|
||||||
"source": "psn",
|
|
||||||
"image": "",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"hash_id": 24515963,
|
|
||||||
"name": "Prodej bytu 3+kk 83 m²",
|
|
||||||
"price": 11390000,
|
|
||||||
"price_formatted": "11 390 000 Kč",
|
|
||||||
"locality": "Kytlická, Praha",
|
|
||||||
"lat": 50.1251431182,
|
|
||||||
"lon": 14.5077027612,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 4,
|
|
||||||
"area": 83,
|
|
||||||
"building_type": "WIREFRAME",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.realingo.cz/prodej/byt-3+kk-kytlicka-praha/24515963",
|
|
||||||
"source": "realingo",
|
|
||||||
"image": "",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 24515884,
|
|
||||||
"name": "Prodej bytu 3+kk 81 m²",
|
|
||||||
"price": 13000000,
|
|
||||||
"price_formatted": "13 000 000 Kč",
|
|
||||||
"locality": "Hlučkova 869, Praha",
|
|
||||||
"lat": 50.142303781599,
|
|
||||||
"lon": 14.522362316941,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 5,
|
|
||||||
"area": 81,
|
|
||||||
"building_type": "OTHER",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.realingo.cz/prodej/byt-3+kk-hluckova-869-praha/24515884",
|
|
||||||
"source": "realingo",
|
|
||||||
"image": "",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 24515669,
|
|
||||||
"name": "Prodej bytu Atypický None m²",
|
|
||||||
"price": 8487297,
|
|
||||||
"price_formatted": "8 487 297 Kč",
|
|
||||||
"locality": "Praha, 190 00",
|
|
||||||
"lat": 50.106598,
|
|
||||||
"lon": 14.506245,
|
|
||||||
"disposition": "Atypický",
|
|
||||||
"floor": null,
|
|
||||||
"area": null,
|
|
||||||
"building_type": "neuvedeno",
|
|
||||||
"ownership": "neuvedeno",
|
|
||||||
"url": "https://www.realingo.cz/prodej/byt-ostatni-byty-praha-190-00/24515669",
|
|
||||||
"source": "realingo",
|
|
||||||
"image": "",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 24515653,
|
|
||||||
"name": "Prodej bytu Atypický None m²",
|
|
||||||
"price": 8890000,
|
|
||||||
"price_formatted": "8 890 000 Kč",
|
|
||||||
"locality": "Praha, 130 00",
|
|
||||||
"lat": 50.087602,
|
|
||||||
"lon": 14.470882,
|
|
||||||
"disposition": "Atypický",
|
|
||||||
"floor": null,
|
|
||||||
"area": null,
|
|
||||||
"building_type": "neuvedeno",
|
|
||||||
"ownership": "neuvedeno",
|
|
||||||
"url": "https://www.realingo.cz/prodej/byt-ostatni-byty-praha-130-00/24515653",
|
|
||||||
"source": "realingo",
|
|
||||||
"image": "",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 24515514,
|
|
||||||
"name": "Prodej bytu Atypický None m²",
|
|
||||||
"price": 7490000,
|
|
||||||
"price_formatted": "7 490 000 Kč",
|
|
||||||
"locality": "Praha, 141 00",
|
|
||||||
"lat": 50.045786,
|
|
||||||
"lon": 14.470711,
|
|
||||||
"disposition": "Atypický",
|
|
||||||
"floor": null,
|
|
||||||
"area": null,
|
|
||||||
"building_type": "neuvedeno",
|
|
||||||
"ownership": "neuvedeno",
|
|
||||||
"url": "https://www.realingo.cz/prodej/byt-ostatni-byty-praha-141-00/24515514",
|
|
||||||
"source": "realingo",
|
|
||||||
"image": "",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 24514922,
|
|
||||||
"name": "Prodej bytu Atypický None m²",
|
|
||||||
"price": 12132000,
|
|
||||||
"price_formatted": "12 132 000 Kč",
|
|
||||||
"locality": "Praha, 120 00",
|
|
||||||
"lat": 50.076449,
|
|
||||||
"lon": 14.435263,
|
|
||||||
"disposition": "Atypický",
|
|
||||||
"floor": null,
|
|
||||||
"area": null,
|
|
||||||
"building_type": "neuvedeno",
|
|
||||||
"ownership": "neuvedeno",
|
|
||||||
"url": "https://www.realingo.cz/prodej/byt-2+kk-slezska-praha/24514922",
|
|
||||||
"source": "realingo",
|
|
||||||
"image": "",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 24514813,
|
|
||||||
"name": "Prodej bytu Atypický None m²",
|
|
||||||
"price": 8490000,
|
|
||||||
"price_formatted": "8 490 000 Kč",
|
|
||||||
"locality": "Praha, 100 00",
|
|
||||||
"lat": 50.074273,
|
|
||||||
"lon": 14.493284,
|
|
||||||
"disposition": "Atypický",
|
|
||||||
"floor": null,
|
|
||||||
"area": null,
|
|
||||||
"building_type": "neuvedeno",
|
|
||||||
"ownership": "neuvedeno",
|
|
||||||
"url": "https://www.realingo.cz/prodej/byt-ostatni-byty-praha-100-00/24514813",
|
|
||||||
"source": "realingo",
|
|
||||||
"image": "",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 24514769,
|
|
||||||
"name": "Prodej bytu Atypický None m²",
|
|
||||||
"price": 6980000,
|
|
||||||
"price_formatted": "6 980 000 Kč",
|
|
||||||
"locality": "Praha, 154 00",
|
|
||||||
"lat": 50.010056,
|
|
||||||
"lon": 14.353809,
|
|
||||||
"disposition": "Atypický",
|
|
||||||
"floor": null,
|
|
||||||
"area": null,
|
|
||||||
"building_type": "neuvedeno",
|
|
||||||
"ownership": "neuvedeno",
|
|
||||||
"url": "https://www.realingo.cz/prodej/byt-ostatni-byty-praha-154-00/24514769",
|
|
||||||
"source": "realingo",
|
|
||||||
"image": "",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 24514708,
|
|
||||||
"name": "Prodej bytu Atypický None m²",
|
|
||||||
"price": 5362000,
|
|
||||||
"price_formatted": "5 362 000 Kč",
|
|
||||||
"locality": "Praha, 155 00",
|
|
||||||
"lat": 50.030571,
|
|
||||||
"lon": 14.308491,
|
|
||||||
"disposition": "Atypický",
|
|
||||||
"floor": null,
|
|
||||||
"area": null,
|
|
||||||
"building_type": "neuvedeno",
|
|
||||||
"ownership": "neuvedeno",
|
|
||||||
"url": "https://www.realingo.cz/prodej/byt-ostatni-byty-praha-155-00/24514708",
|
|
||||||
"source": "realingo",
|
|
||||||
"image": "",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,548 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"hash_id": 938877772,
|
|
||||||
"name": "Prodej bytu 3+kk 99 m²",
|
|
||||||
"price": 12990000,
|
|
||||||
"price_formatted": "12 990 000 Kč",
|
|
||||||
"locality": "Čeljabinská, Praha 10 - Vršovice",
|
|
||||||
"lat": 50.069641,
|
|
||||||
"lon": 14.470198,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 3,
|
|
||||||
"area": 99,
|
|
||||||
"building_type": "Smíšená",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.sreality.cz/detail/prodej/byt/3+kk/praha-vrsovice-celjabinska/938877772",
|
|
||||||
"image": "https://d18-a.sdn.cz/d_18/c_img_p7_D/kBfrbpoeNBLdvLCneFodIxL/21cc.jpeg?fl=res,400,300,3|shr,,20|jpg,90",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 2036855628,
|
|
||||||
"name": "Prodej bytu 3+kk 83 m²",
|
|
||||||
"price": 10490000,
|
|
||||||
"price_formatted": "10 490 000 Kč",
|
|
||||||
"locality": "Na Výrovně, Praha 5 - Stodůlky",
|
|
||||||
"lat": 50.039608,
|
|
||||||
"lon": 14.316702,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 2,
|
|
||||||
"area": 83,
|
|
||||||
"building_type": "Cihlová",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.sreality.cz/detail/prodej/byt/3+kk/praha-stodulky-na-vyrovne/2036855628",
|
|
||||||
"image": "https://d18-a.sdn.cz/d_18/c_img_p7_C/nPXMbbUsvqW7e6cQFkEl5P/7399.jpeg?fl=res,400,300,3|shr,,20|jpg,90",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 2148991820,
|
|
||||||
"name": "Prodej bytu 3+kk 72 m²",
|
|
||||||
"price": 10990000,
|
|
||||||
"price_formatted": "10 990 000 Kč",
|
|
||||||
"locality": "Pod Marjánkou, Praha 6 - Břevnov",
|
|
||||||
"lat": 50.084381,
|
|
||||||
"lon": 14.372257,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 4,
|
|
||||||
"area": 72,
|
|
||||||
"building_type": "Cihlová",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.sreality.cz/detail/prodej/byt/3+kk/praha-brevnov-pod-marjankou/2148991820",
|
|
||||||
"image": "https://d18-a.sdn.cz/d_18/c_img_p7_C/kOzkBkwYBTCNNSBPI1FiGB0F/c3a0.jpeg?fl=res,400,300,3|shr,,20|jpg,90",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 3226313292,
|
|
||||||
"name": "Prodej bytu 3+kk 83 m²",
|
|
||||||
"price": 13500000,
|
|
||||||
"price_formatted": "13 500 000 Kč",
|
|
||||||
"locality": "Na Neklance, Praha 5 - Smíchov",
|
|
||||||
"lat": 50.060715,
|
|
||||||
"lon": 14.401836,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 4,
|
|
||||||
"area": 83,
|
|
||||||
"building_type": "Cihlová",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.sreality.cz/detail/prodej/byt/3+kk/praha-smichov-na-neklance/3226313292",
|
|
||||||
"image": "https://d18-a.sdn.cz/d_18/c_img_p8_A/kBfrbpoeND2I1YDy2Fq7ErU/6389.jpeg?fl=res,400,300,3|shr,,20|jpg,90",
|
|
||||||
"last_updated": "2026-02-15",
|
|
||||||
"first_seen": "2026-02-15"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 475530060,
|
|
||||||
"name": "Prodej bytu 3+kk 83 m²",
|
|
||||||
"price": 12250000,
|
|
||||||
"price_formatted": "12 250 000 Kč",
|
|
||||||
"locality": "Radouňova, Praha 5 - Stodůlky",
|
|
||||||
"lat": 50.039043,
|
|
||||||
"lon": 14.314881,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 3,
|
|
||||||
"area": 83,
|
|
||||||
"building_type": "Cihlová",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.sreality.cz/detail/prodej/byt/3+kk/praha-stodulky-radounova/475530060",
|
|
||||||
"image": "https://d18-a.sdn.cz/d_18/c_img_p7_A/nDJ4VEZEqxQDMR0LFbAhGV/d4cf.png?fl=res,400,300,3|shr,,20|jpg,90",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 2303799884,
|
|
||||||
"name": "Prodej bytu 3+kk 88 m² (Jednopodlažní)",
|
|
||||||
"price": 12860000,
|
|
||||||
"price_formatted": "12 860 000 Kč",
|
|
||||||
"locality": "Spojovací, Praha 9 - Vysočany",
|
|
||||||
"lat": 50.100174,
|
|
||||||
"lon": 14.492079,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 3,
|
|
||||||
"area": 88,
|
|
||||||
"building_type": "Skeletová",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.sreality.cz/detail/prodej/byt/3+kk/praha-vysocany-spojovaci/2303799884",
|
|
||||||
"image": "https://d18-a.sdn.cz/d_18/c_img_oV_A/kQOIvbF2D1DN63hulCAKv40/3667.png?fl=res,400,300,3|shr,,20|jpg,90",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 3493290828,
|
|
||||||
"name": "Prodej bytu 3+kk 83 m²",
|
|
||||||
"price": 11390000,
|
|
||||||
"price_formatted": "11 390 000 Kč",
|
|
||||||
"locality": "Kytlická, Praha 9 - Prosek",
|
|
||||||
"lat": 50.125145,
|
|
||||||
"lon": 14.507703,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 4,
|
|
||||||
"area": 83,
|
|
||||||
"building_type": "Skeletová",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.sreality.cz/detail/prodej/byt/3+kk/praha-prosek-kytlicka/3493290828",
|
|
||||||
"image": "https://d18-a.sdn.cz/d_18/c_img_p8_C/nPVpfd5QLLDqk1BGdrF3rQMW/0fe5.jpeg?fl=res,400,300,3|shr,,20|jpg,90",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 151528268,
|
|
||||||
"name": "Prodej bytu 3+kk 86 m²",
|
|
||||||
"price": 11390000,
|
|
||||||
"price_formatted": "11 390 000 Kč",
|
|
||||||
"locality": "Spojovací, Praha",
|
|
||||||
"lat": 50.101852,
|
|
||||||
"lon": 14.486118,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 2,
|
|
||||||
"area": 86,
|
|
||||||
"building_type": "Smíšená",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.sreality.cz/detail/prodej/byt/3+kk/praha--spojovaci/151528268",
|
|
||||||
"image": "https://d18-a.sdn.cz/d_18/c_img_of_C/kPxr1WDRoIBXSQV6LE550j7/1607.png?fl=res,400,300,3|shr,,20|jpg,90",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 1837527884,
|
|
||||||
"name": "Prodej bytu 3+kk 73 m² (Jednopodlažní)",
|
|
||||||
"price": 12790000,
|
|
||||||
"price_formatted": "12 790 000 Kč",
|
|
||||||
"locality": "Vrázova, Praha - Smíchov",
|
|
||||||
"lat": 50.071224,
|
|
||||||
"lon": 14.407872,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 3,
|
|
||||||
"area": 73,
|
|
||||||
"building_type": "Cihlová",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.sreality.cz/detail/prodej/byt/3+kk/praha-smichov-vrazova/1837527884",
|
|
||||||
"image": "https://d18-a.sdn.cz/d_18/c_img_p8_C/kY1K2LlXQDnuVLD65F2mjiY/96d4.png?fl=res,400,300,3|shr,,20|jpg,90",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 3330433868,
|
|
||||||
"name": "Prodej bytu 3+kk 93 m²",
|
|
||||||
"price": 11890000,
|
|
||||||
"price_formatted": "11 890 000 Kč",
|
|
||||||
"locality": "Kříženeckého náměstí, Praha 5 - Hlubočepy",
|
|
||||||
"lat": 50.03138,
|
|
||||||
"lon": 14.391757,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 2,
|
|
||||||
"area": 93,
|
|
||||||
"building_type": "Cihlová",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.sreality.cz/detail/prodej/byt/3+kk/praha-hlubocepy-krizeneckeho-namesti/3330433868",
|
|
||||||
"image": "https://d18-a.sdn.cz/d_18/c_img_p8_C/nPVpfd5QLLChvUCFgIF2b8p9/bffb.jpeg?fl=res,400,300,3|shr,,20|jpg,90",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 2053579340,
|
|
||||||
"name": "Prodej bytu 3+kk 76 m²",
|
|
||||||
"price": 11858981,
|
|
||||||
"price_formatted": "11 858 981 Kč",
|
|
||||||
"locality": "Za Novákovou zahradou, Praha - Satalice",
|
|
||||||
"lat": 50.122192,
|
|
||||||
"lon": 14.57646,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 3,
|
|
||||||
"area": 76,
|
|
||||||
"building_type": "Smíšená",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.sreality.cz/detail/prodej/byt/3+kk/praha-satalice-za-novakovou-zahradou/2053579340",
|
|
||||||
"image": "https://d18-a.sdn.cz/d_18/c_img_oe_B/nO1Ur3YPjB17k9qAElHGe3/e889.jpeg?fl=res,400,300,3|shr,,20|jpg,90",
|
|
||||||
"last_updated": "2026-02-15",
|
|
||||||
"first_seen": "2026-02-15"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 3651539788,
|
|
||||||
"name": "Prodej bytu 3+kk 69 m² (Jednopodlažní)",
|
|
||||||
"price": 13500000,
|
|
||||||
"price_formatted": "13 500 000 Kč",
|
|
||||||
"locality": "Zvěřinova, Praha 3 - Strašnice",
|
|
||||||
"lat": 50.084606,
|
|
||||||
"lon": 14.482681,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 12,
|
|
||||||
"area": 69,
|
|
||||||
"building_type": "Smíšená",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.sreality.cz/detail/prodej/byt/3+kk/praha-strasnice-zverinova/3651539788",
|
|
||||||
"image": "https://d18-a.sdn.cz/d_18/c_img_og_D/nDJ4VEZEqxmIDK5SFVQ67V/47d3.png?fl=res,400,300,3|shr,,20|jpg,90",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 4005061452,
|
|
||||||
"name": "Prodej bytu 3+kk 101 m² (Jednopodlažní)",
|
|
||||||
"price": 12875000,
|
|
||||||
"price_formatted": "12 875 000 Kč",
|
|
||||||
"locality": "U Hostavického potoka, Praha 9 - Hostavice",
|
|
||||||
"lat": 50.086601,
|
|
||||||
"lon": 14.5636,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 5,
|
|
||||||
"area": 101,
|
|
||||||
"building_type": "Cihlová",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.sreality.cz/detail/prodej/byt/3+kk/praha-hostavice-u-hostavickeho-potoka/4005061452",
|
|
||||||
"image": "https://d18-a.sdn.cz/d_18/c_img_p8_B/kY1K2LlXQBdLY8B5hFyVzb6/c266.png?fl=res,400,300,3|shr,,20|jpg,90",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 589460300,
|
|
||||||
"name": "Prodej bytu 3+kk 75 m²",
|
|
||||||
"price": 13126000,
|
|
||||||
"price_formatted": "13 126 000 Kč",
|
|
||||||
"locality": "Ke Slivenci, Praha - Lochkov",
|
|
||||||
"lat": 50.004192,
|
|
||||||
"lon": 14.355805,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 2,
|
|
||||||
"area": 75,
|
|
||||||
"building_type": "Skeletová",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.sreality.cz/detail/prodej/byt/3+kk/praha-lochkov-ke-slivenci/589460300",
|
|
||||||
"image": "https://d18-a.sdn.cz/d_18/c_img_p8_A/kBfrbpoeNCgOJyFn9Fq0T4y/bed6.png?fl=res,400,300,3|shr,,20|jpg,90",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 2926625612,
|
|
||||||
"name": "Prodej bytu 3+kk 85 m²",
|
|
||||||
"price": 13499900,
|
|
||||||
"price_formatted": "13 499 900 Kč",
|
|
||||||
"locality": "Hořejší nábřeží, Praha 5 - Smíchov",
|
|
||||||
"lat": 50.072403,
|
|
||||||
"lon": 14.410302,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 3,
|
|
||||||
"area": 85,
|
|
||||||
"building_type": "Cihlová",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.sreality.cz/detail/prodej/byt/3+kk/praha-smichov-horejsi-nabrezi/2926625612",
|
|
||||||
"image": "https://d18-a.sdn.cz/d_18/c_img_p8_C/nPVpfd5QLLBPLz5GC0F3Hshx/c1f4.jpeg?fl=res,400,300,3|shr,,20|jpg,90",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 3672994636,
|
|
||||||
"name": "Prodej bytu 3+kk 81 m²",
|
|
||||||
"price": 12390000,
|
|
||||||
"price_formatted": "12 390 000 Kč",
|
|
||||||
"locality": "Stochovská, Praha 6 - Ruzyně",
|
|
||||||
"lat": 50.082985,
|
|
||||||
"lon": 14.311815,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 3,
|
|
||||||
"area": 81,
|
|
||||||
"building_type": "Smíšená",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.sreality.cz/detail/prodej/byt/3+kk/praha-ruzyne-stochovska/3672994636",
|
|
||||||
"image": "https://d18-a.sdn.cz/d_18/c_img_p8_C/nPVpfd5QLLD53UiFNrF2fBro/c20c.jpeg?fl=res,400,300,3|shr,,20|jpg,90",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 4070581580,
|
|
||||||
"name": "Prodej bytu 3+kk 77 m²",
|
|
||||||
"price": 12207113,
|
|
||||||
"price_formatted": "12 207 113 Kč",
|
|
||||||
"locality": "Marie Podvalové, Praha - Čakovice",
|
|
||||||
"lat": 50.157696,
|
|
||||||
"lon": 14.519159,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 2,
|
|
||||||
"area": 77,
|
|
||||||
"building_type": "Skeletová",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.sreality.cz/detail/prodej/byt/3+kk/praha-cakovice-marie-podvalove/4070581580",
|
|
||||||
"image": "https://d18-a.sdn.cz/d_18/c_img_p8_A/kBfrbpoeNBFLmuFzPFuWw0w/0867.jpeg?fl=res,400,300,3|shr,,20|jpg,90",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 2772919116,
|
|
||||||
"name": "Prodej bytu 3+kk 81 m²",
|
|
||||||
"price": 13000000,
|
|
||||||
"price_formatted": "13 000 000 Kč",
|
|
||||||
"locality": "Hlučkova, Praha 9 - Letňany",
|
|
||||||
"lat": 50.141739,
|
|
||||||
"lon": 14.522086,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 5,
|
|
||||||
"area": 81,
|
|
||||||
"building_type": "Smíšená",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.sreality.cz/detail/prodej/byt/3+kk/praha-letnany-hluckova/2772919116",
|
|
||||||
"image": "https://d18-a.sdn.cz/d_18/c_img_p8_C/nPVpfd5QLLDqzReGOMF3mju3/0593.jpeg?fl=res,400,300,3|shr,,20|jpg,90",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 2242032460,
|
|
||||||
"name": "Prodej bytu 3+kk 98 m²",
|
|
||||||
"price": 12762764,
|
|
||||||
"price_formatted": "12 762 764 Kč",
|
|
||||||
"locality": "Lodžská, Praha 8 - Bohnice",
|
|
||||||
"lat": 50.13076,
|
|
||||||
"lon": 14.423249,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 5,
|
|
||||||
"area": 98,
|
|
||||||
"building_type": "Smíšená",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.sreality.cz/detail/prodej/byt/3+kk/praha-bohnice-lodzska/2242032460",
|
|
||||||
"image": "https://d18-a.sdn.cz/d_18/c_img_p8_B/kY1K2LlXQBsPCGB2DFyc98H/0138.jpeg?fl=res,400,300,3|shr,,20|jpg,90",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 3617202764,
|
|
||||||
"name": "Prodej bytu 3+kk 79 m²",
|
|
||||||
"price": 12959520,
|
|
||||||
"price_formatted": "12 959 520 Kč",
|
|
||||||
"locality": "Komárkova, Praha 4 - Chodov",
|
|
||||||
"lat": 50.036095,
|
|
||||||
"lon": 14.48035,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 3,
|
|
||||||
"area": 79,
|
|
||||||
"building_type": "Smíšená",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.sreality.cz/detail/prodej/byt/3+kk/praha-chodov-komarkova/3617202764",
|
|
||||||
"image": "https://d18-a.sdn.cz/d_18/c_img_p8_B/kY1K2LlXQCEs3VCXcFzFm2Z/52f3.jpeg?fl=res,400,300,3|shr,,20|jpg,90",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 2860663372,
|
|
||||||
"name": "Prodej bytu 3+kk 78 m² (Jednopodlažní)",
|
|
||||||
"price": 12463000,
|
|
||||||
"price_formatted": "12 463 000 Kč",
|
|
||||||
"locality": "Kubelíkova, Praha",
|
|
||||||
"lat": 50.082317,
|
|
||||||
"lon": 14.450463,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 4,
|
|
||||||
"area": 78,
|
|
||||||
"building_type": "Cihlová",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.sreality.cz/detail/prodej/byt/3+kk/praha--kubelikova/2860663372",
|
|
||||||
"image": "https://d18-a.sdn.cz/d_18/c_img_oZ_C/nsLxLojIBfZuBIjJDOugLv/2953.png?fl=res,400,300,3|shr,,20|jpg,90",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 158065228,
|
|
||||||
"name": "Prodej bytu 3+kk 73 m²",
|
|
||||||
"price": 10947779,
|
|
||||||
"price_formatted": "10 947 779 Kč",
|
|
||||||
"locality": "Marie Podvalové, Praha - Čakovice",
|
|
||||||
"lat": 50.157696,
|
|
||||||
"lon": 14.519159,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 4,
|
|
||||||
"area": 73,
|
|
||||||
"building_type": "Skeletová",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.sreality.cz/detail/prodej/byt/3+kk/praha-cakovice-marie-podvalove/158065228",
|
|
||||||
"image": "https://d18-a.sdn.cz/d_18/c_img_p8_A/nPXMbbUsvqawIkHTbFrh4zH/4f1d.jpeg?fl=res,400,300,3|shr,,20|jpg,90",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 4227625804,
|
|
||||||
"name": "Prodej bytu 3+kk 81 m²",
|
|
||||||
"price": 10990000,
|
|
||||||
"price_formatted": "10 990 000 Kč",
|
|
||||||
"locality": "Ukrajinská, Praha 10 - Vršovice",
|
|
||||||
"lat": 50.065208,
|
|
||||||
"lon": 14.450711,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 5,
|
|
||||||
"area": 81,
|
|
||||||
"building_type": "Cihlová",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.sreality.cz/detail/prodej/byt/3+kk/praha-vrsovice-ukrajinska/4227625804",
|
|
||||||
"image": "https://d18-a.sdn.cz/d_18/c_img_p8_A/nPXMbbUsvqDzGaHEfWFp5WXH/fd47.jpeg?fl=res,400,300,3|shr,,20|jpg,90",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 1313456972,
|
|
||||||
"name": "Prodej bytu 3+kk 87 m²",
|
|
||||||
"price": 3641309,
|
|
||||||
"price_formatted": "3 641 309 Kč",
|
|
||||||
"locality": "Praha 9",
|
|
||||||
"lat": 50.106956,
|
|
||||||
"lon": 14.510207,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 5,
|
|
||||||
"area": 87,
|
|
||||||
"building_type": "Smíšená",
|
|
||||||
"ownership": "Družstevní",
|
|
||||||
"url": "https://www.sreality.cz/detail/prodej/byt/3+kk/praha-praha-9-/1313456972",
|
|
||||||
"image": "https://d18-a.sdn.cz/d_18/c_img_p8_B/kY1K2LlXQBsPCGXMFvI8II/9010.jpeg?fl=res,400,300,3|shr,,20|jpg,90",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 1671439692,
|
|
||||||
"name": "Prodej bytu 3+kk 77 m²",
|
|
||||||
"price": 12556524,
|
|
||||||
"price_formatted": "12 556 524 Kč",
|
|
||||||
"locality": "Marie Podvalové, Praha - Čakovice",
|
|
||||||
"lat": 50.157696,
|
|
||||||
"lon": 14.519159,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 3,
|
|
||||||
"area": 77,
|
|
||||||
"building_type": "Skeletová",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.sreality.cz/detail/prodej/byt/3+kk/praha-cakovice-marie-podvalove/1671439692",
|
|
||||||
"image": "https://d18-a.sdn.cz/d_18/c_img_p8_A/nPXMbbUsvqW7e6HmhFuWvTy/67cb.jpeg?fl=res,400,300,3|shr,,20|jpg,90",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 576226124,
|
|
||||||
"name": "Prodej bytu 3+kk 71 m²",
|
|
||||||
"price": 12026912,
|
|
||||||
"price_formatted": "12 026 912 Kč",
|
|
||||||
"locality": "Hábova, Praha 5 - Stodůlky",
|
|
||||||
"lat": 50.04636,
|
|
||||||
"lon": 14.310556,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 3,
|
|
||||||
"area": 71,
|
|
||||||
"building_type": "Smíšená",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.sreality.cz/detail/prodej/byt/3+kk/praha-stodulky-habova/576226124",
|
|
||||||
"image": "https://d18-a.sdn.cz/d_18/c_img_of_A/nO5OZtPbfGXuqXa5EzIqYl/4c78.jpeg?fl=res,400,300,3|shr,,20|jpg,90",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 473465676,
|
|
||||||
"name": "Prodej bytu 3+kk 73 m²",
|
|
||||||
"price": 12349349,
|
|
||||||
"price_formatted": "12 349 349 Kč",
|
|
||||||
"locality": "Hábova, Praha 5 - Stodůlky",
|
|
||||||
"lat": 50.04636,
|
|
||||||
"lon": 14.310556,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 3,
|
|
||||||
"area": 73,
|
|
||||||
"building_type": "Smíšená",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.sreality.cz/detail/prodej/byt/3+kk/praha-stodulky-habova/473465676",
|
|
||||||
"image": "https://d18-a.sdn.cz/d_18/c_img_of_A/nO5OZtPbfGCc8bBbKEzIlyN/5708.jpeg?fl=res,400,300,3|shr,,20|jpg,90",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 2185458508,
|
|
||||||
"name": "Prodej bytu 3+kk 76 m²",
|
|
||||||
"price": 11978000,
|
|
||||||
"price_formatted": "11 978 000 Kč",
|
|
||||||
"locality": "Matoušova, Praha - Smíchov",
|
|
||||||
"lat": 50.074284,
|
|
||||||
"lon": 14.405826,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 2,
|
|
||||||
"area": 76,
|
|
||||||
"building_type": "Cihlová",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.sreality.cz/detail/prodej/byt/3+kk/praha-smichov-matousova/2185458508",
|
|
||||||
"image": "https://d18-a.sdn.cz/d_18/c_img_p8_C/kY1K2LlXQJGqrEMbF29iKr/c977.png?fl=res,400,300,3|shr,,20|jpg,90",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 3988325196,
|
|
||||||
"name": "Prodej bytu 3+kk 83 m² (Jednopodlažní)",
|
|
||||||
"price": 13190000,
|
|
||||||
"price_formatted": "13 190 000 Kč",
|
|
||||||
"locality": "Práčská, Praha",
|
|
||||||
"lat": 50.053101,
|
|
||||||
"lon": 14.507191,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 2,
|
|
||||||
"area": 83,
|
|
||||||
"building_type": "Kamenná",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.sreality.cz/detail/prodej/byt/3+kk/praha--pracska/3988325196",
|
|
||||||
"image": "https://d18-a.sdn.cz/d_18/c_img_p7_A/kOzkBkwYBTDPt7SL0bFanjav/5b75.png?fl=res,400,300,3|shr,,20|jpg,90",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 3019572044,
|
|
||||||
"name": "Prodej bytu 3+kk 76 m²",
|
|
||||||
"price": 10790000,
|
|
||||||
"price_formatted": "10 790 000 Kč",
|
|
||||||
"locality": "Plzákova, Praha - Kbely",
|
|
||||||
"lat": 50.13237,
|
|
||||||
"lon": 14.53639,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 3,
|
|
||||||
"area": 76,
|
|
||||||
"building_type": "Cihlová",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.sreality.cz/detail/prodej/byt/3+kk/praha-kbely-plzakova/3019572044",
|
|
||||||
"image": "https://d18-a.sdn.cz/d_18/c_img_p7_A/nDJ4VEZEqCaOxZQ8PFZch2R/b90f.png?fl=res,400,300,3|shr,,20|jpg,90",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 3704697676,
|
|
||||||
"name": "Prodej bytu 3+kk 72 m²",
|
|
||||||
"price": 8000000,
|
|
||||||
"price_formatted": "8 000 000 Kč",
|
|
||||||
"locality": "Litevská, Praha 10 - Vršovice",
|
|
||||||
"lat": 50.072536,
|
|
||||||
"lon": 14.476557,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 5,
|
|
||||||
"area": 72,
|
|
||||||
"building_type": "Cihlová",
|
|
||||||
"ownership": "Družstevní",
|
|
||||||
"url": "https://www.sreality.cz/detail/prodej/byt/3+kk/praha-vrsovice-litevska/3704697676",
|
|
||||||
"image": "https://d18-a.sdn.cz/d_18/c_img_of_A/nOztZkD4ZlC2Y2EU6E0MiZv/15fc.png?fl=res,400,300,3|shr,,20|jpg,90",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hash_id": 1137623884,
|
|
||||||
"name": "Prodej bytu 3+kk 71 m²",
|
|
||||||
"price": 12318349,
|
|
||||||
"price_formatted": "12 318 349 Kč",
|
|
||||||
"locality": "Praha 9",
|
|
||||||
"lat": 50.106956,
|
|
||||||
"lon": 14.510207,
|
|
||||||
"disposition": "3+kk",
|
|
||||||
"floor": 4,
|
|
||||||
"area": 71,
|
|
||||||
"building_type": "Skeletová",
|
|
||||||
"ownership": "Osobní",
|
|
||||||
"url": "https://www.sreality.cz/detail/prodej/byt/3+kk/praha-praha-9-/1137623884",
|
|
||||||
"image": "https://d18-a.sdn.cz/d_18/c_img_p8_B/nPVpfd5QLLksKHbwFvjCd6/56ac.jpeg?fl=res,400,300,3|shr,,20|jpg,90",
|
|
||||||
"scraped_at": "2026-02-25"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
123
docs/validation.md
Normal file
123
docs/validation.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# Validation Recipe
|
||||||
|
|
||||||
|
End-to-end check that scraping, data persistence, history, and the status page all work correctly in Docker.
|
||||||
|
|
||||||
|
## What it verifies
|
||||||
|
|
||||||
|
- All scrapers run and write output to `DATA_DIR` (`/app/data`)
|
||||||
|
- `stats_*.json` land in `/app/data/` (not in `/app/`)
|
||||||
|
- `status.json` and `scraper_history.json` land in `/app/data/`
|
||||||
|
- `/api/status`, `/api/status/history`, and `/scrapers-status` serve correct data
|
||||||
|
- History accumulates across runs
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### 1. Build the image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Start a clean validation container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop/remove any leftover container and volume from a previous run
|
||||||
|
docker stop maru-hleda-byt-validation 2>/dev/null; docker rm maru-hleda-byt-validation 2>/dev/null
|
||||||
|
docker volume rm maru-hleda-byt-validation-data 2>/dev/null
|
||||||
|
|
||||||
|
docker run -d --name maru-hleda-byt-validation \
|
||||||
|
-p 8081:8080 \
|
||||||
|
-v maru-hleda-byt-validation-data:/app/data \
|
||||||
|
maru-hleda-byt
|
||||||
|
```
|
||||||
|
|
||||||
|
Give the container ~3 seconds to start. The entrypoint launches a background full scrape automatically — suppress it so only controlled runs execute:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sleep 3
|
||||||
|
docker exec maru-hleda-byt-validation pkill -f run_all.sh 2>/dev/null || true
|
||||||
|
docker exec maru-hleda-byt-validation rm -f /app/data/scraper_running.json 2>/dev/null || true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Run a limited scrape (run 1)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec maru-hleda-byt-validation bash /app/run_all.sh --max-pages 1 --max-properties 10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output (last few lines):
|
||||||
|
```
|
||||||
|
Status uložen: /app/data/status.json
|
||||||
|
Historie uložena: /app/data/scraper_history.json (1 záznamů)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Verify data files are in `/app/data/`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec maru-hleda-byt-validation ls /app/data/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected files:
|
||||||
|
```
|
||||||
|
byty_cityhome.json byty_idnes.json byty_merged.json
|
||||||
|
byty_realingo.json byty_sreality.json
|
||||||
|
mapa_bytu.html
|
||||||
|
scraper_history.json
|
||||||
|
stats_bezrealitky.json stats_cityhome.json stats_idnes.json
|
||||||
|
stats_realingo.json stats_sreality.json
|
||||||
|
status.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Run a second limited scrape (run 2)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec maru-hleda-byt-validation bash /app/run_all.sh --max-pages 1 --max-properties 10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected last line: `Historie uložena: /app/data/scraper_history.json (2 záznamů)`
|
||||||
|
|
||||||
|
### 6. Verify history via API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:8081/api/status/history | python3 -c "
|
||||||
|
import json, sys
|
||||||
|
h = json.load(sys.stdin)
|
||||||
|
print(f'{len(h)} entries:')
|
||||||
|
for i, e in enumerate(h):
|
||||||
|
print(f' [{i}] {e[\"timestamp\"]} total={e[\"total_accepted\"]}')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 2 entries with different timestamps.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:8081/api/status | python3 -c "
|
||||||
|
import json, sys; s=json.load(sys.stdin)
|
||||||
|
print(f'status={s[\"status\"]} total={s[\"total_accepted\"]} ts={s[\"timestamp\"]}')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `status=done total=<N> ts=<latest timestamp>`
|
||||||
|
|
||||||
|
### 7. Check the status page
|
||||||
|
|
||||||
|
Open http://localhost:8081/scrapers-status in a browser (or `curl -s http://localhost:8081/scrapers-status | grep -c "clickable-row"` — should print `2`).
|
||||||
|
|
||||||
|
### 8. Clean up
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker stop maru-hleda-byt-validation && docker rm maru-hleda-byt-validation
|
||||||
|
docker volume rm maru-hleda-byt-validation-data
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the Makefile shortcut:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make validation-stop
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- PSN scraper does not support `--max-pages` and will always fail with this command; `success=False` in history is expected during validation.
|
||||||
|
- Bezrealitky may return 0 results with a 1-page limit; `byty_bezrealitky.json` will be absent from `/app/data/` in that case — this is normal.
|
||||||
|
- `make validation` (the Makefile target) runs the same limited scrape but does not suppress the background startup scrape, so two concurrent runs may occur. Use the manual steps above for a clean controlled test.
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Generate status.json from scraper JSON outputs and run log."""
|
"""Generate status.json from scraper JSON outputs and per-scraper stats files."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
HERE = Path(__file__).parent
|
HERE = Path(__file__).parent
|
||||||
|
DATA_DIR = Path(os.environ.get("DATA_DIR", HERE))
|
||||||
|
|
||||||
SOURCE_FILES = {
|
SOURCE_FILES = {
|
||||||
"Sreality": "byty_sreality.json",
|
"Sreality": "byty_sreality.json",
|
||||||
@@ -21,7 +20,17 @@ SOURCE_FILES = {
|
|||||||
"CityHome": "byty_cityhome.json",
|
"CityHome": "byty_cityhome.json",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
STATS_FILES = {
|
||||||
|
"Sreality": "stats_sreality.json",
|
||||||
|
"Realingo": "stats_realingo.json",
|
||||||
|
"Bezrealitky": "stats_bezrealitky.json",
|
||||||
|
"iDNES": "stats_idnes.json",
|
||||||
|
"PSN": "stats_psn.json",
|
||||||
|
"CityHome": "stats_cityhome.json",
|
||||||
|
}
|
||||||
|
|
||||||
MERGED_FILE = "byty_merged.json"
|
MERGED_FILE = "byty_merged.json"
|
||||||
|
HISTORY_FILE = "scraper_history.json"
|
||||||
|
|
||||||
|
|
||||||
def count_source(path: Path) -> dict:
|
def count_source(path: Path) -> dict:
|
||||||
@@ -36,105 +45,51 @@ def count_source(path: Path) -> dict:
|
|||||||
return {"accepted": 0, "error": str(e)}
|
return {"accepted": 0, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
def parse_log(log_path: str) -> dict[str, dict]:
|
def read_scraper_stats(path: Path) -> dict:
|
||||||
"""Parse scraper run log and extract per-source statistics.
|
"""Load a per-scraper stats JSON. Returns {} on missing or corrupt file."""
|
||||||
|
if not path.exists():
|
||||||
Scrapers log summary lines like:
|
return {}
|
||||||
✓ Vyhovující byty: 12
|
try:
|
||||||
Vyloučeno (prodáno): 5
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
Staženo stránek: 3
|
return data if isinstance(data, dict) else {}
|
||||||
Staženo inzerátů: 48
|
except Exception:
|
||||||
Celkem bytů v cache: 120
|
|
||||||
and section headers like:
|
|
||||||
[2/6] Realingo
|
|
||||||
"""
|
|
||||||
if not log_path or not os.path.exists(log_path):
|
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
with open(log_path, encoding="utf-8") as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Split into per-source sections by the [N/6] Step header
|
def append_to_history(status: dict, keep: int) -> None:
|
||||||
# Each section header looks like "[2/6] Realingo\n----..."
|
"""Append the current status entry to scraper_history.json, keeping only `keep` latest."""
|
||||||
section_pattern = re.compile(r'\[(\d+)/\d+\]\s+(.+)\n-+', re.MULTILINE)
|
history_path = DATA_DIR / HISTORY_FILE
|
||||||
sections_found = list(section_pattern.finditer(content))
|
history: list = []
|
||||||
|
if history_path.exists():
|
||||||
|
try:
|
||||||
|
history = json.loads(history_path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(history, list):
|
||||||
|
history = []
|
||||||
|
except Exception:
|
||||||
|
history = []
|
||||||
|
|
||||||
if not sections_found:
|
history.append(status)
|
||||||
return {}
|
|
||||||
|
|
||||||
stats = {}
|
# Keep only the N most recent entries
|
||||||
for i, match in enumerate(sections_found):
|
if keep > 0 and len(history) > keep:
|
||||||
step_name = match.group(2).strip()
|
history = history[-keep:]
|
||||||
start = match.end()
|
|
||||||
end = sections_found[i + 1].start() if i + 1 < len(sections_found) else len(content)
|
|
||||||
section_text = content[start:end]
|
|
||||||
|
|
||||||
# Identify which sources this section covers
|
history_path.write_text(json.dumps(history, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
# "PSN + CityHome" covers both
|
print(f"Historie uložena: {history_path} ({len(history)} záznamů)")
|
||||||
source_names = []
|
|
||||||
for name in SOURCE_FILES:
|
|
||||||
if name.lower() in step_name.lower():
|
|
||||||
source_names.append(name)
|
|
||||||
if not source_names:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Parse numeric summary lines
|
|
||||||
def extract(pattern: str) -> Optional[int]:
|
|
||||||
m = re.search(pattern, section_text)
|
|
||||||
return int(m.group(1)) if m else None
|
|
||||||
|
|
||||||
# Lines present in all/most scrapers
|
|
||||||
accepted = extract(r'Vyhovující byty[:\s]+(\d+)')
|
|
||||||
fetched = extract(r'Staženo inzerátů[:\s]+(\d+)')
|
|
||||||
pages = extract(r'Staženo stránek[:\s]+(\d+)')
|
|
||||||
cached = extract(r'Celkem bytů v cache[:\s]+(\d+)')
|
|
||||||
cache_hits = extract(r'Cache hit[:\s]+(\d+)')
|
|
||||||
|
|
||||||
# Rejection reasons — collect all into a dict
|
|
||||||
excluded = {}
|
|
||||||
for m in re.finditer(r'Vyloučeno\s+\(([^)]+)\)[:\s]+(\d+)', section_text):
|
|
||||||
excluded[m.group(1)] = int(m.group(2))
|
|
||||||
# Also PSN-style "Vyloučeno (prodáno): N"
|
|
||||||
total_excluded = sum(excluded.values()) if excluded else extract(r'Vyloučen\w*[:\s]+(\d+)')
|
|
||||||
|
|
||||||
entry = {}
|
|
||||||
if accepted is not None:
|
|
||||||
entry["accepted"] = accepted
|
|
||||||
if fetched is not None:
|
|
||||||
entry["fetched"] = fetched
|
|
||||||
if pages is not None:
|
|
||||||
entry["pages"] = pages
|
|
||||||
if cached is not None:
|
|
||||||
entry["cached"] = cached
|
|
||||||
if cache_hits is not None:
|
|
||||||
entry["cache_hits"] = cache_hits
|
|
||||||
if excluded:
|
|
||||||
entry["excluded"] = excluded
|
|
||||||
elif total_excluded is not None:
|
|
||||||
entry["excluded_total"] = total_excluded
|
|
||||||
|
|
||||||
for name in source_names:
|
|
||||||
stats[name] = entry
|
|
||||||
|
|
||||||
return stats
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
start_time = None
|
parser = argparse.ArgumentParser(description="Generate status.json from scraper outputs.")
|
||||||
duration_sec = None
|
parser.add_argument("--start-time", dest="start_time", default=None,
|
||||||
|
help="ISO timestamp of scrape start (default: now)")
|
||||||
|
parser.add_argument("--duration", dest="duration", type=int, default=None,
|
||||||
|
help="Run duration in seconds")
|
||||||
|
parser.add_argument("--keep", dest="keep", type=int, default=20,
|
||||||
|
help="Number of history entries to keep (default: 20, 0=unlimited)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
if len(sys.argv) >= 3:
|
start_time = args.start_time or datetime.now().isoformat(timespec="seconds")
|
||||||
start_time = sys.argv[1]
|
duration_sec = args.duration
|
||||||
try:
|
|
||||||
duration_sec = int(sys.argv[2])
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not start_time:
|
|
||||||
start_time = datetime.now().isoformat(timespec="seconds")
|
|
||||||
|
|
||||||
log_path = sys.argv[3] if len(sys.argv) >= 4 else None
|
|
||||||
log_stats = parse_log(log_path)
|
|
||||||
|
|
||||||
sources = []
|
sources = []
|
||||||
for name, filename in SOURCE_FILES.items():
|
for name, filename in SOURCE_FILES.items():
|
||||||
@@ -142,14 +97,12 @@ def main():
|
|||||||
info = count_source(path)
|
info = count_source(path)
|
||||||
info["name"] = name
|
info["name"] = name
|
||||||
|
|
||||||
# Merge log stats
|
# Merge in stats from the per-scraper stats file (authoritative for run data)
|
||||||
ls = log_stats.get(name, {})
|
stats = read_scraper_stats(DATA_DIR / STATS_FILES[name])
|
||||||
for k in ("fetched", "pages", "cached", "cache_hits", "excluded", "excluded_total"):
|
for key in ("accepted", "fetched", "pages", "cache_hits", "excluded", "excluded_total",
|
||||||
if k in ls:
|
"success", "duration_sec", "error"):
|
||||||
info[k] = ls[k]
|
if key in stats:
|
||||||
# Override accepted from log if available (log is authoritative for latest run)
|
info[key] = stats[key]
|
||||||
if "accepted" in ls:
|
|
||||||
info["accepted"] = ls["accepted"]
|
|
||||||
|
|
||||||
sources.append(info)
|
sources.append(info)
|
||||||
|
|
||||||
@@ -168,17 +121,21 @@ def main():
|
|||||||
|
|
||||||
duplicates_removed = total_accepted - deduplicated if deduplicated else 0
|
duplicates_removed = total_accepted - deduplicated if deduplicated else 0
|
||||||
|
|
||||||
|
# Top-level success: True if no source has an error
|
||||||
|
success = not any("error" in s for s in sources)
|
||||||
|
|
||||||
status = {
|
status = {
|
||||||
"status": "done",
|
"status": "done",
|
||||||
"timestamp": start_time,
|
"timestamp": start_time,
|
||||||
"duration_sec": duration_sec,
|
"duration_sec": duration_sec,
|
||||||
|
"success": success,
|
||||||
"total_accepted": total_accepted,
|
"total_accepted": total_accepted,
|
||||||
"deduplicated": deduplicated,
|
"deduplicated": deduplicated,
|
||||||
"duplicates_removed": duplicates_removed,
|
"duplicates_removed": duplicates_removed,
|
||||||
"sources": sources,
|
"sources": sources,
|
||||||
}
|
}
|
||||||
|
|
||||||
out = HERE / "status.json"
|
out = DATA_DIR / "status.json"
|
||||||
out.write_text(json.dumps(status, ensure_ascii=False, indent=2), encoding="utf-8")
|
out.write_text(json.dumps(status, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
print(f"Status uložen: {out}")
|
print(f"Status uložen: {out}")
|
||||||
print(f" Celkem bytů (před dedup): {total_accepted}")
|
print(f" Celkem bytů (před dedup): {total_accepted}")
|
||||||
@@ -197,6 +154,8 @@ def main():
|
|||||||
parts.append(f"[CHYBA: {err}]")
|
parts.append(f"[CHYBA: {err}]")
|
||||||
print(" " + " ".join(parts))
|
print(" " + " ".join(parts))
|
||||||
|
|
||||||
|
append_to_history(status, args.keep)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
601
mapa_bytu.html
601
mapa_bytu.html
@@ -1,601 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="cs">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Byty v Praze — mapa (56 bytů)</title>
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
||||||
<style>
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
body { font-family: system-ui, -apple-system, sans-serif; }
|
|
||||||
#map { width: 100%; height: 100vh; }
|
|
||||||
.heart-icon { background: none !important; border: none !important; }
|
|
||||||
.star-icon { background: none !important; border: none !important; }
|
|
||||||
.rate-btn:hover { background: #f0f0f0 !important; }
|
|
||||||
.rate-btn.active-fav { background: #FFF9C4 !important; border-color: #FFC107 !important; }
|
|
||||||
.rate-btn.active-rej { background: #FFEBEE !important; border-color: #F44336 !important; }
|
|
||||||
@keyframes pulse-glow {
|
|
||||||
0% { box-shadow: 0 0 4px 2px rgba(255, 193, 7, 0.4); }
|
|
||||||
50% { box-shadow: 0 0 10px 5px rgba(255, 193, 7, 0.7); }
|
|
||||||
100% { box-shadow: 0 0 4px 2px rgba(255, 193, 7, 0.4); }
|
|
||||||
}
|
|
||||||
.marker-favorite { animation: pulse-glow 2s ease-in-out infinite; border-radius: 50%; }
|
|
||||||
.heart-icon-fav svg path { stroke: gold !important; stroke-width: 2.5 !important; filter: drop-shadow(0 0 4px rgba(255,193,7,0.7)); }
|
|
||||||
.heart-icon-rej { opacity: 0.4 !important; filter: grayscale(1); }
|
|
||||||
.reject-overlay { background: none !important; border: none !important; pointer-events: none !important; }
|
|
||||||
@keyframes pulse-new {
|
|
||||||
0% { stroke-opacity: 1; stroke-width: 3px; r: 11; }
|
|
||||||
50% { stroke-opacity: 0.4; stroke-width: 6px; r: 12; }
|
|
||||||
100% { stroke-opacity: 1; stroke-width: 3px; r: 11; }
|
|
||||||
}
|
|
||||||
.marker-new { animation: pulse-new 2s ease-in-out infinite; }
|
|
||||||
.info-panel {
|
|
||||||
position: absolute; top: 10px; right: 10px; z-index: 1000;
|
|
||||||
background: white; padding: 16px; border-radius: 10px;
|
|
||||||
box-shadow: 0 2px 12px rgba(0,0,0,0.15); max-width: 260px;
|
|
||||||
font-size: 13px; line-height: 1.5;
|
|
||||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
.info-panel.collapsed {
|
|
||||||
transform: translateX(calc(100% + 20px));
|
|
||||||
opacity: 0; pointer-events: none;
|
|
||||||
}
|
|
||||||
.panel-open-btn {
|
|
||||||
position: absolute; top: 10px; right: 10px; z-index: 1001;
|
|
||||||
width: 40px; height: 40px; border-radius: 8px;
|
|
||||||
background: white; border: none; cursor: pointer;
|
|
||||||
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
|
|
||||||
font-size: 20px; display: flex; align-items: center; justify-content: center;
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
.panel-open-btn.hidden { opacity: 0; pointer-events: none; }
|
|
||||||
.panel-close-btn {
|
|
||||||
position: absolute; top: 8px; right: 8px;
|
|
||||||
width: 28px; height: 28px; border-radius: 6px;
|
|
||||||
background: none; border: 1px solid #ddd; cursor: pointer;
|
|
||||||
font-size: 16px; display: flex; align-items: center; justify-content: center;
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
.panel-close-btn:hover { background: #f0f0f0; color: #333; }
|
|
||||||
.info-panel h2 { font-size: 16px; margin-bottom: 8px; }
|
|
||||||
.info-panel .stats { color: #666; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid #eee; }
|
|
||||||
.filter-section { margin-top: 10px; padding-top: 10px; border-top: 1px solid #eee; }
|
|
||||||
.filter-section label { display: flex; align-items: center; gap: 6px; margin: 3px 0; cursor: pointer; }
|
|
||||||
.filter-section input[type="checkbox"] { accent-color: #1976D2; }
|
|
||||||
#floor-filter { margin-top: 8px; }
|
|
||||||
#floor-filter select { width: 100%; padding: 4px; border-radius: 4px; border: 1px solid #ccc; }
|
|
||||||
.status-link { display: block; margin-top: 10px; padding-top: 10px; border-top: 1px solid #eee; text-align: center; }
|
|
||||||
.status-link a { color: #1976D2; text-decoration: none; font-size: 12px; }
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.info-panel { max-width: calc(100vw - 60px); right: 10px; }
|
|
||||||
.info-panel.collapsed { transform: translateX(calc(100% + 20px)); }
|
|
||||||
.panel-close-btn { top: 6px; right: 6px; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="map"></div>
|
|
||||||
<button class="panel-open-btn hidden" id="panel-open-btn" onclick="togglePanel()">☰</button>
|
|
||||||
<div class="info-panel" id="info-panel">
|
|
||||||
<button class="panel-close-btn" id="panel-close-btn" onclick="togglePanel()">✕</button>
|
|
||||||
<h2>Byty v Praze</h2>
|
|
||||||
<div class="stats">
|
|
||||||
<div>Celkem: <b id="visible-count">56</b> bytů</div>
|
|
||||||
<div>Cena: 3 641 309 Kč — 13 994 000 Kč</div>
|
|
||||||
<div>Průměr: 11 279 033 Kč</div>
|
|
||||||
</div>
|
|
||||||
<div style="margin-bottom:4px;font-size:12px;color:#555;font-weight:600;">Cena / m²:</div><div style="display:flex;align-items:center;gap:6px;margin:2px 0;"><span style="width:14px;height:14px;border-radius:50%;background:#1565C0;display:inline-block;border:2px solid white;box-shadow:0 1px 3px rgba(0,0,0,0.3);flex-shrink:0;"></span><span>< 110 000 Kč/m²</span></div><div style="display:flex;align-items:center;gap:6px;margin:2px 0;"><span style="width:14px;height:14px;border-radius:50%;background:#42A5F5;display:inline-block;border:2px solid white;box-shadow:0 1px 3px rgba(0,0,0,0.3);flex-shrink:0;"></span><span>110 – 130 000 Kč/m²</span></div><div style="display:flex;align-items:center;gap:6px;margin:2px 0;"><span style="width:14px;height:14px;border-radius:50%;background:#66BB6A;display:inline-block;border:2px solid white;box-shadow:0 1px 3px rgba(0,0,0,0.3);flex-shrink:0;"></span><span>130 – 150 000 Kč/m²</span></div><div style="display:flex;align-items:center;gap:6px;margin:2px 0;"><span style="width:14px;height:14px;border-radius:50%;background:#EF6C00;display:inline-block;border:2px solid white;box-shadow:0 1px 3px rgba(0,0,0,0.3);flex-shrink:0;"></span><span>150 – 165 000 Kč/m²</span></div><div style="display:flex;align-items:center;gap:6px;margin:2px 0;"><span style="width:14px;height:14px;border-radius:50%;background:#C62828;display:inline-block;border:2px solid white;box-shadow:0 1px 3px rgba(0,0,0,0.3);flex-shrink:0;"></span><span>> 165 000 Kč/m²</span></div><div style="display:flex;align-items:center;gap:6px;margin:2px 0;"><span style="width:14px;height:14px;border-radius:50%;background:#9E9E9E;display:inline-block;border:2px solid white;box-shadow:0 1px 3px rgba(0,0,0,0.3);flex-shrink:0;"></span><span>cena/plocha neuvedena</span></div><div style="display:flex;align-items:center;gap:6px;margin:6px 0 0 0;padding-top:6px;border-top:1px solid #eee;"><span style="width:18px;height:18px;border-radius:50%;background:#66BB6A;display:inline-block;box-shadow:0 1px 4px rgba(0,0,0,0.35);flex-shrink:0;"></span><span>Nové (z dnešního scrapu) — větší</span></div><div style="margin-top:8px;padding-top:6px;border-top:1px solid #eee;font-size:12px;color:#666;">3+kk (42), 3+1 (5), 4+kk (2)</div><div style="display:flex;align-items:center;gap:6px;margin:8px 0 3px 0;padding-top:6px;border-top:1px solid #eee;"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 C2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" fill="#D32F2F"/></svg><span>PSN / CityHome (3)</span></div>
|
|
||||||
<div class="filter-section">
|
|
||||||
<b>Filtry:</b>
|
|
||||||
<div id="floor-filter">
|
|
||||||
<label>Patro od:
|
|
||||||
<select id="min-floor" onchange="applyFilters()">
|
|
||||||
<option value="2">2. NP (vše)</option>
|
|
||||||
<option value="3">3. NP+</option>
|
|
||||||
<option value="4">4. NP+</option>
|
|
||||||
<option value="5">5. NP+</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div style="margin-top:6px;">
|
|
||||||
<label>Max cena:
|
|
||||||
<select id="max-price" onchange="applyFilters()">
|
|
||||||
<option value="13500000">13 500 000 Kč</option>
|
|
||||||
<option value="12000000">12 000 000 Kč</option>
|
|
||||||
<option value="10000000">10 000 000 Kč</option>
|
|
||||||
<option value="8000000">8 000 000 Kč</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="filter-section">
|
|
||||||
<div id="rating-counts" style="margin-bottom:6px;font-size:12px;color:#666;">
|
|
||||||
⭐ 0 oblíbených, 🚫 0 zamítnutých
|
|
||||||
</div>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" id="hide-rejected" onchange="applyFilters()">
|
|
||||||
Skrýt zamítnuté
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="status-link"><a href="status.html">Scraper status</a></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Prague center
|
|
||||||
var map = L.map('map').setView([50.075, 14.437], 12);
|
|
||||||
|
|
||||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png', {
|
|
||||||
attribution: '© OpenStreetMap contributors © CARTO',
|
|
||||||
maxZoom: 19,
|
|
||||||
subdomains: 'abcd',
|
|
||||||
}).addTo(map);
|
|
||||||
|
|
||||||
// Labels as separate layer on top (so markers sit between background and labels)
|
|
||||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_only_labels/{z}/{x}/{y}{r}.png', {
|
|
||||||
maxZoom: 19,
|
|
||||||
subdomains: 'abcd',
|
|
||||||
pane: 'shadowPane',
|
|
||||||
}).addTo(map);
|
|
||||||
|
|
||||||
var allMarkers = [];
|
|
||||||
|
|
||||||
function addMarker(lat, lon, color, popup, hashId) {
|
|
||||||
var marker = L.circleMarker([lat, lon], {
|
|
||||||
radius: 8,
|
|
||||||
fillColor: color,
|
|
||||||
color: '#fff',
|
|
||||||
weight: 2,
|
|
||||||
opacity: 1,
|
|
||||||
fillOpacity: 0.85,
|
|
||||||
}).bindPopup(popup);
|
|
||||||
marker._data = { lat: lat, lon: lon, color: color, hashId: hashId };
|
|
||||||
allMarkers.push(marker);
|
|
||||||
marker.addTo(map);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addNewMarker(lat, lon, color, popup, hashId) {
|
|
||||||
var marker = L.circleMarker([lat, lon], {
|
|
||||||
radius: 12,
|
|
||||||
fillColor: color,
|
|
||||||
color: color,
|
|
||||||
weight: 4,
|
|
||||||
opacity: 0.35,
|
|
||||||
fillOpacity: 0.95,
|
|
||||||
}).bindPopup(popup);
|
|
||||||
marker._data = { lat: lat, lon: lon, color: color, hashId: hashId, isNew: true };
|
|
||||||
allMarkers.push(marker);
|
|
||||||
marker.addTo(map);
|
|
||||||
marker.on('add', function() {
|
|
||||||
if (marker._path) marker._path.classList.add('marker-new');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function heartIcon(color) {
|
|
||||||
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">'
|
|
||||||
+ '<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 '
|
|
||||||
+ 'C2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 '
|
|
||||||
+ '14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 '
|
|
||||||
+ '11.54L12 21.35z" fill="' + color + '" stroke="white" stroke-width="1.5"/></svg>';
|
|
||||||
return L.divIcon({
|
|
||||||
html: svg,
|
|
||||||
className: 'heart-icon',
|
|
||||||
iconSize: [24, 24],
|
|
||||||
iconAnchor: [12, 22],
|
|
||||||
popupAnchor: [0, -18],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function starIcon() {
|
|
||||||
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24">'
|
|
||||||
+ '<path d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.27 '
|
|
||||||
+ '5.82 22 7 14.14 2 9.27l6.91-1.01L12 2z" '
|
|
||||||
+ 'fill="#FFC107" stroke="#F57F17" stroke-width="1" '
|
|
||||||
+ 'filter="drop-shadow(0 1px 3px rgba(0,0,0,0.3))"/></svg>';
|
|
||||||
return L.divIcon({
|
|
||||||
html: svg,
|
|
||||||
className: 'star-icon',
|
|
||||||
iconSize: [28, 28],
|
|
||||||
iconAnchor: [14, 14],
|
|
||||||
popupAnchor: [0, -14],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function addHeartMarker(lat, lon, color, popup, hashId) {
|
|
||||||
var marker = L.marker([lat, lon], {
|
|
||||||
icon: heartIcon(color),
|
|
||||||
}).bindPopup(popup);
|
|
||||||
marker._data = { lat: lat, lon: lon, color: color, hashId: hashId, isHeart: true };
|
|
||||||
allMarkers.push(marker);
|
|
||||||
marker.addTo(map);
|
|
||||||
}
|
|
||||||
|
|
||||||
addHeartMarker(50.0729, 14.4767, '#66BB6A', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="8941"><b style="font-size:14px;">13 994 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#D32F2F;color:white;padding:1px 6px;border-radius:3px;">PSN</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 102.7 m² | 2. NP</span><br><span style="color:#FF9800;font-weight:bold;">⚠ 2. NP — zvážit klidnost lokality</span><br><br><b>Litevská 1174/8, Praha 10</b><br>Stavba: neuvedeno<br>Vlastnictví: osobní<br><br><a href="https://psn.cz/prodej/ubytovaci-jednotka-3-kk-litevska-praha-10-vrsovice-lit4219" target="_blank" style="color:#D32F2F;text-decoration:none;font-weight:bold;">→ Otevřít na PSN</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '8941');
|
|
||||||
addHeartMarker(50.0652858, 14.3931318, '#66BB6A', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="cityhome_na-vaclavce-34_Byt A2.3"><b style="font-size:14px;">13 490 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#D32F2F;color:white;padding:1px 6px;border-radius:3px;">CityHome</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+1 | 99.1 m² | 2. NP</span><br><span style="color:#FF9800;font-weight:bold;">⚠ 2. NP — zvážit klidnost lokality</span><br><br><b>Na Václavce 34, Praha 5</b><br>Stavba: Cihlová<br>Vlastnictví: neuvedeno<br><br><a href="https://www.city-home.cz/projekty/na-vaclavce-34/nabidka-nemovitosti/byt-a23" target="_blank" style="color:#D32F2F;text-decoration:none;font-weight:bold;">→ Otevřít na CityHome</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', 'cityhome_na-vaclavce-34_Byt A2.3');
|
|
||||||
addHeartMarker(50.0652858, 14.3931318, '#66BB6A', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="cityhome_na-vaclavce-34_Byt A3.2"><b style="font-size:14px;">13 490 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#D32F2F;color:white;padding:1px 6px;border-radius:3px;">CityHome</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+1 | 95.6 m² | 3. NP</span><br><br><b>Na Václavce 34, Praha 5</b><br>Stavba: Cihlová<br>Vlastnictví: neuvedeno<br><br><a href="https://www.city-home.cz/projekty/na-vaclavce-34/nabidka-nemovitosti/byt-a32" target="_blank" style="color:#D32F2F;text-decoration:none;font-weight:bold;">→ Otevřít na CityHome</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', 'cityhome_na-vaclavce-34_Byt A3.2');
|
|
||||||
addNewMarker(50.069641, 14.470198, '#66BB6A', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="938877772"><b style="font-size:14px;">12 990 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#1976D2;color:white;padding:1px 6px;border-radius:3px;">Sreality</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 99 m² | 3. NP</span><br><br><b>Čeljabinská, Praha 10 - Vršovice</b><br>Stavba: Smíšená<br>Vlastnictví: Osobní<br><br><a href="https://www.sreality.cz/detail/prodej/byt/3+kk/praha-vrsovice-celjabinska/938877772" target="_blank" style="color:#1976D2;text-decoration:none;font-weight:bold;">→ Otevřít na Sreality</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '938877772');
|
|
||||||
addNewMarker(50.039608, 14.316702, '#42A5F5', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="2036855628"><b style="font-size:14px;">10 490 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#1976D2;color:white;padding:1px 6px;border-radius:3px;">Sreality</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 83 m² | 2. NP</span><br><span style="color:#FF9800;font-weight:bold;">⚠ 2. NP — zvážit klidnost lokality</span><br><br><b>Na Výrovně, Praha 5 - Stodůlky</b><br>Stavba: Cihlová<br>Vlastnictví: Osobní<br><br><a href="https://www.sreality.cz/detail/prodej/byt/3+kk/praha-stodulky-na-vyrovne/2036855628" target="_blank" style="color:#1976D2;text-decoration:none;font-weight:bold;">→ Otevřít na Sreality</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '2036855628');
|
|
||||||
addNewMarker(50.084381, 14.372257, '#EF6C00', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="2148991820"><b style="font-size:14px;">10 990 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#1976D2;color:white;padding:1px 6px;border-radius:3px;">Sreality</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 72 m² | 4. NP</span><br><br><b>Pod Marjánkou, Praha 6 - Břevnov</b><br>Stavba: Cihlová<br>Vlastnictví: Osobní<br><br><a href="https://www.sreality.cz/detail/prodej/byt/3+kk/praha-brevnov-pod-marjankou/2148991820" target="_blank" style="color:#1976D2;text-decoration:none;font-weight:bold;">→ Otevřít na Sreality</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '2148991820');
|
|
||||||
addMarker(50.060715, 14.401836, '#EF6C00', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="3226313292"><b style="font-size:14px;">13 500 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#1976D2;color:white;padding:1px 6px;border-radius:3px;">Sreality</span><br><span style="color:#666;">3+kk | 83 m² | 4. NP</span><br><br><b>Na Neklance, Praha 5 - Smíchov</b><br>Stavba: Cihlová<br>Vlastnictví: Osobní<br><br><a href="https://www.sreality.cz/detail/prodej/byt/3+kk/praha-smichov-na-neklance/3226313292" target="_blank" style="color:#1976D2;text-decoration:none;font-weight:bold;">→ Otevřít na Sreality</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '3226313292');
|
|
||||||
addNewMarker(50.039043, 14.314881, '#66BB6A', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="475530060"><b style="font-size:14px;">12 250 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#1976D2;color:white;padding:1px 6px;border-radius:3px;">Sreality</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 83 m² | 3. NP</span><br><br><b>Radouňova, Praha 5 - Stodůlky</b><br>Stavba: Cihlová<br>Vlastnictví: Osobní<br><br><a href="https://www.sreality.cz/detail/prodej/byt/3+kk/praha-stodulky-radounova/475530060" target="_blank" style="color:#1976D2;text-decoration:none;font-weight:bold;">→ Otevřít na Sreality</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '475530060');
|
|
||||||
addNewMarker(50.100174, 14.492079, '#66BB6A', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="2303799884"><b style="font-size:14px;">12 860 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#1976D2;color:white;padding:1px 6px;border-radius:3px;">Sreality</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 88 m² | 3. NP</span><br><br><b>Spojovací, Praha 9 - Vysočany</b><br>Stavba: Skeletová<br>Vlastnictví: Osobní<br><br><a href="https://www.sreality.cz/detail/prodej/byt/3+kk/praha-vysocany-spojovaci/2303799884" target="_blank" style="color:#1976D2;text-decoration:none;font-weight:bold;">→ Otevřít na Sreality</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '2303799884');
|
|
||||||
addNewMarker(50.125145, 14.507703, '#66BB6A', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="3493290828"><b style="font-size:14px;">11 390 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#1976D2;color:white;padding:1px 6px;border-radius:3px;">Sreality</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 83 m² | 4. NP</span><br><br><b>Kytlická, Praha 9 - Prosek</b><br>Stavba: Skeletová<br>Vlastnictví: Osobní<br><br><a href="https://www.sreality.cz/detail/prodej/byt/3+kk/praha-prosek-kytlicka/3493290828" target="_blank" style="color:#1976D2;text-decoration:none;font-weight:bold;">→ Otevřít na Sreality</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '3493290828');
|
|
||||||
addNewMarker(50.101852, 14.486118, '#66BB6A', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="151528268"><b style="font-size:14px;">11 390 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#1976D2;color:white;padding:1px 6px;border-radius:3px;">Sreality</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 86 m² | 2. NP</span><br><span style="color:#FF9800;font-weight:bold;">⚠ 2. NP — zvážit klidnost lokality</span><br><br><b>Spojovací, Praha</b><br>Stavba: Smíšená<br>Vlastnictví: Osobní<br><br><a href="https://www.sreality.cz/detail/prodej/byt/3+kk/praha--spojovaci/151528268" target="_blank" style="color:#1976D2;text-decoration:none;font-weight:bold;">→ Otevřít na Sreality</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '151528268');
|
|
||||||
addNewMarker(50.071224, 14.407872, '#C62828', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="1837527884"><b style="font-size:14px;">12 790 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#1976D2;color:white;padding:1px 6px;border-radius:3px;">Sreality</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 73 m² | 3. NP</span><br><br><b>Vrázova, Praha - Smíchov</b><br>Stavba: Cihlová<br>Vlastnictví: Osobní<br><br><a href="https://www.sreality.cz/detail/prodej/byt/3+kk/praha-smichov-vrazova/1837527884" target="_blank" style="color:#1976D2;text-decoration:none;font-weight:bold;">→ Otevřít na Sreality</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '1837527884');
|
|
||||||
addNewMarker(50.03138, 14.391757, '#42A5F5', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="3330433868"><b style="font-size:14px;">11 890 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#1976D2;color:white;padding:1px 6px;border-radius:3px;">Sreality</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 93 m² | 2. NP</span><br><span style="color:#FF9800;font-weight:bold;">⚠ 2. NP — zvážit klidnost lokality</span><br><br><b>Kříženeckého náměstí, Praha 5 - Hlubočepy</b><br>Stavba: Cihlová<br>Vlastnictví: Osobní<br><br><a href="https://www.sreality.cz/detail/prodej/byt/3+kk/praha-hlubocepy-krizeneckeho-namesti/3330433868" target="_blank" style="color:#1976D2;text-decoration:none;font-weight:bold;">→ Otevřít na Sreality</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '3330433868');
|
|
||||||
addMarker(50.122192, 14.57646, '#EF6C00', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="2053579340"><b style="font-size:14px;">11 858 981 Kč</b><span style="margin-left:8px;font-size:11px;background:#1976D2;color:white;padding:1px 6px;border-radius:3px;">Sreality</span><br><span style="color:#666;">3+kk | 76 m² | 3. NP</span><br><br><b>Za Novákovou zahradou, Praha - Satalice</b><br>Stavba: Smíšená<br>Vlastnictví: Osobní<br><br><a href="https://www.sreality.cz/detail/prodej/byt/3+kk/praha-satalice-za-novakovou-zahradou/2053579340" target="_blank" style="color:#1976D2;text-decoration:none;font-weight:bold;">→ Otevřít na Sreality</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '2053579340');
|
|
||||||
addNewMarker(50.084606, 14.482681, '#C62828', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="3651539788"><b style="font-size:14px;">13 500 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#1976D2;color:white;padding:1px 6px;border-radius:3px;">Sreality</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 69 m² | 12. NP</span><br><br><b>Zvěřinova, Praha 3 - Strašnice</b><br>Stavba: Smíšená<br>Vlastnictví: Osobní<br><br><a href="https://www.sreality.cz/detail/prodej/byt/3+kk/praha-strasnice-zverinova/3651539788" target="_blank" style="color:#1976D2;text-decoration:none;font-weight:bold;">→ Otevřít na Sreality</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '3651539788');
|
|
||||||
addNewMarker(50.086601, 14.5636, '#42A5F5', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="4005061452"><b style="font-size:14px;">12 875 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#1976D2;color:white;padding:1px 6px;border-radius:3px;">Sreality</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 101 m² | 5. NP</span><br><br><b>U Hostavického potoka, Praha 9 - Hostavice</b><br>Stavba: Cihlová<br>Vlastnictví: Osobní<br><br><a href="https://www.sreality.cz/detail/prodej/byt/3+kk/praha-hostavice-u-hostavickeho-potoka/4005061452" target="_blank" style="color:#1976D2;text-decoration:none;font-weight:bold;">→ Otevřít na Sreality</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '4005061452');
|
|
||||||
addNewMarker(50.004192, 14.355805, '#C62828', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="589460300"><b style="font-size:14px;">13 126 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#1976D2;color:white;padding:1px 6px;border-radius:3px;">Sreality</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 75 m² | 2. NP</span><br><span style="color:#FF9800;font-weight:bold;">⚠ 2. NP — zvážit klidnost lokality</span><br><br><b>Ke Slivenci, Praha - Lochkov</b><br>Stavba: Skeletová<br>Vlastnictví: Osobní<br><br><a href="https://www.sreality.cz/detail/prodej/byt/3+kk/praha-lochkov-ke-slivenci/589460300" target="_blank" style="color:#1976D2;text-decoration:none;font-weight:bold;">→ Otevřít na Sreality</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '589460300');
|
|
||||||
addNewMarker(50.072403, 14.410302, '#EF6C00', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="2926625612"><b style="font-size:14px;">13 499 900 Kč</b><span style="margin-left:8px;font-size:11px;background:#1976D2;color:white;padding:1px 6px;border-radius:3px;">Sreality</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 85 m² | 3. NP</span><br><br><b>Hořejší nábřeží, Praha 5 - Smíchov</b><br>Stavba: Cihlová<br>Vlastnictví: Osobní<br><br><a href="https://www.sreality.cz/detail/prodej/byt/3+kk/praha-smichov-horejsi-nabrezi/2926625612" target="_blank" style="color:#1976D2;text-decoration:none;font-weight:bold;">→ Otevřít na Sreality</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '2926625612');
|
|
||||||
addNewMarker(50.082985, 14.311815, '#EF6C00', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="3672994636"><b style="font-size:14px;">12 390 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#1976D2;color:white;padding:1px 6px;border-radius:3px;">Sreality</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 81 m² | 3. NP</span><br><br><b>Stochovská, Praha 6 - Ruzyně</b><br>Stavba: Smíšená<br>Vlastnictví: Osobní<br><br><a href="https://www.sreality.cz/detail/prodej/byt/3+kk/praha-ruzyne-stochovska/3672994636" target="_blank" style="color:#1976D2;text-decoration:none;font-weight:bold;">→ Otevřít na Sreality</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '3672994636');
|
|
||||||
addNewMarker(50.157696, 14.519159, '#EF6C00', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="4070581580"><b style="font-size:14px;">12 207 113 Kč</b><span style="margin-left:8px;font-size:11px;background:#1976D2;color:white;padding:1px 6px;border-radius:3px;">Sreality</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 77 m² | 2. NP</span><br><span style="color:#FF9800;font-weight:bold;">⚠ 2. NP — zvážit klidnost lokality</span><br><br><b>Marie Podvalové, Praha - Čakovice</b><br>Stavba: Skeletová<br>Vlastnictví: Osobní<br><br><a href="https://www.sreality.cz/detail/prodej/byt/3+kk/praha-cakovice-marie-podvalove/4070581580" target="_blank" style="color:#1976D2;text-decoration:none;font-weight:bold;">→ Otevřít na Sreality</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '4070581580');
|
|
||||||
addNewMarker(50.141739, 14.522086, '#EF6C00', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="2772919116"><b style="font-size:14px;">13 000 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#1976D2;color:white;padding:1px 6px;border-radius:3px;">Sreality</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 81 m² | 5. NP</span><br><br><b>Hlučkova, Praha 9 - Letňany</b><br>Stavba: Smíšená<br>Vlastnictví: Osobní<br><br><a href="https://www.sreality.cz/detail/prodej/byt/3+kk/praha-letnany-hluckova/2772919116" target="_blank" style="color:#1976D2;text-decoration:none;font-weight:bold;">→ Otevřít na Sreality</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '2772919116');
|
|
||||||
addNewMarker(50.13076, 14.423249, '#66BB6A', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="2242032460"><b style="font-size:14px;">12 762 764 Kč</b><span style="margin-left:8px;font-size:11px;background:#1976D2;color:white;padding:1px 6px;border-radius:3px;">Sreality</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 98 m² | 5. NP</span><br><br><b>Lodžská, Praha 8 - Bohnice</b><br>Stavba: Smíšená<br>Vlastnictví: Osobní<br><br><a href="https://www.sreality.cz/detail/prodej/byt/3+kk/praha-bohnice-lodzska/2242032460" target="_blank" style="color:#1976D2;text-decoration:none;font-weight:bold;">→ Otevřít na Sreality</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '2242032460');
|
|
||||||
addNewMarker(50.036095, 14.48035, '#EF6C00', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="3617202764"><b style="font-size:14px;">12 959 520 Kč</b><span style="margin-left:8px;font-size:11px;background:#1976D2;color:white;padding:1px 6px;border-radius:3px;">Sreality</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 79 m² | 3. NP</span><br><br><b>Komárkova, Praha 4 - Chodov</b><br>Stavba: Smíšená<br>Vlastnictví: Osobní<br><br><a href="https://www.sreality.cz/detail/prodej/byt/3+kk/praha-chodov-komarkova/3617202764" target="_blank" style="color:#1976D2;text-decoration:none;font-weight:bold;">→ Otevřít na Sreality</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '3617202764');
|
|
||||||
addNewMarker(50.082317, 14.450463, '#EF6C00', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="2860663372"><b style="font-size:14px;">12 463 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#1976D2;color:white;padding:1px 6px;border-radius:3px;">Sreality</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 78 m² | 4. NP</span><br><br><b>Kubelíkova, Praha</b><br>Stavba: Cihlová<br>Vlastnictví: Osobní<br><br><a href="https://www.sreality.cz/detail/prodej/byt/3+kk/praha--kubelikova/2860663372" target="_blank" style="color:#1976D2;text-decoration:none;font-weight:bold;">→ Otevřít na Sreality</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '2860663372');
|
|
||||||
addNewMarker(50.157696, 14.519159, '#66BB6A', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="158065228"><b style="font-size:14px;">10 947 779 Kč</b><span style="margin-left:8px;font-size:11px;background:#1976D2;color:white;padding:1px 6px;border-radius:3px;">Sreality</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 73 m² | 4. NP</span><br><br><b>Marie Podvalové, Praha - Čakovice</b><br>Stavba: Skeletová<br>Vlastnictví: Osobní<br><br><a href="https://www.sreality.cz/detail/prodej/byt/3+kk/praha-cakovice-marie-podvalove/158065228" target="_blank" style="color:#1976D2;text-decoration:none;font-weight:bold;">→ Otevřít na Sreality</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '158065228');
|
|
||||||
addNewMarker(50.065208, 14.450711, '#66BB6A', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="4227625804"><b style="font-size:14px;">10 990 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#1976D2;color:white;padding:1px 6px;border-radius:3px;">Sreality</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 81 m² | 5. NP</span><br><br><b>Ukrajinská, Praha 10 - Vršovice</b><br>Stavba: Cihlová<br>Vlastnictví: Osobní<br><br><a href="https://www.sreality.cz/detail/prodej/byt/3+kk/praha-vrsovice-ukrajinska/4227625804" target="_blank" style="color:#1976D2;text-decoration:none;font-weight:bold;">→ Otevřít na Sreality</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '4227625804');
|
|
||||||
addNewMarker(50.106956, 14.510207, '#1565C0', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="1313456972"><b style="font-size:14px;">3 641 309 Kč</b><span style="margin-left:8px;font-size:11px;background:#1976D2;color:white;padding:1px 6px;border-radius:3px;">Sreality</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 87 m² | 5. NP</span><br><br><b>Praha 9</b><br>Stavba: Smíšená<br>Vlastnictví: Družstevní<br><br><a href="https://www.sreality.cz/detail/prodej/byt/3+kk/praha-praha-9-/1313456972" target="_blank" style="color:#1976D2;text-decoration:none;font-weight:bold;">→ Otevřít na Sreality</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '1313456972');
|
|
||||||
addNewMarker(50.157696, 14.519159, '#EF6C00', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="1671439692"><b style="font-size:14px;">12 556 524 Kč</b><span style="margin-left:8px;font-size:11px;background:#1976D2;color:white;padding:1px 6px;border-radius:3px;">Sreality</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 77 m² | 3. NP</span><br><br><b>Marie Podvalové, Praha - Čakovice</b><br>Stavba: Skeletová<br>Vlastnictví: Osobní<br><br><a href="https://www.sreality.cz/detail/prodej/byt/3+kk/praha-cakovice-marie-podvalove/1671439692" target="_blank" style="color:#1976D2;text-decoration:none;font-weight:bold;">→ Otevřít na Sreality</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '1671439692');
|
|
||||||
addNewMarker(50.04636, 14.310556, '#C62828', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="576226124"><b style="font-size:14px;">12 026 912 Kč</b><span style="margin-left:8px;font-size:11px;background:#1976D2;color:white;padding:1px 6px;border-radius:3px;">Sreality</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 71 m² | 3. NP</span><br><br><b>Hábova, Praha 5 - Stodůlky</b><br>Stavba: Smíšená<br>Vlastnictví: Osobní<br><br><a href="https://www.sreality.cz/detail/prodej/byt/3+kk/praha-stodulky-habova/576226124" target="_blank" style="color:#1976D2;text-decoration:none;font-weight:bold;">→ Otevřít na Sreality</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '576226124');
|
|
||||||
addNewMarker(50.04636, 14.310556, '#C62828', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="473465676"><b style="font-size:14px;">12 349 349 Kč</b><span style="margin-left:8px;font-size:11px;background:#1976D2;color:white;padding:1px 6px;border-radius:3px;">Sreality</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 73 m² | 3. NP</span><br><br><b>Hábova, Praha 5 - Stodůlky</b><br>Stavba: Smíšená<br>Vlastnictví: Osobní<br><br><a href="https://www.sreality.cz/detail/prodej/byt/3+kk/praha-stodulky-habova/473465676" target="_blank" style="color:#1976D2;text-decoration:none;font-weight:bold;">→ Otevřít na Sreality</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '473465676');
|
|
||||||
addNewMarker(50.074284, 14.405826, '#EF6C00', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="2185458508"><b style="font-size:14px;">11 978 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#1976D2;color:white;padding:1px 6px;border-radius:3px;">Sreality</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 76 m² | 2. NP</span><br><span style="color:#FF9800;font-weight:bold;">⚠ 2. NP — zvážit klidnost lokality</span><br><br><b>Matoušova, Praha - Smíchov</b><br>Stavba: Cihlová<br>Vlastnictví: Osobní<br><br><a href="https://www.sreality.cz/detail/prodej/byt/3+kk/praha-smichov-matousova/2185458508" target="_blank" style="color:#1976D2;text-decoration:none;font-weight:bold;">→ Otevřít na Sreality</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '2185458508');
|
|
||||||
addNewMarker(50.053101, 14.507191, '#EF6C00', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="3988325196"><b style="font-size:14px;">13 190 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#1976D2;color:white;padding:1px 6px;border-radius:3px;">Sreality</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 83 m² | 2. NP</span><br><span style="color:#FF9800;font-weight:bold;">⚠ 2. NP — zvážit klidnost lokality</span><br><br><b>Práčská, Praha</b><br>Stavba: Kamenná<br>Vlastnictví: Osobní<br><br><a href="https://www.sreality.cz/detail/prodej/byt/3+kk/praha--pracska/3988325196" target="_blank" style="color:#1976D2;text-decoration:none;font-weight:bold;">→ Otevřít na Sreality</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '3988325196');
|
|
||||||
addNewMarker(50.13237, 14.53639, '#66BB6A', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="3019572044"><b style="font-size:14px;">10 790 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#1976D2;color:white;padding:1px 6px;border-radius:3px;">Sreality</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 76 m² | 3. NP</span><br><br><b>Plzákova, Praha - Kbely</b><br>Stavba: Cihlová<br>Vlastnictví: Osobní<br><br><a href="https://www.sreality.cz/detail/prodej/byt/3+kk/praha-kbely-plzakova/3019572044" target="_blank" style="color:#1976D2;text-decoration:none;font-weight:bold;">→ Otevřít na Sreality</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '3019572044');
|
|
||||||
addNewMarker(50.072536, 14.476557, '#42A5F5', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="3704697676"><b style="font-size:14px;">8 000 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#1976D2;color:white;padding:1px 6px;border-radius:3px;">Sreality</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 72 m² | 5. NP</span><br><br><b>Litevská, Praha 10 - Vršovice</b><br>Stavba: Cihlová<br>Vlastnictví: Družstevní<br><br><a href="https://www.sreality.cz/detail/prodej/byt/3+kk/praha-vrsovice-litevska/3704697676" target="_blank" style="color:#1976D2;text-decoration:none;font-weight:bold;">→ Otevřít na Sreality</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '3704697676');
|
|
||||||
addNewMarker(50.106956, 14.510207, '#C62828', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="1137623884"><b style="font-size:14px;">12 318 349 Kč</b><span style="margin-left:8px;font-size:11px;background:#1976D2;color:white;padding:1px 6px;border-radius:3px;">Sreality</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 71 m² | 4. NP</span><br><br><b>Praha 9</b><br>Stavba: Skeletová<br>Vlastnictví: Osobní<br><br><a href="https://www.sreality.cz/detail/prodej/byt/3+kk/praha-praha-9-/1137623884" target="_blank" style="color:#1976D2;text-decoration:none;font-weight:bold;">→ Otevřít na Sreality</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '1137623884');
|
|
||||||
addNewMarker(50.142303781599, 14.522362316941, '#EF6C00', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="24515884"><b style="font-size:14px;">13 000 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#00897B;color:white;padding:1px 6px;border-radius:3px;">Realingo</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 81 m² | 5. NP</span><br><br><b>Hlučkova 869, Praha</b><br>Stavba: OTHER<br>Vlastnictví: Osobní<br><br><a href="https://www.realingo.cz/prodej/byt-3+kk-hluckova-869-praha/24515884" target="_blank" style="color:#00897B;text-decoration:none;font-weight:bold;">→ Otevřít na Realingo</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '24515884');
|
|
||||||
addNewMarker(50.106598, 14.506245, '#9E9E9E', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="24515669"><b style="font-size:14px;">8 487 297 Kč</b><span style="margin-left:8px;font-size:11px;background:#00897B;color:white;padding:1px 6px;border-radius:3px;">Realingo</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">Atypický | neuvedeno | neuvedeno</span><br><br><b>Praha, 190 00</b><br>Stavba: neuvedeno<br>Vlastnictví: neuvedeno<br><br><a href="https://www.realingo.cz/prodej/byt-ostatni-byty-praha-190-00/24515669" target="_blank" style="color:#00897B;text-decoration:none;font-weight:bold;">→ Otevřít na Realingo</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '24515669');
|
|
||||||
addNewMarker(50.087602, 14.470882, '#9E9E9E', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="24515653"><b style="font-size:14px;">8 890 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#00897B;color:white;padding:1px 6px;border-radius:3px;">Realingo</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">Atypický | neuvedeno | neuvedeno</span><br><br><b>Praha, 130 00</b><br>Stavba: neuvedeno<br>Vlastnictví: neuvedeno<br><br><a href="https://www.realingo.cz/prodej/byt-ostatni-byty-praha-130-00/24515653" target="_blank" style="color:#00897B;text-decoration:none;font-weight:bold;">→ Otevřít na Realingo</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '24515653');
|
|
||||||
addNewMarker(50.045786, 14.470711, '#9E9E9E', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="24515514"><b style="font-size:14px;">7 490 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#00897B;color:white;padding:1px 6px;border-radius:3px;">Realingo</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">Atypický | neuvedeno | neuvedeno</span><br><br><b>Praha, 141 00</b><br>Stavba: neuvedeno<br>Vlastnictví: neuvedeno<br><br><a href="https://www.realingo.cz/prodej/byt-ostatni-byty-praha-141-00/24515514" target="_blank" style="color:#00897B;text-decoration:none;font-weight:bold;">→ Otevřít na Realingo</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '24515514');
|
|
||||||
addNewMarker(50.076449, 14.435263, '#9E9E9E', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="24514922"><b style="font-size:14px;">12 132 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#00897B;color:white;padding:1px 6px;border-radius:3px;">Realingo</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">Atypický | neuvedeno | neuvedeno</span><br><br><b>Praha, 120 00</b><br>Stavba: neuvedeno<br>Vlastnictví: neuvedeno<br><br><a href="https://www.realingo.cz/prodej/byt-2+kk-slezska-praha/24514922" target="_blank" style="color:#00897B;text-decoration:none;font-weight:bold;">→ Otevřít na Realingo</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '24514922');
|
|
||||||
addNewMarker(50.074273, 14.493284, '#9E9E9E', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="24514813"><b style="font-size:14px;">8 490 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#00897B;color:white;padding:1px 6px;border-radius:3px;">Realingo</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">Atypický | neuvedeno | neuvedeno</span><br><br><b>Praha, 100 00</b><br>Stavba: neuvedeno<br>Vlastnictví: neuvedeno<br><br><a href="https://www.realingo.cz/prodej/byt-ostatni-byty-praha-100-00/24514813" target="_blank" style="color:#00897B;text-decoration:none;font-weight:bold;">→ Otevřít na Realingo</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '24514813');
|
|
||||||
addNewMarker(50.010056, 14.353809, '#9E9E9E', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="24514769"><b style="font-size:14px;">6 980 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#00897B;color:white;padding:1px 6px;border-radius:3px;">Realingo</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">Atypický | neuvedeno | neuvedeno</span><br><br><b>Praha, 154 00</b><br>Stavba: neuvedeno<br>Vlastnictví: neuvedeno<br><br><a href="https://www.realingo.cz/prodej/byt-ostatni-byty-praha-154-00/24514769" target="_blank" style="color:#00897B;text-decoration:none;font-weight:bold;">→ Otevřít na Realingo</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '24514769');
|
|
||||||
addNewMarker(50.030571, 14.308491, '#9E9E9E', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="24514708"><b style="font-size:14px;">5 362 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#00897B;color:white;padding:1px 6px;border-radius:3px;">Realingo</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">Atypický | neuvedeno | neuvedeno</span><br><br><b>Praha, 155 00</b><br>Stavba: neuvedeno<br>Vlastnictví: neuvedeno<br><br><a href="https://www.realingo.cz/prodej/byt-ostatni-byty-praha-155-00/24514708" target="_blank" style="color:#00897B;text-decoration:none;font-weight:bold;">→ Otevřít na Realingo</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '24514708');
|
|
||||||
addMarker(50.1026043, 14.4435365, '#C62828', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="981278"><b style="font-size:14px;">11 890 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#E91E63;color:white;padding:1px 6px;border-radius:3px;">Bezrealitky</span><br><span style="color:#666;">3+kk | 70 m² | 3. NP</span><br><br><b>Argentinská, Praha - Holešovice</b><br>Stavba: Cihlová<br>Vlastnictví: Osobní<br><br><a href="https://www.bezrealitky.cz/nemovitosti-byty-domy/981278-nabidka-prodej-bytu-argentinska-praha" target="_blank" style="color:#E91E63;text-decoration:none;font-weight:bold;">→ Otevřít na Bezrealitky</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '981278');
|
|
||||||
addMarker(50.1113213, 14.5106858, '#EF6C00', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="991217"><b style="font-size:14px;">11 490 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#E91E63;color:white;padding:1px 6px;border-radius:3px;">Bezrealitky</span><br><span style="color:#666;">3+kk | 71 m² | 3. NP</span><br><br><b>Kolbenova, Praha - Vysočany</b><br>Stavba: Cihlová<br>Vlastnictví: Osobní<br><br><a href="https://www.bezrealitky.cz/nemovitosti-byty-domy/991217-nabidka-prodej-bytu-kolbenova-praha" target="_blank" style="color:#E91E63;text-decoration:none;font-weight:bold;">→ Otevřít na Bezrealitky</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '991217');
|
|
||||||
addNewMarker(50.049168412058556, 14.302095927878957, '#1565C0', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="699ed0af74468ff4c2079aa1"><b style="font-size:14px;">4 600 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#FF6F00;color:white;padding:1px 6px;border-radius:3px;">iDNES</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+1 | 86 m² | 8. NP</span><br><br><b>Hynka Puce, Praha 5 - Stodůlky</b><br>Stavba: Cihlová<br>Vlastnictví: družstevní<br><br><a href="https://reality.idnes.cz/detail/prodej/byt/praha-13-hynka-puce/699ed0af74468ff4c2079aa1/" target="_blank" style="color:#FF6F00;text-decoration:none;font-weight:bold;">→ Otevřít na iDNES</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '699ed0af74468ff4c2079aa1');
|
|
||||||
addNewMarker(50.009743674736, 14.460835345662, '#66BB6A', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="693690e98418631b48025208"><b style="font-size:14px;">11 323 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#FF6F00;color:white;padding:1px 6px;border-radius:3px;">iDNES</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+1 | 87 m² | 2. NP</span><br><span style="color:#FF9800;font-weight:bold;">⚠ 2. NP — zvážit klidnost lokality</span><br><br><b>Libušská, Praha 4 - Libuš</b><br>Stavba: Cihlová<br>Vlastnictví: družstevní<br><br><a href="https://reality.idnes.cz/detail/prodej/byt/praha-12-libusska/693690e98418631b48025208/" target="_blank" style="color:#FF6F00;text-decoration:none;font-weight:bold;">→ Otevřít na iDNES</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '693690e98418631b48025208');
|
|
||||||
addNewMarker(50.0652882346, 14.3931192571, '#66BB6A', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="699487a84abe8029bd065570"><b style="font-size:14px;">13 490 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#FF6F00;color:white;padding:1px 6px;border-radius:3px;">iDNES</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+1 | 96 m² | 4. NP</span><br><br><b>Na Václavce, Praha 5 - Smíchov</b><br>Stavba: Cihlová<br>Vlastnictví: osobní<br><br><a href="https://reality.idnes.cz/detail/prodej/byt/praha-5-na-vaclavce/699487a84abe8029bd065570/" target="_blank" style="color:#FF6F00;text-decoration:none;font-weight:bold;">→ Otevřít na iDNES</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '699487a84abe8029bd065570');
|
|
||||||
addNewMarker(50.04710645755815, 14.473057214055794, '#EF6C00', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="697c7e54d08e16f19902d777"><b style="font-size:14px;">11 590 040 Kč</b><span style="margin-left:8px;font-size:11px;background:#FF6F00;color:white;padding:1px 6px;border-radius:3px;">iDNES</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 76 m² | 5. NP</span><br><br><b>Žilinská, Praha 4 - Záběhlice</b><br>Stavba: Cihlová<br>Vlastnictví: osobní<br><br><a href="https://reality.idnes.cz/detail/prodej/byt/praha-4-zilinska/697c7e54d08e16f19902d777/" target="_blank" style="color:#FF6F00;text-decoration:none;font-weight:bold;">→ Otevřít na iDNES</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '697c7e54d08e16f19902d777');
|
|
||||||
addNewMarker(50.0579944444, 14.4682905556, '#66BB6A', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="6941cf632ff10124be08ce19"><b style="font-size:14px;">13 249 900 Kč</b><span style="margin-left:8px;font-size:11px;background:#FF6F00;color:white;padding:1px 6px;border-radius:3px;">iDNES</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">4+kk | 94 m² | 14. NP</span><br><br><b>V dolině, Praha 10 - Michle</b><br>Stavba: Cihlová<br>Vlastnictví: osobní<br><br><a href="https://reality.idnes.cz/detail/prodej/byt/praha-10-v-doline/6941cf632ff10124be08ce19/" target="_blank" style="color:#FF6F00;text-decoration:none;font-weight:bold;">→ Otevřít na iDNES</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '6941cf632ff10124be08ce19');
|
|
||||||
addNewMarker(50.0290438889, 14.3641566667, '#66BB6A', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="690c2cba1c264f9f43027912"><b style="font-size:14px;">10 631 123 Kč</b><span style="margin-left:8px;font-size:11px;background:#FF6F00;color:white;padding:1px 6px;border-radius:3px;">iDNES</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 74 m² | 6. NP</span><br><br><b>Voskovcova, Praha 5 - Hlubočepy</b><br>Stavba: Cihlová<br>Vlastnictví: osobní<br><br><a href="https://reality.idnes.cz/detail/prodej/byt/praha-5-voskovcova/690c2cba1c264f9f43027912/" target="_blank" style="color:#FF6F00;text-decoration:none;font-weight:bold;">→ Otevřít na iDNES</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '690c2cba1c264f9f43027912');
|
|
||||||
addNewMarker(50.026899, 14.613713, '#EF6C00', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="68404b3d8178bbed020f1742"><b style="font-size:14px;">10 990 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#FF6F00;color:white;padding:1px 6px;border-radius:3px;">iDNES</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 71 m² | 5. NP</span><br><br><b>Praha 10 - Uhříněves</b><br>Stavba: Skeletová<br>Vlastnictví: osobní<br><br><a href="https://reality.idnes.cz/detail/prodej/byt/praha-22/68404b3d8178bbed020f1742/" target="_blank" style="color:#FF6F00;text-decoration:none;font-weight:bold;">→ Otevřít na iDNES</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '68404b3d8178bbed020f1742');
|
|
||||||
addNewMarker(50.1297302, 14.4286652, '#42A5F5', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="6932bf9dc9442dc194054416"><b style="font-size:14px;">8 100 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#FF6F00;color:white;padding:1px 6px;border-radius:3px;">iDNES</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 71 m² | 5. NP</span><br><br><b>Štětínská, Praha 8 - Bohnice, okres Praha</b><br>Stavba: 1974<br>Vlastnictví: osobní<br><br><a href="https://reality.idnes.cz/detail/prodej/byt/praha-8-stetinska/6932bf9dc9442dc194054416/" target="_blank" style="color:#FF6F00;text-decoration:none;font-weight:bold;">→ Otevřít na iDNES</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '6932bf9dc9442dc194054416');
|
|
||||||
addNewMarker(50.030382258, 14.5931238354, '#66BB6A', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="68f0b7b4263df471cb050df9"><b style="font-size:14px;">10 363 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#FF6F00;color:white;padding:1px 6px;border-radius:3px;">iDNES</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">4+kk | 75 m² | 4. NP</span><br><br><b>Karla Guta, Praha 10 - Uhříněves</b><br>Stavba: Cihlová<br>Vlastnictví: osobní<br><br><a href="https://reality.idnes.cz/detail/prodej/byt/praha-22-karla-guta/68f0b7b4263df471cb050df9/" target="_blank" style="color:#FF6F00;text-decoration:none;font-weight:bold;">→ Otevřít na iDNES</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '68f0b7b4263df471cb050df9');
|
|
||||||
addNewMarker(50.132835725, 14.5613326001, '#66BB6A', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="695cdf5113e97880200d9e62"><b style="font-size:14px;">11 133 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#FF6F00;color:white;padding:1px 6px;border-radius:3px;">iDNES</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 82 m² | 3. NP</span><br><br><b>K Vinoři, Praha 9 - Kbely</b><br>Stavba: 2026<br>Vlastnictví: osobní<br><br><a href="https://reality.idnes.cz/detail/prodej/byt/praha-19-k-vinori/695cdf5113e97880200d9e62/" target="_blank" style="color:#FF6F00;text-decoration:none;font-weight:bold;">→ Otevřít na iDNES</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '695cdf5113e97880200d9e62');
|
|
||||||
addNewMarker(50.0114383, 14.5469, '#42A5F5', '<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="69930de7098209b20e066a6c"><b style="font-size:14px;">11 000 000 Kč</b><span style="margin-left:8px;font-size:11px;background:#FF6F00;color:white;padding:1px 6px;border-radius:3px;">iDNES</span><span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span><br><span style="color:#666;">3+kk | 91 m² | 3. NP</span><br><br><b>Formanská, Praha 4 - Újezd u Průhonic, okres Praha</b><br>Stavba: 2017<br>Vlastnictví: osobní<br><br><a href="https://reality.idnes.cz/detail/prodej/byt/praha-11-formanska/69930de7098209b20e066a6c/" target="_blank" style="color:#FF6F00;text-decoration:none;font-weight:bold;">→ Otevřít na iDNES</a><div style="margin-top:10px;padding-top:8px;border-top:1px solid #eee;"><div style="display:flex;gap:6px;align-items:center;"><button class="rate-btn fav-btn" data-action="fav" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">⭐</button><button class="rate-btn rej-btn" data-action="reject" style="padding:4px 12px;border:1px solid #ccc;border-radius:4px;cursor:pointer;font-size:16px;background:#fff;">🚫</button><span class="rating-status" style="margin-left:6px;font-size:12px;color:#999;"></span></div><textarea class="rating-note" placeholder="Poznámka..." style="width:100%;margin-top:6px;padding:4px;border:1px solid #ddd;border-radius:4px;font-size:12px;resize:vertical;min-height:32px;display:none;font-family:system-ui,sans-serif;"></textarea></div></div>', '69930de7098209b20e066a6c');
|
|
||||||
|
|
||||||
|
|
||||||
// ── Rating system ──────────────────────────────────────────────
|
|
||||||
var RATINGS_KEY = 'byty_ratings';
|
|
||||||
|
|
||||||
function loadRatings() {
|
|
||||||
try {
|
|
||||||
var data = localStorage.getItem(RATINGS_KEY);
|
|
||||||
return data ? JSON.parse(data) : {};
|
|
||||||
} catch(e) { return {}; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveRatings(ratings) {
|
|
||||||
localStorage.setItem(RATINGS_KEY, JSON.stringify(ratings));
|
|
||||||
fetch('/api/ratings', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(ratings)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function addRejectStrike(marker) {
|
|
||||||
removeRejectStrike(marker);
|
|
||||||
var color = marker._data.color || '#999';
|
|
||||||
// SVG "no entry" icon — circle with diagonal line, colored to match marker
|
|
||||||
var svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20">'
|
|
||||||
+ '<circle cx="12" cy="12" r="10" fill="none" stroke="' + color + '" stroke-width="2.5" opacity="0.85"/>'
|
|
||||||
+ '<line x1="5.5" y1="5.5" x2="18.5" y2="18.5" stroke="' + color + '" stroke-width="2.5" stroke-linecap="round" opacity="0.85"/>'
|
|
||||||
+ '</svg>';
|
|
||||||
var icon = L.divIcon({
|
|
||||||
className: 'reject-overlay',
|
|
||||||
html: svg,
|
|
||||||
iconSize: [20, 20],
|
|
||||||
iconAnchor: [10, 10],
|
|
||||||
});
|
|
||||||
var m = L.marker([marker._data.lat, marker._data.lon], {
|
|
||||||
icon: icon,
|
|
||||||
interactive: false,
|
|
||||||
pane: 'markerPane',
|
|
||||||
});
|
|
||||||
m.addTo(map);
|
|
||||||
marker._rejectStrike = m;
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeRejectStrike(marker) {
|
|
||||||
if (marker._rejectStrike) {
|
|
||||||
map.removeLayer(marker._rejectStrike);
|
|
||||||
marker._rejectStrike = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyMarkerStyle(marker, status) {
|
|
||||||
if (marker._data.isHeart) {
|
|
||||||
var el = marker._icon;
|
|
||||||
if (!el) return;
|
|
||||||
el.classList.remove('heart-icon-fav', 'heart-icon-rej');
|
|
||||||
el.style.transform = el.style.transform.replace(/scale\([^)]*\)/, '');
|
|
||||||
if (status === 'fav') {
|
|
||||||
el.classList.add('heart-icon-fav');
|
|
||||||
el.style.transform += ' scale(1.3)';
|
|
||||||
} else if (status === 'reject') {
|
|
||||||
el.classList.add('heart-icon-rej');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (status === 'fav') {
|
|
||||||
removeRejectStrike(marker);
|
|
||||||
if (!marker._data._origCircle) marker._data._origCircle = true;
|
|
||||||
var popup = marker.getPopup();
|
|
||||||
var popupContent = popup ? popup.getContent() : '';
|
|
||||||
var wasOnMap = map.hasLayer(marker);
|
|
||||||
if (wasOnMap) map.removeLayer(marker);
|
|
||||||
var starMarker = L.marker([marker._data.lat, marker._data.lon], {
|
|
||||||
icon: starIcon(),
|
|
||||||
}).bindPopup(popupContent);
|
|
||||||
starMarker._data = marker._data;
|
|
||||||
var idx = allMarkers.indexOf(marker);
|
|
||||||
if (idx !== -1) allMarkers[idx] = starMarker;
|
|
||||||
if (wasOnMap) starMarker.addTo(map);
|
|
||||||
} else if (status === 'reject') {
|
|
||||||
if (marker._data._origCircle && !(marker instanceof L.CircleMarker)) {
|
|
||||||
revertToCircle(marker, { radius: 6, fillOpacity: 0.35, fillColor: marker._data.color, color: '#fff', weight: 1 });
|
|
||||||
} else {
|
|
||||||
marker.setStyle({
|
|
||||||
radius: 6, fillOpacity: 0.35, fillColor: marker._data.color, color: '#fff', weight: 1,
|
|
||||||
});
|
|
||||||
if (marker._path) marker._path.classList.remove('marker-favorite');
|
|
||||||
}
|
|
||||||
// Add strikethrough line over the marker
|
|
||||||
addRejectStrike(marker);
|
|
||||||
} else {
|
|
||||||
if (marker._data._origCircle && !(marker instanceof L.CircleMarker)) {
|
|
||||||
revertToCircle(marker, { radius: 8, fillColor: marker._data.color, color: '#fff', weight: 2, fillOpacity: 0.85 });
|
|
||||||
} else {
|
|
||||||
marker.setStyle({
|
|
||||||
radius: 8, fillColor: marker._data.color, color: '#fff',
|
|
||||||
weight: 2, fillOpacity: 0.85,
|
|
||||||
});
|
|
||||||
if (marker._path) marker._path.classList.remove('marker-favorite');
|
|
||||||
}
|
|
||||||
if (marker._path) marker._path.classList.remove('marker-rejected');
|
|
||||||
removeRejectStrike(marker);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function revertToCircle(marker, style) {
|
|
||||||
var popup = marker.getPopup();
|
|
||||||
var popupContent = popup ? popup.getContent() : '';
|
|
||||||
var wasOnMap = map.hasLayer(marker);
|
|
||||||
if (wasOnMap) map.removeLayer(marker);
|
|
||||||
var cm = L.circleMarker([marker._data.lat, marker._data.lon], style).bindPopup(popupContent);
|
|
||||||
cm._data = marker._data;
|
|
||||||
delete cm._data._starRef;
|
|
||||||
var idx = allMarkers.indexOf(marker);
|
|
||||||
if (idx !== -1) allMarkers[idx] = cm;
|
|
||||||
if (wasOnMap) cm.addTo(map);
|
|
||||||
}
|
|
||||||
|
|
||||||
function rateMarker(marker, action) {
|
|
||||||
var hashId = marker._data.hashId;
|
|
||||||
var ratings = loadRatings();
|
|
||||||
var current = ratings[hashId];
|
|
||||||
if (current && current.status === action) {
|
|
||||||
delete ratings[hashId];
|
|
||||||
saveRatings(ratings);
|
|
||||||
applyMarkerStyle(marker, null);
|
|
||||||
updateRatingCounts();
|
|
||||||
applyFilters();
|
|
||||||
return null;
|
|
||||||
} else {
|
|
||||||
var note = (current && current.note) || '';
|
|
||||||
ratings[hashId] = { status: action, note: note };
|
|
||||||
saveRatings(ratings);
|
|
||||||
applyMarkerStyle(marker, action);
|
|
||||||
updateRatingCounts();
|
|
||||||
applyFilters();
|
|
||||||
return action;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setNote(hashId, note) {
|
|
||||||
var ratings = loadRatings();
|
|
||||||
if (ratings[hashId]) {
|
|
||||||
ratings[hashId].note = note;
|
|
||||||
saveRatings(ratings);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateRatingCounts() {
|
|
||||||
var ratings = loadRatings();
|
|
||||||
var favCount = 0, rejCount = 0;
|
|
||||||
for (var id in ratings) {
|
|
||||||
if (ratings[id].status === 'fav') favCount++;
|
|
||||||
if (ratings[id].status === 'reject') rejCount++;
|
|
||||||
}
|
|
||||||
var el = document.getElementById('rating-counts');
|
|
||||||
if (el) el.innerHTML = '⭐ ' + favCount + ' oblíbených, 🚫 ' + rejCount + ' zamítnutých';
|
|
||||||
}
|
|
||||||
|
|
||||||
function restoreRatings() {
|
|
||||||
var ratings = loadRatings();
|
|
||||||
allMarkers.forEach(function(m) {
|
|
||||||
var r = ratings[m._data.hashId];
|
|
||||||
if (r) applyMarkerStyle(m, r.status);
|
|
||||||
});
|
|
||||||
updateRatingCounts();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Popup rating handler ───────────────────────────────────────
|
|
||||||
map.on('popupopen', function(e) {
|
|
||||||
var container = e.popup.getElement();
|
|
||||||
if (!container) return;
|
|
||||||
var wrapper = container.querySelector('[data-hashid]');
|
|
||||||
if (!wrapper) return;
|
|
||||||
var hashId = wrapper.getAttribute('data-hashid');
|
|
||||||
|
|
||||||
var marker = null;
|
|
||||||
for (var i = 0; i < allMarkers.length; i++) {
|
|
||||||
if (String(allMarkers[i]._data.hashId) === String(hashId)) {
|
|
||||||
marker = allMarkers[i]; break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!marker) return;
|
|
||||||
|
|
||||||
var ratings = loadRatings();
|
|
||||||
var current = ratings[hashId];
|
|
||||||
var favBtn = container.querySelector('.fav-btn');
|
|
||||||
var rejBtn = container.querySelector('.rej-btn');
|
|
||||||
var statusEl = container.querySelector('.rating-status');
|
|
||||||
var noteEl = container.querySelector('.rating-note');
|
|
||||||
|
|
||||||
// Restore state in popup
|
|
||||||
favBtn.classList.remove('active-fav');
|
|
||||||
rejBtn.classList.remove('active-rej');
|
|
||||||
statusEl.textContent = '';
|
|
||||||
noteEl.style.display = 'none';
|
|
||||||
noteEl.value = '';
|
|
||||||
|
|
||||||
if (current) {
|
|
||||||
if (current.status === 'fav') {
|
|
||||||
favBtn.classList.add('active-fav');
|
|
||||||
statusEl.textContent = 'Oblíbený';
|
|
||||||
statusEl.style.color = '#F9A825';
|
|
||||||
} else if (current.status === 'reject') {
|
|
||||||
rejBtn.classList.add('active-rej');
|
|
||||||
statusEl.textContent = 'Zamítnutý';
|
|
||||||
statusEl.style.color = '#E53935';
|
|
||||||
}
|
|
||||||
noteEl.style.display = 'block';
|
|
||||||
noteEl.value = current.note || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
favBtn.onclick = function() {
|
|
||||||
var result = rateMarker(marker, 'fav');
|
|
||||||
favBtn.classList.remove('active-fav');
|
|
||||||
rejBtn.classList.remove('active-rej');
|
|
||||||
if (result === 'fav') {
|
|
||||||
favBtn.classList.add('active-fav');
|
|
||||||
statusEl.textContent = 'Oblíbený';
|
|
||||||
statusEl.style.color = '#F9A825';
|
|
||||||
noteEl.style.display = 'block';
|
|
||||||
} else {
|
|
||||||
statusEl.textContent = '';
|
|
||||||
noteEl.style.display = 'none';
|
|
||||||
noteEl.value = '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
rejBtn.onclick = function() {
|
|
||||||
var result = rateMarker(marker, 'reject');
|
|
||||||
favBtn.classList.remove('active-fav');
|
|
||||||
rejBtn.classList.remove('active-rej');
|
|
||||||
if (result === 'reject') {
|
|
||||||
rejBtn.classList.add('active-rej');
|
|
||||||
statusEl.textContent = 'Zamítnutý';
|
|
||||||
statusEl.style.color = '#E53935';
|
|
||||||
noteEl.style.display = 'block';
|
|
||||||
} else {
|
|
||||||
statusEl.textContent = '';
|
|
||||||
noteEl.style.display = 'none';
|
|
||||||
noteEl.value = '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var noteTimer = null;
|
|
||||||
noteEl.oninput = function() {
|
|
||||||
clearTimeout(noteTimer);
|
|
||||||
noteTimer = setTimeout(function() {
|
|
||||||
setNote(hashId, noteEl.value);
|
|
||||||
}, 500);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Filters ────────────────────────────────────────────────────
|
|
||||||
function applyFilters() {
|
|
||||||
var minFloor = parseInt(document.getElementById('min-floor').value);
|
|
||||||
var maxPrice = parseInt(document.getElementById('max-price').value);
|
|
||||||
var hideRejected = document.getElementById('hide-rejected').checked;
|
|
||||||
var ratings = loadRatings();
|
|
||||||
var visible = 0;
|
|
||||||
|
|
||||||
allMarkers.forEach(function(m) {
|
|
||||||
var popup = m.getPopup().getContent();
|
|
||||||
var floorMatch = popup.match(/(\d+)\. NP/);
|
|
||||||
var priceMatch = popup.match(/([\d\s]+)\sKč/);
|
|
||||||
|
|
||||||
var floor = floorMatch ? parseInt(floorMatch[1]) : null;
|
|
||||||
var price = priceMatch ? parseInt(priceMatch[1].replace(/\s/g, '')) : 0;
|
|
||||||
|
|
||||||
var show = true;
|
|
||||||
if (floor !== null && floor < minFloor) show = false;
|
|
||||||
if (price > maxPrice) show = false;
|
|
||||||
|
|
||||||
var r = ratings[m._data.hashId];
|
|
||||||
if (hideRejected && r && r.status === 'reject') show = false;
|
|
||||||
|
|
||||||
if (show) {
|
|
||||||
if (!map.hasLayer(m)) m.addTo(map);
|
|
||||||
visible++;
|
|
||||||
// Show strike line if rejected and visible
|
|
||||||
if (m._rejectStrike && !map.hasLayer(m._rejectStrike)) m._rejectStrike.addTo(map);
|
|
||||||
} else {
|
|
||||||
if (map.hasLayer(m)) map.removeLayer(m);
|
|
||||||
// Hide strike line when marker hidden
|
|
||||||
if (m._rejectStrike && map.hasLayer(m._rejectStrike)) map.removeLayer(m._rejectStrike);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Re-apply styles for markers that were just added to map
|
|
||||||
allMarkers.forEach(function(m) {
|
|
||||||
if (map.hasLayer(m)) {
|
|
||||||
var r = ratings[m._data.hashId];
|
|
||||||
if (r) applyMarkerStyle(m, r.status);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('visible-count').textContent = visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize ratings on load — fetch from API (source of truth), seed cache, then restore
|
|
||||||
fetch('/api/ratings')
|
|
||||||
.then(function(r) { return r.ok ? r.json() : {}; })
|
|
||||||
.then(function(data) {
|
|
||||||
localStorage.setItem(RATINGS_KEY, JSON.stringify(data));
|
|
||||||
restoreRatings();
|
|
||||||
})
|
|
||||||
.catch(function() {
|
|
||||||
restoreRatings();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Panel toggle ──────────────────────────────────────────────
|
|
||||||
function togglePanel() {
|
|
||||||
var panel = document.getElementById('info-panel');
|
|
||||||
var openBtn = document.getElementById('panel-open-btn');
|
|
||||||
var isOpen = !panel.classList.contains('collapsed');
|
|
||||||
if (isOpen) {
|
|
||||||
panel.classList.add('collapsed');
|
|
||||||
openBtn.classList.remove('hidden');
|
|
||||||
} else {
|
|
||||||
panel.classList.remove('collapsed');
|
|
||||||
openBtn.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// On mobile, start with panel collapsed
|
|
||||||
if (window.innerWidth <= 600) {
|
|
||||||
document.getElementById('info-panel').classList.add('collapsed');
|
|
||||||
document.getElementById('panel-open-btn').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import unicodedata
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from scrape_and_map import generate_map, format_price
|
from scrape_and_map import generate_map, format_price
|
||||||
@@ -19,14 +20,8 @@ def normalize_street(locality: str) -> str:
|
|||||||
# "Studentská, Praha 6 - Dejvice" → "studentska"
|
# "Studentská, Praha 6 - Dejvice" → "studentska"
|
||||||
# "Rýnská, Praha" → "rynska"
|
# "Rýnská, Praha" → "rynska"
|
||||||
street = locality.split(",")[0].strip().lower()
|
street = locality.split(",")[0].strip().lower()
|
||||||
# Remove diacritics (simple Czech)
|
# Remove diacritics using Unicode decomposition (handles all Czech characters)
|
||||||
replacements = {
|
street = unicodedata.normalize("NFKD", street).encode("ascii", "ignore").decode("ascii")
|
||||||
"á": "a", "č": "c", "ď": "d", "é": "e", "ě": "e",
|
|
||||||
"í": "i", "ň": "n", "ó": "o", "ř": "r", "š": "s",
|
|
||||||
"ť": "t", "ú": "u", "ů": "u", "ý": "y", "ž": "z",
|
|
||||||
}
|
|
||||||
for src, dst in replacements.items():
|
|
||||||
street = street.replace(src, dst)
|
|
||||||
# Remove non-alphanumeric
|
# Remove non-alphanumeric
|
||||||
street = re.sub(r"[^a-z0-9]", "", street)
|
street = re.sub(r"[^a-z0-9]", "", street)
|
||||||
return street
|
return street
|
||||||
@@ -79,6 +74,10 @@ def main():
|
|||||||
if key in seen_keys:
|
if key in seen_keys:
|
||||||
dupes += 1
|
dupes += 1
|
||||||
existing = seen_keys[key]
|
existing = seen_keys[key]
|
||||||
|
# Preserve earliest first_seen across sources
|
||||||
|
dup_fs = e.get("first_seen", "")
|
||||||
|
if dup_fs and (not existing.get("first_seen") or dup_fs < existing["first_seen"]):
|
||||||
|
existing["first_seen"] = dup_fs
|
||||||
# Log it
|
# Log it
|
||||||
print(f" Duplikát: {e['locality']} | {format_price(e['price'])} | {e.get('area', '?')} m² "
|
print(f" Duplikát: {e['locality']} | {format_price(e['price'])} | {e.get('area', '?')} m² "
|
||||||
f"({e.get('source', '?')} vs {existing.get('source', '?')})")
|
f"({e.get('source', '?')} vs {existing.get('source', '?')})")
|
||||||
|
|||||||
@@ -12,8 +12,3 @@
|
|||||||
|
|
||||||
## documentation
|
## documentation
|
||||||
- precisely document original intent of the app (Maru has to provide this)
|
- precisely document original intent of the app (Maru has to provide this)
|
||||||
|
|
||||||
##
|
|
||||||
- prepare production run
|
|
||||||
- probably in home kubernetes
|
|
||||||
- maru-hleda-byt.lab.home.hrajfrisbee.cz
|
|
||||||
114
regen_map.py
114
regen_map.py
@@ -1,114 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Přegeneruje mapu z již stažených dat (byty_sreality.json).
|
|
||||||
Doplní chybějící plochy ze Sreality API, opraví URL, aplikuje filtry.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import urllib.request
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from scrape_and_map import (
|
|
||||||
generate_map, format_price, MIN_AREA, HEADERS, DETAIL_API
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def api_get(url: str) -> dict:
|
|
||||||
req = urllib.request.Request(url, headers=HEADERS)
|
|
||||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
||||||
return json.loads(resp.read().decode("utf-8"))
|
|
||||||
|
|
||||||
|
|
||||||
def fix_sreality_url(estate: dict) -> str:
|
|
||||||
"""Fix the Sreality URL to include disposition segment (only if missing)."""
|
|
||||||
disp = estate.get("disposition", "")
|
|
||||||
slug_map = {
|
|
||||||
"1+kk": "1+kk", "1+1": "1+1", "2+kk": "2+kk", "2+1": "2+1",
|
|
||||||
"3+kk": "3+kk", "3+1": "3+1", "4+kk": "4+kk", "4+1": "4+1",
|
|
||||||
"5+kk": "5+kk", "5+1": "5+1", "6+": "6-a-vice", "Atypický": "atypicky",
|
|
||||||
}
|
|
||||||
slug = slug_map.get(disp, "byt")
|
|
||||||
old_url = estate.get("url", "")
|
|
||||||
parts = old_url.split("/")
|
|
||||||
try:
|
|
||||||
byt_idx = parts.index("byt")
|
|
||||||
# Only insert if disposition slug is not already there
|
|
||||||
if byt_idx + 1 < len(parts) and parts[byt_idx + 1] == slug:
|
|
||||||
return old_url # already correct
|
|
||||||
parts.insert(byt_idx + 1, slug)
|
|
||||||
return "/".join(parts)
|
|
||||||
except ValueError:
|
|
||||||
return old_url
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_area(hash_id: int) -> int | None:
|
|
||||||
"""Fetch area from detail API."""
|
|
||||||
try:
|
|
||||||
url = DETAIL_API.format(hash_id)
|
|
||||||
detail = api_get(url)
|
|
||||||
for item in detail.get("items", []):
|
|
||||||
name = item.get("name", "")
|
|
||||||
if "žitná ploch" in name or "zitna ploch" in name.lower():
|
|
||||||
return int(item["value"])
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
json_path = Path("byty_sreality.json")
|
|
||||||
if not json_path.exists():
|
|
||||||
print("Soubor byty_sreality.json nenalezen. Nejprve spusť scrape_and_map.py")
|
|
||||||
return
|
|
||||||
|
|
||||||
estates = json.loads(json_path.read_text(encoding="utf-8"))
|
|
||||||
print(f"Načteno {len(estates)} bytů z byty_sreality.json")
|
|
||||||
|
|
||||||
# Step 1: Fetch missing areas
|
|
||||||
missing_area = [e for e in estates if e.get("area") is None]
|
|
||||||
print(f"Doplňuji plochu u {len(missing_area)} bytů...")
|
|
||||||
|
|
||||||
for i, e in enumerate(missing_area):
|
|
||||||
time.sleep(0.3)
|
|
||||||
area = fetch_area(e["hash_id"])
|
|
||||||
if area is not None:
|
|
||||||
e["area"] = area
|
|
||||||
if (i + 1) % 50 == 0:
|
|
||||||
print(f" {i + 1}/{len(missing_area)} ...")
|
|
||||||
|
|
||||||
# Count results
|
|
||||||
with_area = sum(1 for e in estates if e.get("area") is not None)
|
|
||||||
print(f"Plocha doplněna: {with_area}/{len(estates)}")
|
|
||||||
|
|
||||||
# Step 2: Fix URLs
|
|
||||||
for e in estates:
|
|
||||||
e["url"] = fix_sreality_url(e)
|
|
||||||
|
|
||||||
# Step 3: Filter by min area
|
|
||||||
filtered = []
|
|
||||||
excluded = 0
|
|
||||||
for e in estates:
|
|
||||||
area = e.get("area")
|
|
||||||
if area is not None and area < MIN_AREA:
|
|
||||||
excluded += 1
|
|
||||||
continue
|
|
||||||
filtered.append(e)
|
|
||||||
|
|
||||||
print(f"Vyloučeno (< {MIN_AREA} m²): {excluded}")
|
|
||||||
print(f"Zbývá: {len(filtered)} bytů")
|
|
||||||
|
|
||||||
# Save updated data
|
|
||||||
filtered_path = Path("byty_sreality.json")
|
|
||||||
filtered_path.write_text(
|
|
||||||
json.dumps(filtered, ensure_ascii=False, indent=2),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate map
|
|
||||||
generate_map(filtered)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
21
run_all.sh
21
run_all.sh
@@ -20,8 +20,10 @@ START_TIME=$(date -u +"%Y-%m-%dT%H:%M:%S")
|
|||||||
START_EPOCH=$(date +%s)
|
START_EPOCH=$(date +%s)
|
||||||
LOG_FILE="$(pwd)/scrape_run.log"
|
LOG_FILE="$(pwd)/scrape_run.log"
|
||||||
|
|
||||||
# Mark status as running
|
# Mark scraper as running; cleaned up on exit (even on error/kill)
|
||||||
echo '{"status":"running"}' > status.json
|
LOCK_FILE="${DATA_DIR:-.}/scraper_running.json"
|
||||||
|
echo '{"running":true,"started_at":"'"$START_TIME"'"}' > "$LOCK_FILE"
|
||||||
|
trap 'rm -f "$LOCK_FILE"' EXIT
|
||||||
|
|
||||||
show_help() {
|
show_help() {
|
||||||
echo "Usage: ./run_all.sh [OPTIONS]"
|
echo "Usage: ./run_all.sh [OPTIONS]"
|
||||||
@@ -32,16 +34,19 @@ show_help() {
|
|||||||
echo " --max-pages N Maximální počet stránek ke stažení z každého zdroje"
|
echo " --max-pages N Maximální počet stránek ke stažení z každého zdroje"
|
||||||
echo " --max-properties N Maximální počet nemovitostí ke stažení z každého zdroje"
|
echo " --max-properties N Maximální počet nemovitostí ke stažení z každého zdroje"
|
||||||
echo " --log-level LEVEL Úroveň logování (DEBUG, INFO, WARNING, ERROR)"
|
echo " --log-level LEVEL Úroveň logování (DEBUG, INFO, WARNING, ERROR)"
|
||||||
|
echo " --keep N Počet běhů v historii (výchozí: 5, 0=neomezeno)"
|
||||||
echo " -h, --help Zobrazí tuto nápovědu"
|
echo " -h, --help Zobrazí tuto nápovědu"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Examples:"
|
echo "Examples:"
|
||||||
echo " ./run_all.sh # plný běh"
|
echo " ./run_all.sh # plný běh"
|
||||||
echo " ./run_all.sh --max-pages 1 --max-properties 10 # rychlý test"
|
echo " ./run_all.sh --max-pages 1 --max-properties 10 # rychlý test"
|
||||||
echo " ./run_all.sh --log-level DEBUG # s debug logováním"
|
echo " ./run_all.sh --log-level DEBUG # s debug logováním"
|
||||||
|
echo " ./run_all.sh --keep 10 # uchovej 10 běhů v historii"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
SCRAPER_ARGS=""
|
SCRAPER_ARGS=""
|
||||||
|
KEEP_ARG=""
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-h|--help)
|
-h|--help)
|
||||||
@@ -52,6 +57,10 @@ while [[ $# -gt 0 ]]; do
|
|||||||
SCRAPER_ARGS="$SCRAPER_ARGS $1 $2"
|
SCRAPER_ARGS="$SCRAPER_ARGS $1 $2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--keep)
|
||||||
|
KEEP_ARG="--keep $2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown argument: $1"
|
echo "Unknown argument: $1"
|
||||||
echo ""
|
echo ""
|
||||||
@@ -75,9 +84,6 @@ exec > >(tee -a "$LOG_FILE") 2>&1
|
|||||||
step "Sreality"
|
step "Sreality"
|
||||||
python3 scrape_and_map.py $SCRAPER_ARGS || { echo -e "${RED}✗ Sreality selhalo${NC}"; FAILED=$((FAILED + 1)); }
|
python3 scrape_and_map.py $SCRAPER_ARGS || { echo -e "${RED}✗ Sreality selhalo${NC}"; FAILED=$((FAILED + 1)); }
|
||||||
|
|
||||||
step "Realingo"
|
|
||||||
python3 scrape_realingo.py $SCRAPER_ARGS || { echo -e "${RED}✗ Realingo selhalo${NC}"; FAILED=$((FAILED + 1)); }
|
|
||||||
|
|
||||||
step "Bezrealitky"
|
step "Bezrealitky"
|
||||||
python3 scrape_bezrealitky.py $SCRAPER_ARGS || { echo -e "${RED}✗ Bezrealitky selhalo${NC}"; FAILED=$((FAILED + 1)); }
|
python3 scrape_bezrealitky.py $SCRAPER_ARGS || { echo -e "${RED}✗ Bezrealitky selhalo${NC}"; FAILED=$((FAILED + 1)); }
|
||||||
|
|
||||||
@@ -92,6 +98,9 @@ PID_CH=$!
|
|||||||
wait $PID_PSN || { echo -e "${RED}✗ PSN selhalo${NC}"; FAILED=$((FAILED + 1)); }
|
wait $PID_PSN || { echo -e "${RED}✗ PSN selhalo${NC}"; FAILED=$((FAILED + 1)); }
|
||||||
wait $PID_CH || { echo -e "${RED}✗ CityHome selhalo${NC}"; FAILED=$((FAILED + 1)); }
|
wait $PID_CH || { echo -e "${RED}✗ CityHome selhalo${NC}"; FAILED=$((FAILED + 1)); }
|
||||||
|
|
||||||
|
step "Realingo"
|
||||||
|
python3 scrape_realingo.py $SCRAPER_ARGS || { echo -e "${RED}✗ Realingo selhalo${NC}"; FAILED=$((FAILED + 1)); }
|
||||||
|
|
||||||
# ── Sloučení + mapa ──────────────────────────────────────────
|
# ── Sloučení + mapa ──────────────────────────────────────────
|
||||||
|
|
||||||
step "Sloučení dat a generování mapy"
|
step "Sloučení dat a generování mapy"
|
||||||
@@ -103,7 +112,7 @@ python3 merge_and_map.py || { echo -e "${RED}✗ Merge selhal${NC}"; FAILED=$((F
|
|||||||
|
|
||||||
END_EPOCH=$(date +%s)
|
END_EPOCH=$(date +%s)
|
||||||
DURATION=$((END_EPOCH - START_EPOCH))
|
DURATION=$((END_EPOCH - START_EPOCH))
|
||||||
python3 generate_status.py "$START_TIME" "$DURATION" "$LOG_FILE"
|
python3 generate_status.py --start-time "$START_TIME" --duration "$DURATION" $KEEP_ARG
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "============================================================"
|
echo "============================================================"
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ import math
|
|||||||
import time
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from scraper_stats import write_stats, validate_listing
|
||||||
|
|
||||||
|
STATS_FILE = "stats_sreality.json"
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -42,9 +45,9 @@ HEADERS = {
|
|||||||
|
|
||||||
|
|
||||||
def api_get(url: str) -> dict:
|
def api_get(url: str) -> dict:
|
||||||
"""Fetch JSON from Sreality API."""
|
"""Fetch JSON from Sreality API with retry."""
|
||||||
logger.debug(f"HTTP GET request: {url}")
|
for attempt in range(3):
|
||||||
logger.debug(f"Headers: {HEADERS}")
|
logger.debug(f"HTTP GET request (attempt {attempt + 1}/3): {url}")
|
||||||
req = urllib.request.Request(url, headers=HEADERS)
|
req = urllib.request.Request(url, headers=HEADERS)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
@@ -52,8 +55,15 @@ def api_get(url: str) -> dict:
|
|||||||
logger.debug(f"HTTP response: status={resp.status}, size={len(response_data)} bytes")
|
logger.debug(f"HTTP response: status={resp.status}, size={len(response_data)} bytes")
|
||||||
logger.debug(f"Response preview: {response_data[:200]}")
|
logger.debug(f"Response preview: {response_data[:200]}")
|
||||||
return json.loads(response_data)
|
return json.loads(response_data)
|
||||||
|
except urllib.error.HTTPError:
|
||||||
|
raise
|
||||||
except (urllib.error.URLError, ConnectionError, OSError) as e:
|
except (urllib.error.URLError, ConnectionError, OSError) as e:
|
||||||
logger.error(f"HTTP request failed for {url}: {e}", exc_info=True)
|
if attempt < 2:
|
||||||
|
wait = (attempt + 1) * 2
|
||||||
|
logger.warning(f"Connection error (retry {attempt + 1}/3 after {wait}s): {e}")
|
||||||
|
time.sleep(wait)
|
||||||
|
else:
|
||||||
|
logger.error(f"HTTP request failed after 3 attempts: {e}", exc_info=True)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
@@ -209,6 +219,8 @@ def load_cache(json_path: str = "byty_sreality.json") -> dict[int, dict]:
|
|||||||
|
|
||||||
def scrape(max_pages: int | None = None, max_properties: int | None = None):
|
def scrape(max_pages: int | None = None, max_properties: int | None = None):
|
||||||
"""Main scraping function. Returns list of filtered estates."""
|
"""Main scraping function. Returns list of filtered estates."""
|
||||||
|
_run_start = time.time()
|
||||||
|
_run_ts = datetime.now().isoformat(timespec="seconds")
|
||||||
all_estates_raw = []
|
all_estates_raw = []
|
||||||
cache = load_cache()
|
cache = load_cache()
|
||||||
|
|
||||||
@@ -348,7 +360,11 @@ def scrape(max_pages: int | None = None, max_properties: int | None = None):
|
|||||||
"url": sreality_url(hash_id, seo),
|
"url": sreality_url(hash_id, seo),
|
||||||
"image": (estate.get("_links", {}).get("images", [{}])[0].get("href", "") if estate.get("_links", {}).get("images") else ""),
|
"image": (estate.get("_links", {}).get("images", [{}])[0].get("href", "") if estate.get("_links", {}).get("images") else ""),
|
||||||
"scraped_at": datetime.now().strftime("%Y-%m-%d"),
|
"scraped_at": datetime.now().strftime("%Y-%m-%d"),
|
||||||
|
"first_seen": cached.get("first_seen", datetime.now().strftime("%Y-%m-%d")) if cached else datetime.now().strftime("%Y-%m-%d"),
|
||||||
|
"last_changed": datetime.now().strftime("%Y-%m-%d"),
|
||||||
}
|
}
|
||||||
|
if not validate_listing(result, "sreality"):
|
||||||
|
continue
|
||||||
results.append(result)
|
results.append(result)
|
||||||
details_fetched += 1
|
details_fetched += 1
|
||||||
|
|
||||||
@@ -366,6 +382,21 @@ def scrape(max_pages: int | None = None, max_properties: int | None = None):
|
|||||||
logger.info(f" ✓ Vyhovující byty: {len(results)}")
|
logger.info(f" ✓ Vyhovující byty: {len(results)}")
|
||||||
logger.info(f"{'=' * 60}")
|
logger.info(f"{'=' * 60}")
|
||||||
|
|
||||||
|
write_stats(STATS_FILE, {
|
||||||
|
"source": "Sreality",
|
||||||
|
"timestamp": _run_ts,
|
||||||
|
"duration_sec": round(time.time() - _run_start, 1),
|
||||||
|
"success": True,
|
||||||
|
"accepted": len(results),
|
||||||
|
"fetched": len(unique_estates),
|
||||||
|
"cache_hits": cache_hits,
|
||||||
|
"excluded": {
|
||||||
|
"panel/síd": excluded_panel,
|
||||||
|
"<69 m²": excluded_small,
|
||||||
|
"bez GPS": excluded_no_gps,
|
||||||
|
"bez detailu": excluded_no_detail,
|
||||||
|
},
|
||||||
|
})
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
@@ -409,18 +440,30 @@ def generate_map(estates: list[dict], output_path: str = "mapa_bytu.html"):
|
|||||||
]
|
]
|
||||||
for bcolor, blabel in bands:
|
for bcolor, blabel in bands:
|
||||||
price_legend_items += (
|
price_legend_items += (
|
||||||
f'<div style="display:flex;align-items:center;gap:6px;margin:2px 0;">'
|
f'<div class="price-band" data-color="{bcolor}" onclick="toggleColorFilter(\'{bcolor}\')" '
|
||||||
|
f'style="display:flex;align-items:center;gap:6px;margin:2px 0;padding:2px 4px;'
|
||||||
|
f'border-radius:4px;border:2px solid transparent;">'
|
||||||
f'<span style="width:14px;height:14px;border-radius:50%;background:{bcolor};'
|
f'<span style="width:14px;height:14px;border-radius:50%;background:{bcolor};'
|
||||||
f'display:inline-block;border:2px solid white;box-shadow:0 1px 3px rgba(0,0,0,0.3);flex-shrink:0;"></span>'
|
f'display:inline-block;border:2px solid white;box-shadow:0 1px 3px rgba(0,0,0,0.3);flex-shrink:0;"></span>'
|
||||||
f'<span>{blabel}</span></div>'
|
f'<span>{blabel}</span></div>'
|
||||||
)
|
)
|
||||||
|
price_legend_items += (
|
||||||
|
'<div id="price-filter-reset" style="display:none;margin:3px 0 0 4px;">'
|
||||||
|
'<a href="#" onclick="resetColorFilter();return false;" '
|
||||||
|
'style="font-size:11px;color:#1976D2;text-decoration:none;">✕ Zobrazit všechny ceny</a>'
|
||||||
|
'</div>'
|
||||||
|
)
|
||||||
# New marker indicator — bigger dot, no extra border
|
# New marker indicator — bigger dot, no extra border
|
||||||
price_legend_items += (
|
price_legend_items += (
|
||||||
'<div style="display:flex;align-items:center;gap:6px;margin:6px 0 0 0;'
|
'<div style="display:flex;align-items:center;gap:6px;margin:6px 0 0 0;'
|
||||||
'padding-top:6px;border-top:1px solid #eee;">'
|
'padding-top:6px;border-top:1px solid #eee;">'
|
||||||
'<span style="width:18px;height:18px;border-radius:50%;background:#66BB6A;'
|
'<span style="display:inline-flex;align-items:center;gap:3px;flex-shrink:0;">'
|
||||||
'display:inline-block;box-shadow:0 1px 4px rgba(0,0,0,0.35);flex-shrink:0;"></span>'
|
'<span style="width:14px;height:14px;border-radius:50%;background:#66BB6A;'
|
||||||
'<span>Nové (z dnešního scrapu) — větší</span></div>'
|
'display:inline-block;box-shadow:0 1px 3px rgba(0,0,0,0.3);"></span>'
|
||||||
|
'<span style="font-size:8px;font-weight:700;background:#FFD600;color:#333;'
|
||||||
|
'padding:1px 3px;border-radius:2px;">NEW</span>'
|
||||||
|
'</span>'
|
||||||
|
'<span>Nové (≤ 1 den)</span></div>'
|
||||||
)
|
)
|
||||||
|
|
||||||
markers_js = ""
|
markers_js = ""
|
||||||
@@ -442,18 +485,32 @@ def generate_map(estates: list[dict], output_path: str = "mapa_bytu.html"):
|
|||||||
source_label = source_labels.get(source, source)
|
source_label = source_labels.get(source, source)
|
||||||
source_color = source_colors.get(source, "#999")
|
source_color = source_colors.get(source, "#999")
|
||||||
|
|
||||||
hash_id = e.get("hash_id", "")
|
hash_id = f"{source}_{e.get('hash_id', '')}"
|
||||||
|
|
||||||
scraped_at = e.get("scraped_at", "")
|
first_seen = e.get("first_seen", "")
|
||||||
is_new = scraped_at == datetime.now().strftime("%Y-%m-%d")
|
last_changed = e.get("last_changed", "")
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||||
|
is_new = first_seen in (today, yesterday)
|
||||||
|
|
||||||
new_badge = (
|
new_badge = (
|
||||||
'<span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;'
|
'<span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;'
|
||||||
'padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span>'
|
'padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span>'
|
||||||
if is_new else ""
|
if is_new else ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
date_parts = []
|
||||||
|
if first_seen:
|
||||||
|
date_parts.append(f'Přidáno: {first_seen}')
|
||||||
|
if last_changed and last_changed != first_seen:
|
||||||
|
date_parts.append(f'Změněno: {last_changed}')
|
||||||
|
date_row = (
|
||||||
|
f'<span style="font-size:11px;color:#888;">{" · ".join(date_parts)}</span><br>'
|
||||||
|
if date_parts else ""
|
||||||
|
)
|
||||||
|
|
||||||
popup = (
|
popup = (
|
||||||
f'<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="{hash_id}">'
|
f'<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="{hash_id}" data-first-seen="{first_seen}" data-last-changed="{last_changed}">'
|
||||||
f'<b style="font-size:14px;">{format_price(e["price"])}</b>'
|
f'<b style="font-size:14px;">{format_price(e["price"])}</b>'
|
||||||
f'<span style="margin-left:8px;font-size:11px;background:{source_color};color:white;'
|
f'<span style="margin-left:8px;font-size:11px;background:{source_color};color:white;'
|
||||||
f'padding:1px 6px;border-radius:3px;">{source_label}</span>{new_badge}<br>'
|
f'padding:1px 6px;border-radius:3px;">{source_label}</span>{new_badge}<br>'
|
||||||
@@ -461,7 +518,9 @@ def generate_map(estates: list[dict], output_path: str = "mapa_bytu.html"):
|
|||||||
f'{floor_note}<br><br>'
|
f'{floor_note}<br><br>'
|
||||||
f'<b>{e["locality"]}</b><br>'
|
f'<b>{e["locality"]}</b><br>'
|
||||||
f'Stavba: {building_text}<br>'
|
f'Stavba: {building_text}<br>'
|
||||||
f'Vlastnictví: {ownership_text}<br><br>'
|
f'Vlastnictví: {ownership_text}<br>'
|
||||||
|
f'{date_row}'
|
||||||
|
f'<br>'
|
||||||
f'<a href="{e["url"]}" target="_blank" '
|
f'<a href="{e["url"]}" target="_blank" '
|
||||||
f'style="color:{source_color};text-decoration:none;font-weight:bold;">'
|
f'style="color:{source_color};text-decoration:none;font-weight:bold;">'
|
||||||
f'→ Otevřít na {source_label}</a>'
|
f'→ Otevřít na {source_label}</a>'
|
||||||
@@ -493,7 +552,7 @@ def generate_map(estates: list[dict], output_path: str = "mapa_bytu.html"):
|
|||||||
else:
|
else:
|
||||||
marker_fn = "addMarker"
|
marker_fn = "addMarker"
|
||||||
markers_js += (
|
markers_js += (
|
||||||
f" {marker_fn}({e['lat']}, {e['lon']}, '{color}', '{popup}', '{hash_id}');\n"
|
f" {marker_fn}({e['lat']}, {e['lon']}, '{color}', '{popup}', '{hash_id}', '{first_seen}', '{last_changed}');\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build legend — price per m² bands + disposition counts
|
# Build legend — price per m² bands + disposition counts
|
||||||
@@ -559,12 +618,12 @@ def generate_map(estates: list[dict], output_path: str = "mapa_bytu.html"):
|
|||||||
.heart-icon-fav svg path {{ stroke: gold !important; stroke-width: 2.5 !important; filter: drop-shadow(0 0 4px rgba(255,193,7,0.7)); }}
|
.heart-icon-fav svg path {{ stroke: gold !important; stroke-width: 2.5 !important; filter: drop-shadow(0 0 4px rgba(255,193,7,0.7)); }}
|
||||||
.heart-icon-rej {{ opacity: 0.4 !important; filter: grayscale(1); }}
|
.heart-icon-rej {{ opacity: 0.4 !important; filter: grayscale(1); }}
|
||||||
.reject-overlay {{ background: none !important; border: none !important; pointer-events: none !important; }}
|
.reject-overlay {{ background: none !important; border: none !important; pointer-events: none !important; }}
|
||||||
@keyframes pulse-new {{
|
.new-badge-icon {{ background: none !important; border: none !important; pointer-events: none !important; }}
|
||||||
0% {{ stroke-opacity: 1; stroke-width: 3px; r: 11; }}
|
.new-badge {{
|
||||||
50% {{ stroke-opacity: 0.4; stroke-width: 6px; r: 12; }}
|
font-size: 9px; font-weight: 700; color: #333; background: #FFD600;
|
||||||
100% {{ stroke-opacity: 1; stroke-width: 3px; r: 11; }}
|
padding: 1px 4px; border-radius: 3px; white-space: nowrap;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.3); letter-spacing: 0.5px;
|
||||||
}}
|
}}
|
||||||
.marker-new {{ animation: pulse-new 2s ease-in-out infinite; }}
|
|
||||||
.info-panel {{
|
.info-panel {{
|
||||||
position: absolute; top: 10px; right: 10px; z-index: 1000;
|
position: absolute; top: 10px; right: 10px; z-index: 1000;
|
||||||
background: white; padding: 16px; border-radius: 10px;
|
background: white; padding: 16px; border-radius: 10px;
|
||||||
@@ -597,6 +656,10 @@ def generate_map(estates: list[dict], output_path: str = "mapa_bytu.html"):
|
|||||||
.info-panel .stats {{ color: #666; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid #eee; }}
|
.info-panel .stats {{ color: #666; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid #eee; }}
|
||||||
.filter-section {{ margin-top: 10px; padding-top: 10px; border-top: 1px solid #eee; }}
|
.filter-section {{ margin-top: 10px; padding-top: 10px; border-top: 1px solid #eee; }}
|
||||||
.filter-section label {{ display: flex; align-items: center; gap: 6px; margin: 3px 0; cursor: pointer; }}
|
.filter-section label {{ display: flex; align-items: center; gap: 6px; margin: 3px 0; cursor: pointer; }}
|
||||||
|
.price-band {{ cursor: pointer; transition: background 0.12s; }}
|
||||||
|
.price-band:hover {{ background: #f0f0f0; }}
|
||||||
|
.price-band.active {{ border-color: #333 !important; background: #e8f0fe; }}
|
||||||
|
.price-band.dimmed {{ opacity: 0.35; }}
|
||||||
.filter-section input[type="checkbox"] {{ accent-color: #1976D2; }}
|
.filter-section input[type="checkbox"] {{ accent-color: #1976D2; }}
|
||||||
#floor-filter {{ margin-top: 8px; }}
|
#floor-filter {{ margin-top: 8px; }}
|
||||||
#floor-filter select {{ width: 100%; padding: 4px; border-radius: 4px; border: 1px solid #ccc; }}
|
#floor-filter select {{ width: 100%; padding: 4px; border-radius: 4px; border: 1px solid #ccc; }}
|
||||||
@@ -635,11 +698,23 @@ def generate_map(estates: list[dict], output_path: str = "mapa_bytu.html"):
|
|||||||
</div>
|
</div>
|
||||||
<div style="margin-top:6px;">
|
<div style="margin-top:6px;">
|
||||||
<label>Max cena:
|
<label>Max cena:
|
||||||
<select id="max-price" onchange="applyFilters()">
|
<input type="number" id="max-price" value="13500000" max="14000000" step="500000"
|
||||||
<option value="13500000">13 500 000 Kč</option>
|
style="width:130px;padding:2px 4px;border:1px solid #ccc;border-radius:3px;"
|
||||||
<option value="12000000">12 000 000 Kč</option>
|
onchange="applyFilters()" onkeyup="applyFilters()"> Kč
|
||||||
<option value="10000000">10 000 000 Kč</option>
|
</label>
|
||||||
<option value="8000000">8 000 000 Kč</option>
|
</div>
|
||||||
|
<div style="margin-top:6px;">
|
||||||
|
<label>Přidáno / změněno:
|
||||||
|
<select id="days-filter" onchange="applyFilters()" style="width:100%;padding:4px;border-radius:4px;border:1px solid #ccc;">
|
||||||
|
<option value="0">Vše</option>
|
||||||
|
<option value="1">za 1 den</option>
|
||||||
|
<option value="2">za 2 dny</option>
|
||||||
|
<option value="3">za 3 dny</option>
|
||||||
|
<option value="4">za 4 dny</option>
|
||||||
|
<option value="5">za 5 dní</option>
|
||||||
|
<option value="7">za 7 dní</option>
|
||||||
|
<option value="14">za 14 dní</option>
|
||||||
|
<option value="30">za 30 dní</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -653,7 +728,7 @@ def generate_map(estates: list[dict], output_path: str = "mapa_bytu.html"):
|
|||||||
Skrýt zamítnuté
|
Skrýt zamítnuté
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-link"><a href="status.html">Scraper status</a></div>
|
<div class="status-link"><a href="/scrapers-status">Scraper status</a></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -673,9 +748,39 @@ L.tileLayer('https://{{s}}.basemaps.cartocdn.com/light_only_labels/{{z}}/{{x}}/{
|
|||||||
pane: 'shadowPane',
|
pane: 'shadowPane',
|
||||||
}}).addTo(map);
|
}}).addTo(map);
|
||||||
|
|
||||||
|
var selectedColors = [];
|
||||||
|
|
||||||
|
function toggleColorFilter(color) {{
|
||||||
|
var idx = selectedColors.indexOf(color);
|
||||||
|
if (idx >= 0) selectedColors.splice(idx, 1);
|
||||||
|
else selectedColors.push(color);
|
||||||
|
document.querySelectorAll('.price-band').forEach(function(el) {{
|
||||||
|
var c = el.getAttribute('data-color');
|
||||||
|
if (selectedColors.length === 0) {{
|
||||||
|
el.classList.remove('active', 'dimmed');
|
||||||
|
}} else if (selectedColors.indexOf(c) >= 0) {{
|
||||||
|
el.classList.add('active'); el.classList.remove('dimmed');
|
||||||
|
}} else {{
|
||||||
|
el.classList.add('dimmed'); el.classList.remove('active');
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
document.getElementById('price-filter-reset').style.display =
|
||||||
|
selectedColors.length > 0 ? 'block' : 'none';
|
||||||
|
applyFilters();
|
||||||
|
}}
|
||||||
|
|
||||||
|
function resetColorFilter() {{
|
||||||
|
selectedColors = [];
|
||||||
|
document.querySelectorAll('.price-band').forEach(function(el) {{
|
||||||
|
el.classList.remove('active', 'dimmed');
|
||||||
|
}});
|
||||||
|
document.getElementById('price-filter-reset').style.display = 'none';
|
||||||
|
applyFilters();
|
||||||
|
}}
|
||||||
|
|
||||||
var allMarkers = [];
|
var allMarkers = [];
|
||||||
|
|
||||||
function addMarker(lat, lon, color, popup, hashId) {{
|
function addMarker(lat, lon, color, popup, hashId, firstSeen, lastChanged) {{
|
||||||
var marker = L.circleMarker([lat, lon], {{
|
var marker = L.circleMarker([lat, lon], {{
|
||||||
radius: 8,
|
radius: 8,
|
||||||
fillColor: color,
|
fillColor: color,
|
||||||
@@ -684,26 +789,35 @@ function addMarker(lat, lon, color, popup, hashId) {{
|
|||||||
opacity: 1,
|
opacity: 1,
|
||||||
fillOpacity: 0.85,
|
fillOpacity: 0.85,
|
||||||
}}).bindPopup(popup);
|
}}).bindPopup(popup);
|
||||||
marker._data = {{ lat: lat, lon: lon, color: color, hashId: hashId }};
|
marker._data = {{ lat: lat, lon: lon, color: color, hashId: hashId, firstSeen: firstSeen || '', lastChanged: lastChanged || '' }};
|
||||||
allMarkers.push(marker);
|
allMarkers.push(marker);
|
||||||
marker.addTo(map);
|
marker.addTo(map);
|
||||||
}}
|
}}
|
||||||
|
|
||||||
function addNewMarker(lat, lon, color, popup, hashId) {{
|
function addNewMarker(lat, lon, color, popup, hashId, firstSeen, lastChanged) {{
|
||||||
var marker = L.circleMarker([lat, lon], {{
|
var marker = L.circleMarker([lat, lon], {{
|
||||||
radius: 12,
|
radius: 8,
|
||||||
fillColor: color,
|
fillColor: color,
|
||||||
color: color,
|
color: '#fff',
|
||||||
weight: 4,
|
weight: 2,
|
||||||
opacity: 0.35,
|
opacity: 1,
|
||||||
fillOpacity: 0.95,
|
fillOpacity: 0.85,
|
||||||
}}).bindPopup(popup);
|
}}).bindPopup(popup);
|
||||||
marker._data = {{ lat: lat, lon: lon, color: color, hashId: hashId, isNew: true }};
|
marker._data = {{ lat: lat, lon: lon, color: color, hashId: hashId, isNew: true, firstSeen: firstSeen || '', lastChanged: lastChanged || '' }};
|
||||||
allMarkers.push(marker);
|
allMarkers.push(marker);
|
||||||
marker.addTo(map);
|
marker.addTo(map);
|
||||||
marker.on('add', function() {{
|
var badge = L.marker([lat, lon], {{
|
||||||
if (marker._path) marker._path.classList.add('marker-new');
|
icon: L.divIcon({{
|
||||||
|
className: 'new-badge-icon',
|
||||||
|
html: '<span class="new-badge">NEW</span>',
|
||||||
|
iconSize: [32, 14],
|
||||||
|
iconAnchor: [-6, 7],
|
||||||
|
}}),
|
||||||
|
interactive: false,
|
||||||
|
pane: 'markerPane',
|
||||||
}});
|
}});
|
||||||
|
badge.addTo(map);
|
||||||
|
marker._newBadge = badge;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
function heartIcon(color) {{
|
function heartIcon(color) {{
|
||||||
@@ -736,11 +850,11 @@ function starIcon() {{
|
|||||||
}});
|
}});
|
||||||
}}
|
}}
|
||||||
|
|
||||||
function addHeartMarker(lat, lon, color, popup, hashId) {{
|
function addHeartMarker(lat, lon, color, popup, hashId, firstSeen, lastChanged) {{
|
||||||
var marker = L.marker([lat, lon], {{
|
var marker = L.marker([lat, lon], {{
|
||||||
icon: heartIcon(color),
|
icon: heartIcon(color),
|
||||||
}}).bindPopup(popup);
|
}}).bindPopup(popup);
|
||||||
marker._data = {{ lat: lat, lon: lon, color: color, hashId: hashId, isHeart: true }};
|
marker._data = {{ lat: lat, lon: lon, color: color, hashId: hashId, isHeart: true, firstSeen: firstSeen || '', lastChanged: lastChanged || '' }};
|
||||||
allMarkers.push(marker);
|
allMarkers.push(marker);
|
||||||
marker.addTo(map);
|
marker.addTo(map);
|
||||||
}}
|
}}
|
||||||
@@ -759,6 +873,11 @@ function loadRatings() {{
|
|||||||
|
|
||||||
function saveRatings(ratings) {{
|
function saveRatings(ratings) {{
|
||||||
localStorage.setItem(RATINGS_KEY, JSON.stringify(ratings));
|
localStorage.setItem(RATINGS_KEY, JSON.stringify(ratings));
|
||||||
|
fetch('/api/ratings', {{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {{'Content-Type': 'application/json'}},
|
||||||
|
body: JSON.stringify(ratings)
|
||||||
|
}}).catch(function() {{}});
|
||||||
}}
|
}}
|
||||||
|
|
||||||
function addRejectStrike(marker) {{
|
function addRejectStrike(marker) {{
|
||||||
@@ -806,6 +925,7 @@ function applyMarkerStyle(marker, status) {{
|
|||||||
}} else {{
|
}} else {{
|
||||||
if (status === 'fav') {{
|
if (status === 'fav') {{
|
||||||
removeRejectStrike(marker);
|
removeRejectStrike(marker);
|
||||||
|
if (marker._newBadge && map.hasLayer(marker._newBadge)) map.removeLayer(marker._newBadge);
|
||||||
if (!marker._data._origCircle) marker._data._origCircle = true;
|
if (!marker._data._origCircle) marker._data._origCircle = true;
|
||||||
var popup = marker.getPopup();
|
var popup = marker.getPopup();
|
||||||
var popupContent = popup ? popup.getContent() : '';
|
var popupContent = popup ? popup.getContent() : '';
|
||||||
@@ -829,6 +949,7 @@ function applyMarkerStyle(marker, status) {{
|
|||||||
}}
|
}}
|
||||||
// Add strikethrough line over the marker
|
// Add strikethrough line over the marker
|
||||||
addRejectStrike(marker);
|
addRejectStrike(marker);
|
||||||
|
if (marker._newBadge && map.hasLayer(marker._newBadge)) map.removeLayer(marker._newBadge);
|
||||||
}} else {{
|
}} else {{
|
||||||
if (marker._data._origCircle && !(marker instanceof L.CircleMarker)) {{
|
if (marker._data._origCircle && !(marker instanceof L.CircleMarker)) {{
|
||||||
revertToCircle(marker, {{ radius: 8, fillColor: marker._data.color, color: '#fff', weight: 2, fillOpacity: 0.85 }});
|
revertToCircle(marker, {{ radius: 8, fillColor: marker._data.color, color: '#fff', weight: 2, fillOpacity: 0.85 }});
|
||||||
@@ -841,6 +962,7 @@ function applyMarkerStyle(marker, status) {{
|
|||||||
}}
|
}}
|
||||||
if (marker._path) marker._path.classList.remove('marker-rejected');
|
if (marker._path) marker._path.classList.remove('marker-rejected');
|
||||||
removeRejectStrike(marker);
|
removeRejectStrike(marker);
|
||||||
|
if (marker._newBadge && !map.hasLayer(marker._newBadge)) marker._newBadge.addTo(map);
|
||||||
}}
|
}}
|
||||||
}}
|
}}
|
||||||
}}
|
}}
|
||||||
@@ -996,11 +1118,21 @@ map.on('popupopen', function(e) {{
|
|||||||
// ── Filters ────────────────────────────────────────────────────
|
// ── Filters ────────────────────────────────────────────────────
|
||||||
function applyFilters() {{
|
function applyFilters() {{
|
||||||
var minFloor = parseInt(document.getElementById('min-floor').value);
|
var minFloor = parseInt(document.getElementById('min-floor').value);
|
||||||
var maxPrice = parseInt(document.getElementById('max-price').value);
|
var maxPriceEl = document.getElementById('max-price');
|
||||||
|
var maxPrice = parseInt(maxPriceEl.value) || 14000000;
|
||||||
|
if (maxPrice > 14000000) {{ maxPrice = 14000000; maxPriceEl.value = 14000000; }}
|
||||||
var hideRejected = document.getElementById('hide-rejected').checked;
|
var hideRejected = document.getElementById('hide-rejected').checked;
|
||||||
|
var daysFilter = parseInt(document.getElementById('days-filter').value) || 0;
|
||||||
var ratings = loadRatings();
|
var ratings = loadRatings();
|
||||||
var visible = 0;
|
var visible = 0;
|
||||||
|
|
||||||
|
var cutoff = null;
|
||||||
|
if (daysFilter > 0) {{
|
||||||
|
cutoff = new Date();
|
||||||
|
cutoff.setDate(cutoff.getDate() - daysFilter);
|
||||||
|
cutoff.setHours(0, 0, 0, 0);
|
||||||
|
}}
|
||||||
|
|
||||||
allMarkers.forEach(function(m) {{
|
allMarkers.forEach(function(m) {{
|
||||||
var popup = m.getPopup().getContent();
|
var popup = m.getPopup().getContent();
|
||||||
var floorMatch = popup.match(/(\\d+)\\. NP/);
|
var floorMatch = popup.match(/(\\d+)\\. NP/);
|
||||||
@@ -1013,6 +1145,14 @@ function applyFilters() {{
|
|||||||
if (floor !== null && floor < minFloor) show = false;
|
if (floor !== null && floor < minFloor) show = false;
|
||||||
if (price > maxPrice) show = false;
|
if (price > maxPrice) show = false;
|
||||||
|
|
||||||
|
if (cutoff) {{
|
||||||
|
var fs = m._data.firstSeen ? new Date(m._data.firstSeen) : null;
|
||||||
|
var lc = m._data.lastChanged ? new Date(m._data.lastChanged) : null;
|
||||||
|
if (!((fs && fs >= cutoff) || (lc && lc >= cutoff))) show = false;
|
||||||
|
}}
|
||||||
|
|
||||||
|
if (selectedColors.length > 0 && selectedColors.indexOf(m._data.color) < 0) show = false;
|
||||||
|
|
||||||
var r = ratings[m._data.hashId];
|
var r = ratings[m._data.hashId];
|
||||||
if (hideRejected && r && r.status === 'reject') show = false;
|
if (hideRejected && r && r.status === 'reject') show = false;
|
||||||
|
|
||||||
@@ -1021,10 +1161,12 @@ function applyFilters() {{
|
|||||||
visible++;
|
visible++;
|
||||||
// Show strike line if rejected and visible
|
// Show strike line if rejected and visible
|
||||||
if (m._rejectStrike && !map.hasLayer(m._rejectStrike)) m._rejectStrike.addTo(map);
|
if (m._rejectStrike && !map.hasLayer(m._rejectStrike)) m._rejectStrike.addTo(map);
|
||||||
|
if (m._newBadge && !map.hasLayer(m._newBadge)) m._newBadge.addTo(map);
|
||||||
}} else {{
|
}} else {{
|
||||||
if (map.hasLayer(m)) map.removeLayer(m);
|
if (map.hasLayer(m)) map.removeLayer(m);
|
||||||
// Hide strike line when marker hidden
|
// Hide strike line when marker hidden
|
||||||
if (m._rejectStrike && map.hasLayer(m._rejectStrike)) map.removeLayer(m._rejectStrike);
|
if (m._rejectStrike && map.hasLayer(m._rejectStrike)) map.removeLayer(m._rejectStrike);
|
||||||
|
if (m._newBadge && map.hasLayer(m._newBadge)) map.removeLayer(m._newBadge);
|
||||||
}}
|
}}
|
||||||
}});
|
}});
|
||||||
|
|
||||||
@@ -1039,8 +1181,25 @@ function applyFilters() {{
|
|||||||
document.getElementById('visible-count').textContent = visible;
|
document.getElementById('visible-count').textContent = visible;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
// Initialize ratings on load
|
// Initialize ratings: load from server, merge with localStorage, then restore
|
||||||
restoreRatings();
|
function initRatings() {{
|
||||||
|
var local = loadRatings();
|
||||||
|
fetch('/api/ratings')
|
||||||
|
.then(function(r) {{ return r.ok ? r.json() : null; }})
|
||||||
|
.then(function(server) {{
|
||||||
|
if (server && typeof server === 'object') {{
|
||||||
|
var merged = Object.assign({{}}, local, server);
|
||||||
|
localStorage.setItem(RATINGS_KEY, JSON.stringify(merged));
|
||||||
|
}}
|
||||||
|
restoreRatings();
|
||||||
|
updateRatingCounts();
|
||||||
|
}})
|
||||||
|
.catch(function() {{
|
||||||
|
restoreRatings();
|
||||||
|
updateRatingCounts();
|
||||||
|
}});
|
||||||
|
}}
|
||||||
|
initRatings();
|
||||||
|
|
||||||
// ── Panel toggle ──────────────────────────────────────────────
|
// ── Panel toggle ──────────────────────────────────────────────
|
||||||
function togglePanel() {{
|
function togglePanel() {{
|
||||||
@@ -1089,8 +1248,22 @@ if __name__ == "__main__":
|
|||||||
handlers=[logging.StreamHandler()]
|
handlers=[logging.StreamHandler()]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_run_ts = datetime.now().isoformat(timespec="seconds")
|
||||||
start = time.time()
|
start = time.time()
|
||||||
|
try:
|
||||||
estates = scrape(max_pages=args.max_pages, max_properties=args.max_properties)
|
estates = scrape(max_pages=args.max_pages, max_properties=args.max_properties)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Scraper failed: {e}", exc_info=True)
|
||||||
|
write_stats(STATS_FILE, {
|
||||||
|
"source": "Sreality",
|
||||||
|
"timestamp": _run_ts,
|
||||||
|
"duration_sec": round(time.time() - start, 1),
|
||||||
|
"success": False,
|
||||||
|
"accepted": 0,
|
||||||
|
"fetched": 0,
|
||||||
|
"error": str(e),
|
||||||
|
})
|
||||||
|
raise
|
||||||
|
|
||||||
if estates:
|
if estates:
|
||||||
# Save raw data as JSON backup
|
# Save raw data as JSON backup
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import re
|
|||||||
import time
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from scraper_stats import write_stats, validate_listing
|
||||||
|
|
||||||
|
STATS_FILE = "stats_bezrealitky.json"
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -68,19 +71,35 @@ HEADERS = {
|
|||||||
BASE_URL = "https://www.bezrealitky.cz"
|
BASE_URL = "https://www.bezrealitky.cz"
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_url(url: str, retries: int = 3) -> str:
|
||||||
|
"""Fetch URL and return HTML string with retry on transient errors."""
|
||||||
|
for attempt in range(retries):
|
||||||
|
try:
|
||||||
|
logger.debug(f"HTTP GET request (attempt {attempt + 1}/{retries}): {url}")
|
||||||
|
req = urllib.request.Request(url, headers=HEADERS)
|
||||||
|
resp = urllib.request.urlopen(req, timeout=30)
|
||||||
|
html = resp.read().decode("utf-8")
|
||||||
|
logger.debug(f"HTTP response: status={resp.status}, size={len(html)} bytes")
|
||||||
|
return html
|
||||||
|
except urllib.error.HTTPError:
|
||||||
|
raise
|
||||||
|
except (ConnectionResetError, ConnectionError, urllib.error.URLError, OSError) as e:
|
||||||
|
if attempt < retries - 1:
|
||||||
|
wait = (attempt + 1) * 2
|
||||||
|
logger.warning(f"Connection error (retry {attempt + 1}/{retries} after {wait}s): {e}")
|
||||||
|
time.sleep(wait)
|
||||||
|
else:
|
||||||
|
logger.error(f"HTTP request failed after {retries} attempts: {e}", exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def fetch_page(page: int) -> tuple[list[dict], int]:
|
def fetch_page(page: int) -> tuple[list[dict], int]:
|
||||||
"""
|
"""
|
||||||
Fetch a listing page from Bezrealitky.
|
Fetch a listing page from Bezrealitky.
|
||||||
Returns (list of advert dicts from Apollo cache, total count).
|
Returns (list of advert dicts from Apollo cache, total count).
|
||||||
"""
|
"""
|
||||||
url = f"{BASE_URL}/vypis/nabidka-prodej/byt/praha?page={page}"
|
url = f"{BASE_URL}/vypis/nabidka-prodej/byt/praha?page={page}"
|
||||||
logger.debug(f"HTTP GET request: {url}")
|
html = fetch_url(url)
|
||||||
logger.debug(f"Headers: {HEADERS}")
|
|
||||||
req = urllib.request.Request(url, headers=HEADERS)
|
|
||||||
try:
|
|
||||||
resp = urllib.request.urlopen(req, timeout=30)
|
|
||||||
html = resp.read().decode("utf-8")
|
|
||||||
logger.debug(f"HTTP response: status={resp.status}, size={len(html)} bytes")
|
|
||||||
|
|
||||||
match = re.search(
|
match = re.search(
|
||||||
r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>',
|
r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>',
|
||||||
@@ -110,20 +129,13 @@ def fetch_page(page: int) -> tuple[list[dict], int]:
|
|||||||
|
|
||||||
logger.debug(f"Page {page}: found {len(adverts)} adverts, total={total}")
|
logger.debug(f"Page {page}: found {len(adverts)} adverts, total={total}")
|
||||||
return adverts, total
|
return adverts, total
|
||||||
except (urllib.error.URLError, ConnectionError, OSError) as e:
|
|
||||||
logger.error(f"HTTP request failed for {url}: {e}", exc_info=True)
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_detail(uri: str) -> dict | None:
|
def fetch_detail(uri: str) -> dict | None:
|
||||||
"""Fetch detail page for a listing."""
|
"""Fetch detail page for a listing."""
|
||||||
try:
|
try:
|
||||||
url = f"{BASE_URL}/nemovitosti-byty-domy/{uri}"
|
url = f"{BASE_URL}/nemovitosti-byty-domy/{uri}"
|
||||||
logger.debug(f"HTTP GET request: {url}")
|
html = fetch_url(url)
|
||||||
req = urllib.request.Request(url, headers=HEADERS)
|
|
||||||
resp = urllib.request.urlopen(req, timeout=30)
|
|
||||||
html = resp.read().decode("utf-8")
|
|
||||||
logger.debug(f"HTTP response: status={resp.status}, size={len(html)} bytes")
|
|
||||||
|
|
||||||
match = re.search(
|
match = re.search(
|
||||||
r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>',
|
r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>',
|
||||||
@@ -171,6 +183,8 @@ def load_cache(json_path: str = "byty_bezrealitky.json") -> dict[int, dict]:
|
|||||||
|
|
||||||
|
|
||||||
def scrape(max_pages: int | None = None, max_properties: int | None = None):
|
def scrape(max_pages: int | None = None, max_properties: int | None = None):
|
||||||
|
_run_start = time.time()
|
||||||
|
_run_ts = datetime.now().isoformat(timespec="seconds")
|
||||||
cache = load_cache()
|
cache = load_cache()
|
||||||
|
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
@@ -357,7 +371,11 @@ def scrape(max_pages: int | None = None, max_properties: int | None = None):
|
|||||||
"source": "bezrealitky",
|
"source": "bezrealitky",
|
||||||
"image": "",
|
"image": "",
|
||||||
"scraped_at": datetime.now().strftime("%Y-%m-%d"),
|
"scraped_at": datetime.now().strftime("%Y-%m-%d"),
|
||||||
|
"first_seen": cached.get("first_seen", datetime.now().strftime("%Y-%m-%d")) if cached else datetime.now().strftime("%Y-%m-%d"),
|
||||||
|
"last_changed": datetime.now().strftime("%Y-%m-%d"),
|
||||||
}
|
}
|
||||||
|
if not validate_listing(result, "bezrealitky"):
|
||||||
|
continue
|
||||||
results.append(result)
|
results.append(result)
|
||||||
properties_fetched += 1
|
properties_fetched += 1
|
||||||
|
|
||||||
@@ -374,6 +392,25 @@ def scrape(max_pages: int | None = None, max_properties: int | None = None):
|
|||||||
logger.info(f" ✓ Vyhovující byty: {len(results)}")
|
logger.info(f" ✓ Vyhovující byty: {len(results)}")
|
||||||
logger.info(f"{'=' * 60}")
|
logger.info(f"{'=' * 60}")
|
||||||
|
|
||||||
|
write_stats(STATS_FILE, {
|
||||||
|
"source": "Bezrealitky",
|
||||||
|
"timestamp": _run_ts,
|
||||||
|
"duration_sec": round(time.time() - _run_start, 1),
|
||||||
|
"success": True,
|
||||||
|
"accepted": len(results),
|
||||||
|
"fetched": len(all_adverts),
|
||||||
|
"pages": page - 1,
|
||||||
|
"cache_hits": cache_hits,
|
||||||
|
"excluded": {
|
||||||
|
"dispozice": excluded_disp,
|
||||||
|
"cena": excluded_price,
|
||||||
|
"plocha": excluded_area,
|
||||||
|
"bez GPS": excluded_no_gps,
|
||||||
|
"panel/síd": excluded_panel,
|
||||||
|
"patro": excluded_floor,
|
||||||
|
"bez detailu": excluded_detail,
|
||||||
|
},
|
||||||
|
})
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
@@ -394,8 +431,22 @@ if __name__ == "__main__":
|
|||||||
handlers=[logging.StreamHandler()]
|
handlers=[logging.StreamHandler()]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_run_ts = datetime.now().isoformat(timespec="seconds")
|
||||||
start = time.time()
|
start = time.time()
|
||||||
|
try:
|
||||||
estates = scrape(max_pages=args.max_pages, max_properties=args.max_properties)
|
estates = scrape(max_pages=args.max_pages, max_properties=args.max_properties)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Scraper failed: {e}", exc_info=True)
|
||||||
|
write_stats(STATS_FILE, {
|
||||||
|
"source": "Bezrealitky",
|
||||||
|
"timestamp": _run_ts,
|
||||||
|
"duration_sec": round(time.time() - start, 1),
|
||||||
|
"success": False,
|
||||||
|
"accepted": 0,
|
||||||
|
"fetched": 0,
|
||||||
|
"error": str(e),
|
||||||
|
})
|
||||||
|
raise
|
||||||
|
|
||||||
if estates:
|
if estates:
|
||||||
json_path = Path("byty_bezrealitky.json")
|
json_path = Path("byty_bezrealitky.json")
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import time
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from scraper_stats import write_stats, validate_listing
|
||||||
|
|
||||||
|
STATS_FILE = "stats_cityhome.json"
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -203,6 +206,8 @@ def extract_project_gps(html: str) -> tuple[float, float] | None:
|
|||||||
|
|
||||||
|
|
||||||
def scrape(max_pages: int | None = None, max_properties: int | None = None):
|
def scrape(max_pages: int | None = None, max_properties: int | None = None):
|
||||||
|
_run_start = time.time()
|
||||||
|
_run_ts = datetime.now().isoformat(timespec="seconds")
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
logger.info("Stahuji inzeráty z CityHome (city-home.cz)")
|
logger.info("Stahuji inzeráty z CityHome (city-home.cz)")
|
||||||
logger.info(f"Cena: do {format_price(MAX_PRICE)}")
|
logger.info(f"Cena: do {format_price(MAX_PRICE)}")
|
||||||
@@ -250,6 +255,16 @@ def scrape(max_pages: int | None = None, max_properties: int | None = None):
|
|||||||
else:
|
else:
|
||||||
logger.info(f"✗ {slug}: GPS nenalezeno")
|
logger.info(f"✗ {slug}: GPS nenalezeno")
|
||||||
|
|
||||||
|
# Load previous output for first_seen/last_changed tracking
|
||||||
|
_prev_cache: dict[str, dict] = {}
|
||||||
|
_prev_path = Path("byty_cityhome.json")
|
||||||
|
if _prev_path.exists():
|
||||||
|
try:
|
||||||
|
for _item in json.loads(_prev_path.read_text(encoding="utf-8")):
|
||||||
|
_prev_cache[str(_item["hash_id"])] = _item
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Step 3: Filter listings
|
# Step 3: Filter listings
|
||||||
logger.info(f"\nFáze 3: Filtrování...")
|
logger.info(f"\nFáze 3: Filtrování...")
|
||||||
results = []
|
results = []
|
||||||
@@ -357,7 +372,11 @@ def scrape(max_pages: int | None = None, max_properties: int | None = None):
|
|||||||
"source": "cityhome",
|
"source": "cityhome",
|
||||||
"image": "",
|
"image": "",
|
||||||
"scraped_at": datetime.now().strftime("%Y-%m-%d"),
|
"scraped_at": datetime.now().strftime("%Y-%m-%d"),
|
||||||
|
"first_seen": _prev_cache.get(f"cityhome_{slug}_{listing['unit_name']}", {}).get("first_seen", datetime.now().strftime("%Y-%m-%d")),
|
||||||
|
"last_changed": datetime.now().strftime("%Y-%m-%d") if _prev_cache.get(f"cityhome_{slug}_{listing['unit_name']}", {}).get("price") != price else _prev_cache[f"cityhome_{slug}_{listing['unit_name']}"].get("last_changed", datetime.now().strftime("%Y-%m-%d")),
|
||||||
}
|
}
|
||||||
|
if not validate_listing(result, "cityhome"):
|
||||||
|
continue
|
||||||
results.append(result)
|
results.append(result)
|
||||||
properties_fetched += 1
|
properties_fetched += 1
|
||||||
|
|
||||||
@@ -374,6 +393,23 @@ def scrape(max_pages: int | None = None, max_properties: int | None = None):
|
|||||||
logger.info(f" ✓ Vyhovující byty: {len(results)}")
|
logger.info(f" ✓ Vyhovující byty: {len(results)}")
|
||||||
logger.info(f"{'=' * 60}")
|
logger.info(f"{'=' * 60}")
|
||||||
|
|
||||||
|
write_stats(STATS_FILE, {
|
||||||
|
"source": "CityHome",
|
||||||
|
"timestamp": _run_ts,
|
||||||
|
"duration_sec": round(time.time() - _run_start, 1),
|
||||||
|
"success": True,
|
||||||
|
"accepted": len(results),
|
||||||
|
"fetched": len(all_listings),
|
||||||
|
"excluded": {
|
||||||
|
"prodáno": excluded_sold,
|
||||||
|
"typ": excluded_type,
|
||||||
|
"dispozice": excluded_disp,
|
||||||
|
"cena": excluded_price,
|
||||||
|
"plocha": excluded_area,
|
||||||
|
"patro": excluded_floor,
|
||||||
|
"bez GPS": excluded_no_gps,
|
||||||
|
},
|
||||||
|
})
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
@@ -394,8 +430,22 @@ if __name__ == "__main__":
|
|||||||
handlers=[logging.StreamHandler()]
|
handlers=[logging.StreamHandler()]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_run_ts = datetime.now().isoformat(timespec="seconds")
|
||||||
start = time.time()
|
start = time.time()
|
||||||
|
try:
|
||||||
estates = scrape(max_pages=args.max_pages, max_properties=args.max_properties)
|
estates = scrape(max_pages=args.max_pages, max_properties=args.max_properties)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Scraper failed: {e}", exc_info=True)
|
||||||
|
write_stats(STATS_FILE, {
|
||||||
|
"source": "CityHome",
|
||||||
|
"timestamp": _run_ts,
|
||||||
|
"duration_sec": round(time.time() - start, 1),
|
||||||
|
"success": False,
|
||||||
|
"accepted": 0,
|
||||||
|
"fetched": 0,
|
||||||
|
"error": str(e),
|
||||||
|
})
|
||||||
|
raise
|
||||||
|
|
||||||
if estates:
|
if estates:
|
||||||
json_path = Path("byty_cityhome.json")
|
json_path = Path("byty_cityhome.json")
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ import re
|
|||||||
import time
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from html.parser import HTMLParser
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from scraper_stats import write_stats, validate_listing
|
||||||
|
|
||||||
|
STATS_FILE = "stats_idnes.json"
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -279,6 +281,8 @@ def load_cache(json_path: str = "byty_idnes.json") -> dict[str, dict]:
|
|||||||
|
|
||||||
|
|
||||||
def scrape(max_pages: int | None = None, max_properties: int | None = None):
|
def scrape(max_pages: int | None = None, max_properties: int | None = None):
|
||||||
|
_run_start = time.time()
|
||||||
|
_run_ts = datetime.now().isoformat(timespec="seconds")
|
||||||
cache = load_cache()
|
cache = load_cache()
|
||||||
|
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
@@ -460,7 +464,11 @@ def scrape(max_pages: int | None = None, max_properties: int | None = None):
|
|||||||
"source": "idnes",
|
"source": "idnes",
|
||||||
"image": "",
|
"image": "",
|
||||||
"scraped_at": datetime.now().strftime("%Y-%m-%d"),
|
"scraped_at": datetime.now().strftime("%Y-%m-%d"),
|
||||||
|
"first_seen": cached.get("first_seen", datetime.now().strftime("%Y-%m-%d")) if cached else datetime.now().strftime("%Y-%m-%d"),
|
||||||
|
"last_changed": datetime.now().strftime("%Y-%m-%d"),
|
||||||
}
|
}
|
||||||
|
if not validate_listing(result, "idnes"):
|
||||||
|
continue
|
||||||
results.append(result)
|
results.append(result)
|
||||||
properties_fetched += 1
|
properties_fetched += 1
|
||||||
|
|
||||||
@@ -478,6 +486,25 @@ def scrape(max_pages: int | None = None, max_properties: int | None = None):
|
|||||||
logger.info(f" ✓ Vyhovující byty: {len(results)}")
|
logger.info(f" ✓ Vyhovující byty: {len(results)}")
|
||||||
logger.info(f"{'=' * 60}")
|
logger.info(f"{'=' * 60}")
|
||||||
|
|
||||||
|
write_stats(STATS_FILE, {
|
||||||
|
"source": "iDNES",
|
||||||
|
"timestamp": _run_ts,
|
||||||
|
"duration_sec": round(time.time() - _run_start, 1),
|
||||||
|
"success": True,
|
||||||
|
"accepted": len(results),
|
||||||
|
"fetched": len(all_listings),
|
||||||
|
"pages": page,
|
||||||
|
"cache_hits": cache_hits,
|
||||||
|
"excluded": {
|
||||||
|
"cena": excluded_price,
|
||||||
|
"plocha": excluded_area,
|
||||||
|
"dispozice": excluded_disp,
|
||||||
|
"panel/síd": excluded_panel,
|
||||||
|
"patro": excluded_floor,
|
||||||
|
"bez GPS": excluded_no_gps,
|
||||||
|
"bez detailu": excluded_detail,
|
||||||
|
},
|
||||||
|
})
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
@@ -498,8 +525,22 @@ if __name__ == "__main__":
|
|||||||
handlers=[logging.StreamHandler()]
|
handlers=[logging.StreamHandler()]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_run_ts = datetime.now().isoformat(timespec="seconds")
|
||||||
start = time.time()
|
start = time.time()
|
||||||
|
try:
|
||||||
estates = scrape(max_pages=args.max_pages, max_properties=args.max_properties)
|
estates = scrape(max_pages=args.max_pages, max_properties=args.max_properties)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Scraper failed: {e}", exc_info=True)
|
||||||
|
write_stats(STATS_FILE, {
|
||||||
|
"source": "iDNES",
|
||||||
|
"timestamp": _run_ts,
|
||||||
|
"duration_sec": round(time.time() - start, 1),
|
||||||
|
"success": False,
|
||||||
|
"accepted": 0,
|
||||||
|
"fetched": 0,
|
||||||
|
"error": str(e),
|
||||||
|
})
|
||||||
|
raise
|
||||||
|
|
||||||
if estates:
|
if estates:
|
||||||
json_path = Path("byty_idnes.json")
|
json_path = Path("byty_idnes.json")
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import time
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
from scraper_stats import write_stats, validate_listing
|
||||||
|
|
||||||
|
STATS_FILE = "stats_psn.json"
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -35,9 +38,10 @@ BASE_URL = "https://psn.cz"
|
|||||||
UNITS_API = f"{BASE_URL}/api/units-list"
|
UNITS_API = f"{BASE_URL}/api/units-list"
|
||||||
|
|
||||||
|
|
||||||
def fetch_json(url: str) -> dict:
|
def fetch_json(url: str, retries: int = 3) -> dict:
|
||||||
"""Fetch JSON via curl (urllib SSL may fail on Cloudflare)."""
|
"""Fetch JSON via curl (urllib SSL may fail on Cloudflare) with retry."""
|
||||||
logger.debug(f"HTTP GET: {url}")
|
for attempt in range(retries):
|
||||||
|
logger.debug(f"HTTP GET (attempt {attempt + 1}/{retries}): {url}")
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["curl", "-s", "-L", "--max-time", "30",
|
["curl", "-s", "-L", "--max-time", "30",
|
||||||
"-H", f"User-Agent: {UA}",
|
"-H", f"User-Agent: {UA}",
|
||||||
@@ -45,9 +49,14 @@ def fetch_json(url: str) -> dict:
|
|||||||
url],
|
url],
|
||||||
capture_output=True, text=True, timeout=60
|
capture_output=True, text=True, timeout=60
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode == 0:
|
||||||
raise RuntimeError(f"curl failed ({result.returncode}): {result.stderr[:200]}")
|
|
||||||
return json.loads(result.stdout)
|
return json.loads(result.stdout)
|
||||||
|
if attempt < retries - 1:
|
||||||
|
wait = (attempt + 1) * 2
|
||||||
|
logger.warning(f"curl failed (retry {attempt + 1}/{retries} after {wait}s): {result.stderr[:200]}")
|
||||||
|
time.sleep(wait)
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"curl failed after {retries} attempts ({result.returncode}): {result.stderr[:200]}")
|
||||||
|
|
||||||
|
|
||||||
def fix_gps(lat, lng):
|
def fix_gps(lat, lng):
|
||||||
@@ -67,6 +76,8 @@ def format_price(price: int) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def scrape(max_properties: int | None = None):
|
def scrape(max_properties: int | None = None):
|
||||||
|
_run_start = time.time()
|
||||||
|
_run_ts = datetime.now().isoformat(timespec="seconds")
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
logger.info("Stahuji inzeráty z PSN.cz")
|
logger.info("Stahuji inzeráty z PSN.cz")
|
||||||
logger.info(f"Cena: do {format_price(MAX_PRICE)}")
|
logger.info(f"Cena: do {format_price(MAX_PRICE)}")
|
||||||
@@ -93,11 +104,30 @@ def scrape(max_properties: int | None = None):
|
|||||||
data = fetch_json(url)
|
data = fetch_json(url)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Chyba při stahování: {e}", exc_info=True)
|
logger.error(f"Chyba při stahování: {e}", exc_info=True)
|
||||||
|
write_stats(STATS_FILE, {
|
||||||
|
"source": "PSN",
|
||||||
|
"timestamp": _run_ts,
|
||||||
|
"duration_sec": round(time.time() - _run_start, 1),
|
||||||
|
"success": False,
|
||||||
|
"accepted": 0,
|
||||||
|
"fetched": 0,
|
||||||
|
"error": str(e),
|
||||||
|
})
|
||||||
return []
|
return []
|
||||||
|
|
||||||
all_units = data.get("units", {}).get("data", [])
|
all_units = data.get("units", {}).get("data", [])
|
||||||
logger.info(f"Staženo jednotek celkem: {len(all_units)}")
|
logger.info(f"Staženo jednotek celkem: {len(all_units)}")
|
||||||
|
|
||||||
|
# Load previous output for first_seen/last_changed tracking
|
||||||
|
_prev_cache: dict[str, dict] = {}
|
||||||
|
_prev_path = Path("byty_psn.json")
|
||||||
|
if _prev_path.exists():
|
||||||
|
try:
|
||||||
|
for _item in json.loads(_prev_path.read_text(encoding="utf-8")):
|
||||||
|
_prev_cache[str(_item["hash_id"])] = _item
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Filtrování
|
# Filtrování
|
||||||
results = []
|
results = []
|
||||||
excluded = {
|
excluded = {
|
||||||
@@ -228,26 +258,37 @@ def scrape(max_properties: int | None = None):
|
|||||||
"source": "psn",
|
"source": "psn",
|
||||||
"image": "",
|
"image": "",
|
||||||
"scraped_at": datetime.now().strftime("%Y-%m-%d"),
|
"scraped_at": datetime.now().strftime("%Y-%m-%d"),
|
||||||
|
"first_seen": _prev_cache.get(str(unit_id), {}).get("first_seen", datetime.now().strftime("%Y-%m-%d")),
|
||||||
|
"last_changed": datetime.now().strftime("%Y-%m-%d") if _prev_cache.get(str(unit_id), {}).get("price") != int(price) else _prev_cache[str(unit_id)].get("last_changed", datetime.now().strftime("%Y-%m-%d")),
|
||||||
}
|
}
|
||||||
|
if not validate_listing(result, "psn"):
|
||||||
|
continue
|
||||||
results.append(result)
|
results.append(result)
|
||||||
properties_fetched += 1
|
properties_fetched += 1
|
||||||
|
|
||||||
logger.info(f"\n{'=' * 60}")
|
logger.info(f"\n{'=' * 60}")
|
||||||
logger.info(f"Výsledky PSN:")
|
logger.info(f"Výsledky PSN:")
|
||||||
logger.info(f" Staženo inzerátů: {len(all_units)}")
|
logger.info(f" Staženo jednotek: {len(all_units)}")
|
||||||
for reason, count in excluded.items():
|
for reason, count in excluded.items():
|
||||||
if count:
|
if count:
|
||||||
logger.info(f" Vyloučeno ({reason}): {count}")
|
logger.info(f" Vyloučeno ({reason}): {count}")
|
||||||
logger.info(f" ✓ Vyhovující byty: {len(results)}")
|
logger.info(f" ✓ Vyhovující byty: {len(results)}")
|
||||||
logger.info(f"{'=' * 60}")
|
logger.info(f"{'=' * 60}")
|
||||||
|
|
||||||
|
write_stats(STATS_FILE, {
|
||||||
|
"source": "PSN",
|
||||||
|
"timestamp": _run_ts,
|
||||||
|
"duration_sec": round(time.time() - _run_start, 1),
|
||||||
|
"success": True,
|
||||||
|
"accepted": len(results),
|
||||||
|
"fetched": len(all_units),
|
||||||
|
"excluded": excluded,
|
||||||
|
})
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
parser = argparse.ArgumentParser(description="Scrape apartments from PSN.cz")
|
parser = argparse.ArgumentParser(description="Scrape apartments from PSN.cz")
|
||||||
parser.add_argument("--max-pages", type=int, default=None,
|
|
||||||
help="Ignored — PSN uses a single API call, no pagination")
|
|
||||||
parser.add_argument("--max-properties", type=int, default=None,
|
parser.add_argument("--max-properties", type=int, default=None,
|
||||||
help="Maximum number of properties to include in results")
|
help="Maximum number of properties to include in results")
|
||||||
parser.add_argument("--log-level", type=str, default="INFO",
|
parser.add_argument("--log-level", type=str, default="INFO",
|
||||||
@@ -261,8 +302,22 @@ if __name__ == "__main__":
|
|||||||
handlers=[logging.StreamHandler()]
|
handlers=[logging.StreamHandler()]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_run_ts = datetime.now().isoformat(timespec="seconds")
|
||||||
start = time.time()
|
start = time.time()
|
||||||
|
try:
|
||||||
estates = scrape(max_properties=args.max_properties)
|
estates = scrape(max_properties=args.max_properties)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Scraper failed: {e}", exc_info=True)
|
||||||
|
write_stats(STATS_FILE, {
|
||||||
|
"source": "PSN",
|
||||||
|
"timestamp": _run_ts,
|
||||||
|
"duration_sec": round(time.time() - start, 1),
|
||||||
|
"success": False,
|
||||||
|
"accepted": 0,
|
||||||
|
"fetched": 0,
|
||||||
|
"error": str(e),
|
||||||
|
})
|
||||||
|
raise
|
||||||
|
|
||||||
if estates:
|
if estates:
|
||||||
json_path = Path("byty_psn.json")
|
json_path = Path("byty_psn.json")
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import re
|
|||||||
import time
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from scraper_stats import write_stats, validate_listing
|
||||||
|
|
||||||
|
STATS_FILE = "stats_realingo.json"
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -53,6 +56,28 @@ HEADERS = {
|
|||||||
BASE_URL = "https://www.realingo.cz"
|
BASE_URL = "https://www.realingo.cz"
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_url(url: str, retries: int = 3) -> str:
|
||||||
|
"""Fetch URL and return HTML string with retry on transient errors."""
|
||||||
|
for attempt in range(retries):
|
||||||
|
try:
|
||||||
|
logger.debug(f"HTTP GET request (attempt {attempt + 1}/{retries}): {url}")
|
||||||
|
req = urllib.request.Request(url, headers=HEADERS)
|
||||||
|
resp = urllib.request.urlopen(req, timeout=30)
|
||||||
|
html = resp.read().decode("utf-8")
|
||||||
|
logger.debug(f"HTTP response: status={resp.status}, size={len(html)} bytes")
|
||||||
|
return html
|
||||||
|
except urllib.error.HTTPError:
|
||||||
|
raise
|
||||||
|
except (ConnectionResetError, ConnectionError, urllib.error.URLError, OSError) as e:
|
||||||
|
if attempt < retries - 1:
|
||||||
|
wait = (attempt + 1) * 2
|
||||||
|
logger.warning(f"Connection error (retry {attempt + 1}/{retries} after {wait}s): {e}")
|
||||||
|
time.sleep(wait)
|
||||||
|
else:
|
||||||
|
logger.error(f"HTTP request failed after {retries} attempts: {e}", exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def fetch_listing_page(page: int = 1) -> tuple[list[dict], int]:
|
def fetch_listing_page(page: int = 1) -> tuple[list[dict], int]:
|
||||||
"""Fetch a page of Prague listings. Returns (items, total_count)."""
|
"""Fetch a page of Prague listings. Returns (items, total_count)."""
|
||||||
if page == 1:
|
if page == 1:
|
||||||
@@ -60,14 +85,7 @@ def fetch_listing_page(page: int = 1) -> tuple[list[dict], int]:
|
|||||||
else:
|
else:
|
||||||
url = f"{BASE_URL}/prodej_byty/praha/{page}_strana/"
|
url = f"{BASE_URL}/prodej_byty/praha/{page}_strana/"
|
||||||
|
|
||||||
logger.debug(f"HTTP GET request: {url}")
|
html = fetch_url(url)
|
||||||
logger.debug(f"Headers: {HEADERS}")
|
|
||||||
req = urllib.request.Request(url, headers=HEADERS)
|
|
||||||
try:
|
|
||||||
resp = urllib.request.urlopen(req, timeout=30)
|
|
||||||
html = resp.read().decode("utf-8")
|
|
||||||
logger.debug(f"HTTP response: status={resp.status}, size={len(html)} bytes")
|
|
||||||
|
|
||||||
match = re.search(
|
match = re.search(
|
||||||
r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>',
|
r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>',
|
||||||
html, re.DOTALL
|
html, re.DOTALL
|
||||||
@@ -80,21 +98,13 @@ def fetch_listing_page(page: int = 1) -> tuple[list[dict], int]:
|
|||||||
offer_list = data["props"]["pageProps"]["store"]["offer"]["list"]
|
offer_list = data["props"]["pageProps"]["store"]["offer"]["list"]
|
||||||
logger.debug(f"Page {page}: found {len(offer_list['data'])} items, total={offer_list['total']}")
|
logger.debug(f"Page {page}: found {len(offer_list['data'])} items, total={offer_list['total']}")
|
||||||
return offer_list["data"], offer_list["total"]
|
return offer_list["data"], offer_list["total"]
|
||||||
except (urllib.error.URLError, ConnectionError, OSError) as e:
|
|
||||||
logger.error(f"HTTP request failed for {url}: {e}", exc_info=True)
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_detail(listing_url: str) -> dict | None:
|
def fetch_detail(listing_url: str) -> dict | None:
|
||||||
"""Fetch detail page for a listing to get floor, building type, etc."""
|
"""Fetch detail page for a listing to get floor, building type, etc."""
|
||||||
try:
|
try:
|
||||||
url = f"{BASE_URL}{listing_url}"
|
url = f"{BASE_URL}{listing_url}"
|
||||||
logger.debug(f"HTTP GET request: {url}")
|
html = fetch_url(url)
|
||||||
req = urllib.request.Request(url, headers=HEADERS)
|
|
||||||
resp = urllib.request.urlopen(req, timeout=30)
|
|
||||||
html = resp.read().decode("utf-8")
|
|
||||||
logger.debug(f"HTTP response: status={resp.status}, size={len(html)} bytes")
|
|
||||||
|
|
||||||
match = re.search(
|
match = re.search(
|
||||||
r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>',
|
r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>',
|
||||||
html, re.DOTALL
|
html, re.DOTALL
|
||||||
@@ -136,6 +146,8 @@ def load_cache(json_path: str = "byty_realingo.json") -> dict[int, dict]:
|
|||||||
|
|
||||||
|
|
||||||
def scrape(max_pages: int | None = None, max_properties: int | None = None):
|
def scrape(max_pages: int | None = None, max_properties: int | None = None):
|
||||||
|
_run_start = time.time()
|
||||||
|
_run_ts = datetime.now().isoformat(timespec="seconds")
|
||||||
cache = load_cache()
|
cache = load_cache()
|
||||||
|
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
@@ -316,7 +328,11 @@ def scrape(max_pages: int | None = None, max_properties: int | None = None):
|
|||||||
"source": "realingo",
|
"source": "realingo",
|
||||||
"image": "",
|
"image": "",
|
||||||
"scraped_at": datetime.now().strftime("%Y-%m-%d"),
|
"scraped_at": datetime.now().strftime("%Y-%m-%d"),
|
||||||
|
"first_seen": cached.get("first_seen", datetime.now().strftime("%Y-%m-%d")) if cached else datetime.now().strftime("%Y-%m-%d"),
|
||||||
|
"last_changed": datetime.now().strftime("%Y-%m-%d"),
|
||||||
}
|
}
|
||||||
|
if not validate_listing(result, "realingo"):
|
||||||
|
continue
|
||||||
results.append(result)
|
results.append(result)
|
||||||
properties_fetched += 1
|
properties_fetched += 1
|
||||||
|
|
||||||
@@ -333,6 +349,25 @@ def scrape(max_pages: int | None = None, max_properties: int | None = None):
|
|||||||
logger.info(f" ✓ Vyhovující byty: {len(results)}")
|
logger.info(f" ✓ Vyhovující byty: {len(results)}")
|
||||||
logger.info(f"{'=' * 60}")
|
logger.info(f"{'=' * 60}")
|
||||||
|
|
||||||
|
write_stats(STATS_FILE, {
|
||||||
|
"source": "Realingo",
|
||||||
|
"timestamp": _run_ts,
|
||||||
|
"duration_sec": round(time.time() - _run_start, 1),
|
||||||
|
"success": True,
|
||||||
|
"accepted": len(results),
|
||||||
|
"fetched": len(all_listings),
|
||||||
|
"pages": page - 1,
|
||||||
|
"cache_hits": cache_hits,
|
||||||
|
"excluded": {
|
||||||
|
"dispozice": excluded_category,
|
||||||
|
"cena": excluded_price,
|
||||||
|
"plocha": excluded_area,
|
||||||
|
"bez GPS": excluded_no_gps,
|
||||||
|
"panel/síd": excluded_panel,
|
||||||
|
"patro": excluded_floor,
|
||||||
|
"bez detailu": excluded_detail,
|
||||||
|
},
|
||||||
|
})
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
@@ -353,8 +388,22 @@ if __name__ == "__main__":
|
|||||||
handlers=[logging.StreamHandler()]
|
handlers=[logging.StreamHandler()]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_run_ts = datetime.now().isoformat(timespec="seconds")
|
||||||
start = time.time()
|
start = time.time()
|
||||||
|
try:
|
||||||
estates = scrape(max_pages=args.max_pages, max_properties=args.max_properties)
|
estates = scrape(max_pages=args.max_pages, max_properties=args.max_properties)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Scraper failed: {e}", exc_info=True)
|
||||||
|
write_stats(STATS_FILE, {
|
||||||
|
"source": "Realingo",
|
||||||
|
"timestamp": _run_ts,
|
||||||
|
"duration_sec": round(time.time() - start, 1),
|
||||||
|
"success": False,
|
||||||
|
"accepted": 0,
|
||||||
|
"fetched": 0,
|
||||||
|
"error": str(e),
|
||||||
|
})
|
||||||
|
raise
|
||||||
|
|
||||||
if estates:
|
if estates:
|
||||||
json_path = Path("byty_realingo.json")
|
json_path = Path("byty_realingo.json")
|
||||||
|
|||||||
55
scraper_stats.py
Normal file
55
scraper_stats.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""Shared utilities for scraper run statistics and listing validation."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HERE = Path(__file__).parent
|
||||||
|
DATA_DIR = Path(os.environ.get("DATA_DIR", HERE))
|
||||||
|
|
||||||
|
_val_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_REQUIRED_FIELDS = ("hash_id", "price", "locality", "lat", "lon", "url", "source")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_listing(listing: dict, context: str = "") -> bool:
|
||||||
|
"""
|
||||||
|
Validate a listing dict before it is written to the output JSON.
|
||||||
|
Returns True if valid, False if the listing should be skipped.
|
||||||
|
Logs a warning for each invalid listing.
|
||||||
|
"""
|
||||||
|
prefix = f"[{context}] " if context else ""
|
||||||
|
|
||||||
|
for field in _REQUIRED_FIELDS:
|
||||||
|
val = listing.get(field)
|
||||||
|
if val is None or val == "":
|
||||||
|
_val_log.warning(f"{prefix}Skipping listing — missing field '{field}': {listing.get('hash_id', '?')}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
price = listing.get("price")
|
||||||
|
if not isinstance(price, (int, float)) or price <= 0:
|
||||||
|
_val_log.warning(f"{prefix}Skipping listing — invalid price={price!r}: {listing.get('hash_id', '?')}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
lat, lon = listing.get("lat"), listing.get("lon")
|
||||||
|
if not isinstance(lat, (int, float)) or not isinstance(lon, (int, float)):
|
||||||
|
_val_log.warning(f"{prefix}Skipping listing — non-numeric GPS lat={lat!r} lon={lon!r}: {listing.get('hash_id', '?')}")
|
||||||
|
return False
|
||||||
|
if not (47.0 <= lat <= 52.0) or not (12.0 <= lon <= 19.0):
|
||||||
|
_val_log.warning(f"{prefix}Skipping listing — GPS outside Czech Republic lat={lat} lon={lon}: {listing.get('hash_id', '?')}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
area = listing.get("area")
|
||||||
|
if area is not None and (not isinstance(area, (int, float)) or area <= 0):
|
||||||
|
_val_log.warning(f"{prefix}Skipping listing — invalid area={area!r}: {listing.get('hash_id', '?')}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def write_stats(filename: str, stats: dict) -> None:
|
||||||
|
"""Write scraper run stats dict to the data directory."""
|
||||||
|
path = DATA_DIR / filename
|
||||||
|
path.write_text(json.dumps(stats, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
500
server.py
500
server.py
@@ -1,60 +1,78 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Combined HTTP server: serves static files from DATA_DIR and
|
General-purpose HTTP server for maru-hleda-byt.
|
||||||
provides the ratings API at /api/ratings.
|
|
||||||
|
|
||||||
GET /api/ratings → returns ratings.json contents
|
Serves static files from DATA_DIR and additionally handles:
|
||||||
POST /api/ratings → saves entire ratings object
|
GET /scrapers-status → SSR scraper status page
|
||||||
GET /api/ratings/export → same as GET, with Content-Disposition: attachment
|
GET /api/ratings → ratings.json contents
|
||||||
GET /<path> → serves static file from DATA_DIR
|
POST /api/ratings → save entire ratings object
|
||||||
|
GET /api/ratings/export → same as GET, with download header
|
||||||
|
GET /api/status → status.json contents (JSON)
|
||||||
|
GET /api/status/history → scraper_history.json contents (JSON)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import functools
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Flask, jsonify, request, send_from_directory
|
PORT = int(os.environ.get("SERVER_PORT", 8080))
|
||||||
|
DATA_DIR = Path(os.environ.get("DATA_DIR", "."))
|
||||||
parser = argparse.ArgumentParser(description="Flat-search map server")
|
|
||||||
parser.add_argument("--log-level", "-l", default=None, choices=["DEBUG", "INFO", "WARNING", "ERROR"], help="Log level (default: INFO)")
|
|
||||||
parser.add_argument("--verbose", "-v", action="store_true", help="Shorthand for --log-level DEBUG")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
log_level = logging.DEBUG if args.verbose else getattr(logging, args.log_level or "INFO")
|
|
||||||
|
|
||||||
PORT = int(os.environ.get("PORT", 8080))
|
|
||||||
DATA_DIR = Path(os.environ.get("DATA_DIR", ".")).resolve()
|
|
||||||
RATINGS_FILE = DATA_DIR / "ratings.json"
|
RATINGS_FILE = DATA_DIR / "ratings.json"
|
||||||
|
_LOG_LEVEL = getattr(logging, os.environ.get("LOG_LEVEL", "INFO").upper(), logging.INFO)
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=log_level,
|
level=_LOG_LEVEL,
|
||||||
format="%(asctime)s [server] %(levelname)s %(message)s",
|
format="%(asctime)s [server] %(levelname)s %(message)s",
|
||||||
datefmt="%Y-%m-%dT%H:%M:%S",
|
datefmt="%Y-%m-%dT%H:%M:%S",
|
||||||
)
|
)
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
app = Flask(__name__, static_folder=None)
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
app.json.ensure_ascii = False
|
|
||||||
|
COLORS = {
|
||||||
|
"sreality": "#1976D2",
|
||||||
|
"realingo": "#7B1FA2",
|
||||||
|
"bezrealitky": "#E65100",
|
||||||
|
"idnes": "#C62828",
|
||||||
|
"psn": "#2E7D32",
|
||||||
|
"cityhome": "#00838F",
|
||||||
|
}
|
||||||
|
|
||||||
|
MONTHS_CZ = [
|
||||||
|
"ledna", "února", "března", "dubna", "května", "června",
|
||||||
|
"července", "srpna", "září", "října", "listopadu", "prosince",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@app.after_request
|
def _load_json(path: Path, default=None):
|
||||||
def add_cors(response):
|
"""Read and parse JSON file; return default on missing or parse error."""
|
||||||
response.headers["Access-Control-Allow-Origin"] = "*"
|
log.debug("_load_json: %s", path.resolve())
|
||||||
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
|
try:
|
||||||
response.headers["Access-Control-Allow-Headers"] = "Content-Type"
|
if path.exists():
|
||||||
return response
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Failed to load %s: %s", path, e)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_date(iso_str: str) -> str:
|
||||||
|
"""Format ISO timestamp as Czech date string."""
|
||||||
|
try:
|
||||||
|
d = datetime.fromisoformat(iso_str)
|
||||||
|
return f"{d.day}. {MONTHS_CZ[d.month - 1]} {d.year}, {d.hour:02d}:{d.minute:02d}"
|
||||||
|
except Exception:
|
||||||
|
return iso_str
|
||||||
|
|
||||||
|
|
||||||
def load_ratings() -> dict:
|
def load_ratings() -> dict:
|
||||||
try:
|
return _load_json(RATINGS_FILE, default={})
|
||||||
if RATINGS_FILE.exists():
|
|
||||||
return json.loads(RATINGS_FILE.read_text(encoding="utf-8"))
|
|
||||||
except Exception as e:
|
|
||||||
log.error("Failed to load ratings: %s", e)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def save_ratings(data: dict) -> None:
|
def save_ratings(data: dict) -> None:
|
||||||
@@ -64,56 +82,396 @@ def save_ratings(data: dict) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/ratings", methods=["OPTIONS"])
|
# ── SSR status page ──────────────────────────────────────────────────────────
|
||||||
@app.route("/api/ratings/export", methods=["OPTIONS"])
|
|
||||||
def ratings_options():
|
_CSS = """\
|
||||||
return ("", 204)
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
background: #f5f5f5; color: #333;
|
||||||
|
padding: 24px; max-width: 640px; margin: 0 auto;
|
||||||
|
}
|
||||||
|
h1 { font-size: 22px; margin-bottom: 4px; }
|
||||||
|
.subtitle { color: #888; font-size: 13px; margin-bottom: 24px; }
|
||||||
|
.card {
|
||||||
|
background: white; border-radius: 12px; padding: 20px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.08); margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.card h2 { font-size: 15px; margin-bottom: 12px; color: #555; }
|
||||||
|
.timestamp { font-size: 28px; font-weight: 700; color: #1976D2; }
|
||||||
|
.timestamp-sub { font-size: 13px; color: #999; margin-top: 2px; }
|
||||||
|
.summary-row {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 10px 0; border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
.summary-row:last-child { border-bottom: none; }
|
||||||
|
.summary-label { font-size: 13px; color: #666; }
|
||||||
|
.summary-value { font-size: 18px; font-weight: 700; }
|
||||||
|
.badge {
|
||||||
|
display: inline-block; padding: 2px 8px; border-radius: 4px;
|
||||||
|
font-size: 11px; font-weight: 600; color: white;
|
||||||
|
}
|
||||||
|
.badge-ok { background: #4CAF50; }
|
||||||
|
.badge-err { background: #F44336; }
|
||||||
|
.badge-skip { background: #FF9800; }
|
||||||
|
.bar-row { display: flex; align-items: center; gap: 8px; margin: 4px 0; }
|
||||||
|
.bar-track { flex: 1; height: 20px; background: #f0f0f0; border-radius: 4px; overflow: hidden; }
|
||||||
|
.bar-fill { height: 100%; border-radius: 4px; }
|
||||||
|
.bar-count { font-size: 12px; width: 36px; font-variant-numeric: tabular-nums; }
|
||||||
|
.loader-wrap {
|
||||||
|
display: flex; flex-direction: column; align-items: center;
|
||||||
|
justify-content: center; padding: 60px 0;
|
||||||
|
}
|
||||||
|
.spinner {
|
||||||
|
width: 40px; height: 40px; border: 4px solid #e0e0e0;
|
||||||
|
border-top-color: #1976D2; border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.loader-text { margin-top: 16px; color: #999; font-size: 14px; }
|
||||||
|
.link-row { text-align: center; margin-top: 8px; }
|
||||||
|
.link-row a { color: #1976D2; text-decoration: none; font-size: 14px; }
|
||||||
|
.history-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||||
|
.history-table th {
|
||||||
|
text-align: left; font-weight: 600; color: #999; font-size: 11px;
|
||||||
|
padding: 4px 6px 8px 6px; border-bottom: 2px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
.history-table td { padding: 7px 6px; border-bottom: 1px solid #f5f5f5; vertical-align: middle; }
|
||||||
|
.history-table tr:last-child td { border-bottom: none; }
|
||||||
|
.history-table tr.latest td { background: #f8fbff; font-weight: 600; }
|
||||||
|
.src-nums { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||||
|
.src-chip {
|
||||||
|
display: inline-block; padding: 1px 5px; border-radius: 3px;
|
||||||
|
font-size: 10px; color: white; font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.clickable-row { cursor: pointer; }
|
||||||
|
.clickable-row:hover td { background: #f0f7ff !important; }
|
||||||
|
/* Modal */
|
||||||
|
#md-overlay {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,0.45);
|
||||||
|
display: flex; align-items: flex-start; justify-content: center;
|
||||||
|
z-index: 1000; padding: 40px 16px; overflow-y: auto;
|
||||||
|
}
|
||||||
|
#md-box {
|
||||||
|
background: white; border-radius: 12px; padding: 24px;
|
||||||
|
width: 100%; max-width: 620px; position: relative;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.24); margin: auto;
|
||||||
|
}
|
||||||
|
#md-close {
|
||||||
|
position: absolute; top: 10px; right: 14px;
|
||||||
|
background: none; border: none; font-size: 26px; cursor: pointer;
|
||||||
|
color: #aaa; line-height: 1;
|
||||||
|
}
|
||||||
|
#md-close:hover { color: #333; }
|
||||||
|
#md-box h3 { font-size: 15px; margin-bottom: 14px; padding-right: 24px; }
|
||||||
|
.md-summary { display: flex; gap: 20px; flex-wrap: wrap; font-size: 13px; margin-bottom: 16px; color: #555; }
|
||||||
|
.md-summary b { color: #333; }
|
||||||
|
.detail-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||||
|
.detail-table th {
|
||||||
|
text-align: left; color: #999; font-size: 11px; font-weight: 600;
|
||||||
|
padding: 4px 8px 6px 0; border-bottom: 2px solid #f0f0f0; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.detail-table td { padding: 6px 8px 6px 0; border-bottom: 1px solid #f5f5f5; vertical-align: top; }
|
||||||
|
.detail-table tr:last-child td { border-bottom: none; }
|
||||||
|
"""
|
||||||
|
|
||||||
|
_SOURCE_ORDER = ["Sreality", "Realingo", "Bezrealitky", "iDNES", "PSN", "CityHome"]
|
||||||
|
_SOURCE_ABBR = ["Sre", "Rea", "Bez", "iDN", "PSN", "CH"]
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/ratings", methods=["GET"])
|
def _sources_html(sources: list) -> str:
|
||||||
def get_ratings():
|
if not sources:
|
||||||
|
return ""
|
||||||
|
max_count = max((s.get("accepted", 0) for s in sources), default=1) or 1
|
||||||
|
parts = ['<div class="card"><h2>Zdroje</h2>']
|
||||||
|
for s in sources:
|
||||||
|
name = s.get("name", "?")
|
||||||
|
accepted = s.get("accepted", 0)
|
||||||
|
error = s.get("error")
|
||||||
|
exc = s.get("excluded", {})
|
||||||
|
excluded_total = sum(exc.values()) if isinstance(exc, dict) else s.get("excluded_total", 0)
|
||||||
|
color = COLORS.get(name.lower(), "#999")
|
||||||
|
pct = round(accepted / max_count * 100) if max_count else 0
|
||||||
|
if error:
|
||||||
|
badge = '<span class="badge badge-err">chyba</span>'
|
||||||
|
elif accepted == 0:
|
||||||
|
badge = '<span class="badge badge-skip">0</span>'
|
||||||
|
else:
|
||||||
|
badge = '<span class="badge badge-ok">OK</span>'
|
||||||
|
parts.append(
|
||||||
|
f'<div style="margin-bottom:12px;">'
|
||||||
|
f'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">'
|
||||||
|
f'<span style="font-weight:600;font-size:14px;">{name} {badge}</span>'
|
||||||
|
f'<span style="font-size:12px;color:#999;">{excluded_total} vyloučených</span>'
|
||||||
|
f'</div>'
|
||||||
|
f'<div class="bar-row">'
|
||||||
|
f'<div class="bar-track"><div class="bar-fill" style="width:{pct}%;background:{color};"></div></div>'
|
||||||
|
f'<span class="bar-count">{accepted}</span>'
|
||||||
|
f'</div></div>'
|
||||||
|
)
|
||||||
|
parts.append("</div>")
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _history_html(history: list) -> str:
|
||||||
|
if not history:
|
||||||
|
return ""
|
||||||
|
rows = list(reversed(history))
|
||||||
|
parts = [
|
||||||
|
'<div class="card">'
|
||||||
|
'<h2>Historie běhů <span style="font-size:11px;font-weight:400;color:#bbb;">– klikni pro detaily</span></h2>',
|
||||||
|
'<table class="history-table"><thead><tr>',
|
||||||
|
'<th>Datum</th><th>Trvání</th><th>Přijato / Dedup</th><th>Zdroje</th><th>OK</th>',
|
||||||
|
'</tr></thead><tbody>',
|
||||||
|
]
|
||||||
|
for i, entry in enumerate(rows):
|
||||||
|
row_class = ' class="latest clickable-row"' if i == 0 else ' class="clickable-row"'
|
||||||
|
src_map = {s["name"]: s for s in entry.get("sources", []) if "name" in s}
|
||||||
|
chips = "".join(
|
||||||
|
f'<span class="src-chip" style="background:{"#F44336" if (src_map.get(name) or {}).get("error") else COLORS.get(name.lower(), "#999")}" title="{name}">'
|
||||||
|
f'{abbr} {src_map[name].get("accepted", 0) if name in src_map else "-"}</span>'
|
||||||
|
for name, abbr in zip(_SOURCE_ORDER, _SOURCE_ABBR)
|
||||||
|
)
|
||||||
|
ok_badge = (
|
||||||
|
'<span class="badge badge-err">chyba</span>'
|
||||||
|
if entry.get("success") is False
|
||||||
|
else '<span class="badge badge-ok">OK</span>'
|
||||||
|
)
|
||||||
|
dur = f'{entry["duration_sec"]}s' if entry.get("duration_sec") is not None else "-"
|
||||||
|
parts.append(
|
||||||
|
f'<tr{row_class} data-idx="{i}">'
|
||||||
|
f'<td>{_fmt_date(entry.get("timestamp", ""))}</td>'
|
||||||
|
f'<td>{dur}</td>'
|
||||||
|
f'<td>{entry.get("total_accepted", "-")} / {entry.get("deduplicated", "-")}</td>'
|
||||||
|
f'<td><div class="src-nums">{chips}</div></td>'
|
||||||
|
f'<td>{ok_badge}</td>'
|
||||||
|
f'</tr>'
|
||||||
|
)
|
||||||
|
parts.append("</tbody></table></div>")
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _modal_script(rows_json: str) -> str:
|
||||||
|
"""Return the modal overlay HTML + JS for the history detail popup."""
|
||||||
|
return (
|
||||||
|
'<div id="md-overlay" style="display:none">'
|
||||||
|
'<div id="md-box"><button id="md-close">×</button>'
|
||||||
|
'<div id="md-body"></div></div></div>\n'
|
||||||
|
'<script>\n(function(){\n'
|
||||||
|
f'var H={rows_json};\n'
|
||||||
|
'var C={"sreality":"#1976D2","realingo":"#7B1FA2","bezrealitky":"#E65100","idnes":"#C62828","psn":"#2E7D32","cityhome":"#00838F"};\n'
|
||||||
|
'var MN=["ledna","února","března","dubna","května","června","července","srpna","září","října","listopadu","prosince"];\n'
|
||||||
|
'function fd(s){var d=new Date(s);return d.getDate()+". "+MN[d.getMonth()]+" "+d.getFullYear()+", "+String(d.getHours()).padStart(2,"0")+":"+String(d.getMinutes()).padStart(2,"0");}\n'
|
||||||
|
'function openModal(idx){\n'
|
||||||
|
' var e=H[idx],src=e.sources||[];\n'
|
||||||
|
' var h="<h3>Detaily b\u011bhu \u2013 "+fd(e.timestamp)+"</h3>";\n'
|
||||||
|
' h+="<div class=\\"md-summary\\">";\n'
|
||||||
|
' if(e.duration_sec!=null) h+="<span><b>Trvání:</b> "+e.duration_sec+"s</span>";\n'
|
||||||
|
' if(e.total_accepted!=null) h+="<span><b>Přijato:</b> "+e.total_accepted+"</span>";\n'
|
||||||
|
' if(e.deduplicated!=null) h+="<span><b>Po dedup:</b> "+e.deduplicated+"</span>";\n'
|
||||||
|
' h+="</div>";\n'
|
||||||
|
' h+="<table class=\\"detail-table\\"><thead><tr>";\n'
|
||||||
|
' h+="<th>Zdroj</th><th>Přijato</th><th>Staženo</th><th>Stránky</th><th>Cache</th><th>Vyloučeno</th><th>Čas</th><th>OK</th>";\n'
|
||||||
|
' h+="</tr></thead><tbody>";\n'
|
||||||
|
' src.forEach(function(s){\n'
|
||||||
|
' var nm=s.name||"?",col=C[nm.toLowerCase()]||"#999";\n'
|
||||||
|
' var exc=s.excluded||{};\n'
|
||||||
|
' var excStr=Object.entries(exc).filter(function(kv){return kv[1]>0;}).map(function(kv){return kv[0]+": "+kv[1];}).join(", ")||"\u2013";\n'
|
||||||
|
' var ok=s.error?"<span class=\\"badge badge-err\\" title=\\""+s.error+"\\">chyba</span>":"<span class=\\"badge badge-ok\\">OK</span>";\n'
|
||||||
|
' var dot="<span style=\\"display:inline-block;width:8px;height:8px;border-radius:50%;background:"+col+";margin-right:5px;\\"></span>";\n'
|
||||||
|
' h+="<tr>";\n'
|
||||||
|
' h+="<td>"+dot+nm+"</td>";\n'
|
||||||
|
' h+="<td>"+(s.accepted!=null?s.accepted:"\u2013")+"</td>";\n'
|
||||||
|
' h+="<td>"+(s.fetched!=null?s.fetched:"\u2013")+"</td>";\n'
|
||||||
|
' h+="<td>"+(s.pages!=null?s.pages:"\u2013")+"</td>";\n'
|
||||||
|
' h+="<td>"+(s.cache_hits!=null?s.cache_hits:"\u2013")+"</td>";\n'
|
||||||
|
' h+="<td style=\\"font-size:11px;color:#666;\\">"+excStr+"</td>";\n'
|
||||||
|
' h+="<td>"+(s.duration_sec!=null?s.duration_sec+"s":"\u2013")+"</td>";\n'
|
||||||
|
' h+="<td>"+ok+"</td></tr>";\n'
|
||||||
|
' });\n'
|
||||||
|
' h+="</tbody></table>";\n'
|
||||||
|
' document.getElementById("md-body").innerHTML=h;\n'
|
||||||
|
' document.getElementById("md-overlay").style.display="flex";\n'
|
||||||
|
'}\n'
|
||||||
|
'function closeModal(){document.getElementById("md-overlay").style.display="none";}\n'
|
||||||
|
'var tb=document.querySelector(".history-table tbody");\n'
|
||||||
|
'if(tb)tb.addEventListener("click",function(e){var tr=e.target.closest("tr[data-idx]");if(tr)openModal(parseInt(tr.dataset.idx,10));});\n'
|
||||||
|
'document.getElementById("md-close").addEventListener("click",closeModal);\n'
|
||||||
|
'document.getElementById("md-overlay").addEventListener("click",function(e){if(e.target===this)closeModal();});\n'
|
||||||
|
'document.addEventListener("keydown",function(e){if(e.key==="Escape")closeModal();});\n'
|
||||||
|
'})();\n</script>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_status_html(status: dict | None, history: list, is_running: bool = False) -> str:
|
||||||
|
"""Generate the complete HTML page for /scrapers-status."""
|
||||||
|
head_open = (
|
||||||
|
'<!DOCTYPE html>\n<html lang="cs">\n<head>\n'
|
||||||
|
'<meta charset="UTF-8">\n'
|
||||||
|
'<meta name="viewport" content="width=device-width, initial-scale=1.0">\n'
|
||||||
|
f'<title>Scraper status</title>\n<style>{_CSS}</style>\n'
|
||||||
|
)
|
||||||
|
page_header = '<h1>Scraper status</h1>\n<div class="subtitle">maru-hleda-byt</div>\n'
|
||||||
|
footer = '<div class="link-row"><a href="/mapa_bytu.html">Otevřít mapu</a></div>'
|
||||||
|
|
||||||
|
if status is None:
|
||||||
|
return (
|
||||||
|
head_open + '</head>\n<body>\n' + page_header
|
||||||
|
+ '<div class="card"><p style="color:#F44336">Status není k dispozici.</p></div>\n'
|
||||||
|
+ footer + '\n</body>\n</html>'
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_running:
|
||||||
|
return (
|
||||||
|
head_open
|
||||||
|
+ '<meta http-equiv="refresh" content="30">\n'
|
||||||
|
+ '</head>\n<body>\n' + page_header
|
||||||
|
+ '<div class="loader-wrap"><div class="spinner"></div>'
|
||||||
|
+ '<div class="loader-text">Scraper právě běží…</div></div>\n'
|
||||||
|
+ footer + '\n</body>\n</html>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Done state ────────────────────────────────────────────────────────────
|
||||||
|
ts = status.get("timestamp", "")
|
||||||
|
duration = status.get("duration_sec")
|
||||||
|
total_accepted = status.get("total_accepted", 0)
|
||||||
|
deduplicated = status.get("deduplicated")
|
||||||
|
|
||||||
|
ts_card = (
|
||||||
|
'<div class="card"><h2>Poslední scrape</h2>'
|
||||||
|
f'<div class="timestamp">{_fmt_date(ts)}</div>'
|
||||||
|
+ (f'<div class="timestamp-sub">Trvání: {round(duration)}s</div>' if duration is not None else "")
|
||||||
|
+ '</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
sum_card = (
|
||||||
|
'<div class="card"><h2>Souhrn</h2>'
|
||||||
|
f'<div class="summary-row"><span class="summary-label">Vyhovujících bytů</span>'
|
||||||
|
f'<span class="summary-value" style="color:#4CAF50">{total_accepted}</span></div>'
|
||||||
|
+ (
|
||||||
|
f'<div class="summary-row"><span class="summary-label">Po deduplikaci (v mapě)</span>'
|
||||||
|
f'<span class="summary-value" style="color:#1976D2">{deduplicated}</span></div>'
|
||||||
|
if deduplicated is not None else ""
|
||||||
|
)
|
||||||
|
+ '</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
rows_for_js = list(reversed(history))
|
||||||
|
body = (
|
||||||
|
page_header
|
||||||
|
+ ts_card + "\n"
|
||||||
|
+ sum_card + "\n"
|
||||||
|
+ _sources_html(status.get("sources", [])) + "\n"
|
||||||
|
+ _history_html(history) + "\n"
|
||||||
|
+ footer
|
||||||
|
)
|
||||||
|
modal = _modal_script(json.dumps(rows_for_js, ensure_ascii=False))
|
||||||
|
return head_open + '</head>\n<body>\n' + body + '\n' + modal + '\n</body>\n</html>'
|
||||||
|
|
||||||
|
|
||||||
|
# ── HTTP handler ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class Handler(SimpleHTTPRequestHandler):
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
pass # suppress default access log; use our own where needed
|
||||||
|
|
||||||
|
def _send_json(self, status: int, body, extra_headers=None):
|
||||||
|
payload = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(payload)))
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
|
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
if extra_headers:
|
||||||
|
for k, v in extra_headers.items():
|
||||||
|
self.send_header(k, v)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(payload)
|
||||||
|
|
||||||
|
def do_OPTIONS(self):
|
||||||
|
self.send_response(204)
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
|
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path.startswith("/api/"):
|
||||||
|
self._handle_api_get()
|
||||||
|
elif self.path.rstrip("/") == "/scrapers-status":
|
||||||
|
self._serve_status_page()
|
||||||
|
else:
|
||||||
|
log.debug("GET %s → static file: %s", self.path, self.translate_path(self.path))
|
||||||
|
super().do_GET()
|
||||||
|
|
||||||
|
def _handle_api_get(self):
|
||||||
|
if self.path in ("/api/ratings", "/api/ratings/export"):
|
||||||
ratings = load_ratings()
|
ratings = load_ratings()
|
||||||
log.info("GET /api/ratings → %d ratings", len(ratings))
|
extra = None
|
||||||
return jsonify(ratings)
|
if self.path == "/api/ratings/export":
|
||||||
|
extra = {"Content-Disposition": 'attachment; filename="ratings.json"'}
|
||||||
|
log.info("GET %s → %d ratings", self.path, len(ratings))
|
||||||
|
self._send_json(200, ratings, extra)
|
||||||
|
elif self.path == "/api/status":
|
||||||
|
data = _load_json(DATA_DIR / "status.json")
|
||||||
|
if data is None:
|
||||||
|
self._send_json(404, {"error": "status not available"})
|
||||||
|
return
|
||||||
|
log.info("GET /api/status → ok")
|
||||||
|
self._send_json(200, data)
|
||||||
|
elif self.path == "/api/status/history":
|
||||||
|
data = _load_json(DATA_DIR / "scraper_history.json", default=[])
|
||||||
|
if not isinstance(data, list):
|
||||||
|
data = []
|
||||||
|
log.info("GET /api/status/history → %d entries", len(data))
|
||||||
|
self._send_json(200, data)
|
||||||
|
else:
|
||||||
|
self._send_json(404, {"error": "not found"})
|
||||||
|
|
||||||
|
def _serve_status_page(self):
|
||||||
|
status = _load_json(DATA_DIR / "status.json")
|
||||||
|
history = _load_json(DATA_DIR / "scraper_history.json", default=[])
|
||||||
|
if not isinstance(history, list):
|
||||||
|
history = []
|
||||||
|
is_running = (DATA_DIR / "scraper_running.json").exists()
|
||||||
|
html = _render_status_html(status, history, is_running)
|
||||||
|
payload = html.encode("utf-8")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(payload)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(payload)
|
||||||
|
|
||||||
@app.route("/api/ratings/export", methods=["GET"])
|
def do_POST(self):
|
||||||
def export_ratings():
|
if self.path == "/api/ratings":
|
||||||
ratings = load_ratings()
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
log.info("GET /api/ratings/export → %d ratings", len(ratings))
|
if length == 0:
|
||||||
response = jsonify(ratings)
|
self._send_json(400, {"error": "empty body"})
|
||||||
response.headers["Content-Disposition"] = 'attachment; filename="ratings.json"'
|
return
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/ratings", methods=["POST"])
|
|
||||||
def post_ratings():
|
|
||||||
length = request.content_length
|
|
||||||
if not length:
|
|
||||||
return jsonify({"error": "empty body"}), 400
|
|
||||||
try:
|
try:
|
||||||
data = request.get_json(force=True, silent=False)
|
raw = self.rfile.read(length)
|
||||||
|
data = json.loads(raw.decode("utf-8"))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning("Bad request body: %s", e)
|
log.warning("Bad request body: %s", e)
|
||||||
return jsonify({"error": "invalid JSON"}), 400
|
self._send_json(400, {"error": "invalid JSON"})
|
||||||
|
return
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return jsonify({"error": "expected JSON object"}), 400
|
self._send_json(400, {"error": "expected JSON object"})
|
||||||
|
return
|
||||||
save_ratings(data)
|
save_ratings(data)
|
||||||
log.info("POST /api/ratings → saved %d ratings", len(data))
|
log.info("POST /api/ratings → saved %d ratings", len(data))
|
||||||
return jsonify({"ok": True, "count": len(data)})
|
self._send_json(200, {"ok": True, "count": len(data)})
|
||||||
|
else:
|
||||||
|
self._send_json(404, {"error": "not found"})
|
||||||
@app.route("/")
|
|
||||||
def index():
|
|
||||||
return send_from_directory(str(DATA_DIR), "mapa_bytu.html")
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/<path:filename>")
|
|
||||||
def static_files(filename):
|
|
||||||
return send_from_directory(str(DATA_DIR), filename)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
log.info("Server starting on port %d, data dir: %s", PORT, DATA_DIR)
|
log.info("Server starting on port %d, data dir: %s", PORT, DATA_DIR)
|
||||||
log.info("Ratings file: %s", RATINGS_FILE)
|
handler = functools.partial(Handler, directory=str(DATA_DIR))
|
||||||
app.run(host="0.0.0.0", port=PORT)
|
server = HTTPServer(("0.0.0.0", PORT), handler)
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
log.info("Stopped.")
|
||||||
|
sys.exit(0)
|
||||||
|
|||||||
204
status.html
204
status.html
@@ -1,204 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="cs">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Scraper status</title>
|
|
||||||
<style>
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
|
||||||
background: #f5f5f5; color: #333;
|
|
||||||
padding: 24px; max-width: 640px; margin: 0 auto;
|
|
||||||
}
|
|
||||||
h1 { font-size: 22px; margin-bottom: 4px; }
|
|
||||||
.subtitle { color: #888; font-size: 13px; margin-bottom: 24px; }
|
|
||||||
.card {
|
|
||||||
background: white; border-radius: 12px; padding: 20px;
|
|
||||||
box-shadow: 0 1px 4px rgba(0,0,0,0.08); margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
.card h2 { font-size: 15px; margin-bottom: 12px; color: #555; }
|
|
||||||
.timestamp {
|
|
||||||
font-size: 28px; font-weight: 700; color: #1976D2;
|
|
||||||
}
|
|
||||||
.timestamp-ago { font-size: 13px; color: #999; margin-top: 2px; }
|
|
||||||
|
|
||||||
/* Source table */
|
|
||||||
.source-table { width: 100%; border-collapse: collapse; }
|
|
||||||
.source-table td { padding: 8px 0; border-bottom: 1px solid #f0f0f0; font-size: 14px; }
|
|
||||||
.source-table tr:last-child td { border-bottom: none; }
|
|
||||||
.source-table .name { font-weight: 600; }
|
|
||||||
.source-table .count { text-align: right; font-variant-numeric: tabular-nums; }
|
|
||||||
.source-table .rejected { text-align: right; color: #999; font-size: 12px; }
|
|
||||||
.badge {
|
|
||||||
display: inline-block; padding: 2px 8px; border-radius: 4px;
|
|
||||||
font-size: 11px; font-weight: 600; color: white;
|
|
||||||
}
|
|
||||||
.badge-ok { background: #4CAF50; }
|
|
||||||
.badge-err { background: #F44336; }
|
|
||||||
.badge-skip { background: #FF9800; }
|
|
||||||
|
|
||||||
/* Summary bar */
|
|
||||||
.summary-row {
|
|
||||||
display: flex; justify-content: space-between; align-items: center;
|
|
||||||
padding: 10px 0; border-bottom: 1px solid #f0f0f0;
|
|
||||||
}
|
|
||||||
.summary-row:last-child { border-bottom: none; }
|
|
||||||
.summary-label { font-size: 13px; color: #666; }
|
|
||||||
.summary-value { font-size: 18px; font-weight: 700; }
|
|
||||||
|
|
||||||
/* Source bar chart */
|
|
||||||
.bar-row { display: flex; align-items: center; gap: 8px; margin: 4px 0; }
|
|
||||||
.bar-label { width: 90px; font-size: 12px; text-align: right; color: #666; }
|
|
||||||
.bar-track { flex: 1; height: 20px; background: #f0f0f0; border-radius: 4px; overflow: hidden; position: relative; }
|
|
||||||
.bar-fill { height: 100%; border-radius: 4px; transition: width 0.5s ease; }
|
|
||||||
.bar-count { font-size: 12px; width: 36px; font-variant-numeric: tabular-nums; }
|
|
||||||
|
|
||||||
/* Loader */
|
|
||||||
.loader-wrap {
|
|
||||||
display: flex; flex-direction: column; align-items: center;
|
|
||||||
justify-content: center; padding: 60px 0;
|
|
||||||
}
|
|
||||||
.spinner {
|
|
||||||
width: 40px; height: 40px; border: 4px solid #e0e0e0;
|
|
||||||
border-top-color: #1976D2; border-radius: 50%;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
.loader-text { margin-top: 16px; color: #999; font-size: 14px; }
|
|
||||||
|
|
||||||
.error-msg { color: #F44336; padding: 40px 0; text-align: center; }
|
|
||||||
.link-row { text-align: center; margin-top: 8px; }
|
|
||||||
.link-row a { color: #1976D2; text-decoration: none; font-size: 14px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<h1>Scraper status</h1>
|
|
||||||
<div class="subtitle">maru-hleda-byt</div>
|
|
||||||
|
|
||||||
<div id="content">
|
|
||||||
<div class="loader-wrap">
|
|
||||||
<div class="spinner"></div>
|
|
||||||
<div class="loader-text">Nacitam status...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="link-row"><a href="mapa_bytu.html">Otevrit mapu</a></div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
var COLORS = {
|
|
||||||
sreality: '#1976D2',
|
|
||||||
realingo: '#7B1FA2',
|
|
||||||
bezrealitky: '#E65100',
|
|
||||||
idnes: '#C62828',
|
|
||||||
psn: '#2E7D32',
|
|
||||||
cityhome: '#00838F',
|
|
||||||
};
|
|
||||||
|
|
||||||
function timeAgo(dateStr) {
|
|
||||||
var d = new Date(dateStr);
|
|
||||||
var now = new Date();
|
|
||||||
var diff = Math.floor((now - d) / 1000);
|
|
||||||
if (diff < 60) return 'prave ted';
|
|
||||||
if (diff < 3600) return Math.floor(diff / 60) + ' min zpet';
|
|
||||||
if (diff < 86400) return Math.floor(diff / 3600) + ' hod zpet';
|
|
||||||
return Math.floor(diff / 86400) + ' dni zpet';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
|
||||||
var d = new Date(dateStr);
|
|
||||||
var day = d.getDate();
|
|
||||||
var months = ['ledna','unora','brezna','dubna','kvetna','cervna',
|
|
||||||
'cervence','srpna','zari','rijna','listopadu','prosince'];
|
|
||||||
var hh = String(d.getHours()).padStart(2, '0');
|
|
||||||
var mm = String(d.getMinutes()).padStart(2, '0');
|
|
||||||
return day + '. ' + months[d.getMonth()] + ' ' + d.getFullYear() + ', ' + hh + ':' + mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(data) {
|
|
||||||
// Check if scrape is currently running
|
|
||||||
if (data.status === 'running') {
|
|
||||||
document.getElementById('content').innerHTML =
|
|
||||||
'<div class="loader-wrap">' +
|
|
||||||
'<div class="spinner"></div>' +
|
|
||||||
'<div class="loader-text">Scraper prave bezi...</div>' +
|
|
||||||
'</div>';
|
|
||||||
setTimeout(loadStatus, 30000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var sources = data.sources || [];
|
|
||||||
var totalOk = 0, totalRej = 0;
|
|
||||||
var maxCount = 0;
|
|
||||||
sources.forEach(function(s) {
|
|
||||||
totalOk += s.accepted || 0;
|
|
||||||
totalRej += s.rejected || 0;
|
|
||||||
if (s.accepted > maxCount) maxCount = s.accepted;
|
|
||||||
});
|
|
||||||
|
|
||||||
var html = '';
|
|
||||||
|
|
||||||
// Timestamp card
|
|
||||||
html += '<div class="card">';
|
|
||||||
html += '<h2>Posledni scrape</h2>';
|
|
||||||
html += '<div class="timestamp">' + formatDate(data.timestamp) + '</div>';
|
|
||||||
html += '<div class="timestamp-ago">' + timeAgo(data.timestamp) + '</div>';
|
|
||||||
if (data.duration_sec) {
|
|
||||||
html += '<div class="timestamp-ago">Trvani: ' + Math.round(data.duration_sec) + 's</div>';
|
|
||||||
}
|
|
||||||
html += '</div>';
|
|
||||||
|
|
||||||
// Summary card
|
|
||||||
html += '<div class="card">';
|
|
||||||
html += '<h2>Souhrn</h2>';
|
|
||||||
html += '<div class="summary-row"><span class="summary-label">Vyhovujicich bytu</span><span class="summary-value" style="color:#4CAF50">' + totalOk + '</span></div>';
|
|
||||||
html += '<div class="summary-row"><span class="summary-label">Vyloucenych</span><span class="summary-value" style="color:#999">' + totalRej + '</span></div>';
|
|
||||||
if (data.deduplicated !== undefined) {
|
|
||||||
html += '<div class="summary-row"><span class="summary-label">Po deduplikaci (v mape)</span><span class="summary-value" style="color:#1976D2">' + data.deduplicated + '</span></div>';
|
|
||||||
}
|
|
||||||
html += '</div>';
|
|
||||||
|
|
||||||
// Sources card
|
|
||||||
html += '<div class="card">';
|
|
||||||
html += '<h2>Zdroje</h2>';
|
|
||||||
sources.forEach(function(s) {
|
|
||||||
var color = COLORS[s.name.toLowerCase()] || '#999';
|
|
||||||
var pct = maxCount > 0 ? Math.round((s.accepted / maxCount) * 100) : 0;
|
|
||||||
var badge = s.error
|
|
||||||
? '<span class="badge badge-err">chyba</span>'
|
|
||||||
: (s.accepted === 0 ? '<span class="badge badge-skip">0</span>' : '<span class="badge badge-ok">OK</span>');
|
|
||||||
|
|
||||||
html += '<div style="margin-bottom:12px;">';
|
|
||||||
html += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">';
|
|
||||||
html += '<span style="font-weight:600;font-size:14px;">' + s.name + ' ' + badge + '</span>';
|
|
||||||
html += '<span style="font-size:12px;color:#999;">' + (s.rejected || 0) + ' vyloucenych</span>';
|
|
||||||
html += '</div>';
|
|
||||||
html += '<div class="bar-row">';
|
|
||||||
html += '<div class="bar-track"><div class="bar-fill" style="width:' + pct + '%;background:' + color + ';"></div></div>';
|
|
||||||
html += '<span class="bar-count">' + (s.accepted || 0) + '</span>';
|
|
||||||
html += '</div>';
|
|
||||||
html += '</div>';
|
|
||||||
});
|
|
||||||
html += '</div>';
|
|
||||||
|
|
||||||
document.getElementById('content').innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadStatus() {
|
|
||||||
fetch('status.json?t=' + Date.now())
|
|
||||||
.then(function(r) {
|
|
||||||
if (!r.ok) throw new Error(r.status);
|
|
||||||
return r.json();
|
|
||||||
})
|
|
||||||
.then(render)
|
|
||||||
.catch(function(err) {
|
|
||||||
document.getElementById('content').innerHTML =
|
|
||||||
'<div class="error-msg">Status zatim neni k dispozici.<br><small>(' + err.message + ')</small></div>';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
loadStatus();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Reference in New Issue
Block a user