├── .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 ;
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 | }
16 | type="danger"
17 | theme="solid"
18 | onClick={async () => {
19 | await xhrPost('/api/login/logout');
20 | location.reload();
21 | }}
22 | >
23 | {text && 'Logout'}
24 |
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 |
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 | }
31 | onClick={() => onEdit(record.id)}
32 | style={{ marginRight: '1rem' }}
33 | />
34 | } onClick={() => onRemove(record.id)} />
35 |
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 |
} onClick={() => onEdit(record)} />
39 |
40 |
} onClick={() => onRemove(record.url)} />
41 |
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 | }
51 | onClick={() => onUserRemoval(user.id)}
52 | style={{ marginRight: '1rem' }}
53 | />
54 | } onClick={() => onUserEdit(user.id)} />
55 |
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 | }
57 | className="jobs__newButton"
58 | onClick={() => navigate('/jobs/new')}
59 | >
60 | New Job
61 |
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 | }
58 | onClick={() => navigate('/users/new')}
59 | >
60 | New User
61 |
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 | {
67 | navigate(key.itemKey);
68 | }}
69 | header={ }
70 | footer={
71 |
72 |
73 | } onClick={() => setCollapsed(!collapsed)}>
74 | {!collapsed && 'Collapse'}
75 |
76 |
77 | }
78 | />
79 | );
80 | }
81 |
--------------------------------------------------------------------------------
/lib/notification/adapter/telegram.md:
--------------------------------------------------------------------------------
1 | ### Telegram Adapter
2 |
3 | Use this adapter to send notifications to Telegram via a bot. You will need:
4 | - A Telegram Bot token (from BotFather)
5 | - A chat ID (where messages will be sent)
6 | - Optionally: a thread ID if you want to post into a specific forum topic in a group
7 |
8 | #### Create a bot
9 | Create a bot with BotFather: open https://telegram.me/BotFather on your phone or in Telegram Desktop and follow the instructions to get your bot token.
10 |
11 | #### Getting the chat ID
12 | A Telegram bot cannot message a user first; you must create a conversation (or add the bot to a group/channel) so Telegram assigns a chat the bot can access.
13 |
14 | Steps:
15 | 1. Start a chat with your bot in Telegram (or add the bot to your group/supergroup/channel) and send any message.
16 | 2. Fetch recent updates from the Bot API:
17 | ```
18 | curl -X GET "https://api.telegram.org/bot{YOUR_TELEGRAM_TOKEN}/getUpdates"
19 | ```
20 | 3. In the JSON response, find the message that you just sent and read `message.chat.id`. That value is your `chatId`.
21 | - Private chats: `chat.id` is a positive number
22 | - Groups/supergroups: `chat.id` is a negative number
23 |
24 | Keep your bot token secret. If `getUpdates` returns an empty list, send a new message and try again, or make sure your bot’s privacy settings allow it to see group messages when used in groups.
25 |
26 | #### Getting the thread ID (this is optional to be used for forum topics)
27 | If you want messages to appear inside a specific forum topic of a supergroup with Topics enabled, you also need a thread ID. In the Telegram Bot API this is called `message_thread_id`.
28 |
29 | When you need it:
30 | - Required only for supergroups with Topics enabled when targeting a topic
31 | - Not used for private chats, basic groups without Topics, or channels
32 |
33 | Steps to obtain it:
34 | 1. In your supergroup, enable Topics (Group settings → Manage group → Topics → Enable). Now add a new topic.
35 | 2. Add your created bot to the topic. (Click on the bot and on "Add to group")
36 | 3. Open the desired topic (or create a new one) and send any message inside that topic.
37 | 4. Call `getUpdates` again:
38 | ```
39 | curl -X GET "https://api.telegram.org/bot{YOUR_TELEGRAM_TOKEN}/getUpdates"
40 | ```
41 | 4. In the update for the message you sent inside the topic, read `message.message_thread_id`. That number is your `threadId` for this topic.
42 |
43 | Example (truncated):
44 | ```
45 | {
46 | "message": {
47 | "chat": { "id": -1001234567890, "type": "supergroup" },
48 | "message_thread_id": 42,
49 | "text": "hello from the topic"
50 | }
51 | }
52 | ```
53 | Use `chat.id` as `chatId` and `message_thread_id` as `threadId` in your configuration.
54 |
55 | More details about bots and BotFather: https://core.telegram.org/bots#botfather
56 |
--------------------------------------------------------------------------------
/lib/services/tracking/Tracker.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 { getJobs } from '../storage/jobStorage.js';
7 | import { getUniqueId } from './uniqueId.js';
8 | import { getPackageVersion, inDevMode } from '../../utils.js';
9 | import os from 'os';
10 | import fetch from 'node-fetch';
11 | import logger from '../logger.js';
12 | import { getSettings } from '../storage/settingsStorage.js';
13 |
14 | const deviceId = getUniqueId() || 'N/A';
15 | const version = await getPackageVersion();
16 | const FREDY_TRACKING_URL = 'https://fredy.orange-coding.net/tracking';
17 |
18 | export const trackMainEvent = async () => {
19 | try {
20 | const settings = await getSettings();
21 | if (settings.analyticsEnabled && !inDevMode()) {
22 | const activeProvider = new Set();
23 | const activeAdapter = new Set();
24 |
25 | const jobs = getJobs();
26 |
27 | if (jobs != null && jobs.length > 0) {
28 | jobs.forEach((job) => {
29 | job.provider.forEach((provider) => activeProvider.add(provider.id));
30 | job.notificationAdapter.forEach((adapter) => activeAdapter.add(adapter.id));
31 | });
32 |
33 | const trackingObj = enrichTrackingObject({
34 | adapter: Array.from(activeAdapter),
35 | provider: Array.from(activeProvider),
36 | });
37 |
38 | await fetch(`${FREDY_TRACKING_URL}/main`, {
39 | method: 'POST',
40 | headers: { 'Content-Type': 'application/json' },
41 | body: JSON.stringify(trackingObj),
42 | });
43 | }
44 | }
45 | } catch (error) {
46 | logger.warn('Error sending tracking data', error);
47 | }
48 | };
49 |
50 | /**
51 | * Note, this will only be used when Fredy runs in demo mode
52 | */
53 | export async function trackDemoAccessed() {
54 | const settings = await getSettings();
55 | if (settings.analyticsEnabled && !inDevMode() && settings.demoMode) {
56 | try {
57 | await fetch(`${FREDY_TRACKING_URL}/demo/accessed`, {
58 | method: 'POST',
59 | headers: { 'Content-Type': 'application/json' },
60 | });
61 | } catch (error) {
62 | logger.warn('Error sending tracking data', error);
63 | }
64 | }
65 | }
66 |
67 | async function enrichTrackingObject(trackingObject) {
68 | const settings = await getSettings();
69 | const operatingSystem = os.platform();
70 | const osVersion = os.release();
71 | const arch = process.arch;
72 | const language = process.env.LANG || 'en';
73 | const nodeVersion = process.version || 'N/A';
74 |
75 | return {
76 | ...trackingObject,
77 | isDemo: settings.demoMode,
78 | operatingSystem,
79 | osVersion,
80 | arch,
81 | nodeVersion,
82 | language,
83 | deviceId,
84 | version,
85 | };
86 | }
87 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | Newer release changelog see https://github.com/orangecoding/fredy/releases
2 |
3 | ---
4 |
5 | ###### [V5.5.0]
6 |
7 | - Upgrading dependencies
8 | - fixing provider
9 | - allow multiple instances of 1 provider
10 | - **BREAKING**: Minimum node version is now 16
11 |
12 | ###### [V5.4.6]
13 |
14 | - Adding Instana node.js monitoring
15 | -
16 |
17 | ###### [V5.4.5]
18 |
19 | - Adding Instana node.js monitoring
20 |
21 | ###### [V5.4.4]
22 |
23 | - Add support for Immo Südwest Presse (immo.swp.de)
24 | - Telegram: Use job name instead of ID and link in title
25 | - Fix race condition if user ID is in session but not in user store
26 | - Allow visiting the original provider URL
27 |
28 | ###### [V5.4.3]
29 |
30 | - re-writing readme
31 | - improving docker build
32 | - using github's actions to build docker and test automatically
33 |
34 | ###### [V5.4.2]
35 |
36 | - Fixing prod build
37 |
38 | ###### [V5.4.1]
39 |
40 | - Upgrading dependencies
41 | - Provider urls are now automagically been changed to include the correct sort order for search results
42 |
43 | ```
44 | Note: It has been an point of confusion since the very beginning of Fredy, that people simply copied the url, but
45 | did not take care of sorting the search results by date. If this is not done, Fredy will most likely not see the latest
46 | results, thus cannot report them. This release fixes it by adding the necessary params (or replaces them).
47 | ```
48 |
49 | ###### [V5.3.0]
50 |
51 | - Upgrading dependencies
52 | - It's now possible to send mails to multiple receiver using comma separation for MailJet & Sendgrid
53 | - Fixing Immowelt scraping
54 |
55 | ###### [V5.2.0]
56 |
57 | - Upgrading dependencies
58 | - Adding new similarity check layer (Duplicates are being removed now)
59 | - Adding paging for search results
60 |
61 | ###### [V5.1.0]
62 |
63 | - Upgrading dependencies
64 | - NodeJS 12.13 is now the minimum supported version
65 | - Adding general settings as new configuration page to ui
66 | - Adding new feature working hours
67 |
68 | ###### [V5.0.0]
69 |
70 | - Upgrading dependencies
71 | - NodeJS 12 is now the minimum supported version
72 |
73 | ###### [V4.0.0]
74 |
75 | Bringing back Immoscout :tada:
76 |
77 | ###### [V3.0.0]
78 |
79 | This is basically a re-write, your old config file will not be compatible anymore. Please re-created your search jobs
80 | on the new ui and use the values from your previous config file if needed.
81 |
82 | ```
83 | - We're getting rid of manual config changes, Fredy, now ships with a UI so that it's easy for you to create and edit jobs
84 | ```
85 |
86 | ###### [V2.0.0]
87 |
88 | ```
89 | - Fredy can now run multiple search job on one instance
90 | - Changed lot's of the structure of Fredy to make this happen
91 | [BREAKING CHANGES]
92 | - The config has been changed, the config of V1.x will not work any longer
93 | - Sources have been renamed to provider
94 | ```
95 |
--------------------------------------------------------------------------------
/test/similarity/similarityCache.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 esmock from 'esmock';
8 |
9 | // Helper to create module under test with mocks
10 | async function loadModuleWith({ entries = [] } = {}) {
11 | const mod = await esmock('../../lib/services/similarity-check/similarityCache.js', {
12 | // Mock the storage to return our controlled entries
13 | '../../lib/services/storage/listingsStorage.js': {
14 | getAllEntriesFromListings: () => entries,
15 | },
16 | });
17 | return mod;
18 | }
19 |
20 | describe('similarityCache', () => {
21 | it('initSimilarityCache builds cache from storage and enables duplicate detection', async () => {
22 | const entries = [
23 | { title: 'A', price: 1000, address: 'Main 1' },
24 | { title: 'B', price: 0, address: 'Zero St' },
25 | ];
26 |
27 | const { initSimilarityCache, checkAndAddEntry } = await loadModuleWith({ entries });
28 |
29 | // Initially, duplicates should not be detected for new data
30 | expect(checkAndAddEntry({ title: 'X', price: 200, address: 'Y' })).to.equal(false);
31 |
32 | // Now initialize from storage
33 | initSimilarityCache();
34 |
35 | // Exact duplicates should be detected
36 | expect(checkAndAddEntry({ title: 'A', price: 1000, address: 'Main 1' })).to.equal(true);
37 | // Ensure falsy-but-valid price 0 is preserved by hashing and detected as duplicate
38 | expect(checkAndAddEntry({ title: 'B', price: 0, address: 'Zero St' })).to.equal(true);
39 | });
40 |
41 | it('checkAndAddEntry returns false for new entry then true for duplicate on second call', async () => {
42 | const { checkAndAddEntry } = await loadModuleWith();
43 |
44 | const first = checkAndAddEntry({ title: 'C', price: 300, address: 'Road 3' });
45 | const second = checkAndAddEntry({ title: 'C', price: 300, address: 'Road 3' });
46 |
47 | expect(first).to.equal(false);
48 | expect(second).to.equal(true);
49 | });
50 |
51 | it('hashing ignores null/undefined but preserves 0 via behavior', async () => {
52 | const { checkAndAddEntry } = await loadModuleWith();
53 |
54 | // Add baseline (null address ignored)
55 | const add1 = checkAndAddEntry({ title: 'T', price: 1, address: null });
56 | expect(add1).to.equal(false);
57 | // Duplicate with undefined address should match
58 | const dup = checkAndAddEntry({ title: 'T', price: 1, address: undefined });
59 | expect(dup).to.equal(true);
60 |
61 | // Now test that price 0 is preserved (not filtered out)
62 | const addZero = checkAndAddEntry({ title: 'Z', price: 0, address: 'Zero' });
63 | expect(addZero).to.equal(false);
64 | const dupZero = checkAndAddEntry({ title: 'Z', price: 0, address: 'Zero' });
65 | expect(dupZero).to.equal(true);
66 | });
67 | });
68 |
--------------------------------------------------------------------------------
/test/provider/testProvider.json:
--------------------------------------------------------------------------------
1 | {
2 | "einsAImmobilien": {
3 | "url": "https://www.1a-immobilienmarkt.de/suchen/duesseldorf/wohnung-kaufen.html?search=yes&cfid=98b39c7e-b403-4764-8f3c-57bf590923d0&data_hash=f46f89548257740094dd708996adcd68&sort_type=newest",
4 | "enabled": true,
5 | "id": "einsAImmobilien"
6 | },
7 | "immobilienDe": {
8 | "url": "https://www.immobilien.de/Wohnen/Suchergebnisse-51797.html?search._digest=true&search._filter=wohnen&search.flaeche_von=50&search.objektart=wohnung&search.preis_bis=1200&search.typ=mieten&search.umkreis=15&search.wo=district%3A2434%2C2695%2C2621%2C2700%2C2967%2C2734%2C2909%2C2955%2C2392%2C2746%2C2767%2C2982%2C2904%2C2612%2C2892%2C2587%2C2871%2C2975%2C2591%2C2887%2C2569%2C2640%2C2735&sort_col=*created_ts&sort_dir=desc",
9 | "enabled": true
10 | },
11 | "immonet": {
12 | "url": "https://www.immonet.de/classified-search?distributionTypes=Buy,Buy_Auction,Compulsory_Auction&estateTypes=House,Apartment&locations=AD08DE2112&order=Default&m=homepage_new_search_classified_search_result",
13 | "enabled": true
14 | },
15 | "immowelt": {
16 | "url": "https://www.immowelt.de/classified-search?distributionTypes=Buy,Buy_Auction,Compulsory_Auction&estateTypes=House,Apartment&locations=AD08DE2350",
17 | "enabled": true
18 | },
19 | "immoscout": {
20 | "url": "https://www.immobilienscout24.de/Suche/de/nordrhein-westfalen/duesseldorf/wohnung-mieten?enteredFrom=one_step_search",
21 | "enabled": true
22 | },
23 | "immoswp": {
24 | "url": "https://immo.swp.de/suchergebnisse?l=M%C3%BCnchen&r=0km&_multiselect_r=0km&ut=private&t=apartment%3Arental&a=de.muenchen&pf=&pt=&rf=0&rt=0&sf=50&st=&yf=&yt=&ff=&ft=&s=most_recently_updated_first&pa=&o=&ad=&u=",
25 | "enabled": true
26 | },
27 | "kleinanzeigen": {
28 | "url": "https://www.kleinanzeigen.de/s-immobilien/duesseldorf/anzeige:angebote/wohnung/k0c195l2068r5",
29 | "enabled": true
30 | },
31 | "mcMakler": {
32 | "url": "https://www.mcmakler.de/immobilien/results?placeId=62649&search=Leipzig%252C+Sachsen&propertyTypes=APARTMENT&page=0",
33 | "enabled": true
34 | },
35 | "ohneMakler": {
36 | "url": "https://www.ohne-makler.net/immobilien/wohnung-kaufen/nordrhein-westfalen/dusseldorf/",
37 | "enabled": true
38 | },
39 | "neubauKompass": {
40 | "url": "https://www.neubaukompass.de/neubau-immobilien/duesseldorf-region/eigentumswohnung/",
41 | "enabled": true
42 | },
43 | "regionalimmobilien24": {
44 | "url": "https://www.regionalimmobilien24.de/rostock/rostock/kaufen/haus/-/-/-/?rd=5",
45 | "enabled": true
46 | },
47 | "sparkasse": {
48 | "url": "https://immobilien.sparkasse.de/immobilien/treffer?marketingType=buy&objectType=flat&perimeter=10&usageType=residential&zipCityEstateId=62782__Hamburg",
49 | "enabled": true
50 | },
51 | "wgGesucht": {
52 | "url": "https://www.wg-gesucht.de/wg-zimmer-in-Duesseldorf.30.0.1.0.html",
53 | "enabled": true
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/lib/notification/adapter/ntfy.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 | import { normalizeImageUrl } from '../../utils.js';
10 |
11 | export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
12 | const { priority, server, topic } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
13 | const job = getJob(jobKey);
14 | const jobName = job == null ? jobKey : job.name;
15 |
16 | const promises = newListings.map((newListing) => {
17 | const message = `
18 | Address: ${newListing.address}
19 | Size: ${newListing.size == null ? 'N/A' : newListing.size.replace(/2m/g, '$m^2$')}
20 | Price: ${newListing.price}
21 | Link: ${newListing.link}`;
22 |
23 | const sanitizeHeaderValue = (value) =>
24 | String(value ?? '')
25 | .replace(/[\r\n]+/g, ' ')
26 | .replace(/[^\x20-\x7E]/g, ' ')
27 | .trim();
28 |
29 | const headers = {
30 | Title: sanitizeHeaderValue(newListing.title),
31 | Priority: sanitizeHeaderValue(priority),
32 | Tags: sanitizeHeaderValue(`${serviceName},${jobName}`),
33 | Click: sanitizeHeaderValue(newListing.link),
34 | };
35 |
36 | if (newListing.image && typeof newListing.image === 'string') {
37 | headers.Attach = normalizeImageUrl(newListing.image);
38 | }
39 |
40 | return fetch(`${server}/${topic}`, {
41 | method: 'POST',
42 | headers,
43 | body: message,
44 | })
45 | .then((res) => {
46 | if (!res.ok) {
47 | throw new Error(`Ntfy message could not be sent. Status code: ${res.status}`);
48 | }
49 | return res.text();
50 | })
51 | .catch((error) => {
52 | // Ensure we reject with an Error object and prevent unhandled rejections
53 | throw error instanceof Error ? error : new Error(String(error));
54 | });
55 | });
56 |
57 | return Promise.all(promises);
58 | };
59 |
60 | export const config = {
61 | id: 'ntfy',
62 | name: 'ntfy',
63 | readme: markdown2Html('lib/notification/adapter/ntfy.md'),
64 | description: 'Fredy will send new listings to your ntfy.',
65 | fields: {
66 | priority: {
67 | type: 'number',
68 | label: 'Priority',
69 | description: 'The priority of the send notification.',
70 | },
71 | server: {
72 | type: 'text',
73 | label: 'Server-URL',
74 | description: 'The server url to the send the notification to.',
75 | },
76 | topic: {
77 | type: 'text',
78 | label: 'topic',
79 | description:
80 | 'The topic where fredy should send notifications to. The topic is a secret, only known to you, make sure it is something not generic.',
81 | },
82 | },
83 | };
84 |
--------------------------------------------------------------------------------
/lib/notification/adapter/pushover.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 = async ({ serviceName, newListings, notificationConfig, jobKey }) => {
11 | const { token, user, device } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
12 | const job = getJob(jobKey);
13 | const jobName = job == null ? jobKey : job.name;
14 |
15 | const results = await Promise.all(
16 | newListings.map(async (newListing) => {
17 | const title = `${jobName} at ${serviceName}: ${newListing.title}`;
18 | const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}`;
19 |
20 | const form = new FormData();
21 | form.append('token', token);
22 | form.append('user', user);
23 | form.append('title', title);
24 | form.append('message', message);
25 | if (device) form.append('device', device);
26 |
27 | // Try to attach image if available
28 | if (newListing.image && typeof newListing.image === 'string') {
29 | try {
30 | const imgRes = await fetch(newListing.image);
31 | if (imgRes.ok) {
32 | const ab = await imgRes.arrayBuffer();
33 | form.append('attachment', new Blob([ab]), 'image.jpg');
34 | }
35 | } catch {
36 | // fail silently, just skip the image
37 | }
38 | }
39 |
40 | const res = await fetch('https://api.pushover.net/1/messages.json', {
41 | method: 'POST',
42 | body: form,
43 | });
44 |
45 | return res.json();
46 | }),
47 | );
48 |
49 | // Collect errors
50 | const errors = results
51 | .map((r) => (r.errors && r.errors.length > 0 ? r.errors.join(', ') : null))
52 | .filter((e) => e !== null);
53 |
54 | if (errors.length > 0) {
55 | return Promise.reject(errors.join('; '));
56 | }
57 |
58 | return results;
59 | };
60 |
61 | export const config = {
62 | id: 'pushover',
63 | name: 'Pushover',
64 | readme: markdown2Html('lib/notification/adapter/pushover.md'),
65 | description: 'Fredy will send new listings to your mobile using Pushover.',
66 | fields: {
67 | token: {
68 | type: 'text',
69 | label: 'API token',
70 | description: "Your application's API token.",
71 | },
72 | user: {
73 | type: 'text',
74 | label: 'User key',
75 | description: 'Your user/group key.',
76 | },
77 | device: {
78 | type: 'text',
79 | label: 'Device name',
80 | description:
81 | 'The device name to send your notification to. Messages may be addressed to multiple specific devices by joining them with a comma.',
82 | },
83 | },
84 | };
85 |
--------------------------------------------------------------------------------
/ui/src/components/cards/PieChartCard.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 { Pie } from 'react-chartjs-2';
8 | import { Chart as ChartJS, ArcElement, Tooltip, Legend, Title as ChartTitle } from 'chart.js';
9 |
10 | import './ChartCard.less';
11 |
12 | ChartJS.register(ArcElement, Tooltip, Legend, ChartTitle);
13 |
14 | export default function PieChartCard({ data = [] }) {
15 | const { labels, values } = React.useMemo(() => {
16 | if (data && typeof data === 'object' && !Array.isArray(data)) {
17 | const lbls = Array.isArray(data.labels) ? data.labels : [];
18 | const vals = Array.isArray(data.values)
19 | ? data.values.map((v) => (Number.isFinite(Number(v)) ? Number(v) : 0))
20 | : [];
21 | return { labels: lbls, values: vals };
22 | }
23 | if (Array.isArray(data)) {
24 | const lbls = data.map((d) => d?.type ?? 'Unknown');
25 | const vals = data.map((d) => {
26 | const v = Number(d?.value);
27 | return Number.isFinite(v) ? v : 0;
28 | });
29 | return { labels: lbls, values: vals };
30 | }
31 | return { labels: [], values: [] };
32 | }, [data]);
33 |
34 | const palette = React.useMemo(
35 | () => [
36 | '#4e79a7',
37 | '#f28e2b',
38 | '#e15759',
39 | '#76b7b2',
40 | '#59a14f',
41 | '#edc948',
42 | '#b07aa1',
43 | '#ff9da7',
44 | '#9c755f',
45 | '#bab0ab',
46 | ],
47 | [],
48 | );
49 |
50 | const chartData = React.useMemo(
51 | () => ({
52 | labels,
53 | datasets: [
54 | {
55 | data: values,
56 | backgroundColor: labels.map((_, i) => palette[i % palette.length]),
57 | borderColor: labels.map((_, i) => palette[i % palette.length]),
58 | borderWidth: 1,
59 | },
60 | ],
61 | }),
62 | [labels, values, palette],
63 | );
64 |
65 | const options = React.useMemo(
66 | () => ({
67 | responsive: true,
68 | maintainAspectRatio: false,
69 | plugins: {
70 | legend: {
71 | display: true,
72 | position: 'right',
73 | labels: {
74 | color: () => '#fff',
75 | },
76 | },
77 | title: { display: false },
78 | tooltip: {
79 | callbacks: {
80 | label: (ctx) => {
81 | const label = ctx.label || '';
82 | const val = ctx.parsed !== undefined ? ctx.parsed : ctx.raw;
83 | return `${label}: ${val}%`;
84 | },
85 | },
86 | },
87 | },
88 | }),
89 | [],
90 | );
91 |
92 | const isEmpty = !labels || labels.length === 0 || !values || values.length === 0;
93 |
94 | return (
95 | <>{isEmpty ? No Data
: }>
96 | );
97 | }
98 |
--------------------------------------------------------------------------------
/lib/api/routes/userRoute.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 jobStorage from '../../services/storage/jobStorage.js';
9 | import { getSettings } from '../../services/storage/settingsStorage.js';
10 | const service = restana();
11 | const userRouter = service.newRouter();
12 | function checkIfAnyAdminAfterRemovingUser(userIdToBeRemoved, allUser) {
13 | return allUser.filter((user) => user.id !== userIdToBeRemoved && user.isAdmin).length > 0;
14 | }
15 | function checkIfUserToBeRemovedIsLoggedIn(userIdToBeRemoved, req) {
16 | return req.session.currentUser === userIdToBeRemoved;
17 | }
18 | const nullOrEmpty = (str) => str == null || str.length === 0;
19 |
20 | userRouter.get('/', async (req, res) => {
21 | res.body = userStorage.getUsers(false);
22 | res.send();
23 | });
24 |
25 | userRouter.get('/:userId', async (req, res) => {
26 | const { userId } = req.params;
27 | res.body = userStorage.getUser(userId);
28 | res.send();
29 | });
30 | userRouter.delete('/', async (req, res) => {
31 | const settings = await getSettings();
32 | if (settings.demoMode) {
33 | res.send(new Error('In demo mode, it is not allowed to remove user.'));
34 | return;
35 | }
36 |
37 | const { userId } = req.body;
38 | const allUser = userStorage.getUsers(false);
39 | if (!checkIfAnyAdminAfterRemovingUser(userId, allUser)) {
40 | res.send(new Error('You are trying to remove the last admin user. This is prohibited.'));
41 | return;
42 | }
43 | if (checkIfUserToBeRemovedIsLoggedIn(userId, req)) {
44 | res.send(new Error('You are trying to remove yourself. This is prohibited.'));
45 | return;
46 | }
47 | //TODO: Remove also analytics
48 | jobStorage.removeJobsByUserId(userId);
49 | userStorage.removeUser(userId);
50 | res.send();
51 | });
52 | userRouter.post('/', async (req, res) => {
53 | const settings = await getSettings();
54 | if (settings.demoMode) {
55 | res.send(new Error('In demo mode, it is not allowed to change or add user.'));
56 | return;
57 | }
58 |
59 | const { username, password, password2, isAdmin, userId } = req.body;
60 | if (password !== password2) {
61 | res.send(new Error('Passwords does not match'));
62 | return;
63 | }
64 | if (nullOrEmpty(username) || nullOrEmpty(password) || nullOrEmpty(password2)) {
65 | res.send(new Error('Username and password are mandatory.'));
66 | return;
67 | }
68 | const allUser = userStorage.getUsers(false);
69 | if (!isAdmin && !checkIfAnyAdminAfterRemovingUser(userId, allUser)) {
70 | res.send(
71 | new Error('You cannot change the admin flag for this user as otherwise, there is no other user in the system'),
72 | );
73 | return;
74 | }
75 | userStorage.upsertUser({
76 | userId,
77 | username,
78 | password,
79 | isAdmin,
80 | });
81 | res.send();
82 | });
83 | export { userRouter };
84 |
--------------------------------------------------------------------------------
/ui/src/views/listings/management/WatchlistManagement.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, { useState } from 'react';
7 | import { IconHorn } from '@douyinfe/semi-icons';
8 | import { SegmentPart } from '../../../components/segment/SegmentPart.jsx';
9 | import { Banner, Button, Checkbox, Space } from '@douyinfe/semi-ui';
10 | import NotificationAdapterMutator from '../../jobs/mutation/components/notificationAdapter/NotificationAdapterMutator.jsx';
11 | import Headline from '../../../components/headline/Headline.jsx';
12 |
13 | export default function WatchlistManagement() {
14 | const [notificationChooserVisible, setNotificationChooserVisible] = useState(false);
15 | const [notificationAdapterData, setNotificationAdapterData] = useState([]);
16 | //TODO: Set default
17 | const [activityChanges, setActivityChanges] = useState(false);
18 | const [priceChanges, setPriceChanges] = useState(false);
19 | return (
20 |
21 |
26 | Note
}
31 | description="You’ll receive notifications only for listings that are on your watch list. To add listings to it, open the 'Listings' section and tag the ones you want to follow."
32 | />
33 |
34 |
35 |
36 | setActivityChanges(e.target.checked)}>
37 | Listing state changes (e.g. listing becomes inactive)
38 |
39 | setPriceChanges(e.target.checked)}>
40 | Listing price changes
41 |
42 |
43 |
44 |
45 | setNotificationChooserVisible(true)}>Select notification method
46 |
47 | {
52 | setNotificationChooserVisible(visible);
53 | }}
54 | selected={notificationAdapterData}
55 | editNotificationAdapter={null}
56 | onData={(data) => {
57 | const oldData = [...notificationAdapterData].filter((o) => o.id !== data.id);
58 | setNotificationAdapterData([...oldData, data]);
59 | }}
60 | />
61 |
62 |
63 | );
64 | }
65 |
--------------------------------------------------------------------------------
/lib/services/storage/migrations/sql/6.settings.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 settings table to store important (config) settings instead of using config file
7 | import fs from 'fs';
8 | import path from 'path';
9 | import { nanoid } from 'nanoid';
10 | import logger from '../../../logger.js';
11 | import { DEFAULT_CONFIG } from '../../../../defaultConfig.js';
12 | import { getDirName } from '../../../../utils.js';
13 |
14 | export function up(db) {
15 | db.exec(`
16 | CREATE TABLE IF NOT EXISTS settings
17 | (
18 | id TEXT PRIMARY KEY,
19 | create_date INTEGER NOT NULL,
20 | user_id TEXT,
21 | name TEXT NOT NULL,
22 | value jsonb NOT NULL
23 | );
24 |
25 | CREATE UNIQUE INDEX IF NOT EXISTS idx_settings_name ON settings (name);
26 | `);
27 |
28 | // Helper to insert one setting row
29 | const insertSetting = (name, rawValue) => {
30 | try {
31 | const id = nanoid();
32 | const createDate = Date.now();
33 | const value = JSON.stringify(rawValue);
34 | db.prepare(
35 | `INSERT INTO settings (id, create_date, name, value)
36 | VALUES (@id, @create_date, @name, @value)`,
37 | ).run({ id, create_date: createDate, name, value });
38 | } catch {
39 | // Ignore duplicate inserts if any (unique by name)
40 | }
41 | };
42 |
43 | // Migrate currently existing config.json into settings
44 | try {
45 | const configPath = path.resolve(process.cwd(), 'conf', 'config.json');
46 |
47 | // Defaults
48 | const defaults = {
49 | interval: '60',
50 | port: 9998,
51 | workingHours: { from: '', to: '' },
52 | demoMode: false,
53 | analyticsEnabled: true,
54 | };
55 |
56 | let config = {};
57 | if (fs.existsSync(configPath)) {
58 | const file = fs.readFileSync(configPath, 'utf8');
59 | try {
60 | config = JSON.parse(file) || {};
61 | } catch (parseErr) {
62 | // If parsing fails, still proceed with defaults
63 | logger.error(parseErr);
64 | config = {};
65 | }
66 | }
67 |
68 | // Insert each known setting, using the value from config when present, otherwise default
69 | insertSetting('interval', config.interval != null ? config.interval : defaults.interval);
70 | insertSetting('port', config.port != null ? config.port : defaults.port);
71 | insertSetting('workingHours', config.workingHours != null ? config.workingHours : defaults.workingHours);
72 | insertSetting('demoMode', config.demoMode != null ? config.demoMode : defaults.demoMode);
73 | insertSetting(
74 | 'analyticsEnabled',
75 | config.analyticsEnabled != null ? config.analyticsEnabled : defaults.analyticsEnabled,
76 | );
77 |
78 | //now making sure only sqlite path remains in the config
79 | const sqlitepath = config.sqlitepath || DEFAULT_CONFIG.sqlitepath;
80 | fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ sqlitepath }));
81 | } catch (e) {
82 | logger.error(e);
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/lib/services/extractor/parser/parser.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 cheerio from 'cheerio';
7 | import logger from '../../logger.js';
8 |
9 | let $ = null;
10 |
11 | export function loadParser(text) {
12 | $ = cheerio.load(text);
13 | }
14 |
15 | export function parse(crawlContainer, crawlFields, text, url) {
16 | if (!text) {
17 | logger.debug('No content found for ', url);
18 | return null;
19 | }
20 |
21 | if (!crawlContainer || !crawlFields) {
22 | logger.debug('Cannot parse, selector was empty for url ', url);
23 | return null;
24 | }
25 |
26 | const result = [];
27 |
28 | if ($(crawlContainer).length === 0) {
29 | logger.debug('No elements in crawl container found for url ', url);
30 | return null;
31 | }
32 |
33 | $(crawlContainer).each((_, element) => {
34 | const container = $(element);
35 | const parsedObject = {};
36 |
37 | // Parse fields based on crawlFields
38 | for (const [key, fieldSelector] of Object.entries(crawlFields)) {
39 | let value;
40 |
41 | try {
42 | const selector = fieldSelector.includes('|')
43 | ? fieldSelector.substring(0, fieldSelector.indexOf('|')).trim()
44 | : fieldSelector;
45 |
46 | if (selector.includes('@')) {
47 | const [sel, attr] = selector.split('@');
48 | if (sel.length === 0) {
49 | value = container.attr(attr.trim());
50 | } else {
51 | value = container.find(sel.trim()).attr(attr.trim());
52 | }
53 | } else {
54 | value = container.find(selector.trim()).text();
55 | }
56 |
57 | // Apply modifiers if specified
58 | if (fieldSelector.includes('|')) {
59 | /* eslint-disable no-unused-vars */
60 | const [_, ...modifiers] = fieldSelector.split('|').map((s) => s.trim());
61 | /* eslint-enable no-unused-vars */
62 | value = applyModifiers(value, modifiers);
63 | }
64 |
65 | parsedObject[key] = value || null;
66 | } catch (error) {
67 | logger.error(`Error parsing field '${key}' with selector '${fieldSelector}':`, error);
68 | parsedObject[key] = null;
69 | }
70 | }
71 |
72 | if (parsedObject.id != null) {
73 | result.push(parsedObject);
74 | } else {
75 | logger.debug('ID not found. Not relaying object.');
76 | }
77 | });
78 |
79 | return result;
80 | }
81 |
82 | // Helper function to apply modifiers
83 | function applyModifiers(value, modifiers) {
84 | if (!value) return value;
85 |
86 | modifiers.forEach((modifier) => {
87 | switch (modifier) {
88 | case 'int':
89 | value = parseInt(value, 10);
90 | break;
91 | case 'trim':
92 | value = value.replace(/\s+/g, ' ').trim();
93 | break;
94 | case 'removeNewline':
95 | value = value.replace(/\n/g, ' ');
96 | break;
97 | default:
98 | logger.warn(`Unknown modifier: ${modifier}`);
99 | }
100 | });
101 |
102 | return value;
103 | }
104 |
--------------------------------------------------------------------------------