mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0606122736 | ||
|
|
53d5098cec | ||
|
|
32c7518454 | ||
|
|
db3702ed33 | ||
|
|
e3c62d4696 | ||
|
|
79a8420dfb | ||
|
|
d433b13db6 | ||
|
|
41d9274dfd | ||
|
|
0436c7f7d7 | ||
|
|
a1cb57318e | ||
|
|
2566db9805 | ||
|
|
b48f786fd3 | ||
|
|
9c74129489 |
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://fredy.orange-coding.net/" target="_blank">Website</a> |
|
<a href="https://fredy.orange-coding.net/" target="_blank">Website</a> |
|
||||||
<a href="https://demo-fredy.orange-coding.net/" target="_blank">Demo</a>
|
<a href="https://fredy-demo.orange-coding.net/" target="_blank">Demo</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -202,7 +202,7 @@ flowchart TD
|
|||||||
F2["Adapter 2"]
|
F2["Adapter 2"]
|
||||||
end
|
end
|
||||||
|
|
||||||
A1 --> B["FredyRuntime"]
|
A1 --> B["FredyPipeline"]
|
||||||
A2 --> B
|
A2 --> B
|
||||||
A3 --> B
|
A3 --> B
|
||||||
B --> C1 & C2 & C3
|
B --> C1 & C2 & C3
|
||||||
|
|||||||
@@ -7,11 +7,14 @@
|
|||||||
content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
|
content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
|
||||||
/>
|
/>
|
||||||
<meta name="google" content="notranslate" />
|
<meta name="google" content="notranslate" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
|
||||||
<title>Fredy</title>
|
<title>Fredy || Real Estate Finder</title>
|
||||||
</head>
|
</head>
|
||||||
<body theme-mode="dark">
|
<body theme-mode="dark">
|
||||||
<div id="fredy" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0"></div>
|
<div id="fredy" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0"></div>
|
||||||
</body>
|
</body>
|
||||||
<script type="module" src="/ui/src/Index.jsx"></script>
|
<script type="module" src="/ui/src/Index.jsx"></script>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
7
index.js
7
index.js
@@ -3,7 +3,7 @@ import path from 'path';
|
|||||||
import { checkIfConfigIsAccessible, config, getProviders, refreshConfig } from './lib/utils.js';
|
import { checkIfConfigIsAccessible, config, getProviders, refreshConfig } from './lib/utils.js';
|
||||||
import * as similarityCache from './lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from './lib/services/similarity-check/similarityCache.js';
|
||||||
import * as jobStorage from './lib/services/storage/jobStorage.js';
|
import * as jobStorage from './lib/services/storage/jobStorage.js';
|
||||||
import FredyRuntime from './lib/FredyRuntime.js';
|
import FredyPipeline from './lib/FredyPipeline.js';
|
||||||
import { duringWorkingHoursOrNotSet } from './lib/utils.js';
|
import { duringWorkingHoursOrNotSet } from './lib/utils.js';
|
||||||
import { runMigrations } from './lib/services/storage/migrations/migrate.js';
|
import { runMigrations } from './lib/services/storage/migrations/migrate.js';
|
||||||
import { ensureDemoUserExists, ensureAdminUserExists } from './lib/services/storage/userStorage.js';
|
import { ensureDemoUserExists, ensureAdminUserExists } from './lib/services/storage/userStorage.js';
|
||||||
@@ -37,6 +37,9 @@ await runMigrations();
|
|||||||
// Load provider modules once at startup
|
// Load provider modules once at startup
|
||||||
const providers = await getProviders();
|
const providers = await getProviders();
|
||||||
|
|
||||||
|
similarityCache.initSimilarityCache();
|
||||||
|
similarityCache.startSimilarityCacheReloader();
|
||||||
|
|
||||||
//assuming interval is always in minutes
|
//assuming interval is always in minutes
|
||||||
const INTERVAL = config.interval * 60 * 1000;
|
const INTERVAL = config.interval * 60 * 1000;
|
||||||
|
|
||||||
@@ -75,7 +78,7 @@ const execute = () => {
|
|||||||
.forEach(async (prov) => {
|
.forEach(async (prov) => {
|
||||||
const matchedProvider = providers.find((loaded) => loaded.metaInformation.id === prov.id);
|
const matchedProvider = providers.find((loaded) => loaded.metaInformation.id === prov.id);
|
||||||
matchedProvider.init(prov, job.blacklist);
|
matchedProvider.init(prov, job.blacklist);
|
||||||
await new FredyRuntime(
|
await new FredyPipeline(
|
||||||
matchedProvider.config,
|
matchedProvider.config,
|
||||||
job.notificationAdapter,
|
job.notificationAdapter,
|
||||||
prov.id,
|
prov.id,
|
||||||
|
|||||||
216
lib/FredyPipeline.js
Executable file
216
lib/FredyPipeline.js
Executable file
@@ -0,0 +1,216 @@
|
|||||||
|
import { NoNewListingsWarning } from './errors.js';
|
||||||
|
import { storeListings, getKnownListingHashesForJobAndProvider } from './services/storage/listingsStorage.js';
|
||||||
|
import * as notify from './notification/notify.js';
|
||||||
|
import Extractor from './services/extractor/extractor.js';
|
||||||
|
import urlModifier from './services/queryStringMutator.js';
|
||||||
|
import logger from './services/logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Listing
|
||||||
|
* @property {string} id Stable unique identifier (hash) of the listing.
|
||||||
|
* @property {string} title Title or headline of the listing.
|
||||||
|
* @property {string} [address] Optional address/location text.
|
||||||
|
* @property {string} [price] Optional price text/value.
|
||||||
|
* @property {string} [url] Link to the listing detail page.
|
||||||
|
* @property {any} [meta] Provider-specific additional metadata.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} SimilarityCache
|
||||||
|
* @property {(title:string, address?:string)=>boolean} hasSimilarEntries Returns true if a similar entry is known.
|
||||||
|
* @property {(title:string, address?:string)=>void} addCacheEntry Adds a new entry to the similarity cache.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime orchestrator for fetching, normalizing, filtering, deduplicating, storing,
|
||||||
|
* and notifying about new listings from a configured provider.
|
||||||
|
*
|
||||||
|
* The execution flow is:
|
||||||
|
* 1) Prepare provider URL (sorting, etc.)
|
||||||
|
* 2) Extract raw listings from the provider
|
||||||
|
* 3) Normalize listings to the provider schema
|
||||||
|
* 4) Filter out incomplete/blacklisted listings
|
||||||
|
* 5) Identify new listings (vs. previously stored hashes)
|
||||||
|
* 6) Persist new listings
|
||||||
|
* 7) Filter out entries similar to already seen ones
|
||||||
|
* 8) Dispatch notifications
|
||||||
|
*/
|
||||||
|
class FredyPipeline {
|
||||||
|
/**
|
||||||
|
* Create a new runtime instance for a single provider/job execution.
|
||||||
|
*
|
||||||
|
* @param {Object} providerConfig Provider configuration.
|
||||||
|
* @param {string} providerConfig.url Base URL to crawl.
|
||||||
|
* @param {string} [providerConfig.sortByDateParam] Query parameter used to enforce sorting by date (provider-specific).
|
||||||
|
* @param {string} [providerConfig.waitForSelector] CSS selector to wait for before parsing content.
|
||||||
|
* @param {Object.<string, string>} providerConfig.crawlFields Mapping of field names to selectors/paths to extract.
|
||||||
|
* @param {string} providerConfig.crawlContainer CSS selector for the container holding listing items.
|
||||||
|
* @param {(raw:any)=>Listing} providerConfig.normalize Function to convert raw scraped data into a Listing shape.
|
||||||
|
* @param {(listing:Listing)=>boolean} providerConfig.filter Function to filter out unwanted listings.
|
||||||
|
* @param {(url:string, waitForSelector?:string)=>Promise<void>|Promise<Listing[]>} [providerConfig.getListings] Optional override to fetch listings.
|
||||||
|
*
|
||||||
|
* @param {Object} notificationConfig Notification configuration passed to notification adapters.
|
||||||
|
* @param {string} providerId The ID of the provider currently in use.
|
||||||
|
* @param {string} jobKey Key of the job that is currently running (from within the config).
|
||||||
|
* @param {SimilarityCache} similarityCache Cache instance for checking similar entries.
|
||||||
|
*/
|
||||||
|
constructor(providerConfig, notificationConfig, providerId, jobKey, similarityCache) {
|
||||||
|
this._providerConfig = providerConfig;
|
||||||
|
this._notificationConfig = notificationConfig;
|
||||||
|
this._providerId = providerId;
|
||||||
|
this._jobKey = jobKey;
|
||||||
|
this._similarityCache = similarityCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the end-to-end pipeline for a single provider run.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Listing[]|void>} Resolves to the list of new (and similarity-filtered) listings
|
||||||
|
* after notifications have been sent; resolves to void when there are no new listings.
|
||||||
|
*/
|
||||||
|
execute() {
|
||||||
|
return Promise.resolve(urlModifier(this._providerConfig.url, this._providerConfig.sortByDateParam))
|
||||||
|
.then(this._providerConfig.getListings?.bind(this) ?? this._getListings.bind(this))
|
||||||
|
.then(this._normalize.bind(this))
|
||||||
|
.then(this._filter.bind(this))
|
||||||
|
.then(this._findNew.bind(this))
|
||||||
|
.then(this._save.bind(this))
|
||||||
|
.then(this._filterBySimilarListings.bind(this))
|
||||||
|
.then(this._notify.bind(this))
|
||||||
|
.catch(this._handleError.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch listings from the provider, using the default Extractor flow unless
|
||||||
|
* a provider-specific getListings override is supplied.
|
||||||
|
*
|
||||||
|
* @param {string} url The provider URL to fetch from.
|
||||||
|
* @returns {Promise<Listing[]>} Resolves with an array of listings (empty when none found).
|
||||||
|
*/
|
||||||
|
_getListings(url) {
|
||||||
|
const extractor = new Extractor();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
extractor
|
||||||
|
.execute(url, this._providerConfig.waitForSelector)
|
||||||
|
.then(() => {
|
||||||
|
const listings = extractor.parseResponseText(
|
||||||
|
this._providerConfig.crawlContainer,
|
||||||
|
this._providerConfig.crawlFields,
|
||||||
|
url,
|
||||||
|
);
|
||||||
|
resolve(listings == null ? [] : listings);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
reject(err);
|
||||||
|
logger.error(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize raw listings into the provider-specific Listing shape.
|
||||||
|
*
|
||||||
|
* @param {any[]} listings Raw listing entries from the extractor or override.
|
||||||
|
* @returns {Listing[]} Normalized listings.
|
||||||
|
*/
|
||||||
|
_normalize(listings) {
|
||||||
|
return listings.map(this._providerConfig.normalize);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter out listings that are missing required fields and those rejected by the
|
||||||
|
* provider's blacklist/filter function.
|
||||||
|
*
|
||||||
|
* @param {Listing[]} listings Listings to filter.
|
||||||
|
* @returns {Listing[]} Filtered listings that pass validation and provider filter.
|
||||||
|
*/
|
||||||
|
_filter(listings) {
|
||||||
|
const keys = Object.keys(this._providerConfig.crawlFields);
|
||||||
|
const filteredListings = listings.filter((item) => keys.every((key) => key in item));
|
||||||
|
return filteredListings.filter(this._providerConfig.filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine which listings are new by comparing their IDs against stored hashes.
|
||||||
|
*
|
||||||
|
* @param {Listing[]} listings Listings to evaluate for novelty.
|
||||||
|
* @returns {Listing[]} New listings not seen before.
|
||||||
|
* @throws {NoNewListingsWarning} When no new listings are found.
|
||||||
|
*/
|
||||||
|
_findNew(listings) {
|
||||||
|
logger.debug(`Checking ${listings.length} listings for new entries (Provider: '${this._providerId}')`);
|
||||||
|
const hashes = getKnownListingHashesForJobAndProvider(this._jobKey, this._providerId) || [];
|
||||||
|
|
||||||
|
const newListings = listings.filter((o) => !hashes.includes(o.id));
|
||||||
|
if (newListings.length === 0) {
|
||||||
|
throw new NoNewListingsWarning();
|
||||||
|
}
|
||||||
|
return newListings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send notifications for new listings using the configured notification adapter(s).
|
||||||
|
*
|
||||||
|
* @param {Listing[]} newListings New listings to notify about.
|
||||||
|
* @returns {Promise<Listing[]>} Resolves to the provided listings after notifications complete.
|
||||||
|
* @throws {NoNewListingsWarning} When there are no listings to notify about.
|
||||||
|
*/
|
||||||
|
_notify(newListings) {
|
||||||
|
if (newListings.length === 0) {
|
||||||
|
throw new NoNewListingsWarning();
|
||||||
|
}
|
||||||
|
const sendNotifications = notify.send(this._providerId, newListings, this._notificationConfig, this._jobKey);
|
||||||
|
return Promise.all(sendNotifications).then(() => newListings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist new listings and pass them through.
|
||||||
|
*
|
||||||
|
* @param {Listing[]} newListings Listings to store.
|
||||||
|
* @returns {Listing[]} The same listings, unchanged.
|
||||||
|
*/
|
||||||
|
_save(newListings) {
|
||||||
|
logger.debug(`Storing ${newListings.length} new listings (Provider: '${this._providerId}')`);
|
||||||
|
storeListings(this._jobKey, this._providerId, newListings);
|
||||||
|
return newListings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove listings that are similar to already known entries according to the similarity cache.
|
||||||
|
* Adds the remaining listings to the cache.
|
||||||
|
*
|
||||||
|
* @param {Listing[]} listings Listings to filter by similarity.
|
||||||
|
* @returns {Listing[]} Listings considered unique enough to keep.
|
||||||
|
*/
|
||||||
|
_filterBySimilarListings(listings) {
|
||||||
|
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}')`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return !similar;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle errors occurring in the pipeline, logging levels depending on type.
|
||||||
|
*
|
||||||
|
* @param {Error} err Error instance thrown by previous steps.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
_handleError(err) {
|
||||||
|
if (err.name === 'NoNewListingsWarning') {
|
||||||
|
logger.debug(`No new listings found (Provider: '${this._providerId}').`);
|
||||||
|
} else {
|
||||||
|
logger.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FredyPipeline;
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import { NoNewListingsWarning } from './errors.js';
|
|
||||||
import { storeListings, getKnownListingHashesForJobAndProvider } from './services/storage/listingsStorage.js';
|
|
||||||
import * as notify from './notification/notify.js';
|
|
||||||
import Extractor from './services/extractor/extractor.js';
|
|
||||||
import urlModifier from './services/queryStringMutator.js';
|
|
||||||
import logger from './services/logger.js';
|
|
||||||
|
|
||||||
class FredyRuntime {
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param providerConfig the config for the specific provider, we're going to query at the moment
|
|
||||||
* @param notificationConfig the config for all notifications
|
|
||||||
* @param providerId the id of the provider currently in use
|
|
||||||
* @param jobKey key of the job that is currently running (from within the config)
|
|
||||||
* @param similarityCache cache instance holding values to check for similarity of entries
|
|
||||||
*/
|
|
||||||
constructor(providerConfig, notificationConfig, providerId, jobKey, similarityCache) {
|
|
||||||
this._providerConfig = providerConfig;
|
|
||||||
this._notificationConfig = notificationConfig;
|
|
||||||
this._providerId = providerId;
|
|
||||||
this._jobKey = jobKey;
|
|
||||||
this._similarityCache = similarityCache;
|
|
||||||
}
|
|
||||||
|
|
||||||
execute() {
|
|
||||||
return (
|
|
||||||
//modify the url to make sure search order is correctly set
|
|
||||||
Promise.resolve(urlModifier(this._providerConfig.url, this._providerConfig.sortByDateParam))
|
|
||||||
//scraping the site and try finding new listings
|
|
||||||
.then(this._providerConfig.getListings?.bind(this) ?? this._getListings.bind(this))
|
|
||||||
//bring them in a proper form (dictated by the provider)
|
|
||||||
.then(this._normalize.bind(this))
|
|
||||||
//filter listings with stuff tagged by the blacklist of the provider
|
|
||||||
.then(this._filter.bind(this))
|
|
||||||
//check if new listings available. if so proceed
|
|
||||||
.then(this._findNew.bind(this))
|
|
||||||
//store everything in db
|
|
||||||
.then(this._save.bind(this))
|
|
||||||
//check for similar listings. if found, remove them before notifying
|
|
||||||
.then(this._filterBySimilarListings.bind(this))
|
|
||||||
//notify the user using the configured notification adapter
|
|
||||||
.then(this._notify.bind(this))
|
|
||||||
//if an error occurred on the way, handle it here.
|
|
||||||
.catch(this._handleError.bind(this))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
_getListings(url) {
|
|
||||||
const extractor = new Extractor();
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
extractor
|
|
||||||
.execute(url, this._providerConfig.waitForSelector)
|
|
||||||
.then(() => {
|
|
||||||
const listings = extractor.parseResponseText(
|
|
||||||
this._providerConfig.crawlContainer,
|
|
||||||
this._providerConfig.crawlFields,
|
|
||||||
url,
|
|
||||||
);
|
|
||||||
resolve(listings == null ? [] : listings);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
reject(err);
|
|
||||||
logger.error(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
_normalize(listings) {
|
|
||||||
return listings.map(this._providerConfig.normalize);
|
|
||||||
}
|
|
||||||
|
|
||||||
_filter(listings) {
|
|
||||||
//only return those where all the fields have been found
|
|
||||||
const keys = Object.keys(this._providerConfig.crawlFields);
|
|
||||||
const filteredListings = listings.filter((item) => keys.every((key) => key in item));
|
|
||||||
return filteredListings.filter(this._providerConfig.filter);
|
|
||||||
}
|
|
||||||
|
|
||||||
_findNew(listings) {
|
|
||||||
logger.debug(`Checking ${listings.length} listings for new entries (Provider: '${this._providerId}')`);
|
|
||||||
const hashes = getKnownListingHashesForJobAndProvider(this._jobKey, this._providerId) || [];
|
|
||||||
|
|
||||||
const newListings = listings.filter((o) => !hashes.includes(o.id));
|
|
||||||
if (newListings.length === 0) {
|
|
||||||
throw new NoNewListingsWarning();
|
|
||||||
}
|
|
||||||
return newListings;
|
|
||||||
}
|
|
||||||
|
|
||||||
_notify(newListings) {
|
|
||||||
if (newListings.length === 0) {
|
|
||||||
throw new NoNewListingsWarning();
|
|
||||||
}
|
|
||||||
const sendNotifications = notify.send(this._providerId, newListings, this._notificationConfig, this._jobKey);
|
|
||||||
return Promise.all(sendNotifications).then(() => newListings);
|
|
||||||
}
|
|
||||||
|
|
||||||
_save(newListings) {
|
|
||||||
logger.debug(`Storing ${newListings.length} new listings (Provider: '${this._providerId}')`);
|
|
||||||
storeListings(this._jobKey, this._providerId, newListings);
|
|
||||||
return newListings;
|
|
||||||
}
|
|
||||||
|
|
||||||
_filterBySimilarListings(listings) {
|
|
||||||
const filteredList = listings.filter((listing) => {
|
|
||||||
const similar = this._similarityCache.hasSimilarEntries(listing.title, listing.address);
|
|
||||||
if (similar) {
|
|
||||||
logger.debug(
|
|
||||||
`Filtering similar entry for title '${listing.title}' and address '${listing.address}' (Provider: '${this._providerId}')`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return !similar;
|
|
||||||
});
|
|
||||||
filteredList.forEach((filter) => this._similarityCache.addCacheEntry(filter.title, filter.address));
|
|
||||||
return filteredList;
|
|
||||||
}
|
|
||||||
|
|
||||||
_handleError(err) {
|
|
||||||
if (err.name === 'NoNewListingsWarning') {
|
|
||||||
logger.debug(`No new listings found (Provider: '${this._providerId}').`);
|
|
||||||
} else {
|
|
||||||
logger.error(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default FredyRuntime;
|
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
### Apprise Adapter
|
### 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.
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
### Console Adapter
|
### 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
|
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.
|
||||||
criteria meet the expectations.
|
|
||||||
|
|||||||
@@ -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).
|
Use a Discord channel webhook to receive notifications.
|
||||||
Once you have created a webhook, copy and paste the webhook URL.
|
|
||||||
|
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.
|
||||||
|
|||||||
@@ -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.
|
For example, if you use yourGmailAccount@gmail.com, add and verify this address in Mailjet.
|
||||||
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.
|
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).
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
### Mattermost Adapter
|
### 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.
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
### ntfy Adapter
|
### 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.
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
### Pushover Adapter
|
### 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.
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
### SendGrid Adapter
|
### 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`.
|
Sending to multiple recipients:
|
||||||
|
- Separate email addresses with commas (e.g., some@email.com, someOther@email.com).
|
||||||
If this email should be sent to multiple receiver use a comma separator (some@email.com, someOther@email.com).
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
### Slack Adapter
|
### Slack Adapter (Legacy)
|
||||||
IMPORTANT:
|
|
||||||
Don't use this adapter anymore, it is outdated and only here for backwards compatability reasons. Use the new Slack Adapter with webhooks!
|
*IMPORTANT:*
|
||||||
|
This legacy adapter is outdated and kept only for backward compatibility. Please use the Slack adapter with webhooks instead.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
### Slack Adapter
|
### Slack Adapter (Webhooks)
|
||||||
|
|
||||||
IMPORTANT:
|
*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.
|
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.
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
### SQLite Adapter
|
### 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`):
|
||||||
|
|
||||||
```
|
```json
|
||||||
['serviceName', 'jobKey', 'id', 'size', 'rooms', 'price', 'address', 'title', 'link', 'description', 'image']
|
[
|
||||||
|
"serviceName",
|
||||||
|
"jobKey",
|
||||||
|
"id",
|
||||||
|
"size",
|
||||||
|
"rooms",
|
||||||
|
"price",
|
||||||
|
"address",
|
||||||
|
"title",
|
||||||
|
"link",
|
||||||
|
"description",
|
||||||
|
"image"
|
||||||
|
]
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -117,10 +117,24 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
|
|||||||
if (!adapterCfg || !adapterCfg.fields) {
|
if (!adapterCfg || !adapterCfg.fields) {
|
||||||
throw new Error(`Telegram adapter configuration missing for job '${jobKey || ''}'`);
|
throw new Error(`Telegram adapter configuration missing for job '${jobKey || ''}'`);
|
||||||
}
|
}
|
||||||
const { token, chatId } = adapterCfg.fields;
|
const { token, chatId, messageThreadId } = adapterCfg.fields;
|
||||||
if (!token || !chatId) {
|
if (!token || !chatId) {
|
||||||
throw new Error("Telegram 'token' and 'chatId' must be provided in notification config");
|
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 job = getJob(jobKey);
|
||||||
const jobName = job == null ? jobKey : job.name;
|
const jobName = job == null ? jobKey : job.name;
|
||||||
|
|
||||||
@@ -147,6 +161,7 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
|
|||||||
text: buildText(jobName, serviceName, o),
|
text: buildText(jobName, serviceName, o),
|
||||||
parse_mode: 'HTML',
|
parse_mode: 'HTML',
|
||||||
disable_web_page_preview: true,
|
disable_web_page_preview: true,
|
||||||
|
...(message_thread_id ? { message_thread_id } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!img) {
|
if (!img) {
|
||||||
@@ -160,6 +175,7 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
|
|||||||
photo: img,
|
photo: img,
|
||||||
caption: buildCaption(jobName, serviceName, o),
|
caption: buildCaption(jobName, serviceName, o),
|
||||||
parse_mode: 'HTML',
|
parse_mode: 'HTML',
|
||||||
|
...(message_thread_id ? { message_thread_id } : {}),
|
||||||
}).catch(async (e) => {
|
}).catch(async (e) => {
|
||||||
logger.error(`Error sending photo to Telegram and use a fallback: ${e.message}`);
|
logger.error(`Error sending photo to Telegram and use a fallback: ${e.message}`);
|
||||||
return await throttledCall('sendMessage', textPayload).catch((e) => {
|
return await throttledCall('sendMessage', textPayload).catch((e) => {
|
||||||
@@ -174,7 +190,7 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Telegram notification adapter configuration schema.
|
* 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 = {
|
export const config = {
|
||||||
id: 'telegram',
|
id: 'telegram',
|
||||||
@@ -192,5 +208,12 @@ export const config = {
|
|||||||
label: 'Chat Id',
|
label: 'Chat Id',
|
||||||
description: 'The chat id to send messages to you.',
|
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.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,55 @@
|
|||||||
### Telegram Adapter
|
### 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.
|
#### Create a bot
|
||||||
After the user has send a message to your bot the first time, you can gather the chatId like this:
|
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 bot’s 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
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ function normalize(o) {
|
|||||||
const title = o.title || 'No title available';
|
const title = o.title || 'No title available';
|
||||||
const link = o.link != null ? decodeURIComponent(o.link) : config.url;
|
const link = o.link != null ? decodeURIComponent(o.link) : config.url;
|
||||||
|
|
||||||
var urlReg = new RegExp(/url\((.*?)\)/gim);
|
const urlReg = new RegExp(/url\((.*?)\)/gim);
|
||||||
const image = o.image != null ? urlReg.exec(o.image)[1] : null;
|
const image = o.image != null ? urlReg.exec(o.image)[1] : null;
|
||||||
return Object.assign(o, { id, address, title, link, image });
|
return Object.assign(o, { id, address, title, link, image });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import logger from '../logger.js';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { URL } from 'url';
|
||||||
|
|
||||||
puppeteer.use(StealthPlugin());
|
puppeteer.use(StealthPlugin());
|
||||||
|
|
||||||
@@ -27,23 +28,97 @@ export default async function execute(url, waitForSelector, options) {
|
|||||||
removeUserDataDir = true;
|
removeUserDataDir = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const launchArgs = [
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-gpu',
|
||||||
|
'--disable-setuid-sandbox',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
'--disable-crash-reporter',
|
||||||
|
'--no-first-run',
|
||||||
|
'--no-default-browser-check',
|
||||||
|
];
|
||||||
|
if (options?.proxyUrl) {
|
||||||
|
launchArgs.push(`--proxy-server=${options.proxyUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
browser = await puppeteer.launch({
|
browser = await puppeteer.launch({
|
||||||
headless: options.puppeteerHeadless ?? true,
|
headless: options?.puppeteerHeadless ?? true,
|
||||||
args: [
|
args: launchArgs,
|
||||||
'--no-sandbox',
|
timeout: options?.puppeteerTimeout || 30_000,
|
||||||
'--disable-gpu',
|
|
||||||
'--disable-setuid-sandbox',
|
|
||||||
'--disable-dev-shm-usage',
|
|
||||||
'--disable-crash-reporter',
|
|
||||||
],
|
|
||||||
timeout: options.puppeteerTimeout || 30_000,
|
|
||||||
userDataDir,
|
userDataDir,
|
||||||
|
executablePath: options?.executablePath, // allow using system Chrome
|
||||||
});
|
});
|
||||||
|
|
||||||
page = await browser.newPage();
|
page = await browser.newPage();
|
||||||
await page.setExtraHTTPHeaders(DEFAULT_HEADER);
|
|
||||||
const response = await page.goto(url, {
|
// Derive domain-specific defaults
|
||||||
waitUntil: 'domcontentloaded',
|
const { hostname } = new URL(url);
|
||||||
|
|
||||||
|
// Set a realistic modern user agent unless provided
|
||||||
|
const userAgent =
|
||||||
|
options?.userAgent ||
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
|
||||||
|
await page.setUserAgent(userAgent);
|
||||||
|
|
||||||
|
// Viewport and device scale for typical desktop
|
||||||
|
await page.setViewport({ width: 1366, height: 768, deviceScaleFactor: 1 });
|
||||||
|
|
||||||
|
// Extra HTTP headers with localized Accept-Language
|
||||||
|
const acceptLanguage = options?.acceptLanguage || 'de-DE,de;q=0.9,en-US;q=0.7,en;q=0.5';
|
||||||
|
const headers = {
|
||||||
|
...DEFAULT_HEADER,
|
||||||
|
'Accept-Language': acceptLanguage,
|
||||||
|
'User-Agent': userAgent,
|
||||||
|
Referer: options?.referer || `https://${hostname}/`,
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
DNT: '1',
|
||||||
|
};
|
||||||
|
await page.setExtraHTTPHeaders(headers);
|
||||||
|
|
||||||
|
// Timezone and locale tweaks to look German when needed
|
||||||
|
try {
|
||||||
|
const tz = options?.timezone || 'Europe/Berlin';
|
||||||
|
if (tz) await page.emulateTimezone(tz);
|
||||||
|
} catch {
|
||||||
|
//noop
|
||||||
|
}
|
||||||
|
|
||||||
|
// Harden navigator properties (stealth already covers many, but we ensure critical ones)
|
||||||
|
await page.evaluateOnNewDocument(() => {
|
||||||
|
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
||||||
|
// Plugins and mimeTypes
|
||||||
|
// @ts-ignore
|
||||||
|
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3] });
|
||||||
|
// @ts-ignore
|
||||||
|
Object.defineProperty(navigator, 'languages', {
|
||||||
|
get: () => (window.localStorage.getItem('__LANGS__') || 'de-DE,de').split(','),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
// Provide languages value before navigation
|
||||||
|
await page.evaluateOnNewDocument((langs) => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem('__LANGS__', langs);
|
||||||
|
} catch {
|
||||||
|
//noop
|
||||||
|
}
|
||||||
|
}, acceptLanguage.split(';')[0]);
|
||||||
|
|
||||||
|
// Optional cookies
|
||||||
|
if (Array.isArray(options?.cookies) && options.cookies.length > 0) {
|
||||||
|
await page.setCookie(...options.cookies);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
const response = await page.goto(url, {
|
||||||
|
waitUntil: options?.waitUntil || 'domcontentloaded',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optionally wait a random small delay to mimic human rendering time
|
||||||
|
if (options?.humanDelay !== false) {
|
||||||
|
const delay = 200 + Math.floor(Math.random() * 400);
|
||||||
|
await new Promise((res) => setTimeout(res, delay));
|
||||||
|
}
|
||||||
|
|
||||||
let pageSource;
|
let pageSource;
|
||||||
// if we're extracting data from a SPA, we must wait for the selector
|
// if we're extracting data from a SPA, we must wait for the selector
|
||||||
if (waitForSelector != null) {
|
if (waitForSelector != null) {
|
||||||
@@ -57,7 +132,7 @@ export default async function execute(url, waitForSelector, options) {
|
|||||||
pageSource = await page.content();
|
pageSource = await page.content();
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusCode = response.status();
|
const statusCode = response?.status?.() ?? 200;
|
||||||
|
|
||||||
if (botDetected(pageSource, statusCode)) {
|
if (botDetected(pageSource, statusCode)) {
|
||||||
logger.warn('We have been detected as a bot :-/ Tried url: => ', url);
|
logger.warn('We have been detected as a bot :-/ Tried url: => ', url);
|
||||||
@@ -66,7 +141,7 @@ export default async function execute(url, waitForSelector, options) {
|
|||||||
result = pageSource || (await page.content());
|
result = pageSource || (await page.content());
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error executing with puppeteer executor', error);
|
logger.warn('Error executing with puppeteer executor', error);
|
||||||
result = null;
|
result = null;
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import markdown$0 from 'markdown';
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
const markdown = markdown$0.markdown;
|
|
||||||
export function markdown2Html(filePath) {
|
export function markdown2Html(filePath) {
|
||||||
return markdown.toHTML(fs.readFileSync(filePath, 'utf8'));
|
return fs.readFileSync(filePath, 'utf8');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,116 +1,94 @@
|
|||||||
import crypto from 'crypto';
|
|
||||||
|
|
||||||
const retention = 60 * 60 * 1000;
|
|
||||||
/**
|
/**
|
||||||
* Internal cache storage.
|
* Similarity cache
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* @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
|
* @returns {string} Hexadecimal hash
|
||||||
*/
|
*/
|
||||||
function toHash(...strings) {
|
function toHash(...strings) {
|
||||||
return crypto.createHash('sha256').update(strings.filter(Boolean).join('|')).digest('hex');
|
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');
|
||||||
* 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();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,8 +152,9 @@ export const storeListings = (jobId, providerId, listings) => {
|
|||||||
*/
|
*/
|
||||||
function extractNumber(str) {
|
function extractNumber(str) {
|
||||||
if (!str) return null;
|
if (!str) return null;
|
||||||
const match = str.replace(/[.,]/g, '').match(/\d+/);
|
const cleaned = str.replace(/\./g, '').replace(',', '.');
|
||||||
return match ? +match[0] : null;
|
const num = parseFloat(cleaned);
|
||||||
|
return isNaN(num) ? null : num;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -213,7 +214,10 @@ export const queryListings = ({
|
|||||||
params.userId = userId || '__NO_USER__';
|
params.userId = userId || '__NO_USER__';
|
||||||
// user scoping (non-admin only): restrict to listings whose job belongs to user
|
// user scoping (non-admin only): restrict to listings whose job belongs to user
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
whereParts.push(`(j.user_id = @userId)`);
|
// Include listings from jobs owned by the user or jobs shared with the user
|
||||||
|
whereParts.push(
|
||||||
|
`(j.user_id = @userId OR EXISTS (SELECT 1 FROM json_each(j.shared_with_user) AS sw WHERE sw.value = @userId))`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (freeTextFilter && String(freeTextFilter).trim().length > 0) {
|
if (freeTextFilter && String(freeTextFilter).trim().length > 0) {
|
||||||
params.filter = `%${String(freeTextFilter).trim()}%`;
|
params.filter = `%${String(freeTextFilter).trim()}%`;
|
||||||
@@ -307,8 +311,8 @@ export const deleteListingsByJobId = (jobId) => {
|
|||||||
if (!jobId) return;
|
if (!jobId) return;
|
||||||
return SqliteConnection.execute(
|
return SqliteConnection.execute(
|
||||||
`DELETE
|
`DELETE
|
||||||
FROM listings
|
FROM listings
|
||||||
WHERE job_id = @jobId`,
|
WHERE job_id = @jobId`,
|
||||||
{ jobId },
|
{ jobId },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -329,3 +333,13 @@ export const deleteListingsById = (ids) => {
|
|||||||
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`);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Migration: Adding a new table to store if somebody "watches" (a.k.a favorite) a listing
|
// Migration: Adding a new table to store if somebody shared a job with someone
|
||||||
|
|
||||||
export function up(db) {
|
export function up(db) {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
|
|||||||
43
package.json
43
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fredy",
|
"name": "fredy",
|
||||||
"version": "14.2.0",
|
"version": "14.3.4",
|
||||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
@@ -56,58 +56,57 @@
|
|||||||
"Firefox ESR"
|
"Firefox ESR"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@douyinfe/semi-icons": "^2.86.0",
|
"@douyinfe/semi-icons": "^2.88.0",
|
||||||
"@douyinfe/semi-ui": "2.86.0",
|
"@douyinfe/semi-ui": "2.88.0",
|
||||||
"@sendgrid/mail": "8.1.6",
|
"@sendgrid/mail": "8.1.6",
|
||||||
"@visactor/react-vchart": "^2.0.5",
|
"@visactor/react-vchart": "^2.0.8",
|
||||||
"@visactor/vchart": "^2.0.5",
|
"@visactor/vchart": "^2.0.8",
|
||||||
"@visactor/vchart-semi-theme": "^1.12.2",
|
"@visactor/vchart-semi-theme": "^1.12.2",
|
||||||
"@vitejs/plugin-react": "5.0.4",
|
"@vitejs/plugin-react": "5.1.1",
|
||||||
"better-sqlite3": "^12.4.1",
|
"better-sqlite3": "^12.4.1",
|
||||||
"body-parser": "2.2.0",
|
"body-parser": "2.2.0",
|
||||||
"cheerio": "^1.1.2",
|
"cheerio": "^1.1.2",
|
||||||
"cookie-session": "2.1.1",
|
"cookie-session": "2.1.1",
|
||||||
"handlebars": "4.7.8",
|
"handlebars": "4.7.8",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"markdown": "^0.5.0",
|
|
||||||
"nanoid": "5.1.6",
|
"nanoid": "5.1.6",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"node-fetch": "3.3.2",
|
"node-fetch": "3.3.2",
|
||||||
"node-mailjet": "6.0.9",
|
"node-mailjet": "6.0.11",
|
||||||
"p-throttle": "^8.0.0",
|
"p-throttle": "^8.1.0",
|
||||||
"package-up": "^5.0.0",
|
"package-up": "^5.0.0",
|
||||||
"puppeteer": "^24.23.0",
|
"puppeteer": "^24.30.0",
|
||||||
"puppeteer-extra": "^3.3.6",
|
"puppeteer-extra": "^3.3.6",
|
||||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||||
"query-string": "9.3.1",
|
"query-string": "9.3.1",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-router": "7.9.3",
|
"react-router": "7.9.6",
|
||||||
"react-router-dom": "7.9.3",
|
"react-router-dom": "7.9.6",
|
||||||
"restana": "5.1.0",
|
"restana": "5.1.0",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
"serve-static": "2.2.0",
|
"serve-static": "2.2.0",
|
||||||
"slack": "11.0.2",
|
"slack": "11.0.2",
|
||||||
"vite": "7.1.9",
|
"vite": "7.2.2",
|
||||||
"x-var": "^3.0.1",
|
"x-var": "^3.0.1",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.28.4",
|
"@babel/core": "7.28.5",
|
||||||
"@babel/eslint-parser": "7.28.4",
|
"@babel/eslint-parser": "7.28.5",
|
||||||
"@babel/preset-env": "7.28.3",
|
"@babel/preset-env": "7.28.5",
|
||||||
"@babel/preset-react": "7.27.1",
|
"@babel/preset-react": "7.28.5",
|
||||||
"chai": "6.2.0",
|
"chai": "6.2.1",
|
||||||
"eslint": "9.37.0",
|
"eslint": "9.39.1",
|
||||||
"eslint-config-prettier": "10.1.8",
|
"eslint-config-prettier": "10.1.8",
|
||||||
"eslint-plugin-react": "7.37.5",
|
"eslint-plugin-react": "7.37.5",
|
||||||
"esmock": "2.7.3",
|
"esmock": "2.7.3",
|
||||||
"history": "5.3.0",
|
"history": "5.3.0",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"less": "4.4.2",
|
"less": "4.4.2",
|
||||||
"lint-staged": "16.2.3",
|
"lint-staged": "16.2.6",
|
||||||
"mocha": "11.7.4",
|
"mocha": "11.7.5",
|
||||||
"nodemon": "^3.1.10",
|
"nodemon": "^3.1.11",
|
||||||
"prettier": "3.6.2"
|
"prettier": "3.6.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import { expect } from 'chai';
|
|
||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
|
||||||
import { mockFredy } from '../utils.js';
|
|
||||||
|
|
||||||
describe('FredyRuntime', () => {
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -5,9 +5,6 @@ import { expect } from 'chai';
|
|||||||
import * as provider from '../../lib/provider/einsAImmobilien.js';
|
import * as provider from '../../lib/provider/einsAImmobilien.js';
|
||||||
|
|
||||||
describe('#einsAImmobilien testsuite()', () => {
|
describe('#einsAImmobilien testsuite()', () => {
|
||||||
after(() => {
|
|
||||||
similarityCache.stopCacheCleanup();
|
|
||||||
});
|
|
||||||
provider.init(providerConfig.einsAImmobilien, [], []);
|
provider.init(providerConfig.einsAImmobilien, [], []);
|
||||||
it('should test einsAImmobilien provider', async () => {
|
it('should test einsAImmobilien provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ import { expect } from 'chai';
|
|||||||
import * as provider from '../../lib/provider/immobilienDe.js';
|
import * as provider from '../../lib/provider/immobilienDe.js';
|
||||||
|
|
||||||
describe('#immobilien.de testsuite()', () => {
|
describe('#immobilien.de testsuite()', () => {
|
||||||
after(() => {
|
|
||||||
similarityCache.stopCacheCleanup();
|
|
||||||
});
|
|
||||||
provider.init(providerConfig.immobilienDe, [], []);
|
provider.init(providerConfig.immobilienDe, [], []);
|
||||||
it('should test immobilien.de provider', async () => {
|
it('should test immobilien.de provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ import { expect } from 'chai';
|
|||||||
import * as provider from '../../lib/provider/immonet.js';
|
import * as provider from '../../lib/provider/immonet.js';
|
||||||
|
|
||||||
describe('#immonet testsuite()', () => {
|
describe('#immonet testsuite()', () => {
|
||||||
after(() => {
|
|
||||||
similarityCache.stopCacheCleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should test immonet provider', async () => {
|
it('should test immonet provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
provider.init(providerConfig.immonet, [], []);
|
provider.init(providerConfig.immonet, [], []);
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ import { get } from '../mocks/mockNotification.js';
|
|||||||
import * as provider from '../../lib/provider/immoscout.js';
|
import * as provider from '../../lib/provider/immoscout.js';
|
||||||
|
|
||||||
describe('#immoscout provider testsuite()', () => {
|
describe('#immoscout provider testsuite()', () => {
|
||||||
after(() => {
|
|
||||||
similarityCache.stopCacheCleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
provider.init(providerConfig.immoscout, [], []);
|
provider.init(providerConfig.immoscout, [], []);
|
||||||
it('should test immoscout provider', async () => {
|
it('should test immoscout provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ import { expect } from 'chai';
|
|||||||
import * as provider from '../../lib/provider/immoswp.js';
|
import * as provider from '../../lib/provider/immoswp.js';
|
||||||
|
|
||||||
describe('#immoswp testsuite()', () => {
|
describe('#immoswp testsuite()', () => {
|
||||||
after(() => {
|
|
||||||
similarityCache.stopCacheCleanup();
|
|
||||||
});
|
|
||||||
provider.init(providerConfig.immoswp, [], []);
|
provider.init(providerConfig.immoswp, [], []);
|
||||||
it('should test immoswp provider', async () => {
|
it('should test immoswp provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ import { expect } from 'chai';
|
|||||||
import * as provider from '../../lib/provider/immowelt.js';
|
import * as provider from '../../lib/provider/immowelt.js';
|
||||||
|
|
||||||
describe('#immowelt testsuite()', () => {
|
describe('#immowelt testsuite()', () => {
|
||||||
after(() => {
|
|
||||||
similarityCache.stopCacheCleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should test immowelt provider', async () => {
|
it('should test immowelt provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
provider.init(providerConfig.immowelt, [], []);
|
provider.init(providerConfig.immowelt, [], []);
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ import { expect } from 'chai';
|
|||||||
import * as provider from '../../lib/provider/kleinanzeigen.js';
|
import * as provider from '../../lib/provider/kleinanzeigen.js';
|
||||||
|
|
||||||
describe('#kleinanzeigen testsuite()', () => {
|
describe('#kleinanzeigen testsuite()', () => {
|
||||||
after(() => {
|
|
||||||
similarityCache.stopCacheCleanup();
|
|
||||||
});
|
|
||||||
it('should test kleinanzeigen provider', async () => {
|
it('should test kleinanzeigen provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
provider.init(providerConfig.kleinanzeigen, [], []);
|
provider.init(providerConfig.kleinanzeigen, [], []);
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ import { expect } from 'chai';
|
|||||||
import * as provider from '../../lib/provider/mcMakler.js';
|
import * as provider from '../../lib/provider/mcMakler.js';
|
||||||
|
|
||||||
describe('#mcMakler testsuite()', () => {
|
describe('#mcMakler testsuite()', () => {
|
||||||
after(() => {
|
|
||||||
similarityCache.stopCacheCleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should test mcMakler provider', async () => {
|
it('should test mcMakler provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
provider.init(providerConfig.mcMakler, []);
|
provider.init(providerConfig.mcMakler, []);
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ import { expect } from 'chai';
|
|||||||
import * as provider from '../../lib/provider/neubauKompass.js';
|
import * as provider from '../../lib/provider/neubauKompass.js';
|
||||||
|
|
||||||
describe('#neubauKompass testsuite()', () => {
|
describe('#neubauKompass testsuite()', () => {
|
||||||
after(() => {
|
|
||||||
similarityCache.stopCacheCleanup();
|
|
||||||
});
|
|
||||||
provider.init(providerConfig.neubauKompass, [], []);
|
provider.init(providerConfig.neubauKompass, [], []);
|
||||||
it('should test neubauKompass provider', async () => {
|
it('should test neubauKompass provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ import { expect } from 'chai';
|
|||||||
import * as provider from '../../lib/provider/regionalimmobilien24.js';
|
import * as provider from '../../lib/provider/regionalimmobilien24.js';
|
||||||
|
|
||||||
describe('#regionalimmobilien24 testsuite()', () => {
|
describe('#regionalimmobilien24 testsuite()', () => {
|
||||||
after(() => {
|
|
||||||
similarityCache.stopCacheCleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should test regionalimmobilien24 provider', async () => {
|
it('should test regionalimmobilien24 provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
provider.init(providerConfig.regionalimmobilien24, []);
|
provider.init(providerConfig.regionalimmobilien24, []);
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ import { expect } from 'chai';
|
|||||||
import * as provider from '../../lib/provider/sparkasse.js';
|
import * as provider from '../../lib/provider/sparkasse.js';
|
||||||
|
|
||||||
describe('#sparkasse testsuite()', () => {
|
describe('#sparkasse testsuite()', () => {
|
||||||
after(() => {
|
|
||||||
similarityCache.stopCacheCleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should test sparkasse provider', async () => {
|
it('should test sparkasse provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
provider.init(providerConfig.sparkasse, []);
|
provider.init(providerConfig.sparkasse, []);
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ import { expect } from 'chai';
|
|||||||
import * as provider from '../../lib/provider/wgGesucht.js';
|
import * as provider from '../../lib/provider/wgGesucht.js';
|
||||||
|
|
||||||
describe('#wgGesucht testsuite()', () => {
|
describe('#wgGesucht testsuite()', () => {
|
||||||
after(() => {
|
|
||||||
similarityCache.stopCacheCleanup();
|
|
||||||
});
|
|
||||||
provider.init(providerConfig.wgGesucht, [], []);
|
provider.init(providerConfig.wgGesucht, [], []);
|
||||||
it('should test wgGesucht provider', async () => {
|
it('should test wgGesucht provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
|
|||||||
@@ -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;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
62
test/similarity/similarityCache.test.js
Normal file
62
test/similarity/similarityCache.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,7 @@ import { send } from './mocks/mockNotification.js';
|
|||||||
export const providerConfig = JSON.parse(await readFile(new URL('./provider/testProvider.json', import.meta.url)));
|
export const providerConfig = JSON.parse(await readFile(new URL('./provider/testProvider.json', import.meta.url)));
|
||||||
|
|
||||||
export const mockFredy = async () => {
|
export const mockFredy = async () => {
|
||||||
return await esmock('../lib/FredyRuntime', {
|
return await esmock('../lib/FredyPipeline', {
|
||||||
'../lib/services/storage/listingsStorage.js': {
|
'../lib/services/storage/listingsStorage.js': {
|
||||||
...mockStore,
|
...mockStore,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 1.7rem;
|
height: 1.7rem;
|
||||||
|
border-radius: .3rem;
|
||||||
|
border-top: 1px solid #45464b;
|
||||||
|
|
||||||
&__version {
|
&__version {
|
||||||
padding-left: .5rem;
|
padding-left: .5rem;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default function Navigation({ isAdmin }) {
|
|||||||
|
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
items.push({ itemKey: '/users', text: 'User Management', icon: <IconUser /> });
|
items.push({ itemKey: '/users', text: 'User Management', icon: <IconUser /> });
|
||||||
items.push({ itemKey: '/generalSettings', text: 'Settings', icon: <IconSetting /> });
|
items.push({ itemKey: '/generalSettings', text: 'General Settings', icon: <IconSetting /> });
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsePathName(name) {
|
function parsePathName(name) {
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import ListingsFilter from './ListingsFilter.jsx';
|
|||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: '#',
|
title: 'Watchlist',
|
||||||
width: 100,
|
width: 110,
|
||||||
dataIndex: 'isWatched',
|
dataIndex: 'isWatched',
|
||||||
sorter: true,
|
sorter: true,
|
||||||
render: (id, row) => {
|
render: (id, row) => {
|
||||||
@@ -180,6 +180,7 @@ export default function ListingsTable() {
|
|||||||
const [activityFilter, setActivityFilter] = useState(null);
|
const [activityFilter, setActivityFilter] = useState(null);
|
||||||
const [providerFilter, setProviderFilter] = useState(null);
|
const [providerFilter, setProviderFilter] = useState(null);
|
||||||
|
|
||||||
|
const [imageWidth, setImageWidth] = useState('100%');
|
||||||
const handlePageChange = (_page) => {
|
const handlePageChange = (_page) => {
|
||||||
setPage(_page);
|
setPage(_page);
|
||||||
};
|
};
|
||||||
@@ -208,14 +209,29 @@ export default function ListingsTable() {
|
|||||||
|
|
||||||
const handleFilterChange = useMemo(() => debounce((value) => setFreeTextFilter(value), 500), []);
|
const handleFilterChange = useMemo(() => debounce((value) => setFreeTextFilter(value), 500), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
// cleanup debounced handler to avoid memory leaks
|
||||||
|
handleFilterChange.cancel && handleFilterChange.cancel();
|
||||||
|
};
|
||||||
|
}, [handleFilterChange]);
|
||||||
|
|
||||||
const expandRowRender = (record) => {
|
const expandRowRender = (record) => {
|
||||||
return (
|
return (
|
||||||
<div className="listingsTable__expanded">
|
<div className="listingsTable__expanded">
|
||||||
<div>
|
<div>
|
||||||
{record.image_url == null ? (
|
{record.image_url == null ? (
|
||||||
<Image height={200} src={no_image} />
|
<Image height={200} width={180} src={no_image} />
|
||||||
) : (
|
) : (
|
||||||
<Image height={200} src={record.image_url} />
|
<Image
|
||||||
|
height={200}
|
||||||
|
width={imageWidth}
|
||||||
|
src={record.image_url}
|
||||||
|
onError={() => {
|
||||||
|
setImageWidth('180px');
|
||||||
|
}}
|
||||||
|
fallback={<Image height={200} src={no_image} />}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -226,7 +242,7 @@ export default function ListingsTable() {
|
|||||||
</Tag>
|
</Tag>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item itemKey="Link">
|
<Descriptions.Item itemKey="Link">
|
||||||
<a href={record.link} target="_blank" rel="noreferrer">
|
<a href={record.link} target="_blank" rel="noopener noreferrer">
|
||||||
Link to Listing
|
Link to Listing
|
||||||
</a>
|
</a>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|||||||
@@ -1,16 +1,32 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { format } from '../../services/time/timeService';
|
import { format } from '../../services/time/timeService';
|
||||||
import { Button, Card, Col, Row, Toast } from '@douyinfe/semi-ui';
|
import { Button, Card, Col, Row, Toast } from '@douyinfe/semi-ui';
|
||||||
import { IconPlayCircle } from '@douyinfe/semi-icons';
|
import {
|
||||||
|
IconClock,
|
||||||
|
IconDoubleChevronLeft,
|
||||||
|
IconDoubleChevronRight,
|
||||||
|
IconPlayCircle,
|
||||||
|
IconSearch,
|
||||||
|
} from '@douyinfe/semi-icons';
|
||||||
import { xhrPost } from '../../services/xhr.js';
|
import { xhrPost } from '../../services/xhr.js';
|
||||||
|
|
||||||
import './ProsessingTimes.less';
|
import './ProsessingTimes.less';
|
||||||
|
import { useScreenWidth } from '../../hooks/screenWidth.js';
|
||||||
|
|
||||||
function InfoCard({ title, value }) {
|
function InfoCard({ title, value, icon }) {
|
||||||
|
const { Meta } = Card;
|
||||||
return (
|
return (
|
||||||
<Card style={{ maxWidth: '13rem', margin: '1rem', background: 'rgb(53, 54, 60)' }} title={title}>
|
<div
|
||||||
{value}
|
style={{
|
||||||
</Card>
|
margin: '1rem',
|
||||||
|
background: 'rgb(53, 54, 60)',
|
||||||
|
borderRadius: '.3rem',
|
||||||
|
padding: '1rem',
|
||||||
|
minHeight: '3rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Meta title={title} description={value} avatar={icon} />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,32 +34,57 @@ export default function ProcessingTimes({ processingTimes = {} }) {
|
|||||||
if (Object.keys(processingTimes).length === 0) {
|
if (Object.keys(processingTimes).length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const width = useScreenWidth();
|
||||||
|
const invisible = width <= 1180;
|
||||||
|
|
||||||
|
if (invisible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row>
|
<Row>
|
||||||
<Col span={6}>
|
<Col span={6}>
|
||||||
<InfoCard title="Processing Interval" value={`${processingTimes.interval} min`} />
|
<InfoCard
|
||||||
|
title="Search Interval"
|
||||||
|
value={`${processingTimes.interval} min`}
|
||||||
|
icon={<IconClock style={{ color: 'rgba(var(--semi-grey-4), 1)' }} />}
|
||||||
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
{processingTimes.lastRun && (
|
{processingTimes.lastRun && (
|
||||||
<>
|
<>
|
||||||
<Col span={6}>
|
<Col span={6}>
|
||||||
<InfoCard title="Last run" value={format(processingTimes.lastRun)} />
|
<InfoCard
|
||||||
|
title="Last search"
|
||||||
|
icon={<IconDoubleChevronLeft style={{ color: 'rgba(var(--semi-grey-4), 1)' }} />}
|
||||||
|
value={format(processingTimes.lastRun)}
|
||||||
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={6}>
|
<Col span={6}>
|
||||||
<InfoCard title="Next run" value={format(processingTimes.lastRun + processingTimes.interval * 60000)} />
|
<InfoCard
|
||||||
|
title="Next search"
|
||||||
|
icon={<IconDoubleChevronRight style={{ color: 'rgba(var(--semi-grey-4), 1)' }} />}
|
||||||
|
value={format(processingTimes.lastRun + processingTimes.interval * 60000)}
|
||||||
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Col span={6}>
|
<Col span={6}>
|
||||||
<InfoCard
|
<InfoCard
|
||||||
title="Find Listings Now"
|
title="Search Now"
|
||||||
|
icon={<IconSearch style={{ color: 'rgba(var(--semi-grey-4), 1)' }} />}
|
||||||
value={
|
value={
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
|
style={{ marginTop: '.2rem' }}
|
||||||
icon={<IconPlayCircle />}
|
icon={<IconPlayCircle />}
|
||||||
aria-label="Start now"
|
aria-label="Start now"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await xhrPost('/api/jobs/startAll', null);
|
try {
|
||||||
Toast.success('Successfully triggered Fredy search.');
|
await xhrPost('/api/jobs/startAll', null);
|
||||||
|
Toast.success('Successfully triggered Fredy search.');
|
||||||
|
} catch {
|
||||||
|
Toast.error('Failed to trigger search');
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Search now
|
Search now
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const sortAdapter = (a, b) => {
|
|||||||
const validate = (selectedAdapter) => {
|
const validate = (selectedAdapter) => {
|
||||||
const results = [];
|
const results = [];
|
||||||
for (let uiElement of Object.values(selectedAdapter.fields || [])) {
|
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.');
|
results.push('All fields are mandatory and must be set.');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -36,7 +36,7 @@ const validate = (selectedAdapter) => {
|
|||||||
results.push('A boolean field cannot be of a different type.');
|
results.push('A boolean field cannot be of a different type.');
|
||||||
continue;
|
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.');
|
results.push('All fields are mandatory and must be set.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Banner } from '@douyinfe/semi-ui';
|
import { Banner, MarkdownRender } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
export default function Help({ readme }) {
|
export default function Help({ readme }) {
|
||||||
return (
|
return (
|
||||||
@@ -8,7 +8,7 @@ export default function Help({ readme }) {
|
|||||||
type="info"
|
type="info"
|
||||||
closeIcon={null}
|
closeIcon={null}
|
||||||
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Information</div>}
|
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Information</div>}
|
||||||
description={<p dangerouslySetInnerHTML={{ __html: readme }} />}
|
description={<MarkdownRender raw={readme} />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user