Compare commits

..

15 Commits

Author SHA1 Message Date
Christian Kellner
d8ccccb82a Next version of fredy 2025-05-20 12:45:12 +02:00
Leon C.
1f54bcfd3f ImmoScout: Allow web paths with SEO optimization to be filtered to query params (#128) 2025-05-20 12:44:43 +02:00
Christian Kellner
f4c2130829 Update README.md 2025-05-17 09:09:42 +02:00
Christian Kellner
25dfad4f5d run cleanup once at start 2025-05-16 13:26:39 +02:00
Christian Kellner
b7a3823049 console log when removing demo jobs 2025-05-16 13:25:55 +02:00
Christian Kellner
6964998695 fixing removing demo jobs 2025-05-16 13:20:54 +02:00
Christian Kellner
ef689cf97e fix docker build harder 2025-05-15 10:37:21 +02:00
Christian Kellner
bd6a572ab0 fix docker build 2025-05-15 10:23:56 +02:00
Christian Kellner
d96c1ee3fe Merge branch 'master' of github.com:orangecoding/fredy 2025-05-15 10:16:55 +02:00
Christian Kellner
9a09548a07 fix docker build 2025-05-15 10:16:43 +02:00
Christian Kellner
00eabecd08 Update README.md 2025-05-15 09:00:17 +02:00
Christian Kellner
c07dc6220e Update README.md 2025-05-14 15:05:50 +02:00
Christian Kellner
4bab3bd9da fix docker build 2025-05-14 14:28:07 +02:00
Christian Kellner
b113621202 next version 2025-05-14 14:00:03 +02:00
Christian Kellner
030e0ca169 starting docu on reverse engineering immoscout api (#127)
* starting docu on reverse engineering immoscout api

* improving immoscout reverse engineering and adding support for most other types
2025-05-14 13:58:58 +02:00
16 changed files with 1360 additions and 1158 deletions

View File

@@ -1,4 +1,5 @@
name: Create and publish Docker image
on:
push:
branches:
@@ -18,14 +19,19 @@ jobs:
packages: write
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
with:
platforms: linux/amd64,linux/arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Log in to the Container registry
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -33,15 +39,15 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v3
uses: docker/metadata-action@v4
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
uses: docker/build-push-action@v2
uses: docker/build-push-action@v3
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64, linux/arm64

View File

@@ -1,6 +1,6 @@
FROM node:20
WORKDIR /fredy
WORKDIR /fredy
COPY . /fredy
@@ -9,6 +9,9 @@ RUN apt-get update && apt-get install -y chromium
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
# Timeout fix für yarn hinzugefügt
RUN yarn config set network-timeout 600000
RUN yarn install
RUN yarn global add pm2

View File

@@ -1,6 +1,6 @@
<img src="https://github.com/orangecoding/fredy/blob/master/doc/logo.png" width="400">
![Build Status](https://github.com/orangecoding/fredy/actions/workflows/test.yml/badge.svg)
![Build Status](https://github.com/orangecoding/fredy/actions/workflows/test.yml/badge.svg) [![Create and publish Docker image](https://github.com/orangecoding/fredy/actions/workflows/docker.yml/badge.svg)](https://github.com/orangecoding/fredy/actions/workflows/docker.yml)
Searching an apartment in Germany can be a frustrating task. Not any longer though, as _Fredy_ will take over and will only notify you once new listings have been found that match your requirements.
@@ -8,6 +8,8 @@ _Fredy_ scrapes multiple services (Immonet, Immowelt etc.) and send new listings
If _Fredy_ finds matching results, it will send them to you via Slack, Email, Telegram etc. (More adapters can be configured.) As _Fredy_ stores the listings it has found, new results will not be sent to you twice (and as a side-effect, _Fredy_ can show some statistics). Furthermore, _Fredy_ checks duplicates per scraping so that the same listings are not being sent twice or more when posted on various platforms (which happens more often than one might think).
<a href="https://www.producthunt.com/posts/fredy-find-real-estates-damn-easy?embed=true&utm_source=badge-featured&utm_medium=badge&utm_source=badge-fredy&#0045;find&#0045;real&#0045;estates&#0045;damn&#0045;easy" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=965690&theme=light&t=1747292331626" alt="Fredy&#0032;&#0045;&#0032;Find&#0032;Real&#0032;Estates&#0032;Damn&#0032;EasY&#0032; - Your&#0032;personal&#0032;real&#0032;estate&#0032;search&#0032;bot | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
# Sponsorship [![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/orangecoding)
If you like my work, consider becoming a sponsor. I'm not expecting anybody to pay for _Fredy_ or any other Open Source Project I'm maintaining, however keep in mind, I'm doing all of this in my spare time :) Thanks.
@@ -82,7 +84,7 @@ yarn run test
![Architecture](/doc/architecture.jpg "Architecture")
### Immoscout
Immoscout has implemented advanced bot detection. In order to work around this, we are using a reversed engineered version of their mobile api. For now, only real estate rentals are supported. Purchases will be supported at a later point in time.
Immoscout has implemented advanced bot detection. In order to work around this, we are using a reversed engineered version of their mobile api. See See [Immoscout Reverse Engineering Documentation](https://github.com/orangecoding/fredy/blob/master/reverse-engineered-immoscout.md)
# Analytics
Fredy is completely free (and will always remain free). However, it would be a huge help if youd allow me to collect some analytical data.
@@ -110,6 +112,10 @@ Put your config.json into a path of your choice, such as `/path/to/your/conf/`.
Example: `docker create --name fredy -v /path/to/your/conf/:/conf -p 9998:9998 fredy/fredy`
## Logs
You can browse the logs with `docker logs fredy -f`.
### 👐 Contributing
Thanks to all the people who already contributed!
@@ -119,6 +125,7 @@ Thanks to all the people who already contributed!
See [Contributing](https://github.com/orangecoding/fredy/blob/master/CONTRIBUTING.md)
## Logs
You can browse the logs with `docker logs fredy -f`.
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=orangecoding/fredy&type=Date)](https://www.star-history.com/#orangecoding/fredy&Date)

View File

@@ -26,7 +26,7 @@ const config = {
url: null,
crawlContainer: 'div[data-testid="serp-core-classified-card-testid"]',
sortByDateParam: 'sortby=19',
waitForSelector: 'div[data-testid="serp-resultscount-testid"]',
waitForSelector: 'div[data-testid="serp-gridcontainer-testid"]',
crawlFields: {
id: 'button@title |trim', // immonet is a piece of sh*t. See comment above
title: 'button@title |trim',

View File

@@ -33,15 +33,10 @@
* but I have decided not to include new parameters as I wanted to keep the existing UX (i.e.,
* users only have to provide a link to an existing search).
*
* Limitations:
* - The current implementation of this provider *does not* support non-rental properties,
* although the same approach can be used to implement support. It's just a matter of
* mapping the web search URL to the corresponding mobile API URL.
* - Pagination support is not implemented.
*/
import utils, { buildHash } from '../utils.js';
import queryString from 'query-string';
import { convertWebToMobile } from '../services/immoscout/immoscout-web-translater.js';
let appliedBlackList = [];
async function getListings(url) {
@@ -92,8 +87,6 @@ function applyBlacklist(o) {
}
const config = {
url: null,
sortByDateParam: 'sorting=-firstactivation',
// Not actually required - used by filter to remove and listings that failed to parse
crawlFields: {
id: 'id',
title: 'title',
@@ -102,6 +95,8 @@ const config = {
link: 'link',
address: 'address',
},
// Not required - used by filter to remove and listings that failed to parse
sortByDateParam: 'sorting=-firstactivation',
normalize: normalize,
filter: applyBlacklist,
getListings: getListings,
@@ -117,89 +112,4 @@ export const metaInformation = {
id: 'immoscout',
};
export function convertWebToMobile(webUrl) {
let url;
try {
url = new URL(webUrl);
} catch (err) {
throw new Error(`Invalid URL: ${webUrl}`);
}
const segments = url.pathname.split('/');
if (segments.length < 6 || segments[1] !== 'Suche') {
throw new Error(`Unexpected path format: ${url.pathname}`);
}
const geocodes = `/${segments[2]}/${segments[3]}/${segments[4]}`;
const paramNameMap = {
heatingtypes: 'heatingtypes',
haspromotion: 'haspromotion',
numberofrooms: 'numberofrooms',
livingspace: 'livingspace',
energyefficiencyclasses: 'energyefficiencyclasses',
exclusioncriteria: 'exclusioncriteria',
equipment: 'equipment',
petsallowedtypes: 'petsallowedtypes',
price: 'price',
constructionyear: 'constructionyear',
apartmenttypes: 'apartmenttypes',
pricetype: 'pricetype',
floor: 'floor',
};
const equipmentValueMap = {
parking: 'parking',
cellar: 'cellar',
builtinkitchen: 'builtInKitchen',
lift: 'lift',
garden: 'garden',
guesttoilet: 'guestToilet',
balcony: 'balcony',
};
const { query: webParams } = queryString.parseUrl(webUrl, { arrayFormat: 'comma' });
delete webParams['enteredFrom'];
// Remove unsupported parameters
Object.keys(webParams).forEach((key) => {
if (!paramNameMap[key]) {
delete webParams[key];
}
});
// Build mobile params
const mobileParams = {
searchType: 'region',
geocodes,
realestatetype: 'apartmentrent',
};
Object.entries(webParams).forEach(([webKey, webVal]) => {
let value = webVal;
if (webKey === 'equipment') {
// Map equipment list to camelCase values
if (!Array.isArray(value)) {
value = ('' + value).split(',');
}
value = value.map((token) => {
const lower = token.toLowerCase();
if (!equipmentValueMap[lower]) {
throw new Error(`Unknown equipment type: "${token}"`);
}
return equipmentValueMap[lower];
});
}
mobileParams[paramNameMap[webKey]] = value;
});
const mobileQuery = queryString.stringify(mobileParams, {
arrayFormat: 'comma',
encode: true,
skipEmptyString: true,
});
return `https://api.mobile.immobilienscout24.de/search/list?${mobileQuery}`;
}
export { config };

View File

@@ -1,29 +1,37 @@
import { setInterval } from 'node:timers';
import {removeJobsByUserName} from './storage/jobStorage.js';
import {config} from '../utils.js';
import { removeJobsByUserName } from './storage/jobStorage.js';
import { config } from '../utils.js';
import { getUsers } from './storage/userStorage.js';
/**
* if we are running in demo environment, we have to cleanup the db files (specifically the jobs table)
*/
export function cleanupDemoAtMidnight() {
const now = new Date();
const millisUntilMidnightUTC = (24 - now.getUTCHours()) * 60 * 60 * 1000
- now.getUTCMinutes() * 60 * 1000
- now.getUTCSeconds() * 1000
- now.getUTCMilliseconds();
const now = new Date();
const millisUntilMidnightUTC =
(24 - now.getUTCHours()) * 60 * 60 * 1000 -
now.getUTCMinutes() * 60 * 1000 -
now.getUTCSeconds() * 1000 -
now.getUTCMilliseconds();
setTimeout(() => {
cleanup();
setTimeout(() => {
setInterval(
() => {
cleanup();
setInterval(() => {
cleanup();
}, 24 * 60 * 60 * 1000);
}, millisUntilMidnightUTC);
},
24 * 60 * 60 * 1000,
);
}, millisUntilMidnightUTC);
}
function cleanup(){
if(config.demoMode){
removeJobsByUserName('demo');
function cleanup() {
if (config.demoMode) {
const demoUser = getUsers(false).find((user) => user.username === 'demo');
if (demoUser == null) {
console.error('Demo user not found, cannot remove Jobs');
return;
}
}
removeJobsByUserName(demoUser.id);
}
}

View File

@@ -0,0 +1,192 @@
/*
Rent a flat
Web:
https://www.immobilienscout24.de/Suche/de/nordrhein-westfalen/duesseldorf/wohnung-mieten?numberofrooms=1.0-10000.0&price=1.0-10000.0&livingspace=10.0-10000.0&pricetype=rentpermonth&enteredFrom=result_list
*/
/*
Rent a flat:
Web:
https://www.immobilienscout24.de/Suche/de/nordrhein-westfalen/duesseldorf/wohnung-mieten?enteredFrom=one_step_search
Mobile:
https://api.mobile.immobilienscout24.de/search/list?numberofrooms=1.5-&searchId=d7c127d8-6630-49e8-a1dd-5ae04dad454d&sorting=standard&pagesize=20&livingspace=10-500&pagenumber=1&realestatetype=apartmentrent&priceType=calculatedtotalrent&price=1-10000&publishedafter=2025-05-14T09:11:54&channel=is24&searchType=region&geocodes=/de/nordrhein-westfalen/duesseldorf&features=adKeysAndStringValues,virtualTour,contactDetails,viareporting,nextgen,calculatedTotalRent,listingsInListFirstSummary,xxlListingType,quickfilters,grouping,projectsInAllRealestateTypes,fairPrice
*/
/*
Rent a house:
Web:
https://www.immobilienscout24.de/Suche/de/nordrhein-westfalen/duesseldorf/haus-mieten?enteredFrom=one_step_search
Mobile:
https://api.mobile.immobilienscout24.de/search/map/v3?publishedafter=2025-05-14T09:12:49&pagenumber=1&searchType=region&geocodes=/de/nordrhein-westfalen/duesseldorf&realEstateType=houserent&pagesize=300&features=disableNHBGrouping,nextGen,fairPrice,listingsInListFirstSummary,xxlListingType,contactDetails&sorting=standard
*/
/*
buy a flat
Web:
https://www.immobilienscout24.de/Suche/de/nordrhein-westfalen/duesseldorf/wohnung-kaufen?numberofrooms=1.0-10000.0&price=1.0-10000.0&livingspace=1.0-10000.0&enteredFrom=result_list
Mobile:
https://api.mobile.immobilienscout24.de/search/map/v3?features=disableNHBGrouping,nextGen,fairPrice,listingsInListFirstSummary,xxlListingType,contactDetails&sorting=standard&realEstateType=apartmentbuy&pagesize=300&pagenumber=1&geocodes=/de/nordrhein-westfalen/duesseldorf&publishedafter=2025-05-14T09:14:43&searchType=region
*/
/*
Buy a house
Web:
https://www.immobilienscout24.de/Suche/de/nordrhein-westfalen/duesseldorf/haus-kaufen?numberofrooms=1.0-10000.0&price=1.0-10000.0E7&livingspace=1.0-10000.0&enteredFrom=result_list
Mobile:
https://api.mobile.immobilienscout24.de/search/map/v3?geocodes=/de/nordrhein-westfalen/duesseldorf&features=disableNHBGrouping,nextGen,fairPrice,listingsInListFirstSummary,xxlListingType,contactDetails&searchType=region&realEstateType=housebuy&pagenumber=1&pagesize=300&sorting=standard&publishedafter=2025-05-14T09:16:28
*/
/*
Buy a house only in parts of a city
Web:
https://www.immobilienscout24.de/Suche/de/nordrhein-westfalen/haus-kaufen?numberofrooms=1.0-10000.0&price=1.0-10000.0E7&livingspace=1.0-10000.0&geocodes=1276010037,1276010014,1276010012&enteredFrom=result_list
Mobile:
https://api.mobile.immobilienscout24.de/search/list?pagesize=20&pagenumber=1&features=adKeysAndStringValues,virtualTour,contactDetails,viareporting,grouping,nextgen,listingsInListFirstSummary,xxlListingType,quickfilters,fairPrice&sorting=standard&channel=is24&geocodes=/de/nordrhein-westfalen/duesseldorf/stadtbezirk-1&searchType=region&realestatetype=housebuy&publishedafter=2025-05-14T09:17:23
*/
/*
Buy a house with radius
Web:
https://www.immobilienscout24.de/Suche/radius/haus-kaufen?centerofsearchaddress=D%C3%BCsseldorf%3B%3B%3B%3B%3B%3B&numberofrooms=1.0-10000.0&price=1.0-1.0E7&livingspace=1.0-10000.0&geocoordinates=51.22496%3B6.77567%3B5.0&enteredFrom=result_list
Mobile:
https://api.mobile.immobilienscout24.de/home/search/total?pagenumber=1&pagesize=1&geocoordinates=51.224960;6.775670;4.0&sorting=standard&searchType=radius&features=adKeysAndStringValues,virtualTour,contactDetails,grouping,nextgen,listingsInListFirstSummary,xxlListingType,fairPrice&channel=is24&realestatetype=housebuy&publishedafter=2025-05-14T09:19:43
*/
/*
Buy a house with shape
Web:
https://www.immobilienscout24.de/Suche/shape/haus-kaufen?shape=eW1yd0hpZGloQGBJa1NfQWFsQG9Uc1ZvVmlDbHdAZ2BAaEBjfEB5U3NWY2NCa0RvWmpwQG1KYGdCeldqU3Z4QGBAbENvQmJWaGtA&numberofrooms=1.0-100000.0&price=1.0-1.0E7&livingspace=1.0-100000.0&enteredFrom=result_list#/
Mobile:
https://api.mobile.immobilienscout24.de/search/map/v3?features=disableNHBGrouping,nextGen,fairPrice,listingsInListFirstSummary,xxlListingType,contactDetails&publishedafter=2025-05-14T09:19:43&sorting=standard&pagesize=300&searchType=shape&realEstateType=housebuy&pagenumber=1&shape=%7D%7BjwHy%7Cqh@jCKdCgAvB_BdB%7DBzAaCjAqCfAqC~@uCt@iCh@eCZkCLyC?_EO%7DEa@%7DEa@iE_@%7BD%5DaDe@gDi@gDo@uCu@kBcB_AeDOiE?iDCgCMuBOkDCkG?yFRgD%60@cB%5C%7BA%60@eBx@aB%7C@kAbAy@rAe@bBUxCAhE?dFh@fGlAzGbBbHlBxGdB%60FrAhDz@xBh@nAf@l@RNNXkCkMJR~B%7CEnCpErCnDtClCvC~ApCh@rCJpC?
*/
import queryString from 'query-string';
const PARAM_NAME_MAP = {
heatingtypes: 'heatingtypes',
haspromotion: 'haspromotion',
numberofrooms: 'numberofrooms',
livingspace: 'livingspace',
energyefficiencyclasses: 'energyefficiencyclasses',
exclusioncriteria: 'exclusioncriteria',
equipment: 'equipment',
petsallowedtypes: 'petsallowedtypes',
price: 'price',
constructionyear: 'constructionyear',
apartmenttypes: 'apartmenttypes',
pricetype: 'pricetype',
floor: 'floor',
geocodes: 'geocodes',
geocoordinates: 'geocoordinates',
shape: 'shape',
sorting: 'sorting',
newbuilding: 'newbuilding'
};
const EQUIPMENT_MAP = {
parking: 'parking',
cellar: 'cellar',
builtinkitchen: 'builtInKitchen',
lift: 'lift',
garden: 'garden',
guesttoilet: 'guestToilet',
balcony: 'balcony',
handicappedaccessible: 'handicappedAccessible'
};
const REAL_ESTATE_TYPE = {
'haus-mieten': 'houserent',
'wohnung-mieten': 'apartmentrent',
'wohnung-kaufen': 'apartmentbuy',
'haus-kaufen': 'housebuy',
};
const WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP = {
// Category "Balkon/Terrasse"
'wohnung-mit-balkon-mieten': { equipment: ['balcony'] },
'wohnung-mit-garten-mieten': { equipment: ['garden'] },
// Category "Wohnungstyp"
'souterrainwohnung-mieten': { apartmenttypes: ['halfbasement'] },
'erdgeschosswohnung-mieten': { apartmenttypes: ['groundfloor'] },
'hochparterrewohnung-mieten': { apartmenttypes: ['raisedgroundfloor'] },
'etagenwohnung-mieten': { apartmenttypes: ['apartment'] },
'loft-mieten': { apartmenttypes: ['loft'] },
'maisonette-mieten': { apartmenttypes: ['maisonette'] },
'terrassenwohnung-mieten': { apartmenttypes: ['terracedflat'] },
'penthouse-mieten': { apartmenttypes: ['penthouse'] },
'dachgeschosswohnung-mieten': { apartmenttypes: ['roofstorey'] },
// Category "Ausstattung"
'wohnung-mit-garage-mieten': { equipment: ['parking'] },
'wohnung-mit-einbaukueche-mieten': { equipment: ['builtinkitchen'] },
'wohnung-mit-keller-mieten': { equipment: ['cellar'] },
// Category "Merkmale"
'neubauwohnung-mieten': { newbuilding: true },
'barrierefreie-wohnung-mieten': { equipment: ['handicappedaccessible'] }
};
export function convertWebToMobile(webUrl) {
let url;
try {
url = new URL(webUrl);
} catch {
throw new Error(`Invalid URL: ${webUrl}`);
}
const segments = url.pathname.split('/');
if (segments[1] !== 'Suche') {
throw new Error(`Unexpected path format: ${url.pathname}. We're expecting to see "/Suche" in the path.`);
}
const realTypeKey = segments.at(-1);
let realType = REAL_ESTATE_TYPE[realTypeKey];
let additionalParamsFromWebPath;
if (!realType) {
// Test for seo optimized apartment path (only used on the ImmoScout web app)
if (WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP[realTypeKey]) {
additionalParamsFromWebPath = WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP[realTypeKey];
realType = REAL_ESTATE_TYPE['wohnung-mieten'];
} else {
throw new Error(`Real estate type not found: ${realTypeKey}`);
}
}
if (segments.includes('shape')) {
throw new Error('Shape is currently not supported using Immoscout');
}
const { query: rawParams } = queryString.parseUrl(webUrl, { arrayFormat: 'comma' });
const webParams = Object.fromEntries(
Object.entries(rawParams).filter(([key]) => key !== 'enteredFrom' && PARAM_NAME_MAP[key]),
);
const geocodes = `/${segments.slice(2, 5).join('/')}`;
const isRadius = segments.includes('radius');
const mobileParams = {
searchType: isRadius ? 'radius' : 'region',
realestatetype: realType,
...(isRadius ? {} : { geocodes }),
...additionalParamsFromWebPath
};
if (webParams.geocoordinates) {
mobileParams.geocoordinates = webParams.geocoordinates;
}
for (const [key, val] of Object.entries(webParams)) {
if (key === 'equipment') {
const items = [].concat(val).flatMap((v) => `${v}`.split(','));
const currentEquipmentParams = mobileParams[PARAM_NAME_MAP[key]];
mobileParams[PARAM_NAME_MAP[key]] = [...currentEquipmentParams ?? [], ...items.map((item) => EQUIPMENT_MAP[item.toLowerCase()]).filter(Boolean)];
} else {
mobileParams[PARAM_NAME_MAP[key]] = val;
}
}
const mobileQuery = queryString.stringify(mobileParams, {
arrayFormat: 'comma',
encode: true,
skipEmptyString: true,
});
return `https://api.mobile.immobilienscout24.de/search/list?${mobileQuery}`;
}

View File

@@ -11,7 +11,6 @@ const db = new LowdashAdapter(adapter, { jobs: [] });
db.read();
export const upsertJob = ({ jobId, name, blacklist = [], enabled = true, provider, notificationAdapter, userId }) => {
const currentJob =
jobId == null
@@ -77,16 +76,25 @@ export const removeJobsByUserId = (userId) => {
.value();
db.write();
};
export const removeJobsByUserName = (userName) => {
export const removeJobsByUserName = (userId) => {
let removedDemoJobs = 0;
db.chain
.get('jobs')
.filter((job) => job.username === userName)
.forEach((job) => listingStorage.removeListings(job.id));
.get('jobs')
.filter((job) => job.userId === userId)
.forEach((job) => {
removedDemoJobs++;
listingStorage.removeListings(job.id);
});
db.chain
.get('jobs')
.remove((job) => job.username === userName)
.value();
.get('jobs')
.remove((job) => job.userId === userId)
.value();
db.write();
if (removedDemoJobs > 0) {
/* eslint-disable no-console */
console.log(`Removed ${removedDemoJobs} demo jobs`);
/* eslint-enable no-console */
}
};
export const getJobs = () => {
return db.chain

View File

@@ -1,6 +1,6 @@
{
"name": "fredy",
"version": "11.1.0",
"version": "11.2.1",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": {
"start": "node prod.js",
@@ -50,30 +50,30 @@
"Firefox ESR"
],
"dependencies": {
"@douyinfe/semi-ui": "2.75.0",
"@douyinfe/semi-ui": "2.79.0",
"@rematch/core": "2.2.0",
"@rematch/loading": "2.1.2",
"@sendgrid/mail": "8.1.4",
"@vitejs/plugin-react": "4.3.4",
"better-sqlite3": "^11.8.1",
"body-parser": "1.20.3",
"@sendgrid/mail": "8.1.5",
"@vitejs/plugin-react": "4.4.1",
"better-sqlite3": "^11.10.0",
"body-parser": "2.2.0",
"cheerio": "^1.0.0",
"cookie-session": "2.1.0",
"handlebars": "4.7.8",
"highcharts": "12.1.2",
"highcharts-react-official": "3.2.1",
"highcharts": "12.2.0",
"highcharts-react-official": "3.2.2",
"lodash": "4.17.21",
"lowdb": "6.0.1",
"markdown": "^0.5.0",
"mixpanel": "^0.18.0",
"nanoid": "5.1.2",
"mixpanel": "^0.18.1",
"nanoid": "5.1.5",
"node-fetch": "3.3.2",
"node-mailjet": "6.0.6",
"node-mailjet": "6.0.8",
"package-up": "^5.0.0",
"puppeteer": "^24.2.1",
"puppeteer": "^24.8.2",
"puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"query-string": "9.1.1",
"query-string": "9.1.2",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-redux": "9.2.0",
@@ -88,10 +88,10 @@
"vite": "5.4.11"
},
"devDependencies": {
"@babel/core": "7.26.9",
"@babel/eslint-parser": "7.26.8",
"@babel/preset-env": "7.26.9",
"@babel/preset-react": "7.26.3",
"@babel/core": "7.27.1",
"@babel/eslint-parser": "7.27.1",
"@babel/preset-env": "7.27.2",
"@babel/preset-react": "7.27.1",
"chai": "5.2.0",
"eslint": "8.56.0",
"eslint-config-prettier": "8.8.0",
@@ -99,10 +99,10 @@
"esmock": "2.7.0",
"history": "5.3.0",
"husky": "9.1.7",
"less": "4.2.2",
"lint-staged": "15.4.3",
"less": "4.3.0",
"lint-staged": "15.5.2",
"mocha": "10.8.2",
"prettier": "3.5.2",
"prettier": "3.5.3",
"redux-logger": "3.0.6"
}
}

View File

@@ -0,0 +1,80 @@
# Reverse Engineered Immoscout24's Mobile API
## What is Immoscout24?
Immobilienscout24 (commonly known as Immoscout) is one of Germany's largest and most popular real estate platforms. It serves as a marketplace where property owners, real estate agents, and property management companies can list apartments, houses, and commercial properties for rent or sale. For people searching for a new home in Germany, Immoscout is often one of the first platforms they check.
The platform allows users to filter properties based on various criteria such as location, price, size, number of rooms, and additional features like balconies or built-in kitchens. Immoscout24 is available both as a website and as a mobile application, making it accessible across different devices.
## Why do we do this?
Crawling Immoscout24 the oldschool way has become virtually impossible due to their extensive bot detection mechanisms. Immoscout has implemented various anti-scraping measures to prevent automated access to their platform. These measures can include:
1. IP-based rate limiting
2. Browser fingerprinting
3. CAPTCHA challenges
4. Behavior analysis to detect non-human patterns
5. JavaScript-based challenges that must be solved before content is displayed
These protections make it extremely difficult to reliably extract data from Immoscout using conventional web scraping approaches. Even with techniques like rotating proxies or mimicking human behavior, the bot detection systems have become increasingly effective at identifying and blocking automated access attempts.
## Mobile API Reverse Engineering
To work around these limitations, we are in the progress of reverse-engineering Immoscout24's mobile API. The mobile applications need to communicate with Immoscout's servers to retrieve listing data, and these API endpoints typically have fewer anti-bot protections than the web interface.
The mobile API provides several key endpoints:
- Search total endpoint: Returns the total number of listings for a given query
- Search list endpoint: Retrieves the actual listings with details
- Expose endpoint: Returns detailed information about a specific listing
Challenges:
1. Identifying the necessary endpoints and parameters required to perform searches
2. Mapping the mobile API parameters to their web counterparts to maintain compatibility with existing search URLs
## Api Specs
#### Search for Listings
`GET /search/total?{search parameters}`
*Returns the total number of listings for the given query.*
```
curl -H "User-Agent: ImmoScout24_1410_30_._" \
-H "Accept: application/json" \
"https://api.mobile.immobilienscout24.de/search/total?searchType=region&realestatetype=apartmentrent&pricetype=calculatedtotalrent&geocodes=%2Fde%2Fberlin%2Fberlin"
```
---
#### Retrieve the listings
`POST /search/list?{search parameters}`
*The body is json encoded and contains data specifying additional results (advertisements) to return. The format is as follows (It is not necessary to provide data for the specified keys.)*
```
{
"supportedResultListTypes": [],
"userData": {}
}
```
```
curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region&geocodes=%2Fde%2Fberlin%2Fberlin&pagenumber=1' \
-H "Connection: keep-alive" \
-H "User-Agent: ImmoScout24_1410_30_._" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"supportedResultListType":[],"userData":{}}'
```
---
#### Get details of listings
`GET /expose/{id}`
The response contains additional details not included in the listing response.
```
curl -H "User-Agent: ImmoScout24_1410_30_._" \
-H "Accept: application/json" \
"https://api.mobile.immobilienscout24.de/expose/158382494"
```
## Parameters
The parameters between web and mobile are very different which is why we have to translate them. Please see `immoscout-web-translator.js`.

View File

@@ -1,5 +1,4 @@
import { expect } from 'chai';
import { convertWebToMobile } from '../../lib/provider/immoscout.js';
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { mockFredy, providerConfig } from '../utils.js';
import { get } from '../mocks/mockNotification.js';
@@ -38,36 +37,3 @@ describe('#immoscout provider testsuite()', () => {
});
});
});
describe('#immoscout-mobile URL conversion', () => {
// Test URL conversion
it('should convert a full web URL to mobile URL', () => {
const webUrl =
'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mieten?heatingtypes=central,selfcontainedcentral&haspromotion=false&numberofrooms=2.0-5.0&livingspace=10.0-25.0&energyefficiencyclasses=a,b,c,d,e,f,g,h,a_plus&exclusioncriteria=projectlisting,swapflat&equipment=parking,cellar,builtinkitchen,lift,garden,guesttoilet,balcony&petsallowedtypes=no,yes,negotiable&price=10.0-100.0&constructionyear=1920-2026&apartmenttypes=halfbasement,penthouse,other,loft,groundfloor,terracedflat,raisedgroundfloor,roofstorey,apartment,maisonette&pricetype=calculatedtotalrent&floor=2-7&enteredFrom=result_list';
const expectedMobileUrl =
'https://api.mobile.immobilienscout24.de/search/list?apartmenttypes=halfbasement,penthouse,other,loft,groundfloor,terracedflat,raisedgroundfloor,roofstorey,apartment,maisonette&constructionyear=1920-2026&energyefficiencyclasses=a,b,c,d,e,f,g,h,a_plus&equipment=parking,cellar,builtInKitchen,lift,garden,guestToilet,balcony&exclusioncriteria=projectlisting,swapflat&floor=2-7&geocodes=%2Fde%2Fberlin%2Fberlin&haspromotion=false&heatingtypes=central,selfcontainedcentral&livingspace=10.0-25.0&numberofrooms=2.0-5.0&petsallowedtypes=no,yes,negotiable&price=10.0-100.0&pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region';
const actualMobileUrl = convertWebToMobile(webUrl);
expect(actualMobileUrl).to.equal(expectedMobileUrl);
});
// Test URL conversion with unsupported query parameters
it('should remove unsupported query parameters', () => {
const webUrl = 'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mieten?minimuminternetspeed=100000';
const converted = convertWebToMobile(webUrl);
expect(converted).that.does.not.include('minimuminternetspeed');
});
// Test URL conversion with invalid URL
it('should throw an error for invalid URL', () => {
const invalidUrl = 'invalid-url';
expect(() => convertWebToMobile(invalidUrl)).to.throw('Invalid URL: invalid-url');
});
// Test URL conversion with unexpected path format
it('should throw an error for unexpected path format', () => {
const webUrl = 'https://www.immobilienscout24.de/invalid/path/format';
expect(() => convertWebToMobile(webUrl)).to.throw('Unexpected path format: /invalid/path/format');
});
});

View File

@@ -24,5 +24,10 @@
"url": "https://www.neubaukompass.de/neubau-immobilien/berlin-region/",
"shouldBecome": "https://www.neubaukompass.de/neubau-immobilien/berlin-region/?Sortierung=Id&Richtung=DESC",
"id": "neubauKompass"
},
{
"url": "https://www.immobilienscout24.de/Suche/de/nordrhein-westfalen/duesseldorf/wohnung-mieten?numberofrooms=1.5-&price=1.0-1000000.0&livingspace=1.0-10000.0&pricetype=rentpermonth&enteredFrom=result_list",
"shouldBecome": "https://www.immobilienscout24.de/Suche/de/nordrhein-westfalen/duesseldorf/wohnung-mieten?numberofrooms=1.5-&price=1.0-1000000.0&livingspace=1.0-10000.0&pricetype=rentpermonth&enteredFrom=result_list&sorting=-firstactivation",
"id": "immoscout"
}
]

View File

@@ -0,0 +1,77 @@
import { convertWebToMobile } from '../../../lib/services/immoscout/immoscout-web-translater.js';
import { expect } from 'chai';
import { readFile } from 'fs/promises';
export const testData = JSON.parse(await readFile(new URL('./testdata.json', import.meta.url)));
describe('#immoscout-mobile URL conversion', () => {
// Test URL conversion
it('should convert a full web URL to mobile URL', () => {
const webUrl =
'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mieten?heatingtypes=central,selfcontainedcentral&haspromotion=false&numberofrooms=2.0-5.0&livingspace=10.0-25.0&energyefficiencyclasses=a,b,c,d,e,f,g,h,a_plus&exclusioncriteria=projectlisting,swapflat&equipment=parking,cellar,builtinkitchen,lift,garden,guesttoilet,balcony&petsallowedtypes=no,yes,negotiable&price=10.0-100.0&constructionyear=1920-2026&apartmenttypes=halfbasement,penthouse,other,loft,groundfloor,terracedflat,raisedgroundfloor,roofstorey,apartment,maisonette&pricetype=calculatedtotalrent&floor=2-7&enteredFrom=result_list';
const expectedMobileUrl =
'https://api.mobile.immobilienscout24.de/search/list?apartmenttypes=halfbasement,penthouse,other,loft,groundfloor,terracedflat,raisedgroundfloor,roofstorey,apartment,maisonette&constructionyear=1920-2026&energyefficiencyclasses=a,b,c,d,e,f,g,h,a_plus&equipment=parking,cellar,builtInKitchen,lift,garden,guestToilet,balcony&exclusioncriteria=projectlisting,swapflat&floor=2-7&geocodes=%2Fde%2Fberlin%2Fberlin&haspromotion=false&heatingtypes=central,selfcontainedcentral&livingspace=10.0-25.0&numberofrooms=2.0-5.0&petsallowedtypes=no,yes,negotiable&price=10.0-100.0&pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region';
const actualMobileUrl = convertWebToMobile(webUrl);
expect(actualMobileUrl).to.equal(expectedMobileUrl);
});
// Test URL conversion of web-only SEO path
it('should convert a SEO web path to the correct query params', () => {
const webUrl =
'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mit-balkon-mieten?equipment=garden';
const converted = convertWebToMobile(webUrl);
const queryParams = new URL(converted).searchParams;
expect(queryParams.get('equipment').split(',')).to.include.members(['garden', 'balcony']);
});
// Test URL conversion with unsupported query parameters
it('should remove unsupported query parameters', () => {
const webUrl = 'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mieten?minimuminternetspeed=100000';
const converted = convertWebToMobile(webUrl);
expect(converted).that.does.not.include('minimuminternetspeed');
});
// Test URL conversion with invalid URL
it('should throw an error for invalid URL', () => {
const invalidUrl = 'invalid-url';
expect(() => convertWebToMobile(invalidUrl)).to.throw('Invalid URL: invalid-url');
});
// Test URL conversion with unexpected path format
it('should throw an error for unexpected path format', () => {
const webUrl = 'https://www.immobilienscout24.de/invalid/path/format';
expect(() => convertWebToMobile(webUrl)).to.throw('Unexpected path format: /invalid/path/format');
});
it('shouldFindResultsForEveryTestData', async () => {
for (const webUrlKey of Object.keys(testData)) {
const url = convertWebToMobile(testData[webUrlKey].url);
const type = testData[webUrlKey].type;
const response = await fetch(url, {
method: 'POST',
headers: {
'User-Agent': 'ImmoScout24_1410_30_._',
'Content-Type': 'application/json',
},
body: JSON.stringify({
supportedResultListTypes: [],
userData: {},
}),
});
if (!response.ok) {
console.error('Error fetching data from ImmoScout Mobile API:', response.statusText);
}
expect([null, true]).to.include(response.ok);
const responseBody = await response.json();
expect(responseBody.totalResults).to.be.greaterThan(0);
expect(responseBody.totalResults).to.be.greaterThan(0);
expect(responseBody.resultListItems.length).to.greaterThan(0);
expect(responseBody.resultListItems[0].item.realEstateType).to.equal(type);
}
});
});

View File

@@ -0,0 +1,22 @@
{
"buyHouseInParts": {
"url": "https://www.immobilienscout24.de/Suche/de/nordrhein-westfalen/haus-kaufen?numberofrooms=1.0-10000.0&price=1.0-1000000.0E7&livingspace=1.0-10000.0&geocodes=1276010037,1276010014,1276010012&enteredFrom=result_list",
"type": "housebuy"
},
"buyHouse": {
"url": "https://www.immobilienscout24.de/Suche/de/nordrhein-westfalen/duesseldorf/haus-kaufen?numberofrooms=1.0-10000.0&price=1.0-1000000.0E7&livingspace=1.0-10000.0&enteredFrom=result_list",
"type": "housebuy"
},
"rentApartment": {
"url": "https://www.immobilienscout24.de/Suche/de/nordrhein-westfalen/duesseldorf/wohnung-mieten?numberofrooms=1.5-&price=1.0-1000000.0&livingspace=1.0-10000.0&pricetype=rentpermonth&enteredFrom=result_list",
"type": "apartmentrent"
},
"buyApartment": {
"url": "https://www.immobilienscout24.de/Suche/de/nordrhein-westfalen/duesseldorf/wohnung-kaufen?numberofrooms=1.5-10000.0&price=1.0-1000000.0&livingspace=1.0-10000.0&enteredFrom=result_list",
"type": "apartmentbuy"
},
"rentHouse": {
"url": "https://www.immobilienscout24.de/Suche/de/nordrhein-westfalen/duesseldorf/haus-mieten?enteredFrom=one_step_search",
"type": "houserent"
}
}

View File

@@ -101,7 +101,7 @@ export default function ProviderMutator({ onVisibilityChanged, visible = false,
description={
<div>
<p>
Currently, Immoscout only works for real estate rentals. Purchases are not yet supported.
Currently, our Immoscout implementation does not drawing shapes on a map. Use a radius instead.
</p>
</div>
}

1858
yarn.lock

File diff suppressed because it is too large Load Diff