Compare commits

..

60 Commits

Author SHA1 Message Date
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
orangecoding
bed0843f30 next release version 2025-09-05 12:07:35 +02:00
orangecoding
947e895de6 upgrading puppeteer / updating config 2025-09-05 12:07:08 +02:00
Christian Kellner
da848fcca1 Update README.md 2025-09-04 09:08:25 +02:00
Christian Kellner
74c3edd635 Update README.md 2025-09-03 15:09:35 +02:00
Christian Kellner
154043bed1 Update README.md 2025-09-03 14:52:58 +02:00
Christian Kellner
1854b421af avoid warnings on test 2025-09-03 14:47:56 +02:00
Christian Kellner
b29fc4b183 avoid warnings on test 2025-09-03 14:47:43 +02:00
Christian Kellner
47afa5659e Merge branch 'master' of github.com:orangecoding/fredy 2025-09-03 14:47:39 +02:00
Christian Kellner
3d87aeb5f9 new chart system (#158)
* new chart system
2025-09-03 14:22:32 +02:00
Christian Kellner
f8e0376ddd adding new chart system 2025-09-03 14:22:04 +02:00
weakmap@gmail.com
29026ccad8 new chart system 2025-09-03 09:45:09 +02:00
weakmap@gmail.com
9774989eeb upgrade to react router 7 2025-09-02 20:18:37 +02:00
weakmap@gmail.com
9db1ffd8eb making immonet null safe 2025-08-31 20:25:52 +02:00
97 changed files with 4079 additions and 1963 deletions

View File

@@ -1,3 +0,0 @@
/ui/public
/db/
/conf/

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

3
.gitignore vendored
View File

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

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 \

293
README.md
View File

@@ -1,102 +1,185 @@
<img src="https://github.com/orangecoding/fredy/blob/master/doc/logo.png" width="400">
![Test](https://github.com/orangecoding/fredy/actions/workflows/test.yml/badge.svg) [![Create and publish Docker image](https://github.com/orangecoding/fredy/actions/workflows/docker.yml/badge.svg)](https://github.com/orangecoding/fredy/actions/workflows/docker.yml) ![Check the sourcecode](https://github.com/orangecoding/fredy/actions/workflows/check_source.yml/badge.svg)
Searching an apartment in Germany can be a frustrating task. Not any longer though, as _Fredy_ will take over and will only notify you once new listings have been found that match your requirements.
_Fredy_ scrapes multiple services (Immonet, Immowelt etc.) and send new listings to you once they become available. The list of available services can easily be extended. For your convenience, _Fredy_ has a UI to help you configure your search jobs.
If _Fredy_ finds matching results, it will send them to you via Slack, Email, Telegram etc. (More adapters can be configured.) As _Fredy_ stores the listings it has found, new results will not be sent to you twice (and as a side-effect, _Fredy_ can show some statistics). Furthermore, _Fredy_ checks duplicates per scraping so that the same listings are not being sent twice or more when posted on various platforms (which happens more often than one might think).
# Sponsorship [![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/orangecoding)
If you like my work, consider becoming a sponsor. I'm not expecting anybody to pay for _Fredy_ or any other Open Source Project I'm maintaining, however keep in mind, I'm doing all of this in my spare time :) Thanks.
[![JetBrains logo.](https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.svg)](https://jb.gg/OpenSourceSupport)
_Fredy_ is supported by JetBrains under Open Source Support Program
## Demo
If you want to try out _Fredy_, you can access the demo version [here](https://fredy.orange-coding.net) 🤘
## Usage
- Make sure to use Node.js 20 or above
- Run the following commands:
```ssh
yarn
yarn run start:backend
yarn run start:frontend
```
_Fredy_ will start with the default port, set to `9998`. You can access _Fredy_ by opening your browser at `http://localhost:9998`. The default login is `admin`, both for username and password. You should change the password as soon as possible when you plan to run Fredy on a server.
<p align="center">
<img alt="Job Configuration" src="https://github.com/orangecoding/fredy/blob/master/doc/screenshot1.png" width="30%">
&nbsp; &nbsp; &nbsp; &nbsp;
<img alt="Job Analytics" src="https://github.com/orangecoding/fredy/blob/master/doc/screenshot_2.png" width="30%">
&nbsp; &nbsp; &nbsp; &nbsp;
<img alt="Job Overview" width="30%" src="https://github.com/orangecoding/fredy/blob/master/doc/screenshot_3.png">
<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>
## Understanding the fundamentals
![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)
![Docker Pulls](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)
There are 3 important parts in Fredy, that you need to understand to leverage the full power of _Fredy_.
#### Provider
# Fredy 🏡 Your Self-Hosted Real Estate Finder for Germany
_Fredy_ supports multiple services. Immonet, Immowelt and Ebay are just a few examples. Those services are called providers within _Fredy_. When creating a new job, you can choose one or more providers.
A provider contains the URL that points to the search results for the respective service. If you go to immonet.de and search for something, the displayed URL in the browser is what the provider needs to do its magic.
**It is important that you order the search results by date, so that _Fredy_ always picks the latest results first!**
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
listings appear.
#### Adapter
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.
_Fredy_ supports multiple adapters, such as Slack, SendGrid, Telegram etc. A search job can have as many adapters as supported by _Fredy_. Each adapter needs different configuration values, which you have to provide when using them. An adapter dictates how the frontend renders by telling the frontend what information it needs in order to send listings to the user.
#### Jobs
A Job wraps adapters and providers. _Fredy_ runs the configured jobs in a specific interval (can be configured in `/conf/config.json`).
------------------------------------------------------------------------
## Creating your first job
## ✨ Key Features
To create your first job, click on the button "Create New Job" on the job table. The job creation dialog should be self-explanatory, however there is one important thing.
When configuring providers, before copying the URL from your browser, make sure that you have sorted the results by date to make sure _Fredy_ always picks the latest results first.
- 🏠 Scrapes **ImmoScout24, Immowelt, Immonet, eBay Kleinanzeigen,
WG-Gesucht**
- ⚡ Instant notifications: Slack, Telegram, Email (SendGrid,
Mailjet), ntfy
- 🔎 Uses the **ImmoScout Mobile API** (reverse engineered)
- 🌍 Runs anywhere: Docker, Node.js, self-hosted
- 🖥️ Intuitive **Web UI** to manage searches
- 🎯 Easy to use thanks to a user-friendly Web UI
- 🔄 Deduplication across platforms
- ⏱️ Customizable search intervals
## User management
------------------------------------------------------------------------
As an administrator, you can create, edit and remove users from _Fredy_. Be careful, each job is connected to the user that has created the job. If you remove the user, their jobs will also be removed.
## 🤝 Sponsorship [![](https://img.shields.io/static/v1?label=Sponsor&message=❤&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/orangecoding)
# Development
I maintain Fredy and other open-source projects in my free time.\
If you find it useful, consider supporting the project 💙
### Running Fredy in development mode
Fredy is proudly backed by the **JetBrains Open Source Support Program**.
Start the backend with:
<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>
```shell
yarn run start:backend:dev
------------------------------------------------------------------------
## 👨‍🏫 Demo
You can try out Fredy here: [Fredy Demo](https://fredy-demo.orange-coding.net/)
------------------------------------------------------------------------
## 🚀 Quick Start
### 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 run -d --name fredy \
-v fredy_conf:/conf \
-v fredy_db:/db \
-p 9998:9998 \
ghcr.io/orangecoding/fredy:master
```
For the frontend, run:
Logs:
```shell
``` bash
docker logs fredy -f
```
### Manual (Node.js)
- Requirement: **Node.js 22 or higher**
- Install dependencies and start:
``` bash
yarn
yarn run start:backend # in one terminal
yarn run start:frontend # in another terminal
```
👉 Open <http://localhost:9998>
**Default Login:**
- Username: `admin`
- Password: `admin`
------------------------------------------------------------------------
## 📸 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) |
------------------------------------------------------------------------
## 🧩 Core Concepts
Fredy is built around three simple concepts:
### Provider 🌐
A **provider** is a real-estate platform (e.g. ImmoScout24, Immowelt,
Immonet, eBay Kleinanzeigen, WG-Gesucht).\
When you create a job, you paste the search URL from the platform into
Fredy.\
⚠️ Always make sure the search results are sorted by **date**, so Fredy
picks up the newest listings first.
### Adapter 📡
An **adapter** is the channel through which Fredy notifies you (Slack,
Telegram, Email, ntfy, ...).\
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.
### Job 📅
A **job** combines providers and adapters.\
Example: "Search apartments on ImmoScout24 + Immowelt and send results
to Slack + Telegram."\
Jobs run automatically at the interval you configure (see
`/conf/config.json`).
------------------------------------------------------------------------
## Immoscout
Immoscout has implemented advanced bot detection. In order to work around this, we are using a reversed engineered version of their mobile api. See [Immoscout Reverse Engineering Documentation](https://github.com/orangecoding/fredy/blob/master/reverse-engineered-immoscout.md)
## Analytics
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 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**🤘
## 🛠️ Development
### Development Mode
``` bash
yarn run start:backend:dev
yarn run start:frontend:dev
```
You should now be able to access _Fredy_ from your browser. Check your Terminal to see what port the frontend is running on.
### Running Tests
### Run Tests
To run the tests, run
```shell
``` bash
yarn run test
```
# Architecture
------------------------------------------------------------------------
```mermaid
## 📐 Architecture
``` mermaid
flowchart TD
subgraph Jobs["Jobs"]
A1["Job 1"]
@@ -109,80 +192,36 @@ flowchart TD
C3["Provider 3"]
end
subgraph NotificationAdapters["Notification Adapters"]
F1["Notification Adapter 1"]
F2["Notification Adapter 2"]
F1["Adapter 1"]
F2["Adapter 2"]
end
A1 --> B["FredyRuntime"]
A2 --> B
A3 --> B
B --> C1 & C2 & C3
C1 --> D["Similarity-Check"]
C1 --> D["Similarity Check"]
C2 --> D
C3 --> D
D --> E{"Found<br>similarity?"}
D --> E{"Duplicate?"}
E -- No --> F1
F1 --> F2
style A1 fill:#fde9a0,stroke:#333333,color:#333333
style A2 fill:#fde9a0,stroke:#333333,color:#333333
style A3 fill:#fde9a0,stroke:#333333,color:#333333
style C1 fill:#c4c9f1,stroke:#333333,color:#333333
style C2 fill:#c4c9f1,stroke:#333333,color:#333333
style C3 fill:#c4c9f1,stroke:#333333,color:#333333
style F1 fill:#d2edba,stroke:#333333,color:#333333
style F2 fill:#d2edba,stroke:#333333,color:#333333
style B fill:#abd8f9,stroke:#333333,color:#333333
style D fill:#fab4a8,stroke:#333333,color:#333333
style E fill:#fffbb4,stroke:#333333,color:#333333
```
### Immoscout
------------------------------------------------------------------------
Immoscout has implemented advanced bot detection. In order to work around this, we are using a reversed engineered version of their mobile api. See [Immoscout Reverse Engineering Documentation](https://github.com/orangecoding/fredy/blob/master/reverse-engineered-immoscout.md)
## 👐 Contributing
# Analytics
Thanks to everyone who has contributed!
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.
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**🤘
<a href="https://github.com/orangecoding/fredy/graphs/contributors"><img src="https://contrib.rocks/image?repo=orangecoding/fredy" /></a>
# Docker
See the [Contributing
Guide](https://github.com/orangecoding/fredy/blob/master/CONTRIBUTING.md).
Use the Dockerfile in this repository to build an image.
------------------------------------------------------------------------
Example: `docker build -t fredy/fredy /path/to/your/Dockerfile`
## ⭐ Star History
Or use docker-compose:
Example `docker-compose build`
Or use the container that will be built automatically.
`docker pull ghcr.io/orangecoding/fredy:master`
## Create & run a container
Put your config.json into a path of your choice, such as `/path/to/your/conf/`.
Example: `docker create --name fredy -v /path/to/your/conf/:/conf -p 9998:9998 fredy/fredy`
## Logs
You can browse the logs with `docker logs fredy -f`.
### 👐 Contributing
Thanks to all the people who already contributed!
<a href="https://github.com/orangecoding/fredy/graphs/contributors">
<img src="https://contrib.rocks/image?repo=orangecoding/fredy" />
</a>
See [Contributing](https://github.com/orangecoding/fredy/blob/master/CONTRIBUTING.md)
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=orangecoding/fredy&type=Date)](https://www.star-history.com/#orangecoding/fredy&Date)
[![Star History
Chart](https://api.star-history.com/svg?repos=orangecoding/fredy&type=Date)](https://www.star-history.com/#orangecoding/fredy&Date)

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

@@ -1,7 +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

View File

@@ -11,5 +11,12 @@ services:
- ./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

View File

@@ -6,6 +6,10 @@ import react from 'eslint-plugin-react';
import babelParser from '@babel/eslint-parser';
export default [
{
files: ['**/*.{js,jsx,ts,tsx}'],
ignores: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/public/**', 'db/**', 'conf/**'],
},
js.configs.recommended,
prettier,
{
@@ -23,70 +27,34 @@ export default [
after: 'readonly',
it: 'readonly',
},
parserOptions: {
requireConfigFile: false,
},
},
plugins: {
react,
parserOptions: { requireConfigFile: false },
},
plugins: { react },
rules: {
eqeqeq: [2, 'allow-null'],
// Semantics / Performance impacting
strict: 0,
'no-redeclare': [2, { builtinGlobals: false }],
'class-methods-use-this': 'off',
// Style
indent: ['off', 2],
'linebreak-style': ['error', 'unix'],
quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: true }],
semi: ['error', 'always'],
'no-console': ['error', { allow: ['warn', 'error'] }],
// React
'jsx-quotes': ['error', 'prefer-double'],
'react/display-name': 'off',
'react/forbid-prop-types': 'off',
'react/jsx-closing-bracket-location': 'off',
'react/jsx-curly-spacing': 'off',
'react/jsx-handler-names': [
'off',
{
eventHandlerPrefix: 'handle',
eventHandlerPropPrefix: 'on',
},
],
'react/jsx-handler-names': ['off', { eventHandlerPrefix: 'handle', eventHandlerPropPrefix: 'on' }],
'react/jsx-indent-props': 'off',
'react/jsx-key': 'off',
'react/jsx-max-props-per-line': 'off',
'react/jsx-no-bind': [
'error',
{
ignoreRefs: true,
allowArrowFunctions: true,
allowBind: false,
},
],
'react/jsx-no-bind': ['error', { ignoreRefs: true, allowArrowFunctions: true, allowBind: false }],
'react/jsx-no-duplicate-props': ['error', { ignoreCase: true }],
'react/jsx-no-literals': 'off',
'react/jsx-no-undef': 'error',
'react/jsx-pascal-case': [
'error',
{
allowAllCaps: true,
ignore: [],
},
],
'react/sort-prop-types': [
'off',
{
ignoreCase: true,
callbacksLast: false,
requiredFirst: false,
},
],
'react/jsx-pascal-case': ['error', { allowAllCaps: true, ignore: [] }],
'react/sort-prop-types': ['off', { ignoreCase: true, callbacksLast: false, requiredFirst: false }],
'react/jsx-sort-prop-types': 'off',
'react/jsx-sort-props': 'off',
'react/jsx-uses-react': 'error',
@@ -106,14 +74,7 @@ export default [
'react/require-render-return': 'error',
'react/self-closing-comp': 'warn',
'react/sort-comp': 'off',
'react/jsx-wrap-multilines': [
'warn',
{
declaration: true,
assignment: true,
return: true,
},
],
'react/jsx-wrap-multilines': ['warn', { declaration: true, assignment: true, return: true }],
'react/wrap-multilines': 'off',
'react/jsx-first-prop-new-line': 'off',
'react/jsx-equals-spacing': ['warn', 'never'],
@@ -126,20 +87,10 @@ export default [
'react/no-find-dom-node': 'warn',
'react/forbid-component-props': ['off', { forbid: [] }],
'react/no-danger-with-children': 'error',
'react/no-unused-prop-types': [
'warn',
{
customValidators: [],
skipShapeProps: true,
},
],
'react/no-unused-prop-types': ['warn', { customValidators: [], skipShapeProps: true }],
'react/style-prop-object': 'error',
'react/no-children-prop': 'warn',
},
settings: {
react: {
version: 'detect',
},
},
settings: { react: { version: 'detect' } },
},
];

122
index.js
View File

@@ -1,62 +1,88 @@
import fs from 'fs';
import { config } from './lib/utils.js';
import path from 'path';
import { config, getProviders, refreshConfig } from './lib/utils.js';
import * as similarityCache from './lib/services/similarity-check/similarityCache.js';
import { setLastJobExecution } from './lib/services/storage/listingsStorage.js';
import * as jobStorage from './lib/services/storage/jobStorage.js';
import FredyRuntime from './lib/FredyRuntime.js';
import { duringWorkingHoursOrNotSet } from './lib/utils.js';
import './lib/api/api.js';
import { 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();
// 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 });
}
const path = './lib/provider';
const provider = fs.readdirSync(path).filter((file) => file.endsWith('.js'));
// Run DB migrations once at startup and block until finished
await runMigrations();
// Load provider modules once at startup
const providers = await getProviders();
//assuming interval is always in minutes
const INTERVAL = config.interval * 60 * 1000;
/* 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 FredyRuntime(
matchedProvider.config,
job.notificationAdapter,
prov.id,
job.id,
similarityCache,
).execute();
});
});
} else {
logger.debug('Working hours set. Skipping as outside of working hours.');
}
return exec;
})(),
INTERVAL,
);
}
};
setInterval(execute, INTERVAL);
//start once at startup
execute();

View File

@@ -1,8 +1,9 @@
import { NoNewListingsWarning } from './errors.js';
import { setKnownListings, getKnownListings } from './services/storage/listingsStorage.js';
import { storeListings, getKnownListingHashesForJobAndProvider } from './services/storage/listingsStorage.js';
import * as notify from './notification/notify.js';
import Extractor from './services/extractor/extractor.js';
import urlModifier from './services/queryStringMutator.js';
import logger from './services/logger.js';
class FredyRuntime {
/**
@@ -59,7 +60,7 @@ class FredyRuntime {
})
.catch((err) => {
reject(err);
console.error(err);
logger.error(err);
});
});
}
@@ -76,7 +77,9 @@ class FredyRuntime {
}
_findNew(listings) {
const newListings = listings.filter((o) => getKnownListings(this._jobKey, this._providerId)[o.id] == null);
const hashes = getKnownListingHashesForJobAndProvider(this._jobKey, this._providerId) || [];
const newListings = listings.filter((o) => !hashes.includes(o.id));
if (newListings.length === 0) {
throw new NoNewListingsWarning();
}
@@ -92,30 +95,24 @@ class FredyRuntime {
}
_save(newListings) {
const currentListings = getKnownListings(this._jobKey, this._providerId) || {};
newListings.forEach((listing) => {
currentListings[listing.id] = Date.now();
});
setKnownListings(this._jobKey, this._providerId, currentListings);
storeListings(this._jobKey, this._providerId, newListings);
return newListings;
}
_filterBySimilarListings(listings) {
const filteredList = listings.filter((listing) => {
const similar = this._similarityCache.hasSimilarEntries(this._jobKey, listing.title);
const similar = this._similarityCache.hasSimilarEntries(listing.title, listing.address);
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 */
logger.debug(`Filtering similar entry for title: ${listing.title} and address ${listing.address}`);
}
return !similar;
});
filteredList.forEach((filter) => this._similarityCache.addCacheEntry(this._jobKey, filter.title));
filteredList.forEach((filter) => this._similarityCache.addCacheEntry(filter.title, listings.address));
return filteredList;
}
_handleError(err) {
if (err.name !== 'NoNewListingsWarning') console.error(err);
if (err.name !== 'NoNewListingsWarning') logger.error(err);
}
}

View File

@@ -7,12 +7,14 @@ import { loginRouter } from './routes/loginRoute.js';
import { config } from '../utils.js';
import { userRouter } from './routes/userRoute.js';
import { jobRouter } from './routes/jobRouter.js';
import { versionRouter } from './routes/versionRouter.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';
const service = restana();
const staticService = files(path.join(getDirName(), '../ui/public'));
const PORT = config.port || 9998;
@@ -22,6 +24,7 @@ service.use(cookieSession());
service.use(staticService);
service.use('/api/admin', authInterceptor());
service.use('/api/jobs', authInterceptor());
service.use('/api/version', 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 +32,12 @@ 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);
//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,6 +20,7 @@ function doesJobBelongsToUser(job, req) {
}
return user.isAdmin || job.userId === user.id;
}
jobRouter.get('/', async (req, res) => {
const isUserAdmin = isAdmin(req);
//show only the jobs which belongs to the user (or all of the user is an admin)
@@ -30,6 +34,12 @@ jobRouter.get('/processingTimes', async (req, res) => {
};
res.send();
});
jobRouter.post('/startAll', async (req, res) => {
bus.emit('jobs:runAll');
res.send();
});
jobRouter.post('/', async (req, res) => {
const { provider, notificationAdapter, name, blacklist = [], jobId, enabled } = req.body;
try {
@@ -44,13 +54,8 @@ jobRouter.post('/', async (req, res) => {
});
} 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) => {
@@ -64,7 +69,7 @@ jobRouter.delete('', async (req, res) => {
}
} catch (error) {
res.send(new Error(error));
console.error(error);
logger.error(error);
}
res.send();
});
@@ -83,7 +88,7 @@ jobRouter.put('/:jobId/status', async (req, res) => {
}
} catch (error) {
res.send(new Error(error));
console.error(error);
logger.error(error);
}
res.send();
});

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

@@ -0,0 +1,30 @@
import restana from 'restana';
import fetch from 'node-fetch';
import { getPackageVersion } from '../../utils.js';
const service = restana();
const versionRouter = service.newRouter();
versionRouter.get('/', async (req, res) => {
const versionPayload = await getCurrentVersionFromGithub();
res.body = versionPayload == null ? { newVersion: false } : 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 (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

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

@@ -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,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,9 @@
### Sqlite Adapter
### SQLite Adapter
This adapter stores search results in a sqlite database located in db/listings.db. This file can be used for further analysis later on.
This adapter stores search results in an SQLite database. By default, the database is located at `db/listings.db`, but you can configure a custom location. This file can be used for further analysis later.
Fields are:
The database table contains the following columns (all stored as `TEXT` type):
```
['serviceName', 'jobKey', 'id', 'size', 'rooms', 'price', 'address', 'title', 'link', 'description']
['serviceName', 'jobKey', 'id', 'size', 'rooms', 'price', 'address', 'title', 'link', 'description', 'image']
```

View File

@@ -63,31 +63,41 @@ export const send = ({ serviceName, newListings, notificationConfig, 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' },
});
return res;
});
const promises = newListings.map(async (o) => {
const img = normalizeImageUrl(o.image);
const textPayload = {
chat_id: chatId,
text: buildText(jobName, serviceName, o),
parse_mode: 'HTML',
disable_web_page_preview: true,
};
if (img) {
return throttledCall('sendPhoto', {
if (!img) {
return throttledCall('sendMessage', textPayload);
}
try {
return await throttledCall('sendPhoto', {
chat_id: chatId,
photo: img,
caption: buildCaption(jobName, serviceName, o),
parse_mode: 'HTML',
});
} catch (e) {
// If we see a timeout due to sending an image, try sending it without
if (e && (e.code === 'ETIMEDOUT' || e.errno === 'ETIMEDOUT')) {
return throttledCall('sendMessage', textPayload);
}
throw e;
}
return throttledCall('sendMessage', {
chat_id: chatId,
text: buildText(jobName, serviceName, o),
parse_mode: 'HTML',
disable_web_page_preview: true,
});
});
return Promise.all(promises);

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(' • ')[o.address.split(' • ').length - 1];
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,17 @@ 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',
},
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;
}
@@ -30,6 +31,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 = [];
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',

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

@@ -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.error('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,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,116 @@
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;
import crypto from 'crypto';
const retention = 60 * 60 * 1000;
/**
* cleanup
* Internal cache storage.
* Maps a SHA-256 hash (string) to its expiry timestamp (number in ms).
* @type {Map<string, number>}
*/
intervalId = setInterval(() => {
const keysToBeRemoved = [];
const entries = new Map();
/**
* Reference to the currently scheduled cleanup timer.
* @type {NodeJS.Timeout | null}
*/
let timer = null;
/**
* Generate a SHA-256 hash from a list of input strings.
* Null or undefined values are ignored.
*
* @param {...(string|null|undefined)} strings - Input values to hash
* @returns {string} Hexadecimal hash
*/
function toHash(...strings) {
return crypto.createHash('sha256').update(strings.filter(Boolean).join('|')).digest('hex');
}
/**
* Cleanup expired cache entries and schedule the next cleanup run.
* This function is invoked automatically by scheduled timers.
*
* @private
*/
function runCleanup() {
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]);
for (const [hash, expiry] of entries) {
if (expiry <= now) entries.delete(hash);
}
}, 10000);
export const addCacheEntry = (jobId, value) => {
cache[jobId] = cache[jobId] || new SimilarityCacheEntry(Date.now());
cache[jobId].setCacheEntry(value);
};
export const hasSimilarEntries = (jobId, value) => {
if (cache[jobId] == null) {
scheduleNext();
}
/**
* Find the soonest expiry timestamp among all cache entries
* and schedule a one-shot timer that will trigger at that time.
* Cancels any existing timer before scheduling a new one.
*
* @private
*/
function scheduleNext() {
if (timer) {
clearTimeout(timer);
timer = null;
}
let next = Infinity;
const now = Date.now();
for (const expiry of entries.values()) {
if (expiry > now && expiry < next) next = expiry;
}
if (next !== Infinity) {
timer = setTimeout(runCleanup, Math.max(0, next - now));
}
}
/**
* Add or refresh a cache entry for the given title and address.
* The entry will automatically expire after the configured retention window.
*
* @param {string} title - The title used to build the cache key
* @param {string} address - The address used to build the cache key
*/
export function addCacheEntry(title, address) {
const hash = toHash(title, address);
const expiry = Date.now() + retention;
entries.set(hash, expiry);
scheduleNext();
}
/**
* Check if a cache entry with the same title and address exists
* and is still valid (not expired).
*
* @param {string} title - The title used to build the cache key
* @param {string} address - The address used to build the cache key
* @returns {boolean} True if a valid cache entry exists, false otherwise
*/
export function hasSimilarEntries(title, address) {
const hash = toHash(title, address);
const expiry = entries.get(hash);
if (expiry == null) return false;
if (expiry <= Date.now()) {
entries.delete(hash);
scheduleNext();
return false;
}
return cache[jobId].hasSimilarEntries(value);
};
export const stopCacheCleanup = () => {
clearInterval(intervalId);
};
return true;
}
/**
* Stop any scheduled cleanup timers and prevent further automatic cleanup.
* Entries that are already in the cache will remain until removed manually
* or until cleanup is started again by adding new entries.
*/
export function stopCacheCleanup() {
if (timer) clearTimeout(timer);
timer = null;
}
/**
* this is only for test purposes
*/
export function invalidateAllForTest() {
for (const key of entries.keys()) {
entries.set(key, 0);
}
runCleanup();
}

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,144 @@
import { JSONFileSync } from 'lowdb/node';
import { nanoid } from 'nanoid';
import * as listingStorage from './listingsStorage.js';
import { getDirName } from '../../utils.js';
import path from 'path';
import LowdashAdapter from './LowDashAdapter.js';
const file = path.join(getDirName(), '../', 'db/jobs.json');
const adapter = new JSONFileSync(file);
const db = new LowdashAdapter(adapter, { jobs: [] });
db.read();
import SqliteConnection from './SqliteConnection.js';
import logger from '../logger.js';
import { toJson, fromJson } from '../../utils.js';
/**
* Insert or update a job. Preserves original owner (userId) when updating an existing job.
*
* @param {Object} params
* @param {string} [params.jobId] - Existing job id to update; omit to insert a new job.
* @param {string} [params.name] - Job display name.
* @param {Array<any>} [params.blacklist] - Blacklist entries; defaults to empty array.
* @param {boolean} [params.enabled] - Whether the job is enabled; defaults to true.
* @param {Array<any>} params.provider - Provider configuration list.
* @param {Array<any>} params.notificationAdapter - Notification adapter configuration list.
* @param {string} params.userId - Owner user id for inserts; preserved on updates.
* @returns {void}
*/
export const upsertJob = ({ jobId, name, blacklist = [], enabled = true, provider, notificationAdapter, userId }) => {
const currentJob =
jobId == null
? null
: db.chain
.get('jobs')
.find((job) => job.id === jobId)
.value();
const jobs = db.chain
.get('jobs')
.filter((job) => job.id !== jobId)
.value();
jobs.push({
id: jobId || nanoid(),
//make sure to not overwrite the user id in case an admin changes the job
userId: currentJob == null ? userId : currentJob.userId,
enabled,
name,
blacklist,
provider,
notificationAdapter,
});
db.chain.set('jobs', jobs).value();
db.write();
};
export const getJob = (jobId) => {
const job = db.chain
.get('jobs')
.find((job) => job.id === jobId)
.value();
if (job == null) {
return null;
const id = jobId || nanoid();
const existing = SqliteConnection.query(`SELECT id, user_id FROM jobs WHERE id = @id LIMIT 1`, { id })[0];
const ownerId = existing ? existing.user_id : userId;
if (existing) {
SqliteConnection.execute(
`UPDATE jobs
SET enabled = @enabled,
name = @name,
blacklist = @blacklist,
provider = @provider,
notification_adapter = @notification_adapter
WHERE id = @id`,
{
id,
enabled: enabled ? 1 : 0,
name: name ?? null,
blacklist: toJson(blacklist ?? []),
provider: toJson(provider ?? []),
notification_adapter: toJson(notificationAdapter ?? []),
},
);
} else {
SqliteConnection.execute(
`INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter)
VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter)`,
{
id,
user_id: ownerId,
enabled: enabled ? 1 : 0,
name: name ?? null,
blacklist: toJson(blacklist ?? []),
provider: toJson(provider ?? []),
notification_adapter: toJson(notificationAdapter ?? []),
},
);
}
};
/**
* Get a single job by id.
* @param {string} jobId - Job primary key.
* @returns {Job|null} The job or null if not found.
*/
export const getJob = (jobId) => {
const row = SqliteConnection.query(
`SELECT j.id,
j.user_id AS userId,
j.enabled,
j.name,
j.blacklist,
j.provider,
j.notification_adapter AS notificationAdapter,
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
FROM jobs j
WHERE j.id = @id
LIMIT 1`,
{ id: jobId },
)[0];
if (!row) return null;
return {
...job,
numberOfFoundListings: listingStorage.getNumberOfAllKnownListings(job.id).length,
...row,
enabled: !!row.enabled,
blacklist: fromJson(row.blacklist, []),
provider: fromJson(row.provider, []),
notificationAdapter: fromJson(row.notificationAdapter, []),
};
};
/**
* Update job enabled status.
* @param {{jobId: string, status: boolean}} params - Parameters.
* @returns {void}
*/
export const setJobStatus = ({ jobId, status }) => {
db.chain
.get('jobs')
.find((job) => job.id === jobId)
.assign({ enabled: status })
.value();
db.write();
SqliteConnection.execute(`UPDATE jobs SET enabled = @enabled WHERE id = @id`, {
id: jobId,
enabled: status ? 1 : 0,
});
};
/**
* Remove a job by id. Listings are deleted automatically due to FK ON DELETE CASCADE.
* @param {string} jobId - Job id.
* @returns {void}
*/
export const removeJob = (jobId) => {
listingStorage.removeListings(jobId);
db.chain
.get('jobs')
.remove((job) => job.id === jobId)
.value();
db.write();
// listings table has FK ON DELETE CASCADE via job_id
SqliteConnection.execute(`DELETE FROM jobs WHERE id = @id`, { id: jobId });
};
export const removeJobsByUserId = (userId) => {
db.chain
.get('jobs')
.filter((job) => job.userId === userId)
.forEach((job) => listingStorage.removeListings(job.id));
db.chain
.get('jobs')
.remove((job) => job.userId === userId)
.value();
db.write();
};
export const removeJobsByUserName = (userId) => {
let removedDemoJobs = 0;
db.chain
.get('jobs')
.filter((job) => job.userId === userId)
.forEach((job) => {
removedDemoJobs++;
listingStorage.removeListings(job.id);
});
db.chain
.get('jobs')
.remove((job) => job.userId === userId)
.value();
db.write();
if (removedDemoJobs > 0) {
/* 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.notification_adapter AS notificationAdapter,
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
FROM jobs j
ORDER BY j.name IS NULL, j.name`,
);
return rows.map((row) => ({
...row,
enabled: !!row.enabled,
blacklist: fromJson(row.blacklist, []),
provider: fromJson(row.provider, []),
notificationAdapter: fromJson(row.notificationAdapter, []),
}));
};

