Compare commits

...

9 Commits

Author SHA1 Message Date
orangecoding
3117044139 fixing immoscout scraper 2026-01-26 19:52:37 +01:00
orangecoding
7879d0e94a next release version 2026-01-26 12:35:57 +01:00
orangecoding
afd1048c9e hardening the check if a listing is active 2026-01-26 12:34:49 +01:00
orangecoding
acbaab05ed next release version 2026-01-26 12:07:43 +01:00
orangecoding
72fffc526b deleting a listing now sets it to deleted in the db, preventing it from reappearing when scraping happens 2026-01-26 12:07:21 +01:00
orangecoding
9e5989ece3 zoom into map where most markers are 2026-01-26 11:54:47 +01:00
orangecoding
afc200c9e1 improved tooltip in map, improved user-settings handling 2026-01-26 11:50:16 +01:00
orangecoding
59226491f2 improved tooltip in map, improved user-settings handling 2026-01-26 11:20:02 +01:00
orangecoding
28f7760120 adapt link to listing in grid view to behave like a real link 2026-01-26 10:43:38 +01:00
19 changed files with 323 additions and 92 deletions

View File

@@ -12,6 +12,7 @@ import { calculateDistanceForUser } from '../../services/geocoding/distanceServi
import { fromJson } from '../../utils.js'; import { fromJson } from '../../utils.js';
import { trackFeature } from '../../services/tracking/Tracker.js'; import { trackFeature } from '../../services/tracking/Tracker.js';
import { FEATURES } from '../../features.js'; import { FEATURES } from '../../features.js';
import logger from '../../services/logger.js';
const service = restana(); const service = restana();
const userSettingsRouter = service.newRouter(); const userSettingsRouter = service.newRouter();
@@ -34,11 +35,12 @@ userSettingsRouter.get('/autocomplete', async (req, res) => {
res.body = results; res.body = results;
res.send(); res.send();
} catch (error) { } catch (error) {
res.status(500).send({ error: error.message }); res.statusCode = 500;
res.send({ error: error.message });
} }
}); });
userSettingsRouter.post('/', async (req, res) => { userSettingsRouter.post('/home-address', async (req, res) => {
const userId = req.session.currentUser; const userId = req.session.currentUser;
const { home_address } = req.body; const { home_address } = req.body;
@@ -51,15 +53,17 @@ userSettingsRouter.post('/', async (req, res) => {
calculateDistanceForUser(userId); calculateDistanceForUser(userId);
res.send({ success: true, coords }); res.send({ success: true, coords });
} else { } else {
res.status(400).send({ error: 'Could not geocode address' }); res.statusCode = 400;
res.send({ error: 'Could not geocode address' });
} }
} else { } else {
// If address is empty, maybe clear it?
upsertSettings({ home_address: null }, userId); upsertSettings({ home_address: null }, userId);
res.send({ success: true }); res.send({ success: true });
} }
} catch (error) { } catch (error) {
res.status(500).send({ error: error.message }); logger.error('Error updating home address settings', error);
res.statusCode = 500;
res.send({ error: error.message });
} }
}); });

View File

