mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ecbbdd774 | ||
|
|
e1db3840f6 | ||
|
|
26127eeac1 | ||
|
|
90a4ee5dcf | ||
|
|
2aaf63c253 | ||
|
|
f52e3e9fd8 | ||
|
|
0d69232395 | ||
|
|
b473cf7fb4 | ||
|
|
3b8279c714 | ||
|
|
214e714c03 | ||
|
|
58965a6f1b | ||
|
|
3c0e9e56c6 | ||
|
|
f5d56a6bda | ||
|
|
324b14da50 | ||
|
|
f8f911aa00 | ||
|
|
13b8701447 | ||
|
|
e25b956eda | ||
|
|
a2c769f786 | ||
|
|
1825a25eaa | ||
|
|
0f20b85f38 | ||
|
|
d17ef9ef1e | ||
|
|
337ee922a6 | ||
|
|
b3ae5f640c | ||
|
|
8f91267b5d | ||
|
|
3d59c0096d | ||
|
|
dab6e4edf3 | ||
|
|
e1c45f18e0 | ||
|
|
5cceae11cc | ||
|
|
a4c5bfcbf7 | ||
|
|
6d2ab5f958 | ||
|
|
d3cb3a5881 | ||
|
|
111ef8be43 | ||
|
|
35feb772d7 | ||
|
|
1bf012f13e | ||
|
|
933dc3fc64 | ||
|
|
42c48fdceb | ||
|
|
f07aa0a06d | ||
|
|
92db8219b4 | ||
|
|
8ba3a53779 | ||
|
|
e7db4e23f5 | ||
|
|
06c4ebb975 | ||
|
|
b075e09ac2 | ||
|
|
f215ab53db | ||
|
|
4ed92b246f | ||
|
|
4a9b60633a | ||
|
|
2123c1024b | ||
|
|
35767e6774 | ||
|
|
bf77ba2667 | ||
|
|
827c7e7321 | ||
|
|
7b63dc72cb | ||
|
|
fd42b57010 | ||
|
|
f5917af8f3 | ||
|
|
a85400d570 | ||
|
|
8ce6668c78 | ||
|
|
2d8121a708 | ||
|
|
172c039c79 | ||
|
|
4ab1fd9294 | ||
|
|
50b3fde075 | ||
|
|
1a3fc6f94d | ||
|
|
26ed42230a | ||
|
|
6f4defdc1b | ||
|
|
f798aed342 | ||
|
|
27e098c244 | ||
|
|
37948be0d3 | ||
|
|
cc7bbb77c4 | ||
|
|
96da0b7892 | ||
|
|
72993312c7 | ||
|
|
17b4bad2e4 | ||
|
|
fbad4456d7 | ||
|
|
deec626feb | ||
|
|
88c6641485 | ||
|
|
f4eedda658 | ||
|
|
d2b80561f8 | ||
|
|
3bda88a075 | ||
|
|
86465e0076 | ||
|
|
d947dad488 |
1
.github/workflows/docker.yml
vendored
1
.github/workflows/docker.yml
vendored
@@ -44,3 +44,4 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
platforms: linux/amd64, linux/arm64
|
||||||
|
|||||||
21
.github/workflows/stales.yml
vendored
Normal file
21
.github/workflows/stales.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
name: "Close stale issues and PRs"
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 0 * * *' # Daily
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
stale:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/stale@v7
|
||||||
|
with:
|
||||||
|
days-before-stale: 30
|
||||||
|
days-before-close: 7
|
||||||
|
stale-issue-message: "This issue has been automatically marked as stale due to inactivity."
|
||||||
|
stale-pr-message: "This PR has been automatically marked as stale due to inactivity."
|
||||||
|
close-issue-message: "Closing this issue due to prolonged inactivity."
|
||||||
|
close-pr-message: "Closing this PR due to prolonged inactivity."
|
||||||
|
exempt-issue-labels: "keep-open"
|
||||||
|
exempt-pr-labels: "keep-open"
|
||||||
|
only: "pulls"
|
||||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -6,6 +6,8 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
|
schedule:
|
||||||
|
- cron: '0 12 * * *'
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Test
|
name: Test
|
||||||
@@ -15,7 +17,7 @@ jobs:
|
|||||||
- name: Setup node
|
- name: Setup node
|
||||||
uses: actions/setup-node@v2.5.1
|
uses: actions/setup-node@v2.5.1
|
||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 20
|
||||||
cache: 'yarn'
|
cache: 'yarn'
|
||||||
- run: yarn install
|
- run: yarn install
|
||||||
- run: yarn run test
|
- run: yarn run test
|
||||||
|
|||||||
1
.husky/pre-commit
Executable file
1
.husky/pre-commit
Executable file
@@ -0,0 +1 @@
|
|||||||
|
npx lint-staged
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
sudo: false
|
|
||||||
language: node_js
|
|
||||||
@@ -106,9 +106,7 @@ exports.config = {
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Running Tests
|
#### Running Tests
|
||||||
If you've written a new provider you are an awesome person. You know it and I do. If you now write tests for it, you are even more awesome. And who doesn't want to be more awesome right?
|
If you've written a new provider you are an awesome person. If you now write tests for it, you are even more awesome. And who doesn't want to be more awesome right?
|
||||||
|
|
||||||
To write tests for provider, you need to use Node 8 as the tests are using `async / await`
|
|
||||||
|
|
||||||
#### Codestyle
|
#### Codestyle
|
||||||
I'm using Eslint to maintain quote style and quality. Do not skip it...
|
I'm using Eslint to maintain quote style and quality. Do not skip it...
|
||||||
|
|||||||
27
Dockerfile
27
Dockerfile
@@ -1,18 +1,25 @@
|
|||||||
# syntax=docker/dockerfile:1.3
|
FROM node:20
|
||||||
FROM node:16-alpine AS builder
|
|
||||||
COPY --chown=1000:1000 . /fredy
|
|
||||||
WORKDIR /fredy
|
WORKDIR /fredy
|
||||||
USER 1000
|
|
||||||
|
COPY . /fredy
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y chromium
|
||||||
|
|
||||||
|
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
|
||||||
|
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||||
|
|
||||||
RUN yarn install
|
RUN yarn install
|
||||||
|
|
||||||
|
RUN yarn global add pm2
|
||||||
|
|
||||||
RUN yarn run prod
|
RUN yarn run prod
|
||||||
|
|
||||||
FROM node:16-alpine
|
|
||||||
COPY --from=builder --chown=1000:1000 /fredy /fredy
|
|
||||||
RUN mkdir /db /conf && \
|
RUN mkdir /db /conf && \
|
||||||
chown 1000:1000 /db /conf && \
|
chown 1000:1000 /db /conf && \
|
||||||
|
chmod 777 -R /db/ && \
|
||||||
ln -s /db /fredy/db && ln -s /conf /fredy/conf
|
ln -s /db /fredy/db && ln -s /conf /fredy/conf
|
||||||
|
|
||||||
EXPOSE 9998
|
EXPOSE 9998
|
||||||
USER 1000
|
|
||||||
VOLUME [ "/conf", "/db" ]
|
CMD pm2-runtime index.js
|
||||||
WORKDIR /fredy
|
|
||||||
CMD node index.js --no-daemon
|
|
||||||
|
|||||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2021 Christian Kellner
|
Copyright (c) 2025 Christian Kellner
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
35
README.md
35
README.md
@@ -15,9 +15,12 @@ If you like my work, consider becoming a sponsor. I'm not expecting anybody to p
|
|||||||
|
|
||||||
_Fredy_ is supported by JetBrains under Open Source Support Program
|
_Fredy_ is supported by JetBrains under Open Source Support Program
|
||||||
|
|
||||||
|
## Demo
|
||||||
|
If you want to try out _Fredy_, you can access the demo version [here](https://fredy.orange-coding.net) 🤘
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
- Make sure to use Node.js 16 or above
|
- Make sure to use Node.js 20 or above
|
||||||
- Run the following commands:
|
- Run the following commands:
|
||||||
```ssh
|
```ssh
|
||||||
yarn (or npm install)
|
yarn (or npm install)
|
||||||
@@ -33,9 +36,6 @@ _Fredy_ will start with the default port, set to `9998`. You can access _Fredy_
|
|||||||
|
|
||||||
<img alt="Job Overview" width="30%" src="https://github.com/orangecoding/fredy/blob/master/doc/screenshot_3.png">
|
<img alt="Job Overview" width="30%" src="https://github.com/orangecoding/fredy/blob/master/doc/screenshot_3.png">
|
||||||
</p>
|
</p>
|
||||||
<p align="center">
|
|
||||||
|
|
||||||
</p>
|
|
||||||
|
|
||||||
## 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_.
|
||||||
@@ -81,17 +81,15 @@ yarn run test
|
|||||||
# Architecture
|
# Architecture
|
||||||

|

|
||||||
|
|
||||||
### Immoscout / Immonet
|
### Immoscout
|
||||||
I have added **experimental** support for Immoscout and Immonet. They both are somewhat special, because they have decided to secure their service from bots using Re-Capture. Finding a way around this is barely possible. For _Fredy_ to be able to bypass this check, I'm using a service called [ScrapingAnt](https://scrapingant.com/). The trick is to use a headless browser, rotating proxies and (once successfully validated) to re-send the cookies each time.
|
Immoscout has implemented advanced bot detection. I’m actively working on bypassing these measures, but until then, selecting Immoscout as a provider will not return any results. I apologize for the inconvenience. 😉
|
||||||
|
|
||||||
To be able to use Immoscout / Immonet, you need to create an account at ScrapingAnt. Configure the API key in the "General Settings" tab (visible when logged in as administrator).
|
# Analytics
|
||||||
The rest will be handled by _Fredy_. Keep in mind, the support is experimental. There might be bugs and you might not always pass the re-capture check, but most of the time it works rather well :)
|
Fredy is completely free (and will always remain free). However, it would be a huge help if you’d allow me to collect some analytical data.
|
||||||
|
Before you freak out, let me explain...
|
||||||
If you need more than the 1000 API calls allowed per month, I'd suggest opting for a paid account... ScrapingAnt loves OpenSource, therefore they have decided to give all _Fredy_ users a 10% discount by using the code **FREDY10** (Disclaimer: I do not earn any money for recommending their service).
|
If you agree, Fredy will send a ping to my Mixpanel project each time it runs.
|
||||||
|
The data includes: names of active adapters/providers, OS, architecture, Node version, and language. The information is entirely anonymous and helps me understand which adapters/providers are most frequently used.</p>
|
||||||
### Contribution guidelines
|
**Thanks**🤘
|
||||||
|
|
||||||
See [Contributing](https://github.com/orangecoding/fredy/blob/master/CONTRIBUTING.md)
|
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
Use the Dockerfile in this repository to build an image.
|
Use the Dockerfile in this repository to build an image.
|
||||||
@@ -112,6 +110,15 @@ Put your config.json into a path of your choice, such as `/path/to/your/conf/`.
|
|||||||
|
|
||||||
Example: `docker create --name fredy -v /path/to/your/conf/:/conf -p 9998:9998 fredy/fredy`
|
Example: `docker create --name fredy -v /path/to/your/conf/:/conf -p 9998:9998 fredy/fredy`
|
||||||
|
|
||||||
|
### 👐 Contributing
|
||||||
|
Thanks to all the people who already contributed!
|
||||||
|
|
||||||
|
<a href="https://github.com/orangecoding/fredy/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=orangecoding/fredy" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
See [Contributing](https://github.com/orangecoding/fredy/blob/master/CONTRIBUTING.md)
|
||||||
|
|
||||||
## Logs
|
## Logs
|
||||||
|
|
||||||
You can browse the logs with `docker logs fredy -f`.
|
You can browse the logs with `docker logs fredy -f`.
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"interval":"60","port":9998,"scrapingAnt":{"apiKey":"","proxy":"datacenter"},"workingHours":{"from":"","to":""}}
|
{"interval":"60","port":9998,"workingHours":{"from":"","to":""},"demoMode":false,"analyticsEnabled":null}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
version: '3.3'
|
version: '3.8'
|
||||||
services:
|
services:
|
||||||
fredy:
|
fredy:
|
||||||
container_name: fredy
|
container_name: fredy
|
||||||
@@ -13,3 +13,4 @@ services:
|
|||||||
- ./db:/db
|
- ./db:/db
|
||||||
ports:
|
ports:
|
||||||
- 9998:9998
|
- 9998:9998
|
||||||
|
restart: unless-stopped
|
||||||
|
|||||||
54
index.js
54
index.js
@@ -1,11 +1,14 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { config } from './lib/utils.js';
|
import {config} from './lib/utils.js';
|
||||||
import * as similarityCache from './lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from './lib/services/similarity-check/similarityCache.js';
|
||||||
import { setLastJobExecution } from './lib/services/storage/listingsStorage.js';
|
import { setLastJobExecution } from './lib/services/storage/listingsStorage.js';
|
||||||
import * as jobStorage from './lib/services/storage/jobStorage.js';
|
import * as jobStorage from './lib/services/storage/jobStorage.js';
|
||||||
import FredyRuntime from './lib/FredyRuntime.js';
|
import FredyRuntime from './lib/FredyRuntime.js';
|
||||||
import { duringWorkingHoursOrNotSet } from './lib/utils.js';
|
import { duringWorkingHoursOrNotSet } from './lib/utils.js';
|
||||||
import './lib/api/api.js';
|
import './lib/api/api.js';
|
||||||
|
import {track} from './lib/services/tracking/Tracker.js';
|
||||||
|
import {handleDemoUser} from './lib/services/storage/userStorage.js';
|
||||||
|
import {cleanupDemoAtMidnight} from './lib/services/demoCleanup.js';
|
||||||
//if db folder does not exist, ensure to create it before loading anything else
|
//if db folder does not exist, ensure to create it before loading anything else
|
||||||
if (!fs.existsSync('./db')) {
|
if (!fs.existsSync('./db')) {
|
||||||
fs.mkdirSync('./db');
|
fs.mkdirSync('./db');
|
||||||
@@ -16,34 +19,43 @@ const provider = fs.readdirSync(path).filter((file) => file.endsWith('.js'));
|
|||||||
const INTERVAL = config.interval * 60 * 1000;
|
const INTERVAL = config.interval * 60 * 1000;
|
||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
console.log(`Started Fredy successfully. Ui can be accessed via http://localhost:${config.port}`);
|
console.log(`Started Fredy successfully. Ui can be accessed via http://localhost:${config.port}`);
|
||||||
|
if(config.demoMode){
|
||||||
|
console.info('Running in demo mode');
|
||||||
|
cleanupDemoAtMidnight();
|
||||||
|
}
|
||||||
/* eslint-enable no-console */
|
/* eslint-enable no-console */
|
||||||
const fetchedProvider = await Promise.all(
|
const fetchedProvider = await Promise.all(
|
||||||
provider.filter((provider) => provider.endsWith('.js')).map(async (pro) => import(`${path}/${pro}`))
|
provider.filter((provider) => provider.endsWith('.js')).map(async (pro) => import(`${path}/${pro}`))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
handleDemoUser();
|
||||||
|
|
||||||
setInterval(
|
setInterval(
|
||||||
(function exec() {
|
(function exec() {
|
||||||
const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(config, Date.now());
|
const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(config, Date.now());
|
||||||
if (isDuringWorkingHoursOrNotSet) {
|
if(!config.demoMode) {
|
||||||
config.lastRun = Date.now();
|
if (isDuringWorkingHoursOrNotSet) {
|
||||||
jobStorage
|
track();
|
||||||
.getJobs()
|
config.lastRun = Date.now();
|
||||||
.filter((job) => job.enabled)
|
jobStorage
|
||||||
.forEach((job) => {
|
.getJobs()
|
||||||
job.provider
|
.filter((job) => job.enabled)
|
||||||
.filter((p) => fetchedProvider.find((fp) => fp.metaInformation.id === p.id) != null)
|
.forEach((job) => {
|
||||||
.forEach(async (prov) => {
|
job.provider
|
||||||
const pro = fetchedProvider.find((fp) => fp.metaInformation.id === prov.id);
|
.filter((p) => fetchedProvider.find((fp) => fp.metaInformation.id === p.id) != null)
|
||||||
pro.init(prov, job.blacklist);
|
.forEach(async (prov) => {
|
||||||
await new FredyRuntime(pro.config, job.notificationAdapter, prov.id, job.id, similarityCache).execute();
|
const pro = fetchedProvider.find((fp) => fp.metaInformation.id === prov.id);
|
||||||
setLastJobExecution(job.id);
|
pro.init(prov, job.blacklist);
|
||||||
});
|
await new FredyRuntime(pro.config, job.notificationAdapter, prov.id, job.id, similarityCache).execute();
|
||||||
});
|
setLastJobExecution(job.id);
|
||||||
} else {
|
});
|
||||||
/* eslint-disable no-console */
|
});
|
||||||
console.debug('Working hours set. Skipping as outside of working hours.');
|
} else {
|
||||||
/* eslint-enable no-console */
|
/* eslint-disable no-console */
|
||||||
}
|
console.debug('Working hours set. Skipping as outside of working hours.');
|
||||||
|
/* eslint-enable no-console */
|
||||||
|
}
|
||||||
|
}
|
||||||
return exec;
|
return exec;
|
||||||
})(),
|
})(),
|
||||||
INTERVAL
|
INTERVAL
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { NoNewListingsWarning } from './errors.js';
|
import { NoNewListingsWarning } from './errors.js';
|
||||||
import { setKnownListings, getKnownListings } from './services/storage/listingsStorage.js';
|
import { setKnownListings, getKnownListings } from './services/storage/listingsStorage.js';
|
||||||
import * as notify from './notification/notify.js';
|
import * as notify from './notification/notify.js';
|
||||||
import xray from './services/scraper.js';
|
import Extractor from './services/extractor/extractor.js';
|
||||||
import * as scrapingAnt from './services/scrapingAnt.js';
|
|
||||||
import urlModifier from './services/queryStringMutator.js';
|
import urlModifier from './services/queryStringMutator.js';
|
||||||
|
|
||||||
class FredyRuntime {
|
class FredyRuntime {
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@@ -20,6 +20,7 @@ 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
|
||||||
@@ -42,53 +43,40 @@ class FredyRuntime {
|
|||||||
.catch(this._handleError.bind(this))
|
.catch(this._handleError.bind(this))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
_getListings(url) {
|
_getListings(url) {
|
||||||
|
const extractor = new Extractor();
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const id = this._providerId;
|
extractor
|
||||||
if (scrapingAnt.needScrapingAnt(id) && !scrapingAnt.isScrapingAntApiKeySet()) {
|
.execute(url, this._providerConfig.waitForSelector)
|
||||||
const error = 'Immoscout or Immonet can only be used with if you have set an apikey for scrapingAnt.';
|
.then(() => {
|
||||||
/* eslint-disable no-console */
|
const listings = extractor.parseResponseText(
|
||||||
console.log(error);
|
this._providerConfig.crawlContainer,
|
||||||
/* eslint-enable no-console */
|
this._providerConfig.crawlFields,
|
||||||
reject(error);
|
url,
|
||||||
return;
|
);
|
||||||
}
|
resolve(listings == null ? [] : listings);
|
||||||
const u = scrapingAnt.needScrapingAnt(id) ? scrapingAnt.transformUrlForScrapingAnt(url, id) : url;
|
})
|
||||||
try {
|
.catch((err) => {
|
||||||
if (this._providerConfig.paginate != null) {
|
reject(err);
|
||||||
xray(u, this._providerConfig.crawlContainer, [this._providerConfig.crawlFields])
|
/* eslint-disable no-console */
|
||||||
//the first 2 pages should be enough here
|
console.error(err);
|
||||||
.limit(2)
|
/* eslint-enable no-console */
|
||||||
.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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_normalize(listings) {
|
_normalize(listings) {
|
||||||
return listings.map(this._providerConfig.normalize);
|
return listings.map(this._providerConfig.normalize);
|
||||||
}
|
}
|
||||||
|
|
||||||
_filter(listings) {
|
_filter(listings) {
|
||||||
return listings.filter(this._providerConfig.filter);
|
//only return those where all the fields have been found
|
||||||
|
const keys = Object.keys(this._providerConfig.crawlFields);
|
||||||
|
const filteredListings = listings.filter((item) => keys.every((key) => key in item));
|
||||||
|
return filteredListings.filter(this._providerConfig.filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
_findNew(listings) {
|
_findNew(listings) {
|
||||||
const newListings = listings.filter((o) => getKnownListings(this._jobKey, this._providerId)[o.id] == null);
|
const newListings = listings.filter((o) => getKnownListings(this._jobKey, this._providerId)[o.id] == null);
|
||||||
if (newListings.length === 0) {
|
if (newListings.length === 0) {
|
||||||
@@ -96,6 +84,7 @@ class FredyRuntime {
|
|||||||
}
|
}
|
||||||
return newListings;
|
return newListings;
|
||||||
}
|
}
|
||||||
|
|
||||||
_notify(newListings) {
|
_notify(newListings) {
|
||||||
if (newListings.length === 0) {
|
if (newListings.length === 0) {
|
||||||
throw new NoNewListingsWarning();
|
throw new NoNewListingsWarning();
|
||||||
@@ -103,6 +92,7 @@ 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) => {
|
||||||
@@ -111,6 +101,7 @@ 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);
|
||||||
@@ -124,8 +115,10 @@ 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;
|
export default FredyRuntime;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import restana from 'restana';
|
|||||||
import files from 'serve-static';
|
import files from 'serve-static';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { getDirName } from '../utils.js';
|
import { getDirName } from '../utils.js';
|
||||||
|
import {demoRouter} from './routes/demoRouter.js';
|
||||||
const service = restana();
|
const service = restana();
|
||||||
const staticService = files(path.join(getDirName(), '../ui/public'));
|
const staticService = files(path.join(getDirName(), '../ui/public'));
|
||||||
const PORT = config.port || 9998;
|
const PORT = config.port || 9998;
|
||||||
@@ -30,6 +31,9 @@ service.use('/api/jobs/insights', analyticsRouter);
|
|||||||
service.use('/api/admin/users', userRouter);
|
service.use('/api/admin/users', userRouter);
|
||||||
service.use('/api/jobs', jobRouter);
|
service.use('/api/jobs', jobRouter);
|
||||||
service.use('/api/login', loginRouter);
|
service.use('/api/login', loginRouter);
|
||||||
|
//this route is unsecured intentionally as it is being queried from the login page
|
||||||
|
service.use('/api/demo', demoRouter);
|
||||||
|
|
||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
service.start(PORT).then(() => {
|
service.start(PORT).then(() => {
|
||||||
console.info(`Started API service on port ${PORT}`);
|
console.info(`Started API service on port ${PORT}`);
|
||||||
|
|||||||
11
lib/api/routes/demoRouter.js
Normal file
11
lib/api/routes/demoRouter.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import restana from 'restana';
|
||||||
|
import {config} from '../../utils.js';
|
||||||
|
const service = restana();
|
||||||
|
const demoRouter = service.newRouter();
|
||||||
|
|
||||||
|
demoRouter.get('/', async (req, res) => {
|
||||||
|
res.body = Object.assign({}, {demoMode: config.demoMode});
|
||||||
|
res.send();
|
||||||
|
});
|
||||||
|
|
||||||
|
export { demoRouter };
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import restana from 'restana';
|
import restana from 'restana';
|
||||||
import { config, getDirName } from '../../utils.js';
|
import {config, getDirName, readConfigFromStorage, refreshConfig} from '../../utils.js';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import {handleDemoUser} from '../../services/storage/userStorage.js';
|
||||||
const service = restana();
|
const service = restana();
|
||||||
const generalSettingsRouter = service.newRouter();
|
const generalSettingsRouter = service.newRouter();
|
||||||
generalSettingsRouter.get('/', async (req, res) => {
|
generalSettingsRouter.get('/', async (req, res) => {
|
||||||
@@ -10,7 +11,14 @@ generalSettingsRouter.get('/', async (req, res) => {
|
|||||||
generalSettingsRouter.post('/', async (req, res) => {
|
generalSettingsRouter.post('/', async (req, res) => {
|
||||||
const settings = req.body;
|
const settings = req.body;
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify(settings));
|
if(config.demoMode){
|
||||||
|
res.send(new Error('In demo mode, it is not allowed to change these settings.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentConfig = await readConfigFromStorage();
|
||||||
|
fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({...currentConfig, ...settings}));
|
||||||
|
await refreshConfig();
|
||||||
|
handleDemoUser();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
res.send(new Error('Error while trying to write settings.'));
|
res.send(new Error('Error while trying to write settings.'));
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import restana from 'restana';
|
import restana from 'restana';
|
||||||
import fetch from 'node-fetch';
|
|
||||||
import * as jobStorage from '../../services/storage/jobStorage.js';
|
import * as jobStorage from '../../services/storage/jobStorage.js';
|
||||||
import * as userStorage from '../../services/storage/userStorage.js';
|
import * as userStorage from '../../services/storage/userStorage.js';
|
||||||
import * as immoscoutProvider from '../../provider/immoscout.js';
|
|
||||||
import { config } from '../../utils.js';
|
import { config } from '../../utils.js';
|
||||||
import { isAdmin } from '../security.js';
|
import { isAdmin } from '../security.js';
|
||||||
|
import { trackDemoJobCreated } from '../../services/tracking/Tracker.js';
|
||||||
const service = restana();
|
const service = restana();
|
||||||
const jobRouter = service.newRouter();
|
const jobRouter = service.newRouter();
|
||||||
function doesJobBelongsToUser(job, req) {
|
function doesJobBelongsToUser(job, req) {
|
||||||
@@ -25,33 +24,14 @@ jobRouter.get('/', async (req, res) => {
|
|||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
jobRouter.get('/processingTimes', async (req, res) => {
|
jobRouter.get('/processingTimes', async (req, res) => {
|
||||||
let scrapingAntData = null;
|
|
||||||
if (config.scrapingAnt.apiKey != null && config.scrapingAnt.apiKey.length > 0) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`https://api.scrapingant.com/v2/usage?x-api-key=${config.scrapingAnt.apiKey}`);
|
|
||||||
scrapingAntData = await response.json();
|
|
||||||
} catch (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,
|
|
||||||
};
|
};
|
||||||
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 (
|
|
||||||
provider.find((p) => p.id === immoscoutProvider.metaInformation.id) != null &&
|
|
||||||
(config.scrapingAnt.apiKey == null || config.scrapingAnt.apiKey.length === 0)
|
|
||||||
) {
|
|
||||||
res.send(
|
|
||||||
new Error('To use Immoscout as provider, you need to configure ScrapingAnt first. Please check the readme.')
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
jobStorage.upsertJob({
|
jobStorage.upsertJob({
|
||||||
userId: req.session.currentUser,
|
userId: req.session.currentUser,
|
||||||
@@ -66,6 +46,11 @@ jobRouter.post('/', async (req, res) => {
|
|||||||
res.send(new Error(error));
|
res.send(new Error(error));
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
|
trackDemoJobCreated({
|
||||||
|
name,
|
||||||
|
provider,
|
||||||
|
adapter: notificationAdapter,
|
||||||
|
});
|
||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
jobRouter.delete('', async (req, res) => {
|
jobRouter.delete('', async (req, res) => {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import restana from 'restana';
|
import restana from 'restana';
|
||||||
import * as userStorage from '../../services/storage/userStorage.js';
|
import * as userStorage from '../../services/storage/userStorage.js';
|
||||||
import * as hasher from '../../services/security/hash.js';
|
import * as hasher from '../../services/security/hash.js';
|
||||||
|
import {config} from '../../utils.js';
|
||||||
|
import {trackDemoAccessed} from '../../services/tracking/Tracker.js';
|
||||||
const service = restana();
|
const service = restana();
|
||||||
const loginRouter = service.newRouter();
|
const loginRouter = service.newRouter();
|
||||||
loginRouter.get('/user', async (req, res) => {
|
loginRouter.get('/user', async (req, res) => {
|
||||||
@@ -24,6 +26,11 @@ loginRouter.post('/', async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (user.password === hasher.hash(password)) {
|
if (user.password === hasher.hash(password)) {
|
||||||
|
|
||||||
|
if(config.demoMode){
|
||||||
|
trackDemoAccessed();
|
||||||
|
}
|
||||||
|
|
||||||
req.session.currentUser = user.id;
|
req.session.currentUser = user.id;
|
||||||
userStorage.setLastLoginToNow({ userId: user.id });
|
userStorage.setLastLoginToNow({ userId: user.id });
|
||||||
res.send(200);
|
res.send(200);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import restana from 'restana';
|
import restana from 'restana';
|
||||||
import * as userStorage from '../../services/storage/userStorage.js';
|
import * as userStorage from '../../services/storage/userStorage.js';
|
||||||
import * as jobStorage from '../../services/storage/jobStorage.js';
|
import * as jobStorage from '../../services/storage/jobStorage.js';
|
||||||
|
import {config} from '../../utils.js';
|
||||||
const service = restana();
|
const service = restana();
|
||||||
const userRouter = service.newRouter();
|
const userRouter = service.newRouter();
|
||||||
function checkIfAnyAdminAfterRemovingUser(userIdToBeRemoved, allUser) {
|
function checkIfAnyAdminAfterRemovingUser(userIdToBeRemoved, allUser) {
|
||||||
@@ -20,6 +21,11 @@ userRouter.get('/:userId', async (req, res) => {
|
|||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
userRouter.delete('/', async (req, res) => {
|
userRouter.delete('/', async (req, res) => {
|
||||||
|
if(config.demoMode){
|
||||||
|
res.send(new Error('In demo mode, it is not allowed to remove user.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { userId } = req.body;
|
const { userId } = req.body;
|
||||||
const allUser = userStorage.getUsers(false);
|
const allUser = userStorage.getUsers(false);
|
||||||
if (!checkIfAnyAdminAfterRemovingUser(userId, allUser)) {
|
if (!checkIfAnyAdminAfterRemovingUser(userId, allUser)) {
|
||||||
@@ -36,6 +42,12 @@ userRouter.delete('/', async (req, res) => {
|
|||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
userRouter.post('/', async (req, res) => {
|
userRouter.post('/', async (req, res) => {
|
||||||
|
|
||||||
|
if(config.demoMode){
|
||||||
|
res.send(new Error('In demo mode, it is not allowed to change or add user.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { username, password, password2, isAdmin, userId } = req.body;
|
const { username, password, password2, isAdmin, userId } = req.body;
|
||||||
if (password !== password2) {
|
if (password !== password2) {
|
||||||
res.send(new Error('Passwords does not match'));
|
res.send(new Error('Passwords does not match'));
|
||||||
|
|||||||
7
lib/defaultConfig.js
Normal file
7
lib/defaultConfig.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const DEFAULT_CONFIG = {
|
||||||
|
'interval': '60',
|
||||||
|
'port': 9998,
|
||||||
|
'workingHours': {'from': '', 'to': ''},
|
||||||
|
'demoMode': false,
|
||||||
|
'analyticsEnabled': null
|
||||||
|
};
|
||||||
36
lib/notification/adapter/apprise.js
Normal file
36
lib/notification/adapter/apprise.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
|
import { getJob } from '../../services/storage/jobStorage.js';
|
||||||
|
import fetch from 'node-fetch';
|
||||||
|
|
||||||
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
|
const { server } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
|
const job = getJob(jobKey);
|
||||||
|
const jobName = job == null ? jobKey : job.name;
|
||||||
|
const promises = newListings.map((newListing) => {
|
||||||
|
const title = `${jobName} at ${serviceName}: ${newListing.title}`;
|
||||||
|
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nink: ${newListing.link}`;
|
||||||
|
return fetch(server, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
body: message,
|
||||||
|
title: title,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.all(promises);
|
||||||
|
};
|
||||||
|
export const config = {
|
||||||
|
id: 'apprise',
|
||||||
|
name: 'Apprise',
|
||||||
|
readme: markdown2Html('lib/notification/adapter/apprise.md'),
|
||||||
|
description: 'Fredy will send new listings to your Apprise instance.',
|
||||||
|
fields: {
|
||||||
|
server: {
|
||||||
|
type: 'text',
|
||||||
|
label: 'Server',
|
||||||
|
description: 'The server URL to send the notification to.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
3
lib/notification/adapter/apprise.md
Normal file
3
lib/notification/adapter/apprise.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
### Apprise Adapter
|
||||||
|
|
||||||
|
Refer to the [instructions](https://github.com/caronc/apprise-api#installation) on how to set up an Apprise instance and how to configure your preferred notification service.
|
||||||
@@ -9,7 +9,7 @@ const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTe
|
|||||||
const emailTemplate = Handlebars.compile(template);
|
const emailTemplate = Handlebars.compile(template);
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
const { apiPublicKey, apiPrivateKey, receiver, from } = notificationConfig.find(
|
const { apiPublicKey, apiPrivateKey, receiver, from } = notificationConfig.find(
|
||||||
(adapter) => adapter.id === 'mailjet'
|
(adapter) => adapter.id === config.id,
|
||||||
).fields;
|
).fields;
|
||||||
const to = receiver
|
const to = receiver
|
||||||
.trim()
|
.trim()
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { markdown2Html } from '../../services/markdown.js';
|
|||||||
import { getJob } from '../../services/storage/jobStorage.js';
|
import { getJob } from '../../services/storage/jobStorage.js';
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
const { webhook, channel } = notificationConfig.find((adapter) => adapter.id === 'mattermost').fields;
|
const { webhook, channel } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
const job = getJob(jobKey);
|
const job = getJob(jobKey);
|
||||||
const jobName = job == null ? jobKey : job.name;
|
const jobName = job == null ? jobKey : job.name;
|
||||||
let message = `### *${jobName}* (${serviceName}) found **${newListings.length}** new listings:\n\n`;
|
let message = `### *${jobName}* (${serviceName}) found **${newListings.length}** new listings:\n\n`;
|
||||||
message += `| Title | Address | Size | Price |\n|:----|:----|:----|:----|\n`;
|
message += `| Title | Address | Size | Price |\n|:----|:----|:----|:----|\n`;
|
||||||
message += newListings.map(
|
message += newListings.map(
|
||||||
(o) => `| [${o.title}](${o.link}) | ` + [o.address, o.size.replace(/2m/g, '$m^2$'), o.price].join(' | ') + ' |\n'
|
(o) => `| [${o.title}](${o.link}) | ` + [o.address, o.size.replace(/2m/g, '$m^2$'), o.price].join(' | ') + ' |\n',
|
||||||
);
|
);
|
||||||
return fetch(webhook, {
|
return fetch(webhook, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { getJob } from '../../services/storage/jobStorage.js';
|
|||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
|
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
const { priority, server, topic } = notificationConfig.find((adapter) => adapter.id === 'ntfy').fields;
|
const { priority, server, topic } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
const job = getJob(jobKey);
|
const job = getJob(jobKey);
|
||||||
const jobName = job == null ? jobKey : job.name;
|
const jobName = job == null ? jobKey : job.name;
|
||||||
const promises = newListings.map((newListing) => {
|
const promises = newListings.map((newListing) => {
|
||||||
|
|||||||
50
lib/notification/adapter/pushover.js
Normal file
50
lib/notification/adapter/pushover.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
|
import { getJob } from '../../services/storage/jobStorage.js';
|
||||||
|
import fetch from 'node-fetch';
|
||||||
|
|
||||||
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
|
const { token, user, device } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
|
const job = getJob(jobKey);
|
||||||
|
const jobName = job == null ? jobKey : job.name;
|
||||||
|
const promises = newListings.map((newListing) => {
|
||||||
|
const title = `${jobName} at ${serviceName}: ${newListing.title}`;
|
||||||
|
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}`;
|
||||||
|
return fetch('https://api.pushover.net/1/messages.json', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
token: token,
|
||||||
|
user: user,
|
||||||
|
message: message,
|
||||||
|
device: device,
|
||||||
|
title: title,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.all(promises);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
id: 'pushover',
|
||||||
|
name: 'Pushover',
|
||||||
|
readme: markdown2Html('lib/notification/adapter/pushover.md'),
|
||||||
|
description: 'Fredy will send new listings to your mobile using Pushover.',
|
||||||
|
fields: {
|
||||||
|
token: {
|
||||||
|
type: 'text',
|
||||||
|
label: 'API token',
|
||||||
|
description: 'Your application\'s API token.',
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
type: 'text',
|
||||||
|
label: 'User key',
|
||||||
|
description: 'Your user/group key.',
|
||||||
|
},
|
||||||
|
device: {
|
||||||
|
type: 'text',
|
||||||
|
label: 'Device name',
|
||||||
|
description: 'The device name to send your notification to. Messages may be addressed to multiple specific devices by joining them with a comma.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
5
lib/notification/adapter/pushover.md
Normal file
5
lib/notification/adapter/pushover.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
### Pushover Adapter
|
||||||
|
|
||||||
|
Refer to the [instructions](https://support.pushover.net/i7-what-is-pushover-and-how-do-i-use-it) to set up your Pushover application.
|
||||||
|
|
||||||
|
After setting up the application, please enter both your newly created User key and API token.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import sgMail from '@sendgrid/mail';
|
import sgMail from '@sendgrid/mail';
|
||||||
import { markdown2Html } from '../../services/markdown.js';
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
const { apiKey, receiver, from, templateId } = notificationConfig.find((adapter) => adapter.id === 'sendGrid').fields;
|
const { apiKey, receiver, from, templateId } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
sgMail.setApiKey(apiKey);
|
sgMail.setApiKey(apiKey);
|
||||||
const msg = {
|
const msg = {
|
||||||
templateId,
|
templateId,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Slack from 'slack';
|
|||||||
import { markdown2Html } from '../../services/markdown.js';
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
const msg = Slack.chat.postMessage;
|
const msg = Slack.chat.postMessage;
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
const { token, channel } = notificationConfig.find((adapter) => adapter.id === 'slack').fields;
|
const { token, channel } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
return newListings.map((payload) =>
|
return newListings.map((payload) =>
|
||||||
msg({
|
msg({
|
||||||
token,
|
token,
|
||||||
@@ -35,7 +35,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
|||||||
ts: new Date().getTime() / 1000,
|
ts: new Date().getTime() / 1000,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
export const config = {
|
export const config = {
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
### Sqlite Adapter
|
### Sqlite Adapter
|
||||||
|
This adapter stores search results in a sqlite database located in db/listings.db. This file can be used for further analysis later on.
|
||||||
|
|
||||||
This adapter stores search results in an sqlite database in db/listings.db
|
Fields are:
|
||||||
|
```
|
||||||
|
['serviceName', 'jobKey', 'id', 'size', 'rooms', 'price', 'address', 'title', 'link', 'description']
|
||||||
|
```
|
||||||
@@ -19,7 +19,7 @@ function shorten(str, len = 30) {
|
|||||||
return str.length > len ? str.substring(0, len) + '...' : str;
|
return str.length > len ? str.substring(0, len) + '...' : str;
|
||||||
}
|
}
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
const { token, chatId } = notificationConfig.find((adapter) => adapter.id === 'telegram').fields;
|
const { token, chatId } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
const job = getJob(jobKey);
|
const job = getJob(jobKey);
|
||||||
const jobName = job == null ? jobKey : job.name;
|
const jobName = job == null ? jobKey : job.name;
|
||||||
//we have to split messages into chunk, because otherwise messages are going to become too big and will fail
|
//we have to split messages into chunk, because otherwise messages are going to become too big and will fail
|
||||||
@@ -30,7 +30,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
|||||||
(o) =>
|
(o) =>
|
||||||
`<a href='${o.link}'><b>${shorten(o.title.replace(/\*/g, ''), 45).trim()}</b></a>\n` +
|
`<a href='${o.link}'><b>${shorten(o.title.replace(/\*/g, ''), 45).trim()}</b></a>\n` +
|
||||||
[o.address, o.price, o.size].join(' | ') +
|
[o.address, o.price, o.size].join(' | ') +
|
||||||
'\n\n'
|
'\n\n',
|
||||||
);
|
);
|
||||||
/**
|
/**
|
||||||
* This is to not break the rate limit. It is to only send 1 message per second
|
* This is to not break the rate limit. It is to only send 1 message per second
|
||||||
|
|||||||
@@ -1,29 +1,46 @@
|
|||||||
import utils from '../utils.js';
|
import utils, { buildHash } from '../utils.js';
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
let size = `${o.size.replace(' Wohnfläche ', '').trim()}`;
|
|
||||||
if (o.rooms != null) {
|
|
||||||
size += ` / / ${o.rooms.trim()}`;
|
|
||||||
}
|
|
||||||
const link = `https://www.1a-immobilienmarkt.de/expose/${o.id}.html`;
|
const link = `https://www.1a-immobilienmarkt.de/expose/${o.id}.html`;
|
||||||
return Object.assign(o, { size, link });
|
const price = normalizePrice(o.price);
|
||||||
|
const id = buildHash(o.id, price);
|
||||||
|
return Object.assign(o, { id, price, link });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* einsAImmobilien sometimes use a weird pricing label such as `775.700,00 EUR Kaufpreis ab 2.475 € mtl`.
|
||||||
|
* Make sure to extract only the actual price out of the string.
|
||||||
|
* @param price
|
||||||
|
* @returns {*}
|
||||||
|
*/
|
||||||
|
function normalizePrice(price) {
|
||||||
|
if (price == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const regex = /(\d{1,3}(?:\.\d{3})*,\d{2})\s?(EUR|€)/g;
|
||||||
|
const result = price.match(regex);
|
||||||
|
if (result == null || result.length === 0) {
|
||||||
|
return price;
|
||||||
|
}
|
||||||
|
return result[0];
|
||||||
}
|
}
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '.tabelle',
|
crawlContainer: '.tabelle',
|
||||||
sortByDateParam: 'sort_type=newest',
|
sortByDateParam: 'sort_type=newest',
|
||||||
|
waitForSelector: 'body',
|
||||||
crawlFields: {
|
crawlFields: {
|
||||||
id: '.inner_object_data input[name="marker_objekt_id"]@value | int',
|
id: '.inner_object_data input[name="marker_objekt_id"]@value | int',
|
||||||
price: '.tabelle .inner_object_data .single_data_price | removeNewline | trim',
|
price: '.inner_object_data .single_data_price | removeNewline | trim',
|
||||||
size: '.tabelle .inner_object_data .data_boxes div:nth-child(1)',
|
size: '.tabelle .tabelle_inhalt_infos .single_data_box | removeNewline | trim',
|
||||||
rooms: '.tabelle .inner_object_data .data_boxes div:nth-child(2)',
|
title: '.inner_object_data .tabelle_inhalt_titel_black | removeNewline | trim',
|
||||||
title: '.tabelle .inner_object_data .tabelle_inhalt_titel_black | removeNewline | trim',
|
|
||||||
description: '.tabelle .inner_object_data .objekt_beschreibung | removeNewline | trim',
|
|
||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import utils from '../utils.js';
|
import utils, {buildHash} from '../utils.js';
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
function shortenLink(link) {
|
function shortenLink(link) {
|
||||||
return link.substring(0, link.indexOf('?'));
|
return link.substring(0, link.indexOf('?'));
|
||||||
@@ -7,12 +7,13 @@ function parseId(shortenedLink) {
|
|||||||
return shortenedLink.substring(shortenedLink.lastIndexOf('/') + 1);
|
return shortenedLink.substring(shortenedLink.lastIndexOf('/') + 1);
|
||||||
}
|
}
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const id = parseId(shortenLink(o.link));
|
|
||||||
const size = o.size || 'N/A m²';
|
const size = o.size || 'N/A m²';
|
||||||
const price = o.price || 'N/A €';
|
const price = o.price || 'N/A €';
|
||||||
const title = o.title || 'No title available';
|
const title = o.title || 'No title available';
|
||||||
const address = o.address || 'No address available';
|
const address = o.address || 'No address available';
|
||||||
const link = shortenLink(o.link);
|
const shortLink = shortenLink(o.link);
|
||||||
|
const link = `https://www.immobilien.de/${shortLink}`;
|
||||||
|
const id = buildHash(parseId(shortLink), o.price);
|
||||||
return Object.assign(o, { id, price, size, title, address, link });
|
return Object.assign(o, { id, price, size, title, address, link });
|
||||||
}
|
}
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
@@ -22,9 +23,11 @@ function applyBlacklist(o) {
|
|||||||
}
|
}
|
||||||
const config = {
|
const config = {
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '.estates_list .list_immo a._ref',
|
crawlContainer: '._ref',
|
||||||
sortByDateParam: 'sort_col=*created_ts&sort_dir=desc',
|
sortByDateParam: 'sort_col=*created_ts&sort_dir=desc',
|
||||||
|
waitForSelector: 'body',
|
||||||
crawlFields: {
|
crawlFields: {
|
||||||
|
id: '@href', //will be transformed later
|
||||||
price: '.list_entry .immo_preis .label_info',
|
price: '.list_entry .immo_preis .label_info',
|
||||||
size: '.list_entry .flaeche .label_info | removeNewline | trim',
|
size: '.list_entry .flaeche .label_info | removeNewline | trim',
|
||||||
title: '.list_entry .part_text h3 span',
|
title: '.list_entry .part_text h3 span',
|
||||||
@@ -32,7 +35,6 @@ const config = {
|
|||||||
link: '@href',
|
link: '@href',
|
||||||
address: '.list_entry .place',
|
address: '.list_entry .place',
|
||||||
},
|
},
|
||||||
paginate: '.list_immo .blocknav .blocknav_list li.next a@href',
|
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
import utils from '../utils.js';
|
import utils, { buildHash } from '../utils.js';
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note, Immonet is rly a piece of sh*t. It is using a weird combination of React and some buttons (instead of links),
|
||||||
|
* so that if somebody clicks the listing, a new page will open with the actual link to the listing. Of course, a scraper
|
||||||
|
* cannot do this (which is why I always just return the link to the whole list of listings).
|
||||||
|
* This is not only bad for us, but also bad for ppl with disabilities...
|
||||||
|
*/
|
||||||
|
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const id = o.id.substring(o.id.lastIndexOf('/') + 1, o.id.length);
|
|
||||||
const size = o.size != null ? o.size.replace('Wohnfläche ', '') : 'N/A m²';
|
const size = o.size != null ? o.size.replace('Wohnfläche ', '') : 'N/A m²';
|
||||||
const price = o.price.replace('Kaufpreis ', '');
|
const price = o.price.replace('Kaufpreis ', '');
|
||||||
const address = o.address.split(' • ')[o.address.split(' • ').length - 1];
|
const address = o.address.split(' • ')[o.address.split(' • ').length - 1];
|
||||||
const title = o.title || 'No title available';
|
const title = o.title || 'No title available';
|
||||||
const link = o.id;
|
const link = config.url;
|
||||||
|
const id = buildHash(title, price);
|
||||||
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) {
|
||||||
@@ -16,16 +24,16 @@ function applyBlacklist(o) {
|
|||||||
}
|
}
|
||||||
const config = {
|
const config = {
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '.content-wrapper-tiles .ng-star-inserted',
|
crawlContainer: 'div[data-testid="serp-core-classified-card-testid"]',
|
||||||
sortByDateParam: 'sortby=19',
|
sortByDateParam: 'sortby=19',
|
||||||
|
waitForSelector: 'div[data-testid="serp-resultscount-testid"]',
|
||||||
crawlFields: {
|
crawlFields: {
|
||||||
id: '.card a@href',
|
id: 'button@title |trim', // immonet is a piece of sh*t. See comment above
|
||||||
title: '.card h3 |trim',
|
title: 'button@title |trim',
|
||||||
price: '.card .has-font-300 .is-bold | trim',
|
price: 'div[data-testid="cardmfe-price-testid"] | trim',
|
||||||
size: '.card .has-font-300 .ml-100 | trim',
|
size: 'div[data-testid="cardmfe-keyfacts-testid"] | trim',
|
||||||
address: '.card span:nth-child(2) | trim',
|
address: 'div[data-testid="cardmfe-description-box-address"] | trim',
|
||||||
},
|
},
|
||||||
paginate: '#idResultList .margin-bottom-6.margin-bottom-sm-12 .panel a.pull-right@href',
|
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import utils from '../utils.js';
|
import utils, {buildHash} from '../utils.js';
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
function nullOrEmpty(val) {
|
function nullOrEmpty(val) {
|
||||||
return val == null || val.length === 0;
|
return val == null || val.length === 0;
|
||||||
@@ -6,8 +6,9 @@ function nullOrEmpty(val) {
|
|||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const title = nullOrEmpty(o.title) ? 'NO TITLE FOUND' : o.title.replace('NEU', '');
|
const title = nullOrEmpty(o.title) ? 'NO TITLE FOUND' : o.title.replace('NEU', '');
|
||||||
const address = nullOrEmpty(o.address) ? 'NO ADDRESS FOUND' : (o.address || '').replace(/\(.*\),.*$/, '').trim();
|
const address = nullOrEmpty(o.address) ? 'NO ADDRESS FOUND' : (o.address || '').replace(/\(.*\),.*$/, '').trim();
|
||||||
const link = `https://www.immobilienscout24.de${o.link.substring(o.link.indexOf('/expose'))}`;
|
const link = nullOrEmpty(o.link) ? 'NO LINK' : `https://www.immobilienscout24.de${o.link.substring(o.link.indexOf('/expose'))}`;
|
||||||
return Object.assign(o, { title, address, link });
|
const id = buildHash(o.id, o.price);
|
||||||
|
return Object.assign(o, { id, title, address, link });
|
||||||
}
|
}
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
return !utils.isOneOf(o.title, appliedBlackList);
|
return !utils.isOneOf(o.title, appliedBlackList);
|
||||||
@@ -16,15 +17,15 @@ const config = {
|
|||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '#resultListItems li.result-list__listing',
|
crawlContainer: '#resultListItems li.result-list__listing',
|
||||||
sortByDateParam: 'sorting=2',
|
sortByDateParam: 'sorting=2',
|
||||||
|
waitForSelector: 'body',
|
||||||
crawlFields: {
|
crawlFields: {
|
||||||
id: '.result-list-entry@data-obid | int',
|
id: '.result-list-entry@data-obid | int',
|
||||||
price: '.result-list-entry .result-list-entry__criteria .grid-item:first-child dd | removeNewline | trim',
|
price: '.result-list-entry .result-list-entry__criteria .grid-item:first-child dd | removeNewline | trim',
|
||||||
size: '.result-list-entry .result-list-entry__criteria .grid-item:nth-child(2) dd | removeNewline | trim',
|
size: '.result-list-entry .result-list-entry__criteria .grid-item:nth-child(2) dd | removeNewline | trim',
|
||||||
title: '.result-list-entry .result-list-entry__brand-title-container h5 | removeNewline | trim',
|
title: '.result-list-entry .result-list-entry__brand-title-container h2 | removeNewline | trim',
|
||||||
link: '.result-list-entry .result-list-entry__brand-title-container@href',
|
link: '.result-list-entry .result-list-entry__brand-title-container@href',
|
||||||
address: '.result-list-entry .result-list-entry__map-link',
|
address: '.result-list-entry .result-list-entry__map-link',
|
||||||
},
|
},
|
||||||
paginate: '#pager .align-right a@href',
|
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,44 +1,48 @@
|
|||||||
import utils from '../utils.js';
|
import utils, {buildHash} from '../utils.js';
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const id = o.id.substring(o.id.indexOf('-') + 1, o.id.length);
|
const size = o.size || 'N/A m²';
|
||||||
const size = o.size || 'N/A m²';
|
const price = (o.price || '--- €').replace('Preis auf Anfrage', '--- €');
|
||||||
const price = (o.price || '--- €').replace('Preis auf Anfrage', '--- €');
|
const title = o.title || 'No title available';
|
||||||
const address = o.address || 'No address available';
|
const immoId = o.id.substring(o.id.indexOf('-') + 1, o.id.length);
|
||||||
const title = o.title || 'No title available';
|
const link = `https://immo.swp.de/immobilien/${immoId}`;
|
||||||
const link = `https://immo.swp.de/immobilien/${id}`;
|
const description = o.description;
|
||||||
const description = o.description;
|
const id = buildHash(immoId, price);
|
||||||
return Object.assign(o, { id, address, price, size, title, link, description });
|
return Object.assign(o, {id, price, size, title, link, description});
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '.js-serp-item',
|
crawlContainer: '.js-serp-item',
|
||||||
sortByDateParam: 's=most_recently_updated_first',
|
sortByDateParam: 's=most_recently_updated_first',
|
||||||
crawlFields: {
|
waitForSelector: 'body',
|
||||||
id: '@id',
|
crawlFields: {
|
||||||
price: 'div.item__spec.item-spec-price | trim',
|
id: '.js-bookmark-btn@data-id',
|
||||||
size: 'div.item__spec.item-spec-area | trim',
|
price: 'div.align-items-start div:first-child | trim',
|
||||||
title: 'a.js-item-title-link@title',
|
size: 'div.align-items-start div:nth-child(3) | trim',
|
||||||
address: 'div.item__locality | removeNewline | trim',
|
title: '.card-title h2 | trim',
|
||||||
description: 'div.item__main-info-points.clearfix p small | removeNewline | trim',
|
link: '.ci-search-result__link@href',
|
||||||
},
|
description: '.js-show-more-item-sm | removeNewline | trim',
|
||||||
paginate: 'li.page-item.pagination__item a.page-link@href',
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
config.enabled = sourceConfig.enabled;
|
config.enabled = sourceConfig.enabled;
|
||||||
config.url = sourceConfig.url;
|
config.url = sourceConfig.url;
|
||||||
appliedBlackList = blacklist || [];
|
appliedBlackList = blacklist || [];
|
||||||
};
|
};
|
||||||
export const metaInformation = {
|
export const metaInformation = {
|
||||||
name: 'Immo Südwest Presse',
|
name: 'Immo Südwest Presse',
|
||||||
baseUrl: 'https://immo.swp.de/',
|
baseUrl: 'https://immo.swp.de/',
|
||||||
id: 'immoswp',
|
id: 'immoswp',
|
||||||
};
|
};
|
||||||
export { config };
|
export {config};
|
||||||
|
|||||||
@@ -1,26 +1,32 @@
|
|||||||
import utils from '../utils.js';
|
import utils, { buildHash } from '../utils.js';
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
return o;
|
const id = buildHash(o.id, o.price);
|
||||||
|
return Object.assign(o, { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: "div[class^='EstateItem-']",
|
crawlContainer:
|
||||||
sortByDateParam: 'sd=DESC&sf=TIMESTAMP',
|
'div[data-testid="serp-core-scrollablelistview-testid"]:not(div[data-testid="serp-enlargementlist-testid"] div[data-testid="serp-card-testid"]) div[data-testid="serp-core-classified-card-testid"]',
|
||||||
|
sortByDateParam: 'order=DateDesc',
|
||||||
|
waitForSelector: 'div[data-testid="serp-gridcontainer-testid"]',
|
||||||
crawlFields: {
|
crawlFields: {
|
||||||
id: 'a@id',
|
id: 'a@href',
|
||||||
price: "div[class^='KeyFacts-'] [data-test='price'] | removeNewline | trim",
|
price: 'div[data-testid="cardmfe-price-testid"] | removeNewline | trim',
|
||||||
size: "div[class^='KeyFacts-'] [data-test='area'] | removeNewline | trim",
|
size: 'div[data-testid="cardmfe-keyfacts-testid"] | removeNewline | trim',
|
||||||
title: "div[class^='FactsMain-'] h2",
|
title: '.css-1cbj9xw',
|
||||||
link: 'a@href',
|
link: 'a@href',
|
||||||
address: "div[class^='estateFacts-'] span | removeNewline | trim",
|
address: 'div[data-testid="cardmfe-description-box-address"] | removeNewline | trim',
|
||||||
},
|
},
|
||||||
paginate: '#pnlPaging #nlbPlus@href',
|
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,44 +1,50 @@
|
|||||||
import utils from '../utils.js';
|
import utils, {buildHash} from '../utils.js';
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
let appliedBlacklistedDistricts = [];
|
let appliedBlacklistedDistricts = [];
|
||||||
|
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const size = o.size || '--- m²';
|
const size = o.size || '--- m²';
|
||||||
return Object.assign(o, { size });
|
const id = buildHash(o.id, o.price);
|
||||||
|
const link = `https://www.kleinanzeigen.de${o.link}`;
|
||||||
|
return Object.assign(o, {id, 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);
|
||||||
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 o.title != null && !isBlacklistedDistrict && titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '#srchrslt-adtable .ad-listitem ',
|
crawlContainer: '#srchrslt-adtable .ad-listitem ',
|
||||||
//sort by date is standard oO
|
//sort by date is standard oO
|
||||||
sortByDateParam: null,
|
sortByDateParam: null,
|
||||||
crawlFields: {
|
waitForSelector: 'body',
|
||||||
id: '.aditem@data-adid | int',
|
crawlFields: {
|
||||||
price: '.aditem-main--middle--price | removeNewline | trim',
|
id: '.aditem@data-adid | int',
|
||||||
size: '.aditem-main .text-module-end span:nth-child(2) | removeNewline | trim',
|
price: '.aditem-main--middle--price-shipping--price | removeNewline | trim',
|
||||||
title: '.aditem-main .text-module-begin a | removeNewline | trim',
|
size: '.aditem-main .text-module-end | removeNewline | trim',
|
||||||
link: '.aditem-main .text-module-begin a@href | removeNewline | trim',
|
title: '.aditem-main .text-module-begin a | removeNewline | trim',
|
||||||
description: '.aditem-main p:not(.text-module-end) | removeNewline | trim',
|
link: '.aditem-main .text-module-begin a@href | removeNewline | trim',
|
||||||
address: '.aditem-main--top--left | trim | removeNewline',
|
description: '.aditem-main .aditem-main--middle--description | removeNewline | trim',
|
||||||
},
|
address: '.aditem-main--top--left | trim | removeNewline',
|
||||||
paginate: '#srchrslt-pagination .pagination-next@href',
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
};
|
};
|
||||||
export const metaInformation = {
|
export const metaInformation = {
|
||||||
name: 'Ebay Kleinanzeigen',
|
name: 'Ebay Kleinanzeigen',
|
||||||
baseUrl: 'https://www.ebay-kleinanzeigen.de/',
|
baseUrl: 'https://www.kleinanzeigen.de/',
|
||||||
id: 'kleinanzeigen',
|
id: 'kleinanzeigen',
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist, blacklistedDistricts) => {
|
export const init = (sourceConfig, blacklist, blacklistedDistricts) => {
|
||||||
config.enabled = sourceConfig.enabled;
|
config.enabled = sourceConfig.enabled;
|
||||||
config.url = sourceConfig.url;
|
config.url = sourceConfig.url;
|
||||||
appliedBlacklistedDistricts = blacklistedDistricts || [];
|
appliedBlacklistedDistricts = blacklistedDistricts || [];
|
||||||
appliedBlackList = blacklist || [];
|
appliedBlackList = blacklist || [];
|
||||||
};
|
};
|
||||||
export { config };
|
export {config};
|
||||||
|
|||||||
@@ -1,34 +1,44 @@
|
|||||||
import utils from '../utils.js';
|
import utils, {buildHash} from '../utils.js';
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
function nullOrEmpty(val) {
|
||||||
|
return val == null || val.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
return o;
|
const link = nullOrEmpty(o.link) ? 'NO LINK' : `https://www.neubaukompass.de${o.link.substring(o.link.indexOf('/neubau'))}`;
|
||||||
|
const id = buildHash(o.link, o.price);
|
||||||
|
return Object.assign(o, {id, link});
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
return !utils.isOneOf(o.title, appliedBlackList);
|
return !utils.isOneOf(o.title, appliedBlackList);
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '.nbk-container >div article',
|
crawlContainer: '.col-12.mb-4',
|
||||||
sortByDateParam: 'Sortierung=Id&Richtung=DESC',
|
sortByDateParam: 'Sortierung=Id&Richtung=DESC',
|
||||||
crawlFields: {
|
waitForSelector: '.nbk-section',
|
||||||
id: '@id',
|
crawlFields: {
|
||||||
title: 'a.nbk-truncate@title | removeNewline | trim',
|
id: 'a@href',
|
||||||
link: 'a.nbk-truncate@href',
|
title: 'a@title | removeNewline | trim',
|
||||||
address: 'p.nbk-truncate | removeNewline | trim',
|
link: 'a@href',
|
||||||
price: 'p.nbk-mb-0 | removeNewline | trim',
|
address: '.nbk-project-card__description | removeNewline | trim',
|
||||||
},
|
price: '.nbk-project-card__spec-item .nbk-project-card__spec-value | removeNewline | trim',
|
||||||
paginate: '.numbered-pager__bottom .numbered-pager--info li:nth-child(2) a@href',
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
config.enabled = sourceConfig.enabled;
|
config.enabled = sourceConfig.enabled;
|
||||||
config.url = sourceConfig.url;
|
config.url = sourceConfig.url;
|
||||||
appliedBlackList = blacklist || [];
|
appliedBlackList = blacklist || [];
|
||||||
};
|
};
|
||||||
export const metaInformation = {
|
export const metaInformation = {
|
||||||
name: 'Neubau Kompass',
|
name: 'Neubau Kompass',
|
||||||
baseUrl: 'https://www.neubaukompass.de/',
|
baseUrl: 'https://www.neubaukompass.de/',
|
||||||
id: 'neubauKompass',
|
id: 'neubauKompass',
|
||||||
};
|
};
|
||||||
export { config };
|
export {config};
|
||||||
|
|||||||
@@ -1,36 +1,42 @@
|
|||||||
import utils from '../utils.js';
|
import utils, {buildHash} from '../utils.js';
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
return o;
|
const id = buildHash(o.id, o.price);
|
||||||
|
return Object.assign(o, {id});
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
||||||
return o.id != null && titleNotBlacklisted && descNotBlacklisted;
|
return o.id != null && titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '#main_column .wgg_card',
|
crawlContainer: '#main_column .wgg_card',
|
||||||
sortByDateParam: 'sort_column=0&sort_order=0',
|
sortByDateParam: 'sort_column=0&sort_order=0',
|
||||||
crawlFields: {
|
waitForSelector: 'body',
|
||||||
id: '@data-id',
|
crawlFields: {
|
||||||
details: '.row .noprint .col-xs-11 |removeNewline |trim',
|
id: '@data-id',
|
||||||
price: '.middle .col-xs-3 |removeNewline |trim',
|
details: '.row .noprint .col-xs-11 |removeNewline |trim',
|
||||||
size: '.middle .text-right |removeNewline |trim',
|
price: '.middle .col-xs-3 |removeNewline |trim',
|
||||||
title: '.truncate_title a |removeNewline |trim',
|
size: '.middle .text-right |removeNewline |trim',
|
||||||
link: '.truncate_title a@href',
|
title: '.truncate_title a |removeNewline |trim',
|
||||||
},
|
link: '.truncate_title a@href',
|
||||||
normalize: normalize,
|
},
|
||||||
filter: applyBlacklist,
|
normalize: normalize,
|
||||||
|
filter: applyBlacklist,
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
config.enabled = sourceConfig.enabled;
|
config.enabled = sourceConfig.enabled;
|
||||||
config.url = sourceConfig.url;
|
config.url = sourceConfig.url;
|
||||||
appliedBlackList = blacklist || [];
|
appliedBlackList = blacklist || [];
|
||||||
};
|
};
|
||||||
export const metaInformation = {
|
export const metaInformation = {
|
||||||
name: 'Wg gesucht',
|
name: 'Wg gesucht',
|
||||||
baseUrl: 'https://www.wg-gesucht.de/',
|
baseUrl: 'https://www.wg-gesucht.de/',
|
||||||
id: 'wgGesucht',
|
id: 'wgGesucht',
|
||||||
};
|
};
|
||||||
export { config };
|
export {config};
|
||||||
|
|||||||
29
lib/services/demoCleanup.js
Normal file
29
lib/services/demoCleanup.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { setInterval } from 'node:timers';
|
||||||
|
import {removeJobsByUserName} from './storage/jobStorage.js';
|
||||||
|
import {config} from '../utils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* if we are running in demo environment, we have to cleanup the db files (specifically the jobs table)
|
||||||
|
*/
|
||||||
|
export function cleanupDemoAtMidnight() {
|
||||||
|
const now = new Date();
|
||||||
|
const millisUntilMidnightUTC = (24 - now.getUTCHours()) * 60 * 60 * 1000
|
||||||
|
- now.getUTCMinutes() * 60 * 1000
|
||||||
|
- now.getUTCSeconds() * 1000
|
||||||
|
- now.getUTCMilliseconds();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
cleanup();
|
||||||
|
}, 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
}, millisUntilMidnightUTC);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup(){
|
||||||
|
if(config.demoMode){
|
||||||
|
removeJobsByUserName('demo');
|
||||||
|
}
|
||||||
|
}
|
||||||
43
lib/services/extractor/extractor.js
Normal file
43
lib/services/extractor/extractor.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { setDebug } from './utils.js';
|
||||||
|
import puppeteerExtractor from './puppeteerExtractor.js';
|
||||||
|
import { loadParser, parse } from './parser/parser.js';
|
||||||
|
|
||||||
|
const DEFAULT_OPTIONS = {
|
||||||
|
debug: false,
|
||||||
|
puppeteerTimeout: 60_000,
|
||||||
|
puppeteerHeadless: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class Extractor {
|
||||||
|
constructor(options) {
|
||||||
|
this.options = {
|
||||||
|
...DEFAULT_OPTIONS,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
this.responseText = null;
|
||||||
|
setDebug(this.options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* if you are extracting data from a SPA, you must provide a selector, otherwise
|
||||||
|
* your response will never contain what you are really looking for
|
||||||
|
* @param url
|
||||||
|
* @param waitForSelector
|
||||||
|
*/
|
||||||
|
execute = async (url, waitForSelector = null) => {
|
||||||
|
this.responseText = null;
|
||||||
|
try {
|
||||||
|
this.responseText = await puppeteerExtractor(url, waitForSelector, this.options);
|
||||||
|
if (this.responseText != null) {
|
||||||
|
loadParser(this.responseText);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error trying to load page.', error);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
};
|
||||||
|
|
||||||
|
parseResponseText = (crawlContainer, crawlFields, url) => {
|
||||||
|
return parse(crawlContainer, crawlFields, this.responseText, url);
|
||||||
|
};
|
||||||
|
}
|
||||||
97
lib/services/extractor/parser/parser.js
Normal file
97
lib/services/extractor/parser/parser.js
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
|
||||||
|
let $ = null;
|
||||||
|
|
||||||
|
export function loadParser(text) {
|
||||||
|
$ = cheerio.load(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parse(crawlContainer, crawlFields, text, url) {
|
||||||
|
if (!text) {
|
||||||
|
console.warn('Cannot parse, text was empty for url ', url);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!crawlContainer || !crawlFields) {
|
||||||
|
console.warn('Cannot parse, selector was empty for url ', url);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = [];
|
||||||
|
|
||||||
|
if ($(crawlContainer).length === 0) {
|
||||||
|
console.warn('No elements in crawl container found for url ', url);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$(crawlContainer).each((_, element) => {
|
||||||
|
const container = $(element);
|
||||||
|
const parsedObject = {};
|
||||||
|
|
||||||
|
// Parse fields based on crawlFields
|
||||||
|
for (const [key, fieldSelector] of Object.entries(crawlFields)) {
|
||||||
|
let value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const selector = fieldSelector.includes('|')
|
||||||
|
? fieldSelector.substring(0, fieldSelector.indexOf('|')).trim()
|
||||||
|
: fieldSelector;
|
||||||
|
|
||||||
|
if (selector.includes('@')) {
|
||||||
|
const [sel, attr] = selector.split('@');
|
||||||
|
if (sel.length === 0) {
|
||||||
|
value = container.attr(attr.trim());
|
||||||
|
} else {
|
||||||
|
value = container.find(sel.trim()).attr(attr.trim());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
value = container.find(selector.trim()).text();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply modifiers if specified
|
||||||
|
if (fieldSelector.includes('|')) {
|
||||||
|
/* eslint-disable no-unused-vars */
|
||||||
|
const [_, ...modifiers] = fieldSelector.split('|').map((s) => s.trim());
|
||||||
|
/* eslint-disable no-unused-vars */
|
||||||
|
value = applyModifiers(value, modifiers);
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedObject[key] = value || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error parsing field '${key}' with selector '${fieldSelector}':`, error);
|
||||||
|
parsedObject[key] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedObject.id != null) {
|
||||||
|
result.push(parsedObject);
|
||||||
|
} else {
|
||||||
|
console.warn('ID not found. Not relaying object.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to apply modifiers
|
||||||
|
function applyModifiers(value, modifiers) {
|
||||||
|
if (!value) return value;
|
||||||
|
|
||||||
|
modifiers.forEach((modifier) => {
|
||||||
|
switch (modifier) {
|
||||||
|
case 'int':
|
||||||
|
value = parseInt(value, 10);
|
||||||
|
break;
|
||||||
|
case 'trim':
|
||||||
|
value = value.replace(/\s+/g, ' ').trim();
|
||||||
|
break;
|
||||||
|
case 'removeNewline':
|
||||||
|
value = value.replace(/\n/g, ' ');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn(`Unknown modifier: ${modifier}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
49
lib/services/extractor/puppeteerExtractor.js
Normal file
49
lib/services/extractor/puppeteerExtractor.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import puppeteer from 'puppeteer-extra';
|
||||||
|
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
||||||
|
import { debug, DEFAULT_HEADER, botDetected } from './utils.js';
|
||||||
|
|
||||||
|
puppeteer.use(StealthPlugin());
|
||||||
|
|
||||||
|
export default async function execute(url, waitForSelector, options) {
|
||||||
|
let browser;
|
||||||
|
try {
|
||||||
|
debug(`Sending request to ${url} using Puppeteer.`);
|
||||||
|
|
||||||
|
browser = await puppeteer.launch({
|
||||||
|
headless: options.puppeteerHeadless ?? true,
|
||||||
|
args: ['--no-sandbox', '--disable-gpu', '--disable-setuid-sandbox'],
|
||||||
|
timeout: options.puppeteerTimeout || 30_000,
|
||||||
|
});
|
||||||
|
let page = await browser.newPage();
|
||||||
|
await page.setExtraHTTPHeaders(DEFAULT_HEADER);
|
||||||
|
const response = await page.goto(url, {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
});
|
||||||
|
let pageSource;
|
||||||
|
//if we're extracting data from a spa, we must wait for the selector
|
||||||
|
if (waitForSelector != null) {
|
||||||
|
await page.waitForSelector(waitForSelector);
|
||||||
|
pageSource = await page.evaluate((selector) => {
|
||||||
|
return document.querySelector(selector).innerHTML;
|
||||||
|
}, waitForSelector);
|
||||||
|
} else {
|
||||||
|
pageSource = await page.content();
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusCode = response.status();
|
||||||
|
|
||||||
|
if (botDetected(pageSource, statusCode)) {
|
||||||
|
console.warn('We have been detected as a bot :-/ Tried url: => ', url);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await page.content();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error executing with puppeteer executor', error);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (browser != null) {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
lib/services/extractor/utils.js
Normal file
32
lib/services/extractor/utils.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
let debuggingOn = false;
|
||||||
|
|
||||||
|
export const DEFAULT_HEADER = {
|
||||||
|
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||||
|
'Accept-Language': 'en-US,en;q=0.5',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'Upgrade-Insecure-Requests': '1',
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setDebug = (options) => {
|
||||||
|
debuggingOn = !!options?.debug;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const debug = (message) => {
|
||||||
|
if (debuggingOn) {
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
console.debug(message);
|
||||||
|
/* eslint-enable no-console */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const botDetected = (pageSource, statusCode) => {
|
||||||
|
const suspiciousStatusCodes = [403, 429];
|
||||||
|
const botDetectionPatterns = [/verify you are human/i, /access denied/i, /x-amz-cf-id/i];
|
||||||
|
|
||||||
|
const detectedInSource = botDetectionPatterns.some((pattern) => pattern.test(pageSource));
|
||||||
|
const detectedByStatus = suspiciousStatusCodes.includes(statusCode);
|
||||||
|
|
||||||
|
return detectedInSource || detectedByStatus;
|
||||||
|
};
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import fetch from 'node-fetch';
|
|
||||||
import { config } from '../utils.js';
|
|
||||||
import { makeUrlResidential } from './scrapingAnt.js';
|
|
||||||
import https from 'https';
|
|
||||||
//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];
|
|
||||||
const agent = new https.Agent({
|
|
||||||
rejectUnauthorized: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
function makeDriver(headers = {}) {
|
|
||||||
let cookies = '';
|
|
||||||
async function scrapingAntDriver(context, callback, retryCounter = 0) {
|
|
||||||
const proxyType = config.scrapingAnt?.proxy || 'datacenter';
|
|
||||||
try {
|
|
||||||
const url = proxyType === 'residential' ? makeUrlResidential(context.url) : context.url;
|
|
||||||
const response = await fetch(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 & Immonet 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,
|
|
||||||
Cookie: cookies,
|
|
||||||
},
|
|
||||||
agent,
|
|
||||||
});
|
|
||||||
const result = await response.text();
|
|
||||||
callback(null, result);
|
|
||||||
} catch (exception) {
|
|
||||||
console.error(`Error while trying to scrape data. Received error: ${exception.message}`);
|
|
||||||
callback(null, []);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
export default makeDriver;
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { config } from '../utils.js';
|
|
||||||
import makeDriver from './requestDriver.js';
|
|
||||||
import Xray from 'x-ray';
|
|
||||||
class Scraper {
|
|
||||||
constructor() {
|
|
||||||
const filters = {
|
|
||||||
removeNewline: this._removeNewline,
|
|
||||||
trim: this._trim,
|
|
||||||
int: this._int,
|
|
||||||
};
|
|
||||||
const headers = {
|
|
||||||
'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',
|
|
||||||
};
|
|
||||||
if (config.scrapingAnt != null && config.scrapingAnt.apiKey != null) {
|
|
||||||
headers['x-api-key'] = config.scrapingAnt.apiKey;
|
|
||||||
}
|
|
||||||
const driver = makeDriver(headers);
|
|
||||||
const xray = Xray({ filters });
|
|
||||||
xray.driver(driver);
|
|
||||||
this.xray = xray;
|
|
||||||
}
|
|
||||||
get x() {
|
|
||||||
return this.xray;
|
|
||||||
}
|
|
||||||
_removeNewline(value) {
|
|
||||||
return typeof value === 'string' ? value.replace(/\\n/g, '') : value;
|
|
||||||
}
|
|
||||||
_trim(value) {
|
|
||||||
return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : value;
|
|
||||||
}
|
|
||||||
_int(value) {
|
|
||||||
return typeof value === 'string' ? parseInt(value, 10) : value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export default new Scraper().x;
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { metaInformation as immoScoutInfo } from '../provider/immoscout.js';
|
|
||||||
import { metaInformation as immoNetInfo } from '../provider/immonet.js';
|
|
||||||
import { config } from '../utils.js';
|
|
||||||
|
|
||||||
const additionalImmonetUrlParams = `&wait_for_selector=.content-wrapper-tiles&js_snippet=${new Buffer(
|
|
||||||
'window.scrollTo(0,document.body.scrollHeight);'
|
|
||||||
).toString('base64')}`;
|
|
||||||
|
|
||||||
const needScrapingAnt = (id) => {
|
|
||||||
return id.toLowerCase() === immoScoutInfo.id || id.toLowerCase() === immoNetInfo.id;
|
|
||||||
};
|
|
||||||
export const transformUrlForScrapingAnt = (url, id) => {
|
|
||||||
let urlParams = '';
|
|
||||||
if (needScrapingAnt(id)) {
|
|
||||||
if (id.toLowerCase() === immoNetInfo.id) {
|
|
||||||
urlParams = additionalImmonetUrlParams;
|
|
||||||
}
|
|
||||||
//only do calls to scrapingAnt when dealing with Immoscout/Immonet
|
|
||||||
url = `https://api.scrapingant.com/v2/general?url=${encodeURIComponent(url)}&proxy_type=datacenter${urlParams}`;
|
|
||||||
}
|
|
||||||
return url;
|
|
||||||
};
|
|
||||||
export const isScrapingAntApiKeySet = () => {
|
|
||||||
return config.scrapingAnt != null && config.scrapingAnt.apiKey != null && config.scrapingAnt.apiKey.length > 0;
|
|
||||||
};
|
|
||||||
export const makeUrlResidential = (url) => {
|
|
||||||
return url.replace('datacenter', 'residential');
|
|
||||||
};
|
|
||||||
export { needScrapingAnt };
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import lodash from 'lodash';
|
import lodash from 'lodash';
|
||||||
import { LowSync } from 'lowdb';
|
import { LowSync } from 'lowdb';
|
||||||
export default class LowdashAdapter extends LowSync {
|
export default class LowdashAdapter extends LowSync {
|
||||||
constructor(adapter) {
|
constructor(adapter, defaultData = {}) {
|
||||||
super(adapter);
|
super(adapter, defaultData);
|
||||||
this.chain = lodash.chain(this).get('data');
|
this.chain = lodash.chain(this).get('data');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,10 @@ import LowdashAdapter from './LowDashAdapter.js';
|
|||||||
|
|
||||||
const file = path.join(getDirName(), '../', 'db/jobs.json');
|
const file = path.join(getDirName(), '../', 'db/jobs.json');
|
||||||
const adapter = new JSONFileSync(file);
|
const adapter = new JSONFileSync(file);
|
||||||
const db = new LowdashAdapter(adapter);
|
const db = new LowdashAdapter(adapter, { jobs: [] });
|
||||||
|
|
||||||
db.read();
|
db.read();
|
||||||
|
|
||||||
db.data ||= { jobs: [] };
|
|
||||||
|
|
||||||
export const upsertJob = ({ jobId, name, blacklist = [], enabled = true, provider, notificationAdapter, userId }) => {
|
export const upsertJob = ({ jobId, name, blacklist = [], enabled = true, provider, notificationAdapter, userId }) => {
|
||||||
const currentJob =
|
const currentJob =
|
||||||
@@ -78,6 +77,17 @@ export const removeJobsByUserId = (userId) => {
|
|||||||
.value();
|
.value();
|
||||||
db.write();
|
db.write();
|
||||||
};
|
};
|
||||||
|
export const removeJobsByUserName = (userName) => {
|
||||||
|
db.chain
|
||||||
|
.get('jobs')
|
||||||
|
.filter((job) => job.username === userName)
|
||||||
|
.forEach((job) => listingStorage.removeListings(job.id));
|
||||||
|
db.chain
|
||||||
|
.get('jobs')
|
||||||
|
.remove((job) => job.username === userName)
|
||||||
|
.value();
|
||||||
|
db.write();
|
||||||
|
};
|
||||||
export const getJobs = () => {
|
export const getJobs = () => {
|
||||||
return db.chain
|
return db.chain
|
||||||
.get('jobs')
|
.get('jobs')
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ import LowdashAdapter from './LowDashAdapter.js';
|
|||||||
|
|
||||||
const file = path.join(getDirName(), '../', 'db/jobListingData.json');
|
const file = path.join(getDirName(), '../', 'db/jobListingData.json');
|
||||||
const adapter = new JSONFileSync(file);
|
const adapter = new JSONFileSync(file);
|
||||||
const db = new LowdashAdapter(adapter);
|
const db = new LowdashAdapter(adapter, {});
|
||||||
|
|
||||||
db.read();
|
db.read();
|
||||||
|
|
||||||
db.data ||= {};
|
|
||||||
|
|
||||||
const buildKey = (jobKey, providerId, endpoint) => {
|
const buildKey = (jobKey, providerId, endpoint) => {
|
||||||
let key = `${jobKey}`;
|
let key = `${jobKey}`;
|
||||||
if (jobKey == null && endpoint == null) {
|
if (jobKey == null && endpoint == null) {
|
||||||
|
|||||||
@@ -1,29 +1,36 @@
|
|||||||
import { JSONFileSync } from 'lowdb/node';
|
import { JSONFileSync } from 'lowdb/node';
|
||||||
import { getDirName } from '../../utils.js';
|
import {config, getDirName} from '../../utils.js';
|
||||||
import * as hasher from '../security/hash.js';
|
import * as hasher from '../security/hash.js';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import * as jobStorage from './jobStorage.js';
|
import * as jobStorage from './jobStorage.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import LowdashAdapter from './LowDashAdapter.js';
|
import LowdashAdapter from './LowDashAdapter.js';
|
||||||
|
|
||||||
|
const defaultData = {
|
||||||
|
user: [
|
||||||
|
//you probably want to change the default password ;)
|
||||||
|
{
|
||||||
|
id: nanoid(),
|
||||||
|
lastLogin: Date.now(),
|
||||||
|
username: 'admin',
|
||||||
|
password: hasher.hash('admin'),
|
||||||
|
isAdmin: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: nanoid(),
|
||||||
|
lastLogin: Date.now(),
|
||||||
|
username: 'demo',
|
||||||
|
password: hasher.hash('demo'),
|
||||||
|
isAdmin: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
const file = path.join(getDirName(), '../', 'db/users.json');
|
const file = path.join(getDirName(), '../', 'db/users.json');
|
||||||
const adapter = new JSONFileSync(file);
|
const adapter = new JSONFileSync(file);
|
||||||
const db = new LowdashAdapter(adapter);
|
const db = new LowdashAdapter(adapter, defaultData);
|
||||||
|
|
||||||
db.read();
|
db.read();
|
||||||
db.data ||= {
|
|
||||||
user: [
|
|
||||||
//you probably want to change the default password ;)
|
|
||||||
{
|
|
||||||
id: nanoid(),
|
|
||||||
lastLogin: Date.now(),
|
|
||||||
username: 'admin',
|
|
||||||
password: hasher.hash('admin'),
|
|
||||||
isAdmin: true,
|
|
||||||
isDemo: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getUsers = (withPassword) => {
|
export const getUsers = (withPassword) => {
|
||||||
const jobs = jobStorage.getJobs();
|
const jobs = jobStorage.getJobs();
|
||||||
@@ -84,3 +91,29 @@ export const removeUser = (userId) => {
|
|||||||
.value();
|
.value();
|
||||||
db.write();
|
db.write();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const handleDemoUser = () => {
|
||||||
|
if(!config.demoMode){
|
||||||
|
const user = db.chain.get('user').value();
|
||||||
|
db.chain.get('user').value();
|
||||||
|
db.chain.set('user', user.filter((u) => u.username !== 'demo')).value();
|
||||||
|
db.write();
|
||||||
|
}else {
|
||||||
|
const demoUser = db.chain
|
||||||
|
.get('user')
|
||||||
|
.filter((u) => u.username === 'demo')
|
||||||
|
.value();
|
||||||
|
if (demoUser == null || demoUser.length === 0) {
|
||||||
|
db.chain.get('user')
|
||||||
|
.value()
|
||||||
|
.push({
|
||||||
|
id: nanoid(),
|
||||||
|
username: 'demo',
|
||||||
|
password: hasher.hash('demo'),
|
||||||
|
isAdmin: true,
|
||||||
|
});
|
||||||
|
db.write();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
90
lib/services/tracking/Tracker.js
Normal file
90
lib/services/tracking/Tracker.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import Mixpanel from 'mixpanel';
|
||||||
|
import {getJobs} from '../storage/jobStorage.js';
|
||||||
|
import {getUniqueId} from './uniqueId.js';
|
||||||
|
import {config, inDevMode} from '../../utils.js';
|
||||||
|
import os from 'os';
|
||||||
|
import {readFileSync} from 'fs';
|
||||||
|
import {packageUp} from 'package-up';
|
||||||
|
|
||||||
|
const mixpanelTracker = Mixpanel.init('718670ef1c58c0208256c1e408a3d75e');
|
||||||
|
const distinct_id = getUniqueId() || 'N/A';
|
||||||
|
const version = await getPackageVersion();
|
||||||
|
|
||||||
|
export const track = function () {
|
||||||
|
//only send tracking information if the user allowed to do so.
|
||||||
|
if (config.analyticsEnabled && !inDevMode()) {
|
||||||
|
const activeProvider = new Set();
|
||||||
|
const activeAdapter = new Set();
|
||||||
|
|
||||||
|
const jobs = getJobs();
|
||||||
|
|
||||||
|
if (jobs != null && jobs.length > 0) {
|
||||||
|
jobs.forEach((job) => {
|
||||||
|
job.provider.forEach((provider) => {
|
||||||
|
activeProvider.add(provider.id);
|
||||||
|
});
|
||||||
|
job.notificationAdapter.forEach((adapter) => {
|
||||||
|
activeAdapter.add(adapter.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
mixpanelTracker.track(
|
||||||
|
'fredy_tracking',
|
||||||
|
enrichTrackingObject({
|
||||||
|
adapter: Array.from(activeAdapter),
|
||||||
|
provider: Array.from(activeProvider),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note, this will only be used when Fredy runs in demo mode
|
||||||
|
*/
|
||||||
|
export function trackDemoJobCreated(jobData) {
|
||||||
|
if (config.analyticsEnabled && !inDevMode() && config.demoMode) {
|
||||||
|
mixpanelTracker.track('demoJobCreated', enrichTrackingObject(jobData));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note, this will only be used when Fredy runs in demo mode
|
||||||
|
*/
|
||||||
|
export function trackDemoAccessed() {
|
||||||
|
if (config.analyticsEnabled && !inDevMode() && config.demoMode) {
|
||||||
|
mixpanelTracker.track('demoAccessed', enrichTrackingObject({}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enrichTrackingObject(trackingObject) {
|
||||||
|
const operating_system = os.platform();
|
||||||
|
const os_version = os.release();
|
||||||
|
const arch = process.arch;
|
||||||
|
const language = process.env.LANG || 'en';
|
||||||
|
const nodeVersion = process.version || 'N/A';
|
||||||
|
|
||||||
|
return {
|
||||||
|
...trackingObject,
|
||||||
|
isDemo: config.demoMode,
|
||||||
|
operating_system,
|
||||||
|
os_version,
|
||||||
|
arch,
|
||||||
|
nodeVersion,
|
||||||
|
language,
|
||||||
|
distinct_id,
|
||||||
|
fredy_version: version
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPackageVersion() {
|
||||||
|
try {
|
||||||
|
const packagePath = await packageUp();
|
||||||
|
const packageJson = readFileSync(packagePath, 'utf8');
|
||||||
|
const json = JSON.parse(packageJson);
|
||||||
|
return json.version;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading version from package.json', error);
|
||||||
|
}
|
||||||
|
return 'N/A';
|
||||||
|
}
|
||||||
19
lib/services/tracking/uniqueId.js
Normal file
19
lib/services/tracking/uniqueId.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { hostname, arch, cpus, platform } from 'os';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Don't worry, we are not evil ;) We however need a unique id per running instance
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export const getUniqueId = () => {
|
||||||
|
const systemInfo = {
|
||||||
|
hostname: hostname(),
|
||||||
|
architecture: arch(),
|
||||||
|
cpuCount: cpus().length,
|
||||||
|
platform: platform(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseData = JSON.stringify(systemInfo);
|
||||||
|
|
||||||
|
return createHash('sha256').update(baseData).digest('hex');
|
||||||
|
};
|
||||||
110
lib/utils.js
110
lib/utils.js
@@ -1,51 +1,91 @@
|
|||||||
import { dirname } from 'node:path';
|
import {dirname} from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import {fileURLToPath} from 'node:url';
|
||||||
import { readFile } from 'fs/promises';
|
import {readFile} from 'fs/promises';
|
||||||
|
import {createHash} from 'crypto';
|
||||||
|
import {DEFAULT_CONFIG} from './defaultConfig.js';
|
||||||
|
|
||||||
|
function inDevMode(){
|
||||||
|
return process.env.NODE_ENV == null || process.env.NODE_ENV !== 'production';
|
||||||
|
}
|
||||||
|
|
||||||
function isOneOf(word, arr) {
|
function isOneOf(word, arr) {
|
||||||
if (arr == null || arr.length === 0) {
|
if (arr == null || arr.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const expression = String.raw`\b(${arr.join('|')})\b`;
|
const expression = String.raw`\b(${arr.join('|')})\b`;
|
||||||
const blacklist = new RegExp(expression, 'ig');
|
const blacklist = new RegExp(expression, 'ig');
|
||||||
return blacklist.test(word);
|
return blacklist.test(word);
|
||||||
}
|
}
|
||||||
|
|
||||||
function nullOrEmpty(val) {
|
function nullOrEmpty(val) {
|
||||||
return val == null || val.length === 0;
|
return val == null || val.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function timeStringToMs(timeString, now) {
|
function timeStringToMs(timeString, now) {
|
||||||
const d = new Date(now);
|
const d = new Date(now);
|
||||||
const parts = timeString.split(':');
|
const parts = timeString.split(':');
|
||||||
d.setHours(parts[0]);
|
d.setHours(parts[0]);
|
||||||
d.setMinutes(parts[1]);
|
d.setMinutes(parts[1]);
|
||||||
d.setSeconds(0);
|
d.setSeconds(0);
|
||||||
return d.getTime();
|
return d.getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
function duringWorkingHoursOrNotSet(config, now) {
|
function duringWorkingHoursOrNotSet(config, now) {
|
||||||
const { workingHours } = config;
|
const {workingHours} = config;
|
||||||
if (workingHours == null || nullOrEmpty(workingHours.from) || nullOrEmpty(workingHours.to)) {
|
if (workingHours == null || nullOrEmpty(workingHours.from) || nullOrEmpty(workingHours.to)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const toDate = timeStringToMs(workingHours.to, now);
|
const toDate = timeStringToMs(workingHours.to, now);
|
||||||
const fromDate = timeStringToMs(workingHours.from, now);
|
const fromDate = timeStringToMs(workingHours.from, now);
|
||||||
return fromDate <= now && toDate >= now;
|
return fromDate <= now && toDate >= now;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDirName() {
|
function getDirName() {
|
||||||
return dirname(fileURLToPath(import.meta.url));
|
return dirname(fileURLToPath(import.meta.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = JSON.parse(await readFile(new URL('../conf/config.json', import.meta.url)));
|
function buildHash(...inputs) {
|
||||||
|
if (inputs == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const cleaned = inputs.filter(i => i != null && i.length > 0);
|
||||||
|
if (cleaned.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return createHash('sha256')
|
||||||
|
.update(cleaned.join(','))
|
||||||
|
.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
export { isOneOf };
|
let config = {};
|
||||||
export { nullOrEmpty };
|
export async function readConfigFromStorage(){
|
||||||
export { duringWorkingHoursOrNotSet };
|
return JSON.parse(await readFile(new URL('../conf/config.json', import.meta.url)));
|
||||||
export { getDirName };
|
}
|
||||||
export { config };
|
|
||||||
|
export async function refreshConfig(){
|
||||||
|
try {
|
||||||
|
config = await readConfigFromStorage();
|
||||||
|
//backwards compatability...
|
||||||
|
config.analyticsEnabled ??= null;
|
||||||
|
config.demoMode ??= false;
|
||||||
|
} catch (error) {
|
||||||
|
config = {...DEFAULT_CONFIG};
|
||||||
|
console.error('Error reading config file', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await refreshConfig();
|
||||||
|
|
||||||
|
export {isOneOf};
|
||||||
|
export {inDevMode};
|
||||||
|
export {nullOrEmpty};
|
||||||
|
export {duringWorkingHoursOrNotSet};
|
||||||
|
export {getDirName};
|
||||||
|
export {config};
|
||||||
|
export {buildHash};
|
||||||
export default {
|
export default {
|
||||||
isOneOf,
|
isOneOf,
|
||||||
nullOrEmpty,
|
nullOrEmpty,
|
||||||
duringWorkingHoursOrNotSet,
|
duringWorkingHoursOrNotSet,
|
||||||
getDirName,
|
getDirName,
|
||||||
config,
|
config,
|
||||||
};
|
};
|
||||||
|
|||||||
90
package.json
90
package.json
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "fredy",
|
"name": "fredy",
|
||||||
"version": "7.2.0",
|
"version": "11.0.1",
|
||||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node index.js",
|
"start": "node prod.js",
|
||||||
"dev": "yarn && rm -rf ./ui/public/* && vite",
|
"dev": "yarn && rm -rf ./ui/public/* && vite",
|
||||||
"ui": "rm -rf ./ui/public/* && vite",
|
"ui": "rm -rf ./ui/public/* && vite",
|
||||||
"prod": "yarn && vite build --emptyOutDir",
|
"prod": "yarn && vite build --emptyOutDir",
|
||||||
@@ -11,11 +11,6 @@
|
|||||||
"test": "mocha --loader=esmock --timeout 3000000 test/**/*.test.js",
|
"test": "mocha --loader=esmock --timeout 3000000 test/**/*.test.js",
|
||||||
"lint": "eslint ./index.js ./lib/**/*.js ./test/**/*.js ./ui/src/**/*.jsx"
|
"lint": "eslint ./index.js ./lib/**/*.js ./test/**/*.js ./ui/src/**/*.jsx"
|
||||||
},
|
},
|
||||||
"husky": {
|
|
||||||
"hooks": {
|
|
||||||
"pre-commit": "lint-staged"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.js": [
|
"*.js": [
|
||||||
@@ -45,7 +40,7 @@
|
|||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.0.0",
|
"node": ">=20.0.0",
|
||||||
"npm": ">=7.0.0"
|
"npm": ">=7.0.0"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
@@ -55,54 +50,59 @@
|
|||||||
"Firefox ESR"
|
"Firefox ESR"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@douyinfe/semi-ui": "2.31.0",
|
"@douyinfe/semi-ui": "2.72.3",
|
||||||
"@rematch/core": "2.2.0",
|
"@rematch/core": "2.2.0",
|
||||||
"@rematch/loading": "2.1.2",
|
"@rematch/loading": "2.1.2",
|
||||||
"@sendgrid/mail": "7.7.0",
|
"@sendgrid/mail": "8.1.4",
|
||||||
"@vitejs/plugin-react": "3.1.0",
|
"@vitejs/plugin-react": "4.3.4",
|
||||||
"better-sqlite3": "8.2.0",
|
"better-sqlite3": "^11.7.2",
|
||||||
"body-parser": "1.20.2",
|
"body-parser": "1.20.3",
|
||||||
"cookie-session": "2.0.0",
|
"cheerio": "^1.0.0",
|
||||||
"handlebars": "4.7.7",
|
"cookie-session": "2.1.0",
|
||||||
"highcharts": "10.3.3",
|
"handlebars": "4.7.8",
|
||||||
"highcharts-react-official": "3.2.0",
|
"highcharts": "12.1.2",
|
||||||
|
"highcharts-react-official": "3.2.1",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"lowdb": "5.1.0",
|
"lowdb": "6.0.1",
|
||||||
"markdown": "^0.5.0",
|
"markdown": "^0.5.0",
|
||||||
"nanoid": "4.0.1",
|
"mixpanel": "^0.18.0",
|
||||||
"node-fetch": "3.3.1",
|
"nanoid": "5.0.9",
|
||||||
"node-mailjet": "6.0.2",
|
"node-fetch": "3.3.2",
|
||||||
"query-string": "8.1.0",
|
"node-mailjet": "6.0.6",
|
||||||
"react": "18.2.0",
|
"package-up": "^5.0.0",
|
||||||
"react-dom": "18.2.0",
|
"puppeteer": "^23.11.1",
|
||||||
"react-redux": "8.0.5",
|
"puppeteer-extra": "^3.3.6",
|
||||||
|
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||||
|
"query-string": "9.1.1",
|
||||||
|
"react": "18.3.1",
|
||||||
|
"react-dom": "18.3.1",
|
||||||
|
"react-redux": "9.2.0",
|
||||||
"react-router": "5.2.1",
|
"react-router": "5.2.1",
|
||||||
"react-router-dom": "5.3.0",
|
"react-router-dom": "5.3.0",
|
||||||
"redux": "4.2.1",
|
"redux": "5.0.1",
|
||||||
"redux-thunk": "2.4.2",
|
"redux-thunk": "3.1.0",
|
||||||
"restana": "4.9.7",
|
"restana": "4.9.9",
|
||||||
"serve-static": "1.15.0",
|
"serve-static": "1.16.2",
|
||||||
"slack": "11.0.2",
|
"slack": "11.0.2",
|
||||||
"string-similarity": "^4.0.4",
|
"string-similarity": "^4.0.4",
|
||||||
"vite": "4.2.0",
|
"vite": "5.4.11"
|
||||||
"x-ray": "2.3.4"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.21.3",
|
"@babel/core": "7.26.0",
|
||||||
"@babel/eslint-parser": "7.21.3",
|
"@babel/eslint-parser": "7.25.9",
|
||||||
"@babel/preset-env": "7.20.2",
|
"@babel/preset-env": "7.26.0",
|
||||||
"@babel/preset-react": "7.18.6",
|
"@babel/preset-react": "7.26.3",
|
||||||
"chai": "4.3.7",
|
"chai": "5.1.2",
|
||||||
"eslint": "8.36.0",
|
"eslint": "8.56.0",
|
||||||
"eslint-config-prettier": "8.7.0",
|
"eslint-config-prettier": "8.8.0",
|
||||||
"eslint-plugin-react": "7.32.2",
|
"eslint-plugin-react": "7.37.3",
|
||||||
"esmock": "2.1.0",
|
"esmock": "2.6.9",
|
||||||
"history": "5.3.0",
|
"history": "5.3.0",
|
||||||
"husky": "4.3.8",
|
"husky": "9.1.7",
|
||||||
"less": "4.1.3",
|
"less": "4.2.1",
|
||||||
"lint-staged": "13.2.0",
|
"lint-staged": "15.3.0",
|
||||||
"mocha": "10.2.0",
|
"mocha": "10.8.2",
|
||||||
"prettier": "2.8.5",
|
"prettier": "3.4.2",
|
||||||
"redux-logger": "3.0.6"
|
"redux-logger": "3.0.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
prod.js
Normal file
2
prod.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
import('./index.js');
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { providerConfig, mockFredy } from '../utils.js';
|
import { providerConfig, mockFredy } from '../utils.js';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
import * as provider from '../../lib/provider/einsAImmobilien.js';
|
import * as provider from '../../lib/provider/einsAImmobilien.js';
|
||||||
|
|
||||||
const expect = chai.expect;
|
|
||||||
|
|
||||||
describe('#einsAImmobilien testsuite()', () => {
|
describe('#einsAImmobilien testsuite()', () => {
|
||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
@@ -22,7 +20,7 @@ describe('#einsAImmobilien testsuite()', () => {
|
|||||||
expect(notificationObj.serviceName).to.equal('einsAImmobilien');
|
expect(notificationObj.serviceName).to.equal('einsAImmobilien');
|
||||||
notificationObj.payload.forEach((notify) => {
|
notificationObj.payload.forEach((notify) => {
|
||||||
/** check the actual structure **/
|
/** check the actual structure **/
|
||||||
expect(notify.id).to.be.a('number');
|
expect(notify.id).to.be.a('string');
|
||||||
expect(notify.price).to.be.a('string');
|
expect(notify.price).to.be.a('string');
|
||||||
expect(notify.size).to.be.a('string');
|
expect(notify.size).to.be.a('string');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.title).to.be.a('string');
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { providerConfig, mockFredy } from '../utils.js';
|
import { providerConfig, mockFredy } from '../utils.js';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
import * as provider from '../../lib/provider/immobilienDe.js';
|
import * as provider from '../../lib/provider/immobilienDe.js';
|
||||||
const expect = chai.expect;
|
|
||||||
describe('#immobilien.de testsuite()', () => {
|
describe('#immobilien.de testsuite()', () => {
|
||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
import * as provider from '../../lib/provider/immonet.js';
|
import * as provider from '../../lib/provider/immonet.js';
|
||||||
import * as scrapingAnt from '../../lib/services/scrapingAnt.js';
|
|
||||||
const expect = chai.expect;
|
|
||||||
describe('#immonet testsuite()', () => {
|
describe('#immonet testsuite()', () => {
|
||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
@@ -13,13 +12,6 @@ describe('#immonet testsuite()', () => {
|
|||||||
it('should test immonet provider', async () => {
|
it('should test immonet provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
return await new Promise((resolve) => {
|
return await new Promise((resolve) => {
|
||||||
if (!scrapingAnt.isScrapingAntApiKeySet()) {
|
|
||||||
/* eslint-disable no-console */
|
|
||||||
console.info('Skipping Immonet test as ScrapingAnt Api Key is not set.');
|
|
||||||
/* eslint-enable no-console */
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immonet', similarityCache);
|
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immonet', similarityCache);
|
||||||
fredy.execute().then((listing) => {
|
fredy.execute().then((listing) => {
|
||||||
expect(listing).to.be.a('array');
|
expect(listing).to.be.a('array');
|
||||||
|
|||||||
@@ -1,48 +1,43 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
//import {get} from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import {/*mockFredy, */providerConfig} from '../utils.js';
|
||||||
import chai from 'chai';
|
//import {expect} from 'chai';
|
||||||
import * as provider from '../../lib/provider/immoscout.js';
|
import * as provider from '../../lib/provider/immoscout.js';
|
||||||
import * as scrapingAnt from '../../lib/services/scrapingAnt.js';
|
|
||||||
const expect = chai.expect;
|
|
||||||
describe('#immoscout testsuite()', () => {
|
describe('#immoscout testsuite()', () => {
|
||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
});
|
});
|
||||||
provider.init(providerConfig.immoscout, [], []);
|
provider.init(providerConfig.immoscout, [], []);
|
||||||
it('should test immoscout provider', async () => {
|
it('should test immoscout provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
//const Fredy = await mockFredy();
|
||||||
return await new Promise((resolve) => {
|
return await new Promise((resolve) => {
|
||||||
if (!scrapingAnt.isScrapingAntApiKeySet()) {
|
/* eslint-disable no-console */
|
||||||
/* eslint-disable no-console */
|
console.info('Skipping Immoscout test for now until we figured out how to surpass bot detection.');
|
||||||
console.info('Skipping Immoscout test as ScrapingAnt Api Key is not set.');
|
/* eslint-enable no-console */
|
||||||
/* eslint-enable no-console */
|
resolve();
|
||||||
resolve();
|
/*
|
||||||
return;
|
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immoscout', similarityCache);
|
||||||
}
|
fredy.execute().then((listing) => {
|
||||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immoscout', similarityCache);
|
expect(listing).to.be.a('array');
|
||||||
fredy.execute().then((listing) => {
|
const notificationObj = get();
|
||||||
expect(listing).to.be.a('array');
|
expect(notificationObj).to.be.a('object');
|
||||||
const notificationObj = get();
|
expect(notificationObj.serviceName).to.equal('immoscout');
|
||||||
expect(notificationObj).to.be.a('object');
|
notificationObj.payload.forEach((notify) => {
|
||||||
expect(notificationObj.serviceName).to.equal('immoscout');
|
expect(notify.id).to.be.a('number');
|
||||||
notificationObj.payload.forEach((notify) => {
|
expect(notify.price).to.be.a('string');
|
||||||
/** check the actual structure **/
|
expect(notify.size).to.be.a('string');
|
||||||
expect(notify.id).to.be.a('number');
|
expect(notify.title).to.be.a('string');
|
||||||
expect(notify.price).to.be.a('string');
|
expect(notify.link).to.be.a('string');
|
||||||
expect(notify.size).to.be.a('string');
|
expect(notify.address).to.be.a('string');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.price).that.does.include('€');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.size).that.does.include('m²');
|
||||||
expect(notify.address).to.be.a('string');
|
expect(notify.title).to.be.not.empty;
|
||||||
/** check the values if possible **/
|
expect(notify.link).that.does.include('https://www.immobilienscout24.de');
|
||||||
expect(notify.price).that.does.include('€');
|
expect(notify.address).to.be.not.empty;
|
||||||
expect(notify.size).that.does.include('m²');
|
});
|
||||||
expect(notify.title).to.be.not.empty;
|
resolve();
|
||||||
expect(notify.link).that.does.include('https://www.immobilienscout24.de');
|
});*/
|
||||||
expect(notify.address).to.be.not.empty;
|
});
|
||||||
});
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
import * as provider from '../../lib/provider/immoswp.js';
|
import * as provider from '../../lib/provider/immoswp.js';
|
||||||
const expect = chai.expect;
|
|
||||||
describe('#immoswp testsuite()', () => {
|
describe('#immoswp testsuite()', () => {
|
||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
@@ -25,12 +25,10 @@ describe('#immoswp testsuite()', () => {
|
|||||||
expect(notify.size).to.be.a('string');
|
expect(notify.size).to.be.a('string');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.title).to.be.a('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.link).to.be.a('string');
|
||||||
expect(notify.address).to.be.a('string');
|
|
||||||
/** check the values if possible **/
|
/** check the values if possible **/
|
||||||
expect(notify.price).that.does.include('€');
|
expect(notify.price).that.does.include('€');
|
||||||
expect(notify.title).to.be.not.empty;
|
expect(notify.title).to.be.not.empty;
|
||||||
expect(notify.link).that.does.include('https://immo.swp.de');
|
expect(notify.link).that.does.include('https://immo.swp.de');
|
||||||
expect(notify.address).to.be.not.empty;
|
|
||||||
});
|
});
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
import * as provider from '../../lib/provider/immowelt.js';
|
import * as provider from '../../lib/provider/immowelt.js';
|
||||||
const expect = chai.expect;
|
|
||||||
describe('#immowelt testsuite()', () => {
|
describe('#immowelt testsuite()', () => {
|
||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
import * as provider from '../../lib/provider/kleinanzeigen.js';
|
import * as provider from '../../lib/provider/kleinanzeigen.js';
|
||||||
const expect = chai.expect;
|
|
||||||
describe('#kleinanzeigen testsuite()', () => {
|
describe('#kleinanzeigen testsuite()', () => {
|
||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
@@ -20,13 +20,13 @@ describe('#kleinanzeigen testsuite()', () => {
|
|||||||
expect(notificationObj.serviceName).to.equal('kleinanzeigen');
|
expect(notificationObj.serviceName).to.equal('kleinanzeigen');
|
||||||
notificationObj.payload.forEach((notify) => {
|
notificationObj.payload.forEach((notify) => {
|
||||||
/** check the actual structure **/
|
/** check the actual structure **/
|
||||||
expect(notify.id).to.be.a('number');
|
expect(notify.id).to.be.a('string');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.title).to.be.a('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.link).to.be.a('string');
|
||||||
expect(notify.address).to.be.a('string');
|
expect(notify.address).to.be.a('string');
|
||||||
/** check the values if possible **/
|
/** check the values if possible **/
|
||||||
expect(notify.title).to.be.not.empty;
|
expect(notify.title).to.be.not.empty;
|
||||||
expect(notify.link).that.does.include('https://www.ebay-kleinanzeigen.de');
|
expect(notify.link).that.does.include('https://www.kleinanzeigen.de');
|
||||||
expect(notify.address).to.be.not.empty;
|
expect(notify.address).to.be.not.empty;
|
||||||
});
|
});
|
||||||
resolve();
|
resolve();
|
||||||
|
|||||||
@@ -1,36 +1,36 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import {get} from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import {mockFredy, providerConfig} from '../utils.js';
|
||||||
import chai from 'chai';
|
import {expect} from 'chai';
|
||||||
import * as provider from '../../lib/provider/neubauKompass.js';
|
import * as provider from '../../lib/provider/neubauKompass.js';
|
||||||
const expect = chai.expect;
|
|
||||||
describe('#neubauKompass testsuite()', () => {
|
describe('#neubauKompass testsuite()', () => {
|
||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
});
|
});
|
||||||
provider.init(providerConfig.neubauKompass, [], []);
|
provider.init(providerConfig.neubauKompass, [], []);
|
||||||
it('should test neubauKompass provider', async () => {
|
it('should test neubauKompass provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
return await new Promise((resolve) => {
|
return await new Promise((resolve) => {
|
||||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'neubauKompass', similarityCache);
|
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 = 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');
|
||||||
expect(notify.address).to.be.not.empty;
|
expect(notify.address).to.be.not.empty;
|
||||||
});
|
});
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,11 +9,11 @@
|
|||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"immonet": {
|
"immonet": {
|
||||||
"url": "https://www.immonet.de/immobiliensuche/beta?pageoffset=1&listsize=100&objecttype=1&locationname=D%C3%BCsseldorf&acid=&actype=&district=8717&district=8718&district=8719&district=8720&district=8721&district=8723&district=8724&district=8725&district=8727&district=8728&district=8729&district=8730&district=8731&district=8732&district=8733&district=8737&district=8738&district=8741&district=8745&district=8747&district=8750&district=8752&district=8754&district=8755&district=8756&district=8759&district=8760&district=8761&district=8763&district=8764&district=8765&ajaxIsRadiusActive=false&sortby=19&suchart=1&radius=0&pcatmtypes=1_1&pCatMTypeStoragefield=&parentcat=1&marketingtype=1&fromprice=&toprice=420000&fromarea=90&toarea=&fromplotarea=&toplotarea=&fromrooms=3&torooms=&objectcat=225&objectcat=18&objectcat=17&objectcat=12&objectcat=16&objectcat=181&objectcat=14&objectcat=15&objectcat=226&objectcat=13&wbs=-1&fromyear=&toyear=",
|
"url": "https://www.immonet.de/classified-search?distributionTypes=Buy,Buy_Auction,Compulsory_Auction&estateTypes=House,Apartment&locations=AD08DE2112&order=Default&m=homepage_new_search_classified_search_result",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"immowelt": {
|
"immowelt": {
|
||||||
"url": "https://www.immowelt.de/liste/duesseldorf/wohnungen/kaufen?d=true&rmi=3&sd=DESC&sf=TIMESTAMP&sp=1",
|
"url": "https://www.immowelt.de/classified-search?distributionTypes=Buy,Buy_Auction,Compulsory_Auction&estateTypes=House,Apartment&locations=AD08DE2350",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"immoscout": {
|
"immoscout": {
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"kleinanzeigen": {
|
"kleinanzeigen": {
|
||||||
"url": "https://www.ebay-kleinanzeigen.de/s-immobilien/duesseldorf/anzeige:angebote/wohnung/k0c195l2068r5",
|
"url": "https://www.kleinanzeigen.de/s-immobilien/duesseldorf/anzeige:angebote/wohnung/k0c195l2068r5",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"neubauKompass": {
|
"neubauKompass": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import utils from '../../lib/utils.js';
|
import utils from '../../lib/utils.js';
|
||||||
import assert from 'assert';
|
import assert from 'assert';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
const expect = chai.expect;
|
|
||||||
const fakeWorkingHoursConfig = (from, to) => ({
|
const fakeWorkingHoursConfig = (from, to) => ({
|
||||||
workingHours: {
|
workingHours: {
|
||||||
to,
|
to,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
import * as provider from '../../lib/provider/wgGesucht.js';
|
import * as provider from '../../lib/provider/wgGesucht.js';
|
||||||
const expect = chai.expect;
|
|
||||||
describe('#wgGesucht testsuite()', () => {
|
describe('#wgGesucht testsuite()', () => {
|
||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
@@ -23,7 +23,6 @@ describe('#wgGesucht testsuite()', () => {
|
|||||||
expect(notify.id).to.be.a('string');
|
expect(notify.id).to.be.a('string');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.title).to.be.a('string');
|
||||||
expect(notify.details).to.be.a('string');
|
expect(notify.details).to.be.a('string');
|
||||||
expect(notify.size).to.be.a('string');
|
|
||||||
expect(notify.price).to.be.a('string');
|
expect(notify.price).to.be.a('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.link).to.be.a('string');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
import { readFile } from 'fs/promises';
|
import { readFile } from 'fs/promises';
|
||||||
import mutator from '../../lib/services/queryStringMutator.js';
|
import mutator from '../../lib/services/queryStringMutator.js';
|
||||||
import queryString from 'query-string';
|
import queryString from 'query-string';
|
||||||
const expect = chai.expect;
|
|
||||||
|
|
||||||
const data = await readFile(new URL('./testData.json', import.meta.url));
|
const data = await readFile(new URL('./testData.json', import.meta.url));
|
||||||
|
|
||||||
const testData = JSON.parse(data);
|
const testData = JSON.parse(data);
|
||||||
|
|
||||||
let _provider = await Promise.all(
|
let _provider = await Promise.all(
|
||||||
fs.readdirSync('./lib/provider/').map(async (integPath) => await import(`../../lib/provider/${integPath}`))
|
fs.readdirSync('./lib/provider/').map(async (integPath) => await import(`../../lib/provider/${integPath}`)),
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"url": "https://www.immowelt.de/liste/40589/wohnungen/mieten?d=true&sd=DESC&sf=PRIMARY_PRICE_AMOUNT&sp=1",
|
"url": "https://www.immowelt.de/classified-search?distributionTypes=Buy,Buy_Auction,Compulsory_Auction&estateTypes=House,Apartment&locations=AD08DE2350",
|
||||||
"shouldBecome": "https://www.immowelt.de/liste/40589/wohnungen/mieten?d=true&sd=DESC&sf=TIMESTAMP&sp=1",
|
"shouldBecome": "https://www.immowelt.de/classified-search?distributionTypes=Buy,Buy_Auction,Compulsory_Auction&estateTypes=House,Apartment&locations=AD08DE2350&order=DateDesc",
|
||||||
"id": "immowelt"
|
"id": "immowelt"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import SimilarityCacheEntry from '../../lib/services/similarity-check/SimilarityCacheEntry.js';
|
import SimilarityCacheEntry from '../../lib/services/similarity-check/SimilarityCacheEntry.js';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
const expect = chai.expect;
|
|
||||||
describe('similarityCheck', () => {
|
describe('similarityCheck', () => {
|
||||||
describe('#similarityCheck()', () => {
|
describe('#similarityCheck()', () => {
|
||||||
it('should be false', () => {
|
it('should be false', () => {
|
||||||
@@ -29,10 +29,10 @@ describe('similarityCheck', () => {
|
|||||||
it('should be false', () => {
|
it('should be false', () => {
|
||||||
const check = new SimilarityCacheEntry(0);
|
const check = new SimilarityCacheEntry(0);
|
||||||
check.setCacheEntry(
|
check.setCacheEntry(
|
||||||
'The index is known by several other names, especially Sørensen–Dice index,[3] Sørensen index and Dice\'s coefficient. Other variations include the "similarity coefficient" or "index", such as Dice similarity coefficient (DSC). Common alternate spellings for Sørensen are Sorenson, Soerenson and Sörenson, and all three can also be seen with the –sen ending.'
|
'The index is known by several other names, especially Sørensen–Dice index,[3] Sørensen index and Dice\'s coefficient. Other variations include the "similarity coefficient" or "index", such as Dice similarity coefficient (DSC). Common alternate spellings for Sørensen are Sorenson, Soerenson and Sörenson, and all three can also be seen with the –sen ending.',
|
||||||
);
|
);
|
||||||
check.setCacheEntry(
|
check.setCacheEntry(
|
||||||
'where |X| and |Y| are the cardinalities of the two sets (i.e. the number of elements in each set). The Sørensen index equals twice the number of elements common to both sets divided by the sum of the number of elements in each set.'
|
'where |X| and |Y| are the cardinalities of the two sets (i.e. the number of elements in each set). The Sørensen index equals twice the number of elements common to both sets divided by the sum of the number of elements in each set.',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
16
test/utils/utils.test.js
Normal file
16
test/utils/utils.test.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { expect } from 'chai';
|
||||||
|
import {buildHash} from '../../lib/utils.js';
|
||||||
|
|
||||||
|
describe('utilsCheck', () => {
|
||||||
|
describe('#utilsCheck()', () => {
|
||||||
|
it('should be null when null input', () => {
|
||||||
|
expect(buildHash(null)).to.be.null;
|
||||||
|
});
|
||||||
|
it('should be null when null empty', () => {
|
||||||
|
expect(buildHash('')).to.be.null;
|
||||||
|
});
|
||||||
|
it('should return a value', () => {
|
||||||
|
expect(buildHash('bla', '', null)).to.be.a.string;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
152
ui/src/App.jsx
152
ui/src/App.jsx
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, {useEffect} from 'react';
|
||||||
|
|
||||||
import InsufficientPermission from './components/permission/InsufficientPermission';
|
import InsufficientPermission from './components/permission/InsufficientPermission';
|
||||||
import PermissionAwareRoute from './components/permission/PermissionAwareRoute';
|
import PermissionAwareRoute from './components/permission/PermissionAwareRoute';
|
||||||
@@ -6,90 +6,106 @@ import GeneralSettings from './views/generalSettings/GeneralSettings';
|
|||||||
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 JobInsight from './views/jobs/insights/JobInsight.jsx';
|
import JobInsight from './views/jobs/insights/JobInsight.jsx';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import {useDispatch, useSelector} from 'react-redux';
|
||||||
import { Switch, Redirect } from 'react-router-dom';
|
import {Switch, Redirect} from 'react-router-dom';
|
||||||
import Logout from './components/logout/Logout';
|
import Logout from './components/logout/Logout';
|
||||||
import Logo from './components/logo/Logo';
|
import Logo from './components/logo/Logo';
|
||||||
import Menu from './components/menu/Menu';
|
import Menu from './components/menu/Menu';
|
||||||
import Login from './views/login/Login';
|
import Login from './views/login/Login';
|
||||||
import Users from './views/user/Users';
|
import Users from './views/user/Users';
|
||||||
import Jobs from './views/jobs/Jobs';
|
import Jobs from './views/jobs/Jobs';
|
||||||
import { Route } from 'react-router';
|
import {Route} from 'react-router';
|
||||||
|
|
||||||
import './App.less';
|
import './App.less';
|
||||||
|
import TrackingModal from './components/tracking/TrackingModal.jsx';
|
||||||
|
import {Banner} from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
export default function FredyApp() {
|
export default function FredyApp() {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const currentUser = useSelector((state) => state.user.currentUser);
|
const currentUser = useSelector((state) => state.user.currentUser);
|
||||||
|
const settings = useSelector((state) => state.generalSettings.settings);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function init() {
|
async function init() {
|
||||||
await dispatch.user.getCurrentUser();
|
await dispatch.user.getCurrentUser();
|
||||||
if (!needsLogin()) {
|
if (!needsLogin()) {
|
||||||
await dispatch.provider.getProvider();
|
await dispatch.provider.getProvider();
|
||||||
await dispatch.jobs.getJobs();
|
await dispatch.jobs.getJobs();
|
||||||
await dispatch.jobs.getProcessingTimes();
|
await dispatch.jobs.getProcessingTimes();
|
||||||
await dispatch.notificationAdapter.getAdapter();
|
await dispatch.notificationAdapter.getAdapter();
|
||||||
}
|
await dispatch.generalSettings.getGeneralSettings();
|
||||||
setLoading(false);
|
}
|
||||||
}
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
init();
|
init();
|
||||||
}, [currentUser?.userId]);
|
}, [currentUser?.userId]);
|
||||||
|
|
||||||
const needsLogin = () => {
|
const needsLogin = () => {
|
||||||
return currentUser == null || Object.keys(currentUser).length === 0;
|
return currentUser == null || Object.keys(currentUser).length === 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isAdmin = () => currentUser != null && currentUser.isAdmin;
|
const isAdmin = () => currentUser != null && currentUser.isAdmin;
|
||||||
|
|
||||||
const login = () => (
|
const login = () => (
|
||||||
<Switch>
|
|
||||||
<Route name="Login" path={'/login'} component={Login} />
|
|
||||||
<Redirect from="*" to={'/login'} />
|
|
||||||
</Switch>
|
|
||||||
);
|
|
||||||
|
|
||||||
return loading ? null : needsLogin() ? (
|
|
||||||
login()
|
|
||||||
) : (
|
|
||||||
<div className="app">
|
|
||||||
<div className="app__container">
|
|
||||||
<Logout />
|
|
||||||
<Logo width={190} white />
|
|
||||||
<Menu isAdmin={isAdmin()} />
|
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route name="Insufficient Permission" path={'/403'} component={InsufficientPermission} />
|
<Route name="Login" path={'/login'} component={Login}/>
|
||||||
<Route name="Create new Job" path={'/jobs/new'} component={JobMutation} />
|
<Redirect from="*" to={'/login'}/>
|
||||||
<Route name="Edit a Job" path={'/jobs/edit/:jobId'} component={JobMutation} />
|
|
||||||
<Route name="Insights into a Job" path={'/jobs/insights/:jobId'} component={JobInsight} />
|
|
||||||
<Route name="Job overview" path={'/jobs'} component={Jobs} />
|
|
||||||
<PermissionAwareRoute
|
|
||||||
name="Create new User"
|
|
||||||
path="/users/new"
|
|
||||||
component={<UserMutator />}
|
|
||||||
currentUser={currentUser}
|
|
||||||
/>
|
|
||||||
<PermissionAwareRoute
|
|
||||||
name="Edit a user"
|
|
||||||
path="/users/edit/:userId"
|
|
||||||
component={<UserMutator />}
|
|
||||||
currentUser={currentUser}
|
|
||||||
/>
|
|
||||||
<PermissionAwareRoute name="Users" path="/users" component={<Users />} currentUser={currentUser} />
|
|
||||||
<PermissionAwareRoute
|
|
||||||
name="General Settings"
|
|
||||||
path="/generalSettings"
|
|
||||||
component={<GeneralSettings />}
|
|
||||||
currentUser={currentUser}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Redirect from="/" to={'/jobs'} />
|
|
||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
);
|
||||||
</div>
|
|
||||||
);
|
return loading ? null : needsLogin() ? (
|
||||||
|
login()
|
||||||
|
) : (
|
||||||
|
<div className="app">
|
||||||
|
<div className="app__container">
|
||||||
|
<Logout/>
|
||||||
|
<Logo width={190} white/>
|
||||||
|
<Menu isAdmin={isAdmin()}/>
|
||||||
|
|
||||||
|
{settings.demoMode && (
|
||||||
|
<>
|
||||||
|
<Banner fullMode={true}
|
||||||
|
type="info"
|
||||||
|
bordered
|
||||||
|
closeIcon={null}
|
||||||
|
description="You're currently viewing the demo version of Fredy. Jobs won't scrape websites, and any changes you make will be reverted at midnight."
|
||||||
|
/>
|
||||||
|
<br/>
|
||||||
|
</>)}
|
||||||
|
{(settings.analyticsEnabled === null && !settings.demoMode) && <TrackingModal/>}
|
||||||
|
<Switch>
|
||||||
|
<Route name="Insufficient Permission" path={'/403'} component={InsufficientPermission}/>
|
||||||
|
<Route name="Create new Job" path={'/jobs/new'} component={JobMutation}/>
|
||||||
|
<Route name="Edit a Job" path={'/jobs/edit/:jobId'} component={JobMutation}/>
|
||||||
|
<Route name="Insights into a Job" path={'/jobs/insights/:jobId'} component={JobInsight}/>
|
||||||
|
<Route name="Job overview" path={'/jobs'} component={Jobs}/>
|
||||||
|
<PermissionAwareRoute
|
||||||
|
name="Create new User"
|
||||||
|
path="/users/new"
|
||||||
|
component={<UserMutator/>}
|
||||||
|
currentUser={currentUser}
|
||||||
|
/>
|
||||||
|
<PermissionAwareRoute
|
||||||
|
name="Edit a user"
|
||||||
|
path="/users/edit/:userId"
|
||||||
|
component={<UserMutator/>}
|
||||||
|
currentUser={currentUser}
|
||||||
|
/>
|
||||||
|
<PermissionAwareRoute name="Users" path="/users" component={<Users/>} currentUser={currentUser}/>
|
||||||
|
<PermissionAwareRoute
|
||||||
|
name="General Settings"
|
||||||
|
path="/generalSettings"
|
||||||
|
component={<GeneralSettings/>}
|
||||||
|
currentUser={currentUser}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Redirect from="/" to={'/jobs'}/>
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
FredyApp.displayName = 'FredyApp';
|
FredyApp.displayName = 'FredyApp';
|
||||||
|
|||||||
48
ui/src/components/tracking/TrackingModal.jsx
Normal file
48
ui/src/components/tracking/TrackingModal.jsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {Modal} from '@douyinfe/semi-ui';
|
||||||
|
import Logo from '../logo/Logo.jsx';
|
||||||
|
import {xhrPost} from '../../services/xhr.js';
|
||||||
|
|
||||||
|
import './TrackingModal.less';
|
||||||
|
|
||||||
|
const saveResponse = async (analyticsEnabled) => {
|
||||||
|
await xhrPost('/api/admin/generalSettings', {
|
||||||
|
analyticsEnabled
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TrackingModal() {
|
||||||
|
|
||||||
|
return <Modal
|
||||||
|
visible={true}
|
||||||
|
onOk={async () => {
|
||||||
|
await saveResponse(true);
|
||||||
|
location.reload();
|
||||||
|
}}
|
||||||
|
onCancel={async () => {
|
||||||
|
await saveResponse(false);
|
||||||
|
location.reload();
|
||||||
|
}}
|
||||||
|
maskClosable={false}
|
||||||
|
closable={false}
|
||||||
|
okText="Yes! I want to help"
|
||||||
|
cancelText="No, thanks"
|
||||||
|
>
|
||||||
|
<Logo white/>
|
||||||
|
<div className="trackingModal__description">
|
||||||
|
<p>Hey 👋</p>
|
||||||
|
<p>Fed up with popups? Yeah, me too. But this one’s important, and I promise it will only appear once ;)</p>
|
||||||
|
<p>Fredy is completely free (and will always remain free). If you’d like, you can support me by donating
|
||||||
|
through my GitHub, but there’s absolutely no obligation to do so.</p>
|
||||||
|
<p>However, it would be a huge
|
||||||
|
help if you’d allow me to collect some analytical data. Wait, before you click "no", let me explain. If
|
||||||
|
you
|
||||||
|
agree, Fredy will send a ping to my Mixpanel project each time it runs.</p>
|
||||||
|
<p>The data includes: names of
|
||||||
|
active adapters/providers, OS, architecture, Node version, and language. The information is entirely
|
||||||
|
anonymous and helps me understand which adapters/providers are most frequently used.</p>
|
||||||
|
<p>Thanks🤘</p>
|
||||||
|
</div>
|
||||||
|
</Modal>;
|
||||||
|
|
||||||
|
}
|
||||||
5
ui/src/components/tracking/TrackingModal.less
Normal file
5
ui/src/components/tracking/TrackingModal.less
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.trackingModal {
|
||||||
|
&__description {
|
||||||
|
margin-top:10rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
ui/src/services/rematch/models/demoMode.js
Normal file
24
ui/src/services/rematch/models/demoMode.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { xhrGet } from '../../xhr';
|
||||||
|
export const demoMode = {
|
||||||
|
state: {
|
||||||
|
demoMode: false,
|
||||||
|
},
|
||||||
|
reducers: {
|
||||||
|
setDemoMode: (state, payload) => {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
demoMode: payload.demoMode,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
effects: {
|
||||||
|
async getDemoMode() {
|
||||||
|
try {
|
||||||
|
const response = await xhrGet('/api/demo');
|
||||||
|
this.setDemoMode(response.json);
|
||||||
|
} catch (Exception) {
|
||||||
|
console.error('Error while trying to get resource for api/demo. Error:', Exception);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ import { provider } from './models/provider';
|
|||||||
import { createLogger } from 'redux-logger';
|
import { createLogger } from 'redux-logger';
|
||||||
import { jobs } from './models/jobs';
|
import { jobs } from './models/jobs';
|
||||||
import { user } from './models/user';
|
import { user } from './models/user';
|
||||||
|
import { demoMode } from './models/demoMode.js';
|
||||||
import { init } from '@rematch/core';
|
import { init } from '@rematch/core';
|
||||||
const middleware = [];
|
const middleware = [];
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
@@ -16,6 +17,7 @@ const store = init({
|
|||||||
models: {
|
models: {
|
||||||
notificationAdapter,
|
notificationAdapter,
|
||||||
generalSettings,
|
generalSettings,
|
||||||
|
demoMode,
|
||||||
provider,
|
provider,
|
||||||
jobs,
|
jobs,
|
||||||
user,
|
user,
|
||||||
|
|||||||
@@ -1,246 +1,261 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import {useDispatch, useSelector} from 'react-redux';
|
||||||
|
|
||||||
import { Divider, Input, Radio, TimePicker, Button, RadioGroup } from '@douyinfe/semi-ui';
|
import {Divider, TimePicker, Button, Checkbox} from '@douyinfe/semi-ui';
|
||||||
import { InputNumber } from '@douyinfe/semi-ui';
|
import {InputNumber} from '@douyinfe/semi-ui';
|
||||||
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 {SegmentPart} from '../../components/segment/SegmentPart';
|
||||||
import { Banner, Toast } from '@douyinfe/semi-ui';
|
import {Banner, Toast} from '@douyinfe/semi-ui';
|
||||||
import { IconSave, IconCalendar, IconKey, IconRefresh, IconSignal } from '@douyinfe/semi-icons';
|
import {IconSave, IconCalendar, IconRefresh, IconSignal, IconLineChartStroked, IconSearch} from '@douyinfe/semi-icons';
|
||||||
import './GeneralSettings.less';
|
import './GeneralSettings.less';
|
||||||
|
|
||||||
function formatFromTimestamp(ts) {
|
function formatFromTimestamp(ts) {
|
||||||
const date = new Date(ts);
|
const date = new Date(ts);
|
||||||
return `${date.getHours()}:${date.getMinutes() > 9 ? date.getMinutes() : '0' + date.getMinutes()}`;
|
return `${date.getHours()}:${date.getMinutes() > 9 ? date.getMinutes() : '0' + date.getMinutes()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFromTBackend(time) {
|
function formatFromTBackend(time) {
|
||||||
if (time == null || time.length === 0) {
|
if (time == null || time.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const date = new Date();
|
const date = new Date();
|
||||||
const split = time.split(':');
|
const split = time.split(':');
|
||||||
date.setHours(split[0]);
|
date.setHours(split[0]);
|
||||||
date.setMinutes(split[1]);
|
date.setMinutes(split[1]);
|
||||||
return date.getTime();
|
return date.getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
const GeneralSettings = function GeneralSettings() {
|
const GeneralSettings = function GeneralSettings() {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
|
||||||
const settings = useSelector((state) => state.generalSettings.settings);
|
const settings = useSelector((state) => state.generalSettings.settings);
|
||||||
|
|
||||||
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 [workingHourFrom, setWorkingHourFrom] = React.useState(null);
|
||||||
const [scrapingAntProxy, setScrapingAntProxy] = React.useState('');
|
const [workingHourTo, setWorkingHourTo] = React.useState(null);
|
||||||
const [workingHourFrom, setWorkingHourFrom] = React.useState(null);
|
const [demoMode, setDemoMode] = React.useState(null);
|
||||||
const [workingHourTo, setWorkingHourTo] = React.useState(null);
|
const [analyticsEnabled, setAnalyticsEnabled] = React.useState(null);
|
||||||
React.useEffect(() => {
|
|
||||||
async function init() {
|
|
||||||
await dispatch.generalSettings.getGeneralSettings();
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
init();
|
React.useEffect(() => {
|
||||||
}, []);
|
async function init() {
|
||||||
|
await dispatch.generalSettings.getGeneralSettings();
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
React.useEffect(() => {
|
init();
|
||||||
async function init() {
|
}, []);
|
||||||
setInterval(settings?.interval);
|
|
||||||
setPort(settings?.port);
|
|
||||||
setScrapingAntApiKey(settings?.scrapingAnt?.apiKey);
|
|
||||||
setWorkingHourFrom(settings?.workingHours?.from);
|
|
||||||
setWorkingHourTo(settings?.workingHours?.to);
|
|
||||||
setScrapingAntProxy(settings?.scrapingAnt?.proxy || 'datacenter');
|
|
||||||
}
|
|
||||||
|
|
||||||
init();
|
React.useEffect(() => {
|
||||||
}, [settings]);
|
async function init() {
|
||||||
|
setInterval(settings?.interval);
|
||||||
|
setPort(settings?.port);
|
||||||
|
setWorkingHourFrom(settings?.workingHours?.from);
|
||||||
|
setWorkingHourTo(settings?.workingHours?.to);
|
||||||
|
setAnalyticsEnabled(settings?.analyticsEnabled || false);
|
||||||
|
setDemoMode(settings?.demoMode || false);
|
||||||
|
}
|
||||||
|
|
||||||
const nullOrEmpty = (val) => val == null || val.length === 0;
|
init();
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
const throwMessage = (message, type) => {
|
const nullOrEmpty = (val) => val == null || val.length === 0;
|
||||||
if (type === 'error') {
|
|
||||||
Toast.error(message);
|
|
||||||
} else {
|
|
||||||
Toast.success(message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onStore = async () => {
|
const throwMessage = (message, type) => {
|
||||||
if (nullOrEmpty(interval)) {
|
if (type === 'error') {
|
||||||
throwMessage('Interval may not be empty.', 'error');
|
Toast.error(message);
|
||||||
return;
|
} else {
|
||||||
}
|
Toast.success(message);
|
||||||
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,
|
|
||||||
proxy: scrapingAntProxy,
|
|
||||||
},
|
|
||||||
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 (
|
const onStore = async () => {
|
||||||
<div>
|
if (nullOrEmpty(interval)) {
|
||||||
{!loading && (
|
throwMessage('Interval may not be empty.', 'error');
|
||||||
<React.Fragment>
|
return;
|
||||||
<Headline text="General Settings" />
|
}
|
||||||
<Banner
|
if (nullOrEmpty(port)) {
|
||||||
fullMode={false}
|
throwMessage('Port may not be empty.', 'error');
|
||||||
type="info"
|
return;
|
||||||
closeIcon={null}
|
}
|
||||||
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Info</div>}
|
if (
|
||||||
style={{ marginBottom: '1rem' }}
|
(!nullOrEmpty(workingHourFrom) && nullOrEmpty(workingHourTo)) ||
|
||||||
description="If you change any settings, you must restart Fredy afterwards."
|
(nullOrEmpty(workingHourFrom) && !nullOrEmpty(workingHourTo))
|
||||||
/>
|
) {
|
||||||
<div>
|
throwMessage('Working hours to and from must be set if either to or from has been set before.', 'error');
|
||||||
<SegmentPart
|
return;
|
||||||
name="Interval"
|
}
|
||||||
helpText="Interval in minutes for running queries against the configured services."
|
try {
|
||||||
Icon={IconRefresh}
|
await xhrPost('/api/admin/generalSettings', {
|
||||||
>
|
interval,
|
||||||
<InputNumber
|
port,
|
||||||
min={0}
|
workingHours: {
|
||||||
max={1440}
|
from: workingHourFrom,
|
||||||
placeholder="Interval in minutes"
|
to: workingHourTo,
|
||||||
value={interval}
|
},
|
||||||
formatter={(value) => `${value}`.replace(/\D/g, '')}
|
demoMode,
|
||||||
onChange={(value) => setInterval(value)}
|
analyticsEnabled
|
||||||
suffix={'minutes'}
|
});
|
||||||
/>
|
} catch (exception) {
|
||||||
</SegmentPart>
|
console.error(exception);
|
||||||
<Divider margin="1rem" />
|
if(exception?.json?.message != null){
|
||||||
<SegmentPart name="Port" helpText="Port on which Fredy is running." Icon={IconSignal}>
|
throwMessage(exception.json.message, 'error');
|
||||||
<InputNumber
|
}else {
|
||||||
min={0}
|
throwMessage('Error while trying to store settings.', 'error');
|
||||||
max={99999}
|
}
|
||||||
placeholder="Port"
|
return;
|
||||||
value={port}
|
}
|
||||||
formatter={(value) => `${value}`.replace(/\D/g, '')}
|
throwMessage('Settings stored successfully. We will reload your browser in 3 seconds.', 'success');
|
||||||
onChange={(value) => setPort(value)}
|
setTimeout(()=>{
|
||||||
/>
|
location.reload();
|
||||||
</SegmentPart>
|
}, 3000);
|
||||||
<Divider margin="1rem" />
|
};
|
||||||
<SegmentPart
|
|
||||||
name="ScrapingAnt Api Key"
|
|
||||||
helpText="The api key for ScrapingAnt is used to be able to scrape Immoscout."
|
|
||||||
Icon={IconKey}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="ScrapingAnt Api Key"
|
|
||||||
value={scrapingAntApiKey}
|
|
||||||
onChange={(val) => setScrapingAntApiKey(val)}
|
|
||||||
/>
|
|
||||||
</SegmentPart>
|
|
||||||
<Divider margin="1rem" />
|
|
||||||
<SegmentPart
|
|
||||||
name="ScrapingAnt proxy settings"
|
|
||||||
helpText="Scraping ant provides different proxies."
|
|
||||||
Icon={IconKey}
|
|
||||||
>
|
|
||||||
<Banner
|
|
||||||
fullMode={false}
|
|
||||||
type="info"
|
|
||||||
closeIcon={null}
|
|
||||||
title={
|
|
||||||
<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>
|
|
||||||
ScrapingAnt is needed to scrape Immoscout. ScrapingAnt itself is using 2 different types of proxies
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
style={{ marginBottom: '1rem' }}
|
|
||||||
description={
|
|
||||||
<div>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<RadioGroup value={scrapingAntProxy} onChange={(e) => setScrapingAntProxy(e.target.value)}>
|
return (
|
||||||
<Radio name="datacenter" value="datacenter" checked={scrapingAntProxy === 'datacenter'}>
|
<div>
|
||||||
Datacenter proxy
|
{!loading && (
|
||||||
</Radio>
|
<React.Fragment>
|
||||||
<Radio name="residential" value="residential" checked={scrapingAntProxy === 'residential'}>
|
<Headline text="General Settings"/>
|
||||||
Residential proxy
|
<div>
|
||||||
</Radio>
|
<SegmentPart
|
||||||
</RadioGroup>
|
name="Interval"
|
||||||
</SegmentPart>
|
helpText="Interval in minutes for running queries against the configured services."
|
||||||
<Divider margin="1rem" />
|
Icon={IconRefresh}
|
||||||
<SegmentPart
|
>
|
||||||
name="Working hours"
|
<InputNumber
|
||||||
helpText="During this hours, Fredy will search for new apartments. If nothing is configured, Fredy will search around the clock."
|
min={0}
|
||||||
Icon={IconCalendar}
|
max={1440}
|
||||||
>
|
placeholder="Interval in minutes"
|
||||||
<div className="generalSettings__timePickerContainer">
|
value={interval}
|
||||||
<TimePicker
|
formatter={(value) => `${value}`.replace(/\D/g, '')}
|
||||||
format={'HH:mm'}
|
onChange={(value) => setInterval(value)}
|
||||||
insetLabel="From"
|
suffix={'minutes'}
|
||||||
value={formatFromTBackend(workingHourFrom)}
|
/>
|
||||||
placeholder=""
|
</SegmentPart>
|
||||||
onChange={(val) => {
|
<Divider margin="1rem"/>
|
||||||
setWorkingHourFrom(val == null ? null : formatFromTimestamp(val));
|
<SegmentPart name="Port" helpText="Port on which Fredy is running." Icon={IconSignal}>
|
||||||
}}
|
<InputNumber
|
||||||
/>
|
min={0}
|
||||||
<TimePicker
|
max={99999}
|
||||||
format={'HH:mm'}
|
placeholder="Port"
|
||||||
insetLabel="Until"
|
value={port}
|
||||||
value={formatFromTBackend(workingHourTo)}
|
formatter={(value) => `${value}`.replace(/\D/g, '')}
|
||||||
placeholder=""
|
onChange={(value) => setPort(value)}
|
||||||
onChange={(val) => {
|
/>
|
||||||
setWorkingHourTo(val == null ? null : formatFromTimestamp(val));
|
</SegmentPart>
|
||||||
}}
|
<Divider margin="1rem"/>
|
||||||
/>
|
<SegmentPart
|
||||||
</div>
|
name="Working hours"
|
||||||
</SegmentPart>
|
helpText="During this hours, Fredy will search for new apartments. If nothing is configured, Fredy will search around the clock."
|
||||||
<Divider margin="1rem" />
|
Icon={IconCalendar}
|
||||||
<Button type="primary" theme="solid" onClick={onStore} icon={<IconSave />}>
|
>
|
||||||
Save
|
<div className="generalSettings__timePickerContainer">
|
||||||
</Button>
|
<TimePicker
|
||||||
</div>
|
format={'HH:mm'}
|
||||||
</React.Fragment>
|
insetLabel="From"
|
||||||
)}
|
value={formatFromTBackend(workingHourFrom)}
|
||||||
</div>
|
placeholder=""
|
||||||
);
|
onChange={(val) => {
|
||||||
|
setWorkingHourFrom(val == null ? null : formatFromTimestamp(val));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TimePicker
|
||||||
|
format={'HH:mm'}
|
||||||
|
insetLabel="Until"
|
||||||
|
value={formatFromTBackend(workingHourTo)}
|
||||||
|
placeholder=""
|
||||||
|
onChange={(val) => {
|
||||||
|
setWorkingHourTo(val == null ? null : formatFromTimestamp(val));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SegmentPart>
|
||||||
|
<Divider margin="1rem"/>
|
||||||
|
|
||||||
|
<SegmentPart
|
||||||
|
name="Analytics"
|
||||||
|
helpText="Insights into the usage of Fredy."
|
||||||
|
Icon={IconLineChartStroked}
|
||||||
|
>
|
||||||
|
<Banner
|
||||||
|
fullMode={false}
|
||||||
|
type="info"
|
||||||
|
closeIcon={null}
|
||||||
|
title={
|
||||||
|
<div style={{fontWeight: 600, fontSize: '14px', lineHeight: '20px'}}>
|
||||||
|
Explanation
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
style={{marginBottom: '1rem'}}
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
Analytics are disabled by default. If you choose to enable them, we will begin tracking the following:<br/>
|
||||||
|
<ul>
|
||||||
|
<li>Name of active provider (e.g. Immoscout)</li>
|
||||||
|
<li>Name of active adapter (e.g. Console)</li>
|
||||||
|
<li>language</li>
|
||||||
|
<li>os</li>
|
||||||
|
<li>node version</li>
|
||||||
|
<li>arch</li>
|
||||||
|
</ul>
|
||||||
|
The data is sent anonymously and helps me understand which providers or adapters are being used the most. In the end it helps me to improve fredy.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
checked={analyticsEnabled}
|
||||||
|
onChange={(e) => setAnalyticsEnabled(e.target.checked)}
|
||||||
|
> Enabled
|
||||||
|
</Checkbox>
|
||||||
|
|
||||||
|
</SegmentPart>
|
||||||
|
|
||||||
|
<Divider margin="1rem"/>
|
||||||
|
|
||||||
|
<SegmentPart
|
||||||
|
name="Demo Mode"
|
||||||
|
helpText="If enabled, Fredy runs in demo mode."
|
||||||
|
Icon={IconSearch}
|
||||||
|
>
|
||||||
|
<Banner
|
||||||
|
fullMode={false}
|
||||||
|
type="info"
|
||||||
|
closeIcon={null}
|
||||||
|
title={
|
||||||
|
<div style={{fontWeight: 600, fontSize: '14px', lineHeight: '20px'}}>
|
||||||
|
Explanation
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
style={{marginBottom: '1rem'}}
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
In demo mode, Fredy will not (really) search for any real estates. Fredy is in a lockdown mode. Also
|
||||||
|
all database files will be set back to the default values at midnight.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
checked={demoMode}
|
||||||
|
onChange={(e) => setDemoMode(e.target.checked)}
|
||||||
|
> Enabled
|
||||||
|
</Checkbox>
|
||||||
|
|
||||||
|
</SegmentPart>
|
||||||
|
|
||||||
|
<Divider margin="1rem"/>
|
||||||
|
<Button type="primary" theme="solid" onClick={onStore} icon={<IconSave/>}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default GeneralSettings;
|
export default GeneralSettings;
|
||||||
|
|||||||
@@ -1,67 +1,32 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { format } from '../../services/time/timeService';
|
import {format} from '../../services/time/timeService';
|
||||||
import { Card, Descriptions, Divider } from '@douyinfe/semi-ui';
|
import {Banner, Descriptions} from '@douyinfe/semi-ui';
|
||||||
import { IconBolt } from '@douyinfe/semi-icons';
|
|
||||||
export default function ProcessingTimes({ processingTimes }) {
|
|
||||||
const { Meta } = Card;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Descriptions
|
|
||||||
row
|
|
||||||
size="small"
|
|
||||||
style={{
|
|
||||||
backgroundColor: '#35363c',
|
|
||||||
borderRadius: '4px',
|
|
||||||
padding: '10px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Descriptions.Item itemKey="Processing Interval">{processingTimes.interval} min</Descriptions.Item>
|
|
||||||
{processingTimes.lastRun && (
|
|
||||||
<>
|
|
||||||
<Descriptions.Item itemKey="Last run">{format(processingTimes.lastRun)}</Descriptions.Item>
|
|
||||||
<Descriptions.Item itemKey="Next run">
|
|
||||||
{format(processingTimes.lastRun + processingTimes.interval * 60000)}
|
|
||||||
</Descriptions.Item>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Descriptions>
|
|
||||||
|
|
||||||
{processingTimes.scrapingAntData != null && (
|
export default function ProcessingTimes({processingTimes = {}}) {
|
||||||
|
if (Object.keys(processingTimes).length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
<>
|
<>
|
||||||
<Divider margin="1rem" />
|
<Descriptions
|
||||||
<Card
|
row
|
||||||
style={{ backgroundColor: '#35363c' }}
|
size="small"
|
||||||
title={
|
style={{
|
||||||
<Meta
|
backgroundColor: '#35363c',
|
||||||
title="Remaining ScrapingAnt calls"
|
borderRadius: '4px',
|
||||||
description="Information about your Scraping Ant Plan"
|
padding: '10px',
|
||||||
avatar={<IconBolt />}
|
}}
|
||||||
/>
|
>
|
||||||
}
|
<Descriptions.Item itemKey="Processing Interval">{processingTimes.interval} min</Descriptions.Item>
|
||||||
>
|
{processingTimes.lastRun && (
|
||||||
<p>Plan: {processingTimes.scrapingAntData.plan_name}</p>
|
<>
|
||||||
<p>
|
<Descriptions.Item itemKey="Last run">{format(processingTimes.lastRun)}</Descriptions.Item>
|
||||||
Duration: {format(new Date(processingTimes.scrapingAntData.start_date))} -{' '}
|
<Descriptions.Item itemKey="Next run">
|
||||||
{format(new Date(processingTimes.scrapingAntData.end_date))}
|
{format(processingTimes.lastRun + processingTimes.interval * 60000)}
|
||||||
<br />
|
</Descriptions.Item>
|
||||||
Credits: {processingTimes.scrapingAntData.remained_credits}/
|
</>
|
||||||
{processingTimes.scrapingAntData.plan_total_credits} (250 credits per call)
|
)}
|
||||||
</p>
|
</Descriptions>
|
||||||
If you want to scrape Immoscout or Immonet more often, you have to purchase a premium account of{' '}
|
|
||||||
<a href="https://scrapingant.com/" target="_blank" rel="noreferrer">
|
|
||||||
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.)
|
|
||||||
</Card>
|
|
||||||
</>
|
</>
|
||||||
)}
|
);
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
*/
|
|
||||||
|
|||||||
@@ -96,17 +96,15 @@ export default function ProviderMutator({ onVisibilityChanged, visible = false,
|
|||||||
fullMode={false}
|
fullMode={false}
|
||||||
type="warning"
|
type="warning"
|
||||||
closeIcon={null}
|
closeIcon={null}
|
||||||
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>ScrapingAnt</div>}
|
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Warning</div>}
|
||||||
style={{ marginBottom: '1rem' }}
|
style={{ marginBottom: '1rem' }}
|
||||||
description={
|
description={
|
||||||
<div>
|
<div>
|
||||||
<p>
|
<p>
|
||||||
If you chose Immoscout or Immonet as a provider, make sure to also add the scrapingAnt apiKey to the config.json.
|
Immoscout will not work at the moment due to advanced bot detection. I'm currently working on a fix.
|
||||||
(See readme)
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Do not forget to sort the results by date before copying the url to Fredy, so that Fredy always captures
|
Until a fix has been released, Immoscout won't yield any results.
|
||||||
the latest search results.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,88 +1,102 @@
|
|||||||
import React from 'react';
|
import React, {useEffect} from 'react';
|
||||||
|
|
||||||
import cityBackground from '../../assets/city_background.jpg';
|
import cityBackground from '../../assets/city_background.jpg';
|
||||||
import Logo from '../../components/logo/Logo';
|
import Logo from '../../components/logo/Logo';
|
||||||
import { xhrPost } from '../../services/xhr';
|
import {xhrPost} from '../../services/xhr';
|
||||||
import { useHistory } from 'react-router';
|
import {useHistory} from 'react-router';
|
||||||
import { useDispatch } from 'react-redux';
|
import {useDispatch, useSelector} from 'react-redux';
|
||||||
import { Input, Button, Banner } from '@douyinfe/semi-ui';
|
import {Input, Button, Banner} from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
import './login.less';
|
import './login.less';
|
||||||
import { IconUser, IconLock } from '@douyinfe/semi-icons';
|
import {IconUser, IconLock} from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
const [username, setUserName] = React.useState('');
|
||||||
|
const [password, setPassword] = React.useState('');
|
||||||
|
const [error, setError] = React.useState(null);
|
||||||
|
const demoMode = useSelector((state) => state.demoMode.demoMode || false);
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
const [username, setUserName] = React.useState('');
|
useEffect(() => {
|
||||||
const [password, setPassword] = React.useState('');
|
async function init() {
|
||||||
const [error, setError] = React.useState(null);
|
await dispatch.demoMode.getDemoMode();
|
||||||
|
}
|
||||||
|
|
||||||
const history = useHistory();
|
init();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const tryLogin = async () => {
|
const tryLogin = async () => {
|
||||||
if (username.length === 0 || password.length === 0) {
|
if (username.length === 0 || password.length === 0) {
|
||||||
setError('Username and password are mandatory.');
|
setError('Username and password are mandatory.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await xhrPost('/api/login', {
|
await xhrPost('/api/login', {
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (Exception) {
|
} catch (Exception) {
|
||||||
setError('Login not successful...');
|
setError('Login not successful...');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await dispatch.user.getCurrentUser();
|
await dispatch.user.getCurrentUser();
|
||||||
history.push('/jobs');
|
history.push('/jobs');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="login">
|
<div className="login">
|
||||||
<div className="login__bgImage" style={{ background: `url("${cityBackground}")` }} />
|
<div className="login__bgImage" style={{background: `url("${cityBackground}")`}}/>
|
||||||
<Logo />
|
<Logo/>
|
||||||
<form>
|
<form>
|
||||||
<div className="login__loginWrapper">
|
<div className="login__loginWrapper">
|
||||||
{error && <Banner type="danger" closeIcon={null} description={error} />}
|
{error && <Banner type="danger" closeIcon={null} description={error}/>}
|
||||||
<Input
|
<Input
|
||||||
size="large"
|
size="large"
|
||||||
prefix={<IconUser />}
|
prefix={<IconUser/>}
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
value={username}
|
value={username}
|
||||||
showClear
|
showClear
|
||||||
style={{ marginTop: error ? '1rem' : '4rem' }}
|
style={{marginTop: error ? '1rem' : '4rem'}}
|
||||||
autofocus
|
autoFocus
|
||||||
onChange={(value) => setUserName(value)}
|
onChange={(value) => setUserName(value)}
|
||||||
onKeyPress={async (e) => {
|
onKeyPress={async (e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
await tryLogin();
|
await tryLogin();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
size="large"
|
size="large"
|
||||||
mode="password"
|
mode="password"
|
||||||
prefix={<IconLock />}
|
prefix={<IconLock/>}
|
||||||
value={password}
|
value={password}
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
style={{ marginTop: '2rem' }}
|
style={{marginTop: '2rem'}}
|
||||||
onChange={(value) => setPassword(value)}
|
onChange={(value) => setPassword(value)}
|
||||||
onKeyPress={async (e) => {
|
onKeyPress={async (e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
await tryLogin();
|
await tryLogin();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button type="primary" onClick={tryLogin} theme="solid" style={{ marginTop: '3rem' }}>
|
<Button type="primary" onClick={tryLogin} theme="solid" style={{marginTop: '3rem'}}>
|
||||||
Login
|
Login
|
||||||
</Button>
|
</Button>
|
||||||
|
<br/>
|
||||||
|
{demoMode && <Banner fullMode={true}
|
||||||
|
type="info"
|
||||||
|
bordered
|
||||||
|
closeIcon={null}
|
||||||
|
description="This is the demo version of Fredy. Use 'demo' as both the username and password to log in."
|
||||||
|
/>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
);
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Login.displayName = 'Login';
|
Login.displayName = 'Login';
|
||||||
|
|||||||
Reference in New Issue
Block a user