Compare commits

...

28 Commits

Author SHA1 Message Date
orangecoding
ebc57702dc next release version 2025-10-03 13:28:09 +02:00
orangecoding
3aa30bc1e2 remove listings from listingstable when clicked 2025-10-03 13:27:44 +02:00
orangecoding
f97fb48e51 Merge branch 'master' of github.com:orangecoding/fredy 2025-10-03 13:04:56 +02:00
orangecoding
4b15894603 adding buttons to remove listings from a given job 2025-10-03 13:04:35 +02:00
orangecoding
31a14a0352 improve footer and upgrade dependencies 2025-10-03 12:45:48 +02:00
Christian Kellner
eecbe91dbd Update README.md 2025-10-02 22:05:49 +02:00
orangecoding
9dd3947cb7 reverting docker file changes, adding script to test things locally 2025-10-02 09:37:01 +02:00
Iaroslav Postovalov
c151f4f76e Use non-root user in Dockerfile (#214) 2025-10-01 20:04:08 +02:00
Christian Kellner
b6755497e4 Ui-Redesign (#203)
* new ui design

* improving ui design

* adding new screenshots

* upgrade dependencies
2025-09-29 20:36:56 +02:00
rugk
412e24b1e3 Add VOLUME to Dockerfile (#208)
Notes/exposes the intended volumes as per best practices.

See https://docs.docker.com/build/building/best-practices/#volume
2025-09-29 12:31:32 +02:00
rugk
0a5785fa1a Specify GitHub image in docker-compose directly (#204)
It's recommend to specify the full "URL" and this aligns with the Readme and default docker would search on Docker Hub, where this is not available: https://hub.docker.com/search?q=fredy%2Ffredy
2025-09-29 12:31:08 +02:00
Thomas Brockmöller
7ebd73c9cf Add new provider McMakler (#201) 2025-09-28 14:16:28 +02:00
orangecoding
95cd4028d7 next release version 2025-09-28 08:13:03 +02:00
orangecoding
eb01c2107c fixing default header 2025-09-28 08:12:51 +02:00
orangecoding
42cd4fa0ae next release version 2025-09-27 18:15:58 +02:00
orangecoding
6d96fd2bf8 Merge branch 'master' of github.com:orangecoding/fredy 2025-09-27 18:15:42 +02:00
orangecoding
ff1d2317a1 improve default puppeteer header 2025-09-27 18:15:28 +02:00
orangecoding
a47fa41278 fixing smaller problems in apprise and mattermost 2025-09-27 18:07:48 +02:00
orangecoding
9654e56846 improving some labels 2025-09-27 18:01:42 +02:00
Christian Kellner
43094640a8 Update README.md 2025-09-27 14:27:25 +02:00
orangecoding
fa234d2d78 fixing code style issues in new discord adapter 2025-09-27 14:24:05 +02:00
orangecoding
7cb0d6e382 next release version 2025-09-27 14:22:09 +02:00
mari
d79f8d2664 Add Discord webhook adapter (#196)
* Add Discord webhook adapter
2025-09-27 14:20:43 +02:00
Thomas Brockmöller
4d37e890ab Add provider for Regionalimmobilien24 (#197) 2025-09-27 14:19:37 +02:00
Thomas Brockmöller
7589f20a18 Add sparkasse immobilien (#199) 2025-09-27 09:43:24 +02:00
Thomas Brockmöller
702ffabc1a Fix and improve immowelt/immonet provider (#194)
* Fix and improve immowelt provider

* Add description to immonet provider

* Fix tests and improve readability
2025-09-27 09:42:08 +02:00
orangecoding
9387de1cd9 next version 2025-09-26 13:09:22 +02:00
orangecoding
facd683d45 santizing ntfy header 2025-09-26 13:07:54 +02:00
57 changed files with 1016 additions and 354 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ db/*.db*
npm-debug.log npm-debug.log
.DS_Store .DS_Store
.idea .idea
.vscode

View File

@@ -31,6 +31,8 @@ RUN mkdir -p /db /conf \
&& ln -s /conf /fredy/conf && ln -s /conf /fredy/conf
EXPOSE 9998 EXPOSE 9998
VOLUME /db
VOLUME /conf
# Start application using PM2 runtime # Start application using PM2 runtime
CMD ["pm2-runtime", "index.js"] CMD ["pm2-runtime", "index.js"]

View File

@@ -9,10 +9,18 @@
</a> </a>
</p> </p>
![Tests](https://github.com/orangecoding/fredy/actions/workflows/test.yml/badge.svg) <p align="center">
[![Docker](https://github.com/orangecoding/fredy/actions/workflows/docker.yml/badge.svg)](https://github.com/orangecoding/fredy/actions/workflows/docker.yml) <a href="https://fredy.orange-coding.net/" target="_blank">Website</a>&nbsp;&nbsp;|&nbsp;&nbsp;
![Source](https://github.com/orangecoding/fredy/actions/workflows/check_source.yml/badge.svg) <a href="https://demo-fredy.orange-coding.net/" target="_blank">Demo</a>
![Docker Pulls](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fghcr-badge.elias.eu.org%2Fapi%2Forangecoding%2Ffredy%2Ffredy&query=%24.downloadCount&label=Docker%20Pulls) </p>
<p align="center">
<img src="https://github.com/orangecoding/fredy/actions/workflows/test.yml/badge.svg" alt="Tests" />
<img src="https://github.com/orangecoding/fredy/actions/workflows/docker.yml/badge.svg" alt="Docker" />
<img src="https://github.com/orangecoding/fredy/actions/workflows/check_source.yml/badge.svg" alt="Source" />
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fghcr-badge.elias.eu.org%2Fapi%2Forangecoding%2Ffredy%2Ffredy&query=%24.downloadCount&label=Docker%20Pulls" alt="Docker Pulls" />
</p>
# Fredy 🏡 Your Self-Hosted Real Estate Finder for Germany # Fredy 🏡 Your Self-Hosted Real Estate Finder for Germany
@@ -21,7 +29,7 @@ Finding an apartment or house in Germany can be stressful and
time-consuming.\ time-consuming.\
**Fredy** makes it easier: it automatically scrapes **ImmoScout24, **Fredy** makes it easier: it automatically scrapes **ImmoScout24,
Immowelt, Immonet, eBay Kleinanzeigen, and WG-Gesucht** and notifies you Immowelt, Immonet, eBay Kleinanzeigen, and WG-Gesucht** and notifies you
instantly via **Slack, Telegram, Email, ntfy, and more** when new instantly via **Slack, Telegram, Email, ntfy, discord and more** when new
listings appear. listings appear.
With a modern architecture, Fredy provides a **clean Web UI**, removes With a modern architecture, Fredy provides a **clean Web UI**, removes
@@ -35,7 +43,7 @@ same listing twice.
- 🏠 Scrapes **ImmoScout24, Immowelt, Immonet, eBay Kleinanzeigen, - 🏠 Scrapes **ImmoScout24, Immowelt, Immonet, eBay Kleinanzeigen,
WG-Gesucht** WG-Gesucht**
- ⚡ Instant notifications: Slack, Telegram, Email (SendGrid, - ⚡ Instant notifications: Slack, Telegram, Email (SendGrid,
Mailjet), ntfy Mailjet), ntfy, discord
- 🔎 Uses the **ImmoScout Mobile API** (reverse engineered) - 🔎 Uses the **ImmoScout Mobile API** (reverse engineered)
- 🌍 Runs anywhere: Docker, Node.js, self-hosted - 🌍 Runs anywhere: Docker, Node.js, self-hosted
- 🖥️ Intuitive **Web UI** to manage searches - 🖥️ Intuitive **Web UI** to manage searches
@@ -107,9 +115,9 @@ yarn run start:frontend # in another terminal
## 📸 Screenshots ## 📸 Screenshots
| Job Configuration | Job Analytics | Job Overview | | Fredy Main Overview | Job Configuration | Found Listings |
|-------------------|--------------|--------------| |--------------------------------------------------|-----------------------------------------------------------------------|-----------------------------------------------------------------------------|
| ![Screenshot showing job configuration in Fredy](doc/screenshot1.png) | ![Screenshot showing job analytics in Fredy](doc/screenshot_2.png) | ![Screenshot showing job overview in Fredy](doc/screenshot_3.png) | | ![Screenshot showing Fredy](doc/screenshot1.png) | ![Screenshot showing job configuration in Fredy](doc/screenshot3.png) | ![Screenshot showing found listings in Fredy](doc/screenshot2.png) |
------------------------------------------------------------------------ ------------------------------------------------------------------------
@@ -129,7 +137,7 @@ picks up the newest listings first.
### Adapter 📡 ### Adapter 📡
An **adapter** is the channel through which Fredy notifies you (Slack, An **adapter** is the channel through which Fredy notifies you (Slack,
Telegram, Email, ntfy, ...).\ Telegram, Email, ntfy, discord ...).\
Each adapter has its own configuration (e.g. API keys, webhook URLs).\ Each adapter has its own configuration (e.g. API keys, webhook URLs).\
You can use multiple adapters at once --- Fredy will send new listings You can use multiple adapters at once --- Fredy will send new listings
through all of them. through all of them.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 197 KiB

BIN
doc/screenshot2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

BIN
doc/screenshot3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -5,7 +5,7 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
image: fredy/fredy image: ghcr.io/orangecoding/fredy
# map existing config and database # map existing config and database
volumes: volumes:
- ./conf:/conf - ./conf:/conf

18
docker-test.sh Normal file
View File

@@ -0,0 +1,18 @@
#!/bin/sh
set -e
# Stop and remove old container if it exists
if [ "$(docker ps -aq -f name=fredy)" ]; then
docker stop fredy || true
docker rm fredy || true
fi
# Build image from local Dockerfile
docker build -t fredy:local .
# Run container with volumes and port mapping
docker run -d --name fredy \
-v fredy_conf:/conf \
-v fredy_db:/db \
-p 9998:9998 \
fredy:local

View File

@@ -1,6 +1,8 @@
import restana from 'restana'; import restana from 'restana';
import * as listingStorage from '../../services/storage/listingsStorage.js'; import * as listingStorage from '../../services/storage/listingsStorage.js';
import { isAdmin as isAdminFn } from '../security.js'; import { isAdmin as isAdminFn } from '../security.js';
import logger from '../../services/logger.js';
const service = restana(); const service = restana();
const listingsRouter = service.newRouter(); const listingsRouter = service.newRouter();
@@ -8,7 +10,7 @@ const listingsRouter = service.newRouter();
listingsRouter.get('/table', async (req, res) => { listingsRouter.get('/table', async (req, res) => {
const { page, pageSize = 50, filter, sortfield = null, sortdir = 'asc' } = req.query || {}; const { page, pageSize = 50, filter, sortfield = null, sortdir = 'asc' } = req.query || {};
const result = listingStorage.queryListings({ res.body = listingStorage.queryListings({
page: page ? parseInt(page, 10) : 1, page: page ? parseInt(page, 10) : 1,
pageSize: pageSize ? parseInt(pageSize, 10) : 50, pageSize: pageSize ? parseInt(pageSize, 10) : 50,
filter: filter || undefined, filter: filter || undefined,
@@ -17,7 +19,31 @@ listingsRouter.get('/table', async (req, res) => {
userId: req.session.currentUser, userId: req.session.currentUser,
isAdmin: isAdminFn(req), isAdmin: isAdminFn(req),
}); });
res.body = result;
res.send(); res.send();
}); });
listingsRouter.delete('/job', async (req, res) => {
const { jobId } = req.body;
try {
listingStorage.deleteListingsByJobId(jobId);
} catch (error) {
res.send(new Error(error));
logger.error(error);
}
res.send();
});
listingsRouter.delete('/', async (req, res) => {
const { ids } = req.body;
try {
if (Array.isArray(ids) && ids.length > 0) {
listingStorage.deleteListingsById(ids);
}
} catch (error) {
res.send(new Error(error));
logger.error(error);
}
res.send();
});
export { listingsRouter }; export { listingsRouter };

View File

@@ -8,7 +8,14 @@ const versionRouter = service.newRouter();
versionRouter.get('/', async (req, res) => { versionRouter.get('/', async (req, res) => {
const versionPayload = await getCurrentVersionFromGithub(); const versionPayload = await getCurrentVersionFromGithub();
res.body = versionPayload == null ? { newVersion: false } : versionPayload; const localFredyVersion = await getPackageVersion();
res.body =
versionPayload == null
? {
newVersion: false,
localFredyVersion,
}
: versionPayload;
res.send(); res.send();
}); });

View File

@@ -8,7 +8,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
const jobName = job == null ? jobKey : job.name; const jobName = job == null ? jobKey : job.name;
const promises = newListings.map((newListing) => { const promises = newListings.map((newListing) => {
const title = `${jobName} at ${serviceName}: ${newListing.title}`; const title = `${jobName} at ${serviceName}: ${newListing.title}`;
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nink: ${newListing.link}`; const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}`;
return fetch(server, { return fetch(server, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },

View File

@@ -0,0 +1,130 @@
import fetch from 'node-fetch';
import { getJob } from '../../services/storage/jobStorage.js';
import { markdown2Html } from '../../services/markdown.js';
import { normalizeImageUrl } from '../../utils.js';
/**
* Generates an idempotent decimal color code. The input string-based color code is
* generated using the djb2 hash algorithm.
*
* @param {string} str - Input string as color code base
* @returns {number} Generated decimal color code (0 - 16777215)
*/
const generateColorFromString = (str) => {
let hash = 5381; // initial value
const input = String(str);
for (let i = 0; i < input.length; i++) {
// hash * 33 + charCode
hash = (hash << 5) + hash + input.charCodeAt(i);
// Ensure the hash is 32 bit
hash |= 0;
}
let positiveHash = hash >>> 0;
const maxColorValue = 16777215;
const colorDecimal = positiveHash % maxColorValue;
return colorDecimal;
};
/**
* Creates an embed per listing
* (-> see https://birdie0.github.io/discord-webhooks-guide/structure/embeds.html).
*
* @param {string} jobKey - Key of job (used to set embed color)
* @param {object} listing - Object holding listing details
* @returns {object} Discord webhook embed
*/
const buildEmbed = (jobKey, listing) => {
const maxTitleLength = 252; // Max embed title length is 256 characters
let title = String(listing.title ?? 'N/A');
if (title.length > maxTitleLength) {
title = title.substring(0, maxTitleLength) + '...';
}
const fields = [
{
name: 'Price',
value: String(listing.price ?? 'n/a'),
inline: true,
},
{
name: 'Size',
value: listing?.size?.replace(/2m/g, 'm²') ?? 'n/a',
inline: true,
},
{
name: 'Address',
value: String(listing.address ?? 'n/a'),
inline: true,
},
];
const embed = {
title: title,
color: generateColorFromString(jobKey),
url: listing.link,
fields: fields,
};
if (listing.image) {
embed.image = {
url: normalizeImageUrl(listing.image),
};
}
return embed;
};
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
const adapter = notificationConfig.find((adapter) => adapter.id === config.id);
const webhookUrl = adapter?.fields?.webhookUrl;
if (!webhookUrl || newListings.length === 0) return Promise.resolve([]);
const job = getJob(jobKey);
const jobName = job?.name || jobKey;
const embeds = newListings.map((listing) => buildEmbed(jobKey, listing));
const maxEmbedsPerMessage = 10; // Discord only allows up to 10 embeds
const webhookPromises = [];
for (let i = 0; i < embeds.length; i += maxEmbedsPerMessage) {
// Send multiple Discord messages with up to 10 embeds per message
const embedChunk = embeds.slice(i, i + maxEmbedsPerMessage);
const content = i === 0 ? `*${jobName}:* ${serviceName} found **${newListings.length}** new listings.` : '';
const body = JSON.stringify({
content: content,
embeds: embedChunk,
});
const fetchPromise = fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
}).catch((error) => {
console.error(`Error sending Discord webhook for chunk starting at ${i}:`, error);
return Promise.reject(new Error(`Webhook failed: ${error.message}`));
});
webhookPromises.push(fetchPromise);
}
return Promise.allSettled(webhookPromises);
};
export const config = {
id: 'discord_webhook',
name: 'Discord Webhook',
readme: markdown2Html('lib/notification/adapter/discord_webhook.md'),
description: 'Fredy will send new listings to the Discord channel of your choice.',
fields: {
webhookUrl: {
type: 'text',
label: 'Webhook URL',
description: 'The URL of the Discord webhook to send messages to.',
},
},
};

View File

@@ -0,0 +1,4 @@
### Discord Adapter
To use the [Discord](https://discord.com/) Adapter, you need to create a webhook on the Discord channel of your choice. You can follow the instructions of _Making A Webhook_ on [this support website](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks).
Once you have created a webhook, copy and paste the webhook URL.

View File

@@ -13,10 +13,10 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
return fetch(webhook, { return fetch(webhook, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: { body: JSON.stringify({
channel: channel, channel: channel,
text: message, text: message,
}, }),
}); });
}; };
export const config = { export const config = {

View File

@@ -15,11 +15,17 @@ Size: ${newListing.size == null ? 'N/A' : newListing.size.replace(/2m/g, '$m^2$'
Price: ${newListing.price} Price: ${newListing.price}
Link: ${newListing.link}`; Link: ${newListing.link}`;
const sanitizeHeaderValue = (value) =>
String(value ?? '')
.replace(/[\r\n]+/g, ' ')
.replace(/[^\x20-\x7E]/g, ' ')
.trim();
const headers = { const headers = {
Title: newListing.title, Title: sanitizeHeaderValue(newListing.title),
Priority: String(priority), Priority: sanitizeHeaderValue(priority),
Tags: `${serviceName},${jobName}`, Tags: sanitizeHeaderValue(`${serviceName},${jobName}`),
Click: newListing.link, Click: sanitizeHeaderValue(newListing.link),
}; };
if (newListing.image && typeof newListing.image === 'string') { if (newListing.image && typeof newListing.image === 'string') {

View File

@@ -29,6 +29,7 @@ const config = {
address: 'div[data-testid="cardmfe-description-box-address"] | trim', address: 'div[data-testid="cardmfe-description-box-address"] | trim',
image: 'div[data-testid="cardmfe-picture-box-test-id"] img@src', image: 'div[data-testid="cardmfe-picture-box-test-id"] img@src',
link: 'button@data-base', link: 'button@data-base',
description: 'div[data-testid="cardmfe-description-text-test-id"] | trim',
}, },
normalize: normalize, normalize: normalize,
filter: applyBlacklist, filter: applyBlacklist,

View File

@@ -26,8 +26,9 @@ const config = {
size: 'div[data-testid="cardmfe-keyfacts-testid"] | removeNewline | trim', size: 'div[data-testid="cardmfe-keyfacts-testid"] | removeNewline | trim',
title: 'div[data-testid="cardmfe-description-box-text-test-id"] > div:nth-of-type(2)', title: 'div[data-testid="cardmfe-description-box-text-test-id"] > div:nth-of-type(2)',
link: 'a@href', link: 'a@href',
description: 'div[data-testid="cardmfe-description-text-test-id"] > div:nth-of-type(2) | removeNewline | trim',
address: 'div[data-testid="cardmfe-description-box-address"] | removeNewline | trim', address: 'div[data-testid="cardmfe-description-box-address"] | removeNewline | trim',
image: 'div[data-testid="cardMfe-card-pictureBox-opacity"] img@src', image: 'div[data-testid="cardmfe-picture-box-opacity-layer-test-id"] img@src',
}, },
normalize: normalize, normalize: normalize,
filter: applyBlacklist, filter: applyBlacklist,

47
lib/provider/mcMakler.js Executable file
View File

@@ -0,0 +1,47 @@
import { isOneOf, buildHash } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
let appliedBlackList = [];
function normalize(o) {
const originalId = o.id.split('/').pop();
const id = buildHash(originalId, o.price);
const size = o.size ?? 'N/A m²';
const title = o.title || 'No title available';
const address = o.address?.replace(' / ', ' ') || null;
const link = o.link != null ? `https://www.mcmakler.de${o.link}` : config.url;
return Object.assign(o, { id, size, title, link, address });
}
function applyBlacklist(o) {
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
return titleNotBlacklisted && descNotBlacklisted;
}
const config = {
url: null,
crawlContainer: 'article[data-testid="propertyCard"]',
sortByDateParam: 'sortBy=DATE&sortOn=DESC',
waitForSelector: 'ul[data-testid="listsContainer"]',
crawlFields: {
id: 'h2 a@href',
title: 'h2 a | removeNewline | trim',
price: 'footer > p:first-of-type | trim',
size: 'footer > p:nth-of-type(2) | trim',
address: 'div > h2 + p | removeNewline | trim',
image: 'img@src',
link: 'h2 a@href',
},
normalize: normalize,
filter: applyBlacklist,
activeTester: checkIfListingIsActive,
};
export const init = (sourceConfig, blacklist) => {
config.enabled = sourceConfig.enabled;
config.url = sourceConfig.url;
appliedBlackList = blacklist || [];
};
export const metaInformation = {
name: 'McMakler',
baseUrl: 'https://www.mcmakler.de/immobilien/',
id: 'mcMakler',
};
export { config };

View File

@@ -0,0 +1,49 @@
import { isOneOf, buildHash } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
let appliedBlackList = [];
function normalize(o) {
const id = buildHash(o.id, o.price);
const address = o.address?.replace(/^adresse /i, '') ?? null;
const title = o.title || 'No title available';
const link = o.link != null ? decodeURIComponent(o.link) : config.url;
var urlReg = new RegExp(/url\((.*?)\)/gim);
const image = o.image != null ? urlReg.exec(o.image)[1] : null;
return Object.assign(o, { id, address, title, link, image });
}
function applyBlacklist(o) {
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
return titleNotBlacklisted && descNotBlacklisted;
}
const config = {
url: null,
crawlContainer: '.listentry-content',
sortByDateParam: null, // sort by date is standard
waitForSelector: 'body',
crawlFields: {
id: '.listentry-iconbar-share@data-sid | trim',
title: 'h2 | trim',
price: '.listentry-details-price .listentry-details-v | trim',
size: '.listentry-details-size .listentry-details-v | trim',
address: '.listentry-adress | trim',
image: '.listentry-img@style',
link: '.shariff@data-url',
description: '.listentry-extras | trim',
},
normalize: normalize,
filter: applyBlacklist,
activeTester: checkIfListingIsActive,
};
export const init = (sourceConfig, blacklist) => {
config.enabled = sourceConfig.enabled;
config.url = sourceConfig.url;
appliedBlackList = blacklist || [];
};
export const metaInformation = {
name: 'Regionalimmobilien24',
baseUrl: 'https://www.regionalimmobilien24.de/',
id: 'regionalimmobilien24',
};
export { config };

46
lib/provider/sparkasse.js Executable file
View File

@@ -0,0 +1,46 @@
import { isOneOf, buildHash } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
let appliedBlackList = [];
function normalize(o) {
const originalId = o.id.split('/').pop().replace('.html', '');
const id = buildHash(originalId, o.price);
const size = o.size?.replace(' Wohnfläche', '') ?? null;
const title = o.title || 'No title available';
const link = o.link != null ? `https://immobilien.sparkasse.de${o.link}` : config.url;
return Object.assign(o, { id, size, title, link });
}
function applyBlacklist(o) {
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
return titleNotBlacklisted && descNotBlacklisted;
}
const config = {
url: null,
crawlContainer: '.estate-list-item-row',
sortByDateParam: 'sortBy=date_desc',
waitForSelector: 'body',
crawlFields: {
id: 'div[data-testid="estate-link"] a@href',
title: 'h3 | trim',
price: '.estate-list-price | trim',
size: '.estate-mainfact:first-child span | trim',
address: 'h6 | trim',
image: '.estate-list-item-image-container img@src',
link: 'div[data-testid="estate-link"] a@href',
},
normalize: normalize,
filter: applyBlacklist,
activeTester: checkIfListingIsActive,
};
export const init = (sourceConfig, blacklist) => {
config.enabled = sourceConfig.enabled;
config.url = sourceConfig.url;
appliedBlackList = blacklist || [];
};
export const metaInformation = {
name: 'Sparkasse Immobilien',
baseUrl: 'https://immobilien.sparkasse.de/',
id: 'sparkasse',
};
export { config };

View File

@@ -251,3 +251,26 @@ export const queryListings = ({
return { totalNumber, page: safePage, result: rows }; return { totalNumber, page: safePage, result: rows };
}; };
/**
* Delete all listings for a given job id.
*
* @param {string} jobId - The job identifier whose listings should be removed.
* @returns {any} The result from SqliteConnection.execute (may contain changes count).
*/
export const deleteListingsByJobId = (jobId) => {
if (!jobId) return;
return SqliteConnection.execute(`DELETE FROM listings WHERE job_id = @jobId`, { jobId });
};
/**
* Delete listings by a list of listing IDs.
*
* @param {string[]} ids - Array of listing IDs to delete.
* @returns {any} The result from SqliteConnection.execute.
*/
export const deleteListingsById = (ids) => {
if (!Array.isArray(ids) || ids.length === 0) return;
const placeholders = ids.map(() => '?').join(',');
return SqliteConnection.execute(`DELETE FROM listings WHERE id IN (${placeholders})`, ids);
};

View File

@@ -1,6 +1,6 @@
{ {
"name": "fredy", "name": "fredy",
"version": "12.2.1", "version": "14.0.1",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].", "description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": { "scripts": {
"prepare": "husky", "prepare": "husky",
@@ -62,7 +62,7 @@
"@visactor/react-vchart": "^2.0.5", "@visactor/react-vchart": "^2.0.5",
"@visactor/vchart": "^2.0.5", "@visactor/vchart": "^2.0.5",
"@visactor/vchart-semi-theme": "^1.12.2", "@visactor/vchart-semi-theme": "^1.12.2",
"@vitejs/plugin-react": "5.0.3", "@vitejs/plugin-react": "5.0.4",
"better-sqlite3": "^12.4.1", "better-sqlite3": "^12.4.1",
"body-parser": "2.2.0", "body-parser": "2.2.0",
"cheerio": "^1.1.2", "cheerio": "^1.1.2",
@@ -76,19 +76,19 @@
"node-mailjet": "6.0.9", "node-mailjet": "6.0.9",
"p-throttle": "^8.0.0", "p-throttle": "^8.0.0",
"package-up": "^5.0.0", "package-up": "^5.0.0",
"puppeteer": "^24.22.3", "puppeteer": "^24.23.0",
"puppeteer-extra": "^3.3.6", "puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2", "puppeteer-extra-plugin-stealth": "^2.11.2",
"query-string": "9.3.1", "query-string": "9.3.1",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-router": "7.9.2", "react-router": "7.9.3",
"react-router-dom": "7.9.2", "react-router-dom": "7.9.3",
"restana": "5.1.0", "restana": "5.1.0",
"semver": "^7.7.2", "semver": "^7.7.2",
"serve-static": "2.2.0", "serve-static": "2.2.0",
"slack": "11.0.2", "slack": "11.0.2",
"vite": "7.1.7", "vite": "7.1.9",
"x-var": "^3.0.1", "x-var": "^3.0.1",
"zustand": "^5.0.8" "zustand": "^5.0.8"
}, },
@@ -97,7 +97,7 @@
"@babel/eslint-parser": "7.28.4", "@babel/eslint-parser": "7.28.4",
"@babel/preset-env": "7.28.3", "@babel/preset-env": "7.28.3",
"@babel/preset-react": "7.27.1", "@babel/preset-react": "7.27.1",
"chai": "6.0.1", "chai": "6.2.0",
"eslint": "9.36.0", "eslint": "9.36.0",
"eslint-config-prettier": "10.1.8", "eslint-config-prettier": "10.1.8",
"eslint-plugin-react": "7.37.5", "eslint-plugin-react": "7.37.5",
@@ -105,8 +105,8 @@
"history": "5.3.0", "history": "5.3.0",
"husky": "9.1.7", "husky": "9.1.7",
"less": "4.4.1", "less": "4.4.1",
"lint-staged": "16.2.1", "lint-staged": "16.2.3",
"mocha": "11.7.2", "mocha": "11.7.4",
"nodemon": "^3.1.10", "nodemon": "^3.1.10",
"prettier": "3.6.2" "prettier": "3.6.2"
} }

View File

@@ -8,31 +8,30 @@ describe('#immonet testsuite()', () => {
after(() => { after(() => {
similarityCache.stopCacheCleanup(); similarityCache.stopCacheCleanup();
}); });
provider.init(providerConfig.immonet, [], []);
it('should test immonet provider', async () => { it('should test immonet provider', async () => {
const Fredy = await mockFredy(); const Fredy = await mockFredy();
return await new Promise((resolve) => { provider.init(providerConfig.immonet, [], []);
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immonet', similarityCache);
fredy.execute().then((listing) => {
expect(listing).to.be.a('array');
const notificationObj = get();
expect(notificationObj).to.be.a('object');
expect(notificationObj.serviceName).to.equal('immonet');
notificationObj.payload.forEach((notify) => {
/** check the actual structure **/
expect(notify.id).to.be.a('string');
expect(notify.price).to.be.a('string');
expect(notify.size).to.be.a('string');
expect(notify.title).to.be.a('string');
expect(notify.link).to.be.a('string');
expect(notify.address).to.be.a('string');
expect(notify.size).that.does.include('m²'); const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immonet', similarityCache);
expect(notify.title).to.be.not.empty; const listing = await fredy.execute();
expect(notify.address).to.be.not.empty;
}); expect(listing).to.be.a('array');
resolve(); const notificationObj = get();
}); expect(notificationObj).to.be.a('object');
expect(notificationObj.serviceName).to.equal('immonet');
notificationObj.payload.forEach((notify) => {
/** check the actual structure **/
expect(notify.id).to.be.a('string');
expect(notify.price).to.be.a('string');
expect(notify.size).to.be.a('string');
expect(notify.title).to.be.a('string');
expect(notify.link).to.be.a('string');
expect(notify.address).to.be.a('string');
/** check the values if possible **/
expect(notify.size).that.does.include('m²');
expect(notify.title).to.be.not.empty;
expect(notify.address).to.be.not.empty;
}); });
}); });
}); });

View File

@@ -8,33 +8,32 @@ describe('#immowelt testsuite()', () => {
after(() => { after(() => {
similarityCache.stopCacheCleanup(); similarityCache.stopCacheCleanup();
}); });
it('should test immowelt provider', async () => { it('should test immowelt provider', async () => {
const Fredy = await mockFredy(); const Fredy = await mockFredy();
provider.init(providerConfig.immowelt, [], []); provider.init(providerConfig.immowelt, [], []);
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immowelt', similarityCache); const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immowelt', similarityCache);
fredy.execute().then((listing) => { const listing = await fredy.execute();
expect(listing).to.be.a('array');
const notificationObj = get(); expect(listing).to.be.a('array');
expect(notificationObj).to.be.a('object'); const notificationObj = get();
expect(notificationObj.serviceName).to.equal('immowelt'); expect(notificationObj).to.be.a('object');
notificationObj.payload.forEach((notify) => { expect(notificationObj.serviceName).to.equal('immowelt');
/** check the actual structure **/ notificationObj.payload.forEach((notify) => {
expect(notify.id).to.be.a('string'); /** check the actual structure **/
expect(notify.price).to.be.a('string'); expect(notify.id).to.be.a('string');
expect(notify.title).to.be.a('string'); expect(notify.price).to.be.a('string');
expect(notify.link).to.be.a('string'); expect(notify.title).to.be.a('string');
expect(notify.address).to.be.a('string'); expect(notify.link).to.be.a('string');
/** check the values if possible **/ expect(notify.address).to.be.a('string');
if (notify.size != null && notify.size.trim().toLowerCase() !== 'k.a.') { /** check the values if possible **/
expect(notify.size).that.does.include('m²'); if (notify.size != null && notify.size.trim().toLowerCase() !== 'k.a.') {
} expect(notify.size).that.does.include('m²');
expect(notify.title).to.be.not.empty; }
expect(notify.link).that.does.include('https://www.immowelt.de'); expect(notify.title).to.be.not.empty;
expect(notify.address).to.be.not.empty; expect(notify.link).that.does.include('https://www.immowelt.de');
}); expect(notify.address).to.be.not.empty;
resolve();
});
}); });
}); });
}); });

