Compare commits

..

51 Commits
5.3.1 ... 5.7.0

Author SHA1 Message Date
Christian Kellner
2062aa11a3 Scrapingant proxies (#59)
* preparing scraping ant proxies

* adding general settings for scraping ant proxy

* retrying with new ui settings
2022-06-13 08:10:30 +02:00
Christian Kellner
a4501007ff next release version 2022-06-10 14:19:41 +02:00
Christian Kellner
bc01806421 fixing telegram provider not respecting rate limits 2022-06-10 14:19:20 +02:00
Christian Kellner
bfba6d4bd9 next release version 2022-04-29 13:26:29 +02:00
Christian Kellner
676d48807a scraping ant retries 2022-04-29 13:22:39 +02:00
Christian Kellner
1a37773a40 Update package.json 2022-04-05 14:36:46 +02:00
Sven
67497d9828 added run-script-os to scripts to separate win use of set from unix use of export (#52)
Co-authored-by: Sven Simonsen <contact@svensimonsen.com>
2022-04-05 09:25:51 +02:00
weakmap@gmail.com
62ea296f3b Merge branch 'master' of https://github.com/orangecoding/fredy 2022-03-27 19:43:09 +02:00
weakmap@gmail.com
52dafcef97 improving ui / ux 2022-03-27 19:42:58 +02:00
Christian Kellner
a06d20ee53 Update README.md 2022-03-26 15:09:33 +01:00
orangecoding
5347d0014d adding scraping ant infos as we now need to use residental proxies 2022-03-25 11:19:17 +01:00
Christian Kellner
946b70003f dependency management | fixing dev mode 2022-03-09 14:28:13 +01:00
Christian Kellner
a6e6656882 Update package.json
next version
2022-03-09 08:59:41 +01:00
Stefan Berger
fbea1aabc4 Add mattermost adapter (#49) (#50) 2022-03-09 05:44:39 +01:00
orangecoding
2dd01ca38f fixing comments 2022-02-15 13:18:20 +01:00
Carl Ambroselli
f010e8951b Add sqlite adapter (#48) 2022-02-15 13:15:27 +01:00
Carl Ambroselli
5225098006 Fix address of immonet (#47) 2022-02-15 13:14:49 +01:00
orangecoding
6e6144e02f fixing tests 2022-02-15 13:14:19 +01:00
Christian Kellner
aa49773a4d improve readme 2022-01-31 10:54:12 +01:00
Jochen Schalanda
b6b8d6814c Make requestDriver more resilient to errors (#46)
If the async request performed in `requestDriver.makeDriver()` fails, it would call the `callback` function with empty parameters but then continue the execution which can lead to the following error and crash of Fredy:
```
Error while trying to scrape data. Received error: Request failed with status code 504
/fredy/lib/services/requestDriver.js:25
    if (typeof result.data === 'object' && url.toLowerCase().indexOf('scrapingant') !== -1) {
                      ^

TypeError: Cannot read properties of undefined (reading 'data')
    at driver (/fredy/lib/services/requestDriver.js:25:23)
    at runMicrotasks (<anonymous>)
    at processTicksAndRejections (node:internal/process/task_queues:96:5)
```
2022-01-31 10:48:11 +01:00
Christian Kellner
b8d658a948 improve instana monitoring 2022-01-31 10:17:46 +01:00
Christian Kellner
bce0c57b02 adding instana monitoring for fredy 2022-01-31 10:01:31 +01:00
Christian Kellner
5e547baa76 Update diagram 2022-01-29 19:35:13 +01:00
Christian Kellner
b368ca7ab8 diagram.drawio 2022-01-29 19:29:02 +01:00
Christian Kellner
eb85641dfb Update CHANGELOG.md 2022-01-28 08:50:46 +01:00
Christian Kellner
0a13037b83 Update CHANGELOG.md
upgrading changelog
2022-01-28 08:50:21 +01:00
Christian Kellner
5600b9766b Update FredyRuntime.js
remove unnecessary todo
2022-01-26 14:48:38 +01:00
Christian Kellner
63b232521e next version 2022-01-26 14:42:23 +01:00
Jochen Schalanda
2f5cc31ae3 Add support for Immo Südwest Presse (immo.swp.de) (#45) 2022-01-26 14:41:44 +01:00
Jochen Schalanda
70e78492ec Telegram: Use job name instead of ID and link in title (#44) 2022-01-26 14:39:53 +01:00
Jochen Schalanda
47adb88cb5 Fix race condition if user ID is in session but not in user store (#43) 2022-01-25 15:11:21 +01:00
Jochen Schalanda
e5627e1d02 Allow visiting the original provider URL (#42)
Instead of truncating the original URL of each provider in the job configuration to 60 characters and losing a lot of context information, put a link to the original URL in the provider table which can be opened directly to verify what is being scraped by Fredy.
2022-01-25 14:20:42 +01:00
Christian Kellner
c831057fba Update package.json
new version
2022-01-25 10:40:11 +01:00
Christian Kellner
355f3bfc76 Update README.md
fixing typos
2022-01-25 10:39:38 +01:00
Christian Kellner
949abcaf09 Update README.md
fixing badge
2022-01-25 10:38:41 +01:00
Christian Kellner
24e925ae0d Update README.md
Adding new container readme, adding new test status badge
2022-01-25 10:37:49 +01:00
Jochen Schalanda
2764b2b776 Refactor Docker build and automatically push images (#41)
Refactor Docker build / Add GitHub workflow for creating and publishing Docker image
2022-01-24 16:41:23 +01:00
Jochen Schalanda
0de6d3df04 Add GitHub Actions workflow to test changes (#40) 2022-01-24 16:34:31 +01:00
Sebastian Wilczek
45a18529ba Spelling and Links within Readme (#39) 2022-01-13 10:39:22 +01:00
orangecoding
84c8aa6d72 Merge branch 'master' of https://github.com/orangecoding/fredy 2021-12-14 20:36:16 +01:00
orangecoding
0f7a07e7fb fixing immowelt test 2021-12-14 20:36:00 +01:00
Christian Kellner
7a65b788b9 Update issue templates 2021-12-04 13:45:20 +01:00
Christian Kellner
ec7689f73f Update issue templates 2021-12-04 13:44:16 +01:00
orangecoding
5a13e6a0a0 next build version 2021-11-28 21:09:40 +01:00
orangecoding
7bd36e554c fixing prod build 2021-11-28 21:09:10 +01:00
orangecoding
d7e3dfc05e fixing version tag.. wups.. 2021-11-26 21:04:16 +01:00
orangecoding
c1c4d55ede adding or replacing sort params for provider urls when necessary 2021-11-26 21:02:09 +01:00
orangecoding
aad0884976 ignoring idea files 2021-11-25 19:16:06 +01:00
Christian Kellner
c0ae72424b Upgrade UI dependencies (#37)
upgrading ui dependencies
2021-11-25 19:13:25 +01:00
Christian Kellner
a3aa512db3 next release 2021-11-12 09:15:34 +01:00
Christian Kellner
8361d9c8ff splitting telegram messages into chunks to avoid errors when message exceeds limit of 4096 chars 2021-11-12 09:14:55 +01:00
53 changed files with 3044 additions and 1990 deletions

View File

@@ -1,3 +1,7 @@
node_modules
node_modules/
npm-debug.log
test
test/
conf/
db/
.git/
.github/

24
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,24 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.

View File

@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

46
.github/workflows/docker.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
name: Create and publish Docker image
on:
push:
branches:
- 'master'
tags:
- '*'
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Checkout repository
uses: actions/checkout@v2
- name: Log in to the Container registry
uses: docker/login-action@v1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v3
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
uses: docker/build-push-action@v2
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

21
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
name: Test
on:
push:
branches:
- master
pull_request:
branches:
- master
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Setup node
uses: actions/setup-node@v2.5.1
with:
node-version: 16
cache: 'yarn'
- run: yarn install
- run: yarn run test

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ ui/public/
db/
npm-debug.log
.DS_Store
.idea

2
.nvmrc
View File

@@ -1 +1 @@
12.18.3
16.14.0

View File

@@ -1,3 +1,37 @@
Newer release changelog see https://github.com/orangecoding/fredy/releases
------------
###### [V5.4.6]
- Adding Instana node.js monitoring
-
###### [V5.4.5]
- Adding Instana node.js monitoring
###### [V5.4.4]
- Add support for Immo Südwest Presse (immo.swp.de)
- Telegram: Use job name instead of ID and link in title
- Fix race condition if user ID is in session but not in user store
- Allow visiting the original provider URL
###### [V5.4.3]
- re-writing readme
- improving docker build
- using github's actions to build docker and test automatically
###### [V5.4.2]
- Fixing prod build
###### [V5.4.1]
- Upgrading dependencies
- Provider urls are now automagically been changed to include the correct sort order for search results
```
Note: It has been an point of confusion since the very beginning of Fredy, that people simply copied the url, but
did not take care of sorting the search results by date. If this is not done, Fredy will most likely not see the latest
results, thus cannot report them. This release fixes it by adding the necessary params (or replaces them).
```
###### [V5.3.0]
- Upgrading dependencies
- It's now possible to send mails to multiple receiver using comma separation for MailJet & Sendgrid
@@ -35,4 +69,4 @@ on the new ui and use the values from your previous config file if needed.
[BREAKING CHANGES]
- The config has been changed, the config of V1.x will not work any longer
- Sources have been renamed to provider
```
```

View File

@@ -17,7 +17,7 @@ function normalize(o) {
return Object.assign(o, { id });
}
//apply blaclist if needed
//apply blacklist if needed
function applyBlacklist(o) {
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);

18
Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
# syntax=docker/dockerfile:1.3
FROM node:16-alpine AS builder
COPY --chown=1000:1000 . /fredy
WORKDIR /fredy
USER 1000
RUN yarn install
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 && \
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

View File

@@ -1,23 +1,23 @@
<img src="https://github.com/orangecoding/fredy/blob/master/doc/logo.png" width="400">
[![Build Status](https://travis-ci.org/orangecoding/fredy.svg?branch=master)](https://travis-ci.org/orangecoding/fredy)
![Build Status](https://github.com/orangecoding/fredy/actions/workflows/test.yml/badge.svg)
Searching an apartment in Germany can be quite frustrating. Not any longer as Fredy will take over and only notifies you once new listings have been found that matches your requirements.
Searching an apartment in Germany can be a frustrating task. Not any longer though, as _Fredy_ will take over and will only notify you once new listings have been found that match your requirements.
_Fredy_ scrapes multiple services (Immonet, Immowelt etc.) and send new listings to you once they appear. The list of available services can easily be extended. For your convenience, a ui helps you to configure your search jobs.
_Fredy_ scrapes multiple services (Immonet, Immowelt etc.) and send new listings to you once they become available. The list of available services can easily be extended. For your convenience, _Fredy_ has a UI to help you configure your search jobs.
If _Fredy_ found matching results, it will send them to you via Slack, Email, Telegram etc. (More adapter possible.) As _Fredy_ will store the listings it has found, new results will not be sent twice (and as a side-effect, _Fredy_ can show some statistics..). Furthermore, _Fredy_ checks duplicates per scraping so that the same listings are not being sent when posted on various platforms. (Happens more often than one might think)
If _Fredy_ finds matching results, it will send them to you via Slack, Email, Telegram etc. (More adapters can be configured.) As _Fredy_ stores the listings it has found, new results will not be sent to you twice (and as a side-effect, _Fredy_ can show some statistics). Furthermore, _Fredy_ checks duplicates per scraping so that the same listings are not being sent twice or more when posted on various platforms (which happens more often than one might think).
## Usage
- Make sure to use NodeJs 12 and above
- Run the following commands
- Make sure to use Node.js 12 or above
- Run the following commands:
```ssh
yarn (or npm install)
yarn run prod
yarn run start
```
_Fredy_ will start with the default port, set to `9998`. You can access _Fredy_ by opening a browser `http://localhost:9998`. The default login is `admin` both for username and password. (You should change the password asap when you plan to run Fredy on your server.)
_Fredy_ will start with the default port, set to `9998`. You can access _Fredy_ by opening 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%">
@@ -30,48 +30,38 @@ _Fredy_ will start with the default port, set to `9998`. You can access _Fredy_
</p>
## Immoscout
I have added **experimental** support for Immoscout. Immoscout is somewhat special, coz they have decided to secure their service from bots using Re-Capture. Finding a way around this is barely possible. For _Fredy_ to be able to bypass the check, I'm using a service called [ScrapingAnt](https://scrapingant.com/). The trick is to use a headless browser, rotating proxies and (once successful validated) re-send the cookies each time.
To be able to use Immoscout, you need to create an account at ScrapingAnt. Configure the ApiKey in the "General Settings" tab (visible when logged in as administrator).
The rest should be done by _Fredy_. Keep in mind, the support is experimental. There might be bugs and you might not always get pass the re-capture check, but most of the time it works pretty good :)
If you need more that the 1000 api calls you can do per month, I'd suggest opting for a paid account... ScrapingAnt loves OpenSource, therefor they've decided to give all _Fredy_ users a 10% discount by using the code **FREDY10** (No I don't get any money for recommending good services...)
## Understanding the fundamentals
There are 3 important parts in Fredy, that you need to understand leveraging the full power of _Fredy_.
There are 3 important parts in Fredy, that you need to understand to leverage the full power of _Fredy_.
#### Adapter
_Fredy_ supports multiple services. Immonet, Immowelt and Ebay are just a few. Those services are called adapter within _Fredy_. When creating a new job, you can choose 1 or many adapter.
An adapter holds the url that points to the search results for the service. If you go to immonet.de and search for something, the shown url in the browser is what the adapter needs to do it's magic.
**It is important that you order the search results by date, so that _Fredy_ always picks the latest ones first**
_Fredy_ supports multiple services. Immonet, Immowelt and Ebay are just a few examples. Those services are called adapters within _Fredy_. When creating a new job, you can choose one or more adapters.
An adapter contains the URL that points to the search results for the respective service. If you go to immonet.de and search for something, the displayed URL in the browser is what the adapter needs to do its magic.
**It is important that you order the search results by date, so that _Fredy_ always picks the latest results first!**
#### Provider
_Fredy_ supports multiple provider. Slack, SendGrid, Telegram etc. A search job can have as many provider as supported by _Fredy_. Each provider needs different configuration values, which you have to provide when using it. A provider itself dictactes how the frontend renders by telling the frontend what information it needs in order to send listings to the user.
_Fredy_ supports multiple providers, such as Slack, SendGrid, Telegram etc. A search job can have as many providers as supported by _Fredy_. Each provider needs different configuration values, which you have to provide when using them. A provider dictactes how the frontend renders by telling the frontend what information it needs in order to send listings to the user.
#### Jobs
A Job wraps adapter and provider. _Fredy_ runs the configured jobs in a specific interval (can be configured in `/conf/config.json`).
A Job wraps adapters and providers. _Fredy_ runs the configured jobs in a specific interval (can be configured in `/conf/config.json`).
## Creating your first job
To create your first job, click on the button "Create New Job" on the job table. The job creation dialog should be self explanatory, however there's one important thing.
When configuring adapter, before copying the url from your browser make sure that you have sorted the results by date to make sure _Fredy_ always picks the latest results first.
To create your first job, click on the button "Create New Job" on the job table. The job creation dialog should be self-explanatory, however there is one important thing.
When configuring adapters, before copying the URL from your browser, make sure that you have sorted the results by date to make sure _Fredy_ always picks the latest results first.
## User management
As an administrator, you can create/edit and remove user from _Fredy_. Be careful, each job is connected to the user that has created the job. If you remove the user, the jobs will also be removed.
As an administrator, you can create, edit and remove users from _Fredy_. Be careful, each job is connected to the user that has created the job. If you remove the user, their jobs will also be removed.
# Development
### Running Fredy in dev mode
To run _Fredy_ in dev mode, you need to run the backend & frontend separately. Run the backend in your favorite IDE, the frontend can be started from the terminal.
### Running Fredy in development mode
To run _Fredy_ in development mode, you need to run the backend & frontend separately. Run the backend in your favorite IDE, the frontend can be started from the terminal.
```shell
yarn run dev
```
You should now be able to access _Fredy_ with your browser. Go to `http://localhost:9000`
You should now be able to access _Fredy_ from your browser. Go to `http://localhost:9000`.
### Running Tests
To run the tests, simply run
To run the tests, run
```shell
yarn run test
```
@@ -79,22 +69,20 @@ yarn run test
# Architecture
![Architecture](/doc/architecture.jpg "Architecture")
## Immoscout
I have added EXPERIMENTAL support for Immoscout. Immoscout is somewhat special, coz they have decided to secure their service from bots using Re-Capture. Finding a way
around this is barely possible. For _Fredy_ to be able to bypass the check, I'm using a service called [ScrapingAnt](https://scrapingant.com/).
### 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.
To be able to use Immoscout, you need to create an account and copy the apiKey into the config file under /conf/config.json.
The rest should be done by _Fredy_. Keep in mind, the support is experimental. There might be bugs and you might not always get pass the re-capture check, but most of the time
it works pretty good :)
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).
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 that the 1000 api calls you can do per month, I'd suggest opting for a paid account... (No I don't get any money for recommending good service)
If you need more than the 1000 API calls allowed per month, I'd suggest opting for a paid account... ScrapingAnt loves OpenSource, therefore they have decided to give all _Fredy_ users a 10% discount by using the code **FREDY10** (Disclaimer: I do not earn any money for recommending their service).
#### Contribution guidelines
### Contribution guidelines
See [Contribution](https://github.com/orangecoding/fredy/blob/master/CONTRIBUTION.md)
See [Contributing](https://github.com/orangecoding/fredy/blob/master/CONTRIBUTING.md)
# Docker
Use the Dockerfile in this Repository to build an image.
Use the Dockerfile in this repository to build an image.
Example: `docker build -t fredy/fredy /path/to/your/Dockerfile`
@@ -102,12 +90,16 @@ Or use docker-compose:
Example `docker-compose build`
Or use the container that will be built automatically.
`docker pull ghcr.io/orangecoding/fredy:master`
## Create & run a container
Put your config.json to `/path/to/your/conf/`
Put your config.json into a path of your choice, such as `/path/to/your/conf/`.
Example: `docker create --name fredy -v /path/to/your/conf/:/conf -p 9998:9998 fredy/fredy`
## Logs
You can browse the logs with `docker logs fredy -f`
You can browse the logs with `docker logs fredy -f`.

View File

@@ -1,33 +0,0 @@
FROM alpine:latest AS build
# use given repository, default below:
ARG repo=https://github.com/orangecoding/fredy.git
RUN mkdir -p /usr/src/
#Install Software
RUN apk add --update nodejs npm git
# Output used repository
RUN echo "Cloning from $repo"
RUN cd /usr/src && git clone $repo
RUN ln -s /usr/src/fredy/conf/ /conf
# create db folder
RUN mkdir /usr/src/fredy/db/
RUN ln -s /usr/src/fredy/db/ /db
RUN npm i -g yarn
RUN cd /usr/src/fredy/ && yarn
WORKDIR /usr/src/fredy
RUN yarn run prod
EXPOSE 9998
VOLUME [ "/conf", "/db" ]
# --no-daemon is required for keeping Container alive
CMD node index.js --no-daemon

View File

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

View File

@@ -0,0 +1,84 @@
<mxfile host="app.diagrams.net" modified="2022-01-29T18:34:51.211Z" agent="5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.99 Safari/537.36" etag="W0jmvptvMSkuHq89hwUy" version="16.5.2" type="github">
<diagram id="C5RBs43oDa-KdzZeNtuy" name="Page-1">
<mxGraphModel dx="850" dy="907" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="WIyWlLk6GJQsqaUBKTNV-0" />
<mxCell id="WIyWlLk6GJQsqaUBKTNV-1" parent="WIyWlLk6GJQsqaUBKTNV-0" />
<mxCell id="4kAlOAlRylSy7JMoHAEd-5" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="WIyWlLk6GJQsqaUBKTNV-1" source="WIyWlLk6GJQsqaUBKTNV-3" target="WIyWlLk6GJQsqaUBKTNV-7">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="WIyWlLk6GJQsqaUBKTNV-3" value="Job1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;glass=0;strokeWidth=1;shadow=0;fillColor=#dae8fc;strokeColor=#6c8ebf;" parent="WIyWlLk6GJQsqaUBKTNV-1" vertex="1">
<mxGeometry x="100" y="50" width="120" height="30" as="geometry" />
</mxCell>
<mxCell id="4kAlOAlRylSy7JMoHAEd-8" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="WIyWlLk6GJQsqaUBKTNV-1" source="WIyWlLk6GJQsqaUBKTNV-7" target="4kAlOAlRylSy7JMoHAEd-2">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="4kAlOAlRylSy7JMoHAEd-10" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="WIyWlLk6GJQsqaUBKTNV-1" source="WIyWlLk6GJQsqaUBKTNV-7" target="4kAlOAlRylSy7JMoHAEd-3">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="4kAlOAlRylSy7JMoHAEd-11" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="WIyWlLk6GJQsqaUBKTNV-1" source="WIyWlLk6GJQsqaUBKTNV-7" target="4kAlOAlRylSy7JMoHAEd-4">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="WIyWlLk6GJQsqaUBKTNV-7" value="FredyRuntime" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;glass=0;strokeWidth=1;shadow=0;fillColor=#fff2cc;strokeColor=#d6b656;" parent="WIyWlLk6GJQsqaUBKTNV-1" vertex="1">
<mxGeometry x="110" y="120" width="360" height="40" as="geometry" />
</mxCell>
<mxCell id="4kAlOAlRylSy7JMoHAEd-6" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="WIyWlLk6GJQsqaUBKTNV-1" source="4kAlOAlRylSy7JMoHAEd-0" target="WIyWlLk6GJQsqaUBKTNV-7">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="4kAlOAlRylSy7JMoHAEd-0" value="Job2" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;glass=0;strokeWidth=1;shadow=0;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="WIyWlLk6GJQsqaUBKTNV-1">
<mxGeometry x="230" y="50" width="120" height="30" as="geometry" />
</mxCell>
<mxCell id="4kAlOAlRylSy7JMoHAEd-7" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="WIyWlLk6GJQsqaUBKTNV-1" source="4kAlOAlRylSy7JMoHAEd-1" target="WIyWlLk6GJQsqaUBKTNV-7">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="4kAlOAlRylSy7JMoHAEd-1" value="Job3" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;glass=0;strokeWidth=1;shadow=0;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="WIyWlLk6GJQsqaUBKTNV-1">
<mxGeometry x="360" y="50" width="120" height="30" as="geometry" />
</mxCell>
<mxCell id="4kAlOAlRylSy7JMoHAEd-13" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="WIyWlLk6GJQsqaUBKTNV-1" source="4kAlOAlRylSy7JMoHAEd-2" target="4kAlOAlRylSy7JMoHAEd-12">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="4kAlOAlRylSy7JMoHAEd-2" value="Provider1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;glass=0;strokeWidth=1;shadow=0;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="WIyWlLk6GJQsqaUBKTNV-1">
<mxGeometry x="100" y="210" width="120" height="40" as="geometry" />
</mxCell>
<mxCell id="4kAlOAlRylSy7JMoHAEd-14" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="WIyWlLk6GJQsqaUBKTNV-1" source="4kAlOAlRylSy7JMoHAEd-3">
<mxGeometry relative="1" as="geometry">
<mxPoint x="290" y="290" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="4kAlOAlRylSy7JMoHAEd-3" value="Provider2" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;glass=0;strokeWidth=1;shadow=0;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="WIyWlLk6GJQsqaUBKTNV-1">
<mxGeometry x="230" y="210" width="120" height="40" as="geometry" />
</mxCell>
<mxCell id="4kAlOAlRylSy7JMoHAEd-15" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="WIyWlLk6GJQsqaUBKTNV-1" source="4kAlOAlRylSy7JMoHAEd-4" target="4kAlOAlRylSy7JMoHAEd-12">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="4kAlOAlRylSy7JMoHAEd-4" value="Provider3" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;glass=0;strokeWidth=1;shadow=0;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="WIyWlLk6GJQsqaUBKTNV-1">
<mxGeometry x="360" y="210" width="120" height="40" as="geometry" />
</mxCell>
<mxCell id="4kAlOAlRylSy7JMoHAEd-17" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="WIyWlLk6GJQsqaUBKTNV-1" source="4kAlOAlRylSy7JMoHAEd-12" target="4kAlOAlRylSy7JMoHAEd-16">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="4kAlOAlRylSy7JMoHAEd-12" value="Similarity check" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;glass=0;strokeWidth=1;shadow=0;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="WIyWlLk6GJQsqaUBKTNV-1">
<mxGeometry x="110" y="290" width="360" height="40" as="geometry" />
</mxCell>
<mxCell id="4kAlOAlRylSy7JMoHAEd-20" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="WIyWlLk6GJQsqaUBKTNV-1" source="4kAlOAlRylSy7JMoHAEd-16" target="4kAlOAlRylSy7JMoHAEd-18">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="4kAlOAlRylSy7JMoHAEd-16" value="Found similarity" style="rhombus;whiteSpace=wrap;html=1;" vertex="1" parent="WIyWlLk6GJQsqaUBKTNV-1">
<mxGeometry x="250" y="360" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="4kAlOAlRylSy7JMoHAEd-21" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="WIyWlLk6GJQsqaUBKTNV-1" source="4kAlOAlRylSy7JMoHAEd-18" target="4kAlOAlRylSy7JMoHAEd-19">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="4kAlOAlRylSy7JMoHAEd-18" value="Notification Adapter1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;glass=0;strokeWidth=1;shadow=0;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="WIyWlLk6GJQsqaUBKTNV-1">
<mxGeometry x="230" y="460" width="120" height="40" as="geometry" />
</mxCell>
<mxCell id="4kAlOAlRylSy7JMoHAEd-19" value="Notification Adapter2" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;glass=0;strokeWidth=1;shadow=0;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="WIyWlLk6GJQsqaUBKTNV-1">
<mxGeometry x="230" y="520" width="120" height="40" as="geometry" />
</mxCell>
<mxCell id="4kAlOAlRylSy7JMoHAEd-22" value="No" style="text;html=1;resizable=0;autosize=1;align=center;verticalAlign=middle;points=[];fillColor=none;strokeColor=none;rounded=0;" vertex="1" parent="WIyWlLk6GJQsqaUBKTNV-1">
<mxGeometry x="300" y="440" width="30" height="20" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

View File

@@ -4,10 +4,8 @@ services:
container_name: fredy
# build from empty build folder to reduce size of image
build:
context: ./build
context: .
dockerfile: Dockerfile
args:
repo: https://github.com/orangecoding/fredy.git
image: fredy/fredy
# map existing config and database
volumes:

View File

@@ -4,6 +4,7 @@ const { setKnownListings, getKnownListings } = require('./services/storage/listi
const notify = require('./notification/notify');
const xray = require('./services/scraper');
const scrapingAnt = require('./services/scrapingAnt');
const urlModifier = require('./services/queryStringMutator');
class FredyRuntime {
/**
@@ -24,7 +25,8 @@ class FredyRuntime {
execute() {
return (
Promise.resolve(this._providerConfig.url)
//modify the url to make sure search order is correctly set
Promise.resolve(urlModifier(this._providerConfig.url, this._providerConfig.sortByDateParam))
//scraping the site and try finding new listings
.then(this._getListings.bind(this))
//bring them in a proper form (dictated by the provider)
@@ -60,7 +62,6 @@ class FredyRuntime {
if (this._providerConfig.paginate != null) {
xray(u, this._providerConfig.crawlContainer, [this._providerConfig.crawlFields])
//the first 2 pages should be enough here
//TODO: Think about automagically sort by date
.limit(2)
.paginate(this._providerConfig.paginate)
.then((listings) => {

View File

@@ -1,10 +1,10 @@
const service = require('restana')();
const jobRouter = service.newRouter();
const axios = require('axios');
const jobStorage = require('../../services/storage/jobStorage');
const userStorage = require('../../services/storage/userStorage');
const immoscoutProvider = require('../../provider/immoscout');
const config = require('../../../conf/config.json');
const { isAdmin } = require('../security');
function doesJobBelongsToUser(job, req) {
@@ -30,9 +30,23 @@ jobRouter.get('/', async (req, res) => {
});
jobRouter.get('/processingTimes', async (req, res) => {
let scrapingAntData = null;
if (config.scrapingAnt.apiKey != null && config.scrapingAnt.apiKey.length > 0) {
try {
const result = await axios({
url: `https://api.scrapingant.com/v1/usage?x-api-key=${config.scrapingAnt.apiKey}`,
});
scrapingAntData = result.data;
} catch (Exception) {
console.error('Could not query plan data from scraping ant.', Exception);
}
}
res.body = {
interval: config.interval,
lastRun: config.lastRun || null,
scrapingAntData,
};
res.send();

View File

@@ -5,13 +5,13 @@ const hasher = require('../../services/security/hash');
loginRouter.get('/user', async (req, res) => {
const currentUserId = req.session.currentUser;
const isAdmin = currentUserId == null ? false : userStorage.getUser(currentUserId).isAdmin;
if (currentUserId == null) {
const currentUser = currentUserId == null ? null : userStorage.getUser(currentUserId);
if (currentUser == null) {
res.body = {};
} else {
res.body = {
userId: currentUserId,
isAdmin,
userId: currentUser.id,
isAdmin: currentUser.isAdmin,
};
}
res.send();

View File

@@ -0,0 +1,52 @@
const { markdown2Html } = require('../../services/markdown');
const { getJob } = require('../../services/storage/jobStorage');
const axios = require('axios');
/**
* sends new listings to mattermost
* @param serviceName e.g immowelt
* @param newListings an array with newly found listings
* @param notificationConfig config of this notification adapter
* @param jobKey name of the current job that is being executed
* @returns {Promise<Void> | void}
*/
exports.send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
const { webhook, channel } = notificationConfig.find((adapter) => adapter.id === 'mattermost').fields;
const job = getJob(jobKey);
const 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'
);
return axios.post(`${webhook}`, {
channel: channel,
text: message,
});
};
/**
* exported config is being used in the frontend to generate the fields
* incoming values will be the keys (and values) of the fields
*
*/
exports.config = {
id: __filename.slice(__dirname.length + 1, -3),
name: 'Mattermost',
readme: markdown2Html('lib/notification/adapter/mattermost.md'),
description: 'Fredy will send new listings to your mattermost team chat.',
fields: {
webhook: {
type: 'text',
label: 'Webhook-URL',
description: 'The incoming webhook url',
},
channel: {
type: 'text',
label: 'Channel',
description: 'The channel where fredy should send notifications to.',
},
},
};

View File

@@ -0,0 +1,5 @@
### Mattermost Adapter
For Mattermost, you need to create a incoming webhook. This is pretty easy. Please visit the steps in the [developer docs](https://docs.mattermost.com/developer/webhooks-incoming.html) and follow the instructions.
As a result, you get the webhook URL for configuration in fredy. In addition, the target channel must be defined.

View File

@@ -0,0 +1,33 @@
const { markdown2Html } = require('../../services/markdown');
const Database = require('better-sqlite3');
/**
* Stores data in a sqlite db in order to use the search results for later analytics
* @param serviceName e.g immowelt
* @param newListings an array with newly found listings
* @param jobKey name of the current job that is being executed
*/
exports.send = ({ serviceName, newListings, jobKey }) => {
const db = new Database('db/listings.db');
const 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();
};
exports.config = {
id: __filename.slice(__dirname.length + 1, -3),
name: 'Sqlite',
description: 'This adapter stores listings in a local sqlite3 database.',
config: {},
readme: markdown2Html('lib/notification/adapter/sqlite.md'),
};

View File

@@ -0,0 +1,3 @@
### Sqlite Adapter
This adapter stores search results in an sqlite database in db/listings.db

View File

@@ -1,33 +1,66 @@
const { markdown2Html } = require('../../services/markdown');
const { getJob } = require('../../services/storage/jobStorage');
const axios = require('axios');
const MAX_ENTITIES_PER_CHUNK = 8;
const RATE_LIMIT_INTERVAL = 1010;
/**
* splitting an array into chunks because Telegram only allows for messages up to
* 4096 chars, thus we have to split messages into chunks
* @param inputArray
* @param perChunk
*/
const arrayChunks = (inputArray, perChunk) =>
inputArray.reduce((all, one, i) => {
const ch = Math.floor(i / perChunk);
all[ch] = [].concat(all[ch] || [], one);
return all;
}, []);
/**
* sends new listings to telegram
* @param serviceName e.g immowelt
* @param newListings an array with newly found listings
* @param notificationConfig config of this notification adapter
* * @param jobKey name of the current job that is being executed
* @param jobKey name of the current job that is being executed
* @returns {Promise<Void> | void}
*/
exports.send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
const { token, chatId } = notificationConfig.find((adapter) => adapter.id === 'telegram').fields;
const job = getJob(jobKey);
const jobName = job == null ? jobKey : job.name;
let message = `Job: ${jobKey} | Service <b>${serviceName}</b> found <b>${newListings.length}</b> new listings:\n\n`;
//we have to split messages into chunk, because otherwise messages are going to become too big and will fail
const chunks = arrayChunks(newListings, MAX_ENTITIES_PER_CHUNK);
message += newListings.map(
(o) =>
`<b>${shorten(o.title.replace(/\*/g, ''), 45)}</b>\n` +
[o.address, o.price, o.size].join(' | ') +
'\n' +
`<a href="${o.link}">${o.link}</a>\n\n`
);
const promises = chunks.map((chunk) => {
let message = `<i>${jobName}</i> (${serviceName}) found <b>${newListings.length}</b> new listings:\n\n`;
message += chunk.map(
(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'
);
return axios.post(`https://api.telegram.org/bot${token}/sendMessage`, {
chat_id: chatId,
text: message,
parse_mode: 'HTML',
disable_web_page_preview: true,
/**
* This is to not break the rate limit. It is to only send 1 message per second
*/
return new Promise((resolve, reject) => {
setTimeout(() => {
axios
.post(`https://api.telegram.org/bot${token}/sendMessage`, {
chat_id: chatId,
text: message,
parse_mode: 'HTML',
disable_web_page_preview: true,
})
.then(() => resolve())
.catch(() => reject());
}, RATE_LIMIT_INTERVAL);
});
});
return Promise.all(promises);
};
function shorten(str, len = 30) {

View File

@@ -22,6 +22,7 @@ function applyBlacklist(o) {
const config = {
url: null,
crawlContainer: '.tabelle',
sortByDateParam: 'sort_type=newest',
crawlFields: {
id: '.inner_object_data input[name="marker_objekt_id"]@value | int',
price: '.tabelle .inner_object_data .single_data_price | removeNewline | trim',

View File

@@ -6,7 +6,7 @@ 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(' • ')[1];
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)
@@ -24,6 +24,7 @@ function applyBlacklist(o) {
const config = {
url: null,
crawlContainer: '#result-list-stage .item',
sortByDateParam: 'sortby=19',
crawlFields: {
id: '@id',
price: 'div[id*="selPrice_"] | trim',

View File

@@ -20,6 +20,7 @@ function applyBlacklist(o) {
const config = {
url: null,
crawlContainer: '#resultListItems li.result-list__listing',
sortByDateParam: 'sorting=2',
crawlFields: {
id: '.result-list-entry@data-obid | int',
price: '.result-list-entry .result-list-entry__criteria .grid-item:first-child dd | removeNewline | trim',

52
lib/provider/immoswp.js Executable file
View File

@@ -0,0 +1,52 @@
const utils = require('../utils');
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 });
}
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: '.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,
};
exports.init = (sourceConfig, blacklist) => {
config.enabled = sourceConfig.enabled;
config.url = sourceConfig.url;
appliedBlackList = blacklist || [];
};
exports.metaInformation = {
name: 'Immo Südwest Presse',
baseUrl: 'https://immo.swp.de/',
id: __filename.slice(__dirname.length + 1, -3),
};
exports.config = config;

View File

@@ -16,6 +16,7 @@ function applyBlacklist(o) {
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",

View File

@@ -21,6 +21,8 @@ function applyBlacklist(o) {
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',

View File

@@ -13,6 +13,7 @@ function applyBlacklist(o) {
const config = {
url: null,
crawlContainer: '.nbk-container >div article',
sortByDateParam: 'Sortierung=Id&Richtung=DESC',
crawlFields: {
id: '@id',
title: 'a.nbk-truncate@title | removeNewline | trim',

View File

@@ -16,6 +16,7 @@ function applyBlacklist(o) {
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',

View File

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

View File

@@ -1,35 +1,76 @@
const axios = require('axios');
const axiosRetry = require('axios-retry');
const config = require('../../conf/config.json');
axiosRetry(axios, { retryDelay: axiosRetry.exponentialDelay, retries: 3 });
const { makeUrlResidential } = require('./scrapingAnt');
//if ScrapingAnt got blocked, this http status is returned
const BLOCKED_HTTP_STATUS = 423;
const NOT_FOUND_HTTP_STATUS = 404;
const MAX_RETRIES_SCRAPING_ANT = 10;
const EXPECTED_STATUS_CODES = [BLOCKED_HTTP_STATUS, NOT_FOUND_HTTP_STATUS];
function makeDriver(headers = {}) {
let cookies = '';
return async function driver(context, callback) {
const url = context.url;
let result;
async function scrapingAntDriver(context, callback, retryCounter = 0) {
const proxyType = config.scrapingAnt?.proxy || 'datacenter';
try {
result = await axios({
const url = proxyType === 'residential' ? makeUrlResidential(context.url) : context.url;
const result = await axios({
url,
headers: {
...headers,
Cookie: cookies,
},
});
} catch (exception) {
console.error(`Error while trying to scrape data. Received error: ${exception.message}`);
callback(null, []);
}
if (typeof result.data === 'object' && url.toLowerCase().indexOf('scrapingant') !== -1) {
//assume we have gotten a response from scrapingAnt
if (cookies.length === 0) {
cookies = result.data.cookies;
}
callback(null, result.data.content);
} else {
} catch (exception) {
/* eslint-disable no-console */
if (!EXPECTED_STATUS_CODES.includes(exception.response?.status)) {
console.error(`Error while trying to scrape data from scraping ant. Received error: ${exception.message}`);
callback(null, []);
return;
}
if (retryCounter <= MAX_RETRIES_SCRAPING_ANT) {
retryCounter++;
console.debug(`ScrapingAnt got blocked. Retrying ${retryCounter} / ${MAX_RETRIES_SCRAPING_ANT}`);
await scrapingAntDriver(context, callback, retryCounter);
} else {
console.error(`Error while trying to scrape data from scraping ant. Received error: ${exception.message}`);
callback(null, []);
}
/* eslint-enable no-console */
}
}
/**
* The regular request driver is taking care of everyting, that doesn't need to be scraped by ScrapingAnt (which is
* everything != Immoscout as of writing this)
*/
return async function driver(context, callback) {
if (context.url.toLowerCase().indexOf('scrapingant') !== -1) {
return scrapingAntDriver(context, callback);
}
try {
const result = await axios({
url: context.url,
headers: {
...headers,
Cookie: cookies,
},
});
callback(null, result.data);
} catch (exception) {
console.error(`Error while trying to scrape data. Received error: ${exception.message}`);
callback(null, []);
}
};
}

View File

@@ -1,6 +1,5 @@
const { metaInformation } = require('../provider/immoscout');
//to better confure re-capture chose a random proxy each time we do a call
const proxies = ['ae', 'br', 'cn', 'de', 'es', 'fr', 'gb', 'hk', 'in', 'it', 'il', 'jp', 'nl', 'ru', 'sa', 'us', 'cz'];
//to better configure re-capture chose a random proxy each time we do a call
const config = require('../../conf/config.json');
const isImmoscout = (id) => {
@@ -8,11 +7,9 @@ const isImmoscout = (id) => {
};
exports.transformUrlForScrapingAnt = (url, id) => {
const randomProxy = proxies[Math.floor(Math.random() * proxies.length)];
if (isImmoscout(id)) {
//only do calls to scrapingAnt when dealing with Immoscout
url = `https://api.scrapingant.com/v1/general?url=${encodeURIComponent(url)}&proxy_country=${randomProxy}`;
url = `https://api.scrapingant.com/v1/general?url=${encodeURIComponent(url)}&proxy_type=datacenter`;
}
return url;
};
@@ -22,3 +19,7 @@ exports.isScrapingAntApiKeySet = () => {
};
exports.isImmoscout = isImmoscout;
exports.makeUrlResidential = (url) => {
return url.replace('datacenter', 'residential');
};

View File

@@ -1,13 +1,18 @@
{
"name": "fredy",
"version": "5.3.1",
"version": "5.7.0",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": {
"start": "node index.js",
"dev": "yarn && export BUILD_DEV='true' && export NODE_ENV='development' && webpack-dev-server --progress --colors --watch --config ./webpack.dev.js",
"prod": "export BUILD_DEV='false' && webpack --node-env=production --config ./webpack.prod.js",
"dev": "run-script-os",
"dev:win32": "yarn && set BUILD_DEV='true' && set NODE_ENV='development' && webpack serve --progress --color --config ./webpack.dev.js",
"dev:default": "yarn && export BUILD_DEV='true' && export NODE_ENV='development' && webpack serve --progress --color --config ./webpack.dev.js",
"prod": "run-script-os",
"prod:win32": "set BUILD_DEV='false' && webpack --node-env=production --config ./webpack.prod.js",
"prod:default": "export BUILD_DEV='false' && webpack --node-env=production --config ./webpack.prod.js",
"format": "prettier --write lib/**/*.js ui/src/**/*.js test/**/*.js *.js --single-quote --print-width 120",
"test": "mocha --timeout 20000 test/**/*.test.js"
"test": "mocha --timeout 3000000 test/**/*.test.js",
"lint": "eslint ./index.js ./lib/**/*.js ./test/**/*.js"
},
"husky": {
"hooks": {
@@ -42,8 +47,8 @@
},
"license": "MIT",
"engines": {
"node": ">=12.13.0",
"npm": ">=6.0.0"
"node": ">=14.0.0",
"npm": ">=7.0.0"
},
"browserslist": [
"> 0.5%",
@@ -52,62 +57,64 @@
"Firefox ESR"
],
"dependencies": {
"@rematch/core": "2.1.0",
"@rematch/loading": "2.1.0",
"@sendgrid/mail": "7.4.7",
"axios": "0.24.0",
"axios-retry": "^3.2.4",
"body-parser": "1.19.0",
"cookie-session": "1.4.0",
"@rematch/core": "2.2.0",
"@rematch/loading": "2.1.2",
"@sendgrid/mail": "7.6.2",
"axios": "0.27.2",
"better-sqlite3": "7.5.1",
"body-parser": "1.20.0",
"cookie-session": "2.0.0",
"handlebars": "4.7.7",
"highcharts": "9.2.2",
"highcharts-react-official": "3.0.0",
"highcharts": "10.0.0",
"highcharts-react-official": "3.1.0",
"lowdb": "1.0.0",
"markdown": "^0.5.0",
"nanoid": "3.1.28",
"node-mailjet": "3.3.4",
"nanoid": "3.3.3",
"node-mailjet": "3.3.13",
"query-string": "7.1.1",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-redux": "7.2.5",
"react-redux": "8.0.1",
"react-router": "5.2.1",
"react-router-dom": "5.3.0",
"react-switch": "^6.0.0",
"redux": "4.1.1",
"redux-thunk": "2.3.0",
"restana": "4.9.1",
"semantic-ui-react": "2.0.4",
"serve-static": "^1.14.1",
"redux": "4.2.0",
"redux-thunk": "2.4.1",
"restana": "4.9.4",
"semantic-ui-react": "2.1.2",
"serve-static": "1.15.0",
"slack": "11.0.2",
"string-similarity": "^4.0.4",
"x-ray": "2.3.4"
},
"devDependencies": {
"@babel/core": "7.15.5",
"@babel/preset-env": "7.15.6",
"@babel/preset-react": "7.14.5",
"@babel/core": "7.17.9",
"@babel/preset-env": "7.16.11",
"@babel/preset-react": "7.16.7",
"babel-eslint": "10.1.0",
"babel-loader": "8.2.2",
"chai": "4.3.4",
"babel-loader": "8.2.5",
"chai": "4.3.6",
"clean-webpack-plugin": "4.0.0",
"copy-webpack-plugin": "9.0.1",
"css-loader": "6.3.0",
"copy-webpack-plugin": "10.2.4",
"css-loader": "6.7.1",
"eslint": "7.32.0",
"eslint-config-prettier": "8.3.0",
"eslint-plugin-react": "7.26.1",
"eslint-config-prettier": "8.5.0",
"eslint-plugin-react": "7.29.4",
"file-loader": "6.2.0",
"history": "5.0.1",
"history": "5.3.0",
"husky": "4.3.8",
"less": "4.1.1",
"less-loader": "10.0.1",
"lint-staged": "11.1.2",
"mocha": "9.1.2",
"prettier": "2.4.1",
"less": "4.1.2",
"less-loader": "10.2.0",
"lint-staged": "12.4.1",
"mocha": "9.2.2",
"prettier": "2.6.2",
"proxyquire": "2.1.3",
"redux-logger": "3.0.6",
"style-loader": "3.3.0",
"run-script-os": "^1.1.6",
"style-loader": "3.3.1",
"url-loader": "4.1.1",
"webpack": "5.56.0",
"webpack-cli": "3.3.12",
"webpack": "5.72.0",
"webpack-cli": "4.9.2",
"webpack-dev-server": "3.11.2",
"webpack-merge": "5.8.0"
}

View File

@@ -39,7 +39,6 @@ describe('#einsAImmobilien testsuite()', () => {
expect(notify.link).to.be.a('string');
/** check the values if possible **/
expect(notify.price).that.does.include('EUR');
expect(notify.size).to.be.not.empty;
expect(notify.title).to.be.not.empty;
expect(notify.link).that.does.include('https://www.1a-immobilienmarkt.de');

View File

@@ -0,0 +1,51 @@
const similarityCache = require('../../lib/services/similarity-check/similarityCache');
const mockNotification = require('../mocks/mockNotification');
const providerConfig = require('./testProvider.json');
const mockStore = require('../mocks/mockStore');
const proxyquire = require('proxyquire').noCallThru();
const expect = require('chai').expect;
const provider = require('../../lib/provider/immoswp');
describe('#immoswp testsuite()', () => {
after(() => {
similarityCache.stopCacheCleanup();
});
provider.init(providerConfig.immoswp, [], []);
const Fredy = proxyquire('../../lib/FredyRuntime', {
'./services/storage/listingsStorage': {
...mockStore,
},
'./notification/notify': mockNotification,
});
it('should test immoswp provider', async () => {
return await new Promise((resolve) => {
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'test1', similarityCache);
fredy.execute().then((listing) => {
expect(listing).to.be.a('array');
const notificationObj = mockNotification.get();
expect(notificationObj).to.be.a('object');
expect(notificationObj.serviceName).to.equal('immoswp');
notificationObj.payload.forEach((notify) => {
/** check the actual structure **/
expect(notify.id).to.be.a('string');
expect(notify.price).to.be.a('string');
expect(notify.size).to.be.a('string');
expect(notify.title).to.be.a('string');
expect(notify.link).to.be.a('string');
expect(notify.address).to.be.a('string');
/** check the values if possible **/
expect(notify.price).that.does.include('€');
expect(notify.title).to.be.not.empty;
expect(notify.link).that.does.include('https://immo.swp.de');
expect(notify.address).to.be.not.empty;
});
resolve();
});
});
});
});

View File

@@ -32,14 +32,13 @@ describe('#immowelt testsuite()', () => {
/** check the actual structure **/
expect(notify.id).to.be.a('string');
expect(notify.price).to.be.a('string');
expect(notify.size).to.be.a('string');
expect(notify.title).to.be.a('string');
expect(notify.link).to.be.a('string');
expect(notify.address).to.be.a('string');
/** check the values if possible **/
expect(notify.price).that.does.include('€');
if (notify.size.trim().toLowerCase() !== 'k.a.') {
if (notify.size != null && notify.size.trim().toLowerCase() !== 'k.a.') {
expect(notify.size).that.does.include('m²');
}
expect(notify.title).to.be.not.empty;

View File

@@ -16,6 +16,10 @@
"url": "https://www.immobilienscout24.de/Suche/de/nordrhein-westfalen/duesseldorf/wohnung-mieten?enteredFrom=one_step_search",
"enabled": true
},
"immoswp": {
"url": "https://immo.swp.de/suchergebnisse?l=M%C3%BCnchen&r=0km&_multiselect_r=0km&ut=private&t=apartment%3Arental&a=de.muenchen&pf=&pt=&rf=0&rt=0&sf=50&st=&yf=&yt=&ff=&ft=&s=most_recently_updated_first&pa=&o=&ad=&u=",
"enabled": true
},
"kalaydo": {
"url": "https://www.kalaydo.de/immobilien/eigentumswohnung-kaufen/o/duesseldorf/4/?attr_gt_estate_size_living_area=90.0&attr_gt_no_of_rooms=3.5&maxPrice=420000.00&radius=5&resultsPerPage=50&sorting=-date",
"enabled": true

View File

@@ -0,0 +1,32 @@
const testData = require('./testData.json');
const expect = require('chai').expect;
const fs = require('fs');
const mutator = require('../../lib/services/queryStringMutator.js');
const queryString = require('query-string');
/**
* Test test might look a bit weird at first, but listen stranger...
* It's not wise to compare 2 urls, as this means all url params must be in the expected order. This is however not
* guaranteed, as params (and their order) are totally variable.
*/
describe('queryStringMutator', () => {
it('should fix all urls', () => {
let _provider = fs.readdirSync('./lib/provider/').map((integPath) => require(`../../lib/provider/${integPath}`));
for (let test of testData) {
const provider = _provider.find((p) => p.metaInformation.id === test.id);
if (provider == null) {
throw new Error(`Cannot find provider for given id: ${test.id}`);
}
const fixedUrl = mutator(test.url, provider.config.sortByDateParam);
const expectedParams = queryString.parseUrl(test.shouldBecome);
const actualParams = queryString.parseUrl(fixedUrl);
//check if all new params are existing
expect(Object.keys(expectedParams.query)).to.include.members(Object.keys(actualParams.query));
expect(Object.values(expectedParams.query)).to.include.members(Object.values(actualParams.query));
}
});
});

View File

@@ -0,0 +1,33 @@
[
{
"url": "https://www.immowelt.de/liste/40589/wohnungen/mieten?d=true&sd=DESC&sf=PRIMARY_PRICE_AMOUNT&sp=1",
"shouldBecome": "https://www.immowelt.de/liste/40589/wohnungen/mieten?d=true&sd=DESC&sf=TIMESTAMP&sp=1",
"id": "immowelt"
},
{
"url": "https://www.1a-immobilienmarkt.de/suchen/duesseldorf/wohnung-mieten.html?search=yes",
"shouldBecome": "https://www.1a-immobilienmarkt.de/suchen/duesseldorf/wohnung-mieten.html?search=yes&sort_type=newest",
"id": "einsAImmobilien"
},
{
"url": "https://www.wg-gesucht.de/1-zimmer-wohnungen-in-Dusseldorf.30.1.1.0.html?sort_column=1&sort_order=0",
"shouldBecome": "https://www.wg-gesucht.de/1-zimmer-wohnungen-in-Dusseldorf.30.1.1.0.html?sort_column=0&sort_order=0",
"id": "wgGesucht"
},
{
"url": "https://www.immonet.de/immobiliensuche/sel.do?sortby=0&suchart=1&objecttype=1&marketingtype=2&parentcat=1&locationname=d%C3%BCsseldorf",
"shouldBecome": "https://www.immonet.de/immobiliensuche/sel.do?sortby=19&suchart=1&objecttype=1&marketingtype=2&parentcat=1&locationname=d%C3%BCsseldorf",
"id": "immonet"
},
{
"url": "https://www.immobilienscout24.de/Suche/de/nordrhein-westfalen/duesseldorf/wohnung-mieten",
"shouldBecome": "https://www.immobilienscout24.de/Suche/de/nordrhein-westfalen/duesseldorf/wohnung-mieten?sorting=2",
"id": "immoscout"
},
{
"url": "https://www.neubaukompass.de/neubau-immobilien/berlin-region/",
"shouldBecome": "https://www.neubaukompass.de/neubau-immobilien/berlin-region/?Sortierung=Id&Richtung=DESC",
"id": "neubauKompass"
}
]

View File

@@ -10,4 +10,12 @@
background-color: #3f3e3ef5;
color: #f1f1f1;
}
}
.ui.inverted.segment{
background: #31303078!important;
}
.ui.black.label, .ui.black.labels .label {
background-color: #31303078!important;
}

View File

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

View File

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

View File

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

View File

@@ -12,10 +12,6 @@ const emptyTable = () => {
);
};
const truncate = (str, n) => {
return str.length > n ? str.substr(0, n - 1) + '…' : str;
};
const content = (providerData, onRemove) => {
return (
<Fragment>
@@ -23,7 +19,11 @@ const content = (providerData, onRemove) => {
return (
<Table.Row key={data.id}>
<Table.Cell>{data.name}</Table.Cell>
<Table.Cell>{truncate(data.url, 60)}</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)} />

View File

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

View File

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

View File

@@ -1,25 +1,50 @@
import React from 'react';
import { format } from '../../services/time/timeService';
import { Label } from 'semantic-ui-react';
import { Header, Label, Message, Segment } from 'semantic-ui-react';
export default function ProcessingTimes({ processingTimes }) {
return (
<React.Fragment>
<Label as="span" color="black">
Processing Interval:
<Label.Detail>{processingTimes.interval} min</Label.Detail>
</Label>
{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>
<div>
<Label as="span" color="black">
Processing Interval:
<Label.Detail>{processingTimes.interval} min</Label.Detail>
</Label>
{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>
)}
</div>
{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>
Duration: {format(new Date(processingTimes.scrapingAntData.start_date))} -{' '}
{format(new Date(processingTimes.scrapingAntData.end_date))}
</Message.Item>
<Message.Item>
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>
)}
</React.Fragment>
);

View File

@@ -2,7 +2,7 @@ import React, { Fragment, useState } from 'react';
import NotificationAdapterMutator from './components/notificationAdapter/NotificationAdapterMutator';
import NotificationAdapterTable from '../../../components/table/NotificationAdapterTable';
import { Header, Icon, Form, Popup, Button, Label } from 'semantic-ui-react';
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';
@@ -14,6 +14,7 @@ import { useParams } from 'react-router';
import './JobMutation.less';
import Switch from 'react-switch';
import { SegmentPart } from '../../../components/segment/SegmentPart';
export default function JobMutator() {
const jobs = useSelector((state) => state.jobs.jobs);
@@ -39,27 +40,6 @@ export default function JobMutator() {
const dispatch = useDispatch();
const ctx = React.useContext(ToastContext);
const header = (name, icon) => (
<Header as="h5" inverted>
<Icon name={icon} inverted />
{name}
</Header>
);
const help = (helpText) => (
<div>
<Popup
content={helpText}
trigger={
<Header as="h6" inverted>
<Icon name="help circle" inverted />
What is this?
</Header>
}
/>
</div>
);
const isSavingEnabled = () => {
return notificationAdapterData.length > 0 && providerData.length > 0 && name != null && name.length > 0;
};
@@ -128,8 +108,8 @@ export default function JobMutator() {
)}
<Headline text={jobToBeEdit ? 'Edit a Job' : 'Create a new Job'} />
<Form className="jobMutation__form">
<div className="jobMutation__block">
<Form>
<SegmentPart name="Name">
<Form.Input
type="text"
maxLength={40}
@@ -140,48 +120,43 @@ export default function JobMutator() {
defaultValue={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
</SegmentPart>
<div className="jobMutation__block jobMutation__separator">
{header('Provider', 'briefcase')}
<SegmentPart
name="Provider"
icon="briefcase"
helpText={
'A provider is essentially the service (Immowelt etc.) that Fredy is using to search for new listings. When adding a new provider, Fredy will open a new tab pointing ' +
'to the website of this provider. You have to adjust your search parameter and click on "Search". If the results are being shown, copy the browser url. This is the url, Fredy will use ' +
'to search for new listings.'
}
>
<Form.Button primary className="jobMutation__newButton" onClick={() => setProviderCreationVisibility(true)}>
<Icon name="plus" />
Add new Provider
</Form.Button>
<div className="jobMutation__helpContainer">
{help(
'A provider is essentially the service (Immowelt etc.) that Fredy is using to search for new listings. When adding a new provider, Fredy will open a new tab pointing ' +
'to the website of this provider. You have to adjust your search parameter and click on "Search". If the results are being shown, copy the browser url. This is the url, Fredy will use ' +
'to search for new listings.'
)}
<Form.Button primary className="jobMutation__newButton" onClick={() => setProviderCreationVisibility(true)}>
<Icon name="plus" />
Add new Provider
</Form.Button>
</div>
<ProviderTable
providerData={providerData}
onRemove={(providerId) => {
setProviderData(providerData.filter((provider) => provider.id !== providerId));
}}
/>
</div>
</SegmentPart>
<div className="jobMutation__block jobMutation__separator">
{header('Notification Adapter', 'bell')}
<div className="jobMutation__helpContainer">
{help(
'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
className="jobMutation__newButton"
onClick={() => setNotificationCreationVisibility(true)}
>
<Icon name="plus" />
Add new Notification Adapter
</Form.Button>
</div>
<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
className="jobMutation__newButton"
onClick={() => setNotificationCreationVisibility(true)}
>
<Icon name="plus" />
Add new Notification Adapter
</Form.Button>
<NotificationAdapterTable
notificationAdapter={notificationAdapterData}
@@ -194,20 +169,15 @@ export default function JobMutator() {
setNotificationCreationVisibility(true);
}}
/>
</div>
<div className="jobMutation__block jobMutation__separator">
{header('Blacklist', 'bell')}
<div className="jobMutation__helpContainer">
{help(
'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).'
)}
</div>
</SegmentPart>
<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)."
>
<Form.Input
type="text"
className="jobMutation__spaceTop"
maxLength={40}
placeholder="Comma separated list of blacklisted words"
autoFocus
@@ -232,19 +202,15 @@ export default function JobMutator() {
color="red"
/>
))}
</div>
<div className="jobMutation__block jobMutation__separator">
{header('Job activation', 'play circle outline')}
<div className="jobMutation__helpContainer">
{help(
'Whether or not the job is activated. If it is not activated, it will be ignored when Fredy checks for new listings.'
)}
</div>
</SegmentPart>
<SegmentPart
icon="play circle outline"
name="Job activation"
helpText="Whether or not the job is activated. If it is not activated, it will be ignored when Fredy checks for new listings."
>
<Switch className="jobMutation__spaceTop" onChange={(checked) => setEnabled(checked)} checked={enabled} />
</div>
</SegmentPart>
<Button color="red" onClick={() => history.push('/jobs')}>
Cancel

View File

@@ -1,29 +1,5 @@
.jobMutation {
&__form {
margin-top:2rem;
}
&__block {
margin-bottom: 2rem;
}
&__newButton{
float: right;
}
&__helpContainer {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
&__spaceTop{
margin-top:1rem !important;
}
&__separator{
background-color: #2b2b2b;
border-radius: 10px;
padding: .8rem;
}
}

View File

@@ -8,6 +8,7 @@ import { useDispatch } from 'react-redux';
import Switch from 'react-switch';
import './UserMutator.less';
import { SegmentPart } from '../../../components/segment/SegmentPart';
const UserMutator = function UserMutator() {
const params = useParams();
@@ -69,40 +70,47 @@ const UserMutator = function UserMutator() {
return (
<Form inverted className="userMutator">
<Form.Input
type="text"
label="Username"
maxLength={30}
placeholder="Username"
autoFocus
inverted
width={6}
defaultValue={username}
onChange={(e) => setUsername(e.target.value)}
/>
<Form.Input
type="password"
label="Password"
placeholder="Password"
inverted
width={6}
defaultValue={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Form.Input
type="password"
label="Retype password"
placeholder="Retype password"
inverted
width={6}
defaultValue={password2}
onChange={(e) => setPassword2(e.target.value)}
/>
<Form.Field>
<label>Is user an admin?</label>
<Switch checked={isAdmin} onChange={(checked) => setIsAdmin(checked)} />
</Form.Field>
<SegmentPart name="Username" helpText="The username used to login to Fredy">
<Form.Input
type="text"
label="Username"
maxLength={30}
placeholder="Username"
autoFocus
inverted
width={6}
defaultValue={username}
onChange={(e) => setUsername(e.target.value)}
/>
</SegmentPart>
<SegmentPart name="Password" helpText="The password used to login to Fredy">
<Form.Input
type="password"
label="Password"
placeholder="Password"
inverted
width={6}
defaultValue={password}
onChange={(e) => setPassword(e.target.value)}
/>
</SegmentPart>
<SegmentPart name="Retype password" helpText="Retype the password to make sure they match">
<Form.Input
type="password"
label="Retype password"
placeholder="Retype password"
inverted
width={6}
defaultValue={password2}
onChange={(e) => setPassword2(e.target.value)}
/>
</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>
</SegmentPart>
<Button color="red" onClick={() => history.push('/users')}>
Cancel
</Button>

3683
yarn.lock

File diff suppressed because it is too large Load Diff