This commit is contained in:
Alexander Roidl
2025-07-31 21:29:34 +02:00
parent 210312b87c
commit 6c1786dcc2
5 changed files with 107 additions and 35 deletions

View File

@@ -1 +1 @@
{"interval":"60","port":9998,"workingHours":{"from":"","to":""},"demoMode":false,"analyticsEnabled":null} {"interval":"60","port":9998,"workingHours":{"from":"","to":""},"demoMode":false,"analyticsEnabled":false}

View File

@@ -2,10 +2,13 @@ import { markdown2Html } from '../../services/markdown.js';
import { getJob } from '../../services/storage/jobStorage.js'; import { getJob } from '../../services/storage/jobStorage.js';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import pThrottle from 'p-throttle'; import pThrottle from 'p-throttle';
import lodash from 'lodash';
const MAX_ENTITIES_PER_CHUNK = 8; const MAX_ENTITIES_PER_CHUNK = 8;
const RATE_LIMIT_INTERVAL = 1000; const RATE_LIMIT_INTERVAL = 1000;
const chatThrottleMap = new Map(); const chatThrottleMap = new Map();
const pollingTokens = new Set();
const updateOffsets = new Map();
function cleanupOldThrottles() { function cleanupOldThrottles() {
const now = Date.now(); const now = Date.now();
@@ -44,55 +47,120 @@ function getThrottled(chatId, call) {
return newThrottle.throttled; return newThrottle.throttled;
} }
/** function getCallbackUpdates(token) {
* splitting an array into chunks because Telegram only allows for messages up to const offset = updateOffsets.get(token) || 0;
* 4096 chars, thus we have to split messages into chunks return fetch(
* @param inputArray `https://api.telegram.org/bot${token}/getUpdates?allowed_updates=["callback_query"]&timeout=30&offset=${offset}`,
* @param perChunk {
*/ method: 'get',
const arrayChunks = (inputArray, perChunk) => headers: { 'Content-Type': 'application/json' },
inputArray.reduce((all, one, i) => { },
const ch = Math.floor(i / perChunk); );
all[ch] = [].concat(all[ch] || [], one);
return all;
}, []);
function shorten(str, len = 30) {
return str.length > len ? str.substring(0, len) + '...' : str;
} }
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
const { token, chatId } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
const job = getJob(jobKey);
const jobName = job == null ? jobKey : job.name;
const chunks = arrayChunks(newListings, MAX_ENTITIES_PER_CHUNK);
const getThrottledSend = getThrottled(chatId, async function (body) { function getThrottledSend(token, chatId) {
return getThrottled(chatId, async function (body) {
await fetch(`https://api.telegram.org/bot${token}/sendMessage`, { await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
method: 'post', method: 'post',
body: JSON.stringify(body), body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
}); });
}
const promises = chunks.map((chunk) => { async function pollCallbackUpdates(token) {
setTimeout(() => pollCallbackUpdates(token), 1000);
let callbackUpdates;
try {
callbackUpdates = await getCallbackUpdates(token);
} catch (error) {
console.error('An error occurred when polling callback updates.', error);
return;
}
const updatesData = await callbackUpdates.json();
if (!updatesData.ok || !updatesData.result || updatesData.result.length === 0) {
return;
}
// Process each callback query
for (const update of updatesData.result) {
if (update.callback_query) {
const callbackQuery = update.callback_query;
const callbackQueryId = callbackQuery.id;
try {
// Answer the callback query to remove the loading state
await fetch(`https://api.telegram.org/bot${token}/answerCallbackQuery`, {
method: 'post',
body: JSON.stringify({
callback_query_id: callbackQueryId,
text: '✅ Opening listing...',
show_alert: false,
}),
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('Error answering callback query:', error);
}
}
// Update offset to avoid processing the same update again
updateOffsets.set(token, update.update_id + 1);
}
}
function startCallbackPolling(token) {
if (!pollingTokens.has(token)) {
pollingTokens.add(token);
pollCallbackUpdates(token);
}
}
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
const { token, chatId } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
const throttledSend = getThrottledSend(token, chatId);
// Start callback polling for this token if not already started
startCallbackPolling(token);
const job = getJob(jobKey);
const jobName = job == null ? jobKey : job.name;
const chunks = lodash.chunk(newListings, MAX_ENTITIES_PER_CHUNK);
const promises = chunks.map((listingsInChunk, chunkIndex) => {
const messageParagraphs = []; const messageParagraphs = [];
const inlineButtons = [];
messageParagraphs.push(`<i>${jobName}</i> (${serviceName}) found <b>${newListings.length}</b> new listings:`);
messageParagraphs.push( messageParagraphs.push(
...chunk.map( `<i>${jobName}</i> (${serviceName}) found <b>${newListings.length}</b> new listings (${chunkIndex + 1}/${chunks.length}):`,
(o) =>
`<a href='${o.link}'><b>${shorten(o.title.replace(/\*/g, ''), 45).trim()}</b></a>\n` +
[o.address, o.price, o.size].join(' | '),
),
); );
const body = { listingsInChunk.forEach((listing, listingIndex) => {
const normalizedTitle = listing.title.replace(/\*/g, '').trim();
const titleExcerpt = lodash.truncate(normalizedTitle, { length: 45, omission: '…' });
messageParagraphs.push(`
${listingIndex + 1}. <a href='${listing.link}'><b>${titleExcerpt}</b></a>
${[listing.address, listing.price, listing.size].join(' | ')}`);
inlineButtons.push({
text: `${listingIndex + 1}`,
url: listing.link,
});
});
return throttledSend({
chat_id: chatId, chat_id: chatId,
text: messageParagraphs.join('\n\n'), text: messageParagraphs.join('\n\n'),
parse_mode: 'HTML', parse_mode: 'HTML',
disable_web_page_preview: true, disable_web_page_preview: true,
}; reply_markup: {
inline_keyboard: lodash.chunk(inlineButtons, 4),
return getThrottledSend(body); },
});
}); });
return Promise.all(promises); return Promise.all(promises);
}; };

View File

@@ -36,7 +36,7 @@
*/ */
import utils, { buildHash } from '../utils.js'; import utils, { buildHash } from '../utils.js';
import { convertWebToMobile } from '../services/immoscout/immoscout-web-translator.js'; import { convertWebToMobile } from '../services/immoscout/index.js';
let appliedBlackList = []; let appliedBlackList = [];
async function getListings(url) { async function getListings(url) {

View File

@@ -164,6 +164,8 @@ export function convertWebToMobile(webUrl) {
const mobileParams = { const mobileParams = {
searchType: isRadius ? 'radius' : 'region', searchType: isRadius ? 'radius' : 'region',
realestatetype: realType, realestatetype: realType,
sorting: '-firstactivation',
pagesize: 25,
...(isRadius ? {} : { geocodes }), ...(isRadius ? {} : { geocodes }),
...additionalParamsFromWebPath, ...additionalParamsFromWebPath,
}; };
@@ -180,9 +182,10 @@ export function convertWebToMobile(webUrl) {
...(currentEquipmentParams ?? []), ...(currentEquipmentParams ?? []),
...items.map((item) => EQUIPMENT_MAP[item.toLowerCase()]).filter(Boolean), ...items.map((item) => EQUIPMENT_MAP[item.toLowerCase()]).filter(Boolean),
]; ];
} else { return;
mobileParams[PARAM_NAME_MAP[key]] = val;
} }
mobileParams[PARAM_NAME_MAP[key]] = val;
} }
const mobileQuery = queryString.stringify(mobileParams, { const mobileQuery = queryString.stringify(mobileParams, {

View File

@@ -0,0 +1 @@
export * from './convertWebToMobile.js';