7 Commits

Author SHA1 Message Date
0ea31d3013 Remove tracked generated/data files and fix map link on status page
- Remove byty_*.json, mapa_bytu.html, .DS_Store and settings.local.json from git tracking
  (already in .gitignore, files kept locally)
- Fix "Otevřít mapu" link on scraper status page: / → /mapa_bytu.html

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 20:42:35 +01:00
Jan Novak
4304a42776 Track first_seen/last_changed per property, add map filters and clickable legend
All checks were successful
Build and Push / build (push) Successful in 6s
Scraper changes (all 6 sources):
- Add first_seen: date the hash_id was first scraped, never overwritten
- Add last_changed: date the price last changed (= first_seen when new)
- PSN and CityHome load previous output as a lightweight cache to compute these fields
- merge_and_map.py preserves earliest first_seen when deduplicating cross-source duplicates

Map popup:
- Show "Přidáno: YYYY-MM-DD" and "Změněno: YYYY-MM-DD" in each property popup
- NOVÉ badge and pulsing marker now driven by first_seen == today (more accurate than scraped_at)

Map filters (sidebar):
- New "Přidáno / změněno" dropdown: 1, 2, 3, 4, 5, 7, 14, 30 days or all
- Clickable price/m² legend bands: click to filter to that band, multi-select supported
- "✕ Zobrazit všechny ceny" reset link appears when any band is active

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 16:58:46 +01:00
23d208a5b7 Merge pull request 'Add scraper status collection and presentation' (#3) from add-scraper-statuses into main
Reviewed-on: #3
2026-02-26 09:04:23 +00:00
Jan Novak
00c9144010 Fix DATA_DIR usage in stats/history paths, set env in Dockerfile, add validation docs
All checks were successful
Build and Push / build (push) Successful in 5s
- scraper_stats.py: respect DATA_DIR env var when writing stats_*.json files
- generate_status.py: read stats files and write history from DATA_DIR instead of HERE
- build/Dockerfile: set DATA_DIR=/app/data as default env var
- docs/validation.md: end-to-end Docker validation recipe

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 09:46:16 +01:00
Jan Novak
44c02b45b4 Increase history retention to 20, run scrapers every 4 hours
All checks were successful
Build and Push / build (push) Successful in 7s
- generate_status.py: raise --keep default from 5 to 20 entries
- build/crontab: change schedule from 06:00/18:00 to every 4 hours (*/4)
  covers 6 runs/day ≈ 3.3 days of history at default retention

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 08:53:27 +01:00
Jan Novak
5fb3b984b6 Add status dashboard, server, scraper stats, and DATA_DIR support
All checks were successful
Build and Push / build (push) Successful in 7s
Key changes:
- Replace ratings_server.py + status.html with a unified server.py that
  serves the map, scraper status dashboard, and ratings API in one process
- Add scraper_stats.py utility: each scraper writes per-run stats (fetched,
  accepted, excluded, duration) to stats_<source>.json for the status page
- generate_status.py: respect DATA_DIR env var so status.json lands in the
  configured data directory instead of always the project root
- run_all.sh: replace the {"status":"running"} overwrite of status.json with
  a dedicated scraper_running.json lock file; trap on EXIT ensures cleanup
  even on kill/error, preventing the previous run's results from being wiped
- server.py: detect running state via scraper_running.json existence instead
  of status["status"] field, eliminating the dual-use race condition
- Makefile: add serve (local dev), debug (Docker debug container) targets;
  add SERVER_PORT variable
- build/Dockerfile + entrypoint.sh: switch to server.py, set DATA_DIR,
  adjust volume mounts
- .gitignore: add *.json and *.log to keep runtime data files out of VCS
- mapa_bytu.html: price-per-m² colouring, status link, UX tweaks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 00:30:25 +01:00
6f49533c94 Merge pull request 'Rewrite PSN + CityHome scrapers, add price/m² map coloring, ratings system, and status dashboard' (#2) from ui-tweaks/2026-02-17 into main
Reviewed-on: #2
2026-02-25 21:26:51 +00:00
29 changed files with 1047 additions and 3171 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -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
View File

@@ -1,3 +1,8 @@
.vscode/
__pycache__/
.DS_Store
byty_*.json
*.json
*.log
mapa_bytu.html

View File

@@ -3,9 +3,13 @@ CONTAINER_NAME := maru-hleda-byt
VOLUME_NAME := maru-hleda-byt-data
VALIDATION_CONTAINER := maru-hleda-byt-validation
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
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:
@echo "Available targets:"
@@ -20,8 +24,9 @@ help:
@echo " validation-local-debug - Run validation locally with DEBUG logging"
@echo " restart - Restart the container (stop and run again)"
@echo " clean - Stop container and remove the Docker image"
@echo " serve - Run server.py locally (DATA_DIR=., port $(PORT))"
@echo " serve-debug - Run server.py locally with DEBUG logging"
@echo " serve - Start server.py locally on port 8080"
@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"
build:
@@ -61,18 +66,33 @@ validation-stop:
@docker rm $(VALIDATION_CONTAINER) 2>/dev/null || true
@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:
./run_all.sh --max-pages 1 --max-properties 10
validation-local-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
clean: stop

View File

@@ -5,14 +5,14 @@ RUN apk add --no-cache curl bash tzdata \
&& echo "Europe/Prague" > /etc/timezone
ENV PYTHONUNBUFFERED=1
RUN pip install --no-cache-dir flask
ENV DATA_DIR=/app/data
WORKDIR /app
COPY scrape_and_map.py scrape_realingo.py scrape_bezrealitky.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 regen_map.py generate_status.py scraper_stats.py \
run_all.sh server.py ./
COPY build/crontab /etc/crontabs/root
COPY build/entrypoint.sh /entrypoint.sh

View File

@@ -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

View File

@@ -1,7 +1,7 @@
#!/bin/bash
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
for f in byty_sreality.json byty_realingo.json byty_bezrealitky.json \
@@ -18,5 +18,5 @@ crond -b -l 2
echo "[entrypoint] Starting initial scrape in background..."
bash /app/run_all.sh &
echo "[entrypoint] Starting combined server on port 8080..."
exec DATA_DIR="$DATA_DIR" python3 /app/server.py
echo "[entrypoint] Starting server on port 8080..."
exec python3 /app/server.py

View File

@@ -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"
}
]

