├── .nvmrc ├── db └── .gitkeep ├── .husky └── pre-commit ├── conf └── config.json ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature.yml │ └── bug.yml ├── FUNDING.yml └── workflows │ ├── test.yml │ ├── check_source.yml │ └── stales.yml ├── .prettierrc ├── doc ├── logo.png ├── logo.psd ├── jetbrains.png ├── logo_white.png ├── screenshot1.png ├── screenshot2.png ├── screenshot3.png └── unraid_fredy_logo.png ├── ui └── src │ ├── views │ ├── user │ │ ├── mutation │ │ │ └── UserMutator.less │ │ ├── Users.less │ │ ├── UserRemovalModal.jsx │ │ └── Users.jsx │ ├── jobs │ │ ├── mutation │ │ │ ├── components │ │ │ │ ├── provider │ │ │ │ │ └── ProviderMutator.less │ │ │ │ └── notificationAdapter │ │ │ │ │ ├── NotificationAdapterMutator.less │ │ │ │ │ └── NotificationHelpDisplay.jsx │ │ │ └── JobMutation.less │ │ ├── Jobs.less │ │ └── Jobs.jsx │ ├── generalSettings │ │ └── GeneralSettings.less │ ├── dashboard │ │ └── Dashboard.less │ ├── listings │ │ ├── Listings.jsx │ │ └── management │ │ │ └── WatchlistManagement.jsx │ └── login │ │ └── login.less │ ├── assets │ ├── logo.png │ ├── heart.png │ ├── no_image.jpg │ ├── logo_white.png │ ├── city_background.jpg │ └── insufficient_permission.png │ ├── components │ ├── logo │ │ ├── Logo.less │ │ └── Logo.jsx │ ├── tracking │ │ ├── TrackingModal.less │ │ └── TrackingModal.jsx │ ├── version │ │ ├── VersionBanner.less │ │ └── VersionBanner.jsx │ ├── segment │ │ ├── SegmentParts.less │ │ └── SegmentPart.jsx │ ├── navigation │ │ ├── Navigate.less │ │ └── Navigation.jsx │ ├── table │ │ ├── JobTable.less │ │ ├── listings │ │ │ └── ListingsTable.less │ │ ├── NotificationAdapterTable.jsx │ │ ├── ProviderTable.jsx │ │ └── UserTable.jsx │ ├── footer │ │ ├── FredyFooter.less │ │ └── FredyFooter.jsx │ ├── headline │ │ └── Headline.jsx │ ├── permission │ │ ├── PermissionAwareRoute.jsx │ │ └── InsufficientPermission.jsx │ ├── cards │ │ ├── DashboardCardColors.less │ │ ├── ChartCard.less │ │ ├── KpiCard.jsx │ │ ├── DashboardCard.less │ │ └── PieChartCard.jsx │ ├── placeholder │ │ ├── Placeholder.less │ │ └── Placeholder.jsx │ └── logout │ │ └── Logout.jsx │ ├── Index.less │ ├── services │ ├── developmentMode.js │ ├── transformer │ │ ├── providerTransformer.js │ │ └── notificationAdapterTransformer.js │ └── time │ │ └── timeService.js │ ├── hooks │ ├── featureHook.js │ └── screenWidth.js │ ├── Index.jsx │ └── App.less ├── .gitignore ├── test ├── esmock-loader.mjs ├── mocks │ ├── mockNotification.js │ └── mockStore.js ├── utils │ └── utils.test.js ├── utils.js ├── services │ └── immoscout │ │ └── testdata.json ├── provider │ ├── wgGesucht.test.js │ ├── immonet.test.js │ ├── mcMakler.test.js │ ├── sparkasse.test.js │ ├── ohneMakler.test.js │ ├── neubauKompass.test.js │ ├── kleinanzeigen.test.js │ ├── immoswp.test.js │ ├── immowelt.test.js │ ├── regionalimmobilien24.test.js │ ├── immoscout.test.js │ ├── einsAImmobilien.test.js │ ├── immobilienDe.test.js │ ├── utils.test.js │ └── testProvider.json ├── queryStringMutator │ ├── queryStringMutator.test.js │ └── testData.json └── similarity │ └── similarityCache.test.js ├── .prettierignore ├── lib ├── notification │ ├── adapter │ │ ├── slack.md │ │ ├── console.md │ │ ├── ntfy.md │ │ ├── pushover.md │ │ ├── mattermost.md │ │ ├── apprise.md │ │ ├── discord_webhook.md │ │ ├── mailJet.md │ │ ├── slack_with_webhooks.md │ │ ├── sqlite.md │ │ ├── sendGrid.md │ │ ├── console.js │ │ ├── http.md │ │ ├── apprise.js │ │ ├── mattermost.js │ │ ├── http.js │ │ ├── sqlite.js │ │ ├── slack.js │ │ ├── slack_with_webhooks.js │ │ ├── sendGrid.js │ │ ├── telegram.md │ │ ├── ntfy.js │ │ └── pushover.js │ └── notify.js ├── services │ ├── events │ │ └── event-bus.js │ ├── security │ │ └── hash.js │ ├── markdown.js │ ├── storage │ │ ├── migrations │ │ │ └── sql │ │ │ │ ├── 5.job-sharing.js │ │ │ │ ├── 3.changeset-for-listings.js │ │ │ │ ├── 2.active-flag-for-listings.js │ │ │ │ ├── 2.new-indexes-for-listings.js │ │ │ │ ├── 0.init.js │ │ │ │ ├── 4.watch-list.js │ │ │ │ └── 6.settings.js │ │ └── watchListStorage.js │ ├── crons │ │ ├── listing-alive-cron.js │ │ ├── tracker-cron.js │ │ └── demoCleanup-cron.js │ ├── queryStringMutator.js │ ├── tracking │ │ ├── uniqueId.js │ │ └── Tracker.js │ ├── extractor │ │ ├── utils.js │ │ ├── extractor.js │ │ └── parser │ │ │ └── parser.js │ ├── logger.js │ └── listings │ │ └── listingActiveTester.js ├── defaultConfig.js ├── features.js ├── api │ ├── routes │ │ ├── featureRouter.js │ │ ├── demoRouter.js │ │ ├── providerRouter.js │ │ ├── versionRouter.js │ │ ├── generalSettingsRoute.js │ │ ├── loginRoute.js │ │ ├── notificationAdapterRouter.js │ │ ├── backupRouter.js │ │ ├── dashboardRouter.js │ │ └── userRoute.js │ ├── security.js │ └── api.js ├── errors.js └── provider │ ├── ohneMakler.js │ ├── neubauKompass.js │ ├── wgGesucht.js │ ├── sparkasse.js │ ├── mcMakler.js │ ├── immoswp.js │ ├── regionalimmobilien24.js │ ├── immowelt.js │ ├── immonet.js │ ├── kleinanzeigen.js │ ├── immobilienDe.js │ └── einsAImmobilien.js ├── .babelrc ├── docker-test.sh ├── docker-compose.yml ├── vite.config.js ├── index.html ├── .dockerignore ├── copyright.js ├── Dockerfile └── CHANGELOG.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 16.14.0 -------------------------------------------------------------------------------- /db/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged -------------------------------------------------------------------------------- /conf/config.json: -------------------------------------------------------------------------------- 1 | {"sqlitepath":"/db"} -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 120 4 | } 5 | -------------------------------------------------------------------------------- /doc/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orangecoding/fredy/HEAD/doc/logo.png -------------------------------------------------------------------------------- /doc/logo.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orangecoding/fredy/HEAD/doc/logo.psd -------------------------------------------------------------------------------- /doc/jetbrains.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orangecoding/fredy/HEAD/doc/jetbrains.png -------------------------------------------------------------------------------- /doc/logo_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orangecoding/fredy/HEAD/doc/logo_white.png -------------------------------------------------------------------------------- /doc/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orangecoding/fredy/HEAD/doc/screenshot1.png -------------------------------------------------------------------------------- /doc/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orangecoding/fredy/HEAD/doc/screenshot2.png -------------------------------------------------------------------------------- /doc/screenshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orangecoding/fredy/HEAD/doc/screenshot3.png -------------------------------------------------------------------------------- /ui/src/views/user/mutation/UserMutator.less: -------------------------------------------------------------------------------- 1 | .userMutator { 2 | margin-top: 2rem; 3 | } 4 | -------------------------------------------------------------------------------- /ui/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orangecoding/fredy/HEAD/ui/src/assets/logo.png -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [orangecoding] 4 | -------------------------------------------------------------------------------- /ui/src/assets/heart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orangecoding/fredy/HEAD/ui/src/assets/heart.png -------------------------------------------------------------------------------- /doc/unraid_fredy_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orangecoding/fredy/HEAD/doc/unraid_fredy_logo.png -------------------------------------------------------------------------------- /ui/src/assets/no_image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orangecoding/fredy/HEAD/ui/src/assets/no_image.jpg -------------------------------------------------------------------------------- /ui/src/assets/logo_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orangecoding/fredy/HEAD/ui/src/assets/logo_white.png -------------------------------------------------------------------------------- /ui/src/components/logo/Logo.less: -------------------------------------------------------------------------------- 1 | .logo { 2 | position: absolute; 3 | top: 0.1rem; 4 | right: 2rem; 5 | } 6 | -------------------------------------------------------------------------------- /ui/src/assets/city_background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orangecoding/fredy/HEAD/ui/src/assets/city_background.jpg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | ui/public/ 3 | db/*.json 4 | db/*.db* 5 | npm-debug.log 6 | .DS_Store 7 | .idea 8 | .vscode 9 | -------------------------------------------------------------------------------- /ui/src/components/tracking/TrackingModal.less: -------------------------------------------------------------------------------- 1 | .trackingModal { 2 | &__description { 3 | margin-top: 10rem; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /ui/src/assets/insufficient_permission.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orangecoding/fredy/HEAD/ui/src/assets/insufficient_permission.png -------------------------------------------------------------------------------- /test/esmock-loader.mjs: -------------------------------------------------------------------------------- 1 | import { register } from 'node:module'; 2 | import { pathToFileURL } from 'node:url'; 3 | 4 | register('esmock', pathToFileURL('./')); -------------------------------------------------------------------------------- /ui/src/views/jobs/mutation/components/provider/ProviderMutator.less: -------------------------------------------------------------------------------- 1 | .providerMutator { 2 | &__fields { 3 | width: 25rem !important; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /ui/public 2 | /db/ 3 | /conf/ 4 | 5 | # TODO re-write from scratch or fix all html structure issues 6 | /lib/notification/emailTemplate/template.hbs -------------------------------------------------------------------------------- /ui/src/components/version/VersionBanner.less: -------------------------------------------------------------------------------- 1 | .versionBanner { 2 | background: rgba(var(--semi-teal-1), 1); 3 | 4 | &__content { 5 | overflow: auto; 6 | } 7 | } -------------------------------------------------------------------------------- /ui/src/views/jobs/Jobs.less: -------------------------------------------------------------------------------- 1 | .jobs { 2 | &__newButton { 3 | margin-top: 1rem !important; 4 | float: left; 5 | margin-bottom: 1rem !important; 6 | margin-left: 1rem; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /ui/src/views/user/Users.less: -------------------------------------------------------------------------------- 1 | .users { 2 | &__newButton { 3 | margin-top: 1rem !important; 4 | float: left; 5 | margin-bottom: 1rem !important; 6 | margin-left: 1rem; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /lib/notification/adapter/slack.md: -------------------------------------------------------------------------------- 1 | ### Slack Adapter (Legacy) 2 | 3 | *IMPORTANT:* 4 | This legacy adapter is outdated and kept only for backward compatibility. Please use the Slack adapter with webhooks instead. 5 | 6 | -------------------------------------------------------------------------------- /ui/src/views/jobs/mutation/JobMutation.less: -------------------------------------------------------------------------------- 1 | .jobMutation { 2 | &__newButton { 3 | float: right; 4 | margin-bottom: 1rem; 5 | } 6 | } 7 | 8 | .semi-select-option-list-wrapper { 9 | width: 25rem; 10 | } 11 | -------------------------------------------------------------------------------- /ui/src/components/segment/SegmentParts.less: -------------------------------------------------------------------------------- 1 | .segmentParts { 2 | border: 1px solid #323232 !important; 3 | border-radius: .9rem !important; 4 | color: rgba(var(--semi-grey-8), 1); 5 | background: rgb(53, 54, 60); 6 | margin: 2rem; 7 | } 8 | -------------------------------------------------------------------------------- /ui/src/components/navigation/Navigate.less: -------------------------------------------------------------------------------- 1 | .navigate { 2 | &__footer { 3 | align-items: center; 4 | justify-content: center; 5 | flex-direction: column; 6 | gap: 0.5rem; 7 | width: 100%; 8 | display: flex; 9 | } 10 | } -------------------------------------------------------------------------------- /lib/services/events/event-bus.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import { EventEmitter } from 'node:events'; 7 | export const bus = new EventEmitter(); 8 | -------------------------------------------------------------------------------- /lib/notification/adapter/console.md: -------------------------------------------------------------------------------- 1 | ### Console Adapter 2 | 3 | The console adapter prints everything found by Fredy to the console (it does not send notifications). This is useful to verify that your search criteria work as expected before enabling a real notification service. 4 | -------------------------------------------------------------------------------- /ui/src/views/generalSettings/GeneralSettings.less: -------------------------------------------------------------------------------- 1 | .generalSettings { 2 | &__timePickerContainer { 3 | display: flex; 4 | align-items: baseline; 5 | gap: 1rem; 6 | } 7 | 8 | &__help { 9 | font-size: 11px; 10 | margin-left: 1rem; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "exclude": ["transform-regenerator"] 7 | } 8 | ], 9 | [ 10 | "@babel/preset-react", 11 | { 12 | "runtime": "automatic" 13 | } 14 | ] 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /lib/services/security/hash.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import crypto from 'crypto'; 7 | export const hash = (x) => crypto.createHash('sha256').update(x, 'utf8').digest('hex'); 8 | -------------------------------------------------------------------------------- /lib/services/markdown.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import fs from 'fs'; 7 | export function markdown2Html(filePath) { 8 | return fs.readFileSync(filePath, 'utf8'); 9 | } 10 | -------------------------------------------------------------------------------- /lib/defaultConfig.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | export const DEFAULT_CONFIG = { 7 | // Default path for sqlite storage directory. Interpreted relative to project root. 8 | sqlitepath: '/db', 9 | }; 10 | -------------------------------------------------------------------------------- /lib/features.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | const FEATURES = { 7 | WATCHLIST_MANAGEMENT: false, 8 | }; 9 | 10 | export default function getFeatures() { 11 | return { 12 | ...FEATURES, 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /ui/src/Index.less: -------------------------------------------------------------------------------- 1 | body, 2 | html { 3 | margin: 0; 4 | height: 100%; 5 | width: 100%; 6 | background-color: #232429; 7 | } 8 | 9 | .semi-table-row-head { 10 | background-color: #2b2b2b !important; 11 | color: #fff !important; 12 | } 13 | 14 | .semi-table-row-cell { 15 | background-color: #333333 !important; 16 | } 17 | -------------------------------------------------------------------------------- /ui/src/views/dashboard/Dashboard.less: -------------------------------------------------------------------------------- 1 | .dashboard { 2 | &__row { 3 | margin-bottom: 1rem; 4 | /* Ensure grid items wrap to next line on narrow screens */ 5 | flex-wrap: wrap; 6 | /* Vertical gap of 1rem between wrapped grid items (no px) */ 7 | .semi-col { 8 | margin-bottom: 1rem; 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ui/src/components/table/JobTable.less: -------------------------------------------------------------------------------- 1 | .interactions { 2 | float: right; 3 | display: flex; 4 | flex-direction: column; 5 | gap: 1rem; 6 | } 7 | 8 | .jobPopoverContent { 9 | padding: 1rem; 10 | color: white; 11 | } 12 | 13 | @media (min-width: 768px) { 14 | .interactions { 15 | flex-direction: initial; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /ui/src/components/table/listings/ListingsTable.less: -------------------------------------------------------------------------------- 1 | .listingsTable { 2 | &__search { 3 | margin-bottom: 1rem !important; 4 | } 5 | 6 | &__expanded { 7 | display: flex; 8 | gap: 1rem; 9 | } 10 | 11 | &__toolbar { 12 | margin-bottom: 1rem; 13 | } 14 | 15 | &__setupButton { 16 | margin-bottom: 1rem; 17 | } 18 | } -------------------------------------------------------------------------------- /lib/notification/adapter/ntfy.md: -------------------------------------------------------------------------------- 1 | ### ntfy Adapter 2 | 3 | Send push notifications using an ntfy topic. 4 | 5 | Quick start: 6 | - Create or choose a topic on your preferred ntfy instance (see docs: https://docs.ntfy.sh/publish/). 7 | - Copy the publish URL for that topic. 8 | - In Fredy, configure the ntfy adapter with the topic URL and set a priority. 9 | -------------------------------------------------------------------------------- /ui/src/services/developmentMode.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | export default function isDevelopmentMode() { 7 | const inDevMode = import.meta.env.MODE; 8 | return inDevMode != null && inDevMode === 'development'; 9 | } 10 | -------------------------------------------------------------------------------- /ui/src/services/transformer/providerTransformer.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | export function transform({ name, id, enabled, url }) { 7 | return { 8 | name, 9 | id, 10 | enabled, 11 | url, 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /lib/notification/adapter/pushover.md: -------------------------------------------------------------------------------- 1 | ### Pushover Adapter 2 | 3 | Use Pushover to receive push notifications on your devices. 4 | 5 | Setup: 6 | - Follow Pushover's getting-started guide: https://support.pushover.net/i7-what-is-pushover-and-how-do-i-use-it 7 | - Create an application and obtain your User Key and API Token. 8 | - In Fredy, configure the Pushover adapter with both values. 9 | -------------------------------------------------------------------------------- /ui/src/views/listings/Listings.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import React from 'react'; 7 | 8 | import ListingsTable from '../../components/table/listings/ListingsTable.jsx'; 9 | 10 | export default function Listings() { 11 | return ; 12 | } 13 | -------------------------------------------------------------------------------- /lib/notification/adapter/mattermost.md: -------------------------------------------------------------------------------- 1 | ### Mattermost Adapter 2 | 3 | Receive notifications in Mattermost via an incoming webhook. 4 | 5 | Quick start: 6 | - Create an incoming webhook following the Mattermost developer docs: https://docs.mattermost.com/developer/webhooks-incoming.html 7 | - Copy the webhook URL. 8 | - In Fredy, configure the Mattermost adapter with this URL and the target channel. 9 | -------------------------------------------------------------------------------- /ui/src/views/jobs/mutation/components/notificationAdapter/NotificationAdapterMutator.less: -------------------------------------------------------------------------------- 1 | .providerMutator { 2 | &__fields { 3 | width: 25rem !important; 4 | } 5 | 6 | &__helpBox { 7 | background-color: #ececec; 8 | border-radius: 5px; 9 | padding: 1rem !important; 10 | } 11 | 12 | &__helpLink { 13 | color: #4183c4; 14 | cursor: pointer; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /test/mocks/mockNotification.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | let tmpStore = {}; 7 | 8 | export const send = (serviceName, payload) => { 9 | tmpStore = { serviceName, payload }; 10 | return [Promise.resolve()]; 11 | }; 12 | 13 | export const get = () => { 14 | return tmpStore; 15 | }; 16 | -------------------------------------------------------------------------------- /lib/notification/adapter/apprise.md: -------------------------------------------------------------------------------- 1 | ### Apprise Adapter 2 | 3 | Use [Apprise](https://github.com/caronc/apprise-api#installation) to forward notifications to many different services. 4 | 5 | Quick start: 6 | - Set up an Apprise API instance (see the installation guide linked above). 7 | - Configure your preferred notification service(s) within Apprise. 8 | - In Fredy, point the Apprise adapter to your Apprise API endpoint. 9 | -------------------------------------------------------------------------------- /lib/services/storage/migrations/sql/5.job-sharing.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | // Migration: Adding a new table to store if somebody shared a job with someone 7 | 8 | export function up(db) { 9 | db.exec(` 10 | ALTER TABLE jobs ADD COLUMN shared_with_user jsonb DEFAULT '[]' 11 | `); 12 | } 13 | -------------------------------------------------------------------------------- /lib/notification/adapter/discord_webhook.md: -------------------------------------------------------------------------------- 1 | ### Discord Webhook Adapter 2 | 3 | Use a Discord channel webhook to receive notifications. 4 | 5 | Quick start: 6 | - Create a webhook in your target Discord channel. See the "Intro to Webhooks" guide on the Discord support site: https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks 7 | - Copy the generated webhook URL. 8 | - In Fredy, configure the Discord adapter with this webhook URL. 9 | -------------------------------------------------------------------------------- /ui/src/components/footer/FredyFooter.less: -------------------------------------------------------------------------------- 1 | .fredyFooter { 2 | background:rgb(53, 54, 60); 3 | color: white; 4 | display: flex; 5 | justify-content: space-between; 6 | align-items: center; 7 | height: 1.7rem; 8 | border-radius: .3rem; 9 | border-top: 1px solid #45464b; 10 | 11 | &__version { 12 | padding-left: .5rem; 13 | font-size: small; 14 | 15 | } 16 | &__copyRight { 17 | padding-right: 1rem; 18 | 19 | } 20 | } -------------------------------------------------------------------------------- /lib/services/storage/migrations/sql/3.changeset-for-listings.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | // Migration: Adding a changeset field to the listings table in preparation for 7 | // a price watch feature 8 | 9 | export function up(db) { 10 | db.exec(` 11 | ALTER TABLE listings ADD COLUMN change_set jsonb; 12 | `); 13 | } 14 | -------------------------------------------------------------------------------- /lib/services/storage/migrations/sql/2.active-flag-for-listings.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | // Migration: there needs to be a unique index on job_id and hash as only 7 | // this makes the listing indeed unique 8 | 9 | export function up(db) { 10 | db.exec(` 11 | ALTER TABLE listings ADD COLUMN is_active INTEGER DEFAULT 1; 12 | `); 13 | } 14 | -------------------------------------------------------------------------------- /ui/src/services/transformer/notificationAdapterTransformer.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | export function transform({ id, name, fields }) { 7 | const fieldValues = {}; 8 | Object.keys(fields).map((key) => { 9 | fieldValues[key] = fields[key].value; 10 | }); 11 | return { 12 | id, 13 | name, 14 | fields: fieldValues, 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: [master] 5 | pull_request: 6 | branches: [master] 7 | schedule: 8 | - cron: '0 12 * * *' 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: 22 19 | cache: 'yarn' 20 | 21 | - run: yarn install 22 | - run: yarn testGH 23 | -------------------------------------------------------------------------------- /test/mocks/mockStore.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | const db = {}; 7 | export const storeListings = (jobKey, providerId, listings) => { 8 | if (!Array.isArray(listings)) throw Error('Not a valid array'); 9 | db[providerId] = listings; 10 | }; 11 | export const getKnownListingHashesForJobAndProvider = (jobKey, providerId) => { 12 | return db[providerId] || []; 13 | }; 14 | -------------------------------------------------------------------------------- /lib/notification/adapter/mailJet.md: -------------------------------------------------------------------------------- 1 | ### Mailjet Adapter 2 | 3 | To use [Mailjet](https://mailjet.com), create an account and decide which email address Fredy should send from. 4 | 5 | For example, if you use yourGmailAccount@gmail.com, add and verify this address in Mailjet. 6 | Provide your public/private API keys in Fredy's configuration. Fredy uses the same email template as for SendGrid. 7 | 8 | To send to multiple recipients, separate email addresses with commas (e.g., some@email.com, someOther@email.com). 9 | -------------------------------------------------------------------------------- /ui/src/components/headline/Headline.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import React from 'react'; 7 | import { Typography } from '@douyinfe/semi-ui'; 8 | 9 | export default function Headline({ text, size = 3 } = {}) { 10 | const { Title } = Typography; 11 | return ( 12 | 13 | {text} 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /ui/src/components/permission/PermissionAwareRoute.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import React from 'react'; 7 | 8 | import { Navigate } from 'react-router-dom'; 9 | 10 | export default function PermissionAwareRoute({ currentUser, children }) { 11 | const isAdmin = currentUser != null && currentUser.isAdmin; 12 | return isAdmin ? children : ; 13 | } 14 | -------------------------------------------------------------------------------- /docker-test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | # Stop and remove old container if it exists 5 | if [ "$(docker ps -aq -f name=fredy)" ]; then 6 | docker stop fredy || true 7 | docker rm fredy || true 8 | fi 9 | 10 | # Build image from local Dockerfile, forcing a fresh build without cache 11 | docker build --no-cache -t fredy:local . 12 | 13 | # Run container with volumes and port mapping 14 | docker run -d --name fredy \ 15 | -v fredy_conf:/conf \ 16 | -v fredy_db:/db \ 17 | -p 9998:9998 \ 18 | fredy:local -------------------------------------------------------------------------------- /ui/src/components/logo/Logo.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import React from 'react'; 7 | import logo from '../../assets/logo.png'; 8 | import logoWhite from '../../assets/logo_white.png'; 9 | 10 | import './Logo.less'; 11 | 12 | export default function Logo({ width = 350, white = false } = {}) { 13 | return Fredy Logo; 14 | } 15 | -------------------------------------------------------------------------------- /lib/notification/adapter/slack_with_webhooks.md: -------------------------------------------------------------------------------- 1 | ### Slack Adapter (Webhooks) 2 | 3 | *IMPORTANT:* 4 | This is the recommended Slack adapter. The old Slack adapter is unmaintained and kept only for backward compatibility. 5 | 6 | Setup: 7 | - Create a Slack account and workspace if you don't have one: https://slack.com 8 | - Create a channel where you want to receive notifications. 9 | - Add the Incoming Webhooks integration to that channel and copy the Webhook URL. 10 | - In Fredy, configure the Slack Webhook adapter with this URL. 11 | -------------------------------------------------------------------------------- /lib/api/routes/featureRouter.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import restana from 'restana'; 7 | import getFeatures from '../../features.js'; 8 | const service = restana(); 9 | const featureRouter = service.newRouter(); 10 | 11 | featureRouter.get('/', async (req, res) => { 12 | const features = getFeatures(); 13 | res.body = Object.assign({}, { features }); 14 | res.send(); 15 | }); 16 | 17 | export { featureRouter }; 18 | -------------------------------------------------------------------------------- /lib/notification/adapter/sqlite.md: -------------------------------------------------------------------------------- 1 | ### SQLite Adapter 2 | 3 | This adapter stores search results in an SQLite database. By default, the database is located at `db/listings.db`, but you can configure a custom location. The file can be used for analysis later. 4 | 5 | The table contains the following columns (all stored as `TEXT`): 6 | 7 | ```json 8 | [ 9 | "serviceName", 10 | "jobKey", 11 | "id", 12 | "size", 13 | "rooms", 14 | "price", 15 | "address", 16 | "title", 17 | "link", 18 | "description", 19 | "image" 20 | ] 21 | ``` 22 | -------------------------------------------------------------------------------- /lib/services/storage/migrations/sql/2.new-indexes-for-listings.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | // Migration: there needs to be a unique index on job_id and hash as only 7 | // this makes the listing indeed unique 8 | 9 | export function up(db) { 10 | db.exec(` 11 | DROP INDEX IF EXISTS idx_listings_hash; 12 | CREATE UNIQUE INDEX IF NOT EXISTS idx_listings_job_hash 13 | ON listings (job_id, hash); 14 | `); 15 | } 16 | -------------------------------------------------------------------------------- /lib/services/crons/listing-alive-cron.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import cron from 'node-cron'; 7 | import runActiveChecker from '../listings/listingActiveService.js'; 8 | 9 | async function runTask() { 10 | await runActiveChecker(); 11 | } 12 | 13 | export async function initActiveCheckerCron() { 14 | //run directly on start 15 | await runTask(); 16 | // then every day at 1 am 17 | cron.schedule('0 1 * * *', runTask); 18 | } 19 | -------------------------------------------------------------------------------- /lib/api/routes/demoRouter.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import restana from 'restana'; 7 | import { getSettings } from '../../services/storage/settingsStorage.js'; 8 | const service = restana(); 9 | const demoRouter = service.newRouter(); 10 | 11 | demoRouter.get('/', async (req, res) => { 12 | const settings = await getSettings(); 13 | res.body = Object.assign({}, { demoMode: settings.demoMode }); 14 | res.send(); 15 | }); 16 | 17 | export { demoRouter }; 18 | -------------------------------------------------------------------------------- /ui/src/services/time/timeService.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | export function format(ts, showSeconds = true) { 7 | return new Intl.DateTimeFormat('default', { 8 | year: 'numeric', 9 | month: 'numeric', 10 | day: 'numeric', 11 | hour: 'numeric', 12 | minute: 'numeric', 13 | ...(showSeconds ? { second: 'numeric' } : {}), 14 | }).format(ts); 15 | } 16 | 17 | export const roundToHour = (ts) => Math.ceil(ts / (1000 * 60 * 60)) * (1000 * 60 * 60); 18 | -------------------------------------------------------------------------------- /ui/src/views/user/UserRemovalModal.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import React from 'react'; 7 | import { Modal } from '@douyinfe/semi-ui'; 8 | const UserRemovalModal = function UserRemovalModal({ onOk, onCancel }) { 9 | return ( 10 | 11 |

Removing this user will also remove all associated jobs.

12 |
13 | ); 14 | }; 15 | 16 | export default UserRemovalModal; 17 | -------------------------------------------------------------------------------- /lib/services/queryStringMutator.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import queryString from 'query-string'; 7 | export default (_url, sortByDateParam) => { 8 | //if no mutation is necessary, just return the original url 9 | if (sortByDateParam == null) { 10 | return _url; 11 | } 12 | const original = queryString.parseUrl(_url); 13 | const mutate = queryString.parse(sortByDateParam); 14 | return `${original.url}?${queryString.stringify({ ...original.query, ...mutate })}`; 15 | }; 16 | -------------------------------------------------------------------------------- /ui/src/components/cards/DashboardCardColors.less: -------------------------------------------------------------------------------- 1 | @color-blue-bg: rgba(0, 123, 255, 0.24); 2 | @color-blue-border: #1E40AFFF; 3 | @color-blue-text: #60a5fa; 4 | 5 | @color-orange-bg: rgba(250, 91, 5, 0.12); 6 | @color-orange-border: #d33601; 7 | @color-orange-text: #FB923CFF; 8 | 9 | @color-green-bg: rgba(38, 250, 5, 0.12); 10 | @color-green-border: #00c316; 11 | @color-green-text: #33f308; 12 | 13 | @color-purple-bg: rgba(91, 3, 218, 0.38); 14 | @color-purple-border: #7500c3; 15 | @color-purple-text: #b15fff; 16 | 17 | @color-gray-bg: rgba(110, 110, 110, 0.38); 18 | @color-gray-border: #807f7f; 19 | @color-gray-text: #bab9b9; 20 | -------------------------------------------------------------------------------- /.github/workflows/check_source.yml: -------------------------------------------------------------------------------- 1 | name: Check the source code 2 | on: 3 | push: 4 | branches: [master] 5 | pull_request: 6 | branches: [master] 7 | jobs: 8 | check_source_code: 9 | name: Check the source code 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: 22 17 | cache: 'yarn' 18 | 19 | - name: Install dependencies 20 | run: yarn install 21 | 22 | - name: Check formatting 23 | run: yarn format:check 24 | 25 | - name: Lint 26 | run: yarn lint 27 | -------------------------------------------------------------------------------- /ui/src/hooks/featureHook.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import { useSelector } from '../services/state/store.js'; 7 | 8 | export function useFeature(name) { 9 | const currentFeatureFlags = useSelector((state) => state.features); 10 | if (Object.keys(currentFeatureFlags || {}).length === 0) { 11 | return null; 12 | } 13 | 14 | if (currentFeatureFlags[name] == null) { 15 | console.warn(`Feature flag with name ${name} is unknown.`); 16 | return null; 17 | } 18 | 19 | return currentFeatureFlags[name]; 20 | } 21 | -------------------------------------------------------------------------------- /ui/src/components/permission/InsufficientPermission.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import React from 'react'; 7 | import insufficientPermission from '../../assets/insufficient_permission.png'; 8 | 9 | export default function InsufficientPermission() { 10 | return ( 11 |
12 | 13 |
14 |

Insufficient permission :(

15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /lib/errors.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | class ExtendableError extends Error { 7 | constructor(message) { 8 | super(message); 9 | this.name = this.constructor.name; 10 | if (typeof Error.captureStackTrace === 'function') { 11 | Error.captureStackTrace(this, this.constructor); 12 | } else { 13 | this.stack = new Error(message).stack; 14 | } 15 | } 16 | } 17 | class NoNewListingsWarning extends ExtendableError {} 18 | export { NoNewListingsWarning }; 19 | export default { 20 | NoNewListingsWarning, 21 | }; 22 | -------------------------------------------------------------------------------- /ui/src/views/jobs/mutation/components/notificationAdapter/NotificationHelpDisplay.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import React from 'react'; 7 | import { Banner, MarkdownRender } from '@douyinfe/semi-ui'; 8 | 9 | export default function Help({ readme }) { 10 | return ( 11 | Information} 16 | description={} 17 | /> 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /lib/notification/adapter/sendGrid.md: -------------------------------------------------------------------------------- 1 | ### SendGrid Adapter 2 | 3 | SendGrid is an email delivery service with a generous free tier, which is more than enough for Fredy. 4 | 5 | Setup: 6 | - Create a SendGrid account: https://sendgrid.com/ 7 | - Decide which email address Fredy should send from (e.g., yourGmailAccount@gmail.com), add it to SendGrid, and complete the verification. 8 | - Create an API key and add it to Fredy's configuration. 9 | - Create a Dynamic Template in SendGrid. You can copy the template from `/lib/notification/emailTemplate/template.hbs`. 10 | 11 | Sending to multiple recipients: 12 | - Separate email addresses with commas (e.g., some@email.com, someOther@email.com). 13 | -------------------------------------------------------------------------------- /test/utils/utils.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import { expect } from 'chai'; 7 | import { buildHash } from '../../lib/utils.js'; 8 | 9 | describe('utilsCheck', () => { 10 | describe('#utilsCheck()', () => { 11 | it('should be null when null input', () => { 12 | expect(buildHash(null)).to.be.null; 13 | }); 14 | it('should be null when null empty', () => { 15 | expect(buildHash('')).to.be.null; 16 | }); 17 | it('should return a value', () => { 18 | expect(buildHash('bla', '', null)).to.be.a.string; 19 | }); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | fredy: 3 | container_name: fredy 4 | build: 5 | context: . 6 | dockerfile: Dockerfile 7 | image: ghcr.io/orangecoding/fredy 8 | volumes: 9 | - ./conf:/conf 10 | - ./db:/db 11 | ports: 12 | - "9998:9998" 13 | restart: unless-stopped 14 | # Resource limits to prevent runaway memory usage from Chromium 15 | deploy: 16 | resources: 17 | limits: 18 | memory: 1G 19 | healthcheck: 20 | test: ["CMD", "curl", "--fail", "--silent", "--show-error", "--max-time", "5", "http://localhost:9998/"] 21 | interval: 120s 22 | timeout: 10s 23 | retries: 3 24 | start_period: 30s 25 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import react from '@vitejs/plugin-react'; 7 | import { defineConfig } from 'vite'; 8 | // https://vitejs.dev/config/ 9 | export default defineConfig({ 10 | base: '', 11 | build: { 12 | chunkSizeWarningLimit: 9999999, 13 | outDir: './ui/public', 14 | emptyOutDir: true, 15 | }, 16 | plugins: [react()], 17 | server: { 18 | proxy: { 19 | '/api': { 20 | target: { 21 | host: '0.0.0.0', 22 | protocol: 'http:', 23 | port: 9998, 24 | }, 25 | }, 26 | }, 27 | }, 28 | }); 29 | -------------------------------------------------------------------------------- /ui/src/views/login/login.less: -------------------------------------------------------------------------------- 1 | .login { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | width: 100%; 6 | height: 100%; 7 | 8 | &__bgImage { 9 | background-size: cover; 10 | filter: blur(8px); 11 | -webkit-filter: blur(8px); 12 | position: absolute; 13 | top: 0; 14 | left: 0; 15 | z-index: 0; 16 | right: 0; 17 | bottom: 0; 18 | } 19 | 20 | &__loginWrapper { 21 | border: 1px solid #555050; 22 | border-radius: 30px; 23 | 24 | z-index: 1; 25 | background-color: #151313ab; 26 | display: flex; 27 | flex-direction: column; 28 | padding: 2rem; 29 | gap: 1rem; 30 | } 31 | 32 | form { 33 | z-index: 1; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /lib/api/routes/providerRouter.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import fs from 'fs'; 7 | import restana from 'restana'; 8 | const service = restana(); 9 | const providerRouter = service.newRouter(); 10 | const providerList = fs.readdirSync('./lib/provider').filter((file) => file.endsWith('.js')); 11 | const provider = await Promise.all( 12 | providerList.map(async (pro) => { 13 | return await import(`../../provider/${pro}`); 14 | }), 15 | ); 16 | providerRouter.get('/', async (req, res) => { 17 | res.body = provider.map((p) => p.metaInformation); 18 | res.send(); 19 | }); 20 | export { providerRouter }; 21 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | 12 | 13 | Fredy || Real Estate Finder 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /ui/src/Index.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import React from 'react'; 7 | 8 | import { HashRouter } from 'react-router-dom'; 9 | import { createRoot } from 'react-dom/client'; 10 | import en_US from '@douyinfe/semi-ui/lib/es/locale/source/en_US'; 11 | import { LocaleProvider } from '@douyinfe/semi-ui'; 12 | import App from './App'; 13 | import './Index.less'; 14 | 15 | const container = document.getElementById('fredy'); 16 | const root = createRoot(container); 17 | 18 | root.render( 19 | 20 | 21 | 22 | 23 | , 24 | ); 25 | -------------------------------------------------------------------------------- /lib/services/tracking/uniqueId.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import { hostname, arch, cpus, platform } from 'os'; 7 | import { createHash } from 'crypto'; 8 | 9 | /** 10 | * Don't worry, we are not evil ;) We however need a unique id per running instance 11 | * @returns {string} 12 | */ 13 | export const getUniqueId = () => { 14 | const systemInfo = { 15 | hostname: hostname(), 16 | architecture: arch(), 17 | cpuCount: cpus().length, 18 | platform: platform(), 19 | }; 20 | 21 | const baseData = JSON.stringify(systemInfo); 22 | 23 | return createHash('sha256').update(baseData).digest('hex'); 24 | }; 25 | -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import { readFile } from 'fs/promises'; 7 | import esmock from 'esmock'; 8 | import * as mockStore from './mocks/mockStore.js'; 9 | import { send } from './mocks/mockNotification.js'; 10 | 11 | export const providerConfig = JSON.parse(await readFile(new URL('./provider/testProvider.json', import.meta.url))); 12 | 13 | export const mockFredy = async () => { 14 | return await esmock('../lib/FredyPipeline', { 15 | '../lib/services/storage/listingsStorage.js': { 16 | ...mockStore, 17 | }, 18 | '../lib/notification/notify.js': { 19 | send, 20 | }, 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /ui/src/components/placeholder/Placeholder.less: -------------------------------------------------------------------------------- 1 | .place { 2 | height: 100%; 3 | width: 100%; 4 | display: flex; 5 | 6 | &__place_lines_wrapper { 7 | width: 100%; 8 | } 9 | 10 | &__line { 11 | height: 10px; 12 | margin: 10px; 13 | animation: pulse 1s infinite ease-in-out; 14 | } 15 | 16 | &__circle { 17 | height: 4rem; 18 | width: 5rem; 19 | margin: 10px; 20 | border-radius: 360px; 21 | animation: pulse 1s infinite ease-in-out; 22 | } 23 | } 24 | 25 | @keyframes pulse { 26 | 0% { 27 | background-color: rgba(165, 165, 165, 0.1); 28 | } 29 | 50% { 30 | background-color: rgba(165, 165, 165, 0.3); 31 | } 32 | 100% { 33 | background-color: rgba(165, 165, 165, 0.1); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /lib/services/storage/migrations/sql/0.init.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | // Initial migration: creates schema_migrations table used by the migration runner. 7 | // 8 | export function up(db) { 9 | db.exec(` 10 | CREATE TABLE IF NOT EXISTS schema_migrations ( 11 | id INTEGER PRIMARY KEY AUTOINCREMENT, 12 | name TEXT NOT NULL UNIQUE, 13 | checksum TEXT NOT NULL, 14 | applied_at TEXT NOT NULL DEFAULT (datetime('now')), 15 | duration_ms INTEGER NOT NULL DEFAULT 0 16 | ); 17 | 18 | CREATE INDEX IF NOT EXISTS idx_schema_migrations_applied_at 19 | ON schema_migrations(applied_at); 20 | `); 21 | } 22 | -------------------------------------------------------------------------------- /ui/src/components/segment/SegmentPart.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import React from 'react'; 7 | import { Card } from '@douyinfe/semi-ui'; 8 | 9 | import './SegmentParts.less'; 10 | 11 | export const SegmentPart = ({ name, Icon = null, children, helpText = null }) => { 12 | const { Meta } = Card; 13 | 14 | return ( 15 | } /> 20 | ) 21 | } 22 | > 23 | {children} 24 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Dependencies (will be installed fresh in container) 2 | node_modules/ 3 | 4 | # Database and config (mounted as volumes) 5 | db/ 6 | conf/ 7 | 8 | # Git 9 | .git/ 10 | .github/ 11 | .gitignore 12 | 13 | # IDE and editor 14 | .idea/ 15 | .vscode/ 16 | *.swp 17 | *.swo 18 | .DS_Store 19 | 20 | # Testing 21 | test/ 22 | 23 | # Documentation 24 | doc/ 25 | *.md 26 | !README.md 27 | 28 | # Development config files 29 | .babelrc 30 | .husky/ 31 | .nvmrc 32 | .prettierrc 33 | .prettierignore 34 | eslint.config.js 35 | 36 | # Docker files (not needed inside container) 37 | Dockerfile 38 | docker-compose.yml 39 | docker-test.sh 40 | .dockerignore 41 | 42 | # Logs 43 | *.log 44 | npm-debug.log 45 | 46 | # Build artifacts (built fresh in container) 47 | dist/ 48 | -------------------------------------------------------------------------------- /lib/services/storage/migrations/sql/4.watch-list.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | // Migration: Adding a new table to store if somebody "watches" (a.k.a favorite) a listing 7 | 8 | export function up(db) { 9 | db.exec(` 10 | CREATE TABLE IF NOT EXISTS watch_list 11 | ( 12 | id TEXT PRIMARY KEY, 13 | listing_id TEXT NOT NULL, 14 | user_id TEXT NOT NULL, 15 | FOREIGN KEY (listing_id) REFERENCES listings (id) ON DELETE CASCADE, 16 | FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE 17 | ); 18 | CREATE UNIQUE INDEX IF NOT EXISTS idx_watch_list ON watch_list (listing_id, user_id); 19 | `); 20 | } 21 | -------------------------------------------------------------------------------- /lib/notification/adapter/console.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import { markdown2Html } from '../../services/markdown.js'; 7 | 8 | export const send = ({ serviceName, newListings, jobKey }) => { 9 | /* eslint-disable no-console */ 10 | return [Promise.resolve(console.info(`Found entry from service ${serviceName}, Job: ${jobKey}:`, newListings))]; 11 | /* eslint-enable no-console */ 12 | }; 13 | export const config = { 14 | id: 'console', 15 | name: 'Console', 16 | description: 'This adapter sends new listings to the console. It is mostly useful for debugging.', 17 | config: {}, 18 | readme: markdown2Html('lib/notification/adapter/console.md'), 19 | }; 20 | -------------------------------------------------------------------------------- /ui/src/hooks/screenWidth.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import { useState, useEffect } from 'react'; 7 | 8 | export function useScreenWidth() { 9 | const [width, setWidth] = useState(window.innerWidth); 10 | 11 | useEffect(() => { 12 | let timeoutId; 13 | 14 | const handleResize = () => { 15 | clearTimeout(timeoutId); 16 | timeoutId = setTimeout(() => setWidth(window.innerWidth), 100); 17 | }; 18 | 19 | window.addEventListener('resize', handleResize); 20 | 21 | return () => { 22 | clearTimeout(timeoutId); 23 | window.removeEventListener('resize', handleResize); 24 | }; 25 | }, []); 26 | 27 | return width; 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/stales.yml: -------------------------------------------------------------------------------- 1 | name: Close stale issues and PRs 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' # Daily 6 | 7 | jobs: 8 | stale: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/stale@v7 12 | with: 13 | days-before-stale: 30 14 | days-before-close: 7 15 | stale-issue-message: 'This issue has been automatically marked as stale due to inactivity.' 16 | stale-pr-message: 'This PR has been automatically marked as stale due to inactivity.' 17 | close-issue-message: 'Closing this issue due to prolonged inactivity.' 18 | close-pr-message: 'Closing this PR due to prolonged inactivity.' 19 | exempt-issue-labels: 'keep-open' 20 | exempt-pr-labels: 'keep-open' 21 | only: 'pulls' 22 | -------------------------------------------------------------------------------- /ui/src/components/logout/Logout.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import React from 'react'; 7 | import { Button } from '@douyinfe/semi-ui'; 8 | import { xhrPost } from '../../services/xhr'; 9 | import { IconUser } from '@douyinfe/semi-icons'; 10 | 11 | const Logout = function Logout({ text }) { 12 | return ( 13 |
14 | 25 |
26 | ); 27 | }; 28 | 29 | export default Logout; 30 | -------------------------------------------------------------------------------- /lib/services/crons/tracker-cron.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import cron from 'node-cron'; 7 | import { inDevMode } from '../../utils.js'; 8 | import { trackMainEvent } from '../tracking/Tracker.js'; 9 | import { getSettings } from '../storage/settingsStorage.js'; 10 | 11 | async function runTask() { 12 | const settings = await getSettings(); 13 | //make sure to only send tracking events if the user gave us the green light and we are not in dev mode 14 | if (settings.analyticsEnabled && !inDevMode()) { 15 | await trackMainEvent(); 16 | } 17 | } 18 | 19 | export async function initTrackerCron() { 20 | //run directly on start 21 | await runTask(); 22 | // then every 6 hours 23 | cron.schedule('0 */6 * * *', runTask); 24 | } 25 | -------------------------------------------------------------------------------- /ui/src/App.less: -------------------------------------------------------------------------------- 1 | .app { 2 | height: 100%; 3 | width: 100%; 4 | 5 | &__content { 6 | margin: 1rem; 7 | } 8 | } 9 | 10 | .ui.inverted.segment { 11 | background: #31303078 !important; 12 | } 13 | 14 | .ui.black.label, 15 | .ui.black.labels .label { 16 | background-color: #31303078 !important; 17 | } 18 | 19 | a:link { 20 | color: #54a9ff; 21 | background-color: transparent; 22 | text-decoration: none; 23 | } 24 | 25 | a:visited { 26 | color: #54a9ff; 27 | background-color: transparent; 28 | text-decoration: none; 29 | } 30 | 31 | a:hover { 32 | color: #54a9ff; 33 | background-color: transparent; 34 | text-decoration: underline; 35 | } 36 | 37 | a:active { 38 | color: #54a9ff; 39 | background-color: transparent; 40 | text-decoration: underline; 41 | } 42 | 43 | .semi-icon:not(.semi-tabs-bar .semi-tabs-tab .semi-icon) { 44 | vertical-align: middle; 45 | } 46 | -------------------------------------------------------------------------------- /ui/src/components/cards/ChartCard.less: -------------------------------------------------------------------------------- 1 | .chartCard { 2 | /* Use provided background with slight transparency and a brighter mix */ 3 | background: color-mix(in oklab, var(--card-bg, rgb(70 72 78)) 20%, white 80%); 4 | border-radius: .6rem; 5 | border: 1px solid color-mix(in oklab, var(--card-bg, rgb(70 72 78)) 35%, white 65%); 6 | box-shadow: 0 6px 20px rgba(0,0,0,0.08); 7 | /* Ensure base text has strong contrast */ 8 | color: var(--semi-color-text-0); 9 | 10 | /* Semi Card header/title styling */ 11 | .semi-card-header .semi-card-header-title { 12 | /* Derive a tinted title color with stronger contrast towards black */ 13 | color: color-mix(in oklab, var(--card-bg, rgb(70 72 78)) 60%, black 40%); 14 | font-weight: 600; 15 | } 16 | 17 | &__no__data { 18 | display: grid; 19 | place-items: center; 20 | height: 14rem; 21 | opacity: .7; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ui/src/components/footer/FredyFooter.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import React from 'react'; 7 | import './FredyFooter.less'; 8 | import { useSelector } from '../../services/state/store.js'; 9 | import { Typography } from '@douyinfe/semi-ui'; 10 | 11 | export default function FredyFooter() { 12 | const { Text } = Typography; 13 | const version = useSelector((state) => state.versionUpdate.versionUpdate); 14 | return ( 15 |
16 |
17 | Fredy V{version?.localFredyVersion || 'N/A'} 18 |
19 |
20 | Made with ❤️ 21 |
22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /ui/src/components/placeholder/Placeholder.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import React from 'react'; 7 | 8 | import './Placeholder.less'; 9 | 10 | function getPlaceholder(rowCount, className) { 11 | const rows = []; 12 | for (let i = 0; i < rowCount; i++) { 13 | rows.push(
); 14 | } 15 | const clazz = `place ${className == null ? '' : className}`; 16 | return ( 17 |
18 |
19 |
{rows}
20 |
21 | ); 22 | } 23 | 24 | export default function Placeholder({ rows = 3, ready = false, children, customPlaceholder, className }) { 25 | if (!ready) { 26 | if (customPlaceholder != null) { 27 | return customPlaceholder; 28 | } 29 | 30 | return getPlaceholder(rows, className); 31 | } 32 | 33 | return children; 34 | } 35 | -------------------------------------------------------------------------------- /lib/services/crons/demoCleanup-cron.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import { removeJobsByUserId } from '../storage/jobStorage.js'; 7 | import { getUsers } from '../storage/userStorage.js'; 8 | import logger from '../logger.js'; 9 | import cron from 'node-cron'; 10 | import { getSettings } from '../storage/settingsStorage.js'; 11 | 12 | /** 13 | * if we are running in demo environment, we have to cleanup the db files (specifically the jobs table) 14 | */ 15 | export function cleanupDemoAtMidnight() { 16 | cron.schedule('0 0 * * *', cleanup); 17 | } 18 | 19 | async function cleanup() { 20 | const settings = await getSettings(); 21 | if (settings.demoMode) { 22 | const demoUser = getUsers(false).find((user) => user.username === 'demo'); 23 | if (demoUser == null) { 24 | logger.error('Demo user not found, cannot remove Jobs'); 25 | return Promise.resolve(); 26 | } 27 | removeJobsByUserId(demoUser.id); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lib/notification/notify.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import fs from 'fs'; 7 | const path = './adapter'; 8 | 9 | /** Read every integration existing in ./adapter **/ 10 | const adapter = await Promise.all( 11 | fs 12 | .readdirSync('./lib/notification/adapter') 13 | .filter((file) => file.endsWith('.js')) 14 | .map(async (integPath) => await import(`${path}/${integPath}`)), 15 | ); 16 | 17 | if (adapter.length === 0) { 18 | throw new Error('Please specify at least one notification provider'); 19 | } 20 | const findAdapter = (notificationAdapter) => { 21 | return adapter.find((a) => a.config.id === notificationAdapter.id); 22 | }; 23 | export const send = (serviceName, newListings, notificationConfig, jobKey) => { 24 | //this is not being used in tests, therefore adapter are always set 25 | return notificationConfig 26 | .filter((notificationAdapter) => findAdapter(notificationAdapter) != null) 27 | .map((notificationAdapter) => findAdapter(notificationAdapter)) 28 | .map((a) => a.send({ serviceName, newListings, notificationConfig, jobKey })); 29 | }; 30 | -------------------------------------------------------------------------------- /ui/src/components/cards/KpiCard.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | /* 7 | * Copyright (c) 2025 by Christian Kellner. 8 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 9 | */ 10 | import React from 'react'; 11 | 12 | import './DashboardCard.less'; 13 | 14 | export default function KpiCard({ 15 | title, 16 | icon, 17 | value, 18 | valueFontSize = '1.5rem', 19 | description, 20 | color = 'gray', 21 | children, 22 | }) { 23 | return ( 24 |
25 |
26 |
{icon}
27 |
28 | {title} 29 |
30 |
31 |
32 |

33 | {value} 34 | {children} 35 |

36 | {description && {description}} 37 |
38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /lib/services/extractor/utils.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import logger from '../logger.js'; 7 | 8 | let debuggingOn = false; 9 | 10 | export const DEFAULT_HEADER = { 11 | Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', 12 | 'Accept-Language': 'en-US,en;q=0.5', 13 | Connection: 'keep-alive', 14 | 'Upgrade-Insecure-Requests': '1', 15 | 'User-Agent': 16 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36', 17 | }; 18 | 19 | export const setDebug = (options) => { 20 | debuggingOn = !!options?.debug; 21 | }; 22 | 23 | export const debug = (message) => { 24 | if (debuggingOn) { 25 | logger.debug(message); 26 | } 27 | }; 28 | 29 | export const botDetected = (pageSource, statusCode) => { 30 | const suspiciousStatusCodes = [403, 429]; 31 | const botDetectionPatterns = [/verify you are human/i, /access denied/i, /x-amz-cf-id/i]; 32 | 33 | const detectedInSource = botDetectionPatterns.some((pattern) => pattern.test(pageSource)); 34 | const detectedByStatus = suspiciousStatusCodes.includes(statusCode); 35 | 36 | return detectedInSource || detectedByStatus; 37 | }; 38 | -------------------------------------------------------------------------------- /test/services/immoscout/testdata.json: -------------------------------------------------------------------------------- 1 | { 2 | "buyHouseInParts": { 3 | "url": "https://www.immobilienscout24.de/Suche/de/nordrhein-westfalen/haus-kaufen?numberofrooms=1.0-10000.0&price=1.0-1000000.0E7&livingspace=1.0-10000.0&geocodes=1276010037,1276010014,1276010012&enteredFrom=result_list", 4 | "type": "housebuy" 5 | }, 6 | "buyHouse": { 7 | "url": "https://www.immobilienscout24.de/Suche/de/nordrhein-westfalen/duesseldorf/haus-kaufen?numberofrooms=1.0-10000.0&price=1.0-1000000.0E7&livingspace=1.0-10000.0&enteredFrom=result_list", 8 | "type": "housebuy" 9 | }, 10 | "rentApartment": { 11 | "url": "https://www.immobilienscout24.de/Suche/de/nordrhein-westfalen/duesseldorf/wohnung-mieten?numberofrooms=1.5-&price=1.0-1000000.0&livingspace=1.0-10000.0&pricetype=rentpermonth&enteredFrom=result_list", 12 | "type": "apartmentrent" 13 | }, 14 | "buyApartment": { 15 | "url": "https://www.immobilienscout24.de/Suche/de/nordrhein-westfalen/duesseldorf/wohnung-kaufen?numberofrooms=1.5-10000.0&price=1.0-1000000.0&livingspace=1.0-10000.0&enteredFrom=result_list", 16 | "type": "apartmentbuy" 17 | }, 18 | "rentHouse": { 19 | "url": "https://www.immobilienscout24.de/Suche/de/nordrhein-westfalen/duesseldorf/haus-mieten?enteredFrom=one_step_search", 20 | "type": "houserent" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /lib/api/routes/versionRouter.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import restana from 'restana'; 7 | import fetch from 'node-fetch'; 8 | import { getPackageVersion } from '../../utils.js'; 9 | import semver from 'semver'; 10 | 11 | const service = restana(); 12 | const versionRouter = service.newRouter(); 13 | 14 | versionRouter.get('/', async (req, res) => { 15 | const versionPayload = await getCurrentVersionFromGithub(); 16 | const localFredyVersion = await getPackageVersion(); 17 | res.body = 18 | versionPayload == null 19 | ? { 20 | newVersion: false, 21 | localFredyVersion, 22 | } 23 | : versionPayload; 24 | res.send(); 25 | }); 26 | 27 | async function getCurrentVersionFromGithub() { 28 | const raw = await fetch('https://api.github.com/repos/orangecoding/fredy/releases/latest'); 29 | const data = await raw.json(); 30 | const localFredyVersion = await getPackageVersion(); 31 | if (data.tag_name == null || semver.gte(localFredyVersion, data.tag_name)) { 32 | return null; 33 | } 34 | return { 35 | newVersion: true, 36 | version: data.tag_name, 37 | url: data.html_url, 38 | body: data.body, 39 | localFredyVersion, 40 | }; 41 | } 42 | 43 | export { versionRouter }; 44 | -------------------------------------------------------------------------------- /lib/notification/adapter/http.md: -------------------------------------------------------------------------------- 1 | ### HTTP Adapter 2 | 3 | This is a generic adapter for sending notifications via HTTP requests. 4 | You can leverage this adapter to integrate with various webhooks or APIs that accept HTTP requests. (e.g. Supabase 5 | Functions, a Node.js server, etc.) 6 | 7 | HTTP adapter supports a `authToken` field, which can be used to include an authorization token in the request headers. 8 | Your token would be included as a Bearer token in the `Authorization` header, which is a common method for securing API requests. 9 | 10 | Request Details: 11 |
12 | Request Method: POST 13 | 14 | Headers: 15 | 16 | ``` 17 | Content Type: `application/json` 18 | Authorization: Bearer {your-optional-auth-token} 19 | ``` 20 | 21 | Body: 22 | 23 | ```json 24 | { 25 | "jobId": "mg1waX4RHmIzL5NDYtYp-", 26 | "provider": "immoscout", 27 | "timestamp": "2024-06-15T12:34:56Z", 28 | "listings": [ 29 | { 30 | "address": "Str. 123, Bielefeld, Germany", 31 | "description": "Möbliert: Einziehen & wohlfühlen: Neu möbliert.", 32 | "id": "123456789", 33 | "imageUrl": "https://.com/listings/123456789.jpg", 34 | "price": "1.240 €", 35 | "size": "38 m²", 36 | "title": "Schöne 1-Zimmer-Wohnung in Bielefeld", 37 | "url": "https://.com/listings/123456789" 38 | } 39 | ] 40 | } 41 | ``` 42 | 43 |
44 | -------------------------------------------------------------------------------- /ui/src/components/table/NotificationAdapterTable.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import React from 'react'; 7 | 8 | import { Empty, Table, Button } from '@douyinfe/semi-ui'; 9 | import { IconDelete, IconEdit } from '@douyinfe/semi-icons'; 10 | 11 | export default function NotificationAdapterTable({ notificationAdapter = [], onRemove, onEdit } = {}) { 12 | return ( 13 | } 16 | columns={[ 17 | { 18 | title: 'Name', 19 | dataIndex: 'name', 20 | }, 21 | 22 | { 23 | title: '', 24 | dataIndex: 'tools', 25 | render: (_, record) => { 26 | return ( 27 |
28 |
36 | ); 37 | }, 38 | }, 39 | ]} 40 | dataSource={notificationAdapter} 41 | /> 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /lib/api/routes/generalSettingsRoute.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import restana from 'restana'; 7 | import { getDirName } from '../../utils.js'; 8 | import fs from 'fs'; 9 | import { ensureDemoUserExists } from '../../services/storage/userStorage.js'; 10 | import logger from '../../services/logger.js'; 11 | import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js'; 12 | const service = restana(); 13 | const generalSettingsRouter = service.newRouter(); 14 | 15 | generalSettingsRouter.get('/', async (req, res) => { 16 | res.body = Object.assign({}, await getSettings()); 17 | res.send(); 18 | }); 19 | generalSettingsRouter.post('/', async (req, res) => { 20 | const { sqlitepath, ...appSettings } = req.body || {}; 21 | const localSettings = await getSettings(); 22 | 23 | if (localSettings.demoMode) { 24 | res.send(new Error('In demo mode, it is not allowed to change these settings.')); 25 | return; 26 | } 27 | 28 | try { 29 | if (typeof sqlitepath !== 'undefined') { 30 | fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ sqlitepath })); 31 | } 32 | upsertSettings(appSettings); 33 | ensureDemoUserExists(); 34 | } catch (err) { 35 | logger.error(err); 36 | res.send(new Error('Error while trying to write settings.')); 37 | return; 38 | } 39 | res.send(); 40 | }); 41 | export { generalSettingsRouter }; 42 | -------------------------------------------------------------------------------- /lib/api/security.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import * as userStorage from '../services/storage/userStorage.js'; 7 | import cookieSession from 'cookie-session'; 8 | import { nanoid } from 'nanoid'; 9 | const unauthorized = (res) => { 10 | return res.send(401); 11 | }; 12 | const isUnauthorized = (req) => { 13 | return req.session.currentUser == null; 14 | }; 15 | const isAdmin = (req) => { 16 | if (!isUnauthorized(req)) { 17 | const user = userStorage.getUser(req.session.currentUser); 18 | return user != null && user.isAdmin; 19 | } 20 | return false; 21 | }; 22 | const authInterceptor = () => { 23 | return (req, res, next) => { 24 | if (isUnauthorized(req)) { 25 | return unauthorized(res); 26 | } else { 27 | next(); 28 | } 29 | }; 30 | }; 31 | const adminInterceptor = () => { 32 | return (req, res, next) => { 33 | if (!isAdmin(req)) { 34 | return unauthorized(res); 35 | } else { 36 | next(); 37 | } 38 | }; 39 | }; 40 | const cookieSession$0 = (userId) => { 41 | return cookieSession({ 42 | name: 'fredy-admin-session', 43 | keys: ['fredy', 'super', 'fancy', 'key', nanoid()], 44 | userId, 45 | maxAge: 2 * 60 * 60 * 1000, // 2 hours 46 | }); 47 | }; 48 | export { cookieSession$0 as cookieSession }; 49 | export { adminInterceptor }; 50 | export { authInterceptor }; 51 | export { isUnauthorized }; 52 | export { isAdmin }; 53 | -------------------------------------------------------------------------------- /test/provider/wgGesucht.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; 7 | import { get } from '../mocks/mockNotification.js'; 8 | import { mockFredy, providerConfig } from '../utils.js'; 9 | import { expect } from 'chai'; 10 | import * as provider from '../../lib/provider/wgGesucht.js'; 11 | 12 | describe('#wgGesucht testsuite()', () => { 13 | provider.init(providerConfig.wgGesucht, [], []); 14 | it('should test wgGesucht provider', async () => { 15 | const Fredy = await mockFredy(); 16 | return await new Promise((resolve) => { 17 | const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'wgGesucht', similarityCache); 18 | fredy.execute().then((listing) => { 19 | expect(listing).to.be.a('array'); 20 | const notificationObj = get(); 21 | expect(notificationObj.serviceName).to.equal('wgGesucht'); 22 | notificationObj.payload.forEach((notify) => { 23 | expect(notify).to.be.a('object'); 24 | /** check the actual structure **/ 25 | expect(notify.id).to.be.a('string'); 26 | expect(notify.title).to.be.a('string'); 27 | expect(notify.details).to.be.a('string'); 28 | expect(notify.price).to.be.a('string'); 29 | expect(notify.link).to.be.a('string'); 30 | }); 31 | resolve(); 32 | }); 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /lib/notification/adapter/apprise.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import { markdown2Html } from '../../services/markdown.js'; 7 | import { getJob } from '../../services/storage/jobStorage.js'; 8 | import fetch from 'node-fetch'; 9 | 10 | export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => { 11 | const { server } = notificationConfig.find((adapter) => adapter.id === config.id).fields; 12 | const job = getJob(jobKey); 13 | const jobName = job == null ? jobKey : job.name; 14 | const promises = newListings.map((newListing) => { 15 | const title = `${jobName} at ${serviceName}: ${newListing.title}`; 16 | const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}`; 17 | return fetch(server, { 18 | method: 'POST', 19 | headers: { 'Content-Type': 'application/json' }, 20 | body: JSON.stringify({ 21 | body: message, 22 | title: title, 23 | }), 24 | }); 25 | }); 26 | return Promise.all(promises); 27 | }; 28 | export const config = { 29 | id: 'apprise', 30 | name: 'Apprise', 31 | readme: markdown2Html('lib/notification/adapter/apprise.md'), 32 | description: 'Fredy will send new listings to your Apprise instance.', 33 | fields: { 34 | server: { 35 | type: 'text', 36 | label: 'Server', 37 | description: 'The server URL to send the notification to.', 38 | }, 39 | }, 40 | }; 41 | -------------------------------------------------------------------------------- /lib/services/extractor/extractor.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import { setDebug } from './utils.js'; 7 | import puppeteerExtractor from './puppeteerExtractor.js'; 8 | import { loadParser, parse } from './parser/parser.js'; 9 | import logger from '../logger.js'; 10 | 11 | const DEFAULT_OPTIONS = { 12 | debug: false, 13 | puppeteerTimeout: 60_000, 14 | puppeteerHeadless: true, 15 | }; 16 | 17 | export default class Extractor { 18 | constructor(options) { 19 | this.options = { 20 | ...DEFAULT_OPTIONS, 21 | ...options, 22 | }; 23 | this.responseText = null; 24 | setDebug(this.options); 25 | } 26 | 27 | /** 28 | * if you are extracting data from a SPA, you must provide a selector, otherwise 29 | * your response will never contain what you are really looking for 30 | * @param url 31 | * @param waitForSelector 32 | */ 33 | execute = async (url, waitForSelector = null) => { 34 | this.responseText = null; 35 | try { 36 | this.responseText = await puppeteerExtractor(url, waitForSelector, this.options); 37 | if (this.responseText != null) { 38 | loadParser(this.responseText); 39 | } 40 | } catch (error) { 41 | logger.error('Error trying to load page.', error); 42 | } 43 | return this; 44 | }; 45 | 46 | parseResponseText = (crawlContainer, crawlFields, url) => { 47 | return parse(crawlContainer, crawlFields, this.responseText, url); 48 | }; 49 | } 50 | -------------------------------------------------------------------------------- /ui/src/components/table/ProviderTable.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import React from 'react'; 7 | 8 | import { Empty, Table, Button } from '@douyinfe/semi-ui'; 9 | import { IconDelete, IconEdit } from '@douyinfe/semi-icons'; 10 | 11 | export default function ProviderTable({ providerData = [], onRemove, onEdit } = {}) { 12 | return ( 13 |
} 16 | columns={[ 17 | { 18 | title: 'Name', 19 | dataIndex: 'name', 20 | }, 21 | { 22 | title: 'URL', 23 | dataIndex: 'url', 24 | render: (_, data) => { 25 | return ( 26 | 27 | Visit site 28 | 29 | ); 30 | }, 31 | }, 32 | { 33 | title: '', 34 | dataIndex: 'tools', 35 | render: (_, record) => { 36 | return ( 37 |
38 |
42 | ); 43 | }, 44 | }, 45 | ]} 46 | dataSource={providerData} 47 | /> 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /copyright.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import fs from 'fs/promises'; 7 | import path from 'path'; 8 | 9 | const COPYRIGHT = `/* 10 | * Copyright (c) ${new Date().getFullYear()} by Christian Kellner. 11 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 12 | */ 13 | 14 | `; 15 | 16 | async function getAllFiles(dir = '.') { 17 | const entries = await fs.readdir(dir, { withFileTypes: true }); 18 | let files = []; 19 | for (let entry of entries) { 20 | const fullPath = path.join(dir, entry.name); 21 | if (entry.isDirectory()) { 22 | if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue; 23 | files = files.concat(await getAllFiles(fullPath)); 24 | } else if (fullPath.endsWith('.js') || fullPath.endsWith('.jsx')) { 25 | files.push(fullPath); 26 | } 27 | } 28 | return files; 29 | } 30 | 31 | /* eslint-disable no-console */ 32 | async function addCopyright(files) { 33 | for (let file of files) { 34 | try { 35 | let content = await fs.readFile(file, 'utf8'); 36 | if (!content.startsWith(COPYRIGHT)) { 37 | await fs.writeFile(file, COPYRIGHT + content); 38 | console.log(`Added copyright to ${file}`); 39 | } 40 | } catch (err) { 41 | console.error(`Error processing ${file}: ${err}`); 42 | } 43 | } 44 | } 45 | /* eslint-enable no-console */ 46 | 47 | const filesToProcess = process.argv.length > 2 ? process.argv.slice(2) : await getAllFiles(); 48 | await addCopyright(filesToProcess); 49 | -------------------------------------------------------------------------------- /test/provider/immonet.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; 7 | import { get } from '../mocks/mockNotification.js'; 8 | import { mockFredy, providerConfig } from '../utils.js'; 9 | import { expect } from 'chai'; 10 | import * as provider from '../../lib/provider/immonet.js'; 11 | 12 | describe('#immonet testsuite()', () => { 13 | it('should test immonet provider', async () => { 14 | const Fredy = await mockFredy(); 15 | provider.init(providerConfig.immonet, [], []); 16 | 17 | const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immonet', similarityCache); 18 | const listing = await fredy.execute(); 19 | 20 | expect(listing).to.be.a('array'); 21 | const notificationObj = get(); 22 | expect(notificationObj).to.be.a('object'); 23 | expect(notificationObj.serviceName).to.equal('immonet'); 24 | notificationObj.payload.forEach((notify) => { 25 | /** check the actual structure **/ 26 | expect(notify.id).to.be.a('string'); 27 | expect(notify.price).to.be.a('string'); 28 | expect(notify.size).to.be.a('string'); 29 | expect(notify.title).to.be.a('string'); 30 | expect(notify.link).to.be.a('string'); 31 | expect(notify.address).to.be.a('string'); 32 | /** check the values if possible **/ 33 | expect(notify.size).that.does.include('m²'); 34 | expect(notify.title).to.be.not.empty; 35 | expect(notify.address).to.be.not.empty; 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /test/provider/mcMakler.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; 7 | import { get } from '../mocks/mockNotification.js'; 8 | import { mockFredy, providerConfig } from '../utils.js'; 9 | import { expect } from 'chai'; 10 | import * as provider from '../../lib/provider/mcMakler.js'; 11 | 12 | describe('#mcMakler testsuite()', () => { 13 | it('should test mcMakler provider', async () => { 14 | const Fredy = await mockFredy(); 15 | provider.init(providerConfig.mcMakler, []); 16 | 17 | const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'mcMakler', similarityCache); 18 | const listing = await fredy.execute(); 19 | 20 | expect(listing).to.be.a('array'); 21 | const notificationObj = get(); 22 | expect(notificationObj).to.be.a('object'); 23 | expect(notificationObj.serviceName).to.equal('mcMakler'); 24 | notificationObj.payload.forEach((notify) => { 25 | /** check the actual structure **/ 26 | expect(notify.id).to.be.a('string'); 27 | expect(notify.price).to.be.a('string'); 28 | expect(notify.size).to.be.a('string'); 29 | expect(notify.title).to.be.a('string'); 30 | expect(notify.link).to.be.a('string'); 31 | expect(notify.address).to.be.a('string'); 32 | /** check the values if possible **/ 33 | expect(notify.size).that.does.include('m²'); 34 | expect(notify.title).to.be.not.empty; 35 | expect(notify.address).to.be.not.empty; 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /test/provider/sparkasse.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; 7 | import { get } from '../mocks/mockNotification.js'; 8 | import { mockFredy, providerConfig } from '../utils.js'; 9 | import { expect } from 'chai'; 10 | import * as provider from '../../lib/provider/sparkasse.js'; 11 | 12 | describe('#sparkasse testsuite()', () => { 13 | it('should test sparkasse provider', async () => { 14 | const Fredy = await mockFredy(); 15 | provider.init(providerConfig.sparkasse, []); 16 | 17 | const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'sparkasse', similarityCache); 18 | const listing = await fredy.execute(); 19 | 20 | expect(listing).to.be.a('array'); 21 | const notificationObj = get(); 22 | expect(notificationObj).to.be.a('object'); 23 | expect(notificationObj.serviceName).to.equal('sparkasse'); 24 | notificationObj.payload.forEach((notify) => { 25 | /** check the actual structure **/ 26 | expect(notify.id).to.be.a('string'); 27 | expect(notify.price).to.be.a('string'); 28 | expect(notify.size).to.be.a('string'); 29 | expect(notify.title).to.be.a('string'); 30 | expect(notify.link).to.be.a('string'); 31 | expect(notify.address).to.be.a('string'); 32 | /** check the values if possible **/ 33 | expect(notify.size).that.does.include('m²'); 34 | expect(notify.title).to.be.not.empty; 35 | expect(notify.address).to.be.not.empty; 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /test/provider/ohneMakler.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; 7 | import { get } from '../mocks/mockNotification.js'; 8 | import { mockFredy, providerConfig } from '../utils.js'; 9 | import { expect } from 'chai'; 10 | import * as provider from '../../lib/provider/ohneMakler.js'; 11 | 12 | describe('#ohneMakler testsuite()', () => { 13 | it('should test ohneMakler provider', async () => { 14 | const Fredy = await mockFredy(); 15 | provider.init(providerConfig.ohneMakler, []); 16 | 17 | const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'ohneMakler', similarityCache); 18 | const listing = await fredy.execute(); 19 | 20 | expect(listing).to.be.a('array'); 21 | const notificationObj = get(); 22 | expect(notificationObj).to.be.a('object'); 23 | expect(notificationObj.serviceName).to.equal('ohneMakler'); 24 | notificationObj.payload.forEach((notify) => { 25 | /** check the actual structure **/ 26 | expect(notify.id).to.be.a('string'); 27 | expect(notify.price).to.be.a('string'); 28 | expect(notify.size).to.be.a('string'); 29 | expect(notify.title).to.be.a('string'); 30 | expect(notify.link).to.be.a('string'); 31 | expect(notify.address).to.be.a('string'); 32 | /** check the values if possible **/ 33 | expect(notify.size).that.does.include('m²'); 34 | expect(notify.title).to.be.not.empty; 35 | expect(notify.address).to.be.not.empty; 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /ui/src/components/version/VersionBanner.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import React from 'react'; 7 | import { Collapse, Descriptions } from '@douyinfe/semi-ui'; 8 | import { useSelector } from '../../services/state/store.js'; 9 | import { MarkdownRender } from '@douyinfe/semi-ui'; 10 | 11 | import './VersionBanner.less'; 12 | 13 | export default function VersionBanner() { 14 | const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate); 15 | return ( 16 | 17 | 18 |
19 |

A new version of Fredy is available. Update now to take advantage of the latest features and bug fixes.

20 | 21 | {versionUpdate.localFredyVersion} 22 | {versionUpdate.version} 23 | 24 | 25 | {versionUpdate.url} 26 | {' '} 27 | 28 | 29 |

30 | 31 | Release Notes 32 | 33 |

34 | 35 |
36 |
37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest an improvement or new idea for Fredy 3 | title: "[Feature]: " 4 | labels: [enhancement] 5 | assignees: [] 6 | 7 | body: 8 | - type: textarea 9 | id: problem 10 | attributes: 11 | label: Related Problem 12 | description: Is your feature request related to a problem? Describe it clearly. 13 | placeholder: "Example: It’s difficult to do X when Y happens..." 14 | validations: 15 | required: false 16 | 17 | - type: textarea 18 | id: solution 19 | attributes: 20 | label: Proposed Feature 21 | description: Describe the feature you would like to see. 22 | placeholder: "I would like Fredy to automatically..." 23 | validations: 24 | required: true 25 | 26 | - type: textarea 27 | id: alternatives 28 | attributes: 29 | label: Alternatives Considered 30 | description: List any alternative solutions or workarounds you’ve tried or thought about. 31 | placeholder: "Instead of this, I also considered..." 32 | validations: 33 | required: false 34 | 35 | - type: textarea 36 | id: benefits 37 | attributes: 38 | label: Benefits 39 | description: Explain how this feature would improve Fredy or it's user experience. 40 | placeholder: "This would save users time by..." 41 | validations: 42 | required: true 43 | 44 | - type: textarea 45 | id: context 46 | attributes: 47 | label: Additional Context 48 | description: Add any other context, examples, or screenshots that might help clarify your idea. 49 | placeholder: "Any other relevant information..." 50 | validations: 51 | required: false 52 | -------------------------------------------------------------------------------- /lib/provider/ohneMakler.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import { isOneOf, buildHash } from '../utils.js'; 7 | import checkIfListingIsActive from '../services/listings/listingActiveTester.js'; 8 | let appliedBlackList = []; 9 | 10 | function normalize(o) { 11 | const link = metaInformation.baseUrl + o.link; 12 | const id = buildHash(o.title, o.link, o.price); 13 | return Object.assign(o, { link, id }); 14 | } 15 | function applyBlacklist(o) { 16 | const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList); 17 | const descNotBlacklisted = !isOneOf(o.description, appliedBlackList); 18 | return titleNotBlacklisted && descNotBlacklisted; 19 | } 20 | const config = { 21 | url: null, 22 | crawlContainer: 'div[data-livecomponent-id*="search/property_list"] .grid > div', 23 | sortByDateParam: null, 24 | waitForSelector: null, 25 | crawlFields: { 26 | id: 'a@href', 27 | title: 'h4 | removeNewline | trim', 28 | price: '.text-xl | trim', 29 | size: 'div[title="Wohnfläche"] | trim', 30 | address: '.text-slate-800 | removeNewline | trim', 31 | image: 'img@src', 32 | link: 'a@href', 33 | }, 34 | normalize: normalize, 35 | filter: applyBlacklist, 36 | activeTester: checkIfListingIsActive, 37 | }; 38 | 39 | export const init = (sourceConfig, blacklist) => { 40 | config.enabled = sourceConfig.enabled; 41 | config.url = sourceConfig.url; 42 | appliedBlackList = blacklist || []; 43 | }; 44 | 45 | export const metaInformation = { 46 | name: 'OhneMakler', 47 | baseUrl: 'https://www.ohne-makler.net/immobilien', 48 | id: 'ohneMakler', 49 | }; 50 | export { config }; 51 | -------------------------------------------------------------------------------- /lib/notification/adapter/mattermost.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import { markdown2Html } from '../../services/markdown.js'; 7 | import { getJob } from '../../services/storage/jobStorage.js'; 8 | import fetch from 'node-fetch'; 9 | export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => { 10 | const { webhook, channel } = notificationConfig.find((adapter) => adapter.id === config.id).fields; 11 | const job = getJob(jobKey); 12 | const jobName = job == null ? jobKey : job.name; 13 | let message = `### *${jobName}* (${serviceName}) found **${newListings.length}** new listings:\n\n`; 14 | message += `| Title | Address | Size | Price |\n|:----|:----|:----|:----|\n`; 15 | message += newListings.map( 16 | (o) => `| [${o.title}](${o.link}) | ` + [o.address, o.size.replace(/2m/g, '$m^2$'), o.price].join(' | ') + ' |\n', 17 | ); 18 | return fetch(webhook, { 19 | method: 'POST', 20 | headers: { 'Content-Type': 'application/json' }, 21 | body: JSON.stringify({ 22 | channel: channel, 23 | text: message, 24 | }), 25 | }); 26 | }; 27 | export const config = { 28 | id: 'mattermost', 29 | name: 'Mattermost', 30 | readme: markdown2Html('lib/notification/adapter/mattermost.md'), 31 | description: 'Fredy will send new listings to your mattermost team chat.', 32 | fields: { 33 | webhook: { 34 | type: 'text', 35 | label: 'Webhook-URL', 36 | description: 'The incoming webhook url', 37 | }, 38 | channel: { 39 | type: 'text', 40 | label: 'Channel', 41 | description: 'The channel where fredy should send notifications to.', 42 | }, 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /test/provider/neubauKompass.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; 7 | import { get } from '../mocks/mockNotification.js'; 8 | import { mockFredy, providerConfig } from '../utils.js'; 9 | import { expect } from 'chai'; 10 | import * as provider from '../../lib/provider/neubauKompass.js'; 11 | 12 | describe('#neubauKompass testsuite()', () => { 13 | provider.init(providerConfig.neubauKompass, [], []); 14 | it('should test neubauKompass provider', async () => { 15 | const Fredy = await mockFredy(); 16 | return await new Promise((resolve) => { 17 | const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'neubauKompass', similarityCache); 18 | fredy.execute().then((listing) => { 19 | expect(listing).to.be.a('array'); 20 | const notificationObj = get(); 21 | expect(notificationObj.serviceName).to.equal('neubauKompass'); 22 | notificationObj.payload.forEach((notify) => { 23 | expect(notify).to.be.a('object'); 24 | /** check the actual structure **/ 25 | expect(notify.id).to.be.a('string'); 26 | expect(notify.title).to.be.a('string'); 27 | expect(notify.link).to.be.a('string'); 28 | expect(notify.address).to.be.a('string'); 29 | /** check the values if possible **/ 30 | expect(notify.title).to.be.not.empty; 31 | expect(notify.link).that.does.include('https://www.neubaukompass.de'); 32 | expect(notify.address).to.be.not.empty; 33 | }); 34 | resolve(); 35 | }); 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /test/provider/kleinanzeigen.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; 7 | import { get } from '../mocks/mockNotification.js'; 8 | import { mockFredy, providerConfig } from '../utils.js'; 9 | import { expect } from 'chai'; 10 | import * as provider from '../../lib/provider/kleinanzeigen.js'; 11 | 12 | describe('#kleinanzeigen testsuite()', () => { 13 | it('should test kleinanzeigen provider', async () => { 14 | const Fredy = await mockFredy(); 15 | provider.init(providerConfig.kleinanzeigen, [], []); 16 | return await new Promise((resolve) => { 17 | const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'kleinanzeigen', similarityCache); 18 | fredy.execute().then((listing) => { 19 | expect(listing).to.be.a('array'); 20 | const notificationObj = get(); 21 | expect(notificationObj).to.be.a('object'); 22 | expect(notificationObj.serviceName).to.equal('kleinanzeigen'); 23 | notificationObj.payload.forEach((notify) => { 24 | /** check the actual structure **/ 25 | expect(notify.id).to.be.a('string'); 26 | expect(notify.title).to.be.a('string'); 27 | expect(notify.link).to.be.a('string'); 28 | expect(notify.address).to.be.a('string'); 29 | /** check the values if possible **/ 30 | expect(notify.title).to.be.not.empty; 31 | expect(notify.link).that.does.include('https://www.kleinanzeigen.de'); 32 | expect(notify.address).to.be.not.empty; 33 | }); 34 | resolve(); 35 | }); 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /test/provider/immoswp.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; 7 | import { get } from '../mocks/mockNotification.js'; 8 | import { mockFredy, providerConfig } from '../utils.js'; 9 | import { expect } from 'chai'; 10 | import * as provider from '../../lib/provider/immoswp.js'; 11 | 12 | describe('#immoswp testsuite()', () => { 13 | provider.init(providerConfig.immoswp, [], []); 14 | it('should test immoswp provider', async () => { 15 | const Fredy = await mockFredy(); 16 | return await new Promise((resolve) => { 17 | const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immoswp', similarityCache); 18 | fredy.execute().then((listing) => { 19 | expect(listing).to.be.a('array'); 20 | const notificationObj = get(); 21 | expect(notificationObj).to.be.a('object'); 22 | expect(notificationObj.serviceName).to.equal('immoswp'); 23 | notificationObj.payload.forEach((notify) => { 24 | /** check the actual structure **/ 25 | expect(notify.id).to.be.a('string'); 26 | expect(notify.price).to.be.a('string'); 27 | expect(notify.size).to.be.a('string'); 28 | expect(notify.title).to.be.a('string'); 29 | expect(notify.link).to.be.a('string'); 30 | /** check the values if possible **/ 31 | expect(notify.price).that.does.include('€'); 32 | expect(notify.title).to.be.not.empty; 33 | expect(notify.link).that.does.include('https://immo.swp.de'); 34 | }); 35 | resolve(); 36 | }); 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /test/provider/immowelt.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; 7 | import { get } from '../mocks/mockNotification.js'; 8 | import { mockFredy, providerConfig } from '../utils.js'; 9 | import { expect } from 'chai'; 10 | import * as provider from '../../lib/provider/immowelt.js'; 11 | 12 | describe('#immowelt testsuite()', () => { 13 | it('should test immowelt provider', async () => { 14 | const Fredy = await mockFredy(); 15 | provider.init(providerConfig.immowelt, [], []); 16 | 17 | const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immowelt', similarityCache); 18 | const listing = await fredy.execute(); 19 | 20 | expect(listing).to.be.a('array'); 21 | const notificationObj = get(); 22 | expect(notificationObj).to.be.a('object'); 23 | expect(notificationObj.serviceName).to.equal('immowelt'); 24 | notificationObj.payload.forEach((notify) => { 25 | /** check the actual structure **/ 26 | expect(notify.id).to.be.a('string'); 27 | expect(notify.price).to.be.a('string'); 28 | expect(notify.title).to.be.a('string'); 29 | expect(notify.link).to.be.a('string'); 30 | expect(notify.address).to.be.a('string'); 31 | /** check the values if possible **/ 32 | if (notify.size != null && notify.size.trim().toLowerCase() !== 'k.a.') { 33 | expect(notify.size).that.does.include('m²'); 34 | } 35 | expect(notify.title).to.be.not.empty; 36 | expect(notify.link).that.does.include('https://www.immowelt.de'); 37 | expect(notify.address).to.be.not.empty; 38 | }); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /test/queryStringMutator/queryStringMutator.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import fs from 'fs'; 7 | import { expect } from 'chai'; 8 | import { readFile } from 'fs/promises'; 9 | import mutator from '../../lib/services/queryStringMutator.js'; 10 | import queryString from 'query-string'; 11 | 12 | const data = await readFile(new URL('./testData.json', import.meta.url)); 13 | 14 | const testData = JSON.parse(data); 15 | 16 | let _provider = await Promise.all( 17 | fs.readdirSync('./lib/provider/').map(async (integPath) => await import(`../../lib/provider/${integPath}`)), 18 | ); 19 | 20 | /** 21 | * Test test might look a bit weird at first, but listen stranger... 22 | * It's not wise to compare 2 urls, as this means all url params must be in the expected order. This is however not 23 | * guaranteed, as params (and their order) are totally variable. 24 | */ 25 | describe('queryStringMutator', () => { 26 | it('should fix all urls', () => { 27 | for (let test of testData) { 28 | const provider = _provider.find((p) => p.metaInformation.id === test.id); 29 | if (provider == null) { 30 | throw new Error(`Cannot find provider for given id: ${test.id}`); 31 | } 32 | const fixedUrl = mutator(test.url, provider.config.sortByDateParam); 33 | const expectedParams = queryString.parseUrl(test.shouldBecome); 34 | const actualParams = queryString.parseUrl(fixedUrl); 35 | //check if all new params are existing 36 | expect(Object.keys(expectedParams.query)).to.include.members(Object.keys(actualParams.query)); 37 | expect(Object.values(expectedParams.query)).to.include.members(Object.values(actualParams.query)); 38 | } 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /test/provider/regionalimmobilien24.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; 7 | import { get } from '../mocks/mockNotification.js'; 8 | import { mockFredy, providerConfig } from '../utils.js'; 9 | import { expect } from 'chai'; 10 | import * as provider from '../../lib/provider/regionalimmobilien24.js'; 11 | 12 | describe('#regionalimmobilien24 testsuite()', () => { 13 | it('should test regionalimmobilien24 provider', async () => { 14 | const Fredy = await mockFredy(); 15 | provider.init(providerConfig.regionalimmobilien24, []); 16 | 17 | const fredy = new Fredy( 18 | provider.config, 19 | null, 20 | provider.metaInformation.id, 21 | 'regionalimmobilien24', 22 | similarityCache, 23 | ); 24 | const listing = await fredy.execute(); 25 | 26 | expect(listing).to.be.a('array'); 27 | const notificationObj = get(); 28 | expect(notificationObj).to.be.a('object'); 29 | expect(notificationObj.serviceName).to.equal('regionalimmobilien24'); 30 | notificationObj.payload.forEach((notify) => { 31 | /** check the actual structure **/ 32 | expect(notify.id).to.be.a('string'); 33 | expect(notify.price).to.be.a('string'); 34 | expect(notify.size).to.be.a('string'); 35 | expect(notify.title).to.be.a('string'); 36 | expect(notify.link).to.be.a('string'); 37 | expect(notify.address).to.be.a('string'); 38 | /** check the values if possible **/ 39 | expect(notify.size).that.does.include('m²'); 40 | expect(notify.title).to.be.not.empty; 41 | expect(notify.address).to.be.not.empty; 42 | }); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /lib/provider/neubauKompass.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import { isOneOf, buildHash } from '../utils.js'; 7 | import checkIfListingIsActive from '../services/listings/listingActiveTester.js'; 8 | 9 | let appliedBlackList = []; 10 | 11 | function nullOrEmpty(val) { 12 | return val == null || val.length === 0; 13 | } 14 | 15 | function normalize(o) { 16 | const link = nullOrEmpty(o.link) 17 | ? 'NO LINK' 18 | : `https://www.neubaukompass.de${o.link.substring(o.link.indexOf('/neubau'))}`; 19 | const id = buildHash(o.link, o.price); 20 | return Object.assign(o, { id, link }); 21 | } 22 | 23 | function applyBlacklist(o) { 24 | return !isOneOf(o.title, appliedBlackList); 25 | } 26 | 27 | const config = { 28 | url: null, 29 | crawlContainer: '.col-12.mb-4', 30 | sortByDateParam: 'Sortierung=Id&Richtung=DESC', 31 | waitForSelector: 'div[data-live-name-value="SearchList"]', 32 | crawlFields: { 33 | id: 'a@href', 34 | title: 'a@title | removeNewline | trim', 35 | link: 'a@href', 36 | address: '.nbk-project-card__description | removeNewline | trim', 37 | price: '.nbk-project-card__spec-item .nbk-project-card__spec-value | removeNewline | trim', 38 | image: '.nbk-project-card__image@src', 39 | }, 40 | normalize: normalize, 41 | filter: applyBlacklist, 42 | activeTester: checkIfListingIsActive, 43 | }; 44 | export const init = (sourceConfig, blacklist) => { 45 | config.enabled = sourceConfig.enabled; 46 | config.url = sourceConfig.url; 47 | appliedBlackList = blacklist || []; 48 | }; 49 | export const metaInformation = { 50 | name: 'Neubau Kompass', 51 | baseUrl: 'https://www.neubaukompass.de/', 52 | id: 'neubauKompass', 53 | }; 54 | export { config }; 55 | -------------------------------------------------------------------------------- /lib/notification/adapter/http.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import { markdown2Html } from '../../services/markdown.js'; 7 | 8 | const mapListing = (listing) => ({ 9 | address: listing.address, 10 | description: listing.description, 11 | id: listing.id, 12 | imageUrl: listing.image, 13 | price: listing.price, 14 | size: listing.size, 15 | title: listing.title, 16 | url: listing.link, 17 | }); 18 | 19 | export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => { 20 | const { authToken, endpointUrl } = notificationConfig.find((a) => a.id === config.id).fields; 21 | 22 | const listings = newListings.map(mapListing); 23 | const body = { 24 | jobId: jobKey, 25 | timestamp: new Date().toISOString(), 26 | provider: serviceName, 27 | listings, 28 | }; 29 | 30 | const headers = { 31 | 'Content-Type': 'application/json', 32 | }; 33 | if (authToken != null) { 34 | headers['Authorization'] = `Bearer ${authToken}`; 35 | } 36 | 37 | return fetch(endpointUrl, { 38 | method: 'POST', 39 | headers: headers, 40 | body: JSON.stringify(body), 41 | }); 42 | }; 43 | 44 | export const config = { 45 | id: 'http', 46 | name: 'HTTP', 47 | readme: markdown2Html('lib/notification/adapter/http.md'), 48 | description: 'Fredy will send a generic HTTP POST request.', 49 | fields: { 50 | endpointUrl: { 51 | description: "Your application's endpoint URL.", 52 | label: 'Endpoint URL', 53 | type: 'text', 54 | }, 55 | authToken: { 56 | description: "Your application's auth token, if required by your endpoint.", 57 | label: 'Auth token (optional)', 58 | optional: true, 59 | type: 'text', 60 | }, 61 | }, 62 | }; 63 | -------------------------------------------------------------------------------- /test/provider/immoscout.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import { expect } from 'chai'; 7 | import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; 8 | import { mockFredy, providerConfig } from '../utils.js'; 9 | import { get } from '../mocks/mockNotification.js'; 10 | import * as provider from '../../lib/provider/immoscout.js'; 11 | 12 | describe('#immoscout provider testsuite()', () => { 13 | provider.init(providerConfig.immoscout, [], []); 14 | it('should test immoscout provider', async () => { 15 | const Fredy = await mockFredy(); 16 | return await new Promise((resolve) => { 17 | const fredy = new Fredy(provider.config, null, provider.metaInformation.id, '', similarityCache); 18 | fredy.execute().then((listings) => { 19 | expect(listings).to.be.a('array'); 20 | const notificationObj = get(); 21 | expect(notificationObj).to.be.a('object'); 22 | expect(notificationObj.serviceName).to.equal('immoscout'); 23 | notificationObj.payload.forEach((notify) => { 24 | /** check the actual structure **/ 25 | expect(notify.id).to.be.a('string'); 26 | expect(notify.price).to.be.a('string'); 27 | expect(notify.size).to.be.a('string'); 28 | expect(notify.title).to.be.a('string'); 29 | expect(notify.link).to.be.a('string'); 30 | expect(notify.address).to.be.a('string'); 31 | /** check the values if possible **/ 32 | expect(notify.size).to.be.not.empty; 33 | expect(notify.title).to.be.not.empty; 34 | expect(notify.link).that.does.include('https://www.immobilienscout24.de/'); 35 | }); 36 | resolve(); 37 | }); 38 | }); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /lib/provider/wgGesucht.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import { isOneOf, buildHash } from '../utils.js'; 7 | import checkIfListingIsActive from '../services/listings/listingActiveTester.js'; 8 | 9 | let appliedBlackList = []; 10 | 11 | function normalize(o) { 12 | const id = buildHash(o.id, o.price); 13 | const link = `https://www.wg-gesucht.de${o.link}`; 14 | const image = o.image != null ? o.image.replace('small', 'large') : null; 15 | return Object.assign(o, { id, link, image }); 16 | } 17 | 18 | function applyBlacklist(o) { 19 | const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList); 20 | const descNotBlacklisted = !isOneOf(o.description, appliedBlackList); 21 | return o.id != null && titleNotBlacklisted && descNotBlacklisted; 22 | } 23 | 24 | const config = { 25 | url: null, 26 | crawlContainer: '#main_column .wgg_card', 27 | sortByDateParam: 'sort_column=0&sort_order=0', 28 | waitForSelector: 'body', 29 | crawlFields: { 30 | id: '@data-id', 31 | details: '.row .noprint .col-xs-11 |removeNewline |trim', 32 | price: '.middle .col-xs-3 |removeNewline |trim', 33 | size: '.middle .text-right |removeNewline |trim', 34 | title: '.truncate_title a |removeNewline |trim', 35 | link: '.truncate_title a@href', 36 | image: '.img-responsive@src', 37 | }, 38 | normalize: normalize, 39 | filter: applyBlacklist, 40 | activeTester: checkIfListingIsActive, 41 | }; 42 | export const init = (sourceConfig, blacklist) => { 43 | config.enabled = sourceConfig.enabled; 44 | config.url = sourceConfig.url; 45 | appliedBlackList = blacklist || []; 46 | }; 47 | export const metaInformation = { 48 | name: 'Wg gesucht', 49 | baseUrl: 'https://www.wg-gesucht.de/', 50 | id: 'wgGesucht', 51 | }; 52 | export { config }; 53 | -------------------------------------------------------------------------------- /lib/api/routes/loginRoute.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import restana from 'restana'; 7 | import * as userStorage from '../../services/storage/userStorage.js'; 8 | import * as hasher from '../../services/security/hash.js'; 9 | import { trackDemoAccessed } from '../../services/tracking/Tracker.js'; 10 | import logger from '../../services/logger.js'; 11 | import { getSettings } from '../../services/storage/settingsStorage.js'; 12 | const service = restana(); 13 | const loginRouter = service.newRouter(); 14 | loginRouter.get('/user', async (req, res) => { 15 | const currentUserId = req.session.currentUser; 16 | const currentUser = currentUserId == null ? null : userStorage.getUser(currentUserId); 17 | if (currentUser == null) { 18 | res.body = {}; 19 | } else { 20 | res.body = { 21 | userId: currentUser.id, 22 | isAdmin: currentUser.isAdmin, 23 | }; 24 | } 25 | res.send(); 26 | }); 27 | loginRouter.post('/', async (req, res) => { 28 | const settings = await getSettings(); 29 | const { username, password } = req.body; 30 | const user = userStorage.getUsers(true).find((user) => user.username === username); 31 | if (user == null) { 32 | res.send(401); 33 | return; 34 | } 35 | if (user.password === hasher.hash(password)) { 36 | if (settings.demoMode) { 37 | await trackDemoAccessed(); 38 | } 39 | 40 | req.session.currentUser = user.id; 41 | userStorage.setLastLoginToNow({ userId: user.id }); 42 | res.send(200); 43 | return; 44 | } else { 45 | logger.error(`User ${username} tried to login, but password was wrong.`); 46 | } 47 | res.send(401); 48 | }); 49 | loginRouter.post('/logout', async (req, res) => { 50 | req.session = null; 51 | res.send(200); 52 | }); 53 | export { loginRouter }; 54 | -------------------------------------------------------------------------------- /test/provider/einsAImmobilien.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; 7 | import { get } from '../mocks/mockNotification.js'; 8 | import { providerConfig, mockFredy } from '../utils.js'; 9 | import { expect } from 'chai'; 10 | import * as provider from '../../lib/provider/einsAImmobilien.js'; 11 | 12 | describe('#einsAImmobilien testsuite()', () => { 13 | provider.init(providerConfig.einsAImmobilien, [], []); 14 | it('should test einsAImmobilien provider', async () => { 15 | const Fredy = await mockFredy(); 16 | return await new Promise((resolve) => { 17 | const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'einsAImmobilien', similarityCache); 18 | fredy.execute().then((listings) => { 19 | expect(listings).to.be.a('array'); 20 | const notificationObj = get(); 21 | expect(notificationObj).to.be.a('object'); 22 | expect(notificationObj.serviceName).to.equal('einsAImmobilien'); 23 | notificationObj.payload.forEach((notify) => { 24 | /** check the actual structure **/ 25 | expect(notify.id).to.be.a('string'); 26 | expect(notify.price).to.be.a('string'); 27 | expect(notify.size).to.be.a('string'); 28 | expect(notify.title).to.be.a('string'); 29 | expect(notify.link).to.be.a('string'); 30 | expect(notify.address).to.be.a('string'); 31 | /** check the values if possible **/ 32 | expect(notify.size).to.be.not.empty; 33 | expect(notify.title).to.be.not.empty; 34 | expect(notify.link).that.does.include('https://www.1a-immobilienmarkt.de'); 35 | }); 36 | resolve(); 37 | }); 38 | }); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /lib/api/routes/notificationAdapterRouter.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import fs from 'fs'; 7 | import restana from 'restana'; 8 | const service = restana(); 9 | const notificationAdapterRouter = service.newRouter(); 10 | const notificationAdapterList = fs.readdirSync('./lib//notification/adapter').filter((file) => file.endsWith('.js')); 11 | const notificationAdapter = await Promise.all( 12 | notificationAdapterList.map(async (pro) => { 13 | return await import(`../../notification/adapter/${pro}`); 14 | }), 15 | ); 16 | notificationAdapterRouter.post('/try', async (req, res) => { 17 | const { id, fields } = req.body; 18 | const adapter = notificationAdapter.find((adapter) => adapter.config.id === id); 19 | if (adapter == null) { 20 | res.send(404); 21 | } 22 | const notificationConfig = []; 23 | const notificationObject = {}; 24 | Object.keys(fields).forEach((key) => { 25 | notificationObject[key] = fields[key].value; 26 | }); 27 | notificationConfig.push({ 28 | fields: { ...notificationObject }, 29 | enabled: true, 30 | id, 31 | }); 32 | try { 33 | await adapter.send({ 34 | serviceName: 'TestCall', 35 | newListings: [ 36 | { 37 | price: '42 €', 38 | title: 'This is a test listing', 39 | address: 'some address', 40 | size: '666 2m', 41 | link: 'https://www.orange-coding.net', 42 | }, 43 | ], 44 | notificationConfig, 45 | jobKey: 'TestJob', 46 | }); 47 | res.send(); 48 | } catch (Exception) { 49 | res.send(new Error(Exception)); 50 | } 51 | }); 52 | notificationAdapterRouter.get('/', async (req, res) => { 53 | res.body = notificationAdapter.map((adapter) => adapter.config); 54 | res.send(); 55 | }); 56 | export { notificationAdapterRouter }; 57 | -------------------------------------------------------------------------------- /lib/services/logger.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | const COLORS = { 7 | debug: '\x1b[36m', 8 | info: '\x1b[32m', 9 | warn: '\x1b[33m', 10 | error: '\x1b[31m', 11 | reset: '\x1b[0m', 12 | }; 13 | 14 | const env = process.env.NODE_ENV || 'development'; 15 | const useColor = process.stdout.isTTY || process.stderr.isTTY; 16 | 17 | function ts() { 18 | const d = new Date(); 19 | const yyyy = d.getFullYear(); 20 | const mm = String(d.getMonth() + 1).padStart(2, '0'); 21 | const dd = String(d.getDate()).padStart(2, '0'); 22 | const hh = String(d.getHours()).padStart(2, '0'); 23 | const mi = String(d.getMinutes()).padStart(2, '0'); 24 | const ss = String(d.getSeconds()).padStart(2, '0'); 25 | return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${ss}`; 26 | } 27 | 28 | function lvl(level) { 29 | const upper = level.toUpperCase(); 30 | if (!useColor) return upper; 31 | return `${COLORS[level] || ''}${upper}${COLORS.reset}`; 32 | } 33 | 34 | /* eslint-disable no-console */ 35 | function log(level, ...args) { 36 | if (level === 'debug' && env !== 'development') { 37 | return; // Skip debug logs in non-development environments 38 | } 39 | 40 | const prefix = `[${ts()}] ${lvl(level)}:`; 41 | switch (level) { 42 | case 'debug': 43 | console.debug(prefix, ...args); 44 | break; 45 | case 'info': 46 | console.info(prefix, ...args); 47 | break; 48 | case 'warn': 49 | console.warn(prefix, ...args); 50 | break; 51 | case 'error': 52 | console.error(prefix, ...args); 53 | break; 54 | default: 55 | console.log(prefix, ...args); 56 | } 57 | } 58 | 59 | export default { 60 | debug: (...a) => log('debug', ...a), 61 | info: (...a) => log('info', ...a), 62 | warn: (...a) => log('warn', ...a), 63 | error: (...a) => log('error', ...a), 64 | }; 65 | -------------------------------------------------------------------------------- /ui/src/components/table/UserTable.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import React from 'react'; 7 | 8 | import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations'; 9 | import { format } from '../../services/time/timeService'; 10 | import { Table, Button, Empty } from '@douyinfe/semi-ui'; 11 | import { IconDelete, IconEdit } from '@douyinfe/semi-icons'; 12 | 13 | const empty = ( 14 | } 16 | darkModeImage={} 17 | description={'No users found.'} 18 | /> 19 | ); 20 | 21 | export default function UserTable({ user = [], onUserRemoval, onUserEdit } = {}) { 22 | return ( 23 |
{ 35 | return format(value); 36 | }, 37 | }, 38 | { 39 | title: 'Number of jobs', 40 | dataIndex: 'numberOfJobs', 41 | }, 42 | { 43 | title: '', 44 | dataIndex: 'tools', 45 | render: (value, user) => { 46 | return ( 47 |
48 |
56 | ); 57 | }, 58 | }, 59 | ]} 60 | dataSource={user} 61 | /> 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /test/provider/immobilienDe.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; 7 | import { get } from '../mocks/mockNotification.js'; 8 | import { providerConfig, mockFredy } from '../utils.js'; 9 | import { expect } from 'chai'; 10 | import * as provider from '../../lib/provider/immobilienDe.js'; 11 | 12 | describe('#immobilien.de testsuite()', () => { 13 | provider.init(providerConfig.immobilienDe, [], []); 14 | it('should test immobilien.de provider', async () => { 15 | const Fredy = await mockFredy(); 16 | return await new Promise((resolve) => { 17 | const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'test1', similarityCache); 18 | fredy.execute().then((listing) => { 19 | expect(listing).to.be.a('array'); 20 | const notificationObj = get(); 21 | expect(notificationObj).to.be.a('object'); 22 | expect(notificationObj.serviceName).to.equal('immobilienDe'); 23 | notificationObj.payload.forEach((notify) => { 24 | /** check the actual structure **/ 25 | expect(notify.id).to.be.a('string'); 26 | expect(notify.price).to.be.a('string'); 27 | expect(notify.size).to.be.a('string'); 28 | expect(notify.title).to.be.a('string'); 29 | expect(notify.link).to.be.a('string'); 30 | expect(notify.address).to.be.a('string'); 31 | /** check the values if possible **/ 32 | expect(notify.price).that.does.include('€'); 33 | expect(notify.size).that.does.include('m²'); 34 | expect(notify.title).to.be.not.empty; 35 | expect(notify.link).that.does.include('https://www.immobilien.de'); 36 | expect(notify.address).to.be.not.empty; 37 | }); 38 | resolve(); 39 | }); 40 | }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /lib/provider/sparkasse.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import { isOneOf, buildHash } from '../utils.js'; 7 | import checkIfListingIsActive from '../services/listings/listingActiveTester.js'; 8 | let appliedBlackList = []; 9 | 10 | function normalize(o) { 11 | const originalId = o.id.split('/').pop().replace('.html', ''); 12 | const id = buildHash(originalId, o.price); 13 | const size = o.size?.replace(' Wohnfläche', '') ?? null; 14 | const title = o.title || 'No title available'; 15 | const link = o.link != null ? `https://immobilien.sparkasse.de${o.link}` : config.url; 16 | return Object.assign(o, { id, size, title, link }); 17 | } 18 | function applyBlacklist(o) { 19 | const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList); 20 | const descNotBlacklisted = !isOneOf(o.description, appliedBlackList); 21 | return titleNotBlacklisted && descNotBlacklisted; 22 | } 23 | const config = { 24 | url: null, 25 | crawlContainer: '.estate-list-item-row', 26 | sortByDateParam: 'sortBy=date_desc', 27 | waitForSelector: 'body', 28 | crawlFields: { 29 | id: 'div[data-testid="estate-link"] a@href', 30 | title: 'h3 | trim', 31 | price: '.estate-list-price | trim', 32 | size: '.estate-mainfact:first-child span | trim', 33 | address: 'h6 | trim', 34 | image: '.estate-list-item-image-container img@src', 35 | link: 'div[data-testid="estate-link"] a@href', 36 | }, 37 | normalize: normalize, 38 | filter: applyBlacklist, 39 | activeTester: checkIfListingIsActive, 40 | }; 41 | export const init = (sourceConfig, blacklist) => { 42 | config.enabled = sourceConfig.enabled; 43 | config.url = sourceConfig.url; 44 | appliedBlackList = blacklist || []; 45 | }; 46 | export const metaInformation = { 47 | name: 'Sparkasse Immobilien', 48 | baseUrl: 'https://immobilien.sparkasse.de/', 49 | id: 'sparkasse', 50 | }; 51 | export { config }; 52 | -------------------------------------------------------------------------------- /lib/provider/mcMakler.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import { isOneOf, buildHash } from '../utils.js'; 7 | import checkIfListingIsActive from '../services/listings/listingActiveTester.js'; 8 | let appliedBlackList = []; 9 | 10 | function normalize(o) { 11 | const originalId = o.id.split('/').pop(); 12 | const id = buildHash(originalId, o.price); 13 | const size = o.size ?? 'N/A m²'; 14 | const title = o.title || 'No title available'; 15 | const address = o.address?.replace(' / ', ' ') || null; 16 | const link = o.link != null ? `https://www.mcmakler.de${o.link}` : config.url; 17 | return Object.assign(o, { id, size, title, link, address }); 18 | } 19 | function applyBlacklist(o) { 20 | const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList); 21 | const descNotBlacklisted = !isOneOf(o.description, appliedBlackList); 22 | return titleNotBlacklisted && descNotBlacklisted; 23 | } 24 | const config = { 25 | url: null, 26 | crawlContainer: 'article[data-testid="propertyCard"]', 27 | sortByDateParam: 'sortBy=DATE&sortOn=DESC', 28 | waitForSelector: 'ul[data-testid="listsContainer"]', 29 | crawlFields: { 30 | id: 'h2 a@href', 31 | title: 'h2 a | removeNewline | trim', 32 | price: 'footer > p:first-of-type | trim', 33 | size: 'footer > p:nth-of-type(2) | trim', 34 | address: 'div > h2 + p | removeNewline | trim', 35 | image: 'img@src', 36 | link: 'h2 a@href', 37 | }, 38 | normalize: normalize, 39 | filter: applyBlacklist, 40 | activeTester: checkIfListingIsActive, 41 | }; 42 | export const init = (sourceConfig, blacklist) => { 43 | config.enabled = sourceConfig.enabled; 44 | config.url = sourceConfig.url; 45 | appliedBlackList = blacklist || []; 46 | }; 47 | export const metaInformation = { 48 | name: 'McMakler', 49 | baseUrl: 'https://www.mcmakler.de/immobilien/', 50 | id: 'mcMakler', 51 | }; 52 | export { config }; 53 | -------------------------------------------------------------------------------- /lib/notification/adapter/sqlite.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import { markdown2Html } from '../../services/markdown.js'; 7 | import Database from 'better-sqlite3'; 8 | import path from 'path'; 9 | import fs from 'fs'; 10 | 11 | export const send = ({ serviceName, newListings, jobKey, notificationConfig }) => { 12 | const sqliteConfig = notificationConfig.find((adapter) => adapter.id === config.id); 13 | const dbPath = sqliteConfig?.fields?.dbPath || 'db/listings.db'; 14 | 15 | const dbDir = path.dirname(dbPath); 16 | if (!fs.existsSync(dbDir)) { 17 | fs.mkdirSync(dbDir, { recursive: true }); 18 | } 19 | 20 | const db = new Database(dbPath); 21 | const fields = [ 22 | 'serviceName', 23 | 'jobKey', 24 | 'id', 25 | 'size', 26 | 'rooms', 27 | 'price', 28 | 'address', 29 | 'title', 30 | 'link', 31 | 'description', 32 | 'image', 33 | ]; 34 | db.prepare(`CREATE TABLE IF NOT EXISTS listing (${fields.join(' TEXT, ')} TEXT);`).run(); 35 | const insert = db.prepare(`INSERT INTO listing (${fields.join(', ')}) VALUES (@${fields.join(', @')})`); 36 | newListings.map((listing) => { 37 | let insertListing = {}; 38 | fields.map((field) => { 39 | insertListing[field] = listing[field]; 40 | }); 41 | insertListing.serviceName = serviceName; 42 | insertListing.jobKey = jobKey; 43 | insert.run(insertListing); 44 | }); 45 | return Promise.resolve(); 46 | }; 47 | export const config = { 48 | id: 'sqlite', 49 | name: 'SQLite', 50 | description: 'This adapter stores listings in a local SQLite 3 database.', 51 | fields: { 52 | dbPath: { 53 | type: 'text', 54 | label: 'Database Path', 55 | description: 56 | 'Path to the SQLite database file (e.g., db/listings.db). If not specified, defaults to db/listings.db', 57 | placeholder: 'db/listings.db', 58 | }, 59 | }, 60 | readme: markdown2Html('lib/notification/adapter/sqlite.md'), 61 | }; 62 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ================================ 2 | # Stage 1: Build stage 3 | # ================================ 4 | FROM node:22-alpine AS builder 5 | 6 | WORKDIR /build 7 | 8 | # Install build dependencies needed for native modules (better-sqlite3) 9 | RUN apk add --no-cache python3 make g++ 10 | 11 | # Copy package files first for better layer caching 12 | COPY package.json yarn.lock ./ 13 | 14 | # Install all dependencies (including devDependencies for building) 15 | RUN yarn config set network-timeout 600000 \ 16 | && yarn --frozen-lockfile 17 | 18 | # Copy source files needed for build 19 | COPY index.html vite.config.js ./ 20 | COPY ui ./ui 21 | COPY lib ./lib 22 | 23 | # Build frontend assets 24 | RUN yarn build:frontend 25 | 26 | # ================================ 27 | # Stage 2: Production stage 28 | # ================================ 29 | FROM node:22-alpine 30 | 31 | WORKDIR /fredy 32 | 33 | # Install Chromium and curl (for healthcheck) 34 | # Using Alpine's chromium package which is much smaller 35 | RUN apk add --no-cache chromium curl 36 | 37 | ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \ 38 | PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser 39 | 40 | # Install build dependencies for native modules, then remove them after yarn install 41 | COPY package.json yarn.lock ./ 42 | 43 | RUN apk add --no-cache --virtual .build-deps python3 make g++ \ 44 | && yarn config set network-timeout 600000 \ 45 | && yarn --frozen-lockfile --production \ 46 | && yarn cache clean \ 47 | && apk del .build-deps 48 | 49 | # Copy built frontend from builder stage 50 | COPY --from=builder /build/ui/public ./ui/public 51 | 52 | # Copy application source (only what's needed at runtime) 53 | COPY index.js ./ 54 | COPY index.html ./ 55 | COPY lib ./lib 56 | 57 | # Prepare runtime directories and symlinks for data and config 58 | RUN mkdir -p /db /conf \ 59 | && chown 1000:1000 /db /conf \ 60 | && chmod 777 /db /conf \ 61 | && ln -s /db /fredy/db \ 62 | && ln -s /conf /fredy/conf 63 | 64 | EXPOSE 9998 65 | VOLUME /db 66 | VOLUME /conf 67 | 68 | CMD ["node", "index.js"] 69 | -------------------------------------------------------------------------------- /test/queryStringMutator/testData.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "url": "https://www.immowelt.de/classified-search?distributionTypes=Buy,Buy_Auction,Compulsory_Auction&estateTypes=House,Apartment&locations=AD08DE2350", 4 | "shouldBecome": "https://www.immowelt.de/classified-search?distributionTypes=Buy,Buy_Auction,Compulsory_Auction&estateTypes=House,Apartment&locations=AD08DE2350&order=DateDesc", 5 | "id": "immowelt" 6 | }, 7 | { 8 | "url": "https://www.1a-immobilienmarkt.de/suchen/duesseldorf/wohnung-mieten.html?search=yes", 9 | "shouldBecome": "https://www.1a-immobilienmarkt.de/suchen/duesseldorf/wohnung-mieten.html?search=yes&sort_type=newest", 10 | "id": "einsAImmobilien" 11 | }, 12 | { 13 | "url": "https://www.wg-gesucht.de/1-zimmer-wohnungen-in-Dusseldorf.30.1.1.0.html?sort_column=1&sort_order=0", 14 | "shouldBecome": "https://www.wg-gesucht.de/1-zimmer-wohnungen-in-Dusseldorf.30.1.1.0.html?sort_column=0&sort_order=0", 15 | "id": "wgGesucht" 16 | }, 17 | 18 | { 19 | "url": "https://www.immonet.de/immobiliensuche/sel.do?sortby=0&suchart=1&objecttype=1&marketingtype=2&parentcat=1&locationname=d%C3%BCsseldorf", 20 | "shouldBecome": "https://www.immonet.de/immobiliensuche/sel.do?sortby=19&suchart=1&objecttype=1&marketingtype=2&parentcat=1&locationname=d%C3%BCsseldorf", 21 | "id": "immonet" 22 | }, 23 | { 24 | "url": "https://www.neubaukompass.de/neubau-immobilien/berlin-region/", 25 | "shouldBecome": "https://www.neubaukompass.de/neubau-immobilien/berlin-region/?Sortierung=Id&Richtung=DESC", 26 | "id": "neubauKompass" 27 | }, 28 | { 29 | "url": "https://www.immobilienscout24.de/Suche/de/nordrhein-westfalen/duesseldorf/wohnung-mieten?numberofrooms=1.5-&price=1.0-1000000.0&livingspace=1.0-10000.0&pricetype=rentpermonth&enteredFrom=result_list", 30 | "shouldBecome": "https://www.immobilienscout24.de/Suche/de/nordrhein-westfalen/duesseldorf/wohnung-mieten?numberofrooms=1.5-&price=1.0-1000000.0&livingspace=1.0-10000.0&pricetype=rentpermonth&enteredFrom=result_list&sorting=-firstactivation", 31 | "id": "immoscout" 32 | } 33 | ] 34 | -------------------------------------------------------------------------------- /lib/provider/immoswp.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import { isOneOf, buildHash } from '../utils.js'; 7 | import checkIfListingIsActive from '../services/listings/listingActiveTester.js'; 8 | 9 | let appliedBlackList = []; 10 | 11 | function normalize(o) { 12 | const size = o.size || 'N/A m²'; 13 | const price = (o.price || '--- €').replace('Preis auf Anfrage', '--- €'); 14 | const title = o.title || 'No title available'; 15 | const immoId = o.id.substring(o.id.indexOf('-') + 1, o.id.length); 16 | const link = `https://immo.swp.de/immobilien/${immoId}`; 17 | const description = o.description; 18 | const id = buildHash(immoId, price); 19 | return Object.assign(o, { id, price, size, title, link, description }); 20 | } 21 | 22 | function applyBlacklist(o) { 23 | const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList); 24 | const descNotBlacklisted = !isOneOf(o.description, appliedBlackList); 25 | return titleNotBlacklisted && descNotBlacklisted; 26 | } 27 | 28 | const config = { 29 | url: null, 30 | crawlContainer: '.js-serp-item', 31 | sortByDateParam: 's=most_recently_updated_first', 32 | waitForSelector: 'body', 33 | crawlFields: { 34 | id: '.js-bookmark-btn@data-id', 35 | price: 'div.align-items-start div:first-child | trim', 36 | size: 'div.align-items-start div:nth-child(3) | trim', 37 | title: '.js-item-title-link@title | trim', 38 | link: '.ci-search-result__link@href', 39 | description: '.js-show-more-item-sm | removeNewline | trim', 40 | image: 'img@src', 41 | }, 42 | normalize: normalize, 43 | filter: applyBlacklist, 44 | activeTester: checkIfListingIsActive, 45 | }; 46 | export const init = (sourceConfig, blacklist) => { 47 | config.enabled = sourceConfig.enabled; 48 | config.url = sourceConfig.url; 49 | appliedBlackList = blacklist || []; 50 | }; 51 | export const metaInformation = { 52 | name: 'Immo Südwest Presse', 53 | baseUrl: 'https://immo.swp.de/', 54 | id: 'immoswp', 55 | }; 56 | export { config }; 57 | -------------------------------------------------------------------------------- /lib/provider/regionalimmobilien24.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import { isOneOf, buildHash } from '../utils.js'; 7 | import checkIfListingIsActive from '../services/listings/listingActiveTester.js'; 8 | let appliedBlackList = []; 9 | 10 | function normalize(o) { 11 | const id = buildHash(o.id, o.price); 12 | const address = o.address?.replace(/^adresse /i, '') ?? null; 13 | const title = o.title || 'No title available'; 14 | const link = o.link != null ? decodeURIComponent(o.link) : config.url; 15 | 16 | const urlReg = new RegExp(/url\((.*?)\)/gim); 17 | const image = o.image != null ? urlReg.exec(o.image)[1] : null; 18 | return Object.assign(o, { id, address, title, link, image }); 19 | } 20 | function applyBlacklist(o) { 21 | const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList); 22 | const descNotBlacklisted = !isOneOf(o.description, appliedBlackList); 23 | return titleNotBlacklisted && descNotBlacklisted; 24 | } 25 | const config = { 26 | url: null, 27 | crawlContainer: '.listentry-content', 28 | sortByDateParam: null, // sort by date is standard 29 | waitForSelector: 'body', 30 | crawlFields: { 31 | id: '.listentry-iconbar-share@data-sid | trim', 32 | title: 'h2 | trim', 33 | price: '.listentry-details-price .listentry-details-v | trim', 34 | size: '.listentry-details-size .listentry-details-v | trim', 35 | address: '.listentry-adress | trim', 36 | image: '.listentry-img@style', 37 | link: '.shariff@data-url', 38 | description: '.listentry-extras | trim', 39 | }, 40 | normalize: normalize, 41 | filter: applyBlacklist, 42 | activeTester: checkIfListingIsActive, 43 | }; 44 | export const init = (sourceConfig, blacklist) => { 45 | config.enabled = sourceConfig.enabled; 46 | config.url = sourceConfig.url; 47 | appliedBlackList = blacklist || []; 48 | }; 49 | export const metaInformation = { 50 | name: 'Regionalimmobilien24', 51 | baseUrl: 'https://www.regionalimmobilien24.de/', 52 | id: 'regionalimmobilien24', 53 | }; 54 | export { config }; 55 | -------------------------------------------------------------------------------- /lib/provider/immowelt.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import { buildHash, isOneOf } from '../utils.js'; 7 | import checkIfListingIsActive from '../services/listings/listingActiveTester.js'; 8 | 9 | let appliedBlackList = []; 10 | 11 | function normalize(o) { 12 | const id = buildHash(o.id, o.price); 13 | return Object.assign(o, { id }); 14 | } 15 | 16 | function applyBlacklist(o) { 17 | const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList); 18 | const descNotBlacklisted = !isOneOf(o.description, appliedBlackList); 19 | return titleNotBlacklisted && descNotBlacklisted; 20 | } 21 | 22 | const config = { 23 | url: null, 24 | crawlContainer: 25 | 'div[data-testid="serp-core-scrollablelistview-testid"]:not(div[data-testid="serp-enlargementlist-testid"] div[data-testid="serp-card-testid"]) div[data-testid="serp-core-classified-card-testid"]', 26 | sortByDateParam: 'order=DateDesc', 27 | waitForSelector: 'div[data-testid="serp-gridcontainer-testid"]', 28 | crawlFields: { 29 | id: 'a@href', 30 | price: 'div[data-testid="cardmfe-price-testid"] | removeNewline | trim', 31 | size: 'div[data-testid="cardmfe-keyfacts-testid"] | removeNewline | trim', 32 | title: 'div[data-testid="cardmfe-description-box-text-test-id"] > div:nth-of-type(2)', 33 | link: 'a@href', 34 | description: 'div[data-testid="cardmfe-description-text-test-id"] > div:nth-of-type(2) | removeNewline | trim', 35 | address: 'div[data-testid="cardmfe-description-box-address"] | removeNewline | trim', 36 | image: 'div[data-testid="cardmfe-picture-box-opacity-layer-test-id"] img@src', 37 | }, 38 | normalize: normalize, 39 | filter: applyBlacklist, 40 | activeTester: checkIfListingIsActive, 41 | }; 42 | export const init = (sourceConfig, blacklist) => { 43 | config.enabled = sourceConfig.enabled; 44 | config.url = sourceConfig.url; 45 | appliedBlackList = blacklist || []; 46 | }; 47 | export const metaInformation = { 48 | name: 'Immowelt', 49 | baseUrl: 'https://www.immowelt.de/', 50 | id: 'immowelt', 51 | }; 52 | export { config }; 53 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Help us improve Fredy by reporting a bug 3 | title: "[Bug]: " 4 | labels: [bug] 5 | assignees: [] 6 | 7 | body: 8 | - type: textarea 9 | id: description 10 | attributes: 11 | label: Bug Description 12 | description: Provide a clear and concise description of the bug. 13 | placeholder: e.g. "Fredy crashes when I click on Save." 14 | validations: 15 | required: true 16 | 17 | - type: textarea 18 | id: steps 19 | attributes: 20 | label: Steps to Reproduce 21 | description: List the steps to reproduce the issue. 22 | placeholder: | 23 | 1. Go to '...' 24 | 2. Click on '...' 25 | 3. Scroll down to '...' 26 | 4. See error 27 | validations: 28 | required: true 29 | 30 | - type: textarea 31 | id: expected 32 | attributes: 33 | label: Expected Behavior 34 | description: What did you expect to happen? 35 | placeholder: "It should save without errors." 36 | validations: 37 | required: true 38 | 39 | - type: textarea 40 | id: actual 41 | attributes: 42 | label: Actual Behavior 43 | description: What actually happened? 44 | placeholder: "Fredy crashed with error XYZ." 45 | validations: 46 | required: true 47 | 48 | - type: textarea 49 | id: screenshots 50 | attributes: 51 | label: Screenshots / Logs 52 | description: Add screenshots or paste log output to help explain the problem. 53 | placeholder: "Drag and drop screenshots here, or paste logs." 54 | validations: 55 | required: false 56 | 57 | - type: input 58 | id: environment 59 | attributes: 60 | label: Environment 61 | description: Provide details about your environment. 62 | placeholder: "OS: macOS 15, Browser: Chrome 124, App version: 1.2.3" 63 | validations: 64 | required: true 65 | 66 | - type: textarea 67 | id: context 68 | attributes: 69 | label: Additional Context 70 | description: Add any other context about the problem here. 71 | placeholder: "Any other information that might help..." 72 | validations: 73 | required: false 74 | -------------------------------------------------------------------------------- /ui/src/components/cards/DashboardCard.less: -------------------------------------------------------------------------------- 1 | @import "DashboardCardColors.less"; 2 | 3 | .color-variant(@bg, @border, @text) { 4 | background-color: @bg; 5 | border: 1px solid @border; 6 | color: @text; 7 | } 8 | 9 | .dashboard-card { 10 | box-sizing: border-box; 11 | padding: .8rem; 12 | border-radius: .5rem; 13 | border-width: 1px; 14 | font-weight: 600; 15 | box-shadow: 0 6px 20px rgba(0,0,0,0.08); 16 | /* Make all KPI boxes the same size regardless of content/font */ 17 | width: 100%; 18 | max-width: none; 19 | height: 10rem; 20 | display: flex; 21 | flex-direction: column; 22 | 23 | &.blue { 24 | .color-variant(@color-blue-bg, @color-blue-border, @color-blue-text); 25 | } 26 | 27 | &.orange { 28 | .color-variant(@color-orange-bg, @color-orange-border, @color-orange-text); 29 | } 30 | 31 | &.green { 32 | .color-variant(@color-green-bg, @color-green-border, @color-green-text); 33 | } 34 | 35 | &.purple { 36 | .color-variant(@color-purple-bg, @color-purple-border, @color-purple-text); 37 | } 38 | 39 | &.gray { 40 | .color-variant(@color-gray-bg, @color-gray-border, @color-gray-text); 41 | } 42 | 43 | &__header { 44 | display: flex; 45 | align-items: center; 46 | gap: .6rem; 47 | /* Keep header from growing content height */ 48 | min-height: 2rem; 49 | overflow: hidden; 50 | } 51 | 52 | &__icon { 53 | border-radius: .6rem; 54 | display: grid; 55 | place-items: center; 56 | } 57 | 58 | &__title { 59 | font-weight: 600; 60 | overflow: hidden; 61 | text-overflow: ellipsis; 62 | white-space: nowrap; 63 | } 64 | 65 | &__content { 66 | margin-top: .4rem; 67 | font-size: .7rem; 68 | flex: 1 1 auto; 69 | display: flex; 70 | flex-direction: column; 71 | justify-content: center; 72 | overflow: hidden; 73 | } 74 | 75 | &__value { 76 | margin: 0; 77 | font-size: 1.5rem; 78 | line-height: 1.1; 79 | color: #fff; 80 | overflow: hidden; 81 | text-overflow: ellipsis; 82 | white-space: nowrap; 83 | } 84 | 85 | &__desc { 86 | opacity: .8; 87 | overflow: hidden; 88 | text-overflow: ellipsis; 89 | white-space: nowrap; 90 | } 91 | 92 | } -------------------------------------------------------------------------------- /lib/provider/immonet.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import { isOneOf, buildHash } from '../utils.js'; 7 | import checkIfListingIsActive from '../services/listings/listingActiveTester.js'; 8 | let appliedBlackList = []; 9 | 10 | function normalize(o) { 11 | const size = o.size != null ? o.size.replace('Wohnfläche ', '') : 'N/A m²'; 12 | const price = o.price.replace('Kaufpreis ', ''); 13 | const address = o.address?.split(' • ')?.pop() ?? null; 14 | const title = o.title || 'No title available'; 15 | const link = o.link != null ? decodeURIComponent(o.link) : config.url; 16 | const id = buildHash(title, price); 17 | return Object.assign(o, { id, address, price, size, title, link }); 18 | } 19 | function applyBlacklist(o) { 20 | const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList); 21 | const descNotBlacklisted = !isOneOf(o.description, appliedBlackList); 22 | return titleNotBlacklisted && descNotBlacklisted; 23 | } 24 | const config = { 25 | url: null, 26 | crawlContainer: 'div[data-testid="serp-core-classified-card-testid"]', 27 | sortByDateParam: 'sortby=19', 28 | waitForSelector: 'div[data-testid="serp-gridcontainer-testid"]', 29 | crawlFields: { 30 | id: 'button@title |trim', 31 | title: 'button@title |trim', 32 | price: 'div[data-testid="cardmfe-price-testid"] | trim', 33 | size: 'div[data-testid="cardmfe-keyfacts-testid"] | trim', 34 | address: 'div[data-testid="cardmfe-description-box-address"] | trim', 35 | image: 'div[data-testid="cardmfe-picture-box-test-id"] img@src', 36 | link: 'button@data-base', 37 | description: 'div[data-testid="cardmfe-description-text-test-id"] | trim', 38 | }, 39 | normalize: normalize, 40 | filter: applyBlacklist, 41 | activeTester: checkIfListingIsActive, 42 | }; 43 | export const init = (sourceConfig, blacklist) => { 44 | config.enabled = sourceConfig.enabled; 45 | config.url = sourceConfig.url; 46 | appliedBlackList = blacklist || []; 47 | }; 48 | export const metaInformation = { 49 | name: 'Immonet', 50 | baseUrl: 'https://www.immonet.de/', 51 | id: 'immonet', 52 | }; 53 | export { config }; 54 | -------------------------------------------------------------------------------- /ui/src/components/tracking/TrackingModal.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import React from 'react'; 7 | import { Modal } from '@douyinfe/semi-ui'; 8 | import Logo from '../logo/Logo.jsx'; 9 | import { xhrPost } from '../../services/xhr.js'; 10 | 11 | import './TrackingModal.less'; 12 | import inDevelopment from '../../services/developmentMode.js'; 13 | 14 | const saveResponse = async (analyticsEnabled) => { 15 | await xhrPost('/api/admin/generalSettings', { 16 | analyticsEnabled, 17 | }); 18 | }; 19 | 20 | export default function TrackingModal() { 21 | if (inDevelopment()) { 22 | return null; 23 | } 24 | 25 | return ( 26 | { 29 | await saveResponse(true); 30 | location.reload(); 31 | }} 32 | onCancel={async () => { 33 | await saveResponse(false); 34 | location.reload(); 35 | }} 36 | maskClosable={false} 37 | closable={false} 38 | okText="Yes! I want to help" 39 | cancelText="No, thanks" 40 | > 41 | 42 |
43 |

Hey 👋

44 |

Fed up with popups? Yeah, me too. But this one’s important, and I promise it will only appear once ;)

45 |

46 | Fredy is completely free (and will always remain free). If you’d like, you can support me by donating through 47 | my GitHub, but there’s absolutely no obligation to do so. 48 |

49 |

50 | However, it would be a huge help if you’d allow me to collect some analytical data. Wait, before you click 51 | "no", let me explain. If you agree, Fredy will send a ping once every 6 hours to my internal tracking project. 52 | (Will be open-sourced soon) 53 |

54 |

55 | The data includes: names of active adapters/providers, OS, architecture, Node version, and language. The 56 | information is entirely anonymous and helps me understand which adapters/providers are most frequently used. 57 |

58 |

Thanks🤘

59 |
60 |
61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /ui/src/views/jobs/Jobs.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import React from 'react'; 7 | 8 | import JobTable from '../../components/table/JobTable'; 9 | import { useSelector, useActions } from '../../services/state/store'; 10 | import { xhrDelete, xhrPut } from '../../services/xhr'; 11 | import { useNavigate } from 'react-router-dom'; 12 | import { Button, Toast } from '@douyinfe/semi-ui'; 13 | import { IconPlusCircle } from '@douyinfe/semi-icons'; 14 | import './Jobs.less'; 15 | 16 | export default function Jobs() { 17 | const jobs = useSelector((state) => state.jobs.jobs); 18 | const navigate = useNavigate(); 19 | const actions = useActions(); 20 | 21 | const onJobRemoval = async (jobId) => { 22 | try { 23 | await xhrDelete('/api/jobs', { jobId }); 24 | Toast.success('Job successfully removed'); 25 | await actions.jobs.getJobs(); 26 | } catch (error) { 27 | Toast.error(error); 28 | } 29 | }; 30 | 31 | const onListingRemoval = async (jobId) => { 32 | try { 33 | await xhrDelete('/api/listings/job', { jobId }); 34 | Toast.success('Listings successfully removed'); 35 | await actions.jobs.getJobs(); 36 | } catch (error) { 37 | Toast.error(error); 38 | } 39 | }; 40 | 41 | const onJobStatusChanged = async (jobId, status) => { 42 | try { 43 | await xhrPut(`/api/jobs/${jobId}/status`, { status }); 44 | Toast.success('Job status successfully changed'); 45 | await actions.jobs.getJobs(); 46 | } catch (error) { 47 | Toast.error(error); 48 | } 49 | }; 50 | 51 | return ( 52 |
53 |
54 | 62 |
63 | 64 | navigate(`/jobs/edit/${jobId}`)} 70 | /> 71 |
72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /test/provider/utils.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import { isOneOf, duringWorkingHoursOrNotSet } from '../../lib/utils.js'; 7 | import assert from 'assert'; 8 | import { expect } from 'chai'; 9 | 10 | const fakeWorkingHoursConfig = (from, to) => ({ 11 | workingHours: { 12 | to, 13 | from, 14 | }, 15 | }); 16 | 17 | describe('utils', () => { 18 | describe('#isOneOf()', () => { 19 | it('should be false', () => { 20 | assert.equal(isOneOf('bla', ['blub']), false); 21 | }); 22 | it('should be true', () => { 23 | assert.equal(isOneOf('bla blub blubber', ['bla']), true); 24 | }); 25 | }); 26 | describe('#duringWorkingHoursOrNotSet()', () => { 27 | it('should be false', () => { 28 | expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('12:00', '13:00'), 0)).to.be.false; 29 | }); 30 | it('should be true', () => { 31 | expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('10:00', '16:00'), 1622026740000)).to.be.true; 32 | }); 33 | it('should be true if nothing set', () => { 34 | expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig(null, null), 1622026740000)).to.be.true; 35 | }); 36 | it('should be true if only to is set', () => { 37 | expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig(null, '13:00'), 1622026740000)).to.be.true; 38 | }); 39 | it('should be true if only from is set', () => { 40 | expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('12:00', null), 1622026740000)).to.be.true; 41 | }); 42 | it('should handle working hours that cross midnight (e.g., 05:00 → 00:30)', () => { 43 | const cfg = fakeWorkingHoursConfig('05:00', '00:30'); 44 | const mkTs = (h, m = 0) => { 45 | const d = new Date(); 46 | d.setHours(h); 47 | d.setMinutes(m); 48 | d.setSeconds(0); 49 | d.setMilliseconds(0); 50 | return d.getTime(); 51 | }; 52 | expect(duringWorkingHoursOrNotSet(cfg, mkTs(23, 0))).to.be.true; // 23:00 => within window 53 | expect(duringWorkingHoursOrNotSet(cfg, mkTs(1, 0))).to.be.false; // 01:00 => outside window 54 | expect(duringWorkingHoursOrNotSet(cfg, mkTs(6, 0))).to.be.true; // 06:00 => within window 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /lib/provider/kleinanzeigen.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import { buildHash, isOneOf } from '../utils.js'; 7 | import checkIfListingIsActive from '../services/listings/listingActiveTester.js'; 8 | 9 | let appliedBlackList = []; 10 | let appliedBlacklistedDistricts = []; 11 | 12 | function normalize(o) { 13 | const size = o.size || '--- m²'; 14 | const id = buildHash(o.id, o.price); 15 | const link = `https://www.kleinanzeigen.de${o.link}`; 16 | return Object.assign(o, { id, size, link }); 17 | } 18 | 19 | function applyBlacklist(o) { 20 | const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList); 21 | const descNotBlacklisted = !isOneOf(o.description, appliedBlackList); 22 | const isBlacklistedDistrict = 23 | appliedBlacklistedDistricts.length === 0 ? false : isOneOf(o.description, appliedBlacklistedDistricts); 24 | return o.title != null && !isBlacklistedDistrict && titleNotBlacklisted && descNotBlacklisted; 25 | } 26 | 27 | const config = { 28 | url: null, 29 | crawlContainer: '#srchrslt-adtable .ad-listitem ', 30 | //sort by date is standard oO 31 | sortByDateParam: null, 32 | waitForSelector: 'body', 33 | crawlFields: { 34 | id: '.aditem@data-adid | int', 35 | price: '.aditem-main--middle--price-shipping--price | removeNewline | trim', 36 | size: '.aditem-main .text-module-end | removeNewline | trim', 37 | title: '.aditem-main .text-module-begin a | removeNewline | trim', 38 | link: '.aditem-main .text-module-begin a@href | removeNewline | trim', 39 | description: '.aditem-main .aditem-main--middle--description | removeNewline | trim', 40 | address: '.aditem-main--top--left | trim | removeNewline', 41 | image: 'img@src', 42 | }, 43 | normalize: normalize, 44 | filter: applyBlacklist, 45 | activeTester: checkIfListingIsActive, 46 | }; 47 | export const metaInformation = { 48 | name: 'Ebay Kleinanzeigen', 49 | baseUrl: 'https://www.kleinanzeigen.de/', 50 | id: 'kleinanzeigen', 51 | }; 52 | export const init = (sourceConfig, blacklist, blacklistedDistricts) => { 53 | config.enabled = sourceConfig.enabled; 54 | config.url = sourceConfig.url; 55 | appliedBlacklistedDistricts = blacklistedDistricts || []; 56 | appliedBlackList = blacklist || []; 57 | }; 58 | export { config }; 59 | -------------------------------------------------------------------------------- /lib/provider/immobilienDe.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import { buildHash, isOneOf } from '../utils.js'; 7 | import checkIfListingIsActive from '../services/listings/listingActiveTester.js'; 8 | 9 | let appliedBlackList = []; 10 | 11 | function shortenLink(link) { 12 | return link.substring(0, link.indexOf('?')); 13 | } 14 | 15 | function parseId(shortenedLink) { 16 | return shortenedLink.substring(shortenedLink.lastIndexOf('/') + 1); 17 | } 18 | 19 | function normalize(o) { 20 | const baseUrl = 'https://www.immobilien.de'; 21 | const size = o.size || null; 22 | const price = o.price || null; 23 | const title = o.title || 'No title available'; 24 | const address = o.address || null; 25 | const shortLink = shortenLink(o.link); 26 | const link = `${baseUrl}/${shortLink}`; 27 | const image = baseUrl + o.image; 28 | const id = buildHash(parseId(shortLink), o.price); 29 | return Object.assign(o, { id, price, size, title, address, link, image }); 30 | } 31 | 32 | function applyBlacklist(o) { 33 | const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList); 34 | const descNotBlacklisted = !isOneOf(o.description, appliedBlackList); 35 | return titleNotBlacklisted && descNotBlacklisted; 36 | } 37 | 38 | const config = { 39 | url: null, 40 | crawlContainer: '._ref', 41 | sortByDateParam: 'sort_col=*created_ts&sort_dir=desc', 42 | waitForSelector: 'body', 43 | crawlFields: { 44 | id: '@href', //will be transformed later 45 | price: '.list_entry .immo_preis .label_info', 46 | size: '.list_entry .flaeche .label_info | removeNewline | trim', 47 | title: '.list_entry .part_text h3 span', 48 | description: '.list_entry .description | trim', 49 | link: '@href', 50 | address: '.list_entry .place', 51 | image: '.list_entry img@src', 52 | }, 53 | normalize: normalize, 54 | filter: applyBlacklist, 55 | activeTester: checkIfListingIsActive, 56 | }; 57 | export const init = (sourceConfig, blacklist) => { 58 | config.enabled = sourceConfig.enabled; 59 | config.url = sourceConfig.url; 60 | appliedBlackList = blacklist || []; 61 | }; 62 | export const metaInformation = { 63 | name: 'Immobilien.de', 64 | baseUrl: 'https://www.immobilien.de/', 65 | id: 'immobilienDe', 66 | }; 67 | export { config }; 68 | -------------------------------------------------------------------------------- /lib/services/listings/listingActiveTester.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import fetch from 'node-fetch'; 7 | import { randomBetween, sleep } from '../../utils.js'; 8 | 9 | const maxAttempts = 3; 10 | 11 | /** 12 | * Check if a listing is still active with up to 3 attempts and exponential backoff. 13 | * Backoff waits are capped and the last wait is at most 2000 ms. 14 | * 15 | * Rules: 16 | * - HTTP 200 => return 1 17 | * - HTTP 401/403 => return -1 (most certainly detected as a bot) 18 | * - HTTP 404 => return 0 19 | * - Other statuses or network errors => retry until attempts are exhausted 20 | * 21 | * @returns {Promise} 1 if active, o if not active and -1 if detected as bot 22 | */ 23 | export default async function checkIfListingIsActive(link) { 24 | await sleep(randomBetween(50, 100)); 25 | 26 | for (let attempt = 1; attempt <= maxAttempts; attempt++) { 27 | try { 28 | const res = await fetch(link, { 29 | headers: { 30 | 'User-Agent': 31 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36', 32 | 'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8', 33 | }, 34 | }); 35 | 36 | if (res.status === 200) { 37 | return 1; 38 | } 39 | if (res.status === 401) return -1; 40 | if (res.status === 403) return -1; 41 | if (res.status === 404) return 0; 42 | 43 | // For any other status, only retry if attempts remain 44 | if (attempt < maxAttempts) { 45 | await sleep(backoffDelay(attempt)); 46 | continue; 47 | } 48 | 49 | return 0; 50 | } catch { 51 | // Network error: retry if attempts remain 52 | if (attempt < maxAttempts) { 53 | await sleep(backoffDelay(attempt)); 54 | continue; 55 | } 56 | return 0; 57 | } 58 | } 59 | 60 | return 0; 61 | } 62 | 63 | /** 64 | * Exponential backoff delay with cap. 65 | * attempt: 1 -> 500ms, 2 -> 1000ms, 3 -> 2000ms (cap) 66 | * @param {number} attempt 1-based attempt index 67 | * @returns {number} delay in ms 68 | */ 69 | function backoffDelay(attempt) { 70 | const base = 500; 71 | const cap = 2000; 72 | return Math.min(base * 2 ** (attempt - 1), cap); 73 | } 74 | -------------------------------------------------------------------------------- /lib/services/storage/watchListStorage.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import SqliteConnection from './SqliteConnection.js'; 7 | import { nanoid } from 'nanoid'; 8 | 9 | /** 10 | * Create a watch entry. Idempotent due to unique index (listing_id, user_id). 11 | * @param {string} listingId 12 | * @param {string} userId 13 | * @returns {{created:boolean}} 14 | */ 15 | export const createWatch = (listingId, userId) => { 16 | if (!listingId || !userId) return { created: false }; 17 | try { 18 | SqliteConnection.execute( 19 | `INSERT INTO watch_list (id, listing_id, user_id) 20 | VALUES (@id, @listing_id, @user_id) 21 | ON CONFLICT(listing_id, user_id) DO NOTHING`, 22 | { id: nanoid(), listing_id: listingId, user_id: userId }, 23 | ); 24 | // check whether it exists now 25 | const row = SqliteConnection.query( 26 | `SELECT 1 AS ok FROM watch_list WHERE listing_id = @listing_id AND user_id = @user_id LIMIT 1`, 27 | { listing_id: listingId, user_id: userId }, 28 | ); 29 | return { created: row.length > 0 }; 30 | } catch { 31 | return { created: false }; 32 | } 33 | }; 34 | 35 | /** 36 | * Delete a watch entry. 37 | * @param {string} listingId 38 | * @param {string} userId 39 | * @returns {{deleted:boolean}} 40 | */ 41 | export const deleteWatch = (listingId, userId) => { 42 | if (!listingId || !userId) return { deleted: false }; 43 | const res = SqliteConnection.execute(`DELETE FROM watch_list WHERE listing_id = @listing_id AND user_id = @user_id`, { 44 | listing_id: listingId, 45 | user_id: userId, 46 | }); 47 | return { deleted: Boolean(res?.changes) }; 48 | }; 49 | 50 | /** 51 | * Toggle a watch entry. If exists -> delete, otherwise create. 52 | * @param {string} listingId 53 | * @param {string} userId 54 | * @returns {{watched:boolean}} 55 | */ 56 | export const toggleWatch = (listingId, userId) => { 57 | if (!listingId || !userId) return { watched: false }; 58 | const exists = 59 | SqliteConnection.query( 60 | `SELECT 1 AS ok FROM watch_list WHERE listing_id = @listing_id AND user_id = @user_id LIMIT 1`, 61 | { listing_id: listingId, user_id: userId }, 62 | ).length > 0; 63 | if (exists) { 64 | deleteWatch(listingId, userId); 65 | return { watched: false }; 66 | } 67 | createWatch(listingId, userId); 68 | return { watched: true }; 69 | }; 70 | -------------------------------------------------------------------------------- /lib/notification/adapter/slack.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import Slack from 'slack'; 7 | import { markdown2Html } from '../../services/markdown.js'; 8 | import { normalizeImageUrl } from '../../utils.js'; 9 | 10 | const buildBlocks = (serviceName, jobKey, p) => { 11 | const blocks = [ 12 | { 13 | type: 'header', 14 | text: { type: 'plain_text', text: `New Listing from ${serviceName} (${jobKey})`, emoji: false }, 15 | }, 16 | { 17 | type: 'section', 18 | text: { type: 'mrkdwn', text: `*<${p.link}|${p.title}>*` }, 19 | }, 20 | { 21 | type: 'section', 22 | fields: [ 23 | { type: 'mrkdwn', text: `*Price*\n${p.price ?? 'n/a'}` }, 24 | { type: 'mrkdwn', text: `*Size*\n${p.size ?? 'n/a'}` }, 25 | { type: 'mrkdwn', text: `*Address*\n${p.address ?? 'n/a'}` }, 26 | ], 27 | }, 28 | ]; 29 | 30 | const img = normalizeImageUrl(p.image); 31 | if (img) { 32 | blocks.push({ 33 | type: 'image', 34 | image_url: img, 35 | alt_text: p.title || 'listing image', 36 | }); 37 | } 38 | 39 | blocks.push({ 40 | type: 'context', 41 | elements: [{ type: 'mrkdwn', text: 'Powered by Fredy' }], 42 | }); 43 | 44 | return blocks; 45 | }; 46 | 47 | export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => { 48 | const { token, channel } = notificationConfig.find((a) => a.id === config.id).fields; 49 | 50 | return Promise.allSettled( 51 | newListings.map((p) => 52 | Slack.chat.postMessage({ 53 | token, 54 | channel, 55 | text: `${serviceName} ${jobKey}: ${p.title}`, 56 | blocks: buildBlocks(serviceName, jobKey, p), 57 | unfurl_links: false, 58 | unfurl_media: false, 59 | }), 60 | ), 61 | ); 62 | }; 63 | 64 | export const config = { 65 | id: 'slack', 66 | name: 'Slack', 67 | readme: markdown2Html('lib/notification/adapter/slack.md'), 68 | description: 'Fredy will send new listings to the slack channel of your choice..', 69 | fields: { 70 | token: { 71 | type: 'text', 72 | label: 'Token', 73 | description: 'The token needed to send notifications to slack.', 74 | }, 75 | channel: { 76 | type: 'channel', 77 | label: 'Channel', 78 | description: 'The channel where fredy should send notifications to.', 79 | }, 80 | }, 81 | }; 82 | -------------------------------------------------------------------------------- /ui/src/views/user/Users.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import React from 'react'; 7 | 8 | import { Toast } from '@douyinfe/semi-ui'; 9 | import UserTable from '../../components/table/UserTable'; 10 | import { useActions, useSelector } from '../../services/state/store'; 11 | import { IconPlus } from '@douyinfe/semi-icons'; 12 | import { Button } from '@douyinfe/semi-ui'; 13 | import UserRemovalModal from './UserRemovalModal'; 14 | import { xhrDelete } from '../../services/xhr'; 15 | import { useNavigate } from 'react-router-dom'; 16 | 17 | import './Users.less'; 18 | 19 | const Users = function Users() { 20 | const actions = useActions(); 21 | const [loading, setLoading] = React.useState(true); 22 | const users = useSelector((state) => state.user.users); 23 | const [userIdToBeRemoved, setUserIdToBeRemoved] = React.useState(null); 24 | const navigate = useNavigate(); 25 | 26 | React.useEffect(() => { 27 | async function init() { 28 | await actions.user.getUsers(); 29 | setLoading(false); 30 | } 31 | 32 | init(); 33 | }, []); 34 | 35 | const onUserRemoval = async () => { 36 | try { 37 | await xhrDelete('/api/admin/users', { userId: userIdToBeRemoved }); 38 | Toast.success('User successfully remove'); 39 | setUserIdToBeRemoved(null); 40 | await actions.jobs.getJobs(); 41 | await actions.user.getUsers(); 42 | } catch (error) { 43 | Toast.error(error); 44 | setUserIdToBeRemoved(null); 45 | } 46 | }; 47 | 48 | return ( 49 |
50 | {!loading && ( 51 | 52 | {userIdToBeRemoved && setUserIdToBeRemoved(null)} onOk={onUserRemoval} />} 53 | 54 | 62 | 63 | { 66 | navigate(`/users/edit/${userId}`); 67 | }} 68 | onUserRemoval={(userId) => { 69 | setUserIdToBeRemoved(userId); 70 | //throw warning message that all jobs will be removed associated to this user 71 | //check if at least 1 admin is available 72 | }} 73 | /> 74 | 75 | )} 76 |
77 | ); 78 | }; 79 | 80 | export default Users; 81 | -------------------------------------------------------------------------------- /lib/notification/adapter/slack_with_webhooks.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import fetch from 'node-fetch'; 7 | import { markdown2Html } from '../../services/markdown.js'; 8 | import { normalizeImageUrl } from '../../utils.js'; 9 | 10 | const buildBlocks = (serviceName, jobKey, p) => { 11 | const blocks = [ 12 | { 13 | type: 'header', 14 | text: { type: 'plain_text', text: `New Listing from ${serviceName} (${jobKey})`, emoji: false }, 15 | }, 16 | { 17 | type: 'section', 18 | text: { type: 'mrkdwn', text: `*<${p.link}|${p.title}>*` }, 19 | }, 20 | { 21 | type: 'section', 22 | fields: [ 23 | { type: 'mrkdwn', text: `*Price*\n${p.price ?? 'n/a'}` }, 24 | { type: 'mrkdwn', text: `*Size*\n${p.size ?? 'n/a'}` }, 25 | { type: 'mrkdwn', text: `*Address*\n${p.address ?? 'n/a'}` }, 26 | ], 27 | }, 28 | ]; 29 | 30 | const img = normalizeImageUrl(p.image); 31 | if (img) { 32 | blocks.push({ 33 | type: 'image', 34 | image_url: img, 35 | alt_text: p.title || 'listing image', 36 | }); 37 | } 38 | 39 | blocks.push({ 40 | type: 'context', 41 | elements: [{ type: 'mrkdwn', text: 'Powered by Fredy' }], 42 | }); 43 | 44 | return blocks; 45 | }; 46 | 47 | const postJson = (url, body) => 48 | fetch(url, { 49 | method: 'POST', 50 | headers: { 'Content-Type': 'application/json' }, 51 | body, 52 | }); 53 | 54 | export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => { 55 | const adapter = notificationConfig.find((a) => a.id === config.id); 56 | const webhookUrl = adapter?.fields?.webhookUrl; 57 | if (!webhookUrl) return Promise.resolve([]); 58 | 59 | const promises = newListings.map((p) => { 60 | const body = JSON.stringify({ 61 | text: `${serviceName} ${jobKey}: ${p.title}`, 62 | blocks: buildBlocks(serviceName, jobKey, p), 63 | unfurl_links: false, 64 | unfurl_media: false, 65 | }); 66 | return postJson(webhookUrl, body); 67 | }); 68 | 69 | return Promise.allSettled(promises); 70 | }; 71 | 72 | export const config = { 73 | id: 'slack_with_webhooks', 74 | name: 'Slack with Webhooks', 75 | readme: markdown2Html('lib/notification/adapter/slack_with_webhooks.md'), 76 | description: 'Fredy will send new listings to the slack channel of your choice..', 77 | fields: { 78 | webhookUrl: { 79 | type: 'text', 80 | label: 'Webhook-Url', 81 | description: 'The Url of the Webhook to send messages to.', 82 | }, 83 | }, 84 | }; 85 | -------------------------------------------------------------------------------- /lib/api/routes/backupRouter.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import restana from 'restana'; 7 | import { 8 | buildBackupFileName, 9 | createBackupZip, 10 | precheckRestore, 11 | restoreFromZip, 12 | } from '../../services/storage/backupRestoreService.js'; 13 | 14 | /** 15 | * Backup & Restore Admin Router 16 | * 17 | * Endpoints: 18 | * - GET /api/admin/backup 19 | * Returns the current database as a zip download. Content-Type: application/zip 20 | * - POST /api/admin/backup/restore?dryRun=true 21 | * Accepts a zip file (raw body). Returns a compatibility report, does not restore. 22 | * - POST /api/admin/backup/restore?force=true|false 23 | * Accepts a zip file (raw body). Restores the database; when incompatible and force=false, returns 400. 24 | */ 25 | const service = restana(); 26 | const backupRouter = service.newRouter(); 27 | 28 | backupRouter.get('/', async (req, res) => { 29 | const zipBuffer = await createBackupZip(); 30 | const fileName = await buildBackupFileName(); 31 | res.setHeader('Content-Type', 'application/zip'); 32 | res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); 33 | res.send(zipBuffer); 34 | }); 35 | 36 | /** 37 | * Read the full request body as a Buffer. Used for raw zip uploads. 38 | * @param {import('http').IncomingMessage} req 39 | * @returns {Promise} 40 | */ 41 | function readBody(req) { 42 | return new Promise((resolve, reject) => { 43 | const chunks = []; 44 | req.on('data', (c) => chunks.push(c)); 45 | req.on('end', () => resolve(Buffer.concat(chunks))); 46 | req.on('error', (e) => reject(e)); 47 | }); 48 | } 49 | 50 | // Upload endpoint. Accepts raw zip (Content-Type: application/zip or application/octet-stream) 51 | // Query parameters: 52 | // - dryRun=true => only validate and return compatibility info 53 | // - force=true => proceed even if incompatible 54 | backupRouter.post('/restore', async (req, res) => { 55 | const { dryRun = 'false', force = 'false' } = req.query || {}; 56 | const doDryRun = String(dryRun) === 'true'; 57 | const doForce = String(force) === 'true'; 58 | const body = await readBody(req); 59 | 60 | if (doDryRun) { 61 | res.body = await precheckRestore(body); 62 | return res.send(); 63 | } 64 | 65 | try { 66 | res.body = await restoreFromZip(body, { force: doForce }); 67 | return res.send(); 68 | } catch (e) { 69 | res.statusCode = 400; 70 | res.body = { message: e?.message || 'Restore failed', details: e?.payload || null }; 71 | return res.send(); 72 | } 73 | }); 74 | 75 | export { backupRouter }; 76 | -------------------------------------------------------------------------------- /lib/provider/einsAImmobilien.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import { buildHash, isOneOf } from '../utils.js'; 7 | import checkIfListingIsActive from '../services/listings/listingActiveTester.js'; 8 | let appliedBlackList = []; 9 | 10 | function normalize(o) { 11 | const baseUrl = 'https://www.1a-immobilienmarkt.de'; 12 | const link = `${baseUrl}/expose/${o.id}.html`; 13 | const price = normalizePrice(o.price); 14 | const id = buildHash(o.id, price); 15 | const image = baseUrl + o.image; 16 | const address = o.address == null ? null : o.address.trim().replaceAll('/', ','); 17 | return Object.assign(o, { id, price, link, image, address }); 18 | } 19 | 20 | /** 21 | * einsAImmobilien sometimes use a weird pricing label such as `775.700,00 EUR Kaufpreis ab 2.475 € mtl`. 22 | * Make sure to extract only the actual price out of the string. 23 | * @param price 24 | * @returns {*} 25 | */ 26 | function normalizePrice(price) { 27 | if (price == null) { 28 | return null; 29 | } 30 | const regex = /(\d{1,3}(?:\.\d{3})*,\d{2})\s?(EUR|€)/g; 31 | const result = price.match(regex); 32 | if (result == null || result.length === 0) { 33 | return price; 34 | } 35 | return result[0]; 36 | } 37 | function applyBlacklist(o) { 38 | const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList); 39 | const descNotBlacklisted = !isOneOf(o.description, appliedBlackList); 40 | return titleNotBlacklisted && descNotBlacklisted; 41 | } 42 | 43 | const config = { 44 | url: null, 45 | crawlContainer: '.tabelle', 46 | sortByDateParam: 'sort_type=newest', 47 | waitForSelector: 'body', 48 | crawlFields: { 49 | id: '.inner_object_data input[name="marker_objekt_id"]@value | int', 50 | price: '.inner_object_data .single_data_price | removeNewline | trim', 51 | size: '.tabelle .tabelle_inhalt_infos .single_data_box | removeNewline | trim', 52 | title: '.inner_object_data .tabelle_inhalt_titel_black | removeNewline | trim', 53 | image: '.inner_object_pic img@src', 54 | address: '.tabelle .tabelle_inhalt_infos .left_information > div:nth-child(2) | removeNewline | trim', 55 | }, 56 | normalize: normalize, 57 | filter: applyBlacklist, 58 | activeTester: checkIfListingIsActive, 59 | }; 60 | export const init = (sourceConfig, blacklist) => { 61 | config.enabled = sourceConfig.enabled; 62 | config.url = sourceConfig.url; 63 | appliedBlackList = blacklist || []; 64 | }; 65 | export const metaInformation = { 66 | name: '1a Immobilien', 67 | baseUrl: 'https://www.1a-immobilienmarkt.de/', 68 | id: 'einsAImmobilien', 69 | }; 70 | export { config }; 71 | -------------------------------------------------------------------------------- /lib/notification/adapter/sendGrid.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import sgMail from '@sendgrid/mail'; 7 | import { markdown2Html } from '../../services/markdown.js'; 8 | import { normalizeImageUrl } from '../../utils.js'; 9 | 10 | const mapListings = (serviceName, jobKey, listings) => 11 | listings.map((l) => { 12 | const image = normalizeImageUrl(l.image); 13 | return { 14 | title: l.title || '', 15 | link: l.link || '', 16 | address: l.address || '', 17 | size: l.size || '', 18 | price: l.price || '', 19 | image, 20 | hasImage: Boolean(image), 21 | // optional plain text snippet 22 | snippet: [l.address, l.price, l.size].filter(Boolean).join(' | '), 23 | serviceName, 24 | jobKey, 25 | }; 26 | }); 27 | 28 | export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => { 29 | const { apiKey, receiver, from, templateId } = notificationConfig.find((adapter) => adapter.id === config.id).fields; 30 | 31 | sgMail.setApiKey(apiKey); 32 | 33 | const to = receiver 34 | .trim() 35 | .split(',') 36 | .map((r) => r.trim()) 37 | .filter(Boolean); 38 | 39 | const listings = mapListings(serviceName, jobKey, newListings); 40 | 41 | const msg = { 42 | templateId, 43 | to, 44 | from, 45 | subject: `Job ${jobKey} | Service ${serviceName} found ${newListings.length} new listing(s)`, 46 | dynamic_template_data: { 47 | serviceName: `Job: (${jobKey}) | Service: ${serviceName}`, 48 | numberOfListings: newListings.length, 49 | listings, 50 | }, 51 | }; 52 | 53 | return sgMail.send(msg); 54 | }; 55 | 56 | export const config = { 57 | id: 'sendgrid', 58 | name: 'SendGrid', 59 | description: 'SendGrid is being used to send new listings via mail.', 60 | readme: markdown2Html('lib/notification/adapter/sendGrid.md'), 61 | fields: { 62 | apiKey: { 63 | type: 'text', 64 | label: 'Api Key', 65 | description: 'The api key needed to access this service.', 66 | }, 67 | receiver: { 68 | type: 'email', 69 | label: 'Receiver Email', 70 | description: 'The email address (single one) which Fredy is using to send notifications to.', 71 | }, 72 | from: { 73 | type: 'email', 74 | label: 'Sender Email', 75 | description: 76 | 'The email address from which Fredy send email. Beware, this email address needs to be verified by Sendgrid.', 77 | }, 78 | templateId: { 79 | type: 'text', 80 | label: 'Template Id', 81 | description: 'Sendgrid supports templates which Fredy is using to send out emails that looks awesome ;)', 82 | }, 83 | }, 84 | }; 85 | -------------------------------------------------------------------------------- /lib/api/routes/dashboardRouter.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import restana from 'restana'; 7 | import * as jobStorage from '../../services/storage/jobStorage.js'; 8 | import * as userStorage from '../../services/storage/userStorage.js'; 9 | import { getListingsKpisForJobIds, getProviderDistributionForJobIds } from '../../services/storage/listingsStorage.js'; 10 | import { getSettings } from '../../services/storage/settingsStorage.js'; 11 | 12 | const service = restana(); 13 | export const dashboardRouter = service.newRouter(); 14 | 15 | function isAdmin(req) { 16 | const user = req.session?.currentUser ? userStorage.getUser(req.session.currentUser) : null; 17 | return !!user?.isAdmin; 18 | } 19 | 20 | function getAccessibleJobs(req) { 21 | const currentUser = req.session.currentUser; 22 | const admin = isAdmin(req); 23 | return jobStorage 24 | .getJobs() 25 | .filter((job) => admin || job.userId === currentUser || job.shared_with_user.includes(currentUser)); 26 | } 27 | 28 | function cap(val) { 29 | return String(val).charAt(0).toUpperCase() + String(val).slice(1); 30 | } 31 | 32 | dashboardRouter.get('/', async (req, res) => { 33 | const jobs = getAccessibleJobs(req); 34 | const settings = await getSettings(); 35 | 36 | // KPIs 37 | const totalJobs = jobs.length; 38 | const totalListings = jobs.reduce((sum, j) => sum + (j.numberOfFoundListings || 0), 0); 39 | const jobIds = jobs.map((j) => j.id); 40 | const { numberOfActiveListings, avgPriceOfListings } = getListingsKpisForJobIds(jobIds); 41 | // Build Pie data in a simple shape the frontend can consume directly 42 | // Shape: { labels: string[], values: number[] } with values as percentages 43 | const providerPieRaw = getProviderDistributionForJobIds(jobIds); 44 | const providerPie = Array.isArray(providerPieRaw) 45 | ? { 46 | labels: providerPieRaw.map((p) => cap(p.type)), 47 | values: providerPieRaw.map((p) => Number(p.value) || 0), 48 | } 49 | : providerPieRaw && typeof providerPieRaw === 'object' 50 | ? { 51 | labels: Array.isArray(providerPieRaw.labels) ? providerPieRaw.labels : [], 52 | values: Array.isArray(providerPieRaw.values) ? providerPieRaw.values : [], 53 | } 54 | : { labels: [], values: [] }; 55 | 56 | res.body = { 57 | general: { 58 | interval: settings.interval, 59 | lastRun: settings.lastRun || null, 60 | nextRun: settings.lastRun == null ? 0 : settings.lastRun + settings.interval * 60000, 61 | }, 62 | kpis: { 63 | totalJobs, 64 | totalListings, 65 | numberOfActiveListings, 66 | avgPriceOfListings, 67 | }, 68 | pie: providerPie, 69 | }; 70 | res.send(); 71 | }); 72 | -------------------------------------------------------------------------------- /lib/api/api.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import { notificationAdapterRouter } from './routes/notificationAdapterRouter.js'; 7 | import { authInterceptor, cookieSession, adminInterceptor } from './security.js'; 8 | import { generalSettingsRouter } from './routes/generalSettingsRoute.js'; 9 | import { providerRouter } from './routes/providerRouter.js'; 10 | import { versionRouter } from './routes/versionRouter.js'; 11 | import { loginRouter } from './routes/loginRoute.js'; 12 | import { userRouter } from './routes/userRoute.js'; 13 | import { jobRouter } from './routes/jobRouter.js'; 14 | import bodyParser from 'body-parser'; 15 | import restana from 'restana'; 16 | import files from 'serve-static'; 17 | import path from 'path'; 18 | import { getDirName } from '../utils.js'; 19 | import { demoRouter } from './routes/demoRouter.js'; 20 | import logger from '../services/logger.js'; 21 | import { listingsRouter } from './routes/listingsRouter.js'; 22 | import { getSettings } from '../services/storage/settingsStorage.js'; 23 | import { featureRouter } from './routes/featureRouter.js'; 24 | import { dashboardRouter } from './routes/dashboardRouter.js'; 25 | import { backupRouter } from './routes/backupRouter.js'; 26 | const service = restana(); 27 | const staticService = files(path.join(getDirName(), '../ui/public')); 28 | const PORT = (await getSettings()).port || 9998; 29 | 30 | service.use(bodyParser.json()); 31 | service.use(cookieSession()); 32 | service.use(staticService); 33 | service.use('/api/admin', authInterceptor()); 34 | service.use('/api/jobs', authInterceptor()); 35 | service.use('/api/version', authInterceptor()); 36 | service.use('/api/listings', authInterceptor()); 37 | service.use('/api/dashboard', authInterceptor()); 38 | service.use('/api/features', authInterceptor()); 39 | 40 | // /admin can only be accessed when user is having admin permissions 41 | service.use('/api/admin', adminInterceptor()); 42 | service.use('/api/jobs/notificationAdapter', notificationAdapterRouter); 43 | service.use('/api/admin/generalSettings', generalSettingsRouter); 44 | service.use('/api/admin/backup', backupRouter); 45 | service.use('/api/jobs/provider', providerRouter); 46 | service.use('/api/admin/users', userRouter); 47 | service.use('/api/version', versionRouter); 48 | service.use('/api/jobs', jobRouter); 49 | service.use('/api/login', loginRouter); 50 | service.use('/api/listings', listingsRouter); 51 | service.use('/api/features', featureRouter); 52 | service.use('/api/dashboard', dashboardRouter); 53 | //this route is unsecured intentionally as it is being queried from the login page 54 | service.use('/api/demo', demoRouter); 55 | 56 | service.start(PORT).then(() => { 57 | logger.debug(`Started API service on port ${PORT}`); 58 | }); 59 | -------------------------------------------------------------------------------- /ui/src/components/navigation/Navigation.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2025 by Christian Kellner. 3 | * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause 4 | */ 5 | 6 | import React, { useEffect, useState } from 'react'; 7 | import { Button, Nav } from '@douyinfe/semi-ui'; 8 | import { IconStar, IconSetting, IconTerminal, IconHistogram, IconSidebar } from '@douyinfe/semi-icons'; 9 | import logoWhite from '../../assets/logo_white.png'; 10 | import heart from '../../assets/heart.png'; 11 | import Logout from '../logout/Logout.jsx'; 12 | import { useLocation, useNavigate } from 'react-router-dom'; 13 | 14 | import './Navigate.less'; 15 | import { useFeature } from '../../hooks/featureHook.js'; 16 | import { useScreenWidth } from '../../hooks/screenWidth.js'; 17 | 18 | export default function Navigation({ isAdmin }) { 19 | const navigate = useNavigate(); 20 | const location = useLocation(); 21 | 22 | const width = useScreenWidth(); 23 | const [collapsed, setCollapsed] = useState(width <= 850); 24 | const watchlistFeature = useFeature('WATCHLIST_MANAGEMENT') || false; 25 | 26 | useEffect(() => { 27 | if (width <= 850) { 28 | setCollapsed(true); 29 | } 30 | }, [width]); 31 | 32 | const items = [ 33 | { itemKey: '/dashboard', text: 'Dashboard', icon: }, 34 | { itemKey: '/jobs', text: 'Jobs', icon: }, 35 | { itemKey: '/listings', text: 'Listings', icon: }, 36 | ]; 37 | 38 | if (isAdmin) { 39 | const settingsItems = [ 40 | { itemKey: '/users', text: 'User Management' }, 41 | { itemKey: '/generalSettings', text: 'General Settings' }, 42 | ]; 43 | if (watchlistFeature) { 44 | settingsItems.push({ itemKey: '/watchlistManagement', text: 'Watchlist Management' }); 45 | } 46 | 47 | items.push({ 48 | itemKey: 'settings', 49 | text: 'Settings', 50 | icon: , 51 | items: settingsItems, 52 | }); 53 | } 54 | 55 | function parsePathName(name) { 56 | const split = name.split('/').filter((s) => s.length !== 0); 57 | return '/' + split[0]; 58 | } 59 | 60 | return ( 61 |