Compare commits

..

23 Commits

Author SHA1 Message Date
orangecoding
a460b813c1 adding news info 2026-03-08 10:07:51 +01:00
orangecoding
4596442f64 upgrading dependencies | mark listings as 'manually_removed' when filtered 2026-03-08 09:55:46 +01:00
Stephan
0bcfa1d4ad feat(): map area filter (#273)
* feat(): create map component, add area filtering to the job config

* feat(): filter listings by area filter

* chore(): cleanup

* feat(): solve feedback

* feat(): solve most providers

* feat(): solve maybe other providers
2026-03-08 09:44:18 +01:00
orangecoding
0cad05124a fixing immowelt provider 2026-03-08 09:29:40 +01:00
Noah Elijah Till
eb53b68d45 🕵️ More immoscout details (#258)
* 🕵️ More immoscout details

- Added more details to immoscout api - description is now populated with a lot of data from the expose using app API
- You can ignore certificates, if deploying locally and using the http notification adapter
- More details for the test call/example for easier testing + placeholder image + actual values + address (famous Erika Mustermans address see https://de.wikipedia.org/wiki/Mustermann)
- Grater timeout for geocode since the api is sometimes slow in germany
- uiElement, type boolean, now has a label as well

* 👀 Requested changes + some extra

Req:
- using logger
- using node-fetch

Extra:
- boolean input fields will trigger the validate check, because they are set undefined at first - setting them to false if they are undefined now
- added more data to the description (phone number and name of the agent)

*  Fixed import

* ️ Toggle immoscout detail fetching

* ️ Requested change
2026-03-08 09:08:40 +01:00
Tom Dohrmann
ba0732e1f6 add support for fulltext parameter for immoscout (#274) 2026-02-24 09:53:14 +01:00
orangecoding
aa67647bbb adding resend as net notification adapter 2026-02-20 17:08:38 +01:00
orangecoding
7a9d49899b improve reusing of puppeteer by adding a safeguard for broken chrome 2026-02-18 20:16:55 +01:00
orangecoding
9a87c58d3e next release version 2026-02-18 20:06:40 +01:00
orangecoding
fdd7e835e8 improve default puppeteer timeout 2026-02-18 20:06:22 +01:00
Christian Kellner
00d6a12b30 Puppeteer improvements (#270)
* improve puppeteer handling. Now only 1 puppeteer instance is being used which is WAY more efficient

* removing package-lock

* reduce logging

* removing problematic docker command

* Remove Immonet. They now belong to immowelt
2026-02-18 20:05:02 +01:00
orangecoding
05218800d2 fixing app init 2026-02-17 14:28:08 +01:00
orangecoding
19d4721f9f improve welcome screen 2026-02-17 14:03:15 +01:00
orangecoding
a794645393 fixing login route 2026-02-17 12:50:21 +01:00
orangecoding
fd7e228972 adding welcome screen 2026-02-17 12:35:39 +01:00
orangecoding
b86e351007 fixing lint even harder 2026-02-16 13:50:50 +01:00
orangecoding
19c4860da7 fixing eslint harder 2026-02-16 12:59:34 +01:00
orangecoding
d98e06cfdf fixing eslint 2026-02-16 12:40:41 +01:00
orangecoding
6ae0c9749b update dependencies 2026-02-16 12:30:59 +01:00
orangecoding
10e40e038e adding check if fredy is running in docker 2026-02-16 12:29:02 +01:00
orangecoding
4ba6828939 adding release tool 2026-02-05 12:02:18 +01:00
orangecoding
d09770dae2 fancy, almost impossible to see animation on dashboard 2026-02-05 09:54:42 +01:00
orangecoding
248e4d2562 improve tracking 2026-02-04 14:41:55 +01:00
89 changed files with 2512 additions and 14608 deletions

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ npm-debug.log
.DS_Store
.idea
.vscode
tools/release/config.json

View File

@@ -35,6 +35,7 @@ WORKDIR /fredy
RUN apk add --no-cache chromium curl
ENV NODE_ENV=production \
IS_DOCKER=true \
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser

View File

@@ -8,20 +8,20 @@ import js from '@eslint/js';
import prettier from 'eslint-config-prettier';
import globals from 'globals';
import react from 'eslint-plugin-react';
import babelParser from '@babel/eslint-parser';
export default [
{
files: ['**/*.{js,jsx,ts,tsx}'],
ignores: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/public/**', 'db/**', 'conf/**'],
},
js.configs.recommended,
prettier,
{
files: ['**/*.{js,jsx}'],
languageOptions: {
parser: babelParser,
ecmaVersion: 'latest',
sourceType: 'module',
ecmaVersion: 2021,
parserOptions: {
ecmaFeatures: { jsx: true },
},
globals: {
...globals.browser,
...globals.node,
@@ -32,70 +32,14 @@ export default [
after: 'readonly',
it: 'readonly',
},
parserOptions: { requireConfigFile: false },
},
plugins: { react },
rules: {
eqeqeq: [2, 'allow-null'],
strict: 0,
'no-redeclare': [2, { builtinGlobals: false }],
'class-methods-use-this': 'off',
indent: ['off', 2],
'linebreak-style': ['error', 'unix'],
quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: true }],
semi: ['error', 'always'],
'no-console': ['error', { allow: ['warn', 'error'] }],
'jsx-quotes': ['error', 'prefer-double'],
'react/display-name': 'off',
'react/forbid-prop-types': 'off',
'react/jsx-closing-bracket-location': 'off',
'react/jsx-curly-spacing': 'off',
'react/jsx-handler-names': ['off', { eventHandlerPrefix: 'handle', eventHandlerPropPrefix: 'on' }],
'react/jsx-indent-props': 'off',
'react/jsx-key': 'off',
'react/jsx-max-props-per-line': 'off',
'react/jsx-no-bind': ['error', { ignoreRefs: true, allowArrowFunctions: true, allowBind: false }],
'react/jsx-no-duplicate-props': ['error', { ignoreCase: true }],
'react/jsx-no-literals': 'off',
'react/jsx-no-undef': 'error',
'react/jsx-pascal-case': ['error', { allowAllCaps: true, ignore: [] }],
'react/sort-prop-types': ['off', { ignoreCase: true, callbacksLast: false, requiredFirst: false }],
'react/jsx-sort-prop-types': 'off',
'react/jsx-sort-props': 'off',
'react/jsx-uses-react': 'error',
'react/jsx-uses-vars': 'error',
'react/no-danger': 'warn',
'react/no-deprecated': 'error',
'react/no-did-mount-set-state': 'error',
'react/no-did-update-set-state': 'warn',
'react/no-direct-mutation-state': 'off',
'react/no-is-mounted': 'error',
'react/no-set-state': 'off',
'react/no-string-refs': 'warn',
'react/no-unknown-property': 'error',
'react/prop-types': ['error', { ignore: [], customValidators: [], skipUndeclared: true }],
'react/react-in-jsx-scope': 'error',
'react/require-extension': 'off',
'react/require-render-return': 'error',
'react/self-closing-comp': 'warn',
'react/sort-comp': 'off',
'react/jsx-wrap-multilines': ['warn', { declaration: true, assignment: true, return: true }],
'react/wrap-multilines': 'off',
'react/jsx-first-prop-new-line': 'off',
'react/jsx-equals-spacing': ['warn', 'never'],
'react/jsx-no-target-blank': 'error',
'react/jsx-filename-extension': ['error', { extensions: ['.jsx'] }],
'react/jsx-no-comment-textnodes': 'error',
'react/no-comment-textnodes': 'off',
'react/no-render-return-value': 'error',
'react/require-optimization': ['off', { allowDecorators: [] }],
'react/no-find-dom-node': 'warn',
'react/forbid-component-props': ['off', { forbid: [] }],
'react/no-danger-with-children': 'error',
'react/no-unused-prop-types': ['warn', { customValidators: [], skipShapeProps: true }],
'react/style-prop-object': 'error',
'react/no-children-prop': 'warn',
},
settings: { react: { version: 'detect' } },
rules: {
...js.configs.recommended.rules,
'no-console': ['error', { allow: ['warn', 'error'] }],
},
},
prettier,
];

View File

@@ -4,7 +4,11 @@
*/
import { NoNewListingsWarning } from './errors.js';
import { storeListings, getKnownListingHashesForJobAndProvider } from './services/storage/listingsStorage.js';
import {
storeListings,
getKnownListingHashesForJobAndProvider,
deleteListingsById,
} from './services/storage/listingsStorage.js';
import { getJob } from './services/storage/jobStorage.js';
import * as notify from './notification/notify.js';
import Extractor from './services/extractor/extractor.js';
@@ -14,6 +18,7 @@ import { geocodeAddress } from './services/geocoding/geoCodingService.js';
import { distanceMeters } from './services/listings/distanceCalculator.js';
import { getUserSettings } from './services/storage/settingsStorage.js';
import { updateListingDistance } from './services/storage/listingsStorage.js';
import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
/**
* @typedef {Object} Listing
@@ -58,18 +63,21 @@ class FredyPipelineExecutioner {
* @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 {Object} spatialFilter Optional spatial filter configuration.
* @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.
* @param browser
*/
constructor(providerConfig, notificationConfig, providerId, jobKey, similarityCache) {
constructor(providerConfig, notificationConfig, spatialFilter, providerId, jobKey, similarityCache, browser) {
this._providerConfig = providerConfig;
this._notificationConfig = notificationConfig;
this._spatialFilter = spatialFilter;
this._providerId = providerId;
this._jobKey = jobKey;
this._similarityCache = similarityCache;
this._browser = browser;
}
/**
@@ -88,6 +96,7 @@ class FredyPipelineExecutioner {
.then(this._save.bind(this))
.then(this._calculateDistance.bind(this))
.then(this._filterBySimilarListings.bind(this))
.then(this._filterByArea.bind(this))
.then(this._notify.bind(this))
.catch(this._handleError.bind(this));
}
@@ -111,6 +120,47 @@ class FredyPipelineExecutioner {
return newListings;
}
/**
* Filter listings by area using the provider's area filter if available.
* Only filters if areaFilter is set on the provider AND the listing has coordinates.
*
* @param {Listing[]} newListings New listings to filter by area.
* @returns {Promise<Listing[]>} Resolves with listings that are within the area (or not filtered if no area is set).
*/
_filterByArea(newListings) {
const polygonFeatures = this._spatialFilter?.features?.filter((f) => f.geometry?.type === 'Polygon');
// If no area filter is set, return all listings
if (!polygonFeatures?.length) {
return newListings;
}
const filteredIds = [];
// Filter listings by area - keep only those within the polygon
const keptListings = newListings.filter((listing) => {
// If listing doesn't have coordinates, keep it (don't filter out)
if (listing.latitude == null || listing.longitude == null) {
return true;
}
// Check if the point is inside the polygons
const point = [listing.longitude, listing.latitude]; // GeoJSON format: [lon, lat]
const isInPolygon = polygonFeatures.some((feature) => booleanPointInPolygon(point, feature));
if (!isInPolygon) {
filteredIds.push(listing.id);
}
return isInPolygon;
});
if (filteredIds.length > 0) {
deleteListingsById(filteredIds);
}
return keptListings;
}
/**
* Fetch listings from the provider, using the default Extractor flow unless
* a provider-specific getListings override is supplied.
@@ -119,7 +169,7 @@ class FredyPipelineExecutioner {
* @returns {Promise<Listing[]>} Resolves with an array of listings (empty when none found).
*/
_getListings(url) {
const extractor = new Extractor();
const extractor = new Extractor({ ...this._providerConfig.puppeteerOptions, browser: this._browser });
return new Promise((resolve, reject) => {
extractor
.execute(url, this._providerConfig.waitForSelector)
@@ -250,7 +300,8 @@ class FredyPipelineExecutioner {
* @returns {Listing[]} Listings considered unique enough to keep.
*/
_filterBySimilarListings(listings) {
return listings.filter((listing) => {
const filteredIds = [];
const keptListings = listings.filter((listing) => {
const similar = this._similarityCache.checkAndAddEntry({
title: listing.title,
address: listing.address,
@@ -260,9 +311,16 @@ class FredyPipelineExecutioner {
logger.debug(
`Filtering similar entry for title '${listing.title}' and address '${listing.address}' (Provider: '${this._providerId}')`,
);
filteredIds.push(listing.id);
}
return !similar;
});
if (filteredIds.length > 0) {
deleteListingsById(filteredIds);
}
return keptListings;
}
/**

View File

@@ -3,6 +3,8 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
export const FEATURES = {
export const TRACKING_POIS = {
DISTANCE_ADDRESS_ENTERED: 'DISTANCE_ADDRESS_ENTERED',
WELCOME_FINISHED: 'WELCOME_FINISHED',
WELCOME_SKIPPED: 'WELCOME_SKIPPED',
};

View File

@@ -23,6 +23,7 @@ import { listingsRouter } from './routes/listingsRouter.js';
import { getSettings } from '../services/storage/settingsStorage.js';
import { dashboardRouter } from './routes/dashboardRouter.js';
import { backupRouter } from './routes/backupRouter.js';
import { trackingRouter } from './routes/trackingRoute.js';
const service = restana();
const staticService = files(path.join(getDirName(), '../ui/public'));
const PORT = (await getSettings()).port || 9998;
@@ -36,6 +37,7 @@ service.use('/api/version', authInterceptor());
service.use('/api/listings', authInterceptor());
service.use('/api/dashboard', authInterceptor());
service.use('/api/user/settings', authInterceptor());
service.use('/api/tracking', authInterceptor());
// /admin can only be accessed when user is having admin permissions
service.use('/api/admin', adminInterceptor());
@@ -50,6 +52,7 @@ service.use('/api/jobs', jobRouter);
service.use('/api/login', loginRouter);
service.use('/api/listings', listingsRouter);
service.use('/api/dashboard', dashboardRouter);
service.use('/api/tracking', trackingRouter);
//this route is unsecured intentionally as it is being queried from the login page
service.use('/api/demo', demoRouter);

View File

@@ -163,7 +163,16 @@ jobRouter.post('/:jobId/run', async (req, res) => {
});
jobRouter.post('/', async (req, res) => {
const { provider, notificationAdapter, name, blacklist = [], jobId, enabled, shareWithUsers = [] } = req.body;
const {
provider,
notificationAdapter,
name,
blacklist = [],
jobId,
enabled,
shareWithUsers = [],
spatialFilter = null,
} = req.body;
const settings = await getSettings();
try {
let jobFromDb = jobStorage.getJob(jobId);
@@ -187,6 +196,7 @@ jobRouter.post('/', async (req, res) => {
provider,
notificationAdapter,
shareWithUsers,
spatialFilter,
});
} catch (error) {
res.send(new Error(error));

View File

@@ -5,6 +5,8 @@
import fs from 'fs';
import restana from 'restana';
import logger from '../../services/logger.js';
const service = restana();
const notificationAdapterRouter = service.newRouter();
const notificationAdapterList = fs.readdirSync('./lib//notification/adapter').filter((file) => file.endsWith('.js'));
@@ -34,11 +36,14 @@ notificationAdapterRouter.post('/try', async (req, res) => {
serviceName: 'TestCall',
newListings: [
{
price: '42 €',
title: 'This is a test listing',
address: 'some address',
size: '666 2m',
link: 'https://www.orange-coding.net',
address: 'Heidestrasse 17, 51147 Köln',
description: exampleDescription,
id: '1',
imageUrl: 'https://placehold.co/600x400/png',
price: '1.000 €',
size: '76 m²',
title: 'Stilvolle gepflegte 3-Raum-Wohnung mit gehobener Innenausstattung',
url: 'https://www.orange-coding.net',
},
],
notificationConfig,
@@ -46,6 +51,7 @@ notificationAdapterRouter.post('/try', async (req, res) => {
});
res.send();
} catch (Exception) {
logger.error('Error during notification adapter test:', Exception);
res.send(new Error(Exception));
}
});
@@ -54,3 +60,51 @@ notificationAdapterRouter.get('/', async (req, res) => {
res.send();
});
export { notificationAdapterRouter };
const exampleDescription = `
Wohnungstyp: Etagenwohnung
Nutzfläche: 76 m²
Etage: 2 von 3
Schlafzimmer: 1
Badezimmer: 1
Bezugsfrei ab: 1.4.2026
Haustiere: Nein
Garage/Stellplatz: Tiefgarage
Anzahl Garage/Stellplatz: 1
Kaltmiete (zzgl. Nebenkosten): 1.000 €
Preis/m²: 13,16 €/m²
Nebenkosten: 230 €
Heizkosten in Nebenkosten enthalten: Ja
Gesamtmiete: 1.230 €
Kaution: 3.000,00
Preis pro Parkfläche: 60 €
Baujahr: 2000
Objektzustand: Modernisiert
Qualität der Ausstattung: Gehoben
Heizungsart: Fernwärme
Energieausweistyp: Verbrauchsausweis
Energieausweis: liegt vor
Endenergieverbrauch: 72 kWh/(m²∙a)
Baujahr laut Energieausweis: 2000
Diese moderne 3-Zimmer-Wohnung liegt direkt neben einem Park und nur wenige Minuten von der S-Bahn-Haltestelle entfernt. Das Stadtzentrum sowie Freizeiteinrichtungen sind 1,5 km entfernt.
Die Wohnung ist ideal für Paare oder kleine Familien geeignet.
Ausstattung:
- neuer hochwertiger Bodenbelag (Holzoptik) in allen Räumen außer Bad/Küche
- sonniger Balkon (Süd)
- Tiefgaragenstellplatz
- Kellerabteil
- gepflegtes Mehrfamilienhaus
Die Küche ist vom Mieter nach eigenen Wünschen einzurichten.
Vermietung direkt vom Eigentümer - provisionsfrei!
Lage:
• Park: 1 Minute zu Fuß
• S-Bahn Station: 2 Minuten zu Fuß
• Supermärkte, Restaurants, täglicher Bedarf in der Nähe
• Gute Anbindung Richtung Großstadt und Flughafen
`;

View File

@@ -0,0 +1,37 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import restana from 'restana';
import { trackPoi } from '../../services/tracking/Tracker.js';
import { TRACKING_POIS } from '../../TRACKING_POIS.js';
import logger from '../../services/logger.js';
const service = restana();
const trackingRouter = service.newRouter();
trackingRouter.get('/trackingPois', async (req, res) => {
res.body = TRACKING_POIS;
res.send();
});
trackingRouter.post('/poi', async (req, res) => {
const { poi } = req.body;
if (!poi) {
res.statusCode = 400;
res.send({ error: 'Feature name is required' });
return;
}
try {
await trackPoi(poi);
res.send({ success: true });
} catch (error) {
logger.error('Error tracking feature', error);
res.statusCode = 500;
res.send({ error: error.message });
}
});
export { trackingRouter };

View File

@@ -10,8 +10,8 @@ import { resetGeocoordinatesAndDistanceForUser } from '../../services/storage/li
import { geocodeAddress } from '../../services/geocoding/geoCodingService.js';
import { autocompleteAddress } from '../../services/geocoding/autocompleteService.js';
import { fromJson } from '../../utils.js';
import { trackFeature } from '../../services/tracking/Tracker.js';
import { FEATURES } from '../../features.js';
import { trackPoi } from '../../services/tracking/Tracker.js';
import { TRACKING_POIS } from '../../TRACKING_POIS.js';
import logger from '../../services/logger.js';
import { runGeoCordTask } from '../../services/crons/geocoding-cron.js';
@@ -53,7 +53,7 @@ userSettingsRouter.post('/home-address', async (req, res) => {
try {
if (home_address) {
await trackFeature(FEATURES.DISTANCE_ADDRESS_ENTERED);
await trackPoi(TRACKING_POIS.DISTANCE_ADDRESS_ENTERED);
const coords = await geocodeAddress(home_address);
if (coords && coords.lat !== -1) {
upsertSettings({ home_address: { address: home_address, coords } }, userId);
@@ -76,4 +76,46 @@ userSettingsRouter.post('/home-address', async (req, res) => {
}
});
userSettingsRouter.post('/news-hash', async (req, res) => {
const userId = req.session.currentUser;
const { news_hash } = req.body;
const globalSettings = await getSettings();
if (globalSettings.demoMode) {
res.statusCode = 403;
res.send({ error: 'In demo mode, it is not allowed to change settings.' });
return;
}
try {
upsertSettings({ news_hash }, userId);
res.send({ success: true });
} catch (error) {
logger.error('Error updating news hash', error);
res.statusCode = 500;
res.send({ error: error.message });
}
});
userSettingsRouter.post('/immoscout-details', async (req, res) => {
const userId = req.session.currentUser;
const { immoscout_details } = req.body;
const globalSettings = await getSettings();
if (globalSettings.demoMode) {
res.statusCode = 403;
res.send({ error: 'In demo mode, it is not allowed to change settings.' });
return;
}
try {
upsertSettings({ immoscout_details: !!immoscout_details }, userId);
res.send({ success: true });
} catch (error) {
logger.error('Error updating immoscout details setting', error);
res.statusCode = 500;
res.send({ error: error.message });
}
});
export { userSettingsRouter };

View File

@@ -16,8 +16,8 @@ const mapListing = (listing) => ({
url: listing.link,
});
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
const { authToken, endpointUrl } = notificationConfig.find((a) => a.id === config.id).fields;
export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => {
const { authToken, endpointUrl, selfSignedCerts } = notificationConfig.find((a) => a.id === config.id).fields;
const listings = newListings.map(mapListing);
const body = {
@@ -34,11 +34,20 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
headers['Authorization'] = `Bearer ${authToken}`;
}
return fetch(endpointUrl, {
let fetchOptions = {
method: 'POST',
headers: headers,
headers,
timeout: 10000,
body: JSON.stringify(body),
});
};
if (selfSignedCerts === true) {
fetchOptions.dispatcher = new (await import('undici')).Agent({
connect: { rejectUnauthorized: false },
});
}
return fetch(endpointUrl, fetchOptions);
};
export const config = {
@@ -52,6 +61,10 @@ export const config = {
label: 'Endpoint URL',
type: 'text',
},
selfSignedCerts: {
label: 'Self-signed certificates',
type: 'boolean',
},
authToken: {
description: "Your application's auth token, if required by your endpoint.",
label: 'Auth token (optional)',

View File

@@ -0,0 +1,88 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { Resend } from 'resend';
import path from 'path';
import fs from 'fs';
import Handlebars from 'handlebars';
import { markdown2Html } from '../../services/markdown.js';
import { getDirName, normalizeImageUrl } from '../../utils.js';
const __dirname = getDirName();
const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTemplate/template.hbs'), 'utf8');
const emailTemplate = Handlebars.compile(template);
const mapListings = (serviceName, jobKey, listings) =>
listings.map((l) => {
const image = normalizeImageUrl(l.image);
return {
title: l.title || '',
link: l.link || '',
address: l.address || '',
size: l.size || '',
price: l.price || '',
image,
hasImage: Boolean(image),
serviceName,
jobKey,
};
});
export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => {
const { apiKey, receiver, from } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
const to = receiver
.trim()
.split(',')
.map((r) => r.trim())
.filter(Boolean);
const resend = new Resend(apiKey);
const listings = mapListings(serviceName, jobKey, newListings);
const html = emailTemplate({
serviceName: `Job: (${jobKey}) | Service: ${serviceName}`,
numberOfListings: listings.length,
listings,
});
const { error } = await resend.emails.send({
from,
to,
subject: `Fredy found ${listings.length} new listing(s) for ${serviceName}`,
html,
});
if (!error) {
return Promise.resolve();
} else {
return Promise.reject(error.message);
}
};
export const config = {
id: 'resend',
name: 'Resend',
description: 'Resend is being used to send new listings via mail.',
readme: markdown2Html('lib/notification/adapter/resend.md'),
fields: {
apiKey: {
type: 'text',
label: 'Api Key',
description: 'The Resend API key used to send emails.',
},
receiver: {
type: 'email',
label: 'Receiver Email',
description: 'Comma-separated email addresses Fredy will send notifications to.',
},
from: {
type: 'email',
label: 'Sender Email',
description: 'The verified email address or domain you send from in Resend.',
},
},
};

View File

@@ -0,0 +1,17 @@
### Resend Adapter
Resend is a modern email delivery service that Fredy can use to send notifications.
Setup:
- Create a Resend account: https://resend.com/
- Create an API key and add it to Fredy's configuration.
- Choose the sender address (e.g., you@yourdomain.com). Verify the domain (https://resend.com/domains/) in Resend before using it.
- Optional for local testing: you can use `onboarding@resend.dev`, but Resend may restrict who you can send to when using test domains.
Multiple recipients:
- Separate email addresses with commas (e.g., some@email.com, someOther@email.com).
Notes & Troubleshooting:
- Ensure the `from` address is verified or belongs to a verified domain in Resend.
- If emails don't arrive, check your spam folder and Resend dashboard logs.
- The template displays listing images via their public URLs; make sure images are reachable.

View File

@@ -9,7 +9,9 @@ import checkIfListingIsActive from '../services/listings/listingActiveTester.js'
let appliedBlackList = [];
function shortenLink(link) {
return link.substring(0, link.indexOf('?'));
if (!link) return '';
const index = link.indexOf('?');
return index === -1 ? link : link.substring(0, index);
}
function parseId(shortenedLink) {
@@ -23,7 +25,7 @@ function normalize(o) {
const title = o.title || 'No title available';
const address = o.address || null;
const shortLink = shortenLink(o.link);
const link = `${baseUrl}/${shortLink}`;
const link = baseUrl + shortLink;
const image = baseUrl + o.image;
const id = buildHash(parseId(shortLink), o.price);
return Object.assign(o, { id, price, size, title, address, link, image });
@@ -37,18 +39,18 @@ function applyBlacklist(o) {
const config = {
url: null,
crawlContainer: '._ref',
crawlContainer: 'a:has(div.list_entry)',
sortByDateParam: 'sort_col=*created_ts&sort_dir=desc',
waitForSelector: 'body',
crawlFields: {
id: '@href', //will be transformed later
price: '.list_entry .immo_preis .label_info',
size: '.list_entry .flaeche .label_info | removeNewline | trim',
title: '.list_entry .part_text h3 span',
description: '.list_entry .description | trim',
price: '.immo_preis .label_info',
size: '.flaeche .label_info | removeNewline | trim',
title: 'h3 span',
description: '.description | trim',
link: '@href',
address: '.list_entry .place',
image: '.list_entry img@src',
address: '.place',
image: 'img@src',
},
normalize: normalize,
filter: applyBlacklist,

View File

@@ -1,53 +0,0 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { isOneOf, buildHash } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
let appliedBlackList = [];
function normalize(o) {
const size = o.size != null ? o.size.replace('Wohnfläche ', '') : 'N/A m²';
const price = o.price.replace('Kaufpreis ', '');
const address = o.address?.split(' • ')?.pop() ?? null;
const title = o.title || 'No title available';
const link = o.link != null ? decodeURIComponent(o.link) : config.url;
const id = buildHash(title, price);
return Object.assign(o, { id, address, price, size, title, link });
}
function applyBlacklist(o) {
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
return titleNotBlacklisted && descNotBlacklisted;
}
const config = {
url: null,
crawlContainer: 'div[data-testid="serp-core-classified-card-testid"]',
sortByDateParam: 'sortby=19',
waitForSelector: 'div[data-testid="serp-gridcontainer-testid"]',
crawlFields: {
id: 'button@title |trim',
title: 'button@title |trim',
price: 'div[data-testid="cardmfe-price-testid"] | trim',
size: 'div[data-testid="cardmfe-keyfacts-testid"] | trim',
address: 'div[data-testid="cardmfe-description-box-address"] | trim',
image: 'div[data-testid="cardmfe-picture-box-test-id"] img@src',
link: 'button@data-base',
description: 'div[data-testid="cardmfe-description-text-test-id"] | trim',
},
normalize: normalize,
filter: applyBlacklist,
activeTester: checkIfListingIsActive,
};
export const init = (sourceConfig, blacklist) => {
config.enabled = sourceConfig.enabled;
config.url = sourceConfig.url;
appliedBlackList = blacklist || [];
};
export const metaInformation = {
name: 'Immonet',
baseUrl: 'https://www.immonet.de/',
id: 'immonet',
};
export { config };

View File

@@ -46,7 +46,9 @@ import {
convertWebToMobile,
} from '../services/immoscout/immoscout-web-translator.js';
import logger from '../services/logger.js';
import { getUserSettings } from '../services/storage/settingsStorage.js';
let appliedBlackList = [];
let currentUserId = null;
async function getListings(url) {
const response = await fetch(url, {
@@ -66,23 +68,86 @@ async function getListings(url) {
}
const responseBody = await response.json();
return responseBody.resultListItems
.filter((item) => item.type === 'EXPOSE_RESULT')
.map((expose) => {
const item = expose.item;
const [price, size] = item.attributes;
const image = item?.titlePicture?.preview ?? null;
return {
id: item.id,
price: price?.value,
size: size?.value,
title: item.title,
description: item.description,
link: `${metaInformation.baseUrl}expose/${item.id}`,
address: item.address?.line,
image,
};
});
return Promise.all(
responseBody.resultListItems
.filter((item) => item.type === 'EXPOSE_RESULT')
.map(async (expose) => {
const item = expose.item;
const [price, size] = item.attributes;
const image = item?.titlePicture?.full ?? item?.titlePicture?.preview ?? null;
let listing = {
id: item.id,
price: price?.value,
size: size?.value,
title: item.title,
link: `${metaInformation.baseUrl}expose/${item.id}`,
address: item.address?.line,
image,
};
if (currentUserId) {
const userSettings = getUserSettings(currentUserId);
if (userSettings.immoscout_details) {
return await pushDetails(listing);
}
}
return listing;
}),
);
}
async function pushDetails(listing) {
const detailed = await fetch(`https://api.mobile.immobilienscout24.de/expose/${listing.id}`, {
headers: {
'User-Agent': 'ImmoScout_27.3_26.0_._',
'Content-Type': 'application/json',
},
});
if (!detailed.ok) {
logger.error('Error fetching listing details from ImmoScout Mobile API:', detailed.statusText);
return listing;
}
const detailBody = await detailed.json();
listing.description = buildDescription(detailBody);
return listing;
}
function buildDescription(detailBody) {
const sections = detailBody.sections || [];
const contact = detailBody.contact || {};
const cData = contact?.contactData || {};
const agentName = cData?.agent?.name || '';
const agentCompany = cData?.agent?.company || '';
const stars = cData?.agent?.rating?.numberOfStars || '';
const phoneNumbers = contact?.phoneNumbers || [];
const phoneNumbersMapped = phoneNumbers
.map((p) => `${p.label}: ${p.text}`)
.join('\n')
.trim();
const attributes = sections
.filter((s) => s.type === 'ATTRIBUTE_LIST')
.flatMap((s) => s.attributes)
.filter((attr) => attr.label && attr.text)
.map((attr) => `${attr.label} ${attr.text}`)
.join('\n');
const freeText = sections
.filter((s) => s.type === 'TEXT_AREA')
.map((s) => {
return `${s.title}\n${s.text}`;
})
.join('\n\n');
return (
`Agent: ${agentName ? agentName : 'Unbekannt'} ${agentCompany ? `(${agentCompany}) ` : ''}${stars ? `- ${stars} stars` : ''}\n` +
(phoneNumbersMapped ? `Phone Numbers:\n${phoneNumbersMapped}` : '') +
'\n\n' +
attributes.trim() +
'\n\n' +
freeText.trim()
);
}
async function isListingActive(link) {
@@ -137,6 +202,7 @@ export const init = (sourceConfig, blacklist) => {
config.enabled = sourceConfig.enabled;
config.url = convertWebToMobile(sourceConfig.url);
appliedBlackList = blacklist || [];
currentUserId = sourceConfig.userId || null;
};
export const metaInformation = {
name: 'Immoscout',

View File

@@ -19,52 +19,80 @@ import path from 'path';
puppeteer.use(StealthPlugin());
export default async function execute(url, waitForSelector, options) {
let browser;
let page;
let result = null;
export async function launchBrowser(url, options) {
const preCfg = getPreLaunchConfig(url, options || {});
const launchArgs = [
'--no-sandbox',
'--disable-gpu',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-crash-reporter',
'--no-first-run',
'--no-default-browser-check',
preCfg.langArg,
preCfg.windowSizeArg,
...preCfg.extraArgs,
];
if (options?.proxyUrl) {
launchArgs.push(`--proxy-server=${options.proxyUrl}`);
}
let userDataDir;
let removeUserDataDir = false;
if (options && options.userDataDir) {
userDataDir = options.userDataDir;
} else {
const prefix = path.join(os.tmpdir(), 'puppeteer-fredy-');
userDataDir = fs.mkdtempSync(prefix);
removeUserDataDir = true;
}
const browser = await puppeteer.launch({
headless: options?.puppeteerHeadless ?? true,
args: launchArgs,
timeout: options?.puppeteerTimeout || 45_000,
userDataDir,
executablePath: options?.executablePath,
});
browser.__fredy_userDataDir = userDataDir;
browser.__fredy_removeUserDataDir = removeUserDataDir;
return browser;
}
export async function closeBrowser(browser) {
if (!browser) return;
const userDataDir = browser.__fredy_userDataDir;
const removeUserDataDir = browser.__fredy_removeUserDataDir;
try {
await browser.close();
} catch {
// ignore
}
if (removeUserDataDir && userDataDir) {
try {
await fs.promises.rm(userDataDir, { recursive: true, force: true });
} catch {
// ignore
}
}
}
export default async function execute(url, waitForSelector, options) {
let browser = options?.browser;
let isExternalBrowser = !!browser;
let page;
let result;
try {
debug(`Sending request to ${url} using Puppeteer.`);
// Prepare a dedicated temporary userDataDir to avoid leaking /tmp/.org.chromium.* dirs
if (options && options.userDataDir) {
userDataDir = options.userDataDir;
removeUserDataDir = !!options.cleanupUserDataDir;
} else {
const prefix = path.join(os.tmpdir(), 'puppeteer-fredy-');
userDataDir = fs.mkdtempSync(prefix);
removeUserDataDir = true;
if (!isExternalBrowser) {
browser = await launchBrowser(url, options);
}
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}`);
}
// Prepare bot prevention pre-launch config
const preCfg = getPreLaunchConfig(url, options || {});
launchArgs.push(preCfg.langArg);
launchArgs.push(preCfg.windowSizeArg);
launchArgs.push(...preCfg.extraArgs);
browser = await puppeteer.launch({
headless: options?.puppeteerHeadless ?? true,
args: launchArgs,
timeout: options?.puppeteerTimeout || 30_000,
userDataDir,
executablePath: options?.executablePath, // allow using system Chrome
});
page = await browser.newPage();
const preCfg = getPreLaunchConfig(url, options || {});
await applyBotPreventionToPage(page, preCfg);
// Provide languages value before navigation
await applyLanguagePersistence(page, preCfg);
@@ -104,7 +132,7 @@ export default async function execute(url, waitForSelector, options) {
result = pageSource || (await page.content());
}
} catch (error) {
if (error?.message?.includes('Timeout')) {
if (error?.name?.includes('Timeout')) {
logger.debug('Error executing with puppeteer executor', error);
} else {
logger.warn('Error executing with puppeteer executor', error);
@@ -118,19 +146,8 @@ export default async function execute(url, waitForSelector, options) {
} catch {
// ignore
}
try {
if (browser != null) {
await browser.close();
}
} catch {
// ignore
}
try {
if (removeUserDataDir && userDataDir) {
await fs.promises.rm(userDataDir, { recursive: true, force: true });
}
} catch {
// ignore
if (browser != null && !isExternalBrowser) {
await closeBrowser(browser);
}
}
return result;

View File

@@ -67,6 +67,7 @@ async function doGeocode(address) {
try {
const response = await fetch(url, {
agent,
timeout: 60000,
headers: {
'User-Agent': userAgent,
},

View File

@@ -86,6 +86,7 @@ const PARAM_NAME_MAP = {
shape: 'shape',
sorting: 'sorting',
newbuilding: 'newbuilding',
fulltext: 'fulltext',
};
const EQUIPMENT_MAP = {

View File

@@ -13,6 +13,7 @@ import FredyPipelineExecutioner from '../../FredyPipelineExecutioner.js';
import * as similarityCache from '../similarity-check/similarityCache.js';
import { isRunning, markFinished, markRunning } from './run-state.js';
import { sendToUsers } from '../sse/sse-broker.js';
import * as puppeteerExtractor from '../extractor/puppeteerExtractor.js';
/**
* Initializes the job execution service.
@@ -94,7 +95,7 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
* @param {{userId?: string, isAdmin?: boolean}} [context] - Who requested the run; determines job filtering.
* @returns {void}
*/
function runAll(respectWorkingHours = true, context = undefined) {
async function runAll(respectWorkingHours = true, context = undefined) {
if (settings.demoMode) return;
const now = Date.now();
const withinHours = duringWorkingHoursOrNotSet(settings, now);
@@ -103,15 +104,18 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
return;
}
settings.lastRun = now;
jobStorage
const jobs = jobStorage
.getJobs()
.filter((job) => job.enabled)
.filter((job) => {
if (!context) return true; // startup/cron → all
if (context.isAdmin) return true; // admin → all
return context.userId ? job.userId === context.userId : false; // user → own
})
.forEach((job) => executeJob(job));
});
for (const job of jobs) {
await executeJob(job);
}
}
/**
@@ -154,28 +158,43 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
} catch (err) {
logger.warn('Failed to emit start status for job', job.id, err);
}
let browser;
try {
const jobProviders = job.provider.filter(
(p) => providers.find((loaded) => loaded.metaInformation.id === p.id) != null,
);
const executions = jobProviders.map(async (prov) => {
const matchedProvider = providers.find((loaded) => loaded.metaInformation.id === prov.id);
matchedProvider.init(prov, job.blacklist);
await new FredyPipelineExecutioner(
matchedProvider.config,
job.notificationAdapter,
prov.id,
job.id,
similarityCache,
).execute();
});
const results = await Promise.allSettled(executions);
for (const r of results) {
if (r.status === 'rejected') {
logger.error(r.reason);
for (const prov of jobProviders) {
try {
const matchedProvider = providers.find((loaded) => loaded.metaInformation.id === prov.id);
matchedProvider.init({ ...prov, userId: job.userId }, job.blacklist);
if (browser && !browser.isConnected()) {
logger.debug('Browser is disconnected, nullifying to launch a new one.');
await puppeteerExtractor.closeBrowser(browser);
browser = null;
}
if (!browser && matchedProvider.config.getListings == null) {
browser = await puppeteerExtractor.launchBrowser(matchedProvider.config.url, {});
}
await new FredyPipelineExecutioner(
matchedProvider.config,
job.notificationAdapter,
job.spatialFilter,
prov.id,
job.id,
similarityCache,
browser,
).execute();
} catch (err) {
logger.error(err);
}
}
} finally {
if (browser) {
await puppeteerExtractor.closeBrowser(browser);
}
markFinished(job.id);
try {
bus.emit('jobs:status', { jobId: job.id, running: false });

View File

@@ -30,6 +30,7 @@ export const upsertJob = ({
notificationAdapter,
userId,
shareWithUsers = [],
spatialFilter = null,
}) => {
const id = jobId || nanoid();
const existing = SqliteConnection.query(`SELECT id, user_id FROM jobs WHERE id = @id LIMIT 1`, { id })[0];
@@ -42,7 +43,8 @@ export const upsertJob = ({
blacklist = @blacklist,
provider = @provider,
notification_adapter = @notification_adapter,
shared_with_user = @shareWithUsers
shared_with_user = @shareWithUsers,
spatial_filter = @spatialFilter
WHERE id = @id`,
{
id,
@@ -52,12 +54,13 @@ export const upsertJob = ({
shareWithUsers: toJson(shareWithUsers ?? []),
provider: toJson(provider ?? []),
notification_adapter: toJson(notificationAdapter ?? []),
spatialFilter: spatialFilter ? toJson(spatialFilter) : null,
},
);
} else {
SqliteConnection.execute(
`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)`,
`INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter, shared_with_user, spatial_filter)
VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter, @shareWithUsers, @spatialFilter)`,
{
id,
user_id: ownerId,
@@ -67,6 +70,7 @@ export const upsertJob = ({
provider: toJson(provider ?? []),
shareWithUsers: toJson(shareWithUsers ?? []),
notification_adapter: toJson(notificationAdapter ?? []),
spatialFilter: spatialFilter ? toJson(spatialFilter) : null,
},
);
}
@@ -87,6 +91,7 @@ export const getJob = (jobId) => {
j.provider,
j.shared_with_user,
j.notification_adapter AS notificationAdapter,
j.spatial_filter AS spatialFilter,
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
FROM jobs j
WHERE j.id = @id
@@ -101,6 +106,7 @@ export const getJob = (jobId) => {
provider: fromJson(row.provider, []),
shared_with_user: fromJson(row.shared_with_user, []),
notificationAdapter: fromJson(row.notificationAdapter, []),
spatialFilter: fromJson(row.spatialFilter, null),
};
};
@@ -150,6 +156,7 @@ export const getJobs = () => {
j.provider,
j.shared_with_user,
j.notification_adapter AS notificationAdapter,
j.spatial_filter AS spatialFilter,
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
FROM jobs j
WHERE j.enabled = 1
@@ -162,6 +169,7 @@ export const getJobs = () => {
provider: fromJson(row.provider, []),
shared_with_user: fromJson(row.shared_with_user, []),
notificationAdapter: fromJson(row.notificationAdapter, []),
spatialFilter: fromJson(row.spatialFilter, null),
}));
};
@@ -251,6 +259,7 @@ export const queryJobs = ({
j.provider,
j.shared_with_user,
j.notification_adapter AS notificationAdapter,
j.spatial_filter AS spatialFilter,
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
FROM jobs j
${whereSql}
@@ -266,6 +275,7 @@ export const queryJobs = ({
provider: fromJson(row.provider, []),
shared_with_user: fromJson(row.shared_with_user, []),
notificationAdapter: fromJson(row.notificationAdapter, []),
spatialFilter: fromJson(row.spatialFilter, null),
}));
return { totalNumber, page: safePage, result };

View File

@@ -88,7 +88,7 @@ export function up(db) {
}
} catch (e) {
// If parsing fails, let it throw to rollback the migration
throw new Error(`Failed to import users from ${usersJsonPath}: ${e.message}`);
throw new Error(`Failed to import users from ${usersJsonPath}: ${e.message}`, { cause: e });
}
}
@@ -116,7 +116,7 @@ export function up(db) {
}
}
} catch (e) {
throw new Error(`Failed to import jobs from ${jobsJsonPath}: ${e.message}`);
throw new Error(`Failed to import jobs from ${jobsJsonPath}: ${e.message}`, { cause: e });
}
}
}

View File

@@ -0,0 +1,11 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
// Migration: Add spatial_filter column to jobs table for storing GeoJSON-based spatial filters
export function up(db) {
db.exec(`
ALTER TABLE jobs ADD COLUMN spatial_filter JSONB DEFAULT NULL;
`);
}

View File

@@ -14,92 +14,89 @@ import { getSettings } from '../storage/settingsStorage.js';
const deviceId = getUniqueId() || 'N/A';
const version = await getPackageVersion();
const FREDY_TRACKING_URL = 'https://fredy.orange-coding.net/tracking';
const isDocker = process.env.IS_DOCKER != null;
export const trackMainEvent = async () => {
const staticTrackingData = {
operatingSystem: os.platform(),
osVersion: os.release(),
isDocker,
arch: process.arch,
language: process.env.LANG || 'en',
nodeVersion: process.version || 'N/A',
deviceId,
version,
};
const shouldTrack = async () => {
const settings = await getSettings();
return settings.analyticsEnabled && !inDevMode();
};
const sendTrackingData = async (endpoint, payload) => {
try {
const settings = await getSettings();
if (settings.analyticsEnabled && !inDevMode()) {
const activeProvider = new Set();
const activeAdapter = new Set();
const jobs = getJobs();
if (jobs != null && jobs.length > 0) {
jobs.forEach((job) => {
job.provider.forEach((provider) => activeProvider.add(provider.id));
job.notificationAdapter.forEach((adapter) => activeAdapter.add(adapter.id));
});
const trackingObj = enrichTrackingObject({
adapter: Array.from(activeAdapter),
provider: Array.from(activeProvider),
});
await fetch(`${FREDY_TRACKING_URL}/main`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(trackingObj),
});
}
const response = await fetch(`${FREDY_TRACKING_URL}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload ? JSON.stringify(payload) : undefined,
});
if (!response.ok) {
logger.warn(`Error sending tracking data to ${endpoint}. Status: ${response.status}`);
}
} catch (error) {
logger.warn('Error sending tracking data', error);
logger.warn(`Error sending tracking data to ${endpoint}`, error);
}
};
export const trackFeature = async (feature) => {
try {
const settings = await getSettings();
if (settings.analyticsEnabled && !inDevMode()) {
const trackingObj = await enrichTrackingObject({
feature,
});
export const trackMainEvent = async () => {
if (!(await shouldTrack())) return;
await fetch(`${FREDY_TRACKING_URL}/feature`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(trackingObj),
});
}
} catch (error) {
logger.warn('Error tracking feature', error);
const activeProvider = new Set();
const activeAdapter = new Set();
const jobs = getJobs();
if (jobs != null && jobs.length > 0) {
jobs.forEach((job) => {
job.provider.forEach((provider) => activeProvider.add(provider.id));
job.notificationAdapter.forEach((adapter) => activeAdapter.add(adapter.id));
});
const trackingObj = await enrichTrackingObject({
adapter: Array.from(activeAdapter),
provider: Array.from(activeProvider),
});
await sendTrackingData('/main', trackingObj);
}
};
export const trackPoi = async (poi) => {
if (!(await shouldTrack())) return;
const trackingObj = await enrichTrackingObject({
feature: poi,
});
await sendTrackingData('/feature', trackingObj);
};
/**
* Note, this will only be used when Fredy runs in demo mode
*/
export async function trackDemoAccessed() {
const settings = await getSettings();
if (settings.analyticsEnabled && !inDevMode() && settings.demoMode) {
try {
await fetch(`${FREDY_TRACKING_URL}/demo/accessed`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
logger.warn('Error sending tracking data', error);
}
const trackingObj = await enrichTrackingObject({});
await sendTrackingData('/demo/accessed', trackingObj);
}
}
async function enrichTrackingObject(trackingObject) {
const settings = await getSettings();
const operatingSystem = os.platform();
const osVersion = os.release();
const arch = process.arch;
const language = process.env.LANG || 'en';
const nodeVersion = process.version || 'N/A';
return {
...trackingObject,
...staticTrackingData,
isDemo: settings.demoMode,
operatingSystem,
osVersion,
arch,
nodeVersion,
language,
deviceId,
version,
};
}

13553
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "fredy",
"version": "19.3.5",
"version": "19.6.0",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": {
"prepare": "husky",
@@ -17,7 +17,8 @@
"lint:fix": "yarn lint --fix",
"migratedb": "node lib/services/storage/migrations/migrate.js",
"migratedb:overwrite": "x-var MIGRATION_ALLOW_CHECKSUM_UPDATE=true node lib/services/storage/migrations/migrate.js",
"copyright": "node ./copyright.js"
"copyright": "node ./copyright.js",
"release": "node ./tools/release/release.js"
},
"type": "module",
"lint-staged": {
@@ -59,27 +60,29 @@
"Firefox ESR"
],
"dependencies": {
"@douyinfe/semi-icons": "^2.90.13",
"@douyinfe/semi-ui": "2.90.13",
"@douyinfe/semi-ui-19": "^2.90.13",
"@douyinfe/semi-icons": "^2.92.2",
"@douyinfe/semi-ui": "2.92.2",
"@douyinfe/semi-ui-19": "^2.92.2",
"@mapbox/mapbox-gl-draw": "^1.5.1",
"@sendgrid/mail": "8.1.6",
"@vitejs/plugin-react": "5.1.3",
"@vitejs/plugin-react": "5.1.4",
"adm-zip": "^0.5.16",
"better-sqlite3": "^12.6.2",
"body-parser": "2.2.2",
"chart.js": "^4.5.1",
"cheerio": "^1.2.0",
"@turf/boolean-point-in-polygon": "^7.3.4",
"cookie-session": "2.1.1",
"handlebars": "4.7.8",
"lodash": "4.17.23",
"maplibre-gl": "^5.17.0",
"maplibre-gl": "^5.19.0",
"nanoid": "5.1.6",
"node-cron": "^4.2.1",
"node-fetch": "3.3.2",
"node-mailjet": "6.0.11",
"p-throttle": "^8.1.0",
"package-up": "^5.0.0",
"puppeteer": "^24.36.1",
"puppeteer": "^24.38.0",
"puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"query-string": "9.3.1",
@@ -87,10 +90,11 @@
"react-chartjs-2": "^5.3.1",
"react-dom": "19.2.4",
"react-range-slider-input": "^3.3.2",
"react-router": "7.13.0",
"react-router-dom": "7.13.0",
"react-router": "7.13.1",
"react-router-dom": "7.13.1",
"resend": "^6.9.3",
"restana": "5.1.0",
"semver": "^7.7.3",
"semver": "^7.7.4",
"serve-static": "2.2.1",
"slack": "11.0.2",
"vite": "7.3.1",
@@ -102,17 +106,20 @@
"@babel/eslint-parser": "7.28.6",
"@babel/preset-env": "7.29.0",
"@babel/preset-react": "7.28.5",
"@eslint/js": "^10.0.1",
"chai": "6.2.2",
"eslint": "9.39.2",
"chalk": "^5.6.2",
"eslint": "10.0.3",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-react": "7.37.5",
"esmock": "2.7.3",
"globals": "^17.4.0",
"history": "5.3.0",
"husky": "9.1.7",
"less": "4.5.1",
"lint-staged": "16.2.7",
"lint-staged": "16.3.2",
"mocha": "11.7.5",
"nodemon": "^3.1.11",
"nodemon": "^3.1.14",
"prettier": "3.8.1"
}
}

View File

@@ -24,4 +24,8 @@ export function getUserSettings(userId) {
export const updateListingDistance = (id, distance) => {
// noop
};
export const deletedIds = [];
export const deleteListingsById = (ids) => {
deletedIds.push(...ids);
};
/* eslint-enable no-unused-vars */

View File

@@ -0,0 +1,89 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { expect } from 'chai';
import { mockFredy } from './utils.js';
import * as mockStore from './mocks/mockStore.js';
describe('Issue reproduction: listings filtered by similarity or area should be marked as manually deleted', () => {
it('should call deleteListingsById when listings are filtered by similarity', async () => {
const Fredy = await mockFredy();
const mockSimilarityCache = {
checkAndAddEntry: () => true, // always similar
};
const providerConfig = {
url: 'http://example.com',
getListings: () => Promise.resolve([{ id: '1', title: 'test', address: 'addr', price: '100' }]),
normalize: (l) => l,
filter: () => true,
crawlFields: { id: 'id', title: 'title', address: 'address', price: 'price' },
};
const fredy = new Fredy(providerConfig, null, null, 'test-provider', 'test-job', mockSimilarityCache);
// Clear deletedIds before test
mockStore.deletedIds.length = 0;
try {
await fredy.execute();
} catch {
// Might throw NoNewListingsWarning if all are filtered out
}
expect(mockStore.deletedIds).to.include('1');
});
it('should call deleteListingsById when listings are filtered by area', async () => {
const Fredy = await mockFredy();
const mockSimilarityCache = {
checkAndAddEntry: () => false, // never similar
};
const spatialFilter = {
features: [
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[0, 0],
[0, 1],
[1, 1],
[1, 0],
[0, 0],
],
],
},
},
],
};
const providerConfig = {
url: 'http://example.com',
getListings: () =>
Promise.resolve([{ id: '2', title: 'test', address: 'addr', price: '100', latitude: 2, longitude: 2 }]), // outside polygon
normalize: (l) => l,
filter: () => true,
crawlFields: { id: 'id', title: 'title', address: 'address', price: 'price' },
};
const fredy = new Fredy(providerConfig, null, spatialFilter, 'test-provider', 'test-job', mockSimilarityCache);
// Clear deletedIds before test
mockStore.deletedIds.length = 0;
try {
await fredy.execute();
} catch {
// Might throw NoNewListingsWarning if all are filtered out
}
expect(mockStore.deletedIds).to.include('2');
});
});

View File

@@ -14,7 +14,14 @@ describe('#einsAImmobilien testsuite()', () => {
it('should test einsAImmobilien provider', async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'einsAImmobilien', similarityCache);
const fredy = new Fredy(
provider.config,
null,
null,
provider.metaInformation.id,
'einsAImmobilien',
similarityCache,
);
fredy.execute().then((listings) => {
expect(listings).to.be.a('array');
const notificationObj = get();

View File

@@ -14,7 +14,7 @@ describe('#immobilien.de testsuite()', () => {
it('should test immobilien.de provider', async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'test1', similarityCache);
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'test1', similarityCache);
fredy.execute().then((listing) => {
expect(listing).to.be.a('array');
const notificationObj = get();

View File

@@ -1,38 +0,0 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js';
import { mockFredy, providerConfig } from '../utils.js';
import { expect } from 'chai';
import * as provider from '../../lib/provider/immonet.js';
describe('#immonet testsuite()', () => {
it('should test immonet provider', async () => {
const Fredy = await mockFredy();
provider.init(providerConfig.immonet, [], []);
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immonet', similarityCache);
const listing = await fredy.execute();
expect(listing).to.be.a('array');
const notificationObj = get();
expect(notificationObj).to.be.a('object');
expect(notificationObj.serviceName).to.equal('immonet');
notificationObj.payload.forEach((notify) => {
/** check the actual structure **/
expect(notify.id).to.be.a('string');
expect(notify.price).to.be.a('string');
expect(notify.size).to.be.a('string');
expect(notify.title).to.be.a('string');
expect(notify.link).to.be.a('string');
expect(notify.address).to.be.a('string');
/** check the values if possible **/
expect(notify.size).that.does.include('m²');
expect(notify.title).to.be.not.empty;
expect(notify.address).to.be.not.empty;
});
});
});

View File

@@ -14,7 +14,7 @@ describe('#immoscout provider testsuite()', () => {
it('should test immoscout provider', async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, '', similarityCache);
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, '', similarityCache);
fredy.execute().then((listings) => {
expect(listings).to.be.a('array');
const notificationObj = get();

View File

@@ -14,7 +14,7 @@ describe('#immoswp testsuite()', () => {
it('should test immoswp provider', async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immoswp', similarityCache);
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immoswp', similarityCache);
fredy.execute().then((listing) => {
expect(listing).to.be.a('array');
const notificationObj = get();

View File

@@ -14,7 +14,7 @@ describe('#immowelt testsuite()', () => {
const Fredy = await mockFredy();
provider.init(providerConfig.immowelt, [], []);
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immowelt', similarityCache);
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immowelt', similarityCache);
const listing = await fredy.execute();
expect(listing).to.be.a('array');

View File

@@ -14,7 +14,14 @@ describe('#kleinanzeigen testsuite()', () => {
const Fredy = await mockFredy();
provider.init(providerConfig.kleinanzeigen, [], []);
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'kleinanzeigen', similarityCache);
const fredy = new Fredy(
provider.config,
null,
null,
provider.metaInformation.id,
'kleinanzeigen',
similarityCache,
);
fredy.execute().then((listing) => {
expect(listing).to.be.a('array');
const notificationObj = get();

View File

@@ -14,7 +14,7 @@ describe('#mcMakler testsuite()', () => {
const Fredy = await mockFredy();
provider.init(providerConfig.mcMakler, []);
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'mcMakler', similarityCache);
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'mcMakler', similarityCache);
const listing = await fredy.execute();
expect(listing).to.be.a('array');

View File

@@ -14,7 +14,14 @@ describe('#neubauKompass testsuite()', () => {
it('should test neubauKompass provider', async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'neubauKompass', similarityCache);
const fredy = new Fredy(
provider.config,
null,
null,
provider.metaInformation.id,
'neubauKompass',
similarityCache,
);
fredy.execute().then((listing) => {
expect(listing).to.be.a('array');
const notificationObj = get();

View File

@@ -14,7 +14,7 @@ describe('#ohneMakler testsuite()', () => {
const Fredy = await mockFredy();
provider.init(providerConfig.ohneMakler, []);
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'ohneMakler', similarityCache);
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'ohneMakler', similarityCache);
const listing = await fredy.execute();
expect(listing).to.be.a('array');

View File

@@ -17,6 +17,7 @@ describe('#regionalimmobilien24 testsuite()', () => {
const fredy = new Fredy(
provider.config,
null,
null,
provider.metaInformation.id,
'regionalimmobilien24',
similarityCache,

View File

@@ -14,7 +14,7 @@ describe('#sparkasse testsuite()', () => {
const Fredy = await mockFredy();
provider.init(providerConfig.sparkasse, []);
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'sparkasse', similarityCache);
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'sparkasse', similarityCache);
const listing = await fredy.execute();
expect(listing).to.be.a('array');

View File

@@ -8,10 +8,6 @@
"url": "https://www.immobilien.de/Wohnen/Suchergebnisse-51797.html?search._digest=true&search._filter=wohnen&search.flaeche_von=50&search.objektart=wohnung&search.preis_bis=1200&search.typ=mieten&search.umkreis=15&search.wo=district%3A2434%2C2695%2C2621%2C2700%2C2967%2C2734%2C2909%2C2955%2C2392%2C2746%2C2767%2C2982%2C2904%2C2612%2C2892%2C2587%2C2871%2C2975%2C2591%2C2887%2C2569%2C2640%2C2735&sort_col=*created_ts&sort_dir=desc",
"enabled": true
},
"immonet": {
"url": "https://www.immonet.de/classified-search?distributionTypes=Buy,Buy_Auction,Compulsory_Auction&estateTypes=House,Apartment&locations=AD08DE2112&order=Default&m=homepage_new_search_classified_search_result",
"enabled": true
},
"immowelt": {
"url": "https://www.immowelt.de/classified-search?distributionTypes=Buy,Buy_Auction,Compulsory_Auction&estateTypes=House,Apartment&locations=AD08DE2350",
"enabled": true

View File

@@ -14,7 +14,7 @@ describe('#wgGesucht testsuite()', () => {
it('should test wgGesucht provider', async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'wgGesucht', similarityCache);
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'wgGesucht', similarityCache);
fredy.execute().then((listing) => {
expect(listing).to.be.a('array');
const notificationObj = get();

View File

@@ -14,7 +14,14 @@ describe('#wohnungsboerse testsuite()', () => {
it('should test wohnungsboerse provider', async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'wohnungsboerse', similarityCache);
const fredy = new Fredy(
provider.config,
null,
null,
provider.metaInformation.id,
'wohnungsboerse',
similarityCache,
);
fredy.execute().then((listings) => {
expect(listings).to.be.a('array');
const notificationObj = get();

View File

@@ -14,12 +14,6 @@
"shouldBecome": "https://www.wg-gesucht.de/1-zimmer-wohnungen-in-Dusseldorf.30.1.1.0.html?sort_column=0&sort_order=0",
"id": "wgGesucht"
},
{
"url": "https://www.immonet.de/immobiliensuche/sel.do?sortby=0&suchart=1&objecttype=1&marketingtype=2&parentcat=1&locationname=d%C3%BCsseldorf",
"shouldBecome": "https://www.immonet.de/immobiliensuche/sel.do?sortby=19&suchart=1&objecttype=1&marketingtype=2&parentcat=1&locationname=d%C3%BCsseldorf",
"id": "immonet"
},
{
"url": "https://www.neubaukompass.de/neubau-immobilien/berlin-region/",
"shouldBecome": "https://www.neubaukompass.de/neubau-immobilien/berlin-region/?Sortierung=Id&Richtung=DESC",

196
tools/release/release.js Normal file
View File

@@ -0,0 +1,196 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import fs from 'fs';
import path from 'path';
import { execSync, spawn } from 'child_process';
import fetch from 'node-fetch';
import chalk from 'chalk';
import { fileURLToPath } from 'url';
/**
* Release Tool for Fredy
*
* This tool automates the process of creating a GitHub release.
* It fetches the latest release, compares it with the current master branch,
* allows manual editing of commit messages, and creates a new release on GitHub.
*/
// Define __dirname for ESM
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Configuration and Paths
const CONFIG_PATH = path.join(__dirname, 'config.json');
const PACKAGE_JSON_PATH = path.join(__dirname, '../../package.json');
const REPO = 'orangecoding/fredy';
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
const GITHUB_TOKEN = config.github_token;
/**
* Main function to execute the release process
*/
async function createRelease() {
/* eslint-disable no-console */
try {
console.log(chalk.cyan('🚀 Starting release process...'));
// 1. Load Configuration
if (!fs.existsSync(CONFIG_PATH)) {
console.error(chalk.red('❌ Error: config.json not found in tools/release/'));
process.exit(1);
}
if (!GITHUB_TOKEN) {
console.error(chalk.red('❌ Error: GitHub token not configured.'));
process.exit(1);
}
// 2. Get current version from package.json
const packageJson = JSON.parse(fs.readFileSync(PACKAGE_JSON_PATH, 'utf8'));
const version = packageJson.version;
const tag = version; // Using version as tag
console.log(chalk.blue(`📦 Target version: ${version}`));
// 3. Check if release already exists
console.log(chalk.yellow('🔍 Checking if release already exists...'));
const existingReleaseResponse = await fetch(`https://api.github.com/repos/${REPO}/releases/tags/${tag}`, {
headers: {
Authorization: `token ${GITHUB_TOKEN}`,
Accept: 'application/vnd.github.v3+json',
},
});
if (existingReleaseResponse.status === 200) {
console.error(chalk.red(`❌ Error: A release with tag ${tag} already exists.`));
process.exit(1);
}
// 4. Fetch latest release to find the starting point for the diff
console.log(chalk.yellow('📡 Fetching latest release from GitHub...'));
const latestReleaseResponse = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, {
headers: {
Authorization: `token ${GITHUB_TOKEN}`,
Accept: 'application/vnd.github.v3+json',
},
});
if (!latestReleaseResponse.ok) {
console.error(chalk.red('❌ Error fetching latest release.'));
const errorData = await latestReleaseResponse.json();
console.error(chalk.red(JSON.stringify(errorData)));
process.exit(1);
}
const latestRelease = await latestReleaseResponse.json();
const latestTag = latestRelease.tag_name;
console.log(chalk.green(`✅ Latest release found: ${latestTag}`));
// 5. Ensure the latest tag is available locally
console.log(chalk.yellow(`📡 Fetching tag ${latestTag} from remote...`));
try {
execSync(`git fetch origin tag ${latestTag} --no-tags`);
} catch (error) {
console.error(chalk.red(`❌ Error fetching tag ${latestTag} from origin.`));
console.error(error.message);
// We don't exit here, maybe it's already there but fetch failed for some reason
}
// 6. Get commit messages between latest tag and current HEAD
console.log(chalk.yellow(`Git diff: ${latestTag} .. HEAD`));
let commitMessages;
try {
commitMessages = execSync(`git log ${latestTag}..HEAD --pretty=format:"- %s"`).toString().trim();
} catch (error) {
console.error(chalk.red('❌ Error running git log. Make sure the latest tag is available locally.'), error);
process.exit(1);
}
if (!commitMessages) {
console.log(chalk.magenta('⚠️ No new commits found since last release.'));
commitMessages = '- No changes recorded';
}
// 7. Open commit messages in editor for manual adjustment
const tempFilePath = path.join(__dirname, 'CHANGELOG_EDIT.tmp');
const initialContent = `# Release Notes for ${version}\n# Edit the messages below. Lines starting with # will be ignored.\n\n${commitMessages}`;
fs.writeFileSync(tempFilePath, initialContent);
console.log(chalk.blue('📝 Opening editor for release notes (using nano or $EDITOR)...'));
await openInEditor(tempFilePath);
// 8. Read edited content
let editedContent = fs
.readFileSync(tempFilePath, 'utf8')
.split('\n')
.filter((line) => !line.startsWith('#'))
.join('\n')
.trim();
fs.unlinkSync(tempFilePath); // Clean up temp file
if (!editedContent) {
console.error(chalk.red('❌ Release notes are empty. Aborting release.'));
process.exit(1);
}
// 9. Create the new release
console.log(chalk.cyan(`🚀 Creating release ${version} on GitHub...`));
const createResponse = await fetch(`https://api.github.com/repos/${REPO}/releases`, {
method: 'POST',
headers: {
Authorization: `token ${GITHUB_TOKEN}`,
Accept: 'application/vnd.github.v3+json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
tag_name: tag,
name: version,
body: editedContent,
draft: false,
prerelease: false,
}),
});
if (createResponse.status === 201) {
const data = await createResponse.json();
console.log(chalk.green('🎉 Release successfully created!'));
console.log(chalk.green(`🔗 URL: ${data.html_url}`));
} else {
const errorData = await createResponse.json();
console.error(chalk.red('❌ Failed to create release.'));
console.error(chalk.red(JSON.stringify(errorData, null, 2)));
process.exit(1);
}
} catch (error) {
console.error(chalk.red('💥 An unexpected error occurred:'));
console.error(error);
process.exit(1);
}
}
/**
* Helper to open a file in a terminal editor
* @param {string} filePath
*/
function openInEditor(filePath) {
return new Promise((resolve, reject) => {
const editor = process.env.EDITOR || 'nano';
const child = spawn(editor, [filePath], {
stdio: 'inherit',
});
child.on('exit', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Editor exited with code ${code}`));
}
});
});
}
await createRelease();
/* eslint-enable no-console */

View File

@@ -29,6 +29,7 @@ import FredyFooter from './components/footer/FredyFooter.jsx';
import WatchlistManagement from './views/listings/management/WatchlistManagement.jsx';
import Dashboard from './views/dashboard/Dashboard.jsx';
import ListingDetail from './views/listings/ListingDetail.jsx';
import NewsModal from './components/news/NewsModal.jsx';
export default function FredyApp() {
const actions = useActions();
@@ -48,6 +49,7 @@ export default function FredyApp() {
await actions.generalSettings.getGeneralSettings();
await actions.userSettings.getUserSettings();
await actions.versionUpdate.getVersionUpdate();
await actions.tracking.getTrackingPois();
}
setLoading(false);
}
@@ -88,6 +90,7 @@ export default function FredyApp() {
</>
)}
{settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />}
{!settings.demoMode && <NewsModal />}
<Routes>
<Route path="/403" element={<InsufficientPermission />} />
<Route path="/jobs/new" element={<JobMutation />} />

View File

@@ -3,8 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import { HashRouter } from 'react-router-dom';
import { createRoot } from 'react-dom/client';
import en_US from '@douyinfe/semi-ui-19/lib/es/locale/source/en_US';

BIN
ui/src/assets/news/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

BIN
ui/src/assets/news/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@@ -0,0 +1,20 @@
{
"key": "00e6b81777a275f5a140fc9101cb943810db6a69f6eb3927319c5aee0c876510",
"content":
[
{
"title": "Listings can now be filtered by location",
"text": "Thanks to https://github.com/strech345 for adding a new feature that allows you to filter listings based on their their location. When creating a job, you can now create multiple areas in which Fredy searches for new listings",
"image": "1.png"
},
{
"title": "More details from the Immoscout Provider",
"text": "https://github.com/MindCollaps added a new user-setting. When enabled Fredy will pull way more information for each listing from Immoscout.",
"image": "2.png"
},
{
"title": "Listings now marked as invisible if filtered",
"text": "So far, when a listing was filtered e.g. due to being a duplicate, it was not send to you, but still shown on the map. This was now fixed."
}
]
}

View File

@@ -3,7 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React, { useState } from 'react';
import { useState } from 'react';
import { Modal, Radio, RadioGroup, Typography } from '@douyinfe/semi-ui-19';
const { Text } = Typography;

View File

@@ -2,30 +2,29 @@
width: 100%;
height: 140px;
margin-bottom: 16px;
transition: transform 0.2s, box-shadow 0.2s;
transition: transform 0.2s;
background-color: rgba(36, 36, 36, 0.9);
backdrop-filter: blur(8px);
border: 1px solid var(--semi-color-border);
--pulse-color: rgba(255, 255, 255, 0.1);
position: relative;
z-index: 1;
overflow: visible;
&:hover {
transform: translateY(-4px);
background-color: rgba(36, 36, 36, 1);
&.blue {
box-shadow: 0 8px 24px -5px var(--semi-color-primary);
}
&.orange {
box-shadow: 0 8px 24px -5px var(--semi-color-warning);
}
&.green {
box-shadow: 0 8px 24px -5px var(--semi-color-success);
}
&.purple {
box-shadow: 0 8px 24px -5px var(--semi-color-info);
}
&.gray {
box-shadow: 0 8px 24px -5px rgba(255, 255, 255, 0.4);
}
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: inherit;
box-shadow: 0 4px 25px -2px var(--pulse-color);
opacity: 0;
animation: pulse 5s infinite ease-in-out;
pointer-events: none;
z-index: -1;
will-change: opacity;
}
&__icon {
@@ -46,22 +45,36 @@
}
&.blue {
box-shadow: 0 4px 20px -5px var(--semi-color-primary);
--pulse-color: var(--semi-color-primary);
box-shadow: 0 4px 20px -5px var(--pulse-color);
}
&.orange {
box-shadow: 0 4px 20px -5px var(--semi-color-warning);
--pulse-color: var(--semi-color-warning);
box-shadow: 0 4px 20px -5px var(--pulse-color);
}
&.green {
box-shadow: 0 4px 20px -5px var(--semi-color-success);
--pulse-color: var(--semi-color-success);
box-shadow: 0 4px 20px -5px var(--pulse-color);
}
&.purple {
box-shadow: 0 4px 20px -5px var(--semi-color-info);
--pulse-color: var(--semi-color-info);
box-shadow: 0 4px 20px -5px var(--pulse-color);
}
&.gray {
box-shadow: 0 4px 20px -5px rgba(255, 255, 255, 0.2);
--pulse-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 4px 20px -5px var(--pulse-color);
}
}
@keyframes pulse {
0%, 100% {
opacity: 0.1;
}
50% {
opacity: 0.5;
}
}

View File

@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import { Card, Typography, Space } from '@douyinfe/semi-ui-19';
import './DashboardCard.less';

View File

@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import './FredyFooter.less';
import { useSelector } from '../../services/state/store.js';
import { Typography, Layout, Space, Divider } from '@douyinfe/semi-ui-19';

View File

@@ -3,7 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { useState, useEffect, useMemo, useRef } from 'react';
import {
Card,
Col,

View File

@@ -3,7 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React, { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo } from 'react';
import {
Card,
Col,

View File

@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import { Typography } from '@douyinfe/semi-ui-19';
export default function Headline({ text, size = 3 } = {}) {

View File

@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import logo from '../../assets/logo.png';
import logoWhite from '../../assets/logo_white.png';

View File

@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import { Button } from '@douyinfe/semi-ui-19';
import { xhrPost } from '../../services/xhr';
import { IconUser } from '@douyinfe/semi-icons';

View File

@@ -0,0 +1,213 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { useEffect, useRef } from 'react';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css';
import { fixMapboxDrawCompatibility, addDrawingControl, setupAreaFilterEventListeners } from './MapDrawingExtension.js';
import './Map.less';
export const GERMANY_BOUNDS = [
[5.866, 47.27], // Southwest coordinates
[15.042, 55.059], // Northeast coordinates
];
export const STYLES = {
STANDARD: 'https://tiles.openfreemap.org/styles/bright',
SATELLITE: {
version: 8,
sources: {
'satellite-tiles': {
type: 'raster',
tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'],
tileSize: 256,
attribution:
'Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community',
},
'satellite-labels': {
type: 'raster',
tiles: [
'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}',
],
tileSize: 256,
attribution: '© Esri',
},
},
layers: [
{
id: 'satellite-tiles',
type: 'raster',
source: 'satellite-tiles',
minzoom: 0,
maxzoom: 19,
},
{
id: 'satellite-labels',
type: 'raster',
source: 'satellite-labels',
minzoom: 0,
maxzoom: 19,
},
],
},
};
export default function Map({
style = 'STANDARD',
show3dBuildings = false,
onMapReady = null,
enableDrawing = false,
initialSpatialFilter = null,
onDrawingChange = null,
}) {
const mapContainerRef = useRef(null);
const mapRef = useRef(null);
const drawRef = useRef(null);
// Initialize map - ONLY when container changes, never reinitialize
useEffect(() => {
if (mapRef.current) return; // Map already exists, don't reinitialize
mapRef.current = new maplibregl.Map({
container: mapContainerRef.current,
style: STYLES[style],
center: [10.4515, 51.1657], // Center of Germany
zoom: 4,
maxBounds: GERMANY_BOUNDS,
antialias: true,
});
mapRef.current.addControl(
new maplibregl.NavigationControl({
showCompass: true,
visualizePitch: true,
visualizeRoll: true,
}),
'top-right',
);
mapRef.current.addControl(
new maplibregl.GeolocateControl({
positionOptions: {
enableHighAccuracy: true,
},
trackUserLocation: true,
}),
);
// Initialize drawing extension only if enabled
if (enableDrawing) {
fixMapboxDrawCompatibility();
drawRef.current = addDrawingControl(mapRef.current);
}
// Call onMapReady callback if provided
if (onMapReady) {
onMapReady(mapRef.current);
}
return () => {
if (mapRef.current) {
mapRef.current.remove();
mapRef.current = null;
}
};
}, [mapContainerRef]); // ONLY depend on mapContainerRef - nothing else!
// Load spatial filter and setup area filter event listeners
useEffect(() => {
if (!mapRef.current || !drawRef.current || !enableDrawing) return;
// Load initial spatial filter if provided
if (initialSpatialFilter) {
try {
drawRef.current.set(initialSpatialFilter);
} catch (error) {
console.error('Error loading spatial filter:', error);
}
}
// Setup drawing event listeners
const cleanup = setupAreaFilterEventListeners(mapRef.current, drawRef.current, onDrawingChange);
return cleanup;
}, [initialSpatialFilter, onDrawingChange, enableDrawing]);
// Handle style changes
useEffect(() => {
if (mapRef.current) {
mapRef.current.setStyle(STYLES[style]);
}
}, [style]);
// Handle 3D buildings layer
useEffect(() => {
if (!mapRef.current) return;
const add3dLayer = () => {
if (!mapRef.current || !mapRef.current.isStyleLoaded()) return;
if (show3dBuildings) {
if (!mapRef.current.getSource('openfreemap')) {
mapRef.current.addSource('openfreemap', {
type: 'vector',
url: 'https://tiles.openfreemap.org/planet',
});
}
if (!mapRef.current.getLayer('3d-buildings')) {
const layers = mapRef.current.getStyle().layers;
let labelLayerId;
for (let i = 0; i < layers.length; i++) {
if (layers[i].type === 'symbol' && layers[i].layout?.['text-field']) {
labelLayerId = layers[i].id;
break;
}
}
mapRef.current.addLayer(
{
id: '3d-buildings',
source: 'openfreemap',
'source-layer': 'building',
type: 'fill-extrusion',
minzoom: 15,
filter: ['!=', ['get', 'hide_3d'], true],
paint: {
'fill-extrusion-color': [
'interpolate',
['linear'],
['get', 'render_height'],
0,
'lightgray',
200,
'royalblue',
400,
'lightblue',
],
'fill-extrusion-height': ['interpolate', ['linear'], ['zoom'], 15, 0, 16, ['get', 'render_height']],
'fill-extrusion-base': ['case', ['>=', ['get', 'zoom'], 16], ['get', 'render_min_height'], 0],
'fill-extrusion-opacity': 0.6,
},
},
labelLayerId,
);
}
} else {
if (mapRef.current.getLayer('3d-buildings')) {
mapRef.current.removeLayer('3d-buildings');
}
}
};
add3dLayer();
}, [show3dBuildings, style]);
// Handle pitch for 3D
useEffect(() => {
if (!mapRef.current) return;
mapRef.current.setPitch(show3dBuildings ? 45 : 0);
}, [show3dBuildings]);
return <div ref={mapContainerRef} className="map-container" />;
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
.map-container {
height: 100%;
}
/* Fix Mapbox Draw cursors for MapLibre GL compatibility */
.maplibregl-map.mouse-pointer .maplibregl-canvas-container.maplibregl-interactive {
cursor: pointer;
}
.maplibregl-map.mouse-move .maplibregl-canvas-container.maplibregl-interactive {
cursor: move;
}
.maplibregl-map.mouse-add .maplibregl-canvas-container.maplibregl-interactive {
cursor: crosshair;
}
.maplibregl-map.mouse-move.mode-direct_select .maplibregl-canvas-container.maplibregl-interactive {
cursor: grab;
cursor: -moz-grab;
cursor: -webkit-grab;
}
.maplibregl-map.mode-direct_select.feature-vertex.mouse-move .maplibregl-canvas-container.maplibregl-interactive {
cursor: move;
}
.maplibregl-map.mode-direct_select.feature-midpoint.mouse-pointer .maplibregl-canvas-container.maplibregl-interactive {
cursor: cell;
}
.maplibregl-map.mode-direct_select.feature-feature.mouse-move .maplibregl-canvas-container.maplibregl-interactive {
cursor: move;
}
.maplibregl-map.mode-static.mouse-pointer .maplibregl-canvas-container.maplibregl-interactive {
cursor: grab;
cursor: -moz-grab;
cursor: -webkit-grab;
}

View File

@@ -0,0 +1,177 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import MapboxDraw from '@mapbox/mapbox-gl-draw';
const drawStyles = [
{
id: 'gl-draw-polygon-fill-inactive',
type: 'fill',
filter: ['all', ['==', 'active', 'false'], ['==', '$type', 'Polygon'], ['!=', 'mode', 'static']],
paint: { 'fill-color': '#3bb2d0', 'fill-outline-color': '#3bb2d0', 'fill-opacity': 0.1 },
},
{
id: 'gl-draw-polygon-fill-active',
type: 'fill',
filter: ['all', ['==', 'active', 'true'], ['==', '$type', 'Polygon']],
paint: { 'fill-color': '#fbb03b', 'fill-outline-color': '#fbb03b', 'fill-opacity': 0.1 },
},
{
id: 'gl-draw-polygon-midpoint',
type: 'circle',
filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'midpoint']],
paint: { 'circle-radius': 3, 'circle-color': '#fbb03b' },
},
{
id: 'gl-draw-polygon-stroke-inactive',
type: 'line',
filter: ['all', ['==', 'active', 'false'], ['==', '$type', 'Polygon'], ['!=', 'mode', 'static']],
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': '#3bb2d0', 'line-width': 2 },
},
{
id: 'gl-draw-polygon-stroke-active',
type: 'line',
filter: ['all', ['==', 'active', 'true'], ['==', '$type', 'Polygon']],
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': '#fbb03b', 'line-dasharray': [0.2, 2], 'line-width': 2 },
},
{
id: 'gl-draw-line-inactive',
type: 'line',
filter: ['all', ['==', 'active', 'false'], ['==', '$type', 'LineString'], ['!=', 'mode', 'static']],
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': '#3bb2d0', 'line-width': 2 },
},
{
id: 'gl-draw-line-active',
type: 'line',
filter: ['all', ['==', '$type', 'LineString'], ['==', 'active', 'true']],
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': '#fbb03b', 'line-dasharray': [0.2, 2], 'line-width': 2 },
},
{
id: 'gl-draw-polygon-and-line-vertex-stroke-inactive',
type: 'circle',
filter: ['all', ['==', 'meta', 'vertex'], ['==', '$type', 'Point'], ['!=', 'mode', 'static']],
paint: { 'circle-radius': 5, 'circle-color': '#fff' },
},
{
id: 'gl-draw-polygon-and-line-vertex-inactive',
type: 'circle',
filter: ['all', ['==', 'meta', 'vertex'], ['==', '$type', 'Point'], ['!=', 'mode', 'static']],
paint: { 'circle-radius': 3, 'circle-color': '#fbb03b' },
},
{
id: 'gl-draw-point-point-stroke-inactive',
type: 'circle',
filter: [
'all',
['==', 'active', 'false'],
['==', '$type', 'Point'],
['==', 'meta', 'feature'],
['!=', 'mode', 'static'],
],
paint: { 'circle-radius': 5, 'circle-opacity': 1, 'circle-color': '#fff' },
},
{
id: 'gl-draw-point-inactive',
type: 'circle',
filter: [
'all',
['==', 'active', 'false'],
['==', '$type', 'Point'],
['==', 'meta', 'feature'],
['!=', 'mode', 'static'],
],
paint: { 'circle-radius': 3, 'circle-color': '#3bb2d0' },
},
{
id: 'gl-draw-point-stroke-active',
type: 'circle',
filter: ['all', ['==', '$type', 'Point'], ['==', 'active', 'true'], ['!=', 'meta', 'midpoint']],
paint: { 'circle-radius': 7, 'circle-color': '#fff' },
},
{
id: 'gl-draw-point-active',
type: 'circle',
filter: ['all', ['==', '$type', 'Point'], ['!=', 'meta', 'midpoint'], ['==', 'active', 'true']],
paint: { 'circle-radius': 5, 'circle-color': '#fbb03b' },
},
{
id: 'gl-draw-polygon-fill-static',
type: 'fill',
filter: ['all', ['==', 'mode', 'static'], ['==', '$type', 'Polygon']],
paint: { 'fill-color': '#404040', 'fill-outline-color': '#404040', 'fill-opacity': 0.1 },
},
{
id: 'gl-draw-polygon-stroke-static',
type: 'line',
filter: ['all', ['==', 'mode', 'static'], ['==', '$type', 'Polygon']],
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': '#404040', 'line-width': 2 },
},
{
id: 'gl-draw-line-static',
type: 'line',
filter: ['all', ['==', 'mode', 'static'], ['==', '$type', 'LineString']],
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': '#404040', 'line-width': 2 },
},
{
id: 'gl-draw-point-static',
type: 'circle',
filter: ['all', ['==', 'mode', 'static'], ['==', '$type', 'Point']],
paint: { 'circle-radius': 5, 'circle-color': '#404040' },
},
];
export function fixMapboxDrawCompatibility() {
MapboxDraw.constants.classes.CANVAS = 'maplibregl-canvas';
MapboxDraw.constants.classes.CONTROL_BASE = 'maplibregl-ctrl';
MapboxDraw.constants.classes.CONTROL_PREFIX = 'maplibregl-ctrl-';
MapboxDraw.constants.classes.CONTROL_GROUP = 'maplibregl-ctrl-group';
MapboxDraw.constants.classes.ATTRIBUTION = 'maplibregl-ctrl-attrib';
}
export function addDrawingControl(map) {
const draw = new MapboxDraw({
displayControlsDefault: false,
controls: {
polygon: true,
trash: true,
},
styles: drawStyles,
});
map.addControl(draw, 'top-left');
return draw;
}
export function setupAreaFilterEventListeners(map, draw, onDrawingChange) {
if (!map || !draw) return () => {};
const handleDrawChange = () => {
if (draw) {
const data = draw.getAll();
if (onDrawingChange) {
onDrawingChange(data);
}
}
};
map.on('draw.create', handleDrawChange);
map.on('draw.update', handleDrawChange);
map.on('draw.delete', handleDrawChange);
// Return cleanup function
return () => {
if (map) {
map.off('draw.create', handleDrawChange);
map.off('draw.update', handleDrawChange);
map.off('draw.delete', handleDrawChange);
}
};
}

View File

@@ -3,7 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { Button, Nav } from '@douyinfe/semi-ui-19';
import { IconStar, IconSetting, IconTerminal, IconHistogram, IconSidebar } from '@douyinfe/semi-icons';
import logoWhite from '../../assets/logo_white.png';

View File

@@ -0,0 +1,70 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { UserGuide } from '@douyinfe/semi-ui-19';
import { useScreenWidth } from '../../hooks/screenWidth';
import heart from '../../assets/heart.png';
import newsConfig from '../../assets/news/news.json';
import { useActions, useSelector } from '../../services/state/store';
import './NewsModal.less';
const newsImages = import.meta.glob('../../assets/news/*', { eager: true, query: '?url', import: 'default' });
const NewsModal = () => {
const screenWidth = useScreenWidth();
const newsHash = useSelector((state) => state.userSettings.settings.news_hash);
const userSettingsLoaded = useSelector((state) => state.userSettings.loaded);
const pois = useSelector((state) => state.tracking.pois);
const actions = useActions();
if (newsConfig == null || newsConfig.length === 0 || screenWidth <= 768) {
return null;
}
const steps = newsConfig.content.map((item) => ({
title: (
<div style={{ display: 'flex', alignItems: 'center' }}>
<img src={heart} width="30" alt="Fredy Logo" style={{ marginRight: '10px' }} />
<b>{item.title}</b>
</div>
),
description: (
<div style={{ textAlign: 'left' }}>
{item.image && newsImages[`../../assets/news/${item.image}`] && (
<img
src={newsImages[`../../assets/news/${item.image}`]}
alt={item.title}
style={{ width: '100%', marginBottom: 10, borderRadius: 4 }}
/>
)}
<p dangerouslySetInnerHTML={{ __html: item.text }} />
</div>
),
}));
const handleClose = (poi) => {
actions.userSettings.setNewsHash(newsConfig.key);
if (poi) {
actions.tracking.trackPoi(poi);
}
};
return (
<UserGuide
mode="modal"
mask={true}
steps={steps}
visible={userSettingsLoaded && newsHash !== newsConfig.key}
onFinish={() => handleClose(pois.WELCOME_FINISHED)}
onSkip={() => handleClose(pois.WELCOME_SKIPPED)}
modalProps={{
width: '10rem',
}}
/>
);
};
export default NewsModal;

View File

@@ -0,0 +1,3 @@
.semi-userGuide-modal-body-title {
width: 100%;
}

View File

@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import insufficientPermission from '../../assets/insufficient_permission.png';
export default function InsufficientPermission() {

View File

@@ -3,8 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import { Navigate } from 'react-router-dom';
export default function PermissionAwareRoute({ currentUser, children }) {

View File

@@ -3,8 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import './Placeholder.less';
function getPlaceholder(rowCount, className) {

View File

@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import { Card } from '@douyinfe/semi-ui-19';
import './SegmentParts.less';

View File

@@ -3,8 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import { Empty, Table, Button } from '@douyinfe/semi-ui-19';
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';

View File

@@ -3,8 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import { Empty, Table, Button } from '@douyinfe/semi-ui-19';
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
import { Typography } from '@douyinfe/semi-ui';

View File

@@ -3,8 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
import { format } from '../../services/time/timeService';
import { Table, Button, Empty } from '@douyinfe/semi-ui-19';

View File

@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import { Modal } from '@douyinfe/semi-ui-19';
import Logo from '../logo/Logo.jsx';
import { xhrPost } from '../../services/xhr.js';

View File

@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import { Collapse, Descriptions } from '@douyinfe/semi-ui-19';
import { useSelector } from '../../services/state/store.js';
import { MarkdownRender } from '@douyinfe/semi-ui-19';

View File

@@ -8,7 +8,7 @@
*/
import { create } from 'zustand';
import { shallow } from 'zustand/shallow';
import { xhrGet } from '../xhr.js';
import { xhrGet, xhrPost } from '../xhr.js';
import queryString from 'query-string';
const logger = (config) => (set, get, api) =>
@@ -27,10 +27,21 @@ const logger = (config) => (set, get, api) =>
api,
);
/**
* Middleware to track loading state of async actions.
*/
const loadingTracker = (config) => (set, get, api) => {
const wrappedSet = (partial, replace) => {
set(partial, replace);
};
return config(wrappedSet, get, api);
};
// Create the Zustand store with slices and actions
export const useFredyState = create(
logger(
(set) => {
loadingTracker((set) => {
// Async actions that directly set state (no separate reducer concept)
const effects = {
dashboard: {
@@ -169,6 +180,23 @@ export const useFredyState = create(
}
},
},
tracking: {
async getTrackingPois() {
try {
const response = await xhrGet('/api/tracking/trackingPois');
set((state) => ({ tracking: { ...state.tracking, pois: Object.freeze(response.json) } }));
} catch (Exception) {
console.error('Error while trying to get resource for api/tracking. Error:', Exception);
}
},
async trackPoi(poi) {
try {
await xhrPost('/api/tracking/poi', { poi });
} catch (Exception) {
console.error('Error while trying to track poi. Error:', Exception);
}
},
},
listingsData: {
async getListingsData({
page = 1,
@@ -234,9 +262,60 @@ export const useFredyState = create(
async getUserSettings() {
try {
const response = await xhrGet('/api/user/settings');
set((state) => ({ userSettings: { ...state.userSettings, settings: response.json } }));
set((state) => ({ userSettings: { ...state.userSettings, settings: response.json, loaded: true } }));
} catch (Exception) {
console.error('Error while trying to get resource for api/user/settings. Error:', Exception);
// Mark as loaded even on error to prevent blocking the UI
set((state) => ({ userSettings: { ...state.userSettings, loaded: true } }));
}
},
async setNewsHash(newsHash) {
try {
await xhrPost('/api/user/settings/news-hash', { news_hash: newsHash });
set((state) => ({
userSettings: {
...state.userSettings,
settings: { ...state.userSettings.settings, news_hash: newsHash },
},
}));
} catch (Exception) {
console.error('Error while trying to update news hash. Error:', Exception);
throw Exception;
}
},
async setHomeAddress(address) {
try {
const response = await xhrPost('/api/user/settings/home-address', { home_address: address });
if (response.status === 200) {
set((state) => ({
userSettings: {
...state.userSettings,
settings: {
...state.userSettings.settings,
home_address: { address, coords: response.json.coords },
},
},
}));
return response.json;
}
throw response;
} catch (Exception) {
console.error('Error while trying to update home address. Error:', Exception);
throw Exception;
}
},
async setImmoscoutDetails(enabled) {
try {
await xhrPost('/api/user/settings/immoscout-details', { immoscout_details: enabled });
set((state) => ({
userSettings: {
...state.userSettings,
settings: { ...state.userSettings.settings, immoscout_details: enabled },
},
}));
} catch (Exception) {
console.error('Error while trying to update immoscout details setting. Error:', Exception);
throw Exception;
}
},
},
@@ -255,9 +334,10 @@ export const useFredyState = create(
maxPrice: 0,
},
generalSettings: { settings: {} },
userSettings: { settings: {} },
userSettings: { settings: {}, loaded: false },
demoMode: { demoMode: false },
versionUpdate: {},
tracking: { pois: {} },
provider: [],
jobsData: {
jobs: [],
@@ -276,6 +356,7 @@ export const useFredyState = create(
generalSettings: { ...effects.generalSettings },
demoMode: { ...effects.demoMode },
versionUpdate: { ...effects.versionUpdate },
tracking: { ...effects.tracking },
listingsData: { ...effects.listingsData },
provider: { ...effects.provider },
jobsData: { ...effects.jobsData },
@@ -283,12 +364,34 @@ export const useFredyState = create(
userSettings: { ...effects.userSettings },
};
// Wrap actions to track loading state
const wrappedActions = {};
Object.keys(actions).forEach((slice) => {
wrappedActions[slice] = {};
Object.keys(actions[slice]).forEach((actionName) => {
const originalAction = actions[slice][actionName];
if (typeof originalAction === 'function') {
wrappedActions[slice][actionName] = async (...args) => {
const fullActionName = `${slice}.${actionName}`;
set((state) => ({ loading: { ...state.loading, [fullActionName]: true } }));
try {
return await originalAction(...args);
} finally {
set((state) => ({ loading: { ...state.loading, [fullActionName]: false } }));
}
};
} else {
wrappedActions[slice][actionName] = originalAction;
}
});
});
return {
...initial,
__actions: { actions },
loading: {},
__actions: { actions: wrappedActions },
};
},
{ name: 'fredy' },
}),
),
);
@@ -312,3 +415,27 @@ export function useSelector(selector, equalityFn = shallow) {
export function useActions() {
return useFredyState((s) => s.__actions.actions);
}
/**
* Hook to check if a specific action is currently loading.
* @param {Function} action - The action function from useActions()
* @returns {boolean}
*/
export function useIsLoading(action) {
const actions = useActions();
const loading = useSelector((state) => state.loading);
// Find the action name by comparing the function
let actionPath = null;
for (const slice in actions) {
for (const name in actions[slice]) {
if (actions[slice][name] === action) {
actionPath = `${slice}.${name}`;
break;
}
}
if (actionPath) break;
}
return !!loading[actionPath];
}

View File

@@ -3,8 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import JobGrid from '../../components/grid/jobs/JobGrid.jsx';
import './Jobs.less';

View File

@@ -3,12 +3,13 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React, { Fragment, useState } from 'react';
import { Fragment, useState, useCallback } from 'react';
import NotificationAdapterMutator from './components/notificationAdapter/NotificationAdapterMutator';
import NotificationAdapterTable from '../../../components/table/NotificationAdapterTable';
import ProviderTable from '../../../components/table/ProviderTable';
import ProviderMutator from './components/provider/ProviderMutator';
import AreaFilter from './components/areaFilter/AreaFilter';
import Headline from '../../../components/headline/Headline';
import { useActions, useSelector } from '../../../services/state/store';
import { xhrPost } from '../../../services/xhr';
@@ -44,6 +45,7 @@ export default function JobMutator() {
const defaultNotificationAdapter = sourceJob?.notificationAdapter || [];
const defaultEnabled = sourceJob?.enabled ?? true;
const defaultShareWithUsers = sourceJob?.shared_with_user ?? [];
const defaultSpatialFilter = sourceJob?.spatialFilter || null;
const [providerToEdit, setProviderToEdit] = useState(null);
const [providerCreationVisible, setProviderCreationVisibility] = useState(false);
@@ -55,9 +57,15 @@ export default function JobMutator() {
const [notificationAdapterData, setNotificationAdapterData] = useState(defaultNotificationAdapter);
const [shareWithUsers, setShareWithUsers] = useState(defaultShareWithUsers);
const [enabled, setEnabled] = useState(defaultEnabled);
const [spatialFilter, setSpatialFilter] = useState(defaultSpatialFilter);
const navigate = useNavigate();
const actions = useActions();
// Memoize the spatial filter change handler to prevent map reinitializations
const handleSpatialFilterChange = useCallback((data) => {
setSpatialFilter(data);
}, []);
const isSavingEnabled = () => {
return Boolean(notificationAdapterData.length && providerData.length && name);
};
@@ -76,6 +84,7 @@ export default function JobMutator() {
shareWithUsers,
name,
blacklist,
spatialFilter,
enabled,
jobId: jobToBeEdit?.id || null,
});
@@ -206,6 +215,13 @@ export default function JobMutator() {
/>
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart
name="Area Filter"
helpText="Define multiple geographic areas on the map to filter listings. Start drawing by clicking on the square symbol in the top left corner of the map. Click on the map to add points of the polygon. Select the first point to close the polygon. After that, click on a free area of the map to apply this polygon (the color will change from yellow to blue). To delete a polygon, select it first and then click on the trash symbol."
>
<AreaFilter spatialFilter={spatialFilter} onChange={handleSpatialFilterChange} />
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart
Icon={IconUser}
name="Sharing with user"

View File

@@ -0,0 +1,21 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import Map from '../../../../../components/map/Map.jsx';
import './AreaFilter.less';
export default function AreaFilter({ spatialFilter = null, onChange = null }) {
return (
<div className="areaFilter">
<Map
style="STANDARD"
show3dBuildings={false}
enableDrawing={true}
initialSpatialFilter={spatialFilter}
onDrawingChange={onChange}
/>
</div>
);
}

View File

@@ -0,0 +1,8 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
.areaFilter {
height: 50rem;
}

View File

@@ -3,7 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React, { useState } from 'react';
import { useState } from 'react';
import { transform } from '../../../../../services/transformer/notificationAdapterTransformer';
import { xhrPost } from '../../../../../services/xhr';
@@ -27,10 +27,13 @@ const sortAdapter = (a, b) => {
const validate = (selectedAdapter) => {
const results = [];
for (let uiElement of Object.values(selectedAdapter.fields || [])) {
if (uiElement.value == null && !uiElement.optional) {
if (uiElement.value == null && !uiElement.optional && uiElement.type !== 'boolean') {
results.push('All fields are mandatory and must be set.');
continue;
}
if (uiElement.type === 'boolean' && typeof uiElement.value !== 'boolean') {
uiElement.value = false;
}
if (uiElement.type === 'number') {
const numberValue = parseFloat(uiElement.value);
if (isNaN(numberValue) || numberValue < 0) {
@@ -153,12 +156,15 @@ export default function NotificationAdapterMutator({
return (
<Form key={key}>
{uiElement.type === 'boolean' ? (
<Switch
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<Switch
checked={uiElement.value || false}
onChange={(checked) => {
setValue(selectedAdapter, uiElement, key, checked);
}}
/>
{uiElement.label}
</div>
) : (
<Form.Input
style={{ width: '100%' }}
@@ -197,27 +203,6 @@ export default function NotificationAdapterMutator({
</div>
}
>
{validationMessage != null && (
<Banner
fullMode={false}
type="danger"
closeIcon={null}
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Error</div>}
style={{ marginBottom: '1rem' }}
description={<p dangerouslySetInnerHTML={{ __html: validationMessage }} />}
/>
)}
{successMessage != null && (
<Banner
fullMode={false}
type="success"
closeIcon={null}
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Yay!</div>}
style={{ marginBottom: '1rem' }}
description={<p dangerouslySetInnerHTML={{ __html: successMessage }} />}
/>
)}
{description != null ? (
<p>{description}</p>
) : (
@@ -264,6 +249,28 @@ export default function NotificationAdapterMutator({
<br />
{selectedAdapter.readme != null && <Help readme={selectedAdapter.readme} />}
<br />
{validationMessage != null && (
<Banner
fullMode={false}
type="danger"
closeIcon={null}
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Error</div>}
style={{ marginBottom: '1rem' }}
description={<p dangerouslySetInnerHTML={{ __html: validationMessage }} />}
/>
)}
{successMessage != null && (
<Banner
fullMode={false}
type="success"
closeIcon={null}
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Yay!</div>}
style={{ marginBottom: '1rem' }}
description={<p dangerouslySetInnerHTML={{ __html: successMessage }} />}
/>
)}
{getFieldsFor(selectedAdapter)}
</>
)}

View File

@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import { Banner, MarkdownRender } from '@douyinfe/semi-ui-19';
export default function Help({ readme }) {

View File

@@ -3,7 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React, { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import { Banner, Modal, Select, Input } from '@douyinfe/semi-ui-19';
import { transform } from '../../../../../services/transformer/providerTransformer';

View File

@@ -3,7 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React, { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useSelector, useActions } from '../../services/state/store.js';
import {

View File

@@ -3,8 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import ListingsGrid from '../../components/grid/listings/ListingsGrid.jsx';
export default function Listings() {

View File

@@ -3,7 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React, { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { renderToString } from 'react-dom/server';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
@@ -20,54 +20,10 @@ import './Map.less';
import { xhrDelete } from '../../services/xhr.js';
import { Link, useNavigate } from 'react-router-dom';
import ListingDeletionModal from '../../components/ListingDeletionModal.jsx';
import Map from '../../components/map/Map.jsx';
const { Text } = Typography;
const GERMANY_BOUNDS = [
[5.866, 47.27], // Southwest coordinates
[15.042, 55.059], // Northeast coordinates
];
const STYLES = {
STANDARD: 'https://tiles.openfreemap.org/styles/bright',
SATELLITE: {
version: 8,
sources: {
'satellite-tiles': {
type: 'raster',
tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'],
tileSize: 256,
attribution:
'Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community',
},
'satellite-labels': {
type: 'raster',
tiles: [
'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}',
],
tileSize: 256,
attribution: '© Esri',
},
},
layers: [
{
id: 'satellite-tiles',
type: 'raster',
source: 'satellite-tiles',
minzoom: 0,
maxzoom: 19,
},
{
id: 'satellite-labels',
type: 'raster',
source: 'satellite-labels',
minzoom: 0,
maxzoom: 19,
},
],
},
};
export default function MapView() {
const mapContainer = useRef(null);
const map = useRef(null);
@@ -136,117 +92,24 @@ export default function MapView() {
};
}, [navigate]);
// Get map instance reference after MapComponent renders
useEffect(() => {
if (map.current) return;
map.current = new maplibregl.Map({
container: mapContainer.current,
style: STYLES[style],
center: [10.4515, 51.1657], // Center of Germany
zoom: 4,
maxBounds: GERMANY_BOUNDS,
antialias: true,
});
map.current.addControl(
new maplibregl.NavigationControl({
showCompass: true,
visualizePitch: true,
visualizeRoll: true,
}),
'top-right',
);
map.current.addControl(
new maplibregl.GeolocateControl({
positionOptions: {
enableHighAccuracy: true,
},
trackUserLocation: true,
}),
);
return () => {
map.current.remove();
};
if (mapContainer.current && !map.current) {
// Wait for MapComponent to initialize the map
const checkMapReady = () => {
if (mapContainer.current?.map) {
map.current = mapContainer.current.map;
} else {
setTimeout(checkMapReady, 100);
}
};
checkMapReady();
}
}, []);
useEffect(() => {
if (map.current) {
map.current.setStyle(STYLES[style]);
}
}, [style]);
useEffect(() => {
if (show3dBuildings && style !== 'STANDARD') {
setStyle('STANDARD');
}
}, [show3dBuildings, style]);
useEffect(() => {
if (!map.current) return;
map.current.setPitch(show3dBuildings ? 45 : 0);
}, [show3dBuildings]);
useEffect(() => {
if (!map.current) return;
const add3dLayer = () => {
if (!map.current || !map.current.isStyleLoaded()) return;
if (show3dBuildings) {
if (!map.current.getSource('openfreemap')) {
map.current.addSource('openfreemap', {
type: 'vector',
url: 'https://tiles.openfreemap.org/planet',
});
}
if (!map.current.getLayer('3d-buildings')) {
const layers = map.current.getStyle().layers;
let labelLayerId;
for (let i = 0; i < layers.length; i++) {
if (layers[i].type === 'symbol' && layers[i].layout?.['text-field']) {
labelLayerId = layers[i].id;
break;
}
}
map.current.addLayer(
{
id: '3d-buildings',
source: 'openfreemap',
'source-layer': 'building',
type: 'fill-extrusion',
minzoom: 15,
filter: ['!=', ['get', 'hide_3d'], true],
paint: {
'fill-extrusion-color': [
'interpolate',
['linear'],
['get', 'render_height'],
0,
'lightgray',
200,
'royalblue',
400,
'lightblue',
],
'fill-extrusion-height': ['interpolate', ['linear'], ['zoom'], 15, 0, 16, ['get', 'render_height']],
'fill-extrusion-base': ['case', ['>=', ['get', 'zoom'], 16], ['get', 'render_min_height'], 0],
'fill-extrusion-opacity': 0.6,
},
},
labelLayerId,
);
}
} else {
if (map.current.getLayer('3d-buildings')) {
map.current.removeLayer('3d-buildings');
}
}
};
add3dLayer();
}, [show3dBuildings, style]);
const handleMapReady = (mapInstance) => {
map.current = mapInstance;
};
const setMapStyle = (value) => {
setStyle(value);
@@ -573,7 +436,7 @@ export default function MapView() {
description="Keep in mind, only listings with proper adresses are being shown on this map."
/>
<div ref={mapContainer} className="map-container" />
<Map mapContainerRef={mapContainer} style={style} show3dBuildings={show3dBuildings} onMapReady={handleMapReady} />
<ListingDeletionModal
visible={deleteModalVisible}
onConfirm={confirmListingDeletion}

View File

@@ -3,7 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React, { useState } from 'react';
import { useState } from 'react';
import { IconHorn } from '@douyinfe/semi-icons';
import { SegmentPart } from '../../../components/segment/SegmentPart.jsx';
import { Banner, Button, Checkbox, Space } from '@douyinfe/semi-ui-19';

View File

@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import { Modal } from '@douyinfe/semi-ui-19';
const UserRemovalModal = function UserRemovalModal({ onOk, onCancel }) {
return (

View File

@@ -3,20 +3,21 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React, { useEffect, useState, useMemo } from 'react';
import { Divider, Button, AutoComplete, Toast, Banner } from '@douyinfe/semi-ui-19';
import { IconSave, IconHome } from '@douyinfe/semi-icons';
import { useSelector, useActions } from '../../services/state/store';
import { xhrGet, xhrPost } from '../../services/xhr';
import { useEffect, useState, useMemo } from 'react';
import { Divider, Button, AutoComplete, Toast, Banner, Switch } from '@douyinfe/semi-ui-19';
import { IconSave, IconHome, IconSearch } from '@douyinfe/semi-icons';
import { useSelector, useActions, useIsLoading } from '../../services/state/store';
import { xhrGet } from '../../services/xhr';
import { SegmentPart } from '../../components/segment/SegmentPart';
import debounce from 'lodash/debounce';
const UserSettings = () => {
const actions = useActions();
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
const immoscoutDetails = useSelector((state) => state.userSettings.settings.immoscout_details);
const [address, setAddress] = useState(homeAddress?.address || '');
const [coords, setCoords] = useState(homeAddress?.coords || null);
const [saving, setSaving] = useState(false);
const saving = useIsLoading(actions.userSettings.setHomeAddress);
const [dataSource, setDataSource] = useState([]);
useEffect(() => {
@@ -25,22 +26,15 @@ const UserSettings = () => {
}, [homeAddress]);
const handleSave = async () => {
setSaving(true);
try {
const response = await xhrPost('/api/user/settings/home-address', { home_address: address });
if (response.status === 200) {
setCoords(response.json.coords);
await actions.userSettings.getUserSettings();
Toast.success(
'Settings saved successfully. We will now start calculating distances for you. This may take a while and runs in the background.',
);
} else {
Toast.error(response.json.error || 'Failed to save settings');
}
const responseJson = await actions.userSettings.setHomeAddress(address);
setCoords(responseJson.coords);
await actions.userSettings.getUserSettings();
Toast.success(
'Settings saved successfully. We will now start calculating distances for you. This may take a while and runs in the background.',
);
} catch (error) {
Toast.error(error.json?.error || 'Error while saving settings');
} finally {
setSaving(false);
}
};
@@ -91,6 +85,33 @@ const UserSettings = () => {
</div>
</SegmentPart>
<Divider />
<SegmentPart
name="ImmoScout Details"
Icon={IconSearch}
helpText="When enabled, Fredy will fetch additional details (description, attributes, agent info) for each listing from ImmoScout. This provides richer notifications but makes an extra API call per listing."
>
<Banner
type="warning"
description="Enabling this feature significantly increases the number of API requests to ImmoScout. This raises the likelihood of being detected and rate-limited or blocked. Use at your own risk."
closeIcon={null}
style={{ marginBottom: '12px', maxWidth: '600px' }}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Switch
checked={!!immoscoutDetails}
onChange={async (checked) => {
try {
await actions.userSettings.setImmoscoutDetails(checked);
Toast.success('ImmoScout details setting updated.');
} catch {
Toast.error('Failed to update setting.');
}
}}
/>
<span>Fetch detailed ImmoScout listings</span>
</div>
</SegmentPart>
<Divider />
<div style={{ marginTop: '20px' }}>
<Button icon={<IconSave />} theme="solid" type="primary" onClick={handleSave} loading={saving}>
Save Settings

1083
yarn.lock

File diff suppressed because it is too large Load Diff