View File

@@ -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"
}
]

View File

@@ -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"
}
]

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
]

View File

@@ -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"
}
]

View File

@@ -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
View 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.

View File

@@ -1,16 +1,15 @@
#!/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
import argparse
import json
import os
import re
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional
HERE = Path(__file__).parent
DATA_DIR = Path(os.environ.get("DATA_DIR", HERE))
SOURCE_FILES = {
"Sreality": "byty_sreality.json",
@@ -21,7 +20,17 @@ SOURCE_FILES = {
"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"
HISTORY_FILE = "scraper_history.json"
def count_source(path: Path) -> dict:
@@ -36,105 +45,51 @@ def count_source(path: Path) -> dict:
return {"accepted": 0, "error": str(e)}
def parse_log(log_path: str) -> dict[str, dict]:
"""Parse scraper run log and extract per-source statistics.
Scrapers log summary lines like:
✓ Vyhovující byty: 12
Vyloučeno (prodáno): 5
Staženo stránek: 3
Staženo inzerátů: 48
Celkem bytů v cache: 120
and section headers like:
[2/6] Realingo
"""
if not log_path or not os.path.exists(log_path):
def read_scraper_stats(path: Path) -> dict:
"""Load a per-scraper stats JSON. Returns {} on missing or corrupt file."""
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except Exception:
return {}
with open(log_path, encoding="utf-8") as f:
content = f.read()
# Split into per-source sections by the [N/6] Step header
# Each section header looks like "[2/6] Realingo\n----..."
section_pattern = re.compile(r'\[(\d+)/\d+\]\s+(.+)\n-+', re.MULTILINE)
sections_found = list(section_pattern.finditer(content))
def append_to_history(status: dict, keep: int) -> None:
"""Append the current status entry to scraper_history.json, keeping only `keep` latest."""
history_path = DATA_DIR / HISTORY_FILE
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:
return {}
history.append(status)
stats = {}
for i, match in enumerate(sections_found):
step_name = match.group(2).strip()
start = match.end()
end = sections_found[i + 1].start() if i + 1 < len(sections_found) else len(content)
section_text = content[start:end]
# Keep only the N most recent entries
if keep > 0 and len(history) > keep:
history = history[-keep:]
# Identify which sources this section covers
# "PSN + CityHome" covers both
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
history_path.write_text(json.dumps(history, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"Historie uložena: {history_path} ({len(history)} záznamů)")
def main():
start_time = None
duration_sec = None
parser = argparse.ArgumentParser(description="Generate status.json from scraper outputs.")
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 = sys.argv[1]
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)
start_time = args.start_time or datetime.now().isoformat(timespec="seconds")
duration_sec = args.duration
sources = []
for name, filename in SOURCE_FILES.items():
@@ -142,14 +97,12 @@ def main():
info = count_source(path)
info["name"] = name
# Merge log stats
ls = log_stats.get(name, {})
for k in ("fetched", "pages", "cached", "cache_hits", "excluded", "excluded_total"):
if k in ls:
info[k] = ls[k]
# Override accepted from log if available (log is authoritative for latest run)
if "accepted" in ls:
info["accepted"] = ls["accepted"]
# Merge in stats from the per-scraper stats file (authoritative for run data)
stats = read_scraper_stats(DATA_DIR / STATS_FILES[name])
for key in ("accepted", "fetched", "pages", "cache_hits", "excluded", "excluded_total",
"success", "duration_sec", "error"):
if key in stats:
info[key] = stats[key]
sources.append(info)
@@ -168,17 +121,21 @@ def main():
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": "done",
"timestamp": start_time,
"duration_sec": duration_sec,
"success": success,
"total_accepted": total_accepted,
"deduplicated": deduplicated,
"duplicates_removed": duplicates_removed,
"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")
print(f"Status uložen: {out}")
print(f" Celkem bytů (před dedup): {total_accepted}")
@@ -197,6 +154,8 @@ def main():
parts.append(f"[CHYBA: {err}]")
print(" " + " ".join(parts))
append_to_history(status, args.keep)
if __name__ == "__main__":
main()

View File

@@ -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 /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: '&copy; OpenStreetMap contributors &copy; 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>

View File

@@ -79,6 +79,10 @@ def main():
if key in seen_keys:
dupes += 1
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
print(f" Duplikát: {e['locality']} | {format_price(e['price'])} | {e.get('area', '?')}"
f"({e.get('source', '?')} vs {existing.get('source', '?')})")

View File

@@ -12,8 +12,3 @@
## documentation
- 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

View File

@@ -20,8 +20,10 @@ START_TIME=$(date -u +"%Y-%m-%dT%H:%M:%S")
START_EPOCH=$(date +%s)
LOG_FILE="$(pwd)/scrape_run.log"
# Mark status as running
echo '{"status":"running"}' > status.json
# Mark scraper as running; cleaned up on exit (even on error/kill)
LOCK_FILE="${DATA_DIR:-.}/scraper_running.json"
echo '{"running":true,"started_at":"'"$START_TIME"'"}' > "$LOCK_FILE"
trap 'rm -f "$LOCK_FILE"' EXIT
show_help() {
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-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 " --keep N Počet běhů v historii (výchozí: 5, 0=neomezeno)"
echo " -h, --help Zobrazí tuto nápovědu"
echo ""
echo "Examples:"
echo " ./run_all.sh # plný běh"
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 --keep 10 # uchovej 10 běhů v historii"
}
# Parse arguments
SCRAPER_ARGS=""
KEEP_ARG=""
while [[ $# -gt 0 ]]; do
case $1 in
-h|--help)
@@ -52,6 +57,10 @@ while [[ $# -gt 0 ]]; do
SCRAPER_ARGS="$SCRAPER_ARGS $1 $2"
shift 2
;;
--keep)
KEEP_ARG="--keep $2"
shift 2
;;
*)
echo "Unknown argument: $1"
echo ""
@@ -103,7 +112,7 @@ python3 merge_and_map.py || { echo -e "${RED}✗ Merge selhal${NC}"; FAILED=$((F
END_EPOCH=$(date +%s)
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 "============================================================"

View File

@@ -15,6 +15,9 @@ import urllib.request
import urllib.parse
from datetime import datetime
from pathlib import Path
from scraper_stats import write_stats
STATS_FILE = "stats_sreality.json"
logger = logging.getLogger(__name__)
@@ -209,6 +212,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):
"""Main scraping function. Returns list of filtered estates."""
_run_start = time.time()
_run_ts = datetime.now().isoformat(timespec="seconds")
all_estates_raw = []
cache = load_cache()
@@ -348,6 +353,8 @@ def scrape(max_pages: int | None = None, max_properties: int | None = None):
"url": sreality_url(hash_id, seo),
"image": (estate.get("_links", {}).get("images", [{}])[0].get("href", "") if estate.get("_links", {}).get("images") else ""),
"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"),
}
results.append(result)
details_fetched += 1
@@ -366,6 +373,21 @@ def scrape(max_pages: int | None = None, max_properties: int | None = None):
logger.info(f" ✓ Vyhovující byty: {len(results)}")
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
@@ -409,11 +431,19 @@ def generate_map(estates: list[dict], output_path: str = "mapa_bytu.html"):
]
for bcolor, blabel in bands:
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'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>'
)
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
price_legend_items += (
'<div style="display:flex;align-items:center;gap:6px;margin:6px 0 0 0;'
@@ -444,16 +474,28 @@ def generate_map(estates: list[dict], output_path: str = "mapa_bytu.html"):
hash_id = e.get("hash_id", "")
scraped_at = e.get("scraped_at", "")
is_new = scraped_at == datetime.now().strftime("%Y-%m-%d")
first_seen = e.get("first_seen", "")
last_changed = e.get("last_changed", "")
is_new = first_seen == datetime.now().strftime("%Y-%m-%d")
new_badge = (
'<span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;'
'padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span>'
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;">{"&nbsp;·&nbsp;".join(date_parts)}</span><br>'
if date_parts else ""
)
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'<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>'
@@ -461,7 +503,9 @@ def generate_map(estates: list[dict], output_path: str = "mapa_bytu.html"):
f'{floor_note}<br><br>'
f'<b>{e["locality"]}</b><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'style="color:{source_color};text-decoration:none;font-weight:bold;">'
f'→ Otevřít na {source_label}</a>'
@@ -493,7 +537,7 @@ def generate_map(estates: list[dict], output_path: str = "mapa_bytu.html"):
else:
marker_fn = "addMarker"
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
@@ -597,6 +641,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; }}
.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; }}
.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; }}
#floor-filter {{ margin-top: 8px; }}
#floor-filter select {{ width: 100%; padding: 4px; border-radius: 4px; border: 1px solid #ccc; }}
@@ -643,6 +691,21 @@ def generate_map(estates: list[dict], output_path: str = "mapa_bytu.html"):
</select>
</label>
</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>
</label>
</div>
</div>
<div class="filter-section">
<div id="rating-counts" style="margin-bottom:6px;font-size:12px;color:#666;">
@@ -653,7 +716,7 @@ def generate_map(estates: list[dict], output_path: str = "mapa_bytu.html"):
Skrýt zamítnuté
</label>
</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>
<script>
@@ -673,9 +736,39 @@ L.tileLayer('https://{{s}}.basemaps.cartocdn.com/light_only_labels/{{z}}/{{x}}/{
pane: 'shadowPane',
}}).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 = [];
function addMarker(lat, lon, color, popup, hashId) {{
function addMarker(lat, lon, color, popup, hashId, firstSeen, lastChanged) {{
var marker = L.circleMarker([lat, lon], {{
radius: 8,
fillColor: color,
@@ -684,12 +777,12 @@ function addMarker(lat, lon, color, popup, hashId) {{
opacity: 1,
fillOpacity: 0.85,
}}).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);
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], {{
radius: 12,
fillColor: color,
@@ -698,7 +791,7 @@ function addNewMarker(lat, lon, color, popup, hashId) {{
opacity: 0.35,
fillOpacity: 0.95,
}}).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);
marker.addTo(map);
marker.on('add', function() {{
@@ -736,11 +829,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], {{
icon: heartIcon(color),
}}).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);
marker.addTo(map);
}}
@@ -998,9 +1091,17 @@ 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 daysFilter = parseInt(document.getElementById('days-filter').value) || 0;
var ratings = loadRatings();
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) {{
var popup = m.getPopup().getContent();
var floorMatch = popup.match(/(\\d+)\\. NP/);
@@ -1013,6 +1114,14 @@ function applyFilters() {{
if (floor !== null && floor < minFloor) 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];
if (hideRejected && r && r.status === 'reject') show = false;
@@ -1089,8 +1198,22 @@ if __name__ == "__main__":
handlers=[logging.StreamHandler()]
)
_run_ts = datetime.now().isoformat(timespec="seconds")
start = time.time()
estates = scrape(max_pages=args.max_pages, max_properties=args.max_properties)
try:
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:
# Save raw data as JSON backup

View File

@@ -15,6 +15,9 @@ import re
import time
import urllib.request
from pathlib import Path
from scraper_stats import write_stats
STATS_FILE = "stats_bezrealitky.json"
logger = logging.getLogger(__name__)
@@ -171,6 +174,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):
_run_start = time.time()
_run_ts = datetime.now().isoformat(timespec="seconds")
cache = load_cache()
logger.info("=" * 60)
@@ -357,6 +362,8 @@ def scrape(max_pages: int | None = None, max_properties: int | None = None):
"source": "bezrealitky",
"image": "",
"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"),
}
results.append(result)
properties_fetched += 1
@@ -374,6 +381,25 @@ def scrape(max_pages: int | None = None, max_properties: int | None = None):
logger.info(f" ✓ Vyhovující byty: {len(results)}")
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
@@ -394,8 +420,22 @@ if __name__ == "__main__":
handlers=[logging.StreamHandler()]
)
_run_ts = datetime.now().isoformat(timespec="seconds")
start = time.time()
estates = scrape(max_pages=args.max_pages, max_properties=args.max_properties)
try:
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:
json_path = Path("byty_bezrealitky.json")

