mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8f911aa00 | ||
|
|
13b8701447 | ||
|
|
e25b956eda | ||
|
|
a2c769f786 | ||
|
|
1825a25eaa | ||
|
|
0f20b85f38 | ||
|
|
d17ef9ef1e | ||
|
|
337ee922a6 | ||
|
|
b3ae5f640c | ||
|
|
8f91267b5d | ||
|
|
3d59c0096d | ||
|
|
dab6e4edf3 | ||
|
|
e1c45f18e0 | ||
|
|
5cceae11cc | ||
|
|
a4c5bfcbf7 | ||
|
|
6d2ab5f958 | ||
|
|
d3cb3a5881 | ||
|
|
111ef8be43 | ||
|
|
35feb772d7 | ||
|
|
1bf012f13e | ||
|
|
933dc3fc64 | ||
|
|
42c48fdceb | ||
|
|
f07aa0a06d | ||
|
|
92db8219b4 | ||
|
|
8ba3a53779 | ||
|
|
e7db4e23f5 | ||
|
|
06c4ebb975 | ||
|
|
b075e09ac2 | ||
|
|
f215ab53db | ||
|
|
4ed92b246f | ||
|
|
4a9b60633a | ||
|
|
2123c1024b | ||
|
|
35767e6774 | ||
|
|
bf77ba2667 | ||
|
|
827c7e7321 | ||
|
|
7b63dc72cb | ||
|
|
fd42b57010 | ||
|
|
f5917af8f3 | ||
|
|
a85400d570 | ||
|
|
8ce6668c78 | ||
|
|
2d8121a708 | ||
|
|
172c039c79 | ||
|
|
4ab1fd9294 | ||
|
|
50b3fde075 | ||
|
|
1a3fc6f94d | ||
|
|
26ed42230a | ||
|
|
6f4defdc1b | ||
|
|
f798aed342 | ||
|
|
27e098c244 | ||
|
|
37948be0d3 | ||
|
|
cc7bbb77c4 | ||
|
|
96da0b7892 | ||
|
|
72993312c7 | ||
|
|
17b4bad2e4 | ||
|
|
fbad4456d7 | ||
|
|
deec626feb | ||
|
|
88c6641485 | ||
|
|
f4eedda658 | ||
|
|
d2b80561f8 | ||
|
|
3bda88a075 | ||
|
|
86465e0076 | ||
|
|
d947dad488 | ||
|
|
23ef434fe1 | ||
|
|
5e6d92c5be | ||
|
|
4ba098e0b6 | ||
|
|
2d1a9a0452 | ||
|
|
6fbee3e7c6 | ||
|
|
46775c3662 | ||
|
|
1feb5bfda1 | ||
|
|
3ec9ed3b2a | ||
|
|
75a536d5ab | ||
|
|
f3cded7e5d | ||
|
|
d7c9c4bf76 |
@@ -203,10 +203,6 @@ module.exports = {
|
|||||||
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/self-closing-comp.md
|
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/self-closing-comp.md
|
||||||
'react/self-closing-comp': 'warn',
|
'react/self-closing-comp': 'warn',
|
||||||
|
|
||||||
// Enforce spaces before the closing bracket of self-closing JSX elements
|
|
||||||
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-space-before-closing.md
|
|
||||||
'react/jsx-space-before-closing': ['warn', 'always'],
|
|
||||||
|
|
||||||
// Enforce component methods order
|
// Enforce component methods order
|
||||||
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/sort-comp.md
|
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/sort-comp.md
|
||||||
'react/sort-comp': 'off',
|
'react/sort-comp': 'off',
|
||||||
@@ -237,7 +233,7 @@ module.exports = {
|
|||||||
|
|
||||||
// only .jsx files may have JSX
|
// only .jsx files may have JSX
|
||||||
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-filename-extension.md
|
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-filename-extension.md
|
||||||
'react/jsx-filename-extension': ['error', { extensions: ['.js'] }],
|
'react/jsx-filename-extension': ['error', { extensions: ['.jsx'] }],
|
||||||
|
|
||||||
// prevent accidental JS comments from being injected into JSX as text
|
// prevent accidental JS comments from being injected into JSX as text
|
||||||
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-no-comment-textnodes.md
|
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-no-comment-textnodes.md
|
||||||
@@ -282,15 +278,5 @@ module.exports = {
|
|||||||
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/no-children-prop.md
|
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/no-children-prop.md
|
||||||
'react/no-children-prop': 'warn',
|
'react/no-children-prop': 'warn',
|
||||||
|
|
||||||
// Validate whitespace in and around the JSX opening and closing brackets
|
|
||||||
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-tag-spacing.md
|
|
||||||
'react/jsx-tag-spacing': [
|
|
||||||
'warn',
|
|
||||||
{
|
|
||||||
closingSlash: 'never',
|
|
||||||
beforeSelfClosing: 'always',
|
|
||||||
afterOpening: 'never',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
1
.github/workflows/docker.yml
vendored
1
.github/workflows/docker.yml
vendored
@@ -44,3 +44,4 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
platforms: linux/amd64, linux/arm64
|
||||||
|
|||||||
21
.github/workflows/stales.yml
vendored
Normal file
21
.github/workflows/stales.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
name: "Close stale issues and PRs"
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 0 * * *' # Daily
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
stale:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/stale@v7
|
||||||
|
with:
|
||||||
|
days-before-stale: 30
|
||||||
|
days-before-close: 7
|
||||||
|
stale-issue-message: "This issue has been automatically marked as stale due to inactivity."
|
||||||
|
stale-pr-message: "This PR has been automatically marked as stale due to inactivity."
|
||||||
|
close-issue-message: "Closing this issue due to prolonged inactivity."
|
||||||
|
close-pr-message: "Closing this PR due to prolonged inactivity."
|
||||||
|
exempt-issue-labels: "keep-open"
|
||||||
|
exempt-pr-labels: "keep-open"
|
||||||
|
only: "pulls"
|
||||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
|||||||
- name: Setup node
|
- name: Setup node
|
||||||
uses: actions/setup-node@v2.5.1
|
uses: actions/setup-node@v2.5.1
|
||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 20
|
||||||
cache: 'yarn'
|
cache: 'yarn'
|
||||||
- run: yarn install
|
- run: yarn install
|
||||||
- run: yarn run test
|
- run: yarn run test
|
||||||
|
|||||||
1
.husky/pre-commit
Executable file
1
.husky/pre-commit
Executable file
@@ -0,0 +1 @@
|
|||||||
|
npx lint-staged
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
sudo: false
|
|
||||||
language: node_js
|
|
||||||
@@ -106,9 +106,7 @@ exports.config = {
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Running Tests
|
#### Running Tests
|
||||||
If you've written a new provider you are an awesome person. You know it and I do. If you now write tests for it, you are even more awesome. And who doesn't want to be more awesome right?
|
If you've written a new provider you are an awesome person. If you now write tests for it, you are even more awesome. And who doesn't want to be more awesome right?
|
||||||
|
|
||||||
To write tests for provider, you need to use Node 8 as the tests are using `async / await`
|
|
||||||
|
|
||||||
#### Codestyle
|
#### Codestyle
|
||||||
I'm using Eslint to maintain quote style and quality. Do not skip it...
|
I'm using Eslint to maintain quote style and quality. Do not skip it...
|
||||||
|
|||||||
22
Dockerfile
22
Dockerfile
@@ -1,18 +1,20 @@
|
|||||||
# syntax=docker/dockerfile:1.3
|
FROM node:20
|
||||||
FROM node:16-alpine AS builder
|
|
||||||
COPY --chown=1000:1000 . /fredy
|
|
||||||
WORKDIR /fredy
|
WORKDIR /fredy
|
||||||
USER 1000
|
|
||||||
|
COPY . /fredy
|
||||||
|
|
||||||
RUN yarn install
|
RUN yarn install
|
||||||
|
|
||||||
|
RUN yarn global add pm2
|
||||||
|
|
||||||
RUN yarn run prod
|
RUN yarn run prod
|
||||||
|
|
||||||
FROM node:16-alpine
|
|
||||||
COPY --from=builder --chown=1000:1000 /fredy /fredy
|
|
||||||
RUN mkdir /db /conf && \
|
RUN mkdir /db /conf && \
|
||||||
chown 1000:1000 /db /conf && \
|
chown 1000:1000 /db /conf && \
|
||||||
|
chmod 777 -R /db/ && \
|
||||||
ln -s /db /fredy/db && ln -s /conf /fredy/conf
|
ln -s /db /fredy/db && ln -s /conf /fredy/conf
|
||||||
|
|
||||||
EXPOSE 9998
|
EXPOSE 9998
|
||||||
USER 1000
|
|
||||||
VOLUME [ "/conf", "/db" ]
|
CMD pm2-runtime index.js
|
||||||
WORKDIR /fredy
|
|
||||||
CMD node index.js --no-daemon
|
|
||||||
|
|||||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2021 Christian Kellner
|
Copyright (c) 2024 Christian Kellner
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
38
README.md
38
README.md
@@ -15,9 +15,12 @@ If you like my work, consider becoming a sponsor. I'm not expecting anybody to p
|
|||||||
|
|
||||||
_Fredy_ is supported by JetBrains under Open Source Support Program
|
_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
|
## Usage
|
||||||
|
|
||||||
- Make sure to use Node.js 16 or above
|
- Make sure to use Node.js 20 or above
|
||||||
- Run the following commands:
|
- Run the following commands:
|
||||||
```ssh
|
```ssh
|
||||||
yarn (or npm install)
|
yarn (or npm install)
|
||||||
@@ -27,14 +30,11 @@ yarn run start
|
|||||||
_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.
|
_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">
|
<p align="center">
|
||||||
<img alt="Job Configuration" src="https://github.com/orangecoding/fredy/blob/master/doc/screenshot__1.png" width="30%">
|
<img alt="Job Configuration" src="https://github.com/orangecoding/fredy/blob/master/doc/screenshot1.png" width="30%">
|
||||||
|
|
||||||
<img alt="Job Analytics" src="https://github.com/orangecoding/fredy/blob/master/doc/screenshot2.png" width="30%">
|
<img alt="Job Analytics" src="https://github.com/orangecoding/fredy/blob/master/doc/screenshot_2.png" width="30%">
|
||||||
|
|
||||||
<img alt="Job Overview" width="30%" src="https://github.com/orangecoding/fredy/blob/master/doc/screenshot3.png">
|
<img alt="Job Overview" width="30%" src="https://github.com/orangecoding/fredy/blob/master/doc/screenshot_3.png">
|
||||||
</p>
|
|
||||||
<p align="center">
|
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Understanding the fundamentals
|
## Understanding the fundamentals
|
||||||
@@ -81,17 +81,20 @@ yarn run test
|
|||||||
# Architecture
|
# Architecture
|
||||||

|

|
||||||
|
|
||||||
### Immoscout
|
### Immoscout / Immonet / NeubauKompass
|
||||||
I have added **experimental** support for Immoscout. Immoscout is somewhat special, because they have decided to secure their service from bots using Re-Capture. Finding a way around this is barely possible. For _Fredy_ to be able to bypass this check, I'm using a service called [ScrapingAnt](https://scrapingant.com/). The trick is to use a headless browser, rotating proxies and (once successfully validated) to re-send the cookies each time.
|
I have added **experimental** support for Immoscout, Immonet and NeubauKompass. They all are somewhat special, because they have decided to secure their service from bots using Re-Capture. Finding a way around this is barely possible. For _Fredy_ to be able to bypass this check, I'm using a service called [ScrapingAnt](https://scrapingant.com/). The trick is to use a headless browser, rotating proxies and (once successfully validated) to re-send the cookies each time.
|
||||||
|
|
||||||
To be able to use Immoscout, you need to create an account at ScrapingAnt. Configure the API key in the "General Settings" tab (visible when logged in as administrator).
|
To be able to use Immoscout / Immonet, you need to create an account at ScrapingAnt. Configure the API key in the "General Settings" tab (visible when logged in as administrator).
|
||||||
The rest will be handled by _Fredy_. Keep in mind, the support is experimental. There might be bugs and you might not always pass the re-capture check, but most of the time it works rather well :)
|
The rest will be handled by _Fredy_. Keep in mind, the support is experimental. There might be bugs and you might not always pass the re-capture check, but most of the time it works rather well :)
|
||||||
|
|
||||||
If you need more than the 1000 API calls allowed per month, I'd suggest opting for a paid account... ScrapingAnt loves OpenSource, therefore they have decided to give all _Fredy_ users a 10% discount by using the code **FREDY10** (Disclaimer: I do not earn any money for recommending their service).
|
If you need more than the 1000 API calls allowed per month, I'd suggest opting for a paid account... ScrapingAnt loves OpenSource, therefore they have decided to give all _Fredy_ users a 10% discount by using the code **FREDY10** (Disclaimer: I do not earn any money for recommending their service).
|
||||||
|
|
||||||
### Contribution guidelines
|
# Analytics
|
||||||
|
Fredy is completely free (and will always remain free). However, it would be a huge help if you’d allow me to collect some analytical data.
|
||||||
See [Contributing](https://github.com/orangecoding/fredy/blob/master/CONTRIBUTING.md)
|
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**🤘
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
Use the Dockerfile in this repository to build an image.
|
Use the Dockerfile in this repository to build an image.
|
||||||
@@ -112,6 +115,15 @@ 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`
|
Example: `docker create --name fredy -v /path/to/your/conf/:/conf -p 9998:9998 fredy/fredy`
|
||||||
|
|
||||||
|
### 👐 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)
|
||||||
|
|
||||||
## Logs
|
## Logs
|
||||||
|
|
||||||
You can browse the logs with `docker logs fredy -f`.
|
You can browse the logs with `docker logs fredy -f`.
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"interval":"60","port":9998,"scrapingAnt":{"apiKey":"","proxy":"datacenter"},"workingHours":{"from":"","to":""}}
|
{"interval":"60","port":9998,"scrapingAnt":{"apiKey":"","proxy":"datacenter"},"workingHours":{"from":"","to":""},"demoMode":false,"analyticsEnabled":null}
|
||||||
|
|||||||
BIN
doc/screenshot1.png
Normal file
BIN
doc/screenshot1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 279 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 380 KiB |
BIN
doc/screenshot_2.png
Normal file
BIN
doc/screenshot_2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 323 KiB |
BIN
doc/screenshot_3.png
Normal file
BIN
doc/screenshot_3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 202 KiB |
@@ -1,4 +1,4 @@
|
|||||||
version: '3.3'
|
version: '3.8'
|
||||||
services:
|
services:
|
||||||
fredy:
|
fredy:
|
||||||
container_name: fredy
|
container_name: fredy
|
||||||
@@ -13,3 +13,4 @@ services:
|
|||||||
- ./db:/db
|
- ./db:/db
|
||||||
ports:
|
ports:
|
||||||
- 9998:9998
|
- 9998:9998
|
||||||
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -4,12 +4,11 @@
|
|||||||
<meta charset="UTF-8"
|
<meta charset="UTF-8"
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1">
|
content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1">
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/semantic-ui@2/dist/semantic.min.css">
|
|
||||||
<meta name="google" content="notranslate">
|
<meta name="google" content="notranslate">
|
||||||
|
|
||||||
<title>Fredy</title>
|
<title>Fredy</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body theme-mode="dark">
|
||||||
|
|
||||||
<div id="fredy" style="position: absolute;top: 0;left: 0;right: 0;bottom: 0;"></div>
|
<div id="fredy" style="position: absolute;top: 0;left: 0;right: 0;bottom: 0;"></div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
54
index.js
54
index.js
@@ -1,11 +1,14 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { config } from './lib/utils.js';
|
import {config} from './lib/utils.js';
|
||||||
import * as similarityCache from './lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from './lib/services/similarity-check/similarityCache.js';
|
||||||
import { setLastJobExecution } from './lib/services/storage/listingsStorage.js';
|
import { setLastJobExecution } from './lib/services/storage/listingsStorage.js';
|
||||||
import * as jobStorage from './lib/services/storage/jobStorage.js';
|
import * as jobStorage from './lib/services/storage/jobStorage.js';
|
||||||
import FredyRuntime from './lib/FredyRuntime.js';
|
import FredyRuntime from './lib/FredyRuntime.js';
|
||||||
import { duringWorkingHoursOrNotSet } from './lib/utils.js';
|
import { duringWorkingHoursOrNotSet } from './lib/utils.js';
|
||||||
import './lib/api/api.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 db folder does not exist, ensure to create it before loading anything else
|
||||||
if (!fs.existsSync('./db')) {
|
if (!fs.existsSync('./db')) {
|
||||||
fs.mkdirSync('./db');
|
fs.mkdirSync('./db');
|
||||||
@@ -16,34 +19,43 @@ const provider = fs.readdirSync(path).filter((file) => file.endsWith('.js'));
|
|||||||
const INTERVAL = config.interval * 60 * 1000;
|
const INTERVAL = config.interval * 60 * 1000;
|
||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
console.log(`Started Fredy successfully. Ui can be accessed via http://localhost:${config.port}`);
|
console.log(`Started Fredy successfully. Ui can be accessed via http://localhost:${config.port}`);
|
||||||
|
if(config.demoMode){
|
||||||
|
console.info('Running in demo mode');
|
||||||
|
cleanupDemoAtMidnight();
|
||||||
|
}
|
||||||
/* eslint-enable no-console */
|
/* eslint-enable no-console */
|
||||||
const fetchedProvider = await Promise.all(
|
const fetchedProvider = await Promise.all(
|
||||||
provider.filter((provider) => provider.endsWith('.js')).map(async (pro) => import(`${path}/${pro}`))
|
provider.filter((provider) => provider.endsWith('.js')).map(async (pro) => import(`${path}/${pro}`))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
handleDemoUser();
|
||||||
|
|
||||||
setInterval(
|
setInterval(
|
||||||
(function exec() {
|
(function exec() {
|
||||||
const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(config, Date.now());
|
const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(config, Date.now());
|
||||||
if (isDuringWorkingHoursOrNotSet) {
|
if(!config.demoMode) {
|
||||||
config.lastRun = Date.now();
|
if (isDuringWorkingHoursOrNotSet) {
|
||||||
jobStorage
|
track();
|
||||||
.getJobs()
|
config.lastRun = Date.now();
|
||||||
.filter((job) => job.enabled)
|
jobStorage
|
||||||
.forEach((job) => {
|
.getJobs()
|
||||||
job.provider
|
.filter((job) => job.enabled)
|
||||||
.filter((p) => fetchedProvider.find((fp) => fp.metaInformation.id === p.id) != null)
|
.forEach((job) => {
|
||||||
.forEach(async (prov) => {
|
job.provider
|
||||||
const pro = fetchedProvider.find((fp) => fp.metaInformation.id === prov.id);
|
.filter((p) => fetchedProvider.find((fp) => fp.metaInformation.id === p.id) != null)
|
||||||
pro.init(prov, job.blacklist);
|
.forEach(async (prov) => {
|
||||||
await new FredyRuntime(pro.config, job.notificationAdapter, prov.id, job.id, similarityCache).execute();
|
const pro = fetchedProvider.find((fp) => fp.metaInformation.id === prov.id);
|
||||||
setLastJobExecution(job.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.');
|
} else {
|
||||||
/* eslint-enable no-console */
|
/* eslint-disable no-console */
|
||||||
}
|
console.debug('Working hours set. Skipping as outside of working hours.');
|
||||||
|
/* eslint-enable no-console */
|
||||||
|
}
|
||||||
|
}
|
||||||
return exec;
|
return exec;
|
||||||
})(),
|
})(),
|
||||||
INTERVAL
|
INTERVAL
|
||||||
|
|||||||
@@ -45,15 +45,15 @@ class FredyRuntime {
|
|||||||
_getListings(url) {
|
_getListings(url) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const id = this._providerId;
|
const id = this._providerId;
|
||||||
if (scrapingAnt.isImmoscout(id) && !scrapingAnt.isScrapingAntApiKeySet()) {
|
if (scrapingAnt.needScrapingAnt(id) && !scrapingAnt.isScrapingAntApiKeySet()) {
|
||||||
const error = 'Immoscout can only be used with if you have set an apikey for scrapingAnt.';
|
const error = 'Immoscout or Immonet can only be used with if you have set an apikey for scrapingAnt.';
|
||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
console.log(error);
|
console.log(error);
|
||||||
/* eslint-enable no-console */
|
/* eslint-enable no-console */
|
||||||
reject(error);
|
reject(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const u = scrapingAnt.isImmoscout(id) ? scrapingAnt.transformUrlForScrapingAnt(url, id) : url;
|
const u = scrapingAnt.needScrapingAnt(id) ? scrapingAnt.transformUrlForScrapingAnt(url, id) : url;
|
||||||
try {
|
try {
|
||||||
if (this._providerConfig.paginate != null) {
|
if (this._providerConfig.paginate != null) {
|
||||||
xray(u, this._providerConfig.crawlContainer, [this._providerConfig.crawlFields])
|
xray(u, this._providerConfig.crawlContainer, [this._providerConfig.crawlFields])
|
||||||
@@ -87,7 +87,10 @@ class FredyRuntime {
|
|||||||
return listings.map(this._providerConfig.normalize);
|
return listings.map(this._providerConfig.normalize);
|
||||||
}
|
}
|
||||||
_filter(listings) {
|
_filter(listings) {
|
||||||
return listings.filter(this._providerConfig.filter);
|
//only return those where all the fields have been found
|
||||||
|
const keys = Object.keys(this._providerConfig.crawlFields);
|
||||||
|
const filteredListings = listings.filter((item) => keys.every((key) => key in item));
|
||||||
|
return filteredListings.filter(this._providerConfig.filter);
|
||||||
}
|
}
|
||||||
_findNew(listings) {
|
_findNew(listings) {
|
||||||
const newListings = listings.filter((o) => getKnownListings(this._jobKey, this._providerId)[o.id] == null);
|
const newListings = listings.filter((o) => getKnownListings(this._jobKey, this._providerId)[o.id] == null);
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ import restana from 'restana';
|
|||||||
import files from 'serve-static';
|
import files from 'serve-static';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { getDirName } from '../utils.js';
|
import { getDirName } from '../utils.js';
|
||||||
|
import {demoRouter} from './routes/demoRouter.js';
|
||||||
const service = restana();
|
const service = restana();
|
||||||
const staticService = files(path.join(getDirName(), '../../ui/public'));
|
const staticService = files(path.join(getDirName(), '../ui/public'));
|
||||||
const PORT = config.port || 9998;
|
const PORT = config.port || 9998;
|
||||||
|
|
||||||
service.use(bodyParser.json());
|
service.use(bodyParser.json());
|
||||||
@@ -30,6 +31,9 @@ service.use('/api/jobs/insights', analyticsRouter);
|
|||||||
service.use('/api/admin/users', userRouter);
|
service.use('/api/admin/users', userRouter);
|
||||||
service.use('/api/jobs', jobRouter);
|
service.use('/api/jobs', jobRouter);
|
||||||
service.use('/api/login', loginRouter);
|
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 */
|
/* eslint-disable no-console */
|
||||||
service.start(PORT).then(() => {
|
service.start(PORT).then(() => {
|
||||||
console.info(`Started API service on port ${PORT}`);
|
console.info(`Started API service on port ${PORT}`);
|
||||||
|
|||||||
11
lib/api/routes/demoRouter.js
Normal file
11
lib/api/routes/demoRouter.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import restana from 'restana';
|
||||||
|
import {config} from '../../utils.js';
|
||||||
|
const service = restana();
|
||||||
|
const demoRouter = service.newRouter();
|
||||||
|
|
||||||
|
demoRouter.get('/', async (req, res) => {
|
||||||
|
res.body = Object.assign({}, {demoMode: config.demoMode});
|
||||||
|
res.send();
|
||||||
|
});
|
||||||
|
|
||||||
|
export { demoRouter };
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import restana from 'restana';
|
import restana from 'restana';
|
||||||
import { config, getDirName } from '../../utils.js';
|
import {config, getDirName, readConfigFromStorage, refreshConfig} from '../../utils.js';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import {handleDemoUser} from '../../services/storage/userStorage.js';
|
||||||
const service = restana();
|
const service = restana();
|
||||||
const generalSettingsRouter = service.newRouter();
|
const generalSettingsRouter = service.newRouter();
|
||||||
generalSettingsRouter.get('/', async (req, res) => {
|
generalSettingsRouter.get('/', async (req, res) => {
|
||||||
@@ -10,7 +11,14 @@ generalSettingsRouter.get('/', async (req, res) => {
|
|||||||
generalSettingsRouter.post('/', async (req, res) => {
|
generalSettingsRouter.post('/', async (req, res) => {
|
||||||
const settings = req.body;
|
const settings = req.body;
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify(settings));
|
if(config.demoMode){
|
||||||
|
res.send(new Error('In demo mode, it is not allowed to change these settings.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentConfig = await readConfigFromStorage();
|
||||||
|
fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({...currentConfig, ...settings}));
|
||||||
|
await refreshConfig();
|
||||||
|
handleDemoUser();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
res.send(new Error('Error while trying to write settings.'));
|
res.send(new Error('Error while trying to write settings.'));
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import * as userStorage from '../../services/storage/userStorage.js';
|
|||||||
import * as immoscoutProvider from '../../provider/immoscout.js';
|
import * as immoscoutProvider from '../../provider/immoscout.js';
|
||||||
import { config } from '../../utils.js';
|
import { config } from '../../utils.js';
|
||||||
import { isAdmin } from '../security.js';
|
import { isAdmin } from '../security.js';
|
||||||
|
import {isScrapingAntApiKeySet} from '../../services/scrapingAnt.js';
|
||||||
|
import {trackDemoJobCreated} from '../../services/tracking/Tracker.js';
|
||||||
const service = restana();
|
const service = restana();
|
||||||
const jobRouter = service.newRouter();
|
const jobRouter = service.newRouter();
|
||||||
function doesJobBelongsToUser(job, req) {
|
function doesJobBelongsToUser(job, req) {
|
||||||
@@ -25,10 +27,10 @@ jobRouter.get('/', async (req, res) => {
|
|||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
jobRouter.get('/processingTimes', async (req, res) => {
|
jobRouter.get('/processingTimes', async (req, res) => {
|
||||||
let scrapingAntData = null;
|
let scrapingAntData = {};
|
||||||
if (config.scrapingAnt.apiKey != null && config.scrapingAnt.apiKey.length > 0) {
|
if (isScrapingAntApiKeySet()) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`https://api.scrapingant.com/v1/usage?x-api-key=${config.scrapingAnt.apiKey}`);
|
const response = await fetch(`https://api.scrapingant.com/v2/usage?x-api-key=${config.scrapingAnt.apiKey}`);
|
||||||
scrapingAntData = await response.json();
|
scrapingAntData = await response.json();
|
||||||
} catch (Exception) {
|
} catch (Exception) {
|
||||||
console.error('Could not query plan data from scraping ant.', Exception);
|
console.error('Could not query plan data from scraping ant.', Exception);
|
||||||
@@ -38,6 +40,7 @@ jobRouter.get('/processingTimes', async (req, res) => {
|
|||||||
interval: config.interval,
|
interval: config.interval,
|
||||||
lastRun: config.lastRun || null,
|
lastRun: config.lastRun || null,
|
||||||
scrapingAntData,
|
scrapingAntData,
|
||||||
|
error: scrapingAntData?.detail == null ? null : scrapingAntData?.detail
|
||||||
};
|
};
|
||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
@@ -66,6 +69,11 @@ jobRouter.post('/', async (req, res) => {
|
|||||||
res.send(new Error(error));
|
res.send(new Error(error));
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
|
trackDemoJobCreated({
|
||||||
|
name,
|
||||||
|
provider,
|
||||||
|
adapter: notificationAdapter
|
||||||
|
});
|
||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
jobRouter.delete('', async (req, res) => {
|
jobRouter.delete('', async (req, res) => {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import restana from 'restana';
|
import restana from 'restana';
|
||||||
import * as userStorage from '../../services/storage/userStorage.js';
|
import * as userStorage from '../../services/storage/userStorage.js';
|
||||||
import * as hasher from '../../services/security/hash.js';
|
import * as hasher from '../../services/security/hash.js';
|
||||||
|
import {config} from '../../utils.js';
|
||||||
|
import {trackDemoAccessed} from '../../services/tracking/Tracker.js';
|
||||||
const service = restana();
|
const service = restana();
|
||||||
const loginRouter = service.newRouter();
|
const loginRouter = service.newRouter();
|
||||||
loginRouter.get('/user', async (req, res) => {
|
loginRouter.get('/user', async (req, res) => {
|
||||||
@@ -24,6 +26,11 @@ loginRouter.post('/', async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (user.password === hasher.hash(password)) {
|
if (user.password === hasher.hash(password)) {
|
||||||
|
|
||||||
|
if(config.demoMode){
|
||||||
|
trackDemoAccessed();
|
||||||
|
}
|
||||||
|
|
||||||
req.session.currentUser = user.id;
|
req.session.currentUser = user.id;
|
||||||
userStorage.setLastLoginToNow({ userId: user.id });
|
userStorage.setLastLoginToNow({ userId: user.id });
|
||||||
res.send(200);
|
res.send(200);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import restana from 'restana';
|
import restana from 'restana';
|
||||||
import * as userStorage from '../../services/storage/userStorage.js';
|
import * as userStorage from '../../services/storage/userStorage.js';
|
||||||
import * as jobStorage from '../../services/storage/jobStorage.js';
|
import * as jobStorage from '../../services/storage/jobStorage.js';
|
||||||
|
import {config} from '../../utils.js';
|
||||||
const service = restana();
|
const service = restana();
|
||||||
const userRouter = service.newRouter();
|
const userRouter = service.newRouter();
|
||||||
function checkIfAnyAdminAfterRemovingUser(userIdToBeRemoved, allUser) {
|
function checkIfAnyAdminAfterRemovingUser(userIdToBeRemoved, allUser) {
|
||||||
@@ -20,6 +21,11 @@ userRouter.get('/:userId', async (req, res) => {
|
|||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
userRouter.delete('/', async (req, res) => {
|
userRouter.delete('/', async (req, res) => {
|
||||||
|
if(config.demoMode){
|
||||||
|
res.send(new Error('In demo mode, it is not allowed to remove user.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { userId } = req.body;
|
const { userId } = req.body;
|
||||||
const allUser = userStorage.getUsers(false);
|
const allUser = userStorage.getUsers(false);
|
||||||
if (!checkIfAnyAdminAfterRemovingUser(userId, allUser)) {
|
if (!checkIfAnyAdminAfterRemovingUser(userId, allUser)) {
|
||||||
@@ -36,6 +42,12 @@ userRouter.delete('/', async (req, res) => {
|
|||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
userRouter.post('/', async (req, res) => {
|
userRouter.post('/', async (req, res) => {
|
||||||
|
|
||||||
|
if(config.demoMode){
|
||||||
|
res.send(new Error('In demo mode, it is not allowed to change or add user.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { username, password, password2, isAdmin, userId } = req.body;
|
const { username, password, password2, isAdmin, userId } = req.body;
|
||||||
if (password !== password2) {
|
if (password !== password2) {
|
||||||
res.send(new Error('Passwords does not match'));
|
res.send(new Error('Passwords does not match'));
|
||||||
|
|||||||
8
lib/defaultConfig.js
Normal file
8
lib/defaultConfig.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export const DEFAULT_CONFIG = {
|
||||||
|
'interval': '60',
|
||||||
|
'port': 9998,
|
||||||
|
'scrapingAnt': {'apiKey': '', 'proxy': 'datacenter'},
|
||||||
|
'workingHours': {'from': '', 'to': ''},
|
||||||
|
'demoMode': false,
|
||||||
|
'analyticsEnabled': null
|
||||||
|
};
|
||||||
36
lib/notification/adapter/apprise.js
Normal file
36
lib/notification/adapter/apprise.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
|
import { getJob } from '../../services/storage/jobStorage.js';
|
||||||
|
import fetch from 'node-fetch';
|
||||||
|
|
||||||
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
|
const { server } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
|
const job = getJob(jobKey);
|
||||||
|
const jobName = job == null ? jobKey : job.name;
|
||||||
|
const promises = newListings.map((newListing) => {
|
||||||
|
const title = `${jobName} at ${serviceName}: ${newListing.title}`;
|
||||||
|
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nink: ${newListing.link}`;
|
||||||
|
return fetch(server, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
body: message,
|
||||||
|
title: title,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.all(promises);
|
||||||
|
};
|
||||||
|
export const config = {
|
||||||
|
id: 'apprise',
|
||||||
|
name: 'Apprise',
|
||||||
|
readme: markdown2Html('lib/notification/adapter/apprise.md'),
|
||||||
|
description: 'Fredy will send new listings to your Apprise instance.',
|
||||||
|
fields: {
|
||||||
|
server: {
|
||||||
|
type: 'text',
|
||||||
|
label: 'Server',
|
||||||
|
description: 'The server URL to send the notification to.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
3
lib/notification/adapter/apprise.md
Normal file
3
lib/notification/adapter/apprise.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
### Apprise Adapter
|
||||||
|
|
||||||
|
Refer to the [instructions](https://github.com/caronc/apprise-api#installation) on how to set up an Apprise instance and how to configure your preferred notification service.
|
||||||
@@ -9,7 +9,7 @@ const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTe
|
|||||||
const emailTemplate = Handlebars.compile(template);
|
const emailTemplate = Handlebars.compile(template);
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
const { apiPublicKey, apiPrivateKey, receiver, from } = notificationConfig.find(
|
const { apiPublicKey, apiPrivateKey, receiver, from } = notificationConfig.find(
|
||||||
(adapter) => adapter.id === 'mailJet'
|
(adapter) => adapter.id === config.id,
|
||||||
).fields;
|
).fields;
|
||||||
const to = receiver
|
const to = receiver
|
||||||
.trim()
|
.trim()
|
||||||
@@ -18,7 +18,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
|||||||
Email: r.trim(),
|
Email: r.trim(),
|
||||||
}));
|
}));
|
||||||
return mailjet
|
return mailjet
|
||||||
.connect(apiPublicKey, apiPrivateKey)
|
.apiConnect(apiPublicKey, apiPrivateKey)
|
||||||
.post('send', { version: 'v3.1' })
|
.post('send', { version: 'v3.1' })
|
||||||
.request({
|
.request({
|
||||||
Messages: [
|
Messages: [
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { markdown2Html } from '../../services/markdown.js';
|
|||||||
import { getJob } from '../../services/storage/jobStorage.js';
|
import { getJob } from '../../services/storage/jobStorage.js';
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
const { webhook, channel } = notificationConfig.find((adapter) => adapter.id === 'mattermost').fields;
|
const { webhook, channel } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
const job = getJob(jobKey);
|
const job = getJob(jobKey);
|
||||||
const jobName = job == null ? jobKey : job.name;
|
const jobName = job == null ? jobKey : job.name;
|
||||||
let message = `### *${jobName}* (${serviceName}) found **${newListings.length}** new listings:\n\n`;
|
let message = `### *${jobName}* (${serviceName}) found **${newListings.length}** new listings:\n\n`;
|
||||||
message += `| Title | Address | Size | Price |\n|:----|:----|:----|:----|\n`;
|
message += `| Title | Address | Size | Price |\n|:----|:----|:----|:----|\n`;
|
||||||
message += newListings.map(
|
message += newListings.map(
|
||||||
(o) => `| [${o.title}](${o.link}) | ` + [o.address, o.size.replace(/2m/g, '$m^2$'), o.price].join(' | ') + ' |\n'
|
(o) => `| [${o.title}](${o.link}) | ` + [o.address, o.size.replace(/2m/g, '$m^2$'), o.price].join(' | ') + ' |\n',
|
||||||
);
|
);
|
||||||
return fetch(webhook, {
|
return fetch(webhook, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
51
lib/notification/adapter/ntfy.js
Normal file
51
lib/notification/adapter/ntfy.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
|
import { getJob } from '../../services/storage/jobStorage.js';
|
||||||
|
import fetch from 'node-fetch';
|
||||||
|
|
||||||
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
|
const { priority, server, topic } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
|
const job = getJob(jobKey);
|
||||||
|
const jobName = job == null ? jobKey : job.name;
|
||||||
|
const promises = newListings.map((newListing) => {
|
||||||
|
const message = `Address: ${newListing.address} Size: ${newListing.size.replace(/2m/g, '$m^2$')} Price: ${
|
||||||
|
newListing.price
|
||||||
|
}`;
|
||||||
|
return fetch(server, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
topic: topic,
|
||||||
|
message: message,
|
||||||
|
title: newListing.title,
|
||||||
|
tags: [serviceName, jobName],
|
||||||
|
priority: parseInt(priority),
|
||||||
|
click: newListing.link,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.all(promises);
|
||||||
|
};
|
||||||
|
export const config = {
|
||||||
|
id: 'ntfy',
|
||||||
|
name: 'ntfy',
|
||||||
|
readme: markdown2Html('lib/notification/adapter/ntfy.md'),
|
||||||
|
description: 'Fredy will send new listings to your ntfy.',
|
||||||
|
fields: {
|
||||||
|
priority: {
|
||||||
|
type: 'number',
|
||||||
|
label: 'Priority',
|
||||||
|
description: 'The priority of the send notification.',
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
type: 'text',
|
||||||
|
label: 'Server-URL',
|
||||||
|
description: 'The server url to the send the notification to.',
|
||||||
|
},
|
||||||
|
topic: {
|
||||||
|
type: 'text',
|
||||||
|
label: 'topic',
|
||||||
|
description:
|
||||||
|
'The topic where fredy should send notifications to. The topic is a secret, only known to you, make sure it is something not generic.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
5
lib/notification/adapter/ntfy.md
Normal file
5
lib/notification/adapter/ntfy.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
### ntfy Adapter
|
||||||
|
|
||||||
|
For ntfy, you need to create a topic on your preferred ntfy instance. This is pretty easy. Please visit the steps in the [docs](https://docs.ntfy.sh/publish/) and follow the instructions.
|
||||||
|
|
||||||
|
As a result, you get the URL for configuration in fredy. In addition, the priority must be defined.
|
||||||
50
lib/notification/adapter/pushover.js
Normal file
50
lib/notification/adapter/pushover.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
|
import { getJob } from '../../services/storage/jobStorage.js';
|
||||||
|
import fetch from 'node-fetch';
|
||||||
|
|
||||||
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
|
const { token, user, device } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
|
const job = getJob(jobKey);
|
||||||
|
const jobName = job == null ? jobKey : job.name;
|
||||||
|
const promises = newListings.map((newListing) => {
|
||||||
|
const title = `${jobName} at ${serviceName}: ${newListing.title}`;
|
||||||
|
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}`;
|
||||||
|
return fetch('https://api.pushover.net/1/messages.json', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
token: token,
|
||||||
|
user: user,
|
||||||
|
message: message,
|
||||||
|
device: device,
|
||||||
|
title: title,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.all(promises);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
id: 'pushover',
|
||||||
|
name: 'Pushover',
|
||||||
|
readme: markdown2Html('lib/notification/adapter/pushover.md'),
|
||||||
|
description: 'Fredy will send new listings to your mobile using Pushover.',
|
||||||
|
fields: {
|
||||||
|
token: {
|
||||||
|
type: 'text',
|
||||||
|
label: 'API token',
|
||||||
|
description: 'Your application\'s API token.',
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
type: 'text',
|
||||||
|
label: 'User key',
|
||||||
|
description: 'Your user/group key.',
|
||||||
|
},
|
||||||
|
device: {
|
||||||
|
type: 'text',
|
||||||
|
label: 'Device name',
|
||||||
|
description: 'The device name to send your notification to. Messages may be addressed to multiple specific devices by joining them with a comma.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
5
lib/notification/adapter/pushover.md
Normal file
5
lib/notification/adapter/pushover.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
### Pushover Adapter
|
||||||
|
|
||||||
|
Refer to the [instructions](https://support.pushover.net/i7-what-is-pushover-and-how-do-i-use-it) to set up your Pushover application.
|
||||||
|
|
||||||
|
After setting up the application, please enter both your newly created User key and API token.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import sgMail from '@sendgrid/mail';
|
import sgMail from '@sendgrid/mail';
|
||||||
import { markdown2Html } from '../../services/markdown.js';
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
const { apiKey, receiver, from, templateId } = notificationConfig.find((adapter) => adapter.id === 'sendGrid').fields;
|
const { apiKey, receiver, from, templateId } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
sgMail.setApiKey(apiKey);
|
sgMail.setApiKey(apiKey);
|
||||||
const msg = {
|
const msg = {
|
||||||
templateId,
|
templateId,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Slack from 'slack';
|
|||||||
import { markdown2Html } from '../../services/markdown.js';
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
const msg = Slack.chat.postMessage;
|
const msg = Slack.chat.postMessage;
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
const { token, channel } = notificationConfig.find((adapter) => adapter.id === 'slack').fields;
|
const { token, channel } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
return newListings.map((payload) =>
|
return newListings.map((payload) =>
|
||||||
msg({
|
msg({
|
||||||
token,
|
token,
|
||||||
@@ -35,7 +35,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
|||||||
ts: new Date().getTime() / 1000,
|
ts: new Date().getTime() / 1000,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
export const config = {
|
export const config = {
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
### 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 in db/listings.db
|
Fields are:
|
||||||
|
```
|
||||||
|
['serviceName', 'jobKey', 'id', 'size', 'rooms', 'price', 'address', 'title', 'link', 'description']
|
||||||
|
```
|
||||||
@@ -19,7 +19,7 @@ function shorten(str, len = 30) {
|
|||||||
return str.length > len ? str.substring(0, len) + '...' : str;
|
return str.length > len ? str.substring(0, len) + '...' : str;
|
||||||
}
|
}
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
const { token, chatId } = notificationConfig.find((adapter) => adapter.id === 'telegram').fields;
|
const { token, chatId } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
const job = getJob(jobKey);
|
const job = getJob(jobKey);
|
||||||
const jobName = job == null ? jobKey : job.name;
|
const jobName = job == null ? jobKey : job.name;
|
||||||
//we have to split messages into chunk, because otherwise messages are going to become too big and will fail
|
//we have to split messages into chunk, because otherwise messages are going to become too big and will fail
|
||||||
@@ -30,7 +30,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
|||||||
(o) =>
|
(o) =>
|
||||||
`<a href='${o.link}'><b>${shorten(o.title.replace(/\*/g, ''), 45).trim()}</b></a>\n` +
|
`<a href='${o.link}'><b>${shorten(o.title.replace(/\*/g, ''), 45).trim()}</b></a>\n` +
|
||||||
[o.address, o.price, o.size].join(' | ') +
|
[o.address, o.price, o.size].join(' | ') +
|
||||||
'\n\n'
|
'\n\n',
|
||||||
);
|
);
|
||||||
/**
|
/**
|
||||||
* This is to not break the rate limit. It is to only send 1 message per second
|
* This is to not break the rate limit. It is to only send 1 message per second
|
||||||
|
|||||||
@@ -1,18 +1,40 @@
|
|||||||
import utils from '../utils.js';
|
import utils, { buildHash } from '../utils.js';
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
let size = `${o.size.replace(' Wohnfläche ', '').trim()}`;
|
let size = `${o.size.replace(' Wohnfläche ', '').trim()}`;
|
||||||
if (o.rooms != null) {
|
if (o.rooms != null) {
|
||||||
size += ` / / ${o.rooms.trim()}`;
|
size += ` / / ${o.rooms.trim()}`;
|
||||||
}
|
}
|
||||||
const link = `https://www.1a-immobilienmarkt.de/expose/${o.id}.html`;
|
const link = `https://www.1a-immobilienmarkt.de/expose/${o.id}.html`;
|
||||||
return Object.assign(o, { size, link });
|
const price = normalizePrice(o.price);
|
||||||
|
const id = buildHash(o.id, price);
|
||||||
|
return Object.assign(o, { id, price, size, link });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* einsAImmobilien sometimes use a weird pricing label such as `775.700,00 EUR Kaufpreis ab 2.475 € mtl`.
|
||||||
|
* Make sure to extract only the actual price out of the string.
|
||||||
|
* @param price
|
||||||
|
* @returns {*}
|
||||||
|
*/
|
||||||
|
function normalizePrice(price) {
|
||||||
|
if (price == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const regex = /(\d{1,3}(?:\.\d{3})*,\d{2})\s?(EUR|€)/g;
|
||||||
|
const result = price.match(regex);
|
||||||
|
if (result == null || result.length === 0) {
|
||||||
|
return price;
|
||||||
|
}
|
||||||
|
return result[0];
|
||||||
}
|
}
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '.tabelle',
|
crawlContainer: '.tabelle',
|
||||||
@@ -23,7 +45,6 @@ const config = {
|
|||||||
size: '.tabelle .inner_object_data .data_boxes div:nth-child(1)',
|
size: '.tabelle .inner_object_data .data_boxes div:nth-child(1)',
|
||||||
rooms: '.tabelle .inner_object_data .data_boxes div:nth-child(2)',
|
rooms: '.tabelle .inner_object_data .data_boxes div:nth-child(2)',
|
||||||
title: '.tabelle .inner_object_data .tabelle_inhalt_titel_black | removeNewline | trim',
|
title: '.tabelle .inner_object_data .tabelle_inhalt_titel_black | removeNewline | trim',
|
||||||
description: '.tabelle .inner_object_data .objekt_beschreibung | removeNewline | trim',
|
|
||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import utils from '../utils.js';
|
import utils, {buildHash} from '../utils.js';
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
function shortenLink(link) {
|
function shortenLink(link) {
|
||||||
return link.substring(0, link.indexOf('?'));
|
return link.substring(0, link.indexOf('?'));
|
||||||
@@ -7,12 +7,12 @@ function parseId(shortenedLink) {
|
|||||||
return shortenedLink.substring(shortenedLink.lastIndexOf('/') + 1);
|
return shortenedLink.substring(shortenedLink.lastIndexOf('/') + 1);
|
||||||
}
|
}
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const id = parseId(shortenLink(o.link));
|
|
||||||
const size = o.size || 'N/A m²';
|
const size = o.size || 'N/A m²';
|
||||||
const price = o.price || 'N/A €';
|
const price = o.price || 'N/A €';
|
||||||
const title = o.title || 'No title available';
|
const title = o.title || 'No title available';
|
||||||
const address = o.address || 'No address available';
|
const address = o.address || 'No address available';
|
||||||
const link = shortenLink(o.link);
|
const link = shortenLink(o.link);
|
||||||
|
const id = buildHash(parseId(shortenLink(o.link)), o.price);
|
||||||
return Object.assign(o, { id, price, size, title, address, link });
|
return Object.assign(o, { id, price, size, title, address, link });
|
||||||
}
|
}
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import utils from '../utils.js';
|
import utils, {buildHash} from '../utils.js';
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const id = parseInt(o.id.substring(o.id.indexOf('_') + 1, o.id.length));
|
|
||||||
const size = o.size != null ? o.size.replace('Wohnfläche ', '') : 'N/A m²';
|
const size = o.size != null ? o.size.replace('Wohnfläche ', '') : 'N/A m²';
|
||||||
const price = o.price.replace('Kaufpreis ', '');
|
const price = o.price.replace('Kaufpreis ', '');
|
||||||
const address = o.address.split(' • ')[o.address.split(' • ').length - 1];
|
const address = o.address.split(' • ')[o.address.split(' • ').length - 1];
|
||||||
const title = o.title || 'No title available';
|
const title = o.title || 'No title available';
|
||||||
//normally we would just read the link from the source, but immonet decided to trick user by adding a click listener instead of
|
const link = o.id;
|
||||||
//a href to do some weird reporting. (Very user friendly for handicaped ppl... not)
|
const id = buildHash(o.id.substring(o.id.lastIndexOf('/') + 1, o.id.length), price);
|
||||||
const link = `https://www.immonet.de/angebot/${id}`;
|
|
||||||
return Object.assign(o, { id, address, price, size, title, link });
|
return Object.assign(o, { id, address, price, size, title, link });
|
||||||
}
|
}
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
@@ -18,14 +16,14 @@ function applyBlacklist(o) {
|
|||||||
}
|
}
|
||||||
const config = {
|
const config = {
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '#result-list-stage .item',
|
crawlContainer: '.content-wrapper-tiles .ng-star-inserted',
|
||||||
sortByDateParam: 'sortby=19',
|
sortByDateParam: 'sortby=19',
|
||||||
crawlFields: {
|
crawlFields: {
|
||||||
id: '@id',
|
id: '.card a@href',
|
||||||
price: 'div[id*="selPrice_"] | trim',
|
title: '.card h3 |trim',
|
||||||
size: 'div[id*="selArea_"] | trim',
|
price: '.card .has-font-300 .is-bold | trim',
|
||||||
title: '.item a img@title',
|
size: '.card .has-font-300 .ml-100 | trim',
|
||||||
address: '.item .box-25 .ellipsis .text-100 | removeNewline | trim',
|
address: '.card span:nth-child(2) | trim',
|
||||||
},
|
},
|
||||||
paginate: '#idResultList .margin-bottom-6.margin-bottom-sm-12 .panel a.pull-right@href',
|
paginate: '#idResultList .margin-bottom-6.margin-bottom-sm-12 .panel a.pull-right@href',
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import utils from '../utils.js';
|
import utils, {buildHash} from '../utils.js';
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
function nullOrEmpty(val) {
|
function nullOrEmpty(val) {
|
||||||
return val == null || val.length === 0;
|
return val == null || val.length === 0;
|
||||||
@@ -6,8 +6,9 @@ function nullOrEmpty(val) {
|
|||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const title = nullOrEmpty(o.title) ? 'NO TITLE FOUND' : o.title.replace('NEU', '');
|
const title = nullOrEmpty(o.title) ? 'NO TITLE FOUND' : o.title.replace('NEU', '');
|
||||||
const address = nullOrEmpty(o.address) ? 'NO ADDRESS FOUND' : (o.address || '').replace(/\(.*\),.*$/, '').trim();
|
const address = nullOrEmpty(o.address) ? 'NO ADDRESS FOUND' : (o.address || '').replace(/\(.*\),.*$/, '').trim();
|
||||||
const link = `https://www.immobilienscout24.de${o.link.substring(o.link.indexOf('/expose'))}`;
|
const link = nullOrEmpty(o.link) ? 'NO LINK' : `https://www.immobilienscout24.de${o.link.substring(o.link.indexOf('/expose'))}`;
|
||||||
return Object.assign(o, { title, address, link });
|
const id = buildHash(o.id, o.price);
|
||||||
|
return Object.assign(o, { id, title, address, link });
|
||||||
}
|
}
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
return !utils.isOneOf(o.title, appliedBlackList);
|
return !utils.isOneOf(o.title, appliedBlackList);
|
||||||
@@ -20,7 +21,7 @@ const config = {
|
|||||||
id: '.result-list-entry@data-obid | int',
|
id: '.result-list-entry@data-obid | int',
|
||||||
price: '.result-list-entry .result-list-entry__criteria .grid-item:first-child dd | removeNewline | trim',
|
price: '.result-list-entry .result-list-entry__criteria .grid-item:first-child dd | removeNewline | trim',
|
||||||
size: '.result-list-entry .result-list-entry__criteria .grid-item:nth-child(2) dd | removeNewline | trim',
|
size: '.result-list-entry .result-list-entry__criteria .grid-item:nth-child(2) dd | removeNewline | trim',
|
||||||
title: '.result-list-entry .result-list-entry__brand-title-container h5 | removeNewline | trim',
|
title: '.result-list-entry .result-list-entry__brand-title-container h2 | removeNewline | trim',
|
||||||
link: '.result-list-entry .result-list-entry__brand-title-container@href',
|
link: '.result-list-entry .result-list-entry__brand-title-container@href',
|
||||||
address: '.result-list-entry .result-list-entry__map-link',
|
address: '.result-list-entry .result-list-entry__map-link',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,44 +1,48 @@
|
|||||||
import utils from '../utils.js';
|
import utils, {buildHash} from '../utils.js';
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const id = o.id.substring(o.id.indexOf('-') + 1, o.id.length);
|
const size = o.size || 'N/A m²';
|
||||||
const size = o.size || 'N/A m²';
|
const price = (o.price || '--- €').replace('Preis auf Anfrage', '--- €');
|
||||||
const price = (o.price || '--- €').replace('Preis auf Anfrage', '--- €');
|
const title = o.title || 'No title available';
|
||||||
const address = o.address || 'No address available';
|
const immoId = o.id.substring(o.id.indexOf('-') + 1, o.id.length);
|
||||||
const title = o.title || 'No title available';
|
const link = `https://immo.swp.de/immobilien/${immoId}`;
|
||||||
const link = `https://immo.swp.de/immobilien/${id}`;
|
const description = o.description;
|
||||||
const description = o.description;
|
const id = buildHash(immoId, price);
|
||||||
return Object.assign(o, { id, address, price, size, title, link, description });
|
return Object.assign(o, {id, price, size, title, link, description});
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '.js-serp-item',
|
crawlContainer: '.js-serp-item',
|
||||||
sortByDateParam: 's=most_recently_updated_first',
|
sortByDateParam: 's=most_recently_updated_first',
|
||||||
crawlFields: {
|
crawlFields: {
|
||||||
id: '@id',
|
id: '.js-bookmark-btn@data-id',
|
||||||
price: 'div.item__spec.item-spec-price | trim',
|
price: 'div.align-items-start div:first-child | trim',
|
||||||
size: 'div.item__spec.item-spec-area | trim',
|
size: 'div.align-items-start div:nth-child(3) | trim',
|
||||||
title: 'a.js-item-title-link@title',
|
title: '.card-title h2 | trim',
|
||||||
address: 'div.item__locality | removeNewline | trim',
|
link: '.ci-search-result__link@href',
|
||||||
description: 'div.item__main-info-points.clearfix p small | removeNewline | trim',
|
description: '.js-show-more-item-sm | removeNewline | trim',
|
||||||
},
|
},
|
||||||
paginate: 'li.page-item.pagination__item a.page-link@href',
|
paginate: 'li.page-item.pagination__item a.page-link@href',
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
config.enabled = sourceConfig.enabled;
|
config.enabled = sourceConfig.enabled;
|
||||||
config.url = sourceConfig.url;
|
config.url = sourceConfig.url;
|
||||||
appliedBlackList = blacklist || [];
|
appliedBlackList = blacklist || [];
|
||||||
};
|
};
|
||||||
export const metaInformation = {
|
export const metaInformation = {
|
||||||
name: 'Immo Südwest Presse',
|
name: 'Immo Südwest Presse',
|
||||||
baseUrl: 'https://immo.swp.de/',
|
baseUrl: 'https://immo.swp.de/',
|
||||||
id: 'immoswp',
|
id: 'immoswp',
|
||||||
};
|
};
|
||||||
export { config };
|
export {config};
|
||||||
|
|||||||
@@ -1,24 +1,30 @@
|
|||||||
import utils from '../utils.js';
|
import utils, { buildHash } from '../utils.js';
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
return o;
|
const id = buildHash(o.id, o.price);
|
||||||
|
return Object.assign(o, { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: "div[class^='EstateItem-']",
|
crawlContainer:
|
||||||
sortByDateParam: 'sd=DESC&sf=TIMESTAMP',
|
'div[data-testid="serp-card-testid"]:not(div[data-testid="serp-enlargementlist-testid"] div[data-testid="serp-card-testid"])',
|
||||||
|
sortByDateParam: 'order=DateDesc',
|
||||||
crawlFields: {
|
crawlFields: {
|
||||||
id: 'a@id',
|
id: 'a@id',
|
||||||
price: "div[class^='KeyFacts-'] [data-test='price'] | removeNewline | trim",
|
price: 'div[data-testid="cardmfe-price-testid"] | removeNewline | trim',
|
||||||
size: "div[class^='KeyFacts-'] [data-test='area'] | removeNewline | trim",
|
size: 'div[data-testid="cardmfe-keyfacts-testid"] | removeNewline | trim',
|
||||||
title: "div[class^='FactsMain-'] h2",
|
title: '.css-1cbj9xw',
|
||||||
link: 'a@href',
|
link: 'a@href',
|
||||||
address: "div[class^='estateFacts-'] span | removeNewline | trim",
|
address: 'div[data-testid="cardmfe-description-box-address"] | removeNewline | trim',
|
||||||
},
|
},
|
||||||
paginate: '#pnlPaging #nlbPlus@href',
|
paginate: '#pnlPaging #nlbPlus@href',
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
|
|||||||
@@ -1,44 +1,49 @@
|
|||||||
import utils from '../utils.js';
|
import utils, {buildHash} from '../utils.js';
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
let appliedBlacklistedDistricts = [];
|
let appliedBlacklistedDistricts = [];
|
||||||
|
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const size = o.size || '--- m²';
|
const size = o.size || '--- m²';
|
||||||
return Object.assign(o, { size });
|
const id = buildHash(o.id, o.price);
|
||||||
|
return Object.assign(o, {id, size});
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
||||||
const isBlacklistedDistrict =
|
const isBlacklistedDistrict =
|
||||||
appliedBlacklistedDistricts.length === 0 ? false : utils.isOneOf(o.description, appliedBlacklistedDistricts);
|
appliedBlacklistedDistricts.length === 0 ? false : utils.isOneOf(o.description, appliedBlacklistedDistricts);
|
||||||
return !isBlacklistedDistrict && titleNotBlacklisted && descNotBlacklisted;
|
return !isBlacklistedDistrict && titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '#srchrslt-adtable .ad-listitem ',
|
crawlContainer: '#srchrslt-adtable .ad-listitem ',
|
||||||
//sort by date is standard oO
|
//sort by date is standard oO
|
||||||
sortByDateParam: null,
|
sortByDateParam: null,
|
||||||
crawlFields: {
|
crawlFields: {
|
||||||
id: '.aditem@data-adid | int',
|
id: '.aditem@data-adid | int',
|
||||||
price: '.aditem-main--middle--price | removeNewline | trim',
|
price: '.aditem-main--middle--price-shipping--price | removeNewline | trim',
|
||||||
size: '.aditem-main .text-module-end span:nth-child(2) | removeNewline | trim',
|
size: '.aditem-main .text-module-end span:nth-child(2) | removeNewline | trim',
|
||||||
title: '.aditem-main .text-module-begin a | removeNewline | trim',
|
title: '.aditem-main .text-module-begin a | removeNewline | trim',
|
||||||
link: '.aditem-main .text-module-begin a@href | removeNewline | trim',
|
link: '.aditem-main .text-module-begin a@href | removeNewline | trim',
|
||||||
description: '.aditem-main p:not(.text-module-end) | removeNewline | trim',
|
description: '.aditem-main p:not(.text-module-end) | removeNewline | trim',
|
||||||
address: '.aditem-main--top--left | trim | removeNewline',
|
address: '.aditem-main--top--left | trim | removeNewline',
|
||||||
},
|
},
|
||||||
paginate: '#srchrslt-pagination .pagination-next@href',
|
paginate: '#srchrslt-pagination .pagination-next@href',
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
};
|
};
|
||||||
export const metaInformation = {
|
export const metaInformation = {
|
||||||
name: 'Ebay Kleinanzeigen',
|
name: 'Ebay Kleinanzeigen',
|
||||||
baseUrl: 'https://www.ebay-kleinanzeigen.de/',
|
baseUrl: 'https://www.kleinanzeigen.de/',
|
||||||
id: 'kleinanzeigen',
|
id: 'kleinanzeigen',
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist, blacklistedDistricts) => {
|
export const init = (sourceConfig, blacklist, blacklistedDistricts) => {
|
||||||
config.enabled = sourceConfig.enabled;
|
config.enabled = sourceConfig.enabled;
|
||||||
config.url = sourceConfig.url;
|
config.url = sourceConfig.url;
|
||||||
appliedBlacklistedDistricts = blacklistedDistricts || [];
|
appliedBlacklistedDistricts = blacklistedDistricts || [];
|
||||||
appliedBlackList = blacklist || [];
|
appliedBlackList = blacklist || [];
|
||||||
};
|
};
|
||||||
export { config };
|
export {config};
|
||||||
|
|||||||
@@ -1,34 +1,44 @@
|
|||||||
import utils from '../utils.js';
|
import utils, {buildHash} from '../utils.js';
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
function nullOrEmpty(val) {
|
||||||
|
return val == null || val.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
return o;
|
const link = nullOrEmpty(o.link) ? 'NO LINK' : `https://www.neubaukompass.de${o.link.substring(o.link.indexOf('/neubau'))}`;
|
||||||
|
const id = buildHash(o.id, o.price);
|
||||||
|
return Object.assign(o, {id, link});
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
return !utils.isOneOf(o.title, appliedBlackList);
|
return !utils.isOneOf(o.title, appliedBlackList);
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '.nbk-container >div article',
|
crawlContainer: '.nbk-container >div article',
|
||||||
sortByDateParam: 'Sortierung=Id&Richtung=DESC',
|
sortByDateParam: 'Sortierung=Id&Richtung=DESC',
|
||||||
crawlFields: {
|
crawlFields: {
|
||||||
id: '@id',
|
id: '@id',
|
||||||
title: 'a.nbk-truncate@title | removeNewline | trim',
|
title: 'a.nbk-truncate@title | removeNewline | trim',
|
||||||
link: 'a.nbk-truncate@href',
|
link: 'a.nbk-truncate@href',
|
||||||
address: 'p.nbk-truncate | removeNewline | trim',
|
address: 'p.nbk-truncate | removeNewline | trim',
|
||||||
price: 'p.nbk-mb-0 | removeNewline | trim',
|
price: 'p.nbk-mb-0 | removeNewline | trim',
|
||||||
},
|
},
|
||||||
paginate: '.numbered-pager__bottom .numbered-pager--info li:nth-child(2) a@href',
|
paginate: '.numbered-pager__bottom .numbered-pager--info li:nth-child(2) a@href',
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
config.enabled = sourceConfig.enabled;
|
config.enabled = sourceConfig.enabled;
|
||||||
config.url = sourceConfig.url;
|
config.url = sourceConfig.url;
|
||||||
appliedBlackList = blacklist || [];
|
appliedBlackList = blacklist || [];
|
||||||
};
|
};
|
||||||
export const metaInformation = {
|
export const metaInformation = {
|
||||||
name: 'Neubau Kompass',
|
name: 'Neubau Kompass',
|
||||||
baseUrl: 'https://www.neubaukompass.de/',
|
baseUrl: 'https://www.neubaukompass.de/',
|
||||||
id: 'neubauKompass',
|
id: 'neubauKompass',
|
||||||
};
|
};
|
||||||
export { config };
|
export {config};
|
||||||
|
|||||||
@@ -1,36 +1,41 @@
|
|||||||
import utils from '../utils.js';
|
import utils, {buildHash} from '../utils.js';
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
return o;
|
const id = buildHash(o.id, o.price);
|
||||||
|
return Object.assign(o, {id});
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
||||||
return o.id != null && titleNotBlacklisted && descNotBlacklisted;
|
return o.id != null && titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '#main_column .wgg_card',
|
crawlContainer: '#main_column .wgg_card',
|
||||||
sortByDateParam: 'sort_column=0&sort_order=0',
|
sortByDateParam: 'sort_column=0&sort_order=0',
|
||||||
crawlFields: {
|
crawlFields: {
|
||||||
id: '@data-id',
|
id: '@data-id',
|
||||||
details: '.row .noprint .col-xs-11 |removeNewline |trim',
|
details: '.row .noprint .col-xs-11 |removeNewline |trim',
|
||||||
price: '.middle .col-xs-3 |removeNewline |trim',
|
price: '.middle .col-xs-3 |removeNewline |trim',
|
||||||
size: '.middle .text-right |removeNewline |trim',
|
size: '.middle .text-right |removeNewline |trim',
|
||||||
title: '.truncate_title a |removeNewline |trim',
|
title: '.truncate_title a |removeNewline |trim',
|
||||||
link: '.truncate_title a@href',
|
link: '.truncate_title a@href',
|
||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
config.enabled = sourceConfig.enabled;
|
config.enabled = sourceConfig.enabled;
|
||||||
config.url = sourceConfig.url;
|
config.url = sourceConfig.url;
|
||||||
appliedBlackList = blacklist || [];
|
appliedBlackList = blacklist || [];
|
||||||
};
|
};
|
||||||
export const metaInformation = {
|
export const metaInformation = {
|
||||||
name: 'Wg gesucht',
|
name: 'Wg gesucht',
|
||||||
baseUrl: 'https://www.wg-gesucht.de/',
|
baseUrl: 'https://www.wg-gesucht.de/',
|
||||||
id: 'wgGesucht',
|
id: 'wgGesucht',
|
||||||
};
|
};
|
||||||
export { config };
|
export {config};
|
||||||
|
|||||||
29
lib/services/demoCleanup.js
Normal file
29
lib/services/demoCleanup.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { setInterval } from 'node:timers';
|
||||||
|
import {removeJobsByUserName} from './storage/jobStorage.js';
|
||||||
|
import {config} from '../utils.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();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
cleanup();
|
||||||
|
}, 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
}, millisUntilMidnightUTC);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup(){
|
||||||
|
if(config.demoMode){
|
||||||
|
removeJobsByUserName('demo');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
import { config } from '../utils.js';
|
import { config } from '../utils.js';
|
||||||
import { makeUrlResidential } from './scrapingAnt.js';
|
import { makeUrlResidential } from './scrapingAnt.js';
|
||||||
|
import https from 'https';
|
||||||
//if ScrapingAnt got blocked, this http status is returned
|
//if ScrapingAnt got blocked, this http status is returned
|
||||||
const BLOCKED_HTTP_STATUS = 423;
|
const BLOCKED_HTTP_STATUS = 423;
|
||||||
const NOT_FOUND_HTTP_STATUS = 404;
|
const NOT_FOUND_HTTP_STATUS = 404;
|
||||||
const MAX_RETRIES_SCRAPING_ANT = 10;
|
const MAX_RETRIES_SCRAPING_ANT = 10;
|
||||||
const EXPECTED_STATUS_CODES = [BLOCKED_HTTP_STATUS, NOT_FOUND_HTTP_STATUS];
|
const EXPECTED_STATUS_CODES = [BLOCKED_HTTP_STATUS, NOT_FOUND_HTTP_STATUS];
|
||||||
|
const agent = new https.Agent({
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
});
|
||||||
|
|
||||||
function makeDriver(headers = {}) {
|
function makeDriver(headers = {}) {
|
||||||
let cookies = '';
|
let cookies = '';
|
||||||
async function scrapingAntDriver(context, callback, retryCounter = 0) {
|
async function scrapingAntDriver(context, callback, retryCounter = 0) {
|
||||||
@@ -19,13 +24,16 @@ function makeDriver(headers = {}) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const result = await response.text();
|
const result = await response.text();
|
||||||
|
if (EXPECTED_STATUS_CODES.includes(response.status)) {
|
||||||
|
throw new Error(`${response.status}`);
|
||||||
|
}
|
||||||
if (cookies.length === 0) {
|
if (cookies.length === 0) {
|
||||||
cookies = response.headers.raw()['set-cookie'] || [];
|
cookies = response.headers.raw()['set-cookie'] || [];
|
||||||
}
|
}
|
||||||
callback(null, result);
|
callback(null, result);
|
||||||
} catch (exception) {
|
} catch (exception) {
|
||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
if (!EXPECTED_STATUS_CODES.includes(exception.response?.status)) {
|
if (!EXPECTED_STATUS_CODES.includes(exception.response?.status) && !EXPECTED_STATUS_CODES.includes(Number(exception.message))) {
|
||||||
console.error(`Error while trying to scrape data from scraping ant. Received error: ${exception.message}`);
|
console.error(`Error while trying to scrape data from scraping ant. Received error: ${exception.message}`);
|
||||||
callback(null, []);
|
callback(null, []);
|
||||||
return;
|
return;
|
||||||
@@ -41,9 +49,10 @@ function makeDriver(headers = {}) {
|
|||||||
/* eslint-enable no-console */
|
/* eslint-enable no-console */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The regular request driver is taking care of everyting, that doesn't need to be scraped by ScrapingAnt (which is
|
* The regular request driver is taking care of everyting, that doesn't need to be scraped by ScrapingAnt (which is
|
||||||
* everything != Immoscout as of writing this)
|
* everything != Immoscout & Immonet as of writing this)
|
||||||
*/
|
*/
|
||||||
return async function driver(context, callback) {
|
return async function driver(context, callback) {
|
||||||
if (context.url.toLowerCase().indexOf('scrapingant') !== -1) {
|
if (context.url.toLowerCase().indexOf('scrapingant') !== -1) {
|
||||||
@@ -55,6 +64,7 @@ function makeDriver(headers = {}) {
|
|||||||
...headers,
|
...headers,
|
||||||
Cookie: cookies,
|
Cookie: cookies,
|
||||||
},
|
},
|
||||||
|
agent,
|
||||||
});
|
});
|
||||||
const result = await response.text();
|
const result = await response.text();
|
||||||
callback(null, result);
|
callback(null, result);
|
||||||
|
|||||||
@@ -1,19 +1,30 @@
|
|||||||
import { metaInformation } from '../provider/immoscout.js';
|
import { metaInformation as immoScoutInfo } from '../provider/immoscout.js';
|
||||||
|
import { metaInformation as immoNetInfo } from '../provider/immonet.js';
|
||||||
|
import { metaInformation as neuBauCompassInfo } from '../provider/neubauKompass.js';
|
||||||
import { config } from '../utils.js';
|
import { config } from '../utils.js';
|
||||||
const isImmoscout = (id) => {
|
|
||||||
return id.toLowerCase() === metaInformation.id;
|
const additionalImmonetUrlParams = `&wait_for_selector=.content-wrapper-tiles&js_snippet=${Buffer.from(
|
||||||
|
'window.scrollTo(0,document.body.scrollHeight);'
|
||||||
|
).toString('base64')}`;
|
||||||
|
|
||||||
|
const needScrapingAnt = (id) => {
|
||||||
|
return id.toLowerCase() === immoScoutInfo.id || id.toLowerCase() === immoNetInfo.id || id.toLowerCase() === neuBauCompassInfo.id.toLowerCase();
|
||||||
};
|
};
|
||||||
export const transformUrlForScrapingAnt = (url, id) => {
|
export const transformUrlForScrapingAnt = (url, id) => {
|
||||||
if (isImmoscout(id)) {
|
let urlParams = '';
|
||||||
//only do calls to scrapingAnt when dealing with Immoscout
|
if (needScrapingAnt(id)) {
|
||||||
url = `https://api.scrapingant.com/v1/general?url=${encodeURIComponent(url)}&proxy_type=datacenter`;
|
if (id.toLowerCase() === immoNetInfo.id) {
|
||||||
|
urlParams = additionalImmonetUrlParams;
|
||||||
|
}
|
||||||
|
//only do calls to scrapingAnt when dealing with Immoscout/Immonet
|
||||||
|
url = `https://api.scrapingant.com/v2/general?url=${encodeURIComponent(url)}&proxy_type=datacenter${urlParams}`;
|
||||||
}
|
}
|
||||||
return url;
|
return url;
|
||||||
};
|
};
|
||||||
export const isScrapingAntApiKeySet = () => {
|
export const isScrapingAntApiKeySet = () => {
|
||||||
return config.scrapingAnt != null && config.scrapingAnt.apiKey != null && config.scrapingAnt.apiKey.length > 0;
|
return config.scrapingAnt != null && config.scrapingAnt.apiKey != null && config.scrapingAnt.apiKey.length > 8;
|
||||||
};
|
};
|
||||||
export const makeUrlResidential = (url) => {
|
export const makeUrlResidential = (url) => {
|
||||||
return url.replace('datacenter', 'residential');
|
return url.replace('datacenter', 'residential');
|
||||||
};
|
};
|
||||||
export { isImmoscout };
|
export { needScrapingAnt };
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import lodash from 'lodash';
|
import lodash from 'lodash';
|
||||||
import { LowSync } from 'lowdb';
|
import { LowSync } from 'lowdb';
|
||||||
export default class LowdashAdapter extends LowSync {
|
export default class LowdashAdapter extends LowSync {
|
||||||
constructor(adapter) {
|
constructor(adapter, defaultData = {}) {
|
||||||
super(adapter);
|
super(adapter, defaultData);
|
||||||
this.chain = lodash.chain(this).get('data');
|
this.chain = lodash.chain(this).get('data');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,10 @@ import LowdashAdapter from './LowDashAdapter.js';
|
|||||||
|
|
||||||
const file = path.join(getDirName(), '../', 'db/jobs.json');
|
const file = path.join(getDirName(), '../', 'db/jobs.json');
|
||||||
const adapter = new JSONFileSync(file);
|
const adapter = new JSONFileSync(file);
|
||||||
const db = new LowdashAdapter(adapter);
|
const db = new LowdashAdapter(adapter, { jobs: [] });
|
||||||
|
|
||||||
db.read();
|
db.read();
|
||||||
|
|
||||||
db.data ||= { jobs: [] };
|
|
||||||
|
|
||||||
export const upsertJob = ({ jobId, name, blacklist = [], enabled = true, provider, notificationAdapter, userId }) => {
|
export const upsertJob = ({ jobId, name, blacklist = [], enabled = true, provider, notificationAdapter, userId }) => {
|
||||||
const currentJob =
|
const currentJob =
|
||||||
@@ -78,6 +77,17 @@ export const removeJobsByUserId = (userId) => {
|
|||||||
.value();
|
.value();
|
||||||
db.write();
|
db.write();
|
||||||
};
|
};
|
||||||
|
export const removeJobsByUserName = (userName) => {
|
||||||
|
db.chain
|
||||||
|
.get('jobs')
|
||||||
|
.filter((job) => job.username === userName)
|
||||||
|
.forEach((job) => listingStorage.removeListings(job.id));
|
||||||
|
db.chain
|
||||||
|
.get('jobs')
|
||||||
|
.remove((job) => job.username === userName)
|
||||||
|
.value();
|
||||||
|
db.write();
|
||||||
|
};
|
||||||
export const getJobs = () => {
|
export const getJobs = () => {
|
||||||
return db.chain
|
return db.chain
|
||||||
.get('jobs')
|
.get('jobs')
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ import LowdashAdapter from './LowDashAdapter.js';
|
|||||||
|
|
||||||
const file = path.join(getDirName(), '../', 'db/jobListingData.json');
|
const file = path.join(getDirName(), '../', 'db/jobListingData.json');
|
||||||
const adapter = new JSONFileSync(file);
|
const adapter = new JSONFileSync(file);
|
||||||
const db = new LowdashAdapter(adapter);
|
const db = new LowdashAdapter(adapter, {});
|
||||||
|
|
||||||
db.read();
|
db.read();
|
||||||
|
|
||||||
db.data ||= {};
|
|
||||||
|
|
||||||
const buildKey = (jobKey, providerId, endpoint) => {
|
const buildKey = (jobKey, providerId, endpoint) => {
|
||||||
let key = `${jobKey}`;
|
let key = `${jobKey}`;
|
||||||
if (jobKey == null && endpoint == null) {
|
if (jobKey == null && endpoint == null) {
|
||||||
|
|||||||
@@ -1,29 +1,36 @@
|
|||||||
import { JSONFileSync } from 'lowdb/node';
|
import { JSONFileSync } from 'lowdb/node';
|
||||||
import { getDirName } from '../../utils.js';
|
import {config, getDirName} from '../../utils.js';
|
||||||
import * as hasher from '../security/hash.js';
|
import * as hasher from '../security/hash.js';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import * as jobStorage from './jobStorage.js';
|
import * as jobStorage from './jobStorage.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import LowdashAdapter from './LowDashAdapter.js';
|
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 file = path.join(getDirName(), '../', 'db/users.json');
|
||||||
const adapter = new JSONFileSync(file);
|
const adapter = new JSONFileSync(file);
|
||||||
const db = new LowdashAdapter(adapter);
|
const db = new LowdashAdapter(adapter, defaultData);
|
||||||
|
|
||||||
db.read();
|
db.read();
|
||||||
db.data ||= {
|
|
||||||
user: [
|
|
||||||
//you probably want to change the default password ;)
|
|
||||||
{
|
|
||||||
id: nanoid(),
|
|
||||||
lastLogin: Date.now(),
|
|
||||||
username: 'admin',
|
|
||||||
password: hasher.hash('admin'),
|
|
||||||
isAdmin: true,
|
|
||||||
isDemo: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getUsers = (withPassword) => {
|
export const getUsers = (withPassword) => {
|
||||||
const jobs = jobStorage.getJobs();
|
const jobs = jobStorage.getJobs();
|
||||||
@@ -84,3 +91,29 @@ export const removeUser = (userId) => {
|
|||||||
.value();
|
.value();
|
||||||
db.write();
|
db.write();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const handleDemoUser = () => {
|
||||||
|
if(!config.demoMode){
|
||||||
|
const user = db.chain.get('user').value();
|
||||||
|
db.chain.get('user').value();
|
||||||
|
db.chain.set('user', user.filter((u) => u.username !== 'demo')).value();
|
||||||
|
db.write();
|
||||||
|
}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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
72
lib/services/tracking/Tracker.js
Normal file
72
lib/services/tracking/Tracker.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import Mixpanel from 'mixpanel';
|
||||||
|
import { getJobs } from '../storage/jobStorage.js';
|
||||||
|
import { getUniqueId } from './uniqueId.js';
|
||||||
|
import { config, inDevMode } from '../../utils.js';
|
||||||
|
|
||||||
|
const mixpanelTracker = Mixpanel.init('718670ef1c58c0208256c1e408a3d75e');
|
||||||
|
|
||||||
|
const distinct_id = getUniqueId() || 'N/A';
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
const jobs = getJobs();
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
mixpanelTracker.track(
|
||||||
|
'fredy_tracking',
|
||||||
|
enrichTrackingObject({
|
||||||
|
adapter: Array.from(activeAdapter),
|
||||||
|
provider: Array.from(activeProvider),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note, this will only be used when Fredy runs in demo mode
|
||||||
|
*/
|
||||||
|
export function trackDemoJobCreated(jobData) {
|
||||||
|
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({}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enrichTrackingObject(trackingObject) {
|
||||||
|
const platform = process.platform;
|
||||||
|
const arch = process.arch;
|
||||||
|
const language = process.env.LANG || 'en';
|
||||||
|
const nodeVersion = process.version || 'N/A';
|
||||||
|
|
||||||
|
return {
|
||||||
|
...trackingObject,
|
||||||
|
isDemo: config.demoMode,
|
||||||
|
platform,
|
||||||
|
arch,
|
||||||
|
nodeVersion,
|
||||||
|
language,
|
||||||
|
distinct_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
19
lib/services/tracking/uniqueId.js
Normal file
19
lib/services/tracking/uniqueId.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { hostname, arch, cpus, platform } from 'os';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Don't worry, we are not evil ;) We however need a unique id per running instance
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export const getUniqueId = () => {
|
||||||
|
const systemInfo = {
|
||||||
|
hostname: hostname(),
|
||||||
|
architecture: arch(),
|
||||||
|
cpuCount: cpus().length,
|
||||||
|
platform: platform(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseData = JSON.stringify(systemInfo);
|
||||||
|
|
||||||
|
return createHash('sha256').update(baseData).digest('hex');
|
||||||
|
};
|
||||||
110
lib/utils.js
110
lib/utils.js
@@ -1,51 +1,91 @@
|
|||||||
import { dirname } from 'node:path';
|
import {dirname} from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import {fileURLToPath} from 'node:url';
|
||||||
import { readFile } from 'fs/promises';
|
import {readFile} from 'fs/promises';
|
||||||
|
import {createHash} from 'crypto';
|
||||||
|
import {DEFAULT_CONFIG} from './defaultConfig.js';
|
||||||
|
|
||||||
|
function inDevMode(){
|
||||||
|
return process.env.NODE_ENV == null || process.env.NODE_ENV !== 'production';
|
||||||
|
}
|
||||||
|
|
||||||
function isOneOf(word, arr) {
|
function isOneOf(word, arr) {
|
||||||
if (arr == null || arr.length === 0) {
|
if (arr == null || arr.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const expression = String.raw`\b(${arr.join('|')})\b`;
|
const expression = String.raw`\b(${arr.join('|')})\b`;
|
||||||
const blacklist = new RegExp(expression, 'ig');
|
const blacklist = new RegExp(expression, 'ig');
|
||||||
return blacklist.test(word);
|
return blacklist.test(word);
|
||||||
}
|
}
|
||||||
|
|
||||||
function nullOrEmpty(val) {
|
function nullOrEmpty(val) {
|
||||||
return val == null || val.length === 0;
|
return val == null || val.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function timeStringToMs(timeString, now) {
|
function timeStringToMs(timeString, now) {
|
||||||
const d = new Date(now);
|
const d = new Date(now);
|
||||||
const parts = timeString.split(':');
|
const parts = timeString.split(':');
|
||||||
d.setHours(parts[0]);
|
d.setHours(parts[0]);
|
||||||
d.setMinutes(parts[1]);
|
d.setMinutes(parts[1]);
|
||||||
d.setSeconds(0);
|
d.setSeconds(0);
|
||||||
return d.getTime();
|
return d.getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
function duringWorkingHoursOrNotSet(config, now) {
|
function duringWorkingHoursOrNotSet(config, now) {
|
||||||
const { workingHours } = config;
|
const {workingHours} = config;
|
||||||
if (workingHours == null || nullOrEmpty(workingHours.from) || nullOrEmpty(workingHours.to)) {
|
if (workingHours == null || nullOrEmpty(workingHours.from) || nullOrEmpty(workingHours.to)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const toDate = timeStringToMs(workingHours.to, now);
|
const toDate = timeStringToMs(workingHours.to, now);
|
||||||
const fromDate = timeStringToMs(workingHours.from, now);
|
const fromDate = timeStringToMs(workingHours.from, now);
|
||||||
return fromDate <= now && toDate >= now;
|
return fromDate <= now && toDate >= now;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDirName() {
|
function getDirName() {
|
||||||
return dirname(fileURLToPath(import.meta.url));
|
return dirname(fileURLToPath(import.meta.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = JSON.parse(await readFile(new URL('../conf/config.json', import.meta.url)));
|
function buildHash(...inputs) {
|
||||||
|
if (inputs == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const cleaned = inputs.filter(i => i != null && i.length > 0);
|
||||||
|
if (cleaned.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return createHash('sha256')
|
||||||
|
.update(cleaned.join(','))
|
||||||
|
.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
export { isOneOf };
|
let config = {};
|
||||||
export { nullOrEmpty };
|
export async function readConfigFromStorage(){
|
||||||
export { duringWorkingHoursOrNotSet };
|
return JSON.parse(await readFile(new URL('../conf/config.json', import.meta.url)));
|
||||||
export { getDirName };
|
}
|
||||||
export { config };
|
|
||||||
|
export async function refreshConfig(){
|
||||||
|
try {
|
||||||
|
config = await readConfigFromStorage();
|
||||||
|
//backwards compatability...
|
||||||
|
config.analyticsEnabled ??= null;
|
||||||
|
config.demoMode ??= false;
|
||||||
|
} catch (error) {
|
||||||
|
config = {...DEFAULT_CONFIG};
|
||||||
|
console.error('Error reading config file', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await refreshConfig();
|
||||||
|
|
||||||
|
export {isOneOf};
|
||||||
|
export {inDevMode};
|
||||||
|
export {nullOrEmpty};
|
||||||
|
export {duringWorkingHoursOrNotSet};
|
||||||
|
export {getDirName};
|
||||||
|
export {config};
|
||||||
|
export {buildHash};
|
||||||
export default {
|
export default {
|
||||||
isOneOf,
|
isOneOf,
|
||||||
nullOrEmpty,
|
nullOrEmpty,
|
||||||
duringWorkingHoursOrNotSet,
|
duringWorkingHoursOrNotSet,
|
||||||
getDirName,
|
getDirName,
|
||||||
config,
|
config,
|
||||||
};
|
};
|
||||||
|
|||||||
91
package.json
91
package.json
@@ -1,20 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "fredy",
|
"name": "fredy",
|
||||||
"version": "7.0.0",
|
"version": "10.4.1",
|
||||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node index.js",
|
"start": "node prod.js",
|
||||||
"dev": "yarn && rm -rf ./ui/public/* && vite",
|
"dev": "yarn && rm -rf ./ui/public/* && vite",
|
||||||
"ui": "rm -rf ./ui/public/* && vite",
|
"ui": "rm -rf ./ui/public/* && vite",
|
||||||
"prod": "yarn && vite build --emptyOutDir",
|
"prod": "yarn && vite build --emptyOutDir",
|
||||||
"format": "prettier --write lib/**/*.js ui/src/**/*.js test/**/*.js *.js --single-quote --print-width 120",
|
"format": "prettier --write lib/**/*.js ui/src/**/*.jsx test/**/*.js *.js --single-quote --print-width 120",
|
||||||
"test": "mocha --loader=esmock --timeout 3000000 test/**/*.test.js",
|
"test": "mocha --loader=esmock --timeout 3000000 test/**/*.test.js",
|
||||||
"lint": "eslint ./index.js ./lib/**/*.js ./test/**/*.js"
|
"lint": "eslint ./index.js ./lib/**/*.js ./test/**/*.js ./ui/src/**/*.jsx"
|
||||||
},
|
|
||||||
"husky": {
|
|
||||||
"hooks": {
|
|
||||||
"pre-commit": "lint-staged"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
@@ -45,7 +40,7 @@
|
|||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.0.0",
|
"node": ">=20.0.0",
|
||||||
"npm": ">=7.0.0"
|
"npm": ">=7.0.0"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
@@ -55,55 +50,55 @@
|
|||||||
"Firefox ESR"
|
"Firefox ESR"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@douyinfe/semi-ui": "2.70.1",
|
||||||
"@rematch/core": "2.2.0",
|
"@rematch/core": "2.2.0",
|
||||||
"@rematch/loading": "2.1.2",
|
"@rematch/loading": "2.1.2",
|
||||||
"@sendgrid/mail": "7.7.0",
|
"@sendgrid/mail": "8.1.4",
|
||||||
"@vitejs/plugin-react": "3.1.0",
|
"@vitejs/plugin-react": "4.3.4",
|
||||||
"better-sqlite3": "8.2.0",
|
"better-sqlite3": "^11.6.0",
|
||||||
"body-parser": "1.20.2",
|
"body-parser": "1.20.3",
|
||||||
"cookie-session": "2.0.0",
|
"cookie-session": "2.1.0",
|
||||||
"handlebars": "4.7.7",
|
"handlebars": "4.7.8",
|
||||||
"highcharts": "10.3.3",
|
"highcharts": "12.0.1",
|
||||||
"highcharts-react-official": "3.2.0",
|
"highcharts-react-official": "3.2.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "4.17.21",
|
||||||
"lowdb": "5.1.0",
|
"lowdb": "6.0.1",
|
||||||
"markdown": "^0.5.0",
|
"markdown": "^0.5.0",
|
||||||
"nanoid": "4.0.1",
|
"mixpanel": "^0.18.0",
|
||||||
"node-fetch": "3.3.1",
|
"nanoid": "5.0.9",
|
||||||
"node-mailjet": "6.0.2",
|
"node-fetch": "3.3.2",
|
||||||
"query-string": "8.1.0",
|
"node-mailjet": "6.0.6",
|
||||||
"react": "18.2.0",
|
"query-string": "9.1.1",
|
||||||
"react-dom": "18.2.0",
|
"react": "18.3.1",
|
||||||
"react-redux": "8.0.5",
|
"react-dom": "18.3.1",
|
||||||
|
"react-redux": "9.1.2",
|
||||||
"react-router": "5.2.1",
|
"react-router": "5.2.1",
|
||||||
"react-router-dom": "5.3.0",
|
"react-router-dom": "5.3.0",
|
||||||
"react-switch": "7.0.0",
|
"redux": "5.0.1",
|
||||||
"redux": "4.2.1",
|
"redux-thunk": "3.1.0",
|
||||||
"redux-thunk": "2.4.2",
|
"restana": "4.9.9",
|
||||||
"restana": "4.9.7",
|
"serve-static": "1.16.2",
|
||||||
"semantic-ui-react": "2.1.4",
|
|
||||||
"serve-static": "1.15.0",
|
|
||||||
"slack": "11.0.2",
|
"slack": "11.0.2",
|
||||||
"string-similarity": "^4.0.4",
|
"string-similarity": "^4.0.4",
|
||||||
"vite": "4.1.4",
|
"vite": "5.4.11",
|
||||||
"x-ray": "2.3.4"
|
"x-ray": "2.3.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"esmock": "2.1.0",
|
"@babel/core": "7.26.0",
|
||||||
"@babel/core": "7.21.0",
|
"@babel/eslint-parser": "7.25.9",
|
||||||
"@babel/eslint-parser": "7.19.1",
|
"@babel/preset-env": "7.26.0",
|
||||||
"@babel/preset-env": "7.20.2",
|
"@babel/preset-react": "7.25.9",
|
||||||
"@babel/preset-react": "7.18.6",
|
"chai": "5.1.2",
|
||||||
"chai": "4.3.7",
|
"eslint": "8.56.0",
|
||||||
"eslint": "8.36.0",
|
"eslint-config-prettier": "8.8.0",
|
||||||
"eslint-config-prettier": "8.7.0",
|
"eslint-plugin-react": "7.37.2",
|
||||||
"eslint-plugin-react": "7.32.2",
|
"esmock": "2.6.9",
|
||||||
"history": "5.3.0",
|
"history": "5.3.0",
|
||||||
"husky": "4.3.8",
|
"husky": "9.1.7",
|
||||||
"less": "4.1.3",
|
"less": "4.2.1",
|
||||||
"lint-staged": "13.2.0",
|
"lint-staged": "15.2.10",
|
||||||
"mocha": "10.2.0",
|
"mocha": "10.8.2",
|
||||||
"prettier": "2.8.4",
|
"prettier": "3.3.3",
|
||||||
"redux-logger": "3.0.6"
|
"redux-logger": "3.0.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
prod.js
Normal file
2
prod.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
import('./index.js');
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { providerConfig, mockFredy } from '../utils.js';
|
import { providerConfig, mockFredy } from '../utils.js';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
import * as provider from '../../lib/provider/einsAImmobilien.js';
|
import * as provider from '../../lib/provider/einsAImmobilien.js';
|
||||||
|
|
||||||
const expect = chai.expect;
|
|
||||||
|
|
||||||
describe('#einsAImmobilien testsuite()', () => {
|
describe('#einsAImmobilien testsuite()', () => {
|
||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
@@ -22,7 +20,7 @@ describe('#einsAImmobilien testsuite()', () => {
|
|||||||
expect(notificationObj.serviceName).to.equal('einsAImmobilien');
|
expect(notificationObj.serviceName).to.equal('einsAImmobilien');
|
||||||
notificationObj.payload.forEach((notify) => {
|
notificationObj.payload.forEach((notify) => {
|
||||||
/** check the actual structure **/
|
/** check the actual structure **/
|
||||||
expect(notify.id).to.be.a('number');
|
expect(notify.id).to.be.a('string');
|
||||||
expect(notify.price).to.be.a('string');
|
expect(notify.price).to.be.a('string');
|
||||||
expect(notify.size).to.be.a('string');
|
expect(notify.size).to.be.a('string');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.title).to.be.a('string');
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { providerConfig, mockFredy } from '../utils.js';
|
import { providerConfig, mockFredy } from '../utils.js';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
import * as provider from '../../lib/provider/immobilienDe.js';
|
import * as provider from '../../lib/provider/immobilienDe.js';
|
||||||
const expect = chai.expect;
|
|
||||||
describe('#immobilien.de testsuite()', () => {
|
describe('#immobilien.de testsuite()', () => {
|
||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
import * as provider from '../../lib/provider/immonet.js';
|
import * as provider from '../../lib/provider/immonet.js';
|
||||||
const expect = chai.expect;
|
import * as scrapingAnt from '../../lib/services/scrapingAnt.js';
|
||||||
|
|
||||||
describe('#immonet testsuite()', () => {
|
describe('#immonet testsuite()', () => {
|
||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
@@ -12,6 +13,13 @@ describe('#immonet testsuite()', () => {
|
|||||||
it('should test immonet provider', async () => {
|
it('should test immonet provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
return await new Promise((resolve) => {
|
return await new Promise((resolve) => {
|
||||||
|
if (!scrapingAnt.isScrapingAntApiKeySet()) {
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
console.info('Skipping Immonet test as ScrapingAnt Api Key is not set.');
|
||||||
|
/* eslint-enable no-console */
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immonet', similarityCache);
|
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immonet', similarityCache);
|
||||||
fredy.execute().then((listing) => {
|
fredy.execute().then((listing) => {
|
||||||
expect(listing).to.be.a('array');
|
expect(listing).to.be.a('array');
|
||||||
@@ -20,17 +28,17 @@ describe('#immonet testsuite()', () => {
|
|||||||
expect(notificationObj.serviceName).to.equal('immonet');
|
expect(notificationObj.serviceName).to.equal('immonet');
|
||||||
notificationObj.payload.forEach((notify) => {
|
notificationObj.payload.forEach((notify) => {
|
||||||
/** check the actual structure **/
|
/** check the actual structure **/
|
||||||
expect(notify.id).to.be.a('number');
|
expect(notify.id).to.be.a('string');
|
||||||
expect(notify.price).to.be.a('string');
|
expect(notify.price).to.be.a('string');
|
||||||
expect(notify.size).to.be.a('string');
|
expect(notify.size).to.be.a('string');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.title).to.be.a('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.link).to.be.a('string');
|
||||||
expect(notify.address).to.be.a('string');
|
expect(notify.address).to.be.a('string');
|
||||||
|
|
||||||
/** check the values if possible **/
|
/** check the values if possible **/
|
||||||
expect(notify.price).that.does.include('€');
|
expect(notify.price).that.does.include('€');
|
||||||
expect(notify.size).that.does.include('m²');
|
expect(notify.size).that.does.include('m²');
|
||||||
expect(notify.title).to.be.not.empty;
|
expect(notify.title).to.be.not.empty;
|
||||||
expect(notify.link).that.does.include('https://www.immonet.de');
|
|
||||||
expect(notify.address).to.be.not.empty;
|
expect(notify.address).to.be.not.empty;
|
||||||
});
|
});
|
||||||
resolve();
|
resolve();
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
import * as provider from '../../lib/provider/immoscout.js';
|
import * as provider from '../../lib/provider/immoscout.js';
|
||||||
import * as scrapingAnt from '../../lib/services/scrapingAnt.js';
|
import * as scrapingAnt from '../../lib/services/scrapingAnt.js';
|
||||||
const expect = chai.expect;
|
|
||||||
describe('#immoscout testsuite()', () => {
|
describe('#immoscout testsuite()', () => {
|
||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
import * as provider from '../../lib/provider/immoswp.js';
|
import * as provider from '../../lib/provider/immoswp.js';
|
||||||
const expect = chai.expect;
|
|
||||||
describe('#immoswp testsuite()', () => {
|
describe('#immoswp testsuite()', () => {
|
||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
@@ -25,12 +25,10 @@ describe('#immoswp testsuite()', () => {
|
|||||||
expect(notify.size).to.be.a('string');
|
expect(notify.size).to.be.a('string');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.title).to.be.a('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.link).to.be.a('string');
|
||||||
expect(notify.address).to.be.a('string');
|
|
||||||
/** check the values if possible **/
|
/** check the values if possible **/
|
||||||
expect(notify.price).that.does.include('€');
|
expect(notify.price).that.does.include('€');
|
||||||
expect(notify.title).to.be.not.empty;
|
expect(notify.title).to.be.not.empty;
|
||||||
expect(notify.link).that.does.include('https://immo.swp.de');
|
expect(notify.link).that.does.include('https://immo.swp.de');
|
||||||
expect(notify.address).to.be.not.empty;
|
|
||||||
});
|
});
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
import * as provider from '../../lib/provider/immowelt.js';
|
import * as provider from '../../lib/provider/immowelt.js';
|
||||||
const expect = chai.expect;
|
|
||||||
describe('#immowelt testsuite()', () => {
|
describe('#immowelt testsuite()', () => {
|
||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
import * as provider from '../../lib/provider/kleinanzeigen.js';
|
import * as provider from '../../lib/provider/kleinanzeigen.js';
|
||||||
const expect = chai.expect;
|
|
||||||
describe('#kleinanzeigen testsuite()', () => {
|
describe('#kleinanzeigen testsuite()', () => {
|
||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
@@ -20,13 +20,13 @@ describe('#kleinanzeigen testsuite()', () => {
|
|||||||
expect(notificationObj.serviceName).to.equal('kleinanzeigen');
|
expect(notificationObj.serviceName).to.equal('kleinanzeigen');
|
||||||
notificationObj.payload.forEach((notify) => {
|
notificationObj.payload.forEach((notify) => {
|
||||||
/** check the actual structure **/
|
/** check the actual structure **/
|
||||||
expect(notify.id).to.be.a('number');
|
expect(notify.id).to.be.a('string');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.title).to.be.a('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.link).to.be.a('string');
|
||||||
expect(notify.address).to.be.a('string');
|
expect(notify.address).to.be.a('string');
|
||||||
/** check the values if possible **/
|
/** check the values if possible **/
|
||||||
expect(notify.title).to.be.not.empty;
|
expect(notify.title).to.be.not.empty;
|
||||||
expect(notify.link).that.does.include('https://www.ebay-kleinanzeigen.de');
|
expect(notify.link).that.does.include('https://www.kleinanzeigen.de');
|
||||||
expect(notify.address).to.be.not.empty;
|
expect(notify.address).to.be.not.empty;
|
||||||
});
|
});
|
||||||
resolve();
|
resolve();
|
||||||
|
|||||||
@@ -1,36 +1,44 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import {get} from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import {mockFredy, providerConfig} from '../utils.js';
|
||||||
import chai from 'chai';
|
import {expect} from 'chai';
|
||||||
import * as provider from '../../lib/provider/neubauKompass.js';
|
import * as provider from '../../lib/provider/neubauKompass.js';
|
||||||
const expect = chai.expect;
|
import * as scrapingAnt from '../../lib/services/scrapingAnt.js';
|
||||||
|
|
||||||
describe('#neubauKompass testsuite()', () => {
|
describe('#neubauKompass testsuite()', () => {
|
||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
});
|
});
|
||||||
provider.init(providerConfig.neubauKompass, [], []);
|
provider.init(providerConfig.neubauKompass, [], []);
|
||||||
it('should test neubauKompass provider', async () => {
|
it('should test neubauKompass provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
return await new Promise((resolve) => {
|
return await new Promise((resolve) => {
|
||||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'neubauKompass', similarityCache);
|
if (!scrapingAnt.isScrapingAntApiKeySet()) {
|
||||||
fredy.execute().then((listing) => {
|
/* eslint-disable no-console */
|
||||||
expect(listing).to.be.a('array');
|
console.info('Skipping Neubaukompass test as ScrapingAnt Api Key is not set.');
|
||||||
const notificationObj = get();
|
/* eslint-enable no-console */
|
||||||
expect(notificationObj.serviceName).to.equal('neubauKompass');
|
resolve();
|
||||||
notificationObj.payload.forEach((notify) => {
|
return;
|
||||||
expect(notify).to.be.a('object');
|
}
|
||||||
/** check the actual structure **/
|
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'neubauKompass', similarityCache);
|
||||||
expect(notify.id).to.be.a('string');
|
fredy.execute().then((listing) => {
|
||||||
expect(notify.title).to.be.a('string');
|
expect(listing).to.be.a('array');
|
||||||
expect(notify.link).to.be.a('string');
|
const notificationObj = get();
|
||||||
expect(notify.address).to.be.a('string');
|
expect(notificationObj.serviceName).to.equal('neubauKompass');
|
||||||
/** check the values if possible **/
|
notificationObj.payload.forEach((notify) => {
|
||||||
expect(notify.title).to.be.not.empty;
|
expect(notify).to.be.a('object');
|
||||||
expect(notify.link).that.does.include('https://www.neubaukompass.de');
|
/** check the actual structure **/
|
||||||
expect(notify.address).to.be.not.empty;
|
expect(notify.id).to.be.a('string');
|
||||||
});
|
expect(notify.title).to.be.a('string');
|
||||||
resolve();
|
expect(notify.link).to.be.a('string');
|
||||||
});
|
expect(notify.address).to.be.a('string');
|
||||||
|
/** check the values if possible **/
|
||||||
|
expect(notify.title).to.be.not.empty;
|
||||||
|
expect(notify.link).that.does.include('https://www.neubaukompass.de');
|
||||||
|
expect(notify.address).to.be.not.empty;
|
||||||
|
});
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,11 +9,11 @@
|
|||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"immonet": {
|
"immonet": {
|
||||||
"url": "https://www.immonet.de/immobiliensuche/sel.do?pageoffset=1&listsize=100&objecttype=1&locationname=Düsseldorf&acid=&actype=&district=8717&district=8718&district=8719&district=8720&district=8721&district=8723&district=8724&district=8725&district=8727&district=8728&district=8729&district=8730&district=8731&district=8732&district=8733&district=8737&district=8738&district=8741&district=8745&district=8747&district=8750&district=8752&district=8754&district=8755&district=8756&district=8759&district=8760&district=8761&district=8763&district=8764&district=8765&ajaxIsRadiusActive=false&sortby=19&suchart=1&radius=0&pcatmtypes=1_1&pCatMTypeStoragefield=&parentcat=1&marketingtype=1&fromprice=&toprice=420000&fromarea=90&toarea=&fromplotarea=&toplotarea=&fromrooms=3&torooms=&objectcat=225&objectcat=18&objectcat=17&objectcat=12&objectcat=16&objectcat=181&objectcat=14&objectcat=15&objectcat=226&objectcat=13&wbs=-1&fromyear=&toyear=",
|
"url": "https://www.immonet.de/immobiliensuche/beta?pageoffset=1&listsize=100&objecttype=1&locationname=D%C3%BCsseldorf&acid=&actype=&district=8717&district=8718&district=8719&district=8720&district=8721&district=8723&district=8724&district=8725&district=8727&district=8728&district=8729&district=8730&district=8731&district=8732&district=8733&district=8737&district=8738&district=8741&district=8745&district=8747&district=8750&district=8752&district=8754&district=8755&district=8756&district=8759&district=8760&district=8761&district=8763&district=8764&district=8765&ajaxIsRadiusActive=false&sortby=19&suchart=1&radius=0&pcatmtypes=1_1&pCatMTypeStoragefield=&parentcat=1&marketingtype=1&fromprice=&toprice=420000&fromarea=90&toarea=&fromplotarea=&toplotarea=&fromrooms=3&torooms=&objectcat=225&objectcat=18&objectcat=17&objectcat=12&objectcat=16&objectcat=181&objectcat=14&objectcat=15&objectcat=226&objectcat=13&wbs=-1&fromyear=&toyear=",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"immowelt": {
|
"immowelt": {
|
||||||
"url": "https://www.immowelt.de/liste/duesseldorf/wohnungen/kaufen?d=true&rmi=3&sd=DESC&sf=TIMESTAMP&sp=1",
|
"url": "https://www.immowelt.de/classified-search?distributionTypes=Buy,Buy_Auction,Compulsory_Auction&estateTypes=House,Apartment&locations=AD08DE2350",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"immoscout": {
|
"immoscout": {
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"kleinanzeigen": {
|
"kleinanzeigen": {
|
||||||
"url": "https://www.ebay-kleinanzeigen.de/s-immobilien/duesseldorf/anzeige:angebote/wohnung/k0c195l2068r5",
|
"url": "https://www.kleinanzeigen.de/s-immobilien/duesseldorf/anzeige:angebote/wohnung/k0c195l2068r5",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"neubauKompass": {
|
"neubauKompass": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import utils from '../../lib/utils.js';
|
import utils from '../../lib/utils.js';
|
||||||
import assert from 'assert';
|
import assert from 'assert';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
const expect = chai.expect;
|
|
||||||
const fakeWorkingHoursConfig = (from, to) => ({
|
const fakeWorkingHoursConfig = (from, to) => ({
|
||||||
workingHours: {
|
workingHours: {
|
||||||
to,
|
to,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
import * as provider from '../../lib/provider/wgGesucht.js';
|
import * as provider from '../../lib/provider/wgGesucht.js';
|
||||||
const expect = chai.expect;
|
|
||||||
describe('#wgGesucht testsuite()', () => {
|
describe('#wgGesucht testsuite()', () => {
|
||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
@@ -23,7 +23,6 @@ describe('#wgGesucht testsuite()', () => {
|
|||||||
expect(notify.id).to.be.a('string');
|
expect(notify.id).to.be.a('string');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.title).to.be.a('string');
|
||||||
expect(notify.details).to.be.a('string');
|
expect(notify.details).to.be.a('string');
|
||||||
expect(notify.size).to.be.a('string');
|
|
||||||
expect(notify.price).to.be.a('string');
|
expect(notify.price).to.be.a('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.link).to.be.a('string');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
import { readFile } from 'fs/promises';
|
import { readFile } from 'fs/promises';
|
||||||
import mutator from '../../lib/services/queryStringMutator.js';
|
import mutator from '../../lib/services/queryStringMutator.js';
|
||||||
import queryString from 'query-string';
|
import queryString from 'query-string';
|
||||||
const expect = chai.expect;
|
|
||||||
|
|
||||||
const data = await readFile(new URL('./testData.json', import.meta.url));
|
const data = await readFile(new URL('./testData.json', import.meta.url));
|
||||||
|
|
||||||
const testData = JSON.parse(data);
|
const testData = JSON.parse(data);
|
||||||
|
|
||||||
let _provider = await Promise.all(
|
let _provider = await Promise.all(
|
||||||
fs.readdirSync('./lib/provider/').map(async (integPath) => await import(`../../lib/provider/${integPath}`))
|
fs.readdirSync('./lib/provider/').map(async (integPath) => await import(`../../lib/provider/${integPath}`)),
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"url": "https://www.immowelt.de/liste/40589/wohnungen/mieten?d=true&sd=DESC&sf=PRIMARY_PRICE_AMOUNT&sp=1",
|
"url": "https://www.immowelt.de/classified-search?distributionTypes=Buy,Buy_Auction,Compulsory_Auction&estateTypes=House,Apartment&locations=AD08DE2350",
|
||||||
"shouldBecome": "https://www.immowelt.de/liste/40589/wohnungen/mieten?d=true&sd=DESC&sf=TIMESTAMP&sp=1",
|
"shouldBecome": "https://www.immowelt.de/classified-search?distributionTypes=Buy,Buy_Auction,Compulsory_Auction&estateTypes=House,Apartment&locations=AD08DE2350&order=DateDesc",
|
||||||
"id": "immowelt"
|
"id": "immowelt"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import SimilarityCacheEntry from '../../lib/services/similarity-check/SimilarityCacheEntry.js';
|
import SimilarityCacheEntry from '../../lib/services/similarity-check/SimilarityCacheEntry.js';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
const expect = chai.expect;
|
|
||||||
describe('similarityCheck', () => {
|
describe('similarityCheck', () => {
|
||||||
describe('#similarityCheck()', () => {
|
describe('#similarityCheck()', () => {
|
||||||
it('should be false', () => {
|
it('should be false', () => {
|
||||||
@@ -29,10 +29,10 @@ describe('similarityCheck', () => {
|
|||||||
it('should be false', () => {
|
it('should be false', () => {
|
||||||
const check = new SimilarityCacheEntry(0);
|
const check = new SimilarityCacheEntry(0);
|
||||||
check.setCacheEntry(
|
check.setCacheEntry(
|
||||||
'The index is known by several other names, especially Sørensen–Dice 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.'
|
'The index is known by several other names, especially Sørensen–Dice 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(
|
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.'
|
'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.',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
16
test/utils/utils.test.js
Normal file
16
test/utils/utils.test.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { expect } from 'chai';
|
||||||
|
import {buildHash} from '../../lib/utils.js';
|
||||||
|
|
||||||
|
describe('utilsCheck', () => {
|
||||||
|
describe('#utilsCheck()', () => {
|
||||||
|
it('should be null when null input', () => {
|
||||||
|
expect(buildHash(null)).to.be.null;
|
||||||
|
});
|
||||||
|
it('should be null when null empty', () => {
|
||||||
|
expect(buildHash('')).to.be.null;
|
||||||
|
});
|
||||||
|
it('should return a value', () => {
|
||||||
|
expect(buildHash('bla', '', null)).to.be.a.string;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
155
ui/src/App.jsx
155
ui/src/App.jsx
@@ -1,100 +1,111 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, {useEffect} from 'react';
|
||||||
|
|
||||||
import InsufficientPermission from './components/permission/InsufficientPermission';
|
import InsufficientPermission from './components/permission/InsufficientPermission';
|
||||||
import PermissionAwareRoute from './components/permission/PermissionAwareRoute';
|
import PermissionAwareRoute from './components/permission/PermissionAwareRoute';
|
||||||
import GeneralSettings from './views/generalSettings/GeneralSettings';
|
import GeneralSettings from './views/generalSettings/GeneralSettings';
|
||||||
import ToastsContainer from './components/toasts/ToastContainer';
|
|
||||||
import JobMutation from './views/jobs/mutation/JobMutation';
|
import JobMutation from './views/jobs/mutation/JobMutation';
|
||||||
import UserMutator from './views/user/mutation/UserMutator';
|
import UserMutator from './views/user/mutation/UserMutator';
|
||||||
import ToastContext from './components/toasts/ToastContext';
|
|
||||||
import JobInsight from './views/jobs/insights/JobInsight.jsx';
|
import JobInsight from './views/jobs/insights/JobInsight.jsx';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import {useDispatch, useSelector} from 'react-redux';
|
||||||
import useToast from './components/toasts/useToast';
|
import {Switch, Redirect} from 'react-router-dom';
|
||||||
import { Switch, Redirect } from 'react-router-dom';
|
|
||||||
import Logout from './components/logout/Logout';
|
import Logout from './components/logout/Logout';
|
||||||
import Logo from './components/logo/Logo';
|
import Logo from './components/logo/Logo';
|
||||||
import Menu from './components/menu/Menu';
|
import Menu from './components/menu/Menu';
|
||||||
import Login from './views/login/Login';
|
import Login from './views/login/Login';
|
||||||
import Users from './views/user/Users';
|
import Users from './views/user/Users';
|
||||||
import Jobs from './views/jobs/Jobs';
|
import Jobs from './views/jobs/Jobs';
|
||||||
import { Route } from 'react-router';
|
import {Route} from 'react-router';
|
||||||
|
|
||||||
import './App.less';
|
import './App.less';
|
||||||
|
import TrackingModal from './components/tracking/TrackingModal.jsx';
|
||||||
|
import {Banner} from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
export default function FredyApp() {
|
export default function FredyApp() {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const [showToast, onToastFinished, toasts] = useToast();
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [loading, setLoading] = React.useState(true);
|
const currentUser = useSelector((state) => state.user.currentUser);
|
||||||
const currentUser = useSelector((state) => state.user.currentUser);
|
const settings = useSelector((state) => state.generalSettings.settings);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function init() {
|
async function init() {
|
||||||
await dispatch.provider.getProvider();
|
await dispatch.user.getCurrentUser();
|
||||||
await dispatch.jobs.getJobs();
|
if (!needsLogin()) {
|
||||||
await dispatch.jobs.getProcessingTimes();
|
await dispatch.provider.getProvider();
|
||||||
await dispatch.notificationAdapter.getAdapter();
|
await dispatch.jobs.getJobs();
|
||||||
await dispatch.user.getCurrentUser();
|
await dispatch.jobs.getProcessingTimes();
|
||||||
|
await dispatch.notificationAdapter.getAdapter();
|
||||||
|
await dispatch.generalSettings.getGeneralSettings();
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(false);
|
init();
|
||||||
}
|
}, [currentUser?.userId]);
|
||||||
init();
|
|
||||||
}, [currentUser?.userId]);
|
|
||||||
|
|
||||||
const needsLogin = () => {
|
const needsLogin = () => {
|
||||||
return currentUser == null || Object.keys(currentUser).length === 0;
|
return currentUser == null || Object.keys(currentUser).length === 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isAdmin = () => currentUser != null && currentUser.isAdmin;
|
const isAdmin = () => currentUser != null && currentUser.isAdmin;
|
||||||
|
|
||||||
const login = () => (
|
const login = () => (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route name="Login" path={'/login'} component={Login} />
|
<Route name="Login" path={'/login'} component={Login}/>
|
||||||
<Redirect from="*" to={'/login'} />
|
<Redirect from="*" to={'/login'}/>
|
||||||
</Switch>
|
</Switch>
|
||||||
);
|
);
|
||||||
|
|
||||||
return loading ? null : needsLogin() ? (
|
return loading ? null : needsLogin() ? (
|
||||||
login()
|
login()
|
||||||
) : (
|
) : (
|
||||||
<ToastContext.Provider value={{ showToast }}>
|
<div className="app">
|
||||||
<div className="app">
|
<div className="app__container">
|
||||||
<div className="app__container">
|
<Logout/>
|
||||||
<Logout />
|
<Logo width={190} white/>
|
||||||
<Logo width={190} white />
|
<Menu isAdmin={isAdmin()}/>
|
||||||
<Menu isAdmin={isAdmin()} />
|
|
||||||
<ToastsContainer toasts={toasts} onToastFinished={onToastFinished} />
|
|
||||||
<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"
|
|
||||||
path="/users/new"
|
|
||||||
component={<UserMutator />}
|
|
||||||
currentUser={currentUser}
|
|
||||||
/>
|
|
||||||
<PermissionAwareRoute
|
|
||||||
name="Edit a user"
|
|
||||||
path="/users/edit/:userId"
|
|
||||||
component={<UserMutator />}
|
|
||||||
currentUser={currentUser}
|
|
||||||
/>
|
|
||||||
<PermissionAwareRoute name="Users" path="/users" component={<Users />} currentUser={currentUser} />
|
|
||||||
<PermissionAwareRoute
|
|
||||||
name="General Settings"
|
|
||||||
path="/generalSettings"
|
|
||||||
component={<GeneralSettings />}
|
|
||||||
currentUser={currentUser}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Redirect from="/" to={'/jobs'} />
|
{settings.demoMode && (
|
||||||
</Switch>
|
<>
|
||||||
|
<Banner fullMode={true}
|
||||||
|
type="info"
|
||||||
|
bordered
|
||||||
|
closeIcon={null}
|
||||||
|
description="You're currently viewing the demo version of Fredy. Jobs won't scrape websites, and any changes you make will be reverted at midnight."
|
||||||
|
/>
|
||||||
|
<br/>
|
||||||
|
</>)}
|
||||||
|
{(settings.analyticsEnabled === null && !settings.demoMode) && <TrackingModal/>}
|
||||||
|
<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"
|
||||||
|
path="/users/new"
|
||||||
|
component={<UserMutator/>}
|
||||||
|
currentUser={currentUser}
|
||||||
|
/>
|
||||||
|
<PermissionAwareRoute
|
||||||
|
name="Edit a user"
|
||||||
|
path="/users/edit/:userId"
|
||||||
|
component={<UserMutator/>}
|
||||||
|
currentUser={currentUser}
|
||||||
|
/>
|
||||||
|
<PermissionAwareRoute name="Users" path="/users" component={<Users/>} currentUser={currentUser}/>
|
||||||
|
<PermissionAwareRoute
|
||||||
|
name="General Settings"
|
||||||
|
path="/generalSettings"
|
||||||
|
component={<GeneralSettings/>}
|
||||||
|
currentUser={currentUser}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Redirect from="/" to={'/jobs'}/>
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
</ToastContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
FredyApp.displayName = 'FredyApp';
|
FredyApp.displayName = 'FredyApp';
|
||||||
|
|||||||
@@ -4,11 +4,9 @@
|
|||||||
width:100%;
|
width:100%;
|
||||||
|
|
||||||
&__container {
|
&__container {
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
padding: 1rem 1rem;
|
padding: 1rem 1rem;
|
||||||
background-color: #595959f5;
|
color: var(--semi-color-text-0);
|
||||||
color: #f1f1f1;
|
background-color: #232429;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,4 +16,28 @@
|
|||||||
|
|
||||||
.ui.black.label, .ui.black.labels .label {
|
.ui.black.label, .ui.black.labels .label {
|
||||||
background-color: #31303078!important;
|
background-color: #31303078!important;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:link {
|
||||||
|
color: #54a9ff;
|
||||||
|
background-color: transparent;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:visited {
|
||||||
|
color: #54a9ff;
|
||||||
|
background-color: transparent;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: #54a9ff;
|
||||||
|
background-color: transparent;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:active {
|
||||||
|
color: #54a9ff;
|
||||||
|
background-color: transparent;
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
@@ -5,9 +5,11 @@ import { HashRouter } from 'react-router-dom';
|
|||||||
import { createHashHistory } from 'history';
|
import { createHashHistory } from 'history';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import en_US from '@douyinfe/semi-ui/lib/es/locale/source/en_US';
|
||||||
|
import { LocaleProvider } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
const container = document.getElementById('fredy');
|
const container = document.getElementById('fredy');
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
|
|
||||||
const history = createHashHistory();
|
const history = createHashHistory();
|
||||||
|
|
||||||
import App from './App';
|
import App from './App';
|
||||||
@@ -17,7 +19,9 @@ import './Index.less';
|
|||||||
root.render(
|
root.render(
|
||||||
<Provider store={reduxStore}>
|
<Provider store={reduxStore}>
|
||||||
<HashRouter history={history}>
|
<HashRouter history={history}>
|
||||||
<App />
|
<LocaleProvider locale={en_US}>
|
||||||
|
<App />
|
||||||
|
</LocaleProvider>
|
||||||
</HashRouter>
|
</HashRouter>
|
||||||
</Provider>
|
</Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,5 +2,14 @@ body, html {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: #595959f5;
|
background-color: #232429;
|
||||||
|
}
|
||||||
|
|
||||||
|
.semi-table-row-head{
|
||||||
|
background-color: #2b2b2b !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.semi-table-row-cell {
|
||||||
|
background-color: #333333 !important;
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Header } from 'semantic-ui-react';
|
import { Typography } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
import './Headline.less';
|
export default function Headline({ text, size = 3 } = {}) {
|
||||||
|
const { Title } = Typography;
|
||||||
export default function Headline({ text, size = 'medium', className = '' } = {}) {
|
|
||||||
return (
|
return (
|
||||||
<Header className={`headline ${className}`} size={size}>
|
<Title heading={size} style={{ marginBottom: '1rem' }}>
|
||||||
{text}
|
{text}
|
||||||
</Header>
|
</Title>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
.headline{
|
|
||||||
color: #f1f1f1 !important;
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Button } from 'semantic-ui-react';
|
import { Button } from '@douyinfe/semi-ui';
|
||||||
import { xhrPost } from '../../services/xhr';
|
import { xhrPost } from '../../services/xhr';
|
||||||
|
import { IconUser } from '@douyinfe/semi-icons';
|
||||||
const Logout = function Logout() {
|
const Logout = function Logout() {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
content="Logout"
|
icon={<IconUser />}
|
||||||
labelPosition="left"
|
type="danger"
|
||||||
icon="user"
|
theme="solid"
|
||||||
size="mini"
|
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await xhrPost('/api/login/logout');
|
await xhrPost('/api/login/logout');
|
||||||
location.reload();
|
location.reload();
|
||||||
}}
|
}}
|
||||||
negative
|
>
|
||||||
/>
|
Logout
|
||||||
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,49 +1,54 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import { Icon, Menu } from 'semantic-ui-react';
|
import { Tabs, TabPane } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
import './Menu.less';
|
|
||||||
import { useLocation } from 'react-router';
|
import { useLocation } from 'react-router';
|
||||||
|
import { IconUser, IconTerminal, IconSetting } from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
|
function parsePathName(name) {
|
||||||
|
const split = name.split('/').filter((s) => s.length !== 0);
|
||||||
|
return '/' + split[0];
|
||||||
|
}
|
||||||
|
|
||||||
const TopMenu = function TopMenu({ isAdmin }) {
|
const TopMenu = function TopMenu({ isAdmin }) {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const isActiveRoute = (name) => location.pathname.indexOf(name) !== -1;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu pointing secondary className="topMenu">
|
<Tabs type="line" activeKey={parsePathName(location.pathname)} onTabClick={(key) => history.push(key)}>
|
||||||
<Menu.Item
|
<TabPane
|
||||||
name="jobs"
|
itemKey="/jobs"
|
||||||
active={isActiveRoute('jobs')}
|
tab={
|
||||||
className={isActiveRoute('jobs') ? 'topMenu__active' : 'topMenu__item'}
|
<span>
|
||||||
onClick={() => history.push('/jobs')}
|
<IconTerminal />
|
||||||
>
|
Jobs
|
||||||
<Icon name="search" /> Job Configuration
|
</span>
|
||||||
</Menu.Item>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Menu.Item
|
<TabPane
|
||||||
name="user"
|
itemKey="/users"
|
||||||
active={isActiveRoute('users')}
|
tab={
|
||||||
className={isActiveRoute('users') ? 'topMenu__active' : 'topMenu__item'}
|
<span>
|
||||||
onClick={() => history.push('/users')}
|
<IconUser />
|
||||||
>
|
User
|
||||||
<Icon name="user" /> User configuration
|
</span>
|
||||||
</Menu.Item>
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Menu.Item
|
<TabPane
|
||||||
name="general"
|
itemKey="/generalSettings"
|
||||||
active={isActiveRoute('general')}
|
tab={
|
||||||
className={isActiveRoute('general') ? 'topMenu__active' : 'topMenu__item'}
|
<span>
|
||||||
onClick={() => history.push('/generalSettings')}
|
<IconSetting />
|
||||||
>
|
General
|
||||||
<Icon name="cog" /> General Settings
|
</span>
|
||||||
</Menu.Item>
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Menu>
|
</Tabs>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
.topMenu {
|
|
||||||
border-bottom: 1px solid #b7b7b7f2 !important;
|
|
||||||
|
|
||||||
&__active {
|
|
||||||
border-bottom: 1px solid #06dcfff2 !important;
|
|
||||||
font-weight: 550 !important;
|
|
||||||
color: #3ed7ff !important;
|
|
||||||
margin: 0 0 -1px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__item {
|
|
||||||
color: #fffffff2 !important;
|
|
||||||
border-color: transparent !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Header } from 'semantic-ui-react';
|
|
||||||
import insufficientPermission from '../../assets/insufficient_permission.png';
|
import insufficientPermission from '../../assets/insufficient_permission.png';
|
||||||
|
|
||||||
export default function InsufficientPermission() {
|
export default function InsufficientPermission() {
|
||||||
@@ -7,9 +6,7 @@ export default function InsufficientPermission() {
|
|||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flexDirection: 'column' }}>
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flexDirection: 'column' }}>
|
||||||
<img src={insufficientPermission} height={250} />
|
<img src={insufficientPermission} height={250} />
|
||||||
<br />
|
<br />
|
||||||
<Header as="h4" inverted>
|
<h4>Insufficient permission :(</h4>
|
||||||
Insufficient permission :(
|
|
||||||
</Header>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Header, Icon, Popup, Segment } from 'semantic-ui-react';
|
import { Card } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
import './SegmentParts.less';
|
import './SegmentParts.less';
|
||||||
|
|
||||||
export const SegmentPart = ({ name, icon = null, children, helpText }) => (
|
export const SegmentPart = ({ name, Icon = null, children, helpText }) => {
|
||||||
<Segment inverted>
|
const { Meta } = Card;
|
||||||
<Header as="h5" inverted sub>
|
|
||||||
{icon && <Icon name={icon} inverted size="mini" />}
|
|
||||||
<Header.Content>{name}</Header.Content>
|
|
||||||
</Header>
|
|
||||||
|
|
||||||
<Popup
|
return (
|
||||||
content={helpText}
|
<Card
|
||||||
trigger={
|
title={
|
||||||
<span className="generalSettings__help">
|
<Meta title={name} description={helpText} avatar={Icon == null ? null : <Icon size="extra-extra-small" />} />
|
||||||
{' '}
|
|
||||||
<Icon name="help circle" inverted />
|
|
||||||
What is this?
|
|
||||||
</span>
|
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
<Segment inverted className="segmentParts">
|
|
||||||
{children}
|
{children}
|
||||||
</Segment>
|
</Card>
|
||||||
</Segment>
|
);
|
||||||
);
|
};
|
||||||
|
|||||||
@@ -1,66 +1,79 @@
|
|||||||
import React, { Fragment } from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Table, Button } from 'semantic-ui-react';
|
import { Button, Empty, Table, Switch } from '@douyinfe/semi-ui';
|
||||||
import Switch from 'react-switch';
|
import { IconDelete, IconEdit, IconHistogram } from '@douyinfe/semi-icons';
|
||||||
|
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||||
const emptyTable = () => {
|
const empty = (
|
||||||
return (
|
<Empty
|
||||||
<Table.Row>
|
image={<IllustrationNoResult />}
|
||||||
<Table.Cell collapsing colSpan={6} style={{ textAlign: 'center' }}>
|
darkModeImage={<IllustrationNoResultDark />}
|
||||||
No Data
|
description={'No jobs available'}
|
||||||
</Table.Cell>
|
/>
|
||||||
</Table.Row>
|
);
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const content = (jobs, onJobRemoval, onJobStatusChanged, onJobEdit, onJobInsight) => {
|
|
||||||
return (
|
|
||||||
<Fragment>
|
|
||||||
{Object.keys(jobs).map((jobKey) => {
|
|
||||||
const job = jobs[jobKey];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Table.Row key={jobKey}>
|
|
||||||
<Table.Cell collapsing>
|
|
||||||
<Switch onChange={(checked) => onJobStatusChanged(job.id, checked)} checked={job.enabled} />
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>{job.name}</Table.Cell>
|
|
||||||
<Table.Cell>{job.numberOfFoundListings || 0}</Table.Cell>
|
|
||||||
<Table.Cell>{job.provider.length || 0}</Table.Cell>
|
|
||||||
<Table.Cell>{job.notificationAdapter.length || 0}</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<div style={{ float: 'right' }}>
|
|
||||||
<Button circular color="teal" icon="chart line" onClick={() => onJobInsight(job.id)} />
|
|
||||||
<Button circular color="blue" icon="edit" onClick={() => onJobEdit(job.id)} />
|
|
||||||
<Button circular color="red" icon="trash" onClick={() => onJobRemoval(job.id)} />
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged, onJobEdit, onJobInsight } = {}) {
|
export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged, onJobEdit, onJobInsight } = {}) {
|
||||||
return (
|
return (
|
||||||
<Table singleLine inverted>
|
<Table
|
||||||
<Table.Header>
|
pagination={false}
|
||||||
<Table.Row>
|
empty={empty}
|
||||||
<Table.HeaderCell />
|
columns={[
|
||||||
<Table.HeaderCell>Job Name</Table.HeaderCell>
|
{
|
||||||
<Table.HeaderCell>Number of findings</Table.HeaderCell>
|
title: '',
|
||||||
<Table.HeaderCell>Active provider</Table.HeaderCell>
|
dataIndex: '',
|
||||||
<Table.HeaderCell>Active notification adapter</Table.HeaderCell>
|
render: (job) => {
|
||||||
<Table.HeaderCell></Table.HeaderCell>
|
return <Switch onChange={(checked) => onJobStatusChanged(job.id, checked)} checked={job.enabled} />;
|
||||||
</Table.Row>
|
},
|
||||||
</Table.Header>
|
},
|
||||||
|
{
|
||||||
<Table.Body>
|
title: 'Job Name',
|
||||||
{Object.keys(jobs).length === 0
|
dataIndex: 'name',
|
||||||
? emptyTable()
|
},
|
||||||
: content(jobs, onJobRemoval, onJobStatusChanged, onJobEdit, onJobInsight)}
|
{
|
||||||
</Table.Body>
|
title: 'Number of findings',
|
||||||
</Table>
|
dataIndex: 'numberOfFoundListings',
|
||||||
|
render: (value) => {
|
||||||
|
return value || 0;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Active provider',
|
||||||
|
dataIndex: 'provider',
|
||||||
|
render: (value) => {
|
||||||
|
return value.length || 0;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Active notification adapter',
|
||||||
|
dataIndex: 'notificationAdapter',
|
||||||
|
render: (value) => {
|
||||||
|
return value.length || 0;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
dataIndex: 'tools',
|
||||||
|
render: (_, job) => {
|
||||||
|
return (
|
||||||
|
<div style={{ float: 'right' }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<IconHistogram />}
|
||||||
|
onClick={() => onJobInsight(job.id)}
|
||||||
|
style={{ marginRight: '1rem' }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="secondary"
|
||||||
|
icon={<IconEdit />}
|
||||||
|
onClick={() => onJobEdit(job.id)}
|
||||||
|
style={{ marginRight: '1rem' }}
|
||||||
|
/>
|
||||||
|
<Button type="danger" icon={<IconDelete />} onClick={() => onJobRemoval(job.id)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
dataSource={jobs}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +1,38 @@
|
|||||||
import React, { Fragment } from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Table, Button } from 'semantic-ui-react';
|
import { Empty, Table, Button } from '@douyinfe/semi-ui';
|
||||||
|
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
||||||
const emptyTable = () => {
|
|
||||||
return (
|
|
||||||
<Table.Row>
|
|
||||||
<Table.Cell collapsing colSpan="3" style={{ textAlign: 'center' }}>
|
|
||||||
No Data
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const content = (adapterData, onRemove, onEdit) => {
|
|
||||||
return (
|
|
||||||
<Fragment>
|
|
||||||
{adapterData.map((data) => {
|
|
||||||
return (
|
|
||||||
<Table.Row key={data.id}>
|
|
||||||
<Table.Cell>{data.name}</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<div style={{ float: 'right' }}>
|
|
||||||
<Button circular color="blue" icon="edit" onClick={() => onEdit(data.id)} />
|
|
||||||
<Button circular color="red" icon="trash" onClick={() => onRemove(data.id)} />
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function NotificationAdapterTable({ notificationAdapter = [], onRemove, onEdit } = {}) {
|
export default function NotificationAdapterTable({ notificationAdapter = [], onRemove, onEdit } = {}) {
|
||||||
return (
|
return (
|
||||||
<Table singleLine inverted>
|
<Table
|
||||||
<Table.Header>
|
pagination={false}
|
||||||
<Table.Row>
|
empty={<Empty description="No Data" />}
|
||||||
<Table.HeaderCell>Notification Adapter Name</Table.HeaderCell>
|
columns={[
|
||||||
<Table.HeaderCell></Table.HeaderCell>
|
{
|
||||||
</Table.Row>
|
title: 'Notification Adapter Name',
|
||||||
</Table.Header>
|
dataIndex: 'name',
|
||||||
|
},
|
||||||
|
|
||||||
<Table.Body>
|
{
|
||||||
{notificationAdapter.length === 0 ? emptyTable() : content(notificationAdapter, onRemove, onEdit)}
|
title: '',
|
||||||
</Table.Body>
|
dataIndex: 'tools',
|
||||||
</Table>
|
render: (_, record) => {
|
||||||
|
return (
|
||||||
|
<div style={{ float: 'right' }}>
|
||||||
|
<Button
|
||||||
|
type="secondary"
|
||||||
|
icon={<IconEdit />}
|
||||||
|
onClick={() => onEdit(record.id)}
|
||||||
|
style={{ marginRight: '1rem' }}
|
||||||
|
/>
|
||||||
|
<Button type="danger" icon={<IconDelete />} onClick={() => onRemove(record.id)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
dataSource={notificationAdapter}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,42 @@
|
|||||||
import React, { Fragment } from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Table, Button } from 'semantic-ui-react';
|
import { Empty, Table, Button } from '@douyinfe/semi-ui';
|
||||||
|
import { IconDelete } from '@douyinfe/semi-icons';
|
||||||
const emptyTable = () => {
|
|
||||||
return (
|
|
||||||
<Table.Row>
|
|
||||||
<Table.Cell collapsing colSpan="3" style={{ textAlign: 'center' }}>
|
|
||||||
No Data
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const content = (providerData, onRemove) => {
|
|
||||||
return (
|
|
||||||
<Fragment>
|
|
||||||
{providerData.map((data) => {
|
|
||||||
return (
|
|
||||||
<Table.Row key={data.id}>
|
|
||||||
<Table.Cell>{data.name}</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<a href={data.url} target="_blank" rel="noopener noreferrer">
|
|
||||||
Visit site
|
|
||||||
</a>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<div style={{ float: 'right' }}>
|
|
||||||
<Button circular color="red" icon="trash" onClick={() => onRemove(data.id)} />
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ProviderTable({ providerData = [], onRemove } = {}) {
|
export default function ProviderTable({ providerData = [], onRemove } = {}) {
|
||||||
return (
|
return (
|
||||||
<Table singleLine inverted>
|
<Table
|
||||||
<Table.Header>
|
pagination={false}
|
||||||
<Table.Row>
|
empty={<Empty description="No Provider available" />}
|
||||||
<Table.HeaderCell>Provider Name</Table.HeaderCell>
|
columns={[
|
||||||
<Table.HeaderCell>Url</Table.HeaderCell>
|
{
|
||||||
<Table.HeaderCell></Table.HeaderCell>
|
title: 'Provider Name',
|
||||||
</Table.Row>
|
dataIndex: 'name',
|
||||||
</Table.Header>
|
},
|
||||||
|
{
|
||||||
<Table.Body>{providerData.length === 0 ? emptyTable() : content(providerData, onRemove)}</Table.Body>
|
title: 'Provider Url',
|
||||||
</Table>
|
dataIndex: 'url',
|
||||||
|
render: (_, data) => {
|
||||||
|
return (
|
||||||
|
<a href={data.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
Visit site
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
dataIndex: 'tools',
|
||||||
|
render: (_, record) => {
|
||||||
|
return (
|
||||||
|
<div style={{ float: 'right' }}>
|
||||||
|
<Button type="danger" icon={<IconDelete />} onClick={() => onRemove(record.id)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
dataSource={providerData}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,58 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Table, Button } from 'semantic-ui-react';
|
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||||
import { format } from '../../services/time/timeService';
|
import { format } from '../../services/time/timeService';
|
||||||
|
import { Table, Button, Empty } from '@douyinfe/semi-ui';
|
||||||
|
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
const emptyTable = () => {
|
const empty = (
|
||||||
return (
|
<Empty
|
||||||
<Table.Row>
|
image={<IllustrationNoResult />}
|
||||||
<Table.Cell collapsing colSpan={4} style={{ textAlign: 'center' }}>
|
darkModeImage={<IllustrationNoResultDark />}
|
||||||
No Data
|
description={'No user available'}
|
||||||
</Table.Cell>
|
/>
|
||||||
</Table.Row>
|
);
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const content = (user, onUserRemoval, onUserEdit) => {
|
|
||||||
return user.map((user) => {
|
|
||||||
return (
|
|
||||||
<Table.Row key={user.id}>
|
|
||||||
<Table.Cell>{user.username}</Table.Cell>
|
|
||||||
<Table.Cell>{user.lastLogin == null ? '---' : format(user.lastLogin)}</Table.Cell>
|
|
||||||
<Table.Cell>{user.numberOfJobs}</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<div style={{ float: 'right' }}>
|
|
||||||
<Button circular color="red" icon="trash" onClick={() => onUserRemoval(user.id)} />
|
|
||||||
<Button circular color="blue" icon="edit" onClick={() => onUserEdit(user.id)} />
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function UserTable({ user = [], onUserRemoval, onUserEdit } = {}) {
|
export default function UserTable({ user = [], onUserRemoval, onUserEdit } = {}) {
|
||||||
return (
|
return (
|
||||||
<Table inverted>
|
<Table
|
||||||
<Table.Header>
|
pagination={false}
|
||||||
<Table.Row>
|
empty={empty}
|
||||||
<Table.HeaderCell>Username</Table.HeaderCell>
|
columns={[
|
||||||
<Table.HeaderCell>Last login</Table.HeaderCell>
|
{
|
||||||
<Table.HeaderCell>Number of jobs</Table.HeaderCell>
|
title: 'Username',
|
||||||
<Table.HeaderCell></Table.HeaderCell>
|
dataIndex: 'username',
|
||||||
</Table.Row>
|
},
|
||||||
</Table.Header>
|
{
|
||||||
|
title: 'Last login',
|
||||||
<Table.Body>{user.length === 0 ? emptyTable() : content(user, onUserRemoval, onUserEdit)}</Table.Body>
|
dataIndex: 'lastLogin',
|
||||||
</Table>
|
render: (value) => {
|
||||||
|
return format(value);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Number of jobs',
|
||||||
|
dataIndex: 'numberOfJobs',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
dataIndex: 'tools',
|
||||||
|
render: (value, user) => {
|
||||||
|
return (
|
||||||
|
<div style={{ float: 'right' }}>
|
||||||
|
<Button
|
||||||
|
type="danger"
|
||||||
|
icon={<IconDelete />}
|
||||||
|
onClick={() => onUserRemoval(user.id)}
|
||||||
|
style={{ marginRight: '1rem' }}
|
||||||
|
/>
|
||||||
|
<Button type="primary" icon={<IconEdit />} onClick={() => onUserEdit(user.id)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
dataSource={user}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import './Toasts.css';
|
|
||||||
|
|
||||||
export default function Toast({ id, delay = 5500, message, onHide, backgroundColor, color, title }) {
|
|
||||||
const [className, setClassname] = React.useState('toast-container show-toast');
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
let hideTimeout = null;
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
setClassname('toast-container hide-toast');
|
|
||||||
hideTimeout = setTimeout(() => {
|
|
||||||
onHide && onHide(id);
|
|
||||||
}, 500);
|
|
||||||
}, delay);
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
clearTimeout(hideTimeout);
|
|
||||||
};
|
|
||||||
}, [id, delay, onHide]);
|
|
||||||
return (
|
|
||||||
<div className={className} style={{ backgroundColor, color }}>
|
|
||||||
<h5>{title}</h5>
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import Toast from './Toast';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function ToastsContainer({ toasts, onToastFinished }) {
|
|
||||||
return (
|
|
||||||
<div className="toasts-container">
|
|
||||||
{toasts.map((toast, index) => (
|
|
||||||
<Toast key={index} {...toast} onHide={onToastFinished} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { createContext } from 'react';
|
|
||||||
|
|
||||||
const CheckoutDrawerContext = createContext({
|
|
||||||
showToast: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default CheckoutDrawerContext;
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
.toasts-container {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 65535;
|
|
||||||
right: 0;
|
|
||||||
max-width: 250px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column-reverse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toasts-container > .toast-container {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toasts-container:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-container {
|
|
||||||
visibility: hidden;
|
|
||||||
position: relative;
|
|
||||||
z-index: 65535;
|
|
||||||
right: -1000px;
|
|
||||||
|
|
||||||
background-color: skyblue;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 10px;
|
|
||||||
|
|
||||||
min-width: 10rem;
|
|
||||||
min-height: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-container.show-toast {
|
|
||||||
visibility: visible;
|
|
||||||
right: 24px;
|
|
||||||
animation: slidein 0.5s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-container.hide-toast {
|
|
||||||
visibility: visible;
|
|
||||||
animation: slideout 0.5s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slidein {
|
|
||||||
from {
|
|
||||||
right: -1000px;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
right: 24px;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideout {
|
|
||||||
from {
|
|
||||||
right: 24px;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
right: -1000px;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function useToast() {
|
|
||||||
const [toasts, setToasts] = React.useState([]);
|
|
||||||
|
|
||||||
const showToast = ({ message, delay, color, backgroundColor, title }) => {
|
|
||||||
const toast = {
|
|
||||||
id: toasts.length,
|
|
||||||
message,
|
|
||||||
delay,
|
|
||||||
backgroundColor,
|
|
||||||
color,
|
|
||||||
title,
|
|
||||||
};
|
|
||||||
setToasts([...toasts, toast].reverse());
|
|
||||||
};
|
|
||||||
|
|
||||||
const onToastFinished = (id) => {
|
|
||||||
setToasts(toasts.filter((toast) => toast.id !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
return [showToast, onToastFinished, toasts];
|
|
||||||
}
|
|
||||||
48
ui/src/components/tracking/TrackingModal.jsx
Normal file
48
ui/src/components/tracking/TrackingModal.jsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {Modal} from '@douyinfe/semi-ui';
|
||||||
|
import Logo from '../logo/Logo.jsx';
|
||||||
|
import {xhrPost} from '../../services/xhr.js';
|
||||||
|
|
||||||
|
import './TrackingModal.less';
|
||||||
|
|
||||||
|
const saveResponse = async (analyticsEnabled) => {
|
||||||
|
await xhrPost('/api/admin/generalSettings', {
|
||||||
|
analyticsEnabled
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TrackingModal() {
|
||||||
|
|
||||||
|
return <Modal
|
||||||
|
visible={true}
|
||||||
|
onOk={async () => {
|
||||||
|
await saveResponse(true);
|
||||||
|
location.reload();
|
||||||
|
}}
|
||||||
|
onCancel={async () => {
|
||||||
|
await saveResponse(false);
|
||||||
|
location.reload();
|
||||||
|
}}
|
||||||
|
maskClosable={false}
|
||||||
|
closable={false}
|
||||||
|
okText="Yes! I want to help"
|
||||||
|
cancelText="No, thanks"
|
||||||
|
>
|
||||||
|
<Logo white/>
|
||||||
|
<div className="trackingModal__description">
|
||||||
|
<p>Hey 👋</p>
|
||||||
|
<p>Fed up with popups? Yeah, me too. But this one’s important, and I promise it will only appear once ;)</p>
|
||||||
|
<p>Fredy is completely free (and will always remain free). If you’d like, you can support me by donating
|
||||||
|
through my GitHub, but there’s absolutely no obligation to do so.</p>
|
||||||
|
<p>However, it would be a huge
|
||||||
|
help if you’d 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.</p>
|
||||||
|
<p>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>
|
||||||
|
<p>Thanks🤘</p>
|
||||||
|
</div>
|
||||||
|
</Modal>;
|
||||||
|
|
||||||
|
}
|
||||||
5
ui/src/components/tracking/TrackingModal.less
Normal file
5
ui/src/components/tracking/TrackingModal.less
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.trackingModal {
|
||||||
|
&__description {
|
||||||
|
margin-top:10rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
ui/src/services/rematch/models/demoMode.js
Normal file
24
ui/src/services/rematch/models/demoMode.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ import { provider } from './models/provider';
|
|||||||
import { createLogger } from 'redux-logger';
|
import { createLogger } from 'redux-logger';
|
||||||
import { jobs } from './models/jobs';
|
import { jobs } from './models/jobs';
|
||||||
import { user } from './models/user';
|
import { user } from './models/user';
|
||||||
|
import { demoMode } from './models/demoMode.js';
|
||||||
import { init } from '@rematch/core';
|
import { init } from '@rematch/core';
|
||||||
const middleware = [];
|
const middleware = [];
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
@@ -16,6 +17,7 @@ const store = init({
|
|||||||
models: {
|
models: {
|
||||||
notificationAdapter,
|
notificationAdapter,
|
||||||
generalSettings,
|
generalSettings,
|
||||||
|
demoMode,
|
||||||
provider,
|
provider,
|
||||||
jobs,
|
jobs,
|
||||||
user,
|
user,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user