Compare commits

...

15 Commits

Author SHA1 Message Date
Christian Kellner
b3ae5f640c Update README.md 2024-11-20 22:23:05 +01:00
Christian Kellner
8f91267b5d sending tracking information (#116)
* Ability to send tracking information
2024-11-20 22:22:16 +01:00
Christian Kellner
3d59c0096d reverting config changes. accidentally pushed 2024-11-20 08:19:16 +01:00
Christian Kellner
dab6e4edf3 upgrading husky 2024-11-19 13:45:07 +01:00
Christian Kellner
e1c45f18e0 adding action for stale pr's 2024-11-06 16:16:16 +01:00
weakmap@gmail.com
5cceae11cc upgrading dependencies | adding sqlite for later analysis 2024-11-01 17:03:43 +01:00
weakmap@gmail.com
a4c5bfcbf7 fixing tests 2024-10-03 16:09:19 +02:00
weakmap@gmail.com
6d2ab5f958 making sure immowelt does not include suggested ranges 2024-10-03 16:03:47 +02:00
weakmap@gmail.com
d3cb3a5881 regex for einsAImmobilien price normalization | filter listings that does not have all required keys 2024-09-29 16:58:01 +02:00
Christian Kellner
111ef8be43 fixing kleinanzeigen test 2024-09-05 13:36:02 +02:00
Christian Kellner
35feb772d7 upgrading dependencies, fixing immowelt, using hash of price and id as unique identifier for listings 2024-09-05 13:34:14 +02:00
Christian Kellner
1bf012f13e next fredy version 2024-07-24 09:44:13 +02:00
Christian Kellner
933dc3fc64 using node 20 in tests as well 2024-07-24 09:43:11 +02:00
Christian Kellner
42c48fdceb using only 64 bit 2024-07-24 09:41:34 +02:00
Christian Kellner
f07aa0a06d using node 20 2024-07-24 09:39:27 +02:00
41 changed files with 2050 additions and 2071 deletions

View File

@@ -44,4 +44,4 @@ jobs:
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64, linux/i386, linux/arm64 platforms: linux/amd64, linux/arm64

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

@@ -0,0 +1,21 @@
name: "Close stale issues and PRs"
on:
schedule:
- cron: '0 0 * * *' # Daily
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v7
with:
days-before-stale: 30
days-before-close: 7
stale-issue-message: "This issue has been automatically marked as stale due to inactivity."
stale-pr-message: "This PR has been automatically marked as stale due to inactivity."
close-issue-message: "Closing this issue due to prolonged inactivity."
close-pr-message: "Closing this PR due to prolonged inactivity."
exempt-issue-labels: "keep-open"
exempt-pr-labels: "keep-open"
only: "pulls"

View File

@@ -15,7 +15,7 @@ jobs:
- name: Setup node - name: Setup node
uses: actions/setup-node@v2.5.1 uses: actions/setup-node@v2.5.1
with: with:
node-version: 18 node-version: 20
cache: 'yarn' cache: 'yarn'
- run: yarn install - run: yarn install
- run: yarn run test - run: yarn run test

1
.husky/pre-commit Executable file
View File

@@ -0,0 +1 @@
npx lint-staged

View File

@@ -1,2 +0,0 @@
sudo: false
language: node_js

View File

@@ -1,4 +1,4 @@
FROM node:18 FROM node:20
WORKDIR /fredy WORKDIR /fredy

View File

@@ -17,7 +17,7 @@ _Fredy_ is supported by JetBrains under Open Source Support Program
## Usage ## Usage
- Make sure to use Node.js 18 or above - Make sure to use Node.js 20 or above
- Run the following commands: - Run the following commands:
```ssh ```ssh
yarn (or npm install) yarn (or npm install)
@@ -86,14 +86,12 @@ The rest will be handled by _Fredy_. Keep in mind, the support is experimental.
If you need more than the 1000 API calls allowed per month, I'd suggest opting for a paid account... ScrapingAnt loves OpenSource, therefore they have decided to give all _Fredy_ users a 10% discount by using the code **FREDY10** (Disclaimer: I do not earn any money for recommending their service). If you 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).
### 👐 Contributing # Analytics
Thanks to all the people who already contributed! Fredy is completely free (and will always remain free). However, it would be a huge help if youd allow me to collect some analytical data.
Before you freak out, let me explain...
<a href="https://github.com/orangecoding/fredy/graphs/contributors"> If you agree, Fredy will send a ping to my Mixpanel project each time it runs.
<img src="https://contrib.rocks/image?repo=orangecoding/fredy" /> The data includes: names of active adapters/providers, OS, architecture, Node version, and language. The information is entirely anonymous and helps me understand which adapters/providers are most frequently used.</p>
</a> **Thanks**🤘
See [Contributing](https://github.com/orangecoding/fredy/blob/master/CONTRIBUTING.md)
# Docker # Docker
Use the Dockerfile in this repository to build an image. Use the Dockerfile in this repository to build an image.
@@ -114,6 +112,15 @@ Put your config.json into a path of your choice, such as `/path/to/your/conf/`.
Example: `docker create --name fredy -v /path/to/your/conf/:/conf -p 9998:9998 fredy/fredy` Example: `docker create --name fredy -v /path/to/your/conf/:/conf -p 9998:9998 fredy/fredy`
### 👐 Contributing
Thanks to all the people who already contributed!
<a href="https://github.com/orangecoding/fredy/graphs/contributors">
<img src="https://contrib.rocks/image?repo=orangecoding/fredy" />
</a>
See [Contributing](https://github.com/orangecoding/fredy/blob/master/CONTRIBUTING.md)
## Logs ## Logs
You can browse the logs with `docker logs fredy -f`. You can browse the logs with `docker logs fredy -f`.

View File

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

View File

@@ -6,6 +6,7 @@ import * as jobStorage from './lib/services/storage/jobStorage.js';
import FredyRuntime from './lib/FredyRuntime.js'; import FredyRuntime from './lib/FredyRuntime.js';
import { duringWorkingHoursOrNotSet } from './lib/utils.js'; import { duringWorkingHoursOrNotSet } from './lib/utils.js';
import './lib/api/api.js'; import './lib/api/api.js';
import {track} from './lib/services/tracking/Tracker.js';
//if db folder does not exist, ensure to create it before loading anything else //if db folder does not exist, ensure to create it before loading anything else
if (!fs.existsSync('./db')) { if (!fs.existsSync('./db')) {
fs.mkdirSync('./db'); fs.mkdirSync('./db');
@@ -25,6 +26,7 @@ setInterval(
(function exec() { (function exec() {
const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(config, Date.now()); const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(config, Date.now());
if (isDuringWorkingHoursOrNotSet) { if (isDuringWorkingHoursOrNotSet) {
track();
config.lastRun = Date.now(); config.lastRun = Date.now();
jobStorage jobStorage
.getJobs() .getJobs()

View File

@@ -87,7 +87,10 @@ class FredyRuntime {
return listings.map(this._providerConfig.normalize); return listings.map(this._providerConfig.normalize);
} }
_filter(listings) { _filter(listings) {
return listings.filter(this._providerConfig.filter); //only return those where all the fields have been found
const keys = Object.keys(this._providerConfig.crawlFields);
const filteredListings = listings.filter((item) => keys.every((key) => key in item));
return filteredListings.filter(this._providerConfig.filter);
} }
_findNew(listings) { _findNew(listings) {
const newListings = listings.filter((o) => getKnownListings(this._jobKey, this._providerId)[o.id] == null); const newListings = listings.filter((o) => getKnownListings(this._jobKey, this._providerId)[o.id] == null);

View File

@@ -1,5 +1,5 @@
import restana from 'restana'; import restana from 'restana';
import { config, getDirName } from '../../utils.js'; import {config, getDirName, readConfigFromStorage, refreshConfig} from '../../utils.js';
import fs from 'fs'; import fs from 'fs';
const service = restana(); const service = restana();
const generalSettingsRouter = service.newRouter(); const generalSettingsRouter = service.newRouter();
@@ -10,7 +10,9 @@ generalSettingsRouter.get('/', async (req, res) => {
generalSettingsRouter.post('/', async (req, res) => { generalSettingsRouter.post('/', async (req, res) => {
const settings = req.body; const settings = req.body;
try { try {
fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify(settings)); const currentConfig = await readConfigFromStorage();
fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({...currentConfig, ...settings}));
await refreshConfig();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
res.send(new Error('Error while trying to write settings.')); res.send(new Error('Error while trying to write settings.'));

View File

@@ -5,6 +5,7 @@ import * as userStorage from '../../services/storage/userStorage.js';
import * as immoscoutProvider from '../../provider/immoscout.js'; import * as immoscoutProvider from '../../provider/immoscout.js';
import { config } from '../../utils.js'; import { config } from '../../utils.js';
import { isAdmin } from '../security.js'; import { isAdmin } from '../security.js';
import {isScrapingAntApiKeySet} from '../../services/scrapingAnt.js';
const service = restana(); const service = restana();
const jobRouter = service.newRouter(); const jobRouter = service.newRouter();
function doesJobBelongsToUser(job, req) { function doesJobBelongsToUser(job, req) {
@@ -25,8 +26,8 @@ jobRouter.get('/', async (req, res) => {
res.send(); res.send();
}); });
jobRouter.get('/processingTimes', async (req, res) => { jobRouter.get('/processingTimes', async (req, res) => {
let scrapingAntData = null; let scrapingAntData = {};
if (config.scrapingAnt.apiKey != null && config.scrapingAnt.apiKey.length > 0) { if (isScrapingAntApiKeySet()) {
try { try {
const response = await fetch(`https://api.scrapingant.com/v2/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(); scrapingAntData = await response.json();
@@ -38,6 +39,7 @@ jobRouter.get('/processingTimes', async (req, res) => {
interval: config.interval, interval: config.interval,
lastRun: config.lastRun || null, lastRun: config.lastRun || null,
scrapingAntData, scrapingAntData,
error: scrapingAntData?.detail == null ? null : scrapingAntData?.detail
}; };
res.send(); res.send();
}); });

8
lib/defaultConfig.js Normal file
View File

@@ -0,0 +1,8 @@
export const DEFAULT_CONFIG = {
'interval': '60',
'port': 9998,
'scrapingAnt': {'apiKey': '', 'proxy': 'datacenter'},
'workingHours': {'from': '', 'to': ''},
'demoMode': false,
'analyticsEnabled': null
};

View File

@@ -8,7 +8,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
const jobName = job == null ? jobKey : job.name; const jobName = job == null ? jobKey : job.name;
const promises = newListings.map((newListing) => { const promises = newListings.map((newListing) => {
const title = `${jobName} at ${serviceName}: ${newListing.title}`; const title = `${jobName} at ${serviceName}: ${newListing.title}`;
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\Link: ${newListing.link}`; const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nink: ${newListing.link}`;
return fetch(server, { return fetch(server, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },

View File

@@ -0,0 +1,25 @@
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'),
};

View File

@@ -0,0 +1,7 @@
### Sqlite Adapter
This adapter stores search results in a sqlite database located in db/listings.db. This file can be used for further analysis later on.
Fields are:
```
['serviceName', 'jobKey', 'id', 'size', 'rooms', 'price', 'address', 'title', 'link', 'description']
```

View File

@@ -1,18 +1,40 @@
import utils from '../utils.js'; import utils, { buildHash } from '../utils.js';
let appliedBlackList = []; let appliedBlackList = [];
function normalize(o) { function normalize(o) {
let size = `${o.size.replace(' Wohnfläche ', '').trim()}`; let size = `${o.size.replace(' Wohnfläche ', '').trim()}`;
if (o.rooms != null) { if (o.rooms != null) {
size += ` / / ${o.rooms.trim()}`; size += ` / / ${o.rooms.trim()}`;
} }
const link = `https://www.1a-immobilienmarkt.de/expose/${o.id}.html`; const link = `https://www.1a-immobilienmarkt.de/expose/${o.id}.html`;
return Object.assign(o, { size, link }); 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) { function applyBlacklist(o) {
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList); const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList); const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
return titleNotBlacklisted && descNotBlacklisted; return titleNotBlacklisted && descNotBlacklisted;
} }
const config = { const config = {
url: null, url: null,
crawlContainer: '.tabelle', crawlContainer: '.tabelle',
@@ -23,7 +45,6 @@ const config = {
size: '.tabelle .inner_object_data .data_boxes div:nth-child(1)', size: '.tabelle .inner_object_data .data_boxes div:nth-child(1)',
rooms: '.tabelle .inner_object_data .data_boxes div:nth-child(2)', rooms: '.tabelle .inner_object_data .data_boxes div:nth-child(2)',
title: '.tabelle .inner_object_data .tabelle_inhalt_titel_black | removeNewline | trim', title: '.tabelle .inner_object_data .tabelle_inhalt_titel_black | removeNewline | trim',
description: '.tabelle .inner_object_data .objekt_beschreibung | removeNewline | trim',
}, },
normalize: normalize, normalize: normalize,
filter: applyBlacklist, filter: applyBlacklist,

View File

@@ -1,4 +1,4 @@
import utils from '../utils.js'; import utils, {buildHash} from '../utils.js';
let appliedBlackList = []; let appliedBlackList = [];
function shortenLink(link) { function shortenLink(link) {
return link.substring(0, link.indexOf('?')); return link.substring(0, link.indexOf('?'));
@@ -7,12 +7,12 @@ function parseId(shortenedLink) {
return shortenedLink.substring(shortenedLink.lastIndexOf('/') + 1); return shortenedLink.substring(shortenedLink.lastIndexOf('/') + 1);
} }
function normalize(o) { function normalize(o) {
const id = parseId(shortenLink(o.link));
const size = o.size || 'N/A m²'; const size = o.size || 'N/A m²';
const price = o.price || 'N/A €'; const price = o.price || 'N/A €';
const title = o.title || 'No title available'; const title = o.title || 'No title available';
const address = o.address || 'No address available'; const address = o.address || 'No address available';
const link = shortenLink(o.link); const link = shortenLink(o.link);
const id = buildHash(parseId(shortenLink(o.link)), o.price);
return Object.assign(o, { id, price, size, title, address, link }); return Object.assign(o, { id, price, size, title, address, link });
} }
function applyBlacklist(o) { function applyBlacklist(o) {

View File

@@ -1,12 +1,12 @@
import utils from '../utils.js'; import utils, {buildHash} from '../utils.js';
let appliedBlackList = []; let appliedBlackList = [];
function normalize(o) { function normalize(o) {
const id = o.id.substring(o.id.lastIndexOf('/') + 1, o.id.length);
const size = o.size != null ? o.size.replace('Wohnfläche ', '') : 'N/A m²'; const size = o.size != null ? o.size.replace('Wohnfläche ', '') : 'N/A m²';
const price = o.price.replace('Kaufpreis ', ''); const price = o.price.replace('Kaufpreis ', '');
const address = o.address.split(' • ')[o.address.split(' • ').length - 1]; const address = o.address.split(' • ')[o.address.split(' • ').length - 1];
const title = o.title || 'No title available'; const title = o.title || 'No title available';
const link = o.id; const link = 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 }); return Object.assign(o, { id, address, price, size, title, link });
} }
function applyBlacklist(o) { function applyBlacklist(o) {

View File

@@ -1,4 +1,4 @@
import utils from '../utils.js'; import utils, {buildHash} from '../utils.js';
let appliedBlackList = []; let appliedBlackList = [];
function nullOrEmpty(val) { function nullOrEmpty(val) {
return val == null || val.length === 0; return val == null || val.length === 0;
@@ -7,7 +7,8 @@ function normalize(o) {
const title = nullOrEmpty(o.title) ? 'NO TITLE FOUND' : o.title.replace('NEU', ''); const title = nullOrEmpty(o.title) ? 'NO TITLE FOUND' : o.title.replace('NEU', '');
const address = nullOrEmpty(o.address) ? 'NO ADDRESS FOUND' : (o.address || '').replace(/\(.*\),.*$/, '').trim(); const address = nullOrEmpty(o.address) ? 'NO ADDRESS FOUND' : (o.address || '').replace(/\(.*\),.*$/, '').trim();
const link = nullOrEmpty(o.link) ? 'NO LINK' : `https://www.immobilienscout24.de${o.link.substring(o.link.indexOf('/expose'))}`; const link = nullOrEmpty(o.link) ? 'NO LINK' : `https://www.immobilienscout24.de${o.link.substring(o.link.indexOf('/expose'))}`;
return Object.assign(o, { title, address, link }); const id = buildHash(o.id, o.price);
return Object.assign(o, { id, title, address, link });
} }
function applyBlacklist(o) { function applyBlacklist(o) {
return !utils.isOneOf(o.title, appliedBlackList); return !utils.isOneOf(o.title, appliedBlackList);

View File

@@ -1,14 +1,15 @@
import utils from '../utils.js'; import utils, {buildHash} from '../utils.js';
let appliedBlackList = []; let appliedBlackList = [];
function normalize(o) { function normalize(o) {
const id = o.id.substring(o.id.indexOf('-') + 1, o.id.length);
const size = o.size || 'N/A m²'; const size = o.size || 'N/A m²';
const price = (o.price || '--- €').replace('Preis auf Anfrage', '--- €'); const price = (o.price || '--- €').replace('Preis auf Anfrage', '--- €');
const title = o.title || 'No title available'; const title = o.title || 'No title available';
const link = `https://immo.swp.de/immobilien/${id}`; 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 description = o.description;
const id = buildHash(immoId, price);
return Object.assign(o, {id, price, size, title, link, description}); return Object.assign(o, {id, price, size, title, link, description});
} }

View File

@@ -1,24 +1,30 @@
import utils from '../utils.js'; import utils, { buildHash } from '../utils.js';
let appliedBlackList = []; let appliedBlackList = [];
function normalize(o) { function normalize(o) {
return o; const id = buildHash(o.id, o.price);
return Object.assign(o, { id });
} }
function applyBlacklist(o) { function applyBlacklist(o) {
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList); const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList); const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
return titleNotBlacklisted && descNotBlacklisted; return titleNotBlacklisted && descNotBlacklisted;
} }
const config = { const config = {
url: null, url: null,
crawlContainer: "div[class^='EstateItem-']", crawlContainer:
sortByDateParam: 'sd=DESC&sf=TIMESTAMP', 'div[data-testid="serp-card-testid"]:not(div[data-testid="serp-enlargementlist-testid"] div[data-testid="serp-card-testid"])',
sortByDateParam: 'order=DateDesc',
crawlFields: { crawlFields: {
id: 'a@id', id: 'a@id',
price: "div[class^='KeyFacts-'] [data-test='price'] | removeNewline | trim", price: 'div[data-testid="cardmfe-price-testid"] | removeNewline | trim',
size: "div[class^='KeyFacts-'] [data-test='area'] | removeNewline | trim", size: 'div[data-testid="cardmfe-keyfacts-testid"] | removeNewline | trim',
title: "div[class^='FactsMain-'] h2", title: '.css-1cbj9xw',
link: 'a@href', link: 'a@href',
address: "div[class^='estateFacts-'] span | removeNewline | trim", address: 'div[data-testid="cardmfe-description-box-address"] | removeNewline | trim',
}, },
paginate: '#pnlPaging #nlbPlus@href', paginate: '#pnlPaging #nlbPlus@href',
normalize: normalize, normalize: normalize,

View File

@@ -1,10 +1,14 @@
import utils from '../utils.js'; import utils, {buildHash} from '../utils.js';
let appliedBlackList = []; let appliedBlackList = [];
let appliedBlacklistedDistricts = []; let appliedBlacklistedDistricts = [];
function normalize(o) { function normalize(o) {
const size = o.size || '--- m²'; const size = o.size || '--- m²';
return Object.assign(o, { size }); const id = buildHash(o.id, o.price);
return Object.assign(o, {id, size});
} }
function applyBlacklist(o) { function applyBlacklist(o) {
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList); const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList); const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
@@ -12,6 +16,7 @@ function applyBlacklist(o) {
appliedBlacklistedDistricts.length === 0 ? false : utils.isOneOf(o.description, appliedBlacklistedDistricts); appliedBlacklistedDistricts.length === 0 ? false : utils.isOneOf(o.description, appliedBlacklistedDistricts);
return !isBlacklistedDistrict && titleNotBlacklisted && descNotBlacklisted; return !isBlacklistedDistrict && titleNotBlacklisted && descNotBlacklisted;
} }
const config = { const config = {
url: null, url: null,
crawlContainer: '#srchrslt-adtable .ad-listitem ', crawlContainer: '#srchrslt-adtable .ad-listitem ',

View File

@@ -1,15 +1,21 @@
import utils from '../utils.js'; import utils, {buildHash} from '../utils.js';
let appliedBlackList = []; let appliedBlackList = [];
function nullOrEmpty(val) { function nullOrEmpty(val) {
return val == null || val.length === 0; return val == null || val.length === 0;
} }
function normalize(o) { function normalize(o) {
const link = nullOrEmpty(o.link) ? 'NO LINK' : `https://www.neubaukompass.de${o.link.substring(o.link.indexOf('/neubau'))}`; const link = nullOrEmpty(o.link) ? 'NO LINK' : `https://www.neubaukompass.de${o.link.substring(o.link.indexOf('/neubau'))}`;
return {...o, link}; const id = buildHash(o.id, o.price);
return Object.assign(o, {id, link});
} }
function applyBlacklist(o) { function applyBlacklist(o) {
return !utils.isOneOf(o.title, appliedBlackList); return !utils.isOneOf(o.title, appliedBlackList);
} }
const config = { const config = {
url: null, url: null,
crawlContainer: '.nbk-container >div article', crawlContainer: '.nbk-container >div article',

View File

@@ -1,13 +1,18 @@
import utils from '../utils.js'; import utils, {buildHash} from '../utils.js';
let appliedBlackList = []; let appliedBlackList = [];
function normalize(o) { function normalize(o) {
return o; const id = buildHash(o.id, o.price);
return Object.assign(o, {id});
} }
function applyBlacklist(o) { function applyBlacklist(o) {
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList); const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList); const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
return o.id != null && titleNotBlacklisted && descNotBlacklisted; return o.id != null && titleNotBlacklisted && descNotBlacklisted;
} }
const config = { const config = {
url: null, url: null,
crawlContainer: '#main_column .wgg_card', crawlContainer: '#main_column .wgg_card',

View File

@@ -22,7 +22,7 @@ export const transformUrlForScrapingAnt = (url, id) => {
return url; return url;
}; };
export const isScrapingAntApiKeySet = () => { export const isScrapingAntApiKeySet = () => {
return config.scrapingAnt != null && config.scrapingAnt.apiKey != null && config.scrapingAnt.apiKey.length > 0; return config.scrapingAnt != null && config.scrapingAnt.apiKey != null && config.scrapingAnt.apiKey.length > 8;
}; };
export const makeUrlResidential = (url) => { export const makeUrlResidential = (url) => {
return url.replace('datacenter', 'residential'); return url.replace('datacenter', 'residential');

View File

@@ -0,0 +1,42 @@
import Mixpanel from 'mixpanel';
import {getJobs} from '../storage/jobStorage.js';
import {config} from '../../utils.js';
export const track = function () {
//only send tracking information if the user allowed to do so.
if (config.analyticsEnabled) {
const mixpanelTracker = Mixpanel.init('718670ef1c58c0208256c1e408a3d75e');
const activeProvider = new Set();
const activeAdapter = new Set();
const platform = process.platform;
const arch = process.arch;
const language = process.env.LANG || 'en';
const nodeVersion = process.version || 'N/A';
const jobs = getJobs();
if (jobs != null && jobs.length > 0) {
jobs.forEach(job => {
job.provider.forEach(provider => {
activeProvider.add(provider.id);
});
job.notificationAdapter.forEach(adapter => {
activeAdapter.add(adapter.id);
});
});
mixpanelTracker.track('fredy_tracking', {
adapter: Array.from(activeAdapter),
provider: Array.from(activeProvider),
isDemo: config.demoMode,
platform,
arch,
nodeVersion,
language
});
}
}
};

View File

@@ -1,6 +1,8 @@
import {dirname} from 'node:path'; import {dirname} from 'node:path';
import {fileURLToPath} from 'node:url'; import {fileURLToPath} from 'node:url';
import {readFile} from 'fs/promises'; import {readFile} from 'fs/promises';
import {createHash} from 'crypto';
import {DEFAULT_CONFIG} from './defaultConfig.js';
function isOneOf(word, arr) { function isOneOf(word, arr) {
if (arr == null || arr.length === 0) { if (arr == null || arr.length === 0) {
@@ -10,9 +12,11 @@ function isOneOf(word, arr) {
const blacklist = new RegExp(expression, 'ig'); const blacklist = new RegExp(expression, 'ig');
return blacklist.test(word); return blacklist.test(word);
} }
function nullOrEmpty(val) { function nullOrEmpty(val) {
return val == null || val.length === 0; return val == null || val.length === 0;
} }
function timeStringToMs(timeString, now) { function timeStringToMs(timeString, now) {
const d = new Date(now); const d = new Date(now);
const parts = timeString.split(':'); const parts = timeString.split(':');
@@ -21,6 +25,7 @@ function timeStringToMs(timeString, now) {
d.setSeconds(0); d.setSeconds(0);
return d.getTime(); return d.getTime();
} }
function duringWorkingHoursOrNotSet(config, now) { function duringWorkingHoursOrNotSet(config, now) {
const {workingHours} = config; const {workingHours} = config;
if (workingHours == null || nullOrEmpty(workingHours.from) || nullOrEmpty(workingHours.to)) { if (workingHours == null || nullOrEmpty(workingHours.from) || nullOrEmpty(workingHours.to)) {
@@ -35,13 +40,43 @@ function getDirName() {
return dirname(fileURLToPath(import.meta.url)); return dirname(fileURLToPath(import.meta.url));
} }
const config = JSON.parse(await readFile(new URL('../conf/config.json', import.meta.url))); function buildHash(...inputs) {
if (inputs == null) {
return null;
}
const cleaned = inputs.filter(i => i != null && i.length > 0);
if (cleaned.length === 0) {
return null;
}
return createHash('sha256')
.update(cleaned.join(','))
.digest('hex');
}
let config = {};
export async function readConfigFromStorage(){
return JSON.parse(await readFile(new URL('../conf/config.json', import.meta.url)));
}
export async function refreshConfig(){
try {
config = await readConfigFromStorage();
//backwards compatability...
config.analyticsEnabled ??= null;
config.demoMode ??= false;
} catch (error) {
config = {...DEFAULT_CONFIG};
console.error('Error reading config file', error);
}
}
await refreshConfig();
export {isOneOf}; export {isOneOf};
export {nullOrEmpty}; export {nullOrEmpty};
export {duringWorkingHoursOrNotSet}; export {duringWorkingHoursOrNotSet};
export {getDirName}; export {getDirName};
export {config}; export {config};
export {buildHash};
export default { export default {
isOneOf, isOneOf,
nullOrEmpty, nullOrEmpty,

View File

@@ -1,6 +1,6 @@
{ {
"name": "fredy", "name": "fredy",
"version": "8.1.0", "version": "10.3.0",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].", "description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": { "scripts": {
"start": "node index.js", "start": "node index.js",
@@ -11,11 +11,6 @@
"test": "mocha --loader=esmock --timeout 3000000 test/**/*.test.js", "test": "mocha --loader=esmock --timeout 3000000 test/**/*.test.js",
"lint": "eslint ./index.js ./lib/**/*.js ./test/**/*.js ./ui/src/**/*.jsx" "lint": "eslint ./index.js ./lib/**/*.js ./test/**/*.js ./ui/src/**/*.jsx"
}, },
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"type": "module", "type": "module",
"lint-staged": { "lint-staged": {
"*.js": [ "*.js": [
@@ -45,7 +40,7 @@
}, },
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=16.0.0", "node": ">=20.0.0",
"npm": ">=7.0.0" "npm": ">=7.0.0"
}, },
"browserslist": [ "browserslist": [
@@ -55,24 +50,25 @@
"Firefox ESR" "Firefox ESR"
], ],
"dependencies": { "dependencies": {
"@douyinfe/semi-ui": "2.62.1", "@douyinfe/semi-ui": "2.69.2",
"@rematch/core": "2.2.0", "@rematch/core": "2.2.0",
"@rematch/loading": "2.1.2", "@rematch/loading": "2.1.2",
"@sendgrid/mail": "8.1.3", "@sendgrid/mail": "8.1.4",
"@vitejs/plugin-react": "4.3.1", "@vitejs/plugin-react": "4.3.3",
"better-sqlite3": "8.6.0", "better-sqlite3": "^11.5.0",
"body-parser": "1.20.2", "body-parser": "1.20.3",
"cookie-session": "2.1.0", "cookie-session": "2.1.0",
"handlebars": "4.7.8", "handlebars": "4.7.8",
"highcharts": "11.4.6", "highcharts": "11.4.8",
"highcharts-react-official": "3.2.1", "highcharts-react-official": "3.2.1",
"lodash": "4.17.21", "lodash": "4.17.21",
"lowdb": "6.0.1", "lowdb": "6.0.1",
"markdown": "^0.5.0", "markdown": "^0.5.0",
"nanoid": "5.0.7", "mixpanel": "^0.18.0",
"nanoid": "5.0.8",
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
"node-mailjet": "6.0.5", "node-mailjet": "6.0.6",
"query-string": "8.2.0", "query-string": "9.1.1",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-redux": "9.1.2", "react-redux": "9.1.2",
@@ -81,27 +77,27 @@
"redux": "5.0.1", "redux": "5.0.1",
"redux-thunk": "3.1.0", "redux-thunk": "3.1.0",
"restana": "4.9.9", "restana": "4.9.9",
"serve-static": "1.15.0", "serve-static": "1.16.2",
"slack": "11.0.2", "slack": "11.0.2",
"string-similarity": "^4.0.4", "string-similarity": "^4.0.4",
"vite": "5.3.4", "vite": "5.4.11",
"x-ray": "2.3.4" "x-ray": "2.3.4"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.24.9", "@babel/core": "7.26.0",
"@babel/eslint-parser": "7.24.8", "@babel/eslint-parser": "7.25.9",
"@babel/preset-env": "7.24.8", "@babel/preset-env": "7.26.0",
"@babel/preset-react": "7.24.7", "@babel/preset-react": "7.25.9",
"chai": "5.1.1", "chai": "5.1.2",
"eslint": "8.56.0", "eslint": "8.56.0",
"eslint-config-prettier": "8.8.0", "eslint-config-prettier": "8.8.0",
"eslint-plugin-react": "7.35.0", "eslint-plugin-react": "7.37.2",
"esmock": "2.6.7", "esmock": "2.6.9",
"history": "5.3.0", "history": "5.3.0",
"husky": "4.3.8", "husky": "9.1.7",
"less": "4.2.0", "less": "4.2.0",
"lint-staged": "13.2.2", "lint-staged": "15.2.10",
"mocha": "10.7.0", "mocha": "10.8.2",
"prettier": "3.3.3", "prettier": "3.3.3",
"redux-logger": "3.0.6" "redux-logger": "3.0.6"
} }

View File

@@ -20,7 +20,7 @@ describe('#einsAImmobilien testsuite()', () => {
expect(notificationObj.serviceName).to.equal('einsAImmobilien'); expect(notificationObj.serviceName).to.equal('einsAImmobilien');
notificationObj.payload.forEach((notify) => { notificationObj.payload.forEach((notify) => {
/** check the actual structure **/ /** check the actual structure **/
expect(notify.id).to.be.a('number'); expect(notify.id).to.be.a('string');
expect(notify.price).to.be.a('string'); expect(notify.price).to.be.a('string');
expect(notify.size).to.be.a('string'); expect(notify.size).to.be.a('string');
expect(notify.title).to.be.a('string'); expect(notify.title).to.be.a('string');

View File

@@ -20,7 +20,7 @@ describe('#kleinanzeigen testsuite()', () => {
expect(notificationObj.serviceName).to.equal('kleinanzeigen'); expect(notificationObj.serviceName).to.equal('kleinanzeigen');
notificationObj.payload.forEach((notify) => { notificationObj.payload.forEach((notify) => {
/** check the actual structure **/ /** check the actual structure **/
expect(notify.id).to.be.a('number'); expect(notify.id).to.be.a('string');
expect(notify.title).to.be.a('string'); expect(notify.title).to.be.a('string');
expect(notify.link).to.be.a('string'); expect(notify.link).to.be.a('string');
expect(notify.address).to.be.a('string'); expect(notify.address).to.be.a('string');

View File

@@ -10,7 +10,7 @@ describe('#neubauKompass testsuite()', () => {
similarityCache.stopCacheCleanup(); similarityCache.stopCacheCleanup();
}); });
provider.init(providerConfig.neubauKompass, [], []); provider.init(providerConfig.neubauKompass, [], []);
it.only('should test neubauKompass provider', async () => { it('should test neubauKompass provider', async () => {
const Fredy = await mockFredy(); const Fredy = await mockFredy();
return await new Promise((resolve) => { return await new Promise((resolve) => {
if (!scrapingAnt.isScrapingAntApiKeySet()) { if (!scrapingAnt.isScrapingAntApiKeySet()) {

View File

@@ -13,7 +13,7 @@
"enabled": true "enabled": true
}, },
"immowelt": { "immowelt": {
"url": "https://www.immowelt.de/liste/duesseldorf/wohnungen/kaufen?d=true&rmi=3&sd=DESC&sf=TIMESTAMP&sp=1", "url": "https://www.immowelt.de/classified-search?distributionTypes=Buy,Buy_Auction,Compulsory_Auction&estateTypes=House,Apartment&locations=AD08DE2350",
"enabled": true "enabled": true
}, },
"immoscout": { "immoscout": {

View File

@@ -1,7 +1,7 @@
[ [
{ {
"url": "https://www.immowelt.de/liste/40589/wohnungen/mieten?d=true&sd=DESC&sf=PRIMARY_PRICE_AMOUNT&sp=1", "url": "https://www.immowelt.de/classified-search?distributionTypes=Buy,Buy_Auction,Compulsory_Auction&estateTypes=House,Apartment&locations=AD08DE2350",
"shouldBecome": "https://www.immowelt.de/liste/40589/wohnungen/mieten?d=true&sd=DESC&sf=TIMESTAMP&sp=1", "shouldBecome": "https://www.immowelt.de/classified-search?distributionTypes=Buy,Buy_Auction,Compulsory_Auction&estateTypes=House,Apartment&locations=AD08DE2350&order=DateDesc",
"id": "immowelt" "id": "immowelt"
}, },
{ {

16
test/utils/utils.test.js Normal file
View 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;
});
});
});

View File

@@ -17,11 +17,13 @@ import Jobs from './views/jobs/Jobs';
import { Route } from 'react-router'; import { Route } from 'react-router';
import './App.less'; import './App.less';
import TrackingModal from './components/tracking/TrackingModal.jsx';
export default function FredyApp() { export default function FredyApp() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const currentUser = useSelector((state) => state.user.currentUser); const currentUser = useSelector((state) => state.user.currentUser);
const settings = useSelector((state) => state.generalSettings.settings);
useEffect(() => { useEffect(() => {
async function init() { async function init() {
@@ -31,6 +33,7 @@ export default function FredyApp() {
await dispatch.jobs.getJobs(); await dispatch.jobs.getJobs();
await dispatch.jobs.getProcessingTimes(); await dispatch.jobs.getProcessingTimes();
await dispatch.notificationAdapter.getAdapter(); await dispatch.notificationAdapter.getAdapter();
await dispatch.generalSettings.getGeneralSettings();
} }
setLoading(false); setLoading(false);
} }
@@ -59,6 +62,7 @@ export default function FredyApp() {
<Logout /> <Logout />
<Logo width={190} white /> <Logo width={190} white />
<Menu isAdmin={isAdmin()} /> <Menu isAdmin={isAdmin()} />
{settings.analyticsEnabled === null && <TrackingModal/>}
<Switch> <Switch>
<Route name="Insufficient Permission" path={'/403'} component={InsufficientPermission} /> <Route name="Insufficient Permission" path={'/403'} component={InsufficientPermission} />
<Route name="Create new Job" path={'/jobs/new'} component={JobMutation} /> <Route name="Create new Job" path={'/jobs/new'} component={JobMutation} />

View File

@@ -0,0 +1,48 @@
import React from 'react';
import {Modal} from '@douyinfe/semi-ui';
import Logo from '../logo/Logo.jsx';
import {xhrPost} from '../../services/xhr.js';
import './TrackingModal.less';
const saveResponse = async (analyticsEnabled) => {
await xhrPost('/api/admin/generalSettings', {
analyticsEnabled
});
};
export default function TrackingModal() {
return <Modal
visible={true}
onOk={async () => {
await saveResponse(true);
location.reload();
}}
onCancel={async () => {
await saveResponse(false);
location.reload();
}}
maskClosable={false}
closable={false}
okText="Yes! I want to help"
cancelText="No, thanks"
>
<Logo white/>
<div className="trackingModal__description">
<p>Hey 👋</p>
<p>Fed up with popups? Yeah, me too. But this ones important, and I promise it will only appear once ;)</p>
<p>Fredy is completely free (and will always remain free). If youd like, you can support me by donating
through my GitHub, but theres absolutely no obligation to do so.</p>
<p>However, it would be a huge
help if youd allow me to collect some analytical data. Wait, before you click "no", let me explain. If
you
agree, Fredy will send a ping to my Mixpanel project each time it runs.</p>
<p>The data includes: names of
active adapters/providers, OS, architecture, Node version, and language. The information is entirely
anonymous and helps me understand which adapters/providers are most frequently used.</p>
<p>Thanks🤘</p>
</div>
</Modal>;
}

View File

@@ -0,0 +1,5 @@
.trackingModal {
&__description {
margin-top:10rem;
}
}

View File

@@ -2,13 +2,13 @@ import React from 'react';
import {useDispatch, useSelector} from 'react-redux'; import {useDispatch, useSelector} from 'react-redux';
import { Divider, Input, Radio, TimePicker, Button, RadioGroup } from '@douyinfe/semi-ui'; import {Divider, Input, Radio, TimePicker, Button, RadioGroup, Checkbox} from '@douyinfe/semi-ui';
import {InputNumber} from '@douyinfe/semi-ui'; import {InputNumber} from '@douyinfe/semi-ui';
import Headline from '../../components/headline/Headline'; import Headline from '../../components/headline/Headline';
import {xhrPost} from '../../services/xhr'; import {xhrPost} from '../../services/xhr';
import {SegmentPart} from '../../components/segment/SegmentPart'; import {SegmentPart} from '../../components/segment/SegmentPart';
import {Banner, Toast} from '@douyinfe/semi-ui'; import {Banner, Toast} from '@douyinfe/semi-ui';
import { IconSave, IconCalendar, IconKey, IconRefresh, IconSignal } from '@douyinfe/semi-icons'; import {IconSave, IconCalendar, IconKey, IconRefresh, IconSignal, IconLineChartStroked, IconSearch} from '@douyinfe/semi-icons';
import './GeneralSettings.less'; import './GeneralSettings.less';
function formatFromTimestamp(ts) { function formatFromTimestamp(ts) {
@@ -39,6 +39,9 @@ const GeneralSettings = function GeneralSettings() {
const [scrapingAntProxy, setScrapingAntProxy] = React.useState(''); const [scrapingAntProxy, setScrapingAntProxy] = React.useState('');
const [workingHourFrom, setWorkingHourFrom] = React.useState(null); const [workingHourFrom, setWorkingHourFrom] = React.useState(null);
const [workingHourTo, setWorkingHourTo] = React.useState(null); const [workingHourTo, setWorkingHourTo] = React.useState(null);
const [demoMode, setDemoMode] = React.useState(null);
const [analyticsEnabled, setAnalyticsEnabled] = React.useState(null);
React.useEffect(() => { React.useEffect(() => {
async function init() { async function init() {
await dispatch.generalSettings.getGeneralSettings(); await dispatch.generalSettings.getGeneralSettings();
@@ -56,6 +59,8 @@ const GeneralSettings = function GeneralSettings() {
setWorkingHourFrom(settings?.workingHours?.from); setWorkingHourFrom(settings?.workingHours?.from);
setWorkingHourTo(settings?.workingHours?.to); setWorkingHourTo(settings?.workingHours?.to);
setScrapingAntProxy(settings?.scrapingAnt?.proxy || 'datacenter'); setScrapingAntProxy(settings?.scrapingAnt?.proxy || 'datacenter');
setAnalyticsEnabled(settings?.analytics || false);
setDemoMode(settings?.demoMode || false);
} }
init(); init();
@@ -99,13 +104,15 @@ const GeneralSettings = function GeneralSettings() {
from: workingHourFrom, from: workingHourFrom,
to: workingHourTo, to: workingHourTo,
}, },
demoMode,
analyticsEnabled
}); });
} catch (exception) { } catch (exception) {
console.error(exception); console.error(exception);
throwMessage('Error while trying to store settings.', 'error'); throwMessage('Error while trying to store settings.', 'error');
return; return;
} }
throwMessage('Settings stored successfully. You MUST restart Fredy.', 'success'); throwMessage('Settings stored successfully.', 'success');
}; };
return ( return (
@@ -173,22 +180,27 @@ const GeneralSettings = function GeneralSettings() {
closeIcon={null} closeIcon={null}
title={ title={
<div style={{fontWeight: 600, fontSize: '14px', lineHeight: '20px'}}> <div style={{fontWeight: 600, fontSize: '14px', lineHeight: '20px'}}>
ScrapingAnt is needed to scrape Immoscout. ScrapingAnt itself is using 2 different types of proxies ScrapingAnt is needed to scrape Immoscout. ScrapingAnt itself is using 2
different types of proxies
</div> </div>
} }
style={{marginBottom: '1rem'}} style={{marginBottom: '1rem'}}
description={ description={
<div> <div>
<h4>Datacenter-Proxy</h4> <h4>Datacenter-Proxy</h4>
Proxy server located in one of the datacenters across the world. Datacenter proxies are slower and Proxy server located in one of the datacenters across the world. Datacenter
more likely to fail, but they are cheaper. A call with a datacenter proxy cost 10 credits. 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> <h4>Residential-Proxy</h4>
High-quality proxy server located in one of the real people houses across the world. Datacenter 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. proxies are faster and more likely to success, but they are more expensive.
<br/> <br/>
<br/> <br/>
<b> <b>
On the free tier, you have 10.000 credits, so chose your option wisely. Keep in mind, only On the free tier, you have 10.000 credits, so chose your option wisely. Keep
in mind, only
successful calls will be charged. successful calls will be charged.
</b> </b>
</div> </div>
@@ -199,7 +211,8 @@ const GeneralSettings = function GeneralSettings() {
<Radio name="datacenter" value="datacenter" checked={scrapingAntProxy === 'datacenter'}> <Radio name="datacenter" value="datacenter" checked={scrapingAntProxy === 'datacenter'}>
Datacenter proxy Datacenter proxy
</Radio> </Radio>
<Radio name="residential" value="residential" checked={scrapingAntProxy === 'residential'}> <Radio name="residential" value="residential"
checked={scrapingAntProxy === 'residential'}>
Residential proxy Residential proxy
</Radio> </Radio>
</RadioGroup> </RadioGroup>
@@ -231,6 +244,80 @@ const GeneralSettings = function GeneralSettings() {
/> />
</div> </div>
</SegmentPart> </SegmentPart>
<Divider margin="1rem"/>
<SegmentPart
name="Analytics"
helpText="Insights into the usage of Fredy."
Icon={IconLineChartStroked}
>
<Banner
fullMode={false}
type="info"
closeIcon={null}
title={
<div style={{fontWeight: 600, fontSize: '14px', lineHeight: '20px'}}>
Explanation
</div>
}
style={{marginBottom: '1rem'}}
description={
<div>
Analytics are disabled by default. If you choose to enable them, we will begin tracking the following:<br/>
<ul>
<li>Name of active provider (e.g. Immoscout)</li>
<li>Name of active adapter (e.g. Console)</li>
<li>language</li>
<li>os</li>
<li>node version</li>
<li>arch</li>
</ul>
The data is sent anonymously and helps me understand which providers or adapters are being used the most. In the end it helps me to improve fredy.
</div>
}
/>
<Checkbox
checked={analyticsEnabled}
onChange={(e) => setAnalyticsEnabled(e.target.checked)}
> Enabled
</Checkbox>
</SegmentPart>
<Divider margin="1rem"/>
<SegmentPart
name="Demo Mode"
helpText="If enabled, Fredy runs in demo mode."
Icon={IconSearch}
>
<Banner
fullMode={false}
type="info"
closeIcon={null}
title={
<div style={{fontWeight: 600, fontSize: '14px', lineHeight: '20px'}}>
Explanation
</div>
}
style={{marginBottom: '1rem'}}
description={
<div>
In demo mode, Fredy will not (really) search for any real estates. Fredy is in a lockdown mode. Also
all database files will be set back to the default values at midnight.
</div>
}
/>
<Checkbox
checked={demoMode}
onChange={(e) => setDemoMode(e.target.checked)}
> Enabled
</Checkbox>
</SegmentPart>
<Divider margin="1rem"/> <Divider margin="1rem"/>
<Button type="primary" theme="solid" onClick={onStore} icon={<IconSave/>}> <Button type="primary" theme="solid" onClick={onStore} icon={<IconSave/>}>
Save Save

View File

@@ -1,9 +1,31 @@
import React from 'react'; import React from 'react';
import {format} from '../../services/time/timeService'; import {format} from '../../services/time/timeService';
import { Card, Descriptions, Divider } from '@douyinfe/semi-ui'; import {Banner, Card, Descriptions, Divider} from '@douyinfe/semi-ui';
import {IconBolt} from '@douyinfe/semi-icons'; import {IconBolt} from '@douyinfe/semi-icons';
export default function ProcessingTimes({ processingTimes }) {
export default function ProcessingTimes({processingTimes = {}}) {
const {Meta} = Card; const {Meta} = Card;
if (Object.keys(processingTimes).length === 0) {
return null;
}
if (processingTimes.error != null) {
return <Banner
fullMode={false}
type="danger"
closeIcon={null}
title={
<div style={{fontWeight: 600, fontSize: '14px', lineHeight: '20px'}}>
Scraping Ant Error
</div>
}
style={{marginBottom: '1rem'}}
description={
<div>
{processingTimes.error}
</div>
}
/>;
}
return ( return (
<> <>
<Descriptions <Descriptions
@@ -26,7 +48,7 @@ export default function ProcessingTimes({ processingTimes }) {
)} )}
</Descriptions> </Descriptions>
{processingTimes.scrapingAntData != null && ( {(processingTimes.scrapingAntData != null && Object.keys(processingTimes.scrapingAntData).length > 0) &&(
<> <>
<Divider margin="1rem"/> <Divider margin="1rem"/>
<Card <Card
@@ -47,12 +69,13 @@ export default function ProcessingTimes({ processingTimes }) {
Credits: {processingTimes.scrapingAntData.remained_credits}/ Credits: {processingTimes.scrapingAntData.remained_credits}/
{processingTimes.scrapingAntData.plan_total_credits} {processingTimes.scrapingAntData.plan_total_credits}
</p> </p>
If you want to scrape Immoscout or Immonet more often, you have to purchase a premium account of{' '} 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"> <a href="https://scrapingant.com/" target="_blank" rel="noreferrer">
ScrapingAnt ScrapingAnt
</a> </a>
. You can use the code <b>FREDY10</b> to get 10% off. (No affiliation, we are <b>not</b> getting paid to . You can use the code <b>FREDY10</b> to get 10% off. (No affiliation, we are <b>not</b> getting
recommend ScrapingAnt.) paid by ScrapingAnt.)
</Card> </Card>
</> </>
)} )}

2814
yarn.lock

File diff suppressed because it is too large Load Diff