Compare commits

...

31 Commits
5.4.8 ... 7.0.0

Author SHA1 Message Date
Christian Kellner
2c5eceb0c1 Making Fredy an ESM project (#70)
Making Fredy an ESM project
2023-03-13 13:42:43 +01:00
weakmap@gmail.com
7d0ec72a0c fixing end of file issue by upgrading node-fetch 2023-02-14 19:46:49 +01:00
weakmap@gmail.com
faf020bd53 typo 2023-02-11 21:33:54 +01:00
weakmap@gmail.com
7df0754217 typo 2023-02-11 21:33:30 +01:00
weakmap@gmail.com
11a3e8771b adding jetbrains as sponsor 2023-02-11 21:32:28 +01:00
weakmap@gmail.com
af996d81c9 smaller design improvements 2022-12-20 13:39:25 +01:00
weakmap@gmail.com
8a5fbcdf71 moving to node-fetch coz axios is causing issues with scrapingAnt 2022-12-20 10:21:15 +01:00
weakmap@gmail.com
60bb75da57 fixing dependencies 2022-12-19 21:49:47 +01:00
weakmap@gmail.com
45411080ab adding forgotten dependencies 2022-12-19 21:48:14 +01:00
weakmap@gmail.com
4785cf797d moving to vite as build system 🎉 2022-12-19 21:44:10 +01:00
weakmap@gmail.com
e155e992d4 Merge branch 'master' of https://github.com/orangecoding/fredy 2022-12-19 21:15:12 +01:00
weakmap@gmail.com
3ce08a3f2e next build version 2022-12-19 21:15:01 +01:00
Christian Kellner
169655800b Update config.json 2022-12-19 21:11:15 +01:00
weakmap@gmail.com
baf57b3641 upgrading to react 18 2022-12-19 21:10:00 +01:00
Quoc Duong Bui
47e4230b39 Provider for immobilien.de (#65)
* Add provider for immobilien.de
2022-12-19 19:29:13 +01:00
Quoc Duong Bui
c5f4333878 Update README information (#66) 2022-12-19 19:27:13 +01:00
Christian Kellner
c99b78fb54 Update README.md 2022-12-14 15:13:00 +01:00
Christian Kellner
88e1e1d3a9 Update README.md 2022-12-14 15:12:18 +01:00
Christian Kellner
31174b3c85 Update README.md 2022-12-14 15:11:57 +01:00
weakmap@gmail.com
265ea58bab correct version 2022-12-11 20:08:40 +01:00
weakmap@gmail.com
ab5ee59d72 ugrading dependencies | fixing tests | supporting multiple provider of the same type 2022-12-11 20:07:18 +01:00
Christian Kellner
2062aa11a3 Scrapingant proxies (#59)
* preparing scraping ant proxies

* adding general settings for scraping ant proxy

* retrying with new ui settings
2022-06-13 08:10:30 +02:00
Christian Kellner
a4501007ff next release version 2022-06-10 14:19:41 +02:00
Christian Kellner
bc01806421 fixing telegram provider not respecting rate limits 2022-06-10 14:19:20 +02:00
Christian Kellner
bfba6d4bd9 next release version 2022-04-29 13:26:29 +02:00
Christian Kellner
676d48807a scraping ant retries 2022-04-29 13:22:39 +02:00
Christian Kellner
1a37773a40 Update package.json 2022-04-05 14:36:46 +02:00
Sven
67497d9828 added run-script-os to scripts to separate win use of set from unix use of export (#52)
Co-authored-by: Sven Simonsen <contact@svensimonsen.com>
2022-04-05 09:25:51 +02:00
weakmap@gmail.com
62ea296f3b Merge branch 'master' of https://github.com/orangecoding/fredy 2022-03-27 19:43:09 +02:00
weakmap@gmail.com
52dafcef97 improving ui / ux 2022-03-27 19:42:58 +02:00
Christian Kellner
a06d20ee53 Update README.md 2022-03-26 15:09:33 +01:00
115 changed files with 3118 additions and 6422 deletions

View File

@@ -1,12 +1,11 @@
module.exports = { module.exports = {
env: { env: {
commonjs: true, es2021: true,
es6: true,
node: true, node: true,
browser: true, browser: true,
mocha: true, mocha: true,
}, },
parser: 'babel-eslint', parser: '@babel/eslint-parser',
extends: ['eslint:recommended', 'prettier'], extends: ['eslint:recommended', 'prettier'],
plugins: ['react'], plugins: ['react'],
globals: { globals: {
@@ -17,7 +16,6 @@ module.exports = {
fetch: true, fetch: true,
}, },
parserOptions: { parserOptions: {
ecmaVersion: 2018,
sourceType: 'module', sourceType: 'module',
}, },
rules: { rules: {

View File

@@ -1,3 +1,16 @@
Newer release changelog see https://github.com/orangecoding/fredy/releases
------------
###### [V5.5.0]
- Upgrading dependencies
- fixing provider
- allow multiple instances of 1 provider
- __BREAKING__: Minimum node version is now 16
###### [V5.4.6]
- Adding Instana node.js monitoring
-
###### [V5.4.5] ###### [V5.4.5]
- Adding Instana node.js monitoring - Adding Instana node.js monitoring

View File

@@ -8,9 +8,16 @@ _Fredy_ scrapes multiple services (Immonet, Immowelt etc.) and send new listings
If _Fredy_ finds matching results, it will send them to you via Slack, Email, Telegram etc. (More adapters can be configured.) As _Fredy_ stores the listings it has found, new results will not be sent to you twice (and as a side-effect, _Fredy_ can show some statistics). Furthermore, _Fredy_ checks duplicates per scraping so that the same listings are not being sent twice or more when posted on various platforms (which happens more often than one might think). If _Fredy_ finds matching results, it will send them to you via Slack, Email, Telegram etc. (More adapters can be configured.) As _Fredy_ stores the listings it has found, new results will not be sent to you twice (and as a side-effect, _Fredy_ can show some statistics). Furthermore, _Fredy_ checks duplicates per scraping so that the same listings are not being sent twice or more when posted on various platforms (which happens more often than one might think).
# Sponsorship [![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/orangecoding)
If you like my work, consider becoming a sponsor. I'm not expecting anybody to pay for _Fredy_ or any other Open Source Project I'm maintaining, however keep in mind, I'm doing all of this in my spare time :) Thanks.
<img src="https://github.com/orangecoding/fredy/blob/master/doc/jetbrains.png" width="200">
_Fredy_ is supported by JetBrains under Open Source Support Program
## Usage ## Usage
- Make sure to use Node.js 12 or above - Make sure to use Node.js 16 or above
- Run the following commands: - Run the following commands:
```ssh ```ssh
yarn (or npm install) yarn (or npm install)
@@ -33,20 +40,20 @@ _Fredy_ will start with the default port, set to `9998`. You can access _Fredy_
## Understanding the fundamentals ## Understanding the fundamentals
There are 3 important parts in Fredy, that you need to understand to leverage the full power of _Fredy_. There are 3 important parts in Fredy, that you need to understand to leverage the full power of _Fredy_.
#### Adapter #### Provider
_Fredy_ supports multiple services. Immonet, Immowelt and Ebay are just a few examples. Those services are called adapters within _Fredy_. When creating a new job, you can choose one or more adapters. _Fredy_ supports multiple services. Immonet, Immowelt and Ebay are just a few examples. Those services are called providers within _Fredy_. When creating a new job, you can choose one or more providers.
An adapter contains the URL that points to the search results for the respective service. If you go to immonet.de and search for something, the displayed URL in the browser is what the adapter needs to do its magic. A provider contains the URL that points to the search results for the respective service. If you go to immonet.de and search for something, the displayed URL in the browser is what the provider needs to do its magic.
**It is important that you order the search results by date, so that _Fredy_ always picks the latest results first!** **It is important that you order the search results by date, so that _Fredy_ always picks the latest results first!**
#### Provider #### Adapter
_Fredy_ supports multiple providers, such as Slack, SendGrid, Telegram etc. A search job can have as many providers as supported by _Fredy_. Each provider needs different configuration values, which you have to provide when using them. A provider dictactes how the frontend renders by telling the frontend what information it needs in order to send listings to the user. _Fredy_ supports multiple adapters, such as Slack, SendGrid, Telegram etc. A search job can have as many adapters as supported by _Fredy_. Each adapter needs different configuration values, which you have to provide when using them. A adapter dictactes how the frontend renders by telling the frontend what information it needs in order to send listings to the user.
#### Jobs #### Jobs
A Job wraps adapters and providers. _Fredy_ runs the configured jobs in a specific interval (can be configured in `/conf/config.json`). A Job wraps adapters and providers. _Fredy_ runs the configured jobs in a specific interval (can be configured in `/conf/config.json`).
## Creating your first job ## Creating your first job
To create your first job, click on the button "Create New Job" on the job table. The job creation dialog should be self-explanatory, however there is one important thing. To create your first job, click on the button "Create New Job" on the job table. The job creation dialog should be self-explanatory, however there is one important thing.
When configuring adapters, before copying the URL from your browser, make sure that you have sorted the results by date to make sure _Fredy_ always picks the latest results first. When configuring providers, before copying the URL from your browser, make sure that you have sorted the results by date to make sure _Fredy_ always picks the latest results first.
## User management ## User management
As an administrator, you can create, edit and remove users from _Fredy_. Be careful, each job is connected to the user that has created the job. If you remove the user, their jobs will also be removed. As an administrator, you can create, edit and remove users from _Fredy_. Be careful, each job is connected to the user that has created the job. If you remove the user, their jobs will also be removed.
@@ -54,11 +61,16 @@ As an administrator, you can create, edit and remove users from _Fredy_. Be care
# Development # Development
### Running Fredy in development mode ### Running Fredy in development mode
To run _Fredy_ in development mode, you need to run the backend & frontend separately. Run the backend in your favorite IDE, the frontend can be started from the terminal. To run _Fredy_ in development mode, you need to run the backend & frontend separately.
Start the backend with:
```shell
yarn run start
```
For the frontend, run:
```shell ```shell
yarn run dev yarn run dev
``` ```
You should now be able to access _Fredy_ from your browser. Go to `http://localhost:9000`. You should now be able to access _Fredy_ from your browser. Check your Terminal to see what port the frontend is running on.
### Running Tests ### Running Tests
To run the tests, run To run the tests, run
@@ -81,12 +93,6 @@ If you need more than the 1000 API calls allowed per month, I'd suggest opting f
See [Contributing](https://github.com/orangecoding/fredy/blob/master/CONTRIBUTING.md) See [Contributing](https://github.com/orangecoding/fredy/blob/master/CONTRIBUTING.md)
### Monitoring
_Fredy_ can be monitored by [Instana](https://www.instana.com). If you are interested, sign up for a free trial. This is totally optional of course :)
If you want to use Instana to monitor _Fredy_, please change the variable `INSTANA_MONITORING` in the `.env` file to `true`.
If you want to know more, head over to the [Instana docs](https://www.ibm.com/docs/en/obi/current?topic=technologies-monitoring-nodejs).
# Docker # Docker
Use the Dockerfile in this repository to build an image. Use the Dockerfile in this repository to build an image.

View File

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

BIN
doc/jetbrains.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

View File

@@ -4,7 +4,7 @@
<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="//cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.css"> <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>
@@ -13,5 +13,5 @@
<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>
<script src="fredy.bundle.js"></script> <script type="module" src="/ui/src/Index.jsx"></script>
</html> </html>

View File

@@ -1,63 +1,41 @@
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();
jobStorage jobStorage
.getJobs() .getJobs()
.filter((job) => job.enabled) .filter((job) => job.enabled)
.forEach((job) => { .forEach((job) => {
const providerIds = job.provider.map((provider) => provider.id); job.provider
.filter((p) => fetchedProvider.find((fp) => fp.metaInformation.id === p.id) != null)
provider .forEach(async (prov) => {
.filter((provider) => provider.endsWith('.js')) const pro = fetchedProvider.find((fp) => fp.metaInformation.id === prov.id);
.map((pro) => require(`${path}/${pro}`)) pro.init(prov, job.blacklist);
.filter((provider) => providerIds.indexOf(provider.metaInformation.id) !== -1) await new FredyRuntime(pro.config, job.notificationAdapter, prov.id, job.id, similarityCache).execute();
.forEach(async (pro) => {
const providerId = pro.metaInformation.id;
if (providerId == null || providerId.length === 0) {
throw new Error('Provider id must not be empty. => ' + pro);
}
const providerConfig = job.provider.find((jobProvider) => jobProvider.id === providerId);
if (providerConfig == null) {
throw new Error(`Provider Config for provider with id ${providerId} not found.`);
}
pro.init(providerConfig, job.blacklist);
await new FredyRuntime(
pro.config,
job.notificationAdapter,
providerId,
job.id,
similarityCache
).execute();
setLastJobExecution(job.id); setLastJobExecution(job.id);
}); });
}); });

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,7 +42,6 @@ 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;
@@ -87,25 +83,19 @@ 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); return listings.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 +103,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 +111,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 +124,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 axios = require('axios');
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,42 +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 result = await axios({ const response = await fetch(`https://api.scrapingant.com/v1/usage?x-api-key=${config.scrapingAnt.apiKey}`);
url: `https://api.scrapingant.com/v1/usage?x-api-key=${config.scrapingAnt.apiKey}`, scrapingAntData = await response.json();
});
scrapingAntData = result.data;
} 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 (
@@ -79,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 {
@@ -95,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;
@@ -115,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

@@ -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,33 +1,22 @@
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 === 'mailJet'
).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) .connect(apiPublicKey, apiPrivateKey)
.post('send', { version: 'v3.1' }) .post('send', { version: 'v3.1' })
@@ -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,39 +1,26 @@
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 axios = require('axios'); import fetch from 'node-fetch';
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
/**
* 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 { 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 axios.post(`${webhook}`, { method: 'POST',
channel: channel, headers: { 'Content-Type': 'application/json' },
text: message, body: {
channel: channel,
text: message,
},
}); });
}; };
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

@@ -1,15 +1,6 @@
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 }) => {
/**
* 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; const { apiKey, receiver, from, templateId } = notificationConfig.find((adapter) => adapter.id === 'sendGrid').fields;
sgMail.setApiKey(apiKey); sgMail.setApiKey(apiKey);
const msg = { const msg = {
@@ -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,16 +1,7 @@
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 }) => {
/**
* 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; const { token, channel } = notificationConfig.find((adapter) => adapter.id === 'slack').fields;
return newListings.map((payload) => return newListings.map((payload) =>
msg({ msg({
@@ -47,9 +38,8 @@ exports.send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
}) })
); );
}; };
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,7 +1,8 @@
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 axios = require('axios'); import fetch from 'node-fetch';
const MAX_ENTITIES_PER_CHUNK = 8;
const RATE_LIMIT_INTERVAL = 1010;
/** /**
* splitting an array into chunks because Telegram only allows for messages up to * splitting an array into chunks because Telegram only allows for messages up to
* 4096 chars, thus we have to split messages into chunks * 4096 chars, thus we have to split messages into chunks
@@ -14,54 +15,51 @@ 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
* @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 { 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, 3); 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'
); );
/**
return axios.post(`https://api.telegram.org/bot${token}/sendMessage`, { * This is to not break the rate limit. It is to only send 1 message per second
chat_id: chatId, */
text: message, return new Promise((resolve, reject) => {
parse_mode: 'HTML', setTimeout(() => {
disable_web_page_preview: true, fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
method: 'post',
body: JSON.stringify({
chat_id: chatId,
text: message,
parse_mode: 'HTML',
disable_web_page_preview: true,
}),
headers: { 'Content-Type': 'application/json' },
})
.then(() => {
resolve();
})
.catch(() => {
reject();
});
}, 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,24 +1,18 @@
const utils = require('../utils'); import utils from '../utils.js';
let appliedBlackList = []; let appliedBlackList = [];
function normalize(o) { function normalize(o) {
let size = `${o.size.replace(' Wohnfläche ', '').trim()}`; let size = `${o.size.replace(' Wohnfläche ', '').trim()}`;
if (o.rooms != null) { if (o.rooms != null) {
size += ` / / ${o.rooms.trim()}`; size += ` / / ${o.rooms.trim()}`;
} }
const link = `https://www.1a-immobilienmarkt.de/expose/${o.id}.html`; const link = `https://www.1a-immobilienmarkt.de/expose/${o.id}.html`;
return Object.assign(o, { size, link }); return Object.assign(o, { size, 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: '.tabelle', crawlContainer: '.tabelle',
@@ -34,17 +28,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: '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

@@ -0,0 +1,49 @@
import utils from '../utils.js';
let appliedBlackList = [];
function shortenLink(link) {
return link.substring(0, link.indexOf('?'));
}
function parseId(shortenedLink) {
return shortenedLink.substring(shortenedLink.lastIndexOf('/') + 1);
}
function normalize(o) {
const id = parseId(shortenLink(o.link));
const size = o.size || 'N/A m²';
const price = o.price || 'N/A €';
const title = o.title || 'No title available';
const address = o.address || 'No address available';
const link = shortenLink(o.link);
return Object.assign(o, { id, price, size, title, address, link });
}
function applyBlacklist(o) {
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
return titleNotBlacklisted && descNotBlacklisted;
}
const config = {
url: null,
crawlContainer: '.estates_list .list_immo a._ref',
sortByDateParam: 'sort_col=*created_ts&sort_dir=desc',
crawlFields: {
price: '.list_entry .immo_preis .label_info',
size: '.list_entry .flaeche .label_info | removeNewline | trim',
title: '.list_entry .part_text h3 span',
description: '.list_entry .description | trim',
link: '@href',
address: '.list_entry .place',
},
paginate: '.list_immo .blocknav .blocknav_list li.next a@href',
normalize: normalize,
filter: applyBlacklist,
};
export const init = (sourceConfig, blacklist) => {
config.enabled = sourceConfig.enabled;
config.url = sourceConfig.url;
appliedBlackList = blacklist || [];
};
export const metaInformation = {
name: 'Immobilien.de',
baseUrl: 'https://www.immobilien.de/',
id: 'immobilienDe',
};
export { config };

View File

@@ -1,7 +1,5 @@
const utils = require('../utils'); import utils 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 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²';
@@ -13,14 +11,11 @@ function normalize(o) {
const link = `https://www.immonet.de/angebot/${id}`; 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: '#result-list-stage .item',
@@ -36,17 +31,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: '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,17 @@
const utils = require('../utils'); import utils 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 = `https://www.immobilienscout24.de${o.link.substring(o.link.indexOf('/expose'))}`;
return Object.assign(o, { title, address, link }); return Object.assign(o, { 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',
@@ -33,17 +28,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,7 +1,5 @@
const utils = require('../utils'); import utils 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 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²';
@@ -12,14 +10,11 @@ function normalize(o) {
const description = o.description; const description = o.description;
return Object.assign(o, { id, address, price, size, title, link, description }); return Object.assign(o, { id, address, 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',
@@ -36,17 +31,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: 'Immo Südwest Presse', name: 'Immo Südwest Presse',
baseUrl: 'https://immo.swp.de/', baseUrl: 'https://immo.swp.de/',
id: __filename.slice(__dirname.length + 1, -3), id: 'immoswp',
}; };
export { config };
exports.config = config;

View File

@@ -1,18 +1,13 @@
const utils = require('../utils'); import utils from '../utils.js';
let appliedBlackList = []; let appliedBlackList = [];
function normalize(o) { function normalize(o) {
return o; return o;
} }
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: "div[class^='EstateItem-']",
@@ -29,16 +24,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 || [];
}; };
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,23 +1,17 @@
const utils = require('../utils'); import utils from '../utils.js';
let appliedBlackList = []; let appliedBlackList = [];
let appliedBlacklistedDistricts = []; let appliedBlacklistedDistricts = [];
function normalize(o) { function normalize(o) {
const size = o.size || '--- m²'; const size = o.size || '--- m²';
return Object.assign(o, { size }); return Object.assign(o, { 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 ',
@@ -36,18 +30,15 @@ const config = {
normalize: normalize, normalize: normalize,
filter: applyBlacklist, filter: applyBlacklist,
}; };
export const metaInformation = {
exports.metaInformation = {
name: 'Ebay Kleinanzeigen', name: 'Ebay Kleinanzeigen',
baseUrl: 'https://www.ebay-kleinanzeigen.de/', baseUrl: 'https://www.ebay-kleinanzeigen.de/',
id: __filename.slice(__dirname.length + 1, -3), id: 'kleinanzeigen',
}; };
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,15 +1,11 @@
const utils = require('../utils'); import utils from '../utils.js';
let appliedBlackList = []; let appliedBlackList = [];
function normalize(o) { function normalize(o) {
return o; return o;
} }
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',
@@ -25,17 +21,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: 'Neubau Kompass', name: 'Neubau Kompass',
baseUrl: 'https://www.neubaukompass.de/', baseUrl: 'https://www.neubaukompass.de/',
id: __filename.slice(__dirname.length + 1, -3), id: 'neubauKompass',
}; };
export { config };
exports.config = config;

View File

@@ -1,18 +1,13 @@
const utils = require('../utils'); import utils from '../utils.js';
let appliedBlackList = []; let appliedBlackList = [];
function normalize(o) { function normalize(o) {
return o; return o;
} }
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',
@@ -28,17 +23,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: 'Wg gesucht', name: 'Wg gesucht',
baseUrl: 'https://www.wg-gesucht.de/', baseUrl: 'https://www.wg-gesucht.de/',
id: __filename.slice(__dirname.length + 1, -3), id: 'wgGesucht',
}; };
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,36 +1,67 @@
const axios = require('axios'); import fetch from 'node-fetch';
const axiosRetry = require('axios-retry'); import { config } from '../utils.js';
import { makeUrlResidential } from './scrapingAnt.js';
axiosRetry(axios, { retryDelay: axiosRetry.exponentialDelay, retries: 3 }); //if ScrapingAnt got blocked, this http status is returned
const BLOCKED_HTTP_STATUS = 423;
const NOT_FOUND_HTTP_STATUS = 404;
const MAX_RETRIES_SCRAPING_ANT = 10;
const EXPECTED_STATUS_CODES = [BLOCKED_HTTP_STATUS, NOT_FOUND_HTTP_STATUS];
function makeDriver(headers = {}) { function makeDriver(headers = {}) {
let cookies = ''; let cookies = '';
async function scrapingAntDriver(context, callback, retryCounter = 0) {
return async function driver(context, callback) { const proxyType = config.scrapingAnt?.proxy || 'datacenter';
try { try {
const url = context.url; const url = proxyType === 'residential' ? makeUrlResidential(context.url) : context.url;
const result = await axios({ const response = await fetch(url, {
url, headers: {
...headers,
cookie: cookies,
},
});
const result = await response.text();
if (cookies.length === 0) {
cookies = response.headers.raw()['set-cookie'] || [];
}
callback(null, result);
} catch (exception) {
/* eslint-disable no-console */
if (!EXPECTED_STATUS_CODES.includes(exception.response?.status)) {
console.error(`Error while trying to scrape data from scraping ant. Received error: ${exception.message}`);
callback(null, []);
return;
}
if (retryCounter <= MAX_RETRIES_SCRAPING_ANT) {
retryCounter++;
console.debug(`ScrapingAnt got blocked. Retrying ${retryCounter} / ${MAX_RETRIES_SCRAPING_ANT}`);
await scrapingAntDriver(context, callback, retryCounter);
} else {
console.error(`Error while trying to scrape data from scraping ant. Received error: ${exception.message}`);
callback(null, []);
}
/* eslint-enable no-console */
}
}
/**
* 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)
*/
return async function driver(context, callback) {
if (context.url.toLowerCase().indexOf('scrapingant') !== -1) {
return scrapingAntDriver(context, callback);
}
try {
const response = await fetch(context.url, {
headers: { headers: {
...headers, ...headers,
Cookie: cookies, Cookie: cookies,
}, },
}); });
const result = await response.text();
if (typeof result.data === 'object' && url.toLowerCase().indexOf('scrapingant') !== -1) { callback(null, result);
//assume we have gotten a response from scrapingAnt
if (cookies.length === 0) {
cookies = result.data.cookies;
}
callback(null, result.data.content);
} else {
callback(null, result.data);
}
} catch (exception) { } catch (exception) {
console.error(`Error while trying to scrape data. Received error: ${exception.message}`); console.error(`Error while trying to scrape data. Received error: ${exception.message}`);
callback(null, []); callback(null, []);
} }
}; };
} }
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,26 +1,19 @@
const { metaInformation } = require('../provider/immoscout'); import { metaInformation } from '../provider/immoscout.js';
//to better configure re-capture chose a random proxy each time we do a call import { config } from '../utils.js';
const proxies = ['ae', 'br', 'cn', 'de', 'es', 'fr', 'gb', 'hk', 'in', 'it', 'il', 'jp', 'nl', 'ru', 'sa', 'us', 'cz'];
const config = require('../../conf/config.json');
const isImmoscout = (id) => { const isImmoscout = (id) => {
return id.toLowerCase() === metaInformation.id; return id.toLowerCase() === metaInformation.id;
}; };
export const transformUrlForScrapingAnt = (url, id) => {
exports.transformUrlForScrapingAnt = (url, id) => {
const randomProxy = proxies[Math.floor(Math.random() * proxies.length)];
if (isImmoscout(id)) { if (isImmoscout(id)) {
//only do calls to scrapingAnt when dealing with Immoscout //only do calls to scrapingAnt when dealing with Immoscout
url = `https://api.scrapingant.com/v1/general?url=${encodeURIComponent( url = `https://api.scrapingant.com/v1/general?url=${encodeURIComponent(url)}&proxy_type=datacenter`;
url
)}&proxy_country=${randomProxy}&proxy_type=residential`;
} }
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; return url.replace('datacenter', 'residential');
};
export { isImmoscout };

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) {
super(adapter);
this.chain = lodash.chain(this).get('data');
}
}

View File

@@ -1,28 +1,30 @@
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);
exports.upsertJob = ({ jobId, name, blacklist = [], enabled = true, provider, notificationAdapter, userId }) => { db.read();
db.data ||= { jobs: [] };
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 +35,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,15 @@
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();
db.data ||= {};
const buildKey = (jobKey, providerId, endpoint) => { const buildKey = (jobKey, providerId, endpoint) => {
let key = `${jobKey}`; let key = `${jobKey}`;
@@ -19,35 +24,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,14 +1,17 @@
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 file = path.join(getDirName(), '../', 'db/users.json');
const adapter = new JSONFileSync(file);
const db = new LowdashAdapter(adapter);
db.read();
db.data ||= {
user: [ user: [
//you probably want to change the default password ;) //you probably want to change the default password ;)
{ {
@@ -20,11 +23,11 @@ db.defaults({
isDemo: false, isDemo: false,
}, },
], ],
}).write(); };
exports.getUsers = (withPassword) => { 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,17 +1,18 @@
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { readFile } from 'fs/promises';
function isOneOf(word, arr) { function isOneOf(word, arr) {
if (arr == null || arr.length === 0) { if (arr == null || arr.length === 0) {
return false; return false;
} }
const expression = String.raw`\b(${arr.join('|')})\b`; const expression = String.raw`\b(${arr.join('|')})\b`;
const blacklist = new RegExp(expression, 'ig'); const blacklist = new RegExp(expression, 'ig');
return blacklist.test(word); return blacklist.test(word);
} }
function nullOrEmpty(val) { function nullOrEmpty(val) {
return val == null || val.length === 0; return val == null || val.length === 0;
} }
function timeStringToMs(timeString, now) { function timeStringToMs(timeString, now) {
const d = new Date(now); const d = new Date(now);
const parts = timeString.split(':'); const parts = timeString.split(':');
@@ -20,17 +21,31 @@ function timeStringToMs(timeString, now) {
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));
}
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 default {
isOneOf,
nullOrEmpty,
duringWorkingHoursOrNotSet,
getDirName,
config,
};

View File

@@ -1,13 +1,14 @@
{ {
"name": "fredy", "name": "fredy",
"version": "5.4.8", "version": "7.0.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 && export BUILD_DEV='true' && export NODE_ENV='development' && webpack serve --progress --color --config ./webpack.dev.js", "dev": "yarn && rm -rf ./ui/public/* && vite",
"prod": "export BUILD_DEV='false' && webpack --node-env=production --config ./webpack.prod.js", "ui": "rm -rf ./ui/public/* && vite",
"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/**/*.js test/**/*.js *.js --single-quote --print-width 120",
"test": "mocha --timeout 20000 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"
}, },
"husky": { "husky": {
@@ -15,6 +16,7 @@
"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",
@@ -43,7 +45,7 @@
}, },
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=14.0.0", "node": ">=16.0.0",
"npm": ">=7.0.0" "npm": ">=7.0.0"
}, },
"browserslist": [ "browserslist": [
@@ -55,63 +57,53 @@
"dependencies": { "dependencies": {
"@rematch/core": "2.2.0", "@rematch/core": "2.2.0",
"@rematch/loading": "2.1.2", "@rematch/loading": "2.1.2",
"@sendgrid/mail": "7.6.2", "@sendgrid/mail": "7.7.0",
"axios": "0.26.1", "@vitejs/plugin-react": "3.1.0",
"axios-retry": "^3.2.4", "better-sqlite3": "8.2.0",
"better-sqlite3": "^7.5.0", "body-parser": "1.20.2",
"body-parser": "1.19.2",
"cookie-session": "2.0.0", "cookie-session": "2.0.0",
"handlebars": "4.7.7", "handlebars": "4.7.7",
"highcharts": "10.0.0", "highcharts": "10.3.3",
"highcharts-react-official": "3.1.0", "highcharts-react-official": "3.2.0",
"lowdb": "1.0.0", "lodash": "^4.17.21",
"lowdb": "5.1.0",
"markdown": "^0.5.0", "markdown": "^0.5.0",
"nanoid": "3.3.1", "nanoid": "4.0.1",
"node-mailjet": "3.3.7", "node-fetch": "3.3.1",
"query-string": "7.1.1", "node-mailjet": "6.0.2",
"react": "17.0.2", "query-string": "8.1.0",
"react-dom": "17.0.2", "react": "18.2.0",
"react-redux": "7.2.6", "react-dom": "18.2.0",
"react-redux": "8.0.5",
"react-router": "5.2.1", "react-router": "5.2.1",
"react-router-dom": "5.3.0", "react-router-dom": "5.3.0",
"react-switch": "^6.0.0", "react-switch": "7.0.0",
"redux": "4.1.2", "redux": "4.2.1",
"redux-thunk": "2.4.1", "redux-thunk": "2.4.2",
"restana": "4.9.3", "restana": "4.9.7",
"semantic-ui-react": "2.1.2", "semantic-ui-react": "2.1.4",
"serve-static": "1.15.0", "serve-static": "1.15.0",
"slack": "11.0.2", "slack": "11.0.2",
"string-similarity": "^4.0.4", "string-similarity": "^4.0.4",
"vite": "4.1.4",
"x-ray": "2.3.4" "x-ray": "2.3.4"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.17.8", "esmock": "2.1.0",
"@babel/preset-env": "7.16.11", "@babel/core": "7.21.0",
"@babel/preset-react": "7.16.7", "@babel/eslint-parser": "7.19.1",
"babel-eslint": "10.1.0", "@babel/preset-env": "7.20.2",
"babel-loader": "8.2.4", "@babel/preset-react": "7.18.6",
"chai": "4.3.6", "chai": "4.3.7",
"clean-webpack-plugin": "4.0.0", "eslint": "8.36.0",
"copy-webpack-plugin": "10.2.4", "eslint-config-prettier": "8.7.0",
"css-loader": "6.7.1", "eslint-plugin-react": "7.32.2",
"eslint": "7.32.0",
"eslint-config-prettier": "8.5.0",
"eslint-plugin-react": "7.29.3",
"file-loader": "6.2.0",
"history": "5.3.0", "history": "5.3.0",
"husky": "4.3.8", "husky": "4.3.8",
"less": "4.1.2", "less": "4.1.3",
"less-loader": "10.2.0", "lint-staged": "13.2.0",
"lint-staged": "12.3.7", "mocha": "10.2.0",
"mocha": "9.2.2", "prettier": "2.8.4",
"prettier": "2.6.1", "redux-logger": "3.0.6"
"proxyquire": "2.1.3",
"redux-logger": "3.0.6",
"style-loader": "3.3.1",
"url-loader": "4.1.1",
"webpack": "5.70.0",
"webpack-cli": "4.9.2",
"webpack-dev-server": "3.11.2",
"webpack-merge": "5.8.0"
} }
} }

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,35 +1,25 @@
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 chai 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'); const expect = chai.expect;
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, 'test1', 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('number');
@@ -37,9 +27,7 @@ describe('#einsAImmobilien 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');
/** check the values if possible **/ /** check the values if possible **/
expect(notify.price).that.does.include('EUR');
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;
expect(notify.link).that.does.include('https://www.1a-immobilienmarkt.de'); expect(notify.link).that.does.include('https://www.1a-immobilienmarkt.de');

View File

@@ -0,0 +1,40 @@
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
import { get } from '../mocks/mockNotification.js';
import { providerConfig, mockFredy } from '../utils.js';
import chai from 'chai';
import * as provider from '../../lib/provider/immobilienDe.js';
const expect = chai.expect;
describe('#immobilien.de testsuite()', () => {
after(() => {
similarityCache.stopCacheCleanup();
});
provider.init(providerConfig.immobilienDe, [], []);
it('should test immobilien.de provider', async () => {
const Fredy = await mockFredy();
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'test1', similarityCache);
fredy.execute().then((listing) => {
expect(listing).to.be.a('array');
const notificationObj = get();
expect(notificationObj).to.be.a('object');
expect(notificationObj.serviceName).to.equal('immobilienDe');
notificationObj.payload.forEach((notify) => {
/** check the actual structure **/
expect(notify.id).to.be.a('string');
expect(notify.price).to.be.a('string');
expect(notify.size).to.be.a('string');
expect(notify.title).to.be.a('string');
expect(notify.link).to.be.a('string');
expect(notify.address).to.be.a('string');
/** check the values if possible **/
expect(notify.price).that.does.include('€');
expect(notify.size).that.does.include('m²');
expect(notify.title).to.be.not.empty;
expect(notify.link).that.does.include('https://www.immobilien.de');
expect(notify.address).to.be.not.empty;
});
resolve();
});
});
});
});

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 chai from 'chai';
const proxyquire = require('proxyquire').noCallThru(); import * as provider from '../../lib/provider/immonet.js';
const expect = require('chai').expect; const expect = chai.expect;
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) => {
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'test1', 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('number');
@@ -37,7 +26,6 @@ describe('#immonet 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,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 chai 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 expect = chai.expect;
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, '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('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 chai from 'chai';
const proxyquire = require('proxyquire').noCallThru(); import * as provider from '../../lib/provider/immoswp.js';
const expect = require('chai').expect; const expect = 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, 'test1', 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');
@@ -37,7 +26,6 @@ describe('#immoswp 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.title).to.be.not.empty; expect(notify.title).to.be.not.empty;

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 chai from 'chai';
const proxyquire = require('proxyquire').noCallThru(); import * as provider from '../../lib/provider/immowelt.js';
const expect = require('chai').expect; const expect = 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, 'test1', 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,42 +1,29 @@
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 chai from 'chai';
const proxyquire = require('proxyquire').noCallThru(); import * as provider from '../../lib/provider/kleinanzeigen.js';
const expect = require('chai').expect; const expect = 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, 'test1', 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('number');
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.price).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.ebay-kleinanzeigen.de');

View File

@@ -1,41 +1,29 @@
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 chai from 'chai';
const proxyquire = require('proxyquire').noCallThru(); import * as provider from '../../lib/provider/neubauKompass.js';
const expect = require('chai').expect; const expect = chai.expect;
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', {
'./services/storage/listingsStorage': {
...mockStore,
},
'./notification/notify': mockNotification,
});
it('should test neubauKompass provider', async () => { it('should test neubauKompass 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, 'neubauKompass', 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.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 **/ /** 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.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.neubaukompass.de'); expect(notify.link).that.does.include('https://www.neubaukompass.de');

View File

@@ -4,6 +4,10 @@
"enabled": true, "enabled": true,
"id": "einsAImmobilien" "id": "einsAImmobilien"
}, },
"immobilienDe": {
"url": "https://www.immobilien.de/Wohnen/Suchergebnisse-51797.html?search._digest=true&search._filter=wohnen&search.flaeche_von=50&search.objektart=wohnung&search.preis_bis=1200&search.typ=mieten&search.umkreis=15&search.wo=district%3A2434%2C2695%2C2621%2C2700%2C2967%2C2734%2C2909%2C2955%2C2392%2C2746%2C2767%2C2982%2C2904%2C2612%2C2892%2C2587%2C2871%2C2975%2C2591%2C2887%2C2569%2C2640%2C2735&sort_col=*created_ts&sort_dir=desc",
"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/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=",
"enabled": true "enabled": true
@@ -36,4 +40,4 @@
"url": "https://www.wg-gesucht.de/wg-zimmer-in-Duesseldorf.30.0.1.0.html?offer_filter=1&noDeact=1&city_id=30&category=0&rent_type=0&rMax=5000", "url": "https://www.wg-gesucht.de/wg-zimmer-in-Duesseldorf.30.0.1.0.html?offer_filter=1&noDeact=1&city_id=30&category=0&rent_type=0&rMax=5000",
"enabled": true "enabled": true
} }
} }

View File

@@ -1,14 +1,13 @@
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 chai from 'chai';
const expect = chai.expect;
const fakeWorkingHoursConfig = (from, to) => ({ const fakeWorkingHoursConfig = (from, to) => ({
workingHours: { workingHours: {
to, 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,35 +1,25 @@
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 chai from 'chai';
const proxyquire = require('proxyquire').noCallThru(); import * as provider from '../../lib/provider/wgGesucht.js';
const expect = require('chai').expect; const expect = 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, 'test1', 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');

View File

@@ -1,9 +1,17 @@
const testData = require('./testData.json'); import fs from 'fs';
const expect = require('chai').expect; import chai from 'chai';
const fs = require('fs'); import { readFile } from 'fs/promises';
import mutator from '../../lib/services/queryStringMutator.js';
import queryString from 'query-string';
const expect = chai.expect;
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 +20,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,6 +1,6 @@
const SimilarityCacheEntry = require('../../lib/services/similarity-check/SimilarityCacheEntry'); import SimilarityCacheEntry from '../../lib/services/similarity-check/SimilarityCacheEntry.js';
const expect = require('chai').expect; import chai from 'chai';
const expect = chai.expect;
describe('similarityCheck', () => { describe('similarityCheck', () => {
describe('#similarityCheck()', () => { describe('#similarityCheck()', () => {
it('should be false', () => { it('should be false', () => {

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

View File

@@ -7,7 +7,7 @@ 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 ToastContext from './components/toasts/ToastContext';
import JobInsight from './views/jobs/insights/JobInsight'; 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 useToast from './components/toasts/useToast';
import { Switch, Redirect } from 'react-router-dom'; import { Switch, Redirect } from 'react-router-dom';
@@ -27,14 +27,17 @@ export default function FredyApp() {
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(async () => { useEffect(() => {
await dispatch.provider.getProvider(); async function init() {
await dispatch.jobs.getJobs(); await dispatch.provider.getProvider();
await dispatch.jobs.getProcessingTimes(); await dispatch.jobs.getJobs();
await dispatch.notificationAdapter.getAdapter(); await dispatch.jobs.getProcessingTimes();
await dispatch.user.getCurrentUser(); await dispatch.notificationAdapter.getAdapter();
await dispatch.user.getCurrentUser();
setLoading(false); setLoading(false);
}
init();
}, [currentUser?.userId]); }, [currentUser?.userId]);
const needsLogin = () => { const needsLogin = () => {

View File

@@ -7,7 +7,15 @@
width: 100%; width: 100%;
padding: 1rem 1rem; padding: 1rem 1rem;
background-color: #3f3e3ef5; background-color: #595959f5;
color: #f1f1f1; color: #f1f1f1;
} }
}
.ui.inverted.segment{
background: #31303078!important;
}
.ui.black.label, .ui.black.labels .label {
background-color: #31303078!important;
} }

View File

@@ -4,7 +4,9 @@ import { reduxStore } from './services/rematch/store';
import { HashRouter } from 'react-router-dom'; 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 ReactDOM from 'react-dom'; import { createRoot } from 'react-dom/client';
const container = document.getElementById('fredy');
const root = createRoot(container);
const history = createHashHistory(); const history = createHashHistory();
@@ -12,11 +14,10 @@ import App from './App';
import './Index.less'; import './Index.less';
ReactDOM.render( root.render(
<Provider store={reduxStore}> <Provider store={reduxStore}>
<HashRouter history={history}> <HashRouter history={history}>
<App /> <App />
</HashRouter> </HashRouter>
</Provider>, </Provider>
document.getElementById('fredy')
); );

View File

@@ -2,5 +2,5 @@ body, html {
margin: 0; margin: 0;
height: 100%; height: 100%;
width: 100%; width: 100%;
background-color: #3f3e3ef5; background-color: #595959f5;
} }

View File

@@ -4,7 +4,7 @@
&__active { &__active {
border-bottom: 1px solid #06dcfff2 !important; border-bottom: 1px solid #06dcfff2 !important;
font-weight: 550 !important; font-weight: 550 !important;
color: #78e5ff !important; color: #3ed7ff !important;
margin: 0 0 -1px !important; margin: 0 0 -1px !important;
} }

View File

@@ -0,0 +1,27 @@
import React from 'react';
import { Header, Icon, Popup, Segment } from 'semantic-ui-react';
import './SegmentParts.less';
export const SegmentPart = ({ name, icon = null, children, helpText }) => (
<Segment inverted>
<Header as="h5" inverted sub>
{icon && <Icon name={icon} inverted size="mini" />}
<Header.Content>{name}</Header.Content>
</Header>
<Popup
content={helpText}
trigger={
<span className="generalSettings__help">
{' '}
<Icon name="help circle" inverted />
What is this?
</span>
}
/>
<Segment inverted className="segmentParts">
{children}
</Segment>
</Segment>
);

View File

@@ -0,0 +1,4 @@
.segmentParts {
border: 1px solid #323232 !important;
border-radius: 5px !important;
}

View File

@@ -1,5 +1,4 @@
import { xhrGet } from '../../xhr'; import { xhrGet } from '../../xhr';
export const generalSettings = { export const generalSettings = {
state: { state: {
settings: {}, settings: {},

View File

@@ -1,5 +1,4 @@
import { xhrGet } from '../../xhr'; import { xhrGet } from '../../xhr';
export const jobs = { export const jobs = {
state: { state: {
jobs: [], jobs: [],

View File

@@ -1,5 +1,4 @@
import { xhrGet } from '../../xhr'; import { xhrGet } from '../../xhr';
export const user = { export const user = {
state: { state: {
users: [], users: [],

View File

@@ -6,14 +6,11 @@ import { createLogger } from 'redux-logger';
import { jobs } from './models/jobs'; import { jobs } from './models/jobs';
import { user } from './models/user'; import { user } from './models/user';
import { init } from '@rematch/core'; import { init } from '@rematch/core';
const middleware = []; const middleware = [];
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
// eslint-disable-line no-redeclare // eslint-disable-line no-redeclare
middleware.push(createLogger({ duration: false, collapsed: (getState, action, logEntry) => !logEntry.error })); middleware.push(createLogger({ duration: false, collapsed: (getState, action, logEntry) => !logEntry.error }));
} }
const store = init({ const store = init({
name: 'fredy', name: 'fredy',
models: { models: {
@@ -28,5 +25,4 @@ const store = init({
middlewares: middleware, middlewares: middleware,
}, },
}); });
export const reduxStore = store; export const reduxStore = store;

View File

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

View File

@@ -1,10 +1,8 @@
export function transform({ id, name, fields }) { export function transform({ id, name, fields }) {
const fieldValues = {}; const fieldValues = {};
Object.keys(fields).map((key) => { Object.keys(fields).map((key) => {
fieldValues[key] = fields[key].value; fieldValues[key] = fields[key].value;
}); });
return { return {
id, id,
name, name,

View File

@@ -9,7 +9,6 @@
export function xhrPost(url, data, contentType = 'application/json; charset=utf-8', isJson = true) { export function xhrPost(url, data, contentType = 'application/json; charset=utf-8', isJson = true) {
return executePostOrPutCall(url, contentType, data, isJson, true); return executePostOrPutCall(url, contentType, data, isJson, true);
} }
/** /**
* put request to backend. * put request to backend.
* *
@@ -21,7 +20,6 @@ export function xhrPost(url, data, contentType = 'application/json; charset=utf-
export function xhrPut(url, data, contentType = 'application/json; charset=utf-8', isJson = true) { export function xhrPut(url, data, contentType = 'application/json; charset=utf-8', isJson = true) {
return executePostOrPutCall(url, contentType, data, isJson, false); return executePostOrPutCall(url, contentType, data, isJson, false);
} }
function executePostOrPutCall(url, contentType, data, isJson, isPost) { function executePostOrPutCall(url, contentType, data, isJson, isPost) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
fetch(url, { fetch(url, {
@@ -41,7 +39,6 @@ function executePostOrPutCall(url, contentType, data, isJson, isPost) {
}); });
}); });
} }
/** /**
* get request to backend * get request to backend
* returns a Promise with * returns a Promise with
@@ -75,7 +72,6 @@ export function xhrGet(url, contentType = 'application/json; charset=utf-8', isJ
}); });
}); });
} }
/** /**
* delete request to backend * delete request to backend
* returns a Promise with * returns a Promise with
@@ -114,7 +110,6 @@ export function xhrDelete(url, data, contentType = 'application/json; charset=ut
}); });
}); });
} }
function parseJSON(response) { function parseJSON(response) {
return new Promise((resolve, reject) => return new Promise((resolve, reject) =>
response response
@@ -122,7 +117,6 @@ function parseJSON(response) {
.then((text) => { .then((text) => {
//some responses doesn't contain a body. .json() would throw errors here... //some responses doesn't contain a body. .json() would throw errors here...
const json = text != null && text.length > 0 ? JSON.parse(text) : {}; const json = text != null && text.length > 0 ? JSON.parse(text) : {};
if (response.ok) { if (response.ok) {
resolve({ resolve({
status: response.status, status: response.status,

View File

@@ -2,36 +2,13 @@ import React from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { Button, Form, Header, Icon, Message, Popup, Segment } from 'semantic-ui-react'; import { Button, Form, Icon, Message, Segment, Radio } from 'semantic-ui-react';
import ToastContext from '../../components/toasts/ToastContext'; import ToastContext from '../../components/toasts/ToastContext';
import Headline from '../../components/headline/Headline'; import Headline from '../../components/headline/Headline';
import { xhrPost } from '../../services/xhr'; import { xhrPost } from '../../services/xhr';
import { SegmentPart } from '../../components/segment/SegmentPart';
import './GeneralSettings.less'; import './GeneralSettings.less';
const SegmentPart = ({ name, icon, children, helpText }) => (
<React.Fragment>
<Header as="h5" inverted attached="top" sub>
<Icon name={icon} inverted size="mini" />
<Header.Content>{name}</Header.Content>
</Header>
<Popup
content={helpText}
trigger={
<span className="generalSettings__help">
{' '}
<Icon name="help circle" inverted />
What is this?
</span>
}
/>
<Segment inverted attached>
{children}
</Segment>
</React.Fragment>
);
const GeneralSettings = function Users() { const GeneralSettings = function Users() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
@@ -41,21 +18,29 @@ const GeneralSettings = function Users() {
const [interval, setInterval] = React.useState(''); const [interval, setInterval] = React.useState('');
const [port, setPort] = React.useState(''); const [port, setPort] = React.useState('');
const [scrapingAntApiKey, setScrapingAntApiKey] = React.useState(''); const [scrapingAntApiKey, setScrapingAntApiKey] = React.useState('');
const [scrapingAntProxy, setScrapingAntProxy] = React.useState('');
const [workingHourFrom, setWorkingHourFrom] = React.useState(null); const [workingHourFrom, setWorkingHourFrom] = React.useState(null);
const [workingHourTo, setWorkingHourTo] = React.useState(null); const [workingHourTo, setWorkingHourTo] = React.useState(null);
const ctx = React.useContext(ToastContext); const ctx = React.useContext(ToastContext);
React.useEffect(async () => { React.useEffect(() => {
await dispatch.generalSettings.getGeneralSettings(); async function init() {
setLoading(false); await dispatch.generalSettings.getGeneralSettings();
setLoading(false);
}
init();
}, []); }, []);
React.useEffect(async () => { React.useEffect(() => {
setInterval(settings?.interval); async function init() {
setPort(settings?.port); setInterval(settings?.interval);
setScrapingAntApiKey(settings?.scrapingAnt?.apiKey); setPort(settings?.port);
setWorkingHourFrom(settings?.workingHours?.from); setScrapingAntApiKey(settings?.scrapingAnt?.apiKey);
setWorkingHourTo(settings?.workingHours?.to); setWorkingHourFrom(settings?.workingHours?.from);
setWorkingHourTo(settings?.workingHours?.to);
setScrapingAntProxy(settings?.scrapingAnt?.proxy || 'datacenter');
}
init();
}, [settings]); }, [settings]);
const nullOrEmpty = (val) => val == null || val.length === 0; const nullOrEmpty = (val) => val == null || val.length === 0;
@@ -92,6 +77,7 @@ const GeneralSettings = function Users() {
port, port,
scrapingAnt: { scrapingAnt: {
apiKey: scrapingAntApiKey, apiKey: scrapingAntApiKey,
proxy: scrapingAntProxy,
}, },
workingHours: { workingHours: {
from: workingHourFrom, from: workingHourFrom,
@@ -111,7 +97,7 @@ const GeneralSettings = function Users() {
{!loading && ( {!loading && (
<React.Fragment> <React.Fragment>
<Headline text="General Settings" /> <Headline text="General Settings" />
<Message info> <Message className="generalSettings__message">
<h5> <h5>
<Icon name="info circle" /> <Icon name="info circle" />
Info Info
@@ -167,6 +153,48 @@ const GeneralSettings = function Users() {
/> />
</SegmentPart> </SegmentPart>
<SegmentPart
name="ScrapingAnt proxy settings"
helpText="Scraping ant provides different proxies."
icon="key"
>
<Message info>
ScrapingAnt is needed to scrape Immoscout. ScrapingAnt itself is using 2 different types of proxies.{' '}
<br />
<h4>Datacenter-Proxy</h4>
Proxy server located in one of the datacenters across the world. Datacenter proxies are slower and more
likely to fail, but they are cheaper. A call with a datacenter proxy cost 10 credits.
<h4>Residential-Proxy</h4>
High-quality proxy server located in one of the real people houses across the world. Datacenter proxies
are faster and more likely to success, but they are more expensive. A call with a datacenter proxy cost
250 credits.
<br />
<br />
<b>
On the free tier, you have 10.000 credits, so chose your option wisely. Keep in mind, only successful
calls will be charged.
</b>
</Message>
<Form.Field>
<Radio
label="Datacenter proxy"
name="scrapingAntProxy"
value="datacenter"
checked={scrapingAntProxy === 'datacenter'}
onChange={(e, { value }) => setScrapingAntProxy(value)}
/>
</Form.Field>
<Form.Field>
<Radio
label="Residential proxy"
name="scrapingAntProxy"
value="residential"
checked={scrapingAntProxy === 'residential'}
onChange={(e, { value }) => setScrapingAntProxy(value)}
/>
</Form.Field>
</SegmentPart>
<SegmentPart <SegmentPart
name="Working hours" name="Working hours"
helpText="During this hours, Fredy will search for new apartments. If nothing is configured, Fredy will search around the clock." helpText="During this hours, Fredy will search for new apartments. If nothing is configured, Fredy will search around the clock."
@@ -176,7 +204,7 @@ const GeneralSettings = function Users() {
<Form.Input <Form.Input
className="generalSettings__time" className="generalSettings__time"
type="time" type="time"
placeholder="ScrapingAnt Api Key" placeholder="Working hours from"
inverted inverted
size="mini" size="mini"
width={2} width={2}
@@ -186,7 +214,7 @@ const GeneralSettings = function Users() {
<div className="generalSettings__until">until</div> <div className="generalSettings__until">until</div>
<Form.Input <Form.Input
type="time" type="time"
placeholder="ScrapingAnt Api Key" placeholder="Working hours to"
inverted inverted
size="mini" size="mini"
width={2} width={2}

View File

@@ -14,4 +14,8 @@
margin-left: 1rem; margin-left: 1rem;
} }
&__message{
background: #8fe8ff!important;
}
} }

View File

@@ -37,9 +37,13 @@ export default function ProcessingTimes({ processingTimes }) {
{processingTimes.scrapingAntData.plan_total_credits} (250 credits per call) {processingTimes.scrapingAntData.plan_total_credits} (250 credits per call)
</Message.Item> </Message.Item>
</Message.List> </Message.List>
If you want to scrape Immoscout more often, you have to purchase a premium account of ScrapingAnt. You can use If you want to scrape Immoscout more often, you have to purchase a premium account of{' '}
the code <b>FREDY10</b> to get 10% off. (No affiliation, we are <b>not</b> getting paid to recommend <a href="https://scrapingant.com/" target="_blank" rel="noreferrer">
ScrapingAnt. {' '}
ScrapingAnt
</a>
. You can use the code <b>FREDY10</b> to get 10% off. (No affiliation, we are <b>not</b> getting paid to
recommend ScrapingAnt.)
</Segment> </Segment>
)} )}
</React.Fragment> </React.Fragment>

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