Compare commits

..

10 Commits

Author SHA1 Message Date
orangecoding
522bbc2282 upgrade dependencies 2026-04-16 12:07:22 +02:00
Adrian Bartnik
c384781137 Add toggle for plain text message to telegram notification adapter (#299) 2026-04-16 12:05:57 +02:00
orangecoding
e2d10d179e next release version 2026-04-12 09:21:08 +02:00
Stephan
10c94eea0a Feature/spec filter (#276)
* feat(): create map component, add area filtering to the job config

* feat(): filter listings by area filter

* chore(): cleanup

* feat(): solve feedback

* feat(): solve most providers

* feat(): solve maybe other providers

* feat(): add specFilter config, also add rooms to listing

* feat(): change tests

* feat(): fix kleinanzeigen parser

* feat(): add spec filter switch for listing overviiews

* feat(): add rooms and size to the overview and detail of a listing

* feat(): rem label

* feat(): add types, update providers, they now return specs as numbers

* feat(): add jsonconfig to enable type checks

* feat: add type for prividerConfig, add fieldNames per provider

* feat: fix tests, provider, add formatListing

* chore: remov duplicates

* feat(): fix tests

* feat: fix immoscout

* chore: geojson typing

* feat: solve requested changes
2026-04-12 09:17:23 +02:00
orangecoding
05f74f99ef adding tool to receive photo of listing 2026-04-09 11:51:42 +02:00
orangecoding
f3ad529107 fixing migration file 2026-04-07 20:22:16 +02:00
orangecoding
791822e7c8 next release version 2026-04-07 19:55:33 +02:00
Christian Kellner
cdc0cbda2f Feature/kleinanzeigen new (#292)
* Feature/Kleinanzeigen addresses (#289)

* upgrade dependencies

* immoscout_details -> provider_details

* fetching details more generic

* removing claude action

* fixing sparkassen selector

* improvements

* fixing immobilienDE test

* upgrading dependencies

* settings for many provider

---------

Co-authored-by: Adrian Bach <65734063+realDayaa@users.noreply.github.com>
2026-04-07 19:53:40 +02:00
Adrian Bach
7888c5b340 fix: broken filters (#294) 2026-04-04 12:26:34 +02:00
orangecoding
d7f46d6c68 security update 2026-03-31 13:33:01 +02:00
72 changed files with 2509 additions and 900 deletions

View File

@@ -1,44 +0,0 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options

View File

@@ -1,50 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'

12
jsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ESNext",
"checkJs": true,
"allowJs": true,
"noEmit": true,
"strict": false
},
"exclude": ["node_modules", "ui"]
}

View File

@@ -19,22 +19,14 @@ import { distanceMeters } from './services/listings/distanceCalculator.js';
import { getUserSettings } from './services/storage/settingsStorage.js';
import { updateListingDistance } from './services/storage/listingsStorage.js';
import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
import { formatListing } from './utils/formatListing.js';
/**
* @typedef {Object} Listing
* @property {string} id Stable unique identifier (hash) of the listing.
* @property {string} title Title or headline of the listing.
* @property {string} [address] Optional address/location text.
* @property {string} [price] Optional price text/value.
* @property {string} [url] Link to the listing detail page.
* @property {any} [meta] Provider-specific additional metadata.
*/
/**
* @typedef {Object} SimilarityCache
* @property {(title:string, address?:string)=>boolean} hasSimilarEntries Returns true if a similar entry is known.
* @property {(title:string, address?:string)=>void} addCacheEntry Adds a new entry to the similarity cache.
*/
/** @import { ParsedListing } from './types/listing.js' */
/** @import { Job } from './types/job.js' */
/** @import { ProviderConfig } from './types/providerConfig.js' */
/** @import { SpecFilter, SpatialFilter } from './types/filter.js' */
/** @import { SimilarityCache } from './types/similarityCache.js' */
/** @import { Browser } from './types/browser.js' */
/**
* Runtime orchestrator for fetching, normalizing, filtering, deduplicating, storing,
@@ -48,42 +40,43 @@ import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
* 5) Identify new listings (vs. previously stored hashes)
* 6) Persist new listings
* 7) Filter out entries similar to already seen ones
* 8) Dispatch notifications
* 8) Filter out entries that do not match the job's specFilter
* 9) Filter out entries that do not match the job's spatialFilter
* 10) Dispatch notifications
*/
class FredyPipelineExecutioner {
/**
* Create a new runtime instance for a single provider/job execution.
*
* @param {Object} providerConfig Provider configuration.
* @param {string} providerConfig.url Base URL to crawl.
* @param {string} [providerConfig.sortByDateParam] Query parameter used to enforce sorting by date (provider-specific).
* @param {string} [providerConfig.waitForSelector] CSS selector to wait for before parsing content.
* @param {Object.<string, string>} providerConfig.crawlFields Mapping of field names to selectors/paths to extract.
* @param {string} providerConfig.crawlContainer CSS selector for the container holding listing items.
* @param {(raw:any)=>Listing} providerConfig.normalize Function to convert raw scraped data into a Listing shape.
* @param {(listing:Listing)=>boolean} providerConfig.filter Function to filter out unwanted listings.
* @param {(url:string, waitForSelector?:string)=>Promise<void>|Promise<Listing[]>} [providerConfig.getListings] Optional override to fetch listings.
* @param {Object} notificationConfig Notification configuration passed to notification adapters.
* @param {Object} spatialFilter Optional spatial filter configuration.
* @param {ProviderConfig} providerConfig Provider configuration.
* @param {Job} job Job configuration.
* @param {string} providerId The ID of the provider currently in use.
* @param {string} jobKey Key of the job that is currently running (from within the config).
* @param {SimilarityCache} similarityCache Cache instance for checking similar entries.
* @param browser
* @param {Browser} browser Puppeteer browser instance.
*/
constructor(providerConfig, notificationConfig, spatialFilter, providerId, jobKey, similarityCache, browser) {
constructor(providerConfig, job, providerId, similarityCache, browser) {
/** @type {ProviderConfig} */
this._providerConfig = providerConfig;
this._notificationConfig = notificationConfig;
this._spatialFilter = spatialFilter;
/** @type {Object} */
this._jobNotificationConfig = job.notificationAdapter;
/** @type {string} */
this._jobKey = job.id;
/** @type {SpecFilter | null} */
this._jobSpecFilter = job.specFilter;
/** @type {SpatialFilter | null} */
this._jobSpatialFilter = job.spatialFilter;
/** @type {string} */
this._providerId = providerId;
this._jobKey = jobKey;
/** @type {SimilarityCache} */
this._similarityCache = similarityCache;
/** @type {Browser} */
this._browser = browser;
}
/**
* Execute the end-to-end pipeline for a single provider run.
*
* @returns {Promise<Listing[]|void>} Resolves to the list of new (and similarity-filtered) listings
* @returns {Promise<ParsedListing[]|void>} Resolves to the list of new (and similarity-filtered) listings
* after notifications have been sent; resolves to void when there are no new listings.
*/
execute() {
@@ -92,20 +85,48 @@ class FredyPipelineExecutioner {
.then(this._normalize.bind(this))
.then(this._filter.bind(this))
.then(this._findNew.bind(this))
.then(this._fetchDetails.bind(this))
.then(this._geocode.bind(this))
.then(this._save.bind(this))
.then(this._calculateDistance.bind(this))
.then(this._filterBySimilarListings.bind(this))
.then(this._filterBySpecs.bind(this))
.then(this._filterByArea.bind(this))
.then(this._notify.bind(this))
.catch(this._handleError.bind(this));
}
/**
* Optionally enrich new listings with data from their detail pages.
* Only called when the provider config defines a `fetchDetails` function.
* Runs all fetches in parallel. Each individual fetch must handle its own errors
* and always resolve (never reject) to avoid aborting other listings.
*
* @param {Listing[]} newListings New listings to enrich.
* @returns {Promise<Listing[]>} Resolves with enriched listings.
*/
async _fetchDetails(newListings) {
if (typeof this._providerConfig.fetchDetails !== 'function') {
return newListings;
}
const userId = getJob(this._jobKey)?.userId;
const enabledProviders = getUserSettings(userId)?.provider_details ?? [];
if (!userId || !Array.isArray(enabledProviders) || !enabledProviders.includes(this._providerId)) {
return newListings;
}
const listingsToEnrich = process.env.NODE_ENV === 'test' ? newListings.slice(0, 1) : newListings;
const enriched = [];
for (const listing of listingsToEnrich) {
enriched.push(await this._providerConfig.fetchDetails(listing, this._browser));
}
return enriched;
}
/**
* Geocode new listings.
*
* @param {Listing[]} newListings New listings to geocode.
* @returns {Promise<Listing[]>} Resolves with the listings (potentially with added coordinates).
* @param {ParsedListing[]} newListings New listings to geocode.
* @returns {Promise<ParsedListing[]>} Resolves with the listings (potentially with added coordinates).
*/
async _geocode(newListings) {
for (const listing of newListings) {
@@ -124,18 +145,18 @@ class FredyPipelineExecutioner {
* Filter listings by area using the provider's area filter if available.
* Only filters if areaFilter is set on the provider AND the listing has coordinates.
*
* @param {Listing[]} newListings New listings to filter by area.
* @returns {Promise<Listing[]>} Resolves with listings that are within the area (or not filtered if no area is set).
* @param {ParsedListing[]} newListings New listings to filter by area.
* @returns {ParsedListing[]} Resolves with listings that are within the area (or not filtered if no area is set).
*/
_filterByArea(newListings) {
const polygonFeatures = this._spatialFilter?.features?.filter((f) => f.geometry?.type === 'Polygon');
const polygonFeatures = this._jobSpatialFilter?.features?.filter((f) => f.geometry?.type === 'Polygon');
// If no area filter is set, return all listings
if (!polygonFeatures?.length) {
return newListings;
}
const filteredIds = [];
const toDeleteListingByIds = [];
// Filter listings by area - keep only those within the polygon
const keptListings = newListings.filter((listing) => {
// If listing doesn't have coordinates, keep it (don't filter out)
@@ -148,14 +169,48 @@ class FredyPipelineExecutioner {
const isInPolygon = polygonFeatures.some((feature) => booleanPointInPolygon(point, feature));
if (!isInPolygon) {
filteredIds.push(listing.id);
toDeleteListingByIds.push(listing.id);
}
return isInPolygon;
});
if (filteredIds.length > 0) {
deleteListingsById(filteredIds);
if (toDeleteListingByIds.length > 0) {
deleteListingsById(toDeleteListingByIds);
}
return keptListings;
}
/**
* Filter listings based on its specifications (minRooms, minSize, maxPrice).
*
* @param {ParsedListing[]} newListings New listings to filter.
* @returns {ParsedListing[]} Resolves with listings that pass the specification filters.
*/
_filterBySpecs(newListings) {
const { minRooms, minSize, maxPrice } = this._jobSpecFilter || {};
// If no specs are set, return all listings
if (!minRooms && !minSize && !maxPrice) {
return newListings;
}
const toDeleteListingByIds = [];
const keptListings = newListings.filter((listing) => {
const filterOut =
(minRooms && listing.rooms && listing.rooms < minRooms) ||
(minSize && listing.size && listing.size < minSize) ||
(maxPrice && listing.price && listing.price > maxPrice);
if (filterOut) {
toDeleteListingByIds.push(listing.id);
}
return !filterOut;
});
if (toDeleteListingByIds.length > 0) {
deleteListingsById(toDeleteListingByIds);
}
return keptListings;
@@ -166,7 +221,7 @@ class FredyPipelineExecutioner {
* a provider-specific getListings override is supplied.
*
* @param {string} url The provider URL to fetch from.
* @returns {Promise<Listing[]>} Resolves with an array of listings (empty when none found).
* @returns {Promise<ParsedListing[]>} Resolves with an array of listings (empty when none found).
*/
_getListings(url) {
const extractor = new Extractor({ ...this._providerConfig.puppeteerOptions, browser: this._browser });
@@ -189,33 +244,42 @@ class FredyPipelineExecutioner {
}
/**
* Normalize raw listings into the provider-specific Listing shape.
* Normalize raw listings into the provider-specific ParsedListing shape.
*
* @param {any[]} listings Raw listing entries from the extractor or override.
* @returns {Listing[]} Normalized listings.
* @returns {ParsedListing[]} Normalized listings.
*/
_normalize(listings) {
return listings.map(this._providerConfig.normalize);
return listings.map((listing) => this._providerConfig.normalize(listing));
}
/**
* Filter out listings that are missing required fields and those rejected by the
* provider's blacklist/filter function.
*
* @param {Listing[]} listings Listings to filter.
* @returns {Listing[]} Filtered listings that pass validation and provider filter.
* @param {ParsedListing[]} listings Listings to filter.
* @returns {ParsedListing[]} Filtered listings that pass validation and provider filter.
*/
_filter(listings) {
const keys = Object.keys(this._providerConfig.crawlFields);
const filteredListings = listings.filter((item) => keys.every((key) => key in item));
return filteredListings.filter(this._providerConfig.filter);
const requiredKeys = this._providerConfig.requiredFieldNames;
const requireValues = ['id', 'link', 'title'];
const filteredListings = listings
// this should never filter some listings out, because the normalize function should always extract all fields.
.filter((item) => requiredKeys.every((key) => key in item))
// TODO: move blacklist filter to this file, so it will handle for all providers in same way.
.filter(this._providerConfig.filter)
// filter out listings that are missing required fields
.filter((item) => requireValues.every((key) => item[key] != null));
return filteredListings;
}
/**
* Determine which listings are new by comparing their IDs against stored hashes.
*
* @param {Listing[]} listings Listings to evaluate for novelty.
* @returns {Listing[]} New listings not seen before.
* @param {ParsedListing[]} listings Listings to evaluate for novelty.
* @returns {ParsedListing[]} New listings not seen before.
* @throws {NoNewListingsWarning} When no new listings are found.
*/
_findNew(listings) {
@@ -232,23 +296,30 @@ class FredyPipelineExecutioner {
/**
* Send notifications for new listings using the configured notification adapter(s).
*
* @param {Listing[]} newListings New listings to notify about.
* @returns {Promise<Listing[]>} Resolves to the provided listings after notifications complete.
* @param {ParsedListing[]} newListings New listings to notify about.
* @returns {Promise<ParsedListing[]>} Resolves to the provided listings after notifications complete.
* @throws {NoNewListingsWarning} When there are no listings to notify about.
*/
_notify(newListings) {
if (newListings.length === 0) {
throw new NoNewListingsWarning();
}
const sendNotifications = notify.send(this._providerId, newListings, this._notificationConfig, this._jobKey);
// TODO: move this to the notification adapter, so it will handle for all providers in same way.
const formattedListings = newListings.map(formatListing);
const sendNotifications = notify.send(
this._providerId,
formattedListings,
this._jobNotificationConfig,
this._jobKey,
);
return Promise.all(sendNotifications).then(() => newListings);
}
/**
* Persist new listings and pass them through.
*
* @param {Listing[]} newListings Listings to store.
* @returns {Listing[]} The same listings, unchanged.
* @param {ParsedListing[]} newListings Listings to store.
* @returns {ParsedListing[]} The same listings, unchanged.
*/
_save(newListings) {
logger.debug(`Storing ${newListings.length} new listings (Provider: '${this._providerId}')`);
@@ -259,8 +330,8 @@ class FredyPipelineExecutioner {
/**
* Calculate distance for new listings.
*
* @param {Listing[]} listings
* @returns {Listing[]}
* @param {ParsedListing[]} listings
* @returns {ParsedListing[]}
* @private
*/
_calculateDistance(listings) {
@@ -296,8 +367,8 @@ class FredyPipelineExecutioner {
* Remove listings that are similar to already known entries according to the similarity cache.
* Adds the remaining listings to the cache.
*
* @param {Listing[]} listings Listings to filter by similarity.
* @returns {Listing[]} Listings considered unique enough to keep.
* @param {ParsedListing[]} listings Listings to filter by similarity.
* @returns {ParsedListing[]} Listings considered unique enough to keep.
*/
_filterBySimilarListings(listings) {
const filteredIds = [];

View File

@@ -20,7 +20,7 @@ import { getDirName } from '../utils.js';
import { demoRouter } from './routes/demoRouter.js';
import logger from '../services/logger.js';
import { listingsRouter } from './routes/listingsRouter.js';
import { getSettings } from '../services/storage/settingsStorage.js';
import { getSettings, getOrCreateSessionSecret } from '../services/storage/settingsStorage.js';
import { dashboardRouter } from './routes/dashboardRouter.js';
import { backupRouter } from './routes/backupRouter.js';
import { trackingRouter } from './routes/trackingRoute.js';
@@ -28,9 +28,10 @@ import { registerMcpRoutes } from '../mcp/mcpHttpRoute.js';
const service = restana();
const staticService = files(path.join(getDirName(), '../ui/public'));
const PORT = (await getSettings()).port || 9998;
const sessionSecret = await getOrCreateSessionSecret();
service.use(bodyParser.json());
service.use(cookieSession());
service.use(cookieSession(sessionSecret));
service.use(staticService);
service.use('/api/admin', authInterceptor());
service.use('/api/jobs', authInterceptor());

View File

@@ -172,6 +172,7 @@ jobRouter.post('/', async (req, res) => {
enabled,
shareWithUsers = [],
spatialFilter = null,
specFilter = null,
} = req.body;
const settings = await getSettings();
try {
@@ -197,6 +198,7 @@ jobRouter.post('/', async (req, res) => {
notificationAdapter,
shareWithUsers,
spatialFilter,
specFilter,
});
} catch (error) {
res.send(new Error(error));

View File

@@ -9,6 +9,27 @@ import * as hasher from '../../services/security/hash.js';
import { trackDemoAccessed } from '../../services/tracking/Tracker.js';
import logger from '../../services/logger.js';
import { getSettings } from '../../services/storage/settingsStorage.js';
const MAX_LOGIN_ATTEMPTS = 10;
const LOGIN_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
const loginAttempts = new Map(); // ip -> { count, firstAttempt }
function getClientIp(req) {
const forwarded = req.headers['x-forwarded-for'];
return (forwarded ? forwarded.split(',')[0] : req.socket?.remoteAddress) || 'unknown';
}
function isRateLimited(ip) {
const now = Date.now();
const record = loginAttempts.get(ip);
if (!record || now - record.firstAttempt > LOGIN_WINDOW_MS) {
loginAttempts.set(ip, { count: 1, firstAttempt: now });
return false;
}
record.count++;
return record.count > MAX_LOGIN_ATTEMPTS;
}
const service = restana();
const loginRouter = service.newRouter();
loginRouter.get('/user', async (req, res) => {
@@ -25,6 +46,12 @@ loginRouter.get('/user', async (req, res) => {
res.send();
});
loginRouter.post('/', async (req, res) => {
const ip = getClientIp(req);
if (isRateLimited(ip)) {
logger.error(`Login rate limit exceeded for IP ${ip}`);
res.send(429);
return;
}
const settings = await getSettings();
const { username, password } = req.body;
const user = userStorage.getUsers(true).find((user) => user.username === username);
@@ -38,6 +65,8 @@ loginRouter.post('/', async (req, res) => {
}
req.session.currentUser = user.id;
req.session.createdAt = Date.now();
loginAttempts.delete(ip);
userStorage.setLastLoginToNow({ userId: user.id });
res.send(200);
return;

View File

@@ -97,9 +97,9 @@ userSettingsRouter.post('/news-hash', async (req, res) => {
}
});
userSettingsRouter.post('/immoscout-details', async (req, res) => {
userSettingsRouter.post('/provider-details', async (req, res) => {
const userId = req.session.currentUser;
const { immoscout_details } = req.body;
const { provider_details } = req.body;
const globalSettings = await getSettings();
if (globalSettings.demoMode) {
@@ -108,11 +108,17 @@ userSettingsRouter.post('/immoscout-details', async (req, res) => {
return;
}
if (!Array.isArray(provider_details)) {
res.statusCode = 400;
res.send({ error: 'provider_details must be an array of provider ids.' });
return;
}
try {
upsertSettings({ immoscout_details: !!immoscout_details }, userId);
upsertSettings({ provider_details }, userId);
res.send({ success: true });
} catch (error) {
logger.error('Error updating immoscout details setting', error);
logger.error('Error updating provider details setting', error);
res.statusCode = 500;
res.send({ error: error.message });
}

View File

@@ -5,12 +5,17 @@
import * as userStorage from '../services/storage/userStorage.js';
import cookieSession from 'cookie-session';
import { nanoid } from 'nanoid';
const SESSION_MAX_AGE = 2 * 60 * 60 * 1000; // 2 hours
const unauthorized = (res) => {
return res.send(401);
};
const isUnauthorized = (req) => {
return req.session.currentUser == null;
if (req.session.currentUser == null) return true;
if (Date.now() - req.session.createdAt > SESSION_MAX_AGE) {
req.session = null;
return true;
}
return false;
};
const isAdmin = (req) => {
if (!isUnauthorized(req)) {
@@ -37,12 +42,11 @@ const adminInterceptor = () => {
}
};
};
const cookieSession$0 = (userId) => {
const cookieSession$0 = (secret) => {
return cookieSession({
name: 'fredy-admin-session',
keys: ['fredy', 'super', 'fancy', 'key', nanoid()],
userId,
maxAge: 2 * 60 * 60 * 1000, // 2 hours
keys: [secret],
maxAge: SESSION_MAX_AGE,
});
};
export { cookieSession$0 as cookieSession };

View File

@@ -220,6 +220,122 @@ export function createMcpServer() {
},
);
// ── get_photo_for_listing ─────────────────────────────────────────────────────
server.tool(
'get_photo_for_listing',
'Fetch and return the photo of a listing by its ID as an image for vision analysis.',
{
listingId: z.string().describe('The listing ID whose photo to fetch'),
},
async ({ listingId }, extra) => {
const { user, error } = authenticateToolCall(extra, 'get_photo_for_listing');
if (error) return normalizeError(error, 'get_photo_for_listing');
const listing = getListingById(listingId, user.id, user.isAdmin);
if (!listing) {
return normalizeError('Listing not found or access denied.', 'get_photo_for_listing');
}
const imageUrl = listing.image_url;
if (!imageUrl) {
return normalizeError('No image available for this listing.', 'get_photo_for_listing');
}
const SUPPORTED_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
let response;
try {
response = await fetch(imageUrl, {
signal: AbortSignal.timeout(10_000),
headers: {
'User-Agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
Accept: 'image/jpeg,image/png,image/webp,image/gif,image/*,*/*',
},
});
} catch (fetchErr) {
return normalizeError(`Failed to fetch image: ${fetchErr.message}`, 'get_photo_for_listing');
}
if (!response.ok) {
return normalizeError(
`Image fetch returned HTTP ${response.status}. Image URL: ${imageUrl}`,
'get_photo_for_listing',
);
}
const contentType = response.headers.get('content-type') ?? '';
const headerMimeType = contentType.split(';')[0].trim().toLowerCase();
let buffer;
try {
buffer = await response.arrayBuffer();
} catch (readErr) {
return normalizeError(`Failed to read image body: ${readErr.message}`, 'get_photo_for_listing');
}
const bytes = new Uint8Array(buffer);
if (bytes.length < 12) {
return normalizeError(
`Downloaded file is too small to determine image type. Image URL: ${imageUrl}`,
'get_photo_for_listing',
);
}
let resolvedMime;
if (SUPPORTED_MIME_TYPES.has(headerMimeType)) {
resolvedMime = headerMimeType;
} else {
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
resolvedMime = 'image/jpeg';
} else if (
bytes[0] === 0x89 &&
bytes[1] === 0x50 &&
bytes[2] === 0x4e &&
bytes[3] === 0x47 &&
bytes[4] === 0x0d &&
bytes[5] === 0x0a &&
bytes[6] === 0x1a &&
bytes[7] === 0x0a
) {
resolvedMime = 'image/png';
} else if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) {
resolvedMime = 'image/gif';
} else if (
bytes[0] === 0x52 &&
bytes[1] === 0x49 &&
bytes[2] === 0x46 &&
bytes[3] === 0x46 &&
bytes[8] === 0x57 &&
bytes[9] === 0x45 &&
bytes[10] === 0x42 &&
bytes[11] === 0x50
) {
resolvedMime = 'image/webp';
} else {
return normalizeError(
`Image format not supported by vision models (header: ${headerMimeType || 'unknown'}). Image URL: ${imageUrl}`,
'get_photo_for_listing',
);
}
}
const base64 = Buffer.from(buffer).toString('base64');
return {
content: [
{
type: 'image',
data: base64,
mimeType: resolvedMime,
},
],
};
},
);
// ── get_current_date_ime ─────────────────────────────────────────────────────
server.tool('get_current_date_time', 'Returns the current date and time.', {}, () => {
return {

View File

@@ -105,6 +105,32 @@ function buildText(jobName, serviceName, o) {
);
}
/**
* Build a plain text Telegram photo caption (max 4096 characters).
* @param {string} jobName
* @param {string} serviceName
* @param {Object} o - Listing object
* @returns {string}
*/
function buildCaptionPlain(jobName, serviceName, o) {
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
return `${jobName} (${serviceName})\n${title}\n${meta}\n\n${o.link || ''}`.slice(0, 4096);
}
/**
* Build a plain text Telegram message.
* @param {string} jobName
* @param {string} serviceName
* @param {Object} o - Listing object
* @returns {string}
*/
function buildTextPlain(jobName, serviceName, o) {
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
return `${jobName} (${serviceName})\n${title}\n${o.link || ''}\n${meta}`;
}
/**
* Send new listings to Telegram.
* - Respects per-chat Telegram rate limits using a lightweight throttle cache.
@@ -122,7 +148,7 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
if (!adapterCfg || !adapterCfg.fields) {
throw new Error(`Telegram adapter configuration missing for job '${jobKey || ''}'`);
}
const { token, chatId, messageThreadId } = adapterCfg.fields;
const { token, chatId, messageThreadId, plainText } = adapterCfg.fields;
if (!token || !chatId) {
throw new Error("Telegram 'token' and 'chatId' must be provided in notification config");
}
@@ -163,8 +189,8 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
const img = normalizeImageUrl(o.image);
const textPayload = {
chat_id: chatId,
text: buildText(jobName, serviceName, o),
parse_mode: 'HTML',
text: plainText ? buildTextPlain(jobName, serviceName, o) : buildText(jobName, serviceName, o),
...(plainText ? {} : { parse_mode: 'HTML' }),
disable_web_page_preview: true,
...(message_thread_id ? { message_thread_id } : {}),
};
@@ -178,8 +204,8 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
return await throttledCall('sendPhoto', {
chat_id: chatId,
photo: img,
caption: buildCaption(jobName, serviceName, o),
parse_mode: 'HTML',
caption: plainText ? buildCaptionPlain(jobName, serviceName, o) : buildCaption(jobName, serviceName, o),
...(plainText ? {} : { parse_mode: 'HTML' }),
...(message_thread_id ? { message_thread_id } : {}),
}).catch(async (e) => {
logger.error(`Error sending photo to Telegram and use a fallback: ${e.message}`);
@@ -220,5 +246,11 @@ export const config = {
description:
'Optional: The topic/thread id within a supergroup to post into (Telegram message_thread_id). Provide a positive integer.',
},
plainText: {
type: 'boolean',
optional: true,
label: 'Send as plain text',
description: 'Send messages as plain text instead of HTML formatted.',
},
},
};

View File

@@ -5,8 +5,16 @@
import { buildHash, isOneOf } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
import { extractNumber } from '../utils/extract-number.js';
/** @import { ParsedListing } from '../types/listing.js' */
/** @import { ProviderConfig } from '../types/providerConfig.js' */
let appliedBlackList = [];
/**
* @param {any} o
* @returns {ParsedListing}
*/
function normalize(o) {
const baseUrl = 'https://www.1a-immobilienmarkt.de';
const link = `${baseUrl}/expose/${o.id}.html`;
@@ -14,7 +22,17 @@ function normalize(o) {
const id = buildHash(o.id, price);
const image = baseUrl + o.image;
const address = o.address == null ? null : o.address.trim().replaceAll('/', ',');
return Object.assign(o, { id, price, link, image, address });
return {
id,
link,
title: o.title || '',
price: extractNumber(price),
size: extractNumber(o.size),
rooms: extractNumber(o.rooms),
address,
image,
description: undefined,
};
}
/**
@@ -34,13 +52,19 @@ function normalizePrice(price) {
}
return result[0];
}
/**
* @param {ParsedListing} o
* @returns {boolean}
*/
function applyBlacklist(o) {
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
return titleNotBlacklisted && descNotBlacklisted;
}
/** @type {ProviderConfig} */
const config = {
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
url: null,
crawlContainer: '.tabelle',
sortByDateParam: 'sort_type=newest',
@@ -48,7 +72,8 @@ const config = {
crawlFields: {
id: '.inner_object_data input[name="marker_objekt_id"]@value | int',
price: '.inner_object_data .single_data_price | removeNewline | trim',
size: '.tabelle .tabelle_inhalt_infos .single_data_box | removeNewline | trim',
size: '.tabelle .tabelle_inhalt_infos .single_data_box:nth-of-type(1) | removeNewline | trim',
rooms: '.tabelle .tabelle_inhalt_infos .single_data_box:nth-of-type(2) | removeNewline | trim',
title: '.inner_object_data .tabelle_inhalt_titel_black | removeNewline | trim',
image: '.inner_object_pic img@src',
address: '.tabelle .tabelle_inhalt_infos .left_information > div:nth-child(2) | removeNewline | trim',

View File

@@ -5,6 +5,12 @@
import { buildHash, isOneOf } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
import { extractNumber } from '../utils/extract-number.js';
import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js';
import * as cheerio from 'cheerio';
import logger from '../services/logger.js';
/** @import { ParsedListing } from '../types/listing.js' */
/** @import { ProviderConfig } from '../types/providerConfig.js' */
let appliedBlackList = [];
@@ -18,42 +24,106 @@ function parseId(shortenedLink) {
return shortenedLink.substring(shortenedLink.lastIndexOf('/') + 1);
}
async function fetchDetails(listing, browser) {
try {
const html = await puppeteerExtractor(listing.link, null, { browser });
if (!html) return listing;
const $ = cheerio.load(html);
// Try JSON-LD first
let description = null;
let address = listing.address;
$('script[type="application/ld+json"]').each((_, el) => {
if (description) return;
try {
const data = JSON.parse($(el).text());
const nodes = Array.isArray(data) ? data : [data];
for (const node of nodes) {
if (node.description && !description) description = String(node.description).replace(/\s+/g, ' ').trim();
const addr = node.address || node?.mainEntity?.address;
if (addr && addr.streetAddress && address === listing.address) {
const parts = [addr.streetAddress, addr.postalCode, addr.addressLocality].filter(Boolean);
if (parts.length) address = parts.join(' ');
}
}
} catch {
// ignore malformed JSON-LD
}
});
// Fallback: common description selectors used by immobilien.de
if (!description) {
const sel = ['.beschreibung', '.freitext', '.objektbeschreibung', '.description'].find((s) => $(s).length > 0);
if (sel) description = $(sel).text().replace(/\s+/g, ' ').trim();
}
return {
...listing,
address,
description: description || listing.description,
};
} catch (error) {
logger.warn(`Could not fetch immobilien.de detail page for listing '${listing.id}'.`, error?.message || error);
return listing;
}
}
/**
* @param {any} o
* @returns {ParsedListing}
*/
function normalize(o) {
const baseUrl = 'https://www.immobilien.de';
const size = o.size || null;
const price = o.price || null;
const title = o.title || 'No title available';
const title = o.title || '';
const address = o.address || null;
const shortLink = shortenLink(o.link);
const link = baseUrl + shortLink;
const image = baseUrl + o.image;
const link = shortLink ? (shortLink.startsWith('http') ? shortLink : baseUrl + shortLink) : baseUrl;
const image = o.image ? (o.image.startsWith('http') ? o.image : baseUrl + o.image) : null;
const id = buildHash(parseId(shortLink), o.price);
return Object.assign(o, { id, price, size, title, address, link, image });
return {
id,
link,
title,
price: extractNumber(o.price),
size: extractNumber(o.size),
rooms: extractNumber(o.rooms),
address,
image,
description: o.description,
};
}
/**
* @param {ParsedListing} o
* @returns {boolean}
*/
function applyBlacklist(o) {
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
return titleNotBlacklisted && descNotBlacklisted;
}
/** @type {ProviderConfig} */
const config = {
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
url: null,
crawlContainer: 'a:has(div.list_entry)',
crawlContainer: 'a.lr-card',
sortByDateParam: 'sort_col=*created_ts&sort_dir=desc',
waitForSelector: 'body',
waitForSelector: null,
crawlFields: {
id: '@href', //will be transformed later
price: '.immo_preis .label_info',
size: '.flaeche .label_info | removeNewline | trim',
title: 'h3 span',
price: '.lr-card__price-amount | trim',
size: '.lr-card__fact:has(.lr-card__fact-label:contains("Fläche")) .lr-card__fact-value | trim',
rooms: '.zimmer .label_info',
title: '.lr-card__title | trim',
description: '.description | trim',
link: '@href',
address: '.place',
image: 'img@src',
address: '.lr-card__address span | trim',
image: 'img.lr-card__gallery-img@src',
},
normalize: normalize,
normalize,
filter: applyBlacklist,
fetchDetails,
activeTester: checkIfListingIsActive,
};
export const init = (sourceConfig, blacklist) => {

View File

@@ -46,9 +46,11 @@ import {
convertWebToMobile,
} from '../services/immoscout/immoscout-web-translator.js';
import logger from '../services/logger.js';
import { getUserSettings } from '../services/storage/settingsStorage.js';
import { extractNumber } from '../utils/extract-number.js';
/** @import { ParsedListing } from '../types/listing.js' */
/** @import { ProviderConfig } from '../types/providerConfig.js' */
let appliedBlackList = [];
let currentUserId = null;
async function getListings(url) {
const response = await fetch(url, {
@@ -68,42 +70,40 @@ async function getListings(url) {
}
const responseBody = await response.json();
return Promise.all(
responseBody.resultListItems
.filter((item) => item.type === 'EXPOSE_RESULT')
.map(async (expose) => {
const item = expose.item;
const [price, size] = item.attributes;
const image = item?.titlePicture?.full ?? item?.titlePicture?.preview ?? null;
let listing = {
id: item.id,
price: price?.value,
size: size?.value,
title: item.title,
link: `${metaInformation.baseUrl}expose/${item.id}`,
address: item.address?.line,
image,
};
if (currentUserId) {
const userSettings = getUserSettings(currentUserId);
if (userSettings.immoscout_details) {
return await pushDetails(listing);
}
}
return listing;
}),
);
return responseBody.resultListItems
.filter((item) => item.type === 'EXPOSE_RESULT')
.map((expose) => {
const item = expose.item;
const [price, size] = item.attributes;
const image = item?.titlePicture?.full ?? item?.titlePicture?.preview ?? null;
return {
id: item.id,
price: price?.value,
size: size?.value,
title: item.title,
link: `${metaInformation.baseUrl}expose/${item.id}`,
address: item.address?.line,
image,
};
});
}
async function fetchDetails(listing) {
return pushDetails(listing);
}
async function pushDetails(listing) {
const detailed = await fetch(`https://api.mobile.immobilienscout24.de/expose/${listing.id}`, {
const exposeId = listing.link?.split('/').pop();
const detailed = await fetch(`https://api.mobile.immobilienscout24.de/expose/${exposeId}`, {
headers: {
'User-Agent': 'ImmoScout_27.3_26.0_._',
'Content-Type': 'application/json',
},
});
if (!detailed.ok) {
logger.error('Error fetching listing details from ImmoScout Mobile API:', detailed.statusText);
logger.warn(
`Error fetching listing details from ImmoScout Mobile API for id: ${exposeId} Status: ${detailed.statusText}`,
);
return listing;
}
const detailBody = await detailed.json();
@@ -172,22 +172,44 @@ async function isListingActive(link) {
function nullOrEmpty(val) {
return val == null || val.length === 0;
}
/**
* @param {any} o
* @returns {ParsedListing}
*/
function normalize(o) {
const title = nullOrEmpty(o.title) ? 'NO TITLE FOUND' : o.title.replace('NEU', '');
const title = (o.title || '').replace('NEU', '').trim();
const address = nullOrEmpty(o.address) ? 'NO ADDRESS FOUND' : (o.address || '').replace(/\(.*\),.*$/, '').trim();
const id = buildHash(o.id, o.price);
return Object.assign(o, { id, title, address });
return {
id,
link: o.link,
title,
price: extractNumber(o.price),
size: extractNumber(o.size),
rooms: extractNumber(o.rooms),
address,
image: o.image,
description: o.description,
};
}
/**
* @param {ParsedListing} o
* @returns {boolean}
*/
function applyBlacklist(o) {
return !isOneOf(o.title, appliedBlackList);
}
/** @type {ProviderConfig} */
const config = {
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
url: null,
crawlFields: {
id: 'id',
title: 'title',
price: 'price',
size: 'size',
rooms: 'rooms',
link: 'link',
address: 'address',
},
@@ -196,13 +218,13 @@ const config = {
normalize: normalize,
filter: applyBlacklist,
getListings: getListings,
fetchDetails: fetchDetails,
activeTester: isListingActive,
};
export const init = (sourceConfig, blacklist) => {
config.enabled = sourceConfig.enabled;
config.url = convertWebToMobile(sourceConfig.url);
appliedBlackList = blacklist || [];
currentUserId = sourceConfig.userId || null;
};
export const metaInformation = {
name: 'Immoscout',

View File

@@ -5,27 +5,46 @@
import { isOneOf, buildHash } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
import { extractNumber } from '../utils/extract-number.js';
/** @import { ParsedListing } from '../types/listing.js' */
/** @import { ProviderConfig } from '../types/providerConfig.js' */
let appliedBlackList = [];
/**
* @param {any} o
* @returns {ParsedListing}
*/
function normalize(o) {
const size = o.size || 'N/A m²';
const price = (o.price || '--- €').replace('Preis auf Anfrage', '--- €');
const title = o.title || 'No title available';
const immoId = o.id.substring(o.id.indexOf('-') + 1, o.id.length);
const link = `https://immo.swp.de/immobilien/${immoId}`;
const description = o.description;
const id = buildHash(immoId, price);
return Object.assign(o, { id, price, size, title, link, description });
const id = buildHash(immoId, o.price);
return {
id,
link,
title: o.title || '',
price: extractNumber(o.price),
size: extractNumber(o.size),
rooms: extractNumber(o.rooms),
address: o.address,
image: o.image,
description: undefined,
};
}
/**
* @param {ParsedListing} o
* @returns {boolean}
*/
function applyBlacklist(o) {
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
return titleNotBlacklisted && descNotBlacklisted;
}
/** @type {ProviderConfig} */
const config = {
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
url: null,
crawlContainer: '.js-serp-item',
sortByDateParam: 's=most_recently_updated_first',
@@ -34,9 +53,10 @@ const config = {
id: '.js-bookmark-btn@data-id',
price: 'div.align-items-start div:first-child | trim',
size: 'div.align-items-start div:nth-child(3) | trim',
rooms: 'div.align-items-start div:nth-child(2) | trim',
address: '.js-bookmark-btn@data-address',
title: '.js-item-title-link@title | trim',
link: '.ci-search-result__link@href',
description: '.js-show-more-item-sm | removeNewline | trim',
image: 'img@src',
},
normalize: normalize,

View File

@@ -5,21 +5,84 @@
import { buildHash, isOneOf } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
import { extractNumber } from '../utils/extract-number.js';
import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js';
import * as cheerio from 'cheerio';
import logger from '../services/logger.js';
/** @import { ParsedListing } from '../types/listing.js' */
/** @import { ProviderConfig } from '../types/providerConfig.js' */
let appliedBlackList = [];
function normalize(o) {
const id = buildHash(o.id, o.price);
return Object.assign(o, { id });
async function fetchDetails(listing, browser) {
try {
const html = await puppeteerExtractor(listing.link, null, { browser });
if (!html) return listing;
const $ = cheerio.load(html);
const nextDataRaw = $('#__NEXT_DATA__').text();
if (!nextDataRaw) return listing;
const classified = JSON.parse(nextDataRaw)?.props?.pageProps?.classified;
if (!classified) return listing;
const description = (classified.Texts || [])
.map((t) => [t.Title, t.Content].filter(Boolean).join('\n'))
.filter(Boolean)
.join('\n\n');
const addr = classified.EstateAddress;
let address = listing.address;
if (addr) {
const street = [addr.Street, addr.HouseNumber].filter(Boolean).join(' ');
const cityLine = [addr.ZipCode, addr.District || addr.City].filter(Boolean).join(' ');
const full = [street, cityLine].filter(Boolean).join(', ');
if (full) address = full;
}
return {
...listing,
address,
description: description || listing.description,
};
} catch (error) {
logger.warn(`Could not fetch immowelt detail page for listing '${listing.id}'.`, error?.message || error);
return listing;
}
}
/**
* @param {any} o
* @returns {ParsedListing}
*/
function normalize(o) {
const id = buildHash(o.id, o.price);
return {
id,
link: o.link,
title: o.title || '',
price: extractNumber(o.price),
size: extractNumber(o.size),
rooms: extractNumber(o.rooms),
address: o.address,
image: o.image,
description: o.description,
};
}
/**
* @param {ParsedListing} o
* @returns {boolean}
*/
function applyBlacklist(o) {
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
return titleNotBlacklisted && descNotBlacklisted;
}
/** @type {ProviderConfig} */
const config = {
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
url: null,
crawlContainer:
'div[data-testid="serp-core-scrollablelistview-testid"]:not(div[data-testid="serp-enlargementlist-testid"] div[data-testid="serp-card-testid"]) div[data-testid="serp-core-classified-card-testid"]',
@@ -28,7 +91,8 @@ const config = {
crawlFields: {
id: 'a@href',
price: 'div[data-testid="cardmfe-price-testid"] | removeNewline | trim',
size: 'div[data-testid="cardmfe-keyfacts-testid"] | removeNewline | trim',
size: 'div[data-testid="cardmfe-keyfacts-testid"] div:nth-of-type(3) | removeNewline | trim',
rooms: 'div[data-testid="cardmfe-keyfacts-testid"] div:nth-of-type(1) | 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',
@@ -37,6 +101,7 @@ const config = {
},
normalize: normalize,
filter: applyBlacklist,
fetchDetails: fetchDetails,
activeTester: checkIfListingIsActive,
};
export const init = (sourceConfig, blacklist) => {

View File

@@ -5,17 +5,177 @@
import { buildHash, isOneOf } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
import { extractNumber } from '../utils/extract-number.js';
/** @import { ParsedListing } from '../types/listing.js' */
/** @import { ProviderConfig } from '../types/providerConfig.js' */
import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js';
import logger from '../services/logger.js';
import * as cheerio from 'cheerio';
let appliedBlackList = [];
let appliedBlacklistedDistricts = [];
function normalize(o) {
const size = o.size || '--- m²';
const id = buildHash(o.id, o.price);
const link = `https://www.kleinanzeigen.de${o.link}`;
return Object.assign(o, { id, size, link });
function toAbsoluteLink(link) {
if (!link) return null;
return link.startsWith('http') ? link : `https://www.kleinanzeigen.de${link}`;
}
function cleanText(value) {
if (value == null) return '';
return String(value)
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function buildAddressFromJsonLd(address) {
if (!address || typeof address !== 'object') return null;
const locality = cleanText(address.addressLocality);
const region = cleanText(address.addressRegion);
const postalCode = cleanText(address.postalCode);
const streetAddress = cleanText(address.streetAddress);
const cityPart = [region, locality].filter(Boolean).join(' - ');
const tail = [postalCode, cityPart || locality || region].filter(Boolean).join(' ');
const fullAddress = [streetAddress, tail].filter(Boolean).join(', ');
return fullAddress || null;
}
function flattenJsonLdNodes(node, acc = []) {
if (node == null) return acc;
if (Array.isArray(node)) {
node.forEach((item) => flattenJsonLdNodes(item, acc));
return acc;
}
if (typeof node !== 'object') return acc;
acc.push(node);
if (Array.isArray(node['@graph'])) {
node['@graph'].forEach((item) => flattenJsonLdNodes(item, acc));
}
if (node.mainEntity) {
flattenJsonLdNodes(node.mainEntity, acc);
}
if (node.itemOffered) {
flattenJsonLdNodes(node.itemOffered, acc);
}
return acc;
}
function extractDetailFromHtml(html) {
const $ = cheerio.load(html);
const nodes = [];
// Prefer the rendered postal address block from the detail page because
// it contains the street line that is missing from list results.
const streetFromDom = cleanText($('#street-address').first().text());
const localityFromDom = cleanText($('#viewad-locality').first().text());
const domAddress = [streetFromDom, localityFromDom].filter(Boolean).join(' ');
$('script[type="application/ld+json"]').each((_, element) => {
const content = $(element).text();
if (!content) return;
try {
const parsed = JSON.parse(content);
flattenJsonLdNodes(parsed, nodes);
} catch {
// Ignore broken JSON-LD blocks from ads/trackers and keep trying others.
}
});
let detailAddress = null;
let detailDescription = null;
if (domAddress) {
detailAddress = domAddress;
}
for (const node of nodes) {
const candidateAddress = buildAddressFromJsonLd(
node.address || node?.itemOffered?.address || node?.offers?.address,
);
if (!detailAddress && candidateAddress) {
detailAddress = candidateAddress;
}
const candidateDescription = cleanText(node.description || node?.itemOffered?.description);
if (!detailDescription && candidateDescription) {
detailDescription = candidateDescription;
}
if (detailAddress && detailDescription) {
break;
}
}
return {
detailAddress,
detailDescription,
};
}
async function enrichListingFromDetails(listing, browser) {
const absoluteLink = toAbsoluteLink(listing.link);
if (!absoluteLink) return listing;
try {
const html = await puppeteerExtractor(absoluteLink, null, { browser });
if (!html) return { ...listing, link: absoluteLink };
const { detailAddress, detailDescription } = extractDetailFromHtml(html);
return {
...listing,
link: absoluteLink,
address: detailAddress || listing.address,
description: detailDescription || listing.description,
};
} catch (error) {
logger.warn(`Could not fetch Kleinanzeigen detail page for listing '${listing.id}'.`, error?.message || error);
return { ...listing, link: absoluteLink };
}
}
async function fetchDetails(listing, browser) {
return enrichListingFromDetails(listing, browser);
}
/**
* @param {any} o
* @returns {ParsedListing}
*/
function normalize(o) {
const parts = (o.tags || '').split('·').map((p) => p.trim());
const size = parts.find((p) => p.includes('m²'));
const rooms = parts.find((p) => p.includes('Zi.'));
const id = buildHash(o.id, o.price);
return {
id,
title: o.title,
link: toAbsoluteLink(o.link) || o.link,
price: extractNumber(o.price),
size: extractNumber(size),
rooms: extractNumber(rooms),
address: o.address,
description: o.description,
image: o.image,
};
}
/**
* @param {ParsedListing} o
* @returns {boolean}
*/
function applyBlacklist(o) {
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
@@ -24,28 +184,31 @@ function applyBlacklist(o) {
return o.title != null && !isBlacklistedDistrict && titleNotBlacklisted && descNotBlacklisted;
}
/** @type {ProviderConfig} */
const config = {
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
url: null,
crawlContainer: '#srchrslt-adtable .ad-listitem ',
//sort by date is standard oO
sortByDateParam: null,
waitForSelector: 'body',
crawlFields: {
id: '.aditem@data-adid | int',
id: '.aditem@data-adid',
price: '.aditem-main--middle--price-shipping--price | removeNewline | trim',
size: '.aditem-main .text-module-end | removeNewline | trim',
tags: '.aditem-main--middle--tags | removeNewline | trim',
title: '.aditem-main .text-module-begin a | removeNewline | trim',
link: '.aditem-main .text-module-begin a@href | removeNewline | trim',
description: '.aditem-main .aditem-main--middle--description | removeNewline | trim',
address: '.aditem-main--top--left | trim | removeNewline',
image: 'img@src',
},
fetchDetails,
normalize: normalize,
filter: applyBlacklist,
activeTester: checkIfListingIsActive,
};
export const metaInformation = {
name: 'Ebay Kleinanzeigen',
name: 'Kleinanzeigen',
baseUrl: 'https://www.kleinanzeigen.de/',
id: 'kleinanzeigen',
};

View File

@@ -5,23 +5,46 @@
import { isOneOf, buildHash } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
import { extractNumber } from '../utils/extract-number.js';
/** @import { ParsedListing } from '../types/listing.js' */
/** @import { ProviderConfig } from '../types/providerConfig.js' */
let appliedBlackList = [];
/**
* @param {any} o
* @returns {ParsedListing}
*/
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 link = o.link != null ? `https://www.mcmakler.de${o.link}` : o.link;
const [rooms, size] = o.tags.split(' | ');
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 });
return {
id,
link,
title: o.title || '',
price: extractNumber(o.price),
size: extractNumber(size),
rooms: extractNumber(rooms),
address,
image: o.image,
description: undefined,
};
}
/**
* @param {ParsedListing} o
* @returns {boolean}
*/
function applyBlacklist(o) {
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
return titleNotBlacklisted && descNotBlacklisted;
}
/** @type {ProviderConfig} */
const config = {
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
url: null,
crawlContainer: 'article[data-testid="propertyCard"]',
sortByDateParam: 'sortBy=DATE&sortOn=DESC',
@@ -30,7 +53,7 @@ const config = {
id: 'h2 a@href',
title: 'h2 a | removeNewline | trim',
price: 'footer > p:first-of-type | trim',
size: 'footer > p:nth-of-type(2) | trim',
tags: 'footer > p:nth-of-type(2) | trim',
address: 'div > h2 + p | removeNewline | trim',
image: 'img@src',
link: 'h2 a@href',

View File

@@ -5,6 +5,9 @@
import { isOneOf, buildHash } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
import { extractNumber } from '../utils/extract-number.js';
/** @import { ParsedListing } from '../types/listing.js' */
/** @import { ProviderConfig } from '../types/providerConfig.js' */
let appliedBlackList = [];
@@ -12,19 +15,39 @@ function nullOrEmpty(val) {
return val == null || val.length === 0;
}
/**
* @param {any} o
* @returns {ParsedListing}
*/
function normalize(o) {
const link = nullOrEmpty(o.link)
? 'NO LINK'
: `https://www.neubaukompass.de${o.link.substring(o.link.indexOf('/neubau'))}`;
const id = buildHash(o.link, o.price);
return Object.assign(o, { id, link });
return {
id,
link,
title: o.title || '',
price: extractNumber(o.price),
size: extractNumber(o.size),
rooms: extractNumber(o.rooms),
address: o.address,
image: o.image,
description: o.description,
};
}
/**
* @param {ParsedListing} o
* @returns {boolean}
*/
function applyBlacklist(o) {
return !isOneOf(o.title, appliedBlackList);
}
/** @type {ProviderConfig} */
const config = {
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
url: null,
crawlContainer: '.col-12.mb-4',
sortByDateParam: 'Sortierung=Id&Richtung=DESC',
@@ -34,7 +57,9 @@ const config = {
title: 'a@title | removeNewline | trim',
link: 'a@href',
address: '.nbk-project-card__description | removeNewline | trim',
price: '.nbk-project-card__spec-item .nbk-project-card__spec-value | removeNewline | trim',
price: '.nbk-project-card__spec-item:nth-child(1) .nbk-project-card__spec-value | removeNewline | trim',
size: '.nbk-project-card__spec-item:nth-child(2) .nbk-project-card__spec-value | removeNewline | trim',
rooms: '.nbk-project-card__spec-item:nth-child(3) .nbk-project-card__spec-value | removeNewline | trim',
image: '.nbk-project-card__image@src',
},
normalize: normalize,

View File

@@ -5,19 +5,43 @@
import { isOneOf, buildHash } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
import { extractNumber } from '../utils/extract-number.js';
/** @import { ParsedListing } from '../types/listing.js' */
/** @import { ProviderConfig } from '../types/providerConfig.js' */
let appliedBlackList = [];
/**
* @param {any} o
* @returns {ParsedListing}
*/
function normalize(o) {
const link = metaInformation.baseUrl + o.link;
const id = buildHash(o.title, o.link, o.price);
return Object.assign(o, { link, id });
return {
id,
link,
title: o.title || '',
price: extractNumber(o.price),
size: extractNumber(o.size),
rooms: extractNumber(o.rooms),
address: o.address,
image: o.image,
description: o.description,
};
}
/**
* @param {ParsedListing} o
* @returns {boolean}
*/
function applyBlacklist(o) {
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
return titleNotBlacklisted && descNotBlacklisted;
}
/** @type {ProviderConfig} */
const config = {
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
url: null,
crawlContainer: 'div[data-livecomponent-id*="search/property_list"] .grid > div',
sortByDateParam: null,
@@ -27,6 +51,7 @@ const config = {
title: 'h4 | removeNewline | trim',
price: '.text-xl | trim',
size: 'div[title="Wohnfläche"] | trim',
rooms: 'div[title="Zimmer"] | trim',
address: '.text-slate-800 | removeNewline | trim',
image: 'img@src',
link: 'a@href',

View File

@@ -5,24 +5,47 @@
import { isOneOf, buildHash } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
import { extractNumber } from '../utils/extract-number.js';
/** @import { ParsedListing } from '../types/listing.js' */
/** @import { ProviderConfig } from '../types/providerConfig.js' */
let appliedBlackList = [];
/**
* @param {any} o
* @returns {ParsedListing}
*/
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;
const 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 });
return {
id,
link,
title: o.title || '',
price: extractNumber(o.price),
size: extractNumber(o.size),
rooms: extractNumber(o.rooms),
address,
image,
description: o.description,
};
}
/**
* @param {ParsedListing} o
* @returns {boolean}
*/
function applyBlacklist(o) {
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
return titleNotBlacklisted && descNotBlacklisted;
}
/** @type {ProviderConfig} */
const config = {
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
url: null,
crawlContainer: '.listentry-content',
sortByDateParam: null, // sort by date is standard
@@ -32,6 +55,7 @@ const config = {
title: 'h2 | trim',
price: '.listentry-details-price .listentry-details-v | trim',
size: '.listentry-details-size .listentry-details-v | trim',
rooms: '.listentry-details-rooms .listentry-details-v | trim',
address: '.listentry-adress | trim',
image: '.listentry-img@style',
link: '.shariff@data-url',

View File

@@ -5,37 +5,109 @@
import { isOneOf, buildHash } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js';
import * as cheerio from 'cheerio';
import logger from '../services/logger.js';
import { extractNumber } from '../utils/extract-number.js';
/** @import { ParsedListing } from '../types/listing.js' */
/** @import { ProviderConfig } from '../types/providerConfig.js' */
let appliedBlackList = [];
async function fetchDetails(listing, browser) {
try {
const html = await puppeteerExtractor(listing.link, 'body', { browser });
const $ = cheerio.load(html);
const nextDataRaw = $('#__NEXT_DATA__').text;
if (!nextDataRaw) return listing;
const estate = JSON.parse(nextDataRaw)?.props?.pageProps?.estate;
if (!estate) return listing;
const description = (estate.frontendItems || [])
.map((item) => {
const texts = (item.contents || [])
.filter((c) => c.type === 'contentBoxes')
.flatMap((c) => c.data || [])
.filter((d) => d.type === 'text' && d.content)
.map((d) => d.content);
if (!texts.length) return null;
return [item.label, ...texts].filter(Boolean).join('\n');
})
.filter(Boolean)
.join('\n\n');
const addr = estate.address;
let address = listing.address;
if (addr) {
const street = [addr.street, addr.streetNumber].filter(Boolean).join(' ');
const cityLine = [addr.zip, addr.city].filter(Boolean).join(' ');
const full = [street, cityLine].filter(Boolean).join(', ');
if (full) address = full;
}
return {
...listing,
address,
description: description || listing.description,
};
} catch (error) {
logger.warn(`Could not fetch Sparkasse detail page for listing '${listing.id}'.`, error?.message || error);
return listing;
}
}
/**
* @param {any} o
* @returns {ParsedListing}
*/
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 });
return {
id,
link,
title: o.title || '',
price: extractNumber(o.price),
size: extractNumber(o.size),
rooms: extractNumber(o.rooms),
address: o.address,
image: o.image,
description: o.description,
};
}
/**
* @param {ParsedListing} o
* @returns {boolean}
*/
function applyBlacklist(o) {
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
return titleNotBlacklisted && descNotBlacklisted;
}
/** @type {ProviderConfig} */
const config = {
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
url: null,
crawlContainer: '.estate-list-item-row',
crawlContainer: 'div[data-testid="estate-link"]',
sortByDateParam: 'sortBy=date_desc',
waitForSelector: 'body',
crawlFields: {
id: 'div[data-testid="estate-link"] a@href',
id: 'a@href',
title: 'h3 | trim',
price: '.estate-list-price | trim',
size: '.estate-mainfact:first-child span | trim',
size: '.estate-mainfact:nth-child(1) span | trim',
rooms: '.estate-mainfact:nth-child(2) span | trim',
address: 'h6 | trim',
image: '.estate-list-item-image-container img@src',
link: 'div[data-testid="estate-link"] a@href',
image: 'img@src',
link: 'a@href',
},
normalize: normalize,
filter: applyBlacklist,
fetchDetails,
activeTester: (url) => checkIfListingIsActive(url, 'Angebot nicht gefunden'),
};
export const init = (sourceConfig, blacklist) => {

View File

@@ -5,22 +5,69 @@
import { isOneOf, buildHash } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
import { extractNumber } from '../utils/extract-number.js';
import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js';
import * as cheerio from 'cheerio';
import logger from '../services/logger.js';
/** @import { ParsedListing } from '../types/listing.js' */
/** @import { ProviderConfig } from '../types/providerConfig.js' */
let appliedBlackList = [];
async function fetchDetails(listing, browser) {
try {
const html = await puppeteerExtractor(listing.link, null, { browser });
if (!html) return listing;
const $ = cheerio.load(html);
$('#freitext_0 script').remove();
const description = $('#freitext_0').text().replace(/\s+/g, ' ').trim();
const address = $('a[href="#map_container"] .section_panel_detail').text().replace(/\s+/g, ' ').trim();
return {
...listing,
address: address || listing.address,
description: description || listing.description,
};
} catch (error) {
logger.warn(`Could not fetch wgGesucht detail page for listing '${listing.id}'.`, error?.message || error);
return listing;
}
}
/**
* @param {any} o
* @returns {ParsedListing}
*/
function normalize(o) {
const id = buildHash(o.id, o.price);
const link = `https://www.wg-gesucht.de${o.link}`;
const image = o.image != null ? o.image.replace('small', 'large') : null;
return Object.assign(o, { id, link, image });
const [rooms, city, road] = o.details?.split(' | ') || [];
return {
id,
link,
title: o.title || '',
price: extractNumber(o.price),
size: extractNumber(o.size),
rooms: extractNumber(rooms),
address: `${city}, ${road}`,
image,
description: o.description,
};
}
/**
* @param {ParsedListing} o
* @returns {boolean}
*/
function applyBlacklist(o) {
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
return o.id != null && titleNotBlacklisted && descNotBlacklisted;
}
/** @type {ProviderConfig} */
const config = {
url: null,
crawlContainer: '#main_column .wgg_card',
@@ -31,12 +78,16 @@ const config = {
details: '.row .noprint .col-xs-11 |removeNewline |trim',
price: '.middle .col-xs-3 |removeNewline |trim',
size: '.middle .text-right |removeNewline |trim',
rooms: '.middle .text-right |removeNewline |trim',
title: '.truncate_title a |removeNewline |trim',
link: '.truncate_title a@href',
image: '.img-responsive@src',
description: '.row .noprint .col-xs-11 |removeNewline |trim',
},
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
normalize: normalize,
filter: applyBlacklist,
fetchDetails,
activeTester: checkIfListingIsActive,
};
export const init = (sourceConfig, blacklist) => {

View File

@@ -5,26 +5,45 @@
import * as utils from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
import { extractNumber } from '../utils/extract-number.js';
/** @import { ParsedListing } from '../types/listing.js' */
/** @import { ProviderConfig } from '../types/providerConfig.js' */
let appliedBlackList = [];
/**
* @param {any} o
* @returns {ParsedListing}
*/
function normalize(o) {
const id = o.link.split('/').pop();
const price = o.price;
const size = o.size;
const rooms = o.rooms;
const [city = '', part = ''] = (o.description || '').split('-').map((v) => v.trim());
const address = `${part}, ${city}`;
return Object.assign(o, { id, price, size, rooms, address });
return {
id: o.link.split('/').pop(),
link: o.link,
title: o.title || '',
price: extractNumber(o.price),
size: extractNumber(o.size),
rooms: extractNumber(o.rooms),
address,
image: o.image,
description: o.description,
};
}
/**
* @param {ParsedListing} o
* @returns {boolean}
*/
function applyBlacklist(o) {
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
return o.id != null && o.title != null && titleNotBlacklisted && descNotBlacklisted && o.link.startsWith(o.link);
}
/** @type {ProviderConfig} */
const config = {
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
url: null,
sortByDateParam: null,
waitForSelector: 'body',
@@ -37,7 +56,7 @@ const config = {
size: 'dl:nth-of-type(3) dd | removeNewline | trim',
description: 'div.before\\:icon-location_marker | trim',
link: '@href',
imageUrl: 'img@src',
image: 'img@src',
},
normalize: normalize,
filter: applyBlacklist,

View File

@@ -94,12 +94,34 @@ export async function applyBotPreventionToPage(page, cfg) {
// webdriver
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
// chrome runtime
// chrome runtime — expose loadTimes, csi and app like real Chrome
// @ts-ignore
if (!window.chrome) {
window.chrome = {
runtime: {},
// @ts-ignore
window.chrome = { runtime: {} };
}
loadTimes: () => ({
requestTime: performance.timeOrigin / 1000,
startLoadTime: performance.timeOrigin / 1000,
commitLoadTime: performance.timeOrigin / 1000 + 0.1,
finishDocumentLoadTime: 0,
finishLoadTime: 0,
firstPaintTime: 0,
firstPaintAfterLoadTime: 0,
navigationType: 'Other',
wasFetchedViaSpdy: false,
wasNpnNegotiated: false,
npnNegotiatedProtocol: '',
wasAlternateProtocolAvailable: false,
connectionInfo: 'http/1.1',
}),
// @ts-ignore
csi: () => ({ startE: performance.timeOrigin, onloadT: Date.now(), pageT: performance.now(), tran: 15 }),
app: {
isInstalled: false,
InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' },
RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' },
},
};
// languages
// @ts-ignore
@@ -107,23 +129,38 @@ export async function applyBotPreventionToPage(page, cfg) {
get: () => (window.localStorage.getItem('__LANGS__') || 'de-DE,de').split(','),
});
// plugins
// plugins — mimic real Chrome's built-in PDF plugins
const makePlugin = (name, filename, description, mimeType, mimeTypeSuffix) => {
const mimeObj = { type: mimeType, suffixes: mimeTypeSuffix, description, enabledPlugin: null };
const plugin = { name, filename, description, length: 1, 0: mimeObj };
mimeObj.enabledPlugin = plugin;
return plugin;
};
const fakePlugins = [
makePlugin('PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format', 'application/pdf', 'pdf'),
makePlugin('Chrome PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format', 'application/pdf', 'pdf'),
makePlugin('Chromium PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format', 'application/pdf', 'pdf'),
makePlugin(
'Microsoft Edge PDF Viewer',
'internal-pdf-viewer',
'Portable Document Format',
'application/pdf',
'pdf',
),
makePlugin('WebKit built-in PDF', 'internal-pdf-viewer', 'Portable Document Format', 'application/pdf', 'pdf'),
];
// @ts-ignore
Object.defineProperty(navigator, 'plugins', {
get: () => [{}, {}, {}],
});
Object.defineProperty(navigator, 'plugins', { get: () => fakePlugins });
// @ts-ignore
Object.defineProperty(navigator, 'mimeTypes', { get: () => [fakePlugins[0][0]] });
// platform and concurrency hints
// @ts-ignore
Object.defineProperty(navigator, 'platform', { get: () => 'Win32' });
// @ts-ignore
if (typeof navigator.hardwareConcurrency === 'number' && navigator.hardwareConcurrency < 2) {
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 4 });
}
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
// @ts-ignore
if (typeof navigator.deviceMemory === 'number' && navigator.deviceMemory < 2) {
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
}
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
// userAgentData (Client Hints)
try {
@@ -236,6 +273,21 @@ export async function applyBotPreventionToPage(page, cfg) {
} catch {
//noop
}
// document.hasFocus — headless returns false; real active tabs return true
try {
document.hasFocus = () => true;
} catch {
//noop
}
// screen color depth — normalise in case headless reports 0
try {
Object.defineProperty(screen, 'colorDepth', { get: () => 24 });
Object.defineProperty(screen, 'pixelDepth', { get: () => 24 });
} catch {
//noop
}
} catch {
//noop
}
@@ -273,6 +325,8 @@ export async function applyPostNavigationHumanSignals(page, cfg) {
const my = Math.floor(vh * (0.3 + Math.random() * 0.4));
await page.mouse.move(mx, my, { steps: 10 + Math.floor(Math.random() * 10) });
await page.mouse.wheel({ deltaY: 100 + Math.floor(Math.random() * 200) });
await new Promise((res) => setTimeout(res, 150 + Math.floor(Math.random() * 200)));
await page.mouse.wheel({ deltaY: -(30 + Math.floor(Math.random() * 60)) });
} catch {
// ignore if mouse is unavailable
}

View File

@@ -110,6 +110,7 @@ export default async function execute(url, waitForSelector, options) {
// Navigation
const response = await page.goto(url, {
waitUntil: options?.waitUntil || 'domcontentloaded',
timeout: options?.puppeteerTimeout || 60000,
});
// Optionally wait and add subtle human-like interactions

View File

@@ -178,15 +178,7 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
browser = await puppeteerExtractor.launchBrowser(matchedProvider.config.url, {});
}
await new FredyPipelineExecutioner(
matchedProvider.config,
job.notificationAdapter,
job.spatialFilter,
prov.id,
job.id,
similarityCache,
browser,
).execute();
await new FredyPipelineExecutioner(matchedProvider.config, job, prov.id, similarityCache, browser).execute();
} catch (err) {
logger.error(err);
}

View File

@@ -31,6 +31,7 @@ export const upsertJob = ({
userId,
shareWithUsers = [],
spatialFilter = null,
specFilter = null,
}) => {
const id = jobId || nanoid();
const existing = SqliteConnection.query(`SELECT id, user_id FROM jobs WHERE id = @id LIMIT 1`, { id })[0];
@@ -44,7 +45,8 @@ export const upsertJob = ({
provider = @provider,
notification_adapter = @notification_adapter,
shared_with_user = @shareWithUsers,
spatial_filter = @spatialFilter
spatial_filter = @spatialFilter,
spec_filter = @specFilter
WHERE id = @id`,
{
id,
@@ -55,12 +57,13 @@ export const upsertJob = ({
provider: toJson(provider ?? []),
notification_adapter: toJson(notificationAdapter ?? []),
spatialFilter: spatialFilter ? toJson(spatialFilter) : null,
specFilter: specFilter ? toJson(specFilter) : null,
},
);
} else {
SqliteConnection.execute(
`INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter, shared_with_user, spatial_filter)
VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter, @shareWithUsers, @spatialFilter)`,
`INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter, shared_with_user, spatial_filter, spec_filter)
VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter, @shareWithUsers, @spatialFilter, @specFilter)`,
{
id,
user_id: ownerId,
@@ -71,6 +74,7 @@ export const upsertJob = ({
shareWithUsers: toJson(shareWithUsers ?? []),
notification_adapter: toJson(notificationAdapter ?? []),
spatialFilter: spatialFilter ? toJson(spatialFilter) : null,
specFilter: specFilter ? toJson(specFilter) : null,
},
);
}
@@ -92,6 +96,7 @@ export const getJob = (jobId) => {
j.shared_with_user,
j.notification_adapter AS notificationAdapter,
j.spatial_filter AS spatialFilter,
j.spec_filter AS specFilter,
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
FROM jobs j
WHERE j.id = @id
@@ -107,6 +112,7 @@ export const getJob = (jobId) => {
shared_with_user: fromJson(row.shared_with_user, []),
notificationAdapter: fromJson(row.notificationAdapter, []),
spatialFilter: fromJson(row.spatialFilter, null),
specFilter: fromJson(row.specFilter, null),
};
};
@@ -157,6 +163,7 @@ export const getJobs = () => {
j.shared_with_user,
j.notification_adapter AS notificationAdapter,
j.spatial_filter AS spatialFilter,
j.spec_filter AS specFilter,
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
FROM jobs j
WHERE j.enabled = 1
@@ -170,6 +177,7 @@ export const getJobs = () => {
shared_with_user: fromJson(row.shared_with_user, []),
notificationAdapter: fromJson(row.notificationAdapter, []),
spatialFilter: fromJson(row.spatialFilter, null),
specFilter: fromJson(row.specFilter, null),
}));
};
@@ -260,6 +268,7 @@ export const queryJobs = ({
j.shared_with_user,
j.notification_adapter AS notificationAdapter,
j.spatial_filter AS spatialFilter,
j.spec_filter AS specFilter,
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
FROM jobs j
${whereSql}
@@ -276,6 +285,7 @@ export const queryJobs = ({
shared_with_user: fromJson(row.shared_with_user, []),
notificationAdapter: fromJson(row.notificationAdapter, []),
spatialFilter: fromJson(row.spatialFilter, null),
specFilter: fromJson(row.specFilter, null),
}));
return { totalNumber, page: safePage, result };

View File

@@ -174,9 +174,9 @@ export const storeListings = (jobId, providerId, listings) => {
SqliteConnection.withTransaction((db) => {
const stmt = db.prepare(
`INSERT INTO listings (id, hash, provider, job_id, price, size, title, image_url, description, address,
`INSERT INTO listings (id, hash, provider, job_id, price, size, rooms, title, image_url, description, address,
link, created_at, is_active, latitude, longitude)
VALUES (@id, @hash, @provider, @job_id, @price, @size, @title, @image_url, @description, @address, @link,
VALUES (@id, @hash, @provider, @job_id, @price, @size, @rooms, @title, @image_url, @description, @address, @link,
@created_at, 1, @latitude, @longitude)
ON CONFLICT(job_id, hash) DO NOTHING`,
);
@@ -187,8 +187,9 @@ export const storeListings = (jobId, providerId, listings) => {
hash: item.id,
provider: providerId,
job_id: jobId,
price: extractNumber(item.price),
size: extractNumber(item.size),
price: item.price,
size: item.size,
rooms: item.rooms,
title: item.title,
image_url: item.image,
description: item.description,
@@ -202,19 +203,6 @@ export const storeListings = (jobId, providerId, listings) => {
}
});
/**
* Extract the first number from a string like "1.234 €" or "70 m²".
* Removes dots/commas before parsing. Returns null on invalid input.
* @param {string|undefined|null} str
* @returns {number|null}
*/
function extractNumber(str) {
if (!str) return null;
const cleaned = str.replace(/\./g, '').replace(',', '.');
const num = parseFloat(cleaned);
return isNaN(num) ? null : num;
}
/**
* Remove any parentheses segments (including surrounding whitespace) from a string.
* Returns null for empty input.

View File

@@ -0,0 +1,17 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
// We have moved the previous immoscout_details setting to provider_details and enable this by default
// We also set it to false per default as this is increasing the chance to be detected as a bot by a lot
export function up(db) {
db.exec(`
UPDATE settings
SET name = 'provider_details', value = false
WHERE name = 'immoscout_details'
AND NOT EXISTS (
SELECT 1 FROM settings WHERE name = 'provider_details'
);
`);
}

View File

@@ -0,0 +1,18 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
// Convert provider_details from a boolean to an array of provider id strings.
// Users will re-configure which providers they want to fetch details from.
export function up(db) {
const row = db.prepare("SELECT value FROM settings WHERE name = 'provider_details'").get();
if (row) {
db.prepare("UPDATE settings SET value = ? WHERE name = 'provider_details'").run(JSON.stringify([]));
} else {
db.prepare("INSERT INTO settings (name, value, create_date) VALUES ('provider_details', ?, ?)").run(
JSON.stringify([]),
Date.now(),
);
}
}

View File

@@ -0,0 +1,10 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
export function up(db) {
db.exec(`
ALTER TABLE jobs ADD COLUMN spec_filter JSONB DEFAULT NULL;
`);
}

View File

@@ -0,0 +1,10 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
export function up(db) {
db.exec(`
ALTER TABLE listings ADD COLUMN rooms INTEGER;
`);
}

View File

@@ -67,6 +67,19 @@ export async function getSettings() {
return cachedSettingsConfig;
}
/**
* Get or create a persistent session signing secret.
* Generated once and stored in the settings table under the key 'session_secret'.
* @returns {Promise<string>}
*/
export async function getOrCreateSessionSecret() {
const settings = await getSettings();
if (settings.session_secret) return settings.session_secret;
const secret = nanoid(64);
upsertSettings({ session_secret: secret });
return secret;
}
/**
* Upsert settings rows.
* - Accepts an object map of name -> value, or an entry {name, value}.

10
lib/types/browser.js Normal file
View File

@@ -0,0 +1,10 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
/**
* @typedef {import('puppeteer').Browser} Browser
*/
export {};

19
lib/types/filter.js Normal file
View File

@@ -0,0 +1,19 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
/**
* @typedef {Object} SpecFilter
* @property {number} [minRooms] Minimum number of rooms.
* @property {number} [minSize] Minimum size in m².
* @property {number} [maxPrice] Maximum price.
*/
/**
* @typedef {Object} SpatialFilter GeoJSON FeatureCollection.
* @property {Array<Object>} [features] GeoJSON features for spatial filtering (typically Polygons).
* @property {string} [type] Type 'FeatureCollection'.
*/
export {};

23
lib/types/job.js Normal file
View File

@@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
/** @import { SpecFilter, SpatialFilter } from './filter.js' */
/**
* @typedef {Object} Job
* @property {string} id Job ID.
* @property {string} [userId] Owner user id.
* @property {string} [name] Job display name.
* @property {boolean} [enabled] Whether the job is enabled.
* @property {Array<any>} [blacklist] Blacklist entries.
* @property {Array<any>} [provider] Provider configuration list.
* @property {Object} [notificationAdapter] Notification configuration.
* @property {Array<string>} [shared_with_user] Users this job is shared with.
* @property {SpatialFilter | null} [spatialFilter] Optional spatial filter configuration as GeoJSON FeatureCollection.
* @property {SpecFilter | null} [specFilter] Optional listing specifications.
* @property {number} [numberOfFoundListings] Count of active listings for this job.
*/
export {};

22
lib/types/listing.js Normal file
View File

@@ -0,0 +1,22 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
/**
* @typedef {Object} ParsedListing
* @property {string} id Stable unique identifier (hash) of the listing.
* @property {string} link Link to the listing detail page.
* @property {string} image Link to the listing image.
* @property {string} title Title or headline of the listing.
* @property {string} [description] Description of the listing.
* @property {string} [address] Optional address/location text.
* @property {number} [price] Optional price of the listing.
* @property {number} [size] Optional size of the listing.
* @property {number} [rooms] Optional number of rooms.
* @property {number} [latitude] Optional latitude.
* @property {number} [longitude] Optional longitude.
* @property {number} [distance_to_destination] Optional distance to destination.
*/
export {};

View File

@@ -0,0 +1,25 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
/** @import { ParsedListing } from './listing.js' */
/**
* @typedef {Object} ProviderConfig
* @property {string} [url] Base URL to crawl.
* @property {string} [sortByDateParam] Query parameter used to enforce sorting by date.
* @property {string} [waitForSelector] CSS selector to wait for before parsing content.
* @property {Object.<string, string>} crawlFields Mapping of field names to selectors/paths.
* @property {string[]} requiredFieldNames List of field names that this provider supports.
* @property {string} [crawlContainer] CSS selector for the container holding listing items.
* @property {(raw: any) => ParsedListing} normalize Function to convert raw scraped data into a ParsedListing shape.
* @property {(listing: ParsedListing) => boolean} filter Function to filter out unwanted listings.
* @property {(url: string, waitForSelector?: string) => Promise<any[]>} [getListings] Optional override to fetch listings.
* @property {(listing:ParsedListing, browser:any)=>Promise<ParsedListing>} [providerConfig.fetchDetails] Optional per-listing detail enrichment. Called in parallel for each new listing after deduplication. Receives the shared browser instance. Must always resolve (never reject).
* @property {Object} [puppeteerOptions] Puppeteer specific options.
* @property {boolean} [enabled] Whether the provider is enabled.
* @property {(url: string) => Promise<number> | number} [activeTester] Function to check if a listing is still active.
*/
export {};

View File

@@ -0,0 +1,11 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
/**
* @typedef {Object} SimilarityCache
* @property {(params: { title?: string, address?: string, price?: number|string }) => boolean} checkAndAddEntry Checks if a listing is similar and adds it if not.
*/
export {};

View File

@@ -0,0 +1,18 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
/**
* Extract the first number from a string like "1.234 €" or "70 m²".
* Removes dots/commas before parsing. Returns null on invalid input.
* @param {string|undefined|null} str
* @returns {number|null}
*/
export const extractNumber = (str) => {
if (str == null) return null;
if (typeof str === 'number') return str;
const cleaned = str.replace(/\./g, '').replace(',', '.');
const num = parseFloat(cleaned);
return isNaN(num) ? null : num;
};

View File

@@ -0,0 +1,29 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
/** @import { ParsedListing } from '../types/listing.js' */
/**
* @typedef {Omit<import('../types/listing.js').ParsedListing, 'price' | 'size' | 'rooms'> & {
* price: string | null,
* size: string | null,
* rooms: string | null,
* }} FormattedListing
*/
/**
* Formats a listing's numerical fields (price, size, rooms) into strings with their respective units.
*
* @param {import('../types/listing.js').ParsedListing} listing The original listing object.
* @returns {FormattedListing} A copy of the listing with formatted strings for price, size, and rooms.
*/
export const formatListing = (listing) => {
return {
...listing,
price: listing.price != null ? `${listing.price}` : null,
size: listing.size != null ? `${listing.size}` : null,
rooms: listing.rooms != null ? `${listing.rooms} Zimmer` : null,
};
};

View File

@@ -1,6 +1,6 @@
{
"name": "fredy",
"version": "20.1.1",
"version": "20.3.1",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": {
"prepare": "husky",
@@ -61,46 +61,45 @@
"Firefox ESR"
],
"dependencies": {
"@douyinfe/semi-icons": "^2.93.0",
"@douyinfe/semi-ui": "2.93.0",
"@douyinfe/semi-ui-19": "^2.93.0",
"@douyinfe/semi-icons": "^2.94.1",
"@douyinfe/semi-ui": "2.94.1",
"@douyinfe/semi-ui-19": "^2.94.1",
"@mapbox/mapbox-gl-draw": "^1.5.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"@sendgrid/mail": "8.1.6",
"@turf/boolean-point-in-polygon": "^7.3.4",
"@vitejs/plugin-react": "6.0.1",
"adm-zip": "^0.5.16",
"better-sqlite3": "^12.8.0",
"adm-zip": "^0.5.17",
"better-sqlite3": "^12.9.0",
"body-parser": "2.2.2",
"chart.js": "^4.5.1",
"cheerio": "^1.2.0",
"cookie-session": "2.1.1",
"handlebars": "4.7.9",
"lodash": "4.17.23",
"maplibre-gl": "^5.21.1",
"nanoid": "5.1.7",
"maplibre-gl": "^5.23.0",
"nanoid": "5.1.9",
"node-cron": "^4.2.1",
"node-fetch": "3.3.2",
"node-mailjet": "6.0.11",
"nodemailer": "^8.0.4",
"nodemailer": "^8.0.5",
"p-throttle": "^8.1.0",
"package-up": "^5.0.0",
"puppeteer": "^24.40.0",
"puppeteer": "^24.41.0",
"puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"query-string": "9.3.1",
"react": "19.2.4",
"react": "19.2.5",
"react-chartjs-2": "^5.3.1",
"react-dom": "19.2.4",
"react-range-slider-input": "^3.3.2",
"react-router": "7.13.2",
"react-router-dom": "7.13.2",
"resend": "^6.10.0",
"react-dom": "19.2.5",
"react-range-slider-input": "^3.3.5",
"react-router": "7.14.1",
"react-router-dom": "7.14.1",
"resend": "^6.12.0",
"restana": "5.1.0",
"semver": "^7.7.4",
"serve-static": "2.2.1",
"slack": "11.0.2",
"vite": "8.0.3",
"vite": "8.0.8",
"x-var": "^3.0.1",
"zustand": "^5.0.12"
},
@@ -111,16 +110,16 @@
"@babel/preset-react": "7.28.5",
"@eslint/js": "^10.0.1",
"chalk": "^5.6.2",
"eslint": "10.1.0",
"eslint": "10.2.0",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-react": "7.37.5",
"globals": "^17.4.0",
"globals": "^17.5.0",
"history": "5.3.0",
"husky": "9.1.7",
"less": "4.6.4",
"lint-staged": "16.4.0",
"nodemon": "^3.1.14",
"prettier": "3.8.1",
"vitest": "^4.1.2"
"prettier": "3.8.3",
"vitest": "^4.1.4"
}
}

View File

@@ -17,13 +17,22 @@ describe('Issue reproduction: listings filtered by similarity or area should be
const providerConfig = {
url: 'http://example.com',
getListings: () => Promise.resolve([{ id: '1', title: 'test', address: 'addr', price: '100' }]),
getListings: () =>
Promise.resolve([{ id: '1', title: 'test', address: 'addr', price: '100', link: 'http://example.com/1' }]),
normalize: (l) => l,
filter: () => true,
crawlFields: { id: 'id', title: 'title', address: 'address', price: 'price' },
requiredFieldNames: ['id', 'title', 'address', 'price'],
};
const fredy = new Fredy(providerConfig, null, null, 'test-provider', 'test-job', mockSimilarityCache);
const mockedJob = {
id: 'test-job',
notificationAdapter: null,
specFilter: null,
spatialFilter: null,
};
const fredy = new Fredy(providerConfig, mockedJob, 'test-provider', mockSimilarityCache, undefined);
// Clear deletedIds before test
mockStore.deletedIds.length = 0;
@@ -64,18 +73,35 @@ describe('Issue reproduction: listings filtered by similarity or area should be
],
};
const mockedJob = {
id: 'test-job',
notificationAdapter: null,
specFilter: null,
spatialFilter: spatialFilter,
};
const providerConfig = {
url: 'http://example.com',
getListings: () =>
Promise.resolve([{ id: '2', title: 'test', address: 'addr', price: '100', latitude: 2, longitude: 2 }]), // outside polygon
Promise.resolve([
{
id: '2',
title: 'test',
address: 'addr',
price: '100',
latitude: 2,
longitude: 2,
link: 'http://example.com/2',
},
]), // outside polygon
normalize: (l) => l,
filter: () => true,
crawlFields: { id: 'id', title: 'title', address: 'address', price: 'price' },
requiredFieldNames: ['id', 'title', 'address', 'price'],
};
const fredy = new Fredy(providerConfig, null, spatialFilter, 'test-provider', 'test-job', mockSimilarityCache);
const fredy = new Fredy(providerConfig, mockedJob, 'test-provider', mockSimilarityCache, undefined);
// Clear deletedIds before test
mockStore.deletedIds.length = 0;
try {

View File

@@ -10,19 +10,22 @@ import { expect } from 'vitest';
import * as provider from '../../lib/provider/einsAImmobilien.js';
describe('#einsAImmobilien testsuite()', () => {
provider.init(providerConfig.einsAImmobilien, [], []);
provider.init(providerConfig.einsAImmobilien, []);
it('should test einsAImmobilien provider', async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => {
const fredy = new Fredy(
provider.config,
null,
null,
provider.metaInformation.id,
'einsAImmobilien',
similarityCache,
);
const mockedJob = {
id: 'einsAImmobilien',
notificationAdapter: null,
spatialFilter: null,
specFilter: null,
};
return await new Promise((resolve, reject) => {
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
fredy.execute().then((listings) => {
if (listings == null || listings.length === 0) {
reject('Listings is empty!');
return;
}
expect(listings).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).toBeTypeOf('object');
@@ -31,12 +34,14 @@ describe('#einsAImmobilien testsuite()', () => {
/** check the actual structure **/
expect(notify.id).toBeTypeOf('string');
expect(notify.price).toBeTypeOf('string');
expect(notify.price).toContain('€');
expect(notify.size).toBeTypeOf('string');
expect(notify.size).toContain('m²');
expect(notify.title).toBeTypeOf('string');
expect(notify.link).toBeTypeOf('string');
expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/
expect(notify.size).not.toBe('');
expect(notify.size).toBeTypeOf('string');
expect(notify.title).not.toBe('');
expect(notify.link).toContain('https://www.1a-immobilienmarkt.de');
});

View File

@@ -6,36 +6,82 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js';
import { providerConfig, mockFredy } from '../utils.js';
import { expect } from 'vitest';
import { expect, vi } from 'vitest';
import * as provider from '../../lib/provider/immobilienDe.js';
import * as mockStore from '../mocks/mockStore.js';
describe('#immobilien.de testsuite()', () => {
provider.init(providerConfig.immobilienDe, [], []);
it('should test immobilien.de provider', async () => {
const mockedJob = {
id: 'test1',
notificationAdapter: null,
spatialFilter: null,
specFilter: null,
};
const Fredy = await mockFredy();
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'test1', similarityCache);
fredy.execute().then((listing) => {
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).toBeTypeOf('object');
expect(notificationObj.serviceName).toBe('immobilienDe');
notificationObj.payload.forEach((notify) => {
/** check the actual structure **/
expect(notify.id).toBeTypeOf('string');
expect(notify.price).toBeTypeOf('string');
expect(notify.size).toBeTypeOf('string');
expect(notify.title).toBeTypeOf('string');
expect(notify.link).toBeTypeOf('string');
expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/
expect(notify.price).toContain('€');
expect(notify.size).toContain('m²');
expect(notify.title).not.toBe('');
expect(notify.link).toContain('https://www.immobilien.de');
expect(notify.address).not.toBe('');
});
resolve();
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
const listing = await fredy.execute();
if (listing == null || listing.length === 0) {
throw new Error('Listings is empty!');
}
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).toBeTypeOf('object');
expect(notificationObj.serviceName).toBe('immobilienDe');
notificationObj.payload.forEach((notify) => {
/** check the actual structure **/
expect(notify.id).toBeTypeOf('string');
expect(notify.price).toBeTypeOf('string');
expect(notify.size).toBeTypeOf('string');
expect(notify.title).toBeTypeOf('string');
expect(notify.link).toBeTypeOf('string');
expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/
expect(notify.price).toContain('');
expect(notify.size).toContain('m²');
expect(notify.title).not.toBe('');
expect(notify.link).toContain('https://www.immobilien.de');
expect(notify.address).not.toBe('');
});
});
describe('with provider_details enabled', () => {
beforeEach(() => {
vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] });
vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should enrich listings with details', async () => {
const Fredy = await mockFredy();
provider.init(providerConfig.immobilienDe, [], []);
const mockedJob = { id: 'test1', notificationAdapter: null, specFilter: null, spatialFilter: null };
const fredy = new Fredy(
provider.config,
mockedJob,
provider.metaInformation.id,
{ checkAndAddEntry: () => false },
undefined,
);
const listings = await fredy.execute();
if (listings == null) return;
expect(listings).toBeInstanceOf(Array);
listings.forEach((listing) => {
expect(listing.link).toContain('https://www.immobilien.de');
expect(listing.address).toBeTypeOf('string');
expect(listing.address).not.toBe('');
// description may be null if selectors don't match yet — falls back gracefully
if (listing.description != null) {
expect(listing.description).toBeTypeOf('string');
}
});
});
});

View File

@@ -3,38 +3,85 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { expect } from 'vitest';
import { expect, vi } from 'vitest';
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { mockFredy, providerConfig } from '../utils.js';
import { get } from '../mocks/mockNotification.js';
import * as provider from '../../lib/provider/immoscout.js';
import * as mockStore from '../mocks/mockStore.js';
describe('#immoscout provider testsuite()', () => {
provider.init(providerConfig.immoscout, [], []);
it('should test immoscout provider', async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, '', similarityCache);
const mockedJob = {
id: '',
notificationAdapter: null,
spatialFilter: null,
specFilter: null,
};
return await new Promise((resolve, reject) => {
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
fredy.execute().then((listings) => {
if (listings == null || listings.length === 0) {
reject('Listings is empty!');
return;
}
expect(listings).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).toBeTypeOf('object');
expect(notificationObj.serviceName).toBe('immoscout');
notificationObj.payload.forEach((notify) => {
/** check the actual structure **/
expect(notify.id).toBeTypeOf('string');
expect(notify.price).toBeTypeOf('string');
expect(notify.size).toBeTypeOf('string');
expect(notify.title).toBeTypeOf('string');
expect(notify.link).toBeTypeOf('string');
expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/
expect(notify.size).not.toBe('');
expect(notify.title).not.toBe('');
expect(notify.link).toContain('https://www.immobilienscout24.de/');
// check if there is at least one valid notification
const hasValidNotification = notificationObj.payload.some((notify) => {
return (
typeof notify.id === 'string' &&
typeof notify.price === 'string' &&
notify.price.includes('€') &&
typeof notify.size === 'string' &&
notify.size.includes('m²') &&
typeof notify.title === 'string' &&
notify.title !== '' &&
typeof notify.link === 'string' &&
notify.link.includes('https://www.immobilienscout24.de/') &&
typeof notify.address === 'string'
);
});
expect(hasValidNotification).toBe(true);
resolve();
});
});
});
describe('with provider_details enabled', () => {
beforeEach(() => {
vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] });
vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should enrich listings with details', async () => {
const Fredy = await mockFredy();
provider.init(providerConfig.immoscout, [], []);
const mockedJob = { id: '', notificationAdapter: null, specFilter: null, spatialFilter: null };
const fredy = new Fredy(
provider.config,
mockedJob,
provider.metaInformation.id,
{ checkAndAddEntry: () => false },
undefined,
);
const listings = await fredy.execute();
expect(listings).toBeInstanceOf(Array);
listings.forEach((listing) => {
expect(listing.description).toBeTypeOf('string');
expect(listing.description).not.toBe('');
});
});
});
});

View File

@@ -13,9 +13,22 @@ describe('#immoswp testsuite()', () => {
provider.init(providerConfig.immoswp, [], []);
it('should test immoswp provider', async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immoswp', similarityCache);
const mockedJob = {
id: 'immoswp',
notificationAdapter: null,
spatialFilter: null,
specFilter: null,
};
return await new Promise((resolve, reject) => {
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
fredy.execute().then((listing) => {
if (listing == null || listing.length === 0) {
reject('Listings is empty!');
return;
}
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).toBeTypeOf('object');
@@ -24,11 +37,13 @@ describe('#immoswp testsuite()', () => {
/** check the actual structure **/
expect(notify.id).toBeTypeOf('string');
expect(notify.price).toBeTypeOf('string');
expect(notify.price).toContain('€');
expect(notify.size).toBeTypeOf('string');
expect(notify.size).toContain('m²');
expect(notify.title).toBeTypeOf('string');
expect(notify.link).toBeTypeOf('string');
/** check the values if possible **/
expect(notify.price).toContain('€');
expect(notify.size).toBeTypeOf('string');
expect(notify.title).not.toBe('');
expect(notify.link).toContain('https://immo.swp.de');
});

View File

@@ -6,17 +6,29 @@
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 'vitest';
import { expect, vi } from 'vitest';
import * as provider from '../../lib/provider/immowelt.js';
import * as mockStore from '../mocks/mockStore.js';
describe('#immowelt testsuite()', () => {
it('should test immowelt provider', async () => {
const Fredy = await mockFredy();
const mockedJob = {
id: 'immowelt',
notificationAdapter: null,
spatialFilter: null,
specFilter: null,
};
provider.init(providerConfig.immowelt, [], []);
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immowelt', similarityCache);
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
const listing = await fredy.execute();
if (listing == null || listing.length === 0) {
throw new Error('Listings is empty!');
}
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).toBeTypeOf('object');
@@ -24,12 +36,16 @@ describe('#immowelt testsuite()', () => {
notificationObj.payload.forEach((notify) => {
/** check the actual structure **/
expect(notify.id).toBeTypeOf('string');
expect(notify.price).toBeTypeOf('string');
if (notify.price != null) {
expect(notify.price).toBeTypeOf('string');
expect(notify.price).toContain('€');
}
expect(notify.title).toBeTypeOf('string');
expect(notify.link).toBeTypeOf('string');
expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/
if (notify.size != null && notify.size.trim().toLowerCase() !== 'k.a.') {
expect(notify.size).toBeTypeOf('string');
expect(notify.size).toContain('m²');
}
expect(notify.title).not.toBe('');
@@ -37,4 +53,40 @@ describe('#immowelt testsuite()', () => {
expect(notify.address).not.toBe('');
});
});
describe('with provider_details enabled', () => {
beforeEach(() => {
vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] });
vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should enrich listings with details', async () => {
const Fredy = await mockFredy();
provider.init(providerConfig.immowelt, [], []);
const mockedJob = { id: 'immowelt', notificationAdapter: null, specFilter: null, spatialFilter: null };
const fredy = new Fredy(
provider.config,
mockedJob,
provider.metaInformation.id,
{ checkAndAddEntry: () => false },
undefined,
);
const listings = await fredy.execute();
expect(listings).toBeInstanceOf(Array);
listings.forEach((listing) => {
expect(listing.link).toContain('https://www.immowelt.de');
expect(listing.address).toBeTypeOf('string');
expect(listing.address).not.toBe('');
// description is enriched from the detail page; falls back gracefully if blocked
if (listing.description != null) {
expect(listing.description).toBeTypeOf('string');
}
});
});
});
});

View File

@@ -6,23 +6,29 @@
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 'vitest';
import { expect, vi } from 'vitest';
import * as provider from '../../lib/provider/kleinanzeigen.js';
import * as mockStore from '../mocks/mockStore.js';
describe('#kleinanzeigen testsuite()', () => {
it('should test kleinanzeigen provider', async () => {
const Fredy = await mockFredy();
const mockedJob = {
id: 'kleinanzeigen',
notificationAdapter: null,
spatialFilter: null,
specFilter: null,
};
provider.init(providerConfig.kleinanzeigen, [], []);
return await new Promise((resolve) => {
const fredy = new Fredy(
provider.config,
null,
null,
provider.metaInformation.id,
'kleinanzeigen',
similarityCache,
);
return await new Promise((resolve, reject) => {
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
fredy.execute().then((listing) => {
if (listing == null || listing.length === 0) {
reject('Listings is empty!');
return;
}
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).toBeTypeOf('object');
@@ -42,4 +48,38 @@ describe('#kleinanzeigen testsuite()', () => {
});
});
});
describe('with provider_details enabled', () => {
beforeEach(() => {
vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] });
vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should enrich listings with details', async () => {
const Fredy = await mockFredy();
provider.init(providerConfig.kleinanzeigen, [], []);
const mockedJob = { id: 'kleinanzeigen', notificationAdapter: null, specFilter: null, spatialFilter: null };
const fredy = new Fredy(
provider.config,
mockedJob,
provider.metaInformation.id,
{ checkAndAddEntry: () => false },
undefined,
);
const listings = await fredy.execute();
expect(listings).toBeInstanceOf(Array);
listings.forEach((listing) => {
expect(listing.link).toContain('https://www.kleinanzeigen.de');
expect(listing.address).toBeTypeOf('string');
expect(listing.address).not.toBe('');
expect(listing.description).toBeTypeOf('string');
expect(listing.description).not.toBe('');
});
});
});
});

View File

@@ -12,11 +12,22 @@ import * as provider from '../../lib/provider/mcMakler.js';
describe('#mcMakler testsuite()', () => {
it('should test mcMakler provider', async () => {
const Fredy = await mockFredy();
const mockedJob = {
id: 'mcMakler',
notificationAdapter: null,
spatialFilter: null,
specFilter: null,
};
provider.init(providerConfig.mcMakler, []);
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'mcMakler', similarityCache);
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
const listing = await fredy.execute();
if (listing == null || listing.length === 0) {
throw new Error('Listings is empty!');
}
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).toBeTypeOf('object');
@@ -25,12 +36,14 @@ describe('#mcMakler testsuite()', () => {
/** check the actual structure **/
expect(notify.id).toBeTypeOf('string');
expect(notify.price).toBeTypeOf('string');
expect(notify.price).toContain('€');
expect(notify.size).toBeTypeOf('string');
expect(notify.size).toContain('m²');
expect(notify.title).toBeTypeOf('string');
expect(notify.link).toBeTypeOf('string');
expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/
expect(notify.size).toContain('m²');
expect(notify.size).toBeTypeOf('string');
expect(notify.title).not.toBe('');
expect(notify.address).not.toBe('');
});

View File

@@ -13,16 +13,22 @@ describe('#neubauKompass testsuite()', () => {
provider.init(providerConfig.neubauKompass, [], []);
it('should test neubauKompass provider', async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => {
const fredy = new Fredy(
provider.config,
null,
null,
provider.metaInformation.id,
'neubauKompass',
similarityCache,
);
const mockedJob = {
id: 'neubauKompass',
notificationAdapter: null,
spatialFilter: null,
specFilter: null,
};
return await new Promise((resolve, reject) => {
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
fredy.execute().then((listing) => {
if (listing == null || listing.length === 0) {
reject('Listings is empty!');
return;
}
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj.serviceName).toBe('neubauKompass');

View File

@@ -12,11 +12,22 @@ import * as provider from '../../lib/provider/ohneMakler.js';
describe('#ohneMakler testsuite()', () => {
it('should test ohneMakler provider', async () => {
const Fredy = await mockFredy();
const mockedJob = {
id: 'ohneMakler',
notificationAdapter: null,
spatialFilter: null,
specFilter: null,
};
provider.init(providerConfig.ohneMakler, []);
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'ohneMakler', similarityCache);
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
const listing = await fredy.execute();
if (listing == null || listing.length === 0) {
throw new Error('Listings is empty!');
}
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).toBeTypeOf('object');
@@ -25,12 +36,14 @@ describe('#ohneMakler testsuite()', () => {
/** check the actual structure **/
expect(notify.id).toBeTypeOf('string');
expect(notify.price).toBeTypeOf('string');
expect(notify.price).toContain('€');
expect(notify.size).toBeTypeOf('string');
expect(notify.size).toContain('m²');
expect(notify.title).toBeTypeOf('string');
expect(notify.link).toBeTypeOf('string');
expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/
expect(notify.size).toContain('m²');
expect(notify.size).toBeTypeOf('string');
expect(notify.title).not.toBe('');
expect(notify.address).not.toBe('');
});

View File

@@ -12,18 +12,22 @@ import * as provider from '../../lib/provider/regionalimmobilien24.js';
describe('#regionalimmobilien24 testsuite()', () => {
it('should test regionalimmobilien24 provider', async () => {
const Fredy = await mockFredy();
const mockedJob = {
id: 'regionalimmobilien24',
notificationAdapter: null,
spatialFilter: null,
specFilter: null,
};
provider.init(providerConfig.regionalimmobilien24, []);
const fredy = new Fredy(
provider.config,
null,
null,
provider.metaInformation.id,
'regionalimmobilien24',
similarityCache,
);
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
const listing = await fredy.execute();
if (listing == null || listing.length === 0) {
throw new Error('Listings is empty!');
}
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).toBeTypeOf('object');
@@ -32,12 +36,14 @@ describe('#regionalimmobilien24 testsuite()', () => {
/** check the actual structure **/
expect(notify.id).toBeTypeOf('string');
expect(notify.price).toBeTypeOf('string');
expect(notify.price).toContain('€');
expect(notify.size).toBeTypeOf('string');
expect(notify.size).toContain('m²');
expect(notify.title).toBeTypeOf('string');
expect(notify.link).toBeTypeOf('string');
expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/
expect(notify.size).toContain('m²');
expect(notify.size).toBeTypeOf('string');
expect(notify.title).not.toBe('');
expect(notify.address).not.toBe('');
});

View File

@@ -6,17 +6,29 @@
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 'vitest';
import { expect, vi } from 'vitest';
import * as provider from '../../lib/provider/sparkasse.js';
import * as mockStore from '../mocks/mockStore.js';
describe('#sparkasse testsuite()', () => {
it('should test sparkasse provider', async () => {
const Fredy = await mockFredy();
const mockedJob = {
id: 'sparkasse',
notificationAdapter: null,
spatialFilter: null,
specFilter: null,
};
provider.init(providerConfig.sparkasse, []);
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'sparkasse', similarityCache);
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
const listing = await fredy.execute();
if (listing == null || listing.length === 0) {
throw new Error('Listings is empty!');
}
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).toBeTypeOf('object');
@@ -25,13 +37,53 @@ describe('#sparkasse testsuite()', () => {
/** check the actual structure **/
expect(notify.id).toBeTypeOf('string');
expect(notify.price).toBeTypeOf('string');
expect(notify.price).toContain('€');
expect(notify.size).toBeTypeOf('string');
expect(notify.size).toContain('m²');
expect(notify.title).toBeTypeOf('string');
expect(notify.link).toBeTypeOf('string');
expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/
expect(notify.size).toContain('m²');
expect(notify.size).toBeTypeOf('string');
expect(notify.title).not.toBe('');
expect(notify.address).not.toBe('');
});
});
describe('with provider_details enabled', () => {
beforeEach(() => {
vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] });
vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should enrich listings with details', async () => {
const Fredy = await mockFredy();
provider.init(providerConfig.sparkasse, []);
const mockedJob = { id: 'sparkasse', notificationAdapter: null, specFilter: null, spatialFilter: null };
const fredy = new Fredy(
provider.config,
mockedJob,
provider.metaInformation.id,
{ checkAndAddEntry: () => false },
undefined,
);
const listings = await fredy.execute();
expect(listings).toBeInstanceOf(Array);
listings.forEach((listing) => {
expect(listing.link).toContain('https://immobilien.sparkasse.de');
expect(listing.address).toBeTypeOf('string');
expect(listing.address).not.toBe('');
// description is enriched from the detail page; falls back gracefully if bot-detected
if (listing.description != null) {
expect(listing.description).toBeTypeOf('string');
expect(listing.description).not.toBe('');
}
});
});
});
});

View File

@@ -6,16 +6,30 @@
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 'vitest';
import { expect, vi } from 'vitest';
import * as provider from '../../lib/provider/wgGesucht.js';
import * as mockStore from '../mocks/mockStore.js';
describe('#wgGesucht testsuite()', () => {
provider.init(providerConfig.wgGesucht, [], []);
it('should test wgGesucht provider', async () => {
it('should test wgGesucht provider', { timeout: 120000 }, async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'wgGesucht', similarityCache);
const mockedJob = {
id: 'wgGesucht',
notificationAdapter: null,
spatialFilter: null,
specFilter: null,
};
return await new Promise((resolve, reject) => {
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
fredy.execute().then((listing) => {
if (listing == null || listing.length === 0) {
reject('Listings is empty!');
return;
}
expect(listing).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj.serviceName).toBe('wgGesucht');
@@ -24,12 +38,45 @@ describe('#wgGesucht testsuite()', () => {
/** check the actual structure **/
expect(notify.id).toBeTypeOf('string');
expect(notify.title).toBeTypeOf('string');
expect(notify.details).toBeTypeOf('string');
// expect(notify.details).toBeTypeOf('string');
expect(notify.price).toBeTypeOf('string');
expect(notify.price).toContain('€');
expect(notify.link).toBeTypeOf('string');
});
resolve();
});
});
});
describe('with provider_details enabled', () => {
beforeEach(() => {
vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] });
vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should enrich listings with details', async () => {
const Fredy = await mockFredy();
provider.init(providerConfig.wgGesucht, [], []);
const mockedJob = { id: 'wgGesucht', notificationAdapter: null, specFilter: null, spatialFilter: null };
const fredy = new Fredy(
provider.config,
mockedJob,
provider.metaInformation.id,
{ checkAndAddEntry: () => false },
undefined,
);
const listings = await fredy.execute();
expect(listings).toBeInstanceOf(Array);
listings.forEach((listing) => {
expect(listing.link).toContain('https://www.wg-gesucht.de');
expect(listing.description).toBeTypeOf('string');
expect(listing.description).not.toBe('');
});
});
});
});

View File

@@ -13,16 +13,22 @@ describe('#wohnungsboerse testsuite()', () => {
provider.init(providerConfig.wohnungsboerse, [], []);
it('should test wohnungsboerse provider', async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => {
const fredy = new Fredy(
provider.config,
null,
null,
provider.metaInformation.id,
'wohnungsboerse',
similarityCache,
);
const mockedJob = {
id: 'wohnungsboerse',
notificationAdapter: null,
spatialFilter: null,
specFilter: null,
};
return await new Promise((resolve, reject) => {
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
fredy.execute().then((listings) => {
if (listings == null || listings.length === 0) {
reject('Listings is empty!');
return;
}
expect(listings).toBeInstanceOf(Array);
const notificationObj = get();
expect(notificationObj).toBeTypeOf('object');
@@ -31,12 +37,14 @@ describe('#wohnungsboerse testsuite()', () => {
/** check the actual structure **/
expect(notify.id).toBeTypeOf('string');
expect(notify.price).toBeTypeOf('string');
expect(notify.price).toContain('€');
expect(notify.size).toBeTypeOf('string');
expect(notify.size).toContain('m²');
expect(notify.title).toBeTypeOf('string');
expect(notify.link).toBeTypeOf('string');
expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/
expect(notify.size).not.toBe('');
expect(notify.size).toBeTypeOf('string');
expect(notify.title).not.toBe('');
expect(notify.link).toContain('https://www.wohnungsboerse.net');
});

View File

@@ -8,7 +8,9 @@ import { readFile } from 'fs/promises';
import * as mockStore from './mocks/mockStore.js';
import { send } from './mocks/mockNotification.js';
export const providerConfig = JSON.parse(await readFile(new URL('./provider/testProvider.json', import.meta.url)));
export const providerConfig = JSON.parse(
await readFile(new URL('./provider/testProvider.json', import.meta.url), 'utf-8'),
);
vi.mock('../lib/services/storage/listingsStorage.js', () => mockStore);
vi.mock('../lib/services/storage/settingsStorage.js', () => mockStore);
@@ -20,7 +22,10 @@ vi.mock('../lib/services/storage/jobStorage.js', () => ({
}));
vi.mock('../lib/notification/notify.js', () => ({ send }));
/**
* @returns {Promise<typeof import('../lib/FredyPipelineExecutioner.js').default>}
*/
export const mockFredy = async () => {
const mod = await import('../lib/FredyPipelineExecutioner.js');
return mod.default ?? mod;
return mod.default;
};

View File

@@ -41,7 +41,7 @@ import { useNavigate } from 'react-router-dom';
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
import { useActions, useSelector } from '../../../services/state/store.js';
import { xhrDelete, xhrPut, xhrPost } from '../../../services/xhr.js';
import debounce from 'lodash/debounce';
import { debounce } from '../../../utils';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
import './JobGrid.less';

View File

@@ -25,6 +25,7 @@ import {
Empty,
Radio,
RadioGroup,
Space,
} from '@douyinfe/semi-ui-19';
import {
IconBriefcase,
@@ -47,7 +48,7 @@ import no_image from '../../../assets/no_image.jpg';
import * as timeService from '../../../services/time/timeService.js';
import { xhrDelete, xhrPost } from '../../../services/xhr.js';
import { useActions, useSelector } from '../../../services/state/store.js';
import debounce from 'lodash/debounce';
import { debounce } from '../../../utils';
import './ListingsGrid.less';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
@@ -90,7 +91,14 @@ const ListingsGrid = () => {
loadData();
}, [page, sortField, sortDir, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]);
const handleFilterChange = useMemo(() => debounce((value) => setFreeTextFilter(value || null), 500), []);
const handleFilterChange = useMemo(
() =>
debounce((value) => {
setFreeTextFilter(value || null);
setPage(1);
}, 500),
[],
);
useEffect(() => {
return () => {
@@ -152,6 +160,7 @@ const ListingsGrid = () => {
onChange={(e) => {
const v = e.target.value;
setActivityFilter(v === 'all' ? null : v === 'true');
setPage(1);
}}
>
<Radio value="all">All</Radio>
@@ -166,6 +175,7 @@ const ListingsGrid = () => {
onChange={(e) => {
const v = e.target.value;
setWatchListFilter(v === 'all' ? null : v === 'true');
setPage(1);
}}
>
<Radio value="all">All</Radio>
@@ -176,7 +186,10 @@ const ListingsGrid = () => {
<Select
placeholder="Provider"
showClear
onChange={(val) => setProviderFilter(val)}
onChange={(val) => {
setProviderFilter(val);
setPage(1);
}}
value={providerFilter}
style={{ width: 130 }}
>
@@ -190,7 +203,10 @@ const ListingsGrid = () => {
<Select
placeholder="Job"
showClear
onChange={(val) => setJobNameFilter(val)}
onChange={(val) => {
setJobNameFilter(val);
setPage(1);
}}
value={jobNameFilter}
style={{ width: 130 }}
>
@@ -278,12 +294,14 @@ const ListingsGrid = () => {
>
{item.address || 'No address provided'}
</Text>
<Text type="tertiary" size="small" icon={<IconClock />}>
{timeService.format(item.created_at, false)}
</Text>
<Text type="tertiary" size="small" icon={<IconBriefcase />}>
{item.provider.charAt(0).toUpperCase() + item.provider.slice(1)}
</Text>
<Space spacing={12} wrap>
<Text type="tertiary" size="small" icon={<IconBriefcase />}>
{item.provider.charAt(0).toUpperCase() + item.provider.slice(1)}
</Text>
<Text type="tertiary" size="small" icon={<IconClock />}>
{timeService.format(item.created_at, false)}
</Text>
</Space>
{item.distance_to_destination ? (
<Text type="tertiary" size="small" icon={<IconActivity />}>
{item.distance_to_destination} m to chosen address

View File

@@ -69,6 +69,7 @@
}
&--inactive {
.listingsGrid__imageContainer,
.listingsGrid__content {
opacity: 0.6;
@@ -169,4 +170,16 @@
background: var(--semi-color-primary-hover);
}
}
// Ensure icons and text are vertically aligned
.semi-typography {
display: inline-flex;
align-items: center;
.semi-typography-icon {
display: flex;
align-items: center;
margin-top: 1px; // Minor nudge if needed, but flex should handle most
}
}
}

View File

@@ -40,6 +40,12 @@ export const parseNullableBoolean = {
* @param {*} defaultValue - value when param is absent
* @param {{ parse: (s: string) => *, stringify: (v: *) => string|null }} [options]
*/
// WeakMap to store pending batched updates per setSearchParams function.
// This lets multiple useSearchParamState hooks on the same component batch
// their changes into a single setSearchParams call, preventing them from
// overwriting each other.
const pendingUpdates = new WeakMap();
export function useSearchParamState([searchParams, setSearchParams], key, defaultValue, options = {}) {
const { parse = (v) => v, stringify = (v) => String(v) } = options;
@@ -48,21 +54,42 @@ export function useSearchParamState([searchParams, setSearchParams], key, defaul
const setValue = useCallback(
(newValue) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
const serialized = stringify(newValue);
if (newValue === defaultValue || newValue === null || newValue === undefined || serialized === null) {
next.delete(key);
} else {
next.set(key, serialized);
}
return next;
},
{ replace: true },
);
// Collect the change
if (!pendingUpdates.has(setSearchParams)) {
pendingUpdates.set(setSearchParams, new Map());
// Schedule a single flush at the end of the current microtask
queueMicrotask(() => {
const updates = pendingUpdates.get(setSearchParams);
pendingUpdates.delete(setSearchParams);
if (!updates || updates.size === 0) return;
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
for (const [k, entry] of updates) {
if (entry.remove) {
next.delete(k);
} else {
next.set(k, entry.serialized);
}
}
return next;
},
{ replace: true },
);
});
}
const batch = pendingUpdates.get(setSearchParams);
const serialized = stringify(newValue);
if (newValue === defaultValue || newValue === null || newValue === undefined || serialized === null) {
batch.set(key, { remove: true });
} else {
batch.set(key, { remove: false, serialized });
}
},
[key, defaultValue, stringify],
[key, defaultValue, stringify, setSearchParams],
);
return [value, setValue];

View File

@@ -207,14 +207,17 @@ export const useFredyState = create(
filter,
}) {
try {
const qryString = queryString.stringify({
page,
pageSize,
freeTextFilter,
sortfield,
sortdir,
...filter,
});
const qryString = queryString.stringify(
{
page,
pageSize,
freeTextFilter,
sortfield,
sortdir,
...filter,
},
{ skipNull: true, skipEmptyString: true },
);
const response = await xhrGet(`/api/listings/table?${qryString}`);
set((state) => ({
listingsData: { ...state.listingsData, ...response.json },
@@ -304,17 +307,17 @@ export const useFredyState = create(
throw Exception;
}
},
async setImmoscoutDetails(enabled) {
async setProviderDetails(providers) {
try {
await xhrPost('/api/user/settings/immoscout-details', { immoscout_details: enabled });
await xhrPost('/api/user/settings/provider-details', { provider_details: providers });
set((state) => ({
userSettings: {
...state.userSettings,
settings: { ...state.userSettings.settings, immoscout_details: enabled },
settings: { ...state.userSettings.settings, provider_details: providers },
},
}));
} catch (Exception) {
console.error('Error while trying to update immoscout details setting. Error:', Exception);
console.error('Error while trying to update provider details setting. Error:', Exception);
throw Exception;
}
},

17
ui/src/utils.js Normal file
View File

@@ -0,0 +1,17 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
export function debounce(fn, delay) {
let timer;
function debounced(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
}
debounced.cancel = () => clearTimeout(timer);
return debounced;
}

View File

@@ -15,9 +15,8 @@ import {
Checkbox,
Input,
Modal,
Typography,
AutoComplete,
Switch,
Select,
Banner,
} from '@douyinfe/semi-ui-19';
import { InputNumber } from '@douyinfe/semi-ui-19';
@@ -30,11 +29,9 @@ import {
restore as clientRestore,
} from '../../services/backupRestoreClient';
import { IconSave, IconRefresh, IconSignal, IconHome, IconFolder } from '@douyinfe/semi-icons';
import debounce from 'lodash/debounce';
import { debounce } from '../../utils';
import './GeneralSettings.less';
const { Text } = Typography;
function formatFromTimestamp(ts) {
const date = new Date(ts);
return `${date.getHours()}:${date.getMinutes() > 9 ? date.getMinutes() : '0' + date.getMinutes()}`;
@@ -72,7 +69,8 @@ const GeneralSettings = function GeneralSettings() {
// User settings state
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
const immoscoutDetails = useSelector((state) => state.userSettings.settings.immoscout_details);
const providerDetails = useSelector((state) => state.userSettings.settings.provider_details);
const allProviders = useSelector((state) => state.provider);
const [address, setAddress] = useState(homeAddress?.address || '');
const [coords, setCoords] = useState(homeAddress?.coords || null);
const saving = useIsLoading(actions.userSettings.setHomeAddress);
@@ -373,39 +371,6 @@ const GeneralSettings = function GeneralSettings() {
</div>
</TabPane>
<TabPane
tab={
<span>
<IconFolder size="small" style={{ marginRight: 6 }} />
Backup & Restore
</span>
}
itemKey="backup"
>
<div className="generalSettings__tab-content">
<SegmentPart
name="Backup & Restore"
helpText="Download a zipped backup of your database or restore from a backup zip."
>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<Button theme="solid" icon={<IconSave />} onClick={handleDownloadBackup}>
Download Backup
</Button>
<input
type="file"
accept=".zip,application/zip"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleSelectRestoreFile}
/>
<Button onClick={handleOpenFilePicker} theme="light" icon={<IconFolder />}>
Restore from Zip
</Button>
</div>
</SegmentPart>
</div>
</TabPane>
<TabPane
tab={
<span>
@@ -440,29 +405,30 @@ const GeneralSettings = function GeneralSettings() {
</SegmentPart>
<SegmentPart
name="ImmoScout Details"
helpText="Fetch additional details (description, attributes, agent info) for ImmoScout listings. Makes an extra API call per listing."
name="Provider Details"
helpText="Fetch additional details (description, attributes, agent info) for listings. Needs an extra API call per listing."
>
<Banner
type="warning"
description="Enabling this significantly increases API requests to ImmoScout, raising the chance of rate limiting or blocking. Use at your own risk."
description="Enabling this significantly increases API requests to providers that have implemented this feature, raising the chance of rate limiting or blocking. Use at your own risk."
closeIcon={null}
style={{ marginBottom: 12 }}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Switch
checked={!!immoscoutDetails}
onChange={async (checked) => {
try {
await actions.userSettings.setImmoscoutDetails(checked);
Toast.success('ImmoScout details setting updated.');
} catch {
Toast.error('Failed to update setting.');
}
}}
/>
<Text>Fetch detailed ImmoScout listings</Text>
</div>
<Select
multiple
style={{ width: '100%' }}
value={Array.isArray(providerDetails) ? providerDetails : []}
optionList={(allProviders ?? []).map((p) => ({ label: p.name, value: p.id }))}
placeholder="Select providers to fetch details from..."
onChange={async (selected) => {
try {
await actions.userSettings.setProviderDetails(selected);
Toast.success('Provider details setting updated.');
} catch {
Toast.error('Failed to update setting.');
}
}}
/>
</SegmentPart>
<div className="generalSettings__save-row">
@@ -478,6 +444,39 @@ const GeneralSettings = function GeneralSettings() {
</div>
</div>
</TabPane>
<TabPane
tab={
<span>
<IconFolder size="small" style={{ marginRight: 6 }} />
Backup & Restore
</span>
}
itemKey="backup"
>
<div className="generalSettings__tab-content">
<SegmentPart
name="Backup & Restore"
helpText="Download a zipped backup of your database or restore from a backup zip."
>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<Button theme="solid" icon={<IconSave />} onClick={handleDownloadBackup}>
Download Backup
</Button>
<input
type="file"
accept=".zip,application/zip"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleSelectRestoreFile}
/>
<Button onClick={handleOpenFilePicker} theme="light" icon={<IconFolder />}>
Restore from Zip
</Button>
</div>
</SegmentPart>
</div>
</TabPane>
</Tabs>
</>
)}

View File

@@ -24,9 +24,15 @@ import {
IconPlayCircle,
IconPlusCircle,
IconUser,
IconClear,
IconFilter,
} from '@douyinfe/semi-icons';
const SPEC_FILTERS = [
{ key: 'maxPrice', translation: 'Max Price' },
{ key: 'minSize', translation: 'Min Size (m²)' },
{ key: 'minRooms', translation: 'Min Rooms' },
];
export default function JobMutator() {
const jobs = useSelector((state) => state.jobsData.jobs);
const shareableUserList = useSelector((state) => state.jobsData.shareableUserList);
@@ -46,6 +52,7 @@ export default function JobMutator() {
const defaultEnabled = sourceJob?.enabled ?? true;
const defaultShareWithUsers = sourceJob?.shared_with_user ?? [];
const defaultSpatialFilter = sourceJob?.spatialFilter || null;
const defaultSpecFilter = sourceJob?.specFilter || null;
const [providerToEdit, setProviderToEdit] = useState(null);
const [providerCreationVisible, setProviderCreationVisibility] = useState(false);
@@ -58,6 +65,7 @@ export default function JobMutator() {
const [shareWithUsers, setShareWithUsers] = useState(defaultShareWithUsers);
const [enabled, setEnabled] = useState(defaultEnabled);
const [spatialFilter, setSpatialFilter] = useState(defaultSpatialFilter);
const [specFilter, setSpecFilter] = useState(defaultSpecFilter);
const navigate = useNavigate();
const actions = useActions();
@@ -66,6 +74,12 @@ export default function JobMutator() {
setSpatialFilter(data);
}, []);
const handleSpecFilterChange = (key, value) => {
if (!SPEC_FILTERS.map(({ key }) => key).includes(key)) return;
setSpecFilter({ ...specFilter, [key]: value ? parseFloat(value) : null });
};
const isSavingEnabled = () => {
return Boolean(notificationAdapterData.length && providerData.length && name);
};
@@ -85,6 +99,7 @@ export default function JobMutator() {
name,
blacklist,
spatialFilter,
specFilter,
enabled,
jobId: jobToBeEdit?.id || null,
});
@@ -204,7 +219,7 @@ export default function JobMutator() {
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart
Icon={IconClear}
Icon={IconFilter}
name="Blacklist"
helpText="If a listing contains one of these words, it will be filtered out. Type in a word, then hit enter."
>
@@ -216,6 +231,27 @@ export default function JobMutator() {
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart
Icon={IconFilter}
name="Criteria Filter"
helpText="Filter listings by specific criteria. Only numbers are allowed. You can leave fields empty if you don't want to filter by them."
>
<div className="jobMutation__specFilter">
{SPEC_FILTERS.map((filter) => (
<div key={filter.key} className="jobMutation__specFilterItem">
<div className="jobMutation__specFilterLabel">{filter.translation}</div>
<Input
type="number"
placeholder="Add a number"
value={specFilter?.[filter.key]}
onChange={(value) => handleSpecFilterChange(filter.key, value)}
/>
</div>
))}
</div>
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart
Icon={IconFilter}
name="Area Filter"
helpText="Define multiple geographic areas on the map to filter listings. Start drawing by clicking on the square symbol in the top left corner of the map. Click on the map to add points of the polygon. Select the first point to close the polygon. After that, click on a free area of the map to apply this polygon (the color will change from yellow to blue). To delete a polygon, select it first and then click on the trash symbol."
>

View File

@@ -3,6 +3,24 @@
float: right;
margin-bottom: 1rem;
}
&__specFilter {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
}
&__specFilterItem {
display: flex;
flex-direction: column;
gap: 0.5rem;
flex: 1;
min-width: 150px;
}
&__specFilterLabel {
font-weight: 500;
}
}
.semi-select-option-list-wrapper {

View File

@@ -156,14 +156,21 @@ export default function NotificationAdapterMutator({
return (
<Form key={key}>
{uiElement.type === 'boolean' ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<Switch
checked={uiElement.value || false}
onChange={(checked) => {
setValue(selectedAdapter, uiElement, key, checked);
}}
/>
{uiElement.label}
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<Switch
checked={uiElement.value || false}
onChange={(checked) => {
setValue(selectedAdapter, uiElement, key, checked);
}}
/>
{uiElement.label}
</div>
{uiElement.description && (
<div className="semi-form-field-extra" style={{ marginTop: '4px' }}>
{uiElement.description}
</div>
)}
</div>
) : (
<Form.Input

View File

@@ -31,7 +31,8 @@ import {
IconLink,
IconStar,
IconStarStroked,
IconRealSize,
IconExpand,
IconGridView,
} from '@douyinfe/semi-icons';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
@@ -259,6 +260,17 @@ export default function ListingDetail() {
if (!listing) return null;
const data = [
{ key: 'Price', value: `${listing.price}`, Icon: <IconCart /> },
{
key: 'Size',
value: listing.size ? `${listing.size}` : 'N/A',
Icon: <IconExpand />,
},
{
key: 'Rooms',
value: listing.rooms ? `${listing.rooms} Rooms` : 'N/A',
Icon: <IconGridView />,
},
{
key: 'Job',
value: listing.job_name,
@@ -269,12 +281,6 @@ export default function ListingDetail() {
value: listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1),
Icon: <IconBriefcase />,
},
{ key: 'Price', value: `${listing.price}`, Icon: <IconCart /> },
{
key: 'Size',
value: listing.size ? `${listing.size}` : 'N/A',
Icon: <IconRealSize />,
},
{
key: 'Added',
value: timeService.format(listing.created_at),

View File

@@ -1,124 +0,0 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { useEffect, useState, useMemo } from 'react';
import { Divider, Button, AutoComplete, Toast, Banner, Switch } from '@douyinfe/semi-ui-19';
import { IconSave, IconHome, IconSearch } from '@douyinfe/semi-icons';
import { useSelector, useActions, useIsLoading } from '../../services/state/store';
import { xhrGet } from '../../services/xhr';
import { SegmentPart } from '../../components/segment/SegmentPart';
import debounce from 'lodash/debounce';
const UserSettings = () => {
const actions = useActions();
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
const immoscoutDetails = useSelector((state) => state.userSettings.settings.immoscout_details);
const [address, setAddress] = useState(homeAddress?.address || '');
const [coords, setCoords] = useState(homeAddress?.coords || null);
const saving = useIsLoading(actions.userSettings.setHomeAddress);
const [dataSource, setDataSource] = useState([]);
useEffect(() => {
setAddress(homeAddress?.address || '');
setCoords(homeAddress?.coords || null);
}, [homeAddress]);
const handleSave = async () => {
try {
const responseJson = await actions.userSettings.setHomeAddress(address);
setCoords(responseJson.coords);
await actions.userSettings.getUserSettings();
Toast.success(
'Settings saved successfully. We will now start calculating distances for you. This may take a while and runs in the background.',
);
} catch (error) {
Toast.error(error.json?.error || 'Error while saving settings');
}
};
const debouncedSearch = useMemo(
() =>
debounce((value) => {
xhrGet(`/api/user/settings/autocomplete?q=${encodeURIComponent(value)}`)
.then((response) => {
if (response.status === 200) {
setDataSource(response.json);
}
})
.catch(() => {
// Silently fail for autocomplete
});
}, 300),
[],
);
const searchAddress = (value) => {
if (!value) {
setDataSource([]);
return;
}
debouncedSearch(value);
};
return (
<div className="user-settings">
<SegmentPart
name="Distance claculation"
Icon={IconHome}
helpText="The address you enter is used to calculate the distance between your chosen location and each listing. The distance is computed using an approximate mathematical method and is intended to give you a rough indication of commute time. If you update your address, we will recalculate the distance for all active listings."
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', maxWidth: '600px' }}>
<AutoComplete
data={dataSource}
value={address}
showClear
onChange={(v) => setAddress(v)}
onSearch={searchAddress}
placeholder="Enter your home address"
style={{ width: '100%' }}
/>
{coords && coords.lat === -1 && (
<Banner type="danger" description="Address found but could not be geocoded accurately." closeIcon={null} />
)}
</div>
</SegmentPart>
<Divider />
<SegmentPart
name="ImmoScout Details"
Icon={IconSearch}
helpText="When enabled, Fredy will fetch additional details (description, attributes, agent info) for each listing from ImmoScout. This provides richer notifications but makes an extra API call per listing."
>
<Banner
type="warning"
description="Enabling this feature significantly increases the number of API requests to ImmoScout. This raises the likelihood of being detected and rate-limited or blocked. Use at your own risk."
closeIcon={null}
style={{ marginBottom: '12px', maxWidth: '600px' }}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Switch
checked={!!immoscoutDetails}
onChange={async (checked) => {
try {
await actions.userSettings.setImmoscoutDetails(checked);
Toast.success('ImmoScout details setting updated.');
} catch {
Toast.error('Failed to update setting.');
}
}}
/>
<span>Fetch detailed ImmoScout listings</span>
</div>
</SegmentPart>
<Divider />
<div style={{ marginTop: '20px' }}>
<Button icon={<IconSave />} theme="solid" type="primary" onClick={handleSave} loading={saving}>
Save Settings
</Button>
</div>
</div>
);
};
export default UserSettings;

View File

@@ -15,6 +15,7 @@ export default mergeConfig(
'test/provider/immonet.test.js',
'test/provider/immobilienDe.test.js',
'test/provider/immowelt.test.js',
'test/provider/sparkasse.test.js',
],
},
}),

597
yarn.lock
View File

@@ -890,34 +890,34 @@
dependencies:
tslib "^2.0.0"
"@douyinfe/semi-animation-react@2.93.0":
version "2.93.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-react/-/semi-animation-react-2.93.0.tgz#ccfd8e0f23ab4813cc74a1fcf878bbf153802999"
integrity sha512-jXuFRTjGEAyfSkqZmdZY5OiDFwwb0hk8HDIRRq3t2UNbgYQz5tWzM9cew8bjQeOdEpmhDTxUNaEeda/RY5oBQA==
"@douyinfe/semi-animation-react@2.94.1":
version "2.94.1"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-react/-/semi-animation-react-2.94.1.tgz#f3a2ea9de435ea11404a489f61c9127ea23d747f"
integrity sha512-I0ZTkHLPyIttSdsn8NsQOOTGB41cMI1NC3TFp3HaWKaSe15lEyhOMzzXdpfHUenImWf4sd/FRRkEqTXh9AzJJg==
dependencies:
"@douyinfe/semi-animation" "2.93.0"
"@douyinfe/semi-animation-styled" "2.93.0"
"@douyinfe/semi-animation" "2.94.1"
"@douyinfe/semi-animation-styled" "2.94.1"
classnames "^2.2.6"
"@douyinfe/semi-animation-styled@2.93.0":
version "2.93.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-styled/-/semi-animation-styled-2.93.0.tgz#5f2cffb7bdd87eb3f9282bc42a10511f53ed3401"
integrity sha512-qI4R8S9n2/OBpvDKpWLJpyWlU3eEUwXt4AgNRa1eSYQ0slH+CLJvndluE3rydIM6cFQPCdhIbgzlUPBMX+OqNA==
"@douyinfe/semi-animation-styled@2.94.1":
version "2.94.1"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-styled/-/semi-animation-styled-2.94.1.tgz#7d4ccd0a203d2bf9f466f1c92ff63c2e3081a8a0"
integrity sha512-72Wy762OWMkDrMRbSg4zCZz2NUM0tZrvFmG7Ya6nVvNJrF0sc0KPRk3iD75wO01yKJGr9lBf+yKSQc+tNrOEpg==
"@douyinfe/semi-animation@2.93.0":
version "2.93.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation/-/semi-animation-2.93.0.tgz#27631c4a49351c8d7b37e75b9c29f57ef6505c14"
integrity sha512-8CHwih93Huarsk8MYZQ09kRDT65h6CpSibAKdLAr8GZzTkds/RpBQ/5tNRkPheunvkOZEY7Q0ug0CzryoOJB6w==
"@douyinfe/semi-animation@2.94.1":
version "2.94.1"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation/-/semi-animation-2.94.1.tgz#d3c8e8287082e57fda982a8be75225f978624a87"
integrity sha512-hp+7L8KIIXR9mqpCkwnww2MydPwEjwTEcVUClhqaRq8ZB5ct53K+mlRJBp6p/IMMaIiwalhnRHX6/WF+89gjPg==
dependencies:
bezier-easing "^2.1.0"
"@douyinfe/semi-foundation@2.93.0":
version "2.93.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-foundation/-/semi-foundation-2.93.0.tgz#fcd7a86f671e2e835e91f7d4322521537f5650bd"
integrity sha512-BxCbSeX6oUv8hUor5gK9Qdg8Mq8qXGHlCmBN3FDGjNVi1wgMYgDGA3gx6SUB20FA+0ZjPifgoaovCf7P6K50oQ==
"@douyinfe/semi-foundation@2.94.1":
version "2.94.1"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-foundation/-/semi-foundation-2.94.1.tgz#aba8a6ff0a98c50bc6603922676a6373bbcfb86f"
integrity sha512-y95pFSy+m8GMoBPsqJD8kn9rJ5OWp/SfHIEyn1YVRmEM0bUG9QS8F53XsAMJzlcCTkPSCVUQLisx4AGDAbyZKA==
dependencies:
"@douyinfe/semi-animation" "2.93.0"
"@douyinfe/semi-json-viewer-core" "2.93.0"
"@douyinfe/semi-animation" "2.94.1"
"@douyinfe/semi-json-viewer-core" "2.94.1"
"@mdx-js/mdx" "^3.0.1"
async-validator "^3.5.0"
classnames "^2.2.6"
@@ -925,50 +925,50 @@
date-fns-tz "^1.3.8"
fast-copy "^3.0.1 "
lodash "^4.17.21"
lottie-web "^5.12.2"
lottie-web "^5.13.0"
memoize-one "^5.2.1"
prismjs "^1.29.0"
remark-gfm "^4.0.0"
scroll-into-view-if-needed "^2.2.24"
"@douyinfe/semi-icons@2.93.0", "@douyinfe/semi-icons@^2.93.0":
version "2.93.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-icons/-/semi-icons-2.93.0.tgz#2b403871fc658a673c6385ff7b36d6ebe4adfb13"
integrity sha512-NYcF7x+UsPtD1Cc4r5a9HqgY6USI9JAH8Tun1BC/il7x/Wcv+P16Her4Ccg55ljg3TeJzA7W8fvYjfbAhF/EEw==
"@douyinfe/semi-icons@2.94.1", "@douyinfe/semi-icons@^2.94.1":
version "2.94.1"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-icons/-/semi-icons-2.94.1.tgz#e6699d55731ef77167bd91912327b9215b6becda"
integrity sha512-BgqlV3fCsQcKPffB2YKZXuoeyzAC8B/j8wgaIFRSRS3EDPk+jTlLtbZEGOzRGvKtPsz4NjirgfobL6hjbNR9Yg==
dependencies:
classnames "^2.2.6"
"@douyinfe/semi-illustrations@2.93.0":
version "2.93.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-illustrations/-/semi-illustrations-2.93.0.tgz#e6bcceb7f7ff40d36d948c5048214df1c3b95efe"
integrity sha512-HdrAmM+whSGq+w5+jKYrcMjwWzrjnPjQPJMYz7c4pspLS6ZeRMQ6G1EvL5uio1OwRwBW1xqCFMGRypBs92KCKQ==
"@douyinfe/semi-illustrations@2.94.1":
version "2.94.1"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-illustrations/-/semi-illustrations-2.94.1.tgz#abb6aef1567b9e6e4a4795dca64a7baea0b5166d"
integrity sha512-ThRW0R6FK0JhGhfRkJHOoeZqZTUAmnAM/91TxxOyLZ7cvukdB57BOQ+nTgNdSTDxqap+WmKg8/Ita154j5k+6A==
"@douyinfe/semi-json-viewer-core@2.93.0":
version "2.93.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-json-viewer-core/-/semi-json-viewer-core-2.93.0.tgz#8bac5092c166161ccf6a2558bd9b05e3ed3fdae9"
integrity sha512-91hpOYqY1mzWkIq3pfb6T4VC7NrxIsYPNTulqWjfURNuQm+RA9Ek+H/TQU+2Hv8PRzdEUUyrKgne6t35/dYkmg==
"@douyinfe/semi-json-viewer-core@2.94.1":
version "2.94.1"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-json-viewer-core/-/semi-json-viewer-core-2.94.1.tgz#230f679e40b9f6c2dc2409b8981fd5abdf472693"
integrity sha512-p1BR0NsEiHV3FJSTiUZqy7cXlL+616AicS6aNL3nxPYzwcK6eoG9Xeadjv/zsX7HbelxL7FADgt2zbTxMZzdag==
dependencies:
jsonc-parser "^3.3.1"
"@douyinfe/semi-theme-default@2.93.0":
version "2.93.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-theme-default/-/semi-theme-default-2.93.0.tgz#74600b1ee93058236824e8419bd3f1e0280dbbc8"
integrity sha512-3LZvwvmoSrXnP85tQ6rMQsd8Ss004I7Psk8/Qvzhz/4x6F9IwIdpHbSqY5HCMDZIbi7ZpIuOs1n2lsf8esTDMg==
"@douyinfe/semi-theme-default@2.94.1":
version "2.94.1"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-theme-default/-/semi-theme-default-2.94.1.tgz#fd19b98b3e137526671f1192c8af8a8c92b73c7d"
integrity sha512-RFNyOvi2w2SyDaKUmXz0DgAh6TY0EkfqUmbRGUHh0juVB0qb8/A37zMFsxDZfFXZ3t02nG5rIXJrOQWCmK7/OA==
"@douyinfe/semi-ui-19@^2.93.0":
version "2.93.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-ui-19/-/semi-ui-19-2.93.0.tgz#74018a90142db0eab6e83e77545335749118fa82"
integrity sha512-HgWZN333p+ktlsbncODLVwpVjMiOaMgc5tSwFl/K/qFFvvF4P4iwvaf1qD8pWqauuu1h5YxGt0Shi770oIjm8g==
"@douyinfe/semi-ui-19@^2.94.1":
version "2.94.1"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-ui-19/-/semi-ui-19-2.94.1.tgz#46efa4846dc7712cdf01375f1e4093fc65998de1"
integrity sha512-FGytGKSEdmfu9IA2bZkDTVYu9V5TbuHYWsQpAYM5loiDL4+yvVHdISzs3pJaimfPgdHIYYciu1FwtGvPnn2gTQ==
dependencies:
"@dnd-kit/core" "^6.0.8"
"@dnd-kit/sortable" "^7.0.2"
"@dnd-kit/utilities" "^3.2.1"
"@douyinfe/semi-animation" "2.93.0"
"@douyinfe/semi-animation-react" "2.93.0"
"@douyinfe/semi-foundation" "2.93.0"
"@douyinfe/semi-icons" "2.93.0"
"@douyinfe/semi-illustrations" "2.93.0"
"@douyinfe/semi-theme-default" "2.93.0"
"@douyinfe/semi-animation" "2.94.1"
"@douyinfe/semi-animation-react" "2.94.1"
"@douyinfe/semi-foundation" "2.94.1"
"@douyinfe/semi-icons" "2.94.1"
"@douyinfe/semi-illustrations" "2.94.1"
"@douyinfe/semi-theme-default" "2.94.1"
"@tiptap/core" "^3.10.7"
"@tiptap/extension-document" "^3.10.7"
"@tiptap/extension-hard-break" "^3.10.7"
@@ -997,20 +997,20 @@
scroll-into-view-if-needed "^2.2.24"
utility-types "^3.10.0"
"@douyinfe/semi-ui@2.93.0":
version "2.93.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-ui/-/semi-ui-2.93.0.tgz#aad1c5116041a4803aee7a23f32d005b9e253832"
integrity sha512-u3NGgFZKHv3zbK0iC/vYnP8SkZFrMAcAAW52XbO96XGeruUPH9dkQ3qMSCbDQM5h2yPTkICElCgT/9YqhduXgQ==
"@douyinfe/semi-ui@2.94.1":
version "2.94.1"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-ui/-/semi-ui-2.94.1.tgz#02b354d3389cdbc2c53549b0b3f810f0922dc91b"
integrity sha512-YAKz6MNpPgZUHgn5kMOV+NznEpoYJr8UlmEIN4RiXEwD4WYzJmlTVMf7osy1yO+ivBD5fhGFSQOv1h+Clj5Vxw==
dependencies:
"@dnd-kit/core" "^6.0.8"
"@dnd-kit/sortable" "^7.0.2"
"@dnd-kit/utilities" "^3.2.1"
"@douyinfe/semi-animation" "2.93.0"
"@douyinfe/semi-animation-react" "2.93.0"
"@douyinfe/semi-foundation" "2.93.0"
"@douyinfe/semi-icons" "2.93.0"
"@douyinfe/semi-illustrations" "2.93.0"
"@douyinfe/semi-theme-default" "2.93.0"
"@douyinfe/semi-animation" "2.94.1"
"@douyinfe/semi-animation-react" "2.94.1"
"@douyinfe/semi-foundation" "2.94.1"
"@douyinfe/semi-icons" "2.94.1"
"@douyinfe/semi-illustrations" "2.94.1"
"@douyinfe/semi-theme-default" "2.94.1"
"@tiptap/core" "^3.10.7"
"@tiptap/extension-document" "^3.10.7"
"@tiptap/extension-hard-break" "^3.10.7"
@@ -1039,6 +1039,14 @@
scroll-into-view-if-needed "^2.2.24"
utility-types "^3.10.0"
"@emnapi/core@1.9.2":
version "1.9.2"
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.9.2.tgz#3870265ecffc7352d01ead62d8d83d8358a2d034"
integrity sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==
dependencies:
"@emnapi/wasi-threads" "1.2.1"
tslib "^2.4.0"
"@emnapi/core@^1.7.1":
version "1.9.0"
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.9.0.tgz#4a54213b208fcf288cce25076c74e0f7613e6100"
@@ -1047,6 +1055,13 @@
"@emnapi/wasi-threads" "1.2.0"
tslib "^2.4.0"
"@emnapi/runtime@1.9.2":
version "1.9.2"
resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.9.2.tgz#8b469a3db160817cadb1de9050211a9d1ea84fa2"
integrity sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==
dependencies:
tslib "^2.4.0"
"@emnapi/runtime@^1.7.1":
version "1.9.0"
resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.9.0.tgz#91c54a6e77c36154c125e873409472e2b70efd5b"
@@ -1061,6 +1076,13 @@
dependencies:
tslib "^2.4.0"
"@emnapi/wasi-threads@1.2.1":
version "1.2.1"
resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz#28fed21a1ba1ce797c44a070abc94d42f3ae8548"
integrity sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==
dependencies:
tslib "^2.4.0"
"@eslint-community/eslint-utils@^4.8.0":
version "4.9.0"
resolved "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz"
@@ -1073,26 +1095,26 @@
resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b"
integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==
"@eslint/config-array@^0.23.3":
version "0.23.3"
resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.23.3.tgz#3f4a93dd546169c09130cbd10f2415b13a20a219"
integrity sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==
"@eslint/config-array@^0.23.4":
version "0.23.4"
resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.23.4.tgz#b4e160e668694011b355bfe9a89eb61a0eb641c4"
integrity sha512-lf19F24LSMfF8weXvW5QEtnLqW70u7kgit5e9PSx0MsHAFclGd1T9ynvWEMDT1w5J4Qt54tomGeAhdoAku1Xow==
dependencies:
"@eslint/object-schema" "^3.0.3"
"@eslint/object-schema" "^3.0.4"
debug "^4.3.1"
minimatch "^10.2.4"
"@eslint/config-helpers@^0.5.3":
version "0.5.3"
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.5.3.tgz#721fe6bbb90d74b0c80d6ff2428e5bbcb002becb"
integrity sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==
"@eslint/config-helpers@^0.5.4":
version "0.5.4"
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.5.4.tgz#0b16c091dd16766f27e41f09bd264e3585a45652"
integrity sha512-jJhqiY3wPMlWWO3370M86CPJ7pt8GmEwSLglMfQhjXal07RCvhmU0as4IuUEW5SJeunfItiEetHmSxCCe9lDBg==
dependencies:
"@eslint/core" "^1.1.1"
"@eslint/core" "^1.2.0"
"@eslint/core@^1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.1.1.tgz#450f3d2be2d463ccd51119544092256b4e88df32"
integrity sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==
"@eslint/core@^1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.2.0.tgz#3f741da0119058ad2a567a6f215677b5557a19e9"
integrity sha512-8FTGbNzTvmSlc4cZBaShkC6YvFMG0riksYWRFKXztqVdXaQbcZLXlFbSpC05s70sGEsXAw0qwhx69JiW7hQS7A==
dependencies:
"@types/json-schema" "^7.0.15"
@@ -1101,17 +1123,17 @@
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-10.0.1.tgz#1e8a876f50117af8ab67e47d5ad94d38d6622583"
integrity sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==
"@eslint/object-schema@^3.0.3":
version "3.0.3"
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-3.0.3.tgz#5bf671e52e382e4adc47a9906f2699374637db6b"
integrity sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==
"@eslint/object-schema@^3.0.4":
version "3.0.4"
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-3.0.4.tgz#8ce3aff08f6ca7c3bd9e1cec34530fc7fb44546a"
integrity sha512-55lO/7+Yp0ISKRP0PsPtNTeNGapXaO085aELZmWCVc5SH3jfrqpuU6YgOdIxMS99ZHkQN1cXKE+cdIqwww9ptw==
"@eslint/plugin-kit@^0.6.1":
version "0.6.1"
resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz#eb9e6689b56ce8bc1855bb33090e63f3fc115e8e"
integrity sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==
"@eslint/plugin-kit@^0.7.0":
version "0.7.0"
resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.7.0.tgz#7442f663da4d61091d2af0b30c8a6b50949fb26d"
integrity sha512-ejvBr8MQCbVsWNZnCwDXjUKq40MDmHalq7cJ6e9s/qzTUFIIo/afzt1Vui9T97FM/V/pN4YsFVoed5NIa96RDg==
dependencies:
"@eslint/core" "^1.1.1"
"@eslint/core" "^1.2.0"
levn "^0.4.1"
"@floating-ui/core@^1.7.3":
@@ -1269,17 +1291,17 @@
resolved "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz"
integrity sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==
"@maplibre/geojson-vt@^6.0.4":
version "6.0.4"
resolved "https://registry.yarnpkg.com/@maplibre/geojson-vt/-/geojson-vt-6.0.4.tgz#f028fa633594c067b4c24030c1c282c0dd6cc835"
integrity sha512-HYv3POhMRCdhP3UPPATM/hfcy6/WuVIf5FKboH8u/ZuFMTnAIcSVlq5nfOqroLokd925w2QtE7YwquFOIacwVQ==
"@maplibre/geojson-vt@^6.1.0":
version "6.1.0"
resolved "https://registry.yarnpkg.com/@maplibre/geojson-vt/-/geojson-vt-6.1.0.tgz#c59a2b63a90e6214b2095358837b60f46c172e95"
integrity sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ==
dependencies:
kdbush "^4.0.2"
"@maplibre/maplibre-gl-style-spec@^24.7.0":
version "24.7.0"
resolved "https://registry.yarnpkg.com/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.7.0.tgz#46e1109303393d15545eb97eb333991c5663f75d"
integrity sha512-Ed7rcKYU5iELfablg9Mj+TVCsXsPBgdMyXPRAxb2v7oWg9YJnpQdZ5msDs1LESu/mtXy3Z48Vdppv2t/x5kAhw==
"@maplibre/maplibre-gl-style-spec@^24.8.1":
version "24.8.1"
resolved "https://registry.yarnpkg.com/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.8.1.tgz#8d4d48591750529ce586f9ee5f836ed97870bce5"
integrity sha512-zxa92qF96ZNojLxeAjnaRpjVCy+swoUNJvDhtpC90k7u5F0TMr4GmvNqMKvYrMoPB8d7gRSXbMG1hBbmgESIsw==
dependencies:
"@mapbox/jsonlint-lines-primitives" "~2.0.2"
"@mapbox/unitbezier" "^0.0.1"
@@ -1372,6 +1394,13 @@
"@emnapi/runtime" "^1.7.1"
"@tybys/wasm-util" "^0.10.1"
"@napi-rs/wasm-runtime@^1.1.3":
version "1.1.4"
resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz#a46bbfedc29751b7170c5d23bc1d8ee8c7e3c1e1"
integrity sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==
dependencies:
"@tybys/wasm-util" "^0.10.1"
"@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1":
version "5.1.1-v1"
resolved "https://registry.yarnpkg.com/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz#dbf733a965ca47b1973177dc0bb6c889edcfb129"
@@ -1384,6 +1413,11 @@
resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.122.0.tgz#2f4e77a3b183c87b2a326affd703ef71ba836601"
integrity sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==
"@oxc-project/types@=0.124.0":
version "0.124.0"
resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.124.0.tgz#1dfd7b3fbb98febc2f91b505f48c940db73c8701"
integrity sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==
"@puppeteer/browsers@2.13.0":
version "2.13.0"
resolved "https://registry.yarnpkg.com/@puppeteer/browsers/-/browsers-2.13.0.tgz#10f980c6d65efeff77f8a3cac6e1a7ac10604500"
@@ -1407,61 +1441,121 @@
resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz#4e6af08b89da02596cc5da4b105082b68673ffec"
integrity sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==
"@rolldown/binding-android-arm64@1.0.0-rc.15":
version "1.0.0-rc.15"
resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz#ca20574c469ade7b941f90c9af5e83e7c67f06b7"
integrity sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==
"@rolldown/binding-darwin-arm64@1.0.0-rc.12":
version "1.0.0-rc.12"
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz#a06890f4c9b48ff0fc97edbedfc762bef7cffd73"
integrity sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==
"@rolldown/binding-darwin-arm64@1.0.0-rc.15":
version "1.0.0-rc.15"
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz#ce2c5c7fc4958dfc94783dc09b3d09f3c2e1d072"
integrity sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==
"@rolldown/binding-darwin-x64@1.0.0-rc.12":
version "1.0.0-rc.12"
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz#eddf6aa3ed3509171fe21711f1e8ec8e0fd7ec49"
integrity sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==
"@rolldown/binding-darwin-x64@1.0.0-rc.15":
version "1.0.0-rc.15"
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz#251ecdf1fdb751031cb6486907c105daaf9dab21"
integrity sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==
"@rolldown/binding-freebsd-x64@1.0.0-rc.12":
version "1.0.0-rc.12"
resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz#2102dfed19fd1f1b53435fcaaf0bc61129a266a3"
integrity sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==
"@rolldown/binding-freebsd-x64@1.0.0-rc.15":
version "1.0.0-rc.15"
resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz#dbcfe95f409bf671a77bd83bff0fdc877d217728"
integrity sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==
"@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12":
version "1.0.0-rc.12"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz#b2c13f40e990fd1e1935492850536c768c961a0f"
integrity sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==
"@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15":
version "1.0.0-rc.15"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz#ea002b45445be6f9ed1883a834b335bc2ccd510f"
integrity sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==
"@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12":
version "1.0.0-rc.12"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz#32ca9f77c1e76b2913b3d53d2029dc171c0532d6"
integrity sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==
"@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15":
version "1.0.0-rc.15"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz#12b96e7e7821a9dc2cd5c670ad56882987ed5c62"
integrity sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==
"@rolldown/binding-linux-arm64-musl@1.0.0-rc.12":
version "1.0.0-rc.12"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz#f4337ddd52f0ed3ada2105b59ee1b757a2c4858c"
integrity sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==
"@rolldown/binding-linux-arm64-musl@1.0.0-rc.15":
version "1.0.0-rc.15"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz#738b0f62f0b65bf676dfe48595017f1883859d1f"
integrity sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==
"@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12":
version "1.0.0-rc.12"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz#22fdd14cb00ee8208c28a39bab7f28860ec6705d"
integrity sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==
"@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15":
version "1.0.0-rc.15"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz#3088b9fbc2783033985b558316f87f39281bc533"
integrity sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==
"@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12":
version "1.0.0-rc.12"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz#838215096d1de6d3d509e0410801cb7cda8161ff"
integrity sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==
"@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15":
version "1.0.0-rc.15"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz#ac0aa6f1b72e3151d56c43145a71c745cf862a9a"
integrity sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==
"@rolldown/binding-linux-x64-gnu@1.0.0-rc.12":
version "1.0.0-rc.12"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz#f7d71d97f6bd43198596b26dc2cb364586e12673"
integrity sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==
"@rolldown/binding-linux-x64-gnu@1.0.0-rc.15":
version "1.0.0-rc.15"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz#b8cf27aa5be6da641c22dad5665d0240551d2dec"
integrity sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==
"@rolldown/binding-linux-x64-musl@1.0.0-rc.12":
version "1.0.0-rc.12"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz#a2ca737f01b0ad620c4c404ca176ea3e3ad804c3"
integrity sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==
"@rolldown/binding-linux-x64-musl@1.0.0-rc.15":
version "1.0.0-rc.15"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz#4531f9eca77963935026634ba9b61c2535340534"
integrity sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==
"@rolldown/binding-openharmony-arm64@1.0.0-rc.12":
version "1.0.0-rc.12"
resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz#f66317e29eafcc300bed7af8dddac26ab3b1bf82"
integrity sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==
"@rolldown/binding-openharmony-arm64@1.0.0-rc.15":
version "1.0.0-rc.15"
resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz#66ff691a65f9325171bced98e353b4cc4b0095c3"
integrity sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==
"@rolldown/binding-wasm32-wasi@1.0.0-rc.12":
version "1.0.0-rc.12"
resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz#8825523fdffa1f1dc4683be9650ffaa9e4a77f04"
@@ -1469,21 +1563,45 @@
dependencies:
"@napi-rs/wasm-runtime" "^1.1.1"
"@rolldown/binding-wasm32-wasi@1.0.0-rc.15":
version "1.0.0-rc.15"
resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz#7db6c90aa510eef65d7d0f14e8ca23775e8e5eee"
integrity sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==
dependencies:
"@emnapi/core" "1.9.2"
"@emnapi/runtime" "1.9.2"
"@napi-rs/wasm-runtime" "^1.1.3"
"@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12":
version "1.0.0-rc.12"
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz#4f3a17e3d68a58309c27c0930b0f7986ccabef47"
integrity sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==
"@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15":
version "1.0.0-rc.15"
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz#81f9097abbd4493cc13373b26f5a3da8461dbb47"
integrity sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==
"@rolldown/binding-win32-x64-msvc@1.0.0-rc.12":
version "1.0.0-rc.12"
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz#d762765d5660598a96b570b513f535c151272985"
integrity sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==
"@rolldown/binding-win32-x64-msvc@1.0.0-rc.15":
version "1.0.0-rc.15"
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz#cef11bc89149f3a77771727be75490fbb13ae193"
integrity sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==
"@rolldown/pluginutils@1.0.0-rc.12":
version "1.0.0-rc.12"
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz#74163aec62fa51cee18d62709483963dceb3f6dc"
integrity sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==
"@rolldown/pluginutils@1.0.0-rc.15":
version "1.0.0-rc.15"
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz#e75d7731593e195d23710f9ff49bf5c745c96682"
integrity sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==
"@rolldown/pluginutils@1.0.0-rc.7":
version "1.0.0-rc.7"
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz#0414869467f0e471a6515d4f506c85fde867e022"
@@ -1997,63 +2115,63 @@
dependencies:
"@rolldown/pluginutils" "1.0.0-rc.7"
"@vitest/expect@4.1.2":
version "4.1.2"
resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-4.1.2.tgz#2aec02233db4eac14777e6a7d14a535c63ae2d9b"
integrity sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==
"@vitest/expect@4.1.4":
version "4.1.4"
resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-4.1.4.tgz#1507e51c53969723c99e8a7f054aa12cfa7c1a4d"
integrity sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==
dependencies:
"@standard-schema/spec" "^1.1.0"
"@types/chai" "^5.2.2"
"@vitest/spy" "4.1.2"
"@vitest/utils" "4.1.2"
"@vitest/spy" "4.1.4"
"@vitest/utils" "4.1.4"
chai "^6.2.2"
tinyrainbow "^3.1.0"
"@vitest/mocker@4.1.2":
version "4.1.2"
resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-4.1.2.tgz#3f23523697f9ab9e851b58b2213c4ab6181aa0e6"
integrity sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==
"@vitest/mocker@4.1.4":
version "4.1.4"
resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-4.1.4.tgz#5d22e99d8dbacf2f77f7a4c30a6e17eece7f25ef"
integrity sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==
dependencies:
"@vitest/spy" "4.1.2"
"@vitest/spy" "4.1.4"
estree-walker "^3.0.3"
magic-string "^0.30.21"
"@vitest/pretty-format@4.1.2":
version "4.1.2"
resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-4.1.2.tgz#c2671aa1c931dc8f2759589fc87ea4b2602892c5"
integrity sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==
"@vitest/pretty-format@4.1.4":
version "4.1.4"
resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-4.1.4.tgz#0ee79cd2ef8321330dabb8cc57ba9bce237e7183"
integrity sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==
dependencies:
tinyrainbow "^3.1.0"
"@vitest/runner@4.1.2":
version "4.1.2"
resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-4.1.2.tgz#6f744fa0d92d31f4c8c255b64bbe073cb75fd96e"
integrity sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==
"@vitest/runner@4.1.4":
version "4.1.4"
resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-4.1.4.tgz#8f884f265efabfdd8a5ee393cfe622a01ec849c2"
integrity sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==
dependencies:
"@vitest/utils" "4.1.2"
"@vitest/utils" "4.1.4"
pathe "^2.0.3"
"@vitest/snapshot@4.1.2":
version "4.1.2"
resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-4.1.2.tgz#3972b8ed7a311133e12cb833bf86463d26cdd455"
integrity sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==
"@vitest/snapshot@4.1.4":
version "4.1.4"
resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-4.1.4.tgz#600c04ee1c598d4e6ce219afae684ff21c3e187d"
integrity sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==
dependencies:
"@vitest/pretty-format" "4.1.2"
"@vitest/utils" "4.1.2"
"@vitest/pretty-format" "4.1.4"
"@vitest/utils" "4.1.4"
magic-string "^0.30.21"
pathe "^2.0.3"
"@vitest/spy@4.1.2":
version "4.1.2"
resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-4.1.2.tgz#1f312cef5756256639b4c0614f74c8ad9a036ef9"
integrity sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==
"@vitest/spy@4.1.4":
version "4.1.4"
resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-4.1.4.tgz#b955fcef98bcc746e7fc61d17d4725b43b33fa6d"
integrity sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==
"@vitest/utils@4.1.2":
version "4.1.2"
resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-4.1.2.tgz#32be8f42eb6683a598b1c61d7ec9f55596c60ecb"
integrity sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==
"@vitest/utils@4.1.4":
version "4.1.4"
resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-4.1.4.tgz#9518fb0ad0903ae455e82e063fa18e7558aa6065"
integrity sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==
dependencies:
"@vitest/pretty-format" "4.1.2"
"@vitest/pretty-format" "4.1.4"
convert-source-map "^2.0.0"
tinyrainbow "^3.1.0"
@@ -2080,10 +2198,10 @@ acorn@^8.16.0:
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a"
integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==
adm-zip@^0.5.16:
version "0.5.16"
resolved "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz"
integrity sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==
adm-zip@^0.5.17:
version "0.5.17"
resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.5.17.tgz#5c0b65f37aeec5c2a94995c024f931f62e4bbc5a"
integrity sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==
agent-base@^7.1.0, agent-base@^7.1.2:
version "7.1.4"
@@ -2400,10 +2518,10 @@ basic-ftp@^5.0.2:
resolved "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz"
integrity sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==
better-sqlite3@^12.8.0:
version "12.8.0"
resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-12.8.0.tgz#ec9ccd4a426a35f3b9355c147af6c92a6ddd6862"
integrity sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==
better-sqlite3@^12.9.0:
version "12.9.0"
resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-12.9.0.tgz#32498c99ba3fb36f604fbb5c70667c5f68c00414"
integrity sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==
dependencies:
bindings "^1.5.0"
prebuild-install "^7.1.1"
@@ -3020,10 +3138,10 @@ devlop@^1.0.0, devlop@^1.1.0:
dependencies:
dequal "^2.0.0"
devtools-protocol@0.0.1581282:
version "0.0.1581282"
resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz#7f289b837e052ad04eb16e9575877801c2b3716c"
integrity sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==
devtools-protocol@0.0.1595872:
version "0.0.1595872"
resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1595872.tgz#6f3f537a8518887d30d5181e41788f697f2a4ab2"
integrity sha512-kRfgp8vWVjBu/fbYCiVFiOqsCk3CrMKEo3WbgGT2NXK2dG7vawWPBljixajVgGK9II8rDO9G0oD0zLt3I1daRg==
doctrine@^2.1.0:
version "2.1.0"
@@ -3403,17 +3521,17 @@ eslint-visitor-keys@^5.0.1:
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be"
integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
eslint@10.1.0:
version "10.1.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.1.0.tgz#9ca98e654e642ab2e1af6d1e9d8613857ac341b4"
integrity sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==
eslint@10.2.0:
version "10.2.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.2.0.tgz#711c80d32fc3fdd3a575bb93977df43887c3ec8e"
integrity sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==
dependencies:
"@eslint-community/eslint-utils" "^4.8.0"
"@eslint-community/regexpp" "^4.12.2"
"@eslint/config-array" "^0.23.3"
"@eslint/config-helpers" "^0.5.3"
"@eslint/core" "^1.1.1"
"@eslint/plugin-kit" "^0.6.1"
"@eslint/config-array" "^0.23.4"
"@eslint/config-helpers" "^0.5.4"
"@eslint/core" "^1.2.0"
"@eslint/plugin-kit" "^0.7.0"
"@humanfs/node" "^0.16.6"
"@humanwhocodes/module-importer" "^1.0.1"
"@humanwhocodes/retry" "^0.4.2"
@@ -3962,10 +4080,10 @@ glob@^7.0.0, glob@^7.1.3:
once "^1.3.0"
path-is-absolute "^1.0.0"
globals@^17.4.0:
version "17.4.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-17.4.0.tgz#33d7d297ed1536b388a0e2f4bcd0ff19c8ff91b5"
integrity sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==
globals@^17.5.0:
version "17.5.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-17.5.0.tgz#a82c641d898f8dfbe0e81f66fdff7d0de43f88c6"
integrity sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==
globalthis@^1.0.4:
version "1.0.4"
@@ -4824,7 +4942,7 @@ lodash.debounce@^4.0.8:
resolved "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz"
integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==
lodash@4.17.23, lodash@^4.17.21:
lodash@^4.17.21:
version "4.17.23"
resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz"
integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==
@@ -4852,9 +4970,9 @@ loose-envify@^1.4.0:
dependencies:
js-tokens "^3.0.0 || ^4.0.0"
lottie-web@^5.12.2:
lottie-web@^5.13.0:
version "5.13.0"
resolved "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz"
resolved "https://registry.yarnpkg.com/lottie-web/-/lottie-web-5.13.0.tgz#441d3df217cc8ba302338c3f168e1a3af0f221d3"
integrity sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==
lru-cache@^11.0.2:
@@ -4889,10 +5007,10 @@ make-dir@^2.1.0:
pify "^4.0.1"
semver "^5.6.0"
maplibre-gl@^5.21.1:
version "5.21.1"
resolved "https://registry.yarnpkg.com/maplibre-gl/-/maplibre-gl-5.21.1.tgz#ae1f09fdae657e7c1a4565f9b2d8ff746d5e21ef"
integrity sha512-zto1RTnFkOpOO1bm93ElCXF1huey2N4LvXaGLMFcYAu9txh0OhGIdX1q3LZLkrMKgMxMeYduaQo+DVNzg098fg==
maplibre-gl@^5.23.0:
version "5.23.0"
resolved "https://registry.yarnpkg.com/maplibre-gl/-/maplibre-gl-5.23.0.tgz#a72d4cda848d93072b9a2334c0ea3beb11a92ea1"
integrity sha512-aou8YBNFS8uVtDWFWt0W/6oorfl18wt+oIA8fnXk1kivjkbtXi9gGrQvflTpwrR3hG13aWdIdbYWeN0NFMV7ag==
dependencies:
"@mapbox/jsonlint-lines-primitives" "^2.0.2"
"@mapbox/point-geometry" "^1.1.0"
@@ -4900,8 +5018,8 @@ maplibre-gl@^5.21.1:
"@mapbox/unitbezier" "^0.0.1"
"@mapbox/vector-tile" "^2.0.4"
"@mapbox/whoots-js" "^3.1.0"
"@maplibre/geojson-vt" "^6.0.4"
"@maplibre/maplibre-gl-style-spec" "^24.7.0"
"@maplibre/geojson-vt" "^6.1.0"
"@maplibre/maplibre-gl-style-spec" "^24.8.1"
"@maplibre/mlt" "^1.1.8"
"@maplibre/vt-pbf" "^4.3.0"
"@types/geojson" "^7946.0.16"
@@ -5621,10 +5739,10 @@ murmurhash-js@^1.0.0:
resolved "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz"
integrity sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==
nanoid@5.1.7:
version "5.1.7"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.1.7.tgz#a9f09a4ce73ba0b88830af36ee49666bad7827b6"
integrity sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==
nanoid@5.1.9:
version "5.1.9"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.1.9.tgz#aac959acf7d685269fb1be7f70a90d9db0848948"
integrity sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw==
nanoid@^3.3.11:
version "3.3.11"
@@ -5709,10 +5827,10 @@ node-releases@^2.0.27:
resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz"
integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==
nodemailer@^8.0.4:
version "8.0.4"
resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-8.0.4.tgz#b63626585693f37a390ddaecde273da991c76010"
integrity sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==
nodemailer@^8.0.5:
version "8.0.5"
resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-8.0.5.tgz#2076fb2b5c1ccfe1c88f6e1aa47c0229ea642e0c"
integrity sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==
nodemon@^3.1.14:
version "3.1.14"
@@ -6092,10 +6210,10 @@ prelude-ls@^1.2.1:
resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz"
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
prettier@3.8.1:
version "3.8.1"
resolved "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz"
integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==
prettier@3.8.3:
version "3.8.3"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.3.tgz#560f2de55bf01b4c0503bc629d5df99b9a1d09b0"
integrity sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==
prismjs@^1.29.0:
version "1.30.0"
@@ -6335,15 +6453,15 @@ punycode@^2.1.0:
resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz"
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
puppeteer-core@24.40.0:
version "24.40.0"
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.40.0.tgz#1f389cd9432cb077f703ca2cb6758490cdccbc7e"
integrity sha512-MWL3XbUCfVgGR0gRsidzT6oKJT2QydPLhMITU6HoVWiiv4gkb6gJi3pcdAa8q4HwjBTbqISOWVP4aJiiyUJvag==
puppeteer-core@24.41.0:
version "24.41.0"
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.41.0.tgz#e1c5030a8720e67a6a2530340ab08a3238b843d5"
integrity sha512-rLIUri7E/NQ3APSEYCCozaSJx0u8Tu9wxO6BJwnvXmIgILSK3L0TombaVh3izp1njAGrO6H2ru0hcIrLF+gWLw==
dependencies:
"@puppeteer/browsers" "2.13.0"
chromium-bidi "14.0.0"
debug "^4.4.3"
devtools-protocol "0.0.1581282"
devtools-protocol "0.0.1595872"
typed-query-selector "^2.12.1"
webdriver-bidi-protocol "0.4.1"
ws "^8.19.0"
@@ -6395,16 +6513,16 @@ puppeteer-extra@^3.3.6:
debug "^4.1.1"
deepmerge "^4.2.2"
puppeteer@^24.40.0:
version "24.40.0"
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.40.0.tgz#6df6aeee9dabf29bed3bb2be5c209d00518d4a79"
integrity sha512-IxQbDq93XHVVLWHrAkFP7F7iHvb9o0mgfsSIMlhHb+JM+JjM1V4v4MNSQfcRWJopx9dsNOr9adYv0U5fm9BJBQ==
puppeteer@^24.41.0:
version "24.41.0"
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.41.0.tgz#fdfcc343e16a87f31578206cf9a1ff10bb33c129"
integrity sha512-W6Fk0J3TPjjtwjXOyR/qf+YaL0H/Uq8HIgHcXG4mNM/IgbKMCH/HPyK0Fi2qbTU/QpSl9bCte2yBpGHKejTpIw==
dependencies:
"@puppeteer/browsers" "2.13.0"
chromium-bidi "14.0.0"
cosmiconfig "^9.0.0"
devtools-protocol "0.0.1581282"
puppeteer-core "24.40.0"
devtools-protocol "0.0.1595872"
puppeteer-core "24.41.0"
typed-query-selector "^2.12.1"
qs@^6.14.0:
@@ -6465,10 +6583,10 @@ react-chartjs-2@^5.3.1:
resolved "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz"
integrity sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==
react-dom@19.2.4:
version "19.2.4"
resolved "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz"
integrity sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==
react-dom@19.2.5:
version "19.2.5"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.5.tgz#b8768b10837d0b8e9ca5b9e2d58dff3d880ea25e"
integrity sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==
dependencies:
scheduler "^0.27.0"
@@ -6485,10 +6603,10 @@ react-is@^16.13.1:
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
react-range-slider-input@^3.3.2:
version "3.3.2"
resolved "https://registry.npmjs.org/react-range-slider-input/-/react-range-slider-input-3.3.2.tgz"
integrity sha512-CGyD/6Vlc7qakSW+92WAKrp333Xo9W+udW62xvf6dSwqEj7LFSY75udcbNRtCQhuXW1O7o71yC4AC/CC0etqSg==
react-range-slider-input@^3.3.5:
version "3.3.5"
resolved "https://registry.yarnpkg.com/react-range-slider-input/-/react-range-slider-input-3.3.5.tgz#3eebab9b249f7de9eb61510151cc383739c9f284"
integrity sha512-HkGjaq+q7u42K5WzFPD67duugPyD2m7EWVCJm139EqfR9AgSdEUxSFgRj6SW+nzGB5y8/6Jw75euxbeiuTT0cQ==
dependencies:
clsx "^1.1.1"
core-js "^3.22.4"
@@ -6501,17 +6619,17 @@ react-resizable@^3.0.5:
prop-types "15.x"
react-draggable "^4.0.3"
react-router-dom@7.13.2:
version "7.13.2"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.13.2.tgz#6582ab2e2f096d19486e854898b719b4efc52524"
integrity sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==
react-router-dom@7.14.1:
version "7.14.1"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.14.1.tgz#1ead5007f8948b6eae6df84e95cc109911ad5d7c"
integrity sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==
dependencies:
react-router "7.13.2"
react-router "7.14.1"
react-router@7.13.2:
version "7.13.2"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.13.2.tgz#bab22c9f96f81759e060a34c04e7527e5f6dbbe1"
integrity sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==
react-router@7.14.1:
version "7.14.1"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.14.1.tgz#adf60e587ad7fb25bd8f33c443fb0e86cd4f051e"
integrity sha512-5BCvFskyAAVumqhEKh/iPhLOIkfxcEUz8WqFIARCkMg8hZZzDYX9CtwxXA0e+qT8zAxmMC0x3Ckb9iMONwc5jg==
dependencies:
cookie "^1.0.1"
set-cookie-parser "^2.6.0"
@@ -6524,10 +6642,10 @@ react-window@^1.8.2:
"@babel/runtime" "^7.0.0"
memoize-one ">=3.1.1 <6"
react@19.2.4:
version "19.2.4"
resolved "https://registry.npmjs.org/react/-/react-19.2.4.tgz"
integrity sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==
react@19.2.5:
version "19.2.5"
resolved "https://registry.yarnpkg.com/react/-/react-19.2.5.tgz#c888ab8b8ef33e2597fae8bdb2d77edbdb42858b"
integrity sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==
readable-stream@^3.1.1, readable-stream@^3.4.0:
version "3.6.2"
@@ -6728,13 +6846,13 @@ require-from-string@^2.0.2:
resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909"
integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==
resend@^6.10.0:
version "6.10.0"
resolved "https://registry.yarnpkg.com/resend/-/resend-6.10.0.tgz#fc4e012268a31bb9575d5028c0a8fc0506fed582"
integrity sha512-i7CwZpYj4Oho1RxsTpLcCUkO08+HiL4NXrm6jLJ2WzJ89UGI8eROSieLONJA3hnUrf1OYnCyfq5F6POnHUMv1Q==
resend@^6.12.0:
version "6.12.0"
resolved "https://registry.yarnpkg.com/resend/-/resend-6.12.0.tgz#99e6f33a53e757b538b9b663cf2a936943db2688"
integrity sha512-CaxEvX1z+/MGbgnhsM/bvmkbnZd1v1sEXELAjBNSDBQNMaB7MgqOyrBgI27CYikEgdaDnBrXghAXYWTBs/h5Bw==
dependencies:
postal-mime "2.7.4"
svix "1.88.0"
svix "1.90.0"
resolve-from@^4.0.0:
version "4.0.0"
@@ -6822,6 +6940,30 @@ rolldown@1.0.0-rc.12:
"@rolldown/binding-win32-arm64-msvc" "1.0.0-rc.12"
"@rolldown/binding-win32-x64-msvc" "1.0.0-rc.12"
rolldown@1.0.0-rc.15:
version "1.0.0-rc.15"
resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.0.0-rc.15.tgz#ea3526443b2dbe834e9f8f6c1fde6232ec687170"
integrity sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==
dependencies:
"@oxc-project/types" "=0.124.0"
"@rolldown/pluginutils" "1.0.0-rc.15"
optionalDependencies:
"@rolldown/binding-android-arm64" "1.0.0-rc.15"
"@rolldown/binding-darwin-arm64" "1.0.0-rc.15"
"@rolldown/binding-darwin-x64" "1.0.0-rc.15"
"@rolldown/binding-freebsd-x64" "1.0.0-rc.15"
"@rolldown/binding-linux-arm-gnueabihf" "1.0.0-rc.15"
"@rolldown/binding-linux-arm64-gnu" "1.0.0-rc.15"
"@rolldown/binding-linux-arm64-musl" "1.0.0-rc.15"
"@rolldown/binding-linux-ppc64-gnu" "1.0.0-rc.15"
"@rolldown/binding-linux-s390x-gnu" "1.0.0-rc.15"
"@rolldown/binding-linux-x64-gnu" "1.0.0-rc.15"
"@rolldown/binding-linux-x64-musl" "1.0.0-rc.15"
"@rolldown/binding-openharmony-arm64" "1.0.0-rc.15"
"@rolldown/binding-wasm32-wasi" "1.0.0-rc.15"
"@rolldown/binding-win32-arm64-msvc" "1.0.0-rc.15"
"@rolldown/binding-win32-x64-msvc" "1.0.0-rc.15"
rope-sequence@^1.3.0:
version "1.3.4"
resolved "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz"
@@ -7369,10 +7511,10 @@ supports-preserve-symlinks-flag@^1.0.0:
resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz"
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
svix@1.88.0:
version "1.88.0"
resolved "https://registry.yarnpkg.com/svix/-/svix-1.88.0.tgz#2d8b952c7c62c84a1b223f5697fd627b8b2784e2"
integrity sha512-vm/JrrUd3bVyBE+3L33TIyVSs8gS5fYx7lrISvKlDJXTYX1ACH4REX8P1tHxsSKoZi/rvifM1t0XRc5Vc45THw==
svix@1.90.0:
version "1.90.0"
resolved "https://registry.yarnpkg.com/svix/-/svix-1.90.0.tgz#49c3a310745b05a26d322f17c0207d0c6da88dc6"
integrity sha512-ljkZuyy2+IBEoESkIpn8sLM+sxJHQcPxlZFxU+nVDhltNfUMisMBzWX/UR8SjEnzoI28ZjCzMbmYAPwSTucoMw==
dependencies:
standardwebhooks "1.0.0"
uuid "^10.0.0"
@@ -7762,7 +7904,20 @@ vfile@^6.0.0:
"@types/unist" "^3.0.0"
vfile-message "^4.0.0"
vite@8.0.3, "vite@^6.0.0 || ^7.0.0 || ^8.0.0":
vite@8.0.8:
version "8.0.8"
resolved "https://registry.yarnpkg.com/vite/-/vite-8.0.8.tgz#4e26a9bba77c4b27a00b6b10100a7dab48d546a3"
integrity sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==
dependencies:
lightningcss "^1.32.0"
picomatch "^4.0.4"
postcss "^8.5.8"
rolldown "1.0.0-rc.15"
tinyglobby "^0.2.15"
optionalDependencies:
fsevents "~2.3.3"
"vite@^6.0.0 || ^7.0.0 || ^8.0.0":
version "8.0.3"
resolved "https://registry.yarnpkg.com/vite/-/vite-8.0.3.tgz#036d9e3b077ff57b128660b3e3a5d2d12bac9b42"
integrity sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==
@@ -7775,18 +7930,18 @@ vite@8.0.3, "vite@^6.0.0 || ^7.0.0 || ^8.0.0":
optionalDependencies:
fsevents "~2.3.3"
vitest@^4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/vitest/-/vitest-4.1.2.tgz#3f7b36838ddf1067160489bea9a21ef465496265"
integrity sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==
vitest@^4.1.4:
version "4.1.4"
resolved "https://registry.yarnpkg.com/vitest/-/vitest-4.1.4.tgz#330a3798ce307f88d3eea373e61a5f14da8f3bb1"
integrity sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==
dependencies:
"@vitest/expect" "4.1.2"
"@vitest/mocker" "4.1.2"
"@vitest/pretty-format" "4.1.2"
"@vitest/runner" "4.1.2"
"@vitest/snapshot" "4.1.2"
"@vitest/spy" "4.1.2"
"@vitest/utils" "4.1.2"
"@vitest/expect" "4.1.4"
"@vitest/mocker" "4.1.4"
"@vitest/pretty-format" "4.1.4"
"@vitest/runner" "4.1.4"
"@vitest/snapshot" "4.1.4"
"@vitest/spy" "4.1.4"
"@vitest/utils" "4.1.4"
es-module-lexer "^2.0.0"
expect-type "^1.3.0"
magic-string "^0.30.21"