mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64d0515c79 | ||
|
|
cc0164b689 | ||
|
|
522bbc2282 | ||
|
|
c384781137 | ||
|
|
e2d10d179e | ||
|
|
10c94eea0a | ||
|
|
05f74f99ef | ||
|
|
f3ad529107 | ||
|
|
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 |
1
.github/workflows/docker.yml
vendored
1
.github/workflows/docker.yml
vendored
@@ -62,6 +62,7 @@ jobs:
|
|||||||
- name: Test container with docker compose
|
- name: Test container with docker compose
|
||||||
run: |
|
run: |
|
||||||
echo "Starting container with docker compose..."
|
echo "Starting container with docker compose..."
|
||||||
|
mkdir -p ./db ./conf && chmod 777 ./db ./conf
|
||||||
docker compose up --build -d
|
docker compose up --build -d
|
||||||
echo "Waiting for container to be ready (60 seconds for start_period)..."
|
echo "Waiting for container to be ready (60 seconds for start_period)..."
|
||||||
sleep 60
|
sleep 60
|
||||||
|
|||||||
81
Dockerfile
81
Dockerfile
@@ -1,70 +1,55 @@
|
|||||||
# ================================
|
FROM node:22-slim
|
||||||
# Stage 1: Build stage
|
|
||||||
# ================================
|
|
||||||
FROM node:22-alpine AS builder
|
|
||||||
|
|
||||||
WORKDIR /build
|
ARG TARGETARCH
|
||||||
|
|
||||||
# Install build dependencies needed for native modules (better-sqlite3)
|
# System deps for Chrome for Testing + build tools for native modules (better-sqlite3)
|
||||||
RUN apk add --no-cache python3 make g++
|
# 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 ./
|
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 \
|
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 index.html vite.config.js ./
|
||||||
COPY ui ./ui
|
COPY ui ./ui
|
||||||
COPY lib ./lib
|
COPY lib ./lib
|
||||||
|
|
||||||
# Build frontend assets
|
|
||||||
RUN yarn build:frontend
|
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.js ./
|
||||||
COPY index.html ./
|
|
||||||
COPY lib ./lib
|
|
||||||
|
|
||||||
# Prepare runtime directories and symlinks for data and config
|
RUN ln -s /db /fredy/db \
|
||||||
RUN mkdir -p /db /conf \
|
|
||||||
&& chown 1000:1000 /db /conf \
|
|
||||||
&& chmod 777 /db /conf \
|
|
||||||
&& ln -s /db /fredy/db \
|
|
||||||
&& ln -s /conf /fredy/conf
|
&& ln -s /conf /fredy/conf
|
||||||
|
|
||||||
EXPOSE 9998
|
EXPOSE 9998
|
||||||
VOLUME /db
|
VOLUME /db
|
||||||
VOLUME /conf
|
VOLUME /conf
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:9998/ || exit 1
|
||||||
|
|
||||||
CMD ["node", "index.js"]
|
CMD ["node", "index.js"]
|
||||||
|
|||||||
@@ -7,12 +7,72 @@ if [ "$(docker ps -aq -f name=fredy)" ]; then
|
|||||||
docker rm fredy || true
|
docker rm fredy || true
|
||||||
fi
|
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
|
# 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
|
# Run container with volumes and port mapping
|
||||||
docker run -d --name fredy \
|
if [ -n "$PLATFORM" ]; then
|
||||||
-v fredy_conf:/conf \
|
docker run -d --name fredy --platform "$PLATFORM" -v fredy_conf:/conf -v fredy_db:/db -p 9998:9998 fredy:local
|
||||||
-v fredy_db:/db \
|
else
|
||||||
-p 9998:9998 \
|
docker run -d --name fredy -v fredy_conf:/conf -v fredy_db:/db -p 9998:9998 fredy:local
|
||||||
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: {
|
||||||
...globals.browser,
|
...globals.browser,
|
||||||
...globals.node,
|
...globals.node,
|
||||||
...globals.mocha,
|
...globals.jest,
|
||||||
Promise: 'readonly',
|
Promise: 'readonly',
|
||||||
fetch: 'readonly',
|
fetch: 'readonly',
|
||||||
describe: 'readonly',
|
describe: 'readonly',
|
||||||
after: 'readonly',
|
after: 'readonly',
|
||||||
it: 'readonly',
|
it: 'readonly',
|
||||||
|
beforeEach: 'readonly',
|
||||||
|
afterEach: 'readonly',
|
||||||
|
vi: 'readonly',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: { react },
|
plugins: { react },
|
||||||
|
|||||||
12
jsconfig.json
Normal file
12
jsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"target": "ESNext",
|
||||||
|
"checkJs": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": false
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "ui"]
|
||||||
|
}
|
||||||
@@ -19,22 +19,14 @@ import { distanceMeters } from './services/listings/distanceCalculator.js';
|
|||||||
import { getUserSettings } from './services/storage/settingsStorage.js';
|
import { getUserSettings } from './services/storage/settingsStorage.js';
|
||||||
import { updateListingDistance } from './services/storage/listingsStorage.js';
|
import { updateListingDistance } from './services/storage/listingsStorage.js';
|
||||||
import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
|
import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
|
||||||
|
import { formatListing } from './utils/formatListing.js';
|
||||||
|
|
||||||
/**
|
/** @import { ParsedListing } from './types/listing.js' */
|
||||||
* @typedef {Object} Listing
|
/** @import { Job } from './types/job.js' */
|
||||||
* @property {string} id Stable unique identifier (hash) of the listing.
|
/** @import { ProviderConfig } from './types/providerConfig.js' */
|
||||||
* @property {string} title Title or headline of the listing.
|
/** @import { SpecFilter, SpatialFilter } from './types/filter.js' */
|
||||||
* @property {string} [address] Optional address/location text.
|
/** @import { SimilarityCache } from './types/similarityCache.js' */
|
||||||
* @property {string} [price] Optional price text/value.
|
/** @import { Browser } from './types/browser.js' */
|
||||||
* @property {string} [url] Link to the listing detail page.
|
|
||||||
* @property {any} [meta] Provider-specific additional metadata.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} SimilarityCache
|
|
||||||
* @property {(title:string, address?:string)=>boolean} hasSimilarEntries Returns true if a similar entry is known.
|
|
||||||
* @property {(title:string, address?:string)=>void} addCacheEntry Adds a new entry to the similarity cache.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runtime orchestrator for fetching, normalizing, filtering, deduplicating, storing,
|
* Runtime orchestrator for fetching, normalizing, filtering, deduplicating, storing,
|
||||||
@@ -48,42 +40,43 @@ import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
|
|||||||
* 5) Identify new listings (vs. previously stored hashes)
|
* 5) Identify new listings (vs. previously stored hashes)
|
||||||
* 6) Persist new listings
|
* 6) Persist new listings
|
||||||
* 7) Filter out entries similar to already seen ones
|
* 7) Filter out entries similar to already seen ones
|
||||||
* 8) Dispatch notifications
|
* 8) Filter out entries that do not match the job's specFilter
|
||||||
|
* 9) Filter out entries that do not match the job's spatialFilter
|
||||||
|
* 10) Dispatch notifications
|
||||||
*/
|
*/
|
||||||
class FredyPipelineExecutioner {
|
class FredyPipelineExecutioner {
|
||||||
/**
|
/**
|
||||||
* Create a new runtime instance for a single provider/job execution.
|
* Create a new runtime instance for a single provider/job execution.
|
||||||
*
|
*
|
||||||
* @param {Object} providerConfig Provider configuration.
|
* @param {ProviderConfig} providerConfig Provider configuration.
|
||||||
* @param {string} providerConfig.url Base URL to crawl.
|
* @param {Job} job Job configuration.
|
||||||
* @param {string} [providerConfig.sortByDateParam] Query parameter used to enforce sorting by date (provider-specific).
|
|
||||||
* @param {string} [providerConfig.waitForSelector] CSS selector to wait for before parsing content.
|
|
||||||
* @param {Object.<string, string>} providerConfig.crawlFields Mapping of field names to selectors/paths to extract.
|
|
||||||
* @param {string} providerConfig.crawlContainer CSS selector for the container holding listing items.
|
|
||||||
* @param {(raw:any)=>Listing} providerConfig.normalize Function to convert raw scraped data into a Listing shape.
|
|
||||||
* @param {(listing:Listing)=>boolean} providerConfig.filter Function to filter out unwanted listings.
|
|
||||||
* @param {(url:string, waitForSelector?:string)=>Promise<void>|Promise<Listing[]>} [providerConfig.getListings] Optional override to fetch listings.
|
|
||||||
* @param {Object} notificationConfig Notification configuration passed to notification adapters.
|
|
||||||
* @param {Object} spatialFilter Optional spatial filter configuration.
|
|
||||||
* @param {string} providerId The ID of the provider currently in use.
|
* @param {string} providerId The ID of the provider currently in use.
|
||||||
* @param {string} jobKey Key of the job that is currently running (from within the config).
|
|
||||||
* @param {SimilarityCache} similarityCache Cache instance for checking similar entries.
|
* @param {SimilarityCache} similarityCache Cache instance for checking similar entries.
|
||||||
* @param browser
|
* @param {Browser} browser Puppeteer browser instance.
|
||||||
*/
|
*/
|
||||||
constructor(providerConfig, notificationConfig, spatialFilter, providerId, jobKey, similarityCache, browser) {
|
constructor(providerConfig, job, providerId, similarityCache, browser) {
|
||||||
|
/** @type {ProviderConfig} */
|
||||||
this._providerConfig = providerConfig;
|
this._providerConfig = providerConfig;
|
||||||
this._notificationConfig = notificationConfig;
|
/** @type {Object} */
|
||||||
this._spatialFilter = spatialFilter;
|
this._jobNotificationConfig = job.notificationAdapter;
|
||||||
|
/** @type {string} */
|
||||||
|
this._jobKey = job.id;
|
||||||
|
/** @type {SpecFilter | null} */
|
||||||
|
this._jobSpecFilter = job.specFilter;
|
||||||
|
/** @type {SpatialFilter | null} */
|
||||||
|
this._jobSpatialFilter = job.spatialFilter;
|
||||||
|
/** @type {string} */
|
||||||
this._providerId = providerId;
|
this._providerId = providerId;
|
||||||
this._jobKey = jobKey;
|
/** @type {SimilarityCache} */
|
||||||
this._similarityCache = similarityCache;
|
this._similarityCache = similarityCache;
|
||||||
|
/** @type {Browser} */
|
||||||
this._browser = browser;
|
this._browser = browser;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute the end-to-end pipeline for a single provider run.
|
* Execute the end-to-end pipeline for a single provider run.
|
||||||
*
|
*
|
||||||
* @returns {Promise<Listing[]|void>} Resolves to the list of new (and similarity-filtered) listings
|
* @returns {Promise<ParsedListing[]|void>} Resolves to the list of new (and similarity-filtered) listings
|
||||||
* after notifications have been sent; resolves to void when there are no new listings.
|
* after notifications have been sent; resolves to void when there are no new listings.
|
||||||
*/
|
*/
|
||||||
execute() {
|
execute() {
|
||||||
@@ -92,20 +85,48 @@ class FredyPipelineExecutioner {
|
|||||||
.then(this._normalize.bind(this))
|
.then(this._normalize.bind(this))
|
||||||
.then(this._filter.bind(this))
|
.then(this._filter.bind(this))
|
||||||
.then(this._findNew.bind(this))
|
.then(this._findNew.bind(this))
|
||||||
|
.then(this._fetchDetails.bind(this))
|
||||||
.then(this._geocode.bind(this))
|
.then(this._geocode.bind(this))
|
||||||
.then(this._save.bind(this))
|
.then(this._save.bind(this))
|
||||||
.then(this._calculateDistance.bind(this))
|
.then(this._calculateDistance.bind(this))
|
||||||
.then(this._filterBySimilarListings.bind(this))
|
.then(this._filterBySimilarListings.bind(this))
|
||||||
|
.then(this._filterBySpecs.bind(this))
|
||||||
.then(this._filterByArea.bind(this))
|
.then(this._filterByArea.bind(this))
|
||||||
.then(this._notify.bind(this))
|
.then(this._notify.bind(this))
|
||||||
.catch(this._handleError.bind(this));
|
.catch(this._handleError.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optionally enrich new listings with data from their detail pages.
|
||||||
|
* Only called when the provider config defines a `fetchDetails` function.
|
||||||
|
* Runs all fetches in parallel. Each individual fetch must handle its own errors
|
||||||
|
* and always resolve (never reject) to avoid aborting other listings.
|
||||||
|
*
|
||||||
|
* @param {Listing[]} newListings New listings to enrich.
|
||||||
|
* @returns {Promise<Listing[]>} Resolves with enriched listings.
|
||||||
|
*/
|
||||||
|
async _fetchDetails(newListings) {
|
||||||
|
if (typeof this._providerConfig.fetchDetails !== 'function') {
|
||||||
|
return newListings;
|
||||||
|
}
|
||||||
|
const userId = getJob(this._jobKey)?.userId;
|
||||||
|
const enabledProviders = getUserSettings(userId)?.provider_details ?? [];
|
||||||
|
if (!userId || !Array.isArray(enabledProviders) || !enabledProviders.includes(this._providerId)) {
|
||||||
|
return newListings;
|
||||||
|
}
|
||||||
|
const listingsToEnrich = process.env.NODE_ENV === 'test' ? newListings.slice(0, 1) : newListings;
|
||||||
|
const enriched = [];
|
||||||
|
for (const listing of listingsToEnrich) {
|
||||||
|
enriched.push(await this._providerConfig.fetchDetails(listing, this._browser));
|
||||||
|
}
|
||||||
|
return enriched;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Geocode new listings.
|
* Geocode new listings.
|
||||||
*
|
*
|
||||||
* @param {Listing[]} newListings New listings to geocode.
|
* @param {ParsedListing[]} newListings New listings to geocode.
|
||||||
* @returns {Promise<Listing[]>} Resolves with the listings (potentially with added coordinates).
|
* @returns {Promise<ParsedListing[]>} Resolves with the listings (potentially with added coordinates).
|
||||||
*/
|
*/
|
||||||
async _geocode(newListings) {
|
async _geocode(newListings) {
|
||||||
for (const listing of newListings) {
|
for (const listing of newListings) {
|
||||||
@@ -124,18 +145,18 @@ class FredyPipelineExecutioner {
|
|||||||
* Filter listings by area using the provider's area filter if available.
|
* Filter listings by area using the provider's area filter if available.
|
||||||
* Only filters if areaFilter is set on the provider AND the listing has coordinates.
|
* Only filters if areaFilter is set on the provider AND the listing has coordinates.
|
||||||
*
|
*
|
||||||
* @param {Listing[]} newListings New listings to filter by area.
|
* @param {ParsedListing[]} newListings New listings to filter by area.
|
||||||
* @returns {Promise<Listing[]>} Resolves with listings that are within the area (or not filtered if no area is set).
|
* @returns {ParsedListing[]} Resolves with listings that are within the area (or not filtered if no area is set).
|
||||||
*/
|
*/
|
||||||
_filterByArea(newListings) {
|
_filterByArea(newListings) {
|
||||||
const polygonFeatures = this._spatialFilter?.features?.filter((f) => f.geometry?.type === 'Polygon');
|
const polygonFeatures = this._jobSpatialFilter?.features?.filter((f) => f.geometry?.type === 'Polygon');
|
||||||
|
|
||||||
// If no area filter is set, return all listings
|
// If no area filter is set, return all listings
|
||||||
if (!polygonFeatures?.length) {
|
if (!polygonFeatures?.length) {
|
||||||
return newListings;
|
return newListings;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredIds = [];
|
const toDeleteListingByIds = [];
|
||||||
// Filter listings by area - keep only those within the polygon
|
// Filter listings by area - keep only those within the polygon
|
||||||
const keptListings = newListings.filter((listing) => {
|
const keptListings = newListings.filter((listing) => {
|
||||||
// If listing doesn't have coordinates, keep it (don't filter out)
|
// If listing doesn't have coordinates, keep it (don't filter out)
|
||||||
@@ -148,14 +169,48 @@ class FredyPipelineExecutioner {
|
|||||||
const isInPolygon = polygonFeatures.some((feature) => booleanPointInPolygon(point, feature));
|
const isInPolygon = polygonFeatures.some((feature) => booleanPointInPolygon(point, feature));
|
||||||
|
|
||||||
if (!isInPolygon) {
|
if (!isInPolygon) {
|
||||||
filteredIds.push(listing.id);
|
toDeleteListingByIds.push(listing.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return isInPolygon;
|
return isInPolygon;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (filteredIds.length > 0) {
|
if (toDeleteListingByIds.length > 0) {
|
||||||
deleteListingsById(filteredIds);
|
deleteListingsById(toDeleteListingByIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return keptListings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter listings based on its specifications (minRooms, minSize, maxPrice).
|
||||||
|
*
|
||||||
|
* @param {ParsedListing[]} newListings New listings to filter.
|
||||||
|
* @returns {ParsedListing[]} Resolves with listings that pass the specification filters.
|
||||||
|
*/
|
||||||
|
_filterBySpecs(newListings) {
|
||||||
|
const { minRooms, minSize, maxPrice } = this._jobSpecFilter || {};
|
||||||
|
|
||||||
|
// If no specs are set, return all listings
|
||||||
|
if (!minRooms && !minSize && !maxPrice) {
|
||||||
|
return newListings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toDeleteListingByIds = [];
|
||||||
|
const keptListings = newListings.filter((listing) => {
|
||||||
|
const filterOut =
|
||||||
|
(minRooms && listing.rooms && listing.rooms < minRooms) ||
|
||||||
|
(minSize && listing.size && listing.size < minSize) ||
|
||||||
|
(maxPrice && listing.price && listing.price > maxPrice);
|
||||||
|
|
||||||
|
if (filterOut) {
|
||||||
|
toDeleteListingByIds.push(listing.id);
|
||||||
|
}
|
||||||
|
return !filterOut;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (toDeleteListingByIds.length > 0) {
|
||||||
|
deleteListingsById(toDeleteListingByIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
return keptListings;
|
return keptListings;
|
||||||
@@ -166,7 +221,7 @@ class FredyPipelineExecutioner {
|
|||||||
* a provider-specific getListings override is supplied.
|
* a provider-specific getListings override is supplied.
|
||||||
*
|
*
|
||||||
* @param {string} url The provider URL to fetch from.
|
* @param {string} url The provider URL to fetch from.
|
||||||
* @returns {Promise<Listing[]>} Resolves with an array of listings (empty when none found).
|
* @returns {Promise<ParsedListing[]>} Resolves with an array of listings (empty when none found).
|
||||||
*/
|
*/
|
||||||
_getListings(url) {
|
_getListings(url) {
|
||||||
const extractor = new Extractor({ ...this._providerConfig.puppeteerOptions, browser: this._browser });
|
const extractor = new Extractor({ ...this._providerConfig.puppeteerOptions, browser: this._browser });
|
||||||
@@ -189,33 +244,42 @@ class FredyPipelineExecutioner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize raw listings into the provider-specific Listing shape.
|
* Normalize raw listings into the provider-specific ParsedListing shape.
|
||||||
*
|
*
|
||||||
* @param {any[]} listings Raw listing entries from the extractor or override.
|
* @param {any[]} listings Raw listing entries from the extractor or override.
|
||||||
* @returns {Listing[]} Normalized listings.
|
* @returns {ParsedListing[]} Normalized listings.
|
||||||
*/
|
*/
|
||||||
_normalize(listings) {
|
_normalize(listings) {
|
||||||
return listings.map(this._providerConfig.normalize);
|
return listings.map((listing) => this._providerConfig.normalize(listing));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter out listings that are missing required fields and those rejected by the
|
* Filter out listings that are missing required fields and those rejected by the
|
||||||
* provider's blacklist/filter function.
|
* provider's blacklist/filter function.
|
||||||
*
|
*
|
||||||
* @param {Listing[]} listings Listings to filter.
|
* @param {ParsedListing[]} listings Listings to filter.
|
||||||
* @returns {Listing[]} Filtered listings that pass validation and provider filter.
|
* @returns {ParsedListing[]} Filtered listings that pass validation and provider filter.
|
||||||
*/
|
*/
|
||||||
_filter(listings) {
|
_filter(listings) {
|
||||||
const keys = Object.keys(this._providerConfig.crawlFields);
|
const requiredKeys = this._providerConfig.requiredFieldNames;
|
||||||
const filteredListings = listings.filter((item) => keys.every((key) => key in item));
|
const requireValues = ['id', 'link', 'title'];
|
||||||
return filteredListings.filter(this._providerConfig.filter);
|
|
||||||
|
const filteredListings = listings
|
||||||
|
// this should never filter some listings out, because the normalize function should always extract all fields.
|
||||||
|
.filter((item) => requiredKeys.every((key) => key in item))
|
||||||
|
// TODO: move blacklist filter to this file, so it will handle for all providers in same way.
|
||||||
|
.filter(this._providerConfig.filter)
|
||||||
|
// filter out listings that are missing required fields
|
||||||
|
.filter((item) => requireValues.every((key) => item[key] != null));
|
||||||
|
|
||||||
|
return filteredListings;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine which listings are new by comparing their IDs against stored hashes.
|
* Determine which listings are new by comparing their IDs against stored hashes.
|
||||||
*
|
*
|
||||||
* @param {Listing[]} listings Listings to evaluate for novelty.
|
* @param {ParsedListing[]} listings Listings to evaluate for novelty.
|
||||||
* @returns {Listing[]} New listings not seen before.
|
* @returns {ParsedListing[]} New listings not seen before.
|
||||||
* @throws {NoNewListingsWarning} When no new listings are found.
|
* @throws {NoNewListingsWarning} When no new listings are found.
|
||||||
*/
|
*/
|
||||||
_findNew(listings) {
|
_findNew(listings) {
|
||||||
@@ -232,23 +296,30 @@ class FredyPipelineExecutioner {
|
|||||||
/**
|
/**
|
||||||
* Send notifications for new listings using the configured notification adapter(s).
|
* Send notifications for new listings using the configured notification adapter(s).
|
||||||
*
|
*
|
||||||
* @param {Listing[]} newListings New listings to notify about.
|
* @param {ParsedListing[]} newListings New listings to notify about.
|
||||||
* @returns {Promise<Listing[]>} Resolves to the provided listings after notifications complete.
|
* @returns {Promise<ParsedListing[]>} Resolves to the provided listings after notifications complete.
|
||||||
* @throws {NoNewListingsWarning} When there are no listings to notify about.
|
* @throws {NoNewListingsWarning} When there are no listings to notify about.
|
||||||
*/
|
*/
|
||||||
_notify(newListings) {
|
_notify(newListings) {
|
||||||
if (newListings.length === 0) {
|
if (newListings.length === 0) {
|
||||||
throw new NoNewListingsWarning();
|
throw new NoNewListingsWarning();
|
||||||
}
|
}
|
||||||
const sendNotifications = notify.send(this._providerId, newListings, this._notificationConfig, this._jobKey);
|
// TODO: move this to the notification adapter, so it will handle for all providers in same way.
|
||||||
|
const formattedListings = newListings.map(formatListing);
|
||||||
|
const sendNotifications = notify.send(
|
||||||
|
this._providerId,
|
||||||
|
formattedListings,
|
||||||
|
this._jobNotificationConfig,
|
||||||
|
this._jobKey,
|
||||||
|
);
|
||||||
return Promise.all(sendNotifications).then(() => newListings);
|
return Promise.all(sendNotifications).then(() => newListings);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist new listings and pass them through.
|
* Persist new listings and pass them through.
|
||||||
*
|
*
|
||||||
* @param {Listing[]} newListings Listings to store.
|
* @param {ParsedListing[]} newListings Listings to store.
|
||||||
* @returns {Listing[]} The same listings, unchanged.
|
* @returns {ParsedListing[]} The same listings, unchanged.
|
||||||
*/
|
*/
|
||||||
_save(newListings) {
|
_save(newListings) {
|
||||||
logger.debug(`Storing ${newListings.length} new listings (Provider: '${this._providerId}')`);
|
logger.debug(`Storing ${newListings.length} new listings (Provider: '${this._providerId}')`);
|
||||||
@@ -259,8 +330,8 @@ class FredyPipelineExecutioner {
|
|||||||
/**
|
/**
|
||||||
* Calculate distance for new listings.
|
* Calculate distance for new listings.
|
||||||
*
|
*
|
||||||
* @param {Listing[]} listings
|
* @param {ParsedListing[]} listings
|
||||||
* @returns {Listing[]}
|
* @returns {ParsedListing[]}
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
_calculateDistance(listings) {
|
_calculateDistance(listings) {
|
||||||
@@ -296,8 +367,8 @@ class FredyPipelineExecutioner {
|
|||||||
* Remove listings that are similar to already known entries according to the similarity cache.
|
* Remove listings that are similar to already known entries according to the similarity cache.
|
||||||
* Adds the remaining listings to the cache.
|
* Adds the remaining listings to the cache.
|
||||||
*
|
*
|
||||||
* @param {Listing[]} listings Listings to filter by similarity.
|
* @param {ParsedListing[]} listings Listings to filter by similarity.
|
||||||
* @returns {Listing[]} Listings considered unique enough to keep.
|
* @returns {ParsedListing[]} Listings considered unique enough to keep.
|
||||||
*/
|
*/
|
||||||
_filterBySimilarListings(listings) {
|
_filterBySimilarListings(listings) {
|
||||||
const filteredIds = [];
|
const filteredIds = [];
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { getDirName } from '../utils.js';
|
|||||||
import { demoRouter } from './routes/demoRouter.js';
|
import { demoRouter } from './routes/demoRouter.js';
|
||||||
import logger from '../services/logger.js';
|
import logger from '../services/logger.js';
|
||||||
import { listingsRouter } from './routes/listingsRouter.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 { dashboardRouter } from './routes/dashboardRouter.js';
|
||||||
import { backupRouter } from './routes/backupRouter.js';
|
import { backupRouter } from './routes/backupRouter.js';
|
||||||
import { trackingRouter } from './routes/trackingRoute.js';
|
import { trackingRouter } from './routes/trackingRoute.js';
|
||||||
@@ -28,9 +28,10 @@ import { registerMcpRoutes } from '../mcp/mcpHttpRoute.js';
|
|||||||
const service = restana();
|
const service = restana();
|
||||||
const staticService = files(path.join(getDirName(), '../ui/public'));
|
const staticService = files(path.join(getDirName(), '../ui/public'));
|
||||||
const PORT = (await getSettings()).port || 9998;
|
const PORT = (await getSettings()).port || 9998;
|
||||||
|
const sessionSecret = await getOrCreateSessionSecret();
|
||||||
|
|
||||||
service.use(bodyParser.json());
|
service.use(bodyParser.json());
|
||||||
service.use(cookieSession());
|
service.use(cookieSession(sessionSecret));
|
||||||
service.use(staticService);
|
service.use(staticService);
|
||||||
service.use('/api/admin', authInterceptor());
|
service.use('/api/admin', authInterceptor());
|
||||||
service.use('/api/jobs', authInterceptor());
|
service.use('/api/jobs', authInterceptor());
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ dashboardRouter.get('/', async (req, res) => {
|
|||||||
const totalJobs = jobs.length;
|
const totalJobs = jobs.length;
|
||||||
const totalListings = jobs.reduce((sum, j) => sum + (j.numberOfFoundListings || 0), 0);
|
const totalListings = jobs.reduce((sum, j) => sum + (j.numberOfFoundListings || 0), 0);
|
||||||
const jobIds = jobs.map((j) => j.id);
|
const jobIds = jobs.map((j) => j.id);
|
||||||
const { numberOfActiveListings, avgPriceOfListings } = getListingsKpisForJobIds(jobIds);
|
const { numberOfActiveListings, medianPriceOfListings } = getListingsKpisForJobIds(jobIds);
|
||||||
// Build Pie data in a simple shape the frontend can consume directly
|
// Build Pie data in a simple shape the frontend can consume directly
|
||||||
// Shape: { labels: string[], values: number[] } with values as percentages
|
// Shape: { labels: string[], values: number[] } with values as percentages
|
||||||
const providerPieRaw = getProviderDistributionForJobIds(jobIds);
|
const providerPieRaw = getProviderDistributionForJobIds(jobIds);
|
||||||
@@ -63,7 +63,7 @@ dashboardRouter.get('/', async (req, res) => {
|
|||||||
totalJobs,
|
totalJobs,
|
||||||
totalListings,
|
totalListings,
|
||||||
numberOfActiveListings,
|
numberOfActiveListings,
|
||||||
avgPriceOfListings,
|
medianPriceOfListings,
|
||||||
},
|
},
|
||||||
pie: providerPie,
|
pie: providerPie,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ jobRouter.post('/', async (req, res) => {
|
|||||||
enabled,
|
enabled,
|
||||||
shareWithUsers = [],
|
shareWithUsers = [],
|
||||||
spatialFilter = null,
|
spatialFilter = null,
|
||||||
|
specFilter = null,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
try {
|
try {
|
||||||
@@ -197,6 +198,7 @@ jobRouter.post('/', async (req, res) => {
|
|||||||
notificationAdapter,
|
notificationAdapter,
|
||||||
shareWithUsers,
|
shareWithUsers,
|
||||||
spatialFilter,
|
spatialFilter,
|
||||||
|
specFilter,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.send(new Error(error));
|
res.send(new Error(error));
|
||||||
|
|||||||
@@ -9,6 +9,27 @@ import * as hasher from '../../services/security/hash.js';
|
|||||||
import { trackDemoAccessed } from '../../services/tracking/Tracker.js';
|
import { trackDemoAccessed } from '../../services/tracking/Tracker.js';
|
||||||
import logger from '../../services/logger.js';
|
import logger from '../../services/logger.js';
|
||||||
import { getSettings } from '../../services/storage/settingsStorage.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 service = restana();
|
||||||
const loginRouter = service.newRouter();
|
const loginRouter = service.newRouter();
|
||||||
loginRouter.get('/user', async (req, res) => {
|
loginRouter.get('/user', async (req, res) => {
|
||||||
@@ -25,6 +46,12 @@ loginRouter.get('/user', async (req, res) => {
|
|||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
loginRouter.post('/', async (req, res) => {
|
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 settings = await getSettings();
|
||||||
const { username, password } = req.body;
|
const { username, password } = req.body;
|
||||||
const user = userStorage.getUsers(true).find((user) => user.username === username);
|
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.currentUser = user.id;
|
||||||
|
req.session.createdAt = Date.now();
|
||||||
|
loginAttempts.delete(ip);
|
||||||
userStorage.setLastLoginToNow({ userId: user.id });
|
userStorage.setLastLoginToNow({ userId: user.id });
|
||||||
res.send(200);
|
res.send(200);
|
||||||
return;
|
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 userId = req.session.currentUser;
|
||||||
const { immoscout_details } = req.body;
|
const { provider_details } = req.body;
|
||||||
|
|
||||||
const globalSettings = await getSettings();
|
const globalSettings = await getSettings();
|
||||||
if (globalSettings.demoMode) {
|
if (globalSettings.demoMode) {
|
||||||
@@ -108,11 +108,17 @@ userSettingsRouter.post('/immoscout-details', async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(provider_details)) {
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.send({ error: 'provider_details must be an array of provider ids.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
upsertSettings({ immoscout_details: !!immoscout_details }, userId);
|
upsertSettings({ provider_details }, userId);
|
||||||
res.send({ success: true });
|
res.send({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error updating immoscout details setting', error);
|
logger.error('Error updating provider details setting', error);
|
||||||
res.statusCode = 500;
|
res.statusCode = 500;
|
||||||
res.send({ error: error.message });
|
res.send({ error: error.message });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,17 @@
|
|||||||
|
|
||||||
import * as userStorage from '../services/storage/userStorage.js';
|
import * as userStorage from '../services/storage/userStorage.js';
|
||||||
import cookieSession from 'cookie-session';
|
import cookieSession from 'cookie-session';
|
||||||
import { nanoid } from 'nanoid';
|
const SESSION_MAX_AGE = 2 * 60 * 60 * 1000; // 2 hours
|
||||||
const unauthorized = (res) => {
|
const unauthorized = (res) => {
|
||||||
return res.send(401);
|
return res.send(401);
|
||||||
};
|
};
|
||||||
const isUnauthorized = (req) => {
|
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) => {
|
const isAdmin = (req) => {
|
||||||
if (!isUnauthorized(req)) {
|
if (!isUnauthorized(req)) {
|
||||||
@@ -37,12 +42,11 @@ const adminInterceptor = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const cookieSession$0 = (userId) => {
|
const cookieSession$0 = (secret) => {
|
||||||
return cookieSession({
|
return cookieSession({
|
||||||
name: 'fredy-admin-session',
|
name: 'fredy-admin-session',
|
||||||
keys: ['fredy', 'super', 'fancy', 'key', nanoid()],
|
keys: [secret],
|
||||||
userId,
|
maxAge: SESSION_MAX_AGE,
|
||||||
maxAge: 2 * 60 * 60 * 1000, // 2 hours
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
export { cookieSession$0 as cookieSession };
|
export { cookieSession$0 as cookieSession };
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Fredy MCP Server
|
# Fredy MCP Server
|
||||||
|
|
||||||
The Fredy MCP Server exposes your real estate jobs and listings data to LLM clients. It supports two transports:
|
The Fredy MCP Server exposes your real estate jobs and listings data to LLM clients. It supports two transports:
|
||||||
|
|
||||||
@@ -126,6 +126,54 @@ The LLM will automatically call the appropriate Fredy MCP tools and present the
|
|||||||
|
|
||||||
> **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).
|
> **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)
|
## 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:
|
The HTTP transport is automatically available when Fredy is running. It uses the MCP Streamable HTTP protocol at:
|
||||||
|
|||||||
@@ -220,6 +220,122 @@ export function createMcpServer() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── get_photo_for_listing ─────────────────────────────────────────────────────
|
||||||
|
server.tool(
|
||||||
|
'get_photo_for_listing',
|
||||||
|
'Fetch and return the photo of a listing by its ID as an image for vision analysis.',
|
||||||
|
{
|
||||||
|
listingId: z.string().describe('The listing ID whose photo to fetch'),
|
||||||
|
},
|
||||||
|
async ({ listingId }, extra) => {
|
||||||
|
const { user, error } = authenticateToolCall(extra, 'get_photo_for_listing');
|
||||||
|
if (error) return normalizeError(error, 'get_photo_for_listing');
|
||||||
|
|
||||||
|
const listing = getListingById(listingId, user.id, user.isAdmin);
|
||||||
|
if (!listing) {
|
||||||
|
return normalizeError('Listing not found or access denied.', 'get_photo_for_listing');
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageUrl = listing.image_url;
|
||||||
|
if (!imageUrl) {
|
||||||
|
return normalizeError('No image available for this listing.', 'get_photo_for_listing');
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUPPORTED_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
|
||||||
|
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(imageUrl, {
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
headers: {
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
Accept: 'image/jpeg,image/png,image/webp,image/gif,image/*,*/*',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (fetchErr) {
|
||||||
|
return normalizeError(`Failed to fetch image: ${fetchErr.message}`, 'get_photo_for_listing');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return normalizeError(
|
||||||
|
`Image fetch returned HTTP ${response.status}. Image URL: ${imageUrl}`,
|
||||||
|
'get_photo_for_listing',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') ?? '';
|
||||||
|
const headerMimeType = contentType.split(';')[0].trim().toLowerCase();
|
||||||
|
|
||||||
|
let buffer;
|
||||||
|
try {
|
||||||
|
buffer = await response.arrayBuffer();
|
||||||
|
} catch (readErr) {
|
||||||
|
return normalizeError(`Failed to read image body: ${readErr.message}`, 'get_photo_for_listing');
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
|
||||||
|
if (bytes.length < 12) {
|
||||||
|
return normalizeError(
|
||||||
|
`Downloaded file is too small to determine image type. Image URL: ${imageUrl}`,
|
||||||
|
'get_photo_for_listing',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvedMime;
|
||||||
|
|
||||||
|
if (SUPPORTED_MIME_TYPES.has(headerMimeType)) {
|
||||||
|
resolvedMime = headerMimeType;
|
||||||
|
} else {
|
||||||
|
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
||||||
|
resolvedMime = 'image/jpeg';
|
||||||
|
} else if (
|
||||||
|
bytes[0] === 0x89 &&
|
||||||
|
bytes[1] === 0x50 &&
|
||||||
|
bytes[2] === 0x4e &&
|
||||||
|
bytes[3] === 0x47 &&
|
||||||
|
bytes[4] === 0x0d &&
|
||||||
|
bytes[5] === 0x0a &&
|
||||||
|
bytes[6] === 0x1a &&
|
||||||
|
bytes[7] === 0x0a
|
||||||
|
) {
|
||||||
|
resolvedMime = 'image/png';
|
||||||
|
} else if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) {
|
||||||
|
resolvedMime = 'image/gif';
|
||||||
|
} else if (
|
||||||
|
bytes[0] === 0x52 &&
|
||||||
|
bytes[1] === 0x49 &&
|
||||||
|
bytes[2] === 0x46 &&
|
||||||
|
bytes[3] === 0x46 &&
|
||||||
|
bytes[8] === 0x57 &&
|
||||||
|
bytes[9] === 0x45 &&
|
||||||
|
bytes[10] === 0x42 &&
|
||||||
|
bytes[11] === 0x50
|
||||||
|
) {
|
||||||
|
resolvedMime = 'image/webp';
|
||||||
|
} else {
|
||||||
|
return normalizeError(
|
||||||
|
`Image format not supported by vision models (header: ${headerMimeType || 'unknown'}). Image URL: ${imageUrl}`,
|
||||||
|
'get_photo_for_listing',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64 = Buffer.from(buffer).toString('base64');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'image',
|
||||||
|
data: base64,
|
||||||
|
mimeType: resolvedMime,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// ── get_current_date_ime ─────────────────────────────────────────────────────
|
// ── get_current_date_ime ─────────────────────────────────────────────────────
|
||||||
server.tool('get_current_date_time', 'Returns the current date and time.', {}, () => {
|
server.tool('get_current_date_time', 'Returns the current date and time.', {}, () => {
|
||||||
return {
|
return {
|
||||||
|
|||||||
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
|
||||||
@@ -105,6 +105,32 @@ function buildText(jobName, serviceName, o) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a plain text Telegram photo caption (max 4096 characters).
|
||||||
|
* @param {string} jobName
|
||||||
|
* @param {string} serviceName
|
||||||
|
* @param {Object} o - Listing object
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function buildCaptionPlain(jobName, serviceName, o) {
|
||||||
|
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
||||||
|
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
|
||||||
|
return `${jobName} (${serviceName})\n${title}\n${meta}\n\n${o.link || ''}`.slice(0, 4096);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a plain text Telegram message.
|
||||||
|
* @param {string} jobName
|
||||||
|
* @param {string} serviceName
|
||||||
|
* @param {Object} o - Listing object
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function buildTextPlain(jobName, serviceName, o) {
|
||||||
|
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
||||||
|
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
|
||||||
|
return `${jobName} (${serviceName})\n${title}\n${o.link || ''}\n${meta}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send new listings to Telegram.
|
* Send new listings to Telegram.
|
||||||
* - Respects per-chat Telegram rate limits using a lightweight throttle cache.
|
* - Respects per-chat Telegram rate limits using a lightweight throttle cache.
|
||||||
@@ -122,7 +148,7 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
|
|||||||
if (!adapterCfg || !adapterCfg.fields) {
|
if (!adapterCfg || !adapterCfg.fields) {
|
||||||
throw new Error(`Telegram adapter configuration missing for job '${jobKey || ''}'`);
|
throw new Error(`Telegram adapter configuration missing for job '${jobKey || ''}'`);
|
||||||
}
|
}
|
||||||
const { token, chatId, messageThreadId } = adapterCfg.fields;
|
const { token, chatId, messageThreadId, plainText } = adapterCfg.fields;
|
||||||
if (!token || !chatId) {
|
if (!token || !chatId) {
|
||||||
throw new Error("Telegram 'token' and 'chatId' must be provided in notification config");
|
throw new Error("Telegram 'token' and 'chatId' must be provided in notification config");
|
||||||
}
|
}
|
||||||
@@ -163,8 +189,8 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
|
|||||||
const img = normalizeImageUrl(o.image);
|
const img = normalizeImageUrl(o.image);
|
||||||
const textPayload = {
|
const textPayload = {
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
text: buildText(jobName, serviceName, o),
|
text: plainText ? buildTextPlain(jobName, serviceName, o) : buildText(jobName, serviceName, o),
|
||||||
parse_mode: 'HTML',
|
...(plainText ? {} : { parse_mode: 'HTML' }),
|
||||||
disable_web_page_preview: true,
|
disable_web_page_preview: true,
|
||||||
...(message_thread_id ? { message_thread_id } : {}),
|
...(message_thread_id ? { message_thread_id } : {}),
|
||||||
};
|
};
|
||||||
@@ -178,8 +204,8 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
|
|||||||
return await throttledCall('sendPhoto', {
|
return await throttledCall('sendPhoto', {
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
photo: img,
|
photo: img,
|
||||||
caption: buildCaption(jobName, serviceName, o),
|
caption: plainText ? buildCaptionPlain(jobName, serviceName, o) : buildCaption(jobName, serviceName, o),
|
||||||
parse_mode: 'HTML',
|
...(plainText ? {} : { parse_mode: 'HTML' }),
|
||||||
...(message_thread_id ? { message_thread_id } : {}),
|
...(message_thread_id ? { message_thread_id } : {}),
|
||||||
}).catch(async (e) => {
|
}).catch(async (e) => {
|
||||||
logger.error(`Error sending photo to Telegram and use a fallback: ${e.message}`);
|
logger.error(`Error sending photo to Telegram and use a fallback: ${e.message}`);
|
||||||
@@ -220,5 +246,11 @@ export const config = {
|
|||||||
description:
|
description:
|
||||||
'Optional: The topic/thread id within a supergroup to post into (Telegram message_thread_id). Provide a positive integer.',
|
'Optional: The topic/thread id within a supergroup to post into (Telegram message_thread_id). Provide a positive integer.',
|
||||||
},
|
},
|
||||||
|
plainText: {
|
||||||
|
type: 'boolean',
|
||||||
|
optional: true,
|
||||||
|
label: 'Send as plain text',
|
||||||
|
description: 'Send messages as plain text instead of HTML formatted.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,8 +5,16 @@
|
|||||||
|
|
||||||
import { buildHash, isOneOf } from '../utils.js';
|
import { buildHash, isOneOf } from '../utils.js';
|
||||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
import { extractNumber } from '../utils/extract-number.js';
|
||||||
|
/** @import { ParsedListing } from '../types/listing.js' */
|
||||||
|
/** @import { ProviderConfig } from '../types/providerConfig.js' */
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} o
|
||||||
|
* @returns {ParsedListing}
|
||||||
|
*/
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const baseUrl = 'https://www.1a-immobilienmarkt.de';
|
const baseUrl = 'https://www.1a-immobilienmarkt.de';
|
||||||
const link = `${baseUrl}/expose/${o.id}.html`;
|
const link = `${baseUrl}/expose/${o.id}.html`;
|
||||||
@@ -14,7 +22,17 @@ function normalize(o) {
|
|||||||
const id = buildHash(o.id, price);
|
const id = buildHash(o.id, price);
|
||||||
const image = baseUrl + o.image;
|
const image = baseUrl + o.image;
|
||||||
const address = o.address == null ? null : o.address.trim().replaceAll('/', ',');
|
const address = o.address == null ? null : o.address.trim().replaceAll('/', ',');
|
||||||
return Object.assign(o, { id, price, link, image, address });
|
return {
|
||||||
|
id,
|
||||||
|
link,
|
||||||
|
title: o.title || '',
|
||||||
|
price: extractNumber(price),
|
||||||
|
size: extractNumber(o.size),
|
||||||
|
rooms: extractNumber(o.rooms),
|
||||||
|
address,
|
||||||
|
image,
|
||||||
|
description: undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,13 +52,19 @@ function normalizePrice(price) {
|
|||||||
}
|
}
|
||||||
return result[0];
|
return result[0];
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* @param {ParsedListing} o
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {ProviderConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '.tabelle',
|
crawlContainer: '.tabelle',
|
||||||
sortByDateParam: 'sort_type=newest',
|
sortByDateParam: 'sort_type=newest',
|
||||||
@@ -48,7 +72,8 @@ const config = {
|
|||||||
crawlFields: {
|
crawlFields: {
|
||||||
id: '.inner_object_data input[name="marker_objekt_id"]@value | int',
|
id: '.inner_object_data input[name="marker_objekt_id"]@value | int',
|
||||||
price: '.inner_object_data .single_data_price | removeNewline | trim',
|
price: '.inner_object_data .single_data_price | removeNewline | trim',
|
||||||
size: '.tabelle .tabelle_inhalt_infos .single_data_box | removeNewline | trim',
|
size: '.tabelle .tabelle_inhalt_infos .single_data_box:nth-of-type(1) | removeNewline | trim',
|
||||||
|
rooms: '.tabelle .tabelle_inhalt_infos .single_data_box:nth-of-type(2) | removeNewline | trim',
|
||||||
title: '.inner_object_data .tabelle_inhalt_titel_black | removeNewline | trim',
|
title: '.inner_object_data .tabelle_inhalt_titel_black | removeNewline | trim',
|
||||||
image: '.inner_object_pic img@src',
|
image: '.inner_object_pic img@src',
|
||||||
address: '.tabelle .tabelle_inhalt_infos .left_information > div:nth-child(2) | removeNewline | trim',
|
address: '.tabelle .tabelle_inhalt_infos .left_information > div:nth-child(2) | removeNewline | trim',
|
||||||
|
|||||||
@@ -5,6 +5,12 @@
|
|||||||
|
|
||||||
import { buildHash, isOneOf } from '../utils.js';
|
import { buildHash, isOneOf } from '../utils.js';
|
||||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
import { extractNumber } from '../utils/extract-number.js';
|
||||||
|
import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
import logger from '../services/logger.js';
|
||||||
|
/** @import { ParsedListing } from '../types/listing.js' */
|
||||||
|
/** @import { ProviderConfig } from '../types/providerConfig.js' */
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
@@ -18,42 +24,106 @@ function parseId(shortenedLink) {
|
|||||||
return shortenedLink.substring(shortenedLink.lastIndexOf('/') + 1);
|
return shortenedLink.substring(shortenedLink.lastIndexOf('/') + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchDetails(listing, browser) {
|
||||||
|
try {
|
||||||
|
const html = await puppeteerExtractor(listing.link, null, { browser });
|
||||||
|
if (!html) return listing;
|
||||||
|
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
|
// Try JSON-LD first
|
||||||
|
let description = null;
|
||||||
|
let address = listing.address;
|
||||||
|
$('script[type="application/ld+json"]').each((_, el) => {
|
||||||
|
if (description) return;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse($(el).text());
|
||||||
|
const nodes = Array.isArray(data) ? data : [data];
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.description && !description) description = String(node.description).replace(/\s+/g, ' ').trim();
|
||||||
|
const addr = node.address || node?.mainEntity?.address;
|
||||||
|
if (addr && addr.streetAddress && address === listing.address) {
|
||||||
|
const parts = [addr.streetAddress, addr.postalCode, addr.addressLocality].filter(Boolean);
|
||||||
|
if (parts.length) address = parts.join(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore malformed JSON-LD
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fallback: common description selectors used by immobilien.de
|
||||||
|
if (!description) {
|
||||||
|
const sel = ['.beschreibung', '.freitext', '.objektbeschreibung', '.description'].find((s) => $(s).length > 0);
|
||||||
|
if (sel) description = $(sel).text().replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...listing,
|
||||||
|
address,
|
||||||
|
description: description || listing.description,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Could not fetch immobilien.de detail page for listing '${listing.id}'.`, error?.message || error);
|
||||||
|
return listing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @param {any} o
|
||||||
|
* @returns {ParsedListing}
|
||||||
|
*/
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const baseUrl = 'https://www.immobilien.de';
|
const baseUrl = 'https://www.immobilien.de';
|
||||||
const size = o.size || null;
|
const title = o.title || '';
|
||||||
const price = o.price || null;
|
|
||||||
const title = o.title || 'No title available';
|
|
||||||
const address = o.address || null;
|
const address = o.address || null;
|
||||||
const shortLink = shortenLink(o.link);
|
const shortLink = shortenLink(o.link);
|
||||||
const link = baseUrl + shortLink;
|
const link = shortLink ? (shortLink.startsWith('http') ? shortLink : baseUrl + shortLink) : baseUrl;
|
||||||
const image = baseUrl + o.image;
|
const image = o.image ? (o.image.startsWith('http') ? o.image : baseUrl + o.image) : null;
|
||||||
const id = buildHash(parseId(shortLink), o.price);
|
const id = buildHash(parseId(shortLink), o.price);
|
||||||
return Object.assign(o, { id, price, size, title, address, link, image });
|
return {
|
||||||
|
id,
|
||||||
|
link,
|
||||||
|
title,
|
||||||
|
price: extractNumber(o.price),
|
||||||
|
size: extractNumber(o.size),
|
||||||
|
rooms: extractNumber(o.rooms),
|
||||||
|
address,
|
||||||
|
image,
|
||||||
|
description: o.description,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ParsedListing} o
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {ProviderConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: 'a:has(div.list_entry)',
|
crawlContainer: 'a.lr-card',
|
||||||
sortByDateParam: 'sort_col=*created_ts&sort_dir=desc',
|
sortByDateParam: 'sort_col=*created_ts&sort_dir=desc',
|
||||||
waitForSelector: 'body',
|
waitForSelector: null,
|
||||||
crawlFields: {
|
crawlFields: {
|
||||||
id: '@href', //will be transformed later
|
id: '@href', //will be transformed later
|
||||||
price: '.immo_preis .label_info',
|
price: '.lr-card__price-amount | trim',
|
||||||
size: '.flaeche .label_info | removeNewline | trim',
|
size: '.lr-card__fact:has(.lr-card__fact-label:contains("Fläche")) .lr-card__fact-value | trim',
|
||||||
title: 'h3 span',
|
rooms: '.zimmer .label_info',
|
||||||
|
title: '.lr-card__title | trim',
|
||||||
description: '.description | trim',
|
description: '.description | trim',
|
||||||
link: '@href',
|
link: '@href',
|
||||||
address: '.place',
|
address: '.lr-card__address span | trim',
|
||||||
image: 'img@src',
|
image: 'img.lr-card__gallery-img@src',
|
||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
fetchDetails,
|
||||||
activeTester: checkIfListingIsActive,
|
activeTester: checkIfListingIsActive,
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
|
|||||||
@@ -46,9 +46,11 @@ import {
|
|||||||
convertWebToMobile,
|
convertWebToMobile,
|
||||||
} from '../services/immoscout/immoscout-web-translator.js';
|
} from '../services/immoscout/immoscout-web-translator.js';
|
||||||
import logger from '../services/logger.js';
|
import logger from '../services/logger.js';
|
||||||
import { getUserSettings } from '../services/storage/settingsStorage.js';
|
import { extractNumber } from '../utils/extract-number.js';
|
||||||
|
/** @import { ParsedListing } from '../types/listing.js' */
|
||||||
|
/** @import { ProviderConfig } from '../types/providerConfig.js' */
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
let currentUserId = null;
|
|
||||||
|
|
||||||
async function getListings(url) {
|
async function getListings(url) {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
@@ -68,42 +70,40 @@ async function getListings(url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const responseBody = await response.json();
|
const responseBody = await response.json();
|
||||||
return Promise.all(
|
return responseBody.resultListItems
|
||||||
responseBody.resultListItems
|
.filter((item) => item.type === 'EXPOSE_RESULT')
|
||||||
.filter((item) => item.type === 'EXPOSE_RESULT')
|
.map((expose) => {
|
||||||
.map(async (expose) => {
|
const item = expose.item;
|
||||||
const item = expose.item;
|
const [price, size] = item.attributes;
|
||||||
const [price, size] = item.attributes;
|
const image = item?.titlePicture?.full ?? item?.titlePicture?.preview ?? null;
|
||||||
const image = item?.titlePicture?.full ?? item?.titlePicture?.preview ?? null;
|
return {
|
||||||
let listing = {
|
id: item.id,
|
||||||
id: item.id,
|
price: price?.value,
|
||||||
price: price?.value,
|
size: size?.value,
|
||||||
size: size?.value,
|
title: item.title,
|
||||||
title: item.title,
|
link: `${metaInformation.baseUrl}expose/${item.id}`,
|
||||||
link: `${metaInformation.baseUrl}expose/${item.id}`,
|
address: item.address?.line,
|
||||||
address: item.address?.line,
|
image,
|
||||||
image,
|
};
|
||||||
};
|
});
|
||||||
if (currentUserId) {
|
}
|
||||||
const userSettings = getUserSettings(currentUserId);
|
|
||||||
if (userSettings.immoscout_details) {
|
async function fetchDetails(listing) {
|
||||||
return await pushDetails(listing);
|
return pushDetails(listing);
|
||||||
}
|
|
||||||
}
|
|
||||||
return listing;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function 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: {
|
headers: {
|
||||||
'User-Agent': 'ImmoScout_27.3_26.0_._',
|
'User-Agent': 'ImmoScout_27.3_26.0_._',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!detailed.ok) {
|
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;
|
return listing;
|
||||||
}
|
}
|
||||||
const detailBody = await detailed.json();
|
const detailBody = await detailed.json();
|
||||||
@@ -172,22 +172,44 @@ async function isListingActive(link) {
|
|||||||
function nullOrEmpty(val) {
|
function nullOrEmpty(val) {
|
||||||
return val == null || val.length === 0;
|
return val == null || val.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} o
|
||||||
|
* @returns {ParsedListing}
|
||||||
|
*/
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const title = nullOrEmpty(o.title) ? 'NO TITLE FOUND' : o.title.replace('NEU', '');
|
const title = (o.title || '').replace('NEU', '').trim();
|
||||||
const address = nullOrEmpty(o.address) ? 'NO ADDRESS FOUND' : (o.address || '').replace(/\(.*\),.*$/, '').trim();
|
const address = nullOrEmpty(o.address) ? 'NO ADDRESS FOUND' : (o.address || '').replace(/\(.*\),.*$/, '').trim();
|
||||||
const id = buildHash(o.id, o.price);
|
const id = buildHash(o.id, o.price);
|
||||||
return Object.assign(o, { id, title, address });
|
return {
|
||||||
|
id,
|
||||||
|
link: o.link,
|
||||||
|
title,
|
||||||
|
price: extractNumber(o.price),
|
||||||
|
size: extractNumber(o.size),
|
||||||
|
rooms: extractNumber(o.rooms),
|
||||||
|
address,
|
||||||
|
image: o.image,
|
||||||
|
description: o.description,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* @param {ParsedListing} o
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
return !isOneOf(o.title, appliedBlackList);
|
return !isOneOf(o.title, appliedBlackList);
|
||||||
}
|
}
|
||||||
|
/** @type {ProviderConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
|
||||||
url: null,
|
url: null,
|
||||||
crawlFields: {
|
crawlFields: {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
title: 'title',
|
title: 'title',
|
||||||
price: 'price',
|
price: 'price',
|
||||||
size: 'size',
|
size: 'size',
|
||||||
|
rooms: 'rooms',
|
||||||
link: 'link',
|
link: 'link',
|
||||||
address: 'address',
|
address: 'address',
|
||||||
},
|
},
|
||||||
@@ -196,13 +218,13 @@ const config = {
|
|||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
getListings: getListings,
|
getListings: getListings,
|
||||||
|
fetchDetails: fetchDetails,
|
||||||
activeTester: isListingActive,
|
activeTester: isListingActive,
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
config.enabled = sourceConfig.enabled;
|
config.enabled = sourceConfig.enabled;
|
||||||
config.url = convertWebToMobile(sourceConfig.url);
|
config.url = convertWebToMobile(sourceConfig.url);
|
||||||
appliedBlackList = blacklist || [];
|
appliedBlackList = blacklist || [];
|
||||||
currentUserId = sourceConfig.userId || null;
|
|
||||||
};
|
};
|
||||||
export const metaInformation = {
|
export const metaInformation = {
|
||||||
name: 'Immoscout',
|
name: 'Immoscout',
|
||||||
|
|||||||
@@ -5,27 +5,46 @@
|
|||||||
|
|
||||||
import { isOneOf, buildHash } from '../utils.js';
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
import { extractNumber } from '../utils/extract-number.js';
|
||||||
|
/** @import { ParsedListing } from '../types/listing.js' */
|
||||||
|
/** @import { ProviderConfig } from '../types/providerConfig.js' */
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} o
|
||||||
|
* @returns {ParsedListing}
|
||||||
|
*/
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const size = o.size || 'N/A m²';
|
|
||||||
const price = (o.price || '--- €').replace('Preis auf Anfrage', '--- €');
|
|
||||||
const title = o.title || 'No title available';
|
|
||||||
const immoId = o.id.substring(o.id.indexOf('-') + 1, o.id.length);
|
const immoId = o.id.substring(o.id.indexOf('-') + 1, o.id.length);
|
||||||
const link = `https://immo.swp.de/immobilien/${immoId}`;
|
const link = `https://immo.swp.de/immobilien/${immoId}`;
|
||||||
const description = o.description;
|
const id = buildHash(immoId, o.price);
|
||||||
const id = buildHash(immoId, price);
|
return {
|
||||||
return Object.assign(o, { id, price, size, title, link, description });
|
id,
|
||||||
|
link,
|
||||||
|
title: o.title || '',
|
||||||
|
price: extractNumber(o.price),
|
||||||
|
size: extractNumber(o.size),
|
||||||
|
rooms: extractNumber(o.rooms),
|
||||||
|
address: o.address,
|
||||||
|
image: o.image,
|
||||||
|
description: undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ParsedListing} o
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {ProviderConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '.js-serp-item',
|
crawlContainer: '.js-serp-item',
|
||||||
sortByDateParam: 's=most_recently_updated_first',
|
sortByDateParam: 's=most_recently_updated_first',
|
||||||
@@ -34,9 +53,10 @@ const config = {
|
|||||||
id: '.js-bookmark-btn@data-id',
|
id: '.js-bookmark-btn@data-id',
|
||||||
price: 'div.align-items-start div:first-child | trim',
|
price: 'div.align-items-start div:first-child | trim',
|
||||||
size: 'div.align-items-start div:nth-child(3) | trim',
|
size: 'div.align-items-start div:nth-child(3) | trim',
|
||||||
|
rooms: 'div.align-items-start div:nth-child(2) | trim',
|
||||||
|
address: '.js-bookmark-btn@data-address',
|
||||||
title: '.js-item-title-link@title | trim',
|
title: '.js-item-title-link@title | trim',
|
||||||
link: '.ci-search-result__link@href',
|
link: '.ci-search-result__link@href',
|
||||||
description: '.js-show-more-item-sm | removeNewline | trim',
|
|
||||||
image: 'img@src',
|
image: 'img@src',
|
||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
|
|||||||
@@ -5,21 +5,84 @@
|
|||||||
|
|
||||||
import { buildHash, isOneOf } from '../utils.js';
|
import { buildHash, isOneOf } from '../utils.js';
|
||||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
import { extractNumber } from '../utils/extract-number.js';
|
||||||
|
import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
import logger from '../services/logger.js';
|
||||||
|
/** @import { ParsedListing } from '../types/listing.js' */
|
||||||
|
/** @import { ProviderConfig } from '../types/providerConfig.js' */
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
function normalize(o) {
|
async function fetchDetails(listing, browser) {
|
||||||
const id = buildHash(o.id, o.price);
|
try {
|
||||||
return Object.assign(o, { id });
|
const html = await puppeteerExtractor(listing.link, null, { browser });
|
||||||
|
if (!html) return listing;
|
||||||
|
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
const nextDataRaw = $('#__NEXT_DATA__').text();
|
||||||
|
if (!nextDataRaw) return listing;
|
||||||
|
|
||||||
|
const classified = JSON.parse(nextDataRaw)?.props?.pageProps?.classified;
|
||||||
|
if (!classified) return listing;
|
||||||
|
|
||||||
|
const description = (classified.Texts || [])
|
||||||
|
.map((t) => [t.Title, t.Content].filter(Boolean).join('\n'))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n');
|
||||||
|
|
||||||
|
const addr = classified.EstateAddress;
|
||||||
|
let address = listing.address;
|
||||||
|
if (addr) {
|
||||||
|
const street = [addr.Street, addr.HouseNumber].filter(Boolean).join(' ');
|
||||||
|
const cityLine = [addr.ZipCode, addr.District || addr.City].filter(Boolean).join(' ');
|
||||||
|
const full = [street, cityLine].filter(Boolean).join(', ');
|
||||||
|
if (full) address = full;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...listing,
|
||||||
|
address,
|
||||||
|
description: description || listing.description,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Could not fetch immowelt detail page for listing '${listing.id}'.`, error?.message || error);
|
||||||
|
return listing;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} o
|
||||||
|
* @returns {ParsedListing}
|
||||||
|
*/
|
||||||
|
function normalize(o) {
|
||||||
|
const id = buildHash(o.id, o.price);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
link: o.link,
|
||||||
|
title: o.title || '',
|
||||||
|
price: extractNumber(o.price),
|
||||||
|
size: extractNumber(o.size),
|
||||||
|
rooms: extractNumber(o.rooms),
|
||||||
|
address: o.address,
|
||||||
|
image: o.image,
|
||||||
|
description: o.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ParsedListing} o
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {ProviderConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer:
|
crawlContainer:
|
||||||
'div[data-testid="serp-core-scrollablelistview-testid"]:not(div[data-testid="serp-enlargementlist-testid"] div[data-testid="serp-card-testid"]) div[data-testid="serp-core-classified-card-testid"]',
|
'div[data-testid="serp-core-scrollablelistview-testid"]:not(div[data-testid="serp-enlargementlist-testid"] div[data-testid="serp-card-testid"]) div[data-testid="serp-core-classified-card-testid"]',
|
||||||
@@ -28,7 +91,8 @@ const config = {
|
|||||||
crawlFields: {
|
crawlFields: {
|
||||||
id: 'a@href',
|
id: 'a@href',
|
||||||
price: 'div[data-testid="cardmfe-price-testid"] | removeNewline | trim',
|
price: 'div[data-testid="cardmfe-price-testid"] | removeNewline | trim',
|
||||||
size: 'div[data-testid="cardmfe-keyfacts-testid"] | removeNewline | trim',
|
size: 'div[data-testid="cardmfe-keyfacts-testid"] div:nth-of-type(3) | removeNewline | trim',
|
||||||
|
rooms: 'div[data-testid="cardmfe-keyfacts-testid"] div:nth-of-type(1) | removeNewline | trim',
|
||||||
title: 'div[data-testid="cardmfe-description-box-text-test-id"] > div:nth-of-type(2)',
|
title: 'div[data-testid="cardmfe-description-box-text-test-id"] > div:nth-of-type(2)',
|
||||||
link: 'a@href',
|
link: 'a@href',
|
||||||
description: 'div[data-testid="cardmfe-description-text-test-id"] > div:nth-of-type(2) | removeNewline | trim',
|
description: 'div[data-testid="cardmfe-description-text-test-id"] > div:nth-of-type(2) | removeNewline | trim',
|
||||||
@@ -37,6 +101,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
fetchDetails: fetchDetails,
|
||||||
activeTester: checkIfListingIsActive,
|
activeTester: checkIfListingIsActive,
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
|
|||||||
@@ -5,17 +5,177 @@
|
|||||||
|
|
||||||
import { buildHash, isOneOf } from '../utils.js';
|
import { buildHash, isOneOf } from '../utils.js';
|
||||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
import { extractNumber } from '../utils/extract-number.js';
|
||||||
|
/** @import { ParsedListing } from '../types/listing.js' */
|
||||||
|
/** @import { ProviderConfig } from '../types/providerConfig.js' */
|
||||||
|
import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js';
|
||||||
|
import logger from '../services/logger.js';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
let appliedBlacklistedDistricts = [];
|
let appliedBlacklistedDistricts = [];
|
||||||
|
|
||||||
function normalize(o) {
|
function toAbsoluteLink(link) {
|
||||||
const size = o.size || '--- m²';
|
if (!link) return null;
|
||||||
const id = buildHash(o.id, o.price);
|
return link.startsWith('http') ? link : `https://www.kleinanzeigen.de${link}`;
|
||||||
const link = `https://www.kleinanzeigen.de${o.link}`;
|
|
||||||
return Object.assign(o, { id, size, link });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanText(value) {
|
||||||
|
if (value == null) return '';
|
||||||
|
return String(value)
|
||||||
|
.replace(/<[^>]*>/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAddressFromJsonLd(address) {
|
||||||
|
if (!address || typeof address !== 'object') return null;
|
||||||
|
|
||||||
|
const locality = cleanText(address.addressLocality);
|
||||||
|
const region = cleanText(address.addressRegion);
|
||||||
|
const postalCode = cleanText(address.postalCode);
|
||||||
|
const streetAddress = cleanText(address.streetAddress);
|
||||||
|
|
||||||
|
const cityPart = [region, locality].filter(Boolean).join(' - ');
|
||||||
|
const tail = [postalCode, cityPart || locality || region].filter(Boolean).join(' ');
|
||||||
|
const fullAddress = [streetAddress, tail].filter(Boolean).join(', ');
|
||||||
|
|
||||||
|
return fullAddress || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenJsonLdNodes(node, acc = []) {
|
||||||
|
if (node == null) return acc;
|
||||||
|
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
node.forEach((item) => flattenJsonLdNodes(item, acc));
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof node !== 'object') return acc;
|
||||||
|
|
||||||
|
acc.push(node);
|
||||||
|
|
||||||
|
if (Array.isArray(node['@graph'])) {
|
||||||
|
node['@graph'].forEach((item) => flattenJsonLdNodes(item, acc));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.mainEntity) {
|
||||||
|
flattenJsonLdNodes(node.mainEntity, acc);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.itemOffered) {
|
||||||
|
flattenJsonLdNodes(node.itemOffered, acc);
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDetailFromHtml(html) {
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
const nodes = [];
|
||||||
|
|
||||||
|
// Prefer the rendered postal address block from the detail page because
|
||||||
|
// it contains the street line that is missing from list results.
|
||||||
|
const streetFromDom = cleanText($('#street-address').first().text());
|
||||||
|
const localityFromDom = cleanText($('#viewad-locality').first().text());
|
||||||
|
const domAddress = [streetFromDom, localityFromDom].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
$('script[type="application/ld+json"]').each((_, element) => {
|
||||||
|
const content = $(element).text();
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
flattenJsonLdNodes(parsed, nodes);
|
||||||
|
} catch {
|
||||||
|
// Ignore broken JSON-LD blocks from ads/trackers and keep trying others.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let detailAddress = null;
|
||||||
|
let detailDescription = null;
|
||||||
|
|
||||||
|
if (domAddress) {
|
||||||
|
detailAddress = domAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
const candidateAddress = buildAddressFromJsonLd(
|
||||||
|
node.address || node?.itemOffered?.address || node?.offers?.address,
|
||||||
|
);
|
||||||
|
if (!detailAddress && candidateAddress) {
|
||||||
|
detailAddress = candidateAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateDescription = cleanText(node.description || node?.itemOffered?.description);
|
||||||
|
if (!detailDescription && candidateDescription) {
|
||||||
|
detailDescription = candidateDescription;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detailAddress && detailDescription) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
detailAddress,
|
||||||
|
detailDescription,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrichListingFromDetails(listing, browser) {
|
||||||
|
const absoluteLink = toAbsoluteLink(listing.link);
|
||||||
|
if (!absoluteLink) return listing;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const html = await puppeteerExtractor(absoluteLink, null, { browser });
|
||||||
|
if (!html) return { ...listing, link: absoluteLink };
|
||||||
|
|
||||||
|
const { detailAddress, detailDescription } = extractDetailFromHtml(html);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...listing,
|
||||||
|
link: absoluteLink,
|
||||||
|
address: detailAddress || listing.address,
|
||||||
|
description: detailDescription || listing.description,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Could not fetch Kleinanzeigen detail page for listing '${listing.id}'.`, error?.message || error);
|
||||||
|
return { ...listing, link: absoluteLink };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDetails(listing, browser) {
|
||||||
|
return enrichListingFromDetails(listing, browser);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} o
|
||||||
|
* @returns {ParsedListing}
|
||||||
|
*/
|
||||||
|
function normalize(o) {
|
||||||
|
const parts = (o.tags || '').split('·').map((p) => p.trim());
|
||||||
|
const size = parts.find((p) => p.includes('m²'));
|
||||||
|
const rooms = parts.find((p) => p.includes('Zi.'));
|
||||||
|
const id = buildHash(o.id, o.price);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: o.title,
|
||||||
|
link: toAbsoluteLink(o.link) || o.link,
|
||||||
|
price: extractNumber(o.price),
|
||||||
|
size: extractNumber(size),
|
||||||
|
rooms: extractNumber(rooms),
|
||||||
|
address: o.address,
|
||||||
|
description: o.description,
|
||||||
|
image: o.image,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ParsedListing} o
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
@@ -24,28 +184,31 @@ function applyBlacklist(o) {
|
|||||||
return o.title != null && !isBlacklistedDistrict && titleNotBlacklisted && descNotBlacklisted;
|
return o.title != null && !isBlacklistedDistrict && titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {ProviderConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '#srchrslt-adtable .ad-listitem ',
|
crawlContainer: '#srchrslt-adtable .ad-listitem ',
|
||||||
//sort by date is standard oO
|
//sort by date is standard oO
|
||||||
sortByDateParam: null,
|
sortByDateParam: null,
|
||||||
waitForSelector: 'body',
|
waitForSelector: 'body',
|
||||||
crawlFields: {
|
crawlFields: {
|
||||||
id: '.aditem@data-adid | int',
|
id: '.aditem@data-adid',
|
||||||
price: '.aditem-main--middle--price-shipping--price | removeNewline | trim',
|
price: '.aditem-main--middle--price-shipping--price | removeNewline | trim',
|
||||||
size: '.aditem-main .text-module-end | removeNewline | trim',
|
tags: '.aditem-main--middle--tags | removeNewline | trim',
|
||||||
title: '.aditem-main .text-module-begin a | removeNewline | trim',
|
title: '.aditem-main .text-module-begin a | removeNewline | trim',
|
||||||
link: '.aditem-main .text-module-begin a@href | removeNewline | trim',
|
link: '.aditem-main .text-module-begin a@href | removeNewline | trim',
|
||||||
description: '.aditem-main .aditem-main--middle--description | removeNewline | trim',
|
description: '.aditem-main .aditem-main--middle--description | removeNewline | trim',
|
||||||
address: '.aditem-main--top--left | trim | removeNewline',
|
address: '.aditem-main--top--left | trim | removeNewline',
|
||||||
image: 'img@src',
|
image: 'img@src',
|
||||||
},
|
},
|
||||||
|
fetchDetails,
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
activeTester: checkIfListingIsActive,
|
activeTester: checkIfListingIsActive,
|
||||||
};
|
};
|
||||||
export const metaInformation = {
|
export const metaInformation = {
|
||||||
name: 'Ebay Kleinanzeigen',
|
name: 'Kleinanzeigen',
|
||||||
baseUrl: 'https://www.kleinanzeigen.de/',
|
baseUrl: 'https://www.kleinanzeigen.de/',
|
||||||
id: 'kleinanzeigen',
|
id: 'kleinanzeigen',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,23 +5,46 @@
|
|||||||
|
|
||||||
import { isOneOf, buildHash } from '../utils.js';
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
import { extractNumber } from '../utils/extract-number.js';
|
||||||
|
/** @import { ParsedListing } from '../types/listing.js' */
|
||||||
|
/** @import { ProviderConfig } from '../types/providerConfig.js' */
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} o
|
||||||
|
* @returns {ParsedListing}
|
||||||
|
*/
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const originalId = o.id.split('/').pop();
|
const originalId = o.id.split('/').pop();
|
||||||
const id = buildHash(originalId, o.price);
|
const id = buildHash(originalId, o.price);
|
||||||
const size = o.size ?? 'N/A m²';
|
const link = o.link != null ? `https://www.mcmakler.de${o.link}` : o.link;
|
||||||
const title = o.title || 'No title available';
|
const [rooms, size] = o.tags.split(' | ');
|
||||||
const address = o.address?.replace(' / ', ' ') || null;
|
const address = o.address?.replace(' / ', ' ') || null;
|
||||||
const link = o.link != null ? `https://www.mcmakler.de${o.link}` : config.url;
|
return {
|
||||||
return Object.assign(o, { id, size, title, link, address });
|
id,
|
||||||
|
link,
|
||||||
|
title: o.title || '',
|
||||||
|
price: extractNumber(o.price),
|
||||||
|
size: extractNumber(size),
|
||||||
|
rooms: extractNumber(rooms),
|
||||||
|
address,
|
||||||
|
image: o.image,
|
||||||
|
description: undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* @param {ParsedListing} o
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
/** @type {ProviderConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: 'article[data-testid="propertyCard"]',
|
crawlContainer: 'article[data-testid="propertyCard"]',
|
||||||
sortByDateParam: 'sortBy=DATE&sortOn=DESC',
|
sortByDateParam: 'sortBy=DATE&sortOn=DESC',
|
||||||
@@ -30,7 +53,7 @@ const config = {
|
|||||||
id: 'h2 a@href',
|
id: 'h2 a@href',
|
||||||
title: 'h2 a | removeNewline | trim',
|
title: 'h2 a | removeNewline | trim',
|
||||||
price: 'footer > p:first-of-type | trim',
|
price: 'footer > p:first-of-type | trim',
|
||||||
size: 'footer > p:nth-of-type(2) | trim',
|
tags: 'footer > p:nth-of-type(2) | trim',
|
||||||
address: 'div > h2 + p | removeNewline | trim',
|
address: 'div > h2 + p | removeNewline | trim',
|
||||||
image: 'img@src',
|
image: 'img@src',
|
||||||
link: 'h2 a@href',
|
link: 'h2 a@href',
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
|
|
||||||
import { isOneOf, buildHash } from '../utils.js';
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
import { extractNumber } from '../utils/extract-number.js';
|
||||||
|
/** @import { ParsedListing } from '../types/listing.js' */
|
||||||
|
/** @import { ProviderConfig } from '../types/providerConfig.js' */
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
@@ -12,19 +15,39 @@ function nullOrEmpty(val) {
|
|||||||
return val == null || val.length === 0;
|
return val == null || val.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} o
|
||||||
|
* @returns {ParsedListing}
|
||||||
|
*/
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const link = nullOrEmpty(o.link)
|
const link = nullOrEmpty(o.link)
|
||||||
? 'NO LINK'
|
? 'NO LINK'
|
||||||
: `https://www.neubaukompass.de${o.link.substring(o.link.indexOf('/neubau'))}`;
|
: `https://www.neubaukompass.de${o.link.substring(o.link.indexOf('/neubau'))}`;
|
||||||
const id = buildHash(o.link, o.price);
|
const id = buildHash(o.link, o.price);
|
||||||
return Object.assign(o, { id, link });
|
return {
|
||||||
|
id,
|
||||||
|
link,
|
||||||
|
title: o.title || '',
|
||||||
|
price: extractNumber(o.price),
|
||||||
|
size: extractNumber(o.size),
|
||||||
|
rooms: extractNumber(o.rooms),
|
||||||
|
address: o.address,
|
||||||
|
image: o.image,
|
||||||
|
description: o.description,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ParsedListing} o
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
return !isOneOf(o.title, appliedBlackList);
|
return !isOneOf(o.title, appliedBlackList);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {ProviderConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '.col-12.mb-4',
|
crawlContainer: '.col-12.mb-4',
|
||||||
sortByDateParam: 'Sortierung=Id&Richtung=DESC',
|
sortByDateParam: 'Sortierung=Id&Richtung=DESC',
|
||||||
@@ -34,7 +57,9 @@ const config = {
|
|||||||
title: 'a@title | removeNewline | trim',
|
title: 'a@title | removeNewline | trim',
|
||||||
link: 'a@href',
|
link: 'a@href',
|
||||||
address: '.nbk-project-card__description | removeNewline | trim',
|
address: '.nbk-project-card__description | removeNewline | trim',
|
||||||
price: '.nbk-project-card__spec-item .nbk-project-card__spec-value | removeNewline | trim',
|
price: '.nbk-project-card__spec-item:nth-child(1) .nbk-project-card__spec-value | removeNewline | trim',
|
||||||
|
size: '.nbk-project-card__spec-item:nth-child(2) .nbk-project-card__spec-value | removeNewline | trim',
|
||||||
|
rooms: '.nbk-project-card__spec-item:nth-child(3) .nbk-project-card__spec-value | removeNewline | trim',
|
||||||
image: '.nbk-project-card__image@src',
|
image: '.nbk-project-card__image@src',
|
||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
|
|||||||
@@ -5,19 +5,43 @@
|
|||||||
|
|
||||||
import { isOneOf, buildHash } from '../utils.js';
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
import { extractNumber } from '../utils/extract-number.js';
|
||||||
|
/** @import { ParsedListing } from '../types/listing.js' */
|
||||||
|
/** @import { ProviderConfig } from '../types/providerConfig.js' */
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} o
|
||||||
|
* @returns {ParsedListing}
|
||||||
|
*/
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const link = metaInformation.baseUrl + o.link;
|
const link = metaInformation.baseUrl + o.link;
|
||||||
const id = buildHash(o.title, o.link, o.price);
|
const id = buildHash(o.title, o.link, o.price);
|
||||||
return Object.assign(o, { link, id });
|
return {
|
||||||
|
id,
|
||||||
|
link,
|
||||||
|
title: o.title || '',
|
||||||
|
price: extractNumber(o.price),
|
||||||
|
size: extractNumber(o.size),
|
||||||
|
rooms: extractNumber(o.rooms),
|
||||||
|
address: o.address,
|
||||||
|
image: o.image,
|
||||||
|
description: o.description,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* @param {ParsedListing} o
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
/** @type {ProviderConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: 'div[data-livecomponent-id*="search/property_list"] .grid > div',
|
crawlContainer: 'div[data-livecomponent-id*="search/property_list"] .grid > div',
|
||||||
sortByDateParam: null,
|
sortByDateParam: null,
|
||||||
@@ -27,6 +51,7 @@ const config = {
|
|||||||
title: 'h4 | removeNewline | trim',
|
title: 'h4 | removeNewline | trim',
|
||||||
price: '.text-xl | trim',
|
price: '.text-xl | trim',
|
||||||
size: 'div[title="Wohnfläche"] | trim',
|
size: 'div[title="Wohnfläche"] | trim',
|
||||||
|
rooms: 'div[title="Zimmer"] | trim',
|
||||||
address: '.text-slate-800 | removeNewline | trim',
|
address: '.text-slate-800 | removeNewline | trim',
|
||||||
image: 'img@src',
|
image: 'img@src',
|
||||||
link: 'a@href',
|
link: 'a@href',
|
||||||
|
|||||||
@@ -5,24 +5,47 @@
|
|||||||
|
|
||||||
import { isOneOf, buildHash } from '../utils.js';
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
import { extractNumber } from '../utils/extract-number.js';
|
||||||
|
/** @import { ParsedListing } from '../types/listing.js' */
|
||||||
|
/** @import { ProviderConfig } from '../types/providerConfig.js' */
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} o
|
||||||
|
* @returns {ParsedListing}
|
||||||
|
*/
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const id = buildHash(o.id, o.price);
|
const id = buildHash(o.id, o.price);
|
||||||
const address = o.address?.replace(/^adresse /i, '') ?? null;
|
const address = o.address?.replace(/^adresse /i, '') ?? null;
|
||||||
const title = o.title || 'No title available';
|
|
||||||
const link = o.link != null ? decodeURIComponent(o.link) : config.url;
|
const link = o.link != null ? decodeURIComponent(o.link) : config.url;
|
||||||
|
|
||||||
const urlReg = new RegExp(/url\((.*?)\)/gim);
|
const urlReg = new RegExp(/url\((.*?)\)/gim);
|
||||||
const image = o.image != null ? urlReg.exec(o.image)[1] : null;
|
const image = o.image != null ? urlReg.exec(o.image)[1] : null;
|
||||||
return Object.assign(o, { id, address, title, link, image });
|
return {
|
||||||
|
id,
|
||||||
|
link,
|
||||||
|
title: o.title || '',
|
||||||
|
price: extractNumber(o.price),
|
||||||
|
size: extractNumber(o.size),
|
||||||
|
rooms: extractNumber(o.rooms),
|
||||||
|
address,
|
||||||
|
image,
|
||||||
|
description: o.description,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* @param {ParsedListing} o
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
/** @type {ProviderConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '.listentry-content',
|
crawlContainer: '.listentry-content',
|
||||||
sortByDateParam: null, // sort by date is standard
|
sortByDateParam: null, // sort by date is standard
|
||||||
@@ -32,6 +55,7 @@ const config = {
|
|||||||
title: 'h2 | trim',
|
title: 'h2 | trim',
|
||||||
price: '.listentry-details-price .listentry-details-v | trim',
|
price: '.listentry-details-price .listentry-details-v | trim',
|
||||||
size: '.listentry-details-size .listentry-details-v | trim',
|
size: '.listentry-details-size .listentry-details-v | trim',
|
||||||
|
rooms: '.listentry-details-rooms .listentry-details-v | trim',
|
||||||
address: '.listentry-adress | trim',
|
address: '.listentry-adress | trim',
|
||||||
image: '.listentry-img@style',
|
image: '.listentry-img@style',
|
||||||
link: '.shariff@data-url',
|
link: '.shariff@data-url',
|
||||||
|
|||||||
@@ -5,37 +5,109 @@
|
|||||||
|
|
||||||
import { isOneOf, buildHash } from '../utils.js';
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
import logger from '../services/logger.js';
|
||||||
|
import { extractNumber } from '../utils/extract-number.js';
|
||||||
|
/** @import { ParsedListing } from '../types/listing.js' */
|
||||||
|
/** @import { ProviderConfig } from '../types/providerConfig.js' */
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
async function fetchDetails(listing, browser) {
|
||||||
|
try {
|
||||||
|
const html = await puppeteerExtractor(listing.link, 'body', { browser });
|
||||||
|
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
const nextDataRaw = $('#__NEXT_DATA__').text;
|
||||||
|
if (!nextDataRaw) return listing;
|
||||||
|
|
||||||
|
const estate = JSON.parse(nextDataRaw)?.props?.pageProps?.estate;
|
||||||
|
if (!estate) return listing;
|
||||||
|
|
||||||
|
const description = (estate.frontendItems || [])
|
||||||
|
.map((item) => {
|
||||||
|
const texts = (item.contents || [])
|
||||||
|
.filter((c) => c.type === 'contentBoxes')
|
||||||
|
.flatMap((c) => c.data || [])
|
||||||
|
.filter((d) => d.type === 'text' && d.content)
|
||||||
|
.map((d) => d.content);
|
||||||
|
if (!texts.length) return null;
|
||||||
|
return [item.label, ...texts].filter(Boolean).join('\n');
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n');
|
||||||
|
|
||||||
|
const addr = estate.address;
|
||||||
|
let address = listing.address;
|
||||||
|
if (addr) {
|
||||||
|
const street = [addr.street, addr.streetNumber].filter(Boolean).join(' ');
|
||||||
|
const cityLine = [addr.zip, addr.city].filter(Boolean).join(' ');
|
||||||
|
const full = [street, cityLine].filter(Boolean).join(', ');
|
||||||
|
if (full) address = full;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...listing,
|
||||||
|
address,
|
||||||
|
description: description || listing.description,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Could not fetch Sparkasse detail page for listing '${listing.id}'.`, error?.message || error);
|
||||||
|
return listing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} o
|
||||||
|
* @returns {ParsedListing}
|
||||||
|
*/
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const originalId = o.id.split('/').pop().replace('.html', '');
|
const originalId = o.id.split('/').pop().replace('.html', '');
|
||||||
const id = buildHash(originalId, o.price);
|
const id = buildHash(originalId, o.price);
|
||||||
const size = o.size?.replace(' Wohnfläche', '') ?? null;
|
|
||||||
const title = o.title || 'No title available';
|
|
||||||
const link = o.link != null ? `https://immobilien.sparkasse.de${o.link}` : config.url;
|
const link = o.link != null ? `https://immobilien.sparkasse.de${o.link}` : config.url;
|
||||||
return Object.assign(o, { id, size, title, link });
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
link,
|
||||||
|
title: o.title || '',
|
||||||
|
price: extractNumber(o.price),
|
||||||
|
size: extractNumber(o.size),
|
||||||
|
rooms: extractNumber(o.rooms),
|
||||||
|
address: o.address,
|
||||||
|
image: o.image,
|
||||||
|
description: o.description,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* @param {ParsedListing} o
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
/** @type {ProviderConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '.estate-list-item-row',
|
crawlContainer: 'div[data-testid="estate-link"]',
|
||||||
sortByDateParam: 'sortBy=date_desc',
|
sortByDateParam: 'sortBy=date_desc',
|
||||||
waitForSelector: 'body',
|
waitForSelector: 'body',
|
||||||
crawlFields: {
|
crawlFields: {
|
||||||
id: 'div[data-testid="estate-link"] a@href',
|
id: 'a@href',
|
||||||
title: 'h3 | trim',
|
title: 'h3 | trim',
|
||||||
price: '.estate-list-price | trim',
|
price: '.estate-list-price | trim',
|
||||||
size: '.estate-mainfact:first-child span | trim',
|
size: '.estate-mainfact:nth-child(1) span | trim',
|
||||||
|
rooms: '.estate-mainfact:nth-child(2) span | trim',
|
||||||
address: 'h6 | trim',
|
address: 'h6 | trim',
|
||||||
image: '.estate-list-item-image-container img@src',
|
image: 'img@src',
|
||||||
link: 'div[data-testid="estate-link"] a@href',
|
link: 'a@href',
|
||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
fetchDetails,
|
||||||
activeTester: (url) => checkIfListingIsActive(url, 'Angebot nicht gefunden'),
|
activeTester: (url) => checkIfListingIsActive(url, 'Angebot nicht gefunden'),
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
|
|||||||
@@ -5,22 +5,69 @@
|
|||||||
|
|
||||||
import { isOneOf, buildHash } from '../utils.js';
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
import { extractNumber } from '../utils/extract-number.js';
|
||||||
|
import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
import logger from '../services/logger.js';
|
||||||
|
/** @import { ParsedListing } from '../types/listing.js' */
|
||||||
|
/** @import { ProviderConfig } from '../types/providerConfig.js' */
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
async function fetchDetails(listing, browser) {
|
||||||
|
try {
|
||||||
|
const html = await puppeteerExtractor(listing.link, null, { browser });
|
||||||
|
if (!html) return listing;
|
||||||
|
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
|
$('#freitext_0 script').remove();
|
||||||
|
const description = $('#freitext_0').text().replace(/\s+/g, ' ').trim();
|
||||||
|
const address = $('a[href="#map_container"] .section_panel_detail').text().replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...listing,
|
||||||
|
address: address || listing.address,
|
||||||
|
description: description || listing.description,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Could not fetch wgGesucht detail page for listing '${listing.id}'.`, error?.message || error);
|
||||||
|
return listing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @param {any} o
|
||||||
|
* @returns {ParsedListing}
|
||||||
|
*/
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const id = buildHash(o.id, o.price);
|
const id = buildHash(o.id, o.price);
|
||||||
const link = `https://www.wg-gesucht.de${o.link}`;
|
const link = `https://www.wg-gesucht.de${o.link}`;
|
||||||
const image = o.image != null ? o.image.replace('small', 'large') : null;
|
const image = o.image != null ? o.image.replace('small', 'large') : null;
|
||||||
return Object.assign(o, { id, link, image });
|
const [rooms, city, road] = o.details?.split(' | ') || [];
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
link,
|
||||||
|
title: o.title || '',
|
||||||
|
price: extractNumber(o.price),
|
||||||
|
size: extractNumber(o.size),
|
||||||
|
rooms: extractNumber(rooms),
|
||||||
|
address: `${city}, ${road}`,
|
||||||
|
image,
|
||||||
|
description: o.description,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ParsedListing} o
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
return o.id != null && titleNotBlacklisted && descNotBlacklisted;
|
return o.id != null && titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {ProviderConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '#main_column .wgg_card',
|
crawlContainer: '#main_column .wgg_card',
|
||||||
@@ -31,12 +78,16 @@ const config = {
|
|||||||
details: '.row .noprint .col-xs-11 |removeNewline |trim',
|
details: '.row .noprint .col-xs-11 |removeNewline |trim',
|
||||||
price: '.middle .col-xs-3 |removeNewline |trim',
|
price: '.middle .col-xs-3 |removeNewline |trim',
|
||||||
size: '.middle .text-right |removeNewline |trim',
|
size: '.middle .text-right |removeNewline |trim',
|
||||||
|
rooms: '.middle .text-right |removeNewline |trim',
|
||||||
title: '.truncate_title a |removeNewline |trim',
|
title: '.truncate_title a |removeNewline |trim',
|
||||||
link: '.truncate_title a@href',
|
link: '.truncate_title a@href',
|
||||||
image: '.img-responsive@src',
|
image: '.img-responsive@src',
|
||||||
|
description: '.row .noprint .col-xs-11 |removeNewline |trim',
|
||||||
},
|
},
|
||||||
|
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
fetchDetails,
|
||||||
activeTester: checkIfListingIsActive,
|
activeTester: checkIfListingIsActive,
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
|
|||||||
@@ -5,26 +5,45 @@
|
|||||||
|
|
||||||
import * as utils from '../utils.js';
|
import * as utils from '../utils.js';
|
||||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
import { extractNumber } from '../utils/extract-number.js';
|
||||||
|
/** @import { ParsedListing } from '../types/listing.js' */
|
||||||
|
/** @import { ProviderConfig } from '../types/providerConfig.js' */
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} o
|
||||||
|
* @returns {ParsedListing}
|
||||||
|
*/
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const id = o.link.split('/').pop();
|
|
||||||
const price = o.price;
|
|
||||||
const size = o.size;
|
|
||||||
const rooms = o.rooms;
|
|
||||||
const [city = '', part = ''] = (o.description || '').split('-').map((v) => v.trim());
|
const [city = '', part = ''] = (o.description || '').split('-').map((v) => v.trim());
|
||||||
const address = `${part}, ${city}`;
|
const address = `${part}, ${city}`;
|
||||||
return Object.assign(o, { id, price, size, rooms, address });
|
return {
|
||||||
|
id: o.link.split('/').pop(),
|
||||||
|
link: o.link,
|
||||||
|
title: o.title || '',
|
||||||
|
price: extractNumber(o.price),
|
||||||
|
size: extractNumber(o.size),
|
||||||
|
rooms: extractNumber(o.rooms),
|
||||||
|
address,
|
||||||
|
image: o.image,
|
||||||
|
description: o.description,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ParsedListing} o
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
||||||
return o.id != null && o.title != null && titleNotBlacklisted && descNotBlacklisted && o.link.startsWith(o.link);
|
return o.id != null && o.title != null && titleNotBlacklisted && descNotBlacklisted && o.link.startsWith(o.link);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {ProviderConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
|
||||||
url: null,
|
url: null,
|
||||||
sortByDateParam: null,
|
sortByDateParam: null,
|
||||||
waitForSelector: 'body',
|
waitForSelector: 'body',
|
||||||
@@ -37,7 +56,7 @@ const config = {
|
|||||||
size: 'dl:nth-of-type(3) dd | removeNewline | trim',
|
size: 'dl:nth-of-type(3) dd | removeNewline | trim',
|
||||||
description: 'div.before\\:icon-location_marker | trim',
|
description: 'div.before\\:icon-location_marker | trim',
|
||||||
link: '@href',
|
link: '@href',
|
||||||
imageUrl: 'img@src',
|
image: 'img@src',
|
||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
|||||||
@@ -94,12 +94,34 @@ export async function applyBotPreventionToPage(page, cfg) {
|
|||||||
// webdriver
|
// webdriver
|
||||||
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
||||||
|
|
||||||
// chrome runtime
|
// chrome runtime — expose loadTimes, csi and app like real Chrome
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (!window.chrome) {
|
window.chrome = {
|
||||||
|
runtime: {},
|
||||||
// @ts-ignore
|
// @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
|
// languages
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -107,23 +129,38 @@ export async function applyBotPreventionToPage(page, cfg) {
|
|||||||
get: () => (window.localStorage.getItem('__LANGS__') || 'de-DE,de').split(','),
|
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
|
// @ts-ignore
|
||||||
Object.defineProperty(navigator, 'plugins', {
|
Object.defineProperty(navigator, 'plugins', { get: () => fakePlugins });
|
||||||
get: () => [{}, {}, {}],
|
// @ts-ignore
|
||||||
});
|
Object.defineProperty(navigator, 'mimeTypes', { get: () => [fakePlugins[0][0]] });
|
||||||
|
|
||||||
// platform and concurrency hints
|
// platform and concurrency hints
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
Object.defineProperty(navigator, 'platform', { get: () => 'Win32' });
|
Object.defineProperty(navigator, 'platform', { get: () => 'Win32' });
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (typeof navigator.hardwareConcurrency === 'number' && navigator.hardwareConcurrency < 2) {
|
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
|
||||||
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 4 });
|
|
||||||
}
|
|
||||||
// @ts-ignore
|
// @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)
|
// userAgentData (Client Hints)
|
||||||
try {
|
try {
|
||||||
@@ -236,6 +273,21 @@ export async function applyBotPreventionToPage(page, cfg) {
|
|||||||
} catch {
|
} catch {
|
||||||
//noop
|
//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 {
|
} catch {
|
||||||
//noop
|
//noop
|
||||||
}
|
}
|
||||||
@@ -273,6 +325,8 @@ export async function applyPostNavigationHumanSignals(page, cfg) {
|
|||||||
const my = Math.floor(vh * (0.3 + Math.random() * 0.4));
|
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.move(mx, my, { steps: 10 + Math.floor(Math.random() * 10) });
|
||||||
await page.mouse.wheel({ deltaY: 100 + Math.floor(Math.random() * 200) });
|
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 {
|
} catch {
|
||||||
// ignore if mouse is unavailable
|
// ignore if mouse is unavailable
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,12 +47,17 @@ export async function launchBrowser(url, options) {
|
|||||||
removeUserDataDir = true;
|
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({
|
const browser = await puppeteer.launch({
|
||||||
headless: options?.puppeteerHeadless ?? true,
|
headless: options?.puppeteerHeadless ?? true,
|
||||||
args: launchArgs,
|
args: launchArgs,
|
||||||
timeout: options?.puppeteerTimeout || 45_000,
|
timeout: options?.puppeteerTimeout || 45_000,
|
||||||
userDataDir,
|
userDataDir,
|
||||||
executablePath: options?.executablePath,
|
executablePath,
|
||||||
});
|
});
|
||||||
|
|
||||||
browser.__fredy_userDataDir = userDataDir;
|
browser.__fredy_userDataDir = userDataDir;
|
||||||
@@ -105,6 +110,7 @@ export default async function execute(url, waitForSelector, options) {
|
|||||||
// Navigation
|
// Navigation
|
||||||
const response = await page.goto(url, {
|
const response = await page.goto(url, {
|
||||||
waitUntil: options?.waitUntil || 'domcontentloaded',
|
waitUntil: options?.waitUntil || 'domcontentloaded',
|
||||||
|
timeout: options?.puppeteerTimeout || 60000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Optionally wait and add subtle human-like interactions
|
// Optionally wait and add subtle human-like interactions
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ const PARAM_NAME_MAP = {
|
|||||||
price: 'price',
|
price: 'price',
|
||||||
constructionyear: 'constructionyear',
|
constructionyear: 'constructionyear',
|
||||||
apartmenttypes: 'apartmenttypes',
|
apartmenttypes: 'apartmenttypes',
|
||||||
|
buildingtypes: 'buildingtypes',
|
||||||
|
ground: 'ground',
|
||||||
pricetype: 'pricetype',
|
pricetype: 'pricetype',
|
||||||
floor: 'floor',
|
floor: 'floor',
|
||||||
geocodes: 'geocodes',
|
geocodes: 'geocodes',
|
||||||
@@ -98,6 +100,7 @@ const EQUIPMENT_MAP = {
|
|||||||
guesttoilet: 'guestToilet',
|
guesttoilet: 'guestToilet',
|
||||||
balcony: 'balcony',
|
balcony: 'balcony',
|
||||||
handicappedaccessible: 'handicappedAccessible',
|
handicappedaccessible: 'handicappedAccessible',
|
||||||
|
lodgerflat: 'lodgerflat',
|
||||||
};
|
};
|
||||||
|
|
||||||
const REAL_ESTATE_TYPE = {
|
const REAL_ESTATE_TYPE = {
|
||||||
@@ -107,6 +110,10 @@ const REAL_ESTATE_TYPE = {
|
|||||||
'wohnung-kaufen-mit-balkon': 'apartmentbuy',
|
'wohnung-kaufen-mit-balkon': 'apartmentbuy',
|
||||||
'eigentumswohnung-mit-garten': 'apartmentbuy',
|
'eigentumswohnung-mit-garten': 'apartmentbuy',
|
||||||
'haus-kaufen': 'housebuy',
|
'haus-kaufen': 'housebuy',
|
||||||
|
'haus-mit-keller-kaufen': 'housebuy',
|
||||||
|
'luxushaus-kaufen': 'housebuy',
|
||||||
|
'villa-kaufen': 'housebuy',
|
||||||
|
'neubauhaus-kaufen': 'housebuy',
|
||||||
};
|
};
|
||||||
|
|
||||||
const WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP = {
|
const WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP = {
|
||||||
|
|||||||
@@ -178,15 +178,7 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
|
|||||||
browser = await puppeteerExtractor.launchBrowser(matchedProvider.config.url, {});
|
browser = await puppeteerExtractor.launchBrowser(matchedProvider.config.url, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
await new FredyPipelineExecutioner(
|
await new FredyPipelineExecutioner(matchedProvider.config, job, prov.id, similarityCache, browser).execute();
|
||||||
matchedProvider.config,
|
|
||||||
job.notificationAdapter,
|
|
||||||
job.spatialFilter,
|
|
||||||
prov.id,
|
|
||||||
job.id,
|
|
||||||
similarityCache,
|
|
||||||
browser,
|
|
||||||
).execute();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(err);
|
logger.error(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const upsertJob = ({
|
|||||||
userId,
|
userId,
|
||||||
shareWithUsers = [],
|
shareWithUsers = [],
|
||||||
spatialFilter = null,
|
spatialFilter = null,
|
||||||
|
specFilter = null,
|
||||||
}) => {
|
}) => {
|
||||||
const id = jobId || nanoid();
|
const id = jobId || nanoid();
|
||||||
const existing = SqliteConnection.query(`SELECT id, user_id FROM jobs WHERE id = @id LIMIT 1`, { id })[0];
|
const existing = SqliteConnection.query(`SELECT id, user_id FROM jobs WHERE id = @id LIMIT 1`, { id })[0];
|
||||||
@@ -44,7 +45,8 @@ export const upsertJob = ({
|
|||||||
provider = @provider,
|
provider = @provider,
|
||||||
notification_adapter = @notification_adapter,
|
notification_adapter = @notification_adapter,
|
||||||
shared_with_user = @shareWithUsers,
|
shared_with_user = @shareWithUsers,
|
||||||
spatial_filter = @spatialFilter
|
spatial_filter = @spatialFilter,
|
||||||
|
spec_filter = @specFilter
|
||||||
WHERE id = @id`,
|
WHERE id = @id`,
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
@@ -55,12 +57,13 @@ export const upsertJob = ({
|
|||||||
provider: toJson(provider ?? []),
|
provider: toJson(provider ?? []),
|
||||||
notification_adapter: toJson(notificationAdapter ?? []),
|
notification_adapter: toJson(notificationAdapter ?? []),
|
||||||
spatialFilter: spatialFilter ? toJson(spatialFilter) : null,
|
spatialFilter: spatialFilter ? toJson(spatialFilter) : null,
|
||||||
|
specFilter: specFilter ? toJson(specFilter) : null,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
SqliteConnection.execute(
|
SqliteConnection.execute(
|
||||||
`INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter, shared_with_user, spatial_filter)
|
`INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter, shared_with_user, spatial_filter, spec_filter)
|
||||||
VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter, @shareWithUsers, @spatialFilter)`,
|
VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter, @shareWithUsers, @spatialFilter, @specFilter)`,
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
user_id: ownerId,
|
user_id: ownerId,
|
||||||
@@ -71,6 +74,7 @@ export const upsertJob = ({
|
|||||||
shareWithUsers: toJson(shareWithUsers ?? []),
|
shareWithUsers: toJson(shareWithUsers ?? []),
|
||||||
notification_adapter: toJson(notificationAdapter ?? []),
|
notification_adapter: toJson(notificationAdapter ?? []),
|
||||||
spatialFilter: spatialFilter ? toJson(spatialFilter) : null,
|
spatialFilter: spatialFilter ? toJson(spatialFilter) : null,
|
||||||
|
specFilter: specFilter ? toJson(specFilter) : null,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -92,6 +96,7 @@ export const getJob = (jobId) => {
|
|||||||
j.shared_with_user,
|
j.shared_with_user,
|
||||||
j.notification_adapter AS notificationAdapter,
|
j.notification_adapter AS notificationAdapter,
|
||||||
j.spatial_filter AS spatialFilter,
|
j.spatial_filter AS spatialFilter,
|
||||||
|
j.spec_filter AS specFilter,
|
||||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
|
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
|
||||||
FROM jobs j
|
FROM jobs j
|
||||||
WHERE j.id = @id
|
WHERE j.id = @id
|
||||||
@@ -107,6 +112,7 @@ export const getJob = (jobId) => {
|
|||||||
shared_with_user: fromJson(row.shared_with_user, []),
|
shared_with_user: fromJson(row.shared_with_user, []),
|
||||||
notificationAdapter: fromJson(row.notificationAdapter, []),
|
notificationAdapter: fromJson(row.notificationAdapter, []),
|
||||||
spatialFilter: fromJson(row.spatialFilter, null),
|
spatialFilter: fromJson(row.spatialFilter, null),
|
||||||
|
specFilter: fromJson(row.specFilter, null),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -157,6 +163,7 @@ export const getJobs = () => {
|
|||||||
j.shared_with_user,
|
j.shared_with_user,
|
||||||
j.notification_adapter AS notificationAdapter,
|
j.notification_adapter AS notificationAdapter,
|
||||||
j.spatial_filter AS spatialFilter,
|
j.spatial_filter AS spatialFilter,
|
||||||
|
j.spec_filter AS specFilter,
|
||||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
|
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
|
||||||
FROM jobs j
|
FROM jobs j
|
||||||
WHERE j.enabled = 1
|
WHERE j.enabled = 1
|
||||||
@@ -170,6 +177,7 @@ export const getJobs = () => {
|
|||||||
shared_with_user: fromJson(row.shared_with_user, []),
|
shared_with_user: fromJson(row.shared_with_user, []),
|
||||||
notificationAdapter: fromJson(row.notificationAdapter, []),
|
notificationAdapter: fromJson(row.notificationAdapter, []),
|
||||||
spatialFilter: fromJson(row.spatialFilter, null),
|
spatialFilter: fromJson(row.spatialFilter, null),
|
||||||
|
specFilter: fromJson(row.specFilter, null),
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -260,6 +268,7 @@ export const queryJobs = ({
|
|||||||
j.shared_with_user,
|
j.shared_with_user,
|
||||||
j.notification_adapter AS notificationAdapter,
|
j.notification_adapter AS notificationAdapter,
|
||||||
j.spatial_filter AS spatialFilter,
|
j.spatial_filter AS spatialFilter,
|
||||||
|
j.spec_filter AS specFilter,
|
||||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
|
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
|
||||||
FROM jobs j
|
FROM jobs j
|
||||||
${whereSql}
|
${whereSql}
|
||||||
@@ -276,6 +285,7 @@ export const queryJobs = ({
|
|||||||
shared_with_user: fromJson(row.shared_with_user, []),
|
shared_with_user: fromJson(row.shared_with_user, []),
|
||||||
notificationAdapter: fromJson(row.notificationAdapter, []),
|
notificationAdapter: fromJson(row.notificationAdapter, []),
|
||||||
spatialFilter: fromJson(row.spatialFilter, null),
|
spatialFilter: fromJson(row.spatialFilter, null),
|
||||||
|
specFilter: fromJson(row.specFilter, null),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { totalNumber, page: safePage, result };
|
return { totalNumber, page: safePage, result };
|
||||||
|
|||||||
@@ -29,33 +29,47 @@ export const getKnownListingHashesForJobAndProvider = (jobId, providerId) => {
|
|||||||
* Compute KPI aggregates for a given set of job IDs from the listings table.
|
* Compute KPI aggregates for a given set of job IDs from the listings table.
|
||||||
*
|
*
|
||||||
* - numberOfActiveListings: count of listings where is_active = 1
|
* - numberOfActiveListings: count of listings where is_active = 1
|
||||||
* - avgPriceOfListings: average of numeric price, rounded to nearest integer
|
* - medianPriceOfListings: median of numeric price, rounded to nearest integer
|
||||||
*
|
*
|
||||||
* When no jobIds are provided, returns zeros.
|
* When no jobIds are provided, returns zeros.
|
||||||
*
|
*
|
||||||
* @param {string[]} jobIds
|
* @param {string[]} jobIds
|
||||||
* @returns {{ numberOfActiveListings: number, avgPriceOfListings: number }}
|
* @returns {{ numberOfActiveListings: number, medianPriceOfListings: number }}
|
||||||
*/
|
*/
|
||||||
export const getListingsKpisForJobIds = (jobIds = []) => {
|
export const getListingsKpisForJobIds = (jobIds = []) => {
|
||||||
if (!Array.isArray(jobIds) || jobIds.length === 0) {
|
if (!Array.isArray(jobIds) || jobIds.length === 0) {
|
||||||
return { numberOfActiveListings: 0, avgPriceOfListings: 0 };
|
return { numberOfActiveListings: 0, medianPriceOfListings: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const placeholders = jobIds.map(() => '?').join(',');
|
const placeholders = jobIds.map(() => '?').join(',');
|
||||||
const row =
|
const rows = SqliteConnection.query(
|
||||||
SqliteConnection.query(
|
`SELECT
|
||||||
`SELECT
|
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) OVER() AS active_count,
|
||||||
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) AS activeCount,
|
price
|
||||||
AVG(price) AS avgPrice
|
FROM listings
|
||||||
FROM listings
|
WHERE job_id IN (${placeholders})
|
||||||
WHERE job_id IN (${placeholders})
|
AND manually_deleted = 0
|
||||||
AND manually_deleted = 0`,
|
GROUP BY
|
||||||
jobIds,
|
id`,
|
||||||
)[0] || {};
|
jobIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeCount = rows[0]?.active_count ?? 0;
|
||||||
|
|
||||||
|
const prices = rows
|
||||||
|
.map((r) => r.price)
|
||||||
|
.filter((p) => p !== null)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
let medianPrice = 0;
|
||||||
|
if (prices.length > 0) {
|
||||||
|
const mid = Math.floor(prices.length / 2);
|
||||||
|
medianPrice = prices.length % 2 !== 0 ? prices[mid] : (prices[mid - 1] + prices[mid]) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
numberOfActiveListings: Number(row.activeCount || 0),
|
numberOfActiveListings: activeCount,
|
||||||
avgPriceOfListings: row?.avgPrice == null ? 0 : Math.round(Number(row.avgPrice)),
|
medianPriceOfListings: medianPrice,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -174,9 +188,9 @@ export const storeListings = (jobId, providerId, listings) => {
|
|||||||
|
|
||||||
SqliteConnection.withTransaction((db) => {
|
SqliteConnection.withTransaction((db) => {
|
||||||
const stmt = db.prepare(
|
const stmt = db.prepare(
|
||||||
`INSERT INTO listings (id, hash, provider, job_id, price, size, title, image_url, description, address,
|
`INSERT INTO listings (id, hash, provider, job_id, price, size, rooms, title, image_url, description, address,
|
||||||
link, created_at, is_active, latitude, longitude)
|
link, created_at, is_active, latitude, longitude)
|
||||||
VALUES (@id, @hash, @provider, @job_id, @price, @size, @title, @image_url, @description, @address, @link,
|
VALUES (@id, @hash, @provider, @job_id, @price, @size, @rooms, @title, @image_url, @description, @address, @link,
|
||||||
@created_at, 1, @latitude, @longitude)
|
@created_at, 1, @latitude, @longitude)
|
||||||
ON CONFLICT(job_id, hash) DO NOTHING`,
|
ON CONFLICT(job_id, hash) DO NOTHING`,
|
||||||
);
|
);
|
||||||
@@ -187,8 +201,9 @@ export const storeListings = (jobId, providerId, listings) => {
|
|||||||
hash: item.id,
|
hash: item.id,
|
||||||
provider: providerId,
|
provider: providerId,
|
||||||
job_id: jobId,
|
job_id: jobId,
|
||||||
price: extractNumber(item.price),
|
price: item.price,
|
||||||
size: extractNumber(item.size),
|
size: item.size,
|
||||||
|
rooms: item.rooms,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
image_url: item.image,
|
image_url: item.image,
|
||||||
description: item.description,
|
description: item.description,
|
||||||
@@ -202,19 +217,6 @@ export const storeListings = (jobId, providerId, listings) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract the first number from a string like "1.234 €" or "70 m²".
|
|
||||||
* Removes dots/commas before parsing. Returns null on invalid input.
|
|
||||||
* @param {string|undefined|null} str
|
|
||||||
* @returns {number|null}
|
|
||||||
*/
|
|
||||||
function extractNumber(str) {
|
|
||||||
if (!str) return null;
|
|
||||||
const cleaned = str.replace(/\./g, '').replace(',', '.');
|
|
||||||
const num = parseFloat(cleaned);
|
|
||||||
return isNaN(num) ? null : num;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove any parentheses segments (including surrounding whitespace) from a string.
|
* Remove any parentheses segments (including surrounding whitespace) from a string.
|
||||||
* Returns null for empty input.
|
* Returns null for empty input.
|
||||||
|
|||||||
@@ -29,12 +29,12 @@
|
|||||||
*/
|
*/
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { pathToFileURL } from 'url';
|
import { pathToFileURL, fileURLToPath } from 'url';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import SqliteConnection from '../SqliteConnection.js';
|
import SqliteConnection from '../SqliteConnection.js';
|
||||||
import logger from '../../logger.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).
|
* Absolute path to the migrations directory (lib/services/storage/migrations/sql).
|
||||||
* @type {string}
|
* @type {string}
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ import crypto from 'crypto';
|
|||||||
// Each user gets a permanent, non-expiring secret token used for MCP API authentication.
|
// 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.
|
// Tokens are auto-generated for all existing users during this migration.
|
||||||
export function up(db) {
|
export function up(db) {
|
||||||
db.exec(`ALTER TABLE users ADD COLUMN mcp_token TEXT`);
|
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
|
// 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 users = db.prepare(`SELECT id FROM users WHERE mcp_token IS NULL`).all();
|
||||||
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,18 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Convert provider_details from a boolean to an array of provider id strings.
|
||||||
|
// Users will re-configure which providers they want to fetch details from.
|
||||||
|
export function up(db) {
|
||||||
|
const row = db.prepare("SELECT value FROM settings WHERE name = 'provider_details'").get();
|
||||||
|
if (row) {
|
||||||
|
db.prepare("UPDATE settings SET value = ? WHERE name = 'provider_details'").run(JSON.stringify([]));
|
||||||
|
} else {
|
||||||
|
db.prepare("INSERT INTO settings (name, value, create_date) VALUES ('provider_details', ?, ?)").run(
|
||||||
|
JSON.stringify([]),
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
lib/services/storage/migrations/sql/15.add-listing-specs.js
Normal file
10
lib/services/storage/migrations/sql/15.add-listing-specs.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function up(db) {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE jobs ADD COLUMN spec_filter JSONB DEFAULT NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function up(db) {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE listings ADD COLUMN rooms INTEGER;
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -67,6 +67,19 @@ export async function getSettings() {
|
|||||||
return cachedSettingsConfig;
|
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.
|
* Upsert settings rows.
|
||||||
* - Accepts an object map of name -> value, or an entry {name, value}.
|
* - Accepts an object map of name -> value, or an entry {name, value}.
|
||||||
|
|||||||
10
lib/types/browser.js
Normal file
10
lib/types/browser.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('puppeteer').Browser} Browser
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {};
|
||||||
19
lib/types/filter.js
Normal file
19
lib/types/filter.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} SpecFilter
|
||||||
|
* @property {number} [minRooms] Minimum number of rooms.
|
||||||
|
* @property {number} [minSize] Minimum size in m².
|
||||||
|
* @property {number} [maxPrice] Maximum price.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} SpatialFilter GeoJSON FeatureCollection.
|
||||||
|
* @property {Array<Object>} [features] GeoJSON features for spatial filtering (typically Polygons).
|
||||||
|
* @property {string} [type] Type 'FeatureCollection'.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {};
|
||||||
23
lib/types/job.js
Normal file
23
lib/types/job.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @import { SpecFilter, SpatialFilter } from './filter.js' */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Job
|
||||||
|
* @property {string} id Job ID.
|
||||||
|
* @property {string} [userId] Owner user id.
|
||||||
|
* @property {string} [name] Job display name.
|
||||||
|
* @property {boolean} [enabled] Whether the job is enabled.
|
||||||
|
* @property {Array<any>} [blacklist] Blacklist entries.
|
||||||
|
* @property {Array<any>} [provider] Provider configuration list.
|
||||||
|
* @property {Object} [notificationAdapter] Notification configuration.
|
||||||
|
* @property {Array<string>} [shared_with_user] Users this job is shared with.
|
||||||
|
* @property {SpatialFilter | null} [spatialFilter] Optional spatial filter configuration as GeoJSON FeatureCollection.
|
||||||
|
* @property {SpecFilter | null} [specFilter] Optional listing specifications.
|
||||||
|
* @property {number} [numberOfFoundListings] Count of active listings for this job.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {};
|
||||||
22
lib/types/listing.js
Normal file
22
lib/types/listing.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} ParsedListing
|
||||||
|
* @property {string} id Stable unique identifier (hash) of the listing.
|
||||||
|
* @property {string} link Link to the listing detail page.
|
||||||
|
* @property {string} image Link to the listing image.
|
||||||
|
* @property {string} title Title or headline of the listing.
|
||||||
|
* @property {string} [description] Description of the listing.
|
||||||
|
* @property {string} [address] Optional address/location text.
|
||||||
|
* @property {number} [price] Optional price of the listing.
|
||||||
|
* @property {number} [size] Optional size of the listing.
|
||||||
|
* @property {number} [rooms] Optional number of rooms.
|
||||||
|
* @property {number} [latitude] Optional latitude.
|
||||||
|
* @property {number} [longitude] Optional longitude.
|
||||||
|
* @property {number} [distance_to_destination] Optional distance to destination.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {};
|
||||||
25
lib/types/providerConfig.js
Normal file
25
lib/types/providerConfig.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @import { ParsedListing } from './listing.js' */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} ProviderConfig
|
||||||
|
* @property {string} [url] Base URL to crawl.
|
||||||
|
* @property {string} [sortByDateParam] Query parameter used to enforce sorting by date.
|
||||||
|
* @property {string} [waitForSelector] CSS selector to wait for before parsing content.
|
||||||
|
* @property {Object.<string, string>} crawlFields Mapping of field names to selectors/paths.
|
||||||
|
* @property {string[]} requiredFieldNames List of field names that this provider supports.
|
||||||
|
* @property {string} [crawlContainer] CSS selector for the container holding listing items.
|
||||||
|
* @property {(raw: any) => ParsedListing} normalize Function to convert raw scraped data into a ParsedListing shape.
|
||||||
|
* @property {(listing: ParsedListing) => boolean} filter Function to filter out unwanted listings.
|
||||||
|
* @property {(url: string, waitForSelector?: string) => Promise<any[]>} [getListings] Optional override to fetch listings.
|
||||||
|
* @property {(listing:ParsedListing, browser:any)=>Promise<ParsedListing>} [providerConfig.fetchDetails] Optional per-listing detail enrichment. Called in parallel for each new listing after deduplication. Receives the shared browser instance. Must always resolve (never reject).
|
||||||
|
* @property {Object} [puppeteerOptions] Puppeteer specific options.
|
||||||
|
* @property {boolean} [enabled] Whether the provider is enabled.
|
||||||
|
* @property {(url: string) => Promise<number> | number} [activeTester] Function to check if a listing is still active.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {};
|
||||||
11
lib/types/similarityCache.js
Normal file
11
lib/types/similarityCache.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} SimilarityCache
|
||||||
|
* @property {(params: { title?: string, address?: string, price?: number|string }) => boolean} checkAndAddEntry Checks if a listing is similar and adds it if not.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {};
|
||||||
18
lib/utils/extract-number.js
Normal file
18
lib/utils/extract-number.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the first number from a string like "1.234 €" or "70 m²".
|
||||||
|
* Removes dots/commas before parsing. Returns null on invalid input.
|
||||||
|
* @param {string|undefined|null} str
|
||||||
|
* @returns {number|null}
|
||||||
|
*/
|
||||||
|
export const extractNumber = (str) => {
|
||||||
|
if (str == null) return null;
|
||||||
|
if (typeof str === 'number') return str;
|
||||||
|
const cleaned = str.replace(/\./g, '').replace(',', '.');
|
||||||
|
const num = parseFloat(cleaned);
|
||||||
|
return isNaN(num) ? null : num;
|
||||||
|
};
|
||||||
29
lib/utils/formatListing.js
Normal file
29
lib/utils/formatListing.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @import { ParsedListing } from '../types/listing.js' */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Omit<import('../types/listing.js').ParsedListing, 'price' | 'size' | 'rooms'> & {
|
||||||
|
* price: string | null,
|
||||||
|
* size: string | null,
|
||||||
|
* rooms: string | null,
|
||||||
|
* }} FormattedListing
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a listing's numerical fields (price, size, rooms) into strings with their respective units.
|
||||||
|
*
|
||||||
|
* @param {import('../types/listing.js').ParsedListing} listing The original listing object.
|
||||||
|
* @returns {FormattedListing} A copy of the listing with formatted strings for price, size, and rooms.
|
||||||
|
*/
|
||||||
|
export const formatListing = (listing) => {
|
||||||
|
return {
|
||||||
|
...listing,
|
||||||
|
price: listing.price != null ? `${listing.price} €` : null,
|
||||||
|
size: listing.size != null ? `${listing.size} m²` : null,
|
||||||
|
rooms: listing.rooms != null ? `${listing.rooms} Zimmer` : null,
|
||||||
|
};
|
||||||
|
};
|
||||||
66
package.json
66
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fredy",
|
"name": "fredy",
|
||||||
"version": "20.0.2",
|
"version": "20.3.2",
|
||||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
@@ -11,8 +11,8 @@
|
|||||||
"build:frontend": "vite build",
|
"build:frontend": "vite build",
|
||||||
"format": "prettier --write \"**/*.js\"",
|
"format": "prettier --write \"**/*.js\"",
|
||||||
"format:check": "prettier --check \"**/*.js\"",
|
"format:check": "prettier --check \"**/*.js\"",
|
||||||
"test": "node --import ./test/esmock-loader.mjs ./node_modules/mocha/bin/mocha.js --timeout 60000 test/**/*.test.js",
|
"test": "vitest run",
|
||||||
"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",
|
"testGH": "vitest run --config vitest.gh.config.js",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"mcp:stdio": "node lib/mcp/stdio.js",
|
"mcp:stdio": "node lib/mcp/stdio.js",
|
||||||
"lint:fix": "yarn lint --fix",
|
"lint:fix": "yarn lint --fix",
|
||||||
@@ -61,67 +61,65 @@
|
|||||||
"Firefox ESR"
|
"Firefox ESR"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@douyinfe/semi-icons": "^2.92.2",
|
"@douyinfe/semi-icons": "^2.95.0",
|
||||||
"@douyinfe/semi-ui": "2.92.2",
|
"@douyinfe/semi-ui": "2.95.0",
|
||||||
"@douyinfe/semi-ui-19": "^2.92.2",
|
"@douyinfe/semi-ui-19": "^2.95.0",
|
||||||
"@mapbox/mapbox-gl-draw": "^1.5.1",
|
"@mapbox/mapbox-gl-draw": "^1.5.1",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"@sendgrid/mail": "8.1.6",
|
"@sendgrid/mail": "8.1.6",
|
||||||
"@vitejs/plugin-react": "5.1.4",
|
"@turf/boolean-point-in-polygon": "^7.3.5",
|
||||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
"@vitejs/plugin-react": "6.0.1",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.17",
|
||||||
"better-sqlite3": "^12.6.2",
|
"better-sqlite3": "^12.9.0",
|
||||||
"body-parser": "2.2.2",
|
"body-parser": "2.2.2",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"cheerio": "^1.2.0",
|
"cheerio": "^1.2.0",
|
||||||
"@turf/boolean-point-in-polygon": "^7.3.4",
|
|
||||||
"cookie-session": "2.1.1",
|
"cookie-session": "2.1.1",
|
||||||
"handlebars": "4.7.8",
|
"handlebars": "4.7.9",
|
||||||
"lodash": "4.17.23",
|
"maplibre-gl": "^5.23.0",
|
||||||
"maplibre-gl": "^5.19.0",
|
"nanoid": "5.1.9",
|
||||||
"nanoid": "5.1.6",
|
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"node-fetch": "3.3.2",
|
"node-fetch": "3.3.2",
|
||||||
"node-mailjet": "6.0.11",
|
"node-mailjet": "6.0.11",
|
||||||
|
"nodemailer": "^8.0.5",
|
||||||
"p-throttle": "^8.1.0",
|
"p-throttle": "^8.1.0",
|
||||||
"package-up": "^5.0.0",
|
"package-up": "^5.0.0",
|
||||||
"puppeteer": "^24.38.0",
|
"puppeteer": "^24.41.0",
|
||||||
"puppeteer-extra": "^3.3.6",
|
"puppeteer-extra": "^3.3.6",
|
||||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||||
"query-string": "9.3.1",
|
"query-string": "9.3.1",
|
||||||
"react": "19.2.4",
|
"react": "19.2.5",
|
||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.5",
|
||||||
"react-range-slider-input": "^3.3.2",
|
"react-range-slider-input": "^3.3.5",
|
||||||
"react-router": "7.13.1",
|
"react-router": "7.14.1",
|
||||||
"react-router-dom": "7.13.1",
|
"react-router-dom": "7.14.1",
|
||||||
"resend": "^6.9.3",
|
"resend": "^6.12.0",
|
||||||
"restana": "5.1.0",
|
"restana": "5.2.0",
|
||||||
"semver": "^7.7.4",
|
"semver": "^7.7.4",
|
||||||
"serve-static": "2.2.1",
|
"serve-static": "2.2.1",
|
||||||
"slack": "11.0.2",
|
"slack": "11.0.2",
|
||||||
"vite": "7.3.1",
|
"vite": "8.0.9",
|
||||||
"x-var": "^3.0.1",
|
"x-var": "^3.0.1",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.29.0",
|
"@babel/core": "7.29.0",
|
||||||
"@babel/eslint-parser": "7.28.6",
|
"@babel/eslint-parser": "7.28.6",
|
||||||
"@babel/preset-env": "7.29.0",
|
"@babel/preset-env": "7.29.2",
|
||||||
"@babel/preset-react": "7.28.5",
|
"@babel/preset-react": "7.28.5",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"chai": "6.2.2",
|
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"eslint": "10.0.3",
|
"eslint": "10.2.1",
|
||||||
"eslint-config-prettier": "10.1.8",
|
"eslint-config-prettier": "10.1.8",
|
||||||
"eslint-plugin-react": "7.37.5",
|
"eslint-plugin-react": "7.37.5",
|
||||||
"esmock": "2.7.3",
|
"globals": "^17.5.0",
|
||||||
"globals": "^17.4.0",
|
|
||||||
"history": "5.3.0",
|
"history": "5.3.0",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"less": "4.5.1",
|
"less": "4.6.4",
|
||||||
"lint-staged": "16.3.3",
|
"lint-staged": "16.4.0",
|
||||||
"mocha": "11.7.5",
|
|
||||||
"nodemon": "^3.1.14",
|
"nodemon": "^3.1.14",
|
||||||
"prettier": "3.8.1"
|
"prettier": "3.8.3",
|
||||||
|
"vitest": "^4.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { expect } from 'chai';
|
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||||
import esmock from 'esmock';
|
|
||||||
|
|
||||||
describe('services/storage/backupRestoreService.js - precheck & filename', () => {
|
describe('services/storage/backupRestoreService.js - precheck & filename', () => {
|
||||||
let svc;
|
let svc;
|
||||||
@@ -14,7 +13,7 @@ describe('services/storage/backupRestoreService.js - precheck & filename', () =>
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
calls = { logger: { info: [], warn: [], error: [] } };
|
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 };
|
globalThis.__ADM_ZIP_STATE__ = { hasDb: false, meta: null };
|
||||||
setZipState = (s) => {
|
setZipState = (s) => {
|
||||||
globalThis.__ADM_ZIP_STATE__ = { ...globalThis.__ADM_ZIP_STATE__, ...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 utilsMock = { getPackageVersion: async () => '16.2.0' };
|
||||||
|
|
||||||
const admZipPath = path.join(ROOT, 'node_modules', 'adm-zip', 'adm-zip.js');
|
vi.resetModules();
|
||||||
const mod = await esmock(
|
vi.doMock('adm-zip', () => admZipMock);
|
||||||
path.join(ROOT, 'lib', 'services', 'storage', 'backupRestoreService.js'),
|
vi.doMock(migratePath, () => migrateMock);
|
||||||
{},
|
vi.doMock(sqlitePath, () => sqliteMock);
|
||||||
{
|
vi.doMock(loggerPath, () => loggerMock);
|
||||||
'adm-zip': admZipMock,
|
vi.doMock(utilsPath, () => utilsMock);
|
||||||
[admZipPath]: admZipMock,
|
|
||||||
[migratePath]: migrateMock,
|
|
||||||
[sqlitePath]: sqliteMock,
|
|
||||||
[loggerPath]: loggerMock,
|
|
||||||
[utilsPath]: utilsMock,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
|
const mod = await import(path.join(ROOT, 'lib', 'services', 'storage', 'backupRestoreService.js'));
|
||||||
svc = mod;
|
svc = mod;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('precheck: empty upload yields danger', async () => {
|
it('precheck: empty upload yields danger', async () => {
|
||||||
const res = await svc.precheckRestore(Buffer.alloc(0));
|
const res = await svc.precheckRestore(Buffer.alloc(0));
|
||||||
expect(res.compatible).to.equal(false);
|
expect(res.compatible).toBe(false);
|
||||||
expect(res.severity).to.equal('danger');
|
expect(res.severity).toBe('danger');
|
||||||
expect(res.message).to.contain('Empty upload');
|
expect(res.message).toContain('Empty upload');
|
||||||
expect(res.requiredMigration).to.equal(10);
|
expect(res.requiredMigration).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('precheck: missing listings.db yields danger', async () => {
|
it('precheck: missing listings.db yields danger', async () => {
|
||||||
setZipState({ hasDb: false, meta: { dbMigration: 9 } });
|
setZipState({ hasDb: false, meta: { dbMigration: 9 } });
|
||||||
const res = await svc.precheckRestore(Buffer.from('dummy'));
|
const res = await svc.precheckRestore(Buffer.from('dummy'));
|
||||||
expect(res.compatible).to.equal(false);
|
expect(res.compatible).toBe(false);
|
||||||
expect(res.severity).to.equal('danger');
|
expect(res.severity).toBe('danger');
|
||||||
expect(res.message).to.match(/missing the database file/i);
|
expect(res.message).toMatch(/missing the database file/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('precheck: older backup is compatible with warning', async () => {
|
it('precheck: older backup is compatible with warning', async () => {
|
||||||
setZipState({ hasDb: true, meta: { dbMigration: 5, fredyVersion: '16.0.0' } });
|
setZipState({ hasDb: true, meta: { dbMigration: 5, fredyVersion: '16.0.0' } });
|
||||||
const res = await svc.precheckRestore(Buffer.from('zip'));
|
const res = await svc.precheckRestore(Buffer.from('zip'));
|
||||||
expect(res.compatible).to.equal(true);
|
expect(res.compatible).toBe(true);
|
||||||
expect(res.severity).to.equal('warning');
|
expect(res.severity).toBe('warning');
|
||||||
expect(res.message).to.match(/automatic migrations/i);
|
expect(res.message).toMatch(/automatic migrations/i);
|
||||||
expect(res.backupMigration).to.equal(5);
|
expect(res.backupMigration).toBe(5);
|
||||||
expect(res.requiredMigration).to.equal(10);
|
expect(res.requiredMigration).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('precheck: equal backup is compatible with info', async () => {
|
it('precheck: equal backup is compatible with info', async () => {
|
||||||
setZipState({ hasDb: true, meta: { dbMigration: 10 } });
|
setZipState({ hasDb: true, meta: { dbMigration: 10 } });
|
||||||
const res = await svc.precheckRestore(Buffer.from('zip'));
|
const res = await svc.precheckRestore(Buffer.from('zip'));
|
||||||
expect(res.compatible).to.equal(true);
|
expect(res.compatible).toBe(true);
|
||||||
expect(res.severity).to.equal('info');
|
expect(res.severity).toBe('info');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('precheck: newer backup yields danger', async () => {
|
it('precheck: newer backup yields danger', async () => {
|
||||||
setZipState({ hasDb: true, meta: { dbMigration: 11 } });
|
setZipState({ hasDb: true, meta: { dbMigration: 11 } });
|
||||||
const res = await svc.precheckRestore(Buffer.from('zip'));
|
const res = await svc.precheckRestore(Buffer.from('zip'));
|
||||||
expect(res.compatible).to.equal(false);
|
expect(res.compatible).toBe(false);
|
||||||
expect(res.severity).to.equal('danger');
|
expect(res.severity).toBe('danger');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('buildBackupFileName: matches pattern and includes version', async () => {
|
it('buildBackupFileName: matches pattern and includes version', async () => {
|
||||||
const name = await svc.buildBackupFileName();
|
const name = await svc.buildBackupFileName();
|
||||||
expect(name).to.match(/^\d{4}-\d{2}-\d{2}-FredyBackup-/);
|
expect(name).toMatch(/^\d{4}-\d{2}-\d{2}-FredyBackup-/);
|
||||||
expect(name).to.include('16.2.0');
|
expect(name).toContain('16.2.0');
|
||||||
expect(name).to.match(/\.zip$/);
|
expect(name).toMatch(/\.zip$/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { expect } from 'chai';
|
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import esmock from 'esmock';
|
|
||||||
|
|
||||||
// We will fully mock fs, crypto, SqliteConnection, and dynamic import of migration modules
|
// 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 path = await import('node:path');
|
||||||
const ROOT = path.resolve('.');
|
const ROOT = path.resolve('.');
|
||||||
const sqlPath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js');
|
const sqlPath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js');
|
||||||
const loggerPath = path.join(ROOT, 'lib', 'services', 'logger.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;
|
runMigrations = mod.runMigrations;
|
||||||
|
|
||||||
// remember original exitCode to restore later
|
// 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 () => {
|
it('logs and returns when no migration files are found', async () => {
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
expect(calls.logs.info.some((a) => String(a[0]).includes('No migration files'))).to.equal(true);
|
expect(calls.logs.info.some((a) => String(a[0]).includes('No migration files'))).toBe(true);
|
||||||
expect(calls.sql.getConnection).to.equal(0);
|
expect(calls.sql.getConnection).toBe(0);
|
||||||
expect(calls.sql.optimize).to.equal(0);
|
expect(calls.sql.optimize).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies a single new migration inside a transaction and records it', async () => {
|
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 path = await import('node:path');
|
||||||
const ROOT = path.resolve('.');
|
const ROOT = path.resolve('.');
|
||||||
|
|
||||||
@@ -178,26 +168,22 @@ describe('db/migrations/migrate.js - runMigrations', () => {
|
|||||||
// Use global importer hook to bypass dynamic import
|
// Use global importer hook to bypass dynamic import
|
||||||
globalThis.__TEST_MIGRATE_IMPORT__ = async () => migrationModule;
|
globalThis.__TEST_MIGRATE_IMPORT__ = async () => migrationModule;
|
||||||
|
|
||||||
const mod = await esmock(
|
vi.resetModules();
|
||||||
'../../../db/migrations/migrate.js',
|
vi.doMock('fs', () => ({ default: fsMock, ...fsMock }));
|
||||||
{},
|
vi.doMock('crypto', () => ({ default: cryptoMock, ...cryptoMock }));
|
||||||
{
|
vi.doMock(sqlPath, () => ({ default: sqlMock }));
|
||||||
fs: fsMock,
|
vi.doMock(loggerPath, () => ({ default: loggerMock }));
|
||||||
crypto: cryptoMock,
|
|
||||||
[sqlPath]: sqlMock,
|
|
||||||
[loggerPath]: loggerMock,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
|
const mod = await import('../../../lib/services/storage/migrations/migrate.js');
|
||||||
runMigrations = mod.runMigrations;
|
runMigrations = mod.runMigrations;
|
||||||
|
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
|
|
||||||
// Should have started a transaction and inserted into schema_migrations
|
// 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'));
|
const inserted = calls.sql.execute.find((e) => String(e.sql).includes('INSERT INTO schema_migrations'));
|
||||||
expect(!!inserted).to.equal(true);
|
expect(!!inserted).toBe(true);
|
||||||
expect(calls.sql.optimize).to.equal(1);
|
expect(calls.sql.optimize).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips already executed migration with same checksum', async () => {
|
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: () => {} });
|
globalThis.__TEST_MIGRATE_IMPORT__ = async () => ({ up: () => {} });
|
||||||
|
|
||||||
const mod = await esmock(
|
vi.resetModules();
|
||||||
'../../../db/migrations/migrate.js',
|
vi.doMock('fs', () => ({ default: fsMock, ...fsMock }));
|
||||||
{},
|
vi.doMock('crypto', () => ({ default: cryptoMock, ...cryptoMock }));
|
||||||
{
|
vi.doMock(sqlPath, () => ({ default: sqlMock }));
|
||||||
fs: fsMock,
|
vi.doMock(loggerPath, () => ({ default: loggerMock }));
|
||||||
crypto: cryptoMock,
|
|
||||||
[sqlPath]: sqlMock,
|
|
||||||
[loggerPath]: loggerMock,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
|
const mod = await import('../../../lib/services/storage/migrations/migrate.js');
|
||||||
runMigrations = mod.runMigrations;
|
runMigrations = mod.runMigrations;
|
||||||
|
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
|
|
||||||
// Should not run transaction because it's skipped
|
// Should not run transaction because it's skipped
|
||||||
expect(calls.sql.withTransaction.length).to.equal(0);
|
expect(calls.sql.withTransaction.length).toBe(0);
|
||||||
expect(calls.sql.optimize).to.equal(1);
|
expect(calls.sql.optimize).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('aborts with exitCode=1 when a migration throws, without applying insert', async () => {
|
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 sqlPath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js');
|
||||||
const loggerPath = path.join(ROOT, 'lib', 'services', 'logger.js');
|
const loggerPath = path.join(ROOT, 'lib', 'services', 'logger.js');
|
||||||
|
|
||||||
const mod = await esmock(
|
vi.resetModules();
|
||||||
'../../../lib/services/storage/migrations/migrate.js',
|
vi.doMock('fs', () => ({ default: fsMock, ...fsMock }));
|
||||||
{},
|
vi.doMock('crypto', () => ({ default: cryptoMock, ...cryptoMock }));
|
||||||
{
|
vi.doMock(sqlPath, () => ({ default: sqlMock }));
|
||||||
fs: fsMock,
|
vi.doMock(loggerPath, () => ({ default: loggerMock }));
|
||||||
crypto: cryptoMock,
|
|
||||||
[sqlPath]: sqlMock,
|
|
||||||
[loggerPath]: loggerMock,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
|
const mod = await import('../../../lib/services/storage/migrations/migrate.js');
|
||||||
runMigrations = mod.runMigrations;
|
runMigrations = mod.runMigrations;
|
||||||
|
|
||||||
await 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
|
// 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'));
|
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
|
* 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 { mockFredy } from './utils.js';
|
||||||
import * as mockStore from './mocks/mockStore.js';
|
import * as mockStore from './mocks/mockStore.js';
|
||||||
|
|
||||||
@@ -17,13 +17,22 @@ describe('Issue reproduction: listings filtered by similarity or area should be
|
|||||||
|
|
||||||
const providerConfig = {
|
const providerConfig = {
|
||||||
url: 'http://example.com',
|
url: 'http://example.com',
|
||||||
getListings: () => Promise.resolve([{ id: '1', title: 'test', address: 'addr', price: '100' }]),
|
getListings: () =>
|
||||||
|
Promise.resolve([{ id: '1', title: 'test', address: 'addr', price: '100', link: 'http://example.com/1' }]),
|
||||||
normalize: (l) => l,
|
normalize: (l) => l,
|
||||||
filter: () => true,
|
filter: () => true,
|
||||||
crawlFields: { id: 'id', title: 'title', address: 'address', price: 'price' },
|
crawlFields: { id: 'id', title: 'title', address: 'address', price: 'price' },
|
||||||
|
requiredFieldNames: ['id', 'title', 'address', 'price'],
|
||||||
};
|
};
|
||||||
|
|
||||||
const fredy = new Fredy(providerConfig, null, null, 'test-provider', 'test-job', mockSimilarityCache);
|
const mockedJob = {
|
||||||
|
id: 'test-job',
|
||||||
|
notificationAdapter: null,
|
||||||
|
specFilter: null,
|
||||||
|
spatialFilter: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fredy = new Fredy(providerConfig, mockedJob, 'test-provider', mockSimilarityCache, undefined);
|
||||||
|
|
||||||
// Clear deletedIds before test
|
// Clear deletedIds before test
|
||||||
mockStore.deletedIds.length = 0;
|
mockStore.deletedIds.length = 0;
|
||||||
@@ -34,7 +43,7 @@ describe('Issue reproduction: listings filtered by similarity or area should be
|
|||||||
// Might throw NoNewListingsWarning if all are filtered out
|
// 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 () => {
|
it('should call deleteListingsById when listings are filtered by area', async () => {
|
||||||
@@ -64,18 +73,35 @@ describe('Issue reproduction: listings filtered by similarity or area should be
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockedJob = {
|
||||||
|
id: 'test-job',
|
||||||
|
notificationAdapter: null,
|
||||||
|
specFilter: null,
|
||||||
|
spatialFilter: spatialFilter,
|
||||||
|
};
|
||||||
|
|
||||||
const providerConfig = {
|
const providerConfig = {
|
||||||
url: 'http://example.com',
|
url: 'http://example.com',
|
||||||
getListings: () =>
|
getListings: () =>
|
||||||
Promise.resolve([{ id: '2', title: 'test', address: 'addr', price: '100', latitude: 2, longitude: 2 }]), // outside polygon
|
Promise.resolve([
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: 'test',
|
||||||
|
address: 'addr',
|
||||||
|
price: '100',
|
||||||
|
latitude: 2,
|
||||||
|
longitude: 2,
|
||||||
|
link: 'http://example.com/2',
|
||||||
|
},
|
||||||
|
]), // outside polygon
|
||||||
normalize: (l) => l,
|
normalize: (l) => l,
|
||||||
filter: () => true,
|
filter: () => true,
|
||||||
crawlFields: { id: 'id', title: 'title', address: 'address', price: 'price' },
|
crawlFields: { id: 'id', title: 'title', address: 'address', price: 'price' },
|
||||||
|
requiredFieldNames: ['id', 'title', 'address', 'price'],
|
||||||
};
|
};
|
||||||
|
|
||||||
const fredy = new Fredy(providerConfig, null, spatialFilter, 'test-provider', 'test-job', mockSimilarityCache);
|
const fredy = new Fredy(providerConfig, mockedJob, 'test-provider', mockSimilarityCache, undefined);
|
||||||
|
|
||||||
// Clear deletedIds before test
|
|
||||||
mockStore.deletedIds.length = 0;
|
mockStore.deletedIds.length = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -84,6 +110,6 @@ describe('Issue reproduction: listings filtered by similarity or area should be
|
|||||||
// Might throw NoNewListingsWarning if all are filtered out
|
// Might throw NoNewListingsWarning if all are filtered out
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(mockStore.deletedIds).to.include('2');
|
expect(mockStore.deletedIds).toContain('2');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,39 +6,44 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { providerConfig, mockFredy } from '../utils.js';
|
import { providerConfig, mockFredy } from '../utils.js';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'vitest';
|
||||||
import * as provider from '../../lib/provider/einsAImmobilien.js';
|
import * as provider from '../../lib/provider/einsAImmobilien.js';
|
||||||
|
|
||||||
describe('#einsAImmobilien testsuite()', () => {
|
describe('#einsAImmobilien testsuite()', () => {
|
||||||
provider.init(providerConfig.einsAImmobilien, [], []);
|
provider.init(providerConfig.einsAImmobilien, []);
|
||||||
it('should test einsAImmobilien provider', async () => {
|
it('should test einsAImmobilien provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
return await new Promise((resolve) => {
|
const mockedJob = {
|
||||||
const fredy = new Fredy(
|
id: 'einsAImmobilien',
|
||||||
provider.config,
|
notificationAdapter: null,
|
||||||
null,
|
spatialFilter: null,
|
||||||
null,
|
specFilter: null,
|
||||||
provider.metaInformation.id,
|
};
|
||||||
'einsAImmobilien',
|
return await new Promise((resolve, reject) => {
|
||||||
similarityCache,
|
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
|
||||||
);
|
|
||||||
fredy.execute().then((listings) => {
|
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();
|
const notificationObj = get();
|
||||||
expect(notificationObj).to.be.a('object');
|
expect(notificationObj).toBeTypeOf('object');
|
||||||
expect(notificationObj.serviceName).to.equal('einsAImmobilien');
|
expect(notificationObj.serviceName).toBe('einsAImmobilien');
|
||||||
notificationObj.payload.forEach((notify) => {
|
notificationObj.payload.forEach((notify) => {
|
||||||
/** check the actual structure **/
|
/** check the actual structure **/
|
||||||
expect(notify.id).to.be.a('string');
|
expect(notify.id).toBeTypeOf('string');
|
||||||
expect(notify.price).to.be.a('string');
|
expect(notify.price).toBeTypeOf('string');
|
||||||
expect(notify.size).to.be.a('string');
|
expect(notify.price).toContain('€');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.size).toBeTypeOf('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.size).toContain('m²');
|
||||||
expect(notify.address).to.be.a('string');
|
expect(notify.title).toBeTypeOf('string');
|
||||||
|
expect(notify.link).toBeTypeOf('string');
|
||||||
|
expect(notify.address).toBeTypeOf('string');
|
||||||
/** check the values if possible **/
|
/** check the values if possible **/
|
||||||
expect(notify.size).to.be.not.empty;
|
expect(notify.size).toBeTypeOf('string');
|
||||||
expect(notify.title).to.be.not.empty;
|
expect(notify.title).not.toBe('');
|
||||||
expect(notify.link).that.does.include('https://www.1a-immobilienmarkt.de');
|
expect(notify.link).toContain('https://www.1a-immobilienmarkt.de');
|
||||||
});
|
});
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,36 +6,82 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { providerConfig, mockFredy } from '../utils.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 provider from '../../lib/provider/immobilienDe.js';
|
||||||
|
import * as mockStore from '../mocks/mockStore.js';
|
||||||
|
|
||||||
describe('#immobilien.de testsuite()', () => {
|
describe('#immobilien.de testsuite()', () => {
|
||||||
provider.init(providerConfig.immobilienDe, [], []);
|
provider.init(providerConfig.immobilienDe, [], []);
|
||||||
it('should test immobilien.de provider', async () => {
|
it('should test immobilien.de provider', async () => {
|
||||||
|
const mockedJob = {
|
||||||
|
id: 'test1',
|
||||||
|
notificationAdapter: null,
|
||||||
|
spatialFilter: null,
|
||||||
|
specFilter: null,
|
||||||
|
};
|
||||||
|
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
return await new Promise((resolve) => {
|
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
|
||||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'test1', similarityCache);
|
const listing = await fredy.execute();
|
||||||
fredy.execute().then((listing) => {
|
|
||||||
expect(listing).to.be.a('array');
|
if (listing == null || listing.length === 0) {
|
||||||
const notificationObj = get();
|
throw new Error('Listings is empty!');
|
||||||
expect(notificationObj).to.be.a('object');
|
}
|
||||||
expect(notificationObj.serviceName).to.equal('immobilienDe');
|
|
||||||
notificationObj.payload.forEach((notify) => {
|
expect(listing).toBeInstanceOf(Array);
|
||||||
/** check the actual structure **/
|
const notificationObj = get();
|
||||||
expect(notify.id).to.be.a('string');
|
expect(notificationObj).toBeTypeOf('object');
|
||||||
expect(notify.price).to.be.a('string');
|
expect(notificationObj.serviceName).toBe('immobilienDe');
|
||||||
expect(notify.size).to.be.a('string');
|
notificationObj.payload.forEach((notify) => {
|
||||||
expect(notify.title).to.be.a('string');
|
/** check the actual structure **/
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.id).toBeTypeOf('string');
|
||||||
expect(notify.address).to.be.a('string');
|
expect(notify.price).toBeTypeOf('string');
|
||||||
/** check the values if possible **/
|
expect(notify.size).toBeTypeOf('string');
|
||||||
expect(notify.price).that.does.include('€');
|
expect(notify.title).toBeTypeOf('string');
|
||||||
expect(notify.size).that.does.include('m²');
|
expect(notify.link).toBeTypeOf('string');
|
||||||
expect(notify.title).to.be.not.empty;
|
expect(notify.address).toBeTypeOf('string');
|
||||||
expect(notify.link).that.does.include('https://www.immobilien.de');
|
/** check the values if possible **/
|
||||||
expect(notify.address).to.be.not.empty;
|
expect(notify.price).toContain('€');
|
||||||
});
|
expect(notify.size).toContain('m²');
|
||||||
resolve();
|
expect(notify.title).not.toBe('');
|
||||||
|
expect(notify.link).toContain('https://www.immobilien.de');
|
||||||
|
expect(notify.address).not.toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with provider_details enabled', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] });
|
||||||
|
vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enrich listings with details', async () => {
|
||||||
|
const Fredy = await mockFredy();
|
||||||
|
provider.init(providerConfig.immobilienDe, [], []);
|
||||||
|
const mockedJob = { id: 'test1', notificationAdapter: null, specFilter: null, spatialFilter: null };
|
||||||
|
|
||||||
|
const fredy = new Fredy(
|
||||||
|
provider.config,
|
||||||
|
mockedJob,
|
||||||
|
provider.metaInformation.id,
|
||||||
|
{ checkAndAddEntry: () => false },
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const listings = await fredy.execute();
|
||||||
|
if (listings == null) return;
|
||||||
|
expect(listings).toBeInstanceOf(Array);
|
||||||
|
listings.forEach((listing) => {
|
||||||
|
expect(listing.link).toContain('https://www.immobilien.de');
|
||||||
|
expect(listing.address).toBeTypeOf('string');
|
||||||
|
expect(listing.address).not.toBe('');
|
||||||
|
// description may be null if selectors don't match yet — falls back gracefully
|
||||||
|
if (listing.description != null) {
|
||||||
|
expect(listing.description).toBeTypeOf('string');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,38 +3,85 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { expect } from 'chai';
|
import { expect, vi } from 'vitest';
|
||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import * as provider from '../../lib/provider/immoscout.js';
|
import * as provider from '../../lib/provider/immoscout.js';
|
||||||
|
import * as mockStore from '../mocks/mockStore.js';
|
||||||
|
|
||||||
describe('#immoscout provider testsuite()', () => {
|
describe('#immoscout provider testsuite()', () => {
|
||||||
provider.init(providerConfig.immoscout, [], []);
|
provider.init(providerConfig.immoscout, [], []);
|
||||||
it('should test immoscout provider', async () => {
|
it('should test immoscout provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
return await new Promise((resolve) => {
|
const mockedJob = {
|
||||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, '', similarityCache);
|
id: '',
|
||||||
|
notificationAdapter: null,
|
||||||
|
spatialFilter: null,
|
||||||
|
specFilter: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
|
||||||
fredy.execute().then((listings) => {
|
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();
|
const notificationObj = get();
|
||||||
expect(notificationObj).to.be.a('object');
|
expect(notificationObj).toBeTypeOf('object');
|
||||||
expect(notificationObj.serviceName).to.equal('immoscout');
|
|
||||||
notificationObj.payload.forEach((notify) => {
|
// check if there is at least one valid notification
|
||||||
/** check the actual structure **/
|
const hasValidNotification = notificationObj.payload.some((notify) => {
|
||||||
expect(notify.id).to.be.a('string');
|
return (
|
||||||
expect(notify.price).to.be.a('string');
|
typeof notify.id === 'string' &&
|
||||||
expect(notify.size).to.be.a('string');
|
typeof notify.price === 'string' &&
|
||||||
expect(notify.title).to.be.a('string');
|
notify.price.includes('€') &&
|
||||||
expect(notify.link).to.be.a('string');
|
typeof notify.size === 'string' &&
|
||||||
expect(notify.address).to.be.a('string');
|
notify.size.includes('m²') &&
|
||||||
/** check the values if possible **/
|
typeof notify.title === 'string' &&
|
||||||
expect(notify.size).to.be.not.empty;
|
notify.title !== '' &&
|
||||||
expect(notify.title).to.be.not.empty;
|
typeof notify.link === 'string' &&
|
||||||
expect(notify.link).that.does.include('https://www.immobilienscout24.de/');
|
notify.link.includes('https://www.immobilienscout24.de/') &&
|
||||||
|
typeof notify.address === 'string'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(hasValidNotification).toBe(true);
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('with provider_details enabled', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] });
|
||||||
|
vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enrich listings with details', async () => {
|
||||||
|
const Fredy = await mockFredy();
|
||||||
|
provider.init(providerConfig.immoscout, [], []);
|
||||||
|
const mockedJob = { id: '', notificationAdapter: null, specFilter: null, spatialFilter: null };
|
||||||
|
const fredy = new Fredy(
|
||||||
|
provider.config,
|
||||||
|
mockedJob,
|
||||||
|
provider.metaInformation.id,
|
||||||
|
{ checkAndAddEntry: () => false },
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const listings = await fredy.execute();
|
||||||
|
expect(listings).toBeInstanceOf(Array);
|
||||||
|
listings.forEach((listing) => {
|
||||||
|
expect(listing.description).toBeTypeOf('string');
|
||||||
|
expect(listing.description).not.toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,31 +6,46 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'vitest';
|
||||||
import * as provider from '../../lib/provider/immoswp.js';
|
import * as provider from '../../lib/provider/immoswp.js';
|
||||||
|
|
||||||
describe('#immoswp testsuite()', () => {
|
describe('#immoswp testsuite()', () => {
|
||||||
provider.init(providerConfig.immoswp, [], []);
|
provider.init(providerConfig.immoswp, [], []);
|
||||||
it('should test immoswp provider', async () => {
|
it('should test immoswp provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
return await new Promise((resolve) => {
|
const mockedJob = {
|
||||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immoswp', similarityCache);
|
id: 'immoswp',
|
||||||
|
notificationAdapter: null,
|
||||||
|
spatialFilter: null,
|
||||||
|
specFilter: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
|
||||||
|
|
||||||
fredy.execute().then((listing) => {
|
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();
|
const notificationObj = get();
|
||||||
expect(notificationObj).to.be.a('object');
|
expect(notificationObj).toBeTypeOf('object');
|
||||||
expect(notificationObj.serviceName).to.equal('immoswp');
|
expect(notificationObj.serviceName).toBe('immoswp');
|
||||||
notificationObj.payload.forEach((notify) => {
|
notificationObj.payload.forEach((notify) => {
|
||||||
/** check the actual structure **/
|
/** check the actual structure **/
|
||||||
expect(notify.id).to.be.a('string');
|
expect(notify.id).toBeTypeOf('string');
|
||||||
expect(notify.price).to.be.a('string');
|
expect(notify.price).toBeTypeOf('string');
|
||||||
expect(notify.size).to.be.a('string');
|
expect(notify.price).toContain('€');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.size).toBeTypeOf('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.size).toContain('m²');
|
||||||
|
expect(notify.title).toBeTypeOf('string');
|
||||||
|
expect(notify.link).toBeTypeOf('string');
|
||||||
/** check the values if possible **/
|
/** check the values if possible **/
|
||||||
expect(notify.price).that.does.include('€');
|
expect(notify.size).toBeTypeOf('string');
|
||||||
expect(notify.title).to.be.not.empty;
|
expect(notify.title).not.toBe('');
|
||||||
expect(notify.link).that.does.include('https://immo.swp.de');
|
expect(notify.link).toContain('https://immo.swp.de');
|
||||||
});
|
});
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,35 +6,87 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.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 provider from '../../lib/provider/immowelt.js';
|
||||||
|
import * as mockStore from '../mocks/mockStore.js';
|
||||||
|
|
||||||
describe('#immowelt testsuite()', () => {
|
describe('#immowelt testsuite()', () => {
|
||||||
it('should test immowelt provider', async () => {
|
it('should test immowelt provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
|
const mockedJob = {
|
||||||
|
id: 'immowelt',
|
||||||
|
notificationAdapter: null,
|
||||||
|
spatialFilter: null,
|
||||||
|
specFilter: null,
|
||||||
|
};
|
||||||
provider.init(providerConfig.immowelt, [], []);
|
provider.init(providerConfig.immowelt, [], []);
|
||||||
|
|
||||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immowelt', similarityCache);
|
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
|
||||||
|
|
||||||
const listing = await fredy.execute();
|
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();
|
const notificationObj = get();
|
||||||
expect(notificationObj).to.be.a('object');
|
expect(notificationObj).toBeTypeOf('object');
|
||||||
expect(notificationObj.serviceName).to.equal('immowelt');
|
expect(notificationObj.serviceName).toBe('immowelt');
|
||||||
notificationObj.payload.forEach((notify) => {
|
notificationObj.payload.forEach((notify) => {
|
||||||
/** check the actual structure **/
|
/** check the actual structure **/
|
||||||
expect(notify.id).to.be.a('string');
|
expect(notify.id).toBeTypeOf('string');
|
||||||
expect(notify.price).to.be.a('string');
|
if (notify.price != null) {
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.price).toBeTypeOf('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.price).toContain('€');
|
||||||
expect(notify.address).to.be.a('string');
|
}
|
||||||
|
expect(notify.title).toBeTypeOf('string');
|
||||||
|
expect(notify.link).toBeTypeOf('string');
|
||||||
|
expect(notify.address).toBeTypeOf('string');
|
||||||
/** check the values if possible **/
|
/** check the values if possible **/
|
||||||
if (notify.size != null && notify.size.trim().toLowerCase() !== 'k.a.') {
|
if (notify.size != null && notify.size.trim().toLowerCase() !== 'k.a.') {
|
||||||
expect(notify.size).that.does.include('m²');
|
expect(notify.size).toBeTypeOf('string');
|
||||||
|
expect(notify.size).toContain('m²');
|
||||||
}
|
}
|
||||||
expect(notify.title).to.be.not.empty;
|
expect(notify.title).not.toBe('');
|
||||||
expect(notify.link).that.does.include('https://www.immowelt.de');
|
expect(notify.link).toContain('https://www.immowelt.de');
|
||||||
expect(notify.address).to.be.not.empty;
|
expect(notify.address).not.toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with provider_details enabled', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] });
|
||||||
|
vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enrich listings with details', async () => {
|
||||||
|
const Fredy = await mockFredy();
|
||||||
|
provider.init(providerConfig.immowelt, [], []);
|
||||||
|
const mockedJob = { id: 'immowelt', notificationAdapter: null, specFilter: null, spatialFilter: null };
|
||||||
|
|
||||||
|
const fredy = new Fredy(
|
||||||
|
provider.config,
|
||||||
|
mockedJob,
|
||||||
|
provider.metaInformation.id,
|
||||||
|
{ checkAndAddEntry: () => false },
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const listings = await fredy.execute();
|
||||||
|
expect(listings).toBeInstanceOf(Array);
|
||||||
|
listings.forEach((listing) => {
|
||||||
|
expect(listing.link).toContain('https://www.immowelt.de');
|
||||||
|
expect(listing.address).toBeTypeOf('string');
|
||||||
|
expect(listing.address).not.toBe('');
|
||||||
|
// description is enriched from the detail page; falls back gracefully if blocked
|
||||||
|
if (listing.description != null) {
|
||||||
|
expect(listing.description).toBeTypeOf('string');
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,40 +6,80 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.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 provider from '../../lib/provider/kleinanzeigen.js';
|
||||||
|
import * as mockStore from '../mocks/mockStore.js';
|
||||||
|
|
||||||
describe('#kleinanzeigen testsuite()', () => {
|
describe('#kleinanzeigen testsuite()', () => {
|
||||||
it('should test kleinanzeigen provider', async () => {
|
it('should test kleinanzeigen provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
|
const mockedJob = {
|
||||||
|
id: 'kleinanzeigen',
|
||||||
|
notificationAdapter: null,
|
||||||
|
spatialFilter: null,
|
||||||
|
specFilter: null,
|
||||||
|
};
|
||||||
provider.init(providerConfig.kleinanzeigen, [], []);
|
provider.init(providerConfig.kleinanzeigen, [], []);
|
||||||
return await new Promise((resolve) => {
|
return await new Promise((resolve, reject) => {
|
||||||
const fredy = new Fredy(
|
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
|
||||||
provider.config,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
provider.metaInformation.id,
|
|
||||||
'kleinanzeigen',
|
|
||||||
similarityCache,
|
|
||||||
);
|
|
||||||
fredy.execute().then((listing) => {
|
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();
|
const notificationObj = get();
|
||||||
expect(notificationObj).to.be.a('object');
|
expect(notificationObj).toBeTypeOf('object');
|
||||||
expect(notificationObj.serviceName).to.equal('kleinanzeigen');
|
expect(notificationObj.serviceName).toBe('kleinanzeigen');
|
||||||
notificationObj.payload.forEach((notify) => {
|
notificationObj.payload.forEach((notify) => {
|
||||||
/** check the actual structure **/
|
/** check the actual structure **/
|
||||||
expect(notify.id).to.be.a('string');
|
expect(notify.id).toBeTypeOf('string');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.title).toBeTypeOf('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.link).toBeTypeOf('string');
|
||||||
expect(notify.address).to.be.a('string');
|
expect(notify.address).toBeTypeOf('string');
|
||||||
/** check the values if possible **/
|
/** check the values if possible **/
|
||||||
expect(notify.title).to.be.not.empty;
|
expect(notify.title).not.toBe('');
|
||||||
expect(notify.link).that.does.include('https://www.kleinanzeigen.de');
|
expect(notify.link).toContain('https://www.kleinanzeigen.de');
|
||||||
expect(notify.address).to.be.not.empty;
|
expect(notify.address).not.toBe('');
|
||||||
});
|
});
|
||||||
resolve();
|
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 mockedJob = { id: 'kleinanzeigen', notificationAdapter: null, specFilter: null, spatialFilter: null };
|
||||||
|
|
||||||
|
const fredy = new Fredy(
|
||||||
|
provider.config,
|
||||||
|
mockedJob,
|
||||||
|
provider.metaInformation.id,
|
||||||
|
{ checkAndAddEntry: () => false },
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const listings = await fredy.execute();
|
||||||
|
expect(listings).toBeInstanceOf(Array);
|
||||||
|
listings.forEach((listing) => {
|
||||||
|
expect(listing.link).toContain('https://www.kleinanzeigen.de');
|
||||||
|
expect(listing.address).toBeTypeOf('string');
|
||||||
|
expect(listing.address).not.toBe('');
|
||||||
|
expect(listing.description).toBeTypeOf('string');
|
||||||
|
expect(listing.description).not.toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,33 +6,46 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'vitest';
|
||||||
import * as provider from '../../lib/provider/mcMakler.js';
|
import * as provider from '../../lib/provider/mcMakler.js';
|
||||||
|
|
||||||
describe('#mcMakler testsuite()', () => {
|
describe('#mcMakler testsuite()', () => {
|
||||||
it('should test mcMakler provider', async () => {
|
it('should test mcMakler provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
|
const mockedJob = {
|
||||||
|
id: 'mcMakler',
|
||||||
|
notificationAdapter: null,
|
||||||
|
spatialFilter: null,
|
||||||
|
specFilter: null,
|
||||||
|
};
|
||||||
provider.init(providerConfig.mcMakler, []);
|
provider.init(providerConfig.mcMakler, []);
|
||||||
|
|
||||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'mcMakler', similarityCache);
|
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
|
||||||
|
|
||||||
const listing = await fredy.execute();
|
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();
|
const notificationObj = get();
|
||||||
expect(notificationObj).to.be.a('object');
|
expect(notificationObj).toBeTypeOf('object');
|
||||||
expect(notificationObj.serviceName).to.equal('mcMakler');
|
expect(notificationObj.serviceName).toBe('mcMakler');
|
||||||
notificationObj.payload.forEach((notify) => {
|
notificationObj.payload.forEach((notify) => {
|
||||||
/** check the actual structure **/
|
/** check the actual structure **/
|
||||||
expect(notify.id).to.be.a('string');
|
expect(notify.id).toBeTypeOf('string');
|
||||||
expect(notify.price).to.be.a('string');
|
expect(notify.price).toBeTypeOf('string');
|
||||||
expect(notify.size).to.be.a('string');
|
expect(notify.price).toContain('€');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.size).toBeTypeOf('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.size).toContain('m²');
|
||||||
expect(notify.address).to.be.a('string');
|
expect(notify.title).toBeTypeOf('string');
|
||||||
|
expect(notify.link).toBeTypeOf('string');
|
||||||
|
expect(notify.address).toBeTypeOf('string');
|
||||||
/** check the values if possible **/
|
/** check the values if possible **/
|
||||||
expect(notify.size).that.does.include('m²');
|
expect(notify.size).toBeTypeOf('string');
|
||||||
expect(notify.title).to.be.not.empty;
|
expect(notify.title).not.toBe('');
|
||||||
expect(notify.address).to.be.not.empty;
|
expect(notify.address).not.toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,37 +6,43 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'vitest';
|
||||||
import * as provider from '../../lib/provider/neubauKompass.js';
|
import * as provider from '../../lib/provider/neubauKompass.js';
|
||||||
|
|
||||||
describe('#neubauKompass testsuite()', () => {
|
describe('#neubauKompass testsuite()', () => {
|
||||||
provider.init(providerConfig.neubauKompass, [], []);
|
provider.init(providerConfig.neubauKompass, [], []);
|
||||||
it('should test neubauKompass provider', async () => {
|
it('should test neubauKompass provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
return await new Promise((resolve) => {
|
const mockedJob = {
|
||||||
const fredy = new Fredy(
|
id: 'neubauKompass',
|
||||||
provider.config,
|
notificationAdapter: null,
|
||||||
null,
|
spatialFilter: null,
|
||||||
null,
|
specFilter: null,
|
||||||
provider.metaInformation.id,
|
};
|
||||||
'neubauKompass',
|
|
||||||
similarityCache,
|
return await new Promise((resolve, reject) => {
|
||||||
);
|
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
|
||||||
|
|
||||||
fredy.execute().then((listing) => {
|
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();
|
const notificationObj = get();
|
||||||
expect(notificationObj.serviceName).to.equal('neubauKompass');
|
expect(notificationObj.serviceName).toBe('neubauKompass');
|
||||||
notificationObj.payload.forEach((notify) => {
|
notificationObj.payload.forEach((notify) => {
|
||||||
expect(notify).to.be.a('object');
|
expect(notify).toBeTypeOf('object');
|
||||||
/** check the actual structure **/
|
/** check the actual structure **/
|
||||||
expect(notify.id).to.be.a('string');
|
expect(notify.id).toBeTypeOf('string');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.title).toBeTypeOf('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.link).toBeTypeOf('string');
|
||||||
expect(notify.address).to.be.a('string');
|
expect(notify.address).toBeTypeOf('string');
|
||||||
/** check the values if possible **/
|
/** check the values if possible **/
|
||||||
expect(notify.title).to.be.not.empty;
|
expect(notify.title).not.toBe('');
|
||||||
expect(notify.link).that.does.include('https://www.neubaukompass.de');
|
expect(notify.link).toContain('https://www.neubaukompass.de');
|
||||||
expect(notify.address).to.be.not.empty;
|
expect(notify.address).not.toBe('');
|
||||||
});
|
});
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,33 +6,46 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'vitest';
|
||||||
import * as provider from '../../lib/provider/ohneMakler.js';
|
import * as provider from '../../lib/provider/ohneMakler.js';
|
||||||
|
|
||||||
describe('#ohneMakler testsuite()', () => {
|
describe('#ohneMakler testsuite()', () => {
|
||||||
it('should test ohneMakler provider', async () => {
|
it('should test ohneMakler provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
|
const mockedJob = {
|
||||||
|
id: 'ohneMakler',
|
||||||
|
notificationAdapter: null,
|
||||||
|
spatialFilter: null,
|
||||||
|
specFilter: null,
|
||||||
|
};
|
||||||
provider.init(providerConfig.ohneMakler, []);
|
provider.init(providerConfig.ohneMakler, []);
|
||||||
|
|
||||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'ohneMakler', similarityCache);
|
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
|
||||||
|
|
||||||
const listing = await fredy.execute();
|
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();
|
const notificationObj = get();
|
||||||
expect(notificationObj).to.be.a('object');
|
expect(notificationObj).toBeTypeOf('object');
|
||||||
expect(notificationObj.serviceName).to.equal('ohneMakler');
|
expect(notificationObj.serviceName).toBe('ohneMakler');
|
||||||
notificationObj.payload.forEach((notify) => {
|
notificationObj.payload.forEach((notify) => {
|
||||||
/** check the actual structure **/
|
/** check the actual structure **/
|
||||||
expect(notify.id).to.be.a('string');
|
expect(notify.id).toBeTypeOf('string');
|
||||||
expect(notify.price).to.be.a('string');
|
expect(notify.price).toBeTypeOf('string');
|
||||||
expect(notify.size).to.be.a('string');
|
expect(notify.price).toContain('€');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.size).toBeTypeOf('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.size).toContain('m²');
|
||||||
expect(notify.address).to.be.a('string');
|
expect(notify.title).toBeTypeOf('string');
|
||||||
|
expect(notify.link).toBeTypeOf('string');
|
||||||
|
expect(notify.address).toBeTypeOf('string');
|
||||||
/** check the values if possible **/
|
/** check the values if possible **/
|
||||||
expect(notify.size).that.does.include('m²');
|
expect(notify.size).toBeTypeOf('string');
|
||||||
expect(notify.title).to.be.not.empty;
|
expect(notify.title).not.toBe('');
|
||||||
expect(notify.address).to.be.not.empty;
|
expect(notify.address).not.toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,40 +6,46 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'vitest';
|
||||||
import * as provider from '../../lib/provider/regionalimmobilien24.js';
|
import * as provider from '../../lib/provider/regionalimmobilien24.js';
|
||||||
|
|
||||||
describe('#regionalimmobilien24 testsuite()', () => {
|
describe('#regionalimmobilien24 testsuite()', () => {
|
||||||
it('should test regionalimmobilien24 provider', async () => {
|
it('should test regionalimmobilien24 provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
|
const mockedJob = {
|
||||||
|
id: 'regionalimmobilien24',
|
||||||
|
notificationAdapter: null,
|
||||||
|
spatialFilter: null,
|
||||||
|
specFilter: null,
|
||||||
|
};
|
||||||
provider.init(providerConfig.regionalimmobilien24, []);
|
provider.init(providerConfig.regionalimmobilien24, []);
|
||||||
|
|
||||||
const fredy = new Fredy(
|
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
|
||||||
provider.config,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
provider.metaInformation.id,
|
|
||||||
'regionalimmobilien24',
|
|
||||||
similarityCache,
|
|
||||||
);
|
|
||||||
const listing = await fredy.execute();
|
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();
|
const notificationObj = get();
|
||||||
expect(notificationObj).to.be.a('object');
|
expect(notificationObj).toBeTypeOf('object');
|
||||||
expect(notificationObj.serviceName).to.equal('regionalimmobilien24');
|
expect(notificationObj.serviceName).toBe('regionalimmobilien24');
|
||||||
notificationObj.payload.forEach((notify) => {
|
notificationObj.payload.forEach((notify) => {
|
||||||
/** check the actual structure **/
|
/** check the actual structure **/
|
||||||
expect(notify.id).to.be.a('string');
|
expect(notify.id).toBeTypeOf('string');
|
||||||
expect(notify.price).to.be.a('string');
|
expect(notify.price).toBeTypeOf('string');
|
||||||
expect(notify.size).to.be.a('string');
|
expect(notify.price).toContain('€');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.size).toBeTypeOf('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.size).toContain('m²');
|
||||||
expect(notify.address).to.be.a('string');
|
expect(notify.title).toBeTypeOf('string');
|
||||||
|
expect(notify.link).toBeTypeOf('string');
|
||||||
|
expect(notify.address).toBeTypeOf('string');
|
||||||
/** check the values if possible **/
|
/** check the values if possible **/
|
||||||
expect(notify.size).that.does.include('m²');
|
expect(notify.size).toBeTypeOf('string');
|
||||||
expect(notify.title).to.be.not.empty;
|
expect(notify.title).not.toBe('');
|
||||||
expect(notify.address).to.be.not.empty;
|
expect(notify.address).not.toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,33 +6,84 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.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 provider from '../../lib/provider/sparkasse.js';
|
||||||
|
import * as mockStore from '../mocks/mockStore.js';
|
||||||
|
|
||||||
describe('#sparkasse testsuite()', () => {
|
describe('#sparkasse testsuite()', () => {
|
||||||
it('should test sparkasse provider', async () => {
|
it('should test sparkasse provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
|
const mockedJob = {
|
||||||
|
id: 'sparkasse',
|
||||||
|
notificationAdapter: null,
|
||||||
|
spatialFilter: null,
|
||||||
|
specFilter: null,
|
||||||
|
};
|
||||||
provider.init(providerConfig.sparkasse, []);
|
provider.init(providerConfig.sparkasse, []);
|
||||||
|
|
||||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'sparkasse', similarityCache);
|
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
|
||||||
|
|
||||||
const listing = await fredy.execute();
|
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();
|
const notificationObj = get();
|
||||||
expect(notificationObj).to.be.a('object');
|
expect(notificationObj).toBeTypeOf('object');
|
||||||
expect(notificationObj.serviceName).to.equal('sparkasse');
|
expect(notificationObj.serviceName).toBe('sparkasse');
|
||||||
notificationObj.payload.forEach((notify) => {
|
notificationObj.payload.forEach((notify) => {
|
||||||
/** check the actual structure **/
|
/** check the actual structure **/
|
||||||
expect(notify.id).to.be.a('string');
|
expect(notify.id).toBeTypeOf('string');
|
||||||
expect(notify.price).to.be.a('string');
|
expect(notify.price).toBeTypeOf('string');
|
||||||
expect(notify.size).to.be.a('string');
|
expect(notify.price).toContain('€');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.size).toBeTypeOf('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.size).toContain('m²');
|
||||||
expect(notify.address).to.be.a('string');
|
expect(notify.title).toBeTypeOf('string');
|
||||||
|
expect(notify.link).toBeTypeOf('string');
|
||||||
|
expect(notify.address).toBeTypeOf('string');
|
||||||
/** check the values if possible **/
|
/** check the values if possible **/
|
||||||
expect(notify.size).that.does.include('m²');
|
expect(notify.size).toBeTypeOf('string');
|
||||||
expect(notify.title).to.be.not.empty;
|
expect(notify.title).not.toBe('');
|
||||||
expect(notify.address).to.be.not.empty;
|
expect(notify.address).not.toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with provider_details enabled', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] });
|
||||||
|
vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enrich listings with details', async () => {
|
||||||
|
const Fredy = await mockFredy();
|
||||||
|
provider.init(providerConfig.sparkasse, []);
|
||||||
|
const mockedJob = { id: 'sparkasse', notificationAdapter: null, specFilter: null, spatialFilter: null };
|
||||||
|
|
||||||
|
const fredy = new Fredy(
|
||||||
|
provider.config,
|
||||||
|
mockedJob,
|
||||||
|
provider.metaInformation.id,
|
||||||
|
{ checkAndAddEntry: () => false },
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const listings = await fredy.execute();
|
||||||
|
expect(listings).toBeInstanceOf(Array);
|
||||||
|
listings.forEach((listing) => {
|
||||||
|
expect(listing.link).toContain('https://immobilien.sparkasse.de');
|
||||||
|
expect(listing.address).toBeTypeOf('string');
|
||||||
|
expect(listing.address).not.toBe('');
|
||||||
|
// description is enriched from the detail page; falls back gracefully if bot-detected
|
||||||
|
if (listing.description != null) {
|
||||||
|
expect(listing.description).toBeTypeOf('string');
|
||||||
|
expect(listing.description).not.toBe('');
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"sparkasse": {
|
"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
|
"enabled": true
|
||||||
},
|
},
|
||||||
"wgGesucht": {
|
"wgGesucht": {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { isOneOf, duringWorkingHoursOrNotSet } from '../../lib/utils.js';
|
import { isOneOf, duringWorkingHoursOrNotSet } from '../../lib/utils.js';
|
||||||
import assert from 'assert';
|
import assert from 'assert';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'vitest';
|
||||||
|
|
||||||
const fakeWorkingHoursConfig = (from, to) => ({
|
const fakeWorkingHoursConfig = (from, to) => ({
|
||||||
workingHours: {
|
workingHours: {
|
||||||
@@ -25,19 +25,19 @@ describe('utils', () => {
|
|||||||
});
|
});
|
||||||
describe('#duringWorkingHoursOrNotSet()', () => {
|
describe('#duringWorkingHoursOrNotSet()', () => {
|
||||||
it('should be false', () => {
|
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', () => {
|
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', () => {
|
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', () => {
|
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', () => {
|
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)', () => {
|
it('should handle working hours that cross midnight (e.g., 05:00 → 00:30)', () => {
|
||||||
const cfg = fakeWorkingHoursConfig('05:00', '00:30');
|
const cfg = fakeWorkingHoursConfig('05:00', '00:30');
|
||||||
@@ -49,9 +49,9 @@ describe('utils', () => {
|
|||||||
d.setMilliseconds(0);
|
d.setMilliseconds(0);
|
||||||
return d.getTime();
|
return d.getTime();
|
||||||
};
|
};
|
||||||
expect(duringWorkingHoursOrNotSet(cfg, mkTs(23, 0))).to.be.true; // 23:00 => within window
|
expect(duringWorkingHoursOrNotSet(cfg, mkTs(23, 0))).toBe(true); // 23:00 => within window
|
||||||
expect(duringWorkingHoursOrNotSet(cfg, mkTs(1, 0))).to.be.false; // 01:00 => outside window
|
expect(duringWorkingHoursOrNotSet(cfg, mkTs(1, 0))).toBe(false); // 01:00 => outside window
|
||||||
expect(duringWorkingHoursOrNotSet(cfg, mkTs(6, 0))).to.be.true; // 06:00 => within window
|
expect(duringWorkingHoursOrNotSet(cfg, mkTs(6, 0))).toBe(true); // 06:00 => within window
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,30 +6,77 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.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 provider from '../../lib/provider/wgGesucht.js';
|
||||||
|
import * as mockStore from '../mocks/mockStore.js';
|
||||||
|
|
||||||
describe('#wgGesucht testsuite()', () => {
|
describe('#wgGesucht testsuite()', () => {
|
||||||
provider.init(providerConfig.wgGesucht, [], []);
|
provider.init(providerConfig.wgGesucht, [], []);
|
||||||
it('should test wgGesucht provider', async () => {
|
it('should test wgGesucht provider', { timeout: 120000 }, async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
return await new Promise((resolve) => {
|
const mockedJob = {
|
||||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'wgGesucht', similarityCache);
|
id: 'wgGesucht',
|
||||||
|
notificationAdapter: null,
|
||||||
|
spatialFilter: null,
|
||||||
|
specFilter: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
|
||||||
|
|
||||||
fredy.execute().then((listing) => {
|
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();
|
const notificationObj = get();
|
||||||
expect(notificationObj.serviceName).to.equal('wgGesucht');
|
expect(notificationObj.serviceName).toBe('wgGesucht');
|
||||||
notificationObj.payload.forEach((notify) => {
|
notificationObj.payload.forEach((notify) => {
|
||||||
expect(notify).to.be.a('object');
|
expect(notify).toBeTypeOf('object');
|
||||||
/** check the actual structure **/
|
/** check the actual structure **/
|
||||||
expect(notify.id).to.be.a('string');
|
expect(notify.id).toBeTypeOf('string');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.title).toBeTypeOf('string');
|
||||||
expect(notify.details).to.be.a('string');
|
// expect(notify.details).toBeTypeOf('string');
|
||||||
expect(notify.price).to.be.a('string');
|
expect(notify.price).toBeTypeOf('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.price).toContain('€');
|
||||||
|
expect(notify.link).toBeTypeOf('string');
|
||||||
});
|
});
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('with provider_details enabled', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] });
|
||||||
|
vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enrich listings with details', async () => {
|
||||||
|
const Fredy = await mockFredy();
|
||||||
|
provider.init(providerConfig.wgGesucht, [], []);
|
||||||
|
const mockedJob = { id: 'wgGesucht', notificationAdapter: null, specFilter: null, spatialFilter: null };
|
||||||
|
|
||||||
|
const fredy = new Fredy(
|
||||||
|
provider.config,
|
||||||
|
mockedJob,
|
||||||
|
provider.metaInformation.id,
|
||||||
|
{ checkAndAddEntry: () => false },
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const listings = await fredy.execute();
|
||||||
|
expect(listings).toBeInstanceOf(Array);
|
||||||
|
listings.forEach((listing) => {
|
||||||
|
expect(listing.link).toContain('https://www.wg-gesucht.de');
|
||||||
|
expect(listing.description).toBeTypeOf('string');
|
||||||
|
expect(listing.description).not.toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,39 +6,47 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { providerConfig, mockFredy } from '../utils.js';
|
import { providerConfig, mockFredy } from '../utils.js';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'vitest';
|
||||||
import * as provider from '../../lib/provider/wohnungsboerse.js';
|
import * as provider from '../../lib/provider/wohnungsboerse.js';
|
||||||
|
|
||||||
describe('#wohnungsboerse testsuite()', () => {
|
describe('#wohnungsboerse testsuite()', () => {
|
||||||
provider.init(providerConfig.wohnungsboerse, [], []);
|
provider.init(providerConfig.wohnungsboerse, [], []);
|
||||||
it('should test wohnungsboerse provider', async () => {
|
it('should test wohnungsboerse provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
return await new Promise((resolve) => {
|
const mockedJob = {
|
||||||
const fredy = new Fredy(
|
id: 'wohnungsboerse',
|
||||||
provider.config,
|
notificationAdapter: null,
|
||||||
null,
|
spatialFilter: null,
|
||||||
null,
|
specFilter: null,
|
||||||
provider.metaInformation.id,
|
};
|
||||||
'wohnungsboerse',
|
|
||||||
similarityCache,
|
return await new Promise((resolve, reject) => {
|
||||||
);
|
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
|
||||||
|
|
||||||
fredy.execute().then((listings) => {
|
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();
|
const notificationObj = get();
|
||||||
expect(notificationObj).to.be.a('object');
|
expect(notificationObj).toBeTypeOf('object');
|
||||||
expect(notificationObj.serviceName).to.equal('wohnungsboerse');
|
expect(notificationObj.serviceName).toBe('wohnungsboerse');
|
||||||
notificationObj.payload.forEach((notify) => {
|
notificationObj.payload.forEach((notify) => {
|
||||||
/** check the actual structure **/
|
/** check the actual structure **/
|
||||||
expect(notify.id).to.be.a('string');
|
expect(notify.id).toBeTypeOf('string');
|
||||||
expect(notify.price).to.be.a('string');
|
expect(notify.price).toBeTypeOf('string');
|
||||||
expect(notify.size).to.be.a('string');
|
expect(notify.price).toContain('€');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.size).toBeTypeOf('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.size).toContain('m²');
|
||||||
expect(notify.address).to.be.a('string');
|
expect(notify.title).toBeTypeOf('string');
|
||||||
|
expect(notify.link).toBeTypeOf('string');
|
||||||
|
expect(notify.address).toBeTypeOf('string');
|
||||||
/** check the values if possible **/
|
/** check the values if possible **/
|
||||||
expect(notify.size).to.be.not.empty;
|
expect(notify.size).toBeTypeOf('string');
|
||||||
expect(notify.title).to.be.not.empty;
|
expect(notify.title).not.toBe('');
|
||||||
expect(notify.link).that.does.include('https://www.wohnungsboerse.net');
|
expect(notify.link).toContain('https://www.wohnungsboerse.net');
|
||||||
});
|
});
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'vitest';
|
||||||
import { readFile } from 'fs/promises';
|
import { readFile } from 'fs/promises';
|
||||||
import mutator from '../../lib/services/queryStringMutator.js';
|
import mutator from '../../lib/services/queryStringMutator.js';
|
||||||
import queryString from 'query-string';
|
import queryString from 'query-string';
|
||||||
@@ -33,8 +33,8 @@ describe('queryStringMutator', () => {
|
|||||||
const expectedParams = queryString.parseUrl(test.shouldBecome);
|
const expectedParams = queryString.parseUrl(test.shouldBecome);
|
||||||
const actualParams = queryString.parseUrl(fixedUrl);
|
const actualParams = queryString.parseUrl(fixedUrl);
|
||||||
//check if all new params are existing
|
//check if all new params are existing
|
||||||
expect(Object.keys(expectedParams.query)).to.include.members(Object.keys(actualParams.query));
|
expect(Object.keys(expectedParams.query)).toEqual(expect.arrayContaining(Object.keys(actualParams.query)));
|
||||||
expect(Object.values(expectedParams.query)).to.include.members(Object.values(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
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it } from 'mocha';
|
import { expect } from 'vitest';
|
||||||
import { expect } from 'chai';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getPreLaunchConfig,
|
getPreLaunchConfig,
|
||||||
@@ -26,16 +25,16 @@ describe('botPrevention helper', () => {
|
|||||||
};
|
};
|
||||||
const cfg = getPreLaunchConfig(url, options);
|
const cfg = getPreLaunchConfig(url, options);
|
||||||
|
|
||||||
expect(cfg.acceptLanguage).to.equal('de-DE,de;q=0.9');
|
expect(cfg.acceptLanguage).toBe('de-DE,de;q=0.9');
|
||||||
expect(cfg.langArg).to.equal('--lang=de-DE');
|
expect(cfg.langArg).toBe('--lang=de-DE');
|
||||||
expect(cfg.windowSizeArg).to.equal('--window-size=1200,700');
|
expect(cfg.windowSizeArg).toBe('--window-size=1200,700');
|
||||||
expect(cfg.viewport).to.deep.equal({ width: 1200, height: 700, deviceScaleFactor: 2 });
|
expect(cfg.viewport).toEqual({ width: 1200, height: 700, deviceScaleFactor: 2 });
|
||||||
expect(cfg.userAgent).to.equal('TestAgent/1.0');
|
expect(cfg.userAgent).toBe('TestAgent/1.0');
|
||||||
expect(cfg.headers['Accept-Language']).to.equal('de-DE,de;q=0.9');
|
expect(cfg.headers['Accept-Language']).toBe('de-DE,de;q=0.9');
|
||||||
expect(cfg.headers['User-Agent']).to.equal('TestAgent/1.0');
|
expect(cfg.headers['User-Agent']).toBe('TestAgent/1.0');
|
||||||
expect(cfg.headers.Referer).to.equal('https://example.com/ref');
|
expect(cfg.headers.Referer).toBe('https://example.com/ref');
|
||||||
expect(cfg.extraArgs).to.include('--disable-blink-features=AutomationControlled');
|
expect(cfg.extraArgs).toContain('--disable-blink-features=AutomationControlled');
|
||||||
expect(cfg.extraArgs).to.include('--proxy-bypass-list=<-loopback>');
|
expect(cfg.extraArgs).toContain('--proxy-bypass-list=<-loopback>');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applyBotPreventionToPage sets UA, viewport, headers and injects patches', async () => {
|
it('applyBotPreventionToPage sets UA, viewport, headers and injects patches', async () => {
|
||||||
@@ -58,15 +57,15 @@ describe('botPrevention helper', () => {
|
|||||||
|
|
||||||
await applyBotPreventionToPage(page, cfg);
|
await applyBotPreventionToPage(page, cfg);
|
||||||
|
|
||||||
expect(calls[0]).to.deep.equal(['setUserAgent', 'Foo/Bar']);
|
expect(calls[0]).toEqual(['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] === 'setViewport' && c[1].width === 1000 && c[1].height === 600)).toBe(true);
|
||||||
expect(calls.some((c) => c[0] === 'setJavaScriptEnabled' && c[1] === true)).to.equal(true);
|
expect(calls.some((c) => c[0] === 'setJavaScriptEnabled' && c[1] === true)).toBe(true);
|
||||||
const headerCall = calls.find((c) => c[0] === 'setExtraHTTPHeaders');
|
const headerCall = calls.find((c) => c[0] === 'setExtraHTTPHeaders');
|
||||||
expect(headerCall).to.exist;
|
expect(headerCall).toBeDefined();
|
||||||
expect(headerCall[1]['Accept-Language']).to.equal('en-US,en');
|
expect(headerCall[1]['Accept-Language']).toBe('en-US,en');
|
||||||
expect(headerCall[1]['User-Agent']).to.equal('Foo/Bar');
|
expect(headerCall[1]['User-Agent']).toBe('Foo/Bar');
|
||||||
expect(calls.some((c) => c[0] === 'emulateTimezone' && c[1] === 'UTC')).to.equal(true);
|
expect(calls.some((c) => c[0] === 'emulateTimezone' && c[1] === 'UTC')).toBe(true);
|
||||||
expect(calls.some((c) => c[0] === 'evaluateOnNewDocument' && c[1] === 'function')).to.equal(true);
|
expect(calls.some((c) => c[0] === 'evaluateOnNewDocument' && c[1] === 'function')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applyLanguagePersistence stores languages early', async () => {
|
it('applyLanguagePersistence stores languages early', async () => {
|
||||||
@@ -80,9 +79,9 @@ describe('botPrevention helper', () => {
|
|||||||
});
|
});
|
||||||
await applyLanguagePersistence(page, cfg);
|
await applyLanguagePersistence(page, cfg);
|
||||||
const call = calls[0];
|
const call = calls[0];
|
||||||
expect(call[0]).to.equal('evaluateOnNewDocument');
|
expect(call[0]).toBe('evaluateOnNewDocument');
|
||||||
expect(call[1]).to.equal('function');
|
expect(call[1]).toBe('function');
|
||||||
expect(call[2]).to.equal('de-DE,de');
|
expect(call[2]).toBe('de-DE,de');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applyPostNavigationHumanSignals moves mouse and scrolls when enabled', async () => {
|
it('applyPostNavigationHumanSignals moves mouse and scrolls when enabled', async () => {
|
||||||
@@ -98,7 +97,7 @@ describe('botPrevention helper', () => {
|
|||||||
viewport: { width: 1200, height: 800 },
|
viewport: { width: 1200, height: 800 },
|
||||||
};
|
};
|
||||||
await applyPostNavigationHumanSignals(page, cfg);
|
await applyPostNavigationHumanSignals(page, cfg);
|
||||||
expect(mouseCalls.some((c) => c[0] === 'move')).to.equal(true);
|
expect(mouseCalls.some((c) => c[0] === 'move')).toBe(true);
|
||||||
expect(mouseCalls.some((c) => c[0] === 'wheel')).to.equal(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 { convertWebToMobile } from '../../../lib/services/immoscout/immoscout-web-translator.js';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'vitest';
|
||||||
import { readFile } from 'fs/promises';
|
import { readFile } from 'fs/promises';
|
||||||
|
|
||||||
export const testData = JSON.parse(await readFile(new URL('./testdata.json', import.meta.url)));
|
export const testData = JSON.parse(await readFile(new URL('./testdata.json', import.meta.url)));
|
||||||
@@ -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';
|
'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);
|
const actualMobileUrl = convertWebToMobile(webUrl);
|
||||||
expect(actualMobileUrl).to.equal(expectedMobileUrl);
|
expect(actualMobileUrl).toBe(expectedMobileUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test URL conversion of web-only SEO path
|
// Test URL conversion of web-only SEO path
|
||||||
@@ -27,27 +27,27 @@ describe('#immoscout-mobile URL conversion', () => {
|
|||||||
|
|
||||||
const converted = convertWebToMobile(webUrl);
|
const converted = convertWebToMobile(webUrl);
|
||||||
const queryParams = new URL(converted).searchParams;
|
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
|
// Test URL conversion with unsupported query parameters
|
||||||
it('should remove unsupported query parameters', () => {
|
it('should remove unsupported query parameters', () => {
|
||||||
const webUrl = 'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mieten?minimuminternetspeed=100000';
|
const webUrl = 'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mieten?minimuminternetspeed=100000';
|
||||||
const converted = convertWebToMobile(webUrl);
|
const converted = convertWebToMobile(webUrl);
|
||||||
expect(converted).that.does.not.include('minimuminternetspeed');
|
expect(converted).not.toContain('minimuminternetspeed');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test URL conversion with invalid URL
|
// Test URL conversion with invalid URL
|
||||||
it('should throw an error for invalid URL', () => {
|
it('should throw an error for invalid URL', () => {
|
||||||
const invalidUrl = '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
|
// Test URL conversion with unexpected path format
|
||||||
it('should throw an error for unexpected path format', () => {
|
it('should throw an error for unexpected path format', () => {
|
||||||
const webUrl = 'https://www.immobilienscout24.de/invalid/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 () => {
|
it('shouldFindResultsForEveryTestData', async () => {
|
||||||
@@ -70,14 +70,12 @@ describe('#immoscout-mobile URL conversion', () => {
|
|||||||
console.error('Error fetching data from ImmoScout Mobile API:', response.statusText);
|
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();
|
const responseBody = await response.json();
|
||||||
expect(responseBody.totalResults).to.be.greaterThan(0);
|
expect(responseBody.totalResults).toBeGreaterThan(0);
|
||||||
expect(responseBody.totalResults).to.be.greaterThan(0);
|
expect(responseBody.totalResults).toBeGreaterThan(0);
|
||||||
expect(responseBody.resultListItems.length).to.greaterThan(0);
|
expect(responseBody.resultListItems.length).toBeGreaterThan(0);
|
||||||
expect(responseBody.resultListItems.filter((r) => r.type === 'EXPOSE_RESULT')[0].item.realEstateType).to.equal(
|
expect(responseBody.resultListItems.filter((r) => r.type === 'EXPOSE_RESULT')[0].item.realEstateType).toBe(type);
|
||||||
type,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { expect } from 'chai';
|
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||||
import esmock from 'esmock';
|
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
|
|
||||||
describe('services/jobs/jobExecutionService', () => {
|
describe('services/jobs/jobExecutionService', () => {
|
||||||
@@ -22,45 +21,39 @@ describe('services/jobs/jobExecutionService', () => {
|
|||||||
const brokerPath = root + '/lib/services/sse/sse-broker.js';
|
const brokerPath = root + '/lib/services/sse/sse-broker.js';
|
||||||
const utilsPath = root + '/lib/utils.js';
|
const utilsPath = root + '/lib/utils.js';
|
||||||
const loggerPath = root + '/lib/services/logger.js';
|
const loggerPath = root + '/lib/services/logger.js';
|
||||||
|
const notifyPath = root + '/lib/notification/notify.js';
|
||||||
|
|
||||||
// esmock the service with all its collaborators
|
vi.resetModules();
|
||||||
const mod = await esmock(
|
vi.doMock(busPath, () => ({ bus }));
|
||||||
svcPath,
|
vi.doMock(jobStoragePath, () => ({
|
||||||
{},
|
getJob: (id) => state.jobsById[id] || null,
|
||||||
{
|
getJobs: () => state.jobsList.slice(),
|
||||||
[busPath]: { bus },
|
}));
|
||||||
[jobStoragePath]: {
|
vi.doMock(userStoragePath, () => ({
|
||||||
getJob: (id) => state.jobsById[id] || null,
|
getUsers: () => state.users.slice(),
|
||||||
getJobs: () => state.jobsList.slice(),
|
getUser: (id) => state.users.find((u) => u.id === id) || null,
|
||||||
},
|
}));
|
||||||
[userStoragePath]: {
|
vi.doMock(brokerPath, () => ({
|
||||||
getUsers: () => state.users.slice(),
|
sendToUsers: (...args) => calls.sent.push(args),
|
||||||
getUser: (id) => state.users.find((u) => u.id === id) || null,
|
}));
|
||||||
},
|
vi.doMock(utilsPath, () => ({
|
||||||
[brokerPath]: {
|
duringWorkingHoursOrNotSet: () => false,
|
||||||
sendToUsers: (...args) => calls.sent.push(args),
|
}));
|
||||||
},
|
vi.doMock(loggerPath, () => {
|
||||||
[utilsPath]: {
|
const m = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} };
|
||||||
duringWorkingHoursOrNotSet: () => false, // avoid startup run
|
return { default: m };
|
||||||
},
|
});
|
||||||
[loggerPath]: {
|
vi.doMock(notifyPath, () => ({ send: async () => [] }));
|
||||||
debug: () => {},
|
vi.doMock(root + '/lib/services/jobs/run-state.js', () => ({
|
||||||
info: () => {},
|
isRunning: () => false,
|
||||||
warn: () => {},
|
markRunning: (id) => {
|
||||||
error: () => {},
|
calls.markRunning.push(id);
|
||||||
},
|
return true;
|
||||||
[root + '/lib/services/jobs/run-state.js']: {
|
|
||||||
isRunning: () => false,
|
|
||||||
markRunning: (id) => {
|
|
||||||
calls.markRunning.push(id);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
markFinished: () => {},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
markFinished: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
// call initializer with minimal deps
|
const mod = await import(svcPath);
|
||||||
mod.initJobExecutionService({ providers: [], settings: { demoMode: false }, intervalMs: 0 });
|
mod.initJobExecutionService({ providers: [], settings: { demoMode: false }, intervalMs: 0 });
|
||||||
return mod;
|
return mod;
|
||||||
}
|
}
|
||||||
@@ -87,13 +80,13 @@ describe('services/jobs/jobExecutionService', () => {
|
|||||||
|
|
||||||
bus.emit('jobs:status', { jobId: 'j1', running: true });
|
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];
|
const [recipients, event, data] = calls.sent[0];
|
||||||
expect(event).to.equal('jobStatus');
|
expect(event).toBe('jobStatus');
|
||||||
expect(data).to.deep.equal({ jobId: 'j1', running: true });
|
expect(data).toEqual({ jobId: 'j1', running: true });
|
||||||
const got = new Set(recipients);
|
const got = new Set(recipients);
|
||||||
const expected = new Set(['owner1', 'u2', 'a1']);
|
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 () => {
|
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' });
|
bus.emit('jobs:runAll', { userId: 'u1' });
|
||||||
// allow microtasks to flush
|
// allow microtasks to flush
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
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
|
// Admin: all jobs
|
||||||
calls.markRunning = [];
|
calls.markRunning = [];
|
||||||
bus.emit('jobs:runAll', { userId: 'admin' });
|
bus.emit('jobs:runAll', { userId: 'admin' });
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
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
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { expect } from 'chai';
|
import { vi, describe, it, expect } from 'vitest';
|
||||||
import esmock from 'esmock';
|
|
||||||
|
|
||||||
// Helper to create module under test with mocks
|
// Helper to create module under test with mocks
|
||||||
async function loadModuleWith({ entries = [] } = {}) {
|
async function loadModuleWith({ entries = [] } = {}) {
|
||||||
const mod = await esmock('../../lib/services/similarity-check/similarityCache.js', {
|
vi.resetModules();
|
||||||
// Mock the storage to return our controlled entries
|
vi.doMock('../../lib/services/storage/listingsStorage.js', () => ({
|
||||||
'../../lib/services/storage/listingsStorage.js': {
|
getAllEntriesFromListings: () => entries,
|
||||||
getAllEntriesFromListings: () => entries,
|
}));
|
||||||
},
|
return await import('../../lib/services/similarity-check/similarityCache.js');
|
||||||
});
|
|
||||||
return mod;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('similarityCache', () => {
|
describe('similarityCache', () => {
|
||||||
@@ -27,15 +24,15 @@ describe('similarityCache', () => {
|
|||||||
const { initSimilarityCache, checkAndAddEntry } = await loadModuleWith({ entries });
|
const { initSimilarityCache, checkAndAddEntry } = await loadModuleWith({ entries });
|
||||||
|
|
||||||
// Initially, duplicates should not be detected for new data
|
// 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
|
// Now initialize from storage
|
||||||
initSimilarityCache();
|
initSimilarityCache();
|
||||||
|
|
||||||
// Exact duplicates should be detected
|
// 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
|
// 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 () => {
|
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 first = checkAndAddEntry({ title: 'C', price: 300, address: 'Road 3' });
|
||||||
const second = checkAndAddEntry({ title: 'C', price: 300, address: 'Road 3' });
|
const second = checkAndAddEntry({ title: 'C', price: 300, address: 'Road 3' });
|
||||||
|
|
||||||
expect(first).to.equal(false);
|
expect(first).toBe(false);
|
||||||
expect(second).to.equal(true);
|
expect(second).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hashing ignores null/undefined but preserves 0 via behavior', async () => {
|
it('hashing ignores null/undefined but preserves 0 via behavior', async () => {
|
||||||
@@ -53,15 +50,15 @@ describe('similarityCache', () => {
|
|||||||
|
|
||||||
// Add baseline (null address ignored)
|
// Add baseline (null address ignored)
|
||||||
const add1 = checkAndAddEntry({ title: 'T', price: 1, address: null });
|
const add1 = checkAndAddEntry({ title: 'T', price: 1, address: null });
|
||||||
expect(add1).to.equal(false);
|
expect(add1).toBe(false);
|
||||||
// Duplicate with undefined address should match
|
// Duplicate with undefined address should match
|
||||||
const dup = checkAndAddEntry({ title: 'T', price: 1, address: undefined });
|
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)
|
// Now test that price 0 is preserved (not filtered out)
|
||||||
const addZero = checkAndAddEntry({ title: 'Z', price: 0, address: 'Zero' });
|
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' });
|
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
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { expect } from 'chai';
|
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import esmock from 'esmock';
|
|
||||||
|
|
||||||
// We explicitly avoid touching the real filesystem or creating a real DB file.
|
// 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.
|
// better-sqlite3 is fully mocked and operates in-memory via our stubs.
|
||||||
@@ -78,15 +77,10 @@ describe('SqliteConnection', () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// esmock the module with our stubs
|
vi.resetModules();
|
||||||
SqliteConnection = await esmock(
|
vi.doMock('fs', () => ({ default: fsMock, ...fsMock }));
|
||||||
'../../lib/services/storage/SqliteConnection.js',
|
vi.doMock('better-sqlite3', () => ({ default: BetterSqlite3Mock }));
|
||||||
{},
|
SqliteConnection = (await import('../../lib/services/storage/SqliteConnection.js')).default;
|
||||||
{
|
|
||||||
fs: fsMock,
|
|
||||||
'better-sqlite3': { default: BetterSqlite3Mock },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -98,9 +92,9 @@ describe('SqliteConnection', () => {
|
|||||||
const db1 = SqliteConnection.getConnection();
|
const db1 = SqliteConnection.getConnection();
|
||||||
const db2 = SqliteConnection.getConnection();
|
const db2 = SqliteConnection.getConnection();
|
||||||
|
|
||||||
expect(db1).to.equal(db2);
|
expect(db1).toBe(db2);
|
||||||
// journal_mode, synchronous, cache_size, foreign_keys, optimize
|
// journal_mode, synchronous, cache_size, foreign_keys, optimize
|
||||||
expect(calls.db.pragma).to.deep.equal([
|
expect(calls.db.pragma).toEqual([
|
||||||
'journal_mode = WAL',
|
'journal_mode = WAL',
|
||||||
'synchronous = NORMAL',
|
'synchronous = NORMAL',
|
||||||
'cache_size = -64000',
|
'cache_size = -64000',
|
||||||
@@ -108,21 +102,21 @@ describe('SqliteConnection', () => {
|
|||||||
'optimize',
|
'optimize',
|
||||||
]);
|
]);
|
||||||
// mkdirSync should not be called because existsSync returned true
|
// 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', () => {
|
it('executes query and execute helpers', () => {
|
||||||
const rows = SqliteConnection.query('SELECT 1', {});
|
const rows = SqliteConnection.query('SELECT 1', {});
|
||||||
expect(rows).to.be.an('array');
|
expect(rows).toBeInstanceOf(Array);
|
||||||
expect(rows[0]).to.deep.equal({ x: 1 });
|
expect(rows[0]).toEqual({ x: 1 });
|
||||||
|
|
||||||
const info = SqliteConnection.execute('UPDATE x SET y=1 WHERE id=@id', { id: 5 });
|
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()', () => {
|
it('tableExists uses sqlite_master get()', () => {
|
||||||
const exists = SqliteConnection.tableExists('users');
|
const exists = SqliteConnection.tableExists('users');
|
||||||
expect(exists).to.equal(true);
|
expect(exists).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('withTransaction wraps callback', () => {
|
it('withTransaction wraps callback', () => {
|
||||||
@@ -131,17 +125,17 @@ describe('SqliteConnection', () => {
|
|||||||
db.prepare('SELECT inside').all({});
|
db.prepare('SELECT inside').all({});
|
||||||
return 42;
|
return 42;
|
||||||
});
|
});
|
||||||
expect(result).to.equal(42);
|
expect(result).toBe(42);
|
||||||
expect(calls.db.prepare).to.include('SELECT inside');
|
expect(calls.db.prepare).toContain('SELECT inside');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('optimize() delegates to PRAGMA optimize and close() calls it again then closes', () => {
|
it('optimize() delegates to PRAGMA optimize and close() calls it again then closes', () => {
|
||||||
SqliteConnection.optimize();
|
SqliteConnection.optimize();
|
||||||
// It will use the existing connection and call pragma('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();
|
SqliteConnection.close();
|
||||||
// close increments close counter
|
// close increments close counter
|
||||||
expect(calls.db.close).to.equal(1);
|
expect(calls.db.close).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,29 +3,29 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { vi } from 'vitest';
|
||||||
import { readFile } from 'fs/promises';
|
import { readFile } from 'fs/promises';
|
||||||
import esmock from 'esmock';
|
|
||||||
import * as mockStore from './mocks/mockStore.js';
|
import * as mockStore from './mocks/mockStore.js';
|
||||||
import { send } from './mocks/mockNotification.js';
|
import { send } from './mocks/mockNotification.js';
|
||||||
|
|
||||||
export const providerConfig = JSON.parse(await readFile(new URL('./provider/testProvider.json', import.meta.url)));
|
export const providerConfig = JSON.parse(
|
||||||
|
await readFile(new URL('./provider/testProvider.json', import.meta.url), 'utf-8'),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock('../lib/services/storage/listingsStorage.js', () => mockStore);
|
||||||
|
vi.mock('../lib/services/storage/settingsStorage.js', () => mockStore);
|
||||||
|
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 }));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise<typeof import('../lib/FredyPipelineExecutioner.js').default>}
|
||||||
|
*/
|
||||||
export const mockFredy = async () => {
|
export const mockFredy = async () => {
|
||||||
return await esmock('../lib/FredyPipelineExecutioner', {
|
const mod = await import('../lib/FredyPipelineExecutioner.js');
|
||||||
'../lib/services/storage/listingsStorage.js': {
|
return mod.default;
|
||||||
...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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,19 +3,19 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { expect } from 'chai';
|
import { expect } from 'vitest';
|
||||||
import { buildHash } from '../../lib/utils.js';
|
import { buildHash } from '../../lib/utils.js';
|
||||||
|
|
||||||
describe('utilsCheck', () => {
|
describe('utilsCheck', () => {
|
||||||
describe('#utilsCheck()', () => {
|
describe('#utilsCheck()', () => {
|
||||||
it('should be null when null input', () => {
|
it('should be null when null input', () => {
|
||||||
expect(buildHash(null)).to.be.null;
|
expect(buildHash(null)).toBeNull();
|
||||||
});
|
});
|
||||||
it('should be null when null empty', () => {
|
it('should be null when null empty', () => {
|
||||||
expect(buildHash('')).to.be.null;
|
expect(buildHash('')).toBeNull();
|
||||||
});
|
});
|
||||||
it('should return a value', () => {
|
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 InsufficientPermission from './components/permission/InsufficientPermission';
|
||||||
import PermissionAwareRoute from './components/permission/PermissionAwareRoute';
|
import PermissionAwareRoute from './components/permission/PermissionAwareRoute';
|
||||||
import GeneralSettings from './views/generalSettings/GeneralSettings';
|
import GeneralSettings from './views/generalSettings/GeneralSettings';
|
||||||
import UserSettings from './views/userSettings/UserSettings';
|
|
||||||
import JobMutation from './views/jobs/mutation/JobMutation';
|
import JobMutation from './views/jobs/mutation/JobMutation';
|
||||||
import UserMutator from './views/user/mutation/UserMutator';
|
import UserMutator from './views/user/mutation/UserMutator';
|
||||||
import { useActions, useSelector } from './services/state/store';
|
import { useActions, useSelector } from './services/state/store';
|
||||||
@@ -127,15 +126,8 @@ export default function FredyApp() {
|
|||||||
</PermissionAwareRoute>
|
</PermissionAwareRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="/userSettings" element={<UserSettings />} />
|
<Route path="/userSettings" element={<Navigate to="/generalSettings" replace />} />
|
||||||
<Route
|
<Route path="/generalSettings" element={<GeneralSettings />} />
|
||||||
path="/generalSettings"
|
|
||||||
element={
|
|
||||||
<PermissionAwareRoute currentUser={currentUser}>
|
|
||||||
<GeneralSettings />
|
|
||||||
</PermissionAwareRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
|
@import './DashboardCardColors.less';
|
||||||
|
|
||||||
.dashboard-card {
|
.dashboard-card {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 140px;
|
height: 140px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
transition: transform 0.2s;
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
background-color: rgba(36, 36, 36, 0.9);
|
background-color: #181b26;
|
||||||
backdrop-filter: blur(8px);
|
border: 1px solid #232735;
|
||||||
border: 1px solid var(--semi-color-border);
|
border-radius: 10px;
|
||||||
--pulse-color: rgba(255, 255, 255, 0.1);
|
--pulse-color: rgba(255, 255, 255, 0.08);
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
@@ -32,6 +34,14 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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 {
|
&__content {
|
||||||
@@ -41,32 +51,51 @@
|
|||||||
&__value {
|
&__value {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 4px;
|
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 {
|
&.blue {
|
||||||
--pulse-color: var(--semi-color-primary);
|
--pulse-color: @color-blue-border;
|
||||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
--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 {
|
&.orange {
|
||||||
--pulse-color: var(--semi-color-warning);
|
--pulse-color: @color-orange-border;
|
||||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
--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 {
|
&.green {
|
||||||
--pulse-color: var(--semi-color-success);
|
--pulse-color: @color-green-border;
|
||||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
--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 {
|
&.purple {
|
||||||
--pulse-color: var(--semi-color-info);
|
--pulse-color: @color-purple-border;
|
||||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
--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 {
|
&.gray {
|
||||||
--pulse-color: rgba(255, 255, 255, 0.2);
|
--pulse-color: @color-gray-border;
|
||||||
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
--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;
|
opacity: 0.1;
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
opacity: 0.5;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
@color-blue-bg: rgba(0, 123, 255, 0.24);
|
@color-blue-bg: rgba(96, 165, 250, 0.10);
|
||||||
@color-blue-border: #1E40AFFF;
|
@color-blue-border: #3b6ea8;
|
||||||
@color-blue-text: #60a5fa;
|
@color-blue-text: #60a5fa;
|
||||||
|
|
||||||
@color-orange-bg: rgba(250, 91, 5, 0.12);
|
@color-orange-bg: rgba(251, 146, 60, 0.10);
|
||||||
@color-orange-border: #992f0c;
|
@color-orange-border: #c2622a;
|
||||||
@color-orange-text: #FB923CFF;
|
@color-orange-text: #fb923c;
|
||||||
|
|
||||||
@color-green-bg: rgba(38, 250, 5, 0.12);
|
@color-green-bg: rgba(52, 211, 153, 0.10);
|
||||||
@color-green-border: #278832;
|
@color-green-border: #2a8a61;
|
||||||
@color-green-text: #33f308;
|
@color-green-text: #34d399;
|
||||||
|
|
||||||
@color-purple-bg: rgba(91, 3, 218, 0.38);
|
@color-purple-bg: rgba(167, 139, 250, 0.10);
|
||||||
@color-purple-border: #7500c3;
|
@color-purple-border: #6d4fc2;
|
||||||
@color-purple-text: #b15fff;
|
@color-purple-text: #a78bfa;
|
||||||
|
|
||||||
@color-gray-bg: rgba(110, 110, 110, 0.38);
|
@color-gray-bg: rgba(148, 163, 184, 0.10);
|
||||||
@color-gray-border: #807f7f;
|
@color-gray-border: #323a47;
|
||||||
@color-gray-text: #bab9b9;
|
@color-gray-text: #94a3b8;
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
Col,
|
Col,
|
||||||
Row,
|
Row,
|
||||||
Button,
|
Button,
|
||||||
Space,
|
|
||||||
Typography,
|
Typography,
|
||||||
Divider,
|
Divider,
|
||||||
Switch,
|
Switch,
|
||||||
@@ -20,6 +19,8 @@ import {
|
|||||||
Pagination,
|
Pagination,
|
||||||
Toast,
|
Toast,
|
||||||
Empty,
|
Empty,
|
||||||
|
Radio,
|
||||||
|
RadioGroup,
|
||||||
} from '@douyinfe/semi-ui-19';
|
} from '@douyinfe/semi-ui-19';
|
||||||
import {
|
import {
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
@@ -31,14 +32,16 @@ import {
|
|||||||
IconBriefcase,
|
IconBriefcase,
|
||||||
IconBell,
|
IconBell,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
IconFilter,
|
|
||||||
IconPlusCircle,
|
IconPlusCircle,
|
||||||
|
IconArrowUp,
|
||||||
|
IconArrowDown,
|
||||||
|
IconHome,
|
||||||
} from '@douyinfe/semi-icons';
|
} from '@douyinfe/semi-icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
|
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
|
||||||
import { useActions, useSelector } from '../../../services/state/store.js';
|
import { useActions, useSelector } from '../../../services/state/store.js';
|
||||||
import { xhrDelete, xhrPut, xhrPost } from '../../../services/xhr.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 { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||||
|
|
||||||
import './JobGrid.less';
|
import './JobGrid.less';
|
||||||
@@ -59,8 +62,6 @@ const JobGrid = () => {
|
|||||||
const [sortDir, setSortDir] = useState('asc');
|
const [sortDir, setSortDir] = useState('asc');
|
||||||
const [freeTextFilter, setFreeTextFilter] = useState(null);
|
const [freeTextFilter, setFreeTextFilter] = useState(null);
|
||||||
const [activityFilter, setActivityFilter] = useState(null);
|
const [activityFilter, setActivityFilter] = useState(null);
|
||||||
const [showFilterBar, setShowFilterBar] = useState(false);
|
|
||||||
|
|
||||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||||
const [pendingDeletion, setPendingDeletion] = useState(null); // { type: 'job'|'listings', jobId }
|
const [pendingDeletion, setPendingDeletion] = useState(null); // { type: 'job'|'listings', jobId }
|
||||||
|
|
||||||
@@ -200,73 +201,45 @@ const JobGrid = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="jobGrid">
|
<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')}>
|
<Button type="primary" icon={<IconPlusCircle />} onClick={() => navigate('/jobs/new')}>
|
||||||
New Job
|
New Job
|
||||||
</Button>
|
</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 && (
|
<Input
|
||||||
<div className="jobGrid__toolbar">
|
className="jobGrid__topbar__search"
|
||||||
<Space wrap style={{ marginBottom: '1rem' }}>
|
prefix={<IconSearch />}
|
||||||
<div className="jobGrid__toolbar__card">
|
showClear
|
||||||
<div>
|
placeholder="Search"
|
||||||
<Text strong>Filter by:</Text>
|
onChange={handleFilterChange}
|
||||||
</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>
|
|
||||||
|
|
||||||
<Select
|
<RadioGroup
|
||||||
placeholder="Direction"
|
type="button"
|
||||||
style={{ width: 120 }}
|
buttonSize="middle"
|
||||||
value={sortDir}
|
value={activityFilter === null ? 'all' : String(activityFilter)}
|
||||||
onChange={(val) => setSortDir(val)}
|
onChange={(e) => {
|
||||||
>
|
const v = e.target.value;
|
||||||
<Select.Option value="asc">Ascending</Select.Option>
|
setActivityFilter(v === 'all' ? null : v === 'true');
|
||||||
<Select.Option value="desc">Descending</Select.Option>
|
}}
|
||||||
</Select>
|
>
|
||||||
</div>
|
<Radio value="all">All</Radio>
|
||||||
</div>
|
<Radio value="true">Active</Radio>
|
||||||
</Space>
|
<Radio value="false">Inactive</Radio>
|
||||||
</div>
|
</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 && (
|
{(jobsData?.result || []).length === 0 && (
|
||||||
<Empty
|
<Empty
|
||||||
@@ -278,78 +251,70 @@ const JobGrid = () => {
|
|||||||
|
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
{(jobsData?.result || []).map((job) => (
|
{(jobsData?.result || []).map((job) => (
|
||||||
<Col key={job.id} xs={24} sm={12} md={8} lg={6} xl={4} xxl={6}>
|
<Col key={job.id} xs={24} sm={12} md={12} lg={8} xl={8} xxl={6}>
|
||||||
<Card
|
<Card className="jobGrid__card" bodyStyle={{ padding: '16px' }}>
|
||||||
className="jobGrid__card"
|
<div className="jobGrid__card__header">
|
||||||
bodyStyle={{ padding: '16px' }}
|
<div className="jobGrid__card__name">
|
||||||
title={
|
<span className={`jobGrid__card__dot${job.enabled ? ' jobGrid__card__dot--active' : ''}`} />
|
||||||
<div className="jobGrid__header">
|
|
||||||
<Title heading={5} ellipsis={{ showTooltip: true }} className="jobGrid__title">
|
<Title heading={5} ellipsis={{ showTooltip: true }} className="jobGrid__title">
|
||||||
{job.name}
|
{job.name}
|
||||||
</Title>
|
</Title>
|
||||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
</div>
|
||||||
{job.isOnlyShared && (
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||||
<Popover
|
{job.isOnlyShared && (
|
||||||
content={getPopoverContent(
|
<Popover
|
||||||
'This job has been shared with you by another user, therefor it is read-only.',
|
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>
|
||||||
</div>
|
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)' }} />
|
||||||
</Popover>
|
</div>
|
||||||
)}
|
</Popover>
|
||||||
</div>
|
)}
|
||||||
{job.running && (
|
{job.running && (
|
||||||
<Tag color="green" variant="light" size="small">
|
<Tag color="green" variant="light" size="small">
|
||||||
RUNNING
|
RUNNING
|
||||||
</Tag>
|
</Tag>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
</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>
|
|
||||||
|
|
||||||
<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">
|
<div className="jobGrid__actions">
|
||||||
<Popover content={getPopoverContent('Run Job')}>
|
<Popover content={getPopoverContent('Run Job')}>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@import '../../cards/DashboardCardColors.less';
|
||||||
|
|
||||||
.jobGrid {
|
.jobGrid {
|
||||||
&__card {
|
&__card {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -12,55 +14,137 @@
|
|||||||
box-shadow: 0 0 15px -3px rgb(78 78 78 / 70%);
|
box-shadow: 0 0 15px -3px rgb(78 78 78 / 70%);
|
||||||
background-color: rgba(36, 36, 36, 1);
|
background-color: rgba(36, 36, 36, 1);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
&__searchbar {
|
&__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: .5rem;
|
align-items: flex-start;
|
||||||
align-items: center;
|
justify-content: space-between;
|
||||||
justify-content: space-between;
|
gap: 8px;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__toolbar {
|
&__name {
|
||||||
&__card {
|
display: flex;
|
||||||
border-radius: var(--semi-border-radius-medium);
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: .3rem;
|
align-items: center;
|
||||||
background: rgba(36, 36, 36, 0.9);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
backdrop-filter: blur(8px);
|
border: 1px solid transparent;
|
||||||
padding: 0.5rem;
|
border-radius: var(--semi-border-radius-small);
|
||||||
border: 1px solid var(--semi-color-border);
|
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;
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
align-items: center;
|
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 {
|
&__title {
|
||||||
margin-bottom: 0 !important;
|
margin-bottom: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__infoItem {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
.semi-typography {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__actions {
|
&__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
gap: 6px;
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__pagination {
|
&__pagination {
|
||||||
|
|||||||
@@ -4,21 +4,28 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
useSearchParamState,
|
||||||
|
parseNumber,
|
||||||
|
parseString,
|
||||||
|
parseNullableBoolean,
|
||||||
|
} from '../../../hooks/useSearchParamState.js';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
Col,
|
Col,
|
||||||
Row,
|
Row,
|
||||||
Image,
|
Image,
|
||||||
Button,
|
Button,
|
||||||
Space,
|
|
||||||
Typography,
|
Typography,
|
||||||
Pagination,
|
Pagination,
|
||||||
Toast,
|
Toast,
|
||||||
Divider,
|
Divider,
|
||||||
Input,
|
Input,
|
||||||
Select,
|
Select,
|
||||||
Popover,
|
|
||||||
Empty,
|
Empty,
|
||||||
|
Radio,
|
||||||
|
RadioGroup,
|
||||||
|
Space,
|
||||||
} from '@douyinfe/semi-ui-19';
|
} from '@douyinfe/semi-ui-19';
|
||||||
import {
|
import {
|
||||||
IconBriefcase,
|
IconBriefcase,
|
||||||
@@ -30,17 +37,18 @@ import {
|
|||||||
IconStar,
|
IconStar,
|
||||||
IconStarStroked,
|
IconStarStroked,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
IconFilter,
|
|
||||||
IconActivity,
|
IconActivity,
|
||||||
IconEyeOpened,
|
IconEyeOpened,
|
||||||
|
IconArrowUp,
|
||||||
|
IconArrowDown,
|
||||||
} from '@douyinfe/semi-icons';
|
} from '@douyinfe/semi-icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
|
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
|
||||||
import no_image from '../../../assets/no_image.jpg';
|
import no_image from '../../../assets/no_image.jpg';
|
||||||
import * as timeService from '../../../services/time/timeService.js';
|
import * as timeService from '../../../services/time/timeService.js';
|
||||||
import { xhrDelete, xhrPost } from '../../../services/xhr.js';
|
import { xhrDelete, xhrPost } from '../../../services/xhr.js';
|
||||||
import { useActions, useSelector } from '../../../services/state/store.js';
|
import { useActions, useSelector } from '../../../services/state/store.js';
|
||||||
import debounce from 'lodash/debounce';
|
import { debounce } from '../../../utils';
|
||||||
|
|
||||||
import './ListingsGrid.less';
|
import './ListingsGrid.less';
|
||||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||||
@@ -53,19 +61,18 @@ const ListingsGrid = () => {
|
|||||||
const jobs = useSelector((state) => state.jobsData.jobs);
|
const jobs = useSelector((state) => state.jobsData.jobs);
|
||||||
const actions = useActions();
|
const actions = useActions();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const sp = useSearchParams();
|
||||||
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useSearchParamState(sp, 'page', 1, parseNumber);
|
||||||
const pageSize = 40;
|
const pageSize = 40;
|
||||||
|
|
||||||
const [sortField, setSortField] = useState('created_at');
|
const [sortField, setSortField] = useSearchParamState(sp, 'sort', 'created_at', parseString);
|
||||||
const [sortDir, setSortDir] = useState('desc');
|
const [sortDir, setSortDir] = useSearchParamState(sp, 'dir', 'desc', parseString);
|
||||||
const [freeTextFilter, setFreeTextFilter] = useState(null);
|
const [freeTextFilter, setFreeTextFilter] = useSearchParamState(sp, 'q', null, parseString);
|
||||||
const [watchListFilter, setWatchListFilter] = useState(null);
|
const [watchListFilter, setWatchListFilter] = useSearchParamState(sp, 'watch', null, parseNullableBoolean);
|
||||||
const [jobNameFilter, setJobNameFilter] = useState(null);
|
const [jobNameFilter, setJobNameFilter] = useSearchParamState(sp, 'job', null, parseString);
|
||||||
const [activityFilter, setActivityFilter] = useState(null);
|
const [activityFilter, setActivityFilter] = useSearchParamState(sp, 'active', null, parseNullableBoolean);
|
||||||
const [providerFilter, setProviderFilter] = useState(null);
|
const [providerFilter, setProviderFilter] = useSearchParamState(sp, 'provider', null, parseString);
|
||||||
const [showFilterBar, setShowFilterBar] = useState(false);
|
|
||||||
|
|
||||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||||
const [listingToDelete, setListingToDelete] = useState(null);
|
const [listingToDelete, setListingToDelete] = useState(null);
|
||||||
|
|
||||||
@@ -84,7 +91,14 @@ const ListingsGrid = () => {
|
|||||||
loadData();
|
loadData();
|
||||||
}, [page, sortField, sortDir, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -129,107 +143,93 @@ const ListingsGrid = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="listingsGrid">
|
<div className="listingsGrid">
|
||||||
<div className="listingsGrid__searchbar">
|
<div className="listingsGrid__topbar">
|
||||||
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
|
<Input
|
||||||
<Popover content="Filter / Sort Results" style={{ color: 'white', padding: '.5rem' }}>
|
className="listingsGrid__topbar__search"
|
||||||
<div>
|
prefix={<IconSearch />}
|
||||||
<Button
|
showClear
|
||||||
icon={<IconFilter />}
|
placeholder="Search"
|
||||||
onClick={() => {
|
defaultValue={freeTextFilter ?? ''}
|
||||||
setShowFilterBar(!showFilterBar);
|
onChange={handleFilterChange}
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
<RadioGroup
|
||||||
</Popover>
|
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>
|
</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 && (
|
{(listingsData?.result || []).length === 0 && (
|
||||||
<Empty
|
<Empty
|
||||||
@@ -240,7 +240,7 @@ const ListingsGrid = () => {
|
|||||||
)}
|
)}
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
{(listingsData?.result || []).map((item) => (
|
{(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
|
<Card
|
||||||
className={`listingsGrid__card ${!item.is_active ? 'listingsGrid__card--inactive' : ''}`}
|
className={`listingsGrid__card ${!item.is_active ? 'listingsGrid__card--inactive' : ''}`}
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
@@ -280,10 +280,11 @@ const ListingsGrid = () => {
|
|||||||
<Text strong ellipsis={{ showTooltip: true }} className="listingsGrid__title">
|
<Text strong ellipsis={{ showTooltip: true }} className="listingsGrid__title">
|
||||||
{cap(item.title)}
|
{cap(item.title)}
|
||||||
</Text>
|
</Text>
|
||||||
<Space vertical align="start" spacing={2} style={{ width: '100%', marginTop: 8 }}>
|
<div className="listingsGrid__price">
|
||||||
<Text type="secondary" icon={<IconCart />} size="small">
|
<IconCart size="small" />
|
||||||
{item.price} €
|
{item.price} €
|
||||||
</Text>
|
</div>
|
||||||
|
<div className="listingsGrid__meta">
|
||||||
<Text
|
<Text
|
||||||
type="secondary"
|
type="secondary"
|
||||||
icon={<IconMapPin />}
|
icon={<IconMapPin />}
|
||||||
@@ -293,30 +294,31 @@ const ListingsGrid = () => {
|
|||||||
>
|
>
|
||||||
{item.address || 'No address provided'}
|
{item.address || 'No address provided'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text type="tertiary" size="small" icon={<IconClock />}>
|
<Space spacing={12} wrap>
|
||||||
{timeService.format(item.created_at, false)}
|
<Text type="tertiary" size="small" icon={<IconBriefcase />}>
|
||||||
</Text>
|
{item.provider.charAt(0).toUpperCase() + item.provider.slice(1)}
|
||||||
<Text type="tertiary" size="small" icon={<IconBriefcase />}>
|
</Text>
|
||||||
{item.provider.charAt(0).toUpperCase() + item.provider.slice(1)}
|
<Text type="tertiary" size="small" icon={<IconClock />}>
|
||||||
</Text>
|
{timeService.format(item.created_at, false)}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
{item.distance_to_destination ? (
|
{item.distance_to_destination ? (
|
||||||
<Text type="tertiary" size="small" icon={<IconActivity />}>
|
<Text type="tertiary" size="small" icon={<IconActivity />}>
|
||||||
{item.distance_to_destination} m to chosen address
|
{item.distance_to_destination} m to chosen address
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Text type="tertiary" size="small" icon={<IconActivity />}>
|
<Text type="tertiary" size="small" icon={<IconActivity />}>
|
||||||
Distance cannot be calculated, provide an address
|
Distance cannot be calculated
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</div>
|
||||||
<Divider margin=".6rem" />
|
<Divider margin=".6rem" />
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
<div className="listingsGrid__actions">
|
||||||
<div className="listingsGrid__linkButton" onClick={(e) => e.stopPropagation()}>
|
<div className="listingsGrid__linkButton" onClick={(e) => e.stopPropagation()}>
|
||||||
<a href={item.link} target="_blank" rel="noopener noreferrer">
|
<a href={item.link} target="_blank" rel="noopener noreferrer">
|
||||||
<IconLink />
|
<IconLink />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="secondary"
|
type="secondary"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -324,7 +326,6 @@ const ListingsGrid = () => {
|
|||||||
onClick={() => navigate(`/listings/listing/${item.id}`)}
|
onClick={() => navigate(`/listings/listing/${item.id}`)}
|
||||||
icon={<IconEyeOpened />}
|
icon={<IconEyeOpened />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
title="Remove"
|
title="Remove"
|
||||||
type="danger"
|
type="danger"
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@import '../../cards/DashboardCardColors.less';
|
||||||
|
|
||||||
.listingsGrid {
|
.listingsGrid {
|
||||||
&__imageContainer {
|
&__imageContainer {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -5,12 +7,34 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__searchbar {
|
&__topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: .5rem;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 8px;
|
||||||
margin-bottom: 1rem;
|
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 {
|
&__watchButton {
|
||||||
@@ -45,6 +69,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&--inactive {
|
&--inactive {
|
||||||
|
|
||||||
.listingsGrid__imageContainer,
|
.listingsGrid__imageContainer,
|
||||||
.listingsGrid__content {
|
.listingsGrid__content {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
@@ -93,17 +118,27 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__toolbar {
|
&__price {
|
||||||
&__card {
|
font-size: 18px;
|
||||||
border-radius: var(--semi-border-radius-medium);
|
font-weight: 700;
|
||||||
display: flex;
|
color: @color-green-text;
|
||||||
flex-direction: column;
|
display: flex;
|
||||||
gap: .3rem;
|
align-items: center;
|
||||||
background: rgba(36, 36, 36, 0.9);
|
gap: 5px;
|
||||||
backdrop-filter: blur(8px);
|
margin: 8px 0 6px;
|
||||||
padding: 0.5rem;
|
}
|
||||||
border: 1px solid var(--semi-color-border);
|
|
||||||
}
|
&__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__setupButton {
|
&__setupButton {
|
||||||
@@ -135,4 +170,16 @@
|
|||||||
background: var(--semi-color-primary-hover);
|
background: var(--semi-color-primary-hover);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure icons and text are vertically aligned
|
||||||
|
.semi-typography {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.semi-typography-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 1px; // Minor nudge if needed, but flex should handle most
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,24 +42,21 @@ export default function Navigation({ isAdmin }) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
const settingsItems = [
|
|
||||||
{ itemKey: '/users', text: 'User Management' },
|
|
||||||
{ itemKey: '/userSettings', text: 'User Specific Settings' },
|
|
||||||
{ itemKey: '/generalSettings', text: 'General Settings' },
|
|
||||||
];
|
|
||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
itemKey: 'settings',
|
itemKey: 'settings',
|
||||||
text: 'Settings',
|
text: 'Settings',
|
||||||
icon: <IconSetting />,
|
icon: <IconSetting />,
|
||||||
items: settingsItems,
|
items: [
|
||||||
|
{ itemKey: '/users', text: 'User Management' },
|
||||||
|
{ itemKey: '/generalSettings', text: 'Settings' },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
items.push({
|
items.push({
|
||||||
itemKey: 'settings',
|
itemKey: 'settings',
|
||||||
text: 'Settings',
|
text: 'Settings',
|
||||||
icon: <IconSetting />,
|
icon: <IconSetting />,
|
||||||
items: [{ itemKey: '/userSettings', text: 'User Specific Settings' }],
|
items: [{ itemKey: '/generalSettings', text: 'Settings' }],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,5 +3,5 @@
|
|||||||
border-radius: .9rem !important;
|
border-radius: .9rem !important;
|
||||||
color: rgba(var(--semi-grey-8), 1);
|
color: rgba(var(--semi-grey-8), 1);
|
||||||
background: rgb(53, 54, 60);
|
background: rgb(53, 54, 60);
|
||||||
margin: 2rem;
|
margin: 0 0 1rem 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,36 +3,86 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { 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 { useSelector } from '../../services/state/store.js';
|
||||||
import { MarkdownRender } from '@douyinfe/semi-ui-19';
|
|
||||||
|
|
||||||
import './VersionBanner.less';
|
import './VersionBanner.less';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
export default function VersionBanner() {
|
export default function VersionBanner() {
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
|
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapse>
|
<>
|
||||||
<Collapse.Panel header="A new version of Fredy is available" itemKey="1" className="versionBanner">
|
<Banner
|
||||||
<div className="versionBanner__content">
|
className="versionBanner"
|
||||||
<p>A new version of Fredy is available. Update now to take advantage of the latest features and bug fixes.</p>
|
type="warning"
|
||||||
<Descriptions row size="small">
|
bordered
|
||||||
<Descriptions.Item itemKey="Your Version">{versionUpdate.localFredyVersion}</Descriptions.Item>
|
closeIcon={null}
|
||||||
<Descriptions.Item itemKey="Latest Version">{versionUpdate.version}</Descriptions.Item>
|
description={
|
||||||
<Descriptions.Item itemKey="Github Release">
|
<div className="versionBanner__bar">
|
||||||
<a href={versionUpdate.url} target="_blank" rel="noreferrer">
|
<Space spacing={8} align="center">
|
||||||
{versionUpdate.url}
|
<IconAlertCircle size="small" />
|
||||||
</a>{' '}
|
<Text strong size="small">
|
||||||
</Descriptions.Item>
|
New version available
|
||||||
</Descriptions>
|
</Text>
|
||||||
<p>
|
<Tag color="amber" size="small" shape="circle">
|
||||||
<b>
|
{versionUpdate.version}
|
||||||
<small>Release Notes</small>
|
</Tag>
|
||||||
</b>
|
<Text type="tertiary" size="small">
|
||||||
</p>
|
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} />
|
<MarkdownRender raw={versionUpdate.body} />
|
||||||
</div>
|
</div>
|
||||||
</Collapse.Panel>
|
</Modal>
|
||||||
</Collapse>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,24 @@
|
|||||||
.versionBanner {
|
.versionBanner {
|
||||||
background: rgba(var(--semi-teal-1), 1);
|
margin-bottom: 0 !important;
|
||||||
|
|
||||||
&__content {
|
.semi-banner-body {
|
||||||
overflow: auto;
|
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,
|
filter,
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const qryString = queryString.stringify({
|
const qryString = queryString.stringify(
|
||||||
page,
|
{
|
||||||
pageSize,
|
page,
|
||||||
freeTextFilter,
|
pageSize,
|
||||||
sortfield,
|
freeTextFilter,
|
||||||
sortdir,
|
sortfield,
|
||||||
...filter,
|
sortdir,
|
||||||
});
|
...filter,
|
||||||
|
},
|
||||||
|
{ skipNull: true, skipEmptyString: true },
|
||||||
|
);
|
||||||
const response = await xhrGet(`/api/listings/table?${qryString}`);
|
const response = await xhrGet(`/api/listings/table?${qryString}`);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
listingsData: { ...state.listingsData, ...response.json },
|
listingsData: { ...state.listingsData, ...response.json },
|
||||||
@@ -304,17 +307,17 @@ export const useFredyState = create(
|
|||||||
throw Exception;
|
throw Exception;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async setImmoscoutDetails(enabled) {
|
async setProviderDetails(providers) {
|
||||||
try {
|
try {
|
||||||
await xhrPost('/api/user/settings/immoscout-details', { immoscout_details: enabled });
|
await xhrPost('/api/user/settings/provider-details', { provider_details: providers });
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
userSettings: {
|
userSettings: {
|
||||||
...state.userSettings,
|
...state.userSettings,
|
||||||
settings: { ...state.userSettings.settings, immoscout_details: enabled },
|
settings: { ...state.userSettings.settings, provider_details: providers },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
} catch (Exception) {
|
} 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;
|
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 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 {
|
import {
|
||||||
IconTerminal,
|
IconTerminal,
|
||||||
IconStar,
|
IconStar,
|
||||||
@@ -22,7 +22,6 @@ import KpiCard from '../../components/cards/KpiCard.jsx';
|
|||||||
import PieChartCard from '../../components/cards/PieChartCard.jsx';
|
import PieChartCard from '../../components/cards/PieChartCard.jsx';
|
||||||
|
|
||||||
import './Dashboard.less';
|
import './Dashboard.less';
|
||||||
import { SegmentPart } from '../../components/segment/SegmentPart.jsx';
|
|
||||||
import { xhrPost } from '../../services/xhr.js';
|
import { xhrPost } from '../../services/xhr.js';
|
||||||
import { format } from '../../services/time/timeService.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 kpis = dashboard?.kpis || { totalJobs: 0, totalListings: 0, providersUsed: 0 };
|
||||||
const pieData = dashboard?.pie || [];
|
const pieData = dashboard?.pie || [];
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard">
|
<div className="dashboard">
|
||||||
|
<Text className="dashboard__section-label">General</Text>
|
||||||
<Row gutter={[16, 16]} className="dashboard__row">
|
<Row gutter={[16, 16]} className="dashboard__row">
|
||||||
<Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}>
|
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||||
<SegmentPart name="General" Icon={IconTerminal}>
|
<KpiCard
|
||||||
<Row gutter={[16, 16]} className="dashboard__row">
|
title="Search Interval"
|
||||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
value={`${dashboard?.general?.interval} min`}
|
||||||
<KpiCard
|
icon={<IconClock />}
|
||||||
title="Search Interval"
|
description="Time interval for job execution"
|
||||||
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>
|
</Col>
|
||||||
<Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}>
|
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||||
<SegmentPart name="Overview" Icon={IconStar}>
|
<KpiCard
|
||||||
<Row gutter={[16, 16]} className="dashboard__row">
|
title="Last Search"
|
||||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
valueFontSize="14px"
|
||||||
<KpiCard
|
value={
|
||||||
title="Jobs"
|
dashboard?.general?.lastRun == null || dashboard?.general?.lastRun === 0
|
||||||
color="blue"
|
? '---'
|
||||||
value={!kpis.totalJobs ? '---' : kpis.totalJobs}
|
: format(dashboard?.general?.lastRun)
|
||||||
icon={<IconTerminal />}
|
}
|
||||||
description="Total number of jobs"
|
icon={<IconDoubleChevronLeft />}
|
||||||
/>
|
description="Last execution timestamp"
|
||||||
</Col>
|
/>
|
||||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
</Col>
|
||||||
<KpiCard
|
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||||
title="Listings"
|
<KpiCard
|
||||||
color="orange"
|
title="Next Search"
|
||||||
value={!kpis.totalListings ? '---' : kpis.totalListings}
|
value={
|
||||||
icon={<IconStarStroked />}
|
dashboard?.general?.nextRun == null || dashboard?.general?.nextRun === 0
|
||||||
description="Total listings found"
|
? '---'
|
||||||
/>
|
: format(dashboard?.general?.nextRun)
|
||||||
</Col>
|
}
|
||||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
valueFontSize="14px"
|
||||||
<KpiCard
|
icon={<IconDoubleChevronRight />}
|
||||||
title="Active Listings"
|
description="Next execution timestamp"
|
||||||
color="green"
|
/>
|
||||||
value={!kpis.numberOfActiveListings ? '---' : kpis.numberOfActiveListings}
|
</Col>
|
||||||
icon={<IconStar />}
|
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||||
description="Total active listings"
|
<KpiCard title="Search Now" icon={<IconSearch />} description="Run a search now">
|
||||||
/>
|
<Button
|
||||||
</Col>
|
size="small"
|
||||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
style={{ marginTop: '.2rem' }}
|
||||||
<KpiCard
|
icon={<IconPlayCircle />}
|
||||||
title="Avg. Price"
|
aria-label="Start now"
|
||||||
color="purple"
|
onClick={async () => {
|
||||||
value={`${
|
try {
|
||||||
!kpis.avgPriceOfListings
|
await xhrPost('/api/jobs/startAll', null);
|
||||||
? '---'
|
Toast.success('Successfully triggered Fredy search.');
|
||||||
: new Intl.NumberFormat('de-DE', {
|
} catch {
|
||||||
style: 'currency',
|
Toast.error('Failed to trigger search');
|
||||||
currency: 'EUR',
|
}
|
||||||
}).format(kpis.avgPriceOfListings)
|
}}
|
||||||
}`}
|
>
|
||||||
icon={<IconNoteMoney />}
|
Search now
|
||||||
description="Avg. Price of listings"
|
</Button>
|
||||||
/>
|
</KpiCard>
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</SegmentPart>
|
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<SegmentPart
|
<Text className="dashboard__section-label">Overview</Text>
|
||||||
name="Provider Insights"
|
<Row gutter={[16, 16]} className="dashboard__row">
|
||||||
Icon={IconStar}
|
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||||
helpText="Percentage of found listings over all providers"
|
<KpiCard
|
||||||
className="dashboard__provider-insights"
|
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="Median Price"
|
||||||
|
color="purple"
|
||||||
|
value={`${
|
||||||
|
!kpis.medianPriceOfListings
|
||||||
|
? '---'
|
||||||
|
: new Intl.NumberFormat('de-DE', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
}).format(kpis.medianPriceOfListings)
|
||||||
|
}`}
|
||||||
|
icon={<IconNoteMoney />}
|
||||||
|
description="Median Price of listings"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Text className="dashboard__section-label">Provider Insights</Text>
|
||||||
|
<div className="dashboard__pie-wrapper">
|
||||||
<PieChartCard data={pieData} />
|
<PieChartCard data={pieData} />
|
||||||
</SegmentPart>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,31 +3,32 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
||||||
&__row {
|
&__section-label {
|
||||||
margin-bottom: 24px;
|
display: block;
|
||||||
flex-wrap: wrap;
|
font-size: 11px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
.semi-col {
|
text-transform: uppercase;
|
||||||
margin-bottom: 0; // Handled by Row gutter
|
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;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin: 0 !important;
|
justify-content: center;
|
||||||
|
|
||||||
.semi-card-body {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
max-height: 300px;
|
|
||||||
|
|
||||||
> * {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,29 +3,33 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import 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 { 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 { SegmentPart } from '../../components/segment/SegmentPart';
|
||||||
import { Banner, Toast } from '@douyinfe/semi-ui-19';
|
|
||||||
import {
|
import {
|
||||||
downloadBackup as downloadBackupZip,
|
downloadBackup as downloadBackupZip,
|
||||||
precheckRestore as clientPrecheckRestore,
|
precheckRestore as clientPrecheckRestore,
|
||||||
restore as clientRestore,
|
restore as clientRestore,
|
||||||
} from '../../services/backupRestoreClient';
|
} from '../../services/backupRestoreClient';
|
||||||
import {
|
import { IconSave, IconRefresh, IconSignal, IconHome, IconFolder } from '@douyinfe/semi-icons';
|
||||||
IconSave,
|
import { debounce } from '../../utils';
|
||||||
IconCalendar,
|
|
||||||
IconRefresh,
|
|
||||||
IconSignal,
|
|
||||||
IconLineChartStroked,
|
|
||||||
IconSearch,
|
|
||||||
IconFolder,
|
|
||||||
} from '@douyinfe/semi-icons';
|
|
||||||
import './GeneralSettings.less';
|
import './GeneralSettings.less';
|
||||||
|
|
||||||
function formatFromTimestamp(ts) {
|
function formatFromTimestamp(ts) {
|
||||||
@@ -63,6 +67,15 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
const [restoreBusy, setRestoreBusy] = React.useState(false);
|
const [restoreBusy, setRestoreBusy] = React.useState(false);
|
||||||
const [selectedRestoreFile, setSelectedRestoreFile] = React.useState(null);
|
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(() => {
|
React.useEffect(() => {
|
||||||
async function init() {
|
async function init() {
|
||||||
await actions.generalSettings.getGeneralSettings();
|
await actions.generalSettings.getGeneralSettings();
|
||||||
@@ -86,6 +99,11 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
init();
|
init();
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAddress(homeAddress?.address || '');
|
||||||
|
setCoords(homeAddress?.coords || null);
|
||||||
|
}, [homeAddress]);
|
||||||
|
|
||||||
const nullOrEmpty = (val) => val == null || val.length === 0;
|
const nullOrEmpty = (val) => val == null || val.length === 0;
|
||||||
|
|
||||||
const handleStore = async () => {
|
const handleStore = async () => {
|
||||||
@@ -177,7 +195,6 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
setSelectedRestoreFile(file);
|
setSelectedRestoreFile(file);
|
||||||
await precheckRestore(file);
|
await precheckRestore(file);
|
||||||
// reset the input to allow same file re-select
|
|
||||||
ev.target.value = '';
|
ev.target.value = '';
|
||||||
},
|
},
|
||||||
[precheckRestore],
|
[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 (
|
return (
|
||||||
<div>
|
<div className="generalSettings">
|
||||||
{!loading && (
|
{!loading && (
|
||||||
<React.Fragment>
|
<>
|
||||||
<div>
|
<Tabs type="line">
|
||||||
<SegmentPart
|
<TabPane
|
||||||
name="Interval"
|
tab={
|
||||||
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."
|
<span>
|
||||||
Icon={IconRefresh}
|
<IconSignal size="small" style={{ marginRight: 6 }} />
|
||||||
|
System
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
itemKey="system"
|
||||||
>
|
>
|
||||||
<InputNumber
|
<div className="generalSettings__tab-content">
|
||||||
min={5}
|
<SegmentPart name="Port" helpText="The port on which Fredy is running.">
|
||||||
max={1440}
|
<InputNumber
|
||||||
placeholder="Interval in minutes"
|
min={0}
|
||||||
value={interval}
|
max={99999}
|
||||||
formatter={(value) => `${value}`.replace(/\D/g, '')}
|
placeholder="Port"
|
||||||
onChange={(value) => setInterval(value)}
|
value={port}
|
||||||
suffix={'minutes'}
|
formatter={(value) => `${value}`.replace(/\D/g, '')}
|
||||||
/>
|
onChange={(value) => setPort(value)}
|
||||||
</SegmentPart>
|
style={{ maxWidth: 160 }}
|
||||||
<Divider margin="1rem" />
|
/>
|
||||||
<SegmentPart
|
</SegmentPart>
|
||||||
name="Backup & Restore"
|
|
||||||
helpText="Download a zipped backup of your database or restore it from a backup zip."
|
<SegmentPart
|
||||||
Icon={IconSave}
|
name="SQLite Database Path"
|
||||||
>
|
helpText="The directory where Fredy stores its SQLite database files."
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
>
|
||||||
<Button theme="solid" icon={<IconSave />} onClick={handleDownloadBackup}>
|
<Banner
|
||||||
Download backup
|
fullMode={false}
|
||||||
</Button>
|
type="warning"
|
||||||
<input
|
closeIcon={null}
|
||||||
type="file"
|
style={{ marginBottom: '12px' }}
|
||||||
accept=".zip,application/zip"
|
description="Changing this path may result in data loss. Restart Fredy immediately after saving."
|
||||||
ref={fileInputRef}
|
/>
|
||||||
style={{ display: 'none' }}
|
<Input
|
||||||
onChange={handleSelectRestoreFile}
|
type="text"
|
||||||
/>
|
placeholder="Database folder path"
|
||||||
<Button onClick={handleOpenFilePicker} theme="light" icon={<IconFolder />}>
|
value={sqlitePath}
|
||||||
Restore from zip
|
onChange={(value) => setSqlitePath(value)}
|
||||||
</Button>
|
/>
|
||||||
|
</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>
|
</div>
|
||||||
</SegmentPart>
|
</TabPane>
|
||||||
<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>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
<TabPane
|
||||||
type="text"
|
tab={
|
||||||
placeholder="Select folder"
|
<span>
|
||||||
value={sqlitePath}
|
<IconRefresh size="small" style={{ marginRight: 6 }} />
|
||||||
onChange={(value) => {
|
Execution
|
||||||
setSqlitePath(value);
|
</span>
|
||||||
}}
|
}
|
||||||
/>
|
itemKey="execution"
|
||||||
</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}
|
|
||||||
>
|
>
|
||||||
<div className="generalSettings__timePickerContainer">
|
<div className="generalSettings__tab-content">
|
||||||
<TimePicker
|
<SegmentPart
|
||||||
format={'HH:mm'}
|
name="Search Interval"
|
||||||
insetLabel="From"
|
helpText="Interval in minutes for running queries against configured services. Do not go below 5 minutes to avoid being detected as a bot."
|
||||||
value={formatFromTBackend(workingHourFrom)}
|
>
|
||||||
placeholder=""
|
<InputNumber
|
||||||
onChange={(val) => {
|
min={5}
|
||||||
setWorkingHourFrom(val == null ? null : formatFromTimestamp(val));
|
max={1440}
|
||||||
}}
|
placeholder="Interval in minutes"
|
||||||
/>
|
value={interval}
|
||||||
<TimePicker
|
formatter={(value) => `${value}`.replace(/\D/g, '')}
|
||||||
format={'HH:mm'}
|
onChange={(value) => setInterval(value)}
|
||||||
insetLabel="Until"
|
suffix={'minutes'}
|
||||||
value={formatFromTBackend(workingHourTo)}
|
style={{ maxWidth: 200 }}
|
||||||
placeholder=""
|
/>
|
||||||
onChange={(val) => {
|
</SegmentPart>
|
||||||
setWorkingHourTo(val == null ? null : formatFromTimestamp(val));
|
|
||||||
}}
|
<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>
|
</div>
|
||||||
</SegmentPart>
|
</TabPane>
|
||||||
<Divider margin="1rem" />
|
|
||||||
|
|
||||||
<SegmentPart name="Analytics" helpText="Insights into the usage of Fredy." Icon={IconLineChartStroked}>
|
<TabPane
|
||||||
<Banner
|
tab={
|
||||||
fullMode={false}
|
<span>
|
||||||
type="info"
|
<IconHome size="small" style={{ marginRight: 6 }} />
|
||||||
closeIcon={null}
|
User Settings
|
||||||
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Explanation</div>}
|
</span>
|
||||||
style={{ marginBottom: '1rem' }}
|
}
|
||||||
description={
|
itemKey="userSettings"
|
||||||
<div>
|
>
|
||||||
Analytics are disabled by default. If you choose to enable them, we will begin tracking the
|
<div className="generalSettings__tab-content">
|
||||||
following:
|
<SegmentPart
|
||||||
<br />
|
name="Home Address"
|
||||||
<ul>
|
helpText="Used to calculate distances between your location and each listing. Updating this recalculates distances for all active listings."
|
||||||
<li>Name of active provider (e.g. Immoscout)</li>
|
>
|
||||||
<li>Name of active adapter (e.g. Console)</li>
|
<AutoComplete
|
||||||
<li>language</li>
|
data={dataSource}
|
||||||
<li>os</li>
|
value={address}
|
||||||
<li>node version</li>
|
showClear
|
||||||
<li>arch</li>
|
onChange={(v) => setAddress(v)}
|
||||||
</ul>
|
onSearch={searchAddress}
|
||||||
The data is sent anonymously and helps me understand which providers or adapters are being used the
|
placeholder="Enter your home address"
|
||||||
most. In the end it helps me to improve fredy.
|
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>
|
</div>
|
||||||
}
|
</SegmentPart>
|
||||||
/>
|
</div>
|
||||||
|
</TabPane>
|
||||||
<Checkbox checked={analyticsEnabled} onChange={(e) => setAnalyticsEnabled(e.target.checked)}>
|
</Tabs>
|
||||||
{' '}
|
</>
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{restoreModalVisible && (
|
{restoreModalVisible && (
|
||||||
<Modal
|
<Modal
|
||||||
title="Restore database"
|
title="Restore database"
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
.generalSettings {
|
.generalSettings {
|
||||||
|
&__tab-content {
|
||||||
|
padding: 20px 0;
|
||||||
|
max-width: 860px;
|
||||||
|
}
|
||||||
|
|
||||||
&__timePickerContainer {
|
&__timePickerContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__help {
|
&__save-row {
|
||||||
font-size: 11px;
|
margin-top: 1.5rem;
|
||||||
margin-left: 1rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,15 @@ import {
|
|||||||
IconPlayCircle,
|
IconPlayCircle,
|
||||||
IconPlusCircle,
|
IconPlusCircle,
|
||||||
IconUser,
|
IconUser,
|
||||||
IconClear,
|
IconFilter,
|
||||||
} from '@douyinfe/semi-icons';
|
} from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
|
const SPEC_FILTERS = [
|
||||||
|
{ key: 'maxPrice', translation: 'Max Price' },
|
||||||
|
{ key: 'minSize', translation: 'Min Size (m²)' },
|
||||||
|
{ key: 'minRooms', translation: 'Min Rooms' },
|
||||||
|
];
|
||||||
|
|
||||||
export default function JobMutator() {
|
export default function JobMutator() {
|
||||||
const jobs = useSelector((state) => state.jobsData.jobs);
|
const jobs = useSelector((state) => state.jobsData.jobs);
|
||||||
const shareableUserList = useSelector((state) => state.jobsData.shareableUserList);
|
const shareableUserList = useSelector((state) => state.jobsData.shareableUserList);
|
||||||
@@ -46,6 +52,7 @@ export default function JobMutator() {
|
|||||||
const defaultEnabled = sourceJob?.enabled ?? true;
|
const defaultEnabled = sourceJob?.enabled ?? true;
|
||||||
const defaultShareWithUsers = sourceJob?.shared_with_user ?? [];
|
const defaultShareWithUsers = sourceJob?.shared_with_user ?? [];
|
||||||
const defaultSpatialFilter = sourceJob?.spatialFilter || null;
|
const defaultSpatialFilter = sourceJob?.spatialFilter || null;
|
||||||
|
const defaultSpecFilter = sourceJob?.specFilter || null;
|
||||||
|
|
||||||
const [providerToEdit, setProviderToEdit] = useState(null);
|
const [providerToEdit, setProviderToEdit] = useState(null);
|
||||||
const [providerCreationVisible, setProviderCreationVisibility] = useState(false);
|
const [providerCreationVisible, setProviderCreationVisibility] = useState(false);
|
||||||
@@ -58,6 +65,7 @@ export default function JobMutator() {
|
|||||||
const [shareWithUsers, setShareWithUsers] = useState(defaultShareWithUsers);
|
const [shareWithUsers, setShareWithUsers] = useState(defaultShareWithUsers);
|
||||||
const [enabled, setEnabled] = useState(defaultEnabled);
|
const [enabled, setEnabled] = useState(defaultEnabled);
|
||||||
const [spatialFilter, setSpatialFilter] = useState(defaultSpatialFilter);
|
const [spatialFilter, setSpatialFilter] = useState(defaultSpatialFilter);
|
||||||
|
const [specFilter, setSpecFilter] = useState(defaultSpecFilter);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const actions = useActions();
|
const actions = useActions();
|
||||||
|
|
||||||
@@ -66,6 +74,12 @@ export default function JobMutator() {
|
|||||||
setSpatialFilter(data);
|
setSpatialFilter(data);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleSpecFilterChange = (key, value) => {
|
||||||
|
if (!SPEC_FILTERS.map(({ key }) => key).includes(key)) return;
|
||||||
|
|
||||||
|
setSpecFilter({ ...specFilter, [key]: value ? parseFloat(value) : null });
|
||||||
|
};
|
||||||
|
|
||||||
const isSavingEnabled = () => {
|
const isSavingEnabled = () => {
|
||||||
return Boolean(notificationAdapterData.length && providerData.length && name);
|
return Boolean(notificationAdapterData.length && providerData.length && name);
|
||||||
};
|
};
|
||||||
@@ -85,6 +99,7 @@ export default function JobMutator() {
|
|||||||
name,
|
name,
|
||||||
blacklist,
|
blacklist,
|
||||||
spatialFilter,
|
spatialFilter,
|
||||||
|
specFilter,
|
||||||
enabled,
|
enabled,
|
||||||
jobId: jobToBeEdit?.id || null,
|
jobId: jobToBeEdit?.id || null,
|
||||||
});
|
});
|
||||||
@@ -204,7 +219,7 @@ export default function JobMutator() {
|
|||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
<Divider margin="1rem" />
|
<Divider margin="1rem" />
|
||||||
<SegmentPart
|
<SegmentPart
|
||||||
Icon={IconClear}
|
Icon={IconFilter}
|
||||||
name="Blacklist"
|
name="Blacklist"
|
||||||
helpText="If a listing contains one of these words, it will be filtered out. Type in a word, then hit enter."
|
helpText="If a listing contains one of these words, it will be filtered out. Type in a word, then hit enter."
|
||||||
>
|
>
|
||||||
@@ -216,6 +231,27 @@ export default function JobMutator() {
|
|||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
<Divider margin="1rem" />
|
<Divider margin="1rem" />
|
||||||
<SegmentPart
|
<SegmentPart
|
||||||
|
Icon={IconFilter}
|
||||||
|
name="Criteria Filter"
|
||||||
|
helpText="Filter listings by specific criteria. Only numbers are allowed. You can leave fields empty if you don't want to filter by them."
|
||||||
|
>
|
||||||
|
<div className="jobMutation__specFilter">
|
||||||
|
{SPEC_FILTERS.map((filter) => (
|
||||||
|
<div key={filter.key} className="jobMutation__specFilterItem">
|
||||||
|
<div className="jobMutation__specFilterLabel">{filter.translation}</div>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="Add a number"
|
||||||
|
value={specFilter?.[filter.key]}
|
||||||
|
onChange={(value) => handleSpecFilterChange(filter.key, value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SegmentPart>
|
||||||
|
<Divider margin="1rem" />
|
||||||
|
<SegmentPart
|
||||||
|
Icon={IconFilter}
|
||||||
name="Area Filter"
|
name="Area Filter"
|
||||||
helpText="Define multiple geographic areas on the map to filter listings. Start drawing by clicking on the square symbol in the top left corner of the map. Click on the map to add points of the polygon. Select the first point to close the polygon. After that, click on a free area of the map to apply this polygon (the color will change from yellow to blue). To delete a polygon, select it first and then click on the trash symbol."
|
helpText="Define multiple geographic areas on the map to filter listings. Start drawing by clicking on the square symbol in the top left corner of the map. Click on the map to add points of the polygon. Select the first point to close the polygon. After that, click on a free area of the map to apply this polygon (the color will change from yellow to blue). To delete a polygon, select it first and then click on the trash symbol."
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,6 +3,24 @@
|
|||||||
float: right;
|
float: right;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__specFilter {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__specFilterItem {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__specFilterLabel {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.semi-select-option-list-wrapper {
|
.semi-select-option-list-wrapper {
|
||||||
|
|||||||
@@ -156,14 +156,21 @@ export default function NotificationAdapterMutator({
|
|||||||
return (
|
return (
|
||||||
<Form key={key}>
|
<Form key={key}>
|
||||||
{uiElement.type === 'boolean' ? (
|
{uiElement.type === 'boolean' ? (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
<div>
|
||||||
<Switch
|
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||||
checked={uiElement.value || false}
|
<Switch
|
||||||
onChange={(checked) => {
|
checked={uiElement.value || false}
|
||||||
setValue(selectedAdapter, uiElement, key, checked);
|
onChange={(checked) => {
|
||||||
}}
|
setValue(selectedAdapter, uiElement, key, checked);
|
||||||
/>
|
}}
|
||||||
{uiElement.label}
|
/>
|
||||||
|
{uiElement.label}
|
||||||
|
</div>
|
||||||
|
{uiElement.description && (
|
||||||
|
<div className="semi-form-field-extra" style={{ marginTop: '4px' }}>
|
||||||
|
{uiElement.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Form.Input
|
<Form.Input
|
||||||
@@ -173,6 +180,7 @@ export default function NotificationAdapterMutator({
|
|||||||
initValue={uiElement.value ?? ''}
|
initValue={uiElement.value ?? ''}
|
||||||
placeholder={uiElement.label}
|
placeholder={uiElement.label}
|
||||||
label={uiElement.label}
|
label={uiElement.label}
|
||||||
|
extraText={uiElement.description}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setValue(selectedAdapter, uiElement, key, value);
|
setValue(selectedAdapter, uiElement, key, value);
|
||||||
}}
|
}}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user