View File

@@ -0,0 +1,37 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js';
import { mockFredy, providerConfig } from '../utils.js';
import { expect } from 'chai';
import * as provider from '../../lib/provider/mcMakler.js';
describe('#mcMakler testsuite()', () => {
after(() => {
similarityCache.stopCacheCleanup();
});
it('should test mcMakler provider', async () => {
const Fredy = await mockFredy();
provider.init(providerConfig.mcMakler, []);
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'mcMakler', similarityCache);
const listing = await fredy.execute();
expect(listing).to.be.a('array');
const notificationObj = get();
expect(notificationObj).to.be.a('object');
expect(notificationObj.serviceName).to.equal('mcMakler');
notificationObj.payload.forEach((notify) => {
/** check the actual structure **/
expect(notify.id).to.be.a('string');
expect(notify.price).to.be.a('string');
expect(notify.size).to.be.a('string');
expect(notify.title).to.be.a('string');
expect(notify.link).to.be.a('string');
expect(notify.address).to.be.a('string');
/** check the values if possible **/
expect(notify.size).that.does.include('m²');
expect(notify.title).to.be.not.empty;
expect(notify.address).to.be.not.empty;
});
});
});

View File

@@ -0,0 +1,43 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js';
import { mockFredy, providerConfig } from '../utils.js';
import { expect } from 'chai';
import * as provider from '../../lib/provider/regionalimmobilien24.js';
describe('#regionalimmobilien24 testsuite()', () => {
after(() => {
similarityCache.stopCacheCleanup();
});
it('should test regionalimmobilien24 provider', async () => {
const Fredy = await mockFredy();
provider.init(providerConfig.regionalimmobilien24, []);
const fredy = new Fredy(
provider.config,
null,
provider.metaInformation.id,
'regionalimmobilien24',
similarityCache,
);
const listing = await fredy.execute();
expect(listing).to.be.a('array');
const notificationObj = get();
expect(notificationObj).to.be.a('object');
expect(notificationObj.serviceName).to.equal('regionalimmobilien24');
notificationObj.payload.forEach((notify) => {
/** check the actual structure **/
expect(notify.id).to.be.a('string');
expect(notify.price).to.be.a('string');
expect(notify.size).to.be.a('string');
expect(notify.title).to.be.a('string');
expect(notify.link).to.be.a('string');
expect(notify.address).to.be.a('string');
/** check the values if possible **/
expect(notify.size).that.does.include('m²');
expect(notify.title).to.be.not.empty;
expect(notify.address).to.be.not.empty;
});
});
});

