diff --git a/.gitignore b/.gitignore index 7ece1ba7..fb610998 100755 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ node_modules/ ui/public/ db/*.json db/*.db* +conf/*.db +conf/*.db-shm +conf/*.db-wal npm-debug.log .DS_Store .idea diff --git a/.nvmrc b/.nvmrc index 0b77208a..2bd5a0a9 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -16.14.0 \ No newline at end of file +22 diff --git a/README.md b/README.md index 8b0790c9..f3a968e4 100755 --- a/README.md +++ b/README.md @@ -23,6 +23,9 @@ +> **This is a personal fork of [orangecoding/fredy](https://github.com/orangecoding/fredy) maintained by [@domisko](https://github.com/domisko).** +> It adds features on top of the upstream project — see [Custom Features](#-custom-features) below. + # Fredy 🏡 - Your Self-Hosted Real Estate Finder for Germany Finding an apartment or house in Germany can be stressful and @@ -38,12 +41,22 @@ same listing twice. ------------------------------------------------------------------------ +## 🔧 Custom Features + +Features added in this fork on top of the upstream Fredy: + +- **Homegate (CH) provider** — scrapes Swiss listings from [homegate.ch](https://www.homegate.ch), with full virtual-list scroll support so all listings on the page are captured +- **Commute times via [Transitous](https://transitous.org/)** — walking, cycling, driving and public transit times from each listing to your destination; no API key required; results cached in the browser for 24 h +- **Bug fix: news modal dismiss** — the "what's new" modal no longer reappears on every page reload + +------------------------------------------------------------------------ + ## ✨ Key Features - 🏠 Scrapes **ImmoScout24, Immowelt, Immonet, eBay Kleinanzeigen, - WG-Gesucht** + WG-Gesucht, Homegate (CH)** - ⚡ Instant notifications: Slack, Telegram, Email (SendGrid, - Mailjet), ntfy, discord + Mailjet), ntfy, discord - 🔎 Uses the **ImmoScout Mobile API** (reverse engineered) - 🌍 Runs anywhere: Docker, Node.js, self-hosted - 🖥️ Intuitive **Web UI** to manage searches @@ -78,25 +91,37 @@ You can try out Fredy here: [Fredy Demo](https://fredy-demo.orange-coding.net/) ## 🚀 Quick Start -### With Docker +### With Docker (this fork) > [!NOTE] -> In order to start Fredy, you must provide a config.json. As a start, use the one in this repo: https://github.com/orangecoding/fredy/blob/master/conf/config.json +> This fork is not published to a container registry. Build the image locally from the repo. ``` bash -docker run -d --name fredy \ - -v fredy_conf:/conf \ - -v fredy_db:/db \ - -p 9998:9998 \ - ghcr.io/orangecoding/fredy:master +# Clone and build +git clone https://github.com/domisko/fredy.git +cd fredy +docker compose up -d --build ``` Logs: ``` bash -docker logs fredy -f +docker compose logs -f ``` +To update after pulling new changes: + +``` bash +git pull +docker compose up -d --build +``` + +> **Syncing with upstream:** to pull in fixes from the original Fredy project, add it as a remote once: +> ```bash +> git remote add upstream https://github.com/orangecoding/fredy.git +> ``` +> Then periodically: `git fetch upstream && git merge upstream/master` + ### Manual (Node.js) - Requirement: **Node.js 22 or higher** diff --git a/docker-compose.yml b/docker-compose.yml index 5961f3df..2dbdfc44 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: build: context: . dockerfile: Dockerfile - image: ghcr.io/orangecoding/fredy + image: fredy-custom environment: - NODE_ENV=production volumes: diff --git a/lib/FredyPipelineExecutioner.js b/lib/FredyPipelineExecutioner.js index b586d770..231cc88f 100755 --- a/lib/FredyPipelineExecutioner.js +++ b/lib/FredyPipelineExecutioner.js @@ -415,22 +415,26 @@ class FredyPipelineExecutioner { _filterBySimilarListings(listings) { const filteredIds = []; const keptListings = listings.filter((listing) => { - const similar = this._similarityCache.checkAndAddEntry({ + const { duplicate, source } = this._similarityCache.checkAndAddEntry({ title: listing.title, address: listing.address, price: listing.price, }); - if (similar) { + if (duplicate) { + const origin = source ? ` (first seen: provider='${source.provider}' job='${source.jobId}')` : ''; logger.debug( - `Filtering similar entry for title '${listing.title}' and address '${listing.address}' (Provider: '${this._providerId}')`, + `Filtering similar entry for title '${listing.title}' and address '${listing.address}' (Provider: '${this._providerId}')${origin}`, ); filteredIds.push(listing.id); } - return !similar; + return !duplicate; }); if (filteredIds.length > 0) { - deleteListingsById(filteredIds); + // Soft-delete so duplicates are hidden from the overview but their hashes remain + // in DB — _findNew will skip them on the next run without hitting the similarity + // cache again. A user hard-delete clears these rows and allows a fresh start. + deleteListingsById(filteredIds, false); } return keptListings; diff --git a/lib/api/routes/generalSettingsRoute.js b/lib/api/routes/generalSettingsRoute.js index a56827be..4fd3796d 100644 --- a/lib/api/routes/generalSettingsRoute.js +++ b/lib/api/routes/generalSettingsRoute.js @@ -17,7 +17,13 @@ import { TRACKING_POIS } from '../../TRACKING_POIS.js'; */ export default async function generalSettingsPlugin(fastify) { fastify.get('/', async () => { - return Object.assign({}, await getSettings()); + const settings = Object.assign({}, await getSettings()); + // Never expose the raw API key to the frontend — return a boolean flag instead + settings.deepl_api_key_set = !!settings.deepl_api_key; + delete settings.deepl_api_key; + settings.immoscout24ch_datadome_set = !!settings.immoscout24ch_datadome; + delete settings.immoscout24ch_datadome; + return settings; }); fastify.post('/', async (request, reply) => { diff --git a/lib/api/routes/listingsRouter.js b/lib/api/routes/listingsRouter.js index 8f2ae22b..ff8c5b19 100644 --- a/lib/api/routes/listingsRouter.js +++ b/lib/api/routes/listingsRouter.js @@ -12,6 +12,10 @@ import { getJob } from '../../services/storage/jobStorage.js'; import { getSettings } from '../../services/storage/settingsStorage.js'; import { trackPoi } from '../../services/tracking/Tracker.js'; import { TRACKING_POIS } from '../../TRACKING_POIS.js'; +import { initSimilarityCache } from '../../services/similarity-check/similarityCache.js'; +import { translate as deeplTranslate } from '../../services/translation/deeplClient.js'; +import { getAllRoutes as transitousGetAllRoutes } from '../../services/routing/transitousClient.js'; +import { getUserSettings } from '../../services/storage/settingsStorage.js'; /** * @param {import('fastify').FastifyInstance} fastify @@ -172,6 +176,7 @@ export default async function listingsPlugin(fastify) { .send({ error: 'You are trying to remove listings for a job that is not associated to your user' }); } listingStorage.deleteListingsByJobId(jobId, hardDelete); + if (hardDelete) initSimilarityCache(); } catch (error) { logger.error(error); return reply.code(500).send({ error: error.message }); @@ -188,6 +193,7 @@ export default async function listingsPlugin(fastify) { } if (Array.isArray(ids) && ids.length > 0) { listingStorage.deleteListingsById(ids, hardDelete); + if (hardDelete) initSimilarityCache(); } } catch (error) { logger.error(error); @@ -196,6 +202,77 @@ export default async function listingsPlugin(fastify) { return reply.send(); }); + fastify.post('/:listingId/translate', async (request, reply) => { + const { listingId } = request.params; + const { targetLanguage } = request.body || {}; + + if (!targetLanguage) { + return reply.code(400).send({ error: 'targetLanguage is required' }); + } + + const settings = await getSettings(); + if (!settings.deepl_api_key) { + return reply.code(400).send({ error: 'DeepL API key not configured' }); + } + + try { + const listing = listingStorage.getListingById(listingId, request.session.currentUser, isAdminFn(request)); + if (!listing) { + return reply.code(404).send({ error: 'Listing not found' }); + } + + if (!listing.description) { + return reply.code(400).send({ error: 'Listing has no description to translate' }); + } + + const translations = listing.translations ? JSON.parse(listing.translations) : {}; + const lang = targetLanguage.toLowerCase(); + if (translations[lang]) { + return reply.send({ text: translations[lang], cached: true }); + } + + const translated = await deeplTranslate( + listing.description, + targetLanguage.toUpperCase(), + settings.deepl_api_key, + ); + listingStorage.setListingTranslation(listingId, lang, translated); + return reply.send({ text: translated, cached: false }); + } catch (error) { + logger.error(error); + return reply.code(500).send({ error: error.message }); + } + }); + + fastify.post('/:listingId/commute', async (request, reply) => { + const { listingId } = request.params; + + try { + const listing = listingStorage.getListingById(listingId, request.session.currentUser, isAdminFn(request)); + if (!listing) return reply.code(404).send({ error: 'Listing not found' }); + if (!listing.latitude || listing.latitude === -1) { + return reply.code(400).send({ error: 'Listing has no coordinates' }); + } + + const userSettings = getUserSettings(request.session.currentUser); + const destination = userSettings?.home_address?.coords; + if (!destination) { + return reply.code(400).send({ error: 'No commute destination set in user settings' }); + } + + const result = await transitousGetAllRoutes( + listing.latitude, + listing.longitude, + destination.lat, + destination.lng, + ); + return reply.send(result); + } catch (error) { + logger.error(error); + return reply.code(500).send({ error: error.message }); + } + }); + fastify.post('/restore', async (request, reply) => { const { ids } = request.body || {}; const settings = await getSettings(); diff --git a/lib/provider/homegate.js b/lib/provider/homegate.js new file mode 100644 index 00000000..d46736c9 --- /dev/null +++ b/lib/provider/homegate.js @@ -0,0 +1,229 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { buildHash, isOneOf } from '../utils.js'; +import { extractNumber } from '../utils/extract-number.js'; +import logger from '../services/logger.js'; +import { getSettings } from '../services/storage/settingsStorage.js'; +import { launchBrowser, closeBrowser } from '../services/extractor/puppeteerExtractor.js'; +/** @import { ParsedListing } from '../types/listing.js' */ +/** @import { ProviderConfig } from '../types/providerConfig.js' */ + +let appliedBlackList = []; + +/** + * Fetch listings using CloakBrowser, reading window.__INITIAL_STATE__ at + * DOMContentLoaded before DataDome's deferred scripts run their challenge. + * + * __INITIAL_STATE__ is set by an inline synchronous script during HTML parsing + * and is therefore available at domcontentloaded. DataDome on Homegate loads + * as a deferred/async external script, so it cannot run its ARM64 fingerprint + * check until after we have already read the data. + * + * @param {string} url - The configured Homegate search URL. + * @returns {Promise} Raw listing objects for the normalize() pipeline step. + */ +async function getListings(url) { + const settings = await getSettings(); + const proxyUrl = typeof settings?.proxyUrl === 'string' ? settings.proxyUrl.trim() : ''; + + let browser; + try { + if (proxyUrl) { + logger.debug(`[Homegate] Using proxy: ${proxyUrl.replace(/:[^:@/]+@/, ':***@')}`); + } else { + logger.warn('[Homegate] No proxy configured — request may be blocked on non-residential IPs'); + } + browser = await launchBrowser(url, proxyUrl ? { proxyUrl } : {}); + const page = await browser.newPage(); + + // Warm-up: visit the homepage first so Homegate/DataDome sees an established session + // before we hit the search URL directly. + try { + await page.goto('https://www.homegate.ch/', { waitUntil: 'domcontentloaded', timeout: 20_000 }); + await new Promise((r) => setTimeout(r, 1500 + Math.random() * 1500)); + } catch { + // ignore — warm-up failure should not block the main request + } + + logger.debug(`[Homegate] Navigating to ${url}`); + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45_000 }); + + // DataDome intercepts at the CDN edge and serves a JS challenge page before real content. + // CloakBrowser can pass the challenge — but we must wait for __INITIAL_STATE__ to appear + // after the challenge resolves and the real Homegate page loads. + logger.debug('[Homegate] Waiting for __INITIAL_STATE__ (DataDome challenge may run first)…'); + try { + await page.waitForFunction(() => typeof window.__INITIAL_STATE__ !== 'undefined', { timeout: 25_000 }); + } catch { + logger.warn('[Homegate] Timed out waiting for __INITIAL_STATE__ — DataDome challenge did not resolve'); + } + + const state = await page.evaluate(() => window.__INITIAL_STATE__ ?? null); + + if (!state) { + const snippet = (await page.content()).slice(0, 300); + logger.warn(`[Homegate] window.__INITIAL_STATE__ not found. Page preview: ${snippet}`); + return []; + } + + const listings = state?.resultList?.search?.fullSearch?.result?.listings ?? []; + logger.debug(`[Homegate] Extracted ${listings.length} listings from __INITIAL_STATE__`); + + return listings.map((entry) => { + const listing = entry.listing ?? entry; + const offerType = listing.offerType ?? 'RENT'; + const pathPrefix = offerType === 'BUY' ? 'kaufen' : 'mieten'; + const chars = listing.characteristics ?? {}; + const prices = listing.prices ?? {}; + const addr = listing.address ?? {}; + const loc = + listing.localization?.de ?? + listing.localization?.fr ?? + listing.localization?.it ?? + listing.localization?.en ?? + {}; + const text = loc.text ?? {}; + const addressStr = [addr.street, addr.postalCode, addr.locality].filter(Boolean).join(', ') || null; + const rentAmount = prices.rent?.gross ?? prices.buy?.price ?? null; + const priceStr = rentAmount != null ? `CHF ${rentAmount}` : null; + const listingId = listing.id ?? entry.id; + + return { + id: `https://www.homegate.ch/${pathPrefix}/${listingId}`, + link: `https://www.homegate.ch/${pathPrefix}/${listingId}`, + title: text.title ?? null, + description: text.description ?? null, + price: priceStr, + rooms: chars.numberOfRooms ?? null, + size: chars.livingSpace != null ? `${chars.livingSpace}` : null, + address: addressStr, + image: null, + }; + }); + } catch (err) { + logger.error(`[Homegate] getListings failed: ${err.message}`); + return []; + } finally { + if (browser) await closeBrowser(browser); + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Parses a Swiss formatted price such as "CHF 1,714.–" or "CHF 2,250.50" into a number. + * Swiss notation uses ',' as the thousands separator, '.' as the decimal separator and + * '.–' as a placeholder for ".00" - the opposite of the German notation handled by + * `extractNumber`. + * @param {string|undefined|null} str + * @returns {number|null} + */ +function parsePrice(str) { + if (str == null) return null; + const cleaned = str + .replace(/CHF/gi, '') + .replace(/[’'`]/g, '') + .replace(/,/g, '') + .replace(/[–-]\s*$/, '') + .trim(); + const num = parseFloat(cleaned); + return isNaN(num) ? null : num; +} + +/** + * @param {any} o + * @returns {ParsedListing} + */ +function normalize(o) { + // Extract the numeric listing ID which is stable across language variants + // (/rent/ vs /mieten/) and query params: "/mieten/4003179219?pos=3" → "4003179219". + const rawHref = o.id || o.link || ''; + const numericId = rawHref.match(/\/(\d{6,})/)?.[1] ?? rawHref.split('?')[0]; + const id = buildHash(numericId); + + // Normalize CDN image URL to a consistent high-res Cloudinary transform. + // Multi-image listings expose a thumbnail strip (t_listing_card_117x102); single-image + // listings load the first slide directly without any transform in the URL. + let image = null; + if (o.image) { + if (o.image.includes('t_listing_card_117x102')) { + image = o.image.replace('t_listing_card_117x102', 't_listing_card_1074x585'); + } else if (o.image.includes('/listings/v2/') && !o.image.includes('/f_auto/')) { + image = o.image.replace('/listings/v2/', '/f_auto/t_listing_card_1074x585/listings/v2/'); + } else { + image = o.image; + } + } + + return { + id, + link: o.link, + title: o.title || o.address || null, + price: parsePrice(o.price), + currency: 'CHF', + size: extractNumber(o.size), + rooms: o.rooms != null ? parseFloat(o.rooms) : null, + address: o.address, + image, + description: o.description, + }; +} + +/** + * @param {ParsedListing} o + * @returns {boolean} + */ +function applyBlacklist(o) { + const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList); + const descNotBlacklisted = !isOneOf(o.description, appliedBlackList); + return titleNotBlacklisted && descNotBlacklisted; +} + +/** @type {ProviderConfig} */ +const config = { + requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'description'], + url: null, + sortByDateParam: 'sortBy=dateCreated,desc', + // crawlContainer / crawlFields / puppeteerOptions are kept so the browser fallback + // still works if getListings is removed. The pipeline uses getListings first when present. + crawlContainer: 'div[data-test="result-list-item"]', + waitForSelector: 'div[data-test="result-list"]', + puppeteerOptions: { + puppeteerTimeout: 120_000, + puppeteerSelectorTimeout: 90_000, + preNavigateUrl: 'https://www.homegate.ch/', + ignoredStatusCodes: [403], + autoScroll: true, + autoScrollDelay: 800, + autoScrollItemSelector: 'div[data-test="result-list-item"]', + autoScrollDedupeSelector: 'a[href*="homegate.ch"]', + }, + crawlFields: { + id: 'a@href', + link: 'a@href', + price: 'span[class*="HgListingCard_price"] | trim', + rooms: 'div[class*="HgListingRoomsLivingSpace_roomsLivingSpace"] span:nth-of-type(1) strong | trim', + size: 'div[class*="HgListingRoomsLivingSpace_roomsLivingSpace"] span:nth-of-type(2) strong | trim', + title: 'p[class*="HgListingDescription_title"] span | removeNewline | trim', + address: 'div[class*="HgListingCard_address"] address | removeNewline | trim', + description: 'p[class*="HgListingDescription_extra-large"] | removeNewline | trim', + image: 'img[src*="media2.homegate.ch"]@src', + }, + getListings, + normalize, + filter: applyBlacklist, +}; +export const init = (sourceConfig, blacklist) => { + config.enabled = sourceConfig.enabled; + config.url = sourceConfig.url; + appliedBlackList = blacklist || []; +}; +export const metaInformation = { + name: 'Homegate', + baseUrl: 'https://www.homegate.ch/', + id: 'homegate', +}; +export { config, getListings }; diff --git a/lib/provider/immoscout24ch.js b/lib/provider/immoscout24ch.js new file mode 100644 index 00000000..794dbf70 --- /dev/null +++ b/lib/provider/immoscout24ch.js @@ -0,0 +1,307 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +/** + * ImmoScout24 Switzerland provider using the mobile API. + * + * Two-step API flow (no browser / DataDome required): + * 1. POST https://api.immoscout24.ch/search/listings + * Body: { query: { offerType, location: { geoTags }, propertyType }, from, size, ... } + * Returns listing IDs in `results[].id`. + * + * 2. GET https://api.immoscout24.ch/listings/listings?ids={comma-separated}&fieldset=srp-list + * Returns full listing detail including images, price, address, description. + * + * Headers reverse-engineered from the Android app (v6.1.9): + * user-agent: immoscout24.ch.nextgen App Android/6.1.9 + * x-app-version: Immoscout24/6.1.9(6100901)/Android/35 + * x-app-time: {unix_ms_timestamp}{random_hex_suffix} + * + * The listing data structure is shared with Homegate (same parent company, Swiss + * Marketplace Group). A listing's `platforms` array lists all portals where it + * appears (homegate, immoscout24, flatfox, alleimmobilien, …). + * + * URL format accepted by this provider (paste your immoscout24.ch search URL): + * https://www.immoscout24.ch/de/immobilien/mieten/ort-lausanne + * https://www.immoscout24.ch/fr/immobilier/louer/lieu-lausanne + * https://www.immoscout24.ch/de/immobilien/kaufen/ort-zurich + */ + +import { randomBytes } from 'crypto'; +import { buildHash, isOneOf } from '../utils.js'; +import { extractNumber } from '../utils/extract-number.js'; +import logger from '../services/logger.js'; +import { getSettings, upsertSettings } from '../services/storage/settingsStorage.js'; + +/** @import { ParsedListing } from '../types/listing.js' */ +/** @import { ProviderConfig } from '../types/providerConfig.js' */ + +let appliedBlackList = []; + +const API_BASE = 'https://api.immoscout24.ch'; +const APP_VERSION = '6.1.9'; +const APP_BUILD = '6100901'; + +/** + * @param {string} [datadoomeCookie] + * @returns {Record} + */ +function makeHeaders(datadoomeCookie) { + const headers = { + 'user-agent': `immoscout24.ch.nextgen App Android/${APP_VERSION}`, + 'x-app-version': `Immoscout24/${APP_VERSION}(${APP_BUILD})/Android/35`, + 'x-app-id': '', + 'x-app-time': `${Date.now()}${randomBytes(6).toString('hex').slice(0, 11)}`, + 'accept-encoding': 'gzip', + }; + if (datadoomeCookie) headers.cookie = `datadome=${datadoomeCookie}`; + return headers; +} + +/** + * Translate the path segment used in immoscout24.ch search URLs to a geoTag + * accepted by the API (e.g. `ort-lausanne` → `geo-city-lausanne`). + * + * Supported prefixes (all languages): + * ort- / lieu- / luogo- / city- → geo-city-{slug} + * kanton- / canton- / cantone- / canton- → geo-canton-{slug} + * region- / région- / regione- → geo-region-{slug} + * + * @param {string} locationSegment + * @returns {string | null} + */ +function locationToGeoTag(locationSegment) { + if (!locationSegment) return null; + const cityPrefixes = ['ort-', 'lieu-', 'luogo-', 'city-']; + const cantonPrefixes = ['kanton-', 'canton-', 'cantone-']; + const regionPrefixes = ['region-', 'région-', 'regione-']; + + for (const p of cityPrefixes) { + if (locationSegment.startsWith(p)) return `geo-city-${locationSegment.slice(p.length)}`; + } + for (const p of cantonPrefixes) { + if (locationSegment.startsWith(p)) return `geo-canton-${locationSegment.slice(p.length)}`; + } + for (const p of regionPrefixes) { + if (locationSegment.startsWith(p)) return `geo-region-${locationSegment.slice(p.length)}`; + } + // Fall back: assume it is already a geoTag or an unknown format + return locationSegment.startsWith('geo-') ? locationSegment : null; +} + +/** + * Parse the Fredy job URL (an immoscout24.ch search URL) into API parameters. + * + * @param {string} url + * @returns {{ offerType: 'RENT'|'BUY', geoTag: string|null }} + */ +function parseJobUrl(url) { + let pathname; + try { + pathname = new URL(url).pathname; + } catch { + return { offerType: 'RENT', geoTag: null }; + } + // e.g. /de/immobilien/mieten/ort-lausanne or /fr/immobilier/louer/lieu-lausanne + const parts = pathname.split('/').filter(Boolean); + const offerSegment = parts[2] ?? ''; + const locationSegment = parts[3] ?? ''; + + const rentKeywords = ['mieten', 'louer', 'affittare', 'rent']; + const offerType = rentKeywords.includes(offerSegment.toLowerCase()) ? 'RENT' : 'BUY'; + const geoTag = locationToGeoTag(locationSegment); + + return { offerType, geoTag }; +} + +/** + * Fetch listings from the ImmoScout24 CH mobile API. + * + * @param {string} url - The configured immoscout24.ch search URL. + * @returns {Promise} Raw listing objects (as returned by the /listings endpoint). + */ +async function getListings(url) { + const { offerType, geoTag } = parseJobUrl(url); + + if (!geoTag) { + logger.warn( + `[ImmoScout24CH] Could not derive a geoTag from URL: ${url}. ` + + 'URL must contain a location segment like ort-lausanne or lieu-lausanne.', + ); + return []; + } + + logger.debug(`[ImmoScout24CH] Searching: offerType=${offerType}, geoTag=${geoTag}`); + + const settings = await getSettings(); + const datadoomeCookie = typeof settings?.immoscout24ch_datadome === 'string' + ? settings.immoscout24ch_datadome.trim() + : ''; + if (!datadoomeCookie) { + logger.warn('[ImmoScout24CH] No DataDome cookie configured — requests may return 403. ' + + 'Set immoscout24ch_datadome in Settings → Execution.'); + } + const headers = makeHeaders(datadoomeCookie || undefined); + + // Step 1 — search for listing IDs + const searchBody = { + query: { + offerType, + location: { geoTags: [geoTag] }, + propertyType: 'APARTMENT_OR_HOUSE', + }, + sortBy: 'listingType', + sortDirection: 'desc', + from: 0, + size: 20, + trackTotalHits: true, + fieldset: 'srp-list', + }; + + let ids; + try { + let res = await fetch(`${API_BASE}/search/listings`, { + method: 'POST', + headers: { ...headers, 'content-type': 'application/json; charset=utf-8' }, + body: JSON.stringify(searchBody), + }); + + // DataDome returns 403 with a Set-Cookie header containing a fresh datadome token. + // Retrying immediately with that cookie is often enough to pass the challenge without + // human CAPTCHA interaction (the mobile SDK does the same thing automatically). + if (res.status === 403) { + const setCookie = res.headers.get('set-cookie') ?? ''; + const freshCookie = setCookie.match(/datadome=([^;]+)/)?.[1]; + if (freshCookie) { + logger.debug('[ImmoScout24CH] Got 403 — retrying with fresh DataDome cookie from response'); + res = await fetch(`${API_BASE}/search/listings`, { + method: 'POST', + headers: { ...headers, 'content-type': 'application/json; charset=utf-8', cookie: `datadome=${freshCookie}` }, + body: JSON.stringify(searchBody), + }); + if (res.ok) { + upsertSettings({ immoscout24ch_datadome: freshCookie }); + } + } + } + + if (!res.ok) { + if (res.status === 403) { + logger.error( + '[ImmoScout24CH] Search failed: HTTP 403 — DataDome requires human CAPTCHA verification. ' + + 'Open the ImmoScout24 CH Android app via Charles Proxy, solve the CAPTCHA once, ' + + 'and paste the datadome cookie value into Settings → Execution → ImmoScout24 CH DataDome Cookie.', + ); + } else { + logger.error(`[ImmoScout24CH] Search failed: HTTP ${res.status}`); + } + return []; + } + const data = await res.json(); + ids = (data.results ?? []).map((r) => r.id).filter(Boolean); + logger.debug(`[ImmoScout24CH] Got ${ids.length} listing IDs (total available: ${data.total})`); + } catch (err) { + logger.error(`[ImmoScout24CH] Search request failed: ${err.message}`); + return []; + } + + if (ids.length === 0) return []; + + // Step 2 — fetch full listing details + try { + const res = await fetch( + `${API_BASE}/listings/listings?ids=${ids.join('%2C')}&fieldset=srp-list`, + { headers }, + ); + if (!res.ok) { + logger.error(`[ImmoScout24CH] Listings fetch failed: HTTP ${res.status}`); + return []; + } + const data = await res.json(); + const listings = data.listings ?? []; + logger.debug(`[ImmoScout24CH] Fetched details for ${listings.length} listings`); + return listings; + } catch (err) { + logger.error(`[ImmoScout24CH] Listings request failed: ${err.message}`); + return []; + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +/** + * @param {any} o - Raw listing object from the /listings API response. + * @returns {ParsedListing} + */ +function normalize(o) { + const listing = o.listing ?? o; + const offerType = listing.offerType ?? 'RENT'; + const pathPrefix = offerType === 'BUY' ? 'kaufen' : 'mieten'; + const listingId = listing.id ?? o.id; + + const chars = listing.characteristics ?? {}; + const prices = listing.prices ?? {}; + const addr = listing.address ?? {}; + const loc = + listing.localization?.de ?? + listing.localization?.fr ?? + listing.localization?.it ?? + listing.localization?.en ?? + {}; + const text = loc.text ?? {}; + const attachments = loc.attachments ?? []; + + const link = `https://www.immoscout24.ch/${pathPrefix}/${listingId}`; + const id = buildHash(String(listingId)); + const addressStr = [addr.street, addr.postalCode, addr.locality].filter(Boolean).join(', ') || null; + const rentAmount = prices.rent?.gross ?? prices.buy?.price ?? null; + const price = rentAmount != null ? rentAmount : null; + const imgAttachment = attachments.find((a) => a.type === 'IMAGE'); + + return { + id, + link, + title: text.title ?? null, + price, + currency: 'CHF', + size: chars.livingSpace != null ? chars.livingSpace : null, + rooms: chars.numberOfRooms != null ? chars.numberOfRooms : null, + address: addressStr, + image: imgAttachment?.url ?? null, + description: text.description ?? null, + }; +} + +/** + * @param {ParsedListing} o + * @returns {boolean} + */ +function applyBlacklist(o) { + return !isOneOf(o.title, appliedBlackList) && !isOneOf(o.description, appliedBlackList); +} + +/** @type {ProviderConfig} */ +const config = { + requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'description'], + url: null, + sortByDateParam: null, + getListings, + normalize, + filter: applyBlacklist, +}; + +export const init = (sourceConfig, blacklist) => { + config.enabled = sourceConfig.enabled; + config.url = sourceConfig.url; + appliedBlackList = blacklist || []; +}; + +export const metaInformation = { + name: 'ImmoScout24 CH', + baseUrl: 'https://www.immoscout24.ch/', + id: 'immoscout24ch', +}; + +export { config, getListings }; diff --git a/lib/services/extractor/puppeteerExtractor.js b/lib/services/extractor/puppeteerExtractor.js index 65c06f2b..1156ffd7 100644 --- a/lib/services/extractor/puppeteerExtractor.js +++ b/lib/services/extractor/puppeteerExtractor.js @@ -48,6 +48,11 @@ export async function launchBrowser(url, options) { // locks it needs and exits with "Invalid file descriptor to ICU data received". '--no-zygote', preCfg.windowSizeArg, + // On ARM (Raspberry Pi) the real GPU renderer string (e.g. "V3D 4.2" / "VideoCore VI") + // is a highly distinctive fingerprint that DataDome uses to identify ARM scrapers even + // behind a residential proxy. SwiftShader replaces it with a generic software renderer + // that is platform-neutral and not flagged by anti-bot systems. + ...(process.arch === 'arm64' || process.arch === 'arm' ? ['--use-angle=swiftshader'] : []), ]; return await launch({ @@ -133,17 +138,88 @@ export default async function execute(url, waitForSelector, options) { if (waitForSelector != null) { const selectorTimeout = options?.puppeteerSelectorTimeout ?? options?.puppeteerTimeout ?? 30_000; await page.waitForSelector(waitForSelector, { timeout: selectorTimeout }); - pageSource = await page.evaluate((selector) => { - const el = document.querySelector(selector); - return el ? el.innerHTML : ''; - }, waitForSelector); + + if (options?.autoScroll) { + // For pages that use virtual lists (items outside the viewport are unmounted), + // a single HTML snapshot misses cards above/below the current scroll position. + // Instead we collect the outerHTML of every matching item as we scroll through + // the page, then stitch them into a synthetic container so the parser sees all + // items at once. + // + // Flow: + // 1. Wait for the container (waitForSelector already done above). + // 2. Scroll one viewport at a time; after each step wait autoScrollDelay ms + // for the virtual list to mount newly visible cards. + // 3. Accumulate item HTML from every scroll position (dedup by first anchor href). + // 4. Return a wrapper containing all collected items. + const delay = options.autoScrollDelay ?? 800; + const itemSelector = options.autoScrollItemSelector ?? waitForSelector; + // CSS selector used to extract a stable dedup key from each item (first anchor href). + // Falls back to outerHTML when no matching element is found. + const dedupeSelector = options.autoScrollDedupeSelector ?? 'a'; + + pageSource = await page.evaluate( + async (containerSel, itemSel, stepDelay, dedupeSel) => { + const seen = new Set(); + const collected = []; + + const getKey = (el) => { + const anchor = el.querySelector(dedupeSel); + return anchor?.href || anchor?.getAttribute('href') || el.outerHTML; + }; + + const collect = () => { + document.querySelectorAll(itemSel).forEach((el) => { + const key = getKey(el); + if (!seen.has(key)) { + seen.add(key); + collected.push(el.outerHTML); + } + }); + }; + + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + + // Scroll from top to bottom, collecting at each position + window.scrollTo(0, 0); + await sleep(stepDelay); + collect(); + + let lastTop = -1; + while (true) { + window.scrollBy(0, window.innerHeight); + await sleep(stepDelay); + collect(); + const currentTop = window.scrollY; + if (currentTop === lastTop) break; // reached the bottom + lastTop = currentTop; + } + + return `
${collected.join('')}
`; + }, + waitForSelector, + itemSelector, + delay, + dedupeSelector, + ); + } else { + pageSource = await page.evaluate((selector) => { + const el = document.querySelector(selector); + return el ? el.innerHTML : ''; + }, waitForSelector); + } } else { pageSource = await page.content(); } const statusCode = response?.status?.() ?? 200; + // Some sites (e.g. homegate.ch via DataDome) return a 403 for the initial HTTP response + // but still serve real content via a client-side JS challenge that CloakBrowser passes. + // Providers can list such codes in ignoredStatusCodes so the status check is skipped; + // waitForSelector already guards against real bot-detection pages (captcha has no listings). + const effectiveStatusCode = options?.ignoredStatusCodes?.includes(statusCode) ? 200 : statusCode; - if (botDetected(pageSource, statusCode)) { + if (botDetected(pageSource, effectiveStatusCode)) { logger.warn('We have been detected as a bot :-/ Tried url: => ', url); if (options != null && options.name != null) { diff --git a/lib/services/geocoding/client/nominatimClient.js b/lib/services/geocoding/client/nominatimClient.js index d2cd1e05..7d0ea4ea 100644 --- a/lib/services/geocoding/client/nominatimClient.js +++ b/lib/services/geocoding/client/nominatimClient.js @@ -62,7 +62,7 @@ async function doGeocode(address) { return null; } - const url = `${API_URL}?q=${encodeURIComponent(address)}&format=json&countrycodes=de`; + const url = `${API_URL}?q=${encodeURIComponent(address)}&format=json&countrycodes=de,ch,at`; try { const response = await fetch(url, { @@ -112,7 +112,7 @@ async function doAutocomplete(query) { return []; } - const url = `${API_URL}?q=${encodeURIComponent(query)}&format=json&addressdetails=1&limit=5&countrycodes=de`; + const url = `${API_URL}?q=${encodeURIComponent(query)}&format=json&addressdetails=1&limit=5&countrycodes=de,ch,at`; try { const response = await fetch(url, { diff --git a/lib/services/routing/orsClient.js b/lib/services/routing/orsClient.js new file mode 100644 index 00000000..5e8e161a --- /dev/null +++ b/lib/services/routing/orsClient.js @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +/** + * Thin wrapper around the OpenRouteService directions API. + * + * Endpoint: GET https://api.openrouteservice.org/v2/directions/{profile} + * Coordinates are passed as longitude,latitude (GeoJSON order). + * + * @module orsClient + */ + +const BASE_URL = 'https://api.openrouteservice.org/v2/directions'; + +export const PROFILES = /** @type {const} */ ({ + WALKING: 'foot-walking', + CYCLING: 'cycling-regular', + DRIVING: 'driving-car', +}); + +/** + * Fetch directions between two points for a given transport profile. + * + * @param {number} startLng - Start longitude (GeoJSON order: lon first) + * @param {number} startLat - Start latitude + * @param {number} endLng - End longitude + * @param {number} endLat - End latitude + * @param {string} profile - ORS profile: 'foot-walking' | 'cycling-regular' | 'driving-car' + * @param {string} apiKey - OpenRouteService API key + * @returns {Promise<{ duration: number, distance: number }>} duration in seconds, distance in meters + * @throws {Error} On rate limit, API error, or unexpected response shape + */ +export async function getDirections(startLng, startLat, endLng, endLat, profile, apiKey) { + const url = `${BASE_URL}/${profile}?start=${startLng},${startLat}&end=${endLng},${endLat}&api_key=${apiKey}`; + + const response = await fetch(url, { + headers: { Accept: 'application/json, application/geo+json' }, + }); + + if (response.status === 429) { + throw new Error('ORS rate limit exceeded. Please try again later.'); + } + + if (!response.ok) { + throw new Error(`ORS API error: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + const summary = data?.features?.[0]?.properties?.summary; + + if (!summary || summary.duration == null) { + throw new Error('ORS returned an unexpected response shape.'); + } + + return { duration: summary.duration, distance: summary.distance }; +} + +/** + * Fetch directions for all three profiles in parallel. + * + * Uses Promise.allSettled so a failure for one profile does not block the others. + * Failed profiles are returned as null. + * + * @param {number} startLng @param {number} startLat + * @param {number} endLng @param {number} endLat + * @param {string} apiKey + * @returns {Promise>} + */ +export async function getAllDirections(startLng, startLat, endLng, endLat, apiKey) { + const profileList = Object.values(PROFILES); + const results = await Promise.allSettled( + profileList.map((profile) => getDirections(startLng, startLat, endLng, endLat, profile, apiKey)), + ); + return Object.fromEntries( + profileList.map((profile, i) => [profile, results[i].status === 'fulfilled' ? results[i].value : null]), + ); +} diff --git a/lib/services/routing/transitousClient.js b/lib/services/routing/transitousClient.js new file mode 100644 index 00000000..9a7c7296 --- /dev/null +++ b/lib/services/routing/transitousClient.js @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +/** + * Thin wrapper around the Transitous / MOTIS v2 routing API. + * + * Endpoint: GET https://api.transitous.org/api/v1/plan + * No API key required. Coordinates are passed as latitude,longitude (MOTIS order). + * + * Modes returned: + * WALK, BIKE, CAR → resolved from `direct[0]` + * TRANSIT → resolved from `itineraries[0]` (best public-transport option) + * + * @module transitousClient + */ + +const BASE_URL = 'https://api.transitous.org/api/v1/plan'; + +/** User-Agent required by Transitous usage policy (contact info per their ToS). */ +const USER_AGENT = 'fredy-fork/1.0 (forked from https://github.com/orangecoding/fredy; contact@dominikmiskovic.com)'; + +export const MODES = /** @type {const} */ ({ + WALKING: 'WALK', + CYCLING: 'BIKE', + DRIVING: 'CAR', + TRANSIT: 'TRANSIT', +}); + +/** + * Build the query URL for a direct (non-transit) mode. + * @param {number} startLat @param {number} startLng + * @param {number} endLat @param {number} endLng + * @param {string} mode One of WALK | BIKE | CAR + * @returns {string} + */ +function directUrl(startLat, startLng, endLat, endLng, mode) { + return `${BASE_URL}?fromPlace=${startLat},${startLng}&toPlace=${endLat},${endLng}&numItineraries=1&directModes=${mode}`; +} + +/** + * Build the query URL for public-transport routing. + * @param {number} startLat @param {number} startLng + * @param {number} endLat @param {number} endLng + * @returns {string} + */ +function transitUrl(startLat, startLng, endLat, endLng) { + return `${BASE_URL}?fromPlace=${startLat},${startLng}&toPlace=${endLat},${endLng}&numItineraries=1&transportModes=TRANSIT,WALK`; +} + +/** + * @param {string} url + * @returns {Promise} + */ +async function apiFetch(url) { + return fetch(url, { headers: { Accept: 'application/json', 'User-Agent': USER_AGENT } }); +} + +/** + * Fetch directions for a direct transport mode (WALK, BIKE, CAR). + * + * @param {number} startLat + * @param {number} startLng + * @param {number} endLat + * @param {number} endLng + * @param {string} mode - WALK | BIKE | CAR + * @returns {Promise<{ duration: number, distance: number }>} duration in seconds, distance in meters + * @throws {Error} On rate limit, API error, or unexpected response shape + */ +export async function getDirectRoute(startLat, startLng, endLat, endLng, mode) { + const response = await apiFetch(directUrl(startLat, startLng, endLat, endLng, mode)); + + if (response.status === 429) throw new Error('Transitous rate limit exceeded. Please try again later.'); + if (!response.ok) throw new Error(`Transitous API error: ${response.status} ${response.statusText}`); + + const data = await response.json(); + const itinerary = data?.direct?.[0]; + + if (!itinerary || itinerary.duration == null) throw new Error('Transitous returned an unexpected response shape.'); + + const leg = itinerary.legs?.[0]; + return { duration: itinerary.duration, distance: leg?.distance ?? 0 }; +} + +/** + * Fetch the best public-transport itinerary. + * + * @param {number} startLat + * @param {number} startLng + * @param {number} endLat + * @param {number} endLng + * @returns {Promise<{ duration: number, transfers: number }>} duration in seconds, number of transfers + * @throws {Error} On rate limit, API error, unexpected response shape, or no transit available + */ +export async function getTransitRoute(startLat, startLng, endLat, endLng) { + const response = await apiFetch(transitUrl(startLat, startLng, endLat, endLng)); + + if (response.status === 429) throw new Error('Transitous rate limit exceeded. Please try again later.'); + if (!response.ok) throw new Error(`Transitous API error: ${response.status} ${response.statusText}`); + + const data = await response.json(); + const itinerary = data?.itineraries?.[0]; + + if (!itinerary || itinerary.duration == null) throw new Error('No transit connection found.'); + + return { duration: itinerary.duration, transfers: itinerary.transfers ?? 0 }; +} + +const WALK_SPEED_MS = 5000 / 3600; // 5 km/h in metres per second + +/** + * Estimate walking time from straight-line (haversine) distance. + * Used as fallback when Transitous does not return a WALK route (distance too large). + * + * @param {number} lat1 @param {number} lng1 + * @param {number} lat2 @param {number} lng2 + * @returns {{ duration: number, distance: number, estimated: true }} + */ +function walkingEstimate(lat1, lng1, lat2, lng2) { + const R = 6_371_000; // Earth radius in metres + const dLat = ((lat2 - lat1) * Math.PI) / 180; + const dLng = ((lng2 - lng1) * Math.PI) / 180; + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLng / 2) ** 2; + const distance = R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return { duration: Math.round(distance / WALK_SPEED_MS), distance: Math.round(distance), estimated: true }; +} + +/** + * Fetch all four transport modes in parallel. + * + * Uses Promise.allSettled so a failure for one mode does not block the others. + * Failed modes are returned as null. + * + * @param {number} startLat + * @param {number} startLng + * @param {number} endLat + * @param {number} endLng + * @returns {Promise>} + */ +export async function getAllRoutes(startLat, startLng, endLat, endLng) { + const [walking, cycling, driving, transit] = await Promise.allSettled([ + getDirectRoute(startLat, startLng, endLat, endLng, MODES.WALKING), + getDirectRoute(startLat, startLng, endLat, endLng, MODES.CYCLING), + getDirectRoute(startLat, startLng, endLat, endLng, MODES.DRIVING), + getTransitRoute(startLat, startLng, endLat, endLng), + ]); + + return { + [MODES.WALKING]: + walking.status === 'fulfilled' ? walking.value : walkingEstimate(startLat, startLng, endLat, endLng), + [MODES.CYCLING]: cycling.status === 'fulfilled' ? cycling.value : null, + [MODES.DRIVING]: driving.status === 'fulfilled' ? driving.value : null, + [MODES.TRANSIT]: transit.status === 'fulfilled' ? transit.value : null, + }; +} diff --git a/lib/services/similarity-check/similarityCache.js b/lib/services/similarity-check/similarityCache.js index a3de3740..076d50df 100644 --- a/lib/services/similarity-check/similarityCache.js +++ b/lib/services/similarity-check/similarityCache.js @@ -22,6 +22,7 @@ */ import crypto from 'crypto'; import { getAllEntriesFromListings } from '../storage/listingsStorage.js'; +import logger from '../logger.js'; /** @type {number} Refresh interval in milliseconds (defaults to one hour). */ const reloadCycle = 60 * 60 * 1000; // every hour, refresh @@ -51,14 +52,22 @@ export const startSimilarityCacheReloader = () => { * This function is idempotent and safe to call at any time. * @returns {void} */ +/** @type {Map} hash → source info for debugging */ +let cacheSource = new Map(); + export const initSimilarityCache = () => { const allEntries = getAllEntriesFromListings(); const newCache = new Set(); + const newSource = new Map(); for (const entry of allEntries) { - newCache.add(toHash(entry?.title, entry?.price, entry?.address)); + const hash = toHash(entry?.title, entry?.price, entry?.address); + newCache.add(hash); + newSource.set(hash, { provider: entry.provider, jobId: entry.job_id, title: entry.title }); } // Atomic swap to avoid mutating the cache while it may be iterated elsewhere cache = newCache; + cacheSource = newSource; + logger.debug(`Similarity cache rebuilt: ${cache.size} entries from ${allEntries.length} listings`); }; /** @@ -72,15 +81,16 @@ export const initSimilarityCache = () => { * @param {string|undefined|null} params.title - The listing title * @param {string|undefined|null} params.address - The listing address * @param {number|string|undefined|null} params.price - The listing price - * @returns {boolean} true if the entry already existed in the cache (duplicate), otherwise false + * @returns {{ duplicate: boolean, source?: { provider: string, jobId: string, title: string } }} */ export const checkAndAddEntry = ({ title, address, price }) => { const hash = toHash(title, price, address); if (cache.has(hash)) { - return true; + return { duplicate: true, source: cacheSource.get(hash) }; } cache.add(hash); - return false; + cacheSource.set(hash, { provider: 'current-run', jobId: '', title }); + return { duplicate: false }; }; /** diff --git a/lib/services/storage/listingsStorage.js b/lib/services/storage/listingsStorage.js index f00c7f7c..2ccabef8 100755 --- a/lib/services/storage/listingsStorage.js +++ b/lib/services/storage/listingsStorage.js @@ -202,9 +202,9 @@ export const storeListings = (jobId, providerId, listings) => { SqliteConnection.withTransaction((db) => { const stmt = db.prepare( `INSERT INTO listings (id, hash, provider, job_id, price, size, rooms, title, image_url, description, address, - link, created_at, is_active, latitude, longitude) + link, created_at, is_active, latitude, longitude, currency) VALUES (@id, @hash, @provider, @job_id, @price, @size, @rooms, @title, @image_url, @description, @address, @link, - @created_at, 1, @latitude, @longitude) + @created_at, 1, @latitude, @longitude, @currency) ON CONFLICT(job_id, hash) DO NOTHING`, ); @@ -225,6 +225,7 @@ export const storeListings = (jobId, providerId, listings) => { created_at: Date.now(), latitude: item.latitude || null, longitude: item.longitude || null, + currency: item.currency ?? null, }; stmt.run(params); // Propagate the DB primary key back so downstream pipeline steps use the correct id @@ -569,7 +570,9 @@ export const getListingsForMap = ({ jobId, userId = null, isAdmin = false } = {} * @returns {{title: string|null, address: string|null, price: number|null}[]} */ export const getAllEntriesFromListings = () => { - return SqliteConnection.query(`SELECT title, address, price FROM listings WHERE manually_deleted = 0`); + return SqliteConnection.query( + `SELECT title, address, price, provider, job_id FROM listings WHERE manually_deleted = 0`, + ); }; /** @@ -742,3 +745,21 @@ export const resetGeocoordinatesAndDistanceForUser = (userId) => { { userId }, ); }; + +/** + * Persist a translated description for a listing. + * + * Reads the existing `translations` JSON object for the listing, merges the new + * language entry, and writes it back. Safe to call multiple times — subsequent + * calls for the same language overwrite the previous value. + * + * @param {string} id - The listing ID (nanoid). + * @param {string} language - Language code in lowercase, e.g. 'en' or 'de'. + * @param {string} text - The translated description text. + */ +export const setListingTranslation = (id, language, text) => { + const rows = SqliteConnection.query(`SELECT translations FROM listings WHERE id = ?`, [id]); + const existing = rows[0]?.translations ? JSON.parse(rows[0].translations) : {}; + existing[language.toLowerCase()] = text; + SqliteConnection.execute(`UPDATE listings SET translations = ? WHERE id = ?`, [JSON.stringify(existing), id]); +}; diff --git a/lib/services/storage/migrations/sql/10.add-currency-to-listings.js b/lib/services/storage/migrations/sql/10.add-currency-to-listings.js new file mode 100644 index 00000000..6d2b6bce --- /dev/null +++ b/lib/services/storage/migrations/sql/10.add-currency-to-listings.js @@ -0,0 +1,8 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +export function up(db) { + db.exec(`ALTER TABLE listings ADD COLUMN currency TEXT;`); +} diff --git a/lib/services/storage/migrations/sql/22.add-translations-to-listings.js b/lib/services/storage/migrations/sql/22.add-translations-to-listings.js new file mode 100644 index 00000000..21cbfd87 --- /dev/null +++ b/lib/services/storage/migrations/sql/22.add-translations-to-listings.js @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +/** + * Adds a `translations` JSON column to the listings table. + * Stores translated descriptions keyed by language code, e.g. { "en": "...", "de": "..." }. + * @param {import('better-sqlite3').Database} db + */ +export function up(db) { + db.exec(`ALTER TABLE listings ADD COLUMN translations TEXT;`); +} diff --git a/lib/services/translation/deeplClient.js b/lib/services/translation/deeplClient.js new file mode 100644 index 00000000..b46c14b8 --- /dev/null +++ b/lib/services/translation/deeplClient.js @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +/** + * Thin wrapper around the DeepL translate API. + * + * Supports both the free tier (keys ending in `:fx`, uses api-free.deepl.com) + * and the pro tier (all other keys, uses api.deepl.com). + * + * @module deeplClient + */ + +/** + * Returns the correct DeepL API base URL based on the API key tier. + * Free-tier keys end with the suffix `:fx`. + * @param {string} apiKey + * @returns {string} + */ +function getApiUrl(apiKey) { + return apiKey.endsWith(':fx') ? 'https://api-free.deepl.com/v2/translate' : 'https://api.deepl.com/v2/translate'; +} + +/** + * Translate a single text string using the DeepL API. + * Source language is omitted so DeepL auto-detects it. + * + * @param {string} text - The text to translate. + * @param {string} targetLang - Target language code in uppercase, e.g. 'EN' or 'DE'. + * @param {string} apiKey - DeepL API authentication key. + * @returns {Promise} The translated text. + * @throws {Error} If the API returns a non-OK status or an unexpected response shape. + */ +export async function translate(text, targetLang, apiKey) { + const url = getApiUrl(apiKey); + + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `DeepL-Auth-Key ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + text: [text], + target_lang: targetLang, + }), + }); + + if (response.status === 429) { + throw new Error('DeepL rate limit exceeded. Please try again later.'); + } + + if (!response.ok) { + throw new Error(`DeepL API error: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + + if (!data?.translations?.[0]?.text) { + throw new Error('DeepL returned an unexpected response shape.'); + } + + return data.translations[0].text; +} diff --git a/lib/types/listing.js b/lib/types/listing.js index a0c9b137..34b8c676 100644 --- a/lib/types/listing.js +++ b/lib/types/listing.js @@ -12,6 +12,7 @@ * @property {string} [description] Description of the listing. * @property {string} [address] Optional address/location text. * @property {number} [price] Optional price of the listing. + * @property {string} [currency] Optional currency symbol/code for the price (defaults to '€'). * @property {number} [size] Optional size of the listing. * @property {number} [rooms] Optional number of rooms. * @property {number} [latitude] Optional latitude. diff --git a/lib/utils/formatListing.js b/lib/utils/formatListing.js index f663f018..9e8e1bce 100644 --- a/lib/utils/formatListing.js +++ b/lib/utils/formatListing.js @@ -22,7 +22,7 @@ export const formatListing = (listing) => { return { ...listing, - price: listing.price != null ? `${listing.price} €` : null, + price: listing.price != null ? `${listing.price} ${listing.currency ?? '€'}` : null, size: listing.size != null ? `${listing.size} m²` : null, rooms: listing.rooms != null ? `${listing.rooms} Zimmer` : null, }; diff --git a/test/offlineFixtures.js b/test/offlineFixtures.js index afe7c4ca..81f61f4f 100644 --- a/test/offlineFixtures.js +++ b/test/offlineFixtures.js @@ -81,13 +81,16 @@ export async function readFixture(url) { } /** - * Returns a fetch replacement that intercepts immoscout mobile API calls and - * serves pre-downloaded JSON fixtures. Throws for any other URL to prevent + * Returns a fetch replacement that intercepts mobile API calls and serves + * pre-downloaded JSON fixtures. Throws for any other URL to prevent * accidental live network traffic in offline mode. */ export function buildFetchMock() { let listData = null; let detailData = null; + let homegateApiData = null; + let immoscout24chSearchData = null; + let immoscout24chListingsData = null; return async (url) => { const urlStr = String(url); @@ -111,6 +114,42 @@ export function buildFetchMock() { return { ok: true, status: 200, json: () => Promise.resolve(detailData) }; } + if (urlStr.includes('api.homegate.ch/search/listings')) { + if (!homegateApiData) { + const raw = await tryReadFile(path.join(FIXTURES_DIR, 'homegate_api.json')); + homegateApiData = raw ? JSON.parse(raw) : { results: [], total: 0 }; + } + return { ok: true, status: 200, json: () => Promise.resolve(homegateApiData) }; + } + + if (urlStr.includes('api.immoscout24.ch/search/listings')) { + if (!immoscout24chSearchData) { + const raw = await tryReadFile(path.join(FIXTURES_DIR, 'immoscout24ch_search.json')); + immoscout24chSearchData = raw ? JSON.parse(raw) : { results: [], total: 0 }; + } + return { ok: true, status: 200, json: () => Promise.resolve(immoscout24chSearchData) }; + } + + if (urlStr.includes('api.immoscout24.ch/listings/listings')) { + if (!immoscout24chListingsData) { + const raw = await tryReadFile(path.join(FIXTURES_DIR, 'immoscout24ch_listings.json')); + immoscout24chListingsData = raw ? JSON.parse(raw) : { listings: [] }; + } + return { ok: true, status: 200, json: () => Promise.resolve(immoscout24chListingsData) }; + } + + // Nominatim geocoding — return a stub for Lausanne so Homegate API tests work offline. + if (urlStr.includes('nominatim.openstreetmap.org')) { + const stubResult = [{ lat: '46.5196535', lon: '6.6322734', display_name: 'Lausanne, Switzerland' }]; + return { ok: true, status: 200, json: () => Promise.resolve(stubResult) }; + } + + // Homegate search page HTML — used by the __INITIAL_STATE__ extraction approach. + if (urlStr.includes('homegate.ch')) { + const html = await tryReadFile(path.join(FIXTURES_DIR, 'homegate.html')); + return { ok: true, status: 200, text: () => Promise.resolve(html ?? '') }; + } + throw new Error(`Network request blocked in offline mode: ${urlStr}`); }; } diff --git a/test/pipeline_filtering.test.js b/test/pipeline_filtering.test.js index 6b0eeb34..6e015863 100644 --- a/test/pipeline_filtering.test.js +++ b/test/pipeline_filtering.test.js @@ -9,11 +9,11 @@ import * as mockStore from './mocks/mockStore.js'; import { get as getLastNotification } from './mocks/mockNotification.js'; describe('Issue reproduction: listings filtered by similarity or area should be marked as manually deleted', () => { - it('should call deleteListingsById when listings are filtered by similarity', async () => { + it('should soft-delete listings filtered by similarity (hidden from overview, kept for _findNew dedup)', async () => { const Fredy = await mockFredy(); const mockSimilarityCache = { - checkAndAddEntry: () => true, // always similar + checkAndAddEntry: () => ({ duplicate: true }), // always similar }; const providerConfig = { @@ -44,6 +44,8 @@ describe('Issue reproduction: listings filtered by similarity or area should be // Might throw NoNewListingsWarning if all are filtered out } + // Similarity-filtered listings are soft-deleted: hidden from overview but hashes + // remain in DB so _findNew skips them on the next run without re-processing. expect(mockStore.deletedIds).toContain('1'); }); @@ -51,7 +53,7 @@ describe('Issue reproduction: listings filtered by similarity or area should be const Fredy = await mockFredy(); const mockSimilarityCache = { - checkAndAddEntry: () => false, // never similar + checkAndAddEntry: () => ({ duplicate: false }), // never similar }; const spatialFilter = { diff --git a/test/provider/homegate.test.js b/test/provider/homegate.test.js new file mode 100644 index 00000000..fac83433 --- /dev/null +++ b/test/provider/homegate.test.js @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; +import { get } from '../mocks/mockNotification.js'; +import { mockFredy, providerConfig } from '../utils.js'; +import { expect } from 'vitest'; +import * as provider from '../../lib/provider/homegate.js'; + +describe('#homegate testsuite()', () => { + it('should test homegate provider', async () => { + const Fredy = await mockFredy(); + const mockedJob = { + id: 'homegate', + notificationAdapter: null, + spatialFilter: null, + specFilter: null, + }; + provider.init(providerConfig.homegate, []); + + const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined); + + const listing = await fredy.execute(); + + if (listing == null || listing.length === 0) { + throw new Error('Listings is empty!'); + } + + expect(listing).toBeInstanceOf(Array); + const notificationObj = get(); + expect(notificationObj).toBeTypeOf('object'); + expect(notificationObj.serviceName).toBe('homegate'); + notificationObj.payload.forEach((notify) => { + /** check the actual structure **/ + expect(notify.id).toBeTypeOf('string'); + expect(notify.title).toBeTypeOf('string'); + expect(notify.link).toBeTypeOf('string'); + expect(notify.link).toContain('https://www.homegate.ch'); + expect(notify.address).toBeTypeOf('string'); + expect(notify.address).not.toBe(''); + /** check the values if possible **/ + if (notify.price != null) { + expect(notify.price).toBeTypeOf('string'); + expect(notify.price).toContain('CHF'); + } + if (notify.size != null) { + expect(notify.size).toBeTypeOf('string'); + expect(notify.size).toContain('m²'); + } + if (notify.image != null) { + expect(notify.image).toBeTypeOf('string'); + expect(notify.image).toContain('homegate.ch'); + } + }); + }); +}); diff --git a/test/provider/immoscout24ch.test.js b/test/provider/immoscout24ch.test.js new file mode 100644 index 00000000..fb9c4942 --- /dev/null +++ b/test/provider/immoscout24ch.test.js @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; +import { get } from '../mocks/mockNotification.js'; +import { mockFredy, providerConfig } from '../utils.js'; +import { expect } from 'vitest'; +import * as provider from '../../lib/provider/immoscout24ch.js'; + +describe('#immoscout24ch testsuite()', () => { + it('should test immoscout24ch provider', async () => { + const Fredy = await mockFredy(); + const mockedJob = { + id: 'immoscout24ch', + notificationAdapter: null, + spatialFilter: null, + specFilter: null, + }; + provider.init(providerConfig.immoscout24ch, []); + + const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined); + + const listing = await fredy.execute(); + + if (listing == null || listing.length === 0) { + throw new Error('Listings is empty!'); + } + + expect(listing).toBeInstanceOf(Array); + const notificationObj = get(); + expect(notificationObj).toBeTypeOf('object'); + expect(notificationObj.serviceName).toBe('immoscout24ch'); + notificationObj.payload.forEach((notify) => { + expect(notify.id).toBeTypeOf('string'); + expect(notify.title).toBeTypeOf('string'); + expect(notify.link).toBeTypeOf('string'); + expect(notify.link).toContain('https://www.immoscout24.ch'); + expect(notify.address).toBeTypeOf('string'); + expect(notify.address).not.toBe(''); + if (notify.price != null) { + expect(notify.price).toBeTypeOf('string'); + expect(notify.price).toContain('CHF'); + } + if (notify.size != null) { + expect(notify.size).toBeTypeOf('string'); + expect(notify.size).toContain('m²'); + } + if (notify.image != null) { + expect(notify.image).toBeTypeOf('string'); + expect(notify.image).toContain('immoscout24.ch'); + } + }); + }); +}); diff --git a/test/provider/testProvider.json b/test/provider/testProvider.json index 5ebcaebe..010ce3ce 100644 --- a/test/provider/testProvider.json +++ b/test/provider/testProvider.json @@ -1,55 +1,63 @@ { - "einsAImmobilien": { - "url": "https://www.1a-immobilienmarkt.de/suchen/duesseldorf/wohnung-kaufen.html?search=yes&cfid=98b39c7e-b403-4764-8f3c-57bf590923d0&data_hash=f46f89548257740094dd708996adcd68&sort_type=newest", - "enabled": true, - "id": "einsAImmobilien" - }, - "immobilienDe": { - "url": "https://www.immobilien.de/Wohnen/Suchergebnisse-51797.html?search._digest=true&search._filter=wohnen&search.flaeche_von=50&search.objektart=wohnung&search.preis_bis=1200&search.typ=mieten&search.umkreis=15&search.wo=district%3A2434%2C2695%2C2621%2C2700%2C2967%2C2734%2C2909%2C2955%2C2392%2C2746%2C2767%2C2982%2C2904%2C2612%2C2892%2C2587%2C2871%2C2975%2C2591%2C2887%2C2569%2C2640%2C2735&sort_col=*created_ts&sort_dir=desc", - "enabled": true - }, - "immowelt": { - "url": "https://www.immowelt.de/classified-search?distributionTypes=Buy,Buy_Auction,Compulsory_Auction&estateTypes=House,Apartment&locations=AD08DE2350", - "enabled": true - }, - "immoscout": { - "url": "https://www.immobilienscout24.de/Suche/de/nordrhein-westfalen/duesseldorf/wohnung-mieten?enteredFrom=one_step_search", - "enabled": true - }, - "immoswp": { - "url": "https://immo.swp.de/suchergebnisse?l=M%C3%BCnchen&r=0km&_multiselect_r=0km&ut=private&t=apartment%3Arental&a=de.muenchen&pf=&pt=&rf=0&rt=0&sf=50&st=&yf=&yt=&ff=&ft=&s=most_recently_updated_first&pa=&o=&ad=&u=", - "enabled": true - }, - "kleinanzeigen": { - "url": "https://www.kleinanzeigen.de/s-immobilien/duesseldorf/anzeige:angebote/wohnung/k0c195l2068r5", - "enabled": true - }, - "mcMakler": { - "url": "https://www.mcmakler.de/immobilien/results?placeId=62649&search=Leipzig%252C+Sachsen&propertyTypes=APARTMENT&page=0", - "enabled": true - }, - "ohneMakler": { - "url": "https://www.ohne-makler.net/immobilien/wohnung-kaufen/nordrhein-westfalen/dusseldorf/", - "enabled": true - }, - "neubauKompass": { - "url": "https://www.neubaukompass.de/neubau-immobilien/duesseldorf-region/eigentumswohnung/", - "enabled": true - }, - "regionalimmobilien24": { - "url": "https://www.regionalimmobilien24.de/rostock/rostock/kaufen/haus/-/-/-/?rd=5", - "enabled": true - }, - "sparkasse": { - "url": "https://immobilien.sparkasse.de/immobilien/treffer?estateTypeGroupingId=403&marketingType=buy&perimeter=10&usageType=residential&zipCityEstateId=51.22422%2F6.78006%2F0__D%C3%BCsseldorf", - "enabled": true - }, - "wgGesucht": { - "url": "https://www.wg-gesucht.de/wg-zimmer-in-Duesseldorf.30.0.1.0.html", - "enabled": true - }, - "wohnungsboerse": { - "url": "https://www.wohnungsboerse.net/searches/index?estate_marketing_types=kauf%2C1&marketing_type=kauf&estate_types%5B0%5D=1&is_rendite=0&estate_id=&zipcodes%5B%5D=&cities%5B%5D=Duesseldorf&districts%5B%5D=&term=D%C3%BCsseldorf&umkreiskm=&pricetext=&minprice=&maxprice=&sizetext=&minsize=&maxsize=&roomstext=&minrooms=&maxrooms=", - "IsActive": true - } + "einsAImmobilien": { + "url": "https://www.1a-immobilienmarkt.de/suchen/duesseldorf/wohnung-kaufen.html?search=yes&cfid=98b39c7e-b403-4764-8f3c-57bf590923d0&data_hash=f46f89548257740094dd708996adcd68&sort_type=newest", + "enabled": true, + "id": "einsAImmobilien" + }, + "immobilienDe": { + "url": "https://www.immobilien.de/Wohnen/Suchergebnisse-51797.html?search._digest=true&search._filter=wohnen&search.flaeche_von=50&search.objektart=wohnung&search.preis_bis=1200&search.typ=mieten&search.umkreis=15&search.wo=district%3A2434%2C2695%2C2621%2C2700%2C2967%2C2734%2C2909%2C2955%2C2392%2C2746%2C2767%2C2982%2C2904%2C2612%2C2892%2C2587%2C2871%2C2975%2C2591%2C2887%2C2569%2C2640%2C2735&sort_col=*created_ts&sort_dir=desc", + "enabled": true + }, + "homegate": { + "url": "https://www.homegate.ch/rent/apartment/city-lausanne/matching-list", + "enabled": true + }, + "immowelt": { + "url": "https://www.immowelt.de/classified-search?distributionTypes=Buy,Buy_Auction,Compulsory_Auction&estateTypes=House,Apartment&locations=AD08DE2350", + "enabled": true + }, + "immoscout": { + "url": "https://www.immobilienscout24.de/Suche/de/nordrhein-westfalen/duesseldorf/wohnung-mieten?enteredFrom=one_step_search", + "enabled": true + }, + "immoswp": { + "url": "https://immo.swp.de/suchergebnisse?l=M%C3%BCnchen&r=0km&_multiselect_r=0km&ut=private&t=apartment%3Arental&a=de.muenchen&pf=&pt=&rf=0&rt=0&sf=50&st=&yf=&yt=&ff=&ft=&s=most_recently_updated_first&pa=&o=&ad=&u=", + "enabled": true + }, + "kleinanzeigen": { + "url": "https://www.kleinanzeigen.de/s-immobilien/duesseldorf/anzeige:angebote/wohnung/k0c195l2068r5", + "enabled": true + }, + "mcMakler": { + "url": "https://www.mcmakler.de/immobilien/results?placeId=62649&search=Leipzig%252C+Sachsen&propertyTypes=APARTMENT&page=0", + "enabled": true + }, + "ohneMakler": { + "url": "https://www.ohne-makler.net/immobilien/wohnung-kaufen/nordrhein-westfalen/dusseldorf/", + "enabled": true + }, + "neubauKompass": { + "url": "https://www.neubaukompass.de/neubau-immobilien/duesseldorf-region/eigentumswohnung/", + "enabled": true + }, + "regionalimmobilien24": { + "url": "https://www.regionalimmobilien24.de/rostock/rostock/kaufen/haus/-/-/-/?rd=5", + "enabled": true + }, + "sparkasse": { + "url": "https://immobilien.sparkasse.de/immobilien/treffer?estateTypeGroupingId=403&marketingType=buy&perimeter=10&usageType=residential&zipCityEstateId=51.22422%2F6.78006%2F0__D%C3%BCsseldorf", + "enabled": true + }, + "wgGesucht": { + "url": "https://www.wg-gesucht.de/wg-zimmer-in-Duesseldorf.30.0.1.0.html", + "enabled": true + }, + "wohnungsboerse": { + "url": "https://www.wohnungsboerse.net/searches/index?estate_marketing_types=kauf%2C1&marketing_type=kauf&estate_types%5B0%5D=1&is_rendite=0&estate_id=&zipcodes%5B%5D=&cities%5B%5D=Duesseldorf&districts%5B%5D=&term=D%C3%BCsseldorf&umkreiskm=&pricetext=&minprice=&maxprice=&sizetext=&minsize=&maxsize=&roomstext=&minrooms=&maxrooms=", + "IsActive": true + }, + "immoscout24ch": { + "url": "https://www.immoscout24.ch/de/immobilien/mieten/ort-lausanne", + "enabled": true + } } diff --git a/test/routing/orsClient.test.js b/test/routing/orsClient.test.js new file mode 100644 index 00000000..d7140a4b --- /dev/null +++ b/test/routing/orsClient.test.js @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { getDirections, getAllDirections, PROFILES } from '../../lib/services/routing/orsClient.js'; + +const API_KEY = 'test-ors-key'; + +const mockFetch = vi.fn(); + +beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + mockFetch.mockReset(); +}); + +function makeResponse(body, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : status === 429 ? 'Too Many Requests' : 'Error', + json: async () => body, + }; +} + +const successBody = { + features: [{ properties: { summary: { duration: 754.3, distance: 1823.6 } } }], +}; + +describe('#orsClient getDirections()', () => { + it('returns duration and distance on successful response', async () => { + mockFetch.mockResolvedValueOnce(makeResponse(successBody)); + + const result = await getDirections(6.5668, 46.5191, 6.5622, 46.5198, PROFILES.WALKING, API_KEY); + + expect(result.duration).toBe(754.3); + expect(result.distance).toBe(1823.6); + }); + + it('includes the profile in the request URL', async () => { + mockFetch.mockResolvedValueOnce(makeResponse(successBody)); + + await getDirections(6.5668, 46.5191, 6.5622, 46.5198, PROFILES.CYCLING, API_KEY); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain('cycling-regular'); + }); + + it('uses longitude,latitude order (GeoJSON) in URL', async () => { + mockFetch.mockResolvedValueOnce(makeResponse(successBody)); + + const startLng = 6.5668; + const startLat = 46.5191; + const endLng = 6.5622; + const endLat = 46.5198; + + await getDirections(startLng, startLat, endLng, endLat, PROFILES.DRIVING, API_KEY); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain(`start=${startLng},${startLat}`); + expect(url).toContain(`end=${endLng},${endLat}`); + }); + + it('includes the api_key in the request URL', async () => { + mockFetch.mockResolvedValueOnce(makeResponse(successBody)); + + await getDirections(6.5668, 46.5191, 6.5622, 46.5198, PROFILES.WALKING, API_KEY); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain(`api_key=${API_KEY}`); + }); + + it('throws with a rate-limit message on 429', async () => { + mockFetch.mockResolvedValueOnce(makeResponse({}, 429)); + + await expect(getDirections(0, 0, 1, 1, PROFILES.WALKING, API_KEY)).rejects.toThrow('rate limit'); + }); + + it('throws on non-OK responses', async () => { + mockFetch.mockResolvedValueOnce(makeResponse({}, 403)); + + await expect(getDirections(0, 0, 1, 1, PROFILES.WALKING, API_KEY)).rejects.toThrow('ORS API error: 403'); + }); + + it('throws when response shape is unexpected', async () => { + mockFetch.mockResolvedValueOnce(makeResponse({ unexpected: true })); + + await expect(getDirections(0, 0, 1, 1, PROFILES.WALKING, API_KEY)).rejects.toThrow('unexpected response shape'); + }); +}); + +describe('#orsClient getAllDirections()', () => { + it('returns results for all three profiles', async () => { + mockFetch + .mockResolvedValueOnce(makeResponse(successBody)) + .mockResolvedValueOnce(makeResponse(successBody)) + .mockResolvedValueOnce(makeResponse(successBody)); + + const result = await getAllDirections(6.5668, 46.5191, 6.5622, 46.5198, API_KEY); + + expect(result[PROFILES.WALKING]).toMatchObject({ duration: 754.3, distance: 1823.6 }); + expect(result[PROFILES.CYCLING]).toMatchObject({ duration: 754.3, distance: 1823.6 }); + expect(result[PROFILES.DRIVING]).toMatchObject({ duration: 754.3, distance: 1823.6 }); + }); + + it('returns null for a profile that fails, keeps others', async () => { + mockFetch + .mockResolvedValueOnce(makeResponse(successBody)) + .mockResolvedValueOnce(makeResponse({}, 500)) + .mockResolvedValueOnce(makeResponse(successBody)); + + const result = await getAllDirections(0, 0, 1, 1, API_KEY); + + expect(result[PROFILES.WALKING]).not.toBeNull(); + expect(result[PROFILES.CYCLING]).toBeNull(); + expect(result[PROFILES.DRIVING]).not.toBeNull(); + }); +}); diff --git a/test/routing/transitousClient.test.js b/test/routing/transitousClient.test.js new file mode 100644 index 00000000..14e6ae94 --- /dev/null +++ b/test/routing/transitousClient.test.js @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { getDirectRoute, getTransitRoute, getAllRoutes, MODES } from '../../lib/services/routing/transitousClient.js'; + +const mockFetch = vi.fn(); + +beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + mockFetch.mockReset(); +}); + +function makeResponse(body, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : status === 429 ? 'Too Many Requests' : 'Error', + json: async () => body, + }; +} + +const directBody = (mode, duration, distance) => ({ + direct: [ + { + duration, + legs: [{ mode, duration, distance }], + }, + ], + itineraries: [], +}); + +const transitBody = (duration, transfers) => ({ + direct: [], + itineraries: [{ duration, transfers, legs: [] }], +}); + +describe('#transitousClient getDirectRoute()', () => { + it('returns duration and distance for WALK', async () => { + mockFetch.mockResolvedValueOnce(makeResponse(directBody('WALK', 600, 450))); + + const result = await getDirectRoute(47.376, 8.541, 47.369, 8.539, MODES.WALKING); + + expect(result.duration).toBe(600); + expect(result.distance).toBe(450); + }); + + it('returns duration and distance for BIKE', async () => { + mockFetch.mockResolvedValueOnce(makeResponse(directBody('BIKE', 360, 1350))); + + const result = await getDirectRoute(47.376, 8.541, 47.369, 8.539, MODES.CYCLING); + + expect(result.duration).toBe(360); + expect(result.distance).toBe(1350); + }); + + it('returns duration and distance for CAR', async () => { + mockFetch.mockResolvedValueOnce(makeResponse(directBody('CAR', 665, 1720))); + + const result = await getDirectRoute(47.376, 8.541, 47.369, 8.539, MODES.DRIVING); + + expect(result.duration).toBe(665); + expect(result.distance).toBe(1720); + }); + + it('uses latitude,longitude order in URL (MOTIS order)', async () => { + mockFetch.mockResolvedValueOnce(makeResponse(directBody('WALK', 600, 450))); + + const startLat = 47.376; + const startLng = 8.541; + await getDirectRoute(startLat, startLng, 47.369, 8.539, MODES.WALKING); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain(`fromPlace=${startLat},${startLng}`); + }); + + it('passes directModes in URL', async () => { + mockFetch.mockResolvedValueOnce(makeResponse(directBody('BIKE', 360, 1350))); + + await getDirectRoute(47.376, 8.541, 47.369, 8.539, MODES.CYCLING); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain('directModes=BIKE'); + }); + + it('throws with rate-limit message on 429', async () => { + mockFetch.mockResolvedValueOnce(makeResponse({}, 429)); + + await expect(getDirectRoute(47.376, 8.541, 47.369, 8.539, MODES.WALKING)).rejects.toThrow('rate limit'); + }); + + it('throws on non-OK responses', async () => { + mockFetch.mockResolvedValueOnce(makeResponse({}, 500)); + + await expect(getDirectRoute(47.376, 8.541, 47.369, 8.539, MODES.WALKING)).rejects.toThrow( + 'Transitous API error: 500', + ); + }); + + it('throws when direct array is empty', async () => { + mockFetch.mockResolvedValueOnce(makeResponse({ direct: [], itineraries: [] })); + + await expect(getDirectRoute(47.376, 8.541, 47.369, 8.539, MODES.WALKING)).rejects.toThrow( + 'unexpected response shape', + ); + }); +}); + +describe('#transitousClient getTransitRoute()', () => { + it('returns duration and transfers', async () => { + mockFetch.mockResolvedValueOnce(makeResponse(transitBody(900, 1))); + + const result = await getTransitRoute(47.376, 8.541, 47.369, 8.539); + + expect(result.duration).toBe(900); + expect(result.transfers).toBe(1); + }); + + it('passes transportModes=TRANSIT,WALK in URL', async () => { + mockFetch.mockResolvedValueOnce(makeResponse(transitBody(900, 0))); + + await getTransitRoute(47.376, 8.541, 47.369, 8.539); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain('transportModes=TRANSIT,WALK'); + }); + + it('throws when no itinerary found', async () => { + mockFetch.mockResolvedValueOnce(makeResponse({ direct: [], itineraries: [] })); + + await expect(getTransitRoute(47.376, 8.541, 47.369, 8.539)).rejects.toThrow('No transit connection found.'); + }); + + it('throws with rate-limit message on 429', async () => { + mockFetch.mockResolvedValueOnce(makeResponse({}, 429)); + + await expect(getTransitRoute(47.376, 8.541, 47.369, 8.539)).rejects.toThrow('rate limit'); + }); +}); + +describe('#transitousClient getAllRoutes()', () => { + it('returns results for all four modes', async () => { + mockFetch + .mockResolvedValueOnce(makeResponse(directBody('WALK', 600, 450))) + .mockResolvedValueOnce(makeResponse(directBody('BIKE', 360, 1350))) + .mockResolvedValueOnce(makeResponse(directBody('CAR', 665, 1720))) + .mockResolvedValueOnce(makeResponse(transitBody(900, 1))); + + const result = await getAllRoutes(47.376, 8.541, 47.369, 8.539); + + expect(result[MODES.WALKING]).toMatchObject({ duration: 600, distance: 450 }); + expect(result[MODES.CYCLING]).toMatchObject({ duration: 360, distance: 1350 }); + expect(result[MODES.DRIVING]).toMatchObject({ duration: 665, distance: 1720 }); + expect(result[MODES.TRANSIT]).toMatchObject({ duration: 900, transfers: 1 }); + }); + + it('returns null for a mode that fails, keeps others', async () => { + mockFetch + .mockResolvedValueOnce(makeResponse(directBody('WALK', 600, 450))) + .mockResolvedValueOnce(makeResponse({}, 500)) + .mockResolvedValueOnce(makeResponse(directBody('CAR', 665, 1720))) + .mockResolvedValueOnce(makeResponse(transitBody(900, 0))); + + const result = await getAllRoutes(47.376, 8.541, 47.369, 8.539); + + expect(result[MODES.WALKING]).not.toBeNull(); + expect(result[MODES.CYCLING]).toBeNull(); + expect(result[MODES.DRIVING]).not.toBeNull(); + expect(result[MODES.TRANSIT]).not.toBeNull(); + }); + + it('falls back to haversine estimate when WALK API returns no route', async () => { + mockFetch + .mockResolvedValueOnce(makeResponse({ direct: [], itineraries: [] })) // WALK → no route + .mockResolvedValueOnce(makeResponse(directBody('BIKE', 360, 1350))) + .mockResolvedValueOnce(makeResponse(directBody('CAR', 665, 1720))) + .mockResolvedValueOnce(makeResponse(transitBody(900, 0))); + + const result = await getAllRoutes(47.376, 8.541, 47.369, 8.539); + + expect(result[MODES.WALKING]).not.toBeNull(); + expect(result[MODES.WALKING].estimated).toBe(true); + expect(result[MODES.WALKING].duration).toBeGreaterThan(0); + expect(result[MODES.WALKING].distance).toBeGreaterThan(0); + }); + + it('returns null for transit when no connection exists', async () => { + mockFetch + .mockResolvedValueOnce(makeResponse(directBody('WALK', 600, 450))) + .mockResolvedValueOnce(makeResponse(directBody('BIKE', 360, 1350))) + .mockResolvedValueOnce(makeResponse(directBody('CAR', 665, 1720))) + .mockResolvedValueOnce(makeResponse({ direct: [], itineraries: [] })); + + const result = await getAllRoutes(47.376, 8.541, 47.369, 8.539); + + expect(result[MODES.TRANSIT]).toBeNull(); + expect(result[MODES.WALKING]).not.toBeNull(); + }); +}); diff --git a/test/similarity/similarityCache.test.js b/test/similarity/similarityCache.test.js index 5bf16850..6a5fd361 100644 --- a/test/similarity/similarityCache.test.js +++ b/test/similarity/similarityCache.test.js @@ -24,15 +24,15 @@ describe('similarityCache', () => { const { initSimilarityCache, checkAndAddEntry } = await loadModuleWith({ entries }); // Initially, duplicates should not be detected for new data - expect(checkAndAddEntry({ title: 'X', price: 200, address: 'Y' })).toBe(false); + expect(checkAndAddEntry({ title: 'X', price: 200, address: 'Y' }).duplicate).toBe(false); // Now initialize from storage initSimilarityCache(); // Exact duplicates should be detected - expect(checkAndAddEntry({ title: 'A', price: 1000, address: 'Main 1' })).toBe(true); + expect(checkAndAddEntry({ title: 'A', price: 1000, address: 'Main 1' }).duplicate).toBe(true); // Ensure falsy-but-valid price 0 is preserved by hashing and detected as duplicate - expect(checkAndAddEntry({ title: 'B', price: 0, address: 'Zero St' })).toBe(true); + expect(checkAndAddEntry({ title: 'B', price: 0, address: 'Zero St' }).duplicate).toBe(true); }); it('checkAndAddEntry returns false for new entry then true for duplicate on second call', async () => { @@ -41,8 +41,8 @@ describe('similarityCache', () => { const first = checkAndAddEntry({ title: 'C', price: 300, address: 'Road 3' }); const second = checkAndAddEntry({ title: 'C', price: 300, address: 'Road 3' }); - expect(first).toBe(false); - expect(second).toBe(true); + expect(first.duplicate).toBe(false); + expect(second.duplicate).toBe(true); }); it('hashing ignores null/undefined but preserves 0 via behavior', async () => { @@ -50,15 +50,15 @@ describe('similarityCache', () => { // Add baseline (null address ignored) const add1 = checkAndAddEntry({ title: 'T', price: 1, address: null }); - expect(add1).toBe(false); + expect(add1.duplicate).toBe(false); // Duplicate with undefined address should match const dup = checkAndAddEntry({ title: 'T', price: 1, address: undefined }); - expect(dup).toBe(true); + expect(dup.duplicate).toBe(true); // Now test that price 0 is preserved (not filtered out) const addZero = checkAndAddEntry({ title: 'Z', price: 0, address: 'Zero' }); - expect(addZero).toBe(false); + expect(addZero.duplicate).toBe(false); const dupZero = checkAndAddEntry({ title: 'Z', price: 0, address: 'Zero' }); - expect(dupZero).toBe(true); + expect(dupZero.duplicate).toBe(true); }); }); diff --git a/test/testFixtures/homegate.html b/test/testFixtures/homegate.html new file mode 100644 index 00000000..7c86bae8 --- /dev/null +++ b/test/testFixtures/homegate.html @@ -0,0 +1,150 @@ + + + + 521 Apartment for rent in Lausanne + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

521 results - Apartment for rent in Lausanne

1 / 13
  • View
  • Parking space
  • Elevator
  • +2
CHF 1,714.– Premium
1.5 rooms23m² living space
Rue du Petit Rocher 6, 1003 Lausanne

City Pop - La solution flexible pour les longs et courts séjours, pour se sentir toujours chez soi !

Nous proposons des appartements d'environ 23 m², de différents types (taille S, M, L, XL, Duplex, avec ou sans balcon) à partir de 400CHF/semaine.Nos ‘’Pop’’ entièrement meublés vous garantiront une expérience de vie authentique.Détendez-vous, travaillez, dormez, cuisinez dans un espace efficacement organisé avec :-Séjour ;- Cuisine entièrement équipée (lave-vaisselle, frigo, four, plaque à induction) ;- Salle de bain privées avec douche ;-Zone de couchage.Vous aurez la sensation d’un hôtel 4 étoiles !En outre, les appartements sont équipés de ;-Ustensiles de cuisine (vaisselle, verres, casseroles, etc.), ligne de lit et serviettes ;-Frais accessoires (eau, électricité) ;-Wi-Fi ;-Smart TV (excl. Serafe, ECA)-Petit déjeuner (croissant et café). Mais ce n'est pas tout !City Pop c’est un nouveau et innovant concept qui peut être réservé pour un minimum de 4 semaines et un maximum de 52 semaines.Des services supplémentaires sont disponibles et peuvent être activé facilement à travers de notre App :- Service de ménage ;- Cave ;- Parking intérieur.C'est aussi facile que de réserver une chambre d’hôtel ! Téléchargez notre application "City Pop" et choisissez le Pop que vous préférez !En cas de doute ou question, contactez-nous ! Nous sommes heureux de vous aider !Si vous rencontrez des difficultés lors du processus de réservation ou si vous avez besoin d'informations supplémentaires, n'hésitez pas à nous contacter à l'adresse contact.ch@citypop.com. Notre équipe est à votre disposition pour vous fournir de l'assistance.***********************************City Pop - A flexible solution for long and short stays, to always feel at home.We offer centrally located apartments of about 23 m² (size S, M, L, XL or Duplex). Prices start from 400CHF/week.These furnished apartments guarantee an authentic living experience: you can relax, work, sleep, cook, all in an apartment with such an organized 4-star interior:- Complete furnished apartment with kitchen, living room, sleeping area and bathroom;- Complete inventory for kitchen (plates, glasses, pots, etc.), sleeping area and towels;- Expenses costs (electricity, water);- Fast Internet connection;- Smart TV (Serafe excluded, ECA excluded);- Breakfast (croissant and coffee).But there is more! This fantastic and innovative living concept is free of contractual restrictions regarding the minimum length of stay: City Pop can be booked for 4 weeks up to a maximum of 52 weeks.A wide range of standard and additional services is available, which can be activated e.g. via the City-Pop-App:- Cleaning service (weekly, biweekly or one-shot);- Storage;- Parking spot (internal).Create and adapt the range of services to best suit your needs and lifestyle.Book a Pop is easy as booking a hotel room!To book a Pop, download our App and choose the Pop you prefer!Get more information to our Website or through our App "Citypop"!We are looking forward to meeting you soon at City Pop!If you encounter any difficulties during the booking process or need further information, please do not hesitate to contact us at contact.ch@citypop.com. Our team is at your service to provide assistance.

  • View
  • Parking space
  • Elevator
  • +2
Travel time
1 min.Station: Lausanne-Chauderon

Find a new tenant for free

Create a free listing on Homegate to find the perfect next tenant for your apartment in no time at all.

List your property now
1 / 19
CHF 4,600.– Top
5.5 rooms143m² living space
Chemin de Craivavers, 8c, 1012 Lausanne

Magnifique appartement meublé de 5.5 pièces à Lausanne avec vue panoramique sur le lac pour 12 mois

Magnifique appartement meublé de 5.5 pièces (4 chambres à coucher) de 143 m² situé en duplex (avec entrée indépendante) d'un immeuble moderne construit en 2018 dans le quartier très prisé de Chailly. Cet espace de vie offre tout le confort nécessaire pour une installation immédiate, et est à louer pour une durée déterminée de 12 mois - du 1 août 2026 au 1 août 2027 (à discuter). L'appartement jouit aussi d'un petit jardin avec jeux pour enfants et balançoires.L'appartement est loué entièrement meublé et comprend :A l'étage: • Salon spacieux, lumineux avec de grandes fenêtres offrant une vue dégagée sur le lac. Equippé d'une climatisation • Cuisine moderne entièrement équipée et coin à manger • Salle à manger ouverte sur le salon et la cuisine • Terrasse de 81m2 avec vue sur le lac, exposée a 270° (sud-ouest) • 2 chambres à coucher (11m2 et 13m2) • 1 piece de rangement (6m2) • 1 salle de bains (avec baignoire) Au rez-de chaussée • 1 vestaire/hall d'entrée • 1 chambre d'ami / bureau avec salle de douche/WC • 1 chambre à coucher avec dressing mural • 1 salle de douche (italienne) • 1 buanderie /seconde entrée Situation privilégiée : L'appartement est situé dans une PPE de 5 appartements, bénéficie d'un environnement extrêment calme et très ensoleillé. L'arrêt de bus Craivavers se trouve à seulement 3 minutes à pieds, tandis que l'École nouvelle de la Suisse romande est accessible en 6 minutes de marche.Possibilité de louer une place de parc dans le garage pour CHF 100/mois. La terrasse est équipée d'arrosage automatique.Acomptes de charges: incluent eau, internet, chauffage, jardinier pour tondre la pelouse et entretenir les plantes extérieures. Non inclus: électricité.Caution: 3mois de loyer

Travel time
21 min.Station: Fourmi
1 / 9
CHF 3,570.– Top
4.5 rooms95m² living space
Evian 4, 1006 Lausanne

Appartement de 4.5 pièces meublé ou partiellement meublé avec terrasse et vue sur le lac à Lausanne

Découvrez ce magnifique appartement de caractère de 4.5 pièces, situé au rez-de-chaussée d'un élégant immeuble de style ancien datant de 1911, rénové en 2006, dans le quartier prisé de Lausanne 1006. Avec ses 95 m² de surface habitable, ses hauts plafonds et parquets anciens, cet appartement allie le charme de l'ancien et le confort moderne.L'appartement vous offre :Séjour lumineux : Vaste pièce de vie avec parquet, grandes fenêtres laissant entrer la lumière naturelle, accès à la terrasse.Terrasse vec vue panoramique Cuisine renovée et totalement équipée2 grande chambres à coucher avec parquets anciens1 plus petite chambre 1 salle de bain et un wc séparéPlace de stationnement en sus.Cet appartement peut'etre loué meublé ou partiellement meublé.Disponible dès le 1er juillet 2026.Situation idéale : Niché à proximité immédiate du lac et des transports en commun, cet appartement jouit d'une localisation exceptionnelle.Arrêt de bus Musée Olympique : à 1 minute à pied Gare de Lausanne : à 10minutes à piedCe bien d'exception, alliant cachet architectural, confort et emplacement privilégié face au lac Léman et aux Alpes, est une opportunité rare sur le marché lausannois. Ne manquez pas la chance de faire de cet appartement votre nouveau chez-vous.

Travel time
11 min.Station: Jordils
1 / 8
CHF 6,450.– Top
6.5 rooms180m² living space
Avenue des Mousquines 24, 1005 Lausanne

Un appartement d'exception au cœur de Lausanne

Situé dans le très recherché quartier des Mousquines à Lausanne, ce somptueux appartement de 6,5 pièces, d’une surface de plus de 180 m², prend place au 2ᵉ étage avec combles d’un immeuble historique datant de 1897, entièrement rénové en 2026. Il conjugue avec élégance le charme de l’ancien et le confort contemporain.Dès l’entrée, les volumes généreux et la distribution harmonieuse des espaces séduisent immédiatement. Le séjour principal, agrémenté d’une cheminée et de bibliothèques intégrées, s’ouvre sur un balcon orienté plein sud, tout comme le second salon attenant, offrant une luminosité exceptionnelle et une vue dégagée sur le lac Léman et un parc arboré.La cuisine, fermée et entièrement équipée, propose un espace convivial et fonctionnel. L’appartement dispose également de trois chambres, dont une avec accès à un espace bureau aménagé dans les combles, idéal pour le télétravail ou un espace privé.Les combles, soigneusement aménagés, offrent un espace bureau chaleureux avec poutres apparentes, rangements sous pente et fenêtres de toit, renforçant le caractère unique du bien.Deux salles d’eau complètent ce bien : une salle de bains et une salle de douche. Une buanderie privative (lave-linge et sèche-linge) est également intégrée à l’appartement.Les prestations sont de grande qualité : hauts plafonds de près de 3 mètres, parquets en grandes lames ou en chevrons, radiateurs en fonte, armoires encastrées et finitions soignées.Baigné de lumière, cet appartement bénéficie d’un environnement calme et verdoyant, avec une place de parking extérieure incluse. Les résidents profitent également d’un accès à un jardin commun réservé exclusivement à l’immeuble, d’une grande cave ainsi que d’un local commun pour vélos et poussettes.Idéalement situé, à proximité immédiate des écoles, des transports publics et des commerces, ce bien offre un cadre de vie privilégié à quelques minutes du centre-ville.Ce bien rare propose un art de vivre raffiné dans l’un des quartiers les plus prisés de Lausanne.Une place de parking extérieur est inclus avec l'appartement.

Travel time
10 min.Station: Ours

Suggested properties for you based on search results

Additional information

Overview

+

The number of residents in Lausanne has changed by 3.03% over the past 3 years, to 144’873 inhabitants.

+

Demographic data

+

On average, residents earn CHF 75’095 per year. 31.05% of the population have an SEK II qualification, and 6.05% have a higher vocational education qualification. 38.45% are university graduates and 24.44% of residents are currently in compulsory schooling. The unemployment rate is 3.1%.

+

Taxes

+

The area has an overall rate of taxation of 14.17%. The actual percentage of tax paid varies from person to person and is based on aspects including current income, marital status, the amount of deductions and so on. In Lausanne, the tax rate for childless, single individuals is 20.17%. A married couple on a pension (over 65) pays an average of 18.45%, a married couple with two children 9.19% and a couple with no children 13%.

+

New builds

+

Over the last 5 years, 3040 new apartments have been created in Lausanne. Of these new builds, 375 are 1-room apartments, which are perfect for singles. A total of 769 new 2-room apartments have also been constructed. For families trying to find new living space, the 1067 apartments and the 669 4-room apartments are particularly attractive. 132 apartments with 5 rooms and 28 apartments with 6 or more rooms have been added to the local housing stock.

+

Housing stock

+

The new buildings have contributed to a further expansion of the housing stock, and there are now 82’321 apartments in Lausanne. The overall number of 1-room apartments amounts to 12’642. Additionally, there are 22’376 apartments with 2 rooms, 26’416 apartments with 3 rooms and 13’489 apartments with 4 rooms. In total, there are 4818 5-room apartments and 2580 large apartments with 6 or more rooms.

+

Empty apartments

+

The 0.58% of apartments in Lausanne are vacant. Consequently, 0.67% of the 1-room apartments, 0.65% of the apartments with 2 rooms, 0.54% of all 3-room apartments, 0.55% of the apartments with 4 rooms and 0.42% of apartments with 5 rooms are presently unoccupied. Apartments with more than 5 rooms have a vacancy rate of 0.42%; of that figure, 0.43% are apartments with more than 6 rooms.

+

Housing market (letting only)

+

On average, the rent price for rental properties is CHF 1700 per month. In terms of the rents on offer, a quarter of them are equivalent to or less than a monthly rent of CHF 1351. Monthly rents are less than CHF 2130 (75th percentile), or they are equal to this average price.

+
+ + + + + \ No newline at end of file diff --git a/test/testFixtures/homegate_api.json b/test/testFixtures/homegate_api.json new file mode 100644 index 00000000..e8b669c5 --- /dev/null +++ b/test/testFixtures/homegate_api.json @@ -0,0 +1,76 @@ +{ + "from": 0, + "size": 20, + "total": 2, + "maxFrom": 980, + "results": [ + { + "id": "4003179219", + "listing": { + "id": "4003179219", + "address": { + "street": "Rue de la Paix 10", + "city": "Lausanne", + "zipCode": "1003" + }, + "characteristics": { + "numberOfRooms": 3.5, + "livingSpace": 78 + }, + "localization": { + "fr": { + "text": { + "title": "Appartement spacieux au centre-ville", + "description": "Bel appartement lumineux avec vue sur le lac." + }, + "attachments": [ + { + "type": "IMAGE", + "url": "https://media2.homegate.ch/listings/v2/hg/media/4003179219/image/abc.jpg" + } + ] + } + }, + "prices": { + "rent": { + "gross": 1850 + } + } + } + }, + { + "id": "4003299999", + "listing": { + "id": "4003299999", + "address": { + "street": "Avenue de la Gare 5", + "city": "Lausanne", + "zipCode": "1003" + }, + "characteristics": { + "numberOfRooms": 2.5, + "livingSpace": 55 + }, + "localization": { + "fr": { + "text": { + "title": "Studio moderne près de la gare", + "description": "Studio bien aménagé avec toutes commodités." + }, + "attachments": [ + { + "type": "IMAGE", + "url": "https://media2.homegate.ch/listings/v2/hg/media/4003299999/image/def.jpg" + } + ] + } + }, + "prices": { + "rent": { + "gross": 1350 + } + } + } + } + ] +} diff --git a/test/testFixtures/immoscout24ch_listings.json b/test/testFixtures/immoscout24ch_listings.json new file mode 100644 index 00000000..0eaf7f0d --- /dev/null +++ b/test/testFixtures/immoscout24ch_listings.json @@ -0,0 +1,72 @@ +{ + "listings": [ + { + "id": "4003206723", + "listing": { + "address": { + "geoCoordinates": { "accuracy": "LOW", "longitude": 6.585132, "latitude": 46.536659 }, + "locality": "Lausanne", + "postalCode": "1020", + "street": "Avenue des Figuiers 27" + }, + "categories": ["APARTMENT", "FLAT"], + "characteristics": { "livingSpace": 78, "numberOfRooms": 3.5 }, + "id": "4003206723", + "localization": { + "fr": { + "text": { + "title": "Appartement spacieux au centre-ville", + "description": "Bel appartement lumineux avec vue sur le lac." + }, + "attachments": [ + { + "type": "IMAGE", + "url": "https://cdn.immoscout24.ch/listings/v2/anibisfill/4003206723/image/9fb80da04a392e61c7fe9f7063c3220f.jpg", + "file": "image1.jpg" + } + ], + "isMachineTranslated": false + } + }, + "meta": { "createdAt": "2026-06-02T10:09:07.917Z" }, + "offerType": "RENT", + "platforms": ["homegate", "immoscout24"], + "prices": { "currency": "CHF", "rent": { "gross": 1850, "interval": "MONTH" } } + } + }, + { + "id": "4003186650", + "listing": { + "address": { + "geoCoordinates": { "accuracy": "LOW", "latitude": 46.520995, "longitude": 6.631682 }, + "locality": "Lausanne", + "postalCode": "1003", + "street": "Rue de Lausanne" + }, + "categories": ["APARTMENT", "FLAT"], + "characteristics": { "livingSpace": 55, "numberOfRooms": 2.5 }, + "id": "4003186650", + "localization": { + "fr": { + "text": { + "title": "Studio moderne près de la gare", + "description": "Studio bien aménagé avec toutes commodités." + }, + "attachments": [ + { + "type": "IMAGE", + "url": "https://cdn.immoscout24.ch/listings/v2/anibisfill/4003186650/image/b8e5c44b6e9b50baa95990e3bfe03b08.jpg", + "file": "image1.jpg" + } + ], + "isMachineTranslated": false + } + }, + "meta": { "createdAt": "2026-06-01T08:00:00.000Z" }, + "offerType": "RENT", + "platforms": ["homegate", "immoscout24"], + "prices": { "currency": "CHF", "rent": { "gross": 1350, "interval": "MONTH" } } + } + } + ] +} diff --git a/test/testFixtures/immoscout24ch_search.json b/test/testFixtures/immoscout24ch_search.json new file mode 100644 index 00000000..1e2ed0c1 --- /dev/null +++ b/test/testFixtures/immoscout24ch_search.json @@ -0,0 +1,10 @@ +{ + "from": 0, + "size": 20, + "total": 528, + "results": [ + { "id": "4003206723" }, + { "id": "4003186650" } + ], + "maxFrom": 980 +} diff --git a/test/translation/deeplClient.test.js b/test/translation/deeplClient.test.js new file mode 100644 index 00000000..9acfd173 --- /dev/null +++ b/test/translation/deeplClient.test.js @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { translate } from '../../lib/services/translation/deeplClient.js'; + +const FREE_KEY = 'test-key:fx'; +const PRO_KEY = 'test-key-pro'; + +const mockFetch = vi.fn(); + +beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + mockFetch.mockReset(); +}); + +function makeResponse(body, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : status === 429 ? 'Too Many Requests' : 'Error', + json: async () => body, + }; +} + +describe('#deeplClient translate()', () => { + it('returns translated text on successful response', async () => { + mockFetch.mockResolvedValueOnce(makeResponse({ translations: [{ text: 'Hello world' }] })); + + const result = await translate('Bonjour le monde', 'EN', FREE_KEY); + + expect(result).toBe('Hello world'); + }); + + it('calls the free-tier URL for keys ending with :fx', async () => { + mockFetch.mockResolvedValueOnce(makeResponse({ translations: [{ text: 'Hello' }] })); + + await translate('Bonjour', 'EN', FREE_KEY); + + expect(mockFetch).toHaveBeenCalledWith('https://api-free.deepl.com/v2/translate', expect.any(Object)); + }); + + it('calls the pro URL for keys not ending with :fx', async () => { + mockFetch.mockResolvedValueOnce(makeResponse({ translations: [{ text: 'Hello' }] })); + + await translate('Bonjour', 'EN', PRO_KEY); + + expect(mockFetch).toHaveBeenCalledWith('https://api.deepl.com/v2/translate', expect.any(Object)); + }); + + it('sends the correct Authorization header and body', async () => { + mockFetch.mockResolvedValueOnce(makeResponse({ translations: [{ text: 'Hallo' }] })); + + await translate('Hello', 'DE', FREE_KEY); + + const [, options] = mockFetch.mock.calls[0]; + expect(options.method).toBe('POST'); + expect(options.headers['Authorization']).toBe(`DeepL-Auth-Key ${FREE_KEY}`); + const body = JSON.parse(options.body); + expect(body.text).toEqual(['Hello']); + expect(body.target_lang).toBe('DE'); + expect(body.source_lang).toBeUndefined(); + }); + + it('throws with a rate-limit message on 429', async () => { + mockFetch.mockResolvedValueOnce(makeResponse({}, 429)); + + await expect(translate('Bonjour', 'EN', FREE_KEY)).rejects.toThrow('rate limit'); + }); + + it('throws on non-OK responses', async () => { + mockFetch.mockResolvedValueOnce(makeResponse({}, 403)); + + await expect(translate('Bonjour', 'EN', FREE_KEY)).rejects.toThrow('DeepL API error: 403'); + }); + + it('throws when response shape is unexpected', async () => { + mockFetch.mockResolvedValueOnce(makeResponse({ unexpected: true })); + + await expect(translate('Bonjour', 'EN', FREE_KEY)).rejects.toThrow('unexpected response shape'); + }); +}); diff --git a/test/utils.js b/test/utils.js index bb2e43a1..6dd07d8f 100644 --- a/test/utils.js +++ b/test/utils.js @@ -29,7 +29,44 @@ vi.mock('../lib/services/extractor/puppeteerExtractor.js', async (importOriginal const { readFixture } = await import('./offlineFixtures.js'); return { default: (url) => readFixture(url), - launchBrowser: async () => ({ close: async () => {}, isConnected: () => true }), + launchBrowser: async (url) => { + const html = (await readFixture(url)) ?? ''; + // Extract __INITIAL_STATE__ so page.evaluate(() => window.__INITIAL_STATE__) works. + const marker = html.includes('window.__INITIAL_STATE__ =') + ? 'window.__INITIAL_STATE__ =' + : 'window.__INITIAL_STATE__='; + const markerIdx = html.indexOf(marker); + let initialState = null; + if (markerIdx !== -1) { + const scriptEnd = html.indexOf('', markerIdx); + let jsonStr = ( + scriptEnd !== -1 ? html.slice(markerIdx + marker.length, scriptEnd) : html.slice(markerIdx + marker.length) + ).trim(); + if (jsonStr.endsWith(';')) jsonStr = jsonStr.slice(0, -1); + try { + initialState = JSON.parse(jsonStr); + } catch { + initialState = null; + } + } + const page = { + goto: async () => {}, + url: () => url, + content: async () => html, + waitForFunction: async () => {}, + evaluate: async (fn) => { + const src = fn.toString(); + if (src.includes('__INITIAL_STATE__')) return initialState; + return fn(); + }, + close: async () => {}, + }; + return { + newPage: async () => page, + close: async () => {}, + isConnected: () => true, + }; + }, closeBrowser: async () => {}, }; }); diff --git a/ui/src/components/grid/listings/ListingsGrid.jsx b/ui/src/components/grid/listings/ListingsGrid.jsx index c6ea39a9..ee06d5bb 100644 --- a/ui/src/components/grid/listings/ListingsGrid.jsx +++ b/ui/src/components/grid/listings/ListingsGrid.jsx @@ -15,6 +15,7 @@ import { IconEyeOpened, } from '@douyinfe/semi-icons'; import no_image from '../../../assets/no_image.png'; +import { formatEuroPrice } from '../../../services/price/priceService.js'; import * as timeService from '../../../services/time/timeService.js'; import StatusControl from '../../listings/StatusControl.jsx'; @@ -79,7 +80,7 @@ const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onRestore, isHi {item.price && (
- {item.price} + {formatEuroPrice(item.price, item.currency ?? '€')}
)} {item.address && ( diff --git a/ui/src/components/map/Map.jsx b/ui/src/components/map/Map.jsx index 77258566..b743191f 100644 --- a/ui/src/components/map/Map.jsx +++ b/ui/src/components/map/Map.jsx @@ -11,9 +11,9 @@ import { fixMapboxDrawCompatibility, addDrawingControl, setupAreaFilterEventList import { getBoundsFromCoords } from '../../views/listings/mapUtils.js'; import './Map.less'; -export const GERMANY_BOUNDS = [ - [5.866, 47.27], // Southwest coordinates - [15.042, 55.059], // Northeast coordinates +export const DACH_BOUNDS = [ + [5.866, 45.818], // Southwest (includes Switzerland and Austria) + [17.16, 55.059], // Northeast (includes Austria) ]; export const STYLES = { @@ -76,9 +76,9 @@ export default function Map({ mapRef.current = new maplibregl.Map({ container: mapContainerRef.current, style: STYLES[style], - center: [10.4515, 51.1657], // Center of Germany + center: [10.4515, 50.5], // Center of DACH region zoom: 4, - maxBounds: GERMANY_BOUNDS, + maxBounds: DACH_BOUNDS, antialias: true, }); diff --git a/ui/src/components/table/ListingsTable.jsx b/ui/src/components/table/ListingsTable.jsx index 06057855..945c51be 100644 --- a/ui/src/components/table/ListingsTable.jsx +++ b/ui/src/components/table/ListingsTable.jsx @@ -63,7 +63,11 @@ const ListingsTable = ({
- {item.price ? formatEuroPrice(item.price) : ---} + {item.price ? ( + formatEuroPrice(item.price, item.currency ?? '€') + ) : ( + --- + )}
diff --git a/ui/src/locales/de.json b/ui/src/locales/de.json index 67ecda34..ed4a9b9d 100644 --- a/ui/src/locales/de.json +++ b/ui/src/locales/de.json @@ -195,7 +195,12 @@ "listing.detail.detailsTitle": "Details", "listing.detail.descriptionTitle": "Beschreibung", "listing.detail.noDescription": "Keine Beschreibung verfügbar.", - "listing.detail.distanceToHome": "Entfernung nach Hause:", + "listing.detail.translate": "Übersetzen", + "listing.detail.showOriginal": "Original anzeigen", + "listing.detail.translateError": "Übersetzung fehlgeschlagen. Bitte DeepL-API-Schlüssel prüfen.", + "listing.detail.distanceToHome": "Entfernung zum Ziel:", + "listing.detail.commuteTimes": "Pendelzeiten:", + "listing.detail.commuteTimesHint": "Zeiten sind Schätzungen von Transitous (OpenStreetMap-basiertes Routing). Gehzeiten mit ~ sind Luftlinien-Näherungswerte. Tatsächliche Zeiten können abweichen.", "listing.detail.locationTitle": "Lage", "listing.detail.noGeoWarning": "Dieses Inserat hat keine gültigen Geokoordinaten und kann daher nicht auf der Karte angezeigt werden.", "listing.detail.fieldPrice": "Preis", @@ -334,11 +339,19 @@ "settings.workingHoursFrom": "Von", "settings.workingHoursUntil": "Bis", "settings.proxyUrl": "Proxy-URL", + "settings.deeplApiKey": "DeepL-API-Schlüssel", + "settings.deeplApiKeyHelp": "Optional. Ermöglicht die On-Demand-Übersetzung von Beschreibungen in der Inseratdetailansicht. Kostenlosen Schlüssel unter deepl.com erhalten (1 Mio. Zeichen gratis, einmalig). Unterstützt sowohl kostenlose (:fx-Suffix) als auch Pro-Schlüssel.", + "settings.deeplApiKeyPlaceholder": "DeepL-API-Schlüssel hier einfügen", + "settings.deeplApiKeyAlreadySet": "Bereits gesetzt — neuen Schlüssel einfügen zum Ersetzen", + "settings.immoscout24chDatadome": "ImmoScout24 CH — DataDome Cookie", + "settings.immoscout24chDatadomeHelp": "Erforderlich für den ImmoScout24 CH Anbieter. Diesen Wert aus der Android-App mit Charles Proxy erfassen (datadome= Cookie-Wert aus einer erfolgreichen API-Anfrage). Aktualisieren wenn der Anbieter 403-Fehler zurückgibt.", + "settings.immoscout24chDatadomePlaceholder": "DataDome Cookie-Wert hier einfügen", + "settings.immoscout24chDatadomeAlreadySet": "Bereits gesetzt — neuen Wert einfügen zum Ersetzen", "settings.proxyUrlHelp": "Optional. Leitet den Scraping-Browser durch einen Proxy. Server/Rechenzentrum-IPs werden von Anbietern (z. B. Immowelt) unabhängig vom Browser-Fingerprint häufig blockiert. Ein deutscher Wohnproxy lässt Anfragen wie einen normalen Haushalt erscheinen. Format: http://benutzer:passwort@host:port oder socks5://benutzer:passwort@host:port. Leer lassen zum Deaktivieren.", "settings.proxyUrlPlaceholder": "http://benutzer:passwort@host:port", - "settings.homeAddress": "Heimatadresse", - "settings.homeAddressHelp": "Wird zur Berechnung der Entfernung zwischen deinem Standort und jedem Inserat verwendet. Eine Aktualisierung berechnet die Entfernungen für alle aktiven Inserate neu.", - "settings.homeAddressPlaceholder": "Heimatadresse eingeben", + "settings.homeAddress": "Pendeladresse", + "settings.homeAddressHelp": "Wird zur Berechnung von Pendelzeiten und Luftlinienentfernungen zu jedem Inserat verwendet. Arbeitsplatz, Universität oder ein anderes regelmäßiges Ziel eingeben. Eine Aktualisierung berechnet die Entfernungen für alle aktiven Inserate neu.", + "settings.homeAddressPlaceholder": "Pendelziel eingeben", "settings.homeAddressGeoError": "Adresse gefunden, konnte aber nicht genau geokodiert werden.", "settings.providerDetails": "Anbieter-Details", "settings.providerDetailsHelp": "Zusätzliche Details (Beschreibung, Attribute, Maklerinfos) für Inserate abrufen. Erfordert einen zusätzlichen API-Aufruf pro Inserat.", diff --git a/ui/src/locales/en.json b/ui/src/locales/en.json index ae5dbf86..28ccefb1 100644 --- a/ui/src/locales/en.json +++ b/ui/src/locales/en.json @@ -195,7 +195,12 @@ "listing.detail.detailsTitle": "Details", "listing.detail.descriptionTitle": "Description", "listing.detail.noDescription": "No description available.", - "listing.detail.distanceToHome": "Distance to home:", + "listing.detail.translate": "Translate", + "listing.detail.showOriginal": "Show original", + "listing.detail.translateError": "Translation failed. Please check your DeepL API key.", + "listing.detail.distanceToHome": "Distance to destination:", + "listing.detail.commuteTimes": "Commute times:", + "listing.detail.commuteTimesHint": "Times are estimates from Transitous (openstreetmap-based routing). Walking times marked with ~ are straight-line approximations. Actual times may vary.", "listing.detail.locationTitle": "Location", "listing.detail.noGeoWarning": "This listing has no valid geocoordinates, so we cannot show it on the map.", "listing.detail.fieldPrice": "Price", @@ -334,11 +339,19 @@ "settings.workingHoursFrom": "From", "settings.workingHoursUntil": "Until", "settings.proxyUrl": "Proxy URL", + "settings.deeplApiKey": "DeepL API Key", + "settings.deeplApiKeyHelp": "Optional. Enables on-demand description translation in listing detail views. Get a free key at deepl.com (1M characters free, one-time). Supports both free (:fx suffix) and pro keys.", + "settings.deeplApiKeyPlaceholder": "Paste your DeepL API key here", + "settings.deeplApiKeyAlreadySet": "Already set — paste a new key to replace it", + "settings.immoscout24chDatadome": "ImmoScout24 CH — DataDome Cookie", + "settings.immoscout24chDatadomeHelp": "Required for the ImmoScout24 CH provider. Capture this value from the Android app using Charles Proxy (the datadome= cookie value from a successful API request). Refresh it when the provider starts returning 403 errors.", + "settings.immoscout24chDatadomePlaceholder": "Paste the datadome cookie value here", + "settings.immoscout24chDatadomeAlreadySet": "Already set — paste a new value to replace it", "settings.proxyUrlHelp": "Optional. Routes the scraping browser through a proxy. Server/datacenter IPs are frequently blocked by providers (e.g. immowelt) regardless of browser fingerprint, a German residential proxy makes requests look like a normal household and is the most effective fix. Format: http://user:pass@host:port or socks5://user:pass@host:port. Leave empty to disable.", "settings.proxyUrlPlaceholder": "http://user:pass@host:port", - "settings.homeAddress": "Home Address", - "settings.homeAddressHelp": "Used to calculate distances between your location and each listing. Updating this recalculates distances for all active listings.", - "settings.homeAddressPlaceholder": "Enter your home address", + "settings.homeAddress": "Commute Address", + "settings.homeAddressHelp": "Used to calculate commute times and straight-line distances from each listing. Enter your workplace, university, or any destination you regularly commute to. Updating this recalculates distances for all active listings.", + "settings.homeAddressPlaceholder": "Enter your commute destination", "settings.homeAddressGeoError": "Address found but could not be geocoded accurately.", "settings.providerDetails": "Provider Details", "settings.providerDetailsHelp": "Fetch additional details (description, attributes, agent info) for listings. Needs an extra API call per listing.", diff --git a/ui/src/services/price/priceService.js b/ui/src/services/price/priceService.js index 38a9552b..74e97467 100644 --- a/ui/src/services/price/priceService.js +++ b/ui/src/services/price/priceService.js @@ -10,13 +10,14 @@ const euroPriceFormatter = new Intl.NumberFormat('de-DE', { /** * @param {number|string} price + * @param {string} [currency='€'] * @returns {string} */ -export const formatEuroPrice = (price) => { +export const formatEuroPrice = (price, currency = '€') => { const parsedPrice = Number(price); if (!Number.isFinite(parsedPrice)) { - return `${price} €`; + return `${price} ${currency}`; } - return `${euroPriceFormatter.format(parsedPrice)} €`; + return `${euroPriceFormatter.format(parsedPrice)} ${currency}`; }; diff --git a/ui/src/services/state/store.js b/ui/src/services/state/store.js index 7fba7306..48c7182b 100644 --- a/ui/src/services/state/store.js +++ b/ui/src/services/state/store.js @@ -296,7 +296,12 @@ export const useFredyState = create( async getUserSettings() { try { const response = await xhrGet('/api/user/settings'); - set((state) => ({ userSettings: { ...state.userSettings, settings: response.json, loaded: true } })); + const settings = response.json; + // Fall back to localStorage so dismissal survives a session expiry + if (!settings.news_hash) { + settings.news_hash = localStorage.getItem('fredy_news_hash') ?? undefined; + } + set((state) => ({ userSettings: { ...state.userSettings, settings, loaded: true } })); } catch (Exception) { console.error('Error while trying to get resource for api/user/settings. Error:', Exception); // Mark as loaded even on error to prevent blocking the UI @@ -304,17 +309,17 @@ export const useFredyState = create( } }, async setNewsHash(newsHash) { + localStorage.setItem('fredy_news_hash', newsHash); + set((state) => ({ + userSettings: { + ...state.userSettings, + settings: { ...state.userSettings.settings, news_hash: newsHash }, + }, + })); try { await xhrPost('/api/user/settings/news-hash', { news_hash: newsHash }); - set((state) => ({ - userSettings: { - ...state.userSettings, - settings: { ...state.userSettings.settings, news_hash: newsHash }, - }, - })); } catch (Exception) { console.error('Error while trying to update news hash. Error:', Exception); - throw Exception; } }, async setHomeAddress(address) { diff --git a/ui/src/views/generalSettings/GeneralSettings.jsx b/ui/src/views/generalSettings/GeneralSettings.jsx index c07deca6..ab2959b9 100644 --- a/ui/src/views/generalSettings/GeneralSettings.jsx +++ b/ui/src/views/generalSettings/GeneralSettings.jsx @@ -100,6 +100,10 @@ const GeneralSettings = function GeneralSettings() { const [interval, setInterval] = React.useState(''); const [proxyUrl, setProxyUrl] = React.useState(''); + const [deeplApiKey, setDeeplApiKey] = React.useState(''); + const [deeplApiKeySet, setDeeplApiKeySet] = React.useState(false); + const [immoscout24chDatadome, setImmoscout24chDatadome] = React.useState(''); + const [immoscout24chDatadomeSet, setImmoscout24chDatadomeSet] = React.useState(false); const [port, setPort] = React.useState(''); const [workingHourFrom, setWorkingHourFrom] = React.useState(null); const [workingHourTo, setWorkingHourTo] = React.useState(null); @@ -163,6 +167,8 @@ const GeneralSettings = function GeneralSettings() { setDemoMode(settings?.demoMode || false); setSqlitePath(settings?.sqlitepath); setBaseUrl(settings?.baseUrl ?? ''); + setDeeplApiKeySet(settings?.deepl_api_key_set ?? false); + setImmoscout24chDatadomeSet(settings?.immoscout24ch_datadome_set ?? false); } init(); @@ -252,6 +258,8 @@ const GeneralSettings = function GeneralSettings() { analyticsEnabled, sqlitepath: sqlitePath, baseUrl, + ...(deeplApiKey.trim() ? { deepl_api_key: deeplApiKey.trim() } : {}), + ...(immoscout24chDatadome.trim() ? { immoscout24ch_datadome: immoscout24chDatadome.trim() } : {}), }); } catch (exception) { console.error(exception); @@ -569,6 +577,33 @@ const GeneralSettings = function GeneralSettings() { /> + + setDeeplApiKey(value)} + /> + + + + setImmoscout24chDatadome(value)} + /> + +
diff --git a/ui/src/views/listings/Map.jsx b/ui/src/views/listings/Map.jsx index 3e7a684c..d96ed9bd 100644 --- a/ui/src/views/listings/Map.jsx +++ b/ui/src/views/listings/Map.jsx @@ -131,8 +131,11 @@ export default function MapView() { } }, []); + const [mapReady, setMapReady] = useState(false); + const handleMapReady = (mapInstance) => { map.current = mapInstance; + setMapReady(true); }; const handleMapStyle = (value) => { @@ -224,7 +227,7 @@ export default function MapView() { }); } } - }, [homeAddress?.address, distanceFilter, listings]); + }, [homeAddress?.address, distanceFilter, listings, mapReady]); useEffect(() => { if (!map.current) return; @@ -319,7 +322,7 @@ export default function MapView() { />

${listing.title}

- ${t('map.popupPrice')} ${listing.price ? listing.price + ' €' : t('common.na')} + ${t('map.popupPrice')} ${listing.price ? listing.price + ' ' + (listing.currency ?? '€') : t('common.na')} ${t('map.popupAddress')} ${listing.address || t('common.na')} ${t('map.popupJob')} ${listing.job_name || t('common.na')} ${t('map.popupProvider')} ${capitalizedProvider}