mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0606122736 | ||
|
|
53d5098cec | ||
|
|
32c7518454 | ||
|
|
db3702ed33 | ||
|
|
e3c62d4696 | ||
|
|
79a8420dfb | ||
|
|
d433b13db6 | ||
|
|
41d9274dfd | ||
|
|
0436c7f7d7 | ||
|
|
a1cb57318e | ||
|
|
2566db9805 | ||
|
|
b48f786fd3 | ||
|
|
9c74129489 | ||
|
|
33120ebeca | ||
|
|
de2dd05c70 | ||
|
|
e4784e5960 | ||
|
|
2e537ce0be | ||
|
|
f0f1244baa | ||
|
|
b858529f06 | ||
|
|
c9bd5dc161 | ||
|
|
daa4a7b8f1 | ||
|
|
035f0e9f83 | ||
|
|
a5efd9af32 | ||
|
|
9f1e27d011 | ||
|
|
ebc57702dc | ||
|
|
3aa30bc1e2 | ||
|
|
f97fb48e51 | ||
|
|
4b15894603 | ||
|
|
31a14a0352 | ||
|
|
eecbe91dbd | ||
|
|
9dd3947cb7 | ||
|
|
c151f4f76e |
18
README.md
18
README.md
@@ -9,10 +9,18 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||