View File

@@ -1,52 +1,168 @@
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, '');
}
};

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

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

@@ -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,13 @@ function timeStringToMs(timeString, now) {
return d.getTime();
}
/**
* Check whether current time is within configured working hours, or no hours are set.
* If working hours are missing or incomplete, returns true.
* @param {{workingHours?: {from?: string, to?: string}}} config
* @param {number} now - Epoch ms
* @returns {boolean}
*/
function duringWorkingHoursOrNotSet(config, now) {
const { workingHours } = config;
if (workingHours == null || nullOrEmpty(workingHours.from) || nullOrEmpty(workingHours.to)) {
@@ -37,10 +125,20 @@ function duringWorkingHoursOrNotSet(config, now) {
return fromDate <= now && toDate >= now;
}
/**
* 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 +150,61 @@ 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 = {};
/**
* 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 +218,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));
}
/**
* returns a random into between start and end
* @param a start int
* @param b max int
* @returns {*}
*/
function randomBetween(a, b) {
return Math.floor(Math.random() * (b - a + 1)) + a;
}
// 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.4.3",
"version": "12.1.7",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": {
"prepare": "husky",
@@ -11,9 +11,11 @@
"build:frontend": "vite build",
"format": "prettier --write \"**/*.js\"",
"format:check": "prettier --check \"**/*.js\"",
"test": "mocha --loader=esmock --timeout 60000 test/**/*.test.js",
"test": "node --import ./test/esmock-loader.mjs ./node_modules/mocha/bin/mocha.js --timeout 60000 test/**/*.test.js",
"lint": "eslint .",
"lint:fix": "yarn lint --fix"
"lint:fix": "yarn lint --fix",
"migratedb": "node lib/services/storage/migrations/migrate.js",
"migratedb:overwrite": "x-var MIGRATION_ALLOW_CHECKSUM_UPDATE=true node lib/services/storage/migrations/migrate.js"
},
"type": "module",
"lint-staged": {
@@ -44,7 +46,7 @@
},
"license": "MIT",
"engines": {
"node": ">=20.0.0",
"node": ">=22.0.0",
"npm": ">=7.0.0"
},
"browserslist": [
@@ -54,62 +56,57 @@
"Firefox ESR"
],
"dependencies": {
"@douyinfe/semi-ui": "2.85.0",
"@rematch/core": "2.2.0",
"@rematch/loading": "2.1.2",
"@sendgrid/mail": "8.1.5",
"@vitejs/plugin-react": "5.0.2",
"better-sqlite3": "^12.2.0",
"@douyinfe/semi-icons": "^2.86.0",
"@douyinfe/semi-ui": "2.86.0",
"@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.3",
"better-sqlite3": "^12.3.0",
"body-parser": "2.2.0",
"cheerio": "^1.1.2",
"cookie-session": "2.1.1",
"handlebars": "4.7.8",
"highcharts": "12.3.0",
"highcharts-react-official": "3.2.2",
"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.17.1",
"puppeteer": "^24.22.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": "5.2.1",
"react-router-dom": "5.3.0",
"redux": "5.0.1",
"redux-thunk": "3.1.0",
"react-router": "7.9.1",
"react-router-dom": "7.9.1",
"restana": "5.1.0",
"serve-static": "2.2.0",
"slack": "11.0.2",
"string-similarity": "^4.0.4",
"vite": "7.1.3",
"x-var": "^2.1.0"
"vite": "7.1.7",
"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",
"eslint": "9.36.0",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-react": "7.37.5",
"esmock": "2.7.1",
"esmock": "2.7.3",
"history": "5.3.0",
"husky": "9.1.7",
"less": "4.4.1",
"lint-staged": "16.1.5",
"mocha": "11.7.1",
"lint-staged": "16.2.0",
"mocha": "11.7.2",
"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);
});
});

