Compare commits

...

61 Commits

Author SHA1 Message Date
weakmap@gmail.com
5cceae11cc upgrading dependencies | adding sqlite for later analysis 2024-11-01 17:03:43 +01:00
weakmap@gmail.com
a4c5bfcbf7 fixing tests 2024-10-03 16:09:19 +02:00
weakmap@gmail.com
6d2ab5f958 making sure immowelt does not include suggested ranges 2024-10-03 16:03:47 +02:00
weakmap@gmail.com
d3cb3a5881 regex for einsAImmobilien price normalization | filter listings that does not have all required keys 2024-09-29 16:58:01 +02:00
Christian Kellner
111ef8be43 fixing kleinanzeigen test 2024-09-05 13:36:02 +02:00
Christian Kellner
35feb772d7 upgrading dependencies, fixing immowelt, using hash of price and id as unique identifier for listings 2024-09-05 13:34:14 +02:00
Christian Kellner
1bf012f13e next fredy version 2024-07-24 09:44:13 +02:00
Christian Kellner
933dc3fc64 using node 20 in tests as well 2024-07-24 09:43:11 +02:00
Christian Kellner
42c48fdceb using only 64 bit 2024-07-24 09:41:34 +02:00
Christian Kellner
f07aa0a06d using node 20 2024-07-24 09:39:27 +02:00
Christian Kellner
92db8219b4 building multi platform docker images (#101)
* building multi platform docker images

* upgrading dependencies | using scraping ant for neubaukompass
2024-07-24 09:32:21 +02:00
Christian Kellner
8ba3a53779 Upgrade version 2024-07-22 10:42:16 +02:00
Vladislav
e7db4e23f5 update error handling (#100) 2024-07-22 10:41:30 +02:00
Christian Kellner
06c4ebb975 fixing immoswp 2024-06-12 14:15:21 +02:00
Christian Kellner
b075e09ac2 upgrading dependencies | fixing confusing descriptions 2024-06-12 13:52:28 +02:00
Ali Sharafi
f215ab53db Add pm2 in dockerfile & restart docker ps on error (#97) 2024-04-22 16:14:27 +02:00
Christian Kellner
4ed92b246f Update package.json 2024-03-27 11:19:48 +01:00
pomeloy
4a9b60633a Remove unnecessary Apprise adapter config field (#95) 2024-03-27 11:19:14 +01:00
Christian Kellner
2123c1024b Update README.md 2024-03-25 21:10:09 +01:00
Christian Kellner
35767e6774 Update README.md 2024-03-25 21:09:31 +01:00
Christian Kellner
bf77ba2667 Update package.json 2024-03-17 08:02:39 +01:00
pomeloy
827c7e7321 Fix Apprise/Pushover notification title (#94) 2024-03-17 08:02:02 +01:00
Christian Kellner
7b63dc72cb Next release version 2024-03-13 15:05:56 +01:00
pomeloy
fd42b57010 Add Apprise notification adapter (#92) 2024-03-13 15:05:12 +01:00
pomeloy
f5917af8f3 Add Pushover notification adapter (#91)
* Add Pushover notification adapter
2024-03-13 15:04:22 +01:00
Christian Kellner
a85400d570 fixing immoscout 2024-02-08 10:36:47 +01:00
weakmap@gmail.com
8ce6668c78 upgrading dependencies 2024-01-26 19:51:45 +01:00
weakmap@gmail.com
2d8121a708 Merge branch 'master' of https://github.com/orangecoding/fredy 2024-01-26 19:36:43 +01:00
weakmap@gmail.com
172c039c79 fixing permission issue with docker 2024-01-26 19:36:35 +01:00
Farasath Ahamed
4ab1fd9294 Update immoscout.js (#88)
Fixes https://github.com/orangecoding/fredy/issues/87
2024-01-26 19:33:45 +01:00
weakmap@gmail.com
50b3fde075 using node 18 in github test setup 2024-01-01 16:24:39 +01:00
weakmap@gmail.com
1a3fc6f94d Merge branch 'master' of https://github.com/orangecoding/fredy 2024-01-01 16:24:31 +01:00
Christian Kellner
26ed42230a Using node v18 for github tests 2024-01-01 16:21:25 +01:00
weakmap@gmail.com
6f4defdc1b using node 18 in github test setup 2024-01-01 16:20:25 +01:00
weakmap@gmail.com
f798aed342 merged dev 2024-01-01 16:17:39 +01:00
weakmap@gmail.com
27e098c244 upgrading dependencies, dropping support for node < 18. Happy new Year 2024-01-01 16:14:25 +01:00
Christian Kellner
37948be0d3 next build version 2023-10-26 12:47:14 +02:00
Christian Kellner
cc7bbb77c4 removing sqlite as it only generates build errors 2023-10-26 12:46:42 +02:00
Christian Kellner
96da0b7892 Update LICENSE 2023-10-05 18:39:16 +02:00
jstnw
72993312c7 fix: kleinanzeigen price (#82) 2023-10-05 18:33:55 +02:00
weakmap@gmail.com
17b4bad2e4 fixing notification provider 2023-09-27 17:45:38 +02:00
weakmap@gmail.com
fbad4456d7 upgrading dependencies 2023-09-07 20:52:27 +02:00
weakmap@gmail.com
deec626feb Merge branch 'master' of https://github.com/orangecoding/fredy 2023-09-07 20:40:15 +02:00
weakmap@gmail.com
88c6641485 fixing wgGesucht test 2023-09-07 20:40:07 +02:00
Christian Kellner
f4eedda658 moving back to sqllite v8.2.0 2023-05-11 12:17:26 +02:00
Christian Kellner
d2b80561f8 moving back to sqllite v8.2.0 2023-05-11 12:16:28 +02:00
Christian Kellner
3bda88a075 upgrade dependencies 2023-05-11 11:51:23 +02:00
Christian Kellner
86465e0076 next release version 2023-05-08 09:33:20 +02:00
Christian Kellner
d947dad488 fixing ebay kleinanzeigen, now becoming kleinanzeigen 2023-05-08 09:32:07 +02:00
weakmap@gmail.com
23ef434fe1 next release version 2023-04-15 18:25:31 +02:00
weakmap@gmail.com
5e6d92c5be Merge branch 'master' of https://github.com/orangecoding/fredy 2023-04-15 18:24:57 +02:00
weakmap@gmail.com
4ba098e0b6 bringing back immonet by using scrapingant 2023-04-15 18:24:51 +02:00
Janek Bettinger
2d1a9a0452 fix Mailjet adapter (#76) 2023-04-15 16:27:27 +02:00
weakmap@gmail.com
6fbee3e7c6 Merge branch 'master' of https://github.com/orangecoding/fredy 2023-04-14 21:36:53 +02:00
Daniel Linsenmeyer
46775c3662 Fix validation and add ntfy as notification adapter (#75) 2023-04-14 17:16:08 +02:00
weakmap@gmail.com
1feb5bfda1 running all tests 2023-04-07 19:45:24 +02:00
weakmap@gmail.com
3ec9ed3b2a ignoring expired ssl certificate o0 2023-04-07 19:44:59 +02:00
Christian Kellner
75a536d5ab fixing ui not being shown 2023-03-20 15:08:06 +01:00
Christian Kellner
f3cded7e5d fixing npe 2023-03-20 10:56:33 +01:00
Christian Kellner
d7c9c4bf76 Modernizing ui (#73)
Modernizing ui
2023-03-20 08:52:13 +01:00
Christian Kellner
2c5eceb0c1 Making Fredy an ESM project (#70)
Making Fredy an ESM project
2023-03-13 13:42:43 +01:00
126 changed files with 6349 additions and 4966 deletions

View File

@@ -1,7 +1,6 @@
module.exports = { module.exports = {
env: { env: {
commonjs: true, es2021: true,
es6: true,
node: true, node: true,
browser: true, browser: true,
mocha: true, mocha: true,
@@ -17,7 +16,6 @@ module.exports = {
fetch: true, fetch: true,
}, },
parserOptions: { parserOptions: {
ecmaVersion: 2018,
sourceType: 'module', sourceType: 'module',
}, },
rules: { rules: {
@@ -205,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',
@@ -239,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
@@ -284,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',
},
],
}, },
}; };

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ _Fredy_ is supported by JetBrains under Open Source Support Program
## 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 +27,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%">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
<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%">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
<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,15 +78,20 @@ yarn run test
# Architecture # Architecture
![Architecture](/doc/architecture.jpg "Architecture") ![Architecture](/doc/architecture.jpg "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 ### 👐 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) See [Contributing](https://github.com/orangecoding/fredy/blob/master/CONTRIBUTING.md)

View File

@@ -1 +1 @@
{"interval":"60","port":9998,"scrapingAnt":{"apiKey":"","proxy":"datacenter"},"workingHours":{"from":"","to":""}} {"interval":"60","port":9998,"scrapingAnt":{"apiKey":"","proxy":"datacenter"},"workingHours":{"from":"","to":""}}

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

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

View File

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

View File

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

View File

@@ -1,41 +1,31 @@
const fs = require('fs'); import fs from 'fs';
import { config } from './lib/utils.js';
import * as similarityCache from './lib/services/similarity-check/similarityCache.js';
import { setLastJobExecution } from './lib/services/storage/listingsStorage.js';
import * as jobStorage from './lib/services/storage/jobStorage.js';
import FredyRuntime from './lib/FredyRuntime.js';
import { duringWorkingHoursOrNotSet } from './lib/utils.js';
import './lib/api/api.js';
//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');
} }
const path = './lib/provider'; const path = './lib/provider';
const provider = fs.readdirSync(path).filter((file) => file.endsWith('.js')); const provider = fs.readdirSync(path).filter((file) => file.endsWith('.js'));
const config = require('./conf/config.json');
const similarityCache = require('./lib/services/similarity-check/similarityCache');
const { setLastJobExecution } = require('./lib/services/storage/listingsStorage');
const jobStorage = require('./lib/services/storage/jobStorage');
const FredyRuntime = require('./lib/FredyRuntime');
const { duringWorkingHoursOrNotSet } = require('./lib/utils');
//starting the api service
require('./lib/api/api');
//assuming interval is always in minutes //assuming interval is always in minutes
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}`);
/* eslint-enable no-console */ /* eslint-enable no-console */
const fetchedProvider = await Promise.all(
provider.filter((provider) => provider.endsWith('.js')).map(async (pro) => import(`${path}/${pro}`))
);
setInterval( setInterval(
(function exec() { (function exec() {
const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(config, Date.now()); const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(config, Date.now());
if (isDuringWorkingHoursOrNotSet) { if (isDuringWorkingHoursOrNotSet) {
config.lastRun = Date.now(); config.lastRun = Date.now();
const fetchedProvider = provider
.filter((provider) => provider.endsWith('.js'))
.map((pro) => require(`${path}/${pro}`));
jobStorage jobStorage
.getJobs() .getJobs()
.filter((job) => job.enabled) .filter((job) => job.enabled)

View File

@@ -1,11 +1,9 @@
const { NoNewListingsWarning } = require('./errors'); import { NoNewListingsWarning } from './errors.js';
const { setKnownListings, getKnownListings } = require('./services/storage/listingsStorage'); import { setKnownListings, getKnownListings } from './services/storage/listingsStorage.js';
import * as notify from './notification/notify.js';
const notify = require('./notification/notify'); import xray from './services/scraper.js';
const xray = require('./services/scraper'); import * as scrapingAnt from './services/scrapingAnt.js';
const scrapingAnt = require('./services/scrapingAnt'); import urlModifier from './services/queryStringMutator.js';
const urlModifier = require('./services/queryStringMutator');
class FredyRuntime { class FredyRuntime {
/** /**
* *
@@ -22,7 +20,6 @@ class FredyRuntime {
this._jobKey = jobKey; this._jobKey = jobKey;
this._similarityCache = similarityCache; this._similarityCache = similarityCache;
} }
execute() { execute() {
return ( return (
//modify the url to make sure search order is correctly set //modify the url to make sure search order is correctly set
@@ -45,19 +42,18 @@ class FredyRuntime {
.catch(this._handleError.bind(this)) .catch(this._handleError.bind(this))
); );
} }
_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,25 +83,22 @@ class FredyRuntime {
} }
}); });
} }
_normalize(listings) { _normalize(listings) {
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);
if (newListings.length === 0) { if (newListings.length === 0) {
throw new NoNewListingsWarning(); throw new NoNewListingsWarning();
} }
return newListings; return newListings;
} }
_notify(newListings) { _notify(newListings) {
if (newListings.length === 0) { if (newListings.length === 0) {
throw new NoNewListingsWarning(); throw new NoNewListingsWarning();
@@ -113,7 +106,6 @@ class FredyRuntime {
const sendNotifications = notify.send(this._providerId, newListings, this._notificationConfig, this._jobKey); const sendNotifications = notify.send(this._providerId, newListings, this._notificationConfig, this._jobKey);
return Promise.all(sendNotifications).then(() => newListings); return Promise.all(sendNotifications).then(() => newListings);
} }
_save(newListings) { _save(newListings) {
const currentListings = getKnownListings(this._jobKey, this._providerId) || {}; const currentListings = getKnownListings(this._jobKey, this._providerId) || {};
newListings.forEach((listing) => { newListings.forEach((listing) => {
@@ -122,7 +114,6 @@ class FredyRuntime {
setKnownListings(this._jobKey, this._providerId, currentListings); setKnownListings(this._jobKey, this._providerId, currentListings);
return newListings; return newListings;
} }
_filterBySimilarListings(listings) { _filterBySimilarListings(listings) {
const filteredList = listings.filter((listing) => { const filteredList = listings.filter((listing) => {
const similar = this._similarityCache.hasSimilarEntries(this._jobKey, listing.title); const similar = this._similarityCache.hasSimilarEntries(this._jobKey, listing.title);
@@ -136,10 +127,8 @@ class FredyRuntime {
filteredList.forEach((filter) => this._similarityCache.addCacheEntry(this._jobKey, filter.title)); filteredList.forEach((filter) => this._similarityCache.addCacheEntry(this._jobKey, filter.title));
return filteredList; return filteredList;
} }
_handleError(err) { _handleError(err) {
if (err.name !== 'NoNewListingsWarning') console.error(err); if (err.name !== 'NoNewListingsWarning') console.error(err);
} }
} }
export default FredyRuntime;
module.exports = FredyRuntime;

View File

@@ -1,44 +1,36 @@
const { notificationAdapterRouter } = require('./routes/notificationAdapterRouter'); import { notificationAdapterRouter } from './routes/notificationAdapterRouter.js';
const { authInterceptor, cookieSession, adminInterceptor } = require('./security'); import { authInterceptor, cookieSession, adminInterceptor } from './security.js';
const { generalSettingsRouter } = require('./routes/generalSettingsRoute'); import { generalSettingsRouter } from './routes/generalSettingsRoute.js';
const { analyticsRouter } = require('./routes/analyticsRouter'); import { analyticsRouter } from './routes/analyticsRouter.js';
const { providerRouter } = require('./routes/providerRouter'); import { providerRouter } from './routes/providerRouter.js';
const { loginRouter } = require('./routes/loginRoute'); import { loginRouter } from './routes/loginRoute.js';
const config = require('../../conf/config.json'); import { config } from '../utils.js';
const { userRouter } = require('./routes/userRoute'); import { userRouter } from './routes/userRoute.js';
const { jobRouter } = require('./routes/jobRouter'); import { jobRouter } from './routes/jobRouter.js';
const bodyParser = require('body-parser'); import bodyParser from 'body-parser';
const service = require('restana')(); import restana from 'restana';
const files = require('serve-static'); import files from 'serve-static';
const path = require('path'); import path from 'path';
import { getDirName } from '../utils.js';
const staticService = files(path.join(__dirname, '../../ui/public')); const service = restana();
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());
service.use(cookieSession()); service.use(cookieSession());
service.use(staticService); service.use(staticService);
service.use('/api/admin', authInterceptor()); service.use('/api/admin', authInterceptor());
service.use('/api/jobs', authInterceptor()); service.use('/api/jobs', authInterceptor());
// /admin can only be accessed when user is having admin permissions // /admin can only be accessed when user is having admin permissions
service.use('/api/admin', adminInterceptor()); service.use('/api/admin', adminInterceptor());
service.use('/api/jobs/notificationAdapter', notificationAdapterRouter); service.use('/api/jobs/notificationAdapter', notificationAdapterRouter);
service.use('/api/admin/generalSettings', generalSettingsRouter); service.use('/api/admin/generalSettings', generalSettingsRouter);
service.use('/api/jobs/provider', providerRouter); service.use('/api/jobs/provider', providerRouter);
service.use('/api/jobs/insights', analyticsRouter); service.use('/api/jobs/insights', analyticsRouter);
service.use('/api/admin/users', userRouter); service.use('/api/admin/users', userRouter);
service.use('/api/jobs', jobRouter); service.use('/api/jobs', jobRouter);
service.use('/api/login', loginRouter); service.use('/api/login', loginRouter);
/* 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}`);
}); });
/* eslint-enable no-console */

View File

@@ -1,12 +1,10 @@
const service = require('restana')(); import restana from 'restana';
import * as listingStorage from '../../services/storage/listingsStorage.js';
const service = restana();
const analyticsRouter = service.newRouter(); const analyticsRouter = service.newRouter();
const listingStorage = require('../../services/storage/listingsStorage');
analyticsRouter.get('/:jobId', async (req, res) => { analyticsRouter.get('/:jobId', async (req, res) => {
const { jobId } = req.params; const { jobId } = req.params;
res.body = listingStorage.getListingProviderDataForAnalytics(jobId) || {}; res.body = listingStorage.getListingProviderDataForAnalytics(jobId) || {};
res.send(); res.send();
}); });
export { analyticsRouter };
exports.analyticsRouter = analyticsRouter;

View File

@@ -1,18 +1,16 @@
const service = require('restana')(); import restana from 'restana';
import { config, getDirName } from '../../utils.js';
import fs from 'fs';
const service = restana();
const generalSettingsRouter = service.newRouter(); const generalSettingsRouter = service.newRouter();
const config = require('../../../conf/config.json');
const fs = require('fs');
generalSettingsRouter.get('/', async (req, res) => { generalSettingsRouter.get('/', async (req, res) => {
res.body = Object.assign({}, config); res.body = Object.assign({}, config);
res.send(); res.send();
}); });
generalSettingsRouter.post('/', async (req, res) => { generalSettingsRouter.post('/', async (req, res) => {
const settings = req.body; const settings = req.body;
try { try {
fs.writeFileSync(`${__dirname}/../../../conf/config.json`, JSON.stringify(settings)); fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify(settings));
} 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.'));
@@ -20,5 +18,4 @@ generalSettingsRouter.post('/', async (req, res) => {
} }
res.send(); res.send();
}); });
export { generalSettingsRouter };
exports.generalSettingsRouter = generalSettingsRouter;

View File

@@ -1,12 +1,12 @@
const service = require('restana')(); import restana from 'restana';
import fetch from 'node-fetch';
import * as jobStorage from '../../services/storage/jobStorage.js';
import * as userStorage from '../../services/storage/userStorage.js';
import * as immoscoutProvider from '../../provider/immoscout.js';
import { config } from '../../utils.js';
import { isAdmin } from '../security.js';
const service = restana();
const jobRouter = service.newRouter(); const jobRouter = service.newRouter();
const fetch = require('node-fetch');
const jobStorage = require('../../services/storage/jobStorage');
const userStorage = require('../../services/storage/userStorage');
const immoscoutProvider = require('../../provider/immoscout');
const config = require('../../../conf/config.json');
const { isAdmin } = require('../security');
function doesJobBelongsToUser(job, req) { function doesJobBelongsToUser(job, req) {
const userId = req.session.currentUser; const userId = req.session.currentUser;
if (userId == null) { if (userId == null) {
@@ -16,40 +16,31 @@ function doesJobBelongsToUser(job, req) {
if (user == null) { if (user == null) {
return false; return false;
} }
return user.isAdmin || job.userId === job.userId; return user.isAdmin || job.userId === job.userId;
} }
jobRouter.get('/', async (req, res) => { jobRouter.get('/', async (req, res) => {
const isUserAdmin = isAdmin(req); const isUserAdmin = isAdmin(req);
//show only the jobs which belongs to the user (or all of the user is an admin) //show only the jobs which belongs to the user (or all of the user is an admin)
res.body = jobStorage.getJobs().filter((job) => isUserAdmin || job.userId === req.session.currentUser); res.body = jobStorage.getJobs().filter((job) => isUserAdmin || job.userId === req.session.currentUser);
res.send(); res.send();
}); });
jobRouter.get('/processingTimes', async (req, res) => { jobRouter.get('/processingTimes', async (req, res) => {
let scrapingAntData = null; let scrapingAntData = null;
if (config.scrapingAnt.apiKey != null && config.scrapingAnt.apiKey.length > 0) { if (config.scrapingAnt.apiKey != null && config.scrapingAnt.apiKey.length > 0) {
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);
} }
} }
res.body = { res.body = {
interval: config.interval, interval: config.interval,
lastRun: config.lastRun || null, lastRun: config.lastRun || null,
scrapingAntData, scrapingAntData,
}; };
res.send(); res.send();
}); });
jobRouter.post('/', async (req, res) => { jobRouter.post('/', async (req, res) => {
const { provider, notificationAdapter, name, blacklist = [], jobId, enabled } = req.body; const { provider, notificationAdapter, name, blacklist = [], jobId, enabled } = req.body;
if ( if (
@@ -77,7 +68,6 @@ jobRouter.post('/', async (req, res) => {
} }
res.send(); res.send();
}); });
jobRouter.delete('', async (req, res) => { jobRouter.delete('', async (req, res) => {
const { jobId } = req.body; const { jobId } = req.body;
try { try {
@@ -93,7 +83,6 @@ jobRouter.delete('', async (req, res) => {
} }
res.send(); res.send();
}); });
jobRouter.put('/:jobId/status', async (req, res) => { jobRouter.put('/:jobId/status', async (req, res) => {
const { status } = req.body; const { status } = req.body;
const { jobId } = req.params; const { jobId } = req.params;
@@ -113,5 +102,4 @@ jobRouter.put('/:jobId/status', async (req, res) => {
} }
res.send(); res.send();
}); });
export { jobRouter };
exports.jobRouter = jobRouter;

View File

@@ -1,8 +1,8 @@
const service = require('restana')(); import restana from 'restana';
import * as userStorage from '../../services/storage/userStorage.js';
import * as hasher from '../../services/security/hash.js';
const service = restana();
const loginRouter = service.newRouter(); const loginRouter = service.newRouter();
const userStorage = require('../../services/storage/userStorage');
const hasher = require('../../services/security/hash');
loginRouter.get('/user', async (req, res) => { loginRouter.get('/user', async (req, res) => {
const currentUserId = req.session.currentUser; const currentUserId = req.session.currentUser;
const currentUser = currentUserId == null ? null : userStorage.getUser(currentUserId); const currentUser = currentUserId == null ? null : userStorage.getUser(currentUserId);
@@ -16,17 +16,13 @@ loginRouter.get('/user', async (req, res) => {
} }
res.send(); res.send();
}); });
loginRouter.post('/', async (req, res) => { loginRouter.post('/', async (req, res) => {
const { username, password } = req.body; const { username, password } = req.body;
const user = userStorage.getUsers(true).find((user) => user.username === username); const user = userStorage.getUsers(true).find((user) => user.username === username);
if (user == null) { if (user == null) {
res.send(401); res.send(401);
return; return;
} }
if (user.password === hasher.hash(password)) { if (user.password === hasher.hash(password)) {
req.session.currentUser = user.id; req.session.currentUser = user.id;
userStorage.setLastLoginToNow({ userId: user.id }); userStorage.setLastLoginToNow({ userId: user.id });
@@ -35,13 +31,10 @@ loginRouter.post('/', async (req, res) => {
} else { } else {
console.error(`User ${username} tried to login, but password was wrong.`); console.error(`User ${username} tried to login, but password was wrong.`);
} }
res.send(401); res.send(401);
}); });
loginRouter.post('/logout', async (req, res) => { loginRouter.post('/logout', async (req, res) => {
req.session = null; req.session = null;
res.send(200); res.send(200);
}); });
export { loginRouter };
exports.loginRouter = loginRouter;

View File

@@ -1,13 +1,13 @@
const fs = require('fs'); import fs from 'fs';
const service = require('restana')(); import restana from 'restana';
const service = restana();
const notificationAdapterRouter = service.newRouter(); const notificationAdapterRouter = service.newRouter();
const notificationAdapterList = fs.readdirSync('./lib//notification/adapter').filter((file) => file.endsWith('.js')); const notificationAdapterList = fs.readdirSync('./lib//notification/adapter').filter((file) => file.endsWith('.js'));
const notificationAdapter = await Promise.all(
const notificationAdapter = notificationAdapterList.map((pro) => { notificationAdapterList.map(async (pro) => {
return require(`../../notification/adapter/${pro}`); return await import(`../../notification/adapter/${pro}`);
}); })
);
notificationAdapterRouter.post('/try', async (req, res) => { notificationAdapterRouter.post('/try', async (req, res) => {
const { id, fields } = req.body; const { id, fields } = req.body;
const adapter = notificationAdapter.find((adapter) => adapter.config.id === id); const adapter = notificationAdapter.find((adapter) => adapter.config.id === id);
@@ -24,7 +24,6 @@ notificationAdapterRouter.post('/try', async (req, res) => {
enabled: true, enabled: true,
id, id,
}); });
try { try {
await adapter.send({ await adapter.send({
serviceName: 'TestCall', serviceName: 'TestCall',
@@ -40,16 +39,13 @@ notificationAdapterRouter.post('/try', async (req, res) => {
notificationConfig, notificationConfig,
jobKey: 'TestJob', jobKey: 'TestJob',
}); });
res.send(); res.send();
} catch (Exception) { } catch (Exception) {
res.send(new Error(Exception)); res.send(new Error(Exception));
} }
}); });
notificationAdapterRouter.get('/', async (req, res) => { notificationAdapterRouter.get('/', async (req, res) => {
res.body = notificationAdapter.map((adapter) => adapter.config); res.body = notificationAdapter.map((adapter) => adapter.config);
res.send(); res.send();
}); });
export { notificationAdapterRouter };
exports.notificationAdapterRouter = notificationAdapterRouter;

View File

@@ -1,16 +1,15 @@
const fs = require('fs'); import fs from 'fs';
const service = require('restana')(); import restana from 'restana';
const service = restana();
const providerRouter = service.newRouter(); const providerRouter = service.newRouter();
const providerList = fs.readdirSync('./lib/provider').filter((file) => file.endsWith('.js')); const providerList = fs.readdirSync('./lib/provider').filter((file) => file.endsWith('.js'));
const provider = await Promise.all(
const provider = providerList.map((pro) => { providerList.map(async (pro) => {
return require(`../../provider/${pro}`).metaInformation; return await import(`../../provider/${pro}`);
}); })
);
providerRouter.get('/', async (req, res) => { providerRouter.get('/', async (req, res) => {
res.body = provider; res.body = provider.map((p) => p.metaInformation);
res.send(); res.send();
}); });
export { providerRouter };
exports.providerRouter = providerRouter;

View File

@@ -1,33 +1,27 @@
const service = require('restana')(); import restana from 'restana';
import * as userStorage from '../../services/storage/userStorage.js';
import * as jobStorage from '../../services/storage/jobStorage.js';
const service = restana();
const userRouter = service.newRouter(); const userRouter = service.newRouter();
const userStorage = require('../../services/storage/userStorage');
const jobStorage = require('../../services/storage/jobStorage');
function checkIfAnyAdminAfterRemovingUser(userIdToBeRemoved, allUser) { function checkIfAnyAdminAfterRemovingUser(userIdToBeRemoved, allUser) {
return allUser.filter((user) => user.id !== userIdToBeRemoved && user.isAdmin).length > 0; return allUser.filter((user) => user.id !== userIdToBeRemoved && user.isAdmin).length > 0;
} }
function checkIfUserToBeRemovedIsLoggedIn(userIdToBeRemoved, req) { function checkIfUserToBeRemovedIsLoggedIn(userIdToBeRemoved, req) {
return req.session.currentUser === userIdToBeRemoved; return req.session.currentUser === userIdToBeRemoved;
} }
const nullOrEmpty = (str) => str == null || str.length === 0; const nullOrEmpty = (str) => str == null || str.length === 0;
userRouter.get('/', async (req, res) => { userRouter.get('/', async (req, res) => {
res.body = userStorage.getUsers(false); res.body = userStorage.getUsers(false);
res.send(); res.send();
}); });
userRouter.get('/:userId', async (req, res) => { userRouter.get('/:userId', async (req, res) => {
const { userId } = req.params; const { userId } = req.params;
res.body = userStorage.getUser(userId); res.body = userStorage.getUser(userId);
res.send(); res.send();
}); });
userRouter.delete('/', async (req, res) => { userRouter.delete('/', async (req, res) => {
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)) {
res.send(new Error('You are trying to remove the last admin user. This is prohibited.')); res.send(new Error('You are trying to remove the last admin user. This is prohibited.'));
return; return;
@@ -36,14 +30,11 @@ userRouter.delete('/', async (req, res) => {
res.send(new Error('You are trying to remove yourself. This is prohibited.')); res.send(new Error('You are trying to remove yourself. This is prohibited.'));
return; return;
} }
//TODO: Remove also analytics //TODO: Remove also analytics
jobStorage.removeJobsByUserId(userId); jobStorage.removeJobsByUserId(userId);
userStorage.removeUser(userId); userStorage.removeUser(userId);
res.send(); res.send();
}); });
userRouter.post('/', async (req, res) => { userRouter.post('/', async (req, res) => {
const { username, password, password2, isAdmin, userId } = req.body; const { username, password, password2, isAdmin, userId } = req.body;
if (password !== password2) { if (password !== password2) {
@@ -55,22 +46,18 @@ userRouter.post('/', async (req, res) => {
return; return;
} }
const allUser = userStorage.getUsers(false); const allUser = userStorage.getUsers(false);
if (!isAdmin && !checkIfAnyAdminAfterRemovingUser(userId, allUser)) { if (!isAdmin && !checkIfAnyAdminAfterRemovingUser(userId, allUser)) {
res.send( res.send(
new Error('You cannot change the admin flag for this user as otherwise, there is no other user in the system') new Error('You cannot change the admin flag for this user as otherwise, there is no other user in the system')
); );
return; return;
} }
userStorage.upsertUser({ userStorage.upsertUser({
userId, userId,
username, username,
password, password,
isAdmin, isAdmin,
}); });
res.send(); res.send();
}); });
export { userRouter };
exports.userRouter = userRouter;

View File

@@ -1,15 +1,12 @@
const userStorage = require('../services/storage/userStorage'); import * as userStorage from '../services/storage/userStorage.js';
const cookieSession = require('cookie-session'); import cookieSession from 'cookie-session';
const { nanoid } = require('nanoid'); import { nanoid } from 'nanoid';
const unauthorized = (res) => { const unauthorized = (res) => {
return res.send(401); return res.send(401);
}; };
const isUnauthorized = (req) => { const isUnauthorized = (req) => {
return req.session.currentUser == null; return req.session.currentUser == null;
}; };
const isAdmin = (req) => { const isAdmin = (req) => {
if (!isUnauthorized(req)) { if (!isUnauthorized(req)) {
const user = userStorage.getUser(req.session.currentUser); const user = userStorage.getUser(req.session.currentUser);
@@ -17,7 +14,6 @@ const isAdmin = (req) => {
} }
return false; return false;
}; };
const authInterceptor = () => { const authInterceptor = () => {
return (req, res, next) => { return (req, res, next) => {
if (isUnauthorized(req)) { if (isUnauthorized(req)) {
@@ -27,7 +23,6 @@ const authInterceptor = () => {
} }
}; };
}; };
const adminInterceptor = () => { const adminInterceptor = () => {
return (req, res, next) => { return (req, res, next) => {
if (!isAdmin(req)) { if (!isAdmin(req)) {
@@ -37,8 +32,7 @@ const adminInterceptor = () => {
} }
}; };
}; };
const cookieSession$0 = (userId) => {
exports.cookieSession = (userId) => {
return cookieSession({ return cookieSession({
name: 'fredy-admin-session', name: 'fredy-admin-session',
keys: ['fredy', 'super', 'fancy', 'key', nanoid()], keys: ['fredy', 'super', 'fancy', 'key', nanoid()],
@@ -46,8 +40,8 @@ exports.cookieSession = (userId) => {
maxAge: 8 * 60 * 60 * 1000, // 8 hours maxAge: 8 * 60 * 60 * 1000, // 8 hours
}); });
}; };
export { cookieSession$0 as cookieSession };
exports.adminInterceptor = adminInterceptor; export { adminInterceptor };
exports.authInterceptor = authInterceptor; export { authInterceptor };
exports.isUnauthorized = isUnauthorized; export { isUnauthorized };
exports.isAdmin = isAdmin; export { isAdmin };

View File

@@ -9,7 +9,8 @@ class ExtendableError extends Error {
} }
} }
} }
class NoNewListingsWarning extends ExtendableError {} class NoNewListingsWarning extends ExtendableError {}
export { NoNewListingsWarning };
module.exports = { NoNewListingsWarning }; export default {
NoNewListingsWarning,
};

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

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

View File

@@ -1,19 +1,12 @@
const { markdown2Html } = require('../../services/markdown'); import { markdown2Html } from '../../services/markdown.js';
/** export const send = ({ serviceName, newListings, jobKey }) => {
* simply prints out the found data to the console
* @param serviceName e.g immowelt
* @param newListings an array with newly found listings
* @param jobKey name of the current job that is being executed
*/
exports.send = ({ serviceName, newListings, jobKey }) => {
/* eslint-disable no-console */ /* eslint-disable no-console */
return [Promise.resolve(console.info(`Found entry from service ${serviceName}, Job: ${jobKey}:`, newListings))]; return [Promise.resolve(console.info(`Found entry from service ${serviceName}, Job: ${jobKey}:`, newListings))];
/* eslint-enable no-console */ /* eslint-enable no-console */
}; };
export const config = {
exports.config = { id: 'console',
id: __filename.slice(__dirname.length + 1, -3),
name: 'Console', name: 'Console',
description: 'This adapter sends new listings to the console. It is mostly useful for debugging.', description: 'This adapter sends new listings to the console. It is mostly useful for debugging.',
config: {}, config: {},

View File

@@ -1,35 +1,24 @@
const mailjet = require('node-mailjet'); import mailjet from 'node-mailjet';
import path from 'path';
const path = require('path'); import fs from 'fs';
const fs = require('fs'); import Handlebars from 'handlebars';
const template = fs.readFileSync(path.resolve(__dirname, '../', 'emailTemplate/template.hbs'), 'utf8'); import { markdown2Html } from '../../services/markdown.js';
import { getDirName } from '../../utils.js';
const Handlebars = require('handlebars'); const __dirname = getDirName();
const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTemplate/template.hbs'), 'utf8');
const emailTemplate = Handlebars.compile(template); const emailTemplate = Handlebars.compile(template);
const { markdown2Html } = require('../../services/markdown'); export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
/**
* sends a new listing using MailJet
* @param serviceName e.g immowelt
* @param newListings an array with newly found listings
* @param notificationConfig config of this notification adapter
* * @param jobKey name of the current job that is being executed
* @returns {Promise<Chat.PostMessage.Response> | void}
*/
exports.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()
.split(',') .split(',')
.map((r) => ({ .map((r) => ({
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: [
@@ -49,9 +38,8 @@ exports.send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
], ],
}); });
}; };
export const config = {
exports.config = { id: 'mailjet',
id: __filename.slice(__dirname.length + 1, -3),
name: 'MailJet', name: 'MailJet',
description: 'MailJet is being used to send new listings via mail.', description: 'MailJet is being used to send new listings via mail.',
readme: markdown2Html('lib/notification/adapter/mailJet.md'), readme: markdown2Html('lib/notification/adapter/mailJet.md'),

View File

@@ -1,26 +1,15 @@
const { markdown2Html } = require('../../services/markdown'); import { markdown2Html } from '../../services/markdown.js';
const { getJob } = require('../../services/storage/jobStorage'); import { getJob } from '../../services/storage/jobStorage.js';
const fetch = require('node-fetch'); import fetch from 'node-fetch';
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
/** const { webhook, channel } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
* sends new listings to mattermost
* @param serviceName e.g immowelt
* @param newListings an array with newly found listings
* @param notificationConfig config of this notification adapter
* @param jobKey name of the current job that is being executed
* @returns {Promise<Void> | void}
*/
exports.send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
const { webhook, channel } = notificationConfig.find((adapter) => adapter.id === 'mattermost').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',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -30,14 +19,8 @@ exports.send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
}, },
}); });
}; };
export const config = {
/** id: 'mattermost',
* exported config is being used in the frontend to generate the fields
* incoming values will be the keys (and values) of the fields
*
*/
exports.config = {
id: __filename.slice(__dirname.length + 1, -3),
name: 'Mattermost', name: 'Mattermost',
readme: markdown2Html('lib/notification/adapter/mattermost.md'), readme: markdown2Html('lib/notification/adapter/mattermost.md'),
description: 'Fredy will send new listings to your mattermost team chat.', description: 'Fredy will send new listings to your mattermost team chat.',

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

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

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

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

View File

@@ -1,16 +1,7 @@
const sgMail = require('@sendgrid/mail'); import sgMail from '@sendgrid/mail';
const { markdown2Html } = require('../../services/markdown'); import { markdown2Html } from '../../services/markdown.js';
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
/** const { apiKey, receiver, from, templateId } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
* sends a new listing using SendGrid
* @param serviceName e.g immowelt
* @param newListings an array with newly found listings
* @param notificationConfig config of this notification adapter
* @param jobKey name of the current job that is being executed
* @returns {Promise<Chat.PostMessage.Response> | void}
*/
exports.send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
const { apiKey, receiver, from, templateId } = notificationConfig.find((adapter) => adapter.id === 'sendGrid').fields;
sgMail.setApiKey(apiKey); sgMail.setApiKey(apiKey);
const msg = { const msg = {
templateId, templateId,
@@ -28,9 +19,8 @@ exports.send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
}; };
return sgMail.send(msg); return sgMail.send(msg);
}; };
export const config = {
exports.config = { id: 'sendgrid',
id: __filename.slice(__dirname.length + 1, -3),
name: 'SendGrid', name: 'SendGrid',
description: 'SendGrid is being used to send new listings via mail.', description: 'SendGrid is being used to send new listings via mail.',
readme: markdown2Html('lib/notification/adapter/sendGrid.md'), readme: markdown2Html('lib/notification/adapter/sendGrid.md'),

View File

@@ -1,17 +1,8 @@
const Slack = require('slack'); import Slack from 'slack';
import { markdown2Html } from '../../services/markdown.js';
const msg = Slack.chat.postMessage; const msg = Slack.chat.postMessage;
const { markdown2Html } = require('../../services/markdown'); export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
const { token, channel } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
/**
* sends a new listing to slack
* @param serviceName e.g immowelt
* @param newListings an array with newly found listings
* @param notificationConfig config of this notification adapter
* * @param jobKey name of the current job that is being executed
* @returns {Promise<Chat.PostMessage.Response> | void}
*/
exports.send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
const { token, channel } = notificationConfig.find((adapter) => adapter.id === 'slack').fields;
return newListings.map((payload) => return newListings.map((payload) =>
msg({ msg({
token, token,
@@ -44,12 +35,11 @@ exports.send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
ts: new Date().getTime() / 1000, ts: new Date().getTime() / 1000,
}, },
], ],
}) }),
); );
}; };
export const config = {
exports.config = { id: 'slack',
id: __filename.slice(__dirname.length + 1, -3),
name: 'Slack', name: 'Slack',
readme: markdown2Html('lib/notification/adapter/slack.md'), readme: markdown2Html('lib/notification/adapter/slack.md'),
description: 'Fredy will send new listings to the slack channel of your choice..', description: 'Fredy will send new listings to the slack channel of your choice..',

View File

@@ -1,13 +1,6 @@
const { markdown2Html } = require('../../services/markdown'); import { markdown2Html } from '../../services/markdown.js';
const Database = require('better-sqlite3'); import Database from 'better-sqlite3';
export const send = ({ serviceName, newListings, jobKey }) => {
/**
* Stores data in a sqlite db in order to use the search results for later analytics
* @param serviceName e.g immowelt
* @param newListings an array with newly found listings
* @param jobKey name of the current job that is being executed
*/
exports.send = ({ serviceName, newListings, jobKey }) => {
const db = new Database('db/listings.db'); const db = new Database('db/listings.db');
const fields = ['serviceName', 'jobKey', 'id', 'size', 'rooms', 'price', 'address', 'title', 'link', 'description']; const fields = ['serviceName', 'jobKey', 'id', 'size', 'rooms', 'price', 'address', 'title', 'link', 'description'];
db.prepare(`CREATE TABLE IF NOT EXISTS listing (${fields.join(' TEXT, ')} TEXT);`).run(); db.prepare(`CREATE TABLE IF NOT EXISTS listing (${fields.join(' TEXT, ')} TEXT);`).run();
@@ -23,9 +16,8 @@ exports.send = ({ serviceName, newListings, jobKey }) => {
}); });
return Promise.resolve(); return Promise.resolve();
}; };
export const config = {
exports.config = { id: 'sqlite',
id: __filename.slice(__dirname.length + 1, -3),
name: 'Sqlite', name: 'Sqlite',
description: 'This adapter stores listings in a local sqlite3 database.', description: 'This adapter stores listings in a local sqlite3 database.',
config: {}, config: {},

View File

@@ -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']
```

View File

@@ -1,7 +1,6 @@
const { markdown2Html } = require('../../services/markdown'); import { markdown2Html } from '../../services/markdown.js';
const { getJob } = require('../../services/storage/jobStorage'); import { getJob } from '../../services/storage/jobStorage.js';
const fetch = require('node-fetch'); import fetch from 'node-fetch';
const MAX_ENTITIES_PER_CHUNK = 8; const MAX_ENTITIES_PER_CHUNK = 8;
const RATE_LIMIT_INTERVAL = 1010; const RATE_LIMIT_INTERVAL = 1010;
/** /**
@@ -16,32 +15,23 @@ const arrayChunks = (inputArray, perChunk) =>
all[ch] = [].concat(all[ch] || [], one); all[ch] = [].concat(all[ch] || [], one);
return all; return all;
}, []); }, []);
function shorten(str, len = 30) {
/** return str.length > len ? str.substring(0, len) + '...' : str;
* sends new listings to telegram }
* @param serviceName e.g immowelt export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
* @param newListings an array with newly found listings const { token, chatId } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
* @param notificationConfig config of this notification adapter
* @param jobKey name of the current job that is being executed
* @returns {Promise<Void> | void}
*/
exports.send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
const { token, chatId } = notificationConfig.find((adapter) => adapter.id === 'telegram').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
const chunks = arrayChunks(newListings, MAX_ENTITIES_PER_CHUNK); const chunks = arrayChunks(newListings, MAX_ENTITIES_PER_CHUNK);
const promises = chunks.map((chunk) => { const promises = chunks.map((chunk) => {
let message = `<i>${jobName}</i> (${serviceName}) found <b>${newListings.length}</b> new listings:\n\n`; let message = `<i>${jobName}</i> (${serviceName}) found <b>${newListings.length}</b> new listings:\n\n`;
message += chunk.map( message += chunk.map(
(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
*/ */
@@ -66,21 +56,10 @@ exports.send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
}, RATE_LIMIT_INTERVAL); }, RATE_LIMIT_INTERVAL);
}); });
}); });
return Promise.all(promises); return Promise.all(promises);
}; };
export const config = {
function shorten(str, len = 30) { id: 'telegram',
return str.length > len ? str.substring(0, len) + '...' : str;
}
/**
* exported config is being used in the frontend to generate the fields
* incoming values will be the keys (and values) of the fields
*
*/
exports.config = {
id: __filename.slice(__dirname.length + 1, -3),
name: 'Telegram', name: 'Telegram',
readme: markdown2Html('lib/notification/adapter/telegram.md'), readme: markdown2Html('lib/notification/adapter/telegram.md'),
description: 'Fredy will send new listings to your mobile, using Telegram.', description: 'Fredy will send new listings to your mobile, using Telegram.',

View File

@@ -1,24 +1,24 @@
const fs = require('fs'); import fs from 'fs';
const path = './adapter'; const path = './adapter';
/** Read every integration existing in ./adapter **/ /** Read every integration existing in ./adapter **/
const adapter = fs const adapter = await Promise.all(
.readdirSync('./lib/notification/adapter') fs
.filter((file) => file.endsWith('.js')) .readdirSync('./lib/notification/adapter')
.map((integPath) => require(`${path}/${integPath}`)); .filter((file) => file.endsWith('.js'))
.map(async (integPath) => await import(`${path}/${integPath}`))
);
if (adapter.length === 0) { if (adapter.length === 0) {
throw new Error('Please specify at least one notification provider'); throw new Error('Please specify at least one notification provider');
} }
const findAdapter = (notificationAdapter) => {
exports.send = (serviceName, newListings, notificationConfig, jobKey) => { return adapter.find((a) => a.config.id === notificationAdapter.id);
};
export const send = (serviceName, newListings, notificationConfig, jobKey) => {
//this is not being used in tests, therefore adapter are always set //this is not being used in tests, therefore adapter are always set
return notificationConfig return notificationConfig
.filter((notificationAdapter) => findAdapter(notificationAdapter) != null) .filter((notificationAdapter) => findAdapter(notificationAdapter) != null)
.map((notificationAdapter) => findAdapter(notificationAdapter)) .map((notificationAdapter) => findAdapter(notificationAdapter))
.map((a) => a.send({ serviceName, newListings, notificationConfig, jobKey })); .map((a) => a.send({ serviceName, newListings, notificationConfig, jobKey }));
}; };
const findAdapter = (notificationAdapter) => {
return adapter.find((a) => a.config.id === notificationAdapter.id);
};

View File

@@ -1,5 +1,4 @@
const utils = require('../utils'); import utils, { buildHash } from '../utils.js';
let appliedBlackList = []; let appliedBlackList = [];
function normalize(o) { function normalize(o) {
@@ -8,14 +7,31 @@ function normalize(o) {
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`;
const price = normalizePrice(o.price);
return Object.assign(o, { size, link }); 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;
} }
@@ -29,22 +45,18 @@ 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,
}; };
export const init = (sourceConfig, blacklist) => {
exports.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 = {
exports.metaInformation = {
name: '1a Immobilien', name: '1a Immobilien',
baseUrl: 'https://www.1a-immobilienmarkt.de/', baseUrl: 'https://www.1a-immobilienmarkt.de/',
id: __filename.slice(__dirname.length + 1, -3), id: 'einsAImmobilien',
}; };
export { config };
exports.config = config;

View File

@@ -1,32 +1,25 @@
const utils = require('../utils'); 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('?'));
} }
function parseId(shortenedLink) { 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) {
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: '.estates_list .list_immo a._ref', crawlContainer: '.estates_list .list_immo a._ref',
@@ -43,17 +36,14 @@ const config = {
normalize: normalize, normalize: normalize,
filter: applyBlacklist, filter: applyBlacklist,
}; };
export const init = (sourceConfig, blacklist) => {
exports.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 = {
exports.metaInformation = {
name: 'Immobilien.de', name: 'Immobilien.de',
baseUrl: 'https://www.immobilien.de/', baseUrl: 'https://www.immobilien.de/',
id: __filename.slice(__dirname.length + 1, -3), id: 'immobilienDe',
}; };
export { config };
exports.config = config;

View File

@@ -1,52 +1,42 @@
const utils = require('../utils'); 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) {
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: '#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,
filter: applyBlacklist, filter: applyBlacklist,
}; };
export const init = (sourceConfig, blacklist) => {
exports.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 = {
exports.metaInformation = {
name: 'Immonet', name: 'Immonet',
baseUrl: 'https://www.immonet.de/', baseUrl: 'https://www.immonet.de/',
id: __filename.slice(__dirname.length + 1, -3), id: 'immonet',
}; };
export { config };
exports.config = config;

View File

@@ -1,22 +1,18 @@
const utils = require('../utils'); 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;
} }
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);
} }
const config = { const config = {
url: null, url: null,
crawlContainer: '#resultListItems li.result-list__listing', crawlContainer: '#resultListItems li.result-list__listing',
@@ -25,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',
}, },
@@ -33,17 +29,14 @@ const config = {
normalize: normalize, normalize: normalize,
filter: applyBlacklist, filter: applyBlacklist,
}; };
export const init = (sourceConfig, blacklist) => {
exports.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 = {
exports.metaInformation = {
name: 'Immoscout', name: 'Immoscout',
baseUrl: 'https://www.immobilienscout24.de/', baseUrl: 'https://www.immobilienscout24.de/',
id: __filename.slice(__dirname.length + 1, -3), id: 'immoscout',
}; };
export { config };
exports.config = config;

View File

@@ -1,52 +1,48 @@
const utils = require('../utils'); 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) => {
exports.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 = {
exports.metaInformation = { name: 'Immo Südwest Presse',
name: 'Immo Südwest Presse', baseUrl: 'https://immo.swp.de/',
baseUrl: 'https://immo.swp.de/', id: 'immoswp',
id: __filename.slice(__dirname.length + 1, -3),
}; };
export {config};
exports.config = config;

View File

@@ -1,44 +1,43 @@
const utils = require('../utils'); 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,
filter: applyBlacklist, filter: applyBlacklist,
}; };
export const init = (sourceConfig, blacklist) => {
exports.init = (sourceConfig, blacklist) => {
config.enabled = sourceConfig.enabled; config.enabled = sourceConfig.enabled;
config.url = sourceConfig.url; config.url = sourceConfig.url;
appliedBlackList = blacklist || []; appliedBlackList = blacklist || [];
}; };
exports.metaInformation = { export const metaInformation = {
name: 'Immowelt', name: 'Immowelt',
baseUrl: 'https://www.immowelt.de/', baseUrl: 'https://www.immowelt.de/',
id: __filename.slice(__dirname.length + 1, -3), id: 'immowelt',
}; };
export { config };
exports.config = config;

