Compare commits

..

103 Commits

Author SHA1 Message Date
orangecoding
db3702ed33 improve markdown readme's & and adding ability to send telegram messages to a topic in a supergroup 2025-10-30 12:42:03 +01:00
orangecoding
e3c62d4696 fixing test runner 2025-10-29 10:35:07 +01:00
orangecoding
79a8420dfb improving similarity cache 2025-10-29 09:36:05 +01:00
orangecoding
d433b13db6 next release version 2025-10-12 16:47:46 +02:00
orangecoding
41d9274dfd reducing logging 2025-10-12 16:47:28 +02:00
orangecoding
0436c7f7d7 upgrading dependencies / FredyRuntime >> FredyPipeline 2025-10-12 16:43:56 +02:00
Christian Kellner
a1cb57318e Update README.md 2025-10-11 17:37:51 +02:00
orangecoding
2566db9805 improve index 2025-10-08 15:00:28 +02:00
orangecoding
b48f786fd3 improve docu 2025-10-08 12:16:10 +02:00
orangecoding
9c74129489 fixing listings 2025-10-07 21:22:29 +02:00
orangecoding
33120ebeca ability to share jobs with users 2025-10-07 21:06:59 +02:00
orangecoding
de2dd05c70 reverting docker file change 2025-10-07 07:18:45 +02:00
orangecoding
e4784e5960 reverting docker file change 2025-10-06 20:21:26 +02:00
orangecoding
2e537ce0be improving ntfy error handling 2025-10-06 20:19:53 +02:00
orangecoding
f0f1244baa using docker without root 2025-10-06 19:55:37 +02:00
orangecoding
b858529f06 next release version 2025-10-05 18:57:52 +02:00
orangecoding
c9bd5dc161 fixing delete listings 2025-10-05 18:57:27 +02:00
orangecoding
daa4a7b8f1 refine telegram adapter 2025-10-05 18:53:17 +02:00
Thomas Brockmöller
035f0e9f83 Check Telegram response (#205) (#211)
* Add error handling and logging to Telegram message sending

* Add debug logging for new listings
2025-10-05 17:06:57 +02:00
Christian Kellner
a5efd9af32 New Feature: Watch Listings (#215)
* adding new feature: watch listings for changes

* adding todo for watch feature

* sort by watch
2025-10-05 14:23:32 +02:00
orangecoding
9f1e27d011 check if fredy config exists and is accessible 2025-10-03 17:23:46 +02:00
orangecoding
ebc57702dc next release version 2025-10-03 13:28:09 +02:00
orangecoding
3aa30bc1e2 remove listings from listingstable when clicked 2025-10-03 13:27:44 +02:00
orangecoding
f97fb48e51 Merge branch 'master' of github.com:orangecoding/fredy 2025-10-03 13:04:56 +02:00
orangecoding
4b15894603 adding buttons to remove listings from a given job 2025-10-03 13:04:35 +02:00
orangecoding
31a14a0352 improve footer and upgrade dependencies 2025-10-03 12:45:48 +02:00
Christian Kellner
eecbe91dbd Update README.md 2025-10-02 22:05:49 +02:00
orangecoding
9dd3947cb7 reverting docker file changes, adding script to test things locally 2025-10-02 09:37:01 +02:00
Iaroslav Postovalov
c151f4f76e Use non-root user in Dockerfile (#214) 2025-10-01 20:04:08 +02:00
Christian Kellner
b6755497e4 Ui-Redesign (#203)
* new ui design

* improving ui design

* adding new screenshots

* upgrade dependencies
2025-09-29 20:36:56 +02:00
rugk
412e24b1e3 Add VOLUME to Dockerfile (#208)
Notes/exposes the intended volumes as per best practices.

See https://docs.docker.com/build/building/best-practices/#volume
2025-09-29 12:31:32 +02:00
rugk
0a5785fa1a Specify GitHub image in docker-compose directly (#204)
It's recommend to specify the full "URL" and this aligns with the Readme and default docker would search on Docker Hub, where this is not available: https://hub.docker.com/search?q=fredy%2Ffredy
2025-09-29 12:31:08 +02:00
Thomas Brockmöller
7ebd73c9cf Add new provider McMakler (#201) 2025-09-28 14:16:28 +02:00
orangecoding
95cd4028d7 next release version 2025-09-28 08:13:03 +02:00
orangecoding
eb01c2107c fixing default header 2025-09-28 08:12:51 +02:00
orangecoding
42cd4fa0ae next release version 2025-09-27 18:15:58 +02:00
orangecoding
6d96fd2bf8 Merge branch 'master' of github.com:orangecoding/fredy 2025-09-27 18:15:42 +02:00
orangecoding
ff1d2317a1 improve default puppeteer header 2025-09-27 18:15:28 +02:00
orangecoding
a47fa41278 fixing smaller problems in apprise and mattermost 2025-09-27 18:07:48 +02:00
orangecoding
9654e56846 improving some labels 2025-09-27 18:01:42 +02:00
Christian Kellner
43094640a8 Update README.md 2025-09-27 14:27:25 +02:00
orangecoding
fa234d2d78 fixing code style issues in new discord adapter 2025-09-27 14:24:05 +02:00
orangecoding
7cb0d6e382 next release version 2025-09-27 14:22:09 +02:00
mari
d79f8d2664 Add Discord webhook adapter (#196)
* Add Discord webhook adapter
2025-09-27 14:20:43 +02:00
Thomas Brockmöller
4d37e890ab Add provider for Regionalimmobilien24 (#197) 2025-09-27 14:19:37 +02:00
Thomas Brockmöller
7589f20a18 Add sparkasse immobilien (#199) 2025-09-27 09:43:24 +02:00
Thomas Brockmöller
702ffabc1a Fix and improve immowelt/immonet provider (#194)
* Fix and improve immowelt provider

* Add description to immonet provider

* Fix tests and improve readability
2025-09-27 09:42:08 +02:00
orangecoding
9387de1cd9 next version 2025-09-26 13:09:22 +02:00
orangecoding
facd683d45 santizing ntfy header 2025-09-26 13:07:54 +02:00
Christian Kellner
8324357edb Improvements (#193)
* improving release banner

* renaming general to settings

* fixing working hours if they go to next day

* fixing comparing versions

* upgrade dependencies
2025-09-26 10:45:55 +02:00
Christian Kellner
67af7c7dc5 next version 2025-09-25 15:06:38 +02:00
Christian Kellner
6f5b52f3ad Merge branch 'master' of github.com:orangecoding/fredy 2025-09-25 15:06:25 +02:00
Christian Kellner
89d239c360 New Listings view (#192)
* completing found listings

---------

Co-authored-by: Christian Kellner <Christian.Kellner1@ibm.com>
2025-09-25 15:03:47 +02:00
Thomas Brockmöller
dd5c5b29d9 Fix address value in similarity filtering (#191)
* Fix address field in similarity filter
2025-09-25 15:02:00 +02:00
Christian Kellner
0cb2f48645 Merge branch 'master' of github.com:orangecoding/fredy 2025-09-25 09:47:16 +02:00
orangecoding
3f294b8099 next release version 2025-09-22 20:56:42 +02:00
Christian Kellner
11fd18e76a Puppeteer improvements (#186)
* improving puppeteer handling

* upgrade dependencies

* reduce logging

* upgrade nanoid
2025-09-22 20:53:00 +02:00
Christian Kellner
c839f3abc9 Check if a listing is still active (#184)
* check if a listing is still active

* upgrade dependencies
2025-09-22 09:57:50 +02:00
orangecoding
28eddc5d7f next release version 2025-09-20 19:49:32 +02:00
Iaroslav Postovalov
0ca9c5ae02 Add health check for Docker container (#179)
- Introduced `HealthCheck` in `docker-compose.yml` to monitor container status.
- Added a test step to validate container's health using Docker Compose in the GitHub workflow.
- Updated `Dockerfile` to include `curl` for health check commands.
2025-09-20 19:39:48 +02:00
orangecoding
a7d0037edd next release version 2025-09-20 19:37:47 +02:00
orangecoding
f339a2e2cf adding version banner to check if a new version of fredy is available 2025-09-20 19:37:27 +02:00
orangecoding
da8fd13973 fixing immoscout 2025-09-19 21:11:28 +02:00
orangecoding
7deffc64af next release version 2025-09-18 20:48:49 +02:00
orangecoding
d1dad7fd3b adding new unique index, adding button to start now 2025-09-18 20:48:25 +02:00
Christian Kellner
4f79c5cba2 replacing rematch with zustand (#180)
* replacing rematch with zustand

* upgrading dependencies

* next release version
2025-09-18 20:09:11 +02:00
orangecoding
28e885f6c7 fixing migration checksum 2025-09-18 18:42:19 +02:00
orangecoding
1d99fc95f7 using cron to run demo cleanup every day at midnight 2025-09-18 18:04:49 +02:00
orangecoding
28f0a167e6 fixing docker migration path 2025-09-18 17:28:30 +02:00
Christian Kellner
8d95f052c6 Migrate to SQLite (#174)
* Migrating Fredy from LowDb to SqLite 🎉

* adding new sql migration system for future sql migrations

* adding setting to change  sqlite path for db files

* create migration plan for graceful migration lowdb -> sqlite

* Improving Documentation

* adding test for sqliteconnection

* upgrading dependencies

* making nodejs 22 as min version

* improve scraper

* adding overwrite ability for db migra
2025-09-18 15:38:23 +02:00
orangecoding
18fdbd761a next release version 2025-09-17 09:12:45 +02:00
Iaroslav Postovalov
027e7d70ed Update SQLite adapter: configurable database path (#169) 2025-09-17 09:12:04 +02:00
Christian Kellner
de119c9199 Update logger.js 2025-09-14 15:46:31 +02:00
orangecoding
ce7f0bca9f next release version 2025-09-14 10:40:41 +02:00
orangecoding
ae1c4d936b do not log debug on production 2025-09-14 10:40:18 +02:00
orangecoding
d01a1a94d0 Merge branch 'master' of github.com:orangecoding/fredy 2025-09-14 10:32:52 +02:00
orangecoding
bda4212249 improve logging 2025-09-14 10:32:39 +02:00
Christian Kellner
694809fedf Using white fredy logo on dark background 2025-09-13 22:20:50 +02:00
Christian Kellner
3cd1893b51 Update Jetbrains logo to use the correct one on dark background 2025-09-13 22:16:16 +02:00
orangecoding
21415dcff3 using winston logger 2025-09-13 18:57:56 +02:00
orangecoding
e868cdce86 Merge branch 'master' of github.com:orangecoding/fredy 2025-09-13 17:06:30 +02:00
orangecoding
d66dc2cd93 improve tracking 2025-09-13 17:06:18 +02:00
Christian Kellner
5e0405f1ec Update README.md 2025-09-12 18:47:10 +02:00
orangecoding
251de1e42d next release version 2025-09-12 13:48:05 +02:00
orangecoding
edc91291b6 fixing telegram 2025-09-12 13:45:54 +02:00
orangecoding
ac0ea64c07 remove unnecessary logging 2025-09-12 13:41:08 +02:00
orangecoding
9f7506a1b3 Merge branch 'master' of github.com:orangecoding/fredy 2025-09-12 13:39:15 +02:00
orangecoding
85cea66051 improving tracking. now using internal tracking 2025-09-12 13:38:53 +02:00
Christian Kellner
05c2df917c Adding link to fredy demo 2025-09-12 13:00:43 +02:00
Christian Kellner
4ad2895eec Update docker command 2025-09-10 11:31:49 +02:00
orangecoding
7372e5313f creating config automagically if missing 2025-09-09 18:41:14 +02:00
orangecoding
637a54e01e upgrading dependencies 2025-09-09 15:17:36 +02:00
orangecoding
04265eaec7 making sure scan interval does not go under 5 2025-09-08 08:30:45 +02:00
orangecoding
fa76821f7d next release version 2025-09-07 22:15:45 +02:00
orangecoding
09c6ce1d0b improve similarity cache. It now checks for similarities independend from jobs 2025-09-07 22:15:14 +02:00
Christian Kellner
7fa9a265ef Fixing docker command 2025-09-07 16:46:43 +02:00
Christian Kellner
f201090b56 Update README.md 2025-09-05 12:35:20 +02:00
Christian Kellner
dda5b5fbcb Update README.md 2025-09-05 12:34:03 +02:00
Christian Kellner
a93c7ffee5 Update README.md 2025-09-05 12:33:28 +02:00
Christian Kellner
79a2d967e8 Update README.md 2025-09-05 12:33:12 +02:00
Christian Kellner
c264e11c26 Update README.md 2025-09-05 12:32:50 +02:00
Christian Kellner
9f8d189f47 Update README.md 2025-09-05 12:24:16 +02:00
Christian Kellner
0d2b21c789 improve security by shortn the cookie ttl 2025-09-04 12:52:18 +02:00
163 changed files with 5415 additions and 1759 deletions

View File

@@ -13,7 +13,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: 'yarn'
- name: Install dependencies

View File

@@ -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

View File

@@ -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
View File

@@ -1,6 +1,8 @@
node_modules/
ui/public/
db/
db/*.json
db/*.db*
npm-debug.log
.DS_Store
.idea
.vscode

View File

@@ -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"]

View File

@@ -1,22 +1,41 @@
<p align="center">
<a href="https://fredy.orange-coding.net/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/orangecoding/fredy/blob/master/doc/logo_white.png" width="400">
<source media="(prefers-color-scheme: light)" srcset="https://github.com/orangecoding/fredy/blob/master/doc/logo.png" width="400">
<img alt="Jetbrains Open Source" src="https://github.com/orangecoding/fredy/blob/master/doc/logo.png">
</picture>
</a>
</p>
<p align="center">
<a href="https://fredy.orange-coding.net/" target="_blank">Website</a>&nbsp;&nbsp;|&nbsp;&nbsp;
<a href="https://fredy-demo.orange-coding.net/" target="_blank">Demo</a>
</p>
<p align="center">
<img src="https://github.com/orangecoding/fredy/actions/workflows/test.yml/badge.svg" alt="Tests" />
<img src="https://github.com/orangecoding/fredy/actions/workflows/docker.yml/badge.svg" alt="Docker" />
<img src="https://github.com/orangecoding/fredy/actions/workflows/check_source.yml/badge.svg" alt="Source" />
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fghcr-badge.elias.eu.org%2Fapi%2Forangecoding%2Ffredy%2Ffredy&query=%24.downloadCount&label=Docker%20Pulls" alt="Docker Pulls" />
</p>
# Fredy 🏡 Your Self-Hosted Real Estate Finder for Germany
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.
<img src="https://github.com/orangecoding/fredy/blob/master/doc/logo.png" width="400">
![Tests](https://github.com/orangecoding/fredy/actions/workflows/test.yml/badge.svg)
[![Docker](https://github.com/orangecoding/fredy/actions/workflows/docker.yml/badge.svg)](https://github.com/orangecoding/fredy/actions/workflows/docker.yml)
![Source](https://github.com/orangecoding/fredy/actions/workflows/check_source.yml/badge.svg)
------------------------------------------------------------------------
## ✨ Key Features
@@ -24,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
@@ -39,10 +58,18 @@ same listing twice.
I maintain Fredy and other open-source projects in my free time.\
If you find it useful, consider supporting the project 💙
[<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.svg" alt="JetBrains" width="120"/>](https://jb.gg/OpenSourceSupport)
Fredy is proudly backed by the **JetBrains Open Source Support Program**.
Fredy is proudly supported by the **JetBrains Open Source Support
Program**.
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://www.jetbrains.com/company/brand/img/logo_jb_dos_3.svg">
<source media="(prefers-color-scheme: light)" srcset="https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.svg">
<img alt="Jetbrains Open Source" src="https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.svg">
</picture>
------------------------------------------------------------------------
## 👨‍🏫 Demo
You can try out Fredy here: [Fredy Demo](https://fredy-demo.orange-coding.net/)
------------------------------------------------------------------------
@@ -50,10 +77,15 @@ Program**.
### With Docker
> [!NOTE]
> In order to start Fredy, you must provide a config.json. As a start, use the one in this repo: https://github.com/orangecoding/fredy/blob/master/conf/config.json
``` bash
docker pull ghcr.io/orangecoding/fredy:master
docker create --name fredy -v /path/to/your/conf/:/conf -p 9998:9998 fredy/fredy
docker start fredy
docker run -d --name fredy \
-v fredy_conf:/conf \
-v fredy_db:/db \
-p 9998:9998 \
ghcr.io/orangecoding/fredy:master
```
Logs:
@@ -64,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
@@ -83,9 +115,9 @@ yarn run start:frontend # in another terminal
## 📸 Screenshots
| Job Configuration | Job Analytics | Job Overview |
|-------------------|--------------|--------------|
| ![Screenshot showing job configuration in Fredy](doc/screenshot1.png) | ![Screenshot showing job analytics in Fredy](doc/screenshot_2.png) | ![Screenshot showing job overview in Fredy](doc/screenshot_3.png) |
| Fredy Main Overview | Job Configuration | Found Listings |
|--------------------------------------------------|-----------------------------------------------------------------------|-----------------------------------------------------------------------------|
| ![Screenshot showing Fredy](doc/screenshot1.png) | ![Screenshot showing job configuration in Fredy](doc/screenshot3.png) | ![Screenshot showing found listings in Fredy](doc/screenshot2.png) |
------------------------------------------------------------------------
@@ -105,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.
@@ -128,7 +160,7 @@ Immoscout has implemented advanced bot detection. In order to work around this,
Fredy is completely free (and will always remain free). However, it would be a huge help if youd allow me to collect some analytical data.
Before you freak out, let me explain...
If you agree, Fredy will send a ping to my Mixpanel project each time it runs.
If you agree, Fredy will send a ping once every 6 hours to my internal tracking project (Will be open sourced soon).
The data includes: names of active adapters/providers, OS, architecture, Node version, and language. The information is entirely anonymous and helps me understand which adapters/providers are most frequently used.</p>
**Thanks**🤘
@@ -170,7 +202,7 @@ flowchart TD
F2["Adapter 2"]
end
A1 --> B["FredyRuntime"]
A1 --> B["FredyPipeline"]
A2 --> B
A3 --> B
B --> C1 & C2 & C3
@@ -188,9 +220,7 @@ flowchart TD
Thanks to everyone who has contributed!
`<a href="https://github.com/orangecoding/fredy/graphs/contributors">`{=html}
`<img src="https://contrib.rocks/image?repo=orangecoding/fredy" />`{=html}
`</a>`{=html}
<a href="https://github.com/orangecoding/fredy/graphs/contributors"><img src="https://contrib.rocks/image?repo=orangecoding/fredy" /></a>
See the [Contributing
Guide](https://github.com/orangecoding/fredy/blob/master/CONTRIBUTING.md).

2
conf/config.json Executable file → Normal file
View File

@@ -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
View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 197 KiB

BIN
doc/screenshot2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 KiB

BIN
doc/screenshot3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -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 Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/sh
set -e
# Stop and remove old container if it exists
if [ "$(docker ps -aq -f name=fredy)" ]; then
docker stop fredy || true
docker rm fredy || true
fi
# Build image from local Dockerfile, forcing a fresh build without cache
docker build --no-cache -t fredy:local .
# Run container with volumes and port mapping
docker run -d --name fredy \
-v fredy_conf:/conf \
-v fredy_db:/db \
-p 9998:9998 \
fredy:local

View File

@@ -7,11 +7,14 @@
content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
/>
<meta name="google" content="notranslate" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>Fredy</title>
<title>Fredy || Real Estate Finder</title>
</head>
<body theme-mode="dark">
<div id="fredy" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0"></div>
</body>
<script type="module" src="/ui/src/Index.jsx"></script>
</html>

134
index.js
View File

@@ -1,62 +1,98 @@
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 FredyPipeline from './lib/FredyPipeline.js';
import { duringWorkingHoursOrNotSet } from './lib/utils.js';
import './lib/api/api.js';
import { track } from './lib/services/tracking/Tracker.js';
import { handleDemoUser } from './lib/services/storage/userStorage.js';
import { cleanupDemoAtMidnight } from './lib/services/demoCleanup.js';
//if db folder does not exist, ensure to create it before loading anything else
if (!fs.existsSync('./db')) {
fs.mkdirSync('./db');
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';
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();
similarityCache.initSimilarityCache();
similarityCache.startSimilarityCacheReloader();
//assuming interval is always in minutes
const INTERVAL = config.interval * 60 * 1000;
/* eslint-disable no-console */
console.log(`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) {
console.info('Running in demo mode');
logger.info('Running in demo mode');
cleanupDemoAtMidnight();
}
/* eslint-enable no-console */
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}`);
setInterval(
(function exec() {
const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(config, Date.now());
if (!config.demoMode) {
if (isDuringWorkingHoursOrNotSet) {
track();
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 {
/* eslint-disable no-console */
console.debug('Working hours set. Skipping as outside of working hours.');
/* eslint-enable no-console */
}
ensureAdminUserExists();
ensureDemoUserExists();
await initTrackerCron();
//do not wait for this to finish, let it run in the background
initActiveCheckerCron();
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 FredyPipeline(
matchedProvider.config,
job.notificationAdapter,
prov.id,
job.id,
similarityCache,
).execute();
});
});
} else {
logger.debug('Working hours set. Skipping as outside of working hours.');
}
return exec;
})(),
INTERVAL,
);
}
};
setInterval(execute, INTERVAL);
//start once at startup
execute();

216
lib/FredyPipeline.js Executable file
View File

@@ -0,0 +1,216 @@
import { NoNewListingsWarning } from './errors.js';
import { storeListings, getKnownListingHashesForJobAndProvider } from './services/storage/listingsStorage.js';
import * as notify from './notification/notify.js';
import Extractor from './services/extractor/extractor.js';
import urlModifier from './services/queryStringMutator.js';
import logger from './services/logger.js';
/**
* @typedef {Object} Listing
* @property {string} id Stable unique identifier (hash) of the listing.
* @property {string} title Title or headline of the listing.
* @property {string} [address] Optional address/location text.
* @property {string} [price] Optional price text/value.
* @property {string} [url] Link to the listing detail page.
* @property {any} [meta] Provider-specific additional metadata.
*/
/**
* @typedef {Object} SimilarityCache
* @property {(title:string, address?:string)=>boolean} hasSimilarEntries Returns true if a similar entry is known.
* @property {(title:string, address?:string)=>void} addCacheEntry Adds a new entry to the similarity cache.
*/
/**
* Runtime orchestrator for fetching, normalizing, filtering, deduplicating, storing,
* and notifying about new listings from a configured provider.
*
* The execution flow is:
* 1) Prepare provider URL (sorting, etc.)
* 2) Extract raw listings from the provider
* 3) Normalize listings to the provider schema
* 4) Filter out incomplete/blacklisted listings
* 5) Identify new listings (vs. previously stored hashes)
* 6) Persist new listings
* 7) Filter out entries similar to already seen ones
* 8) Dispatch notifications
*/
class FredyPipeline {
/**
* Create a new runtime instance for a single provider/job execution.
*
* @param {Object} providerConfig Provider configuration.
* @param {string} providerConfig.url Base URL to crawl.
* @param {string} [providerConfig.sortByDateParam] Query parameter used to enforce sorting by date (provider-specific).
* @param {string} [providerConfig.waitForSelector] CSS selector to wait for before parsing content.
* @param {Object.<string, string>} providerConfig.crawlFields Mapping of field names to selectors/paths to extract.
* @param {string} providerConfig.crawlContainer CSS selector for the container holding listing items.
* @param {(raw:any)=>Listing} providerConfig.normalize Function to convert raw scraped data into a Listing shape.
* @param {(listing:Listing)=>boolean} providerConfig.filter Function to filter out unwanted listings.
* @param {(url:string, waitForSelector?:string)=>Promise<void>|Promise<Listing[]>} [providerConfig.getListings] Optional override to fetch listings.
*
* @param {Object} notificationConfig Notification configuration passed to notification adapters.
* @param {string} providerId The ID of the provider currently in use.
* @param {string} jobKey Key of the job that is currently running (from within the config).
* @param {SimilarityCache} similarityCache Cache instance for checking similar entries.
*/
constructor(providerConfig, notificationConfig, providerId, jobKey, similarityCache) {
this._providerConfig = providerConfig;
this._notificationConfig = notificationConfig;
this._providerId = providerId;
this._jobKey = jobKey;
this._similarityCache = similarityCache;
}
/**
* Execute the end-to-end pipeline for a single provider run.
*
* @returns {Promise<Listing[]|void>} Resolves to the list of new (and similarity-filtered) listings
* after notifications have been sent; resolves to void when there are no new listings.
*/
execute() {
return Promise.resolve(urlModifier(this._providerConfig.url, this._providerConfig.sortByDateParam))
.then(this._providerConfig.getListings?.bind(this) ?? this._getListings.bind(this))
.then(this._normalize.bind(this))
.then(this._filter.bind(this))
.then(this._findNew.bind(this))
.then(this._save.bind(this))
.then(this._filterBySimilarListings.bind(this))
.then(this._notify.bind(this))
.catch(this._handleError.bind(this));
}
/**
* Fetch listings from the provider, using the default Extractor flow unless
* a provider-specific getListings override is supplied.
*
* @param {string} url The provider URL to fetch from.
* @returns {Promise<Listing[]>} Resolves with an array of listings (empty when none found).
*/
_getListings(url) {
const extractor = new Extractor();
return new Promise((resolve, reject) => {
extractor
.execute(url, this._providerConfig.waitForSelector)
.then(() => {
const listings = extractor.parseResponseText(
this._providerConfig.crawlContainer,
this._providerConfig.crawlFields,
url,
);
resolve(listings == null ? [] : listings);
})
.catch((err) => {
reject(err);
logger.error(err);
});
});
}
/**
* Normalize raw listings into the provider-specific Listing shape.
*
* @param {any[]} listings Raw listing entries from the extractor or override.
* @returns {Listing[]} Normalized listings.
*/
_normalize(listings) {
return listings.map(this._providerConfig.normalize);
}
/**
* Filter out listings that are missing required fields and those rejected by the
* provider's blacklist/filter function.
*
* @param {Listing[]} listings Listings to filter.
* @returns {Listing[]} Filtered listings that pass validation and provider filter.
*/
_filter(listings) {
const keys = Object.keys(this._providerConfig.crawlFields);
const filteredListings = listings.filter((item) => keys.every((key) => key in item));
return filteredListings.filter(this._providerConfig.filter);
}
/**
* Determine which listings are new by comparing their IDs against stored hashes.
*
* @param {Listing[]} listings Listings to evaluate for novelty.
* @returns {Listing[]} New listings not seen before.
* @throws {NoNewListingsWarning} When no new listings are found.
*/
_findNew(listings) {
logger.debug(`Checking ${listings.length} listings for new entries (Provider: '${this._providerId}')`);
const hashes = getKnownListingHashesForJobAndProvider(this._jobKey, this._providerId) || [];
const newListings = listings.filter((o) => !hashes.includes(o.id));
if (newListings.length === 0) {
throw new NoNewListingsWarning();
}
return newListings;
}
/**
* Send notifications for new listings using the configured notification adapter(s).
*
* @param {Listing[]} newListings New listings to notify about.
* @returns {Promise<Listing[]>} Resolves to the provided listings after notifications complete.
* @throws {NoNewListingsWarning} When there are no listings to notify about.
*/
_notify(newListings) {
if (newListings.length === 0) {
throw new NoNewListingsWarning();
}
const sendNotifications = notify.send(this._providerId, newListings, this._notificationConfig, this._jobKey);
return Promise.all(sendNotifications).then(() => newListings);
}
/**
* Persist new listings and pass them through.
*
* @param {Listing[]} newListings Listings to store.
* @returns {Listing[]} The same listings, unchanged.
*/
_save(newListings) {
logger.debug(`Storing ${newListings.length} new listings (Provider: '${this._providerId}')`);
storeListings(this._jobKey, this._providerId, newListings);
return newListings;
}
/**
* Remove listings that are similar to already known entries according to the similarity cache.
* Adds the remaining listings to the cache.
*
* @param {Listing[]} listings Listings to filter by similarity.
* @returns {Listing[]} Listings considered unique enough to keep.
*/
_filterBySimilarListings(listings) {
return listings.filter((listing) => {
const similar = this._similarityCache.checkAndAddEntry({
title: listing.title,
address: listing.address,
price: listing.price,
});
if (similar) {
logger.debug(
`Filtering similar entry for title '${listing.title}' and address '${listing.address}' (Provider: '${this._providerId}')`,
);
}
return !similar;
});
}
/**
* Handle errors occurring in the pipeline, logging levels depending on type.
*
* @param {Error} err Error instance thrown by previous steps.
* @returns {void}
*/
_handleError(err) {
if (err.name === 'NoNewListingsWarning') {
logger.debug(`No new listings found (Provider: '${this._providerId}').`);
} else {
logger.error(err);
}
}
}
export default FredyPipeline;

View File

@@ -1,122 +0,0 @@
import { NoNewListingsWarning } from './errors.js';
import { setKnownListings, getKnownListings } 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';
class FredyRuntime {
/**
*
* @param providerConfig the config for the specific provider, we're going to query at the moment
* @param notificationConfig the config for all notifications
* @param providerId the id of the provider currently in use
* @param jobKey key of the job that is currently running (from within the config)
* @param similarityCache cache instance holding values to check for similarity of entries
*/
constructor(providerConfig, notificationConfig, providerId, jobKey, similarityCache) {
this._providerConfig = providerConfig;
this._notificationConfig = notificationConfig;
this._providerId = providerId;
this._jobKey = jobKey;
this._similarityCache = similarityCache;
}
execute() {
return (
//modify the url to make sure search order is correctly set
Promise.resolve(urlModifier(this._providerConfig.url, this._providerConfig.sortByDateParam))
//scraping the site and try finding new listings
.then(this._providerConfig.getListings?.bind(this) ?? this._getListings.bind(this))
//bring them in a proper form (dictated by the provider)
.then(this._normalize.bind(this))
//filter listings with stuff tagged by the blacklist of the provider
.then(this._filter.bind(this))
//check if new listings available. if so proceed
.then(this._findNew.bind(this))
//store everything in db
.then(this._save.bind(this))
//check for similar listings. if found, remove them before notifying
.then(this._filterBySimilarListings.bind(this))
//notify the user using the configured notification adapter
.then(this._notify.bind(this))
//if an error occurred on the way, handle it here.
.catch(this._handleError.bind(this))
);
}
_getListings(url) {
const extractor = new Extractor();
return new Promise((resolve, reject) => {
extractor
.execute(url, this._providerConfig.waitForSelector)
.then(() => {
const listings = extractor.parseResponseText(
this._providerConfig.crawlContainer,
this._providerConfig.crawlFields,
url,
);
resolve(listings == null ? [] : listings);
})
.catch((err) => {
reject(err);
console.error(err);
});
});
}
_normalize(listings) {
return listings.map(this._providerConfig.normalize);
}
_filter(listings) {
//only return those where all the fields have been found
const keys = Object.keys(this._providerConfig.crawlFields);
const filteredListings = listings.filter((item) => keys.every((key) => key in item));
return filteredListings.filter(this._providerConfig.filter);
}
_findNew(listings) {
const newListings = listings.filter((o) => getKnownListings(this._jobKey, this._providerId)[o.id] == null);
if (newListings.length === 0) {
throw new NoNewListingsWarning();
}
return newListings;
}
_notify(newListings) {
if (newListings.length === 0) {
throw new NoNewListingsWarning();
}
const sendNotifications = notify.send(this._providerId, newListings, this._notificationConfig, this._jobKey);
return Promise.all(sendNotifications).then(() => newListings);
}
_save(newListings) {
const currentListings = getKnownListings(this._jobKey, this._providerId) || {};
newListings.forEach((listing) => {
currentListings[listing.id] = Date.now();
});
setKnownListings(this._jobKey, this._providerId, currentListings);
return newListings;
}
_filterBySimilarListings(listings) {
const filteredList = listings.filter((listing) => {
const similar = this._similarityCache.hasSimilarEntries(this._jobKey, listing.title);
if (similar) {
/* eslint-disable no-console */
console.debug(`Filtering similar entry for job with id ${this._jobKey} with title: `, listing.title);
/* eslint-enable no-console */
}
return !similar;
});
filteredList.forEach((filter) => this._similarityCache.addCacheEntry(this._jobKey, filter.title));
return filteredList;
}
_handleError(err) {
if (err.name !== 'NoNewListingsWarning') console.error(err);
}
}
export default FredyRuntime;

View File

@@ -3,16 +3,19 @@ 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';
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;
@@ -22,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);
@@ -29,12 +35,13 @@ 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);
/* eslint-disable no-console */
service.start(PORT).then(() => {
console.info(`Started API service on port ${PORT}`);
logger.debug(`Started API service on port ${PORT}`);
});

View File

@@ -1,7 +1,8 @@
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();
generalSettingsRouter.get('/', async (req, res) => {
@@ -18,9 +19,9 @@ 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) {
console.error(err);
logger.error(err);
res.send(new Error('Error while trying to write settings.'));
return;
}

View File

@@ -3,9 +3,12 @@ import * as jobStorage from '../../services/storage/jobStorage.js';
import * as userStorage from '../../services/storage/userStorage.js';
import { config } from '../../utils.js';
import { isAdmin } from '../security.js';
import { trackDemoJobCreated } from '../../services/tracking/Tracker.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,12 +20,29 @@ 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)
res.body = jobStorage.getJobs().filter((job) => isUserAdmin || job.userId === req.session.currentUser);
res.body = jobStorage
.getJobs()
.filter(
(job) =>
isUserAdmin || job.userId === req.session.currentUser || job.shared_with_user.includes(req.session.currentUser),
)
.map((job) => {
return {
...job,
isOnlyShared:
!isUserAdmin &&
job.userId !== req.session.currentUser &&
job.shared_with_user.includes(req.session.currentUser),
};
});
res.send();
});
jobRouter.get('/processingTimes', async (req, res) => {
res.body = {
interval: config.interval,
@@ -30,9 +50,22 @@ 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;
const { provider, notificationAdapter, name, blacklist = [], jobId, enabled, shareWithUsers = [] } = req.body;
try {
let jobFromDb = jobStorage.getJob(jobId);
if (jobFromDb && !doesJobBelongsToUser(jobFromDb, req)) {
res.send(new Error('You are trying to change a job that is not associated to your user.'));
return;
}
jobStorage.upsertJob({
userId: req.session.currentUser,
jobId,
@@ -41,18 +74,15 @@ jobRouter.post('/', async (req, res) => {
blacklist,
provider,
notificationAdapter,
shareWithUsers,
});
} catch (error) {
res.send(new Error(error));
console.error(error);
logger.error(error);
}
trackDemoJobCreated({
name,
provider,
adapter: notificationAdapter,
});
res.send();
});
jobRouter.delete('', async (req, res) => {
const { jobId } = req.body;
try {
@@ -64,7 +94,7 @@ jobRouter.delete('', async (req, res) => {
}
} catch (error) {
res.send(new Error(error));
console.error(error);
logger.error(error);
}
res.send();
});
@@ -83,8 +113,20 @@ jobRouter.put('/:jobId/status', async (req, res) => {
}
} catch (error) {
res.send(new Error(error));
console.error(error);
logger.error(error);
}
res.send();
});
jobRouter.get('/shareableUserList', async (req, res) => {
const currentUser = req.session.currentUser;
const users = userStorage.getUsers(false);
res.body = users
.filter((user) => !user.isAdmin && user.id !== currentUser)
.map((user) => ({
id: user.id,
name: user.username,
}));
res.send();
});
export { jobRouter };

View 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 };

View File

@@ -3,6 +3,7 @@ import * as userStorage from '../../services/storage/userStorage.js';
import * as hasher from '../../services/security/hash.js';
import { config } from '../../utils.js';
import { trackDemoAccessed } from '../../services/tracking/Tracker.js';
import logger from '../../services/logger.js';
const service = restana();
const loginRouter = service.newRouter();
loginRouter.get('/user', async (req, res) => {
@@ -27,7 +28,7 @@ loginRouter.post('/', async (req, res) => {
}
if (user.password === hasher.hash(password)) {
if (config.demoMode) {
trackDemoAccessed();
await trackDemoAccessed();
}
req.session.currentUser = user.id;
@@ -35,7 +36,7 @@ loginRouter.post('/', async (req, res) => {
res.send(200);
return;
} else {
console.error(`User ${username} tried to login, but password was wrong.`);
logger.error(`User ${username} tried to login, but password was wrong.`);
}
res.send(401);
});

View File

@@ -11,10 +11,12 @@ function checkIfUserToBeRemovedIsLoggedIn(userIdToBeRemoved, req) {
return req.session.currentUser === userIdToBeRemoved;
}
const nullOrEmpty = (str) => str == null || str.length === 0;
userRouter.get('/', async (req, res) => {
res.body = userStorage.getUsers(false);
res.send();
});
userRouter.get('/:userId', async (req, res) => {
const { userId } = req.params;
res.body = userStorage.getUser(userId);

View 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 };

View File

@@ -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 };

View File

@@ -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',
};

View File

@@ -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' },

View File

@@ -1,3 +1,8 @@
### Apprise Adapter
Refer to the [instructions](https://github.com/caronc/apprise-api#installation) on how to set up an Apprise instance and how to configure your preferred notification service.
Use [Apprise](https://github.com/caronc/apprise-api#installation) to forward notifications to many different services.
Quick start:
- Set up an Apprise API instance (see the installation guide linked above).
- Configure your preferred notification service(s) within Apprise.
- In Fredy, point the Apprise adapter to your Apprise API endpoint.

View File

@@ -1,4 +1,3 @@
### Console Adapter
The console adapter prints everything found by Fredy into the console (not sending any notifications to you). This can be useful when you want to check if your search
criteria meet the expectations.
The console adapter prints everything found by Fredy to the console (it does not send notifications). This is useful to verify that your search criteria work as expected before enabling a real notification service.

View 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.',
},
},
};

View File

@@ -0,0 +1,8 @@
### Discord Webhook Adapter
Use a Discord channel webhook to receive notifications.
Quick start:
- Create a webhook in your target Discord channel. See the "Intro to Webhooks" guide on the Discord support site: https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks
- Copy the generated webhook URL.
- In Fredy, configure the Discord adapter with this webhook URL.

View File

@@ -5,6 +5,7 @@ import Handlebars from 'handlebars';
import fetch from 'node-fetch';
import { markdown2Html } from '../../services/markdown.js';
import { getDirName, normalizeImageUrl } from '../../utils.js';
import logger from '../../services/logger.js';
const __dirname = getDirName();
const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTemplate/template.hbs'), 'utf8');
@@ -24,7 +25,7 @@ const toBase64 = async (url) => {
const ab = await res.arrayBuffer();
return Buffer.from(ab).toString('base64');
} catch (error) {
console.error(`Error fetching image from ${url}:`, error.message);
logger.error(`Error fetching image from ${url}:`, error.message);
throw error;
}
};
@@ -62,7 +63,7 @@ const mapListingsWithCid = async (serviceName, jobKey, listings) => {
item.hasImage = true;
item.imageCid = cid;
} catch (error) {
console.warn(`Skipping image for listing ${i} due to error: ${error.message}`);
logger.warn(`Skipping image for listing ${i} due to error: ${error.message}`);
}
}

View File

@@ -1,8 +1,8 @@
### MailJet Adapter
### Mailjet Adapter
To use [MailJet](https://mailjet.com), you need to create an account. You'll need to decide from which email address you want Fredy to send from.
To use [Mailjet](https://mailjet.com), create an account and decide which email address Fredy should send from.
E.g. if you use yourGmailAccount@gmail.com, you have to add this to MailJet and verify it as well.
The given public/private api keys are needed in order to use MailJet with Fredy. Fredy will use the same template, it is using for SendGrid.
For example, if you use yourGmailAccount@gmail.com, add and verify this address in Mailjet.
Provide your public/private API keys in Fredy's configuration. Fredy uses the same email template as for SendGrid.
If this email should be sent to multiple receiver, use a comma separator (some@email.com, someOther@email.com).
To send to multiple recipients, separate email addresses with commas (e.g., some@email.com, someOther@email.com).

View File

@@ -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 = {

View File

@@ -1,5 +1,8 @@
### Mattermost Adapter
For Mattermost, you need to create a incoming webhook. This is pretty easy. Please visit the steps in the [developer docs](https://docs.mattermost.com/developer/webhooks-incoming.html) and follow the instructions.
Receive notifications in Mattermost via an incoming webhook.
As a result, you get the webhook URL for configuration in fredy. In addition, the target channel must be defined.
Quick start:
- Create an incoming webhook following the Mattermost developer docs: https://docs.mattermost.com/developer/webhooks-incoming.html
- Copy the webhook URL.
- In Fredy, configure the Mattermost adapter with this URL and the target channel.

View File

@@ -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') {
@@ -30,7 +36,17 @@ Link: ${newListing.link}`;
method: 'POST',
headers,
body: message,
});
})
.then((res) => {
if (!res.ok) {
throw new Error(`Ntfy message could not be sent. Status code: ${res.status}`);
}
return res.text();
})
.catch((error) => {
// Ensure we reject with an Error object and prevent unhandled rejections
throw error instanceof Error ? error : new Error(String(error));
});
});
return Promise.all(promises);

View File

@@ -1,5 +1,8 @@
### ntfy Adapter
For ntfy, you need to create a topic on your preferred ntfy instance. This is pretty easy. Please visit the steps in the [docs](https://docs.ntfy.sh/publish/) and follow the instructions.
Send push notifications using an ntfy topic.
As a result, you get the URL for configuration in fredy. In addition, the priority must be defined.
Quick start:
- Create or choose a topic on your preferred ntfy instance (see docs: https://docs.ntfy.sh/publish/).
- Copy the publish URL for that topic.
- In Fredy, configure the ntfy adapter with the topic URL and set a priority.

View File

@@ -1,5 +1,8 @@
### Pushover Adapter
Refer to the [instructions](https://support.pushover.net/i7-what-is-pushover-and-how-do-i-use-it) to set up your Pushover application.
Use Pushover to receive push notifications on your devices.
After setting up the application, please enter both your newly created User key and API token.
Setup:
- Follow Pushover's getting-started guide: https://support.pushover.net/i7-what-is-pushover-and-how-do-i-use-it
- Create an application and obtain your User Key and API Token.
- In Fredy, configure the Pushover adapter with both values.

View File

@@ -1,9 +1,12 @@
### SendGrid Adapter
SendGrid is a free email service (free as in "you cannot send more than 100(Sendgrid) and 200(Mailjet) emails a day"), which is more than enough for Fredy.
SendGrid is an email delivery service with a generous free tier, which is more than enough for Fredy.
To use [SendGrid](https://sendgrid.com/), you need to create an account. You'll need to decided from which email address you want Fredy to send from. E.g. if you use yourGmailAccount@gmail.com, you have to add this to sendgrid and verify it as well.
Setup:
- Create a SendGrid account: https://sendgrid.com/
- Decide which email address Fredy should send from (e.g., yourGmailAccount@gmail.com), add it to SendGrid, and complete the verification.
- Create an API key and add it to Fredy's configuration.
- Create a Dynamic Template in SendGrid. You can copy the template from `/lib/notification/emailTemplate/template.hbs`.
Lastly you have to create an api-key and feed it into Fredy's config, as well as creating a new dynamic template. For this new template, I recommend copying and pasting the code from the one I have provided under `/lib/notification/emailTemplate/template.hbs`.
If this email should be sent to multiple receiver use a comma separator (some@email.com, someOther@email.com).
Sending to multiple recipients:
- Separate email addresses with commas (e.g., some@email.com, someOther@email.com).

View File

@@ -1,4 +1,5 @@
### Slack Adapter
IMPORTANT:
Don't use this adapter anymore, it is outdated and only here for backwards compatability reasons. Use the new Slack Adapter with webhooks!
### Slack Adapter (Legacy)
*IMPORTANT:*
This legacy adapter is outdated and kept only for backward compatibility. Please use the Slack adapter with webhooks instead.

View File

@@ -1,6 +1,10 @@
### Slack Adapter
### Slack Adapter (Webhooks)
IMPORTANT:
This is the new version of the Slack adapter. I strongly encourage you to use it, the old version is now unmaintained and only kept due to backwards compatability reasons.
*IMPORTANT:*
This is the recommended Slack adapter. The old Slack adapter is unmaintained and kept only for backward compatibility.
In order to use [Slack](https://slack.com), you need to create an account. When done, create a new channel and add the Webhook integration to that channel. Copy the webhook url. That's it.
Setup:
- Create a Slack account and workspace if you don't have one: https://slack.com
- Create a channel where you want to receive notifications.
- Add the Incoming Webhooks integration to that channel and copy the Webhook URL.
- In Fredy, configure the Slack Webhook adapter with this URL.

View File

@@ -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'),
};

View File

@@ -1,9 +1,21 @@
### 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. The file can be used for analysis later.
Fields are:
The table contains the following columns (all stored as `TEXT`):
```
['serviceName', 'jobKey', 'id', 'size', 'rooms', 'price', 'address', 'title', 'link', 'description']
```json
[
"serviceName",
"jobKey",
"id",
"size",
"rooms",
"price",
"address",
"title",
"link",
"description",
"image"
]
```

View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/**
* 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,42 +100,98 @@ 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, messageThreadId } = adapterCfg.fields;
if (!token || !chatId) {
throw new Error("Telegram 'token' and 'chatId' must be provided in notification config");
}
// Optional Telegram topic/thread support (supergroups)
let message_thread_id;
if (messageThreadId !== undefined && messageThreadId !== null && `${messageThreadId}`.trim() !== '') {
const n = Number(messageThreadId);
if (Number.isInteger(n) && n > 0) {
message_thread_id = n;
} else {
logger.warn(
`Telegram adapter: 'messageThreadId' is invalid ('${messageThreadId}'). It must be a positive integer. Ignoring.`,
);
}
}
const job = getJob(jobKey);
const jobName = job == null ? jobKey : job.name;
const throttledCall = getThrottled(chatId, async function (endpoint, body) {
await fetch(`https://api.telegram.org/bot${token}/${endpoint}`, {
const res = await fetch(`https://api.telegram.org/bot${token}/${endpoint}`, {
method: 'post',
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);
if (img) {
return throttledCall('sendPhoto', {
chat_id: chatId,
photo: img,
caption: buildCaption(jobName, serviceName, o),
parse_mode: 'HTML',
});
}
return throttledCall('sendMessage', {
const textPayload = {
chat_id: chatId,
text: buildText(jobName, serviceName, o),
parse_mode: 'HTML',
disable_web_page_preview: true,
...(message_thread_id ? { message_thread_id } : {}),
};
if (!img) {
return await throttledCall('sendMessage', textPayload).catch(async (e) => {
logger.error(`Error sending message to Telegram: ${e.message}`);
});
}
return await throttledCall('sendPhoto', {
chat_id: chatId,
photo: img,
caption: buildCaption(jobName, serviceName, o),
parse_mode: 'HTML',
...(message_thread_id ? { message_thread_id } : {}),
}).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;
});
});
});
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},messageThreadId?:{type:string,label:string,description:string}}}}
*/
export const config = {
id: 'telegram',
name: 'Telegram',
@@ -109,5 +208,12 @@ export const config = {
label: 'Chat Id',
description: 'The chat id to send messages to you.',
},
messageThreadId: {
type: 'text',
optional: true,
label: 'Message Thread Id (optional)',
description:
'Optional: The topic/thread id within a supergroup to post into (Telegram message_thread_id). Provide a positive integer.',
},
},
};

View File

@@ -1,12 +1,55 @@
### Telegram Adapter
For Telegram, you need to create a Bot. This is pretty easy. Open [this](https://telegram.me/BotFather) url on your smartphone and follow the instructions.
Use this adapter to send notifications to Telegram via a bot. You will need:
- A Telegram Bot token (from BotFather)
- A chat ID (where messages will be sent)
- Optionally: a thread ID if you want to post into a specific forum topic in a group
A telegram bot is not allowed to send messages directly to a user, you as a user need to first contact the bot to get a chatId.
After the user has send a message to your bot the first time, you can gather the chatId like this:
#### Create a bot
Create a bot with BotFather: open https://telegram.me/BotFather on your phone or in Telegram Desktop and follow the instructions to get your bot token.
#### Getting the chat ID
A Telegram bot cannot message a user first; you must create a conversation (or add the bot to a group/channel) so Telegram assigns a chat the bot can access.
Steps:
1. Start a chat with your bot in Telegram (or add the bot to your group/supergroup/channel) and send any message.
2. Fetch recent updates from the Bot API:
```
curl -X GET "https://api.telegram.org/bot{YOUR_TELEGRAM_TOKEN}/getUpdates"
```
3. In the JSON response, find the message that you just sent and read `message.chat.id`. That value is your `chatId`.
- Private chats: `chat.id` is a positive number
- Groups/supergroups: `chat.id` is a negative number
Keep your bot token secret. If `getUpdates` returns an empty list, send a new message and try again, or make sure your bots privacy settings allow it to see group messages when used in groups.
#### Getting the thread ID (this is optional to be used for forum topics)
If you want messages to appear inside a specific forum topic of a supergroup with Topics enabled, you also need a thread ID. In the Telegram Bot API this is called `message_thread_id`.
When you need it:
- Required only for supergroups with Topics enabled when targeting a topic
- Not used for private chats, basic groups without Topics, or channels
Steps to obtain it:
1. In your supergroup, enable Topics (Group settings → Manage group → Topics → Enable). Now add a new topic.
2. Add your created bot to the topic. (Click on the bot and on "Add to group")
3. Open the desired topic (or create a new one) and send any message inside that topic.
4. Call `getUpdates` again:
```
curl -X GET "https://api.telegram.org/bot{YOUR_TELEGRAM_TOKEN}/getUpdates"
```
4. In the update for the message you sent inside the topic, read `message.message_thread_id`. That number is your `threadId` for this topic.
Example (truncated):
```
curl -X GET https://api.telegram.org/bot{YOUR_TELEGRAM_TOKEN}/getUpdates
{
"message": {
"chat": { "id": -1001234567890, "type": "supergroup" },
"message_thread_id": 42,
"text": "hello from the topic"
}
}
```
Use `chat.id` as `chatId` and `message_thread_id` as `threadId` in your configuration.
A more detailed list of instructions can be found here [https://core.telegram.org/bots#botfather](https://core.telegram.org/bots#botfather)
More details about bots and BotFather: https://core.telegram.org/bots#botfather

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,15 +35,19 @@
*
*/
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 = [];
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({
@@ -52,7 +56,7 @@ async function getListings(url) {
}),
});
if (!response.ok) {
console.error('Error fetching data from ImmoScout Mobile API:', response.statusText);
logger.error('Error fetching data from ImmoScout Mobile API:', response.statusText);
return [];
}
@@ -68,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,
@@ -75,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;
}
@@ -85,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,
@@ -102,6 +126,7 @@ const config = {
normalize: normalize,
filter: applyBlacklist,
getListings: getListings,
activeTester: isListingActive,
};
export const init = (sourceConfig, blacklist) => {
config.enabled = sourceConfig.enabled;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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
View 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 };

View File

@@ -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;

View 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
View 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 };

View File

@@ -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;

View 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);
}
}

View 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);
}

View File

@@ -0,0 +1,17 @@
import cron from 'node-cron';
import { config, inDevMode } from '../../utils.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
if (config.analyticsEnabled && !inDevMode()) {
await trackMainEvent();
}
}
export async function initTrackerCron() {
//run directly on start
await runTask();
// then every 6 hours
cron.schedule('0 */6 * * *', runTask);
}

View File

@@ -1,37 +0,0 @@
import { setInterval } from 'node:timers';
import { removeJobsByUserName } from './storage/jobStorage.js';
import { config } from '../utils.js';
import { getUsers } from './storage/userStorage.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) {
console.error('Demo user not found, cannot remove Jobs');
return;
}
removeJobsByUserName(demoUser.id);
}
}

View File

@@ -0,0 +1,2 @@
import { EventEmitter } from 'node:events';
export const bus = new EventEmitter();

View File

@@ -1,6 +1,7 @@
import { setDebug } from './utils.js';
import puppeteerExtractor from './puppeteerExtractor.js';
import { loadParser, parse } from './parser/parser.js';
import logger from '../logger.js';
const DEFAULT_OPTIONS = {
debug: false,
@@ -32,7 +33,7 @@ export default class Extractor {
loadParser(this.responseText);
}
} catch (error) {
console.error('Error trying to load page.', error);
logger.error('Error trying to load page.', error);
}
return this;
};

View File

@@ -1,4 +1,5 @@
import * as cheerio from 'cheerio';
import logger from '../../logger.js';
let $ = null;
@@ -8,19 +9,19 @@ export function loadParser(text) {
export function parse(crawlContainer, crawlFields, text, url) {
if (!text) {
console.warn('No content found for ', url);
logger.debug('No content found for ', url);
return null;
}
if (!crawlContainer || !crawlFields) {
console.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) {
console.warn('No elements in crawl container found for url ', url);
logger.debug('No elements in crawl container found for url ', url);
return null;
}
@@ -58,7 +59,7 @@ export function parse(crawlContainer, crawlFields, text, url) {
parsedObject[key] = value || null;
} catch (error) {
console.error(`Error parsing field '${key}' with selector '${fieldSelector}':`, error);
logger.error(`Error parsing field '${key}' with selector '${fieldSelector}':`, error);
parsedObject[key] = null;
}
}
@@ -66,7 +67,7 @@ export function parse(crawlContainer, crawlFields, text, url) {
if (parsedObject.id != null) {
result.push(parsedObject);
} else {
console.warn('ID not found. Not relaying object.');
logger.debug('ID not found. Not relaying object.');
}
});
@@ -89,7 +90,7 @@ function applyModifiers(value, modifiers) {
value = value.replace(/\n/g, ' ');
break;
default:
console.warn(`Unknown modifier: ${modifier}`);
logger.warn(`Unknown modifier: ${modifier}`);
}
});

View File

@@ -1,30 +1,57 @@
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();
@@ -33,17 +60,36 @@ export default async function execute(url, waitForSelector, options) {
const statusCode = response.status();
if (botDetected(pageSource, statusCode)) {
console.warn('We have been detected as a bot :-/ Tried url: => ', url);
return null;
logger.warn('We have been detected as a bot :-/ Tried url: => ', url);
result = null;
} else {
result = pageSource || (await page.content());
}
return await page.content();
} catch (error) {
console.error('Error executing with puppeteer executor', error);
return null;
logger.warn('Error executing with puppeteer executor', error);
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;
}

View File

@@ -1,3 +1,5 @@
import logger from '../logger.js';
let debuggingOn = false;
export const DEFAULT_HEADER = {
@@ -15,9 +17,7 @@ export const setDebug = (options) => {
export const debug = (message) => {
if (debuggingOn) {
/* eslint-disable no-console */
console.debug(message);
/* eslint-enable no-console */
logger.debug(message);
}
};

View File

@@ -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/',
);
}

View 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.');
}
}

