-
-
Notifications
You must be signed in to change notification settings - Fork 190
feat(lib-provider): add deutsche wohnen provider #365
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,258 @@ | ||
| /* | ||
| * Copyright (c) 2026 by Christian Kellner. | ||
| * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause | ||
| */ | ||
|
|
||
| /** | ||
| * Deutsche Wohnen provider using the site's JSON API to retrieve listings. | ||
| * | ||
| * Users paste a search URL from https://www.deutsche-wohnen.com/mieten/mietangebote | ||
| * which is translated to the internal API endpoint: | ||
| * GET /api/deuwo-real-estate/list?{search parameters} | ||
| * | ||
| * Detail pages are server-rendered and embed structured listing data in the | ||
| * `data-vonovia-data` attribute, which is parsed during fetchDetails(). | ||
| */ | ||
|
|
||
| import { buildHash, isOneOf } from '../utils.js'; | ||
| import logger from '../services/logger.js'; | ||
| import { extractNumber } from '../utils/extract-number.js'; | ||
| import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js'; | ||
| import * as cheerio from 'cheerio'; | ||
| /** @import { ParsedListing } from '../types/listing.js' */ | ||
| /** @import { ProviderConfig } from '../types/providerConfig.js' */ | ||
|
|
||
| /** @type {string[]} */ | ||
| let appliedBlackList = []; | ||
| /** @type {string | null} */ | ||
| let refererUrl = null; | ||
|
|
||
| /** | ||
| * Translates a Deutsche Wohnen search page URL into the JSON API endpoint. | ||
| * | ||
| * @param {string} url Web search URL or API URL pasted by the user. | ||
| * @returns {string} API URL used by getListings(). | ||
| */ | ||
| export function convertWebToApi(url) { | ||
| const parsed = new URL(url); | ||
|
|
||
| if (parsed.pathname === '/api/deuwo-real-estate/list') { | ||
| return parsed.toString(); | ||
| } | ||
|
|
||
| const params = parsed.searchParams; | ||
| params.delete('scroll'); | ||
| params.set('limit', params.get('limit') || '100'); | ||
| params.set('dataSet', params.get('dataSet') || 'deuwo'); | ||
|
|
||
| for (const key of ['parkingCarport', 'parkingStellplatz', 'parkingGarage', 'parkingTiefgarage']) { | ||
| if (!params.has(key)) { | ||
| params.set(key, '0'); | ||
| } | ||
| } | ||
|
|
||
| return `${metaInformation.baseUrl}api/deuwo-real-estate/list?${params.toString()}`; | ||
| } | ||
|
|
||
| /** | ||
| * @param {any} item | ||
| * @returns {string} | ||
| */ | ||
| function buildAddress(item) { | ||
| const street = item.strasse?.trim(); | ||
| const cityLine = [item.plz, item.ort].filter(Boolean).join(' ').trim(); | ||
| return [street, cityLine].filter(Boolean).join(', '); | ||
| } | ||
|
|
||
| /** | ||
| * @param {number | null | undefined} value | ||
| * @returns {number | null} | ||
| */ | ||
| function normalizeCoordinate(value) { | ||
| if (value == null || value === 0) { | ||
| return null; | ||
| } | ||
| return value; | ||
| } | ||
|
|
||
| /** | ||
| * @param {any} detailData | ||
| * @returns {string} | ||
| */ | ||
| function buildDescription(detailData) { | ||
| const parts = []; | ||
|
|
||
| if (detailData.description?.trim()) { | ||
| parts.push(`Beschreibung\n${detailData.description.trim()}`); | ||
| } | ||
|
|
||
| if (detailData.features?.length) { | ||
| parts.push( | ||
| `Ausstattung\n${detailData.features | ||
| .map((f) => f.trim()) | ||
| .filter(Boolean) | ||
| .join('\n')}`, | ||
| ); | ||
| } | ||
|
|
||
| if (detailData.location?.trim()) { | ||
| parts.push(`Lage\n${detailData.location.trim()}`); | ||
| } | ||
|
|
||
| const sectionText = (detailData.sections || []) | ||
| .map((section) => { | ||
| const rows = (section.rows || []) | ||
| .filter((row) => row.label && row.value) | ||
| .map((row) => `${row.label}: ${row.value}`) | ||
| .join('\n'); | ||
| if (!rows) return null; | ||
| return [section.heading, rows].filter(Boolean).join('\n'); | ||
| }) | ||
| .filter(Boolean) | ||
| .join('\n\n'); | ||
|
|
||
| if (sectionText) { | ||
| parts.push(sectionText); | ||
| } | ||
|
|
||
| if (detailData.miscellaneous?.trim()) { | ||
| parts.push(detailData.miscellaneous.trim()); | ||
| } | ||
|
|
||
| return parts.join('\n\n').trim(); | ||
| } | ||
|
|
||
| /** | ||
| * @param {string} url | ||
| * @returns {Promise<any[]>} | ||
| */ | ||
| async function getListings(url) { | ||
| const response = await fetch(url, { | ||
| method: 'GET', | ||
| headers: { | ||
| 'User-Agent': | ||
| 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36', | ||
| Accept: 'application/json', | ||
| ...(refererUrl ? { Referer: refererUrl } : {}), | ||
| }, | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| logger.error('Error fetching data from Deutsche Wohnen API:', response.statusText); | ||
| return []; | ||
| } | ||
|
|
||
| const responseBody = await response.json(); | ||
| return (responseBody.results || []) | ||
| .filter((item) => item.vermarktungsart_miete === '1') | ||
| .map((item) => ({ | ||
| id: item.wrk_id, | ||
| price: item.preis, | ||
| size: item.groesse, | ||
| rooms: item.anzahl_zimmer, | ||
| title: item.titel, | ||
| link: `${metaInformation.baseUrl}mieten/mietangebote/${item.slug}`, | ||
| address: buildAddress(item), | ||
| image: item.preview_img_url, | ||
| latitude: item.lat, | ||
| longitude: item.lng, | ||
| })); | ||
| } | ||
|
|
||
| /** | ||
| * @param {ParsedListing} listing | ||
| * @param {any} [browser] | ||
| * @returns {Promise<ParsedListing>} | ||
| */ | ||
| async function fetchDetails(listing, browser) { | ||
| try { | ||
| const html = await puppeteerExtractor(listing.link, 'body', { browser, name: 'deutscheWohnen_details' }); | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What is the reason on the general list you use fetch and here you use puppeteer?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. DeutscheWohnen provides a |
||
| if (!html) return listing; | ||
|
|
||
| const $ = cheerio.load(html); | ||
| const rawData = $('[data-vonovia-data]').attr('data-vonovia-data'); | ||
| if (!rawData) return listing; | ||
|
|
||
| const detailData = JSON.parse(rawData); | ||
| const description = buildDescription(detailData); | ||
| const address = [detailData.streetAndHouseNumber, detailData.postCodeAndCity].filter(Boolean).join(', '); | ||
|
|
||
| return { | ||
| ...listing, | ||
| address: address || listing.address, | ||
| description: description || listing.description, | ||
| latitude: normalizeCoordinate(detailData.latitude) ?? listing.latitude, | ||
| longitude: normalizeCoordinate(detailData.longitude) ?? listing.longitude, | ||
| }; | ||
| } catch (error) { | ||
| logger.warn(`Could not fetch Deutsche Wohnen detail page for listing '${listing.id}'.`, error?.message || error); | ||
| return listing; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * @param {any} o | ||
| * @returns {ParsedListing} | ||
| */ | ||
| function normalize(o) { | ||
| const id = buildHash(o.id, o.price); | ||
| const price = extractNumber(o.price); | ||
| return { | ||
| id, | ||
| link: o.link, | ||
| title: (o.title || '').trim(), | ||
| price: price != null ? Math.round(price) : null, | ||
| size: extractNumber(o.size), | ||
| rooms: extractNumber(o.rooms), | ||
| address: o.address, | ||
| image: o.image, | ||
| description: o.description, | ||
| latitude: normalizeCoordinate(o.latitude), | ||
| longitude: normalizeCoordinate(o.longitude), | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * @param {ParsedListing} o | ||
| * @returns {boolean} | ||
| */ | ||
| function applyBlacklist(o) { | ||
| const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList); | ||
| const descNotBlacklisted = !isOneOf(o.description, appliedBlackList); | ||
| return titleNotBlacklisted && descNotBlacklisted; | ||
| } | ||
|
|
||
| /** @type {ProviderConfig} */ | ||
| const config = { | ||
| requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'], | ||
| url: null, | ||
| crawlFields: { | ||
| id: 'wrk_id', | ||
| title: 'titel', | ||
| price: 'preis', | ||
| size: 'groesse', | ||
| rooms: 'anzahl_zimmer', | ||
| link: 'slug', | ||
| address: 'strasse', | ||
| image: 'preview_img_url', | ||
| }, | ||
| normalize, | ||
| filter: applyBlacklist, | ||
| getListings, | ||
| fetchDetails, | ||
| }; | ||
|
|
||
| export const init = (sourceConfig, blacklist) => { | ||
| config.enabled = sourceConfig.enabled; | ||
| refererUrl = sourceConfig.url; | ||
| config.url = convertWebToApi(sourceConfig.url); | ||
| appliedBlackList = blacklist || []; | ||
| }; | ||
|
|
||
| export const metaInformation = { | ||
| name: 'Deutsche Wohnen', | ||
| baseUrl: 'https://www.deutsche-wohnen.com/', | ||
| id: 'deutscheWohnen', | ||
| }; | ||
|
|
||
| export { config }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| /* | ||
| * Copyright (c) 2026 by Christian Kellner. | ||
| * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause | ||
| */ | ||
|
|
||
| import { expect } 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/deutscheWohnen.js'; | ||
|
|
||
| // Deutsche Wohnen uses a JSON API (fetch-based, no browser). Both tests share | ||
| // the same module-level listings so the API is only queried once. | ||
| const TEST_TIMEOUT = 120_000; | ||
|
|
||
| describe('#deutscheWohnen provider testsuite()', () => { | ||
| provider.init(providerConfig.deutscheWohnen, [], []); | ||
|
|
||
| let liveListings; | ||
|
|
||
| it( | ||
| 'should test deutscheWohnen provider', | ||
| async () => { | ||
| const Fredy = await mockFredy(); | ||
| const mockedJob = { | ||
| id: 'deutscheWohnen', | ||
| notificationAdapter: null, | ||
| spatialFilter: null, | ||
| specFilter: null, | ||
| }; | ||
|
|
||
| const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined); | ||
|
|
||
| liveListings = await fredy.execute(); | ||
|
|
||
| if (liveListings == null || liveListings.length === 0) { | ||
| throw new Error('Listings is empty!'); | ||
| } | ||
|
|
||
| expect(liveListings).toBeInstanceOf(Array); | ||
| const notificationObj = get(); | ||
| expect(notificationObj).toBeTypeOf('object'); | ||
| expect(notificationObj.serviceName).toBe('deutscheWohnen'); | ||
|
|
||
| const hasValidNotification = notificationObj.payload.some((notify) => { | ||
| return ( | ||
| typeof notify.id === 'string' && | ||
| typeof notify.price === 'string' && | ||
| notify.price.includes('€') && | ||
| typeof notify.size === 'string' && | ||
| notify.size.includes('m²') && | ||
| typeof notify.title === 'string' && | ||
| notify.title !== '' && | ||
| typeof notify.link === 'string' && | ||
| notify.link.includes('https://www.deutsche-wohnen.com/') && | ||
| typeof notify.address === 'string' && | ||
| notify.address !== '' | ||
| ); | ||
| }); | ||
|
|
||
| expect(hasValidNotification).toBe(true); | ||
| }, | ||
| TEST_TIMEOUT, | ||
| ); | ||
|
|
||
| describe('with provider_details enabled', () => { | ||
| it( | ||
| 'should enrich listings with details', | ||
| async () => { | ||
| if (!liveListings?.length) throw new Error('No listings from first test to enrich'); | ||
|
|
||
| const enriched = await provider.config.fetchDetails(liveListings[0]); | ||
|
|
||
| expect(enriched).toBeTruthy(); | ||
| expect(enriched.link).toContain('https://www.deutsche-wohnen.com/'); | ||
| expect(enriched.address).toBeTypeOf('string'); | ||
| expect(enriched.address).not.toBe(''); | ||
| if (enriched.description != null) { | ||
| expect(enriched.description).toBeTypeOf('string'); | ||
| expect(enriched.description).not.toBe(''); | ||
| } | ||
| }, | ||
| TEST_TIMEOUT, | ||
| ); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here I think it is ok