View File

@@ -1,53 +1,49 @@
const utils = require('../utils'); 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²';
const id = buildHash(o.id, o.price);
return Object.assign(o, { size }); 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 = {
exports.metaInformation = { name: 'Ebay Kleinanzeigen',
name: 'Ebay Kleinanzeigen', baseUrl: 'https://www.kleinanzeigen.de/',
baseUrl: 'https://www.ebay-kleinanzeigen.de/', id: 'kleinanzeigen',
id: __filename.slice(__dirname.length + 1, -3),
}; };
export const init = (sourceConfig, blacklist, blacklistedDistricts) => {
exports.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};
exports.config = config;

View File

@@ -1,41 +1,44 @@
const utils = require('../utils'); 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) => {
exports.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 = {
exports.metaInformation = { name: 'Neubau Kompass',
name: 'Neubau Kompass', baseUrl: 'https://www.neubaukompass.de/',
baseUrl: 'https://www.neubaukompass.de/', id: 'neubauKompass',
id: __filename.slice(__dirname.length + 1, -3),
}; };
export {config};
exports.config = config;

View File

@@ -1,44 +1,41 @@
const utils = require('../utils'); 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) => {
exports.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 = {
exports.metaInformation = { name: 'Wg gesucht',
name: 'Wg gesucht', baseUrl: 'https://www.wg-gesucht.de/',
baseUrl: 'https://www.wg-gesucht.de/', id: 'wgGesucht',
id: __filename.slice(__dirname.length + 1, -3),
}; };
export {config};
exports.config = config;

View File

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

View File

@@ -1,21 +1,9 @@
const queryString = require('query-string'); import queryString from 'query-string';
export default (_url, sortByDateParam) => {
/**
* for Fredy, it is important to sort search results by date, starting with the latest listing. if it is not sorted, we
* might never actually find the newest results, no matter how many pages we crawl.
* It has been written in the documentation, but obviously nobody reads docu theses days which is why it's been done
* automagically now.
*
* @param _url actual provider url containing the searchParams
* @param sortByDateParam param(s) indicating the correct sort order
* @returns {`${string}?${string}`} correctly formatted url
*/
module.exports = (_url, sortByDateParam) => {
//if no mutation is necessary, just return the original url //if no mutation is necessary, just return the original url
if (sortByDateParam == null) { if (sortByDateParam == null) {
return _url; return _url;
} }
const original = queryString.parseUrl(_url); const original = queryString.parseUrl(_url);
const mutate = queryString.parse(sortByDateParam); const mutate = queryString.parse(sortByDateParam);
return `${original.url}?${queryString.stringify({ ...original.query, ...mutate })}`; return `${original.url}?${queryString.stringify({ ...original.query, ...mutate })}`;

View File

@@ -1,19 +1,20 @@
const fetch = require('node-fetch'); import fetch from 'node-fetch';
const config = require('../../conf/config.json'); import { config } from '../utils.js';
import { makeUrlResidential } from './scrapingAnt.js';
const { makeUrlResidential } = require('./scrapingAnt'); 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) {
const proxyType = config.scrapingAnt?.proxy || 'datacenter'; const proxyType = config.scrapingAnt?.proxy || 'datacenter';
try { try {
const url = proxyType === 'residential' ? makeUrlResidential(context.url) : context.url; const url = proxyType === 'residential' ? makeUrlResidential(context.url) : context.url;
const response = await fetch(url, { const response = await fetch(url, {
@@ -22,21 +23,21 @@ function makeDriver(headers = {}) {
cookie: cookies, cookie: cookies,
}, },
}); });
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;
} }
if (retryCounter <= MAX_RETRIES_SCRAPING_ANT) { if (retryCounter <= MAX_RETRIES_SCRAPING_ANT) {
retryCounter++; retryCounter++;
console.debug(`ScrapingAnt got blocked. Retrying ${retryCounter} / ${MAX_RETRIES_SCRAPING_ANT}`); console.debug(`ScrapingAnt got blocked. Retrying ${retryCounter} / ${MAX_RETRIES_SCRAPING_ANT}`);
@@ -51,21 +52,20 @@ function makeDriver(headers = {}) {
/** /**
* 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) {
return scrapingAntDriver(context, callback); return scrapingAntDriver(context, callback);
} }
try { try {
const response = await fetch(context.url, { const response = await fetch(context.url, {
headers: { headers: {
...headers, ...headers,
Cookie: cookies, Cookie: cookies,
}, },
agent,
}); });
const result = await response.text(); const result = await response.text();
callback(null, result); callback(null, result);
} catch (exception) { } catch (exception) {
@@ -74,5 +74,4 @@ function makeDriver(headers = {}) {
} }
}; };
} }
export default makeDriver;
module.exports = makeDriver;

View File

@@ -1,7 +1,6 @@
const config = require('../../conf/config.json'); import { config } from '../utils.js';
const makeDriver = require('./requestDriver'); import makeDriver from './requestDriver.js';
const Xray = require('x-ray'); import Xray from 'x-ray';
class Scraper { class Scraper {
constructor() { constructor() {
const filters = { const filters = {
@@ -9,38 +8,29 @@ class Scraper {
trim: this._trim, trim: this._trim,
int: this._int, int: this._int,
}; };
const headers = { const headers = {
'User-Agent': 'User-Agent':
'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/40.0.2214.85 Safari/537.36', 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/40.0.2214.85 Safari/537.36',
}; };
if (config.scrapingAnt != null && config.scrapingAnt.apiKey != null) { if (config.scrapingAnt != null && config.scrapingAnt.apiKey != null) {
headers['x-api-key'] = config.scrapingAnt.apiKey; headers['x-api-key'] = config.scrapingAnt.apiKey;
} }
const driver = makeDriver(headers); const driver = makeDriver(headers);
const xray = Xray({ filters }); const xray = Xray({ filters });
xray.driver(driver); xray.driver(driver);
this.xray = xray; this.xray = xray;
} }
get x() { get x() {
return this.xray; return this.xray;
} }
_removeNewline(value) { _removeNewline(value) {
return typeof value === 'string' ? value.replace(/\\n/g, '') : value; return typeof value === 'string' ? value.replace(/\\n/g, '') : value;
} }
_trim(value) { _trim(value) {
return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : value; return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : value;
} }
_int(value) { _int(value) {
return typeof value === 'string' ? parseInt(value, 10) : value; return typeof value === 'string' ? parseInt(value, 10) : value;
} }
} }
export default new Scraper().x;
module.exports = new Scraper().x;

View File

@@ -1,25 +1,30 @@
const { metaInformation } = require('../provider/immoscout'); import { metaInformation as immoScoutInfo } from '../provider/immoscout.js';
//to better configure re-capture chose a random proxy each time we do a call import { metaInformation as immoNetInfo } from '../provider/immonet.js';
const config = require('../../conf/config.json'); import { metaInformation as neuBauCompassInfo } from '../provider/neubauKompass.js';
import { config } from '../utils.js';
const isImmoscout = (id) => { const additionalImmonetUrlParams = `&wait_for_selector=.content-wrapper-tiles&js_snippet=${Buffer.from(
return id.toLowerCase() === metaInformation.id; '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) => {
exports.transformUrlForScrapingAnt = (url, id) => { let urlParams = '';
if (isImmoscout(id)) { if (needScrapingAnt(id)) {
//only do calls to scrapingAnt when dealing with Immoscout if (id.toLowerCase() === immoNetInfo.id) {
url = `https://api.scrapingant.com/v1/general?url=${encodeURIComponent(url)}&proxy_type=datacenter`; 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 = () => {
exports.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 > 0;
}; };
export const makeUrlResidential = (url) => {
exports.isImmoscout = isImmoscout;
exports.makeUrlResidential = (url) => {
return url.replace('datacenter', 'residential'); return url.replace('datacenter', 'residential');
}; };
export { needScrapingAnt };

View File

@@ -1,3 +1,2 @@
const crypto = require('crypto'); import crypto from 'crypto';
export const hash = (x) => crypto.createHash('sha256').update(x, 'utf8').digest('hex');
exports.hash = (x) => crypto.createHash('sha256').update(x, 'utf8').digest('hex');

View File

@@ -1,27 +1,17 @@
const stringSimilarity = require('string-similarity'); import stringSimilarity from 'string-similarity';
//if the score is higher than this, it will be considered a match //if the score is higher than this, it will be considered a match
const MAX_DICE_INDEX = 0.7; const MAX_DICE_INDEX = 0.7;
export default (class SimilarityCacheEntry {
/**
* The similarity check is based on the dice coefficient. => https://en.wikipedia.org/wiki/S%C3%B8rensen%E2%80%93Dice_coefficient
*
* @type {module.SimilarityCacheEntry}
*/
module.exports = class SimilarityCacheEntry {
constructor(time) { constructor(time) {
this.time = time; this.time = time;
this.values = []; this.values = [];
} }
setCacheEntry = (entry) => { setCacheEntry = (entry) => {
this.values.push(entry); this.values.push(entry);
}; };
getTime = () => { getTime = () => {
return this.time; return this.time;
}; };
hasSimilarEntries = (value) => { hasSimilarEntries = (value) => {
if (this.values.length > 0) { if (this.values.length > 0) {
for (let i = 0; i < this.values.length; i++) { for (let i = 0; i < this.values.length; i++) {
@@ -33,4 +23,4 @@ module.exports = class SimilarityCacheEntry {
} }
return false; return false;
}; };
}; });

View File

@@ -1,63 +1,40 @@
/** import SimilarityCacheEntry from './SimilarityCacheEntry.js';
* each job that runs scrapes all provider. This cache holds the titles of the found listing(s) and provides import { config } from '../../utils.js';
* a similarity check. if this check returns true, it will not be forwarded to the notification adapter, thus
* the user won't see any duplicates
*
* The retention of this cache is per default 5 minutes, but can be smaller if the interval is > 5 mins.
*
* @type {module.SimilarityCacheEntry|{}}
*/
const SimilarityCacheEntry = require('./SimilarityCacheEntry');
const config = require('../../../conf/config.json');
//5 minutes //5 minutes
let retention = 5 * 60 * 1000; let retention = 5 * 60 * 1000;
const intervalInMs = config.interval * 60 * 1000; const intervalInMs = config.interval * 60 * 1000;
//an interval below 5 mins sounds crazy, but there are ppl out there doing crazy shit. //an interval below 5 mins sounds crazy, but there are ppl out there doing crazy shit.
if (intervalInMs <= retention) { if (intervalInMs <= retention) {
retention = Math.floor(intervalInMs / 2); retention = Math.floor(intervalInMs / 2);
} }
//jobid -> SimilarityCacheEntry //jobid -> SimilarityCacheEntry
const cache = {}; const cache = {};
let intervalId; let intervalId;
exports.addCacheEntry = (jobId, value) => {
cache[jobId] = cache[jobId] || new SimilarityCacheEntry(Date.now());
cache[jobId].setCacheEntry(value);
};
exports.hasSimilarEntries = (jobId, value) => {
if (cache[jobId] == null) {
return false;
}
return cache[jobId].hasSimilarEntries(value);
};
/** /**
* cleanup * cleanup
*/ */
intervalId = setInterval(() => { intervalId = setInterval(() => {
const keysToBeRemoved = []; const keysToBeRemoved = [];
const now = Date.now(); const now = Date.now();
Object.keys(cache).forEach((key) => { Object.keys(cache).forEach((key) => {
if (cache[key].getTime() + retention < now) { if (cache[key].getTime() + retention < now) {
keysToBeRemoved.push(key); keysToBeRemoved.push(key);
} }
}); });
if (keysToBeRemoved.length > 0) { if (keysToBeRemoved.length > 0) {
keysToBeRemoved.forEach((key) => delete cache[key]); keysToBeRemoved.forEach((key) => delete cache[key]);
} }
}, 10000); }, 10000);
export const addCacheEntry = (jobId, value) => {
/** cache[jobId] = cache[jobId] || new SimilarityCacheEntry(Date.now());
* mostly used for tests cache[jobId].setCacheEntry(value);
*/ };
exports.stopCacheCleanup = () => { export const hasSimilarEntries = (jobId, value) => {
if (cache[jobId] == null) {
return false;
}
return cache[jobId].hasSimilarEntries(value);
};
export const stopCacheCleanup = () => {
clearInterval(intervalId); clearInterval(intervalId);
}; };

View File

@@ -0,0 +1,8 @@
import lodash from 'lodash';
import { LowSync } from 'lowdb';
export default class LowdashAdapter extends LowSync {
constructor(adapter, defaultData = {}) {
super(adapter, defaultData);
this.chain = lodash.chain(this).get('data');
}
}

View File

@@ -1,28 +1,29 @@
const path = require('path'); import { JSONFileSync } from 'lowdb/node';
const DB_PATH = path.dirname(require.main.filename) + '/db/jobs.json'; import { nanoid } from 'nanoid';
const FileSync = require('lowdb/adapters/FileSync'); import * as listingStorage from './listingsStorage.js';
const adapter = new FileSync(DB_PATH); import { getDirName } from '../../utils.js';
const low = require('lowdb'); import path from 'path';
const db = low(adapter); import LowdashAdapter from './LowDashAdapter.js';
const { nanoid } = require('nanoid');
const listingStorage = require('./listingsStorage');
db.defaults({ jobs: [] }).write(); const file = path.join(getDirName(), '../', 'db/jobs.json');
const adapter = new JSONFileSync(file);
const db = new LowdashAdapter(adapter, { jobs: [] });
exports.upsertJob = ({ jobId, name, blacklist = [], enabled = true, provider, notificationAdapter, userId }) => { db.read();
export const upsertJob = ({ jobId, name, blacklist = [], enabled = true, provider, notificationAdapter, userId }) => {
const currentJob = const currentJob =
jobId == null jobId == null
? null ? null
: db : db.chain
.get('jobs') .get('jobs')
.find((job) => job.id === jobId) .find((job) => job.id === jobId)
.value(); .value();
const jobs = db.chain
const jobs = db
.get('jobs') .get('jobs')
.value() .filter((job) => job.id !== jobId)
.filter((job) => job.id !== jobId); .value();
jobs.push({ jobs.push({
id: jobId || nanoid(), id: jobId || nanoid(),
//make sure to not overwrite the user id in case an admin changes the job //make sure to not overwrite the user id in case an admin changes the job
@@ -33,57 +34,55 @@ exports.upsertJob = ({ jobId, name, blacklist = [], enabled = true, provider, no
provider, provider,
notificationAdapter, notificationAdapter,
}); });
db.chain.set('jobs', jobs).value();
db.set('jobs', jobs).write(); db.write();
}; };
export const getJob = (jobId) => {
exports.getJob = (jobId) => { const job = db.chain
const job = db
.get('jobs') .get('jobs')
.find((job) => job.id === jobId) .find((job) => job.id === jobId)
.value(); .value();
if (job == null) { if (job == null) {
return null; return null;
} }
return { return {
...job, ...job,
numberOfFoundListings: listingStorage.getNumberOfAllKnownListings(job.id).length, numberOfFoundListings: listingStorage.getNumberOfAllKnownListings(job.id).length,
}; };
}; };
export const setJobStatus = ({ jobId, status }) => {
exports.setJobStatus = ({ jobId, status }) => { db.chain
db.get('jobs') .get('jobs')
.find((job) => job.id === jobId) .find((job) => job.id === jobId)
.assign({ enabled: status }) .assign({ enabled: status })
.write(); .value();
db.write();
}; };
export const removeJob = (jobId) => {
exports.removeJob = (jobId) => {
listingStorage.removeListings(jobId); listingStorage.removeListings(jobId);
db.get('jobs') db.chain
.get('jobs')
.remove((job) => job.id === jobId) .remove((job) => job.id === jobId)
.write(); .value();
db.write();
}; };
export const removeJobsByUserId = (userId) => {
exports.removeJobsByUserId = (userId) => { db.chain
db.get('jobs') .get('jobs')
.value()
.filter((job) => job.userId === userId) .filter((job) => job.userId === userId)
.forEach((job) => listingStorage.removeListings(job.id)); .forEach((job) => listingStorage.removeListings(job.id));
db.chain
db.get('jobs') .get('jobs')
.remove((job) => job.userId === userId) .remove((job) => job.userId === userId)
.write(); .value();
}; db.write();
};
exports.getJobs = () => { export const getJobs = () => {
return db return db.chain
.get('jobs') .get('jobs')
.value()
.map((job) => ({ .map((job) => ({
...job, ...job,
numberOfFoundListings: listingStorage.getNumberOfAllKnownListings(job.id), numberOfFoundListings: listingStorage.getNumberOfAllKnownListings(job.id),
})); }))
.value();
}; };

View File

@@ -1,10 +1,13 @@
const path = require('path'); import { JSONFileSync } from 'lowdb/node';
import { getDirName } from '../../utils.js';
import path from 'path';
import LowdashAdapter from './LowDashAdapter.js';
const DB_PATH = path.dirname(require.main.filename) + '/db/jobListingData.json'; const file = path.join(getDirName(), '../', 'db/jobListingData.json');
const FileSync = require('lowdb/adapters/FileSync'); const adapter = new JSONFileSync(file);
const adapter = new FileSync(DB_PATH); const db = new LowdashAdapter(adapter, {});
const low = require('lowdb');
const db = low(adapter); db.read();
const buildKey = (jobKey, providerId, endpoint) => { const buildKey = (jobKey, providerId, endpoint) => {
let key = `${jobKey}`; let key = `${jobKey}`;
@@ -19,35 +22,31 @@ const buildKey = (jobKey, providerId, endpoint) => {
} }
return key; return key;
}; };
export const getNumberOfAllKnownListings = (jobId) => {
exports.getNumberOfAllKnownListings = (jobId) => { const data = db.chain.get(`${jobId}.providerData`).value() || {};
const data = db.get(`${jobId}.providerData`).value() || {};
return Object.values(data) return Object.values(data)
.map((values) => Object.keys(values).length) .map((values) => Object.keys(values).length)
.reduce((accumulator, currentValue) => accumulator + currentValue, 0); .reduce((accumulator, currentValue) => accumulator + currentValue, 0);
}; };
export const getListingProviderDataForAnalytics = (jobId) => {
exports.getListingProviderDataForAnalytics = (jobId) => {
const key = buildKey(jobId, 'providerData'); const key = buildKey(jobId, 'providerData');
return db.get(key).value() || {}; return db.chain.get(key).value() || {};
}; };
export const getKnownListings = (jobId, providerId) => {
exports.getKnownListings = (jobId, providerId) => {
const providerListingsKey = buildKey(jobId, 'providerData', providerId, 'listings'); const providerListingsKey = buildKey(jobId, 'providerData', providerId, 'listings');
return db.get(providerListingsKey).value() || {}; return db.chain.get(providerListingsKey).value() || {};
}; };
export const setKnownListings = (jobId, providerId, listings) => {
exports.setKnownListings = (jobId, providerId, listings) => {
const providerListingsKey = buildKey(jobId, 'providerData', providerId, 'listings'); const providerListingsKey = buildKey(jobId, 'providerData', providerId, 'listings');
db.chain.set(providerListingsKey, listings).value();
return db.set(providerListingsKey, listings).write(); return db.write();
}; };
export const setLastJobExecution = (jobId) => {
exports.setLastJobExecution = (jobId) => {
const key = buildKey(jobId, null, 'lastExecution'); const key = buildKey(jobId, null, 'lastExecution');
return db.set(key, Date.now()).write(); db.chain.set(key, Date.now()).value();
return db.write();
}; };
export const removeListings = (jobId) => {
exports.removeListings = (jobId) => { db.chain.unset(jobId).value();
db.unset(jobId).write(); db.write();
}; };

View File

@@ -1,30 +1,33 @@
const path = require('path'); import { JSONFileSync } from 'lowdb/node';
const DB_PATH = path.dirname(require.main.filename) + '/db/users.json'; import { getDirName } from '../../utils.js';
const FileSync = require('lowdb/adapters/FileSync'); import * as hasher from '../security/hash.js';
const adapter = new FileSync(DB_PATH); import { nanoid } from 'nanoid';
const low = require('lowdb'); import * as jobStorage from './jobStorage.js';
const db = low(adapter); import path from 'path';
const hasher = require('../security/hash'); import LowdashAdapter from './LowDashAdapter.js';
const { nanoid } = require('nanoid');
const jobStorage = require('./jobStorage');
db.defaults({ const defaultData = {
user: [ user: [
//you probably want to change the default password ;) //you probably want to change the default password ;)
{ {
id: nanoid(), id: nanoid(),
lastLogin: Date.now(), lastLogin: Date.now(),
username: 'admin', username: 'admin',
password: hasher.hash('admin'), password: hasher.hash('admin'),
isAdmin: true, isAdmin: true,
isDemo: false, },
}, ],
], };
}).write();
exports.getUsers = (withPassword) => { const file = path.join(getDirName(), '../', 'db/users.json');
const adapter = new JSONFileSync(file);
const db = new LowdashAdapter(adapter, defaultData);
db.read();
export const getUsers = (withPassword) => {
const jobs = jobStorage.getJobs(); const jobs = jobStorage.getJobs();
return db return db.chain
.get('user') .get('user')
.value() .value()
.map((user) => ({ .map((user) => ({
@@ -34,13 +37,12 @@ exports.getUsers = (withPassword) => {
numberOfJobs: jobs.filter((job) => job.userId === user.id).length, numberOfJobs: jobs.filter((job) => job.userId === user.id).length,
})); }));
}; };
export const getUser = (id) => {
exports.getUser = (id) => {
const jobs = jobStorage.getJobs(); const jobs = jobStorage.getJobs();
const user = db const user = db.chain
.get('user') .get('user')
.value() .find((user) => user.id === id)
.find((user) => user.id === id); .value();
if (user == null) { if (user == null) {
return null; return null;
} }
@@ -49,13 +51,11 @@ exports.getUser = (id) => {
numberOfJobs: jobs.filter((job) => job.userId === user.id).length, numberOfJobs: jobs.filter((job) => job.userId === user.id).length,
}; };
}; };
export const upsertUser = ({ username, password, userId, isAdmin }) => {
exports.upsertUser = ({ username, password, userId, isAdmin }) => { const user = db.chain
const user = db
.get('user') .get('user')
.value() .filter((u) => u.id !== userId)
.filter((u) => u.id !== userId); .value();
user.push({ user.push({
id: userId || nanoid(), id: userId || nanoid(),
username, username,
@@ -63,21 +63,24 @@ exports.upsertUser = ({ username, password, userId, isAdmin }) => {
password: hasher.hash(password), password: hasher.hash(password),
isAdmin, isAdmin,
}); });
db.chain.set('user', user).value();
db.set('user', user).write(); db.write();
}; };
export const setLastLoginToNow = ({ userId }) => {
exports.setLastLoginToNow = ({ userId }) => { db.chain
db.get('user') .get('user')
.find((u) => u.id === userId) .find((u) => u.id === userId)
.assign({ lastLogin: Date.now() }) .assign({ lastLogin: Date.now() })
.write(); .value();
db.write();
}; };
export const removeUser = (userId) => {
exports.removeUser = (userId) => { const user = db.chain.get('user').value();
const user = db.get('user').value(); db.chain
db.set( .set(
'user', 'user',
user.filter((u) => u.id !== userId) user.filter((u) => u.id !== userId)
).write(); )
.value();
db.write();
}; };

View File

@@ -1,36 +1,69 @@
function isOneOf(word, arr) { import {dirname} from 'node:path';
if (arr == null || arr.length === 0) { import {fileURLToPath} from 'node:url';
return false; import {readFile} from 'fs/promises';
} import {createHash} from 'crypto';
const expression = String.raw`\b(${arr.join('|')})\b`;
const blacklist = new RegExp(expression, 'ig');
return blacklist.test(word); function isOneOf(word, arr) {
if (arr == null || arr.length === 0) {
return false;
}
const expression = String.raw`\b(${arr.join('|')})\b`;
const blacklist = new RegExp(expression, 'ig');
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;
} }
module.exports = { isOneOf, nullOrEmpty, duringWorkingHoursOrNotSet }; function getDirName() {
return dirname(fileURLToPath(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');
}
const config = JSON.parse(await readFile(new URL('../conf/config.json', import.meta.url)));
export {isOneOf};
export {nullOrEmpty};
export {duringWorkingHoursOrNotSet};
export {getDirName};
export {config};
export {buildHash};
export default {
isOneOf,
nullOrEmpty,
duringWorkingHoursOrNotSet,
getDirName,
config,
};

View File

@@ -1,21 +1,22 @@
{ {
"name": "fredy", "name": "fredy",
"version": "6.0.2", "version": "10.2.0",
"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 index.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 --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": { "husky": {
"hooks": { "hooks": {
"pre-commit": "lint-staged" "pre-commit": "lint-staged"
} }
}, },
"type": "module",
"lint-staged": { "lint-staged": {
"*.js": [ "*.js": [
"eslint ./index.js ./lib/**/*.js ./test/**/*.js", "eslint ./index.js ./lib/**/*.js ./test/**/*.js",
@@ -44,7 +45,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": [
@@ -54,54 +55,54 @@
"Firefox ESR" "Firefox ESR"
], ],
"dependencies": { "dependencies": {
"@douyinfe/semi-ui": "2.68.3",
"@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.3",
"better-sqlite3": "8.1.0", "better-sqlite3": "^11.5.0",
"body-parser": "1.20.1", "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": "11.4.8",
"highcharts-react-official": "3.1.0", "highcharts-react-official": "3.2.1",
"lowdb": "1.0.0", "lodash": "4.17.21",
"lowdb": "6.0.1",
"markdown": "^0.5.0", "markdown": "^0.5.0",
"nanoid": "3.3.3", "nanoid": "5.0.8",
"node-fetch": "2.6.9", "node-fetch": "3.3.2",
"node-mailjet": "3.3.13", "node-mailjet": "6.0.6",
"query-string": "7.1.3", "query-string": "9.1.1",
"react": "18.2.0", "react": "18.3.1",
"react-dom": "18.2.0", "react-dom": "18.3.1",
"react-redux": "8.0.5", "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.1", "vite": "5.4.10",
"x-ray": "2.3.4" "x-ray": "2.3.4"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.20.12", "@babel/core": "7.26.0",
"@babel/eslint-parser": "^7.19.1", "@babel/eslint-parser": "7.25.9",
"@babel/preset-env": "7.20.2", "@babel/preset-env": "7.26.0",
"@babel/preset-react": "7.18.6", "@babel/preset-react": "7.25.9",
"chai": "4.3.7", "chai": "5.1.2",
"eslint": "8.34.0", "eslint": "8.56.0",
"eslint-config-prettier": "8.6.0", "eslint-config-prettier": "8.8.0",
"eslint-plugin-react": "7.32.2", "eslint-plugin-react": "7.37.2",
"esmock": "2.6.9",
"history": "5.3.0", "history": "5.3.0",
"husky": "4.3.8", "husky": "4.3.8",
"less": "4.1.3", "less": "4.2.0",
"lint-staged": "13.1.2", "lint-staged": "13.2.2",
"mocha": "10.2.0", "mocha": "10.8.2",
"prettier": "2.8.4", "prettier": "3.3.3",
"proxyquire": "2.1.3",
"redux-logger": "3.0.6" "redux-logger": "3.0.6"
} }
} }

View File

@@ -1,12 +1,10 @@
module.exports = { let tmpStore = {};
_tmpStore: {},
send: (serviceName, payload) => { export const send = (serviceName, payload) => {
this._tmpStore = { serviceName, payload }; tmpStore = { serviceName, payload };
return [Promise.resolve()]; return [Promise.resolve()];
}, };
get: () => { export const get = () => {
return this._tmpStore; return tmpStore;
},
}; };

View File

@@ -1,11 +1,8 @@
const db = {}; const db = {};
export const setKnownListings = (jobKey, providerId, listings) => {
exports.setKnownListings = (jobKey, providerId, listings) => {
if (!Array.isArray(listings)) throw Error('Not a valid array'); if (!Array.isArray(listings)) throw Error('Not a valid array');
db[providerId] = listings; db[providerId] = listings;
}; };
export const getKnownListings = (jobKey, providerId) => {
exports.getKnownListings = (jobKey, providerId) => {
return db[providerId] || []; return db[providerId] || [];
}; };

View File

@@ -1,43 +1,30 @@
const similarityCache = require('../../lib/services/similarity-check/similarityCache'); import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
const mockNotification = require('../mocks/mockNotification'); import { get } from '../mocks/mockNotification.js';
const providerConfig = require('./testProvider.json'); import { providerConfig, mockFredy } from '../utils.js';
const mockStore = require('../mocks/mockStore'); import { expect } from 'chai';
const proxyquire = require('proxyquire').noCallThru(); import * as provider from '../../lib/provider/einsAImmobilien.js';
const expect = require('chai').expect;
const provider = require('../../lib/provider/einsAImmobilien');
describe('#einsAImmobilien testsuite()', () => { describe('#einsAImmobilien testsuite()', () => {
after(() => { after(() => {
similarityCache.stopCacheCleanup(); similarityCache.stopCacheCleanup();
}); });
provider.init(providerConfig.einsAImmobilien, [], []); provider.init(providerConfig.einsAImmobilien, [], []);
const Fredy = proxyquire('../../lib/FredyRuntime', {
'./services/storage/listingsStorage': {
...mockStore,
},
'./notification/notify': mockNotification,
});
it('should test einsAImmobilien provider', async () => { it('should test einsAImmobilien provider', async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => { return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'einsAImmobilien', similarityCache); const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'einsAImmobilien', similarityCache);
fredy.execute().then((listings) => { fredy.execute().then((listings) => {
expect(listings).to.be.a('array'); expect(listings).to.be.a('array');
const notificationObj = get();
const notificationObj = mockNotification.get();
expect(notificationObj).to.be.a('object'); expect(notificationObj).to.be.a('object');
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');
expect(notify.link).to.be.a('string'); expect(notify.link).to.be.a('string');
/** check the values if possible **/ /** check the values if possible **/
expect(notify.size).to.be.not.empty; expect(notify.size).to.be.not.empty;
expect(notify.title).to.be.not.empty; expect(notify.title).to.be.not.empty;

View File

@@ -1,31 +1,21 @@
const similarityCache = require('../../lib/services/similarity-check/similarityCache'); import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
const mockNotification = require('../mocks/mockNotification'); import { get } from '../mocks/mockNotification.js';
const providerConfig = require('./testProvider.json'); import { providerConfig, mockFredy } from '../utils.js';
const mockStore = require('../mocks/mockStore'); import { expect } from 'chai';
const proxyquire = require('proxyquire').noCallThru(); import * as provider from '../../lib/provider/immobilienDe.js';
const expect = require('chai').expect;
const provider = require('../../lib/provider/immobilienDe');
describe('#immobilien.de testsuite()', () => { describe('#immobilien.de testsuite()', () => {
after(() => { after(() => {
similarityCache.stopCacheCleanup(); similarityCache.stopCacheCleanup();
}); });
provider.init(providerConfig.immobilienDe, [], []); provider.init(providerConfig.immobilienDe, [], []);
const Fredy = proxyquire('../../lib/FredyRuntime', {
'./services/storage/listingsStorage': {
...mockStore,
},
'./notification/notify': mockNotification,
});
it('should test immobilien.de provider', async () => { it('should test immobilien.de provider', async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => { return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'test1', similarityCache); const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'test1', similarityCache);
fredy.execute().then((listing) => { fredy.execute().then((listing) => {
expect(listing).to.be.a('array'); expect(listing).to.be.a('array');
const notificationObj = get();
const notificationObj = mockNotification.get();
expect(notificationObj).to.be.a('object'); expect(notificationObj).to.be.a('object');
expect(notificationObj.serviceName).to.equal('immobilienDe'); expect(notificationObj.serviceName).to.equal('immobilienDe');
notificationObj.payload.forEach((notify) => { notificationObj.payload.forEach((notify) => {
@@ -36,7 +26,6 @@ describe('#immobilien.de testsuite()', () => {
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²');

View File

@@ -1,37 +1,34 @@
const similarityCache = require('../../lib/services/similarity-check/similarityCache'); import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
const mockNotification = require('../mocks/mockNotification'); import { get } from '../mocks/mockNotification.js';
const providerConfig = require('./testProvider.json'); import { mockFredy, providerConfig } from '../utils.js';
const mockStore = require('../mocks/mockStore'); import { expect } from 'chai';
const proxyquire = require('proxyquire').noCallThru(); import * as provider from '../../lib/provider/immonet.js';
const expect = require('chai').expect; import * as scrapingAnt from '../../lib/services/scrapingAnt.js';
const provider = require('../../lib/provider/immonet');
describe('#immonet testsuite()', () => { describe('#immonet testsuite()', () => {
after(() => { after(() => {
similarityCache.stopCacheCleanup(); similarityCache.stopCacheCleanup();
}); });
provider.init(providerConfig.immonet, [], []); provider.init(providerConfig.immonet, [], []);
const Fredy = proxyquire('../../lib/FredyRuntime', {
'./services/storage/listingsStorage': {
...mockStore,
},
'./notification/notify': mockNotification,
});
it('should test immonet provider', async () => { it('should test immonet provider', async () => {
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');
const notificationObj = get();
const notificationObj = mockNotification.get();
expect(notificationObj).to.be.a('object'); expect(notificationObj).to.be.a('object');
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');
@@ -42,7 +39,6 @@ describe('#immonet testsuite()', () => {
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();

View File

@@ -1,25 +1,17 @@
const similarityCache = require('../../lib/services/similarity-check/similarityCache'); import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
const mockNotification = require('../mocks/mockNotification'); import { get } from '../mocks/mockNotification.js';
const providerConfig = require('./testProvider.json'); import { mockFredy, providerConfig } from '../utils.js';
const mockStore = require('../mocks/mockStore'); import { expect } from 'chai';
const proxyquire = require('proxyquire').noCallThru(); import * as provider from '../../lib/provider/immoscout.js';
const expect = require('chai').expect; import * as scrapingAnt from '../../lib/services/scrapingAnt.js';
const provider = require('../../lib/provider/immoscout');
const scrapingAnt = require('../../lib/services/scrapingAnt');
describe('#immoscout testsuite()', () => { describe('#immoscout testsuite()', () => {
after(() => { after(() => {
similarityCache.stopCacheCleanup(); similarityCache.stopCacheCleanup();
}); });
provider.init(providerConfig.immoscout, [], []); provider.init(providerConfig.immoscout, [], []);
const Fredy = proxyquire('../../lib/FredyRuntime', {
'./services/storage/listingsStorage': {
...mockStore,
},
'./notification/notify': mockNotification,
});
it('should test immoscout provider', async () => { it('should test immoscout provider', async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => { return await new Promise((resolve) => {
if (!scrapingAnt.isScrapingAntApiKeySet()) { if (!scrapingAnt.isScrapingAntApiKeySet()) {
/* eslint-disable no-console */ /* eslint-disable no-console */
@@ -28,15 +20,12 @@ describe('#immoscout testsuite()', () => {
resolve(); resolve();
return; return;
} }
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immoscout', similarityCache); const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immoscout', similarityCache);
fredy.execute().then((listing) => { fredy.execute().then((listing) => {
expect(listing).to.be.a('array'); expect(listing).to.be.a('array');
const notificationObj = get();
const notificationObj = mockNotification.get();
expect(notificationObj).to.be.a('object'); expect(notificationObj).to.be.a('object');
expect(notificationObj.serviceName).to.equal('immoscout'); expect(notificationObj.serviceName).to.equal('immoscout');
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('number');
@@ -45,7 +34,6 @@ describe('#immoscout testsuite()', () => {
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²');

View File

@@ -1,34 +1,23 @@
const similarityCache = require('../../lib/services/similarity-check/similarityCache'); import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
const mockNotification = require('../mocks/mockNotification'); import { get } from '../mocks/mockNotification.js';
const providerConfig = require('./testProvider.json'); import { mockFredy, providerConfig } from '../utils.js';
const mockStore = require('../mocks/mockStore'); import { expect } from 'chai';
const proxyquire = require('proxyquire').noCallThru(); import * as provider from '../../lib/provider/immoswp.js';
const expect = require('chai').expect;
const provider = require('../../lib/provider/immoswp');
describe('#immoswp testsuite()', () => { describe('#immoswp testsuite()', () => {
after(() => { after(() => {
similarityCache.stopCacheCleanup(); similarityCache.stopCacheCleanup();
}); });
provider.init(providerConfig.immoswp, [], []); provider.init(providerConfig.immoswp, [], []);
const Fredy = proxyquire('../../lib/FredyRuntime', {
'./services/storage/listingsStorage': {
...mockStore,
},
'./notification/notify': mockNotification,
});
it('should test immoswp provider', async () => { it('should test immoswp provider', async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => { return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immoswp', similarityCache); const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immoswp', similarityCache);
fredy.execute().then((listing) => { fredy.execute().then((listing) => {
expect(listing).to.be.a('array'); expect(listing).to.be.a('array');
const notificationObj = get();
const notificationObj = mockNotification.get();
expect(notificationObj).to.be.a('object'); expect(notificationObj).to.be.a('object');
expect(notificationObj.serviceName).to.equal('immoswp'); expect(notificationObj.serviceName).to.equal('immoswp');
notificationObj.payload.forEach((notify) => { notificationObj.payload.forEach((notify) => {
/** check the actual structure **/ /** check the actual structure **/
expect(notify.id).to.be.a('string'); expect(notify.id).to.be.a('string');
@@ -36,13 +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();
}); });

View File

@@ -1,42 +1,30 @@
const similarityCache = require('../../lib/services/similarity-check/similarityCache'); import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
const mockNotification = require('../mocks/mockNotification'); import { get } from '../mocks/mockNotification.js';
const providerConfig = require('./testProvider.json'); import { mockFredy, providerConfig } from '../utils.js';
const mockStore = require('../mocks/mockStore'); import { expect } from 'chai';
const proxyquire = require('proxyquire').noCallThru(); import * as provider from '../../lib/provider/immowelt.js';
const expect = require('chai').expect;
const provider = require('../../lib/provider/immowelt');
describe('#immowelt testsuite()', () => { describe('#immowelt testsuite()', () => {
after(() => { after(() => {
similarityCache.stopCacheCleanup(); similarityCache.stopCacheCleanup();
}); });
it('should test immowelt provider', async () => { it('should test immowelt provider', async () => {
const Fredy = await mockFredy();
provider.init(providerConfig.immowelt, [], []); provider.init(providerConfig.immowelt, [], []);
const Fredy = proxyquire('../../lib/FredyRuntime', {
'./services/storage/listingsStorage': {
...mockStore,
},
'./notification/notify': mockNotification,
});
return await new Promise((resolve) => { return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immowelt', similarityCache); const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immowelt', similarityCache);
fredy.execute().then((listing) => { fredy.execute().then((listing) => {
expect(listing).to.be.a('array'); expect(listing).to.be.a('array');
const notificationObj = get();
const notificationObj = mockNotification.get();
expect(notificationObj).to.be.a('object'); expect(notificationObj).to.be.a('object');
expect(notificationObj.serviceName).to.equal('immowelt'); expect(notificationObj.serviceName).to.equal('immowelt');
notificationObj.payload.forEach((notify) => { notificationObj.payload.forEach((notify) => {
/** check the actual structure **/ /** check the actual structure **/
expect(notify.id).to.be.a('string'); expect(notify.id).to.be.a('string');
expect(notify.price).to.be.a('string'); expect(notify.price).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 **/
if (notify.size != null && notify.size.trim().toLowerCase() !== 'k.a.') { if (notify.size != null && notify.size.trim().toLowerCase() !== 'k.a.') {
expect(notify.size).that.does.include('m²'); expect(notify.size).that.does.include('m²');

View File

@@ -1,43 +1,32 @@
const similarityCache = require('../../lib/services/similarity-check/similarityCache'); import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
const mockNotification = require('../mocks/mockNotification'); import { get } from '../mocks/mockNotification.js';
const providerConfig = require('./testProvider.json'); import { mockFredy, providerConfig } from '../utils.js';
const mockStore = require('../mocks/mockStore'); import { expect } from 'chai';
const proxyquire = require('proxyquire').noCallThru(); import * as provider from '../../lib/provider/kleinanzeigen.js';
const expect = require('chai').expect;
const provider = require('../../lib/provider/kleinanzeigen');
describe('#kleinanzeigen testsuite()', () => { describe('#kleinanzeigen testsuite()', () => {
after(() => { after(() => {
similarityCache.stopCacheCleanup(); similarityCache.stopCacheCleanup();
}); });
it('should test kleinanzeigen provider', async () => { it('should test kleinanzeigen provider', async () => {
const Fredy = await mockFredy();
provider.init(providerConfig.kleinanzeigen, [], []); provider.init(providerConfig.kleinanzeigen, [], []);
const Fredy = proxyquire('../../lib/FredyRuntime', {
'./services/storage/listingsStorage': {
...mockStore,
},
'./notification/notify': mockNotification,
});
return await new Promise((resolve) => { return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'kleinanzeigen', similarityCache); const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'kleinanzeigen', similarityCache);
fredy.execute().then((listing) => { fredy.execute().then((listing) => {
expect(listing).to.be.a('array'); expect(listing).to.be.a('array');
const notificationObj = get();
const notificationObj = mockNotification.get();
expect(notificationObj).to.be.a('object'); expect(notificationObj).to.be.a('object');
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();

View File

@@ -1,48 +1,44 @@
const similarityCache = require('../../lib/services/similarity-check/similarityCache'); import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
const mockNotification = require('../mocks/mockNotification'); import {get} from '../mocks/mockNotification.js';
const providerConfig = require('./testProvider.json'); import {mockFredy, providerConfig} from '../utils.js';
const mockStore = require('../mocks/mockStore'); import {expect} from 'chai';
const proxyquire = require('proxyquire').noCallThru(); import * as provider from '../../lib/provider/neubauKompass.js';
const expect = require('chai').expect; import * as scrapingAnt from '../../lib/services/scrapingAnt.js';
const provider = require('../../lib/provider/neubauKompass');
describe('#neubauKompass testsuite()', () => { describe('#neubauKompass testsuite()', () => {
after(() => { after(() => {
similarityCache.stopCacheCleanup(); similarityCache.stopCacheCleanup();
}); });
provider.init(providerConfig.neubauKompass, [], []); provider.init(providerConfig.neubauKompass, [], []);
const Fredy = proxyquire('../../lib/FredyRuntime', { it('should test neubauKompass provider', async () => {
'./services/storage/listingsStorage': { const Fredy = await mockFredy();
...mockStore, return await new Promise((resolve) => {
}, if (!scrapingAnt.isScrapingAntApiKeySet()) {
'./notification/notify': mockNotification, /* eslint-disable no-console */
}); console.info('Skipping Neubaukompass test as ScrapingAnt Api Key is not set.');
/* eslint-enable no-console */
it('should test neubauKompass provider', async () => { resolve();
return await new Promise((resolve) => { return;
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'neubauKompass', similarityCache); }
fredy.execute().then((listing) => { const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'neubauKompass', similarityCache);
expect(listing).to.be.a('array'); fredy.execute().then((listing) => {
expect(listing).to.be.a('array');
const notificationObj = mockNotification.get(); const notificationObj = get();
expect(notificationObj.serviceName).to.equal('neubauKompass'); expect(notificationObj.serviceName).to.equal('neubauKompass');
notificationObj.payload.forEach((notify) => {
notificationObj.payload.forEach((notify) => { expect(notify).to.be.a('object');
expect(notify).to.be.a('object'); /** check the actual structure **/
expect(notify.id).to.be.a('string');
/** check the actual structure **/ expect(notify.title).to.be.a('string');
expect(notify.id).to.be.a('string'); expect(notify.link).to.be.a('string');
expect(notify.title).to.be.a('string'); expect(notify.address).to.be.a('string');
expect(notify.link).to.be.a('string'); /** check the values if possible **/
expect(notify.address).to.be.a('string'); expect(notify.title).to.be.not.empty;
expect(notify.link).that.does.include('https://www.neubaukompass.de');
/** check the values if possible **/ expect(notify.address).to.be.not.empty;
expect(notify.title).to.be.not.empty; });
expect(notify.link).that.does.include('https://www.neubaukompass.de'); resolve();
expect(notify.address).to.be.not.empty; });
}); });
resolve();
});
}); });
});
}); });

View File

@@ -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": {

View File

@@ -1,6 +1,6 @@
const utils = require('../../lib/utils'); import utils from '../../lib/utils.js';
const assert = require('assert'); import assert from 'assert';
const expect = require('chai').expect; import { expect } from 'chai';
const fakeWorkingHoursConfig = (from, to) => ({ const fakeWorkingHoursConfig = (from, to) => ({
workingHours: { workingHours: {
@@ -8,7 +8,6 @@ const fakeWorkingHoursConfig = (from, to) => ({
from, from,
}, },
}); });
describe('utils', () => { describe('utils', () => {
describe('#isOneOf()', () => { describe('#isOneOf()', () => {
it('should be false', () => { it('should be false', () => {
@@ -18,7 +17,6 @@ describe('utils', () => {
assert.equal(utils.isOneOf('bla blub blubber', ['bla']), true); assert.equal(utils.isOneOf('bla blub blubber', ['bla']), true);
}); });
}); });
describe('#duringWorkingHoursOrNotSet()', () => { describe('#duringWorkingHoursOrNotSet()', () => {
it('should be false', () => { it('should be false', () => {
expect(utils.duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('12:00', '13:00'), 0)).to.be.false; expect(utils.duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('12:00', '13:00'), 0)).to.be.false;

View File

@@ -1,39 +1,28 @@
const similarityCache = require('../../lib/services/similarity-check/similarityCache'); import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
const mockNotification = require('../mocks/mockNotification'); import { get } from '../mocks/mockNotification.js';
const providerConfig = require('./testProvider.json'); import { mockFredy, providerConfig } from '../utils.js';
const mockStore = require('../mocks/mockStore'); import { expect } from 'chai';
const proxyquire = require('proxyquire').noCallThru(); import * as provider from '../../lib/provider/wgGesucht.js';
const expect = require('chai').expect;
const provider = require('../../lib/provider/wgGesucht');
describe('#wgGesucht testsuite()', () => { describe('#wgGesucht testsuite()', () => {
after(() => { after(() => {
similarityCache.stopCacheCleanup(); similarityCache.stopCacheCleanup();
}); });
provider.init(providerConfig.wgGesucht, [], []); provider.init(providerConfig.wgGesucht, [], []);
const Fredy = proxyquire('../../lib/FredyRuntime', {
'./services/storage/listingsStorage': {
...mockStore,
},
'./notification/notify': mockNotification,
});
it('should test wgGesucht provider', async () => { it('should test wgGesucht provider', async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => { return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'wgGesucht', similarityCache); const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'wgGesucht', similarityCache);
fredy.execute().then((listing) => { fredy.execute().then((listing) => {
expect(listing).to.be.a('array'); expect(listing).to.be.a('array');
const notificationObj = mockNotification.get(); const notificationObj = get();
expect(notificationObj.serviceName).to.equal('wgGesucht'); expect(notificationObj.serviceName).to.equal('wgGesucht');
notificationObj.payload.forEach((notify) => { notificationObj.payload.forEach((notify) => {
expect(notify).to.be.a('object'); expect(notify).to.be.a('object');
/** check the actual structure **/ /** check the actual structure **/
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');
}); });

View File

@@ -1,9 +1,16 @@
const testData = require('./testData.json'); import fs from 'fs';
const expect = require('chai').expect; import { expect } from 'chai';
const fs = require('fs'); import { readFile } from 'fs/promises';
import mutator from '../../lib/services/queryStringMutator.js';
import queryString from 'query-string';
const mutator = require('../../lib/services/queryStringMutator.js'); const data = await readFile(new URL('./testData.json', import.meta.url));
const queryString = require('query-string');
const testData = JSON.parse(data);
let _provider = await Promise.all(
fs.readdirSync('./lib/provider/').map(async (integPath) => await import(`../../lib/provider/${integPath}`)),
);
/** /**
* Test test might look a bit weird at first, but listen stranger... * Test test might look a bit weird at first, but listen stranger...
@@ -12,18 +19,14 @@ const queryString = require('query-string');
*/ */
describe('queryStringMutator', () => { describe('queryStringMutator', () => {
it('should fix all urls', () => { it('should fix all urls', () => {
let _provider = fs.readdirSync('./lib/provider/').map((integPath) => require(`../../lib/provider/${integPath}`));
for (let test of testData) { for (let test of testData) {
const provider = _provider.find((p) => p.metaInformation.id === test.id); const provider = _provider.find((p) => p.metaInformation.id === test.id);
if (provider == null) { if (provider == null) {
throw new Error(`Cannot find provider for given id: ${test.id}`); throw new Error(`Cannot find provider for given id: ${test.id}`);
} }
const fixedUrl = mutator(test.url, provider.config.sortByDateParam); const fixedUrl = mutator(test.url, provider.config.sortByDateParam);
const expectedParams = queryString.parseUrl(test.shouldBecome); const expectedParams = queryString.parseUrl(test.shouldBecome);
const actualParams = queryString.parseUrl(fixedUrl); const actualParams = queryString.parseUrl(fixedUrl);
//check if all new params are existing //check if all new params are existing
expect(Object.keys(expectedParams.query)).to.include.members(Object.keys(actualParams.query)); expect(Object.keys(expectedParams.query)).to.include.members(Object.keys(actualParams.query));
expect(Object.values(expectedParams.query)).to.include.members(Object.values(actualParams.query)); expect(Object.values(expectedParams.query)).to.include.members(Object.values(actualParams.query));

View File

@@ -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"
}, },
{ {

View File

@@ -1,5 +1,5 @@
const SimilarityCacheEntry = require('../../lib/services/similarity-check/SimilarityCacheEntry'); import SimilarityCacheEntry from '../../lib/services/similarity-check/SimilarityCacheEntry.js';
const expect = require('chai').expect; import { expect } from 'chai';
describe('similarityCheck', () => { describe('similarityCheck', () => {
describe('#similarityCheck()', () => { describe('#similarityCheck()', () => {
@@ -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ørensenDice index,[3] Sørensen index and Dice\'s coefficient. Other variations include the "similarity coefficient" or "index", such as Dice similarity coefficient (DSC). Common alternate spellings for Sørensen are Sorenson, Soerenson and Sörenson, and all three can also be seen with the sen ending.' 'The index is known by several other names, especially SørensenDice index,[3] Sørensen index and Dice\'s coefficient. Other variations include the "similarity coefficient" or "index", such as Dice similarity coefficient (DSC). Common alternate spellings for Sørensen are Sorenson, Soerenson and Sörenson, and all three can also be seen with the sen ending.',
); );
check.setCacheEntry( 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.',
); );
}); });
}); });

17
test/utils.js Normal file
View File

@@ -0,0 +1,17 @@
import { readFile } from 'fs/promises';
import esmock from 'esmock';
import * as mockStore from './mocks/mockStore.js';
import { send } from './mocks/mockNotification.js';
export const providerConfig = JSON.parse(await readFile(new URL('./provider/testProvider.json', import.meta.url)));
export const mockFredy = async () => {
return await esmock('../lib/FredyRuntime', {
'../lib/services/storage/listingsStorage.js': {
...mockStore,
},
'../lib/notification/notify.js': {
send,
},
});
};

16
test/utils/utils.test.js Normal file
View 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;
});
});
});

View File

@@ -3,13 +3,10 @@ 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';
@@ -23,20 +20,21 @@ import './App.less';
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);
useEffect(() => { useEffect(() => {
async function init() { async function init() {
await dispatch.provider.getProvider();
await dispatch.jobs.getJobs();
await dispatch.jobs.getProcessingTimes();
await dispatch.notificationAdapter.getAdapter();
await dispatch.user.getCurrentUser(); await dispatch.user.getCurrentUser();
if (!needsLogin()) {
await dispatch.provider.getProvider();
await dispatch.jobs.getJobs();
await dispatch.jobs.getProcessingTimes();
await dispatch.notificationAdapter.getAdapter();
}
setLoading(false); setLoading(false);
} }
init(); init();
}, [currentUser?.userId]); }, [currentUser?.userId]);
@@ -56,44 +54,41 @@ export default function FredyApp() {
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()} /> <Switch>
<ToastsContainer toasts={toasts} onToastFinished={onToastFinished} /> <Route name="Insufficient Permission" path={'/403'} component={InsufficientPermission} />
<Switch> <Route name="Create new Job" path={'/jobs/new'} component={JobMutation} />
<Route name="Insufficient Permission" path={'/403'} component={InsufficientPermission} /> <Route name="Edit a Job" path={'/jobs/edit/:jobId'} component={JobMutation} />
<Route name="Create new Job" path={'/jobs/new'} component={JobMutation} /> <Route name="Insights into a Job" path={'/jobs/insights/:jobId'} component={JobInsight} />
<Route name="Edit a Job" path={'/jobs/edit/:jobId'} component={JobMutation} /> <Route name="Job overview" path={'/jobs'} component={Jobs} />
<Route name="Insights into a Job" path={'/jobs/insights/:jobId'} component={JobInsight} /> <PermissionAwareRoute
<Route name="Job overview" path={'/jobs'} component={Jobs} /> name="Create new User"
<PermissionAwareRoute path="/users/new"
name="Create new User" component={<UserMutator />}
path="/users/new" currentUser={currentUser}
component={<UserMutator />} />
currentUser={currentUser} <PermissionAwareRoute
/> name="Edit a user"
<PermissionAwareRoute path="/users/edit/:userId"
name="Edit a user" component={<UserMutator />}
path="/users/edit/:userId" currentUser={currentUser}
component={<UserMutator />} />
currentUser={currentUser} <PermissionAwareRoute name="Users" path="/users" component={<Users />} currentUser={currentUser} />
/> <PermissionAwareRoute
<PermissionAwareRoute name="Users" path="/users" component={<Users />} currentUser={currentUser} /> name="General Settings"
<PermissionAwareRoute path="/generalSettings"
name="General Settings" component={<GeneralSettings />}
path="/generalSettings" currentUser={currentUser}
component={<GeneralSettings />} />
currentUser={currentUser}
/>
<Redirect from="/" to={'/jobs'} /> <Redirect from="/" to={'/jobs'} />
</Switch> </Switch>
</div>
</div> </div>
</ToastContext.Provider> </div>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +0,0 @@
.headline{
color: #f1f1f1 !important;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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