4
test/esmock-loader.mjs Normal file
View File

@@ -0,0 +1,4 @@
import { register } from 'node:module';
import { pathToFileURL } from 'node:url';
register('esmock', pathToFileURL('./'));

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

@@ -25,6 +25,7 @@ describe('#einsAImmobilien testsuite()', () => {
expect(notify.size).to.be.a('string');
expect(notify.title).to.be.a('string');
expect(notify.link).to.be.a('string');
expect(notify.address).to.be.a('string');
/** check the values if possible **/
expect(notify.size).to.be.not.empty;
expect(notify.title).to.be.not.empty;

View File

@@ -1,4 +1,4 @@
import utils from '../../lib/utils.js';
import { isOneOf, duringWorkingHoursOrNotSet } from '../../lib/utils.js';
import assert from 'assert';
import { expect } from 'chai';
@@ -11,27 +11,27 @@ const fakeWorkingHoursConfig = (from, to) => ({
describe('utils', () => {
describe('#isOneOf()', () => {
it('should be false', () => {
assert.equal(utils.isOneOf('bla', ['blub']), false);
assert.equal(isOneOf('bla', ['blub']), false);
});
it('should be true', () => {
assert.equal(utils.isOneOf('bla blub blubber', ['bla']), true);
assert.equal(isOneOf('bla blub blubber', ['bla']), true);
});
});
describe('#duringWorkingHoursOrNotSet()', () => {
it('should be false', () => {
expect(utils.duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('12:00', '13:00'), 0)).to.be.false;
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('12:00', '13:00'), 0)).to.be.false;
});
it('should be true', () => {
expect(utils.duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('10:00', '16:00'), 1622026740000)).to.be.true;
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('10:00', '16:00'), 1622026740000)).to.be.true;
});
it('should be true if nothing set', () => {
expect(utils.duringWorkingHoursOrNotSet(fakeWorkingHoursConfig(null, null), 1622026740000)).to.be.true;
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig(null, null), 1622026740000)).to.be.true;
});
it('should be true if only to is set', () => {
expect(utils.duringWorkingHoursOrNotSet(fakeWorkingHoursConfig(null, '13:00'), 1622026740000)).to.be.true;
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig(null, '13:00'), 1622026740000)).to.be.true;
});
it('should be true if only from is set', () => {
expect(utils.duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('12:00', null), 1622026740000)).to.be.true;
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('12:00', null), 1622026740000)).to.be.true;
});
});
});

