Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d433b13db6 | ||
|
|
41d9274dfd | ||
|
|
0436c7f7d7 | ||
|
|
a1cb57318e | ||
|
|
2566db9805 | ||
|
|
b48f786fd3 | ||
|
|
9c74129489 | ||
|
|
33120ebeca | ||
|
|
de2dd05c70 | ||
|
|
e4784e5960 | ||
|
|
2e537ce0be | ||
|
|
f0f1244baa | ||
|
|
b858529f06 | ||
|
|
c9bd5dc161 | ||
|
|
daa4a7b8f1 | ||
|
|
035f0e9f83 | ||
|
|
a5efd9af32 | ||
|
|
9f1e27d011 | ||
|
|
ebc57702dc | ||
|
|
3aa30bc1e2 | ||
|
|
f97fb48e51 | ||
|
|
4b15894603 | ||
|
|
31a14a0352 | ||
|
|
eecbe91dbd | ||
|
|
9dd3947cb7 | ||
|
|
c151f4f76e | ||
|
|
b6755497e4 | ||
|
|
412e24b1e3 | ||
|
|
0a5785fa1a | ||
|
|
7ebd73c9cf | ||
|
|
95cd4028d7 | ||
|
|
eb01c2107c | ||
|
|
42cd4fa0ae | ||
|
|
6d96fd2bf8 | ||
|
|
ff1d2317a1 | ||
|
|
a47fa41278 | ||
|
|
9654e56846 | ||
|
|
43094640a8 | ||
|
|
fa234d2d78 | ||
|
|
7cb0d6e382 | ||
|
|
d79f8d2664 | ||
|
|
4d37e890ab | ||
|
|
7589f20a18 | ||
|
|
702ffabc1a | ||
|
|
9387de1cd9 | ||
|
|
facd683d45 | ||
|
|
8324357edb | ||
|
|
67af7c7dc5 | ||
|
|
6f5b52f3ad | ||
|
|
89d239c360 | ||
|
|
dd5c5b29d9 | ||
|
|
0cb2f48645 | ||
|
|
3f294b8099 | ||
|
|
11fd18e76a | ||
|
|
c839f3abc9 | ||
|
|
28eddc5d7f | ||
|
|
0ca9c5ae02 | ||
|
|
a7d0037edd | ||
|
|
f339a2e2cf | ||
|
|
da8fd13973 | ||
|
|
7deffc64af | ||
|
|
d1dad7fd3b | ||
|
|
4f79c5cba2 | ||
|
|
0d2b21c789 |
38
.github/workflows/docker.yml
vendored
@@ -57,3 +57,41 @@ jobs:
|
|||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
# Test container health with docker compose
|
||||||
|
- name: Test container with docker compose
|
||||||
|
run: |
|
||||||
|
echo "Starting container with docker compose..."
|
||||||
|
docker compose up --build -d
|
||||||
|
echo "Waiting for container to be ready (60 seconds for start_period)..."
|
||||||
|
sleep 60
|
||||||
|
|
||||||
|
echo "Monitoring container health for 30 seconds..."
|
||||||
|
SECONDS_ELAPSED=0
|
||||||
|
HEALTH_CHECK_INTERVAL=5
|
||||||
|
TOTAL_DURATION=30
|
||||||
|
|
||||||
|
while [ $SECONDS_ELAPSED -lt $TOTAL_DURATION ]; do
|
||||||
|
HEALTH_STATUS=$(docker inspect --format='{{.State.Health.Status}}' fredy 2>/dev/null || echo "not_found")
|
||||||
|
CONTAINER_STATUS=$(docker inspect --format='{{.State.Status}}' fredy 2>/dev/null || echo "not_found")
|
||||||
|
echo "[$SECONDS_ELAPSED/$TOTAL_DURATION sec] Container: $CONTAINER_STATUS, Health: $HEALTH_STATUS"
|
||||||
|
|
||||||
|
# Check if container is not running or unhealthy
|
||||||
|
if [ "$CONTAINER_STATUS" != "running" ]; then
|
||||||
|
echo "Container stopped running! Status: $CONTAINER_STATUS"
|
||||||
|
docker compose logs fredy
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$HEALTH_STATUS" = "unhealthy" ]; then
|
||||||
|
echo "Container is unhealthy!"
|
||||||
|
docker compose logs fredy
|
||||||
|
docker inspect --format='{{json .State.Health}}' fredy | jq
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep $HEALTH_CHECK_INTERVAL
|
||||||
|
SECONDS_ELAPSED=$((SECONDS_ELAPSED + HEALTH_CHECK_INTERVAL))
|
||||||
|
done
|
||||||
|
|
||||||
|
docker compose down
|
||||||
|
|||||||
1
.gitignore
vendored
@@ -5,3 +5,4 @@ db/*.db*
|
|||||||
npm-debug.log
|
npm-debug.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.idea
|
.idea
|
||||||
|
.vscode
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ FROM node:22-slim
|
|||||||
|
|
||||||
WORKDIR /fredy
|
WORKDIR /fredy
|
||||||
|
|
||||||
# Install Chromium without extra recommended packages and clean apt cache
|
# Install Chromium and curl without extra recommended packages and clean apt cache
|
||||||
|
# curl is needed for the health check
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends chromium \
|
&& apt-get install -y --no-install-recommends chromium curl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
|
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
|
||||||
@@ -30,6 +31,8 @@ RUN mkdir -p /db /conf \
|
|||||||
&& ln -s /conf /fredy/conf
|
&& ln -s /conf /fredy/conf
|
||||||
|
|
||||||
EXPOSE 9998
|
EXPOSE 9998
|
||||||
|
VOLUME /db
|
||||||
|
VOLUME /conf
|
||||||
|
|
||||||
# Start application using PM2 runtime
|
# Start application using PM2 runtime
|
||||||
CMD ["pm2-runtime", "index.js"]
|
CMD ["pm2-runtime", "index.js"]
|
||||||
|
|||||||
32
README.md
@@ -9,10 +9,18 @@
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||

|
<p align="center">
|
||||||
[](https://github.com/orangecoding/fredy/actions/workflows/docker.yml)
|
<a href="https://fredy.orange-coding.net/" target="_blank">Website</a> |
|
||||||

|
<a href="https://fredy-demo.orange-coding.net/" target="_blank">Demo</a>
|
||||||

|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://github.com/orangecoding/fredy/actions/workflows/test.yml/badge.svg" alt="Tests" />
|
||||||
|
<img src="https://github.com/orangecoding/fredy/actions/workflows/docker.yml/badge.svg" alt="Docker" />
|
||||||
|
<img src="https://github.com/orangecoding/fredy/actions/workflows/check_source.yml/badge.svg" alt="Source" />
|
||||||
|
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fghcr-badge.elias.eu.org%2Fapi%2Forangecoding%2Ffredy%2Ffredy&query=%24.downloadCount&label=Docker%20Pulls" alt="Docker Pulls" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Fredy 🏡 – Your Self-Hosted Real Estate Finder for Germany
|
# Fredy 🏡 – Your Self-Hosted Real Estate Finder for Germany
|
||||||
@@ -21,15 +29,13 @@ Finding an apartment or house in Germany can be stressful and
|
|||||||
time-consuming.\
|
time-consuming.\
|
||||||
**Fredy** makes it easier: it automatically scrapes **ImmoScout24,
|
**Fredy** makes it easier: it automatically scrapes **ImmoScout24,
|
||||||
Immowelt, Immonet, eBay Kleinanzeigen, and WG-Gesucht** and notifies you
|
Immowelt, Immonet, eBay Kleinanzeigen, and WG-Gesucht** and notifies you
|
||||||
instantly via **Slack, Telegram, Email, ntfy, and more** when new
|
instantly via **Slack, Telegram, Email, ntfy, discord and more** when new
|
||||||
listings appear.
|
listings appear.
|
||||||
|
|
||||||
With a modern architecture, Fredy provides a **clean Web UI**, removes
|
With a modern architecture, Fredy provides a **clean Web UI**, removes
|
||||||
duplicates across platforms, and stores results so you never see the
|
duplicates across platforms, and stores results so you never see the
|
||||||
same listing twice.
|
same listing twice.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
## ✨ Key Features
|
## ✨ Key Features
|
||||||
@@ -37,7 +43,7 @@ same listing twice.
|
|||||||
- 🏠 Scrapes **ImmoScout24, Immowelt, Immonet, eBay Kleinanzeigen,
|
- 🏠 Scrapes **ImmoScout24, Immowelt, Immonet, eBay Kleinanzeigen,
|
||||||
WG-Gesucht**
|
WG-Gesucht**
|
||||||
- ⚡ Instant notifications: Slack, Telegram, Email (SendGrid,
|
- ⚡ Instant notifications: Slack, Telegram, Email (SendGrid,
|
||||||
Mailjet), ntfy
|
Mailjet), ntfy, discord
|
||||||
- 🔎 Uses the **ImmoScout Mobile API** (reverse engineered)
|
- 🔎 Uses the **ImmoScout Mobile API** (reverse engineered)
|
||||||
- 🌍 Runs anywhere: Docker, Node.js, self-hosted
|
- 🌍 Runs anywhere: Docker, Node.js, self-hosted
|
||||||
- 🖥️ Intuitive **Web UI** to manage searches
|
- 🖥️ Intuitive **Web UI** to manage searches
|
||||||
@@ -109,9 +115,9 @@ yarn run start:frontend # in another terminal
|
|||||||
|
|
||||||
## 📸 Screenshots
|
## 📸 Screenshots
|
||||||
|
|
||||||
| Job Configuration | Job Analytics | Job Overview |
|
| Fredy Main Overview | Job Configuration | Found Listings |
|
||||||
|-------------------|--------------|--------------|
|
|--------------------------------------------------|-----------------------------------------------------------------------|-----------------------------------------------------------------------------|
|
||||||
|  |  |  |
|
|  |  |  |
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -131,7 +137,7 @@ picks up the newest listings first.
|
|||||||
### Adapter 📡
|
### Adapter 📡
|
||||||
|
|
||||||
An **adapter** is the channel through which Fredy notifies you (Slack,
|
An **adapter** is the channel through which Fredy notifies you (Slack,
|
||||||
Telegram, Email, ntfy, ...).\
|
Telegram, Email, ntfy, discord ...).\
|
||||||
Each adapter has its own configuration (e.g. API keys, webhook URLs).\
|
Each adapter has its own configuration (e.g. API keys, webhook URLs).\
|
||||||
You can use multiple adapters at once --- Fredy will send new listings
|
You can use multiple adapters at once --- Fredy will send new listings
|
||||||
through all of them.
|
through all of them.
|
||||||
@@ -196,7 +202,7 @@ flowchart TD
|
|||||||
F2["Adapter 2"]
|
F2["Adapter 2"]
|
||||||
end
|
end
|
||||||
|
|
||||||
A1 --> B["FredyRuntime"]
|
A1 --> B["FredyPipeline"]
|
||||||
A2 --> B
|
A2 --> B
|
||||||
A3 --> B
|
A3 --> B
|
||||||
B --> C1 & C2 & C3
|
B --> C1 & C2 & C3
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"interval":"60","port":9998,"workingHours":{"from":"","to":""},"demoMode":false,"analyticsEnabled":null,"sqlitepath":"/db"}
|
{"interval":"60","port":9998,"workingHours":{"from":"","to":""},"demoMode":false,"analyticsEnabled":true,"sqlitepath":"/db"}
|
||||||
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 197 KiB |
BIN
doc/screenshot2.png
Normal file
|
After Width: | Height: | Size: 512 KiB |
BIN
doc/screenshot3.png
Normal file
|
After Width: | Height: | Size: 372 KiB |
|
Before Width: | Height: | Size: 323 KiB |
|
Before Width: | Height: | Size: 93 KiB |
@@ -5,11 +5,18 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: fredy/fredy
|
image: ghcr.io/orangecoding/fredy
|
||||||
# map existing config and database
|
# map existing config and database
|
||||||
volumes:
|
volumes:
|
||||||
- ./conf:/conf
|
- ./conf:/conf
|
||||||
- ./db:/db
|
- ./db:/db
|
||||||
ports:
|
ports:
|
||||||
- 9998:9998
|
- "9998:9998"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
# The container will immediately stop when health check fails after retries
|
||||||
|
test: ["CMD-SHELL", "curl --fail --silent --show-error --max-time 5 http://localhost:9998/ || exit 1"]
|
||||||
|
interval: 120s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 1
|
||||||
|
start_period: 10s
|
||||||
|
|||||||
18
docker-test.sh
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Stop and remove old container if it exists
|
||||||
|
if [ "$(docker ps -aq -f name=fredy)" ]; then
|
||||||
|
docker stop fredy || true
|
||||||
|
docker rm fredy || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build image from local Dockerfile, forcing a fresh build without cache
|
||||||
|
docker build --no-cache -t fredy:local .
|
||||||
|
|
||||||
|
# Run container with volumes and port mapping
|
||||||
|
docker run -d --name fredy \
|
||||||
|
-v fredy_conf:/conf \
|
||||||
|
-v fredy_db:/db \
|
||||||
|
-p 9998:9998 \
|
||||||
|
fredy:local
|
||||||
@@ -7,11 +7,14 @@
|
|||||||
content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
|
content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
|
||||||
/>
|
/>
|
||||||
<meta name="google" content="notranslate" />
|
<meta name="google" content="notranslate" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
|
||||||
<title>Fredy</title>
|
<title>Fredy || Real Estate Finder</title>
|
||||||
</head>
|
</head>
|
||||||
<body theme-mode="dark">
|
<body theme-mode="dark">
|
||||||
<div id="fredy" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0"></div>
|
<div id="fredy" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0"></div>
|
||||||
</body>
|
</body>
|
||||||
<script type="module" src="/ui/src/Index.jsx"></script>
|
<script type="module" src="/ui/src/Index.jsx"></script>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
92
index.js
@@ -1,15 +1,27 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { config } from './lib/utils.js';
|
import { checkIfConfigIsAccessible, config, getProviders, refreshConfig } from './lib/utils.js';
|
||||||
import * as similarityCache from './lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from './lib/services/similarity-check/similarityCache.js';
|
||||||
import * as jobStorage from './lib/services/storage/jobStorage.js';
|
import * as jobStorage from './lib/services/storage/jobStorage.js';
|
||||||
import FredyRuntime from './lib/FredyRuntime.js';
|
import FredyPipeline from './lib/FredyPipeline.js';
|
||||||
import { duringWorkingHoursOrNotSet } from './lib/utils.js';
|
import { duringWorkingHoursOrNotSet } from './lib/utils.js';
|
||||||
import { runMigrations } from './lib/services/storage/migrations/migrate.js';
|
import { runMigrations } from './lib/services/storage/migrations/migrate.js';
|
||||||
import { ensureDemoUserExists, ensureAdminUserExists } from './lib/services/storage/userStorage.js';
|
import { ensureDemoUserExists, ensureAdminUserExists } from './lib/services/storage/userStorage.js';
|
||||||
import { cleanupDemoAtMidnight } from './lib/services/demoCleanup.js';
|
import { cleanupDemoAtMidnight } from './lib/services/crons/demoCleanup-cron.js';
|
||||||
import { initTrackerCron } from './lib/services/tracking/Tracker-Cron.js';
|
import { initTrackerCron } from './lib/services/crons/tracker-cron.js';
|
||||||
import logger from './lib/services/logger.js';
|
import logger from './lib/services/logger.js';
|
||||||
|
import { bus } from './lib/services/events/event-bus.js';
|
||||||
|
import { initActiveCheckerCron } from './lib/services/crons/listing-alive-cron.js';
|
||||||
|
|
||||||
|
// Load configuration before any other startup steps
|
||||||
|
await refreshConfig();
|
||||||
|
|
||||||
|
const isConfigAccessible = await checkIfConfigIsAccessible();
|
||||||
|
|
||||||
|
if (!isConfigAccessible) {
|
||||||
|
logger.error('Configuration exists, but is not accessible. Please check the file permission');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure sqlite directory exists before loading anything else (based on config.sqlitepath)
|
// Ensure sqlite directory exists before loading anything else (based on config.sqlitepath)
|
||||||
const rawDir = config.sqlitepath || '/db';
|
const rawDir = config.sqlitepath || '/db';
|
||||||
@@ -22,8 +34,9 @@ if (!fs.existsSync(absDir)) {
|
|||||||
// Run DB migrations once at startup and block until finished
|
// Run DB migrations once at startup and block until finished
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
|
|
||||||
const providersPath = './lib/provider';
|
// Load provider modules once at startup
|
||||||
const provider = fs.readdirSync(providersPath).filter((file) => file.endsWith('.js'));
|
const providers = await getProviders();
|
||||||
|
|
||||||
//assuming interval is always in minutes
|
//assuming interval is always in minutes
|
||||||
const INTERVAL = config.interval * 60 * 1000;
|
const INTERVAL = config.interval * 60 * 1000;
|
||||||
|
|
||||||
@@ -37,37 +50,46 @@ if (config.demoMode) {
|
|||||||
|
|
||||||
logger.info(`Started Fredy successfully. Ui can be accessed via http://localhost:${config.port}`);
|
logger.info(`Started Fredy successfully. Ui can be accessed via http://localhost:${config.port}`);
|
||||||
|
|
||||||
const fetchedProvider = await Promise.all(
|
|
||||||
provider.filter((provider) => provider.endsWith('.js')).map(async (pro) => import(`${providersPath}/${pro}`)),
|
|
||||||
);
|
|
||||||
|
|
||||||
ensureAdminUserExists();
|
ensureAdminUserExists();
|
||||||
ensureDemoUserExists();
|
ensureDemoUserExists();
|
||||||
await initTrackerCron();
|
await initTrackerCron();
|
||||||
|
//do not wait for this to finish, let it run in the background
|
||||||
|
initActiveCheckerCron();
|
||||||
|
|
||||||
setInterval(
|
bus.on('jobs:runAll', () => {
|
||||||
(function exec() {
|
logger.debug('Running Fredy Job manually');
|
||||||
const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(config, Date.now());
|
execute();
|
||||||
if (!config.demoMode) {
|
});
|
||||||
if (isDuringWorkingHoursOrNotSet) {
|
|
||||||
config.lastRun = Date.now();
|
const execute = () => {
|
||||||
jobStorage
|
const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(config, Date.now());
|
||||||
.getJobs()
|
if (!config.demoMode) {
|
||||||
.filter((job) => job.enabled)
|
if (isDuringWorkingHoursOrNotSet) {
|
||||||
.forEach((job) => {
|
config.lastRun = Date.now();
|
||||||
job.provider
|
jobStorage
|
||||||
.filter((p) => fetchedProvider.find((fp) => fp.metaInformation.id === p.id) != null)
|
.getJobs()
|
||||||
.forEach(async (prov) => {
|
.filter((job) => job.enabled)
|
||||||
const pro = fetchedProvider.find((fp) => fp.metaInformation.id === prov.id);
|
.forEach((job) => {
|
||||||
pro.init(prov, job.blacklist);
|
job.provider
|
||||||
await new FredyRuntime(pro.config, job.notificationAdapter, prov.id, job.id, similarityCache).execute();
|
.filter((p) => providers.find((loaded) => loaded.metaInformation.id === p.id) != null)
|
||||||
});
|
.forEach(async (prov) => {
|
||||||
});
|
const matchedProvider = providers.find((loaded) => loaded.metaInformation.id === prov.id);
|
||||||
} else {
|
matchedProvider.init(prov, job.blacklist);
|
||||||
logger.debug('Working hours set. Skipping as outside of working hours.');
|
await new FredyPipeline(
|
||||||
}
|
matchedProvider.config,
|
||||||
|
job.notificationAdapter,
|
||||||
|
prov.id,
|
||||||
|
job.id,
|
||||||
|
similarityCache,
|
||||||
|
).execute();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.debug('Working hours set. Skipping as outside of working hours.');
|
||||||
}
|
}
|
||||||
return exec;
|
}
|
||||||
})(),
|
};
|
||||||
INTERVAL,
|
|
||||||
);
|
setInterval(execute, INTERVAL);
|
||||||
|
//start once at startup
|
||||||
|
execute();
|
||||||
|
|||||||
214
lib/FredyPipeline.js
Executable file
@@ -0,0 +1,214 @@
|
|||||||
|
import { NoNewListingsWarning } from './errors.js';
|
||||||
|
import { storeListings, getKnownListingHashesForJobAndProvider } from './services/storage/listingsStorage.js';
|
||||||
|
import * as notify from './notification/notify.js';
|
||||||
|
import Extractor from './services/extractor/extractor.js';
|
||||||
|
import urlModifier from './services/queryStringMutator.js';
|
||||||
|
import logger from './services/logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Listing
|
||||||
|
* @property {string} id Stable unique identifier (hash) of the listing.
|
||||||
|
* @property {string} title Title or headline of the listing.
|
||||||
|
* @property {string} [address] Optional address/location text.
|
||||||
|
* @property {string} [price] Optional price text/value.
|
||||||
|
* @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,
|
||||||
|
* and notifying about new listings from a configured provider.
|
||||||
|
*
|
||||||
|
* The execution flow is:
|
||||||
|
* 1) Prepare provider URL (sorting, etc.)
|
||||||
|
* 2) Extract raw listings from the provider
|
||||||
|
* 3) Normalize listings to the provider schema
|
||||||
|
* 4) Filter out incomplete/blacklisted listings
|
||||||
|
* 5) Identify new listings (vs. previously stored hashes)
|
||||||
|
* 6) Persist new listings
|
||||||
|
* 7) Filter out entries similar to already seen ones
|
||||||
|
* 8) Dispatch notifications
|
||||||
|
*/
|
||||||
|
class FredyPipeline {
|
||||||
|
/**
|
||||||
|
* Create a new runtime instance for a single provider/job execution.
|
||||||
|
*
|
||||||
|
* @param {Object} providerConfig Provider configuration.
|
||||||
|
* @param {string} providerConfig.url Base URL to crawl.
|
||||||
|
* @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 {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.
|
||||||
|
*/
|
||||||
|
constructor(providerConfig, notificationConfig, providerId, jobKey, similarityCache) {
|
||||||
|
this._providerConfig = providerConfig;
|
||||||
|
this._notificationConfig = notificationConfig;
|
||||||
|
this._providerId = providerId;
|
||||||
|
this._jobKey = jobKey;
|
||||||
|
this._similarityCache = similarityCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* after notifications have been sent; resolves to void when there are no new listings.
|
||||||
|
*/
|
||||||
|
execute() {
|
||||||
|
return Promise.resolve(urlModifier(this._providerConfig.url, this._providerConfig.sortByDateParam))
|
||||||
|
.then(this._providerConfig.getListings?.bind(this) ?? this._getListings.bind(this))
|
||||||
|
.then(this._normalize.bind(this))
|
||||||
|
.then(this._filter.bind(this))
|
||||||
|
.then(this._findNew.bind(this))
|
||||||
|
.then(this._save.bind(this))
|
||||||
|
.then(this._filterBySimilarListings.bind(this))
|
||||||
|
.then(this._notify.bind(this))
|
||||||
|
.catch(this._handleError.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch listings from the provider, using the default Extractor flow unless
|
||||||
|
* a provider-specific getListings override is supplied.
|
||||||
|
*
|
||||||
|
* @param {string} url The provider URL to fetch from.
|
||||||
|
* @returns {Promise<Listing[]>} Resolves with an array of listings (empty when none found).
|
||||||
|
*/
|
||||||
|
_getListings(url) {
|
||||||
|
const extractor = new Extractor();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
extractor
|
||||||
|
.execute(url, this._providerConfig.waitForSelector)
|
||||||
|
.then(() => {
|
||||||
|
const listings = extractor.parseResponseText(
|
||||||
|
this._providerConfig.crawlContainer,
|
||||||
|
this._providerConfig.crawlFields,
|
||||||
|
url,
|
||||||
|
);
|
||||||
|
resolve(listings == null ? [] : listings);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
reject(err);
|
||||||
|
logger.error(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize raw listings into the provider-specific Listing shape.
|
||||||
|
*
|
||||||
|
* @param {any[]} listings Raw listing entries from the extractor or override.
|
||||||
|
* @returns {Listing[]} Normalized listings.
|
||||||
|
*/
|
||||||
|
_normalize(listings) {
|
||||||
|
return listings.map(this._providerConfig.normalize);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter out listings that are missing required fields and those rejected by the
|
||||||
|
* provider's blacklist/filter function.
|
||||||
|
*
|
||||||
|
* @param {Listing[]} listings Listings to filter.
|
||||||
|
* @returns {Listing[]} Filtered listings that pass validation and provider filter.
|
||||||
|
*/
|
||||||
|
_filter(listings) {
|
||||||
|
const keys = Object.keys(this._providerConfig.crawlFields);
|
||||||
|
const filteredListings = listings.filter((item) => keys.every((key) => key in item));
|
||||||
|
return filteredListings.filter(this._providerConfig.filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine which listings are new by comparing their IDs against stored hashes.
|
||||||
|
*
|
||||||
|
* @param {Listing[]} listings Listings to evaluate for novelty.
|
||||||
|
* @returns {Listing[]} New listings not seen before.
|
||||||
|
* @throws {NoNewListingsWarning} When no new listings are found.
|
||||||
|
*/
|
||||||
|
_findNew(listings) {
|
||||||
|
logger.debug(`Checking ${listings.length} listings for new entries (Provider: '${this._providerId}')`);
|
||||||
|
const hashes = getKnownListingHashesForJobAndProvider(this._jobKey, this._providerId) || [];
|
||||||
|
|
||||||
|
const newListings = listings.filter((o) => !hashes.includes(o.id));
|
||||||
|
if (newListings.length === 0) {
|
||||||
|
throw new NoNewListingsWarning();
|
||||||
|
}
|
||||||
|
return newListings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send notifications for new listings using the configured notification adapter(s).
|
||||||
|
*
|
||||||
|
* @param {Listing[]} newListings New listings to notify about.
|
||||||
|
* @returns {Promise<Listing[]>} Resolves to the provided listings after notifications complete.
|
||||||
|
* @throws {NoNewListingsWarning} When there are no listings to notify about.
|
||||||
|
*/
|
||||||
|
_notify(newListings) {
|
||||||
|
if (newListings.length === 0) {
|
||||||
|
throw new NoNewListingsWarning();
|
||||||
|
}
|
||||||
|
const sendNotifications = notify.send(this._providerId, newListings, this._notificationConfig, this._jobKey);
|
||||||
|
return Promise.all(sendNotifications).then(() => newListings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist new listings and pass them through.
|
||||||
|
*
|
||||||
|
* @param {Listing[]} newListings Listings to store.
|
||||||
|
* @returns {Listing[]} The same listings, unchanged.
|
||||||
|
*/
|
||||||
|
_save(newListings) {
|
||||||
|
logger.debug(`Storing ${newListings.length} new listings (Provider: '${this._providerId}')`);
|
||||||
|
storeListings(this._jobKey, this._providerId, newListings);
|
||||||
|
return newListings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove listings that are similar to already known entries according to the similarity cache.
|
||||||
|
* Adds the remaining listings to the cache.
|
||||||
|
*
|
||||||
|
* @param {Listing[]} listings Listings to filter by similarity.
|
||||||
|
* @returns {Listing[]} Listings considered unique enough to keep.
|
||||||
|
*/
|
||||||
|
_filterBySimilarListings(listings) {
|
||||||
|
const filteredList = listings.filter((listing) => {
|
||||||
|
const similar = this._similarityCache.hasSimilarEntries(listing.title, listing.address);
|
||||||
|
if (similar) {
|
||||||
|
logger.debug(
|
||||||
|
`Filtering similar entry for title '${listing.title}' and address '${listing.address}' (Provider: '${this._providerId}')`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return !similar;
|
||||||
|
});
|
||||||
|
filteredList.forEach((filter) => this._similarityCache.addCacheEntry(filter.title, filter.address));
|
||||||
|
return filteredList;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle errors occurring in the pipeline, logging levels depending on type.
|
||||||
|
*
|
||||||
|
* @param {Error} err Error instance thrown by previous steps.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
_handleError(err) {
|
||||||
|
if (err.name === 'NoNewListingsWarning') {
|
||||||
|
logger.debug(`No new listings found (Provider: '${this._providerId}').`);
|
||||||
|
} else {
|
||||||
|
logger.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FredyPipeline;
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import { NoNewListingsWarning } from './errors.js';
|
|
||||||
import { storeListings, getKnownListingHashesForJobAndProvider } from './services/storage/listingsStorage.js';
|
|
||||||
import * as notify from './notification/notify.js';
|
|
||||||
import Extractor from './services/extractor/extractor.js';
|
|
||||||
import urlModifier from './services/queryStringMutator.js';
|
|
||||||
import logger from './services/logger.js';
|
|
||||||
|
|
||||||
class FredyRuntime {
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param providerConfig the config for the specific provider, we're going to query at the moment
|
|
||||||
* @param notificationConfig the config for all notifications
|
|
||||||
* @param providerId the id of the provider currently in use
|
|
||||||
* @param jobKey key of the job that is currently running (from within the config)
|
|
||||||
* @param similarityCache cache instance holding values to check for similarity of entries
|
|
||||||
*/
|
|
||||||
constructor(providerConfig, notificationConfig, providerId, jobKey, similarityCache) {
|
|
||||||
this._providerConfig = providerConfig;
|
|
||||||
this._notificationConfig = notificationConfig;
|
|
||||||
this._providerId = providerId;
|
|
||||||
this._jobKey = jobKey;
|
|
||||||
this._similarityCache = similarityCache;
|
|
||||||
}
|
|
||||||
|
|
||||||
execute() {
|
|
||||||
return (
|
|
||||||
//modify the url to make sure search order is correctly set
|
|
||||||
Promise.resolve(urlModifier(this._providerConfig.url, this._providerConfig.sortByDateParam))
|
|
||||||
//scraping the site and try finding new listings
|
|
||||||
.then(this._providerConfig.getListings?.bind(this) ?? this._getListings.bind(this))
|
|
||||||
//bring them in a proper form (dictated by the provider)
|
|
||||||
.then(this._normalize.bind(this))
|
|
||||||
//filter listings with stuff tagged by the blacklist of the provider
|
|
||||||
.then(this._filter.bind(this))
|
|
||||||
//check if new listings available. if so proceed
|
|
||||||
.then(this._findNew.bind(this))
|
|
||||||
//store everything in db
|
|
||||||
.then(this._save.bind(this))
|
|
||||||
//check for similar listings. if found, remove them before notifying
|
|
||||||
.then(this._filterBySimilarListings.bind(this))
|
|
||||||
//notify the user using the configured notification adapter
|
|
||||||
.then(this._notify.bind(this))
|
|
||||||
//if an error occurred on the way, handle it here.
|
|
||||||
.catch(this._handleError.bind(this))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
_getListings(url) {
|
|
||||||
const extractor = new Extractor();
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
extractor
|
|
||||||
.execute(url, this._providerConfig.waitForSelector)
|
|
||||||
.then(() => {
|
|
||||||
const listings = extractor.parseResponseText(
|
|
||||||
this._providerConfig.crawlContainer,
|
|
||||||
this._providerConfig.crawlFields,
|
|
||||||
url,
|
|
||||||
);
|
|
||||||
resolve(listings == null ? [] : listings);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
reject(err);
|
|
||||||
logger.error(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
_normalize(listings) {
|
|
||||||
return listings.map(this._providerConfig.normalize);
|
|
||||||
}
|
|
||||||
|
|
||||||
_filter(listings) {
|
|
||||||
//only return those where all the fields have been found
|
|
||||||
const keys = Object.keys(this._providerConfig.crawlFields);
|
|
||||||
const filteredListings = listings.filter((item) => keys.every((key) => key in item));
|
|
||||||
return filteredListings.filter(this._providerConfig.filter);
|
|
||||||
}
|
|
||||||
|
|
||||||
_findNew(listings) {
|
|
||||||
const hashes = getKnownListingHashesForJobAndProvider(this._jobKey, this._providerId) || [];
|
|
||||||
|
|
||||||
const newListings = listings.filter((o) => !hashes.includes(o.id));
|
|
||||||
if (newListings.length === 0) {
|
|
||||||
throw new NoNewListingsWarning();
|
|
||||||
}
|
|
||||||
return newListings;
|
|
||||||
}
|
|
||||||
|
|
||||||
_notify(newListings) {
|
|
||||||
if (newListings.length === 0) {
|
|
||||||
throw new NoNewListingsWarning();
|
|
||||||
}
|
|
||||||
const sendNotifications = notify.send(this._providerId, newListings, this._notificationConfig, this._jobKey);
|
|
||||||
return Promise.all(sendNotifications).then(() => newListings);
|
|
||||||
}
|
|
||||||
|
|
||||||
_save(newListings) {
|
|
||||||
storeListings(this._jobKey, this._providerId, newListings);
|
|
||||||
return newListings;
|
|
||||||
}
|
|
||||||
|
|
||||||
_filterBySimilarListings(listings) {
|
|
||||||
const filteredList = listings.filter((listing) => {
|
|
||||||
const similar = this._similarityCache.hasSimilarEntries(listing.title, listing.address);
|
|
||||||
if (similar) {
|
|
||||||
logger.debug(`Filtering similar entry for title: ${listing.title} and address ${listing.address}`);
|
|
||||||
}
|
|
||||||
return !similar;
|
|
||||||
});
|
|
||||||
filteredList.forEach((filter) => this._similarityCache.addCacheEntry(filter.title, listings.address));
|
|
||||||
return filteredList;
|
|
||||||
}
|
|
||||||
|
|
||||||
_handleError(err) {
|
|
||||||
if (err.name !== 'NoNewListingsWarning') logger.error(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default FredyRuntime;
|
|
||||||
@@ -3,10 +3,11 @@ import { authInterceptor, cookieSession, adminInterceptor } from './security.js'
|
|||||||
import { generalSettingsRouter } from './routes/generalSettingsRoute.js';
|
import { generalSettingsRouter } from './routes/generalSettingsRoute.js';
|
||||||
import { analyticsRouter } from './routes/analyticsRouter.js';
|
import { analyticsRouter } from './routes/analyticsRouter.js';
|
||||||
import { providerRouter } from './routes/providerRouter.js';
|
import { providerRouter } from './routes/providerRouter.js';
|
||||||
|
import { versionRouter } from './routes/versionRouter.js';
|
||||||
import { loginRouter } from './routes/loginRoute.js';
|
import { loginRouter } from './routes/loginRoute.js';
|
||||||
import { config } from '../utils.js';
|
|
||||||
import { userRouter } from './routes/userRoute.js';
|
import { userRouter } from './routes/userRoute.js';
|
||||||
import { jobRouter } from './routes/jobRouter.js';
|
import { jobRouter } from './routes/jobRouter.js';
|
||||||
|
import { config } from '../utils.js';
|
||||||
import bodyParser from 'body-parser';
|
import bodyParser from 'body-parser';
|
||||||
import restana from 'restana';
|
import restana from 'restana';
|
||||||
import files from 'serve-static';
|
import files from 'serve-static';
|
||||||
@@ -14,6 +15,7 @@ import path from 'path';
|
|||||||
import { getDirName } from '../utils.js';
|
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';
|
||||||
const service = restana();
|
const service = restana();
|
||||||
const staticService = files(path.join(getDirName(), '../ui/public'));
|
const staticService = files(path.join(getDirName(), '../ui/public'));
|
||||||
const PORT = config.port || 9998;
|
const PORT = config.port || 9998;
|
||||||
@@ -23,6 +25,9 @@ service.use(cookieSession());
|
|||||||
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());
|
||||||
|
service.use('/api/version', authInterceptor());
|
||||||
|
service.use('/api/listings', authInterceptor());
|
||||||
|
|
||||||
// /admin can only be accessed when user is having admin permissions
|
// /admin can only be accessed when user is having admin permissions
|
||||||
service.use('/api/admin', adminInterceptor());
|
service.use('/api/admin', adminInterceptor());
|
||||||
service.use('/api/jobs/notificationAdapter', notificationAdapterRouter);
|
service.use('/api/jobs/notificationAdapter', notificationAdapterRouter);
|
||||||
@@ -30,8 +35,10 @@ service.use('/api/admin/generalSettings', generalSettingsRouter);
|
|||||||
service.use('/api/jobs/provider', providerRouter);
|
service.use('/api/jobs/provider', providerRouter);
|
||||||
service.use('/api/jobs/insights', analyticsRouter);
|
service.use('/api/jobs/insights', analyticsRouter);
|
||||||
service.use('/api/admin/users', userRouter);
|
service.use('/api/admin/users', userRouter);
|
||||||
|
service.use('/api/version', versionRouter);
|
||||||
service.use('/api/jobs', jobRouter);
|
service.use('/api/jobs', jobRouter);
|
||||||
service.use('/api/login', loginRouter);
|
service.use('/api/login', loginRouter);
|
||||||
|
service.use('/api/listings', listingsRouter);
|
||||||
//this route is unsecured intentionally as it is being queried from the login page
|
//this route is unsecured intentionally as it is being queried from the login page
|
||||||
service.use('/api/demo', demoRouter);
|
service.use('/api/demo', demoRouter);
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import * as userStorage from '../../services/storage/userStorage.js';
|
|||||||
import { config } from '../../utils.js';
|
import { config } from '../../utils.js';
|
||||||
import { isAdmin } from '../security.js';
|
import { isAdmin } from '../security.js';
|
||||||
import logger from '../../services/logger.js';
|
import logger from '../../services/logger.js';
|
||||||
|
import { bus } from '../../services/events/event-bus.js';
|
||||||
|
|
||||||
const service = restana();
|
const service = restana();
|
||||||
const jobRouter = service.newRouter();
|
const jobRouter = service.newRouter();
|
||||||
|
|
||||||
function doesJobBelongsToUser(job, req) {
|
function doesJobBelongsToUser(job, req) {
|
||||||
const userId = req.session.currentUser;
|
const userId = req.session.currentUser;
|
||||||
if (userId == null) {
|
if (userId == null) {
|
||||||
@@ -17,12 +20,29 @@ function doesJobBelongsToUser(job, req) {
|
|||||||
}
|
}
|
||||||
return user.isAdmin || job.userId === user.id;
|
return user.isAdmin || job.userId === user.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
jobRouter.get('/', async (req, res) => {
|
jobRouter.get('/', async (req, res) => {
|
||||||
const isUserAdmin = isAdmin(req);
|
const isUserAdmin = isAdmin(req);
|
||||||
//show only the jobs which belongs to the user (or all of the user is an admin)
|
//show only the jobs which belongs to the user (or all of the user is an admin)
|
||||||
res.body = jobStorage.getJobs().filter((job) => isUserAdmin || job.userId === req.session.currentUser);
|
res.body = jobStorage
|
||||||
|
.getJobs()
|
||||||
|
.filter(
|
||||||
|
(job) =>
|
||||||
|
isUserAdmin || job.userId === req.session.currentUser || job.shared_with_user.includes(req.session.currentUser),
|
||||||
|
)
|
||||||
|
.map((job) => {
|
||||||
|
return {
|
||||||
|
...job,
|
||||||
|
isOnlyShared:
|
||||||
|
!isUserAdmin &&
|
||||||
|
job.userId !== req.session.currentUser &&
|
||||||
|
job.shared_with_user.includes(req.session.currentUser),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
|
|
||||||
jobRouter.get('/processingTimes', async (req, res) => {
|
jobRouter.get('/processingTimes', async (req, res) => {
|
||||||
res.body = {
|
res.body = {
|
||||||
interval: config.interval,
|
interval: config.interval,
|
||||||
@@ -30,9 +50,22 @@ jobRouter.get('/processingTimes', async (req, res) => {
|
|||||||
};
|
};
|
||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
jobRouter.post('/startAll', async (req, res) => {
|
||||||
|
bus.emit('jobs:runAll');
|
||||||
|
res.send();
|
||||||
|
});
|
||||||
|
|
||||||
jobRouter.post('/', async (req, res) => {
|
jobRouter.post('/', async (req, res) => {
|
||||||
const { provider, notificationAdapter, name, blacklist = [], jobId, enabled } = req.body;
|
const { provider, notificationAdapter, name, blacklist = [], jobId, enabled, shareWithUsers = [] } = req.body;
|
||||||
try {
|
try {
|
||||||
|
let jobFromDb = jobStorage.getJob(jobId);
|
||||||
|
|
||||||
|
if (jobFromDb && !doesJobBelongsToUser(jobFromDb, req)) {
|
||||||
|
res.send(new Error('You are trying to change a job that is not associated to your user.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
jobStorage.upsertJob({
|
jobStorage.upsertJob({
|
||||||
userId: req.session.currentUser,
|
userId: req.session.currentUser,
|
||||||
jobId,
|
jobId,
|
||||||
@@ -41,6 +74,7 @@ jobRouter.post('/', async (req, res) => {
|
|||||||
blacklist,
|
blacklist,
|
||||||
provider,
|
provider,
|
||||||
notificationAdapter,
|
notificationAdapter,
|
||||||
|
shareWithUsers,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.send(new Error(error));
|
res.send(new Error(error));
|
||||||
@@ -48,6 +82,7 @@ jobRouter.post('/', async (req, res) => {
|
|||||||
}
|
}
|
||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
|
|
||||||
jobRouter.delete('', async (req, res) => {
|
jobRouter.delete('', async (req, res) => {
|
||||||
const { jobId } = req.body;
|
const { jobId } = req.body;
|
||||||
try {
|
try {
|
||||||
@@ -82,4 +117,16 @@ jobRouter.put('/:jobId/status', async (req, res) => {
|
|||||||
}
|
}
|
||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
jobRouter.get('/shareableUserList', async (req, res) => {
|
||||||
|
const currentUser = req.session.currentUser;
|
||||||
|
const users = userStorage.getUsers(false);
|
||||||
|
res.body = users
|
||||||
|
.filter((user) => !user.isAdmin && user.id !== currentUser)
|
||||||
|
.map((user) => ({
|
||||||
|
id: user.id,
|
||||||
|
name: user.username,
|
||||||
|
}));
|
||||||
|
res.send();
|
||||||
|
});
|
||||||
export { jobRouter };
|
export { jobRouter };
|
||||||
|
|||||||
100
lib/api/routes/listingsRouter.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import restana from 'restana';
|
||||||
|
import * as listingStorage from '../../services/storage/listingsStorage.js';
|
||||||
|
import * as watchListStorage from '../../services/storage/watchListStorage.js';
|
||||||
|
import { isAdmin as isAdminFn } from '../security.js';
|
||||||
|
import logger from '../../services/logger.js';
|
||||||
|
import { nullOrEmpty } from '../../utils.js';
|
||||||
|
import { getJobs } from '../../services/storage/jobStorage.js';
|
||||||
|
|
||||||
|
const service = restana();
|
||||||
|
|
||||||
|
const listingsRouter = service.newRouter();
|
||||||
|
|
||||||
|
listingsRouter.get('/table', async (req, res) => {
|
||||||
|
const {
|
||||||
|
page,
|
||||||
|
pageSize = 50,
|
||||||
|
activityFilter,
|
||||||
|
jobNameFilter,
|
||||||
|
providerFilter,
|
||||||
|
watchListFilter,
|
||||||
|
sortfield = null,
|
||||||
|
sortdir = 'asc',
|
||||||
|
freeTextFilter,
|
||||||
|
} = req.query || {};
|
||||||
|
|
||||||
|
// normalize booleans (accept true, 'true', 1, '1')
|
||||||
|
const toBool = (v) => v === true || v === 'true' || v === 1 || v === '1';
|
||||||
|
const normalizedActivity = toBool(activityFilter) ? true : null;
|
||||||
|
const normalizedWatch = toBool(watchListFilter) ? true : null;
|
||||||
|
|
||||||
|
let jobFilter = null;
|
||||||
|
let jobIdFilter = null;
|
||||||
|
const jobs = getJobs();
|
||||||
|
if (!nullOrEmpty(jobNameFilter)) {
|
||||||
|
const job = jobs.find((j) => j.id === jobNameFilter);
|
||||||
|
jobFilter = job != null ? job.name : null;
|
||||||
|
jobIdFilter = job != null ? job.id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.body = listingStorage.queryListings({
|
||||||
|
page: page ? parseInt(page, 10) : 1,
|
||||||
|
pageSize: pageSize ? parseInt(pageSize, 10) : 50,
|
||||||
|
freeTextFilter: freeTextFilter || null,
|
||||||
|
activityFilter: normalizedActivity,
|
||||||
|
jobNameFilter: jobFilter,
|
||||||
|
jobIdFilter: jobIdFilter,
|
||||||
|
providerFilter,
|
||||||
|
watchListFilter: normalizedWatch,
|
||||||
|
sortField: sortfield || null,
|
||||||
|
sortDir: sortdir === 'desc' ? 'desc' : 'asc',
|
||||||
|
userId: req.session.currentUser,
|
||||||
|
isAdmin: isAdminFn(req),
|
||||||
|
});
|
||||||
|
res.send();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle watch state for the current user on a listing
|
||||||
|
listingsRouter.post('/watch', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { listingId } = req.body || {};
|
||||||
|
const userId = req.session?.currentUser;
|
||||||
|
if (!listingId || !userId) {
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.body = { message: 'listingId or user not provided' };
|
||||||
|
return res.send();
|
||||||
|
}
|
||||||
|
watchListStorage.toggleWatch(listingId, userId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.body = { message: 'Failed to toggle watch' };
|
||||||
|
}
|
||||||
|
res.send();
|
||||||
|
});
|
||||||
|
|
||||||
|
listingsRouter.delete('/job', async (req, res) => {
|
||||||
|
const { jobId } = req.body;
|
||||||
|
try {
|
||||||
|
listingStorage.deleteListingsByJobId(jobId);
|
||||||
|
} catch (error) {
|
||||||
|
res.send(new Error(error));
|
||||||
|
logger.error(error);
|
||||||
|
}
|
||||||
|
res.send();
|
||||||
|
});
|
||||||
|
|
||||||
|
listingsRouter.delete('/', async (req, res) => {
|
||||||
|
const { ids } = req.body;
|
||||||
|
try {
|
||||||
|
if (Array.isArray(ids) && ids.length > 0) {
|
||||||
|
listingStorage.deleteListingsById(ids);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
res.send(new Error(error));
|
||||||
|
logger.error(error);
|
||||||
|
}
|
||||||
|
res.send();
|
||||||
|
});
|
||||||
|
|
||||||
|
export { listingsRouter };
|
||||||
@@ -11,10 +11,12 @@ function checkIfUserToBeRemovedIsLoggedIn(userIdToBeRemoved, req) {
|
|||||||
return req.session.currentUser === userIdToBeRemoved;
|
return req.session.currentUser === userIdToBeRemoved;
|
||||||
}
|
}
|
||||||
const nullOrEmpty = (str) => str == null || str.length === 0;
|
const nullOrEmpty = (str) => str == null || str.length === 0;
|
||||||
|
|
||||||
userRouter.get('/', async (req, res) => {
|
userRouter.get('/', async (req, res) => {
|
||||||
res.body = userStorage.getUsers(false);
|
res.body = userStorage.getUsers(false);
|
||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
|
|
||||||
userRouter.get('/:userId', async (req, res) => {
|
userRouter.get('/:userId', async (req, res) => {
|
||||||
const { userId } = req.params;
|
const { userId } = req.params;
|
||||||
res.body = userStorage.getUser(userId);
|
res.body = userStorage.getUser(userId);
|
||||||
|
|||||||
38
lib/api/routes/versionRouter.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import restana from 'restana';
|
||||||
|
import fetch from 'node-fetch';
|
||||||
|
import { getPackageVersion } from '../../utils.js';
|
||||||
|
import semver from 'semver';
|
||||||
|
|
||||||
|
const service = restana();
|
||||||
|
const versionRouter = service.newRouter();
|
||||||
|
|
||||||
|
versionRouter.get('/', async (req, res) => {
|
||||||
|
const versionPayload = await getCurrentVersionFromGithub();
|
||||||
|
const localFredyVersion = await getPackageVersion();
|
||||||
|
res.body =
|
||||||
|
versionPayload == null
|
||||||
|
? {
|
||||||
|
newVersion: false,
|
||||||
|
localFredyVersion,
|
||||||
|
}
|
||||||
|
: versionPayload;
|
||||||
|
res.send();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function getCurrentVersionFromGithub() {
|
||||||
|
const raw = await fetch('https://api.github.com/repos/orangecoding/fredy/releases/latest');
|
||||||
|
const data = await raw.json();
|
||||||
|
const localFredyVersion = await getPackageVersion();
|
||||||
|
if (data.tag_name == null || semver.gte(localFredyVersion, data.tag_name)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
newVersion: true,
|
||||||
|
version: data.tag_name,
|
||||||
|
url: data.html_url,
|
||||||
|
body: data.body,
|
||||||
|
localFredyVersion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { versionRouter };
|
||||||
@@ -37,7 +37,7 @@ const cookieSession$0 = (userId) => {
|
|||||||
name: 'fredy-admin-session',
|
name: 'fredy-admin-session',
|
||||||
keys: ['fredy', 'super', 'fancy', 'key', nanoid()],
|
keys: ['fredy', 'super', 'fancy', 'key', nanoid()],
|
||||||
userId,
|
userId,
|
||||||
maxAge: 8 * 60 * 60 * 1000, // 8 hours
|
maxAge: 2 * 60 * 60 * 1000, // 2 hours
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
export { cookieSession$0 as cookieSession };
|
export { cookieSession$0 as cookieSession };
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
|||||||
const jobName = job == null ? jobKey : job.name;
|
const jobName = job == null ? jobKey : job.name;
|
||||||
const promises = newListings.map((newListing) => {
|
const promises = newListings.map((newListing) => {
|
||||||
const title = `${jobName} at ${serviceName}: ${newListing.title}`;
|
const title = `${jobName} at ${serviceName}: ${newListing.title}`;
|
||||||
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nink: ${newListing.link}`;
|
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}`;
|
||||||
return fetch(server, {
|
return fetch(server, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
130
lib/notification/adapter/discord_webhook.js
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import fetch from 'node-fetch';
|
||||||
|
import { getJob } from '../../services/storage/jobStorage.js';
|
||||||
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
|
import { normalizeImageUrl } from '../../utils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an idempotent decimal color code. The input string-based color code is
|
||||||
|
* generated using the djb2 hash algorithm.
|
||||||
|
*
|
||||||
|
* @param {string} str - Input string as color code base
|
||||||
|
* @returns {number} Generated decimal color code (0 - 16777215)
|
||||||
|
*/
|
||||||
|
const generateColorFromString = (str) => {
|
||||||
|
let hash = 5381; // initial value
|
||||||
|
const input = String(str);
|
||||||
|
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
// hash * 33 + charCode
|
||||||
|
hash = (hash << 5) + hash + input.charCodeAt(i);
|
||||||
|
// Ensure the hash is 32 bit
|
||||||
|
hash |= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let positiveHash = hash >>> 0;
|
||||||
|
const maxColorValue = 16777215;
|
||||||
|
const colorDecimal = positiveHash % maxColorValue;
|
||||||
|
|
||||||
|
return colorDecimal;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an embed per listing
|
||||||
|
* (-> see https://birdie0.github.io/discord-webhooks-guide/structure/embeds.html).
|
||||||
|
*
|
||||||
|
* @param {string} jobKey - Key of job (used to set embed color)
|
||||||
|
* @param {object} listing - Object holding listing details
|
||||||
|
* @returns {object} Discord webhook embed
|
||||||
|
*/
|
||||||
|
const buildEmbed = (jobKey, listing) => {
|
||||||
|
const maxTitleLength = 252; // Max embed title length is 256 characters
|
||||||
|
let title = String(listing.title ?? 'N/A');
|
||||||
|
if (title.length > maxTitleLength) {
|
||||||
|
title = title.substring(0, maxTitleLength) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = [
|
||||||
|
{
|
||||||
|
name: 'Price',
|
||||||
|
value: String(listing.price ?? 'n/a'),
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Size',
|
||||||
|
value: listing?.size?.replace(/2m/g, 'm²') ?? 'n/a',
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Address',
|
||||||
|
value: String(listing.address ?? 'n/a'),
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const embed = {
|
||||||
|
title: title,
|
||||||
|
color: generateColorFromString(jobKey),
|
||||||
|
url: listing.link,
|
||||||
|
fields: fields,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (listing.image) {
|
||||||
|
embed.image = {
|
||||||
|
url: normalizeImageUrl(listing.image),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
|
const adapter = notificationConfig.find((adapter) => adapter.id === config.id);
|
||||||
|
const webhookUrl = adapter?.fields?.webhookUrl;
|
||||||
|
if (!webhookUrl || newListings.length === 0) return Promise.resolve([]);
|
||||||
|
|
||||||
|
const job = getJob(jobKey);
|
||||||
|
const jobName = job?.name || jobKey;
|
||||||
|
|
||||||
|
const embeds = newListings.map((listing) => buildEmbed(jobKey, listing));
|
||||||
|
|
||||||
|
const maxEmbedsPerMessage = 10; // Discord only allows up to 10 embeds
|
||||||
|
const webhookPromises = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < embeds.length; i += maxEmbedsPerMessage) {
|
||||||
|
// Send multiple Discord messages with up to 10 embeds per message
|
||||||
|
const embedChunk = embeds.slice(i, i + maxEmbedsPerMessage);
|
||||||
|
|
||||||
|
const content = i === 0 ? `*${jobName}:* ${serviceName} found **${newListings.length}** new listings.` : '';
|
||||||
|
const body = JSON.stringify({
|
||||||
|
content: content,
|
||||||
|
embeds: embedChunk,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchPromise = fetch(webhookUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error(`Error sending Discord webhook for chunk starting at ${i}:`, error);
|
||||||
|
return Promise.reject(new Error(`Webhook failed: ${error.message}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
webhookPromises.push(fetchPromise);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.allSettled(webhookPromises);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
id: 'discord_webhook',
|
||||||
|
name: 'Discord Webhook',
|
||||||
|
readme: markdown2Html('lib/notification/adapter/discord_webhook.md'),
|
||||||
|
description: 'Fredy will send new listings to the Discord channel of your choice.',
|
||||||
|
fields: {
|
||||||
|
webhookUrl: {
|
||||||
|
type: 'text',
|
||||||
|
label: 'Webhook URL',
|
||||||
|
description: 'The URL of the Discord webhook to send messages to.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
4
lib/notification/adapter/discord_webhook.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
### Discord Adapter
|
||||||
|
|
||||||
|
To use the [Discord](https://discord.com/) Adapter, you need to create a webhook on the Discord channel of your choice. You can follow the instructions of _Making A Webhook_ on [this support website](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks).
|
||||||
|
Once you have created a webhook, copy and paste the webhook URL.
|
||||||
@@ -13,10 +13,10 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
|||||||
return fetch(webhook, {
|
return fetch(webhook, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: {
|
body: JSON.stringify({
|
||||||
channel: channel,
|
channel: channel,
|
||||||
text: message,
|
text: message,
|
||||||
},
|
}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
export const config = {
|
export const config = {
|
||||||
|
|||||||
@@ -15,11 +15,17 @@ Size: ${newListing.size == null ? 'N/A' : newListing.size.replace(/2m/g, '$m^2$'
|
|||||||
Price: ${newListing.price}
|
Price: ${newListing.price}
|
||||||
Link: ${newListing.link}`;
|
Link: ${newListing.link}`;
|
||||||
|
|
||||||
|
const sanitizeHeaderValue = (value) =>
|
||||||
|
String(value ?? '')
|
||||||
|
.replace(/[\r\n]+/g, ' ')
|
||||||
|
.replace(/[^\x20-\x7E]/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
Title: newListing.title,
|
Title: sanitizeHeaderValue(newListing.title),
|
||||||
Priority: String(priority),
|
Priority: sanitizeHeaderValue(priority),
|
||||||
Tags: `${serviceName},${jobName}`,
|
Tags: sanitizeHeaderValue(`${serviceName},${jobName}`),
|
||||||
Click: newListing.link,
|
Click: sanitizeHeaderValue(newListing.link),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (newListing.image && typeof newListing.image === 'string') {
|
if (newListing.image && typeof newListing.image === 'string') {
|
||||||
@@ -30,7 +36,17 @@ Link: ${newListing.link}`;
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body: message,
|
body: message,
|
||||||
});
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Ntfy message could not be sent. Status code: ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.text();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
// Ensure we reject with an Error object and prevent unhandled rejections
|
||||||
|
throw error instanceof Error ? error : new Error(String(error));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return Promise.all(promises);
|
return Promise.all(promises);
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ import { getJob } from '../../services/storage/jobStorage.js';
|
|||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
import pThrottle from 'p-throttle';
|
import pThrottle from 'p-throttle';
|
||||||
import { normalizeImageUrl } from '../../utils.js';
|
import { normalizeImageUrl } from '../../utils.js';
|
||||||
|
import logger from '../../services/logger.js';
|
||||||
|
|
||||||
const RATE_LIMIT_INTERVAL = 1000;
|
const RATE_LIMIT_INTERVAL = 1000;
|
||||||
const chatThrottleMap = new Map();
|
const chatThrottleMap = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes stale throttled call entries to keep memory bounded.
|
||||||
|
*/
|
||||||
function cleanupOldThrottles() {
|
function cleanupOldThrottles() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const maxAge = RATE_LIMIT_INTERVAL + 1000;
|
const maxAge = RATE_LIMIT_INTERVAL + 1000;
|
||||||
@@ -17,6 +21,15 @@ function cleanupOldThrottles() {
|
|||||||
for (const chatId of toBeDeleted) chatThrottleMap.delete(chatId);
|
for (const chatId of toBeDeleted) chatThrottleMap.delete(chatId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a throttled wrapper for a chatId to limit Telegram API calls.
|
||||||
|
* Uses p-throttle with 1 request per RATE_LIMIT_INTERVAL per chat.
|
||||||
|
*
|
||||||
|
* @template {Function} T
|
||||||
|
* @param {string|number} chatId
|
||||||
|
* @param {T} call - async function (endpoint: string, body: any) => Promise<Response>
|
||||||
|
* @returns {T}
|
||||||
|
*/
|
||||||
function getThrottled(chatId, call) {
|
function getThrottled(chatId, call) {
|
||||||
cleanupOldThrottles();
|
cleanupOldThrottles();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -30,15 +43,38 @@ function getThrottled(chatId, call) {
|
|||||||
return throttled;
|
return throttled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shorten a string to a maximum length with an ellipsis suffix.
|
||||||
|
* @param {string} str
|
||||||
|
* @param {number} [len=90]
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
function shorten(str, len = 90) {
|
function shorten(str, len = 90) {
|
||||||
if (!str) return '';
|
if (!str) return '';
|
||||||
return str.length > len ? str.substring(0, len).trim() + '...' : str;
|
return str.length > len ? str.substring(0, len).trim() + '...' : str;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape basic HTML entities for Telegram HTML parse mode.
|
||||||
|
* @param {string} [s='']
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
function escapeHtml(s = '') {
|
function escapeHtml(s = '') {
|
||||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Telegram photo caption (max 1024 characters) using HTML parse mode.
|
||||||
|
* @param {string} jobName
|
||||||
|
* @param {string} serviceName
|
||||||
|
* @param {Object} o - Listing object
|
||||||
|
* @param {string} [o.title]
|
||||||
|
* @param {string} [o.address]
|
||||||
|
* @param {string|number} [o.price]
|
||||||
|
* @param {string|number} [o.size]
|
||||||
|
* @param {string} [o.link]
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
function buildCaption(jobName, serviceName, o) {
|
function buildCaption(jobName, serviceName, o) {
|
||||||
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
||||||
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
|
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
|
||||||
@@ -47,6 +83,13 @@ function buildCaption(jobName, serviceName, o) {
|
|||||||
)}'><b>${escapeHtml(title)}</b></a>\n${escapeHtml(meta)}`.slice(0, 1024);
|
)}'><b>${escapeHtml(title)}</b></a>\n${escapeHtml(meta)}`.slice(0, 1024);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Telegram message text using HTML parse mode.
|
||||||
|
* @param {string} jobName
|
||||||
|
* @param {string} serviceName
|
||||||
|
* @param {Object} o - Listing object
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
function buildText(jobName, serviceName, o) {
|
function buildText(jobName, serviceName, o) {
|
||||||
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
||||||
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
|
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
|
||||||
@@ -57,8 +100,27 @@ function buildText(jobName, serviceName, o) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
/**
|
||||||
const { token, chatId } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
* Send new listings to Telegram.
|
||||||
|
* - Respects per-chat Telegram rate limits using a lightweight throttle cache.
|
||||||
|
* - Falls back to sendMessage when sendPhoto fails or image is missing.
|
||||||
|
*
|
||||||
|
* @param {Object} params
|
||||||
|
* @param {string} params.serviceName - Name of the crawler/service producing the listings.
|
||||||
|
* @param {Array<Object>} params.newListings - Array of new listing objects.
|
||||||
|
* @param {Array<Object>} params.notificationConfig - Notification adapters configuration array.
|
||||||
|
* @param {string} params.jobKey - Storage job key to resolve the human readable job name.
|
||||||
|
* @returns {Promise<Array<Response>>} Promise resolving when all send operations complete.
|
||||||
|
*/
|
||||||
|
export const send = ({ serviceName, newListings = [], notificationConfig, jobKey }) => {
|
||||||
|
const adapterCfg = notificationConfig.find((adapter) => adapter.id === config.id);
|
||||||
|
if (!adapterCfg || !adapterCfg.fields) {
|
||||||
|
throw new Error(`Telegram adapter configuration missing for job '${jobKey || ''}'`);
|
||||||
|
}
|
||||||
|
const { token, chatId } = adapterCfg.fields;
|
||||||
|
if (!token || !chatId) {
|
||||||
|
throw new Error("Telegram 'token' and 'chatId' must be provided in notification config");
|
||||||
|
}
|
||||||
const job = getJob(jobKey);
|
const job = getJob(jobKey);
|
||||||
const jobName = job == null ? jobKey : job.name;
|
const jobName = job == null ? jobKey : job.name;
|
||||||
|
|
||||||
@@ -68,9 +130,16 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorBody = await res.text();
|
||||||
|
throw new Error(`API error for '${jobName}'. '${endpoint}' returned ${errorBody}`);
|
||||||
|
}
|
||||||
return res;
|
return res;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!Array.isArray(newListings) || newListings.length === 0) return Promise.resolve([]);
|
||||||
|
|
||||||
const promises = newListings.map(async (o) => {
|
const promises = newListings.map(async (o) => {
|
||||||
const img = normalizeImageUrl(o.image);
|
const img = normalizeImageUrl(o.image);
|
||||||
const textPayload = {
|
const textPayload = {
|
||||||
@@ -81,28 +150,32 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!img) {
|
if (!img) {
|
||||||
return throttledCall('sendMessage', textPayload);
|
return await throttledCall('sendMessage', textPayload).catch(async (e) => {
|
||||||
|
logger.error(`Error sending message to Telegram: ${e.message}`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return await throttledCall('sendPhoto', {
|
||||||
return await throttledCall('sendPhoto', {
|
chat_id: chatId,
|
||||||
chat_id: chatId,
|
photo: img,
|
||||||
photo: img,
|
caption: buildCaption(jobName, serviceName, o),
|
||||||
caption: buildCaption(jobName, serviceName, o),
|
parse_mode: 'HTML',
|
||||||
parse_mode: 'HTML',
|
}).catch(async (e) => {
|
||||||
|
logger.error(`Error sending photo to Telegram and use a fallback: ${e.message}`);
|
||||||
|
return await throttledCall('sendMessage', textPayload).catch((e) => {
|
||||||
|
logger.error(`Error sending message to Telegram: ${e.message}`);
|
||||||
|
throw e;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
});
|
||||||
// If we see a timeout due to sending an image, try sending it without
|
|
||||||
if (e && (e.code === 'ETIMEDOUT' || e.errno === 'ETIMEDOUT')) {
|
|
||||||
return throttledCall('sendMessage', textPayload);
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return Promise.all(promises);
|
return Promise.all(promises);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram notification adapter configuration schema.
|
||||||
|
* @type {{id:string,name:string,readme:string,description:string,fields:{token:{type:string,label:string,description:string},chatId:{type:string,label:string,description:string}}}}
|
||||||
|
*/
|
||||||
export const config = {
|
export const config = {
|
||||||
id: 'telegram',
|
id: 'telegram',
|
||||||
name: 'Telegram',
|
name: 'Telegram',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import utils, { buildHash } from '../utils.js';
|
import { buildHash, isOneOf } from '../utils.js';
|
||||||
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
@@ -29,8 +30,8 @@ function normalizePrice(price) {
|
|||||||
return result[0];
|
return result[0];
|
||||||
}
|
}
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
activeTester: checkIfListingIsActive,
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
config.enabled = sourceConfig.enabled;
|
config.enabled = sourceConfig.enabled;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import utils, { buildHash } from '../utils.js';
|
import { buildHash, isOneOf } from '../utils.js';
|
||||||
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
@@ -24,8 +25,8 @@ function normalize(o) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
activeTester: checkIfListingIsActive,
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
config.enabled = sourceConfig.enabled;
|
config.enabled = sourceConfig.enabled;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import utils, { buildHash } from '../utils.js';
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
@@ -11,8 +12,8 @@ function normalize(o) {
|
|||||||
return Object.assign(o, { id, address, price, size, title, link });
|
return Object.assign(o, { id, address, price, size, title, link });
|
||||||
}
|
}
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
const config = {
|
const config = {
|
||||||
@@ -28,9 +29,11 @@ const config = {
|
|||||||
address: 'div[data-testid="cardmfe-description-box-address"] | trim',
|
address: 'div[data-testid="cardmfe-description-box-address"] | trim',
|
||||||
image: 'div[data-testid="cardmfe-picture-box-test-id"] img@src',
|
image: 'div[data-testid="cardmfe-picture-box-test-id"] img@src',
|
||||||
link: 'button@data-base',
|
link: 'button@data-base',
|
||||||
|
description: 'div[data-testid="cardmfe-description-text-test-id"] | trim',
|
||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
activeTester: checkIfListingIsActive,
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
config.enabled = sourceConfig.enabled;
|
config.enabled = sourceConfig.enabled;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* The mobile API provides the following endpoints:
|
* The mobile API provides the following endpoints:
|
||||||
* - GET /search/total?{search parameters}: Returns the total number of listings for the given query
|
* - GET /search/total?{search parameters}: Returns the total number of listings for the given query
|
||||||
* Example: `curl -H "User-Agent: ImmoScout24_1410_30_._" https://api.mobile.immobilienscout24.de/search/total?searchType=region&realestatetype=apartmentrent&pricetype=calculatedtotalrent&geocodes=%2Fde%2Fberlin%2Fberlin `
|
* Example: `curl -H "User-Agent: ImmoScout_27.3_26.0_._" https://api.mobile.immobilienscout24.de/search/total?searchType=region&realestatetype=apartmentrent&pricetype=calculatedtotalrent&geocodes=%2Fde%2Fberlin%2Fberlin `
|
||||||
*
|
*
|
||||||
* - POST /search/list?{search parameters}: Actually retrieves the listings. Body is json encoded and contains
|
* - POST /search/list?{search parameters}: Actually retrieves the listings. Body is json encoded and contains
|
||||||
* data specifying additional results (advertisements) to return. The format is as follows:
|
* data specifying additional results (advertisements) to return. The format is as follows:
|
||||||
@@ -15,12 +15,12 @@
|
|||||||
* ```
|
* ```
|
||||||
* It is not necessary to provide data for the specified keys.
|
* It is not necessary to provide data for the specified keys.
|
||||||
*
|
*
|
||||||
* Example: `curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region&geocodes=%2Fde%2Fberlin%2Fberlin&pagenumber=1' -H "Connection: keep-alive" -H "User-Agent: ImmoScout24_1410_30_._" -H "Accept: application/json" -H "Content-Type: application/json" -d '{"supportedResultListType": [], "userData": {}}'`
|
* Example: `curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region&geocodes=%2Fde%2Fberlin%2Fberlin&pagenumber=1' -H "Connection: keep-alive" -H "User-Agent: ImmoScout_27.3_26.0_._" -H "Accept: application/json" -H "Content-Type: application/json" -d '{"supportedResultListType": [], "userData": {}}'`
|
||||||
|
|
||||||
* - GET /expose/{id} - Returns the details of a listing. The response contains additional details not included in the
|
* - GET /expose/{id} - Returns the details of a listing. The response contains additional details not included in the
|
||||||
* listing response.
|
* listing response.
|
||||||
*
|
*
|
||||||
* Example: `curl -H "User-Agent: ImmoScout24_1410_30_._" "https://api.mobile.immobilienscout24.de/expose/158382494"`
|
* Example: `curl -H "User-Agent: ImmoScout_27.3_26.0_._" "https://api.mobile.immobilienscout24.de/expose/158382494"`
|
||||||
*
|
*
|
||||||
*
|
*
|
||||||
* It is necessary to set the correct User Agent (see `getListings`) in the request header.
|
* It is necessary to set the correct User Agent (see `getListings`) in the request header.
|
||||||
@@ -35,8 +35,11 @@
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import utils, { buildHash } from '../utils.js';
|
import { buildHash, isOneOf } from '../utils.js';
|
||||||
import { convertWebToMobile } from '../services/immoscout/immoscout-web-translator.js';
|
import {
|
||||||
|
convertImmoscoutListingToMobileListing,
|
||||||
|
convertWebToMobile,
|
||||||
|
} from '../services/immoscout/immoscout-web-translator.js';
|
||||||
import logger from '../services/logger.js';
|
import logger from '../services/logger.js';
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
@@ -44,7 +47,7 @@ async function getListings(url) {
|
|||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'ImmoScout24_1410_30_._',
|
'User-Agent': 'ImmoScout_27.3_26.0_._',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -77,6 +80,25 @@ async function getListings(url) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function isListingActive(link) {
|
||||||
|
const result = await fetch(convertImmoscoutListingToMobileListing(link), {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'ImmoScout_27.3_26.0_._',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status === 200) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === 404) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('Unknown status for immoscout listing', link);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
function nullOrEmpty(val) {
|
function nullOrEmpty(val) {
|
||||||
return val == null || val.length === 0;
|
return val == null || val.length === 0;
|
||||||
}
|
}
|
||||||
@@ -87,7 +109,7 @@ function normalize(o) {
|
|||||||
return Object.assign(o, { id, title, address });
|
return Object.assign(o, { id, title, address });
|
||||||
}
|
}
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
return !utils.isOneOf(o.title, appliedBlackList);
|
return !isOneOf(o.title, appliedBlackList);
|
||||||
}
|
}
|
||||||
const config = {
|
const config = {
|
||||||
url: null,
|
url: null,
|
||||||
@@ -104,6 +126,7 @@ const config = {
|
|||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
getListings: getListings,
|
getListings: getListings,
|
||||||
|
activeTester: isListingActive,
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
config.enabled = sourceConfig.enabled;
|
config.enabled = sourceConfig.enabled;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import utils, { buildHash } from '../utils.js';
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
@@ -14,8 +15,8 @@ function normalize(o) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
activeTester: checkIfListingIsActive,
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
config.enabled = sourceConfig.enabled;
|
config.enabled = sourceConfig.enabled;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import utils, { buildHash } from '../utils.js';
|
import { buildHash, isOneOf } from '../utils.js';
|
||||||
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
@@ -8,8 +9,8 @@ function normalize(o) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,11 +26,13 @@ const config = {
|
|||||||
size: 'div[data-testid="cardmfe-keyfacts-testid"] | removeNewline | trim',
|
size: 'div[data-testid="cardmfe-keyfacts-testid"] | 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',
|
||||||
address: 'div[data-testid="cardmfe-description-box-address"] | removeNewline | trim',
|
address: 'div[data-testid="cardmfe-description-box-address"] | removeNewline | trim',
|
||||||
image: 'div[data-testid="cardMfe-card-pictureBox-opacity"] img@src',
|
image: 'div[data-testid="cardmfe-picture-box-opacity-layer-test-id"] img@src',
|
||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
activeTester: checkIfListingIsActive,
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
config.enabled = sourceConfig.enabled;
|
config.enabled = sourceConfig.enabled;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import utils, { buildHash } from '../utils.js';
|
import { buildHash, isOneOf } from '../utils.js';
|
||||||
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
let appliedBlacklistedDistricts = [];
|
let appliedBlacklistedDistricts = [];
|
||||||
@@ -11,10 +12,10 @@ function normalize(o) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
const isBlacklistedDistrict =
|
const isBlacklistedDistrict =
|
||||||
appliedBlacklistedDistricts.length === 0 ? false : utils.isOneOf(o.description, appliedBlacklistedDistricts);
|
appliedBlacklistedDistricts.length === 0 ? false : isOneOf(o.description, appliedBlacklistedDistricts);
|
||||||
return o.title != null && !isBlacklistedDistrict && titleNotBlacklisted && descNotBlacklisted;
|
return o.title != null && !isBlacklistedDistrict && titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
activeTester: checkIfListingIsActive,
|
||||||
};
|
};
|
||||||
export const metaInformation = {
|
export const metaInformation = {
|
||||||
name: 'Ebay Kleinanzeigen',
|
name: 'Ebay Kleinanzeigen',
|
||||||
|
|||||||
47
lib/provider/mcMakler.js
Executable file
@@ -0,0 +1,47 @@
|
|||||||
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
function normalize(o) {
|
||||||
|
const originalId = o.id.split('/').pop();
|
||||||
|
const id = buildHash(originalId, o.price);
|
||||||
|
const size = o.size ?? 'N/A m²';
|
||||||
|
const title = o.title || 'No title available';
|
||||||
|
const address = o.address?.replace(' / ', ' ') || null;
|
||||||
|
const link = o.link != null ? `https://www.mcmakler.de${o.link}` : config.url;
|
||||||
|
return Object.assign(o, { id, size, title, link, address });
|
||||||
|
}
|
||||||
|
function applyBlacklist(o) {
|
||||||
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
|
}
|
||||||
|
const config = {
|
||||||
|
url: null,
|
||||||
|
crawlContainer: 'article[data-testid="propertyCard"]',
|
||||||
|
sortByDateParam: 'sortBy=DATE&sortOn=DESC',
|
||||||
|
waitForSelector: 'ul[data-testid="listsContainer"]',
|
||||||
|
crawlFields: {
|
||||||
|
id: 'h2 a@href',
|
||||||
|
title: 'h2 a | removeNewline | trim',
|
||||||
|
price: 'footer > p:first-of-type | trim',
|
||||||
|
size: 'footer > p:nth-of-type(2) | trim',
|
||||||
|
address: 'div > h2 + p | removeNewline | trim',
|
||||||
|
image: 'img@src',
|
||||||
|
link: 'h2 a@href',
|
||||||
|
},
|
||||||
|
normalize: normalize,
|
||||||
|
filter: applyBlacklist,
|
||||||
|
activeTester: checkIfListingIsActive,
|
||||||
|
};
|
||||||
|
export const init = (sourceConfig, blacklist) => {
|
||||||
|
config.enabled = sourceConfig.enabled;
|
||||||
|
config.url = sourceConfig.url;
|
||||||
|
appliedBlackList = blacklist || [];
|
||||||
|
};
|
||||||
|
export const metaInformation = {
|
||||||
|
name: 'McMakler',
|
||||||
|
baseUrl: 'https://www.mcmakler.de/immobilien/',
|
||||||
|
id: 'mcMakler',
|
||||||
|
};
|
||||||
|
export { config };
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import utils, { buildHash } from '../utils.js';
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
@@ -15,14 +16,14 @@ function normalize(o) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
return !utils.isOneOf(o.title, appliedBlackList);
|
return !isOneOf(o.title, appliedBlackList);
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '.col-12.mb-4',
|
crawlContainer: '.col-12.mb-4',
|
||||||
sortByDateParam: 'Sortierung=Id&Richtung=DESC',
|
sortByDateParam: 'Sortierung=Id&Richtung=DESC',
|
||||||
waitForSelector: '.nbk-section',
|
waitForSelector: 'div[data-live-name-value="SearchList"]',
|
||||||
crawlFields: {
|
crawlFields: {
|
||||||
id: 'a@href',
|
id: 'a@href',
|
||||||
title: 'a@title | removeNewline | trim',
|
title: 'a@title | removeNewline | trim',
|
||||||
@@ -33,6 +34,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
activeTester: checkIfListingIsActive,
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
config.enabled = sourceConfig.enabled;
|
config.enabled = sourceConfig.enabled;
|
||||||
|
|||||||
49
lib/provider/regionalimmobilien24.js
Executable file
@@ -0,0 +1,49 @@
|
|||||||
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
function normalize(o) {
|
||||||
|
const id = buildHash(o.id, o.price);
|
||||||
|
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;
|
||||||
|
|
||||||
|
var urlReg = new RegExp(/url\((.*?)\)/gim);
|
||||||
|
const image = o.image != null ? urlReg.exec(o.image)[1] : null;
|
||||||
|
return Object.assign(o, { id, address, title, link, image });
|
||||||
|
}
|
||||||
|
function applyBlacklist(o) {
|
||||||
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
|
}
|
||||||
|
const config = {
|
||||||
|
url: null,
|
||||||
|
crawlContainer: '.listentry-content',
|
||||||
|
sortByDateParam: null, // sort by date is standard
|
||||||
|
waitForSelector: 'body',
|
||||||
|
crawlFields: {
|
||||||
|
id: '.listentry-iconbar-share@data-sid | trim',
|
||||||
|
title: 'h2 | trim',
|
||||||
|
price: '.listentry-details-price .listentry-details-v | trim',
|
||||||
|
size: '.listentry-details-size .listentry-details-v | trim',
|
||||||
|
address: '.listentry-adress | trim',
|
||||||
|
image: '.listentry-img@style',
|
||||||
|
link: '.shariff@data-url',
|
||||||
|
description: '.listentry-extras | trim',
|
||||||
|
},
|
||||||
|
normalize: normalize,
|
||||||
|
filter: applyBlacklist,
|
||||||
|
activeTester: checkIfListingIsActive,
|
||||||
|
};
|
||||||
|
export const init = (sourceConfig, blacklist) => {
|
||||||
|
config.enabled = sourceConfig.enabled;
|
||||||
|
config.url = sourceConfig.url;
|
||||||
|
appliedBlackList = blacklist || [];
|
||||||
|
};
|
||||||
|
export const metaInformation = {
|
||||||
|
name: 'Regionalimmobilien24',
|
||||||
|
baseUrl: 'https://www.regionalimmobilien24.de/',
|
||||||
|
id: 'regionalimmobilien24',
|
||||||
|
};
|
||||||
|
export { config };
|
||||||
46
lib/provider/sparkasse.js
Executable file
@@ -0,0 +1,46 @@
|
|||||||
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
function normalize(o) {
|
||||||
|
const originalId = o.id.split('/').pop().replace('.html', '');
|
||||||
|
const id = buildHash(originalId, o.price);
|
||||||
|
const size = o.size?.replace(' Wohnfläche', '') ?? null;
|
||||||
|
const title = o.title || 'No title available';
|
||||||
|
const link = o.link != null ? `https://immobilien.sparkasse.de${o.link}` : config.url;
|
||||||
|
return Object.assign(o, { id, size, title, link });
|
||||||
|
}
|
||||||
|
function applyBlacklist(o) {
|
||||||
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
|
}
|
||||||
|
const config = {
|
||||||
|
url: null,
|
||||||
|
crawlContainer: '.estate-list-item-row',
|
||||||
|
sortByDateParam: 'sortBy=date_desc',
|
||||||
|
waitForSelector: 'body',
|
||||||
|
crawlFields: {
|
||||||
|
id: 'div[data-testid="estate-link"] a@href',
|
||||||
|
title: 'h3 | trim',
|
||||||
|
price: '.estate-list-price | trim',
|
||||||
|
size: '.estate-mainfact:first-child span | trim',
|
||||||
|
address: 'h6 | trim',
|
||||||
|
image: '.estate-list-item-image-container img@src',
|
||||||
|
link: 'div[data-testid="estate-link"] a@href',
|
||||||
|
},
|
||||||
|
normalize: normalize,
|
||||||
|
filter: applyBlacklist,
|
||||||
|
activeTester: checkIfListingIsActive,
|
||||||
|
};
|
||||||
|
export const init = (sourceConfig, blacklist) => {
|
||||||
|
config.enabled = sourceConfig.enabled;
|
||||||
|
config.url = sourceConfig.url;
|
||||||
|
appliedBlackList = blacklist || [];
|
||||||
|
};
|
||||||
|
export const metaInformation = {
|
||||||
|
name: 'Sparkasse Immobilien',
|
||||||
|
baseUrl: 'https://immobilien.sparkasse.de/',
|
||||||
|
id: 'sparkasse',
|
||||||
|
};
|
||||||
|
export { config };
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import utils, { buildHash } from '../utils.js';
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
@@ -10,8 +11,8 @@ function normalize(o) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
return o.id != null && titleNotBlacklisted && descNotBlacklisted;
|
return o.id != null && titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
activeTester: checkIfListingIsActive,
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
config.enabled = sourceConfig.enabled;
|
config.enabled = sourceConfig.enabled;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { removeJobsByUserId } from './storage/jobStorage.js';
|
import { removeJobsByUserId } from '../storage/jobStorage.js';
|
||||||
import { config } from '../utils.js';
|
import { config } from '../../utils.js';
|
||||||
import { getUsers } from './storage/userStorage.js';
|
import { getUsers } from '../storage/userStorage.js';
|
||||||
import logger from './logger.js';
|
import logger from '../logger.js';
|
||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
13
lib/services/crons/listing-alive-cron.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import cron from 'node-cron';
|
||||||
|
import runActiveChecker from '../listings/listingActiveService.js';
|
||||||
|
|
||||||
|
async function runTask() {
|
||||||
|
await runActiveChecker();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initActiveCheckerCron() {
|
||||||
|
//run directly on start
|
||||||
|
await runTask();
|
||||||
|
// then every day at 1 am
|
||||||
|
cron.schedule('0 1 * * *', runTask);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import { config, inDevMode } from '../../utils.js';
|
import { config, inDevMode } from '../../utils.js';
|
||||||
import { trackMainEvent } from './Tracker.js';
|
import { trackMainEvent } from '../tracking/Tracker.js';
|
||||||
|
|
||||||
async function runTask() {
|
async function runTask() {
|
||||||
//make sure to only send tracking events if the user gave us the green light and we are not in dev mode
|
//make sure to only send tracking events if the user gave us the green light and we are not in dev mode
|
||||||
2
lib/services/events/event-bus.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
export const bus = new EventEmitter();
|
||||||
@@ -9,19 +9,19 @@ export function loadParser(text) {
|
|||||||
|
|
||||||
export function parse(crawlContainer, crawlFields, text, url) {
|
export function parse(crawlContainer, crawlFields, text, url) {
|
||||||
if (!text) {
|
if (!text) {
|
||||||
logger.warn('No content found for ', url);
|
logger.debug('No content found for ', url);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!crawlContainer || !crawlFields) {
|
if (!crawlContainer || !crawlFields) {
|
||||||
logger.warn('Cannot parse, selector was empty for url ', url);
|
logger.debug('Cannot parse, selector was empty for url ', url);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = [];
|
const result = [];
|
||||||
|
|
||||||
if ($(crawlContainer).length === 0) {
|
if ($(crawlContainer).length === 0) {
|
||||||
logger.warn('No elements in crawl container found for url ', url);
|
logger.debug('No elements in crawl container found for url ', url);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,30 +2,56 @@ import puppeteer from 'puppeteer-extra';
|
|||||||
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
||||||
import { debug, DEFAULT_HEADER, botDetected } from './utils.js';
|
import { debug, DEFAULT_HEADER, botDetected } from './utils.js';
|
||||||
import logger from '../logger.js';
|
import logger from '../logger.js';
|
||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
puppeteer.use(StealthPlugin());
|
puppeteer.use(StealthPlugin());
|
||||||
|
|
||||||
export default async function execute(url, waitForSelector, options) {
|
export default async function execute(url, waitForSelector, options) {
|
||||||
let browser;
|
let browser;
|
||||||
|
let page;
|
||||||
|
let result = null;
|
||||||
|
let userDataDir;
|
||||||
|
let removeUserDataDir = false;
|
||||||
try {
|
try {
|
||||||
debug(`Sending request to ${url} using Puppeteer.`);
|
debug(`Sending request to ${url} using Puppeteer.`);
|
||||||
|
|
||||||
|
// Prepare a dedicated temporary userDataDir to avoid leaking /tmp/.org.chromium.* dirs
|
||||||
|
if (options && options.userDataDir) {
|
||||||
|
userDataDir = options.userDataDir;
|
||||||
|
removeUserDataDir = !!options.cleanupUserDataDir;
|
||||||
|
} else {
|
||||||
|
const prefix = path.join(os.tmpdir(), 'puppeteer-fredy-');
|
||||||
|
userDataDir = fs.mkdtempSync(prefix);
|
||||||
|
removeUserDataDir = true;
|
||||||
|
}
|
||||||
|
|
||||||
browser = await puppeteer.launch({
|
browser = await puppeteer.launch({
|
||||||
headless: options.puppeteerHeadless ?? true,
|
headless: options.puppeteerHeadless ?? true,
|
||||||
args: ['--no-sandbox', '--disable-gpu', '--disable-setuid-sandbox'],
|
args: [
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-gpu',
|
||||||
|
'--disable-setuid-sandbox',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
'--disable-crash-reporter',
|
||||||
|
],
|
||||||
timeout: options.puppeteerTimeout || 30_000,
|
timeout: options.puppeteerTimeout || 30_000,
|
||||||
|
userDataDir,
|
||||||
});
|
});
|
||||||
let page = await browser.newPage();
|
page = await browser.newPage();
|
||||||
await page.setExtraHTTPHeaders(DEFAULT_HEADER);
|
await page.setExtraHTTPHeaders(DEFAULT_HEADER);
|
||||||
const response = await page.goto(url, {
|
const response = await page.goto(url, {
|
||||||
waitUntil: 'domcontentloaded',
|
waitUntil: 'domcontentloaded',
|
||||||
});
|
});
|
||||||
let pageSource;
|
let pageSource;
|
||||||
//if we're extracting data from a spa, we must wait for the selector
|
// if we're extracting data from a SPA, we must wait for the selector
|
||||||
if (waitForSelector != null) {
|
if (waitForSelector != null) {
|
||||||
await page.waitForSelector(waitForSelector);
|
const selectorTimeout = options?.puppeteerSelectorTimeout ?? options?.puppeteerTimeout ?? 30_000;
|
||||||
|
await page.waitForSelector(waitForSelector, { timeout: selectorTimeout });
|
||||||
pageSource = await page.evaluate((selector) => {
|
pageSource = await page.evaluate((selector) => {
|
||||||
return document.querySelector(selector).innerHTML;
|
const el = document.querySelector(selector);
|
||||||
|
return el ? el.innerHTML : '';
|
||||||
}, waitForSelector);
|
}, waitForSelector);
|
||||||
} else {
|
} else {
|
||||||
pageSource = await page.content();
|
pageSource = await page.content();
|
||||||
@@ -35,16 +61,35 @@ export default async function execute(url, waitForSelector, options) {
|
|||||||
|
|
||||||
if (botDetected(pageSource, statusCode)) {
|
if (botDetected(pageSource, statusCode)) {
|
||||||
logger.warn('We have been detected as a bot :-/ Tried url: => ', url);
|
logger.warn('We have been detected as a bot :-/ Tried url: => ', url);
|
||||||
return null;
|
result = null;
|
||||||
|
} else {
|
||||||
|
result = pageSource || (await page.content());
|
||||||
}
|
}
|
||||||
|
|
||||||
return await page.content();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error executing with puppeteer executor', error);
|
logger.warn('Error executing with puppeteer executor', error);
|
||||||
return null;
|
result = null;
|
||||||
} finally {
|
} finally {
|
||||||
if (browser != null) {
|
try {
|
||||||
await browser.close();
|
if (page) {
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (browser != null) {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (removeUserDataDir && userDataDir) {
|
||||||
|
await fs.promises.rm(userDataDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ https://api.mobile.immobilienscout24.de/search/map/v3?publishedafter=2025-05-14T
|
|||||||
https://api.mobile.immobilienscout24.de/search/map/v3?features=disableNHBGrouping,nextGen,fairPrice,listingsInListFirstSummary,xxlListingType,contactDetails&publishedafter=2025-05-14T09:19:43&sorting=standard&pagesize=300&searchType=shape&realEstateType=housebuy&pagenumber=1&shape=%7D%7BjwHy%7Cqh@jCKdCgAvB_BdB%7DBzAaCjAqCfAqC~@uCt@iCh@eCZkCLyC?_EO%7DEa@%7DEa@iE_@%7BD%5DaDe@gDi@gDo@uCu@kBcB_AeDOiE?iDCgCMuBOkDCkG?yFRgD%60@cB%5C%7BA%60@eBx@aB%7C@kAbAy@rAe@bBUxCAhE?dFh@fGlAzGbBbHlBxGdB%60FrAhDz@xBh@nAf@l@RNNXkCkMJR~B%7CEnCpErCnDtClCvC~ApCh@rCJpC?
|
https://api.mobile.immobilienscout24.de/search/map/v3?features=disableNHBGrouping,nextGen,fairPrice,listingsInListFirstSummary,xxlListingType,contactDetails&publishedafter=2025-05-14T09:19:43&sorting=standard&pagesize=300&searchType=shape&realEstateType=housebuy&pagenumber=1&shape=%7D%7BjwHy%7Cqh@jCKdCgAvB_BdB%7DBzAaCjAqCfAqC~@uCt@iCh@eCZkCLyC?_EO%7DEa@%7DEa@iE_@%7BD%5DaDe@gDi@gDo@uCu@kBcB_AeDOiE?iDCgCMuBOkDCkG?yFRgD%60@cB%5C%7BA%60@eBx@aB%7C@kAbAy@rAe@bBUxCAhE?dFh@fGlAzGbBbHlBxGdB%60FrAhDz@xBh@nAf@l@RNNXkCkMJR~B%7CEnCpErCnDtClCvC~ApCh@rCJpC?
|
||||||
*/
|
*/
|
||||||
import queryString from 'query-string';
|
import queryString from 'query-string';
|
||||||
|
import { nullOrEmpty } from '../../utils.js';
|
||||||
|
|
||||||
const PARAM_NAME_MAP = {
|
const PARAM_NAME_MAP = {
|
||||||
heatingtypes: 'heatingtypes',
|
heatingtypes: 'heatingtypes',
|
||||||
@@ -193,3 +194,14 @@ export function convertWebToMobile(webUrl) {
|
|||||||
|
|
||||||
return `https://api.mobile.immobilienscout24.de/search/list?${mobileQuery}`;
|
return `https://api.mobile.immobilienscout24.de/search/list?${mobileQuery}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function convertImmoscoutListingToMobileListing(url) {
|
||||||
|
if (nullOrEmpty(url)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.replace(
|
||||||
|
/^https:\/\/www\.immobilienscout24\.de\/expose\//,
|
||||||
|
'https://api.mobile.immobilienscout24.de/expose/',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
104
lib/services/listings/listingActiveService.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { deactivateListings, getActiveOrUnknownListings } from '../storage/listingsStorage.js';
|
||||||
|
import { getProviders } from '../../utils.js';
|
||||||
|
import logger from '../../services/logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the active-listing checker:
|
||||||
|
* 1) Loads all listings with unknown or active status.
|
||||||
|
* 2) Resolves each listing's provider and calls its `activeTester(link)`.
|
||||||
|
* 3) Collects listings that are no longer active and deactivates them in one batch.
|
||||||
|
*
|
||||||
|
* Concurrency: network-bound checks are executed with a configurable concurrency limit.
|
||||||
|
*
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {number} [opts.concurrency=8] Max number of parallel activeTester calls.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export default async function runActiveChecker(opts = {}) {
|
||||||
|
const { concurrency = 4 } = opts;
|
||||||
|
|
||||||
|
const listings = getActiveOrUnknownListings();
|
||||||
|
if (!Array.isArray(listings) || listings.length === 0) {
|
||||||
|
logger.debug('No listings to check.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers = await getProviders();
|
||||||
|
if (!Array.isArray(providers) || providers.length === 0) {
|
||||||
|
logger.warn('No providers available. Skipping active checks.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a map for O(1) provider lookup by id
|
||||||
|
/** @type {Record<string, any>} */
|
||||||
|
const providerById = Object.create(null);
|
||||||
|
for (const p of providers) {
|
||||||
|
const id = p?.metaInformation?.id;
|
||||||
|
if (id) providerById[id] = p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small generic mapLimit to cap concurrency without extra deps
|
||||||
|
/**
|
||||||
|
* @template T, R
|
||||||
|
* @param {T[]} items
|
||||||
|
* @param {number} limit
|
||||||
|
* @param {(item: T, index: number) => Promise<R>} worker
|
||||||
|
* @returns {Promise<R[]>}
|
||||||
|
*/
|
||||||
|
async function mapLimit(items, limit, worker) {
|
||||||
|
const results = new Array(items.length);
|
||||||
|
let next = 0;
|
||||||
|
|
||||||
|
async function runOne() {
|
||||||
|
while (next < items.length) {
|
||||||
|
const i = next++;
|
||||||
|
try {
|
||||||
|
results[i] = await worker(items[i], i);
|
||||||
|
} catch (err) {
|
||||||
|
results[i] = /** @type {any} */ (err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runners = Array.from({ length: Math.min(limit, items.length) }, runOne);
|
||||||
|
await Promise.all(runners);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {string[]} */
|
||||||
|
const listingsSetToInactive = [];
|
||||||
|
|
||||||
|
await mapLimit(listings, concurrency, async (listing) => {
|
||||||
|
const { provider: listingProviderId, link, id } = listing || {};
|
||||||
|
|
||||||
|
const matchedProvider = providerById[listingProviderId];
|
||||||
|
if (!matchedProvider) {
|
||||||
|
logger.warn('Could not find matching provider for', listingProviderId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tester = matchedProvider?.config?.activeTester;
|
||||||
|
if (typeof tester !== 'function') {
|
||||||
|
logger.warn('No activeTester configured for', listingProviderId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contract: activeTester(link) returns 1 if active, 0 if inactive
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await tester(link);
|
||||||
|
} catch {
|
||||||
|
result = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result === 0 && id) {
|
||||||
|
listingsSetToInactive.push(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (listingsSetToInactive.length > 0) {
|
||||||
|
logger.info(`Setting ${listingsSetToInactive.length} listings to inactive.`);
|
||||||
|
deactivateListings(listingsSetToInactive);
|
||||||
|
} else {
|
||||||
|
logger.debug('No listings need to be set inactive.');
|
||||||
|
}
|
||||||
|
}
|
||||||
68
lib/services/listings/listingActiveTester.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import fetch from 'node-fetch';
|
||||||
|
import { randomBetween, sleep } from '../../utils.js';
|
||||||
|
|
||||||
|
const maxAttempts = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a listing is still active with up to 3 attempts and exponential backoff.
|
||||||
|
* Backoff waits are capped and the last wait is at most 2000 ms.
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* - HTTP 200 => return 1
|
||||||
|
* - HTTP 401/403 => return -1 (most certainly detected as a bot)
|
||||||
|
* - HTTP 404 => return 0
|
||||||
|
* - Other statuses or network errors => retry until attempts are exhausted
|
||||||
|
*
|
||||||
|
* @returns {Promise<Integer>} 1 if active, o if not active and -1 if detected as bot
|
||||||
|
*/
|
||||||
|
export default async function checkIfListingIsActive(link) {
|
||||||
|
await sleep(randomBetween(50, 100));
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(link, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36',
|
||||||
|
'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 200) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (res.status === 401) return -1;
|
||||||
|
if (res.status === 403) return -1;
|
||||||
|
if (res.status === 404) return 0;
|
||||||
|
|
||||||
|
// For any other status, only retry if attempts remain
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
await sleep(backoffDelay(attempt));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
} catch {
|
||||||
|
// Network error: retry if attempts remain
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
await sleep(backoffDelay(attempt));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exponential backoff delay with cap.
|
||||||
|
* attempt: 1 -> 500ms, 2 -> 1000ms, 3 -> 2000ms (cap)
|
||||||
|
* @param {number} attempt 1-based attempt index
|
||||||
|
* @returns {number} delay in ms
|
||||||
|
*/
|
||||||
|
function backoffDelay(attempt) {
|
||||||
|
const base = 500;
|
||||||
|
const cap = 2000;
|
||||||
|
return Math.min(base * 2 ** (attempt - 1), cap);
|
||||||
|
}
|
||||||
@@ -16,7 +16,16 @@ import { toJson, fromJson } from '../../utils.js';
|
|||||||
* @param {string} params.userId - Owner user id for inserts; preserved on updates.
|
* @param {string} params.userId - Owner user id for inserts; preserved on updates.
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
export const upsertJob = ({ jobId, name, blacklist = [], enabled = true, provider, notificationAdapter, userId }) => {
|
export const upsertJob = ({
|
||||||
|
jobId,
|
||||||
|
name,
|
||||||
|
blacklist = [],
|
||||||
|
enabled = true,
|
||||||
|
provider,
|
||||||
|
notificationAdapter,
|
||||||
|
userId,
|
||||||
|
shareWithUsers = [],
|
||||||
|
}) => {
|
||||||
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];
|
||||||
const ownerId = existing ? existing.user_id : userId;
|
const ownerId = existing ? existing.user_id : userId;
|
||||||
@@ -27,21 +36,23 @@ export const upsertJob = ({ jobId, name, blacklist = [], enabled = true, provide
|
|||||||
name = @name,
|
name = @name,
|
||||||
blacklist = @blacklist,
|
blacklist = @blacklist,
|
||||||
provider = @provider,
|
provider = @provider,
|
||||||
notification_adapter = @notification_adapter
|
notification_adapter = @notification_adapter,
|
||||||
|
shared_with_user = @shareWithUsers
|
||||||
WHERE id = @id`,
|
WHERE id = @id`,
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
enabled: enabled ? 1 : 0,
|
enabled: enabled ? 1 : 0,
|
||||||
name: name ?? null,
|
name: name ?? null,
|
||||||
blacklist: toJson(blacklist ?? []),
|
blacklist: toJson(blacklist ?? []),
|
||||||
|
shareWithUsers: toJson(shareWithUsers ?? []),
|
||||||
provider: toJson(provider ?? []),
|
provider: toJson(provider ?? []),
|
||||||
notification_adapter: toJson(notificationAdapter ?? []),
|
notification_adapter: toJson(notificationAdapter ?? []),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
SqliteConnection.execute(
|
SqliteConnection.execute(
|
||||||
`INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter)
|
`INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter, shared_with_user)
|
||||||
VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter)`,
|
VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter, @shareWithUsers)`,
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
user_id: ownerId,
|
user_id: ownerId,
|
||||||
@@ -49,6 +60,7 @@ export const upsertJob = ({ jobId, name, blacklist = [], enabled = true, provide
|
|||||||
name: name ?? null,
|
name: name ?? null,
|
||||||
blacklist: toJson(blacklist ?? []),
|
blacklist: toJson(blacklist ?? []),
|
||||||
provider: toJson(provider ?? []),
|
provider: toJson(provider ?? []),
|
||||||
|
shareWithUsers: toJson(shareWithUsers ?? []),
|
||||||
notification_adapter: toJson(notificationAdapter ?? []),
|
notification_adapter: toJson(notificationAdapter ?? []),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -129,6 +141,7 @@ export const getJobs = () => {
|
|||||||
j.name,
|
j.name,
|
||||||
j.blacklist,
|
j.blacklist,
|
||||||
j.provider,
|
j.provider,
|
||||||
|
j.shared_with_user,
|
||||||
j.notification_adapter AS notificationAdapter,
|
j.notification_adapter AS notificationAdapter,
|
||||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
|
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
|
||||||
FROM jobs j
|
FROM jobs j
|
||||||
@@ -139,6 +152,7 @@ export const getJobs = () => {
|
|||||||
enabled: !!row.enabled,
|
enabled: !!row.enabled,
|
||||||
blacklist: fromJson(row.blacklist, []),
|
blacklist: fromJson(row.blacklist, []),
|
||||||
provider: fromJson(row.provider, []),
|
provider: fromJson(row.provider, []),
|
||||||
|
shared_with_user: fromJson(row.shared_with_user, []),
|
||||||
notificationAdapter: fromJson(row.notificationAdapter, []),
|
notificationAdapter: fromJson(row.notificationAdapter, []),
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -48,11 +48,44 @@ export const getKnownListingHashesForJobAndProvider = (jobId, providerId) => {
|
|||||||
return SqliteConnection.query(
|
return SqliteConnection.query(
|
||||||
`SELECT hash
|
`SELECT hash
|
||||||
FROM listings
|
FROM listings
|
||||||
WHERE job_id = @jobId AND provider = @providerId`,
|
WHERE job_id = @jobId
|
||||||
|
AND provider = @providerId`,
|
||||||
{ jobId, providerId },
|
{ jobId, providerId },
|
||||||
).map((r) => r.hash);
|
).map((r) => r.hash);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a list of listing that either are active or have an unknown status
|
||||||
|
* to constantly check if they are still online
|
||||||
|
*
|
||||||
|
* @returns {string[]} Array of listings
|
||||||
|
*/
|
||||||
|
export const getActiveOrUnknownListings = () => {
|
||||||
|
return SqliteConnection.query(
|
||||||
|
`SELECT *
|
||||||
|
FROM listings
|
||||||
|
WHERE is_active is null
|
||||||
|
OR is_active = 1
|
||||||
|
ORDER BY provider`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deactivates listings by setting is_active = 0 for all matching IDs.
|
||||||
|
*
|
||||||
|
* @param {string[]} ids - Array of listing IDs to deactivate.
|
||||||
|
* @returns {object[]} Result of the SQLite query execution.
|
||||||
|
*/
|
||||||
|
export const deactivateListings = (ids) => {
|
||||||
|
const placeholders = ids.map(() => '?').join(',');
|
||||||
|
return SqliteConnection.execute(
|
||||||
|
`UPDATE listings
|
||||||
|
SET is_active = 0
|
||||||
|
WHERE id IN (${placeholders})`,
|
||||||
|
ids,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist a batch of scraped listings for a given job and provider.
|
* Persist a batch of scraped listings for a given job and provider.
|
||||||
*
|
*
|
||||||
@@ -85,11 +118,11 @@ 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, city,
|
`INSERT INTO listings (id, hash, provider, job_id, price, size, title, image_url, description, address,
|
||||||
link, created_at)
|
link, created_at, is_active)
|
||||||
VALUES (@id, @hash, @provider, @job_id, @price, @size, @title, @image_url, @description, @address, @city, @link,
|
VALUES (@id, @hash, @provider, @job_id, @price, @size, @title, @image_url, @description, @address, @link,
|
||||||
@created_at)
|
@created_at, 1)
|
||||||
ON CONFLICT(hash) DO NOTHING`,
|
ON CONFLICT(job_id, hash) DO NOTHING`,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const item of listings) {
|
for (const item of listings) {
|
||||||
@@ -136,3 +169,166 @@ export const storeListings = (jobId, providerId, listings) => {
|
|||||||
return str.replace(/\s*\([^)]*\)/g, '');
|
return str.replace(/\s*\([^)]*\)/g, '');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query listings with pagination, filtering and sorting.
|
||||||
|
*
|
||||||
|
* @param {Object} params
|
||||||
|
* @param {number} [params.pageSize=50]
|
||||||
|
* @param {number} [params.page=1]
|
||||||
|
* @param {string} [params.freeTextFilter]
|
||||||
|
* @param {object} [params.activityFilter]
|
||||||
|
* @param {object} [params.jobNameFilter]
|
||||||
|
* @param {object} [params.providerFilter]
|
||||||
|
* @param {object} [params.watchListFilter]
|
||||||
|
* @param {string|null} [params.sortField=null] - One of: 'created_at','price','size','provider','title'.
|
||||||
|
* @param {('asc'|'desc')} [params.sortDir='asc']
|
||||||
|
* @param {string} [params.userId] - Current user id used to scope listings (ignored for admins).
|
||||||
|
* @param {boolean} [params.isAdmin=false] - When true, returns all listings.
|
||||||
|
* @returns {{ totalNumber:number, page:number, result:Object[] }}
|
||||||
|
*/
|
||||||
|
export const queryListings = ({
|
||||||
|
pageSize = 50,
|
||||||
|
page = 1,
|
||||||
|
activityFilter,
|
||||||
|
jobNameFilter,
|
||||||
|
jobIdFilter,
|
||||||
|
providerFilter,
|
||||||
|
watchListFilter,
|
||||||
|
freeTextFilter,
|
||||||
|
sortField = null,
|
||||||
|
sortDir = 'asc',
|
||||||
|
userId = null,
|
||||||
|
isAdmin = false,
|
||||||
|
} = {}) => {
|
||||||
|
// sanitize inputs
|
||||||
|
const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(500, Math.floor(pageSize)) : 50;
|
||||||
|
const safePage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1;
|
||||||
|
const offset = (safePage - 1) * safePageSize;
|
||||||
|
|
||||||
|
// build WHERE filter across common text columns
|
||||||
|
const whereParts = [];
|
||||||
|
const params = { limit: safePageSize, offset };
|
||||||
|
// always provide userId param for watched-flag evaluation (null -> no matches)
|
||||||
|
params.userId = userId || '__NO_USER__';
|
||||||
|
// user scoping (non-admin only): restrict to listings whose job belongs to user
|
||||||
|
if (!isAdmin) {
|
||||||
|
// Include listings from jobs owned by the user or jobs shared with the user
|
||||||
|
whereParts.push(
|
||||||
|
`(j.user_id = @userId OR EXISTS (SELECT 1 FROM json_each(j.shared_with_user) AS sw WHERE sw.value = @userId))`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (freeTextFilter && String(freeTextFilter).trim().length > 0) {
|
||||||
|
params.filter = `%${String(freeTextFilter).trim()}%`;
|
||||||
|
whereParts.push(`(title LIKE @filter OR address LIKE @filter OR provider LIKE @filter OR link LIKE @filter)`);
|
||||||
|
}
|
||||||
|
// activityFilter: when true -> only active listings (is_active = 1)
|
||||||
|
if (activityFilter === true) {
|
||||||
|
whereParts.push('(is_active = 1)');
|
||||||
|
}
|
||||||
|
// Prefer filtering by job id when provided (unambiguous and robust)
|
||||||
|
if (jobIdFilter && String(jobIdFilter).trim().length > 0) {
|
||||||
|
params.jobId = String(jobIdFilter).trim();
|
||||||
|
whereParts.push('(l.job_id = @jobId)');
|
||||||
|
} else if (jobNameFilter && String(jobNameFilter).trim().length > 0) {
|
||||||
|
// Fallback to exact job name match
|
||||||
|
params.jobName = String(jobNameFilter).trim();
|
||||||
|
whereParts.push('(j.name = @jobName)');
|
||||||
|
}
|
||||||
|
// providerFilter: when provided as string (assumed provider name), filter listings where provider equals that name (exact match)
|
||||||
|
if (providerFilter && String(providerFilter).trim().length > 0) {
|
||||||
|
params.providerName = String(providerFilter).trim();
|
||||||
|
whereParts.push('(provider = @providerName)');
|
||||||
|
}
|
||||||
|
// watchListFilter: when true -> only watched listings
|
||||||
|
if (watchListFilter === true) {
|
||||||
|
whereParts.push('(wl.id IS NOT NULL)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||||||
|
const whereSqlWithAlias = whereSql
|
||||||
|
.replace(/\btitle\b/g, 'l.title')
|
||||||
|
.replace(/\bdescription\b/g, 'l.description')
|
||||||
|
.replace(/\baddress\b/g, 'l.address')
|
||||||
|
.replace(/\bprovider\b/g, 'l.provider')
|
||||||
|
.replace(/\blink\b/g, 'l.link')
|
||||||
|
.replace(/\bis_active\b/g, 'l.is_active')
|
||||||
|
.replace(/\bj\.user_id\b/g, 'j.user_id')
|
||||||
|
.replace(/\bj\.name\b/g, 'j.name')
|
||||||
|
.replace(/\bwl\.id\b/g, 'wl.id');
|
||||||
|
|
||||||
|
// whitelist sortable fields to avoid SQL injection
|
||||||
|
const sortable = new Set(['created_at', 'price', 'size', 'provider', 'title', 'job_name', 'is_active', 'isWatched']);
|
||||||
|
const safeSortField = sortField && sortable.has(sortField) ? sortField : null;
|
||||||
|
const safeSortDir = String(sortDir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
||||||
|
const orderSql = safeSortField ? `ORDER BY ${safeSortField} ${safeSortDir}` : 'ORDER BY created_at DESC';
|
||||||
|
const orderSqlWithAlias = orderSql
|
||||||
|
.replace(/\bcreated_at\b/g, 'l.created_at')
|
||||||
|
.replace(/\bprice\b/g, 'l.price')
|
||||||
|
.replace(/\bsize\b/g, 'l.size')
|
||||||
|
.replace(/\bprovider\b/g, 'l.provider')
|
||||||
|
.replace(/\btitle\b/g, 'l.title')
|
||||||
|
.replace(/\bjob_name\b/g, 'j.name')
|
||||||
|
// Sort by computed watch flag when requested
|
||||||
|
.replace(/\bisWatched\b/g, 'CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END');
|
||||||
|
|
||||||
|
// count total with same WHERE
|
||||||
|
const countRow = SqliteConnection.query(
|
||||||
|
`SELECT COUNT(1) as cnt
|
||||||
|
FROM listings l
|
||||||
|
LEFT JOIN jobs j ON j.id = l.job_id
|
||||||
|
LEFT JOIN watch_list wl ON wl.listing_id = l.id AND wl.user_id = @userId
|
||||||
|
${whereSqlWithAlias}`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
const totalNumber = countRow?.[0]?.cnt ?? 0;
|
||||||
|
|
||||||
|
// fetch page
|
||||||
|
const rows = SqliteConnection.query(
|
||||||
|
`SELECT l.*,
|
||||||
|
j.name AS job_name,
|
||||||
|
CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END AS isWatched
|
||||||
|
FROM listings l
|
||||||
|
LEFT JOIN jobs j ON j.id = l.job_id
|
||||||
|
LEFT JOIN watch_list wl ON wl.listing_id = l.id AND wl.user_id = @userId
|
||||||
|
${whereSqlWithAlias}
|
||||||
|
${orderSqlWithAlias}
|
||||||
|
LIMIT @limit OFFSET @offset`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { totalNumber, page: safePage, result: rows };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all listings for a given job id.
|
||||||
|
*
|
||||||
|
* @param {string} jobId - The job identifier whose listings should be removed.
|
||||||
|
* @returns {any} The result from SqliteConnection.execute (may contain changes count).
|
||||||
|
*/
|
||||||
|
export const deleteListingsByJobId = (jobId) => {
|
||||||
|
if (!jobId) return;
|
||||||
|
return SqliteConnection.execute(
|
||||||
|
`DELETE
|
||||||
|
FROM listings
|
||||||
|
WHERE job_id = @jobId`,
|
||||||
|
{ jobId },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete listings by a list of listing IDs.
|
||||||
|
*
|
||||||
|
* @param {string[]} ids - Array of listing IDs to delete.
|
||||||
|
* @returns {any} The result from SqliteConnection.execute.
|
||||||
|
*/
|
||||||
|
export const deleteListingsById = (ids) => {
|
||||||
|
if (!Array.isArray(ids) || ids.length === 0) return;
|
||||||
|
const placeholders = ids.map(() => '?').join(',');
|
||||||
|
return SqliteConnection.execute(
|
||||||
|
`DELETE
|
||||||
|
FROM listings
|
||||||
|
WHERE id IN (${placeholders})`,
|
||||||
|
ids,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Migration: there needs to be a unique index on job_id and hash as only
|
||||||
|
// this makes the listing indeed unique
|
||||||
|
|
||||||
|
export function up(db) {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE listings ADD COLUMN is_active INTEGER DEFAULT 1;
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Migration: there needs to be a unique index on job_id and hash as only
|
||||||
|
// this makes the listing indeed unique
|
||||||
|
|
||||||
|
export function up(db) {
|
||||||
|
db.exec(`
|
||||||
|
DROP INDEX IF EXISTS idx_listings_hash;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_listings_job_hash
|
||||||
|
ON listings (job_id, hash);
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Migration: Adding a changeset field to the listings table in preparation for
|
||||||
|
// a price watch feature
|
||||||
|
|
||||||
|
export function up(db) {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE listings ADD COLUMN change_set jsonb;
|
||||||
|
`);
|
||||||
|
}
|
||||||
15
lib/services/storage/migrations/sql/4.watch-list.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// Migration: Adding a new table to store if somebody "watches" (a.k.a favorite) a listing
|
||||||
|
|
||||||
|
export function up(db) {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS watch_list
|
||||||
|
(
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
listing_id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (listing_id) REFERENCES listings (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_watch_list ON watch_list (listing_id, user_id);
|
||||||
|
`);
|
||||||
|
}
|
||||||
7
lib/services/storage/migrations/sql/5.job-sharing.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// Migration: Adding a new table to store if somebody shared a job with someone
|
||||||
|
|
||||||
|
export function up(db) {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE jobs ADD COLUMN shared_with_user jsonb DEFAULT '[]'
|
||||||
|
`);
|
||||||
|
}
|
||||||
64
lib/services/storage/watchListStorage.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import SqliteConnection from './SqliteConnection.js';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a watch entry. Idempotent due to unique index (listing_id, user_id).
|
||||||
|
* @param {string} listingId
|
||||||
|
* @param {string} userId
|
||||||
|
* @returns {{created:boolean}}
|
||||||
|
*/
|
||||||
|
export const createWatch = (listingId, userId) => {
|
||||||
|
if (!listingId || !userId) return { created: false };
|
||||||
|
try {
|
||||||
|
SqliteConnection.execute(
|
||||||
|
`INSERT INTO watch_list (id, listing_id, user_id)
|
||||||
|
VALUES (@id, @listing_id, @user_id)
|
||||||
|
ON CONFLICT(listing_id, user_id) DO NOTHING`,
|
||||||
|
{ id: nanoid(), listing_id: listingId, user_id: userId },
|
||||||
|
);
|
||||||
|
// check whether it exists now
|
||||||
|
const row = SqliteConnection.query(
|
||||||
|
`SELECT 1 AS ok FROM watch_list WHERE listing_id = @listing_id AND user_id = @user_id LIMIT 1`,
|
||||||
|
{ listing_id: listingId, user_id: userId },
|
||||||
|
);
|
||||||
|
return { created: row.length > 0 };
|
||||||
|
} catch {
|
||||||
|
return { created: false };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a watch entry.
|
||||||
|
* @param {string} listingId
|
||||||
|
* @param {string} userId
|
||||||
|
* @returns {{deleted:boolean}}
|
||||||
|
*/
|
||||||
|
export const deleteWatch = (listingId, userId) => {
|
||||||
|
if (!listingId || !userId) return { deleted: false };
|
||||||
|
const res = SqliteConnection.execute(`DELETE FROM watch_list WHERE listing_id = @listing_id AND user_id = @user_id`, {
|
||||||
|
listing_id: listingId,
|
||||||
|
user_id: userId,
|
||||||
|
});
|
||||||
|
return { deleted: Boolean(res?.changes) };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle a watch entry. If exists -> delete, otherwise create.
|
||||||
|
* @param {string} listingId
|
||||||
|
* @param {string} userId
|
||||||
|
* @returns {{watched:boolean}}
|
||||||
|
*/
|
||||||
|
export const toggleWatch = (listingId, userId) => {
|
||||||
|
if (!listingId || !userId) return { watched: false };
|
||||||
|
const exists =
|
||||||
|
SqliteConnection.query(
|
||||||
|
`SELECT 1 AS ok FROM watch_list WHERE listing_id = @listing_id AND user_id = @user_id LIMIT 1`,
|
||||||
|
{ listing_id: listingId, user_id: userId },
|
||||||
|
).length > 0;
|
||||||
|
if (exists) {
|
||||||
|
deleteWatch(listingId, userId);
|
||||||
|
return { watched: false };
|
||||||
|
}
|
||||||
|
createWatch(listingId, userId);
|
||||||
|
return { watched: true };
|
||||||
|
};
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import { getJobs } from '../storage/jobStorage.js';
|
import { getJobs } from '../storage/jobStorage.js';
|
||||||
import { getUniqueId } from './uniqueId.js';
|
import { getUniqueId } from './uniqueId.js';
|
||||||
import { config, inDevMode } from '../../utils.js';
|
import { config, getPackageVersion, inDevMode } from '../../utils.js';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import { readFileSync } from 'fs';
|
|
||||||
import { packageUp } from 'package-up';
|
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
import logger from '../logger.js';
|
import logger from '../logger.js';
|
||||||
|
|
||||||
@@ -77,15 +75,3 @@ function enrichTrackingObject(trackingObject) {
|
|||||||
version,
|
version,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getPackageVersion() {
|
|
||||||
try {
|
|
||||||
const packagePath = await packageUp();
|
|
||||||
const packageJson = readFileSync(packagePath, 'utf8');
|
|
||||||
const json = JSON.parse(packageJson);
|
|
||||||
return json.version;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error reading version from package.json', error);
|
|
||||||
}
|
|
||||||
return 'N/A';
|
|
||||||
}
|
|
||||||
|
|||||||
135
lib/utils.js
@@ -1,15 +1,37 @@
|
|||||||
import { dirname } from 'node:path';
|
import { dirname } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
import { readFile } from 'fs/promises';
|
import { readFile } from 'fs/promises';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { DEFAULT_CONFIG } from './defaultConfig.js';
|
import { DEFAULT_CONFIG } from './defaultConfig.js';
|
||||||
import fs from 'fs';
|
import fs, { readFileSync } from 'fs';
|
||||||
import logger from './services/logger.js';
|
import logger from './services/logger.js';
|
||||||
|
import { packageUp } from 'package-up';
|
||||||
|
|
||||||
const RE_GT = />/g;
|
const RE_GT = />/g;
|
||||||
const RE_WEBP = /\/format\/webp/gi;
|
const RE_WEBP = /\/format\/webp/gi;
|
||||||
const RE_EXT = /\.(jpe?g|png|gif)(\?.*)?$/i;
|
const RE_EXT = /\.(jpe?g|png|gif)(\?.*)?$/i;
|
||||||
const HTTPS_PREFIX = 'https://';
|
const HTTPS_PREFIX = 'https://';
|
||||||
|
const providersDirectoryPath = `${getDirName()}/provider`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily load all provider modules from the provider directory.
|
||||||
|
* Caches the resolved array to avoid re-importing on subsequent calls.
|
||||||
|
*
|
||||||
|
* @returns {Promise<any[]>} A list of loaded provider modules.
|
||||||
|
*/
|
||||||
|
let cachedProvidersPromise = null;
|
||||||
|
|
||||||
|
export function getProviders() {
|
||||||
|
if (!cachedProvidersPromise) {
|
||||||
|
/** @type {string[]} */
|
||||||
|
const providerFileNames = fs.readdirSync(providersDirectoryPath).filter((fileName) => fileName.endsWith('.js'));
|
||||||
|
cachedProvidersPromise = Promise.all(
|
||||||
|
providerFileNames.map((fileName) => import(pathToFileURL(path.join(providersDirectoryPath, fileName)).href)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return cachedProvidersPromise;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely stringify a value to JSON for storage.
|
* Safely stringify a value to JSON for storage.
|
||||||
@@ -20,7 +42,7 @@ const HTTPS_PREFIX = 'https://';
|
|||||||
* @param {T} v - Any JSON-serializable value.
|
* @param {T} v - Any JSON-serializable value.
|
||||||
* @returns {string|null} JSON string or null.
|
* @returns {string|null} JSON string or null.
|
||||||
*/
|
*/
|
||||||
export const toJson = (v) => (v == null ? null : JSON.stringify(v));
|
const toJson = (v) => (v == null ? null : JSON.stringify(v));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely parse JSON text coming from storage.
|
* Safely parse JSON text coming from storage.
|
||||||
@@ -32,7 +54,7 @@ export const toJson = (v) => (v == null ? null : JSON.stringify(v));
|
|||||||
* @param {T} fallback - Value to return when txt is null/invalid.
|
* @param {T} fallback - Value to return when txt is null/invalid.
|
||||||
* @returns {T} Parsed value or fallback.
|
* @returns {T} Parsed value or fallback.
|
||||||
*/
|
*/
|
||||||
export const fromJson = (txt, fallback) => {
|
const fromJson = (txt, fallback) => {
|
||||||
if (txt == null) return fallback;
|
if (txt == null) return fallback;
|
||||||
try {
|
try {
|
||||||
return JSON.parse(txt);
|
return JSON.parse(txt);
|
||||||
@@ -87,11 +109,22 @@ function timeStringToMs(timeString, now) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether current time is within configured working hours, or no hours are set.
|
* Determine whether the given timestamp is within the configured working hours, or return true when the window is not set.
|
||||||
* If working hours are missing or incomplete, returns true.
|
* - If workingHours is missing or either 'from' or 'to' is empty/null, returns true.
|
||||||
* @param {{workingHours?: {from?: string, to?: string}}} config
|
* - Supports windows that cross midnight (e.g., from '23:00' to '06:00').
|
||||||
* @param {number} now - Epoch ms
|
*
|
||||||
* @returns {boolean}
|
* Time parsing is based on the local timezone of the running process.
|
||||||
|
*
|
||||||
|
* @param {{workingHours?: {from?: string|null, to?: string|null}}} config - Configuration object containing working hours in 'HH:mm' format.
|
||||||
|
* @param {number} now - Epoch milliseconds to evaluate.
|
||||||
|
* @returns {boolean} True when execution is allowed at 'now'.
|
||||||
|
* @example
|
||||||
|
* // Same-day window
|
||||||
|
* duringWorkingHoursOrNotSet({ workingHours: { from: '08:00', to: '17:00' } }, someTime);
|
||||||
|
* @example
|
||||||
|
* // Window crossing midnight
|
||||||
|
* // For { from: '05:00', to: '00:30' } → 23:00 => true, 01:00 => false, 06:00 => true
|
||||||
|
* duringWorkingHoursOrNotSet({ workingHours: { from: '05:00', to: '00:30' } }, Date.now());
|
||||||
*/
|
*/
|
||||||
function duringWorkingHoursOrNotSet(config, now) {
|
function duringWorkingHoursOrNotSet(config, now) {
|
||||||
const { workingHours } = config;
|
const { workingHours } = config;
|
||||||
@@ -100,7 +133,20 @@ function duringWorkingHoursOrNotSet(config, now) {
|
|||||||
}
|
}
|
||||||
const toDate = timeStringToMs(workingHours.to, now);
|
const toDate = timeStringToMs(workingHours.to, now);
|
||||||
const fromDate = timeStringToMs(workingHours.from, now);
|
const fromDate = timeStringToMs(workingHours.from, now);
|
||||||
return fromDate <= now && toDate >= now;
|
|
||||||
|
// If parsing fails (e.g., malformed time), be lenient and allow.
|
||||||
|
if (isNaN(toDate) || isNaN(fromDate)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toDate >= fromDate) {
|
||||||
|
// Same-day window (e.g., 08:00 - 17:00)
|
||||||
|
return now >= fromDate && now <= toDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Window crosses midnight (e.g., 05:00 -> 00:30 next day)
|
||||||
|
// Accept if we are after 'from' today OR before 'to' today (which represents next day's cutoff).
|
||||||
|
return now >= fromDate || now <= toDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,6 +180,23 @@ function buildHash(...inputs) {
|
|||||||
*/
|
*/
|
||||||
let config = {};
|
let config = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the config exists, but cannot be accessed, we quit Fredy as something is fishy here.
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
export async function checkIfConfigIsAccessible() {
|
||||||
|
const path = new URL('../conf/config.json', import.meta.url);
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(path)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
fs.accessSync(path, fs.constants.R_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read config JSON from disk (conf/config.json) and parse it.
|
* Read config JSON from disk (conf/config.json) and parse it.
|
||||||
* @returns {Promise<any>} Parsed configuration object.
|
* @returns {Promise<any>} Parsed configuration object.
|
||||||
@@ -196,22 +259,56 @@ const normalizeImageUrl = (url) => {
|
|||||||
return u;
|
return u;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* returns Fredy's version
|
||||||
|
* @returns {Promise<*|string>}
|
||||||
|
*/
|
||||||
|
async function getPackageVersion() {
|
||||||
|
try {
|
||||||
|
const packagePath = await packageUp();
|
||||||
|
const packageJson = readFileSync(packagePath, 'utf8');
|
||||||
|
const json = JSON.parse(packageJson);
|
||||||
|
return json.version;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error reading version from package.json', error);
|
||||||
|
}
|
||||||
|
return 'N/A';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleep helper
|
||||||
|
* @param {number} ms milliseconds to wait
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a random integer between min and max (inclusive).
|
||||||
|
* @param {number} min - Minimum integer value.
|
||||||
|
* @param {number} max - Maximum integer value.
|
||||||
|
* @returns {number} A random integer N where min <= N <= max.
|
||||||
|
*/
|
||||||
|
function randomBetween(min, max) {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call refreshConfig() from the application entrypoint during startup to populate config.
|
||||||
await refreshConfig();
|
await refreshConfig();
|
||||||
|
|
||||||
export { isOneOf };
|
export {
|
||||||
export { normalizeImageUrl };
|
|
||||||
export { inDevMode };
|
|
||||||
export { nullOrEmpty };
|
|
||||||
export { duringWorkingHoursOrNotSet };
|
|
||||||
export { getDirName };
|
|
||||||
export { config };
|
|
||||||
export { buildHash };
|
|
||||||
export default {
|
|
||||||
isOneOf,
|
isOneOf,
|
||||||
|
normalizeImageUrl,
|
||||||
|
inDevMode,
|
||||||
nullOrEmpty,
|
nullOrEmpty,
|
||||||
duringWorkingHoursOrNotSet,
|
duringWorkingHoursOrNotSet,
|
||||||
getDirName,
|
getDirName,
|
||||||
|
sleep,
|
||||||
|
randomBetween,
|
||||||
config,
|
config,
|
||||||
|
buildHash,
|
||||||
|
getPackageVersion,
|
||||||
toJson,
|
toJson,
|
||||||
fromJson,
|
fromJson,
|
||||||
};
|
};
|
||||||
|
|||||||
47
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fredy",
|
"name": "fredy",
|
||||||
"version": "12.0.2",
|
"version": "14.2.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",
|
||||||
@@ -56,61 +56,58 @@
|
|||||||
"Firefox ESR"
|
"Firefox ESR"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@douyinfe/semi-icons": "^2.86.0",
|
||||||
"@douyinfe/semi-ui": "2.86.0",
|
"@douyinfe/semi-ui": "2.86.0",
|
||||||
"@rematch/core": "2.2.0",
|
"@sendgrid/mail": "8.1.6",
|
||||||
"@rematch/loading": "2.1.2",
|
"@visactor/react-vchart": "^2.0.5",
|
||||||
"@sendgrid/mail": "8.1.5",
|
"@visactor/vchart": "^2.0.5",
|
||||||
"@visactor/react-vchart": "^2.0.4",
|
|
||||||
"@visactor/vchart": "^2.0.4",
|
|
||||||
"@visactor/vchart-semi-theme": "^1.12.2",
|
"@visactor/vchart-semi-theme": "^1.12.2",
|
||||||
"@vitejs/plugin-react": "5.0.3",
|
"@vitejs/plugin-react": "5.0.4",
|
||||||
"better-sqlite3": "^12.2.0",
|
"better-sqlite3": "^12.4.1",
|
||||||
"body-parser": "2.2.0",
|
"body-parser": "2.2.0",
|
||||||
"cheerio": "^1.1.2",
|
"cheerio": "^1.1.2",
|
||||||
"cookie-session": "2.1.1",
|
"cookie-session": "2.1.1",
|
||||||
"handlebars": "4.7.8",
|
"handlebars": "4.7.8",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"markdown": "^0.5.0",
|
"markdown": "^0.5.0",
|
||||||
"nanoid": "5.1.5",
|
"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.9",
|
"node-mailjet": "6.0.9",
|
||||||
"p-throttle": "^8.0.0",
|
"p-throttle": "^8.0.0",
|
||||||
"package-up": "^5.0.0",
|
"package-up": "^5.0.0",
|
||||||
"puppeteer": "^24.22.0",
|
"puppeteer": "^24.24.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.0",
|
"query-string": "9.3.1",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-redux": "9.2.0",
|
"react-router": "7.9.4",
|
||||||
"react-router": "7.9.1",
|
"react-router-dom": "7.9.4",
|
||||||
"react-router-dom": "7.9.1",
|
|
||||||
"redux": "5.0.1",
|
|
||||||
"redux-thunk": "3.1.0",
|
|
||||||
"restana": "5.1.0",
|
"restana": "5.1.0",
|
||||||
|
"semver": "^7.7.3",
|
||||||
"serve-static": "2.2.0",
|
"serve-static": "2.2.0",
|
||||||
"slack": "11.0.2",
|
"slack": "11.0.2",
|
||||||
"vite": "7.1.6",
|
"vite": "7.1.9",
|
||||||
"x-var": "^3.0.1"
|
"x-var": "^3.0.1",
|
||||||
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.28.4",
|
"@babel/core": "7.28.4",
|
||||||
"@babel/eslint-parser": "7.28.4",
|
"@babel/eslint-parser": "7.28.4",
|
||||||
"@babel/preset-env": "7.28.3",
|
"@babel/preset-env": "7.28.3",
|
||||||
"@babel/preset-react": "7.27.1",
|
"@babel/preset-react": "7.27.1",
|
||||||
"chai": "6.0.1",
|
"chai": "6.2.0",
|
||||||
"eslint": "9.35.0",
|
"eslint": "9.37.0",
|
||||||
"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",
|
"esmock": "2.7.3",
|
||||||
"history": "5.3.0",
|
"history": "5.3.0",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"less": "4.4.1",
|
"less": "4.4.2",
|
||||||
"lint-staged": "16.1.6",
|
"lint-staged": "16.2.4",
|
||||||
"mocha": "11.7.2",
|
"mocha": "11.7.4",
|
||||||
"nodemon": "^3.1.10",
|
"nodemon": "^3.1.10",
|
||||||
"prettier": "3.6.2",
|
"prettier": "3.6.2"
|
||||||
"redux-logger": "3.0.6"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ Challenges:
|
|||||||
_Returns the total number of listings for the given query._
|
_Returns the total number of listings for the given query._
|
||||||
|
|
||||||
```
|
```
|
||||||
curl -H "User-Agent: ImmoScout24_1410_30_._" \
|
curl -H "User-Agent: ImmoScout_27.3_26.0_._" \
|
||||||
-H "Accept: application/json" \
|
-H "Accept: application/json" \
|
||||||
"https://api.mobile.immobilienscout24.de/search/total?searchType=region&realestatetype=apartmentrent&pricetype=calculatedtotalrent&geocodes=%2Fde%2Fberlin%2Fberlin"
|
"https://api.mobile.immobilienscout24.de/search/total?searchType=region&realestatetype=apartmentrent&pricetype=calculatedtotalrent&geocodes=%2Fde%2Fberlin%2Fberlin"
|
||||||
```
|
```
|
||||||
@@ -63,7 +63,7 @@ _The body is json encoded and contains data specifying additional results (adver
|
|||||||
```
|
```
|
||||||
curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region&geocodes=%2Fde%2Fberlin%2Fberlin&pagenumber=1' \
|
curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region&geocodes=%2Fde%2Fberlin%2Fberlin&pagenumber=1' \
|
||||||
-H "Connection: keep-alive" \
|
-H "Connection: keep-alive" \
|
||||||
-H "User-Agent: ImmoScout24_1410_30_._" \
|
-H "User-Agent: ImmoScout_27.3_26.0_._" \
|
||||||
-H "Accept: application/json" \
|
-H "Accept: application/json" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"supportedResultListType":[],"userData":{}}'
|
-d '{"supportedResultListType":[],"userData":{}}'
|
||||||
@@ -78,7 +78,7 @@ curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calc
|
|||||||
The response contains additional details not included in the listing response.
|
The response contains additional details not included in the listing response.
|
||||||
|
|
||||||
```
|
```
|
||||||
curl -H "User-Agent: ImmoScout24_1410_30_._" \
|
curl -H "User-Agent: ImmoScout_27.3_26.0_._" \
|
||||||
-H "Accept: application/json" \
|
-H "Accept: application/json" \
|
||||||
"https://api.mobile.immobilienscout24.de/expose/158382494"
|
"https://api.mobile.immobilienscout24.de/expose/158382494"
|
||||||
```
|
```
|
||||||
|
|||||||
53
test/FredyPipeline/FredyPipeline.test.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { expect } from 'chai';
|
||||||
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
|
import { mockFredy } from '../utils.js';
|
||||||
|
|
||||||
|
describe('FredyPipeline', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
similarityCache.invalidateAllForTest();
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
similarityCache.stopCacheCleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_filterBySimilarListings', () => {
|
||||||
|
let fredyRuntime;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const FredyRuntime = await mockFredy();
|
||||||
|
fredyRuntime = new FredyRuntime({}, null, 'dummy-provider', 'dummy-job', similarityCache);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter out listings with similar title and address already in cache', () => {
|
||||||
|
similarityCache.addCacheEntry('Penthouse', 'Mustermann Straße 1');
|
||||||
|
|
||||||
|
const listings = [
|
||||||
|
{ id: '1', title: 'Penthouse', address: 'Mustermann Straße 1' },
|
||||||
|
{ id: '2', title: 'Nice apartment', address: 'Mustermann Straße 15' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = fredyRuntime._filterBySimilarListings(listings);
|
||||||
|
|
||||||
|
expect(result).to.have.length(1);
|
||||||
|
expect(result[0].id).to.equal('2');
|
||||||
|
expect(result[0].title).to.equal('Nice apartment');
|
||||||
|
|
||||||
|
expect(similarityCache.hasSimilarEntries('Nice apartment', 'Mustermann Straße 15')).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle listings with null or undefined address', () => {
|
||||||
|
const listings = [
|
||||||
|
{ id: '1', title: 'Penthouse', address: null },
|
||||||
|
{ id: '2', title: 'Nice apartment', address: undefined },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = fredyRuntime._filterBySimilarListings(listings);
|
||||||
|
|
||||||
|
expect(result).to.have.length(2);
|
||||||
|
|
||||||
|
expect(similarityCache.hasSimilarEntries('Penthouse', null)).to.be.true;
|
||||||
|
expect(similarityCache.hasSimilarEntries('Nice apartment', undefined)).to.be.true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,31 +8,30 @@ describe('#immonet testsuite()', () => {
|
|||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
});
|
});
|
||||||
provider.init(providerConfig.immonet, [], []);
|
|
||||||
it('should test immonet provider', async () => {
|
it('should test immonet provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
return await new Promise((resolve) => {
|
provider.init(providerConfig.immonet, [], []);
|
||||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immonet', similarityCache);
|
|
||||||
fredy.execute().then((listing) => {
|
|
||||||
expect(listing).to.be.a('array');
|
|
||||||
const notificationObj = get();
|
|
||||||
expect(notificationObj).to.be.a('object');
|
|
||||||
expect(notificationObj.serviceName).to.equal('immonet');
|
|
||||||
notificationObj.payload.forEach((notify) => {
|
|
||||||
/** check the actual structure **/
|
|
||||||
expect(notify.id).to.be.a('string');
|
|
||||||
expect(notify.price).to.be.a('string');
|
|
||||||
expect(notify.size).to.be.a('string');
|
|
||||||
expect(notify.title).to.be.a('string');
|
|
||||||
expect(notify.link).to.be.a('string');
|
|
||||||
expect(notify.address).to.be.a('string');
|
|
||||||
|
|
||||||
expect(notify.size).that.does.include('m²');
|
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immonet', similarityCache);
|
||||||
expect(notify.title).to.be.not.empty;
|
const listing = await fredy.execute();
|
||||||
expect(notify.address).to.be.not.empty;
|
|
||||||
});
|
expect(listing).to.be.a('array');
|
||||||
resolve();
|
const notificationObj = get();
|
||||||
});
|
expect(notificationObj).to.be.a('object');
|
||||||
|
expect(notificationObj.serviceName).to.equal('immonet');
|
||||||
|
notificationObj.payload.forEach((notify) => {
|
||||||
|
/** check the actual structure **/
|
||||||
|
expect(notify.id).to.be.a('string');
|
||||||
|
expect(notify.price).to.be.a('string');
|
||||||
|
expect(notify.size).to.be.a('string');
|
||||||
|
expect(notify.title).to.be.a('string');
|
||||||
|
expect(notify.link).to.be.a('string');
|
||||||
|
expect(notify.address).to.be.a('string');
|
||||||
|
/** check the values if possible **/
|
||||||
|
expect(notify.size).that.does.include('m²');
|
||||||
|
expect(notify.title).to.be.not.empty;
|
||||||
|
expect(notify.address).to.be.not.empty;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,33 +8,32 @@ describe('#immowelt testsuite()', () => {
|
|||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should test immowelt provider', async () => {
|
it('should test immowelt provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
provider.init(providerConfig.immowelt, [], []);
|
provider.init(providerConfig.immowelt, [], []);
|
||||||
return await new Promise((resolve) => {
|
|
||||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immowelt', similarityCache);
|
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immowelt', similarityCache);
|
||||||
fredy.execute().then((listing) => {
|
const listing = await fredy.execute();
|
||||||
expect(listing).to.be.a('array');
|
|
||||||
const notificationObj = get();
|
expect(listing).to.be.a('array');
|
||||||
expect(notificationObj).to.be.a('object');
|
const notificationObj = get();
|
||||||
expect(notificationObj.serviceName).to.equal('immowelt');
|
expect(notificationObj).to.be.a('object');
|
||||||
notificationObj.payload.forEach((notify) => {
|
expect(notificationObj.serviceName).to.equal('immowelt');
|
||||||
/** check the actual structure **/
|
notificationObj.payload.forEach((notify) => {
|
||||||
expect(notify.id).to.be.a('string');
|
/** check the actual structure **/
|
||||||
expect(notify.price).to.be.a('string');
|
expect(notify.id).to.be.a('string');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.price).to.be.a('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.title).to.be.a('string');
|
||||||
expect(notify.address).to.be.a('string');
|
expect(notify.link).to.be.a('string');
|
||||||
/** check the values if possible **/
|
expect(notify.address).to.be.a('string');
|
||||||
if (notify.size != null && notify.size.trim().toLowerCase() !== 'k.a.') {
|
/** check the values if possible **/
|
||||||
expect(notify.size).that.does.include('m²');
|
if (notify.size != null && notify.size.trim().toLowerCase() !== 'k.a.') {
|
||||||
}
|
expect(notify.size).that.does.include('m²');
|
||||||
expect(notify.title).to.be.not.empty;
|
}
|
||||||
expect(notify.link).that.does.include('https://www.immowelt.de');
|
expect(notify.title).to.be.not.empty;
|
||||||
expect(notify.address).to.be.not.empty;
|
expect(notify.link).that.does.include('https://www.immowelt.de');
|
||||||
});
|
expect(notify.address).to.be.not.empty;
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
37
test/provider/mcMakler.test.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
|
import { get } from '../mocks/mockNotification.js';
|
||||||
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
|
import { expect } from 'chai';
|
||||||
|
import * as provider from '../../lib/provider/mcMakler.js';
|
||||||
|
|
||||||
|
describe('#mcMakler testsuite()', () => {
|
||||||
|
after(() => {
|
||||||
|
similarityCache.stopCacheCleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should test mcMakler provider', async () => {
|
||||||
|
const Fredy = await mockFredy();
|
||||||
|
provider.init(providerConfig.mcMakler, []);
|
||||||
|
|
||||||
|
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'mcMakler', similarityCache);
|
||||||
|
const listing = await fredy.execute();
|
||||||
|
|
||||||
|
expect(listing).to.be.a('array');
|
||||||
|
const notificationObj = get();
|
||||||
|
expect(notificationObj).to.be.a('object');
|
||||||
|
expect(notificationObj.serviceName).to.equal('mcMakler');
|
||||||
|
notificationObj.payload.forEach((notify) => {
|
||||||
|
/** check the actual structure **/
|
||||||
|
expect(notify.id).to.be.a('string');
|
||||||
|
expect(notify.price).to.be.a('string');
|
||||||
|
expect(notify.size).to.be.a('string');
|
||||||
|
expect(notify.title).to.be.a('string');
|
||||||
|
expect(notify.link).to.be.a('string');
|
||||||
|
expect(notify.address).to.be.a('string');
|
||||||
|
/** check the values if possible **/
|
||||||
|
expect(notify.size).that.does.include('m²');
|
||||||
|
expect(notify.title).to.be.not.empty;
|
||||||
|
expect(notify.address).to.be.not.empty;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
43
test/provider/regionalimmobilien24.test.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
|
import { get } from '../mocks/mockNotification.js';
|
||||||
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
|
import { expect } from 'chai';
|
||||||
|
import * as provider from '../../lib/provider/regionalimmobilien24.js';
|
||||||
|
|
||||||
|
describe('#regionalimmobilien24 testsuite()', () => {
|
||||||
|
after(() => {
|
||||||
|
similarityCache.stopCacheCleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should test regionalimmobilien24 provider', async () => {
|
||||||
|
const Fredy = await mockFredy();
|
||||||
|
provider.init(providerConfig.regionalimmobilien24, []);
|
||||||
|
|
||||||
|
const fredy = new Fredy(
|
||||||
|
provider.config,
|
||||||
|
null,
|
||||||
|
provider.metaInformation.id,
|
||||||
|
'regionalimmobilien24',
|
||||||
|
similarityCache,
|
||||||
|
);
|
||||||
|
const listing = await fredy.execute();
|
||||||
|
|
||||||
|
expect(listing).to.be.a('array');
|
||||||
|
const notificationObj = get();
|
||||||
|
expect(notificationObj).to.be.a('object');
|
||||||
|
expect(notificationObj.serviceName).to.equal('regionalimmobilien24');
|
||||||
|
notificationObj.payload.forEach((notify) => {
|
||||||
|
/** check the actual structure **/
|
||||||
|
expect(notify.id).to.be.a('string');
|
||||||
|
expect(notify.price).to.be.a('string');
|
||||||
|
expect(notify.size).to.be.a('string');
|
||||||
|
expect(notify.title).to.be.a('string');
|
||||||
|
expect(notify.link).to.be.a('string');
|
||||||
|
expect(notify.address).to.be.a('string');
|
||||||
|
/** check the values if possible **/
|
||||||
|
expect(notify.size).that.does.include('m²');
|
||||||
|
expect(notify.title).to.be.not.empty;
|
||||||
|
expect(notify.address).to.be.not.empty;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
37
test/provider/sparkasse.test.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
|
import { get } from '../mocks/mockNotification.js';
|
||||||
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
|
import { expect } from 'chai';
|
||||||
|
import * as provider from '../../lib/provider/sparkasse.js';
|
||||||
|
|
||||||
|
describe('#sparkasse testsuite()', () => {
|
||||||
|
after(() => {
|
||||||
|
similarityCache.stopCacheCleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should test sparkasse provider', async () => {
|
||||||
|
const Fredy = await mockFredy();
|
||||||
|
provider.init(providerConfig.sparkasse, []);
|
||||||
|
|
||||||
|
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'sparkasse', similarityCache);
|
||||||
|
const listing = await fredy.execute();
|
||||||
|
|
||||||
|
expect(listing).to.be.a('array');
|
||||||
|
const notificationObj = get();
|
||||||
|
expect(notificationObj).to.be.a('object');
|
||||||
|
expect(notificationObj.serviceName).to.equal('sparkasse');
|
||||||
|
notificationObj.payload.forEach((notify) => {
|
||||||
|
/** check the actual structure **/
|
||||||
|
expect(notify.id).to.be.a('string');
|
||||||
|
expect(notify.price).to.be.a('string');
|
||||||
|
expect(notify.size).to.be.a('string');
|
||||||
|
expect(notify.title).to.be.a('string');
|
||||||
|
expect(notify.link).to.be.a('string');
|
||||||
|
expect(notify.address).to.be.a('string');
|
||||||
|
/** check the values if possible **/
|
||||||
|
expect(notify.size).that.does.include('m²');
|
||||||
|
expect(notify.title).to.be.not.empty;
|
||||||
|
expect(notify.address).to.be.not.empty;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,10 +28,22 @@
|
|||||||
"url": "https://www.kleinanzeigen.de/s-immobilien/duesseldorf/anzeige:angebote/wohnung/k0c195l2068r5",
|
"url": "https://www.kleinanzeigen.de/s-immobilien/duesseldorf/anzeige:angebote/wohnung/k0c195l2068r5",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
|
"mcMakler": {
|
||||||
|
"url": "https://www.mcmakler.de/immobilien/results?placeId=62649&search=Leipzig%252C+Sachsen&propertyTypes=APARTMENT&page=0",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
"neubauKompass": {
|
"neubauKompass": {
|
||||||
"url": "https://www.neubaukompass.de/neubau-immobilien/duesseldorf-region/eigentumswohnung/",
|
"url": "https://www.neubaukompass.de/neubau-immobilien/duesseldorf-region/eigentumswohnung/",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
|
"regionalimmobilien24": {
|
||||||
|
"url": "https://www.regionalimmobilien24.de/rostock/rostock/kaufen/haus/-/-/-/?rd=5",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"sparkasse": {
|
||||||
|
"url": "https://immobilien.sparkasse.de/immobilien/treffer?marketingType=buy&objectType=flat&perimeter=10&usageType=residential&zipCityEstateId=62782__Hamburg",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
"wgGesucht": {
|
"wgGesucht": {
|
||||||
"url": "https://www.wg-gesucht.de/wg-zimmer-in-Duesseldorf.30.0.1.0.html",
|
"url": "https://www.wg-gesucht.de/wg-zimmer-in-Duesseldorf.30.0.1.0.html",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import utils 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 'chai';
|
||||||
|
|
||||||
@@ -8,30 +8,45 @@ const fakeWorkingHoursConfig = (from, to) => ({
|
|||||||
from,
|
from,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('utils', () => {
|
describe('utils', () => {
|
||||||
describe('#isOneOf()', () => {
|
describe('#isOneOf()', () => {
|
||||||
it('should be false', () => {
|
it('should be false', () => {
|
||||||
assert.equal(utils.isOneOf('bla', ['blub']), false);
|
assert.equal(isOneOf('bla', ['blub']), false);
|
||||||
});
|
});
|
||||||
it('should be true', () => {
|
it('should be true', () => {
|
||||||
assert.equal(utils.isOneOf('bla blub blubber', ['bla']), true);
|
assert.equal(isOneOf('bla blub blubber', ['bla']), true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe('#duringWorkingHoursOrNotSet()', () => {
|
describe('#duringWorkingHoursOrNotSet()', () => {
|
||||||
it('should be false', () => {
|
it('should be false', () => {
|
||||||
expect(utils.duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('12:00', '13:00'), 0)).to.be.false;
|
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('12:00', '13:00'), 0)).to.be.false;
|
||||||
});
|
});
|
||||||
it('should be true', () => {
|
it('should be true', () => {
|
||||||
expect(utils.duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('10:00', '16:00'), 1622026740000)).to.be.true;
|
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('10:00', '16:00'), 1622026740000)).to.be.true;
|
||||||
});
|
});
|
||||||
it('should be true if nothing set', () => {
|
it('should be true if nothing set', () => {
|
||||||
expect(utils.duringWorkingHoursOrNotSet(fakeWorkingHoursConfig(null, null), 1622026740000)).to.be.true;
|
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig(null, null), 1622026740000)).to.be.true;
|
||||||
});
|
});
|
||||||
it('should be true if only to is set', () => {
|
it('should be true if only to is set', () => {
|
||||||
expect(utils.duringWorkingHoursOrNotSet(fakeWorkingHoursConfig(null, '13:00'), 1622026740000)).to.be.true;
|
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig(null, '13:00'), 1622026740000)).to.be.true;
|
||||||
});
|
});
|
||||||
it('should be true if only from is set', () => {
|
it('should be true if only from is set', () => {
|
||||||
expect(utils.duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('12:00', null), 1622026740000)).to.be.true;
|
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('12:00', null), 1622026740000)).to.be.true;
|
||||||
|
});
|
||||||
|
it('should handle working hours that cross midnight (e.g., 05:00 → 00:30)', () => {
|
||||||
|
const cfg = fakeWorkingHoursConfig('05:00', '00:30');
|
||||||
|
const mkTs = (h, m = 0) => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(h);
|
||||||
|
d.setMinutes(m);
|
||||||
|
d.setSeconds(0);
|
||||||
|
d.setMilliseconds(0);
|
||||||
|
return d.getTime();
|
||||||
|
};
|
||||||
|
expect(duringWorkingHoursOrNotSet(cfg, mkTs(23, 0))).to.be.true; // 23:00 => within window
|
||||||
|
expect(duringWorkingHoursOrNotSet(cfg, mkTs(1, 0))).to.be.false; // 01:00 => outside window
|
||||||
|
expect(duringWorkingHoursOrNotSet(cfg, mkTs(6, 0))).to.be.true; // 06:00 => within window
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ describe('#immoscout-mobile URL conversion', () => {
|
|||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'ImmoScout24_1410_30_._',
|
'User-Agent': 'ImmoScout_27.3_26.0_._',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ 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)));
|
||||||
|
|
||||||
export const mockFredy = async () => {
|
export const mockFredy = async () => {
|
||||||
return await esmock('../lib/FredyRuntime', {
|
return await esmock('../lib/FredyPipeline', {
|
||||||
'../lib/services/storage/listingsStorage.js': {
|
'../lib/services/storage/listingsStorage.js': {
|
||||||
...mockStore,
|
...mockStore,
|
||||||
},
|
},
|
||||||
|
|||||||
172
ui/src/App.jsx
@@ -6,34 +6,41 @@ import GeneralSettings from './views/generalSettings/GeneralSettings';
|
|||||||
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 JobInsight from './views/jobs/insights/JobInsight.jsx';
|
import JobInsight from './views/jobs/insights/JobInsight.jsx';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useActions, useSelector } from './services/state/store';
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import Logout from './components/logout/Logout';
|
|
||||||
import Logo from './components/logo/Logo';
|
|
||||||
import Menu from './components/menu/Menu';
|
|
||||||
import Login from './views/login/Login';
|
import Login from './views/login/Login';
|
||||||
import Users from './views/user/Users';
|
import Users from './views/user/Users';
|
||||||
import Jobs from './views/jobs/Jobs';
|
import Jobs from './views/jobs/Jobs';
|
||||||
|
|
||||||
import './App.less';
|
import './App.less';
|
||||||
import TrackingModal from './components/tracking/TrackingModal.jsx';
|
import TrackingModal from './components/tracking/TrackingModal.jsx';
|
||||||
import { Banner } from '@douyinfe/semi-ui';
|
import { Banner, Divider } from '@douyinfe/semi-ui';
|
||||||
|
import VersionBanner from './components/version/VersionBanner.jsx';
|
||||||
|
import Listings from './views/listings/Listings.jsx';
|
||||||
|
import Navigation from './components/navigation/Navigation.jsx';
|
||||||
|
import { Layout } from '@douyinfe/semi-ui';
|
||||||
|
import FredyFooter from './components/footer/FredyFooter.jsx';
|
||||||
|
import ProcessingTimes from './views/jobs/ProcessingTimes.jsx';
|
||||||
|
|
||||||
export default function FredyApp() {
|
export default function FredyApp() {
|
||||||
const dispatch = useDispatch();
|
const actions = useActions();
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const currentUser = useSelector((state) => state.user.currentUser);
|
const currentUser = useSelector((state) => state.user.currentUser);
|
||||||
|
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
|
||||||
const settings = useSelector((state) => state.generalSettings.settings);
|
const settings = useSelector((state) => state.generalSettings.settings);
|
||||||
|
const processingTimes = useSelector((state) => state.jobs.processingTimes);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function init() {
|
async function init() {
|
||||||
await dispatch.user.getCurrentUser();
|
await actions.user.getCurrentUser();
|
||||||
if (!needsLogin()) {
|
if (!needsLogin()) {
|
||||||
await dispatch.provider.getProvider();
|
await actions.provider.getProvider();
|
||||||
await dispatch.jobs.getJobs();
|
await actions.jobs.getJobs();
|
||||||
await dispatch.jobs.getProcessingTimes();
|
await actions.jobs.getProcessingTimes();
|
||||||
await dispatch.notificationAdapter.getAdapter();
|
await actions.jobs.getSharableUserList();
|
||||||
await dispatch.generalSettings.getGeneralSettings();
|
await actions.notificationAdapter.getAdapter();
|
||||||
|
await actions.generalSettings.getGeneralSettings();
|
||||||
|
await actions.versionUpdate.getVersionUpdate();
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -46,81 +53,88 @@ export default function FredyApp() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isAdmin = () => currentUser != null && currentUser.isAdmin;
|
const isAdmin = () => currentUser != null && currentUser.isAdmin;
|
||||||
|
const { Footer, Sider, Content } = Layout;
|
||||||
|
|
||||||
const login = () => (
|
return loading ? null : needsLogin() ? (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
|
||||||
|
|
||||||
return loading ? null : needsLogin() ? (
|
|
||||||
login()
|
|
||||||
) : (
|
) : (
|
||||||
<div className="app">
|
<Layout className="app">
|
||||||
<div className="app__container">
|
<Layout className="app">
|
||||||
<Logout />
|
<Sider>
|
||||||
<Logo width={190} white />
|
<Navigation isAdmin={isAdmin()} />
|
||||||
<Menu isAdmin={isAdmin()} />
|
</Sider>
|
||||||
|
<Content>
|
||||||
|
{versionUpdate?.newVersion && <VersionBanner />}
|
||||||
|
{settings.demoMode && (
|
||||||
|
<>
|
||||||
|
<Banner
|
||||||
|
fullMode={true}
|
||||||
|
type="info"
|
||||||
|
bordered
|
||||||
|
closeIcon={null}
|
||||||
|
description="You're currently viewing the demo version of Fredy. Jobs won't scrape websites, and any changes you make will be reverted at midnight."
|
||||||
|
/>
|
||||||
|
<br />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />}
|
||||||
|
{processingTimes != null && <ProcessingTimes processingTimes={processingTimes} />}
|
||||||
|
<Divider />
|
||||||
|
<div className="app__content">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/403" element={<InsufficientPermission />} />
|
||||||
|
<Route path="/jobs/new" element={<JobMutation />} />
|
||||||
|
<Route path="/jobs/edit/:jobId" element={<JobMutation />} />
|
||||||
|
<Route path="/jobs/insights/:jobId" element={<JobInsight />} />
|
||||||
|
<Route path="/jobs" element={<Jobs />} />
|
||||||
|
<Route path="/listings" element={<Listings />} />
|
||||||
|
|
||||||
{settings.demoMode && (
|
{/* Permission-aware routes */}
|
||||||
<>
|
<Route
|
||||||
<Banner
|
path="/users/new"
|
||||||
fullMode={true}
|
element={
|
||||||
type="info"
|
<PermissionAwareRoute currentUser={currentUser}>
|
||||||
bordered
|
<UserMutator />
|
||||||
closeIcon={null}
|
</PermissionAwareRoute>
|
||||||
description="You're currently viewing the demo version of Fredy. Jobs won't scrape websites, and any changes you make will be reverted at midnight."
|
}
|
||||||
/>
|
/>
|
||||||
<br />
|
<Route
|
||||||
</>
|
path="/users/edit/:userId"
|
||||||
)}
|
element={
|
||||||
{settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />}
|
<PermissionAwareRoute currentUser={currentUser}>
|
||||||
<Routes>
|
<UserMutator />
|
||||||
<Route path="/403" element={<InsufficientPermission />} />
|
</PermissionAwareRoute>
|
||||||
<Route path="/jobs/new" element={<JobMutation />} />
|
}
|
||||||
<Route path="/jobs/edit/:jobId" element={<JobMutation />} />
|
/>
|
||||||
<Route path="/jobs/insights/:jobId" element={<JobInsight />} />
|
<Route
|
||||||
<Route path="/jobs" element={<Jobs />} />
|
path="/users"
|
||||||
|
element={
|
||||||
|
<PermissionAwareRoute currentUser={currentUser}>
|
||||||
|
<Users />
|
||||||
|
</PermissionAwareRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/generalSettings"
|
||||||
|
element={
|
||||||
|
<PermissionAwareRoute currentUser={currentUser}>
|
||||||
|
<GeneralSettings />
|
||||||
|
</PermissionAwareRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Permission-aware routes */}
|
<Route path="/" element={<Navigate to="/jobs" replace />} />
|
||||||
<Route
|
</Routes>
|
||||||
path="/users/new"
|
</div>
|
||||||
element={
|
</Content>
|
||||||
<PermissionAwareRoute currentUser={currentUser}>
|
</Layout>
|
||||||
<UserMutator />
|
<Footer>
|
||||||
</PermissionAwareRoute>
|
<FredyFooter />
|
||||||
}
|
</Footer>
|
||||||
/>
|
</Layout>
|
||||||
<Route
|
|
||||||
path="/users/edit/:userId"
|
|
||||||
element={
|
|
||||||
<PermissionAwareRoute currentUser={currentUser}>
|
|
||||||
<UserMutator />
|
|
||||||
</PermissionAwareRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/users"
|
|
||||||
element={
|
|
||||||
<PermissionAwareRoute currentUser={currentUser}>
|
|
||||||
<Users />
|
|
||||||
</PermissionAwareRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/generalSettings"
|
|
||||||
element={
|
|
||||||
<PermissionAwareRoute currentUser={currentUser}>
|
|
||||||
<GeneralSettings />
|
|
||||||
</PermissionAwareRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route path="/" element={<Navigate to="/jobs" replace />} />
|
|
||||||
</Routes>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
.app {
|
.app {
|
||||||
display: flex;
|
height: 100%;
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
&__container {
|
&__content {
|
||||||
padding: 1rem 1rem;
|
margin: 1rem;
|
||||||
color: var(--semi-color-text-0);
|
|
||||||
background-color: #232429;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { reduxStore } from './services/rematch/store';
|
|
||||||
import { HashRouter } from 'react-router-dom';
|
import { HashRouter } from 'react-router-dom';
|
||||||
import { Provider } from 'react-redux';
|
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import en_US from '@douyinfe/semi-ui/lib/es/locale/source/en_US';
|
import en_US from '@douyinfe/semi-ui/lib/es/locale/source/en_US';
|
||||||
import { LocaleProvider } from '@douyinfe/semi-ui';
|
import { LocaleProvider } from '@douyinfe/semi-ui';
|
||||||
@@ -18,11 +16,9 @@ initVChartSemiTheme({
|
|||||||
});
|
});
|
||||||
|
|
||||||
root.render(
|
root.render(
|
||||||
<Provider store={reduxStore}>
|
<HashRouter>
|
||||||
<HashRouter>
|
<LocaleProvider locale={en_US}>
|
||||||
<LocaleProvider locale={en_US}>
|
<App />
|
||||||
<App />
|
</LocaleProvider>
|
||||||
</LocaleProvider>
|
</HashRouter>,
|
||||||
</HashRouter>
|
|
||||||
</Provider>,
|
|
||||||
);
|
);
|
||||||
|
|||||||
BIN
ui/src/assets/no_image.jpg
Normal file
|
After Width: | Height: | Size: 242 KiB |
19
ui/src/components/footer/FredyFooter.jsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import './FredyFooter.less';
|
||||||
|
import { useSelector } from '../../services/state/store.js';
|
||||||
|
import { Typography } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
|
export default function FredyFooter() {
|
||||||
|
const { Text } = Typography;
|
||||||
|
const version = useSelector((state) => state.versionUpdate.versionUpdate);
|
||||||
|
return (
|
||||||
|
<div className="fredyFooter">
|
||||||
|
<div className="fredyFooter__version">
|
||||||
|
<Text type="tertiary">Fredy V{version?.localFredyVersion || 'N/A'}</Text>
|
||||||
|
</div>
|
||||||
|
<div className="fredyFooter__copyRight">
|
||||||
|
<Text link={{ href: 'https://github.com/orangecoding', target: '_blank' }}>Made with ❤️</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
ui/src/components/footer/FredyFooter.less
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
.fredyFooter {
|
||||||
|
background:rgb(53, 54, 60);
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
height: 1.7rem;
|
||||||
|
|
||||||
|
&__version {
|
||||||
|
padding-left: .5rem;
|
||||||
|
font-size: small;
|
||||||
|
|
||||||
|
}
|
||||||
|
&__copyRight {
|
||||||
|
padding-right: 1rem;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,5 +5,5 @@ import logoWhite from '../../assets/logo_white.png';
|
|||||||
import './Logo.less';
|
import './Logo.less';
|
||||||
|
|
||||||
export default function Logo({ width = 350, white = false } = {}) {
|
export default function Logo({ width = 350, white = false } = {}) {
|
||||||
return <img src={white ? logoWhite : logo} width={width} className="logo" />;
|
return <img src={white ? logoWhite : logo} width={width} className="logo" alt="Fredy Logo" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,19 +2,22 @@ import React from 'react';
|
|||||||
import { Button } from '@douyinfe/semi-ui';
|
import { Button } from '@douyinfe/semi-ui';
|
||||||
import { xhrPost } from '../../services/xhr';
|
import { xhrPost } from '../../services/xhr';
|
||||||
import { IconUser } from '@douyinfe/semi-icons';
|
import { IconUser } from '@douyinfe/semi-icons';
|
||||||
const Logout = function Logout() {
|
|
||||||
|
const Logout = function Logout({ text }) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<div>
|
||||||
icon={<IconUser />}
|
<Button
|
||||||
type="danger"
|
icon={<IconUser />}
|
||||||
theme="solid"
|
type="danger"
|
||||||
onClick={async () => {
|
theme="solid"
|
||||||
await xhrPost('/api/login/logout');
|
onClick={async () => {
|
||||||
location.reload();
|
await xhrPost('/api/login/logout');
|
||||||
}}
|
location.reload();
|
||||||
>
|
}}
|
||||||
Logout
|
>
|
||||||
</Button>
|
{text && 'Logout'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { Tabs, TabPane } from '@douyinfe/semi-ui';
|
|
||||||
|
|
||||||
import { useLocation } from 'react-router-dom';
|
|
||||||
import { IconUser, IconTerminal, IconSetting } from '@douyinfe/semi-icons';
|
|
||||||
import './Menu.less';
|
|
||||||
|
|
||||||
function parsePathName(name) {
|
|
||||||
const split = name.split('/').filter((s) => s.length !== 0);
|
|
||||||
return '/' + split[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
const TopMenu = function TopMenu({ isAdmin }) {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
|
||||||
return (
|
|
||||||
<Tabs className="menu" type="line" activeKey={parsePathName(location.pathname)} onTabClick={(key) => navigate(key)}>
|
|
||||||
<TabPane
|
|
||||||
itemKey="/jobs"
|
|
||||||
tab={
|
|
||||||
<span>
|
|
||||||
<IconTerminal />
|
|
||||||
Jobs
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isAdmin && (
|
|
||||||
<TabPane
|
|
||||||
itemKey="/users"
|
|
||||||
tab={
|
|
||||||
<span>
|
|
||||||
<IconUser />
|
|
||||||
User
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isAdmin && (
|
|
||||||
<TabPane
|
|
||||||
itemKey="/generalSettings"
|
|
||||||
tab={
|
|
||||||
<span>
|
|
||||||
<IconSetting />
|
|
||||||
General
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Tabs>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TopMenu;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
.menu {
|
|
||||||
margin-top: 3rem;
|
|
||||||
}
|
|
||||||
9
ui/src/components/navigation/Navigate.less
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.navigate {
|
||||||
|
&__logout_Button {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
50
ui/src/components/navigation/Navigation.jsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Nav } from '@douyinfe/semi-ui';
|
||||||
|
import { IconUser, IconStar, IconSetting, IconTerminal } from '@douyinfe/semi-icons';
|
||||||
|
import logoWhite from '../../assets/logo_white.png';
|
||||||
|
import Logout from '../logout/Logout.jsx';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import './Navigate.less';
|
||||||
|
import { useScreenWidth } from '../../hooks/screenWidth.js';
|
||||||
|
|
||||||
|
export default function Navigation({ isAdmin }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const width = useScreenWidth();
|
||||||
|
const collapsed = width <= 850;
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ itemKey: '/jobs', text: 'Jobs', icon: <IconTerminal /> },
|
||||||
|
{ itemKey: '/listings', text: 'Found Listings', icon: <IconStar /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
items.push({ itemKey: '/users', text: 'User Management', icon: <IconUser /> });
|
||||||
|
items.push({ itemKey: '/generalSettings', text: 'Settings', icon: <IconSetting /> });
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePathName(name) {
|
||||||
|
const split = name.split('/').filter((s) => s.length !== 0);
|
||||||
|
return '/' + split[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Nav
|
||||||
|
style={{ height: '100%', width: collapsed ? '' : '13rem' }}
|
||||||
|
items={items}
|
||||||
|
isCollapsed={collapsed}
|
||||||
|
selectedKeys={[parsePathName(location.pathname)]}
|
||||||
|
onSelect={(key) => {
|
||||||
|
navigate(key.itemKey);
|
||||||
|
}}
|
||||||
|
header={<img src={logoWhite} width="180" alt="Fredy Logo" />}
|
||||||
|
footer={
|
||||||
|
<div className="navigate__logout_Button">
|
||||||
|
<Logout text={!collapsed} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ export const SegmentPart = ({ name, Icon = null, children, helpText }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
|
className="segmentParts"
|
||||||
title={
|
title={
|
||||||
<Meta title={name} description={helpText} avatar={Icon == null ? null : <Icon size="extra-extra-small" />} />
|
<Meta title={name} description={helpText} avatar={Icon == null ? null : <Icon size="extra-extra-small" />} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
.segmentParts {
|
.segmentParts {
|
||||||
border: 1px solid #323232 !important;
|
border: 1px solid #323232 !important;
|
||||||
border-radius: 5px !important;
|
border-radius: 5px !important;
|
||||||
|
color: rgba(var(--semi-grey-8), 1);
|
||||||
|
background: rgb(53, 54, 60);
|
||||||
|
margin: 2rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Button, Empty, Table, Switch } from '@douyinfe/semi-ui';
|
import { Button, Empty, Table, Switch, Popover } from '@douyinfe/semi-ui';
|
||||||
import { IconDelete, IconEdit, IconHistogram } from '@douyinfe/semi-icons';
|
import { IconAlertTriangle, IconDelete, IconDescend2, IconEdit, IconHistogram } from '@douyinfe/semi-icons';
|
||||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||||
|
|
||||||
import './JobTable.less';
|
import './JobTable.less';
|
||||||
@@ -10,11 +10,20 @@ const empty = (
|
|||||||
<Empty
|
<Empty
|
||||||
image={<IllustrationNoResult />}
|
image={<IllustrationNoResult />}
|
||||||
darkModeImage={<IllustrationNoResultDark />}
|
darkModeImage={<IllustrationNoResultDark />}
|
||||||
description={'No jobs available.'}
|
description="No jobs available. Why don't you create one? ;)"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged, onJobEdit, onJobInsight } = {}) {
|
const getPopoverContent = (text) => <article className="jobPopoverContent">{text}</article>;
|
||||||
|
|
||||||
|
export default function JobTable({
|
||||||
|
jobs = {},
|
||||||
|
onJobRemoval,
|
||||||
|
onJobStatusChanged,
|
||||||
|
onJobEdit,
|
||||||
|
onJobInsight,
|
||||||
|
onListingRemoval,
|
||||||
|
} = {}) {
|
||||||
return (
|
return (
|
||||||
<Table
|
<Table
|
||||||
pagination={false}
|
pagination={false}
|
||||||
@@ -24,29 +33,55 @@ export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged,
|
|||||||
title: '',
|
title: '',
|
||||||
dataIndex: '',
|
dataIndex: '',
|
||||||
render: (job) => {
|
render: (job) => {
|
||||||
return <Switch onChange={(checked) => onJobStatusChanged(job.id, checked)} checked={job.enabled} />;
|
return (
|
||||||
|
<Switch
|
||||||
|
onChange={(checked) => onJobStatusChanged(job.id, checked)}
|
||||||
|
checked={job.enabled}
|
||||||
|
disabled={job.isOnlyShared}
|
||||||
|
/>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Name',
|
title: 'Name',
|
||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
|
render: (name, job) => {
|
||||||
|
if (job.isOnlyShared) {
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
content={getPopoverContent(
|
||||||
|
'This job has been shared with you by another user, therefor it is read-only.',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', gap: '.3rem' }}>
|
||||||
|
<div style={{ color: 'rgba(var(--semi-yellow-7), 1)' }}>
|
||||||
|
<IconAlertTriangle />
|
||||||
|
</div>
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Findings',
|
title: 'Listings',
|
||||||
dataIndex: 'numberOfFoundListings',
|
dataIndex: 'numberOfFoundListings',
|
||||||
render: (value) => {
|
render: (value) => {
|
||||||
return value || 0;
|
return value || 0;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Providers',
|
title: 'Provider',
|
||||||
dataIndex: 'provider',
|
dataIndex: 'provider',
|
||||||
render: (value) => {
|
render: (value) => {
|
||||||
return value.length || 0;
|
return value.length || 0;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Notification adapters',
|
title: 'Notification Adapter',
|
||||||
dataIndex: 'notificationAdapter',
|
dataIndex: 'notificationAdapter',
|
||||||
render: (value) => {
|
render: (value) => {
|
||||||
return value.length || 0;
|
return value.length || 0;
|
||||||
@@ -58,9 +93,38 @@ export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged,
|
|||||||
render: (_, job) => {
|
render: (_, job) => {
|
||||||
return (
|
return (
|
||||||
<div className="interactions">
|
<div className="interactions">
|
||||||
<Button type="primary" icon={<IconHistogram />} onClick={() => onJobInsight(job.id)} />
|
<Popover content={getPopoverContent('Job Insights')}>
|
||||||
<Button type="secondary" icon={<IconEdit />} onClick={() => onJobEdit(job.id)} />
|
<Button
|
||||||
<Button type="danger" icon={<IconDelete />} onClick={() => onJobRemoval(job.id)} />
|
type="primary"
|
||||||
|
icon={<IconHistogram />}
|
||||||
|
disabled={job.isOnlyShared}
|
||||||
|
onClick={() => onJobInsight(job.id)}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
<Popover content={getPopoverContent('Edit a Job')}>
|
||||||
|
<Button
|
||||||
|
type="secondary"
|
||||||
|
icon={<IconEdit />}
|
||||||
|
disabled={job.isOnlyShared}
|
||||||
|
onClick={() => onJobEdit(job.id)}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
<Popover content={getPopoverContent('Delete all found Listings of this Job')}>
|
||||||
|
<Button
|
||||||
|
type="danger"
|
||||||
|
icon={<IconDescend2 />}
|
||||||
|
disabled={job.isOnlyShared}
|
||||||
|
onClick={() => onListingRemoval(job.id)}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
<Popover content={getPopoverContent('Delete Job')}>
|
||||||
|
<Button
|
||||||
|
type="danger"
|
||||||
|
icon={<IconDelete />}
|
||||||
|
disabled={job.isOnlyShared}
|
||||||
|
onClick={() => onJobRemoval(job.id)}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,11 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.jobPopoverContent {
|
||||||
|
padding: 1rem;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.interactions {
|
.interactions {
|
||||||
flex-direction: initial;
|
flex-direction: initial;
|
||||||
|
|||||||
53
ui/src/components/table/listings/ListingsFilter.jsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Card, Checkbox, Descriptions, Divider, Select } from '@douyinfe/semi-ui';
|
||||||
|
import React from 'react';
|
||||||
|
import { useSelector } from '../../../services/state/store.js';
|
||||||
|
import { Typography } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
|
import './ListingsFilter.less';
|
||||||
|
|
||||||
|
export default function ListingsFilter({ onWatchListFilter, onActivityFilter, onJobNameFilter, onProviderFilter }) {
|
||||||
|
const jobs = useSelector((state) => state.jobs.jobs);
|
||||||
|
const provider = useSelector((state) => state.provider);
|
||||||
|
const { Title } = Typography;
|
||||||
|
return (
|
||||||
|
<Card className="listingsFilter">
|
||||||
|
<Title heading={6}>Filter by:</Title>
|
||||||
|
<Divider />
|
||||||
|
<br />
|
||||||
|
<Descriptions row>
|
||||||
|
<Descriptions.Item itemKey="Watch List">
|
||||||
|
<Checkbox onChange={(e) => onWatchListFilter(e.target.checked)}>Only Watch List</Checkbox>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item itemKey="Activity status">
|
||||||
|
<Checkbox onChange={(e) => onActivityFilter(e.target.checked)}>Only Active Listings</Checkbox>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item itemKey="Job Name">
|
||||||
|
<Select showClear placeholder="Select Job to Filter" onChange={(val) => onJobNameFilter(val)}>
|
||||||
|
{jobs != null &&
|
||||||
|
jobs.length > 0 &&
|
||||||
|
jobs.map((job) => {
|
||||||
|
return (
|
||||||
|
<Select.Option value={job.id} key={job.id}>
|
||||||
|
{job.name}
|
||||||
|
</Select.Option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Select>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item itemKey="Provider">
|
||||||
|
<Select showClear placeholder="Select Provider to Filter" onChange={(val) => onProviderFilter(val)}>
|
||||||
|
{provider != null &&
|
||||||
|
provider.length > 0 &&
|
||||||
|
provider.map((prov) => {
|
||||||
|
return (
|
||||||
|
<Select.Option value={prov.id} key={prov.id}>
|
||||||
|
{prov.name}
|
||||||
|
</Select.Option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Select>
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
ui/src/components/table/listings/ListingsFilter.less
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.listingsFilter {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: rgb(53, 54, 60);
|
||||||
|
}
|
||||||
289
ui/src/components/table/listings/ListingsTable.jsx
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { Table, Popover, Input, Descriptions, Tag, Image, Empty, Button, Toast, Divider } from '@douyinfe/semi-ui';
|
||||||
|
import { useActions, useSelector } from '../../../services/state/store.js';
|
||||||
|
import { IconClose, IconDelete, IconSearch, IconStar, IconStarStroked, IconTick } from '@douyinfe/semi-icons';
|
||||||
|
import * as timeService from '../../../services/time/timeService.js';
|
||||||
|
import debounce from 'lodash/debounce';
|
||||||
|
import no_image from '../../../assets/no_image.jpg';
|
||||||
|
|
||||||
|
import './ListingsTable.less';
|
||||||
|
import { format } from '../../../services/time/timeService.js';
|
||||||
|
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||||
|
import { xhrDelete, xhrPost } from '../../../services/xhr.js';
|
||||||
|
import ListingsFilter from './ListingsFilter.jsx';
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '#',
|
||||||
|
width: 100,
|
||||||
|
dataIndex: 'isWatched',
|
||||||
|
sorter: true,
|
||||||
|
render: (id, row) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Popover
|
||||||
|
style={{
|
||||||
|
padding: '.4rem',
|
||||||
|
color: 'var(--semi-color-white)',
|
||||||
|
}}
|
||||||
|
content={row.isWatched === 1 ? 'Unwatch Listing' : 'Watch Listing'}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon={
|
||||||
|
row.isWatched === 1 ? (
|
||||||
|
<IconStar style={{ color: 'rgba(var(--semi-green-5), 1)' }} />
|
||||||
|
) : (
|
||||||
|
<IconStarStroked />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
theme="borderless"
|
||||||
|
size="small"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await xhrPost('/api/listings/watch', { listingId: row.id });
|
||||||
|
Toast.success(row.isWatched === 1 ? 'Listing removed from Watchlist' : 'Listing added to Watchlist');
|
||||||
|
row.reloadTable();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
Toast.error('Failed to operate Watchlist');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
<Divider layout="vertical" margin="4px" />
|
||||||
|
<Popover
|
||||||
|
style={{
|
||||||
|
padding: '.4rem',
|
||||||
|
color: 'var(--semi-color-white)',
|
||||||
|
}}
|
||||||
|
content="Delete Listing"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon={<IconDelete />}
|
||||||
|
theme="borderless"
|
||||||
|
size="small"
|
||||||
|
type="danger"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await xhrDelete('/api/listings/', { ids: [row.id] });
|
||||||
|
Toast.success('Listing(s) successfully removed');
|
||||||
|
row.reloadTable();
|
||||||
|
} catch (error) {
|
||||||
|
Toast.error(error);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'State',
|
||||||
|
dataIndex: 'is_active',
|
||||||
|
width: 84,
|
||||||
|
sorter: true,
|
||||||
|
render: (value) => {
|
||||||
|
return value ? (
|
||||||
|
<div style={{ color: 'rgba(var(--semi-green-6), 1)' }}>
|
||||||
|
<Popover
|
||||||
|
style={{
|
||||||
|
padding: '.4rem',
|
||||||
|
color: 'var(--semi-color-white)',
|
||||||
|
}}
|
||||||
|
content="Listing is still active"
|
||||||
|
>
|
||||||
|
<IconTick />
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ color: 'rgba(var(--semi-red-5), 1)' }}>
|
||||||
|
<Popover
|
||||||
|
style={{
|
||||||
|
padding: '.4rem',
|
||||||
|
color: 'var(--semi-color-white)',
|
||||||
|
}}
|
||||||
|
content="Listing is inactive"
|
||||||
|
>
|
||||||
|
<IconClose />
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Job-Name',
|
||||||
|
sorter: true,
|
||||||
|
ellipsis: true,
|
||||||
|
dataIndex: 'job_name',
|
||||||
|
width: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Listing date',
|
||||||
|
width: 130,
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
sorter: true,
|
||||||
|
render: (text) => timeService.format(text, false),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Provider',
|
||||||
|
width: 130,
|
||||||
|
dataIndex: 'provider',
|
||||||
|
sorter: true,
|
||||||
|
render: (text) => text.charAt(0).toUpperCase() + text.slice(1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Price',
|
||||||
|
width: 110,
|
||||||
|
dataIndex: 'price',
|
||||||
|
sorter: true,
|
||||||
|
render: (text) => text + ' €',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Address',
|
||||||
|
width: 150,
|
||||||
|
dataIndex: 'address',
|
||||||
|
sorter: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Title',
|
||||||
|
dataIndex: 'title',
|
||||||
|
sorter: true,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (text, row) => {
|
||||||
|
return (
|
||||||
|
<a href={row.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
{text}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const empty = (
|
||||||
|
<Empty
|
||||||
|
image={<IllustrationNoResult />}
|
||||||
|
darkModeImage={<IllustrationNoResultDark />}
|
||||||
|
description="No listings available."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function ListingsTable() {
|
||||||
|
const tableData = useSelector((state) => state.listingsTable);
|
||||||
|
const actions = useActions();
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const pageSize = 10;
|
||||||
|
const [sortData, setSortData] = useState({});
|
||||||
|
const [freeTextFilter, setFreeTextFilter] = useState(null);
|
||||||
|
const [watchListFilter, setWatchListFilter] = useState(null);
|
||||||
|
const [jobNameFilter, setJobNameFilter] = useState(null);
|
||||||
|
const [activityFilter, setActivityFilter] = useState(null);
|
||||||
|
const [providerFilter, setProviderFilter] = useState(null);
|
||||||
|
|
||||||
|
const handlePageChange = (_page) => {
|
||||||
|
setPage(_page);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadTable = () => {
|
||||||
|
let sortfield = null;
|
||||||
|
let sortdir = null;
|
||||||
|
|
||||||
|
if (sortData != null && Object.keys(sortData).length > 0) {
|
||||||
|
sortfield = sortData.field;
|
||||||
|
sortdir = sortData.direction;
|
||||||
|
}
|
||||||
|
actions.listingsTable.getListingsTable({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
sortfield,
|
||||||
|
sortdir,
|
||||||
|
freeTextFilter,
|
||||||
|
filter: { watchListFilter, jobNameFilter, activityFilter, providerFilter },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTable();
|
||||||
|
}, [page, sortData, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]);
|
||||||
|
|
||||||
|
const handleFilterChange = useMemo(() => debounce((value) => setFreeTextFilter(value), 500), []);
|
||||||
|
|
||||||
|
const expandRowRender = (record) => {
|
||||||
|
return (
|
||||||
|
<div className="listingsTable__expanded">
|
||||||
|
<div>
|
||||||
|
{record.image_url == null ? (
|
||||||
|
<Image height={200} src={no_image} />
|
||||||
|
) : (
|
||||||
|
<Image height={200} src={record.image_url} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Descriptions align="justify">
|
||||||
|
<Descriptions.Item itemKey="Listing still online">
|
||||||
|
<Tag size="small" shape="circle" color={record.is_active ? 'green' : 'red'}>
|
||||||
|
{record.is_active ? 'Yes' : 'No'}
|
||||||
|
</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item itemKey="Link">
|
||||||
|
<a href={record.link} target="_blank" rel="noreferrer">
|
||||||
|
Link to Listing
|
||||||
|
</a>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item itemKey="Listing date">{format(record.created_at)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item itemKey="Price">{record.price} €</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
<b>{record.title}</b>
|
||||||
|
<p>{record.description == null ? 'No description available' : record.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ListingsFilter
|
||||||
|
onActivityFilter={setActivityFilter}
|
||||||
|
onWatchListFilter={setWatchListFilter}
|
||||||
|
onJobNameFilter={setJobNameFilter}
|
||||||
|
onProviderFilter={setProviderFilter}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
prefix={<IconSearch />}
|
||||||
|
showClear
|
||||||
|
className="listingsTable__search"
|
||||||
|
placeholder="Search"
|
||||||
|
onChange={handleFilterChange}
|
||||||
|
/>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
empty={empty}
|
||||||
|
hideExpandedColumn={false}
|
||||||
|
sticky={{ top: 5 }}
|
||||||
|
columns={columns}
|
||||||
|
expandedRowRender={expandRowRender}
|
||||||
|
dataSource={(tableData?.result || []).map((row) => {
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
reloadTable: loadTable,
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
onChange={(changeSet) => {
|
||||||
|
if (changeSet?.extra?.changeType === 'sorter') {
|
||||||
|
setSortData({
|
||||||
|
field: changeSet.sorter.dataIndex,
|
||||||
|
direction: changeSet.sorter.sortOrder === 'ascend' ? 'asc' : 'desc',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
pagination={{
|
||||||
|
currentPage: page,
|
||||||
|
//for now fixed
|
||||||
|
pageSize,
|
||||||
|
total: tableData?.totalNumber || 0,
|
||||||
|
onPageChange: handlePageChange,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
ui/src/components/table/listings/ListingsTable.less
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
.listingsTable {
|
||||||
|
&__search {
|
||||||
|
margin-bottom: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__expanded {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__toolbar {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
ui/src/components/version/VersionBanner.jsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Collapse, Descriptions } from '@douyinfe/semi-ui';
|
||||||
|
import { useSelector } from '../../services/state/store.js';
|
||||||
|
import { MarkdownRender } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
|
import './VersionBanner.less';
|
||||||
|
|
||||||
|
export default function VersionBanner() {
|
||||||
|
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
|
||||||
|
return (
|
||||||
|
<Collapse>
|
||||||
|
<Collapse.Panel header="A new version of Fredy is available" itemKey="1" className="versionBanner">
|
||||||
|
<div className="versionBanner__content">
|
||||||
|
<p>A new version of Fredy is available. Update now to take advantage of the latest features and bug fixes.</p>
|
||||||
|
<Descriptions row size="small">
|
||||||
|
<Descriptions.Item itemKey="Your Version">{versionUpdate.localFredyVersion}</Descriptions.Item>
|
||||||
|
<Descriptions.Item itemKey="Latest Version">{versionUpdate.version}</Descriptions.Item>
|
||||||
|
<Descriptions.Item itemKey="Github Release">
|
||||||
|
<a href={versionUpdate.url} target="_blank" rel="noreferrer">
|
||||||
|
{versionUpdate.url}
|
||||||
|
</a>{' '}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
<p>
|
||||||
|
<b>
|
||||||
|
<small>Release Notes</small>
|
||||||
|
</b>
|
||||||
|
</p>
|
||||||
|
<MarkdownRender raw={versionUpdate.body} />
|
||||||
|
</div>
|
||||||
|
</Collapse.Panel>
|
||||||
|
</Collapse>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
ui/src/components/version/VersionBanner.less
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.versionBanner {
|
||||||
|
background: rgba(var(--semi-teal-1), 1);
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
ui/src/hooks/screenWidth.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useScreenWidth() {
|
||||||
|
const [width, setWidth] = useState(window.innerWidth);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timeoutId;
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(() => setWidth(window.innerWidth), 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return width;
|
||||||
|
}
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { xhrGet } from '../../xhr';
|
|
||||||
export const demoMode = {
|
|
||||||
state: {
|
|
||||||
demoMode: false,
|
|
||||||
},
|
|
||||||
reducers: {
|
|
||||||
setDemoMode: (state, payload) => {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
demoMode: payload.demoMode,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
effects: {
|
|
||||||
async getDemoMode() {
|
|
||||||
try {
|
|
||||||
const response = await xhrGet('/api/demo');
|
|
||||||
this.setDemoMode(response.json);
|
|
||||||
} catch (Exception) {
|
|
||||||
console.error('Error while trying to get resource for api/demo. Error:', Exception);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { xhrGet } from '../../xhr';
|
|
||||||
export const generalSettings = {
|
|
||||||
state: {
|
|
||||||
settings: {},
|
|
||||||
},
|
|
||||||
reducers: {
|
|
||||||
//only admins
|
|
||||||
setGeneralSettings: (state, payload) => {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
settings: payload,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
effects: {
|
|
||||||
async getGeneralSettings() {
|
|
||||||
try {
|
|
||||||
const response = await xhrGet('/api/admin/generalSettings');
|
|
||||||
this.setGeneralSettings(response.json);
|
|
||||||
} catch (Exception) {
|
|
||||||
console.error('Error while trying to get resource for api/admin/generalSettings. Error:', Exception);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { xhrGet } from '../../xhr';
|
|
||||||
export const jobs = {
|
|
||||||
state: {
|
|
||||||
jobs: [],
|
|
||||||
insights: {},
|
|
||||||
processingTimes: {},
|
|
||||||
},
|
|
||||||
reducers: {
|
|
||||||
setJobs: (state, payload) => {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
jobs: Object.freeze(payload),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
setProcessingTimes: (state, payload) => {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
processingTimes: Object.freeze(payload),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
setJobInsights: (state, payload, jobId) => {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
insights: {
|
|
||||||
...state.insights,
|
|
||||||
[jobId]: Object.freeze(payload),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
effects: {
|
|
||||||
async getJobs() {
|
|
||||||
try {
|
|
||||||
const response = await xhrGet('/api/jobs');
|
|
||||||
this.setJobs(response.json);
|
|
||||||
} catch (Exception) {
|
|
||||||
console.error(`Error while trying to get resource for api/jobs. Error:`, Exception);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async getProcessingTimes() {
|
|
||||||
try {
|
|
||||||
const response = await xhrGet('/api/jobs/processingTimes');
|
|
||||||
this.setProcessingTimes(response.json);
|
|
||||||
} catch (Exception) {
|
|
||||||
console.error(`Error while trying to get resource for api/processingTimes. Error:`, Exception);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async getInsightDataForJob(jobId) {
|
|
||||||
try {
|
|
||||||
const response = await xhrGet(`/api/jobs/insights/${jobId}`);
|
|
||||||
this.setJobInsights(response.json, jobId);
|
|
||||||
} catch (Exception) {
|
|
||||||
console.error(`Error while trying to get resource for api/jobs/insights. Error:`, Exception);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { xhrGet } from '../../xhr';
|
|
||||||
export const notificationAdapter = {
|
|
||||||
state: [],
|
|
||||||
reducers: {
|
|
||||||
setAdapter: (state, payload) => {
|
|
||||||
return [...Object.freeze(payload)];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
effects: {
|
|
||||||
async getAdapter() {
|
|
||||||
try {
|
|
||||||
const response = await xhrGet('/api/jobs/notificationAdapter');
|
|
||||||
this.setAdapter(response.json);
|
|
||||||
} catch (Exception) {
|
|
||||||
console.error(`Error while trying to get resource for api/jobs/notificationAdapter. Error:`, Exception);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { xhrGet } from '../../xhr';
|
|
||||||
export const provider = {
|
|
||||||
state: [],
|
|
||||||
reducers: {
|
|
||||||
setProvider: (state, payload) => {
|
|
||||||
return [...Object.freeze(payload)];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
effects: {
|
|
||||||
async getProvider() {
|
|
||||||
try {
|
|
||||||
const response = await xhrGet('/api/jobs/provider');
|
|
||||||
this.setProvider(response.json);
|
|
||||||
} catch (Exception) {
|
|
||||||
console.error(`Error while trying to get resource for api/jobs/provider. Error:`, Exception);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { xhrGet } from '../../xhr';
|
|
||||||
export const user = {
|
|
||||||
state: {
|
|
||||||
users: [],
|
|
||||||
currentUser: null,
|
|
||||||
},
|
|
||||||
reducers: {
|
|
||||||
//only admins
|
|
||||||
setUsers: (state, payload) => {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
users: payload,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
setCurrentUser: (state, payload) => {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
currentUser: Object.freeze(payload),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
effects: {
|
|
||||||
async getUsers() {
|
|
||||||
try {
|
|
||||||
const response = await xhrGet('/api/admin/users');
|
|
||||||
this.setUsers(response.json);
|
|
||||||
} catch (Exception) {
|
|
||||||
console.error('Error while trying to get resource for api/admin/users. Error:', Exception);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async getCurrentUser() {
|
|
||||||
try {
|
|
||||||
const response = await xhrGet('/api/login/user');
|
|
||||||
this.setCurrentUser(response.json);
|
|
||||||
} catch (Exception) {
|
|
||||||
console.error('Error while trying to get resource for api/login/user. Error:', Exception);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||