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 -
- } - style={{ marginBottom: '1rem' }} - description={ -
-

Datacenter-Proxy

- 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. - -
- } - /> - - setScrapingAntProxy(e.target.value)}> - - Datacenter proxy - - - Residential proxy - - - - - -
- { - setWorkingHourFrom(val == null ? null : formatFromTimestamp(val)); - }} - /> - { - setWorkingHourTo(val == null ? null : formatFromTimestamp(val)); - }} - /> -
-
- - - - - )} - - ); + React.useEffect(() => { + async function init() { + setInterval(settings?.interval); + setPort(settings?.port); + setScrapingAntApiKey(settings?.scrapingAnt?.apiKey); + setWorkingHourFrom(settings?.workingHours?.from); + setWorkingHourTo(settings?.workingHours?.to); + setScrapingAntProxy(settings?.scrapingAnt?.proxy || 'datacenter'); + setAnalyticsEnabled(settings?.analytics || false); + setDemoMode(settings?.demoMode || false); + } + + init(); + }, [settings]); + + const nullOrEmpty = (val) => val == null || val.length === 0; + + const throwMessage = (message, type) => { + if (type === 'error') { + Toast.error(message); + } else { + Toast.success(message); + } + }; + + const onStore = async () => { + if (nullOrEmpty(interval)) { + throwMessage('Interval may not be empty.', 'error'); + return; + } + if (nullOrEmpty(port)) { + throwMessage('Port may not be empty.', 'error'); + return; + } + if ( + (!nullOrEmpty(workingHourFrom) && nullOrEmpty(workingHourTo)) || + (nullOrEmpty(workingHourFrom) && !nullOrEmpty(workingHourTo)) + ) { + throwMessage('Working hours to and from must be set if either to or from has been set before.', 'error'); + return; + } + try { + await xhrPost('/api/admin/generalSettings', { + interval, + port, + scrapingAnt: { + apiKey: scrapingAntApiKey, + proxy: scrapingAntProxy, + }, + workingHours: { + from: workingHourFrom, + to: workingHourTo, + }, + demoMode, + analyticsEnabled + }); + } catch (exception) { + console.error(exception); + throwMessage('Error while trying to store settings.', 'error'); + return; + } + throwMessage('Settings stored successfully.', '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 +
+ } + style={{marginBottom: '1rem'}} + description={ +
+

Datacenter-Proxy

+ 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. + +
+ } + /> + + setScrapingAntProxy(e.target.value)}> + + Datacenter proxy + + + Residential proxy + + + + + +
+ { + setWorkingHourFrom(val == null ? null : formatFromTimestamp(val)); + }} + /> + { + setWorkingHourTo(val == null ? null : formatFromTimestamp(val)); + }} + /> +
+
+ + + + + Explanation + + } + style={{marginBottom: '1rem'}} + description={ +
+ Analytics are disabled by default. If you choose to enable them, we will begin tracking the following:
+
    +
  • Name of active provider (e.g. Immoscout)
  • +
  • Name of active adapter (e.g. Console)
  • +
  • language
  • +
  • os
  • +
  • node version
  • +
  • arch
  • +
+ 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. +
+ } + /> + + setAnalyticsEnabled(e.target.checked)} + > Enabled + + +
+ + + + + + Explanation + + } + style={{marginBottom: '1rem'}} + description={ +
+ 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. +
+ } + /> + + setDemoMode(e.target.checked)} + > Enabled + + +
+ + + + + + )} + + ); }; export default GeneralSettings; diff --git a/ui/src/views/jobs/ProcessingTimes.jsx b/ui/src/views/jobs/ProcessingTimes.jsx index 8d95d41..03e7a5d 100644 --- a/ui/src/views/jobs/ProcessingTimes.jsx +++ b/ui/src/views/jobs/ProcessingTimes.jsx @@ -1,63 +1,86 @@ import React from 'react'; -import { format } from '../../services/time/timeService'; -import { Card, Descriptions, Divider } from '@douyinfe/semi-ui'; -import { IconBolt } from '@douyinfe/semi-icons'; -export default function ProcessingTimes({ processingTimes }) { - const { Meta } = Card; - return ( - <> - - {processingTimes.interval} min - {processingTimes.lastRun && ( - <> - {format(processingTimes.lastRun)} - - {format(processingTimes.lastRun + processingTimes.interval * 60000)} - - - )} - +import {format} from '../../services/time/timeService'; +import {Banner, Card, Descriptions, Divider} from '@douyinfe/semi-ui'; +import {IconBolt} from '@douyinfe/semi-icons'; - {processingTimes.scrapingAntData != null && ( - <> - - } - /> +
+ Scraping Ant Error +
+ } + style={{marginBottom: '1rem'}} + description={ +
+ {processingTimes.error} +
} - > -

Plan: {processingTimes.scrapingAntData.plan_name}

-

- Duration: {format(new Date(processingTimes.scrapingAntData.start_date))} -{' '} - {format(new Date(processingTimes.scrapingAntData.end_date))} -
- Credits: {processingTimes.scrapingAntData.remained_credits}/ - {processingTimes.scrapingAntData.plan_total_credits} -

- 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) &&( + <> + + } + /> + } + > +

Plan: {processingTimes.scrapingAntData.plan_name}

+

+ Duration: {format(new Date(processingTimes.scrapingAntData.start_date))} -{' '} + {format(new Date(processingTimes.scrapingAntData.end_date))} +
+ Credits: {processingTimes.scrapingAntData.remained_credits}/ + {processingTimes.scrapingAntData.plan_total_credits} +

+ 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 by ScrapingAnt.) +
+ + )} - )} - - ); + ); } /* diff --git a/yarn.lock b/yarn.lock index e562ee0..a88056b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1626,6 +1626,13 @@ acorn@^8.9.0: resolved "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz" integrity sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw== +agent-base@6: + version "6.0.2" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + ajv@^6.12.4: version "6.12.4" resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.4.tgz" @@ -2473,6 +2480,13 @@ debug@3.2.7, debug@^3.2.6: dependencies: ms "^2.1.1" +debug@4, debug@~4.3.6: + version "4.3.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" + integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== + dependencies: + ms "^2.1.3" + debug@^3.1.0: version "3.2.6" resolved "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz" @@ -2508,13 +2522,6 @@ debug@^4.3.2: dependencies: ms "2.1.2" -debug@~4.3.6: - version "4.3.7" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" - integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== - dependencies: - ms "^2.1.3" - decamelize@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz" @@ -4004,6 +4011,14 @@ http-outgoing@^0.12.0: resolved "https://registry.npmjs.org/http-outgoing/-/http-outgoing-0.12.0.tgz" integrity sha1-Zi86J8ek0UySS19TFJCe+r3hgw0= +https-proxy-agent@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz#e2a90542abb68a762e0a0850f6c9edadfd8506b2" + integrity sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA== + dependencies: + agent-base "6" + debug "4" + human-signals@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-5.0.0.tgz#42665a284f9ae0dade3ba41ebc37eb4b852f3a28" @@ -5457,6 +5472,13 @@ minimist@^1.2.5: resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz" integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== +mixpanel@^0.18.0: + version "0.18.0" + resolved "https://registry.yarnpkg.com/mixpanel/-/mixpanel-0.18.0.tgz#f010f2622902d0d4b434de238446ec8e5966ee32" + integrity sha512-VyUoiLB/S/7abYYHGD5x0LijeuJCUabG8Hb+FvYU3Y99xHf1Qh+s4/pH9lt50fRitAHncWbU1FE01EknUfVVjQ== + dependencies: + https-proxy-agent "5.0.0" + mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3: version "0.5.3" resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113"