Compare commits

...

13 Commits

Author SHA1 Message Date
Christian Kellner
f8f911aa00 improving tracking 2024-12-03 14:05:00 +01:00
Christian Kellner
13b8701447 Update CONTRIBUTING.md 2024-12-02 15:02:36 +01:00
Christian Kellner
e25b956eda Update config.json 2024-11-22 12:32:37 +01:00
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
32 changed files with 1279 additions and 924 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

@@ -106,9 +106,7 @@ exports.config = {
``` ```
#### Running Tests #### Running Tests
If you've written a new provider you are an awesome person. You know it and I do. If you now write tests for it, you are even more awesome. And who doesn't want to be more awesome right? If you've written a new provider you are an awesome person. If you now write tests for it, you are even more awesome. And who doesn't want to be more awesome right?
To write tests for provider, you need to use Node 8 as the tests are using `async / await`
#### Codestyle #### Codestyle
I'm using Eslint to maintain quote style and quality. Do not skip it... I'm using Eslint to maintain quote style and quality. Do not skip it...

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 _Fredy_ is supported by JetBrains under Open Source Support Program
## Demo
If you want to try out _Fredy_, you can access the demo version [here](https://fredy.orange-coding.net) 🤘
## Usage ## Usage
- Make sure to use Node.js 20 or above - 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). 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 +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` 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

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

View File

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

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

View File

@@ -5,6 +5,8 @@ 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';
import {trackDemoJobCreated} from '../../services/tracking/Tracker.js';
const service = restana(); const service = restana();
const jobRouter = service.newRouter(); const jobRouter = service.newRouter();
function doesJobBelongsToUser(job, req) { function doesJobBelongsToUser(job, req) {
@@ -25,8 +27,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 +40,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();
}); });
@@ -66,6 +69,11 @@ jobRouter.post('/', async (req, res) => {
res.send(new Error(error)); res.send(new Error(error));
console.error(error); console.error(error);
} }
trackDemoJobCreated({
name,
provider,
adapter: notificationAdapter
});
res.send(); res.send();
}); });
jobRouter.delete('', async (req, res) => { jobRouter.delete('', async (req, res) => {

View File

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

View File

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

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,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; 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

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

View File

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

View File

@@ -0,0 +1,72 @@
import Mixpanel from 'mixpanel';
import { getJobs } from '../storage/jobStorage.js';
import { getUniqueId } from './uniqueId.js';
import { config, inDevMode } from '../../utils.js';
const mixpanelTracker = Mixpanel.init('718670ef1c58c0208256c1e408a3d75e');
const distinct_id = getUniqueId() || 'N/A';
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,
distinct_id,
};
}

View File

@@ -0,0 +1,19 @@
import { hostname, arch, cpus, platform } from 'os';
import { createHash } from 'crypto';
/**
* Don't worry, we are not evil ;) We however need a unique id per running instance
* @returns {string}
*/
export const getUniqueId = () => {
const systemInfo = {
hostname: hostname(),
architecture: arch(),
cpuCount: cpus().length,
platform: platform(),
};
const baseData = JSON.stringify(systemInfo);
return createHash('sha256').update(baseData).digest('hex');
};

View File

@@ -2,6 +2,11 @@ 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 {createHash} from 'crypto';
import {DEFAULT_CONFIG} from './defaultConfig.js';
function inDevMode(){
return process.env.NODE_ENV == null || process.env.NODE_ENV !== 'production';
}
function isOneOf(word, arr) { function isOneOf(word, arr) {
if (arr == null || arr.length === 0) { if (arr == null || arr.length === 0) {
@@ -52,9 +57,26 @@ function buildHash(...inputs) {
.digest('hex'); .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 {isOneOf};
export {inDevMode};
export {nullOrEmpty}; export {nullOrEmpty};
export {duringWorkingHoursOrNotSet}; export {duringWorkingHoursOrNotSet};
export {getDirName}; export {getDirName};

View File

@@ -1,9 +1,9 @@
{ {
"name": "fredy", "name": "fredy",
"version": "10.2.0", "version": "10.4.1",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].", "description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": { "scripts": {
"start": "node index.js", "start": "node prod.js",
"dev": "yarn && rm -rf ./ui/public/* && vite", "dev": "yarn && rm -rf ./ui/public/* && vite",
"ui": "rm -rf ./ui/public/* && vite", "ui": "rm -rf ./ui/public/* && vite",
"prod": "yarn && vite build --emptyOutDir", "prod": "yarn && vite build --emptyOutDir",
@@ -11,11 +11,6 @@
"test": "mocha --loader=esmock --timeout 3000000 test/**/*.test.js", "test": "mocha --loader=esmock --timeout 3000000 test/**/*.test.js",
"lint": "eslint ./index.js ./lib/**/*.js ./test/**/*.js ./ui/src/**/*.jsx" "lint": "eslint ./index.js ./lib/**/*.js ./test/**/*.js ./ui/src/**/*.jsx"
}, },
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"type": "module", "type": "module",
"lint-staged": { "lint-staged": {
"*.js": [ "*.js": [
@@ -55,21 +50,22 @@
"Firefox ESR" "Firefox ESR"
], ],
"dependencies": { "dependencies": {
"@douyinfe/semi-ui": "2.68.3", "@douyinfe/semi-ui": "2.70.1",
"@rematch/core": "2.2.0", "@rematch/core": "2.2.0",
"@rematch/loading": "2.1.2", "@rematch/loading": "2.1.2",
"@sendgrid/mail": "8.1.4", "@sendgrid/mail": "8.1.4",
"@vitejs/plugin-react": "4.3.3", "@vitejs/plugin-react": "4.3.4",
"better-sqlite3": "^11.5.0", "better-sqlite3": "^11.6.0",
"body-parser": "1.20.3", "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.8", "highcharts": "12.0.1",
"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.8", "mixpanel": "^0.18.0",
"nanoid": "5.0.9",
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
"node-mailjet": "6.0.6", "node-mailjet": "6.0.6",
"query-string": "9.1.1", "query-string": "9.1.1",
@@ -84,7 +80,7 @@
"serve-static": "1.16.2", "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.4.10", "vite": "5.4.11",
"x-ray": "2.3.4" "x-ray": "2.3.4"
}, },
"devDependencies": { "devDependencies": {
@@ -98,9 +94,9 @@
"eslint-plugin-react": "7.37.2", "eslint-plugin-react": "7.37.2",
"esmock": "2.6.9", "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.1",
"lint-staged": "13.2.2", "lint-staged": "15.2.10",
"mocha": "10.8.2", "mocha": "10.8.2",
"prettier": "3.3.3", "prettier": "3.3.3",
"redux-logger": "3.0.6" "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 InsufficientPermission from './components/permission/InsufficientPermission';
import PermissionAwareRoute from './components/permission/PermissionAwareRoute'; import PermissionAwareRoute from './components/permission/PermissionAwareRoute';
@@ -6,90 +6,106 @@ import GeneralSettings from './views/generalSettings/GeneralSettings';
import JobMutation from './views/jobs/mutation/JobMutation'; import JobMutation from './views/jobs/mutation/JobMutation';
import UserMutator from './views/user/mutation/UserMutator'; import UserMutator from './views/user/mutation/UserMutator';
import JobInsight from './views/jobs/insights/JobInsight.jsx'; import JobInsight from './views/jobs/insights/JobInsight.jsx';
import { useDispatch, useSelector } from 'react-redux'; import {useDispatch, useSelector} from 'react-redux';
import { Switch, Redirect } from 'react-router-dom'; import {Switch, Redirect} from 'react-router-dom';
import Logout from './components/logout/Logout'; import Logout from './components/logout/Logout';
import Logo from './components/logo/Logo'; import Logo from './components/logo/Logo';
import Menu from './components/menu/Menu'; import Menu from './components/menu/Menu';
import Login from './views/login/Login'; import Login from './views/login/Login';
import Users from './views/user/Users'; import Users from './views/user/Users';
import Jobs from './views/jobs/Jobs'; import Jobs from './views/jobs/Jobs';
import { Route } from 'react-router'; import {Route} from 'react-router';
import './App.less'; import './App.less';
import TrackingModal from './components/tracking/TrackingModal.jsx';
import {Banner} from '@douyinfe/semi-ui';
export default function FredyApp() { export default function FredyApp() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const currentUser = useSelector((state) => state.user.currentUser); const currentUser = useSelector((state) => state.user.currentUser);
const settings = useSelector((state) => state.generalSettings.settings);
useEffect(() => { useEffect(() => {
async function init() { async function init() {
await dispatch.user.getCurrentUser(); await dispatch.user.getCurrentUser();
if (!needsLogin()) { if (!needsLogin()) {
await dispatch.provider.getProvider(); await dispatch.provider.getProvider();
await dispatch.jobs.getJobs(); await dispatch.jobs.getJobs();
await dispatch.jobs.getProcessingTimes(); await dispatch.jobs.getProcessingTimes();
await dispatch.notificationAdapter.getAdapter(); await dispatch.notificationAdapter.getAdapter();
} await dispatch.generalSettings.getGeneralSettings();
setLoading(false); }
} setLoading(false);
}
init(); init();
}, [currentUser?.userId]); }, [currentUser?.userId]);
const needsLogin = () => { const needsLogin = () => {
return currentUser == null || Object.keys(currentUser).length === 0; return currentUser == null || Object.keys(currentUser).length === 0;
}; };
const isAdmin = () => currentUser != null && currentUser.isAdmin; const isAdmin = () => currentUser != null && currentUser.isAdmin;
const login = () => ( const login = () => (
<Switch>
<Route name="Login" path={'/login'} component={Login} />
<Redirect from="*" to={'/login'} />
</Switch>
);
return loading ? null : needsLogin() ? (
login()
) : (
<div className="app">
<div className="app__container">
<Logout />
<Logo width={190} white />
<Menu isAdmin={isAdmin()} />
<Switch> <Switch>
<Route name="Insufficient Permission" path={'/403'} component={InsufficientPermission} /> <Route name="Login" path={'/login'} component={Login}/>
<Route name="Create new Job" path={'/jobs/new'} component={JobMutation} /> <Redirect from="*" to={'/login'}/>
<Route name="Edit a Job" path={'/jobs/edit/:jobId'} component={JobMutation} />
<Route name="Insights into a Job" path={'/jobs/insights/:jobId'} component={JobInsight} />
<Route name="Job overview" path={'/jobs'} component={Jobs} />
<PermissionAwareRoute
name="Create new User"
path="/users/new"
component={<UserMutator />}
currentUser={currentUser}
/>
<PermissionAwareRoute
name="Edit a user"
path="/users/edit/:userId"
component={<UserMutator />}
currentUser={currentUser}
/>
<PermissionAwareRoute name="Users" path="/users" component={<Users />} currentUser={currentUser} />
<PermissionAwareRoute
name="General Settings"
path="/generalSettings"
component={<GeneralSettings />}
currentUser={currentUser}
/>
<Redirect from="/" to={'/jobs'} />
</Switch> </Switch>
</div> );
</div>
); return loading ? null : needsLogin() ? (
login()
) : (
<div className="app">
<div className="app__container">
<Logout/>
<Logo width={190} white/>
<Menu isAdmin={isAdmin()}/>
{settings.demoMode && (
<>
<Banner fullMode={true}
type="info"
bordered
closeIcon={null}
description="You're currently viewing the demo version of Fredy. Jobs won't scrape websites, and any changes you make will be reverted at midnight."
/>
<br/>
</>)}
{(settings.analyticsEnabled === null && !settings.demoMode) && <TrackingModal/>}
<Switch>
<Route name="Insufficient Permission" path={'/403'} component={InsufficientPermission}/>
<Route name="Create new Job" path={'/jobs/new'} component={JobMutation}/>
<Route name="Edit a Job" path={'/jobs/edit/:jobId'} component={JobMutation}/>
<Route name="Insights into a Job" path={'/jobs/insights/:jobId'} component={JobInsight}/>
<Route name="Job overview" path={'/jobs'} component={Jobs}/>
<PermissionAwareRoute
name="Create new User"
path="/users/new"
component={<UserMutator/>}
currentUser={currentUser}
/>
<PermissionAwareRoute
name="Edit a user"
path="/users/edit/:userId"
component={<UserMutator/>}
currentUser={currentUser}
/>
<PermissionAwareRoute name="Users" path="/users" component={<Users/>} currentUser={currentUser}/>
<PermissionAwareRoute
name="General Settings"
path="/generalSettings"
component={<GeneralSettings/>}
currentUser={currentUser}
/>
<Redirect from="/" to={'/jobs'}/>
</Switch>
</div>
</div>
);
} }
FredyApp.displayName = 'FredyApp'; FredyApp.displayName = 'FredyApp';

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

View File

@@ -1,245 +1,331 @@
import React from 'react'; import React from 'react';
import { useDispatch, useSelector } from 'react-redux'; import {useDispatch, useSelector} from 'react-redux';
import { Divider, Input, Radio, TimePicker, Button, RadioGroup } from '@douyinfe/semi-ui'; import {Divider, 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) {
const date = new Date(ts); const date = new Date(ts);
return `${date.getHours()}:${date.getMinutes() > 9 ? date.getMinutes() : '0' + date.getMinutes()}`; return `${date.getHours()}:${date.getMinutes() > 9 ? date.getMinutes() : '0' + date.getMinutes()}`;
} }
function formatFromTBackend(time) { function formatFromTBackend(time) {
if (time == null || time.length === 0) { if (time == null || time.length === 0) {
return null; return null;
} }
const date = new Date(); const date = new Date();
const split = time.split(':'); const split = time.split(':');
date.setHours(split[0]); date.setHours(split[0]);
date.setMinutes(split[1]); date.setMinutes(split[1]);
return date.getTime(); return date.getTime();
} }
const GeneralSettings = function GeneralSettings() { const GeneralSettings = function GeneralSettings() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const settings = useSelector((state) => state.generalSettings.settings); const settings = useSelector((state) => state.generalSettings.settings);
const [interval, setInterval] = React.useState(''); const [interval, setInterval] = React.useState('');
const [port, setPort] = React.useState(''); const [port, setPort] = React.useState('');
const [scrapingAntApiKey, setScrapingAntApiKey] = React.useState(''); const [scrapingAntApiKey, setScrapingAntApiKey] = React.useState('');
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);
React.useEffect(() => { const [demoMode, setDemoMode] = React.useState(null);
async function init() { const [analyticsEnabled, setAnalyticsEnabled] = React.useState(null);
await dispatch.generalSettings.getGeneralSettings();
setLoading(false);
}
init(); React.useEffect(() => {
}, []); async function init() {
await dispatch.generalSettings.getGeneralSettings();
setLoading(false);
}
React.useEffect(() => { init();
async function init() { }, []);
setInterval(settings?.interval);
setPort(settings?.port);
setScrapingAntApiKey(settings?.scrapingAnt?.apiKey);
setWorkingHourFrom(settings?.workingHours?.from);
setWorkingHourTo(settings?.workingHours?.to);
setScrapingAntProxy(settings?.scrapingAnt?.proxy || 'datacenter');
}
init(); React.useEffect(() => {
}, [settings]); async function init() {
setInterval(settings?.interval);
setPort(settings?.port);
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) => { const nullOrEmpty = (val) => val == null || val.length === 0;
if (type === 'error') {
Toast.error(message);
} else {
Toast.success(message);
}
};
const onStore = async () => { const throwMessage = (message, type) => {
if (nullOrEmpty(interval)) { if (type === 'error') {
throwMessage('Interval may not be empty.', 'error'); Toast.error(message);
return; } else {
} Toast.success(message);
if (nullOrEmpty(port)) { }
throwMessage('Port may not be empty.', 'error'); };
return;
}
if (
(!nullOrEmpty(workingHourFrom) && nullOrEmpty(workingHourTo)) ||
(nullOrEmpty(workingHourFrom) && !nullOrEmpty(workingHourTo))
) {
throwMessage('Working hours to and from must be set if either to or from has been set before.', 'error');
return;
}
try {
await xhrPost('/api/admin/generalSettings', {
interval,
port,
scrapingAnt: {
apiKey: scrapingAntApiKey,
proxy: scrapingAntProxy,
},
workingHours: {
from: workingHourFrom,
to: workingHourTo,
},
});
} catch (exception) {
console.error(exception);
throwMessage('Error while trying to store settings.', 'error');
return;
}
throwMessage('Settings stored successfully. You MUST restart Fredy.', 'success');
};
return ( const onStore = async () => {
<div> if (nullOrEmpty(interval)) {
{!loading && ( throwMessage('Interval may not be empty.', 'error');
<React.Fragment> return;
<Headline text="General Settings" /> }
<Banner if (nullOrEmpty(port)) {
fullMode={false} throwMessage('Port may not be empty.', 'error');
type="info" return;
closeIcon={null} }
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Info</div>} if (
style={{ marginBottom: '1rem' }} (!nullOrEmpty(workingHourFrom) && nullOrEmpty(workingHourTo)) ||
description="If you change any settings, you must restart Fredy afterwards." (nullOrEmpty(workingHourFrom) && !nullOrEmpty(workingHourTo))
/> ) {
<div> throwMessage('Working hours to and from must be set if either to or from has been set before.', 'error');
<SegmentPart return;
name="Interval" }
helpText="Interval in minutes for running queries against the configured services." try {
Icon={IconRefresh} await xhrPost('/api/admin/generalSettings', {
> interval,
<InputNumber port,
min={0} scrapingAnt: {
max={1440} apiKey: scrapingAntApiKey,
placeholder="Interval in minutes" proxy: scrapingAntProxy,
value={interval} },
formatter={(value) => `${value}`.replace(/\D/g, '')} workingHours: {
onChange={(value) => setInterval(value)} from: workingHourFrom,
suffix={'minutes'} to: workingHourTo,
/> },
</SegmentPart> demoMode,
<Divider margin="1rem" /> analyticsEnabled
<SegmentPart name="Port" helpText="Port on which Fredy is running." Icon={IconSignal}> });
<InputNumber } catch (exception) {
min={0} console.error(exception);
max={99999} if(exception?.json?.message != null){
placeholder="Port" throwMessage(exception.json.message, 'error');
value={port} }else {
formatter={(value) => `${value}`.replace(/\D/g, '')} throwMessage('Error while trying to store settings.', 'error');
onChange={(value) => setPort(value)} }
/> return;
</SegmentPart> }
<Divider margin="1rem" /> throwMessage('Settings stored successfully. We will reload your browser in 3 seconds.', 'success');
<SegmentPart setTimeout(()=>{
name="ScrapingAnt Api Key" location.reload();
helpText="The api key for ScrapingAnt is used to be able to scrape Immoscout." }, 3000);
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)}> return (
<Radio name="datacenter" value="datacenter" checked={scrapingAntProxy === 'datacenter'}> <div>
Datacenter proxy {!loading && (
</Radio> <React.Fragment>
<Radio name="residential" value="residential" checked={scrapingAntProxy === 'residential'}> <Headline text="General Settings"/>
Residential proxy <div>
</Radio> <SegmentPart
</RadioGroup> name="Interval"
</SegmentPart> helpText="Interval in minutes for running queries against the configured services."
<Divider margin="1rem" /> Icon={IconRefresh}
<SegmentPart >
name="Working hours" <InputNumber
helpText="During this hours, Fredy will search for new apartments. If nothing is configured, Fredy will search around the clock." min={0}
Icon={IconCalendar} max={1440}
> placeholder="Interval in minutes"
<div className="generalSettings__timePickerContainer"> value={interval}
<TimePicker formatter={(value) => `${value}`.replace(/\D/g, '')}
format={'HH:mm'} onChange={(value) => setInterval(value)}
insetLabel="From" suffix={'minutes'}
value={formatFromTBackend(workingHourFrom)} />
placeholder="" </SegmentPart>
onChange={(val) => { <Divider margin="1rem"/>
setWorkingHourFrom(val == null ? null : formatFromTimestamp(val)); <SegmentPart name="Port" helpText="Port on which Fredy is running." Icon={IconSignal}>
}} <InputNumber
/> min={0}
<TimePicker max={99999}
format={'HH:mm'} placeholder="Port"
insetLabel="Until" value={port}
value={formatFromTBackend(workingHourTo)} formatter={(value) => `${value}`.replace(/\D/g, '')}
placeholder="" onChange={(value) => setPort(value)}
onChange={(val) => { />
setWorkingHourTo(val == null ? null : formatFromTimestamp(val)); </SegmentPart>
}} <Divider margin="1rem"/>
/> <SegmentPart
</div> name="ScrapingAnt Api Key"
</SegmentPart> helpText="The api key for ScrapingAnt is used to be able to scrape Immoscout."
<Divider margin="1rem" /> Icon={IconKey}
<Button type="primary" theme="solid" onClick={onStore} icon={<IconSave />}> >
Save <Input
</Button> type="text"
</div> placeholder="ScrapingAnt Api Key"
</React.Fragment> value={scrapingAntApiKey}
)} onChange={(val) => setScrapingAntApiKey(val)}
</div> />
); </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; export default GeneralSettings;

View File

@@ -1,63 +1,86 @@
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 }) {
const { Meta } = Card;
return (
<>
<Descriptions
row
size="small"
style={{
backgroundColor: '#35363c',
borderRadius: '4px',
padding: '10px',
}}
>
<Descriptions.Item itemKey="Processing Interval">{processingTimes.interval} min</Descriptions.Item>
{processingTimes.lastRun && (
<>
<Descriptions.Item itemKey="Last run">{format(processingTimes.lastRun)}</Descriptions.Item>
<Descriptions.Item itemKey="Next run">
{format(processingTimes.lastRun + processingTimes.interval * 60000)}
</Descriptions.Item>
</>
)}
</Descriptions>
{processingTimes.scrapingAntData != null && ( export default function ProcessingTimes({processingTimes = {}}) {
<> const {Meta} = Card;
<Divider margin="1rem" /> if (Object.keys(processingTimes).length === 0) {
<Card return null;
style={{ backgroundColor: '#35363c' }} }
if (processingTimes.error != null) {
return <Banner
fullMode={false}
type="danger"
closeIcon={null}
title={ title={
<Meta <div style={{fontWeight: 600, fontSize: '14px', lineHeight: '20px'}}>
title="Remaining ScrapingAnt calls" Scraping Ant Error
description="Information about your Scraping Ant Plan" </div>
avatar={<IconBolt />}
/>
} }
> style={{marginBottom: '1rem'}}
<p>Plan: {processingTimes.scrapingAntData.plan_name}</p> description={
<p> <div>
Duration: {format(new Date(processingTimes.scrapingAntData.start_date))} -{' '} {processingTimes.error}
{format(new Date(processingTimes.scrapingAntData.end_date))} </div>
<br /> }
Credits: {processingTimes.scrapingAntData.remained_credits}/ />;
{processingTimes.scrapingAntData.plan_total_credits} }
</p> return (
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"> <Descriptions
ScrapingAnt row
</a> size="small"
. You can use the code <b>FREDY10</b> to get 10% off. (No affiliation, we are <b>not</b> getting paid to style={{
recommend ScrapingAnt.) backgroundColor: '#35363c',
</Card> 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 cityBackground from '../../assets/city_background.jpg';
import Logo from '../../components/logo/Logo'; import Logo from '../../components/logo/Logo';
import { xhrPost } from '../../services/xhr'; import {xhrPost} from '../../services/xhr';
import { useHistory } from 'react-router'; import {useHistory} from 'react-router';
import { useDispatch } from 'react-redux'; import {useDispatch, useSelector} from 'react-redux';
import { Input, Button, Banner } from '@douyinfe/semi-ui'; import {Input, Button, Banner} from '@douyinfe/semi-ui';
import './login.less'; import './login.less';
import { IconUser, IconLock } from '@douyinfe/semi-icons'; import {IconUser, IconLock} from '@douyinfe/semi-icons';
export default function Login() { export default function Login() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const [username, setUserName] = React.useState('');
const [password, setPassword] = React.useState('');
const [error, setError] = React.useState(null);
const demoMode = useSelector((state) => state.demoMode.demoMode || false);
const history = useHistory();
const [username, setUserName] = React.useState(''); useEffect(() => {
const [password, setPassword] = React.useState(''); async function init() {
const [error, setError] = React.useState(null); await dispatch.demoMode.getDemoMode();
}
const history = useHistory(); init();
}, []);
const tryLogin = async () => { const tryLogin = async () => {
if (username.length === 0 || password.length === 0) { if (username.length === 0 || password.length === 0) {
setError('Username and password are mandatory.'); setError('Username and password are mandatory.');
return; return;
} }
try { try {
await xhrPost('/api/login', { await xhrPost('/api/login', {
username, username,
password, password,
}); });
setError(null); setError(null);
} catch (Exception) { } catch (Exception) {
setError('Login not successful...'); setError('Login not successful...');
return; return;
} }
await dispatch.user.getCurrentUser(); await dispatch.user.getCurrentUser();
history.push('/jobs'); history.push('/jobs');
}; };
return ( return (
<div className="login"> <div className="login">
<div className="login__bgImage" style={{ background: `url("${cityBackground}")` }} /> <div className="login__bgImage" style={{background: `url("${cityBackground}")`}}/>
<Logo /> <Logo/>
<form> <form>
<div className="login__loginWrapper"> <div className="login__loginWrapper">
{error && <Banner type="danger" closeIcon={null} description={error} />} {error && <Banner type="danger" closeIcon={null} description={error}/>}
<Input <Input
size="large" size="large"
prefix={<IconUser />} prefix={<IconUser/>}
placeholder="Username" placeholder="Username"
value={username} value={username}
showClear showClear
style={{ marginTop: error ? '1rem' : '4rem' }} style={{marginTop: error ? '1rem' : '4rem'}}
autofocus autoFocus
onChange={(value) => setUserName(value)} onChange={(value) => setUserName(value)}
onKeyPress={async (e) => { onKeyPress={async (e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
await tryLogin(); await tryLogin();
} }
}} }}
/> />
<Input <Input
size="large" size="large"
mode="password" mode="password"
prefix={<IconLock />} prefix={<IconLock/>}
value={password} value={password}
placeholder="Password" placeholder="Password"
style={{ marginTop: '2rem' }} style={{marginTop: '2rem'}}
onChange={(value) => setPassword(value)} onChange={(value) => setPassword(value)}
onKeyPress={async (e) => { onKeyPress={async (e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
await tryLogin(); await tryLogin();
} }
}} }}
/> />
<Button type="primary" onClick={tryLogin} theme="solid" style={{ marginTop: '3rem' }}> <Button type="primary" onClick={tryLogin} theme="solid" style={{marginTop: '3rem'}}>
Login Login
</Button> </Button>
<br/>
{demoMode && <Banner fullMode={true}
type="info"
bordered
closeIcon={null}
description="This is the demo version of Fredy. Use 'demo' as both the username and password to log in."
/>}
</div>
</form>
</div> </div>
</form> );
</div>
);
} }
Login.displayName = 'Login'; Login.displayName = 'Login';

761
yarn.lock

File diff suppressed because it is too large Load Diff