View File

@@ -14,6 +14,9 @@ import time
import urllib.request
from datetime import datetime
from pathlib import Path
from scraper_stats import write_stats
STATS_FILE = "stats_cityhome.json"
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):
_run_start = time.time()
_run_ts = datetime.now().isoformat(timespec="seconds")
logger.info("=" * 60)
logger.info("Stahuji inzeráty z CityHome (city-home.cz)")
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:
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
logger.info(f"\nFáze 3: Filtrování...")
results = []
@@ -357,6 +372,8 @@ def scrape(max_pages: int | None = None, max_properties: int | None = None):
"source": "cityhome",
"image": "",
"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")),
}
results.append(result)
properties_fetched += 1
@@ -374,6 +391,23 @@ def scrape(max_pages: int | None = None, max_properties: int | None = None):
logger.info(f" ✓ Vyhovující byty: {len(results)}")
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
@@ -394,8 +428,22 @@ if __name__ == "__main__":
handlers=[logging.StreamHandler()]
)
_run_ts = datetime.now().isoformat(timespec="seconds")
start = time.time()
estates = scrape(max_pages=args.max_pages, max_properties=args.max_properties)
try:
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:
json_path = Path("byty_cityhome.json")

View File

@@ -17,6 +17,9 @@ import urllib.request
import urllib.parse
from html.parser import HTMLParser
from pathlib import Path
from scraper_stats import write_stats
STATS_FILE = "stats_idnes.json"
logger = logging.getLogger(__name__)
@@ -279,6 +282,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):
_run_start = time.time()
_run_ts = datetime.now().isoformat(timespec="seconds")
cache = load_cache()
logger.info("=" * 60)
@@ -460,6 +465,8 @@ def scrape(max_pages: int | None = None, max_properties: int | None = None):
"source": "idnes",
"image": "",
"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"),
}
results.append(result)
properties_fetched += 1
@@ -478,6 +485,25 @@ def scrape(max_pages: int | None = None, max_properties: int | None = None):
logger.info(f" ✓ Vyhovující byty: {len(results)}")
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
@@ -498,8 +524,22 @@ if __name__ == "__main__":
handlers=[logging.StreamHandler()]
)
_run_ts = datetime.now().isoformat(timespec="seconds")
start = time.time()
estates = scrape(max_pages=args.max_pages, max_properties=args.max_properties)
try:
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:
json_path = Path("byty_idnes.json")

