mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b727ea708 | ||
|
|
a2a765f43d | ||
|
|
c17a815263 | ||
|
|
7a2dacaa61 | ||
|
|
359e00e69f | ||
|
|
bc9c56a224 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ npm-debug.log
|
||||
.idea
|
||||
.vscode
|
||||
tools/release/config.json
|
||||
.agents
|
||||
@@ -38,11 +38,15 @@ import { formatListing } from './utils/formatListing.js';
|
||||
* 3) Normalize listings to the provider schema
|
||||
* 4) Filter out incomplete/blacklisted listings
|
||||
* 5) Identify new listings (vs. previously stored hashes)
|
||||
* 6) Persist new listings
|
||||
* 7) Filter out entries similar to already seen ones
|
||||
* 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
|
||||
* 6) Optionally enrich new listings via provider.fetchDetails
|
||||
* 7) Optionally re-apply the provider blacklist using the (now enriched)
|
||||
* description — only when the user opted in via
|
||||
* `blacklist_filter_on_provider_details`
|
||||
* 8) Persist new listings
|
||||
* 9) Filter out entries similar to already seen ones
|
||||
* 10) Filter out entries that do not match the job's specFilter
|
||||
* 11) Filter out entries that do not match the job's spatialFilter
|
||||
* 12) Dispatch notifications
|
||||
*/
|
||||
class FredyPipelineExecutioner {
|
||||
/**
|
||||
@@ -86,6 +90,7 @@ class FredyPipelineExecutioner {
|
||||
.then(this._filter.bind(this))
|
||||
.then(this._findNew.bind(this))
|
||||
.then(this._fetchDetails.bind(this))
|
||||
.then(this._filterAfterDetails.bind(this))
|
||||
.then(this._geocode.bind(this))
|
||||
.then(this._save.bind(this))
|
||||
.then(this._calculateDistance.bind(this))
|
||||
@@ -266,6 +271,48 @@ class FredyPipelineExecutioner {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-apply the provider's blacklist filter after `_fetchDetails` has had a
|
||||
* chance to enrich the listings (e.g., load the full description from the
|
||||
* detail page). The initial `_filter` step only sees the truncated snippet
|
||||
* exposed on the search results page, so a blacklisted term that lives
|
||||
* deeper in the listing's full description would otherwise slip through.
|
||||
*
|
||||
* Opt-in: gated by the user setting `blacklist_filter_on_provider_details`.
|
||||
* The full detail description tends to contain a lot of boilerplate (legal,
|
||||
* exposé contact info, generic marketing copy) which can accidentally match
|
||||
* a blacklist term and remove otherwise relevant listings. Users who want
|
||||
* the stricter behavior must enable the setting explicitly.
|
||||
*
|
||||
* Throws {@link NoNewListingsWarning} when all listings are filtered out
|
||||
* so the rest of the pipeline (save + notify) is short-circuited.
|
||||
*
|
||||
* @param {ParsedListing[]} listings Enriched listings to re-filter.
|
||||
* @returns {ParsedListing[]} Listings that still pass the provider's filter.
|
||||
* @throws {NoNewListingsWarning} When every listing is filtered out.
|
||||
*/
|
||||
_filterAfterDetails(listings) {
|
||||
if (typeof this._providerConfig.filter !== 'function') {
|
||||
return listings;
|
||||
}
|
||||
const userId = getJob(this._jobKey)?.userId;
|
||||
const enabled = getUserSettings(userId)?.blacklist_filter_on_provider_details === true;
|
||||
if (!enabled) {
|
||||
return listings;
|
||||
}
|
||||
const kept = listings.filter(this._providerConfig.filter);
|
||||
const removed = listings.length - kept.length;
|
||||
if (removed > 0) {
|
||||
logger.debug(
|
||||
`Re-filter after detail enrichment removed ${removed} listing(s) by blacklist (Provider: '${this._providerId}')`,
|
||||
);
|
||||
}
|
||||
if (kept.length === 0) {
|
||||
throw new NoNewListingsWarning();
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which listings are new by comparing their IDs against stored hashes.
|
||||
*
|
||||
|
||||
@@ -20,6 +20,28 @@ function cap(val) {
|
||||
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the most recent job trigger timestamp across the given jobs.
|
||||
*
|
||||
* Returns `null` when none of the jobs has ever been triggered. The value is
|
||||
* persisted per-job via `jobs.last_run_at`, so the dashboard reflects the
|
||||
* scope visible to the current user (own + shared, or all for admins) rather
|
||||
* than a process-wide in-memory value.
|
||||
*
|
||||
* @param {Array<{lastRunAt?: number|null}>} jobs
|
||||
* @returns {number|null}
|
||||
*/
|
||||
function computeLastRun(jobs) {
|
||||
let lastRun = null;
|
||||
for (const job of jobs) {
|
||||
const ts = job.lastRunAt;
|
||||
if (typeof ts === 'number' && (lastRun == null || ts > lastRun)) {
|
||||
lastRun = ts;
|
||||
}
|
||||
}
|
||||
return lastRun;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('fastify').FastifyInstance} fastify
|
||||
*/
|
||||
@@ -46,11 +68,13 @@ export default async function dashboardPlugin(fastify) {
|
||||
}
|
||||
: { labels: [], values: [] };
|
||||
|
||||
const lastRun = computeLastRun(jobs);
|
||||
|
||||
return {
|
||||
general: {
|
||||
interval: settings.interval,
|
||||
lastRun: settings.lastRun || null,
|
||||
nextRun: settings.lastRun == null ? 0 : settings.lastRun + settings.interval * 60000,
|
||||
lastRun,
|
||||
nextRun: lastRun == null ? 0 : lastRun + settings.interval * 60000,
|
||||
},
|
||||
kpis: {
|
||||
totalJobs,
|
||||
|
||||
@@ -103,6 +103,28 @@ export default async function userSettingsPlugin(fastify) {
|
||||
}
|
||||
});
|
||||
|
||||
fastify.post('/blacklist-filter-on-details', async (request, reply) => {
|
||||
const userId = request.session.currentUser;
|
||||
const { blacklist_filter_on_provider_details } = request.body;
|
||||
|
||||
const globalSettings = await getSettings();
|
||||
if (globalSettings.demoMode && !isAdmin(request)) {
|
||||
return reply.code(403).send({ error: 'In demo mode, it is not allowed to change settings.' });
|
||||
}
|
||||
|
||||
if (typeof blacklist_filter_on_provider_details !== 'boolean') {
|
||||
return reply.code(400).send({ error: 'blacklist_filter_on_provider_details must be a boolean.' });
|
||||
}
|
||||
|
||||
try {
|
||||
upsertSettings({ blacklist_filter_on_provider_details }, userId);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Error updating blacklist-filter-on-details setting', error);
|
||||
return reply.code(500).send({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
fastify.post('/listings-view-mode', async (request, reply) => {
|
||||
const userId = request.session.currentUser;
|
||||
const { listings_view_mode } = request.body;
|
||||
|
||||
@@ -14,7 +14,7 @@ const mapListing = (listing, baseUrl) => ({
|
||||
size: listing.size,
|
||||
title: listing.title,
|
||||
url: listing.link,
|
||||
fredyUrl: baseUrl && listing.id ? `${baseUrl}/listings/listing/${listing.id}` : null,
|
||||
fredyUrl: baseUrl && listing.id ? `${baseUrl}/#/listings/listing/${listing.id}` : null,
|
||||
});
|
||||
|
||||
export const send = async ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
|
||||
|
||||
@@ -53,7 +53,7 @@ const mapListingsWithCid = async (serviceName, jobKey, listings, baseUrl) => {
|
||||
jobKey,
|
||||
hasImage: false,
|
||||
imageCid: '',
|
||||
fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null,
|
||||
fredyUrl: baseUrl && l.id ? `${baseUrl}/#/listings/listing/${l.id}` : null,
|
||||
};
|
||||
|
||||
if (imgUrl) {
|
||||
|
||||
@@ -25,7 +25,7 @@ const mapListings = (serviceName, jobKey, listings, baseUrl) =>
|
||||
price: l.price || '',
|
||||
image,
|
||||
hasImage: Boolean(image),
|
||||
fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null,
|
||||
fredyUrl: baseUrl && l.id ? `${baseUrl}/#/listings/listing/${l.id}` : null,
|
||||
serviceName,
|
||||
jobKey,
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ const mapListings = (serviceName, jobKey, listings, baseUrl) =>
|
||||
hasImage: Boolean(image),
|
||||
// optional plain text snippet
|
||||
snippet: [l.address, l.price, l.size].filter(Boolean).join(' | '),
|
||||
fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null,
|
||||
fredyUrl: baseUrl && l.id ? `${baseUrl}/#/listings/listing/${l.id}` : null,
|
||||
serviceName,
|
||||
jobKey,
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ const mapListings = (serviceName, jobKey, listings, baseUrl) =>
|
||||
price: l.price || '',
|
||||
image,
|
||||
hasImage: Boolean(image),
|
||||
fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null,
|
||||
fredyUrl: baseUrl && l.id ? `${baseUrl}/#/listings/listing/${l.id}` : null,
|
||||
serviceName,
|
||||
jobKey,
|
||||
};
|
||||
|
||||
@@ -198,7 +198,9 @@ function normalize(o) {
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function applyBlacklist(o) {
|
||||
return !isOneOf(o.title, appliedBlackList);
|
||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||
return titleNotBlacklisted && descNotBlacklisted;
|
||||
}
|
||||
/** @type {ProviderConfig} */
|
||||
const config = {
|
||||
|
||||
@@ -42,7 +42,9 @@ function normalize(o) {
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function applyBlacklist(o) {
|
||||
return !isOneOf(o.title, appliedBlackList);
|
||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||
return titleNotBlacklisted && descNotBlacklisted;
|
||||
}
|
||||
|
||||
/** @type {ProviderConfig} */
|
||||
|
||||
@@ -103,6 +103,13 @@ const EQUIPMENT_MAP = {
|
||||
lodgerflat: 'lodgerflat',
|
||||
};
|
||||
|
||||
// The web UI uses "swapflat", but the mobile API only understands "swap_flat".
|
||||
// An unknown value is not ignored: the API silently returns 0 results for the
|
||||
// whole search. Other values (e.g. "projectlisting") are identical on both APIs.
|
||||
const EXCLUSION_CRITERIA_MAP = {
|
||||
swapflat: 'swap_flat',
|
||||
};
|
||||
|
||||
const REAL_ESTATE_TYPE = {
|
||||
'haus-mieten': 'houserent',
|
||||
'wohnung-mieten': 'apartmentrent',
|
||||
@@ -251,6 +258,9 @@ export function convertWebToMobile(webUrl) {
|
||||
...(currentEquipmentParams ?? []),
|
||||
...items.map((item) => EQUIPMENT_MAP[item.toLowerCase()]).filter(Boolean),
|
||||
];
|
||||
} else if (key === 'exclusioncriteria') {
|
||||
const items = [].concat(val).flatMap((v) => `${v}`.split(','));
|
||||
mobileParams[PARAM_NAME_MAP[key]] = items.map((item) => EXCLUSION_CRITERIA_MAP[item.toLowerCase()] ?? item);
|
||||
} else {
|
||||
mobileParams[PARAM_NAME_MAP[key]] = val;
|
||||
}
|
||||
|
||||
@@ -104,7 +104,6 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
|
||||
logger.debug('Working hours set. Skipping as outside of working hours.');
|
||||
return;
|
||||
}
|
||||
settings.lastRun = now;
|
||||
const jobs = jobStorage.getJobs().filter((job) => {
|
||||
if (!context) return true; // startup/cron → all
|
||||
if (context.isAdmin) return true; // admin → all
|
||||
@@ -150,6 +149,13 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
|
||||
}
|
||||
const acquired = markRunning(job.id);
|
||||
if (!acquired) return;
|
||||
// Persist the trigger time so the dashboard "last search" KPI can be
|
||||
// derived per accessible user without an in-memory cache.
|
||||
try {
|
||||
jobStorage.updateJobLastRunAt(job.id, Date.now());
|
||||
} catch (err) {
|
||||
logger.warn('Failed to persist last_run_at for job', job.id, err);
|
||||
}
|
||||
// notify listeners (SSE) that the job started
|
||||
try {
|
||||
bus.emit('jobs:status', { jobId: job.id, running: true });
|
||||
|
||||
@@ -97,6 +97,7 @@ export const getJob = (jobId) => {
|
||||
j.notification_adapter AS notificationAdapter,
|
||||
j.spatial_filter AS spatialFilter,
|
||||
j.spec_filter AS specFilter,
|
||||
j.last_run_at AS lastRunAt,
|
||||
(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
|
||||
@@ -116,6 +117,24 @@ export const getJob = (jobId) => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Record the timestamp at which a job was last triggered.
|
||||
*
|
||||
* Called from the job execution service when a job starts running. The value
|
||||
* is persisted so that the dashboard "last search" KPI survives restarts and
|
||||
* can be computed per accessible user.
|
||||
*
|
||||
* @param {string} jobId - Job primary key.
|
||||
* @param {number} timestamp - Epoch milliseconds.
|
||||
* @returns {void}
|
||||
*/
|
||||
export const updateJobLastRunAt = (jobId, timestamp) => {
|
||||
SqliteConnection.execute(`UPDATE jobs SET last_run_at = @timestamp WHERE id = @id`, {
|
||||
id: jobId,
|
||||
timestamp,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Update job enabled status.
|
||||
* @param {{jobId: string, status: boolean}} params - Parameters.
|
||||
@@ -164,6 +183,7 @@ export const getJobs = () => {
|
||||
j.notification_adapter AS notificationAdapter,
|
||||
j.spatial_filter AS spatialFilter,
|
||||
j.spec_filter AS specFilter,
|
||||
j.last_run_at AS lastRunAt,
|
||||
(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
|
||||
@@ -269,6 +289,7 @@ export const queryJobs = ({
|
||||
j.notification_adapter AS notificationAdapter,
|
||||
j.spatial_filter AS spatialFilter,
|
||||
j.spec_filter AS specFilter,
|
||||
j.last_run_at AS lastRunAt,
|
||||
(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}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
/**
|
||||
* Migration: add `last_run_at` to the `jobs` table.
|
||||
*
|
||||
* Stores the epoch-ms timestamp at which a job was last triggered. Used by the
|
||||
* dashboard "last search" KPI so the value survives restarts and reflects the
|
||||
* actual jobs the requesting user can see (own, shared, or all for admins),
|
||||
* replacing the previous in-memory `settings.lastRun` value.
|
||||
*
|
||||
* NULL means the job has not yet been triggered since this column was added.
|
||||
*/
|
||||
export function up(db) {
|
||||
db.exec(`
|
||||
ALTER TABLE jobs ADD COLUMN last_run_at INTEGER
|
||||
`);
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
* @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.
|
||||
* @property {number | null} [lastRunAt] Epoch ms at which the job was last triggered, or null if never triggered.
|
||||
*/
|
||||
|
||||
export {};
|
||||
|
||||
18
package.json
18
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fredy",
|
||||
"version": "22.5.0",
|
||||
"version": "22.7.0",
|
||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||
"scripts": {
|
||||
"prepare": "husky",
|
||||
@@ -62,9 +62,9 @@
|
||||
"Firefox ESR"
|
||||
],
|
||||
"dependencies": {
|
||||
"@douyinfe/semi-icons": "^2.99.3",
|
||||
"@douyinfe/semi-ui": "2.99.3",
|
||||
"@douyinfe/semi-ui-19": "^2.99.3",
|
||||
"@douyinfe/semi-icons": "^2.100.0",
|
||||
"@douyinfe/semi-ui": "2.100.0",
|
||||
"@douyinfe/semi-ui-19": "^2.100.0",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/helmet": "^13.0.2",
|
||||
"@fastify/session": "^11.1.1",
|
||||
@@ -86,7 +86,7 @@
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "3.3.2",
|
||||
"node-mailjet": "6.0.11",
|
||||
"nodemailer": "^8.0.10",
|
||||
"nodemailer": "^8.0.11",
|
||||
"p-throttle": "^8.1.0",
|
||||
"package-up": "^5.0.0",
|
||||
"puppeteer-core": "^25.1.0",
|
||||
@@ -95,10 +95,10 @@
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "19.2.7",
|
||||
"react-range-slider-input": "^3.3.5",
|
||||
"react-router": "7.16.0",
|
||||
"react-router-dom": "7.16.0",
|
||||
"react-router": "7.17.0",
|
||||
"react-router-dom": "7.17.0",
|
||||
"resend": "^6.12.4",
|
||||
"semver": "^7.8.1",
|
||||
"semver": "^7.8.4",
|
||||
"slack": "11.0.2",
|
||||
"vite": "8.0.16",
|
||||
"x-var": "^3.0.1",
|
||||
@@ -120,7 +120,7 @@
|
||||
"less": "4.6.4",
|
||||
"lint-staged": "17.0.7",
|
||||
"nodemon": "^3.1.14",
|
||||
"prettier": "3.8.3",
|
||||
"prettier": "3.8.4",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,12 @@ export const getGeocoordinatesByAddress = (any) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
let userSettings = null;
|
||||
export function setUserSettings(settings) {
|
||||
userSettings = settings;
|
||||
}
|
||||
export function getUserSettings(userId) {
|
||||
return null;
|
||||
return userSettings;
|
||||
}
|
||||
|
||||
export async function getSettings() {
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { expect } from 'vitest';
|
||||
import { afterEach, expect } from 'vitest';
|
||||
import { mockFredy } from './utils.js';
|
||||
import * as mockStore from './mocks/mockStore.js';
|
||||
import { get as getLastNotification } from './mocks/mockNotification.js';
|
||||
|
||||
describe('Issue reproduction: listings filtered by similarity or area should be marked as manually deleted', () => {
|
||||
it('should call deleteListingsById when listings are filtered by similarity', async () => {
|
||||
@@ -113,3 +114,223 @@ describe('Issue reproduction: listings filtered by similarity or area should be
|
||||
expect(mockStore.deletedIds).toContain('2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Blacklist is re-applied after detail enrichment', () => {
|
||||
afterEach(() => {
|
||||
mockStore.setUserSettings(null);
|
||||
});
|
||||
|
||||
it('filters out a listing whose blacklisted term only appears in the enriched description', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
const providerId = 'test-provider';
|
||||
|
||||
mockStore.setUserSettings({
|
||||
provider_details: [providerId],
|
||||
blacklist_filter_on_provider_details: true,
|
||||
});
|
||||
|
||||
const mockSimilarityCache = {
|
||||
checkAndAddEntry: () => false,
|
||||
};
|
||||
|
||||
const blacklist = ['allkauf'];
|
||||
|
||||
// The search results page returns a clean snippet (no blacklisted term).
|
||||
// fetchDetails simulates loading the full detail page and discovers the
|
||||
// blacklisted term hidden deep in the description.
|
||||
const providerConfig = {
|
||||
url: 'http://example.com',
|
||||
getListings: () =>
|
||||
Promise.resolve([
|
||||
{
|
||||
id: 'kept',
|
||||
title: 'Nice house',
|
||||
address: 'Some street',
|
||||
price: '500000',
|
||||
link: 'http://example.com/kept',
|
||||
description: 'Cozy home with garden',
|
||||
},
|
||||
{
|
||||
id: 'blacklisted',
|
||||
title: 'Eleganz trifft Raumkomfort',
|
||||
address: 'Other street',
|
||||
price: '600000',
|
||||
link: 'http://example.com/blacklisted',
|
||||
description: 'Eleganz trifft Raumkomfort',
|
||||
},
|
||||
]),
|
||||
normalize: (l) => l,
|
||||
filter: (l) => {
|
||||
const text = `${l.title ?? ''} ${l.description ?? ''}`.toLowerCase();
|
||||
return !blacklist.some((term) => text.includes(term));
|
||||
},
|
||||
fetchDetails: (listing) => {
|
||||
if (listing.id === 'blacklisted') {
|
||||
return Promise.resolve({
|
||||
...listing,
|
||||
description: 'Mit allkauf Haus wird dein Traum vom Eigenheim wahr.',
|
||||
});
|
||||
}
|
||||
return Promise.resolve(listing);
|
||||
},
|
||||
crawlFields: {
|
||||
id: 'id',
|
||||
title: 'title',
|
||||
address: 'address',
|
||||
price: 'price',
|
||||
link: 'link',
|
||||
description: 'description',
|
||||
},
|
||||
requiredFieldNames: ['id', 'title', 'address', 'price', 'link', 'description'],
|
||||
};
|
||||
|
||||
const mockedJob = {
|
||||
id: 'blacklist-test-job',
|
||||
notificationAdapter: null,
|
||||
specFilter: null,
|
||||
spatialFilter: null,
|
||||
};
|
||||
|
||||
const fredy = new Fredy(providerConfig, mockedJob, providerId, mockSimilarityCache, undefined);
|
||||
|
||||
const result = await fredy.execute();
|
||||
|
||||
expect(result).toBeInstanceOf(Array);
|
||||
const ids = result.map((l) => l.id);
|
||||
expect(ids).toContain('kept');
|
||||
expect(ids).not.toContain('blacklisted');
|
||||
|
||||
const notification = getLastNotification();
|
||||
const notifiedIds = (notification?.payload ?? []).map((p) => p.id);
|
||||
expect(notifiedIds).not.toContain('blacklisted');
|
||||
});
|
||||
|
||||
it('short-circuits the pipeline when all listings get blacklisted after enrichment', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
const providerId = 'all-blacklisted-provider';
|
||||
|
||||
mockStore.setUserSettings({
|
||||
provider_details: [providerId],
|
||||
blacklist_filter_on_provider_details: true,
|
||||
});
|
||||
|
||||
const mockSimilarityCache = {
|
||||
checkAndAddEntry: () => false,
|
||||
};
|
||||
|
||||
const blacklist = ['allkauf'];
|
||||
|
||||
const providerConfig = {
|
||||
url: 'http://example.com',
|
||||
getListings: () =>
|
||||
Promise.resolve([
|
||||
{
|
||||
id: 'only',
|
||||
title: 'Eleganz trifft Raumkomfort',
|
||||
address: 'Some street',
|
||||
price: '700000',
|
||||
link: 'http://example.com/only',
|
||||
description: 'Eleganz trifft Raumkomfort',
|
||||
},
|
||||
]),
|
||||
normalize: (l) => l,
|
||||
filter: (l) => {
|
||||
const text = `${l.title ?? ''} ${l.description ?? ''}`.toLowerCase();
|
||||
return !blacklist.some((term) => text.includes(term));
|
||||
},
|
||||
fetchDetails: (listing) =>
|
||||
Promise.resolve({
|
||||
...listing,
|
||||
description: 'Mit allkauf Haus wird dein Traum vom Eigenheim wahr.',
|
||||
}),
|
||||
crawlFields: {
|
||||
id: 'id',
|
||||
title: 'title',
|
||||
address: 'address',
|
||||
price: 'price',
|
||||
link: 'link',
|
||||
description: 'description',
|
||||
},
|
||||
requiredFieldNames: ['id', 'title', 'address', 'price', 'link', 'description'],
|
||||
};
|
||||
|
||||
const mockedJob = {
|
||||
id: 'all-blacklisted-job',
|
||||
notificationAdapter: null,
|
||||
specFilter: null,
|
||||
spatialFilter: null,
|
||||
};
|
||||
|
||||
const fredy = new Fredy(providerConfig, mockedJob, providerId, mockSimilarityCache, undefined);
|
||||
|
||||
// Should resolve to undefined (NoNewListingsWarning is caught in _handleError).
|
||||
const result = await fredy.execute();
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does NOT re-filter when blacklist_filter_on_provider_details is disabled', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
const providerId = 'opt-out-provider';
|
||||
|
||||
// provider_details enabled (so fetchDetails runs) but blacklist re-filter NOT enabled.
|
||||
mockStore.setUserSettings({
|
||||
provider_details: [providerId],
|
||||
blacklist_filter_on_provider_details: false,
|
||||
});
|
||||
|
||||
const mockSimilarityCache = {
|
||||
checkAndAddEntry: () => false,
|
||||
};
|
||||
|
||||
const blacklist = ['allkauf'];
|
||||
|
||||
const providerConfig = {
|
||||
url: 'http://example.com',
|
||||
getListings: () =>
|
||||
Promise.resolve([
|
||||
{
|
||||
id: 'leaks-through',
|
||||
title: 'Eleganz trifft Raumkomfort',
|
||||
address: 'Other street',
|
||||
price: '600000',
|
||||
link: 'http://example.com/leaks-through',
|
||||
description: 'Eleganz trifft Raumkomfort',
|
||||
},
|
||||
]),
|
||||
normalize: (l) => l,
|
||||
filter: (l) => {
|
||||
const text = `${l.title ?? ''} ${l.description ?? ''}`.toLowerCase();
|
||||
return !blacklist.some((term) => text.includes(term));
|
||||
},
|
||||
fetchDetails: (listing) =>
|
||||
Promise.resolve({
|
||||
...listing,
|
||||
description: 'Mit allkauf Haus wird dein Traum vom Eigenheim wahr.',
|
||||
}),
|
||||
crawlFields: {
|
||||
id: 'id',
|
||||
title: 'title',
|
||||
address: 'address',
|
||||
price: 'price',
|
||||
link: 'link',
|
||||
description: 'description',
|
||||
},
|
||||
requiredFieldNames: ['id', 'title', 'address', 'price', 'link', 'description'],
|
||||
};
|
||||
|
||||
const mockedJob = {
|
||||
id: 'opt-out-job',
|
||||
notificationAdapter: null,
|
||||
specFilter: null,
|
||||
spatialFilter: null,
|
||||
};
|
||||
|
||||
const fredy = new Fredy(providerConfig, mockedJob, providerId, mockSimilarityCache, undefined);
|
||||
|
||||
const result = await fredy.execute();
|
||||
|
||||
// Listing leaks through because user has not opted in to the stricter check.
|
||||
expect(result).toBeInstanceOf(Array);
|
||||
expect(result.map((l) => l.id)).toContain('leaks-through');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,12 +31,35 @@ describe('#immoscout-mobile URL conversion', () => {
|
||||
const webUrl =
|
||||
'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mieten?heatingtypes=central,selfcontainedcentral&haspromotion=false&numberofrooms=2.0-5.0&livingspace=10.0-25.0&energyefficiencyclasses=a,b,c,d,e,f,g,h,a_plus&exclusioncriteria=projectlisting,swapflat&equipment=parking,cellar,builtinkitchen,lift,garden,guesttoilet,balcony&petsallowedtypes=no,yes,negotiable&price=10.0-100.0&constructionyear=1920-2026&apartmenttypes=halfbasement,penthouse,other,loft,groundfloor,terracedflat,raisedgroundfloor,roofstorey,apartment,maisonette&pricetype=calculatedtotalrent&floor=2-7&enteredFrom=result_list';
|
||||
const expectedMobileUrl =
|
||||
'https://api.mobile.immobilienscout24.de/search/list?apartmenttypes=halfbasement,penthouse,other,loft,groundfloor,terracedflat,raisedgroundfloor,roofstorey,apartment,maisonette&constructionyear=1920-2026&energyefficiencyclasses=a,b,c,d,e,f,g,h,a_plus&equipment=parking,cellar,builtInKitchen,lift,garden,guestToilet,balcony&exclusioncriteria=projectlisting,swapflat&floor=2-7&geocodes=%2Fde%2Fberlin%2Fberlin&haspromotion=false&heatingtypes=central,selfcontainedcentral&livingspace=10.0-25.0&numberofrooms=2.0-5.0&petsallowedtypes=no,yes,negotiable&price=10.0-100.0&pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region';
|
||||
'https://api.mobile.immobilienscout24.de/search/list?apartmenttypes=halfbasement,penthouse,other,loft,groundfloor,terracedflat,raisedgroundfloor,roofstorey,apartment,maisonette&constructionyear=1920-2026&energyefficiencyclasses=a,b,c,d,e,f,g,h,a_plus&equipment=parking,cellar,builtInKitchen,lift,garden,guestToilet,balcony&exclusioncriteria=projectlisting,swap_flat&floor=2-7&geocodes=%2Fde%2Fberlin%2Fberlin&haspromotion=false&heatingtypes=central,selfcontainedcentral&livingspace=10.0-25.0&numberofrooms=2.0-5.0&petsallowedtypes=no,yes,negotiable&price=10.0-100.0&pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region';
|
||||
|
||||
const actualMobileUrl = convertWebToMobile(webUrl);
|
||||
expect(actualMobileUrl).toBe(expectedMobileUrl);
|
||||
});
|
||||
|
||||
// The web UI encodes "no swap flats" as exclusioncriteria=swapflat, but the
|
||||
// mobile API only understands swap_flat. Unknown values are not ignored by the
|
||||
// API - the search silently returns 0 results, so the mapping is essential.
|
||||
it('should map exclusioncriteria=swapflat to the mobile API value swap_flat', () => {
|
||||
const webUrl =
|
||||
'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mieten?exclusioncriteria=swapflat&price=-1500.0';
|
||||
|
||||
const converted = convertWebToMobile(webUrl);
|
||||
const queryParams = new URL(converted).searchParams;
|
||||
expect(queryParams.get('exclusioncriteria')).toBe('swap_flat');
|
||||
});
|
||||
|
||||
// Values the mobile API shares with the web API (e.g. projectlisting) must
|
||||
// pass through unchanged, in any combination with mapped values.
|
||||
it('should keep other exclusioncriteria values untouched', () => {
|
||||
const webUrl =
|
||||
'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mieten?exclusioncriteria=projectlisting,swapflat';
|
||||
|
||||
const converted = convertWebToMobile(webUrl);
|
||||
const queryParams = new URL(converted).searchParams;
|
||||
expect(queryParams.get('exclusioncriteria')).toBe('projectlisting,swap_flat');
|
||||
});
|
||||
|
||||
// Test URL conversion of web-only SEO path
|
||||
it('should convert a SEO web path to the correct query params', () => {
|
||||
const webUrl = 'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mit-balkon-mieten?equipment=garden';
|
||||
|
||||
110
test/services/jobs/dashboardRouter.test.js
Normal file
110
test/services/jobs/dashboardRouter.test.js
Normal file
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import Fastify from 'fastify';
|
||||
|
||||
describe('api/routes/dashboardRouter.js', () => {
|
||||
let app;
|
||||
let state;
|
||||
|
||||
async function buildApp() {
|
||||
const ROOT = path.resolve('.');
|
||||
const jobStoragePath = path.join(ROOT, 'lib', 'services', 'storage', 'jobStorage.js');
|
||||
const listingsStoragePath = path.join(ROOT, 'lib', 'services', 'storage', 'listingsStorage.js');
|
||||
const settingsStoragePath = path.join(ROOT, 'lib', 'services', 'storage', 'settingsStorage.js');
|
||||
const securityPath = path.join(ROOT, 'lib', 'api', 'security.js');
|
||||
|
||||
vi.resetModules();
|
||||
vi.doMock(jobStoragePath, () => ({
|
||||
getJobs: () => state.jobs.slice(),
|
||||
}));
|
||||
vi.doMock(listingsStoragePath, () => ({
|
||||
getListingsKpisForJobIds: () => ({ numberOfActiveListings: 0, medianPriceOfListings: 0 }),
|
||||
getProviderDistributionForJobIds: () => [],
|
||||
}));
|
||||
vi.doMock(settingsStoragePath, () => ({
|
||||
getSettings: async () => ({ interval: 30 }),
|
||||
}));
|
||||
vi.doMock(securityPath, () => ({
|
||||
isAdmin: () => state.admin,
|
||||
}));
|
||||
|
||||
const mod = await import(path.join(ROOT, 'lib', 'api', 'routes', 'dashboardRouter.js'));
|
||||
const plugin = mod.default;
|
||||
const instance = Fastify({ logger: false });
|
||||
instance.addHook('onRequest', async (request) => {
|
||||
request.session = { currentUser: state.currentUser, createdAt: Date.now() };
|
||||
});
|
||||
await instance.register(plugin, { prefix: '/api/dashboard' });
|
||||
await instance.ready();
|
||||
return instance;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state = {
|
||||
currentUser: 'u1',
|
||||
admin: false,
|
||||
jobs: [],
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (app) await app.close();
|
||||
app = null;
|
||||
});
|
||||
|
||||
it('derives lastRun from the most recent accessible job for a regular user', async () => {
|
||||
state.jobs = [
|
||||
{ id: 'a', userId: 'u1', shared_with_user: [], lastRunAt: 1000 },
|
||||
{ id: 'b', userId: 'u1', shared_with_user: [], lastRunAt: 5000 },
|
||||
{ id: 'c', userId: 'someone-else', shared_with_user: [], lastRunAt: 9999 },
|
||||
];
|
||||
app = await buildApp();
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/api/dashboard/' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.general.lastRun).toBe(5000);
|
||||
expect(body.general.nextRun).toBe(5000 + 30 * 60000);
|
||||
});
|
||||
|
||||
it('includes shared jobs in the lastRun calculation', async () => {
|
||||
state.jobs = [
|
||||
{ id: 'mine', userId: 'u1', shared_with_user: [], lastRunAt: 1000 },
|
||||
{ id: 'shared', userId: 'someone-else', shared_with_user: ['u1'], lastRunAt: 4000 },
|
||||
];
|
||||
app = await buildApp();
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/api/dashboard/' });
|
||||
expect(res.json().general.lastRun).toBe(4000);
|
||||
});
|
||||
|
||||
it('admins see lastRun across all jobs', async () => {
|
||||
state.admin = true;
|
||||
state.jobs = [
|
||||
{ id: 'a', userId: 'someone', shared_with_user: [], lastRunAt: 1000 },
|
||||
{ id: 'b', userId: 'another', shared_with_user: [], lastRunAt: 7000 },
|
||||
];
|
||||
app = await buildApp();
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/api/dashboard/' });
|
||||
expect(res.json().general.lastRun).toBe(7000);
|
||||
});
|
||||
|
||||
it('returns null lastRun and 0 nextRun when no accessible job has ever run', async () => {
|
||||
state.jobs = [
|
||||
{ id: 'a', userId: 'u1', shared_with_user: [], lastRunAt: null },
|
||||
{ id: 'b', userId: 'someone-else', shared_with_user: [], lastRunAt: 9999 },
|
||||
];
|
||||
app = await buildApp();
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/api/dashboard/' });
|
||||
const body = res.json();
|
||||
expect(body.general.lastRun).toBeNull();
|
||||
expect(body.general.nextRun).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ describe('services/jobs/jobExecutionService', () => {
|
||||
vi.doMock(jobStoragePath, () => ({
|
||||
getJob: (id) => state.jobsById[id] || null,
|
||||
getJobs: () => state.jobsList.slice(),
|
||||
updateJobLastRunAt: (id, timestamp) => calls.lastRunUpdates.push({ id, timestamp }),
|
||||
}));
|
||||
vi.doMock(userStoragePath, () => ({
|
||||
getUsers: () => state.users.slice(),
|
||||
@@ -65,7 +66,7 @@ describe('services/jobs/jobExecutionService', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
bus = new EventEmitter();
|
||||
calls = { sent: [], markRunning: [] };
|
||||
calls = { sent: [], markRunning: [], lastRunUpdates: [] };
|
||||
state = {
|
||||
jobsById: {},
|
||||
jobsList: [],
|
||||
@@ -119,4 +120,23 @@ describe('services/jobs/jobExecutionService', () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(new Set(calls.markRunning)).toEqual(new Set(['j1', 'j2']));
|
||||
});
|
||||
|
||||
it('persists last_run_at when a job is executed', async () => {
|
||||
state.jobsById['j1'] = { id: 'j1', enabled: true, userId: 'u1', provider: [] };
|
||||
state.jobsList = [state.jobsById['j1']];
|
||||
state.users = [{ id: 'u1', isAdmin: false }];
|
||||
|
||||
await initService();
|
||||
|
||||
const before = Date.now();
|
||||
bus.emit('jobs:runOne', { jobId: 'j1' });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const after = Date.now();
|
||||
|
||||
expect(calls.lastRunUpdates.length).toBe(1);
|
||||
const [update] = calls.lastRunUpdates;
|
||||
expect(update.id).toBe('j1');
|
||||
expect(update.timestamp).toBeGreaterThanOrEqual(before);
|
||||
expect(update.timestamp).toBeLessThanOrEqual(after);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -334,6 +334,11 @@
|
||||
"settings.providerDetailsPlaceholder": "Anbieter für Detail-Abruf auswählen...",
|
||||
"settings.providerDetailsUpdated": "Anbieter-Detail-Einstellung aktualisiert.",
|
||||
"settings.providerDetailsUpdateError": "Einstellung konnte nicht aktualisiert werden.",
|
||||
"settings.blacklistFilterOnProviderDetails": "Blacklist-Filter auf Anbieter-Details anwenden",
|
||||
"settings.blacklistFilterOnProviderDetailsHelp": "Wenn aktiv, wird die Blacklist zusätzlich gegen die vollständige Beschreibung geprüft, die durch den obigen Anbieter-Details-Schritt geladen wurde. Damit lassen sich Spam-Anbieter (z. B. 'allkauf', 'massa') herausfiltern, die nur tief in der Detail-Seite auftauchen und nicht im kurzen Vorschau-Text der Suchergebnisse stehen. Standardmäßig aus, weil die vollständige Beschreibung oft generischen Boilerplate-Text (Kontaktdaten, rechtliche Hinweise) enthält, der ein Blacklist-Wort versehentlich auslösen und passende Inserate entfernen kann. Hat keine Wirkung auf Anbieter, für die Anbieter-Details nicht aktiviert sind.",
|
||||
"settings.blacklistFilterOnProviderDetailsEnable": "Blacklist auf die vollständige Detail-Beschreibung anwenden",
|
||||
"settings.blacklistFilterOnProviderDetailsUpdated": "Einstellung Blacklist-auf-Details aktualisiert.",
|
||||
"settings.blacklistFilterOnProviderDetailsUpdateError": "Einstellung konnte nicht aktualisiert werden.",
|
||||
"settings.listingDeletion": "Inserate löschen",
|
||||
"settings.listingDeletionHelp": "Wähle den Standard-Löschmodus. Soft Delete blendet Inserate aus ohne erneutes Scraping; Hard Delete entfernt sie aus der Datenbank.",
|
||||
"settings.listingDeletionSoftLabel": "Als gelöscht markieren (Soft Delete)",
|
||||
|
||||
@@ -334,6 +334,11 @@
|
||||
"settings.providerDetailsPlaceholder": "Select providers to fetch details from...",
|
||||
"settings.providerDetailsUpdated": "Provider details setting updated.",
|
||||
"settings.providerDetailsUpdateError": "Failed to update setting.",
|
||||
"settings.blacklistFilterOnProviderDetails": "Blacklist-Filtering on Provider Details",
|
||||
"settings.blacklistFilterOnProviderDetailsHelp": "When enabled, the blacklist is re-checked against the full description loaded by the Provider Details step above. This catches spam advertisers (e.g. 'allkauf', 'massa') that only appear deep in the detail page and not in the short search-result snippet. Off by default, because the full description often contains generic boilerplate (contact info, legal text) that may accidentally trigger a blacklist term and remove otherwise relevant listings. Has no effect on providers for which Provider Details is not enabled.",
|
||||
"settings.blacklistFilterOnProviderDetailsEnable": "Apply blacklist to the full detail description",
|
||||
"settings.blacklistFilterOnProviderDetailsUpdated": "Blacklist-on-details setting updated.",
|
||||
"settings.blacklistFilterOnProviderDetailsUpdateError": "Failed to update setting.",
|
||||
"settings.listingDeletion": "Listing deletion",
|
||||
"settings.listingDeletionHelp": "Choose the default deletion mode. Soft delete hides them without re-scraping; hard delete removes them from the database.",
|
||||
"settings.listingDeletionSoftLabel": "Mark as deleted (Soft Delete)",
|
||||
|
||||
@@ -337,6 +337,28 @@ export const useFredyState = create(
|
||||
throw Exception;
|
||||
}
|
||||
},
|
||||
async setBlacklistFilterOnProviderDetails(enabled) {
|
||||
try {
|
||||
await xhrPost('/api/user/settings/blacklist-filter-on-details', {
|
||||
blacklist_filter_on_provider_details: enabled,
|
||||
});
|
||||
set((state) => ({
|
||||
userSettings: {
|
||||
...state.userSettings,
|
||||
settings: {
|
||||
...state.userSettings.settings,
|
||||
blacklist_filter_on_provider_details: enabled,
|
||||
},
|
||||
},
|
||||
}));
|
||||
} catch (Exception) {
|
||||
console.error(
|
||||
'Error while trying to update blacklist-filter-on-provider-details setting. Error:',
|
||||
Exception,
|
||||
);
|
||||
throw Exception;
|
||||
}
|
||||
},
|
||||
async setListingsViewMode(listings_view_mode) {
|
||||
try {
|
||||
await xhrPost('/api/user/settings/listings-view-mode', { listings_view_mode });
|
||||
|
||||
@@ -130,6 +130,9 @@ const GeneralSettings = function GeneralSettings() {
|
||||
// User settings state
|
||||
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
|
||||
const providerDetails = useSelector((state) => state.userSettings.settings.provider_details);
|
||||
const blacklistFilterOnProviderDetails = useSelector(
|
||||
(state) => state.userSettings.settings.blacklist_filter_on_provider_details,
|
||||
);
|
||||
const listingDeletionPreference = useSelector((state) => state.userSettings.settings.listing_deletion_preference);
|
||||
const allProviders = useSelector((state) => state.provider);
|
||||
const [address, setAddress] = useState(homeAddress?.address || '');
|
||||
@@ -647,6 +650,25 @@ const GeneralSettings = function GeneralSettings() {
|
||||
/>
|
||||
</SegmentPart>
|
||||
|
||||
<SegmentPart
|
||||
name={t('settings.blacklistFilterOnProviderDetails')}
|
||||
helpText={t('settings.blacklistFilterOnProviderDetailsHelp')}
|
||||
>
|
||||
<Checkbox
|
||||
checked={blacklistFilterOnProviderDetails === true}
|
||||
onChange={async (e) => {
|
||||
try {
|
||||
await actions.userSettings.setBlacklistFilterOnProviderDetails(e.target.checked);
|
||||
Toast.success(t('settings.blacklistFilterOnProviderDetailsUpdated'));
|
||||
} catch {
|
||||
Toast.error(t('settings.blacklistFilterOnProviderDetailsUpdateError'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('settings.blacklistFilterOnProviderDetailsEnable')}
|
||||
</Checkbox>
|
||||
</SegmentPart>
|
||||
|
||||
<SegmentPart name={t('settings.listingDeletion')} helpText={t('settings.listingDeletionHelp')}>
|
||||
<RadioGroup
|
||||
value={listingDeleteHard ? 'hard' : 'soft'}
|
||||
|
||||
@@ -7,7 +7,10 @@ import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
base: '',
|
||||
// Must be absolute: with a relative base, asset URLs in index.html break on
|
||||
// deep links like /listings/listing/:id (the SPA fallback serves index.html,
|
||||
// but ./assets/* then resolves below the route path and loads HTML as JS).
|
||||
base: '/',
|
||||
build: {
|
||||
chunkSizeWarningLimit: 9999999,
|
||||
outDir: './ui/public',
|
||||
|
||||
154
yarn.lock
154
yarn.lock
@@ -950,34 +950,34 @@
|
||||
dependencies:
|
||||
tslib "^2.0.0"
|
||||
|
||||
"@douyinfe/semi-animation-react@2.99.3":
|
||||
version "2.99.3"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-react/-/semi-animation-react-2.99.3.tgz#504e45896db45761d173be8a68cb2aa43157e8cd"
|
||||
integrity sha512-0iUWQRO1t838Q1VaPE7DwOnYWeAuuu98MrNnaFkbD8JncYsct2K/2A5TDfa56DwSZ5iVz53jz2En8dMi7oF8sw==
|
||||
"@douyinfe/semi-animation-react@2.100.0":
|
||||
version "2.100.0"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-react/-/semi-animation-react-2.100.0.tgz#f53cb41a259f4dfefafd68cab76964635a215736"
|
||||
integrity sha512-zp224kBejXu+28z56uxLNasaijDJN55w0Ll+/JN+NaksTeKoUteEa93hx2SZVt6GGwZAM3H3mfDwF1UcE+fvLA==
|
||||
dependencies:
|
||||
"@douyinfe/semi-animation" "2.99.3"
|
||||
"@douyinfe/semi-animation-styled" "2.99.3"
|
||||
"@douyinfe/semi-animation" "2.100.0"
|
||||
"@douyinfe/semi-animation-styled" "2.100.0"
|
||||
classnames "^2.2.6"
|
||||
|
||||
"@douyinfe/semi-animation-styled@2.99.3":
|
||||
version "2.99.3"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-styled/-/semi-animation-styled-2.99.3.tgz#d810fd4fb1e2fa6c617b479b3bcbf6ef91d1e4d8"
|
||||
integrity sha512-38/ui6SoIJFWRs2jHv1IiNV2CKHaQKhYB4WftCVXCaYYQGL24+0oQ3iLo6qUeaHEWiQK3EcK2Rt7pxtJCJxVOA==
|
||||
"@douyinfe/semi-animation-styled@2.100.0":
|
||||
version "2.100.0"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-styled/-/semi-animation-styled-2.100.0.tgz#20a3fde32b94feb4d1fdf7eba037b19ad74c99ba"
|
||||
integrity sha512-UHluoWLAHPSVYK2OpdreaSHQI3bh300rrp/dP0UCjsl3FngTUHhsOHVqdWPJ3flTWnc3Mg1Flqr2gUmFjHplhw==
|
||||
|
||||
"@douyinfe/semi-animation@2.99.3":
|
||||
version "2.99.3"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation/-/semi-animation-2.99.3.tgz#3544687b8bc1f287c60a0f116494ce23adb42893"
|
||||
integrity sha512-Uva9MLF+EjC+m6eBYnX9PFZIQKLxD+iKV6ps/nX/P1FWy17DCDxIsga/cByF0PIsVRLzrSdkCsddj3XETcDw9A==
|
||||
"@douyinfe/semi-animation@2.100.0":
|
||||
version "2.100.0"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation/-/semi-animation-2.100.0.tgz#2f96f57d5c60d732eae5bd02a90ee1e6ec4d23b4"
|
||||
integrity sha512-X9AxxUrrHWhgxxLkM4oJw8ZM/VAXsu7/fkr4dyIkkZHDhQcnMfMc2YtughqaVqkaicm3SV9zRx9npjYe/S5nVw==
|
||||
dependencies:
|
||||
bezier-easing "^2.1.0"
|
||||
|
||||
"@douyinfe/semi-foundation@2.99.3":
|
||||
version "2.99.3"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-foundation/-/semi-foundation-2.99.3.tgz#ac7f9afd4d141711a5aca14b0a1b6e4ffba70417"
|
||||
integrity sha512-HKzrcdNGYoEZD81CKI6fj8jU2MWNrZx8HZ0NDHym+smBxSyhpoE/b0FrVo0PmLjCzbCDnySDdJ31GsK5GScmuw==
|
||||
"@douyinfe/semi-foundation@2.100.0":
|
||||
version "2.100.0"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-foundation/-/semi-foundation-2.100.0.tgz#e503dbc31bbc18c2f8862653bcbdc1d5a330fd65"
|
||||
integrity sha512-D2pjhpqOMOpjgw4M4Hg0Pj8KSnxl/jVsfynrIji5TwW7V2bGgt/aWOnBqdTXlrTLk4CHDmfAXKyr+rxY9aihhw==
|
||||
dependencies:
|
||||
"@douyinfe/semi-animation" "2.99.3"
|
||||
"@douyinfe/semi-json-viewer-core" "2.99.3"
|
||||
"@douyinfe/semi-animation" "2.100.0"
|
||||
"@douyinfe/semi-json-viewer-core" "2.100.0"
|
||||
"@mdx-js/mdx" "^3.0.1"
|
||||
async-validator "^3.5.0"
|
||||
classnames "^2.2.6"
|
||||
@@ -991,44 +991,44 @@
|
||||
remark-gfm "^4.0.0"
|
||||
scroll-into-view-if-needed "^2.2.24"
|
||||
|
||||
"@douyinfe/semi-icons@2.99.3", "@douyinfe/semi-icons@^2.99.3":
|
||||
version "2.99.3"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-icons/-/semi-icons-2.99.3.tgz#295d4fd79b2bf987bbcd34c0a4e3fb364f96e509"
|
||||
integrity sha512-Pm5H3Ua/PDumUCCsnJWwN+znVoKiyFCqag6DJy9/cuF6OOdd1+QUnvi0NHNg6+0fx/LHH088UwKFoOiZRkbaSw==
|
||||
"@douyinfe/semi-icons@2.100.0", "@douyinfe/semi-icons@^2.100.0":
|
||||
version "2.100.0"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-icons/-/semi-icons-2.100.0.tgz#b0853f230bfa993acbf90a1c2e9fcbb97321819b"
|
||||
integrity sha512-S/UZAOgzhbk2Dpwn0mUz/SrjswRpSTjSupzluLO0QmM8mCVuLSetmJ0Y/HO4MGM1eY9rEUrXON/FV3+SukFzxQ==
|
||||
dependencies:
|
||||
classnames "^2.2.6"
|
||||
|
||||
"@douyinfe/semi-illustrations@2.99.3":
|
||||
version "2.99.3"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-illustrations/-/semi-illustrations-2.99.3.tgz#e97d6c30830d44b7ec299d7e05f1a6e3c4938bf7"
|
||||
integrity sha512-z1rQPgWOV2xtZS8NkmL8JCK1DltQ8FGiL1qYlXbSHjEs1XkNYruq4W3dKv0IJEpTVLIlPsbDg4VmPAuuwLCCkQ==
|
||||
"@douyinfe/semi-illustrations@2.100.0":
|
||||
version "2.100.0"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-illustrations/-/semi-illustrations-2.100.0.tgz#4ca6623eedd1944817f1b7c8eba0095a6a7d2985"
|
||||
integrity sha512-SN7plpE328WGBohLHOVpYe6FwWSO6RLS7Xf6LhqEdtarwK52ircr4C/b+OyRqIwcLOzRYMgIoqcWnAQGmowcUw==
|
||||
|
||||
"@douyinfe/semi-json-viewer-core@2.99.3":
|
||||
version "2.99.3"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-json-viewer-core/-/semi-json-viewer-core-2.99.3.tgz#a8dee4ea6cbf1bcac85c723696a9430b4faf3152"
|
||||
integrity sha512-KEbZEyyM2qqGv9K+Yw/ZvAn4CEgcY2lQfL6a2ASEt80FlPoDAIWA7tGjpYxxM9/NcX9omNtsM/HLgDmrCjjBXQ==
|
||||
"@douyinfe/semi-json-viewer-core@2.100.0":
|
||||
version "2.100.0"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-json-viewer-core/-/semi-json-viewer-core-2.100.0.tgz#c0c3bf50f722aa51008a8e6acf17ac7842baceeb"
|
||||
integrity sha512-iQ6rX04YBngrsMz7Eds8zBI+W0MXb0mAICvfTaiX8RpoAwau9yFwbyHiCPKOVPSzI0hS8GwdMLSIYxdCOQPNqQ==
|
||||
dependencies:
|
||||
jsonc-parser "^3.3.1"
|
||||
|
||||
"@douyinfe/semi-theme-default@2.99.3":
|
||||
version "2.99.3"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-theme-default/-/semi-theme-default-2.99.3.tgz#e5ee0e4a8eec413ea3f58ed12f93415729c47251"
|
||||
integrity sha512-r0IIjrN6vQE1bqbky7FIRi4HQ03x4ykzSIRMf4Za04BFp76IFV6CclyYyUg6cLJ6GjWCnEPMFtwTLKP+b8dAYA==
|
||||
"@douyinfe/semi-theme-default@2.100.0":
|
||||
version "2.100.0"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-theme-default/-/semi-theme-default-2.100.0.tgz#919bb12307f6b3258016cf36e320c607717eb8c2"
|
||||
integrity sha512-7tJjg5NiuUYtChWr/E5rQ4Kcko3izz8rTxlNDWSS4YR3RQg3S+lQTgG5bD7LMnBqX399erf3wgE35KLwQZKWTg==
|
||||
|
||||
"@douyinfe/semi-ui-19@^2.99.3":
|
||||
version "2.99.3"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-ui-19/-/semi-ui-19-2.99.3.tgz#236a8894ea38ac3cd9d4a4d9c784dc9712b2105a"
|
||||
integrity sha512-HrXK1xIXfzS7OYzkrS+3PQKlMnx6J5HEw7wfYtDvGSIN/riSbjeD8vciHeIvP1tvhEAubFY8DMFwT07ZdmqfxA==
|
||||
"@douyinfe/semi-ui-19@^2.100.0":
|
||||
version "2.100.0"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-ui-19/-/semi-ui-19-2.100.0.tgz#bee76e0a0eec57b49b64f8dd2d73b9039b7a2c1b"
|
||||
integrity sha512-eL4DTJm4CPopWgr4d278dXIa2UwNgUundRJ37ksQ7Ev1TZnWr8SxCWLcmi4exl8kymZurAWV7j2w1sv7BHqtAA==
|
||||
dependencies:
|
||||
"@dnd-kit/core" "^6.0.8"
|
||||
"@dnd-kit/sortable" "^7.0.2"
|
||||
"@dnd-kit/utilities" "^3.2.1"
|
||||
"@douyinfe/semi-animation" "2.99.3"
|
||||
"@douyinfe/semi-animation-react" "2.99.3"
|
||||
"@douyinfe/semi-foundation" "2.99.3"
|
||||
"@douyinfe/semi-icons" "2.99.3"
|
||||
"@douyinfe/semi-illustrations" "2.99.3"
|
||||
"@douyinfe/semi-theme-default" "2.99.3"
|
||||
"@douyinfe/semi-animation" "2.100.0"
|
||||
"@douyinfe/semi-animation-react" "2.100.0"
|
||||
"@douyinfe/semi-foundation" "2.100.0"
|
||||
"@douyinfe/semi-icons" "2.100.0"
|
||||
"@douyinfe/semi-illustrations" "2.100.0"
|
||||
"@douyinfe/semi-theme-default" "2.100.0"
|
||||
"@tiptap/core" "^3.10.7"
|
||||
"@tiptap/extension-document" "^3.10.7"
|
||||
"@tiptap/extension-hard-break" "^3.10.7"
|
||||
@@ -1057,20 +1057,20 @@
|
||||
scroll-into-view-if-needed "^2.2.24"
|
||||
utility-types "^3.10.0"
|
||||
|
||||
"@douyinfe/semi-ui@2.99.3":
|
||||
version "2.99.3"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-ui/-/semi-ui-2.99.3.tgz#a183ecc4db0e96c48c714d8733a6d30e9395bc4a"
|
||||
integrity sha512-6NkeijjZZWzD31omteNVLz+oZuuMKQm3nEcwLI8+44Vv+VUSJPb87WnSFSD3F6eUIt/hZp2vJbCXHWW9SbCpDw==
|
||||
"@douyinfe/semi-ui@2.100.0":
|
||||
version "2.100.0"
|
||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-ui/-/semi-ui-2.100.0.tgz#2964299a4c4da2501c4ba1fd2699fbfd2daef106"
|
||||
integrity sha512-fTaqS6B1gHLjwMKgcWTcJWdMk9gY96h94I71Y3z9ee6qIXJyjAO8XiE8G6bihEIeVO3vTKXp1DOKiGhlgMVJKQ==
|
||||
dependencies:
|
||||
"@dnd-kit/core" "^6.0.8"
|
||||
"@dnd-kit/sortable" "^7.0.2"
|
||||
"@dnd-kit/utilities" "^3.2.1"
|
||||
"@douyinfe/semi-animation" "2.99.3"
|
||||
"@douyinfe/semi-animation-react" "2.99.3"
|
||||
"@douyinfe/semi-foundation" "2.99.3"
|
||||
"@douyinfe/semi-icons" "2.99.3"
|
||||
"@douyinfe/semi-illustrations" "2.99.3"
|
||||
"@douyinfe/semi-theme-default" "2.99.3"
|
||||
"@douyinfe/semi-animation" "2.100.0"
|
||||
"@douyinfe/semi-animation-react" "2.100.0"
|
||||
"@douyinfe/semi-foundation" "2.100.0"
|
||||
"@douyinfe/semi-icons" "2.100.0"
|
||||
"@douyinfe/semi-illustrations" "2.100.0"
|
||||
"@douyinfe/semi-theme-default" "2.100.0"
|
||||
"@tiptap/core" "^3.10.7"
|
||||
"@tiptap/extension-document" "^3.10.7"
|
||||
"@tiptap/extension-hard-break" "^3.10.7"
|
||||
@@ -5795,10 +5795,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.10:
|
||||
version "8.0.10"
|
||||
resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-8.0.10.tgz#009d4deaa06f54b6bd7ddc6cac1bf78e3bcb0bf2"
|
||||
integrity sha512-BLFuSth7QtHOkBzyqTehWWyub0NTRDuK2Q2SQfnGLsrJnzyU+Yeh4WpV1eZGuARFj1xQJHIdnTuJZLP+b9R1GQ==
|
||||
nodemailer@^8.0.11:
|
||||
version "8.0.11"
|
||||
resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-8.0.11.tgz#ce46b7c2c8bbf17b0408122fbfb4e47f4bffc688"
|
||||
integrity sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==
|
||||
|
||||
nodemon@^3.1.14:
|
||||
version "3.1.14"
|
||||
@@ -6180,10 +6180,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.3:
|
||||
version "3.8.3"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.3.tgz#560f2de55bf01b4c0503bc629d5df99b9a1d09b0"
|
||||
integrity sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==
|
||||
prettier@3.8.4:
|
||||
version "3.8.4"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.4.tgz#f334f013ac04a96676f24dabc23c1c4ae1bae411"
|
||||
integrity sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==
|
||||
|
||||
prismjs@^1.29.0:
|
||||
version "1.30.0"
|
||||
@@ -6525,17 +6525,17 @@ react-resizable@^3.0.5:
|
||||
prop-types "15.x"
|
||||
react-draggable "^4.0.3"
|
||||
|
||||
react-router-dom@7.16.0:
|
||||
version "7.16.0"
|
||||
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.16.0.tgz#284a7cd021052aa7d0a9240dca4a02eec24eceb5"
|
||||
integrity sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==
|
||||
react-router-dom@7.17.0:
|
||||
version "7.17.0"
|
||||
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.17.0.tgz#e77527b4b7862f7b47ff26dd5b9315fb897b82a7"
|
||||
integrity sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==
|
||||
dependencies:
|
||||
react-router "7.16.0"
|
||||
react-router "7.17.0"
|
||||
|
||||
react-router@7.16.0:
|
||||
version "7.16.0"
|
||||
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.16.0.tgz#fb41536aef2ccc2c7be12ea6be819a1e56eb6343"
|
||||
integrity sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==
|
||||
react-router@7.17.0:
|
||||
version "7.17.0"
|
||||
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.17.0.tgz#88bbe817c6e37ab36faf140623b5d4678bf81e41"
|
||||
integrity sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==
|
||||
dependencies:
|
||||
cookie "^1.0.1"
|
||||
set-cookie-parser "^2.6.0"
|
||||
@@ -6974,10 +6974,10 @@ semver@^7.6.0:
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a"
|
||||
integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==
|
||||
|
||||
semver@^7.8.1:
|
||||
version "7.8.1"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.1.tgz#bf4970b5e70fda0686363cc18bfe8805d5ed957e"
|
||||
integrity sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==
|
||||
semver@^7.8.4:
|
||||
version "7.8.4"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.4.tgz#c73eceebae0616934be8dff28a7fd70757c8e696"
|
||||
integrity sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==
|
||||
|
||||
send@^1.1.0:
|
||||
version "1.2.1"
|
||||
|
||||
Reference in New Issue
Block a user