View File

@@ -0,0 +1,37 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js';
import { mockFredy, providerConfig } from '../utils.js';
import { expect } from 'chai';
import * as provider from '../../lib/provider/sparkasse.js';
describe('#sparkasse testsuite()', () => {
after(() => {
similarityCache.stopCacheCleanup();
});
it('should test sparkasse provider', async () => {
const Fredy = await mockFredy();
provider.init(providerConfig.sparkasse, []);
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'sparkasse', similarityCache);
const listing = await fredy.execute();
expect(listing).to.be.a('array');
const notificationObj = get();
expect(notificationObj).to.be.a('object');
expect(notificationObj.serviceName).to.equal('sparkasse');
notificationObj.payload.forEach((notify) => {
/** check the actual structure **/
expect(notify.id).to.be.a('string');
expect(notify.price).to.be.a('string');
expect(notify.size).to.be.a('string');
expect(notify.title).to.be.a('string');
expect(notify.link).to.be.a('string');
expect(notify.address).to.be.a('string');
/** check the values if possible **/
expect(notify.size).that.does.include('m²');
expect(notify.title).to.be.not.empty;
expect(notify.address).to.be.not.empty;
});
});
});

View File

@@ -28,10 +28,22 @@
"url": "https://www.kleinanzeigen.de/s-immobilien/duesseldorf/anzeige:angebote/wohnung/k0c195l2068r5", "url": "https://www.kleinanzeigen.de/s-immobilien/duesseldorf/anzeige:angebote/wohnung/k0c195l2068r5",
"enabled": true "enabled": true
}, },
"mcMakler": {
"url": "https://www.mcmakler.de/immobilien/results?placeId=62649&search=Leipzig%252C+Sachsen&propertyTypes=APARTMENT&page=0",
"enabled": true
},
"neubauKompass": { "neubauKompass": {
"url": "https://www.neubaukompass.de/neubau-immobilien/duesseldorf-region/eigentumswohnung/", "url": "https://www.neubaukompass.de/neubau-immobilien/duesseldorf-region/eigentumswohnung/",
"enabled": true "enabled": true
}, },
"regionalimmobilien24": {
"url": "https://www.regionalimmobilien24.de/rostock/rostock/kaufen/haus/-/-/-/?rd=5",
"enabled": true
},
"sparkasse": {
"url": "https://immobilien.sparkasse.de/immobilien/treffer?marketingType=buy&objectType=flat&perimeter=10&usageType=residential&zipCityEstateId=62782__Hamburg",
"enabled": true
},
"wgGesucht": { "wgGesucht": {
"url": "https://www.wg-gesucht.de/wg-zimmer-in-Duesseldorf.30.0.1.0.html", "url": "https://www.wg-gesucht.de/wg-zimmer-in-Duesseldorf.30.0.1.0.html",
"enabled": true "enabled": true

View File

@@ -8,18 +8,19 @@ import UserMutator from './views/user/mutation/UserMutator';
import JobInsight from './views/jobs/insights/JobInsight.jsx'; import JobInsight from './views/jobs/insights/JobInsight.jsx';
import { useActions, useSelector } from './services/state/store'; import { useActions, useSelector } from './services/state/store';
import { Routes, Route, Navigate } from 'react-router-dom'; import { Routes, Route, Navigate } from 'react-router-dom';
import Logout from './components/logout/Logout';
import Logo from './components/logo/Logo';
import Menu from './components/menu/Menu';
import Login from './views/login/Login'; import Login from './views/login/Login';
import Users from './views/user/Users'; import Users from './views/user/Users';
import Jobs from './views/jobs/Jobs'; import Jobs from './views/jobs/Jobs';
import './App.less'; import './App.less';
import TrackingModal from './components/tracking/TrackingModal.jsx'; import TrackingModal from './components/tracking/TrackingModal.jsx';
import { Banner } from '@douyinfe/semi-ui'; import { Banner, Divider } from '@douyinfe/semi-ui';
import VersionBanner from './components/version/VersionBanner.jsx'; import VersionBanner from './components/version/VersionBanner.jsx';
import Listings from './views/listings/Listings.jsx'; import Listings from './views/listings/Listings.jsx';
import Navigation from './components/navigation/Navigation.jsx';
import { Layout } from '@douyinfe/semi-ui';
import FredyFooter from './components/footer/FredyFooter.jsx';
import ProcessingTimes from './views/jobs/ProcessingTimes.jsx';
export default function FredyApp() { export default function FredyApp() {
const actions = useActions(); const actions = useActions();
@@ -27,6 +28,7 @@ export default function FredyApp() {
const currentUser = useSelector((state) => state.user.currentUser); const currentUser = useSelector((state) => state.user.currentUser);
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate); const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
const settings = useSelector((state) => state.generalSettings.settings); const settings = useSelector((state) => state.generalSettings.settings);
const processingTimes = useSelector((state) => state.jobs.processingTimes);
useEffect(() => { useEffect(() => {
async function init() { async function init() {
@@ -50,6 +52,7 @@ export default function FredyApp() {
}; };
const isAdmin = () => currentUser != null && currentUser.isAdmin; const isAdmin = () => currentUser != null && currentUser.isAdmin;
const { Footer, Sider, Content } = Layout;
return loading ? null : needsLogin() ? ( return loading ? null : needsLogin() ? (
<Routes> <Routes>
@@ -57,71 +60,80 @@ export default function FredyApp() {
<Route path="*" element={<Navigate to="/login" replace />} /> <Route path="*" element={<Navigate to="/login" replace />} />
</Routes> </Routes>
) : ( ) : (
<div className="app"> <Layout className="app">
<div className="app__container"> <Layout className="app">
<Logout /> <Sider>
<Logo width={190} white /> <Navigation isAdmin={isAdmin()} />
<Menu isAdmin={isAdmin()} /> </Sider>
{versionUpdate?.newVersion && <VersionBanner />} <Content>
{settings.demoMode && ( {versionUpdate?.newVersion && <VersionBanner />}
<> {settings.demoMode && (
<Banner <>
fullMode={true} <Banner
type="info" fullMode={true}
bordered type="info"
closeIcon={null} bordered
description="You're currently viewing the demo version of Fredy. Jobs won't scrape websites, and any changes you make will be reverted at midnight." closeIcon={null}
/> description="You're currently viewing the demo version of Fredy. Jobs won't scrape websites, and any changes you make will be reverted at midnight."
<br /> />
</> <br />
)} </>
{settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />} )}
<Routes> {settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />}
<Route path="/403" element={<InsufficientPermission />} /> {processingTimes != null && <ProcessingTimes processingTimes={processingTimes} />}
<Route path="/jobs/new" element={<JobMutation />} /> <Divider />
<Route path="/jobs/edit/:jobId" element={<JobMutation />} /> <div className="app__content">
<Route path="/jobs/insights/:jobId" element={<JobInsight />} /> <Routes>
<Route path="/jobs" element={<Jobs />} /> <Route path="/403" element={<InsufficientPermission />} />
<Route path="/listings" element={<Listings />} /> <Route path="/jobs/new" element={<JobMutation />} />
<Route path="/jobs/edit/:jobId" element={<JobMutation />} />
<Route path="/jobs/insights/:jobId" element={<JobInsight />} />
<Route path="/jobs" element={<Jobs />} />
<Route path="/listings" element={<Listings />} />
{/* Permission-aware routes */} {/* Permission-aware routes */}
<Route <Route
path="/users/new" path="/users/new"
element={ element={
<PermissionAwareRoute currentUser={currentUser}> <PermissionAwareRoute currentUser={currentUser}>
<UserMutator /> <UserMutator />
</PermissionAwareRoute> </PermissionAwareRoute>
} }
/> />
<Route <Route
path="/users/edit/:userId" path="/users/edit/:userId"
element={ element={
<PermissionAwareRoute currentUser={currentUser}> <PermissionAwareRoute currentUser={currentUser}>
<UserMutator /> <UserMutator />
</PermissionAwareRoute> </PermissionAwareRoute>
} }
/> />
<Route <Route
path="/users" path="/users"
element={ element={
<PermissionAwareRoute currentUser={currentUser}> <PermissionAwareRoute currentUser={currentUser}>
<Users /> <Users />
</PermissionAwareRoute> </PermissionAwareRoute>
} }
/> />
<Route <Route
path="/generalSettings" path="/generalSettings"
element={ element={
<PermissionAwareRoute currentUser={currentUser}> <PermissionAwareRoute currentUser={currentUser}>
<GeneralSettings /> <GeneralSettings />
</PermissionAwareRoute> </PermissionAwareRoute>
} }
/> />
<Route path="/" element={<Navigate to="/jobs" replace />} /> <Route path="/" element={<Navigate to="/jobs" replace />} />
</Routes> </Routes>
</div> </div>
</div> </Content>
</Layout>
<Footer>
<FredyFooter />
</Footer>
</Layout>
); );
} }

View File

@@ -1,12 +1,9 @@
.app { .app {
display: flex; height: 100%;
flex-direction: column;
width: 100%; width: 100%;
&__container { &__content {
padding: 1rem 1rem; margin: 1rem;
color: var(--semi-color-text-0);
background-color: #232429;
} }
} }

View File

@@ -0,0 +1,19 @@
import React from 'react';
import './FredyFooter.less';
import { useSelector } from '../../services/state/store.js';
import { Typography } from '@douyinfe/semi-ui';
export default function FredyFooter() {
const { Text } = Typography;
const version = useSelector((state) => state.versionUpdate.versionUpdate);
return (
<div className="fredyFooter">
<div className="fredyFooter__version">
<Text type="tertiary">Fredy V{version?.localFredyVersion || 'N/A'}</Text>
</div>
<div className="fredyFooter__copyRight">
<Text link={{ href: 'https://github.com/orangecoding', target: '_blank' }}>Made with </Text>
</div>
</div>
);
}

View File

@@ -0,0 +1,18 @@
.fredyFooter {
background:rgb(53, 54, 60);
color: white;
display: flex;
justify-content: space-between;
align-items: center;
height: 1.7rem;
&__version {
padding-left: .5rem;
font-size: small;
}
&__copyRight {
padding-right: 1rem;
}
}

View File

@@ -2,19 +2,22 @@ import React from 'react';
import { Button } from '@douyinfe/semi-ui'; import { Button } from '@douyinfe/semi-ui';
import { xhrPost } from '../../services/xhr'; import { xhrPost } from '../../services/xhr';
import { IconUser } from '@douyinfe/semi-icons'; import { IconUser } from '@douyinfe/semi-icons';
const Logout = function Logout() {
const Logout = function Logout({ text }) {
return ( return (
<Button <div>
icon={<IconUser />} <Button
type="danger" icon={<IconUser />}
theme="solid" type="danger"
onClick={async () => { theme="solid"
await xhrPost('/api/login/logout'); onClick={async () => {
location.reload(); await xhrPost('/api/login/logout');
}} location.reload();
> }}
Logout >
</Button> {text && 'Logout'}
</Button>
</div>
); );
}; };

View File

@@ -1,65 +0,0 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Tabs, TabPane } from '@douyinfe/semi-ui';
import { useLocation } from 'react-router-dom';
import { IconUser, IconTerminal, IconSetting, IconArchive } from '@douyinfe/semi-icons';
import './Menu.less';
function parsePathName(name) {
const split = name.split('/').filter((s) => s.length !== 0);
return '/' + split[0];
}
const TopMenu = function TopMenu({ isAdmin }) {
const navigate = useNavigate();
const location = useLocation();
return (
<Tabs className="menu" type="line" activeKey={parsePathName(location.pathname)} onTabClick={(key) => navigate(key)}>
<TabPane
itemKey="/jobs"
tab={
<span>
<IconTerminal />
Jobs
</span>
}
/>
<TabPane
itemKey="/listings"
tab={
<span>
<IconArchive />
Found listings
</span>
}
/>
{isAdmin && (
<TabPane
itemKey="/users"
tab={
<span>
<IconUser />
User
</span>
}
/>
)}
{isAdmin && (
<TabPane
itemKey="/generalSettings"
tab={
<span>
<IconSetting />
Settings
</span>
}
/>
)}
</Tabs>
);
};
export default TopMenu;

View File

@@ -1,3 +0,0 @@
.menu {
margin-top: 3rem;
}

View File

@@ -0,0 +1,9 @@
.navigate {
&__logout_Button {
align-items: center;
justify-content: center;
width: 100%;
display: flex;
}
}

View File

@@ -0,0 +1,50 @@
import React from 'react';
import { Nav } from '@douyinfe/semi-ui';
import { IconUser, IconStar, IconSetting, IconTerminal } from '@douyinfe/semi-icons';
import logoWhite from '../../assets/logo_white.png';
import Logout from '../logout/Logout.jsx';
import { useLocation, useNavigate } from 'react-router-dom';
import './Navigate.less';
import { useScreenWidth } from '../../hooks/screenWidth.js';
export default function Navigation({ isAdmin }) {
const navigate = useNavigate();
const location = useLocation();
const width = useScreenWidth();
const collapsed = width <= 850;
const items = [
{ itemKey: '/jobs', text: 'Jobs', icon: <IconTerminal /> },
{ itemKey: '/listings', text: 'Found Listings', icon: <IconStar /> },
];
if (isAdmin) {
items.push({ itemKey: '/users', text: 'User Management', icon: <IconUser /> });
items.push({ itemKey: '/generalSettings', text: 'Settings', icon: <IconSetting /> });
}
function parsePathName(name) {
const split = name.split('/').filter((s) => s.length !== 0);
return '/' + split[0];
}
return (
<Nav
style={{ height: '100%', width: collapsed ? '' : '13rem' }}
items={items}
isCollapsed={collapsed}
selectedKeys={[parsePathName(location.pathname)]}
onSelect={(key) => {
navigate(key.itemKey);
}}
header={<img src={logoWhite} width="180" alt="Fredy Logo" />}
footer={
<div className="navigate__logout_Button">
<Logout text={!collapsed} />
</div>
}
/>
);
}

View File

@@ -8,6 +8,7 @@ export const SegmentPart = ({ name, Icon = null, children, helpText }) => {
return ( return (
<Card <Card
className="segmentParts"
title={ title={
<Meta title={name} description={helpText} avatar={Icon == null ? null : <Icon size="extra-extra-small" />} /> <Meta title={name} description={helpText} avatar={Icon == null ? null : <Icon size="extra-extra-small" />} />
} }

View File

@@ -1,4 +1,7 @@
.segmentParts { .segmentParts {
border: 1px solid #323232 !important; border: 1px solid #323232 !important;
border-radius: 5px !important; border-radius: 5px !important;
color: rgba(var(--semi-grey-8), 1);
background: rgb(53, 54, 60);
margin: 2rem;
} }

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { Button, Empty, Table, Switch } from '@douyinfe/semi-ui'; import { Button, Empty, Table, Switch, Popover } from '@douyinfe/semi-ui';
import { IconDelete, IconEdit, IconHistogram } from '@douyinfe/semi-icons'; import { IconDelete, IconDescend2, IconEdit, IconHistogram } from '@douyinfe/semi-icons';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations'; import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
import './JobTable.less'; import './JobTable.less';
@@ -10,11 +10,20 @@ const empty = (
<Empty <Empty
image={<IllustrationNoResult />} image={<IllustrationNoResult />}
darkModeImage={<IllustrationNoResultDark />} darkModeImage={<IllustrationNoResultDark />}
description={'No jobs available.'} description="No jobs available. Why don't you create one? ;)"
/> />
); );
export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged, onJobEdit, onJobInsight } = {}) { const getPopoverContent = (text) => <article className="jobPopoverContent">{text}</article>;
export default function JobTable({
jobs = {},
onJobRemoval,
onJobStatusChanged,
onJobEdit,
onJobInsight,
onListingRemoval,
} = {}) {
return ( return (
<Table <Table
pagination={false} pagination={false}
@@ -32,7 +41,7 @@ export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged,
dataIndex: 'name', dataIndex: 'name',
}, },
{ {
title: 'Findings', title: 'Listings',
dataIndex: 'numberOfFoundListings', dataIndex: 'numberOfFoundListings',
render: (value) => { render: (value) => {
return value || 0; return value || 0;
@@ -58,9 +67,18 @@ export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged,
render: (_, job) => { render: (_, job) => {
return ( return (
<div className="interactions"> <div className="interactions">
<Button type="primary" icon={<IconHistogram />} onClick={() => onJobInsight(job.id)} /> <Popover content={getPopoverContent('Job Insights')}>
<Button type="secondary" icon={<IconEdit />} onClick={() => onJobEdit(job.id)} /> <Button type="primary" icon={<IconHistogram />} onClick={() => onJobInsight(job.id)} />
<Button type="danger" icon={<IconDelete />} onClick={() => onJobRemoval(job.id)} /> </Popover>
<Popover content={getPopoverContent('Edit a Job')}>
<Button type="secondary" icon={<IconEdit />} onClick={() => onJobEdit(job.id)} />
</Popover>
<Popover content={getPopoverContent('Delete all found Listings of this Job')}>
<Button type="danger" icon={<IconDescend2 />} onClick={() => onListingRemoval(job.id)} />
</Popover>
<Popover content={getPopoverContent('Delete Job')}>
<Button type="danger" icon={<IconDelete />} onClick={() => onJobRemoval(job.id)} />
</Popover>
</div> </div>
); );
}, },

View File

@@ -5,6 +5,11 @@
gap: 1rem; gap: 1rem;
} }
.jobPopoverContent {
padding: 1rem;
color: white;
}
@media (min-width: 768px) { @media (min-width: 768px) {
.interactions { .interactions {
flex-direction: initial; flex-direction: initial;

View File

@@ -1,13 +1,15 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { Table, Popover, Input, Descriptions, Tag, Image } from '@douyinfe/semi-ui'; import { Table, Popover, Input, Descriptions, Tag, Image, Empty, Button, Card, Toast } from '@douyinfe/semi-ui';
import { useActions, useSelector } from '../../services/state/store.js'; import { useActions, useSelector } from '../../services/state/store.js';
import { IconClose, IconSearch, IconTick } from '@douyinfe/semi-icons'; import { IconClose, IconDelete, IconSearch, IconTick } from '@douyinfe/semi-icons';
import * as timeService from '../../services/time/timeService.js'; import * as timeService from '../../services/time/timeService.js';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import no_image from '../../assets/no_image.jpg'; import no_image from '../../assets/no_image.jpg';
import './ListingsTable.less'; import './ListingsTable.less';
import { format } from '../../services/time/timeService.js'; import { format } from '../../services/time/timeService.js';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
import { xhrDelete } from '../../services/xhr.js';
const columns = [ const columns = [
{ {
@@ -65,7 +67,7 @@ const columns = [
}, },
{ {
title: 'Price', title: 'Price',
width: 100, width: 110,
dataIndex: 'price', dataIndex: 'price',
sorter: true, sorter: true,
render: (text) => text + ' €', render: (text) => text + ' €',
@@ -80,6 +82,7 @@ const columns = [
title: 'Title', title: 'Title',
dataIndex: 'title', dataIndex: 'title',
sorter: true, sorter: true,
ellipsis: true,
render: (text, row) => { render: (text, row) => {
return ( return (
<a href={row.url} target="_blank" rel="noopener noreferrer"> <a href={row.url} target="_blank" rel="noopener noreferrer">
@@ -90,19 +93,28 @@ const columns = [
}, },
]; ];
const empty = (
<Empty
image={<IllustrationNoResult />}
darkModeImage={<IllustrationNoResultDark />}
description="No listings available."
/>
);
export default function ListingsTable() { export default function ListingsTable() {
const tableData = useSelector((state) => state.listingsTable); const tableData = useSelector((state) => state.listingsTable);
const actions = useActions(); const actions = useActions();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const pageSize = 15; const pageSize = 10;
const [sortData, setSortData] = useState({}); const [sortData, setSortData] = useState({});
const [filter, setFilter] = useState(null); const [filter, setFilter] = useState(null);
const [selectedKeys, setSelectedKeys] = useState([]);
const handlePageChange = (_page) => { const handlePageChange = (_page) => {
setPage(_page); setPage(_page);
}; };
useEffect(() => { const loadTable = () => {
let sortfield = null; let sortfield = null;
let sortdir = null; let sortdir = null;
@@ -111,10 +123,20 @@ export default function ListingsTable() {
sortdir = sortData.direction; sortdir = sortData.direction;
} }
actions.listingsTable.getListingsTable({ page, pageSize, sortfield, sortdir, filter }); actions.listingsTable.getListingsTable({ page, pageSize, sortfield, sortdir, filter });
};
useEffect(() => {
loadTable();
}, [page, sortData, filter]); }, [page, sortData, filter]);
const handleFilterChange = useMemo(() => debounce((value) => setFilter(value), 500), []); const handleFilterChange = useMemo(() => debounce((value) => setFilter(value), 500), []);
const rowSelection = {
onChange: (selectedRowKeys) => {
setSelectedKeys(selectedRowKeys);
},
};
const expandRowRender = (record) => { const expandRowRender = (record) => {
return ( return (
<div className="listingsTable__expanded"> <div className="listingsTable__expanded">
@@ -147,6 +169,18 @@ export default function ListingsTable() {
); );
}; };
const onRemoveSelectedListings = async () => {
if (selectedKeys != null && selectedKeys.length > 0) {
try {
await xhrDelete('/api/listings/', { ids: selectedKeys });
Toast.success('Listing(s) successfully removed');
loadTable();
} catch (error) {
Toast.error(error);
}
}
};
return ( return (
<div> <div>
<Input <Input
@@ -156,11 +190,20 @@ export default function ListingsTable() {
placeholder="Search" placeholder="Search"
onChange={handleFilterChange} onChange={handleFilterChange}
/> />
{selectedKeys != null && selectedKeys.length > 0 && (
<Card className="listingsTable__toolbar">
<Button type="danger" icon={<IconDelete />} onClick={() => onRemoveSelectedListings()}>
Remove selected Listings
</Button>
</Card>
)}
<Table <Table
rowKey="id" rowKey="id"
empty={empty}
hideExpandedColumn={false} hideExpandedColumn={false}
sticky={{ top: 5 }} sticky={{ top: 5 }}
columns={columns} columns={columns}
rowSelection={rowSelection}
expandedRowRender={expandRowRender} expandedRowRender={expandRowRender}
dataSource={tableData?.result || []} dataSource={tableData?.result || []}
onChange={(changeSet) => { onChange={(changeSet) => {

View File

@@ -7,4 +7,8 @@
display: flex; display: flex;
gap: 1rem; gap: 1rem;
} }
&__toolbar {
margin-bottom: 1rem;
}
} }

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Banner, Descriptions } from '@douyinfe/semi-ui'; import { Collapse, Descriptions } from '@douyinfe/semi-ui';
import { useSelector } from '../../services/state/store.js'; import { useSelector } from '../../services/state/store.js';
import { MarkdownRender } from '@douyinfe/semi-ui'; import { MarkdownRender } from '@douyinfe/semi-ui';
@@ -8,12 +8,9 @@ import './VersionBanner.less';
export default function VersionBanner() { export default function VersionBanner() {
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate); const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
return ( return (
<Banner <Collapse>
className="versionBanner" <Collapse.Panel header="A new version of Fredy is available" itemKey="1" className="versionBanner">
type="success" <div className="versionBanner__content">
icon={null}
description={
<div style={{ overflow: 'auto' }}>
<p>A new version of Fredy is available. Update now to take advantage of the latest features and bug fixes.</p> <p>A new version of Fredy is available. Update now to take advantage of the latest features and bug fixes.</p>
<Descriptions row size="small"> <Descriptions row size="small">
<Descriptions.Item itemKey="Your Version">{versionUpdate.localFredyVersion}</Descriptions.Item> <Descriptions.Item itemKey="Your Version">{versionUpdate.localFredyVersion}</Descriptions.Item>
@@ -29,9 +26,9 @@ export default function VersionBanner() {
<small>Release Notes</small> <small>Release Notes</small>
</b> </b>
</p> </p>
<MarkdownRender raw={versionUpdate.body} style={{ height: '200px' }} /> <MarkdownRender raw={versionUpdate.body} />
</div> </div>
} </Collapse.Panel>
/> </Collapse>
); );
} }

View File

@@ -1,3 +1,7 @@
.versionBanner { .versionBanner {
margin-bottom: 1rem; background: rgba(var(--semi-teal-1), 1);
&__content {
overflow: auto;
}
} }

View File

@@ -0,0 +1,23 @@
import { useState, useEffect } from 'react';
export function useScreenWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
let timeoutId;
const handleResize = () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => setWidth(window.innerWidth), 100);
};
window.addEventListener('resize', handleResize);
return () => {
clearTimeout(timeoutId);
window.removeEventListener('resize', handleResize);
};
}, []);
return width;
}

View File

@@ -4,7 +4,6 @@ import { useActions, useSelector } from '../../services/state/store';
import { Divider, TimePicker, Button, Checkbox, Input } from '@douyinfe/semi-ui'; import { Divider, TimePicker, Button, Checkbox, Input } from '@douyinfe/semi-ui';
import { InputNumber } from '@douyinfe/semi-ui'; import { InputNumber } from '@douyinfe/semi-ui';
import Headline from '../../components/headline/Headline';
import { xhrPost } from '../../services/xhr'; import { xhrPost } from '../../services/xhr';
import { SegmentPart } from '../../components/segment/SegmentPart'; import { SegmentPart } from '../../components/segment/SegmentPart';
import { Banner, Toast } from '@douyinfe/semi-ui'; import { Banner, Toast } from '@douyinfe/semi-ui';
@@ -125,7 +124,6 @@ const GeneralSettings = function GeneralSettings() {
<div> <div>
{!loading && ( {!loading && (
<React.Fragment> <React.Fragment>
<Headline text="General Settings" />
<div> <div>
<SegmentPart <SegmentPart
name="Interval" name="Interval"
@@ -186,7 +184,7 @@ const GeneralSettings = function GeneralSettings() {
<Divider margin="1rem" /> <Divider margin="1rem" />
<SegmentPart <SegmentPart
name="Working hours" name="Working hours"
helpText="During this hours, Fredy will search for new apartments. If nothing is configured, Fredy will search around the clock." helpText="During these hours, Fredy will search for new apartments. If nothing is configured, Fredy will search around the clock."
Icon={IconCalendar} Icon={IconCalendar}
> >
<div className="generalSettings__timePickerContainer"> <div className="generalSettings__timePickerContainer">

View File

@@ -4,21 +4,29 @@ import JobTable from '../../components/table/JobTable';
import { useSelector, useActions } from '../../services/state/store'; import { useSelector, useActions } from '../../services/state/store';
import { xhrDelete, xhrPut } from '../../services/xhr'; import { xhrDelete, xhrPut } from '../../services/xhr';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import ProcessingTimes from './ProcessingTimes';
import { Button, Toast } from '@douyinfe/semi-ui'; import { Button, Toast } from '@douyinfe/semi-ui';
import { IconPlusCircle } from '@douyinfe/semi-icons'; import { IconPlusCircle } from '@douyinfe/semi-icons';
import './Jobs.less'; import './Jobs.less';
export default function Jobs() { export default function Jobs() {
const jobs = useSelector((state) => state.jobs.jobs); const jobs = useSelector((state) => state.jobs.jobs);
const processingTimes = useSelector((state) => state.jobs.processingTimes);
const navigate = useNavigate(); const navigate = useNavigate();
const actions = useActions(); const actions = useActions();
const onJobRemoval = async (jobId) => { const onJobRemoval = async (jobId) => {
try { try {
await xhrDelete('/api/jobs', { jobId }); await xhrDelete('/api/jobs', { jobId });
Toast.success('Job successfully remove'); Toast.success('Job successfully removed');
await actions.jobs.getJobs();
} catch (error) {
Toast.error(error);
}
};
const onListingRemoval = async (jobId) => {
try {
await xhrDelete('/api/listings/job', { jobId });
Toast.success('Listings successfully removed');
await actions.jobs.getJobs(); await actions.jobs.getJobs();
} catch (error) { } catch (error) {
Toast.error(error); Toast.error(error);
@@ -38,7 +46,6 @@ export default function Jobs() {
return ( return (
<div> <div>
<div> <div>
{processingTimes != null && <ProcessingTimes processingTimes={processingTimes} />}
<Button <Button
type="primary" type="primary"
icon={<IconPlusCircle />} icon={<IconPlusCircle />}
@@ -52,6 +59,7 @@ export default function Jobs() {
<JobTable <JobTable
jobs={jobs || []} jobs={jobs || []}
onJobRemoval={onJobRemoval} onJobRemoval={onJobRemoval}
onListingRemoval={onListingRemoval}
onJobStatusChanged={onJobStatusChanged} onJobStatusChanged={onJobStatusChanged}
onJobInsight={(jobId) => navigate(`/jobs/insights/${jobId}`)} onJobInsight={(jobId) => navigate(`/jobs/insights/${jobId}`)}
onJobEdit={(jobId) => navigate(`/jobs/edit/${jobId}`)} onJobEdit={(jobId) => navigate(`/jobs/edit/${jobId}`)}

View File

@@ -1,7 +1,8 @@
.jobs { .jobs {
&__newButton { &__newButton {
margin-top: 1rem !important; margin-top: 1rem !important;
float: right; float: left;
margin-bottom: 1rem !important; margin-bottom: 1rem !important;
margin-left: 1rem;
} }
} }

View File

@@ -1,47 +1,56 @@
import React from 'react'; import React from 'react';
import { format } from '../../services/time/timeService'; import { format } from '../../services/time/timeService';
import { Button, Descriptions, Toast } from '@douyinfe/semi-ui'; import { Button, Card, Col, Row, Toast } from '@douyinfe/semi-ui';
import { IconPlayCircle } from '@douyinfe/semi-icons'; import { IconPlayCircle } from '@douyinfe/semi-icons';
import { xhrPost } from '../../services/xhr.js'; import { xhrPost } from '../../services/xhr.js';
import './ProsessingTimes.less';
function InfoCard({ title, value }) {
return (
<Card style={{ maxWidth: '13rem', margin: '1rem', background: 'rgb(53, 54, 60)' }} title={title}>
{value}
</Card>
);
}
export default function ProcessingTimes({ processingTimes = {} }) { export default function ProcessingTimes({ processingTimes = {} }) {
if (Object.keys(processingTimes).length === 0) { if (Object.keys(processingTimes).length === 0) {
return null; return null;
} }
return ( return (
<> <Row>
<Descriptions <Col span={6}>
row <InfoCard title="Processing Interval" value={`${processingTimes.interval} min`} />
size="small" </Col>
style={{ {processingTimes.lastRun && (
backgroundColor: '#35363c', <>
borderRadius: '4px', <Col span={6}>
padding: '10px', <InfoCard title="Last run" value={format(processingTimes.lastRun)} />
}} </Col>
> <Col span={6}>
<Descriptions.Item itemKey="Processing Interval">{processingTimes.interval} min</Descriptions.Item> <InfoCard title="Next run" value={format(processingTimes.lastRun + processingTimes.interval * 60000)} />
{processingTimes.lastRun && ( </Col>
<> </>
<Descriptions.Item itemKey="Last run">{format(processingTimes.lastRun)}</Descriptions.Item> )}
<Descriptions.Item itemKey="Next run"> <Col span={6}>
{format(processingTimes.lastRun + processingTimes.interval * 60000)} <InfoCard
</Descriptions.Item> title="Find Listings Now"
<Descriptions.Item itemKey="Find Listings now"> value={
<Button <Button
size="small" size="small"
icon={<IconPlayCircle />} icon={<IconPlayCircle />}
aria-label="Start now" aria-label="Start now"
onClick={async () => { onClick={async () => {
await xhrPost('/api/jobs/startAll', null); await xhrPost('/api/jobs/startAll', null);
Toast.success('Successfully triggered Fredy search.'); Toast.success('Successfully triggered Fredy search.');
}} }}
> >
Search now Search now
</Button> </Button>
</Descriptions.Item> }
</> />
)} </Col>
</Descriptions> </Row>
</>
); );
} }

View File

@@ -0,0 +1,5 @@
.processingTimes {
display: flex;
gap: 1rem;
justify-content: space-between;
}

View File

@@ -89,7 +89,7 @@ export default function JobMutator() {
/> />
)} )}
<Headline text={jobToBeEdit ? 'Edit a Job' : 'Create a new Job'} /> <Headline text={jobToBeEdit ? 'Edit Job' : 'Create new Job'} />
<form> <form>
<SegmentPart name="Name"> <SegmentPart name="Name">
<Input <Input

View File

@@ -52,7 +52,7 @@ const Users = function Users() {
icon={<IconPlus />} icon={<IconPlus />}
onClick={() => navigate('/users/new')} onClick={() => navigate('/users/new')}
> >
Create new User New User
</Button> </Button>
<UserTable <UserTable

View File

@@ -1,7 +1,8 @@
.users { .users {
&__newButton { &__newButton {
margin-top: 1rem !important; margin-top: 1rem !important;
float: right; float: left;
margin-bottom: 1rem !important; margin-bottom: 1rem !important;
margin-left: 1rem;
} }
} }

114
yarn.lock
View File

@@ -1428,10 +1428,10 @@
"@resvg/resvg-js-win32-ia32-msvc" "2.4.1" "@resvg/resvg-js-win32-ia32-msvc" "2.4.1"
"@resvg/resvg-js-win32-x64-msvc" "2.4.1" "@resvg/resvg-js-win32-x64-msvc" "2.4.1"
"@rolldown/pluginutils@1.0.0-beta.35": "@rolldown/pluginutils@1.0.0-beta.38":
version "1.0.0-beta.35" version "1.0.0-beta.38"
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.35.tgz#1a477e7742b154b67519d40e4fc17485de338e7a" resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz#95253608c4629eb2a5f3d656009ac9ba031eb292"
integrity sha512-slYrCpoxJUqzFDDNlvrOYRazQUNRvWPjXA17dAOISY3rDMxX6k8K4cj2H+hEYMHF81HO3uNd5rHVigAWRM5dSg== integrity sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==
"@rollup/rollup-android-arm-eabi@4.49.0": "@rollup/rollup-android-arm-eabi@4.49.0":
version "4.49.0" version "4.49.0"
@@ -1895,15 +1895,15 @@
"@turf/invariant" "^6.5.0" "@turf/invariant" "^6.5.0"
eventemitter3 "^4.0.7" eventemitter3 "^4.0.7"
"@vitejs/plugin-react@5.0.3": "@vitejs/plugin-react@5.0.4":
version "5.0.3" version "5.0.4"
resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-5.0.3.tgz#182ea45406d89e55b4e35c92a4a8c2c8388726c8" resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-5.0.4.tgz#d642058e89c5b712655c8cbd13482f5813519602"
integrity sha512-PFVHhosKkofGH0Yzrw1BipSedTH68BFF8ZWy1kfUpCtJcouXXY0+racG8sExw7hw0HoX36813ga5o3LTWZ4FUg== integrity sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA==
dependencies: dependencies:
"@babel/core" "^7.28.4" "@babel/core" "^7.28.4"
"@babel/plugin-transform-react-jsx-self" "^7.27.1" "@babel/plugin-transform-react-jsx-self" "^7.27.1"
"@babel/plugin-transform-react-jsx-source" "^7.27.1" "@babel/plugin-transform-react-jsx-source" "^7.27.1"
"@rolldown/pluginutils" "1.0.0-beta.35" "@rolldown/pluginutils" "1.0.0-beta.38"
"@types/babel__core" "^7.20.5" "@types/babel__core" "^7.20.5"
react-refresh "^0.17.0" react-refresh "^0.17.0"
@@ -2362,10 +2362,10 @@ ccount@^2.0.0:
resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5" resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5"
integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg== integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==
chai@6.0.1: chai@6.2.0:
version "6.0.1" version "6.2.0"
resolved "https://registry.yarnpkg.com/chai/-/chai-6.0.1.tgz#88c2b4682fb56050647e222d2cf9d6772f2607b3" resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.0.tgz#181bca6a219cddb99c3eeefb82483800ffa550ce"
integrity sha512-/JOoU2//6p5vCXh00FpNgtlw0LjvhGttaWc+y7wpW9yjBm3ys0dI8tSKZxIOgNruz5J0RleccatSIC3uxEZP0g== integrity sha512-aUTnJc/JipRzJrNADXVvpVqi6CO0dn3nx4EVPxijri+fj3LUUDyZQOgVeW54Ob3Y1Xh9Iz8f+CgaCl8v0mn9bA==
chalk@^4.0.0, chalk@^4.1.0: chalk@^4.0.0, chalk@^4.1.0:
version "4.1.2" version "4.1.2"
@@ -2863,10 +2863,10 @@ devlop@^1.0.0, devlop@^1.1.0:
dependencies: dependencies:
dequal "^2.0.0" dequal "^2.0.0"
devtools-protocol@0.0.1495869: devtools-protocol@0.0.1508733:
version "0.0.1495869" version "0.0.1508733"
resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1495869.tgz#f68daef77a48d5dcbcdd55dbfa3265a51989c91b" resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz#047deb3531470efda2c7bf43c10b3ae9e4b3d51b"
integrity sha512-i+bkd9UYFis40RcnkW7XrOprCujXRAHg62IVh/Ah3G8MmNXpCGt1m0dTFhSdx/AVs8XEMbdOGRwdkR1Bcta8AA== integrity sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==
diff@^7.0.0: diff@^7.0.0:
version "7.0.0" version "7.0.0"
@@ -4274,6 +4274,11 @@ is-number@^7.0.0:
resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
is-path-inside@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283"
integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==
is-plain-obj@^2.1.0: is-plain-obj@^2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287"
@@ -4559,10 +4564,10 @@ lines-and-columns@^1.1.6:
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
lint-staged@16.2.1: lint-staged@16.2.3:
version "16.2.1" version "16.2.3"
resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-16.2.1.tgz#bb82da8ce10059296b220f321980f0ee1ce40c28" resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-16.2.3.tgz#790866221d75602510507b5be40b2c7963715960"
integrity sha512-KMeYmH9wKvHsXdUp+z6w7HN3fHKHXwT1pSTQTYxB9kI6ekK1rlL3kLZEoXZCppRPXFK9PFW/wfQctV7XUqMrPQ== integrity sha512-1OnJEESB9zZqsp61XHH2fvpS1es3hRCxMplF/AJUDa8Ho8VrscYDIuxGrj3m8KPXbcWZ8fT9XTMUhEQmOVKpKw==
dependencies: dependencies:
commander "^14.0.1" commander "^14.0.1"
listr2 "^9.0.4" listr2 "^9.0.4"
@@ -5370,10 +5375,10 @@ mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3:
resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113"
integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==
mocha@11.7.2: mocha@11.7.4:
version "11.7.2" version "11.7.4"
resolved "https://registry.yarnpkg.com/mocha/-/mocha-11.7.2.tgz#3c0079fe5cc2f8ea86d99124debcc42bb1ab22b5" resolved "https://registry.yarnpkg.com/mocha/-/mocha-11.7.4.tgz#f161b17aeccb0762484b33bdb3f7ab9410ba5c82"
integrity sha512-lkqVJPmqqG/w5jmmFtiRvtA2jkDyNVUcefFJKb2uyX4dekk8Okgqop3cgbFiaIvj8uCRJVTP5x9dfxGyXm2jvQ== integrity sha512-1jYAaY8x0kAZ0XszLWu14pzsf4KV740Gld4HXkhNTXwcHx4AUEDkPzgEHg9CM5dVcW+zv036tjpsEbLraPJj4w==
dependencies: dependencies:
browser-stdout "^1.3.1" browser-stdout "^1.3.1"
chokidar "^4.0.1" chokidar "^4.0.1"
@@ -5383,6 +5388,7 @@ mocha@11.7.2:
find-up "^5.0.0" find-up "^5.0.0"
glob "^10.4.5" glob "^10.4.5"
he "^1.2.0" he "^1.2.0"
is-path-inside "^3.0.3"
js-yaml "^4.1.0" js-yaml "^4.1.0"
log-symbols "^4.1.0" log-symbols "^4.1.0"
minimatch "^9.0.5" minimatch "^9.0.5"
@@ -5962,17 +5968,17 @@ punycode@^2.1.0:
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
puppeteer-core@24.22.3: puppeteer-core@24.23.0:
version "24.22.3" version "24.23.0"
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.22.3.tgz#63285a37da6e2c44069c0b31f2171f8ab81bbe23" resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.23.0.tgz#1f84abafa480358652ae8df340af984438173a14"
integrity sha512-M/Jhg4PWRANSbL/C9im//Yb55wsWBS5wdp+h59iwM+EPicVQQCNs56iC5aEAO7avfDPRfxs4MM16wHjOYHNJEw== integrity sha512-yl25C59gb14sOdIiSnJ08XiPP+O2RjuyZmEG+RjYmCXO7au0jcLf7fRiyii96dXGUBW7Zwei/mVKfxMx/POeFw==
dependencies: dependencies:
"@puppeteer/browsers" "2.10.10" "@puppeteer/browsers" "2.10.10"
chromium-bidi "9.1.0" chromium-bidi "9.1.0"
debug "^4.4.3" debug "^4.4.3"
devtools-protocol "0.0.1495869" devtools-protocol "0.0.1508733"
typed-query-selector "^2.12.0" typed-query-selector "^2.12.0"
webdriver-bidi-protocol "0.2.11" webdriver-bidi-protocol "0.3.6"
ws "^8.18.3" ws "^8.18.3"
puppeteer-extra-plugin-stealth@^2.11.2: puppeteer-extra-plugin-stealth@^2.11.2:
@@ -6022,16 +6028,16 @@ puppeteer-extra@^3.3.6:
debug "^4.1.1" debug "^4.1.1"
deepmerge "^4.2.2" deepmerge "^4.2.2"
puppeteer@^24.22.3: puppeteer@^24.23.0:
version "24.22.3" version "24.23.0"
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.22.3.tgz#07dcfabdb4e924b014cb7b96bcc92f43086e637e" resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.23.0.tgz#fa3c1bffc1b40c3d7a59b9463d444ff4be69f5c7"
integrity sha512-mnhXzIqSYSJ1SMv1RYH07YMzWP81xCmmQj91Q8iQMZqnf97eVzeHgsGL6kpywiGCi+nQafta/+NkwM4URMy/XQ== integrity sha512-BVR1Lg8sJGKXY79JARdIssFWK2F6e1j+RyuJP66w4CUmpaXjENicmA3nNpUXA8lcTdDjAndtP+oNdni3T/qQqA==
dependencies: dependencies:
"@puppeteer/browsers" "2.10.10" "@puppeteer/browsers" "2.10.10"
chromium-bidi "9.1.0" chromium-bidi "9.1.0"
cosmiconfig "^9.0.0" cosmiconfig "^9.0.0"
devtools-protocol "0.0.1495869" devtools-protocol "0.0.1508733"
puppeteer-core "24.22.3" puppeteer-core "24.23.0"
typed-query-selector "^2.12.0" typed-query-selector "^2.12.0"
qs@^6.14.0: qs@^6.14.0:
@@ -6121,17 +6127,17 @@ react-resizable@^3.0.5:
prop-types "15.x" prop-types "15.x"
react-draggable "^4.0.3" react-draggable "^4.0.3"
react-router-dom@7.9.2: react-router-dom@7.9.3:
version "7.9.2" version "7.9.3"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.9.2.tgz#2bb35d226ca23329f4e39c8f86d1db26ee4fdf26" resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.9.3.tgz#67ab1655f67b9b6108fe20ed3d4881b53dccf87a"
integrity sha512-pagqpVJnjZOfb+vIM23eTp7Sp/AAJjOgaowhP1f1TWOdk5/W8Uk8d/M/0wfleqx7SgjitjNPPsKeCZE1hTSp3w== integrity sha512-1QSbA0TGGFKTAc/aWjpfW/zoEukYfU4dc1dLkT/vvf54JoGMkW+fNA+3oyo2gWVW1GM7BxjJVHz5GnPJv40rvg==
dependencies: dependencies:
react-router "7.9.2" react-router "7.9.3"
react-router@7.9.2: react-router@7.9.3:
version "7.9.2" version "7.9.3"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.9.2.tgz#f424a14f87e4d7b5b268ce3647876e9504e4fca6" resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.9.3.tgz#f2d5ff6181851de3df3acb4e7364fce0dee5fba2"
integrity sha512-i2TPp4dgaqrOqiRGLZmqh2WXmbdFknUyiCRmSKs0hf6fWXkTKg5h56b+9F22NbGRAMxjYfqQnpi63egzD2SuZA== integrity sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==
dependencies: dependencies:
cookie "^1.0.1" cookie "^1.0.1"
set-cookie-parser "^2.6.0" set-cookie-parser "^2.6.0"
@@ -7408,10 +7414,10 @@ vfile@^6.0.0:
"@types/unist" "^3.0.0" "@types/unist" "^3.0.0"
vfile-message "^4.0.0" vfile-message "^4.0.0"
vite@7.1.7: vite@7.1.9:
version "7.1.7" version "7.1.9"
resolved "https://registry.yarnpkg.com/vite/-/vite-7.1.7.tgz#ed3f9f06e21d6574fe1ad425f6b0912d027ffc13" resolved "https://registry.yarnpkg.com/vite/-/vite-7.1.9.tgz#ba844410e5d0c0f2a4eaf17a52af60ebea322cbf"
integrity sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA== integrity sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==
dependencies: dependencies:
esbuild "^0.25.0" esbuild "^0.25.0"
fdir "^6.5.0" fdir "^6.5.0"
@@ -7427,10 +7433,10 @@ web-streams-polyfill@^3.0.3:
resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b" resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b"
integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw== integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==
webdriver-bidi-protocol@0.2.11: webdriver-bidi-protocol@0.3.6:
version "0.2.11" version "0.3.6"
resolved "https://registry.yarnpkg.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.2.11.tgz#dba18d9b0a33aed33fab272dbd6e42411ac753cc" resolved "https://registry.yarnpkg.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.6.tgz#55ad4ff9697532e3e04fb0446bb6dd4c158b3ad5"
integrity sha512-Y9E1/oi4XMxcR8AT0ZC4OvYntl34SPgwjmELH+owjBr0korAX4jKgZULBWILGCVGdVCQ0dodTToIETozhG8zvA== integrity sha512-mlGndEOA9yK9YAbvtxaPTqdi/kaCWYYfwrZvGzcmkr/3lWM+tQj53BxtpVd6qbC6+E5OnHXgCcAhre6AkXzxjA==
whatwg-encoding@^3.1.1: whatwg-encoding@^3.1.1:
version "3.1.1" version "3.1.1"