Files
kleinanzeigen-boosted/web/index.html
2025-11-25 18:26:59 +01:00

852 lines
28 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kleinanzeigen Karten-Suche</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
<style>
* {
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: 100vh;
}
.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); }
}
@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;
}
}
</style>
</head>
<body>
<div class="container">
<div class="search-bar">
<div class="form-group">
<label>Suchbegriff</label>
<input type="text" id="searchTerm" placeholder="z.B. Fahrrad" value="Fahrrad">
</div>
<div class="form-group">
<label>Preisspanne</label>
<div class="price-inputs">
<input type="number" id="minPrice" placeholder="0 €" value="0" min="0" max="1000000000">
<span class="price-separator"></span>
<input type="number" id="maxPrice" placeholder="∞ €" value="" min="0" max="1000000000">
</div>
</div>
<div class="form-group">
<label>Anzahl Inserate</label>
<select id="numListings">
<option value="25" selected>25 Inserate</option>
<option value="50">50 Inserate</option>
<option value="100">100 Inserate</option>
<option value="250">250 Inserate</option>
</select>
</div>
<button id="searchBtn">Suchen</button>
<button id="cancelBtn" class="cancel" style="display: none;">Abbrechen</button>
</div>
<div class="results-panel">
<div class="results-header">
<div class="results-count">Keine Ergebnisse</div>
<div id="progressInfo" class="progress-info">
<div class="progress-text">Inserate werden geladen...</div>
<div class="progress-bar">
<div class="progress-fill"></div>
</div>
<div class="eta-text"></div>
</div>
<div class="sort-control">
<label>Sortierung:</label>
<select id="sortSelect">
<option value="date-desc">Datum (neueste)</option>
<option value="date-asc">Datum (älteste)</option>
<option value="price-asc">Preis (niedrig → hoch)</option>
<option value="price-desc">Preis (hoch → niedrig)</option>
</select>
</div>
</div>
<div id="resultsList"></div>
</div>
<div class="map-container">
<div id="statusBar" class="status-bar"></div>
<div id="map"></div>
</div>
</div>
<script>
const API_BASE_URL = 'http://localhost:5000';
let map;
let markers = [];
let allListings = [];
let selectedListingId = null;
let currentSessionId = null;
let scrapeStartTime = null;
let isScrapingActive = false;
// Initialize map
function initMap() {
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(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: '<div style="width: 16px; height: 16px; background: #0ea5e9; border: 3px solid white; border-radius: 50%; box-shadow: 0 0 10px rgba(14, 165, 233, 0.6);"></div>',
className: '',
iconSize: [16, 16],
iconAnchor: [8, 8]
});
L.marker([lat, lon], { icon: userIcon })
.addTo(map)
.bindPopup('<div style="background: #1a1a1a; color: #e0e0e0; padding: 8px;"><strong>Dein Standort</strong></div>');
console.log('User location:', lat, lon);
},
(error) => {
console.log('Geolocation error:', error.message);
}
);
}
}
// Show status message
function showStatus(message, type = 'loading') {
const statusBar = document.getElementById('statusBar');
statusBar.className = `status-bar visible ${type}`;
if (type === 'loading') {
statusBar.innerHTML = `<span class="loading-spinner"></span>${message}`;
} else {
statusBar.textContent = message;
}
if (type !== 'loading') {
setTimeout(() => {
statusBar.classList.remove('visible');
}, 3000);
}
}
// Update progress
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 (scrapeStartTime && current > 0) {
const elapsed = (Date.now() - 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`;
}
}
}
// Clear all markers from map
function clearMarkers() {
markers.forEach(marker => map.removeLayer(marker));
markers = [];
}
// Add marker to map
function addMarker(listing) {
if (!listing.lat || !listing.lon) return;
const marker = L.marker([listing.lat, listing.lon]).addTo(map);
const imageHtml = listing.image
? `<img src="${listing.image}" style="width: 100%; max-height: 150px; object-fit: cover; margin: 8px 0;" alt="${listing.title}">`
: '';
const popupContent = `
<div style="min-width: 200px; 8px;">
<strong style="font-size: 14px;">${listing.title}</strong><br>
${imageHtml}
<span style="color: #0066cc; font-weight: bold; font-size: 16px;">${listing.price} €</span><br>
<span style="color: #666; font-size: 12px;">${listing.address}</span><br>
<a href="${listing.url}" target="_blank" style="color: #0066cc; text-decoration: none; font-weight: 600;">Link öffnen →</a>
</div>
`;
marker.bindPopup(popupContent);
marker.on('click', () => {
selectedListingId = listing.id;
highlightSelectedListing();
});
markers.push(marker);
}
// Highlight selected listing in results list
function highlightSelectedListing() {
document.querySelectorAll('.result-item').forEach(item => {
const itemId = parseInt(item.dataset.id);
if (itemId === selectedListingId) {
item.classList.add('selected');
item.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} else {
item.classList.remove('selected');
}
});
}
// Format date
function formatDate(dateString) {
if (!dateString) return 'Unbekanntes Datum';
const date = new Date(dateString);
return date.toLocaleDateString('de-DE');
}
// Render results list
function renderResults(listings) {
const resultsList = document.getElementById('resultsList');
const resultsCount = document.querySelector('.results-count');
if (listings.length === 0) {
resultsList.innerHTML = '<div class="no-results">Keine Inserate gefunden</div>';
resultsCount.textContent = 'Keine Ergebnisse';
return;
}
resultsCount.textContent = `${listings.length} Inserat${listings.length !== 1 ? 'e' : ''}`;
resultsList.innerHTML = listings.map(listing => `
<div class="result-item" data-id="${listing.id}">
${listing.image ? `<img src="${listing.image}" class="result-image" alt="${listing.title}">` : '<div class="result-image"></div>'}
<div class="result-content">
<div class="result-title">${listing.title}</div>
<div class="result-price">${listing.price} €</div>
<div class="result-meta">
<div class="result-location">
<span>📍</span>
<span>${listing.address || listing.zip_code}</span>
</div>
<div class="result-date">${formatDate(listing.date_added)}</div>
</div>
</div>
</div>
`).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) {
selectedListingId = id;
highlightSelectedListing();
if (listing.lat && listing.lon) {
map.setView([listing.lat, listing.lon], 13);
const marker = markers.find(m =>
m.getLatLng().lat === listing.lat &&
m.getLatLng().lng === listing.lon
);
if (marker) {
marker.openPopup();
}
}
window.open(listing.url, '_blank');
}
});
});
}
// Sort listings
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;
}
// Scrape next listing
async function scrapeNextListing() {
if (!currentSessionId || !isScrapingActive) {
console.log('Scraping stopped: session or active flag cleared');
return false;
}
try {
const response = await fetch(`${API_BASE_URL}/api/scrape/${currentSessionId}`);
const data = await response.json();
if (data.cancelled) {
console.log('Scraping cancelled by backend');
return false;
}
if (data.listing) {
allListings.push(data.listing);
addMarker(data.listing);
// Update display
const sortBy = document.getElementById('sortSelect').value;
const sortedListings = sortListings(allListings, sortBy);
renderResults(sortedListings);
// Fit map to markers
if (markers.length > 0) {
const group = L.featureGroup(markers);
map.fitBounds(group.getBounds().pad(0.1));
}
}
updateProgress(data.progress.current, data.progress.total);
if (data.complete) {
console.log('Scraping complete, finalizing...');
isScrapingActive = false;
document.getElementById('searchBtn').disabled = false;
document.getElementById('cancelBtn').style.display = 'none';
updateProgress(0, 0);
showStatus(`Fertig! ${allListings.length} Inserate geladen`, 'success');
console.log('Final listings count:', allListings.length);
return false;
}
return true;
} catch (error) {
console.error('Scrape error:', error);
isScrapingActive = false;
document.getElementById('searchBtn').disabled = false;
document.getElementById('cancelBtn').style.display = 'none';
showStatus('Fehler beim Laden der Inserate', 'error');
return false;
}
}
// Start scraping loop
async function startScrapingLoop() {
while (isScrapingActive && currentSessionId) {
const shouldContinue = await scrapeNextListing();
if (!shouldContinue) {
break;
}
await new Promise(resolve => setTimeout(resolve, 100));
}
}
// Search listings
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();
allListings = [];
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();
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
isScrapingActive = true;
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;
}
}
// Cancel scraping
async function cancelScraping() {
if (!currentSessionId) return;
try {
await fetch(`${API_BASE_URL}/api/scrape/${currentSessionId}/cancel`, {
method: 'POST'
});
isScrapingActive = false;
document.getElementById('searchBtn').disabled = false;
document.getElementById('cancelBtn').style.display = 'none';
updateProgress(0, 0);
showStatus(`Abgebrochen. ${allListings.length} Inserate geladen`, 'error');
} catch (error) {
console.error('Cancel error:', error);
}
}
// 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 (allListings.length > 0) {
const sortedListings = sortListings(allListings, e.target.value);
renderResults(sortedListings);
}
});
// Initialize
initMap();
</script>
</body>
</html>