diff --git a/.gitignore b/.gitignore index 6ab07cf..31d198d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ __pycache__/helper.cpython-313.pyc +backend/zip_cache.json diff --git a/README.md b/README.md index 3f78689..a0564d9 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,352 @@ -# kleinanzeigen-boosted +# Kleinanzeigen Boosted -***WIP*** +A web-based map visualization tool for searching and exploring listings from kleinanzeigen.de with real-time geographic display on OpenStreetMap. + +## Features + +- 🗺️ Interactive map visualization +- 🔍 Advanced search with price range (more options in future) +- 📍 Automatic geocoding of listings via Nominatim API +- ⚡ Parallel scraping with concurrent workers +- 📊 Prometheus-compatible metrics endpoint +- 🎯 Real-time progress tracking with ETA +- 💾 ZIP code caching to minimize API calls +- 🌐 User location display on map + +## Architecture + +**Backend**: Flask API server with multi-threaded scraping +**Frontend**: Vanilla JavaScript with Leaflet.js for maps +**Data Sources**: kleinanzeigen.de, OpenStreetMap/Nominatim ## Requirements -``` +### Python Packages + +```bash pip install flask flask-cors beautifulsoup4 lxml urllib3 requests ``` +### System Requirements +- Python 3.8+ +- nginx (for production deployment) + +## Installation + +### 1. Create System User + +```bash +mkdir -p /home/kleinanzeigenscraper/ +useradd --system -K MAIL_DIR=/dev/null kleinanzeigenscraper -d /home/kleinanzeigenscraper +chown -R kleinanzeigenscraper:kleinanzeigenscraper /home/kleinanzeigenscraper +``` + +### 2. Clone Repository + +```bash +cd /home/kleinanzeigenscraper/ +mkdir git +cd git +git clone https://git.mosad.xyz/localhorst/kleinanzeigen-boosted.git +cd kleinanzeigen-boosted +git checkout main +``` + +### 3. Install Dependencies + +```bash +pip install flask flask-cors beautifulsoup4 lxml urllib3 requests +``` + +### 4. Configure Application + +Create `config.json`: + +```json +{ + "server": { + "host": "127.0.0.1", + "port": 5000, + "debug": false + }, + "scraping": { + "session_timeout": 300, + "listings_per_page": 25, + "max_workers": 5, + "min_workers": 2, + "rate_limit_delay": 0.5, + "geocoding_delay": 1.0 + }, + "cache": { + "zip_cache_file": "zip_cache.json" + }, + "apis": { + "nominatim": { + "url": "https://nominatim.openstreetmap.org/search", + "user_agent": "kleinanzeigen-scraper" + }, + "kleinanzeigen": { + "base_url": "https://www.kleinanzeigen.de" + } + }, + "user_agents": [ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + ] +} +``` + +### 5. Create Systemd Service + +Create `/lib/systemd/system/kleinanzeigenscraper.service`: + +```ini +[Unit] +Description=Kleinanzeigen Scraper API +After=network.target systemd-networkd-wait-online.service + +[Service] +Type=simple +User=kleinanzeigenscraper +WorkingDirectory=/home/kleinanzeigenscraper/git/kleinanzeigen-boosted/backend/ +ExecStart=/usr/bin/python3 scrape_proxy.py +Restart=on-failure +RestartSec=10 +StandardOutput=append:/var/log/kleinanzeigenscraper.log +StandardError=append:/var/log/kleinanzeigenscraper.log + +[Install] +WantedBy=multi-user.target +``` + +### 6. Enable and Start Service + +```bash +systemctl daemon-reload +systemctl enable kleinanzeigenscraper.service +systemctl start kleinanzeigenscraper.service +systemctl status kleinanzeigenscraper.service +``` + +### 7. Configure nginx Reverse Proxy + +Create `/etc/nginx/sites-available/kleinanzeigenscraper`: + +```nginx +server { + listen 80; + server_name your-domain.com; + + # Redirect HTTP to HTTPS + return 301 https://$server_name$request_uri; +} + +server { + listen 443 ssl http2; + server_name your-domain.com; + + ssl_certificate /path/to/ssl/cert.pem; + ssl_certificate_key /path/to/ssl/key.pem; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + location / { + proxy_pass http://127.0.0.1:5000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300; + } + + location /api/ { + proxy_pass http://127.0.0.1:5000/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300; + } +} +``` + +Enable site: + +```bash +ln -s /etc/nginx/sites-available/kleinanzeigenscraper /etc/nginx/sites-enabled/ +nginx -t +systemctl reload nginx +``` + +## API Endpoints + +### `POST /api/search` +Start a new search session. + +**Request Body:** +```json +{ + "search_term": "Fahrrad", + "num_listings": 25, + "min_price": 0, + "max_price": 1000 +} +``` + +**Response:** +```json +{ + "session_id": "uuid-string", + "total": 25 +} +``` + +### `GET /api/scrape/` +Get the next scraped listing from an active session. + +**Response:** +```json +{ + "complete": false, + "listing": { + "title": "Mountain Bike", + "price": 450, + "id": 123456, + "zip_code": "76593", + "address": "Gernsbach", + "date_added": "2025-11-20", + "image": "https://...", + "url": "https://...", + "lat": 48.7634, + "lon": 8.3344 + }, + "progress": { + "current": 5, + "total": 25 + } +} +``` + +### `POST /api/scrape//cancel` +Cancel an active scraping session and delete cached listings. + +**Response:** +```json +{ + "cancelled": true, + "message": "Session deleted" +} +``` + +### `GET /api/health` +Health check endpoint. + +**Response:** +```json +{ + "status": "ok" +} +``` + +### `GET /api/metrics` +Prometheus-compatible metrics endpoint. + +**Response** (text/plain): +``` +# HELP search_requests_total Total number of search requests +# TYPE search_requests_total counter +search_requests_total 42 + +# HELP scrape_requests_total Total number of scrape requests +# TYPE scrape_requests_total counter +scrape_requests_total 1050 + +# HELP uptime_seconds Application uptime in seconds +# TYPE uptime_seconds gauge +uptime_seconds 86400 + +# HELP active_sessions Number of active scraping sessions +# TYPE active_sessions gauge +active_sessions 2 + +# HELP cache_size Number of cached ZIP codes +# TYPE cache_size gauge +zip_code_cache_size 150 + +# HELP kleinanzeigen_http_responses_total HTTP responses from kleinanzeigen.de +# TYPE kleinanzeigen_http_responses_total counter +kleinanzeigen_http_responses_total{code="200"} 1000 +kleinanzeigen_http_responses_total{code="error"} 5 + +# HELP nominatim_http_responses_total HTTP responses from Nominatim API +# TYPE nominatim_http_responses_total counter +nominatim_http_responses_total{code="200"} 150 +``` + +## Configuration Options + +### Server Configuration +- `host`: Bind address (default: 0.0.0.0) +- `port`: Port number (default: 5000) +- `debug`: Debug mode (default: false) + +### Scraping Configuration +- `session_timeout`: Session expiry in seconds (default: 300) +- `listings_per_page`: Listings per page on kleinanzeigen.de (default: 25) +- `max_workers`: Number of parallel scraping threads (default: 4) +- `min_workers`: Number of parallel scraping threads (default: 2) +- `rate_limit_delay`: Delay between batches in seconds (default: 0.5) +- `geocoding_delay`: Delay between geocoding requests (default: 1.0) + +### Cache Configuration +- `zip_cache_file`: Path to ZIP code cache file (default: zip_cache.json) + +## Monitoring + +View logs: +```bash +tail -f /var/log/kleinanzeigenscraper.log +``` + +Check service status: +```bash +systemctl status kleinanzeigenscraper.service +``` + +Monitor metrics (Prometheus): +```bash +curl http://localhost:5000/api/metrics +``` + +## Development + +Run in debug mode: +```bash +python3 scrape_proxy.py +``` + +Frontend files are located in `web/`: +- `index.html` - Main HTML file +- `css/style.css` - Stylesheet +- `js/config.js` - Configuration +- `js/map.js` - Map functions +- `js/ui.js` - UI functions +- `js/api.js` - API communication +- `js/app.js` - Main application + +## License + +This project is provided as-is for educational purposes. Respect kleinanzeigen.de's terms of service and robots.txt when using this tool. + +## Credits + +Built with: +- Flask (Python web framework) +- Leaflet.js (Interactive maps) +- BeautifulSoup4 (HTML parsing) +- OpenStreetMap & Nominatim (Geocoding) \ No newline at end of file diff --git a/backend/config.json b/backend/config.json new file mode 100644 index 0000000..2eac50f --- /dev/null +++ b/backend/config.json @@ -0,0 +1,34 @@ +{ + "server": { + "host": "0.0.0.0", + "port": 5000, + "debug": false + }, + "scraping": { + "session_timeout": 300, + "listings_per_page": 25, + "max_workers": 4, + "min_workers": 2, + "rate_limit_delay": 0.5, + "geocoding_delay": 1.0 + }, + "cache": { + "zip_cache_file": "zip_cache.json" + }, + "apis": { + "nominatim": { + "url": "https://nominatim.openstreetmap.org/search", + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + }, + "kleinanzeigen": { + "base_url": "https://www.kleinanzeigen.de" + } + }, + "user_agents": [ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + ] +} \ No newline at end of file diff --git a/backend/scrape_proxy.py b/backend/scrape_proxy.py index 5ff02d1..5f14451 100644 --- a/backend/scrape_proxy.py +++ b/backend/scrape_proxy.py @@ -1,10 +1,5 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -""" -Flask API Server for Kleinanzeigen Scraper -Author: Hendrik Schutter -Date: 2025/11/24 -""" from flask import Flask, request, jsonify from flask_cors import CORS @@ -24,10 +19,32 @@ CORS(app) urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) -# Configuration -CACHE_FILE = "zip_cache.json" -SESSION_TIMEOUT = 300 # seconds -LISTINGS_PER_PAGE = 25 +# Load configuration +CONFIG_FILE = "config.json" +config = {} + +if os.path.exists(CONFIG_FILE): + with open(CONFIG_FILE, "r", encoding="utf-8") as f: + config = json.load(f) +else: + print(f"ERROR: {CONFIG_FILE} not found!") + exit(1) + +# Configuration values +CACHE_FILE = config["cache"]["zip_cache_file"] +SESSION_TIMEOUT = config["scraping"]["session_timeout"] +LISTINGS_PER_PAGE = config["scraping"]["listings_per_page"] +MAX_WORKERS = config["scraping"]["max_workers"] +MIN_WORKERS = config["scraping"]["min_workers"] +RATE_LIMIT_DELAY = config["scraping"]["rate_limit_delay"] +GEOCODING_DELAY = config["scraping"]["geocoding_delay"] +USER_AGENTS = config["user_agents"] +NOMINATIM_URL = config["apis"]["nominatim"]["url"] +NOMINATIM_USER_AGENT = config["apis"]["nominatim"]["user_agent"] +KLEINANZEIGEN_BASE_URL = config["apis"]["kleinanzeigen"]["base_url"] +SERVER_HOST = config["server"]["host"] +SERVER_PORT = config["server"]["port"] +SERVER_DEBUG = config["server"]["debug"] # Global state zip_cache = {} @@ -61,14 +78,7 @@ def cleanup_old_sessions(): def get_random_user_agent(): """Generate random user agent string""" - uastrings = [ - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15", - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - ] - return random.choice(uastrings) + return random.choice(USER_AGENTS) def make_soup(url): @@ -79,14 +89,14 @@ def make_soup(url): r = http.request("GET", url) # Track response code status_code = str(r.status) - if "kleinanzeigen.de" in url: + if KLEINANZEIGEN_BASE_URL in url: metrics["kleinanzeigen_response_codes"][status_code] = ( metrics["kleinanzeigen_response_codes"].get(status_code, 0) + 1 ) return BeautifulSoup(r.data, "lxml") except Exception as e: print(f"Error fetching {url}: {e}") - if "kleinanzeigen.de" in url: + if KLEINANZEIGEN_BASE_URL in url: metrics["kleinanzeigen_response_codes"]["error"] = ( metrics["kleinanzeigen_response_codes"].get("error", 0) + 1 ) @@ -102,7 +112,6 @@ def geocode_zip(zip_code): return zip_cache[zip_code] # Call Nominatim API - url = "https://nominatim.openstreetmap.org/search" params = { "postalcode": zip_code, "country": "Germany", @@ -112,7 +121,7 @@ def geocode_zip(zip_code): try: response = requests.get( - url, params=params, headers={"user-agent": get_random_user_agent()} + NOMINATIM_URL, params=params, headers={"user-agent": NOMINATIM_USER_AGENT} ) # Track response code @@ -131,7 +140,7 @@ def geocode_zip(zip_code): with open(CACHE_FILE, "w", encoding="utf-8") as f: json.dump(zip_cache, f, ensure_ascii=False, indent=2) - time.sleep(1) # Respect API rate limits + time.sleep(GEOCODING_DELAY) return coords except Exception as e: print(f"Geocoding error for {zip_code}: {e}") @@ -144,12 +153,11 @@ def geocode_zip(zip_code): def search_listings(search_term, max_pages, min_price, max_price): """Search for listings on kleinanzeigen.de - returns only URLs""" - base_url = "https://www.kleinanzeigen.de" found_listings = set() for page_counter in range(1, max_pages + 1): listing_url = ( - base_url + KLEINANZEIGEN_BASE_URL + "/s-anbieter:privat/anzeige:angebote/preis:" + str(min_price) + ":" @@ -173,7 +181,7 @@ def search_listings(search_term, max_pages, min_price, max_price): for result in results: try: listing_href = result.a["href"] - found_listings.add(base_url + listing_href) + found_listings.add(KLEINANZEIGEN_BASE_URL + listing_href) except (AttributeError, KeyError): pass except Exception as e: @@ -284,13 +292,11 @@ def prefetch_listings_thread(session_id): if not session: return urls = session["urls"] - max_workers = random.randrange(2, 8) + workers = random.randrange(MIN_WORKERS, MAX_WORKERS) - print( - f"Starting prefetch for session {session_id} with {max_workers} parallel workers" - ) + print(f"Starting prefetch for session {session_id} with {workers} parallel workers") - for i in range(0, len(urls), max_workers): + for i in range(0, len(urls), workers): # Check if session was cancelled or deleted if ( session_id not in scrape_sessions @@ -300,7 +306,7 @@ def prefetch_listings_thread(session_id): return # Process batch of URLs in parallel - batch = urls[i : i + max_workers] + batch = urls[i : i + workers] threads = [] results = [None] * len(batch) @@ -325,7 +331,7 @@ def prefetch_listings_thread(session_id): session["scraped"] += len(batch) # Rate limiting between batches - time.sleep(0.5) + time.sleep(RATE_LIMIT_DELAY) print(f"Prefetch complete for session {session_id}") @@ -446,8 +452,6 @@ def health(): @app.route("/api/metrics", methods=["GET"]) def api_metrics(): """Prometheus-style metrics endpoint""" - cleanup_old_sessions() - uptime = time.time() - app_start_time # Build Prometheus text format @@ -517,4 +521,4 @@ if __name__ == "__main__": zip_cache = json.load(f) print(f"Loaded {len(zip_cache)} ZIP codes from cache") - app.run(debug=True, host="0.0.0.0", port=5000, threaded=True) + app.run(debug=SERVER_DEBUG, host=SERVER_HOST, port=SERVER_PORT, threaded=True) diff --git a/backend/zip_cache.json b/backend/zip_cache.json deleted file mode 100644 index 73ea3f3..0000000 --- a/backend/zip_cache.json +++ /dev/null @@ -1,2030 +0,0 @@ -{ - "65396": [ - 50.0493089, - 8.1528234 - ], - "31224": [ - 52.3217589, - 10.2740354 - ], - "87700": [ - 47.981622, - 10.168735 - ], - "18119": [ - 54.171753, - 12.0715803 - ], - "17235": [ - 53.3364758, - 13.0891908 - ], - "33100": [ - 51.7188767, - 8.8204875 - ], - "41564": [ - 51.219695, - 6.6018513 - ], - "85049": [ - 48.7637998, - 11.3534347 - ], - "50858": [ - 50.9241854, - 6.8594359 - ], - "53619": [ - 50.6248614, - 7.236131 - ], - "35104": [ - 51.169858, - 8.7991212 - ], - "12681": [ - 52.53836, - 13.5345701 - ], - "24796": [ - 54.3295051, - 9.843516 - ], - "38226": [ - 52.1582602, - 10.3280734 - ], - "12051": [ - 52.4666256, - 13.4291505 - ], - "63762": [ - 49.91076, - 9.0649764 - ], - "53113": [ - 50.720036, - 7.1222135 - ], - "81825": [ - 48.1180693, - 11.6617926 - ], - "60437": [ - 50.199045, - 8.6785157 - ], - "22175": [ - 53.624188, - 10.0964195 - ], - "24939": [ - 54.8039798, - 9.4199303 - ], - "87645": [ - 47.5796844, - 10.7589108 - ], - "46446": [ - 51.8554151, - 6.2334481 - ], - "55543": [ - 49.8270169, - 7.8649707 - ], - "45145": [ - 51.4466252, - 6.9759833 - ], - "80636": [ - 48.1503498, - 11.5421682 - ], - "46562": [ - 51.5996368, - 6.6553946 - ], - "21683": [ - 53.6379564, - 9.4633336 - ], - "45147": [ - 51.439, - 6.978438 - ], - "42699": [ - 51.1472454, - 7.0160744 - ], - "66606": [ - 49.4638241, - 7.1921958 - ], - "63075": [ - 50.1172347, - 8.7952867 - ], - "89233": [ - 48.38224, - 10.063212 - ], - "64750": [ - 49.7698447, - 9.0733458 - ], - "60488": [ - 50.1409814, - 8.61332 - ], - "41372": [ - 51.2028546, - 6.1488664 - ], - "79106": [ - 48.0066367, - 7.84115 - ], - "25421": [ - 53.6461272, - 9.7978295 - ], - "55120": [ - 50.0230033, - 8.226238 - ], - "93354": [ - 48.7563843, - 11.8315879 - ], - "22417": [ - 53.6667862, - 10.0394606 - ], - "45276": [ - 51.4483666, - 7.0766091 - ], - "21614": [ - 53.4611745, - 9.6871791 - ], - "78183": [ - 47.894954, - 8.5286859 - ], - "76133": [ - 49.0145529, - 8.3869012 - ], - "30159": [ - 52.3754607, - 9.73718 - ], - "14974": [ - 52.2920028, - 13.253629 - ], - "22848": [ - 53.6702396, - 9.9647065 - ], - "65549": [ - 50.3875825, - 8.0632306 - ], - "24568": [ - 53.8409965, - 9.9604144 - ], - "51399": [ - 51.0912479, - 7.119063 - ], - "13507": [ - 52.5765704, - 13.274292 - ], - "74336": [ - 49.0817102, - 9.0820642 - ], - "28197": [ - 53.0977106, - 8.7113664 - ], - "38114": [ - 52.2821182, - 10.5057529 - ], - "73230": [ - 48.6405169, - 9.4514092 - ], - "65185": [ - 50.0754415, - 8.2409901 - ], - "63477": [ - 50.1549692, - 8.8291468 - ], - "79102": [ - 47.9867122, - 7.8606821 - ], - "44789": [ - 51.4681782, - 7.2196341 - ], - "66706": [ - 49.5020118, - 6.4292476 - ], - "39307": [ - 52.3825952, - 12.1672886 - ], - "16515": [ - 52.7711793, - 13.2878028 - ], - "53844": [ - 50.7974348, - 7.1155495 - ], - "66440": [ - 49.21398, - 7.2393898 - ], - "40764": [ - 51.107968, - 6.9485475 - ], - "72393": [ - 48.3144446, - 9.1316398 - ], - "31582": [ - 52.6392045, - 9.2177882 - ], - "64291": [ - 49.924334, - 8.6700403 - ], - "66994": [ - 49.148501, - 7.7599286 - ], - "10439": [ - 52.551628, - 13.4080206 - ], - "90482": [ - 49.4612837, - 11.1569783 - ], - "80336": [ - 48.1309411, - 11.5527628 - ], - "46397": [ - 51.8704976, - 6.6515339 - ], - "73434": [ - 48.861834, - 10.0087368 - ], - "55128": [ - 49.9803406, - 8.2359449 - ], - "56075": [ - 50.3185867, - 7.5671197 - ], - "42799": [ - 51.1097468, - 7.0699535 - ], - "60323": [ - 50.1238046, - 8.6617905 - ], - "49082": [ - 52.2467152, - 8.0611039 - ], - "71397": [ - 48.9018295, - 9.3913064 - ], - "59269": [ - 51.7595446, - 8.0495504 - ], - "58642": [ - 51.3697717, - 7.6112597 - ], - "69121": [ - 49.4355994, - 8.6909827 - ], - "99084": [ - 50.977007, - 11.0270416 - ], - "79100": [ - 47.948102, - 7.8806006 - ], - "30163": [ - 52.3985964, - 9.7468275 - ], - "65195": [ - 50.10668, - 8.1929797 - ], - "99427": [ - 51.007008, - 11.318925 - ], - "31632": [ - 52.5721633, - 9.2488844 - ], - "80805": [ - 48.1719894, - 11.6031233 - ], - "74575": [ - 49.3515287, - 9.9792114 - ], - "22415": [ - 53.6468629, - 10.0071084 - ], - "53879": [ - 50.6575091, - 6.7885086 - ], - "83435": [ - 47.7266717, - 12.8540813 - ], - "35080": [ - 50.7689326, - 8.47644 - ], - "88250": [ - 47.8048499, - 9.6401004 - ], - "53757": [ - 50.7699533, - 7.1900983 - ], - "91338": [ - 49.6178641, - 11.2272346 - ], - "48163": [ - 51.8913816, - 7.5748843 - ], - "50676": [ - 50.9302048, - 6.9530214 - ], - "20359": [ - 53.5513888, - 9.9652207 - ], - "48683": [ - 52.1012241, - 6.9597729 - ], - "58566": [ - 51.1451584, - 7.5867375 - ], - "47509": [ - 51.4512701, - 6.4681553 - ], - "52224": [ - 50.7367763, - 6.2868736 - ], - "30916": [ - 52.4597727, - 9.8484595 - ], - "13355": [ - 52.5410841, - 13.389616 - ], - "90408": [ - 49.4657815, - 11.0760787 - ], - "30161": [ - 52.3833242, - 9.7451797 - ], - "29574": [ - 53.027968, - 10.4238542 - ], - "68775": [ - 49.3661548, - 8.5256571 - ], - "26384": [ - 53.5380722, - 8.1441081 - ], - "45309": [ - 51.4795218, - 7.0725348 - ], - "70806": [ - 48.8659279, - 9.1851251 - ], - "81541": [ - 48.1212633, - 11.5877883 - ], - "88471": [ - 48.2284106, - 9.8458981 - ], - "73666": [ - 48.7497871, - 9.4398076 - ], - "53881": [ - 50.6318282, - 6.8138181 - ], - "31134": [ - 52.1426466, - 9.9525357 - ], - "18107": [ - 54.1509015, - 11.9998073 - ], - "96215": [ - 50.1315263, - 11.0839299 - ], - "25779": [ - 54.2778477, - 9.1499664 - ], - "91301": [ - 49.7122951, - 11.0664285 - ], - "99444": [ - 50.8463328, - 11.3542801 - ], - "57610": [ - 50.6880529, - 7.6639834 - ], - "90522": [ - 49.4236043, - 10.9708709 - ], - "16348": [ - 52.7603588, - 13.5034557 - ], - "71686": [ - 48.8824228, - 9.2656553 - ], - "94032": [ - 48.5673568, - 13.4621956 - ], - "67434": [ - 49.3395315, - 8.0767774 - ], - "13409": [ - 52.5674283, - 13.373084 - ], - "85609": [ - 48.192658, - 11.7142122 - ], - "23552": [ - 53.8672062, - 10.6879337 - ], - "55116": [ - 50.0010941, - 8.2698319 - ], - "46395": [ - 51.8194111, - 6.5897644 - ], - "91257": [ - 49.7388149, - 11.5281328 - ], - "86498": [ - 48.1958025, - 10.2678327 - ], - "83071": [ - 47.8658574, - 12.1883594 - ], - "80798": [ - 48.1551839, - 11.5661602 - ], - "65428": [ - 49.9819502, - 8.446357 - ], - "65205": [ - 50.0527128, - 8.3119383 - ], - "90455": [ - 49.368, - 11.0832301 - ], - "40476": { - "lat": 51.2497334, - "lon": 6.7826416 - }, - "28203": { - "lat": 53.0745847, - "lon": 8.8255484 - }, - "30629": { - "lat": 52.3945187, - "lon": 9.856456 - }, - "26939": { - "lat": 53.3125659, - "lon": 8.3709506 - }, - "24105": { - "lat": 54.3378072, - "lon": 10.1450606 - }, - "47059": { - "lat": 51.439653, - "lon": 6.7386164 - }, - "12529": { - "lat": 52.3658691, - "lon": 13.4986104 - }, - "94469": { - "lat": 48.8477283, - "lon": 12.9726396 - }, - "49080": { - "lat": 52.2574684, - "lon": 8.032973 - }, - "12459": { - "lat": 52.468305, - "lon": 13.5147534 - }, - "85716": { - "lat": 48.2771574, - "lon": 11.5535728 - }, - "52459": { - "lat": 50.8649164, - "lon": 6.3735307 - }, - "54298": { - "lat": 49.860982, - "lon": 6.5698888 - }, - "10249": { - "lat": 52.5232023, - "lon": 13.4415068 - }, - "74321": { - "lat": 48.9536697, - "lon": 9.1277841 - }, - "06246": { - "lat": 51.3790758, - "lon": 11.8125212 - }, - "57076": { - "lat": 50.9023972, - "lon": 8.0328723 - }, - "74193": { - "lat": 49.1457847, - "lon": 9.0490182 - }, - "50672": { - "lat": 50.9425062, - "lon": 6.9345727 - }, - "36199": { - "lat": 51.0163485, - "lon": 9.739399 - }, - "92421": { - "lat": 49.3153268, - "lon": 12.0650867 - }, - "74861": { - "lat": 49.29759, - "lon": 9.2801659 - }, - "82152": { - "lat": 48.0984515, - "lon": 11.4274282 - }, - "89134": { - "lat": 48.4332154, - "lon": 9.8730389 - }, - "85375": { - "lat": 48.3222881, - "lon": 11.6696699 - }, - "50968": { - "lat": 50.9014161, - "lon": 6.9666561 - }, - "10587": { - "lat": 52.5179717, - "lon": 13.3188456 - }, - "44339": { - "lat": 51.5660996, - "lon": 7.4634282 - }, - "91126": { - "lat": 49.3197346, - "lon": 11.0058789 - }, - "78462": { - "lat": 47.6620359, - "lon": 9.1704389 - }, - "12277": { - "lat": 52.4154196, - "lon": 13.3781799 - }, - "80997": { - "lat": 48.1927896, - "lon": 11.4844607 - }, - "22299": { - "lat": 53.592923, - "lon": 10.0002707 - }, - "32423": { - "lat": 52.2949237, - "lon": 8.9504689 - }, - "78187": { - "lat": 47.9038998, - "lon": 8.6530838 - }, - "47228": { - "lat": 51.4172098, - "lon": 6.6995226 - }, - "91735": { - "lat": 49.1641497, - "lon": 10.7183644 - }, - "70197": { - "lat": 48.7743488, - "lon": 9.1024708 - }, - "23701": { - "lat": 54.1196445, - "lon": 10.6400269 - }, - "67067": { - "lat": 49.4392191, - "lon": 8.4002324 - }, - "34117": { - "lat": 51.3157257, - "lon": 9.4926363 - }, - "26670": { - "lat": 53.3040802, - "lon": 7.7711552 - }, - "59302": { - "lat": 51.8289338, - "lon": 8.1434012 - }, - "57439": { - "lat": 51.1216585, - "lon": 7.9106444 - }, - "50679": { - "lat": 50.9358894, - "lon": 6.979201 - }, - "48565": { - "lat": 52.1371446, - "lon": 7.3760905 - }, - "63322": { - "lat": 49.9778903, - "lon": 8.8030176 - }, - "48153": { - "lat": 51.9373558, - "lon": 7.630713 - }, - "12049": { - "lat": 52.4778365, - "lon": 13.4222553 - }, - "27404": { - "lat": 53.280695, - "lon": 9.2815154 - }, - "56072": { - "lat": 50.3527777, - "lon": 7.517017 - }, - "06406": { - "lat": 51.7825763, - "lon": 11.7556064 - }, - "68542": { - "lat": 49.5158462, - "lon": 8.6120745 - }, - "28217": { - "lat": 53.0961375, - "lon": 8.7763043 - }, - "22335": { - "lat": 53.6313283, - "lon": 10.005055 - }, - "70195": { - "lat": 48.7856881, - "lon": 9.1243149 - }, - "32760": { - "lat": 51.9062473, - "lon": 8.8874725 - }, - "23568": { - "lat": 53.890909, - "lon": 10.7744232 - }, - "57555": { - "lat": 50.8231404, - "lon": 7.9434087 - }, - "89542": { - "lat": 48.6273822, - "lon": 10.1539367 - }, - "33813": { - "lat": 51.9492295, - "lon": 8.673579 - }, - "04416": { - "lat": 51.2684588, - "lon": 12.3965452 - }, - "47574": { - "lat": 51.6806879, - "lon": 6.1231731 - }, - "70176": { - "lat": 48.7777939, - "lon": 9.1620434 - }, - "51570": { - "lat": 50.7904205, - "lon": 7.5827514 - }, - "79111": { - "lat": 47.9952724, - "lon": 7.7809313 - }, - "70191": { - "lat": 48.7987327, - "lon": 9.1891082 - }, - "83673": { - "lat": 47.7194673, - "lon": 11.4121069 - }, - "52477": { - "lat": 50.8678838, - "lon": 6.1763183 - }, - "90768": { - "lat": 49.4935888, - "lon": 10.9216565 - }, - "41836": { - "lat": 51.0384886, - "lon": 6.2442831 - }, - "65239": { - "lat": 50.0276093, - "lon": 8.3582881 - }, - "69221": { - "lat": 49.4516551, - "lon": 8.6742993 - }, - "10965": { - "lat": 52.4868877, - "lon": 13.3865743 - }, - "04275": { - "lat": 51.3195745, - "lon": 12.3699042 - }, - "81479": { - "lat": 48.0760967, - "lon": 11.5215568 - }, - "52156": { - "lat": 50.5530635, - "lon": 6.2630994 - }, - "66887": { - "lat": 49.5708609, - "lon": 7.5126376 - }, - "33775": { - "lat": 52.0421486, - "lon": 8.1741646 - }, - "14478": { - "lat": 52.3665195, - "lon": 13.0947401 - }, - "46325": { - "lat": 51.8533164, - "lon": 6.8293691 - }, - "80637": { - "lat": 48.1594366, - "lon": 11.5366132 - }, - "59755": { - "lat": 51.4569728, - "lon": 7.9839003 - }, - "91578": { - "lat": 49.2957346, - "lon": 10.4213529 - }, - "26835": { - "lat": 53.302177, - "lon": 7.6133537 - }, - "49565": { - "lat": 52.4199148, - "lon": 8.0118594 - }, - "41748": { - "lat": 51.2466738, - "lon": 6.4367107 - }, - "23556": { - "lat": 53.8736733, - "lon": 10.627504 - }, - "13403": { - "lat": 52.5751043, - "lon": 13.3186742 - }, - "33699": { - "lat": 51.9890201, - "lon": 8.6259807 - }, - "49074": { - "lat": 52.2760322, - "lon": 8.0520849 - }, - "20259": { - "lat": 53.571374, - "lon": 9.9589336 - }, - "86179": { - "lat": 48.2950044, - "lon": 10.9017479 - }, - "76865": { - "lat": 49.1501434, - "lon": 8.1445643 - }, - "39175": { - "lat": 52.1511831, - "lon": 11.7569815 - }, - "04157": { - "lat": 51.3738253, - "lon": 12.3678529 - }, - "14052": { - "lat": 52.5142533, - "lon": 13.2587723 - }, - "86529": { - "lat": 48.5787122, - "lon": 11.2314376 - }, - "61169": { - "lat": 50.3268008, - "lon": 8.7369123 - }, - "24594": { - "lat": 54.0960833, - "lon": 9.6683364 - }, - "66130": { - "lat": 49.1987978, - "lon": 7.0603721 - }, - "12203": { - "lat": 52.4428381, - "lon": 13.3113628 - }, - "42719": { - "lat": 51.1900844, - "lon": 7.048417 - }, - "71032": { - "lat": 48.6817876, - "lon": 9.0480546 - }, - "31737": { - "lat": 52.1601068, - "lon": 9.0932974 - }, - "23566": { - "lat": 53.8718244, - "lon": 10.7363623 - }, - "85276": { - "lat": 48.5434775, - "lon": 11.4838556 - }, - "33824": { - "lat": 52.0793486, - "lon": 8.4217865 - }, - "97440": { - "lat": 49.9862294, - "lon": 10.0955939 - }, - "12526": { - "lat": 52.4004327, - "lon": 13.5662666 - }, - "28844": { - "lat": 52.9967094, - "lon": 8.859078 - }, - "48151": { - "lat": 51.939695, - "lon": 7.6078585 - }, - "26188": { - "lat": 53.1162202, - "lon": 8.0196762 - }, - "75217": { - "lat": 48.8711492, - "lon": 8.6066886 - }, - "01157": { - "lat": 51.0684686, - "lon": 13.6646468 - }, - "09212": { - "lat": 50.8728729, - "lon": 12.7133477 - }, - "77770": { - "lat": 48.4857086, - "lon": 8.0306699 - }, - "14550": { - "lat": 52.4189427, - "lon": 12.770724 - }, - "25774": { - "lat": 54.3135379, - "lon": 9.0083313 - }, - "41061": { - "lat": 51.1943408, - "lon": 6.433353 - }, - "42281": { - "lat": 51.2859753, - "lon": 7.1947824 - }, - "41464": { - "lat": 51.1901268, - "lon": 6.6681998 - }, - "53557": { - "lat": 50.5288429, - "lon": 7.3372336 - }, - "42655": { - "lat": 51.1658371, - "lon": 7.0583627 - }, - "45889": { - "lat": 51.5369896, - "lon": 7.1106775 - }, - "22089": { - "lat": 53.5675844, - "lon": 10.0477763 - }, - "66740": { - "lat": 49.3114303, - "lon": 6.7271048 - }, - "30165": { - "lat": 52.4007589, - "lon": 9.7201547 - }, - "26817": { - "lat": 53.1255126, - "lon": 7.5531302 - }, - "26388": { - "lat": 53.5880645, - "lon": 8.0827844 - }, - "10435": { - "lat": 52.5371985, - "lon": 13.4103257 - }, - "77948": { - "lat": 48.3811043, - "lon": 7.8795944 - }, - "20357": { - "lat": 53.5642527, - "lon": 9.9678986 - }, - "76437": { - "lat": 48.8672308, - "lon": 8.1843707 - }, - "70180": { - "lat": 48.7627075, - "lon": 9.173756 - }, - "29525": { - "lat": 52.9602539, - "lon": 10.5841215 - }, - "22609": { - "lat": 53.5580877, - "lon": 9.8507315 - }, - "22769": { - "lat": 53.5666313, - "lon": 9.9453671 - }, - "49170": { - "lat": 52.1966896, - "lon": 7.9617524 - }, - "06179": { - "lat": 51.4439668, - "lon": 11.8428613 - }, - "76646": { - "lat": 49.1048939, - "lon": 8.6011546 - }, - "66640": { - "lat": 49.5154697, - "lon": 7.1642596 - }, - "35037": { - "lat": 50.8019802, - "lon": 8.7534373 - }, - "17033": { - "lat": 53.5316255, - "lon": 13.259116 - }, - "57562": { - "lat": 50.7803241, - "lon": 7.9418665 - }, - "47475": { - "lat": 51.5200643, - "lon": 6.5331459 - }, - "25358": { - "lat": 53.8214677, - "lon": 9.6181853 - }, - "91242": { - "lat": 49.5003308, - "lon": 11.3409464 - }, - "22337": { - "lat": 53.6228371, - "lon": 10.0541373 - }, - "09125": { - "lat": 50.787209, - "lon": 12.9429497 - }, - "74532": { - "lat": 49.1729333, - "lon": 9.9223018 - }, - "86199": { - "lat": 48.3227064, - "lon": 10.8439401 - }, - "14469": { - "lat": 52.4230779, - "lon": 13.0375312 - }, - "41541": { - "lat": 51.1350698, - "lon": 6.8232105 - }, - "57258": { - "lat": 50.8894272, - "lon": 7.8850991 - }, - "31785": { - "lat": 52.1054558, - "lon": 9.3625582 - }, - "52379": { - "lat": 50.7959349, - "lon": 6.3625594 - }, - "53562": { - "lat": 50.5853146, - "lon": 7.3608497 - }, - "22047": { - "lat": 53.5891876, - "lon": 10.095758 - }, - "50737": { - "lat": 50.9940548, - "lon": 6.9283471 - }, - "08451": { - "lat": 50.8190657, - "lon": 12.3796393 - }, - "31275": { - "lat": 52.3768215, - "lon": 10.0183462 - }, - "87760": { - "lat": 47.9420271, - "lon": 10.2388364 - }, - "38100": { - "lat": 52.2622379, - "lon": 10.5235223 - }, - "17291": { - "lat": 53.2867057, - "lon": 13.9075293 - }, - "18055": { - "lat": 54.0859441, - "lon": 12.1622629 - }, - "34513": { - "lat": 51.2455919, - "lon": 9.0361229 - }, - "71229": { - "lat": 48.7897001, - "lon": 9.0007802 - }, - "40599": { - "lat": 51.1796944, - "lon": 6.8725057 - }, - "45894": { - "lat": 51.5803692, - "lon": 7.0544666 - }, - "41460": { - "lat": 51.2074219, - "lon": 6.708413 - }, - "71522": { - "lat": 48.9474784, - "lon": 9.4280913 - }, - "32257": { - "lat": 52.2106441, - "lon": 8.562226 - }, - "56479": { - "lat": 50.6196216, - "lon": 8.0283948 - }, - "46119": { - "lat": 51.5224185, - "lon": 6.8799162 - }, - "25348": { - "lat": 53.7855628, - "lon": 9.4463377 - }, - "42659": { - "lat": 51.1438175, - "lon": 7.1141132 - }, - "57462": { - "lat": 51.0383244, - "lon": 7.8816773 - }, - "10781": { - "lat": 52.4935864, - "lon": 13.3530545 - }, - "99974": { - "lat": 51.2392332, - "lon": 10.486337 - }, - "33165": { - "lat": 51.6151313, - "lon": 8.8884995 - }, - "86551": { - "lat": 48.4493709, - "lon": 11.1074497 - }, - "31789": { - "lat": 52.0953668, - "lon": 9.4085359 - }, - "78628": { - "lat": 48.1701427, - "lon": 8.6574411 - }, - "65719": { - "lat": 50.0940795, - "lon": 8.4220047 - }, - "48429": { - "lat": 52.2871136, - "lon": 7.4589654 - }, - "47804": { - "lat": 51.3190214, - "lon": 6.5305185 - }, - "20257": { - "lat": 53.5751346, - "lon": 9.9453964 - }, - "76694": { - "lat": 49.1610701, - "lon": 8.5861814 - }, - "25336": { - "lat": 53.7313007, - "lon": 9.6569799 - }, - "90537": { - "lat": 49.3810222, - "lon": 11.2182963 - }, - "82166": { - "lat": 48.1221494, - "lon": 11.4359327 - }, - "19348": { - "lat": 53.075548, - "lon": 11.8212016 - }, - "68642": { - "lat": 49.6437056, - "lon": 8.4685664 - }, - "77723": { - "lat": 48.4044327, - "lon": 8.0355024 - }, - "49809": { - "lat": 52.5270769, - "lon": 7.3403526 - }, - "72401": { - "lat": 48.3656772, - "lon": 8.7940997 - }, - "88046": { - "lat": 47.6596599, - "lon": 9.5041895 - }, - "65474": { - "lat": 49.9865493, - "lon": 8.359341 - }, - "20457": { - "lat": 53.5335376, - "lon": 9.9806284 - }, - "39120": { - "lat": 52.0855315, - "lon": 11.6329414 - }, - "47137": { - "lat": 51.4714791, - "lon": 6.7669058 - }, - "69207": { - "lat": 49.3421242, - "lon": 8.6370166 - }, - "27616": { - "lat": 53.4469731, - "lon": 8.8063121 - }, - "10119": { - "lat": 52.5301255, - "lon": 13.4055082 - }, - "47443": { - "lat": 51.4656893, - "lon": 6.6524975 - }, - "10557": { - "lat": 52.5256483, - "lon": 13.3640508 - }, - "37671": { - "lat": 51.7685153, - "lon": 9.3310829 - }, - "16225": { - "lat": 52.8292295, - "lon": 13.8384643 - }, - "52146": { - "lat": 50.8292627, - "lon": 6.1519607 - }, - "48149": { - "lat": 51.9638718, - "lon": 7.6026944 - }, - "48653": { - "lat": 51.9171483, - "lon": 7.1606437 - }, - "71134": { - "lat": 48.6820157, - "lon": 8.8818568 - }, - "53111": { - "lat": 50.7402492, - "lon": 7.0985907 - }, - "01993": { - "lat": 51.5082352, - "lon": 13.8868418 - }, - "32105": { - "lat": 52.0890558, - "lon": 8.7396016 - }, - "82445": { - "lat": 47.6253557, - "lon": 11.1138077 - }, - "40217": { - "lat": 51.2131204, - "lon": 6.774469 - }, - "49401": { - "lat": 52.5279348, - "lon": 8.232843 - }, - "55268": { - "lat": 49.90063, - "lon": 8.203086 - }, - "24306": { - "lat": 54.1620728, - "lon": 10.4375556 - }, - "22763": { - "lat": 53.550853, - "lon": 9.9138756 - }, - "21339": { - "lat": 53.2548482, - "lon": 10.3911518 - }, - "56218": { - "lat": 50.388183, - "lon": 7.5046437 - }, - "86899": { - "lat": 48.0336819, - "lon": 10.8638784 - }, - "84034": { - "lat": 48.5286909, - "lon": 12.0999127 - }, - "82110": { - "lat": 48.1321961, - "lon": 11.3600169 - }, - "56626": { - "lat": 50.431234, - "lon": 7.3730436 - }, - "10315": { - "lat": 52.5180707, - "lon": 13.5144045 - }, - "52080": { - "lat": 50.784851, - "lon": 6.160716 - }, - "51688": { - "lat": 51.1169225, - "lon": 7.419399 - }, - "45127": { - "lat": 51.4574619, - "lon": 7.0103435 - }, - "48324": { - "lat": 51.8545927, - "lon": 7.7859503 - }, - "26386": { - "lat": 53.553156, - "lon": 8.1039435 - }, - "86356": { - "lat": 48.392226, - "lon": 10.8016665 - }, - "50939": { - "lat": 50.9095331, - "lon": 6.9259241 - }, - "14195": { - "lat": 52.4585754, - "lon": 13.2846329 - }, - "21680": { - "lat": 53.5904569, - "lon": 9.4760161 - }, - "01257": { - "lat": 50.9983029, - "lon": 13.8123958 - }, - "29410": { - "lat": 52.8367097, - "lon": 11.1224073 - }, - "38300": { - "lat": 52.1513013, - "lon": 10.56812 - }, - "01819": { - "lat": 50.8809928, - "lon": 13.9044785 - }, - "85238": { - "lat": 48.4082502, - "lon": 11.4634544 - }, - "33378": { - "lat": 51.8441657, - "lon": 8.317883 - }, - "99192": { - "lat": 50.934868, - "lon": 10.9138336 - }, - "60438": { - "lat": 50.1786706, - "lon": 8.6271811 - }, - "35075": { - "lat": 50.7787074, - "lon": 8.5791746 - }, - "10827": { - "lat": 52.4836896, - "lon": 13.3528221 - }, - "24392": { - "lat": 54.6331185, - "lon": 9.7771951 - }, - "78647": { - "lat": 48.0717008, - "lon": 8.6373591 - }, - "10627": { - "lat": 52.5075196, - "lon": 13.3031999 - }, - "22419": { - "lat": 53.6662872, - "lon": 10.0055952 - }, - "06388": { - "lat": 51.6898413, - "lon": 11.9119914 - }, - "67117": { - "lat": 49.4122026, - "lon": 8.3936093 - }, - "68219": { - "lat": 49.435092, - "lon": 8.5365013 - }, - "77866": { - "lat": 48.6610036, - "lon": 7.9359671 - }, - "53175": { - "lat": 50.6989638, - "lon": 7.1445107 - }, - "78467": { - "lat": 47.6929555, - "lon": 9.1513759 - }, - "48703": { - "lat": 52.0035321, - "lon": 6.9517971 - }, - "46049": { - "lat": 51.4725211, - "lon": 6.8311577 - }, - "48143": { - "lat": 51.9604439, - "lon": 7.6262442 - }, - "06231": { - "lat": 51.2849151, - "lon": 12.1146298 - }, - "33332": { - "lat": 51.8972222, - "lon": 8.4006525 - }, - "27283": { - "lat": 52.9410676, - "lon": 9.2354716 - }, - "10317": { - "lat": 52.4986204, - "lon": 13.4838382 - }, - "01640": { - "lat": 51.1331059, - "lon": 13.5656911 - }, - "46244": { - "lat": 51.5984773, - "lon": 6.9123203 - }, - "01796": { - "lat": 50.9470409, - "lon": 13.9505572 - }, - "32339": { - "lat": 52.3741653, - "lon": 8.6212978 - }, - "50181": { - "lat": 51.0144705, - "lon": 6.5569525 - }, - "93055": { - "lat": 49.007933, - "lon": 12.1608121 - }, - "18147": { - "lat": 54.1309902, - "lon": 12.1196962 - }, - "49504": { - "lat": 52.2991515, - "lon": 7.9218375 - }, - "60318": { - "lat": 50.1246887, - "lon": 8.6865254 - }, - "96052": { - "lat": 49.9117586, - "lon": 10.8880355 - }, - "29559": { - "lat": 52.8774192, - "lon": 10.6061272 - }, - "10115": { - "lat": 52.5319487, - "lon": 13.3837943 - }, - "27251": { - "lat": 52.7492339, - "lon": 8.7757762 - }, - "22303": { - "lat": 53.5897407, - "lon": 10.0234361 - }, - "38122": { - "lat": 52.2297328, - "lon": 10.4745918 - }, - "51371": { - "lat": 51.0590744, - "lon": 6.9417484 - }, - "60314": { - "lat": 50.1166698, - "lon": 8.7334387 - }, - "70376": { - "lat": 48.818393, - "lon": 9.2066864 - }, - "93499": { - "lat": 49.1429872, - "lon": 12.7164157 - }, - "18435": { - "lat": 54.32997, - "lon": 13.0649961 - }, - "12105": { - "lat": 52.4484553, - "lon": 13.3722304 - }, - "81929": { - "lat": 48.1606494, - "lon": 11.6631075 - }, - "45768": { - "lat": 51.6575564, - "lon": 7.0659333 - }, - "91074": { - "lat": 49.5738171, - "lon": 10.8926968 - }, - "49593": { - "lat": 52.5674997, - "lon": 7.9325832 - }, - "80935": { - "lat": 48.1997053, - "lon": 11.5552742 - }, - "52134": { - "lat": 50.8605761, - "lon": 6.1001816 - }, - "94535": { - "lat": 48.7110796, - "lon": 13.2553681 - }, - "99947": { - "lat": 51.1242532, - "lon": 10.6769762 - }, - "09112": { - "lat": 50.830933, - "lon": 12.9053458 - }, - "01968": { - "lat": 51.5238377, - "lon": 14.0284911 - }, - "31515": { - "lat": 52.4314053, - "lon": 9.428236 - }, - "40547": { - "lat": 51.2441486, - "lon": 6.7400785 - }, - "72800": { - "lat": 48.4830973, - "lon": 9.2728039 - }, - "81476": { - "lat": 48.0873869, - "lon": 11.4957046 - }, - "94034": { - "lat": 48.593963, - "lon": 13.449846 - }, - "84478": { - "lat": 48.1977065, - "lon": 12.4064772 - }, - "69120": { - "lat": 49.4197028, - "lon": 8.7013385 - }, - "16303": { - "lat": 53.0795487, - "lon": 14.2322027 - }, - "48165": { - "lat": 51.8982648, - "lon": 7.650382 - }, - "23554": { - "lat": 53.889632, - "lon": 10.6772133 - }, - "57648": { - "lat": 50.6553129, - "lon": 7.9089968 - }, - "50677": { - "lat": 50.9222793, - "lon": 6.9491251 - }, - "26826": { - "lat": 53.165921, - "lon": 7.3277997 - }, - "24340": { - "lat": 54.4684418, - "lon": 9.7984274 - }, - "25335": { - "lat": 53.7556754, - "lon": 9.6072404 - }, - "89160": { - "lat": 48.4795088, - "lon": 9.9097371 - }, - "51580": { - "lat": 50.955823, - "lon": 7.6952729 - }, - "59075": { - "lat": 51.706537, - "lon": 7.7471066 - }, - "28355": { - "lat": 53.1001424, - "lon": 8.9369005 - }, - "10961": { - "lat": 52.492375, - "lon": 13.3969612 - }, - "33649": { - "lat": 51.9812735, - "lon": 8.4631941 - }, - "01945": { - "lat": 51.426725, - "lon": 13.8800707 - }, - "40225": { - "lat": 51.1952407, - "lon": 6.7930966 - }, - "83043": { - "lat": 47.8653219, - "lon": 12.0086382 - }, - "01279": { - "lat": 51.0279271, - "lon": 13.8224355 - }, - "88348": { - "lat": 48.0132718, - "lon": 9.5038216 - }, - "57078": { - "lat": 50.9241982, - "lon": 7.9979802 - }, - "72160": { - "lat": 48.4524183, - "lon": 8.6624266 - }, - "49716": { - "lat": 52.6985078, - "lon": 7.2503852 - }, - "24111": { - "lat": 54.3043198, - "lon": 10.0647871 - }, - "09116": { - "lat": 50.8205765, - "lon": 12.8734753 - }, - "63450": { - "lat": 50.1285671, - "lon": 8.9252343 - }, - "64285": { - "lat": 49.8517954, - "lon": 8.6583914 - }, - "46399": { - "lat": 51.8767165, - "lon": 6.592176 - }, - "50823": { - "lat": 50.9508203, - "lon": 6.9259111 - }, - "51702": { - "lat": 51.0304049, - "lon": 7.6756018 - }, - "26129": { - "lat": 53.1529595, - "lon": 8.1751768 - }, - "22391": { - "lat": 53.6423048, - "lon": 10.081893 - }, - "41472": { - "lat": 51.1601804, - "lon": 6.654715 - }, - "76199": { - "lat": 48.9755465, - "lon": 8.4040415 - }, - "35043": { - "lat": 50.7979432, - "lon": 8.8227218 - }, - "65929": { - "lat": 50.0944874, - "lon": 8.5308675 - }, - "27308": { - "lat": 52.9255206, - "lon": 9.3782295 - }, - "99510": { - "lat": 51.0351265, - "lon": 11.4866204 - }, - "94315": { - "lat": 48.8839157, - "lon": 12.5955773 - }, - "69126": { - "lat": 49.3773204, - "lon": 8.7015986 - }, - "14193": { - "lat": 52.4813456, - "lon": 13.2384701 - }, - "04318": { - "lat": 51.3431283, - "lon": 12.4282967 - }, - "48161": { - "lat": 51.9892494, - "lon": 7.5383949 - }, - "35683": { - "lat": 50.7423221, - "lon": 8.2847449 - }, - "42477": { - "lat": 51.2100192, - "lon": 7.3649391 - }, - "48317": { - "lat": 51.8011531, - "lon": 7.7434268 - }, - "10999": { - "lat": 52.4976589, - "lon": 13.4231017 - }, - "88260": { - "lat": 47.7032598, - "lon": 9.9431795 - }, - "72760": { - "lat": 48.5120972, - "lon": 9.2052416 - }, - "82467": { - "lat": 47.4902875, - "lon": 11.0332252 - }, - "44319": { - "lat": 51.5383021, - "lon": 7.6017367 - }, - "12524": { - "lat": 52.4118113, - "lon": 13.5481684 - }, - "99428": { - "lat": 50.9720626, - "lon": 11.2029566 - }, - "86695": { - "lat": 48.6048016, - "lon": 10.8196538 - }, - "04177": { - "lat": 51.3425514, - "lon": 12.330756 - }, - "50735": { - "lat": 50.9893938, - "lon": 6.9609471 - }, - "53909": { - "lat": 50.692835, - "lon": 6.6581295 - }, - "50169": { - "lat": 50.8807881, - "lon": 6.7426581 - }, - "89584": { - "lat": 48.2846841, - "lon": 9.6586816 - }, - "47179": { - "lat": 51.5247327, - "lon": 6.7297793 - }, - "76287": { - "lat": 48.9632556, - "lon": 8.310033 - }, - "74072": { - "lat": 49.1394593, - "lon": 9.2148992 - } -} \ No newline at end of file diff --git a/web/css/style.css b/web/css/style.css new file mode 100644 index 0000000..3fd1f7f --- /dev/null +++ b/web/css/style.css @@ -0,0 +1,526 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + height: 100vh; + overflow: hidden; + background: #1a1a1a; + color: #e0e0e0; +} + +.container { + display: grid; + grid-template-columns: 380px 1fr; + grid-template-rows: auto 1fr; + height: calc(100vh - 60px); +} + +.search-bar { + grid-column: 1 / -1; + background: #242424; + padding: 20px; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); + display: grid; + grid-template-columns: 1fr auto auto auto auto; + gap: 12px; + align-items: end; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.form-group label { + font-size: 12px; + font-weight: 600; + color: #b0b0b0; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.search-bar input, .search-bar select { + padding: 12px; + border: 1px solid #3a3a3a; + border-radius: 6px; + font-size: 14px; + background: #2a2a2a; + color: #e0e0e0; + transition: all 0.2s; +} + +.search-bar input:focus, .search-bar select:focus { + outline: none; + border-color: #0ea5e9; + box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.1); +} + +.price-inputs { + display: flex; + gap: 8px; + align-items: center; +} + +.price-inputs input { + width: 120px; +} + +.price-separator { + color: #666; + font-weight: 600; + padding: 0 4px; +} + +.search-bar button { + padding: 12px 24px; + background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%); + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: 600; + font-size: 14px; + transition: all 0.2s; + box-shadow: 0 2px 8px rgba(14, 165, 233, 0.3); +} + +.search-bar button:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(14, 165, 233, 0.4); +} + +.search-bar button:disabled { + background: #3a3a3a; + cursor: not-allowed; + box-shadow: none; +} + +.search-bar button.cancel { + background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); + box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3); +} + +.search-bar button.cancel:hover { + box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4); +} + +.results-panel { + background: #1e1e1e; + overflow-y: auto; + border-right: 1px solid #2a2a2a; +} + +.results-panel::-webkit-scrollbar { + width: 8px; +} + +.results-panel::-webkit-scrollbar-track { + background: #1a1a1a; +} + +.results-panel::-webkit-scrollbar-thumb { + background: #3a3a3a; + border-radius: 4px; +} + +.results-panel::-webkit-scrollbar-thumb:hover { + background: #4a4a4a; +} + +.results-header { + background: #242424; + padding: 20px; + border-bottom: 1px solid #2a2a2a; + position: sticky; + top: 0; + z-index: 10; +} + +.results-count { + font-weight: 700; + color: #e0e0e0; + margin-bottom: 12px; + font-size: 16px; +} + +.progress-info { + background: rgba(14, 165, 233, 0.1); + padding: 14px; + border-radius: 6px; + margin-bottom: 12px; + display: none; + border: 1px solid rgba(14, 165, 233, 0.2); +} + +.progress-info.active { + display: block; +} + +.progress-bar { + width: 100%; + height: 6px; + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; + overflow: hidden; + margin-top: 10px; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, #0ea5e9 0%, #06b6d4 100%); + width: 0%; + transition: width 0.3s; + box-shadow: 0 0 10px rgba(14, 165, 233, 0.5); +} + +.progress-text { + font-size: 13px; + color: #0ea5e9; + margin-bottom: 6px; + font-weight: 600; +} + +.eta-text { + font-size: 11px; + color: #888; + margin-top: 6px; +} + +.sort-control { + display: flex; + gap: 8px; + align-items: center; +} + +.sort-control label { + font-size: 12px; + color: #888; + font-weight: 600; +} + +.sort-control select { + padding: 8px 12px; + border: 1px solid #3a3a3a; + border-radius: 6px; + font-size: 13px; + background: #2a2a2a; + color: #e0e0e0; +} + +.result-item { + background: #242424; + margin: 12px; + border-radius: 8px; + overflow: hidden; + cursor: pointer; + transition: all 0.2s; + border: 1px solid #2a2a2a; +} + +.result-item:hover { + border-color: #0ea5e9; + box-shadow: 0 4px 16px rgba(14, 165, 233, 0.2); + transform: translateY(-2px); +} + +.result-item.selected { + border-color: #0ea5e9; + box-shadow: 0 0 0 2px rgba(14, 165, 233, 0.3); +} + +.result-image { + width: 100%; + height: 180px; + object-fit: cover; + background: #1a1a1a; +} + +.result-content { + padding: 14px; +} + +.result-title { + font-weight: 600; + color: #e0e0e0; + margin-bottom: 10px; + font-size: 14px; + line-height: 1.4; +} + +.result-price { + color: #0ea5e9; + font-weight: 700; + font-size: 20px; + margin-bottom: 10px; +} + +.result-meta { + display: flex; + justify-content: space-between; + font-size: 12px; + color: #888; +} + +.result-location { + display: flex; + align-items: center; + gap: 4px; +} + +.result-date { + font-style: italic; +} + +.map-container { + position: relative; + height: 100%; + background: #1a1a1a; +} + +#map { + width: 100%; + height: 100%; + filter: brightness(0.9) contrast(1.1); +} + +.status-bar { + position: absolute; + top: 20px; + left: 50%; + transform: translateX(-50%); + background: #242424; + padding: 14px 24px; + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0,0,0,0.4); + z-index: 1000; + display: none; + border: 1px solid #3a3a3a; +} + +.status-bar.visible { + display: block; +} + +.status-bar.loading { + background: rgba(14, 165, 233, 0.2); + color: #0ea5e9; + border-color: rgba(14, 165, 233, 0.3); +} + +.status-bar.success { + background: rgba(34, 197, 94, 0.2); + color: #22c55e; + border-color: rgba(34, 197, 94, 0.3); +} + +.status-bar.error { + background: rgba(239, 68, 68, 0.2); + color: #ef4444; + border-color: rgba(239, 68, 68, 0.3); +} + +.no-results { + text-align: center; + padding: 60px 20px; + color: #666; + font-size: 14px; +} + +.loading-spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid #0ea5e9; + border-radius: 50%; + border-top-color: transparent; + animation: spin 0.8s linear infinite; + margin-right: 10px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.footer { + background: #242424; + border-top: 1px solid #2a2a2a; + padding: 15px 20px; + height: 60px; +} + +.footer-content { + display: flex; + justify-content: space-between; + align-items: center; + max-width: 1400px; + margin: 0 auto; +} + +.footer-links { + display: flex; + gap: 20px; +} + +.footer-links a { + color: #888; + text-decoration: none; + font-size: 13px; + transition: color 0.2s; +} + +.footer-links a:hover { + color: #0ea5e9; +} + +.footer-info { + color: #666; + font-size: 12px; +} + +.modal { + display: none; + position: fixed; + z-index: 10000; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgba(0,0,0,0.8); +} + +.modal.show { + display: block; +} + +.modal-content { + background-color: #242424; + margin: 5% auto; + padding: 0; + border: 1px solid #3a3a3a; + border-radius: 8px; + width: 90%; + max-width: 800px; + max-height: 80vh; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.modal-content h2 { + background: #2a2a2a; + padding: 20px; + margin: 0; + color: #e0e0e0; + border-bottom: 1px solid #3a3a3a; +} + +.modal-body { + padding: 20px; + overflow-y: auto; + flex: 1; +} + +.modal-body::-webkit-scrollbar { + width: 8px; +} + +.modal-body::-webkit-scrollbar-track { + background: #1a1a1a; +} + +.modal-body::-webkit-scrollbar-thumb { + background: #3a3a3a; + border-radius: 4px; +} + +.modal-body h3 { + color: #0ea5e9; + margin-top: 20px; + margin-bottom: 10px; + font-size: 18px; +} + +.modal-body h4 { + color: #888; + margin-top: 15px; + margin-bottom: 8px; + font-size: 14px; +} + +.modal-body p { + margin-bottom: 10px; + line-height: 1.6; + color: #ccc; +} + +.modal-body ul { + margin-left: 20px; + margin-bottom: 15px; +} + +.modal-body li { + margin-bottom: 8px; + color: #ccc; + line-height: 1.6; +} + +.modal-body a { + color: #0ea5e9; + text-decoration: none; +} + +.modal-body a:hover { + text-decoration: underline; +} + +.modal-close { + color: #888; + position: absolute; + right: 20px; + top: 20px; + font-size: 28px; + font-weight: bold; + cursor: pointer; + transition: color 0.2s; +} + +.modal-close:hover, +.modal-close:focus { + color: #fff; +} + +@media (max-width: 1024px) { + .container { + grid-template-columns: 320px 1fr; + } + + .search-bar { + grid-template-columns: 1fr; + } + + .price-inputs input { + width: 100px; + } +} + +@media (max-width: 768px) { + .container { + grid-template-columns: 1fr; + grid-template-rows: auto auto 1fr; + } + + .results-panel { + max-height: 300px; + } + + .footer-content { + flex-direction: column; + gap: 10px; + } +} \ No newline at end of file diff --git a/web/index.html b/web/index.html index 2e20acc..f1a9a2f 100644 --- a/web/index.html +++ b/web/index.html @@ -1,394 +1,22 @@ + Kleinanzeigen Karten-Suche - - + +
@@ -415,7 +43,7 @@
Keine Ergebnisse
- +
Inserate werden geladen...
@@ -443,410 +71,94 @@
- + + + + + + + \ No newline at end of file diff --git a/web/js/api.js b/web/js/api.js new file mode 100644 index 0000000..4fd341d --- /dev/null +++ b/web/js/api.js @@ -0,0 +1,151 @@ +// API communication functions + +async function scrapeNextListing() { + if (!AppState.currentSessionId || !AppState.isScrapingActive) { + console.log('Scraping stopped: session or active flag cleared'); + return false; + } + + try { + const response = await fetch(`${API_BASE_URL}/api/scrape/${AppState.currentSessionId}`); + const data = await response.json(); + + if (data.cancelled) { + console.log('Scraping cancelled by backend'); + return false; + } + + if (data.listing) { + AppState.allListings.push(data.listing); + addMarker(data.listing); + + // Update display + const sortBy = document.getElementById('sortSelect').value; + const sortedListings = sortListings(AppState.allListings, sortBy); + renderResults(sortedListings); + + // Fit map to markers + if (AppState.markers.length > 0) { + const group = L.featureGroup(AppState.markers); + AppState.map.fitBounds(group.getBounds().pad(0.1)); + } + } + + updateProgress(data.progress.current, data.progress.total); + + if (data.complete) { + console.log('Scraping complete, finalizing...'); + AppState.isScrapingActive = false; + document.getElementById('searchBtn').disabled = false; + document.getElementById('cancelBtn').style.display = 'none'; + updateProgress(0, 0); + showStatus(`Fertig! ${AppState.allListings.length} Inserate geladen`, 'success'); + console.log('Final listings count:', AppState.allListings.length); + return false; + } + + return true; + + } catch (error) { + console.error('Scrape error:', error); + AppState.isScrapingActive = false; + document.getElementById('searchBtn').disabled = false; + document.getElementById('cancelBtn').style.display = 'none'; + showStatus('Fehler beim Laden der Inserate', 'error'); + return false; + } +} + +async function startScrapingLoop() { + while (AppState.isScrapingActive && AppState.currentSessionId) { + const shouldContinue = await scrapeNextListing(); + if (!shouldContinue) { + break; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } +} + +async function searchListings() { + const searchTerm = document.getElementById('searchTerm').value.trim(); + const minPrice = parseInt(document.getElementById('minPrice').value) || 0; + const maxPriceInput = document.getElementById('maxPrice').value; + const maxPrice = maxPriceInput ? parseInt(maxPriceInput) : 1000000000; + const numListings = parseInt(document.getElementById('numListings').value) || 25; + + if (!searchTerm) { + showStatus('Bitte Suchbegriff eingeben', 'error'); + return; + } + + document.getElementById('searchBtn').disabled = true; + clearMarkers(); + AppState.allListings = []; + AppState.selectedListingId = null; + document.getElementById('resultsList').innerHTML = ''; + + showStatus('Suche nach Inseraten...', 'loading'); + + try { + const response = await fetch(`${API_BASE_URL}/api/search`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + search_term: searchTerm, + min_price: minPrice, + max_price: maxPrice, + num_listings: numListings + }) + }); + + if (!response.ok) { + throw new Error('API request failed'); + } + + const data = await response.json(); + AppState.currentSessionId = data.session_id; + + if (data.total === 0) { + showStatus('Keine Inserate gefunden', 'error'); + document.getElementById('searchBtn').disabled = false; + return; + } + + showStatus(`${data.total} Inserate gefunden. Lade Details...`, 'success'); + + // Show cancel button + document.getElementById('cancelBtn').style.display = 'inline-block'; + + // Start scraping + AppState.isScrapingActive = true; + AppState.scrapeStartTime = Date.now(); + updateProgress(0, data.total); + startScrapingLoop(); + + } catch (error) { + console.error('Search error:', error); + showStatus('Fehler: Verbindung zum Server fehlgeschlagen', 'error'); + document.getElementById('searchBtn').disabled = false; + } +} + +async function cancelScraping() { + if (!AppState.currentSessionId) return; + + try { + await fetch(`${API_BASE_URL}/api/scrape/${AppState.currentSessionId}/cancel`, { + method: 'POST' + }); + + AppState.isScrapingActive = false; + document.getElementById('searchBtn').disabled = false; + document.getElementById('cancelBtn').style.display = 'none'; + updateProgress(0, 0); + showStatus(`Abgebrochen. ${AppState.allListings.length} Inserate geladen`, 'error'); + + } catch (error) { + console.error('Cancel error:', error); + } +} \ No newline at end of file diff --git a/web/js/app.js b/web/js/app.js new file mode 100644 index 0000000..1c8cf30 --- /dev/null +++ b/web/js/app.js @@ -0,0 +1,24 @@ +// Main application initialization and event handlers + +// Event listeners +document.getElementById('searchBtn').addEventListener('click', searchListings); +document.getElementById('cancelBtn').addEventListener('click', cancelScraping); + +document.getElementById('searchTerm').addEventListener('keypress', (e) => { + if (e.key === 'Enter') searchListings(); +}); + +document.getElementById('sortSelect').addEventListener('change', (e) => { + if (AppState.allListings.length > 0) { + const sortedListings = sortListings(AppState.allListings, e.target.value); + renderResults(sortedListings); + } +}); + +// Initialize on page load +document.addEventListener('DOMContentLoaded', () => { + initMap(); + initPrivacyModal(); + console.log('Kleinanzeigen Karten-Suche initialized'); + console.log('API Base URL:', API_BASE_URL); +}); \ No newline at end of file diff --git a/web/js/config.js b/web/js/config.js new file mode 100644 index 0000000..e578128 --- /dev/null +++ b/web/js/config.js @@ -0,0 +1,15 @@ +// Auto-detect API base URL from current domain +const API_BASE_URL = window.location.protocol + '//' + window.location.host; + +//const API_BASE_URL = 'http://localhost:5000'; //used for development + +// Application state +const AppState = { + map: null, + markers: [], + allListings: [], + selectedListingId: null, + currentSessionId: null, + scrapeStartTime: null, + isScrapingActive: false +}; \ No newline at end of file diff --git a/web/js/map.js b/web/js/map.js new file mode 100644 index 0000000..b9ab978 --- /dev/null +++ b/web/js/map.js @@ -0,0 +1,70 @@ +// Map initialization and marker management + +function initMap() { + AppState.map = L.map('map').setView([51.1657, 10.4515], 6); + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + maxZoom: 19, + }).addTo(AppState.map); + + // Try to get user's location + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition( + (position) => { + const lat = position.coords.latitude; + const lon = position.coords.longitude; + + // Add user location marker + const userIcon = L.divIcon({ + html: '
', + className: '', + iconSize: [16, 16], + iconAnchor: [8, 8] + }); + + L.marker([lat, lon], { icon: userIcon }) + .addTo(AppState.map) + .bindPopup('
Dein Standort
'); + + console.log('User location:', lat, lon); + }, + (error) => { + console.log('Geolocation error:', error.message); + } + ); + } +} + +function clearMarkers() { + AppState.markers.forEach(marker => AppState.map.removeLayer(marker)); + AppState.markers = []; +} + +function addMarker(listing) { + if (!listing.lat || !listing.lon) return; + + const marker = L.marker([listing.lat, listing.lon]).addTo(AppState.map); + + const imageHtml = listing.image + ? `${listing.title}` + : ''; + + const popupContent = ` +
+ ${listing.title}
+ ${imageHtml} + ${listing.price} €
+ ${listing.address}
+ Link öffnen → +
+ `; + marker.bindPopup(popupContent); + + marker.on('click', () => { + AppState.selectedListingId = listing.id; + highlightSelectedListing(); + }); + + AppState.markers.push(marker); +} \ No newline at end of file diff --git a/web/js/ui.js b/web/js/ui.js new file mode 100644 index 0000000..42390d6 --- /dev/null +++ b/web/js/ui.js @@ -0,0 +1,168 @@ +// UI management functions + +function showStatus(message, type = 'loading') { + const statusBar = document.getElementById('statusBar'); + statusBar.className = `status-bar visible ${type}`; + + if (type === 'loading') { + statusBar.innerHTML = `${message}`; + } else { + statusBar.textContent = message; + } + + if (type !== 'loading') { + setTimeout(() => { + statusBar.classList.remove('visible'); + }, 3000); + } +} + +function updateProgress(current, total) { + const progressInfo = document.getElementById('progressInfo'); + const progressFill = progressInfo.querySelector('.progress-fill'); + const progressText = progressInfo.querySelector('.progress-text'); + const etaText = progressInfo.querySelector('.eta-text'); + + if (total === 0) { + progressInfo.classList.remove('active'); + return; + } + + progressInfo.classList.add('active'); + const percentage = (current / total) * 100; + progressFill.style.width = percentage + '%'; + progressText.textContent = `Inserate werden geladen: ${current}/${total}`; + + // Calculate ETA + if (AppState.scrapeStartTime && current > 0) { + const elapsed = (Date.now() - AppState.scrapeStartTime) / 1000; + const avgTimePerListing = elapsed / current; + const remaining = total - current; + const etaSeconds = Math.round(avgTimePerListing * remaining); + + const minutes = Math.floor(etaSeconds / 60); + const seconds = etaSeconds % 60; + + if (minutes > 0) { + etaText.textContent = `Noch ca. ${minutes}m ${seconds}s`; + } else { + etaText.textContent = `Noch ca. ${seconds}s`; + } + } +} + +function highlightSelectedListing() { + document.querySelectorAll('.result-item').forEach(item => { + const itemId = parseInt(item.dataset.id); + if (itemId === AppState.selectedListingId) { + item.classList.add('selected'); + item.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } else { + item.classList.remove('selected'); + } + }); +} + +function formatDate(dateString) { + if (!dateString) return 'Unbekanntes Datum'; + const date = new Date(dateString); + return date.toLocaleDateString('de-DE'); +} + +function renderResults(listings) { + const resultsList = document.getElementById('resultsList'); + const resultsCount = document.querySelector('.results-count'); + + if (listings.length === 0) { + resultsList.innerHTML = '
Keine Inserate gefunden
'; + resultsCount.textContent = 'Keine Ergebnisse'; + return; + } + + resultsCount.textContent = `${listings.length} Inserat${listings.length !== 1 ? 'e' : ''}`; + + resultsList.innerHTML = listings.map(listing => ` +
+ ${listing.image ? `${listing.title}` : '
'} +
+
${listing.title}
+
${listing.price} €
+
+
+ 📍 + ${listing.address || listing.zip_code} +
+
${formatDate(listing.date_added)}
+
+
+
+ `).join(''); + + // Add click handlers + document.querySelectorAll('.result-item').forEach(item => { + item.addEventListener('click', () => { + const id = parseInt(item.dataset.id); + const listing = listings.find(l => l.id === id); + if (listing) { + AppState.selectedListingId = id; + highlightSelectedListing(); + + if (listing.lat && listing.lon) { + AppState.map.setView([listing.lat, listing.lon], 13); + const marker = AppState.markers.find(m => + m.getLatLng().lat === listing.lat && + m.getLatLng().lng === listing.lon + ); + if (marker) { + marker.openPopup(); + } + } + + window.open(listing.url, '_blank'); + } + }); + }); +} + +function sortListings(listings, sortBy) { + const sorted = [...listings]; + + switch (sortBy) { + case 'price-asc': + sorted.sort((a, b) => a.price - b.price); + break; + case 'price-desc': + sorted.sort((a, b) => b.price - a.price); + break; + case 'date-asc': + sorted.sort((a, b) => new Date(a.date_added || 0) - new Date(b.date_added || 0)); + break; + case 'date-desc': + sorted.sort((a, b) => new Date(b.date_added || 0) - new Date(a.date_added || 0)); + break; + } + + return sorted; +} + +// Privacy modal +function initPrivacyModal() { + const modal = document.getElementById('privacyModal'); + const link = document.getElementById('privacyLink'); + const close = document.querySelector('.modal-close'); + + link.addEventListener('click', (e) => { + e.preventDefault(); + modal.classList.add('show'); + }); + + close.addEventListener('click', () => { + modal.classList.remove('show'); + }); + + window.addEventListener('click', (e) => { + if (e.target === modal) { + modal.classList.remove('show'); + } + }); +} \ No newline at end of file