diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index ff4d6f4..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,2 +0,0 @@
-sudo: false
-language: node_js
\ No newline at end of file
diff --git a/README.md b/README.md
index 43b686a..1cd6893 100755
--- a/README.md
+++ b/README.md
@@ -95,6 +95,13 @@ Thanks to all the people who already contributed!
See [Contributing](https://github.com/orangecoding/fredy/blob/master/CONTRIBUTING.md)
+# Analytics
+Fredy is completely free (and will always remain free). However, it would be a huge help if you’d allow me to collect some analytical data.
+Before you freak out, let me explain...
+If you agree, Fredy will send a ping to my Mixpanel project each time it runs.
+The data includes: names of active adapters/providers, OS, architecture, Node version, and language. The information is entirely anonymous and helps me understand which adapters/providers are most frequently used.
+**Thanks**🤘
+
# Docker
Use the Dockerfile in this repository to build an image.
diff --git a/conf/config.json b/conf/config.json
index 6df9d31..037f907 100755
--- a/conf/config.json
+++ b/conf/config.json
@@ -1 +1 @@
-{"interval":"60","port":9998,"scrapingAnt":{"apiKey":"","proxy":"datacenter"},"workingHours":{"from":"","to":""}}
\ No newline at end of file
+{"interval":"60","port":9998,"scrapingAnt":{"apiKey":"","proxy":"datacenter"},"workingHours":{"from":"","to":""},"demoMode":false,"analyticsEnabled":null}
\ No newline at end of file
diff --git a/index.js b/index.js
index c1e38d1..511086b 100755
--- a/index.js
+++ b/index.js
@@ -6,6 +6,7 @@ import * as jobStorage from './lib/services/storage/jobStorage.js';
import FredyRuntime from './lib/FredyRuntime.js';
import { duringWorkingHoursOrNotSet } from './lib/utils.js';
import './lib/api/api.js';
+import {track} from './lib/services/tracking/Tracker.js';
//if db folder does not exist, ensure to create it before loading anything else
if (!fs.existsSync('./db')) {
fs.mkdirSync('./db');
@@ -25,6 +26,7 @@ setInterval(
(function exec() {
const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(config, Date.now());
if (isDuringWorkingHoursOrNotSet) {
+ track();
config.lastRun = Date.now();
jobStorage
.getJobs()
diff --git a/lib/api/routes/generalSettingsRoute.js b/lib/api/routes/generalSettingsRoute.js
index 92b3379..10e2e49 100644
--- a/lib/api/routes/generalSettingsRoute.js
+++ b/lib/api/routes/generalSettingsRoute.js
@@ -1,5 +1,5 @@
import restana from 'restana';
-import { config, getDirName } from '../../utils.js';
+import {config, getDirName, readConfigFromStorage, refreshConfig} from '../../utils.js';
import fs from 'fs';
const service = restana();
const generalSettingsRouter = service.newRouter();
@@ -10,7 +10,9 @@ generalSettingsRouter.get('/', async (req, res) => {
generalSettingsRouter.post('/', async (req, res) => {
const settings = req.body;
try {
- fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify(settings));
+ const currentConfig = await readConfigFromStorage();
+ fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({...currentConfig, ...settings}));
+ await refreshConfig();
} catch (err) {
console.error(err);
res.send(new Error('Error while trying to write settings.'));
diff --git a/lib/api/routes/jobRouter.js b/lib/api/routes/jobRouter.js
index 09694b4..37ac1f5 100644
--- a/lib/api/routes/jobRouter.js
+++ b/lib/api/routes/jobRouter.js
@@ -5,6 +5,7 @@ import * as userStorage from '../../services/storage/userStorage.js';
import * as immoscoutProvider from '../../provider/immoscout.js';
import { config } from '../../utils.js';
import { isAdmin } from '../security.js';
+import {isScrapingAntApiKeySet} from '../../services/scrapingAnt.js';
const service = restana();
const jobRouter = service.newRouter();
function doesJobBelongsToUser(job, req) {
@@ -25,8 +26,8 @@ jobRouter.get('/', async (req, res) => {
res.send();
});
jobRouter.get('/processingTimes', async (req, res) => {
- let scrapingAntData = null;
- if (config.scrapingAnt.apiKey != null && config.scrapingAnt.apiKey.length > 0) {
+ let scrapingAntData = {};
+ if (isScrapingAntApiKeySet()) {
try {
const response = await fetch(`https://api.scrapingant.com/v2/usage?x-api-key=${config.scrapingAnt.apiKey}`);
scrapingAntData = await response.json();
@@ -38,6 +39,7 @@ jobRouter.get('/processingTimes', async (req, res) => {
interval: config.interval,
lastRun: config.lastRun || null,
scrapingAntData,
+ error: scrapingAntData?.detail == null ? null : scrapingAntData?.detail
};
res.send();
});
diff --git a/lib/defaultConfig.js b/lib/defaultConfig.js
new file mode 100644
index 0000000..b2545d5
--- /dev/null
+++ b/lib/defaultConfig.js
@@ -0,0 +1,8 @@
+export const DEFAULT_CONFIG = {
+ 'interval': '60',
+ 'port': 9998,
+ 'scrapingAnt': {'apiKey': '', 'proxy': 'datacenter'},
+ 'workingHours': {'from': '', 'to': ''},
+ 'demoMode': false,
+ 'analyticsEnabled': null
+ };
\ No newline at end of file
diff --git a/lib/services/scrapingAnt.js b/lib/services/scrapingAnt.js
index bfa8fb3..cd2d9dd 100644
--- a/lib/services/scrapingAnt.js
+++ b/lib/services/scrapingAnt.js
@@ -22,7 +22,7 @@ export const transformUrlForScrapingAnt = (url, id) => {
return url;
};
export const isScrapingAntApiKeySet = () => {
- return config.scrapingAnt != null && config.scrapingAnt.apiKey != null && config.scrapingAnt.apiKey.length > 0;
+ return config.scrapingAnt != null && config.scrapingAnt.apiKey != null && config.scrapingAnt.apiKey.length > 8;
};
export const makeUrlResidential = (url) => {
return url.replace('datacenter', 'residential');
diff --git a/lib/services/tracking/Tracker.js b/lib/services/tracking/Tracker.js
new file mode 100644
index 0000000..1439770
--- /dev/null
+++ b/lib/services/tracking/Tracker.js
@@ -0,0 +1,42 @@
+import Mixpanel from 'mixpanel';
+import {getJobs} from '../storage/jobStorage.js';
+
+import {config} from '../../utils.js';
+
+export const track = function () {
+ //only send tracking information if the user allowed to do so.
+ if (config.analyticsEnabled) {
+
+ const mixpanelTracker = Mixpanel.init('718670ef1c58c0208256c1e408a3d75e');
+
+ const activeProvider = new Set();
+ const activeAdapter = new Set();
+ const platform = process.platform;
+ const arch = process.arch;
+ const language = process.env.LANG || 'en';
+ const nodeVersion = process.version || 'N/A';
+
+ const jobs = getJobs();
+
+ if (jobs != null && jobs.length > 0) {
+ jobs.forEach(job => {
+ job.provider.forEach(provider => {
+ activeProvider.add(provider.id);
+ });
+ job.notificationAdapter.forEach(adapter => {
+ activeAdapter.add(adapter.id);
+ });
+ });
+
+ mixpanelTracker.track('fredy_tracking', {
+ adapter: Array.from(activeAdapter),
+ provider: Array.from(activeProvider),
+ isDemo: config.demoMode,
+ platform,
+ arch,
+ nodeVersion,
+ language
+ });
+ }
+ }
+};
\ No newline at end of file
diff --git a/lib/utils.js b/lib/utils.js
index 508c7c8..ea534b6 100755
--- a/lib/utils.js
+++ b/lib/utils.js
@@ -2,6 +2,7 @@ import {dirname} from 'node:path';
import {fileURLToPath} from 'node:url';
import {readFile} from 'fs/promises';
import {createHash} from 'crypto';
+import {DEFAULT_CONFIG} from './defaultConfig.js';
function isOneOf(word, arr) {
if (arr == null || arr.length === 0) {
@@ -52,7 +53,23 @@ function buildHash(...inputs) {
.digest('hex');
}
-const config = JSON.parse(await readFile(new URL('../conf/config.json', import.meta.url)));
+let config = {};
+export async function readConfigFromStorage(){
+ return JSON.parse(await readFile(new URL('../conf/config.json', import.meta.url)));
+}
+
+export async function refreshConfig(){
+ try {
+ config = await readConfigFromStorage();
+ //backwards compatability...
+ config.analyticsEnabled ??= null;
+ config.demoMode ??= false;
+ } catch (error) {
+ config = {...DEFAULT_CONFIG};
+ console.error('Error reading config file', error);
+ }
+}
+await refreshConfig();
export {isOneOf};
export {nullOrEmpty};
diff --git a/package.json b/package.json
index cd31c44..4cda337 100755
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "fredy",
- "version": "10.2.0",
+ "version": "10.3.0",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": {
"start": "node index.js",
@@ -64,6 +64,7 @@
"lodash": "4.17.21",
"lowdb": "6.0.1",
"markdown": "^0.5.0",
+ "mixpanel": "^0.18.0",
"nanoid": "5.0.8",
"node-fetch": "3.3.2",
"node-mailjet": "6.0.6",
diff --git a/ui/src/App.jsx b/ui/src/App.jsx
index a4e3ace..cef92eb 100644
--- a/ui/src/App.jsx
+++ b/ui/src/App.jsx
@@ -17,11 +17,13 @@ import Jobs from './views/jobs/Jobs';
import { Route } from 'react-router';
import './App.less';
+import TrackingModal from './components/tracking/TrackingModal.jsx';
export default function FredyApp() {
const dispatch = useDispatch();
const [loading, setLoading] = React.useState(true);
const currentUser = useSelector((state) => state.user.currentUser);
+ const settings = useSelector((state) => state.generalSettings.settings);
useEffect(() => {
async function init() {
@@ -31,6 +33,7 @@ export default function FredyApp() {
await dispatch.jobs.getJobs();
await dispatch.jobs.getProcessingTimes();
await dispatch.notificationAdapter.getAdapter();
+ await dispatch.generalSettings.getGeneralSettings();
}
setLoading(false);
}
@@ -59,6 +62,7 @@ export default function FredyApp() {
+ {settings.analyticsEnabled === null && }
diff --git a/ui/src/components/tracking/TrackingModal.jsx b/ui/src/components/tracking/TrackingModal.jsx
new file mode 100644
index 0000000..88f451c
--- /dev/null
+++ b/ui/src/components/tracking/TrackingModal.jsx
@@ -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 {
+ 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"
+ >
+
+
+
Hey 👋
+
Fed up with popups? Yeah, me too. But this one’s important, and I promise it will only appear once ;)
+
Fredy is completely free (and will always remain free). If you’d like, you can support me by donating
+ through my GitHub, but there’s absolutely no obligation to do so.
+
However, it would be a huge
+ help if you’d 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.
+
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.
+
Thanks🤘
+
+ ;
+
+}
\ No newline at end of file
diff --git a/ui/src/components/tracking/TrackingModal.less b/ui/src/components/tracking/TrackingModal.less
new file mode 100644
index 0000000..fb8c177
--- /dev/null
+++ b/ui/src/components/tracking/TrackingModal.less
@@ -0,0 +1,5 @@
+.trackingModal {
+ &__description {
+ margin-top:10rem;
+ }
+}
\ No newline at end of file
diff --git a/ui/src/views/generalSettings/GeneralSettings.jsx b/ui/src/views/generalSettings/GeneralSettings.jsx
index c675f7e..1381081 100644
--- a/ui/src/views/generalSettings/GeneralSettings.jsx
+++ b/ui/src/views/generalSettings/GeneralSettings.jsx
@@ -1,245 +1,332 @@
import React from 'react';
-import { useDispatch, useSelector } from 'react-redux';
+import {useDispatch, useSelector} from 'react-redux';
-import { Divider, Input, Radio, TimePicker, Button, RadioGroup } from '@douyinfe/semi-ui';
-import { InputNumber } from '@douyinfe/semi-ui';
+import {Divider, Input, Radio, TimePicker, Button, RadioGroup, Checkbox} from '@douyinfe/semi-ui';
+import {InputNumber} from '@douyinfe/semi-ui';
import Headline from '../../components/headline/Headline';
-import { xhrPost } from '../../services/xhr';
-import { SegmentPart } from '../../components/segment/SegmentPart';
-import { Banner, Toast } from '@douyinfe/semi-ui';
-import { IconSave, IconCalendar, IconKey, IconRefresh, IconSignal } from '@douyinfe/semi-icons';
+import {xhrPost} from '../../services/xhr';
+import {SegmentPart} from '../../components/segment/SegmentPart';
+import {Banner, Toast} from '@douyinfe/semi-ui';
+import {IconSave, IconCalendar, IconKey, IconRefresh, IconSignal, IconLineChartStroked, IconSearch} from '@douyinfe/semi-icons';
import './GeneralSettings.less';
function formatFromTimestamp(ts) {
- const date = new Date(ts);
- return `${date.getHours()}:${date.getMinutes() > 9 ? date.getMinutes() : '0' + date.getMinutes()}`;
+ const date = new Date(ts);
+ return `${date.getHours()}:${date.getMinutes() > 9 ? date.getMinutes() : '0' + date.getMinutes()}`;
}
function formatFromTBackend(time) {
- if (time == null || time.length === 0) {
- return null;
- }
- const date = new Date();
- const split = time.split(':');
- date.setHours(split[0]);
- date.setMinutes(split[1]);
- return date.getTime();
+ if (time == null || time.length === 0) {
+ return null;
+ }
+ const date = new Date();
+ const split = time.split(':');
+ date.setHours(split[0]);
+ date.setMinutes(split[1]);
+ return date.getTime();
}
const GeneralSettings = function GeneralSettings() {
- const dispatch = useDispatch();
- const [loading, setLoading] = React.useState(true);
-
- const settings = useSelector((state) => state.generalSettings.settings);
-
- const [interval, setInterval] = React.useState('');
- const [port, setPort] = React.useState('');
- const [scrapingAntApiKey, setScrapingAntApiKey] = React.useState('');
- const [scrapingAntProxy, setScrapingAntProxy] = React.useState('');
- const [workingHourFrom, setWorkingHourFrom] = React.useState(null);
- const [workingHourTo, setWorkingHourTo] = React.useState(null);
- React.useEffect(() => {
- async function init() {
- await dispatch.generalSettings.getGeneralSettings();
- setLoading(false);
- }
+ const dispatch = useDispatch();
+ const [loading, setLoading] = React.useState(true);
- init();
- }, []);
-
- React.useEffect(() => {
- async function init() {
- setInterval(settings?.interval);
- setPort(settings?.port);
- setScrapingAntApiKey(settings?.scrapingAnt?.apiKey);
- setWorkingHourFrom(settings?.workingHours?.from);
- setWorkingHourTo(settings?.workingHours?.to);
- setScrapingAntProxy(settings?.scrapingAnt?.proxy || 'datacenter');
- }
+ const settings = useSelector((state) => state.generalSettings.settings);
- init();
- }, [settings]);
+ const [interval, setInterval] = React.useState('');
+ const [port, setPort] = React.useState('');
+ const [scrapingAntApiKey, setScrapingAntApiKey] = React.useState('');
+ const [scrapingAntProxy, setScrapingAntProxy] = React.useState('');
+ const [workingHourFrom, setWorkingHourFrom] = React.useState(null);
+ const [workingHourTo, setWorkingHourTo] = React.useState(null);
+ const [demoMode, setDemoMode] = React.useState(null);
+ const [analyticsEnabled, setAnalyticsEnabled] = React.useState(null);
- const nullOrEmpty = (val) => val == null || val.length === 0;
+ React.useEffect(() => {
+ async function init() {
+ await dispatch.generalSettings.getGeneralSettings();
+ setLoading(false);
+ }
- const throwMessage = (message, type) => {
- if (type === 'error') {
- Toast.error(message);
- } else {
- Toast.success(message);
- }
- };
+ init();
+ }, []);
- const onStore = async () => {
- if (nullOrEmpty(interval)) {
- throwMessage('Interval may not be empty.', 'error');
- return;
- }
- if (nullOrEmpty(port)) {
- throwMessage('Port may not be empty.', 'error');
- return;
- }
- if (
- (!nullOrEmpty(workingHourFrom) && nullOrEmpty(workingHourTo)) ||
- (nullOrEmpty(workingHourFrom) && !nullOrEmpty(workingHourTo))
- ) {
- throwMessage('Working hours to and from must be set if either to or from has been set before.', 'error');
- return;
- }
- try {
- await xhrPost('/api/admin/generalSettings', {
- interval,
- port,
- scrapingAnt: {
- apiKey: scrapingAntApiKey,
- proxy: scrapingAntProxy,
- },
- workingHours: {
- from: workingHourFrom,
- to: workingHourTo,
- },
- });
- } catch (exception) {
- console.error(exception);
- throwMessage('Error while trying to store settings.', 'error');
- return;
- }
- throwMessage('Settings stored successfully. You MUST restart Fredy.', 'success');
- };
-
- return (
-
- {!loading && (
-
-
- Info
}
- style={{ marginBottom: '1rem' }}
- description="If you change any settings, you must restart Fredy afterwards."
- />
-
-
- `${value}`.replace(/\D/g, '')}
- onChange={(value) => setInterval(value)}
- suffix={'minutes'}
- />
-
-
-
- `${value}`.replace(/\D/g, '')}
- onChange={(value) => setPort(value)}
- />
-
-
-
- setScrapingAntApiKey(val)}
- />
-
-
-
-
- ScrapingAnt is needed to scrape Immoscout. ScrapingAnt itself is using 2 different types of proxies
-
- 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.
-
Residential-Proxy
- 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.
-
-
-
- On the free tier, you have 10.000 credits, so chose your option wisely. Keep in mind, only
- successful calls will be charged.
-
-
+ 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.
+
Residential-Proxy
+ 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.
+
+
+
+ On the free tier, you have 10.000 credits, so chose your option wisely. Keep
+ in mind, only
+ successful calls will be charged.
+
+
+ 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.
+
- If you want to scrape Immoscout or Immonet more often, you have to purchase a premium account of{' '}
-
- ScrapingAnt
-
- . You can use the code FREDY10 to get 10% off. (No affiliation, we are not getting paid to
- recommend ScrapingAnt.)
-
+ />;
+ }
+ return (
+ <>
+
+ {processingTimes.interval} min
+ {processingTimes.lastRun && (
+ <>
+ {format(processingTimes.lastRun)}
+
+ {format(processingTimes.lastRun + processingTimes.interval * 60000)}
+
+ >
+ )}
+
+
+ {(processingTimes.scrapingAntData != null && Object.keys(processingTimes.scrapingAntData).length > 0) &&(
+ <>
+
+ }
+ />
+ }
+ >
+