Compare commits

...

11 Commits

Author SHA1 Message Date
weakmap@gmail.com
a2c769f786 Merge branch 'master' of https://github.com/orangecoding/fredy 2024-11-22 11:37:51 +01:00
weakmap@gmail.com
1825a25eaa fixing typo 2024-11-22 11:37:44 +01:00
Christian Kellner
0f20b85f38 Update README.md 2024-11-22 09:38:50 +01:00
weakmap@gmail.com
d17ef9ef1e update fredy version 2024-11-22 09:11:43 +01:00
Christian Kellner
337ee922a6 Demo Mode (#117)
* Adding Demo Mode to Fredy
2024-11-22 09:11:10 +01:00
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
32 changed files with 2059 additions and 2119 deletions

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"

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

@@ -15,6 +15,9 @@ If you like my work, consider becoming a sponsor. I'm not expecting anybody to p
_Fredy_ is supported by JetBrains under Open Source Support Program
## Demo
If you want to try out _Fredy_, you can access the demo version [here](https://fredy.orange-coding.net) 🤘
## Usage
- Make sure to use Node.js 20 or above
@@ -86,14 +89,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).
### 👐 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)
# Analytics
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...
If you agree, Fredy will send a ping to my Mixpanel project each time it runs.
The data includes: names of active adapters/providers, OS, architecture, Node version, and language. The information is entirely anonymous and helps me understand which adapters/providers are most frequently used.</p>
**Thanks**🤘
# Docker
Use the Dockerfile in this repository to build an image.
@@ -114,6 +115,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`
### 👐 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
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":"d","proxy":"datacenter"},"workingHours":{"from":"","to":""},"demoMode":false,"analyticsEnabled":null}

View File

@@ -1,11 +1,14 @@
import fs from 'fs';
import { config } from './lib/utils.js';
import {config} from './lib/utils.js';
import * as similarityCache from './lib/services/similarity-check/similarityCache.js';
import { setLastJobExecution } from './lib/services/storage/listingsStorage.js';
import * as jobStorage from './lib/services/storage/jobStorage.js';
import FredyRuntime from './lib/FredyRuntime.js';
import { duringWorkingHoursOrNotSet } from './lib/utils.js';
import './lib/api/api.js';
import {track} from './lib/services/tracking/Tracker.js';
import {handleDemoUser} from './lib/services/storage/userStorage.js';
import {cleanupDemoAtMidnight} from './lib/services/demoCleanup.js';
//if db folder does not exist, ensure to create it before loading anything else
if (!fs.existsSync('./db')) {
fs.mkdirSync('./db');
@@ -16,34 +19,43 @@ const provider = fs.readdirSync(path).filter((file) => file.endsWith('.js'));
const INTERVAL = config.interval * 60 * 1000;
/* eslint-disable no-console */
console.log(`Started Fredy successfully. Ui can be accessed via http://localhost:${config.port}`);
if(config.demoMode){
console.info('Running in demo mode');
cleanupDemoAtMidnight();
}
/* eslint-enable no-console */
const fetchedProvider = await Promise.all(
provider.filter((provider) => provider.endsWith('.js')).map(async (pro) => import(`${path}/${pro}`))
);
handleDemoUser();
setInterval(
(function exec() {
const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(config, Date.now());
if (isDuringWorkingHoursOrNotSet) {
config.lastRun = Date.now();
jobStorage
.getJobs()
.filter((job) => job.enabled)
.forEach((job) => {
job.provider
.filter((p) => fetchedProvider.find((fp) => fp.metaInformation.id === p.id) != null)
.forEach(async (prov) => {
const pro = fetchedProvider.find((fp) => fp.metaInformation.id === prov.id);
pro.init(prov, job.blacklist);
await new FredyRuntime(pro.config, job.notificationAdapter, prov.id, job.id, similarityCache).execute();
setLastJobExecution(job.id);
});
});
} else {
/* eslint-disable no-console */
console.debug('Working hours set. Skipping as outside of working hours.');
/* eslint-enable no-console */
}
if(!config.demoMode) {
if (isDuringWorkingHoursOrNotSet) {
track();
config.lastRun = Date.now();
jobStorage
.getJobs()
.filter((job) => job.enabled)
.forEach((job) => {
job.provider
.filter((p) => fetchedProvider.find((fp) => fp.metaInformation.id === p.id) != null)
.forEach(async (prov) => {
const pro = fetchedProvider.find((fp) => fp.metaInformation.id === prov.id);
pro.init(prov, job.blacklist);
await new FredyRuntime(pro.config, job.notificationAdapter, prov.id, job.id, similarityCache).execute();
setLastJobExecution(job.id);
});
});
} else {
/* eslint-disable no-console */
console.debug('Working hours set. Skipping as outside of working hours.');
/* eslint-enable no-console */
}
}
return exec;
})(),
INTERVAL

View File

@@ -12,6 +12,7 @@ import restana from 'restana';
import files from 'serve-static';
import path from 'path';
import { getDirName } from '../utils.js';
import {demoRouter} from './routes/demoRouter.js';
const service = restana();
const staticService = files(path.join(getDirName(), '../ui/public'));
const PORT = config.port || 9998;
@@ -30,6 +31,9 @@ service.use('/api/jobs/insights', analyticsRouter);
service.use('/api/admin/users', userRouter);
service.use('/api/jobs', jobRouter);
service.use('/api/login', loginRouter);
//this route is unsecured intentionally as it is being queried from the login page
service.use('/api/demo', demoRouter);
/* eslint-disable no-console */
service.start(PORT).then(() => {
console.info(`Started API service on port ${PORT}`);

View File

@@ -0,0 +1,11 @@
import restana from 'restana';
import {config} from '../../utils.js';
const service = restana();
const demoRouter = service.newRouter();
demoRouter.get('/', async (req, res) => {
res.body = Object.assign({}, {demoMode: config.demoMode});
res.send();
});
export { demoRouter };

View File

@@ -1,6 +1,7 @@
import restana from 'restana';
import { config, getDirName } from '../../utils.js';
import {config, getDirName, readConfigFromStorage, refreshConfig} from '../../utils.js';
import fs from 'fs';
import {handleDemoUser} from '../../services/storage/userStorage.js';
const service = restana();
const generalSettingsRouter = service.newRouter();
generalSettingsRouter.get('/', async (req, res) => {
@@ -10,7 +11,14 @@ generalSettingsRouter.get('/', async (req, res) => {
generalSettingsRouter.post('/', async (req, res) => {
const settings = req.body;
try {
fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify(settings));
if(config.demoMode){
res.send(new Error('In demo mode, it is not allowed to change these settings.'));
return;
}
const currentConfig = await readConfigFromStorage();
fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({...currentConfig, ...settings}));
await refreshConfig();
handleDemoUser();
} catch (err) {
console.error(err);
res.send(new Error('Error while trying to write settings.'));

View File

@@ -5,6 +5,8 @@ import * as userStorage from '../../services/storage/userStorage.js';
import * as immoscoutProvider from '../../provider/immoscout.js';
import { config } from '../../utils.js';
import { isAdmin } from '../security.js';
import {isScrapingAntApiKeySet} from '../../services/scrapingAnt.js';
import {trackDemoJobCreated} from '../../services/tracking/Tracker.js';
const service = restana();
const jobRouter = service.newRouter();
function doesJobBelongsToUser(job, req) {
@@ -25,8 +27,8 @@ jobRouter.get('/', async (req, res) => {
res.send();
});
jobRouter.get('/processingTimes', async (req, res) => {
let scrapingAntData = null;
if (config.scrapingAnt.apiKey != null && config.scrapingAnt.apiKey.length > 0) {
let scrapingAntData = {};
if (isScrapingAntApiKeySet()) {
try {
const response = await fetch(`https://api.scrapingant.com/v2/usage?x-api-key=${config.scrapingAnt.apiKey}`);
scrapingAntData = await response.json();
@@ -38,6 +40,7 @@ jobRouter.get('/processingTimes', async (req, res) => {
interval: config.interval,
lastRun: config.lastRun || null,
scrapingAntData,
error: scrapingAntData?.detail == null ? null : scrapingAntData?.detail
};
res.send();
});
@@ -66,6 +69,11 @@ jobRouter.post('/', async (req, res) => {
res.send(new Error(error));
console.error(error);
}
trackDemoJobCreated({
name,
provider,
adapter: notificationAdapter
});
res.send();
});
jobRouter.delete('', async (req, res) => {

View File

@@ -1,6 +1,8 @@
import restana from 'restana';
import * as userStorage from '../../services/storage/userStorage.js';
import * as hasher from '../../services/security/hash.js';
import {config} from '../../utils.js';
import {trackDemoAccessed} from '../../services/tracking/Tracker.js';
const service = restana();
const loginRouter = service.newRouter();
loginRouter.get('/user', async (req, res) => {
@@ -24,6 +26,11 @@ loginRouter.post('/', async (req, res) => {
return;
}
if (user.password === hasher.hash(password)) {
if(config.demoMode){
trackDemoAccessed();
}
req.session.currentUser = user.id;
userStorage.setLastLoginToNow({ userId: user.id });
res.send(200);

View File

@@ -1,6 +1,7 @@
import restana from 'restana';
import * as userStorage from '../../services/storage/userStorage.js';
import * as jobStorage from '../../services/storage/jobStorage.js';
import {config} from '../../utils.js';
const service = restana();
const userRouter = service.newRouter();
function checkIfAnyAdminAfterRemovingUser(userIdToBeRemoved, allUser) {
@@ -20,6 +21,11 @@ userRouter.get('/:userId', async (req, res) => {
res.send();
});
userRouter.delete('/', async (req, res) => {
if(config.demoMode){
res.send(new Error('In demo mode, it is not allowed to remove user.'));
return;
}
const { userId } = req.body;
const allUser = userStorage.getUsers(false);
if (!checkIfAnyAdminAfterRemovingUser(userId, allUser)) {
@@ -36,6 +42,12 @@ userRouter.delete('/', async (req, res) => {
res.send();
});
userRouter.post('/', async (req, res) => {
if(config.demoMode){
res.send(new Error('In demo mode, it is not allowed to change or add user.'));
return;
}
const { username, password, password2, isAdmin, userId } = req.body;
if (password !== password2) {
res.send(new Error('Passwords does not match'));

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

@@ -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

@@ -0,0 +1,29 @@
import { setInterval } from 'node:timers';
import {removeJobsByUserName} from './storage/jobStorage.js';
import {config} from '../utils.js';
/**
* if we are running in demo environment, we have to cleanup the db files (specifically the jobs table)
*/
export function cleanupDemoAtMidnight() {
const now = new Date();
const millisUntilMidnightUTC = (24 - now.getUTCHours()) * 60 * 60 * 1000
- now.getUTCMinutes() * 60 * 1000
- now.getUTCSeconds() * 1000
- now.getUTCMilliseconds();
setTimeout(() => {
cleanup();
setInterval(() => {
cleanup();
}, 24 * 60 * 60 * 1000);
}, millisUntilMidnightUTC);
}
function cleanup(){
if(config.demoMode){
removeJobsByUserName('demo');
}
}

View File

@@ -22,7 +22,7 @@ export const transformUrlForScrapingAnt = (url, id) => {
return url;
};
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) => {
return url.replace('datacenter', 'residential');

View File

@@ -77,6 +77,17 @@ export const removeJobsByUserId = (userId) => {
.value();
db.write();
};
export const removeJobsByUserName = (userName) => {
db.chain
.get('jobs')
.filter((job) => job.username === userName)
.forEach((job) => listingStorage.removeListings(job.id));
db.chain
.get('jobs')
.remove((job) => job.username === userName)
.value();
db.write();
};
export const getJobs = () => {
return db.chain
.get('jobs')

View File

@@ -1,5 +1,5 @@
import { JSONFileSync } from 'lowdb/node';
import { getDirName } from '../../utils.js';
import {config, getDirName} from '../../utils.js';
import * as hasher from '../security/hash.js';
import { nanoid } from 'nanoid';
import * as jobStorage from './jobStorage.js';
@@ -16,6 +16,13 @@ const defaultData = {
password: hasher.hash('admin'),
isAdmin: true,
},
{
id: nanoid(),
lastLogin: Date.now(),
username: 'demo',
password: hasher.hash('demo'),
isAdmin: true,
},
],
};
@@ -84,3 +91,29 @@ export const removeUser = (userId) => {
.value();
db.write();
};
export const handleDemoUser = () => {
if(!config.demoMode){
const user = db.chain.get('user').value();
db.chain.get('user').value();
db.chain.set('user', user.filter((u) => u.username !== 'demo')).value();
db.write();
}else {
const demoUser = db.chain
.get('user')
.filter((u) => u.username === 'demo')
.value();
if (demoUser == null || demoUser.length === 0) {
db.chain.get('user')
.value()
.push({
id: nanoid(),
username: 'demo',
password: hasher.hash('demo'),
isAdmin: true,
});
db.write();
}
}
};

View File

@@ -0,0 +1,68 @@
import Mixpanel from 'mixpanel';
import {getJobs} from '../storage/jobStorage.js';
import {config, inDevMode} from '../../utils.js';
const mixpanelTracker = Mixpanel.init('718670ef1c58c0208256c1e408a3d75e');
export const track = function () {
//only send tracking information if the user allowed to do so.
if (config.analyticsEnabled && !inDevMode()) {
const activeProvider = new Set();
const activeAdapter = new Set();
const jobs = getJobs();
if (jobs != null && jobs.length > 0) {
jobs.forEach(job => {
job.provider.forEach(provider => {
activeProvider.add(provider.id);
});
job.notificationAdapter.forEach(adapter => {
activeAdapter.add(adapter.id);
});
});
mixpanelTracker.track('fredy_tracking', enrichTrackingObject({
adapter: Array.from(activeAdapter),
provider: Array.from(activeProvider),
}));
}
}
};
/**
* Note, this will only be used when Fredy runs in demo mode
*/
export function trackDemoJobCreated(jobData) {
if (config.analyticsEnabled && !inDevMode() && config.demoMode) {
mixpanelTracker.track('demoJobCreated', enrichTrackingObject(jobData));
}
}
/**
* Note, this will only be used when Fredy runs in demo mode
*/
export function trackDemoAccessed() {
if (config.analyticsEnabled && !inDevMode() && config.demoMode) {
mixpanelTracker.track('demoAccessed', enrichTrackingObject({}));
}
}
function enrichTrackingObject(trackingObject) {
const platform = process.platform;
const arch = process.arch;
const language = process.env.LANG || 'en';
const nodeVersion = process.version || 'N/A';
return {
...trackingObject,
isDemo: config.demoMode,
platform,
arch,
nodeVersion,
language
};
}

View File

@@ -2,6 +2,11 @@ import {dirname} from 'node:path';
import {fileURLToPath} from 'node:url';
import {readFile} from 'fs/promises';
import {createHash} from 'crypto';
import {DEFAULT_CONFIG} from './defaultConfig.js';
function inDevMode(){
return process.env.NODE_ENV == null || process.env.NODE_ENV !== 'production';
}
function isOneOf(word, arr) {
if (arr == null || arr.length === 0) {
@@ -52,9 +57,26 @@ function buildHash(...inputs) {
.digest('hex');
}
const config = JSON.parse(await readFile(new URL('../conf/config.json', import.meta.url)));
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 {inDevMode};
export {nullOrEmpty};
export {duringWorkingHoursOrNotSet};
export {getDirName};

View File

@@ -1,9 +1,9 @@
{
"name": "fredy",
"version": "10.1.1",
"version": "10.4.1",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": {
"start": "node index.js",
"start": "node prod.js",
"dev": "yarn && rm -rf ./ui/public/* && vite",
"ui": "rm -rf ./ui/public/* && vite",
"prod": "yarn && vite build --emptyOutDir",
@@ -11,11 +11,6 @@
"test": "mocha --loader=esmock --timeout 3000000 test/**/*.test.js",
"lint": "eslint ./index.js ./lib/**/*.js ./test/**/*.js ./ui/src/**/*.jsx"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"type": "module",
"lint-staged": {
"*.js": [
@@ -55,13 +50,13 @@
"Firefox ESR"
],
"dependencies": {
"@douyinfe/semi-ui": "2.65.0",
"@douyinfe/semi-ui": "2.69.2",
"@rematch/core": "2.2.0",
"@rematch/loading": "2.1.2",
"@sendgrid/mail": "8.1.3",
"@vitejs/plugin-react": "4.3.1",
"better-sqlite3": "8.6.0",
"body-parser": "1.20.2",
"@sendgrid/mail": "8.1.4",
"@vitejs/plugin-react": "4.3.3",
"better-sqlite3": "^11.5.0",
"body-parser": "1.20.3",
"cookie-session": "2.1.0",
"handlebars": "4.7.8",
"highcharts": "11.4.8",
@@ -69,10 +64,11 @@
"lodash": "4.17.21",
"lowdb": "6.0.1",
"markdown": "^0.5.0",
"nanoid": "5.0.7",
"mixpanel": "^0.18.0",
"nanoid": "5.0.8",
"node-fetch": "3.3.2",
"node-mailjet": "6.0.6",
"query-string": "8.2.0",
"query-string": "9.1.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-redux": "9.1.2",
@@ -81,27 +77,27 @@
"redux": "5.0.1",
"redux-thunk": "3.1.0",
"restana": "4.9.9",
"serve-static": "1.15.0",
"serve-static": "1.16.2",
"slack": "11.0.2",
"string-similarity": "^4.0.4",
"vite": "5.4.3",
"vite": "5.4.11",
"x-ray": "2.3.4"
},
"devDependencies": {
"@babel/core": "7.25.2",
"@babel/eslint-parser": "7.25.1",
"@babel/preset-env": "7.25.4",
"@babel/preset-react": "7.24.7",
"chai": "5.1.1",
"@babel/core": "7.26.0",
"@babel/eslint-parser": "7.25.9",
"@babel/preset-env": "7.26.0",
"@babel/preset-react": "7.25.9",
"chai": "5.1.2",
"eslint": "8.56.0",
"eslint-config-prettier": "8.8.0",
"eslint-plugin-react": "7.35.2",
"esmock": "2.6.7",
"eslint-plugin-react": "7.37.2",
"esmock": "2.6.9",
"history": "5.3.0",
"husky": "4.3.8",
"husky": "9.1.7",
"less": "4.2.0",
"lint-staged": "13.2.2",
"mocha": "10.7.3",
"lint-staged": "15.2.10",
"mocha": "10.8.2",
"prettier": "3.3.3",
"redux-logger": "3.0.6"
}

2
prod.js Normal file
View File

@@ -0,0 +1,2 @@
process.env.NODE_ENV = 'production';
import('./index.js');

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React, {useEffect} from 'react';
import InsufficientPermission from './components/permission/InsufficientPermission';
import PermissionAwareRoute from './components/permission/PermissionAwareRoute';
@@ -6,90 +6,106 @@ import GeneralSettings from './views/generalSettings/GeneralSettings';
import JobMutation from './views/jobs/mutation/JobMutation';
import UserMutator from './views/user/mutation/UserMutator';
import JobInsight from './views/jobs/insights/JobInsight.jsx';
import { useDispatch, useSelector } from 'react-redux';
import { Switch, Redirect } from 'react-router-dom';
import {useDispatch, useSelector} from 'react-redux';
import {Switch, Redirect} from 'react-router-dom';
import Logout from './components/logout/Logout';
import Logo from './components/logo/Logo';
import Menu from './components/menu/Menu';
import Login from './views/login/Login';
import Users from './views/user/Users';
import Jobs from './views/jobs/Jobs';
import { Route } from 'react-router';
import {Route} from 'react-router';
import './App.less';
import TrackingModal from './components/tracking/TrackingModal.jsx';
import {Banner} from '@douyinfe/semi-ui';
export default function FredyApp() {
const dispatch = useDispatch();
const [loading, setLoading] = React.useState(true);
const currentUser = useSelector((state) => state.user.currentUser);
const dispatch = useDispatch();
const [loading, setLoading] = React.useState(true);
const currentUser = useSelector((state) => state.user.currentUser);
const settings = useSelector((state) => state.generalSettings.settings);
useEffect(() => {
async function init() {
await dispatch.user.getCurrentUser();
if (!needsLogin()) {
await dispatch.provider.getProvider();
await dispatch.jobs.getJobs();
await dispatch.jobs.getProcessingTimes();
await dispatch.notificationAdapter.getAdapter();
}
setLoading(false);
}
useEffect(() => {
async function init() {
await dispatch.user.getCurrentUser();
if (!needsLogin()) {
await dispatch.provider.getProvider();
await dispatch.jobs.getJobs();
await dispatch.jobs.getProcessingTimes();
await dispatch.notificationAdapter.getAdapter();
await dispatch.generalSettings.getGeneralSettings();
}
setLoading(false);
}
init();
}, [currentUser?.userId]);
init();
}, [currentUser?.userId]);
const needsLogin = () => {
return currentUser == null || Object.keys(currentUser).length === 0;
};
const needsLogin = () => {
return currentUser == null || Object.keys(currentUser).length === 0;
};
const isAdmin = () => currentUser != null && currentUser.isAdmin;
const isAdmin = () => currentUser != null && currentUser.isAdmin;
const login = () => (
<Switch>
<Route name="Login" path={'/login'} component={Login} />
<Redirect from="*" to={'/login'} />
</Switch>
);
return loading ? null : needsLogin() ? (
login()
) : (
<div className="app">
<div className="app__container">
<Logout />
<Logo width={190} white />
<Menu isAdmin={isAdmin()} />
const login = () => (
<Switch>
<Route name="Insufficient Permission" path={'/403'} component={InsufficientPermission} />
<Route name="Create new Job" path={'/jobs/new'} component={JobMutation} />
<Route name="Edit a Job" path={'/jobs/edit/:jobId'} component={JobMutation} />
<Route name="Insights into a Job" path={'/jobs/insights/:jobId'} component={JobInsight} />
<Route name="Job overview" path={'/jobs'} component={Jobs} />
<PermissionAwareRoute
name="Create new User"
path="/users/new"
component={<UserMutator />}
currentUser={currentUser}
/>
<PermissionAwareRoute
name="Edit a user"
path="/users/edit/:userId"
component={<UserMutator />}
currentUser={currentUser}
/>
<PermissionAwareRoute name="Users" path="/users" component={<Users />} currentUser={currentUser} />
<PermissionAwareRoute
name="General Settings"
path="/generalSettings"
component={<GeneralSettings />}
currentUser={currentUser}
/>
<Redirect from="/" to={'/jobs'} />
<Route name="Login" path={'/login'} component={Login}/>
<Redirect from="*" to={'/login'}/>
</Switch>
</div>
</div>
);
);
return loading ? null : needsLogin() ? (
login()
) : (
<div className="app">
<div className="app__container">
<Logout/>
<Logo width={190} white/>
<Menu isAdmin={isAdmin()}/>
{settings.demoMode && (
<>
<Banner fullMode={true}
type="info"
bordered
closeIcon={null}
description="You're currently viewing the demo version of Fredy. Jobs won't scrape websites, and any changes you make will be reverted at midnight."
/>
<br/>
</>)}
{(settings.analyticsEnabled === null && !settings.demoMode) && <TrackingModal/>}
<Switch>
<Route name="Insufficient Permission" path={'/403'} component={InsufficientPermission}/>
<Route name="Create new Job" path={'/jobs/new'} component={JobMutation}/>
<Route name="Edit a Job" path={'/jobs/edit/:jobId'} component={JobMutation}/>
<Route name="Insights into a Job" path={'/jobs/insights/:jobId'} component={JobInsight}/>
<Route name="Job overview" path={'/jobs'} component={Jobs}/>
<PermissionAwareRoute
name="Create new User"
path="/users/new"
component={<UserMutator/>}
currentUser={currentUser}
/>
<PermissionAwareRoute
name="Edit a user"
path="/users/edit/:userId"
component={<UserMutator/>}
currentUser={currentUser}
/>
<PermissionAwareRoute name="Users" path="/users" component={<Users/>} currentUser={currentUser}/>
<PermissionAwareRoute
name="General Settings"
path="/generalSettings"
component={<GeneralSettings/>}
currentUser={currentUser}
/>
<Redirect from="/" to={'/jobs'}/>
</Switch>
</div>
</div>
);
}
FredyApp.displayName = 'FredyApp';

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

@@ -0,0 +1,24 @@
import { xhrGet } from '../../xhr';
export const demoMode = {
state: {
demoMode: false,
},
reducers: {
setDemoMode: (state, payload) => {
return {
...state,
demoMode: payload.demoMode,
};
},
},
effects: {
async getDemoMode() {
try {
const response = await xhrGet('/api/demo');
this.setDemoMode(response.json);
} catch (Exception) {
console.error('Error while trying to get resource for api/demo. Error:', Exception);
}
},
},
};

View File

@@ -5,6 +5,7 @@ import { provider } from './models/provider';
import { createLogger } from 'redux-logger';
import { jobs } from './models/jobs';
import { user } from './models/user';
import { demoMode } from './models/demoMode.js';
import { init } from '@rematch/core';
const middleware = [];
if (process.env.NODE_ENV === 'development') {
@@ -16,6 +17,7 @@ const store = init({
models: {
notificationAdapter,
generalSettings,
demoMode,
provider,
jobs,
user,

View File

@@ -1,245 +1,331 @@
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 { InputNumber } from '@douyinfe/semi-ui';
import {Divider, Input, Radio, TimePicker, Button, RadioGroup, Checkbox} from '@douyinfe/semi-ui';
import {InputNumber} from '@douyinfe/semi-ui';
import Headline from '../../components/headline/Headline';
import { xhrPost } from '../../services/xhr';
import { SegmentPart } from '../../components/segment/SegmentPart';
import { Banner, Toast } from '@douyinfe/semi-ui';
import { IconSave, IconCalendar, IconKey, IconRefresh, IconSignal } from '@douyinfe/semi-icons';
import {xhrPost} from '../../services/xhr';
import {SegmentPart} from '../../components/segment/SegmentPart';
import {Banner, Toast} from '@douyinfe/semi-ui';
import {IconSave, IconCalendar, IconKey, IconRefresh, IconSignal, IconLineChartStroked, IconSearch} from '@douyinfe/semi-icons';
import './GeneralSettings.less';
function formatFromTimestamp(ts) {
const date = new Date(ts);
return `${date.getHours()}:${date.getMinutes() > 9 ? date.getMinutes() : '0' + date.getMinutes()}`;
const date = new Date(ts);
return `${date.getHours()}:${date.getMinutes() > 9 ? date.getMinutes() : '0' + date.getMinutes()}`;
}
function formatFromTBackend(time) {
if (time == null || time.length === 0) {
return null;
}
const date = new Date();
const split = time.split(':');
date.setHours(split[0]);
date.setMinutes(split[1]);
return date.getTime();
if (time == null || time.length === 0) {
return null;
}
const date = new Date();
const split = time.split(':');
date.setHours(split[0]);
date.setMinutes(split[1]);
return date.getTime();
}
const GeneralSettings = function GeneralSettings() {
const dispatch = useDispatch();
const [loading, setLoading] = React.useState(true);
const dispatch = useDispatch();
const [loading, setLoading] = React.useState(true);
const settings = useSelector((state) => state.generalSettings.settings);
const settings = useSelector((state) => state.generalSettings.settings);
const [interval, setInterval] = React.useState('');
const [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);
React.useEffect(() => {
async function init() {
await dispatch.generalSettings.getGeneralSettings();
setLoading(false);
}
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 [demoMode, setDemoMode] = React.useState(null);
const [analyticsEnabled, setAnalyticsEnabled] = React.useState(null);
init();
}, []);
React.useEffect(() => {
async function init() {
await dispatch.generalSettings.getGeneralSettings();
setLoading(false);
}
React.useEffect(() => {
async function init() {
setInterval(settings?.interval);
setPort(settings?.port);
setScrapingAntApiKey(settings?.scrapingAnt?.apiKey);
setWorkingHourFrom(settings?.workingHours?.from);
setWorkingHourTo(settings?.workingHours?.to);
setScrapingAntProxy(settings?.scrapingAnt?.proxy || 'datacenter');
}
init();
}, []);
init();
}, [settings]);
React.useEffect(() => {
async function init() {
setInterval(settings?.interval);
setPort(settings?.port);
setScrapingAntApiKey(settings?.scrapingAnt?.apiKey);
setWorkingHourFrom(settings?.workingHours?.from);
setWorkingHourTo(settings?.workingHours?.to);
setScrapingAntProxy(settings?.scrapingAnt?.proxy || 'datacenter');
setAnalyticsEnabled(settings?.analyticsEnabled || false);
setDemoMode(settings?.demoMode || false);
}
const nullOrEmpty = (val) => val == null || val.length === 0;
init();
}, [settings]);
const throwMessage = (message, type) => {
if (type === 'error') {
Toast.error(message);
} else {
Toast.success(message);
}
};
const nullOrEmpty = (val) => val == null || val.length === 0;
const onStore = async () => {
if (nullOrEmpty(interval)) {
throwMessage('Interval may not be empty.', 'error');
return;
}
if (nullOrEmpty(port)) {
throwMessage('Port may not be empty.', 'error');
return;
}
if (
(!nullOrEmpty(workingHourFrom) && nullOrEmpty(workingHourTo)) ||
(nullOrEmpty(workingHourFrom) && !nullOrEmpty(workingHourTo))
) {
throwMessage('Working hours to and from must be set if either to or from has been set before.', 'error');
return;
}
try {
await xhrPost('/api/admin/generalSettings', {
interval,
port,
scrapingAnt: {
apiKey: scrapingAntApiKey,
proxy: scrapingAntProxy,
},
workingHours: {
from: workingHourFrom,
to: workingHourTo,
},
});
} catch (exception) {
console.error(exception);
throwMessage('Error while trying to store settings.', 'error');
return;
}
throwMessage('Settings stored successfully. You MUST restart Fredy.', 'success');
};
const throwMessage = (message, type) => {
if (type === 'error') {
Toast.error(message);
} else {
Toast.success(message);
}
};
return (
<div>
{!loading && (
<React.Fragment>
<Headline text="General Settings" />
<Banner
fullMode={false}
type="info"
closeIcon={null}
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Info</div>}
style={{ marginBottom: '1rem' }}
description="If you change any settings, you must restart Fredy afterwards."
/>
<div>
<SegmentPart
name="Interval"
helpText="Interval in minutes for running queries against the configured services."
Icon={IconRefresh}
>
<InputNumber
min={0}
max={1440}
placeholder="Interval in minutes"
value={interval}
formatter={(value) => `${value}`.replace(/\D/g, '')}
onChange={(value) => setInterval(value)}
suffix={'minutes'}
/>
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart name="Port" helpText="Port on which Fredy is running." Icon={IconSignal}>
<InputNumber
min={0}
max={99999}
placeholder="Port"
value={port}
formatter={(value) => `${value}`.replace(/\D/g, '')}
onChange={(value) => setPort(value)}
/>
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart
name="ScrapingAnt Api Key"
helpText="The api key for ScrapingAnt is used to be able to scrape Immoscout."
Icon={IconKey}
>
<Input
type="text"
placeholder="ScrapingAnt Api Key"
value={scrapingAntApiKey}
onChange={(val) => setScrapingAntApiKey(val)}
/>
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart
name="ScrapingAnt proxy settings"
helpText="Scraping ant provides different proxies."
Icon={IconKey}
>
<Banner
fullMode={false}
type="info"
closeIcon={null}
title={
<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>
ScrapingAnt is needed to scrape Immoscout. ScrapingAnt itself is using 2 different types of proxies
</div>
}
style={{ marginBottom: '1rem' }}
description={
<div>
<h4>Datacenter-Proxy</h4>
Proxy server located in one of the datacenters across the world. Datacenter proxies are slower and
more likely to fail, but they are cheaper. A call with a datacenter proxy cost 10 credits.
<h4>Residential-Proxy</h4>
High-quality proxy server located in one of the real people houses across the world. Datacenter
proxies are faster and more likely to success, but they are more expensive.
<br />
<br />
<b>
On the free tier, you have 10.000 credits, so chose your option wisely. Keep in mind, only
successful calls will be charged.
</b>
</div>
}
/>
const onStore = async () => {
if (nullOrEmpty(interval)) {
throwMessage('Interval may not be empty.', 'error');
return;
}
if (nullOrEmpty(port)) {
throwMessage('Port may not be empty.', 'error');
return;
}
if (
(!nullOrEmpty(workingHourFrom) && nullOrEmpty(workingHourTo)) ||
(nullOrEmpty(workingHourFrom) && !nullOrEmpty(workingHourTo))
) {
throwMessage('Working hours to and from must be set if either to or from has been set before.', 'error');
return;
}
try {
await xhrPost('/api/admin/generalSettings', {
interval,
port,
scrapingAnt: {
apiKey: scrapingAntApiKey,
proxy: scrapingAntProxy,
},
workingHours: {
from: workingHourFrom,
to: workingHourTo,
},
demoMode,
analyticsEnabled
});
} catch (exception) {
console.error(exception);
if(exception?.json?.message != null){
throwMessage(exception.json.message, 'error');
}else {
throwMessage('Error while trying to store settings.', 'error');
}
return;
}
throwMessage('Settings stored successfully. We will reload your browser in 3 seconds.', 'success');
setTimeout(()=>{
location.reload();
}, 3000);
};
<RadioGroup value={scrapingAntProxy} onChange={(e) => setScrapingAntProxy(e.target.value)}>
<Radio name="datacenter" value="datacenter" checked={scrapingAntProxy === 'datacenter'}>
Datacenter proxy
</Radio>
<Radio name="residential" value="residential" checked={scrapingAntProxy === 'residential'}>
Residential proxy
</Radio>
</RadioGroup>
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart
name="Working hours"
helpText="During this hours, Fredy will search for new apartments. If nothing is configured, Fredy will search around the clock."
Icon={IconCalendar}
>
<div className="generalSettings__timePickerContainer">
<TimePicker
format={'HH:mm'}
insetLabel="From"
value={formatFromTBackend(workingHourFrom)}
placeholder=""
onChange={(val) => {
setWorkingHourFrom(val == null ? null : formatFromTimestamp(val));
}}
/>
<TimePicker
format={'HH:mm'}
insetLabel="Until"
value={formatFromTBackend(workingHourTo)}
placeholder=""
onChange={(val) => {
setWorkingHourTo(val == null ? null : formatFromTimestamp(val));
}}
/>
</div>
</SegmentPart>
<Divider margin="1rem" />
<Button type="primary" theme="solid" onClick={onStore} icon={<IconSave />}>
Save
</Button>
</div>
</React.Fragment>
)}
</div>
);
return (
<div>
{!loading && (
<React.Fragment>
<Headline text="General Settings"/>
<div>
<SegmentPart
name="Interval"
helpText="Interval in minutes for running queries against the configured services."
Icon={IconRefresh}
>
<InputNumber
min={0}
max={1440}
placeholder="Interval in minutes"
value={interval}
formatter={(value) => `${value}`.replace(/\D/g, '')}
onChange={(value) => setInterval(value)}
suffix={'minutes'}
/>
</SegmentPart>
<Divider margin="1rem"/>
<SegmentPart name="Port" helpText="Port on which Fredy is running." Icon={IconSignal}>
<InputNumber
min={0}
max={99999}
placeholder="Port"
value={port}
formatter={(value) => `${value}`.replace(/\D/g, '')}
onChange={(value) => setPort(value)}
/>
</SegmentPart>
<Divider margin="1rem"/>
<SegmentPart
name="ScrapingAnt Api Key"
helpText="The api key for ScrapingAnt is used to be able to scrape Immoscout."
Icon={IconKey}
>
<Input
type="text"
placeholder="ScrapingAnt Api Key"
value={scrapingAntApiKey}
onChange={(val) => setScrapingAntApiKey(val)}
/>
</SegmentPart>
<Divider margin="1rem"/>
<SegmentPart
name="ScrapingAnt proxy settings"
helpText="Scraping ant provides different proxies."
Icon={IconKey}
>
<Banner
fullMode={false}
type="info"
closeIcon={null}
title={
<div style={{fontWeight: 600, fontSize: '14px', lineHeight: '20px'}}>
ScrapingAnt is needed to scrape Immoscout. ScrapingAnt itself is using 2
different types of proxies
</div>
}
style={{marginBottom: '1rem'}}
description={
<div>
<h4>Datacenter-Proxy</h4>
Proxy server located in one of the datacenters across the world. Datacenter
proxies are slower and
more likely to fail, but they are cheaper. A call with a datacenter proxy cost
10 credits.
<h4>Residential-Proxy</h4>
High-quality proxy server located in one of the real people houses across the
world. Datacenter
proxies are faster and more likely to success, but they are more expensive.
<br/>
<br/>
<b>
On the free tier, you have 10.000 credits, so chose your option wisely. Keep
in mind, only
successful calls will be charged.
</b>
</div>
}
/>
<RadioGroup value={scrapingAntProxy} onChange={(e) => setScrapingAntProxy(e.target.value)}>
<Radio name="datacenter" value="datacenter" checked={scrapingAntProxy === 'datacenter'}>
Datacenter proxy
</Radio>
<Radio name="residential" value="residential"
checked={scrapingAntProxy === 'residential'}>
Residential proxy
</Radio>
</RadioGroup>
</SegmentPart>
<Divider margin="1rem"/>
<SegmentPart
name="Working hours"
helpText="During this hours, Fredy will search for new apartments. If nothing is configured, Fredy will search around the clock."
Icon={IconCalendar}
>
<div className="generalSettings__timePickerContainer">
<TimePicker
format={'HH:mm'}
insetLabel="From"
value={formatFromTBackend(workingHourFrom)}
placeholder=""
onChange={(val) => {
setWorkingHourFrom(val == null ? null : formatFromTimestamp(val));
}}
/>
<TimePicker
format={'HH:mm'}
insetLabel="Until"
value={formatFromTBackend(workingHourTo)}
placeholder=""
onChange={(val) => {
setWorkingHourTo(val == null ? null : formatFromTimestamp(val));
}}
/>
</div>
</SegmentPart>
<Divider margin="1rem"/>
<SegmentPart
name="Analytics"
helpText="Insights into the usage of Fredy."
Icon={IconLineChartStroked}
>
<Banner
fullMode={false}
type="info"
closeIcon={null}
title={
<div style={{fontWeight: 600, fontSize: '14px', lineHeight: '20px'}}>
Explanation
</div>
}
style={{marginBottom: '1rem'}}
description={
<div>
Analytics are disabled by default. If you choose to enable them, we will begin tracking the following:<br/>
<ul>
<li>Name of active provider (e.g. Immoscout)</li>
<li>Name of active adapter (e.g. Console)</li>
<li>language</li>
<li>os</li>
<li>node version</li>
<li>arch</li>
</ul>
The data is sent anonymously and helps me understand which providers or adapters are being used the most. In the end it helps me to improve fredy.
</div>
}
/>
<Checkbox
checked={analyticsEnabled}
onChange={(e) => setAnalyticsEnabled(e.target.checked)}
> Enabled
</Checkbox>
</SegmentPart>
<Divider margin="1rem"/>
<SegmentPart
name="Demo Mode"
helpText="If enabled, Fredy runs in demo mode."
Icon={IconSearch}
>
<Banner
fullMode={false}
type="info"
closeIcon={null}
title={
<div style={{fontWeight: 600, fontSize: '14px', lineHeight: '20px'}}>
Explanation
</div>
}
style={{marginBottom: '1rem'}}
description={
<div>
In demo mode, Fredy will not (really) search for any real estates. Fredy is in a lockdown mode. Also
all database files will be set back to the default values at midnight.
</div>
}
/>
<Checkbox
checked={demoMode}
onChange={(e) => setDemoMode(e.target.checked)}
> Enabled
</Checkbox>
</SegmentPart>
<Divider margin="1rem"/>
<Button type="primary" theme="solid" onClick={onStore} icon={<IconSave/>}>
Save
</Button>
</div>
</React.Fragment>
)}
</div>
);
};
export default GeneralSettings;

View File

@@ -1,63 +1,86 @@
import React from 'react';
import { format } from '../../services/time/timeService';
import { Card, Descriptions, Divider } from '@douyinfe/semi-ui';
import { IconBolt } from '@douyinfe/semi-icons';
export default function ProcessingTimes({ processingTimes }) {
const { Meta } = Card;
return (
<>
<Descriptions
row
size="small"
style={{
backgroundColor: '#35363c',
borderRadius: '4px',
padding: '10px',
}}
>
<Descriptions.Item itemKey="Processing Interval">{processingTimes.interval} min</Descriptions.Item>
{processingTimes.lastRun && (
<>
<Descriptions.Item itemKey="Last run">{format(processingTimes.lastRun)}</Descriptions.Item>
<Descriptions.Item itemKey="Next run">
{format(processingTimes.lastRun + processingTimes.interval * 60000)}
</Descriptions.Item>
</>
)}
</Descriptions>
import {format} from '../../services/time/timeService';
import {Banner, Card, Descriptions, Divider} from '@douyinfe/semi-ui';
import {IconBolt} from '@douyinfe/semi-icons';
{processingTimes.scrapingAntData != null && (
<>
<Divider margin="1rem" />
<Card
style={{ backgroundColor: '#35363c' }}
export default function ProcessingTimes({processingTimes = {}}) {
const {Meta} = Card;
if (Object.keys(processingTimes).length === 0) {
return null;
}
if (processingTimes.error != null) {
return <Banner
fullMode={false}
type="danger"
closeIcon={null}
title={
<Meta
title="Remaining ScrapingAnt calls"
description="Information about your Scraping Ant Plan"
avatar={<IconBolt />}
/>
<div style={{fontWeight: 600, fontSize: '14px', lineHeight: '20px'}}>
Scraping Ant Error
</div>
}
>
<p>Plan: {processingTimes.scrapingAntData.plan_name}</p>
<p>
Duration: {format(new Date(processingTimes.scrapingAntData.start_date))} -{' '}
{format(new Date(processingTimes.scrapingAntData.end_date))}
<br />
Credits: {processingTimes.scrapingAntData.remained_credits}/
{processingTimes.scrapingAntData.plan_total_credits}
</p>
If you want to scrape Immoscout or Immonet more often, you have to purchase a premium account of{' '}
<a href="https://scrapingant.com/" target="_blank" rel="noreferrer">
ScrapingAnt
</a>
. You can use the code <b>FREDY10</b> to get 10% off. (No affiliation, we are <b>not</b> getting paid to
recommend ScrapingAnt.)
</Card>
style={{marginBottom: '1rem'}}
description={
<div>
{processingTimes.error}
</div>
}
/>;
}
return (
<>
<Descriptions
row
size="small"
style={{
backgroundColor: '#35363c',
borderRadius: '4px',
padding: '10px',
}}
>
<Descriptions.Item itemKey="Processing Interval">{processingTimes.interval} min</Descriptions.Item>
{processingTimes.lastRun && (
<>
<Descriptions.Item itemKey="Last run">{format(processingTimes.lastRun)}</Descriptions.Item>
<Descriptions.Item itemKey="Next run">
{format(processingTimes.lastRun + processingTimes.interval * 60000)}
</Descriptions.Item>
</>
)}
</Descriptions>
{(processingTimes.scrapingAntData != null && Object.keys(processingTimes.scrapingAntData).length > 0) &&(
<>
<Divider margin="1rem"/>
<Card
style={{backgroundColor: '#35363c'}}
title={
<Meta
title="Remaining ScrapingAnt calls"
description="Information about your Scraping Ant Plan"
avatar={<IconBolt/>}
/>
}
>
<p>Plan: {processingTimes.scrapingAntData.plan_name}</p>
<p>
Duration: {format(new Date(processingTimes.scrapingAntData.start_date))} -{' '}
{format(new Date(processingTimes.scrapingAntData.end_date))}
<br/>
Credits: {processingTimes.scrapingAntData.remained_credits}/
{processingTimes.scrapingAntData.plan_total_credits}
</p>
If you want to scrape Immoscout or Immonet more often, you have to purchase a premium account
of{' '}
<a href="https://scrapingant.com/" target="_blank" rel="noreferrer">
ScrapingAnt
</a>
. You can use the code <b>FREDY10</b> to get 10% off. (No affiliation, we are <b>not</b> getting
paid by ScrapingAnt.)
</Card>
</>
)}
</>
)}
</>
);
);
}
/*

View File

@@ -1,88 +1,102 @@
import React from 'react';
import React, {useEffect} from 'react';
import cityBackground from '../../assets/city_background.jpg';
import Logo from '../../components/logo/Logo';
import { xhrPost } from '../../services/xhr';
import { useHistory } from 'react-router';
import { useDispatch } from 'react-redux';
import { Input, Button, Banner } from '@douyinfe/semi-ui';
import {xhrPost} from '../../services/xhr';
import {useHistory} from 'react-router';
import {useDispatch, useSelector} from 'react-redux';
import {Input, Button, Banner} from '@douyinfe/semi-ui';
import './login.less';
import { IconUser, IconLock } from '@douyinfe/semi-icons';
import {IconUser, IconLock} from '@douyinfe/semi-icons';
export default function Login() {
const dispatch = useDispatch();
const dispatch = useDispatch();
const [username, setUserName] = React.useState('');
const [password, setPassword] = React.useState('');
const [error, setError] = React.useState(null);
const demoMode = useSelector((state) => state.demoMode.demoMode || false);
const history = useHistory();
const [username, setUserName] = React.useState('');
const [password, setPassword] = React.useState('');
const [error, setError] = React.useState(null);
useEffect(() => {
async function init() {
await dispatch.demoMode.getDemoMode();
}
const history = useHistory();
init();
}, []);
const tryLogin = async () => {
if (username.length === 0 || password.length === 0) {
setError('Username and password are mandatory.');
return;
}
try {
await xhrPost('/api/login', {
username,
password,
});
setError(null);
} catch (Exception) {
setError('Login not successful...');
return;
}
await dispatch.user.getCurrentUser();
history.push('/jobs');
};
const tryLogin = async () => {
if (username.length === 0 || password.length === 0) {
setError('Username and password are mandatory.');
return;
}
try {
await xhrPost('/api/login', {
username,
password,
});
setError(null);
} catch (Exception) {
setError('Login not successful...');
return;
}
await dispatch.user.getCurrentUser();
history.push('/jobs');
};
return (
<div className="login">
<div className="login__bgImage" style={{ background: `url("${cityBackground}")` }} />
<Logo />
<form>
<div className="login__loginWrapper">
{error && <Banner type="danger" closeIcon={null} description={error} />}
<Input
size="large"
prefix={<IconUser />}
placeholder="Username"
value={username}
showClear
style={{ marginTop: error ? '1rem' : '4rem' }}
autofocus
onChange={(value) => setUserName(value)}
onKeyPress={async (e) => {
if (e.key === 'Enter') {
await tryLogin();
}
}}
/>
return (
<div className="login">
<div className="login__bgImage" style={{background: `url("${cityBackground}")`}}/>
<Logo/>
<form>
<div className="login__loginWrapper">
{error && <Banner type="danger" closeIcon={null} description={error}/>}
<Input
size="large"
prefix={<IconUser/>}
placeholder="Username"
value={username}
showClear
style={{marginTop: error ? '1rem' : '4rem'}}
autoFocus
onChange={(value) => setUserName(value)}
onKeyPress={async (e) => {
if (e.key === 'Enter') {
await tryLogin();
}
}}
/>
<Input
size="large"
mode="password"
prefix={<IconLock />}
value={password}
placeholder="Password"
style={{ marginTop: '2rem' }}
onChange={(value) => setPassword(value)}
onKeyPress={async (e) => {
if (e.key === 'Enter') {
await tryLogin();
}
}}
/>
<Input
size="large"
mode="password"
prefix={<IconLock/>}
value={password}
placeholder="Password"
style={{marginTop: '2rem'}}
onChange={(value) => setPassword(value)}
onKeyPress={async (e) => {
if (e.key === 'Enter') {
await tryLogin();
}
}}
/>
<Button type="primary" onClick={tryLogin} theme="solid" style={{ marginTop: '3rem' }}>
Login
</Button>
<Button type="primary" onClick={tryLogin} theme="solid" style={{marginTop: '3rem'}}>
Login
</Button>
<br/>
{demoMode && <Banner fullMode={true}
type="info"
bordered
closeIcon={null}
description="This is the demo version of Fredy. Use 'demo' as both the username and password to log in."
/>}
</div>
</form>
</div>
</form>
</div>
);
);
}
Login.displayName = 'Login';

2711
yarn.lock

File diff suppressed because it is too large Load Diff