View File

@@ -53,7 +53,7 @@ describe('#immoscout-mobile URL conversion', () => {
const response = await fetch(url, {
method: 'POST',
headers: {
'User-Agent': 'ImmoScout24_1410_30_._',
'User-Agent': 'ImmoScout_27.3_26.0_._',
'Content-Type': 'application/json',
},
body: JSON.stringify({

View File

@@ -1,40 +1,30 @@
import SimilarityCacheEntry from '../../lib/services/similarity-check/SimilarityCacheEntry.js';
import { expect } from 'chai';
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
describe('similarityCheck', () => {
describe('#similarityCheck()', () => {
it('should be false', () => {
const check = new SimilarityCacheEntry(0);
check.setCacheEntry('Hallo');
expect(check.hasSimilarEntries('Welt')).to.be.false;
});
it('should be true', () => {
const check = new SimilarityCacheEntry(0);
check.setCacheEntry('Hallo');
expect(check.hasSimilarEntries('hallo')).to.be.true;
});
it('should be true', () => {
const check = new SimilarityCacheEntry(0);
check.setCacheEntry('Selling an incredible house in san francisco');
expect(check.hasSimilarEntries('incredible house in san francisco for sale')).to.be.true;
});
it('should be true', () => {
const check = new SimilarityCacheEntry(0);
check.setCacheEntry('a');
check.setCacheEntry('b');
check.setCacheEntry('c');
check.setCacheEntry('d');
expect(check.hasSimilarEntries('b')).to.be.true;
});
it('should be false', () => {
const check = new SimilarityCacheEntry(0);
check.setCacheEntry(
'The index is known by several other names, especially SørensenDice index,[3] Sørensen index and Dice\'s coefficient. Other variations include the "similarity coefficient" or "index", such as Dice similarity coefficient (DSC). Common alternate spellings for Sørensen are Sorenson, Soerenson and Sörenson, and all three can also be seen with the sen ending.',
);
check.setCacheEntry(
'where |X| and |Y| are the cardinalities of the two sets (i.e. the number of elements in each set). The Sørensen index equals twice the number of elements common to both sets divided by the sum of the number of elements in each set.',
);
expect(check.hasSimilarEntries('unrelated text')).to.be.false;
});
it('should return true on duplicate', () => {
similarityCache.addCacheEntry('Hello World', 'Test');
expect(similarityCache.hasSimilarEntries('Hello World', 'Test')).to.be.true;
});
it('should return true even if one value is null', () => {
similarityCache.addCacheEntry('Hello World', null);
expect(similarityCache.hasSimilarEntries('Hello World', null)).to.be.true;
});
it('should return true even if one value is an obj', () => {
similarityCache.addCacheEntry('Hello World', [{ TR: 'OLOLO' }]);
expect(similarityCache.hasSimilarEntries('Hello World', [{ TR: 'OLOLO' }])).to.be.true;
});
it('should return false when no duplicate', () => {
similarityCache.addCacheEntry('Hello World__', 'Test');
expect(similarityCache.hasSimilarEntries('Hello World___', 'Test')).to.be.false;
});
it('should return false when no duplicate', () => {
expect(similarityCache.hasSimilarEntries('Hello World', 'Test')).to.be.true;
similarityCache.invalidateAllForTest();
expect(similarityCache.hasSimilarEntries('Hello World', 'Test')).to.be.false;
});
});

View File

@@ -0,0 +1,142 @@
import { expect } from 'chai';
import esmock from 'esmock';
// We explicitly avoid touching the real filesystem or creating a real DB file.
// better-sqlite3 is fully mocked and operates in-memory via our stubs.
describe('SqliteConnection', () => {
let SqliteConnection;
let calls;
beforeEach(async () => {
calls = {
fs: { existsSync: [], mkdirSync: [] },
db: { pragma: [], prepare: [], transactionWraps: 0, close: 0 },
prepareAll: [],
prepareRun: [],
prepareGet: [],
processOnce: [],
logs: { warn: [], debug: [] },
};
// stub for fs
const fsMock = {
existsSync: (dir) => {
calls.fs.existsSync.push(dir);
// Pretend directory always exists to avoid mkdir
return true;
},
mkdirSync: (dir, opts) => {
calls.fs.mkdirSync.push({ dir, opts });
},
};
// Prepare object returned from db.prepare()
const prepareObj = {
all: (params) => {
calls.prepareAll.push(params);
return [{ x: 1 }];
},
run: (params) => {
calls.prepareRun.push(params);
return { changes: 1 };
},
get: (param) => {
calls.prepareGet.push(param);
// return truthy by default
return { one: 1 };
},
};
// Database mock constructor
const BetterSqlite3Mock = function (filepath, options) {
// expose on instance
this.filepath = filepath;
this.options = options;
this.pragma = (p) => {
calls.db.pragma.push(p);
return undefined;
};
this.prepare = (sql) => {
calls.db.prepare.push(sql);
return prepareObj;
};
this.transaction = (fn) => {
// better-sqlite3 returns a function that executes inside a transaction
return (cb) => {
calls.db.transactionWraps += 1;
return fn(cb);
};
};
this.close = () => {
calls.db.close += 1;
};
};
// esmock the module with our stubs
SqliteConnection = await esmock(
'../../lib/services/storage/SqliteConnection.js',
{},
{
fs: fsMock,
'better-sqlite3': { default: BetterSqlite3Mock },
},
);
});
afterEach(() => {
// ensure we can close between tests
SqliteConnection.close();
});
it('creates singleton connection and applies PRAGMAs without touching disk', () => {
const db1 = SqliteConnection.getConnection();
const db2 = SqliteConnection.getConnection();
expect(db1).to.equal(db2);
// journal_mode, synchronous, cache_size, foreign_keys, optimize
expect(calls.db.pragma).to.deep.equal([
'journal_mode = WAL',
'synchronous = NORMAL',
'cache_size = -64000',
'foreign_keys = ON',
'optimize',
]);
// mkdirSync should not be called because existsSync returned true
expect(calls.fs.mkdirSync).to.have.length(0);
});
it('executes query and execute helpers', () => {
const rows = SqliteConnection.query('SELECT 1', {});
expect(rows).to.be.an('array');
expect(rows[0]).to.deep.equal({ x: 1 });
const info = SqliteConnection.execute('UPDATE x SET y=1 WHERE id=@id', { id: 5 });
expect(info).to.have.property('changes', 1);
});
it('tableExists uses sqlite_master get()', () => {
const exists = SqliteConnection.tableExists('users');
expect(exists).to.equal(true);
});
it('withTransaction wraps callback', () => {
const result = SqliteConnection.withTransaction((db) => {
// ensure we can use the db to prepare
db.prepare('SELECT inside').all({});
return 42;
});
expect(result).to.equal(42);
expect(calls.db.prepare).to.include('SELECT inside');
});
it('optimize() delegates to PRAGMA optimize and close() calls it again then closes', () => {
SqliteConnection.optimize();
// It will use the existing connection and call pragma('optimize')
expect(calls.db.pragma).to.include('optimize');
SqliteConnection.close();
// close increments close counter
expect(calls.db.close).to.equal(1);
});
});

View File

@@ -6,35 +6,37 @@ import GeneralSettings from './views/generalSettings/GeneralSettings';
import JobMutation from './views/jobs/mutation/JobMutation';
import UserMutator from './views/user/mutation/UserMutator';
import JobInsight from './views/jobs/insights/JobInsight.jsx';
import { useDispatch, useSelector } from 'react-redux';
import { Switch, Redirect } from 'react-router-dom';
import { useActions, useSelector } from './services/state/store';
import { Routes, Route, Navigate } from 'react-router-dom';
import Logout from './components/logout/Logout';
import Logo from './components/logo/Logo';
import Menu from './components/menu/Menu';
import Login from './views/login/Login';
import Users from './views/user/Users';
import Jobs from './views/jobs/Jobs';
import { Route } from 'react-router';
import './App.less';
import TrackingModal from './components/tracking/TrackingModal.jsx';
import { Banner } from '@douyinfe/semi-ui';
import VersionBanner from './components/version/VersionBanner.jsx';
export default function FredyApp() {
const dispatch = useDispatch();
const actions = useActions();
const [loading, setLoading] = React.useState(true);
const currentUser = useSelector((state) => state.user.currentUser);
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
const settings = useSelector((state) => state.generalSettings.settings);
useEffect(() => {
async function init() {
await dispatch.user.getCurrentUser();
await actions.user.getCurrentUser();
if (!needsLogin()) {
await dispatch.provider.getProvider();
await dispatch.jobs.getJobs();
await dispatch.jobs.getProcessingTimes();
await dispatch.notificationAdapter.getAdapter();
await dispatch.generalSettings.getGeneralSettings();
await actions.provider.getProvider();
await actions.jobs.getJobs();
await actions.jobs.getProcessingTimes();
await actions.notificationAdapter.getAdapter();
await actions.generalSettings.getGeneralSettings();
await actions.versionUpdate.getVersionUpdate();
}
setLoading(false);
}
@@ -49,12 +51,11 @@ export default function FredyApp() {
const isAdmin = () => currentUser != null && currentUser.isAdmin;
const login = () => (
<Switch>
<Route name="Login" path={'/login'} component={Login} />
<Redirect from="*" to={'/login'} />
</Switch>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
);
return loading ? null : needsLogin() ? (
login()
) : (
@@ -63,7 +64,7 @@ export default function FredyApp() {
<Logout />
<Logo width={190} white />
<Menu isAdmin={isAdmin()} />
{versionUpdate?.newVersion && <VersionBanner />}
{settings.demoMode && (
<>
<Banner
@@ -77,34 +78,49 @@ export default function FredyApp() {
</>
)}
{settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />}
<Switch>
<Route name="Insufficient Permission" path={'/403'} component={InsufficientPermission} />
<Route name="Create new Job" path={'/jobs/new'} component={JobMutation} />
<Route name="Edit a Job" path={'/jobs/edit/:jobId'} component={JobMutation} />
<Route name="Insights into a Job" path={'/jobs/insights/:jobId'} component={JobInsight} />
<Route name="Job overview" path={'/jobs'} component={Jobs} />
<PermissionAwareRoute
name="Create new User"
<Routes>
<Route path="/403" element={<InsufficientPermission />} />
<Route path="/jobs/new" element={<JobMutation />} />
<Route path="/jobs/edit/:jobId" element={<JobMutation />} />
<Route path="/jobs/insights/:jobId" element={<JobInsight />} />
<Route path="/jobs" element={<Jobs />} />
{/* Permission-aware routes */}
<Route
path="/users/new"
component={<UserMutator />}
currentUser={currentUser}
element={
<PermissionAwareRoute currentUser={currentUser}>
<UserMutator />
</PermissionAwareRoute>
}
/>
<PermissionAwareRoute
name="Edit a user"
<Route
path="/users/edit/:userId"
component={<UserMutator />}
currentUser={currentUser}
element={
<PermissionAwareRoute currentUser={currentUser}>
<UserMutator />
</PermissionAwareRoute>
}
/>
<PermissionAwareRoute name="Users" path="/users" component={<Users />} currentUser={currentUser} />
<PermissionAwareRoute
name="General Settings"
<Route
path="/users"
element={
<PermissionAwareRoute currentUser={currentUser}>
<Users />
</PermissionAwareRoute>
}
/>
<Route
path="/generalSettings"
component={<GeneralSettings />}
currentUser={currentUser}
element={
<PermissionAwareRoute currentUser={currentUser}>
<GeneralSettings />
</PermissionAwareRoute>
}
/>
<Redirect from="/" to={'/jobs'} />
</Switch>
<Route path="/" element={<Navigate to="/jobs" replace />} />
</Routes>
</div>
</div>
);

View File

@@ -1,27 +1,24 @@
import React from 'react';
import { reduxStore } from './services/rematch/store';
import { HashRouter } from 'react-router-dom';
import { createHashHistory } from 'history';
import { Provider } from 'react-redux';
import { createRoot } from 'react-dom/client';
import en_US from '@douyinfe/semi-ui/lib/es/locale/source/en_US';
import { LocaleProvider } from '@douyinfe/semi-ui';
import { initVChartSemiTheme } from '@visactor/vchart-semi-theme';
import App from './App';
import './Index.less';
const container = document.getElementById('fredy');
const root = createRoot(container);
const history = createHashHistory();
import App from './App';
import './Index.less';
initVChartSemiTheme({
defaultMode: 'dark',
});
root.render(
<Provider store={reduxStore}>
<HashRouter history={history}>
<LocaleProvider locale={en_US}>
<App />
</LocaleProvider>
</HashRouter>
</Provider>,
<HashRouter>
<LocaleProvider locale={en_US}>
<App />
</LocaleProvider>
</HashRouter>,
);

View File

@@ -1,8 +1,8 @@
import React from 'react';
import { useHistory } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import { Tabs, TabPane } from '@douyinfe/semi-ui';
import { useLocation } from 'react-router';
import { useLocation } from 'react-router-dom';
import { IconUser, IconTerminal, IconSetting } from '@douyinfe/semi-icons';
import './Menu.less';
@@ -12,15 +12,10 @@ function parsePathName(name) {
}
const TopMenu = function TopMenu({ isAdmin }) {
const history = useHistory();
const navigate = useNavigate();
const location = useLocation();
return (
<Tabs
className="menu"
type="line"
activeKey={parsePathName(location.pathname)}
onTabClick={(key) => history.push(key)}
>
<Tabs className="menu" type="line" activeKey={parsePathName(location.pathname)} onTabClick={(key) => navigate(key)}>
<TabPane
itemKey="/jobs"
tab={

View File

@@ -1,21 +1,8 @@
import React from 'react';
import { Redirect } from 'react-router-dom';
import { Route } from 'react-router';
import { Navigate } from 'react-router-dom';
export default function PermissionAwareRoute({ currentUser, name, path, component }) {
/**
* Checks if given component should be rendered if current user has given permission enabled. If that's not the case,
* user is redirected to '/403'.
*
* @param permission
* @param component
* @param path
* @returns {*}
*/
const checkIfAdmin = (component, path) => {
return currentUser != null && currentUser.isAdmin ? component : <Redirect from={path} to="/403" />;
};
return <Route name={name} path={path} render={() => checkIfAdmin(component, path)} />;
export default function PermissionAwareRoute({ currentUser, children }) {
const isAdmin = currentUser != null && currentUser.isAdmin;
return isAdmin ? children : <Navigate to="/403" replace />;
}

View File

@@ -43,7 +43,8 @@ export default function TrackingModal() {
</p>
<p>
However, it would be a huge help if youd allow me to collect some analytical data. Wait, before you click
"no", let me explain. If you agree, Fredy will send a ping to my Mixpanel project each time it runs.
"no", let me explain. If you agree, Fredy will send a ping once every 6 hours to my internal tracking project.
(Will be open-sourced soon)
</p>
<p>
The data includes: names of active adapters/providers, OS, architecture, Node version, and language. The

View File

@@ -0,0 +1,43 @@
import React from 'react';
import { Banner, Descriptions } from '@douyinfe/semi-ui';
import { useSelector } from '../../services/state/store.js';
import './VersionBanner.less';
export default function VersionBanner() {
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
return (
<Banner
className="versionBanner"
type="success"
icon={null}
description={
<div>
<p>A new version of Fredy is available. Update now to take advantage of the latest features and bug fixes.</p>
<Descriptions row size="small">
<Descriptions.Item itemKey="Your Version">{versionUpdate.localFredyVersion}</Descriptions.Item>
<Descriptions.Item itemKey="Latest Version">{versionUpdate.version}</Descriptions.Item>
<Descriptions.Item itemKey="Github Release">
<a href={versionUpdate.url} target="_blank" rel="noreferrer">
{versionUpdate.url}
</a>{' '}
</Descriptions.Item>
</Descriptions>
<p>
<b>
<small>Release Notes</small>
</b>
</p>
<pre>{stripFullChangelog(versionUpdate.body)}</pre>
</div>
}
/>
);
function stripFullChangelog(text) {
if (text == null) {
return '';
}
return text.replace(/(?:\r?\n)\*\*Full Changelog\*\*[\s\S]*$/u, '');
}
}

View File

@@ -0,0 +1,3 @@
.versionBanner {
margin-bottom: 1rem;
}

View File

@@ -1,24 +0,0 @@
import { xhrGet } from '../../xhr';
export const demoMode = {
state: {
demoMode: false,
},
reducers: {
setDemoMode: (state, payload) => {
return {
...state,
demoMode: payload.demoMode,
};
},
},
effects: {
async getDemoMode() {
try {
const response = await xhrGet('/api/demo');
this.setDemoMode(response.json);
} catch (Exception) {
console.error('Error while trying to get resource for api/demo. Error:', Exception);
}
},
},
};

View File

@@ -1,25 +0,0 @@
import { xhrGet } from '../../xhr';
export const generalSettings = {
state: {
settings: {},
},
reducers: {
//only admins
setGeneralSettings: (state, payload) => {
return {
...state,
settings: payload,
};
},
},
effects: {
async getGeneralSettings() {
try {
const response = await xhrGet('/api/admin/generalSettings');
this.setGeneralSettings(response.json);
} catch (Exception) {
console.error('Error while trying to get resource for api/admin/generalSettings. Error:', Exception);
}
},
},
};

View File

@@ -1,57 +0,0 @@
import { xhrGet } from '../../xhr';
export const jobs = {
state: {
jobs: [],
insights: {},
processingTimes: {},
},
reducers: {
setJobs: (state, payload) => {
return {
...state,
jobs: Object.freeze(payload),
};
},
setProcessingTimes: (state, payload) => {
return {
...state,
processingTimes: Object.freeze(payload),
};
},
setJobInsights: (state, payload, jobId) => {
return {
...state,
insights: {
...state.insights,
[jobId]: Object.freeze(payload),
},
};
},
},
effects: {
async getJobs() {
try {
const response = await xhrGet('/api/jobs');
this.setJobs(response.json);
} catch (Exception) {
console.error(`Error while trying to get resource for api/jobs. Error:`, Exception);
}
},
async getProcessingTimes() {
try {
const response = await xhrGet('/api/jobs/processingTimes');
this.setProcessingTimes(response.json);
} catch (Exception) {
console.error(`Error while trying to get resource for api/processingTimes. Error:`, Exception);
}
},
async getInsightDataForJob(jobId) {
try {
const response = await xhrGet(`/api/jobs/insights/${jobId}`);
this.setJobInsights(response.json, jobId);
} catch (Exception) {
console.error(`Error while trying to get resource for api/jobs/insights. Error:`, Exception);
}
},
},
};

View File

@@ -1,19 +0,0 @@
import { xhrGet } from '../../xhr';
export const notificationAdapter = {
state: [],
reducers: {
setAdapter: (state, payload) => {
return [...Object.freeze(payload)];
},
},
effects: {
async getAdapter() {
try {
const response = await xhrGet('/api/jobs/notificationAdapter');
this.setAdapter(response.json);
} catch (Exception) {
console.error(`Error while trying to get resource for api/jobs/notificationAdapter. Error:`, Exception);
}
},
},
};

View File

@@ -1,19 +0,0 @@
import { xhrGet } from '../../xhr';
export const provider = {
state: [],
reducers: {
setProvider: (state, payload) => {
return [...Object.freeze(payload)];
},
},
effects: {
async getProvider() {
try {
const response = await xhrGet('/api/jobs/provider');
this.setProvider(response.json);
} catch (Exception) {
console.error(`Error while trying to get resource for api/jobs/provider. Error:`, Exception);
}
},
},
};

View File

@@ -1,40 +0,0 @@
import { xhrGet } from '../../xhr';
export const user = {
state: {
users: [],
currentUser: null,
},
reducers: {
//only admins
setUsers: (state, payload) => {
return {
...state,
users: payload,
};
},
setCurrentUser: (state, payload) => {
return {
...state,
currentUser: Object.freeze(payload),
};
},
},
effects: {
async getUsers() {
try {
const response = await xhrGet('/api/admin/users');
this.setUsers(response.json);
} catch (Exception) {
console.error('Error while trying to get resource for api/admin/users. Error:', Exception);
}
},
async getCurrentUser() {
try {
const response = await xhrGet('/api/login/user');
this.setCurrentUser(response.json);
} catch (Exception) {
console.error('Error while trying to get resource for api/login/user. Error:', Exception);
}
},
},
};

View File

@@ -1,29 +0,0 @@
import { notificationAdapter } from './models/notificationAdapter';
import { generalSettings } from './models/generalSettings';
import createLoadingPlugin from '@rematch/loading';
import { provider } from './models/provider';
import { createLogger } from 'redux-logger';
import { jobs } from './models/jobs';
import { user } from './models/user';
import { demoMode } from './models/demoMode.js';
import { init } from '@rematch/core';
const middleware = [];
if (process.env.NODE_ENV === 'development') {
middleware.push(createLogger({ duration: false, collapsed: (getState, action, logEntry) => !logEntry.error }));
}
const store = init({
name: 'fredy',
models: {
notificationAdapter,
generalSettings,
demoMode,
provider,
jobs,
user,
},
plugins: [createLoadingPlugin({})],
redux: {
middlewares: middleware,
},
});
export const reduxStore = store;

View File

@@ -0,0 +1,185 @@
/**
* Zustand store for Fredy ui state.
*/
import { create } from 'zustand';
import { shallow } from 'zustand/shallow';
import { xhrGet } from '../xhr.js';
const logger = (config) => (set, get, api) =>
config(
(partial, replace) => {
const prev = get();
set(partial, replace);
const next = get();
if (process.env.NODE_ENV !== 'production') {
/* eslint-disable no-console */
console.info('[zustand] state changed:', { prev, next });
/* eslint-enable no-console */
}
},
get,
api,
);
// Create the Zustand store with slices and actions
export const useFredyState = create(
logger(
(set) => {
// Async actions that directly set state (no separate reducer concept)
const effects = {
notificationAdapter: {
async getAdapter() {
try {
const response = await xhrGet('/api/jobs/notificationAdapter');
set(() => ({ notificationAdapter: Object.freeze([...response.json]) }));
} catch (Exception) {
console.error(`Error while trying to get resource for api/jobs/notificationAdapter. Error:`, Exception);
}
},
},
generalSettings: {
async getGeneralSettings() {
try {
const response = await xhrGet('/api/admin/generalSettings');
set((state) => ({ generalSettings: { ...state.generalSettings, settings: response.json } }));
} catch (Exception) {
console.error('Error while trying to get resource for api/admin/generalSettings. Error:', Exception);
}
},
},
provider: {
async getProvider() {
try {
const response = await xhrGet('/api/jobs/provider');
set(() => ({ provider: Object.freeze([...response.json]) }));
} catch (Exception) {
console.error(`Error while trying to get resource for api/jobs/provider. Error:`, Exception);
}
},
},
jobs: {
async getJobs() {
try {
const response = await xhrGet('/api/jobs');
set((state) => ({ jobs: { ...state.jobs, jobs: Object.freeze(response.json) } }));
} catch (Exception) {
console.error(`Error while trying to get resource for api/jobs. Error:`, Exception);
}
},
async getProcessingTimes() {
try {
const response = await xhrGet('/api/jobs/processingTimes');
set((state) => ({ jobs: { ...state.jobs, processingTimes: Object.freeze(response.json) } }));
} catch (Exception) {
console.error(`Error while trying to get resource for api/processingTimes. Error:`, Exception);
}
},
async getInsightDataForJob(jobId) {
try {
const response = await xhrGet(`/api/jobs/insights/${jobId}`);
set((state) => ({
jobs: {
...state.jobs,
insights: { ...state.jobs.insights, [jobId]: Object.freeze(response.json) },
},
}));
} catch (Exception) {
console.error(`Error while trying to get resource for api/jobs/insights. Error:`, Exception);
}
},
},
user: {
async getUsers() {
try {
const response = await xhrGet('/api/admin/users');
set((state) => ({ user: { ...state.user, users: response.json } }));
} catch (Exception) {
console.error('Error while trying to get resource for api/admin/users. Error:', Exception);
}
},
async getCurrentUser() {
try {
const response = await xhrGet('/api/login/user');
set((state) => ({ user: { ...state.user, currentUser: Object.freeze(response.json) } }));
} catch (Exception) {
console.error('Error while trying to get resource for api/login/user. Error:', Exception);
}
},
},
demoMode: {
async getDemoMode() {
try {
const response = await xhrGet('/api/demo');
set((state) => ({
demoMode: { ...state.demoMode, demoMode: response.json.demoMode },
}));
} catch (Exception) {
console.error('Error while trying to get resource for api/demo. Error:', Exception);
}
},
},
versionUpdate: {
async getVersionUpdate() {
try {
const response = await xhrGet('/api/version');
set((state) => ({
versionUpdate: { ...state.versionUpdate, versionUpdate: response.json },
}));
} catch (Exception) {
console.error('Error while trying to get resource for api/version. Error:', Exception);
}
},
},
};
// Initial state
const initial = {
notificationAdapter: [],
generalSettings: { settings: {} },
demoMode: { demoMode: false },
versionUpdate: {},
provider: [],
jobs: { jobs: [], insights: {}, processingTimes: {} },
user: { users: [], currentUser: null },
};
// Expose actions by grouping them per slice
const actions = {
notificationAdapter: { ...effects.notificationAdapter },
generalSettings: { ...effects.generalSettings },
demoMode: { ...effects.demoMode },
versionUpdate: { ...effects.versionUpdate },
provider: { ...effects.provider },
jobs: { ...effects.jobs },
user: { ...effects.user },
};
return {
...initial,
__actions: { actions },
};
},
{ name: 'fredy' },
),
);
/**
* Selector hook, drop-in replacement for react-redux useSelector.
* Pass a selector function and optional equality function. Defaults to shallow comparison.
* @template T
* @param {(state: FredyState) => T} selector
* @param {(a: T, b: T) => boolean} [equalityFn]
* @returns {T}
*/
export function useSelector(selector, equalityFn = shallow) {
return useFredyState(selector, equalityFn);
}
/**
* Actions hook returning grouped async actions per slice.
* Example: const { jobs } = useActions(); await jobs.getJobs();
* @returns {{notificationAdapter: any, generalSettings: any, demoMode: any, provider: any, jobs: any, user: any}}
*/
export function useActions() {
return useFredyState((s) => s.__actions.actions);
}

View File

@@ -8,4 +8,4 @@ export function format(ts) {
second: 'numeric',
}).format(ts);
}
export const roundToNext5Minute = (ts) => Math.ceil(ts / (1000 * 60 * 5)) * (1000 * 60 * 5);
export const roundToHour = (ts) => Math.ceil(ts / (1000 * 60 * 60)) * (1000 * 60 * 60);

View File

@@ -1,8 +1,8 @@
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useActions, useSelector } from '../../services/state/store';
import { Divider, TimePicker, Button, Checkbox } from '@douyinfe/semi-ui';
import { Divider, TimePicker, Button, Checkbox, Input } from '@douyinfe/semi-ui';
import { InputNumber } from '@douyinfe/semi-ui';
import Headline from '../../components/headline/Headline';
import { xhrPost } from '../../services/xhr';
@@ -15,6 +15,7 @@ import {
IconSignal,
IconLineChartStroked,
IconSearch,
IconFolder,
} from '@douyinfe/semi-icons';
import './GeneralSettings.less';
@@ -35,7 +36,7 @@ function formatFromTBackend(time) {
}
const GeneralSettings = function GeneralSettings() {
const dispatch = useDispatch();
const actions = useActions();
const [loading, setLoading] = React.useState(true);
const settings = useSelector((state) => state.generalSettings.settings);
@@ -46,10 +47,11 @@ const GeneralSettings = function GeneralSettings() {
const [workingHourTo, setWorkingHourTo] = React.useState(null);
const [demoMode, setDemoMode] = React.useState(null);
const [analyticsEnabled, setAnalyticsEnabled] = React.useState(null);
const [sqlitePath, setSqlitePath] = React.useState(null);
React.useEffect(() => {
async function init() {
await dispatch.generalSettings.getGeneralSettings();
await actions.generalSettings.getGeneralSettings();
setLoading(false);
}
@@ -64,6 +66,7 @@ const GeneralSettings = function GeneralSettings() {
setWorkingHourTo(settings?.workingHours?.to);
setAnalyticsEnabled(settings?.analyticsEnabled || false);
setDemoMode(settings?.demoMode || false);
setSqlitePath(settings?.sqlitepath);
}
init();
@@ -87,6 +90,10 @@ const GeneralSettings = function GeneralSettings() {
Toast.error('Working hours to and from must be set if either to or from has been set before.');
return;
}
if (nullOrEmpty(sqlitePath)) {
Toast.error('SQLite db path cannot be empty.');
return;
}
try {
await xhrPost('/api/admin/generalSettings', {
interval,
@@ -97,6 +104,7 @@ const GeneralSettings = function GeneralSettings() {
},
demoMode,
analyticsEnabled,
sqlitepath: sqlitePath,
});
} catch (exception) {
console.error(exception);
@@ -121,11 +129,11 @@ const GeneralSettings = function GeneralSettings() {
<div>
<SegmentPart
name="Interval"
helpText="Interval in minutes for running queries against the configured services."
helpText="Interval in minutes for running queries against the configured services. Do NOT go under 5 minutes as with a lower interval, your instance might be detected as a bot."
Icon={IconRefresh}
>
<InputNumber
min={0}
min={5}
max={1440}
placeholder="Interval in minutes"
value={interval}
@@ -146,6 +154,36 @@ const GeneralSettings = function GeneralSettings() {
/>
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart
name="SQLite Database path"
helpText="The directory where Fredy stores its SQLite database files."
Icon={IconFolder}
>
<Banner
fullMode={false}
type="warning"
closeIcon={null}
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Warning</div>}
style={{ marginBottom: '1rem' }}
description={
<div>
Changing the path later may result in data loss.
<br />
You <b>must</b> restart Fredy immediately after changing this setting!
</div>
}
/>
<Input
type="text"
placeholder="Select folder"
value={sqlitePath}
onChange={(value) => {
setSqlitePath(value);
}}
/>
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart
name="Working hours"
helpText="During this hours, Fredy will search for new apartments. If nothing is configured, Fredy will search around the clock."

View File

@@ -1,9 +1,9 @@
import React from 'react';
import JobTable from '../../components/table/JobTable';
import { useSelector, useDispatch } from 'react-redux';
import { useSelector, useActions } from '../../services/state/store';
import { xhrDelete, xhrPut } from '../../services/xhr';
import { useHistory } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import ProcessingTimes from './ProcessingTimes';
import { Button, Toast } from '@douyinfe/semi-ui';
import { IconPlusCircle } from '@douyinfe/semi-icons';
@@ -12,14 +12,14 @@ import './Jobs.less';
export default function Jobs() {
const jobs = useSelector((state) => state.jobs.jobs);
const processingTimes = useSelector((state) => state.jobs.processingTimes);
const history = useHistory();
const dispatch = useDispatch();
const navigate = useNavigate();
const actions = useActions();
const onJobRemoval = async (jobId) => {
try {
await xhrDelete('/api/jobs', { jobId });
Toast.success('Job successfully remove');
await dispatch.jobs.getJobs();
await actions.jobs.getJobs();
} catch (error) {
Toast.error(error);
}
@@ -29,7 +29,7 @@ export default function Jobs() {
try {
await xhrPut(`/api/jobs/${jobId}/status`, { status });
Toast.success('Job status successfully changed');
await dispatch.jobs.getJobs();
await actions.jobs.getJobs();
} catch (error) {
Toast.error(error);
}
@@ -43,7 +43,7 @@ export default function Jobs() {
type="primary"
icon={<IconPlusCircle />}
className="jobs__newButton"
onClick={() => history.push('/jobs/new')}
onClick={() => navigate('/jobs/new')}
>
New Job
</Button>
@@ -53,8 +53,8 @@ export default function Jobs() {
jobs={jobs || []}
onJobRemoval={onJobRemoval}
onJobStatusChanged={onJobStatusChanged}
onJobInsight={(jobId) => history.push(`/jobs/insights/${jobId}`)}
onJobEdit={(jobId) => history.push(`/jobs/edit/${jobId}`)}
onJobInsight={(jobId) => navigate(`/jobs/insights/${jobId}`)}
onJobEdit={(jobId) => navigate(`/jobs/edit/${jobId}`)}
/>
</div>
);

View File

@@ -1,6 +1,8 @@
import React from 'react';
import { format } from '../../services/time/timeService';
import { Descriptions } from '@douyinfe/semi-ui';
import { Button, Descriptions, Toast } from '@douyinfe/semi-ui';
import { IconPlayCircle } from '@douyinfe/semi-icons';
import { xhrPost } from '../../services/xhr.js';
export default function ProcessingTimes({ processingTimes = {} }) {
if (Object.keys(processingTimes).length === 0) {
@@ -24,6 +26,19 @@ export default function ProcessingTimes({ processingTimes = {} }) {
<Descriptions.Item itemKey="Next run">
{format(processingTimes.lastRun + processingTimes.interval * 60000)}
</Descriptions.Item>
<Descriptions.Item itemKey="Find Listings now">
<Button
size="small"
icon={<IconPlayCircle />}
aria-label="Start now"
onClick={async () => {
await xhrPost('/api/jobs/startAll', null);
Toast.success('Successfully triggered Fredy search.');
}}
>
Search now
</Button>
</Descriptions.Item>
</>
)}
</Descriptions>

View File

@@ -1,46 +1,66 @@
import React from 'react';
import { roundToNext5Minute } from '../../../services/time/timeService';
import { roundToHour } from '../../../services/time/timeService';
import Headline from '../../../components/headline/Headline';
import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router';
import { useActions, useSelector } from '../../../services/state/store';
import { useParams } from 'react-router-dom';
import Linechart from './Linechart';
const JobInsight = function JobInsight() {
const dispatch = useDispatch();
const actions = useActions();
const insights = useSelector((state) => state.jobs.insights);
const jobs = useSelector((state) => state.jobs.jobs);
const params = useParams();
React.useEffect(() => {
dispatch.jobs.getInsightDataForJob(params.jobId);
dispatch.jobs.getJobs();
actions.jobs.getInsightDataForJob(params.jobId);
actions.jobs.getJobs();
}, []);
const getData = () => {
const data = insights[params.jobId] || {};
const providers = Object.keys(data);
const result = [];
Object.keys(data).forEach((key) => {
const series = {
name: key[0].toUpperCase() + key.substring(1),
data: [],
};
const countsByProvider = {};
const allTimes = new Set();
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : 'Unknown');
providers.forEach((key) => {
const providerName = cap(key);
const tmpTimeObj = {};
Object.values(data[key] || {}).forEach((listingTs) => {
const time = roundToNext5Minute(listingTs);
const time = roundToHour(listingTs);
tmpTimeObj[time] = tmpTimeObj[time] == null ? 1 : tmpTimeObj[time] + 1;
allTimes.add(time);
});
Object.keys(tmpTimeObj)
.sort()
.forEach((timeKey) => {
series.data.push([parseInt(timeKey), tmpTimeObj[timeKey]]);
countsByProvider[providerName] = tmpTimeObj;
});
const sortedTimes = Array.from(allTimes).sort((a, b) => a - b);
const result = [];
providers.forEach((key) => {
const providerName = cap(key);
const bucket = countsByProvider[providerName] || {};
sortedTimes.forEach((t) => {
result.push({
listings: new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(new Date(parseInt(t))),
listingsNumber: bucket[t] || 0, // y value
provider: providerName, // series key
});
result.push(series);
});
});
return result;

View File

@@ -1,337 +1,49 @@
import React from 'react';
import Placeholder from '../../../components/placeholder/Placeholder';
import HighchartsReact from 'highcharts-react-official';
import Highcharts from 'highcharts/highcharts.src.js';
import { VChart } from '@visactor/react-vchart';
import './Linechart.less';
Highcharts.theme = {
colors: [
'#2b908f',
'#90ee7e',
'#f45b5b',
'#7798BF',
'#aaeeee',
'#ff0066',
'#eeaaee',
'#55BF3B',
'#DF5353',
'#7798BF',
'#aaeeee',
const commonSpec = {
type: 'line',
xField: 'listings',
yField: 'listingsNumber',
seriesField: 'provider',
legends: { visible: true },
line: {
style: {
lineWidth: 2,
},
},
point: {
visible: false,
},
axes: [
{
orient: 'bottom',
field: 'listings',
zero: false,
},
],
chart: {
backgroundColor: {
linearGradient: {
x1: 0,
y1: 0,
x2: 1,
y2: 1,
},
stops: [
[0, '#2a2a2b'],
[1, '#3e3e40'],
],
},
style: {
fontFamily: "'Unica One', sans-serif",
},
plotBorderColor: '#606063',
},
title: {
style: {
color: '#E0E0E3',
textTransform: 'uppercase',
fontSize: '20px',
},
},
subtitle: {
style: {
color: '#E0E0E3',
textTransform: 'uppercase',
},
},
xAxis: {
gridLineColor: '#707073',
labels: {
style: {
color: '#E0E0E3',
},
},
lineColor: '#707073',
minorGridLineColor: '#505053',
tickColor: '#707073',
title: {
style: {
color: '#A0A0A3',
},
},
},
yAxis: {
gridLineColor: '#707073',
labels: {
style: {
color: '#E0E0E3',
},
},
lineColor: '#707073',
minorGridLineColor: '#505053',
tickColor: '#707073',
tickWidth: 1,
title: {
style: {
color: '#A0A0A3',
},
},
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.85)',
style: {
color: '#F0F0F0',
},
},
plotOptions: {
series: {
dataLabels: {
color: '#F0F0F3',
style: {
fontSize: '13px',
},
},
marker: {
lineColor: '#333',
},
},
boxplot: {
fillColor: '#505053',
},
candlestick: {
lineColor: 'white',
},
errorbar: {
color: 'white',
},
},
legend: {
backgroundColor: 'rgba(0, 0, 0, 0.5)',
itemStyle: {
color: '#E0E0E3',
},
itemHoverStyle: {
color: '#FFF',
},
itemHiddenStyle: {
color: '#606063',
},
title: {
style: {
color: '#C0C0C0',
},
},
},
credits: {
style: {
color: '#666',
},
},
labels: {
style: {
color: '#707073',
},
},
drilldown: {
activeAxisLabelStyle: {
color: '#F0F0F3',
},
activeDataLabelStyle: {
color: '#F0F0F3',
},
},
navigation: {
buttonOptions: {
symbolStroke: '#DDDDDD',
theme: {
fill: '#505053',
},
},
},
// scroll charts
rangeSelector: {
buttonTheme: {
fill: '#505053',
stroke: '#000000',
style: {
color: '#CCC',
},
states: {
hover: {
fill: '#707073',
stroke: '#000000',
style: {
color: 'white',
},
},
select: {
fill: '#000003',
stroke: '#000000',
style: {
color: 'white',
},
},
},
},
inputBoxBorderColor: '#505053',
inputStyle: {
backgroundColor: '#333',
color: 'silver',
},
labelStyle: {
color: 'silver',
},
},
navigator: {
handles: {
backgroundColor: '#666',
borderColor: '#AAA',
},
outlineColor: '#CCC',
maskFill: 'rgba(255,255,255,0.1)',
series: {
color: '#7798BF',
lineColor: '#A6C7ED',
},
xAxis: {
gridLineColor: '#505053',
},
},
scrollbar: {
barBackgroundColor: '#808083',
barBorderColor: '#808083',
buttonArrowColor: '#CCC',
buttonBackgroundColor: '#606063',
buttonBorderColor: '#606063',
rifleColor: '#FFF',
trackBackgroundColor: '#404043',
trackBorderColor: '#404043',
},
};
// Apply the theme
Highcharts.setOptions(Highcharts.theme);
const defaultOptions = {
title: {
text: null,
},
legend: {
enabled: true,
},
xAxis: {
//most of the time (if not everytime), the x axis is time
type: 'datetime',
crosshair: {
snap: false,
},
},
yAxis: {
title: {
text: null,
},
//do not show float numbers
allowDecimals: false,
},
chart: {
type: 'line',
zoomType: 'x',
plotBackgroundColor: null,
plotBorderWidth: null,
},
exporting: {
enabled: false,
},
tooltip: {
shared: true,
formatter: null,
},
plotOptions: {
line: {
animation: false,
marker: {
enabled: false,
},
},
series: {
lineWidth: 1.5,
connectNulls: true,
marker: {
enabled: false,
},
},
},
series: [],
};
/**
* Usage of this chart:
* title: optional (show a title, if null, no title is shown)
* zoom: optional (if true, zooming in x axis is possible)
* legend: optional (show / hide the legend)
* series: mandatory (an array of data to be shown)
*
* <Linechart
* title="something"
* legend={true}
* timeframe={week/month/all} --> If this is not set, we assume the timeframe is 'all'
* //everything that is "subscribed" to this topic will receive this update
* highlightTopic="someTopic"
* height={"500px"}
* zoom={true}
* series={[
* {
* name: 'something',
* data: [x,y],
* dashStyle: (OPTIONAL) | solid / 'shortdot'
* }
* ]}
* />
*/
const Linechart = function Linechart({ title, series, height, isLoading = false }) {
const options = () => {
return {
...defaultOptions,
title: {
text: title,
},
time: {
useUTC: false,
},
legend: {
enabled: true,
},
series: series.map((series) => {
return {
...series,
};
}),
chart: {
type: 'line',
zoomType: 'x',
height: height || '400px',
},
};
};
const Linechart = function Linechart({ title, series, isLoading = false }) {
return (
<Placeholder ready={!isLoading} rows={6}>
{series == null || series.length === 0 ? (
<div className="linechart__no__data">No Data for selected timeframe :-/</div>
) : (
<HighchartsReact highcharts={Highcharts} options={options()} />
<VChart
spec={{
...commonSpec,
title: {
visible: true,
text: title,
},
data: { values: series },
}}
/>
)}
</Placeholder>
);

View File

@@ -5,10 +5,9 @@ import NotificationAdapterTable from '../../../components/table/NotificationAdap
import ProviderTable from '../../../components/table/ProviderTable';
import ProviderMutator from './components/provider/ProviderMutator';
import Headline from '../../../components/headline/Headline';
import { useDispatch, useSelector } from 'react-redux';
import { useActions, useSelector } from '../../../services/state/store';
import { xhrPost } from '../../../services/xhr';
import { useHistory } from 'react-router-dom';
import { useParams } from 'react-router';
import { useNavigate, useParams } from 'react-router-dom';
import { Divider, Input, Switch, Button, TagInput, Toast } from '@douyinfe/semi-ui';
import './JobMutation.less';
import { SegmentPart } from '../../../components/segment/SegmentPart';
@@ -34,8 +33,8 @@ export default function JobMutator() {
const [blacklist, setBlacklist] = useState(defaultBlacklist);
const [notificationAdapterData, setNotificationAdapterData] = useState(defaultNotificationAdapter);
const [enabled, setEnabled] = useState(defaultEnabled);
const history = useHistory();
const dispatch = useDispatch();
const navigate = useNavigate();
const actions = useActions();
const isSavingEnabled = () => {
return Boolean(notificationAdapterData.length && providerData.length && name);
@@ -51,9 +50,9 @@ export default function JobMutator() {
enabled,
jobId: jobToBeEdit?.id || null,
});
await dispatch.jobs.getJobs();
await actions.jobs.getJobs();
Toast.success('Job successfully saved...');
history.push('/jobs');
navigate('/jobs');
} catch (Exception) {
console.error(Exception.json.message);
Toast.error(Exception.json != null ? Exception.json.message : Exception);
@@ -177,7 +176,7 @@ export default function JobMutator() {
<Switch className="jobMutation__spaceTop" onChange={(checked) => setEnabled(checked)} checked={enabled} />
</SegmentPart>
<Divider margin="1rem" />
<Button type="danger" style={{ marginRight: '1rem' }} onClick={() => history.push('/jobs')}>
<Button type="danger" style={{ marginRight: '1rem' }} onClick={() => navigate('/jobs')}>
Cancel
</Button>
<Button type="primary" icon={<IconPlusCircle />} disabled={!isSavingEnabled()} onClick={mutateJob}>

View File

@@ -3,7 +3,7 @@ import React, { useState } from 'react';
import { transform } from '../../../../../services/transformer/notificationAdapterTransformer';
import { xhrPost } from '../../../../../services/xhr';
import Help from './NotificationHelpDisplay';
import { useSelector } from 'react-redux';
import { useSelector } from '../../../../../services/state/store';
import { Banner, Button, Form, Modal, Select, Switch } from '@douyinfe/semi-ui';
import './NotificationAdapterMutator.less';

View File

@@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { Banner, Modal, Select, Input } from '@douyinfe/semi-ui';
import { transform } from '../../../../../services/transformer/providerTransformer';
import { useSelector } from 'react-redux';
import { useSelector } from '../../../../../services/state/store';
import { IconLikeHeart } from '@douyinfe/semi-icons';
import './ProviderMutator.less';
@@ -30,7 +30,8 @@ export default function ProviderMutator({ onVisibilityChanged, visible = false,
if (selectedProvider.baseUrl.indexOf(url.origin) === -1) {
return 'The url you have copied is not valid.';
}
} catch (Exception) {
/* eslint-disable no-unused-vars */
} catch (ignored) {
return 'The url you have copied is not valid.';
}
return null;

View File

@@ -3,24 +3,24 @@ import React, { useEffect } from 'react';
import cityBackground from '../../assets/city_background.jpg';
import Logo from '../../components/logo/Logo';
import { xhrPost } from '../../services/xhr';
import { useHistory } from 'react-router';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import { useActions, useSelector } from '../../services/state/store';
import { Input, Button, Banner, Toast } from '@douyinfe/semi-ui';
import './login.less';
import { IconUser, IconLock } from '@douyinfe/semi-icons';
export default function Login() {
const dispatch = useDispatch();
const actions = useActions();
const [username, setUserName] = React.useState('');
const [password, setPassword] = React.useState('');
const [error, setError] = React.useState(null);
const demoMode = useSelector((state) => state.demoMode.demoMode || false);
const history = useHistory();
const navigate = useNavigate();
useEffect(() => {
async function init() {
await dispatch.demoMode.getDemoMode();
await actions.demoMode.getDemoMode();
}
init();
@@ -38,15 +38,16 @@ export default function Login() {
username: username.trim(),
password,
});
} catch (Exception) {
/* eslint-disable no-unused-vars */
} catch (ignored) {
Toast.error('Login unsuccessful…');
return;
}
Toast.success('Login successful!');
await dispatch.user.getCurrentUser();
history.push('/jobs');
await actions.user.getCurrentUser();
navigate('/jobs');
};
return (

View File

@@ -2,25 +2,25 @@ import React from 'react';
import { Toast } from '@douyinfe/semi-ui';
import UserTable from '../../components/table/UserTable';
import { useDispatch, useSelector } from 'react-redux';
import { useActions, useSelector } from '../../services/state/store';
import { IconPlus } from '@douyinfe/semi-icons';
import { Button } from '@douyinfe/semi-ui';
import UserRemovalModal from './UserRemovalModal';
import { xhrDelete } from '../../services/xhr';
import { useHistory } from 'react-router';
import { useNavigate } from 'react-router-dom';
import './Users.less';
const Users = function Users() {
const dispatch = useDispatch();
const actions = useActions();
const [loading, setLoading] = React.useState(true);
const users = useSelector((state) => state.user.users);
const [userIdToBeRemoved, setUserIdToBeRemoved] = React.useState(null);
const history = useHistory();
const navigate = useNavigate();
React.useEffect(() => {
async function init() {
await dispatch.user.getUsers();
await actions.user.getUsers();
setLoading(false);
}
@@ -32,8 +32,8 @@ const Users = function Users() {
await xhrDelete('/api/admin/users', { userId: userIdToBeRemoved });
Toast.success('User successfully remove');
setUserIdToBeRemoved(null);
await dispatch.jobs.getJobs();
await dispatch.user.getUsers();
await actions.jobs.getJobs();
await actions.user.getUsers();
} catch (error) {
Toast.error(error);
setUserIdToBeRemoved(null);
@@ -50,7 +50,7 @@ const Users = function Users() {
type="primary"
className="users__newButton"
icon={<IconPlus />}
onClick={() => history.push('/users/new')}
onClick={() => navigate('/users/new')}
>
Create new User
</Button>
@@ -58,7 +58,7 @@ const Users = function Users() {
<UserTable
user={users}
onUserEdit={(userId) => {
history.push(`/users/edit/${userId}`);
navigate(`/users/edit/${userId}`);
}}
onUserRemoval={(userId) => {
setUserIdToBeRemoved(userId);

View File

@@ -1,8 +1,8 @@
import React from 'react';
import { xhrGet, xhrPost } from '../../../services/xhr';
import { useHistory, useParams } from 'react-router';
import { useDispatch } from 'react-redux';
import { useNavigate, useParams } from 'react-router-dom';
import { useActions } from '../../../services/state/store';
import { Divider, Input, Switch, Button, Toast } from '@douyinfe/semi-ui';
import './UserMutator.less';
import { SegmentPart } from '../../../components/segment/SegmentPart';
@@ -15,8 +15,8 @@ const UserMutator = function UserMutator() {
const [password2, setPassword2] = React.useState('');
const [isAdmin, setIsAdmin] = React.useState(false);
const history = useHistory();
const dispatch = useDispatch();
const navigate = useNavigate();
const actions = useActions();
React.useEffect(() => {
async function init() {
@@ -48,9 +48,9 @@ const UserMutator = function UserMutator() {
password2,
isAdmin,
});
await dispatch.user.getUsers();
await actions.user.getUsers();
Toast.success('User successfully saved...');
history.push('/users');
navigate('/users');
} catch (error) {
console.error(error);
Toast.error(error.json.message);
@@ -98,7 +98,7 @@ const UserMutator = function UserMutator() {
<Switch checked={isAdmin} onChange={(checked) => setIsAdmin(checked)} />
</SegmentPart>
<Divider margin="1rem" />
<Button type="danger" style={{ marginRight: '1rem' }} onClick={() => history.push('/users')}>
<Button type="danger" style={{ marginRight: '1rem' }} onClick={() => navigate('/users')}>
Cancel
</Button>
<Button type="primary" icon={<IconPlusCircle />} onClick={saveUser}>

1310
yarn.lock

File diff suppressed because it is too large Load Diff