|
||||
[](https://github.com/orangecoding/fredy/actions/workflows/docker.yml)
|
||||

|
||||

|
||||
<p align="center">
|
||||
<a href="https://fredy.orange-coding.net/" target="_blank">Website</a> |
|
||||
<a href="https://fredy-demo.orange-coding.net/" target="_blank">Demo</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://github.com/orangecoding/fredy/actions/workflows/test.yml/badge.svg" alt="Tests" />
|
||||
<img src="https://github.com/orangecoding/fredy/actions/workflows/docker.yml/badge.svg" alt="Docker" />
|
||||
<img src="https://github.com/orangecoding/fredy/actions/workflows/check_source.yml/badge.svg" alt="Source" />
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fghcr-badge.elias.eu.org%2Fapi%2Forangecoding%2Ffredy%2Ffredy&query=%24.downloadCount&label=Docker%20Pulls" alt="Docker Pulls" />
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
# Fredy 🏡 – Your Self-Hosted Real Estate Finder for Germany
|
||||
@@ -194,7 +202,7 @@ flowchart TD
|
||||
F2["Adapter 2"]
|
||||
end
|
||||
|
||||
A1 --> B["FredyRuntime"]
|
||||
A1 --> B["FredyPipeline"]
|
||||
A2 --> B
|
||||
A3 --> B
|
||||
B --> C1 & C2 & C3
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 331 KiB After Width: | Height: | Size: 512 KiB |
18
docker-test.sh
Executable file
18
docker-test.sh
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Stop and remove old container if it exists
|
||||
if [ "$(docker ps -aq -f name=fredy)" ]; then
|
||||
docker stop fredy || true
|
||||
docker rm fredy || true
|
||||
fi
|
||||
|
||||
# Build image from local Dockerfile, forcing a fresh build without cache
|
||||
docker build --no-cache -t fredy:local .
|
||||
|
||||
# Run container with volumes and port mapping
|
||||
docker run -d --name fredy \
|
||||
-v fredy_conf:/conf \
|
||||
-v fredy_db:/db \
|
||||
-p 9998:9998 \
|
||||
fredy:local
|
||||
@@ -7,11 +7,14 @@
|
||||
content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
|
||||
/>
|
||||
<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>
|
||||
<body theme-mode="dark">
|
||||
<div id="fredy" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0"></div>
|
||||
</body>
|
||||
<script type="module" src="/ui/src/Index.jsx"></script>
|
||||
</html>
|
||||
|
||||
|
||||
16
index.js
16
index.js
@@ -1,9 +1,9 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { 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 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 { runMigrations } from './lib/services/storage/migrations/migrate.js';
|
||||
import { ensureDemoUserExists, ensureAdminUserExists } from './lib/services/storage/userStorage.js';
|
||||
@@ -16,6 +16,13 @@ import { initActiveCheckerCron } from './lib/services/crons/listing-alive-cron.j
|
||||
// Load configuration before any other startup steps
|
||||
await refreshConfig();
|
||||
|
||||
const isConfigAccessible = await checkIfConfigIsAccessible();
|
||||
|
||||
if (!isConfigAccessible) {
|
||||
logger.error('Configuration exists, but is not accessible. Please check the file permission');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Ensure sqlite directory exists before loading anything else (based on config.sqlitepath)
|
||||
const rawDir = config.sqlitepath || '/db';
|
||||
const relDir = rawDir.startsWith('/') ? rawDir.slice(1) : rawDir;
|
||||
@@ -30,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;
|
||||
|
||||
@@ -68,7 +78,7 @@ const execute = () => {
|
||||
.forEach(async (prov) => {
|
||||
const matchedProvider = providers.find((loaded) => loaded.metaInformation.id === prov.id);
|
||||
matchedProvider.init(prov, job.blacklist);
|
||||
await new FredyRuntime(
|
||||
await new FredyPipeline(
|
||||
matchedProvider.config,
|
||||
job.notificationAdapter,
|
||||
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,119 +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) {
|
||||
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) {
|
||||
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}`);
|
||||
}
|
||||
return !similar;
|
||||
});
|
||||
filteredList.forEach((filter) => this._similarityCache.addCacheEntry(filter.title, filter.address));
|
||||
return filteredList;
|
||||
}
|
||||
|
||||
_handleError(err) {
|
||||
if (err.name !== 'NoNewListingsWarning') logger.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
export default FredyRuntime;
|
||||
@@ -24,9 +24,25 @@ function doesJobBelongsToUser(job, req) {
|
||||
jobRouter.get('/', async (req, res) => {
|
||||
const isUserAdmin = isAdmin(req);
|
||||
//show only the jobs which belongs to the user (or all of the user is an admin)
|
||||
res.body = jobStorage.getJobs().filter((job) => isUserAdmin || job.userId === req.session.currentUser);
|
||||
res.body = jobStorage
|
||||
.getJobs()
|
||||
.filter(
|
||||
(job) =>
|
||||
isUserAdmin || job.userId === req.session.currentUser || job.shared_with_user.includes(req.session.currentUser),
|
||||
)
|
||||
.map((job) => {
|
||||
return {
|
||||
...job,
|
||||
isOnlyShared:
|
||||
!isUserAdmin &&
|
||||
job.userId !== req.session.currentUser &&
|
||||
job.shared_with_user.includes(req.session.currentUser),
|
||||
};
|
||||
});
|
||||
|
||||
res.send();
|
||||
});
|
||||
|
||||
jobRouter.get('/processingTimes', async (req, res) => {
|
||||
res.body = {
|
||||
interval: config.interval,
|
||||
@@ -41,8 +57,15 @@ jobRouter.post('/startAll', async (req, res) => {
|
||||
});
|
||||
|
||||
jobRouter.post('/', async (req, res) => {
|
||||
const { provider, notificationAdapter, name, blacklist = [], jobId, enabled } = req.body;
|
||||
const { provider, notificationAdapter, name, blacklist = [], jobId, enabled, shareWithUsers = [] } = req.body;
|
||||
try {
|
||||
let jobFromDb = jobStorage.getJob(jobId);
|
||||
|
||||
if (jobFromDb && !doesJobBelongsToUser(jobFromDb, req)) {
|
||||
res.send(new Error('You are trying to change a job that is not associated to your user.'));
|
||||
return;
|
||||
}
|
||||
|
||||
jobStorage.upsertJob({
|
||||
userId: req.session.currentUser,
|
||||
jobId,
|
||||
@@ -51,6 +74,7 @@ jobRouter.post('/', async (req, res) => {
|
||||
blacklist,
|
||||
provider,
|
||||
notificationAdapter,
|
||||
shareWithUsers,
|
||||
});
|
||||
} catch (error) {
|
||||
res.send(new Error(error));
|
||||
@@ -58,6 +82,7 @@ jobRouter.post('/', async (req, res) => {
|
||||
}
|
||||
res.send();
|
||||
});
|
||||
|
||||
jobRouter.delete('', async (req, res) => {
|
||||
const { jobId } = req.body;
|
||||
try {
|
||||
@@ -92,4 +117,16 @@ jobRouter.put('/:jobId/status', async (req, res) => {
|
||||
}
|
||||
res.send();
|
||||
});
|
||||
|
||||
jobRouter.get('/shareableUserList', async (req, res) => {
|
||||
const currentUser = req.session.currentUser;
|
||||
const users = userStorage.getUsers(false);
|
||||
res.body = users
|
||||
.filter((user) => !user.isAdmin && user.id !== currentUser)
|
||||
.map((user) => ({
|
||||
id: user.id,
|
||||
name: user.username,
|
||||
}));
|
||||
res.send();
|
||||
});
|
||||
export { jobRouter };
|
||||
|
||||
@@ -1,23 +1,100 @@
|
||||
import restana from 'restana';
|
||||
import * as listingStorage from '../../services/storage/listingsStorage.js';
|
||||
import * as watchListStorage from '../../services/storage/watchListStorage.js';
|
||||
import { isAdmin as isAdminFn } from '../security.js';
|
||||
import logger from '../../services/logger.js';
|
||||
import { nullOrEmpty } from '../../utils.js';
|
||||
import { getJobs } from '../../services/storage/jobStorage.js';
|
||||
|
||||
const service = restana();
|
||||
|
||||
const listingsRouter = service.newRouter();
|
||||
|
||||
listingsRouter.get('/table', async (req, res) => {
|
||||
const { page, pageSize = 50, filter, sortfield = null, sortdir = 'asc' } = req.query || {};
|
||||
const {
|
||||
page,
|
||||
pageSize = 50,
|
||||
activityFilter,
|
||||
jobNameFilter,
|
||||
providerFilter,
|
||||
watchListFilter,
|
||||
sortfield = null,
|
||||
sortdir = 'asc',
|
||||
freeTextFilter,
|
||||
} = req.query || {};
|
||||
|
||||
const result = listingStorage.queryListings({
|
||||
// normalize booleans (accept true, 'true', 1, '1')
|
||||
const toBool = (v) => v === true || v === 'true' || v === 1 || v === '1';
|
||||
const normalizedActivity = toBool(activityFilter) ? true : null;
|
||||
const normalizedWatch = toBool(watchListFilter) ? true : null;
|
||||
|
||||
let jobFilter = null;
|
||||
let jobIdFilter = null;
|
||||
const jobs = getJobs();
|
||||
if (!nullOrEmpty(jobNameFilter)) {
|
||||
const job = jobs.find((j) => j.id === jobNameFilter);
|
||||
jobFilter = job != null ? job.name : null;
|
||||
jobIdFilter = job != null ? job.id : null;
|
||||
}
|
||||
|
||||
res.body = listingStorage.queryListings({
|
||||
page: page ? parseInt(page, 10) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize, 10) : 50,
|
||||
filter: filter || undefined,
|
||||
freeTextFilter: freeTextFilter || null,
|
||||
activityFilter: normalizedActivity,
|
||||
jobNameFilter: jobFilter,
|
||||
jobIdFilter: jobIdFilter,
|
||||
providerFilter,
|
||||
watchListFilter: normalizedWatch,
|
||||
sortField: sortfield || null,
|
||||
sortDir: sortdir === 'desc' ? 'desc' : 'asc',
|
||||
userId: req.session.currentUser,
|
||||
isAdmin: isAdminFn(req),
|
||||
});
|
||||
res.body = result;
|
||||
res.send();
|
||||
});
|
||||
|
||||
// Toggle watch state for the current user on a listing
|
||||
listingsRouter.post('/watch', async (req, res) => {
|
||||
try {
|
||||
const { listingId } = req.body || {};
|
||||
const userId = req.session?.currentUser;
|
||||
if (!listingId || !userId) {
|
||||
res.statusCode = 400;
|
||||
res.body = { message: 'listingId or user not provided' };
|
||||
return res.send();
|
||||
}
|
||||
watchListStorage.toggleWatch(listingId, userId);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
res.statusCode = 500;
|
||||
res.body = { message: 'Failed to toggle watch' };
|
||||
}
|
||||
res.send();
|
||||
});
|
||||
|
||||
listingsRouter.delete('/job', async (req, res) => {
|
||||
const { jobId } = req.body;
|
||||
try {
|
||||
listingStorage.deleteListingsByJobId(jobId);
|
||||
} catch (error) {
|
||||
res.send(new Error(error));
|
||||
logger.error(error);
|
||||
}
|
||||
res.send();
|
||||
});
|
||||
|
||||
listingsRouter.delete('/', async (req, res) => {
|
||||
const { ids } = req.body;
|
||||
try {
|
||||
if (Array.isArray(ids) && ids.length > 0) {
|
||||
listingStorage.deleteListingsById(ids);
|
||||
}
|
||||
} catch (error) {
|
||||
res.send(new Error(error));
|
||||
logger.error(error);
|
||||
}
|
||||
res.send();
|
||||
});
|
||||
|
||||
export { listingsRouter };
|
||||
|
||||
@@ -11,10 +11,12 @@ function checkIfUserToBeRemovedIsLoggedIn(userIdToBeRemoved, req) {
|
||||
return req.session.currentUser === userIdToBeRemoved;
|
||||
}
|
||||
const nullOrEmpty = (str) => str == null || str.length === 0;
|
||||
|
||||
userRouter.get('/', async (req, res) => {
|
||||
res.body = userStorage.getUsers(false);
|
||||
res.send();
|
||||
});
|
||||
|
||||
userRouter.get('/:userId', async (req, res) => {
|
||||
const { userId } = req.params;
|
||||
res.body = userStorage.getUser(userId);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -36,7 +36,17 @@ Link: ${newListing.link}`;
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: message,
|
||||
});
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`Ntfy message could not be sent. Status code: ${res.status}`);
|
||||
}
|
||||
return res.text();
|
||||
})
|
||||
.catch((error) => {
|
||||
// Ensure we reject with an Error object and prevent unhandled rejections
|
||||
throw error instanceof Error ? error : new Error(String(error));
|
||||
});
|
||||
});
|
||||
|
||||
return Promise.all(promises);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
```
|
||||
|
||||
@@ -3,10 +3,14 @@ import { getJob } from '../../services/storage/jobStorage.js';
|
||||
import fetch from 'node-fetch';
|
||||
import pThrottle from 'p-throttle';
|
||||
import { normalizeImageUrl } from '../../utils.js';
|
||||
import logger from '../../services/logger.js';
|
||||
|
||||
const RATE_LIMIT_INTERVAL = 1000;
|
||||
const chatThrottleMap = new Map();
|
||||
|
||||
/**
|
||||
* Removes stale throttled call entries to keep memory bounded.
|
||||
*/
|
||||
function cleanupOldThrottles() {
|
||||
const now = Date.now();
|
||||
const maxAge = RATE_LIMIT_INTERVAL + 1000;
|
||||
@@ -17,6 +21,15 @@ function cleanupOldThrottles() {
|
||||
for (const chatId of toBeDeleted) chatThrottleMap.delete(chatId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a throttled wrapper for a chatId to limit Telegram API calls.
|
||||
* Uses p-throttle with 1 request per RATE_LIMIT_INTERVAL per chat.
|
||||
*
|
||||
* @template {Function} T
|
||||
* @param {string|number} chatId
|
||||
* @param {T} call - async function (endpoint: string, body: any) => Promise<Response>
|
||||
* @returns {T}
|
||||
*/
|
||||
function getThrottled(chatId, call) {
|
||||
cleanupOldThrottles();
|
||||
const now = Date.now();
|
||||
@@ -30,15 +43,38 @@ function getThrottled(chatId, call) {
|
||||
return throttled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorten a string to a maximum length with an ellipsis suffix.
|
||||
* @param {string} str
|
||||
* @param {number} [len=90]
|
||||
* @returns {string}
|
||||
*/
|
||||
function shorten(str, len = 90) {
|
||||
if (!str) return '';
|
||||
return str.length > len ? str.substring(0, len).trim() + '...' : str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape basic HTML entities for Telegram HTML parse mode.
|
||||
* @param {string} [s='']
|
||||
* @returns {string}
|
||||
*/
|
||||
function escapeHtml(s = '') {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Telegram photo caption (max 1024 characters) using HTML parse mode.
|
||||
* @param {string} jobName
|
||||
* @param {string} serviceName
|
||||
* @param {Object} o - Listing object
|
||||
* @param {string} [o.title]
|
||||
* @param {string} [o.address]
|
||||
* @param {string|number} [o.price]
|
||||
* @param {string|number} [o.size]
|
||||
* @param {string} [o.link]
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildCaption(jobName, serviceName, o) {
|
||||
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
||||
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
|
||||
@@ -47,6 +83,13 @@ function buildCaption(jobName, serviceName, o) {
|
||||
)}'><b>${escapeHtml(title)}</b></a>\n${escapeHtml(meta)}`.slice(0, 1024);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Telegram message text using HTML parse mode.
|
||||
* @param {string} jobName
|
||||
* @param {string} serviceName
|
||||
* @param {Object} o - Listing object
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildText(jobName, serviceName, o) {
|
||||
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
||||
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
|
||||
@@ -57,8 +100,41 @@ function buildText(jobName, serviceName, o) {
|
||||
);
|
||||
}
|
||||
|
||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||
const { token, chatId } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||
/**
|
||||
* Send new listings to Telegram.
|
||||
* - Respects per-chat Telegram rate limits using a lightweight throttle cache.
|
||||
* - Falls back to sendMessage when sendPhoto fails or image is missing.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.serviceName - Name of the crawler/service producing the listings.
|
||||
* @param {Array<Object>} params.newListings - Array of new listing objects.
|
||||
* @param {Array<Object>} params.notificationConfig - Notification adapters configuration array.
|
||||
* @param {string} params.jobKey - Storage job key to resolve the human readable job name.
|
||||
* @returns {Promise<Array<Response>>} Promise resolving when all send operations complete.
|
||||
*/
|
||||
export const send = ({ serviceName, newListings = [], notificationConfig, jobKey }) => {
|
||||
const adapterCfg = notificationConfig.find((adapter) => adapter.id === config.id);
|
||||
if (!adapterCfg || !adapterCfg.fields) {
|
||||
throw new Error(`Telegram adapter configuration missing for job '${jobKey || ''}'`);
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -68,9 +144,16 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
||||
body: JSON.stringify(body),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorBody = await res.text();
|
||||
throw new Error(`API error for '${jobName}'. '${endpoint}' returned ${errorBody}`);
|
||||
}
|
||||
return res;
|
||||
});
|
||||
|
||||
if (!Array.isArray(newListings) || newListings.length === 0) return Promise.resolve([]);
|
||||
|
||||
const promises = newListings.map(async (o) => {
|
||||
const img = normalizeImageUrl(o.image);
|
||||
const textPayload = {
|
||||
@@ -78,31 +161,37 @@ 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) {
|
||||
return throttledCall('sendMessage', textPayload);
|
||||
return await throttledCall('sendMessage', textPayload).catch(async (e) => {
|
||||
logger.error(`Error sending message to Telegram: ${e.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
return await throttledCall('sendPhoto', {
|
||||
chat_id: chatId,
|
||||
photo: img,
|
||||
caption: buildCaption(jobName, serviceName, o),
|
||||
parse_mode: 'HTML',
|
||||
return await throttledCall('sendPhoto', {
|
||||
chat_id: chatId,
|
||||
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) => {
|
||||
logger.error(`Error sending message to Telegram: ${e.message}`);
|
||||
throw e;
|
||||
});
|
||||
} catch (e) {
|
||||
// If we see a timeout due to sending an image, try sending it without
|
||||
if (e && (e.code === 'ETIMEDOUT' || e.errno === 'ETIMEDOUT')) {
|
||||
return throttledCall('sendMessage', textPayload);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return Promise.all(promises);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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},messageThreadId?:{type:string,label:string,description:string}}}}
|
||||
*/
|
||||
export const config = {
|
||||
id: 'telegram',
|
||||
name: 'Telegram',
|
||||
@@ -119,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.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 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 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;
|
||||
return Object.assign(o, { id, address, title, link, image });
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import logger from '../logger.js';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { URL } from 'url';
|
||||
|
||||
puppeteer.use(StealthPlugin());
|
||||
|
||||
@@ -27,23 +28,97 @@ export default async function execute(url, waitForSelector, options) {
|
||||
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({
|
||||
headless: options.puppeteerHeadless ?? true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-gpu',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-crash-reporter',
|
||||
],
|
||||
timeout: options.puppeteerTimeout || 30_000,
|
||||
headless: options?.puppeteerHeadless ?? true,
|
||||
args: launchArgs,
|
||||
timeout: options?.puppeteerTimeout || 30_000,
|
||||
userDataDir,
|
||||
executablePath: options?.executablePath, // allow using system Chrome
|
||||
});
|
||||
|
||||
page = await browser.newPage();
|
||||
await page.setExtraHTTPHeaders(DEFAULT_HEADER);
|
||||
const response = await page.goto(url, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
|
||||
// Derive domain-specific defaults
|
||||
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;
|
||||
// if we're extracting data from a SPA, we must wait for the selector
|
||||
if (waitForSelector != null) {
|
||||
@@ -57,7 +132,7 @@ export default async function execute(url, waitForSelector, options) {
|
||||
pageSource = await page.content();
|
||||
}
|
||||
|
||||
const statusCode = response.status();
|
||||
const statusCode = response?.status?.() ?? 200;
|
||||
|
||||
if (botDetected(pageSource, statusCode)) {
|
||||
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());
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error executing with puppeteer executor', error);
|
||||
logger.warn('Error executing with puppeteer executor', error);
|
||||
result = null;
|
||||
} finally {
|
||||
try {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -16,7 +16,16 @@ import { toJson, fromJson } from '../../utils.js';
|
||||
* @param {string} params.userId - Owner user id for inserts; preserved on updates.
|
||||
* @returns {void}
|
||||
*/
|
||||
export const upsertJob = ({ jobId, name, blacklist = [], enabled = true, provider, notificationAdapter, userId }) => {
|
||||
export const upsertJob = ({
|
||||
jobId,
|
||||
name,
|
||||
blacklist = [],
|
||||
enabled = true,
|
||||
provider,
|
||||
notificationAdapter,
|
||||
userId,
|
||||
shareWithUsers = [],
|
||||
}) => {
|
||||
const id = jobId || nanoid();
|
||||
const existing = SqliteConnection.query(`SELECT id, user_id FROM jobs WHERE id = @id LIMIT 1`, { id })[0];
|
||||
const ownerId = existing ? existing.user_id : userId;
|
||||
@@ -27,21 +36,23 @@ export const upsertJob = ({ jobId, name, blacklist = [], enabled = true, provide
|
||||
name = @name,
|
||||
blacklist = @blacklist,
|
||||
provider = @provider,
|
||||
notification_adapter = @notification_adapter
|
||||
notification_adapter = @notification_adapter,
|
||||
shared_with_user = @shareWithUsers
|
||||
WHERE id = @id`,
|
||||
{
|
||||
id,
|
||||
enabled: enabled ? 1 : 0,
|
||||
name: name ?? null,
|
||||
blacklist: toJson(blacklist ?? []),
|
||||
shareWithUsers: toJson(shareWithUsers ?? []),
|
||||
provider: toJson(provider ?? []),
|
||||
notification_adapter: toJson(notificationAdapter ?? []),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
SqliteConnection.execute(
|
||||
`INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter)
|
||||
VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter)`,
|
||||
`INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter, shared_with_user)
|
||||
VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter, @shareWithUsers)`,
|
||||
{
|
||||
id,
|
||||
user_id: ownerId,
|
||||
@@ -49,6 +60,7 @@ export const upsertJob = ({ jobId, name, blacklist = [], enabled = true, provide
|
||||
name: name ?? null,
|
||||
blacklist: toJson(blacklist ?? []),
|
||||
provider: toJson(provider ?? []),
|
||||
shareWithUsers: toJson(shareWithUsers ?? []),
|
||||
notification_adapter: toJson(notificationAdapter ?? []),
|
||||
},
|
||||
);
|
||||
@@ -129,6 +141,7 @@ export const getJobs = () => {
|
||||
j.name,
|
||||
j.blacklist,
|
||||
j.provider,
|
||||
j.shared_with_user,
|
||||
j.notification_adapter AS notificationAdapter,
|
||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
|
||||
FROM jobs j
|
||||
@@ -139,6 +152,7 @@ export const getJobs = () => {
|
||||
enabled: !!row.enabled,
|
||||
blacklist: fromJson(row.blacklist, []),
|
||||
provider: fromJson(row.provider, []),
|
||||
shared_with_user: fromJson(row.shared_with_user, []),
|
||||
notificationAdapter: fromJson(row.notificationAdapter, []),
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -48,7 +48,8 @@ export const getKnownListingHashesForJobAndProvider = (jobId, providerId) => {
|
||||
return SqliteConnection.query(
|
||||
`SELECT hash
|
||||
FROM listings
|
||||
WHERE job_id = @jobId AND provider = @providerId`,
|
||||
WHERE job_id = @jobId
|
||||
AND provider = @providerId`,
|
||||
{ jobId, providerId },
|
||||
).map((r) => r.hash);
|
||||
};
|
||||
@@ -63,7 +64,9 @@ export const getActiveOrUnknownListings = () => {
|
||||
return SqliteConnection.query(
|
||||
`SELECT *
|
||||
FROM listings
|
||||
WHERE is_active is null OR is_active = 1 ORDER BY provider`,
|
||||
WHERE is_active is null
|
||||
OR is_active = 1
|
||||
ORDER BY provider`,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -149,8 +152,9 @@ export const storeListings = (jobId, providerId, listings) => {
|
||||
*/
|
||||
function extractNumber(str) {
|
||||
if (!str) return null;
|
||||
const match = str.replace(/[.,]/g, '').match(/\d+/);
|
||||
return match ? +match[0] : null;
|
||||
const cleaned = str.replace(/\./g, '').replace(',', '.');
|
||||
const num = parseFloat(cleaned);
|
||||
return isNaN(num) ? null : num;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,7 +177,11 @@ export const storeListings = (jobId, providerId, listings) => {
|
||||
* @param {Object} params
|
||||
* @param {number} [params.pageSize=50]
|
||||
* @param {number} [params.page=1]
|
||||
* @param {string} [params.filter]
|
||||
* @param {string} [params.freeTextFilter]
|
||||
* @param {object} [params.activityFilter]
|
||||
* @param {object} [params.jobNameFilter]
|
||||
* @param {object} [params.providerFilter]
|
||||
* @param {object} [params.watchListFilter]
|
||||
* @param {string|null} [params.sortField=null] - One of: 'created_at','price','size','provider','title'.
|
||||
* @param {('asc'|'desc')} [params.sortDir='asc']
|
||||
* @param {string} [params.userId] - Current user id used to scope listings (ignored for admins).
|
||||
@@ -183,7 +191,12 @@ export const storeListings = (jobId, providerId, listings) => {
|
||||
export const queryListings = ({
|
||||
pageSize = 50,
|
||||
page = 1,
|
||||
filter,
|
||||
activityFilter,
|
||||
jobNameFilter,
|
||||
jobIdFilter,
|
||||
providerFilter,
|
||||
watchListFilter,
|
||||
freeTextFilter,
|
||||
sortField = null,
|
||||
sortDir = 'asc',
|
||||
userId = null,
|
||||
@@ -197,15 +210,42 @@ export const queryListings = ({
|
||||
// build WHERE filter across common text columns
|
||||
const whereParts = [];
|
||||
const params = { limit: safePageSize, offset };
|
||||
// always provide userId param for watched-flag evaluation (null -> no matches)
|
||||
params.userId = userId || '__NO_USER__';
|
||||
// user scoping (non-admin only): restrict to listings whose job belongs to user
|
||||
if (!isAdmin) {
|
||||
params.userId = userId || '__NO_USER__';
|
||||
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 (filter && String(filter).trim().length > 0) {
|
||||
params.filter = `%${String(filter).trim()}%`;
|
||||
if (freeTextFilter && String(freeTextFilter).trim().length > 0) {
|
||||
params.filter = `%${String(freeTextFilter).trim()}%`;
|
||||
whereParts.push(`(title LIKE @filter OR address LIKE @filter OR provider LIKE @filter OR link LIKE @filter)`);
|
||||
}
|
||||
// activityFilter: when true -> only active listings (is_active = 1)
|
||||
if (activityFilter === true) {
|
||||
whereParts.push('(is_active = 1)');
|
||||
}
|
||||
// Prefer filtering by job id when provided (unambiguous and robust)
|
||||
if (jobIdFilter && String(jobIdFilter).trim().length > 0) {
|
||||
params.jobId = String(jobIdFilter).trim();
|
||||
whereParts.push('(l.job_id = @jobId)');
|
||||
} else if (jobNameFilter && String(jobNameFilter).trim().length > 0) {
|
||||
// Fallback to exact job name match
|
||||
params.jobName = String(jobNameFilter).trim();
|
||||
whereParts.push('(j.name = @jobName)');
|
||||
}
|
||||
// providerFilter: when provided as string (assumed provider name), filter listings where provider equals that name (exact match)
|
||||
if (providerFilter && String(providerFilter).trim().length > 0) {
|
||||
params.providerName = String(providerFilter).trim();
|
||||
whereParts.push('(provider = @providerName)');
|
||||
}
|
||||
// watchListFilter: when true -> only watched listings
|
||||
if (watchListFilter === true) {
|
||||
whereParts.push('(wl.id IS NOT NULL)');
|
||||
}
|
||||
|
||||
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||||
const whereSqlWithAlias = whereSql
|
||||
.replace(/\btitle\b/g, 'l.title')
|
||||
@@ -213,10 +253,13 @@ export const queryListings = ({
|
||||
.replace(/\baddress\b/g, 'l.address')
|
||||
.replace(/\bprovider\b/g, 'l.provider')
|
||||
.replace(/\blink\b/g, 'l.link')
|
||||
.replace(/\bj\.user_id\b/g, 'j.user_id');
|
||||
.replace(/\bis_active\b/g, 'l.is_active')
|
||||
.replace(/\bj\.user_id\b/g, 'j.user_id')
|
||||
.replace(/\bj\.name\b/g, 'j.name')
|
||||
.replace(/\bwl\.id\b/g, 'wl.id');
|
||||
|
||||
// whitelist sortable fields to avoid SQL injection
|
||||
const sortable = new Set(['created_at', 'price', 'size', 'provider', 'title', 'job_name', 'is_active']);
|
||||
const sortable = new Set(['created_at', 'price', 'size', 'provider', 'title', 'job_name', 'is_active', 'isWatched']);
|
||||
const safeSortField = sortField && sortable.has(sortField) ? sortField : null;
|
||||
const safeSortDir = String(sortDir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
||||
const orderSql = safeSortField ? `ORDER BY ${safeSortField} ${safeSortDir}` : 'ORDER BY created_at DESC';
|
||||
@@ -226,28 +269,77 @@ export const queryListings = ({
|
||||
.replace(/\bsize\b/g, 'l.size')
|
||||
.replace(/\bprovider\b/g, 'l.provider')
|
||||
.replace(/\btitle\b/g, 'l.title')
|
||||
.replace(/\bjob_name\b/g, 'j.name');
|
||||
.replace(/\bjob_name\b/g, 'j.name')
|
||||
// Sort by computed watch flag when requested
|
||||
.replace(/\bisWatched\b/g, 'CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END');
|
||||
|
||||
// count total with same WHERE
|
||||
const countRow = SqliteConnection.query(
|
||||
`SELECT COUNT(1) as cnt
|
||||
FROM listings l
|
||||
LEFT JOIN jobs j ON j.id = l.job_id
|
||||
${whereSqlWithAlias}`,
|
||||
LEFT JOIN jobs j ON j.id = l.job_id
|
||||
LEFT JOIN watch_list wl ON wl.listing_id = l.id AND wl.user_id = @userId
|
||||
${whereSqlWithAlias}`,
|
||||
params,
|
||||
);
|
||||
const totalNumber = countRow?.[0]?.cnt ?? 0;
|
||||
|
||||
// fetch page
|
||||
const rows = SqliteConnection.query(
|
||||
`SELECT l.*, j.name AS job_name
|
||||
`SELECT l.*,
|
||||
j.name AS job_name,
|
||||
CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END AS isWatched
|
||||
FROM listings l
|
||||
LEFT JOIN jobs j ON j.id = l.job_id
|
||||
${whereSqlWithAlias}
|
||||
${orderSqlWithAlias}
|
||||
LEFT JOIN jobs j ON j.id = l.job_id
|
||||
LEFT JOIN watch_list wl ON wl.listing_id = l.id AND wl.user_id = @userId
|
||||
${whereSqlWithAlias}
|
||||
${orderSqlWithAlias}
|
||||
LIMIT @limit OFFSET @offset`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { totalNumber, page: safePage, result: rows };
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete all listings for a given job id.
|
||||
*
|
||||
* @param {string} jobId - The job identifier whose listings should be removed.
|
||||
* @returns {any} The result from SqliteConnection.execute (may contain changes count).
|
||||
*/
|
||||
export const deleteListingsByJobId = (jobId) => {
|
||||
if (!jobId) return;
|
||||
return SqliteConnection.execute(
|
||||
`DELETE
|
||||
FROM listings
|
||||
WHERE job_id = @jobId`,
|
||||
{ jobId },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete listings by a list of listing IDs.
|
||||
*
|
||||
* @param {string[]} ids - Array of listing IDs to delete.
|
||||
* @returns {any} The result from SqliteConnection.execute.
|
||||
*/
|
||||
export const deleteListingsById = (ids) => {
|
||||
if (!Array.isArray(ids) || ids.length === 0) return;
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
return SqliteConnection.execute(
|
||||
`DELETE
|
||||
FROM listings
|
||||
WHERE id IN (${placeholders})`,
|
||||
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`);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// Migration: Adding a changeset field to the listings table in preparation for
|
||||
// a price watch feature
|
||||
|
||||
export function up(db) {
|
||||
db.exec(`
|
||||
ALTER TABLE listings ADD COLUMN change_set jsonb;
|
||||
`);
|
||||
}
|
||||
15
lib/services/storage/migrations/sql/4.watch-list.js
Normal file
15
lib/services/storage/migrations/sql/4.watch-list.js
Normal file
@@ -0,0 +1,15 @@
|
||||
// Migration: Adding a new table to store if somebody "watches" (a.k.a favorite) a listing
|
||||
|
||||
export function up(db) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS watch_list
|
||||
(
|
||||
id TEXT PRIMARY KEY,
|
||||
listing_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
FOREIGN KEY (listing_id) REFERENCES listings (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_watch_list ON watch_list (listing_id, user_id);
|
||||
`);
|
||||
}
|
||||
7
lib/services/storage/migrations/sql/5.job-sharing.js
Normal file
7
lib/services/storage/migrations/sql/5.job-sharing.js
Normal file
@@ -0,0 +1,7 @@
|
||||
// Migration: Adding a new table to store if somebody shared a job with someone
|
||||
|
||||
export function up(db) {
|
||||
db.exec(`
|
||||
ALTER TABLE jobs ADD COLUMN shared_with_user jsonb DEFAULT '[]'
|
||||
`);
|
||||
}
|
||||
64
lib/services/storage/watchListStorage.js
Normal file
64
lib/services/storage/watchListStorage.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import SqliteConnection from './SqliteConnection.js';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
/**
|
||||
* Create a watch entry. Idempotent due to unique index (listing_id, user_id).
|
||||
* @param {string} listingId
|
||||
* @param {string} userId
|
||||
* @returns {{created:boolean}}
|
||||
*/
|
||||
export const createWatch = (listingId, userId) => {
|
||||
if (!listingId || !userId) return { created: false };
|
||||
try {
|
||||
SqliteConnection.execute(
|
||||
`INSERT INTO watch_list (id, listing_id, user_id)
|
||||
VALUES (@id, @listing_id, @user_id)
|
||||
ON CONFLICT(listing_id, user_id) DO NOTHING`,
|
||||
{ id: nanoid(), listing_id: listingId, user_id: userId },
|
||||
);
|
||||
// check whether it exists now
|
||||
const row = SqliteConnection.query(
|
||||
`SELECT 1 AS ok FROM watch_list WHERE listing_id = @listing_id AND user_id = @user_id LIMIT 1`,
|
||||
{ listing_id: listingId, user_id: userId },
|
||||
);
|
||||
return { created: row.length > 0 };
|
||||
} catch {
|
||||
return { created: false };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a watch entry.
|
||||
* @param {string} listingId
|
||||
* @param {string} userId
|
||||
* @returns {{deleted:boolean}}
|
||||
*/
|
||||
export const deleteWatch = (listingId, userId) => {
|
||||
if (!listingId || !userId) return { deleted: false };
|
||||
const res = SqliteConnection.execute(`DELETE FROM watch_list WHERE listing_id = @listing_id AND user_id = @user_id`, {
|
||||
listing_id: listingId,
|
||||
user_id: userId,
|
||||
});
|
||||
return { deleted: Boolean(res?.changes) };
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle a watch entry. If exists -> delete, otherwise create.
|
||||
* @param {string} listingId
|
||||
* @param {string} userId
|
||||
* @returns {{watched:boolean}}
|
||||
*/
|
||||
export const toggleWatch = (listingId, userId) => {
|
||||
if (!listingId || !userId) return { watched: false };
|
||||
const exists =
|
||||
SqliteConnection.query(
|
||||
`SELECT 1 AS ok FROM watch_list WHERE listing_id = @listing_id AND user_id = @user_id LIMIT 1`,
|
||||
{ listing_id: listingId, user_id: userId },
|
||||
).length > 0;
|
||||
if (exists) {
|
||||
deleteWatch(listingId, userId);
|
||||
return { watched: false };
|
||||
}
|
||||
createWatch(listingId, userId);
|
||||
return { watched: true };
|
||||
};
|
||||
17
lib/utils.js
17
lib/utils.js
@@ -180,6 +180,23 @@ function buildHash(...inputs) {
|
||||
*/
|
||||
let config = {};
|
||||
|
||||
/**
|
||||
* If the config exists, but cannot be accessed, we quit Fredy as something is fishy here.
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function checkIfConfigIsAccessible() {
|
||||
const path = new URL('../conf/config.json', import.meta.url);
|
||||
try {
|
||||
if (!fs.existsSync(path)) {
|
||||
return true;
|
||||
}
|
||||
fs.accessSync(path, fs.constants.R_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read config JSON from disk (conf/config.json) and parse it.
|
||||
* @returns {Promise<any>} Parsed configuration object.
|
||||
|
||||
47
package.json
47
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fredy",
|
||||
"version": "14.0.0",
|
||||
"version": "14.3.4",
|
||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||
"scripts": {
|
||||
"prepare": "husky",
|
||||
@@ -56,58 +56,57 @@
|
||||
"Firefox ESR"
|
||||
],
|
||||
"dependencies": {
|
||||
"@douyinfe/semi-icons": "^2.86.0",
|
||||
"@douyinfe/semi-ui": "2.86.0",
|
||||
"@douyinfe/semi-icons": "^2.88.0",
|
||||
"@douyinfe/semi-ui": "2.88.0",
|
||||
"@sendgrid/mail": "8.1.6",
|
||||
"@visactor/react-vchart": "^2.0.5",
|
||||
"@visactor/vchart": "^2.0.5",
|
||||
"@visactor/react-vchart": "^2.0.8",
|
||||
"@visactor/vchart": "^2.0.8",
|
||||
"@visactor/vchart-semi-theme": "^1.12.2",
|
||||
"@vitejs/plugin-react": "5.0.4",
|
||||
"@vitejs/plugin-react": "5.1.1",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"body-parser": "2.2.0",
|
||||
"cheerio": "^1.1.2",
|
||||
"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",
|
||||
"node-mailjet": "6.0.9",
|
||||
"p-throttle": "^8.0.0",
|
||||
"node-mailjet": "6.0.11",
|
||||
"p-throttle": "^8.1.0",
|
||||
"package-up": "^5.0.0",
|
||||
"puppeteer": "^24.22.3",
|
||||
"puppeteer": "^24.30.0",
|
||||
"puppeteer-extra": "^3.3.6",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||
"query-string": "9.3.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-router": "7.9.3",
|
||||
"react-router-dom": "7.9.3",
|
||||
"react-router": "7.9.6",
|
||||
"react-router-dom": "7.9.6",
|
||||
"restana": "5.1.0",
|
||||
"semver": "^7.7.2",
|
||||
"semver": "^7.7.3",
|
||||
"serve-static": "2.2.0",
|
||||
"slack": "11.0.2",
|
||||
"vite": "7.1.7",
|
||||
"vite": "7.2.2",
|
||||
"x-var": "^3.0.1",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.28.4",
|
||||
"@babel/eslint-parser": "7.28.4",
|
||||
"@babel/preset-env": "7.28.3",
|
||||
"@babel/preset-react": "7.27.1",
|
||||
"chai": "6.2.0",
|
||||
"eslint": "9.36.0",
|
||||
"@babel/core": "7.28.5",
|
||||
"@babel/eslint-parser": "7.28.5",
|
||||
"@babel/preset-env": "7.28.5",
|
||||
"@babel/preset-react": "7.28.5",
|
||||
"chai": "6.2.1",
|
||||
"eslint": "9.39.1",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"esmock": "2.7.3",
|
||||
"history": "5.3.0",
|
||||
"husky": "9.1.7",
|
||||
"less": "4.4.1",
|
||||
"lint-staged": "16.2.3",
|
||||
"mocha": "11.7.2",
|
||||
"nodemon": "^3.1.10",
|
||||
"less": "4.4.2",
|
||||
"lint-staged": "16.2.6",
|
||||
"mocha": "11.7.5",
|
||||
"nodemon": "^3.1.11",
|
||||
"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';
|
||||
|
||||
describe('#einsAImmobilien testsuite()', () => {
|
||||
after(() => {
|
||||
similarityCache.stopCacheCleanup();
|
||||
});
|
||||
provider.init(providerConfig.einsAImmobilien, [], []);
|
||||
it('should test einsAImmobilien provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, [], []);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, [], []);
|
||||
|
||||
@@ -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, [], []);
|
||||
|
||||
@@ -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, []);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, []);
|
||||
|
||||
@@ -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, []);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 mockFredy = async () => {
|
||||
return await esmock('../lib/FredyRuntime', {
|
||||
return await esmock('../lib/FredyPipeline', {
|
||||
'../lib/services/storage/listingsStorage.js': {
|
||||
...mockStore,
|
||||
},
|
||||
|
||||
@@ -37,6 +37,7 @@ export default function FredyApp() {
|
||||
await actions.provider.getProvider();
|
||||
await actions.jobs.getJobs();
|
||||
await actions.jobs.getProcessingTimes();
|
||||
await actions.jobs.getSharableUserList();
|
||||
await actions.notificationAdapter.getAdapter();
|
||||
await actions.generalSettings.getGeneralSettings();
|
||||
await actions.versionUpdate.getVersionUpdate();
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 1.7rem;
|
||||
border-radius: .3rem;
|
||||
border-top: 1px solid #45464b;
|
||||
|
||||
&__version {
|
||||
padding-left: .5rem;
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function Navigation({ isAdmin }) {
|
||||
|
||||
if (isAdmin) {
|
||||
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) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Button, Empty, Table, Switch } from '@douyinfe/semi-ui';
|
||||
import { IconDelete, IconEdit, IconHistogram } from '@douyinfe/semi-icons';
|
||||
import { Button, Empty, Table, Switch, Popover } from '@douyinfe/semi-ui';
|
||||
import { IconAlertTriangle, IconDelete, IconDescend2, IconEdit, IconHistogram } from '@douyinfe/semi-icons';
|
||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||
|
||||
import './JobTable.less';
|
||||
@@ -14,7 +14,16 @@ const empty = (
|
||||
/>
|
||||
);
|
||||
|
||||
export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged, onJobEdit, onJobInsight } = {}) {
|
||||
const getPopoverContent = (text) => <article className="jobPopoverContent">{text}</article>;
|
||||
|
||||
export default function JobTable({
|
||||
jobs = {},
|
||||
onJobRemoval,
|
||||
onJobStatusChanged,
|
||||
onJobEdit,
|
||||
onJobInsight,
|
||||
onListingRemoval,
|
||||
} = {}) {
|
||||
return (
|
||||
<Table
|
||||
pagination={false}
|
||||
@@ -24,12 +33,38 @@ export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged,
|
||||
title: '',
|
||||
dataIndex: '',
|
||||
render: (job) => {
|
||||
return <Switch onChange={(checked) => onJobStatusChanged(job.id, checked)} checked={job.enabled} />;
|
||||
return (
|
||||
<Switch
|
||||
onChange={(checked) => onJobStatusChanged(job.id, checked)}
|
||||
checked={job.enabled}
|
||||
disabled={job.isOnlyShared}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
render: (name, job) => {
|
||||
if (job.isOnlyShared) {
|
||||
return (
|
||||
<Popover
|
||||
content={getPopoverContent(
|
||||
'This job has been shared with you by another user, therefor it is read-only.',
|
||||
)}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: '.3rem' }}>
|
||||
<div style={{ color: 'rgba(var(--semi-yellow-7), 1)' }}>
|
||||
<IconAlertTriangle />
|
||||
</div>
|
||||
{name}
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
} else {
|
||||
return name;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Listings',
|
||||
@@ -39,14 +74,14 @@ export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Providers',
|
||||
title: 'Provider',
|
||||
dataIndex: 'provider',
|
||||
render: (value) => {
|
||||
return value.length || 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Notification adapters',
|
||||
title: 'Notification Adapter',
|
||||
dataIndex: 'notificationAdapter',
|
||||
render: (value) => {
|
||||
return value.length || 0;
|
||||
@@ -58,9 +93,38 @@ export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged,
|
||||
render: (_, job) => {
|
||||
return (
|
||||
<div className="interactions">
|
||||
<Button type="primary" icon={<IconHistogram />} onClick={() => onJobInsight(job.id)} />
|
||||
<Button type="secondary" icon={<IconEdit />} onClick={() => onJobEdit(job.id)} />
|
||||
<Button type="danger" icon={<IconDelete />} onClick={() => onJobRemoval(job.id)} />
|
||||
<Popover content={getPopoverContent('Job Insights')}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<IconHistogram />}
|
||||
disabled={job.isOnlyShared}
|
||||
onClick={() => onJobInsight(job.id)}
|
||||
/>
|
||||
</Popover>
|
||||
<Popover content={getPopoverContent('Edit a Job')}>
|
||||
<Button
|
||||
type="secondary"
|
||||
icon={<IconEdit />}
|
||||
disabled={job.isOnlyShared}
|
||||
onClick={() => onJobEdit(job.id)}
|
||||
/>
|
||||
</Popover>
|
||||
<Popover content={getPopoverContent('Delete all found Listings of this Job')}>
|
||||
<Button
|
||||
type="danger"
|
||||
icon={<IconDescend2 />}
|
||||
disabled={job.isOnlyShared}
|
||||
onClick={() => onListingRemoval(job.id)}
|
||||
/>
|
||||
</Popover>
|
||||
<Popover content={getPopoverContent('Delete Job')}>
|
||||
<Button
|
||||
type="danger"
|
||||
icon={<IconDelete />}
|
||||
disabled={job.isOnlyShared}
|
||||
onClick={() => onJobRemoval(job.id)}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.jobPopoverContent {
|
||||
padding: 1rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.interactions {
|
||||
flex-direction: initial;
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Table, Popover, Input, Descriptions, Tag, Image, Empty } from '@douyinfe/semi-ui';
|
||||
import { useActions, useSelector } from '../../services/state/store.js';
|
||||
import { IconClose, IconSearch, IconTick } from '@douyinfe/semi-icons';
|
||||
import * as timeService from '../../services/time/timeService.js';
|
||||
import debounce from 'lodash/debounce';
|
||||
import no_image from '../../assets/no_image.jpg';
|
||||
|
||||
import './ListingsTable.less';
|
||||
import { format } from '../../services/time/timeService.js';
|
||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '#',
|
||||
dataIndex: 'is_active',
|
||||
width: 58,
|
||||
sorter: true,
|
||||
render: (value) => {
|
||||
return value ? (
|
||||
<div style={{ color: 'rgba(var(--semi-green-6), 1)' }}>
|
||||
<Popover
|
||||
style={{
|
||||
padding: '.4rem',
|
||||
color: 'var(--semi-color-white)',
|
||||
}}
|
||||
content="Listing still online"
|
||||
>
|
||||
<IconTick />
|
||||
</Popover>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: 'rgba(var(--semi-red-5), 1)' }}>
|
||||
<Popover
|
||||
style={{
|
||||
padding: '.4rem',
|
||||
color: 'var(--semi-color-white)',
|
||||
}}
|
||||
content="Listing not online anymore"
|
||||
>
|
||||
<IconClose />
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Job-Name',
|
||||
sorter: true,
|
||||
dataIndex: 'job_name',
|
||||
width: 170,
|
||||
},
|
||||
{
|
||||
title: 'Listing date',
|
||||
width: 130,
|
||||
dataIndex: 'created_at',
|
||||
sorter: true,
|
||||
render: (text) => timeService.format(text),
|
||||
},
|
||||
{
|
||||
title: 'Provider',
|
||||
width: 130,
|
||||
dataIndex: 'provider',
|
||||
sorter: true,
|
||||
render: (text) => text.charAt(0).toUpperCase() + text.slice(1),
|
||||
},
|
||||
{
|
||||
title: 'Price',
|
||||
width: 110,
|
||||
dataIndex: 'price',
|
||||
sorter: true,
|
||||
render: (text) => text + ' €',
|
||||
},
|
||||
{
|
||||
title: 'Address',
|
||||
width: 150,
|
||||
dataIndex: 'address',
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: 'Title',
|
||||
dataIndex: 'title',
|
||||
sorter: true,
|
||||
render: (text, row) => {
|
||||
return (
|
||||
<a href={row.url} target="_blank" rel="noopener noreferrer">
|
||||
{text}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const empty = (
|
||||
<Empty
|
||||
image={<IllustrationNoResult />}
|
||||
darkModeImage={<IllustrationNoResultDark />}
|
||||
description="No listings available."
|
||||
/>
|
||||
);
|
||||
|
||||
export default function ListingsTable() {
|
||||
const tableData = useSelector((state) => state.listingsTable);
|
||||
const actions = useActions();
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 10;
|
||||
const [sortData, setSortData] = useState({});
|
||||
const [filter, setFilter] = useState(null);
|
||||
|
||||
const handlePageChange = (_page) => {
|
||||
setPage(_page);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let sortfield = null;
|
||||
let sortdir = null;
|
||||
|
||||
if (sortData != null && Object.keys(sortData).length > 0) {
|
||||
sortfield = sortData.field;
|
||||
sortdir = sortData.direction;
|
||||
}
|
||||
actions.listingsTable.getListingsTable({ page, pageSize, sortfield, sortdir, filter });
|
||||
}, [page, sortData, filter]);
|
||||
|
||||
const handleFilterChange = useMemo(() => debounce((value) => setFilter(value), 500), []);
|
||||
|
||||
const expandRowRender = (record) => {
|
||||
return (
|
||||
<div className="listingsTable__expanded">
|
||||
<div>
|
||||
{record.image_url == null ? (
|
||||
<Image height={200} src={no_image} />
|
||||
) : (
|
||||
<Image height={200} src={record.image_url} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Descriptions align="justify">
|
||||
<Descriptions.Item itemKey="Listing still online">
|
||||
<Tag size="small" shape="circle" color={record.is_active ? 'green' : 'red'}>
|
||||
{record.is_active ? 'Yes' : 'No'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item itemKey="Link">
|
||||
<a href={record.link} target="_blank" rel="noreferrer">
|
||||
Link to Listing
|
||||
</a>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item itemKey="Listing date">{format(record.created_at)}</Descriptions.Item>
|
||||
<Descriptions.Item itemKey="Price">{record.price} €</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<b>{record.title}</b>
|
||||
<p>{record.description == null ? 'No description available' : record.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
showClear
|
||||
className="listingsTable__search"
|
||||
placeholder="Search"
|
||||
onChange={handleFilterChange}
|
||||
/>
|
||||
<Table
|
||||
rowKey="id"
|
||||
empty={empty}
|
||||
hideExpandedColumn={false}
|
||||
sticky={{ top: 5 }}
|
||||
columns={columns}
|
||||
expandedRowRender={expandRowRender}
|
||||
dataSource={tableData?.result || []}
|
||||
onChange={(changeSet) => {
|
||||
if (changeSet?.extra?.changeType === 'sorter') {
|
||||
setSortData({
|
||||
field: changeSet.sorter.dataIndex,
|
||||
direction: changeSet.sorter.sortOrder === 'ascend' ? 'asc' : 'desc',
|
||||
});
|
||||
}
|
||||
}}
|
||||
pagination={{
|
||||
currentPage: page,
|
||||
//for now fixed
|
||||
pageSize,
|
||||
total: tableData?.totalNumber || 0,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
ui/src/components/table/listings/ListingsFilter.jsx
Normal file
53
ui/src/components/table/listings/ListingsFilter.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Card, Checkbox, Descriptions, Divider, Select } from '@douyinfe/semi-ui';
|
||||
import React from 'react';
|
||||
import { useSelector } from '../../../services/state/store.js';
|
||||
import { Typography } from '@douyinfe/semi-ui';
|
||||
|
||||
import './ListingsFilter.less';
|
||||
|
||||
export default function ListingsFilter({ onWatchListFilter, onActivityFilter, onJobNameFilter, onProviderFilter }) {
|
||||
const jobs = useSelector((state) => state.jobs.jobs);
|
||||
const provider = useSelector((state) => state.provider);
|
||||
const { Title } = Typography;
|
||||
return (
|
||||
<Card className="listingsFilter">
|
||||
<Title heading={6}>Filter by:</Title>
|
||||
<Divider />
|
||||
<br />
|
||||
<Descriptions row>
|
||||
<Descriptions.Item itemKey="Watch List">
|
||||
<Checkbox onChange={(e) => onWatchListFilter(e.target.checked)}>Only Watch List</Checkbox>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item itemKey="Activity status">
|
||||
<Checkbox onChange={(e) => onActivityFilter(e.target.checked)}>Only Active Listings</Checkbox>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item itemKey="Job Name">
|
||||
<Select showClear placeholder="Select Job to Filter" onChange={(val) => onJobNameFilter(val)}>
|
||||
{jobs != null &&
|
||||
jobs.length > 0 &&
|
||||
jobs.map((job) => {
|
||||
return (
|
||||
<Select.Option value={job.id} key={job.id}>
|
||||
{job.name}
|
||||
</Select.Option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item itemKey="Provider">
|
||||
<Select showClear placeholder="Select Provider to Filter" onChange={(val) => onProviderFilter(val)}>
|
||||
{provider != null &&
|
||||
provider.length > 0 &&
|
||||
provider.map((prov) => {
|
||||
return (
|
||||
<Select.Option value={prov.id} key={prov.id}>
|
||||
{prov.name}
|
||||
</Select.Option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
4
ui/src/components/table/listings/ListingsFilter.less
Normal file
4
ui/src/components/table/listings/ListingsFilter.less
Normal file
@@ -0,0 +1,4 @@
|
||||
.listingsFilter {
|
||||
margin-bottom: 1rem;
|
||||
background: rgb(53, 54, 60);
|
||||
}
|
||||
305
ui/src/components/table/listings/ListingsTable.jsx
Normal file
305
ui/src/components/table/listings/ListingsTable.jsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Table, Popover, Input, Descriptions, Tag, Image, Empty, Button, Toast, Divider } from '@douyinfe/semi-ui';
|
||||
import { useActions, useSelector } from '../../../services/state/store.js';
|
||||
import { IconClose, IconDelete, IconSearch, IconStar, IconStarStroked, IconTick } from '@douyinfe/semi-icons';
|
||||
import * as timeService from '../../../services/time/timeService.js';
|
||||
import debounce from 'lodash/debounce';
|
||||
import no_image from '../../../assets/no_image.jpg';
|
||||
|
||||
import './ListingsTable.less';
|
||||
import { format } from '../../../services/time/timeService.js';
|
||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||
import { xhrDelete, xhrPost } from '../../../services/xhr.js';
|
||||
import ListingsFilter from './ListingsFilter.jsx';
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'Watchlist',
|
||||
width: 110,
|
||||
dataIndex: 'isWatched',
|
||||
sorter: true,
|
||||
render: (id, row) => {
|
||||
return (
|
||||
<div>
|
||||
<Popover
|
||||
style={{
|
||||
padding: '.4rem',
|
||||
color: 'var(--semi-color-white)',
|
||||
}}
|
||||
content={row.isWatched === 1 ? 'Unwatch Listing' : 'Watch Listing'}
|
||||
>
|
||||
<Button
|
||||
icon={
|
||||
row.isWatched === 1 ? (
|
||||
<IconStar style={{ color: 'rgba(var(--semi-green-5), 1)' }} />
|
||||
) : (
|
||||
<IconStarStroked />
|
||||
)
|
||||
}
|
||||
theme="borderless"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await xhrPost('/api/listings/watch', { listingId: row.id });
|
||||
Toast.success(row.isWatched === 1 ? 'Listing removed from Watchlist' : 'Listing added to Watchlist');
|
||||
row.reloadTable();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
Toast.error('Failed to operate Watchlist');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
<Divider layout="vertical" margin="4px" />
|
||||
<Popover
|
||||
style={{
|
||||
padding: '.4rem',
|
||||
color: 'var(--semi-color-white)',
|
||||
}}
|
||||
content="Delete Listing"
|
||||
>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
theme="borderless"
|
||||
size="small"
|
||||
type="danger"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await xhrDelete('/api/listings/', { ids: [row.id] });
|
||||
Toast.success('Listing(s) successfully removed');
|
||||
row.reloadTable();
|
||||
} catch (error) {
|
||||
Toast.error(error);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'State',
|
||||
dataIndex: 'is_active',
|
||||
width: 84,
|
||||
sorter: true,
|
||||
render: (value) => {
|
||||
return value ? (
|
||||
<div style={{ color: 'rgba(var(--semi-green-6), 1)' }}>
|
||||
<Popover
|
||||
style={{
|
||||
padding: '.4rem',
|
||||
color: 'var(--semi-color-white)',
|
||||
}}
|
||||
content="Listing is still active"
|
||||
>
|
||||
<IconTick />
|
||||
</Popover>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: 'rgba(var(--semi-red-5), 1)' }}>
|
||||
<Popover
|
||||
style={{
|
||||
padding: '.4rem',
|
||||
color: 'var(--semi-color-white)',
|
||||
}}
|
||||
content="Listing is inactive"
|
||||
>
|
||||
<IconClose />
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Job-Name',
|
||||
sorter: true,
|
||||
ellipsis: true,
|
||||
dataIndex: 'job_name',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: 'Listing date',
|
||||
width: 130,
|
||||
dataIndex: 'created_at',
|
||||
sorter: true,
|
||||
render: (text) => timeService.format(text, false),
|
||||
},
|
||||
{
|
||||
title: 'Provider',
|
||||
width: 130,
|
||||
dataIndex: 'provider',
|
||||
sorter: true,
|
||||
render: (text) => text.charAt(0).toUpperCase() + text.slice(1),
|
||||
},
|
||||
{
|
||||
title: 'Price',
|
||||
width: 110,
|
||||
dataIndex: 'price',
|
||||
sorter: true,
|
||||
render: (text) => text + ' €',
|
||||
},
|
||||
{
|
||||
title: 'Address',
|
||||
width: 150,
|
||||
dataIndex: 'address',
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: 'Title',
|
||||
dataIndex: 'title',
|
||||
sorter: true,
|
||||
ellipsis: true,
|
||||
render: (text, row) => {
|
||||
return (
|
||||
<a href={row.url} target="_blank" rel="noopener noreferrer">
|
||||
{text}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const empty = (
|
||||
<Empty
|
||||
image={<IllustrationNoResult />}
|
||||
darkModeImage={<IllustrationNoResultDark />}
|
||||
description="No listings available."
|
||||
/>
|
||||
);
|
||||
|
||||
export default function ListingsTable() {
|
||||
const tableData = useSelector((state) => state.listingsTable);
|
||||
const actions = useActions();
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 10;
|
||||
const [sortData, setSortData] = useState({});
|
||||
const [freeTextFilter, setFreeTextFilter] = useState(null);
|
||||
const [watchListFilter, setWatchListFilter] = useState(null);
|
||||
const [jobNameFilter, setJobNameFilter] = useState(null);
|
||||
const [activityFilter, setActivityFilter] = useState(null);
|
||||
const [providerFilter, setProviderFilter] = useState(null);
|
||||
|
||||
const [imageWidth, setImageWidth] = useState('100%');
|
||||
const handlePageChange = (_page) => {
|
||||
setPage(_page);
|
||||
};
|
||||
|
||||
const loadTable = () => {
|
||||
let sortfield = null;
|
||||
let sortdir = null;
|
||||
|
||||
if (sortData != null && Object.keys(sortData).length > 0) {
|
||||
sortfield = sortData.field;
|
||||
sortdir = sortData.direction;
|
||||
}
|
||||
actions.listingsTable.getListingsTable({
|
||||
page,
|
||||
pageSize,
|
||||
sortfield,
|
||||
sortdir,
|
||||
freeTextFilter,
|
||||
filter: { watchListFilter, jobNameFilter, activityFilter, providerFilter },
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTable();
|
||||
}, [page, sortData, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]);
|
||||
|
||||
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) => {
|
||||
return (
|
||||
<div className="listingsTable__expanded">
|
||||
<div>
|
||||
{record.image_url == null ? (
|
||||
<Image height={200} width={180} src={no_image} />
|
||||
) : (
|
||||
<Image
|
||||
height={200}
|
||||
width={imageWidth}
|
||||
src={record.image_url}
|
||||
onError={() => {
|
||||
setImageWidth('180px');
|
||||
}}
|
||||
fallback={<Image height={200} src={no_image} />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Descriptions align="justify">
|
||||
<Descriptions.Item itemKey="Listing still online">
|
||||
<Tag size="small" shape="circle" color={record.is_active ? 'green' : 'red'}>
|
||||
{record.is_active ? 'Yes' : 'No'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item itemKey="Link">
|
||||
<a href={record.link} target="_blank" rel="noopener noreferrer">
|
||||
Link to Listing
|
||||
</a>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item itemKey="Listing date">{format(record.created_at)}</Descriptions.Item>
|
||||
<Descriptions.Item itemKey="Price">{record.price} €</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<b>{record.title}</b>
|
||||
<p>{record.description == null ? 'No description available' : record.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ListingsFilter
|
||||
onActivityFilter={setActivityFilter}
|
||||
onWatchListFilter={setWatchListFilter}
|
||||
onJobNameFilter={setJobNameFilter}
|
||||
onProviderFilter={setProviderFilter}
|
||||
/>
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
showClear
|
||||
className="listingsTable__search"
|
||||
placeholder="Search"
|
||||
onChange={handleFilterChange}
|
||||
/>
|
||||
<Table
|
||||
rowKey="id"
|
||||
empty={empty}
|
||||
hideExpandedColumn={false}
|
||||
sticky={{ top: 5 }}
|
||||
columns={columns}
|
||||
expandedRowRender={expandRowRender}
|
||||
dataSource={(tableData?.result || []).map((row) => {
|
||||
return {
|
||||
...row,
|
||||
reloadTable: loadTable,
|
||||
};
|
||||
})}
|
||||
onChange={(changeSet) => {
|
||||
if (changeSet?.extra?.changeType === 'sorter') {
|
||||
setSortData({
|
||||
field: changeSet.sorter.dataIndex,
|
||||
direction: changeSet.sorter.sortOrder === 'ascend' ? 'asc' : 'desc',
|
||||
});
|
||||
}
|
||||
}}
|
||||
pagination={{
|
||||
currentPage: page,
|
||||
//for now fixed
|
||||
pageSize,
|
||||
total: tableData?.totalNumber || 0,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,4 +7,8 @@
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
&__toolbar {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,14 @@ export const useFredyState = create(
|
||||
console.error(`Error while trying to get resource for api/jobs. Error:`, Exception);
|
||||
}
|
||||
},
|
||||
async getSharableUserList() {
|
||||
try {
|
||||
const response = await xhrGet('/api/jobs/shareableUserList');
|
||||
set((state) => ({ jobs: { ...state.jobs, shareableUserList: Object.freeze(response.json) } }));
|
||||
} catch (Exception) {
|
||||
console.error(`Error while trying to get resource for api/jobs. Error:`, Exception);
|
||||
}
|
||||
},
|
||||
async getProcessingTimes() {
|
||||
try {
|
||||
const response = await xhrGet('/api/jobs/processingTimes');
|
||||
@@ -132,14 +140,22 @@ export const useFredyState = create(
|
||||
},
|
||||
},
|
||||
listingsTable: {
|
||||
async getListingsTable({ page = 1, pageSize = 20, filter = null, sortfield = null, sortdir = 'asc' }) {
|
||||
async getListingsTable({
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
freeTextFilter = null,
|
||||
sortfield = null,
|
||||
sortdir = 'asc',
|
||||
filter,
|
||||
}) {
|
||||
try {
|
||||
const qryString = queryString.stringify({
|
||||
page,
|
||||
pageSize,
|
||||
filter,
|
||||
freeTextFilter,
|
||||
sortfield,
|
||||
sortdir,
|
||||
...filter,
|
||||
});
|
||||
const response = await xhrGet(`/api/listings/table?${qryString}`);
|
||||
set((state) => ({
|
||||
@@ -164,7 +180,7 @@ export const useFredyState = create(
|
||||
demoMode: { demoMode: false },
|
||||
versionUpdate: {},
|
||||
provider: [],
|
||||
jobs: { jobs: [], insights: {}, processingTimes: {} },
|
||||
jobs: { jobs: [], insights: {}, processingTimes: {}, shareableUserList: [] },
|
||||
user: { users: [], currentUser: null },
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
export function format(ts) {
|
||||
export function format(ts, showSeconds = true) {
|
||||
return new Intl.DateTimeFormat('default', {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric',
|
||||
...(showSeconds ? { second: 'numeric' } : {}),
|
||||
}).format(ts);
|
||||
}
|
||||
|
||||
export const roundToHour = (ts) => Math.ceil(ts / (1000 * 60 * 60)) * (1000 * 60 * 60);
|
||||
|
||||
@@ -16,7 +16,17 @@ export default function Jobs() {
|
||||
const onJobRemoval = async (jobId) => {
|
||||
try {
|
||||
await xhrDelete('/api/jobs', { jobId });
|
||||
Toast.success('Job successfully remove');
|
||||
Toast.success('Job successfully removed');
|
||||
await actions.jobs.getJobs();
|
||||
} catch (error) {
|
||||
Toast.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const onListingRemoval = async (jobId) => {
|
||||
try {
|
||||
await xhrDelete('/api/listings/job', { jobId });
|
||||
Toast.success('Listings successfully removed');
|
||||
await actions.jobs.getJobs();
|
||||
} catch (error) {
|
||||
Toast.error(error);
|
||||
@@ -49,6 +59,7 @@ export default function Jobs() {
|
||||
<JobTable
|
||||
jobs={jobs || []}
|
||||
onJobRemoval={onJobRemoval}
|
||||
onListingRemoval={onListingRemoval}
|
||||
onJobStatusChanged={onJobStatusChanged}
|
||||
onJobInsight={(jobId) => navigate(`/jobs/insights/${jobId}`)}
|
||||
onJobEdit={(jobId) => navigate(`/jobs/edit/${jobId}`)}
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
import React from 'react';
|
||||
import { format } from '../../services/time/timeService';
|
||||
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 './ProsessingTimes.less';
|
||||
import { useScreenWidth } from '../../hooks/screenWidth.js';
|
||||
|
||||
function InfoCard({ title, value }) {
|
||||
function InfoCard({ title, value, icon }) {
|
||||
const { Meta } = Card;
|
||||
return (
|
||||
<Card style={{ maxWidth: '13rem', margin: '1rem', background: 'rgb(53, 54, 60)' }} title={title}>
|
||||
{value}
|
||||
</Card>
|
||||
<div
|
||||
style={{
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
const width = useScreenWidth();
|
||||
const invisible = width <= 1180;
|
||||
|
||||
if (invisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<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>
|
||||
{processingTimes.lastRun && (
|
||||
<>
|
||||
<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 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 span={6}>
|
||||
<InfoCard
|
||||
title="Find Listings Now"
|
||||
title="Search Now"
|
||||
icon={<IconSearch style={{ color: 'rgba(var(--semi-grey-4), 1)' }} />}
|
||||
value={
|
||||
<Button
|
||||
size="small"
|
||||
style={{ marginTop: '.2rem' }}
|
||||
icon={<IconPlayCircle />}
|
||||
aria-label="Start now"
|
||||
onClick={async () => {
|
||||
await xhrPost('/api/jobs/startAll', null);
|
||||
Toast.success('Successfully triggered Fredy search.');
|
||||
try {
|
||||
await xhrPost('/api/jobs/startAll', null);
|
||||
Toast.success('Successfully triggered Fredy search.');
|
||||
} catch {
|
||||
Toast.error('Failed to trigger search');
|
||||
}
|
||||
}}
|
||||
>
|
||||
Search now
|
||||
|
||||
@@ -8,13 +8,14 @@ import Headline from '../../../components/headline/Headline';
|
||||
import { useActions, useSelector } from '../../../services/state/store';
|
||||
import { xhrPost } from '../../../services/xhr';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Divider, Input, Switch, Button, TagInput, Toast } from '@douyinfe/semi-ui';
|
||||
import { Divider, Input, Switch, Button, TagInput, Toast, Select } from '@douyinfe/semi-ui';
|
||||
import './JobMutation.less';
|
||||
import { SegmentPart } from '../../../components/segment/SegmentPart';
|
||||
import { IconPlusCircle } from '@douyinfe/semi-icons';
|
||||
import { IconBell, IconBriefcase, IconPaperclip, IconPlayCircle, IconPlusCircle, IconUser } from '@douyinfe/semi-icons';
|
||||
|
||||
export default function JobMutator() {
|
||||
const jobs = useSelector((state) => state.jobs.jobs);
|
||||
const shareableUserList = useSelector((state) => state.jobs.shareableUserList);
|
||||
const params = useParams();
|
||||
|
||||
const jobToBeEdit = params.jobId == null ? null : jobs.find((job) => job.id === params.jobId);
|
||||
@@ -32,6 +33,7 @@ export default function JobMutator() {
|
||||
const [name, setName] = useState(defaultName);
|
||||
const [blacklist, setBlacklist] = useState(defaultBlacklist);
|
||||
const [notificationAdapterData, setNotificationAdapterData] = useState(defaultNotificationAdapter);
|
||||
const [shareWithUsers, setShareWithUsers] = useState(jobToBeEdit?.shared_with_user ?? []);
|
||||
const [enabled, setEnabled] = useState(defaultEnabled);
|
||||
const navigate = useNavigate();
|
||||
const actions = useActions();
|
||||
@@ -45,6 +47,7 @@ export default function JobMutator() {
|
||||
await xhrPost('/api/jobs', {
|
||||
provider: providerData,
|
||||
notificationAdapter: notificationAdapterData,
|
||||
shareWithUsers,
|
||||
name,
|
||||
blacklist,
|
||||
enabled,
|
||||
@@ -91,7 +94,7 @@ export default function JobMutator() {
|
||||
|
||||
<Headline text={jobToBeEdit ? 'Edit Job' : 'Create new Job'} />
|
||||
<form>
|
||||
<SegmentPart name="Name">
|
||||
<SegmentPart name="Name" Icon={IconPaperclip}>
|
||||
<Input
|
||||
autoFocus
|
||||
type="text"
|
||||
@@ -105,7 +108,7 @@ export default function JobMutator() {
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart
|
||||
name="Providers"
|
||||
icon="briefcase"
|
||||
Icon={IconBriefcase}
|
||||
helpText={`
|
||||
A provider is essentially the service (e.g. ImmoScout24, Kleinanzeigen) that Fredy searches for new listings.
|
||||
Fredy will open a new tab pointing to the website of this provider. You have to adjust your search parameter
|
||||
@@ -130,7 +133,7 @@ export default function JobMutator() {
|
||||
</SegmentPart>
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart
|
||||
icon="bell"
|
||||
Icon={IconBell}
|
||||
name="Notification Adapters"
|
||||
helpText="Fredy supports multiple ways to notify you about new findings. These are called notification adapter. You can chose between email, Telegram etc."
|
||||
>
|
||||
@@ -157,7 +160,7 @@ export default function JobMutator() {
|
||||
</SegmentPart>
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart
|
||||
icon="bell"
|
||||
Icon={IconBell}
|
||||
name="Blacklist"
|
||||
helpText="If a listing contains one of these words, it will be filtered out. Type in a word, then hit enter."
|
||||
>
|
||||
@@ -169,7 +172,32 @@ export default function JobMutator() {
|
||||
</SegmentPart>
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart
|
||||
icon="play circle outline"
|
||||
Icon={IconUser}
|
||||
name="Sharing with user"
|
||||
helpText="You can share this job with other users. They will be able to see the listings, but only (as the creator) you can edit the job. Admins are filtered from this list as they have access to everything."
|
||||
>
|
||||
{shareableUserList.length === 0 ? (
|
||||
<div>No users found to share this Job to. Please create additional non-admin user.</div>
|
||||
) : (
|
||||
<Select
|
||||
filter
|
||||
multiple
|
||||
placeholder="Search user"
|
||||
autoClearSearchValue={false}
|
||||
defaultValue={shareWithUsers}
|
||||
onChange={(value) => setShareWithUsers(value)}
|
||||
>
|
||||
{shareableUserList.map((user) => (
|
||||
<Select.Option value={user.id} key={user.id}>
|
||||
{user.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</SegmentPart>
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart
|
||||
Icon={IconPlayCircle}
|
||||
name="Job activation"
|
||||
helpText="Whether or not the job is activated. Inactive jobs will be ignored when Fredy checks for new listings."
|
||||
>
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
import ListingsTable from '../../components/table/ListingsTable.jsx';
|
||||
import ListingsTable from '../../components/table/listings/ListingsTable.jsx';
|
||||
|
||||
export default function Listings() {
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user