Compare commits

..

6 Commits

Author SHA1 Message Date
orangecoding
8d93581dfc do not send notifications when listings have been filtered 2021-07-05 12:01:14 +02:00
Christian Kellner
b65c5d1a0c Update README.md 2021-07-01 14:26:56 +02:00
Christian Kellner
57d295e882 Update README.md 2021-06-28 14:51:23 +02:00
Christian Kellner
59e6d287fc adding similarity check (#29)
* adding similarity check

* adding paging

* fixing tests

* docu

* better error handling

* fixing tests

* adjusting page limit

* fixing login screen

* cleanup

* upgrade browser list

* prevent spamming the log

* fixing tests

* removing job listings when removing a job or the user
2021-06-28 08:52:09 +02:00
Christian Kellner
88c046dbd4 Update config.json
Set the default interval to 1 hour. 30 mins was a little too much
2021-05-31 08:58:41 +02:00
Christian Kellner
97858b7539 General settings (#28)
adding ui for general settings | adding 'working hours' as new feature
2021-05-30 09:37:45 +02:00
40 changed files with 1801 additions and 1137 deletions

View File

@@ -4,6 +4,7 @@ module.exports = {
es6: true,
node: true,
browser: true,
mocha: true,
},
parser: 'babel-eslint',
extends: ['eslint:recommended', 'prettier'],
@@ -11,6 +12,7 @@ module.exports = {
globals: {
Promise: false,
describe: true,
after: true,
it: true,
fetch: true,
},

View File

@@ -1,3 +1,14 @@
###### [V5.2.0]
- Upgrading dependencies
- Adding new similarity check layer (Duplicates are being removed now)
- Adding paging for search results
###### [V5.1.0]
- Upgrading dependencies
- NodeJS 12.13 is now the minimum supported version
- Adding general settings as new configuration page to ui
- Adding new feature working hours
###### [V5.0.0]
- Upgrading dependencies
- NodeJS 12 is now the minimum supported version

View File

@@ -2,9 +2,11 @@
[![Build Status](https://travis-ci.org/orangecoding/fredy.svg?branch=master)](https://travis-ci.org/orangecoding/fredy)
_Fredy_ scrapes multiple services (Immonet, Immowelt etc.) as often as you want and send new listings to you once they appear. The list of available services can easily be extended. For your convenience, a ui helps you to configure your search jobs.
Searching an apartment in Germany can be quite frustrating. Not any longer as Fredy will take over and only notifies you once new listings have been found that matches your requirements.
If _Fredy_ found matching results, it will send them to you via Slack, Email, Telegram etc. (More adapter possible.) As _Fredy_ will store the listings it found, new results will not be sent twice (and as a side-effect, _Fredy_ can show some statistics..)
_Fredy_ scrapes multiple services (Immonet, Immowelt etc.) and send new listings to you once they appear. The list of available services can easily be extended. For your convenience, a ui helps you to configure your search jobs.
If _Fredy_ found matching results, it will send them to you via Slack, Email, Telegram etc. (More adapter possible.) As _Fredy_ will store the listings it has found, new results will not be sent 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 when posted on various platforms. (Happens more often than one might think)
## Usage
@@ -15,10 +17,10 @@ yarn (or npm install)
yarn run prod
yarn run start
```
_Fredy_ will start with the default port, set to `9998`. You can access _Fredy_ by opening a browser `http://localhost:9998`. The default login is `admin` for username and password. (You should change the password asap when you plan to run Fredy on your server.)
_Fredy_ will start with the default port, set to `9998`. You can access _Fredy_ by opening a browser `http://localhost:9998`. The default login is `admin` both for username and password. (You should change the password asap when you plan to run Fredy on your server.)
<p align="center">
<img alt="Job Configuration" src="https://github.com/orangecoding/fredy/blob/master/doc/screenshot_1.png" width="30%">
<img alt="Job Configuration" src="https://github.com/orangecoding/fredy/blob/master/doc/screenshot__1.png" width="30%">
&nbsp; &nbsp; &nbsp; &nbsp;
<img alt="Job Analytics" src="https://github.com/orangecoding/fredy/blob/master/doc/screenshot2.png" width="30%">
&nbsp; &nbsp; &nbsp; &nbsp;
@@ -29,9 +31,9 @@ _Fredy_ will start with the default port, set to `9998`. You can access _Fredy_
</p>
## Immoscout
I have added **EXPERIMENTAL** support for Immoscout. Immoscout is somewhat special, coz they have decided to secure their service from bots using Re-Capture. Finding a way around this is barely possible. For _Fredy_ to be able to bypass the check, I'm using a service called [ScrapingAnt](https://scrapingant.com/). The trick is to use a headless browser, rotating proxies and (once successful validated) re-send the cookies each time.
I have added **experimental** support for Immoscout. Immoscout is somewhat special, coz they have decided to secure their service from bots using Re-Capture. Finding a way around this is barely possible. For _Fredy_ to be able to bypass the check, I'm using a service called [ScrapingAnt](https://scrapingant.com/). The trick is to use a headless browser, rotating proxies and (once successful validated) re-send the cookies each time.
To be able to use Immoscout, you need to create an account and copy the apiKey into the config file under /conf/config.json.
To be able to use Immoscout, you need to create an account at ScrapingAnt. Configure the ApiKey in the "General Settings" tab (visible when logged in as administrator).
The rest should be done by _Fredy_. Keep in mind, the support is experimental. There might be bugs and you might not always get pass the re-capture check, but most of the time it works pretty good :)
If you need more that the 1000 api calls you can do per month, I'd suggest opting for a paid account... ScrapingAnt loves OpenSource, therefor they've decided to give all _Fredy_ users a 10% discount by using the code **FREDY10** (No I don't get any money for recommending good services...)

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

BIN
doc/screenshot__1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

View File

@@ -9,10 +9,13 @@ const path = './lib/provider';
const provider = fs.readdirSync(path).filter((file) => file.endsWith('.js'));
const config = require('./conf/config.json');
const jobStorage = require('./lib/services/storage/jobStorage');
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');
@@ -24,31 +27,45 @@ console.log(`Started Fredy successfully. Ui can be accessed via http://localhost
/* eslint-enable no-console */
setInterval(
(function exec() {
config.lastRun = Date.now();
jobStorage
.getJobs()
.filter((job) => job.enabled)
.forEach((job) => {
const providerIds = job.provider.map((provider) => provider.id);
const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(config, Date.now());
provider
.filter((provider) => provider.endsWith('.js'))
.map((pro) => require(`${path}/${pro}`))
.filter((provider) => providerIds.indexOf(provider.metaInformation.id) !== -1)
.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).execute();
setLastJobExecution(job.id);
});
});
if (isDuringWorkingHoursOrNotSet) {
config.lastRun = Date.now();
jobStorage
.getJobs()
.filter((job) => job.enabled)
.forEach((job) => {
const providerIds = job.provider.map((provider) => provider.id);
provider
.filter((provider) => provider.endsWith('.js'))
.map((pro) => require(`${path}/${pro}`))
.filter((provider) => providerIds.indexOf(provider.metaInformation.id) !== -1)
.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);
});
});
} else {
/* eslint-disable no-console */
console.debug('Working hours set. Skipping as outside of working hours.');
/* eslint-enable no-console */
}
return exec;
})(),
INTERVAL

View File

@@ -1,4 +1,4 @@
const { NoNewListingsError } = require('./errors');
const { NoNewListingsWarning } = require('./errors');
const { setKnownListings, getKnownListings } = require('./services/storage/listingsStorage');
const notify = require('./notification/notify');
@@ -12,12 +12,14 @@ class FredyRuntime {
* @param notificationConfig the config for all notifications
* @param providerId the id of the provider currently in use
* @param jobKey key of the job that is currently running (from within the config)
* @param similarityCache cache instance holding values to check for similarity of entries
*/
constructor(providerConfig, notificationConfig, providerId, jobKey) {
constructor(providerConfig, notificationConfig, providerId, jobKey, similarityCache) {
this._providerConfig = providerConfig;
this._notificationConfig = notificationConfig;
this._providerId = providerId;
this._jobKey = jobKey;
this._similarityCache = similarityCache;
}
execute() {
@@ -33,6 +35,8 @@ class FredyRuntime {
.then(this._findNew.bind(this))
//store everything in db
.then(this._save.bind(this))
//check for similar listings. if found, remove them before notifying
.then(this._filterBySimilarListings.bind(this))
//notify the user using the configured notification adapter
.then(this._notify.bind(this))
//if an error occurred on the way, handle it here.
@@ -53,14 +57,29 @@ class FredyRuntime {
}
const u = scrapingAnt.isImmoscout(id) ? scrapingAnt.transformUrlForScrapingAnt(url, id) : url;
try {
xray(u, this._providerConfig.crawlContainer, [this._providerConfig.crawlFields])
.then((listings) => {
resolve(listings == null ? [] : listings);
})
.catch((err) => {
reject(err);
console.error(err);
});
if (this._providerConfig.paginate != null) {
xray(u, this._providerConfig.crawlContainer, [this._providerConfig.crawlFields])
//the first 2 pages should be enough here
//TODO: Think about automagically sort by date
.limit(2)
.paginate(this._providerConfig.paginate)
.then((listings) => {
resolve(listings == null ? [] : listings);
})
.catch((err) => {
reject(err);
console.error(err);
});
} else {
xray(u, this._providerConfig.crawlContainer, [this._providerConfig.crawlFields])
.then((listings) => {
resolve(listings == null ? [] : listings);
})
.catch((err) => {
reject(err);
console.error(err);
});
}
} catch (error) {
reject(error);
console.error(error);
@@ -80,13 +99,16 @@ class FredyRuntime {
const newListings = listings.filter((o) => getKnownListings(this._jobKey, this._providerId)[o.id] == null);
if (newListings.length === 0) {
throw new NoNewListingsError();
throw new NoNewListingsWarning();
}
return newListings;
}
_notify(newListings) {
if (newListings.length === 0) {
throw new NoNewListingsWarning();
}
const sendNotifications = notify.send(this._providerId, newListings, this._notificationConfig, this._jobKey);
return Promise.all(sendNotifications).then(() => newListings);
}
@@ -100,8 +122,22 @@ class FredyRuntime {
return newListings;
}
_filterBySimilarListings(listings) {
const filteredList = listings.filter((listing) => {
const similar = this._similarityCache.hasSimilarEntries(this._jobKey, listing.title);
if (similar) {
/* eslint-disable no-console */
console.debug(`Filtering similar entry for job with id ${this._jobKey} with title: `, listing.title);
/* eslint-enable no-console */
}
return !similar;
});
filteredList.forEach((filter) => this._similarityCache.addCacheEntry(this._jobKey, filter.title));
return filteredList;
}
_handleError(err) {
if (err.name !== 'NoNewListingsError') console.error(err);
if (err.name !== 'NoNewListingsWarning') console.error(err);
}
}

View File

@@ -1,5 +1,6 @@
const { notificationAdapterRouter } = require('./routes/notificationAdapterRouter');
const { authInterceptor, cookieSession, adminInterceptor } = require('./security');
const { generalSettingsRouter } = require('./routes/generalSettingsRoute');
const { analyticsRouter } = require('./routes/analyticsRouter');
const { providerRouter } = require('./routes/providerRouter');
const { loginRouter } = require('./routes/loginRoute');
@@ -28,6 +29,7 @@ service.use('/api/jobs', authInterceptor());
service.use('/api/admin', adminInterceptor());
service.use('/api/jobs/notificationAdapter', notificationAdapterRouter);
service.use('/api/admin/generalSettings', generalSettingsRouter);
service.use('/api/jobs/provider', providerRouter);
service.use('/api/jobs/insights', analyticsRouter);
service.use('/api/admin/users', userRouter);

View File

@@ -0,0 +1,24 @@
const service = require('restana')();
const generalSettingsRouter = service.newRouter();
const config = require('../../../conf/config.json');
const fs = require('fs');
generalSettingsRouter.get('/', async (req, res) => {
res.body = Object.assign({}, config);
res.send();
});
generalSettingsRouter.post('/', async (req, res) => {
const settings = req.body;
try {
fs.writeFileSync(`${__dirname}/../../../conf/config.json`, JSON.stringify(settings));
} catch (err) {
console.error(err);
res.send(new Error('Error while trying to write settings.'));
return;
}
res.send();
});
exports.generalSettingsRouter = generalSettingsRouter;

View File

@@ -10,6 +10,6 @@ class ExtendableError extends Error {
}
}
class NoNewListingsError extends ExtendableError {}
class NoNewListingsWarning extends ExtendableError {}
module.exports = { NoNewListingsError };
module.exports = { NoNewListingsWarning };

View File

@@ -30,7 +30,6 @@ const config = {
title: '.tabelle .inner_object_data .tabelle_inhalt_titel_black | removeNewline | trim',
description: '.tabelle .inner_object_data .objekt_beschreibung | removeNewline | trim',
},
paginate: '.pagination_blocks div:last a@href',
normalize: normalize,
filter: applyBlacklist,
};

View File

@@ -20,7 +20,7 @@ function applyBlacklist(o) {
const config = {
url: null,
crawlContainer: '#srchrslt-adtable .ad-listitem',
crawlContainer: '#srchrslt-adtable .ad-listitem ',
crawlFields: {
id: '.aditem@data-adid | int',
price: '.aditem-main--middle--price | removeNewline | trim',

View File

@@ -24,7 +24,6 @@ const config = {
title: '.truncate_title a |removeNewline |trim',
link: '.truncate_title a@href',
},
paginate: '.pagination-sm:first a:last@href',
normalize: normalize,
filter: applyBlacklist,
};

View File

@@ -0,0 +1,36 @@
const stringSimilarity = require('string-similarity');
//if the score is higher than this, it will be considered a match
const MAX_DICE_INDEX = 0.7;
/**
* 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) {
this.time = time;
this.values = [];
}
setCacheEntry = (entry) => {
this.values.push(entry);
};
getTime = () => {
return this.time;
};
hasSimilarEntries = (value) => {
if (this.values.length > 0) {
for (let i = 0; i < this.values.length; i++) {
const index = stringSimilarity.compareTwoStrings(value, this.values[i]);
if (index >= MAX_DICE_INDEX) {
return true;
}
}
}
return false;
};
};

View File

@@ -0,0 +1,63 @@
/**
* each job that runs scrapes all provider. This cache holds the titles of the found listing(s) and provides
* 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
let retention = 5 * 60 * 1000;
const intervalInMs = config.interval * 60 * 1000;
//an interval below 5 mins sounds crazy, but there are ppl out there doing crazy shit.
if (intervalInMs <= retention) {
retention = Math.floor(intervalInMs / 2);
}
//jobid -> SimilarityCacheEntry
const cache = {};
let intervalId;
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
*/
intervalId = setInterval(() => {
const keysToBeRemoved = [];
const now = Date.now();
Object.keys(cache).forEach((key) => {
if (cache[key].getTime() + retention < now) {
keysToBeRemoved.push(key);
}
});
if (keysToBeRemoved.length > 0) {
keysToBeRemoved.forEach((key) => delete cache[key]);
}
}, 10000);
/**
* mostly used for tests
*/
exports.stopCacheCleanup = () => {
clearInterval(intervalId);
};

View File

@@ -61,12 +61,18 @@ exports.setJobStatus = ({ jobId, status }) => {
};
exports.removeJob = (jobId) => {
listingStorage.removeListings(jobId);
db.get('jobs')
.remove((job) => job.id === jobId)
.write();
};
exports.removeJobsByUserId = (userId) => {
db.get('jobs')
.value()
.filter((job) => job.userId === userId)
.forEach((job) => listingStorage.removeListings(job.id));
db.get('jobs')
.remove((job) => job.userId === userId)
.write();

View File

@@ -47,3 +47,7 @@ exports.setLastJobExecution = (jobId) => {
const key = buildKey(jobId, null, 'lastExecution');
return db.set(key, Date.now()).write();
};
exports.removeListings = (jobId) => {
db.unset(jobId).write();
};

View File

@@ -8,4 +8,29 @@ function isOneOf(word, arr) {
return blacklist.test(word);
}
module.exports = { isOneOf };
function nullOrEmpty(val) {
return val == null || val.length === 0;
}
function timeStringToMs(timeString, now) {
const d = new Date(now);
const parts = timeString.split(':');
d.setHours(parts[0]);
d.setMinutes(parts[1]);
d.setSeconds(0);
return d.getTime();
}
function duringWorkingHoursOrNotSet(config, now) {
const { workingHours } = config;
if (workingHours == null || nullOrEmpty(workingHours.from) || nullOrEmpty(workingHours.to)) {
return true;
}
const toDate = timeStringToMs(workingHours.to, now);
const fromDate = timeStringToMs(workingHours.from, now);
return fromDate <= now && toDate >= now;
}
module.exports = { isOneOf, nullOrEmpty, duringWorkingHoursOrNotSet };

View File

@@ -1,6 +1,6 @@
{
"name": "fredy",
"version": "5.0.0",
"version": "5.2.0",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": {
"start": "node index.js",
@@ -41,7 +41,7 @@
},
"license": "MIT",
"engines": {
"node": ">=12.0.0",
"node": ">=12.13.0",
"npm": ">=6.0.0"
},
"browserslist": [
@@ -53,17 +53,17 @@
"dependencies": {
"@rematch/core": "2.0.1",
"@rematch/loading": "2.0.1",
"@sendgrid/mail": "7.4.4",
"@sendgrid/mail": "7.4.5",
"axios": "0.21.1",
"body-parser": "1.19.0",
"cookie-session": "1.4.0",
"handlebars": "4.7.7",
"highcharts": "9.1.0",
"highcharts": "9.1.2",
"highcharts-react-official": "3.0.0",
"lowdb": "1.0.0",
"markdown": "^0.5.0",
"nanoid": "3.1.23",
"node-mailjet": "3.3.1",
"node-mailjet": "3.3.4",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-redux": "7.2.4",
@@ -76,36 +76,37 @@
"semantic-ui-react": "2.0.3",
"serve-static": "^1.14.1",
"slack": "11.0.2",
"string-similarity": "^4.0.4",
"x-ray": "2.3.4"
},
"devDependencies": {
"@babel/core": "7.14.3",
"@babel/preset-env": "7.14.2",
"@babel/preset-react": "7.13.13",
"@babel/core": "7.14.6",
"@babel/preset-env": "7.14.7",
"@babel/preset-react": "7.14.5",
"babel-eslint": "10.1.0",
"babel-loader": "8.2.2",
"chai": "4.3.4",
"clean-webpack-plugin": "3.0.0",
"copy-webpack-plugin": "8.1.1",
"css-loader": "5.2.4",
"eslint": "7.26.0",
"copy-webpack-plugin": "9.0.1",
"css-loader": "5.2.6",
"eslint": "7.29.0",
"eslint-config-prettier": "8.3.0",
"eslint-plugin-react": "7.23.2",
"eslint-plugin-react": "7.24.0",
"file-loader": "6.2.0",
"history": "5.0.0",
"husky": "4.3.8",
"less": "4.1.1",
"less-loader": "9.0.0",
"less-loader": "10.0.0",
"lint-staged": "11.0.0",
"mocha": "8.4.0",
"prettier": "2.3.0",
"mocha": "9.0.1",
"prettier": "2.3.2",
"proxyquire": "2.1.3",
"redux-logger": "3.0.6",
"style-loader": "2.0.0",
"style-loader": "3.0.0",
"url-loader": "4.1.1",
"webpack": "5.37.1",
"webpack": "5.40.0",
"webpack-cli": "3.3.12",
"webpack-dev-server": "3.11.2",
"webpack-merge": "5.7.3"
"webpack-merge": "5.8.0"
}
}

View File

@@ -1,3 +1,4 @@
const similarityCache = require('../../lib/services/similarity-check/similarityCache');
const mockNotification = require('../mocks/mockNotification');
const providerConfig = require('./testProvider.json');
const mockStore = require('../mocks/mockStore');
@@ -6,6 +7,10 @@ const expect = require('chai').expect;
const provider = require('../../lib/provider/einsAImmobilien');
describe('#einsAImmobilien testsuite()', () => {
after(() => {
similarityCache.stopCacheCleanup();
});
provider.init(providerConfig.einsAImmobilien, [], []);
const Fredy = proxyquire('../../lib/FredyRuntime', {
@@ -17,7 +22,7 @@ describe('#einsAImmobilien testsuite()', () => {
it('should test einsAImmobilien provider', async () => {
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'test1');
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'test1', similarityCache);
fredy.execute().then((listings) => {
expect(listings).to.be.a('array');

View File

@@ -1,3 +1,4 @@
const similarityCache = require('../../lib/services/similarity-check/similarityCache');
const mockNotification = require('../mocks/mockNotification');
const providerConfig = require('./testProvider.json');
const mockStore = require('../mocks/mockStore');
@@ -6,6 +7,10 @@ const expect = require('chai').expect;
const provider = require('../../lib/provider/immonet');
describe('#immonet testsuite()', () => {
after(() => {
similarityCache.stopCacheCleanup();
});
provider.init(providerConfig.immonet, [], []);
const Fredy = proxyquire('../../lib/FredyRuntime', {
'./services/storage/listingsStorage': {
@@ -16,7 +21,7 @@ describe('#immonet testsuite()', () => {
it('should test immonet provider', async () => {
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'test1');
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'test1', similarityCache);
fredy.execute().then((listing) => {
expect(listing).to.be.a('array');

View File

@@ -1,3 +1,4 @@
const similarityCache = require('../../lib/services/similarity-check/similarityCache');
const mockNotification = require('../mocks/mockNotification');
const providerConfig = require('./testProvider.json');
const mockStore = require('../mocks/mockStore');
@@ -7,6 +8,9 @@ const provider = require('../../lib/provider/immoscout');
const scrapingAnt = require('../../lib/services/scrapingAnt');
describe('#immoscout testsuite()', () => {
after(() => {
similarityCache.stopCacheCleanup();
});
provider.init(providerConfig.immoscout, [], []);
const Fredy = proxyquire('../../lib/FredyRuntime', {
'./services/storage/listingsStorage': {
@@ -25,7 +29,7 @@ describe('#immoscout testsuite()', () => {
return;
}
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'test1');
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'test1', similarityCache);
fredy.execute().then((listing) => {
expect(listing).to.be.a('array');

View File

@@ -1,3 +1,4 @@
const similarityCache = require('../../lib/services/similarity-check/similarityCache');
const mockNotification = require('../mocks/mockNotification');
const providerConfig = require('./testProvider.json');
const mockStore = require('../mocks/mockStore');
@@ -6,6 +7,9 @@ const expect = require('chai').expect;
const provider = require('../../lib/provider/immowelt');
describe('#immowelt testsuite()', () => {
after(() => {
similarityCache.stopCacheCleanup();
});
it('should test immowelt provider', async () => {
provider.init(providerConfig.immowelt, [], []);
const Fredy = proxyquire('../../lib/FredyRuntime', {
@@ -16,7 +20,7 @@ describe('#immowelt testsuite()', () => {
});
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'test1');
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'test1', similarityCache);
fredy.execute().then((listing) => {
expect(listing).to.be.a('array');

View File

@@ -1,3 +1,4 @@
const similarityCache = require('../../lib/services/similarity-check/similarityCache');
const mockNotification = require('../mocks/mockNotification');
const providerConfig = require('./testProvider.json');
const mockStore = require('../mocks/mockStore');
@@ -6,6 +7,9 @@ const expect = require('chai').expect;
const provider = require('../../lib/provider/kleinanzeigen');
describe('#kleinanzeigen testsuite()', () => {
after(() => {
similarityCache.stopCacheCleanup();
});
it('should test kleinanzeigen provider', async () => {
provider.init(providerConfig.kleinanzeigen, [], []);
const Fredy = proxyquire('../../lib/FredyRuntime', {
@@ -16,7 +20,7 @@ describe('#kleinanzeigen testsuite()', () => {
});
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'test1');
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'test1', similarityCache);
fredy.execute().then((listing) => {
expect(listing).to.be.a('array');

View File

@@ -1,3 +1,4 @@
const similarityCache = require('../../lib/services/similarity-check/similarityCache');
const mockNotification = require('../mocks/mockNotification');
const providerConfig = require('./testProvider.json');
const mockStore = require('../mocks/mockStore');
@@ -6,6 +7,9 @@ const expect = require('chai').expect;
const provider = require('../../lib/provider/neubauKompass');
describe('#neubauKompass testsuite()', () => {
after(() => {
similarityCache.stopCacheCleanup();
});
provider.init(providerConfig.neubauKompass, [], []);
const Fredy = proxyquire('../../lib/FredyRuntime', {
'./services/storage/listingsStorage': {
@@ -16,7 +20,7 @@ describe('#neubauKompass testsuite()', () => {
it('should test neubauKompass provider', async () => {
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'test1');
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'test1', similarityCache);
fredy.execute().then((listing) => {
expect(listing).to.be.a('array');

View File

@@ -21,7 +21,7 @@
"enabled": true
},
"kleinanzeigen": {
"url": "https://www.ebay-kleinanzeigen.de/s-wohnung-kaufen/duesseldorf/anzeige:angebote/preis::420000/wohnung/k0c196l2068r5+wohnung_kaufen.qm_d:90,+wohnung_kaufen.zimmer_d:3.5,",
"url": "https://www.ebay-kleinanzeigen.de/s-immobilien/duesseldorf/anzeige:angebote/wohnung/k0c195l2068r5",
"enabled": true
},
"neubauKompass": {

View File

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

View File

@@ -1,3 +1,4 @@
const similarityCache = require('../../lib/services/similarity-check/similarityCache');
const mockNotification = require('../mocks/mockNotification');
const providerConfig = require('./testProvider.json');
const mockStore = require('../mocks/mockStore');
@@ -6,6 +7,9 @@ const expect = require('chai').expect;
const provider = require('../../lib/provider/wgGesucht');
describe('#wgGesucht testsuite()', () => {
after(() => {
similarityCache.stopCacheCleanup();
});
provider.init(providerConfig.wgGesucht, [], []);
const Fredy = proxyquire('../../lib/FredyRuntime', {
'./services/storage/listingsStorage': {
@@ -16,7 +20,7 @@ describe('#wgGesucht testsuite()', () => {
it('should test wgGesucht provider', async () => {
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'test1');
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'test1', similarityCache);
fredy.execute().then((listing) => {
expect(listing).to.be.a('array');
const notificationObj = mockNotification.get();

View File

@@ -0,0 +1,39 @@
const SimilarityCacheEntry = require('../../lib/services/similarity-check/SimilarityCacheEntry');
const expect = require('chai').expect;
describe('similarityCheck', () => {
describe('#similarityCheck()', () => {
it('should be false', () => {
const check = new SimilarityCacheEntry(0);
check.setCacheEntry('Hallo');
expect(check.hasSimilarEntries('Welt')).to.be.false;
});
it('should be true', () => {
const check = new SimilarityCacheEntry(0);
check.setCacheEntry('Hallo');
expect(check.hasSimilarEntries('hallo')).to.be.true;
});
it('should be true', () => {
const check = new SimilarityCacheEntry(0);
check.setCacheEntry('Selling an incredible house in san francisco');
expect(check.hasSimilarEntries('incredible house in san francisco for sale')).to.be.true;
});
it('should be true', () => {
const check = new SimilarityCacheEntry(0);
check.setCacheEntry('a');
check.setCacheEntry('b');
check.setCacheEntry('c');
check.setCacheEntry('d');
expect(check.hasSimilarEntries('b')).to.be.true;
});
it('should be false', () => {
const check = new SimilarityCacheEntry(0);
check.setCacheEntry(
'The index is known by several other names, especially SørensenDice index,[3] Sørensen index and Dice\'s coefficient. Other variations include the "similarity coefficient" or "index", such as Dice similarity coefficient (DSC). Common alternate spellings for Sørensen are Sorenson, Soerenson and Sörenson, and all three can also be seen with the sen ending.'
);
check.setCacheEntry(
'where |X| and |Y| are the cardinalities of the two sets (i.e. the number of elements in each set). The Sørensen index equals twice the number of elements common to both sets divided by the sum of the number of elements in each set.'
);
});
});
});

View File

@@ -2,6 +2,7 @@ import React, { useEffect } from 'react';
import InsufficientPermission from './components/permission/InsufficientPermission';
import PermissionAwareRoute from './components/permission/PermissionAwareRoute';
import GeneralSettings from './views/generalSettings/GeneralSettings';
import ToastsContainer from './components/toasts/ToastContainer';
import JobMutation from './views/jobs/mutation/JobMutation';
import UserMutator from './views/user/mutation/UserMutator';
@@ -78,6 +79,12 @@ export default function FredyApp() {
currentUser={currentUser}
/>
<PermissionAwareRoute name="Users" path="/users" component={<Users />} currentUser={currentUser} />
<PermissionAwareRoute
name="General Settings"
path="/generalSettings"
component={<GeneralSettings />}
currentUser={currentUser}
/>
<Redirect from="/" to={'/jobs'} />
</Switch>

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { useHistory } from 'react-router-dom';
import { Menu } from 'semantic-ui-react';
import { Icon, Menu } from 'semantic-ui-react';
import './Menu.less';
import { useLocation } from 'react-router';
@@ -19,7 +19,7 @@ const TopMenu = function TopMenu({ isAdmin }) {
className={isActiveRoute('jobs') ? 'topMenu__active' : 'topMenu__item'}
onClick={() => history.push('/jobs')}
>
Job Configuration
<Icon name="search" /> Job Configuration
</Menu.Item>
{isAdmin && (
@@ -29,7 +29,18 @@ const TopMenu = function TopMenu({ isAdmin }) {
className={isActiveRoute('users') ? 'topMenu__active' : 'topMenu__item'}
onClick={() => history.push('/users')}
>
User configuration
<Icon name="user" /> User configuration
</Menu.Item>
)}
{isAdmin && (
<Menu.Item
name="general"
active={isActiveRoute('general')}
className={isActiveRoute('general') ? 'topMenu__active' : 'topMenu__item'}
onClick={() => history.push('/generalSettings')}
>
<Icon name="cog" /> General Settings
</Menu.Item>
)}
</Menu>

View File

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

View File

@@ -1,4 +1,5 @@
import { notificationAdapter } from './models/notificationAdapter';
import { generalSettings } from './models/generalSettings';
import createLoadingPlugin from '@rematch/loading';
import { provider } from './models/provider';
import { createLogger } from 'redux-logger';
@@ -17,6 +18,7 @@ const store = init({
name: 'fredy',
models: {
notificationAdapter,
generalSettings,
provider,
jobs,
user,

View File

@@ -0,0 +1,211 @@
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Button, Form, Header, Icon, Message, Popup, Segment } from 'semantic-ui-react';
import ToastContext from '../../components/toasts/ToastContext';
import Headline from '../../components/headline/Headline';
import { xhrPost } from '../../services/xhr';
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 dispatch = useDispatch();
const [loading, setLoading] = React.useState(true);
const settings = useSelector((state) => state.generalSettings.settings);
const [interval, setInterval] = React.useState('');
const [port, setPort] = React.useState('');
const [scrapingAntApiKey, setScrapingAntApiKey] = React.useState('');
const [workingHourFrom, setWorkingHourFrom] = React.useState(null);
const [workingHourTo, setWorkingHourTo] = React.useState(null);
const ctx = React.useContext(ToastContext);
React.useEffect(async () => {
await dispatch.generalSettings.getGeneralSettings();
setLoading(false);
}, []);
React.useEffect(async () => {
setInterval(settings?.interval);
setPort(settings?.port);
setScrapingAntApiKey(settings?.scrapingAnt?.apiKey);
setWorkingHourFrom(settings?.workingHours?.from);
setWorkingHourTo(settings?.workingHours?.to);
}, [settings]);
const nullOrEmpty = (val) => val == null || val.length === 0;
const throwMessage = (message, type) => {
ctx.showToast({
title: type === 'error' ? 'Error' : 'Success',
message: message,
delay: 5000,
backgroundColor: type === 'error' ? '#db2828' : '#87eb8f',
color: type === 'error' ? '#fff' : '#000',
});
};
const onStore = async () => {
if (nullOrEmpty(interval)) {
throwMessage('Interval may not be empty.', 'error');
return;
}
if (nullOrEmpty(port)) {
throwMessage('Port may not be empty.', 'error');
return;
}
if (
(!nullOrEmpty(workingHourFrom) && nullOrEmpty(workingHourTo)) ||
(nullOrEmpty(workingHourFrom) && !nullOrEmpty(workingHourTo))
) {
throwMessage('Working hours to and from must be set if either to or from has been set before.', 'error');
return;
}
try {
await xhrPost('/api/admin/generalSettings', {
interval,
port,
scrapingAnt: {
apiKey: scrapingAntApiKey,
},
workingHours: {
from: workingHourFrom,
to: workingHourTo,
},
});
} catch (exception) {
console.error(exception);
throwMessage('Error while trying to store settings.', 'error');
return;
}
throwMessage('Settings stored successfully. You MUST restart Fredy.', 'success');
};
return (
<div>
{!loading && (
<React.Fragment>
<Headline text="General Settings" />
<Message info>
<h5>
<Icon name="info circle" />
Info
</h5>
<p>If you change any settings, you must restart Fredy afterwards.</p>
</Message>
<Form>
<SegmentPart
name="Interval"
helpText="Interval in minutes for running queries against the configured services."
icon="refresh"
>
<Form.Input
type="number"
min="0"
max="1440"
placeholder="Interval in minutes"
inverted
size="mini"
width={6}
defaultValue={interval}
onChange={(e) => setInterval(e.target.value)}
/>
</SegmentPart>
<SegmentPart name="Port" helpText="Port on which Fredy is running." icon="connectdevelop">
<Form.Input
type="number"
min="0"
max="99999"
placeholder="Port"
inverted
size="mini"
width={6}
defaultValue={port}
onChange={(e) => setPort(e.target.value)}
/>
</SegmentPart>
<SegmentPart
name="ScrapingAnt Api Key"
helpText="The api key for ScrapingAnt is used to be able to scrape Immoscout."
icon="key"
>
<Form.Input
type="text"
placeholder="ScrapingAnt Api Key"
inverted
size="mini"
width={6}
defaultValue={scrapingAntApiKey}
onChange={(e) => setScrapingAntApiKey(e.target.value)}
/>
</SegmentPart>
<SegmentPart
name="Working hours"
helpText="During this hours, Fredy will search for new apartments. If nothing is configured, Fredy will search around the clock."
icon="calendar outline"
>
<div className="generalSettings__timePickerContainer">
<Form.Input
className="generalSettings__time"
type="time"
placeholder="ScrapingAnt Api Key"
inverted
size="mini"
width={2}
defaultValue={workingHourFrom}
onChange={(e) => setWorkingHourFrom(e.target.value)}
/>
<div className="generalSettings__until">until</div>
<Form.Input
type="time"
placeholder="ScrapingAnt Api Key"
inverted
size="mini"
width={2}
defaultValue={workingHourTo}
onChange={(e) => setWorkingHourTo(e.target.value)}
/>
</div>
</SegmentPart>
<Segment inverted floated="right">
<Button color="teal" onClick={onStore}>
Save
</Button>
</Segment>
</Form>
</React.Fragment>
)}
</div>
);
};
export default GeneralSettings;

View File

@@ -0,0 +1,17 @@
.generalSettings {
&__timePickerContainer {
display: flex;
align-items: baseline;
}
&__until {
margin-left: 1rem;
margin-right: 1rem;
}
&__help{
font-size: 11px;
margin-left: 1rem;
}
}

View File

@@ -40,9 +40,8 @@ export default function Login() {
return (
<div className="login">
<div className="login__bgImage" style={{ background: `url("${cityBackground}")` }} />
<Logo />
<div className="login__bgImage" style={{ background: `url(${cityBackground})` }} />
<form>
<div className="login__loginWrapper">
{error && <Message negative icon="error" content={error} />}

View File

@@ -2,18 +2,17 @@
display: flex;
justify-content: center;
align-items: center;
width:100%;
width: 100%;
height: 100%;
&__bgImage {
background-size: cover;
filter: blur(8px);
-webkit-filter: blur(8px);
background-size: cover;
position: absolute;
top: 0;
left: 0;
z-index: -1;
z-index: 0;
right: 0;
bottom: 0;
}
@@ -23,9 +22,14 @@
border-radius: 30px;
height: 25rem;
width: 30rem;
z-index: 1;
background-color: #151313ab;
display: flex;
flex-direction: column;
padding: 2rem;
}
form {
z-index: 1;
}
}

2165
yarn.lock

File diff suppressed because it is too large Load Diff