Compare commits

...

15 Commits

Author SHA1 Message Date
Christian Kellner
f5d56a6bda version update 2024-12-03 14:25:02 +01:00
Christian Kellner
324b14da50 improving tracking 2024-12-03 14:23:09 +01:00
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 1282 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

@@ -6,6 +6,9 @@ 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,15 +19,23 @@ 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(!config.demoMode) {
if (isDuringWorkingHoursOrNotSet) { if (isDuringWorkingHoursOrNotSet) {
track();
config.lastRun = Date.now(); config.lastRun = Date.now();
jobStorage jobStorage
.getJobs() .getJobs()
@@ -44,6 +55,7 @@ setInterval(
console.debug('Working hours set. Skipping as outside of working hours.'); console.debug('Working hours set. Skipping as outside of working hours.');
/* eslint-enable no-console */ /* 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,75 @@
import Mixpanel from 'mixpanel';
import { getJobs } from '../storage/jobStorage.js';
import { getUniqueId } from './uniqueId.js';
import { config, inDevMode } from '../../utils.js';
import os from 'os';
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 operating_system = os.platform();
const os_version = os.release();
const arch = process.arch;
const language = process.env.LANG || 'en';
const nodeVersion = process.version || 'N/A';
return {
...trackingObject,
isDemo: config.demoMode,
operating_system,
os_version,
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.3",
"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

@@ -17,11 +17,14 @@ 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() {
@@ -31,6 +34,7 @@ export default function FredyApp() {
await dispatch.jobs.getJobs(); await dispatch.jobs.getJobs();
await dispatch.jobs.getProcessingTimes(); await dispatch.jobs.getProcessingTimes();
await dispatch.notificationAdapter.getAdapter(); await dispatch.notificationAdapter.getAdapter();
await dispatch.generalSettings.getGeneralSettings();
} }
setLoading(false); setLoading(false);
} }
@@ -59,6 +63,18 @@ export default function FredyApp() {
<Logout/> <Logout/>
<Logo width={190} white/> <Logo width={190} white/>
<Menu isAdmin={isAdmin()}/> <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> <Switch>
<Route name="Insufficient Permission" path={'/403'} component={InsufficientPermission}/> <Route name="Insufficient Permission" path={'/403'} component={InsufficientPermission}/>
<Route name="Create new Job" path={'/jobs/new'} component={JobMutation}/> <Route name="Create new Job" path={'/jobs/new'} component={JobMutation}/>

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -1,10 +1,10 @@
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';
@@ -12,13 +12,20 @@ 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 [username, setUserName] = React.useState('');
const [password, setPassword] = React.useState(''); const [password, setPassword] = React.useState('');
const [error, setError] = React.useState(null); const [error, setError] = React.useState(null);
const demoMode = useSelector((state) => state.demoMode.demoMode || false);
const history = useHistory(); const history = useHistory();
useEffect(() => {
async function init() {
await dispatch.demoMode.getDemoMode();
}
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.');
@@ -52,7 +59,7 @@ export default function Login() {
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') {
@@ -79,6 +86,13 @@ export default function Login() {
<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> </div>
</form> </form>
</div> </div>

761
yarn.lock

File diff suppressed because it is too large Load Diff