mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bcec04d55 | ||
|
|
ee2112a24d | ||
|
|
5a54448288 | ||
|
|
f1b8709ab7 | ||
|
|
b56e13aa16 | ||
|
|
a834abc31c | ||
|
|
573868eccb | ||
|
|
1a210d7c1c | ||
|
|
996b841cfb | ||
|
|
b2e294e38c | ||
|
|
8afeaa05d9 | ||
|
|
ec47137b89 | ||
|
|
33161de087 | ||
|
|
acab23207e |
34
README.md
34
README.md
@@ -167,6 +167,40 @@ For more information on how to set it up and use it, please refer to the [MCP Re
|
|||||||
|
|
||||||
Immoscout has implemented advanced bot detection. In order to work around this, we are using a reversed engineered version of their mobile api. See [Immoscout Reverse Engineering Documentation](https://github.com/orangecoding/fredy/blob/master/reverse-engineered-immoscout.md)
|
Immoscout has implemented advanced bot detection. In order to work around this, we are using a reversed engineered version of their mobile api. See [Immoscout Reverse Engineering Documentation](https://github.com/orangecoding/fredy/blob/master/reverse-engineered-immoscout.md)
|
||||||
|
|
||||||
|
## 🛡️ Bot Detection & Proxies
|
||||||
|
|
||||||
|
Most browser-based providers (immowelt, immonet, kleinanzeigen, ...) are scraped through a hardened headless browser ([CloakBrowser](https://www.npmjs.com/package/cloakbrowser)). It makes the **browser fingerprint** indistinguishable from a real Chrome, which is enough when you run Fredy on a normal home connection.
|
||||||
|
|
||||||
|
On a **server / VPS the requests usually originate from a datacenter IP**, and providers behind anti-bot systems (e.g. AWS CloudFront/WAF) block those based on **IP reputation alone**, no matter how perfect the fingerprint is. The typical symptom: it works locally but you get `We have been detected as a bot :-/` on the server.
|
||||||
|
|
||||||
|
### The fix: a residential proxy
|
||||||
|
|
||||||
|
A **residential proxy** routes Fredy's browser through the internet connection of a real household, so the provider sees a "normal user" IP instead of a datacenter. For German portals, use a **German (DE) residential** (or mobile/4G) proxy. Plain VPNs and **datacenter proxies do not help** here, they share the same bad reputation as your server.
|
||||||
|
|
||||||
|
**Configure it** under **Settings → Execution → Proxy URL**. Supported formats:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://user:pass@host:port
|
||||||
|
socks5://user:pass@host:port
|
||||||
|
```
|
||||||
|
|
||||||
|
Leave the field empty to disable. The proxy applies to all headless-browser providers and takes effect on the next job run (no restart needed). Immoscout uses a separate mobile API and is not affected.
|
||||||
|
|
||||||
|
### Where to get a residential proxy
|
||||||
|
|
||||||
|
Residential proxies are a paid service (usually billed per GB, Fredy's traffic is small). Well-known providers offering German residential IPs include:
|
||||||
|
|
||||||
|
| Provider | Notes |
|
||||||
|
|---|---|
|
||||||
|
| [IPRoyal](https://iproyal.com) | Pay-as-you-go, no monthly minimum, good for low volume |
|
||||||
|
| [Webshare](https://www.webshare.io) | Cheap entry tier, has a small free plan to test with |
|
||||||
|
| [Decodo (formerly Smartproxy)](https://decodo.com) | Easy setup, country/city targeting |
|
||||||
|
| [SOAX](https://soax.com) | Residential + mobile, fine-grained geo-targeting |
|
||||||
|
| [Bright Data](https://brightdata.com) | Largest pool, most features, higher complexity/price |
|
||||||
|
| [Oxylabs](https://oxylabs.io) | Enterprise-grade, larger plans |
|
||||||
|
|
||||||
|
This is not an endorsement, pick whatever fits your budget. For low-volume use like Fredy, a pay-as-you-go plan (e.g. IPRoyal) or a cheap entry tier (e.g. Webshare) is usually plenty. Make sure to select **Germany** as the proxy location and keep the search interval reasonable (the higher the interval, the less you look like a bot).
|
||||||
|
|
||||||
## Analytics
|
## Analytics
|
||||||
|
|
||||||
Fredy is completely free (and will always remain free). However, it would be a huge help if you’d allow me to collect some analytical data.
|
Fredy is completely free (and will always remain free). However, it would be a huge help if you’d allow me to collect some analytical data.
|
||||||
|
|||||||
@@ -5,9 +5,10 @@
|
|||||||
|
|
||||||
import { NoNewListingsWarning } from './errors.js';
|
import { NoNewListingsWarning } from './errors.js';
|
||||||
import {
|
import {
|
||||||
storeListings,
|
|
||||||
getKnownListingHashesForJobAndProvider,
|
|
||||||
deleteListingsById,
|
deleteListingsById,
|
||||||
|
getKnownListingHashesForJobAndProvider,
|
||||||
|
storeListings,
|
||||||
|
updateListingDistance,
|
||||||
} from './services/storage/listingsStorage.js';
|
} from './services/storage/listingsStorage.js';
|
||||||
import { getJob } from './services/storage/jobStorage.js';
|
import { getJob } from './services/storage/jobStorage.js';
|
||||||
import * as notify from './notification/notify.js';
|
import * as notify from './notification/notify.js';
|
||||||
@@ -16,8 +17,7 @@ import urlModifier from './services/queryStringMutator.js';
|
|||||||
import logger from './services/logger.js';
|
import logger from './services/logger.js';
|
||||||
import { geocodeAddress } from './services/geocoding/geoCodingService.js';
|
import { geocodeAddress } from './services/geocoding/geoCodingService.js';
|
||||||
import { distanceMeters } from './services/listings/distanceCalculator.js';
|
import { distanceMeters } from './services/listings/distanceCalculator.js';
|
||||||
import { getUserSettings, getSettings } from './services/storage/settingsStorage.js';
|
import { getSettings, getUserSettings } from './services/storage/settingsStorage.js';
|
||||||
import { updateListingDistance } from './services/storage/listingsStorage.js';
|
|
||||||
import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
|
import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
|
||||||
import { formatListing } from './utils/formatListing.js';
|
import { formatListing } from './utils/formatListing.js';
|
||||||
|
|
||||||
@@ -97,9 +97,9 @@ class FredyPipelineExecutioner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optionally enrich new listings with data from their detail pages.
|
* Optionally, enrich new listings with data from their detail pages.
|
||||||
* Only called when the provider config defines a `fetchDetails` function.
|
* Only called when the provider config defines a `fetchDetails` function.
|
||||||
* Runs all fetches in parallel. Each individual fetch must handle its own errors
|
* Runs all fetches in parallel. Each fetch must handle its own errors
|
||||||
* and always resolve (never reject) to avoid aborting other listings.
|
* and always resolve (never reject) to avoid aborting other listings.
|
||||||
*
|
*
|
||||||
* @param {Listing[]} newListings New listings to enrich.
|
* @param {Listing[]} newListings New listings to enrich.
|
||||||
@@ -132,7 +132,7 @@ class FredyPipelineExecutioner {
|
|||||||
for (const listing of newListings) {
|
for (const listing of newListings) {
|
||||||
if (listing.address) {
|
if (listing.address) {
|
||||||
const coords = await geocodeAddress(listing.address);
|
const coords = await geocodeAddress(listing.address);
|
||||||
if (coords) {
|
if (coords && coords.lat !== -1 && coords.lng !== -1) {
|
||||||
listing.latitude = coords.lat;
|
listing.latitude = coords.lat;
|
||||||
listing.longitude = coords.lng;
|
listing.longitude = coords.lng;
|
||||||
}
|
}
|
||||||
@@ -264,15 +264,15 @@ class FredyPipelineExecutioner {
|
|||||||
const requiredKeys = this._providerConfig.requiredFieldNames;
|
const requiredKeys = this._providerConfig.requiredFieldNames;
|
||||||
const requireValues = ['id', 'link', 'title'];
|
const requireValues = ['id', 'link', 'title'];
|
||||||
|
|
||||||
const filteredListings = listings
|
return (
|
||||||
// this should never filter some listings out, because the normalize function should always extract all fields.
|
listings
|
||||||
.filter((item) => requiredKeys.every((key) => key in item))
|
// this should never filter some listings out, because the normalize function should always extract all fields.
|
||||||
// TODO: move blacklist filter to this file, so it will handle for all providers in same way.
|
.filter((item) => requiredKeys.every((key) => key in item))
|
||||||
.filter(this._providerConfig.filter)
|
// TODO: move blacklist filter to this file, so it will handle for all providers in same way.
|
||||||
// filter out listings that are missing required fields
|
.filter(this._providerConfig.filter)
|
||||||
.filter((item) => requireValues.every((key) => item[key] != null));
|
// filter out listings that are missing required fields
|
||||||
|
.filter((item) => requireValues.every((key) => item[key] != null))
|
||||||
return filteredListings;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ export const TRACKING_POIS = {
|
|||||||
JOBS_TABLE_VIEW: 'JOBS_TABLE_VIEW',
|
JOBS_TABLE_VIEW: 'JOBS_TABLE_VIEW',
|
||||||
LISTING_TABLE_VIEW: 'LISTING_TABLE_VIEW',
|
LISTING_TABLE_VIEW: 'LISTING_TABLE_VIEW',
|
||||||
BASE_URL_SETTING: 'BASE_URL_SETTING',
|
BASE_URL_SETTING: 'BASE_URL_SETTING',
|
||||||
|
SET_PROXY_SETTING: 'SET_PROXY_SETTING',
|
||||||
DETECTED_AS_BOT: 'DETECTED_AS_BOT',
|
DETECTED_AS_BOT: 'DETECTED_AS_BOT',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ export default async function generalSettingsPlugin(fastify) {
|
|||||||
if (appSettings.baseUrl != null) {
|
if (appSettings.baseUrl != null) {
|
||||||
await trackPoi(TRACKING_POIS.BASE_URL_SETTING);
|
await trackPoi(TRACKING_POIS.BASE_URL_SETTING);
|
||||||
}
|
}
|
||||||
|
if (appSettings.proxyUrl != null) {
|
||||||
|
await trackPoi(TRACKING_POIS.SET_PROXY_SETTING);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(err);
|
logger.error(err);
|
||||||
return reply.code(500).send({ error: 'Error while trying to write settings.' });
|
return reply.code(500).send({ error: 'Error while trying to write settings.' });
|
||||||
|
|||||||
@@ -151,4 +151,28 @@ export default async function userSettingsPlugin(fastify) {
|
|||||||
return reply.code(500).send({ error: error.message });
|
return reply.code(500).send({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fastify.post('/listing-deletion-preference', async (request, reply) => {
|
||||||
|
const userId = request.session.currentUser;
|
||||||
|
const { listing_deletion_preference } = 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 (listing_deletion_preference == null) {
|
||||||
|
return reply.code(400).send({ error: 'listing_deletion_preference is required.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { skipPrompt, hardDelete } = listing_deletion_preference;
|
||||||
|
|
||||||
|
try {
|
||||||
|
upsertSettings({ listing_deletion_preference: { skipPrompt, hardDelete } }, userId);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating listing deletion preference', error);
|
||||||
|
return reply.code(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,8 +196,8 @@ const config = {
|
|||||||
id: '.aditem@data-adid',
|
id: '.aditem@data-adid',
|
||||||
price: '.aditem-main--middle--price-shipping--price | removeNewline | trim',
|
price: '.aditem-main--middle--price-shipping--price | removeNewline | trim',
|
||||||
tags: '.aditem-main--middle--tags | removeNewline | trim',
|
tags: '.aditem-main--middle--tags | removeNewline | trim',
|
||||||
title: '.aditem-main .text-module-begin a | removeNewline | trim',
|
title: '.aditem-main .text-module-begin | removeNewline | trim',
|
||||||
link: '.aditem-main .text-module-begin a@href | removeNewline | trim',
|
link: '.aditem@data-href',
|
||||||
description: '.aditem-main .aditem-main--middle--description | removeNewline | trim',
|
description: '.aditem-main .aditem-main--middle--description | removeNewline | trim',
|
||||||
address: '.aditem-main--top--left | trim | removeNewline',
|
address: '.aditem-main--top--left | trim | removeNewline',
|
||||||
image: 'img@src',
|
image: 'img@src',
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { launch } from 'cloakbrowser/puppeteer';
|
import { launch } from 'cloakbrowser/puppeteer';
|
||||||
import { debug, botDetected } from './utils.js';
|
import { botDetected, debug } from './utils.js';
|
||||||
import { getPreLaunchConfig } from './botPrevention.js';
|
import { getPreLaunchConfig } from './botPrevention.js';
|
||||||
import logger from '../logger.js';
|
import logger from '../logger.js';
|
||||||
import { trackPoi } from '../tracking/Tracker.js';
|
import { trackPoi } from '../tracking/Tracker.js';
|
||||||
@@ -50,7 +50,7 @@ export async function launchBrowser(url, options) {
|
|||||||
preCfg.windowSizeArg,
|
preCfg.windowSizeArg,
|
||||||
];
|
];
|
||||||
|
|
||||||
const browser = await launch({
|
return await launch({
|
||||||
headless: options?.puppeteerHeadless ?? true,
|
headless: options?.puppeteerHeadless ?? true,
|
||||||
humanize: true,
|
humanize: true,
|
||||||
args,
|
args,
|
||||||
@@ -59,8 +59,6 @@ export async function launchBrowser(url, options) {
|
|||||||
...(options?.proxyUrl ? { proxy: options.proxyUrl } : {}),
|
...(options?.proxyUrl ? { proxy: options.proxyUrl } : {}),
|
||||||
...(preCfg.timezone ? { timezone: preCfg.timezone } : {}),
|
...(preCfg.timezone ? { timezone: preCfg.timezone } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return browser;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import * as similarityCache from '../similarity-check/similarityCache.js';
|
|||||||
import { isRunning, markFinished, markRunning } from './run-state.js';
|
import { isRunning, markFinished, markRunning } from './run-state.js';
|
||||||
import { sendToUsers } from '../sse/sse-broker.js';
|
import { sendToUsers } from '../sse/sse-broker.js';
|
||||||
import * as puppeteerExtractor from '../extractor/puppeteerExtractor.js';
|
import * as puppeteerExtractor from '../extractor/puppeteerExtractor.js';
|
||||||
|
import { getSettings } from '../storage/settingsStorage.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the job execution service.
|
* Initializes the job execution service.
|
||||||
@@ -160,6 +161,14 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
|
|||||||
}
|
}
|
||||||
let browser;
|
let browser;
|
||||||
try {
|
try {
|
||||||
|
// Read the proxy live (not from the startup snapshot) so changing it in the
|
||||||
|
// UI takes effect on the next run without a backend restart. An empty value
|
||||||
|
// disables the proxy. Routing the headless browser through a (German
|
||||||
|
// residential) proxy avoids datacenter-IP based bot detection on the
|
||||||
|
// Puppeteer-based providers (immowelt, immonet, kleinanzeigen, ...).
|
||||||
|
const liveSettings = await getSettings();
|
||||||
|
const proxyUrl = typeof liveSettings?.proxyUrl === 'string' ? liveSettings.proxyUrl.trim() : '';
|
||||||
|
|
||||||
const jobProviders = job.provider.filter(
|
const jobProviders = job.provider.filter(
|
||||||
(p) => providers.find((loaded) => loaded.metaInformation.id === p.id) != null,
|
(p) => providers.find((loaded) => loaded.metaInformation.id === p.id) != null,
|
||||||
);
|
);
|
||||||
@@ -168,14 +177,14 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
|
|||||||
const matchedProvider = providers.find((loaded) => loaded.metaInformation.id === prov.id);
|
const matchedProvider = providers.find((loaded) => loaded.metaInformation.id === prov.id);
|
||||||
matchedProvider.init({ ...prov, userId: job.userId }, job.blacklist);
|
matchedProvider.init({ ...prov, userId: job.userId }, job.blacklist);
|
||||||
|
|
||||||
if (browser && !browser.isConnected()) {
|
if (browser && !browser.connected) {
|
||||||
logger.debug('Browser is disconnected, nullifying to launch a new one.');
|
logger.debug('Browser is disconnected, nullifying to launch a new one.');
|
||||||
await puppeteerExtractor.closeBrowser(browser);
|
await puppeteerExtractor.closeBrowser(browser);
|
||||||
browser = null;
|
browser = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!browser && matchedProvider.config.getListings == null) {
|
if (!browser && matchedProvider.config.getListings == null) {
|
||||||
browser = await puppeteerExtractor.launchBrowser(matchedProvider.config.url, {});
|
browser = await puppeteerExtractor.launchBrowser(matchedProvider.config.url, proxyUrl ? { proxyUrl } : {});
|
||||||
}
|
}
|
||||||
|
|
||||||
await new FredyPipelineExecutioner(matchedProvider.config, job, prov.id, similarityCache, browser).execute();
|
await new FredyPipelineExecutioner(matchedProvider.config, job, prov.id, similarityCache, browser).execute();
|
||||||
|
|||||||
@@ -214,6 +214,8 @@ export const storeListings = (jobId, providerId, listings) => {
|
|||||||
longitude: item.longitude || null,
|
longitude: item.longitude || null,
|
||||||
};
|
};
|
||||||
stmt.run(params);
|
stmt.run(params);
|
||||||
|
// Propagate the DB primary key back so downstream pipeline steps use the correct id
|
||||||
|
item.id = params.id;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -417,9 +419,10 @@ export const deleteListingsByJobId = (jobId, hardDelete = false) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete listings by a list of listing IDs.
|
* Delete listings by a list of listing IDs (the nanoid primary key stored in the `id` column).
|
||||||
|
* Used by API routes that receive row IDs from the client.
|
||||||
*
|
*
|
||||||
* @param {string[]} ids - Array of listing IDs to delete.
|
* @param {string[]} ids - Array of DB row IDs to delete.
|
||||||
* @param {boolean} [hardDelete=false] - Whether to hard delete from DB or just mark as deleted.
|
* @param {boolean} [hardDelete=false] - Whether to hard delete from DB or just mark as deleted.
|
||||||
* @returns {any} The result from SqliteConnection.execute.
|
* @returns {any} The result from SqliteConnection.execute.
|
||||||
*/
|
*/
|
||||||
|
|||||||
48
package.json
48
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fredy",
|
"name": "fredy",
|
||||||
"version": "22.0.7",
|
"version": "22.2.1",
|
||||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
@@ -62,9 +62,9 @@
|
|||||||
"Firefox ESR"
|
"Firefox ESR"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@douyinfe/semi-icons": "^2.97.0",
|
"@douyinfe/semi-icons": "^2.99.3",
|
||||||
"@douyinfe/semi-ui": "2.97.0",
|
"@douyinfe/semi-ui": "2.99.3",
|
||||||
"@douyinfe/semi-ui-19": "^2.97.0",
|
"@douyinfe/semi-ui-19": "^2.99.3",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/helmet": "^13.0.2",
|
"@fastify/helmet": "^13.0.2",
|
||||||
"@fastify/session": "^11.1.1",
|
"@fastify/session": "^11.1.1",
|
||||||
@@ -73,12 +73,12 @@
|
|||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"@sendgrid/mail": "8.1.6",
|
"@sendgrid/mail": "8.1.6",
|
||||||
"@turf/boolean-point-in-polygon": "^7.3.5",
|
"@turf/boolean-point-in-polygon": "^7.3.5",
|
||||||
"@vitejs/plugin-react": "6.0.1",
|
"@vitejs/plugin-react": "6.0.2",
|
||||||
"adm-zip": "^0.5.17",
|
"adm-zip": "^0.5.17",
|
||||||
"better-sqlite3": "^12.10.0",
|
"better-sqlite3": "^12.10.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"cheerio": "^1.2.0",
|
"cheerio": "^1.2.0",
|
||||||
"cloakbrowser": "^0.3.28",
|
"cloakbrowser": "^0.3.31",
|
||||||
"fastify": "^5.8.5",
|
"fastify": "^5.8.5",
|
||||||
"handlebars": "4.7.9",
|
"handlebars": "4.7.9",
|
||||||
"maplibre-gl": "^5.24.0",
|
"maplibre-gl": "^5.24.0",
|
||||||
@@ -86,41 +86,41 @@
|
|||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"node-fetch": "3.3.2",
|
"node-fetch": "3.3.2",
|
||||||
"node-mailjet": "6.0.11",
|
"node-mailjet": "6.0.11",
|
||||||
"nodemailer": "^8.0.7",
|
"nodemailer": "^8.0.10",
|
||||||
"p-throttle": "^8.1.0",
|
"p-throttle": "^8.1.0",
|
||||||
"package-up": "^5.0.0",
|
"package-up": "^5.0.0",
|
||||||
"puppeteer-core": "^24.43.1",
|
"puppeteer-core": "^25.1.0",
|
||||||
"query-string": "9.3.1",
|
"query-string": "9.4.0",
|
||||||
"react": "19.2.6",
|
"react": "19.2.7",
|
||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
"react-dom": "19.2.6",
|
"react-dom": "19.2.7",
|
||||||
"react-range-slider-input": "^3.3.5",
|
"react-range-slider-input": "^3.3.5",
|
||||||
"react-router": "7.15.0",
|
"react-router": "7.16.0",
|
||||||
"react-router-dom": "7.15.0",
|
"react-router-dom": "7.16.0",
|
||||||
"resend": "^6.12.3",
|
"resend": "^6.12.4",
|
||||||
"semver": "^7.8.0",
|
"semver": "^7.8.1",
|
||||||
"slack": "11.0.2",
|
"slack": "11.0.2",
|
||||||
"vite": "8.0.12",
|
"vite": "8.0.16",
|
||||||
"x-var": "^3.0.1",
|
"x-var": "^3.0.1",
|
||||||
"zustand": "^5.0.13"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.29.0",
|
"@babel/core": "7.29.7",
|
||||||
"@babel/eslint-parser": "7.28.6",
|
"@babel/eslint-parser": "7.29.7",
|
||||||
"@babel/preset-env": "7.29.5",
|
"@babel/preset-env": "7.29.7",
|
||||||
"@babel/preset-react": "7.28.5",
|
"@babel/preset-react": "7.29.7",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"eslint": "10.3.0",
|
"eslint": "10.4.1",
|
||||||
"eslint-config-prettier": "10.1.8",
|
"eslint-config-prettier": "10.1.8",
|
||||||
"eslint-plugin-react": "7.37.5",
|
"eslint-plugin-react": "7.37.5",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
"history": "5.3.0",
|
"history": "5.3.0",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"less": "4.6.4",
|
"less": "4.6.4",
|
||||||
"lint-staged": "17.0.4",
|
"lint-staged": "17.0.7",
|
||||||
"nodemon": "^3.1.14",
|
"nodemon": "^3.1.14",
|
||||||
"prettier": "3.8.3",
|
"prettier": "3.8.3",
|
||||||
"vitest": "^4.1.6"
|
"vitest": "^4.1.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,4 +32,7 @@ export const deletedIds = [];
|
|||||||
export const deleteListingsById = (ids) => {
|
export const deleteListingsById = (ids) => {
|
||||||
deletedIds.push(...ids);
|
deletedIds.push(...ids);
|
||||||
};
|
};
|
||||||
|
export const deleteListingsByHash = (hashes) => {
|
||||||
|
deletedIds.push(...hashes);
|
||||||
|
};
|
||||||
/* eslint-enable no-unused-vars */
|
/* eslint-enable no-unused-vars */
|
||||||
|
|||||||
@@ -38,6 +38,20 @@ async function tryReadFile(filepath) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withRealEstateType(data, realEstateType) {
|
||||||
|
if (!realEstateType?.length || !Array.isArray(data?.resultListItems)) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloned = typeof structuredClone === 'function' ? structuredClone(data) : JSON.parse(JSON.stringify(data));
|
||||||
|
for (const item of cloned.resultListItems) {
|
||||||
|
if (item?.type === 'EXPOSE_RESULT' && item?.item) {
|
||||||
|
item.item.realEstateType = realEstateType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns fixture HTML for the given URL by mapping hostname → provider name,
|
* Returns fixture HTML for the given URL by mapping hostname → provider name,
|
||||||
* then distinguishing list vs detail pages by comparing the URL path against
|
* then distinguishing list vs detail pages by comparing the URL path against
|
||||||
@@ -83,7 +97,10 @@ export function buildFetchMock() {
|
|||||||
const raw = await tryReadFile(path.join(FIXTURES_DIR, 'immoscout_list.json'));
|
const raw = await tryReadFile(path.join(FIXTURES_DIR, 'immoscout_list.json'));
|
||||||
listData = raw ? JSON.parse(raw) : { resultListItems: [] };
|
listData = raw ? JSON.parse(raw) : { resultListItems: [] };
|
||||||
}
|
}
|
||||||
return { ok: true, status: 200, json: () => Promise.resolve(listData) };
|
|
||||||
|
const requestedType = new URL(urlStr).searchParams.get('realestatetype');
|
||||||
|
const responseData = withRealEstateType(listData, requestedType);
|
||||||
|
return { ok: true, status: 200, json: () => Promise.resolve(responseData) };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (urlStr.includes('api.mobile.immobilienscout24.de/expose/')) {
|
if (urlStr.includes('api.mobile.immobilienscout24.de/expose/')) {
|
||||||
|
|||||||
37
test/services/extractor/puppeteerExtractor.test.js
Normal file
37
test/services/extractor/puppeteerExtractor.test.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// Mock the CloakBrowser launcher so no real Chromium binary is needed and we can
|
||||||
|
// assert which options get forwarded to it.
|
||||||
|
const { launchMock } = vi.hoisted(() => ({ launchMock: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock('cloakbrowser/puppeteer', () => ({
|
||||||
|
launch: launchMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { launchBrowser } = await import('../../../lib/services/extractor/puppeteerExtractor.js');
|
||||||
|
|
||||||
|
describe('launchBrowser proxy forwarding', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
launchMock.mockReset();
|
||||||
|
launchMock.mockResolvedValue({ close: async () => {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards proxyUrl to CloakBrowser as the proxy option', async () => {
|
||||||
|
await launchBrowser('https://www.immowelt.de/', { proxyUrl: 'http://user:pass@host:8080' });
|
||||||
|
|
||||||
|
expect(launchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(launchMock.mock.calls[0][0]).toMatchObject({ proxy: 'http://user:pass@host:8080' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not set a proxy when no proxyUrl is given', async () => {
|
||||||
|
await launchBrowser('https://www.immowelt.de/', {});
|
||||||
|
|
||||||
|
expect(launchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(launchMock.mock.calls[0][0].proxy).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,11 +4,16 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { convertWebToMobile } from '../../../lib/services/immoscout/immoscout-web-translator.js';
|
import { convertWebToMobile } from '../../../lib/services/immoscout/immoscout-web-translator.js';
|
||||||
import { expect } from 'vitest';
|
import { expect, vi } from 'vitest';
|
||||||
import { readFile } from 'fs/promises';
|
import { readFile } from 'fs/promises';
|
||||||
|
import { buildFetchMock } from '../../offlineFixtures.js';
|
||||||
|
|
||||||
export const testData = JSON.parse(await readFile(new URL('./testdata.json', import.meta.url)));
|
export const testData = JSON.parse(await readFile(new URL('./testdata.json', import.meta.url)));
|
||||||
|
|
||||||
|
if (process.env.TEST_MODE === 'offline') {
|
||||||
|
vi.stubGlobal('fetch', buildFetchMock());
|
||||||
|
}
|
||||||
|
|
||||||
describe('#immoscout-mobile URL conversion', () => {
|
describe('#immoscout-mobile URL conversion', () => {
|
||||||
// Test shape URL conversion
|
// Test shape URL conversion
|
||||||
it('should convert a full web URL with shape to mobile URL', () => {
|
it('should convert a full web URL with shape to mobile URL', () => {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ describe('services/jobs/jobExecutionService', () => {
|
|||||||
const busPath = root + '/lib/services/events/event-bus.js';
|
const busPath = root + '/lib/services/events/event-bus.js';
|
||||||
const jobStoragePath = root + '/lib/services/storage/jobStorage.js';
|
const jobStoragePath = root + '/lib/services/storage/jobStorage.js';
|
||||||
const userStoragePath = root + '/lib/services/storage/userStorage.js';
|
const userStoragePath = root + '/lib/services/storage/userStorage.js';
|
||||||
|
const settingsStoragePath = root + '/lib/services/storage/settingsStorage.js';
|
||||||
const brokerPath = root + '/lib/services/sse/sse-broker.js';
|
const brokerPath = root + '/lib/services/sse/sse-broker.js';
|
||||||
const utilsPath = root + '/lib/utils.js';
|
const utilsPath = root + '/lib/utils.js';
|
||||||
const loggerPath = root + '/lib/services/logger.js';
|
const loggerPath = root + '/lib/services/logger.js';
|
||||||
@@ -33,6 +34,9 @@ describe('services/jobs/jobExecutionService', () => {
|
|||||||
getUsers: () => state.users.slice(),
|
getUsers: () => state.users.slice(),
|
||||||
getUser: (id) => state.users.find((u) => u.id === id) || null,
|
getUser: (id) => state.users.find((u) => u.id === id) || null,
|
||||||
}));
|
}));
|
||||||
|
vi.doMock(settingsStoragePath, () => ({
|
||||||
|
getSettings: async () => ({}),
|
||||||
|
}));
|
||||||
vi.doMock(brokerPath, () => ({
|
vi.doMock(brokerPath, () => ({
|
||||||
sendToUsers: (...args) => calls.sent.push(args),
|
sendToUsers: (...args) => calls.sent.push(args),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ const routes = {
|
|||||||
'GET /api/dashboard': dashboard,
|
'GET /api/dashboard': dashboard,
|
||||||
'GET /api/demo': { demoMode: false },
|
'GET /api/demo': { demoMode: false },
|
||||||
'POST /api/user/settings/news-hash': {},
|
'POST /api/user/settings/news-hash': {},
|
||||||
|
'POST /api/user/settings/listing-deletion-preference': {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Modal, Radio, RadioGroup, Typography } from '@douyinfe/semi-ui-19';
|
import { Modal, Radio, RadioGroup, Typography, Checkbox } from '@douyinfe/semi-ui-19';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -15,11 +15,24 @@ const ListingDeletionModal = ({
|
|||||||
title = 'Delete Listings',
|
title = 'Delete Listings',
|
||||||
showOptions = true,
|
showOptions = true,
|
||||||
message = 'How would you like to delete the selected listing(s)?',
|
message = 'How would you like to delete the selected listing(s)?',
|
||||||
|
defaultDeleteType = 'soft',
|
||||||
}) => {
|
}) => {
|
||||||
const [deleteType, setDeleteType] = useState('soft');
|
const [deleteType, setDeleteType] = useState('soft');
|
||||||
|
const [remember, setRemember] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setDeleteType(defaultDeleteType);
|
||||||
|
setRemember(false);
|
||||||
|
}
|
||||||
|
}, [visible, defaultDeleteType]);
|
||||||
|
|
||||||
const handleOk = () => {
|
const handleOk = () => {
|
||||||
onConfirm(!showOptions || deleteType === 'hard');
|
if (showOptions) {
|
||||||
|
onConfirm(deleteType === 'hard', remember);
|
||||||
|
} else {
|
||||||
|
onConfirm(true);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -36,32 +49,37 @@ const ListingDeletionModal = ({
|
|||||||
<Text>{message}</Text>
|
<Text>{message}</Text>
|
||||||
</div>
|
</div>
|
||||||
{showOptions && (
|
{showOptions && (
|
||||||
<RadioGroup value={deleteType} onChange={(e) => setDeleteType(e.target.value)} style={{ width: '100%' }}>
|
<>
|
||||||
<Radio value="soft" style={{ alignItems: 'flex-start', width: '100%' }}>
|
<RadioGroup value={deleteType} onChange={(e) => setDeleteType(e.target.value)} style={{ width: '100%' }}>
|
||||||
<div style={{ marginLeft: 8 }}>
|
<Radio value="soft" style={{ alignItems: 'flex-start', width: '100%' }}>
|
||||||
<Text strong>Mark as deleted (Soft Delete)</Text>
|
<div style={{ marginLeft: 8 }}>
|
||||||
<br />
|
<Text strong>Mark as deleted (Soft Delete)</Text>
|
||||||
<Text type="secondary">
|
|
||||||
Listings are kept in the database but marked as hidden. They will <b>not</b> re-appear during the next
|
|
||||||
scraping session.
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Radio>
|
|
||||||
<Radio value="hard" style={{ marginTop: 16, alignItems: 'flex-start', width: '100%' }}>
|
|
||||||
<div style={{ marginLeft: 8 }}>
|
|
||||||
<Text strong>Remove from database (Hard Delete)</Text>
|
|
||||||
<br />
|
|
||||||
<Text type="secondary">
|
|
||||||
Listings are completely removed from the database.
|
|
||||||
<br />
|
<br />
|
||||||
<Text type="warning">
|
<Text type="secondary">
|
||||||
Consequence: They might re-appear when scraping the next time because Fredy won't know they were
|
Listings are kept in the database but marked as hidden. They will <b>not</b> re-appear during the next
|
||||||
previously found.
|
scraping session.
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</div>
|
||||||
</div>
|
</Radio>
|
||||||
</Radio>
|
<Radio value="hard" style={{ marginTop: 16, alignItems: 'flex-start', width: '100%' }}>
|
||||||
</RadioGroup>
|
<div style={{ marginLeft: 8 }}>
|
||||||
|
<Text strong>Remove from database (Hard Delete)</Text>
|
||||||
|
<br />
|
||||||
|
<Text type="secondary">
|
||||||
|
Listings are completely removed from the database.
|
||||||
|
<br />
|
||||||
|
<Text type="warning">
|
||||||
|
Consequence: They might re-appear when scraping the next time because Fredy won't know they were
|
||||||
|
previously found.
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Radio>
|
||||||
|
</RadioGroup>
|
||||||
|
<Checkbox checked={remember} onChange={(e) => setRemember(e.target.checked)} style={{ marginTop: 16 }}>
|
||||||
|
Remember my choice and skip this dialog next time
|
||||||
|
</Checkbox>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ const JobGrid = () => {
|
|||||||
|
|
||||||
const userSettings = useSelector((state) => state.userSettings.settings);
|
const userSettings = useSelector((state) => state.userSettings.settings);
|
||||||
const viewMode = userSettings?.jobs_view_mode ?? 'grid';
|
const viewMode = userSettings?.jobs_view_mode ?? 'grid';
|
||||||
|
const listingDeletionPref = userSettings?.listing_deletion_preference;
|
||||||
|
const defaultDeleteType = listingDeletionPref?.hardDelete ? 'hard' : 'soft';
|
||||||
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const pageSize = 12;
|
const pageSize = 12;
|
||||||
@@ -142,13 +144,21 @@ const JobGrid = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onListingRemoval = (jobId) => {
|
const onListingRemoval = (jobId) => {
|
||||||
setPendingDeletion({ type: 'listings', jobId });
|
const deletion = { type: 'listings', jobId };
|
||||||
|
if (listingDeletionPref?.skipPrompt) {
|
||||||
|
confirmDeletion(listingDeletionPref.hardDelete, false, deletion);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingDeletion(deletion);
|
||||||
setDeleteModalVisible(true);
|
setDeleteModalVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDeletion = async (hardDelete) => {
|
const confirmDeletion = async (hardDelete, remember, deletion = pendingDeletion) => {
|
||||||
const { type, jobId } = pendingDeletion;
|
const { type, jobId } = deletion;
|
||||||
try {
|
try {
|
||||||
|
if (remember && type === 'listings') {
|
||||||
|
await actions.userSettings.setListingDeletionPreference({ skipPrompt: true, hardDelete });
|
||||||
|
}
|
||||||
if (type === 'job') {
|
if (type === 'job') {
|
||||||
await xhrDelete('/api/jobs', { jobId });
|
await xhrDelete('/api/jobs', { jobId });
|
||||||
Toast.success('Job and listings successfully removed');
|
Toast.success('Job and listings successfully removed');
|
||||||
@@ -425,6 +435,7 @@ const JobGrid = () => {
|
|||||||
visible={deleteModalVisible}
|
visible={deleteModalVisible}
|
||||||
title={pendingDeletion?.type === 'job' ? 'Delete Job' : 'Delete Listings'}
|
title={pendingDeletion?.type === 'job' ? 'Delete Job' : 'Delete Listings'}
|
||||||
showOptions={pendingDeletion?.type !== 'job'}
|
showOptions={pendingDeletion?.type !== 'job'}
|
||||||
|
defaultDeleteType={defaultDeleteType}
|
||||||
message={
|
message={
|
||||||
pendingDeletion?.type === 'job'
|
pendingDeletion?.type === 'job'
|
||||||
? 'Are you sure you want to delete this job? All associated listings will be removed from the database.'
|
? 'Are you sure you want to delete this job? All associated listings will be removed from the database.'
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ const ListingsOverview = () => {
|
|||||||
const sp = useSearchParams();
|
const sp = useSearchParams();
|
||||||
|
|
||||||
const viewMode = userSettings?.listings_view_mode ?? 'grid';
|
const viewMode = userSettings?.listings_view_mode ?? 'grid';
|
||||||
|
const listingDeletionPref = userSettings?.listing_deletion_preference;
|
||||||
|
const defaultDeleteType = listingDeletionPref?.hardDelete ? 'hard' : 'soft';
|
||||||
|
|
||||||
const [page, setPage] = useSearchParamState(sp, 'page', 1, parseNumber);
|
const [page, setPage] = useSearchParamState(sp, 'page', 1, parseNumber);
|
||||||
const pageSize = 40;
|
const pageSize = 40;
|
||||||
@@ -91,15 +93,22 @@ const ListingsOverview = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (id) => {
|
const handleDelete = (id) => {
|
||||||
|
if (listingDeletionPref?.skipPrompt) {
|
||||||
|
confirmDeletion(listingDeletionPref.hardDelete, false, id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setListingToDelete(id);
|
setListingToDelete(id);
|
||||||
setDeleteModalVisible(true);
|
setDeleteModalVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNavigate = (id) => navigate(`/listings/listing/${id}`);
|
const handleNavigate = (id) => navigate(`/listings/listing/${id}`);
|
||||||
|
|
||||||
const confirmDeletion = async (hardDelete) => {
|
const confirmDeletion = async (hardDelete, remember, id = listingToDelete) => {
|
||||||
try {
|
try {
|
||||||
await xhrDelete('/api/listings/', { ids: [listingToDelete], hardDelete });
|
if (remember) {
|
||||||
|
await actions.userSettings.setListingDeletionPreference({ skipPrompt: true, hardDelete });
|
||||||
|
}
|
||||||
|
await xhrDelete('/api/listings/', { ids: [id], hardDelete });
|
||||||
Toast.success('Listing successfully removed');
|
Toast.success('Listing successfully removed');
|
||||||
loadData();
|
loadData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -251,6 +260,7 @@ const ListingsOverview = () => {
|
|||||||
|
|
||||||
<ListingDeletionModal
|
<ListingDeletionModal
|
||||||
visible={deleteModalVisible}
|
visible={deleteModalVisible}
|
||||||
|
defaultDeleteType={defaultDeleteType}
|
||||||
onConfirm={confirmDeletion}
|
onConfirm={confirmDeletion}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setDeleteModalVisible(false);
|
setDeleteModalVisible(false);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import maplibregl from 'maplibre-gl';
|
|||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css';
|
import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css';
|
||||||
import { fixMapboxDrawCompatibility, addDrawingControl, setupAreaFilterEventListeners } from './MapDrawingExtension.js';
|
import { fixMapboxDrawCompatibility, addDrawingControl, setupAreaFilterEventListeners } from './MapDrawingExtension.js';
|
||||||
|
import { getBoundsFromCoords } from '../../views/listings/mapUtils.js';
|
||||||
import './Map.less';
|
import './Map.less';
|
||||||
|
|
||||||
export const GERMANY_BOUNDS = [
|
export const GERMANY_BOUNDS = [
|
||||||
@@ -66,6 +67,7 @@ export default function Map({
|
|||||||
const mapContainerRef = useRef(null);
|
const mapContainerRef = useRef(null);
|
||||||
const mapRef = useRef(null);
|
const mapRef = useRef(null);
|
||||||
const drawRef = useRef(null);
|
const drawRef = useRef(null);
|
||||||
|
const hasFittedToInitialAreaRef = useRef(false);
|
||||||
|
|
||||||
// Initialize map - ONLY when container changes, never reinitialize
|
// Initialize map - ONLY when container changes, never reinitialize
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -128,6 +130,17 @@ export default function Map({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading spatial filter:', error);
|
console.error('Error loading spatial filter:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasFittedToInitialAreaRef.current) {
|
||||||
|
const coords = initialSpatialFilter.features.flatMap((feature) =>
|
||||||
|
feature.geometry?.type === 'Polygon' ? feature.geometry.coordinates.flat() : [],
|
||||||
|
);
|
||||||
|
const bounds = getBoundsFromCoords(coords);
|
||||||
|
if (bounds) {
|
||||||
|
mapRef.current.fitBounds(bounds, { padding: 50, maxZoom: 15, duration: 0 });
|
||||||
|
hasFittedToInitialAreaRef.current = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup drawing event listeners
|
// Setup drawing event listeners
|
||||||
|
|||||||
@@ -349,6 +349,20 @@ export const useFredyState = create(
|
|||||||
throw Exception;
|
throw Exception;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async setListingDeletionPreference(listing_deletion_preference) {
|
||||||
|
try {
|
||||||
|
await xhrPost('/api/user/settings/listing-deletion-preference', { listing_deletion_preference });
|
||||||
|
set((state) => ({
|
||||||
|
userSettings: {
|
||||||
|
...state.userSettings,
|
||||||
|
settings: { ...state.userSettings.settings, listing_deletion_preference },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (Exception) {
|
||||||
|
console.error('Error while trying to update listing deletion preference. Error:', Exception);
|
||||||
|
throw Exception;
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ import {
|
|||||||
AutoComplete,
|
AutoComplete,
|
||||||
Select,
|
Select,
|
||||||
Banner,
|
Banner,
|
||||||
|
Radio,
|
||||||
|
RadioGroup,
|
||||||
|
Typography,
|
||||||
} from '@douyinfe/semi-ui-19';
|
} from '@douyinfe/semi-ui-19';
|
||||||
import { InputNumber } from '@douyinfe/semi-ui-19';
|
import { InputNumber } from '@douyinfe/semi-ui-19';
|
||||||
import { xhrPost, xhrGet } from '../../services/xhr';
|
import { xhrPost, xhrGet } from '../../services/xhr';
|
||||||
@@ -33,6 +36,8 @@ import { debounce } from '../../utils';
|
|||||||
import Headline from '../../components/headline/Headline.jsx';
|
import Headline from '../../components/headline/Headline.jsx';
|
||||||
import './GeneralSettings.less';
|
import './GeneralSettings.less';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
function formatFromTimestamp(ts) {
|
function formatFromTimestamp(ts) {
|
||||||
const date = new Date(ts);
|
const date = new Date(ts);
|
||||||
return `${date.getHours()}:${date.getMinutes() > 9 ? date.getMinutes() : '0' + date.getMinutes()}`;
|
return `${date.getHours()}:${date.getMinutes() > 9 ? date.getMinutes() : '0' + date.getMinutes()}`;
|
||||||
@@ -57,6 +62,7 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
const currentUser = useSelector((state) => state.user.currentUser);
|
const currentUser = useSelector((state) => state.user.currentUser);
|
||||||
|
|
||||||
const [interval, setInterval] = React.useState('');
|
const [interval, setInterval] = React.useState('');
|
||||||
|
const [proxyUrl, setProxyUrl] = React.useState('');
|
||||||
const [port, setPort] = React.useState('');
|
const [port, setPort] = React.useState('');
|
||||||
const [workingHourFrom, setWorkingHourFrom] = React.useState(null);
|
const [workingHourFrom, setWorkingHourFrom] = React.useState(null);
|
||||||
const [workingHourTo, setWorkingHourTo] = React.useState(null);
|
const [workingHourTo, setWorkingHourTo] = React.useState(null);
|
||||||
@@ -73,9 +79,12 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
// User settings state
|
// User settings state
|
||||||
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
|
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
|
||||||
const providerDetails = useSelector((state) => state.userSettings.settings.provider_details);
|
const providerDetails = useSelector((state) => state.userSettings.settings.provider_details);
|
||||||
|
const listingDeletionPreference = useSelector((state) => state.userSettings.settings.listing_deletion_preference);
|
||||||
const allProviders = useSelector((state) => state.provider);
|
const allProviders = useSelector((state) => state.provider);
|
||||||
const [address, setAddress] = useState(homeAddress?.address || '');
|
const [address, setAddress] = useState(homeAddress?.address || '');
|
||||||
const [coords, setCoords] = useState(homeAddress?.coords || null);
|
const [coords, setCoords] = useState(homeAddress?.coords || null);
|
||||||
|
const [listingDeleteHard, setListingDeleteHard] = useState(false);
|
||||||
|
const [listingDeleteSkipPrompt, setListingDeleteSkipPrompt] = useState(false);
|
||||||
const saving = useIsLoading(actions.userSettings.setHomeAddress);
|
const saving = useIsLoading(actions.userSettings.setHomeAddress);
|
||||||
const [dataSource, setDataSource] = useState([]);
|
const [dataSource, setDataSource] = useState([]);
|
||||||
|
|
||||||
@@ -91,6 +100,7 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
async function init() {
|
async function init() {
|
||||||
setInterval(settings?.interval);
|
setInterval(settings?.interval);
|
||||||
|
setProxyUrl(settings?.proxyUrl ?? '');
|
||||||
setPort(settings?.port);
|
setPort(settings?.port);
|
||||||
setWorkingHourFrom(settings?.workingHours?.from);
|
setWorkingHourFrom(settings?.workingHours?.from);
|
||||||
setWorkingHourTo(settings?.workingHours?.to);
|
setWorkingHourTo(settings?.workingHours?.to);
|
||||||
@@ -108,6 +118,11 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
setCoords(homeAddress?.coords || null);
|
setCoords(homeAddress?.coords || null);
|
||||||
}, [homeAddress]);
|
}, [homeAddress]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setListingDeleteHard(listingDeletionPreference?.hardDelete ?? false);
|
||||||
|
setListingDeleteSkipPrompt(listingDeletionPreference?.skipPrompt ?? false);
|
||||||
|
}, [listingDeletionPreference]);
|
||||||
|
|
||||||
const nullOrEmpty = (val) => val == null || val.length === 0;
|
const nullOrEmpty = (val) => val == null || val.length === 0;
|
||||||
|
|
||||||
const handleStore = async () => {
|
const handleStore = async () => {
|
||||||
@@ -133,6 +148,7 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
try {
|
try {
|
||||||
await xhrPost('/api/admin/generalSettings', {
|
await xhrPost('/api/admin/generalSettings', {
|
||||||
interval,
|
interval,
|
||||||
|
proxyUrl: proxyUrl?.trim() ?? '',
|
||||||
port,
|
port,
|
||||||
workingHours: {
|
workingHours: {
|
||||||
from: workingHourFrom,
|
from: workingHourFrom,
|
||||||
@@ -215,6 +231,10 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
try {
|
try {
|
||||||
const responseJson = await actions.userSettings.setHomeAddress(address);
|
const responseJson = await actions.userSettings.setHomeAddress(address);
|
||||||
setCoords(responseJson.coords);
|
setCoords(responseJson.coords);
|
||||||
|
await actions.userSettings.setListingDeletionPreference({
|
||||||
|
skipPrompt: listingDeleteSkipPrompt,
|
||||||
|
hardDelete: listingDeleteHard,
|
||||||
|
});
|
||||||
await actions.userSettings.getUserSettings();
|
await actions.userSettings.getUserSettings();
|
||||||
Toast.success('Settings saved. Distance calculations are running in the background.');
|
Toast.success('Settings saved. Distance calculations are running in the background.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -376,6 +396,18 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
|
|
||||||
|
<SegmentPart
|
||||||
|
name="Proxy URL"
|
||||||
|
helpText="Optional. Routes the scraping browser through a proxy. Server/datacenter IPs are frequently blocked by providers (e.g. immowelt) regardless of browser fingerprint, a German residential proxy makes requests look like a normal household and is the most effective fix. Format: http://user:pass@host:port or socks5://user:pass@host:port. Leave empty to disable."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="http://user:pass@host:port"
|
||||||
|
value={proxyUrl}
|
||||||
|
onChange={(value) => setProxyUrl(value)}
|
||||||
|
/>
|
||||||
|
</SegmentPart>
|
||||||
|
|
||||||
<div className="generalSettings__save-row">
|
<div className="generalSettings__save-row">
|
||||||
<Button type="primary" theme="solid" onClick={handleStore} icon={<IconSave />}>
|
<Button type="primary" theme="solid" onClick={handleStore} icon={<IconSave />}>
|
||||||
Save
|
Save
|
||||||
@@ -444,6 +476,48 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
/>
|
/>
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
|
|
||||||
|
<SegmentPart
|
||||||
|
name="Listing deletion"
|
||||||
|
helpText="Choose the default deletion mode. Soft delete hides them without re-scraping; hard delete removes them from the database."
|
||||||
|
>
|
||||||
|
<RadioGroup
|
||||||
|
value={listingDeleteHard ? 'hard' : 'soft'}
|
||||||
|
onChange={(e) => setListingDeleteHard(e.target.value === 'hard')}
|
||||||
|
>
|
||||||
|
<Radio value="soft">
|
||||||
|
<div>
|
||||||
|
<Text strong>Mark as deleted (Soft Delete)</Text>
|
||||||
|
<br />
|
||||||
|
<Text type="secondary">
|
||||||
|
Listings are kept in the database but marked as hidden. They will <b>not</b> re-appear during
|
||||||
|
the next scraping session.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Radio>
|
||||||
|
<Radio value="hard">
|
||||||
|
<div>
|
||||||
|
<Text strong>Remove from database (Hard Delete)</Text>
|
||||||
|
<br />
|
||||||
|
<Text type="secondary">
|
||||||
|
Listings are completely removed from the database.
|
||||||
|
<br />
|
||||||
|
<Text type="warning">
|
||||||
|
Consequence: They might re-appear when scraping the next time because Fredy won't know they
|
||||||
|
were previously found.
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Radio>
|
||||||
|
</RadioGroup>
|
||||||
|
<Checkbox
|
||||||
|
checked={listingDeleteSkipPrompt}
|
||||||
|
onChange={(e) => setListingDeleteSkipPrompt(e.target.checked)}
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
>
|
||||||
|
Skip confirmation dialog
|
||||||
|
</Checkbox>
|
||||||
|
</SegmentPart>
|
||||||
|
|
||||||
<div className="generalSettings__save-row">
|
<div className="generalSettings__save-row">
|
||||||
<Button
|
<Button
|
||||||
icon={<IconSave />}
|
icon={<IconSave />}
|
||||||
|
|||||||
@@ -57,7 +57,10 @@ export default function ListingDetail() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const actions = useActions();
|
const actions = useActions();
|
||||||
const listing = useSelector((state) => state.listingsData.currentListing);
|
const listing = useSelector((state) => state.listingsData.currentListing);
|
||||||
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
|
const userSettings = useSelector((state) => state.userSettings.settings);
|
||||||
|
const homeAddress = userSettings?.home_address;
|
||||||
|
const listingDeletionPref = userSettings?.listing_deletion_preference;
|
||||||
|
const defaultDeleteType = listingDeletionPref?.hardDelete ? 'hard' : 'soft';
|
||||||
const mapContainer = useRef(null);
|
const mapContainer = useRef(null);
|
||||||
const map = useRef(null);
|
const map = useRef(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -242,8 +245,11 @@ export default function ListingDetail() {
|
|||||||
};
|
};
|
||||||
}, [listing, loading, homeAddress]);
|
}, [listing, loading, homeAddress]);
|
||||||
|
|
||||||
const confirmDeletion = async (hardDelete) => {
|
const confirmDeletion = async (hardDelete, remember) => {
|
||||||
try {
|
try {
|
||||||
|
if (remember) {
|
||||||
|
await actions.userSettings.setListingDeletionPreference({ skipPrompt: true, hardDelete });
|
||||||
|
}
|
||||||
await xhrDelete('/api/listings/', { ids: [listing.id], hardDelete });
|
await xhrDelete('/api/listings/', { ids: [listing.id], hardDelete });
|
||||||
Toast.success('Listing successfully removed');
|
Toast.success('Listing successfully removed');
|
||||||
navigate('/listings');
|
navigate('/listings');
|
||||||
@@ -347,7 +353,13 @@ export default function ListingDetail() {
|
|||||||
</a>
|
</a>
|
||||||
<Button
|
<Button
|
||||||
icon={<IconDelete />}
|
icon={<IconDelete />}
|
||||||
onClick={() => setDeleteModalVisible(true)}
|
onClick={() => {
|
||||||
|
if (listingDeletionPref?.skipPrompt) {
|
||||||
|
confirmDeletion(listingDeletionPref.hardDelete);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDeleteModalVisible(true);
|
||||||
|
}}
|
||||||
theme="light"
|
theme="light"
|
||||||
type="danger"
|
type="danger"
|
||||||
>
|
>
|
||||||
@@ -423,6 +435,7 @@ export default function ListingDetail() {
|
|||||||
|
|
||||||
<ListingDeletionModal
|
<ListingDeletionModal
|
||||||
visible={deleteModalVisible}
|
visible={deleteModalVisible}
|
||||||
|
defaultDeleteType={defaultDeleteType}
|
||||||
onConfirm={confirmDeletion}
|
onConfirm={confirmDeletion}
|
||||||
onCancel={() => setDeleteModalVisible(false)}
|
onCancel={() => setDeleteModalVisible(false)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ export default function MapView() {
|
|||||||
const sp = useSearchParams();
|
const sp = useSearchParams();
|
||||||
const [searchParams, setSearchParams] = sp;
|
const [searchParams, setSearchParams] = sp;
|
||||||
const listings = useSelector((state) => state.listingsData.mapListings);
|
const listings = useSelector((state) => state.listingsData.mapListings);
|
||||||
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
|
const userSettings = useSelector((state) => state.userSettings.settings);
|
||||||
|
const homeAddress = userSettings?.home_address;
|
||||||
|
const listingDeletionPref = userSettings?.listing_deletion_preference;
|
||||||
|
const defaultDeleteType = listingDeletionPref?.hardDelete ? 'hard' : 'soft';
|
||||||
|
|
||||||
const jobs = useSelector((state) => state.jobsData.jobs);
|
const jobs = useSelector((state) => state.jobsData.jobs);
|
||||||
const [jobId, setJobId] = useSearchParamState(sp, 'job', null, parseString);
|
const [jobId, setJobId] = useSearchParamState(sp, 'job', null, parseString);
|
||||||
@@ -52,10 +55,14 @@ export default function MapView() {
|
|||||||
|
|
||||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||||
const [listingToDelete, setListingToDelete] = useState(null);
|
const [listingToDelete, setListingToDelete] = useState(null);
|
||||||
|
const deleteListingRef = useRef(null);
|
||||||
|
|
||||||
const confirmListingDeletion = async (hardDelete) => {
|
const confirmListingDeletion = async (hardDelete, remember, id = listingToDelete) => {
|
||||||
try {
|
try {
|
||||||
await xhrDelete('/api/listings/', { ids: [listingToDelete], hardDelete });
|
if (remember) {
|
||||||
|
await actions.userSettings.setListingDeletionPreference({ skipPrompt: true, hardDelete });
|
||||||
|
}
|
||||||
|
await xhrDelete('/api/listings/', { ids: [id], hardDelete });
|
||||||
Toast.success('Listing successfully removed');
|
Toast.success('Listing successfully removed');
|
||||||
fetchListings();
|
fetchListings();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -66,6 +73,15 @@ export default function MapView() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
deleteListingRef.current = (id) => {
|
||||||
|
if (listingDeletionPref?.skipPrompt) {
|
||||||
|
confirmListingDeletion(listingDeletionPref.hardDelete, false, id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setListingToDelete(id);
|
||||||
|
setDeleteModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only reset to full range when no URL override is set
|
// Only reset to full range when no URL override is set
|
||||||
if (urlPriceMax === null) {
|
if (urlPriceMax === null) {
|
||||||
@@ -88,10 +104,7 @@ export default function MapView() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.deleteListing = (id) => {
|
window.deleteListing = (id) => deleteListingRef.current(id);
|
||||||
setListingToDelete(id);
|
|
||||||
setDeleteModalVisible(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.viewDetails = (id) => {
|
window.viewDetails = (id) => {
|
||||||
navigate(`/listings/listing/${id}`);
|
navigate(`/listings/listing/${id}`);
|
||||||
@@ -472,6 +485,7 @@ export default function MapView() {
|
|||||||
|
|
||||||
<ListingDeletionModal
|
<ListingDeletionModal
|
||||||
visible={deleteModalVisible}
|
visible={deleteModalVisible}
|
||||||
|
defaultDeleteType={defaultDeleteType}
|
||||||
onConfirm={confirmListingDeletion}
|
onConfirm={confirmListingDeletion}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setDeleteModalVisible(false);
|
setDeleteModalVisible(false);
|
||||||
|
|||||||
Reference in New Issue
Block a user