Files
fredy/lib/provider/immoscout.js
2025-05-09 11:00:35 +02:00

206 lines
6.8 KiB
JavaScript

/**
* ImmoScout provider using the mobile API to retrieve listings.
*
* The mobile API provides the following endpoints:
* - GET /search/total?{search parameters}: Returns the total number of listings for the given query
* Example: `curl -H "User-Agent: ImmoScout24_1410_30_._" https://api.mobile.immobilienscout24.de/search/total?searchType=region&realestatetype=apartmentrent&pricetype=calculatedtotalrent&geocodes=%2Fde%2Fberlin%2Fberlin `
*
* - POST /search/list?{search parameters}: Actually retrieves the listings. Body is json encoded and contains
* data specifying additional results (advertisements) to return. The format is as follows:
* ```
* {
* "supportedResultListTypes": [],
* "userData": {}
* }
* ```
* It is not necessary to provide data for the specified keys.
*
* Example: `curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region&geocodes=%2Fde%2Fberlin%2Fberlin&pagenumber=1' -H "Connection: keep-alive" -H "User-Agent: ImmoScout24_1410_30_._" -H "Accept: application/json" -H "Content-Type: application/json" -d '{"supportedResultListType": [], "userData": {}}'`
* - GET /expose/{id} - Returns the details of a listing. The response contains additional details not included in the
* listing response.
*
* Example: `curl -H "User-Agent: ImmoScout24_1410_30_._" "https://api.mobile.immobilienscout24.de/expose/158382494"`
*
*
* It is necessary to set the correct User Agent (see `getListings`) in the request header.
*
* Note that the mobile API is not publicly documented. I've reverse-engineered
* it by intercepting traffic from an android emulator running the immoscout app.
* Moreover, the search parameters differ slightly from the web API. I've mapped them
* to the web API parameters by comparing a search request with all parameters set between
* the web and mobile API. The mobile API actually seems to be a superset of the web API,
* 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';
let appliedBlackList = [];
async function getListings(url) {
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);
return [];
}
const responseBody = await response.json();
return responseBody.resultListItems
.filter((item) => item.type === 'EXPOSE_RESULT')
.map((expose) => {
const item = expose.item;
const [price, size] = item.attributes;
return {
id: item.id,
price: price?.value,
size: size?.value,
title: item.title,
link: `${metaInformation.baseUrl}expose/${item.id}`,
address: item.address?.line,
};
});
}
function nullOrEmpty(val) {
return val == null || val.length === 0;
}
function normalize(o) {
const title = nullOrEmpty(o.title) ? 'NO TITLE FOUND' : o.title.replace('NEU', '');
const address = nullOrEmpty(o.address) ? 'NO ADDRESS FOUND' : (o.address || '').replace(/\(.*\),.*$/, '').trim();
const id = buildHash(o.id, o.price);
return Object.assign(o, { id, title, address });
}
function applyBlacklist(o) {
return !utils.isOneOf(o.title, appliedBlackList);
}
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',
price: 'price',
size: 'size',
link: 'link',
address: 'address',
},
normalize: normalize,
filter: applyBlacklist,
getListings: getListings,
};
export const init = (sourceConfig, blacklist) => {
config.enabled = sourceConfig.enabled;
config.url = convertWebToMobile(sourceConfig.url);
appliedBlackList = blacklist || [];
};
export const metaInformation = {
name: 'Immoscout',
baseUrl: 'https://www.immobilienscout24.de/',
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 };