mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b531a7b77a | ||
|
|
3523057221 | ||
|
|
77311cf39d | ||
|
|
556c0aff35 | ||
|
|
c40d275e52 | ||
|
|
cbf2766783 | ||
|
|
1b39e345b6 | ||
|
|
6ccbdd8afc | ||
|
|
2a30c89eb2 | ||
|
|
4878dc98e3 | ||
|
|
dc2704997d | ||
|
|
e107b0fb00 | ||
|
|
6c08675fee | ||
|
|
34c4de7267 | ||
|
|
b64a118a18 | ||
|
|
03cb4d18cb | ||
|
|
be5c4af3cf | ||
|
|
a460b813c1 | ||
|
|
4596442f64 | ||
|
|
0bcfa1d4ad | ||
|
|
0cad05124a | ||
|
|
eb53b68d45 | ||
|
|
ba0732e1f6 |
@@ -154,6 +154,13 @@ to Slack + Telegram."\
|
||||
Jobs run automatically at the interval you configure (see
|
||||
`/conf/config.json`).
|
||||
|
||||
### MCP Server 🤖
|
||||
|
||||
Starting with **V20**, Fredy ships with a built-in **MCP Server **. This allows you to connect Fredy to LLMs (like Claude, ChatGPT, or local models via LM Studio) and query your real estate data using natural language.
|
||||
The local LLM can even enrich existing listings by checking the listing online.
|
||||
|
||||
For more information on how to set it up and use it, please refer to the [MCP Readme](lib/mcp/README.md).
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
## Immoscout
|
||||
|
||||
@@ -25,12 +25,15 @@ export default [
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
...globals.mocha,
|
||||
...globals.jest,
|
||||
Promise: 'readonly',
|
||||
fetch: 'readonly',
|
||||
describe: 'readonly',
|
||||
after: 'readonly',
|
||||
it: 'readonly',
|
||||
beforeEach: 'readonly',
|
||||
afterEach: 'readonly',
|
||||
vi: 'readonly',
|
||||
},
|
||||
},
|
||||
plugins: { react },
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
*/
|
||||
|
||||
import { NoNewListingsWarning } from './errors.js';
|
||||
import { storeListings, getKnownListingHashesForJobAndProvider } from './services/storage/listingsStorage.js';
|
||||
import {
|
||||
storeListings,
|
||||
getKnownListingHashesForJobAndProvider,
|
||||
deleteListingsById,
|
||||
} from './services/storage/listingsStorage.js';
|
||||
import { getJob } from './services/storage/jobStorage.js';
|
||||
import * as notify from './notification/notify.js';
|
||||
import Extractor from './services/extractor/extractor.js';
|
||||
@@ -14,6 +18,7 @@ import { geocodeAddress } from './services/geocoding/geoCodingService.js';
|
||||
import { distanceMeters } from './services/listings/distanceCalculator.js';
|
||||
import { getUserSettings } from './services/storage/settingsStorage.js';
|
||||
import { updateListingDistance } from './services/storage/listingsStorage.js';
|
||||
import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Listing
|
||||
@@ -58,16 +63,17 @@ class FredyPipelineExecutioner {
|
||||
* @param {(raw:any)=>Listing} providerConfig.normalize Function to convert raw scraped data into a Listing shape.
|
||||
* @param {(listing:Listing)=>boolean} providerConfig.filter Function to filter out unwanted listings.
|
||||
* @param {(url:string, waitForSelector?:string)=>Promise<void>|Promise<Listing[]>} [providerConfig.getListings] Optional override to fetch listings.
|
||||
*
|
||||
* @param {Object} notificationConfig Notification configuration passed to notification adapters.
|
||||
* @param {Object} spatialFilter Optional spatial filter configuration.
|
||||
* @param {string} providerId The ID of the provider currently in use.
|
||||
* @param {string} jobKey Key of the job that is currently running (from within the config).
|
||||
* @param {SimilarityCache} similarityCache Cache instance for checking similar entries.
|
||||
* @param browser
|
||||
*/
|
||||
constructor(providerConfig, notificationConfig, providerId, jobKey, similarityCache, browser) {
|
||||
constructor(providerConfig, notificationConfig, spatialFilter, providerId, jobKey, similarityCache, browser) {
|
||||
this._providerConfig = providerConfig;
|
||||
this._notificationConfig = notificationConfig;
|
||||
this._spatialFilter = spatialFilter;
|
||||
this._providerId = providerId;
|
||||
this._jobKey = jobKey;
|
||||
this._similarityCache = similarityCache;
|
||||
@@ -90,6 +96,7 @@ class FredyPipelineExecutioner {
|
||||
.then(this._save.bind(this))
|
||||
.then(this._calculateDistance.bind(this))
|
||||
.then(this._filterBySimilarListings.bind(this))
|
||||
.then(this._filterByArea.bind(this))
|
||||
.then(this._notify.bind(this))
|
||||
.catch(this._handleError.bind(this));
|
||||
}
|
||||
@@ -113,6 +120,47 @@ class FredyPipelineExecutioner {
|
||||
return newListings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter listings by area using the provider's area filter if available.
|
||||
* Only filters if areaFilter is set on the provider AND the listing has coordinates.
|
||||
*
|
||||
* @param {Listing[]} newListings New listings to filter by area.
|
||||
* @returns {Promise<Listing[]>} Resolves with listings that are within the area (or not filtered if no area is set).
|
||||
*/
|
||||
_filterByArea(newListings) {
|
||||
const polygonFeatures = this._spatialFilter?.features?.filter((f) => f.geometry?.type === 'Polygon');
|
||||
|
||||
// If no area filter is set, return all listings
|
||||
if (!polygonFeatures?.length) {
|
||||
return newListings;
|
||||
}
|
||||
|
||||
const filteredIds = [];
|
||||
// Filter listings by area - keep only those within the polygon
|
||||
const keptListings = newListings.filter((listing) => {
|
||||
// If listing doesn't have coordinates, keep it (don't filter out)
|
||||
if (listing.latitude == null || listing.longitude == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the point is inside the polygons
|
||||
const point = [listing.longitude, listing.latitude]; // GeoJSON format: [lon, lat]
|
||||
const isInPolygon = polygonFeatures.some((feature) => booleanPointInPolygon(point, feature));
|
||||
|
||||
if (!isInPolygon) {
|
||||
filteredIds.push(listing.id);
|
||||
}
|
||||
|
||||
return isInPolygon;
|
||||
});
|
||||
|
||||
if (filteredIds.length > 0) {
|
||||
deleteListingsById(filteredIds);
|
||||
}
|
||||
|
||||
return keptListings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch listings from the provider, using the default Extractor flow unless
|
||||
* a provider-specific getListings override is supplied.
|
||||
@@ -252,7 +300,8 @@ class FredyPipelineExecutioner {
|
||||
* @returns {Listing[]} Listings considered unique enough to keep.
|
||||
*/
|
||||
_filterBySimilarListings(listings) {
|
||||
return listings.filter((listing) => {
|
||||
const filteredIds = [];
|
||||
const keptListings = listings.filter((listing) => {
|
||||
const similar = this._similarityCache.checkAndAddEntry({
|
||||
title: listing.title,
|
||||
address: listing.address,
|
||||
@@ -262,9 +311,16 @@ class FredyPipelineExecutioner {
|
||||
logger.debug(
|
||||
`Filtering similar entry for title '${listing.title}' and address '${listing.address}' (Provider: '${this._providerId}')`,
|
||||
);
|
||||
filteredIds.push(listing.id);
|
||||
}
|
||||
return !similar;
|
||||
});
|
||||
|
||||
if (filteredIds.length > 0) {
|
||||
deleteListingsById(filteredIds);
|
||||
}
|
||||
|
||||
return keptListings;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,6 +24,7 @@ import { getSettings } from '../services/storage/settingsStorage.js';
|
||||
import { dashboardRouter } from './routes/dashboardRouter.js';
|
||||
import { backupRouter } from './routes/backupRouter.js';
|
||||
import { trackingRouter } from './routes/trackingRoute.js';
|
||||
import { registerMcpRoutes } from '../mcp/mcpHttpRoute.js';
|
||||
const service = restana();
|
||||
const staticService = files(path.join(getDirName(), '../ui/public'));
|
||||
const PORT = (await getSettings()).port || 9998;
|
||||
@@ -56,6 +57,9 @@ service.use('/api/tracking', trackingRouter);
|
||||
//this route is unsecured intentionally as it is being queried from the login page
|
||||
service.use('/api/demo', demoRouter);
|
||||
|
||||
// MCP Streamable HTTP endpoint (secured via Bearer token, not cookie-session)
|
||||
registerMcpRoutes(service);
|
||||
|
||||
service.start(PORT).then(() => {
|
||||
logger.debug(`Started API service on port ${PORT}`);
|
||||
});
|
||||
|
||||
@@ -163,7 +163,16 @@ jobRouter.post('/:jobId/run', async (req, res) => {
|
||||
});
|
||||
|
||||
jobRouter.post('/', async (req, res) => {
|
||||
const { provider, notificationAdapter, name, blacklist = [], jobId, enabled, shareWithUsers = [] } = req.body;
|
||||
const {
|
||||
provider,
|
||||
notificationAdapter,
|
||||
name,
|
||||
blacklist = [],
|
||||
jobId,
|
||||
enabled,
|
||||
shareWithUsers = [],
|
||||
spatialFilter = null,
|
||||
} = req.body;
|
||||
const settings = await getSettings();
|
||||
try {
|
||||
let jobFromDb = jobStorage.getJob(jobId);
|
||||
@@ -187,6 +196,7 @@ jobRouter.post('/', async (req, res) => {
|
||||
provider,
|
||||
notificationAdapter,
|
||||
shareWithUsers,
|
||||
spatialFilter,
|
||||
});
|
||||
} catch (error) {
|
||||
res.send(new Error(error));
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
import fs from 'fs';
|
||||
import restana from 'restana';
|
||||
import logger from '../../services/logger.js';
|
||||
|
||||
const service = restana();
|
||||
const notificationAdapterRouter = service.newRouter();
|
||||
const notificationAdapterList = fs.readdirSync('./lib//notification/adapter').filter((file) => file.endsWith('.js'));
|
||||
@@ -34,11 +36,14 @@ notificationAdapterRouter.post('/try', async (req, res) => {
|
||||
serviceName: 'TestCall',
|
||||
newListings: [
|
||||
{
|
||||
price: '42 €',
|
||||
title: 'This is a test listing',
|
||||
address: 'some address',
|
||||
size: '666 2m',
|
||||
link: 'https://www.orange-coding.net',
|
||||
address: 'Heidestrasse 17, 51147 Köln',
|
||||
description: exampleDescription,
|
||||
id: '1',
|
||||
imageUrl: 'https://placehold.co/600x400/png',
|
||||
price: '1.000 €',
|
||||
size: '76 m²',
|
||||
title: 'Stilvolle gepflegte 3-Raum-Wohnung mit gehobener Innenausstattung',
|
||||
url: 'https://www.orange-coding.net',
|
||||
},
|
||||
],
|
||||
notificationConfig,
|
||||
@@ -46,6 +51,7 @@ notificationAdapterRouter.post('/try', async (req, res) => {
|
||||
});
|
||||
res.send();
|
||||
} catch (Exception) {
|
||||
logger.error('Error during notification adapter test:', Exception);
|
||||
res.send(new Error(Exception));
|
||||
}
|
||||
});
|
||||
@@ -54,3 +60,51 @@ notificationAdapterRouter.get('/', async (req, res) => {
|
||||
res.send();
|
||||
});
|
||||
export { notificationAdapterRouter };
|
||||
|
||||
const exampleDescription = `
|
||||
Wohnungstyp: Etagenwohnung
|
||||
Nutzfläche: 76 m²
|
||||
Etage: 2 von 3
|
||||
Schlafzimmer: 1
|
||||
Badezimmer: 1
|
||||
Bezugsfrei ab: 1.4.2026
|
||||
Haustiere: Nein
|
||||
Garage/Stellplatz: Tiefgarage
|
||||
Anzahl Garage/Stellplatz: 1
|
||||
Kaltmiete (zzgl. Nebenkosten): 1.000 €
|
||||
Preis/m²: 13,16 €/m²
|
||||
Nebenkosten: 230 €
|
||||
Heizkosten in Nebenkosten enthalten: Ja
|
||||
Gesamtmiete: 1.230 €
|
||||
Kaution: 3.000,00
|
||||
Preis pro Parkfläche: 60 €
|
||||
Baujahr: 2000
|
||||
Objektzustand: Modernisiert
|
||||
Qualität der Ausstattung: Gehoben
|
||||
Heizungsart: Fernwärme
|
||||
Energieausweistyp: Verbrauchsausweis
|
||||
Energieausweis: liegt vor
|
||||
Endenergieverbrauch: 72 kWh/(m²∙a)
|
||||
Baujahr laut Energieausweis: 2000
|
||||
|
||||
Diese moderne 3-Zimmer-Wohnung liegt direkt neben einem Park und nur wenige Minuten von der S-Bahn-Haltestelle entfernt. Das Stadtzentrum sowie Freizeiteinrichtungen sind 1,5 km entfernt.
|
||||
|
||||
Die Wohnung ist ideal für Paare oder kleine Familien geeignet.
|
||||
|
||||
Ausstattung:
|
||||
- neuer hochwertiger Bodenbelag (Holzoptik) in allen Räumen außer Bad/Küche
|
||||
- sonniger Balkon (Süd)
|
||||
- Tiefgaragenstellplatz
|
||||
- Kellerabteil
|
||||
- gepflegtes Mehrfamilienhaus
|
||||
|
||||
Die Küche ist vom Mieter nach eigenen Wünschen einzurichten.
|
||||
|
||||
Vermietung direkt vom Eigentümer - provisionsfrei!
|
||||
|
||||
Lage:
|
||||
• Park: 1 Minute zu Fuß
|
||||
• S-Bahn Station: 2 Minuten zu Fuß
|
||||
• Supermärkte, Restaurants, täglicher Bedarf in der Nähe
|
||||
• Gute Anbindung Richtung Großstadt und Flughafen
|
||||
`;
|
||||
|
||||
@@ -97,4 +97,25 @@ userSettingsRouter.post('/news-hash', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
userSettingsRouter.post('/immoscout-details', async (req, res) => {
|
||||
const userId = req.session.currentUser;
|
||||
const { immoscout_details } = req.body;
|
||||
|
||||
const globalSettings = await getSettings();
|
||||
if (globalSettings.demoMode) {
|
||||
res.statusCode = 403;
|
||||
res.send({ error: 'In demo mode, it is not allowed to change settings.' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
upsertSettings({ immoscout_details: !!immoscout_details }, userId);
|
||||
res.send({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Error updating immoscout details setting', error);
|
||||
res.statusCode = 500;
|
||||
res.send({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export { userSettingsRouter };
|
||||
|
||||
323
lib/mcp/README.md
Normal file
323
lib/mcp/README.md
Normal file
@@ -0,0 +1,323 @@
|
||||
# Fredy MCP Server
|
||||
|
||||
The Fredy MCP Server exposes your real estate jobs and listings data to LLM clients. It supports two transports:
|
||||
|
||||
- **Stdio**: for local LLM clients (Claude Desktop, LM Studio, llm-cli, mcp-cli, etc.)
|
||||
- **Streamable HTTP**: for remote LLM clients (ChatGPT, cloud-hosted agents, etc.)
|
||||
|
||||
## Authentication
|
||||
|
||||
All MCP access is **token-based** based. Every Fredy user is automatically assigned a **permanent, non-expiring MCP token** when their account is created. This token is a secret and should be treated like a password.
|
||||
|
||||
### Where to find your token
|
||||
|
||||
MCP tokens are displayed in the **User Management** list (Admin → Users). Each user's token is shown in the **"MCP Token"** column.
|
||||
|
||||
> **Important:** MCP tokens never expire. They are permanent secrets tied to each user account. If a token is compromised, you must change the token! If you chose to use a token from an admin account, the LLM can query information from ALL jobs/listings.
|
||||
|
||||
## Available Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|--------------------------------------------------------------------------------|
|
||||
| `list_jobs` | List real estate search jobs with pagination and text filtering |
|
||||
| `get_job` | Get detailed information about a specific job |
|
||||
| `list_listings` | Search and list real estate listings with pagination, text search, and filters |
|
||||
| `get_listing` | Get full details of a single listing |
|
||||
| `get_current_date_time` | Gets the current date/time for the llm to be used |
|
||||
|
||||
### Tool Details
|
||||
|
||||
#### list_jobs
|
||||
- `page` (number, optional) – Page number (default: 1)
|
||||
- `pageSize` (number, optional) – Results per page (default: 50, max: 1000). Use pagination to fetch more.
|
||||
- `filter` (string, optional) – Free-text filter on job name
|
||||
|
||||
Response: markdown table with columns ID, Name, Enabled, Active Listings. Includes summary and pagination info.
|
||||
|
||||
#### get_job
|
||||
- `jobId` (string, required) – The job ID to retrieve
|
||||
|
||||
#### list_listings
|
||||
- `page` (number, optional) – Page number (default: 1)
|
||||
- `pageSize` (number, optional) – Results per page (default: 50, max: 1000). Use pagination to fetch more.
|
||||
- `filter` (string, optional) – Free-text search across title, address, provider, link
|
||||
- `jobId` (string, optional) – Filter listings by job ID
|
||||
- `activeOnly` (boolean, optional) – When true, only show active listings
|
||||
- `provider` (string, optional) – Filter by provider name
|
||||
- `createdAfter` (number, optional) – Only include listings created at or after this unix timestamp in milliseconds (e.g. `1772008362564`). Useful for queries like "give me all listings from today".
|
||||
- `createdBefore` (number, optional) – Only include listings created at or before this unix timestamp in milliseconds (e.g. `1772008362564`).
|
||||
- `minPrice` (number, optional) – Only include listings with price >= this value (e.g. `500`). Numeric, no currency symbol.
|
||||
- `maxPrice` (number, optional) – Only include listings with price <= this value (e.g. `1500`). Numeric, no currency symbol.
|
||||
- `sortField` (string, optional) – Sort by: created_at, price, size, provider, title, is_active
|
||||
- `sortDir` (string, optional) – Sort direction: asc or desc
|
||||
|
||||
Response: markdown table with columns ID, Title, Address, Price, Size, Provider, Active, Created, Job. Includes summary and pagination info. Use `get_listing` for full details.
|
||||
|
||||
> **Note:** All timestamps are **unix timestamps in milliseconds** (e.g. `1772008362564`), not seconds.
|
||||
|
||||
#### get_listing
|
||||
- `listingId` (string, required) – The listing ID to retrieve
|
||||
|
||||
## Usage with Local LLM (stdio transport)
|
||||
|
||||
The stdio transport communicates over stdin/stdout and is ideal for local LLM tools.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
MCP_TOKEN=fredy_<your-token> node mcp/stdio.js
|
||||
# or
|
||||
MCP_TOKEN=fredy_<your-token> yarn mcp:stdio
|
||||
```
|
||||
|
||||
### Testing with MCP Inspector
|
||||
|
||||
The [MCP Inspector](https://github.com/modelcontextprotocol/inspector) lets you interactively test your MCP server in a browser UI.
|
||||
|
||||
```bash
|
||||
npx @modelcontextprotocol/inspector -e MCP_TOKEN=fredy_<your-token> -- node mcp/stdio.js
|
||||
```
|
||||
|
||||
Once the inspector is running, open the URL shown in your terminal (usually `http://localhost:6274`). You can then:
|
||||
1. Click **Connect** to establish the stdio connection
|
||||
2. Go to the **Tools** tab to see all available tools
|
||||
3. Select a tool, fill in parameters, and click **Run** to test it
|
||||
|
||||
### LM Studio Configuration
|
||||
|
||||
[LM Studio](https://lmstudio.ai/) supports MCP servers natively, allowing your local LLM to access Fredy's jobs and listings data.
|
||||
|
||||
#### Setup
|
||||
|
||||
1. Open **LM Studio** and load a model that supports tool use (e.g., Qwen 2.5, Llama 3.1, Mistral, etc.)
|
||||
2. In the right side under **Integrations** click on "# install" and "edit mcp.json"
|
||||
3. Edit the LM Studio MCP config file directly (`~/.lmstudio/config/mcp.json` or via the UI export):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"fredy": {
|
||||
"command": "node",
|
||||
"args": ["/absolute/path/to/fredy/mcp/stdio.js"],
|
||||
"env": {
|
||||
"MCP_TOKEN": "fredy_<your-token>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. Toggle the server **on**: LM Studio will spawn the stdio process and connect
|
||||
5. You should see the Fredy tools appear as available tools
|
||||
|
||||
#### Suggestion on LLM
|
||||
After testing numerous LLM's, I got the best results with Qwen 3.5 or Qwen 2.5.. E.g. `Qwen2.5-14B-Instruct-1M-8bit`.
|
||||
|
||||
#### Usage
|
||||
|
||||
Once connected, simply ask your LLM about your real estate data in natural language:
|
||||
|
||||
- *"Show me all my active search jobs"*
|
||||
- *"List the latest listings from my Berlin apartment search"*
|
||||
- *"Get details for listing XYZ"*
|
||||
- *"What are the cheapest listings across all my jobs?"*
|
||||
|
||||
The LLM will automatically call the appropriate Fredy MCP tools and present the results.
|
||||
|
||||
> **Tip:** Make sure Fredy is running and the database is accessible before starting the MCP server in LM Studio. The stdio transport initializes its own database connection, so Fredy's main process does not need to be running, but the database file must exist and be up-to-date (migrations applied).
|
||||
|
||||
### Claude Desktop Configuration
|
||||
|
||||
[Claude Desktop](https://claude.ai/download) supports MCP servers natively via its developer settings.
|
||||
|
||||
#### Setup
|
||||
|
||||
1. Open **Claude Desktop**
|
||||
2. Go to **Settings → Developer → Edit Config** — this opens the `claude_desktop_config.json` file
|
||||
3. Add the `fredy` server to the `mcpServers` object:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"fredy": {
|
||||
"command": "/opt/homebrew/opt/node@22/bin/node",
|
||||
"args": ["/absolute/path/to/fredy/lib/mcp/stdio.js"],
|
||||
"env": {
|
||||
"MCP_TOKEN": "fredy_<your-token>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Replace `/absolute/path/to/fredy` with the actual path on your machine (e.g. `/Users/you/dev/fredy`).
|
||||
|
||||
> **Important:** Claude Desktop launches with a restricted `PATH` and often cannot find `node` by name. Always use the **full absolute path** to the node binary. Find yours by running `which node` in a terminal. Common locations:
|
||||
> - Homebrew (default): `/opt/homebrew/bin/node`
|
||||
> - Homebrew (versioned, e.g. node@22): `/opt/homebrew/opt/node@22/bin/node`
|
||||
> - nvm: `/Users/<you>/.nvm/versions/node/<version>/bin/node`
|
||||
|
||||
4. Save the file and **restart Claude Desktop**
|
||||
5. You should see a hammer icon (🔨) in the chat input — click it to confirm the Fredy tools are listed
|
||||
|
||||
#### Usage
|
||||
|
||||
Once connected, simply ask Claude about your real estate data:
|
||||
|
||||
- *"Show me all my active search jobs"*
|
||||
- *"List the latest listings from my Berlin apartment search"*
|
||||
- *"What are the cheapest apartments added this week?"*
|
||||
|
||||
Claude will automatically call the appropriate Fredy MCP tools.
|
||||
|
||||
> **Note:** Fredy's main web process does not need to be running — the stdio transport opens its own database connection directly. But the SQLite database file must exist and migrations must have been applied.
|
||||
|
||||
---
|
||||
|
||||
## Usage with Remote LLM (Streamable HTTP transport)
|
||||
|
||||
The HTTP transport is automatically available when Fredy is running. It uses the MCP Streamable HTTP protocol at:
|
||||
|
||||
```
|
||||
POST /api/mcp – JSON-RPC messages (initialize, tool calls)
|
||||
GET /api/mcp – SSE stream for server-initiated notifications
|
||||
DELETE /api/mcp – Terminate session
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
All requests must include the token as a Bearer token:
|
||||
|
||||
```
|
||||
Authorization: Bearer fredy_<your-token>
|
||||
```
|
||||
|
||||
### Example: Initialize a session
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:9998/api/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer fredy_<your-token>" \
|
||||
-d '{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-03-26",
|
||||
"capabilities": {},
|
||||
"clientInfo": { "name": "test-client", "version": "1.0.0" }
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Example: Call a tool
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:9998/api/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer fredy_<your-token>" \
|
||||
-H "Mcp-Session-Id: <session-id-from-init-response>" \
|
||||
-d '{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "list_jobs",
|
||||
"arguments": { "page": 1, "pageSize": 10 }
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
- Every user is automatically assigned a permanent MCP token at account creation – **tokens never expire**
|
||||
- Tokens are cryptographically random (256-bit) and prefixed with `fredy_`
|
||||
- Each token is scoped to a single user – the LLM can only access that user's data
|
||||
- Non-admin users only see their own jobs and jobs shared with them
|
||||
- Tokens are stored in the `mcp_token` column of the `users` table
|
||||
- Tokens are deleted automatically when the owning user is removed
|
||||
- The `/api/mcp` endpoint uses Bearer token auth (independent of cookie-session)
|
||||
- Treat MCP tokens like passwords – do not share them publicly
|
||||
|
||||
## Response Format
|
||||
|
||||
All tool responses use **markdown** instead of JSON for maximum LLM readability and token efficiency:
|
||||
|
||||
- **List responses** (list_jobs, list_listings) use markdown tables with a summary line and pagination footer
|
||||
- **Detail responses** (get_job, get_listing) use markdown key-value lists
|
||||
- **Error responses** include the tool name and error message
|
||||
|
||||
Example list response:
|
||||
|
||||
```
|
||||
**Tool:** list_listings | **Status:** OK
|
||||
|
||||
Found **85** listing(s). Showing page 1 of 2 (50 on this page). More pages available — use page=2 to continue.
|
||||
|
||||
| ID | Title | Address | Price | Size | Provider | Active | Created | Job |
|
||||
|----|-------|---------|-------|------|----------|--------|---------|-----|
|
||||
| abc123 | Nice flat | Berlin | 1200 | 70 | immoscout | yes | 2026-02-25 10:30:00 | My Search |
|
||||
| ... | ... | ... | ... | ... | ... | ... | ... | ... |
|
||||
|
||||
Use **get_listing** with an ID for full details (description, link, image).
|
||||
|
||||
**Page:** 1/2 | **Has more:** yes
|
||||
```
|
||||
|
||||
Example detail response:
|
||||
|
||||
```
|
||||
**Tool:** get_listing | **Status:** OK
|
||||
|
||||
### Listing: Nice flat
|
||||
|
||||
- **ID:** abc123
|
||||
- **Title:** Nice flat
|
||||
- **Address:** Berlin
|
||||
- **Price:** 1200
|
||||
- **Size:** 70
|
||||
- **Provider:** immoscout
|
||||
- **Link:** https://...
|
||||
- **Active:** yes
|
||||
- **Created:** 2026-02-25 10:30:00
|
||||
```
|
||||
|
||||
Markdown is used because it is significantly more token-efficient than JSON (~40-60% fewer tokens for tabular data) and natively understood by all LLMs.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ stdio ┌──────────────┐
|
||||
│ Local LLM │◄──────────────►│ mcp/stdio.js│
|
||||
│ (LM Studio, │ │ (transport) │
|
||||
│ Claude, etc.) │ │ │
|
||||
└─────────────────┘ └──────┬───────┘
|
||||
│
|
||||
┌─────────────────┐ Streamable HTTP ┌────┴────────┐
|
||||
│ Remote LLM │◄───────────────►│ /api/mcp │
|
||||
│ │ (Bearer token) │ (transport) │
|
||||
└─────────────────┘ └──────┬───────┘
|
||||
│
|
||||
┌──────────┴──────────┐
|
||||
│ mcpAuthentication │
|
||||
│ (token validation, │
|
||||
│ access control) │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
┌────────┴────────┐
|
||||
│ mcpAdapter.js │
|
||||
│ (tool routing │
|
||||
│ + data fetch) │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────┴────────┐
|
||||
│ mcpNormalizer.js│
|
||||
│ (markdown │
|
||||
│ formatting) │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌──────┴───────┐
|
||||
│ Fredy DB │
|
||||
│ (SQLite) │
|
||||
└──────────────┘
|
||||
```
|
||||
231
lib/mcp/mcpAdapter.js
Normal file
231
lib/mcp/mcpAdapter.js
Normal file
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import { queryJobs, getJob } from '../services/storage/jobStorage.js';
|
||||
import { queryListings, getListingById } from '../services/storage/listingsStorage.js';
|
||||
import { authenticateToolCall, checkJobAccess } from './mcpAuthentication.js';
|
||||
import {
|
||||
normalizeListJobs,
|
||||
normalizeGetJob,
|
||||
normalizeListListings,
|
||||
normalizeGetListing,
|
||||
normalizeError,
|
||||
} from './mcpNormalizer.js';
|
||||
|
||||
/**
|
||||
* Create a configured MCP server instance with all Fredy tools registered.
|
||||
*
|
||||
* The adapter fetches raw data from storage and delegates response formatting
|
||||
* to the normalizer layer (mcpNormalizer.js) which produces a consistent
|
||||
* { ok, summary, data, meta } envelope for every tool response.
|
||||
*
|
||||
* Each tool call requires a userId (resolved from the MCP token before invocation).
|
||||
* Tools respect user scoping: non-admin users only see their own jobs/listings.
|
||||
*
|
||||
* @returns {McpServer}
|
||||
*/
|
||||
export function createMcpServer() {
|
||||
const server = new McpServer(
|
||||
{
|
||||
name: 'fredy-mcp',
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
instructions:
|
||||
'Fredy MCP Server – query real estate jobs and listings. ' +
|
||||
'All timestamps are unix timestamps in milliseconds (e.g. 1772008362564). ' +
|
||||
'Use list_jobs to browse jobs, get_job for details, ' +
|
||||
'list_listings to search listings (supports time filters like createdAfter/createdBefore), ' +
|
||||
'and get_listing for full details of a single listing. ' +
|
||||
'Responses are formatted as markdown with a summary, data (tables for lists, key-value for details), and pagination info. ' +
|
||||
'Always present results to the user as soon as you have them — do NOT call the tool again unless you need additional pages or different data.',
|
||||
},
|
||||
);
|
||||
|
||||
// ── list_jobs ───────────────────────────────────────────────────────
|
||||
server.tool(
|
||||
'list_jobs',
|
||||
'List real estate search jobs for the authenticated user. ' +
|
||||
'Returns up to 50 jobs per page by default. Use pagination (page parameter) to fetch more. ' +
|
||||
'Check meta.hasMore to know if there are additional pages.',
|
||||
{
|
||||
page: z.number().optional().describe('Page number (default: 1)'),
|
||||
pageSize: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Results per page (default: 50, max: 1000). Start with the default and paginate if needed.'),
|
||||
filter: z.string().optional().describe('Free-text filter on job name'),
|
||||
},
|
||||
async ({ page, pageSize, filter }, extra) => {
|
||||
const { user, error } = authenticateToolCall(extra, 'list_jobs');
|
||||
if (error) return normalizeError(error, 'list_jobs');
|
||||
|
||||
const safePage = page ?? 1;
|
||||
const safePageSize = pageSize ?? 50;
|
||||
|
||||
const result = queryJobs({
|
||||
page: safePage,
|
||||
pageSize: safePageSize,
|
||||
freeTextFilter: filter,
|
||||
userId: user.id,
|
||||
isAdmin: user.isAdmin,
|
||||
});
|
||||
|
||||
return normalizeListJobs(result, { page: safePage, pageSize: safePageSize });
|
||||
},
|
||||
);
|
||||
|
||||
// ── get_job ─────────────────────────────────────────────────────────
|
||||
server.tool(
|
||||
'get_job',
|
||||
'Get detailed information about a specific job by its ID.',
|
||||
{
|
||||
jobId: z.string().describe('The job ID to retrieve'),
|
||||
},
|
||||
async ({ jobId }, extra) => {
|
||||
const { user, error } = authenticateToolCall(extra, 'get_job');
|
||||
if (error) return normalizeError(error, 'get_job');
|
||||
|
||||
const job = getJob(jobId);
|
||||
if (!job) {
|
||||
return normalizeError('Job not found.', 'get_job');
|
||||
}
|
||||
|
||||
if (!checkJobAccess(user, job)) {
|
||||
return normalizeError('Access denied.', 'get_job');
|
||||
}
|
||||
|
||||
return normalizeGetJob(job);
|
||||
},
|
||||
);
|
||||
|
||||
// ── list_listings ───────────────────────────────────────────────────
|
||||
server.tool(
|
||||
'list_listings',
|
||||
'Search and list real estate listings. Returns up to 50 listings per page by default. ' +
|
||||
'Use pagination (page parameter) to fetch more. Check meta.hasMore in the response. ' +
|
||||
'Supports text search, time filtering, and various filters. ' +
|
||||
'All timestamps are unix timestamps in milliseconds (e.g. 1772008362564). ' +
|
||||
'Use createdAfter/createdBefore to filter by time, e.g. "give me all listings from today". ' +
|
||||
'Use get_listing to get full details (description, link, image) for a specific listing.',
|
||||
{
|
||||
page: z.number().optional().describe('Page number (default: 1)'),
|
||||
pageSize: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Results per page (default: 50, max: 1000). Start with the default and paginate if needed.'),
|
||||
filter: z.string().optional().describe('Free-text search across title, address, provider, link'),
|
||||
jobId: z.string().optional().describe('Filter listings by job ID'),
|
||||
activeOnly: z.boolean().optional().describe('When true, only show active listings'),
|
||||
provider: z.string().optional().describe('Filter by provider name'),
|
||||
createdAfter: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
'Only include listings created at or after this unix timestamp in milliseconds (e.g. 1772008362564). Useful for queries like "listings from today".',
|
||||
),
|
||||
createdBefore: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
'Only include listings created at or before this unix timestamp in milliseconds (e.g. 1772008362564).',
|
||||
),
|
||||
minPrice: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
'Only include listings with price >= this value (e.g. 500). Price is a numeric value (no currency symbol).',
|
||||
),
|
||||
maxPrice: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
'Only include listings with price <= this value (e.g. 1500). Price is a numeric value (no currency symbol).',
|
||||
),
|
||||
sortField: z.string().optional().describe('Sort by: created_at, price, size, provider, title, is_active'),
|
||||
sortDir: z.string().optional().describe('Sort direction: asc or desc'),
|
||||
},
|
||||
async (
|
||||
{
|
||||
page,
|
||||
pageSize,
|
||||
filter,
|
||||
jobId,
|
||||
activeOnly,
|
||||
provider,
|
||||
createdAfter,
|
||||
createdBefore,
|
||||
minPrice,
|
||||
maxPrice,
|
||||
sortField,
|
||||
sortDir,
|
||||
},
|
||||
extra,
|
||||
) => {
|
||||
const { user, error } = authenticateToolCall(extra, 'list_listings');
|
||||
if (error) return normalizeError(error, 'list_listings');
|
||||
|
||||
const safePage = page ?? 1;
|
||||
const safePageSize = pageSize ?? 50;
|
||||
|
||||
const result = queryListings({
|
||||
page: safePage,
|
||||
pageSize: safePageSize,
|
||||
freeTextFilter: filter,
|
||||
jobIdFilter: jobId,
|
||||
activityFilter: activeOnly === true ? true : activeOnly === false ? false : undefined,
|
||||
providerFilter: provider,
|
||||
createdAfter: createdAfter ?? null,
|
||||
createdBefore: createdBefore ?? null,
|
||||
minPrice: minPrice ?? null,
|
||||
maxPrice: maxPrice ?? null,
|
||||
sortField: sortField ?? null,
|
||||
sortDir: sortDir ?? 'desc',
|
||||
userId: user.id,
|
||||
isAdmin: user.isAdmin,
|
||||
});
|
||||
|
||||
return normalizeListListings(result, { page: safePage, pageSize: safePageSize });
|
||||
},
|
||||
);
|
||||
|
||||
// ── get_listing ─────────────────────────────────────────────────────
|
||||
server.tool(
|
||||
'get_listing',
|
||||
'Get full details of a single listing by its ID.',
|
||||
{
|
||||
listingId: z.string().describe('The listing ID to retrieve'),
|
||||
},
|
||||
async ({ listingId }, extra) => {
|
||||
const { user, error } = authenticateToolCall(extra, 'get_listing');
|
||||
if (error) return normalizeError(error, 'get_listing');
|
||||
|
||||
const listing = getListingById(listingId, user.id, user.isAdmin);
|
||||
if (!listing) {
|
||||
return normalizeError('Listing not found or access denied.', 'get_listing');
|
||||
}
|
||||
|
||||
return normalizeGetListing(listing);
|
||||
},
|
||||
);
|
||||
|
||||
// ── get_current_date_ime ─────────────────────────────────────────────────────
|
||||
server.tool('get_current_date_time', 'Returns the current date and time.', {}, () => {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Timestring: ${new Date().toLocaleString()}, MS since 1970: ${Date.now()}` }],
|
||||
};
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
66
lib/mcp/mcpAuthentication.js
Normal file
66
lib/mcp/mcpAuthentication.js
Normal file
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
/**
|
||||
* MCP Authentication Layer
|
||||
*
|
||||
* Centralizes all authentication and authorization logic for MCP tool calls
|
||||
* and HTTP requests. Ensures consistent access control across all transports.
|
||||
*/
|
||||
|
||||
import { getUser, validateMcpToken } from '../services/storage/userStorage.js';
|
||||
|
||||
/**
|
||||
* Authenticate an MCP tool call by extracting and validating the user from authInfo.
|
||||
*
|
||||
* @param {{ authInfo?: { userId?: string } }} extra - The extra context passed by the MCP SDK.
|
||||
* @returns {{ user: object|null, error: string|null }}
|
||||
* - On success: { user: <userObject>, error: null }
|
||||
* - On failure: { user: null, error: <errorMessage> }
|
||||
*/
|
||||
export function authenticateToolCall(extra) {
|
||||
const userId = extra?.authInfo?.userId;
|
||||
if (!userId) {
|
||||
return { user: null, error: 'Authentication required. Please provide a valid MCP API token.' };
|
||||
}
|
||||
|
||||
const user = getUser(userId);
|
||||
if (!user) {
|
||||
return { user: null, error: 'Authentication required. Please provide a valid MCP API token.' };
|
||||
}
|
||||
|
||||
return { user, error: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a user has access to a specific job.
|
||||
* Admins have access to all jobs. Non-admins can only access their own jobs
|
||||
* or jobs explicitly shared with them.
|
||||
*
|
||||
* @param {object} user - The authenticated user object.
|
||||
* @param {object} job - The job object from storage.
|
||||
* @returns {boolean} True if the user is allowed to access this job.
|
||||
*/
|
||||
export function checkJobAccess(user, job) {
|
||||
if (user.isAdmin) return true;
|
||||
if (job.userId === user.id) return true;
|
||||
if (Array.isArray(job.shared_with_user) && job.shared_with_user.includes(user.id)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate an HTTP request by extracting and validating the Bearer token
|
||||
* from the Authorization header.
|
||||
*
|
||||
* @param {import('http').IncomingMessage} req
|
||||
* @returns {{ userId: string } | null} The authenticated user info, or null if invalid.
|
||||
*/
|
||||
export function authenticateRequest(req) {
|
||||
const authHeader = req.headers['authorization'] || '';
|
||||
if (!authHeader.startsWith('Bearer ')) return null;
|
||||
const token = authHeader.slice(7).trim();
|
||||
if (!token) return null;
|
||||
return validateMcpToken(token);
|
||||
}
|
||||
131
lib/mcp/mcpHttpRoute.js
Normal file
131
lib/mcp/mcpHttpRoute.js
Normal file
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { createMcpServer } from './mcpAdapter.js';
|
||||
import { authenticateRequest } from './mcpAuthentication.js';
|
||||
import logger from '../services/logger.js';
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Active transports keyed by session id.
|
||||
* Each session gets its own McpServer + StreamableHTTPServerTransport pair.
|
||||
* @type {Map<string, { server: McpServer, transport: StreamableHTTPServerTransport }>}
|
||||
*/
|
||||
const sessions = new Map();
|
||||
|
||||
/**
|
||||
* Get or create a session for the given session id with authentication.
|
||||
* @param {string|undefined} sessionId
|
||||
* @param {{ userId: string }} auth
|
||||
* @returns {{ server: McpServer, transport: StreamableHTTPServerTransport }}
|
||||
*/
|
||||
function getOrCreateSession(sessionId, auth) {
|
||||
if (sessionId && sessions.has(sessionId)) {
|
||||
return sessions.get(sessionId);
|
||||
}
|
||||
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => crypto.randomUUID(),
|
||||
onsessioninitialized: (sid) => {
|
||||
sessions.set(sid, entry);
|
||||
logger.debug(`MCP session created: ${sid}`);
|
||||
},
|
||||
});
|
||||
|
||||
const server = createMcpServer();
|
||||
const entry = { server, transport, userId: auth.userId };
|
||||
|
||||
transport.onclose = () => {
|
||||
const sid = transport.sessionId;
|
||||
if (sid) {
|
||||
sessions.delete(sid);
|
||||
logger.debug(`MCP session closed: ${sid}`);
|
||||
}
|
||||
};
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register MCP Streamable HTTP routes on a restana service.
|
||||
*
|
||||
* Mounts handlers at /api/mcp to handle the MCP Streamable HTTP protocol:
|
||||
* - POST /api/mcp – JSON-RPC messages (initialize, tool calls, etc.)
|
||||
* - GET /api/mcp – SSE stream for server-initiated notifications
|
||||
* - DELETE /api/mcp – session termination
|
||||
*
|
||||
* All endpoints require a valid Bearer token in the Authorization header.
|
||||
*
|
||||
* @param {import('restana').Service} service - The restana service instance.
|
||||
*/
|
||||
export function registerMcpRoutes(service) {
|
||||
// POST – main JSON-RPC endpoint
|
||||
service.post('/api/mcp', async (req, res) => {
|
||||
const auth = authenticateRequest(req);
|
||||
if (!auth) {
|
||||
res.statusCode = 401;
|
||||
return res.send({ error: 'Unauthorized. Provide a valid Bearer token.' });
|
||||
}
|
||||
|
||||
const sessionId = req.headers['mcp-session-id'];
|
||||
const { server, transport } = getOrCreateSession(sessionId, auth);
|
||||
|
||||
// Connect server to transport if not already connected
|
||||
if (!transport.onmessage) {
|
||||
await server.connect(transport);
|
||||
}
|
||||
|
||||
// Inject authInfo so tools can access the authenticated user
|
||||
req.auth = { userId: auth.userId };
|
||||
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
});
|
||||
|
||||
// GET – SSE stream for server-initiated messages
|
||||
service.get('/api/mcp', async (req, res) => {
|
||||
const auth = authenticateRequest(req);
|
||||
if (!auth) {
|
||||
res.statusCode = 401;
|
||||
return res.send({ error: 'Unauthorized. Provide a valid Bearer token.' });
|
||||
}
|
||||
|
||||
const sessionId = req.headers['mcp-session-id'];
|
||||
if (!sessionId || !sessions.has(sessionId)) {
|
||||
res.statusCode = 400;
|
||||
return res.send({ error: 'Invalid or missing session. Send an initialize request first.' });
|
||||
}
|
||||
|
||||
const { transport } = sessions.get(sessionId);
|
||||
await transport.handleRequest(req, res);
|
||||
});
|
||||
|
||||
// DELETE – terminate session
|
||||
service.delete('/api/mcp', async (req, res) => {
|
||||
const auth = authenticateRequest(req);
|
||||
if (!auth) {
|
||||
res.statusCode = 401;
|
||||
return res.send({ error: 'Unauthorized. Provide a valid Bearer token.' });
|
||||
}
|
||||
|
||||
const sessionId = req.headers['mcp-session-id'];
|
||||
if (!sessionId || !sessions.has(sessionId)) {
|
||||
res.statusCode = 404;
|
||||
return res.send({ error: 'Session not found.' });
|
||||
}
|
||||
|
||||
const { transport } = sessions.get(sessionId);
|
||||
await transport.close();
|
||||
sessions.delete(sessionId);
|
||||
res.statusCode = 200;
|
||||
res.send({ ok: true });
|
||||
});
|
||||
|
||||
logger.debug('MCP Streamable HTTP endpoint registered at /api/mcp');
|
||||
}
|
||||
180
lib/mcp/mcpNormalizer.js
Normal file
180
lib/mcp/mcpNormalizer.js
Normal file
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
/**
|
||||
* MCP Response Normalizer
|
||||
*
|
||||
* Transforms raw adapter data into LLM-friendly markdown responses.
|
||||
* Markdown is significantly better than JSON for LLM consumption because:
|
||||
* - LLMs are trained extensively on markdown text
|
||||
* - Markdown tables are ~40-60% more token-efficient than JSON arrays
|
||||
* - Less syntactic noise (no quotes, brackets, commas around every value)
|
||||
* - Natively readable and structured
|
||||
*
|
||||
* Each response follows a consistent structure:
|
||||
* 1. Status line (OK/ERROR + tool name)
|
||||
* 2. Summary (human-readable description)
|
||||
* 3. Data (markdown table for lists, key-value for single items)
|
||||
* 4. Pagination info (for list responses)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Wrap a markdown string as an MCP text content result.
|
||||
* @param {string} markdown
|
||||
* @param {boolean} [isError=false]
|
||||
* @returns {{ content: Array, isError?: boolean }}
|
||||
*/
|
||||
function toMcpResponse(markdown, isError = false) {
|
||||
const result = {
|
||||
content: [{ type: 'text', text: markdown }],
|
||||
};
|
||||
if (isError) result.isError = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a unix timestamp (ms) as a human-readable date string.
|
||||
* @param {number|null|undefined} ts
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatDate(ts) {
|
||||
if (ts == null) return '–';
|
||||
return new Date(ts)
|
||||
.toISOString()
|
||||
.replace('T', ' ')
|
||||
.replace(/\.\d{3}Z$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape pipe characters in table cell values.
|
||||
* @param {*} val
|
||||
* @returns {string}
|
||||
*/
|
||||
function cell(val) {
|
||||
if (val == null) return '–';
|
||||
return String(val).replace(/\|/g, '\\|').replace(/\n/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a list_jobs response.
|
||||
* @param {{ totalNumber: number, page: number, result: object[] }} queryResult
|
||||
* @param {{ page: number, pageSize: number }} params
|
||||
* @returns {{ content: Array }}
|
||||
*/
|
||||
export function normalizeListJobs(queryResult, { page, pageSize }) {
|
||||
const maxPage = Math.max(1, Math.ceil(queryResult.totalNumber / pageSize));
|
||||
const hasMore = page < maxPage;
|
||||
const jobs = queryResult.result;
|
||||
|
||||
let md = `**Tool:** list_jobs | **Status:** OK\n\n`;
|
||||
md += `Found **${queryResult.totalNumber}** job(s). Showing page ${page} of ${maxPage} (${jobs.length} on this page).`;
|
||||
if (hasMore) md += ` More pages available — use page=${page + 1} to continue.`;
|
||||
md += '\n\n';
|
||||
|
||||
if (jobs.length > 0) {
|
||||
md += `| ID | Name | Enabled | Active Listings |\n`;
|
||||
md += `|----|------|---------|----------------|\n`;
|
||||
for (const j of jobs) {
|
||||
md += `| ${cell(j.id)} | ${cell(j.name)} | ${j.enabled ? 'yes' : 'no'} | ${j.numberOfFoundListings ?? 0} |\n`;
|
||||
}
|
||||
} else {
|
||||
md += `No jobs found.\n`;
|
||||
}
|
||||
|
||||
md += `\n**Page:** ${page}/${maxPage} | **Has more:** ${hasMore ? 'yes' : 'no'}`;
|
||||
return toMcpResponse(md);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a get_job response.
|
||||
* @param {object} job - The job object from storage.
|
||||
* @returns {{ content: Array }}
|
||||
*/
|
||||
export function normalizeGetJob(job) {
|
||||
const providers = (job.provider ?? []).map((p) => p.id || p);
|
||||
|
||||
let md = `**Tool:** get_job | **Status:** OK\n\n`;
|
||||
md += `### Job: ${job.name || job.id}\n\n`;
|
||||
md += `- **ID:** ${job.id}\n`;
|
||||
md += `- **Name:** ${job.name || '–'}\n`;
|
||||
md += `- **Enabled:** ${job.enabled ? 'yes' : 'no'}\n`;
|
||||
md += `- **Active Listings:** ${job.numberOfFoundListings ?? 0}\n`;
|
||||
md += `- **Providers:** ${providers.length > 0 ? providers.join(', ') : '–'}\n`;
|
||||
md += `- **Blacklist:** ${(job.blacklist ?? []).length > 0 ? job.blacklist.join(', ') : '–'}\n`;
|
||||
|
||||
return toMcpResponse(md);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a list_listings response.
|
||||
* @param {{ totalNumber: number, page: number, result: object[] }} queryResult
|
||||
* @param {{ page: number, pageSize: number }} params
|
||||
* @returns {{ content: Array }}
|
||||
*/
|
||||
export function normalizeListListings(queryResult, { page, pageSize }) {
|
||||
const maxPage = Math.max(1, Math.ceil(queryResult.totalNumber / pageSize));
|
||||
const hasMore = page < maxPage;
|
||||
const listings = queryResult.result;
|
||||
|
||||
let md = `**Tool:** list_listings | **Status:** OK\n\n`;
|
||||
md += `Found **${queryResult.totalNumber}** listing(s). Showing page ${page} of ${maxPage} (${listings.length} on this page).`;
|
||||
if (hasMore) md += ` More pages available — use page=${page + 1} to continue.`;
|
||||
md += '\n\n';
|
||||
|
||||
if (listings.length > 0) {
|
||||
md += `| ID | Title | Address | Price | Size | Provider | Active | Created | Job |\n`;
|
||||
md += `|----|-------|---------|-------|------|----------|--------|---------|-----|\n`;
|
||||
for (const l of listings) {
|
||||
md += `| ${cell(l.id)} | ${cell(l.title)} | ${cell(l.address)} | ${cell(l.price)} | ${cell(l.size)} | ${cell(l.provider)} | ${l.is_active ? 'yes' : 'no'} | ${formatDate(l.created_at)} | ${cell(l.job_name)} |\n`;
|
||||
}
|
||||
md += `\nUse **get_listing** with an ID for full details (description, link, image).\n`;
|
||||
} else {
|
||||
md += `No listings found.\n`;
|
||||
}
|
||||
|
||||
md += `\n**Page:** ${page}/${maxPage} | **Has more:** ${hasMore ? 'yes' : 'no'}`;
|
||||
return toMcpResponse(md);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a get_listing response.
|
||||
* @param {object} listing - The listing object from storage.
|
||||
* @returns {{ content: Array }}
|
||||
*/
|
||||
export function normalizeGetListing(listing) {
|
||||
let md = `**Tool:** get_listing | **Status:** OK\n\n`;
|
||||
md += `### Listing: ${listing.title || listing.id}\n\n`;
|
||||
md += `- **ID:** ${listing.id}\n`;
|
||||
md += `- **Title:** ${listing.title || '–'}\n`;
|
||||
md += `- **Description:** ${listing.description || '–'}\n`;
|
||||
md += `- **Address:** ${listing.address || '–'}\n`;
|
||||
md += `- **Price:** ${listing.price ?? '–'}\n`;
|
||||
md += `- **Size:** ${listing.size ?? '–'}\n`;
|
||||
md += `- **Provider:** ${listing.provider || '–'}\n`;
|
||||
md += `- **Link:** ${listing.link || '–'}\n`;
|
||||
md += `- **Image:** ${listing.image_url || '–'}\n`;
|
||||
md += `- **Active:** ${listing.is_active ? 'yes' : 'no'}\n`;
|
||||
md += `- **Created:** ${formatDate(listing.created_at)}\n`;
|
||||
md += `- **Job:** ${listing.job_name || '–'}\n`;
|
||||
if (listing.latitude != null && listing.longitude != null) {
|
||||
md += `- **Location:** ${listing.latitude}, ${listing.longitude}\n`;
|
||||
}
|
||||
if (listing.distance_to_destination != null) {
|
||||
md += `- **Distance to destination:** ${listing.distance_to_destination}\n`;
|
||||
}
|
||||
|
||||
return toMcpResponse(md);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an error response.
|
||||
* @param {string} message - The error message.
|
||||
* @param {string} [tool] - Optional tool name for context.
|
||||
* @returns {{ content: Array, isError: boolean }}
|
||||
*/
|
||||
export function normalizeError(message, tool) {
|
||||
const md = `**Tool:** ${tool ?? 'unknown'} | **Status:** ERROR\n\n${message}`;
|
||||
return toMcpResponse(md, true);
|
||||
}
|
||||
76
lib/mcp/stdio.js
Normal file
76
lib/mcp/stdio.js
Normal file
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
/**
|
||||
* Fredy MCP Server – stdio transport
|
||||
*
|
||||
* Launches the MCP server over stdin/stdout so that local LLM clients
|
||||
* (e.g. Claude Desktop, llm-cli, mcp-cli) can connect directly.
|
||||
*
|
||||
* Usage:
|
||||
* MCP_TOKEN=fredy_<your-token> node mcp/stdio.js
|
||||
*
|
||||
* The MCP_TOKEN environment variable must contain a valid Fredy MCP token.
|
||||
* Each user has a permanent, non-expiring token shown in the user management list.
|
||||
*/
|
||||
import { fileURLToPath } from 'url';
|
||||
import path from 'path';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import SqliteConnection from '../services/storage/SqliteConnection.js';
|
||||
import { runMigrations } from '../services/storage/migrations/migrate.js';
|
||||
import { createMcpServer } from './mcpAdapter.js';
|
||||
import { validateMcpToken } from '../services/storage/userStorage.js';
|
||||
|
||||
// Ensure cwd is the project root so that relative DB/config paths resolve correctly
|
||||
// (LM Studio and other MCP hosts may spawn this process from an arbitrary directory)
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
process.chdir(path.resolve(__dirname, '..', '..'));
|
||||
|
||||
// Initialize the database (required for standalone usage)
|
||||
await SqliteConnection.init();
|
||||
await runMigrations();
|
||||
|
||||
const token = process.env.MCP_TOKEN;
|
||||
if (!token) {
|
||||
process.stderr.write('Error: MCP_TOKEN environment variable is required.\n');
|
||||
process.stderr.write('Each user has a permanent MCP token shown in the user management list.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const auth = validateMcpToken(token);
|
||||
if (!auth) {
|
||||
process.stderr.write('Error: Invalid MCP_TOKEN. Token not found or user no longer exists.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mcpServer = createMcpServer();
|
||||
|
||||
// Wrap the stdio transport to inject authInfo into every message
|
||||
const transport = new StdioServerTransport();
|
||||
|
||||
// Patch: the MCP SDK passes authInfo through the transport's onmessage extra param.
|
||||
// For stdio we inject the resolved user from the token.
|
||||
const patchedTransport = new Proxy(transport, {
|
||||
set(target, prop, value) {
|
||||
if (prop === 'onmessage') {
|
||||
target.onmessage = (message, extra) => {
|
||||
value(message, { ...extra, authInfo: { userId: auth.userId } });
|
||||
};
|
||||
return true;
|
||||
}
|
||||
target[prop] = value;
|
||||
return true;
|
||||
},
|
||||
get(target, prop) {
|
||||
return target[prop];
|
||||
},
|
||||
});
|
||||
|
||||
await mcpServer.connect(patchedTransport);
|
||||
process.stderr.write('Fredy MCP Server running on stdio\n');
|
||||
@@ -16,8 +16,8 @@ const mapListing = (listing) => ({
|
||||
url: listing.link,
|
||||
});
|
||||
|
||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||
const { authToken, endpointUrl } = notificationConfig.find((a) => a.id === config.id).fields;
|
||||
export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||
const { authToken, endpointUrl, selfSignedCerts } = notificationConfig.find((a) => a.id === config.id).fields;
|
||||
|
||||
const listings = newListings.map(mapListing);
|
||||
const body = {
|
||||
@@ -34,11 +34,20 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
||||
headers['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
return fetch(endpointUrl, {
|
||||
let fetchOptions = {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
headers,
|
||||
timeout: 10000,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
};
|
||||
|
||||
if (selfSignedCerts === true) {
|
||||
fetchOptions.dispatcher = new (await import('undici')).Agent({
|
||||
connect: { rejectUnauthorized: false },
|
||||
});
|
||||
}
|
||||
|
||||
return fetch(endpointUrl, fetchOptions);
|
||||
};
|
||||
|
||||
export const config = {
|
||||
@@ -52,6 +61,10 @@ export const config = {
|
||||
label: 'Endpoint URL',
|
||||
type: 'text',
|
||||
},
|
||||
selfSignedCerts: {
|
||||
label: 'Self-signed certificates',
|
||||
type: 'boolean',
|
||||
},
|
||||
authToken: {
|
||||
description: "Your application's auth token, if required by your endpoint.",
|
||||
label: 'Auth token (optional)',
|
||||
|
||||
112
lib/notification/adapter/smtp.js
Normal file
112
lib/notification/adapter/smtp.js
Normal file
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import nodemailer from 'nodemailer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import Handlebars from 'handlebars';
|
||||
import { markdown2Html } from '../../services/markdown.js';
|
||||
import { getDirName, normalizeImageUrl } from '../../utils.js';
|
||||
|
||||
const __dirname = getDirName();
|
||||
const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTemplate/template.hbs'), 'utf8');
|
||||
const emailTemplate = Handlebars.compile(template);
|
||||
|
||||
const mapListings = (serviceName, jobKey, listings) =>
|
||||
listings.map((l) => {
|
||||
const image = normalizeImageUrl(l.image);
|
||||
return {
|
||||
title: l.title || '',
|
||||
link: l.link || '',
|
||||
address: l.address || '',
|
||||
size: l.size || '',
|
||||
price: l.price || '',
|
||||
image,
|
||||
hasImage: Boolean(image),
|
||||
serviceName,
|
||||
jobKey,
|
||||
};
|
||||
});
|
||||
|
||||
export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||
const { host, port, secure, username, password, receiver, from } = notificationConfig.find(
|
||||
(adapter) => adapter.id === config.id,
|
||||
).fields;
|
||||
|
||||
const to = receiver
|
||||
.trim()
|
||||
.split(',')
|
||||
.map((r) => r.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host,
|
||||
port: Number(port),
|
||||
secure: secure === 'true',
|
||||
auth: {
|
||||
user: username,
|
||||
pass: password,
|
||||
},
|
||||
});
|
||||
|
||||
const listings = mapListings(serviceName, jobKey, newListings);
|
||||
|
||||
const html = emailTemplate({
|
||||
serviceName: `Job: (${jobKey}) | Service: ${serviceName}`,
|
||||
numberOfListings: listings.length,
|
||||
listings,
|
||||
});
|
||||
|
||||
return transporter.sendMail({
|
||||
from,
|
||||
to: to.join(','),
|
||||
subject: `Fredy found ${listings.length} new listing(s) for ${serviceName}`,
|
||||
html,
|
||||
});
|
||||
};
|
||||
|
||||
export const config = {
|
||||
id: 'smtp',
|
||||
name: 'SMTP',
|
||||
description: 'Send notifications via any SMTP server using Nodemailer.',
|
||||
readme: markdown2Html('lib/notification/adapter/smtp.md'),
|
||||
fields: {
|
||||
host: {
|
||||
type: 'text',
|
||||
label: 'SMTP Host',
|
||||
description: 'The hostname of the SMTP server (e.g., smtp.gmail.com).',
|
||||
},
|
||||
port: {
|
||||
type: 'text',
|
||||
label: 'SMTP Port',
|
||||
description: 'The port of the SMTP server (e.g., 587 for STARTTLS, 465 for SSL).',
|
||||
},
|
||||
secure: {
|
||||
type: 'text',
|
||||
label: 'Secure (SSL/TLS)',
|
||||
description: 'Set to "true" for port 465 (SSL). Leave empty or "false" for STARTTLS on port 587.',
|
||||
},
|
||||
username: {
|
||||
type: 'text',
|
||||
label: 'Username',
|
||||
description: 'The username for SMTP authentication.',
|
||||
},
|
||||
password: {
|
||||
type: 'text',
|
||||
label: 'Password',
|
||||
description: 'The password (or app password) for SMTP authentication.',
|
||||
},
|
||||
receiver: {
|
||||
type: 'text',
|
||||
label: 'Receiver Email(s)',
|
||||
description: 'Comma-separated email addresses Fredy will send notifications to.',
|
||||
},
|
||||
from: {
|
||||
type: 'email',
|
||||
label: 'Sender Email',
|
||||
description: 'The email address Fredy sends from.',
|
||||
},
|
||||
},
|
||||
};
|
||||
22
lib/notification/adapter/smtp.md
Normal file
22
lib/notification/adapter/smtp.md
Normal file
@@ -0,0 +1,22 @@
|
||||
### SMTP Adapter
|
||||
|
||||
Send notifications through any SMTP server using [Nodemailer](https://nodemailer.com/).
|
||||
This works with Gmail, Outlook, self-hosted mail servers, or any provider that supports SMTP.
|
||||
|
||||
Setup:
|
||||
|
||||
- Provide the SMTP host and port of your mail server.
|
||||
- For **SSL/TLS** (port 465), set Secure to `true`.
|
||||
- For **STARTTLS** (port 587), leave Secure empty or set it to `false`.
|
||||
- Enter the username and password for authentication. For Gmail, use an [App Password](https://support.google.com/accounts/answer/185833).
|
||||
- Set the sender email address (must be allowed by your SMTP server).
|
||||
|
||||
Multiple recipients:
|
||||
|
||||
- Separate email addresses with commas (e.g., `some@email.com`, `someOther@email.com`).
|
||||
|
||||
Common SMTP settings:
|
||||
|
||||
- **Gmail** — `smtp.gmail.com`, port 587, secure: false
|
||||
- **Outlook** — `smtp.office365.com`, port 587, secure: false
|
||||
- **Yahoo** — `smtp.mail.yahoo.com`, port 465, secure: true
|
||||
@@ -9,7 +9,9 @@ import checkIfListingIsActive from '../services/listings/listingActiveTester.js'
|
||||
let appliedBlackList = [];
|
||||
|
||||
function shortenLink(link) {
|
||||
return link.substring(0, link.indexOf('?'));
|
||||
if (!link) return '';
|
||||
const index = link.indexOf('?');
|
||||
return index === -1 ? link : link.substring(0, index);
|
||||
}
|
||||
|
||||
function parseId(shortenedLink) {
|
||||
@@ -23,7 +25,7 @@ function normalize(o) {
|
||||
const title = o.title || 'No title available';
|
||||
const address = o.address || null;
|
||||
const shortLink = shortenLink(o.link);
|
||||
const link = `${baseUrl}/${shortLink}`;
|
||||
const link = baseUrl + shortLink;
|
||||
const image = baseUrl + o.image;
|
||||
const id = buildHash(parseId(shortLink), o.price);
|
||||
return Object.assign(o, { id, price, size, title, address, link, image });
|
||||
@@ -37,18 +39,18 @@ function applyBlacklist(o) {
|
||||
|
||||
const config = {
|
||||
url: null,
|
||||
crawlContainer: '._ref',
|
||||
crawlContainer: 'a:has(div.list_entry)',
|
||||
sortByDateParam: 'sort_col=*created_ts&sort_dir=desc',
|
||||
waitForSelector: 'body',
|
||||
crawlFields: {
|
||||
id: '@href', //will be transformed later
|
||||
price: '.list_entry .immo_preis .label_info',
|
||||
size: '.list_entry .flaeche .label_info | removeNewline | trim',
|
||||
title: '.list_entry .part_text h3 span',
|
||||
description: '.list_entry .description | trim',
|
||||
price: '.immo_preis .label_info',
|
||||
size: '.flaeche .label_info | removeNewline | trim',
|
||||
title: 'h3 span',
|
||||
description: '.description | trim',
|
||||
link: '@href',
|
||||
address: '.list_entry .place',
|
||||
image: '.list_entry img@src',
|
||||
address: '.place',
|
||||
image: 'img@src',
|
||||
},
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
|
||||
@@ -46,7 +46,9 @@ import {
|
||||
convertWebToMobile,
|
||||
} from '../services/immoscout/immoscout-web-translator.js';
|
||||
import logger from '../services/logger.js';
|
||||
import { getUserSettings } from '../services/storage/settingsStorage.js';
|
||||
let appliedBlackList = [];
|
||||
let currentUserId = null;
|
||||
|
||||
async function getListings(url) {
|
||||
const response = await fetch(url, {
|
||||
@@ -66,23 +68,86 @@ async function getListings(url) {
|
||||
}
|
||||
|
||||
const responseBody = await response.json();
|
||||
return responseBody.resultListItems
|
||||
.filter((item) => item.type === 'EXPOSE_RESULT')
|
||||
.map((expose) => {
|
||||
const item = expose.item;
|
||||
const [price, size] = item.attributes;
|
||||
const image = item?.titlePicture?.preview ?? null;
|
||||
return {
|
||||
id: item.id,
|
||||
price: price?.value,
|
||||
size: size?.value,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
link: `${metaInformation.baseUrl}expose/${item.id}`,
|
||||
address: item.address?.line,
|
||||
image,
|
||||
};
|
||||
});
|
||||
return Promise.all(
|
||||
responseBody.resultListItems
|
||||
.filter((item) => item.type === 'EXPOSE_RESULT')
|
||||
.map(async (expose) => {
|
||||
const item = expose.item;
|
||||
const [price, size] = item.attributes;
|
||||
const image = item?.titlePicture?.full ?? item?.titlePicture?.preview ?? null;
|
||||
let listing = {
|
||||
id: item.id,
|
||||
price: price?.value,
|
||||
size: size?.value,
|
||||
title: item.title,
|
||||
link: `${metaInformation.baseUrl}expose/${item.id}`,
|
||||
address: item.address?.line,
|
||||
image,
|
||||
};
|
||||
if (currentUserId) {
|
||||
const userSettings = getUserSettings(currentUserId);
|
||||
if (userSettings.immoscout_details) {
|
||||
return await pushDetails(listing);
|
||||
}
|
||||
}
|
||||
return listing;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function pushDetails(listing) {
|
||||
const detailed = await fetch(`https://api.mobile.immobilienscout24.de/expose/${listing.id}`, {
|
||||
headers: {
|
||||
'User-Agent': 'ImmoScout_27.3_26.0_._',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (!detailed.ok) {
|
||||
logger.error('Error fetching listing details from ImmoScout Mobile API:', detailed.statusText);
|
||||
return listing;
|
||||
}
|
||||
const detailBody = await detailed.json();
|
||||
|
||||
listing.description = buildDescription(detailBody);
|
||||
|
||||
return listing;
|
||||
}
|
||||
|
||||
function buildDescription(detailBody) {
|
||||
const sections = detailBody.sections || [];
|
||||
const contact = detailBody.contact || {};
|
||||
const cData = contact?.contactData || {};
|
||||
const agentName = cData?.agent?.name || '';
|
||||
const agentCompany = cData?.agent?.company || '';
|
||||
const stars = cData?.agent?.rating?.numberOfStars || '';
|
||||
const phoneNumbers = contact?.phoneNumbers || [];
|
||||
const phoneNumbersMapped = phoneNumbers
|
||||
.map((p) => `${p.label}: ${p.text}`)
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
const attributes = sections
|
||||
.filter((s) => s.type === 'ATTRIBUTE_LIST')
|
||||
.flatMap((s) => s.attributes)
|
||||
.filter((attr) => attr.label && attr.text)
|
||||
.map((attr) => `${attr.label} ${attr.text}`)
|
||||
.join('\n');
|
||||
|
||||
const freeText = sections
|
||||
.filter((s) => s.type === 'TEXT_AREA')
|
||||
.map((s) => {
|
||||
return `${s.title}\n${s.text}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
return (
|
||||
`Agent: ${agentName ? agentName : 'Unbekannt'} ${agentCompany ? `(${agentCompany}) ` : ''}${stars ? `- ${stars} stars` : ''}\n` +
|
||||
(phoneNumbersMapped ? `Phone Numbers:\n${phoneNumbersMapped}` : '') +
|
||||
'\n\n' +
|
||||
attributes.trim() +
|
||||
'\n\n' +
|
||||
freeText.trim()
|
||||
);
|
||||
}
|
||||
|
||||
async function isListingActive(link) {
|
||||
@@ -137,6 +202,7 @@ export const init = (sourceConfig, blacklist) => {
|
||||
config.enabled = sourceConfig.enabled;
|
||||
config.url = convertWebToMobile(sourceConfig.url);
|
||||
appliedBlackList = blacklist || [];
|
||||
currentUserId = sourceConfig.userId || null;
|
||||
};
|
||||
export const metaInformation = {
|
||||
name: 'Immoscout',
|
||||
|
||||
@@ -67,6 +67,7 @@ async function doGeocode(address) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
agent,
|
||||
timeout: 60000,
|
||||
headers: {
|
||||
'User-Agent': userAgent,
|
||||
},
|
||||
|
||||
@@ -79,6 +79,8 @@ const PARAM_NAME_MAP = {
|
||||
price: 'price',
|
||||
constructionyear: 'constructionyear',
|
||||
apartmenttypes: 'apartmenttypes',
|
||||
buildingtypes: 'buildingtypes',
|
||||
ground: 'ground',
|
||||
pricetype: 'pricetype',
|
||||
floor: 'floor',
|
||||
geocodes: 'geocodes',
|
||||
@@ -86,6 +88,7 @@ const PARAM_NAME_MAP = {
|
||||
shape: 'shape',
|
||||
sorting: 'sorting',
|
||||
newbuilding: 'newbuilding',
|
||||
fulltext: 'fulltext',
|
||||
};
|
||||
|
||||
const EQUIPMENT_MAP = {
|
||||
@@ -97,6 +100,7 @@ const EQUIPMENT_MAP = {
|
||||
guesttoilet: 'guestToilet',
|
||||
balcony: 'balcony',
|
||||
handicappedaccessible: 'handicappedAccessible',
|
||||
lodgerflat: 'lodgerflat',
|
||||
};
|
||||
|
||||
const REAL_ESTATE_TYPE = {
|
||||
@@ -106,6 +110,10 @@ const REAL_ESTATE_TYPE = {
|
||||
'wohnung-kaufen-mit-balkon': 'apartmentbuy',
|
||||
'eigentumswohnung-mit-garten': 'apartmentbuy',
|
||||
'haus-kaufen': 'housebuy',
|
||||
'haus-mit-keller-kaufen': 'housebuy',
|
||||
'luxushaus-kaufen': 'housebuy',
|
||||
'villa-kaufen': 'housebuy',
|
||||
'neubauhaus-kaufen': 'housebuy',
|
||||
};
|
||||
|
||||
const WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP = {
|
||||
|
||||
@@ -166,7 +166,7 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
|
||||
for (const prov of jobProviders) {
|
||||
try {
|
||||
const matchedProvider = providers.find((loaded) => loaded.metaInformation.id === prov.id);
|
||||
matchedProvider.init(prov, job.blacklist);
|
||||
matchedProvider.init({ ...prov, userId: job.userId }, job.blacklist);
|
||||
|
||||
if (browser && !browser.isConnected()) {
|
||||
logger.debug('Browser is disconnected, nullifying to launch a new one.');
|
||||
@@ -181,6 +181,7 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
|
||||
await new FredyPipelineExecutioner(
|
||||
matchedProvider.config,
|
||||
job.notificationAdapter,
|
||||
job.spatialFilter,
|
||||
prov.id,
|
||||
job.id,
|
||||
similarityCache,
|
||||
|
||||
@@ -34,9 +34,8 @@ class SqliteConnection {
|
||||
|
||||
static async init() {
|
||||
if (this.#sqlLiteCfg == null) {
|
||||
readConfigFromStorage().then((c) => {
|
||||
this.#sqlLiteCfg = c.sqlitepath;
|
||||
});
|
||||
const c = await readConfigFromStorage();
|
||||
this.#sqlLiteCfg = c.sqlitepath;
|
||||
}
|
||||
}
|
||||
/**
|
||||
|
||||
@@ -30,6 +30,7 @@ export const upsertJob = ({
|
||||
notificationAdapter,
|
||||
userId,
|
||||
shareWithUsers = [],
|
||||
spatialFilter = null,
|
||||
}) => {
|
||||
const id = jobId || nanoid();
|
||||
const existing = SqliteConnection.query(`SELECT id, user_id FROM jobs WHERE id = @id LIMIT 1`, { id })[0];
|
||||
@@ -42,7 +43,8 @@ export const upsertJob = ({
|
||||
blacklist = @blacklist,
|
||||
provider = @provider,
|
||||
notification_adapter = @notification_adapter,
|
||||
shared_with_user = @shareWithUsers
|
||||
shared_with_user = @shareWithUsers,
|
||||
spatial_filter = @spatialFilter
|
||||
WHERE id = @id`,
|
||||
{
|
||||
id,
|
||||
@@ -52,12 +54,13 @@ export const upsertJob = ({
|
||||
shareWithUsers: toJson(shareWithUsers ?? []),
|
||||
provider: toJson(provider ?? []),
|
||||
notification_adapter: toJson(notificationAdapter ?? []),
|
||||
spatialFilter: spatialFilter ? toJson(spatialFilter) : null,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
SqliteConnection.execute(
|
||||
`INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter, shared_with_user)
|
||||
VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter, @shareWithUsers)`,
|
||||
`INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter, shared_with_user, spatial_filter)
|
||||
VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter, @shareWithUsers, @spatialFilter)`,
|
||||
{
|
||||
id,
|
||||
user_id: ownerId,
|
||||
@@ -67,6 +70,7 @@ export const upsertJob = ({
|
||||
provider: toJson(provider ?? []),
|
||||
shareWithUsers: toJson(shareWithUsers ?? []),
|
||||
notification_adapter: toJson(notificationAdapter ?? []),
|
||||
spatialFilter: spatialFilter ? toJson(spatialFilter) : null,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -87,6 +91,7 @@ export const getJob = (jobId) => {
|
||||
j.provider,
|
||||
j.shared_with_user,
|
||||
j.notification_adapter AS notificationAdapter,
|
||||
j.spatial_filter AS spatialFilter,
|
||||
(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
|
||||
@@ -101,6 +106,7 @@ export const getJob = (jobId) => {
|
||||
provider: fromJson(row.provider, []),
|
||||
shared_with_user: fromJson(row.shared_with_user, []),
|
||||
notificationAdapter: fromJson(row.notificationAdapter, []),
|
||||
spatialFilter: fromJson(row.spatialFilter, null),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -150,6 +156,7 @@ export const getJobs = () => {
|
||||
j.provider,
|
||||
j.shared_with_user,
|
||||
j.notification_adapter AS notificationAdapter,
|
||||
j.spatial_filter AS spatialFilter,
|
||||
(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
|
||||
@@ -162,6 +169,7 @@ export const getJobs = () => {
|
||||
provider: fromJson(row.provider, []),
|
||||
shared_with_user: fromJson(row.shared_with_user, []),
|
||||
notificationAdapter: fromJson(row.notificationAdapter, []),
|
||||
spatialFilter: fromJson(row.spatialFilter, null),
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -190,7 +198,7 @@ export const queryJobs = ({
|
||||
isAdmin = false,
|
||||
} = {}) => {
|
||||
// sanitize inputs
|
||||
const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(500, Math.floor(pageSize)) : 50;
|
||||
const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(1000, Math.floor(pageSize)) : 50;
|
||||
const safePage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1;
|
||||
const offset = (safePage - 1) * safePageSize;
|
||||
|
||||
@@ -251,6 +259,7 @@ export const queryJobs = ({
|
||||
j.provider,
|
||||
j.shared_with_user,
|
||||
j.notification_adapter AS notificationAdapter,
|
||||
j.spatial_filter AS spatialFilter,
|
||||
(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}
|
||||
@@ -266,6 +275,7 @@ export const queryJobs = ({
|
||||
provider: fromJson(row.provider, []),
|
||||
shared_with_user: fromJson(row.shared_with_user, []),
|
||||
notificationAdapter: fromJson(row.notificationAdapter, []),
|
||||
spatialFilter: fromJson(row.spatialFilter, null),
|
||||
}));
|
||||
|
||||
return { totalNumber, page: safePage, result };
|
||||
|
||||
@@ -242,6 +242,8 @@ export const storeListings = (jobId, providerId, listings) => {
|
||||
* @param {object} [params.watchListFilter]
|
||||
* @param {string|null} [params.sortField=null] - One of: 'created_at','price','size','provider','title'.
|
||||
* @param {('asc'|'desc')} [params.sortDir='asc']
|
||||
* @param {number} [params.createdAfter] - Only include listings created at or after this unix timestamp (ms).
|
||||
* @param {number} [params.createdBefore] - Only include listings created at or before this unix timestamp (ms).
|
||||
* @param {string} [params.userId] - Current user id used to scope listings (ignored for admins).
|
||||
* @param {boolean} [params.isAdmin=false] - When true, returns all listings.
|
||||
* @returns {{ totalNumber:number, page:number, result:Object[] }}
|
||||
@@ -257,11 +259,15 @@ export const queryListings = ({
|
||||
freeTextFilter,
|
||||
sortField = null,
|
||||
sortDir = 'asc',
|
||||
createdAfter = null,
|
||||
createdBefore = null,
|
||||
minPrice = null,
|
||||
maxPrice = null,
|
||||
userId = null,
|
||||
isAdmin = false,
|
||||
} = {}) => {
|
||||
// sanitize inputs
|
||||
const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(500, Math.floor(pageSize)) : 50;
|
||||
const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(1000, Math.floor(pageSize)) : 50;
|
||||
const safePage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1;
|
||||
const offset = (safePage - 1) * safePageSize;
|
||||
|
||||
@@ -307,6 +313,24 @@ export const queryListings = ({
|
||||
} else if (watchListFilter === false) {
|
||||
whereParts.push('(wl.id IS NULL)');
|
||||
}
|
||||
// Time range filters (unix timestamps in milliseconds)
|
||||
if (Number.isFinite(createdAfter) && createdAfter > 0) {
|
||||
params.createdAfter = createdAfter;
|
||||
whereParts.push('(created_at >= @createdAfter)');
|
||||
}
|
||||
if (Number.isFinite(createdBefore) && createdBefore > 0) {
|
||||
params.createdBefore = createdBefore;
|
||||
whereParts.push('(created_at <= @createdBefore)');
|
||||
}
|
||||
// Price range filters
|
||||
if (Number.isFinite(minPrice) && minPrice >= 0) {
|
||||
params.minPrice = minPrice;
|
||||
whereParts.push('(l.price >= @minPrice)');
|
||||
}
|
||||
if (Number.isFinite(maxPrice) && maxPrice >= 0) {
|
||||
params.maxPrice = maxPrice;
|
||||
whereParts.push('(l.price <= @maxPrice)');
|
||||
}
|
||||
|
||||
// Build whereSql (filtering by manually_deleted = 0)
|
||||
whereParts.push('(l.manually_deleted = 0)');
|
||||
|
||||
@@ -29,12 +29,12 @@
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { pathToFileURL, fileURLToPath } from 'url';
|
||||
import crypto from 'crypto';
|
||||
import SqliteConnection from '../SqliteConnection.js';
|
||||
import logger from '../../logger.js';
|
||||
|
||||
const ROOT = path.resolve('.');
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..');
|
||||
/**
|
||||
* Absolute path to the migrations directory (lib/services/storage/migrations/sql).
|
||||
* @type {string}
|
||||
|
||||
11
lib/services/storage/migrations/sql/11.add-spatial-filter.js
Normal file
11
lib/services/storage/migrations/sql/11.add-spatial-filter.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
// Migration: Add spatial_filter column to jobs table for storing GeoJSON-based spatial filters
|
||||
export function up(db) {
|
||||
db.exec(`
|
||||
ALTER TABLE jobs ADD COLUMN spatial_filter JSONB DEFAULT NULL;
|
||||
`);
|
||||
}
|
||||
28
lib/services/storage/migrations/sql/12.mcp-tokens.js
Normal file
28
lib/services/storage/migrations/sql/12.mcp-tokens.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
import crypto from 'crypto';
|
||||
|
||||
// Migration: Add mcp_token column to users table.
|
||||
// Each user gets a permanent, non-expiring secret token used for MCP API authentication.
|
||||
// Tokens are auto-generated for all existing users during this migration.
|
||||
export function up(db) {
|
||||
const columns = db.prepare(`PRAGMA table_info(users)`).all();
|
||||
if (!columns.some((col) => col.name === 'mcp_token')) {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN mcp_token TEXT`);
|
||||
}
|
||||
|
||||
// Backfill all existing users that don't have a token yet
|
||||
const users = db.prepare(`SELECT id FROM users WHERE mcp_token IS NULL`).all();
|
||||
const update = db.prepare(`UPDATE users SET mcp_token = @token WHERE id = @id`);
|
||||
for (const user of users) {
|
||||
const token = `fredy_${crypto.randomBytes(32).toString('hex')}`;
|
||||
update.run({ id: user.id, token });
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,18 @@
|
||||
|
||||
import * as hasher from '../security/hash.js';
|
||||
import { nanoid } from 'nanoid';
|
||||
import crypto from 'crypto';
|
||||
import SqliteConnection from './SqliteConnection.js';
|
||||
import { getSettings } from './settingsStorage.js';
|
||||
import { inDevMode } from '../../utils.js';
|
||||
|
||||
/**
|
||||
* Generate a permanent, non-expiring MCP API token.
|
||||
* These tokens are secrets that never expire and are used for MCP authentication.
|
||||
* @returns {string}
|
||||
*/
|
||||
const generateMcpToken = () => `fredy_${crypto.randomBytes(32).toString('hex')}`;
|
||||
|
||||
/**
|
||||
* Get all users.
|
||||
*
|
||||
@@ -21,7 +29,7 @@ import { inDevMode } from '../../utils.js';
|
||||
*/
|
||||
export const getUsers = (withPassword) => {
|
||||
const rows = SqliteConnection.query(
|
||||
`SELECT u.id, u.username, u.password, u.last_login AS lastLogin, u.is_admin AS isAdmin,
|
||||
`SELECT u.id, u.username, u.password, u.last_login AS lastLogin, u.is_admin AS isAdmin, u.mcp_token AS mcpToken,
|
||||
(SELECT COUNT(1) FROM jobs j WHERE j.user_id = u.id) AS numberOfJobs
|
||||
FROM users u
|
||||
ORDER BY u.username`,
|
||||
@@ -41,7 +49,7 @@ export const getUsers = (withPassword) => {
|
||||
*/
|
||||
export const getUser = (id) => {
|
||||
const rows = SqliteConnection.query(
|
||||
`SELECT u.id, u.username, u.password, u.last_login AS lastLogin, u.is_admin AS isAdmin,
|
||||
`SELECT u.id, u.username, u.password, u.last_login AS lastLogin, u.is_admin AS isAdmin, u.mcp_token AS mcpToken,
|
||||
(SELECT COUNT(1) FROM jobs j WHERE j.user_id = u.id) AS numberOfJobs
|
||||
FROM users u
|
||||
WHERE u.id = @id
|
||||
@@ -88,14 +96,15 @@ export const upsertUser = ({ username, password, userId, isAdmin }) => {
|
||||
}
|
||||
} else {
|
||||
SqliteConnection.execute(
|
||||
`INSERT INTO users (id, username, password, last_login, is_admin)
|
||||
VALUES (@id, @username, @password, @last_login, @is_admin)`,
|
||||
`INSERT INTO users (id, username, password, last_login, is_admin, mcp_token)
|
||||
VALUES (@id, @username, @password, @last_login, @is_admin, @mcp_token)`,
|
||||
{
|
||||
id,
|
||||
username,
|
||||
password: hasher.hash(password || ''),
|
||||
last_login: null,
|
||||
is_admin: isAdmin ? 1 : 0,
|
||||
mcp_token: generateMcpToken(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -150,9 +159,9 @@ export const ensureDemoUserExists = async () => {
|
||||
const existing = SqliteConnection.query(`SELECT id FROM users WHERE username = 'demo' LIMIT 1`);
|
||||
if (existing.length === 0) {
|
||||
SqliteConnection.execute(
|
||||
`INSERT INTO users (id, username, password, last_login, is_admin)
|
||||
VALUES (@id, 'demo', @password, NULL, 1)`,
|
||||
{ id: nanoid(), password: hasher.hash('demo') },
|
||||
`INSERT INTO users (id, username, password, last_login, is_admin, mcp_token)
|
||||
VALUES (@id, 'demo', @password, NULL, 1, @mcp_token)`,
|
||||
{ id: nanoid(), password: hasher.hash('demo'), mcp_token: generateMcpToken() },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -167,13 +176,25 @@ export const ensureDemoUserExists = async () => {
|
||||
* Security: On a fresh instance, a default admin/admin is created; change this password immediately.
|
||||
* @returns {void}
|
||||
*/
|
||||
/**
|
||||
* Validate an MCP API token and return the associated user id.
|
||||
* MCP tokens are permanent secrets stored in the users table that never expire.
|
||||
* @param {string} token - The raw token string (e.g. fredy_...).
|
||||
* @returns {{ userId: string } | null} The user id or null if invalid.
|
||||
*/
|
||||
export const validateMcpToken = (token) => {
|
||||
if (!token) return null;
|
||||
const row = SqliteConnection.query(`SELECT id FROM users WHERE mcp_token = @token LIMIT 1`, { token })[0];
|
||||
return row ? { userId: row.id } : null;
|
||||
};
|
||||
|
||||
export const ensureAdminUserExists = () => {
|
||||
const anyUser = SqliteConnection.query(`SELECT id FROM users LIMIT 1`).length > 0;
|
||||
if (!anyUser) {
|
||||
SqliteConnection.execute(
|
||||
`INSERT INTO users (id, username, password, last_login, is_admin)
|
||||
VALUES (@id, 'admin', @password, @last_login, 1)`,
|
||||
{ id: nanoid(), password: hasher.hash('admin'), last_login: Date.now() },
|
||||
`INSERT INTO users (id, username, password, last_login, is_admin, mcp_token)
|
||||
VALUES (@id, 'admin', @password, @last_login, 1, @mcp_token)`,
|
||||
{ id: nanoid(), password: hasher.hash('admin'), last_login: Date.now(), mcp_token: generateMcpToken() },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
55
package.json
55
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fredy",
|
||||
"version": "19.5.2",
|
||||
"version": "20.0.6",
|
||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||
"scripts": {
|
||||
"prepare": "husky",
|
||||
@@ -11,9 +11,10 @@
|
||||
"build:frontend": "vite build",
|
||||
"format": "prettier --write \"**/*.js\"",
|
||||
"format:check": "prettier --check \"**/*.js\"",
|
||||
"test": "node --import ./test/esmock-loader.mjs ./node_modules/mocha/bin/mocha.js --timeout 60000 test/**/*.test.js",
|
||||
"testGH": "node --import ./test/esmock-loader.mjs ./node_modules/mocha/bin/mocha.js --timeout 60000 --exclude test/provider/immonet.test.js --exclude test/provider/immobilienDe.test.js --exclude test/provider/immowelt.test.js test/**/*.test.js",
|
||||
"test": "vitest run",
|
||||
"testGH": "vitest run --config vitest.gh.config.js",
|
||||
"lint": "eslint .",
|
||||
"mcp:stdio": "node lib/mcp/stdio.js",
|
||||
"lint:fix": "yarn lint --fix",
|
||||
"migratedb": "node lib/services/storage/migrations/migrate.js",
|
||||
"migratedb:overwrite": "x-var MIGRATION_ALLOW_CHECKSUM_UPDATE=true node lib/services/storage/migrations/migrate.js",
|
||||
@@ -60,27 +61,31 @@
|
||||
"Firefox ESR"
|
||||
],
|
||||
"dependencies": {
|
||||
"@douyinfe/semi-icons": "^2.91.0",
|
||||
"@douyinfe/semi-ui": "2.91.0",
|
||||
"@douyinfe/semi-ui-19": "^2.91.0",
|
||||
"@douyinfe/semi-icons": "^2.93.0",
|
||||
"@douyinfe/semi-ui": "2.93.0",
|
||||
"@douyinfe/semi-ui-19": "^2.93.0",
|
||||
"@mapbox/mapbox-gl-draw": "^1.5.1",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@sendgrid/mail": "8.1.6",
|
||||
"@vitejs/plugin-react": "5.1.4",
|
||||
"@turf/boolean-point-in-polygon": "^7.3.4",
|
||||
"@vitejs/plugin-react": "6.0.1",
|
||||
"adm-zip": "^0.5.16",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"body-parser": "2.2.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"cheerio": "^1.2.0",
|
||||
"cookie-session": "2.1.1",
|
||||
"handlebars": "4.7.8",
|
||||
"lodash": "4.17.23",
|
||||
"maplibre-gl": "^5.18.0",
|
||||
"nanoid": "5.1.6",
|
||||
"maplibre-gl": "^5.20.2",
|
||||
"nanoid": "5.1.7",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "3.3.2",
|
||||
"node-mailjet": "6.0.11",
|
||||
"nodemailer": "^8.0.3",
|
||||
"p-throttle": "^8.1.0",
|
||||
"package-up": "^5.0.0",
|
||||
"puppeteer": "^24.37.3",
|
||||
"puppeteer": "^24.40.0",
|
||||
"puppeteer-extra": "^3.3.6",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||
"query-string": "9.3.1",
|
||||
@@ -88,36 +93,34 @@
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "19.2.4",
|
||||
"react-range-slider-input": "^3.3.2",
|
||||
"react-router": "7.13.0",
|
||||
"react-router-dom": "7.13.0",
|
||||
"resend": "^6.9.2",
|
||||
"react-router": "7.13.1",
|
||||
"react-router-dom": "7.13.1",
|
||||
"resend": "^6.9.4",
|
||||
"restana": "5.1.0",
|
||||
"semver": "^7.7.4",
|
||||
"serve-static": "2.2.1",
|
||||
"slack": "11.0.2",
|
||||
"vite": "7.3.1",
|
||||
"vite": "8.0.1",
|
||||
"x-var": "^3.0.1",
|
||||
"zustand": "^5.0.11"
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.29.0",
|
||||
"@babel/eslint-parser": "7.28.6",
|
||||
"@babel/preset-env": "7.29.0",
|
||||
"@babel/preset-env": "7.29.2",
|
||||
"@babel/preset-react": "7.28.5",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"chai": "6.2.2",
|
||||
"chalk": "^5.6.2",
|
||||
"eslint": "10.0.0",
|
||||
"eslint": "10.0.3",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"esmock": "2.7.3",
|
||||
"globals": "^17.3.0",
|
||||
"globals": "^17.4.0",
|
||||
"history": "5.3.0",
|
||||
"husky": "9.1.7",
|
||||
"less": "4.5.1",
|
||||
"lint-staged": "16.2.7",
|
||||
"mocha": "11.7.5",
|
||||
"nodemon": "^3.1.11",
|
||||
"prettier": "3.8.1"
|
||||
"less": "4.6.4",
|
||||
"lint-staged": "16.4.0",
|
||||
"nodemon": "^3.1.14",
|
||||
"prettier": "3.8.1",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { expect } from 'chai';
|
||||
import esmock from 'esmock';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
describe('services/storage/backupRestoreService.js - precheck & filename', () => {
|
||||
let svc;
|
||||
@@ -14,7 +13,7 @@ describe('services/storage/backupRestoreService.js - precheck & filename', () =>
|
||||
beforeEach(async () => {
|
||||
calls = { logger: { info: [], warn: [], error: [] } };
|
||||
|
||||
// Mock AdmZip with configurable state via globalThis (avoid esmock export name pitfalls)
|
||||
// Mock AdmZip with configurable state via globalThis (avoid mock export name pitfalls)
|
||||
globalThis.__ADM_ZIP_STATE__ = { hasDb: false, meta: null };
|
||||
setZipState = (s) => {
|
||||
globalThis.__ADM_ZIP_STATE__ = { ...globalThis.__ADM_ZIP_STATE__, ...s };
|
||||
@@ -77,67 +76,61 @@ describe('services/storage/backupRestoreService.js - precheck & filename', () =>
|
||||
|
||||
const utilsMock = { getPackageVersion: async () => '16.2.0' };
|
||||
|
||||
const admZipPath = path.join(ROOT, 'node_modules', 'adm-zip', 'adm-zip.js');
|
||||
const mod = await esmock(
|
||||
path.join(ROOT, 'lib', 'services', 'storage', 'backupRestoreService.js'),
|
||||
{},
|
||||
{
|
||||
'adm-zip': admZipMock,
|
||||
[admZipPath]: admZipMock,
|
||||
[migratePath]: migrateMock,
|
||||
[sqlitePath]: sqliteMock,
|
||||
[loggerPath]: loggerMock,
|
||||
[utilsPath]: utilsMock,
|
||||
},
|
||||
);
|
||||
vi.resetModules();
|
||||
vi.doMock('adm-zip', () => admZipMock);
|
||||
vi.doMock(migratePath, () => migrateMock);
|
||||
vi.doMock(sqlitePath, () => sqliteMock);
|
||||
vi.doMock(loggerPath, () => loggerMock);
|
||||
vi.doMock(utilsPath, () => utilsMock);
|
||||
|
||||
const mod = await import(path.join(ROOT, 'lib', 'services', 'storage', 'backupRestoreService.js'));
|
||||
svc = mod;
|
||||
});
|
||||
|
||||
it('precheck: empty upload yields danger', async () => {
|
||||
const res = await svc.precheckRestore(Buffer.alloc(0));
|
||||
expect(res.compatible).to.equal(false);
|
||||
expect(res.severity).to.equal('danger');
|
||||
expect(res.message).to.contain('Empty upload');
|
||||
expect(res.requiredMigration).to.equal(10);
|
||||
expect(res.compatible).toBe(false);
|
||||
expect(res.severity).toBe('danger');
|
||||
expect(res.message).toContain('Empty upload');
|
||||
expect(res.requiredMigration).toBe(10);
|
||||
});
|
||||
|
||||
it('precheck: missing listings.db yields danger', async () => {
|
||||
setZipState({ hasDb: false, meta: { dbMigration: 9 } });
|
||||
const res = await svc.precheckRestore(Buffer.from('dummy'));
|
||||
expect(res.compatible).to.equal(false);
|
||||
expect(res.severity).to.equal('danger');
|
||||
expect(res.message).to.match(/missing the database file/i);
|
||||
expect(res.compatible).toBe(false);
|
||||
expect(res.severity).toBe('danger');
|
||||
expect(res.message).toMatch(/missing the database file/i);
|
||||
});
|
||||
|
||||
it('precheck: older backup is compatible with warning', async () => {
|
||||
setZipState({ hasDb: true, meta: { dbMigration: 5, fredyVersion: '16.0.0' } });
|
||||
const res = await svc.precheckRestore(Buffer.from('zip'));
|
||||
expect(res.compatible).to.equal(true);
|
||||
expect(res.severity).to.equal('warning');
|
||||
expect(res.message).to.match(/automatic migrations/i);
|
||||
expect(res.backupMigration).to.equal(5);
|
||||
expect(res.requiredMigration).to.equal(10);
|
||||
expect(res.compatible).toBe(true);
|
||||
expect(res.severity).toBe('warning');
|
||||
expect(res.message).toMatch(/automatic migrations/i);
|
||||
expect(res.backupMigration).toBe(5);
|
||||
expect(res.requiredMigration).toBe(10);
|
||||
});
|
||||
|
||||
it('precheck: equal backup is compatible with info', async () => {
|
||||
setZipState({ hasDb: true, meta: { dbMigration: 10 } });
|
||||
const res = await svc.precheckRestore(Buffer.from('zip'));
|
||||
expect(res.compatible).to.equal(true);
|
||||
expect(res.severity).to.equal('info');
|
||||
expect(res.compatible).toBe(true);
|
||||
expect(res.severity).toBe('info');
|
||||
});
|
||||
|
||||
it('precheck: newer backup yields danger', async () => {
|
||||
setZipState({ hasDb: true, meta: { dbMigration: 11 } });
|
||||
const res = await svc.precheckRestore(Buffer.from('zip'));
|
||||
expect(res.compatible).to.equal(false);
|
||||
expect(res.severity).to.equal('danger');
|
||||
expect(res.compatible).toBe(false);
|
||||
expect(res.severity).toBe('danger');
|
||||
});
|
||||
|
||||
it('buildBackupFileName: matches pattern and includes version', async () => {
|
||||
const name = await svc.buildBackupFileName();
|
||||
expect(name).to.match(/^\d{4}-\d{2}-\d{2}-FredyBackup-/);
|
||||
expect(name).to.include('16.2.0');
|
||||
expect(name).to.match(/\.zip$/);
|
||||
expect(name).toMatch(/^\d{4}-\d{2}-\d{2}-FredyBackup-/);
|
||||
expect(name).toContain('16.2.0');
|
||||
expect(name).toMatch(/\.zip$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { expect } from 'chai';
|
||||
import esmock from 'esmock';
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// We will fully mock fs, crypto, SqliteConnection, and dynamic import of migration modules
|
||||
|
||||
@@ -85,22 +84,18 @@ describe('db/migrations/migrate.js - runMigrations', () => {
|
||||
},
|
||||
};
|
||||
|
||||
// esmock with dependency replacements
|
||||
const path = await import('node:path');
|
||||
const ROOT = path.resolve('.');
|
||||
const sqlPath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js');
|
||||
const loggerPath = path.join(ROOT, 'lib', 'services', 'logger.js');
|
||||
const mod = await esmock(
|
||||
'../../../db/migrations/migrate.js',
|
||||
{},
|
||||
{
|
||||
fs: fsMock,
|
||||
crypto: cryptoMock,
|
||||
[sqlPath]: sqlMock,
|
||||
[loggerPath]: loggerMock,
|
||||
},
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
vi.doMock('fs', () => ({ default: fsMock, ...fsMock }));
|
||||
vi.doMock('crypto', () => ({ default: cryptoMock, ...cryptoMock }));
|
||||
vi.doMock(sqlPath, () => ({ default: sqlMock }));
|
||||
vi.doMock(loggerPath, () => ({ default: loggerMock }));
|
||||
|
||||
const mod = await import('../../../lib/services/storage/migrations/migrate.js');
|
||||
runMigrations = mod.runMigrations;
|
||||
|
||||
// remember original exitCode to restore later
|
||||
@@ -114,9 +109,9 @@ describe('db/migrations/migrate.js - runMigrations', () => {
|
||||
|
||||
it('logs and returns when no migration files are found', async () => {
|
||||
await runMigrations();
|
||||
expect(calls.logs.info.some((a) => String(a[0]).includes('No migration files'))).to.equal(true);
|
||||
expect(calls.sql.getConnection).to.equal(0);
|
||||
expect(calls.sql.optimize).to.equal(0);
|
||||
expect(calls.logs.info.some((a) => String(a[0]).includes('No migration files'))).toBe(true);
|
||||
expect(calls.sql.getConnection).toBe(0);
|
||||
expect(calls.sql.optimize).toBe(0);
|
||||
});
|
||||
|
||||
it('applies a single new migration inside a transaction and records it', async () => {
|
||||
@@ -165,11 +160,6 @@ describe('db/migrations/migrate.js - runMigrations', () => {
|
||||
},
|
||||
};
|
||||
|
||||
// We need to intercept dynamic import by esmock: provide a stub for import(url)
|
||||
// esmock supports mocking via a virtual module using URL matching, but simpler approach:
|
||||
// place the file path that migrate.js will compute and make Node import resolve to our stub
|
||||
// We simulate by mocking url.pathToFileURL is still used, but dynamic import will be handled by esmock when we map the computed path.
|
||||
|
||||
const path = await import('node:path');
|
||||
const ROOT = path.resolve('.');
|
||||
|
||||
@@ -178,26 +168,22 @@ describe('db/migrations/migrate.js - runMigrations', () => {
|
||||
// Use global importer hook to bypass dynamic import
|
||||
globalThis.__TEST_MIGRATE_IMPORT__ = async () => migrationModule;
|
||||
|
||||
const mod = await esmock(
|
||||
'../../../db/migrations/migrate.js',
|
||||
{},
|
||||
{
|
||||
fs: fsMock,
|
||||
crypto: cryptoMock,
|
||||
[sqlPath]: sqlMock,
|
||||
[loggerPath]: loggerMock,
|
||||
},
|
||||
);
|
||||
vi.resetModules();
|
||||
vi.doMock('fs', () => ({ default: fsMock, ...fsMock }));
|
||||
vi.doMock('crypto', () => ({ default: cryptoMock, ...cryptoMock }));
|
||||
vi.doMock(sqlPath, () => ({ default: sqlMock }));
|
||||
vi.doMock(loggerPath, () => ({ default: loggerMock }));
|
||||
|
||||
const mod = await import('../../../lib/services/storage/migrations/migrate.js');
|
||||
runMigrations = mod.runMigrations;
|
||||
|
||||
await runMigrations();
|
||||
|
||||
// Should have started a transaction and inserted into schema_migrations
|
||||
expect(calls.sql.withTransaction.length).to.equal(1);
|
||||
expect(calls.sql.withTransaction.length).toBe(1);
|
||||
const inserted = calls.sql.execute.find((e) => String(e.sql).includes('INSERT INTO schema_migrations'));
|
||||
expect(!!inserted).to.equal(true);
|
||||
expect(calls.sql.optimize).to.equal(1);
|
||||
expect(!!inserted).toBe(true);
|
||||
expect(calls.sql.optimize).toBe(1);
|
||||
});
|
||||
|
||||
it('skips already executed migration with same checksum', async () => {
|
||||
@@ -242,24 +228,20 @@ describe('db/migrations/migrate.js - runMigrations', () => {
|
||||
|
||||
globalThis.__TEST_MIGRATE_IMPORT__ = async () => ({ up: () => {} });
|
||||
|
||||
const mod = await esmock(
|
||||
'../../../db/migrations/migrate.js',
|
||||
{},
|
||||
{
|
||||
fs: fsMock,
|
||||
crypto: cryptoMock,
|
||||
[sqlPath]: sqlMock,
|
||||
[loggerPath]: loggerMock,
|
||||
},
|
||||
);
|
||||
vi.resetModules();
|
||||
vi.doMock('fs', () => ({ default: fsMock, ...fsMock }));
|
||||
vi.doMock('crypto', () => ({ default: cryptoMock, ...cryptoMock }));
|
||||
vi.doMock(sqlPath, () => ({ default: sqlMock }));
|
||||
vi.doMock(loggerPath, () => ({ default: loggerMock }));
|
||||
|
||||
const mod = await import('../../../lib/services/storage/migrations/migrate.js');
|
||||
runMigrations = mod.runMigrations;
|
||||
|
||||
await runMigrations();
|
||||
|
||||
// Should not run transaction because it's skipped
|
||||
expect(calls.sql.withTransaction.length).to.equal(0);
|
||||
expect(calls.sql.optimize).to.equal(1);
|
||||
expect(calls.sql.withTransaction.length).toBe(0);
|
||||
expect(calls.sql.optimize).toBe(1);
|
||||
});
|
||||
|
||||
it('aborts with exitCode=1 when a migration throws, without applying insert', async () => {
|
||||
@@ -311,24 +293,20 @@ describe('db/migrations/migrate.js - runMigrations', () => {
|
||||
const sqlPath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js');
|
||||
const loggerPath = path.join(ROOT, 'lib', 'services', 'logger.js');
|
||||
|
||||
const mod = await esmock(
|
||||
'../../../lib/services/storage/migrations/migrate.js',
|
||||
{},
|
||||
{
|
||||
fs: fsMock,
|
||||
crypto: cryptoMock,
|
||||
[sqlPath]: sqlMock,
|
||||
[loggerPath]: loggerMock,
|
||||
},
|
||||
);
|
||||
vi.resetModules();
|
||||
vi.doMock('fs', () => ({ default: fsMock, ...fsMock }));
|
||||
vi.doMock('crypto', () => ({ default: cryptoMock, ...cryptoMock }));
|
||||
vi.doMock(sqlPath, () => ({ default: sqlMock }));
|
||||
vi.doMock(loggerPath, () => ({ default: loggerMock }));
|
||||
|
||||
const mod = await import('../../../lib/services/storage/migrations/migrate.js');
|
||||
runMigrations = mod.runMigrations;
|
||||
|
||||
await runMigrations();
|
||||
|
||||
expect(process.exitCode).to.equal(1);
|
||||
expect(process.exitCode).toBe(1);
|
||||
// No insert into schema_migrations should be recorded since transaction failed
|
||||
const inserted = calls.sql.execute.find((e) => String(e.sql).includes('INSERT INTO schema_migrations'));
|
||||
expect(inserted).to.equal(undefined);
|
||||
expect(inserted).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import { register } from 'node:module';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
register('esmock', pathToFileURL('./'));
|
||||
@@ -24,4 +24,8 @@ export function getUserSettings(userId) {
|
||||
export const updateListingDistance = (id, distance) => {
|
||||
// noop
|
||||
};
|
||||
export const deletedIds = [];
|
||||
export const deleteListingsById = (ids) => {
|
||||
deletedIds.push(...ids);
|
||||
};
|
||||
/* eslint-enable no-unused-vars */
|
||||
|
||||
89
test/pipeline_filtering.test.js
Normal file
89
test/pipeline_filtering.test.js
Normal file
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { expect } from 'vitest';
|
||||
import { mockFredy } from './utils.js';
|
||||
import * as mockStore from './mocks/mockStore.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 () => {
|
||||
const Fredy = await mockFredy();
|
||||
|
||||
const mockSimilarityCache = {
|
||||
checkAndAddEntry: () => true, // always similar
|
||||
};
|
||||
|
||||
const providerConfig = {
|
||||
url: 'http://example.com',
|
||||
getListings: () => Promise.resolve([{ id: '1', title: 'test', address: 'addr', price: '100' }]),
|
||||
normalize: (l) => l,
|
||||
filter: () => true,
|
||||
crawlFields: { id: 'id', title: 'title', address: 'address', price: 'price' },
|
||||
};
|
||||
|
||||
const fredy = new Fredy(providerConfig, null, null, 'test-provider', 'test-job', mockSimilarityCache);
|
||||
|
||||
// Clear deletedIds before test
|
||||
mockStore.deletedIds.length = 0;
|
||||
|
||||
try {
|
||||
await fredy.execute();
|
||||
} catch {
|
||||
// Might throw NoNewListingsWarning if all are filtered out
|
||||
}
|
||||
|
||||
expect(mockStore.deletedIds).toContain('1');
|
||||
});
|
||||
|
||||
it('should call deleteListingsById when listings are filtered by area', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
|
||||
const mockSimilarityCache = {
|
||||
checkAndAddEntry: () => false, // never similar
|
||||
};
|
||||
|
||||
const spatialFilter = {
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[0, 0],
|
||||
[0, 1],
|
||||
[1, 1],
|
||||
[1, 0],
|
||||
[0, 0],
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const providerConfig = {
|
||||
url: 'http://example.com',
|
||||
getListings: () =>
|
||||
Promise.resolve([{ id: '2', title: 'test', address: 'addr', price: '100', latitude: 2, longitude: 2 }]), // outside polygon
|
||||
normalize: (l) => l,
|
||||
filter: () => true,
|
||||
crawlFields: { id: 'id', title: 'title', address: 'address', price: 'price' },
|
||||
};
|
||||
|
||||
const fredy = new Fredy(providerConfig, null, spatialFilter, 'test-provider', 'test-job', mockSimilarityCache);
|
||||
|
||||
// Clear deletedIds before test
|
||||
mockStore.deletedIds.length = 0;
|
||||
|
||||
try {
|
||||
await fredy.execute();
|
||||
} catch {
|
||||
// Might throw NoNewListingsWarning if all are filtered out
|
||||
}
|
||||
|
||||
expect(mockStore.deletedIds).toContain('2');
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||
import { get } from '../mocks/mockNotification.js';
|
||||
import { providerConfig, mockFredy } from '../utils.js';
|
||||
import { expect } from 'chai';
|
||||
import { expect } from 'vitest';
|
||||
import * as provider from '../../lib/provider/einsAImmobilien.js';
|
||||
|
||||
describe('#einsAImmobilien testsuite()', () => {
|
||||
@@ -14,24 +14,31 @@ describe('#einsAImmobilien testsuite()', () => {
|
||||
it('should test einsAImmobilien provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
return await new Promise((resolve) => {
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'einsAImmobilien', similarityCache);
|
||||
const fredy = new Fredy(
|
||||
provider.config,
|
||||
null,
|
||||
null,
|
||||
provider.metaInformation.id,
|
||||
'einsAImmobilien',
|
||||
similarityCache,
|
||||
);
|
||||
fredy.execute().then((listings) => {
|
||||
expect(listings).to.be.a('array');
|
||||
expect(listings).toBeInstanceOf(Array);
|
||||
const notificationObj = get();
|
||||
expect(notificationObj).to.be.a('object');
|
||||
expect(notificationObj.serviceName).to.equal('einsAImmobilien');
|
||||
expect(notificationObj).toBeTypeOf('object');
|
||||
expect(notificationObj.serviceName).toBe('einsAImmobilien');
|
||||
notificationObj.payload.forEach((notify) => {
|
||||
/** check the actual structure **/
|
||||
expect(notify.id).to.be.a('string');
|
||||
expect(notify.price).to.be.a('string');
|
||||
expect(notify.size).to.be.a('string');
|
||||
expect(notify.title).to.be.a('string');
|
||||
expect(notify.link).to.be.a('string');
|
||||
expect(notify.address).to.be.a('string');
|
||||
expect(notify.id).toBeTypeOf('string');
|
||||
expect(notify.price).toBeTypeOf('string');
|
||||
expect(notify.size).toBeTypeOf('string');
|
||||
expect(notify.title).toBeTypeOf('string');
|
||||
expect(notify.link).toBeTypeOf('string');
|
||||
expect(notify.address).toBeTypeOf('string');
|
||||
/** check the values if possible **/
|
||||
expect(notify.size).to.be.not.empty;
|
||||
expect(notify.title).to.be.not.empty;
|
||||
expect(notify.link).that.does.include('https://www.1a-immobilienmarkt.de');
|
||||
expect(notify.size).not.toBe('');
|
||||
expect(notify.title).not.toBe('');
|
||||
expect(notify.link).toContain('https://www.1a-immobilienmarkt.de');
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||
import { get } from '../mocks/mockNotification.js';
|
||||
import { providerConfig, mockFredy } from '../utils.js';
|
||||
import { expect } from 'chai';
|
||||
import { expect } from 'vitest';
|
||||
import * as provider from '../../lib/provider/immobilienDe.js';
|
||||
|
||||
describe('#immobilien.de testsuite()', () => {
|
||||
@@ -14,26 +14,26 @@ describe('#immobilien.de testsuite()', () => {
|
||||
it('should test immobilien.de provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
return await new Promise((resolve) => {
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'test1', similarityCache);
|
||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'test1', similarityCache);
|
||||
fredy.execute().then((listing) => {
|
||||
expect(listing).to.be.a('array');
|
||||
expect(listing).toBeInstanceOf(Array);
|
||||
const notificationObj = get();
|
||||
expect(notificationObj).to.be.a('object');
|
||||
expect(notificationObj.serviceName).to.equal('immobilienDe');
|
||||
expect(notificationObj).toBeTypeOf('object');
|
||||
expect(notificationObj.serviceName).toBe('immobilienDe');
|
||||
notificationObj.payload.forEach((notify) => {
|
||||
/** check the actual structure **/
|
||||
expect(notify.id).to.be.a('string');
|
||||
expect(notify.price).to.be.a('string');
|
||||
expect(notify.size).to.be.a('string');
|
||||
expect(notify.title).to.be.a('string');
|
||||
expect(notify.link).to.be.a('string');
|
||||
expect(notify.address).to.be.a('string');
|
||||
expect(notify.id).toBeTypeOf('string');
|
||||
expect(notify.price).toBeTypeOf('string');
|
||||
expect(notify.size).toBeTypeOf('string');
|
||||
expect(notify.title).toBeTypeOf('string');
|
||||
expect(notify.link).toBeTypeOf('string');
|
||||
expect(notify.address).toBeTypeOf('string');
|
||||
/** check the values if possible **/
|
||||
expect(notify.price).that.does.include('€');
|
||||
expect(notify.size).that.does.include('m²');
|
||||
expect(notify.title).to.be.not.empty;
|
||||
expect(notify.link).that.does.include('https://www.immobilien.de');
|
||||
expect(notify.address).to.be.not.empty;
|
||||
expect(notify.price).toContain('€');
|
||||
expect(notify.size).toContain('m²');
|
||||
expect(notify.title).not.toBe('');
|
||||
expect(notify.link).toContain('https://www.immobilien.de');
|
||||
expect(notify.address).not.toBe('');
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { expect } from 'chai';
|
||||
import { expect } from 'vitest';
|
||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||
import { mockFredy, providerConfig } from '../utils.js';
|
||||
import { get } from '../mocks/mockNotification.js';
|
||||
@@ -14,24 +14,24 @@ describe('#immoscout provider testsuite()', () => {
|
||||
it('should test immoscout provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
return await new Promise((resolve) => {
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, '', similarityCache);
|
||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, '', similarityCache);
|
||||
fredy.execute().then((listings) => {
|
||||
expect(listings).to.be.a('array');
|
||||
expect(listings).toBeInstanceOf(Array);
|
||||
const notificationObj = get();
|
||||
expect(notificationObj).to.be.a('object');
|
||||
expect(notificationObj.serviceName).to.equal('immoscout');
|
||||
expect(notificationObj).toBeTypeOf('object');
|
||||
expect(notificationObj.serviceName).toBe('immoscout');
|
||||
notificationObj.payload.forEach((notify) => {
|
||||
/** check the actual structure **/
|
||||
expect(notify.id).to.be.a('string');
|
||||
expect(notify.price).to.be.a('string');
|
||||
expect(notify.size).to.be.a('string');
|
||||
expect(notify.title).to.be.a('string');
|
||||
expect(notify.link).to.be.a('string');
|
||||
expect(notify.address).to.be.a('string');
|
||||
expect(notify.id).toBeTypeOf('string');
|
||||
expect(notify.price).toBeTypeOf('string');
|
||||
expect(notify.size).toBeTypeOf('string');
|
||||
expect(notify.title).toBeTypeOf('string');
|
||||
expect(notify.link).toBeTypeOf('string');
|
||||
expect(notify.address).toBeTypeOf('string');
|
||||
/** check the values if possible **/
|
||||
expect(notify.size).to.be.not.empty;
|
||||
expect(notify.title).to.be.not.empty;
|
||||
expect(notify.link).that.does.include('https://www.immobilienscout24.de/');
|
||||
expect(notify.size).not.toBe('');
|
||||
expect(notify.title).not.toBe('');
|
||||
expect(notify.link).toContain('https://www.immobilienscout24.de/');
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||
import { get } from '../mocks/mockNotification.js';
|
||||
import { mockFredy, providerConfig } from '../utils.js';
|
||||
import { expect } from 'chai';
|
||||
import { expect } from 'vitest';
|
||||
import * as provider from '../../lib/provider/immoswp.js';
|
||||
|
||||
describe('#immoswp testsuite()', () => {
|
||||
@@ -14,23 +14,23 @@ describe('#immoswp testsuite()', () => {
|
||||
it('should test immoswp provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
return await new Promise((resolve) => {
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immoswp', similarityCache);
|
||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immoswp', similarityCache);
|
||||
fredy.execute().then((listing) => {
|
||||
expect(listing).to.be.a('array');
|
||||
expect(listing).toBeInstanceOf(Array);
|
||||
const notificationObj = get();
|
||||
expect(notificationObj).to.be.a('object');
|
||||
expect(notificationObj.serviceName).to.equal('immoswp');
|
||||
expect(notificationObj).toBeTypeOf('object');
|
||||
expect(notificationObj.serviceName).toBe('immoswp');
|
||||
notificationObj.payload.forEach((notify) => {
|
||||
/** check the actual structure **/
|
||||
expect(notify.id).to.be.a('string');
|
||||
expect(notify.price).to.be.a('string');
|
||||
expect(notify.size).to.be.a('string');
|
||||
expect(notify.title).to.be.a('string');
|
||||
expect(notify.link).to.be.a('string');
|
||||
expect(notify.id).toBeTypeOf('string');
|
||||
expect(notify.price).toBeTypeOf('string');
|
||||
expect(notify.size).toBeTypeOf('string');
|
||||
expect(notify.title).toBeTypeOf('string');
|
||||
expect(notify.link).toBeTypeOf('string');
|
||||
/** check the values if possible **/
|
||||
expect(notify.price).that.does.include('€');
|
||||
expect(notify.title).to.be.not.empty;
|
||||
expect(notify.link).that.does.include('https://immo.swp.de');
|
||||
expect(notify.price).toContain('€');
|
||||
expect(notify.title).not.toBe('');
|
||||
expect(notify.link).toContain('https://immo.swp.de');
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||
import { get } from '../mocks/mockNotification.js';
|
||||
import { mockFredy, providerConfig } from '../utils.js';
|
||||
import { expect } from 'chai';
|
||||
import { expect } from 'vitest';
|
||||
import * as provider from '../../lib/provider/immowelt.js';
|
||||
|
||||
describe('#immowelt testsuite()', () => {
|
||||
@@ -14,27 +14,27 @@ describe('#immowelt testsuite()', () => {
|
||||
const Fredy = await mockFredy();
|
||||
provider.init(providerConfig.immowelt, [], []);
|
||||
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immowelt', similarityCache);
|
||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immowelt', similarityCache);
|
||||
const listing = await fredy.execute();
|
||||
|
||||
expect(listing).to.be.a('array');
|
||||
expect(listing).toBeInstanceOf(Array);
|
||||
const notificationObj = get();
|
||||
expect(notificationObj).to.be.a('object');
|
||||
expect(notificationObj.serviceName).to.equal('immowelt');
|
||||
expect(notificationObj).toBeTypeOf('object');
|
||||
expect(notificationObj.serviceName).toBe('immowelt');
|
||||
notificationObj.payload.forEach((notify) => {
|
||||
/** check the actual structure **/
|
||||
expect(notify.id).to.be.a('string');
|
||||
expect(notify.price).to.be.a('string');
|
||||
expect(notify.title).to.be.a('string');
|
||||
expect(notify.link).to.be.a('string');
|
||||
expect(notify.address).to.be.a('string');
|
||||
expect(notify.id).toBeTypeOf('string');
|
||||
expect(notify.price).toBeTypeOf('string');
|
||||
expect(notify.title).toBeTypeOf('string');
|
||||
expect(notify.link).toBeTypeOf('string');
|
||||
expect(notify.address).toBeTypeOf('string');
|
||||
/** check the values if possible **/
|
||||
if (notify.size != null && notify.size.trim().toLowerCase() !== 'k.a.') {
|
||||
expect(notify.size).that.does.include('m²');
|
||||
expect(notify.size).toContain('m²');
|
||||
}
|
||||
expect(notify.title).to.be.not.empty;
|
||||
expect(notify.link).that.does.include('https://www.immowelt.de');
|
||||
expect(notify.address).to.be.not.empty;
|
||||
expect(notify.title).not.toBe('');
|
||||
expect(notify.link).toContain('https://www.immowelt.de');
|
||||
expect(notify.address).not.toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||
import { get } from '../mocks/mockNotification.js';
|
||||
import { mockFredy, providerConfig } from '../utils.js';
|
||||
import { expect } from 'chai';
|
||||
import { expect } from 'vitest';
|
||||
import * as provider from '../../lib/provider/kleinanzeigen.js';
|
||||
|
||||
describe('#kleinanzeigen testsuite()', () => {
|
||||
@@ -14,22 +14,29 @@ describe('#kleinanzeigen testsuite()', () => {
|
||||
const Fredy = await mockFredy();
|
||||
provider.init(providerConfig.kleinanzeigen, [], []);
|
||||
return await new Promise((resolve) => {
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'kleinanzeigen', similarityCache);
|
||||
const fredy = new Fredy(
|
||||
provider.config,
|
||||
null,
|
||||
null,
|
||||
provider.metaInformation.id,
|
||||
'kleinanzeigen',
|
||||
similarityCache,
|
||||
);
|
||||
fredy.execute().then((listing) => {
|
||||
expect(listing).to.be.a('array');
|
||||
expect(listing).toBeInstanceOf(Array);
|
||||
const notificationObj = get();
|
||||
expect(notificationObj).to.be.a('object');
|
||||
expect(notificationObj.serviceName).to.equal('kleinanzeigen');
|
||||
expect(notificationObj).toBeTypeOf('object');
|
||||
expect(notificationObj.serviceName).toBe('kleinanzeigen');
|
||||
notificationObj.payload.forEach((notify) => {
|
||||
/** check the actual structure **/
|
||||
expect(notify.id).to.be.a('string');
|
||||
expect(notify.title).to.be.a('string');
|
||||
expect(notify.link).to.be.a('string');
|
||||
expect(notify.address).to.be.a('string');
|
||||
expect(notify.id).toBeTypeOf('string');
|
||||
expect(notify.title).toBeTypeOf('string');
|
||||
expect(notify.link).toBeTypeOf('string');
|
||||
expect(notify.address).toBeTypeOf('string');
|
||||
/** check the values if possible **/
|
||||
expect(notify.title).to.be.not.empty;
|
||||
expect(notify.link).that.does.include('https://www.kleinanzeigen.de');
|
||||
expect(notify.address).to.be.not.empty;
|
||||
expect(notify.title).not.toBe('');
|
||||
expect(notify.link).toContain('https://www.kleinanzeigen.de');
|
||||
expect(notify.address).not.toBe('');
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||
import { get } from '../mocks/mockNotification.js';
|
||||
import { mockFredy, providerConfig } from '../utils.js';
|
||||
import { expect } from 'chai';
|
||||
import { expect } from 'vitest';
|
||||
import * as provider from '../../lib/provider/mcMakler.js';
|
||||
|
||||
describe('#mcMakler testsuite()', () => {
|
||||
@@ -14,25 +14,25 @@ describe('#mcMakler testsuite()', () => {
|
||||
const Fredy = await mockFredy();
|
||||
provider.init(providerConfig.mcMakler, []);
|
||||
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'mcMakler', similarityCache);
|
||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'mcMakler', similarityCache);
|
||||
const listing = await fredy.execute();
|
||||
|
||||
expect(listing).to.be.a('array');
|
||||
expect(listing).toBeInstanceOf(Array);
|
||||
const notificationObj = get();
|
||||
expect(notificationObj).to.be.a('object');
|
||||
expect(notificationObj.serviceName).to.equal('mcMakler');
|
||||
expect(notificationObj).toBeTypeOf('object');
|
||||
expect(notificationObj.serviceName).toBe('mcMakler');
|
||||
notificationObj.payload.forEach((notify) => {
|
||||
/** check the actual structure **/
|
||||
expect(notify.id).to.be.a('string');
|
||||
expect(notify.price).to.be.a('string');
|
||||
expect(notify.size).to.be.a('string');
|
||||
expect(notify.title).to.be.a('string');
|
||||
expect(notify.link).to.be.a('string');
|
||||
expect(notify.address).to.be.a('string');
|
||||
expect(notify.id).toBeTypeOf('string');
|
||||
expect(notify.price).toBeTypeOf('string');
|
||||
expect(notify.size).toBeTypeOf('string');
|
||||
expect(notify.title).toBeTypeOf('string');
|
||||
expect(notify.link).toBeTypeOf('string');
|
||||
expect(notify.address).toBeTypeOf('string');
|
||||
/** check the values if possible **/
|
||||
expect(notify.size).that.does.include('m²');
|
||||
expect(notify.title).to.be.not.empty;
|
||||
expect(notify.address).to.be.not.empty;
|
||||
expect(notify.size).toContain('m²');
|
||||
expect(notify.title).not.toBe('');
|
||||
expect(notify.address).not.toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||
import { get } from '../mocks/mockNotification.js';
|
||||
import { mockFredy, providerConfig } from '../utils.js';
|
||||
import { expect } from 'chai';
|
||||
import { expect } from 'vitest';
|
||||
import * as provider from '../../lib/provider/neubauKompass.js';
|
||||
|
||||
describe('#neubauKompass testsuite()', () => {
|
||||
@@ -14,22 +14,29 @@ describe('#neubauKompass testsuite()', () => {
|
||||
it('should test neubauKompass provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
return await new Promise((resolve) => {
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'neubauKompass', similarityCache);
|
||||
const fredy = new Fredy(
|
||||
provider.config,
|
||||
null,
|
||||
null,
|
||||
provider.metaInformation.id,
|
||||
'neubauKompass',
|
||||
similarityCache,
|
||||
);
|
||||
fredy.execute().then((listing) => {
|
||||
expect(listing).to.be.a('array');
|
||||
expect(listing).toBeInstanceOf(Array);
|
||||
const notificationObj = get();
|
||||
expect(notificationObj.serviceName).to.equal('neubauKompass');
|
||||
expect(notificationObj.serviceName).toBe('neubauKompass');
|
||||
notificationObj.payload.forEach((notify) => {
|
||||
expect(notify).to.be.a('object');
|
||||
expect(notify).toBeTypeOf('object');
|
||||
/** check the actual structure **/
|
||||
expect(notify.id).to.be.a('string');
|
||||
expect(notify.title).to.be.a('string');
|
||||
expect(notify.link).to.be.a('string');
|
||||
expect(notify.address).to.be.a('string');
|
||||
expect(notify.id).toBeTypeOf('string');
|
||||
expect(notify.title).toBeTypeOf('string');
|
||||
expect(notify.link).toBeTypeOf('string');
|
||||
expect(notify.address).toBeTypeOf('string');
|
||||
/** check the values if possible **/
|
||||
expect(notify.title).to.be.not.empty;
|
||||
expect(notify.link).that.does.include('https://www.neubaukompass.de');
|
||||
expect(notify.address).to.be.not.empty;
|
||||
expect(notify.title).not.toBe('');
|
||||
expect(notify.link).toContain('https://www.neubaukompass.de');
|
||||
expect(notify.address).not.toBe('');
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||
import { get } from '../mocks/mockNotification.js';
|
||||
import { mockFredy, providerConfig } from '../utils.js';
|
||||
import { expect } from 'chai';
|
||||
import { expect } from 'vitest';
|
||||
import * as provider from '../../lib/provider/ohneMakler.js';
|
||||
|
||||
describe('#ohneMakler testsuite()', () => {
|
||||
@@ -14,25 +14,25 @@ describe('#ohneMakler testsuite()', () => {
|
||||
const Fredy = await mockFredy();
|
||||
provider.init(providerConfig.ohneMakler, []);
|
||||
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'ohneMakler', similarityCache);
|
||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'ohneMakler', similarityCache);
|
||||
const listing = await fredy.execute();
|
||||
|
||||
expect(listing).to.be.a('array');
|
||||
expect(listing).toBeInstanceOf(Array);
|
||||
const notificationObj = get();
|
||||
expect(notificationObj).to.be.a('object');
|
||||
expect(notificationObj.serviceName).to.equal('ohneMakler');
|
||||
expect(notificationObj).toBeTypeOf('object');
|
||||
expect(notificationObj.serviceName).toBe('ohneMakler');
|
||||
notificationObj.payload.forEach((notify) => {
|
||||
/** check the actual structure **/
|
||||
expect(notify.id).to.be.a('string');
|
||||
expect(notify.price).to.be.a('string');
|
||||
expect(notify.size).to.be.a('string');
|
||||
expect(notify.title).to.be.a('string');
|
||||
expect(notify.link).to.be.a('string');
|
||||
expect(notify.address).to.be.a('string');
|
||||
expect(notify.id).toBeTypeOf('string');
|
||||
expect(notify.price).toBeTypeOf('string');
|
||||
expect(notify.size).toBeTypeOf('string');
|
||||
expect(notify.title).toBeTypeOf('string');
|
||||
expect(notify.link).toBeTypeOf('string');
|
||||
expect(notify.address).toBeTypeOf('string');
|
||||
/** check the values if possible **/
|
||||
expect(notify.size).that.does.include('m²');
|
||||
expect(notify.title).to.be.not.empty;
|
||||
expect(notify.address).to.be.not.empty;
|
||||
expect(notify.size).toContain('m²');
|
||||
expect(notify.title).not.toBe('');
|
||||
expect(notify.address).not.toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||
import { get } from '../mocks/mockNotification.js';
|
||||
import { mockFredy, providerConfig } from '../utils.js';
|
||||
import { expect } from 'chai';
|
||||
import { expect } from 'vitest';
|
||||
import * as provider from '../../lib/provider/regionalimmobilien24.js';
|
||||
|
||||
describe('#regionalimmobilien24 testsuite()', () => {
|
||||
@@ -17,28 +17,29 @@ describe('#regionalimmobilien24 testsuite()', () => {
|
||||
const fredy = new Fredy(
|
||||
provider.config,
|
||||
null,
|
||||
null,
|
||||
provider.metaInformation.id,
|
||||
'regionalimmobilien24',
|
||||
similarityCache,
|
||||
);
|
||||
const listing = await fredy.execute();
|
||||
|
||||
expect(listing).to.be.a('array');
|
||||
expect(listing).toBeInstanceOf(Array);
|
||||
const notificationObj = get();
|
||||
expect(notificationObj).to.be.a('object');
|
||||
expect(notificationObj.serviceName).to.equal('regionalimmobilien24');
|
||||
expect(notificationObj).toBeTypeOf('object');
|
||||
expect(notificationObj.serviceName).toBe('regionalimmobilien24');
|
||||
notificationObj.payload.forEach((notify) => {
|
||||
/** check the actual structure **/
|
||||
expect(notify.id).to.be.a('string');
|
||||
expect(notify.price).to.be.a('string');
|
||||
expect(notify.size).to.be.a('string');
|
||||
expect(notify.title).to.be.a('string');
|
||||
expect(notify.link).to.be.a('string');
|
||||
expect(notify.address).to.be.a('string');
|
||||
expect(notify.id).toBeTypeOf('string');
|
||||
expect(notify.price).toBeTypeOf('string');
|
||||
expect(notify.size).toBeTypeOf('string');
|
||||
expect(notify.title).toBeTypeOf('string');
|
||||
expect(notify.link).toBeTypeOf('string');
|
||||
expect(notify.address).toBeTypeOf('string');
|
||||
/** check the values if possible **/
|
||||
expect(notify.size).that.does.include('m²');
|
||||
expect(notify.title).to.be.not.empty;
|
||||
expect(notify.address).to.be.not.empty;
|
||||
expect(notify.size).toContain('m²');
|
||||
expect(notify.title).not.toBe('');
|
||||
expect(notify.address).not.toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||
import { get } from '../mocks/mockNotification.js';
|
||||
import { mockFredy, providerConfig } from '../utils.js';
|
||||
import { expect } from 'chai';
|
||||
import { expect } from 'vitest';
|
||||
import * as provider from '../../lib/provider/sparkasse.js';
|
||||
|
||||
describe('#sparkasse testsuite()', () => {
|
||||
@@ -14,25 +14,24 @@ describe('#sparkasse testsuite()', () => {
|
||||
const Fredy = await mockFredy();
|
||||
provider.init(providerConfig.sparkasse, []);
|
||||
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'sparkasse', similarityCache);
|
||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'sparkasse', similarityCache);
|
||||
const listing = await fredy.execute();
|
||||
|
||||
expect(listing).to.be.a('array');
|
||||
expect(listing).toBeInstanceOf(Array);
|
||||
const notificationObj = get();
|
||||
expect(notificationObj).to.be.a('object');
|
||||
expect(notificationObj.serviceName).to.equal('sparkasse');
|
||||
expect(notificationObj).toBeTypeOf('object');
|
||||
expect(notificationObj.serviceName).toBe('sparkasse');
|
||||
notificationObj.payload.forEach((notify) => {
|
||||
/** check the actual structure **/
|
||||
expect(notify.id).to.be.a('string');
|
||||
expect(notify.price).to.be.a('string');
|
||||
expect(notify.size).to.be.a('string');
|
||||
expect(notify.title).to.be.a('string');
|
||||
expect(notify.link).to.be.a('string');
|
||||
expect(notify.address).to.be.a('string');
|
||||
expect(notify.id).toBeTypeOf('string');
|
||||
expect(notify.price).toBeTypeOf('string');
|
||||
expect(notify.title).toBeTypeOf('string');
|
||||
expect(notify.link).toBeTypeOf('string');
|
||||
expect(notify.address).toBeTypeOf('string');
|
||||
/** check the values if possible **/
|
||||
expect(notify.size).that.does.include('m²');
|
||||
expect(notify.title).to.be.not.empty;
|
||||
expect(notify.address).to.be.not.empty;
|
||||
expect(notify.size).toContain('m²');
|
||||
expect(notify.title).not.toBe('');
|
||||
expect(notify.address).not.toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"enabled": true
|
||||
},
|
||||
"sparkasse": {
|
||||
"url": "https://immobilien.sparkasse.de/immobilien/treffer?marketingType=buy&objectType=flat&perimeter=10&usageType=residential&zipCityEstateId=62782__Hamburg",
|
||||
"url": "https://immobilien.sparkasse.de/immobilien/treffer?estateTypeGroupingId=403&marketingType=buy&perimeter=10&usageType=residential&zipCityEstateId=51.22422%2F6.78006%2F0__D%C3%BCsseldorf",
|
||||
"enabled": true
|
||||
},
|
||||
"wgGesucht": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { isOneOf, duringWorkingHoursOrNotSet } from '../../lib/utils.js';
|
||||
import assert from 'assert';
|
||||
import { expect } from 'chai';
|
||||
import { expect } from 'vitest';
|
||||
|
||||
const fakeWorkingHoursConfig = (from, to) => ({
|
||||
workingHours: {
|
||||
@@ -25,19 +25,19 @@ describe('utils', () => {
|
||||
});
|
||||
describe('#duringWorkingHoursOrNotSet()', () => {
|
||||
it('should be false', () => {
|
||||
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('12:00', '13:00'), 0)).to.be.false;
|
||||
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('12:00', '13:00'), 0)).toBe(false);
|
||||
});
|
||||
it('should be true', () => {
|
||||
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('10:00', '16:00'), 1622026740000)).to.be.true;
|
||||
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('10:00', '16:00'), 1622026740000)).toBe(true);
|
||||
});
|
||||
it('should be true if nothing set', () => {
|
||||
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig(null, null), 1622026740000)).to.be.true;
|
||||
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig(null, null), 1622026740000)).toBe(true);
|
||||
});
|
||||
it('should be true if only to is set', () => {
|
||||
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig(null, '13:00'), 1622026740000)).to.be.true;
|
||||
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig(null, '13:00'), 1622026740000)).toBe(true);
|
||||
});
|
||||
it('should be true if only from is set', () => {
|
||||
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('12:00', null), 1622026740000)).to.be.true;
|
||||
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('12:00', null), 1622026740000)).toBe(true);
|
||||
});
|
||||
it('should handle working hours that cross midnight (e.g., 05:00 → 00:30)', () => {
|
||||
const cfg = fakeWorkingHoursConfig('05:00', '00:30');
|
||||
@@ -49,9 +49,9 @@ describe('utils', () => {
|
||||
d.setMilliseconds(0);
|
||||
return d.getTime();
|
||||
};
|
||||
expect(duringWorkingHoursOrNotSet(cfg, mkTs(23, 0))).to.be.true; // 23:00 => within window
|
||||
expect(duringWorkingHoursOrNotSet(cfg, mkTs(1, 0))).to.be.false; // 01:00 => outside window
|
||||
expect(duringWorkingHoursOrNotSet(cfg, mkTs(6, 0))).to.be.true; // 06:00 => within window
|
||||
expect(duringWorkingHoursOrNotSet(cfg, mkTs(23, 0))).toBe(true); // 23:00 => within window
|
||||
expect(duringWorkingHoursOrNotSet(cfg, mkTs(1, 0))).toBe(false); // 01:00 => outside window
|
||||
expect(duringWorkingHoursOrNotSet(cfg, mkTs(6, 0))).toBe(true); // 06:00 => within window
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||
import { get } from '../mocks/mockNotification.js';
|
||||
import { mockFredy, providerConfig } from '../utils.js';
|
||||
import { expect } from 'chai';
|
||||
import { expect } from 'vitest';
|
||||
import * as provider from '../../lib/provider/wgGesucht.js';
|
||||
|
||||
describe('#wgGesucht testsuite()', () => {
|
||||
@@ -14,19 +14,19 @@ describe('#wgGesucht testsuite()', () => {
|
||||
it('should test wgGesucht provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
return await new Promise((resolve) => {
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'wgGesucht', similarityCache);
|
||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'wgGesucht', similarityCache);
|
||||
fredy.execute().then((listing) => {
|
||||
expect(listing).to.be.a('array');
|
||||
expect(listing).toBeInstanceOf(Array);
|
||||
const notificationObj = get();
|
||||
expect(notificationObj.serviceName).to.equal('wgGesucht');
|
||||
expect(notificationObj.serviceName).toBe('wgGesucht');
|
||||
notificationObj.payload.forEach((notify) => {
|
||||
expect(notify).to.be.a('object');
|
||||
expect(notify).toBeTypeOf('object');
|
||||
/** check the actual structure **/
|
||||
expect(notify.id).to.be.a('string');
|
||||
expect(notify.title).to.be.a('string');
|
||||
expect(notify.details).to.be.a('string');
|
||||
expect(notify.price).to.be.a('string');
|
||||
expect(notify.link).to.be.a('string');
|
||||
expect(notify.id).toBeTypeOf('string');
|
||||
expect(notify.title).toBeTypeOf('string');
|
||||
expect(notify.details).toBeTypeOf('string');
|
||||
expect(notify.price).toBeTypeOf('string');
|
||||
expect(notify.link).toBeTypeOf('string');
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||
import { get } from '../mocks/mockNotification.js';
|
||||
import { providerConfig, mockFredy } from '../utils.js';
|
||||
import { expect } from 'chai';
|
||||
import { expect } from 'vitest';
|
||||
import * as provider from '../../lib/provider/wohnungsboerse.js';
|
||||
|
||||
describe('#wohnungsboerse testsuite()', () => {
|
||||
@@ -14,24 +14,31 @@ describe('#wohnungsboerse testsuite()', () => {
|
||||
it('should test wohnungsboerse provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
return await new Promise((resolve) => {
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'wohnungsboerse', similarityCache);
|
||||
const fredy = new Fredy(
|
||||
provider.config,
|
||||
null,
|
||||
null,
|
||||
provider.metaInformation.id,
|
||||
'wohnungsboerse',
|
||||
similarityCache,
|
||||
);
|
||||
fredy.execute().then((listings) => {
|
||||
expect(listings).to.be.a('array');
|
||||
expect(listings).toBeInstanceOf(Array);
|
||||
const notificationObj = get();
|
||||
expect(notificationObj).to.be.a('object');
|
||||
expect(notificationObj.serviceName).to.equal('wohnungsboerse');
|
||||
expect(notificationObj).toBeTypeOf('object');
|
||||
expect(notificationObj.serviceName).toBe('wohnungsboerse');
|
||||
notificationObj.payload.forEach((notify) => {
|
||||
/** check the actual structure **/
|
||||
expect(notify.id).to.be.a('string');
|
||||
expect(notify.price).to.be.a('string');
|
||||
expect(notify.size).to.be.a('string');
|
||||
expect(notify.title).to.be.a('string');
|
||||
expect(notify.link).to.be.a('string');
|
||||
expect(notify.address).to.be.a('string');
|
||||
expect(notify.id).toBeTypeOf('string');
|
||||
expect(notify.price).toBeTypeOf('string');
|
||||
expect(notify.size).toBeTypeOf('string');
|
||||
expect(notify.title).toBeTypeOf('string');
|
||||
expect(notify.link).toBeTypeOf('string');
|
||||
expect(notify.address).toBeTypeOf('string');
|
||||
/** check the values if possible **/
|
||||
expect(notify.size).to.be.not.empty;
|
||||
expect(notify.title).to.be.not.empty;
|
||||
expect(notify.link).that.does.include('https://www.wohnungsboerse.net');
|
||||
expect(notify.size).not.toBe('');
|
||||
expect(notify.title).not.toBe('');
|
||||
expect(notify.link).toContain('https://www.wohnungsboerse.net');
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import { expect } from 'chai';
|
||||
import { expect } from 'vitest';
|
||||
import { readFile } from 'fs/promises';
|
||||
import mutator from '../../lib/services/queryStringMutator.js';
|
||||
import queryString from 'query-string';
|
||||
@@ -33,8 +33,8 @@ describe('queryStringMutator', () => {
|
||||
const expectedParams = queryString.parseUrl(test.shouldBecome);
|
||||
const actualParams = queryString.parseUrl(fixedUrl);
|
||||
//check if all new params are existing
|
||||
expect(Object.keys(expectedParams.query)).to.include.members(Object.keys(actualParams.query));
|
||||
expect(Object.values(expectedParams.query)).to.include.members(Object.values(actualParams.query));
|
||||
expect(Object.keys(expectedParams.query)).toEqual(expect.arrayContaining(Object.keys(actualParams.query)));
|
||||
expect(Object.values(expectedParams.query)).toEqual(expect.arrayContaining(Object.values(actualParams.query)));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { describe, it } from 'mocha';
|
||||
import { expect } from 'chai';
|
||||
import { expect } from 'vitest';
|
||||
|
||||
import {
|
||||
getPreLaunchConfig,
|
||||
@@ -26,16 +25,16 @@ describe('botPrevention helper', () => {
|
||||
};
|
||||
const cfg = getPreLaunchConfig(url, options);
|
||||
|
||||
expect(cfg.acceptLanguage).to.equal('de-DE,de;q=0.9');
|
||||
expect(cfg.langArg).to.equal('--lang=de-DE');
|
||||
expect(cfg.windowSizeArg).to.equal('--window-size=1200,700');
|
||||
expect(cfg.viewport).to.deep.equal({ width: 1200, height: 700, deviceScaleFactor: 2 });
|
||||
expect(cfg.userAgent).to.equal('TestAgent/1.0');
|
||||
expect(cfg.headers['Accept-Language']).to.equal('de-DE,de;q=0.9');
|
||||
expect(cfg.headers['User-Agent']).to.equal('TestAgent/1.0');
|
||||
expect(cfg.headers.Referer).to.equal('https://example.com/ref');
|
||||
expect(cfg.extraArgs).to.include('--disable-blink-features=AutomationControlled');
|
||||
expect(cfg.extraArgs).to.include('--proxy-bypass-list=<-loopback>');
|
||||
expect(cfg.acceptLanguage).toBe('de-DE,de;q=0.9');
|
||||
expect(cfg.langArg).toBe('--lang=de-DE');
|
||||
expect(cfg.windowSizeArg).toBe('--window-size=1200,700');
|
||||
expect(cfg.viewport).toEqual({ width: 1200, height: 700, deviceScaleFactor: 2 });
|
||||
expect(cfg.userAgent).toBe('TestAgent/1.0');
|
||||
expect(cfg.headers['Accept-Language']).toBe('de-DE,de;q=0.9');
|
||||
expect(cfg.headers['User-Agent']).toBe('TestAgent/1.0');
|
||||
expect(cfg.headers.Referer).toBe('https://example.com/ref');
|
||||
expect(cfg.extraArgs).toContain('--disable-blink-features=AutomationControlled');
|
||||
expect(cfg.extraArgs).toContain('--proxy-bypass-list=<-loopback>');
|
||||
});
|
||||
|
||||
it('applyBotPreventionToPage sets UA, viewport, headers and injects patches', async () => {
|
||||
@@ -58,15 +57,15 @@ describe('botPrevention helper', () => {
|
||||
|
||||
await applyBotPreventionToPage(page, cfg);
|
||||
|
||||
expect(calls[0]).to.deep.equal(['setUserAgent', 'Foo/Bar']);
|
||||
expect(calls.some((c) => c[0] === 'setViewport' && c[1].width === 1000 && c[1].height === 600)).to.equal(true);
|
||||
expect(calls.some((c) => c[0] === 'setJavaScriptEnabled' && c[1] === true)).to.equal(true);
|
||||
expect(calls[0]).toEqual(['setUserAgent', 'Foo/Bar']);
|
||||
expect(calls.some((c) => c[0] === 'setViewport' && c[1].width === 1000 && c[1].height === 600)).toBe(true);
|
||||
expect(calls.some((c) => c[0] === 'setJavaScriptEnabled' && c[1] === true)).toBe(true);
|
||||
const headerCall = calls.find((c) => c[0] === 'setExtraHTTPHeaders');
|
||||
expect(headerCall).to.exist;
|
||||
expect(headerCall[1]['Accept-Language']).to.equal('en-US,en');
|
||||
expect(headerCall[1]['User-Agent']).to.equal('Foo/Bar');
|
||||
expect(calls.some((c) => c[0] === 'emulateTimezone' && c[1] === 'UTC')).to.equal(true);
|
||||
expect(calls.some((c) => c[0] === 'evaluateOnNewDocument' && c[1] === 'function')).to.equal(true);
|
||||
expect(headerCall).toBeDefined();
|
||||
expect(headerCall[1]['Accept-Language']).toBe('en-US,en');
|
||||
expect(headerCall[1]['User-Agent']).toBe('Foo/Bar');
|
||||
expect(calls.some((c) => c[0] === 'emulateTimezone' && c[1] === 'UTC')).toBe(true);
|
||||
expect(calls.some((c) => c[0] === 'evaluateOnNewDocument' && c[1] === 'function')).toBe(true);
|
||||
});
|
||||
|
||||
it('applyLanguagePersistence stores languages early', async () => {
|
||||
@@ -80,9 +79,9 @@ describe('botPrevention helper', () => {
|
||||
});
|
||||
await applyLanguagePersistence(page, cfg);
|
||||
const call = calls[0];
|
||||
expect(call[0]).to.equal('evaluateOnNewDocument');
|
||||
expect(call[1]).to.equal('function');
|
||||
expect(call[2]).to.equal('de-DE,de');
|
||||
expect(call[0]).toBe('evaluateOnNewDocument');
|
||||
expect(call[1]).toBe('function');
|
||||
expect(call[2]).toBe('de-DE,de');
|
||||
});
|
||||
|
||||
it('applyPostNavigationHumanSignals moves mouse and scrolls when enabled', async () => {
|
||||
@@ -98,7 +97,7 @@ describe('botPrevention helper', () => {
|
||||
viewport: { width: 1200, height: 800 },
|
||||
};
|
||||
await applyPostNavigationHumanSignals(page, cfg);
|
||||
expect(mouseCalls.some((c) => c[0] === 'move')).to.equal(true);
|
||||
expect(mouseCalls.some((c) => c[0] === 'wheel')).to.equal(true);
|
||||
expect(mouseCalls.some((c) => c[0] === 'move')).toBe(true);
|
||||
expect(mouseCalls.some((c) => c[0] === 'wheel')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { convertWebToMobile } from '../../../lib/services/immoscout/immoscout-web-translator.js';
|
||||
import { expect } from 'chai';
|
||||
import { expect } from 'vitest';
|
||||
import { readFile } from 'fs/promises';
|
||||
|
||||
export const testData = JSON.parse(await readFile(new URL('./testdata.json', import.meta.url)));
|
||||
@@ -18,7 +18,7 @@ describe('#immoscout-mobile URL conversion', () => {
|
||||
'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';
|
||||
|
||||
const actualMobileUrl = convertWebToMobile(webUrl);
|
||||
expect(actualMobileUrl).to.equal(expectedMobileUrl);
|
||||
expect(actualMobileUrl).toBe(expectedMobileUrl);
|
||||
});
|
||||
|
||||
// Test URL conversion of web-only SEO path
|
||||
@@ -27,27 +27,27 @@ describe('#immoscout-mobile URL conversion', () => {
|
||||
|
||||
const converted = convertWebToMobile(webUrl);
|
||||
const queryParams = new URL(converted).searchParams;
|
||||
expect(queryParams.get('equipment').split(',')).to.include.members(['garden', 'balcony']);
|
||||
expect(queryParams.get('equipment').split(',')).toEqual(expect.arrayContaining(['garden', 'balcony']));
|
||||
});
|
||||
|
||||
// Test URL conversion with unsupported query parameters
|
||||
it('should remove unsupported query parameters', () => {
|
||||
const webUrl = 'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mieten?minimuminternetspeed=100000';
|
||||
const converted = convertWebToMobile(webUrl);
|
||||
expect(converted).that.does.not.include('minimuminternetspeed');
|
||||
expect(converted).not.toContain('minimuminternetspeed');
|
||||
});
|
||||
|
||||
// Test URL conversion with invalid URL
|
||||
it('should throw an error for invalid URL', () => {
|
||||
const invalidUrl = 'invalid-url';
|
||||
|
||||
expect(() => convertWebToMobile(invalidUrl)).to.throw('Invalid URL: invalid-url');
|
||||
expect(() => convertWebToMobile(invalidUrl)).toThrow('Invalid URL: invalid-url');
|
||||
});
|
||||
|
||||
// Test URL conversion with unexpected path format
|
||||
it('should throw an error for unexpected path format', () => {
|
||||
const webUrl = 'https://www.immobilienscout24.de/invalid/path/format';
|
||||
expect(() => convertWebToMobile(webUrl)).to.throw('Unexpected path format: /invalid/path/format');
|
||||
expect(() => convertWebToMobile(webUrl)).toThrow('Unexpected path format: /invalid/path/format');
|
||||
});
|
||||
|
||||
it('shouldFindResultsForEveryTestData', async () => {
|
||||
@@ -70,14 +70,12 @@ describe('#immoscout-mobile URL conversion', () => {
|
||||
console.error('Error fetching data from ImmoScout Mobile API:', response.statusText);
|
||||
}
|
||||
|
||||
expect([null, true]).to.include(response.ok);
|
||||
expect([null, true]).toContain(response.ok);
|
||||
const responseBody = await response.json();
|
||||
expect(responseBody.totalResults).to.be.greaterThan(0);
|
||||
expect(responseBody.totalResults).to.be.greaterThan(0);
|
||||
expect(responseBody.resultListItems.length).to.greaterThan(0);
|
||||
expect(responseBody.resultListItems.filter((r) => r.type === 'EXPOSE_RESULT')[0].item.realEstateType).to.equal(
|
||||
type,
|
||||
);
|
||||
expect(responseBody.totalResults).toBeGreaterThan(0);
|
||||
expect(responseBody.totalResults).toBeGreaterThan(0);
|
||||
expect(responseBody.resultListItems.length).toBeGreaterThan(0);
|
||||
expect(responseBody.resultListItems.filter((r) => r.type === 'EXPOSE_RESULT')[0].item.realEstateType).toBe(type);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { expect } from 'chai';
|
||||
import esmock from 'esmock';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
describe('services/jobs/jobExecutionService', () => {
|
||||
@@ -22,45 +21,39 @@ describe('services/jobs/jobExecutionService', () => {
|
||||
const brokerPath = root + '/lib/services/sse/sse-broker.js';
|
||||
const utilsPath = root + '/lib/utils.js';
|
||||
const loggerPath = root + '/lib/services/logger.js';
|
||||
const notifyPath = root + '/lib/notification/notify.js';
|
||||
|
||||
// esmock the service with all its collaborators
|
||||
const mod = await esmock(
|
||||
svcPath,
|
||||
{},
|
||||
{
|
||||
[busPath]: { bus },
|
||||
[jobStoragePath]: {
|
||||
getJob: (id) => state.jobsById[id] || null,
|
||||
getJobs: () => state.jobsList.slice(),
|
||||
},
|
||||
[userStoragePath]: {
|
||||
getUsers: () => state.users.slice(),
|
||||
getUser: (id) => state.users.find((u) => u.id === id) || null,
|
||||
},
|
||||
[brokerPath]: {
|
||||
sendToUsers: (...args) => calls.sent.push(args),
|
||||
},
|
||||
[utilsPath]: {
|
||||
duringWorkingHoursOrNotSet: () => false, // avoid startup run
|
||||
},
|
||||
[loggerPath]: {
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
},
|
||||
[root + '/lib/services/jobs/run-state.js']: {
|
||||
isRunning: () => false,
|
||||
markRunning: (id) => {
|
||||
calls.markRunning.push(id);
|
||||
return true;
|
||||
},
|
||||
markFinished: () => {},
|
||||
},
|
||||
vi.resetModules();
|
||||
vi.doMock(busPath, () => ({ bus }));
|
||||
vi.doMock(jobStoragePath, () => ({
|
||||
getJob: (id) => state.jobsById[id] || null,
|
||||
getJobs: () => state.jobsList.slice(),
|
||||
}));
|
||||
vi.doMock(userStoragePath, () => ({
|
||||
getUsers: () => state.users.slice(),
|
||||
getUser: (id) => state.users.find((u) => u.id === id) || null,
|
||||
}));
|
||||
vi.doMock(brokerPath, () => ({
|
||||
sendToUsers: (...args) => calls.sent.push(args),
|
||||
}));
|
||||
vi.doMock(utilsPath, () => ({
|
||||
duringWorkingHoursOrNotSet: () => false,
|
||||
}));
|
||||
vi.doMock(loggerPath, () => {
|
||||
const m = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} };
|
||||
return { default: m };
|
||||
});
|
||||
vi.doMock(notifyPath, () => ({ send: async () => [] }));
|
||||
vi.doMock(root + '/lib/services/jobs/run-state.js', () => ({
|
||||
isRunning: () => false,
|
||||
markRunning: (id) => {
|
||||
calls.markRunning.push(id);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
markFinished: () => {},
|
||||
}));
|
||||
|
||||
// call initializer with minimal deps
|
||||
const mod = await import(svcPath);
|
||||
mod.initJobExecutionService({ providers: [], settings: { demoMode: false }, intervalMs: 0 });
|
||||
return mod;
|
||||
}
|
||||
@@ -87,13 +80,13 @@ describe('services/jobs/jobExecutionService', () => {
|
||||
|
||||
bus.emit('jobs:status', { jobId: 'j1', running: true });
|
||||
|
||||
expect(calls.sent.length).to.equal(1, 'sendToUsers should be called once');
|
||||
expect(calls.sent.length, 'sendToUsers should be called once').toBe(1);
|
||||
const [recipients, event, data] = calls.sent[0];
|
||||
expect(event).to.equal('jobStatus');
|
||||
expect(data).to.deep.equal({ jobId: 'j1', running: true });
|
||||
expect(event).toBe('jobStatus');
|
||||
expect(data).toEqual({ jobId: 'j1', running: true });
|
||||
const got = new Set(recipients);
|
||||
const expected = new Set(['owner1', 'u2', 'a1']);
|
||||
expect(got).to.deep.equal(expected);
|
||||
expect(got).toEqual(expected);
|
||||
});
|
||||
|
||||
it('runs all jobs for admin; only own jobs for regular user', async () => {
|
||||
@@ -113,12 +106,12 @@ describe('services/jobs/jobExecutionService', () => {
|
||||
bus.emit('jobs:runAll', { userId: 'u1' });
|
||||
// allow microtasks to flush
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(new Set(calls.markRunning)).to.deep.equal(new Set(['j1']));
|
||||
expect(new Set(calls.markRunning)).toEqual(new Set(['j1']));
|
||||
|
||||
// Admin: all jobs
|
||||
calls.markRunning = [];
|
||||
bus.emit('jobs:runAll', { userId: 'admin' });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(new Set(calls.markRunning)).to.deep.equal(new Set(['j1', 'j2']));
|
||||
expect(new Set(calls.markRunning)).toEqual(new Set(['j1', 'j2']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,18 +3,15 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { expect } from 'chai';
|
||||
import esmock from 'esmock';
|
||||
import { vi, describe, it, expect } from 'vitest';
|
||||
|
||||
// Helper to create module under test with mocks
|
||||
async function loadModuleWith({ entries = [] } = {}) {
|
||||
const mod = await esmock('../../lib/services/similarity-check/similarityCache.js', {
|
||||
// Mock the storage to return our controlled entries
|
||||
'../../lib/services/storage/listingsStorage.js': {
|
||||
getAllEntriesFromListings: () => entries,
|
||||
},
|
||||
});
|
||||
return mod;
|
||||
vi.resetModules();
|
||||
vi.doMock('../../lib/services/storage/listingsStorage.js', () => ({
|
||||
getAllEntriesFromListings: () => entries,
|
||||
}));
|
||||
return await import('../../lib/services/similarity-check/similarityCache.js');
|
||||
}
|
||||
|
||||
describe('similarityCache', () => {
|
||||
@@ -27,15 +24,15 @@ describe('similarityCache', () => {
|
||||
const { initSimilarityCache, checkAndAddEntry } = await loadModuleWith({ entries });
|
||||
|
||||
// Initially, duplicates should not be detected for new data
|
||||
expect(checkAndAddEntry({ title: 'X', price: 200, address: 'Y' })).to.equal(false);
|
||||
expect(checkAndAddEntry({ title: 'X', price: 200, address: 'Y' })).toBe(false);
|
||||
|
||||
// Now initialize from storage
|
||||
initSimilarityCache();
|
||||
|
||||
// Exact duplicates should be detected
|
||||
expect(checkAndAddEntry({ title: 'A', price: 1000, address: 'Main 1' })).to.equal(true);
|
||||
expect(checkAndAddEntry({ title: 'A', price: 1000, address: 'Main 1' })).toBe(true);
|
||||
// Ensure falsy-but-valid price 0 is preserved by hashing and detected as duplicate
|
||||
expect(checkAndAddEntry({ title: 'B', price: 0, address: 'Zero St' })).to.equal(true);
|
||||
expect(checkAndAddEntry({ title: 'B', price: 0, address: 'Zero St' })).toBe(true);
|
||||
});
|
||||
|
||||
it('checkAndAddEntry returns false for new entry then true for duplicate on second call', async () => {
|
||||
@@ -44,8 +41,8 @@ describe('similarityCache', () => {
|
||||
const first = checkAndAddEntry({ title: 'C', price: 300, address: 'Road 3' });
|
||||
const second = checkAndAddEntry({ title: 'C', price: 300, address: 'Road 3' });
|
||||
|
||||
expect(first).to.equal(false);
|
||||
expect(second).to.equal(true);
|
||||
expect(first).toBe(false);
|
||||
expect(second).toBe(true);
|
||||
});
|
||||
|
||||
it('hashing ignores null/undefined but preserves 0 via behavior', async () => {
|
||||
@@ -53,15 +50,15 @@ describe('similarityCache', () => {
|
||||
|
||||
// Add baseline (null address ignored)
|
||||
const add1 = checkAndAddEntry({ title: 'T', price: 1, address: null });
|
||||
expect(add1).to.equal(false);
|
||||
expect(add1).toBe(false);
|
||||
// Duplicate with undefined address should match
|
||||
const dup = checkAndAddEntry({ title: 'T', price: 1, address: undefined });
|
||||
expect(dup).to.equal(true);
|
||||
expect(dup).toBe(true);
|
||||
|
||||
// Now test that price 0 is preserved (not filtered out)
|
||||
const addZero = checkAndAddEntry({ title: 'Z', price: 0, address: 'Zero' });
|
||||
expect(addZero).to.equal(false);
|
||||
expect(addZero).toBe(false);
|
||||
const dupZero = checkAndAddEntry({ title: 'Z', price: 0, address: 'Zero' });
|
||||
expect(dupZero).to.equal(true);
|
||||
expect(dupZero).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { expect } from 'chai';
|
||||
import esmock from 'esmock';
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// We explicitly avoid touching the real filesystem or creating a real DB file.
|
||||
// better-sqlite3 is fully mocked and operates in-memory via our stubs.
|
||||
@@ -78,15 +77,10 @@ describe('SqliteConnection', () => {
|
||||
};
|
||||
};
|
||||
|
||||
// esmock the module with our stubs
|
||||
SqliteConnection = await esmock(
|
||||
'../../lib/services/storage/SqliteConnection.js',
|
||||
{},
|
||||
{
|
||||
fs: fsMock,
|
||||
'better-sqlite3': { default: BetterSqlite3Mock },
|
||||
},
|
||||
);
|
||||
vi.resetModules();
|
||||
vi.doMock('fs', () => ({ default: fsMock, ...fsMock }));
|
||||
vi.doMock('better-sqlite3', () => ({ default: BetterSqlite3Mock }));
|
||||
SqliteConnection = (await import('../../lib/services/storage/SqliteConnection.js')).default;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -98,9 +92,9 @@ describe('SqliteConnection', () => {
|
||||
const db1 = SqliteConnection.getConnection();
|
||||
const db2 = SqliteConnection.getConnection();
|
||||
|
||||
expect(db1).to.equal(db2);
|
||||
expect(db1).toBe(db2);
|
||||
// journal_mode, synchronous, cache_size, foreign_keys, optimize
|
||||
expect(calls.db.pragma).to.deep.equal([
|
||||
expect(calls.db.pragma).toEqual([
|
||||
'journal_mode = WAL',
|
||||
'synchronous = NORMAL',
|
||||
'cache_size = -64000',
|
||||
@@ -108,21 +102,21 @@ describe('SqliteConnection', () => {
|
||||
'optimize',
|
||||
]);
|
||||
// mkdirSync should not be called because existsSync returned true
|
||||
expect(calls.fs.mkdirSync).to.have.length(0);
|
||||
expect(calls.fs.mkdirSync).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('executes query and execute helpers', () => {
|
||||
const rows = SqliteConnection.query('SELECT 1', {});
|
||||
expect(rows).to.be.an('array');
|
||||
expect(rows[0]).to.deep.equal({ x: 1 });
|
||||
expect(rows).toBeInstanceOf(Array);
|
||||
expect(rows[0]).toEqual({ x: 1 });
|
||||
|
||||
const info = SqliteConnection.execute('UPDATE x SET y=1 WHERE id=@id', { id: 5 });
|
||||
expect(info).to.have.property('changes', 1);
|
||||
expect(info).toHaveProperty('changes', 1);
|
||||
});
|
||||
|
||||
it('tableExists uses sqlite_master get()', () => {
|
||||
const exists = SqliteConnection.tableExists('users');
|
||||
expect(exists).to.equal(true);
|
||||
expect(exists).toBe(true);
|
||||
});
|
||||
|
||||
it('withTransaction wraps callback', () => {
|
||||
@@ -131,17 +125,17 @@ describe('SqliteConnection', () => {
|
||||
db.prepare('SELECT inside').all({});
|
||||
return 42;
|
||||
});
|
||||
expect(result).to.equal(42);
|
||||
expect(calls.db.prepare).to.include('SELECT inside');
|
||||
expect(result).toBe(42);
|
||||
expect(calls.db.prepare).toContain('SELECT inside');
|
||||
});
|
||||
|
||||
it('optimize() delegates to PRAGMA optimize and close() calls it again then closes', () => {
|
||||
SqliteConnection.optimize();
|
||||
// It will use the existing connection and call pragma('optimize')
|
||||
expect(calls.db.pragma).to.include('optimize');
|
||||
expect(calls.db.pragma).toContain('optimize');
|
||||
|
||||
SqliteConnection.close();
|
||||
// close increments close counter
|
||||
expect(calls.db.close).to.equal(1);
|
||||
expect(calls.db.close).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,29 +3,24 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
import { readFile } from 'fs/promises';
|
||||
import esmock from 'esmock';
|
||||
import * as mockStore from './mocks/mockStore.js';
|
||||
import { send } from './mocks/mockNotification.js';
|
||||
|
||||
export const providerConfig = JSON.parse(await readFile(new URL('./provider/testProvider.json', import.meta.url)));
|
||||
|
||||
vi.mock('../lib/services/storage/listingsStorage.js', () => mockStore);
|
||||
vi.mock('../lib/services/storage/settingsStorage.js', () => mockStore);
|
||||
vi.mock('../lib/services/geocoding/geoCodingService.js', () => ({
|
||||
geocodeAddress: mockStore.getGeocoordinatesByAddress,
|
||||
}));
|
||||
vi.mock('../lib/services/storage/jobStorage.js', () => ({
|
||||
getJob: (jobKey) => ({ id: jobKey, userId: 'user1' }),
|
||||
}));
|
||||
vi.mock('../lib/notification/notify.js', () => ({ send }));
|
||||
|
||||
export const mockFredy = async () => {
|
||||
return await esmock('../lib/FredyPipelineExecutioner', {
|
||||
'../lib/services/storage/listingsStorage.js': {
|
||||
...mockStore,
|
||||
},
|
||||
'../lib/services/storage/settingsStorage.js': {
|
||||
...mockStore,
|
||||
},
|
||||
'../lib/services/geocoding/geoCodingService.js': {
|
||||
geocodeAddress: mockStore.getGeocoordinatesByAddress,
|
||||
},
|
||||
'../lib/services/storage/jobStorage.js': {
|
||||
getJob: (jobKey) => ({ id: jobKey, userId: 'user1' }),
|
||||
},
|
||||
'../lib/notification/notify.js': {
|
||||
send,
|
||||
},
|
||||
});
|
||||
const mod = await import('../lib/FredyPipelineExecutioner.js');
|
||||
return mod.default ?? mod;
|
||||
};
|
||||
|
||||
@@ -3,19 +3,19 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { expect } from 'chai';
|
||||
import { expect } from 'vitest';
|
||||
import { buildHash } from '../../lib/utils.js';
|
||||
|
||||
describe('utilsCheck', () => {
|
||||
describe('#utilsCheck()', () => {
|
||||
it('should be null when null input', () => {
|
||||
expect(buildHash(null)).to.be.null;
|
||||
expect(buildHash(null)).toBeNull();
|
||||
});
|
||||
it('should be null when null empty', () => {
|
||||
expect(buildHash('')).to.be.null;
|
||||
expect(buildHash('')).toBeNull();
|
||||
});
|
||||
it('should return a value', () => {
|
||||
expect(buildHash('bla', '', null)).to.be.a.string;
|
||||
expect(buildHash('bla', '', null)).toBeTypeOf('string');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 531 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.8 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.7 MiB |
@@ -1,21 +1,11 @@
|
||||
{
|
||||
"key": "00e6b81777a275f5a140fc9101cb943810db6a69f6eb3927319c5aee0c876509",
|
||||
"key": "00e6b81777a275f5a140fc9101cb943810db6a69f6eb3927319c5aee0c876511",
|
||||
"content":
|
||||
[
|
||||
{
|
||||
"title": "Welcome to Fredy",
|
||||
"text": "Thanks for choosing Fredy!<br/>With Fredy you can quickly set up scraping jobs that run automatically every few minutes and continuously search platforms like ImmoScout and similar sites for new listings that match your criteria. Instead of manually refreshing pages and risking to miss out on good offers, Fredy monitors the market in the background and notifies you as soon as something relevant appears.",
|
||||
"image": "1.png"
|
||||
},
|
||||
{
|
||||
"title": "Stay in Control with the Grid View",
|
||||
"text": "The grid view gives you a clean and structured overview of all matching listings. You can instantly compare prices, sizes, locations and key details without leaving Fredy. Open listings in a focused detail view, bookmark interesting ones and keep track of what you have already checked.",
|
||||
"image": "2.png"
|
||||
},
|
||||
{
|
||||
"title": "Explore Listings on the Map",
|
||||
"text": "Switch to the map view to understand the bigger picture. See exactly where listings are located and evaluate neighborhoods at a glance. This helps you spot hidden gems, avoid unwanted areas and make faster, better decisions based on location.<br/>If you want Fredy to calculate commute times for you, head over to the User-Specific Settings and add your home address.",
|
||||
"image": "3.png"
|
||||
"title": "Fredy goes AI",
|
||||
"text": "With Fredy v20.0.0, we are introducing Fredy’s own MCP server. This brings a powerful new capability: you can connect your local LLM directly to Fredy and explore the data it collects in a much more flexible way.<br/><br/>The MCP server exposes Fredy’s tools and findings through a structured interface, allowing your LLM to query listings, inspect collected details, and analyze results programmatically. Instead of manually searching through the data, you can simply ask your model questions and let it dig into what Fredy has discovered for you.<br/><br/>In practice, this means your local LLM can interact with Fredy almost like an assistant: investigating properties, summarizing listings, filtering results, or helping you identify interesting opportunities based on the data Fredy gathered.",
|
||||
"media": "news.mp4"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
BIN
ui/src/assets/news/news.mp4
Normal file
BIN
ui/src/assets/news/news.mp4
Normal file
Binary file not shown.
213
ui/src/components/map/Map.jsx
Normal file
213
ui/src/components/map/Map.jsx
Normal file
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css';
|
||||
import { fixMapboxDrawCompatibility, addDrawingControl, setupAreaFilterEventListeners } from './MapDrawingExtension.js';
|
||||
import './Map.less';
|
||||
|
||||
export const GERMANY_BOUNDS = [
|
||||
[5.866, 47.27], // Southwest coordinates
|
||||
[15.042, 55.059], // Northeast coordinates
|
||||
];
|
||||
|
||||
export const STYLES = {
|
||||
STANDARD: 'https://tiles.openfreemap.org/styles/bright',
|
||||
SATELLITE: {
|
||||
version: 8,
|
||||
sources: {
|
||||
'satellite-tiles': {
|
||||
type: 'raster',
|
||||
tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'],
|
||||
tileSize: 256,
|
||||
attribution:
|
||||
'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community',
|
||||
},
|
||||
'satellite-labels': {
|
||||
type: 'raster',
|
||||
tiles: [
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}',
|
||||
],
|
||||
tileSize: 256,
|
||||
attribution: '© Esri',
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: 'satellite-tiles',
|
||||
type: 'raster',
|
||||
source: 'satellite-tiles',
|
||||
minzoom: 0,
|
||||
maxzoom: 19,
|
||||
},
|
||||
{
|
||||
id: 'satellite-labels',
|
||||
type: 'raster',
|
||||
source: 'satellite-labels',
|
||||
minzoom: 0,
|
||||
maxzoom: 19,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default function Map({
|
||||
style = 'STANDARD',
|
||||
show3dBuildings = false,
|
||||
onMapReady = null,
|
||||
enableDrawing = false,
|
||||
initialSpatialFilter = null,
|
||||
onDrawingChange = null,
|
||||
}) {
|
||||
const mapContainerRef = useRef(null);
|
||||
const mapRef = useRef(null);
|
||||
const drawRef = useRef(null);
|
||||
|
||||
// Initialize map - ONLY when container changes, never reinitialize
|
||||
useEffect(() => {
|
||||
if (mapRef.current) return; // Map already exists, don't reinitialize
|
||||
|
||||
mapRef.current = new maplibregl.Map({
|
||||
container: mapContainerRef.current,
|
||||
style: STYLES[style],
|
||||
center: [10.4515, 51.1657], // Center of Germany
|
||||
zoom: 4,
|
||||
maxBounds: GERMANY_BOUNDS,
|
||||
antialias: true,
|
||||
});
|
||||
|
||||
mapRef.current.addControl(
|
||||
new maplibregl.NavigationControl({
|
||||
showCompass: true,
|
||||
visualizePitch: true,
|
||||
visualizeRoll: true,
|
||||
}),
|
||||
'top-right',
|
||||
);
|
||||
|
||||
mapRef.current.addControl(
|
||||
new maplibregl.GeolocateControl({
|
||||
positionOptions: {
|
||||
enableHighAccuracy: true,
|
||||
},
|
||||
trackUserLocation: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Initialize drawing extension only if enabled
|
||||
if (enableDrawing) {
|
||||
fixMapboxDrawCompatibility();
|
||||
drawRef.current = addDrawingControl(mapRef.current);
|
||||
}
|
||||
|
||||
// Call onMapReady callback if provided
|
||||
if (onMapReady) {
|
||||
onMapReady(mapRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (mapRef.current) {
|
||||
mapRef.current.remove();
|
||||
mapRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [mapContainerRef]); // ONLY depend on mapContainerRef - nothing else!
|
||||
|
||||
// Load spatial filter and setup area filter event listeners
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || !drawRef.current || !enableDrawing) return;
|
||||
|
||||
// Load initial spatial filter if provided
|
||||
if (initialSpatialFilter) {
|
||||
try {
|
||||
drawRef.current.set(initialSpatialFilter);
|
||||
} catch (error) {
|
||||
console.error('Error loading spatial filter:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Setup drawing event listeners
|
||||
const cleanup = setupAreaFilterEventListeners(mapRef.current, drawRef.current, onDrawingChange);
|
||||
|
||||
return cleanup;
|
||||
}, [initialSpatialFilter, onDrawingChange, enableDrawing]);
|
||||
|
||||
// Handle style changes
|
||||
useEffect(() => {
|
||||
if (mapRef.current) {
|
||||
mapRef.current.setStyle(STYLES[style]);
|
||||
}
|
||||
}, [style]);
|
||||
|
||||
// Handle 3D buildings layer
|
||||
useEffect(() => {
|
||||
if (!mapRef.current) return;
|
||||
|
||||
const add3dLayer = () => {
|
||||
if (!mapRef.current || !mapRef.current.isStyleLoaded()) return;
|
||||
if (show3dBuildings) {
|
||||
if (!mapRef.current.getSource('openfreemap')) {
|
||||
mapRef.current.addSource('openfreemap', {
|
||||
type: 'vector',
|
||||
url: 'https://tiles.openfreemap.org/planet',
|
||||
});
|
||||
}
|
||||
if (!mapRef.current.getLayer('3d-buildings')) {
|
||||
const layers = mapRef.current.getStyle().layers;
|
||||
let labelLayerId;
|
||||
for (let i = 0; i < layers.length; i++) {
|
||||
if (layers[i].type === 'symbol' && layers[i].layout?.['text-field']) {
|
||||
labelLayerId = layers[i].id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
mapRef.current.addLayer(
|
||||
{
|
||||
id: '3d-buildings',
|
||||
source: 'openfreemap',
|
||||
'source-layer': 'building',
|
||||
type: 'fill-extrusion',
|
||||
minzoom: 15,
|
||||
filter: ['!=', ['get', 'hide_3d'], true],
|
||||
paint: {
|
||||
'fill-extrusion-color': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['get', 'render_height'],
|
||||
0,
|
||||
'lightgray',
|
||||
200,
|
||||
'royalblue',
|
||||
400,
|
||||
'lightblue',
|
||||
],
|
||||
'fill-extrusion-height': ['interpolate', ['linear'], ['zoom'], 15, 0, 16, ['get', 'render_height']],
|
||||
'fill-extrusion-base': ['case', ['>=', ['get', 'zoom'], 16], ['get', 'render_min_height'], 0],
|
||||
'fill-extrusion-opacity': 0.6,
|
||||
},
|
||||
},
|
||||
labelLayerId,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (mapRef.current.getLayer('3d-buildings')) {
|
||||
mapRef.current.removeLayer('3d-buildings');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
add3dLayer();
|
||||
}, [show3dBuildings, style]);
|
||||
|
||||
// Handle pitch for 3D
|
||||
useEffect(() => {
|
||||
if (!mapRef.current) return;
|
||||
mapRef.current.setPitch(show3dBuildings ? 45 : 0);
|
||||
}, [show3dBuildings]);
|
||||
|
||||
return <div ref={mapContainerRef} className="map-container" />;
|
||||
}
|
||||
45
ui/src/components/map/Map.less
Normal file
45
ui/src/components/map/Map.less
Normal file
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
.map-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Fix Mapbox Draw cursors for MapLibre GL compatibility */
|
||||
.maplibregl-map.mouse-pointer .maplibregl-canvas-container.maplibregl-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.maplibregl-map.mouse-move .maplibregl-canvas-container.maplibregl-interactive {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.maplibregl-map.mouse-add .maplibregl-canvas-container.maplibregl-interactive {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.maplibregl-map.mouse-move.mode-direct_select .maplibregl-canvas-container.maplibregl-interactive {
|
||||
cursor: grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: -webkit-grab;
|
||||
}
|
||||
|
||||
.maplibregl-map.mode-direct_select.feature-vertex.mouse-move .maplibregl-canvas-container.maplibregl-interactive {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.maplibregl-map.mode-direct_select.feature-midpoint.mouse-pointer .maplibregl-canvas-container.maplibregl-interactive {
|
||||
cursor: cell;
|
||||
}
|
||||
|
||||
.maplibregl-map.mode-direct_select.feature-feature.mouse-move .maplibregl-canvas-container.maplibregl-interactive {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.maplibregl-map.mode-static.mouse-pointer .maplibregl-canvas-container.maplibregl-interactive {
|
||||
cursor: grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: -webkit-grab;
|
||||
}
|
||||
177
ui/src/components/map/MapDrawingExtension.js
Normal file
177
ui/src/components/map/MapDrawingExtension.js
Normal file
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import MapboxDraw from '@mapbox/mapbox-gl-draw';
|
||||
|
||||
const drawStyles = [
|
||||
{
|
||||
id: 'gl-draw-polygon-fill-inactive',
|
||||
type: 'fill',
|
||||
filter: ['all', ['==', 'active', 'false'], ['==', '$type', 'Polygon'], ['!=', 'mode', 'static']],
|
||||
paint: { 'fill-color': '#3bb2d0', 'fill-outline-color': '#3bb2d0', 'fill-opacity': 0.1 },
|
||||
},
|
||||
{
|
||||
id: 'gl-draw-polygon-fill-active',
|
||||
type: 'fill',
|
||||
filter: ['all', ['==', 'active', 'true'], ['==', '$type', 'Polygon']],
|
||||
paint: { 'fill-color': '#fbb03b', 'fill-outline-color': '#fbb03b', 'fill-opacity': 0.1 },
|
||||
},
|
||||
{
|
||||
id: 'gl-draw-polygon-midpoint',
|
||||
type: 'circle',
|
||||
filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'midpoint']],
|
||||
paint: { 'circle-radius': 3, 'circle-color': '#fbb03b' },
|
||||
},
|
||||
{
|
||||
id: 'gl-draw-polygon-stroke-inactive',
|
||||
type: 'line',
|
||||
filter: ['all', ['==', 'active', 'false'], ['==', '$type', 'Polygon'], ['!=', 'mode', 'static']],
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
paint: { 'line-color': '#3bb2d0', 'line-width': 2 },
|
||||
},
|
||||
{
|
||||
id: 'gl-draw-polygon-stroke-active',
|
||||
type: 'line',
|
||||
filter: ['all', ['==', 'active', 'true'], ['==', '$type', 'Polygon']],
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
paint: { 'line-color': '#fbb03b', 'line-dasharray': [0.2, 2], 'line-width': 2 },
|
||||
},
|
||||
{
|
||||
id: 'gl-draw-line-inactive',
|
||||
type: 'line',
|
||||
filter: ['all', ['==', 'active', 'false'], ['==', '$type', 'LineString'], ['!=', 'mode', 'static']],
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
paint: { 'line-color': '#3bb2d0', 'line-width': 2 },
|
||||
},
|
||||
{
|
||||
id: 'gl-draw-line-active',
|
||||
type: 'line',
|
||||
filter: ['all', ['==', '$type', 'LineString'], ['==', 'active', 'true']],
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
paint: { 'line-color': '#fbb03b', 'line-dasharray': [0.2, 2], 'line-width': 2 },
|
||||
},
|
||||
{
|
||||
id: 'gl-draw-polygon-and-line-vertex-stroke-inactive',
|
||||
type: 'circle',
|
||||
filter: ['all', ['==', 'meta', 'vertex'], ['==', '$type', 'Point'], ['!=', 'mode', 'static']],
|
||||
paint: { 'circle-radius': 5, 'circle-color': '#fff' },
|
||||
},
|
||||
{
|
||||
id: 'gl-draw-polygon-and-line-vertex-inactive',
|
||||
type: 'circle',
|
||||
filter: ['all', ['==', 'meta', 'vertex'], ['==', '$type', 'Point'], ['!=', 'mode', 'static']],
|
||||
paint: { 'circle-radius': 3, 'circle-color': '#fbb03b' },
|
||||
},
|
||||
{
|
||||
id: 'gl-draw-point-point-stroke-inactive',
|
||||
type: 'circle',
|
||||
filter: [
|
||||
'all',
|
||||
['==', 'active', 'false'],
|
||||
['==', '$type', 'Point'],
|
||||
['==', 'meta', 'feature'],
|
||||
['!=', 'mode', 'static'],
|
||||
],
|
||||
paint: { 'circle-radius': 5, 'circle-opacity': 1, 'circle-color': '#fff' },
|
||||
},
|
||||
{
|
||||
id: 'gl-draw-point-inactive',
|
||||
type: 'circle',
|
||||
filter: [
|
||||
'all',
|
||||
['==', 'active', 'false'],
|
||||
['==', '$type', 'Point'],
|
||||
['==', 'meta', 'feature'],
|
||||
['!=', 'mode', 'static'],
|
||||
],
|
||||
paint: { 'circle-radius': 3, 'circle-color': '#3bb2d0' },
|
||||
},
|
||||
{
|
||||
id: 'gl-draw-point-stroke-active',
|
||||
type: 'circle',
|
||||
filter: ['all', ['==', '$type', 'Point'], ['==', 'active', 'true'], ['!=', 'meta', 'midpoint']],
|
||||
paint: { 'circle-radius': 7, 'circle-color': '#fff' },
|
||||
},
|
||||
{
|
||||
id: 'gl-draw-point-active',
|
||||
type: 'circle',
|
||||
filter: ['all', ['==', '$type', 'Point'], ['!=', 'meta', 'midpoint'], ['==', 'active', 'true']],
|
||||
paint: { 'circle-radius': 5, 'circle-color': '#fbb03b' },
|
||||
},
|
||||
{
|
||||
id: 'gl-draw-polygon-fill-static',
|
||||
type: 'fill',
|
||||
filter: ['all', ['==', 'mode', 'static'], ['==', '$type', 'Polygon']],
|
||||
paint: { 'fill-color': '#404040', 'fill-outline-color': '#404040', 'fill-opacity': 0.1 },
|
||||
},
|
||||
{
|
||||
id: 'gl-draw-polygon-stroke-static',
|
||||
type: 'line',
|
||||
filter: ['all', ['==', 'mode', 'static'], ['==', '$type', 'Polygon']],
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
paint: { 'line-color': '#404040', 'line-width': 2 },
|
||||
},
|
||||
{
|
||||
id: 'gl-draw-line-static',
|
||||
type: 'line',
|
||||
filter: ['all', ['==', 'mode', 'static'], ['==', '$type', 'LineString']],
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
paint: { 'line-color': '#404040', 'line-width': 2 },
|
||||
},
|
||||
{
|
||||
id: 'gl-draw-point-static',
|
||||
type: 'circle',
|
||||
filter: ['all', ['==', 'mode', 'static'], ['==', '$type', 'Point']],
|
||||
paint: { 'circle-radius': 5, 'circle-color': '#404040' },
|
||||
},
|
||||
];
|
||||
|
||||
export function fixMapboxDrawCompatibility() {
|
||||
MapboxDraw.constants.classes.CANVAS = 'maplibregl-canvas';
|
||||
MapboxDraw.constants.classes.CONTROL_BASE = 'maplibregl-ctrl';
|
||||
MapboxDraw.constants.classes.CONTROL_PREFIX = 'maplibregl-ctrl-';
|
||||
MapboxDraw.constants.classes.CONTROL_GROUP = 'maplibregl-ctrl-group';
|
||||
MapboxDraw.constants.classes.ATTRIBUTION = 'maplibregl-ctrl-attrib';
|
||||
}
|
||||
|
||||
export function addDrawingControl(map) {
|
||||
const draw = new MapboxDraw({
|
||||
displayControlsDefault: false,
|
||||
controls: {
|
||||
polygon: true,
|
||||
trash: true,
|
||||
},
|
||||
styles: drawStyles,
|
||||
});
|
||||
|
||||
map.addControl(draw, 'top-left');
|
||||
return draw;
|
||||
}
|
||||
|
||||
export function setupAreaFilterEventListeners(map, draw, onDrawingChange) {
|
||||
if (!map || !draw) return () => {};
|
||||
|
||||
const handleDrawChange = () => {
|
||||
if (draw) {
|
||||
const data = draw.getAll();
|
||||
if (onDrawingChange) {
|
||||
onDrawingChange(data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
map.on('draw.create', handleDrawChange);
|
||||
map.on('draw.update', handleDrawChange);
|
||||
map.on('draw.delete', handleDrawChange);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
if (map) {
|
||||
map.off('draw.create', handleDrawChange);
|
||||
map.off('draw.update', handleDrawChange);
|
||||
map.off('draw.delete', handleDrawChange);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { useActions, useSelector } from '../../services/state/store';
|
||||
|
||||
import './NewsModal.less';
|
||||
|
||||
const newsImages = import.meta.glob('../../assets/news/*', { eager: true, query: '?url', import: 'default' });
|
||||
const newsMedia = import.meta.glob('../../assets/news/*', { eager: true, query: '?url', import: 'default' });
|
||||
|
||||
const NewsModal = () => {
|
||||
const screenWidth = useScreenWidth();
|
||||
@@ -20,7 +20,7 @@ const NewsModal = () => {
|
||||
const pois = useSelector((state) => state.tracking.pois);
|
||||
const actions = useActions();
|
||||
|
||||
if (newsConfig == null || newsConfig.length === 0 || screenWidth <= 768) {
|
||||
if (newsConfig == null || newsConfig.content == null || newsConfig.content.length === 0 || screenWidth <= 768) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -33,13 +33,20 @@ const NewsModal = () => {
|
||||
),
|
||||
description: (
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
{item.image && newsImages[`../../assets/news/${item.image}`] && (
|
||||
<img
|
||||
src={newsImages[`../../assets/news/${item.image}`]}
|
||||
alt={item.title}
|
||||
style={{ width: '100%', marginBottom: 10, borderRadius: 4 }}
|
||||
/>
|
||||
)}
|
||||
{item.media &&
|
||||
newsMedia[`../../assets/news/${item.media}`] &&
|
||||
(item.media.includes('mp4') ? (
|
||||
<video controls width="500">
|
||||
<source src={newsMedia[`../../assets/news/${item.media}`]} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
) : (
|
||||
<img
|
||||
src={newsMedia[`../../assets/news/${item.media}`]}
|
||||
alt={item.title}
|
||||
style={{ width: '100%', marginBottom: 10, borderRadius: 4 }}
|
||||
/>
|
||||
))}
|
||||
<p dangerouslySetInnerHTML={{ __html: item.text }} />
|
||||
</div>
|
||||
),
|
||||
@@ -61,7 +68,7 @@ const NewsModal = () => {
|
||||
onFinish={() => handleClose(pois.WELCOME_FINISHED)}
|
||||
onSkip={() => handleClose(pois.WELCOME_SKIPPED)}
|
||||
modalProps={{
|
||||
width: '10rem',
|
||||
width: '850px',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -37,6 +37,17 @@ export default function UserTable({ user = [], onUserRemoval, onUserEdit } = {})
|
||||
title: 'Number of jobs',
|
||||
dataIndex: 'numberOfJobs',
|
||||
},
|
||||
{
|
||||
title: 'MCP Token',
|
||||
dataIndex: 'mcpToken',
|
||||
render: (value) => {
|
||||
return (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.85em', wordBreak: 'break-all' }}>
|
||||
{value || '---'}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'tools',
|
||||
|
||||
@@ -3,36 +3,86 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { Collapse, Descriptions } from '@douyinfe/semi-ui-19';
|
||||
import { useState } from 'react';
|
||||
import { Banner, Button, Modal, Tag, Space, Typography, Descriptions, MarkdownRender } from '@douyinfe/semi-ui-19';
|
||||
import { IconAlertCircle, IconArrowRight } from '@douyinfe/semi-icons';
|
||||
import { useSelector } from '../../services/state/store.js';
|
||||
import { MarkdownRender } from '@douyinfe/semi-ui-19';
|
||||
|
||||
import './VersionBanner.less';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function VersionBanner() {
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
|
||||
|
||||
return (
|
||||
<Collapse>
|
||||
<Collapse.Panel header="A new version of Fredy is available" itemKey="1" className="versionBanner">
|
||||
<div className="versionBanner__content">
|
||||
<p>A new version of Fredy is available. Update now to take advantage of the latest features and bug fixes.</p>
|
||||
<Descriptions row size="small">
|
||||
<Descriptions.Item itemKey="Your Version">{versionUpdate.localFredyVersion}</Descriptions.Item>
|
||||
<Descriptions.Item itemKey="Latest Version">{versionUpdate.version}</Descriptions.Item>
|
||||
<Descriptions.Item itemKey="Github Release">
|
||||
<a href={versionUpdate.url} target="_blank" rel="noreferrer">
|
||||
{versionUpdate.url}
|
||||
</a>{' '}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<p>
|
||||
<b>
|
||||
<small>Release Notes</small>
|
||||
</b>
|
||||
</p>
|
||||
<>
|
||||
<Banner
|
||||
className="versionBanner"
|
||||
type="warning"
|
||||
bordered
|
||||
closeIcon={null}
|
||||
description={
|
||||
<div className="versionBanner__bar">
|
||||
<Space spacing={8} align="center">
|
||||
<IconAlertCircle size="small" />
|
||||
<Text strong size="small">
|
||||
New version available
|
||||
</Text>
|
||||
<Tag color="amber" size="small" shape="circle">
|
||||
{versionUpdate.version}
|
||||
</Tag>
|
||||
<Text type="tertiary" size="small">
|
||||
Current: {versionUpdate.localFredyVersion}
|
||||
</Text>
|
||||
</Space>
|
||||
<Button
|
||||
theme="borderless"
|
||||
size="small"
|
||||
icon={<IconArrowRight />}
|
||||
iconPosition="right"
|
||||
onClick={() => setModalVisible(true)}
|
||||
>
|
||||
Release notes
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Modal
|
||||
title={
|
||||
<Space spacing={8} align="center">
|
||||
<Text strong>Fredy {versionUpdate.version}</Text>
|
||||
<Tag color="amber" size="small">
|
||||
New
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
visible={modalVisible}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
width={640}
|
||||
footer={
|
||||
<Space>
|
||||
<Button onClick={() => setModalVisible(false)}>Close</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<IconArrowRight />}
|
||||
iconPosition="right"
|
||||
onClick={() => window.open(versionUpdate.url, '_blank')}
|
||||
>
|
||||
View on GitHub
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Descriptions row size="small" className="versionBanner__details">
|
||||
<Descriptions.Item itemKey="Your Version">{versionUpdate.localFredyVersion}</Descriptions.Item>
|
||||
<Descriptions.Item itemKey="Latest Version">{versionUpdate.version}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<div className="versionBanner__notes">
|
||||
<MarkdownRender raw={versionUpdate.body} />
|
||||
</div>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
.versionBanner {
|
||||
background: rgba(var(--semi-teal-1), 1);
|
||||
margin-bottom: 0 !important;
|
||||
|
||||
&__content {
|
||||
overflow: auto;
|
||||
.semi-banner-body {
|
||||
padding: 6px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__details {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__notes {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,6 +304,20 @@ export const useFredyState = create(
|
||||
throw Exception;
|
||||
}
|
||||
},
|
||||
async setImmoscoutDetails(enabled) {
|
||||
try {
|
||||
await xhrPost('/api/user/settings/immoscout-details', { immoscout_details: enabled });
|
||||
set((state) => ({
|
||||
userSettings: {
|
||||
...state.userSettings,
|
||||
settings: { ...state.userSettings.settings, immoscout_details: enabled },
|
||||
},
|
||||
}));
|
||||
} catch (Exception) {
|
||||
console.error('Error while trying to update immoscout details setting. Error:', Exception);
|
||||
throw Exception;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { Fragment, useState } from 'react';
|
||||
import { Fragment, useState, useCallback } from 'react';
|
||||
|
||||
import NotificationAdapterMutator from './components/notificationAdapter/NotificationAdapterMutator';
|
||||
import NotificationAdapterTable from '../../../components/table/NotificationAdapterTable';
|
||||
import ProviderTable from '../../../components/table/ProviderTable';
|
||||
import ProviderMutator from './components/provider/ProviderMutator';
|
||||
import AreaFilter from './components/areaFilter/AreaFilter';
|
||||
import Headline from '../../../components/headline/Headline';
|
||||
import { useActions, useSelector } from '../../../services/state/store';
|
||||
import { xhrPost } from '../../../services/xhr';
|
||||
@@ -44,6 +45,7 @@ export default function JobMutator() {
|
||||
const defaultNotificationAdapter = sourceJob?.notificationAdapter || [];
|
||||
const defaultEnabled = sourceJob?.enabled ?? true;
|
||||
const defaultShareWithUsers = sourceJob?.shared_with_user ?? [];
|
||||
const defaultSpatialFilter = sourceJob?.spatialFilter || null;
|
||||
|
||||
const [providerToEdit, setProviderToEdit] = useState(null);
|
||||
const [providerCreationVisible, setProviderCreationVisibility] = useState(false);
|
||||
@@ -55,9 +57,15 @@ export default function JobMutator() {
|
||||
const [notificationAdapterData, setNotificationAdapterData] = useState(defaultNotificationAdapter);
|
||||
const [shareWithUsers, setShareWithUsers] = useState(defaultShareWithUsers);
|
||||
const [enabled, setEnabled] = useState(defaultEnabled);
|
||||
const [spatialFilter, setSpatialFilter] = useState(defaultSpatialFilter);
|
||||
const navigate = useNavigate();
|
||||
const actions = useActions();
|
||||
|
||||
// Memoize the spatial filter change handler to prevent map reinitializations
|
||||
const handleSpatialFilterChange = useCallback((data) => {
|
||||
setSpatialFilter(data);
|
||||
}, []);
|
||||
|
||||
const isSavingEnabled = () => {
|
||||
return Boolean(notificationAdapterData.length && providerData.length && name);
|
||||
};
|
||||
@@ -76,6 +84,7 @@ export default function JobMutator() {
|
||||
shareWithUsers,
|
||||
name,
|
||||
blacklist,
|
||||
spatialFilter,
|
||||
enabled,
|
||||
jobId: jobToBeEdit?.id || null,
|
||||
});
|
||||
@@ -206,6 +215,13 @@ export default function JobMutator() {
|
||||
/>
|
||||
</SegmentPart>
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart
|
||||
name="Area Filter"
|
||||
helpText="Define multiple geographic areas on the map to filter listings. Start drawing by clicking on the square symbol in the top left corner of the map. Click on the map to add points of the polygon. Select the first point to close the polygon. After that, click on a free area of the map to apply this polygon (the color will change from yellow to blue). To delete a polygon, select it first and then click on the trash symbol."
|
||||
>
|
||||
<AreaFilter spatialFilter={spatialFilter} onChange={handleSpatialFilterChange} />
|
||||
</SegmentPart>
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart
|
||||
Icon={IconUser}
|
||||
name="Sharing with user"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import Map from '../../../../../components/map/Map.jsx';
|
||||
import './AreaFilter.less';
|
||||
|
||||
export default function AreaFilter({ spatialFilter = null, onChange = null }) {
|
||||
return (
|
||||
<div className="areaFilter">
|
||||
<Map
|
||||
style="STANDARD"
|
||||
show3dBuildings={false}
|
||||
enableDrawing={true}
|
||||
initialSpatialFilter={spatialFilter}
|
||||
onDrawingChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
.areaFilter {
|
||||
height: 50rem;
|
||||
}
|
||||
@@ -27,10 +27,13 @@ const sortAdapter = (a, b) => {
|
||||
const validate = (selectedAdapter) => {
|
||||
const results = [];
|
||||
for (let uiElement of Object.values(selectedAdapter.fields || [])) {
|
||||
if (uiElement.value == null && !uiElement.optional) {
|
||||
if (uiElement.value == null && !uiElement.optional && uiElement.type !== 'boolean') {
|
||||
results.push('All fields are mandatory and must be set.');
|
||||
continue;
|
||||
}
|
||||
if (uiElement.type === 'boolean' && typeof uiElement.value !== 'boolean') {
|
||||
uiElement.value = false;
|
||||
}
|
||||
if (uiElement.type === 'number') {
|
||||
const numberValue = parseFloat(uiElement.value);
|
||||
if (isNaN(numberValue) || numberValue < 0) {
|
||||
@@ -153,12 +156,15 @@ export default function NotificationAdapterMutator({
|
||||
return (
|
||||
<Form key={key}>
|
||||
{uiElement.type === 'boolean' ? (
|
||||
<Switch
|
||||
checked={uiElement.value || false}
|
||||
onChange={(checked) => {
|
||||
setValue(selectedAdapter, uiElement, key, checked);
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<Switch
|
||||
checked={uiElement.value || false}
|
||||
onChange={(checked) => {
|
||||
setValue(selectedAdapter, uiElement, key, checked);
|
||||
}}
|
||||
/>
|
||||
{uiElement.label}
|
||||
</div>
|
||||
) : (
|
||||
<Form.Input
|
||||
style={{ width: '100%' }}
|
||||
@@ -167,6 +173,7 @@ export default function NotificationAdapterMutator({
|
||||
initValue={uiElement.value ?? ''}
|
||||
placeholder={uiElement.label}
|
||||
label={uiElement.label}
|
||||
extraText={uiElement.description}
|
||||
onChange={(value) => {
|
||||
setValue(selectedAdapter, uiElement, key, value);
|
||||
}}
|
||||
|
||||
@@ -20,54 +20,10 @@ import './Map.less';
|
||||
import { xhrDelete } from '../../services/xhr.js';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import ListingDeletionModal from '../../components/ListingDeletionModal.jsx';
|
||||
import Map from '../../components/map/Map.jsx';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const GERMANY_BOUNDS = [
|
||||
[5.866, 47.27], // Southwest coordinates
|
||||
[15.042, 55.059], // Northeast coordinates
|
||||
];
|
||||
|
||||
const STYLES = {
|
||||
STANDARD: 'https://tiles.openfreemap.org/styles/bright',
|
||||
SATELLITE: {
|
||||
version: 8,
|
||||
sources: {
|
||||
'satellite-tiles': {
|
||||
type: 'raster',
|
||||
tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'],
|
||||
tileSize: 256,
|
||||
attribution:
|
||||
'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community',
|
||||
},
|
||||
'satellite-labels': {
|
||||
type: 'raster',
|
||||
tiles: [
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}',
|
||||
],
|
||||
tileSize: 256,
|
||||
attribution: '© Esri',
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: 'satellite-tiles',
|
||||
type: 'raster',
|
||||
source: 'satellite-tiles',
|
||||
minzoom: 0,
|
||||
maxzoom: 19,
|
||||
},
|
||||
{
|
||||
id: 'satellite-labels',
|
||||
type: 'raster',
|
||||
source: 'satellite-labels',
|
||||
minzoom: 0,
|
||||
maxzoom: 19,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default function MapView() {
|
||||
const mapContainer = useRef(null);
|
||||
const map = useRef(null);
|
||||
@@ -136,117 +92,24 @@ export default function MapView() {
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
// Get map instance reference after MapComponent renders
|
||||
useEffect(() => {
|
||||
if (map.current) return;
|
||||
|
||||
map.current = new maplibregl.Map({
|
||||
container: mapContainer.current,
|
||||
style: STYLES[style],
|
||||
center: [10.4515, 51.1657], // Center of Germany
|
||||
zoom: 4,
|
||||
maxBounds: GERMANY_BOUNDS,
|
||||
antialias: true,
|
||||
});
|
||||
|
||||
map.current.addControl(
|
||||
new maplibregl.NavigationControl({
|
||||
showCompass: true,
|
||||
visualizePitch: true,
|
||||
visualizeRoll: true,
|
||||
}),
|
||||
'top-right',
|
||||
);
|
||||
|
||||
map.current.addControl(
|
||||
new maplibregl.GeolocateControl({
|
||||
positionOptions: {
|
||||
enableHighAccuracy: true,
|
||||
},
|
||||
trackUserLocation: true,
|
||||
}),
|
||||
);
|
||||
|
||||
return () => {
|
||||
map.current.remove();
|
||||
};
|
||||
if (mapContainer.current && !map.current) {
|
||||
// Wait for MapComponent to initialize the map
|
||||
const checkMapReady = () => {
|
||||
if (mapContainer.current?.map) {
|
||||
map.current = mapContainer.current.map;
|
||||
} else {
|
||||
setTimeout(checkMapReady, 100);
|
||||
}
|
||||
};
|
||||
checkMapReady();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (map.current) {
|
||||
map.current.setStyle(STYLES[style]);
|
||||
}
|
||||
}, [style]);
|
||||
|
||||
useEffect(() => {
|
||||
if (show3dBuildings && style !== 'STANDARD') {
|
||||
setStyle('STANDARD');
|
||||
}
|
||||
}, [show3dBuildings, style]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map.current) return;
|
||||
|
||||
map.current.setPitch(show3dBuildings ? 45 : 0);
|
||||
}, [show3dBuildings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map.current) return;
|
||||
|
||||
const add3dLayer = () => {
|
||||
if (!map.current || !map.current.isStyleLoaded()) return;
|
||||
if (show3dBuildings) {
|
||||
if (!map.current.getSource('openfreemap')) {
|
||||
map.current.addSource('openfreemap', {
|
||||
type: 'vector',
|
||||
url: 'https://tiles.openfreemap.org/planet',
|
||||
});
|
||||
}
|
||||
if (!map.current.getLayer('3d-buildings')) {
|
||||
const layers = map.current.getStyle().layers;
|
||||
let labelLayerId;
|
||||
for (let i = 0; i < layers.length; i++) {
|
||||
if (layers[i].type === 'symbol' && layers[i].layout?.['text-field']) {
|
||||
labelLayerId = layers[i].id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
map.current.addLayer(
|
||||
{
|
||||
id: '3d-buildings',
|
||||
source: 'openfreemap',
|
||||
'source-layer': 'building',
|
||||
type: 'fill-extrusion',
|
||||
minzoom: 15,
|
||||
filter: ['!=', ['get', 'hide_3d'], true],
|
||||
paint: {
|
||||
'fill-extrusion-color': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['get', 'render_height'],
|
||||
0,
|
||||
'lightgray',
|
||||
200,
|
||||
'royalblue',
|
||||
400,
|
||||
'lightblue',
|
||||
],
|
||||
'fill-extrusion-height': ['interpolate', ['linear'], ['zoom'], 15, 0, 16, ['get', 'render_height']],
|
||||
'fill-extrusion-base': ['case', ['>=', ['get', 'zoom'], 16], ['get', 'render_min_height'], 0],
|
||||
'fill-extrusion-opacity': 0.6,
|
||||
},
|
||||
},
|
||||
labelLayerId,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (map.current.getLayer('3d-buildings')) {
|
||||
map.current.removeLayer('3d-buildings');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
add3dLayer();
|
||||
}, [show3dBuildings, style]);
|
||||
const handleMapReady = (mapInstance) => {
|
||||
map.current = mapInstance;
|
||||
};
|
||||
|
||||
const setMapStyle = (value) => {
|
||||
setStyle(value);
|
||||
@@ -573,7 +436,7 @@ export default function MapView() {
|
||||
description="Keep in mind, only listings with proper adresses are being shown on this map."
|
||||
/>
|
||||
|
||||
<div ref={mapContainer} className="map-container" />
|
||||
<Map mapContainerRef={mapContainer} style={style} show3dBuildings={show3dBuildings} onMapReady={handleMapReady} />
|
||||
<ListingDeletionModal
|
||||
visible={deleteModalVisible}
|
||||
onConfirm={confirmListingDeletion}
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { Divider, Button, AutoComplete, Toast, Banner } from '@douyinfe/semi-ui-19';
|
||||
import { IconSave, IconHome } from '@douyinfe/semi-icons';
|
||||
import { Divider, Button, AutoComplete, Toast, Banner, Switch } from '@douyinfe/semi-ui-19';
|
||||
import { IconSave, IconHome, IconSearch } from '@douyinfe/semi-icons';
|
||||
import { useSelector, useActions, useIsLoading } from '../../services/state/store';
|
||||
import { xhrGet } from '../../services/xhr';
|
||||
import { SegmentPart } from '../../components/segment/SegmentPart';
|
||||
@@ -14,6 +14,7 @@ import debounce from 'lodash/debounce';
|
||||
const UserSettings = () => {
|
||||
const actions = useActions();
|
||||
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
|
||||
const immoscoutDetails = useSelector((state) => state.userSettings.settings.immoscout_details);
|
||||
const [address, setAddress] = useState(homeAddress?.address || '');
|
||||
const [coords, setCoords] = useState(homeAddress?.coords || null);
|
||||
const saving = useIsLoading(actions.userSettings.setHomeAddress);
|
||||
@@ -84,6 +85,33 @@ const UserSettings = () => {
|
||||
</div>
|
||||
</SegmentPart>
|
||||
<Divider />
|
||||
<SegmentPart
|
||||
name="ImmoScout Details"
|
||||
Icon={IconSearch}
|
||||
helpText="When enabled, Fredy will fetch additional details (description, attributes, agent info) for each listing from ImmoScout. This provides richer notifications but makes an extra API call per listing."
|
||||
>
|
||||
<Banner
|
||||
type="warning"
|
||||
description="Enabling this feature significantly increases the number of API requests to ImmoScout. This raises the likelihood of being detected and rate-limited or blocked. Use at your own risk."
|
||||
closeIcon={null}
|
||||
style={{ marginBottom: '12px', maxWidth: '600px' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<Switch
|
||||
checked={!!immoscoutDetails}
|
||||
onChange={async (checked) => {
|
||||
try {
|
||||
await actions.userSettings.setImmoscoutDetails(checked);
|
||||
Toast.success('ImmoScout details setting updated.');
|
||||
} catch {
|
||||
Toast.error('Failed to update setting.');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>Fetch detailed ImmoScout listings</span>
|
||||
</div>
|
||||
</SegmentPart>
|
||||
<Divider />
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<Button icon={<IconSave />} theme="solid" type="primary" onClick={handleSave} loading={saving}>
|
||||
Save Settings
|
||||
|
||||
16
vitest.config.js
Normal file
16
vitest.config.js
Normal file
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['test/**/*.test.js'],
|
||||
testTimeout: 60000,
|
||||
reporters: ['verbose'],
|
||||
},
|
||||
});
|
||||
21
vitest.gh.config.js
Normal file
21
vitest.gh.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { defineConfig, mergeConfig } from 'vitest/config';
|
||||
import base from './vitest.config.js';
|
||||
|
||||
export default mergeConfig(
|
||||
base,
|
||||
defineConfig({
|
||||
test: {
|
||||
exclude: [
|
||||
'**/node_modules/**',
|
||||
'test/provider/immonet.test.js',
|
||||
'test/provider/immobilienDe.test.js',
|
||||
'test/provider/immowelt.test.js',
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user