Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
28e885f6c7 | ||
|
|
1d99fc95f7 | ||
|
|
28f0a167e6 | ||
|
|
8d95f052c6 | ||
|
|
18fdbd761a | ||
|
|
027e7d70ed | ||
|
|
de119c9199 | ||
|
|
ce7f0bca9f | ||
|
|
ae1c4d936b | ||
|
|
0d2b21c789 |
2
.github/workflows/check_source.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
38
.github/workflows/docker.yml
vendored
@@ -57,3 +57,41 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
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
|
||||
|
||||
2
.github/workflows/test.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: 'yarn'
|
||||
|
||||
- run: yarn install
|
||||
|
||||
4
.gitignore
vendored
@@ -1,6 +1,8 @@
|
||||
node_modules/
|
||||
ui/public/
|
||||
db/
|
||||
db/*.json
|
||||
db/*.db*
|
||||
npm-debug.log
|
||||
.DS_Store
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
@@ -2,9 +2,10 @@ FROM node:22-slim
|
||||
|
||||
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 \
|
||||
&& apt-get install -y --no-install-recommends chromium \
|
||||
&& apt-get install -y --no-install-recommends chromium curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
|
||||
@@ -30,6 +31,8 @@ RUN mkdir -p /db /conf \
|
||||
&& ln -s /conf /fredy/conf
|
||||
|
||||
EXPOSE 9998
|
||||
VOLUME /db
|
||||
VOLUME /conf
|
||||
|
||||
# Start application using PM2 runtime
|
||||
CMD ["pm2-runtime", "index.js"]
|
||||
|
||||
32
README.md
@@ -9,10 +9,18 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||

|
||||
[](https://github.com/orangecoding/fredy/actions/workflows/docker.yml)
|
||||

|
||||

|
||||
<p align="center">
|
||||
<a href="https://fredy.orange-coding.net/" target="_blank">Website</a> |
|
||||
<a href="https://demo-fredy.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
|
||||
@@ -21,15 +29,13 @@ Finding an apartment or house in Germany can be stressful and
|
||||
time-consuming.\
|
||||
**Fredy** makes it easier: it automatically scrapes **ImmoScout24,
|
||||
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.
|
||||
|
||||
With a modern architecture, Fredy provides a **clean Web UI**, removes
|
||||
duplicates across platforms, and stores results so you never see the
|
||||
same listing twice.
|
||||
|
||||
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
## ✨ Key Features
|
||||
@@ -37,7 +43,7 @@ same listing twice.
|
||||
- 🏠 Scrapes **ImmoScout24, Immowelt, Immonet, eBay Kleinanzeigen,
|
||||
WG-Gesucht**
|
||||
- ⚡ Instant notifications: Slack, Telegram, Email (SendGrid,
|
||||
Mailjet), ntfy
|
||||
Mailjet), ntfy, discord
|
||||
- 🔎 Uses the **ImmoScout Mobile API** (reverse engineered)
|
||||
- 🌍 Runs anywhere: Docker, Node.js, self-hosted
|
||||
- 🖥️ Intuitive **Web UI** to manage searches
|
||||
@@ -90,7 +96,7 @@ docker logs fredy -f
|
||||
|
||||
### Manual (Node.js)
|
||||
|
||||
- Requirement: **Node.js 20 or higher**
|
||||
- Requirement: **Node.js 22 or higher**
|
||||
- Install dependencies and start:
|
||||
|
||||
``` bash
|
||||
@@ -109,9 +115,9 @@ yarn run start:frontend # in another terminal
|
||||
|
||||
## 📸 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 📡
|
||||
|
||||
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).\
|
||||
You can use multiple adapters at once --- Fredy will send new listings
|
||||
through all of them.
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"interval":"60","port":9998,"workingHours":{"from":"","to":""},"demoMode":false,"analyticsEnabled":null}
|
||||
{"interval":"60","port":9998,"workingHours":{"from":"","to":""},"demoMode":false,"analyticsEnabled":true,"sqlitepath":"/db"}
|
||||
0
db/.gitkeep
Normal file
|
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:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: fredy/fredy
|
||||
image: ghcr.io/orangecoding/fredy
|
||||
# map existing config and database
|
||||
volumes:
|
||||
- ./conf:/conf
|
||||
- ./db:/db
|
||||
ports:
|
||||
- 9998:9998
|
||||
- "9998:9998"
|
||||
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
Normal 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
|
||||
docker build -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
|
||||
120
index.js
@@ -1,59 +1,95 @@
|
||||
import fs from 'fs';
|
||||
import { config } from './lib/utils.js';
|
||||
import path from 'path';
|
||||
import { checkIfConfigIsAccessible, config, getProviders, refreshConfig } from './lib/utils.js';
|
||||
import * as similarityCache from './lib/services/similarity-check/similarityCache.js';
|
||||
import { setLastJobExecution } from './lib/services/storage/listingsStorage.js';
|
||||
import * as jobStorage from './lib/services/storage/jobStorage.js';
|
||||
import FredyRuntime from './lib/FredyRuntime.js';
|
||||
import { duringWorkingHoursOrNotSet } from './lib/utils.js';
|
||||
import './lib/api/api.js';
|
||||
import { handleDemoUser } from './lib/services/storage/userStorage.js';
|
||||
import { cleanupDemoAtMidnight } from './lib/services/demoCleanup.js';
|
||||
import { initTrackerCron } from './lib/services/tracking/Tracker-Cron.js';
|
||||
import { runMigrations } from './lib/services/storage/migrations/migrate.js';
|
||||
import { ensureDemoUserExists, ensureAdminUserExists } from './lib/services/storage/userStorage.js';
|
||||
import { cleanupDemoAtMidnight } from './lib/services/crons/demoCleanup-cron.js';
|
||||
import { initTrackerCron } from './lib/services/crons/tracker-cron.js';
|
||||
import logger from './lib/services/logger.js';
|
||||
//if db folder does not exist, ensure to create it before loading anything else
|
||||
if (!fs.existsSync('./db')) {
|
||||
fs.mkdirSync('./db');
|
||||
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);
|
||||
}
|
||||
const path = './lib/provider';
|
||||
const provider = fs.readdirSync(path).filter((file) => file.endsWith('.js'));
|
||||
|
||||
// Ensure sqlite directory exists before loading anything else (based on config.sqlitepath)
|
||||
const rawDir = config.sqlitepath || '/db';
|
||||
const relDir = rawDir.startsWith('/') ? rawDir.slice(1) : rawDir;
|
||||
const absDir = path.isAbsolute(relDir) ? relDir : path.join(process.cwd(), relDir);
|
||||
if (!fs.existsSync(absDir)) {
|
||||
fs.mkdirSync(absDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Run DB migrations once at startup and block until finished
|
||||
await runMigrations();
|
||||
|
||||
// Load provider modules once at startup
|
||||
const providers = await getProviders();
|
||||
|
||||
//assuming interval is always in minutes
|
||||
const INTERVAL = config.interval * 60 * 1000;
|
||||
logger.info(`Started Fredy successfully. Ui can be accessed via http://localhost:${config.port}`);
|
||||
|
||||
// Initialize API only after migrations completed
|
||||
await import('./lib/api/api.js');
|
||||
|
||||
if (config.demoMode) {
|
||||
logger.info('Running in demo mode');
|
||||
cleanupDemoAtMidnight();
|
||||
}
|
||||
const fetchedProvider = await Promise.all(
|
||||
provider.filter((provider) => provider.endsWith('.js')).map(async (pro) => import(`${path}/${pro}`)),
|
||||
);
|
||||
|
||||
handleDemoUser();
|
||||
logger.info(`Started Fredy successfully. Ui can be accessed via http://localhost:${config.port}`);
|
||||
|
||||
ensureAdminUserExists();
|
||||
ensureDemoUserExists();
|
||||
await initTrackerCron();
|
||||
//do not wait for this to finish, let it run in the background
|
||||
initActiveCheckerCron();
|
||||
|
||||
setInterval(
|
||||
(function exec() {
|
||||
const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(config, Date.now());
|
||||
if (!config.demoMode) {
|
||||
if (isDuringWorkingHoursOrNotSet) {
|
||||
config.lastRun = Date.now();
|
||||
jobStorage
|
||||
.getJobs()
|
||||
.filter((job) => job.enabled)
|
||||
.forEach((job) => {
|
||||
job.provider
|
||||
.filter((p) => fetchedProvider.find((fp) => fp.metaInformation.id === p.id) != null)
|
||||
.forEach(async (prov) => {
|
||||
const pro = fetchedProvider.find((fp) => fp.metaInformation.id === prov.id);
|
||||
pro.init(prov, job.blacklist);
|
||||
await new FredyRuntime(pro.config, job.notificationAdapter, prov.id, job.id, similarityCache).execute();
|
||||
setLastJobExecution(job.id);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
logger.debug('Working hours set. Skipping as outside of working hours.');
|
||||
}
|
||||
bus.on('jobs:runAll', () => {
|
||||
logger.debug('Running Fredy Job manually');
|
||||
execute();
|
||||
});
|
||||
|
||||
const execute = () => {
|
||||
const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(config, Date.now());
|
||||
if (!config.demoMode) {
|
||||
if (isDuringWorkingHoursOrNotSet) {
|
||||
config.lastRun = Date.now();
|
||||
jobStorage
|
||||
.getJobs()
|
||||
.filter((job) => job.enabled)
|
||||
.forEach((job) => {
|
||||
job.provider
|
||||
.filter((p) => providers.find((loaded) => loaded.metaInformation.id === p.id) != null)
|
||||
.forEach(async (prov) => {
|
||||
const matchedProvider = providers.find((loaded) => loaded.metaInformation.id === prov.id);
|
||||
matchedProvider.init(prov, job.blacklist);
|
||||
await new FredyRuntime(
|
||||
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();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NoNewListingsWarning } from './errors.js';
|
||||
import { setKnownListings, getKnownListings } from './services/storage/listingsStorage.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';
|
||||
@@ -77,7 +77,10 @@ class FredyRuntime {
|
||||
}
|
||||
|
||||
_findNew(listings) {
|
||||
const newListings = listings.filter((o) => getKnownListings(this._jobKey, this._providerId)[o.id] == null);
|
||||
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();
|
||||
}
|
||||
@@ -93,11 +96,8 @@ class FredyRuntime {
|
||||
}
|
||||
|
||||
_save(newListings) {
|
||||
const currentListings = getKnownListings(this._jobKey, this._providerId) || {};
|
||||
newListings.forEach((listing) => {
|
||||
currentListings[listing.id] = Date.now();
|
||||
});
|
||||
setKnownListings(this._jobKey, this._providerId, currentListings);
|
||||
logger.debug(`Storing ${newListings.length} new listings (Provider: '${this._providerId}')`);
|
||||
storeListings(this._jobKey, this._providerId, newListings);
|
||||
return newListings;
|
||||
}
|
||||
|
||||
@@ -105,16 +105,22 @@ class FredyRuntime {
|
||||
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}`);
|
||||
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, listings.address));
|
||||
filteredList.forEach((filter) => this._similarityCache.addCacheEntry(filter.title, filter.address));
|
||||
return filteredList;
|
||||
}
|
||||
|
||||
_handleError(err) {
|
||||
if (err.name !== 'NoNewListingsWarning') logger.error(err);
|
||||
if (err.name === 'NoNewListingsWarning') {
|
||||
logger.debug(`No new listings found (Provider: '${this._providerId}').`);
|
||||
} else {
|
||||
logger.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,11 @@ import { authInterceptor, cookieSession, adminInterceptor } from './security.js'
|
||||
import { generalSettingsRouter } from './routes/generalSettingsRoute.js';
|
||||
import { analyticsRouter } from './routes/analyticsRouter.js';
|
||||
import { providerRouter } from './routes/providerRouter.js';
|
||||
import { versionRouter } from './routes/versionRouter.js';
|
||||
import { loginRouter } from './routes/loginRoute.js';
|
||||
import { config } from '../utils.js';
|
||||
import { userRouter } from './routes/userRoute.js';
|
||||
import { jobRouter } from './routes/jobRouter.js';
|
||||
import { config } from '../utils.js';
|
||||
import bodyParser from 'body-parser';
|
||||
import restana from 'restana';
|
||||
import files from 'serve-static';
|
||||
@@ -14,6 +15,7 @@ import path from 'path';
|
||||
import { getDirName } from '../utils.js';
|
||||
import { demoRouter } from './routes/demoRouter.js';
|
||||
import logger from '../services/logger.js';
|
||||
import { listingsRouter } from './routes/listingsRouter.js';
|
||||
const service = restana();
|
||||
const staticService = files(path.join(getDirName(), '../ui/public'));
|
||||
const PORT = config.port || 9998;
|
||||
@@ -23,6 +25,9 @@ service.use(cookieSession());
|
||||
service.use(staticService);
|
||||
service.use('/api/admin', 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
|
||||
service.use('/api/admin', adminInterceptor());
|
||||
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/insights', analyticsRouter);
|
||||
service.use('/api/admin/users', userRouter);
|
||||
service.use('/api/version', versionRouter);
|
||||
service.use('/api/jobs', jobRouter);
|
||||
service.use('/api/login', loginRouter);
|
||||
service.use('/api/listings', listingsRouter);
|
||||
//this route is unsecured intentionally as it is being queried from the login page
|
||||
service.use('/api/demo', demoRouter);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import restana from 'restana';
|
||||
import { config, getDirName, readConfigFromStorage, refreshConfig } from '../../utils.js';
|
||||
import fs from 'fs';
|
||||
import { handleDemoUser } from '../../services/storage/userStorage.js';
|
||||
import { ensureDemoUserExists } from '../../services/storage/userStorage.js';
|
||||
import logger from '../../services/logger.js';
|
||||
const service = restana();
|
||||
const generalSettingsRouter = service.newRouter();
|
||||
@@ -19,7 +19,7 @@ generalSettingsRouter.post('/', async (req, res) => {
|
||||
const currentConfig = await readConfigFromStorage();
|
||||
fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ ...currentConfig, ...settings }));
|
||||
await refreshConfig();
|
||||
handleDemoUser();
|
||||
ensureDemoUserExists();
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
res.send(new Error('Error while trying to write settings.'));
|
||||
|
||||
@@ -4,8 +4,11 @@ import * as userStorage from '../../services/storage/userStorage.js';
|
||||
import { config } from '../../utils.js';
|
||||
import { isAdmin } from '../security.js';
|
||||
import logger from '../../services/logger.js';
|
||||
import { bus } from '../../services/events/event-bus.js';
|
||||
|
||||
const service = restana();
|
||||
const jobRouter = service.newRouter();
|
||||
|
||||
function doesJobBelongsToUser(job, req) {
|
||||
const userId = req.session.currentUser;
|
||||
if (userId == null) {
|
||||
@@ -17,6 +20,7 @@ function doesJobBelongsToUser(job, req) {
|
||||
}
|
||||
return user.isAdmin || job.userId === user.id;
|
||||
}
|
||||
|
||||
jobRouter.get('/', async (req, res) => {
|
||||
const isUserAdmin = isAdmin(req);
|
||||
//show only the jobs which belongs to the user (or all of the user is an admin)
|
||||
@@ -30,6 +34,12 @@ jobRouter.get('/processingTimes', async (req, res) => {
|
||||
};
|
||||
res.send();
|
||||
});
|
||||
|
||||
jobRouter.post('/startAll', async (req, res) => {
|
||||
bus.emit('jobs:runAll');
|
||||
res.send();
|
||||
});
|
||||
|
||||
jobRouter.post('/', async (req, res) => {
|
||||
const { provider, notificationAdapter, name, blacklist = [], jobId, enabled } = req.body;
|
||||
try {
|
||||
|
||||
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 };
|
||||
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',
|
||||
keys: ['fredy', 'super', 'fancy', 'key', nanoid()],
|
||||
userId,
|
||||
maxAge: 8 * 60 * 60 * 1000, // 8 hours
|
||||
maxAge: 2 * 60 * 60 * 1000, // 2 hours
|
||||
});
|
||||
};
|
||||
export { cookieSession$0 as cookieSession };
|
||||
|
||||
@@ -4,4 +4,6 @@ export const DEFAULT_CONFIG = {
|
||||
workingHours: { from: '', to: '' },
|
||||
demoMode: false,
|
||||
analyticsEnabled: null,
|
||||
// Default path for sqlite storage directory. Interpreted relative to project root.
|
||||
sqlitepath: '/db',
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
||||
const jobName = job == null ? jobKey : job.name;
|
||||
const promises = newListings.map((newListing) => {
|
||||
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, {
|
||||
method: 'POST',
|
||||
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, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: {
|
||||
body: JSON.stringify({
|
||||
channel: channel,
|
||||
text: message,
|
||||
},
|
||||
}),
|
||||
});
|
||||
};
|
||||
export const config = {
|
||||
|
||||
@@ -15,11 +15,17 @@ Size: ${newListing.size == null ? 'N/A' : newListing.size.replace(/2m/g, '$m^2$'
|
||||
Price: ${newListing.price}
|
||||
Link: ${newListing.link}`;
|
||||
|
||||
const sanitizeHeaderValue = (value) =>
|
||||
String(value ?? '')
|
||||
.replace(/[\r\n]+/g, ' ')
|
||||
.replace(/[^\x20-\x7E]/g, ' ')
|
||||
.trim();
|
||||
|
||||
const headers = {
|
||||
Title: newListing.title,
|
||||
Priority: String(priority),
|
||||
Tags: `${serviceName},${jobName}`,
|
||||
Click: newListing.link,
|
||||
Title: sanitizeHeaderValue(newListing.title),
|
||||
Priority: sanitizeHeaderValue(priority),
|
||||
Tags: sanitizeHeaderValue(`${serviceName},${jobName}`),
|
||||
Click: sanitizeHeaderValue(newListing.link),
|
||||
};
|
||||
|
||||
if (newListing.image && typeof newListing.image === 'string') {
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { markdown2Html } from '../../services/markdown.js';
|
||||
import Database from 'better-sqlite3';
|
||||
export const send = ({ serviceName, newListings, jobKey }) => {
|
||||
const db = new Database('db/listings.db');
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
export const send = ({ serviceName, newListings, jobKey, notificationConfig }) => {
|
||||
const sqliteConfig = notificationConfig.find((adapter) => adapter.id === config.id);
|
||||
const dbPath = sqliteConfig?.fields?.dbPath || 'db/listings.db';
|
||||
|
||||
const dbDir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
const db = new Database(dbPath);
|
||||
const fields = [
|
||||
'serviceName',
|
||||
'jobKey',
|
||||
@@ -30,8 +41,16 @@ export const send = ({ serviceName, newListings, jobKey }) => {
|
||||
};
|
||||
export const config = {
|
||||
id: 'sqlite',
|
||||
name: 'Sqlite',
|
||||
description: 'This adapter stores listings in a local sqlite3 database.',
|
||||
config: {},
|
||||
name: 'SQLite',
|
||||
description: 'This adapter stores listings in a local SQLite 3 database.',
|
||||
fields: {
|
||||
dbPath: {
|
||||
type: 'text',
|
||||
label: 'Database Path',
|
||||
description:
|
||||
'Path to the SQLite database file (e.g., db/listings.db). If not specified, defaults to db/listings.db',
|
||||
placeholder: 'db/listings.db',
|
||||
},
|
||||
},
|
||||
readme: markdown2Html('lib/notification/adapter/sqlite.md'),
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
### Sqlite Adapter
|
||||
### SQLite Adapter
|
||||
|
||||
This adapter stores search results in a sqlite database located in db/listings.db. This file can be used for further analysis later on.
|
||||
This adapter stores search results in an SQLite database. By default, the database is located at `db/listings.db`, but you can configure a custom location. This file can be used for further analysis later.
|
||||
|
||||
Fields are:
|
||||
The database table contains the following columns (all stored as `TEXT` type):
|
||||
|
||||
```
|
||||
['serviceName', 'jobKey', 'id', 'size', 'rooms', 'price', 'address', 'title', 'link', 'description']
|
||||
['serviceName', 'jobKey', 'id', 'size', 'rooms', 'price', 'address', 'title', 'link', 'description', 'image']
|
||||
```
|
||||
|
||||
@@ -3,10 +3,14 @@ import { getJob } from '../../services/storage/jobStorage.js';
|
||||
import fetch from 'node-fetch';
|
||||
import pThrottle from 'p-throttle';
|
||||
import { normalizeImageUrl } from '../../utils.js';
|
||||
import logger from '../../services/logger.js';
|
||||
|
||||
const RATE_LIMIT_INTERVAL = 1000;
|
||||
const chatThrottleMap = new Map();
|
||||
|
||||
/**
|
||||
* Removes stale throttled call entries to keep memory bounded.
|
||||
*/
|
||||
function cleanupOldThrottles() {
|
||||
const now = Date.now();
|
||||
const maxAge = RATE_LIMIT_INTERVAL + 1000;
|
||||
@@ -17,6 +21,15 @@ function cleanupOldThrottles() {
|
||||
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) {
|
||||
cleanupOldThrottles();
|
||||
const now = Date.now();
|
||||
@@ -30,15 +43,38 @@ function getThrottled(chatId, call) {
|
||||
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) {
|
||||
if (!str) return '';
|
||||
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 = '') {
|
||||
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) {
|
||||
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
||||
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 jobName = job == null ? jobKey : job.name;
|
||||
|
||||
@@ -68,9 +130,16 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
||||
body: JSON.stringify(body),
|
||||
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;
|
||||
});
|
||||
|
||||
if (!Array.isArray(newListings) || newListings.length === 0) return Promise.resolve([]);
|
||||
|
||||
const promises = newListings.map(async (o) => {
|
||||
const img = normalizeImageUrl(o.image);
|
||||
const textPayload = {
|
||||
@@ -81,28 +150,32 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
||||
};
|
||||
|
||||
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', {
|
||||
chat_id: chatId,
|
||||
photo: img,
|
||||
caption: buildCaption(jobName, serviceName, o),
|
||||
parse_mode: 'HTML',
|
||||
return await throttledCall('sendPhoto', {
|
||||
chat_id: chatId,
|
||||
photo: img,
|
||||
caption: buildCaption(jobName, serviceName, o),
|
||||
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);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 = {
|
||||
id: '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 = [];
|
||||
|
||||
function normalize(o) {
|
||||
@@ -7,7 +8,8 @@ function normalize(o) {
|
||||
const price = normalizePrice(o.price);
|
||||
const id = buildHash(o.id, price);
|
||||
const image = baseUrl + o.image;
|
||||
return Object.assign(o, { id, price, link, image });
|
||||
const address = o.address == null ? null : o.address.trim().replaceAll('/', ',');
|
||||
return Object.assign(o, { id, price, link, image, address });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,8 +30,8 @@ function normalizePrice(price) {
|
||||
return result[0];
|
||||
}
|
||||
function applyBlacklist(o) {
|
||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||
return titleNotBlacklisted && descNotBlacklisted;
|
||||
}
|
||||
|
||||
@@ -44,9 +46,11 @@ const config = {
|
||||
size: '.tabelle .tabelle_inhalt_infos .single_data_box | removeNewline | trim',
|
||||
title: '.inner_object_data .tabelle_inhalt_titel_black | removeNewline | trim',
|
||||
image: '.inner_object_pic img@src',
|
||||
address: '.tabelle .tabelle_inhalt_infos .left_information > div:nth-child(2) | removeNewline | trim',
|
||||
},
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
activeTester: checkIfListingIsActive,
|
||||
};
|
||||
export const init = (sourceConfig, blacklist) => {
|
||||
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 = [];
|
||||
|
||||
@@ -12,10 +13,10 @@ function parseId(shortenedLink) {
|
||||
|
||||
function normalize(o) {
|
||||
const baseUrl = 'https://www.immobilien.de';
|
||||
const size = o.size || 'N/A m²';
|
||||
const price = o.price || 'N/A €';
|
||||
const size = o.size || null;
|
||||
const price = o.price || null;
|
||||
const title = o.title || 'No title available';
|
||||
const address = o.address || 'No address available';
|
||||
const address = o.address || null;
|
||||
const shortLink = shortenLink(o.link);
|
||||
const link = `${baseUrl}/${shortLink}`;
|
||||
const image = baseUrl + o.image;
|
||||
@@ -24,8 +25,8 @@ function normalize(o) {
|
||||
}
|
||||
|
||||
function applyBlacklist(o) {
|
||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||
return titleNotBlacklisted && descNotBlacklisted;
|
||||
}
|
||||
|
||||
@@ -46,6 +47,7 @@ const config = {
|
||||
},
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
activeTester: checkIfListingIsActive,
|
||||
};
|
||||
export const init = (sourceConfig, blacklist) => {
|
||||
config.enabled = sourceConfig.enabled;
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
import utils, { buildHash } from '../utils.js';
|
||||
import { isOneOf, buildHash } from '../utils.js';
|
||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||
let appliedBlackList = [];
|
||||
|
||||
/**
|
||||
* Note, Immonet is rly a piece of sh*t. It is using a weird combination of React and some buttons (instead of links),
|
||||
* so that if somebody clicks the listing, a new page will open with the actual link to the listing. Of course, a scraper
|
||||
* cannot do this (which is why I always just return the link to the whole list of listings).
|
||||
* This is not only bad for us, but also bad for ppl with disabilities...
|
||||
*/
|
||||
|
||||
function normalize(o) {
|
||||
const size = o.size != null ? o.size.replace('Wohnfläche ', '') : 'N/A m²';
|
||||
const price = o.price.replace('Kaufpreis ', '');
|
||||
const address = o.address?.split(' • ')?.pop() ?? null;
|
||||
const title = o.title || 'No title available';
|
||||
const link = config.url;
|
||||
const link = o.link != null ? decodeURIComponent(o.link) : config.url;
|
||||
const id = buildHash(title, price);
|
||||
return Object.assign(o, { id, address, price, size, title, link });
|
||||
}
|
||||
function applyBlacklist(o) {
|
||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||
return titleNotBlacklisted && descNotBlacklisted;
|
||||
}
|
||||
const config = {
|
||||
@@ -28,15 +22,18 @@ const config = {
|
||||
sortByDateParam: 'sortby=19',
|
||||
waitForSelector: 'div[data-testid="serp-gridcontainer-testid"]',
|
||||
crawlFields: {
|
||||
id: 'button@title |trim', // immonet is a piece of sh*t. See comment above
|
||||
id: 'button@title |trim',
|
||||
title: 'button@title |trim',
|
||||
price: 'div[data-testid="cardmfe-price-testid"] | trim',
|
||||
size: 'div[data-testid="cardmfe-keyfacts-testid"] | trim',
|
||||
address: 'div[data-testid="cardmfe-description-box-address"] | trim',
|
||||
image: 'div[data-testid="cardmfe-picture-box-test-id"] img@src',
|
||||
link: 'button@data-base',
|
||||
description: 'div[data-testid="cardmfe-description-text-test-id"] | trim',
|
||||
},
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
activeTester: checkIfListingIsActive,
|
||||
};
|
||||
export const init = (sourceConfig, blacklist) => {
|
||||
config.enabled = sourceConfig.enabled;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* The mobile API provides the following endpoints:
|
||||
* - 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
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
* 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.
|
||||
@@ -35,8 +35,11 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import utils, { buildHash } from '../utils.js';
|
||||
import { convertWebToMobile } from '../services/immoscout/immoscout-web-translator.js';
|
||||
import { buildHash, isOneOf } from '../utils.js';
|
||||
import {
|
||||
convertImmoscoutListingToMobileListing,
|
||||
convertWebToMobile,
|
||||
} from '../services/immoscout/immoscout-web-translator.js';
|
||||
import logger from '../services/logger.js';
|
||||
let appliedBlackList = [];
|
||||
|
||||
@@ -44,7 +47,7 @@ async function getListings(url) {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'User-Agent': 'ImmoScout24_1410_30_._',
|
||||
'User-Agent': 'ImmoScout_27.3_26.0_._',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
@@ -69,6 +72,7 @@ async function getListings(url) {
|
||||
price: price?.value,
|
||||
size: size?.value,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
link: `${metaInformation.baseUrl}expose/${item.id}`,
|
||||
address: item.address?.line,
|
||||
image,
|
||||
@@ -76,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) {
|
||||
return val == null || val.length === 0;
|
||||
}
|
||||
@@ -86,7 +109,7 @@ function normalize(o) {
|
||||
return Object.assign(o, { id, title, address });
|
||||
}
|
||||
function applyBlacklist(o) {
|
||||
return !utils.isOneOf(o.title, appliedBlackList);
|
||||
return !isOneOf(o.title, appliedBlackList);
|
||||
}
|
||||
const config = {
|
||||
url: null,
|
||||
@@ -103,6 +126,7 @@ const config = {
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
getListings: getListings,
|
||||
activeTester: isListingActive,
|
||||
};
|
||||
export const init = (sourceConfig, blacklist) => {
|
||||
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 = [];
|
||||
|
||||
@@ -14,8 +15,8 @@ function normalize(o) {
|
||||
}
|
||||
|
||||
function applyBlacklist(o) {
|
||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||
return titleNotBlacklisted && descNotBlacklisted;
|
||||
}
|
||||
|
||||
@@ -35,6 +36,7 @@ const config = {
|
||||
},
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
activeTester: checkIfListingIsActive,
|
||||
};
|
||||
export const init = (sourceConfig, blacklist) => {
|
||||
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 = [];
|
||||
|
||||
@@ -8,8 +9,8 @@ function normalize(o) {
|
||||
}
|
||||
|
||||
function applyBlacklist(o) {
|
||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||
return titleNotBlacklisted && descNotBlacklisted;
|
||||
}
|
||||
|
||||
@@ -25,11 +26,13 @@ const config = {
|
||||
size: 'div[data-testid="cardmfe-keyfacts-testid"] | removeNewline | trim',
|
||||
title: 'div[data-testid="cardmfe-description-box-text-test-id"] > div:nth-of-type(2)',
|
||||
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',
|
||||
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,
|
||||
filter: applyBlacklist,
|
||||
activeTester: checkIfListingIsActive,
|
||||
};
|
||||
export const init = (sourceConfig, blacklist) => {
|
||||
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 appliedBlacklistedDistricts = [];
|
||||
@@ -11,10 +12,10 @@ function normalize(o) {
|
||||
}
|
||||
|
||||
function applyBlacklist(o) {
|
||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -36,6 +37,7 @@ const config = {
|
||||
},
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
activeTester: checkIfListingIsActive,
|
||||
};
|
||||
export const metaInformation = {
|
||||
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 = [];
|
||||
|
||||
@@ -15,14 +16,14 @@ function normalize(o) {
|
||||
}
|
||||
|
||||
function applyBlacklist(o) {
|
||||
return !utils.isOneOf(o.title, appliedBlackList);
|
||||
return !isOneOf(o.title, appliedBlackList);
|
||||
}
|
||||
|
||||
const config = {
|
||||
url: null,
|
||||
crawlContainer: '.col-12.mb-4',
|
||||
sortByDateParam: 'Sortierung=Id&Richtung=DESC',
|
||||
waitForSelector: '.nbk-section',
|
||||
waitForSelector: 'div[data-live-name-value="SearchList"]',
|
||||
crawlFields: {
|
||||
id: 'a@href',
|
||||
title: 'a@title | removeNewline | trim',
|
||||
@@ -33,6 +34,7 @@ const config = {
|
||||
},
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
activeTester: checkIfListingIsActive,
|
||||
};
|
||||
export const init = (sourceConfig, blacklist) => {
|
||||
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 = [];
|
||||
|
||||
@@ -10,8 +11,8 @@ function normalize(o) {
|
||||
}
|
||||
|
||||
function applyBlacklist(o) {
|
||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||
return o.id != null && titleNotBlacklisted && descNotBlacklisted;
|
||||
}
|
||||
|
||||
@@ -31,6 +32,7 @@ const config = {
|
||||
},
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
activeTester: checkIfListingIsActive,
|
||||
};
|
||||
export const init = (sourceConfig, blacklist) => {
|
||||
config.enabled = sourceConfig.enabled;
|
||||
|
||||
23
lib/services/crons/demoCleanup-cron.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { removeJobsByUserId } from '../storage/jobStorage.js';
|
||||
import { config } from '../../utils.js';
|
||||
import { getUsers } from '../storage/userStorage.js';
|
||||
import logger from '../logger.js';
|
||||
import cron from 'node-cron';
|
||||
|
||||
/**
|
||||
* if we are running in demo environment, we have to cleanup the db files (specifically the jobs table)
|
||||
*/
|
||||
export function cleanupDemoAtMidnight() {
|
||||
cron.schedule('0 0 * * *', cleanup);
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (config.demoMode) {
|
||||
const demoUser = getUsers(false).find((user) => user.username === 'demo');
|
||||
if (demoUser == null) {
|
||||
logger.error('Demo user not found, cannot remove Jobs');
|
||||
return;
|
||||
}
|
||||
removeJobsByUserId(demoUser.id);
|
||||
}
|
||||
}
|
||||
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 { config, inDevMode } from '../../utils.js';
|
||||
import { trackMainEvent } from './Tracker.js';
|
||||
import { trackMainEvent } from '../tracking/Tracker.js';
|
||||
|
||||
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
|
||||
@@ -1,38 +0,0 @@
|
||||
import { setInterval } from 'node:timers';
|
||||
import { removeJobsByUserName } from './storage/jobStorage.js';
|
||||
import { config } from '../utils.js';
|
||||
import { getUsers } from './storage/userStorage.js';
|
||||
import logger from './logger.js';
|
||||
|
||||
/**
|
||||
* if we are running in demo environment, we have to cleanup the db files (specifically the jobs table)
|
||||
*/
|
||||
export function cleanupDemoAtMidnight() {
|
||||
const now = new Date();
|
||||
const millisUntilMidnightUTC =
|
||||
(24 - now.getUTCHours()) * 60 * 60 * 1000 -
|
||||
now.getUTCMinutes() * 60 * 1000 -
|
||||
now.getUTCSeconds() * 1000 -
|
||||
now.getUTCMilliseconds();
|
||||
|
||||
cleanup();
|
||||
setTimeout(() => {
|
||||
setInterval(
|
||||
() => {
|
||||
cleanup();
|
||||
},
|
||||
24 * 60 * 60 * 1000,
|
||||
);
|
||||
}, millisUntilMidnightUTC);
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (config.demoMode) {
|
||||
const demoUser = getUsers(false).find((user) => user.username === 'demo');
|
||||
if (demoUser == null) {
|
||||
logger.error('Demo user not found, cannot remove Jobs');
|
||||
return;
|
||||
}
|
||||
removeJobsByUserName(demoUser.id);
|
||||
}
|
||||
}
|
||||
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) {
|
||||
if (!text) {
|
||||
logger.warn('No content found for ', url);
|
||||
logger.debug('No content found for ', url);
|
||||
return null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const result = [];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,30 +2,56 @@ import puppeteer from 'puppeteer-extra';
|
||||
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
||||
import { debug, DEFAULT_HEADER, botDetected } from './utils.js';
|
||||
import logger from '../logger.js';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
puppeteer.use(StealthPlugin());
|
||||
|
||||
export default async function execute(url, waitForSelector, options) {
|
||||
let browser;
|
||||
let page;
|
||||
let result = null;
|
||||
let userDataDir;
|
||||
let removeUserDataDir = false;
|
||||
try {
|
||||
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({
|
||||
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,
|
||||
userDataDir,
|
||||
});
|
||||
let page = await browser.newPage();
|
||||
page = await browser.newPage();
|
||||
await page.setExtraHTTPHeaders(DEFAULT_HEADER);
|
||||
const response = await page.goto(url, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
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) {
|
||||
await page.waitForSelector(waitForSelector);
|
||||
const selectorTimeout = options?.puppeteerSelectorTimeout ?? options?.puppeteerTimeout ?? 30_000;
|
||||
await page.waitForSelector(waitForSelector, { timeout: selectorTimeout });
|
||||
pageSource = await page.evaluate((selector) => {
|
||||
return document.querySelector(selector).innerHTML;
|
||||
const el = document.querySelector(selector);
|
||||
return el ? el.innerHTML : '';
|
||||
}, waitForSelector);
|
||||
} else {
|
||||
pageSource = await page.content();
|
||||
@@ -35,16 +61,35 @@ export default async function execute(url, waitForSelector, options) {
|
||||
|
||||
if (botDetected(pageSource, statusCode)) {
|
||||
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) {
|
||||
logger.error('Error executing with puppeteer executor', error);
|
||||
return null;
|
||||
result = null;
|
||||
} finally {
|
||||
if (browser != null) {
|
||||
await browser.close();
|
||||
try {
|
||||
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?
|
||||
*/
|
||||
import queryString from 'query-string';
|
||||
import { nullOrEmpty } from '../../utils.js';
|
||||
|
||||
const PARAM_NAME_MAP = {
|
||||
heatingtypes: 'heatingtypes',
|
||||
@@ -193,3 +194,14 @@ export function convertWebToMobile(webUrl) {
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ const COLORS = {
|
||||
reset: '\x1b[0m',
|
||||
};
|
||||
|
||||
const env = process.env.NODE_ENV || 'development';
|
||||
const useColor = process.stdout.isTTY || process.stderr.isTTY;
|
||||
|
||||
function ts() {
|
||||
@@ -27,6 +28,10 @@ function lvl(level) {
|
||||
|
||||
/* eslint-disable no-console */
|
||||
function log(level, ...args) {
|
||||
if (level === 'debug' && env !== 'development') {
|
||||
return; // Skip debug logs in non-development environments
|
||||
}
|
||||
|
||||
const prefix = `[${ts()}] ${lvl(level)}:`;
|
||||
switch (level) {
|
||||
case 'debug':
|
||||
@@ -52,10 +57,3 @@ export default {
|
||||
warn: (...a) => log('warn', ...a),
|
||||
error: (...a) => log('error', ...a),
|
||||
};
|
||||
|
||||
// Beispiel:
|
||||
// import logger from './logger.js';
|
||||
// const a = 'fick';
|
||||
// const b = { tr: 'lolo' };
|
||||
// logger.info('hallo', a, b);
|
||||
// -> In IntelliJ siehst du das Objekt wie bei console.info, plus Prefix
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import lodash from 'lodash';
|
||||
import { LowSync } from 'lowdb';
|
||||
export default class LowdashAdapter extends LowSync {
|
||||
constructor(adapter, defaultData = {}) {
|
||||
super(adapter, defaultData);
|
||||
this.chain = lodash.chain(this).get('data');
|
||||
}
|
||||
}
|
||||
140
lib/services/storage/SqliteConnection.js
Normal file
@@ -0,0 +1,140 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import Database from 'better-sqlite3';
|
||||
import logger from '../../services/logger.js';
|
||||
import { config } from '../../utils.js';
|
||||
|
||||
/**
|
||||
* SqliteConnection
|
||||
* A small, high-performance wrapper around better-sqlite3 that provides a
|
||||
* singleton connection, sensible PRAGMA tuning, and helper methods. This
|
||||
* module is safe to import and reuse.
|
||||
*
|
||||
* Performance notes:
|
||||
* - journal_mode = WAL: allows concurrent readers with a single writer and
|
||||
* yields better performance for server apps.
|
||||
* - synchronous = NORMAL: trades a bit of durability for significant speed
|
||||
* while still being safe in most environments.
|
||||
* - cache_size = -64000: ~64MB page cache (negative value sets KB) to improve
|
||||
* query performance for frequent reads.
|
||||
* - foreign_keys = ON: ensure referential integrity is enforced.
|
||||
* - optimize: runs SQLite's auto-analysis and purges internal caches. It is
|
||||
* cheap; we call it at startup and before process exit. You can also call
|
||||
* optimize() manually after large schema changes or bulk operations.
|
||||
*/
|
||||
class SqliteConnection {
|
||||
static #db = null;
|
||||
|
||||
/**
|
||||
* Returns a singleton instance of better-sqlite3 Database.
|
||||
* Respects env var SQLITE_DB_PATH and defaults to db/listings.db.
|
||||
*/
|
||||
static getConnection() {
|
||||
if (this.#db) return this.#db;
|
||||
|
||||
// Interpret config.sqlitepath as a directory relative to project root when it starts with '/'
|
||||
const cfg = typeof config === 'object' && config ? config.sqlitepath : undefined;
|
||||
const rawDir = cfg && cfg.length > 0 ? cfg : '/db';
|
||||
const relDir = rawDir.startsWith('/') ? rawDir.slice(1) : rawDir;
|
||||
const absDir = path.isAbsolute(relDir) ? relDir : path.join(process.cwd(), relDir);
|
||||
const dbPath = path.join(absDir, 'listings.db');
|
||||
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
// Open the database synchronously (better-sqlite3 is sync and very fast)
|
||||
this.#db = new Database(dbPath, { verbose: undefined });
|
||||
|
||||
// Apply high-performance PRAGMA's
|
||||
try {
|
||||
this.#db.pragma('journal_mode = WAL');
|
||||
this.#db.pragma('synchronous = NORMAL');
|
||||
this.#db.pragma('cache_size = -64000');
|
||||
this.#db.pragma('foreign_keys = ON');
|
||||
this.#db.pragma('optimize');
|
||||
} catch (e) {
|
||||
logger.warn('Failed to apply one or more PRAGMAs:', e.message);
|
||||
}
|
||||
|
||||
// Run optimize on exit to persist analysis and cleanup internal caches.
|
||||
process.once('beforeExit', () => {
|
||||
try {
|
||||
this.#db?.pragma('optimize');
|
||||
} catch (e) {
|
||||
logger.debug('PRAGMA optimize on exit failed:', e.message);
|
||||
}
|
||||
});
|
||||
|
||||
return this.#db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a write statement (INSERT/UPDATE/DELETE/DDL). Returns better-sqlite3 run info.
|
||||
*/
|
||||
static execute(sql, params = {}) {
|
||||
const db = this.getConnection();
|
||||
return db.prepare(sql).run(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query and returns all rows.
|
||||
*/
|
||||
static query(sql, params = {}) {
|
||||
const db = this.getConnection();
|
||||
return db.prepare(sql).all(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a table exists.
|
||||
*/
|
||||
static tableExists(tableName) {
|
||||
const db = this.getConnection();
|
||||
const row = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(tableName);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the given callback inside a transaction. The callback receives the Database instance.
|
||||
* If the callback throws, the transaction is rolled back and the error re-thrown.
|
||||
*/
|
||||
static withTransaction(callback) {
|
||||
const db = this.getConnection();
|
||||
const trx = db.transaction((cb) => cb(db));
|
||||
return trx(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run SQLite PRAGMA optimize. See https://sqlite.org/pragma.html#pragma_optimize
|
||||
*
|
||||
* Explanation: PRAGMA optimize triggers internal housekeeping, such as
|
||||
* recomputing query planner statistics (similar to ANALYZE) when appropriate
|
||||
* and purging unused pages from caches. It is inexpensive and can improve
|
||||
* performance after schema changes or heavy write activity.
|
||||
*/
|
||||
static optimize() {
|
||||
const db = this.getConnection();
|
||||
try {
|
||||
db.pragma('optimize');
|
||||
} catch (e) {
|
||||
logger.warn('PRAGMA optimize failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection. Typically not needed for long-running apps.
|
||||
*/
|
||||
static close() {
|
||||
if (this.#db) {
|
||||
try {
|
||||
this.#db.pragma('optimize');
|
||||
} catch (e) {
|
||||
logger.debug('PRAGMA optimize before close failed:', e.message);
|
||||
}
|
||||
this.#db.close();
|
||||
this.#db = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default SqliteConnection;
|
||||
@@ -1,106 +1,144 @@
|
||||
import { JSONFileSync } from 'lowdb/node';
|
||||
import { nanoid } from 'nanoid';
|
||||
import * as listingStorage from './listingsStorage.js';
|
||||
import { getDirName } from '../../utils.js';
|
||||
import path from 'path';
|
||||
import LowdashAdapter from './LowDashAdapter.js';
|
||||
import SqliteConnection from './SqliteConnection.js';
|
||||
import logger from '../logger.js';
|
||||
import { toJson, fromJson } from '../../utils.js';
|
||||
|
||||
const file = path.join(getDirName(), '../', 'db/jobs.json');
|
||||
const adapter = new JSONFileSync(file);
|
||||
const db = new LowdashAdapter(adapter, { jobs: [] });
|
||||
|
||||
db.read();
|
||||
|
||||
/**
|
||||
* Insert or update a job. Preserves original owner (userId) when updating an existing job.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} [params.jobId] - Existing job id to update; omit to insert a new job.
|
||||
* @param {string} [params.name] - Job display name.
|
||||
* @param {Array<any>} [params.blacklist] - Blacklist entries; defaults to empty array.
|
||||
* @param {boolean} [params.enabled] - Whether the job is enabled; defaults to true.
|
||||
* @param {Array<any>} params.provider - Provider configuration list.
|
||||
* @param {Array<any>} params.notificationAdapter - Notification adapter configuration list.
|
||||
* @param {string} params.userId - Owner user id for inserts; preserved on updates.
|
||||
* @returns {void}
|
||||
*/
|
||||
export const upsertJob = ({ jobId, name, blacklist = [], enabled = true, provider, notificationAdapter, userId }) => {
|
||||
const currentJob =
|
||||
jobId == null
|
||||
? null
|
||||
: db.chain
|
||||
.get('jobs')
|
||||
.find((job) => job.id === jobId)
|
||||
.value();
|
||||
const jobs = db.chain
|
||||
.get('jobs')
|
||||
.filter((job) => job.id !== jobId)
|
||||
.value();
|
||||
jobs.push({
|
||||
id: jobId || nanoid(),
|
||||
//make sure to not overwrite the user id in case an admin changes the job
|
||||
userId: currentJob == null ? userId : currentJob.userId,
|
||||
enabled,
|
||||
name,
|
||||
blacklist,
|
||||
provider,
|
||||
notificationAdapter,
|
||||
});
|
||||
db.chain.set('jobs', jobs).value();
|
||||
db.write();
|
||||
};
|
||||
export const getJob = (jobId) => {
|
||||
const job = db.chain
|
||||
.get('jobs')
|
||||
.find((job) => job.id === jobId)
|
||||
.value();
|
||||
if (job == null) {
|
||||
return null;
|
||||
const id = jobId || nanoid();
|
||||
const existing = SqliteConnection.query(`SELECT id, user_id FROM jobs WHERE id = @id LIMIT 1`, { id })[0];
|
||||
const ownerId = existing ? existing.user_id : userId;
|
||||
if (existing) {
|
||||
SqliteConnection.execute(
|
||||
`UPDATE jobs
|
||||
SET enabled = @enabled,
|
||||
name = @name,
|
||||
blacklist = @blacklist,
|
||||
provider = @provider,
|
||||
notification_adapter = @notification_adapter
|
||||
WHERE id = @id`,
|
||||
{
|
||||
id,
|
||||
enabled: enabled ? 1 : 0,
|
||||
name: name ?? null,
|
||||
blacklist: toJson(blacklist ?? []),
|
||||
provider: toJson(provider ?? []),
|
||||
notification_adapter: toJson(notificationAdapter ?? []),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
SqliteConnection.execute(
|
||||
`INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter)
|
||||
VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter)`,
|
||||
{
|
||||
id,
|
||||
user_id: ownerId,
|
||||
enabled: enabled ? 1 : 0,
|
||||
name: name ?? null,
|
||||
blacklist: toJson(blacklist ?? []),
|
||||
provider: toJson(provider ?? []),
|
||||
notification_adapter: toJson(notificationAdapter ?? []),
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a single job by id.
|
||||
* @param {string} jobId - Job primary key.
|
||||
* @returns {Job|null} The job or null if not found.
|
||||
*/
|
||||
export const getJob = (jobId) => {
|
||||
const row = SqliteConnection.query(
|
||||
`SELECT j.id,
|
||||
j.user_id AS userId,
|
||||
j.enabled,
|
||||
j.name,
|
||||
j.blacklist,
|
||||
j.provider,
|
||||
j.notification_adapter AS notificationAdapter,
|
||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
|
||||
FROM jobs j
|
||||
WHERE j.id = @id
|
||||
LIMIT 1`,
|
||||
{ id: jobId },
|
||||
)[0];
|
||||
if (!row) return null;
|
||||
return {
|
||||
...job,
|
||||
numberOfFoundListings: listingStorage.getNumberOfAllKnownListings(job.id).length,
|
||||
...row,
|
||||
enabled: !!row.enabled,
|
||||
blacklist: fromJson(row.blacklist, []),
|
||||
provider: fromJson(row.provider, []),
|
||||
notificationAdapter: fromJson(row.notificationAdapter, []),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Update job enabled status.
|
||||
* @param {{jobId: string, status: boolean}} params - Parameters.
|
||||
* @returns {void}
|
||||
*/
|
||||
export const setJobStatus = ({ jobId, status }) => {
|
||||
db.chain
|
||||
.get('jobs')
|
||||
.find((job) => job.id === jobId)
|
||||
.assign({ enabled: status })
|
||||
.value();
|
||||
db.write();
|
||||
SqliteConnection.execute(`UPDATE jobs SET enabled = @enabled WHERE id = @id`, {
|
||||
id: jobId,
|
||||
enabled: status ? 1 : 0,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove a job by id. Listings are deleted automatically due to FK ON DELETE CASCADE.
|
||||
* @param {string} jobId - Job id.
|
||||
* @returns {void}
|
||||
*/
|
||||
export const removeJob = (jobId) => {
|
||||
listingStorage.removeListings(jobId);
|
||||
db.chain
|
||||
.get('jobs')
|
||||
.remove((job) => job.id === jobId)
|
||||
.value();
|
||||
db.write();
|
||||
// listings table has FK ON DELETE CASCADE via job_id
|
||||
SqliteConnection.execute(`DELETE FROM jobs WHERE id = @id`, { id: jobId });
|
||||
};
|
||||
|
||||
export const removeJobsByUserId = (userId) => {
|
||||
db.chain
|
||||
.get('jobs')
|
||||
.filter((job) => job.userId === userId)
|
||||
.forEach((job) => listingStorage.removeListings(job.id));
|
||||
db.chain
|
||||
.get('jobs')
|
||||
.remove((job) => job.userId === userId)
|
||||
.value();
|
||||
db.write();
|
||||
};
|
||||
export const removeJobsByUserName = (userId) => {
|
||||
let removedDemoJobs = 0;
|
||||
db.chain
|
||||
.get('jobs')
|
||||
.filter((job) => job.userId === userId)
|
||||
.forEach((job) => {
|
||||
removedDemoJobs++;
|
||||
listingStorage.removeListings(job.id);
|
||||
});
|
||||
db.chain
|
||||
.get('jobs')
|
||||
.remove((job) => job.userId === userId)
|
||||
.value();
|
||||
db.write();
|
||||
if (removedDemoJobs > 0) {
|
||||
logger.info(`Removed ${removedDemoJobs} demo jobs`);
|
||||
// Count jobs to log similar to previous behavior
|
||||
const count =
|
||||
SqliteConnection.query(`SELECT COUNT(1) AS c FROM jobs WHERE user_id = @user_id`, { user_id: userId })[0]?.c ?? 0;
|
||||
SqliteConnection.execute(`DELETE FROM jobs WHERE user_id = @user_id`, { user_id: userId });
|
||||
if (count > 0) {
|
||||
logger.info(`Removed ${count} jobs for user ${userId}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all jobs.
|
||||
* @returns {Job[]} List of jobs ordered by name (NULLs last).
|
||||
*/
|
||||
export const getJobs = () => {
|
||||
return db.chain
|
||||
.get('jobs')
|
||||
.map((job) => ({
|
||||
...job,
|
||||
numberOfFoundListings: listingStorage.getNumberOfAllKnownListings(job.id),
|
||||
}))
|
||||
.value();
|
||||
const rows = SqliteConnection.query(
|
||||
`SELECT j.id,
|
||||
j.user_id AS userId,
|
||||
j.enabled,
|
||||
j.name,
|
||||
j.blacklist,
|
||||
j.provider,
|
||||
j.notification_adapter AS notificationAdapter,
|
||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
|
||||
FROM jobs j
|
||||
ORDER BY j.name IS NULL, j.name`,
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
enabled: !!row.enabled,
|
||||
blacklist: fromJson(row.blacklist, []),
|
||||
provider: fromJson(row.provider, []),
|
||||
notificationAdapter: fromJson(row.notificationAdapter, []),
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -1,52 +1,331 @@
|
||||
import { JSONFileSync } from 'lowdb/node';
|
||||
import { getDirName } from '../../utils.js';
|
||||
import path from 'path';
|
||||
import LowdashAdapter from './LowDashAdapter.js';
|
||||
import { nullOrEmpty } from '../../utils.js';
|
||||
import SqliteConnection from './SqliteConnection.js';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
const file = path.join(getDirName(), '../', 'db/jobListingData.json');
|
||||
const adapter = new JSONFileSync(file);
|
||||
const db = new LowdashAdapter(adapter, {});
|
||||
|
||||
db.read();
|
||||
|
||||
const buildKey = (jobKey, providerId, endpoint) => {
|
||||
let key = `${jobKey}`;
|
||||
if (jobKey == null && endpoint == null) {
|
||||
return key;
|
||||
}
|
||||
if (providerId != null) {
|
||||
key += `.${providerId}`;
|
||||
}
|
||||
if (endpoint != null) {
|
||||
key += `.${endpoint}`;
|
||||
}
|
||||
return key;
|
||||
};
|
||||
export const getNumberOfAllKnownListings = (jobId) => {
|
||||
const data = db.chain.get(`${jobId}.providerData`).value() || {};
|
||||
return Object.values(data)
|
||||
.map((values) => Object.keys(values).length)
|
||||
.reduce((accumulator, currentValue) => accumulator + currentValue, 0);
|
||||
};
|
||||
/**
|
||||
* Build analytics data for a given job by grouping all listings by provider and
|
||||
* mapping each listing hash to its creation timestamp.
|
||||
*
|
||||
* SQL shape:
|
||||
* SELECT json_group_object(provider, json_object(hash, created_at)) AS result
|
||||
* FROM listings WHERE job_id = @jobId;
|
||||
*
|
||||
* The resulting object has the shape:
|
||||
* {
|
||||
* providerA: { "<hash1>": <created_at_ms>, "<hash2>": <created_at_ms>, ... },
|
||||
* providerB: { ... }
|
||||
* }
|
||||
*
|
||||
* @param {string} jobId - ID of the job whose listings should be aggregated.
|
||||
* @returns {Record<string, Record<string, number>>} Object grouped by provider mapping listing-hash -> created_at epoch ms.
|
||||
*/
|
||||
export const getListingProviderDataForAnalytics = (jobId) => {
|
||||
const key = buildKey(jobId, 'providerData');
|
||||
return db.chain.get(key).value() || {};
|
||||
const row = SqliteConnection.query(
|
||||
`SELECT COALESCE(
|
||||
json_group_object(provider, json(provider_map)),
|
||||
json('{}')
|
||||
) AS result
|
||||
FROM (SELECT provider,
|
||||
json_group_object(hash, created_at) AS provider_map
|
||||
FROM listings
|
||||
WHERE job_id = @jobId
|
||||
GROUP BY provider);`,
|
||||
{ jobId },
|
||||
);
|
||||
|
||||
return row?.length > 0 ? JSON.parse(row[0].result) : {};
|
||||
};
|
||||
export const getKnownListings = (jobId, providerId) => {
|
||||
const providerListingsKey = buildKey(jobId, 'providerData', providerId, 'listings');
|
||||
return db.chain.get(providerListingsKey).value() || {};
|
||||
|
||||
/**
|
||||
* Return a list of known listing hashes for a given job and provider.
|
||||
* Useful to de-duplicate before inserting new listings.
|
||||
*
|
||||
* @param {string} jobId - The job identifier.
|
||||
* @param {string} providerId - The provider identifier (e.g., 'immoscout').
|
||||
* @returns {string[]} Array of listing hashes.
|
||||
*/
|
||||
export const getKnownListingHashesForJobAndProvider = (jobId, providerId) => {
|
||||
return SqliteConnection.query(
|
||||
`SELECT hash
|
||||
FROM listings
|
||||
WHERE job_id = @jobId
|
||||
AND provider = @providerId`,
|
||||
{ jobId, providerId },
|
||||
).map((r) => r.hash);
|
||||
};
|
||||
export const setKnownListings = (jobId, providerId, listings) => {
|
||||
const providerListingsKey = buildKey(jobId, 'providerData', providerId, 'listings');
|
||||
db.chain.set(providerListingsKey, listings).value();
|
||||
return db.write();
|
||||
|
||||
/**
|
||||
* 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`,
|
||||
);
|
||||
};
|
||||
export const setLastJobExecution = (jobId) => {
|
||||
const key = buildKey(jobId, null, 'lastExecution');
|
||||
db.chain.set(key, Date.now()).value();
|
||||
return db.write();
|
||||
|
||||
/**
|
||||
* 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,
|
||||
);
|
||||
};
|
||||
export const removeListings = (jobId) => {
|
||||
db.chain.unset(jobId).value();
|
||||
db.write();
|
||||
|
||||
/**
|
||||
* Persist a batch of scraped listings for a given job and provider.
|
||||
*
|
||||
* - Empty or non-array inputs are ignored.
|
||||
* - Each listing is inserted with ON CONFLICT(hash) DO NOTHING to avoid duplicates.
|
||||
* - Performs inserts in a single transaction for performance.
|
||||
*
|
||||
* Listing input shape (minimal expected):
|
||||
* {
|
||||
* id: string, // unique id
|
||||
* hash: string // stable hash/id of the listing (used as unique hash)
|
||||
* price?: string, // e.g., "1.234 €" or "1,234€"
|
||||
* size?: string, // e.g., "70 m²"
|
||||
* title?: string,
|
||||
* image?: string, // image URL
|
||||
* description?: string,
|
||||
* address?: string, // free-text address possibly containing parentheses
|
||||
* link?: string
|
||||
* }
|
||||
*
|
||||
* @param {string} jobId - The job identifier.
|
||||
* @param {string} providerId - The provider identifier.
|
||||
* @param {Array<Object>} listings - Array of listing objects as described above.
|
||||
* @returns {void}
|
||||
*/
|
||||
export const storeListings = (jobId, providerId, listings) => {
|
||||
if (!Array.isArray(listings) || listings.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
SqliteConnection.withTransaction((db) => {
|
||||
const stmt = db.prepare(
|
||||
`INSERT INTO listings (id, hash, provider, job_id, price, size, title, image_url, description, address,
|
||||
link, created_at, is_active)
|
||||
VALUES (@id, @hash, @provider, @job_id, @price, @size, @title, @image_url, @description, @address, @link,
|
||||
@created_at, 1)
|
||||
ON CONFLICT(job_id, hash) DO NOTHING`,
|
||||
);
|
||||
|
||||
for (const item of listings) {
|
||||
const params = {
|
||||
id: nanoid(),
|
||||
hash: item.id,
|
||||
provider: providerId,
|
||||
job_id: jobId,
|
||||
price: extractNumber(item.price),
|
||||
size: extractNumber(item.size),
|
||||
title: item.title,
|
||||
image_url: item.image,
|
||||
description: item.description,
|
||||
address: removeParentheses(item.address),
|
||||
link: item.link,
|
||||
created_at: Date.now(),
|
||||
};
|
||||
stmt.run(params);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Extract the first number from a string like "1.234 €" or "70 m²".
|
||||
* Removes dots/commas before parsing. Returns null on invalid input.
|
||||
* @param {string|undefined|null} str
|
||||
* @returns {number|null}
|
||||
*/
|
||||
function extractNumber(str) {
|
||||
if (!str) return null;
|
||||
const match = str.replace(/[.,]/g, '').match(/\d+/);
|
||||
return match ? +match[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove any parentheses segments (including surrounding whitespace) from a string.
|
||||
* Returns null for empty input.
|
||||
* @param {string|undefined|null} str
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function removeParentheses(str) {
|
||||
if (nullOrEmpty(str)) {
|
||||
return null;
|
||||
}
|
||||
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) {
|
||||
whereParts.push(`(j.user_id = @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,
|
||||
);
|
||||
};
|
||||
|
||||
185
lib/services/storage/migrations/migrate.js
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Migration Runner for better-sqlite3
|
||||
* I know there are external libs out there, but
|
||||
* a) most of them are pretty bloated
|
||||
* b) I wanted to have something that fit's this limited use-case
|
||||
* c) I was searching for justifications anyway to build a migration system on my own. Don't judge me ;)
|
||||
*
|
||||
* Executes all migration files in lib/services/storage/migrations/sql in natural order.
|
||||
* Each migration runs in its own transaction. If a migration fails, only that
|
||||
* migration is rolled back and the process stops with a non-zero exit code.
|
||||
* Already applied migrations are skipped using the schema_migrations table.
|
||||
*
|
||||
* Usage:
|
||||
* CLI: yarn run migratedb
|
||||
* Programmatic:
|
||||
* import { runMigrations } from './lib/services/storage/migrations/migrate.js';
|
||||
* await runMigrations();
|
||||
*
|
||||
* Migration file format (example: lib/services/storage/migrations/sql/1.add-users.js):
|
||||
* export function up(db) {
|
||||
* db.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)");
|
||||
* }
|
||||
*
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
import crypto from 'crypto';
|
||||
import SqliteConnection from '../SqliteConnection.js';
|
||||
import logger from '../../logger.js';
|
||||
|
||||
const ROOT = path.resolve('.');
|
||||
const MIGRATIONS_DIR = path.join(ROOT, 'lib', 'services', 'storage', 'migrations', 'sql');
|
||||
|
||||
/**
|
||||
* Ensures that the given directory exists, creating it recursively if needed.
|
||||
* @param {string} p - Path to the directory.
|
||||
*/
|
||||
function ensureDir(p) {
|
||||
if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all migration files in the migrations directory.
|
||||
* Migration files must follow the format: <number>.<label>.js
|
||||
* @returns {Array<{id:number, name:string, label:string, path:string}>}
|
||||
*/
|
||||
function listMigrationFiles() {
|
||||
ensureDir(MIGRATIONS_DIR);
|
||||
return fs
|
||||
.readdirSync(MIGRATIONS_DIR)
|
||||
.filter((f) => /^\d+\..+\.js$/.test(f))
|
||||
.map((file) => {
|
||||
const [idStr, ...rest] = file.split('.');
|
||||
const id = Number.parseInt(idStr, 10);
|
||||
const label = rest.slice(0, -1).join('.');
|
||||
const fullPath = path.join(MIGRATIONS_DIR, file);
|
||||
return { id, name: file, label, path: fullPath };
|
||||
})
|
||||
.sort((a, b) => (a.id === b.id ? a.name.localeCompare(b.name) : a.id - b.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the SHA-256 checksum of a file.
|
||||
* @param {string} filePath - Path to the file.
|
||||
* @returns {string} Hex-encoded checksum.
|
||||
*/
|
||||
function sha256File(filePath) {
|
||||
const buf = fs.readFileSync(filePath);
|
||||
return crypto.createHash('sha256').update(buf).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically imports a migration module and returns its `up` function.
|
||||
* @param {string} filePath - Path to the migration file.
|
||||
* @returns {Promise<Function>} Migration function.
|
||||
* @throws {Error} If the migration file does not export a valid function.
|
||||
*/
|
||||
async function loadMigrationModule(filePath) {
|
||||
const testImporter = globalThis.__TEST_MIGRATE_IMPORT__;
|
||||
const url = pathToFileURL(filePath);
|
||||
const mod = testImporter ? await testImporter(filePath, url) : await import(url.href);
|
||||
const fn = mod.up || mod.default;
|
||||
if (typeof fn !== 'function') {
|
||||
throw new Error(`Migration ${filePath} must export function up(db) or default function(db)`);
|
||||
}
|
||||
return fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all previously executed migrations from the database.
|
||||
* @returns {Map<string,string>} Map of migration name to checksum.
|
||||
*/
|
||||
function loadExecutedMigrations() {
|
||||
const executed = new Map();
|
||||
const hasTable = SqliteConnection.tableExists('schema_migrations');
|
||||
if (!hasTable) return executed;
|
||||
const rows = SqliteConnection.query('SELECT name, checksum FROM schema_migrations ORDER BY applied_at ASC');
|
||||
for (const r of rows) executed.set(r.name, r.checksum);
|
||||
return executed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes all pending migrations.
|
||||
* Ensures that each migration runs inside its own transaction.
|
||||
* Already applied migrations are skipped, unless checksum updates are allowed.
|
||||
* On success, updates the schema_migrations table and runs PRAGMA optimize.
|
||||
*/
|
||||
export async function runMigrations() {
|
||||
ensureDir(path.join(ROOT, 'db'));
|
||||
ensureDir(MIGRATIONS_DIR);
|
||||
|
||||
const files = listMigrationFiles();
|
||||
if (files.length === 0) {
|
||||
logger.info('No migration files found under', MIGRATIONS_DIR);
|
||||
return;
|
||||
}
|
||||
|
||||
SqliteConnection.getConnection();
|
||||
|
||||
const executed = loadExecutedMigrations();
|
||||
|
||||
let appliedMigrations = 0;
|
||||
for (const m of files) {
|
||||
const checksum = sha256File(m.path);
|
||||
|
||||
if (executed.has(m.name)) {
|
||||
const prev = executed.get(m.name);
|
||||
if (prev !== checksum) {
|
||||
logger.info(`Mismatch found in migration ${m.name}. Fixing.`);
|
||||
SqliteConnection.execute('UPDATE schema_migrations SET checksum = @checksum WHERE name = @name', {
|
||||
checksum,
|
||||
name: m.name,
|
||||
});
|
||||
executed.set(m.name, checksum);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
appliedMigrations++;
|
||||
logger.info(`Applying migration: ${m.name}`);
|
||||
const fn = await loadMigrationModule(m.path);
|
||||
|
||||
try {
|
||||
let duration = 0;
|
||||
SqliteConnection.withTransaction((db) => {
|
||||
const t0 = Date.now();
|
||||
fn(db);
|
||||
duration = Date.now() - t0;
|
||||
db.prepare(
|
||||
"INSERT INTO schema_migrations (name, checksum, applied_at, duration_ms) VALUES (?, ?, datetime('now'), ?)",
|
||||
).run(m.name, checksum, duration);
|
||||
});
|
||||
logger.info(`Migration applied: ${m.name} (${duration} ms)`);
|
||||
} catch (e) {
|
||||
logger.error(`Migration failed and was rolled back: ${m.name}`, e);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
SqliteConnection.optimize();
|
||||
if (appliedMigrations > 0) {
|
||||
logger.info('All migrations completed successfully.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects whether the current file is being executed directly via Node.js.
|
||||
* This allows `node lib/services/storage/migrations/migrate.js` to run migrations directly.
|
||||
* @returns {boolean} True if the file was run directly.
|
||||
*/
|
||||
const isDirectRun = (() => {
|
||||
try {
|
||||
const thisFile = import.meta.url;
|
||||
const invoked = pathToFileURL(process.argv[1] || '').href;
|
||||
return thisFile === invoked;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
if (isDirectRun) {
|
||||
await runMigrations();
|
||||
}
|
||||
16
lib/services/storage/migrations/sql/0.init.js
Normal file
@@ -0,0 +1,16 @@
|
||||
// Initial migration: creates schema_migrations table used by the migration runner.
|
||||
//
|
||||
export function up(db) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
checksum TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_schema_migrations_applied_at
|
||||
ON schema_migrations(applied_at);
|
||||
`);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Migration: Create fredy's base structure (users, jobs and listings) import initial
|
||||
// data from JSON files if present. (This applies only for jobs and users, for the old jobListingData,
|
||||
// I cannot migrate the data as the new format is totally different.
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { toJson } from '../../../../utils.js';
|
||||
|
||||
export function up(db) {
|
||||
// 1) Create tables
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users
|
||||
(
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
last_login INTEGER,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users (username);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs
|
||||
(
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
name TEXT,
|
||||
blacklist JSONB NOT NULL DEFAULT '[]',
|
||||
provider JSONB NOT NULL DEFAULT '[]',
|
||||
notification_adapter JSONB NOT NULL DEFAULT '[]',
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_user_id ON jobs (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_enabled ON jobs (enabled);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS listings
|
||||
(
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at INTEGER,
|
||||
hash TEXT,
|
||||
provider TEXT,
|
||||
job_id TEXT,
|
||||
price INTEGER,
|
||||
size INTEGER,
|
||||
title TEXT,
|
||||
image_url TEXT,
|
||||
description TEXT,
|
||||
address TEXT,
|
||||
link TEXT,
|
||||
FOREIGN KEY (job_id) REFERENCES jobs (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_listings_hash ON listings (hash);
|
||||
`);
|
||||
|
||||
// 2) Optionally import data from JSON files if present for users and jobs
|
||||
const ROOT = path.resolve('.');
|
||||
const usersJsonPath = path.join(ROOT, 'db', 'users.json');
|
||||
const jobsJsonPath = path.join(ROOT, 'db', 'jobs.json');
|
||||
|
||||
// Insert users
|
||||
if (fs.existsSync(usersJsonPath)) {
|
||||
try {
|
||||
const raw = fs.readFileSync(usersJsonPath, 'utf8');
|
||||
const json = JSON.parse(raw);
|
||||
const arr = Array.isArray(json?.user) ? json.user : [];
|
||||
if (arr.length > 0) {
|
||||
const stmt = db.prepare(
|
||||
`INSERT INTO users (id, username, password, last_login, is_admin)
|
||||
VALUES (@id, @username, @password, @last_login, @is_admin)`,
|
||||
);
|
||||
for (const u of arr) {
|
||||
stmt.run({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
password: u.password,
|
||||
last_login: u.lastLogin ?? null,
|
||||
is_admin: u.isAdmin ? 1 : 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// If parsing fails, let it throw to rollback the migration
|
||||
throw new Error(`Failed to import users from ${usersJsonPath}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Insert jobs
|
||||
if (fs.existsSync(jobsJsonPath)) {
|
||||
try {
|
||||
const raw = fs.readFileSync(jobsJsonPath, 'utf8');
|
||||
const json = JSON.parse(raw);
|
||||
const arr = Array.isArray(json?.jobs) ? json.jobs : [];
|
||||
if (arr.length > 0) {
|
||||
const stmt = db.prepare(
|
||||
`INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter)
|
||||
VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter)`,
|
||||
);
|
||||
for (const j of arr) {
|
||||
stmt.run({
|
||||
id: j.id,
|
||||
user_id: j.userId,
|
||||
enabled: j.enabled ? 1 : 0,
|
||||
name: j.name ?? null,
|
||||
blacklist: toJson(j.blacklist ?? []),
|
||||
provider: toJson(j.provider ?? []),
|
||||
notification_adapter: toJson(j.notificationAdapter ?? []),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to import jobs from ${jobsJsonPath}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
`);
|
||||
}
|
||||
@@ -1,123 +1,176 @@
|
||||
import { JSONFileSync } from 'lowdb/node';
|
||||
import { config, getDirName } from '../../utils.js';
|
||||
import { config } from '../../utils.js';
|
||||
import * as hasher from '../security/hash.js';
|
||||
import { nanoid } from 'nanoid';
|
||||
import * as jobStorage from './jobStorage.js';
|
||||
import path from 'path';
|
||||
import LowdashAdapter from './LowDashAdapter.js';
|
||||
|
||||
const defaultData = {
|
||||
user: [
|
||||
//you probably want to change the default password ;)
|
||||
{
|
||||
id: nanoid(),
|
||||
lastLogin: Date.now(),
|
||||
username: 'admin',
|
||||
password: hasher.hash('admin'),
|
||||
isAdmin: true,
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
lastLogin: Date.now(),
|
||||
username: 'demo',
|
||||
password: hasher.hash('demo'),
|
||||
isAdmin: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const file = path.join(getDirName(), '../', 'db/users.json');
|
||||
const adapter = new JSONFileSync(file);
|
||||
const db = new LowdashAdapter(adapter, defaultData);
|
||||
|
||||
db.read();
|
||||
import SqliteConnection from './SqliteConnection.js';
|
||||
|
||||
/**
|
||||
* Get all users.
|
||||
*
|
||||
* Notes:
|
||||
* - Password hashes are omitted by default to avoid leaking them to callers that don’t need them.
|
||||
* - numberOfJobs is computed via a subquery for each user.
|
||||
*
|
||||
* @param {boolean} withPassword - If true, include the hashed password in the returned objects; otherwise set password to null.
|
||||
* @returns {User[]} Array of users ordered by username.
|
||||
*/
|
||||
export const getUsers = (withPassword) => {
|
||||
const jobs = jobStorage.getJobs();
|
||||
return db.chain
|
||||
.get('user')
|
||||
.value()
|
||||
.map((user) => ({
|
||||
//we dont want the password in the frontend, even tho it's hashed
|
||||
...user,
|
||||
password: withPassword ? user.password : null,
|
||||
numberOfJobs: jobs.filter((job) => job.userId === user.id).length,
|
||||
}));
|
||||
};
|
||||
export const getUser = (id) => {
|
||||
const jobs = jobStorage.getJobs();
|
||||
const user = db.chain
|
||||
.get('user')
|
||||
.find((user) => user.id === id)
|
||||
.value();
|
||||
if (user == null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...user,
|
||||
numberOfJobs: jobs.filter((job) => job.userId === user.id).length,
|
||||
};
|
||||
};
|
||||
export const upsertUser = ({ username, password, userId, isAdmin }) => {
|
||||
const user = db.chain
|
||||
.get('user')
|
||||
.filter((u) => u.id !== userId)
|
||||
.value();
|
||||
user.push({
|
||||
id: userId || nanoid(),
|
||||
username,
|
||||
lastLogin: user.lastLogin,
|
||||
password: hasher.hash(password),
|
||||
isAdmin,
|
||||
});
|
||||
db.chain.set('user', user).value();
|
||||
db.write();
|
||||
};
|
||||
export const setLastLoginToNow = ({ userId }) => {
|
||||
db.chain
|
||||
.get('user')
|
||||
.find((u) => u.id === userId)
|
||||
.assign({ lastLogin: Date.now() })
|
||||
.value();
|
||||
db.write();
|
||||
};
|
||||
export const removeUser = (userId) => {
|
||||
const user = db.chain.get('user').value();
|
||||
db.chain
|
||||
.set(
|
||||
'user',
|
||||
user.filter((u) => u.id !== userId),
|
||||
)
|
||||
.value();
|
||||
db.write();
|
||||
const rows = SqliteConnection.query(
|
||||
`SELECT u.id, u.username, u.password, u.last_login AS lastLogin, u.is_admin AS isAdmin,
|
||||
(SELECT COUNT(1) FROM jobs j WHERE j.user_id = u.id) AS numberOfJobs
|
||||
FROM users u
|
||||
ORDER BY u.username`,
|
||||
);
|
||||
return rows.map((u) => ({
|
||||
...u,
|
||||
password: withPassword ? u.password : null,
|
||||
isAdmin: !!u.isAdmin,
|
||||
}));
|
||||
};
|
||||
|
||||
export const handleDemoUser = () => {
|
||||
if (!config.demoMode) {
|
||||
const user = db.chain.get('user').value();
|
||||
db.chain
|
||||
.set(
|
||||
'user',
|
||||
user.filter((u) => u.username !== 'demo'),
|
||||
)
|
||||
.value();
|
||||
db.write();
|
||||
/**
|
||||
* Get a single user by id.
|
||||
*
|
||||
* @param {string} id - User id (primary key).
|
||||
* @returns {User|null} The user when found; otherwise null. The password field is included but callers should not expose it.
|
||||
*/
|
||||
export const getUser = (id) => {
|
||||
const rows = SqliteConnection.query(
|
||||
`SELECT u.id, u.username, u.password, u.last_login AS lastLogin, u.is_admin AS isAdmin,
|
||||
(SELECT COUNT(1) FROM jobs j WHERE j.user_id = u.id) AS numberOfJobs
|
||||
FROM users u
|
||||
WHERE u.id = @id
|
||||
LIMIT 1`,
|
||||
{ id },
|
||||
);
|
||||
const u = rows[0];
|
||||
if (!u) return null;
|
||||
return { ...u, isAdmin: !!u.isAdmin };
|
||||
};
|
||||
|
||||
/**
|
||||
* Insert a new user or update an existing one.
|
||||
*
|
||||
* Behavior:
|
||||
* - When userId is provided and exists: updates username and isAdmin. Password is only updated when a non-empty password is provided.
|
||||
* - When userId is missing or does not exist: inserts a new user with a freshly generated id. last_login is initialized to null.
|
||||
* - Passwords are hashed using the same hashing function used for login comparison.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.username - Username (must be unique in DB).
|
||||
* @param {string} [params.password] - Plain text password to set; if omitted on update, existing hash is preserved.
|
||||
* @param {string} [params.userId] - Existing user id to update; if missing, a new id is generated.
|
||||
* @param {boolean} params.isAdmin - Whether the user should have admin privileges.
|
||||
* @returns {void}
|
||||
*/
|
||||
export const upsertUser = ({ username, password, userId, isAdmin }) => {
|
||||
const id = userId || nanoid();
|
||||
// Check if user exists
|
||||
const exists = SqliteConnection.query(`SELECT 1 FROM users WHERE id = @id LIMIT 1`, { id }).length > 0;
|
||||
if (exists) {
|
||||
// Update existing user. Update password only if provided (non-empty string)
|
||||
if (password && password.length > 0) {
|
||||
SqliteConnection.execute(
|
||||
`UPDATE users SET username = @username, password = @password, is_admin = @is_admin WHERE id = @id`,
|
||||
{ id, username, password: hasher.hash(password), is_admin: isAdmin ? 1 : 0 },
|
||||
);
|
||||
} else {
|
||||
SqliteConnection.execute(`UPDATE users SET username = @username, is_admin = @is_admin WHERE id = @id`, {
|
||||
id,
|
||||
username,
|
||||
is_admin: isAdmin ? 1 : 0,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const demoUser = db.chain
|
||||
.get('user')
|
||||
.filter((u) => u.username === 'demo')
|
||||
.value();
|
||||
if (demoUser == null || demoUser.length === 0) {
|
||||
db.chain
|
||||
.get('user')
|
||||
.value()
|
||||
.push({
|
||||
id: nanoid(),
|
||||
username: 'demo',
|
||||
password: hasher.hash('demo'),
|
||||
isAdmin: true,
|
||||
});
|
||||
db.write();
|
||||
SqliteConnection.execute(
|
||||
`INSERT INTO users (id, username, password, last_login, is_admin)
|
||||
VALUES (@id, @username, @password, @last_login, @is_admin)`,
|
||||
{
|
||||
id,
|
||||
username,
|
||||
password: hasher.hash(password || ''),
|
||||
last_login: null,
|
||||
is_admin: isAdmin ? 1 : 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update the last_login timestamp to now for the given user.
|
||||
*
|
||||
* @param {{userId: string}} params - Parameters.
|
||||
* @param {string} params.userId - The user's id.
|
||||
* @returns {void}
|
||||
*/
|
||||
export const setLastLoginToNow = ({ userId }) => {
|
||||
SqliteConnection.execute(`UPDATE users SET last_login = @now WHERE id = @id`, { id: userId, now: Date.now() });
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove a user by id.
|
||||
*
|
||||
* Notes:
|
||||
* - In the SQLite schema, jobs reference users with ON DELETE CASCADE, so jobs (and their listings via jobs) are removed automatically.
|
||||
*
|
||||
* @param {string} userId - The id of the user to remove.
|
||||
* @returns {void}
|
||||
*/
|
||||
export const removeUser = (userId) => {
|
||||
SqliteConnection.execute(`DELETE FROM users WHERE id = @id`, { id: userId });
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensure the demo user matches the demo mode setting.
|
||||
*
|
||||
* Behavior:
|
||||
* - When config.demoMode is false: remove the demo user (and its cascading data via FKs).
|
||||
* - When config.demoMode is true: ensure a 'demo' user exists with password 'demo' and admin rights.
|
||||
*
|
||||
* Security: The demo user's password is set to a known value ('demo') and should only be enabled in demoMode.
|
||||
* @returns {void}
|
||||
*/
|
||||
export const ensureDemoUserExists = () => {
|
||||
if (!config.demoMode) {
|
||||
// Remove demo user (and cascade delete their jobs/listings)
|
||||
SqliteConnection.execute(`DELETE FROM users WHERE username = 'demo'`);
|
||||
return;
|
||||
}
|
||||
// Ensure demo user exists when demo mode is on
|
||||
const existing = SqliteConnection.query(`SELECT id FROM users WHERE username = 'demo' LIMIT 1`);
|
||||
if (existing.length === 0) {
|
||||
SqliteConnection.execute(
|
||||
`INSERT INTO users (id, username, password, last_login, is_admin)
|
||||
VALUES (@id, 'demo', @password, NULL, 1)`,
|
||||
{ id: nanoid(), password: hasher.hash('demo') },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensure there is at least one administrator in the system.
|
||||
*
|
||||
* Behavior:
|
||||
* - If there are no users at all, create default 'admin' user with password 'admin'.
|
||||
* - If users exist but none is admin, promote the first existing user to admin.
|
||||
*
|
||||
* Security: On a fresh instance, a default admin/admin is created; change this password immediately.
|
||||
* @returns {void}
|
||||
*/
|
||||
export const ensureAdminUserExists = () => {
|
||||
const anyUser = SqliteConnection.query(`SELECT id FROM users LIMIT 1`).length > 0;
|
||||
if (!anyUser) {
|
||||
SqliteConnection.execute(
|
||||
`INSERT INTO users (id, username, password, last_login, is_admin)
|
||||
VALUES (@id, 'admin', @password, @last_login, 1)`,
|
||||
{ id: nanoid(), password: hasher.hash('admin'), last_login: Date.now() },
|
||||
);
|
||||
return;
|
||||
}
|
||||
const adminCount = SqliteConnection.query(`SELECT COUNT(1) AS c FROM users WHERE is_admin = 1`)[0]?.c ?? 0;
|
||||
if (adminCount === 0) {
|
||||
const firstUser = SqliteConnection.query(`SELECT id FROM users LIMIT 1`)[0];
|
||||
if (firstUser) {
|
||||
SqliteConnection.execute(`UPDATE users SET is_admin = 1 WHERE id = @id`, { id: firstUser.id });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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 { getUniqueId } from './uniqueId.js';
|
||||
import { config, inDevMode } from '../../utils.js';
|
||||
import { config, getPackageVersion, inDevMode } from '../../utils.js';
|
||||
import os from 'os';
|
||||
import { readFileSync } from 'fs';
|
||||
import { packageUp } from 'package-up';
|
||||
import fetch from 'node-fetch';
|
||||
import logger from '../logger.js';
|
||||
|
||||
@@ -77,15 +75,3 @@ function enrichTrackingObject(trackingObject) {
|
||||
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';
|
||||
}
|
||||
|
||||
221
lib/utils.js
@@ -1,30 +1,104 @@
|
||||
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 { createHash } from 'crypto';
|
||||
import { DEFAULT_CONFIG } from './defaultConfig.js';
|
||||
import fs from 'fs';
|
||||
import fs, { readFileSync } from 'fs';
|
||||
import logger from './services/logger.js';
|
||||
import { packageUp } from 'package-up';
|
||||
|
||||
const RE_GT = />/g;
|
||||
const RE_WEBP = /\/format\/webp/gi;
|
||||
const RE_EXT = /\.(jpe?g|png|gif)(\?.*)?$/i;
|
||||
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.
|
||||
* - Returns null when the input is null or undefined.
|
||||
* - Uses JSON.stringify directly otherwise.
|
||||
*
|
||||
* @template T
|
||||
* @param {T} v - Any JSON-serializable value.
|
||||
* @returns {string|null} JSON string or null.
|
||||
*/
|
||||
const toJson = (v) => (v == null ? null : JSON.stringify(v));
|
||||
|
||||
/**
|
||||
* Safely parse JSON text coming from storage.
|
||||
* - Returns the provided fallback when input is null/undefined.
|
||||
* - Returns the fallback when parsing fails.
|
||||
*
|
||||
* @template T
|
||||
* @param {string|null|undefined} txt - JSON text from DB/storage.
|
||||
* @param {T} fallback - Value to return when txt is null/invalid.
|
||||
* @returns {T} Parsed value or fallback.
|
||||
*/
|
||||
const fromJson = (txt, fallback) => {
|
||||
if (txt == null) return fallback;
|
||||
try {
|
||||
return JSON.parse(txt);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine if the current process runs in development mode.
|
||||
* Returns true when NODE_ENV is not 'production'.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function inDevMode() {
|
||||
return process.env.NODE_ENV == null || process.env.NODE_ENV !== 'production';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a word contains any of the strings in the given array (case-insensitive, substring match).
|
||||
* @param {string} word
|
||||
* @param {string[]} arr
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isOneOf(word, arr) {
|
||||
if (!arr || arr.length === 0 || word == null) return false;
|
||||
const lowerWord = word.toLowerCase();
|
||||
return arr.some((item) => lowerWord.indexOf(item.toLowerCase()) !== -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is null or an empty string/array.
|
||||
* @param {any} val
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function nullOrEmpty(val) {
|
||||
return val == null || val.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a day time string (HH:mm) to epoch milliseconds for the given reference date.
|
||||
* @param {string} timeString - Format HH:mm
|
||||
* @param {number} now - Epoch ms used as the date basis
|
||||
* @returns {number}
|
||||
*/
|
||||
function timeStringToMs(timeString, now) {
|
||||
const d = new Date(now);
|
||||
const parts = timeString.split(':');
|
||||
@@ -34,6 +108,24 @@ function timeStringToMs(timeString, now) {
|
||||
return d.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the given timestamp is within the configured working hours, or return true when the window is not set.
|
||||
* - If workingHours is missing or either 'from' or 'to' is empty/null, returns true.
|
||||
* - Supports windows that cross midnight (e.g., from '23:00' to '06:00').
|
||||
*
|
||||
* 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) {
|
||||
const { workingHours } = config;
|
||||
if (workingHours == null || nullOrEmpty(workingHours.from) || nullOrEmpty(workingHours.to)) {
|
||||
@@ -41,13 +133,36 @@ function duringWorkingHoursOrNotSet(config, now) {
|
||||
}
|
||||
const toDate = timeStringToMs(workingHours.to, 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the directory name of the current module (ESM equivalent of __dirname).
|
||||
* @returns {string}
|
||||
*/
|
||||
function getDirName() {
|
||||
return dirname(fileURLToPath(import.meta.url));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a sha256 hash string from the provided inputs (ignores null/empty strings).
|
||||
* Returns null if there are no valid inputs.
|
||||
* @param {...(string|null|undefined)} inputs
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function buildHash(...inputs) {
|
||||
if (inputs == null) {
|
||||
return null;
|
||||
@@ -59,20 +174,52 @@ function buildHash(...inputs) {
|
||||
return createHash('sha256').update(cleaned.join(',')).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* The in-memory configuration object. Call refreshConfig() to populate/update.
|
||||
* @type {any}
|
||||
*/
|
||||
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.
|
||||
* @returns {Promise<any>} Parsed configuration object.
|
||||
*/
|
||||
export async function readConfigFromStorage() {
|
||||
return JSON.parse(await readFile(new URL('../conf/config.json', import.meta.url)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the in-memory config, ensuring the file exists and setting backward-compatible defaults.
|
||||
* Populates defaults for analyticsEnabled, demoMode, sqlitepath when missing.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function refreshConfig() {
|
||||
checkIfConfigExistsAndWriteIfNot();
|
||||
|
||||
try {
|
||||
config = await readConfigFromStorage();
|
||||
//backwards compatability...
|
||||
//backwards compatibility...
|
||||
config.analyticsEnabled ??= null;
|
||||
config.demoMode ??= false;
|
||||
// default sqlitepath when missing in older configs
|
||||
config.sqlitepath ??= '/db';
|
||||
} catch (error) {
|
||||
config = { ...DEFAULT_CONFIG };
|
||||
logger.info('Error reading config file.', error);
|
||||
@@ -80,7 +227,8 @@ export async function refreshConfig() {
|
||||
}
|
||||
|
||||
/**
|
||||
* If the config file does not exist, we will create it.
|
||||
* If the config file does not exist, create it with DEFAULT_CONFIG.
|
||||
* @returns {void}
|
||||
*/
|
||||
const checkIfConfigExistsAndWriteIfNot = () => {
|
||||
if (!fs.existsSync(`${getDirName()}/../conf/config.json`)) {
|
||||
@@ -89,6 +237,15 @@ const checkIfConfigExistsAndWriteIfNot = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize image URLs:
|
||||
* - Trim, remove stray '>' characters.
|
||||
* - Convert '/format/webp' segments to '/format/jpg'.
|
||||
* - Enforce HTTPS and ensure a valid image extension (jpg/png/gif). If URL contains '.jpg' without query, cut trailing parts.
|
||||
* - Return null for invalid inputs.
|
||||
* @param {string} url
|
||||
* @returns {string|null}
|
||||
*/
|
||||
const normalizeImageUrl = (url) => {
|
||||
if (typeof url !== 'string' || url.length === 0) return null;
|
||||
|
||||
@@ -102,20 +259,56 @@ const normalizeImageUrl = (url) => {
|
||||
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();
|
||||
|
||||
export { isOneOf };
|
||||
export { normalizeImageUrl };
|
||||
export { inDevMode };
|
||||
export { nullOrEmpty };
|
||||
export { duringWorkingHoursOrNotSet };
|
||||
export { getDirName };
|
||||
export { config };
|
||||
export { buildHash };
|
||||
export default {
|
||||
export {
|
||||
isOneOf,
|
||||
normalizeImageUrl,
|
||||
inDevMode,
|
||||
nullOrEmpty,
|
||||
duringWorkingHoursOrNotSet,
|
||||
getDirName,
|
||||
sleep,
|
||||
randomBetween,
|
||||
config,
|
||||
buildHash,
|
||||
getPackageVersion,
|
||||
toJson,
|
||||
fromJson,
|
||||
};
|
||||
|
||||
54
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fredy",
|
||||
"version": "11.6.4",
|
||||
"version": "14.1.1",
|
||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||
"scripts": {
|
||||
"prepare": "husky",
|
||||
@@ -13,7 +13,9 @@
|
||||
"format:check": "prettier --check \"**/*.js\"",
|
||||
"test": "node --import ./test/esmock-loader.mjs ./node_modules/mocha/bin/mocha.js --timeout 60000 test/**/*.test.js",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "yarn lint --fix"
|
||||
"lint:fix": "yarn lint --fix",
|
||||
"migratedb": "node lib/services/storage/migrations/migrate.js",
|
||||
"migratedb:overwrite": "x-var MIGRATION_ALLOW_CHECKSUM_UPDATE=true node lib/services/storage/migrations/migrate.js"
|
||||
},
|
||||
"type": "module",
|
||||
"lint-staged": {
|
||||
@@ -44,7 +46,7 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
"node": ">=22.0.0",
|
||||
"npm": ">=7.0.0"
|
||||
},
|
||||
"browserslist": [
|
||||
@@ -54,62 +56,58 @@
|
||||
"Firefox ESR"
|
||||
],
|
||||
"dependencies": {
|
||||
"@douyinfe/semi-icons": "^2.86.0",
|
||||
"@douyinfe/semi-ui": "2.86.0",
|
||||
"@rematch/core": "2.2.0",
|
||||
"@rematch/loading": "2.1.2",
|
||||
"@sendgrid/mail": "8.1.5",
|
||||
"@visactor/react-vchart": "^2.0.4",
|
||||
"@visactor/vchart": "^2.0.4",
|
||||
"@sendgrid/mail": "8.1.6",
|
||||
"@visactor/react-vchart": "^2.0.5",
|
||||
"@visactor/vchart": "^2.0.5",
|
||||
"@visactor/vchart-semi-theme": "^1.12.2",
|
||||
"@vitejs/plugin-react": "5.0.2",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"@vitejs/plugin-react": "5.0.4",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"body-parser": "2.2.0",
|
||||
"cheerio": "^1.1.2",
|
||||
"cookie-session": "2.1.1",
|
||||
"handlebars": "4.7.8",
|
||||
"lodash": "4.17.21",
|
||||
"lowdb": "7.0.1",
|
||||
"markdown": "^0.5.0",
|
||||
"nanoid": "5.1.5",
|
||||
"nanoid": "5.1.6",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "3.3.2",
|
||||
"node-mailjet": "6.0.9",
|
||||
"p-throttle": "^8.0.0",
|
||||
"package-up": "^5.0.0",
|
||||
"puppeteer": "^24.19.0",
|
||||
"puppeteer": "^24.23.0",
|
||||
"puppeteer-extra": "^3.3.6",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||
"query-string": "9.3.0",
|
||||
"query-string": "9.3.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-redux": "9.2.0",
|
||||
"react-router": "7.8.2",
|
||||
"react-router-dom": "7.8.2",
|
||||
"redux": "5.0.1",
|
||||
"redux-thunk": "3.1.0",
|
||||
"react-router": "7.9.3",
|
||||
"react-router-dom": "7.9.3",
|
||||
"restana": "5.1.0",
|
||||
"semver": "^7.7.2",
|
||||
"serve-static": "2.2.0",
|
||||
"slack": "11.0.2",
|
||||
"vite": "7.1.5",
|
||||
"x-var": "^2.1.0"
|
||||
"vite": "7.1.9",
|
||||
"x-var": "^3.0.1",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.28.4",
|
||||
"@babel/eslint-parser": "7.28.4",
|
||||
"@babel/preset-env": "7.28.3",
|
||||
"@babel/preset-react": "7.27.1",
|
||||
"chai": "6.0.1",
|
||||
"eslint": "9.35.0",
|
||||
"chai": "6.2.0",
|
||||
"eslint": "9.36.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"esmock": "2.7.2",
|
||||
"esmock": "2.7.3",
|
||||
"history": "5.3.0",
|
||||
"husky": "9.1.7",
|
||||
"less": "4.4.1",
|
||||
"lint-staged": "16.1.6",
|
||||
"mocha": "11.7.2",
|
||||
"lint-staged": "16.2.3",
|
||||
"mocha": "11.7.4",
|
||||
"nodemon": "^3.1.10",
|
||||
"prettier": "3.6.2",
|
||||
"redux-logger": "3.0.6"
|
||||
"prettier": "3.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ Challenges:
|
||||
_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" \
|
||||
"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' \
|
||||
-H "Connection: keep-alive" \
|
||||
-H "User-Agent: ImmoScout24_1410_30_._" \
|
||||
-H "User-Agent: ImmoScout_27.3_26.0_._" \
|
||||
-H "Accept: application/json" \
|
||||
-H "Content-Type: application/json" \
|
||||
-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.
|
||||
|
||||
```
|
||||
curl -H "User-Agent: ImmoScout24_1410_30_._" \
|
||||
curl -H "User-Agent: ImmoScout_27.3_26.0_._" \
|
||||
-H "Accept: application/json" \
|
||||
"https://api.mobile.immobilienscout24.de/expose/158382494"
|
||||
```
|
||||
|
||||
53
test/FredyRuntime/FredyRuntime.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('FredyRuntime', () => {
|
||||
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;
|
||||
});
|
||||
});
|
||||
});
|
||||
329
test/db/migrations/migrate.test.js
Normal file
@@ -0,0 +1,329 @@
|
||||
import { expect } from 'chai';
|
||||
import esmock from 'esmock';
|
||||
|
||||
// We will fully mock fs, crypto, SqliteConnection, and dynamic import of migration modules
|
||||
|
||||
describe('db/migrations/migrate.js - runMigrations', () => {
|
||||
let calls;
|
||||
let runMigrations;
|
||||
let prevExitCode;
|
||||
|
||||
beforeEach(async () => {
|
||||
calls = {
|
||||
fs: { existsSync: [], mkdirSync: [], readdirSync: [], readFileSync: [] },
|
||||
sql: {
|
||||
getConnection: 0,
|
||||
tableExists: false,
|
||||
query: [],
|
||||
execute: [],
|
||||
withTransaction: [],
|
||||
optimize: 0,
|
||||
},
|
||||
logs: { info: [], warn: [], error: [] },
|
||||
};
|
||||
|
||||
// Mock fs to avoid touching disk
|
||||
const fsMock = {
|
||||
existsSync: (p) => {
|
||||
calls.fs.existsSync.push(p);
|
||||
return true;
|
||||
},
|
||||
mkdirSync: (p, opts) => {
|
||||
calls.fs.mkdirSync.push({ p, opts });
|
||||
},
|
||||
readdirSync: (p) => {
|
||||
calls.fs.readdirSync.push(p);
|
||||
return [];
|
||||
},
|
||||
readFileSync: (p) => {
|
||||
calls.fs.readFileSync.push(p);
|
||||
return Buffer.from('dummy');
|
||||
},
|
||||
};
|
||||
|
||||
// Mock crypto sha256
|
||||
const cryptoMock = {
|
||||
createHash: () => ({ update: () => ({ digest: () => 'sha256sum' }) }),
|
||||
};
|
||||
|
||||
// Mock logger
|
||||
const loggerMock = {
|
||||
info: (...a) => calls.logs.info.push(a),
|
||||
warn: (...a) => calls.logs.warn.push(a),
|
||||
error: (...a) => calls.logs.error.push(a),
|
||||
};
|
||||
|
||||
// Mock SqliteConnection
|
||||
const sqlMock = {
|
||||
getConnection: () => {
|
||||
calls.sql.getConnection += 1;
|
||||
return {};
|
||||
},
|
||||
tableExists: () => calls.sql.tableExists,
|
||||
query: (sql) => {
|
||||
calls.sql.query.push(sql);
|
||||
return [];
|
||||
},
|
||||
execute: (sql, params) => {
|
||||
calls.sql.execute.push({ sql, params });
|
||||
return { changes: 1 };
|
||||
},
|
||||
withTransaction: (cb) => {
|
||||
calls.sql.withTransaction.push(true);
|
||||
const db = {
|
||||
prepare: (s) => ({ run: (...args) => calls.sql.execute.push({ sql: s, params: args }) }),
|
||||
};
|
||||
return cb(db);
|
||||
},
|
||||
optimize: () => {
|
||||
calls.sql.optimize += 1;
|
||||
},
|
||||
};
|
||||
|
||||
// esmock with dependency replacements
|
||||
const path = await import('node:path');
|
||||
const ROOT = path.resolve('.');
|
||||
const sqlPath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js');
|
||||
const loggerPath = path.join(ROOT, 'lib', 'services', 'logger.js');
|
||||
const mod = await esmock(
|
||||
'../../../db/migrations/migrate.js',
|
||||
{},
|
||||
{
|
||||
fs: fsMock,
|
||||
crypto: cryptoMock,
|
||||
[sqlPath]: sqlMock,
|
||||
[loggerPath]: loggerMock,
|
||||
},
|
||||
);
|
||||
|
||||
runMigrations = mod.runMigrations;
|
||||
|
||||
// remember original exitCode to restore later
|
||||
prevExitCode = process.exitCode;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// restore original process.exitCode
|
||||
process.exitCode = prevExitCode;
|
||||
});
|
||||
|
||||
it('logs and returns when no migration files are found', async () => {
|
||||
await runMigrations();
|
||||
expect(calls.logs.info.some((a) => String(a[0]).includes('No migration files'))).to.equal(true);
|
||||
expect(calls.sql.getConnection).to.equal(0);
|
||||
expect(calls.sql.optimize).to.equal(0);
|
||||
});
|
||||
|
||||
it('applies a single new migration inside a transaction and records it', async () => {
|
||||
// Re-mock with one file and module loader
|
||||
const fsMock = {
|
||||
existsSync: () => true,
|
||||
mkdirSync: () => {},
|
||||
readdirSync: () => ['1.init.js'],
|
||||
readFileSync: () => Buffer.from('dummy'),
|
||||
};
|
||||
const cryptoMock = { createHash: () => ({ update: () => ({ digest: () => 'abc' }) }) };
|
||||
const loggerMock = {
|
||||
info: (...a) => calls.logs.info.push(a),
|
||||
warn: (...a) => calls.logs.warn.push(a),
|
||||
error: (...a) => calls.logs.error.push(a),
|
||||
};
|
||||
|
||||
const sqlMock = {
|
||||
getConnection: () => {
|
||||
calls.sql.getConnection += 1;
|
||||
return {};
|
||||
},
|
||||
tableExists: () => false, // schema_migrations not present yet
|
||||
query: () => [],
|
||||
execute: (sql, params) => {
|
||||
calls.sql.execute.push({ sql, params });
|
||||
return { changes: 1 };
|
||||
},
|
||||
withTransaction: (cb) => {
|
||||
calls.sql.withTransaction.push(true);
|
||||
const db = {
|
||||
exec: () => {},
|
||||
prepare: (s) => ({ run: (...args) => calls.sql.execute.push({ sql: s, params: args }) }),
|
||||
};
|
||||
return cb(db);
|
||||
},
|
||||
optimize: () => {
|
||||
calls.sql.optimize += 1;
|
||||
},
|
||||
};
|
||||
|
||||
// The migration module: exports up(db)
|
||||
const migrationModule = {
|
||||
up: (db) => {
|
||||
db.exec && db.exec('CREATE TABLE schema_migrations(name TEXT)');
|
||||
},
|
||||
};
|
||||
|
||||
// We need to intercept dynamic import by esmock: provide a stub for import(url)
|
||||
// esmock supports mocking via a virtual module using URL matching, but simpler approach:
|
||||
// place the file path that migrate.js will compute and make Node import resolve to our stub
|
||||
// We simulate by mocking url.pathToFileURL is still used, but dynamic import will be handled by esmock when we map the computed path.
|
||||
|
||||
const path = await import('node:path');
|
||||
const ROOT = path.resolve('.');
|
||||
|
||||
const sqlPath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js');
|
||||
const loggerPath = path.join(ROOT, 'lib', 'services', 'logger.js');
|
||||
// Use global importer hook to bypass dynamic import
|
||||
globalThis.__TEST_MIGRATE_IMPORT__ = async () => migrationModule;
|
||||
|
||||
const mod = await esmock(
|
||||
'../../../db/migrations/migrate.js',
|
||||
{},
|
||||
{
|
||||
fs: fsMock,
|
||||
crypto: cryptoMock,
|
||||
[sqlPath]: sqlMock,
|
||||
[loggerPath]: loggerMock,
|
||||
},
|
||||
);
|
||||
|
||||
runMigrations = mod.runMigrations;
|
||||
|
||||
await runMigrations();
|
||||
|
||||
// Should have started a transaction and inserted into schema_migrations
|
||||
expect(calls.sql.withTransaction.length).to.equal(1);
|
||||
const inserted = calls.sql.execute.find((e) => String(e.sql).includes('INSERT INTO schema_migrations'));
|
||||
expect(!!inserted).to.equal(true);
|
||||
expect(calls.sql.optimize).to.equal(1);
|
||||
});
|
||||
|
||||
it('skips already executed migration with same checksum', async () => {
|
||||
const fsMock = {
|
||||
existsSync: () => true,
|
||||
mkdirSync: () => {},
|
||||
readdirSync: () => ['1.init.js'],
|
||||
readFileSync: () => Buffer.from('dummy'),
|
||||
};
|
||||
const cryptoMock = { createHash: () => ({ update: () => ({ digest: () => 'same' }) }) };
|
||||
const loggerMock = {
|
||||
info: (...a) => calls.logs.info.push(a),
|
||||
warn: (...a) => calls.logs.warn.push(a),
|
||||
error: (...a) => calls.logs.error.push(a),
|
||||
};
|
||||
|
||||
const sqlMock = {
|
||||
getConnection: () => {
|
||||
calls.sql.getConnection += 1;
|
||||
return {};
|
||||
},
|
||||
tableExists: () => true,
|
||||
query: () => [{ name: '1.init.js', checksum: 'same' }],
|
||||
execute: (sql, params) => {
|
||||
calls.sql.execute.push({ sql, params });
|
||||
return { changes: 1 };
|
||||
},
|
||||
withTransaction: (cb) => {
|
||||
calls.sql.withTransaction.push(true);
|
||||
const db = { prepare: (s) => ({ run: (...args) => calls.sql.execute.push({ sql: s, params: args }) }) };
|
||||
return cb(db);
|
||||
},
|
||||
optimize: () => {
|
||||
calls.sql.optimize += 1;
|
||||
},
|
||||
};
|
||||
|
||||
const path = await import('node:path');
|
||||
const ROOT = path.resolve('.');
|
||||
const sqlPath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js');
|
||||
const loggerPath = path.join(ROOT, 'lib', 'services', 'logger.js');
|
||||
|
||||
globalThis.__TEST_MIGRATE_IMPORT__ = async () => ({ up: () => {} });
|
||||
|
||||
const mod = await esmock(
|
||||
'../../../db/migrations/migrate.js',
|
||||
{},
|
||||
{
|
||||
fs: fsMock,
|
||||
crypto: cryptoMock,
|
||||
[sqlPath]: sqlMock,
|
||||
[loggerPath]: loggerMock,
|
||||
},
|
||||
);
|
||||
|
||||
runMigrations = mod.runMigrations;
|
||||
|
||||
await runMigrations();
|
||||
|
||||
// Should not run transaction because it's skipped
|
||||
expect(calls.sql.withTransaction.length).to.equal(0);
|
||||
expect(calls.sql.optimize).to.equal(1);
|
||||
});
|
||||
|
||||
it('aborts with exitCode=1 when a migration throws, without applying insert', async () => {
|
||||
const fsMock = {
|
||||
existsSync: () => true,
|
||||
mkdirSync: () => {},
|
||||
readdirSync: () => ['1.bad.js'],
|
||||
readFileSync: () => Buffer.from('dummy'),
|
||||
};
|
||||
const cryptoMock = { createHash: () => ({ update: () => ({ digest: () => 'bad' }) }) };
|
||||
const loggerMock = {
|
||||
info: (...a) => calls.logs.info.push(a),
|
||||
warn: (...a) => calls.logs.warn.push(a),
|
||||
error: (...a) => calls.logs.error.push(a),
|
||||
};
|
||||
|
||||
const sqlMock = {
|
||||
getConnection: () => {
|
||||
calls.sql.getConnection += 1;
|
||||
return {};
|
||||
},
|
||||
tableExists: () => false,
|
||||
query: () => [],
|
||||
execute: (sql, params) => {
|
||||
calls.sql.execute.push({ sql, params });
|
||||
return { changes: 1 };
|
||||
},
|
||||
withTransaction: (cb) => {
|
||||
calls.sql.withTransaction.push(true);
|
||||
const db = {
|
||||
exec: () => {},
|
||||
prepare: (s) => ({ run: (...args) => calls.sql.execute.push({ sql: s, params: args }) }),
|
||||
};
|
||||
return cb(db);
|
||||
},
|
||||
optimize: () => {
|
||||
calls.sql.optimize += 1;
|
||||
},
|
||||
};
|
||||
|
||||
const path = await import('node:path');
|
||||
const ROOT = path.resolve('.');
|
||||
|
||||
globalThis.__TEST_MIGRATE_IMPORT__ = async () => ({
|
||||
up: () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
});
|
||||
const sqlPath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js');
|
||||
const loggerPath = path.join(ROOT, 'lib', 'services', 'logger.js');
|
||||
|
||||
const mod = await esmock(
|
||||
'../../../lib/services/storage/migrations/migrate.js',
|
||||
{},
|
||||
{
|
||||
fs: fsMock,
|
||||
crypto: cryptoMock,
|
||||
[sqlPath]: sqlMock,
|
||||
[loggerPath]: loggerMock,
|
||||
},
|
||||
);
|
||||
|
||||
runMigrations = mod.runMigrations;
|
||||
|
||||
await runMigrations();
|
||||
|
||||
expect(process.exitCode).to.equal(1);
|
||||
// No insert into schema_migrations should be recorded since transaction failed
|
||||
const inserted = calls.sql.execute.find((e) => String(e.sql).includes('INSERT INTO schema_migrations'));
|
||||
expect(inserted).to.equal(undefined);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
const db = {};
|
||||
export const setKnownListings = (jobKey, providerId, listings) => {
|
||||
export const storeListings = (jobKey, providerId, listings) => {
|
||||
if (!Array.isArray(listings)) throw Error('Not a valid array');
|
||||
db[providerId] = listings;
|
||||
};
|
||||
export const getKnownListings = (jobKey, providerId) => {
|
||||
export const getKnownListingHashesForJobAndProvider = (jobKey, providerId) => {
|
||||
return db[providerId] || [];
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ describe('#einsAImmobilien testsuite()', () => {
|
||||
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).to.be.not.empty;
|
||||
expect(notify.title).to.be.not.empty;
|
||||
|
||||
@@ -8,31 +8,30 @@ describe('#immonet testsuite()', () => {
|
||||
after(() => {
|
||||
similarityCache.stopCacheCleanup();
|
||||
});
|
||||
provider.init(providerConfig.immonet, [], []);
|
||||
|
||||
it('should test immonet provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
return await new Promise((resolve) => {
|
||||
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');
|
||||
provider.init(providerConfig.immonet, [], []);
|
||||
|
||||
expect(notify.size).that.does.include('m²');
|
||||
expect(notify.title).to.be.not.empty;
|
||||
expect(notify.address).to.be.not.empty;
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immonet', 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('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(() => {
|
||||
similarityCache.stopCacheCleanup();
|
||||
});
|
||||
|
||||
it('should test immowelt provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
provider.init(providerConfig.immowelt, [], []);
|
||||
return await new Promise((resolve) => {
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immowelt', 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('immowelt');
|
||||
notificationObj.payload.forEach((notify) => {
|
||||
/** check the actual structure **/
|
||||
expect(notify.id).to.be.a('string');
|
||||
expect(notify.price).to.be.a('string');
|
||||
expect(notify.title).to.be.a('string');
|
||||
expect(notify.link).to.be.a('string');
|
||||
expect(notify.address).to.be.a('string');
|
||||
/** check the values if possible **/
|
||||
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.address).to.be.not.empty;
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immowelt', 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('immowelt');
|
||||
notificationObj.payload.forEach((notify) => {
|
||||
/** check the actual structure **/
|
||||
expect(notify.id).to.be.a('string');
|
||||
expect(notify.price).to.be.a('string');
|
||||
expect(notify.title).to.be.a('string');
|
||||
expect(notify.link).to.be.a('string');
|
||||
expect(notify.address).to.be.a('string');
|
||||
/** check the values if possible **/
|
||||
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.address).to.be.not.empty;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
"enabled": true
|
||||
},
|
||||
"mcMakler": {
|
||||
"url": "https://www.mcmakler.de/immobilien/results?placeId=62649&search=Leipzig%252C+Sachsen&propertyTypes=APARTMENT&page=0",
|
||||
"enabled": true
|
||||
},
|
||||
"neubauKompass": {
|
||||
"url": "https://www.neubaukompass.de/neubau-immobilien/duesseldorf-region/eigentumswohnung/",
|
||||
"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": {
|
||||
"url": "https://www.wg-gesucht.de/wg-zimmer-in-Duesseldorf.30.0.1.0.html",
|
||||
"enabled": true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import utils from '../../lib/utils.js';
|
||||
import { isOneOf, duringWorkingHoursOrNotSet } from '../../lib/utils.js';
|
||||
import assert from 'assert';
|
||||
import { expect } from 'chai';
|
||||
|
||||
@@ -8,30 +8,45 @@ const fakeWorkingHoursConfig = (from, to) => ({
|
||||
from,
|
||||
},
|
||||
});
|
||||
|
||||
describe('utils', () => {
|
||||
describe('#isOneOf()', () => {
|
||||
it('should be false', () => {
|
||||
assert.equal(utils.isOneOf('bla', ['blub']), false);
|
||||
assert.equal(isOneOf('bla', ['blub']), false);
|
||||
});
|
||||
it('should be true', () => {
|
||||
assert.equal(utils.isOneOf('bla blub blubber', ['bla']), true);
|
||||
assert.equal(isOneOf('bla blub blubber', ['bla']), true);
|
||||
});
|
||||
});
|
||||
describe('#duringWorkingHoursOrNotSet()', () => {
|
||||
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', () => {
|
||||
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', () => {
|
||||
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', () => {
|
||||
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', () => {
|
||||
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, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'User-Agent': 'ImmoScout24_1410_30_._',
|
||||
'User-Agent': 'ImmoScout_27.3_26.0_._',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
|
||||
142
test/storage/SqliteConnection.test.js
Normal file
@@ -0,0 +1,142 @@
|
||||
import { expect } from 'chai';
|
||||
import esmock from 'esmock';
|
||||
|
||||
// We explicitly avoid touching the real filesystem or creating a real DB file.
|
||||
// better-sqlite3 is fully mocked and operates in-memory via our stubs.
|
||||
|
||||
describe('SqliteConnection', () => {
|
||||
let SqliteConnection;
|
||||
let calls;
|
||||
|
||||
beforeEach(async () => {
|
||||
calls = {
|
||||
fs: { existsSync: [], mkdirSync: [] },
|
||||
db: { pragma: [], prepare: [], transactionWraps: 0, close: 0 },
|
||||
prepareAll: [],
|
||||
prepareRun: [],
|
||||
prepareGet: [],
|
||||
processOnce: [],
|
||||
logs: { warn: [], debug: [] },
|
||||
};
|
||||
|
||||
// stub for fs
|
||||
const fsMock = {
|
||||
existsSync: (dir) => {
|
||||
calls.fs.existsSync.push(dir);
|
||||
// Pretend directory always exists to avoid mkdir
|
||||
return true;
|
||||
},
|
||||
mkdirSync: (dir, opts) => {
|
||||
calls.fs.mkdirSync.push({ dir, opts });
|
||||
},
|
||||
};
|
||||
|
||||
// Prepare object returned from db.prepare()
|
||||
const prepareObj = {
|
||||
all: (params) => {
|
||||
calls.prepareAll.push(params);
|
||||
return [{ x: 1 }];
|
||||
},
|
||||
run: (params) => {
|
||||
calls.prepareRun.push(params);
|
||||
return { changes: 1 };
|
||||
},
|
||||
get: (param) => {
|
||||
calls.prepareGet.push(param);
|
||||
// return truthy by default
|
||||
return { one: 1 };
|
||||
},
|
||||
};
|
||||
|
||||
// Database mock constructor
|
||||
const BetterSqlite3Mock = function (filepath, options) {
|
||||
// expose on instance
|
||||
this.filepath = filepath;
|
||||
this.options = options;
|
||||
this.pragma = (p) => {
|
||||
calls.db.pragma.push(p);
|
||||
return undefined;
|
||||
};
|
||||
this.prepare = (sql) => {
|
||||
calls.db.prepare.push(sql);
|
||||
return prepareObj;
|
||||
};
|
||||
this.transaction = (fn) => {
|
||||
// better-sqlite3 returns a function that executes inside a transaction
|
||||
return (cb) => {
|
||||
calls.db.transactionWraps += 1;
|
||||
return fn(cb);
|
||||
};
|
||||
};
|
||||
this.close = () => {
|
||||
calls.db.close += 1;
|
||||
};
|
||||
};
|
||||
|
||||
// esmock the module with our stubs
|
||||
SqliteConnection = await esmock(
|
||||
'../../lib/services/storage/SqliteConnection.js',
|
||||
{},
|
||||
{
|
||||
fs: fsMock,
|
||||
'better-sqlite3': { default: BetterSqlite3Mock },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// ensure we can close between tests
|
||||
SqliteConnection.close();
|
||||
});
|
||||
|
||||
it('creates singleton connection and applies PRAGMAs without touching disk', () => {
|
||||
const db1 = SqliteConnection.getConnection();
|
||||
const db2 = SqliteConnection.getConnection();
|
||||
|
||||
expect(db1).to.equal(db2);
|
||||
// journal_mode, synchronous, cache_size, foreign_keys, optimize
|
||||
expect(calls.db.pragma).to.deep.equal([
|
||||
'journal_mode = WAL',
|
||||
'synchronous = NORMAL',
|
||||
'cache_size = -64000',
|
||||
'foreign_keys = ON',
|
||||
'optimize',
|
||||
]);
|
||||
// mkdirSync should not be called because existsSync returned true
|
||||
expect(calls.fs.mkdirSync).to.have.length(0);
|
||||
});
|
||||
|
||||
it('executes query and execute helpers', () => {
|
||||
const rows = SqliteConnection.query('SELECT 1', {});
|
||||
expect(rows).to.be.an('array');
|
||||
expect(rows[0]).to.deep.equal({ x: 1 });
|
||||
|
||||
const info = SqliteConnection.execute('UPDATE x SET y=1 WHERE id=@id', { id: 5 });
|
||||
expect(info).to.have.property('changes', 1);
|
||||
});
|
||||
|
||||
it('tableExists uses sqlite_master get()', () => {
|
||||
const exists = SqliteConnection.tableExists('users');
|
||||
expect(exists).to.equal(true);
|
||||
});
|
||||
|
||||
it('withTransaction wraps callback', () => {
|
||||
const result = SqliteConnection.withTransaction((db) => {
|
||||
// ensure we can use the db to prepare
|
||||
db.prepare('SELECT inside').all({});
|
||||
return 42;
|
||||
});
|
||||
expect(result).to.equal(42);
|
||||
expect(calls.db.prepare).to.include('SELECT inside');
|
||||
});
|
||||
|
||||
it('optimize() delegates to PRAGMA optimize and close() calls it again then closes', () => {
|
||||
SqliteConnection.optimize();
|
||||
// It will use the existing connection and call pragma('optimize')
|
||||
expect(calls.db.pragma).to.include('optimize');
|
||||
|
||||
SqliteConnection.close();
|
||||
// close increments close counter
|
||||
expect(calls.db.close).to.equal(1);
|
||||
});
|
||||
});
|
||||
171
ui/src/App.jsx
@@ -6,34 +6,40 @@ import GeneralSettings from './views/generalSettings/GeneralSettings';
|
||||
import JobMutation from './views/jobs/mutation/JobMutation';
|
||||
import UserMutator from './views/user/mutation/UserMutator';
|
||||
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 Logout from './components/logout/Logout';
|
||||
import Logo from './components/logo/Logo';
|
||||
import Menu from './components/menu/Menu';
|
||||
import Login from './views/login/Login';
|
||||
import Users from './views/user/Users';
|
||||
import Jobs from './views/jobs/Jobs';
|
||||
|
||||
import './App.less';
|
||||
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() {
|
||||
const dispatch = useDispatch();
|
||||
const actions = useActions();
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const currentUser = useSelector((state) => state.user.currentUser);
|
||||
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
|
||||
const settings = useSelector((state) => state.generalSettings.settings);
|
||||
const processingTimes = useSelector((state) => state.jobs.processingTimes);
|
||||
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
await dispatch.user.getCurrentUser();
|
||||
await actions.user.getCurrentUser();
|
||||
if (!needsLogin()) {
|
||||
await dispatch.provider.getProvider();
|
||||
await dispatch.jobs.getJobs();
|
||||
await dispatch.jobs.getProcessingTimes();
|
||||
await dispatch.notificationAdapter.getAdapter();
|
||||
await dispatch.generalSettings.getGeneralSettings();
|
||||
await actions.provider.getProvider();
|
||||
await actions.jobs.getJobs();
|
||||
await actions.jobs.getProcessingTimes();
|
||||
await actions.notificationAdapter.getAdapter();
|
||||
await actions.generalSettings.getGeneralSettings();
|
||||
await actions.versionUpdate.getVersionUpdate();
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -46,81 +52,88 @@ export default function FredyApp() {
|
||||
};
|
||||
|
||||
const isAdmin = () => currentUser != null && currentUser.isAdmin;
|
||||
const { Footer, Sider, Content } = Layout;
|
||||
|
||||
const login = () => (
|
||||
return loading ? null : needsLogin() ? (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
return loading ? null : needsLogin() ? (
|
||||
login()
|
||||
) : (
|
||||
<div className="app">
|
||||
<div className="app__container">
|
||||
<Logout />
|
||||
<Logo width={190} white />
|
||||
<Menu isAdmin={isAdmin()} />
|
||||
<Layout className="app">
|
||||
<Layout className="app">
|
||||
<Sider>
|
||||
<Navigation 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 && (
|
||||
<>
|
||||
<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 />}
|
||||
<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 />} />
|
||||
{/* Permission-aware routes */}
|
||||
<Route
|
||||
path="/users/new"
|
||||
element={
|
||||
<PermissionAwareRoute currentUser={currentUser}>
|
||||
<UserMutator />
|
||||
</PermissionAwareRoute>
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Permission-aware routes */}
|
||||
<Route
|
||||
path="/users/new"
|
||||
element={
|
||||
<PermissionAwareRoute currentUser={currentUser}>
|
||||
<UserMutator />
|
||||
</PermissionAwareRoute>
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
<Route path="/" element={<Navigate to="/jobs" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</Content>
|
||||
</Layout>
|
||||
<Footer>
|
||||
<FredyFooter />
|
||||
</Footer>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
&__container {
|
||||
padding: 1rem 1rem;
|
||||
color: var(--semi-color-text-0);
|
||||
background-color: #232429;
|
||||
&__content {
|
||||
margin: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
import { reduxStore } from './services/rematch/store';
|
||||
import { HashRouter } from 'react-router-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import en_US from '@douyinfe/semi-ui/lib/es/locale/source/en_US';
|
||||
import { LocaleProvider } from '@douyinfe/semi-ui';
|
||||
@@ -18,11 +16,9 @@ initVChartSemiTheme({
|
||||
});
|
||||
|
||||
root.render(
|
||||
<Provider store={reduxStore}>
|
||||
<HashRouter>
|
||||
<LocaleProvider locale={en_US}>
|
||||
<App />
|
||||
</LocaleProvider>
|
||||
</HashRouter>
|
||||
</Provider>,
|
||||
<HashRouter>
|
||||
<LocaleProvider locale={en_US}>
|
||||
<App />
|
||||
</LocaleProvider>
|
||||
</HashRouter>,
|
||||
);
|
||||
|
||||
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';
|
||||
|
||||
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 { xhrPost } from '../../services/xhr';
|
||||
import { IconUser } from '@douyinfe/semi-icons';
|
||||
const Logout = function Logout() {
|
||||
|
||||
const Logout = function Logout({ text }) {
|
||||
return (
|
||||
<Button
|
||||
icon={<IconUser />}
|
||||
type="danger"
|
||||
theme="solid"
|
||||
onClick={async () => {
|
||||
await xhrPost('/api/login/logout');
|
||||
location.reload();
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
<div>
|
||||
<Button
|
||||
icon={<IconUser />}
|
||||
type="danger"
|
||||
theme="solid"
|
||||
onClick={async () => {
|
||||
await xhrPost('/api/login/logout');
|
||||
location.reload();
|
||||
}}
|
||||
>
|
||||
{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 (
|
||||
<Card
|
||||
className="segmentParts"
|
||||
title={
|
||||
<Meta title={name} description={helpText} avatar={Icon == null ? null : <Icon size="extra-extra-small" />} />
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
.segmentParts {
|
||||
border: 1px solid #323232 !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 { Button, Empty, Table, Switch } from '@douyinfe/semi-ui';
|
||||
import { IconDelete, IconEdit, IconHistogram } from '@douyinfe/semi-icons';
|
||||
import { Button, Empty, Table, Switch, Popover } from '@douyinfe/semi-ui';
|
||||
import { IconDelete, IconDescend2, IconEdit, IconHistogram } from '@douyinfe/semi-icons';
|
||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||
|
||||
import './JobTable.less';
|
||||
@@ -10,11 +10,20 @@ const empty = (
|
||||
<Empty
|
||||
image={<IllustrationNoResult />}
|
||||
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 (
|
||||
<Table
|
||||
pagination={false}
|
||||
@@ -32,7 +41,7 @@ export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged,
|
||||
dataIndex: 'name',
|
||||
},
|
||||
{
|
||||
title: 'Findings',
|
||||
title: 'Listings',
|
||||
dataIndex: 'numberOfFoundListings',
|
||||
render: (value) => {
|
||||
return value || 0;
|
||||
@@ -58,9 +67,18 @@ export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged,
|
||||
render: (_, job) => {
|
||||
return (
|
||||
<div className="interactions">
|
||||
<Button type="primary" icon={<IconHistogram />} onClick={() => onJobInsight(job.id)} />
|
||||
<Button type="secondary" icon={<IconEdit />} onClick={() => onJobEdit(job.id)} />
|
||||
<Button type="danger" icon={<IconDelete />} onClick={() => onJobRemoval(job.id)} />
|
||||
<Popover content={getPopoverContent('Job Insights')}>
|
||||
<Button type="primary" icon={<IconHistogram />} onClick={() => onJobInsight(job.id)} />
|
||||
</Popover>
|
||||
<Popover content={getPopoverContent('Edit a Job')}>
|
||||
<Button type="secondary" icon={<IconEdit />} onClick={() => onJobEdit(job.id)} />
|
||||
</Popover>
|
||||
<Popover content={getPopoverContent('Delete all found Listings of this Job')}>
|
||||
<Button type="danger" icon={<IconDescend2 />} onClick={() => onListingRemoval(job.id)} />
|
||||
</Popover>
|
||||
<Popover content={getPopoverContent('Delete Job')}>
|
||||
<Button type="danger" icon={<IconDelete />} onClick={() => onJobRemoval(job.id)} />
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||