View 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);
}

59
lib/services/logger.js Normal file
View File

@@ -0,0 +1,59 @@
const COLORS = {
debug: '\x1b[36m',
info: '\x1b[32m',
warn: '\x1b[33m',
error: '\x1b[31m',
reset: '\x1b[0m',
};
const env = process.env.NODE_ENV || 'development';
const useColor = process.stdout.isTTY || process.stderr.isTTY;
function ts() {
const d = new Date();
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${ss}`;
}
function lvl(level) {
const upper = level.toUpperCase();
if (!useColor) return upper;
return `${COLORS[level] || ''}${upper}${COLORS.reset}`;
}
/* 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':
console.debug(prefix, ...args);
break;
case 'info':
console.info(prefix, ...args);
break;
case 'warn':
console.warn(prefix, ...args);
break;
case 'error':
console.error(prefix, ...args);
break;
default:
console.log(prefix, ...args);
}
}
export default {
debug: (...a) => log('debug', ...a),
info: (...a) => log('info', ...a),
warn: (...a) => log('warn', ...a),
error: (...a) => log('error', ...a),
};

View File

@@ -1,6 +1,4 @@
import markdown$0 from 'markdown';
import fs from 'fs';
const markdown = markdown$0.markdown;
export function markdown2Html(filePath) {
return markdown.toHTML(fs.readFileSync(filePath, 'utf8'));
return fs.readFileSync(filePath, 'utf8');
}

View File

@@ -1,26 +0,0 @@
import stringSimilarity from 'string-similarity';
//if the score is higher than this, it will be considered a match
const MAX_DICE_INDEX = 0.7;
export default (class SimilarityCacheEntry {
constructor(time) {
this.time = time;
this.values = [];
}
setCacheEntry = (entry) => {
this.values.push(entry);
};
getTime = () => {
return this.time;
};
hasSimilarEntries = (value) => {
if (this.values.length > 0) {
for (let i = 0; i < this.values.length; i++) {
const index = stringSimilarity.compareTwoStrings(value, this.values[i]);
if (index >= MAX_DICE_INDEX) {
return true;
}
}
}
return false;
};
});

View File

@@ -1,40 +1,94 @@
import SimilarityCacheEntry from './SimilarityCacheEntry.js';
import { config } from '../../utils.js';
//5 minutes
let retention = 5 * 60 * 1000;
const intervalInMs = config.interval * 60 * 1000;
//an interval below 5 mins sounds crazy, but there are ppl out there doing crazy shit.
if (intervalInMs <= retention) {
retention = Math.floor(intervalInMs / 2);
}
//jobid -> SimilarityCacheEntry
const cache = {};
let intervalId;
/**
* cleanup
* Similarity cache
*
* Maintains an in-memory Set of content hashes to detect whether a listing
* (identified by a tuple of title, price and address) has been seen before.
*
* Design notes:
* - The cache is refreshed periodically from persistent storage. To avoid
* modification-during-iteration issues, the refresh builds a new Set and
* atomically swaps the reference instead of mutating in place.
* - Hashing ignores null/undefined values but preserves falsy-yet-valid values
* like 0. Non-string values are coerced to strings before hashing.
*
* This module has no persistence of its own; it relies on
* getAllEntriesFromListings() for data hydration.
* @module similarityCache
*/
intervalId = setInterval(() => {
const keysToBeRemoved = [];
const now = Date.now();
Object.keys(cache).forEach((key) => {
if (cache[key].getTime() + retention < now) {
keysToBeRemoved.push(key);
}
});
if (keysToBeRemoved.length > 0) {
keysToBeRemoved.forEach((key) => delete cache[key]);
import crypto from 'crypto';
import { getAllEntriesFromListings } from '../storage/listingsStorage.js';
/** @type {number} Refresh interval in milliseconds (defaults to one hour). */
const reloadCycle = 60 * 60 * 1000; // every hour, refresh
/**
* Internal cache of content hashes for known listings.
*
* Each entry is an SHA-256 hex digest produced by toHash(title, price, address).
* @type {Set<string>}
*/
let cache = new Set();
export const startSimilarityCacheReloader = () => {
// Periodically refresh the cache from storage
setInterval(() => {
initSimilarityCache();
}, reloadCycle);
};
/**
* Initialize or refresh the similarity cache from persistent storage.
*
* Reads all stored listings via getAllEntriesFromListings(), computes a hash for
* each, and swaps the in-memory Set atomically to avoid in-place mutations that
* could interfere with concurrent iteration.
*
* This function is idempotent and safe to call at any time.
* @returns {void}
*/
export const initSimilarityCache = () => {
const allEntries = getAllEntriesFromListings();
const newCache = new Set();
for (const entry of allEntries) {
newCache.add(toHash(entry?.title, entry?.price, entry?.address));
}
}, 10000);
export const addCacheEntry = (jobId, value) => {
cache[jobId] = cache[jobId] || new SimilarityCacheEntry(Date.now());
cache[jobId].setCacheEntry(value);
// Atomic swap to avoid mutating the cache while it may be iterated elsewhere
cache = newCache;
};
export const hasSimilarEntries = (jobId, value) => {
if (cache[jobId] == null) {
return false;
/**
* Check if a listing is already known and add it to the cache if not.
*
* The listing is identified by the combination of its title, price and
* address. Null/undefined fields are ignored during hashing. Falsy-but-valid
* values (e.g., price 0) are preserved.
*
* @param {Object} params - Listing fields
* @param {string|undefined|null} params.title - The listing title
* @param {string|undefined|null} params.address - The listing address
* @param {number|string|undefined|null} params.price - The listing price
* @returns {boolean} true if the entry already existed in the cache (duplicate), otherwise false
*/
export const checkAndAddEntry = ({ title, address, price }) => {
const hash = toHash(title, price, address);
if (cache.has(hash)) {
return true;
}
return cache[jobId].hasSimilarEntries(value);
};
export const stopCacheCleanup = () => {
clearInterval(intervalId);
cache.add(hash);
return false;
};
/**
* Generate an SHA-256 hash from a list of input values.
* Null or undefined values are ignored. Falsy but valid values like 0 are preserved.
* Non-string values are coerced to strings prior to hashing.
*
* @param {...(string|number|null|undefined)} strings - Input values to hash
* @returns {string} Hexadecimal hash
*/
function toHash(...strings) {
const normalized = strings
.filter((v) => v !== null && v !== undefined)
.map((v) => (typeof v === 'string' ? v : String(v)));
return crypto.createHash('sha256').update(normalized.join('|')).digest('hex');
}

View File

@@ -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');
}
}

View 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;

View File

@@ -1,107 +1,158 @@
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();
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;
/**
* 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,
shareWithUsers = [],
}) => {
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,
shared_with_user = @shareWithUsers
WHERE id = @id`,
{
id,
enabled: enabled ? 1 : 0,
name: name ?? null,
blacklist: toJson(blacklist ?? []),
shareWithUsers: toJson(shareWithUsers ?? []),
provider: toJson(provider ?? []),
notification_adapter: toJson(notificationAdapter ?? []),
},
);
} else {
SqliteConnection.execute(
`INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter, shared_with_user)
VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter, @shareWithUsers)`,
{
id,
user_id: ownerId,
enabled: enabled ? 1 : 0,
name: name ?? null,
blacklist: toJson(blacklist ?? []),
provider: toJson(provider ?? []),
shareWithUsers: toJson(shareWithUsers ?? []),
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) {
/* eslint-disable no-console */
console.log(`Removed ${removedDemoJobs} demo jobs`);
/* eslint-enable no-console */
// 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.shared_with_user,
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, []),
shared_with_user: fromJson(row.shared_with_user, []),
notificationAdapter: fromJson(row.notificationAdapter, []),
}));
};