@@ -8,7 +8,7 @@
* *
* The mobile API provides the following endpoints: * The mobile API provides the following endpoints:
* - GET /search/total?{search parameters}: Returns the total number of listings for the given query * - GET /search/total?{search parameters}: Returns the total number of listings for the given query
* Example: `curl -H "User-Agent: ImmoScout_27.3_26.0_._" https://api.mobile.immobilienscout24.de/search/total?searchType=region&realestatetype=apartmentrent&pricetype=calculatedtotalrent&geocodes=%2Fde%2Fberlin%2Fberlin ` * Example: `curl -H "User-Agent: ImmoScout_27.12_26.2_._" https://api.mobile.immobilienscout24.de/search/total?searchType=region&realestatetype=apartmentrent&pricetype=calculatedtotalrent&geocodes=%2Fde%2Fberlin%2Fberlin `
* *
* - POST /search/list?{search parameters}: Actually retrieves the listings. Body is json encoded and contains * - POST /search/list?{search parameters}: Actually retrieves the listings. Body is json encoded and contains
* data specifying additional results (advertisements) to return. The format is as follows: * data specifying additional results (advertisements) to return. The format is as follows:
@@ -20,12 +20,12 @@
* ``` * ```
* It is not necessary to provide data for the specified keys. * It is not necessary to provide data for the specified keys.
* *
* Example: `curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region&geocodes=%2Fde%2Fberlin%2Fberlin&pagenumber=1' -H "Connection: keep-alive" -H "User-Agent: ImmoScout_27.3_26.0_._" -H "Accept: application/json" -H "Content-Type: application/json" -d '{"supportedResultListType": [], "userData": {}}'` * Example: `curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region&geocodes=%2Fde%2Fberlin%2Fberlin&pagenumber=1' -H "Connection: keep-alive" -H "User-Agent: ImmoScout_27.12_26.2_._" -H "Accept: application/json" -H "Content-Type: application/json" -d '{"supportedResultListType": [], "userData": {}}'`
* - GET /expose/{id} - Returns the details of a listing. The response contains additional details not included in the * - GET /expose/{id} - Returns the details of a listing. The response contains additional details not included in the
* listing response. * listing response.
* *
* Example: `curl -H "User-Agent: ImmoScout_27.3_26.0_._" "https://api.mobile.immobilienscout24.de/expose/158382494"` * Example: `curl -H "User-Agent: ImmoScout_27.12_26.2_._" "https://api.mobile.immobilienscout24.de/expose/158382494"`
* *
* *
* It is necessary to set the correct User Agent (see `getListings`) in the request header. * It is necessary to set the correct User Agent (see `getListings`) in the request header.
@@ -52,7 +52,7 @@ async function getListings(url) {
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: 'POST',
headers: { headers: {
'User-Agent': 'ImmoScout_27.3_26.0_._', 'User-Agent': 'ImmoScout_27.12_26.2_._',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
@@ -88,7 +88,7 @@ async function getListings(url) {
async function isListingActive(link) { async function isListingActive(link) {
const result = await fetch(convertImmoscoutListingToMobileListing(link), { const result = await fetch(convertImmoscoutListingToMobileListing(link), {
headers: { headers: {
'User-Agent': 'ImmoScout_27.3_26.0_._', 'User-Agent': 'ImmoScout_27.12_26.2_._',
}, },
}); });

View File

@@ -36,7 +36,7 @@ const config = {
}, },
normalize: normalize, normalize: normalize,
filter: applyBlacklist, filter: applyBlacklist,
activeTester: checkIfListingIsActive, activeTester: (url) => checkIfListingIsActive(url, 'Angebot nicht gefunden'),
}; };
export const init = (sourceConfig, blacklist) => { export const init = (sourceConfig, blacklist) => {
config.enabled = sourceConfig.enabled; config.enabled = sourceConfig.enabled;

View File

@@ -103,6 +103,8 @@ const REAL_ESTATE_TYPE = {
'haus-mieten': 'houserent', 'haus-mieten': 'houserent',
'wohnung-mieten': 'apartmentrent', 'wohnung-mieten': 'apartmentrent',
'wohnung-kaufen': 'apartmentbuy', 'wohnung-kaufen': 'apartmentbuy',
'wohnung-kaufen-mit-balkon': 'apartmentbuy',
'eigentumswohnung-mit-garten': 'apartmentbuy',
'haus-kaufen': 'housebuy', 'haus-kaufen': 'housebuy',
}; };
@@ -146,7 +148,7 @@ export function convertWebToMobile(webUrl) {
const realTypeKey = segments.at(-1); const realTypeKey = segments.at(-1);
let realType = REAL_ESTATE_TYPE[realTypeKey]; let realType = REAL_ESTATE_TYPE[realTypeKey];
let additionalParamsFromWebPath; let additionalParamsFromWebPath = WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP[realTypeKey] || null;
if (!realType) { if (!realType) {
// Test for seo optimized apartment path (only used on the ImmoScout web app) // Test for seo optimized apartment path (only used on the ImmoScout web app)
@@ -167,7 +169,7 @@ export function convertWebToMobile(webUrl) {
Object.entries(rawParams).filter(([key]) => key !== 'enteredFrom' && PARAM_NAME_MAP[key]), Object.entries(rawParams).filter(([key]) => key !== 'enteredFrom' && PARAM_NAME_MAP[key]),
); );
const geocodes = `/${segments.slice(2, 5).join('/')}`; const geocodes = `/${segments.slice(2, segments.length - 1).join('/')}`;
const isRadius = segments.includes('radius'); const isRadius = segments.includes('radius');
const mobileParams = { const mobileParams = {
searchType: isRadius ? 'radius' : 'region', searchType: isRadius ? 'radius' : 'region',

View File

@@ -8,38 +8,71 @@ import { randomBetween, sleep } from '../../utils.js';
const maxAttempts = 3; const maxAttempts = 3;
const userAgents = [
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15',
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
];
/** /**
* Check if a listing is still active with up to 3 attempts and exponential backoff. * Check if a listing is still active with up to 5 attempts and exponential backoff.
* Backoff waits are capped and the last wait is at most 2000 ms. * Backoff waits are randomized and capped.
* *
* Rules: * Rules:
* - HTTP 200 => return 1 * - HTTP 200 => return 1 (if checkForText is provided and found, returns 0)
* - HTTP 401/403 => return -1 (most certainly detected as a bot) * - HTTP 401/403 => return -1 (most certainly detected as a bot)
* - HTTP 404 => return 0 * - HTTP 404 => return 0
* - Other statuses or network errors => retry until attempts are exhausted * - Other statuses or network errors => retry until attempts are exhausted
* *
* @returns {Promise<Integer>} 1 if active, o if not active and -1 if detected as bot * @returns {Promise<Integer>} 1 if active, 0 if not active and -1 if detected as bot
*/ */
export default async function checkIfListingIsActive(link) { export default async function checkIfListingIsActive(link, checkForText = null) {
await sleep(randomBetween(50, 100)); await sleep(randomBetween(50, 100));
for (let attempt = 1; attempt <= maxAttempts; attempt++) { for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try { try {
const userAgent = userAgents[Math.floor(Math.random() * userAgents.length)];
const res = await fetch(link, { const res = await fetch(link, {
redirect: 'manual', redirect: 'manual',
headers: { headers: {
'User-Agent': 'User-Agent': userAgent,
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36', Accept:
'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8', 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
'Accept-Language': 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7',
'Accept-Encoding': 'gzip, deflate, br',
'Cache-Control': 'max-age=0',
'Sec-Ch-Ua': '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
'Sec-Ch-Ua-Mobile': '?0',
'Sec-Ch-Ua-Platform': '"macOS"',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none',
'Sec-Fetch-User': '?1',
'Upgrade-Insecure-Requests': '1',
Referer: 'https://www.google.com/',
}, },
}); });
if (res.status === 200) { if (res.status === 200) {
if (checkForText) {
const htmText = await res.text();
if (htmText.includes(checkForText)) {
return 0;
}
}
return 1; return 1;
} }
if (res.status === 401) return -1; if (res.status === 401 || res.status === 403) {
if (res.status === 403) return -1; if (attempt < maxAttempts) {
if (res.status === 404) return 0; await sleep(backoffDelay(attempt));
continue;
}
return -1;
}
if (res.status === 404 || res.status === 410) return 0;
// For any other status, only retry if attempts remain // For any other status, only retry if attempts remain
if (attempt < maxAttempts) { if (attempt < maxAttempts) {
@@ -62,13 +95,13 @@ export default async function checkIfListingIsActive(link) {
} }
/** /**
* Exponential backoff delay with cap. * Exponential backoff delay with cap and jitter.
* attempt: 1 -> 500ms, 2 -> 1000ms, 3 -> 2000ms (cap)
* @param {number} attempt 1-based attempt index * @param {number} attempt 1-based attempt index
* @returns {number} delay in ms * @returns {number} delay in ms
*/ */
function backoffDelay(attempt) { function backoffDelay(attempt) {
const base = 500; const base = 500;
const cap = 2000; const cap = 2000;
return Math.min(base * 2 ** (attempt - 1), cap); const delay = Math.min(base * 2 ** (attempt - 1), cap);
return delay + randomBetween(0, 1000);
} }

View File

@@ -48,7 +48,8 @@ export const getListingsKpisForJobIds = (jobIds = []) => {
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) AS activeCount, SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) AS activeCount,
AVG(price) AS avgPrice AVG(price) AS avgPrice
FROM listings FROM listings
WHERE job_id IN (${placeholders})`, WHERE job_id IN (${placeholders})
AND manually_deleted = 0`,
jobIds, jobIds,
)[0] || {}; )[0] || {};
@@ -80,6 +81,7 @@ export const getProviderDistributionForJobIds = (jobIds = []) => {
`SELECT provider, COUNT(*) AS cnt `SELECT provider, COUNT(*) AS cnt
FROM listings FROM listings
WHERE job_id IN (${placeholders}) WHERE job_id IN (${placeholders})
AND manually_deleted = 0
GROUP BY provider GROUP BY provider
ORDER BY cnt DESC`, ORDER BY cnt DESC`,
jobIds, jobIds,
@@ -118,8 +120,8 @@ export const getActiveOrUnknownListings = () => {
return SqliteConnection.query( return SqliteConnection.query(
`SELECT * `SELECT *
FROM listings FROM listings
WHERE is_active is null WHERE (is_active is null OR is_active = 1)
OR is_active = 1 AND manually_deleted = 0
ORDER BY provider`, ORDER BY provider`,
); );
}; };
@@ -306,6 +308,9 @@ export const queryListings = ({
whereParts.push('(wl.id IS NULL)'); whereParts.push('(wl.id IS NULL)');
} }
// Build whereSql (filtering by manually_deleted = 0)
whereParts.push('(l.manually_deleted = 0)');
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''; const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
const whereSqlWithAlias = whereSql const whereSqlWithAlias = whereSql
.replace(/\btitle\b/g, 'l.title') .replace(/\btitle\b/g, 'l.title')
@@ -370,8 +375,8 @@ export const queryListings = ({
export const deleteListingsByJobId = (jobId) => { export const deleteListingsByJobId = (jobId) => {
if (!jobId) return; if (!jobId) return;
return SqliteConnection.execute( return SqliteConnection.execute(
`DELETE `UPDATE listings
FROM listings SET manually_deleted = 1
WHERE job_id = @jobId`, WHERE job_id = @jobId`,
{ jobId }, { jobId },
); );
@@ -387,9 +392,9 @@ export const deleteListingsById = (ids) => {
if (!Array.isArray(ids) || ids.length === 0) return; if (!Array.isArray(ids) || ids.length === 0) return;
const placeholders = ids.map(() => '?').join(','); const placeholders = ids.map(() => '?').join(',');
return SqliteConnection.execute( return SqliteConnection.execute(
`DELETE `UPDATE listings
FROM listings SET manually_deleted = 1
WHERE id IN (${placeholders})`, WHERE id IN (${placeholders})`,
ids, ids,
); );
}; };
@@ -404,6 +409,7 @@ export const getListingsToGeocode = () => {
`SELECT id, address `SELECT id, address
FROM listings FROM listings
WHERE is_active = 1 WHERE is_active = 1
AND manually_deleted = 0
AND address IS NOT NULL AND address IS NOT NULL
AND (latitude IS NULL OR longitude IS NULL)`, AND (latitude IS NULL OR longitude IS NULL)`,
); );
@@ -443,6 +449,7 @@ export const getListingsForMap = ({ jobId, userId = null, isAdmin = false } = {}
'l.latitude != -1', 'l.latitude != -1',
'l.longitude != -1', 'l.longitude != -1',
'l.is_active = 1', 'l.is_active = 1',
'l.manually_deleted = 0',
]; ];
const params = { userId: userId || '__NO_USER__' }; const params = { userId: userId || '__NO_USER__' };
@@ -479,7 +486,7 @@ export const getListingsForMap = ({ jobId, userId = null, isAdmin = false } = {}
* @returns {{title: string|null, address: string|null, price: number|null}[]} * @returns {{title: string|null, address: string|null, price: number|null}[]}
*/ */
export const getAllEntriesFromListings = () => { export const getAllEntriesFromListings = () => {
return SqliteConnection.query(`SELECT title, address, price FROM listings`); return SqliteConnection.query(`SELECT title, address, price FROM listings WHERE manually_deleted = 0`);
}; };
/** /**
@@ -493,6 +500,7 @@ export const getGeocoordinatesByAddress = (address) => {
`SELECT latitude, longitude `SELECT latitude, longitude
FROM listings FROM listings
WHERE address = @address WHERE address = @address
AND manually_deleted = 0
AND latitude IS NOT NULL AND latitude IS NOT NULL
AND longitude IS NOT NULL AND longitude IS NOT NULL
AND latitude != -1 AND latitude != -1
@@ -515,6 +523,7 @@ export const getListingsToCalculateDistance = (jobId) => {
FROM listings FROM listings
WHERE job_id = @jobId WHERE job_id = @jobId
AND is_active = 1 AND is_active = 1
AND manually_deleted = 0
AND latitude IS NOT NULL AND latitude IS NOT NULL
AND longitude IS NOT NULL AND longitude IS NOT NULL
AND distance_to_destination IS NULL`, AND distance_to_destination IS NULL`,
@@ -535,6 +544,7 @@ export const getListingsForUserToCalculateDistance = (userId) => {
JOIN jobs j ON l.job_id = j.id JOIN jobs j ON l.job_id = j.id
WHERE j.user_id = @userId WHERE j.user_id = @userId
AND l.is_active = 1 AND l.is_active = 1
AND l.manually_deleted = 0
AND l.latitude IS NOT NULL AND l.latitude IS NOT NULL
AND l.longitude IS NOT NULL`, AND l.longitude IS NOT NULL`,
{ userId }, { userId },

View File

@@ -0,0 +1,16 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
export function up(db) {
// 1. Add manually_deleted column
db.exec(`ALTER TABLE listings ADD COLUMN manually_deleted INTEGER NOT NULL DEFAULT 0;`);
// 2. Remove change_set column
try {
db.exec(`ALTER TABLE listings DROP COLUMN change_set;`);
} catch {
// if column does not exists for whatever reason
}
}

View File

@@ -90,15 +90,25 @@ export function upsertSettings(settingsMapOrEntry, userId = null) {
: Object.entries(settingsMapOrEntry || {}); : Object.entries(settingsMapOrEntry || {});
for (const [name, rawValue] of entries) { for (const [name, rawValue] of entries) {
const id = nanoid(); if (rawValue === null) {
const create_date = Date.now(); SqliteConnection.execute(
const json = toJson(rawValue); `DELETE FROM settings WHERE name = @name AND (user_id = @userId OR (user_id IS NULL AND @userId IS NULL))`,
SqliteConnection.execute( {
`INSERT INTO settings (id, create_date, name, value, user_id) name,
userId,
},
);
} else {
const id = nanoid();
const create_date = Date.now();
const json = toJson(rawValue);
SqliteConnection.execute(
`INSERT INTO settings (id, create_date, name, value, user_id)
VALUES (@id, @create_date, @name, @value, @userId) VALUES (@id, @create_date, @name, @value, @userId)
ON CONFLICT(name, IFNULL(user_id, 'GLOBAL_SETTING')) DO UPDATE SET value = excluded.value`, ON CONFLICT(name, IFNULL(user_id, 'GLOBAL_SETTING')) DO UPDATE SET value = excluded.value`,
{ id, create_date, name, value: json, userId }, { id, create_date, name, value: json, userId },
); );
}
} }
// keep cache in sync (only for global settings) // keep cache in sync (only for global settings)
if (userId == null) { if (userId == null) {

View File

@@ -95,7 +95,7 @@ function isOneOf(word, arr) {
* @returns {boolean} * @returns {boolean}
*/ */
function nullOrEmpty(val) { function nullOrEmpty(val) {
return val == null || val.length === 0 || val === 'null' || val === 'undefined'; return val == null || val.length === 0;
} }
/** /**

View File

@@ -1,6 +1,6 @@
{ {
"name": "fredy", "name": "fredy",
"version": "19.1.1", "version": "19.2.2",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].", "description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": { "scripts": {
"prepare": "husky", "prepare": "husky",

View File

@@ -41,7 +41,7 @@ Challenges:
_Returns the total number of listings for the given query._ _Returns the total number of listings for the given query._
``` ```
curl -H "User-Agent: ImmoScout_27.3_26.0_._" \ curl -H "User-Agent: ImmoScout_27.12_26.2_._" \
-H "Accept: application/json" \ -H "Accept: application/json" \
"https://api.mobile.immobilienscout24.de/search/total?searchType=region&realestatetype=apartmentrent&pricetype=calculatedtotalrent&geocodes=%2Fde%2Fberlin%2Fberlin" "https://api.mobile.immobilienscout24.de/search/total?searchType=region&realestatetype=apartmentrent&pricetype=calculatedtotalrent&geocodes=%2Fde%2Fberlin%2Fberlin"
``` ```
@@ -63,7 +63,7 @@ _The body is json encoded and contains data specifying additional results (adver
``` ```
curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region&geocodes=%2Fde%2Fberlin%2Fberlin&pagenumber=1' \ curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region&geocodes=%2Fde%2Fberlin%2Fberlin&pagenumber=1' \
-H "Connection: keep-alive" \ -H "Connection: keep-alive" \
-H "User-Agent: ImmoScout_27.3_26.0_._" \ -H "User-Agent: ImmoScout_27.12_26.2_._" \
-H "Accept: application/json" \ -H "Accept: application/json" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"supportedResultListType":[],"userData":{}}' -d '{"supportedResultListType":[],"userData":{}}'
@@ -78,7 +78,7 @@ curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calc
The response contains additional details not included in the listing response. The response contains additional details not included in the listing response.
``` ```
curl -H "User-Agent: ImmoScout_27.3_26.0_._" \ curl -H "User-Agent: ImmoScout_27.12_26.2_._" \
-H "Accept: application/json" \ -H "Accept: application/json" \
"https://api.mobile.immobilienscout24.de/expose/158382494" "https://api.mobile.immobilienscout24.de/expose/158382494"
``` ```

View File

@@ -58,7 +58,7 @@ describe('#immoscout-mobile URL conversion', () => {
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: 'POST',
headers: { headers: {
'User-Agent': 'ImmoScout_27.3_26.0_._', 'User-Agent': 'ImmoScout_27.12_26.2_._',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
@@ -75,7 +75,9 @@ describe('#immoscout-mobile URL conversion', () => {
expect(responseBody.totalResults).to.be.greaterThan(0); expect(responseBody.totalResults).to.be.greaterThan(0);
expect(responseBody.totalResults).to.be.greaterThan(0); expect(responseBody.totalResults).to.be.greaterThan(0);
expect(responseBody.resultListItems.length).to.greaterThan(0); expect(responseBody.resultListItems.length).to.greaterThan(0);
expect(responseBody.resultListItems[0].item.realEstateType).to.equal(type); expect(responseBody.resultListItems.filter((r) => r.type === 'EXPOSE_RESULT')[0].item.realEstateType).to.equal(
type,
);
} }
}); });
}); });

View File

@@ -40,6 +40,8 @@ a:active {
text-decoration: underline; text-decoration: underline;
} }
a {outline : none;}
.semi-icon:not(.semi-tabs-bar .semi-tabs-tab .semi-icon) { .semi-icon:not(.semi-tabs-bar .semi-tabs-tab .semi-icon) {
vertical-align: middle; vertical-align: middle;
} }

View File

@@ -103,6 +103,10 @@ const ListingsGrid = () => {
setPage(_page); setPage(_page);
}; };
const cap = (val) => {
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
};
return ( return (
<div className="listingsGrid"> <div className="listingsGrid">
<div className="listingsGrid__searchbar"> <div className="listingsGrid__searchbar">
@@ -251,11 +255,9 @@ const ListingsGrid = () => {
bodyStyle={{ padding: '12px' }} bodyStyle={{ padding: '12px' }}
> >
<div className="listingsGrid__content"> <div className="listingsGrid__content">
<a href={item.url} target="_blank" rel="noopener noreferrer" className="listingsGrid__titleLink"> <Text strong ellipsis={{ showTooltip: true }} className="listingsGrid__title">
<Text strong ellipsis={{ showTooltip: true }} className="listingsGrid__title"> {cap(item.title)}
{item.title} </Text>
</Text>
</a>
<Space vertical align="start" spacing={2} style={{ width: '100%', marginTop: 8 }}> <Space vertical align="start" spacing={2} style={{ width: '100%', marginTop: 8 }}>
<Text type="secondary" icon={<IconCart />} size="small"> <Text type="secondary" icon={<IconCart />} size="small">
{item.price} {item.price}
@@ -287,15 +289,11 @@ const ListingsGrid = () => {
</Space> </Space>
<Divider margin=".6rem" /> <Divider margin=".6rem" />
<div style={{ display: 'flex', justifyContent: 'space-between' }}> <div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Button <div className="listingsGrid__linkButton">
title="Link to listing" <a href={item.link} target="_blank" rel="noopener noreferrer">
type="primary" <IconLink />
size="small" </a>
onClick={async () => { </div>
window.open(item.link);
}}
icon={<IconLink />}
/>
<Button <Button
title="Remove" title="Remove"

View File

@@ -103,4 +103,17 @@
&__setupButton { &__setupButton {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
&__linkButton {
background: var(--semi-color-fill-0);
font-size: 14px;
line-height: 20px;
font-weight: 600;
height: 24px;
width: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
}
} }

View File

@@ -4,16 +4,21 @@
*/ */
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { renderToString } from 'react-dom/server';
import maplibregl from 'maplibre-gl'; import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import { useSelector, useActions } from '../../services/state/store.js'; import { useSelector, useActions } from '../../services/state/store.js';
import { distanceMeters, generateCircleCoords, getBoundsFromCenter } from './mapUtils.js'; import { distanceMeters, generateCircleCoords, getBoundsFromCenter, getBoundsFromCoords } from './mapUtils.js';
import { Select, Space, Typography, Button, Popover, Divider, Switch, Banner } from '@douyinfe/semi-ui-19'; import { Select, Space, Typography, Button, Popover, Divider, Switch, Banner, Toast } from '@douyinfe/semi-ui-19';
import { IconFilter } from '@douyinfe/semi-icons'; import { IconFilter, IconLink } from '@douyinfe/semi-icons';
import { IconDelete } from '@douyinfe/semi-icons';
import no_image from '../../assets/no_image.jpg'; import no_image from '../../assets/no_image.jpg';
import RangeSlider from 'react-range-slider-input'; import RangeSlider from 'react-range-slider-input';
import 'react-range-slider-input/dist/style.css'; import 'react-range-slider-input/dist/style.css';
import './Map.less'; import './Map.less';
import { xhrDelete } from '../../services/xhr.js';
import { Link } from 'react-router';
const { Text } = Typography; const { Text } = Typography;
@@ -97,6 +102,22 @@ export default function MapView() {
return listings.filter((listing) => listing.price && listing.price >= min && listing.price <= max); return listings.filter((listing) => listing.price && listing.price >= min && listing.price <= max);
}; };
useEffect(() => {
window.deleteListing = async (id) => {
try {
await xhrDelete('/api/listings/', { ids: [id] });
Toast.success('Listing successfully removed');
fetchListings();
} catch (error) {
Toast.error(error.message || 'Error deleting listing');
}
};
return () => {
delete window.deleteListing;
};
}, []);
useEffect(() => { useEffect(() => {
if (map.current) return; if (map.current) return;
@@ -227,26 +248,42 @@ export default function MapView() {
}, [jobId]); }, [jobId]);
useEffect(() => { useEffect(() => {
if (!map.current || !homeAddress?.coords) return; if (!map.current) return;
// We only want to zoom/fly when distanceFilter OR homeAddress actually change, if (homeAddress?.coords) {
// not on every render. useEffect dependency array handles this. // We only want to zoom/fly when distanceFilter OR homeAddress actually change,
if (distanceFilter > 0) { // not on every render. useEffect dependency array handles this.
const bounds = getBoundsFromCenter([homeAddress.coords.lng, homeAddress.coords.lat], distanceFilter); if (distanceFilter > 0) {
const bounds = getBoundsFromCenter([homeAddress.coords.lng, homeAddress.coords.lat], distanceFilter);
map.current.fitBounds(bounds, { map.current.fitBounds(bounds, {
padding: 20, padding: 20,
maxZoom: 15, maxZoom: 15,
duration: 1000, duration: 1000,
}); });
} else {
map.current.flyTo({
center: [homeAddress.coords.lng, homeAddress.coords.lat],
zoom: 12,
duration: 1000,
});
}
} else { } else {
map.current.flyTo({ const filtered = filterListings();
center: [homeAddress.coords.lng, homeAddress.coords.lat], const coords = filtered
zoom: 12, .filter((l) => l.latitude != null && l.longitude != null && l.latitude !== -1 && l.longitude !== -1)
duration: 1000, .map((l) => [l.longitude, l.latitude]);
});
if (coords.length > 0) {
const bounds = getBoundsFromCoords(coords);
map.current.fitBounds(bounds, {
padding: 50,
maxZoom: 15,
duration: 1000,
});
}
} }
}, [homeAddress?.address, distanceFilter]); }, [homeAddress?.address, distanceFilter, listings]);
useEffect(() => { useEffect(() => {
if (!map.current) return; if (!map.current) return;
@@ -325,8 +362,8 @@ export default function MapView() {
? listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1) ? listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1)
: 'N/A'; : 'N/A';
const popup = new maplibregl.Popup({ offset: 25 }).setHTML( const popupContent = `
`<div class="map-popup-content"> <div class="map-popup-content">
<img src="${listing.image_url || no_image}" alt="${listing.title}" /> <img src="${listing.image_url || no_image}" alt="${listing.title}" />
<h4>${listing.title}</h4> <h4>${listing.title}</h4>
<div class="info"> <div class="info">
@@ -334,10 +371,25 @@ export default function MapView() {
<span><strong>Address:</strong> ${listing.address || 'N/A'}</span> <span><strong>Address:</strong> ${listing.address || 'N/A'}</span>
<span><strong>Job:</strong> ${listing.job_name || 'N/A'}</span> <span><strong>Job:</strong> ${listing.job_name || 'N/A'}</span>
<span><strong>Provider:</strong> ${capitalizedProvider}</span> <span><strong>Provider:</strong> ${capitalizedProvider}</span>
<a href="${listing.link}" target="_blank" rel="noopener noreferrer">View Listing</a> <span><strong>Size:</strong> ${listing.size != null ? `${listing.size}` : 'N/A'}</span>
<div style="display: flex; gap: 8px; margin-top: 8px; justify-content: space-between;">
<div class="map-popup-content__linkButton">
<a href="${listing.link}" target="_blank" rel="noopener noreferrer">
${renderToString(<IconLink />)}
</a>
</div>
<button
class="map-popup-content__deleteButton"
title="Remove"
onclick="deleteListing('${listing.id}')"
>
${renderToString(<IconDelete />)}
</button>
</div>
</div> </div>
</div>`, </div>`;
);
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(popupContent);
let color = '#3FB1CE'; // Default blue-ish let color = '#3FB1CE'; // Default blue-ish
if (distanceFilter > 0 && homeAddress?.coords) { if (distanceFilter > 0 && homeAddress?.coords) {
@@ -468,7 +520,12 @@ export default function MapView() {
type="warning" type="warning"
bordered bordered
closeIcon={null} closeIcon={null}
description="You have not set your home address yet. Please do so in the settings to use the distance filter." description={
<span>
You have not set your home address yet. Please do so in the <Link to="/userSettings">user settings</Link>{' '}
to use the distance filter.
</span>
}
/> />
)} )}

View File

@@ -43,10 +43,62 @@
} }
.info { .info {
font-size: 0.9rem; font-size: 0.8rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.2rem; gap: 0.1rem;
}
&__linkButton {
background: var(--semi-color-primary);
color: white;
font-size: 14px;
line-height: 20px;
font-weight: 600;
height: 32px;
width: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
a {
color: white;
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
width: 100%;
height: 100%;
}
&:hover {
background: var(--semi-color-primary-hover);
cursor: pointer;
}
}
&__deleteButton {
background: var(--semi-color-danger);
color: white;
border: none;
font-size: 14px;
height: 32px;
width: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
cursor: pointer;
padding: 0;
&:hover {
background: var(--semi-color-danger-hover);
}
svg {
fill: currentColor;
}
} }
} }

View File

@@ -97,3 +97,34 @@ export const getBoundsFromCenter = (center, radiusInKm, padding = 0.15) => {
[lng + offsetLng, lat + offsetLat], [lng + offsetLng, lat + offsetLat],
]; ];
}; };
/**
* Calculates the bounding box for a set of coordinates.
*
* @param {number[][]} coords - Array of [longitude, latitude] coordinates
* @param {number} [padding=0.1] - Padding to add to the bounds
* @returns {number[][]} Bounding box coordinates [[minLon, minLat], [maxLon, maxLat]]
*/
export const getBoundsFromCoords = (coords, padding = 0.1) => {
if (!coords || coords.length === 0) return null;
let minLng = Infinity;
let minLat = Infinity;
let maxLng = -Infinity;
let maxLat = -Infinity;
coords.forEach(([lng, lat]) => {
if (lng < minLng) minLng = lng;
if (lng > maxLng) maxLng = lng;
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
});
const lngDiff = maxLng - minLng;
const latDiff = maxLat - minLat;
return [
[minLng - lngDiff * padding, minLat - latDiff * padding],
[maxLng + lngDiff * padding, maxLat + latDiff * padding],
];
};

View File

@@ -29,7 +29,7 @@ const UserSettings = () => {
const handleSave = async () => { const handleSave = async () => {
setSaving(true); setSaving(true);
try { try {
const response = await xhrPost('/api/user/settings', { home_address: address }); const response = await xhrPost('/api/user/settings/home-address', { home_address: address });
if (response.status === 200) { if (response.status === 200) {
setCoords(response.json.coords); setCoords(response.json.coords);
await actions.userSettings.getUserSettings(); await actions.userSettings.getUserSettings();
@@ -81,6 +81,7 @@ const UserSettings = () => {
<AutoComplete <AutoComplete
data={dataSource} data={dataSource}
value={address} value={address}
showClear
onChange={(v) => setAddress(v)} onChange={(v) => setAddress(v)}
onSearch={searchAddress} onSearch={searchAddress}
placeholder="Enter your home address" placeholder="Enter your home address"