From c9cc39ae81b0c979162b839f47f241e3c1e300e5 Mon Sep 17 00:00:00 2001
From: Adrian Bach <65734063+realDayaa@users.noreply.github.com>
Date: Wed, 1 Apr 2026 13:34:57 +0200
Subject: [PATCH 1/8] Feature/Kleinanzeigen addresses (#289)
---
lib/api/routes/userSettingsRoute.js | 21 +++
lib/provider/kleinanzeigen.js | 178 +++++++++++++++++-
ui/src/services/state/store.js | 14 ++
.../views/generalSettings/GeneralSettings.jsx | 27 +++
ui/src/views/userSettings/UserSettings.jsx | 28 +++
5 files changed, 265 insertions(+), 3 deletions(-)
diff --git a/lib/api/routes/userSettingsRoute.js b/lib/api/routes/userSettingsRoute.js
index 78631266..9cbbb48c 100644
--- a/lib/api/routes/userSettingsRoute.js
+++ b/lib/api/routes/userSettingsRoute.js
@@ -118,4 +118,25 @@ userSettingsRouter.post('/immoscout-details', async (req, res) => {
}
});
+userSettingsRouter.post('/kleinanzeigen-details', async (req, res) => {
+ const userId = req.session.currentUser;
+ const { kleinanzeigen_details } = req.body;
+
+ const globalSettings = await getSettings();
+ if (globalSettings.demoMode) {
+ res.statusCode = 403;
+ res.send({ error: 'In demo mode, it is not allowed to change settings.' });
+ return;
+ }
+
+ try {
+ upsertSettings({ kleinanzeigen_details: !!kleinanzeigen_details }, userId);
+ res.send({ success: true });
+ } catch (error) {
+ logger.error('Error updating kleinanzeigen details setting', error);
+ res.statusCode = 500;
+ res.send({ error: error.message });
+ }
+});
+
export { userSettingsRouter };
diff --git a/lib/provider/kleinanzeigen.js b/lib/provider/kleinanzeigen.js
index d72c474c..b32c4e09 100755
--- a/lib/provider/kleinanzeigen.js
+++ b/lib/provider/kleinanzeigen.js
@@ -5,15 +5,185 @@
import { buildHash, isOneOf } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
+import Extractor from '../services/extractor/extractor.js';
+import logger from '../services/logger.js';
+import { getUserSettings } from '../services/storage/settingsStorage.js';
+import * as cheerio from 'cheerio';
let appliedBlackList = [];
let appliedBlacklistedDistricts = [];
+let currentUserId = null;
+
+function toAbsoluteLink(link) {
+ if (!link) return null;
+ return link.startsWith('http') ? link : `https://www.kleinanzeigen.de${link}`;
+}
+
+function cleanText(value) {
+ if (value == null) return '';
+ return String(value)
+ .replace(/<[^>]*>/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+
+function buildAddressFromJsonLd(address) {
+ if (!address || typeof address !== 'object') return null;
+
+ const locality = cleanText(address.addressLocality);
+ const region = cleanText(address.addressRegion);
+ const postalCode = cleanText(address.postalCode);
+ const streetAddress = cleanText(address.streetAddress);
+
+ const cityPart = [region, locality].filter(Boolean).join(' - ');
+ const tail = [postalCode, cityPart || locality || region].filter(Boolean).join(' ');
+ const fullAddress = [streetAddress, tail].filter(Boolean).join(', ');
+
+ return fullAddress || null;
+}
+
+function flattenJsonLdNodes(node, acc = []) {
+ if (node == null) return acc;
+
+ if (Array.isArray(node)) {
+ node.forEach((item) => flattenJsonLdNodes(item, acc));
+ return acc;
+ }
+
+ if (typeof node !== 'object') return acc;
+
+ acc.push(node);
+
+ if (Array.isArray(node['@graph'])) {
+ node['@graph'].forEach((item) => flattenJsonLdNodes(item, acc));
+ }
+
+ if (node.mainEntity) {
+ flattenJsonLdNodes(node.mainEntity, acc);
+ }
+
+ if (node.itemOffered) {
+ flattenJsonLdNodes(node.itemOffered, acc);
+ }
+
+ return acc;
+}
+
+function extractDetailFromHtml(html) {
+ const $ = cheerio.load(html);
+ const nodes = [];
+
+ // Prefer the rendered postal address block from the detail page because
+ // it contains the street line that is missing from list results.
+ const streetFromDom = cleanText($('#street-address').first().text());
+ const localityFromDom = cleanText($('#viewad-locality').first().text());
+ const domAddress = [streetFromDom, localityFromDom].filter(Boolean).join(' ');
+
+ $('script[type="application/ld+json"]').each((_, element) => {
+ const content = $(element).text();
+ if (!content) return;
+
+ try {
+ const parsed = JSON.parse(content);
+ flattenJsonLdNodes(parsed, nodes);
+ } catch {
+ // Ignore broken JSON-LD blocks from ads/trackers and keep trying others.
+ }
+ });
+
+ let detailAddress = null;
+ let detailDescription = null;
+
+ if (domAddress) {
+ detailAddress = domAddress;
+ }
+
+ for (const node of nodes) {
+ const candidateAddress = buildAddressFromJsonLd(
+ node.address || node?.itemOffered?.address || node?.offers?.address,
+ );
+ if (!detailAddress && candidateAddress) {
+ detailAddress = candidateAddress;
+ }
+
+ const candidateDescription = cleanText(node.description || node?.itemOffered?.description);
+ if (!detailDescription && candidateDescription) {
+ detailDescription = candidateDescription;
+ }
+
+ if (detailAddress && detailDescription) {
+ break;
+ }
+ }
+
+ return {
+ detailAddress,
+ detailDescription,
+ };
+}
+
+async function enrichListingFromDetails(listing) {
+ const absoluteLink = toAbsoluteLink(listing.link);
+ if (!absoluteLink) return listing;
+
+ try {
+ const response = await fetch(absoluteLink, {
+ headers: {
+ 'User-Agent':
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36',
+ },
+ });
+
+ if (!response.ok) {
+ return {
+ ...listing,
+ link: absoluteLink,
+ };
+ }
+
+ const html = await response.text();
+ const { detailAddress, detailDescription } = extractDetailFromHtml(html);
+
+ return {
+ ...listing,
+ link: absoluteLink,
+ address: detailAddress || listing.address,
+ description: detailDescription || listing.description,
+ };
+ } catch (error) {
+ logger.warn(`Could not fetch Kleinanzeigen detail page for listing '${listing.id}'.`, error?.message || error);
+ return {
+ ...listing,
+ link: absoluteLink,
+ };
+ }
+}
+
+async function getListings(url) {
+ const extractor = new Extractor();
+ await extractor.execute(url, config.waitForSelector);
+ const listings = extractor.parseResponseText(config.crawlContainer, config.crawlFields, url) || [];
+
+ const shouldFetchDetails = currentUserId ? !!getUserSettings(currentUserId).kleinanzeigen_details : false;
+
+ if (!shouldFetchDetails) {
+ return listings.map((listing) => ({
+ ...listing,
+ link: toAbsoluteLink(listing.link) || listing.link,
+ }));
+ }
+
+ const enriched = [];
+ for (const listing of listings) {
+ enriched.push(await enrichListingFromDetails(listing));
+ }
+ return enriched;
+}
function normalize(o) {
const size = o.size || '--- m²';
const id = buildHash(o.id, o.price);
- const link = `https://www.kleinanzeigen.de${o.link}`;
- return Object.assign(o, { id, size, link });
+ return Object.assign(o, { id, size });
}
function applyBlacklist(o) {
@@ -40,12 +210,13 @@ const config = {
address: '.aditem-main--top--left | trim | removeNewline',
image: 'img@src',
},
+ getListings: getListings,
normalize: normalize,
filter: applyBlacklist,
activeTester: checkIfListingIsActive,
};
export const metaInformation = {
- name: 'Ebay Kleinanzeigen',
+ name: 'Kleinanzeigen',
baseUrl: 'https://www.kleinanzeigen.de/',
id: 'kleinanzeigen',
};
@@ -54,5 +225,6 @@ export const init = (sourceConfig, blacklist, blacklistedDistricts) => {
config.url = sourceConfig.url;
appliedBlacklistedDistricts = blacklistedDistricts || [];
appliedBlackList = blacklist || [];
+ currentUserId = sourceConfig.userId || null;
};
export { config };
diff --git a/ui/src/services/state/store.js b/ui/src/services/state/store.js
index d76f1dd4..c3718d32 100644
--- a/ui/src/services/state/store.js
+++ b/ui/src/services/state/store.js
@@ -318,6 +318,20 @@ export const useFredyState = create(
throw Exception;
}
},
+ async setKleinanzeigenDetails(enabled) {
+ try {
+ await xhrPost('/api/user/settings/kleinanzeigen-details', { kleinanzeigen_details: enabled });
+ set((state) => ({
+ userSettings: {
+ ...state.userSettings,
+ settings: { ...state.userSettings.settings, kleinanzeigen_details: enabled },
+ },
+ }));
+ } catch (Exception) {
+ console.error('Error while trying to update kleinanzeigen details setting. Error:', Exception);
+ throw Exception;
+ }
+ },
},
};
diff --git a/ui/src/views/generalSettings/GeneralSettings.jsx b/ui/src/views/generalSettings/GeneralSettings.jsx
index d0da96a2..b6c3ec8f 100644
--- a/ui/src/views/generalSettings/GeneralSettings.jsx
+++ b/ui/src/views/generalSettings/GeneralSettings.jsx
@@ -73,6 +73,7 @@ const GeneralSettings = function GeneralSettings() {
// User settings state
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
const immoscoutDetails = useSelector((state) => state.userSettings.settings.immoscout_details);
+ const kleinanzeigenDetails = useSelector((state) => state.userSettings.settings.kleinanzeigen_details);
const [address, setAddress] = useState(homeAddress?.address || '');
const [coords, setCoords] = useState(homeAddress?.coords || null);
const saving = useIsLoading(actions.userSettings.setHomeAddress);
@@ -465,6 +466,32 @@ const GeneralSettings = function GeneralSettings() {
+
+
+
+ {
+ try {
+ await actions.userSettings.setKleinanzeigenDetails(checked);
+ Toast.success('Kleinanzeigen details setting updated.');
+ } catch {
+ Toast.error('Failed to update setting.');
+ }
+ }}
+ />
+ Fetch detailed Kleinanzeigen listings
+
+
+
}
diff --git a/ui/src/views/userSettings/UserSettings.jsx b/ui/src/views/userSettings/UserSettings.jsx
index 3373374a..845183ef 100644
--- a/ui/src/views/userSettings/UserSettings.jsx
+++ b/ui/src/views/userSettings/UserSettings.jsx
@@ -15,6 +15,7 @@ const UserSettings = () => {
const actions = useActions();
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
const immoscoutDetails = useSelector((state) => state.userSettings.settings.immoscout_details);
+ const kleinanzeigenDetails = useSelector((state) => state.userSettings.settings.kleinanzeigen_details);
const [address, setAddress] = useState(homeAddress?.address || '');
const [coords, setCoords] = useState(homeAddress?.coords || null);
const saving = useIsLoading(actions.userSettings.setHomeAddress);
@@ -112,6 +113,33 @@ const UserSettings = () => {
+
+
+
+ {
+ try {
+ await actions.userSettings.setKleinanzeigenDetails(checked);
+ Toast.success('Kleinanzeigen details setting updated.');
+ } catch {
+ Toast.error('Failed to update setting.');
+ }
+ }}
+ />
+ Fetch detailed Kleinanzeigen listings
+
+
+
} theme="solid" type="primary" onClick={handleSave} loading={saving}>
Save Settings
From d9d9f17a1997e273a977d8b1d7178d73b16ab932 Mon Sep 17 00:00:00 2001
From: orangecoding
Date: Wed, 1 Apr 2026 14:27:19 +0200
Subject: [PATCH 2/8] upgrade dependencies
---
package.json | 2 +-
yarn.lock | 8 ++++----
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/package.json b/package.json
index 2bd1a649..9adc7c9a 100755
--- a/package.json
+++ b/package.json
@@ -69,7 +69,7 @@
"@sendgrid/mail": "8.1.6",
"@turf/boolean-point-in-polygon": "^7.3.4",
"@vitejs/plugin-react": "6.0.1",
- "adm-zip": "^0.5.16",
+ "adm-zip": "^0.5.17",
"better-sqlite3": "^12.8.0",
"body-parser": "2.2.2",
"chart.js": "^4.5.1",
diff --git a/yarn.lock b/yarn.lock
index 96a9e0bf..98f7d71b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2080,10 +2080,10 @@ acorn@^8.16.0:
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a"
integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==
-adm-zip@^0.5.16:
- version "0.5.16"
- resolved "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz"
- integrity sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==
+adm-zip@^0.5.17:
+ version "0.5.17"
+ resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.5.17.tgz#5c0b65f37aeec5c2a94995c024f931f62e4bbc5a"
+ integrity sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==
agent-base@^7.1.0, agent-base@^7.1.2:
version "7.1.4"
From 03434e0aa95e62734b473e6736126e96cb7cd3d3 Mon Sep 17 00:00:00 2001
From: orangecoding
Date: Wed, 1 Apr 2026 19:26:36 +0200
Subject: [PATCH 3/8] immoscout_details -> provider_details
---
lib/api/routes/userSettingsRoute.js | 8 ++++----
.../migrations/sql/13.provider-details.js | 16 ++++++++++++++++
ui/src/services/state/store.js | 8 ++++----
ui/src/views/userSettings/UserSettings.jsx | 16 ++++++++--------
4 files changed, 32 insertions(+), 16 deletions(-)
create mode 100644 lib/services/storage/migrations/sql/13.provider-details.js
diff --git a/lib/api/routes/userSettingsRoute.js b/lib/api/routes/userSettingsRoute.js
index 9cbbb48c..2f085809 100644
--- a/lib/api/routes/userSettingsRoute.js
+++ b/lib/api/routes/userSettingsRoute.js
@@ -97,9 +97,9 @@ userSettingsRouter.post('/news-hash', async (req, res) => {
}
});
-userSettingsRouter.post('/immoscout-details', async (req, res) => {
+userSettingsRouter.post('/provider-details', async (req, res) => {
const userId = req.session.currentUser;
- const { immoscout_details } = req.body;
+ const { provider_details } = req.body;
const globalSettings = await getSettings();
if (globalSettings.demoMode) {
@@ -109,10 +109,10 @@ userSettingsRouter.post('/immoscout-details', async (req, res) => {
}
try {
- upsertSettings({ immoscout_details: !!immoscout_details }, userId);
+ upsertSettings({ provider_details: !!provider_details }, userId);
res.send({ success: true });
} catch (error) {
- logger.error('Error updating immoscout details setting', error);
+ logger.error('Error updating provider details setting', error);
res.statusCode = 500;
res.send({ error: error.message });
}
diff --git a/lib/services/storage/migrations/sql/13.provider-details.js b/lib/services/storage/migrations/sql/13.provider-details.js
new file mode 100644
index 00000000..719555d3
--- /dev/null
+++ b/lib/services/storage/migrations/sql/13.provider-details.js
@@ -0,0 +1,16 @@
+/*
+ * Copyright (c) 2026 by Christian Kellner.
+ * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
+ */
+
+// We have moved the previous immoscout_details setting to provider_details and enable this by default
+export function up(db) {
+ db.exec(`
+ UPDATE settings
+ SET name = 'provider_details', value = true
+ WHERE name = 'immoscout_details'
+ AND NOT EXISTS (
+ SELECT 1 FROM settings WHERE name = 'provider_details'
+ );
+ `);
+}
diff --git a/ui/src/services/state/store.js b/ui/src/services/state/store.js
index c3718d32..145a3f03 100644
--- a/ui/src/services/state/store.js
+++ b/ui/src/services/state/store.js
@@ -304,17 +304,17 @@ export const useFredyState = create(
throw Exception;
}
},
- async setImmoscoutDetails(enabled) {
+ async setProviderDetails(enabled) {
try {
- await xhrPost('/api/user/settings/immoscout-details', { immoscout_details: enabled });
+ await xhrPost('/api/user/settings/provider-details', { provider_details: enabled });
set((state) => ({
userSettings: {
...state.userSettings,
- settings: { ...state.userSettings.settings, immoscout_details: enabled },
+ settings: { ...state.userSettings.settings, provider_details: enabled },
},
}));
} catch (Exception) {
- console.error('Error while trying to update immoscout details setting. Error:', Exception);
+ console.error('Error while trying to update provider details setting. Error:', Exception);
throw Exception;
}
},
diff --git a/ui/src/views/userSettings/UserSettings.jsx b/ui/src/views/userSettings/UserSettings.jsx
index 845183ef..770799a9 100644
--- a/ui/src/views/userSettings/UserSettings.jsx
+++ b/ui/src/views/userSettings/UserSettings.jsx
@@ -14,7 +14,7 @@ import debounce from 'lodash/debounce';
const UserSettings = () => {
const actions = useActions();
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
- const immoscoutDetails = useSelector((state) => state.userSettings.settings.immoscout_details);
+ const providerDetails = useSelector((state) => state.userSettings.settings.provider_details);
const kleinanzeigenDetails = useSelector((state) => state.userSettings.settings.kleinanzeigen_details);
const [address, setAddress] = useState(homeAddress?.address || '');
const [coords, setCoords] = useState(homeAddress?.coords || null);
@@ -87,29 +87,29 @@ const UserSettings = () => {
{
try {
- await actions.userSettings.setImmoscoutDetails(checked);
- Toast.success('ImmoScout details setting updated.');
+ await actions.userSettings.setProviderDetails(checked);
+ Toast.success('Provider details setting updated.');
} catch {
Toast.error('Failed to update setting.');
}
}}
/>
- Fetch detailed ImmoScout listings
+ Fetch detailed Provider listings
From 54ea3edac38abac76f90f84f5957bbd2024b2c31 Mon Sep 17 00:00:00 2001
From: orangecoding
Date: Wed, 1 Apr 2026 20:47:06 +0200
Subject: [PATCH 4/8] fetching details more generic
---
lib/FredyPipelineExecutioner.js | 22 +++
lib/api/routes/userSettingsRoute.js | 21 ---
lib/provider/immoscout.js | 56 +++----
lib/provider/immowelt.js | 41 +++++
lib/provider/kleinanzeigen.js | 30 +---
test/provider/immoscout.test.js | 28 +++-
test/provider/immowelt.test.js | 33 +++-
test/provider/kleinanzeigen.test.js | 31 +++-
ui/src/services/state/store.js | 14 --
.../views/generalSettings/GeneralSettings.jsx | 43 +----
ui/src/views/userSettings/UserSettings.jsx | 152 ------------------
11 files changed, 191 insertions(+), 280 deletions(-)
delete mode 100644 ui/src/views/userSettings/UserSettings.jsx
diff --git a/lib/FredyPipelineExecutioner.js b/lib/FredyPipelineExecutioner.js
index 58221060..ffe36d19 100755
--- a/lib/FredyPipelineExecutioner.js
+++ b/lib/FredyPipelineExecutioner.js
@@ -63,6 +63,7 @@ class FredyPipelineExecutioner {
* @param {(raw:any)=>Listing} providerConfig.normalize Function to convert raw scraped data into a Listing shape.
* @param {(listing:Listing)=>boolean} providerConfig.filter Function to filter out unwanted listings.
* @param {(url:string, waitForSelector?:string)=>Promise|Promise} [providerConfig.getListings] Optional override to fetch listings.
+ * @param {(listing:Listing, browser:any)=>Promise} [providerConfig.fetchDetails] Optional per-listing detail enrichment. Called in parallel for each new listing after deduplication. Receives the shared browser instance. Must always resolve (never reject).
* @param {Object} notificationConfig Notification configuration passed to notification adapters.
* @param {Object} spatialFilter Optional spatial filter configuration.
* @param {string} providerId The ID of the provider currently in use.
@@ -92,6 +93,7 @@ class FredyPipelineExecutioner {
.then(this._normalize.bind(this))
.then(this._filter.bind(this))
.then(this._findNew.bind(this))
+ .then(this._fetchDetails.bind(this))
.then(this._geocode.bind(this))
.then(this._save.bind(this))
.then(this._calculateDistance.bind(this))
@@ -101,6 +103,26 @@ class FredyPipelineExecutioner {
.catch(this._handleError.bind(this));
}
+ /**
+ * Optionally enrich new listings with data from their detail pages.
+ * Only called when the provider config defines a `fetchDetails` function.
+ * Runs all fetches in parallel. Each individual fetch must handle its own errors
+ * and always resolve (never reject) to avoid aborting other listings.
+ *
+ * @param {Listing[]} newListings New listings to enrich.
+ * @returns {Promise} Resolves with enriched listings.
+ */
+ async _fetchDetails(newListings) {
+ if (typeof this._providerConfig.fetchDetails !== 'function') {
+ return newListings;
+ }
+ const userId = getJob(this._jobKey)?.userId;
+ if (!userId || !getUserSettings(userId)?.provider_details) {
+ return newListings;
+ }
+ return Promise.all(newListings.map((listing) => this._providerConfig.fetchDetails(listing, this._browser)));
+ }
+
/**
* Geocode new listings.
*
diff --git a/lib/api/routes/userSettingsRoute.js b/lib/api/routes/userSettingsRoute.js
index 2f085809..64f9f897 100644
--- a/lib/api/routes/userSettingsRoute.js
+++ b/lib/api/routes/userSettingsRoute.js
@@ -118,25 +118,4 @@ userSettingsRouter.post('/provider-details', async (req, res) => {
}
});
-userSettingsRouter.post('/kleinanzeigen-details', async (req, res) => {
- const userId = req.session.currentUser;
- const { kleinanzeigen_details } = req.body;
-
- const globalSettings = await getSettings();
- if (globalSettings.demoMode) {
- res.statusCode = 403;
- res.send({ error: 'In demo mode, it is not allowed to change settings.' });
- return;
- }
-
- try {
- upsertSettings({ kleinanzeigen_details: !!kleinanzeigen_details }, userId);
- res.send({ success: true });
- } catch (error) {
- logger.error('Error updating kleinanzeigen details setting', error);
- res.statusCode = 500;
- res.send({ error: error.message });
- }
-});
-
export { userSettingsRouter };
diff --git a/lib/provider/immoscout.js b/lib/provider/immoscout.js
index fcc48ae4..2a529671 100644
--- a/lib/provider/immoscout.js
+++ b/lib/provider/immoscout.js
@@ -46,9 +46,7 @@ import {
convertWebToMobile,
} from '../services/immoscout/immoscout-web-translator.js';
import logger from '../services/logger.js';
-import { getUserSettings } from '../services/storage/settingsStorage.js';
let appliedBlackList = [];
-let currentUserId = null;
async function getListings(url) {
const response = await fetch(url, {
@@ -68,42 +66,40 @@ async function getListings(url) {
}
const responseBody = await response.json();
- return Promise.all(
- responseBody.resultListItems
- .filter((item) => item.type === 'EXPOSE_RESULT')
- .map(async (expose) => {
- const item = expose.item;
- const [price, size] = item.attributes;
- const image = item?.titlePicture?.full ?? item?.titlePicture?.preview ?? null;
- let listing = {
- id: item.id,
- price: price?.value,
- size: size?.value,
- title: item.title,
- link: `${metaInformation.baseUrl}expose/${item.id}`,
- address: item.address?.line,
- image,
- };
- if (currentUserId) {
- const userSettings = getUserSettings(currentUserId);
- if (userSettings.immoscout_details) {
- return await pushDetails(listing);
- }
- }
- return listing;
- }),
- );
+ return responseBody.resultListItems
+ .filter((item) => item.type === 'EXPOSE_RESULT')
+ .map((expose) => {
+ const item = expose.item;
+ const [price, size] = item.attributes;
+ const image = item?.titlePicture?.full ?? item?.titlePicture?.preview ?? null;
+ return {
+ id: item.id,
+ price: price?.value,
+ size: size?.value,
+ title: item.title,
+ link: `${metaInformation.baseUrl}expose/${item.id}`,
+ address: item.address?.line,
+ image,
+ };
+ });
+}
+
+async function fetchDetails(listing) {
+ return pushDetails(listing);
}
async function pushDetails(listing) {
- const detailed = await fetch(`https://api.mobile.immobilienscout24.de/expose/${listing.id}`, {
+ const exposeId = listing.link?.split('/').pop();
+ const detailed = await fetch(`https://api.mobile.immobilienscout24.de/expose/${exposeId}`, {
headers: {
'User-Agent': 'ImmoScout_27.3_26.0_._',
'Content-Type': 'application/json',
},
});
if (!detailed.ok) {
- logger.error('Error fetching listing details from ImmoScout Mobile API:', detailed.statusText);
+ logger.warn(
+ `Error fetching listing details from ImmoScout Mobile API for id: ${exposeId} Status: ${detailed.statusText}`,
+ );
return listing;
}
const detailBody = await detailed.json();
@@ -196,13 +192,13 @@ const config = {
normalize: normalize,
filter: applyBlacklist,
getListings: getListings,
+ fetchDetails: fetchDetails,
activeTester: isListingActive,
};
export const init = (sourceConfig, blacklist) => {
config.enabled = sourceConfig.enabled;
config.url = convertWebToMobile(sourceConfig.url);
appliedBlackList = blacklist || [];
- currentUserId = sourceConfig.userId || null;
};
export const metaInformation = {
name: 'Immoscout',
diff --git a/lib/provider/immowelt.js b/lib/provider/immowelt.js
index df50c4be..ce05c8b1 100755
--- a/lib/provider/immowelt.js
+++ b/lib/provider/immowelt.js
@@ -5,9 +5,49 @@
import { buildHash, isOneOf } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
+import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js';
+import * as cheerio from 'cheerio';
+import logger from '../services/logger.js';
let appliedBlackList = [];
+async function fetchDetails(listing, browser) {
+ try {
+ const html = await puppeteerExtractor(listing.link, null, { browser });
+ if (!html) return listing;
+
+ const $ = cheerio.load(html);
+ const nextDataRaw = $('#__NEXT_DATA__').text();
+ if (!nextDataRaw) return listing;
+
+ const classified = JSON.parse(nextDataRaw)?.props?.pageProps?.classified;
+ if (!classified) return listing;
+
+ const description = (classified.Texts || [])
+ .map((t) => [t.Title, t.Content].filter(Boolean).join('\n'))
+ .filter(Boolean)
+ .join('\n\n');
+
+ const addr = classified.EstateAddress;
+ let address = listing.address;
+ if (addr) {
+ const street = [addr.Street, addr.HouseNumber].filter(Boolean).join(' ');
+ const cityLine = [addr.ZipCode, addr.District || addr.City].filter(Boolean).join(' ');
+ const full = [street, cityLine].filter(Boolean).join(', ');
+ if (full) address = full;
+ }
+
+ return {
+ ...listing,
+ address,
+ description: description || listing.description,
+ };
+ } catch (error) {
+ logger.warn(`Could not fetch immowelt detail page for listing '${listing.id}'.`, error?.message || error);
+ return listing;
+ }
+}
+
function normalize(o) {
const id = buildHash(o.id, o.price);
return Object.assign(o, { id });
@@ -37,6 +77,7 @@ const config = {
},
normalize: normalize,
filter: applyBlacklist,
+ fetchDetails: fetchDetails,
activeTester: checkIfListingIsActive,
};
export const init = (sourceConfig, blacklist) => {
diff --git a/lib/provider/kleinanzeigen.js b/lib/provider/kleinanzeigen.js
index b32c4e09..69d91430 100755
--- a/lib/provider/kleinanzeigen.js
+++ b/lib/provider/kleinanzeigen.js
@@ -5,14 +5,11 @@
import { buildHash, isOneOf } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
-import Extractor from '../services/extractor/extractor.js';
import logger from '../services/logger.js';
-import { getUserSettings } from '../services/storage/settingsStorage.js';
import * as cheerio from 'cheerio';
let appliedBlackList = [];
let appliedBlacklistedDistricts = [];
-let currentUserId = null;
function toAbsoluteLink(link) {
if (!link) return null;
@@ -159,31 +156,15 @@ async function enrichListingFromDetails(listing) {
}
}
-async function getListings(url) {
- const extractor = new Extractor();
- await extractor.execute(url, config.waitForSelector);
- const listings = extractor.parseResponseText(config.crawlContainer, config.crawlFields, url) || [];
-
- const shouldFetchDetails = currentUserId ? !!getUserSettings(currentUserId).kleinanzeigen_details : false;
-
- if (!shouldFetchDetails) {
- return listings.map((listing) => ({
- ...listing,
- link: toAbsoluteLink(listing.link) || listing.link,
- }));
- }
-
- const enriched = [];
- for (const listing of listings) {
- enriched.push(await enrichListingFromDetails(listing));
- }
- return enriched;
+async function fetchDetails(listing) {
+ return enrichListingFromDetails(listing);
}
function normalize(o) {
const size = o.size || '--- m²';
const id = buildHash(o.id, o.price);
- return Object.assign(o, { id, size });
+ const link = toAbsoluteLink(o.link) || o.link;
+ return Object.assign(o, { id, size, link });
}
function applyBlacklist(o) {
@@ -210,7 +191,7 @@ const config = {
address: '.aditem-main--top--left | trim | removeNewline',
image: 'img@src',
},
- getListings: getListings,
+ fetchDetails,
normalize: normalize,
filter: applyBlacklist,
activeTester: checkIfListingIsActive,
@@ -225,6 +206,5 @@ export const init = (sourceConfig, blacklist, blacklistedDistricts) => {
config.url = sourceConfig.url;
appliedBlacklistedDistricts = blacklistedDistricts || [];
appliedBlackList = blacklist || [];
- currentUserId = sourceConfig.userId || null;
};
export { config };
diff --git a/test/provider/immoscout.test.js b/test/provider/immoscout.test.js
index 3067c614..a9371710 100644
--- a/test/provider/immoscout.test.js
+++ b/test/provider/immoscout.test.js
@@ -3,11 +3,12 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
-import { expect } from 'vitest';
+import { expect, vi } from 'vitest';
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { mockFredy, providerConfig } from '../utils.js';
import { get } from '../mocks/mockNotification.js';
import * as provider from '../../lib/provider/immoscout.js';
+import * as mockStore from '../mocks/mockStore.js';
describe('#immoscout provider testsuite()', () => {
provider.init(providerConfig.immoscout, [], []);
@@ -37,4 +38,29 @@ describe('#immoscout provider testsuite()', () => {
});
});
});
+
+ describe('with provider_details enabled', () => {
+ beforeEach(() => {
+ vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: true });
+ vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should enrich listings with details', async () => {
+ const Fredy = await mockFredy();
+ provider.init(providerConfig.immoscout, [], []);
+ const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, '', {
+ checkAndAddEntry: () => false,
+ });
+ const listings = await fredy.execute();
+ expect(listings).toBeInstanceOf(Array);
+ listings.forEach((listing) => {
+ expect(listing.description).toBeTypeOf('string');
+ expect(listing.description).not.toBe('');
+ });
+ });
+ });
});
diff --git a/test/provider/immowelt.test.js b/test/provider/immowelt.test.js
index ad2fe137..6ad73f81 100644
--- a/test/provider/immowelt.test.js
+++ b/test/provider/immowelt.test.js
@@ -6,8 +6,9 @@
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 { expect, vi } from 'vitest';
import * as provider from '../../lib/provider/immowelt.js';
+import * as mockStore from '../mocks/mockStore.js';
describe('#immowelt testsuite()', () => {
it('should test immowelt provider', async () => {
@@ -37,4 +38,34 @@ describe('#immowelt testsuite()', () => {
expect(notify.address).not.toBe('');
});
});
+
+ describe('with provider_details enabled', () => {
+ beforeEach(() => {
+ vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: true });
+ vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should enrich listings with details', async () => {
+ const Fredy = await mockFredy();
+ provider.init(providerConfig.immowelt, [], []);
+ const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immowelt', {
+ checkAndAddEntry: () => false,
+ });
+ const listings = await fredy.execute();
+ expect(listings).toBeInstanceOf(Array);
+ listings.forEach((listing) => {
+ expect(listing.link).toContain('https://www.immowelt.de');
+ expect(listing.address).toBeTypeOf('string');
+ expect(listing.address).not.toBe('');
+ // description is enriched from the detail page; falls back gracefully if blocked
+ if (listing.description != null) {
+ expect(listing.description).toBeTypeOf('string');
+ }
+ });
+ });
+ });
});
diff --git a/test/provider/kleinanzeigen.test.js b/test/provider/kleinanzeigen.test.js
index d7dd0820..9f5c1ccf 100644
--- a/test/provider/kleinanzeigen.test.js
+++ b/test/provider/kleinanzeigen.test.js
@@ -6,8 +6,9 @@
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 { expect, vi } from 'vitest';
import * as provider from '../../lib/provider/kleinanzeigen.js';
+import * as mockStore from '../mocks/mockStore.js';
describe('#kleinanzeigen testsuite()', () => {
it('should test kleinanzeigen provider', async () => {
@@ -42,4 +43,32 @@ describe('#kleinanzeigen testsuite()', () => {
});
});
});
+
+ describe('with provider_details enabled', () => {
+ beforeEach(() => {
+ vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: true });
+ vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should enrich listings with details', async () => {
+ const Fredy = await mockFredy();
+ provider.init(providerConfig.kleinanzeigen, [], []);
+ const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'kleinanzeigen', {
+ checkAndAddEntry: () => false,
+ });
+ const listings = await fredy.execute();
+ expect(listings).toBeInstanceOf(Array);
+ listings.forEach((listing) => {
+ expect(listing.link).toContain('https://www.kleinanzeigen.de');
+ expect(listing.address).toBeTypeOf('string');
+ expect(listing.address).not.toBe('');
+ expect(listing.description).toBeTypeOf('string');
+ expect(listing.description).not.toBe('');
+ });
+ });
+ });
});
diff --git a/ui/src/services/state/store.js b/ui/src/services/state/store.js
index 145a3f03..4ffa93af 100644
--- a/ui/src/services/state/store.js
+++ b/ui/src/services/state/store.js
@@ -318,20 +318,6 @@ export const useFredyState = create(
throw Exception;
}
},
- async setKleinanzeigenDetails(enabled) {
- try {
- await xhrPost('/api/user/settings/kleinanzeigen-details', { kleinanzeigen_details: enabled });
- set((state) => ({
- userSettings: {
- ...state.userSettings,
- settings: { ...state.userSettings.settings, kleinanzeigen_details: enabled },
- },
- }));
- } catch (Exception) {
- console.error('Error while trying to update kleinanzeigen details setting. Error:', Exception);
- throw Exception;
- }
- },
},
};
diff --git a/ui/src/views/generalSettings/GeneralSettings.jsx b/ui/src/views/generalSettings/GeneralSettings.jsx
index b6c3ec8f..b0d70480 100644
--- a/ui/src/views/generalSettings/GeneralSettings.jsx
+++ b/ui/src/views/generalSettings/GeneralSettings.jsx
@@ -72,8 +72,7 @@ const GeneralSettings = function GeneralSettings() {
// User settings state
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
- const immoscoutDetails = useSelector((state) => state.userSettings.settings.immoscout_details);
- const kleinanzeigenDetails = useSelector((state) => state.userSettings.settings.kleinanzeigen_details);
+ const providerDetails = useSelector((state) => state.userSettings.settings.provider_details);
const [address, setAddress] = useState(homeAddress?.address || '');
const [coords, setCoords] = useState(homeAddress?.coords || null);
const saving = useIsLoading(actions.userSettings.setHomeAddress);
@@ -441,54 +440,28 @@ const GeneralSettings = function GeneralSettings() {
{
try {
- await actions.userSettings.setImmoscoutDetails(checked);
- Toast.success('ImmoScout details setting updated.');
+ await actions.userSettings.setProviderDetails(checked);
+ Toast.success('Provider details setting updated.');
} catch {
Toast.error('Failed to update setting.');
}
}}
/>
- Fetch detailed ImmoScout listings
-
-
-
-
-
-
- {
- try {
- await actions.userSettings.setKleinanzeigenDetails(checked);
- Toast.success('Kleinanzeigen details setting updated.');
- } catch {
- Toast.error('Failed to update setting.');
- }
- }}
- />
- Fetch detailed Kleinanzeigen listings
+ Fetch detailed Provider listings
diff --git a/ui/src/views/userSettings/UserSettings.jsx b/ui/src/views/userSettings/UserSettings.jsx
deleted file mode 100644
index 770799a9..00000000
--- a/ui/src/views/userSettings/UserSettings.jsx
+++ /dev/null
@@ -1,152 +0,0 @@
-/*
- * Copyright (c) 2026 by Christian Kellner.
- * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
- */
-
-import { useEffect, useState, useMemo } from 'react';
-import { Divider, Button, AutoComplete, Toast, Banner, Switch } from '@douyinfe/semi-ui-19';
-import { IconSave, IconHome, IconSearch } from '@douyinfe/semi-icons';
-import { useSelector, useActions, useIsLoading } from '../../services/state/store';
-import { xhrGet } from '../../services/xhr';
-import { SegmentPart } from '../../components/segment/SegmentPart';
-import debounce from 'lodash/debounce';
-
-const UserSettings = () => {
- const actions = useActions();
- const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
- const providerDetails = useSelector((state) => state.userSettings.settings.provider_details);
- const kleinanzeigenDetails = useSelector((state) => state.userSettings.settings.kleinanzeigen_details);
- const [address, setAddress] = useState(homeAddress?.address || '');
- const [coords, setCoords] = useState(homeAddress?.coords || null);
- const saving = useIsLoading(actions.userSettings.setHomeAddress);
- const [dataSource, setDataSource] = useState([]);
-
- useEffect(() => {
- setAddress(homeAddress?.address || '');
- setCoords(homeAddress?.coords || null);
- }, [homeAddress]);
-
- const handleSave = async () => {
- try {
- const responseJson = await actions.userSettings.setHomeAddress(address);
- setCoords(responseJson.coords);
- await actions.userSettings.getUserSettings();
- Toast.success(
- 'Settings saved successfully. We will now start calculating distances for you. This may take a while and runs in the background.',
- );
- } catch (error) {
- Toast.error(error.json?.error || 'Error while saving settings');
- }
- };
-
- const debouncedSearch = useMemo(
- () =>
- debounce((value) => {
- xhrGet(`/api/user/settings/autocomplete?q=${encodeURIComponent(value)}`)
- .then((response) => {
- if (response.status === 200) {
- setDataSource(response.json);
- }
- })
- .catch(() => {
- // Silently fail for autocomplete
- });
- }, 300),
- [],
- );
-
- const searchAddress = (value) => {
- if (!value) {
- setDataSource([]);
- return;
- }
- debouncedSearch(value);
- };
-
- return (
-
-
-
-
setAddress(v)}
- onSearch={searchAddress}
- placeholder="Enter your home address"
- style={{ width: '100%' }}
- />
- {coords && coords.lat === -1 && (
-
- )}
-
-
-
-
-
-
- {
- try {
- await actions.userSettings.setProviderDetails(checked);
- Toast.success('Provider details setting updated.');
- } catch {
- Toast.error('Failed to update setting.');
- }
- }}
- />
- Fetch detailed Provider listings
-
-
-
-
-
-
- {
- try {
- await actions.userSettings.setKleinanzeigenDetails(checked);
- Toast.success('Kleinanzeigen details setting updated.');
- } catch {
- Toast.error('Failed to update setting.');
- }
- }}
- />
- Fetch detailed Kleinanzeigen listings
-
-
-
-
- } theme="solid" type="primary" onClick={handleSave} loading={saving}>
- Save Settings
-
-
-
- );
-};
-
-export default UserSettings;
From b5d55f3b84f7f5d241dea4d697236edb25719ed1 Mon Sep 17 00:00:00 2001
From: orangecoding
Date: Thu, 2 Apr 2026 07:52:41 +0200
Subject: [PATCH 5/8] removing claude action
---
.github/workflows/claude-code-review.yml | 44 ---------------------
.github/workflows/claude.yml | 50 ------------------------
2 files changed, 94 deletions(-)
delete mode 100644 .github/workflows/claude-code-review.yml
delete mode 100644 .github/workflows/claude.yml
diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml
deleted file mode 100644
index b5e8cfd4..00000000
--- a/.github/workflows/claude-code-review.yml
+++ /dev/null
@@ -1,44 +0,0 @@
-name: Claude Code Review
-
-on:
- pull_request:
- types: [opened, synchronize, ready_for_review, reopened]
- # Optional: Only run on specific file changes
- # paths:
- # - "src/**/*.ts"
- # - "src/**/*.tsx"
- # - "src/**/*.js"
- # - "src/**/*.jsx"
-
-jobs:
- claude-review:
- # Optional: Filter by PR author
- # if: |
- # github.event.pull_request.user.login == 'external-contributor' ||
- # github.event.pull_request.user.login == 'new-developer' ||
- # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
-
- runs-on: ubuntu-latest
- permissions:
- contents: read
- pull-requests: read
- issues: read
- id-token: write
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@v4
- with:
- fetch-depth: 1
-
- - name: Run Claude Code Review
- id: claude-review
- uses: anthropics/claude-code-action@v1
- with:
- claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
- plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
- plugins: 'code-review@claude-code-plugins'
- prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
- # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
- # or https://code.claude.com/docs/en/cli-reference for available options
-
diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml
deleted file mode 100644
index 1bf05901..00000000
--- a/.github/workflows/claude.yml
+++ /dev/null
@@ -1,50 +0,0 @@
-name: Claude Code
-
-on:
- issue_comment:
- types: [created]
- pull_request_review_comment:
- types: [created]
- issues:
- types: [opened, assigned]
- pull_request_review:
- types: [submitted]
-
-jobs:
- claude:
- if: |
- (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
- (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
- (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
- (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
- runs-on: ubuntu-latest
- permissions:
- contents: read
- pull-requests: write
- issues: read
- id-token: write
- actions: read # Required for Claude to read CI results on PRs
- steps:
- - name: Checkout repository
- uses: actions/checkout@v4
- with:
- fetch-depth: 1
-
- - name: Run Claude Code
- id: claude
- uses: anthropics/claude-code-action@v1
- with:
- claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
-
- # This is an optional setting that allows Claude to read CI results on PRs
- additional_permissions: |
- actions: read
-
- # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
- # prompt: 'Update the pull request description to include a summary of changes.'
-
- # Optional: Add claude_args to customize behavior and configuration
- # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
- # or https://code.claude.com/docs/en/cli-reference for available options
- # claude_args: '--allowed-tools Bash(gh pr:*)'
-
From 4c2e33de5cfdeff7cec1965e0265b7f16c36310c Mon Sep 17 00:00:00 2001
From: orangecoding
Date: Thu, 2 Apr 2026 08:27:19 +0200
Subject: [PATCH 6/8] fixing sparkassen selector
---
lib/provider/sparkasse.js | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/lib/provider/sparkasse.js b/lib/provider/sparkasse.js
index bc5aa2a5..ca93bd09 100755
--- a/lib/provider/sparkasse.js
+++ b/lib/provider/sparkasse.js
@@ -10,7 +10,7 @@ let appliedBlackList = [];
function normalize(o) {
const originalId = o.id.split('/').pop().replace('.html', '');
const id = buildHash(originalId, o.price);
- const size = o.size?.replace(' Wohnfläche', '') ?? null;
+ const size = o.size?.replace(' Wohnfläche', '').replace(' m²', 'm²') ?? null;
const title = o.title || 'No title available';
const link = o.link != null ? `https://immobilien.sparkasse.de${o.link}` : config.url;
return Object.assign(o, { id, size, title, link });
@@ -22,17 +22,17 @@ function applyBlacklist(o) {
}
const config = {
url: null,
- crawlContainer: '.estate-list-item-row',
+ crawlContainer: 'div[data-testid="estate-link"]',
sortByDateParam: 'sortBy=date_desc',
waitForSelector: 'body',
crawlFields: {
- id: 'div[data-testid="estate-link"] a@href',
+ id: 'a@href',
title: 'h3 | trim',
price: '.estate-list-price | trim',
- size: '.estate-mainfact:first-child span | trim',
+ size: '.estate-mainfact span | trim',
address: 'h6 | trim',
- image: '.estate-list-item-image-container img@src',
- link: 'div[data-testid="estate-link"] a@href',
+ image: 'img@src',
+ link: 'a@href',
},
normalize: normalize,
filter: applyBlacklist,
From 570ce635541a48d3dce0a3ed97f7de70dd7a6248 Mon Sep 17 00:00:00 2001
From: orangecoding
Date: Thu, 2 Apr 2026 19:57:10 +0200
Subject: [PATCH 7/8] improvements
---
lib/FredyPipelineExecutioner.js | 7 +-
lib/provider/immobilienDe.js | 67 +++++++++++++--
lib/provider/kleinanzeigen.js | 28 ++-----
lib/provider/sparkasse.js | 49 +++++++++++
lib/provider/wgGesucht.js | 26 ++++++
lib/services/extractor/botPrevention.js | 82 +++++++++++++++----
lib/services/extractor/puppeteerExtractor.js | 1 +
.../migrations/sql/13.provider-details.js | 3 +-
package.json | 1 -
test/provider/einsAImmobilien.test.js | 6 +-
test/provider/immobilienDe.test.js | 81 ++++++++++++------
test/provider/immoscout.test.js | 7 +-
test/provider/immoswp.test.js | 7 +-
test/provider/immowelt.test.js | 4 +
test/provider/kleinanzeigen.test.js | 7 +-
test/provider/mcMakler.test.js | 4 +
test/provider/neubauKompass.test.js | 7 +-
test/provider/ohneMakler.test.js | 4 +
test/provider/regionalimmobilien24.test.js | 4 +
test/provider/sparkasse.test.js | 38 ++++++++-
test/provider/wgGesucht.test.js | 36 +++++++-
test/provider/wohnungsboerse.test.js | 7 +-
ui/src/components/grid/jobs/JobGrid.jsx | 2 +-
.../components/grid/listings/ListingsGrid.jsx | 2 +-
ui/src/utils.js | 17 ++++
.../views/generalSettings/GeneralSettings.jsx | 2 +-
yarn.lock | 7 +-
27 files changed, 422 insertions(+), 84 deletions(-)
create mode 100644 ui/src/utils.js
diff --git a/lib/FredyPipelineExecutioner.js b/lib/FredyPipelineExecutioner.js
index ffe36d19..3d221d05 100755
--- a/lib/FredyPipelineExecutioner.js
+++ b/lib/FredyPipelineExecutioner.js
@@ -120,7 +120,12 @@ class FredyPipelineExecutioner {
if (!userId || !getUserSettings(userId)?.provider_details) {
return newListings;
}
- return Promise.all(newListings.map((listing) => this._providerConfig.fetchDetails(listing, this._browser)));
+ const listingsToEnrich = process.env.NODE_ENV === 'test' ? newListings.slice(0, 1) : newListings;
+ const enriched = [];
+ for (const listing of listingsToEnrich) {
+ enriched.push(await this._providerConfig.fetchDetails(listing, this._browser));
+ }
+ return enriched;
}
/**
diff --git a/lib/provider/immobilienDe.js b/lib/provider/immobilienDe.js
index f509fad9..ea25b6da 100644
--- a/lib/provider/immobilienDe.js
+++ b/lib/provider/immobilienDe.js
@@ -5,6 +5,9 @@
import { buildHash, isOneOf } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
+import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js';
+import * as cheerio from 'cheerio';
+import logger from '../services/logger.js';
let appliedBlackList = [];
@@ -18,6 +21,51 @@ function parseId(shortenedLink) {
return shortenedLink.substring(shortenedLink.lastIndexOf('/') + 1);
}
+async function fetchDetails(listing, browser) {
+ try {
+ const html = await puppeteerExtractor(listing.link, null, { browser });
+ if (!html) return listing;
+
+ const $ = cheerio.load(html);
+
+ // Try JSON-LD first
+ let description = null;
+ let address = listing.address;
+ $('script[type="application/ld+json"]').each((_, el) => {
+ if (description) return;
+ try {
+ const data = JSON.parse($(el).text());
+ const nodes = Array.isArray(data) ? data : [data];
+ for (const node of nodes) {
+ if (node.description && !description) description = String(node.description).replace(/\s+/g, ' ').trim();
+ const addr = node.address || node?.mainEntity?.address;
+ if (addr && addr.streetAddress && address === listing.address) {
+ const parts = [addr.streetAddress, addr.postalCode, addr.addressLocality].filter(Boolean);
+ if (parts.length) address = parts.join(' ');
+ }
+ }
+ } catch {
+ // ignore malformed JSON-LD
+ }
+ });
+
+ // Fallback: common description selectors used by immobilien.de
+ if (!description) {
+ const sel = ['.beschreibung', '.freitext', '.objektbeschreibung', '.description'].find((s) => $(s).length > 0);
+ if (sel) description = $(sel).text().replace(/\s+/g, ' ').trim();
+ }
+
+ return {
+ ...listing,
+ address,
+ description: description || listing.description,
+ };
+ } catch (error) {
+ logger.warn(`Could not fetch immobilien.de detail page for listing '${listing.id}'.`, error?.message || error);
+ return listing;
+ }
+}
+
function normalize(o) {
const baseUrl = 'https://www.immobilien.de';
const size = o.size || null;
@@ -25,8 +73,8 @@ function normalize(o) {
const title = o.title || 'No title available';
const address = o.address || null;
const shortLink = shortenLink(o.link);
- const link = baseUrl + shortLink;
- const image = baseUrl + o.image;
+ const link = shortLink ? (shortLink.startsWith('http') ? shortLink : baseUrl + shortLink) : baseUrl;
+ const image = o.image ? (o.image.startsWith('http') ? o.image : baseUrl + o.image) : null;
const id = buildHash(parseId(shortLink), o.price);
return Object.assign(o, { id, price, size, title, address, link, image });
}
@@ -39,21 +87,22 @@ function applyBlacklist(o) {
const config = {
url: null,
- crawlContainer: 'a:has(div.list_entry)',
+ crawlContainer: 'a.lr-card',
sortByDateParam: 'sort_col=*created_ts&sort_dir=desc',
- waitForSelector: 'body',
+ waitForSelector: 'a.lr-card',
crawlFields: {
id: '@href', //will be transformed later
- price: '.immo_preis .label_info',
- size: '.flaeche .label_info | removeNewline | trim',
- title: 'h3 span',
+ price: '.lr-card__price-amount | trim',
+ size: '.lr-card__fact:has(.lr-card__fact-label:contains("Fläche")) .lr-card__fact-value | trim',
+ title: '.lr-card__title | trim',
description: '.description | trim',
link: '@href',
- address: '.place',
- image: 'img@src',
+ address: '.lr-card__address span | trim',
+ image: 'img.lr-card__gallery-img@src',
},
normalize: normalize,
filter: applyBlacklist,
+ fetchDetails,
activeTester: checkIfListingIsActive,
};
export const init = (sourceConfig, blacklist) => {
diff --git a/lib/provider/kleinanzeigen.js b/lib/provider/kleinanzeigen.js
index 69d91430..cc5cdaa2 100755
--- a/lib/provider/kleinanzeigen.js
+++ b/lib/provider/kleinanzeigen.js
@@ -5,6 +5,7 @@
import { buildHash, isOneOf } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
+import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js';
import logger from '../services/logger.js';
import * as cheerio from 'cheerio';
@@ -119,26 +120,14 @@ function extractDetailFromHtml(html) {
};
}
-async function enrichListingFromDetails(listing) {
+async function enrichListingFromDetails(listing, browser) {
const absoluteLink = toAbsoluteLink(listing.link);
if (!absoluteLink) return listing;
try {
- const response = await fetch(absoluteLink, {
- headers: {
- 'User-Agent':
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36',
- },
- });
-
- if (!response.ok) {
- return {
- ...listing,
- link: absoluteLink,
- };
- }
+ const html = await puppeteerExtractor(absoluteLink, null, { browser });
+ if (!html) return { ...listing, link: absoluteLink };
- const html = await response.text();
const { detailAddress, detailDescription } = extractDetailFromHtml(html);
return {
@@ -149,15 +138,12 @@ async function enrichListingFromDetails(listing) {
};
} catch (error) {
logger.warn(`Could not fetch Kleinanzeigen detail page for listing '${listing.id}'.`, error?.message || error);
- return {
- ...listing,
- link: absoluteLink,
- };
+ return { ...listing, link: absoluteLink };
}
}
-async function fetchDetails(listing) {
- return enrichListingFromDetails(listing);
+async function fetchDetails(listing, browser) {
+ return enrichListingFromDetails(listing, browser);
}
function normalize(o) {
diff --git a/lib/provider/sparkasse.js b/lib/provider/sparkasse.js
index ca93bd09..8f4af8c6 100755
--- a/lib/provider/sparkasse.js
+++ b/lib/provider/sparkasse.js
@@ -5,8 +5,56 @@
import { isOneOf, buildHash } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
+import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js';
+import * as cheerio from 'cheerio';
+import logger from '../services/logger.js';
+
let appliedBlackList = [];
+async function fetchDetails(listing, browser) {
+ try {
+ const html = await puppeteerExtractor(listing.link, 'body', { browser });
+
+ const $ = cheerio.load(html);
+ const nextDataRaw = $('#__NEXT_DATA__').text;
+ if (!nextDataRaw) return listing;
+
+ const estate = JSON.parse(nextDataRaw)?.props?.pageProps?.estate;
+ if (!estate) return listing;
+
+ const description = (estate.frontendItems || [])
+ .map((item) => {
+ const texts = (item.contents || [])
+ .filter((c) => c.type === 'contentBoxes')
+ .flatMap((c) => c.data || [])
+ .filter((d) => d.type === 'text' && d.content)
+ .map((d) => d.content);
+ if (!texts.length) return null;
+ return [item.label, ...texts].filter(Boolean).join('\n');
+ })
+ .filter(Boolean)
+ .join('\n\n');
+
+ const addr = estate.address;
+ let address = listing.address;
+ if (addr) {
+ const street = [addr.street, addr.streetNumber].filter(Boolean).join(' ');
+ const cityLine = [addr.zip, addr.city].filter(Boolean).join(' ');
+ const full = [street, cityLine].filter(Boolean).join(', ');
+ if (full) address = full;
+ }
+
+ return {
+ ...listing,
+ address,
+ description: description || listing.description,
+ };
+ } catch (error) {
+ logger.warn(`Could not fetch Sparkasse detail page for listing '${listing.id}'.`, error?.message || error);
+ return listing;
+ }
+}
+
function normalize(o) {
const originalId = o.id.split('/').pop().replace('.html', '');
const id = buildHash(originalId, o.price);
@@ -36,6 +84,7 @@ const config = {
},
normalize: normalize,
filter: applyBlacklist,
+ fetchDetails,
activeTester: (url) => checkIfListingIsActive(url, 'Angebot nicht gefunden'),
};
export const init = (sourceConfig, blacklist) => {
diff --git a/lib/provider/wgGesucht.js b/lib/provider/wgGesucht.js
index d0d05519..87e8a375 100755
--- a/lib/provider/wgGesucht.js
+++ b/lib/provider/wgGesucht.js
@@ -5,9 +5,34 @@
import { isOneOf, buildHash } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
+import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js';
+import * as cheerio from 'cheerio';
+import logger from '../services/logger.js';
let appliedBlackList = [];
+async function fetchDetails(listing, browser) {
+ try {
+ const html = await puppeteerExtractor(listing.link, null, { browser });
+ if (!html) return listing;
+
+ const $ = cheerio.load(html);
+
+ $('#freitext_0 script').remove();
+ const description = $('#freitext_0').text().replace(/\s+/g, ' ').trim();
+ const address = $('a[href="#map_container"] .section_panel_detail').text().replace(/\s+/g, ' ').trim();
+
+ return {
+ ...listing,
+ address: address || listing.address,
+ description: description || listing.description,
+ };
+ } catch (error) {
+ logger.warn(`Could not fetch wgGesucht detail page for listing '${listing.id}'.`, error?.message || error);
+ return listing;
+ }
+}
+
function normalize(o) {
const id = buildHash(o.id, o.price);
const link = `https://www.wg-gesucht.de${o.link}`;
@@ -37,6 +62,7 @@ const config = {
},
normalize: normalize,
filter: applyBlacklist,
+ fetchDetails,
activeTester: checkIfListingIsActive,
};
export const init = (sourceConfig, blacklist) => {
diff --git a/lib/services/extractor/botPrevention.js b/lib/services/extractor/botPrevention.js
index 19267919..e8f08813 100644
--- a/lib/services/extractor/botPrevention.js
+++ b/lib/services/extractor/botPrevention.js
@@ -94,12 +94,34 @@ export async function applyBotPreventionToPage(page, cfg) {
// webdriver
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
- // chrome runtime
+ // chrome runtime — expose loadTimes, csi and app like real Chrome
// @ts-ignore
- if (!window.chrome) {
+ window.chrome = {
+ runtime: {},
// @ts-ignore
- window.chrome = { runtime: {} };
- }
+ loadTimes: () => ({
+ requestTime: performance.timeOrigin / 1000,
+ startLoadTime: performance.timeOrigin / 1000,
+ commitLoadTime: performance.timeOrigin / 1000 + 0.1,
+ finishDocumentLoadTime: 0,
+ finishLoadTime: 0,
+ firstPaintTime: 0,
+ firstPaintAfterLoadTime: 0,
+ navigationType: 'Other',
+ wasFetchedViaSpdy: false,
+ wasNpnNegotiated: false,
+ npnNegotiatedProtocol: '',
+ wasAlternateProtocolAvailable: false,
+ connectionInfo: 'http/1.1',
+ }),
+ // @ts-ignore
+ csi: () => ({ startE: performance.timeOrigin, onloadT: Date.now(), pageT: performance.now(), tran: 15 }),
+ app: {
+ isInstalled: false,
+ InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' },
+ RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' },
+ },
+ };
// languages
// @ts-ignore
@@ -107,23 +129,38 @@ export async function applyBotPreventionToPage(page, cfg) {
get: () => (window.localStorage.getItem('__LANGS__') || 'de-DE,de').split(','),
});
- // plugins
+ // plugins — mimic real Chrome's built-in PDF plugins
+ const makePlugin = (name, filename, description, mimeType, mimeTypeSuffix) => {
+ const mimeObj = { type: mimeType, suffixes: mimeTypeSuffix, description, enabledPlugin: null };
+ const plugin = { name, filename, description, length: 1, 0: mimeObj };
+ mimeObj.enabledPlugin = plugin;
+ return plugin;
+ };
+ const fakePlugins = [
+ makePlugin('PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format', 'application/pdf', 'pdf'),
+ makePlugin('Chrome PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format', 'application/pdf', 'pdf'),
+ makePlugin('Chromium PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format', 'application/pdf', 'pdf'),
+ makePlugin(
+ 'Microsoft Edge PDF Viewer',
+ 'internal-pdf-viewer',
+ 'Portable Document Format',
+ 'application/pdf',
+ 'pdf',
+ ),
+ makePlugin('WebKit built-in PDF', 'internal-pdf-viewer', 'Portable Document Format', 'application/pdf', 'pdf'),
+ ];
// @ts-ignore
- Object.defineProperty(navigator, 'plugins', {
- get: () => [{}, {}, {}],
- });
+ Object.defineProperty(navigator, 'plugins', { get: () => fakePlugins });
+ // @ts-ignore
+ Object.defineProperty(navigator, 'mimeTypes', { get: () => [fakePlugins[0][0]] });
// platform and concurrency hints
// @ts-ignore
Object.defineProperty(navigator, 'platform', { get: () => 'Win32' });
// @ts-ignore
- if (typeof navigator.hardwareConcurrency === 'number' && navigator.hardwareConcurrency < 2) {
- Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 4 });
- }
+ Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
// @ts-ignore
- if (typeof navigator.deviceMemory === 'number' && navigator.deviceMemory < 2) {
- Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
- }
+ Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
// userAgentData (Client Hints)
try {
@@ -236,6 +273,21 @@ export async function applyBotPreventionToPage(page, cfg) {
} catch {
//noop
}
+
+ // document.hasFocus — headless returns false; real active tabs return true
+ try {
+ document.hasFocus = () => true;
+ } catch {
+ //noop
+ }
+
+ // screen color depth — normalise in case headless reports 0
+ try {
+ Object.defineProperty(screen, 'colorDepth', { get: () => 24 });
+ Object.defineProperty(screen, 'pixelDepth', { get: () => 24 });
+ } catch {
+ //noop
+ }
} catch {
//noop
}
@@ -273,6 +325,8 @@ export async function applyPostNavigationHumanSignals(page, cfg) {
const my = Math.floor(vh * (0.3 + Math.random() * 0.4));
await page.mouse.move(mx, my, { steps: 10 + Math.floor(Math.random() * 10) });
await page.mouse.wheel({ deltaY: 100 + Math.floor(Math.random() * 200) });
+ await new Promise((res) => setTimeout(res, 150 + Math.floor(Math.random() * 200)));
+ await page.mouse.wheel({ deltaY: -(30 + Math.floor(Math.random() * 60)) });
} catch {
// ignore if mouse is unavailable
}
diff --git a/lib/services/extractor/puppeteerExtractor.js b/lib/services/extractor/puppeteerExtractor.js
index e76e5c40..e6bdd959 100644
--- a/lib/services/extractor/puppeteerExtractor.js
+++ b/lib/services/extractor/puppeteerExtractor.js
@@ -110,6 +110,7 @@ export default async function execute(url, waitForSelector, options) {
// Navigation
const response = await page.goto(url, {
waitUntil: options?.waitUntil || 'domcontentloaded',
+ timeout: options?.puppeteerTimeout || 60000,
});
// Optionally wait and add subtle human-like interactions
diff --git a/lib/services/storage/migrations/sql/13.provider-details.js b/lib/services/storage/migrations/sql/13.provider-details.js
index 719555d3..e7258210 100644
--- a/lib/services/storage/migrations/sql/13.provider-details.js
+++ b/lib/services/storage/migrations/sql/13.provider-details.js
@@ -4,10 +4,11 @@
*/
// We have moved the previous immoscout_details setting to provider_details and enable this by default
+// We also set it to false per default as this is increasing the chance to be detected as a bot by a lot
export function up(db) {
db.exec(`
UPDATE settings
- SET name = 'provider_details', value = true
+ SET name = 'provider_details', value = false
WHERE name = 'immoscout_details'
AND NOT EXISTS (
SELECT 1 FROM settings WHERE name = 'provider_details'
diff --git a/package.json b/package.json
index 9adc7c9a..10e9cb19 100755
--- a/package.json
+++ b/package.json
@@ -76,7 +76,6 @@
"cheerio": "^1.2.0",
"cookie-session": "2.1.1",
"handlebars": "4.7.9",
- "lodash": "4.17.23",
"maplibre-gl": "^5.21.1",
"nanoid": "5.1.7",
"node-cron": "^4.2.1",
diff --git a/test/provider/einsAImmobilien.test.js b/test/provider/einsAImmobilien.test.js
index 1ee67927..73388033 100644
--- a/test/provider/einsAImmobilien.test.js
+++ b/test/provider/einsAImmobilien.test.js
@@ -13,7 +13,7 @@ describe('#einsAImmobilien testsuite()', () => {
provider.init(providerConfig.einsAImmobilien, [], []);
it('should test einsAImmobilien provider', async () => {
const Fredy = await mockFredy();
- return await new Promise((resolve) => {
+ return await new Promise((resolve, reject) => {
const fredy = new Fredy(
provider.config,
null,
@@ -23,6 +23,10 @@ describe('#einsAImmobilien testsuite()', () => {
similarityCache,
);
fredy.execute().then((listings) => {
+ if (listings == null || listings.length === 0) {
+ reject('Listings is empty!');
+ return;
+ }
expect(listings).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).toBeTypeOf('object');
diff --git a/test/provider/immobilienDe.test.js b/test/provider/immobilienDe.test.js
index 078377a6..fc3fd036 100644
--- a/test/provider/immobilienDe.test.js
+++ b/test/provider/immobilienDe.test.js
@@ -6,36 +6,69 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js';
import { providerConfig, mockFredy } from '../utils.js';
-import { expect } from 'vitest';
+import { expect, vi } from 'vitest';
import * as provider from '../../lib/provider/immobilienDe.js';
+import * as mockStore from '../mocks/mockStore.js';
describe('#immobilien.de testsuite()', () => {
provider.init(providerConfig.immobilienDe, [], []);
it('should test immobilien.de provider', async () => {
const Fredy = await mockFredy();
- return await new Promise((resolve) => {
- const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'test1', similarityCache);
- fredy.execute().then((listing) => {
- expect(listing).toBeInstanceOf(Array);
- const notificationObj = get();
- expect(notificationObj).toBeTypeOf('object');
- expect(notificationObj.serviceName).toBe('immobilienDe');
- notificationObj.payload.forEach((notify) => {
- /** check the actual structure **/
- expect(notify.id).toBeTypeOf('string');
- expect(notify.price).toBeTypeOf('string');
- expect(notify.size).toBeTypeOf('string');
- expect(notify.title).toBeTypeOf('string');
- expect(notify.link).toBeTypeOf('string');
- expect(notify.address).toBeTypeOf('string');
- /** check the values if possible **/
- expect(notify.price).toContain('€');
- expect(notify.size).toContain('m²');
- expect(notify.title).not.toBe('');
- expect(notify.link).toContain('https://www.immobilien.de');
- expect(notify.address).not.toBe('');
- });
- resolve();
+ const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'test1', similarityCache);
+ 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('immobilienDe');
+ notificationObj.payload.forEach((notify) => {
+ /** check the actual structure **/
+ expect(notify.id).toBeTypeOf('string');
+ expect(notify.price).toBeTypeOf('string');
+ expect(notify.size).toBeTypeOf('string');
+ expect(notify.title).toBeTypeOf('string');
+ expect(notify.link).toBeTypeOf('string');
+ expect(notify.address).toBeTypeOf('string');
+ /** check the values if possible **/
+ expect(notify.price).toContain('€');
+ expect(notify.size).toContain('m²');
+ expect(notify.title).not.toBe('');
+ expect(notify.link).toContain('https://www.immobilien.de');
+ expect(notify.address).not.toBe('');
+ });
+ });
+
+ describe('with provider_details enabled', () => {
+ beforeEach(() => {
+ vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: true });
+ vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should enrich listings with details', async () => {
+ const Fredy = await mockFredy();
+ provider.init(providerConfig.immobilienDe, [], []);
+ const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'test1', {
+ checkAndAddEntry: () => false,
+ });
+ const listings = await fredy.execute();
+ if (listings == null) return;
+ expect(listings).toBeInstanceOf(Array);
+ listings.forEach((listing) => {
+ expect(listing.link).toContain('https://www.immobilien.de');
+ expect(listing.address).toBeTypeOf('string');
+ expect(listing.address).not.toBe('');
+ // description may be null if selectors don't match yet — falls back gracefully
+ if (listing.description != null) {
+ expect(listing.description).toBeTypeOf('string');
+ }
});
});
});
diff --git a/test/provider/immoscout.test.js b/test/provider/immoscout.test.js
index a9371710..a594e6fb 100644
--- a/test/provider/immoscout.test.js
+++ b/test/provider/immoscout.test.js
@@ -14,9 +14,14 @@ describe('#immoscout provider testsuite()', () => {
provider.init(providerConfig.immoscout, [], []);
it('should test immoscout provider', async () => {
const Fredy = await mockFredy();
- return await new Promise((resolve) => {
+ return await new Promise((resolve, reject) => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, '', similarityCache);
fredy.execute().then((listings) => {
+ if (listings == null || listings.length === 0) {
+ reject('Listings is empty!');
+ return;
+ }
+
expect(listings).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).toBeTypeOf('object');
diff --git a/test/provider/immoswp.test.js b/test/provider/immoswp.test.js
index dbf2d60e..dd19fdbf 100644
--- a/test/provider/immoswp.test.js
+++ b/test/provider/immoswp.test.js
@@ -13,9 +13,14 @@ describe('#immoswp testsuite()', () => {
provider.init(providerConfig.immoswp, [], []);
it('should test immoswp provider', async () => {
const Fredy = await mockFredy();
- return await new Promise((resolve) => {
+ return await new Promise((resolve, reject) => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immoswp', similarityCache);
fredy.execute().then((listing) => {
+ if (listing == null || listing.length === 0) {
+ reject('Listings is empty!');
+ return;
+ }
+
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).toBeTypeOf('object');
diff --git a/test/provider/immowelt.test.js b/test/provider/immowelt.test.js
index 6ad73f81..b262364e 100644
--- a/test/provider/immowelt.test.js
+++ b/test/provider/immowelt.test.js
@@ -18,6 +18,10 @@ describe('#immowelt testsuite()', () => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immowelt', similarityCache);
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');
diff --git a/test/provider/kleinanzeigen.test.js b/test/provider/kleinanzeigen.test.js
index 9f5c1ccf..f0ed4f35 100644
--- a/test/provider/kleinanzeigen.test.js
+++ b/test/provider/kleinanzeigen.test.js
@@ -14,7 +14,7 @@ describe('#kleinanzeigen testsuite()', () => {
it('should test kleinanzeigen provider', async () => {
const Fredy = await mockFredy();
provider.init(providerConfig.kleinanzeigen, [], []);
- return await new Promise((resolve) => {
+ return await new Promise((resolve, reject) => {
const fredy = new Fredy(
provider.config,
null,
@@ -24,6 +24,11 @@ describe('#kleinanzeigen testsuite()', () => {
similarityCache,
);
fredy.execute().then((listing) => {
+ if (listing == null || listing.length === 0) {
+ reject('Listings is empty!');
+ return;
+ }
+
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).toBeTypeOf('object');
diff --git a/test/provider/mcMakler.test.js b/test/provider/mcMakler.test.js
index 21bacdcf..3cbaa45d 100644
--- a/test/provider/mcMakler.test.js
+++ b/test/provider/mcMakler.test.js
@@ -17,6 +17,10 @@ describe('#mcMakler testsuite()', () => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'mcMakler', similarityCache);
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');
diff --git a/test/provider/neubauKompass.test.js b/test/provider/neubauKompass.test.js
index 08110e14..e83a4992 100644
--- a/test/provider/neubauKompass.test.js
+++ b/test/provider/neubauKompass.test.js
@@ -13,7 +13,7 @@ describe('#neubauKompass testsuite()', () => {
provider.init(providerConfig.neubauKompass, [], []);
it('should test neubauKompass provider', async () => {
const Fredy = await mockFredy();
- return await new Promise((resolve) => {
+ return await new Promise((resolve, reject) => {
const fredy = new Fredy(
provider.config,
null,
@@ -23,6 +23,11 @@ describe('#neubauKompass testsuite()', () => {
similarityCache,
);
fredy.execute().then((listing) => {
+ if (listing == null || listing.length === 0) {
+ reject('Listings is empty!');
+ return;
+ }
+
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj.serviceName).toBe('neubauKompass');
diff --git a/test/provider/ohneMakler.test.js b/test/provider/ohneMakler.test.js
index 060efa6f..10c33272 100644
--- a/test/provider/ohneMakler.test.js
+++ b/test/provider/ohneMakler.test.js
@@ -17,6 +17,10 @@ describe('#ohneMakler testsuite()', () => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'ohneMakler', similarityCache);
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');
diff --git a/test/provider/regionalimmobilien24.test.js b/test/provider/regionalimmobilien24.test.js
index 61166b38..58b00467 100644
--- a/test/provider/regionalimmobilien24.test.js
+++ b/test/provider/regionalimmobilien24.test.js
@@ -24,6 +24,10 @@ describe('#regionalimmobilien24 testsuite()', () => {
);
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');
diff --git a/test/provider/sparkasse.test.js b/test/provider/sparkasse.test.js
index 1b139040..0b8e0bdc 100644
--- a/test/provider/sparkasse.test.js
+++ b/test/provider/sparkasse.test.js
@@ -6,8 +6,9 @@
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 { expect, vi } from 'vitest';
import * as provider from '../../lib/provider/sparkasse.js';
+import * as mockStore from '../mocks/mockStore.js';
describe('#sparkasse testsuite()', () => {
it('should test sparkasse provider', async () => {
@@ -17,6 +18,10 @@ describe('#sparkasse testsuite()', () => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'sparkasse', similarityCache);
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');
@@ -34,4 +39,35 @@ describe('#sparkasse testsuite()', () => {
expect(notify.address).not.toBe('');
});
});
+
+ describe('with provider_details enabled', () => {
+ beforeEach(() => {
+ vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: true });
+ vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should enrich listings with details', async () => {
+ const Fredy = await mockFredy();
+ provider.init(providerConfig.sparkasse, []);
+ const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'sparkasse', {
+ checkAndAddEntry: () => false,
+ });
+ const listings = await fredy.execute();
+ expect(listings).toBeInstanceOf(Array);
+ listings.forEach((listing) => {
+ expect(listing.link).toContain('https://immobilien.sparkasse.de');
+ expect(listing.address).toBeTypeOf('string');
+ expect(listing.address).not.toBe('');
+ // description is enriched from the detail page; falls back gracefully if bot-detected
+ if (listing.description != null) {
+ expect(listing.description).toBeTypeOf('string');
+ expect(listing.description).not.toBe('');
+ }
+ });
+ });
+ });
});
diff --git a/test/provider/wgGesucht.test.js b/test/provider/wgGesucht.test.js
index 110d669d..9a9d0512 100644
--- a/test/provider/wgGesucht.test.js
+++ b/test/provider/wgGesucht.test.js
@@ -6,16 +6,22 @@
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 { expect, vi } from 'vitest';
import * as provider from '../../lib/provider/wgGesucht.js';
+import * as mockStore from '../mocks/mockStore.js';
describe('#wgGesucht testsuite()', () => {
provider.init(providerConfig.wgGesucht, [], []);
it('should test wgGesucht provider', async () => {
const Fredy = await mockFredy();
- return await new Promise((resolve) => {
+ return await new Promise((resolve, reject) => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'wgGesucht', similarityCache);
fredy.execute().then((listing) => {
+ if (listing == null || listing.length === 0) {
+ reject('Listings is empty!');
+ return;
+ }
+
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj.serviceName).toBe('wgGesucht');
@@ -32,4 +38,30 @@ describe('#wgGesucht testsuite()', () => {
});
});
});
+
+ describe('with provider_details enabled', () => {
+ beforeEach(() => {
+ vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: true });
+ vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should enrich listings with details', async () => {
+ const Fredy = await mockFredy();
+ provider.init(providerConfig.wgGesucht, [], []);
+ const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'wgGesucht', {
+ checkAndAddEntry: () => false,
+ });
+ const listings = await fredy.execute();
+ expect(listings).toBeInstanceOf(Array);
+ listings.forEach((listing) => {
+ expect(listing.link).toContain('https://www.wg-gesucht.de');
+ expect(listing.description).toBeTypeOf('string');
+ expect(listing.description).not.toBe('');
+ });
+ });
+ });
});
diff --git a/test/provider/wohnungsboerse.test.js b/test/provider/wohnungsboerse.test.js
index 138950bb..4d76968c 100644
--- a/test/provider/wohnungsboerse.test.js
+++ b/test/provider/wohnungsboerse.test.js
@@ -13,7 +13,7 @@ describe('#wohnungsboerse testsuite()', () => {
provider.init(providerConfig.wohnungsboerse, [], []);
it('should test wohnungsboerse provider', async () => {
const Fredy = await mockFredy();
- return await new Promise((resolve) => {
+ return await new Promise((resolve, reject) => {
const fredy = new Fredy(
provider.config,
null,
@@ -23,6 +23,11 @@ describe('#wohnungsboerse testsuite()', () => {
similarityCache,
);
fredy.execute().then((listings) => {
+ if (listings == null || listings.length === 0) {
+ reject('Listings is empty!');
+ return;
+ }
+
expect(listings).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).toBeTypeOf('object');
diff --git a/ui/src/components/grid/jobs/JobGrid.jsx b/ui/src/components/grid/jobs/JobGrid.jsx
index 9e1ab61e..868db2ee 100644
--- a/ui/src/components/grid/jobs/JobGrid.jsx
+++ b/ui/src/components/grid/jobs/JobGrid.jsx
@@ -41,7 +41,7 @@ import { useNavigate } from 'react-router-dom';
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
import { useActions, useSelector } from '../../../services/state/store.js';
import { xhrDelete, xhrPut, xhrPost } from '../../../services/xhr.js';
-import debounce from 'lodash/debounce';
+import { debounce } from '../../../utils';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
import './JobGrid.less';
diff --git a/ui/src/components/grid/listings/ListingsGrid.jsx b/ui/src/components/grid/listings/ListingsGrid.jsx
index 00502952..98c6cb05 100644
--- a/ui/src/components/grid/listings/ListingsGrid.jsx
+++ b/ui/src/components/grid/listings/ListingsGrid.jsx
@@ -47,7 +47,7 @@ import no_image from '../../../assets/no_image.jpg';
import * as timeService from '../../../services/time/timeService.js';
import { xhrDelete, xhrPost } from '../../../services/xhr.js';
import { useActions, useSelector } from '../../../services/state/store.js';
-import debounce from 'lodash/debounce';
+import { debounce } from '../../../utils';
import './ListingsGrid.less';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
diff --git a/ui/src/utils.js b/ui/src/utils.js
new file mode 100644
index 00000000..d4036588
--- /dev/null
+++ b/ui/src/utils.js
@@ -0,0 +1,17 @@
+/*
+ * Copyright (c) 2026 by Christian Kellner.
+ * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
+ */
+
+export function debounce(fn, delay) {
+ let timer;
+
+ function debounced(...args) {
+ clearTimeout(timer);
+ timer = setTimeout(() => fn.apply(this, args), delay);
+ }
+
+ debounced.cancel = () => clearTimeout(timer);
+
+ return debounced;
+}
diff --git a/ui/src/views/generalSettings/GeneralSettings.jsx b/ui/src/views/generalSettings/GeneralSettings.jsx
index b0d70480..aa5794a9 100644
--- a/ui/src/views/generalSettings/GeneralSettings.jsx
+++ b/ui/src/views/generalSettings/GeneralSettings.jsx
@@ -30,7 +30,7 @@ import {
restore as clientRestore,
} from '../../services/backupRestoreClient';
import { IconSave, IconRefresh, IconSignal, IconHome, IconFolder } from '@douyinfe/semi-icons';
-import debounce from 'lodash/debounce';
+import { debounce } from '../../utils';
import './GeneralSettings.less';
const { Text } = Typography;
diff --git a/yarn.lock b/yarn.lock
index 98f7d71b..e251cf2a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4824,7 +4824,12 @@ lodash.debounce@^4.0.8:
resolved "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz"
integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==
-lodash@4.17.23, lodash@^4.17.21:
+lodash@4.18.1:
+ version "4.18.1"
+ resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c"
+ integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==
+
+lodash@^4.17.21:
version "4.17.23"
resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz"
integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==
From 8242657d0c5b8e7143c27d57391a46edd5a1ce91 Mon Sep 17 00:00:00 2001
From: orangecoding
Date: Sat, 4 Apr 2026 10:07:32 +0200
Subject: [PATCH 8/8] fixing immobilienDE test
---
lib/provider/immobilienDe.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/lib/provider/immobilienDe.js b/lib/provider/immobilienDe.js
index ea25b6da..8076752c 100644
--- a/lib/provider/immobilienDe.js
+++ b/lib/provider/immobilienDe.js
@@ -89,7 +89,7 @@ const config = {
url: null,
crawlContainer: 'a.lr-card',
sortByDateParam: 'sort_col=*created_ts&sort_dir=desc',
- waitForSelector: 'a.lr-card',
+ waitForSelector: null,
crawlFields: {
id: '@href', //will be transformed later
price: '.lr-card__price-amount | trim',
@@ -100,7 +100,7 @@ const config = {
address: '.lr-card__address span | trim',
image: 'img.lr-card__gallery-img@src',
},
- normalize: normalize,
+ normalize,
filter: applyBlacklist,
fetchDetails,
activeTester: checkIfListingIsActive,