mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3cb3a5881 | ||
|
|
111ef8be43 | ||
|
|
35feb772d7 | ||
|
|
1bf012f13e | ||
|
|
933dc3fc64 | ||
|
|
42c48fdceb | ||
|
|
f07aa0a06d | ||
|
|
92db8219b4 | ||
|
|
8ba3a53779 | ||
|
|
e7db4e23f5 | ||
|
|
06c4ebb975 | ||
|
|
b075e09ac2 | ||
|
|
f215ab53db | ||
|
|
4ed92b246f | ||
|
|
4a9b60633a | ||
|
|
2123c1024b | ||
|
|
35767e6774 | ||
|
|
bf77ba2667 | ||
|
|
827c7e7321 | ||
|
|
7b63dc72cb | ||
|
|
fd42b57010 | ||
|
|
f5917af8f3 | ||
|
|
a85400d570 | ||
|
|
8ce6668c78 | ||
|
|
2d8121a708 | ||
|
|
172c039c79 | ||
|
|
4ab1fd9294 | ||
|
|
50b3fde075 | ||
|
|
1a3fc6f94d | ||
|
|
26ed42230a | ||
|
|
6f4defdc1b | ||
|
|
f798aed342 | ||
|
|
27e098c244 | ||
|
|
37948be0d3 | ||
|
|
cc7bbb77c4 | ||
|
|
96da0b7892 | ||
|
|
72993312c7 | ||
|
|
17b4bad2e4 | ||
|
|
fbad4456d7 | ||
|
|
deec626feb | ||
|
|
88c6641485 | ||
|
|
f4eedda658 | ||
|
|
d2b80561f8 | ||
|
|
3bda88a075 | ||
|
|
86465e0076 | ||
|
|
d947dad488 | ||
|
|
23ef434fe1 | ||
|
|
5e6d92c5be | ||
|
|
4ba098e0b6 | ||
|
|
2d1a9a0452 | ||
|
|
6fbee3e7c6 | ||
|
|
46775c3662 | ||
|
|
1feb5bfda1 | ||
|
|
3ec9ed3b2a | ||
|
|
75a536d5ab | ||
|
|
f3cded7e5d | ||
|
|
d7c9c4bf76 |
@@ -203,10 +203,6 @@ module.exports = {
|
||||
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/self-closing-comp.md
|
||||
'react/self-closing-comp': 'warn',
|
||||
|
||||
// Enforce spaces before the closing bracket of self-closing JSX elements
|
||||
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-space-before-closing.md
|
||||
'react/jsx-space-before-closing': ['warn', 'always'],
|
||||
|
||||
// Enforce component methods order
|
||||
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/sort-comp.md
|
||||
'react/sort-comp': 'off',
|
||||
@@ -237,7 +233,7 @@ module.exports = {
|
||||
|
||||
// only .jsx files may have JSX
|
||||
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-filename-extension.md
|
||||
'react/jsx-filename-extension': ['error', { extensions: ['.js'] }],
|
||||
'react/jsx-filename-extension': ['error', { extensions: ['.jsx'] }],
|
||||
|
||||
// prevent accidental JS comments from being injected into JSX as text
|
||||
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-no-comment-textnodes.md
|
||||
@@ -282,15 +278,5 @@ module.exports = {
|
||||
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/no-children-prop.md
|
||||
'react/no-children-prop': 'warn',
|
||||
|
||||
// Validate whitespace in and around the JSX opening and closing brackets
|
||||
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-tag-spacing.md
|
||||
'react/jsx-tag-spacing': [
|
||||
'warn',
|
||||
{
|
||||
closingSlash: 'never',
|
||||
beforeSelfClosing: 'always',
|
||||
afterOpening: 'never',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
1
.github/workflows/docker.yml
vendored
1
.github/workflows/docker.yml
vendored
@@ -44,3 +44,4 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64, linux/arm64
|
||||
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v2.5.1
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 20
|
||||
cache: 'yarn'
|
||||
- run: yarn install
|
||||
- run: yarn run test
|
||||
|
||||
22
Dockerfile
22
Dockerfile
@@ -1,18 +1,20 @@
|
||||
# syntax=docker/dockerfile:1.3
|
||||
FROM node:16-alpine AS builder
|
||||
COPY --chown=1000:1000 . /fredy
|
||||
FROM node:20
|
||||
|
||||
WORKDIR /fredy
|
||||
USER 1000
|
||||
|
||||
COPY . /fredy
|
||||
|
||||
RUN yarn install
|
||||
|
||||
RUN yarn global add pm2
|
||||
|
||||
RUN yarn run prod
|
||||
|
||||
FROM node:16-alpine
|
||||
COPY --from=builder --chown=1000:1000 /fredy /fredy
|
||||
RUN mkdir /db /conf && \
|
||||
chown 1000:1000 /db /conf && \
|
||||
chmod 777 -R /db/ && \
|
||||
ln -s /db /fredy/db && ln -s /conf /fredy/conf
|
||||
|
||||
EXPOSE 9998
|
||||
USER 1000
|
||||
VOLUME [ "/conf", "/db" ]
|
||||
WORKDIR /fredy
|
||||
CMD node index.js --no-daemon
|
||||
|
||||
CMD pm2-runtime index.js
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 Christian Kellner
|
||||
Copyright (c) 2024 Christian Kellner
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
24
README.md
24
README.md
@@ -17,7 +17,7 @@ _Fredy_ is supported by JetBrains under Open Source Support Program
|
||||
|
||||
## Usage
|
||||
|
||||
- Make sure to use Node.js 16 or above
|
||||
- Make sure to use Node.js 20 or above
|
||||
- Run the following commands:
|
||||
```ssh
|
||||
yarn (or npm install)
|
||||
@@ -27,14 +27,11 @@ yarn run start
|
||||
_Fredy_ will start with the default port, set to `9998`. You can access _Fredy_ by opening your browser at `http://localhost:9998`. The default login is `admin`, both for username and password. You should change the password as soon as possible when you plan to run Fredy on a server.
|
||||
|
||||
<p align="center">
|
||||
<img alt="Job Configuration" src="https://github.com/orangecoding/fredy/blob/master/doc/screenshot__1.png" width="30%">
|
||||
<img alt="Job Configuration" src="https://github.com/orangecoding/fredy/blob/master/doc/screenshot1.png" width="30%">
|
||||
|
||||
<img alt="Job Analytics" src="https://github.com/orangecoding/fredy/blob/master/doc/screenshot2.png" width="30%">
|
||||
<img alt="Job Analytics" src="https://github.com/orangecoding/fredy/blob/master/doc/screenshot_2.png" width="30%">
|
||||
|
||||
<img alt="Job Overview" width="30%" src="https://github.com/orangecoding/fredy/blob/master/doc/screenshot3.png">
|
||||
</p>
|
||||
<p align="center">
|
||||
|
||||
<img alt="Job Overview" width="30%" src="https://github.com/orangecoding/fredy/blob/master/doc/screenshot_3.png">
|
||||
</p>
|
||||
|
||||
## Understanding the fundamentals
|
||||
@@ -81,15 +78,20 @@ yarn run test
|
||||
# Architecture
|
||||

|
||||
|
||||
### Immoscout
|
||||
I have added **experimental** support for Immoscout. Immoscout is somewhat special, because they have decided to secure their service from bots using Re-Capture. Finding a way around this is barely possible. For _Fredy_ to be able to bypass this check, I'm using a service called [ScrapingAnt](https://scrapingant.com/). The trick is to use a headless browser, rotating proxies and (once successfully validated) to re-send the cookies each time.
|
||||
### Immoscout / Immonet / NeubauKompass
|
||||
I have added **experimental** support for Immoscout, Immonet and NeubauKompass. They all are somewhat special, because they have decided to secure their service from bots using Re-Capture. Finding a way around this is barely possible. For _Fredy_ to be able to bypass this check, I'm using a service called [ScrapingAnt](https://scrapingant.com/). The trick is to use a headless browser, rotating proxies and (once successfully validated) to re-send the cookies each time.
|
||||
|
||||
To be able to use Immoscout, you need to create an account at ScrapingAnt. Configure the API key in the "General Settings" tab (visible when logged in as administrator).
|
||||
To be able to use Immoscout / Immonet, you need to create an account at ScrapingAnt. Configure the API key in the "General Settings" tab (visible when logged in as administrator).
|
||||
The rest will be handled by _Fredy_. Keep in mind, the support is experimental. There might be bugs and you might not always pass the re-capture check, but most of the time it works rather well :)
|
||||
|
||||
If you need more than the 1000 API calls allowed per month, I'd suggest opting for a paid account... ScrapingAnt loves OpenSource, therefore they have decided to give all _Fredy_ users a 10% discount by using the code **FREDY10** (Disclaimer: I do not earn any money for recommending their service).
|
||||
|
||||
### Contribution guidelines
|
||||
### 👐 Contributing
|
||||
Thanks to all the people who already contributed!
|
||||
|
||||
<a href="https://github.com/orangecoding/fredy/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=orangecoding/fredy" />
|
||||
</a>
|
||||
|
||||
See [Contributing](https://github.com/orangecoding/fredy/blob/master/CONTRIBUTING.md)
|
||||
|
||||
|
||||
BIN
doc/screenshot1.png
Normal file
BIN
doc/screenshot1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 279 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 380 KiB |
BIN
doc/screenshot_2.png
Normal file
BIN
doc/screenshot_2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 323 KiB |
BIN
doc/screenshot_3.png
Normal file
BIN
doc/screenshot_3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 202 KiB |
@@ -1,4 +1,4 @@
|
||||
version: '3.3'
|
||||
version: '3.8'
|
||||
services:
|
||||
fredy:
|
||||
container_name: fredy
|
||||
@@ -13,3 +13,4 @@ services:
|
||||
- ./db:/db
|
||||
ports:
|
||||
- 9998:9998
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -4,12 +4,11 @@
|
||||
<meta charset="UTF-8"
|
||||
name="viewport"
|
||||
content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/semantic-ui@2/dist/semantic.min.css">
|
||||
<meta name="google" content="notranslate">
|
||||
|
||||
<title>Fredy</title>
|
||||
</head>
|
||||
<body>
|
||||
<body theme-mode="dark">
|
||||
|
||||
<div id="fredy" style="position: absolute;top: 0;left: 0;right: 0;bottom: 0;"></div>
|
||||
</body>
|
||||
|
||||
@@ -45,15 +45,15 @@ class FredyRuntime {
|
||||
_getListings(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = this._providerId;
|
||||
if (scrapingAnt.isImmoscout(id) && !scrapingAnt.isScrapingAntApiKeySet()) {
|
||||
const error = 'Immoscout can only be used with if you have set an apikey for scrapingAnt.';
|
||||
if (scrapingAnt.needScrapingAnt(id) && !scrapingAnt.isScrapingAntApiKeySet()) {
|
||||
const error = 'Immoscout or Immonet can only be used with if you have set an apikey for scrapingAnt.';
|
||||
/* eslint-disable no-console */
|
||||
console.log(error);
|
||||
/* eslint-enable no-console */
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
const u = scrapingAnt.isImmoscout(id) ? scrapingAnt.transformUrlForScrapingAnt(url, id) : url;
|
||||
const u = scrapingAnt.needScrapingAnt(id) ? scrapingAnt.transformUrlForScrapingAnt(url, id) : url;
|
||||
try {
|
||||
if (this._providerConfig.paginate != null) {
|
||||
xray(u, this._providerConfig.crawlContainer, [this._providerConfig.crawlFields])
|
||||
@@ -87,7 +87,10 @@ class FredyRuntime {
|
||||
return listings.map(this._providerConfig.normalize);
|
||||
}
|
||||
_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) {
|
||||
const newListings = listings.filter((o) => getKnownListings(this._jobKey, this._providerId)[o.id] == null);
|
||||
|
||||
@@ -13,7 +13,7 @@ import files from 'serve-static';
|
||||
import path from 'path';
|
||||
import { getDirName } from '../utils.js';
|
||||
const service = restana();
|
||||
const staticService = files(path.join(getDirName(), '../../ui/public'));
|
||||
const staticService = files(path.join(getDirName(), '../ui/public'));
|
||||
const PORT = config.port || 9998;
|
||||
|
||||
service.use(bodyParser.json());
|
||||
|
||||
@@ -28,7 +28,7 @@ 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/v1/usage?x-api-key=${config.scrapingAnt.apiKey}`);
|
||||
const response = await fetch(`https://api.scrapingant.com/v2/usage?x-api-key=${config.scrapingAnt.apiKey}`);
|
||||
scrapingAntData = await response.json();
|
||||
} catch (Exception) {
|
||||
console.error('Could not query plan data from scraping ant.', Exception);
|
||||
|
||||
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);
|
||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||
const { apiPublicKey, apiPrivateKey, receiver, from } = notificationConfig.find(
|
||||
(adapter) => adapter.id === 'mailJet'
|
||||
(adapter) => adapter.id === config.id,
|
||||
).fields;
|
||||
const to = receiver
|
||||
.trim()
|
||||
@@ -18,7 +18,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
||||
Email: r.trim(),
|
||||
}));
|
||||
return mailjet
|
||||
.connect(apiPublicKey, apiPrivateKey)
|
||||
.apiConnect(apiPublicKey, apiPrivateKey)
|
||||
.post('send', { version: 'v3.1' })
|
||||
.request({
|
||||
Messages: [
|
||||
|
||||
@@ -2,13 +2,13 @@ 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 { 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 jobName = job == null ? jobKey : job.name;
|
||||
let message = `### *${jobName}* (${serviceName}) found **${newListings.length}** new listings:\n\n`;
|
||||
message += `| Title | Address | Size | Price |\n|:----|:----|:----|:----|\n`;
|
||||
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, {
|
||||
method: 'POST',
|
||||
|
||||
51
lib/notification/adapter/ntfy.js
Normal file
51
lib/notification/adapter/ntfy.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { markdown2Html } from '../../services/markdown.js';
|
||||
import { getJob } from '../../services/storage/jobStorage.js';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||
const { priority, server, topic } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||
const job = getJob(jobKey);
|
||||
const jobName = job == null ? jobKey : job.name;
|
||||
const promises = newListings.map((newListing) => {
|
||||
const message = `Address: ${newListing.address} Size: ${newListing.size.replace(/2m/g, '$m^2$')} Price: ${
|
||||
newListing.price
|
||||
}`;
|
||||
return fetch(server, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
topic: topic,
|
||||
message: message,
|
||||
title: newListing.title,
|
||||
tags: [serviceName, jobName],
|
||||
priority: parseInt(priority),
|
||||
click: newListing.link,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
return Promise.all(promises);
|
||||
};
|
||||
export const config = {
|
||||
id: 'ntfy',
|
||||
name: 'ntfy',
|
||||
readme: markdown2Html('lib/notification/adapter/ntfy.md'),
|
||||
description: 'Fredy will send new listings to your ntfy.',
|
||||
fields: {
|
||||
priority: {
|
||||
type: 'number',
|
||||
label: 'Priority',
|
||||
description: 'The priority of the send notification.',
|
||||
},
|
||||
server: {
|
||||
type: 'text',
|
||||
label: 'Server-URL',
|
||||
description: 'The server url to the send the notification to.',
|
||||
},
|
||||
topic: {
|
||||
type: 'text',
|
||||
label: 'topic',
|
||||
description:
|
||||
'The topic where fredy should send notifications to. The topic is a secret, only known to you, make sure it is something not generic.',
|
||||
},
|
||||
},
|
||||
};
|
||||
5
lib/notification/adapter/ntfy.md
Normal file
5
lib/notification/adapter/ntfy.md
Normal file
@@ -0,0 +1,5 @@
|
||||
### ntfy Adapter
|
||||
|
||||
For ntfy, you need to create a topic on your preferred ntfy instance. This is pretty easy. Please visit the steps in the [docs](https://docs.ntfy.sh/publish/) and follow the instructions.
|
||||
|
||||
As a result, you get the URL for configuration in fredy. In addition, the priority must be defined.
|
||||
50
lib/notification/adapter/pushover.js
Normal file
50
lib/notification/adapter/pushover.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { markdown2Html } from '../../services/markdown.js';
|
||||
import { getJob } from '../../services/storage/jobStorage.js';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||
const { token, user, device } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||
const job = getJob(jobKey);
|
||||
const jobName = job == null ? jobKey : job.name;
|
||||
const promises = newListings.map((newListing) => {
|
||||
const title = `${jobName} at ${serviceName}: ${newListing.title}`;
|
||||
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}`;
|
||||
return fetch('https://api.pushover.net/1/messages.json', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: token,
|
||||
user: user,
|
||||
message: message,
|
||||
device: device,
|
||||
title: title,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
return Promise.all(promises);
|
||||
};
|
||||
|
||||
export const config = {
|
||||
id: 'pushover',
|
||||
name: 'Pushover',
|
||||
readme: markdown2Html('lib/notification/adapter/pushover.md'),
|
||||
description: 'Fredy will send new listings to your mobile using Pushover.',
|
||||
fields: {
|
||||
token: {
|
||||
type: 'text',
|
||||
label: 'API token',
|
||||
description: 'Your application\'s API token.',
|
||||
},
|
||||
user: {
|
||||
type: 'text',
|
||||
label: 'User key',
|
||||
description: 'Your user/group key.',
|
||||
},
|
||||
device: {
|
||||
type: 'text',
|
||||
label: 'Device name',
|
||||
description: 'The device name to send your notification to. Messages may be addressed to multiple specific devices by joining them with a comma.',
|
||||
},
|
||||
},
|
||||
};
|
||||
5
lib/notification/adapter/pushover.md
Normal file
5
lib/notification/adapter/pushover.md
Normal file
@@ -0,0 +1,5 @@
|
||||
### Pushover Adapter
|
||||
|
||||
Refer to the [instructions](https://support.pushover.net/i7-what-is-pushover-and-how-do-i-use-it) to set up your Pushover application.
|
||||
|
||||
After setting up the application, please enter both your newly created User key and API token.
|
||||
@@ -1,7 +1,7 @@
|
||||
import sgMail from '@sendgrid/mail';
|
||||
import { markdown2Html } from '../../services/markdown.js';
|
||||
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);
|
||||
const msg = {
|
||||
templateId,
|
||||
|
||||
@@ -2,7 +2,7 @@ import Slack from 'slack';
|
||||
import { markdown2Html } from '../../services/markdown.js';
|
||||
const msg = Slack.chat.postMessage;
|
||||
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) =>
|
||||
msg({
|
||||
token,
|
||||
@@ -35,7 +35,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
||||
ts: new Date().getTime() / 1000,
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
);
|
||||
};
|
||||
export const config = {
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { markdown2Html } from '../../services/markdown.js';
|
||||
import Database from 'better-sqlite3';
|
||||
export const send = ({ serviceName, newListings, jobKey }) => {
|
||||
const db = new Database('db/listings.db');
|
||||
const fields = ['serviceName', 'jobKey', 'id', 'size', 'rooms', 'price', 'address', 'title', 'link', 'description'];
|
||||
db.prepare(`CREATE TABLE IF NOT EXISTS listing (${fields.join(' TEXT, ')} TEXT);`).run();
|
||||
const insert = db.prepare(`INSERT INTO listing (${fields.join(', ')}) VALUES (@${fields.join(', @')})`);
|
||||
newListings.map((listing) => {
|
||||
let insertListing = {};
|
||||
fields.map((field) => {
|
||||
insertListing[field] = listing[field];
|
||||
});
|
||||
insertListing.serviceName = serviceName;
|
||||
insertListing.jobKey = jobKey;
|
||||
insert.run(insertListing);
|
||||
});
|
||||
return Promise.resolve();
|
||||
};
|
||||
export const config = {
|
||||
id: 'sqlite',
|
||||
name: 'Sqlite',
|
||||
description: 'This adapter stores listings in a local sqlite3 database.',
|
||||
config: {},
|
||||
readme: markdown2Html('lib/notification/adapter/sqlite.md'),
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
### Sqlite Adapter
|
||||
|
||||
This adapter stores search results in an sqlite database in db/listings.db
|
||||
@@ -19,7 +19,7 @@ function shorten(str, len = 30) {
|
||||
return str.length > len ? str.substring(0, len) + '...' : str;
|
||||
}
|
||||
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 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
|
||||
@@ -30,7 +30,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
||||
(o) =>
|
||||
`<a href='${o.link}'><b>${shorten(o.title.replace(/\*/g, ''), 45).trim()}</b></a>\n` +
|
||||
[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
|
||||
|
||||
@@ -1,18 +1,40 @@
|
||||
import utils from '../utils.js';
|
||||
import utils, { buildHash } from '../utils.js';
|
||||
let appliedBlackList = [];
|
||||
|
||||
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`;
|
||||
return Object.assign(o, { size, link });
|
||||
const price = normalizePrice(o.price);
|
||||
const id = buildHash(o.id, price);
|
||||
return Object.assign(o, { id, price, size, link });
|
||||
}
|
||||
|
||||
/**
|
||||
* einsAImmobilien sometimes use a weird pricing label such as `775.700,00 EUR Kaufpreis ab 2.475 € mtl`.
|
||||
* Make sure to extract only the actual price out of the string.
|
||||
* @param price
|
||||
* @returns {*}
|
||||
*/
|
||||
function normalizePrice(price) {
|
||||
if (price == null) {
|
||||
return null;
|
||||
}
|
||||
const regex = /(\d{1,3}(?:\.\d{3})*,\d{2})\s?(EUR|€)/g;
|
||||
const result = price.match(regex);
|
||||
if (result == null || result.length === 0) {
|
||||
return price;
|
||||
}
|
||||
return result[0];
|
||||
}
|
||||
function applyBlacklist(o) {
|
||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
||||
return titleNotBlacklisted && descNotBlacklisted;
|
||||
}
|
||||
|
||||
const config = {
|
||||
url: null,
|
||||
crawlContainer: '.tabelle',
|
||||
@@ -23,7 +45,6 @@ const config = {
|
||||
size: '.tabelle .inner_object_data .data_boxes div:nth-child(1)',
|
||||
rooms: '.tabelle .inner_object_data .data_boxes div:nth-child(2)',
|
||||
title: '.tabelle .inner_object_data .tabelle_inhalt_titel_black | removeNewline | trim',
|
||||
description: '.tabelle .inner_object_data .objekt_beschreibung | removeNewline | trim',
|
||||
},
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import utils from '../utils.js';
|
||||
import utils, {buildHash} from '../utils.js';
|
||||
let appliedBlackList = [];
|
||||
function shortenLink(link) {
|
||||
return link.substring(0, link.indexOf('?'));
|
||||
@@ -7,12 +7,12 @@ function parseId(shortenedLink) {
|
||||
return shortenedLink.substring(shortenedLink.lastIndexOf('/') + 1);
|
||||
}
|
||||
function normalize(o) {
|
||||
const id = parseId(shortenLink(o.link));
|
||||
const size = o.size || 'N/A m²';
|
||||
const price = o.price || 'N/A €';
|
||||
const title = o.title || 'No title available';
|
||||
const address = o.address || 'No address available';
|
||||
const link = shortenLink(o.link);
|
||||
const id = buildHash(parseId(shortenLink(o.link)), o.price);
|
||||
return Object.assign(o, { id, price, size, title, address, link });
|
||||
}
|
||||
function applyBlacklist(o) {
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import utils from '../utils.js';
|
||||
import utils, {buildHash} from '../utils.js';
|
||||
let appliedBlackList = [];
|
||||
function normalize(o) {
|
||||
const id = parseInt(o.id.substring(o.id.indexOf('_') + 1, o.id.length));
|
||||
const size = o.size != null ? o.size.replace('Wohnfläche ', '') : 'N/A m²';
|
||||
const price = o.price.replace('Kaufpreis ', '');
|
||||
const address = o.address.split(' • ')[o.address.split(' • ').length - 1];
|
||||
const title = o.title || 'No title available';
|
||||
//normally we would just read the link from the source, but immonet decided to trick user by adding a click listener instead of
|
||||
//a href to do some weird reporting. (Very user friendly for handicaped ppl... not)
|
||||
const link = `https://www.immonet.de/angebot/${id}`;
|
||||
const link = o.id;
|
||||
const id = buildHash(o.id.substring(o.id.lastIndexOf('/') + 1, o.id.length), price);
|
||||
return Object.assign(o, { id, address, price, size, title, link });
|
||||
}
|
||||
function applyBlacklist(o) {
|
||||
@@ -18,14 +16,14 @@ function applyBlacklist(o) {
|
||||
}
|
||||
const config = {
|
||||
url: null,
|
||||
crawlContainer: '#result-list-stage .item',
|
||||
crawlContainer: '.content-wrapper-tiles .ng-star-inserted',
|
||||
sortByDateParam: 'sortby=19',
|
||||
crawlFields: {
|
||||
id: '@id',
|
||||
price: 'div[id*="selPrice_"] | trim',
|
||||
size: 'div[id*="selArea_"] | trim',
|
||||
title: '.item a img@title',
|
||||
address: '.item .box-25 .ellipsis .text-100 | removeNewline | trim',
|
||||
id: '.card a@href',
|
||||
title: '.card h3 |trim',
|
||||
price: '.card .has-font-300 .is-bold | trim',
|
||||
size: '.card .has-font-300 .ml-100 | trim',
|
||||
address: '.card span:nth-child(2) | trim',
|
||||
},
|
||||
paginate: '#idResultList .margin-bottom-6.margin-bottom-sm-12 .panel a.pull-right@href',
|
||||
normalize: normalize,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import utils from '../utils.js';
|
||||
import utils, {buildHash} from '../utils.js';
|
||||
let appliedBlackList = [];
|
||||
function nullOrEmpty(val) {
|
||||
return val == null || val.length === 0;
|
||||
@@ -6,8 +6,9 @@ function nullOrEmpty(val) {
|
||||
function normalize(o) {
|
||||
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 link = `https://www.immobilienscout24.de${o.link.substring(o.link.indexOf('/expose'))}`;
|
||||
return Object.assign(o, { title, address, link });
|
||||
const link = nullOrEmpty(o.link) ? 'NO LINK' : `https://www.immobilienscout24.de${o.link.substring(o.link.indexOf('/expose'))}`;
|
||||
const id = buildHash(o.id, o.price);
|
||||
return Object.assign(o, { id, title, address, link });
|
||||
}
|
||||
function applyBlacklist(o) {
|
||||
return !utils.isOneOf(o.title, appliedBlackList);
|
||||
@@ -20,7 +21,7 @@ const config = {
|
||||
id: '.result-list-entry@data-obid | int',
|
||||
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',
|
||||
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',
|
||||
address: '.result-list-entry .result-list-entry__map-link',
|
||||
},
|
||||
|
||||
@@ -1,44 +1,48 @@
|
||||
import utils from '../utils.js';
|
||||
import utils, {buildHash} from '../utils.js';
|
||||
|
||||
let appliedBlackList = [];
|
||||
|
||||
function normalize(o) {
|
||||
const id = o.id.substring(o.id.indexOf('-') + 1, o.id.length);
|
||||
const size = o.size || 'N/A m²';
|
||||
const price = (o.price || '--- €').replace('Preis auf Anfrage', '--- €');
|
||||
const address = o.address || 'No address available';
|
||||
const title = o.title || 'No title available';
|
||||
const link = `https://immo.swp.de/immobilien/${id}`;
|
||||
const description = o.description;
|
||||
return Object.assign(o, { id, address, price, size, title, link, description });
|
||||
const size = o.size || 'N/A m²';
|
||||
const price = (o.price || '--- €').replace('Preis auf Anfrage', '--- €');
|
||||
const title = o.title || 'No title available';
|
||||
const immoId = o.id.substring(o.id.indexOf('-') + 1, o.id.length);
|
||||
const link = `https://immo.swp.de/immobilien/${immoId}`;
|
||||
const description = o.description;
|
||||
const id = buildHash(immoId, price);
|
||||
return Object.assign(o, {id, price, size, title, link, description});
|
||||
}
|
||||
|
||||
function applyBlacklist(o) {
|
||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
||||
return titleNotBlacklisted && descNotBlacklisted;
|
||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
||||
return titleNotBlacklisted && descNotBlacklisted;
|
||||
}
|
||||
|
||||
const config = {
|
||||
url: null,
|
||||
crawlContainer: '.js-serp-item',
|
||||
sortByDateParam: 's=most_recently_updated_first',
|
||||
crawlFields: {
|
||||
id: '@id',
|
||||
price: 'div.item__spec.item-spec-price | trim',
|
||||
size: 'div.item__spec.item-spec-area | trim',
|
||||
title: 'a.js-item-title-link@title',
|
||||
address: 'div.item__locality | removeNewline | trim',
|
||||
description: 'div.item__main-info-points.clearfix p small | removeNewline | trim',
|
||||
},
|
||||
paginate: 'li.page-item.pagination__item a.page-link@href',
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
url: null,
|
||||
crawlContainer: '.js-serp-item',
|
||||
sortByDateParam: 's=most_recently_updated_first',
|
||||
crawlFields: {
|
||||
id: '.js-bookmark-btn@data-id',
|
||||
price: 'div.align-items-start div:first-child | trim',
|
||||
size: 'div.align-items-start div:nth-child(3) | trim',
|
||||
title: '.card-title h2 | 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,
|
||||
filter: applyBlacklist,
|
||||
};
|
||||
export const init = (sourceConfig, blacklist) => {
|
||||
config.enabled = sourceConfig.enabled;
|
||||
config.url = sourceConfig.url;
|
||||
appliedBlackList = blacklist || [];
|
||||
config.enabled = sourceConfig.enabled;
|
||||
config.url = sourceConfig.url;
|
||||
appliedBlackList = blacklist || [];
|
||||
};
|
||||
export const metaInformation = {
|
||||
name: 'Immo Südwest Presse',
|
||||
baseUrl: 'https://immo.swp.de/',
|
||||
id: 'immoswp',
|
||||
name: 'Immo Südwest Presse',
|
||||
baseUrl: 'https://immo.swp.de/',
|
||||
id: 'immoswp',
|
||||
};
|
||||
export { config };
|
||||
export {config};
|
||||
|
||||
@@ -1,37 +1,42 @@
|
||||
import utils from '../utils.js';
|
||||
import utils, {buildHash} from '../utils.js';
|
||||
|
||||
let appliedBlackList = [];
|
||||
|
||||
function normalize(o) {
|
||||
return o;
|
||||
const id = buildHash(o.id, o.price);
|
||||
return Object.assign(o, {id});
|
||||
}
|
||||
|
||||
function applyBlacklist(o) {
|
||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
||||
return titleNotBlacklisted && descNotBlacklisted;
|
||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
||||
return titleNotBlacklisted && descNotBlacklisted;
|
||||
}
|
||||
|
||||
const config = {
|
||||
url: null,
|
||||
crawlContainer: "div[class^='EstateItem-']",
|
||||
sortByDateParam: 'sd=DESC&sf=TIMESTAMP',
|
||||
crawlFields: {
|
||||
id: 'a@id',
|
||||
price: "div[class^='KeyFacts-'] [data-test='price'] | removeNewline | trim",
|
||||
size: "div[class^='KeyFacts-'] [data-test='area'] | removeNewline | trim",
|
||||
title: "div[class^='FactsMain-'] h2",
|
||||
link: 'a@href',
|
||||
address: "div[class^='estateFacts-'] span | removeNewline | trim",
|
||||
},
|
||||
paginate: '#pnlPaging #nlbPlus@href',
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
url: null,
|
||||
crawlContainer: 'div[data-testid="serp-card-testid"]',
|
||||
sortByDateParam: 'sd=DESC&sf=TIMESTAMP',
|
||||
crawlFields: {
|
||||
id: 'a@id',
|
||||
price: 'div[data-testid="cardmfe-price-testid"] | removeNewline | trim',
|
||||
size: 'div[data-testid="cardmfe-keyfacts-testid"] | removeNewline | trim',
|
||||
title: '.css-1cbj9xw',
|
||||
link: 'a@href',
|
||||
address: 'div[data-testid="cardmfe-description-box-address"] | removeNewline | trim',
|
||||
},
|
||||
paginate: '#pnlPaging #nlbPlus@href',
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
};
|
||||
export const init = (sourceConfig, blacklist) => {
|
||||
config.enabled = sourceConfig.enabled;
|
||||
config.url = sourceConfig.url;
|
||||
appliedBlackList = blacklist || [];
|
||||
config.enabled = sourceConfig.enabled;
|
||||
config.url = sourceConfig.url;
|
||||
appliedBlackList = blacklist || [];
|
||||
};
|
||||
export const metaInformation = {
|
||||
name: 'Immowelt',
|
||||
baseUrl: 'https://www.immowelt.de/',
|
||||
id: 'immowelt',
|
||||
name: 'Immowelt',
|
||||
baseUrl: 'https://www.immowelt.de/',
|
||||
id: 'immowelt',
|
||||
};
|
||||
export { config };
|
||||
export {config};
|
||||
|
||||
@@ -1,44 +1,49 @@
|
||||
import utils from '../utils.js';
|
||||
import utils, {buildHash} from '../utils.js';
|
||||
|
||||
let appliedBlackList = [];
|
||||
let appliedBlacklistedDistricts = [];
|
||||
|
||||
function normalize(o) {
|
||||
const size = o.size || '--- m²';
|
||||
return Object.assign(o, { size });
|
||||
const size = o.size || '--- m²';
|
||||
const id = buildHash(o.id, o.price);
|
||||
return Object.assign(o, {id, size});
|
||||
}
|
||||
|
||||
function applyBlacklist(o) {
|
||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
||||
const isBlacklistedDistrict =
|
||||
appliedBlacklistedDistricts.length === 0 ? false : utils.isOneOf(o.description, appliedBlacklistedDistricts);
|
||||
return !isBlacklistedDistrict && titleNotBlacklisted && descNotBlacklisted;
|
||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
||||
const isBlacklistedDistrict =
|
||||
appliedBlacklistedDistricts.length === 0 ? false : utils.isOneOf(o.description, appliedBlacklistedDistricts);
|
||||
return !isBlacklistedDistrict && titleNotBlacklisted && descNotBlacklisted;
|
||||
}
|
||||
|
||||
const config = {
|
||||
url: null,
|
||||
crawlContainer: '#srchrslt-adtable .ad-listitem ',
|
||||
//sort by date is standard oO
|
||||
sortByDateParam: null,
|
||||
crawlFields: {
|
||||
id: '.aditem@data-adid | int',
|
||||
price: '.aditem-main--middle--price | removeNewline | trim',
|
||||
size: '.aditem-main .text-module-end span:nth-child(2) | removeNewline | trim',
|
||||
title: '.aditem-main .text-module-begin a | removeNewline | trim',
|
||||
link: '.aditem-main .text-module-begin a@href | removeNewline | trim',
|
||||
description: '.aditem-main p:not(.text-module-end) | removeNewline | trim',
|
||||
address: '.aditem-main--top--left | trim | removeNewline',
|
||||
},
|
||||
paginate: '#srchrslt-pagination .pagination-next@href',
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
url: null,
|
||||
crawlContainer: '#srchrslt-adtable .ad-listitem ',
|
||||
//sort by date is standard oO
|
||||
sortByDateParam: null,
|
||||
crawlFields: {
|
||||
id: '.aditem@data-adid | int',
|
||||
price: '.aditem-main--middle--price-shipping--price | removeNewline | trim',
|
||||
size: '.aditem-main .text-module-end span:nth-child(2) | removeNewline | trim',
|
||||
title: '.aditem-main .text-module-begin a | removeNewline | trim',
|
||||
link: '.aditem-main .text-module-begin a@href | removeNewline | trim',
|
||||
description: '.aditem-main p:not(.text-module-end) | removeNewline | trim',
|
||||
address: '.aditem-main--top--left | trim | removeNewline',
|
||||
},
|
||||
paginate: '#srchrslt-pagination .pagination-next@href',
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
};
|
||||
export const metaInformation = {
|
||||
name: 'Ebay Kleinanzeigen',
|
||||
baseUrl: 'https://www.ebay-kleinanzeigen.de/',
|
||||
id: 'kleinanzeigen',
|
||||
name: 'Ebay Kleinanzeigen',
|
||||
baseUrl: 'https://www.kleinanzeigen.de/',
|
||||
id: 'kleinanzeigen',
|
||||
};
|
||||
export const init = (sourceConfig, blacklist, blacklistedDistricts) => {
|
||||
config.enabled = sourceConfig.enabled;
|
||||
config.url = sourceConfig.url;
|
||||
appliedBlacklistedDistricts = blacklistedDistricts || [];
|
||||
appliedBlackList = blacklist || [];
|
||||
config.enabled = sourceConfig.enabled;
|
||||
config.url = sourceConfig.url;
|
||||
appliedBlacklistedDistricts = blacklistedDistricts || [];
|
||||
appliedBlackList = blacklist || [];
|
||||
};
|
||||
export { config };
|
||||
export {config};
|
||||
|
||||
@@ -1,34 +1,44 @@
|
||||
import utils from '../utils.js';
|
||||
import utils, {buildHash} from '../utils.js';
|
||||
|
||||
let appliedBlackList = [];
|
||||
|
||||
function nullOrEmpty(val) {
|
||||
return val == null || val.length === 0;
|
||||
}
|
||||
|
||||
function normalize(o) {
|
||||
return o;
|
||||
const link = nullOrEmpty(o.link) ? 'NO LINK' : `https://www.neubaukompass.de${o.link.substring(o.link.indexOf('/neubau'))}`;
|
||||
const id = buildHash(o.id, o.price);
|
||||
return Object.assign(o, {id, link});
|
||||
}
|
||||
|
||||
function applyBlacklist(o) {
|
||||
return !utils.isOneOf(o.title, appliedBlackList);
|
||||
return !utils.isOneOf(o.title, appliedBlackList);
|
||||
}
|
||||
|
||||
const config = {
|
||||
url: null,
|
||||
crawlContainer: '.nbk-container >div article',
|
||||
sortByDateParam: 'Sortierung=Id&Richtung=DESC',
|
||||
crawlFields: {
|
||||
id: '@id',
|
||||
title: 'a.nbk-truncate@title | removeNewline | trim',
|
||||
link: 'a.nbk-truncate@href',
|
||||
address: 'p.nbk-truncate | removeNewline | trim',
|
||||
price: 'p.nbk-mb-0 | removeNewline | trim',
|
||||
},
|
||||
paginate: '.numbered-pager__bottom .numbered-pager--info li:nth-child(2) a@href',
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
url: null,
|
||||
crawlContainer: '.nbk-container >div article',
|
||||
sortByDateParam: 'Sortierung=Id&Richtung=DESC',
|
||||
crawlFields: {
|
||||
id: '@id',
|
||||
title: 'a.nbk-truncate@title | removeNewline | trim',
|
||||
link: 'a.nbk-truncate@href',
|
||||
address: 'p.nbk-truncate | removeNewline | trim',
|
||||
price: 'p.nbk-mb-0 | removeNewline | trim',
|
||||
},
|
||||
paginate: '.numbered-pager__bottom .numbered-pager--info li:nth-child(2) a@href',
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
};
|
||||
export const init = (sourceConfig, blacklist) => {
|
||||
config.enabled = sourceConfig.enabled;
|
||||
config.url = sourceConfig.url;
|
||||
appliedBlackList = blacklist || [];
|
||||
config.enabled = sourceConfig.enabled;
|
||||
config.url = sourceConfig.url;
|
||||
appliedBlackList = blacklist || [];
|
||||
};
|
||||
export const metaInformation = {
|
||||
name: 'Neubau Kompass',
|
||||
baseUrl: 'https://www.neubaukompass.de/',
|
||||
id: 'neubauKompass',
|
||||
name: 'Neubau Kompass',
|
||||
baseUrl: 'https://www.neubaukompass.de/',
|
||||
id: 'neubauKompass',
|
||||
};
|
||||
export { config };
|
||||
export {config};
|
||||
|
||||
@@ -1,36 +1,41 @@
|
||||
import utils from '../utils.js';
|
||||
import utils, {buildHash} from '../utils.js';
|
||||
|
||||
let appliedBlackList = [];
|
||||
|
||||
function normalize(o) {
|
||||
return o;
|
||||
const id = buildHash(o.id, o.price);
|
||||
return Object.assign(o, {id});
|
||||
}
|
||||
|
||||
function applyBlacklist(o) {
|
||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
||||
return o.id != null && titleNotBlacklisted && descNotBlacklisted;
|
||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
||||
return o.id != null && titleNotBlacklisted && descNotBlacklisted;
|
||||
}
|
||||
|
||||
const config = {
|
||||
url: null,
|
||||
crawlContainer: '#main_column .wgg_card',
|
||||
sortByDateParam: 'sort_column=0&sort_order=0',
|
||||
crawlFields: {
|
||||
id: '@data-id',
|
||||
details: '.row .noprint .col-xs-11 |removeNewline |trim',
|
||||
price: '.middle .col-xs-3 |removeNewline |trim',
|
||||
size: '.middle .text-right |removeNewline |trim',
|
||||
title: '.truncate_title a |removeNewline |trim',
|
||||
link: '.truncate_title a@href',
|
||||
},
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
url: null,
|
||||
crawlContainer: '#main_column .wgg_card',
|
||||
sortByDateParam: 'sort_column=0&sort_order=0',
|
||||
crawlFields: {
|
||||
id: '@data-id',
|
||||
details: '.row .noprint .col-xs-11 |removeNewline |trim',
|
||||
price: '.middle .col-xs-3 |removeNewline |trim',
|
||||
size: '.middle .text-right |removeNewline |trim',
|
||||
title: '.truncate_title a |removeNewline |trim',
|
||||
link: '.truncate_title a@href',
|
||||
},
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
};
|
||||
export const init = (sourceConfig, blacklist) => {
|
||||
config.enabled = sourceConfig.enabled;
|
||||
config.url = sourceConfig.url;
|
||||
appliedBlackList = blacklist || [];
|
||||
config.enabled = sourceConfig.enabled;
|
||||
config.url = sourceConfig.url;
|
||||
appliedBlackList = blacklist || [];
|
||||
};
|
||||
export const metaInformation = {
|
||||
name: 'Wg gesucht',
|
||||
baseUrl: 'https://www.wg-gesucht.de/',
|
||||
id: 'wgGesucht',
|
||||
name: 'Wg gesucht',
|
||||
baseUrl: 'https://www.wg-gesucht.de/',
|
||||
id: 'wgGesucht',
|
||||
};
|
||||
export { config };
|
||||
export {config};
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
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) {
|
||||
@@ -19,13 +24,16 @@ function makeDriver(headers = {}) {
|
||||
},
|
||||
});
|
||||
const result = await response.text();
|
||||
if (EXPECTED_STATUS_CODES.includes(response.status)) {
|
||||
throw new Error(`${response.status}`);
|
||||
}
|
||||
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)) {
|
||||
if (!EXPECTED_STATUS_CODES.includes(exception.response?.status) && !EXPECTED_STATUS_CODES.includes(Number(exception.message))) {
|
||||
console.error(`Error while trying to scrape data from scraping ant. Received error: ${exception.message}`);
|
||||
callback(null, []);
|
||||
return;
|
||||
@@ -41,9 +49,10 @@ function makeDriver(headers = {}) {
|
||||
/* eslint-enable no-console */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The regular request driver is taking care of everyting, that doesn't need to be scraped by ScrapingAnt (which is
|
||||
* everything != Immoscout as of writing this)
|
||||
* everything != Immoscout & Immonet as of writing this)
|
||||
*/
|
||||
return async function driver(context, callback) {
|
||||
if (context.url.toLowerCase().indexOf('scrapingant') !== -1) {
|
||||
@@ -55,6 +64,7 @@ function makeDriver(headers = {}) {
|
||||
...headers,
|
||||
Cookie: cookies,
|
||||
},
|
||||
agent,
|
||||
});
|
||||
const result = await response.text();
|
||||
callback(null, result);
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { metaInformation } from '../provider/immoscout.js';
|
||||
import { metaInformation as immoScoutInfo } from '../provider/immoscout.js';
|
||||
import { metaInformation as immoNetInfo } from '../provider/immonet.js';
|
||||
import { metaInformation as neuBauCompassInfo } from '../provider/neubauKompass.js';
|
||||
import { config } from '../utils.js';
|
||||
const isImmoscout = (id) => {
|
||||
return id.toLowerCase() === metaInformation.id;
|
||||
|
||||
const additionalImmonetUrlParams = `&wait_for_selector=.content-wrapper-tiles&js_snippet=${Buffer.from(
|
||||
'window.scrollTo(0,document.body.scrollHeight);'
|
||||
).toString('base64')}`;
|
||||
|
||||
const needScrapingAnt = (id) => {
|
||||
return id.toLowerCase() === immoScoutInfo.id || id.toLowerCase() === immoNetInfo.id || id.toLowerCase() === neuBauCompassInfo.id.toLowerCase();
|
||||
};
|
||||
export const transformUrlForScrapingAnt = (url, id) => {
|
||||
if (isImmoscout(id)) {
|
||||
//only do calls to scrapingAnt when dealing with Immoscout
|
||||
url = `https://api.scrapingant.com/v1/general?url=${encodeURIComponent(url)}&proxy_type=datacenter`;
|
||||
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;
|
||||
};
|
||||
@@ -16,4 +27,4 @@ export const isScrapingAntApiKeySet = () => {
|
||||
export const makeUrlResidential = (url) => {
|
||||
return url.replace('datacenter', 'residential');
|
||||
};
|
||||
export { isImmoscout };
|
||||
export { needScrapingAnt };
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import lodash from 'lodash';
|
||||
import { LowSync } from 'lowdb';
|
||||
export default class LowdashAdapter extends LowSync {
|
||||
constructor(adapter) {
|
||||
super(adapter);
|
||||
constructor(adapter, defaultData = {}) {
|
||||
super(adapter, defaultData);
|
||||
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 adapter = new JSONFileSync(file);
|
||||
const db = new LowdashAdapter(adapter);
|
||||
const db = new LowdashAdapter(adapter, { jobs: [] });
|
||||
|
||||
db.read();
|
||||
|
||||
db.data ||= { jobs: [] };
|
||||
|
||||
export const upsertJob = ({ jobId, name, blacklist = [], enabled = true, provider, notificationAdapter, userId }) => {
|
||||
const currentJob =
|
||||
|
||||
@@ -5,12 +5,10 @@ import LowdashAdapter from './LowDashAdapter.js';
|
||||
|
||||
const file = path.join(getDirName(), '../', 'db/jobListingData.json');
|
||||
const adapter = new JSONFileSync(file);
|
||||
const db = new LowdashAdapter(adapter);
|
||||
const db = new LowdashAdapter(adapter, {});
|
||||
|
||||
db.read();
|
||||
|
||||
db.data ||= {};
|
||||
|
||||
const buildKey = (jobKey, providerId, endpoint) => {
|
||||
let key = `${jobKey}`;
|
||||
if (jobKey == null && endpoint == null) {
|
||||
|
||||
@@ -6,24 +6,24 @@ import * as jobStorage from './jobStorage.js';
|
||||
import path from 'path';
|
||||
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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const file = path.join(getDirName(), '../', 'db/users.json');
|
||||
const adapter = new JSONFileSync(file);
|
||||
const db = new LowdashAdapter(adapter);
|
||||
const db = new LowdashAdapter(adapter, defaultData);
|
||||
|
||||
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) => {
|
||||
const jobs = jobStorage.getJobs();
|
||||
|
||||
86
lib/utils.js
86
lib/utils.js
@@ -1,51 +1,69 @@
|
||||
import { dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readFile } from 'fs/promises';
|
||||
import {dirname} from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {readFile} from 'fs/promises';
|
||||
import {createHash} from 'crypto';
|
||||
|
||||
function isOneOf(word, arr) {
|
||||
if (arr == null || arr.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const expression = String.raw`\b(${arr.join('|')})\b`;
|
||||
const blacklist = new RegExp(expression, 'ig');
|
||||
return blacklist.test(word);
|
||||
if (arr == null || arr.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const expression = String.raw`\b(${arr.join('|')})\b`;
|
||||
const blacklist = new RegExp(expression, 'ig');
|
||||
return blacklist.test(word);
|
||||
}
|
||||
|
||||
function nullOrEmpty(val) {
|
||||
return val == null || val.length === 0;
|
||||
return val == null || val.length === 0;
|
||||
}
|
||||
|
||||
function timeStringToMs(timeString, now) {
|
||||
const d = new Date(now);
|
||||
const parts = timeString.split(':');
|
||||
d.setHours(parts[0]);
|
||||
d.setMinutes(parts[1]);
|
||||
d.setSeconds(0);
|
||||
return d.getTime();
|
||||
const d = new Date(now);
|
||||
const parts = timeString.split(':');
|
||||
d.setHours(parts[0]);
|
||||
d.setMinutes(parts[1]);
|
||||
d.setSeconds(0);
|
||||
return d.getTime();
|
||||
}
|
||||
|
||||
function duringWorkingHoursOrNotSet(config, now) {
|
||||
const { workingHours } = config;
|
||||
if (workingHours == null || nullOrEmpty(workingHours.from) || nullOrEmpty(workingHours.to)) {
|
||||
return true;
|
||||
}
|
||||
const toDate = timeStringToMs(workingHours.to, now);
|
||||
const fromDate = timeStringToMs(workingHours.from, now);
|
||||
return fromDate <= now && toDate >= now;
|
||||
const {workingHours} = config;
|
||||
if (workingHours == null || nullOrEmpty(workingHours.from) || nullOrEmpty(workingHours.to)) {
|
||||
return true;
|
||||
}
|
||||
const toDate = timeStringToMs(workingHours.to, now);
|
||||
const fromDate = timeStringToMs(workingHours.from, now);
|
||||
return fromDate <= now && toDate >= now;
|
||||
}
|
||||
|
||||
function getDirName() {
|
||||
return dirname(fileURLToPath(import.meta.url));
|
||||
return dirname(fileURLToPath(import.meta.url));
|
||||
}
|
||||
|
||||
function buildHash(...inputs) {
|
||||
if (inputs == null) {
|
||||
return null;
|
||||
}
|
||||
const cleaned = inputs.filter(i => i != null && i.length > 0);
|
||||
if (cleaned.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return createHash('sha256')
|
||||
.update(cleaned.join(','))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
const config = JSON.parse(await readFile(new URL('../conf/config.json', import.meta.url)));
|
||||
|
||||
export { isOneOf };
|
||||
export { nullOrEmpty };
|
||||
export { duringWorkingHoursOrNotSet };
|
||||
export { getDirName };
|
||||
export { config };
|
||||
export {isOneOf};
|
||||
export {nullOrEmpty};
|
||||
export {duringWorkingHoursOrNotSet};
|
||||
export {getDirName};
|
||||
export {config};
|
||||
export {buildHash};
|
||||
export default {
|
||||
isOneOf,
|
||||
nullOrEmpty,
|
||||
duringWorkingHoursOrNotSet,
|
||||
getDirName,
|
||||
config,
|
||||
isOneOf,
|
||||
nullOrEmpty,
|
||||
duringWorkingHoursOrNotSet,
|
||||
getDirName,
|
||||
config,
|
||||
};
|
||||
|
||||
77
package.json
77
package.json
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "fredy",
|
||||
"version": "7.0.0",
|
||||
"version": "10.1.0",
|
||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "yarn && rm -rf ./ui/public/* && vite",
|
||||
"ui": "rm -rf ./ui/public/* && vite",
|
||||
"prod": "yarn && vite build --emptyOutDir",
|
||||
"format": "prettier --write lib/**/*.js ui/src/**/*.js test/**/*.js *.js --single-quote --print-width 120",
|
||||
"format": "prettier --write lib/**/*.js ui/src/**/*.jsx test/**/*.js *.js --single-quote --print-width 120",
|
||||
"test": "mocha --loader=esmock --timeout 3000000 test/**/*.test.js",
|
||||
"lint": "eslint ./index.js ./lib/**/*.js ./test/**/*.js"
|
||||
"lint": "eslint ./index.js ./lib/**/*.js ./test/**/*.js ./ui/src/**/*.jsx"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
@@ -45,7 +45,7 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0",
|
||||
"node": ">=20.0.0",
|
||||
"npm": ">=7.0.0"
|
||||
},
|
||||
"browserslist": [
|
||||
@@ -55,55 +55,54 @@
|
||||
"Firefox ESR"
|
||||
],
|
||||
"dependencies": {
|
||||
"@douyinfe/semi-ui": "2.65.0",
|
||||
"@rematch/core": "2.2.0",
|
||||
"@rematch/loading": "2.1.2",
|
||||
"@sendgrid/mail": "7.7.0",
|
||||
"@vitejs/plugin-react": "3.1.0",
|
||||
"better-sqlite3": "8.2.0",
|
||||
"@sendgrid/mail": "8.1.3",
|
||||
"@vitejs/plugin-react": "4.3.1",
|
||||
"better-sqlite3": "8.6.0",
|
||||
"body-parser": "1.20.2",
|
||||
"cookie-session": "2.0.0",
|
||||
"handlebars": "4.7.7",
|
||||
"highcharts": "10.3.3",
|
||||
"highcharts-react-official": "3.2.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lowdb": "5.1.0",
|
||||
"cookie-session": "2.1.0",
|
||||
"handlebars": "4.7.8",
|
||||
"highcharts": "11.4.8",
|
||||
"highcharts-react-official": "3.2.1",
|
||||
"lodash": "4.17.21",
|
||||
"lowdb": "6.0.1",
|
||||
"markdown": "^0.5.0",
|
||||
"nanoid": "4.0.1",
|
||||
"node-fetch": "3.3.1",
|
||||
"node-mailjet": "6.0.2",
|
||||
"query-string": "8.1.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-redux": "8.0.5",
|
||||
"nanoid": "5.0.7",
|
||||
"node-fetch": "3.3.2",
|
||||
"node-mailjet": "6.0.6",
|
||||
"query-string": "8.2.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-redux": "9.1.2",
|
||||
"react-router": "5.2.1",
|
||||
"react-router-dom": "5.3.0",
|
||||
"react-switch": "7.0.0",
|
||||
"redux": "4.2.1",
|
||||
"redux-thunk": "2.4.2",
|
||||
"restana": "4.9.7",
|
||||
"semantic-ui-react": "2.1.4",
|
||||
"redux": "5.0.1",
|
||||
"redux-thunk": "3.1.0",
|
||||
"restana": "4.9.9",
|
||||
"serve-static": "1.15.0",
|
||||
"slack": "11.0.2",
|
||||
"string-similarity": "^4.0.4",
|
||||
"vite": "4.1.4",
|
||||
"vite": "5.4.3",
|
||||
"x-ray": "2.3.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esmock": "2.1.0",
|
||||
"@babel/core": "7.21.0",
|
||||
"@babel/eslint-parser": "7.19.1",
|
||||
"@babel/preset-env": "7.20.2",
|
||||
"@babel/preset-react": "7.18.6",
|
||||
"chai": "4.3.7",
|
||||
"eslint": "8.36.0",
|
||||
"eslint-config-prettier": "8.7.0",
|
||||
"eslint-plugin-react": "7.32.2",
|
||||
"@babel/core": "7.25.2",
|
||||
"@babel/eslint-parser": "7.25.1",
|
||||
"@babel/preset-env": "7.25.4",
|
||||
"@babel/preset-react": "7.24.7",
|
||||
"chai": "5.1.1",
|
||||
"eslint": "8.56.0",
|
||||
"eslint-config-prettier": "8.8.0",
|
||||
"eslint-plugin-react": "7.35.2",
|
||||
"esmock": "2.6.7",
|
||||
"history": "5.3.0",
|
||||
"husky": "4.3.8",
|
||||
"less": "4.1.3",
|
||||
"lint-staged": "13.2.0",
|
||||
"mocha": "10.2.0",
|
||||
"prettier": "2.8.4",
|
||||
"less": "4.2.0",
|
||||
"lint-staged": "13.2.2",
|
||||
"mocha": "10.7.3",
|
||||
"prettier": "3.3.3",
|
||||
"redux-logger": "3.0.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||
import { get } from '../mocks/mockNotification.js';
|
||||
import { providerConfig, mockFredy } from '../utils.js';
|
||||
import chai from 'chai';
|
||||
import { expect } from 'chai';
|
||||
import * as provider from '../../lib/provider/einsAImmobilien.js';
|
||||
|
||||
const expect = chai.expect;
|
||||
|
||||
describe('#einsAImmobilien testsuite()', () => {
|
||||
after(() => {
|
||||
similarityCache.stopCacheCleanup();
|
||||
@@ -22,7 +20,7 @@ describe('#einsAImmobilien testsuite()', () => {
|
||||
expect(notificationObj.serviceName).to.equal('einsAImmobilien');
|
||||
notificationObj.payload.forEach((notify) => {
|
||||
/** 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.size).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 { get } from '../mocks/mockNotification.js';
|
||||
import { providerConfig, mockFredy } from '../utils.js';
|
||||
import chai from 'chai';
|
||||
import { expect } from 'chai';
|
||||
import * as provider from '../../lib/provider/immobilienDe.js';
|
||||
const expect = chai.expect;
|
||||
|
||||
describe('#immobilien.de testsuite()', () => {
|
||||
after(() => {
|
||||
similarityCache.stopCacheCleanup();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||
import { get } from '../mocks/mockNotification.js';
|
||||
import { mockFredy, providerConfig } from '../utils.js';
|
||||
import chai from 'chai';
|
||||
import { expect } from 'chai';
|
||||
import * as provider from '../../lib/provider/immonet.js';
|
||||
const expect = chai.expect;
|
||||
import * as scrapingAnt from '../../lib/services/scrapingAnt.js';
|
||||
|
||||
describe('#immonet testsuite()', () => {
|
||||
after(() => {
|
||||
similarityCache.stopCacheCleanup();
|
||||
@@ -12,6 +13,13 @@ describe('#immonet testsuite()', () => {
|
||||
it('should test immonet provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
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);
|
||||
fredy.execute().then((listing) => {
|
||||
expect(listing).to.be.a('array');
|
||||
@@ -20,17 +28,17 @@ describe('#immonet testsuite()', () => {
|
||||
expect(notificationObj.serviceName).to.equal('immonet');
|
||||
notificationObj.payload.forEach((notify) => {
|
||||
/** 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.size).to.be.a('string');
|
||||
expect(notify.title).to.be.a('string');
|
||||
expect(notify.link).to.be.a('string');
|
||||
expect(notify.address).to.be.a('string');
|
||||
|
||||
/** check the values if possible **/
|
||||
expect(notify.price).that.does.include('€');
|
||||
expect(notify.size).that.does.include('m²');
|
||||
expect(notify.title).to.be.not.empty;
|
||||
expect(notify.link).that.does.include('https://www.immonet.de');
|
||||
expect(notify.address).to.be.not.empty;
|
||||
});
|
||||
resolve();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||
import { get } from '../mocks/mockNotification.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 scrapingAnt from '../../lib/services/scrapingAnt.js';
|
||||
const expect = chai.expect;
|
||||
|
||||
describe('#immoscout testsuite()', () => {
|
||||
after(() => {
|
||||
similarityCache.stopCacheCleanup();
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||
import { get } from '../mocks/mockNotification.js';
|
||||
import { mockFredy, providerConfig } from '../utils.js';
|
||||
import chai from 'chai';
|
||||
import { expect } from 'chai';
|
||||
import * as provider from '../../lib/provider/immoswp.js';
|
||||
const expect = chai.expect;
|
||||
|
||||
describe('#immoswp testsuite()', () => {
|
||||
after(() => {
|
||||
similarityCache.stopCacheCleanup();
|
||||
@@ -25,12 +25,10 @@ describe('#immoswp testsuite()', () => {
|
||||
expect(notify.size).to.be.a('string');
|
||||
expect(notify.title).to.be.a('string');
|
||||
expect(notify.link).to.be.a('string');
|
||||
expect(notify.address).to.be.a('string');
|
||||
/** check the values if possible **/
|
||||
expect(notify.price).that.does.include('€');
|
||||
expect(notify.title).to.be.not.empty;
|
||||
expect(notify.link).that.does.include('https://immo.swp.de');
|
||||
expect(notify.address).to.be.not.empty;
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||
import { get } from '../mocks/mockNotification.js';
|
||||
import { mockFredy, providerConfig } from '../utils.js';
|
||||
import chai from 'chai';
|
||||
import { expect } from 'chai';
|
||||
import * as provider from '../../lib/provider/immowelt.js';
|
||||
const expect = chai.expect;
|
||||
|
||||
describe('#immowelt testsuite()', () => {
|
||||
after(() => {
|
||||
similarityCache.stopCacheCleanup();
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||
import { get } from '../mocks/mockNotification.js';
|
||||
import { mockFredy, providerConfig } from '../utils.js';
|
||||
import chai from 'chai';
|
||||
import { expect } from 'chai';
|
||||
import * as provider from '../../lib/provider/kleinanzeigen.js';
|
||||
const expect = chai.expect;
|
||||
|
||||
describe('#kleinanzeigen testsuite()', () => {
|
||||
after(() => {
|
||||
similarityCache.stopCacheCleanup();
|
||||
@@ -20,13 +20,13 @@ describe('#kleinanzeigen testsuite()', () => {
|
||||
expect(notificationObj.serviceName).to.equal('kleinanzeigen');
|
||||
notificationObj.payload.forEach((notify) => {
|
||||
/** 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.link).to.be.a('string');
|
||||
expect(notify.address).to.be.a('string');
|
||||
/** check the values if possible **/
|
||||
expect(notify.title).to.be.not.empty;
|
||||
expect(notify.link).that.does.include('https://www.ebay-kleinanzeigen.de');
|
||||
expect(notify.link).that.does.include('https://www.kleinanzeigen.de');
|
||||
expect(notify.address).to.be.not.empty;
|
||||
});
|
||||
resolve();
|
||||
|
||||
@@ -1,36 +1,44 @@
|
||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||
import { get } from '../mocks/mockNotification.js';
|
||||
import { mockFredy, providerConfig } from '../utils.js';
|
||||
import chai from 'chai';
|
||||
import {get} from '../mocks/mockNotification.js';
|
||||
import {mockFredy, providerConfig} from '../utils.js';
|
||||
import {expect} from 'chai';
|
||||
import * as provider from '../../lib/provider/neubauKompass.js';
|
||||
const expect = chai.expect;
|
||||
import * as scrapingAnt from '../../lib/services/scrapingAnt.js';
|
||||
|
||||
describe('#neubauKompass testsuite()', () => {
|
||||
after(() => {
|
||||
similarityCache.stopCacheCleanup();
|
||||
});
|
||||
provider.init(providerConfig.neubauKompass, [], []);
|
||||
it('should test neubauKompass provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
return await new Promise((resolve) => {
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'neubauKompass', similarityCache);
|
||||
fredy.execute().then((listing) => {
|
||||
expect(listing).to.be.a('array');
|
||||
const notificationObj = get();
|
||||
expect(notificationObj.serviceName).to.equal('neubauKompass');
|
||||
notificationObj.payload.forEach((notify) => {
|
||||
expect(notify).to.be.a('object');
|
||||
/** check the actual structure **/
|
||||
expect(notify.id).to.be.a('string');
|
||||
expect(notify.title).to.be.a('string');
|
||||
expect(notify.link).to.be.a('string');
|
||||
expect(notify.address).to.be.a('string');
|
||||
/** check the values if possible **/
|
||||
expect(notify.title).to.be.not.empty;
|
||||
expect(notify.link).that.does.include('https://www.neubaukompass.de');
|
||||
expect(notify.address).to.be.not.empty;
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
after(() => {
|
||||
similarityCache.stopCacheCleanup();
|
||||
});
|
||||
provider.init(providerConfig.neubauKompass, [], []);
|
||||
it('should test neubauKompass provider', async () => {
|
||||
const Fredy = await mockFredy();
|
||||
return await new Promise((resolve) => {
|
||||
if (!scrapingAnt.isScrapingAntApiKeySet()) {
|
||||
/* eslint-disable no-console */
|
||||
console.info('Skipping Neubaukompass test as ScrapingAnt Api Key is not set.');
|
||||
/* eslint-enable no-console */
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'neubauKompass', similarityCache);
|
||||
fredy.execute().then((listing) => {
|
||||
expect(listing).to.be.a('array');
|
||||
const notificationObj = get();
|
||||
expect(notificationObj.serviceName).to.equal('neubauKompass');
|
||||
notificationObj.payload.forEach((notify) => {
|
||||
expect(notify).to.be.a('object');
|
||||
/** check the actual structure **/
|
||||
expect(notify.id).to.be.a('string');
|
||||
expect(notify.title).to.be.a('string');
|
||||
expect(notify.link).to.be.a('string');
|
||||
expect(notify.address).to.be.a('string');
|
||||
/** check the values if possible **/
|
||||
expect(notify.title).to.be.not.empty;
|
||||
expect(notify.link).that.does.include('https://www.neubaukompass.de');
|
||||
expect(notify.address).to.be.not.empty;
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"enabled": true
|
||||
},
|
||||
"immonet": {
|
||||
"url": "https://www.immonet.de/immobiliensuche/sel.do?pageoffset=1&listsize=100&objecttype=1&locationname=Düsseldorf&acid=&actype=&district=8717&district=8718&district=8719&district=8720&district=8721&district=8723&district=8724&district=8725&district=8727&district=8728&district=8729&district=8730&district=8731&district=8732&district=8733&district=8737&district=8738&district=8741&district=8745&district=8747&district=8750&district=8752&district=8754&district=8755&district=8756&district=8759&district=8760&district=8761&district=8763&district=8764&district=8765&ajaxIsRadiusActive=false&sortby=19&suchart=1&radius=0&pcatmtypes=1_1&pCatMTypeStoragefield=&parentcat=1&marketingtype=1&fromprice=&toprice=420000&fromarea=90&toarea=&fromplotarea=&toplotarea=&fromrooms=3&torooms=&objectcat=225&objectcat=18&objectcat=17&objectcat=12&objectcat=16&objectcat=181&objectcat=14&objectcat=15&objectcat=226&objectcat=13&wbs=-1&fromyear=&toyear=",
|
||||
"url": "https://www.immonet.de/immobiliensuche/beta?pageoffset=1&listsize=100&objecttype=1&locationname=D%C3%BCsseldorf&acid=&actype=&district=8717&district=8718&district=8719&district=8720&district=8721&district=8723&district=8724&district=8725&district=8727&district=8728&district=8729&district=8730&district=8731&district=8732&district=8733&district=8737&district=8738&district=8741&district=8745&district=8747&district=8750&district=8752&district=8754&district=8755&district=8756&district=8759&district=8760&district=8761&district=8763&district=8764&district=8765&ajaxIsRadiusActive=false&sortby=19&suchart=1&radius=0&pcatmtypes=1_1&pCatMTypeStoragefield=&parentcat=1&marketingtype=1&fromprice=&toprice=420000&fromarea=90&toarea=&fromplotarea=&toplotarea=&fromrooms=3&torooms=&objectcat=225&objectcat=18&objectcat=17&objectcat=12&objectcat=16&objectcat=181&objectcat=14&objectcat=15&objectcat=226&objectcat=13&wbs=-1&fromyear=&toyear=",
|
||||
"enabled": true
|
||||
},
|
||||
"immowelt": {
|
||||
@@ -29,7 +29,7 @@
|
||||
"enabled": true
|
||||
},
|
||||
"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
|
||||
},
|
||||
"neubauKompass": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import utils from '../../lib/utils.js';
|
||||
import assert from 'assert';
|
||||
import chai from 'chai';
|
||||
const expect = chai.expect;
|
||||
import { expect } from 'chai';
|
||||
|
||||
const fakeWorkingHoursConfig = (from, to) => ({
|
||||
workingHours: {
|
||||
to,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||
import { get } from '../mocks/mockNotification.js';
|
||||
import { mockFredy, providerConfig } from '../utils.js';
|
||||
import chai from 'chai';
|
||||
import { expect } from 'chai';
|
||||
import * as provider from '../../lib/provider/wgGesucht.js';
|
||||
const expect = chai.expect;
|
||||
|
||||
describe('#wgGesucht testsuite()', () => {
|
||||
after(() => {
|
||||
similarityCache.stopCacheCleanup();
|
||||
@@ -23,7 +23,6 @@ describe('#wgGesucht testsuite()', () => {
|
||||
expect(notify.id).to.be.a('string');
|
||||
expect(notify.title).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.link).to.be.a('string');
|
||||
});
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import fs from 'fs';
|
||||
import chai from 'chai';
|
||||
import { expect } from 'chai';
|
||||
import { readFile } from 'fs/promises';
|
||||
import mutator from '../../lib/services/queryStringMutator.js';
|
||||
import queryString from 'query-string';
|
||||
const expect = chai.expect;
|
||||
|
||||
const data = await readFile(new URL('./testData.json', import.meta.url));
|
||||
|
||||
const testData = JSON.parse(data);
|
||||
|
||||
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,6 +1,6 @@
|
||||
import SimilarityCacheEntry from '../../lib/services/similarity-check/SimilarityCacheEntry.js';
|
||||
import chai from 'chai';
|
||||
const expect = chai.expect;
|
||||
import { expect } from 'chai';
|
||||
|
||||
describe('similarityCheck', () => {
|
||||
describe('#similarityCheck()', () => {
|
||||
it('should be false', () => {
|
||||
@@ -29,10 +29,10 @@ describe('similarityCheck', () => {
|
||||
it('should be false', () => {
|
||||
const check = new SimilarityCacheEntry(0);
|
||||
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(
|
||||
'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;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,13 +3,10 @@ import React, { useEffect } from 'react';
|
||||
import InsufficientPermission from './components/permission/InsufficientPermission';
|
||||
import PermissionAwareRoute from './components/permission/PermissionAwareRoute';
|
||||
import GeneralSettings from './views/generalSettings/GeneralSettings';
|
||||
import ToastsContainer from './components/toasts/ToastContainer';
|
||||
import JobMutation from './views/jobs/mutation/JobMutation';
|
||||
import UserMutator from './views/user/mutation/UserMutator';
|
||||
import ToastContext from './components/toasts/ToastContext';
|
||||
import JobInsight from './views/jobs/insights/JobInsight.jsx';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import useToast from './components/toasts/useToast';
|
||||
import { Switch, Redirect } from 'react-router-dom';
|
||||
import Logout from './components/logout/Logout';
|
||||
import Logo from './components/logo/Logo';
|
||||
@@ -23,20 +20,21 @@ import './App.less';
|
||||
|
||||
export default function FredyApp() {
|
||||
const dispatch = useDispatch();
|
||||
const [showToast, onToastFinished, toasts] = useToast();
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const currentUser = useSelector((state) => state.user.currentUser);
|
||||
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
await dispatch.provider.getProvider();
|
||||
await dispatch.jobs.getJobs();
|
||||
await dispatch.jobs.getProcessingTimes();
|
||||
await dispatch.notificationAdapter.getAdapter();
|
||||
await dispatch.user.getCurrentUser();
|
||||
|
||||
if (!needsLogin()) {
|
||||
await dispatch.provider.getProvider();
|
||||
await dispatch.jobs.getJobs();
|
||||
await dispatch.jobs.getProcessingTimes();
|
||||
await dispatch.notificationAdapter.getAdapter();
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
init();
|
||||
}, [currentUser?.userId]);
|
||||
|
||||
@@ -56,44 +54,41 @@ export default function FredyApp() {
|
||||
return loading ? null : needsLogin() ? (
|
||||
login()
|
||||
) : (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
<div className="app">
|
||||
<div className="app__container">
|
||||
<Logout />
|
||||
<Logo width={190} white />
|
||||
<Menu isAdmin={isAdmin()} />
|
||||
<ToastsContainer toasts={toasts} onToastFinished={onToastFinished} />
|
||||
<Switch>
|
||||
<Route name="Insufficient Permission" path={'/403'} component={InsufficientPermission} />
|
||||
<Route name="Create new Job" path={'/jobs/new'} component={JobMutation} />
|
||||
<Route name="Edit a Job" path={'/jobs/edit/:jobId'} component={JobMutation} />
|
||||
<Route name="Insights into a Job" path={'/jobs/insights/:jobId'} component={JobInsight} />
|
||||
<Route name="Job overview" path={'/jobs'} component={Jobs} />
|
||||
<PermissionAwareRoute
|
||||
name="Create new User"
|
||||
path="/users/new"
|
||||
component={<UserMutator />}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
<PermissionAwareRoute
|
||||
name="Edit a user"
|
||||
path="/users/edit/:userId"
|
||||
component={<UserMutator />}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
<PermissionAwareRoute name="Users" path="/users" component={<Users />} currentUser={currentUser} />
|
||||
<PermissionAwareRoute
|
||||
name="General Settings"
|
||||
path="/generalSettings"
|
||||
component={<GeneralSettings />}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
<div className="app">
|
||||
<div className="app__container">
|
||||
<Logout />
|
||||
<Logo width={190} white />
|
||||
<Menu isAdmin={isAdmin()} />
|
||||
<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>
|
||||
<Redirect from="/" to={'/jobs'} />
|
||||
</Switch>
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,9 @@
|
||||
width:100%;
|
||||
|
||||
&__container {
|
||||
width: 100%;
|
||||
|
||||
padding: 1rem 1rem;
|
||||
background-color: #595959f5;
|
||||
color: #f1f1f1;
|
||||
color: var(--semi-color-text-0);
|
||||
background-color: #232429;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,4 +16,28 @@
|
||||
|
||||
.ui.black.label, .ui.black.labels .label {
|
||||
background-color: #31303078!important;
|
||||
}
|
||||
|
||||
a:link {
|
||||
color: #54a9ff;
|
||||
background-color: transparent;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: #54a9ff;
|
||||
background-color: transparent;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #54a9ff;
|
||||
background-color: transparent;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a:active {
|
||||
color: #54a9ff;
|
||||
background-color: transparent;
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -5,9 +5,11 @@ import { HashRouter } from 'react-router-dom';
|
||||
import { createHashHistory } from 'history';
|
||||
import { Provider } from 'react-redux';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import en_US from '@douyinfe/semi-ui/lib/es/locale/source/en_US';
|
||||
import { LocaleProvider } from '@douyinfe/semi-ui';
|
||||
|
||||
const container = document.getElementById('fredy');
|
||||
const root = createRoot(container);
|
||||
|
||||
const history = createHashHistory();
|
||||
|
||||
import App from './App';
|
||||
@@ -17,7 +19,9 @@ import './Index.less';
|
||||
root.render(
|
||||
<Provider store={reduxStore}>
|
||||
<HashRouter history={history}>
|
||||
<App />
|
||||
<LocaleProvider locale={en_US}>
|
||||
<App />
|
||||
</LocaleProvider>
|
||||
</HashRouter>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
@@ -2,5 +2,14 @@ body, html {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: #595959f5;
|
||||
background-color: #232429;
|
||||
}
|
||||
|
||||
.semi-table-row-head{
|
||||
background-color: #2b2b2b !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.semi-table-row-cell {
|
||||
background-color: #333333 !important;
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Header } from 'semantic-ui-react';
|
||||
import { Typography } from '@douyinfe/semi-ui';
|
||||
|
||||
import './Headline.less';
|
||||
|
||||
export default function Headline({ text, size = 'medium', className = '' } = {}) {
|
||||
export default function Headline({ text, size = 3 } = {}) {
|
||||
const { Title } = Typography;
|
||||
return (
|
||||
<Header className={`headline ${className}`} size={size}>
|
||||
<Title heading={size} style={{ marginBottom: '1rem' }}>
|
||||
{text}
|
||||
</Header>
|
||||
</Title>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
.headline{
|
||||
color: #f1f1f1 !important;
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
import React from 'react';
|
||||
import { Button } from 'semantic-ui-react';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
import { xhrPost } from '../../services/xhr';
|
||||
|
||||
import { IconUser } from '@douyinfe/semi-icons';
|
||||
const Logout = function Logout() {
|
||||
return (
|
||||
<Button
|
||||
content="Logout"
|
||||
labelPosition="left"
|
||||
icon="user"
|
||||
size="mini"
|
||||
icon={<IconUser />}
|
||||
type="danger"
|
||||
theme="solid"
|
||||
onClick={async () => {
|
||||
await xhrPost('/api/login/logout');
|
||||
location.reload();
|
||||
}}
|
||||
negative
|
||||
/>
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,49 +1,54 @@
|
||||
import React from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Icon, Menu } from 'semantic-ui-react';
|
||||
import { Tabs, TabPane } from '@douyinfe/semi-ui';
|
||||
|
||||
import './Menu.less';
|
||||
import { useLocation } from 'react-router';
|
||||
import { IconUser, IconTerminal, IconSetting } from '@douyinfe/semi-icons';
|
||||
|
||||
function parsePathName(name) {
|
||||
const split = name.split('/').filter((s) => s.length !== 0);
|
||||
return '/' + split[0];
|
||||
}
|
||||
|
||||
const TopMenu = function TopMenu({ isAdmin }) {
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
|
||||
const isActiveRoute = (name) => location.pathname.indexOf(name) !== -1;
|
||||
|
||||
return (
|
||||
<Menu pointing secondary className="topMenu">
|
||||
<Menu.Item
|
||||
name="jobs"
|
||||
active={isActiveRoute('jobs')}
|
||||
className={isActiveRoute('jobs') ? 'topMenu__active' : 'topMenu__item'}
|
||||
onClick={() => history.push('/jobs')}
|
||||
>
|
||||
<Icon name="search" /> Job Configuration
|
||||
</Menu.Item>
|
||||
<Tabs type="line" activeKey={parsePathName(location.pathname)} onTabClick={(key) => history.push(key)}>
|
||||
<TabPane
|
||||
itemKey="/jobs"
|
||||
tab={
|
||||
<span>
|
||||
<IconTerminal />
|
||||
Jobs
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{isAdmin && (
|
||||
<Menu.Item
|
||||
name="user"
|
||||
active={isActiveRoute('users')}
|
||||
className={isActiveRoute('users') ? 'topMenu__active' : 'topMenu__item'}
|
||||
onClick={() => history.push('/users')}
|
||||
>
|
||||
<Icon name="user" /> User configuration
|
||||
</Menu.Item>
|
||||
<TabPane
|
||||
itemKey="/users"
|
||||
tab={
|
||||
<span>
|
||||
<IconUser />
|
||||
User
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<Menu.Item
|
||||
name="general"
|
||||
active={isActiveRoute('general')}
|
||||
className={isActiveRoute('general') ? 'topMenu__active' : 'topMenu__item'}
|
||||
onClick={() => history.push('/generalSettings')}
|
||||
>
|
||||
<Icon name="cog" /> General Settings
|
||||
</Menu.Item>
|
||||
<TabPane
|
||||
itemKey="/generalSettings"
|
||||
tab={
|
||||
<span>
|
||||
<IconSetting />
|
||||
General
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Menu>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
.topMenu {
|
||||
border-bottom: 1px solid #b7b7b7f2 !important;
|
||||
|
||||
&__active {
|
||||
border-bottom: 1px solid #06dcfff2 !important;
|
||||
font-weight: 550 !important;
|
||||
color: #3ed7ff !important;
|
||||
margin: 0 0 -1px !important;
|
||||
}
|
||||
|
||||
&__item {
|
||||
color: #fffffff2 !important;
|
||||
border-color: transparent !important;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import { Header } from 'semantic-ui-react';
|
||||
import insufficientPermission from '../../assets/insufficient_permission.png';
|
||||
|
||||
export default function InsufficientPermission() {
|
||||
@@ -7,9 +6,7 @@ export default function InsufficientPermission() {
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flexDirection: 'column' }}>
|
||||
<img src={insufficientPermission} height={250} />
|
||||
<br />
|
||||
<Header as="h4" inverted>
|
||||
Insufficient permission :(
|
||||
</Header>
|
||||
<h4>Insufficient permission :(</h4>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,18 @@
|
||||
import React from 'react';
|
||||
import { Header, Icon, Popup, Segment } from 'semantic-ui-react';
|
||||
import { Card } from '@douyinfe/semi-ui';
|
||||
|
||||
import './SegmentParts.less';
|
||||
|
||||
export const SegmentPart = ({ name, icon = null, children, helpText }) => (
|
||||
<Segment inverted>
|
||||
<Header as="h5" inverted sub>
|
||||
{icon && <Icon name={icon} inverted size="mini" />}
|
||||
<Header.Content>{name}</Header.Content>
|
||||
</Header>
|
||||
export const SegmentPart = ({ name, Icon = null, children, helpText }) => {
|
||||
const { Meta } = Card;
|
||||
|
||||
<Popup
|
||||
content={helpText}
|
||||
trigger={
|
||||
<span className="generalSettings__help">
|
||||
{' '}
|
||||
<Icon name="help circle" inverted />
|
||||
What is this?
|
||||
</span>
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<Meta title={name} description={helpText} avatar={Icon == null ? null : <Icon size="extra-extra-small" />} />
|
||||
}
|
||||
/>
|
||||
<Segment inverted className="segmentParts">
|
||||
>
|
||||
{children}
|
||||
</Segment>
|
||||
</Segment>
|
||||
);
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,66 +1,79 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { Table, Button } from 'semantic-ui-react';
|
||||
import Switch from 'react-switch';
|
||||
|
||||
const emptyTable = () => {
|
||||
return (
|
||||
<Table.Row>
|
||||
<Table.Cell collapsing colSpan={6} style={{ textAlign: 'center' }}>
|
||||
No Data
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
};
|
||||
|
||||
const content = (jobs, onJobRemoval, onJobStatusChanged, onJobEdit, onJobInsight) => {
|
||||
return (
|
||||
<Fragment>
|
||||
{Object.keys(jobs).map((jobKey) => {
|
||||
const job = jobs[jobKey];
|
||||
|
||||
return (
|
||||
<Table.Row key={jobKey}>
|
||||
<Table.Cell collapsing>
|
||||
<Switch onChange={(checked) => onJobStatusChanged(job.id, checked)} checked={job.enabled} />
|
||||
</Table.Cell>
|
||||
<Table.Cell>{job.name}</Table.Cell>
|
||||
<Table.Cell>{job.numberOfFoundListings || 0}</Table.Cell>
|
||||
<Table.Cell>{job.provider.length || 0}</Table.Cell>
|
||||
<Table.Cell>{job.notificationAdapter.length || 0}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div style={{ float: 'right' }}>
|
||||
<Button circular color="teal" icon="chart line" onClick={() => onJobInsight(job.id)} />
|
||||
<Button circular color="blue" icon="edit" onClick={() => onJobEdit(job.id)} />
|
||||
<Button circular color="red" icon="trash" onClick={() => onJobRemoval(job.id)} />
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
import { Button, Empty, Table, Switch } from '@douyinfe/semi-ui';
|
||||
import { IconDelete, IconEdit, IconHistogram } from '@douyinfe/semi-icons';
|
||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||
const empty = (
|
||||
<Empty
|
||||
image={<IllustrationNoResult />}
|
||||
darkModeImage={<IllustrationNoResultDark />}
|
||||
description={'No jobs available'}
|
||||
/>
|
||||
);
|
||||
|
||||
export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged, onJobEdit, onJobInsight } = {}) {
|
||||
return (
|
||||
<Table singleLine inverted>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell />
|
||||
<Table.HeaderCell>Job Name</Table.HeaderCell>
|
||||
<Table.HeaderCell>Number of findings</Table.HeaderCell>
|
||||
<Table.HeaderCell>Active provider</Table.HeaderCell>
|
||||
<Table.HeaderCell>Active notification adapter</Table.HeaderCell>
|
||||
<Table.HeaderCell></Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
{Object.keys(jobs).length === 0
|
||||
? emptyTable()
|
||||
: content(jobs, onJobRemoval, onJobStatusChanged, onJobEdit, onJobInsight)}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
<Table
|
||||
pagination={false}
|
||||
empty={empty}
|
||||
columns={[
|
||||
{
|
||||
title: '',
|
||||
dataIndex: '',
|
||||
render: (job) => {
|
||||
return <Switch onChange={(checked) => onJobStatusChanged(job.id, checked)} checked={job.enabled} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Job Name',
|
||||
dataIndex: 'name',
|
||||
},
|
||||
{
|
||||
title: 'Number of findings',
|
||||
dataIndex: 'numberOfFoundListings',
|
||||
render: (value) => {
|
||||
return value || 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Active provider',
|
||||
dataIndex: 'provider',
|
||||
render: (value) => {
|
||||
return value.length || 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Active notification adapter',
|
||||
dataIndex: 'notificationAdapter',
|
||||
render: (value) => {
|
||||
return value.length || 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'tools',
|
||||
render: (_, job) => {
|
||||
return (
|
||||
<div style={{ float: 'right' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<IconHistogram />}
|
||||
onClick={() => onJobInsight(job.id)}
|
||||
style={{ marginRight: '1rem' }}
|
||||
/>
|
||||
<Button
|
||||
type="secondary"
|
||||
icon={<IconEdit />}
|
||||
onClick={() => onJobEdit(job.id)}
|
||||
style={{ marginRight: '1rem' }}
|
||||
/>
|
||||
<Button type="danger" icon={<IconDelete />} onClick={() => onJobRemoval(job.id)} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
dataSource={jobs}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,50 +1,38 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { Table, Button } from 'semantic-ui-react';
|
||||
|
||||
const emptyTable = () => {
|
||||
return (
|
||||
<Table.Row>
|
||||
<Table.Cell collapsing colSpan="3" style={{ textAlign: 'center' }}>
|
||||
No Data
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
};
|
||||
|
||||
const content = (adapterData, onRemove, onEdit) => {
|
||||
return (
|
||||
<Fragment>
|
||||
{adapterData.map((data) => {
|
||||
return (
|
||||
<Table.Row key={data.id}>
|
||||
<Table.Cell>{data.name}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div style={{ float: 'right' }}>
|
||||
<Button circular color="blue" icon="edit" onClick={() => onEdit(data.id)} />
|
||||
<Button circular color="red" icon="trash" onClick={() => onRemove(data.id)} />
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
import { Empty, Table, Button } from '@douyinfe/semi-ui';
|
||||
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
||||
|
||||
export default function NotificationAdapterTable({ notificationAdapter = [], onRemove, onEdit } = {}) {
|
||||
return (
|
||||
<Table singleLine inverted>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Notification Adapter Name</Table.HeaderCell>
|
||||
<Table.HeaderCell></Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table
|
||||
pagination={false}
|
||||
empty={<Empty description="No Data" />}
|
||||
columns={[
|
||||
{
|
||||
title: 'Notification Adapter Name',
|
||||
dataIndex: 'name',
|
||||
},
|
||||
|
||||
<Table.Body>
|
||||
{notificationAdapter.length === 0 ? emptyTable() : content(notificationAdapter, onRemove, onEdit)}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'tools',
|
||||
render: (_, record) => {
|
||||
return (
|
||||
<div style={{ float: 'right' }}>
|
||||
<Button
|
||||
type="secondary"
|
||||
icon={<IconEdit />}
|
||||
onClick={() => onEdit(record.id)}
|
||||
style={{ marginRight: '1rem' }}
|
||||
/>
|
||||
<Button type="danger" icon={<IconDelete />} onClick={() => onRemove(record.id)} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
dataSource={notificationAdapter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,53 +1,42 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { Table, Button } from 'semantic-ui-react';
|
||||
|
||||
const emptyTable = () => {
|
||||
return (
|
||||
<Table.Row>
|
||||
<Table.Cell collapsing colSpan="3" style={{ textAlign: 'center' }}>
|
||||
No Data
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
};
|
||||
|
||||
const content = (providerData, onRemove) => {
|
||||
return (
|
||||
<Fragment>
|
||||
{providerData.map((data) => {
|
||||
return (
|
||||
<Table.Row key={data.id}>
|
||||
<Table.Cell>{data.name}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<a href={data.url} target="_blank" rel="noopener noreferrer">
|
||||
Visit site
|
||||
</a>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div style={{ float: 'right' }}>
|
||||
<Button circular color="red" icon="trash" onClick={() => onRemove(data.id)} />
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
import { Empty, Table, Button } from '@douyinfe/semi-ui';
|
||||
import { IconDelete } from '@douyinfe/semi-icons';
|
||||
|
||||
export default function ProviderTable({ providerData = [], onRemove } = {}) {
|
||||
return (
|
||||
<Table singleLine inverted>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Provider Name</Table.HeaderCell>
|
||||
<Table.HeaderCell>Url</Table.HeaderCell>
|
||||
<Table.HeaderCell></Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>{providerData.length === 0 ? emptyTable() : content(providerData, onRemove)}</Table.Body>
|
||||
</Table>
|
||||
<Table
|
||||
pagination={false}
|
||||
empty={<Empty description="No Provider available" />}
|
||||
columns={[
|
||||
{
|
||||
title: 'Provider Name',
|
||||
dataIndex: 'name',
|
||||
},
|
||||
{
|
||||
title: 'Provider Url',
|
||||
dataIndex: 'url',
|
||||
render: (_, data) => {
|
||||
return (
|
||||
<a href={data.url} target="_blank" rel="noopener noreferrer">
|
||||
Visit site
|
||||
</a>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'tools',
|
||||
render: (_, record) => {
|
||||
return (
|
||||
<div style={{ float: 'right' }}>
|
||||
<Button type="danger" icon={<IconDelete />} onClick={() => onRemove(record.id)} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
dataSource={providerData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,49 +1,58 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Table, Button } from 'semantic-ui-react';
|
||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||
import { format } from '../../services/time/timeService';
|
||||
import { Table, Button, Empty } from '@douyinfe/semi-ui';
|
||||
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
||||
|
||||
const emptyTable = () => {
|
||||
return (
|
||||
<Table.Row>
|
||||
<Table.Cell collapsing colSpan={4} style={{ textAlign: 'center' }}>
|
||||
No Data
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
};
|
||||
|
||||
const content = (user, onUserRemoval, onUserEdit) => {
|
||||
return user.map((user) => {
|
||||
return (
|
||||
<Table.Row key={user.id}>
|
||||
<Table.Cell>{user.username}</Table.Cell>
|
||||
<Table.Cell>{user.lastLogin == null ? '---' : format(user.lastLogin)}</Table.Cell>
|
||||
<Table.Cell>{user.numberOfJobs}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div style={{ float: 'right' }}>
|
||||
<Button circular color="red" icon="trash" onClick={() => onUserRemoval(user.id)} />
|
||||
<Button circular color="blue" icon="edit" onClick={() => onUserEdit(user.id)} />
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
});
|
||||
};
|
||||
const empty = (
|
||||
<Empty
|
||||
image={<IllustrationNoResult />}
|
||||
darkModeImage={<IllustrationNoResultDark />}
|
||||
description={'No user available'}
|
||||
/>
|
||||
);
|
||||
|
||||
export default function UserTable({ user = [], onUserRemoval, onUserEdit } = {}) {
|
||||
return (
|
||||
<Table inverted>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Username</Table.HeaderCell>
|
||||
<Table.HeaderCell>Last login</Table.HeaderCell>
|
||||
<Table.HeaderCell>Number of jobs</Table.HeaderCell>
|
||||
<Table.HeaderCell></Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>{user.length === 0 ? emptyTable() : content(user, onUserRemoval, onUserEdit)}</Table.Body>
|
||||
</Table>
|
||||
<Table
|
||||
pagination={false}
|
||||
empty={empty}
|
||||
columns={[
|
||||
{
|
||||
title: 'Username',
|
||||
dataIndex: 'username',
|
||||
},
|
||||
{
|
||||
title: 'Last login',
|
||||
dataIndex: 'lastLogin',
|
||||
render: (value) => {
|
||||
return format(value);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Number of jobs',
|
||||
dataIndex: 'numberOfJobs',
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'tools',
|
||||
render: (value, user) => {
|
||||
return (
|
||||
<div style={{ float: 'right' }}>
|
||||
<Button
|
||||
type="danger"
|
||||
icon={<IconDelete />}
|
||||
onClick={() => onUserRemoval(user.id)}
|
||||
style={{ marginRight: '1rem' }}
|
||||
/>
|
||||
<Button type="primary" icon={<IconEdit />} onClick={() => onUserEdit(user.id)} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
dataSource={user}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import './Toasts.css';
|
||||
|
||||
export default function Toast({ id, delay = 5500, message, onHide, backgroundColor, color, title }) {
|
||||
const [className, setClassname] = React.useState('toast-container show-toast');
|
||||
|
||||
React.useEffect(() => {
|
||||
let hideTimeout = null;
|
||||
const timeout = setTimeout(() => {
|
||||
setClassname('toast-container hide-toast');
|
||||
hideTimeout = setTimeout(() => {
|
||||
onHide && onHide(id);
|
||||
}, 500);
|
||||
}, delay);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
clearTimeout(hideTimeout);
|
||||
};
|
||||
}, [id, delay, onHide]);
|
||||
return (
|
||||
<div className={className} style={{ backgroundColor, color }}>
|
||||
<h5>{title}</h5>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import Toast from './Toast';
|
||||
import React from 'react';
|
||||
|
||||
export default function ToastsContainer({ toasts, onToastFinished }) {
|
||||
return (
|
||||
<div className="toasts-container">
|
||||
{toasts.map((toast, index) => (
|
||||
<Toast key={index} {...toast} onHide={onToastFinished} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
const CheckoutDrawerContext = createContext({
|
||||
showToast: () => {},
|
||||
});
|
||||
|
||||
export default CheckoutDrawerContext;
|
||||
@@ -1,63 +0,0 @@
|
||||
.toasts-container {
|
||||
position: fixed;
|
||||
z-index: 65535;
|
||||
right: 0;
|
||||
max-width: 250px;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.toasts-container > .toast-container {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.toasts-container:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
visibility: hidden;
|
||||
position: relative;
|
||||
z-index: 65535;
|
||||
right: -1000px;
|
||||
|
||||
background-color: skyblue;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
|
||||
min-width: 10rem;
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
.toast-container.show-toast {
|
||||
visibility: visible;
|
||||
right: 24px;
|
||||
animation: slidein 0.5s;
|
||||
}
|
||||
|
||||
.toast-container.hide-toast {
|
||||
visibility: visible;
|
||||
animation: slideout 0.5s;
|
||||
}
|
||||
|
||||
@keyframes slidein {
|
||||
from {
|
||||
right: -1000px;
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
right: 24px;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideout {
|
||||
from {
|
||||
right: 24px;
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
right: -1000px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function useToast() {
|
||||
const [toasts, setToasts] = React.useState([]);
|
||||
|
||||
const showToast = ({ message, delay, color, backgroundColor, title }) => {
|
||||
const toast = {
|
||||
id: toasts.length,
|
||||
message,
|
||||
delay,
|
||||
backgroundColor,
|
||||
color,
|
||||
title,
|
||||
};
|
||||
setToasts([...toasts, toast].reverse());
|
||||
};
|
||||
|
||||
const onToastFinished = (id) => {
|
||||
setToasts(toasts.filter((toast) => toast.id !== id));
|
||||
};
|
||||
|
||||
return [showToast, onToastFinished, toasts];
|
||||
}
|
||||
@@ -2,14 +2,32 @@ import React from 'react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { Button, Form, Icon, Message, Segment, Radio } from 'semantic-ui-react';
|
||||
import ToastContext from '../../components/toasts/ToastContext';
|
||||
import { Divider, Input, Radio, TimePicker, Button, RadioGroup } from '@douyinfe/semi-ui';
|
||||
import { InputNumber } from '@douyinfe/semi-ui';
|
||||
import Headline from '../../components/headline/Headline';
|
||||
import { xhrPost } from '../../services/xhr';
|
||||
import { SegmentPart } from '../../components/segment/SegmentPart';
|
||||
import { Banner, Toast } from '@douyinfe/semi-ui';
|
||||
import { IconSave, IconCalendar, IconKey, IconRefresh, IconSignal } from '@douyinfe/semi-icons';
|
||||
import './GeneralSettings.less';
|
||||
|
||||
const GeneralSettings = function Users() {
|
||||
function formatFromTimestamp(ts) {
|
||||
const date = new Date(ts);
|
||||
return `${date.getHours()}:${date.getMinutes() > 9 ? date.getMinutes() : '0' + date.getMinutes()}`;
|
||||
}
|
||||
|
||||
function formatFromTBackend(time) {
|
||||
if (time == null || time.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date();
|
||||
const split = time.split(':');
|
||||
date.setHours(split[0]);
|
||||
date.setMinutes(split[1]);
|
||||
return date.getTime();
|
||||
}
|
||||
|
||||
const GeneralSettings = function GeneralSettings() {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
|
||||
@@ -21,13 +39,12 @@ const GeneralSettings = function Users() {
|
||||
const [scrapingAntProxy, setScrapingAntProxy] = React.useState('');
|
||||
const [workingHourFrom, setWorkingHourFrom] = React.useState(null);
|
||||
const [workingHourTo, setWorkingHourTo] = React.useState(null);
|
||||
const ctx = React.useContext(ToastContext);
|
||||
|
||||
React.useEffect(() => {
|
||||
async function init() {
|
||||
await dispatch.generalSettings.getGeneralSettings();
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
init();
|
||||
}, []);
|
||||
|
||||
@@ -40,19 +57,18 @@ const GeneralSettings = function Users() {
|
||||
setWorkingHourTo(settings?.workingHours?.to);
|
||||
setScrapingAntProxy(settings?.scrapingAnt?.proxy || 'datacenter');
|
||||
}
|
||||
|
||||
init();
|
||||
}, [settings]);
|
||||
|
||||
const nullOrEmpty = (val) => val == null || val.length === 0;
|
||||
|
||||
const throwMessage = (message, type) => {
|
||||
ctx.showToast({
|
||||
title: type === 'error' ? 'Error' : 'Success',
|
||||
message: message,
|
||||
delay: 5000,
|
||||
backgroundColor: type === 'error' ? '#db2828' : '#87eb8f',
|
||||
color: type === 'error' ? '#fff' : '#000',
|
||||
});
|
||||
if (type === 'error') {
|
||||
Toast.error(message);
|
||||
} else {
|
||||
Toast.success(message);
|
||||
}
|
||||
};
|
||||
|
||||
const onStore = async () => {
|
||||
@@ -97,139 +113,129 @@ const GeneralSettings = function Users() {
|
||||
{!loading && (
|
||||
<React.Fragment>
|
||||
<Headline text="General Settings" />
|
||||
<Message className="generalSettings__message">
|
||||
<h5>
|
||||
<Icon name="info circle" />
|
||||
Info
|
||||
</h5>
|
||||
<p>If you change any settings, you must restart Fredy afterwards.</p>
|
||||
</Message>
|
||||
<Form>
|
||||
<Banner
|
||||
fullMode={false}
|
||||
type="info"
|
||||
closeIcon={null}
|
||||
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Info</div>}
|
||||
style={{ marginBottom: '1rem' }}
|
||||
description="If you change any settings, you must restart Fredy afterwards."
|
||||
/>
|
||||
<div>
|
||||
<SegmentPart
|
||||
name="Interval"
|
||||
helpText="Interval in minutes for running queries against the configured services."
|
||||
icon="refresh"
|
||||
Icon={IconRefresh}
|
||||
>
|
||||
<Form.Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="1440"
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={1440}
|
||||
placeholder="Interval in minutes"
|
||||
inverted
|
||||
size="mini"
|
||||
width={6}
|
||||
defaultValue={interval}
|
||||
onChange={(e) => setInterval(e.target.value)}
|
||||
value={interval}
|
||||
formatter={(value) => `${value}`.replace(/\D/g, '')}
|
||||
onChange={(value) => setInterval(value)}
|
||||
suffix={'minutes'}
|
||||
/>
|
||||
</SegmentPart>
|
||||
|
||||
<SegmentPart name="Port" helpText="Port on which Fredy is running." icon="connectdevelop">
|
||||
<Form.Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="99999"
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart name="Port" helpText="Port on which Fredy is running." Icon={IconSignal}>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={99999}
|
||||
placeholder="Port"
|
||||
inverted
|
||||
size="mini"
|
||||
width={6}
|
||||
defaultValue={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
value={port}
|
||||
formatter={(value) => `${value}`.replace(/\D/g, '')}
|
||||
onChange={(value) => setPort(value)}
|
||||
/>
|
||||
</SegmentPart>
|
||||
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart
|
||||
name="ScrapingAnt Api Key"
|
||||
helpText="The api key for ScrapingAnt is used to be able to scrape Immoscout."
|
||||
icon="key"
|
||||
Icon={IconKey}
|
||||
>
|
||||
<Form.Input
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="ScrapingAnt Api Key"
|
||||
inverted
|
||||
size="mini"
|
||||
width={6}
|
||||
defaultValue={scrapingAntApiKey}
|
||||
onChange={(e) => setScrapingAntApiKey(e.target.value)}
|
||||
value={scrapingAntApiKey}
|
||||
onChange={(val) => setScrapingAntApiKey(val)}
|
||||
/>
|
||||
</SegmentPart>
|
||||
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart
|
||||
name="ScrapingAnt proxy settings"
|
||||
helpText="Scraping ant provides different proxies."
|
||||
icon="key"
|
||||
Icon={IconKey}
|
||||
>
|
||||
<Message info>
|
||||
ScrapingAnt is needed to scrape Immoscout. ScrapingAnt itself is using 2 different types of proxies.{' '}
|
||||
<br />
|
||||
<h4>Datacenter-Proxy</h4>
|
||||
Proxy server located in one of the datacenters across the world. Datacenter proxies are slower and more
|
||||
likely to fail, but they are cheaper. A call with a datacenter proxy cost 10 credits.
|
||||
<h4>Residential-Proxy</h4>
|
||||
High-quality proxy server located in one of the real people houses across the world. Datacenter proxies
|
||||
are faster and more likely to success, but they are more expensive. A call with a datacenter proxy cost
|
||||
250 credits.
|
||||
<br />
|
||||
<br />
|
||||
<b>
|
||||
On the free tier, you have 10.000 credits, so chose your option wisely. Keep in mind, only successful
|
||||
calls will be charged.
|
||||
</b>
|
||||
</Message>
|
||||
<Form.Field>
|
||||
<Radio
|
||||
label="Datacenter proxy"
|
||||
name="scrapingAntProxy"
|
||||
value="datacenter"
|
||||
checked={scrapingAntProxy === 'datacenter'}
|
||||
onChange={(e, { value }) => setScrapingAntProxy(value)}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Radio
|
||||
label="Residential proxy"
|
||||
name="scrapingAntProxy"
|
||||
value="residential"
|
||||
checked={scrapingAntProxy === 'residential'}
|
||||
onChange={(e, { value }) => setScrapingAntProxy(value)}
|
||||
/>
|
||||
</Form.Field>
|
||||
</SegmentPart>
|
||||
<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.
|
||||
<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)}>
|
||||
<Radio name="datacenter" value="datacenter" checked={scrapingAntProxy === 'datacenter'}>
|
||||
Datacenter proxy
|
||||
</Radio>
|
||||
<Radio name="residential" value="residential" checked={scrapingAntProxy === 'residential'}>
|
||||
Residential proxy
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
</SegmentPart>
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart
|
||||
name="Working hours"
|
||||
helpText="During this hours, Fredy will search for new apartments. If nothing is configured, Fredy will search around the clock."
|
||||
icon="calendar outline"
|
||||
Icon={IconCalendar}
|
||||
>
|
||||
<div className="generalSettings__timePickerContainer">
|
||||
<Form.Input
|
||||
className="generalSettings__time"
|
||||
type="time"
|
||||
placeholder="Working hours from"
|
||||
inverted
|
||||
size="mini"
|
||||
width={2}
|
||||
defaultValue={workingHourFrom}
|
||||
onChange={(e) => setWorkingHourFrom(e.target.value)}
|
||||
<TimePicker
|
||||
format={'HH:mm'}
|
||||
insetLabel="From"
|
||||
value={formatFromTBackend(workingHourFrom)}
|
||||
placeholder=""
|
||||
onChange={(val) => {
|
||||
setWorkingHourFrom(val == null ? null : formatFromTimestamp(val));
|
||||
}}
|
||||
/>
|
||||
<div className="generalSettings__until">until</div>
|
||||
<Form.Input
|
||||
type="time"
|
||||
placeholder="Working hours to"
|
||||
inverted
|
||||
size="mini"
|
||||
width={2}
|
||||
defaultValue={workingHourTo}
|
||||
onChange={(e) => setWorkingHourTo(e.target.value)}
|
||||
<TimePicker
|
||||
format={'HH:mm'}
|
||||
insetLabel="Until"
|
||||
value={formatFromTBackend(workingHourTo)}
|
||||
placeholder=""
|
||||
onChange={(val) => {
|
||||
setWorkingHourTo(val == null ? null : formatFromTimestamp(val));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SegmentPart>
|
||||
|
||||
<Segment inverted floated="right">
|
||||
<Button color="teal" onClick={onStore}>
|
||||
Save
|
||||
</Button>
|
||||
</Segment>
|
||||
</Form>
|
||||
<Divider margin="1rem" />
|
||||
<Button type="primary" theme="solid" onClick={onStore} icon={<IconSave />}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
&__timePickerContainer {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
&__until {
|
||||
margin-left: 1rem;
|
||||
margin-right: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
&__help{
|
||||
@@ -14,8 +10,4 @@
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
&__message{
|
||||
background: #8fe8ff!important;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
import ToastContext from '../../components/toasts/ToastContext';
|
||||
import JobTable from '../../components/table/JobTable';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { xhrDelete, xhrPut } from '../../services/xhr';
|
||||
import { Button, Icon } from 'semantic-ui-react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import ProcessingTimes from './ProcessingTimes';
|
||||
|
||||
import { Button, Toast } from '@douyinfe/semi-ui';
|
||||
import { IconPlusCircle } from '@douyinfe/semi-icons';
|
||||
import './Jobs.less';
|
||||
|
||||
export default function Jobs() {
|
||||
@@ -15,49 +14,24 @@ export default function Jobs() {
|
||||
const processingTimes = useSelector((state) => state.jobs.processingTimes);
|
||||
const history = useHistory();
|
||||
const dispatch = useDispatch();
|
||||
const ctx = React.useContext(ToastContext);
|
||||
|
||||
const onJobRemoval = async (jobId) => {
|
||||
try {
|
||||
await xhrDelete('/api/jobs', { jobId });
|
||||
ctx.showToast({
|
||||
title: 'Success',
|
||||
message: 'Job successfully remove',
|
||||
delay: 5000,
|
||||
backgroundColor: '#87eb8f',
|
||||
color: '#000',
|
||||
});
|
||||
Toast.success('Job successfully remove');
|
||||
await dispatch.jobs.getJobs();
|
||||
} catch (error) {
|
||||
ctx.showToast({
|
||||
title: 'Error',
|
||||
message: error,
|
||||
delay: 35000,
|
||||
backgroundColor: '#db2828',
|
||||
color: '#fff',
|
||||
});
|
||||
Toast.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const onJobStatusChanged = async (jobId, status) => {
|
||||
try {
|
||||
await xhrPut(`/api/jobs/${jobId}/status`, { status });
|
||||
ctx.showToast({
|
||||
title: 'Success',
|
||||
message: 'Job status successfully changed',
|
||||
delay: 5000,
|
||||
backgroundColor: '#87eb8f',
|
||||
color: '#000',
|
||||
});
|
||||
Toast.success('Job status successfully changed');
|
||||
await dispatch.jobs.getJobs();
|
||||
} catch (error) {
|
||||
ctx.showToast({
|
||||
title: 'Error',
|
||||
message: error,
|
||||
delay: 35000,
|
||||
backgroundColor: '#db2828',
|
||||
color: '#fff',
|
||||
});
|
||||
Toast.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -65,8 +39,12 @@ export default function Jobs() {
|
||||
<div>
|
||||
<div>
|
||||
{processingTimes != null && <ProcessingTimes processingTimes={processingTimes} />}
|
||||
<Button primary className="jobs__newButton" onClick={() => history.push('/jobs/new')}>
|
||||
<Icon name="plus" />
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<IconPlusCircle />}
|
||||
className="jobs__newButton"
|
||||
onClick={() => history.push('/jobs/new')}
|
||||
>
|
||||
New Job
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,51 +1,67 @@
|
||||
import React from 'react';
|
||||
import { format } from '../../services/time/timeService';
|
||||
import { Header, Label, Message, Segment } from 'semantic-ui-react';
|
||||
|
||||
import { Card, Descriptions, Divider } from '@douyinfe/semi-ui';
|
||||
import { IconBolt } from '@douyinfe/semi-icons';
|
||||
export default function ProcessingTimes({ processingTimes }) {
|
||||
const { Meta } = Card;
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div>
|
||||
<Label as="span" color="black">
|
||||
Processing Interval:
|
||||
<Label.Detail>{processingTimes.interval} min</Label.Detail>
|
||||
</Label>
|
||||
<>
|
||||
<Descriptions
|
||||
row
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: '#35363c',
|
||||
borderRadius: '4px',
|
||||
padding: '10px',
|
||||
}}
|
||||
>
|
||||
<Descriptions.Item itemKey="Processing Interval">{processingTimes.interval} min</Descriptions.Item>
|
||||
{processingTimes.lastRun && (
|
||||
<React.Fragment>
|
||||
<Label as="span" color="black">
|
||||
Last run:
|
||||
<Label.Detail>{format(processingTimes.lastRun)}</Label.Detail>
|
||||
</Label>
|
||||
<Label as="span" color="black">
|
||||
Next run:
|
||||
<Label.Detail>{format(processingTimes.lastRun + processingTimes.interval * 60000)}</Label.Detail>
|
||||
</Label>
|
||||
</React.Fragment>
|
||||
<>
|
||||
<Descriptions.Item itemKey="Last run">{format(processingTimes.lastRun)}</Descriptions.Item>
|
||||
<Descriptions.Item itemKey="Next run">
|
||||
{format(processingTimes.lastRun + processingTimes.interval * 60000)}
|
||||
</Descriptions.Item>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Descriptions>
|
||||
|
||||
{processingTimes.scrapingAntData != null && (
|
||||
<Segment inverted>
|
||||
<Header as="h5">Remaining ScrapingAnt calls</Header>
|
||||
<Message.List>
|
||||
<Message.Item>Plan: {processingTimes.scrapingAntData.plan_name}</Message.Item>
|
||||
<Message.Item>
|
||||
<>
|
||||
<Divider margin="1rem" />
|
||||
<Card
|
||||
style={{ backgroundColor: '#35363c' }}
|
||||
title={
|
||||
<Meta
|
||||
title="Remaining ScrapingAnt calls"
|
||||
description="Information about your Scraping Ant Plan"
|
||||
avatar={<IconBolt />}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<p>Plan: {processingTimes.scrapingAntData.plan_name}</p>
|
||||
<p>
|
||||
Duration: {format(new Date(processingTimes.scrapingAntData.start_date))} -{' '}
|
||||
{format(new Date(processingTimes.scrapingAntData.end_date))}
|
||||
</Message.Item>
|
||||
<Message.Item>
|
||||
<br />
|
||||
Credits: {processingTimes.scrapingAntData.remained_credits}/
|
||||
{processingTimes.scrapingAntData.plan_total_credits} (250 credits per call)
|
||||
</Message.Item>
|
||||
</Message.List>
|
||||
If you want to scrape Immoscout 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.)
|
||||
</Segment>
|
||||
{processingTimes.scrapingAntData.plan_total_credits}
|
||||
</p>
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
|
||||
|
||||
*/
|
||||
|
||||
@@ -2,19 +2,17 @@ import React, { Fragment, useState } from 'react';
|
||||
|
||||
import NotificationAdapterMutator from './components/notificationAdapter/NotificationAdapterMutator';
|
||||
import NotificationAdapterTable from '../../../components/table/NotificationAdapterTable';
|
||||
import { Icon, Form, Button, Label } from 'semantic-ui-react';
|
||||
import ProviderTable from '../../../components/table/ProviderTable';
|
||||
import ProviderMutator from './components/provider/ProviderMutator';
|
||||
import ToastContext from '../../../components/toasts/ToastContext';
|
||||
import Headline from '../../../components/headline/Headline';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { xhrPost } from '../../../services/xhr';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { Divider, Input, Switch, Button, TagInput, Toast } from '@douyinfe/semi-ui';
|
||||
import './JobMutation.less';
|
||||
import Switch from 'react-switch';
|
||||
import { SegmentPart } from '../../../components/segment/SegmentPart';
|
||||
import { IconPlusCircle } from '@douyinfe/semi-icons';
|
||||
|
||||
export default function JobMutator() {
|
||||
const jobs = useSelector((state) => state.jobs.jobs);
|
||||
@@ -38,7 +36,6 @@ export default function JobMutator() {
|
||||
const [enabled, setEnabled] = useState(defaultEnabled);
|
||||
const history = useHistory();
|
||||
const dispatch = useDispatch();
|
||||
const ctx = React.useContext(ToastContext);
|
||||
|
||||
const isSavingEnabled = () => {
|
||||
return notificationAdapterData.length > 0 && providerData.length > 0 && name != null && name.length > 0;
|
||||
@@ -55,24 +52,11 @@ export default function JobMutator() {
|
||||
jobId: jobToBeEdit?.id || null,
|
||||
});
|
||||
await dispatch.jobs.getJobs();
|
||||
ctx.showToast({
|
||||
title: 'Success',
|
||||
message: 'Job successfully saved...',
|
||||
delay: 5000,
|
||||
backgroundColor: '#87eb8f',
|
||||
color: '#000',
|
||||
});
|
||||
Toast.success('Job successfully saved...');
|
||||
history.push('/jobs');
|
||||
} catch (Exception) {
|
||||
console.error(Exception.json.message);
|
||||
|
||||
ctx.showToast({
|
||||
title: 'Error',
|
||||
message: Exception.json != null ? Exception.json.message : Exception,
|
||||
delay: 8000,
|
||||
backgroundColor: '#db2828',
|
||||
color: '#fff',
|
||||
});
|
||||
Toast.error(Exception.json != null ? Exception.json.message : Exception);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -107,20 +91,19 @@ export default function JobMutator() {
|
||||
)}
|
||||
|
||||
<Headline text={jobToBeEdit ? 'Edit a Job' : 'Create a new Job'} />
|
||||
<Form>
|
||||
<form>
|
||||
<SegmentPart name="Name">
|
||||
<Form.Input
|
||||
<Input
|
||||
autofocus
|
||||
type="text"
|
||||
maxLength={40}
|
||||
placeholder="Name"
|
||||
autoFocus
|
||||
inverted
|
||||
width={6}
|
||||
defaultValue={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
value={name}
|
||||
onChange={(value) => setName(value)}
|
||||
/>
|
||||
</SegmentPart>
|
||||
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart
|
||||
name="Provider"
|
||||
icon="briefcase"
|
||||
@@ -130,10 +113,14 @@ export default function JobMutator() {
|
||||
'to search for new listings.'
|
||||
}
|
||||
>
|
||||
<Form.Button primary className="jobMutation__newButton" onClick={() => setProviderCreationVisibility(true)}>
|
||||
<Icon name="plus" />
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<IconPlusCircle />}
|
||||
className="jobMutation__newButton"
|
||||
onClick={() => setProviderCreationVisibility(true)}
|
||||
>
|
||||
Add new Provider
|
||||
</Form.Button>
|
||||
</Button>
|
||||
|
||||
<ProviderTable
|
||||
providerData={providerData}
|
||||
@@ -142,20 +129,20 @@ export default function JobMutator() {
|
||||
}}
|
||||
/>
|
||||
</SegmentPart>
|
||||
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart
|
||||
icon="bell"
|
||||
name="Notification Adapter"
|
||||
helpText="Fredy supports multiple ways to notify you about new findings. These are called notification adapter. You can chose between email, Telegram etc."
|
||||
>
|
||||
<Form.Button
|
||||
primary
|
||||
<Button
|
||||
type="primary"
|
||||
className="jobMutation__newButton"
|
||||
icon={<IconPlusCircle />}
|
||||
onClick={() => setNotificationCreationVisibility(true)}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
Add new Notification Adapter
|
||||
</Form.Button>
|
||||
</Button>
|
||||
|
||||
<NotificationAdapterTable
|
||||
notificationAdapter={notificationAdapterData}
|
||||
@@ -169,40 +156,19 @@ export default function JobMutator() {
|
||||
}}
|
||||
/>
|
||||
</SegmentPart>
|
||||
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart
|
||||
icon="bell"
|
||||
name="Blacklist"
|
||||
helpText="If a listing contains one of these words, it will be filtered out. Words must be comma separated. To remove a word from the black list, just click the red label(s)."
|
||||
helpText="If a listing contains one of these words, it will be filtered out. Type in a word, then hit enter."
|
||||
>
|
||||
<Form.Input
|
||||
type="text"
|
||||
maxLength={40}
|
||||
placeholder="Comma separated list of blacklisted words"
|
||||
autoFocus
|
||||
inverted
|
||||
width={6}
|
||||
onChange={(e) => {
|
||||
if (e.target.value.indexOf(',') !== -1) {
|
||||
setBlacklist([...blacklist, e.target.value.replace(',', '')]);
|
||||
e.target.value = '';
|
||||
}
|
||||
}}
|
||||
<TagInput
|
||||
value={blacklist || []}
|
||||
placeholder="Add a word for filtering..."
|
||||
onChange={(v) => setBlacklist([...v])}
|
||||
/>
|
||||
{blacklist.map((blacklistWord) => (
|
||||
<Label
|
||||
as="a"
|
||||
key={`blacklist_${blacklistWord}`}
|
||||
onClick={(e, obj) => {
|
||||
setBlacklist(blacklist.filter((word) => word !== obj.content));
|
||||
}}
|
||||
content={blacklistWord}
|
||||
icon="thumbs down"
|
||||
color="red"
|
||||
/>
|
||||
))}
|
||||
</SegmentPart>
|
||||
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart
|
||||
icon="play circle outline"
|
||||
name="Job activation"
|
||||
@@ -210,14 +176,14 @@ export default function JobMutator() {
|
||||
>
|
||||
<Switch className="jobMutation__spaceTop" onChange={(checked) => setEnabled(checked)} checked={enabled} />
|
||||
</SegmentPart>
|
||||
|
||||
<Button color="red" onClick={() => history.push('/jobs')}>
|
||||
<Divider margin="1rem" />
|
||||
<Button type="danger" style={{ marginRight: '1rem' }} onClick={() => history.push('/jobs')}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="green" disabled={!isSavingEnabled()} onClick={mutateJob}>
|
||||
<Button type="primary" icon={<IconPlusCircle />} disabled={!isSavingEnabled()} onClick={mutateJob}>
|
||||
Save
|
||||
</Button>
|
||||
</Form>
|
||||
</form>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
.jobMutation {
|
||||
&__newButton{
|
||||
float: right;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.semi-select-option-list-wrapper {
|
||||
width: 25rem;
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { transform } from '../../../../../services/transformer/notificationAdapterTransformer';
|
||||
import { Modal, Form, Button, Dropdown, Input, Message } from 'semantic-ui-react';
|
||||
import { xhrPost } from '../../../../../services/xhr';
|
||||
import Help from './NotificationHelpDisplay';
|
||||
import { useSelector } from 'react-redux';
|
||||
import Switch from 'react-switch';
|
||||
import { Banner, Button, Form, Modal, Select, Switch } from '@douyinfe/semi-ui';
|
||||
|
||||
import './NotificationAdapterMutator.less';
|
||||
|
||||
@@ -26,9 +25,12 @@ const validate = (selectedAdapter) => {
|
||||
results.push('All fields are mandatory and must be set.');
|
||||
continue;
|
||||
}
|
||||
if (uiElement.type === 'number' && (typeof uiElement.value !== 'number' || uiElement.value < 0)) {
|
||||
results.push('A number field cannot contain anything else and must be > 0.');
|
||||
continue;
|
||||
if (uiElement.type === 'number') {
|
||||
const numberValue = parseFloat(uiElement.value);
|
||||
if(isNaN(numberValue) || numberValue < 0) {
|
||||
results.push('A number field cannot contain anything else and must be > 0.');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (uiElement.type === 'boolean' && typeof uiElement.value !== 'boolean') {
|
||||
results.push('A boolean field cannot be of a different type.');
|
||||
@@ -138,8 +140,7 @@ export default function NotificationAdapterMutator({
|
||||
const uiElement = selectedAdapter.fields[key];
|
||||
|
||||
return (
|
||||
<Form.Field key={uiElement.description}>
|
||||
<label>{uiElement.label}:</label>
|
||||
<Form key={key}>
|
||||
{uiElement.type === 'boolean' ? (
|
||||
<Switch
|
||||
checked={uiElement.value || false}
|
||||
@@ -148,106 +149,108 @@ export default function NotificationAdapterMutator({
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
<Form.Input
|
||||
style={{ width: '100%' }}
|
||||
field={uiElement.label}
|
||||
type={uiElement.type}
|
||||
value={uiElement.value || ''}
|
||||
placeholder={uiElement.label}
|
||||
onChange={(e) => {
|
||||
setValue(selectedAdapter, uiElement, key, e.target.value);
|
||||
label={uiElement.label}
|
||||
onChange={(value) => {
|
||||
setValue(selectedAdapter, uiElement, key, value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Form.Field>
|
||||
</Form>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={() => onVisibilityChanged(false)}
|
||||
onOpen={() => onVisibilityChanged(true)}
|
||||
open={visible}
|
||||
title="Adding a new Notification Adapter"
|
||||
visible={visible}
|
||||
style={{ width: '95%' }}
|
||||
footer={
|
||||
<div>
|
||||
<Button type="secondary" disabled={selectedAdapter == null} style={{ float: 'left' }} onClick={() => onTry()}>
|
||||
Try
|
||||
</Button>
|
||||
<Button type="danger" onClick={() => onSubmit(true)}>
|
||||
Save
|
||||
</Button>
|
||||
<Button type="primary" onClick={() => onSubmit(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Modal.Header>Adding a new Notification Adapter</Modal.Header>
|
||||
<Modal.Content image>
|
||||
<Modal.Description>
|
||||
{validationMessage != null && (
|
||||
<Message negative>
|
||||
<Message.Header>Houston we have a problem...</Message.Header>
|
||||
<p dangerouslySetInnerHTML={{ __html: validationMessage }} />
|
||||
</Message>
|
||||
)}
|
||||
{successMessage != null && (
|
||||
<Message positive>
|
||||
<Message.Header>Yay!</Message.Header>
|
||||
<p dangerouslySetInnerHTML={{ __html: successMessage }} />
|
||||
</Message>
|
||||
)}
|
||||
|
||||
<p>
|
||||
When Fredy found new listings, we like to report them to you. To do so, notification adapter can be
|
||||
configured. <br />
|
||||
There are multiple ways how Fredy can send new listings to you. Chose your weapon...
|
||||
</p>
|
||||
<Dropdown
|
||||
placeholder="Select a notification adapter"
|
||||
className="providerMutator__fields"
|
||||
selection
|
||||
value={selectedAdapter == null ? '' : selectedAdapter.id}
|
||||
options={adapter
|
||||
.map((a) => {
|
||||
return {
|
||||
key: a.id,
|
||||
value: a.id,
|
||||
text: a.name,
|
||||
};
|
||||
})
|
||||
//filter out those, that have already been selected
|
||||
.filter((option) =>
|
||||
editNotificationAdapter != null
|
||||
? true
|
||||
: selected.find((selectedOption) => selectedOption.id === option.key) == null
|
||||
)
|
||||
.sort(sortAdapter)}
|
||||
onChange={(e, { value }) => {
|
||||
setSuccessMessage(null);
|
||||
setValidationMessage(null);
|
||||
const selectedAdapter = adapter.find((a) => a.id === value);
|
||||
setSelectedAdapter(Object.assign({}, selectedAdapter));
|
||||
}}
|
||||
/>
|
||||
<br />
|
||||
<br />
|
||||
{selectedAdapter != null && (
|
||||
<Form>
|
||||
<i>{selectedAdapter.description}</i>
|
||||
<br />
|
||||
{selectedAdapter.readme != null && (
|
||||
<React.Fragment>
|
||||
<Help readme={selectedAdapter.readme} />
|
||||
</React.Fragment>
|
||||
)}
|
||||
<br />
|
||||
{getFieldsFor(selectedAdapter)}
|
||||
</Form>
|
||||
)}
|
||||
</Modal.Description>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button
|
||||
content="Try Notification Adapter"
|
||||
labelPosition="left"
|
||||
floated="left"
|
||||
icon="hand spock"
|
||||
onClick={() => onTry()}
|
||||
color="teal"
|
||||
{validationMessage != null && (
|
||||
<Banner
|
||||
fullMode={false}
|
||||
type="danger"
|
||||
closeIcon={null}
|
||||
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Error</div>}
|
||||
style={{ marginBottom: '1rem' }}
|
||||
description={<p dangerouslySetInnerHTML={{ __html: validationMessage }} />}
|
||||
/>
|
||||
<Button color="black" onClick={() => onSubmit(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button content="Save" labelPosition="right" icon="checkmark" onClick={() => onSubmit(true)} positive />
|
||||
</Modal.Actions>
|
||||
)}
|
||||
{successMessage != null && (
|
||||
<Banner
|
||||
fullMode={false}
|
||||
type="success"
|
||||
closeIcon={null}
|
||||
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Yay!</div>}
|
||||
style={{ marginBottom: '1rem' }}
|
||||
description={<p dangerouslySetInnerHTML={{ __html: successMessage }} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p>
|
||||
When Fredy found new listings, we like to report them to you. To do so, notification adapter can be configured.{' '}
|
||||
<br />
|
||||
There are multiple ways how Fredy can send new listings to you. Chose your weapon...
|
||||
</p>
|
||||
|
||||
<Select
|
||||
filter
|
||||
placeholder="Select a notification adapter"
|
||||
className="providerMutator__fields"
|
||||
value={selectedAdapter == null ? '' : selectedAdapter.id}
|
||||
optionList={adapter
|
||||
.map((a) => {
|
||||
return {
|
||||
otherKey: a.id,
|
||||
value: a.id,
|
||||
label: a.name,
|
||||
};
|
||||
})
|
||||
//filter out those, that have already been selected
|
||||
.filter((option) =>
|
||||
editNotificationAdapter != null
|
||||
? true
|
||||
: selected.find((selectedOption) => selectedOption.id === option.key) == null
|
||||
)
|
||||
.sort(sortAdapter)}
|
||||
onChange={(value) => {
|
||||
setSuccessMessage(null);
|
||||
setValidationMessage(null);
|
||||
const selectedAdapter = adapter.find((a) => a.id === value);
|
||||
setSelectedAdapter(Object.assign({}, selectedAdapter));
|
||||
}}
|
||||
/>
|
||||
<br />
|
||||
<br />
|
||||
{selectedAdapter != null && (
|
||||
<>
|
||||
<i>{selectedAdapter.description}</i>
|
||||
<br />
|
||||
<br />
|
||||
{selectedAdapter.readme != null && <Help readme={selectedAdapter.readme} />}
|
||||
<br />
|
||||
{getFieldsFor(selectedAdapter)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import React from 'react';
|
||||
import { Accordion, Icon } from 'semantic-ui-react';
|
||||
import { Banner } from '@douyinfe/semi-ui';
|
||||
|
||||
export default function Help({ readme }) {
|
||||
const [active, setActive] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Accordion>
|
||||
<Accordion.Title active={active} index={0} onClick={() => setActive(!active)}>
|
||||
<React.Fragment>
|
||||
<Icon name="dropdown" /> <span className="providerMutator__helpLink"> More information</span>
|
||||
</React.Fragment>
|
||||
</Accordion.Title>
|
||||
<Accordion.Content active={active} className="providerMutator__helpBox">
|
||||
<p dangerouslySetInnerHTML={{ __html: readme }} />
|
||||
</Accordion.Content>
|
||||
</Accordion>
|
||||
<Banner
|
||||
fullMode={false}
|
||||
type="info"
|
||||
closeIcon={null}
|
||||
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Information</div>}
|
||||
description={<p dangerouslySetInnerHTML={{ __html: readme }} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Banner, Modal, Select, Input } from '@douyinfe/semi-ui';
|
||||
import { transform } from '../../../../../services/transformer/providerTransformer';
|
||||
import { Modal, Icon, Button, Dropdown, Input, Message } from 'semantic-ui-react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { IconLikeHeart } from '@douyinfe/semi-icons';
|
||||
import './ProviderMutator.less';
|
||||
|
||||
const sortProvider = (a, b) => {
|
||||
@@ -61,79 +61,90 @@ export default function ProviderMutator({ onVisibilityChanged, visible = false,
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal onClose={() => onVisibilityChanged(false)} onOpen={() => onVisibilityChanged(true)} open={visible}>
|
||||
<Modal.Header>Adding a new Provider</Modal.Header>
|
||||
<Modal.Content image>
|
||||
<Modal.Description>
|
||||
{validationMessage != null && (
|
||||
<Message negative>
|
||||
<Message.Header>Houston we have a problem...</Message.Header>
|
||||
<p>{validationMessage}</p>
|
||||
</Message>
|
||||
)}
|
||||
<Modal
|
||||
title="Adding a new Provider"
|
||||
visible={visible}
|
||||
onOk={() => onSubmit(true)}
|
||||
onCancel={() => onSubmit(false)}
|
||||
style={{ width: '50rem' }}
|
||||
okText="Save"
|
||||
>
|
||||
{validationMessage != null && (
|
||||
<Banner
|
||||
fullMode={false}
|
||||
type="danger"
|
||||
closeIcon={null}
|
||||
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Error</div>}
|
||||
style={{ marginBottom: '1rem' }}
|
||||
description={validationMessage}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p>
|
||||
Provider are the <Icon name="heart" color="red" /> of Fredy. We're supporting multiple Provider such as
|
||||
Immowelt, Kalaydo etc. Select a provider from the list below.
|
||||
<br />
|
||||
Fredy will then open the provider's url in a new tab.
|
||||
</p>
|
||||
<p>
|
||||
You will need to configure your search parameter like you would do when you do a regular search on the
|
||||
provider's website.
|
||||
<br />
|
||||
When the search results are shown on the website, copy the url and paste it into the textfield below.
|
||||
<br />
|
||||
<span style={{ color: '#ff0000' }}>
|
||||
If you chose Immoscout as a provider, make sure to also add the scrapingAnt apiKey to the config.json.
|
||||
<p>
|
||||
Provider are the <IconLikeHeart style={{ color: '#ff0000' }} /> of Fredy. We're supporting multiple Provider
|
||||
such as Immowelt, Kalaydo etc. Select a provider from the list below.
|
||||
<br />
|
||||
Fredy will then open the provider's url in a new tab.
|
||||
</p>
|
||||
<p>
|
||||
You will need to configure your search parameter like you would do when you do a regular search on the
|
||||
provider's website.
|
||||
<br />
|
||||
When the search results are shown on the website, copy the url and paste it into the textfield below.
|
||||
</p>
|
||||
<Banner
|
||||
fullMode={false}
|
||||
type="warning"
|
||||
closeIcon={null}
|
||||
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>ScrapingAnt</div>}
|
||||
style={{ marginBottom: '1rem' }}
|
||||
description={
|
||||
<div>
|
||||
<p>
|
||||
If you chose Immoscout, Immonet or NeubauKompass as a provider, make sure to also add the scrapingAnt apiKey to the config.json.
|
||||
(See readme)
|
||||
</span>
|
||||
<br />
|
||||
<span style={{ color: '#ff0000' }}>
|
||||
</p>
|
||||
<p>
|
||||
Do not forget to sort the results by date before copying the url to Fredy, so that Fredy always captures
|
||||
the latest search results.
|
||||
</span>
|
||||
</p>
|
||||
<Dropdown
|
||||
placeholder="Select a provider"
|
||||
className="providerMutator__fields"
|
||||
selection
|
||||
value={selectedProvider == null ? '' : selectedProvider.id}
|
||||
options={provider
|
||||
.map((pro) => {
|
||||
return {
|
||||
key: pro.id,
|
||||
value: pro.id,
|
||||
text: pro.name,
|
||||
};
|
||||
})
|
||||
.sort(sortProvider)}
|
||||
onChange={(e, { value }) => {
|
||||
const selectedProvider = provider.find((pro) => pro.id === value);
|
||||
setSelectedProvider(selectedProvider);
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
window.open(selectedProvider.baseUrl);
|
||||
}}
|
||||
/>
|
||||
<br />
|
||||
<br />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Provider Url"
|
||||
width={10}
|
||||
className="providerMutator__fields"
|
||||
onBlur={(e) => {
|
||||
setProviderUrl(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</Modal.Description>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button color="black" onClick={() => onSubmit(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button content="Save" labelPosition="right" icon="checkmark" onClick={() => onSubmit(true)} positive />
|
||||
</Modal.Actions>
|
||||
<Select
|
||||
filter
|
||||
placeholder="Select a provider"
|
||||
className="providerMutator__fields"
|
||||
optionList={provider
|
||||
.map((pro) => {
|
||||
return {
|
||||
otherKey: pro.id,
|
||||
value: pro.id,
|
||||
label: pro.name,
|
||||
};
|
||||
})
|
||||
.sort(sortProvider)}
|
||||
style={{ width: 180 }}
|
||||
value={selectedProvider == null ? '' : selectedProvider.id}
|
||||
onChange={(value) => {
|
||||
const selectedProvider = provider.find((pro) => pro.id === value);
|
||||
setSelectedProvider(selectedProvider);
|
||||
|
||||
window.open(selectedProvider.baseUrl);
|
||||
}}
|
||||
/>
|
||||
<br />
|
||||
<br />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Provider Url"
|
||||
width={10}
|
||||
className="providerMutator__fields"
|
||||
onBlur={(e) => {
|
||||
setProviderUrl(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React from 'react';
|
||||
import { Input } from 'semantic-ui-react';
|
||||
|
||||
import cityBackground from '../../assets/city_background.jpg';
|
||||
import Logo from '../../components/logo/Logo';
|
||||
import { xhrPost } from '../../services/xhr';
|
||||
import { Message } from 'semantic-ui-react';
|
||||
import { useHistory } from 'react-router';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Input, Button, Banner } from '@douyinfe/semi-ui';
|
||||
|
||||
import './login.less';
|
||||
import { IconUser, IconLock } from '@douyinfe/semi-icons';
|
||||
|
||||
export default function Login() {
|
||||
const dispatch = useDispatch();
|
||||
@@ -44,30 +44,41 @@ export default function Login() {
|
||||
<Logo />
|
||||
<form>
|
||||
<div className="login__loginWrapper">
|
||||
{error && <Message negative icon="error" content={error} />}
|
||||
{error && <Banner type="danger" closeIcon={null} description={error} />}
|
||||
<Input
|
||||
icon="user"
|
||||
iconPosition="left"
|
||||
size="large"
|
||||
prefix={<IconUser />}
|
||||
placeholder="Username"
|
||||
defaultValue={username}
|
||||
value={username}
|
||||
showClear
|
||||
style={{ marginTop: error ? '1rem' : '4rem' }}
|
||||
autoFocus
|
||||
onChange={(e) => setUserName(e.target.value)}
|
||||
autofocus
|
||||
onChange={(value) => setUserName(value)}
|
||||
onKeyPress={async (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
await tryLogin();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
icon="lock"
|
||||
iconPosition="left"
|
||||
defaultValue={password}
|
||||
size="large"
|
||||
mode="password"
|
||||
prefix={<IconLock />}
|
||||
value={password}
|
||||
placeholder="Password"
|
||||
style={{ marginTop: '2rem' }}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onChange={(value) => setPassword(value)}
|
||||
onKeyPress={async (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
await tryLogin();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<button className="ui primary button" style={{ marginTop: '3rem' }} onClick={tryLogin}>
|
||||
<Button type="primary" onClick={tryLogin} theme="solid" style={{ marginTop: '3rem' }}>
|
||||
Login
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
&__loginWrapper {
|
||||
border: 1px solid #555050;
|
||||
border-radius: 30px;
|
||||
height: 25rem;
|
||||
height: 23rem;
|
||||
width: 30rem;
|
||||
z-index: 1;
|
||||
background-color: #151313ab;
|
||||
|
||||
@@ -1,21 +1,9 @@
|
||||
import React from 'react';
|
||||
import { Modal, Header, Icon, Button } from 'semantic-ui-react';
|
||||
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
const UserRemovalModal = function UserRemovalModal({ onOk, onCancel }) {
|
||||
return (
|
||||
<Modal open={true}>
|
||||
<Header icon="warning sign" content="Warning" />
|
||||
<Modal.Content>
|
||||
<p>Removing this user will also remove all associated jobs.</p>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button color="red" onClick={() => onCancel()}>
|
||||
<Icon name="remove" /> Cancel
|
||||
</Button>
|
||||
<Button color="green" onClick={() => onOk()}>
|
||||
<Icon name="checkmark" /> Remove
|
||||
</Button>
|
||||
</Modal.Actions>
|
||||
<Modal title="Removing user" visible={true} closable={false} onOk={onOk} onCancel={onCancel}>
|
||||
<p>Removing this user will also remove all associated jobs.</p>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react';
|
||||
|
||||
import ToastContext from '../../components/toasts/ToastContext';
|
||||
import { Toast } from '@douyinfe/semi-ui';
|
||||
import UserTable from '../../components/table/UserTable';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Button, Icon } from 'semantic-ui-react';
|
||||
import { IconPlus } from '@douyinfe/semi-icons';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
import UserRemovalModal from './UserRemovalModal';
|
||||
import { xhrDelete } from '../../services/xhr';
|
||||
import { useHistory } from 'react-router';
|
||||
@@ -14,7 +15,6 @@ const Users = function Users() {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const users = useSelector((state) => state.user.users);
|
||||
const ctx = React.useContext(ToastContext);
|
||||
const [userIdToBeRemoved, setUserIdToBeRemoved] = React.useState(null);
|
||||
const history = useHistory();
|
||||
|
||||
@@ -23,30 +23,19 @@ const Users = function Users() {
|
||||
await dispatch.user.getUsers();
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
init();
|
||||
}, []);
|
||||
|
||||
const onUserRemoval = async () => {
|
||||
try {
|
||||
await xhrDelete('/api/admin/users', { userId: userIdToBeRemoved });
|
||||
ctx.showToast({
|
||||
title: 'Success',
|
||||
message: 'User successfully remove',
|
||||
delay: 4000,
|
||||
backgroundColor: '#87eb8f',
|
||||
color: '#000',
|
||||
});
|
||||
Toast.success('User successfully remove');
|
||||
setUserIdToBeRemoved(null);
|
||||
await dispatch.jobs.getJobs();
|
||||
await dispatch.user.getUsers();
|
||||
} catch (error) {
|
||||
ctx.showToast({
|
||||
title: 'Error',
|
||||
message: error,
|
||||
delay: 8000,
|
||||
backgroundColor: '#db2828',
|
||||
color: '#fff',
|
||||
});
|
||||
Toast.error(error);
|
||||
setUserIdToBeRemoved(null);
|
||||
}
|
||||
};
|
||||
@@ -57,8 +46,12 @@ const Users = function Users() {
|
||||
<React.Fragment>
|
||||
{userIdToBeRemoved && <UserRemovalModal onCancel={() => setUserIdToBeRemoved(null)} onOk={onUserRemoval} />}
|
||||
|
||||
<Button primary className="users__newButton" onClick={() => history.push('/users/new')}>
|
||||
<Icon name="plus" />
|
||||
<Button
|
||||
type="primary"
|
||||
className="users__newButton"
|
||||
icon={<IconPlus />}
|
||||
onClick={() => history.push('/users/new')}
|
||||
>
|
||||
Create new User
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
import ToastContext from '../../../components/toasts/ToastContext';
|
||||
import { xhrGet, xhrPost } from '../../../services/xhr';
|
||||
import { useHistory, useParams } from 'react-router';
|
||||
import { Button, Form } from 'semantic-ui-react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import Switch from 'react-switch';
|
||||
|
||||
import { Divider, Input, Switch, Button, Toast } from '@douyinfe/semi-ui';
|
||||
import './UserMutator.less';
|
||||
import { SegmentPart } from '../../../components/segment/SegmentPart';
|
||||
import { IconPlusCircle } from '@douyinfe/semi-icons';
|
||||
|
||||
const UserMutator = function UserMutator() {
|
||||
const params = useParams();
|
||||
@@ -18,7 +16,6 @@ const UserMutator = function UserMutator() {
|
||||
const [isAdmin, setIsAdmin] = React.useState(false);
|
||||
|
||||
const history = useHistory();
|
||||
const ctx = React.useContext(ToastContext);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -38,6 +35,7 @@ const UserMutator = function UserMutator() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
}, [params.userId]);
|
||||
|
||||
@@ -51,76 +49,62 @@ const UserMutator = function UserMutator() {
|
||||
isAdmin,
|
||||
});
|
||||
await dispatch.user.getUsers();
|
||||
ctx.showToast({
|
||||
title: 'Success',
|
||||
message: 'User successfully saved...',
|
||||
delay: 5000,
|
||||
backgroundColor: '#87eb8f',
|
||||
color: '#000',
|
||||
});
|
||||
Toast.success('User successfully saved...');
|
||||
history.push('/users');
|
||||
} catch (Exception) {
|
||||
console.error(Exception);
|
||||
ctx.showToast({
|
||||
title: 'Error',
|
||||
message: Exception.json.message,
|
||||
delay: 6000,
|
||||
backgroundColor: '#db2828',
|
||||
color: '#fff',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Toast.error(error.json.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form inverted className="userMutator">
|
||||
<form className="userMutator">
|
||||
<SegmentPart name="Username" helpText="The username used to login to Fredy">
|
||||
<Form.Input
|
||||
<Input
|
||||
type="text"
|
||||
label="Username"
|
||||
maxLength={30}
|
||||
placeholder="Username"
|
||||
autoFocus
|
||||
inverted
|
||||
width={6}
|
||||
defaultValue={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
value={username}
|
||||
onChange={(val) => setUsername(val)}
|
||||
/>
|
||||
</SegmentPart>
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart name="Password" helpText="The password used to login to Fredy">
|
||||
<Form.Input
|
||||
type="password"
|
||||
<Input
|
||||
mode="password"
|
||||
label="Password"
|
||||
placeholder="Password"
|
||||
inverted
|
||||
width={6}
|
||||
defaultValue={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
value={password}
|
||||
onChange={(val) => setPassword(val)}
|
||||
/>
|
||||
</SegmentPart>
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart name="Retype password" helpText="Retype the password to make sure they match">
|
||||
<Form.Input
|
||||
type="password"
|
||||
<Input
|
||||
mode="password"
|
||||
label="Retype password"
|
||||
placeholder="Retype password"
|
||||
inverted
|
||||
width={6}
|
||||
defaultValue={password2}
|
||||
onChange={(e) => setPassword2(e.target.value)}
|
||||
value={password2}
|
||||
onChange={(val) => setPassword2(val)}
|
||||
/>
|
||||
</SegmentPart>
|
||||
<SegmentPart name="Admin use" helpText="Check this if the user is an administrator">
|
||||
<Form.Field>
|
||||
<label>Is user an admin?</label>
|
||||
<Switch checked={isAdmin} onChange={(checked) => setIsAdmin(checked)} />
|
||||
</Form.Field>
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart name="Is user an admin?" helpText="Check this if the user is an administrator">
|
||||
<Switch checked={isAdmin} onChange={(checked) => setIsAdmin(checked)} />
|
||||
</SegmentPart>
|
||||
<Button color="red" onClick={() => history.push('/users')}>
|
||||
<Divider margin="1rem" />
|
||||
<Button type="danger" style={{ marginRight: '1rem' }} onClick={() => history.push('/users')}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="green" onClick={saveUser}>
|
||||
<Button type="primary" icon={<IconPlusCircle />} onClick={saveUser}>
|
||||
Save
|
||||
</Button>
|
||||
</Form>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user