View File

@@ -1,52 +1,344 @@
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) {
// Include listings from jobs owned by the user or jobs shared with the user
whereParts.push(
`(j.user_id = @userId OR EXISTS (SELECT 1 FROM json_each(j.shared_with_user) AS sw WHERE sw.value = @userId))`,
);
}
if (freeTextFilter && String(freeTextFilter).trim().length > 0) {
params.filter = `%${String(freeTextFilter).trim()}%`;
whereParts.push(`(title LIKE @filter OR address LIKE @filter OR provider LIKE @filter OR link LIKE @filter)`);
}
// activityFilter: when true -> only active listings (is_active = 1)
if (activityFilter === true) {
whereParts.push('(is_active = 1)');
}
// Prefer filtering by job id when provided (unambiguous and robust)
if (jobIdFilter && String(jobIdFilter).trim().length > 0) {
params.jobId = String(jobIdFilter).trim();
whereParts.push('(l.job_id = @jobId)');
} else if (jobNameFilter && String(jobNameFilter).trim().length > 0) {
// Fallback to exact job name match
params.jobName = String(jobNameFilter).trim();
whereParts.push('(j.name = @jobName)');
}
// providerFilter: when provided as string (assumed provider name), filter listings where provider equals that name (exact match)
if (providerFilter && String(providerFilter).trim().length > 0) {
params.providerName = String(providerFilter).trim();
whereParts.push('(provider = @providerName)');
}
// watchListFilter: when true -> only watched listings
if (watchListFilter === true) {
whereParts.push('(wl.id IS NOT NULL)');
}
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
const whereSqlWithAlias = whereSql
.replace(/\btitle\b/g, 'l.title')
.replace(/\bdescription\b/g, 'l.description')
.replace(/\baddress\b/g, 'l.address')
.replace(/\bprovider\b/g, 'l.provider')
.replace(/\blink\b/g, 'l.link')
.replace(/\bis_active\b/g, 'l.is_active')
.replace(/\bj\.user_id\b/g, 'j.user_id')
.replace(/\bj\.name\b/g, 'j.name')
.replace(/\bwl\.id\b/g, 'wl.id');
// whitelist sortable fields to avoid SQL injection
const sortable = new Set(['created_at', 'price', 'size', 'provider', 'title', 'job_name', 'is_active', 'isWatched']);
const safeSortField = sortField && sortable.has(sortField) ? sortField : null;
const safeSortDir = String(sortDir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
const orderSql = safeSortField ? `ORDER BY ${safeSortField} ${safeSortDir}` : 'ORDER BY created_at DESC';
const orderSqlWithAlias = orderSql
.replace(/\bcreated_at\b/g, 'l.created_at')
.replace(/\bprice\b/g, 'l.price')
.replace(/\bsize\b/g, 'l.size')
.replace(/\bprovider\b/g, 'l.provider')
.replace(/\btitle\b/g, 'l.title')
.replace(/\bjob_name\b/g, 'j.name')
// Sort by computed watch flag when requested
.replace(/\bisWatched\b/g, 'CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END');
// count total with same WHERE
const countRow = SqliteConnection.query(
`SELECT COUNT(1) as cnt
FROM listings l
LEFT JOIN jobs j ON j.id = l.job_id
LEFT JOIN watch_list wl ON wl.listing_id = l.id AND wl.user_id = @userId
${whereSqlWithAlias}`,
params,
);
const totalNumber = countRow?.[0]?.cnt ?? 0;
// fetch page
const rows = SqliteConnection.query(
`SELECT l.*,
j.name AS job_name,
CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END AS isWatched
FROM listings l
LEFT JOIN jobs j ON j.id = l.job_id
LEFT JOIN watch_list wl ON wl.listing_id = l.id AND wl.user_id = @userId
${whereSqlWithAlias}
${orderSqlWithAlias}
LIMIT @limit OFFSET @offset`,
params,
);
return { totalNumber, page: safePage, result: rows };
};
/**
* Delete all listings for a given job id.
*
* @param {string} jobId - The job identifier whose listings should be removed.
* @returns {any} The result from SqliteConnection.execute (may contain changes count).
*/
export const deleteListingsByJobId = (jobId) => {
if (!jobId) return;
return SqliteConnection.execute(
`DELETE
FROM listings
WHERE job_id = @jobId`,
{ jobId },
);
};
/**
* Delete listings by a list of listing IDs.
*
* @param {string[]} ids - Array of listing IDs to delete.
* @returns {any} The result from SqliteConnection.execute.
*/
export const deleteListingsById = (ids) => {
if (!Array.isArray(ids) || ids.length === 0) return;
const placeholders = ids.map(() => '?').join(',');
return SqliteConnection.execute(
`DELETE
FROM listings
WHERE id IN (${placeholders})`,
ids,
);
};
/**
* Return all listings with only the fields: title, address, and price.
* This is the single helper requested for simple consumers.
*
* @returns {{title: string|null, address: string|null, price: number|null}[]}
*/
export const getAllEntriesFromListings = () => {
return SqliteConnection.query(`SELECT title, address, price FROM listings`);
};

