mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
791822e7c8 | ||
|
|
cdc0cbda2f | ||
|
|
7888c5b340 | ||
|
|
d7f46d6c68 | ||
|
|
1c9d7c9d92 | ||
|
|
bc73de6703 | ||
|
|
568e0abfa1 | ||
|
|
3992a9c81c | ||
|
|
7346075b9d | ||
|
|
8c039f0026 | ||
|
|
a1289acf15 | ||
|
|
8501fc7266 | ||
|
|
4960846cd7 | ||
|
|
3ed17f4442 | ||
|
|
b531a7b77a | ||
|
|
3523057221 | ||
|
|
77311cf39d | ||
|
|
556c0aff35 | ||
|
|
c40d275e52 | ||
|
|
cbf2766783 | ||
|
|
1b39e345b6 | ||
|
|
6ccbdd8afc | ||
|
|
2a30c89eb2 | ||
|
|
4878dc98e3 | ||
|
|
dc2704997d | ||
|
|
e107b0fb00 | ||
|
|
6c08675fee | ||
|
|
34c4de7267 | ||
|
|
b64a118a18 | ||
|
|
03cb4d18cb | ||
|
|
be5c4af3cf |
1
.github/workflows/docker.yml
vendored
1
.github/workflows/docker.yml
vendored
@@ -62,6 +62,7 @@ jobs:
|
||||
- name: Test container with docker compose
|
||||
run: |
|
||||
echo "Starting container with docker compose..."
|
||||
mkdir -p ./db ./conf && chmod 777 ./db ./conf
|
||||
docker compose up --build -d
|
||||
echo "Waiting for container to be ready (60 seconds for start_period)..."
|
||||
sleep 60
|
||||
|
||||
81
Dockerfile
81
Dockerfile
@@ -1,70 +1,55 @@
|
||||
# ================================
|
||||
# Stage 1: Build stage
|
||||
# ================================
|
||||
FROM node:22-alpine AS builder
|
||||
FROM node:22-slim
|
||||
|
||||
WORKDIR /build
|
||||
ARG TARGETARCH
|
||||
|
||||
# Install build dependencies needed for native modules (better-sqlite3)
|
||||
RUN apk add --no-cache python3 make g++
|
||||
# System deps for Chrome for Testing + build tools for native modules (better-sqlite3)
|
||||
# On ARM64 we also install system Chromium (Chrome for Testing has no ARM64 binary)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates fonts-liberation libasound2 \
|
||||
libatk-bridge2.0-0 libatk1.0-0 libcups2 libdbus-1-3 \
|
||||
libdrm2 libgbm1 libgtk-3-0 libnspr4 libnss3 \
|
||||
libx11-xcb1 libxcomposite1 libxdamage1 libxrandr2 xdg-utils \
|
||||
python3 make g++ \
|
||||
&& if [ "$TARGETARCH" = "arm64" ]; then apt-get install -y --no-install-recommends chromium; fi \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& mkdir -p /db /conf /fredy
|
||||
|
||||
WORKDIR /fredy
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
IS_DOCKER=true
|
||||
|
||||
# Copy package files first for better layer caching
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
# Install all dependencies (including devDependencies for building)
|
||||
# Install dependencies and purge build tools (only needed to compile better-sqlite3)
|
||||
RUN yarn config set network-timeout 600000 \
|
||||
&& yarn --frozen-lockfile
|
||||
&& yarn --frozen-lockfile \
|
||||
&& yarn cache clean
|
||||
|
||||
# on arm64 use the system Chromium installed above
|
||||
RUN if [ "$TARGETARCH" != "arm64" ]; then npx puppeteer browsers install chrome; fi
|
||||
|
||||
# Purge build tools now that native modules are compiled
|
||||
RUN apt-get purge -y python3 make g++ \
|
||||
&& apt-get autoremove -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy source files needed for build
|
||||
COPY index.html vite.config.js ./
|
||||
COPY ui ./ui
|
||||
COPY lib ./lib
|
||||
|
||||
# Build frontend assets
|
||||
RUN yarn build:frontend
|
||||
|
||||
# ================================
|
||||
# Stage 2: Production stage
|
||||
# ================================
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /fredy
|
||||
|
||||
# Install Chromium and curl (for healthcheck)
|
||||
# Using Alpine's chromium package which is much smaller
|
||||
RUN apk add --no-cache chromium curl
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
IS_DOCKER=true \
|
||||
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
|
||||
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
||||
|
||||
# Install build dependencies for native modules, then remove them after yarn install
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
RUN apk add --no-cache --virtual .build-deps python3 make g++ \
|
||||
&& yarn config set network-timeout 600000 \
|
||||
&& yarn --frozen-lockfile --production \
|
||||
&& yarn cache clean \
|
||||
&& apk del .build-deps
|
||||
|
||||
# Copy built frontend from builder stage
|
||||
COPY --from=builder /build/ui/public ./ui/public
|
||||
|
||||
# Copy application source (only what's needed at runtime)
|
||||
COPY index.js ./
|
||||
COPY index.html ./
|
||||
COPY lib ./lib
|
||||
|
||||
# Prepare runtime directories and symlinks for data and config
|
||||
RUN mkdir -p /db /conf \
|
||||
&& chown 1000:1000 /db /conf \
|
||||
&& chmod 777 /db /conf \
|
||||
&& ln -s /db /fredy/db \
|
||||
RUN ln -s /db /fredy/db \
|
||||
&& ln -s /conf /fredy/conf
|
||||
|
||||
EXPOSE 9998
|
||||
VOLUME /db
|
||||
VOLUME /conf
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD curl -f http://localhost:9998/ || exit 1
|
||||
|
||||
CMD ["node", "index.js"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,12 +7,72 @@ if [ "$(docker ps -aq -f name=fredy)" ]; then
|
||||
docker rm fredy || true
|
||||
fi
|
||||
|
||||
# On Apple Silicon, force linux/amd64 to match production CI and avoid arm64/x86_64
|
||||
# Chrome mismatch under Rosetta. On native Linux (amd64 or arm64) let Docker pick naturally. That took me fucking 1 hour to figure out.
|
||||
PLATFORM=""
|
||||
if [ "$(uname -m)" = "arm64" ] && [ "$(uname -s)" = "Darwin" ]; then
|
||||
PLATFORM="linux/amd64"
|
||||
fi
|
||||
|
||||
# Build image from local Dockerfile, forcing a fresh build without cache
|
||||
docker build --no-cache -t fredy:local .
|
||||
if [ -n "$PLATFORM" ]; then
|
||||
docker build --no-cache --platform "$PLATFORM" -t fredy:local .
|
||||
else
|
||||
docker build --no-cache -t fredy:local .
|
||||
fi
|
||||
|
||||
# Run container with volumes and port mapping
|
||||
docker run -d --name fredy \
|
||||
-v fredy_conf:/conf \
|
||||
-v fredy_db:/db \
|
||||
-p 9998:9998 \
|
||||
fredy:local
|
||||
if [ -n "$PLATFORM" ]; then
|
||||
docker run -d --name fredy --platform "$PLATFORM" -v fredy_conf:/conf -v fredy_db:/db -p 9998:9998 fredy:local
|
||||
else
|
||||
docker run -d --name fredy -v fredy_conf:/conf -v fredy_db:/db -p 9998:9998 fredy:local
|
||||
fi
|
||||
|
||||
echo "Waiting for app to be ready..."
|
||||
for i in $(seq 1 30); do
|
||||
if docker exec fredy curl -sf http://localhost:9998/ > /dev/null 2>&1; then
|
||||
echo "App is up"
|
||||
break
|
||||
fi
|
||||
if [ "$i" = "30" ]; then
|
||||
echo "App did not come up in time"
|
||||
docker logs fredy
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Verify the DB is readable/writable via the API.
|
||||
# /api/demo is unauthenticated and reads the settings table — if SQLite is broken this returns an error.
|
||||
echo "Testing DB via API (/api/demo)..."
|
||||
DEMO_RESPONSE=$(docker exec fredy curl -sf http://localhost:9998/api/demo 2>&1)
|
||||
if echo "$DEMO_RESPONSE" | grep -q "demoMode"; then
|
||||
echo "DB is readable (got demoMode from /api/demo)"
|
||||
else
|
||||
echo "DB check failed — unexpected response from /api/demo: $DEMO_RESPONSE"
|
||||
docker logs fredy
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify Chrome launches without crashing.
|
||||
# On amd64: Chrome for Testing lives in the puppeteer cache.
|
||||
# On arm64: system Chromium is used instead.
|
||||
echo "Testing Chrome..."
|
||||
CHROME=$(docker exec fredy find /root/.cache/puppeteer /home -name chrome -type f 2>/dev/null | head -1)
|
||||
if [ -z "$CHROME" ]; then
|
||||
CHROME=$(docker exec fredy which chromium 2>/dev/null || true)
|
||||
fi
|
||||
if [ -z "$CHROME" ]; then
|
||||
echo "Chrome/Chromium binary not found"
|
||||
exit 1
|
||||
fi
|
||||
if docker exec fredy "$CHROME" --headless --no-sandbox --disable-gpu --dump-dom https://example.com 2>&1 | grep -q "<html"; then
|
||||
echo "Chrome works"
|
||||
else
|
||||
echo "Chrome failed to render a page"
|
||||
docker exec fredy "$CHROME" --headless --no-sandbox --disable-gpu --dump-dom https://example.com 2>&1 | head -20
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "All checks passed."
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -63,6 +63,7 @@ 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 {(listing:Listing, browser:any)=>Promise<Listing>} [providerConfig.fetchDetails] Optional per-listing detail enrichment. Called in parallel for each new listing after deduplication. Receives the shared browser instance. Must always resolve (never reject).
|
||||
* @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.
|
||||
@@ -92,6 +93,7 @@ class FredyPipelineExecutioner {
|
||||
.then(this._normalize.bind(this))
|
||||
.then(this._filter.bind(this))
|
||||
.then(this._findNew.bind(this))
|
||||
.then(this._fetchDetails.bind(this))
|
||||
.then(this._geocode.bind(this))
|
||||
.then(this._save.bind(this))
|
||||
.then(this._calculateDistance.bind(this))
|
||||
@@ -101,6 +103,32 @@ class FredyPipelineExecutioner {
|
||||
.catch(this._handleError.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Optionally enrich new listings with data from their detail pages.
|
||||
* Only called when the provider config defines a `fetchDetails` function.
|
||||
* Runs all fetches in parallel. Each individual fetch must handle its own errors
|
||||
* and always resolve (never reject) to avoid aborting other listings.
|
||||
*
|
||||
* @param {Listing[]} newListings New listings to enrich.
|
||||
* @returns {Promise<Listing[]>} Resolves with enriched listings.
|
||||
*/
|
||||
async _fetchDetails(newListings) {
|
||||
if (typeof this._providerConfig.fetchDetails !== 'function') {
|
||||
return newListings;
|
||||
}
|
||||
const userId = getJob(this._jobKey)?.userId;
|
||||
const enabledProviders = getUserSettings(userId)?.provider_details ?? [];
|
||||
if (!userId || !Array.isArray(enabledProviders) || !enabledProviders.includes(this._providerId)) {
|
||||
return newListings;
|
||||
}
|
||||
const listingsToEnrich = process.env.NODE_ENV === 'test' ? newListings.slice(0, 1) : newListings;
|
||||
const enriched = [];
|
||||
for (const listing of listingsToEnrich) {
|
||||
enriched.push(await this._providerConfig.fetchDetails(listing, this._browser));
|
||||
}
|
||||
return enriched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Geocode new listings.
|
||||
*
|
||||
|
||||
@@ -20,16 +20,18 @@ import { getDirName } from '../utils.js';
|
||||
import { demoRouter } from './routes/demoRouter.js';
|
||||
import logger from '../services/logger.js';
|
||||
import { listingsRouter } from './routes/listingsRouter.js';
|
||||
import { getSettings } from '../services/storage/settingsStorage.js';
|
||||
import { getSettings, getOrCreateSessionSecret } from '../services/storage/settingsStorage.js';
|
||||
import { dashboardRouter } from './routes/dashboardRouter.js';
|
||||
import { backupRouter } from './routes/backupRouter.js';
|
||||
import { trackingRouter } from './routes/trackingRoute.js';
|
||||
import { registerMcpRoutes } from '../mcp/mcpHttpRoute.js';
|
||||
const service = restana();
|
||||
const staticService = files(path.join(getDirName(), '../ui/public'));
|
||||
const PORT = (await getSettings()).port || 9998;
|
||||
const sessionSecret = await getOrCreateSessionSecret();
|
||||
|
||||
service.use(bodyParser.json());
|
||||
service.use(cookieSession());
|
||||
service.use(cookieSession(sessionSecret));
|
||||
service.use(staticService);
|
||||
service.use('/api/admin', authInterceptor());
|
||||
service.use('/api/jobs', authInterceptor());
|
||||
@@ -56,6 +58,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}`);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,27 @@ import * as hasher from '../../services/security/hash.js';
|
||||
import { trackDemoAccessed } from '../../services/tracking/Tracker.js';
|
||||
import logger from '../../services/logger.js';
|
||||
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||
|
||||
const MAX_LOGIN_ATTEMPTS = 10;
|
||||
const LOGIN_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
|
||||
const loginAttempts = new Map(); // ip -> { count, firstAttempt }
|
||||
|
||||
function getClientIp(req) {
|
||||
const forwarded = req.headers['x-forwarded-for'];
|
||||
return (forwarded ? forwarded.split(',')[0] : req.socket?.remoteAddress) || 'unknown';
|
||||
}
|
||||
|
||||
function isRateLimited(ip) {
|
||||
const now = Date.now();
|
||||
const record = loginAttempts.get(ip);
|
||||
if (!record || now - record.firstAttempt > LOGIN_WINDOW_MS) {
|
||||
loginAttempts.set(ip, { count: 1, firstAttempt: now });
|
||||
return false;
|
||||
}
|
||||
record.count++;
|
||||
return record.count > MAX_LOGIN_ATTEMPTS;
|
||||
}
|
||||
|
||||
const service = restana();
|
||||
const loginRouter = service.newRouter();
|
||||
loginRouter.get('/user', async (req, res) => {
|
||||
@@ -25,6 +46,12 @@ loginRouter.get('/user', async (req, res) => {
|
||||
res.send();
|
||||
});
|
||||
loginRouter.post('/', async (req, res) => {
|
||||
const ip = getClientIp(req);
|
||||
if (isRateLimited(ip)) {
|
||||
logger.error(`Login rate limit exceeded for IP ${ip}`);
|
||||
res.send(429);
|
||||
return;
|
||||
}
|
||||
const settings = await getSettings();
|
||||
const { username, password } = req.body;
|
||||
const user = userStorage.getUsers(true).find((user) => user.username === username);
|
||||
@@ -38,6 +65,8 @@ loginRouter.post('/', async (req, res) => {
|
||||
}
|
||||
|
||||
req.session.currentUser = user.id;
|
||||
req.session.createdAt = Date.now();
|
||||
loginAttempts.delete(ip);
|
||||
userStorage.setLastLoginToNow({ userId: user.id });
|
||||
res.send(200);
|
||||
return;
|
||||
|
||||
@@ -97,9 +97,9 @@ userSettingsRouter.post('/news-hash', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
userSettingsRouter.post('/immoscout-details', async (req, res) => {
|
||||
userSettingsRouter.post('/provider-details', async (req, res) => {
|
||||
const userId = req.session.currentUser;
|
||||
const { immoscout_details } = req.body;
|
||||
const { provider_details } = req.body;
|
||||
|
||||
const globalSettings = await getSettings();
|
||||
if (globalSettings.demoMode) {
|
||||
@@ -108,11 +108,17 @@ userSettingsRouter.post('/immoscout-details', async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(provider_details)) {
|
||||
res.statusCode = 400;
|
||||
res.send({ error: 'provider_details must be an array of provider ids.' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
upsertSettings({ immoscout_details: !!immoscout_details }, userId);
|
||||
upsertSettings({ provider_details }, userId);
|
||||
res.send({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Error updating immoscout details setting', error);
|
||||
logger.error('Error updating provider details setting', error);
|
||||
res.statusCode = 500;
|
||||
res.send({ error: error.message });
|
||||
}
|
||||
|
||||
@@ -5,12 +5,17 @@
|
||||
|
||||
import * as userStorage from '../services/storage/userStorage.js';
|
||||
import cookieSession from 'cookie-session';
|
||||
import { nanoid } from 'nanoid';
|
||||
const SESSION_MAX_AGE = 2 * 60 * 60 * 1000; // 2 hours
|
||||
const unauthorized = (res) => {
|
||||
return res.send(401);
|
||||
};
|
||||
const isUnauthorized = (req) => {
|
||||
return req.session.currentUser == null;
|
||||
if (req.session.currentUser == null) return true;
|
||||
if (Date.now() - req.session.createdAt > SESSION_MAX_AGE) {
|
||||
req.session = null;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const isAdmin = (req) => {
|
||||
if (!isUnauthorized(req)) {
|
||||
@@ -37,12 +42,11 @@ const adminInterceptor = () => {
|
||||
}
|
||||
};
|
||||
};
|
||||
const cookieSession$0 = (userId) => {
|
||||
const cookieSession$0 = (secret) => {
|
||||
return cookieSession({
|
||||
name: 'fredy-admin-session',
|
||||
keys: ['fredy', 'super', 'fancy', 'key', nanoid()],
|
||||
userId,
|
||||
maxAge: 2 * 60 * 60 * 1000, // 2 hours
|
||||
keys: [secret],
|
||||
maxAge: SESSION_MAX_AGE,
|
||||
});
|
||||
};
|
||||
export { cookieSession$0 as cookieSession };
|
||||
|
||||
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');
|
||||
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
|
||||
@@ -5,6 +5,9 @@
|
||||
|
||||
import { buildHash, isOneOf } from '../utils.js';
|
||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||
import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js';
|
||||
import * as cheerio from 'cheerio';
|
||||
import logger from '../services/logger.js';
|
||||
|
||||
let appliedBlackList = [];
|
||||
|
||||
@@ -18,6 +21,51 @@ function parseId(shortenedLink) {
|
||||
return shortenedLink.substring(shortenedLink.lastIndexOf('/') + 1);
|
||||
}
|
||||
|
||||
async function fetchDetails(listing, browser) {
|
||||
try {
|
||||
const html = await puppeteerExtractor(listing.link, null, { browser });
|
||||
if (!html) return listing;
|
||||
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
// Try JSON-LD first
|
||||
let description = null;
|
||||
let address = listing.address;
|
||||
$('script[type="application/ld+json"]').each((_, el) => {
|
||||
if (description) return;
|
||||
try {
|
||||
const data = JSON.parse($(el).text());
|
||||
const nodes = Array.isArray(data) ? data : [data];
|
||||
for (const node of nodes) {
|
||||
if (node.description && !description) description = String(node.description).replace(/\s+/g, ' ').trim();
|
||||
const addr = node.address || node?.mainEntity?.address;
|
||||
if (addr && addr.streetAddress && address === listing.address) {
|
||||
const parts = [addr.streetAddress, addr.postalCode, addr.addressLocality].filter(Boolean);
|
||||
if (parts.length) address = parts.join(' ');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed JSON-LD
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback: common description selectors used by immobilien.de
|
||||
if (!description) {
|
||||
const sel = ['.beschreibung', '.freitext', '.objektbeschreibung', '.description'].find((s) => $(s).length > 0);
|
||||
if (sel) description = $(sel).text().replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
return {
|
||||
...listing,
|
||||
address,
|
||||
description: description || listing.description,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(`Could not fetch immobilien.de detail page for listing '${listing.id}'.`, error?.message || error);
|
||||
return listing;
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(o) {
|
||||
const baseUrl = 'https://www.immobilien.de';
|
||||
const size = o.size || null;
|
||||
@@ -25,8 +73,8 @@ 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 image = baseUrl + o.image;
|
||||
const link = shortLink ? (shortLink.startsWith('http') ? shortLink : baseUrl + shortLink) : baseUrl;
|
||||
const image = o.image ? (o.image.startsWith('http') ? o.image : baseUrl + o.image) : null;
|
||||
const id = buildHash(parseId(shortLink), o.price);
|
||||
return Object.assign(o, { id, price, size, title, address, link, image });
|
||||
}
|
||||
@@ -39,21 +87,22 @@ function applyBlacklist(o) {
|
||||
|
||||
const config = {
|
||||
url: null,
|
||||
crawlContainer: 'a:has(div.list_entry)',
|
||||
crawlContainer: 'a.lr-card',
|
||||
sortByDateParam: 'sort_col=*created_ts&sort_dir=desc',
|
||||
waitForSelector: 'body',
|
||||
waitForSelector: null,
|
||||
crawlFields: {
|
||||
id: '@href', //will be transformed later
|
||||
price: '.immo_preis .label_info',
|
||||
size: '.flaeche .label_info | removeNewline | trim',
|
||||
title: 'h3 span',
|
||||
price: '.lr-card__price-amount | trim',
|
||||
size: '.lr-card__fact:has(.lr-card__fact-label:contains("Fläche")) .lr-card__fact-value | trim',
|
||||
title: '.lr-card__title | trim',
|
||||
description: '.description | trim',
|
||||
link: '@href',
|
||||
address: '.place',
|
||||
image: 'img@src',
|
||||
address: '.lr-card__address span | trim',
|
||||
image: 'img.lr-card__gallery-img@src',
|
||||
},
|
||||
normalize: normalize,
|
||||
normalize,
|
||||
filter: applyBlacklist,
|
||||
fetchDetails,
|
||||
activeTester: checkIfListingIsActive,
|
||||
};
|
||||
export const init = (sourceConfig, blacklist) => {
|
||||
|
||||
@@ -46,9 +46,7 @@ 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, {
|
||||
@@ -68,42 +66,40 @@ async function getListings(url) {
|
||||
}
|
||||
|
||||
const responseBody = await response.json();
|
||||
return Promise.all(
|
||||
responseBody.resultListItems
|
||||
.filter((item) => item.type === 'EXPOSE_RESULT')
|
||||
.map(async (expose) => {
|
||||
const item = expose.item;
|
||||
const [price, size] = item.attributes;
|
||||
const image = item?.titlePicture?.full ?? item?.titlePicture?.preview ?? null;
|
||||
let listing = {
|
||||
id: item.id,
|
||||
price: price?.value,
|
||||
size: size?.value,
|
||||
title: item.title,
|
||||
link: `${metaInformation.baseUrl}expose/${item.id}`,
|
||||
address: item.address?.line,
|
||||
image,
|
||||
};
|
||||
if (currentUserId) {
|
||||
const userSettings = getUserSettings(currentUserId);
|
||||
if (userSettings.immoscout_details) {
|
||||
return await pushDetails(listing);
|
||||
}
|
||||
}
|
||||
return listing;
|
||||
}),
|
||||
);
|
||||
return responseBody.resultListItems
|
||||
.filter((item) => item.type === 'EXPOSE_RESULT')
|
||||
.map((expose) => {
|
||||
const item = expose.item;
|
||||
const [price, size] = item.attributes;
|
||||
const image = item?.titlePicture?.full ?? item?.titlePicture?.preview ?? null;
|
||||
return {
|
||||
id: item.id,
|
||||
price: price?.value,
|
||||
size: size?.value,
|
||||
title: item.title,
|
||||
link: `${metaInformation.baseUrl}expose/${item.id}`,
|
||||
address: item.address?.line,
|
||||
image,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchDetails(listing) {
|
||||
return pushDetails(listing);
|
||||
}
|
||||
|
||||
async function pushDetails(listing) {
|
||||
const detailed = await fetch(`https://api.mobile.immobilienscout24.de/expose/${listing.id}`, {
|
||||
const exposeId = listing.link?.split('/').pop();
|
||||
const detailed = await fetch(`https://api.mobile.immobilienscout24.de/expose/${exposeId}`, {
|
||||
headers: {
|
||||
'User-Agent': 'ImmoScout_27.3_26.0_._',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (!detailed.ok) {
|
||||
logger.error('Error fetching listing details from ImmoScout Mobile API:', detailed.statusText);
|
||||
logger.warn(
|
||||
`Error fetching listing details from ImmoScout Mobile API for id: ${exposeId} Status: ${detailed.statusText}`,
|
||||
);
|
||||
return listing;
|
||||
}
|
||||
const detailBody = await detailed.json();
|
||||
@@ -196,13 +192,13 @@ const config = {
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
getListings: getListings,
|
||||
fetchDetails: fetchDetails,
|
||||
activeTester: isListingActive,
|
||||
};
|
||||
export const init = (sourceConfig, blacklist) => {
|
||||
config.enabled = sourceConfig.enabled;
|
||||
config.url = convertWebToMobile(sourceConfig.url);
|
||||
appliedBlackList = blacklist || [];
|
||||
currentUserId = sourceConfig.userId || null;
|
||||
};
|
||||
export const metaInformation = {
|
||||
name: 'Immoscout',
|
||||
|
||||
@@ -5,9 +5,49 @@
|
||||
|
||||
import { buildHash, isOneOf } from '../utils.js';
|
||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||
import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js';
|
||||
import * as cheerio from 'cheerio';
|
||||
import logger from '../services/logger.js';
|
||||
|
||||
let appliedBlackList = [];
|
||||
|
||||
async function fetchDetails(listing, browser) {
|
||||
try {
|
||||
const html = await puppeteerExtractor(listing.link, null, { browser });
|
||||
if (!html) return listing;
|
||||
|
||||
const $ = cheerio.load(html);
|
||||
const nextDataRaw = $('#__NEXT_DATA__').text();
|
||||
if (!nextDataRaw) return listing;
|
||||
|
||||
const classified = JSON.parse(nextDataRaw)?.props?.pageProps?.classified;
|
||||
if (!classified) return listing;
|
||||
|
||||
const description = (classified.Texts || [])
|
||||
.map((t) => [t.Title, t.Content].filter(Boolean).join('\n'))
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
|
||||
const addr = classified.EstateAddress;
|
||||
let address = listing.address;
|
||||
if (addr) {
|
||||
const street = [addr.Street, addr.HouseNumber].filter(Boolean).join(' ');
|
||||
const cityLine = [addr.ZipCode, addr.District || addr.City].filter(Boolean).join(' ');
|
||||
const full = [street, cityLine].filter(Boolean).join(', ');
|
||||
if (full) address = full;
|
||||
}
|
||||
|
||||
return {
|
||||
...listing,
|
||||
address,
|
||||
description: description || listing.description,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(`Could not fetch immowelt detail page for listing '${listing.id}'.`, error?.message || error);
|
||||
return listing;
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(o) {
|
||||
const id = buildHash(o.id, o.price);
|
||||
return Object.assign(o, { id });
|
||||
@@ -37,6 +77,7 @@ const config = {
|
||||
},
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
fetchDetails: fetchDetails,
|
||||
activeTester: checkIfListingIsActive,
|
||||
};
|
||||
export const init = (sourceConfig, blacklist) => {
|
||||
|
||||
@@ -5,14 +5,151 @@
|
||||
|
||||
import { buildHash, isOneOf } from '../utils.js';
|
||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||
import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js';
|
||||
import logger from '../services/logger.js';
|
||||
import * as cheerio from 'cheerio';
|
||||
|
||||
let appliedBlackList = [];
|
||||
let appliedBlacklistedDistricts = [];
|
||||
|
||||
function toAbsoluteLink(link) {
|
||||
if (!link) return null;
|
||||
return link.startsWith('http') ? link : `https://www.kleinanzeigen.de${link}`;
|
||||
}
|
||||
|
||||
function cleanText(value) {
|
||||
if (value == null) return '';
|
||||
return String(value)
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function buildAddressFromJsonLd(address) {
|
||||
if (!address || typeof address !== 'object') return null;
|
||||
|
||||
const locality = cleanText(address.addressLocality);
|
||||
const region = cleanText(address.addressRegion);
|
||||
const postalCode = cleanText(address.postalCode);
|
||||
const streetAddress = cleanText(address.streetAddress);
|
||||
|
||||
const cityPart = [region, locality].filter(Boolean).join(' - ');
|
||||
const tail = [postalCode, cityPart || locality || region].filter(Boolean).join(' ');
|
||||
const fullAddress = [streetAddress, tail].filter(Boolean).join(', ');
|
||||
|
||||
return fullAddress || null;
|
||||
}
|
||||
|
||||
function flattenJsonLdNodes(node, acc = []) {
|
||||
if (node == null) return acc;
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach((item) => flattenJsonLdNodes(item, acc));
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (typeof node !== 'object') return acc;
|
||||
|
||||
acc.push(node);
|
||||
|
||||
if (Array.isArray(node['@graph'])) {
|
||||
node['@graph'].forEach((item) => flattenJsonLdNodes(item, acc));
|
||||
}
|
||||
|
||||
if (node.mainEntity) {
|
||||
flattenJsonLdNodes(node.mainEntity, acc);
|
||||
}
|
||||
|
||||
if (node.itemOffered) {
|
||||
flattenJsonLdNodes(node.itemOffered, acc);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}
|
||||
|
||||
function extractDetailFromHtml(html) {
|
||||
const $ = cheerio.load(html);
|
||||
const nodes = [];
|
||||
|
||||
// Prefer the rendered postal address block from the detail page because
|
||||
// it contains the street line that is missing from list results.
|
||||
const streetFromDom = cleanText($('#street-address').first().text());
|
||||
const localityFromDom = cleanText($('#viewad-locality').first().text());
|
||||
const domAddress = [streetFromDom, localityFromDom].filter(Boolean).join(' ');
|
||||
|
||||
$('script[type="application/ld+json"]').each((_, element) => {
|
||||
const content = $(element).text();
|
||||
if (!content) return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
flattenJsonLdNodes(parsed, nodes);
|
||||
} catch {
|
||||
// Ignore broken JSON-LD blocks from ads/trackers and keep trying others.
|
||||
}
|
||||
});
|
||||
|
||||
let detailAddress = null;
|
||||
let detailDescription = null;
|
||||
|
||||
if (domAddress) {
|
||||
detailAddress = domAddress;
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
const candidateAddress = buildAddressFromJsonLd(
|
||||
node.address || node?.itemOffered?.address || node?.offers?.address,
|
||||
);
|
||||
if (!detailAddress && candidateAddress) {
|
||||
detailAddress = candidateAddress;
|
||||
}
|
||||
|
||||
const candidateDescription = cleanText(node.description || node?.itemOffered?.description);
|
||||
if (!detailDescription && candidateDescription) {
|
||||
detailDescription = candidateDescription;
|
||||
}
|
||||
|
||||
if (detailAddress && detailDescription) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
detailAddress,
|
||||
detailDescription,
|
||||
};
|
||||
}
|
||||
|
||||
async function enrichListingFromDetails(listing, browser) {
|
||||
const absoluteLink = toAbsoluteLink(listing.link);
|
||||
if (!absoluteLink) return listing;
|
||||
|
||||
try {
|
||||
const html = await puppeteerExtractor(absoluteLink, null, { browser });
|
||||
if (!html) return { ...listing, link: absoluteLink };
|
||||
|
||||
const { detailAddress, detailDescription } = extractDetailFromHtml(html);
|
||||
|
||||
return {
|
||||
...listing,
|
||||
link: absoluteLink,
|
||||
address: detailAddress || listing.address,
|
||||
description: detailDescription || listing.description,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(`Could not fetch Kleinanzeigen detail page for listing '${listing.id}'.`, error?.message || error);
|
||||
return { ...listing, link: absoluteLink };
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDetails(listing, browser) {
|
||||
return enrichListingFromDetails(listing, browser);
|
||||
}
|
||||
|
||||
function normalize(o) {
|
||||
const size = o.size || '--- m²';
|
||||
const id = buildHash(o.id, o.price);
|
||||
const link = `https://www.kleinanzeigen.de${o.link}`;
|
||||
const link = toAbsoluteLink(o.link) || o.link;
|
||||
return Object.assign(o, { id, size, link });
|
||||
}
|
||||
|
||||
@@ -40,12 +177,13 @@ const config = {
|
||||
address: '.aditem-main--top--left | trim | removeNewline',
|
||||
image: 'img@src',
|
||||
},
|
||||
fetchDetails,
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
activeTester: checkIfListingIsActive,
|
||||
};
|
||||
export const metaInformation = {
|
||||
name: 'Ebay Kleinanzeigen',
|
||||
name: 'Kleinanzeigen',
|
||||
baseUrl: 'https://www.kleinanzeigen.de/',
|
||||
id: 'kleinanzeigen',
|
||||
};
|
||||
|
||||
@@ -5,12 +5,60 @@
|
||||
|
||||
import { isOneOf, buildHash } from '../utils.js';
|
||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||
import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js';
|
||||
import * as cheerio from 'cheerio';
|
||||
import logger from '../services/logger.js';
|
||||
|
||||
let appliedBlackList = [];
|
||||
|
||||
async function fetchDetails(listing, browser) {
|
||||
try {
|
||||
const html = await puppeteerExtractor(listing.link, 'body', { browser });
|
||||
|
||||
const $ = cheerio.load(html);
|
||||
const nextDataRaw = $('#__NEXT_DATA__').text;
|
||||
if (!nextDataRaw) return listing;
|
||||
|
||||
const estate = JSON.parse(nextDataRaw)?.props?.pageProps?.estate;
|
||||
if (!estate) return listing;
|
||||
|
||||
const description = (estate.frontendItems || [])
|
||||
.map((item) => {
|
||||
const texts = (item.contents || [])
|
||||
.filter((c) => c.type === 'contentBoxes')
|
||||
.flatMap((c) => c.data || [])
|
||||
.filter((d) => d.type === 'text' && d.content)
|
||||
.map((d) => d.content);
|
||||
if (!texts.length) return null;
|
||||
return [item.label, ...texts].filter(Boolean).join('\n');
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
|
||||
const addr = estate.address;
|
||||
let address = listing.address;
|
||||
if (addr) {
|
||||
const street = [addr.street, addr.streetNumber].filter(Boolean).join(' ');
|
||||
const cityLine = [addr.zip, addr.city].filter(Boolean).join(' ');
|
||||
const full = [street, cityLine].filter(Boolean).join(', ');
|
||||
if (full) address = full;
|
||||
}
|
||||
|
||||
return {
|
||||
...listing,
|
||||
address,
|
||||
description: description || listing.description,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(`Could not fetch Sparkasse detail page for listing '${listing.id}'.`, error?.message || error);
|
||||
return listing;
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(o) {
|
||||
const originalId = o.id.split('/').pop().replace('.html', '');
|
||||
const id = buildHash(originalId, o.price);
|
||||
const size = o.size?.replace(' Wohnfläche', '') ?? null;
|
||||
const size = o.size?.replace(' Wohnfläche', '').replace(' m²', 'm²') ?? null;
|
||||
const title = o.title || 'No title available';
|
||||
const link = o.link != null ? `https://immobilien.sparkasse.de${o.link}` : config.url;
|
||||
return Object.assign(o, { id, size, title, link });
|
||||
@@ -22,20 +70,21 @@ function applyBlacklist(o) {
|
||||
}
|
||||
const config = {
|
||||
url: null,
|
||||
crawlContainer: '.estate-list-item-row',
|
||||
crawlContainer: 'div[data-testid="estate-link"]',
|
||||
sortByDateParam: 'sortBy=date_desc',
|
||||
waitForSelector: 'body',
|
||||
crawlFields: {
|
||||
id: 'div[data-testid="estate-link"] a@href',
|
||||
id: 'a@href',
|
||||
title: 'h3 | trim',
|
||||
price: '.estate-list-price | trim',
|
||||
size: '.estate-mainfact:first-child span | trim',
|
||||
size: '.estate-mainfact span | trim',
|
||||
address: 'h6 | trim',
|
||||
image: '.estate-list-item-image-container img@src',
|
||||
link: 'div[data-testid="estate-link"] a@href',
|
||||
image: 'img@src',
|
||||
link: 'a@href',
|
||||
},
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
fetchDetails,
|
||||
activeTester: (url) => checkIfListingIsActive(url, 'Angebot nicht gefunden'),
|
||||
};
|
||||
export const init = (sourceConfig, blacklist) => {
|
||||
|
||||
@@ -5,9 +5,34 @@
|
||||
|
||||
import { isOneOf, buildHash } from '../utils.js';
|
||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||
import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js';
|
||||
import * as cheerio from 'cheerio';
|
||||
import logger from '../services/logger.js';
|
||||
|
||||
let appliedBlackList = [];
|
||||
|
||||
async function fetchDetails(listing, browser) {
|
||||
try {
|
||||
const html = await puppeteerExtractor(listing.link, null, { browser });
|
||||
if (!html) return listing;
|
||||
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
$('#freitext_0 script').remove();
|
||||
const description = $('#freitext_0').text().replace(/\s+/g, ' ').trim();
|
||||
const address = $('a[href="#map_container"] .section_panel_detail').text().replace(/\s+/g, ' ').trim();
|
||||
|
||||
return {
|
||||
...listing,
|
||||
address: address || listing.address,
|
||||
description: description || listing.description,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(`Could not fetch wgGesucht detail page for listing '${listing.id}'.`, error?.message || error);
|
||||
return listing;
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(o) {
|
||||
const id = buildHash(o.id, o.price);
|
||||
const link = `https://www.wg-gesucht.de${o.link}`;
|
||||
@@ -37,6 +62,7 @@ const config = {
|
||||
},
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
fetchDetails,
|
||||
activeTester: checkIfListingIsActive,
|
||||
};
|
||||
export const init = (sourceConfig, blacklist) => {
|
||||
|
||||
@@ -94,12 +94,34 @@ export async function applyBotPreventionToPage(page, cfg) {
|
||||
// webdriver
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
||||
|
||||
// chrome runtime
|
||||
// chrome runtime — expose loadTimes, csi and app like real Chrome
|
||||
// @ts-ignore
|
||||
if (!window.chrome) {
|
||||
window.chrome = {
|
||||
runtime: {},
|
||||
// @ts-ignore
|
||||
window.chrome = { runtime: {} };
|
||||
}
|
||||
loadTimes: () => ({
|
||||
requestTime: performance.timeOrigin / 1000,
|
||||
startLoadTime: performance.timeOrigin / 1000,
|
||||
commitLoadTime: performance.timeOrigin / 1000 + 0.1,
|
||||
finishDocumentLoadTime: 0,
|
||||
finishLoadTime: 0,
|
||||
firstPaintTime: 0,
|
||||
firstPaintAfterLoadTime: 0,
|
||||
navigationType: 'Other',
|
||||
wasFetchedViaSpdy: false,
|
||||
wasNpnNegotiated: false,
|
||||
npnNegotiatedProtocol: '',
|
||||
wasAlternateProtocolAvailable: false,
|
||||
connectionInfo: 'http/1.1',
|
||||
}),
|
||||
// @ts-ignore
|
||||
csi: () => ({ startE: performance.timeOrigin, onloadT: Date.now(), pageT: performance.now(), tran: 15 }),
|
||||
app: {
|
||||
isInstalled: false,
|
||||
InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' },
|
||||
RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' },
|
||||
},
|
||||
};
|
||||
|
||||
// languages
|
||||
// @ts-ignore
|
||||
@@ -107,23 +129,38 @@ export async function applyBotPreventionToPage(page, cfg) {
|
||||
get: () => (window.localStorage.getItem('__LANGS__') || 'de-DE,de').split(','),
|
||||
});
|
||||
|
||||
// plugins
|
||||
// plugins — mimic real Chrome's built-in PDF plugins
|
||||
const makePlugin = (name, filename, description, mimeType, mimeTypeSuffix) => {
|
||||
const mimeObj = { type: mimeType, suffixes: mimeTypeSuffix, description, enabledPlugin: null };
|
||||
const plugin = { name, filename, description, length: 1, 0: mimeObj };
|
||||
mimeObj.enabledPlugin = plugin;
|
||||
return plugin;
|
||||
};
|
||||
const fakePlugins = [
|
||||
makePlugin('PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format', 'application/pdf', 'pdf'),
|
||||
makePlugin('Chrome PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format', 'application/pdf', 'pdf'),
|
||||
makePlugin('Chromium PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format', 'application/pdf', 'pdf'),
|
||||
makePlugin(
|
||||
'Microsoft Edge PDF Viewer',
|
||||
'internal-pdf-viewer',
|
||||
'Portable Document Format',
|
||||
'application/pdf',
|
||||
'pdf',
|
||||
),
|
||||
makePlugin('WebKit built-in PDF', 'internal-pdf-viewer', 'Portable Document Format', 'application/pdf', 'pdf'),
|
||||
];
|
||||
// @ts-ignore
|
||||
Object.defineProperty(navigator, 'plugins', {
|
||||
get: () => [{}, {}, {}],
|
||||
});
|
||||
Object.defineProperty(navigator, 'plugins', { get: () => fakePlugins });
|
||||
// @ts-ignore
|
||||
Object.defineProperty(navigator, 'mimeTypes', { get: () => [fakePlugins[0][0]] });
|
||||
|
||||
// platform and concurrency hints
|
||||
// @ts-ignore
|
||||
Object.defineProperty(navigator, 'platform', { get: () => 'Win32' });
|
||||
// @ts-ignore
|
||||
if (typeof navigator.hardwareConcurrency === 'number' && navigator.hardwareConcurrency < 2) {
|
||||
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 4 });
|
||||
}
|
||||
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
|
||||
// @ts-ignore
|
||||
if (typeof navigator.deviceMemory === 'number' && navigator.deviceMemory < 2) {
|
||||
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
|
||||
}
|
||||
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
|
||||
|
||||
// userAgentData (Client Hints)
|
||||
try {
|
||||
@@ -236,6 +273,21 @@ export async function applyBotPreventionToPage(page, cfg) {
|
||||
} catch {
|
||||
//noop
|
||||
}
|
||||
|
||||
// document.hasFocus — headless returns false; real active tabs return true
|
||||
try {
|
||||
document.hasFocus = () => true;
|
||||
} catch {
|
||||
//noop
|
||||
}
|
||||
|
||||
// screen color depth — normalise in case headless reports 0
|
||||
try {
|
||||
Object.defineProperty(screen, 'colorDepth', { get: () => 24 });
|
||||
Object.defineProperty(screen, 'pixelDepth', { get: () => 24 });
|
||||
} catch {
|
||||
//noop
|
||||
}
|
||||
} catch {
|
||||
//noop
|
||||
}
|
||||
@@ -273,6 +325,8 @@ export async function applyPostNavigationHumanSignals(page, cfg) {
|
||||
const my = Math.floor(vh * (0.3 + Math.random() * 0.4));
|
||||
await page.mouse.move(mx, my, { steps: 10 + Math.floor(Math.random() * 10) });
|
||||
await page.mouse.wheel({ deltaY: 100 + Math.floor(Math.random() * 200) });
|
||||
await new Promise((res) => setTimeout(res, 150 + Math.floor(Math.random() * 200)));
|
||||
await page.mouse.wheel({ deltaY: -(30 + Math.floor(Math.random() * 60)) });
|
||||
} catch {
|
||||
// ignore if mouse is unavailable
|
||||
}
|
||||
|
||||
@@ -47,12 +47,17 @@ export async function launchBrowser(url, options) {
|
||||
removeUserDataDir = true;
|
||||
}
|
||||
|
||||
// On ARM64 Docker, Chrome for Testing has no native binary — use system Chromium instead.
|
||||
const executablePath =
|
||||
options?.executablePath ||
|
||||
(process.arch === 'arm64' && process.env.IS_DOCKER === 'true' ? '/usr/bin/chromium' : undefined);
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: options?.puppeteerHeadless ?? true,
|
||||
args: launchArgs,
|
||||
timeout: options?.puppeteerTimeout || 45_000,
|
||||
userDataDir,
|
||||
executablePath: options?.executablePath,
|
||||
executablePath,
|
||||
});
|
||||
|
||||
browser.__fredy_userDataDir = userDataDir;
|
||||
@@ -105,6 +110,7 @@ export default async function execute(url, waitForSelector, options) {
|
||||
// Navigation
|
||||
const response = await page.goto(url, {
|
||||
waitUntil: options?.waitUntil || 'domcontentloaded',
|
||||
timeout: options?.puppeteerTimeout || 60000,
|
||||
});
|
||||
|
||||
// Optionally wait and add subtle human-like interactions
|
||||
|
||||
@@ -79,6 +79,8 @@ const PARAM_NAME_MAP = {
|
||||
price: 'price',
|
||||
constructionyear: 'constructionyear',
|
||||
apartmenttypes: 'apartmenttypes',
|
||||
buildingtypes: 'buildingtypes',
|
||||
ground: 'ground',
|
||||
pricetype: 'pricetype',
|
||||
floor: 'floor',
|
||||
geocodes: 'geocodes',
|
||||
@@ -98,6 +100,7 @@ const EQUIPMENT_MAP = {
|
||||
guesttoilet: 'guestToilet',
|
||||
balcony: 'balcony',
|
||||
handicappedaccessible: 'handicappedAccessible',
|
||||
lodgerflat: 'lodgerflat',
|
||||
};
|
||||
|
||||
const REAL_ESTATE_TYPE = {
|
||||
@@ -107,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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
/**
|
||||
|
||||
@@ -198,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;
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
17
lib/services/storage/migrations/sql/13.provider-details.js
Normal file
17
lib/services/storage/migrations/sql/13.provider-details.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
// We have moved the previous immoscout_details setting to provider_details and enable this by default
|
||||
// We also set it to false per default as this is increasing the chance to be detected as a bot by a lot
|
||||
export function up(db) {
|
||||
db.exec(`
|
||||
UPDATE settings
|
||||
SET name = 'provider_details', value = false
|
||||
WHERE name = 'immoscout_details'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM settings WHERE name = 'provider_details'
|
||||
);
|
||||
`);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
// Convert provider_details from a boolean to an array of provider id strings.
|
||||
// Users will re-configure which providers they want to fetch details from.
|
||||
export function up(db) {
|
||||
const row = db.prepare("SELECT value FROM settings WHERE name = 'provider_details'").get();
|
||||
if (row) {
|
||||
db.prepare("UPDATE settings SET value = ? WHERE name = 'provider_details'").run(JSON.stringify([]));
|
||||
} else {
|
||||
db.prepare("INSERT INTO settings (name, value) VALUES ('provider_details', ?)").run(JSON.stringify([]));
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,19 @@ export async function getSettings() {
|
||||
return cachedSettingsConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a persistent session signing secret.
|
||||
* Generated once and stored in the settings table under the key 'session_secret'.
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function getOrCreateSessionSecret() {
|
||||
const settings = await getSettings();
|
||||
if (settings.session_secret) return settings.session_secret;
|
||||
const secret = nanoid(64);
|
||||
upsertSettings({ session_secret: secret });
|
||||
return secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert settings rows.
|
||||
* - Accepts an object map of name -> value, or an entry {name, value}.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
58
package.json
58
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fredy",
|
||||
"version": "19.6.0",
|
||||
"version": "20.2.0",
|
||||
"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,66 +61,65 @@
|
||||
"Firefox ESR"
|
||||
],
|
||||
"dependencies": {
|
||||
"@douyinfe/semi-icons": "^2.92.2",
|
||||
"@douyinfe/semi-ui": "2.92.2",
|
||||
"@douyinfe/semi-ui-19": "^2.92.2",
|
||||
"@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.29.0",
|
||||
"@sendgrid/mail": "8.1.6",
|
||||
"@vitejs/plugin-react": "5.1.4",
|
||||
"adm-zip": "^0.5.16",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"@turf/boolean-point-in-polygon": "^7.3.4",
|
||||
"@vitejs/plugin-react": "6.0.1",
|
||||
"adm-zip": "^0.5.17",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"body-parser": "2.2.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"cheerio": "^1.2.0",
|
||||
"@turf/boolean-point-in-polygon": "^7.3.4",
|
||||
"cookie-session": "2.1.1",
|
||||
"handlebars": "4.7.8",
|
||||
"lodash": "4.17.23",
|
||||
"maplibre-gl": "^5.19.0",
|
||||
"nanoid": "5.1.6",
|
||||
"handlebars": "4.7.9",
|
||||
"maplibre-gl": "^5.22.0",
|
||||
"nanoid": "5.1.7",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "3.3.2",
|
||||
"node-mailjet": "6.0.11",
|
||||
"nodemailer": "^8.0.5",
|
||||
"p-throttle": "^8.1.0",
|
||||
"package-up": "^5.0.0",
|
||||
"puppeteer": "^24.38.0",
|
||||
"puppeteer": "^24.40.0",
|
||||
"puppeteer-extra": "^3.3.6",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||
"query-string": "9.3.1",
|
||||
"react": "19.2.4",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "19.2.4",
|
||||
"react-range-slider-input": "^3.3.2",
|
||||
"react-router": "7.13.1",
|
||||
"react-router-dom": "7.13.1",
|
||||
"resend": "^6.9.3",
|
||||
"react-range-slider-input": "^3.3.5",
|
||||
"react-router": "7.14.0",
|
||||
"react-router-dom": "7.14.0",
|
||||
"resend": "^6.10.0",
|
||||
"restana": "5.1.0",
|
||||
"semver": "^7.7.4",
|
||||
"serve-static": "2.2.1",
|
||||
"slack": "11.0.2",
|
||||
"vite": "7.3.1",
|
||||
"vite": "8.0.7",
|
||||
"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.3",
|
||||
"eslint": "10.2.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"esmock": "2.7.3",
|
||||
"globals": "^17.4.0",
|
||||
"history": "5.3.0",
|
||||
"husky": "9.1.7",
|
||||
"less": "4.5.1",
|
||||
"lint-staged": "16.3.2",
|
||||
"mocha": "11.7.5",
|
||||
"less": "4.6.4",
|
||||
"lint-staged": "16.4.0",
|
||||
"nodemon": "^3.1.14",
|
||||
"prettier": "3.8.1"
|
||||
"prettier": "3.8.1",
|
||||
"vitest": "^4.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('./'));
|
||||
@@ -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 { mockFredy } from './utils.js';
|
||||
import * as mockStore from './mocks/mockStore.js';
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('Issue reproduction: listings filtered by similarity or area should be
|
||||
// Might throw NoNewListingsWarning if all are filtered out
|
||||
}
|
||||
|
||||
expect(mockStore.deletedIds).to.include('1');
|
||||
expect(mockStore.deletedIds).toContain('1');
|
||||
});
|
||||
|
||||
it('should call deleteListingsById when listings are filtered by area', async () => {
|
||||
@@ -84,6 +84,6 @@ describe('Issue reproduction: listings filtered by similarity or area should be
|
||||
// Might throw NoNewListingsWarning if all are filtered out
|
||||
}
|
||||
|
||||
expect(mockStore.deletedIds).to.include('2');
|
||||
expect(mockStore.deletedIds).toContain('2');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
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()', () => {
|
||||
provider.init(providerConfig.einsAImmobilien, [], []);
|
||||
it('should test einsAImmobilien provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
return await new Promise((resolve) => {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const fredy = new Fredy(
|
||||
provider.config,
|
||||
null,
|
||||
@@ -23,22 +23,26 @@ describe('#einsAImmobilien testsuite()', () => {
|
||||
similarityCache,
|
||||
);
|
||||
fredy.execute().then((listings) => {
|
||||
expect(listings).to.be.a('array');
|
||||
if (listings == null || listings.length === 0) {
|
||||
reject('Listings is empty!');
|
||||
return;
|
||||
}
|
||||
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,36 +6,69 @@
|
||||
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, vi } from 'vitest';
|
||||
import * as provider from '../../lib/provider/immobilienDe.js';
|
||||
import * as mockStore from '../mocks/mockStore.js';
|
||||
|
||||
describe('#immobilien.de testsuite()', () => {
|
||||
provider.init(providerConfig.immobilienDe, [], []);
|
||||
it('should test immobilien.de provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
return await new Promise((resolve) => {
|
||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'test1', similarityCache);
|
||||
fredy.execute().then((listing) => {
|
||||
expect(listing).to.be.a('array');
|
||||
const notificationObj = get();
|
||||
expect(notificationObj).to.be.a('object');
|
||||
expect(notificationObj.serviceName).to.equal('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');
|
||||
/** 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;
|
||||
});
|
||||
resolve();
|
||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'test1', similarityCache);
|
||||
const listing = await fredy.execute();
|
||||
|
||||
if (listing == null || listing.length === 0) {
|
||||
throw new Error('Listings is empty!');
|
||||
}
|
||||
|
||||
expect(listing).toBeInstanceOf(Array);
|
||||
const notificationObj = get();
|
||||
expect(notificationObj).toBeTypeOf('object');
|
||||
expect(notificationObj.serviceName).toBe('immobilienDe');
|
||||
notificationObj.payload.forEach((notify) => {
|
||||
/** check the actual structure **/
|
||||
expect(notify.id).toBeTypeOf('string');
|
||||
expect(notify.price).toBeTypeOf('string');
|
||||
expect(notify.size).toBeTypeOf('string');
|
||||
expect(notify.title).toBeTypeOf('string');
|
||||
expect(notify.link).toBeTypeOf('string');
|
||||
expect(notify.address).toBeTypeOf('string');
|
||||
/** check the values if possible **/
|
||||
expect(notify.price).toContain('€');
|
||||
expect(notify.size).toContain('m²');
|
||||
expect(notify.title).not.toBe('');
|
||||
expect(notify.link).toContain('https://www.immobilien.de');
|
||||
expect(notify.address).not.toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with provider_details enabled', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] });
|
||||
vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should enrich listings with details', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
provider.init(providerConfig.immobilienDe, [], []);
|
||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'test1', {
|
||||
checkAndAddEntry: () => false,
|
||||
});
|
||||
const listings = await fredy.execute();
|
||||
if (listings == null) return;
|
||||
expect(listings).toBeInstanceOf(Array);
|
||||
listings.forEach((listing) => {
|
||||
expect(listing.link).toContain('https://www.immobilien.de');
|
||||
expect(listing.address).toBeTypeOf('string');
|
||||
expect(listing.address).not.toBe('');
|
||||
// description may be null if selectors don't match yet — falls back gracefully
|
||||
if (listing.description != null) {
|
||||
expect(listing.description).toBeTypeOf('string');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,38 +3,69 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { expect } from 'chai';
|
||||
import { expect, vi } from 'vitest';
|
||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||
import { mockFredy, providerConfig } from '../utils.js';
|
||||
import { get } from '../mocks/mockNotification.js';
|
||||
import * as provider from '../../lib/provider/immoscout.js';
|
||||
import * as mockStore from '../mocks/mockStore.js';
|
||||
|
||||
describe('#immoscout provider testsuite()', () => {
|
||||
provider.init(providerConfig.immoscout, [], []);
|
||||
it('should test immoscout provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
return await new Promise((resolve) => {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, '', similarityCache);
|
||||
fredy.execute().then((listings) => {
|
||||
expect(listings).to.be.a('array');
|
||||
if (listings == null || listings.length === 0) {
|
||||
reject('Listings is empty!');
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with provider_details enabled', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] });
|
||||
vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should enrich listings with details', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
provider.init(providerConfig.immoscout, [], []);
|
||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, '', {
|
||||
checkAndAddEntry: () => false,
|
||||
});
|
||||
const listings = await fredy.execute();
|
||||
expect(listings).toBeInstanceOf(Array);
|
||||
listings.forEach((listing) => {
|
||||
expect(listing.description).toBeTypeOf('string');
|
||||
expect(listing.description).not.toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,31 +6,36 @@
|
||||
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()', () => {
|
||||
provider.init(providerConfig.immoswp, [], []);
|
||||
it('should test immoswp provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
return await new Promise((resolve) => {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immoswp', similarityCache);
|
||||
fredy.execute().then((listing) => {
|
||||
expect(listing).to.be.a('array');
|
||||
if (listing == null || listing.length === 0) {
|
||||
reject('Listings is empty!');
|
||||
return;
|
||||
}
|
||||
|
||||
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,8 +6,9 @@
|
||||
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, vi } from 'vitest';
|
||||
import * as provider from '../../lib/provider/immowelt.js';
|
||||
import * as mockStore from '../mocks/mockStore.js';
|
||||
|
||||
describe('#immowelt testsuite()', () => {
|
||||
it('should test immowelt provider', async () => {
|
||||
@@ -17,24 +18,58 @@ describe('#immowelt testsuite()', () => {
|
||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immowelt', similarityCache);
|
||||
const listing = await fredy.execute();
|
||||
|
||||
expect(listing).to.be.a('array');
|
||||
if (listing == null || listing.length === 0) {
|
||||
throw new Error('Listings is empty!');
|
||||
}
|
||||
|
||||
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('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with provider_details enabled', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] });
|
||||
vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should enrich listings with details', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
provider.init(providerConfig.immowelt, [], []);
|
||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immowelt', {
|
||||
checkAndAddEntry: () => false,
|
||||
});
|
||||
const listings = await fredy.execute();
|
||||
expect(listings).toBeInstanceOf(Array);
|
||||
listings.forEach((listing) => {
|
||||
expect(listing.link).toContain('https://www.immowelt.de');
|
||||
expect(listing.address).toBeTypeOf('string');
|
||||
expect(listing.address).not.toBe('');
|
||||
// description is enriched from the detail page; falls back gracefully if blocked
|
||||
if (listing.description != null) {
|
||||
expect(listing.description).toBeTypeOf('string');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,14 +6,15 @@
|
||||
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, vi } from 'vitest';
|
||||
import * as provider from '../../lib/provider/kleinanzeigen.js';
|
||||
import * as mockStore from '../mocks/mockStore.js';
|
||||
|
||||
describe('#kleinanzeigen testsuite()', () => {
|
||||
it('should test kleinanzeigen provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
provider.init(providerConfig.kleinanzeigen, [], []);
|
||||
return await new Promise((resolve) => {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const fredy = new Fredy(
|
||||
provider.config,
|
||||
null,
|
||||
@@ -23,23 +24,56 @@ describe('#kleinanzeigen testsuite()', () => {
|
||||
similarityCache,
|
||||
);
|
||||
fredy.execute().then((listing) => {
|
||||
expect(listing).to.be.a('array');
|
||||
if (listing == null || listing.length === 0) {
|
||||
reject('Listings is empty!');
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with provider_details enabled', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] });
|
||||
vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should enrich listings with details', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
provider.init(providerConfig.kleinanzeigen, [], []);
|
||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'kleinanzeigen', {
|
||||
checkAndAddEntry: () => false,
|
||||
});
|
||||
const listings = await fredy.execute();
|
||||
expect(listings).toBeInstanceOf(Array);
|
||||
listings.forEach((listing) => {
|
||||
expect(listing.link).toContain('https://www.kleinanzeigen.de');
|
||||
expect(listing.address).toBeTypeOf('string');
|
||||
expect(listing.address).not.toBe('');
|
||||
expect(listing.description).toBeTypeOf('string');
|
||||
expect(listing.description).not.toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()', () => {
|
||||
@@ -17,22 +17,26 @@ describe('#mcMakler testsuite()', () => {
|
||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'mcMakler', similarityCache);
|
||||
const listing = await fredy.execute();
|
||||
|
||||
expect(listing).to.be.a('array');
|
||||
if (listing == null || listing.length === 0) {
|
||||
throw new Error('Listings is empty!');
|
||||
}
|
||||
|
||||
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,14 +6,14 @@
|
||||
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()', () => {
|
||||
provider.init(providerConfig.neubauKompass, [], []);
|
||||
it('should test neubauKompass provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
return await new Promise((resolve) => {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const fredy = new Fredy(
|
||||
provider.config,
|
||||
null,
|
||||
@@ -23,20 +23,25 @@ describe('#neubauKompass testsuite()', () => {
|
||||
similarityCache,
|
||||
);
|
||||
fredy.execute().then((listing) => {
|
||||
expect(listing).to.be.a('array');
|
||||
if (listing == null || listing.length === 0) {
|
||||
reject('Listings is empty!');
|
||||
return;
|
||||
}
|
||||
|
||||
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()', () => {
|
||||
@@ -17,22 +17,26 @@ describe('#ohneMakler testsuite()', () => {
|
||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'ohneMakler', similarityCache);
|
||||
const listing = await fredy.execute();
|
||||
|
||||
expect(listing).to.be.a('array');
|
||||
if (listing == null || listing.length === 0) {
|
||||
throw new Error('Listings is empty!');
|
||||
}
|
||||
|
||||
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()', () => {
|
||||
@@ -24,22 +24,26 @@ describe('#regionalimmobilien24 testsuite()', () => {
|
||||
);
|
||||
const listing = await fredy.execute();
|
||||
|
||||
expect(listing).to.be.a('array');
|
||||
if (listing == null || listing.length === 0) {
|
||||
throw new Error('Listings is empty!');
|
||||
}
|
||||
|
||||
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,8 +6,9 @@
|
||||
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, vi } from 'vitest';
|
||||
import * as provider from '../../lib/provider/sparkasse.js';
|
||||
import * as mockStore from '../mocks/mockStore.js';
|
||||
|
||||
describe('#sparkasse testsuite()', () => {
|
||||
it('should test sparkasse provider', async () => {
|
||||
@@ -17,22 +18,56 @@ describe('#sparkasse testsuite()', () => {
|
||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'sparkasse', similarityCache);
|
||||
const listing = await fredy.execute();
|
||||
|
||||
expect(listing).to.be.a('array');
|
||||
if (listing == null || listing.length === 0) {
|
||||
throw new Error('Listings is empty!');
|
||||
}
|
||||
|
||||
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('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with provider_details enabled', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] });
|
||||
vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should enrich listings with details', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
provider.init(providerConfig.sparkasse, []);
|
||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'sparkasse', {
|
||||
checkAndAddEntry: () => false,
|
||||
});
|
||||
const listings = await fredy.execute();
|
||||
expect(listings).toBeInstanceOf(Array);
|
||||
listings.forEach((listing) => {
|
||||
expect(listing.link).toContain('https://immobilien.sparkasse.de');
|
||||
expect(listing.address).toBeTypeOf('string');
|
||||
expect(listing.address).not.toBe('');
|
||||
// description is enriched from the detail page; falls back gracefully if bot-detected
|
||||
if (listing.description != null) {
|
||||
expect(listing.description).toBeTypeOf('string');
|
||||
expect(listing.description).not.toBe('');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,30 +6,62 @@
|
||||
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, vi } from 'vitest';
|
||||
import * as provider from '../../lib/provider/wgGesucht.js';
|
||||
import * as mockStore from '../mocks/mockStore.js';
|
||||
|
||||
describe('#wgGesucht testsuite()', () => {
|
||||
provider.init(providerConfig.wgGesucht, [], []);
|
||||
it('should test wgGesucht provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
return await new Promise((resolve) => {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'wgGesucht', similarityCache);
|
||||
fredy.execute().then((listing) => {
|
||||
expect(listing).to.be.a('array');
|
||||
if (listing == null || listing.length === 0) {
|
||||
reject('Listings is empty!');
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with provider_details enabled', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] });
|
||||
vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should enrich listings with details', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
provider.init(providerConfig.wgGesucht, [], []);
|
||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'wgGesucht', {
|
||||
checkAndAddEntry: () => false,
|
||||
});
|
||||
const listings = await fredy.execute();
|
||||
expect(listings).toBeInstanceOf(Array);
|
||||
listings.forEach((listing) => {
|
||||
expect(listing.link).toContain('https://www.wg-gesucht.de');
|
||||
expect(listing.description).toBeTypeOf('string');
|
||||
expect(listing.description).not.toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
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()', () => {
|
||||
provider.init(providerConfig.wohnungsboerse, [], []);
|
||||
it('should test wohnungsboerse provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
return await new Promise((resolve) => {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const fredy = new Fredy(
|
||||
provider.config,
|
||||
null,
|
||||
@@ -23,22 +23,27 @@ describe('#wohnungsboerse testsuite()', () => {
|
||||
similarityCache,
|
||||
);
|
||||
fredy.execute().then((listings) => {
|
||||
expect(listings).to.be.a('array');
|
||||
if (listings == null || listings.length === 0) {
|
||||
reject('Listings is empty!');
|
||||
return;
|
||||
}
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ import React, { useEffect } from 'react';
|
||||
import InsufficientPermission from './components/permission/InsufficientPermission';
|
||||
import PermissionAwareRoute from './components/permission/PermissionAwareRoute';
|
||||
import GeneralSettings from './views/generalSettings/GeneralSettings';
|
||||
import UserSettings from './views/userSettings/UserSettings';
|
||||
import JobMutation from './views/jobs/mutation/JobMutation';
|
||||
import UserMutator from './views/user/mutation/UserMutator';
|
||||
import { useActions, useSelector } from './services/state/store';
|
||||
@@ -127,15 +126,8 @@ export default function FredyApp() {
|
||||
</PermissionAwareRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/userSettings" element={<UserSettings />} />
|
||||
<Route
|
||||
path="/generalSettings"
|
||||
element={
|
||||
<PermissionAwareRoute currentUser={currentUser}>
|
||||
<GeneralSettings />
|
||||
</PermissionAwareRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/userSettings" element={<Navigate to="/generalSettings" replace />} />
|
||||
<Route path="/generalSettings" element={<GeneralSettings />} />
|
||||
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.6 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 MiB |
@@ -1,20 +1,11 @@
|
||||
{
|
||||
"key": "00e6b81777a275f5a140fc9101cb943810db6a69f6eb3927319c5aee0c876510",
|
||||
"key": "00e6b81777a275f5a140fc9101cb943810db6a69f6eb3927319c5aee0c876511",
|
||||
"content":
|
||||
[
|
||||
{
|
||||
"title": "Listings can now be filtered by location",
|
||||
"text": "Thanks to https://github.com/strech345 for adding a new feature that allows you to filter listings based on their their location. When creating a job, you can now create multiple areas in which Fredy searches for new listings",
|
||||
"image": "1.png"
|
||||
},
|
||||
{
|
||||
"title": "More details from the Immoscout Provider",
|
||||
"text": "https://github.com/MindCollaps added a new user-setting. When enabled Fredy will pull way more information for each listing from Immoscout.",
|
||||
"image": "2.png"
|
||||
},
|
||||
{
|
||||
"title": "Listings now marked as invisible if filtered",
|
||||
"text": "So far, when a listing was filtered e.g. due to being a duplicate, it was not send to you, but still shown on the map. This was now fixed."
|
||||
"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.
@@ -1,12 +1,14 @@
|
||||
@import './DashboardCardColors.less';
|
||||
|
||||
.dashboard-card {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
margin-bottom: 16px;
|
||||
transition: transform 0.2s;
|
||||
background-color: rgba(36, 36, 36, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--semi-color-border);
|
||||
--pulse-color: rgba(255, 255, 255, 0.1);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
background-color: #181b26;
|
||||
border: 1px solid #232735;
|
||||
border-radius: 10px;
|
||||
--pulse-color: rgba(255, 255, 255, 0.08);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
overflow: visible;
|
||||
@@ -32,6 +34,14 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--card-accent, #94a3b8);
|
||||
}
|
||||
|
||||
&__title {
|
||||
color: var(--semi-color-text-2) !important;
|
||||
font-size: 12px !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
&__content {
|
||||
@@ -41,32 +51,51 @@
|
||||
&__value {
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
color: var(--semi-color-text-0);
|
||||
color: var(--card-accent, var(--semi-color-text-0));
|
||||
}
|
||||
|
||||
&__desc {
|
||||
color: var(--semi-color-text-3) !important;
|
||||
}
|
||||
|
||||
&.blue {
|
||||
--pulse-color: var(--semi-color-primary);
|
||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||
--pulse-color: @color-blue-border;
|
||||
--card-accent: @color-blue-text;
|
||||
background-color: @color-blue-bg;
|
||||
border-color: @color-blue-border;
|
||||
box-shadow: 0 2px 16px -6px @color-blue-border;
|
||||
}
|
||||
|
||||
&.orange {
|
||||
--pulse-color: var(--semi-color-warning);
|
||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||
--pulse-color: @color-orange-border;
|
||||
--card-accent: @color-orange-text;
|
||||
background-color: @color-orange-bg;
|
||||
border-color: @color-orange-border;
|
||||
box-shadow: 0 2px 16px -6px @color-orange-border;
|
||||
}
|
||||
|
||||
&.green {
|
||||
--pulse-color: var(--semi-color-success);
|
||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||
--pulse-color: @color-green-border;
|
||||
--card-accent: @color-green-text;
|
||||
background-color: @color-green-bg;
|
||||
border-color: @color-green-border;
|
||||
box-shadow: 0 2px 16px -6px @color-green-border;
|
||||
}
|
||||
|
||||
&.purple {
|
||||
--pulse-color: var(--semi-color-info);
|
||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||
--pulse-color: @color-purple-border;
|
||||
--card-accent: @color-purple-text;
|
||||
background-color: @color-purple-bg;
|
||||
border-color: @color-purple-border;
|
||||
box-shadow: 0 2px 16px -6px @color-purple-border;
|
||||
}
|
||||
|
||||
&.gray {
|
||||
--pulse-color: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||
--pulse-color: @color-gray-border;
|
||||
--card-accent: @color-gray-text;
|
||||
background-color: @color-gray-bg;
|
||||
border-color: @color-gray-border;
|
||||
box-shadow: 0 2px 16px -6px @color-gray-border;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +104,6 @@
|
||||
opacity: 0.1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
@color-blue-bg: rgba(0, 123, 255, 0.24);
|
||||
@color-blue-border: #1E40AFFF;
|
||||
@color-blue-bg: rgba(96, 165, 250, 0.10);
|
||||
@color-blue-border: #3b6ea8;
|
||||
@color-blue-text: #60a5fa;
|
||||
|
||||
@color-orange-bg: rgba(250, 91, 5, 0.12);
|
||||
@color-orange-border: #992f0c;
|
||||
@color-orange-text: #FB923CFF;
|
||||
@color-orange-bg: rgba(251, 146, 60, 0.10);
|
||||
@color-orange-border: #c2622a;
|
||||
@color-orange-text: #fb923c;
|
||||
|
||||
@color-green-bg: rgba(38, 250, 5, 0.12);
|
||||
@color-green-border: #278832;
|
||||
@color-green-text: #33f308;
|
||||
@color-green-bg: rgba(52, 211, 153, 0.10);
|
||||
@color-green-border: #2a8a61;
|
||||
@color-green-text: #34d399;
|
||||
|
||||
@color-purple-bg: rgba(91, 3, 218, 0.38);
|
||||
@color-purple-border: #7500c3;
|
||||
@color-purple-text: #b15fff;
|
||||
@color-purple-bg: rgba(167, 139, 250, 0.10);
|
||||
@color-purple-border: #6d4fc2;
|
||||
@color-purple-text: #a78bfa;
|
||||
|
||||
@color-gray-bg: rgba(110, 110, 110, 0.38);
|
||||
@color-gray-border: #807f7f;
|
||||
@color-gray-text: #bab9b9;
|
||||
@color-gray-bg: rgba(148, 163, 184, 0.10);
|
||||
@color-gray-border: #323a47;
|
||||
@color-gray-text: #94a3b8;
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
Col,
|
||||
Row,
|
||||
Button,
|
||||
Space,
|
||||
Typography,
|
||||
Divider,
|
||||
Switch,
|
||||
@@ -20,6 +19,8 @@ import {
|
||||
Pagination,
|
||||
Toast,
|
||||
Empty,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
} from '@douyinfe/semi-ui-19';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
@@ -31,14 +32,16 @@ import {
|
||||
IconBriefcase,
|
||||
IconBell,
|
||||
IconSearch,
|
||||
IconFilter,
|
||||
IconPlusCircle,
|
||||
IconArrowUp,
|
||||
IconArrowDown,
|
||||
IconHome,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
|
||||
import { useActions, useSelector } from '../../../services/state/store.js';
|
||||
import { xhrDelete, xhrPut, xhrPost } from '../../../services/xhr.js';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { debounce } from '../../../utils';
|
||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||
|
||||
import './JobGrid.less';
|
||||
@@ -59,8 +62,6 @@ const JobGrid = () => {
|
||||
const [sortDir, setSortDir] = useState('asc');
|
||||
const [freeTextFilter, setFreeTextFilter] = useState(null);
|
||||
const [activityFilter, setActivityFilter] = useState(null);
|
||||
const [showFilterBar, setShowFilterBar] = useState(false);
|
||||
|
||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||
const [pendingDeletion, setPendingDeletion] = useState(null); // { type: 'job'|'listings', jobId }
|
||||
|
||||
@@ -200,73 +201,45 @@ const JobGrid = () => {
|
||||
|
||||
return (
|
||||
<div className="jobGrid">
|
||||
<Space vertical align="start" style={{ width: '100%', marginBottom: '16px' }} spacing="medium">
|
||||
<div className="jobGrid__topbar">
|
||||
<Button type="primary" icon={<IconPlusCircle />} onClick={() => navigate('/jobs/new')}>
|
||||
New Job
|
||||
</Button>
|
||||
<div className="jobGrid__searchbar" style={{ width: '100%' }}>
|
||||
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
|
||||
<Button
|
||||
icon={<IconFilter />}
|
||||
style={{ marginLeft: '8px' }}
|
||||
onClick={() => {
|
||||
setShowFilterBar(!showFilterBar);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
{showFilterBar && (
|
||||
<div className="jobGrid__toolbar">
|
||||
<Space wrap style={{ marginBottom: '1rem' }}>
|
||||
<div className="jobGrid__toolbar__card">
|
||||
<div>
|
||||
<Text strong>Filter by:</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '.3rem' }}>
|
||||
<Select
|
||||
placeholder="Status"
|
||||
showClear
|
||||
onChange={(val) => setActivityFilter(val)}
|
||||
value={activityFilter}
|
||||
style={{ width: 140 }}
|
||||
>
|
||||
<Select.Option value={true}>Active</Select.Option>
|
||||
<Select.Option value={false}>Not Active</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Divider layout="vertical" />
|
||||
<div className="jobGrid__toolbar__card">
|
||||
<div>
|
||||
<Text strong>Sort by:</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '.3rem' }}>
|
||||
<Select
|
||||
placeholder="Sort By"
|
||||
style={{ width: 160 }}
|
||||
value={sortField}
|
||||
onChange={(val) => setSortField(val)}
|
||||
>
|
||||
<Select.Option value="name">Name</Select.Option>
|
||||
<Select.Option value="numberOfFoundListings">Number of Listings</Select.Option>
|
||||
<Select.Option value="enabled">Status</Select.Option>
|
||||
</Select>
|
||||
<Input
|
||||
className="jobGrid__topbar__search"
|
||||
prefix={<IconSearch />}
|
||||
showClear
|
||||
placeholder="Search"
|
||||
onChange={handleFilterChange}
|
||||
/>
|
||||
|
||||
<Select
|
||||
placeholder="Direction"
|
||||
style={{ width: 120 }}
|
||||
value={sortDir}
|
||||
onChange={(val) => setSortDir(val)}
|
||||
>
|
||||
<Select.Option value="asc">Ascending</Select.Option>
|
||||
<Select.Option value="desc">Descending</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
<RadioGroup
|
||||
type="button"
|
||||
buttonSize="middle"
|
||||
value={activityFilter === null ? 'all' : String(activityFilter)}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setActivityFilter(v === 'all' ? null : v === 'true');
|
||||
}}
|
||||
>
|
||||
<Radio value="all">All</Radio>
|
||||
<Radio value="true">Active</Radio>
|
||||
<Radio value="false">Inactive</Radio>
|
||||
</RadioGroup>
|
||||
|
||||
<Select prefix="Sort by" style={{ width: 200 }} value={sortField} onChange={(val) => setSortField(val)}>
|
||||
<Select.Option value="name">Name</Select.Option>
|
||||
<Select.Option value="numberOfFoundListings">Number of Listings</Select.Option>
|
||||
<Select.Option value="enabled">Status</Select.Option>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
icon={sortDir === 'asc' ? <IconArrowUp /> : <IconArrowDown />}
|
||||
onClick={() => setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))}
|
||||
title={sortDir === 'asc' ? 'Ascending' : 'Descending'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(jobsData?.result || []).length === 0 && (
|
||||
<Empty
|
||||
@@ -278,78 +251,70 @@ const JobGrid = () => {
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
{(jobsData?.result || []).map((job) => (
|
||||
<Col key={job.id} xs={24} sm={12} md={8} lg={6} xl={4} xxl={6}>
|
||||
<Card
|
||||
className="jobGrid__card"
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
title={
|
||||
<div className="jobGrid__header">
|
||||
<Col key={job.id} xs={24} sm={12} md={12} lg={8} xl={8} xxl={6}>
|
||||
<Card className="jobGrid__card" bodyStyle={{ padding: '16px' }}>
|
||||
<div className="jobGrid__card__header">
|
||||
<div className="jobGrid__card__name">
|
||||
<span className={`jobGrid__card__dot${job.enabled ? ' jobGrid__card__dot--active' : ''}`} />
|
||||
<Title heading={5} ellipsis={{ showTooltip: true }} className="jobGrid__title">
|
||||
{job.name}
|
||||
</Title>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
{job.isOnlyShared && (
|
||||
<Popover
|
||||
content={getPopoverContent(
|
||||
'This job has been shared with you by another user, therefor it is read-only.',
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)', marginLeft: '8px' }} />
|
||||
</div>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||
{job.isOnlyShared && (
|
||||
<Popover
|
||||
content={getPopoverContent(
|
||||
'This job has been shared with you by another user, therefor it is read-only.',
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)' }} />
|
||||
</div>
|
||||
</Popover>
|
||||
)}
|
||||
{job.running && (
|
||||
<Tag color="green" variant="light" size="small">
|
||||
RUNNING
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="jobGrid__content">
|
||||
<Space vertical align="start" spacing={4} style={{ width: '100%', marginTop: 12 }}>
|
||||
<div className="jobGrid__infoItem">
|
||||
<Text type="secondary" icon={<IconSearch />} size="small">
|
||||
Is active:
|
||||
</Text>
|
||||
<Switch
|
||||
onChange={(checked) => onJobStatusChanged(job.id, checked)}
|
||||
style={{ marginLeft: 'auto' }}
|
||||
checked={job.enabled}
|
||||
disabled={job.isOnlyShared}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<div className="jobGrid__infoItem">
|
||||
<Text type="secondary" icon={<IconSearch />} size="small">
|
||||
Listings:
|
||||
</Text>
|
||||
<Tag color="blue" size="small" style={{ marginLeft: 'auto' }}>
|
||||
{job.numberOfFoundListings || 0}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className="jobGrid__infoItem">
|
||||
<Text type="secondary" icon={<IconBriefcase />} size="small">
|
||||
Providers:
|
||||
</Text>
|
||||
<Tag color="cyan" size="small" style={{ marginLeft: 'auto' }}>
|
||||
{job.provider.length || 0}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className="jobGrid__infoItem">
|
||||
<Text type="secondary" icon={<IconBell />} size="small">
|
||||
Adapters:
|
||||
</Text>
|
||||
<Tag color="purple" size="small" style={{ marginLeft: 'auto' }}>
|
||||
{job.notificationAdapter.length || 0}
|
||||
</Tag>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Divider margin="12px" />
|
||||
<div className="jobGrid__card__stats">
|
||||
<div className="jobGrid__card__stat jobGrid__card__stat--blue">
|
||||
<span className="jobGrid__card__stat__number">{job.numberOfFoundListings || 0}</span>
|
||||
<span className="jobGrid__card__stat__label">
|
||||
<IconHome size="small" /> Listings
|
||||
</span>
|
||||
</div>
|
||||
<div className="jobGrid__card__stat jobGrid__card__stat--orange">
|
||||
<span className="jobGrid__card__stat__number">{job.provider.length || 0}</span>
|
||||
<span className="jobGrid__card__stat__label">
|
||||
<IconBriefcase size="small" /> Providers
|
||||
</span>
|
||||
</div>
|
||||
<div className="jobGrid__card__stat jobGrid__card__stat--purple">
|
||||
<span className="jobGrid__card__stat__number">{job.notificationAdapter.length || 0}</span>
|
||||
<span className="jobGrid__card__stat__label">
|
||||
<IconBell size="small" /> Adapters
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider margin="12px" />
|
||||
|
||||
<div className="jobGrid__card__footer">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Switch
|
||||
onChange={(checked) => onJobStatusChanged(job.id, checked)}
|
||||
checked={job.enabled}
|
||||
disabled={job.isOnlyShared}
|
||||
size="small"
|
||||
/>
|
||||
<Text type="secondary" size="small">
|
||||
Active
|
||||
</Text>
|
||||
</div>
|
||||
<div className="jobGrid__actions">
|
||||
<Popover content={getPopoverContent('Run Job')}>
|
||||
<div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import '../../cards/DashboardCardColors.less';
|
||||
|
||||
.jobGrid {
|
||||
&__card {
|
||||
height: 100%;
|
||||
@@ -12,55 +14,137 @@
|
||||
box-shadow: 0 0 15px -3px rgb(78 78 78 / 70%);
|
||||
background-color: rgba(36, 36, 36, 1);
|
||||
}
|
||||
}
|
||||
|
||||
&__searchbar {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__toolbar {
|
||||
&__card {
|
||||
border-radius: var(--semi-border-radius-medium);
|
||||
&__name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--semi-color-text-3);
|
||||
|
||||
&--active {
|
||||
background-color: #21aa21;
|
||||
}
|
||||
}
|
||||
|
||||
&__stats {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__stat {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .3rem;
|
||||
background: rgba(36, 36, 36, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--semi-color-border);
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--semi-border-radius-small);
|
||||
padding: 10px 4px 8px;
|
||||
|
||||
&__number {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--semi-color-text-0);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 11px;
|
||||
color: var(--semi-color-text-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
&--blue {
|
||||
background: @color-blue-bg;
|
||||
border-color: @color-blue-border;
|
||||
.jobGrid__card__stat__number { color: @color-blue-text; }
|
||||
.jobGrid__card__stat__label { color: @color-blue-text; opacity: 0.7; }
|
||||
}
|
||||
|
||||
&--orange {
|
||||
background: @color-orange-bg;
|
||||
border-color: @color-orange-border;
|
||||
.jobGrid__card__stat__number { color: @color-orange-text; }
|
||||
.jobGrid__card__stat__label { color: @color-orange-text; opacity: 0.7; }
|
||||
}
|
||||
|
||||
&--purple {
|
||||
background: @color-purple-bg;
|
||||
border-color: @color-purple-border;
|
||||
.jobGrid__card__stat__number { color: @color-purple-text; }
|
||||
.jobGrid__card__stat__label { color: @color-purple-text; opacity: 0.7; }
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
&__topbar {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.jobGrid__topbar__search {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-wrap: wrap;
|
||||
|
||||
.semi-button:first-child {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.jobGrid__topbar__search {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.semi-radio-group {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.semi-select {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
&__infoItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
.semi-typography {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&__pagination {
|
||||
|
||||
@@ -4,21 +4,27 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
useSearchParamState,
|
||||
parseNumber,
|
||||
parseString,
|
||||
parseNullableBoolean,
|
||||
} from '../../../hooks/useSearchParamState.js';
|
||||
import {
|
||||
Card,
|
||||
Col,
|
||||
Row,
|
||||
Image,
|
||||
Button,
|
||||
Space,
|
||||
Typography,
|
||||
Pagination,
|
||||
Toast,
|
||||
Divider,
|
||||
Input,
|
||||
Select,
|
||||
Popover,
|
||||
Empty,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
} from '@douyinfe/semi-ui-19';
|
||||
import {
|
||||
IconBriefcase,
|
||||
@@ -30,17 +36,18 @@ import {
|
||||
IconStar,
|
||||
IconStarStroked,
|
||||
IconSearch,
|
||||
IconFilter,
|
||||
IconActivity,
|
||||
IconEyeOpened,
|
||||
IconArrowUp,
|
||||
IconArrowDown,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
|
||||
import no_image from '../../../assets/no_image.jpg';
|
||||
import * as timeService from '../../../services/time/timeService.js';
|
||||
import { xhrDelete, xhrPost } from '../../../services/xhr.js';
|
||||
import { useActions, useSelector } from '../../../services/state/store.js';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { debounce } from '../../../utils';
|
||||
|
||||
import './ListingsGrid.less';
|
||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||
@@ -53,19 +60,18 @@ const ListingsGrid = () => {
|
||||
const jobs = useSelector((state) => state.jobsData.jobs);
|
||||
const actions = useActions();
|
||||
const navigate = useNavigate();
|
||||
const sp = useSearchParams();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [page, setPage] = useSearchParamState(sp, 'page', 1, parseNumber);
|
||||
const pageSize = 40;
|
||||
|
||||
const [sortField, setSortField] = useState('created_at');
|
||||
const [sortDir, setSortDir] = useState('desc');
|
||||
const [freeTextFilter, setFreeTextFilter] = useState(null);
|
||||
const [watchListFilter, setWatchListFilter] = useState(null);
|
||||
const [jobNameFilter, setJobNameFilter] = useState(null);
|
||||
const [activityFilter, setActivityFilter] = useState(null);
|
||||
const [providerFilter, setProviderFilter] = useState(null);
|
||||
const [showFilterBar, setShowFilterBar] = useState(false);
|
||||
|
||||
const [sortField, setSortField] = useSearchParamState(sp, 'sort', 'created_at', parseString);
|
||||
const [sortDir, setSortDir] = useSearchParamState(sp, 'dir', 'desc', parseString);
|
||||
const [freeTextFilter, setFreeTextFilter] = useSearchParamState(sp, 'q', null, parseString);
|
||||
const [watchListFilter, setWatchListFilter] = useSearchParamState(sp, 'watch', null, parseNullableBoolean);
|
||||
const [jobNameFilter, setJobNameFilter] = useSearchParamState(sp, 'job', null, parseString);
|
||||
const [activityFilter, setActivityFilter] = useSearchParamState(sp, 'active', null, parseNullableBoolean);
|
||||
const [providerFilter, setProviderFilter] = useSearchParamState(sp, 'provider', null, parseString);
|
||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||
const [listingToDelete, setListingToDelete] = useState(null);
|
||||
|
||||
@@ -84,7 +90,14 @@ const ListingsGrid = () => {
|
||||
loadData();
|
||||
}, [page, sortField, sortDir, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]);
|
||||
|
||||
const handleFilterChange = useMemo(() => debounce((value) => setFreeTextFilter(value), 500), []);
|
||||
const handleFilterChange = useMemo(
|
||||
() =>
|
||||
debounce((value) => {
|
||||
setFreeTextFilter(value || null);
|
||||
setPage(1);
|
||||
}, 500),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -129,107 +142,93 @@ const ListingsGrid = () => {
|
||||
|
||||
return (
|
||||
<div className="listingsGrid">
|
||||
<div className="listingsGrid__searchbar">
|
||||
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
|
||||
<Popover content="Filter / Sort Results" style={{ color: 'white', padding: '.5rem' }}>
|
||||
<div>
|
||||
<Button
|
||||
icon={<IconFilter />}
|
||||
onClick={() => {
|
||||
setShowFilterBar(!showFilterBar);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Popover>
|
||||
<div className="listingsGrid__topbar">
|
||||
<Input
|
||||
className="listingsGrid__topbar__search"
|
||||
prefix={<IconSearch />}
|
||||
showClear
|
||||
placeholder="Search"
|
||||
defaultValue={freeTextFilter ?? ''}
|
||||
onChange={handleFilterChange}
|
||||
/>
|
||||
|
||||
<RadioGroup
|
||||
type="button"
|
||||
buttonSize="middle"
|
||||
value={activityFilter === null ? 'all' : String(activityFilter)}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setActivityFilter(v === 'all' ? null : v === 'true');
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Radio value="all">All</Radio>
|
||||
<Radio value="true">Active</Radio>
|
||||
<Radio value="false">Inactive</Radio>
|
||||
</RadioGroup>
|
||||
|
||||
<RadioGroup
|
||||
type="button"
|
||||
buttonSize="middle"
|
||||
value={watchListFilter === null ? 'all' : String(watchListFilter)}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setWatchListFilter(v === 'all' ? null : v === 'true');
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Radio value="all">All</Radio>
|
||||
<Radio value="true">Watched</Radio>
|
||||
<Radio value="false">Unwatched</Radio>
|
||||
</RadioGroup>
|
||||
|
||||
<Select
|
||||
placeholder="Provider"
|
||||
showClear
|
||||
onChange={(val) => {
|
||||
setProviderFilter(val);
|
||||
setPage(1);
|
||||
}}
|
||||
value={providerFilter}
|
||||
style={{ width: 130 }}
|
||||
>
|
||||
{providers?.map((p) => (
|
||||
<Select.Option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
placeholder="Job"
|
||||
showClear
|
||||
onChange={(val) => {
|
||||
setJobNameFilter(val);
|
||||
setPage(1);
|
||||
}}
|
||||
value={jobNameFilter}
|
||||
style={{ width: 130 }}
|
||||
>
|
||||
{jobs?.map((j) => (
|
||||
<Select.Option key={j.id} value={j.id}>
|
||||
{j.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Select prefix="Sort by" style={{ width: 185 }} value={sortField} onChange={(val) => setSortField(val)}>
|
||||
<Select.Option value="job_name">Job Name</Select.Option>
|
||||
<Select.Option value="created_at">Listing Date</Select.Option>
|
||||
<Select.Option value="price">Price</Select.Option>
|
||||
<Select.Option value="provider">Provider</Select.Option>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
icon={sortDir === 'asc' ? <IconArrowUp /> : <IconArrowDown />}
|
||||
onClick={() => setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))}
|
||||
title={sortDir === 'asc' ? 'Ascending' : 'Descending'}
|
||||
/>
|
||||
</div>
|
||||
{showFilterBar && (
|
||||
<div className="listingsGrid__toolbar">
|
||||
<Space wrap style={{ marginBottom: '1rem' }}>
|
||||
<div className="listingsGrid__toolbar__card">
|
||||
<div>
|
||||
<Text strong>Filter by:</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '.3rem' }}>
|
||||
<Select
|
||||
placeholder="Status"
|
||||
showClear
|
||||
onChange={(val) => setActivityFilter(val)}
|
||||
value={activityFilter}
|
||||
>
|
||||
<Select.Option value={true}>Active</Select.Option>
|
||||
<Select.Option value={false}>Not Active</Select.Option>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
placeholder="Watchlist"
|
||||
showClear
|
||||
onChange={(val) => setWatchListFilter(val)}
|
||||
value={watchListFilter}
|
||||
>
|
||||
<Select.Option value={true}>Watched</Select.Option>
|
||||
<Select.Option value={false}>Not Watched</Select.Option>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
placeholder="Provider"
|
||||
showClear
|
||||
onChange={(val) => setProviderFilter(val)}
|
||||
value={providerFilter}
|
||||
>
|
||||
{providers?.map((p) => (
|
||||
<Select.Option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
placeholder="Job Name"
|
||||
showClear
|
||||
onChange={(val) => setJobNameFilter(val)}
|
||||
value={jobNameFilter}
|
||||
>
|
||||
{jobs?.map((j) => (
|
||||
<Select.Option key={j.id} value={j.id}>
|
||||
{j.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Divider layout="vertical" />
|
||||
|
||||
<div className="listingsGrid__toolbar__card">
|
||||
<div>
|
||||
<Text strong>Sort by:</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '.3rem' }}>
|
||||
<Select
|
||||
placeholder="Sort By"
|
||||
style={{ width: 140 }}
|
||||
value={sortField}
|
||||
onChange={(val) => setSortField(val)}
|
||||
>
|
||||
<Select.Option value="job_name">Job Name</Select.Option>
|
||||
<Select.Option value="created_at">Listing Date</Select.Option>
|
||||
<Select.Option value="price">Price</Select.Option>
|
||||
<Select.Option value="provider">Provider</Select.Option>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
placeholder="Direction"
|
||||
style={{ width: 120 }}
|
||||
value={sortDir}
|
||||
onChange={(val) => setSortDir(val)}
|
||||
>
|
||||
<Select.Option value="asc">Ascending</Select.Option>
|
||||
<Select.Option value="desc">Descending</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(listingsData?.result || []).length === 0 && (
|
||||
<Empty
|
||||
@@ -240,7 +239,7 @@ const ListingsGrid = () => {
|
||||
)}
|
||||
<Row gutter={[16, 16]}>
|
||||
{(listingsData?.result || []).map((item) => (
|
||||
<Col key={item.id} xs={24} sm={12} md={8} lg={6} xl={4} xxl={6}>
|
||||
<Col key={item.id} xs={24} sm={12} md={12} lg={8} xl={8} xxl={6}>
|
||||
<Card
|
||||
className={`listingsGrid__card ${!item.is_active ? 'listingsGrid__card--inactive' : ''}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
@@ -280,10 +279,11 @@ const ListingsGrid = () => {
|
||||
<Text strong ellipsis={{ showTooltip: true }} className="listingsGrid__title">
|
||||
{cap(item.title)}
|
||||
</Text>
|
||||
<Space vertical align="start" spacing={2} style={{ width: '100%', marginTop: 8 }}>
|
||||
<Text type="secondary" icon={<IconCart />} size="small">
|
||||
{item.price} €
|
||||
</Text>
|
||||
<div className="listingsGrid__price">
|
||||
<IconCart size="small" />
|
||||
{item.price} €
|
||||
</div>
|
||||
<div className="listingsGrid__meta">
|
||||
<Text
|
||||
type="secondary"
|
||||
icon={<IconMapPin />}
|
||||
@@ -305,18 +305,17 @@ const ListingsGrid = () => {
|
||||
</Text>
|
||||
) : (
|
||||
<Text type="tertiary" size="small" icon={<IconActivity />}>
|
||||
Distance cannot be calculated, provide an address
|
||||
Distance cannot be calculated
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
<Divider margin=".6rem" />
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<div className="listingsGrid__actions">
|
||||
<div className="listingsGrid__linkButton" onClick={(e) => e.stopPropagation()}>
|
||||
<a href={item.link} target="_blank" rel="noopener noreferrer">
|
||||
<IconLink />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="secondary"
|
||||
size="small"
|
||||
@@ -324,7 +323,6 @@ const ListingsGrid = () => {
|
||||
onClick={() => navigate(`/listings/listing/${item.id}`)}
|
||||
icon={<IconEyeOpened />}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Remove"
|
||||
type="danger"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import '../../cards/DashboardCardColors.less';
|
||||
|
||||
.listingsGrid {
|
||||
&__imageContainer {
|
||||
position: relative;
|
||||
@@ -5,12 +7,34 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__searchbar {
|
||||
&__topbar {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.listingsGrid__topbar__search {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.listingsGrid__topbar__search {
|
||||
width: 100%;
|
||||
flex: unset;
|
||||
}
|
||||
|
||||
.semi-radio-group {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.semi-select {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__watchButton {
|
||||
@@ -93,17 +117,27 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__toolbar {
|
||||
&__card {
|
||||
border-radius: var(--semi-border-radius-medium);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .3rem;
|
||||
background: rgba(36, 36, 36, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--semi-color-border);
|
||||
}
|
||||
&__price {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: @color-green-text;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin: 8px 0 6px;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__setupButton {
|
||||
|
||||
@@ -42,24 +42,21 @@ export default function Navigation({ isAdmin }) {
|
||||
];
|
||||
|
||||
if (isAdmin) {
|
||||
const settingsItems = [
|
||||
{ itemKey: '/users', text: 'User Management' },
|
||||
{ itemKey: '/userSettings', text: 'User Specific Settings' },
|
||||
{ itemKey: '/generalSettings', text: 'General Settings' },
|
||||
];
|
||||
|
||||
items.push({
|
||||
itemKey: 'settings',
|
||||
text: 'Settings',
|
||||
icon: <IconSetting />,
|
||||
items: settingsItems,
|
||||
items: [
|
||||
{ itemKey: '/users', text: 'User Management' },
|
||||
{ itemKey: '/generalSettings', text: 'Settings' },
|
||||
],
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
itemKey: 'settings',
|
||||
text: 'Settings',
|
||||
icon: <IconSetting />,
|
||||
items: [{ itemKey: '/userSettings', text: 'User Specific Settings' }],
|
||||
items: [{ itemKey: '/generalSettings', text: 'Settings' }],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
border-radius: .9rem !important;
|
||||
color: rgba(var(--semi-grey-8), 1);
|
||||
background: rgb(53, 54, 60);
|
||||
margin: 2rem;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
96
ui/src/hooks/useSearchParamState.js
Normal file
96
ui/src/hooks/useSearchParamState.js
Normal file
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
|
||||
// Preset parsers for common types
|
||||
export const parseString = {
|
||||
parse: (v) => v,
|
||||
stringify: (v) => v,
|
||||
};
|
||||
|
||||
export const parseNumber = {
|
||||
parse: (v) => Number(v),
|
||||
stringify: (v) => String(v),
|
||||
};
|
||||
|
||||
export const parseBoolean = {
|
||||
parse: (v) => v === 'true',
|
||||
stringify: (v) => String(v),
|
||||
};
|
||||
|
||||
// For state that is null | true | false
|
||||
export const parseNullableBoolean = {
|
||||
parse: (v) => (v === 'true' ? true : v === 'false' ? false : null),
|
||||
stringify: (v) => (v === null ? null : String(v)),
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop-in replacement for useState that syncs with URL search params.
|
||||
* Uses replace: true so filter changes don't add browser history entries.
|
||||
*
|
||||
* Requires a shared [searchParams, setSearchParams] pair from a single
|
||||
* useSearchParams() call in the component. This ensures multiple hooks
|
||||
* in the same component don't overwrite each other's params.
|
||||
*
|
||||
* @param {[URLSearchParams, Function]} searchParamsPair - from useSearchParams()
|
||||
* @param {string} key - URL search param key
|
||||
* @param {*} defaultValue - value when param is absent
|
||||
* @param {{ parse: (s: string) => *, stringify: (v: *) => string|null }} [options]
|
||||
*/
|
||||
// WeakMap to store pending batched updates per setSearchParams function.
|
||||
// This lets multiple useSearchParamState hooks on the same component batch
|
||||
// their changes into a single setSearchParams call, preventing them from
|
||||
// overwriting each other.
|
||||
const pendingUpdates = new WeakMap();
|
||||
|
||||
export function useSearchParamState([searchParams, setSearchParams], key, defaultValue, options = {}) {
|
||||
const { parse = (v) => v, stringify = (v) => String(v) } = options;
|
||||
|
||||
const rawValue = searchParams.get(key);
|
||||
const value = rawValue !== null ? parse(rawValue) : defaultValue;
|
||||
|
||||
const setValue = useCallback(
|
||||
(newValue) => {
|
||||
// Collect the change
|
||||
if (!pendingUpdates.has(setSearchParams)) {
|
||||
pendingUpdates.set(setSearchParams, new Map());
|
||||
|
||||
// Schedule a single flush at the end of the current microtask
|
||||
queueMicrotask(() => {
|
||||
const updates = pendingUpdates.get(setSearchParams);
|
||||
pendingUpdates.delete(setSearchParams);
|
||||
if (!updates || updates.size === 0) return;
|
||||
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
for (const [k, entry] of updates) {
|
||||
if (entry.remove) {
|
||||
next.delete(k);
|
||||
} else {
|
||||
next.set(k, entry.serialized);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const batch = pendingUpdates.get(setSearchParams);
|
||||
const serialized = stringify(newValue);
|
||||
if (newValue === defaultValue || newValue === null || newValue === undefined || serialized === null) {
|
||||
batch.set(key, { remove: true });
|
||||
} else {
|
||||
batch.set(key, { remove: false, serialized });
|
||||
}
|
||||
},
|
||||
[key, defaultValue, stringify, setSearchParams],
|
||||
);
|
||||
|
||||
return [value, setValue];
|
||||
}
|
||||
@@ -207,14 +207,17 @@ export const useFredyState = create(
|
||||
filter,
|
||||
}) {
|
||||
try {
|
||||
const qryString = queryString.stringify({
|
||||
page,
|
||||
pageSize,
|
||||
freeTextFilter,
|
||||
sortfield,
|
||||
sortdir,
|
||||
...filter,
|
||||
});
|
||||
const qryString = queryString.stringify(
|
||||
{
|
||||
page,
|
||||
pageSize,
|
||||
freeTextFilter,
|
||||
sortfield,
|
||||
sortdir,
|
||||
...filter,
|
||||
},
|
||||
{ skipNull: true, skipEmptyString: true },
|
||||
);
|
||||
const response = await xhrGet(`/api/listings/table?${qryString}`);
|
||||
set((state) => ({
|
||||
listingsData: { ...state.listingsData, ...response.json },
|
||||
@@ -304,17 +307,17 @@ export const useFredyState = create(
|
||||
throw Exception;
|
||||
}
|
||||
},
|
||||
async setImmoscoutDetails(enabled) {
|
||||
async setProviderDetails(providers) {
|
||||
try {
|
||||
await xhrPost('/api/user/settings/immoscout-details', { immoscout_details: enabled });
|
||||
await xhrPost('/api/user/settings/provider-details', { provider_details: providers });
|
||||
set((state) => ({
|
||||
userSettings: {
|
||||
...state.userSettings,
|
||||
settings: { ...state.userSettings.settings, immoscout_details: enabled },
|
||||
settings: { ...state.userSettings.settings, provider_details: providers },
|
||||
},
|
||||
}));
|
||||
} catch (Exception) {
|
||||
console.error('Error while trying to update immoscout details setting. Error:', Exception);
|
||||
console.error('Error while trying to update provider details setting. Error:', Exception);
|
||||
throw Exception;
|
||||
}
|
||||
},
|
||||
|
||||
17
ui/src/utils.js
Normal file
17
ui/src/utils.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
export function debounce(fn, delay) {
|
||||
let timer;
|
||||
|
||||
function debounced(...args) {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn.apply(this, args), delay);
|
||||
}
|
||||
|
||||
debounced.cancel = () => clearTimeout(timer);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button, Col, Row, Toast } from '@douyinfe/semi-ui-19';
|
||||
import { Button, Col, Row, Toast, Typography } from '@douyinfe/semi-ui-19';
|
||||
import {
|
||||
IconTerminal,
|
||||
IconStar,
|
||||
@@ -22,7 +22,6 @@ import KpiCard from '../../components/cards/KpiCard.jsx';
|
||||
import PieChartCard from '../../components/cards/PieChartCard.jsx';
|
||||
|
||||
import './Dashboard.less';
|
||||
import { SegmentPart } from '../../components/segment/SegmentPart.jsx';
|
||||
import { xhrPost } from '../../services/xhr.js';
|
||||
import { format } from '../../services/time/timeService.js';
|
||||
|
||||
@@ -35,129 +34,119 @@ export default function Dashboard() {
|
||||
|
||||
const kpis = dashboard?.kpis || { totalJobs: 0, totalListings: 0, providersUsed: 0 };
|
||||
const pieData = dashboard?.pie || [];
|
||||
const { Text } = Typography;
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<Text className="dashboard__section-label">General</Text>
|
||||
<Row gutter={[16, 16]} className="dashboard__row">
|
||||
<Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}>
|
||||
<SegmentPart name="General" Icon={IconTerminal}>
|
||||
<Row gutter={[16, 16]} className="dashboard__row">
|
||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||
<KpiCard
|
||||
title="Search Interval"
|
||||
value={`${dashboard?.general?.interval} min`}
|
||||
icon={<IconClock />}
|
||||
description="Time interval for job execution"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||
<KpiCard
|
||||
title="Last Search"
|
||||
valueFontSize="14px"
|
||||
value={
|
||||
dashboard?.general?.lastRun == null || dashboard?.general?.lastRun === 0
|
||||
? '---'
|
||||
: format(dashboard?.general?.lastRun)
|
||||
}
|
||||
icon={<IconDoubleChevronLeft />}
|
||||
description="Last execution timestamp"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||
<KpiCard
|
||||
title="Next Search"
|
||||
value={
|
||||
dashboard?.general?.nextRun == null || dashboard?.general?.nextRun === 0
|
||||
? '---'
|
||||
: format(dashboard?.general?.nextRun)
|
||||
}
|
||||
valueFontSize="14px"
|
||||
icon={<IconDoubleChevronRight />}
|
||||
description="Next execution timestamp"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||
<KpiCard title="Search Now" icon={<IconSearch />} description="Run a search now">
|
||||
<Button
|
||||
size="small"
|
||||
style={{ marginTop: '.2rem' }}
|
||||
icon={<IconPlayCircle />}
|
||||
aria-label="Start now"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await xhrPost('/api/jobs/startAll', null);
|
||||
Toast.success('Successfully triggered Fredy search.');
|
||||
} catch {
|
||||
Toast.error('Failed to trigger search');
|
||||
}
|
||||
}}
|
||||
>
|
||||
Search now
|
||||
</Button>
|
||||
</KpiCard>
|
||||
</Col>
|
||||
</Row>
|
||||
</SegmentPart>
|
||||
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||
<KpiCard
|
||||
title="Search Interval"
|
||||
value={`${dashboard?.general?.interval} min`}
|
||||
icon={<IconClock />}
|
||||
description="Time interval for job execution"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}>
|
||||
<SegmentPart name="Overview" Icon={IconStar}>
|
||||
<Row gutter={[16, 16]} className="dashboard__row">
|
||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||
<KpiCard
|
||||
title="Jobs"
|
||||
color="blue"
|
||||
value={!kpis.totalJobs ? '---' : kpis.totalJobs}
|
||||
icon={<IconTerminal />}
|
||||
description="Total number of jobs"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||
<KpiCard
|
||||
title="Listings"
|
||||
color="orange"
|
||||
value={!kpis.totalListings ? '---' : kpis.totalListings}
|
||||
icon={<IconStarStroked />}
|
||||
description="Total listings found"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||
<KpiCard
|
||||
title="Active Listings"
|
||||
color="green"
|
||||
value={!kpis.numberOfActiveListings ? '---' : kpis.numberOfActiveListings}
|
||||
icon={<IconStar />}
|
||||
description="Total active listings"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||
<KpiCard
|
||||
title="Avg. Price"
|
||||
color="purple"
|
||||
value={`${
|
||||
!kpis.avgPriceOfListings
|
||||
? '---'
|
||||
: new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(kpis.avgPriceOfListings)
|
||||
}`}
|
||||
icon={<IconNoteMoney />}
|
||||
description="Avg. Price of listings"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</SegmentPart>
|
||||
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||
<KpiCard
|
||||
title="Last Search"
|
||||
valueFontSize="14px"
|
||||
value={
|
||||
dashboard?.general?.lastRun == null || dashboard?.general?.lastRun === 0
|
||||
? '---'
|
||||
: format(dashboard?.general?.lastRun)
|
||||
}
|
||||
icon={<IconDoubleChevronLeft />}
|
||||
description="Last execution timestamp"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||
<KpiCard
|
||||
title="Next Search"
|
||||
value={
|
||||
dashboard?.general?.nextRun == null || dashboard?.general?.nextRun === 0
|
||||
? '---'
|
||||
: format(dashboard?.general?.nextRun)
|
||||
}
|
||||
valueFontSize="14px"
|
||||
icon={<IconDoubleChevronRight />}
|
||||
description="Next execution timestamp"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||
<KpiCard title="Search Now" icon={<IconSearch />} description="Run a search now">
|
||||
<Button
|
||||
size="small"
|
||||
style={{ marginTop: '.2rem' }}
|
||||
icon={<IconPlayCircle />}
|
||||
aria-label="Start now"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await xhrPost('/api/jobs/startAll', null);
|
||||
Toast.success('Successfully triggered Fredy search.');
|
||||
} catch {
|
||||
Toast.error('Failed to trigger search');
|
||||
}
|
||||
}}
|
||||
>
|
||||
Search now
|
||||
</Button>
|
||||
</KpiCard>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<SegmentPart
|
||||
name="Provider Insights"
|
||||
Icon={IconStar}
|
||||
helpText="Percentage of found listings over all providers"
|
||||
className="dashboard__provider-insights"
|
||||
>
|
||||
<Text className="dashboard__section-label">Overview</Text>
|
||||
<Row gutter={[16, 16]} className="dashboard__row">
|
||||
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||
<KpiCard
|
||||
title="Jobs"
|
||||
color="blue"
|
||||
value={!kpis.totalJobs ? '---' : kpis.totalJobs}
|
||||
icon={<IconTerminal />}
|
||||
description="Total number of jobs"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||
<KpiCard
|
||||
title="Listings"
|
||||
color="orange"
|
||||
value={!kpis.totalListings ? '---' : kpis.totalListings}
|
||||
icon={<IconStarStroked />}
|
||||
description="Total listings found"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||
<KpiCard
|
||||
title="Active Listings"
|
||||
color="green"
|
||||
value={!kpis.numberOfActiveListings ? '---' : kpis.numberOfActiveListings}
|
||||
icon={<IconStar />}
|
||||
description="Total active listings"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||
<KpiCard
|
||||
title="Avg. Price"
|
||||
color="purple"
|
||||
value={`${
|
||||
!kpis.avgPriceOfListings
|
||||
? '---'
|
||||
: new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(kpis.avgPriceOfListings)
|
||||
}`}
|
||||
icon={<IconNoteMoney />}
|
||||
description="Avg. Price of listings"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Text className="dashboard__section-label">Provider Insights</Text>
|
||||
<div className="dashboard__pie-wrapper">
|
||||
<PieChartCard data={pieData} />
|
||||
</SegmentPart>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,31 +3,32 @@
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
|
||||
&__row {
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.semi-col {
|
||||
margin-bottom: 0; // Handled by Row gutter
|
||||
}
|
||||
&__section-label {
|
||||
display: block;
|
||||
font-size: 11px !important;
|
||||
font-weight: 600 !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #5a6478 !important;
|
||||
margin-bottom: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
&__provider-insights {
|
||||
&__row {
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__pie-wrapper {
|
||||
background: #23242a;
|
||||
border: 1px solid #37404e;
|
||||
|
||||
border-radius: 10px;
|
||||
padding: 24px;
|
||||
max-height: 320px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 !important;
|
||||
|
||||
.semi-card-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
max-height: 300px;
|
||||
|
||||
> * {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,29 +3,33 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
|
||||
import { useActions, useSelector } from '../../services/state/store';
|
||||
import { useActions, useSelector, useIsLoading } from '../../services/state/store';
|
||||
|
||||
import { Divider, TimePicker, Button, Checkbox, Input, Modal } from '@douyinfe/semi-ui-19';
|
||||
import {
|
||||
Tabs,
|
||||
TabPane,
|
||||
TimePicker,
|
||||
Button,
|
||||
Checkbox,
|
||||
Input,
|
||||
Modal,
|
||||
AutoComplete,
|
||||
Select,
|
||||
Banner,
|
||||
} from '@douyinfe/semi-ui-19';
|
||||
import { InputNumber } from '@douyinfe/semi-ui-19';
|
||||
import { xhrPost } from '../../services/xhr';
|
||||
import { xhrPost, xhrGet } from '../../services/xhr';
|
||||
import { Toast } from '@douyinfe/semi-ui-19';
|
||||
import { SegmentPart } from '../../components/segment/SegmentPart';
|
||||
import { Banner, Toast } from '@douyinfe/semi-ui-19';
|
||||
import {
|
||||
downloadBackup as downloadBackupZip,
|
||||
precheckRestore as clientPrecheckRestore,
|
||||
restore as clientRestore,
|
||||
} from '../../services/backupRestoreClient';
|
||||
import {
|
||||
IconSave,
|
||||
IconCalendar,
|
||||
IconRefresh,
|
||||
IconSignal,
|
||||
IconLineChartStroked,
|
||||
IconSearch,
|
||||
IconFolder,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { IconSave, IconRefresh, IconSignal, IconHome, IconFolder } from '@douyinfe/semi-icons';
|
||||
import { debounce } from '../../utils';
|
||||
import './GeneralSettings.less';
|
||||
|
||||
function formatFromTimestamp(ts) {
|
||||
@@ -63,6 +67,15 @@ const GeneralSettings = function GeneralSettings() {
|
||||
const [restoreBusy, setRestoreBusy] = React.useState(false);
|
||||
const [selectedRestoreFile, setSelectedRestoreFile] = React.useState(null);
|
||||
|
||||
// User settings state
|
||||
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
|
||||
const providerDetails = useSelector((state) => state.userSettings.settings.provider_details);
|
||||
const allProviders = useSelector((state) => state.provider);
|
||||
const [address, setAddress] = useState(homeAddress?.address || '');
|
||||
const [coords, setCoords] = useState(homeAddress?.coords || null);
|
||||
const saving = useIsLoading(actions.userSettings.setHomeAddress);
|
||||
const [dataSource, setDataSource] = useState([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
async function init() {
|
||||
await actions.generalSettings.getGeneralSettings();
|
||||
@@ -86,6 +99,11 @@ const GeneralSettings = function GeneralSettings() {
|
||||
init();
|
||||
}, [settings]);
|
||||
|
||||
useEffect(() => {
|
||||
setAddress(homeAddress?.address || '');
|
||||
setCoords(homeAddress?.coords || null);
|
||||
}, [homeAddress]);
|
||||
|
||||
const nullOrEmpty = (val) => val == null || val.length === 0;
|
||||
|
||||
const handleStore = async () => {
|
||||
@@ -177,7 +195,6 @@ const GeneralSettings = function GeneralSettings() {
|
||||
if (!file) return;
|
||||
setSelectedRestoreFile(file);
|
||||
await precheckRestore(file);
|
||||
// reset the input to allow same file re-select
|
||||
ev.target.value = '';
|
||||
},
|
||||
[precheckRestore],
|
||||
@@ -189,180 +206,281 @@ const GeneralSettings = function GeneralSettings() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSaveUserSettings = async () => {
|
||||
try {
|
||||
const responseJson = await actions.userSettings.setHomeAddress(address);
|
||||
setCoords(responseJson.coords);
|
||||
await actions.userSettings.getUserSettings();
|
||||
Toast.success('Settings saved. Distance calculations are running in the background.');
|
||||
} catch (error) {
|
||||
Toast.error(error.json?.error || 'Error while saving settings');
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedSearch = useMemo(
|
||||
() =>
|
||||
debounce((value) => {
|
||||
xhrGet(`/api/user/settings/autocomplete?q=${encodeURIComponent(value)}`)
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
setDataSource(response.json);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 300),
|
||||
[],
|
||||
);
|
||||
|
||||
const searchAddress = (value) => {
|
||||
if (!value) {
|
||||
setDataSource([]);
|
||||
return;
|
||||
}
|
||||
debouncedSearch(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="generalSettings">
|
||||
{!loading && (
|
||||
<React.Fragment>
|
||||
<div>
|
||||
<SegmentPart
|
||||
name="Interval"
|
||||
helpText="Interval in minutes for running queries against the configured services. Do NOT go under 5 minutes as with a lower interval, your instance might be detected as a bot."
|
||||
Icon={IconRefresh}
|
||||
<>
|
||||
<Tabs type="line">
|
||||
<TabPane
|
||||
tab={
|
||||
<span>
|
||||
<IconSignal size="small" style={{ marginRight: 6 }} />
|
||||
System
|
||||
</span>
|
||||
}
|
||||
itemKey="system"
|
||||
>
|
||||
<InputNumber
|
||||
min={5}
|
||||
max={1440}
|
||||
placeholder="Interval in minutes"
|
||||
value={interval}
|
||||
formatter={(value) => `${value}`.replace(/\D/g, '')}
|
||||
onChange={(value) => setInterval(value)}
|
||||
suffix={'minutes'}
|
||||
/>
|
||||
</SegmentPart>
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart
|
||||
name="Backup & Restore"
|
||||
helpText="Download a zipped backup of your database or restore it from a backup zip."
|
||||
Icon={IconSave}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Button theme="solid" icon={<IconSave />} onClick={handleDownloadBackup}>
|
||||
Download backup
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleSelectRestoreFile}
|
||||
/>
|
||||
<Button onClick={handleOpenFilePicker} theme="light" icon={<IconFolder />}>
|
||||
Restore from zip
|
||||
</Button>
|
||||
<div className="generalSettings__tab-content">
|
||||
<SegmentPart name="Port" helpText="The port on which Fredy is running.">
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={99999}
|
||||
placeholder="Port"
|
||||
value={port}
|
||||
formatter={(value) => `${value}`.replace(/\D/g, '')}
|
||||
onChange={(value) => setPort(value)}
|
||||
style={{ maxWidth: 160 }}
|
||||
/>
|
||||
</SegmentPart>
|
||||
|
||||
<SegmentPart
|
||||
name="SQLite Database Path"
|
||||
helpText="The directory where Fredy stores its SQLite database files."
|
||||
>
|
||||
<Banner
|
||||
fullMode={false}
|
||||
type="warning"
|
||||
closeIcon={null}
|
||||
style={{ marginBottom: '12px' }}
|
||||
description="Changing this path may result in data loss. Restart Fredy immediately after saving."
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Database folder path"
|
||||
value={sqlitePath}
|
||||
onChange={(value) => setSqlitePath(value)}
|
||||
/>
|
||||
</SegmentPart>
|
||||
|
||||
<SegmentPart
|
||||
name="Analytics"
|
||||
helpText="Anonymous usage data to help improve Fredy — provider names, adapter names, OS, Node version, and architecture."
|
||||
>
|
||||
<Checkbox checked={analyticsEnabled} onChange={(e) => setAnalyticsEnabled(e.target.checked)}>
|
||||
Enable analytics
|
||||
</Checkbox>
|
||||
</SegmentPart>
|
||||
|
||||
<SegmentPart
|
||||
name="Demo Mode"
|
||||
helpText="In demo mode, Fredy will not search for real estates and all data resets to defaults at midnight."
|
||||
>
|
||||
<Checkbox checked={demoMode} onChange={(e) => setDemoMode(e.target.checked)}>
|
||||
Enable demo mode
|
||||
</Checkbox>
|
||||
</SegmentPart>
|
||||
|
||||
<div className="generalSettings__save-row">
|
||||
<Button type="primary" theme="solid" onClick={handleStore} icon={<IconSave />}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SegmentPart>
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart name="Port" helpText="Port on which Fredy is running." Icon={IconSignal}>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={99999}
|
||||
placeholder="Port"
|
||||
value={port}
|
||||
formatter={(value) => `${value}`.replace(/\D/g, '')}
|
||||
onChange={(value) => setPort(value)}
|
||||
/>
|
||||
</SegmentPart>
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart
|
||||
name="SQLite Database path"
|
||||
helpText="The directory where Fredy stores its SQLite database files."
|
||||
Icon={IconFolder}
|
||||
>
|
||||
<Banner
|
||||
fullMode={false}
|
||||
type="warning"
|
||||
closeIcon={null}
|
||||
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Warning</div>}
|
||||
style={{ marginBottom: '1rem' }}
|
||||
description={
|
||||
<div>
|
||||
Changing the path later may result in data loss.
|
||||
<br />
|
||||
You <b>must</b> restart Fredy immediately after changing this setting!
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</TabPane>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Select folder"
|
||||
value={sqlitePath}
|
||||
onChange={(value) => {
|
||||
setSqlitePath(value);
|
||||
}}
|
||||
/>
|
||||
</SegmentPart>
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart
|
||||
name="Working hours"
|
||||
helpText="During these hours, Fredy will search for new apartments. If nothing is configured, Fredy will search around the clock."
|
||||
Icon={IconCalendar}
|
||||
<TabPane
|
||||
tab={
|
||||
<span>
|
||||
<IconRefresh size="small" style={{ marginRight: 6 }} />
|
||||
Execution
|
||||
</span>
|
||||
}
|
||||
itemKey="execution"
|
||||
>
|
||||
<div className="generalSettings__timePickerContainer">
|
||||
<TimePicker
|
||||
format={'HH:mm'}
|
||||
insetLabel="From"
|
||||
value={formatFromTBackend(workingHourFrom)}
|
||||
placeholder=""
|
||||
onChange={(val) => {
|
||||
setWorkingHourFrom(val == null ? null : formatFromTimestamp(val));
|
||||
}}
|
||||
/>
|
||||
<TimePicker
|
||||
format={'HH:mm'}
|
||||
insetLabel="Until"
|
||||
value={formatFromTBackend(workingHourTo)}
|
||||
placeholder=""
|
||||
onChange={(val) => {
|
||||
setWorkingHourTo(val == null ? null : formatFromTimestamp(val));
|
||||
}}
|
||||
/>
|
||||
<div className="generalSettings__tab-content">
|
||||
<SegmentPart
|
||||
name="Search Interval"
|
||||
helpText="Interval in minutes for running queries against configured services. Do not go below 5 minutes to avoid being detected as a bot."
|
||||
>
|
||||
<InputNumber
|
||||
min={5}
|
||||
max={1440}
|
||||
placeholder="Interval in minutes"
|
||||
value={interval}
|
||||
formatter={(value) => `${value}`.replace(/\D/g, '')}
|
||||
onChange={(value) => setInterval(value)}
|
||||
suffix={'minutes'}
|
||||
style={{ maxWidth: 200 }}
|
||||
/>
|
||||
</SegmentPart>
|
||||
|
||||
<SegmentPart
|
||||
name="Working Hours"
|
||||
helpText="Fredy will only search for listings during these hours. Leave empty to search around the clock."
|
||||
>
|
||||
<div className="generalSettings__timePickerContainer">
|
||||
<TimePicker
|
||||
format={'HH:mm'}
|
||||
insetLabel="From"
|
||||
value={formatFromTBackend(workingHourFrom)}
|
||||
placeholder=""
|
||||
onChange={(val) => {
|
||||
setWorkingHourFrom(val == null ? null : formatFromTimestamp(val));
|
||||
}}
|
||||
/>
|
||||
<TimePicker
|
||||
format={'HH:mm'}
|
||||
insetLabel="Until"
|
||||
value={formatFromTBackend(workingHourTo)}
|
||||
placeholder=""
|
||||
onChange={(val) => {
|
||||
setWorkingHourTo(val == null ? null : formatFromTimestamp(val));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SegmentPart>
|
||||
|
||||
<div className="generalSettings__save-row">
|
||||
<Button type="primary" theme="solid" onClick={handleStore} icon={<IconSave />}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SegmentPart>
|
||||
<Divider margin="1rem" />
|
||||
</TabPane>
|
||||
|
||||
<SegmentPart name="Analytics" helpText="Insights into the usage of Fredy." Icon={IconLineChartStroked}>
|
||||
<Banner
|
||||
fullMode={false}
|
||||
type="info"
|
||||
closeIcon={null}
|
||||
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Explanation</div>}
|
||||
style={{ marginBottom: '1rem' }}
|
||||
description={
|
||||
<div>
|
||||
Analytics are disabled by default. If you choose to enable them, we will begin tracking the
|
||||
following:
|
||||
<br />
|
||||
<ul>
|
||||
<li>Name of active provider (e.g. Immoscout)</li>
|
||||
<li>Name of active adapter (e.g. Console)</li>
|
||||
<li>language</li>
|
||||
<li>os</li>
|
||||
<li>node version</li>
|
||||
<li>arch</li>
|
||||
</ul>
|
||||
The data is sent anonymously and helps me understand which providers or adapters are being used the
|
||||
most. In the end it helps me to improve fredy.
|
||||
<TabPane
|
||||
tab={
|
||||
<span>
|
||||
<IconHome size="small" style={{ marginRight: 6 }} />
|
||||
User Settings
|
||||
</span>
|
||||
}
|
||||
itemKey="userSettings"
|
||||
>
|
||||
<div className="generalSettings__tab-content">
|
||||
<SegmentPart
|
||||
name="Home Address"
|
||||
helpText="Used to calculate distances between your location and each listing. Updating this recalculates distances for all active listings."
|
||||
>
|
||||
<AutoComplete
|
||||
data={dataSource}
|
||||
value={address}
|
||||
showClear
|
||||
onChange={(v) => setAddress(v)}
|
||||
onSearch={searchAddress}
|
||||
placeholder="Enter your home address"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
{coords && coords.lat === -1 && (
|
||||
<Banner
|
||||
type="danger"
|
||||
description="Address found but could not be geocoded accurately."
|
||||
closeIcon={null}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
)}
|
||||
</SegmentPart>
|
||||
|
||||
<SegmentPart
|
||||
name="Provider Details"
|
||||
helpText="Fetch additional details (description, attributes, agent info) for listings. Needs an extra API call per listing."
|
||||
>
|
||||
<Banner
|
||||
type="warning"
|
||||
description="Enabling this significantly increases API requests to providers that have implemented this feature, raising the chance of rate limiting or blocking. Use at your own risk."
|
||||
closeIcon={null}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
<Select
|
||||
multiple
|
||||
style={{ width: '100%' }}
|
||||
value={Array.isArray(providerDetails) ? providerDetails : []}
|
||||
optionList={(allProviders ?? []).map((p) => ({ label: p.name, value: p.id }))}
|
||||
placeholder="Select providers to fetch details from..."
|
||||
onChange={async (selected) => {
|
||||
try {
|
||||
await actions.userSettings.setProviderDetails(selected);
|
||||
Toast.success('Provider details setting updated.');
|
||||
} catch {
|
||||
Toast.error('Failed to update setting.');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</SegmentPart>
|
||||
|
||||
<div className="generalSettings__save-row">
|
||||
<Button
|
||||
icon={<IconSave />}
|
||||
theme="solid"
|
||||
type="primary"
|
||||
onClick={handleSaveUserSettings}
|
||||
loading={saving}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabPane>
|
||||
|
||||
<TabPane
|
||||
tab={
|
||||
<span>
|
||||
<IconFolder size="small" style={{ marginRight: 6 }} />
|
||||
Backup & Restore
|
||||
</span>
|
||||
}
|
||||
itemKey="backup"
|
||||
>
|
||||
<div className="generalSettings__tab-content">
|
||||
<SegmentPart
|
||||
name="Backup & Restore"
|
||||
helpText="Download a zipped backup of your database or restore from a backup zip."
|
||||
>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Button theme="solid" icon={<IconSave />} onClick={handleDownloadBackup}>
|
||||
Download Backup
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleSelectRestoreFile}
|
||||
/>
|
||||
<Button onClick={handleOpenFilePicker} theme="light" icon={<IconFolder />}>
|
||||
Restore from Zip
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Checkbox checked={analyticsEnabled} onChange={(e) => setAnalyticsEnabled(e.target.checked)}>
|
||||
{' '}
|
||||
Enabled
|
||||
</Checkbox>
|
||||
</SegmentPart>
|
||||
|
||||
<Divider margin="1rem" />
|
||||
|
||||
<SegmentPart name="Demo Mode" helpText="If enabled, Fredy runs in demo mode." Icon={IconSearch}>
|
||||
<Banner
|
||||
fullMode={false}
|
||||
type="info"
|
||||
closeIcon={null}
|
||||
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Explanation</div>}
|
||||
style={{ marginBottom: '1rem' }}
|
||||
description={
|
||||
<div>
|
||||
In demo mode, Fredy will not (really) search for any real estates. Fredy is in a lockdown mode. Also
|
||||
all database files will be set back to the default values at midnight.
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Checkbox checked={demoMode} onChange={(e) => setDemoMode(e.target.checked)}>
|
||||
{' '}
|
||||
Enabled
|
||||
</Checkbox>
|
||||
</SegmentPart>
|
||||
|
||||
<Divider margin="1rem" />
|
||||
<Button type="primary" theme="solid" onClick={handleStore} icon={<IconSave />}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
</SegmentPart>
|
||||
</div>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
|
||||
{restoreModalVisible && (
|
||||
<Modal
|
||||
title="Restore database"
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
.generalSettings {
|
||||
&__tab-content {
|
||||
padding: 20px 0;
|
||||
max-width: 860px;
|
||||
}
|
||||
|
||||
&__timePickerContainer {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__help {
|
||||
font-size: 11px;
|
||||
margin-left: 1rem;
|
||||
&__save-row {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,11 +158,11 @@ export default function NotificationAdapterMutator({
|
||||
{uiElement.type === 'boolean' ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<Switch
|
||||
checked={uiElement.value || false}
|
||||
onChange={(checked) => {
|
||||
setValue(selectedAdapter, uiElement, key, checked);
|
||||
}}
|
||||
/>
|
||||
checked={uiElement.value || false}
|
||||
onChange={(checked) => {
|
||||
setValue(selectedAdapter, uiElement, key, checked);
|
||||
}}
|
||||
/>
|
||||
{uiElement.label}
|
||||
</div>
|
||||
) : (
|
||||
@@ -173,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);
|
||||
}}
|
||||
|
||||
@@ -4,24 +4,26 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { parseBoolean, parseNumber, parseString, useSearchParamState } from '../../hooks/useSearchParamState.js';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { useSelector, useActions } from '../../services/state/store.js';
|
||||
import { useActions, useSelector } from '../../services/state/store.js';
|
||||
import { distanceMeters, generateCircleCoords, getBoundsFromCenter, getBoundsFromCoords } from './mapUtils.js';
|
||||
import { Select, Space, Typography, Button, Popover, Divider, Switch, Banner, Toast } from '@douyinfe/semi-ui-19';
|
||||
import { IconFilter, IconLink } from '@douyinfe/semi-icons';
|
||||
import { IconDelete, IconEyeOpened } from '@douyinfe/semi-icons';
|
||||
import { Banner, Select, Switch, Toast, Typography } from '@douyinfe/semi-ui-19';
|
||||
import { IconDelete, IconEyeOpened, IconLink } from '@douyinfe/semi-icons';
|
||||
|
||||
import no_image from '../../assets/no_image.jpg';
|
||||
import RangeSlider from 'react-range-slider-input';
|
||||
import _RangeSlider from 'react-range-slider-input';
|
||||
import 'react-range-slider-input/dist/style.css';
|
||||
import './Map.less';
|
||||
import { xhrDelete } from '../../services/xhr.js';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import ListingDeletionModal from '../../components/ListingDeletionModal.jsx';
|
||||
import Map from '../../components/map/Map.jsx';
|
||||
|
||||
const RangeSlider = _RangeSlider?.default ?? _RangeSlider;
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function MapView() {
|
||||
@@ -31,16 +33,21 @@ export default function MapView() {
|
||||
const homeMarker = useRef(null);
|
||||
const actions = useActions();
|
||||
const navigate = useNavigate();
|
||||
const sp = useSearchParams();
|
||||
const [searchParams, setSearchParams] = sp;
|
||||
const listings = useSelector((state) => state.listingsData.mapListings);
|
||||
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
|
||||
const [style, setStyle] = useState('STANDARD');
|
||||
const [show3dBuildings, setShow3dBuildings] = useState(false);
|
||||
|
||||
const jobs = useSelector((state) => state.jobsData.jobs);
|
||||
const [jobId, setJobId] = useState(null);
|
||||
const [priceRange, setPriceRange] = useState([0, 0]);
|
||||
const [showFilterBar, setShowFilterBar] = useState(false);
|
||||
const [distanceFilter, setDistanceFilter] = useState(0);
|
||||
const [jobId, setJobId] = useSearchParamState(sp, 'job', null, parseString);
|
||||
const [distanceFilter, setDistanceFilter] = useSearchParamState(sp, 'distance', 0, parseNumber);
|
||||
const [style] = useSearchParamState(sp, 'style', 'STANDARD', parseString);
|
||||
const [show3dBuildings, setShow3dBuildings] = useSearchParamState(sp, 'buildings', false, parseBoolean);
|
||||
|
||||
// Price range: stored as priceMin/priceMax URL params; default max derived from loaded listings
|
||||
const urlPriceMin = searchParams.has('priceMin') ? Number(searchParams.get('priceMin')) : null;
|
||||
const urlPriceMax = searchParams.has('priceMax') ? Number(searchParams.get('priceMax')) : null;
|
||||
const [priceRange, setPriceRange] = useState([urlPriceMin ?? 0, urlPriceMax ?? 0]);
|
||||
|
||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||
const [listingToDelete, setListingToDelete] = useState(null);
|
||||
@@ -59,14 +66,17 @@ export default function MapView() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setPriceRange([0, getMaxPrice()]);
|
||||
// Only reset to full range when no URL override is set
|
||||
if (urlPriceMax === null) {
|
||||
setPriceRange([0, getMaxPrice()]);
|
||||
}
|
||||
}, [listings]);
|
||||
|
||||
const getMaxPrice = () => {
|
||||
return listings.reduce((max, item) => {
|
||||
return listings.reduce((acc, item) => {
|
||||
const price = Number(item.price);
|
||||
return Number.isFinite(price) && price > max ? price : max;
|
||||
}, -Infinity);
|
||||
return Number.isFinite(price) && price > acc ? price : acc;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const filterListings = () => {
|
||||
@@ -92,10 +102,8 @@ export default function MapView() {
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
// Get map instance reference after MapComponent renders
|
||||
useEffect(() => {
|
||||
if (mapContainer.current && !map.current) {
|
||||
// Wait for MapComponent to initialize the map
|
||||
const checkMapReady = () => {
|
||||
if (mapContainer.current?.map) {
|
||||
map.current = mapContainer.current.map;
|
||||
@@ -111,11 +119,45 @@ export default function MapView() {
|
||||
map.current = mapInstance;
|
||||
};
|
||||
|
||||
const setMapStyle = (value) => {
|
||||
setStyle(value);
|
||||
if (value === 'SATELLITE') {
|
||||
setShow3dBuildings(false);
|
||||
}
|
||||
const handleMapStyle = (value) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (value === 'STANDARD') {
|
||||
next.delete('style');
|
||||
} else {
|
||||
next.set('style', value);
|
||||
}
|
||||
if (value === 'SATELLITE') {
|
||||
next.delete('buildings');
|
||||
}
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
};
|
||||
|
||||
const handlePriceRange = (val) => {
|
||||
const maxPrice = getMaxPrice();
|
||||
if (maxPrice <= 0) return; // skip until listings are loaded
|
||||
setPriceRange(val);
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (val[0] === 0) {
|
||||
next.delete('priceMin');
|
||||
} else {
|
||||
next.set('priceMin', String(val[0]));
|
||||
}
|
||||
if (val[1] === 0 || val[1] >= maxPrice) {
|
||||
next.delete('priceMax');
|
||||
} else {
|
||||
next.set('priceMax', String(val[1]));
|
||||
}
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
};
|
||||
|
||||
const fetchListings = async () => {
|
||||
@@ -132,8 +174,6 @@ export default function MapView() {
|
||||
if (!map.current) return;
|
||||
|
||||
if (homeAddress?.coords) {
|
||||
// We only want to zoom/fly when distanceFilter OR homeAddress actually change,
|
||||
// not on every render. useEffect dependency array handles this.
|
||||
if (distanceFilter > 0) {
|
||||
const bounds = getBoundsFromCenter([homeAddress.coords.lng, homeAddress.coords.lat], distanceFilter);
|
||||
|
||||
@@ -290,7 +330,7 @@ export default function MapView() {
|
||||
|
||||
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(popupContent);
|
||||
|
||||
let color = '#3FB1CE'; // Default blue-ish
|
||||
let color = '#3FB1CE';
|
||||
if (distanceFilter > 0 && homeAddress?.coords) {
|
||||
const dist = distanceMeters(
|
||||
homeAddress.coords.lat,
|
||||
@@ -315,114 +355,17 @@ export default function MapView() {
|
||||
|
||||
return (
|
||||
<div className="map-view-container">
|
||||
<div className="listingsGrid__searchbar map-filter-bar">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexGrow: 1 }}>
|
||||
<Text strong>Map View</Text>
|
||||
<Select placeholder="Style" style={{ width: 120 }} value={style} onChange={(val) => setMapStyle(val)}>
|
||||
<Select.Option value="STANDARD">Standard</Select.Option>
|
||||
<Select.Option value="SATELLITE">Satellite</Select.Option>
|
||||
</Select>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginLeft: '1rem' }}>
|
||||
<Text strong>3D Buildings</Text>
|
||||
<Switch size="small" checked={show3dBuildings} onChange={(v) => setShow3dBuildings(v)} />
|
||||
</div>
|
||||
</div>
|
||||
<Popover content="Filter Results" style={{ color: 'white', padding: '.5rem' }}>
|
||||
<div>
|
||||
<Button
|
||||
icon={<IconFilter />}
|
||||
onClick={() => {
|
||||
setShowFilterBar(!showFilterBar);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{showFilterBar && (
|
||||
<div className="listingsGrid__toolbar">
|
||||
<Space wrap style={{ marginBottom: '1rem' }}>
|
||||
<div className="listingsGrid__toolbar__card">
|
||||
<div>
|
||||
<Text strong>Filter by:</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '.3rem', alignItems: 'center' }}>
|
||||
<Select
|
||||
placeholder="Job"
|
||||
showClear
|
||||
style={{ width: 150 }}
|
||||
onChange={(val) => {
|
||||
setJobId(val);
|
||||
}}
|
||||
value={jobId}
|
||||
>
|
||||
{jobs?.map((j) => (
|
||||
<Select.Option key={j.id} value={j.id}>
|
||||
{j.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Divider layout="vertical" />
|
||||
<div className="listingsGrid__toolbar__card">
|
||||
<div>
|
||||
<Text strong>Distance:</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '.3rem', alignItems: 'center' }}>
|
||||
<Select
|
||||
placeholder="Distance"
|
||||
style={{ width: 100 }}
|
||||
onChange={(val) => {
|
||||
setDistanceFilter(val);
|
||||
}}
|
||||
value={distanceFilter}
|
||||
>
|
||||
<Select.Option value={0}>---</Select.Option>
|
||||
<Select.Option value={5}>5 km</Select.Option>
|
||||
<Select.Option value={10}>10 km</Select.Option>
|
||||
<Select.Option value={15}>15 km</Select.Option>
|
||||
<Select.Option value={20}>20 km</Select.Option>
|
||||
<Select.Option value={25}>25 km</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Divider layout="vertical" />
|
||||
<div className="listingsGrid__toolbar__card">
|
||||
<div>
|
||||
<Text strong>Price Range (€):</Text>
|
||||
</div>
|
||||
<div style={{ width: 250, padding: '0 10px' }}>
|
||||
<div className="map__rangesliderLabels">
|
||||
<span>{priceRange[0]} €</span>
|
||||
<span>{priceRange[1]} €</span>
|
||||
</div>
|
||||
<RangeSlider
|
||||
min={0}
|
||||
max={getMaxPrice()}
|
||||
step={100}
|
||||
value={priceRange}
|
||||
onInput={(val) => {
|
||||
setPriceRange(val);
|
||||
}}
|
||||
tipFormatter={(val) => `${val} €`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!homeAddress && (
|
||||
<Banner
|
||||
fullMode={true}
|
||||
type="warning"
|
||||
bordered
|
||||
closeIcon={null}
|
||||
style={{ marginBottom: '8px' }}
|
||||
description={
|
||||
<span>
|
||||
You have not set your home address yet. Please do so in the <Link to="/userSettings">user settings</Link>{' '}
|
||||
to use the distance filter.
|
||||
No home address set. Configure it in <Link to="/userSettings">user settings</Link> to use the distance
|
||||
filter.
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
@@ -433,10 +376,97 @@ export default function MapView() {
|
||||
type="info"
|
||||
bordered
|
||||
closeIcon={null}
|
||||
description="Keep in mind, only listings with proper adresses are being shown on this map."
|
||||
style={{ marginBottom: '8px' }}
|
||||
description="Only listings with valid addresses are shown on this map."
|
||||
/>
|
||||
|
||||
<Map mapContainerRef={mapContainer} style={style} show3dBuildings={show3dBuildings} onMapReady={handleMapReady} />
|
||||
<div className="map-view-container__map-wrapper">
|
||||
<Map
|
||||
mapContainerRef={mapContainer}
|
||||
style={style}
|
||||
show3dBuildings={show3dBuildings}
|
||||
onMapReady={handleMapReady}
|
||||
/>
|
||||
|
||||
{/* Floating filter panel */}
|
||||
<div className="map-view-container__floating-panel">
|
||||
<div className="map-view-container__panel-row">
|
||||
<Text size="small" strong style={{ color: '#8892a4' }}>
|
||||
Job
|
||||
</Text>
|
||||
<Select
|
||||
placeholder="All jobs"
|
||||
showClear
|
||||
size="small"
|
||||
onChange={(val) => setJobId(val)}
|
||||
value={jobId}
|
||||
style={{ width: 160 }}
|
||||
>
|
||||
{jobs?.map((j) => (
|
||||
<Select.Option key={j.id} value={j.id}>
|
||||
{j.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="map-view-container__panel-row">
|
||||
<Text size="small" strong style={{ color: '#8892a4' }}>
|
||||
Distance
|
||||
</Text>
|
||||
<Select
|
||||
placeholder="None"
|
||||
size="small"
|
||||
onChange={(val) => setDistanceFilter(val)}
|
||||
value={distanceFilter}
|
||||
style={{ width: 100 }}
|
||||
>
|
||||
<Select.Option value={0}>None</Select.Option>
|
||||
<Select.Option value={5}>5 km</Select.Option>
|
||||
<Select.Option value={10}>10 km</Select.Option>
|
||||
<Select.Option value={15}>15 km</Select.Option>
|
||||
<Select.Option value={20}>20 km</Select.Option>
|
||||
<Select.Option value={25}>25 km</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="map-view-container__panel-row">
|
||||
<Text size="small" strong style={{ color: '#8892a4' }}>
|
||||
Price (€)
|
||||
</Text>
|
||||
<div className="map-view-container__price-slider">
|
||||
<div className="map__rangesliderLabels">
|
||||
<span>{priceRange[0]}</span>
|
||||
<span>{priceRange[1]}</span>
|
||||
</div>
|
||||
<RangeSlider min={0} max={getMaxPrice()} step={100} value={priceRange} onInput={handlePriceRange} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="map-view-container__panel-row">
|
||||
<Text size="small" strong style={{ color: '#8892a4' }}>
|
||||
Style
|
||||
</Text>
|
||||
<Select size="small" value={style} onChange={(val) => handleMapStyle(val)} style={{ width: 110 }}>
|
||||
<Select.Option value="STANDARD">Standard</Select.Option>
|
||||
<Select.Option value="SATELLITE">Satellite</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="map-view-container__panel-row">
|
||||
<Text size="small" strong style={{ color: '#8892a4' }}>
|
||||
3D Buildings
|
||||
</Text>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={show3dBuildings}
|
||||
onChange={(v) => setShow3dBuildings(v)}
|
||||
disabled={style === 'SATELLITE'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ListingDeletionModal
|
||||
visible={deleteModalVisible}
|
||||
onConfirm={confirmListingDeletion}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
@@ -9,18 +9,48 @@
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.map-filter-bar {
|
||||
margin-bottom: 1rem;
|
||||
&__map-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&__floating-panel {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 10;
|
||||
background: rgba(13, 15, 20, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid #262a3a;
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
min-width: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__panel-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__price-slider {
|
||||
width: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
.map-container {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--semi-color-border);
|
||||
border: 1px solid #262a3a;
|
||||
}
|
||||
|
||||
.map-popup-content {
|
||||
@@ -126,7 +156,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Override MapLibre default popup styles to match application theme */
|
||||
/* Override MapLibre default popup styles */
|
||||
.maplibregl-popup-content {
|
||||
background-color: var(--semi-color-bg-1) !important;
|
||||
color: var(--semi-color-text-0) !important;
|
||||
@@ -140,21 +170,26 @@
|
||||
}
|
||||
|
||||
.map {
|
||||
&__rangesliderLabels{
|
||||
color: white;
|
||||
&__rangesliderLabels {
|
||||
color: #94a3b8;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: .3rem;
|
||||
font-size: .7rem;
|
||||
}
|
||||
}
|
||||
|
||||
.range-slider .range-slider__thumb {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: 50%;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
background: #2196f3;
|
||||
}
|
||||
background: #0ab5b3;
|
||||
}
|
||||
|
||||
.range-slider .range-slider__range {
|
||||
background: #0ab5b3;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import Logo from '../../components/logo/Logo';
|
||||
import { xhrPost } from '../../services/xhr';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useActions, useSelector } from '../../services/state/store';
|
||||
import { Input, Button, Banner, Toast } from '@douyinfe/semi-ui-19';
|
||||
import { Input, Button, Banner } from '@douyinfe/semi-ui-19';
|
||||
|
||||
import './login.less';
|
||||
import { IconUser, IconLock } from '@douyinfe/semi-icons';
|
||||
@@ -45,12 +45,10 @@ export default function Login() {
|
||||
});
|
||||
/* eslint-disable no-unused-vars */
|
||||
} catch (ignored) {
|
||||
Toast.error('Login unsuccessful…');
|
||||
setError('Login unsuccessful. Please check your username and password.');
|
||||
return;
|
||||
}
|
||||
|
||||
Toast.success('Login successful!');
|
||||
|
||||
await actions.user.getCurrentUser();
|
||||
navigate('/dashboard');
|
||||
};
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { Divider, Button, AutoComplete, Toast, Banner, Switch } from '@douyinfe/semi-ui-19';
|
||||
import { IconSave, IconHome, IconSearch } from '@douyinfe/semi-icons';
|
||||
import { useSelector, useActions, useIsLoading } from '../../services/state/store';
|
||||
import { xhrGet } from '../../services/xhr';
|
||||
import { SegmentPart } from '../../components/segment/SegmentPart';
|
||||
import debounce from 'lodash/debounce';
|
||||
|
||||
const UserSettings = () => {
|
||||
const actions = useActions();
|
||||
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
|
||||
const immoscoutDetails = useSelector((state) => state.userSettings.settings.immoscout_details);
|
||||
const [address, setAddress] = useState(homeAddress?.address || '');
|
||||
const [coords, setCoords] = useState(homeAddress?.coords || null);
|
||||
const saving = useIsLoading(actions.userSettings.setHomeAddress);
|
||||
const [dataSource, setDataSource] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
setAddress(homeAddress?.address || '');
|
||||
setCoords(homeAddress?.coords || null);
|
||||
}, [homeAddress]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const responseJson = await actions.userSettings.setHomeAddress(address);
|
||||
setCoords(responseJson.coords);
|
||||
await actions.userSettings.getUserSettings();
|
||||
Toast.success(
|
||||
'Settings saved successfully. We will now start calculating distances for you. This may take a while and runs in the background.',
|
||||
);
|
||||
} catch (error) {
|
||||
Toast.error(error.json?.error || 'Error while saving settings');
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedSearch = useMemo(
|
||||
() =>
|
||||
debounce((value) => {
|
||||
xhrGet(`/api/user/settings/autocomplete?q=${encodeURIComponent(value)}`)
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
setDataSource(response.json);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently fail for autocomplete
|
||||
});
|
||||
}, 300),
|
||||
[],
|
||||
);
|
||||
|
||||
const searchAddress = (value) => {
|
||||
if (!value) {
|
||||
setDataSource([]);
|
||||
return;
|
||||
}
|
||||
debouncedSearch(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="user-settings">
|
||||
<SegmentPart
|
||||
name="Distance claculation"
|
||||
Icon={IconHome}
|
||||
helpText="The address you enter is used to calculate the distance between your chosen location and each listing. The distance is computed using an approximate mathematical method and is intended to give you a rough indication of commute time. If you update your address, we will recalculate the distance for all active listings."
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', maxWidth: '600px' }}>
|
||||
<AutoComplete
|
||||
data={dataSource}
|
||||
value={address}
|
||||
showClear
|
||||
onChange={(v) => setAddress(v)}
|
||||
onSearch={searchAddress}
|
||||
placeholder="Enter your home address"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
{coords && coords.lat === -1 && (
|
||||
<Banner type="danger" description="Address found but could not be geocoded accurately." closeIcon={null} />
|
||||
)}
|
||||
</div>
|
||||
</SegmentPart>
|
||||
<Divider />
|
||||
<SegmentPart
|
||||
name="ImmoScout Details"
|
||||
Icon={IconSearch}
|
||||
helpText="When enabled, Fredy will fetch additional details (description, attributes, agent info) for each listing from ImmoScout. This provides richer notifications but makes an extra API call per listing."
|
||||
>
|
||||
<Banner
|
||||
type="warning"
|
||||
description="Enabling this feature significantly increases the number of API requests to ImmoScout. This raises the likelihood of being detected and rate-limited or blocked. Use at your own risk."
|
||||
closeIcon={null}
|
||||
style={{ marginBottom: '12px', maxWidth: '600px' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<Switch
|
||||
checked={!!immoscoutDetails}
|
||||
onChange={async (checked) => {
|
||||
try {
|
||||
await actions.userSettings.setImmoscoutDetails(checked);
|
||||
Toast.success('ImmoScout details setting updated.');
|
||||
} catch {
|
||||
Toast.error('Failed to update setting.');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>Fetch detailed ImmoScout listings</span>
|
||||
</div>
|
||||
</SegmentPart>
|
||||
<Divider />
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<Button icon={<IconSave />} theme="solid" type="primary" onClick={handleSave} loading={saving}>
|
||||
Save Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserSettings;
|
||||
@@ -12,6 +12,18 @@ export default defineConfig({
|
||||
chunkSizeWarningLimit: 9999999,
|
||||
outDir: './ui/public',
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('node_modules/maplibre-gl')) {
|
||||
return 'maplibre-gl';
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['maplibre-gl'],
|
||||
},
|
||||
plugins: [react()],
|
||||
server: {
|
||||
|
||||
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