mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
25 Commits
185-bug-ol
...
12.3.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95cd4028d7 | ||
|
|
eb01c2107c | ||
|
|
42cd4fa0ae | ||
|
|
6d96fd2bf8 | ||
|
|
ff1d2317a1 | ||
|
|
a47fa41278 | ||
|
|
9654e56846 | ||
|
|
43094640a8 | ||
|
|
fa234d2d78 | ||
|
|
7cb0d6e382 | ||
|
|
d79f8d2664 | ||
|
|
4d37e890ab | ||
|
|
7589f20a18 | ||
|
|
702ffabc1a | ||
|
|
9387de1cd9 | ||
|
|
facd683d45 | ||
|
|
8324357edb | ||
|
|
67af7c7dc5 | ||
|
|
6f5b52f3ad | ||
|
|
89d239c360 | ||
|
|
dd5c5b29d9 | ||
|
|
0cb2f48645 | ||
|
|
3f294b8099 | ||
|
|
11fd18e76a | ||
|
|
0d2b21c789 |
@@ -21,15 +21,13 @@ Finding an apartment or house in Germany can be stressful and
|
||||
time-consuming.\
|
||||
**Fredy** makes it easier: it automatically scrapes **ImmoScout24,
|
||||
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.
|
||||
|
||||
With a modern architecture, Fredy provides a **clean Web UI**, removes
|
||||
duplicates across platforms, and stores results so you never see the
|
||||
same listing twice.
|
||||
|
||||
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
## ✨ Key Features
|
||||
@@ -37,7 +35,7 @@ same listing twice.
|
||||
- 🏠 Scrapes **ImmoScout24, Immowelt, Immonet, eBay Kleinanzeigen,
|
||||
WG-Gesucht**
|
||||
- ⚡ Instant notifications: Slack, Telegram, Email (SendGrid,
|
||||
Mailjet), ntfy
|
||||
Mailjet), ntfy, discord
|
||||
- 🔎 Uses the **ImmoScout Mobile API** (reverse engineered)
|
||||
- 🌍 Runs anywhere: Docker, Node.js, self-hosted
|
||||
- 🖥️ Intuitive **Web UI** to manage searches
|
||||
@@ -131,7 +129,7 @@ picks up the newest listings first.
|
||||
### Adapter 📡
|
||||
|
||||
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).\
|
||||
You can use multiple adapters at once --- Fredy will send new listings
|
||||
through all of them.
|
||||
|
||||
@@ -107,7 +107,7 @@ class FredyRuntime {
|
||||
}
|
||||
return !similar;
|
||||
});
|
||||
filteredList.forEach((filter) => this._similarityCache.addCacheEntry(filter.title, listings.address));
|
||||
filteredList.forEach((filter) => this._similarityCache.addCacheEntry(filter.title, filter.address));
|
||||
return filteredList;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@ import { authInterceptor, cookieSession, adminInterceptor } from './security.js'
|
||||
import { generalSettingsRouter } from './routes/generalSettingsRoute.js';
|
||||
import { analyticsRouter } from './routes/analyticsRouter.js';
|
||||
import { providerRouter } from './routes/providerRouter.js';
|
||||
import { versionRouter } from './routes/versionRouter.js';
|
||||
import { loginRouter } from './routes/loginRoute.js';
|
||||
import { config } from '../utils.js';
|
||||
import { userRouter } from './routes/userRoute.js';
|
||||
import { jobRouter } from './routes/jobRouter.js';
|
||||
import { versionRouter } from './routes/versionRouter.js';
|
||||
import { config } from '../utils.js';
|
||||
import bodyParser from 'body-parser';
|
||||
import restana from 'restana';
|
||||
import files from 'serve-static';
|
||||
@@ -15,6 +15,7 @@ import path from 'path';
|
||||
import { getDirName } from '../utils.js';
|
||||
import { demoRouter } from './routes/demoRouter.js';
|
||||
import logger from '../services/logger.js';
|
||||
import { listingsRouter } from './routes/listingsRouter.js';
|
||||
const service = restana();
|
||||
const staticService = files(path.join(getDirName(), '../ui/public'));
|
||||
const PORT = config.port || 9998;
|
||||
@@ -25,6 +26,8 @@ service.use(staticService);
|
||||
service.use('/api/admin', authInterceptor());
|
||||
service.use('/api/jobs', authInterceptor());
|
||||
service.use('/api/version', authInterceptor());
|
||||
service.use('/api/listings', authInterceptor());
|
||||
|
||||
// /admin can only be accessed when user is having admin permissions
|
||||
service.use('/api/admin', adminInterceptor());
|
||||
service.use('/api/jobs/notificationAdapter', notificationAdapterRouter);
|
||||
@@ -35,6 +38,7 @@ service.use('/api/admin/users', userRouter);
|
||||
service.use('/api/version', versionRouter);
|
||||
service.use('/api/jobs', jobRouter);
|
||||
service.use('/api/login', loginRouter);
|
||||
service.use('/api/listings', listingsRouter);
|
||||
//this route is unsecured intentionally as it is being queried from the login page
|
||||
service.use('/api/demo', demoRouter);
|
||||
|
||||
|
||||
23
lib/api/routes/listingsRouter.js
Normal file
23
lib/api/routes/listingsRouter.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import restana from 'restana';
|
||||
import * as listingStorage from '../../services/storage/listingsStorage.js';
|
||||
import { isAdmin as isAdminFn } from '../security.js';
|
||||
const service = restana();
|
||||
|
||||
const listingsRouter = service.newRouter();
|
||||
|
||||
listingsRouter.get('/table', async (req, res) => {
|
||||
const { page, pageSize = 50, filter, sortfield = null, sortdir = 'asc' } = req.query || {};
|
||||
|
||||
const result = listingStorage.queryListings({
|
||||
page: page ? parseInt(page, 10) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize, 10) : 50,
|
||||
filter: filter || undefined,
|
||||
sortField: sortfield || null,
|
||||
sortDir: sortdir === 'desc' ? 'desc' : 'asc',
|
||||
userId: req.session.currentUser,
|
||||
isAdmin: isAdminFn(req),
|
||||
});
|
||||
res.body = result;
|
||||
res.send();
|
||||
});
|
||||
export { listingsRouter };
|
||||
@@ -1,6 +1,7 @@
|
||||
import restana from 'restana';
|
||||
import fetch from 'node-fetch';
|
||||
import { getPackageVersion } from '../../utils.js';
|
||||
import semver from 'semver';
|
||||
|
||||
const service = restana();
|
||||
const versionRouter = service.newRouter();
|
||||
@@ -15,7 +16,7 @@ async function getCurrentVersionFromGithub() {
|
||||
const raw = await fetch('https://api.github.com/repos/orangecoding/fredy/releases/latest');
|
||||
const data = await raw.json();
|
||||
const localFredyVersion = await getPackageVersion();
|
||||
if (localFredyVersion === data.tag_name) {
|
||||
if (data.tag_name == null || semver.gte(localFredyVersion, data.tag_name)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -37,7 +37,7 @@ const cookieSession$0 = (userId) => {
|
||||
name: 'fredy-admin-session',
|
||||
keys: ['fredy', 'super', 'fancy', 'key', nanoid()],
|
||||
userId,
|
||||
maxAge: 8 * 60 * 60 * 1000, // 8 hours
|
||||
maxAge: 2 * 60 * 60 * 1000, // 2 hours
|
||||
});
|
||||
};
|
||||
export { cookieSession$0 as cookieSession };
|
||||
|
||||
@@ -8,7 +8,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
||||
const jobName = job == null ? jobKey : job.name;
|
||||
const promises = newListings.map((newListing) => {
|
||||
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, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
130
lib/notification/adapter/discord_webhook.js
Normal file
130
lib/notification/adapter/discord_webhook.js
Normal 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.',
|
||||
},
|
||||
},
|
||||
};
|
||||
4
lib/notification/adapter/discord_webhook.md
Normal file
4
lib/notification/adapter/discord_webhook.md
Normal 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.
|
||||
@@ -13,10 +13,10 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
||||
return fetch(webhook, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: {
|
||||
body: JSON.stringify({
|
||||
channel: channel,
|
||||
text: message,
|
||||
},
|
||||
}),
|
||||
});
|
||||
};
|
||||
export const config = {
|
||||
|
||||
@@ -15,11 +15,17 @@ Size: ${newListing.size == null ? 'N/A' : newListing.size.replace(/2m/g, '$m^2$'
|
||||
Price: ${newListing.price}
|
||||
Link: ${newListing.link}`;
|
||||
|
||||
const sanitizeHeaderValue = (value) =>
|
||||
String(value ?? '')
|
||||
.replace(/[\r\n]+/g, ' ')
|
||||
.replace(/[^\x20-\x7E]/g, ' ')
|
||||
.trim();
|
||||
|
||||
const headers = {
|
||||
Title: newListing.title,
|
||||
Priority: String(priority),
|
||||
Tags: `${serviceName},${jobName}`,
|
||||
Click: newListing.link,
|
||||
Title: sanitizeHeaderValue(newListing.title),
|
||||
Priority: sanitizeHeaderValue(priority),
|
||||
Tags: sanitizeHeaderValue(`${serviceName},${jobName}`),
|
||||
Click: sanitizeHeaderValue(newListing.link),
|
||||
};
|
||||
|
||||
if (newListing.image && typeof newListing.image === 'string') {
|
||||
|
||||
@@ -29,6 +29,7 @@ const config = {
|
||||
address: 'div[data-testid="cardmfe-description-box-address"] | trim',
|
||||
image: 'div[data-testid="cardmfe-picture-box-test-id"] img@src',
|
||||
link: 'button@data-base',
|
||||
description: 'div[data-testid="cardmfe-description-text-test-id"] | trim',
|
||||
},
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
|
||||
@@ -26,8 +26,9 @@ const config = {
|
||||
size: 'div[data-testid="cardmfe-keyfacts-testid"] | removeNewline | trim',
|
||||
title: 'div[data-testid="cardmfe-description-box-text-test-id"] > div:nth-of-type(2)',
|
||||
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',
|
||||
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,
|
||||
filter: applyBlacklist,
|
||||
|
||||
@@ -23,7 +23,7 @@ const config = {
|
||||
url: null,
|
||||
crawlContainer: '.col-12.mb-4',
|
||||
sortByDateParam: 'Sortierung=Id&Richtung=DESC',
|
||||
waitForSelector: '.nbk-section',
|
||||
waitForSelector: 'div[data-live-name-value="SearchList"]',
|
||||
crawlFields: {
|
||||
id: 'a@href',
|
||||
title: 'a@title | removeNewline | trim',
|
||||
|
||||
49
lib/provider/regionalimmobilien24.js
Executable file
49
lib/provider/regionalimmobilien24.js
Executable 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
46
lib/provider/sparkasse.js
Executable 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 };
|
||||
@@ -9,12 +9,12 @@ export function loadParser(text) {
|
||||
|
||||
export function parse(crawlContainer, crawlFields, text, url) {
|
||||
if (!text) {
|
||||
logger.warn('No content found for ', url);
|
||||
logger.debug('No content found for ', url);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!crawlContainer || !crawlFields) {
|
||||
logger.warn('Cannot parse, selector was empty for url ', url);
|
||||
logger.debug('Cannot parse, selector was empty for url ', url);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,30 +2,56 @@ import puppeteer from 'puppeteer-extra';
|
||||
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
||||
import { debug, DEFAULT_HEADER, botDetected } from './utils.js';
|
||||
import logger from '../logger.js';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
puppeteer.use(StealthPlugin());
|
||||
|
||||
export default async function execute(url, waitForSelector, options) {
|
||||
let browser;
|
||||
let page;
|
||||
let result = null;
|
||||
let userDataDir;
|
||||
let removeUserDataDir = false;
|
||||
try {
|
||||
debug(`Sending request to ${url} using Puppeteer.`);
|
||||
|
||||
// Prepare a dedicated temporary userDataDir to avoid leaking /tmp/.org.chromium.* dirs
|
||||
if (options && options.userDataDir) {
|
||||
userDataDir = options.userDataDir;
|
||||
removeUserDataDir = !!options.cleanupUserDataDir;
|
||||
} else {
|
||||
const prefix = path.join(os.tmpdir(), 'puppeteer-fredy-');
|
||||
userDataDir = fs.mkdtempSync(prefix);
|
||||
removeUserDataDir = true;
|
||||
}
|
||||
|
||||
browser = await puppeteer.launch({
|
||||
headless: options.puppeteerHeadless ?? true,
|
||||
args: ['--no-sandbox', '--disable-gpu', '--disable-setuid-sandbox'],
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-gpu',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-crash-reporter',
|
||||
],
|
||||
timeout: options.puppeteerTimeout || 30_000,
|
||||
userDataDir,
|
||||
});
|
||||
let page = await browser.newPage();
|
||||
page = await browser.newPage();
|
||||
await page.setExtraHTTPHeaders(DEFAULT_HEADER);
|
||||
const response = await page.goto(url, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
let pageSource;
|
||||
//if we're extracting data from a spa, we must wait for the selector
|
||||
// if we're extracting data from a SPA, we must wait for the selector
|
||||
if (waitForSelector != null) {
|
||||
await page.waitForSelector(waitForSelector);
|
||||
const selectorTimeout = options?.puppeteerSelectorTimeout ?? options?.puppeteerTimeout ?? 30_000;
|
||||
await page.waitForSelector(waitForSelector, { timeout: selectorTimeout });
|
||||
pageSource = await page.evaluate((selector) => {
|
||||
return document.querySelector(selector).innerHTML;
|
||||
const el = document.querySelector(selector);
|
||||
return el ? el.innerHTML : '';
|
||||
}, waitForSelector);
|
||||
} else {
|
||||
pageSource = await page.content();
|
||||
@@ -35,16 +61,35 @@ export default async function execute(url, waitForSelector, options) {
|
||||
|
||||
if (botDetected(pageSource, statusCode)) {
|
||||
logger.warn('We have been detected as a bot :-/ Tried url: => ', url);
|
||||
return null;
|
||||
result = null;
|
||||
} else {
|
||||
result = pageSource || (await page.content());
|
||||
}
|
||||
|
||||
return await page.content();
|
||||
} catch (error) {
|
||||
logger.error('Error executing with puppeteer executor', error);
|
||||
return null;
|
||||
result = null;
|
||||
} finally {
|
||||
if (browser != null) {
|
||||
await browser.close();
|
||||
try {
|
||||
if (page) {
|
||||
await page.close();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (browser != null) {
|
||||
await browser.close();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (removeUserDataDir && userDataDir) {
|
||||
await fs.promises.rm(userDataDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -166,3 +166,88 @@ export const storeListings = (jobId, providerId, listings) => {
|
||||
return str.replace(/\s*\([^)]*\)/g, '');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Query listings with pagination, filtering and sorting.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {number} [params.pageSize=50]
|
||||
* @param {number} [params.page=1]
|
||||
* @param {string} [params.filter]
|
||||
* @param {string|null} [params.sortField=null] - One of: 'created_at','price','size','provider','title'.
|
||||
* @param {('asc'|'desc')} [params.sortDir='asc']
|
||||
* @param {string} [params.userId] - Current user id used to scope listings (ignored for admins).
|
||||
* @param {boolean} [params.isAdmin=false] - When true, returns all listings.
|
||||
* @returns {{ totalNumber:number, page:number, result:Object[] }}
|
||||
*/
|
||||
export const queryListings = ({
|
||||
pageSize = 50,
|
||||
page = 1,
|
||||
filter,
|
||||
sortField = null,
|
||||
sortDir = 'asc',
|
||||
userId = null,
|
||||
isAdmin = false,
|
||||
} = {}) => {
|
||||
// sanitize inputs
|
||||
const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(500, Math.floor(pageSize)) : 50;
|
||||
const safePage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1;
|
||||
const offset = (safePage - 1) * safePageSize;
|
||||
|
||||
// build WHERE filter across common text columns
|
||||
const whereParts = [];
|
||||
const params = { limit: safePageSize, offset };
|
||||
// user scoping (non-admin only): restrict to listings whose job belongs to user
|
||||
if (!isAdmin) {
|
||||
params.userId = userId || '__NO_USER__';
|
||||
whereParts.push(`(j.user_id = @userId)`);
|
||||
}
|
||||
if (filter && String(filter).trim().length > 0) {
|
||||
params.filter = `%${String(filter).trim()}%`;
|
||||
whereParts.push(`(title LIKE @filter OR address LIKE @filter OR provider LIKE @filter OR link LIKE @filter)`);
|
||||
}
|
||||
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||||
const whereSqlWithAlias = whereSql
|
||||
.replace(/\btitle\b/g, 'l.title')
|
||||
.replace(/\bdescription\b/g, 'l.description')
|
||||
.replace(/\baddress\b/g, 'l.address')
|
||||
.replace(/\bprovider\b/g, 'l.provider')
|
||||
.replace(/\blink\b/g, 'l.link')
|
||||
.replace(/\bj\.user_id\b/g, 'j.user_id');
|
||||
|
||||
// whitelist sortable fields to avoid SQL injection
|
||||
const sortable = new Set(['created_at', 'price', 'size', 'provider', 'title', 'job_name', 'is_active']);
|
||||
const safeSortField = sortField && sortable.has(sortField) ? sortField : null;
|
||||
const safeSortDir = String(sortDir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
||||
const orderSql = safeSortField ? `ORDER BY ${safeSortField} ${safeSortDir}` : 'ORDER BY created_at DESC';
|
||||
const orderSqlWithAlias = orderSql
|
||||
.replace(/\bcreated_at\b/g, 'l.created_at')
|
||||
.replace(/\bprice\b/g, 'l.price')
|
||||
.replace(/\bsize\b/g, 'l.size')
|
||||
.replace(/\bprovider\b/g, 'l.provider')
|
||||
.replace(/\btitle\b/g, 'l.title')
|
||||
.replace(/\bjob_name\b/g, 'j.name');
|
||||
|
||||
// count total with same WHERE
|
||||
const countRow = SqliteConnection.query(
|
||||
`SELECT COUNT(1) as cnt
|
||||
FROM listings l
|
||||
LEFT JOIN jobs j ON j.id = l.job_id
|
||||
${whereSqlWithAlias}`,
|
||||
params,
|
||||
);
|
||||
const totalNumber = countRow?.[0]?.cnt ?? 0;
|
||||
|
||||
// fetch page
|
||||
const rows = SqliteConnection.query(
|
||||
`SELECT l.*, j.name AS job_name
|
||||
FROM listings l
|
||||
LEFT JOIN jobs j ON j.id = l.job_id
|
||||
${whereSqlWithAlias}
|
||||
${orderSqlWithAlias}
|
||||
LIMIT @limit OFFSET @offset`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { totalNumber, page: safePage, result: rows };
|
||||
};
|
||||
|
||||
48
lib/utils.js
48
lib/utils.js
@@ -109,11 +109,22 @@ function timeStringToMs(timeString, now) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether current time is within configured working hours, or no hours are set.
|
||||
* If working hours are missing or incomplete, returns true.
|
||||
* @param {{workingHours?: {from?: string, to?: string}}} config
|
||||
* @param {number} now - Epoch ms
|
||||
* @returns {boolean}
|
||||
* Determine whether the given timestamp is within the configured working hours, or return true when the window is not set.
|
||||
* - If workingHours is missing or either 'from' or 'to' is empty/null, returns true.
|
||||
* - Supports windows that cross midnight (e.g., from '23:00' to '06:00').
|
||||
*
|
||||
* Time parsing is based on the local timezone of the running process.
|
||||
*
|
||||
* @param {{workingHours?: {from?: string|null, to?: string|null}}} config - Configuration object containing working hours in 'HH:mm' format.
|
||||
* @param {number} now - Epoch milliseconds to evaluate.
|
||||
* @returns {boolean} True when execution is allowed at 'now'.
|
||||
* @example
|
||||
* // Same-day window
|
||||
* duringWorkingHoursOrNotSet({ workingHours: { from: '08:00', to: '17:00' } }, someTime);
|
||||
* @example
|
||||
* // Window crossing midnight
|
||||
* // For { from: '05:00', to: '00:30' } → 23:00 => true, 01:00 => false, 06:00 => true
|
||||
* duringWorkingHoursOrNotSet({ workingHours: { from: '05:00', to: '00:30' } }, Date.now());
|
||||
*/
|
||||
function duringWorkingHoursOrNotSet(config, now) {
|
||||
const { workingHours } = config;
|
||||
@@ -122,7 +133,20 @@ function duringWorkingHoursOrNotSet(config, now) {
|
||||
}
|
||||
const toDate = timeStringToMs(workingHours.to, now);
|
||||
const fromDate = timeStringToMs(workingHours.from, now);
|
||||
return fromDate <= now && toDate >= now;
|
||||
|
||||
// If parsing fails (e.g., malformed time), be lenient and allow.
|
||||
if (isNaN(toDate) || isNaN(fromDate)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (toDate >= fromDate) {
|
||||
// Same-day window (e.g., 08:00 - 17:00)
|
||||
return now >= fromDate && now <= toDate;
|
||||
}
|
||||
|
||||
// Window crosses midnight (e.g., 05:00 -> 00:30 next day)
|
||||
// Accept if we are after 'from' today OR before 'to' today (which represents next day's cutoff).
|
||||
return now >= fromDate || now <= toDate;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -244,13 +268,13 @@ function sleep(ms) {
|
||||
}
|
||||
|
||||
/**
|
||||
* returns a random into between start and end
|
||||
* @param a start int
|
||||
* @param b max int
|
||||
* @returns {*}
|
||||
* Return a random integer between min and max (inclusive).
|
||||
* @param {number} min - Minimum integer value.
|
||||
* @param {number} max - Maximum integer value.
|
||||
* @returns {number} A random integer N where min <= N <= max.
|
||||
*/
|
||||
function randomBetween(a, b) {
|
||||
return Math.floor(Math.random() * (b - a + 1)) + a;
|
||||
function randomBetween(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
// Call refreshConfig() from the application entrypoint during startup to populate config.
|
||||
|
||||
15
package.json
15
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fredy",
|
||||
"version": "12.1.5",
|
||||
"version": "12.3.2",
|
||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||
"scripts": {
|
||||
"prepare": "husky",
|
||||
@@ -63,28 +63,29 @@
|
||||
"@visactor/vchart": "^2.0.5",
|
||||
"@visactor/vchart-semi-theme": "^1.12.2",
|
||||
"@vitejs/plugin-react": "5.0.3",
|
||||
"better-sqlite3": "^12.3.0",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"body-parser": "2.2.0",
|
||||
"cheerio": "^1.1.2",
|
||||
"cookie-session": "2.1.1",
|
||||
"handlebars": "4.7.8",
|
||||
"lodash": "4.17.21",
|
||||
"markdown": "^0.5.0",
|
||||
"nanoid": "5.1.5",
|
||||
"nanoid": "5.1.6",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "3.3.2",
|
||||
"node-mailjet": "6.0.9",
|
||||
"p-throttle": "^8.0.0",
|
||||
"package-up": "^5.0.0",
|
||||
"puppeteer": "^24.22.0",
|
||||
"puppeteer": "^24.22.3",
|
||||
"puppeteer-extra": "^3.3.6",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||
"query-string": "9.3.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-router": "7.9.1",
|
||||
"react-router-dom": "7.9.1",
|
||||
"react-router": "7.9.2",
|
||||
"react-router-dom": "7.9.2",
|
||||
"restana": "5.1.0",
|
||||
"semver": "^7.7.2",
|
||||
"serve-static": "2.2.0",
|
||||
"slack": "11.0.2",
|
||||
"vite": "7.1.7",
|
||||
@@ -104,7 +105,7 @@
|
||||
"history": "5.3.0",
|
||||
"husky": "9.1.7",
|
||||
"less": "4.4.1",
|
||||
"lint-staged": "16.2.0",
|
||||
"lint-staged": "16.2.1",
|
||||
"mocha": "11.7.2",
|
||||
"nodemon": "^3.1.10",
|
||||
"prettier": "3.6.2"
|
||||
|
||||
53
test/FredyRuntime/FredyRuntime.test.js
Normal file
53
test/FredyRuntime/FredyRuntime.test.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import { expect } from 'chai';
|
||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||
import { mockFredy } from '../utils.js';
|
||||
|
||||
describe('FredyRuntime', () => {
|
||||
afterEach(() => {
|
||||
similarityCache.invalidateAllForTest();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
similarityCache.stopCacheCleanup();
|
||||
});
|
||||
|
||||
describe('_filterBySimilarListings', () => {
|
||||
let fredyRuntime;
|
||||
|
||||
beforeEach(async () => {
|
||||
const FredyRuntime = await mockFredy();
|
||||
fredyRuntime = new FredyRuntime({}, null, 'dummy-provider', 'dummy-job', similarityCache);
|
||||
});
|
||||
|
||||
it('should filter out listings with similar title and address already in cache', () => {
|
||||
similarityCache.addCacheEntry('Penthouse', 'Mustermann Straße 1');
|
||||
|
||||
const listings = [
|
||||
{ id: '1', title: 'Penthouse', address: 'Mustermann Straße 1' },
|
||||
{ id: '2', title: 'Nice apartment', address: 'Mustermann Straße 15' },
|
||||
];
|
||||
|
||||
const result = fredyRuntime._filterBySimilarListings(listings);
|
||||
|
||||
expect(result).to.have.length(1);
|
||||
expect(result[0].id).to.equal('2');
|
||||
expect(result[0].title).to.equal('Nice apartment');
|
||||
|
||||
expect(similarityCache.hasSimilarEntries('Nice apartment', 'Mustermann Straße 15')).to.be.true;
|
||||
});
|
||||
|
||||
it('should handle listings with null or undefined address', () => {
|
||||
const listings = [
|
||||
{ id: '1', title: 'Penthouse', address: null },
|
||||
{ id: '2', title: 'Nice apartment', address: undefined },
|
||||
];
|
||||
|
||||
const result = fredyRuntime._filterBySimilarListings(listings);
|
||||
|
||||
expect(result).to.have.length(2);
|
||||
|
||||
expect(similarityCache.hasSimilarEntries('Penthouse', null)).to.be.true;
|
||||
expect(similarityCache.hasSimilarEntries('Nice apartment', undefined)).to.be.true;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,31 +8,30 @@ describe('#immonet testsuite()', () => {
|
||||
after(() => {
|
||||
similarityCache.stopCacheCleanup();
|
||||
});
|
||||
provider.init(providerConfig.immonet, [], []);
|
||||
|
||||
it('should test immonet provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
return await new Promise((resolve) => {
|
||||
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');
|
||||
provider.init(providerConfig.immonet, [], []);
|
||||
|
||||
expect(notify.size).that.does.include('m²');
|
||||
expect(notify.title).to.be.not.empty;
|
||||
expect(notify.address).to.be.not.empty;
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immonet', similarityCache);
|
||||
const listing = await fredy.execute();
|
||||
|
||||
expect(listing).to.be.a('array');
|
||||
const notificationObj = get();
|
||||
expect(notificationObj).to.be.a('object');
|
||||
expect(notificationObj.serviceName).to.equal('immonet');
|
||||
notificationObj.payload.forEach((notify) => {
|
||||
/** check the actual structure **/
|
||||
expect(notify.id).to.be.a('string');
|
||||
expect(notify.price).to.be.a('string');
|
||||
expect(notify.size).to.be.a('string');
|
||||
expect(notify.title).to.be.a('string');
|
||||
expect(notify.link).to.be.a('string');
|
||||
expect(notify.address).to.be.a('string');
|
||||
/** check the values if possible **/
|
||||
expect(notify.size).that.does.include('m²');
|
||||
expect(notify.title).to.be.not.empty;
|
||||
expect(notify.address).to.be.not.empty;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,33 +8,32 @@ describe('#immowelt testsuite()', () => {
|
||||
after(() => {
|
||||
similarityCache.stopCacheCleanup();
|
||||
});
|
||||
|
||||
it('should test immowelt provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
provider.init(providerConfig.immowelt, [], []);
|
||||
return await new Promise((resolve) => {
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immowelt', 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('immowelt');
|
||||
notificationObj.payload.forEach((notify) => {
|
||||
/** check the actual structure **/
|
||||
expect(notify.id).to.be.a('string');
|
||||
expect(notify.price).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 **/
|
||||
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.address).to.be.not.empty;
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immowelt', 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('immowelt');
|
||||
notificationObj.payload.forEach((notify) => {
|
||||
/** check the actual structure **/
|
||||
expect(notify.id).to.be.a('string');
|
||||
expect(notify.price).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 **/
|
||||
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.address).to.be.not.empty;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
43
test/provider/regionalimmobilien24.test.js
Normal file
43
test/provider/regionalimmobilien24.test.js
Normal 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;
|
||||
});
|
||||
});
|
||||
});
|
||||
37
test/provider/sparkasse.test.js
Normal file
37
test/provider/sparkasse.test.js
Normal 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;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,14 @@
|
||||
"url": "https://www.neubaukompass.de/neubau-immobilien/duesseldorf-region/eigentumswohnung/",
|
||||
"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": {
|
||||
"url": "https://www.wg-gesucht.de/wg-zimmer-in-Duesseldorf.30.0.1.0.html",
|
||||
"enabled": true
|
||||
|
||||
@@ -8,6 +8,7 @@ const fakeWorkingHoursConfig = (from, to) => ({
|
||||
from,
|
||||
},
|
||||
});
|
||||
|
||||
describe('utils', () => {
|
||||
describe('#isOneOf()', () => {
|
||||
it('should be false', () => {
|
||||
@@ -33,5 +34,19 @@ describe('utils', () => {
|
||||
it('should be true if only from is set', () => {
|
||||
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('12:00', null), 1622026740000)).to.be.true;
|
||||
});
|
||||
it('should handle working hours that cross midnight (e.g., 05:00 → 00:30)', () => {
|
||||
const cfg = fakeWorkingHoursConfig('05:00', '00:30');
|
||||
const mkTs = (h, m = 0) => {
|
||||
const d = new Date();
|
||||
d.setHours(h);
|
||||
d.setMinutes(m);
|
||||
d.setSeconds(0);
|
||||
d.setMilliseconds(0);
|
||||
return d.getTime();
|
||||
};
|
||||
expect(duringWorkingHoursOrNotSet(cfg, mkTs(23, 0))).to.be.true; // 23:00 => within window
|
||||
expect(duringWorkingHoursOrNotSet(cfg, mkTs(1, 0))).to.be.false; // 01:00 => outside window
|
||||
expect(duringWorkingHoursOrNotSet(cfg, mkTs(6, 0))).to.be.true; // 06:00 => within window
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import './App.less';
|
||||
import TrackingModal from './components/tracking/TrackingModal.jsx';
|
||||
import { Banner } from '@douyinfe/semi-ui';
|
||||
import VersionBanner from './components/version/VersionBanner.jsx';
|
||||
import Listings from './views/listings/Listings.jsx';
|
||||
|
||||
export default function FredyApp() {
|
||||
const actions = useActions();
|
||||
@@ -50,14 +51,11 @@ export default function FredyApp() {
|
||||
|
||||
const isAdmin = () => currentUser != null && currentUser.isAdmin;
|
||||
|
||||
const login = () => (
|
||||
return loading ? null : needsLogin() ? (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
return loading ? null : needsLogin() ? (
|
||||
login()
|
||||
) : (
|
||||
<div className="app">
|
||||
<div className="app__container">
|
||||
@@ -84,6 +82,7 @@ export default function FredyApp() {
|
||||
<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 */}
|
||||
<Route
|
||||
|
||||
BIN
ui/src/assets/no_image.jpg
Normal file
BIN
ui/src/assets/no_image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 242 KiB |
@@ -5,5 +5,5 @@ import logoWhite from '../../assets/logo_white.png';
|
||||
import './Logo.less';
|
||||
|
||||
export default function Logo({ width = 350, white = false } = {}) {
|
||||
return <img src={white ? logoWhite : logo} width={width} className="logo" />;
|
||||
return <img src={white ? logoWhite : logo} width={width} className="logo" alt="Fredy Logo" />;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { Tabs, TabPane } from '@douyinfe/semi-ui';
|
||||
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { IconUser, IconTerminal, IconSetting } from '@douyinfe/semi-icons';
|
||||
import { IconUser, IconTerminal, IconSetting, IconArchive } from '@douyinfe/semi-icons';
|
||||
import './Menu.less';
|
||||
|
||||
function parsePathName(name) {
|
||||
@@ -25,6 +25,15 @@ const TopMenu = function TopMenu({ isAdmin }) {
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<TabPane
|
||||
itemKey="/listings"
|
||||
tab={
|
||||
<span>
|
||||
<IconArchive />
|
||||
Found listings
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{isAdmin && (
|
||||
<TabPane
|
||||
@@ -44,7 +53,7 @@ const TopMenu = function TopMenu({ isAdmin }) {
|
||||
tab={
|
||||
<span>
|
||||
<IconSetting />
|
||||
General
|
||||
Settings
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged,
|
||||
dataIndex: 'name',
|
||||
},
|
||||
{
|
||||
title: 'Findings',
|
||||
title: 'Listings',
|
||||
dataIndex: 'numberOfFoundListings',
|
||||
render: (value) => {
|
||||
return value || 0;
|
||||
|
||||
184
ui/src/components/table/ListingsTable.jsx
Normal file
184
ui/src/components/table/ListingsTable.jsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Table, Popover, Input, Descriptions, Tag, Image } from '@douyinfe/semi-ui';
|
||||
import { useActions, useSelector } from '../../services/state/store.js';
|
||||
import { IconClose, IconSearch, IconTick } from '@douyinfe/semi-icons';
|
||||
import * as timeService from '../../services/time/timeService.js';
|
||||
import debounce from 'lodash/debounce';
|
||||
import no_image from '../../assets/no_image.jpg';
|
||||
|
||||
import './ListingsTable.less';
|
||||
import { format } from '../../services/time/timeService.js';
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '#',
|
||||
dataIndex: 'is_active',
|
||||
width: 58,
|
||||
sorter: true,
|
||||
render: (value) => {
|
||||
return value ? (
|
||||
<div style={{ color: 'rgba(var(--semi-green-6), 1)' }}>
|
||||
<Popover
|
||||
style={{
|
||||
padding: '.4rem',
|
||||
color: 'var(--semi-color-white)',
|
||||
}}
|
||||
content="Listing still online"
|
||||
>
|
||||
<IconTick />
|
||||
</Popover>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: 'rgba(var(--semi-red-5), 1)' }}>
|
||||
<Popover
|
||||
style={{
|
||||
padding: '.4rem',
|
||||
color: 'var(--semi-color-white)',
|
||||
}}
|
||||
content="Listing not online anymore"
|
||||
>
|
||||
<IconClose />
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Job-Name',
|
||||
sorter: true,
|
||||
dataIndex: 'job_name',
|
||||
width: 170,
|
||||
},
|
||||
{
|
||||
title: 'Listing date',
|
||||
width: 130,
|
||||
dataIndex: 'created_at',
|
||||
sorter: true,
|
||||
render: (text) => timeService.format(text),
|
||||
},
|
||||
{
|
||||
title: 'Provider',
|
||||
width: 130,
|
||||
dataIndex: 'provider',
|
||||
sorter: true,
|
||||
render: (text) => text.charAt(0).toUpperCase() + text.slice(1),
|
||||
},
|
||||
{
|
||||
title: 'Price',
|
||||
width: 100,
|
||||
dataIndex: 'price',
|
||||
sorter: true,
|
||||
render: (text) => text + ' €',
|
||||
},
|
||||
{
|
||||
title: 'Address',
|
||||
width: 150,
|
||||
dataIndex: 'address',
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: 'Title',
|
||||
dataIndex: 'title',
|
||||
sorter: true,
|
||||
render: (text, row) => {
|
||||
return (
|
||||
<a href={row.url} target="_blank" rel="noopener noreferrer">
|
||||
{text}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default function ListingsTable() {
|
||||
const tableData = useSelector((state) => state.listingsTable);
|
||||
const actions = useActions();
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 15;
|
||||
const [sortData, setSortData] = useState({});
|
||||
const [filter, setFilter] = useState(null);
|
||||
|
||||
const handlePageChange = (_page) => {
|
||||
setPage(_page);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let sortfield = null;
|
||||
let sortdir = null;
|
||||
|
||||
if (sortData != null && Object.keys(sortData).length > 0) {
|
||||
sortfield = sortData.field;
|
||||
sortdir = sortData.direction;
|
||||
}
|
||||
actions.listingsTable.getListingsTable({ page, pageSize, sortfield, sortdir, filter });
|
||||
}, [page, sortData, filter]);
|
||||
|
||||
const handleFilterChange = useMemo(() => debounce((value) => setFilter(value), 500), []);
|
||||
|
||||
const expandRowRender = (record) => {
|
||||
return (
|
||||
<div className="listingsTable__expanded">
|
||||
<div>
|
||||
{record.image_url == null ? (
|
||||
<Image height={200} src={no_image} />
|
||||
) : (
|
||||
<Image height={200} src={record.image_url} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Descriptions align="justify">
|
||||
<Descriptions.Item itemKey="Listing still online">
|
||||
<Tag size="small" shape="circle" color={record.is_active ? 'green' : 'red'}>
|
||||
{record.is_active ? 'Yes' : 'No'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item itemKey="Link">
|
||||
<a href={record.link} target="_blank" rel="noreferrer">
|
||||
Link to Listing
|
||||
</a>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item itemKey="Listing date">{format(record.created_at)}</Descriptions.Item>
|
||||
<Descriptions.Item itemKey="Price">{record.price} €</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<b>{record.title}</b>
|
||||
<p>{record.description == null ? 'No description available' : record.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
showClear
|
||||
className="listingsTable__search"
|
||||
placeholder="Search"
|
||||
onChange={handleFilterChange}
|
||||
/>
|
||||
<Table
|
||||
rowKey="id"
|
||||
hideExpandedColumn={false}
|
||||
sticky={{ top: 5 }}
|
||||
columns={columns}
|
||||
expandedRowRender={expandRowRender}
|
||||
dataSource={tableData?.result || []}
|
||||
onChange={(changeSet) => {
|
||||
if (changeSet?.extra?.changeType === 'sorter') {
|
||||
setSortData({
|
||||
field: changeSet.sorter.dataIndex,
|
||||
direction: changeSet.sorter.sortOrder === 'ascend' ? 'asc' : 'desc',
|
||||
});
|
||||
}
|
||||
}}
|
||||
pagination={{
|
||||
currentPage: page,
|
||||
//for now fixed
|
||||
pageSize,
|
||||
total: tableData?.totalNumber || 0,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
ui/src/components/table/ListingsTable.less
Normal file
10
ui/src/components/table/ListingsTable.less
Normal file
@@ -0,0 +1,10 @@
|
||||
.listingsTable {
|
||||
&__search {
|
||||
margin-bottom: 1rem !important;
|
||||
}
|
||||
|
||||
&__expanded {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Banner, Descriptions } from '@douyinfe/semi-ui';
|
||||
import { useSelector } from '../../services/state/store.js';
|
||||
import { MarkdownRender } from '@douyinfe/semi-ui';
|
||||
|
||||
import './VersionBanner.less';
|
||||
|
||||
@@ -12,7 +13,7 @@ export default function VersionBanner() {
|
||||
type="success"
|
||||
icon={null}
|
||||
description={
|
||||
<div>
|
||||
<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>
|
||||
<Descriptions row size="small">
|
||||
<Descriptions.Item itemKey="Your Version">{versionUpdate.localFredyVersion}</Descriptions.Item>
|
||||
@@ -28,16 +29,9 @@ export default function VersionBanner() {
|
||||
<small>Release Notes</small>
|
||||
</b>
|
||||
</p>
|
||||
<pre>{stripFullChangelog(versionUpdate.body)}</pre>
|
||||
<MarkdownRender raw={versionUpdate.body} style={{ height: '200px' }} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
function stripFullChangelog(text) {
|
||||
if (text == null) {
|
||||
return '';
|
||||
}
|
||||
return text.replace(/(?:\r?\n)\*\*Full Changelog\*\*[\s\S]*$/u, '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { xhrGet } from '../xhr.js';
|
||||
import queryString from 'query-string';
|
||||
|
||||
const logger = (config) => (set, get, api) =>
|
||||
config(
|
||||
@@ -130,11 +131,35 @@ export const useFredyState = create(
|
||||
}
|
||||
},
|
||||
},
|
||||
listingsTable: {
|
||||
async getListingsTable({ page = 1, pageSize = 20, filter = null, sortfield = null, sortdir = 'asc' }) {
|
||||
try {
|
||||
const qryString = queryString.stringify({
|
||||
page,
|
||||
pageSize,
|
||||
filter,
|
||||
sortfield,
|
||||
sortdir,
|
||||
});
|
||||
const response = await xhrGet(`/api/listings/table?${qryString}`);
|
||||
set((state) => ({
|
||||
listingsTable: { ...state.listingsTable, ...response.json },
|
||||
}));
|
||||
} catch (Exception) {
|
||||
console.error('Error while trying to get resource for api/listings. Error:', Exception);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Initial state
|
||||
const initial = {
|
||||
notificationAdapter: [],
|
||||
listingsTable: {
|
||||
totalNumber: 0,
|
||||
page: 1,
|
||||
result: [],
|
||||
},
|
||||
generalSettings: { settings: {} },
|
||||
demoMode: { demoMode: false },
|
||||
versionUpdate: {},
|
||||
@@ -149,6 +174,7 @@ export const useFredyState = create(
|
||||
generalSettings: { ...effects.generalSettings },
|
||||
demoMode: { ...effects.demoMode },
|
||||
versionUpdate: { ...effects.versionUpdate },
|
||||
listingsTable: { ...effects.listingsTable },
|
||||
provider: { ...effects.provider },
|
||||
jobs: { ...effects.jobs },
|
||||
user: { ...effects.user },
|
||||
|
||||
@@ -186,7 +186,7 @@ const GeneralSettings = function GeneralSettings() {
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart
|
||||
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}
|
||||
>
|
||||
<div className="generalSettings__timePickerContainer">
|
||||
|
||||
@@ -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>
|
||||
<SegmentPart name="Name">
|
||||
<Input
|
||||
|
||||
11
ui/src/views/listings/Listings.jsx
Normal file
11
ui/src/views/listings/Listings.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
import ListingsTable from '../../components/table/ListingsTable.jsx';
|
||||
|
||||
export default function Listings() {
|
||||
return (
|
||||
<div>
|
||||
<ListingsTable />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
yarn.lock
108
yarn.lock
@@ -2197,10 +2197,10 @@ basic-ftp@^5.0.2:
|
||||
resolved "https://registry.yarnpkg.com/basic-ftp/-/basic-ftp-5.0.5.tgz#14a474f5fffecca1f4f406f1c26b18f800225ac0"
|
||||
integrity sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==
|
||||
|
||||
better-sqlite3@^12.3.0:
|
||||
version "12.3.0"
|
||||
resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-12.3.0.tgz#999817506ed9d985604ae053b5e5fe3c8a052bb1"
|
||||
integrity sha512-FFf+rsghyvXQIPV/6PDUj05EsuZA1b0drGLzNgtrELkXnJKUH6NNM2h7Ce7dkA6vvPOM4SOoUIDGRPy3yRKmqw==
|
||||
better-sqlite3@^12.4.1:
|
||||
version "12.4.1"
|
||||
resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-12.4.1.tgz#f78df6c80530d1a0b750b538033e6199b7d30d26"
|
||||
integrity sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==
|
||||
dependencies:
|
||||
bindings "^1.5.0"
|
||||
prebuild-install "^7.1.1"
|
||||
@@ -2451,10 +2451,10 @@ chownr@^1.1.1:
|
||||
resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
|
||||
integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
|
||||
|
||||
chromium-bidi@8.0.0:
|
||||
version "8.0.0"
|
||||
resolved "https://registry.yarnpkg.com/chromium-bidi/-/chromium-bidi-8.0.0.tgz#d73c9beed40317adf2bcfeb9a47087003cd467ec"
|
||||
integrity sha512-d1VmE0FD7lxZQHzcDUCKZSNRtRwISXDsdg4HjdTR5+Ll5nQ/vzU12JeNmupD6VWffrPSlrnGhEWlLESKH3VO+g==
|
||||
chromium-bidi@9.1.0:
|
||||
version "9.1.0"
|
||||
resolved "https://registry.yarnpkg.com/chromium-bidi/-/chromium-bidi-9.1.0.tgz#356eaea018eecc7977644305ee9fd27874b2b676"
|
||||
integrity sha512-rlUzQ4WzIAWdIbY/viPShhZU2n21CxDUgazXVbw4Hu1MwaeUSEksSeM6DqPgpRjCLXRk702AVRxJxoOz0dw4OA==
|
||||
dependencies:
|
||||
mitt "^3.0.1"
|
||||
zod "^3.24.1"
|
||||
@@ -2538,16 +2538,16 @@ comma-separated-tokens@^2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz#4e89c9458acb61bc8fef19f4529973b2392839ee"
|
||||
integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==
|
||||
|
||||
commander@14.0.1:
|
||||
version "14.0.1"
|
||||
resolved "https://registry.yarnpkg.com/commander/-/commander-14.0.1.tgz#2f9225c19e6ebd0dc4404dd45821b2caa17ea09b"
|
||||
integrity sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==
|
||||
|
||||
commander@2:
|
||||
version "2.20.3"
|
||||
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
|
||||
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
|
||||
|
||||
commander@^14.0.1:
|
||||
version "14.0.1"
|
||||
resolved "https://registry.yarnpkg.com/commander/-/commander-14.0.1.tgz#2f9225c19e6ebd0dc4404dd45821b2caa17ea09b"
|
||||
integrity sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==
|
||||
|
||||
compute-scroll-into-view@^1.0.20:
|
||||
version "1.0.20"
|
||||
resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz#1768b5522d1172754f5d0c9b02de3af6be506a43"
|
||||
@@ -4559,20 +4559,20 @@ lines-and-columns@^1.1.6:
|
||||
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
|
||||
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
|
||||
|
||||
lint-staged@16.2.0:
|
||||
version "16.2.0"
|
||||
resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-16.2.0.tgz#ea7157bf007bdb50d2bb0559bc91c8e77d71c84b"
|
||||
integrity sha512-spdYSOCQ2MdZ9CM1/bu/kDmaYGsrpNOeu1InFFV8uhv14x6YIubGxbCpSmGILFoxkiheNQPDXSg5Sbb5ZuVnug==
|
||||
lint-staged@16.2.1:
|
||||
version "16.2.1"
|
||||
resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-16.2.1.tgz#bb82da8ce10059296b220f321980f0ee1ce40c28"
|
||||
integrity sha512-KMeYmH9wKvHsXdUp+z6w7HN3fHKHXwT1pSTQTYxB9kI6ekK1rlL3kLZEoXZCppRPXFK9PFW/wfQctV7XUqMrPQ==
|
||||
dependencies:
|
||||
commander "14.0.1"
|
||||
listr2 "9.0.4"
|
||||
micromatch "4.0.8"
|
||||
nano-spawn "1.0.3"
|
||||
pidtree "0.6.0"
|
||||
string-argv "0.3.2"
|
||||
yaml "2.8.1"
|
||||
commander "^14.0.1"
|
||||
listr2 "^9.0.4"
|
||||
micromatch "^4.0.8"
|
||||
nano-spawn "^1.0.3"
|
||||
pidtree "^0.6.0"
|
||||
string-argv "^0.3.2"
|
||||
yaml "^2.8.1"
|
||||
|
||||
listr2@9.0.4:
|
||||
listr2@^9.0.4:
|
||||
version "9.0.4"
|
||||
resolved "https://registry.yarnpkg.com/listr2/-/listr2-9.0.4.tgz#2916e633ae6e09d1a3f981172937ac1c5a8fa64f"
|
||||
integrity sha512-1wd/kpAdKRLwv7/3OKC8zZ5U8e/fajCfWMxacUvB79S5nLrYGPtUI/8chMQhn3LQjsRVErTb9i1ECAwW0ZIHnQ==
|
||||
@@ -5271,7 +5271,7 @@ micromark@^4.0.0:
|
||||
micromark-util-symbol "^2.0.0"
|
||||
micromark-util-types "^2.0.0"
|
||||
|
||||
micromatch@4.0.8:
|
||||
micromatch@^4.0.8:
|
||||
version "4.0.8"
|
||||
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
|
||||
integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
|
||||
@@ -5401,15 +5401,15 @@ ms@^2.1.1, ms@^2.1.3:
|
||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
|
||||
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
|
||||
|
||||
nano-spawn@1.0.3:
|
||||
nano-spawn@^1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/nano-spawn/-/nano-spawn-1.0.3.tgz#ef8d89a275eebc8657e67b95fc312a6527a05b8d"
|
||||
integrity sha512-jtpsQDetTnvS2Ts1fiRdci5rx0VYws5jGyC+4IYOTnIQ/wwdf6JdomlHBwqC3bJYOvaKu0C2GSZ1A60anrYpaA==
|
||||
|
||||
nanoid@5.1.5:
|
||||
version "5.1.5"
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.1.5.tgz#f7597f9d9054eb4da9548cdd53ca70f1790e87de"
|
||||
integrity sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==
|
||||
nanoid@5.1.6:
|
||||
version "5.1.6"
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.1.6.tgz#30363f664797e7d40429f6c16946d6bd7a3f26c9"
|
||||
integrity sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==
|
||||
|
||||
nanoid@^3.3.11:
|
||||
version "3.3.11"
|
||||
@@ -5817,7 +5817,7 @@ picomatch@^4.0.3:
|
||||
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042"
|
||||
integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
|
||||
|
||||
pidtree@0.6.0:
|
||||
pidtree@^0.6.0:
|
||||
version "0.6.0"
|
||||
resolved "https://registry.yarnpkg.com/pidtree/-/pidtree-0.6.0.tgz#90ad7b6d42d5841e69e0a2419ef38f8883aa057c"
|
||||
integrity sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==
|
||||
@@ -5962,13 +5962,13 @@ punycode@^2.1.0:
|
||||
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
|
||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||
|
||||
puppeteer-core@24.22.0:
|
||||
version "24.22.0"
|
||||
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.22.0.tgz#4d576b1a2b7699c088d3f0e843c32d81df82c3a6"
|
||||
integrity sha512-oUeWlIg0pMz8YM5pu0uqakM+cCyYyXkHBxx9di9OUELu9X9+AYrNGGRLK9tNME3WfN3JGGqQIH3b4/E9LGek/w==
|
||||
puppeteer-core@24.22.3:
|
||||
version "24.22.3"
|
||||
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.22.3.tgz#63285a37da6e2c44069c0b31f2171f8ab81bbe23"
|
||||
integrity sha512-M/Jhg4PWRANSbL/C9im//Yb55wsWBS5wdp+h59iwM+EPicVQQCNs56iC5aEAO7avfDPRfxs4MM16wHjOYHNJEw==
|
||||
dependencies:
|
||||
"@puppeteer/browsers" "2.10.10"
|
||||
chromium-bidi "8.0.0"
|
||||
chromium-bidi "9.1.0"
|
||||
debug "^4.4.3"
|
||||
devtools-protocol "0.0.1495869"
|
||||
typed-query-selector "^2.12.0"
|
||||
@@ -6022,16 +6022,16 @@ puppeteer-extra@^3.3.6:
|
||||
debug "^4.1.1"
|
||||
deepmerge "^4.2.2"
|
||||
|
||||
puppeteer@^24.22.0:
|
||||
version "24.22.0"
|
||||
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.22.0.tgz#9f6905e9c3d5c316c364adb598903a1dfbfe800f"
|
||||
integrity sha512-QabGIvu7F0hAMiKGHZCIRHMb6UoH0QAJA2OaqxEU2tL5noXPrxUcotg2l3ttOA4p1PFnVIGkr6PXRAWlM2evVQ==
|
||||
puppeteer@^24.22.3:
|
||||
version "24.22.3"
|
||||
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.22.3.tgz#07dcfabdb4e924b014cb7b96bcc92f43086e637e"
|
||||
integrity sha512-mnhXzIqSYSJ1SMv1RYH07YMzWP81xCmmQj91Q8iQMZqnf97eVzeHgsGL6kpywiGCi+nQafta/+NkwM4URMy/XQ==
|
||||
dependencies:
|
||||
"@puppeteer/browsers" "2.10.10"
|
||||
chromium-bidi "8.0.0"
|
||||
chromium-bidi "9.1.0"
|
||||
cosmiconfig "^9.0.0"
|
||||
devtools-protocol "0.0.1495869"
|
||||
puppeteer-core "24.22.0"
|
||||
puppeteer-core "24.22.3"
|
||||
typed-query-selector "^2.12.0"
|
||||
|
||||
qs@^6.14.0:
|
||||
@@ -6121,17 +6121,17 @@ react-resizable@^3.0.5:
|
||||
prop-types "15.x"
|
||||
react-draggable "^4.0.3"
|
||||
|
||||
react-router-dom@7.9.1:
|
||||
version "7.9.1"
|
||||
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.9.1.tgz#48044923701773da6362f9003ec46f308f293f15"
|
||||
integrity sha512-U9WBQssBE9B1vmRjo9qTM7YRzfZ3lUxESIZnsf4VjR/lXYz9MHjvOxHzr/aUm4efpktbVOrF09rL/y4VHa8RMw==
|
||||
react-router-dom@7.9.2:
|
||||
version "7.9.2"
|
||||
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.9.2.tgz#2bb35d226ca23329f4e39c8f86d1db26ee4fdf26"
|
||||
integrity sha512-pagqpVJnjZOfb+vIM23eTp7Sp/AAJjOgaowhP1f1TWOdk5/W8Uk8d/M/0wfleqx7SgjitjNPPsKeCZE1hTSp3w==
|
||||
dependencies:
|
||||
react-router "7.9.1"
|
||||
react-router "7.9.2"
|
||||
|
||||
react-router@7.9.1:
|
||||
version "7.9.1"
|
||||
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.9.1.tgz#b227410c31f24dd416c939ca5d0f8d5c8a1404d4"
|
||||
integrity sha512-pfAByjcTpX55mqSDGwGnY9vDCpxqBLASg0BMNAuMmpSGESo/TaOUG6BllhAtAkCGx8Rnohik/XtaqiYUJtgW2g==
|
||||
react-router@7.9.2:
|
||||
version "7.9.2"
|
||||
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.9.2.tgz#f424a14f87e4d7b5b268ce3647876e9504e4fca6"
|
||||
integrity sha512-i2TPp4dgaqrOqiRGLZmqh2WXmbdFknUyiCRmSKs0hf6fWXkTKg5h56b+9F22NbGRAMxjYfqQnpi63egzD2SuZA==
|
||||
dependencies:
|
||||
cookie "^1.0.1"
|
||||
set-cookie-parser "^2.6.0"
|
||||
@@ -6835,7 +6835,7 @@ streamx@^2.15.0, streamx@^2.21.0:
|
||||
optionalDependencies:
|
||||
bare-events "^2.2.0"
|
||||
|
||||
string-argv@0.3.2:
|
||||
string-argv@^0.3.2:
|
||||
version "0.3.2"
|
||||
resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6"
|
||||
integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==
|
||||
@@ -7583,7 +7583,7 @@ yallist@^3.0.2:
|
||||
resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"
|
||||
integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==
|
||||
|
||||
yaml@2.8.1:
|
||||
yaml@^2.8.1:
|
||||
version "2.8.1"
|
||||
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.1.tgz#1870aa02b631f7e8328b93f8bc574fac5d6c4d79"
|
||||
integrity sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==
|
||||
|
||||
Reference in New Issue
Block a user