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 +
+
+
+ + +
+ { + try { + await actions.userSettings.setKleinanzeigenDetails(checked); + Toast.success('Kleinanzeigen details setting updated.'); + } catch { + Toast.error('Failed to update setting.'); + } + }} + /> + Fetch detailed Kleinanzeigen listings +
+
+
-
- - ); -}; - -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,