Compare commits

..

3 Commits

Author SHA1 Message Date
orangecoding
db3702ed33 improve markdown readme's & and adding ability to send telegram messages to a topic in a supergroup 2025-10-30 12:42:03 +01:00
orangecoding
e3c62d4696 fixing test runner 2025-10-29 10:35:07 +01:00
orangecoding
79a8420dfb improving similarity cache 2025-10-29 09:36:05 +01:00
37 changed files with 320 additions and 309 deletions

View File

@@ -37,6 +37,9 @@ await runMigrations();
// Load provider modules once at startup
const providers = await getProviders();
similarityCache.initSimilarityCache();
similarityCache.startSimilarityCacheReloader();
//assuming interval is always in minutes
const INTERVAL = config.interval * 60 * 1000;

View File

@@ -183,8 +183,12 @@ class FredyPipeline {
* @returns {Listing[]} Listings considered unique enough to keep.
*/
_filterBySimilarListings(listings) {
const filteredList = listings.filter((listing) => {
const similar = this._similarityCache.hasSimilarEntries(listing.title, listing.address);
return listings.filter((listing) => {
const similar = this._similarityCache.checkAndAddEntry({
title: listing.title,
address: listing.address,
price: listing.price,
});
if (similar) {
logger.debug(
`Filtering similar entry for title '${listing.title}' and address '${listing.address}' (Provider: '${this._providerId}')`,
@@ -192,8 +196,6 @@ class FredyPipeline {
}
return !similar;
});
filteredList.forEach((filter) => this._similarityCache.addCacheEntry(filter.title, filter.address));
return filteredList;
}
/**

View File

@@ -1,3 +1,8 @@
### Apprise Adapter
Refer to the [instructions](https://github.com/caronc/apprise-api#installation) on how to set up an Apprise instance and how to configure your preferred notification service.
Use [Apprise](https://github.com/caronc/apprise-api#installation) to forward notifications to many different services.
Quick start:
- Set up an Apprise API instance (see the installation guide linked above).
- Configure your preferred notification service(s) within Apprise.
- In Fredy, point the Apprise adapter to your Apprise API endpoint.

View File

@@ -1,4 +1,3 @@
### Console Adapter
The console adapter prints everything found by Fredy into the console (not sending any notifications to you). This can be useful when you want to check if your search
criteria meet the expectations.
The console adapter prints everything found by Fredy to the console (it does not send notifications). This is useful to verify that your search criteria work as expected before enabling a real notification service.

View File

@@ -1,4 +1,8 @@
### Discord Adapter
### Discord Webhook Adapter
To use the [Discord](https://discord.com/) Adapter, you need to create a webhook on the Discord channel of your choice. You can follow the instructions of _Making A Webhook_ on [this support website](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks).
Once you have created a webhook, copy and paste the webhook URL.
Use a Discord channel webhook to receive notifications.
Quick start:
- Create a webhook in your target Discord channel. See the "Intro to Webhooks" guide on the Discord support site: https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks
- Copy the generated webhook URL.
- In Fredy, configure the Discord adapter with this webhook URL.

View File

@@ -1,8 +1,8 @@
### MailJet Adapter
### Mailjet Adapter
To use [MailJet](https://mailjet.com), you need to create an account. You'll need to decide from which email address you want Fredy to send from.
To use [Mailjet](https://mailjet.com), create an account and decide which email address Fredy should send from.
E.g. if you use yourGmailAccount@gmail.com, you have to add this to MailJet and verify it as well.
The given public/private api keys are needed in order to use MailJet with Fredy. Fredy will use the same template, it is using for SendGrid.
For example, if you use yourGmailAccount@gmail.com, add and verify this address in Mailjet.
Provide your public/private API keys in Fredy's configuration. Fredy uses the same email template as for SendGrid.
If this email should be sent to multiple receiver, use a comma separator (some@email.com, someOther@email.com).
To send to multiple recipients, separate email addresses with commas (e.g., some@email.com, someOther@email.com).

View File

@@ -1,5 +1,8 @@
### Mattermost Adapter
For Mattermost, you need to create a incoming webhook. This is pretty easy. Please visit the steps in the [developer docs](https://docs.mattermost.com/developer/webhooks-incoming.html) and follow the instructions.
Receive notifications in Mattermost via an incoming webhook.
As a result, you get the webhook URL for configuration in fredy. In addition, the target channel must be defined.
Quick start:
- Create an incoming webhook following the Mattermost developer docs: https://docs.mattermost.com/developer/webhooks-incoming.html
- Copy the webhook URL.
- In Fredy, configure the Mattermost adapter with this URL and the target channel.

View File

@@ -1,5 +1,8 @@
### ntfy Adapter
For ntfy, you need to create a topic on your preferred ntfy instance. This is pretty easy. Please visit the steps in the [docs](https://docs.ntfy.sh/publish/) and follow the instructions.
Send push notifications using an ntfy topic.
As a result, you get the URL for configuration in fredy. In addition, the priority must be defined.
Quick start:
- Create or choose a topic on your preferred ntfy instance (see docs: https://docs.ntfy.sh/publish/).
- Copy the publish URL for that topic.
- In Fredy, configure the ntfy adapter with the topic URL and set a priority.

View File

@@ -1,5 +1,8 @@
### Pushover Adapter
Refer to the [instructions](https://support.pushover.net/i7-what-is-pushover-and-how-do-i-use-it) to set up your Pushover application.
Use Pushover to receive push notifications on your devices.
After setting up the application, please enter both your newly created User key and API token.
Setup:
- Follow Pushover's getting-started guide: https://support.pushover.net/i7-what-is-pushover-and-how-do-i-use-it
- Create an application and obtain your User Key and API Token.
- In Fredy, configure the Pushover adapter with both values.

View File

@@ -1,9 +1,12 @@
### SendGrid Adapter
SendGrid is a free email service (free as in "you cannot send more than 100(Sendgrid) and 200(Mailjet) emails a day"), which is more than enough for Fredy.
SendGrid is an email delivery service with a generous free tier, which is more than enough for Fredy.
To use [SendGrid](https://sendgrid.com/), you need to create an account. You'll need to decided from which email address you want Fredy to send from. E.g. if you use yourGmailAccount@gmail.com, you have to add this to sendgrid and verify it as well.
Setup:
- Create a SendGrid account: https://sendgrid.com/
- Decide which email address Fredy should send from (e.g., yourGmailAccount@gmail.com), add it to SendGrid, and complete the verification.
- Create an API key and add it to Fredy's configuration.
- Create a Dynamic Template in SendGrid. You can copy the template from `/lib/notification/emailTemplate/template.hbs`.
Lastly you have to create an api-key and feed it into Fredy's config, as well as creating a new dynamic template. For this new template, I recommend copying and pasting the code from the one I have provided under `/lib/notification/emailTemplate/template.hbs`.
If this email should be sent to multiple receiver use a comma separator (some@email.com, someOther@email.com).
Sending to multiple recipients:
- Separate email addresses with commas (e.g., some@email.com, someOther@email.com).

View File

@@ -1,4 +1,5 @@
### Slack Adapter
IMPORTANT:
Don't use this adapter anymore, it is outdated and only here for backwards compatability reasons. Use the new Slack Adapter with webhooks!
### Slack Adapter (Legacy)
*IMPORTANT:*
This legacy adapter is outdated and kept only for backward compatibility. Please use the Slack adapter with webhooks instead.

View File

@@ -1,6 +1,10 @@
### Slack Adapter
### Slack Adapter (Webhooks)
IMPORTANT:
This is the new version of the Slack adapter. I strongly encourage you to use it, the old version is now unmaintained and only kept due to backwards compatability reasons.
*IMPORTANT:*
This is the recommended Slack adapter. The old Slack adapter is unmaintained and kept only for backward compatibility.
In order to use [Slack](https://slack.com), you need to create an account. When done, create a new channel and add the Webhook integration to that channel. Copy the webhook url. That's it.
Setup:
- Create a Slack account and workspace if you don't have one: https://slack.com
- Create a channel where you want to receive notifications.
- Add the Incoming Webhooks integration to that channel and copy the Webhook URL.
- In Fredy, configure the Slack Webhook adapter with this URL.

View File

@@ -1,9 +1,21 @@
### SQLite Adapter
This adapter stores search results in an SQLite database. By default, the database is located at `db/listings.db`, but you can configure a custom location. This file can be used for further analysis later.
This adapter stores search results in an SQLite database. By default, the database is located at `db/listings.db`, but you can configure a custom location. The file can be used for analysis later.
The database table contains the following columns (all stored as `TEXT` type):
The table contains the following columns (all stored as `TEXT`):
```
['serviceName', 'jobKey', 'id', 'size', 'rooms', 'price', 'address', 'title', 'link', 'description', 'image']
```json
[
"serviceName",
"jobKey",
"id",
"size",
"rooms",
"price",
"address",
"title",
"link",
"description",
"image"
]
```

View File

@@ -117,10 +117,24 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
if (!adapterCfg || !adapterCfg.fields) {
throw new Error(`Telegram adapter configuration missing for job '${jobKey || ''}'`);
}
const { token, chatId } = adapterCfg.fields;
const { token, chatId, messageThreadId } = adapterCfg.fields;
if (!token || !chatId) {
throw new Error("Telegram 'token' and 'chatId' must be provided in notification config");
}
// Optional Telegram topic/thread support (supergroups)
let message_thread_id;
if (messageThreadId !== undefined && messageThreadId !== null && `${messageThreadId}`.trim() !== '') {
const n = Number(messageThreadId);
if (Number.isInteger(n) && n > 0) {
message_thread_id = n;
} else {
logger.warn(
`Telegram adapter: 'messageThreadId' is invalid ('${messageThreadId}'). It must be a positive integer. Ignoring.`,
);
}
}
const job = getJob(jobKey);
const jobName = job == null ? jobKey : job.name;
@@ -147,6 +161,7 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
text: buildText(jobName, serviceName, o),
parse_mode: 'HTML',
disable_web_page_preview: true,
...(message_thread_id ? { message_thread_id } : {}),
};
if (!img) {
@@ -160,6 +175,7 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
photo: img,
caption: buildCaption(jobName, serviceName, o),
parse_mode: 'HTML',
...(message_thread_id ? { message_thread_id } : {}),
}).catch(async (e) => {
logger.error(`Error sending photo to Telegram and use a fallback: ${e.message}`);
return await throttledCall('sendMessage', textPayload).catch((e) => {
@@ -174,7 +190,7 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
/**
* Telegram notification adapter configuration schema.
* @type {{id:string,name:string,readme:string,description:string,fields:{token:{type:string,label:string,description:string},chatId:{type:string,label:string,description:string}}}}
* @type {{id:string,name:string,readme:string,description:string,fields:{token:{type:string,label:string,description:string},chatId:{type:string,label:string,description:string},messageThreadId?:{type:string,label:string,description:string}}}}
*/
export const config = {
id: 'telegram',
@@ -192,5 +208,12 @@ export const config = {
label: 'Chat Id',
description: 'The chat id to send messages to you.',
},
messageThreadId: {
type: 'text',
optional: true,
label: 'Message Thread Id (optional)',
description:
'Optional: The topic/thread id within a supergroup to post into (Telegram message_thread_id). Provide a positive integer.',
},
},
};

View File

@@ -1,12 +1,55 @@
### Telegram Adapter
For Telegram, you need to create a Bot. This is pretty easy. Open [this](https://telegram.me/BotFather) url on your smartphone and follow the instructions.
Use this adapter to send notifications to Telegram via a bot. You will need:
- A Telegram Bot token (from BotFather)
- A chat ID (where messages will be sent)
- Optionally: a thread ID if you want to post into a specific forum topic in a group
A telegram bot is not allowed to send messages directly to a user, you as a user need to first contact the bot to get a chatId.
After the user has send a message to your bot the first time, you can gather the chatId like this:
#### Create a bot
Create a bot with BotFather: open https://telegram.me/BotFather on your phone or in Telegram Desktop and follow the instructions to get your bot token.
#### Getting the chat ID
A Telegram bot cannot message a user first; you must create a conversation (or add the bot to a group/channel) so Telegram assigns a chat the bot can access.
Steps:
1. Start a chat with your bot in Telegram (or add the bot to your group/supergroup/channel) and send any message.
2. Fetch recent updates from the Bot API:
```
curl -X GET "https://api.telegram.org/bot{YOUR_TELEGRAM_TOKEN}/getUpdates"
```
3. In the JSON response, find the message that you just sent and read `message.chat.id`. That value is your `chatId`.
- Private chats: `chat.id` is a positive number
- Groups/supergroups: `chat.id` is a negative number
Keep your bot token secret. If `getUpdates` returns an empty list, send a new message and try again, or make sure your bots privacy settings allow it to see group messages when used in groups.
#### Getting the thread ID (this is optional to be used for forum topics)
If you want messages to appear inside a specific forum topic of a supergroup with Topics enabled, you also need a thread ID. In the Telegram Bot API this is called `message_thread_id`.
When you need it:
- Required only for supergroups with Topics enabled when targeting a topic
- Not used for private chats, basic groups without Topics, or channels
Steps to obtain it:
1. In your supergroup, enable Topics (Group settings → Manage group → Topics → Enable). Now add a new topic.
2. Add your created bot to the topic. (Click on the bot and on "Add to group")
3. Open the desired topic (or create a new one) and send any message inside that topic.
4. Call `getUpdates` again:
```
curl -X GET "https://api.telegram.org/bot{YOUR_TELEGRAM_TOKEN}/getUpdates"
```
4. In the update for the message you sent inside the topic, read `message.message_thread_id`. That number is your `threadId` for this topic.
Example (truncated):
```
curl -X GET https://api.telegram.org/bot{YOUR_TELEGRAM_TOKEN}/getUpdates
{
"message": {
"chat": { "id": -1001234567890, "type": "supergroup" },
"message_thread_id": 42,
"text": "hello from the topic"
}
}
```
Use `chat.id` as `chatId` and `message_thread_id` as `threadId` in your configuration.
A more detailed list of instructions can be found here [https://core.telegram.org/bots#botfather](https://core.telegram.org/bots#botfather)
More details about bots and BotFather: https://core.telegram.org/bots#botfather

View File

@@ -1,6 +1,4 @@
import markdown$0 from 'markdown';
import fs from 'fs';
const markdown = markdown$0.markdown;
export function markdown2Html(filePath) {
return markdown.toHTML(fs.readFileSync(filePath, 'utf8'));
return fs.readFileSync(filePath, 'utf8');
}

View File

@@ -1,116 +1,94 @@
import crypto from 'crypto';
const retention = 60 * 60 * 1000;
/**
* Internal cache storage.
* Maps a SHA-256 hash (string) to its expiry timestamp (number in ms).
* @type {Map<string, number>}
*/
const entries = new Map();
/**
* Reference to the currently scheduled cleanup timer.
* @type {NodeJS.Timeout | null}
*/
let timer = null;
/**
* Generate a SHA-256 hash from a list of input strings.
* Null or undefined values are ignored.
* Similarity cache
*
* @param {...(string|null|undefined)} strings - Input values to hash
* Maintains an in-memory Set of content hashes to detect whether a listing
* (identified by a tuple of title, price and address) has been seen before.
*
* Design notes:
* - The cache is refreshed periodically from persistent storage. To avoid
* modification-during-iteration issues, the refresh builds a new Set and
* atomically swaps the reference instead of mutating in place.
* - Hashing ignores null/undefined values but preserves falsy-yet-valid values
* like 0. Non-string values are coerced to strings before hashing.
*
* This module has no persistence of its own; it relies on
* getAllEntriesFromListings() for data hydration.
* @module similarityCache
*/
import crypto from 'crypto';
import { getAllEntriesFromListings } from '../storage/listingsStorage.js';
/** @type {number} Refresh interval in milliseconds (defaults to one hour). */
const reloadCycle = 60 * 60 * 1000; // every hour, refresh
/**
* Internal cache of content hashes for known listings.
*
* Each entry is an SHA-256 hex digest produced by toHash(title, price, address).
* @type {Set<string>}
*/
let cache = new Set();
export const startSimilarityCacheReloader = () => {
// Periodically refresh the cache from storage
setInterval(() => {
initSimilarityCache();
}, reloadCycle);
};
/**
* Initialize or refresh the similarity cache from persistent storage.
*
* Reads all stored listings via getAllEntriesFromListings(), computes a hash for
* each, and swaps the in-memory Set atomically to avoid in-place mutations that
* could interfere with concurrent iteration.
*
* This function is idempotent and safe to call at any time.
* @returns {void}
*/
export const initSimilarityCache = () => {
const allEntries = getAllEntriesFromListings();
const newCache = new Set();
for (const entry of allEntries) {
newCache.add(toHash(entry?.title, entry?.price, entry?.address));
}
// Atomic swap to avoid mutating the cache while it may be iterated elsewhere
cache = newCache;
};
/**
* Check if a listing is already known and add it to the cache if not.
*
* The listing is identified by the combination of its title, price and
* address. Null/undefined fields are ignored during hashing. Falsy-but-valid
* values (e.g., price 0) are preserved.
*
* @param {Object} params - Listing fields
* @param {string|undefined|null} params.title - The listing title
* @param {string|undefined|null} params.address - The listing address
* @param {number|string|undefined|null} params.price - The listing price
* @returns {boolean} true if the entry already existed in the cache (duplicate), otherwise false
*/
export const checkAndAddEntry = ({ title, address, price }) => {
const hash = toHash(title, price, address);
if (cache.has(hash)) {
return true;
}
cache.add(hash);
return false;
};
/**
* Generate an SHA-256 hash from a list of input values.
* Null or undefined values are ignored. Falsy but valid values like 0 are preserved.
* Non-string values are coerced to strings prior to hashing.
*
* @param {...(string|number|null|undefined)} strings - Input values to hash
* @returns {string} Hexadecimal hash
*/
function toHash(...strings) {
return crypto.createHash('sha256').update(strings.filter(Boolean).join('|')).digest('hex');
}
/**
* Cleanup expired cache entries and schedule the next cleanup run.
* This function is invoked automatically by scheduled timers.
*
* @private
*/
function runCleanup() {
const now = Date.now();
for (const [hash, expiry] of entries) {
if (expiry <= now) entries.delete(hash);
}
scheduleNext();
}
/**
* Find the soonest expiry timestamp among all cache entries
* and schedule a one-shot timer that will trigger at that time.
* Cancels any existing timer before scheduling a new one.
*
* @private
*/
function scheduleNext() {
if (timer) {
clearTimeout(timer);
timer = null;
}
let next = Infinity;
const now = Date.now();
for (const expiry of entries.values()) {
if (expiry > now && expiry < next) next = expiry;
}
if (next !== Infinity) {
timer = setTimeout(runCleanup, Math.max(0, next - now));
}
}
/**
* Add or refresh a cache entry for the given title and address.
* The entry will automatically expire after the configured retention window.
*
* @param {string} title - The title used to build the cache key
* @param {string} address - The address used to build the cache key
*/
export function addCacheEntry(title, address) {
const hash = toHash(title, address);
const expiry = Date.now() + retention;
entries.set(hash, expiry);
scheduleNext();
}
/**
* Check if a cache entry with the same title and address exists
* and is still valid (not expired).
*
* @param {string} title - The title used to build the cache key
* @param {string} address - The address used to build the cache key
* @returns {boolean} True if a valid cache entry exists, false otherwise
*/
export function hasSimilarEntries(title, address) {
const hash = toHash(title, address);
const expiry = entries.get(hash);
if (expiry == null) return false;
if (expiry <= Date.now()) {
entries.delete(hash);
scheduleNext();
return false;
}
return true;
}
/**
* Stop any scheduled cleanup timers and prevent further automatic cleanup.
* Entries that are already in the cache will remain until removed manually
* or until cleanup is started again by adding new entries.
*/
export function stopCacheCleanup() {
if (timer) clearTimeout(timer);
timer = null;
}
/**
* this is only for test purposes
*/
export function invalidateAllForTest() {
for (const key of entries.keys()) {
entries.set(key, 0);
}
runCleanup();
const normalized = strings
.filter((v) => v !== null && v !== undefined)
.map((v) => (typeof v === 'string' ? v : String(v)));
return crypto.createHash('sha256').update(normalized.join('|')).digest('hex');
}

View File

@@ -310,8 +310,8 @@ export const deleteListingsByJobId = (jobId) => {
if (!jobId) return;
return SqliteConnection.execute(
`DELETE
FROM listings
WHERE job_id = @jobId`,
FROM listings
WHERE job_id = @jobId`,
{ jobId },
);
};
@@ -332,3 +332,13 @@ export const deleteListingsById = (ids) => {
ids,
);
};
/**
* Return all listings with only the fields: title, address, and price.
* This is the single helper requested for simple consumers.
*
* @returns {{title: string|null, address: string|null, price: number|null}[]}
*/
export const getAllEntriesFromListings = () => {
return SqliteConnection.query(`SELECT title, address, price FROM listings`);
};

View File

@@ -1,6 +1,6 @@
{
"name": "fredy",
"version": "14.2.2",
"version": "14.3.1",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": {
"prepare": "husky",
@@ -69,7 +69,6 @@
"cookie-session": "2.1.1",
"handlebars": "4.7.8",
"lodash": "4.17.21",
"markdown": "^0.5.0",
"nanoid": "5.1.6",
"node-cron": "^4.2.1",
"node-fetch": "3.3.2",

View File

@@ -1,53 +0,0 @@
import { expect } from 'chai';
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { mockFredy } from '../utils.js';
describe('FredyPipeline', () => {
afterEach(() => {
similarityCache.invalidateAllForTest();
});
after(() => {
similarityCache.stopCacheCleanup();
});
describe('_filterBySimilarListings', () => {
let fredyRuntime;
beforeEach(async () => {
const FredyRuntime = await mockFredy();
fredyRuntime = new FredyRuntime({}, null, 'dummy-provider', 'dummy-job', similarityCache);
});
it('should filter out listings with similar title and address already in cache', () => {
similarityCache.addCacheEntry('Penthouse', 'Mustermann Straße 1');
const listings = [
{ id: '1', title: 'Penthouse', address: 'Mustermann Straße 1' },
{ id: '2', title: 'Nice apartment', address: 'Mustermann Straße 15' },
];
const result = fredyRuntime._filterBySimilarListings(listings);
expect(result).to.have.length(1);
expect(result[0].id).to.equal('2');
expect(result[0].title).to.equal('Nice apartment');
expect(similarityCache.hasSimilarEntries('Nice apartment', 'Mustermann Straße 15')).to.be.true;
});
it('should handle listings with null or undefined address', () => {
const listings = [
{ id: '1', title: 'Penthouse', address: null },
{ id: '2', title: 'Nice apartment', address: undefined },
];
const result = fredyRuntime._filterBySimilarListings(listings);
expect(result).to.have.length(2);
expect(similarityCache.hasSimilarEntries('Penthouse', null)).to.be.true;
expect(similarityCache.hasSimilarEntries('Nice apartment', undefined)).to.be.true;
});
});
});

View File

@@ -5,9 +5,6 @@ import { expect } from 'chai';
import * as provider from '../../lib/provider/einsAImmobilien.js';
describe('#einsAImmobilien testsuite()', () => {
after(() => {
similarityCache.stopCacheCleanup();
});
provider.init(providerConfig.einsAImmobilien, [], []);
it('should test einsAImmobilien provider', async () => {
const Fredy = await mockFredy();

View File

@@ -5,9 +5,6 @@ import { expect } from 'chai';
import * as provider from '../../lib/provider/immobilienDe.js';
describe('#immobilien.de testsuite()', () => {
after(() => {
similarityCache.stopCacheCleanup();
});
provider.init(providerConfig.immobilienDe, [], []);
it('should test immobilien.de provider', async () => {
const Fredy = await mockFredy();

View File

@@ -5,10 +5,6 @@ import { expect } from 'chai';
import * as provider from '../../lib/provider/immonet.js';
describe('#immonet testsuite()', () => {
after(() => {
similarityCache.stopCacheCleanup();
});
it('should test immonet provider', async () => {
const Fredy = await mockFredy();
provider.init(providerConfig.immonet, [], []);

View File

@@ -5,10 +5,6 @@ import { get } from '../mocks/mockNotification.js';
import * as provider from '../../lib/provider/immoscout.js';
describe('#immoscout provider testsuite()', () => {
after(() => {
similarityCache.stopCacheCleanup();
});
provider.init(providerConfig.immoscout, [], []);
it('should test immoscout provider', async () => {
const Fredy = await mockFredy();

View File

@@ -5,9 +5,6 @@ import { expect } from 'chai';
import * as provider from '../../lib/provider/immoswp.js';
describe('#immoswp testsuite()', () => {
after(() => {
similarityCache.stopCacheCleanup();
});
provider.init(providerConfig.immoswp, [], []);
it('should test immoswp provider', async () => {
const Fredy = await mockFredy();

View File

@@ -5,10 +5,6 @@ import { expect } from 'chai';
import * as provider from '../../lib/provider/immowelt.js';
describe('#immowelt testsuite()', () => {
after(() => {
similarityCache.stopCacheCleanup();
});
it('should test immowelt provider', async () => {
const Fredy = await mockFredy();
provider.init(providerConfig.immowelt, [], []);

View File

@@ -5,9 +5,6 @@ import { expect } from 'chai';
import * as provider from '../../lib/provider/kleinanzeigen.js';
describe('#kleinanzeigen testsuite()', () => {
after(() => {
similarityCache.stopCacheCleanup();
});
it('should test kleinanzeigen provider', async () => {
const Fredy = await mockFredy();
provider.init(providerConfig.kleinanzeigen, [], []);

View File

@@ -5,10 +5,6 @@ import { expect } from 'chai';
import * as provider from '../../lib/provider/mcMakler.js';
describe('#mcMakler testsuite()', () => {
after(() => {
similarityCache.stopCacheCleanup();
});
it('should test mcMakler provider', async () => {
const Fredy = await mockFredy();
provider.init(providerConfig.mcMakler, []);

View File

@@ -5,9 +5,6 @@ import { expect } from 'chai';
import * as provider from '../../lib/provider/neubauKompass.js';
describe('#neubauKompass testsuite()', () => {
after(() => {
similarityCache.stopCacheCleanup();
});
provider.init(providerConfig.neubauKompass, [], []);
it('should test neubauKompass provider', async () => {
const Fredy = await mockFredy();

View File

@@ -5,10 +5,6 @@ import { expect } from 'chai';
import * as provider from '../../lib/provider/regionalimmobilien24.js';
describe('#regionalimmobilien24 testsuite()', () => {
after(() => {
similarityCache.stopCacheCleanup();
});
it('should test regionalimmobilien24 provider', async () => {
const Fredy = await mockFredy();
provider.init(providerConfig.regionalimmobilien24, []);

View File

@@ -5,10 +5,6 @@ import { expect } from 'chai';
import * as provider from '../../lib/provider/sparkasse.js';
describe('#sparkasse testsuite()', () => {
after(() => {
similarityCache.stopCacheCleanup();
});
it('should test sparkasse provider', async () => {
const Fredy = await mockFredy();
provider.init(providerConfig.sparkasse, []);

View File

@@ -5,9 +5,6 @@ import { expect } from 'chai';
import * as provider from '../../lib/provider/wgGesucht.js';
describe('#wgGesucht testsuite()', () => {
after(() => {
similarityCache.stopCacheCleanup();
});
provider.init(providerConfig.wgGesucht, [], []);
it('should test wgGesucht provider', async () => {
const Fredy = await mockFredy();

View File

@@ -1,30 +0,0 @@
import { expect } from 'chai';
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
describe('similarityCheck', () => {
it('should return true on duplicate', () => {
similarityCache.addCacheEntry('Hello World', 'Test');
expect(similarityCache.hasSimilarEntries('Hello World', 'Test')).to.be.true;
});
it('should return true even if one value is null', () => {
similarityCache.addCacheEntry('Hello World', null);
expect(similarityCache.hasSimilarEntries('Hello World', null)).to.be.true;
});
it('should return true even if one value is an obj', () => {
similarityCache.addCacheEntry('Hello World', [{ TR: 'OLOLO' }]);
expect(similarityCache.hasSimilarEntries('Hello World', [{ TR: 'OLOLO' }])).to.be.true;
});
it('should return false when no duplicate', () => {
similarityCache.addCacheEntry('Hello World__', 'Test');
expect(similarityCache.hasSimilarEntries('Hello World___', 'Test')).to.be.false;
});
it('should return false when no duplicate', () => {
expect(similarityCache.hasSimilarEntries('Hello World', 'Test')).to.be.true;
similarityCache.invalidateAllForTest();
expect(similarityCache.hasSimilarEntries('Hello World', 'Test')).to.be.false;
});
});

View File

@@ -0,0 +1,62 @@
import { expect } from 'chai';
import esmock from 'esmock';
// Helper to create module under test with mocks
async function loadModuleWith({ entries = [] } = {}) {
const mod = await esmock('../../lib/services/similarity-check/similarityCache.js', {
// Mock the storage to return our controlled entries
'../../lib/services/storage/listingsStorage.js': {
getAllEntriesFromListings: () => entries,
},
});
return mod;
}
describe('similarityCache', () => {
it('initSimilarityCache builds cache from storage and enables duplicate detection', async () => {
const entries = [
{ title: 'A', price: 1000, address: 'Main 1' },
{ title: 'B', price: 0, address: 'Zero St' },
];
const { initSimilarityCache, checkAndAddEntry } = await loadModuleWith({ entries });
// Initially, duplicates should not be detected for new data
expect(checkAndAddEntry({ title: 'X', price: 200, address: 'Y' })).to.equal(false);
// Now initialize from storage
initSimilarityCache();
// Exact duplicates should be detected
expect(checkAndAddEntry({ title: 'A', price: 1000, address: 'Main 1' })).to.equal(true);
// Ensure falsy-but-valid price 0 is preserved by hashing and detected as duplicate
expect(checkAndAddEntry({ title: 'B', price: 0, address: 'Zero St' })).to.equal(true);
});
it('checkAndAddEntry returns false for new entry then true for duplicate on second call', async () => {
const { checkAndAddEntry } = await loadModuleWith();
const first = checkAndAddEntry({ title: 'C', price: 300, address: 'Road 3' });
const second = checkAndAddEntry({ title: 'C', price: 300, address: 'Road 3' });
expect(first).to.equal(false);
expect(second).to.equal(true);
});
it('hashing ignores null/undefined but preserves 0 via behavior', async () => {
const { checkAndAddEntry } = await loadModuleWith();
// Add baseline (null address ignored)
const add1 = checkAndAddEntry({ title: 'T', price: 1, address: null });
expect(add1).to.equal(false);
// Duplicate with undefined address should match
const dup = checkAndAddEntry({ title: 'T', price: 1, address: undefined });
expect(dup).to.equal(true);
// Now test that price 0 is preserved (not filtered out)
const addZero = checkAndAddEntry({ title: 'Z', price: 0, address: 'Zero' });
expect(addZero).to.equal(false);
const dupZero = checkAndAddEntry({ title: 'Z', price: 0, address: 'Zero' });
expect(dupZero).to.equal(true);
});
});

View File

@@ -21,7 +21,7 @@ const sortAdapter = (a, b) => {
const validate = (selectedAdapter) => {
const results = [];
for (let uiElement of Object.values(selectedAdapter.fields || [])) {
if (uiElement.value == null) {
if (uiElement.value == null && !uiElement.optional) {
results.push('All fields are mandatory and must be set.');
continue;
}
@@ -36,7 +36,7 @@ const validate = (selectedAdapter) => {
results.push('A boolean field cannot be of a different type.');
continue;
}
if (typeof uiElement.value === 'string' && uiElement.value.length === 0) {
if (typeof uiElement.value === 'string' && uiElement.value.length === 0 && !uiElement.optional) {
results.push('All fields are mandatory and must be set.');
}
}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Banner } from '@douyinfe/semi-ui';
import { Banner, MarkdownRender } from '@douyinfe/semi-ui';
export default function Help({ readme }) {
return (
@@ -8,7 +8,7 @@ export default function Help({ readme }) {
type="info"
closeIcon={null}
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Information</div>}
description={<p dangerouslySetInnerHTML={{ __html: readme }} />}
description={<MarkdownRender raw={readme} />}
/>
);
}

View File

@@ -1909,11 +1909,6 @@
"@types/babel__core" "^7.20.5"
react-refresh "^0.17.0"
abbrev@1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==
abs-svg-path@~0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/abs-svg-path/-/abs-svg-path-0.1.1.tgz#df601c8e8d2ba10d4a76d625e236a9a39c2723bf"
@@ -4689,13 +4684,6 @@ markdown-table@^3.0.0:
resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-3.0.4.tgz#fe44d6d410ff9d6f2ea1797a3f60aa4d2b631c2a"
integrity sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==
markdown@^0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/markdown/-/markdown-0.5.0.tgz#28205b565a8ae7592de207463d6637dc182722b2"
integrity sha512-ctGPIcuqsYoJ493sCtFK7H4UEgMWAUdXeBhPbdsg1W0LsV9yJELAHRsMmWfTgao6nH0/x5gf9FmsbxiXnrgaIQ==
dependencies:
nopt "~2.1.1"
math-intrinsics@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
@@ -5508,13 +5496,6 @@ nodemon@^3.1.10:
touch "^3.1.0"
undefsafe "^2.0.5"
nopt@~2.1.1:
version "2.1.2"
resolved "https://registry.yarnpkg.com/nopt/-/nopt-2.1.2.tgz#6cccd977b80132a07731d6e8ce58c2c8303cf9af"
integrity sha512-x8vXm7BZ2jE1Txrxh/hO74HTuYZQEbo8edoRcANgdZ4+PCV+pbjd/xdummkmjjC7LU5EjPzlu8zEq/oxWylnKA==
dependencies:
abbrev "1"
normalize-path@^3.0.0, normalize-path@~3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"