Compare commits

..

11 Commits

Author SHA1 Message Date
orangecoding
362166651d adding fredy category 2026-06-14 11:00:58 +02:00
orangecoding
a020117a78 Merge branch 'master' of github.com:orangecoding/fredy 2026-06-13 14:02:53 +02:00
orangecoding
9207280ab4 bugfixes and improvements 2026-06-13 14:02:42 +02:00
orangecoding
94384df36d next release version 2026-06-13 13:34:13 +02:00
orangecoding
730cc52187 when storing settings and something is wrong, show the correct error 2026-06-13 13:33:49 +02:00
orangecoding
e82db5b6db removing annoying map animation 2026-06-13 13:21:49 +02:00
orangecoding
2f8c021819 ability to jump back to main menu when clicking on nav bar and on submenu 2026-06-13 13:17:19 +02:00
orangecoding
72c2c02e49 fixing job state setting when job is disabled 2026-06-13 13:14:07 +02:00
Christian Kellner
48c0360111 Update GitHub link to sponsors page 2026-06-12 14:34:06 +02:00
Christian Kellner
63c947896e Revise sponsorship message and add support links
Updated sponsorship section to improve clarity and added support links.
2026-06-12 14:33:13 +02:00
Christian Kellner
2a814b6bb6 Add Ko-fi funding option 2026-06-12 14:29:48 +02:00
23 changed files with 158 additions and 70 deletions

1
.github/FUNDING.yml vendored
View File

@@ -1,3 +1,4 @@
# These are supported funding model platforms
github: [orangecoding]
ko_fi: orangecoding

View File

@@ -46,7 +46,7 @@ index.js (startup)
├── runMigrations()
├── getProviders() # lazily imports lib/provider/*.js
├── similarityCache.init() # preloads hash cache from DB
├── api.js # starts restana HTTP server
├── api.js # starts fastify HTTP server
└── initJobExecutionService() # registers event-bus listeners + starts scheduler
scheduler (every N minutes) or manual trigger via POST /api/jobs/:id/run

View File