View File

@@ -15,6 +15,9 @@ import time
from datetime import datetime
from pathlib import Path
from urllib.parse import urlencode
from scraper_stats import write_stats
STATS_FILE = "stats_psn.json"
logger = logging.getLogger(__name__)
@@ -67,6 +70,8 @@ def format_price(price: int) -> str:
def scrape(max_properties: int | None = None):
_run_start = time.time()
_run_ts = datetime.now().isoformat(timespec="seconds")
logger.info("=" * 60)
logger.info("Stahuji inzeráty z PSN.cz")
logger.info(f"Cena: do {format_price(MAX_PRICE)}")
@@ -93,11 +98,30 @@ def scrape(max_properties: int | None = None):
data = fetch_json(url)
except Exception as e:
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 []
all_units = data.get("units", {}).get("data", [])
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í
results = []
excluded = {
@@ -228,26 +252,35 @@ def scrape(max_properties: int | None = None):
"source": "psn",
"image": "",
"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")),
}
results.append(result)
properties_fetched += 1
logger.info(f"\n{'=' * 60}")
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():
if count:
logger.info(f" Vyloučeno ({reason}): {count}")
logger.info(f" ✓ Vyhovující byty: {len(results)}")
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
if __name__ == "__main__":
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,
help="Maximum number of properties to include in results")
parser.add_argument("--log-level", type=str, default="INFO",
@@ -261,8 +294,22 @@ if __name__ == "__main__":
handlers=[logging.StreamHandler()]
)
_run_ts = datetime.now().isoformat(timespec="seconds")
start = time.time()
estates = scrape(max_properties=args.max_properties)
try:
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:
json_path = Path("byty_psn.json")

View File

@@ -15,6 +15,9 @@ import re
import time
import urllib.request
from pathlib import Path
from scraper_stats import write_stats
STATS_FILE = "stats_realingo.json"
logger = logging.getLogger(__name__)
@@ -136,6 +139,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):
_run_start = time.time()
_run_ts = datetime.now().isoformat(timespec="seconds")
cache = load_cache()
logger.info("=" * 60)
@@ -316,6 +321,8 @@ def scrape(max_pages: int | None = None, max_properties: int | None = None):
"source": "realingo",
"image": "",
"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"),
}
results.append(result)
properties_fetched += 1
@@ -333,6 +340,25 @@ def scrape(max_pages: int | None = None, max_properties: int | None = None):
logger.info(f" ✓ Vyhovující byty: {len(results)}")
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
@@ -353,8 +379,22 @@ if __name__ == "__main__":
handlers=[logging.StreamHandler()]
)
_run_ts = datetime.now().isoformat(timespec="seconds")
start = time.time()
estates = scrape(max_pages=args.max_pages, max_properties=args.max_properties)
try:
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:
json_path = Path("byty_realingo.json")

15
scraper_stats.py Normal file
View File

@@ -0,0 +1,15 @@
"""Shared utility for writing per-scraper run statistics to JSON."""
from __future__ import annotations
import json
import os
from pathlib import Path
HERE = Path(__file__).parent
DATA_DIR = Path(os.environ.get("DATA_DIR", HERE))
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")

502
server.py
View File

@@ -1,60 +1,78 @@
#!/usr/bin/env python3
"""
Combined HTTP server: serves static files from DATA_DIR and
provides the ratings API at /api/ratings.
General-purpose HTTP server for maru-hleda-byt.
GET /api/ratings → returns ratings.json contents
POST /api/ratings → saves entire ratings object
GET /api/ratings/export → same as GET, with Content-Disposition: attachment
GET /<path> → serves static file from DATA_DIR
Serves static files from DATA_DIR and additionally handles:
GET /scrapers-status → SSR scraper status page
GET /api/ratings → ratings.json contents
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 logging
import os
import sys
from datetime import datetime
from http.server import HTTPServer, SimpleHTTPRequestHandler
from pathlib import Path
from flask import Flask, jsonify, request, send_from_directory
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()
PORT = int(os.environ.get("SERVER_PORT", 8080))
DATA_DIR = Path(os.environ.get("DATA_DIR", "."))
RATINGS_FILE = DATA_DIR / "ratings.json"
_LOG_LEVEL = getattr(logging, os.environ.get("LOG_LEVEL", "INFO").upper(), logging.INFO)
logging.basicConfig(
level=log_level,
level=_LOG_LEVEL,
format="%(asctime)s [server] %(levelname)s %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S",
)
log = logging.getLogger(__name__)
app = Flask(__name__, static_folder=None)
app.json.ensure_ascii = False
# ── Helpers ──────────────────────────────────────────────────────────────────
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 add_cors(response):
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
response.headers["Access-Control-Allow-Headers"] = "Content-Type"
return response
def _load_json(path: Path, default=None):
"""Read and parse JSON file; return default on missing or parse error."""
log.debug("_load_json: %s", path.resolve())
try:
if path.exists():
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:
try:
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 {}
return _load_json(RATINGS_FILE, default={})
def save_ratings(data: dict) -> None:
@@ -64,56 +82,396 @@ def save_ratings(data: dict) -> None:
)
@app.route("/api/ratings", methods=["OPTIONS"])
@app.route("/api/ratings/export", methods=["OPTIONS"])
def ratings_options():
return ("", 204)
# ── SSR status page ──────────────────────────────────────────────────────────
_CSS = """\
* { 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 get_ratings():
ratings = load_ratings()
log.info("GET /api/ratings → %d ratings", len(ratings))
return jsonify(ratings)
def _sources_html(sources: list) -> str:
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)
@app.route("/api/ratings/export", methods=["GET"])
def export_ratings():
ratings = load_ratings()
log.info("GET /api/ratings/export → %d ratings", len(ratings))
response = jsonify(ratings)
response.headers["Content-Disposition"] = 'attachment; filename="ratings.json"'
return response
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&nbsp;/&nbsp;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}&nbsp;{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", "-")}&nbsp;/&nbsp;{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)
@app.route("/api/ratings", methods=["POST"])
def post_ratings():
length = request.content_length
if not length:
return jsonify({"error": "empty body"}), 400
try:
data = request.get_json(force=True, silent=False)
except Exception as e:
log.warning("Bad request body: %s", e)
return jsonify({"error": "invalid JSON"}), 400
if not isinstance(data, dict):
return jsonify({"error": "expected JSON object"}), 400
save_ratings(data)
log.info("POST /api/ratings → saved %d ratings", len(data))
return jsonify({"ok": True, "count": len(data)})
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">&times;</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]+":&nbsp;"+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>'
)
@app.route("/")
def index():
return send_from_directory(str(DATA_DIR), "mapa_bytu.html")
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>'
@app.route("/<path:filename>")
def static_files(filename):
return send_from_directory(str(DATA_DIR), filename)
# ── 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()
extra = None
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)
def do_POST(self):
if self.path == "/api/ratings":
length = int(self.headers.get("Content-Length", 0))
if length == 0:
self._send_json(400, {"error": "empty body"})
return
try:
raw = self.rfile.read(length)
data = json.loads(raw.decode("utf-8"))
except Exception as e:
log.warning("Bad request body: %s", e)
self._send_json(400, {"error": "invalid JSON"})
return
if not isinstance(data, dict):
self._send_json(400, {"error": "expected JSON object"})
return
save_ratings(data)
log.info("POST /api/ratings → saved %d ratings", len(data))
self._send_json(200, {"ok": True, "count": len(data)})
else:
self._send_json(404, {"error": "not found"})
if __name__ == "__main__":
log.info("Server starting on port %d, data dir: %s", PORT, DATA_DIR)
log.info("Ratings file: %s", RATINGS_FILE)
app.run(host="0.0.0.0", port=PORT)
handler = functools.partial(Handler, directory=str(DATA_DIR))
server = HTTPServer(("0.0.0.0", PORT), handler)
try:
server.serve_forever()
except KeyboardInterrupt:
log.info("Stopped.")
sys.exit(0)

View File

@@ -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>