View 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();
}

View 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);
`);
}

View File

@@ -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}`);
}
}
}

View File

@@ -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;
`);
}

View File

@@ -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);
`);
}

View File

@@ -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;
`);
}

View 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);
`);
}

View File

@@ -0,0 +1,7 @@
// Migration: Adding a new table to store if somebody shared a job with someone
export function up(db) {
db.exec(`
ALTER TABLE jobs ADD COLUMN shared_with_user jsonb DEFAULT '[]'
`);
}

View File

@@ -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 dont 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 });
}
}
};

View 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 };
};

View File

@@ -1,65 +1,64 @@
import Mixpanel from 'mixpanel';
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';
const mixpanelTracker = Mixpanel.init('718670ef1c58c0208256c1e408a3d75e');
const distinct_id = getUniqueId() || 'N/A';
const deviceId = getUniqueId() || 'N/A';
const version = await getPackageVersion();
const FREDY_TRACKING_URL = 'https://fredy.orange-coding.net/tracking';
export const track = function () {
//only send tracking information if the user allowed to do so.
if (config.analyticsEnabled && !inDevMode()) {
const activeProvider = new Set();
const activeAdapter = new Set();
export const trackMainEvent = async () => {
try {
if (config.analyticsEnabled && !inDevMode()) {
const activeProvider = new Set();
const activeAdapter = new Set();
const jobs = getJobs();
const jobs = getJobs();
if (jobs != null && jobs.length > 0) {
jobs.forEach((job) => {
job.provider.forEach((provider) => {
activeProvider.add(provider.id);
if (jobs != null && jobs.length > 0) {
jobs.forEach((job) => {
job.provider.forEach((provider) => activeProvider.add(provider.id));
job.notificationAdapter.forEach((adapter) => activeAdapter.add(adapter.id));
});
job.notificationAdapter.forEach((adapter) => {
activeAdapter.add(adapter.id);
});
});
mixpanelTracker.track(
'fredy_tracking',
enrichTrackingObject({
const trackingObj = enrichTrackingObject({
adapter: Array.from(activeAdapter),
provider: Array.from(activeProvider),
}),
);
});
await fetch(`${FREDY_TRACKING_URL}/main`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(trackingObj),
});
}
}
} catch (error) {
logger.warn('Error sending tracking data', error);
}
};
/**
* Note, this will only be used when Fredy runs in demo mode
*/
export function trackDemoJobCreated(jobData) {
export async function trackDemoAccessed() {
if (config.analyticsEnabled && !inDevMode() && config.demoMode) {
mixpanelTracker.track('demoJobCreated', enrichTrackingObject(jobData));
}
}
/**
* Note, this will only be used when Fredy runs in demo mode
*/
export function trackDemoAccessed() {
if (config.analyticsEnabled && !inDevMode() && config.demoMode) {
mixpanelTracker.track('demoAccessed', enrichTrackingObject({}));
try {
await fetch(`${FREDY_TRACKING_URL}/demo/accessed`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
logger.warn('Error sending tracking data', error);
}
}
}
function enrichTrackingObject(trackingObject) {
const operating_system = os.platform();
const os_version = os.release();
const operatingSystem = os.platform();
const osVersion = os.release();
const arch = process.arch;
const language = process.env.LANG || 'en';
const nodeVersion = process.version || 'N/A';
@@ -67,24 +66,12 @@ function enrichTrackingObject(trackingObject) {
return {
...trackingObject,
isDemo: config.demoMode,
operating_system,
os_version,
operatingSystem,
osVersion,
arch,
nodeVersion,
language,
distinct_id,
fredy_version: version,
deviceId,
version,
};
}
async function getPackageVersion() {
try {
const packagePath = await packageUp();
const packageJson = readFileSync(packagePath, 'utf8');
const json = JSON.parse(packageJson);
return json.version;
} catch (error) {
console.error('Error reading version from package.json', error);
}
return 'N/A';
}

View File

@@ -1,23 +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, { 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(':');
@@ -27,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)) {
@@ -34,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;
@@ -52,28 +174,78 @@ 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 };
console.error('Error reading config file', error);
logger.info('Error reading config file.', error);
}
}
const RE_GT = />/g;
const RE_WEBP = /\/format\/webp/gi;
const RE_EXT = /\.(jpe?g|png|gif)(\?.*)?$/i;
const HTTPS_PREFIX = 'https://';
/**
* If the config file does not exist, create it with DEFAULT_CONFIG.
* @returns {void}
*/
const checkIfConfigExistsAndWriteIfNot = () => {
if (!fs.existsSync(`${getDirName()}/../conf/config.json`)) {
logger.info('Could not find config file. Will create one with default values now');
fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ ...DEFAULT_CONFIG }));
}
};
/**
* 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;
@@ -87,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,
};

View File

@@ -1,6 +1,6 @@
{
"name": "fredy",
"version": "11.5.1",
"version": "14.3.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,63 +56,57 @@
"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",
"mixpanel": "^0.18.1",
"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.24.0",
"puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"query-string": "9.2.2",
"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.4",
"react-router-dom": "7.9.4",
"restana": "5.1.0",
"semver": "^7.7.3",
"serve-static": "2.2.0",
"slack": "11.0.2",
"string-similarity": "^4.0.4",
"vite": "7.1.4",
"x-var": "^2.1.0"
"vite": "7.1.9",
"x-var": "^3.0.1",
"zustand": "^5.0.8"
},
"devDependencies": {
"@babel/core": "7.28.3",
"@babel/eslint-parser": "7.28.0",
"@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.34.0",
"chai": "6.2.0",
"eslint": "9.37.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",
"less": "4.4.2",
"lint-staged": "16.2.4",
"mocha": "11.7.4",
"nodemon": "^3.1.10",
"prettier": "3.6.2",
"redux-logger": "3.0.6"
"prettier": "3.6.2"
}
}

View File

@@ -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"
```

View 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);
});
});

View File

@@ -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] || [];
};

View File

@@ -5,9 +5,6 @@ import { expect } from 'chai';
import * as provider from '../../lib/provider/einsAImmobilien.js';
describe('#einsAImmobilien testsuite()', () => {
after(() => {
similarityCache.stopCacheCleanup();
});
provider.init(providerConfig.einsAImmobilien, [], []);
it('should test einsAImmobilien provider', async () => {
const Fredy = await mockFredy();
@@ -25,6 +22,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;

View File

@@ -5,9 +5,6 @@ import { expect } from 'chai';
import * as provider from '../../lib/provider/immobilienDe.js';
describe('#immobilien.de testsuite()', () => {
after(() => {
similarityCache.stopCacheCleanup();
});
provider.init(providerConfig.immobilienDe, [], []);
it('should test immobilien.de provider', async () => {
const Fredy = await mockFredy();

View File

@@ -5,34 +5,29 @@ import { expect } from 'chai';
import * as provider from '../../lib/provider/immonet.js';
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;
});
});
});

View File

@@ -5,10 +5,6 @@ import { get } from '../mocks/mockNotification.js';
import * as provider from '../../lib/provider/immoscout.js';
describe('#immoscout provider testsuite()', () => {
after(() => {
similarityCache.stopCacheCleanup();
});
provider.init(providerConfig.immoscout, [], []);
it('should test immoscout provider', async () => {
const Fredy = await mockFredy();

View File

@@ -5,9 +5,6 @@ import { expect } from 'chai';
import * as provider from '../../lib/provider/immoswp.js';
describe('#immoswp testsuite()', () => {
after(() => {
similarityCache.stopCacheCleanup();
});
provider.init(providerConfig.immoswp, [], []);
it('should test immoswp provider', async () => {
const Fredy = await mockFredy();

Some files were not shown because too many files have changed in this diff Show More