|
| 1 | +/** |
| 2 | + * ImmoScout provider using the mobile API to retrieve listings. |
| 3 | + * |
| 4 | + * The mobile API provides the following endpoints: |
| 5 | + * - GET /search/total?{search parameters}: Returns the total number of listings for the given query |
| 6 | + * 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 ` |
| 7 | + * |
| 8 | + * - POST /search/list?{search parameters}: Actually retrieves the listings. Body is json encoded and contains |
| 9 | + * data specifying additional results (advertisements) to return. The format is as follows: |
| 10 | + * ``` |
| 11 | + * { |
| 12 | + * "supportedResultListTypes": [], |
| 13 | + * "userData": {} |
| 14 | + * } |
| 15 | + * ``` |
| 16 | + * It is not necessary to provide data for the specified keys. |
| 17 | + * |
| 18 | + * 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": {}}'` |
| 19 | +
|
| 20 | + * - GET /expose/{id} - Returns the details of a listing. The response contains additional details not included in the |
| 21 | + * listing response. |
| 22 | + * |
| 23 | + * Example: `curl -H "User-Agent: ImmoScout24_1410_30_._" "https://api.mobile.immobilienscout24.de/expose/158382494"` |
| 24 | + * |
| 25 | + * |
| 26 | + * It is necessary to set the correct User Agent (see `getListings`) in the request header. |
| 27 | + * |
| 28 | + * Note that the mobile API is not publicly documented. I've reverse-engineered |
| 29 | + * it by intercepting traffic from an android emulator running the immoscout app. |
| 30 | + * Moreover, the search parameters differ slightly from the web API. I've mapped them |
| 31 | + * to the web API parameters by comparing a search request with all parameters set between |
| 32 | + * the web and mobile API. The mobile API actually seems to be a superset of the web API, |
| 33 | + * but I have decided not to include new parameters as I wanted to keep the existing UX (i.e., |
| 34 | + * users only have to provide a link to an existing search). |
| 35 | + * |
| 36 | + * Limitations: |
| 37 | + * - The current implementation of this provider *does not* support non-rental properties, |
| 38 | + * although the same approach can be used to implement support. It's just a matter of |
| 39 | + * mapping the web search URL to the corresponding mobile API URL. |
| 40 | + * - Pagination support is not implemented. |
| 41 | + */ |
| 42 | + |
| 43 | +import utils, {buildHash} from '../utils.js'; |
| 44 | +import queryString from 'query-string'; |
| 45 | +let appliedBlackList = []; |
| 46 | + |
| 47 | +async function getListings(url) { |
| 48 | + const response = await fetch(url, { |
| 49 | + method: 'POST', |
| 50 | + headers: { |
| 51 | + 'User-Agent': 'ImmoScout24_1410_30_._', |
| 52 | + 'Content-Type': 'application/json', |
| 53 | + }, |
| 54 | + body: JSON.stringify({ |
| 55 | + supportedResultListTypes: [], |
| 56 | + userData: {} |
| 57 | + }) |
| 58 | + }); |
| 59 | + if (!response.ok) { |
| 60 | + console.error('Error fetching data from ImmoScout Mobile API:', response.statusText); |
| 61 | + return []; |
| 62 | + } |
| 63 | + |
| 64 | + const responseBody = await response.json(); |
| 65 | + return responseBody.resultListItems.filter((item) => item.type === 'EXPOSE_RESULT').map(expose => { |
| 66 | + const item = expose.item; |
| 67 | + const [price, size, ] = item.attributes; |
| 68 | + return { |
| 69 | + id: item.id, |
| 70 | + price: price?.value, |
| 71 | + size: size?.value, |
| 72 | + title: item.title, |
| 73 | + link: `${metaInformation.baseUrl}/expose/${item.id}`, |
| 74 | + address: item.address?.line, |
| 75 | + }; |
| 76 | + }); |
| 77 | +} |
| 78 | + |
| 79 | +function nullOrEmpty(val) { |
| 80 | + return val == null || val.length === 0; |
| 81 | +} |
| 82 | +function normalize(o) { |
| 83 | + const title = nullOrEmpty(o.title) ? 'NO TITLE FOUND' : o.title.replace('NEU', ''); |
| 84 | + const address = nullOrEmpty(o.address) ? 'NO ADDRESS FOUND' : (o.address || '').replace(/\(.*\),.*$/, '').trim(); |
| 85 | + const id = buildHash(o.id, o.price); |
| 86 | + return Object.assign(o, { id, title, address}); |
| 87 | +} |
| 88 | +function applyBlacklist(o) { |
| 89 | + return !utils.isOneOf(o.title, appliedBlackList); |
| 90 | +} |
| 91 | +const config = { |
| 92 | + url: null, |
| 93 | + sortByDateParam: 'sorting=-firstactivation', |
| 94 | + // Not actually required - used by filter to remove and listings that failed to parse |
| 95 | + crawlFields: { |
| 96 | + 'id': 'id', |
| 97 | + 'title': 'title', |
| 98 | + 'price': 'price', |
| 99 | + 'size': 'size', |
| 100 | + 'link': 'link', |
| 101 | + 'address': 'address' |
| 102 | + }, |
| 103 | + normalize: normalize, |
| 104 | + filter: applyBlacklist, |
| 105 | + getListings: getListings |
| 106 | +}; |
| 107 | +export const init = (sourceConfig, blacklist) => { |
| 108 | + config.enabled = sourceConfig.enabled; |
| 109 | + config.url = convertWebToMobile(sourceConfig.url); |
| 110 | + appliedBlackList = blacklist || []; |
| 111 | +}; |
| 112 | +export const metaInformation = { |
| 113 | + name: 'Immoscout', |
| 114 | + baseUrl: 'https://www.immobilienscout24.de/', |
| 115 | + id: 'immoscout-mobile', |
| 116 | +}; |
| 117 | + |
| 118 | +export function convertWebToMobile(webUrl) { |
| 119 | + let url; |
| 120 | + try { |
| 121 | + url = new URL(webUrl); |
| 122 | + } catch (err) { |
| 123 | + throw new Error(`Invalid URL: ${webUrl}`); |
| 124 | + } |
| 125 | + const segments = url.pathname.split('/'); |
| 126 | + if (segments.length < 6 || segments[1] !== 'Suche') { |
| 127 | + throw new Error(`Unexpected path format: ${url.pathname}`); |
| 128 | + } |
| 129 | + const geocodes = `/${segments[2]}/${segments[3]}/${segments[4]}`; |
| 130 | + |
| 131 | + const paramNameMap = { |
| 132 | + heatingtypes: 'heatingtypes', |
| 133 | + haspromotion: 'haspromotion', |
| 134 | + numberofrooms: 'numberofrooms', |
| 135 | + livingspace: 'livingspace', |
| 136 | + energyefficiencyclasses: 'energyefficiencyclasses', |
| 137 | + exclusioncriteria: 'exclusioncriteria', |
| 138 | + equipment: 'equipment', |
| 139 | + petsallowedtypes: 'petsallowedtypes', |
| 140 | + price: 'price', |
| 141 | + constructionyear: 'constructionyear', |
| 142 | + apartmenttypes: 'apartmenttypes', |
| 143 | + pricetype: 'pricetype', |
| 144 | + floor: 'floor' |
| 145 | + }; |
| 146 | + |
| 147 | + const equipmentValueMap = { |
| 148 | + parking: 'parking', |
| 149 | + cellar: 'cellar', |
| 150 | + builtinkitchen: 'builtInKitchen', |
| 151 | + lift: 'lift', |
| 152 | + garden: 'garden', |
| 153 | + guesttoilet: 'guestToilet', |
| 154 | + balcony: 'balcony' |
| 155 | + }; |
| 156 | + |
| 157 | + const { query: webParams } = queryString.parseUrl(webUrl, { arrayFormat: 'comma' }); |
| 158 | + delete webParams['enteredFrom']; |
| 159 | + |
| 160 | + // Check for unsupported parameters |
| 161 | + Object.keys(webParams).forEach((key) => { |
| 162 | + if (!paramNameMap[key]) { |
| 163 | + throw new Error(`Unsupported Web-API parameter: "${key}"`); |
| 164 | + } |
| 165 | + }); |
| 166 | + |
| 167 | + // Build mobile params |
| 168 | + const mobileParams = { |
| 169 | + searchType: 'region', |
| 170 | + geocodes, |
| 171 | + realestatetype: 'apartmentrent' |
| 172 | + }; |
| 173 | + |
| 174 | + Object.entries(webParams).forEach(([webKey, webVal]) => { |
| 175 | + let value = webVal; |
| 176 | + |
| 177 | + if (webKey === 'equipment') { |
| 178 | + // Map equipment list to camelCase values |
| 179 | + if (!Array.isArray(value)) { |
| 180 | + value = ('' + value).split(','); |
| 181 | + } |
| 182 | + value = value.map((token) => { |
| 183 | + const lower = token.toLowerCase(); |
| 184 | + if (!equipmentValueMap[lower]) { |
| 185 | + throw new Error(`Unknown equipment type: "${token}"`); |
| 186 | + } |
| 187 | + return equipmentValueMap[lower]; |
| 188 | + }); |
| 189 | + } |
| 190 | + |
| 191 | + mobileParams[paramNameMap[webKey]] = value; |
| 192 | + }); |
| 193 | + |
| 194 | + const mobileQuery = queryString.stringify(mobileParams, { |
| 195 | + arrayFormat: 'comma', |
| 196 | + encode: true, |
| 197 | + skipEmptyString: true |
| 198 | + }); |
| 199 | + |
| 200 | + return `https://api.mobile.immobilienscout24.de/search/list?${mobileQuery}`; |
| 201 | +} |
| 202 | + |
| 203 | +export { config }; |
0 commit comments