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 fetch from 'node-fetch';
import pThrottle from 'p-throttle';
import lodash from 'lodash';
const MAX_ENTITIES_PER_CHUNK = 8;
const RATE_LIMIT_INTERVAL = 1000;
const chatThrottleMap = new Map();
const pollingTokens = new Set();
const updateOffsets = new Map();
function cleanupOldThrottles() {
const now = Date.now();
@@ -44,55 +47,120 @@ function getThrottled(chatId, call) {
return newThrottle.throttled;
}
/**
* splitting an array into chunks because Telegram only allows for messages up to
* 4096 chars, thus we have to split messages into chunks
* @param inputArray
* @param perChunk
*/
const arrayChunks = (inputArray, perChunk) =>
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;
function getCallbackUpdates(token) {
const offset = updateOffsets.get(token) || 0;
return fetch(
`https://api.telegram.org/bot${token}/getUpdates?allowed_updates=["callback_query"]&timeout=30&offset=${offset}`,
{
method: 'get',
headers: { 'Content-Type': 'application/json' },
},
);
}
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`, {
method: 'post',
body: JSON.stringify(body),
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 inlineButtons = [];
messageParagraphs.push(`<i>${jobName}</i> (${serviceName}) found <b>${newListings.length}</b> new listings:`);
messageParagraphs.push(
...chunk.map(
(o) =>
`<a href='${o.link}'><b>${shorten(o.title.replace(/\*/g, ''), 45).trim()}</b></a>\n` +
[o.address, o.price, o.size].join(' | '),
),
`<i>${jobName}</i> (${serviceName}) found <b>${newListings.length}</b> new listings (${chunkIndex + 1}/${chunks.length}):`,
);
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,
text: messageParagraphs.join('\n\n'),
parse_mode: 'HTML',
disable_web_page_preview: true,
};
return getThrottledSend(body);
reply_markup: {
inline_keyboard: lodash.chunk(inlineButtons, 4),
},
});
});
return Promise.all(promises);
};

View File

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

View File

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

View File

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