@@ -55,8 +55,11 @@ same listing twice.
## 🤝 Sponsorship [![](https://img.shields.io/static/v1?label=Sponsor&message=❤&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/orangecoding)
I maintain Fredy and other open-source projects in my free time.\
If you find it useful, consider supporting the project 💙
I maintain Fredy and other open-source projects in my free time, if you find it useful, consider supporting the project ❤️
#### Support me on
[Ko-Fi](https://ko-fi.com/orangecoding) | [Github](https://github.com/sponsors/orangecoding)
----
Fredy is proudly backed by the **JetBrains Open Source Support Program**.

View File

@@ -264,10 +264,12 @@ class FredyPipelineExecutioner {
listings
// this should never filter some listings out, because the normalize function should always extract all fields.
.filter((item) => requiredKeys.every((key) => key in item))
// Drop listings missing a required identifying field *before* the provider
// filter runs, so provider filter functions never have to defend against a
// null id/link/title.
.filter((item) => requireValues.every((key) => item[key] != null))
// TODO: move blacklist filter to this file, so it will handle for all providers in same way.
.filter(this._providerConfig.filter)
// filter out listings that are missing required fields
.filter((item) => requireValues.every((key) => item[key] != null))
);
}
@@ -322,9 +324,9 @@ class FredyPipelineExecutioner {
*/
_findNew(listings) {
logger.debug(`Checking ${listings.length} listings for new entries (Provider: '${this._providerId}')`);
const hashes = getKnownListingHashesForJobAndProvider(this._jobKey, this._providerId) || [];
const knownHashes = new Set(getKnownListingHashesForJobAndProvider(this._jobKey, this._providerId) || []);
const newListings = listings.filter((o) => !hashes.includes(o.id));
const newListings = listings.filter((o) => !knownHashes.has(o.id));
if (newListings.length === 0) {
throw new NoNewListingsWarning();
}

View File

@@ -29,7 +29,7 @@ export default async function jobPlugin(fastify) {
fastify.get('/', async (request) => {
const isUserAdmin = isAdmin(request);
return jobStorage
.getJobs()
.getJobs({ includeDisabled: true })
.filter(
(job) =>
isUserAdmin ||

View File

@@ -20,7 +20,7 @@ function normalize(o) {
const link = `${baseUrl}/expose/${o.id}.html`;
const price = normalizePrice(o.price);
const id = buildHash(o.id, price);
const image = baseUrl + o.image;
const image = o.image == null ? null : baseUrl + o.image;
const address = o.address == null ? null : o.address.trim().replaceAll('/', ',');
return {
id,

View File

@@ -19,7 +19,7 @@ function normalize(o) {
const originalId = o.id.split('/').pop();
const id = buildHash(originalId, o.price);
const link = o.link != null ? `https://www.mcmakler.de${o.link}` : o.link;
const [rooms, size] = o.tags.split(' | ');
const [rooms, size] = (o.tags || '').split(' | ');
const address = o.address?.replace(' / ', ' ') || null;
return {
id,

View File

@@ -21,7 +21,8 @@ function normalize(o) {
const link = o.link != null ? decodeURIComponent(o.link) : config.url;
const urlReg = new RegExp(/url\((.*?)\)/gim);
const image = o.image != null ? urlReg.exec(o.image)[1] : null;
const imageMatch = o.image != null ? urlReg.exec(o.image) : null;
const image = imageMatch != null ? imageMatch[1] : null;
return {
id,
link,

View File

@@ -44,6 +44,7 @@ function normalize(o) {
const link = `https://www.wg-gesucht.de${o.link}`;
const image = o.image != null ? o.image.replace('small', 'large') : null;
const [rooms, city, road] = o.details?.split(' | ') || [];
const address = [city, road].filter(Boolean).join(', ') || null;
return {
id,
link,
@@ -51,7 +52,7 @@ function normalize(o) {
price: extractNumber(o.price),
size: extractNumber(o.size),
rooms: extractNumber(rooms),
address: `${city}, ${road}`,
address,
image,
description: o.description,
};

View File

@@ -19,7 +19,7 @@ function normalize(o) {
const [city = '', part = ''] = (o.description || '').split('-').map((v) => v.trim());
const address = `${part}, ${city}`;
return {
id: o.link.split('/').pop(),
id: o.link != null ? o.link.split('/').pop() : null,
link: o.link,
title: o.title || '',
price: extractNumber(o.price),
@@ -38,7 +38,7 @@ function normalize(o) {
function applyBlacklist(o) {
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
return o.id != null && o.title != null && titleNotBlacklisted && descNotBlacklisted && o.link.startsWith(o.link);
return o.id != null && o.title != null && o.link != null && titleNotBlacklisted && descNotBlacklisted;
}
/** @type {ProviderConfig} */

View File

@@ -17,16 +17,16 @@ const userAgents = [
];
/**
* Check if a listing is still active with up to 5 attempts and exponential backoff.
* Check if a listing is still active with up to `maxAttempts` attempts and exponential backoff.
* Backoff waits are randomized and capped.
*
* Rules:
* - HTTP 200 => return 1 (if checkForText is provided and found, returns 0)
* - HTTP 401/403 => return -1 (most certainly detected as a bot)
* - HTTP 404 => return 0
* - HTTP 404/410 => return 0
* - Other statuses or network errors => retry until attempts are exhausted
*
* @returns {Promise<Integer>} 1 if active, 0 if not active and -1 if detected as bot
* @returns {Promise<number>} 1 if active, 0 if not active and -1 if detected as bot
*/
export default async function checkIfListingIsActive(link, checkForText = null) {
await sleep(randomBetween(50, 100));

View File

@@ -40,7 +40,8 @@ class SqliteConnection {
}
/**
* Returns a singleton instance of better-sqlite3 Database.
* Respects env var SQLITE_DB_PATH and defaults to db/listings.db.
* Uses the configured `sqlitepath` (from conf/config.json) as the directory,
* defaulting to `/db` (relative to the project root) when unset.
*/
static getConnection() {
if (this.#db) return this.#db;

View File

@@ -169,9 +169,17 @@ export const removeJobsByUserId = (userId) => {
/**
* Get all jobs.
*
* By default only enabled jobs are returned, since most callers (scheduler,
* geocoding cron, tracker, dashboard) operate on active jobs only. The UI,
* however, must also be able to load disabled jobs (e.g. to edit them or view
* their listings), so it passes `includeDisabled: true`.
*
* @param {Object} [params]
* @param {boolean} [params.includeDisabled=false] - When true, disabled jobs are included.
* @returns {Job[]} List of jobs ordered by name (NULLs last).
*/
export const getJobs = () => {
export const getJobs = ({ includeDisabled = false } = {}) => {
const rows = SqliteConnection.query(
`SELECT j.id,
j.user_id AS userId,
@@ -186,7 +194,7 @@ export const getJobs = () => {
j.last_run_at AS lastRunAt,
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
FROM jobs j
WHERE j.enabled = 1
${includeDisabled ? '' : 'WHERE j.enabled = 1'}
ORDER BY j.name IS NULL, j.name`,
);
return rows.map((row) => ({

View File

@@ -14,6 +14,7 @@ import { getSettings } from '../storage/settingsStorage.js';
const deviceId = getUniqueId() || 'N/A';
const version = await getPackageVersion();
const FREDY_TRACKING_URL = 'https://fredy.orange-coding.net/tracking';
const TRACKING_CATEGORY = 'fredy';
const isDocker = process.env.IS_DOCKER != null;
const staticTrackingData = {
@@ -95,6 +96,7 @@ async function enrichTrackingObject(trackingObject) {
const settings = await getSettings();
return {
category: TRACKING_CATEGORY,
...trackingObject,
...staticTrackingData,
isDemo: settings.demoMode,

View File

@@ -5,12 +5,13 @@
/**
* Extract the first number from a string like "1.234 €" or "70 m²".
* Removes dots/commas before parsing. Returns null on invalid input.
* Removes dots/commas before parsing. Returns null when the input is
* null/undefined or cannot be parsed into a number.
* @param {string|undefined|null} str
* @returns {number|null}
*/
export const extractNumber = (str) => {
if (str == null) return 0;
if (str == null) return null;
if (typeof str === 'number') return str;
const cleaned = str.replace(/\./g, '').replace(',', '.');
const num = parseFloat(cleaned);

View File

@@ -1,7 +1,7 @@
{
"name": "fredy",
"version": "22.8.0",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"version": "22.9.1",
"description": "Fredy - [F]ind [R]eal [E]state [D]amn Eas[y] - Fredy keeps searching for new apartments, houses, and flats in Germany on platforms like ImmoScout24, Immowelt, Immonet, eBay Kleinanzeigen, and WG-Gesucht and instantly delivers the results to you via Slack, Telegram, Email, Discord or ntfy, so you can focus on the more important things in life ;)",
"scripts": {
"prepare": "husky",
"start:backend": "x-var NODE_ENV=production node index.js",
@@ -42,6 +42,7 @@
"house",
"rent",
"immoscout",
"kleinanzeigen",
"scraper",
"immonet",
"immowelt",
@@ -75,7 +76,7 @@
"@turf/boolean-point-in-polygon": "^7.3.5",
"@vitejs/plugin-react": "6.0.2",
"adm-zip": "^0.5.17",
"better-sqlite3": "^12.10.0",
"better-sqlite3": "^12.10.1",
"chart.js": "^4.5.1",
"cheerio": "^1.2.0",
"cloakbrowser": "^0.3.31",
@@ -111,13 +112,13 @@
"@babel/preset-react": "7.29.7",
"@eslint/js": "^10.0.1",
"chalk": "^5.6.2",
"eslint": "10.4.1",
"eslint": "10.5.0",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-react": "7.37.5",
"globals": "^17.6.0",
"history": "5.3.0",
"husky": "9.1.7",
"less": "4.6.4",
"less": "4.6.6",
"lint-staged": "17.0.7",
"nodemon": "^3.1.14",
"prettier": "3.8.4",

View File

@@ -57,13 +57,17 @@ describe('#sparkasse testsuite()', () => {
expect(notify.id).toBeTypeOf('string');
expect(notify.price).toBeTypeOf('string');
expect(notify.price).toContain('€');
expect(notify.size).toBeTypeOf('string');
expect(notify.size).toContain('m²');
// Size can legitimately be absent for a card whose layout shifts the
// value out of the expected slot; when present it must be a formatted
// "… m²" string.
if (notify.size != null) {
expect(notify.size).toBeTypeOf('string');
expect(notify.size).toContain('m²');
}
expect(notify.title).toBeTypeOf('string');
expect(notify.link).toBeTypeOf('string');
expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/
expect(notify.size).toBeTypeOf('string');
expect(notify.title).not.toBe('');
expect(notify.address).not.toBe('');
});

View File

@@ -0,0 +1,64 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
// Mock SqliteConnection so we can assert which SQL the storage layer runs
// without spinning up a real SQLite DB.
const calls = {
execute: [],
query: [],
};
const sqliteMock = {
execute: (sql, params) => {
calls.execute.push({ sql, params });
return { changes: 1 };
},
query: (sql, params) => {
calls.query.push({ sql, params });
if (sqliteMock.__queryHandler) return sqliteMock.__queryHandler(sql, params);
return [];
},
__queryHandler: null,
};
vi.mock('../../lib/services/storage/SqliteConnection.js', () => ({
default: sqliteMock,
}));
describe('jobStorage.getJobs', () => {
let jobStorage;
beforeEach(async () => {
calls.execute.length = 0;
calls.query.length = 0;
sqliteMock.__queryHandler = null;
jobStorage = await import('../../lib/services/storage/jobStorage.js');
});
it('filters out disabled jobs by default (WHERE j.enabled = 1)', () => {
jobStorage.getJobs();
expect(calls.query).toHaveLength(1);
expect(calls.query[0].sql).toMatch(/WHERE j\.enabled = 1/);
});
it('includes disabled jobs when includeDisabled is true', () => {
jobStorage.getJobs({ includeDisabled: true });
expect(calls.query).toHaveLength(1);
expect(calls.query[0].sql).not.toMatch(/WHERE j\.enabled = 1/);
});
it('coerces the enabled column to a boolean', () => {
sqliteMock.__queryHandler = () => [
{ id: 'enabled-job', enabled: 1 },
{ id: 'disabled-job', enabled: 0 },
];
const jobs = jobStorage.getJobs({ includeDisabled: true });
expect(jobs.find((j) => j.id === 'enabled-job').enabled).toBe(true);
expect(jobs.find((j) => j.id === 'disabled-job').enabled).toBe(false);
});
});

View File

@@ -185,6 +185,7 @@ const JobGrid = () => {
await xhrPut(`/api/jobs/${jobId}/status`, { status });
Toast.success(t('jobs.toastStatusChanged'));
loadData();
actions.jobsData.getJobs(); // refresh the jobs slice read by the edit form so its switch isn't stale
} catch (error) {
Toast.error(error.error);
}

View File

@@ -92,8 +92,15 @@ export default function Navigation({ isAdmin }) {
items={items}
isCollapsed={collapsed}
selectedKeys={[parsePathName(location.pathname)]}
onSelect={(key) => {
navigate(key.itemKey);
onClick={({ itemKey }) => {
// Use onClick (fires on every click) instead of onSelect (skips the
// already-selected item) so clicking e.g. "Jobs" while on a nested
// route like /jobs/edit/:id still navigates back to the list. Only
// leaf routes navigate; parent items (keys without a leading '/') just
// toggle their submenu.
if (typeof itemKey === 'string' && itemKey.startsWith('/')) {
navigate(itemKey);
}
}}
header={
<div className="navigate__header">

View File

@@ -255,11 +255,11 @@ const GeneralSettings = function GeneralSettings() {
});
} catch (exception) {
console.error(exception);
if (exception?.json?.message != null) {
Toast.error(exception.json.message);
} else {
Toast.error(t('settings.toastSaveError'));
}
// The backend returns the concrete reason in `json.error` (e.g. a 403
// "Only admins can change these settings."). Fall back to `json.message`
// and finally the generic toast so the user always sees why it failed.
const serverReason = exception?.json?.error ?? exception?.json?.message;
Toast.error(serverReason ?? t('settings.toastSaveError'));
return;
}
Toast.success(t('settings.toastSavedReloading'));

View File

@@ -189,6 +189,10 @@ export default function MapView() {
useEffect(() => {
if (!map.current) return;
// Use duration: 0 so the map jumps straight to the target view instead of
// animating from the zoomed-out initial state. This effect re-runs whenever
// listings/filters change, and the fly/zoom animation was distracting on
// every refresh.
if (homeAddress?.coords) {
if (distanceFilter > 0) {
const bounds = getBoundsFromCenter([homeAddress.coords.lng, homeAddress.coords.lat], distanceFilter);
@@ -196,13 +200,13 @@ export default function MapView() {
map.current.fitBounds(bounds, {
padding: 20,
maxZoom: 15,
duration: 1000,
duration: 0,
});
} else {
map.current.flyTo({
center: [homeAddress.coords.lng, homeAddress.coords.lat],
zoom: 12,
duration: 1000,
duration: 0,
});
}
} else {
@@ -216,7 +220,7 @@ export default function MapView() {
map.current.fitBounds(bounds, {
padding: 50,
maxZoom: 15,
duration: 1000,
duration: 0,
});
}
}

View File

@@ -2636,10 +2636,10 @@ baseline-browser-mapping@^2.9.0:
resolved "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz"
integrity sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==
better-sqlite3@^12.10.0:
version "12.10.0"
resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-12.10.0.tgz#bde622d14a18008583a53bc53501ae98f1a12221"
integrity sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==
better-sqlite3@^12.10.1:
version "12.10.1"
resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-12.10.1.tgz#1fedf77460210c83d5140fb700c81700964a1a24"
integrity sha512-HfFtzCqnSfwB3+HroF6PSKzyh+7RfNMGPCzHFUZXRlvrPCb4P3cvxKZNN43Sr7IrkofqQZM+gIvffGpA8VvqgA==
dependencies:
bindings "^1.5.0"
prebuild-install "^7.1.1"
@@ -3560,10 +3560,10 @@ eslint-visitor-keys@^5.0.1:
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be"
integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
eslint@10.4.1:
version "10.4.1"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.4.1.tgz#f6640b176e0912246d9ddbf8fcfa5e8b7f02445a"
integrity sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==
eslint@10.5.0:
version "10.5.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.5.0.tgz#5fca69d6b41fe7e00ba22d4100b2e44efe439ad5"
integrity sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==
dependencies:
"@eslint-community/eslint-utils" "^4.8.0"
"@eslint-community/regexpp" "^4.12.2"
@@ -4749,10 +4749,10 @@ keyv@^4.5.4:
dependencies:
json-buffer "3.0.1"
less@4.6.4:
version "4.6.4"
resolved "https://registry.yarnpkg.com/less/-/less-4.6.4.tgz#3ff8068e6c8a59f1ece8a6b9227bda28c1ed68a2"
integrity sha512-OJmO5+HxZLLw0RLzkqaNHzcgEAQG7C0y3aMbwtCzIUFZsLMNNq/1IdAdHEycQ58CwUO3jPTHmoN+tE5I7FQxNg==
less@4.6.6:
version "4.6.6"
resolved "https://registry.yarnpkg.com/less/-/less-4.6.6.tgz#f7854302a3389d2daf96fb3444ba80a54436e66e"
integrity sha512-ooPSwQGQ2sVe8Dh1jVsbKKsRR2gd8lFK72BDkeSzjnD1T5aIHL65hCMfO0GVmtriKgDKrQv6xp9UrihUsWuAzA==
dependencies:
copy-anything "^3.0.5"
parse-node-version "^1.0.1"
@@ -4760,7 +4760,7 @@ less@4.6.4:
errno "^0.1.1"
graceful-fs "^4.1.2"
image-size "~0.5.0"
make-dir "^2.1.0"
make-dir "^5.1.0"
mime "^1.4.1"
needle "^3.1.0"
source-map "~0.6.0"
@@ -4955,13 +4955,10 @@ magic-string@^0.30.21:
dependencies:
"@jridgewell/sourcemap-codec" "^1.5.5"
make-dir@^2.1.0:
version "2.1.0"
resolved "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz"
integrity sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==
dependencies:
pify "^4.0.1"
semver "^5.6.0"
make-dir@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-5.1.0.tgz#59b2d9acf7ffa543d14238617a697458fa8dd5c9"
integrity sha512-IfpFq6UM39dUNiphpA6uDezNx/AvWyhwfICWPR3t1VspkgkMZrL+Rk1RbN1bx+aeNYwOrqGJgEgV3yotk+ZUVw==
maplibre-gl@^5.24.0:
version "5.24.0"
@@ -6078,11 +6075,6 @@ picomatch@^4.0.4:
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589"
integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
pify@^4.0.1:
version "4.0.1"
resolved "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz"
integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==
pino-abstract-transport@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz#b21e5f33a297e8c4c915c62b3ce5dd4a87a52c23"
@@ -6954,11 +6946,6 @@ secure-json-parse@^4.0.0:
resolved "https://registry.yarnpkg.com/secure-json-parse/-/secure-json-parse-4.1.0.tgz#4f1ab41c67a13497ea1b9131bb4183a22865477c"
integrity sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==
semver@^5.6.0:
version "5.7.2"
resolved "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz"
integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==
semver@^6.3.1:
version "6.3.1"
resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz"