Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5efd9af32 | ||
|
|
9f1e27d011 | ||
|
|
ebc57702dc | ||
|
|
3aa30bc1e2 | ||
|
|
f97fb48e51 | ||
|
|
4b15894603 | ||
|
|
31a14a0352 | ||
|
|
eecbe91dbd | ||
|
|
9dd3947cb7 | ||
|
|
c151f4f76e | ||
|
|
b6755497e4 | ||
|
|
412e24b1e3 | ||
|
|
0a5785fa1a | ||
|
|
7ebd73c9cf | ||
|
|
95cd4028d7 | ||
|
|
eb01c2107c | ||
|
|
42cd4fa0ae | ||
|
|
6d96fd2bf8 | ||
|
|
ff1d2317a1 | ||
|
|
a47fa41278 | ||
|
|
9654e56846 | ||
|
|
43094640a8 | ||
|
|
fa234d2d78 | ||
|
|
7cb0d6e382 | ||
|
|
d79f8d2664 | ||
|
|
4d37e890ab | ||
|
|
7589f20a18 | ||
|
|
702ffabc1a | ||
|
|
9387de1cd9 | ||
|
|
facd683d45 | ||
|
|
8324357edb | ||
|
|
67af7c7dc5 | ||
|
|
6f5b52f3ad | ||
|
|
89d239c360 | ||
|
|
dd5c5b29d9 | ||
|
|
0cb2f48645 | ||
|
|
3f294b8099 | ||
|
|
11fd18e76a | ||
|
|
c839f3abc9 | ||
|
|
28eddc5d7f | ||
|
|
0ca9c5ae02 | ||
|
|
a7d0037edd | ||
|
|
f339a2e2cf | ||
|
|
0d2b21c789 |
38
.github/workflows/docker.yml
vendored
@@ -57,3 +57,41 @@ jobs:
|
|||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
# Test container health with docker compose
|
||||||
|
- name: Test container with docker compose
|
||||||
|
run: |
|
||||||
|
echo "Starting container with docker compose..."
|
||||||
|
docker compose up --build -d
|
||||||
|
echo "Waiting for container to be ready (60 seconds for start_period)..."
|
||||||
|
sleep 60
|
||||||
|
|
||||||
|
echo "Monitoring container health for 30 seconds..."
|
||||||
|
SECONDS_ELAPSED=0
|
||||||
|
HEALTH_CHECK_INTERVAL=5
|
||||||
|
TOTAL_DURATION=30
|
||||||
|
|
||||||
|
while [ $SECONDS_ELAPSED -lt $TOTAL_DURATION ]; do
|
||||||
|
HEALTH_STATUS=$(docker inspect --format='{{.State.Health.Status}}' fredy 2>/dev/null || echo "not_found")
|
||||||
|
CONTAINER_STATUS=$(docker inspect --format='{{.State.Status}}' fredy 2>/dev/null || echo "not_found")
|
||||||
|
echo "[$SECONDS_ELAPSED/$TOTAL_DURATION sec] Container: $CONTAINER_STATUS, Health: $HEALTH_STATUS"
|
||||||
|
|
||||||
|
# Check if container is not running or unhealthy
|
||||||
|
if [ "$CONTAINER_STATUS" != "running" ]; then
|
||||||
|
echo "Container stopped running! Status: $CONTAINER_STATUS"
|
||||||
|
docker compose logs fredy
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$HEALTH_STATUS" = "unhealthy" ]; then
|
||||||
|
echo "Container is unhealthy!"
|
||||||
|
docker compose logs fredy
|
||||||
|
docker inspect --format='{{json .State.Health}}' fredy | jq
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep $HEALTH_CHECK_INTERVAL
|
||||||
|
SECONDS_ELAPSED=$((SECONDS_ELAPSED + HEALTH_CHECK_INTERVAL))
|
||||||
|
done
|
||||||
|
|
||||||
|
docker compose down
|
||||||
|
|||||||
1
.gitignore
vendored
@@ -5,3 +5,4 @@ db/*.db*
|
|||||||
npm-debug.log
|
npm-debug.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.idea
|
.idea
|
||||||
|
.vscode
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ FROM node:22-slim
|
|||||||
|
|
||||||
WORKDIR /fredy
|
WORKDIR /fredy
|
||||||
|
|
||||||
# Install Chromium without extra recommended packages and clean apt cache
|
# Install Chromium and curl without extra recommended packages and clean apt cache
|
||||||
|
# curl is needed for the health check
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends chromium \
|
&& apt-get install -y --no-install-recommends chromium curl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
|
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
|
||||||
@@ -30,6 +31,8 @@ RUN mkdir -p /db /conf \
|
|||||||
&& ln -s /conf /fredy/conf
|
&& ln -s /conf /fredy/conf
|
||||||
|
|
||||||
EXPOSE 9998
|
EXPOSE 9998
|
||||||
|
VOLUME /db
|
||||||
|
VOLUME /conf
|
||||||
|
|
||||||
# Start application using PM2 runtime
|
# Start application using PM2 runtime
|
||||||
CMD ["pm2-runtime", "index.js"]
|
CMD ["pm2-runtime", "index.js"]
|
||||||
|
|||||||
30
README.md
@@ -9,10 +9,18 @@
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||

|
<p align="center">
|
||||||
[](https://github.com/orangecoding/fredy/actions/workflows/docker.yml)
|
<a href="https://fredy.orange-coding.net/" target="_blank">Website</a> |
|
||||||

|
<a href="https://demo-fredy.orange-coding.net/" target="_blank">Demo</a>
|
||||||

|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://github.com/orangecoding/fredy/actions/workflows/test.yml/badge.svg" alt="Tests" />
|
||||||
|
<img src="https://github.com/orangecoding/fredy/actions/workflows/docker.yml/badge.svg" alt="Docker" />
|
||||||
|
<img src="https://github.com/orangecoding/fredy/actions/workflows/check_source.yml/badge.svg" alt="Source" />
|
||||||
|
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fghcr-badge.elias.eu.org%2Fapi%2Forangecoding%2Ffredy%2Ffredy&query=%24.downloadCount&label=Docker%20Pulls" alt="Docker Pulls" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Fredy 🏡 – Your Self-Hosted Real Estate Finder for Germany
|
# Fredy 🏡 – Your Self-Hosted Real Estate Finder for Germany
|
||||||
@@ -21,15 +29,13 @@ Finding an apartment or house in Germany can be stressful and
|
|||||||
time-consuming.\
|
time-consuming.\
|
||||||
**Fredy** makes it easier: it automatically scrapes **ImmoScout24,
|
**Fredy** makes it easier: it automatically scrapes **ImmoScout24,
|
||||||
Immowelt, Immonet, eBay Kleinanzeigen, and WG-Gesucht** and notifies you
|
Immowelt, Immonet, eBay Kleinanzeigen, and WG-Gesucht** and notifies you
|
||||||
instantly via **Slack, Telegram, Email, ntfy, and more** when new
|
instantly via **Slack, Telegram, Email, ntfy, discord and more** when new
|
||||||
listings appear.
|
listings appear.
|
||||||
|
|
||||||
With a modern architecture, Fredy provides a **clean Web UI**, removes
|
With a modern architecture, Fredy provides a **clean Web UI**, removes
|
||||||
duplicates across platforms, and stores results so you never see the
|
duplicates across platforms, and stores results so you never see the
|
||||||
same listing twice.
|
same listing twice.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
## ✨ Key Features
|
## ✨ Key Features
|
||||||
@@ -37,7 +43,7 @@ same listing twice.
|
|||||||
- 🏠 Scrapes **ImmoScout24, Immowelt, Immonet, eBay Kleinanzeigen,
|
- 🏠 Scrapes **ImmoScout24, Immowelt, Immonet, eBay Kleinanzeigen,
|
||||||
WG-Gesucht**
|
WG-Gesucht**
|
||||||
- ⚡ Instant notifications: Slack, Telegram, Email (SendGrid,
|
- ⚡ Instant notifications: Slack, Telegram, Email (SendGrid,
|
||||||
Mailjet), ntfy
|
Mailjet), ntfy, discord
|
||||||
- 🔎 Uses the **ImmoScout Mobile API** (reverse engineered)
|
- 🔎 Uses the **ImmoScout Mobile API** (reverse engineered)
|
||||||
- 🌍 Runs anywhere: Docker, Node.js, self-hosted
|
- 🌍 Runs anywhere: Docker, Node.js, self-hosted
|
||||||
- 🖥️ Intuitive **Web UI** to manage searches
|
- 🖥️ Intuitive **Web UI** to manage searches
|
||||||
@@ -109,9 +115,9 @@ yarn run start:frontend # in another terminal
|
|||||||
|
|
||||||
## 📸 Screenshots
|
## 📸 Screenshots
|
||||||
|
|
||||||
| Job Configuration | Job Analytics | Job Overview |
|
| Fredy Main Overview | Job Configuration | Found Listings |
|
||||||
|-------------------|--------------|--------------|
|
|--------------------------------------------------|-----------------------------------------------------------------------|-----------------------------------------------------------------------------|
|
||||||
|  |  |  |
|
|  |  |  |
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -131,7 +137,7 @@ picks up the newest listings first.
|
|||||||
### Adapter 📡
|
### Adapter 📡
|
||||||
|
|
||||||
An **adapter** is the channel through which Fredy notifies you (Slack,
|
An **adapter** is the channel through which Fredy notifies you (Slack,
|
||||||
Telegram, Email, ntfy, ...).\
|
Telegram, Email, ntfy, discord ...).\
|
||||||
Each adapter has its own configuration (e.g. API keys, webhook URLs).\
|
Each adapter has its own configuration (e.g. API keys, webhook URLs).\
|
||||||
You can use multiple adapters at once --- Fredy will send new listings
|
You can use multiple adapters at once --- Fredy will send new listings
|
||||||
through all of them.
|
through all of them.
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"interval":"60","port":9998,"workingHours":{"from":"","to":""},"demoMode":false,"analyticsEnabled":null,"sqlitepath":"/db"}
|
{"interval":"60","port":9998,"workingHours":{"from":"","to":""},"demoMode":false,"analyticsEnabled":true,"sqlitepath":"/db"}
|
||||||
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 197 KiB |
BIN
doc/screenshot2.png
Normal file
|
After Width: | Height: | Size: 512 KiB |
BIN
doc/screenshot3.png
Normal file
|
After Width: | Height: | Size: 372 KiB |
|
Before Width: | Height: | Size: 323 KiB |
|
Before Width: | Height: | Size: 93 KiB |
@@ -5,11 +5,18 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: fredy/fredy
|
image: ghcr.io/orangecoding/fredy
|
||||||
# map existing config and database
|
# map existing config and database
|
||||||
volumes:
|
volumes:
|
||||||
- ./conf:/conf
|
- ./conf:/conf
|
||||||
- ./db:/db
|
- ./db:/db
|
||||||
ports:
|
ports:
|
||||||
- 9998:9998
|
- "9998:9998"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
# The container will immediately stop when health check fails after retries
|
||||||
|
test: ["CMD-SHELL", "curl --fail --silent --show-error --max-time 5 http://localhost:9998/ || exit 1"]
|
||||||
|
interval: 120s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 1
|
||||||
|
start_period: 10s
|
||||||
|
|||||||
18
docker-test.sh
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Stop and remove old container if it exists
|
||||||
|
if [ "$(docker ps -aq -f name=fredy)" ]; then
|
||||||
|
docker stop fredy || true
|
||||||
|
docker rm fredy || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build image from local Dockerfile
|
||||||
|
docker build -t fredy:local .
|
||||||
|
|
||||||
|
# Run container with volumes and port mapping
|
||||||
|
docker run -d --name fredy \
|
||||||
|
-v fredy_conf:/conf \
|
||||||
|
-v fredy_db:/db \
|
||||||
|
-p 9998:9998 \
|
||||||
|
fredy:local
|
||||||
42
index.js
@@ -1,16 +1,27 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { config } from './lib/utils.js';
|
import { checkIfConfigIsAccessible, config, getProviders, refreshConfig } from './lib/utils.js';
|
||||||
import * as similarityCache from './lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from './lib/services/similarity-check/similarityCache.js';
|
||||||
import * as jobStorage from './lib/services/storage/jobStorage.js';
|
import * as jobStorage from './lib/services/storage/jobStorage.js';
|
||||||
import FredyRuntime from './lib/FredyRuntime.js';
|
import FredyRuntime from './lib/FredyRuntime.js';
|
||||||
import { duringWorkingHoursOrNotSet } from './lib/utils.js';
|
import { duringWorkingHoursOrNotSet } from './lib/utils.js';
|
||||||
import { runMigrations } from './lib/services/storage/migrations/migrate.js';
|
import { runMigrations } from './lib/services/storage/migrations/migrate.js';
|
||||||
import { ensureDemoUserExists, ensureAdminUserExists } from './lib/services/storage/userStorage.js';
|
import { ensureDemoUserExists, ensureAdminUserExists } from './lib/services/storage/userStorage.js';
|
||||||
import { cleanupDemoAtMidnight } from './lib/services/demoCleanup.js';
|
import { cleanupDemoAtMidnight } from './lib/services/crons/demoCleanup-cron.js';
|
||||||
import { initTrackerCron } from './lib/services/tracking/Tracker-Cron.js';
|
import { initTrackerCron } from './lib/services/crons/tracker-cron.js';
|
||||||
import logger from './lib/services/logger.js';
|
import logger from './lib/services/logger.js';
|
||||||
import { bus } from './lib/services/events/event-bus.js';
|
import { bus } from './lib/services/events/event-bus.js';
|
||||||
|
import { initActiveCheckerCron } from './lib/services/crons/listing-alive-cron.js';
|
||||||
|
|
||||||
|
// Load configuration before any other startup steps
|
||||||
|
await refreshConfig();
|
||||||
|
|
||||||
|
const isConfigAccessible = await checkIfConfigIsAccessible();
|
||||||
|
|
||||||
|
if (!isConfigAccessible) {
|
||||||
|
logger.error('Configuration exists, but is not accessible. Please check the file permission');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure sqlite directory exists before loading anything else (based on config.sqlitepath)
|
// Ensure sqlite directory exists before loading anything else (based on config.sqlitepath)
|
||||||
const rawDir = config.sqlitepath || '/db';
|
const rawDir = config.sqlitepath || '/db';
|
||||||
@@ -23,8 +34,9 @@ if (!fs.existsSync(absDir)) {
|
|||||||
// Run DB migrations once at startup and block until finished
|
// Run DB migrations once at startup and block until finished
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
|
|
||||||
const providersPath = './lib/provider';
|
// Load provider modules once at startup
|
||||||
const provider = fs.readdirSync(providersPath).filter((file) => file.endsWith('.js'));
|
const providers = await getProviders();
|
||||||
|
|
||||||
//assuming interval is always in minutes
|
//assuming interval is always in minutes
|
||||||
const INTERVAL = config.interval * 60 * 1000;
|
const INTERVAL = config.interval * 60 * 1000;
|
||||||
|
|
||||||
@@ -38,13 +50,11 @@ if (config.demoMode) {
|
|||||||
|
|
||||||
logger.info(`Started Fredy successfully. Ui can be accessed via http://localhost:${config.port}`);
|
logger.info(`Started Fredy successfully. Ui can be accessed via http://localhost:${config.port}`);
|
||||||
|
|
||||||
const fetchedProvider = await Promise.all(
|
|
||||||
provider.filter((provider) => provider.endsWith('.js')).map(async (pro) => import(`${providersPath}/${pro}`)),
|
|
||||||
);
|
|
||||||
|
|
||||||
ensureAdminUserExists();
|
ensureAdminUserExists();
|
||||||
ensureDemoUserExists();
|
ensureDemoUserExists();
|
||||||
await initTrackerCron();
|
await initTrackerCron();
|
||||||
|
//do not wait for this to finish, let it run in the background
|
||||||
|
initActiveCheckerCron();
|
||||||
|
|
||||||
bus.on('jobs:runAll', () => {
|
bus.on('jobs:runAll', () => {
|
||||||
logger.debug('Running Fredy Job manually');
|
logger.debug('Running Fredy Job manually');
|
||||||
@@ -61,11 +71,17 @@ const execute = () => {
|
|||||||
.filter((job) => job.enabled)
|
.filter((job) => job.enabled)
|
||||||
.forEach((job) => {
|
.forEach((job) => {
|
||||||
job.provider
|
job.provider
|
||||||
.filter((p) => fetchedProvider.find((fp) => fp.metaInformation.id === p.id) != null)
|
.filter((p) => providers.find((loaded) => loaded.metaInformation.id === p.id) != null)
|
||||||
.forEach(async (prov) => {
|
.forEach(async (prov) => {
|
||||||
const pro = fetchedProvider.find((fp) => fp.metaInformation.id === prov.id);
|
const matchedProvider = providers.find((loaded) => loaded.metaInformation.id === prov.id);
|
||||||
pro.init(prov, job.blacklist);
|
matchedProvider.init(prov, job.blacklist);
|
||||||
await new FredyRuntime(pro.config, job.notificationAdapter, prov.id, job.id, similarityCache).execute();
|
await new FredyRuntime(
|
||||||
|
matchedProvider.config,
|
||||||
|
job.notificationAdapter,
|
||||||
|
prov.id,
|
||||||
|
job.id,
|
||||||
|
similarityCache,
|
||||||
|
).execute();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ class FredyRuntime {
|
|||||||
}
|
}
|
||||||
return !similar;
|
return !similar;
|
||||||
});
|
});
|
||||||
filteredList.forEach((filter) => this._similarityCache.addCacheEntry(filter.title, listings.address));
|
filteredList.forEach((filter) => this._similarityCache.addCacheEntry(filter.title, filter.address));
|
||||||
return filteredList;
|
return filteredList;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { authInterceptor, cookieSession, adminInterceptor } from './security.js'
|
|||||||
import { generalSettingsRouter } from './routes/generalSettingsRoute.js';
|
import { generalSettingsRouter } from './routes/generalSettingsRoute.js';
|
||||||
import { analyticsRouter } from './routes/analyticsRouter.js';
|
import { analyticsRouter } from './routes/analyticsRouter.js';
|
||||||
import { providerRouter } from './routes/providerRouter.js';
|
import { providerRouter } from './routes/providerRouter.js';
|
||||||
|
import { versionRouter } from './routes/versionRouter.js';
|
||||||
import { loginRouter } from './routes/loginRoute.js';
|
import { loginRouter } from './routes/loginRoute.js';
|
||||||
import { config } from '../utils.js';
|
|
||||||
import { userRouter } from './routes/userRoute.js';
|
import { userRouter } from './routes/userRoute.js';
|
||||||
import { jobRouter } from './routes/jobRouter.js';
|
import { jobRouter } from './routes/jobRouter.js';
|
||||||
|
import { config } from '../utils.js';
|
||||||
import bodyParser from 'body-parser';
|
import bodyParser from 'body-parser';
|
||||||
import restana from 'restana';
|
import restana from 'restana';
|
||||||
import files from 'serve-static';
|
import files from 'serve-static';
|
||||||
@@ -14,6 +15,7 @@ import path from 'path';
|
|||||||
import { getDirName } from '../utils.js';
|
import { getDirName } from '../utils.js';
|
||||||
import { demoRouter } from './routes/demoRouter.js';
|
import { demoRouter } from './routes/demoRouter.js';
|
||||||
import logger from '../services/logger.js';
|
import logger from '../services/logger.js';
|
||||||
|
import { listingsRouter } from './routes/listingsRouter.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;
|
||||||
@@ -23,6 +25,9 @@ service.use(cookieSession());
|
|||||||
service.use(staticService);
|
service.use(staticService);
|
||||||
service.use('/api/admin', authInterceptor());
|
service.use('/api/admin', authInterceptor());
|
||||||
service.use('/api/jobs', authInterceptor());
|
service.use('/api/jobs', authInterceptor());
|
||||||
|
service.use('/api/version', authInterceptor());
|
||||||
|
service.use('/api/listings', authInterceptor());
|
||||||
|
|
||||||
// /admin can only be accessed when user is having admin permissions
|
// /admin can only be accessed when user is having admin permissions
|
||||||
service.use('/api/admin', adminInterceptor());
|
service.use('/api/admin', adminInterceptor());
|
||||||
service.use('/api/jobs/notificationAdapter', notificationAdapterRouter);
|
service.use('/api/jobs/notificationAdapter', notificationAdapterRouter);
|
||||||
@@ -30,8 +35,10 @@ service.use('/api/admin/generalSettings', generalSettingsRouter);
|
|||||||
service.use('/api/jobs/provider', providerRouter);
|
service.use('/api/jobs/provider', providerRouter);
|
||||||
service.use('/api/jobs/insights', analyticsRouter);
|
service.use('/api/jobs/insights', analyticsRouter);
|
||||||
service.use('/api/admin/users', userRouter);
|
service.use('/api/admin/users', userRouter);
|
||||||
|
service.use('/api/version', versionRouter);
|
||||||
service.use('/api/jobs', jobRouter);
|
service.use('/api/jobs', jobRouter);
|
||||||
service.use('/api/login', loginRouter);
|
service.use('/api/login', loginRouter);
|
||||||
|
service.use('/api/listings', listingsRouter);
|
||||||
//this route is unsecured intentionally as it is being queried from the login page
|
//this route is unsecured intentionally as it is being queried from the login page
|
||||||
service.use('/api/demo', demoRouter);
|
service.use('/api/demo', demoRouter);
|
||||||
|
|
||||||
|
|||||||
100
lib/api/routes/listingsRouter.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import restana from 'restana';
|
||||||
|
import * as listingStorage from '../../services/storage/listingsStorage.js';
|
||||||
|
import * as watchListStorage from '../../services/storage/watchListStorage.js';
|
||||||
|
import { isAdmin as isAdminFn } from '../security.js';
|
||||||
|
import logger from '../../services/logger.js';
|
||||||
|
import { nullOrEmpty } from '../../utils.js';
|
||||||
|
import { getJobs } from '../../services/storage/jobStorage.js';
|
||||||
|
|
||||||
|
const service = restana();
|
||||||
|
|
||||||
|
const listingsRouter = service.newRouter();
|
||||||
|
|
||||||
|
listingsRouter.get('/table', async (req, res) => {
|
||||||
|
const {
|
||||||
|
page,
|
||||||
|
pageSize = 50,
|
||||||
|
activityFilter,
|
||||||
|
jobNameFilter,
|
||||||
|
providerFilter,
|
||||||
|
watchListFilter,
|
||||||
|
sortfield = null,
|
||||||
|
sortdir = 'asc',
|
||||||
|
freeTextFilter,
|
||||||
|
} = req.query || {};
|
||||||
|
|
||||||
|
// normalize booleans (accept true, 'true', 1, '1')
|
||||||
|
const toBool = (v) => v === true || v === 'true' || v === 1 || v === '1';
|
||||||
|
const normalizedActivity = toBool(activityFilter) ? true : null;
|
||||||
|
const normalizedWatch = toBool(watchListFilter) ? true : null;
|
||||||
|
|
||||||
|
let jobFilter = null;
|
||||||
|
let jobIdFilter = null;
|
||||||
|
const jobs = getJobs();
|
||||||
|
if (!nullOrEmpty(jobNameFilter)) {
|
||||||
|
const job = jobs.find((j) => j.id === jobNameFilter);
|
||||||
|
jobFilter = job != null ? job.name : null;
|
||||||
|
jobIdFilter = job != null ? job.id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.body = listingStorage.queryListings({
|
||||||
|
page: page ? parseInt(page, 10) : 1,
|
||||||
|
pageSize: pageSize ? parseInt(pageSize, 10) : 50,
|
||||||
|
freeTextFilter: freeTextFilter || null,
|
||||||
|
activityFilter: normalizedActivity,
|
||||||
|
jobNameFilter: jobFilter,
|
||||||
|
jobIdFilter: jobIdFilter,
|
||||||
|
providerFilter,
|
||||||
|
watchListFilter: normalizedWatch,
|
||||||
|
sortField: sortfield || null,
|
||||||
|
sortDir: sortdir === 'desc' ? 'desc' : 'asc',
|
||||||
|
userId: req.session.currentUser,
|
||||||
|
isAdmin: isAdminFn(req),
|
||||||
|
});
|
||||||
|
res.send();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle watch state for the current user on a listing
|
||||||
|
listingsRouter.post('/watch', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { listingId } = req.body || {};
|
||||||
|
const userId = req.session?.currentUser;
|
||||||
|
if (!listingId || !userId) {
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.body = { message: 'listingId or user not provided' };
|
||||||
|
return res.send();
|
||||||
|
}
|
||||||
|
watchListStorage.toggleWatch(listingId, userId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.body = { message: 'Failed to toggle watch' };
|
||||||
|
}
|
||||||
|
res.send();
|
||||||
|
});
|
||||||
|
|
||||||
|
listingsRouter.delete('/job', async (req, res) => {
|
||||||
|
const { jobId } = req.body;
|
||||||
|
try {
|
||||||
|
listingStorage.deleteListingsByJobId(jobId);
|
||||||
|
} catch (error) {
|
||||||
|
res.send(new Error(error));
|
||||||
|
logger.error(error);
|
||||||
|
}
|
||||||
|
res.send();
|
||||||
|
});
|
||||||
|
|
||||||
|
listingsRouter.delete('/', async (req, res) => {
|
||||||
|
const { ids } = req.body;
|
||||||
|
try {
|
||||||
|
if (Array.isArray(ids) && ids.length > 0) {
|
||||||
|
listingStorage.deleteListingsById(ids);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
res.send(new Error(error));
|
||||||
|
logger.error(error);
|
||||||
|
}
|
||||||
|
res.send();
|
||||||
|
});
|
||||||
|
|
||||||
|
export { listingsRouter };
|
||||||
38
lib/api/routes/versionRouter.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import restana from 'restana';
|
||||||
|
import fetch from 'node-fetch';
|
||||||
|
import { getPackageVersion } from '../../utils.js';
|
||||||
|
import semver from 'semver';
|
||||||
|
|
||||||
|
const service = restana();
|
||||||
|
const versionRouter = service.newRouter();
|
||||||
|
|
||||||
|
versionRouter.get('/', async (req, res) => {
|
||||||
|
const versionPayload = await getCurrentVersionFromGithub();
|
||||||
|
const localFredyVersion = await getPackageVersion();
|
||||||
|
res.body =
|
||||||
|
versionPayload == null
|
||||||
|
? {
|
||||||
|
newVersion: false,
|
||||||
|
localFredyVersion,
|
||||||
|
}
|
||||||
|
: versionPayload;
|
||||||
|
res.send();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function getCurrentVersionFromGithub() {
|
||||||
|
const raw = await fetch('https://api.github.com/repos/orangecoding/fredy/releases/latest');
|
||||||
|
const data = await raw.json();
|
||||||
|
const localFredyVersion = await getPackageVersion();
|
||||||
|
if (data.tag_name == null || semver.gte(localFredyVersion, data.tag_name)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
newVersion: true,
|
||||||
|
version: data.tag_name,
|
||||||
|
url: data.html_url,
|
||||||
|
body: data.body,
|
||||||
|
localFredyVersion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { versionRouter };
|
||||||
@@ -37,7 +37,7 @@ const cookieSession$0 = (userId) => {
|
|||||||
name: 'fredy-admin-session',
|
name: 'fredy-admin-session',
|
||||||
keys: ['fredy', 'super', 'fancy', 'key', nanoid()],
|
keys: ['fredy', 'super', 'fancy', 'key', nanoid()],
|
||||||
userId,
|
userId,
|
||||||
maxAge: 8 * 60 * 60 * 1000, // 8 hours
|
maxAge: 2 * 60 * 60 * 1000, // 2 hours
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
export { cookieSession$0 as cookieSession };
|
export { cookieSession$0 as cookieSession };
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
|||||||
const jobName = job == null ? jobKey : job.name;
|
const jobName = job == null ? jobKey : job.name;
|
||||||
const promises = newListings.map((newListing) => {
|
const promises = newListings.map((newListing) => {
|
||||||
const title = `${jobName} at ${serviceName}: ${newListing.title}`;
|
const title = `${jobName} at ${serviceName}: ${newListing.title}`;
|
||||||
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nink: ${newListing.link}`;
|
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}`;
|
||||||
return fetch(server, {
|
return fetch(server, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
130
lib/notification/adapter/discord_webhook.js
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import fetch from 'node-fetch';
|
||||||
|
import { getJob } from '../../services/storage/jobStorage.js';
|
||||||
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
|
import { normalizeImageUrl } from '../../utils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an idempotent decimal color code. The input string-based color code is
|
||||||
|
* generated using the djb2 hash algorithm.
|
||||||
|
*
|
||||||
|
* @param {string} str - Input string as color code base
|
||||||
|
* @returns {number} Generated decimal color code (0 - 16777215)
|
||||||
|
*/
|
||||||
|
const generateColorFromString = (str) => {
|
||||||
|
let hash = 5381; // initial value
|
||||||
|
const input = String(str);
|
||||||
|
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
// hash * 33 + charCode
|
||||||
|
hash = (hash << 5) + hash + input.charCodeAt(i);
|
||||||
|
// Ensure the hash is 32 bit
|
||||||
|
hash |= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let positiveHash = hash >>> 0;
|
||||||
|
const maxColorValue = 16777215;
|
||||||
|
const colorDecimal = positiveHash % maxColorValue;
|
||||||
|
|
||||||
|
return colorDecimal;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an embed per listing
|
||||||
|
* (-> see https://birdie0.github.io/discord-webhooks-guide/structure/embeds.html).
|
||||||
|
*
|
||||||
|
* @param {string} jobKey - Key of job (used to set embed color)
|
||||||
|
* @param {object} listing - Object holding listing details
|
||||||
|
* @returns {object} Discord webhook embed
|
||||||
|
*/
|
||||||
|
const buildEmbed = (jobKey, listing) => {
|
||||||
|
const maxTitleLength = 252; // Max embed title length is 256 characters
|
||||||
|
let title = String(listing.title ?? 'N/A');
|
||||||
|
if (title.length > maxTitleLength) {
|
||||||
|
title = title.substring(0, maxTitleLength) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = [
|
||||||
|
{
|
||||||
|
name: 'Price',
|
||||||
|
value: String(listing.price ?? 'n/a'),
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Size',
|
||||||
|
value: listing?.size?.replace(/2m/g, 'm²') ?? 'n/a',
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Address',
|
||||||
|
value: String(listing.address ?? 'n/a'),
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const embed = {
|
||||||
|
title: title,
|
||||||
|
color: generateColorFromString(jobKey),
|
||||||
|
url: listing.link,
|
||||||
|
fields: fields,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (listing.image) {
|
||||||
|
embed.image = {
|
||||||
|
url: normalizeImageUrl(listing.image),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
|
const adapter = notificationConfig.find((adapter) => adapter.id === config.id);
|
||||||
|
const webhookUrl = adapter?.fields?.webhookUrl;
|
||||||
|
if (!webhookUrl || newListings.length === 0) return Promise.resolve([]);
|
||||||
|
|
||||||
|
const job = getJob(jobKey);
|
||||||
|
const jobName = job?.name || jobKey;
|
||||||
|
|
||||||
|
const embeds = newListings.map((listing) => buildEmbed(jobKey, listing));
|
||||||
|
|
||||||
|
const maxEmbedsPerMessage = 10; // Discord only allows up to 10 embeds
|
||||||
|
const webhookPromises = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < embeds.length; i += maxEmbedsPerMessage) {
|
||||||
|
// Send multiple Discord messages with up to 10 embeds per message
|
||||||
|
const embedChunk = embeds.slice(i, i + maxEmbedsPerMessage);
|
||||||
|
|
||||||
|
const content = i === 0 ? `*${jobName}:* ${serviceName} found **${newListings.length}** new listings.` : '';
|
||||||
|
const body = JSON.stringify({
|
||||||
|
content: content,
|
||||||
|
embeds: embedChunk,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchPromise = fetch(webhookUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error(`Error sending Discord webhook for chunk starting at ${i}:`, error);
|
||||||
|
return Promise.reject(new Error(`Webhook failed: ${error.message}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
webhookPromises.push(fetchPromise);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.allSettled(webhookPromises);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
id: 'discord_webhook',
|
||||||
|
name: 'Discord Webhook',
|
||||||
|
readme: markdown2Html('lib/notification/adapter/discord_webhook.md'),
|
||||||
|
description: 'Fredy will send new listings to the Discord channel of your choice.',
|
||||||
|
fields: {
|
||||||
|
webhookUrl: {
|
||||||
|
type: 'text',
|
||||||
|
label: 'Webhook URL',
|
||||||
|
description: 'The URL of the Discord webhook to send messages to.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
4
lib/notification/adapter/discord_webhook.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
### Discord Adapter
|
||||||
|
|
||||||
|
To use the [Discord](https://discord.com/) Adapter, you need to create a webhook on the Discord channel of your choice. You can follow the instructions of _Making A Webhook_ on [this support website](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks).
|
||||||
|
Once you have created a webhook, copy and paste the webhook URL.
|
||||||
@@ -13,10 +13,10 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
|||||||
return fetch(webhook, {
|
return fetch(webhook, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: {
|
body: JSON.stringify({
|
||||||
channel: channel,
|
channel: channel,
|
||||||
text: message,
|
text: message,
|
||||||
},
|
}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
export const config = {
|
export const config = {
|
||||||
|
|||||||
@@ -15,11 +15,17 @@ Size: ${newListing.size == null ? 'N/A' : newListing.size.replace(/2m/g, '$m^2$'
|
|||||||
Price: ${newListing.price}
|
Price: ${newListing.price}
|
||||||
Link: ${newListing.link}`;
|
Link: ${newListing.link}`;
|
||||||
|
|
||||||
|
const sanitizeHeaderValue = (value) =>
|
||||||
|
String(value ?? '')
|
||||||
|
.replace(/[\r\n]+/g, ' ')
|
||||||
|
.replace(/[^\x20-\x7E]/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
Title: newListing.title,
|
Title: sanitizeHeaderValue(newListing.title),
|
||||||
Priority: String(priority),
|
Priority: sanitizeHeaderValue(priority),
|
||||||
Tags: `${serviceName},${jobName}`,
|
Tags: sanitizeHeaderValue(`${serviceName},${jobName}`),
|
||||||
Click: newListing.link,
|
Click: sanitizeHeaderValue(newListing.link),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (newListing.image && typeof newListing.image === 'string') {
|
if (newListing.image && typeof newListing.image === 'string') {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import utils, { buildHash } from '../utils.js';
|
import { buildHash, isOneOf } from '../utils.js';
|
||||||
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
@@ -29,8 +30,8 @@ function normalizePrice(price) {
|
|||||||
return result[0];
|
return result[0];
|
||||||
}
|
}
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
activeTester: checkIfListingIsActive,
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
config.enabled = sourceConfig.enabled;
|
config.enabled = sourceConfig.enabled;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import utils, { buildHash } from '../utils.js';
|
import { buildHash, isOneOf } from '../utils.js';
|
||||||
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
@@ -24,8 +25,8 @@ function normalize(o) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
activeTester: checkIfListingIsActive,
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
config.enabled = sourceConfig.enabled;
|
config.enabled = sourceConfig.enabled;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import utils, { buildHash } from '../utils.js';
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
@@ -11,8 +12,8 @@ function normalize(o) {
|
|||||||
return Object.assign(o, { id, address, price, size, title, link });
|
return Object.assign(o, { id, address, price, size, title, link });
|
||||||
}
|
}
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
const config = {
|
const config = {
|
||||||
@@ -28,9 +29,11 @@ const config = {
|
|||||||
address: 'div[data-testid="cardmfe-description-box-address"] | trim',
|
address: 'div[data-testid="cardmfe-description-box-address"] | trim',
|
||||||
image: 'div[data-testid="cardmfe-picture-box-test-id"] img@src',
|
image: 'div[data-testid="cardmfe-picture-box-test-id"] img@src',
|
||||||
link: 'button@data-base',
|
link: 'button@data-base',
|
||||||
|
description: 'div[data-testid="cardmfe-description-text-test-id"] | trim',
|
||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
activeTester: checkIfListingIsActive,
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
config.enabled = sourceConfig.enabled;
|
config.enabled = sourceConfig.enabled;
|
||||||
|
|||||||
@@ -35,8 +35,11 @@
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import utils, { buildHash } from '../utils.js';
|
import { buildHash, isOneOf } from '../utils.js';
|
||||||
import { convertWebToMobile } from '../services/immoscout/immoscout-web-translator.js';
|
import {
|
||||||
|
convertImmoscoutListingToMobileListing,
|
||||||
|
convertWebToMobile,
|
||||||
|
} from '../services/immoscout/immoscout-web-translator.js';
|
||||||
import logger from '../services/logger.js';
|
import logger from '../services/logger.js';
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
@@ -77,6 +80,25 @@ async function getListings(url) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function isListingActive(link) {
|
||||||
|
const result = await fetch(convertImmoscoutListingToMobileListing(link), {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'ImmoScout_27.3_26.0_._',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status === 200) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === 404) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('Unknown status for immoscout listing', link);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
function nullOrEmpty(val) {
|
function nullOrEmpty(val) {
|
||||||
return val == null || val.length === 0;
|
return val == null || val.length === 0;
|
||||||
}
|
}
|
||||||
@@ -87,7 +109,7 @@ function normalize(o) {
|
|||||||
return Object.assign(o, { id, title, address });
|
return Object.assign(o, { id, title, address });
|
||||||
}
|
}
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
return !utils.isOneOf(o.title, appliedBlackList);
|
return !isOneOf(o.title, appliedBlackList);
|
||||||
}
|
}
|
||||||
const config = {
|
const config = {
|
||||||
url: null,
|
url: null,
|
||||||
@@ -104,6 +126,7 @@ const config = {
|
|||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
getListings: getListings,
|
getListings: getListings,
|
||||||
|
activeTester: isListingActive,
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
config.enabled = sourceConfig.enabled;
|
config.enabled = sourceConfig.enabled;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import utils, { buildHash } from '../utils.js';
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
@@ -14,8 +15,8 @@ function normalize(o) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
activeTester: checkIfListingIsActive,
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
config.enabled = sourceConfig.enabled;
|
config.enabled = sourceConfig.enabled;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import utils, { buildHash } from '../utils.js';
|
import { buildHash, isOneOf } from '../utils.js';
|
||||||
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
@@ -8,8 +9,8 @@ function normalize(o) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,11 +26,13 @@ const config = {
|
|||||||
size: 'div[data-testid="cardmfe-keyfacts-testid"] | removeNewline | trim',
|
size: 'div[data-testid="cardmfe-keyfacts-testid"] | removeNewline | trim',
|
||||||
title: 'div[data-testid="cardmfe-description-box-text-test-id"] > div:nth-of-type(2)',
|
title: 'div[data-testid="cardmfe-description-box-text-test-id"] > div:nth-of-type(2)',
|
||||||
link: 'a@href',
|
link: 'a@href',
|
||||||
|
description: 'div[data-testid="cardmfe-description-text-test-id"] > div:nth-of-type(2) | removeNewline | trim',
|
||||||
address: 'div[data-testid="cardmfe-description-box-address"] | removeNewline | trim',
|
address: 'div[data-testid="cardmfe-description-box-address"] | removeNewline | trim',
|
||||||
image: 'div[data-testid="cardMfe-card-pictureBox-opacity"] img@src',
|
image: 'div[data-testid="cardmfe-picture-box-opacity-layer-test-id"] img@src',
|
||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
activeTester: checkIfListingIsActive,
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
config.enabled = sourceConfig.enabled;
|
config.enabled = sourceConfig.enabled;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import utils, { buildHash } from '../utils.js';
|
import { buildHash, isOneOf } from '../utils.js';
|
||||||
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
let appliedBlacklistedDistricts = [];
|
let appliedBlacklistedDistricts = [];
|
||||||
@@ -11,10 +12,10 @@ function normalize(o) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
const isBlacklistedDistrict =
|
const isBlacklistedDistrict =
|
||||||
appliedBlacklistedDistricts.length === 0 ? false : utils.isOneOf(o.description, appliedBlacklistedDistricts);
|
appliedBlacklistedDistricts.length === 0 ? false : isOneOf(o.description, appliedBlacklistedDistricts);
|
||||||
return o.title != null && !isBlacklistedDistrict && titleNotBlacklisted && descNotBlacklisted;
|
return o.title != null && !isBlacklistedDistrict && titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
activeTester: checkIfListingIsActive,
|
||||||
};
|
};
|
||||||
export const metaInformation = {
|
export const metaInformation = {
|
||||||
name: 'Ebay Kleinanzeigen',
|
name: 'Ebay Kleinanzeigen',
|
||||||
|
|||||||
47
lib/provider/mcMakler.js
Executable file
@@ -0,0 +1,47 @@
|
|||||||
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
function normalize(o) {
|
||||||
|
const originalId = o.id.split('/').pop();
|
||||||
|
const id = buildHash(originalId, o.price);
|
||||||
|
const size = o.size ?? 'N/A m²';
|
||||||
|
const title = o.title || 'No title available';
|
||||||
|
const address = o.address?.replace(' / ', ' ') || null;
|
||||||
|
const link = o.link != null ? `https://www.mcmakler.de${o.link}` : config.url;
|
||||||
|
return Object.assign(o, { id, size, title, link, address });
|
||||||
|
}
|
||||||
|
function applyBlacklist(o) {
|
||||||
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
|
}
|
||||||
|
const config = {
|
||||||
|
url: null,
|
||||||
|
crawlContainer: 'article[data-testid="propertyCard"]',
|
||||||
|
sortByDateParam: 'sortBy=DATE&sortOn=DESC',
|
||||||
|
waitForSelector: 'ul[data-testid="listsContainer"]',
|
||||||
|
crawlFields: {
|
||||||
|
id: 'h2 a@href',
|
||||||
|
title: 'h2 a | removeNewline | trim',
|
||||||
|
price: 'footer > p:first-of-type | trim',
|
||||||
|
size: 'footer > p:nth-of-type(2) | trim',
|
||||||
|
address: 'div > h2 + p | removeNewline | trim',
|
||||||
|
image: 'img@src',
|
||||||
|
link: 'h2 a@href',
|
||||||
|
},
|
||||||
|
normalize: normalize,
|
||||||
|
filter: applyBlacklist,
|
||||||
|
activeTester: checkIfListingIsActive,
|
||||||
|
};
|
||||||
|
export const init = (sourceConfig, blacklist) => {
|
||||||
|
config.enabled = sourceConfig.enabled;
|
||||||
|
config.url = sourceConfig.url;
|
||||||
|
appliedBlackList = blacklist || [];
|
||||||
|
};
|
||||||
|
export const metaInformation = {
|
||||||
|
name: 'McMakler',
|
||||||
|
baseUrl: 'https://www.mcmakler.de/immobilien/',
|
||||||
|
id: 'mcMakler',
|
||||||
|
};
|
||||||
|
export { config };
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import utils, { buildHash } from '../utils.js';
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
@@ -15,14 +16,14 @@ function normalize(o) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
return !utils.isOneOf(o.title, appliedBlackList);
|
return !isOneOf(o.title, appliedBlackList);
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '.col-12.mb-4',
|
crawlContainer: '.col-12.mb-4',
|
||||||
sortByDateParam: 'Sortierung=Id&Richtung=DESC',
|
sortByDateParam: 'Sortierung=Id&Richtung=DESC',
|
||||||
waitForSelector: '.nbk-section',
|
waitForSelector: 'div[data-live-name-value="SearchList"]',
|
||||||
crawlFields: {
|
crawlFields: {
|
||||||
id: 'a@href',
|
id: 'a@href',
|
||||||
title: 'a@title | removeNewline | trim',
|
title: 'a@title | removeNewline | trim',
|
||||||
@@ -33,6 +34,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
activeTester: checkIfListingIsActive,
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
config.enabled = sourceConfig.enabled;
|
config.enabled = sourceConfig.enabled;
|
||||||
|
|||||||
49
lib/provider/regionalimmobilien24.js
Executable file
@@ -0,0 +1,49 @@
|
|||||||
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
function normalize(o) {
|
||||||
|
const id = buildHash(o.id, o.price);
|
||||||
|
const address = o.address?.replace(/^adresse /i, '') ?? null;
|
||||||
|
const title = o.title || 'No title available';
|
||||||
|
const link = o.link != null ? decodeURIComponent(o.link) : config.url;
|
||||||
|
|
||||||
|
var urlReg = new RegExp(/url\((.*?)\)/gim);
|
||||||
|
const image = o.image != null ? urlReg.exec(o.image)[1] : null;
|
||||||
|
return Object.assign(o, { id, address, title, link, image });
|
||||||
|
}
|
||||||
|
function applyBlacklist(o) {
|
||||||
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
|
}
|
||||||
|
const config = {
|
||||||
|
url: null,
|
||||||
|
crawlContainer: '.listentry-content',
|
||||||
|
sortByDateParam: null, // sort by date is standard
|
||||||
|
waitForSelector: 'body',
|
||||||
|
crawlFields: {
|
||||||
|
id: '.listentry-iconbar-share@data-sid | trim',
|
||||||
|
title: 'h2 | trim',
|
||||||
|
price: '.listentry-details-price .listentry-details-v | trim',
|
||||||
|
size: '.listentry-details-size .listentry-details-v | trim',
|
||||||
|
address: '.listentry-adress | trim',
|
||||||
|
image: '.listentry-img@style',
|
||||||
|
link: '.shariff@data-url',
|
||||||
|
description: '.listentry-extras | trim',
|
||||||
|
},
|
||||||
|
normalize: normalize,
|
||||||
|
filter: applyBlacklist,
|
||||||
|
activeTester: checkIfListingIsActive,
|
||||||
|
};
|
||||||
|
export const init = (sourceConfig, blacklist) => {
|
||||||
|
config.enabled = sourceConfig.enabled;
|
||||||
|
config.url = sourceConfig.url;
|
||||||
|
appliedBlackList = blacklist || [];
|
||||||
|
};
|
||||||
|
export const metaInformation = {
|
||||||
|
name: 'Regionalimmobilien24',
|
||||||
|
baseUrl: 'https://www.regionalimmobilien24.de/',
|
||||||
|
id: 'regionalimmobilien24',
|
||||||
|
};
|
||||||
|
export { config };
|
||||||
46
lib/provider/sparkasse.js
Executable file
@@ -0,0 +1,46 @@
|
|||||||
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
function normalize(o) {
|
||||||
|
const originalId = o.id.split('/').pop().replace('.html', '');
|
||||||
|
const id = buildHash(originalId, o.price);
|
||||||
|
const size = o.size?.replace(' Wohnfläche', '') ?? null;
|
||||||
|
const title = o.title || 'No title available';
|
||||||
|
const link = o.link != null ? `https://immobilien.sparkasse.de${o.link}` : config.url;
|
||||||
|
return Object.assign(o, { id, size, title, link });
|
||||||
|
}
|
||||||
|
function applyBlacklist(o) {
|
||||||
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
|
}
|
||||||
|
const config = {
|
||||||
|
url: null,
|
||||||
|
crawlContainer: '.estate-list-item-row',
|
||||||
|
sortByDateParam: 'sortBy=date_desc',
|
||||||
|
waitForSelector: 'body',
|
||||||
|
crawlFields: {
|
||||||
|
id: 'div[data-testid="estate-link"] a@href',
|
||||||
|
title: 'h3 | trim',
|
||||||
|
price: '.estate-list-price | trim',
|
||||||
|
size: '.estate-mainfact:first-child span | trim',
|
||||||
|
address: 'h6 | trim',
|
||||||
|
image: '.estate-list-item-image-container img@src',
|
||||||
|
link: 'div[data-testid="estate-link"] a@href',
|
||||||
|
},
|
||||||
|
normalize: normalize,
|
||||||
|
filter: applyBlacklist,
|
||||||
|
activeTester: checkIfListingIsActive,
|
||||||
|
};
|
||||||
|
export const init = (sourceConfig, blacklist) => {
|
||||||
|
config.enabled = sourceConfig.enabled;
|
||||||
|
config.url = sourceConfig.url;
|
||||||
|
appliedBlackList = blacklist || [];
|
||||||
|
};
|
||||||
|
export const metaInformation = {
|
||||||
|
name: 'Sparkasse Immobilien',
|
||||||
|
baseUrl: 'https://immobilien.sparkasse.de/',
|
||||||
|
id: 'sparkasse',
|
||||||
|
};
|
||||||
|
export { config };
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import utils, { buildHash } from '../utils.js';
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
@@ -10,8 +11,8 @@ function normalize(o) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
return o.id != null && titleNotBlacklisted && descNotBlacklisted;
|
return o.id != null && titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
activeTester: checkIfListingIsActive,
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
config.enabled = sourceConfig.enabled;
|
config.enabled = sourceConfig.enabled;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { removeJobsByUserId } from './storage/jobStorage.js';
|
import { removeJobsByUserId } from '../storage/jobStorage.js';
|
||||||
import { config } from '../utils.js';
|
import { config } from '../../utils.js';
|
||||||
import { getUsers } from './storage/userStorage.js';
|
import { getUsers } from '../storage/userStorage.js';
|
||||||
import logger from './logger.js';
|
import logger from '../logger.js';
|
||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
13
lib/services/crons/listing-alive-cron.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import cron from 'node-cron';
|
||||||
|
import runActiveChecker from '../listings/listingActiveService.js';
|
||||||
|
|
||||||
|
async function runTask() {
|
||||||
|
await runActiveChecker();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initActiveCheckerCron() {
|
||||||
|
//run directly on start
|
||||||
|
await runTask();
|
||||||
|
// then every day at 1 am
|
||||||
|
cron.schedule('0 1 * * *', runTask);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import { config, inDevMode } from '../../utils.js';
|
import { config, inDevMode } from '../../utils.js';
|
||||||
import { trackMainEvent } from './Tracker.js';
|
import { trackMainEvent } from '../tracking/Tracker.js';
|
||||||
|
|
||||||
async function runTask() {
|
async function runTask() {
|
||||||
//make sure to only send tracking events if the user gave us the green light and we are not in dev mode
|
//make sure to only send tracking events if the user gave us the green light and we are not in dev mode
|
||||||
@@ -9,12 +9,12 @@ export function loadParser(text) {
|
|||||||
|
|
||||||
export function parse(crawlContainer, crawlFields, text, url) {
|
export function parse(crawlContainer, crawlFields, text, url) {
|
||||||
if (!text) {
|
if (!text) {
|
||||||
logger.warn('No content found for ', url);
|
logger.debug('No content found for ', url);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!crawlContainer || !crawlFields) {
|
if (!crawlContainer || !crawlFields) {
|
||||||
logger.warn('Cannot parse, selector was empty for url ', url);
|
logger.debug('Cannot parse, selector was empty for url ', url);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,30 +2,56 @@ import puppeteer from 'puppeteer-extra';
|
|||||||
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
||||||
import { debug, DEFAULT_HEADER, botDetected } from './utils.js';
|
import { debug, DEFAULT_HEADER, botDetected } from './utils.js';
|
||||||
import logger from '../logger.js';
|
import logger from '../logger.js';
|
||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
puppeteer.use(StealthPlugin());
|
puppeteer.use(StealthPlugin());
|
||||||
|
|
||||||
export default async function execute(url, waitForSelector, options) {
|
export default async function execute(url, waitForSelector, options) {
|
||||||
let browser;
|
let browser;
|
||||||
|
let page;
|
||||||
|
let result = null;
|
||||||
|
let userDataDir;
|
||||||
|
let removeUserDataDir = false;
|
||||||
try {
|
try {
|
||||||
debug(`Sending request to ${url} using Puppeteer.`);
|
debug(`Sending request to ${url} using Puppeteer.`);
|
||||||
|
|
||||||
|
// Prepare a dedicated temporary userDataDir to avoid leaking /tmp/.org.chromium.* dirs
|
||||||
|
if (options && options.userDataDir) {
|
||||||
|
userDataDir = options.userDataDir;
|
||||||
|
removeUserDataDir = !!options.cleanupUserDataDir;
|
||||||
|
} else {
|
||||||
|
const prefix = path.join(os.tmpdir(), 'puppeteer-fredy-');
|
||||||
|
userDataDir = fs.mkdtempSync(prefix);
|
||||||
|
removeUserDataDir = true;
|
||||||
|
}
|
||||||
|
|
||||||
browser = await puppeteer.launch({
|
browser = await puppeteer.launch({
|
||||||
headless: options.puppeteerHeadless ?? true,
|
headless: options.puppeteerHeadless ?? true,
|
||||||
args: ['--no-sandbox', '--disable-gpu', '--disable-setuid-sandbox'],
|
args: [
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-gpu',
|
||||||
|
'--disable-setuid-sandbox',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
'--disable-crash-reporter',
|
||||||
|
],
|
||||||
timeout: options.puppeteerTimeout || 30_000,
|
timeout: options.puppeteerTimeout || 30_000,
|
||||||
|
userDataDir,
|
||||||
});
|
});
|
||||||
let page = await browser.newPage();
|
page = await browser.newPage();
|
||||||
await page.setExtraHTTPHeaders(DEFAULT_HEADER);
|
await page.setExtraHTTPHeaders(DEFAULT_HEADER);
|
||||||
const response = await page.goto(url, {
|
const response = await page.goto(url, {
|
||||||
waitUntil: 'domcontentloaded',
|
waitUntil: 'domcontentloaded',
|
||||||
});
|
});
|
||||||
let pageSource;
|
let pageSource;
|
||||||
//if we're extracting data from a spa, we must wait for the selector
|
// if we're extracting data from a SPA, we must wait for the selector
|
||||||
if (waitForSelector != null) {
|
if (waitForSelector != null) {
|
||||||
await page.waitForSelector(waitForSelector);
|
const selectorTimeout = options?.puppeteerSelectorTimeout ?? options?.puppeteerTimeout ?? 30_000;
|
||||||
|
await page.waitForSelector(waitForSelector, { timeout: selectorTimeout });
|
||||||
pageSource = await page.evaluate((selector) => {
|
pageSource = await page.evaluate((selector) => {
|
||||||
return document.querySelector(selector).innerHTML;
|
const el = document.querySelector(selector);
|
||||||
|
return el ? el.innerHTML : '';
|
||||||
}, waitForSelector);
|
}, waitForSelector);
|
||||||
} else {
|
} else {
|
||||||
pageSource = await page.content();
|
pageSource = await page.content();
|
||||||
@@ -35,16 +61,35 @@ export default async function execute(url, waitForSelector, options) {
|
|||||||
|
|
||||||
if (botDetected(pageSource, statusCode)) {
|
if (botDetected(pageSource, statusCode)) {
|
||||||
logger.warn('We have been detected as a bot :-/ Tried url: => ', url);
|
logger.warn('We have been detected as a bot :-/ Tried url: => ', url);
|
||||||
return null;
|
result = null;
|
||||||
|
} else {
|
||||||
|
result = pageSource || (await page.content());
|
||||||
}
|
}
|
||||||
|
|
||||||
return await page.content();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error executing with puppeteer executor', error);
|
logger.error('Error executing with puppeteer executor', error);
|
||||||
return null;
|
result = null;
|
||||||
} finally {
|
} finally {
|
||||||
if (browser != null) {
|
try {
|
||||||
await browser.close();
|
if (page) {
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (browser != null) {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (removeUserDataDir && userDataDir) {
|
||||||
|
await fs.promises.rm(userDataDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ https://api.mobile.immobilienscout24.de/search/map/v3?publishedafter=2025-05-14T
|
|||||||
https://api.mobile.immobilienscout24.de/search/map/v3?features=disableNHBGrouping,nextGen,fairPrice,listingsInListFirstSummary,xxlListingType,contactDetails&publishedafter=2025-05-14T09:19:43&sorting=standard&pagesize=300&searchType=shape&realEstateType=housebuy&pagenumber=1&shape=%7D%7BjwHy%7Cqh@jCKdCgAvB_BdB%7DBzAaCjAqCfAqC~@uCt@iCh@eCZkCLyC?_EO%7DEa@%7DEa@iE_@%7BD%5DaDe@gDi@gDo@uCu@kBcB_AeDOiE?iDCgCMuBOkDCkG?yFRgD%60@cB%5C%7BA%60@eBx@aB%7C@kAbAy@rAe@bBUxCAhE?dFh@fGlAzGbBbHlBxGdB%60FrAhDz@xBh@nAf@l@RNNXkCkMJR~B%7CEnCpErCnDtClCvC~ApCh@rCJpC?
|
https://api.mobile.immobilienscout24.de/search/map/v3?features=disableNHBGrouping,nextGen,fairPrice,listingsInListFirstSummary,xxlListingType,contactDetails&publishedafter=2025-05-14T09:19:43&sorting=standard&pagesize=300&searchType=shape&realEstateType=housebuy&pagenumber=1&shape=%7D%7BjwHy%7Cqh@jCKdCgAvB_BdB%7DBzAaCjAqCfAqC~@uCt@iCh@eCZkCLyC?_EO%7DEa@%7DEa@iE_@%7BD%5DaDe@gDi@gDo@uCu@kBcB_AeDOiE?iDCgCMuBOkDCkG?yFRgD%60@cB%5C%7BA%60@eBx@aB%7C@kAbAy@rAe@bBUxCAhE?dFh@fGlAzGbBbHlBxGdB%60FrAhDz@xBh@nAf@l@RNNXkCkMJR~B%7CEnCpErCnDtClCvC~ApCh@rCJpC?
|
||||||
*/
|
*/
|
||||||
import queryString from 'query-string';
|
import queryString from 'query-string';
|
||||||
|
import { nullOrEmpty } from '../../utils.js';
|
||||||
|
|
||||||
const PARAM_NAME_MAP = {
|
const PARAM_NAME_MAP = {
|
||||||
heatingtypes: 'heatingtypes',
|
heatingtypes: 'heatingtypes',
|
||||||
@@ -193,3 +194,14 @@ export function convertWebToMobile(webUrl) {
|
|||||||
|
|
||||||
return `https://api.mobile.immobilienscout24.de/search/list?${mobileQuery}`;
|
return `https://api.mobile.immobilienscout24.de/search/list?${mobileQuery}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function convertImmoscoutListingToMobileListing(url) {
|
||||||
|
if (nullOrEmpty(url)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.replace(
|
||||||
|
/^https:\/\/www\.immobilienscout24\.de\/expose\//,
|
||||||
|
'https://api.mobile.immobilienscout24.de/expose/',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
104
lib/services/listings/listingActiveService.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { deactivateListings, getActiveOrUnknownListings } from '../storage/listingsStorage.js';
|
||||||
|
import { getProviders } from '../../utils.js';
|
||||||
|
import logger from '../../services/logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the active-listing checker:
|
||||||
|
* 1) Loads all listings with unknown or active status.
|
||||||
|
* 2) Resolves each listing's provider and calls its `activeTester(link)`.
|
||||||
|
* 3) Collects listings that are no longer active and deactivates them in one batch.
|
||||||
|
*
|
||||||
|
* Concurrency: network-bound checks are executed with a configurable concurrency limit.
|
||||||
|
*
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {number} [opts.concurrency=8] Max number of parallel activeTester calls.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export default async function runActiveChecker(opts = {}) {
|
||||||
|
const { concurrency = 4 } = opts;
|
||||||
|
|
||||||
|
const listings = getActiveOrUnknownListings();
|
||||||
|
if (!Array.isArray(listings) || listings.length === 0) {
|
||||||
|
logger.debug('No listings to check.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers = await getProviders();
|
||||||
|
if (!Array.isArray(providers) || providers.length === 0) {
|
||||||
|
logger.warn('No providers available. Skipping active checks.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a map for O(1) provider lookup by id
|
||||||
|
/** @type {Record<string, any>} */
|
||||||
|
const providerById = Object.create(null);
|
||||||
|
for (const p of providers) {
|
||||||
|
const id = p?.metaInformation?.id;
|
||||||
|
if (id) providerById[id] = p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small generic mapLimit to cap concurrency without extra deps
|
||||||
|
/**
|
||||||
|
* @template T, R
|
||||||
|
* @param {T[]} items
|
||||||
|
* @param {number} limit
|
||||||
|
* @param {(item: T, index: number) => Promise<R>} worker
|
||||||
|
* @returns {Promise<R[]>}
|
||||||
|
*/
|
||||||
|
async function mapLimit(items, limit, worker) {
|
||||||
|
const results = new Array(items.length);
|
||||||
|
let next = 0;
|
||||||
|
|
||||||
|
async function runOne() {
|
||||||
|
while (next < items.length) {
|
||||||
|
const i = next++;
|
||||||
|
try {
|
||||||
|
results[i] = await worker(items[i], i);
|
||||||
|
} catch (err) {
|
||||||
|
results[i] = /** @type {any} */ (err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runners = Array.from({ length: Math.min(limit, items.length) }, runOne);
|
||||||
|
await Promise.all(runners);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {string[]} */
|
||||||
|
const listingsSetToInactive = [];
|
||||||
|
|
||||||
|
await mapLimit(listings, concurrency, async (listing) => {
|
||||||
|
const { provider: listingProviderId, link, id } = listing || {};
|
||||||
|
|
||||||
|
const matchedProvider = providerById[listingProviderId];
|
||||||
|
if (!matchedProvider) {
|
||||||
|
logger.warn('Could not find matching provider for', listingProviderId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tester = matchedProvider?.config?.activeTester;
|
||||||
|
if (typeof tester !== 'function') {
|
||||||
|
logger.warn('No activeTester configured for', listingProviderId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contract: activeTester(link) returns 1 if active, 0 if inactive
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await tester(link);
|
||||||
|
} catch {
|
||||||
|
result = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result === 0 && id) {
|
||||||
|
listingsSetToInactive.push(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (listingsSetToInactive.length > 0) {
|
||||||
|
logger.info(`Setting ${listingsSetToInactive.length} listings to inactive.`);
|
||||||
|
deactivateListings(listingsSetToInactive);
|
||||||
|
} else {
|
||||||
|
logger.debug('No listings need to be set inactive.');
|
||||||
|
}
|
||||||
|
}
|
||||||
68
lib/services/listings/listingActiveTester.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import fetch from 'node-fetch';
|
||||||
|
import { randomBetween, sleep } from '../../utils.js';
|
||||||
|
|
||||||
|
const maxAttempts = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a listing is still active with up to 3 attempts and exponential backoff.
|
||||||
|
* Backoff waits are capped and the last wait is at most 2000 ms.
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* - HTTP 200 => return 1
|
||||||
|
* - HTTP 401/403 => return -1 (most certainly detected as a bot)
|
||||||
|
* - HTTP 404 => return 0
|
||||||
|
* - Other statuses or network errors => retry until attempts are exhausted
|
||||||
|
*
|
||||||
|
* @returns {Promise<Integer>} 1 if active, o if not active and -1 if detected as bot
|
||||||
|
*/
|
||||||
|
export default async function checkIfListingIsActive(link) {
|
||||||
|
await sleep(randomBetween(50, 100));
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(link, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36',
|
||||||
|
'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 200) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (res.status === 401) return -1;
|
||||||
|
if (res.status === 403) return -1;
|
||||||
|
if (res.status === 404) return 0;
|
||||||
|
|
||||||
|
// For any other status, only retry if attempts remain
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
await sleep(backoffDelay(attempt));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
} catch {
|
||||||
|
// Network error: retry if attempts remain
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
await sleep(backoffDelay(attempt));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exponential backoff delay with cap.
|
||||||
|
* attempt: 1 -> 500ms, 2 -> 1000ms, 3 -> 2000ms (cap)
|
||||||
|
* @param {number} attempt 1-based attempt index
|
||||||
|
* @returns {number} delay in ms
|
||||||
|
*/
|
||||||
|
function backoffDelay(attempt) {
|
||||||
|
const base = 500;
|
||||||
|
const cap = 2000;
|
||||||
|
return Math.min(base * 2 ** (attempt - 1), cap);
|
||||||
|
}
|
||||||
@@ -48,11 +48,44 @@ export const getKnownListingHashesForJobAndProvider = (jobId, providerId) => {
|
|||||||
return SqliteConnection.query(
|
return SqliteConnection.query(
|
||||||
`SELECT hash
|
`SELECT hash
|
||||||
FROM listings
|
FROM listings
|
||||||
WHERE job_id = @jobId AND provider = @providerId`,
|
WHERE job_id = @jobId
|
||||||
|
AND provider = @providerId`,
|
||||||
{ jobId, providerId },
|
{ jobId, providerId },
|
||||||
).map((r) => r.hash);
|
).map((r) => r.hash);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a list of listing that either are active or have an unknown status
|
||||||
|
* to constantly check if they are still online
|
||||||
|
*
|
||||||
|
* @returns {string[]} Array of listings
|
||||||
|
*/
|
||||||
|
export const getActiveOrUnknownListings = () => {
|
||||||
|
return SqliteConnection.query(
|
||||||
|
`SELECT *
|
||||||
|
FROM listings
|
||||||
|
WHERE is_active is null
|
||||||
|
OR is_active = 1
|
||||||
|
ORDER BY provider`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deactivates listings by setting is_active = 0 for all matching IDs.
|
||||||
|
*
|
||||||
|
* @param {string[]} ids - Array of listing IDs to deactivate.
|
||||||
|
* @returns {object[]} Result of the SQLite query execution.
|
||||||
|
*/
|
||||||
|
export const deactivateListings = (ids) => {
|
||||||
|
const placeholders = ids.map(() => '?').join(',');
|
||||||
|
return SqliteConnection.execute(
|
||||||
|
`UPDATE listings
|
||||||
|
SET is_active = 0
|
||||||
|
WHERE id IN (${placeholders})`,
|
||||||
|
ids,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist a batch of scraped listings for a given job and provider.
|
* Persist a batch of scraped listings for a given job and provider.
|
||||||
*
|
*
|
||||||
@@ -86,9 +119,9 @@ export const storeListings = (jobId, providerId, listings) => {
|
|||||||
SqliteConnection.withTransaction((db) => {
|
SqliteConnection.withTransaction((db) => {
|
||||||
const stmt = db.prepare(
|
const stmt = db.prepare(
|
||||||
`INSERT INTO listings (id, hash, provider, job_id, price, size, title, image_url, description, address,
|
`INSERT INTO listings (id, hash, provider, job_id, price, size, title, image_url, description, address,
|
||||||
link, created_at)
|
link, created_at, is_active)
|
||||||
VALUES (@id, @hash, @provider, @job_id, @price, @size, @title, @image_url, @description, @address, @link,
|
VALUES (@id, @hash, @provider, @job_id, @price, @size, @title, @image_url, @description, @address, @link,
|
||||||
@created_at)
|
@created_at, 1)
|
||||||
ON CONFLICT(job_id, hash) DO NOTHING`,
|
ON CONFLICT(job_id, hash) DO NOTHING`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -136,3 +169,163 @@ export const storeListings = (jobId, providerId, listings) => {
|
|||||||
return str.replace(/\s*\([^)]*\)/g, '');
|
return str.replace(/\s*\([^)]*\)/g, '');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query listings with pagination, filtering and sorting.
|
||||||
|
*
|
||||||
|
* @param {Object} params
|
||||||
|
* @param {number} [params.pageSize=50]
|
||||||
|
* @param {number} [params.page=1]
|
||||||
|
* @param {string} [params.freeTextFilter]
|
||||||
|
* @param {object} [params.activityFilter]
|
||||||
|
* @param {object} [params.jobNameFilter]
|
||||||
|
* @param {object} [params.providerFilter]
|
||||||
|
* @param {object} [params.watchListFilter]
|
||||||
|
* @param {string|null} [params.sortField=null] - One of: 'created_at','price','size','provider','title'.
|
||||||
|
* @param {('asc'|'desc')} [params.sortDir='asc']
|
||||||
|
* @param {string} [params.userId] - Current user id used to scope listings (ignored for admins).
|
||||||
|
* @param {boolean} [params.isAdmin=false] - When true, returns all listings.
|
||||||
|
* @returns {{ totalNumber:number, page:number, result:Object[] }}
|
||||||
|
*/
|
||||||
|
export const queryListings = ({
|
||||||
|
pageSize = 50,
|
||||||
|
page = 1,
|
||||||
|
activityFilter,
|
||||||
|
jobNameFilter,
|
||||||
|
jobIdFilter,
|
||||||
|
providerFilter,
|
||||||
|
watchListFilter,
|
||||||
|
freeTextFilter,
|
||||||
|
sortField = null,
|
||||||
|
sortDir = 'asc',
|
||||||
|
userId = null,
|
||||||
|
isAdmin = false,
|
||||||
|
} = {}) => {
|
||||||
|
// sanitize inputs
|
||||||
|
const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(500, Math.floor(pageSize)) : 50;
|
||||||
|
const safePage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1;
|
||||||
|
const offset = (safePage - 1) * safePageSize;
|
||||||
|
|
||||||
|
// build WHERE filter across common text columns
|
||||||
|
const whereParts = [];
|
||||||
|
const params = { limit: safePageSize, offset };
|
||||||
|
// always provide userId param for watched-flag evaluation (null -> no matches)
|
||||||
|
params.userId = userId || '__NO_USER__';
|
||||||
|
// user scoping (non-admin only): restrict to listings whose job belongs to user
|
||||||
|
if (!isAdmin) {
|
||||||
|
whereParts.push(`(j.user_id = @userId)`);
|
||||||
|
}
|
||||||
|
if (freeTextFilter && String(freeTextFilter).trim().length > 0) {
|
||||||
|
params.filter = `%${String(freeTextFilter).trim()}%`;
|
||||||
|
whereParts.push(`(title LIKE @filter OR address LIKE @filter OR provider LIKE @filter OR link LIKE @filter)`);
|
||||||
|
}
|
||||||
|
// activityFilter: when true -> only active listings (is_active = 1)
|
||||||
|
if (activityFilter === true) {
|
||||||
|
whereParts.push('(is_active = 1)');
|
||||||
|
}
|
||||||
|
// Prefer filtering by job id when provided (unambiguous and robust)
|
||||||
|
if (jobIdFilter && String(jobIdFilter).trim().length > 0) {
|
||||||
|
params.jobId = String(jobIdFilter).trim();
|
||||||
|
whereParts.push('(l.job_id = @jobId)');
|
||||||
|
} else if (jobNameFilter && String(jobNameFilter).trim().length > 0) {
|
||||||
|
// Fallback to exact job name match
|
||||||
|
params.jobName = String(jobNameFilter).trim();
|
||||||
|
whereParts.push('(j.name = @jobName)');
|
||||||
|
}
|
||||||
|
// providerFilter: when provided as string (assumed provider name), filter listings where provider equals that name (exact match)
|
||||||
|
if (providerFilter && String(providerFilter).trim().length > 0) {
|
||||||
|
params.providerName = String(providerFilter).trim();
|
||||||
|
whereParts.push('(provider = @providerName)');
|
||||||
|
}
|
||||||
|
// watchListFilter: when true -> only watched listings
|
||||||
|
if (watchListFilter === true) {
|
||||||
|
whereParts.push('(wl.id IS NOT NULL)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||||||
|
const whereSqlWithAlias = whereSql
|
||||||
|
.replace(/\btitle\b/g, 'l.title')
|
||||||
|
.replace(/\bdescription\b/g, 'l.description')
|
||||||
|
.replace(/\baddress\b/g, 'l.address')
|
||||||
|
.replace(/\bprovider\b/g, 'l.provider')
|
||||||
|
.replace(/\blink\b/g, 'l.link')
|
||||||
|
.replace(/\bis_active\b/g, 'l.is_active')
|
||||||
|
.replace(/\bj\.user_id\b/g, 'j.user_id')
|
||||||
|
.replace(/\bj\.name\b/g, 'j.name')
|
||||||
|
.replace(/\bwl\.id\b/g, 'wl.id');
|
||||||
|
|
||||||
|
// whitelist sortable fields to avoid SQL injection
|
||||||
|
const sortable = new Set(['created_at', 'price', 'size', 'provider', 'title', 'job_name', 'is_active', 'isWatched']);
|
||||||
|
const safeSortField = sortField && sortable.has(sortField) ? sortField : null;
|
||||||
|
const safeSortDir = String(sortDir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
||||||
|
const orderSql = safeSortField ? `ORDER BY ${safeSortField} ${safeSortDir}` : 'ORDER BY created_at DESC';
|
||||||
|
const orderSqlWithAlias = orderSql
|
||||||
|
.replace(/\bcreated_at\b/g, 'l.created_at')
|
||||||
|
.replace(/\bprice\b/g, 'l.price')
|
||||||
|
.replace(/\bsize\b/g, 'l.size')
|
||||||
|
.replace(/\bprovider\b/g, 'l.provider')
|
||||||
|
.replace(/\btitle\b/g, 'l.title')
|
||||||
|
.replace(/\bjob_name\b/g, 'j.name')
|
||||||
|
// Sort by computed watch flag when requested
|
||||||
|
.replace(/\bisWatched\b/g, 'CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END');
|
||||||
|
|
||||||
|
// count total with same WHERE
|
||||||
|
const countRow = SqliteConnection.query(
|
||||||
|
`SELECT COUNT(1) as cnt
|
||||||
|
FROM listings l
|
||||||
|
LEFT JOIN jobs j ON j.id = l.job_id
|
||||||
|
LEFT JOIN watch_list wl ON wl.listing_id = l.id AND wl.user_id = @userId
|
||||||
|
${whereSqlWithAlias}`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
const totalNumber = countRow?.[0]?.cnt ?? 0;
|
||||||
|
|
||||||
|
// fetch page
|
||||||
|
const rows = SqliteConnection.query(
|
||||||
|
`SELECT l.*,
|
||||||
|
j.name AS job_name,
|
||||||
|
CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END AS isWatched
|
||||||
|
FROM listings l
|
||||||
|
LEFT JOIN jobs j ON j.id = l.job_id
|
||||||
|
LEFT JOIN watch_list wl ON wl.listing_id = l.id AND wl.user_id = @userId
|
||||||
|
${whereSqlWithAlias}
|
||||||
|
${orderSqlWithAlias}
|
||||||
|
LIMIT @limit OFFSET @offset`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { totalNumber, page: safePage, result: rows };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all listings for a given job id.
|
||||||
|
*
|
||||||
|
* @param {string} jobId - The job identifier whose listings should be removed.
|
||||||
|
* @returns {any} The result from SqliteConnection.execute (may contain changes count).
|
||||||
|
*/
|
||||||
|
export const deleteListingsByJobId = (jobId) => {
|
||||||
|
if (!jobId) return;
|
||||||
|
return SqliteConnection.execute(
|
||||||
|
`DELETE
|
||||||
|
FROM listings
|
||||||
|
WHERE job_id = @jobId`,
|
||||||
|
{ jobId },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete listings by a list of listing IDs.
|
||||||
|
*
|
||||||
|
* @param {string[]} ids - Array of listing IDs to delete.
|
||||||
|
* @returns {any} The result from SqliteConnection.execute.
|
||||||
|
*/
|
||||||
|
export const deleteListingsById = (ids) => {
|
||||||
|
if (!Array.isArray(ids) || ids.length === 0) return;
|
||||||
|
const placeholders = ids.map(() => '?').join(',');
|
||||||
|
return SqliteConnection.execute(
|
||||||
|
`DELETE
|
||||||
|
FROM listings
|
||||||
|
WHERE id IN (${placeholders})`,
|
||||||
|
ids,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Migration: there needs to be a unique index on job_id and hash as only
|
||||||
|
// this makes the listing indeed unique
|
||||||
|
|
||||||
|
export function up(db) {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE listings ADD COLUMN is_active INTEGER DEFAULT 1;
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Migration: Adding a changeset field to the listings table in preparation for
|
||||||
|
// a price watch feature
|
||||||
|
|
||||||
|
export function up(db) {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE listings ADD COLUMN change_set jsonb;
|
||||||
|
`);
|
||||||
|
}
|
||||||
15
lib/services/storage/migrations/sql/4.watch-list.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// Migration: Adding a new table to store if somebody "watches" (a.k.a favorite) a listing
|
||||||
|
|
||||||
|
export function up(db) {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS watch_list
|
||||||
|
(
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
listing_id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (listing_id) REFERENCES listings (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_watch_list ON watch_list (listing_id, user_id);
|
||||||
|
`);
|
||||||
|
}
|
||||||
64
lib/services/storage/watchListStorage.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import SqliteConnection from './SqliteConnection.js';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a watch entry. Idempotent due to unique index (listing_id, user_id).
|
||||||
|
* @param {string} listingId
|
||||||
|
* @param {string} userId
|
||||||
|
* @returns {{created:boolean}}
|
||||||
|
*/
|
||||||
|
export const createWatch = (listingId, userId) => {
|
||||||
|
if (!listingId || !userId) return { created: false };
|
||||||
|
try {
|
||||||
|
SqliteConnection.execute(
|
||||||
|
`INSERT INTO watch_list (id, listing_id, user_id)
|
||||||
|
VALUES (@id, @listing_id, @user_id)
|
||||||
|
ON CONFLICT(listing_id, user_id) DO NOTHING`,
|
||||||
|
{ id: nanoid(), listing_id: listingId, user_id: userId },
|
||||||
|
);
|
||||||
|
// check whether it exists now
|
||||||
|
const row = SqliteConnection.query(
|
||||||
|
`SELECT 1 AS ok FROM watch_list WHERE listing_id = @listing_id AND user_id = @user_id LIMIT 1`,
|
||||||
|
{ listing_id: listingId, user_id: userId },
|
||||||
|
);
|
||||||
|
return { created: row.length > 0 };
|
||||||
|
} catch {
|
||||||
|
return { created: false };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a watch entry.
|
||||||
|
* @param {string} listingId
|
||||||
|
* @param {string} userId
|
||||||
|
* @returns {{deleted:boolean}}
|
||||||
|
*/
|
||||||
|
export const deleteWatch = (listingId, userId) => {
|
||||||
|
if (!listingId || !userId) return { deleted: false };
|
||||||
|
const res = SqliteConnection.execute(`DELETE FROM watch_list WHERE listing_id = @listing_id AND user_id = @user_id`, {
|
||||||
|
listing_id: listingId,
|
||||||
|
user_id: userId,
|
||||||
|
});
|
||||||
|
return { deleted: Boolean(res?.changes) };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle a watch entry. If exists -> delete, otherwise create.
|
||||||
|
* @param {string} listingId
|
||||||
|
* @param {string} userId
|
||||||
|
* @returns {{watched:boolean}}
|
||||||
|
*/
|
||||||
|
export const toggleWatch = (listingId, userId) => {
|
||||||
|
if (!listingId || !userId) return { watched: false };
|
||||||
|
const exists =
|
||||||
|
SqliteConnection.query(
|
||||||
|
`SELECT 1 AS ok FROM watch_list WHERE listing_id = @listing_id AND user_id = @user_id LIMIT 1`,
|
||||||
|
{ listing_id: listingId, user_id: userId },
|
||||||
|
).length > 0;
|
||||||
|
if (exists) {
|
||||||
|
deleteWatch(listingId, userId);
|
||||||
|
return { watched: false };
|
||||||
|
}
|
||||||
|
createWatch(listingId, userId);
|
||||||
|
return { watched: true };
|
||||||
|
};
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import { getJobs } from '../storage/jobStorage.js';
|
import { getJobs } from '../storage/jobStorage.js';
|
||||||
import { getUniqueId } from './uniqueId.js';
|
import { getUniqueId } from './uniqueId.js';
|
||||||
import { config, inDevMode } from '../../utils.js';
|
import { config, getPackageVersion, inDevMode } from '../../utils.js';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import { readFileSync } from 'fs';
|
|
||||||
import { packageUp } from 'package-up';
|
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
import logger from '../logger.js';
|
import logger from '../logger.js';
|
||||||
|
|
||||||
@@ -77,15 +75,3 @@ function enrichTrackingObject(trackingObject) {
|
|||||||
version,
|
version,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getPackageVersion() {
|
|
||||||
try {
|
|
||||||
const packagePath = await packageUp();
|
|
||||||
const packageJson = readFileSync(packagePath, 'utf8');
|
|
||||||
const json = JSON.parse(packageJson);
|
|
||||||
return json.version;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error reading version from package.json', error);
|
|
||||||
}
|
|
||||||
return 'N/A';
|
|
||||||
}
|
|
||||||
|
|||||||
135
lib/utils.js
@@ -1,15 +1,37 @@
|
|||||||
import { dirname } from 'node:path';
|
import { dirname } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath, pathToFileURL } 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';
|
import { DEFAULT_CONFIG } from './defaultConfig.js';
|
||||||
import fs from 'fs';
|
import fs, { readFileSync } from 'fs';
|
||||||
import logger from './services/logger.js';
|
import logger from './services/logger.js';
|
||||||
|
import { packageUp } from 'package-up';
|
||||||
|
|
||||||
const RE_GT = />/g;
|
const RE_GT = />/g;
|
||||||
const RE_WEBP = /\/format\/webp/gi;
|
const RE_WEBP = /\/format\/webp/gi;
|
||||||
const RE_EXT = /\.(jpe?g|png|gif)(\?.*)?$/i;
|
const RE_EXT = /\.(jpe?g|png|gif)(\?.*)?$/i;
|
||||||
const HTTPS_PREFIX = 'https://';
|
const HTTPS_PREFIX = 'https://';
|
||||||
|
const providersDirectoryPath = `${getDirName()}/provider`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily load all provider modules from the provider directory.
|
||||||
|
* Caches the resolved array to avoid re-importing on subsequent calls.
|
||||||
|
*
|
||||||
|
* @returns {Promise<any[]>} A list of loaded provider modules.
|
||||||
|
*/
|
||||||
|
let cachedProvidersPromise = null;
|
||||||
|
|
||||||
|
export function getProviders() {
|
||||||
|
if (!cachedProvidersPromise) {
|
||||||
|
/** @type {string[]} */
|
||||||
|
const providerFileNames = fs.readdirSync(providersDirectoryPath).filter((fileName) => fileName.endsWith('.js'));
|
||||||
|
cachedProvidersPromise = Promise.all(
|
||||||
|
providerFileNames.map((fileName) => import(pathToFileURL(path.join(providersDirectoryPath, fileName)).href)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return cachedProvidersPromise;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely stringify a value to JSON for storage.
|
* Safely stringify a value to JSON for storage.
|
||||||
@@ -20,7 +42,7 @@ const HTTPS_PREFIX = 'https://';
|
|||||||
* @param {T} v - Any JSON-serializable value.
|
* @param {T} v - Any JSON-serializable value.
|
||||||
* @returns {string|null} JSON string or null.
|
* @returns {string|null} JSON string or null.
|
||||||
*/
|
*/
|
||||||
export const toJson = (v) => (v == null ? null : JSON.stringify(v));
|
const toJson = (v) => (v == null ? null : JSON.stringify(v));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely parse JSON text coming from storage.
|
* Safely parse JSON text coming from storage.
|
||||||
@@ -32,7 +54,7 @@ export const toJson = (v) => (v == null ? null : JSON.stringify(v));
|
|||||||
* @param {T} fallback - Value to return when txt is null/invalid.
|
* @param {T} fallback - Value to return when txt is null/invalid.
|
||||||
* @returns {T} Parsed value or fallback.
|
* @returns {T} Parsed value or fallback.
|
||||||
*/
|
*/
|
||||||
export const fromJson = (txt, fallback) => {
|
const fromJson = (txt, fallback) => {
|
||||||
if (txt == null) return fallback;
|
if (txt == null) return fallback;
|
||||||
try {
|
try {
|
||||||
return JSON.parse(txt);
|
return JSON.parse(txt);
|
||||||
@@ -87,11 +109,22 @@ function timeStringToMs(timeString, now) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether current time is within configured working hours, or no hours are set.
|
* Determine whether the given timestamp is within the configured working hours, or return true when the window is not set.
|
||||||
* If working hours are missing or incomplete, returns true.
|
* - If workingHours is missing or either 'from' or 'to' is empty/null, returns true.
|
||||||
* @param {{workingHours?: {from?: string, to?: string}}} config
|
* - Supports windows that cross midnight (e.g., from '23:00' to '06:00').
|
||||||
* @param {number} now - Epoch ms
|
*
|
||||||
* @returns {boolean}
|
* Time parsing is based on the local timezone of the running process.
|
||||||
|
*
|
||||||
|
* @param {{workingHours?: {from?: string|null, to?: string|null}}} config - Configuration object containing working hours in 'HH:mm' format.
|
||||||
|
* @param {number} now - Epoch milliseconds to evaluate.
|
||||||
|
* @returns {boolean} True when execution is allowed at 'now'.
|
||||||
|
* @example
|
||||||
|
* // Same-day window
|
||||||
|
* duringWorkingHoursOrNotSet({ workingHours: { from: '08:00', to: '17:00' } }, someTime);
|
||||||
|
* @example
|
||||||
|
* // Window crossing midnight
|
||||||
|
* // For { from: '05:00', to: '00:30' } → 23:00 => true, 01:00 => false, 06:00 => true
|
||||||
|
* duringWorkingHoursOrNotSet({ workingHours: { from: '05:00', to: '00:30' } }, Date.now());
|
||||||
*/
|
*/
|
||||||
function duringWorkingHoursOrNotSet(config, now) {
|
function duringWorkingHoursOrNotSet(config, now) {
|
||||||
const { workingHours } = config;
|
const { workingHours } = config;
|
||||||
@@ -100,7 +133,20 @@ function duringWorkingHoursOrNotSet(config, now) {
|
|||||||
}
|
}
|
||||||
const toDate = timeStringToMs(workingHours.to, now);
|
const toDate = timeStringToMs(workingHours.to, now);
|
||||||
const fromDate = timeStringToMs(workingHours.from, now);
|
const fromDate = timeStringToMs(workingHours.from, now);
|
||||||
return fromDate <= now && toDate >= now;
|
|
||||||
|
// If parsing fails (e.g., malformed time), be lenient and allow.
|
||||||
|
if (isNaN(toDate) || isNaN(fromDate)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toDate >= fromDate) {
|
||||||
|
// Same-day window (e.g., 08:00 - 17:00)
|
||||||
|
return now >= fromDate && now <= toDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Window crosses midnight (e.g., 05:00 -> 00:30 next day)
|
||||||
|
// Accept if we are after 'from' today OR before 'to' today (which represents next day's cutoff).
|
||||||
|
return now >= fromDate || now <= toDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,6 +180,23 @@ function buildHash(...inputs) {
|
|||||||
*/
|
*/
|
||||||
let config = {};
|
let config = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the config exists, but cannot be accessed, we quit Fredy as something is fishy here.
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
export async function checkIfConfigIsAccessible() {
|
||||||
|
const path = new URL('../conf/config.json', import.meta.url);
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(path)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
fs.accessSync(path, fs.constants.R_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read config JSON from disk (conf/config.json) and parse it.
|
* Read config JSON from disk (conf/config.json) and parse it.
|
||||||
* @returns {Promise<any>} Parsed configuration object.
|
* @returns {Promise<any>} Parsed configuration object.
|
||||||
@@ -196,22 +259,56 @@ const normalizeImageUrl = (url) => {
|
|||||||
return u;
|
return u;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* returns Fredy's version
|
||||||
|
* @returns {Promise<*|string>}
|
||||||
|
*/
|
||||||
|
async function getPackageVersion() {
|
||||||
|
try {
|
||||||
|
const packagePath = await packageUp();
|
||||||
|
const packageJson = readFileSync(packagePath, 'utf8');
|
||||||
|
const json = JSON.parse(packageJson);
|
||||||
|
return json.version;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error reading version from package.json', error);
|
||||||
|
}
|
||||||
|
return 'N/A';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleep helper
|
||||||
|
* @param {number} ms milliseconds to wait
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a random integer between min and max (inclusive).
|
||||||
|
* @param {number} min - Minimum integer value.
|
||||||
|
* @param {number} max - Maximum integer value.
|
||||||
|
* @returns {number} A random integer N where min <= N <= max.
|
||||||
|
*/
|
||||||
|
function randomBetween(min, max) {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call refreshConfig() from the application entrypoint during startup to populate config.
|
||||||
await refreshConfig();
|
await refreshConfig();
|
||||||
|
|
||||||
export { isOneOf };
|
export {
|
||||||
export { normalizeImageUrl };
|
|
||||||
export { inDevMode };
|
|
||||||
export { nullOrEmpty };
|
|
||||||
export { duringWorkingHoursOrNotSet };
|
|
||||||
export { getDirName };
|
|
||||||
export { config };
|
|
||||||
export { buildHash };
|
|
||||||
export default {
|
|
||||||
isOneOf,
|
isOneOf,
|
||||||
|
normalizeImageUrl,
|
||||||
|
inDevMode,
|
||||||
nullOrEmpty,
|
nullOrEmpty,
|
||||||
duringWorkingHoursOrNotSet,
|
duringWorkingHoursOrNotSet,
|
||||||
getDirName,
|
getDirName,
|
||||||
|
sleep,
|
||||||
|
randomBetween,
|
||||||
config,
|
config,
|
||||||
|
buildHash,
|
||||||
|
getPackageVersion,
|
||||||
toJson,
|
toJson,
|
||||||
fromJson,
|
fromJson,
|
||||||
};
|
};
|
||||||
|
|||||||
31
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fredy",
|
"name": "fredy",
|
||||||
"version": "12.1.2",
|
"version": "14.1.0",
|
||||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
@@ -58,36 +58,37 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@douyinfe/semi-icons": "^2.86.0",
|
"@douyinfe/semi-icons": "^2.86.0",
|
||||||
"@douyinfe/semi-ui": "2.86.0",
|
"@douyinfe/semi-ui": "2.86.0",
|
||||||
"@sendgrid/mail": "8.1.5",
|
"@sendgrid/mail": "8.1.6",
|
||||||
"@visactor/react-vchart": "^2.0.4",
|
"@visactor/react-vchart": "^2.0.5",
|
||||||
"@visactor/vchart": "^2.0.4",
|
"@visactor/vchart": "^2.0.5",
|
||||||
"@visactor/vchart-semi-theme": "^1.12.2",
|
"@visactor/vchart-semi-theme": "^1.12.2",
|
||||||
"@vitejs/plugin-react": "5.0.3",
|
"@vitejs/plugin-react": "5.0.4",
|
||||||
"better-sqlite3": "^12.2.0",
|
"better-sqlite3": "^12.4.1",
|
||||||
"body-parser": "2.2.0",
|
"body-parser": "2.2.0",
|
||||||
"cheerio": "^1.1.2",
|
"cheerio": "^1.1.2",
|
||||||
"cookie-session": "2.1.1",
|
"cookie-session": "2.1.1",
|
||||||
"handlebars": "4.7.8",
|
"handlebars": "4.7.8",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"markdown": "^0.5.0",
|
"markdown": "^0.5.0",
|
||||||
"nanoid": "5.1.5",
|
"nanoid": "5.1.6",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"node-fetch": "3.3.2",
|
"node-fetch": "3.3.2",
|
||||||
"node-mailjet": "6.0.9",
|
"node-mailjet": "6.0.9",
|
||||||
"p-throttle": "^8.0.0",
|
"p-throttle": "^8.0.0",
|
||||||
"package-up": "^5.0.0",
|
"package-up": "^5.0.0",
|
||||||
"puppeteer": "^24.22.0",
|
"puppeteer": "^24.23.0",
|
||||||
"puppeteer-extra": "^3.3.6",
|
"puppeteer-extra": "^3.3.6",
|
||||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||||
"query-string": "9.3.1",
|
"query-string": "9.3.1",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-router": "7.9.1",
|
"react-router": "7.9.3",
|
||||||
"react-router-dom": "7.9.1",
|
"react-router-dom": "7.9.3",
|
||||||
"restana": "5.1.0",
|
"restana": "5.1.0",
|
||||||
|
"semver": "^7.7.2",
|
||||||
"serve-static": "2.2.0",
|
"serve-static": "2.2.0",
|
||||||
"slack": "11.0.2",
|
"slack": "11.0.2",
|
||||||
"vite": "7.1.6",
|
"vite": "7.1.9",
|
||||||
"x-var": "^3.0.1",
|
"x-var": "^3.0.1",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
@@ -96,16 +97,16 @@
|
|||||||
"@babel/eslint-parser": "7.28.4",
|
"@babel/eslint-parser": "7.28.4",
|
||||||
"@babel/preset-env": "7.28.3",
|
"@babel/preset-env": "7.28.3",
|
||||||
"@babel/preset-react": "7.27.1",
|
"@babel/preset-react": "7.27.1",
|
||||||
"chai": "6.0.1",
|
"chai": "6.2.0",
|
||||||
"eslint": "9.35.0",
|
"eslint": "9.36.0",
|
||||||
"eslint-config-prettier": "10.1.8",
|
"eslint-config-prettier": "10.1.8",
|
||||||
"eslint-plugin-react": "7.37.5",
|
"eslint-plugin-react": "7.37.5",
|
||||||
"esmock": "2.7.3",
|
"esmock": "2.7.3",
|
||||||
"history": "5.3.0",
|
"history": "5.3.0",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"less": "4.4.1",
|
"less": "4.4.1",
|
||||||
"lint-staged": "16.1.6",
|
"lint-staged": "16.2.3",
|
||||||
"mocha": "11.7.2",
|
"mocha": "11.7.4",
|
||||||
"nodemon": "^3.1.10",
|
"nodemon": "^3.1.10",
|
||||||
"prettier": "3.6.2"
|
"prettier": "3.6.2"
|
||||||
}
|
}
|
||||||
|
|||||||
53
test/FredyRuntime/FredyRuntime.test.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { expect } from 'chai';
|
||||||
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
|
import { mockFredy } from '../utils.js';
|
||||||
|
|
||||||
|
describe('FredyRuntime', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
similarityCache.invalidateAllForTest();
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
similarityCache.stopCacheCleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_filterBySimilarListings', () => {
|
||||||
|
let fredyRuntime;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const FredyRuntime = await mockFredy();
|
||||||
|
fredyRuntime = new FredyRuntime({}, null, 'dummy-provider', 'dummy-job', similarityCache);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter out listings with similar title and address already in cache', () => {
|
||||||
|
similarityCache.addCacheEntry('Penthouse', 'Mustermann Straße 1');
|
||||||
|
|
||||||
|
const listings = [
|
||||||
|
{ id: '1', title: 'Penthouse', address: 'Mustermann Straße 1' },
|
||||||
|
{ id: '2', title: 'Nice apartment', address: 'Mustermann Straße 15' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = fredyRuntime._filterBySimilarListings(listings);
|
||||||
|
|
||||||
|
expect(result).to.have.length(1);
|
||||||
|
expect(result[0].id).to.equal('2');
|
||||||
|
expect(result[0].title).to.equal('Nice apartment');
|
||||||
|
|
||||||
|
expect(similarityCache.hasSimilarEntries('Nice apartment', 'Mustermann Straße 15')).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle listings with null or undefined address', () => {
|
||||||
|
const listings = [
|
||||||
|
{ id: '1', title: 'Penthouse', address: null },
|
||||||
|
{ id: '2', title: 'Nice apartment', address: undefined },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = fredyRuntime._filterBySimilarListings(listings);
|
||||||
|
|
||||||
|
expect(result).to.have.length(2);
|
||||||
|
|
||||||
|
expect(similarityCache.hasSimilarEntries('Penthouse', null)).to.be.true;
|
||||||
|
expect(similarityCache.hasSimilarEntries('Nice apartment', undefined)).to.be.true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,31 +8,30 @@ describe('#immonet testsuite()', () => {
|
|||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
});
|
});
|
||||||
provider.init(providerConfig.immonet, [], []);
|
|
||||||
it('should test immonet provider', async () => {
|
it('should test immonet provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
return await new Promise((resolve) => {
|
provider.init(providerConfig.immonet, [], []);
|
||||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immonet', similarityCache);
|
|
||||||
fredy.execute().then((listing) => {
|
|
||||||
expect(listing).to.be.a('array');
|
|
||||||
const notificationObj = get();
|
|
||||||
expect(notificationObj).to.be.a('object');
|
|
||||||
expect(notificationObj.serviceName).to.equal('immonet');
|
|
||||||
notificationObj.payload.forEach((notify) => {
|
|
||||||
/** check the actual structure **/
|
|
||||||
expect(notify.id).to.be.a('string');
|
|
||||||
expect(notify.price).to.be.a('string');
|
|
||||||
expect(notify.size).to.be.a('string');
|
|
||||||
expect(notify.title).to.be.a('string');
|
|
||||||
expect(notify.link).to.be.a('string');
|
|
||||||
expect(notify.address).to.be.a('string');
|
|
||||||
|
|
||||||
expect(notify.size).that.does.include('m²');
|
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immonet', similarityCache);
|
||||||
expect(notify.title).to.be.not.empty;
|
const listing = await fredy.execute();
|
||||||
expect(notify.address).to.be.not.empty;
|
|
||||||
});
|
expect(listing).to.be.a('array');
|
||||||
resolve();
|
const notificationObj = get();
|
||||||
});
|
expect(notificationObj).to.be.a('object');
|
||||||
|
expect(notificationObj.serviceName).to.equal('immonet');
|
||||||
|
notificationObj.payload.forEach((notify) => {
|
||||||
|
/** check the actual structure **/
|
||||||
|
expect(notify.id).to.be.a('string');
|
||||||
|
expect(notify.price).to.be.a('string');
|
||||||
|
expect(notify.size).to.be.a('string');
|
||||||
|
expect(notify.title).to.be.a('string');
|
||||||
|
expect(notify.link).to.be.a('string');
|
||||||
|
expect(notify.address).to.be.a('string');
|
||||||
|
/** check the values if possible **/
|
||||||
|
expect(notify.size).that.does.include('m²');
|
||||||
|
expect(notify.title).to.be.not.empty;
|
||||||
|
expect(notify.address).to.be.not.empty;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,33 +8,32 @@ describe('#immowelt testsuite()', () => {
|
|||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should test immowelt provider', async () => {
|
it('should test immowelt provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
provider.init(providerConfig.immowelt, [], []);
|
provider.init(providerConfig.immowelt, [], []);
|
||||||
return await new Promise((resolve) => {
|
|
||||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immowelt', similarityCache);
|
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immowelt', similarityCache);
|
||||||
fredy.execute().then((listing) => {
|
const listing = await fredy.execute();
|
||||||
expect(listing).to.be.a('array');
|
|
||||||
const notificationObj = get();
|
expect(listing).to.be.a('array');
|
||||||
expect(notificationObj).to.be.a('object');
|
const notificationObj = get();
|
||||||
expect(notificationObj.serviceName).to.equal('immowelt');
|
expect(notificationObj).to.be.a('object');
|
||||||
notificationObj.payload.forEach((notify) => {
|
expect(notificationObj.serviceName).to.equal('immowelt');
|
||||||
/** check the actual structure **/
|
notificationObj.payload.forEach((notify) => {
|
||||||
expect(notify.id).to.be.a('string');
|
/** check the actual structure **/
|
||||||
expect(notify.price).to.be.a('string');
|
expect(notify.id).to.be.a('string');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.price).to.be.a('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.title).to.be.a('string');
|
||||||
expect(notify.address).to.be.a('string');
|
expect(notify.link).to.be.a('string');
|
||||||
/** check the values if possible **/
|
expect(notify.address).to.be.a('string');
|
||||||
if (notify.size != null && notify.size.trim().toLowerCase() !== 'k.a.') {
|
/** check the values if possible **/
|
||||||
expect(notify.size).that.does.include('m²');
|
if (notify.size != null && notify.size.trim().toLowerCase() !== 'k.a.') {
|
||||||
}
|
expect(notify.size).that.does.include('m²');
|
||||||
expect(notify.title).to.be.not.empty;
|
}
|
||||||
expect(notify.link).that.does.include('https://www.immowelt.de');
|
expect(notify.title).to.be.not.empty;
|
||||||
expect(notify.address).to.be.not.empty;
|
expect(notify.link).that.does.include('https://www.immowelt.de');
|
||||||
});
|
expect(notify.address).to.be.not.empty;
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
37
test/provider/mcMakler.test.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
|
import { get } from '../mocks/mockNotification.js';
|
||||||
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
|
import { expect } from 'chai';
|
||||||
|
import * as provider from '../../lib/provider/mcMakler.js';
|
||||||
|
|
||||||
|
describe('#mcMakler testsuite()', () => {
|
||||||
|
after(() => {
|
||||||
|
similarityCache.stopCacheCleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should test mcMakler provider', async () => {
|
||||||
|
const Fredy = await mockFredy();
|
||||||
|
provider.init(providerConfig.mcMakler, []);
|
||||||
|
|
||||||
|
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'mcMakler', similarityCache);
|
||||||
|
const listing = await fredy.execute();
|
||||||
|
|
||||||
|
expect(listing).to.be.a('array');
|
||||||
|
const notificationObj = get();
|
||||||
|
expect(notificationObj).to.be.a('object');
|
||||||
|
expect(notificationObj.serviceName).to.equal('mcMakler');
|
||||||
|
notificationObj.payload.forEach((notify) => {
|
||||||
|
/** check the actual structure **/
|
||||||
|
expect(notify.id).to.be.a('string');
|
||||||
|
expect(notify.price).to.be.a('string');
|
||||||
|
expect(notify.size).to.be.a('string');
|
||||||
|
expect(notify.title).to.be.a('string');
|
||||||
|
expect(notify.link).to.be.a('string');
|
||||||
|
expect(notify.address).to.be.a('string');
|
||||||
|
/** check the values if possible **/
|
||||||
|
expect(notify.size).that.does.include('m²');
|
||||||
|
expect(notify.title).to.be.not.empty;
|
||||||
|
expect(notify.address).to.be.not.empty;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
43
test/provider/regionalimmobilien24.test.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
|
import { get } from '../mocks/mockNotification.js';
|
||||||
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
|
import { expect } from 'chai';
|
||||||
|
import * as provider from '../../lib/provider/regionalimmobilien24.js';
|
||||||
|
|
||||||
|
describe('#regionalimmobilien24 testsuite()', () => {
|
||||||
|
after(() => {
|
||||||
|
similarityCache.stopCacheCleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should test regionalimmobilien24 provider', async () => {
|
||||||
|
const Fredy = await mockFredy();
|
||||||
|
provider.init(providerConfig.regionalimmobilien24, []);
|
||||||
|
|
||||||
|
const fredy = new Fredy(
|
||||||
|
provider.config,
|
||||||
|
null,
|
||||||
|
provider.metaInformation.id,
|
||||||
|
'regionalimmobilien24',
|
||||||
|
similarityCache,
|
||||||
|
);
|
||||||
|
const listing = await fredy.execute();
|
||||||
|
|
||||||
|
expect(listing).to.be.a('array');
|
||||||
|
const notificationObj = get();
|
||||||
|
expect(notificationObj).to.be.a('object');
|
||||||
|
expect(notificationObj.serviceName).to.equal('regionalimmobilien24');
|
||||||
|
notificationObj.payload.forEach((notify) => {
|
||||||
|
/** check the actual structure **/
|
||||||
|
expect(notify.id).to.be.a('string');
|
||||||
|
expect(notify.price).to.be.a('string');
|
||||||
|
expect(notify.size).to.be.a('string');
|
||||||
|
expect(notify.title).to.be.a('string');
|
||||||
|
expect(notify.link).to.be.a('string');
|
||||||
|
expect(notify.address).to.be.a('string');
|
||||||
|
/** check the values if possible **/
|
||||||
|
expect(notify.size).that.does.include('m²');
|
||||||
|
expect(notify.title).to.be.not.empty;
|
||||||
|
expect(notify.address).to.be.not.empty;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
37
test/provider/sparkasse.test.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
|
import { get } from '../mocks/mockNotification.js';
|
||||||
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
|
import { expect } from 'chai';
|
||||||
|
import * as provider from '../../lib/provider/sparkasse.js';
|
||||||
|
|
||||||
|
describe('#sparkasse testsuite()', () => {
|
||||||
|
after(() => {
|
||||||
|
similarityCache.stopCacheCleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should test sparkasse provider', async () => {
|
||||||
|
const Fredy = await mockFredy();
|
||||||
|
provider.init(providerConfig.sparkasse, []);
|
||||||
|
|
||||||
|
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'sparkasse', similarityCache);
|
||||||
|
const listing = await fredy.execute();
|
||||||
|
|
||||||
|
expect(listing).to.be.a('array');
|
||||||
|
const notificationObj = get();
|
||||||
|
expect(notificationObj).to.be.a('object');
|
||||||
|
expect(notificationObj.serviceName).to.equal('sparkasse');
|
||||||
|
notificationObj.payload.forEach((notify) => {
|
||||||
|
/** check the actual structure **/
|
||||||
|
expect(notify.id).to.be.a('string');
|
||||||
|
expect(notify.price).to.be.a('string');
|
||||||
|
expect(notify.size).to.be.a('string');
|
||||||
|
expect(notify.title).to.be.a('string');
|
||||||
|
expect(notify.link).to.be.a('string');
|
||||||
|
expect(notify.address).to.be.a('string');
|
||||||
|
/** check the values if possible **/
|
||||||
|
expect(notify.size).that.does.include('m²');
|
||||||
|
expect(notify.title).to.be.not.empty;
|
||||||
|
expect(notify.address).to.be.not.empty;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,10 +28,22 @@
|
|||||||
"url": "https://www.kleinanzeigen.de/s-immobilien/duesseldorf/anzeige:angebote/wohnung/k0c195l2068r5",
|
"url": "https://www.kleinanzeigen.de/s-immobilien/duesseldorf/anzeige:angebote/wohnung/k0c195l2068r5",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
|
"mcMakler": {
|
||||||
|
"url": "https://www.mcmakler.de/immobilien/results?placeId=62649&search=Leipzig%252C+Sachsen&propertyTypes=APARTMENT&page=0",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
"neubauKompass": {
|
"neubauKompass": {
|
||||||
"url": "https://www.neubaukompass.de/neubau-immobilien/duesseldorf-region/eigentumswohnung/",
|
"url": "https://www.neubaukompass.de/neubau-immobilien/duesseldorf-region/eigentumswohnung/",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
|
"regionalimmobilien24": {
|
||||||
|
"url": "https://www.regionalimmobilien24.de/rostock/rostock/kaufen/haus/-/-/-/?rd=5",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"sparkasse": {
|
||||||
|
"url": "https://immobilien.sparkasse.de/immobilien/treffer?marketingType=buy&objectType=flat&perimeter=10&usageType=residential&zipCityEstateId=62782__Hamburg",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
"wgGesucht": {
|
"wgGesucht": {
|
||||||
"url": "https://www.wg-gesucht.de/wg-zimmer-in-Duesseldorf.30.0.1.0.html",
|
"url": "https://www.wg-gesucht.de/wg-zimmer-in-Duesseldorf.30.0.1.0.html",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import utils from '../../lib/utils.js';
|
import { isOneOf, duringWorkingHoursOrNotSet } from '../../lib/utils.js';
|
||||||
import assert from 'assert';
|
import assert from 'assert';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'chai';
|
||||||
|
|
||||||
@@ -8,30 +8,45 @@ const fakeWorkingHoursConfig = (from, to) => ({
|
|||||||
from,
|
from,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('utils', () => {
|
describe('utils', () => {
|
||||||
describe('#isOneOf()', () => {
|
describe('#isOneOf()', () => {
|
||||||
it('should be false', () => {
|
it('should be false', () => {
|
||||||
assert.equal(utils.isOneOf('bla', ['blub']), false);
|
assert.equal(isOneOf('bla', ['blub']), false);
|
||||||
});
|
});
|
||||||
it('should be true', () => {
|
it('should be true', () => {
|
||||||
assert.equal(utils.isOneOf('bla blub blubber', ['bla']), true);
|
assert.equal(isOneOf('bla blub blubber', ['bla']), true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe('#duringWorkingHoursOrNotSet()', () => {
|
describe('#duringWorkingHoursOrNotSet()', () => {
|
||||||
it('should be false', () => {
|
it('should be false', () => {
|
||||||
expect(utils.duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('12:00', '13:00'), 0)).to.be.false;
|
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('12:00', '13:00'), 0)).to.be.false;
|
||||||
});
|
});
|
||||||
it('should be true', () => {
|
it('should be true', () => {
|
||||||
expect(utils.duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('10:00', '16:00'), 1622026740000)).to.be.true;
|
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('10:00', '16:00'), 1622026740000)).to.be.true;
|
||||||
});
|
});
|
||||||
it('should be true if nothing set', () => {
|
it('should be true if nothing set', () => {
|
||||||
expect(utils.duringWorkingHoursOrNotSet(fakeWorkingHoursConfig(null, null), 1622026740000)).to.be.true;
|
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig(null, null), 1622026740000)).to.be.true;
|
||||||
});
|
});
|
||||||
it('should be true if only to is set', () => {
|
it('should be true if only to is set', () => {
|
||||||
expect(utils.duringWorkingHoursOrNotSet(fakeWorkingHoursConfig(null, '13:00'), 1622026740000)).to.be.true;
|
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig(null, '13:00'), 1622026740000)).to.be.true;
|
||||||
});
|
});
|
||||||
it('should be true if only from is set', () => {
|
it('should be true if only from is set', () => {
|
||||||
expect(utils.duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('12:00', null), 1622026740000)).to.be.true;
|
expect(duringWorkingHoursOrNotSet(fakeWorkingHoursConfig('12:00', null), 1622026740000)).to.be.true;
|
||||||
|
});
|
||||||
|
it('should handle working hours that cross midnight (e.g., 05:00 → 00:30)', () => {
|
||||||
|
const cfg = fakeWorkingHoursConfig('05:00', '00:30');
|
||||||
|
const mkTs = (h, m = 0) => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(h);
|
||||||
|
d.setMinutes(m);
|
||||||
|
d.setSeconds(0);
|
||||||
|
d.setMilliseconds(0);
|
||||||
|
return d.getTime();
|
||||||
|
};
|
||||||
|
expect(duringWorkingHoursOrNotSet(cfg, mkTs(23, 0))).to.be.true; // 23:00 => within window
|
||||||
|
expect(duringWorkingHoursOrNotSet(cfg, mkTs(1, 0))).to.be.false; // 01:00 => outside window
|
||||||
|
expect(duringWorkingHoursOrNotSet(cfg, mkTs(6, 0))).to.be.true; // 06:00 => within window
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
155
ui/src/App.jsx
@@ -8,22 +8,27 @@ import UserMutator from './views/user/mutation/UserMutator';
|
|||||||
import JobInsight from './views/jobs/insights/JobInsight.jsx';
|
import JobInsight from './views/jobs/insights/JobInsight.jsx';
|
||||||
import { useActions, useSelector } from './services/state/store';
|
import { useActions, useSelector } from './services/state/store';
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import Logout from './components/logout/Logout';
|
|
||||||
import Logo from './components/logo/Logo';
|
|
||||||
import Menu from './components/menu/Menu';
|
|
||||||
import Login from './views/login/Login';
|
import Login from './views/login/Login';
|
||||||
import Users from './views/user/Users';
|
import Users from './views/user/Users';
|
||||||
import Jobs from './views/jobs/Jobs';
|
import Jobs from './views/jobs/Jobs';
|
||||||
|
|
||||||
import './App.less';
|
import './App.less';
|
||||||
import TrackingModal from './components/tracking/TrackingModal.jsx';
|
import TrackingModal from './components/tracking/TrackingModal.jsx';
|
||||||
import { Banner } from '@douyinfe/semi-ui';
|
import { Banner, Divider } from '@douyinfe/semi-ui';
|
||||||
|
import VersionBanner from './components/version/VersionBanner.jsx';
|
||||||
|
import Listings from './views/listings/Listings.jsx';
|
||||||
|
import Navigation from './components/navigation/Navigation.jsx';
|
||||||
|
import { Layout } from '@douyinfe/semi-ui';
|
||||||
|
import FredyFooter from './components/footer/FredyFooter.jsx';
|
||||||
|
import ProcessingTimes from './views/jobs/ProcessingTimes.jsx';
|
||||||
|
|
||||||
export default function FredyApp() {
|
export default function FredyApp() {
|
||||||
const actions = useActions();
|
const actions = useActions();
|
||||||
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 versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
|
||||||
const settings = useSelector((state) => state.generalSettings.settings);
|
const settings = useSelector((state) => state.generalSettings.settings);
|
||||||
|
const processingTimes = useSelector((state) => state.jobs.processingTimes);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function init() {
|
async function init() {
|
||||||
@@ -34,6 +39,7 @@ export default function FredyApp() {
|
|||||||
await actions.jobs.getProcessingTimes();
|
await actions.jobs.getProcessingTimes();
|
||||||
await actions.notificationAdapter.getAdapter();
|
await actions.notificationAdapter.getAdapter();
|
||||||
await actions.generalSettings.getGeneralSettings();
|
await actions.generalSettings.getGeneralSettings();
|
||||||
|
await actions.versionUpdate.getVersionUpdate();
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -46,81 +52,88 @@ export default function FredyApp() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isAdmin = () => currentUser != null && currentUser.isAdmin;
|
const isAdmin = () => currentUser != null && currentUser.isAdmin;
|
||||||
|
const { Footer, Sider, Content } = Layout;
|
||||||
|
|
||||||
const login = () => (
|
return loading ? null : needsLogin() ? (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
|
||||||
|
|
||||||
return loading ? null : needsLogin() ? (
|
|
||||||
login()
|
|
||||||
) : (
|
) : (
|
||||||
<div className="app">
|
<Layout className="app">
|
||||||
<div className="app__container">
|
<Layout className="app">
|
||||||
<Logout />
|
<Sider>
|
||||||
<Logo width={190} white />
|
<Navigation isAdmin={isAdmin()} />
|
||||||
<Menu isAdmin={isAdmin()} />
|
</Sider>
|
||||||
|
<Content>
|
||||||
|
{versionUpdate?.newVersion && <VersionBanner />}
|
||||||
|
{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 />}
|
||||||
|
{processingTimes != null && <ProcessingTimes processingTimes={processingTimes} />}
|
||||||
|
<Divider />
|
||||||
|
<div className="app__content">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/403" element={<InsufficientPermission />} />
|
||||||
|
<Route path="/jobs/new" element={<JobMutation />} />
|
||||||
|
<Route path="/jobs/edit/:jobId" element={<JobMutation />} />
|
||||||
|
<Route path="/jobs/insights/:jobId" element={<JobInsight />} />
|
||||||
|
<Route path="/jobs" element={<Jobs />} />
|
||||||
|
<Route path="/listings" element={<Listings />} />
|
||||||
|
|
||||||
{settings.demoMode && (
|
{/* Permission-aware routes */}
|
||||||
<>
|
<Route
|
||||||
<Banner
|
path="/users/new"
|
||||||
fullMode={true}
|
element={
|
||||||
type="info"
|
<PermissionAwareRoute currentUser={currentUser}>
|
||||||
bordered
|
<UserMutator />
|
||||||
closeIcon={null}
|
</PermissionAwareRoute>
|
||||||
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 />
|
<Route
|
||||||
</>
|
path="/users/edit/:userId"
|
||||||
)}
|
element={
|
||||||
{settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />}
|
<PermissionAwareRoute currentUser={currentUser}>
|
||||||
<Routes>
|
<UserMutator />
|
||||||
<Route path="/403" element={<InsufficientPermission />} />
|
</PermissionAwareRoute>
|
||||||
<Route path="/jobs/new" element={<JobMutation />} />
|
}
|
||||||
<Route path="/jobs/edit/:jobId" element={<JobMutation />} />
|
/>
|
||||||
<Route path="/jobs/insights/:jobId" element={<JobInsight />} />
|
<Route
|
||||||
<Route path="/jobs" element={<Jobs />} />
|
path="/users"
|
||||||
|
element={
|
||||||
|
<PermissionAwareRoute currentUser={currentUser}>
|
||||||
|
<Users />
|
||||||
|
</PermissionAwareRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/generalSettings"
|
||||||
|
element={
|
||||||
|
<PermissionAwareRoute currentUser={currentUser}>
|
||||||
|
<GeneralSettings />
|
||||||
|
</PermissionAwareRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Permission-aware routes */}
|
<Route path="/" element={<Navigate to="/jobs" replace />} />
|
||||||
<Route
|
</Routes>
|
||||||
path="/users/new"
|
</div>
|
||||||
element={
|
</Content>
|
||||||
<PermissionAwareRoute currentUser={currentUser}>
|
</Layout>
|
||||||
<UserMutator />
|
<Footer>
|
||||||
</PermissionAwareRoute>
|
<FredyFooter />
|
||||||
}
|
</Footer>
|
||||||
/>
|
</Layout>
|
||||||
<Route
|
|
||||||
path="/users/edit/:userId"
|
|
||||||
element={
|
|
||||||
<PermissionAwareRoute currentUser={currentUser}>
|
|
||||||
<UserMutator />
|
|
||||||
</PermissionAwareRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/users"
|
|
||||||
element={
|
|
||||||
<PermissionAwareRoute currentUser={currentUser}>
|
|
||||||
<Users />
|
|
||||||
</PermissionAwareRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/generalSettings"
|
|
||||||
element={
|
|
||||||
<PermissionAwareRoute currentUser={currentUser}>
|
|
||||||
<GeneralSettings />
|
|
||||||
</PermissionAwareRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route path="/" element={<Navigate to="/jobs" replace />} />
|
|
||||||
</Routes>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
.app {
|
.app {
|
||||||
display: flex;
|
height: 100%;
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
&__container {
|
&__content {
|
||||||
padding: 1rem 1rem;
|
margin: 1rem;
|
||||||
color: var(--semi-color-text-0);
|
|
||||||
background-color: #232429;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
BIN
ui/src/assets/no_image.jpg
Normal file
|
After Width: | Height: | Size: 242 KiB |
19
ui/src/components/footer/FredyFooter.jsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import './FredyFooter.less';
|
||||||
|
import { useSelector } from '../../services/state/store.js';
|
||||||
|
import { Typography } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
|
export default function FredyFooter() {
|
||||||
|
const { Text } = Typography;
|
||||||
|
const version = useSelector((state) => state.versionUpdate.versionUpdate);
|
||||||
|
return (
|
||||||
|
<div className="fredyFooter">
|
||||||
|
<div className="fredyFooter__version">
|
||||||
|
<Text type="tertiary">Fredy V{version?.localFredyVersion || 'N/A'}</Text>
|
||||||
|
</div>
|
||||||
|
<div className="fredyFooter__copyRight">
|
||||||
|
<Text link={{ href: 'https://github.com/orangecoding', target: '_blank' }}>Made with ❤️</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
ui/src/components/footer/FredyFooter.less
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
.fredyFooter {
|
||||||
|
background:rgb(53, 54, 60);
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
height: 1.7rem;
|
||||||
|
|
||||||
|
&__version {
|
||||||
|
padding-left: .5rem;
|
||||||
|
font-size: small;
|
||||||
|
|
||||||
|
}
|
||||||
|
&__copyRight {
|
||||||
|
padding-right: 1rem;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,5 +5,5 @@ import logoWhite from '../../assets/logo_white.png';
|
|||||||
import './Logo.less';
|
import './Logo.less';
|
||||||
|
|
||||||
export default function Logo({ width = 350, white = false } = {}) {
|
export default function Logo({ width = 350, white = false } = {}) {
|
||||||
return <img src={white ? logoWhite : logo} width={width} className="logo" />;
|
return <img src={white ? logoWhite : logo} width={width} className="logo" alt="Fredy Logo" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,19 +2,22 @@ import React from 'react';
|
|||||||
import { Button } from '@douyinfe/semi-ui';
|
import { Button } from '@douyinfe/semi-ui';
|
||||||
import { xhrPost } from '../../services/xhr';
|
import { xhrPost } from '../../services/xhr';
|
||||||
import { IconUser } from '@douyinfe/semi-icons';
|
import { IconUser } from '@douyinfe/semi-icons';
|
||||||
const Logout = function Logout() {
|
|
||||||
|
const Logout = function Logout({ text }) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<div>
|
||||||
icon={<IconUser />}
|
<Button
|
||||||
type="danger"
|
icon={<IconUser />}
|
||||||
theme="solid"
|
type="danger"
|
||||||
onClick={async () => {
|
theme="solid"
|
||||||
await xhrPost('/api/login/logout');
|
onClick={async () => {
|
||||||
location.reload();
|
await xhrPost('/api/login/logout');
|
||||||
}}
|
location.reload();
|
||||||
>
|
}}
|
||||||
Logout
|
>
|
||||||
</Button>
|
{text && 'Logout'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { Tabs, TabPane } from '@douyinfe/semi-ui';
|
|
||||||
|
|
||||||
import { useLocation } from 'react-router-dom';
|
|
||||||
import { IconUser, IconTerminal, IconSetting } from '@douyinfe/semi-icons';
|
|
||||||
import './Menu.less';
|
|
||||||
|
|
||||||
function parsePathName(name) {
|
|
||||||
const split = name.split('/').filter((s) => s.length !== 0);
|
|
||||||
return '/' + split[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
const TopMenu = function TopMenu({ isAdmin }) {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
|
||||||
return (
|
|
||||||
<Tabs className="menu" type="line" activeKey={parsePathName(location.pathname)} onTabClick={(key) => navigate(key)}>
|
|
||||||
<TabPane
|
|
||||||
itemKey="/jobs"
|
|
||||||
tab={
|
|
||||||
<span>
|
|
||||||
<IconTerminal />
|
|
||||||
Jobs
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isAdmin && (
|
|
||||||
<TabPane
|
|
||||||
itemKey="/users"
|
|
||||||
tab={
|
|
||||||
<span>
|
|
||||||
<IconUser />
|
|
||||||
User
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isAdmin && (
|
|
||||||
<TabPane
|
|
||||||
itemKey="/generalSettings"
|
|
||||||
tab={
|
|
||||||
<span>
|
|
||||||
<IconSetting />
|
|
||||||
General
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Tabs>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TopMenu;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
.menu {
|
|
||||||
margin-top: 3rem;
|
|
||||||
}
|
|
||||||
9
ui/src/components/navigation/Navigate.less
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.navigate {
|
||||||
|
&__logout_Button {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
50
ui/src/components/navigation/Navigation.jsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Nav } from '@douyinfe/semi-ui';
|
||||||
|
import { IconUser, IconStar, IconSetting, IconTerminal } from '@douyinfe/semi-icons';
|
||||||
|
import logoWhite from '../../assets/logo_white.png';
|
||||||
|
import Logout from '../logout/Logout.jsx';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import './Navigate.less';
|
||||||
|
import { useScreenWidth } from '../../hooks/screenWidth.js';
|
||||||
|
|
||||||
|
export default function Navigation({ isAdmin }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const width = useScreenWidth();
|
||||||
|
const collapsed = width <= 850;
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ itemKey: '/jobs', text: 'Jobs', icon: <IconTerminal /> },
|
||||||
|
{ itemKey: '/listings', text: 'Found Listings', icon: <IconStar /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
items.push({ itemKey: '/users', text: 'User Management', icon: <IconUser /> });
|
||||||
|
items.push({ itemKey: '/generalSettings', text: 'Settings', icon: <IconSetting /> });
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePathName(name) {
|
||||||
|
const split = name.split('/').filter((s) => s.length !== 0);
|
||||||
|
return '/' + split[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Nav
|
||||||
|
style={{ height: '100%', width: collapsed ? '' : '13rem' }}
|
||||||
|
items={items}
|
||||||
|
isCollapsed={collapsed}
|
||||||
|
selectedKeys={[parsePathName(location.pathname)]}
|
||||||
|
onSelect={(key) => {
|
||||||
|
navigate(key.itemKey);
|
||||||
|
}}
|
||||||
|
header={<img src={logoWhite} width="180" alt="Fredy Logo" />}
|
||||||
|
footer={
|
||||||
|
<div className="navigate__logout_Button">
|
||||||
|
<Logout text={!collapsed} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ export const SegmentPart = ({ name, Icon = null, children, helpText }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
|
className="segmentParts"
|
||||||
title={
|
title={
|
||||||
<Meta title={name} description={helpText} avatar={Icon == null ? null : <Icon size="extra-extra-small" />} />
|
<Meta title={name} description={helpText} avatar={Icon == null ? null : <Icon size="extra-extra-small" />} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
.segmentParts {
|
.segmentParts {
|
||||||
border: 1px solid #323232 !important;
|
border: 1px solid #323232 !important;
|
||||||
border-radius: 5px !important;
|
border-radius: 5px !important;
|
||||||
|
color: rgba(var(--semi-grey-8), 1);
|
||||||
|
background: rgb(53, 54, 60);
|
||||||
|
margin: 2rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Button, Empty, Table, Switch } from '@douyinfe/semi-ui';
|
import { Button, Empty, Table, Switch, Popover } from '@douyinfe/semi-ui';
|
||||||
import { IconDelete, IconEdit, IconHistogram } from '@douyinfe/semi-icons';
|
import { IconDelete, IconDescend2, IconEdit, IconHistogram } from '@douyinfe/semi-icons';
|
||||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||||
|
|
||||||
import './JobTable.less';
|
import './JobTable.less';
|
||||||
@@ -10,11 +10,20 @@ const empty = (
|
|||||||
<Empty
|
<Empty
|
||||||
image={<IllustrationNoResult />}
|
image={<IllustrationNoResult />}
|
||||||
darkModeImage={<IllustrationNoResultDark />}
|
darkModeImage={<IllustrationNoResultDark />}
|
||||||
description={'No jobs available.'}
|
description="No jobs available. Why don't you create one? ;)"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged, onJobEdit, onJobInsight } = {}) {
|
const getPopoverContent = (text) => <article className="jobPopoverContent">{text}</article>;
|
||||||
|
|
||||||
|
export default function JobTable({
|
||||||
|
jobs = {},
|
||||||
|
onJobRemoval,
|
||||||
|
onJobStatusChanged,
|
||||||
|
onJobEdit,
|
||||||
|
onJobInsight,
|
||||||
|
onListingRemoval,
|
||||||
|
} = {}) {
|
||||||
return (
|
return (
|
||||||
<Table
|
<Table
|
||||||
pagination={false}
|
pagination={false}
|
||||||
@@ -32,7 +41,7 @@ export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged,
|
|||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Findings',
|
title: 'Listings',
|
||||||
dataIndex: 'numberOfFoundListings',
|
dataIndex: 'numberOfFoundListings',
|
||||||
render: (value) => {
|
render: (value) => {
|
||||||
return value || 0;
|
return value || 0;
|
||||||
@@ -58,9 +67,18 @@ export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged,
|
|||||||
render: (_, job) => {
|
render: (_, job) => {
|
||||||
return (
|
return (
|
||||||
<div className="interactions">
|
<div className="interactions">
|
||||||
<Button type="primary" icon={<IconHistogram />} onClick={() => onJobInsight(job.id)} />
|
<Popover content={getPopoverContent('Job Insights')}>
|
||||||
<Button type="secondary" icon={<IconEdit />} onClick={() => onJobEdit(job.id)} />
|
<Button type="primary" icon={<IconHistogram />} onClick={() => onJobInsight(job.id)} />
|
||||||
<Button type="danger" icon={<IconDelete />} onClick={() => onJobRemoval(job.id)} />
|
</Popover>
|
||||||
|
<Popover content={getPopoverContent('Edit a Job')}>
|
||||||
|
<Button type="secondary" icon={<IconEdit />} onClick={() => onJobEdit(job.id)} />
|
||||||
|
</Popover>
|
||||||
|
<Popover content={getPopoverContent('Delete all found Listings of this Job')}>
|
||||||
|
<Button type="danger" icon={<IconDescend2 />} onClick={() => onListingRemoval(job.id)} />
|
||||||
|
</Popover>
|
||||||
|
<Popover content={getPopoverContent('Delete Job')}>
|
||||||
|
<Button type="danger" icon={<IconDelete />} onClick={() => onJobRemoval(job.id)} />
|
||||||
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,11 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.jobPopoverContent {
|
||||||
|
padding: 1rem;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.interactions {
|
.interactions {
|
||||||
flex-direction: initial;
|
flex-direction: initial;
|
||||||
|
|||||||
45
ui/src/components/table/listings/ListingsFilter.jsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Card, Checkbox, Descriptions, Divider, Select } from '@douyinfe/semi-ui';
|
||||||
|
import React from 'react';
|
||||||
|
import { useSelector } from '../../../services/state/store.js';
|
||||||
|
import { Typography } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
|
import './ListingsFilter.less';
|
||||||
|
|
||||||
|
export default function ListingsFilter({ onWatchListFilter, onActivityFilter, onJobNameFilter, onProviderFilter }) {
|
||||||
|
const jobs = useSelector((state) => state.jobs.jobs);
|
||||||
|
const provider = useSelector((state) => state.provider);
|
||||||
|
const { Title } = Typography;
|
||||||
|
return (
|
||||||
|
<Card className="listingsFilter">
|
||||||
|
<Title heading={6}>Filter by:</Title>
|
||||||
|
<Divider />
|
||||||
|
<br />
|
||||||
|
<Descriptions row>
|
||||||
|
<Descriptions.Item itemKey="Watch List">
|
||||||
|
<Checkbox onChange={(e) => onWatchListFilter(e.target.checked)}>Only Watch List</Checkbox>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item itemKey="Activity status">
|
||||||
|
<Checkbox onChange={(e) => onActivityFilter(e.target.checked)}>Only Active Listings</Checkbox>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item itemKey="Job Name">
|
||||||
|
<Select showClear placeholder="Select Job to Filter" onChange={(val) => onJobNameFilter(val)}>
|
||||||
|
{jobs != null &&
|
||||||
|
jobs.length > 0 &&
|
||||||
|
jobs.map((job) => {
|
||||||
|
return <Select.Option value={job.id}>{job.name}</Select.Option>;
|
||||||
|
})}
|
||||||
|
</Select>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item itemKey="Provider">
|
||||||
|
<Select showClear placeholder="Select Provider to Filter" onChange={(val) => onProviderFilter(val)}>
|
||||||
|
{provider != null &&
|
||||||
|
provider.length > 0 &&
|
||||||
|
provider.map((prov) => {
|
||||||
|
return <Select.Option value={prov.id}>{prov.name}</Select.Option>;
|
||||||
|
})}
|
||||||
|
</Select>
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
ui/src/components/table/listings/ListingsFilter.less
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.listingsFilter {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: rgb(53, 54, 60);
|
||||||
|
}
|
||||||
289
ui/src/components/table/listings/ListingsTable.jsx
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { Table, Popover, Input, Descriptions, Tag, Image, Empty, Button, Toast, Divider } from '@douyinfe/semi-ui';
|
||||||
|
import { useActions, useSelector } from '../../../services/state/store.js';
|
||||||
|
import { IconClose, IconDelete, IconSearch, IconStar, IconStarStroked, IconTick } from '@douyinfe/semi-icons';
|
||||||
|
import * as timeService from '../../../services/time/timeService.js';
|
||||||
|
import debounce from 'lodash/debounce';
|
||||||
|
import no_image from '../../../assets/no_image.jpg';
|
||||||
|
|
||||||
|
import './ListingsTable.less';
|
||||||
|
import { format } from '../../../services/time/timeService.js';
|
||||||
|
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||||
|
import { xhrDelete, xhrPost } from '../../../services/xhr.js';
|
||||||
|
import ListingsFilter from './ListingsFilter.jsx';
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '#',
|
||||||
|
width: 100,
|
||||||
|
dataIndex: 'isWatched',
|
||||||
|
sorter: true,
|
||||||
|
render: (id, row) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Popover
|
||||||
|
style={{
|
||||||
|
padding: '.4rem',
|
||||||
|
color: 'var(--semi-color-white)',
|
||||||
|
}}
|
||||||
|
content={row.isWatched === 1 ? 'Unwatch Listing' : 'Watch Listing'}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon={
|
||||||
|
row.isWatched === 1 ? (
|
||||||
|
<IconStar style={{ color: 'rgba(var(--semi-green-5), 1)' }} />
|
||||||
|
) : (
|
||||||
|
<IconStarStroked />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
theme="borderless"
|
||||||
|
size="small"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await xhrPost('/api/listings/watch', { listingId: row.id });
|
||||||
|
Toast.success(row.isWatched === 1 ? 'Listing removed from Watchlist' : 'Listing added to Watchlist');
|
||||||
|
row.reloadTable();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
Toast.error('Failed to operate Watchlist');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
<Divider layout="vertical" margin="4px" />
|
||||||
|
<Popover
|
||||||
|
style={{
|
||||||
|
padding: '.4rem',
|
||||||
|
color: 'var(--semi-color-white)',
|
||||||
|
}}
|
||||||
|
content="Delete Listing"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon={<IconDelete />}
|
||||||
|
theme="borderless"
|
||||||
|
size="small"
|
||||||
|
type="danger"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await xhrDelete('/api/listings/', { ids: [id] });
|
||||||
|
Toast.success('Listing(s) successfully removed');
|
||||||
|
row.reloadTable();
|
||||||
|
} catch (error) {
|
||||||
|
Toast.error(error);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'State',
|
||||||
|
dataIndex: 'is_active',
|
||||||
|
width: 84,
|
||||||
|
sorter: true,
|
||||||
|
render: (value) => {
|
||||||
|
return value ? (
|
||||||
|
<div style={{ color: 'rgba(var(--semi-green-6), 1)' }}>
|
||||||
|
<Popover
|
||||||
|
style={{
|
||||||
|
padding: '.4rem',
|
||||||
|
color: 'var(--semi-color-white)',
|
||||||
|
}}
|
||||||
|
content="Listing is still active"
|
||||||
|
>
|
||||||
|
<IconTick />
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ color: 'rgba(var(--semi-red-5), 1)' }}>
|
||||||
|
<Popover
|
||||||
|
style={{
|
||||||
|
padding: '.4rem',
|
||||||
|
color: 'var(--semi-color-white)',
|
||||||
|
}}
|
||||||
|
content="Listing is inactive"
|
||||||
|
>
|
||||||
|
<IconClose />
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Job-Name',
|
||||||
|
sorter: true,
|
||||||
|
ellipsis: true,
|
||||||
|
dataIndex: 'job_name',
|
||||||
|
width: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Listing date',
|
||||||
|
width: 130,
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
sorter: true,
|
||||||
|
render: (text) => timeService.format(text, false),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Provider',
|
||||||
|
width: 130,
|
||||||
|
dataIndex: 'provider',
|
||||||
|
sorter: true,
|
||||||
|
render: (text) => text.charAt(0).toUpperCase() + text.slice(1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Price',
|
||||||
|
width: 110,
|
||||||
|
dataIndex: 'price',
|
||||||
|
sorter: true,
|
||||||
|
render: (text) => text + ' €',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Address',
|
||||||
|
width: 150,
|
||||||
|
dataIndex: 'address',
|
||||||
|
sorter: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Title',
|
||||||
|
dataIndex: 'title',
|
||||||
|
sorter: true,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (text, row) => {
|
||||||
|
return (
|
||||||
|
<a href={row.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
{text}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const empty = (
|
||||||
|
<Empty
|
||||||
|
image={<IllustrationNoResult />}
|
||||||
|
darkModeImage={<IllustrationNoResultDark />}
|
||||||
|
description="No listings available."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function ListingsTable() {
|
||||||
|
const tableData = useSelector((state) => state.listingsTable);
|
||||||
|
const actions = useActions();
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const pageSize = 10;
|
||||||
|
const [sortData, setSortData] = useState({});
|
||||||
|
const [freeTextFilter, setFreeTextFilter] = useState(null);
|
||||||
|
const [watchListFilter, setWatchListFilter] = useState(null);
|
||||||
|
const [jobNameFilter, setJobNameFilter] = useState(null);
|
||||||
|
const [activityFilter, setActivityFilter] = useState(null);
|
||||||
|
const [providerFilter, setProviderFilter] = useState(null);
|
||||||
|
|
||||||
|
const handlePageChange = (_page) => {
|
||||||
|
setPage(_page);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadTable = () => {
|
||||||
|
let sortfield = null;
|
||||||
|
let sortdir = null;
|
||||||
|
|
||||||
|
if (sortData != null && Object.keys(sortData).length > 0) {
|
||||||
|
sortfield = sortData.field;
|
||||||
|
sortdir = sortData.direction;
|
||||||
|
}
|
||||||
|
actions.listingsTable.getListingsTable({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
sortfield,
|
||||||
|
sortdir,
|
||||||
|
freeTextFilter,
|
||||||
|
filter: { watchListFilter, jobNameFilter, activityFilter, providerFilter },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTable();
|
||||||
|
}, [page, sortData, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]);
|
||||||
|
|
||||||
|
const handleFilterChange = useMemo(() => debounce((value) => setFreeTextFilter(value), 500), []);
|
||||||
|
|
||||||
|
const expandRowRender = (record) => {
|
||||||
|
return (
|
||||||
|
<div className="listingsTable__expanded">
|
||||||
|
<div>
|
||||||
|
{record.image_url == null ? (
|
||||||
|
<Image height={200} src={no_image} />
|
||||||
|
) : (
|
||||||
|
<Image height={200} src={record.image_url} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Descriptions align="justify">
|
||||||
|
<Descriptions.Item itemKey="Listing still online">
|
||||||
|
<Tag size="small" shape="circle" color={record.is_active ? 'green' : 'red'}>
|
||||||
|
{record.is_active ? 'Yes' : 'No'}
|
||||||
|
</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item itemKey="Link">
|
||||||
|
<a href={record.link} target="_blank" rel="noreferrer">
|
||||||
|
Link to Listing
|
||||||
|
</a>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item itemKey="Listing date">{format(record.created_at)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item itemKey="Price">{record.price} €</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
<b>{record.title}</b>
|
||||||
|
<p>{record.description == null ? 'No description available' : record.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ListingsFilter
|
||||||
|
onActivityFilter={setActivityFilter}
|
||||||
|
onWatchListFilter={setWatchListFilter}
|
||||||
|
onJobNameFilter={setJobNameFilter}
|
||||||
|
onProviderFilter={setProviderFilter}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
prefix={<IconSearch />}
|
||||||
|
showClear
|
||||||
|
className="listingsTable__search"
|
||||||
|
placeholder="Search"
|
||||||
|
onChange={handleFilterChange}
|
||||||
|
/>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
empty={empty}
|
||||||
|
hideExpandedColumn={false}
|
||||||
|
sticky={{ top: 5 }}
|
||||||
|
columns={columns}
|
||||||
|
expandedRowRender={expandRowRender}
|
||||||
|
dataSource={(tableData?.result || []).map((row) => {
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
reloadTable: loadTable,
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
onChange={(changeSet) => {
|
||||||
|
if (changeSet?.extra?.changeType === 'sorter') {
|
||||||
|
setSortData({
|
||||||
|
field: changeSet.sorter.dataIndex,
|
||||||
|
direction: changeSet.sorter.sortOrder === 'ascend' ? 'asc' : 'desc',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
pagination={{
|
||||||
|
currentPage: page,
|
||||||
|
//for now fixed
|
||||||
|
pageSize,
|
||||||
|
total: tableData?.totalNumber || 0,
|
||||||
|
onPageChange: handlePageChange,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
ui/src/components/table/listings/ListingsTable.less
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
.listingsTable {
|
||||||
|
&__search {
|
||||||
|
margin-bottom: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__expanded {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__toolbar {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
ui/src/components/version/VersionBanner.jsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Collapse, Descriptions } from '@douyinfe/semi-ui';
|
||||||
|
import { useSelector } from '../../services/state/store.js';
|
||||||
|
import { MarkdownRender } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
|
import './VersionBanner.less';
|
||||||
|
|
||||||
|
export default function VersionBanner() {
|
||||||
|
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
|
||||||
|
return (
|
||||||
|
<Collapse>
|
||||||
|
<Collapse.Panel header="A new version of Fredy is available" itemKey="1" className="versionBanner">
|
||||||
|
<div className="versionBanner__content">
|
||||||
|
<p>A new version of Fredy is available. Update now to take advantage of the latest features and bug fixes.</p>
|
||||||
|
<Descriptions row size="small">
|
||||||
|
<Descriptions.Item itemKey="Your Version">{versionUpdate.localFredyVersion}</Descriptions.Item>
|
||||||
|
<Descriptions.Item itemKey="Latest Version">{versionUpdate.version}</Descriptions.Item>
|
||||||
|
<Descriptions.Item itemKey="Github Release">
|
||||||
|
<a href={versionUpdate.url} target="_blank" rel="noreferrer">
|
||||||
|
{versionUpdate.url}
|
||||||
|
</a>{' '}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
<p>
|
||||||
|
<b>
|
||||||
|
<small>Release Notes</small>
|
||||||
|
</b>
|
||||||
|
</p>
|
||||||
|
<MarkdownRender raw={versionUpdate.body} />
|
||||||
|
</div>
|
||||||
|
</Collapse.Panel>
|
||||||
|
</Collapse>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
ui/src/components/version/VersionBanner.less
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.versionBanner {
|
||||||
|
background: rgba(var(--semi-teal-1), 1);
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
ui/src/hooks/screenWidth.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useScreenWidth() {
|
||||||
|
const [width, setWidth] = useState(window.innerWidth);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timeoutId;
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(() => setWidth(window.innerWidth), 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return width;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { shallow } from 'zustand/shallow';
|
import { shallow } from 'zustand/shallow';
|
||||||
import { xhrGet } from '../xhr.js';
|
import { xhrGet } from '../xhr.js';
|
||||||
|
import queryString from 'query-string';
|
||||||
|
|
||||||
const logger = (config) => (set, get, api) =>
|
const logger = (config) => (set, get, api) =>
|
||||||
config(
|
config(
|
||||||
@@ -118,13 +119,58 @@ export const useFredyState = create(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
versionUpdate: {
|
||||||
|
async getVersionUpdate() {
|
||||||
|
try {
|
||||||
|
const response = await xhrGet('/api/version');
|
||||||
|
set((state) => ({
|
||||||
|
versionUpdate: { ...state.versionUpdate, versionUpdate: response.json },
|
||||||
|
}));
|
||||||
|
} catch (Exception) {
|
||||||
|
console.error('Error while trying to get resource for api/version. Error:', Exception);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
listingsTable: {
|
||||||
|
async getListingsTable({
|
||||||
|
page = 1,
|
||||||
|
pageSize = 20,
|
||||||
|
freeTextFilter = null,
|
||||||
|
sortfield = null,
|
||||||
|
sortdir = 'asc',
|
||||||
|
filter,
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const qryString = queryString.stringify({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
freeTextFilter,
|
||||||
|
sortfield,
|
||||||
|
sortdir,
|
||||||
|
...filter,
|
||||||
|
});
|
||||||
|
const response = await xhrGet(`/api/listings/table?${qryString}`);
|
||||||
|
set((state) => ({
|
||||||
|
listingsTable: { ...state.listingsTable, ...response.json },
|
||||||
|
}));
|
||||||
|
} catch (Exception) {
|
||||||
|
console.error('Error while trying to get resource for api/listings. Error:', Exception);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initial state
|
// Initial state
|
||||||
const initial = {
|
const initial = {
|
||||||
notificationAdapter: [],
|
notificationAdapter: [],
|
||||||
|
listingsTable: {
|
||||||
|
totalNumber: 0,
|
||||||
|
page: 1,
|
||||||
|
result: [],
|
||||||
|
},
|
||||||
generalSettings: { settings: {} },
|
generalSettings: { settings: {} },
|
||||||
demoMode: { demoMode: false },
|
demoMode: { demoMode: false },
|
||||||
|
versionUpdate: {},
|
||||||
provider: [],
|
provider: [],
|
||||||
jobs: { jobs: [], insights: {}, processingTimes: {} },
|
jobs: { jobs: [], insights: {}, processingTimes: {} },
|
||||||
user: { users: [], currentUser: null },
|
user: { users: [], currentUser: null },
|
||||||
@@ -135,6 +181,8 @@ export const useFredyState = create(
|
|||||||
notificationAdapter: { ...effects.notificationAdapter },
|
notificationAdapter: { ...effects.notificationAdapter },
|
||||||
generalSettings: { ...effects.generalSettings },
|
generalSettings: { ...effects.generalSettings },
|
||||||
demoMode: { ...effects.demoMode },
|
demoMode: { ...effects.demoMode },
|
||||||
|
versionUpdate: { ...effects.versionUpdate },
|
||||||
|
listingsTable: { ...effects.listingsTable },
|
||||||
provider: { ...effects.provider },
|
provider: { ...effects.provider },
|
||||||
jobs: { ...effects.jobs },
|
jobs: { ...effects.jobs },
|
||||||
user: { ...effects.user },
|
user: { ...effects.user },
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
export function format(ts) {
|
export function format(ts, showSeconds = true) {
|
||||||
return new Intl.DateTimeFormat('default', {
|
return new Intl.DateTimeFormat('default', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'numeric',
|
month: 'numeric',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: 'numeric',
|
hour: 'numeric',
|
||||||
minute: 'numeric',
|
minute: 'numeric',
|
||||||
second: 'numeric',
|
...(showSeconds ? { second: 'numeric' } : {}),
|
||||||
}).format(ts);
|
}).format(ts);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const roundToHour = (ts) => Math.ceil(ts / (1000 * 60 * 60)) * (1000 * 60 * 60);
|
export const roundToHour = (ts) => Math.ceil(ts / (1000 * 60 * 60)) * (1000 * 60 * 60);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useActions, useSelector } from '../../services/state/store';
|
|||||||
|
|
||||||
import { Divider, TimePicker, Button, Checkbox, Input } from '@douyinfe/semi-ui';
|
import { Divider, TimePicker, Button, Checkbox, Input } from '@douyinfe/semi-ui';
|
||||||
import { InputNumber } from '@douyinfe/semi-ui';
|
import { InputNumber } from '@douyinfe/semi-ui';
|
||||||
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';
|
||||||
@@ -125,7 +124,6 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
<div>
|
<div>
|
||||||
{!loading && (
|
{!loading && (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<Headline text="General Settings" />
|
|
||||||
<div>
|
<div>
|
||||||
<SegmentPart
|
<SegmentPart
|
||||||
name="Interval"
|
name="Interval"
|
||||||
@@ -186,7 +184,7 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
<Divider margin="1rem" />
|
<Divider margin="1rem" />
|
||||||
<SegmentPart
|
<SegmentPart
|
||||||
name="Working hours"
|
name="Working hours"
|
||||||
helpText="During this hours, Fredy will search for new apartments. If nothing is configured, Fredy will search around the clock."
|
helpText="During these hours, Fredy will search for new apartments. If nothing is configured, Fredy will search around the clock."
|
||||||
Icon={IconCalendar}
|
Icon={IconCalendar}
|
||||||
>
|
>
|
||||||
<div className="generalSettings__timePickerContainer">
|
<div className="generalSettings__timePickerContainer">
|
||||||
|
|||||||
@@ -4,21 +4,29 @@ import JobTable from '../../components/table/JobTable';
|
|||||||
import { useSelector, useActions } from '../../services/state/store';
|
import { useSelector, useActions } from '../../services/state/store';
|
||||||
import { xhrDelete, xhrPut } from '../../services/xhr';
|
import { xhrDelete, xhrPut } from '../../services/xhr';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import ProcessingTimes from './ProcessingTimes';
|
|
||||||
import { Button, Toast } from '@douyinfe/semi-ui';
|
import { Button, Toast } from '@douyinfe/semi-ui';
|
||||||
import { IconPlusCircle } from '@douyinfe/semi-icons';
|
import { IconPlusCircle } from '@douyinfe/semi-icons';
|
||||||
import './Jobs.less';
|
import './Jobs.less';
|
||||||
|
|
||||||
export default function Jobs() {
|
export default function Jobs() {
|
||||||
const jobs = useSelector((state) => state.jobs.jobs);
|
const jobs = useSelector((state) => state.jobs.jobs);
|
||||||
const processingTimes = useSelector((state) => state.jobs.processingTimes);
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const actions = useActions();
|
const actions = useActions();
|
||||||
|
|
||||||
const onJobRemoval = async (jobId) => {
|
const onJobRemoval = async (jobId) => {
|
||||||
try {
|
try {
|
||||||
await xhrDelete('/api/jobs', { jobId });
|
await xhrDelete('/api/jobs', { jobId });
|
||||||
Toast.success('Job successfully remove');
|
Toast.success('Job successfully removed');
|
||||||
|
await actions.jobs.getJobs();
|
||||||
|
} catch (error) {
|
||||||
|
Toast.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onListingRemoval = async (jobId) => {
|
||||||
|
try {
|
||||||
|
await xhrDelete('/api/listings/job', { jobId });
|
||||||
|
Toast.success('Listings successfully removed');
|
||||||
await actions.jobs.getJobs();
|
await actions.jobs.getJobs();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Toast.error(error);
|
Toast.error(error);
|
||||||
@@ -38,7 +46,6 @@ export default function Jobs() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
{processingTimes != null && <ProcessingTimes processingTimes={processingTimes} />}
|
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<IconPlusCircle />}
|
icon={<IconPlusCircle />}
|
||||||
@@ -52,6 +59,7 @@ export default function Jobs() {
|
|||||||
<JobTable
|
<JobTable
|
||||||
jobs={jobs || []}
|
jobs={jobs || []}
|
||||||
onJobRemoval={onJobRemoval}
|
onJobRemoval={onJobRemoval}
|
||||||
|
onListingRemoval={onListingRemoval}
|
||||||
onJobStatusChanged={onJobStatusChanged}
|
onJobStatusChanged={onJobStatusChanged}
|
||||||
onJobInsight={(jobId) => navigate(`/jobs/insights/${jobId}`)}
|
onJobInsight={(jobId) => navigate(`/jobs/insights/${jobId}`)}
|
||||||
onJobEdit={(jobId) => navigate(`/jobs/edit/${jobId}`)}
|
onJobEdit={(jobId) => navigate(`/jobs/edit/${jobId}`)}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
.jobs {
|
.jobs {
|
||||||
&__newButton {
|
&__newButton {
|
||||||
margin-top: 1rem !important;
|
margin-top: 1rem !important;
|
||||||
float: right;
|
float: left;
|
||||||
margin-bottom: 1rem !important;
|
margin-bottom: 1rem !important;
|
||||||
|
margin-left: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,56 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { format } from '../../services/time/timeService';
|
import { format } from '../../services/time/timeService';
|
||||||
import { Button, Descriptions, Toast } from '@douyinfe/semi-ui';
|
import { Button, Card, Col, Row, Toast } from '@douyinfe/semi-ui';
|
||||||
import { IconPlayCircle } from '@douyinfe/semi-icons';
|
import { IconPlayCircle } from '@douyinfe/semi-icons';
|
||||||
import { xhrPost } from '../../services/xhr.js';
|
import { xhrPost } from '../../services/xhr.js';
|
||||||
|
|
||||||
|
import './ProsessingTimes.less';
|
||||||
|
|
||||||
|
function InfoCard({ title, value }) {
|
||||||
|
return (
|
||||||
|
<Card style={{ maxWidth: '13rem', margin: '1rem', background: 'rgb(53, 54, 60)' }} title={title}>
|
||||||
|
{value}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProcessingTimes({ processingTimes = {} }) {
|
export default function ProcessingTimes({ processingTimes = {} }) {
|
||||||
if (Object.keys(processingTimes).length === 0) {
|
if (Object.keys(processingTimes).length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<Row>
|
||||||
<Descriptions
|
<Col span={6}>
|
||||||
row
|
<InfoCard title="Processing Interval" value={`${processingTimes.interval} min`} />
|
||||||
size="small"
|
</Col>
|
||||||
style={{
|
{processingTimes.lastRun && (
|
||||||
backgroundColor: '#35363c',
|
<>
|
||||||
borderRadius: '4px',
|
<Col span={6}>
|
||||||
padding: '10px',
|
<InfoCard title="Last run" value={format(processingTimes.lastRun)} />
|
||||||
}}
|
</Col>
|
||||||
>
|
<Col span={6}>
|
||||||
<Descriptions.Item itemKey="Processing Interval">{processingTimes.interval} min</Descriptions.Item>
|
<InfoCard title="Next run" value={format(processingTimes.lastRun + processingTimes.interval * 60000)} />
|
||||||
{processingTimes.lastRun && (
|
</Col>
|
||||||
<>
|
</>
|
||||||
<Descriptions.Item itemKey="Last run">{format(processingTimes.lastRun)}</Descriptions.Item>
|
)}
|
||||||
<Descriptions.Item itemKey="Next run">
|
<Col span={6}>
|
||||||
{format(processingTimes.lastRun + processingTimes.interval * 60000)}
|
<InfoCard
|
||||||
</Descriptions.Item>
|
title="Find Listings Now"
|
||||||
<Descriptions.Item itemKey="Find Listings now">
|
value={
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
icon={<IconPlayCircle />}
|
icon={<IconPlayCircle />}
|
||||||
aria-label="Start now"
|
aria-label="Start now"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await xhrPost('/api/jobs/startAll', null);
|
await xhrPost('/api/jobs/startAll', null);
|
||||||
Toast.success('Successfully triggered Fredy search.');
|
Toast.success('Successfully triggered Fredy search.');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Search now
|
Search now
|
||||||
</Button>
|
</Button>
|
||||||
</Descriptions.Item>
|
}
|
||||||
</>
|
/>
|
||||||
)}
|
</Col>
|
||||||
</Descriptions>
|
</Row>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
5
ui/src/views/jobs/ProsessingTimes.less
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.processingTimes {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
@@ -89,7 +89,7 @@ export default function JobMutator() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Headline text={jobToBeEdit ? 'Edit a Job' : 'Create a new Job'} />
|
<Headline text={jobToBeEdit ? 'Edit Job' : 'Create new Job'} />
|
||||||
<form>
|
<form>
|
||||||
<SegmentPart name="Name">
|
<SegmentPart name="Name">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
11
ui/src/views/listings/Listings.jsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import ListingsTable from '../../components/table/listings/ListingsTable.jsx';
|
||||||
|
|
||||||
|
export default function Listings() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ListingsTable />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@ const Users = function Users() {
|
|||||||
icon={<IconPlus />}
|
icon={<IconPlus />}
|
||||||
onClick={() => navigate('/users/new')}
|
onClick={() => navigate('/users/new')}
|
||||||
>
|
>
|
||||||
Create new User
|
New User
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<UserTable
|
<UserTable
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
.users {
|
.users {
|
||||||
&__newButton {
|
&__newButton {
|
||||||
margin-top: 1rem !important;
|
margin-top: 1rem !important;
|
||||||
float: right;
|
float: left;
|
||||||
margin-bottom: 1rem !important;
|
margin-bottom: 1rem !important;
|
||||||
|
margin-left: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
293
yarn.lock
@@ -1203,10 +1203,10 @@
|
|||||||
minimatch "^3.1.2"
|
minimatch "^3.1.2"
|
||||||
strip-json-comments "^3.1.1"
|
strip-json-comments "^3.1.1"
|
||||||
|
|
||||||
"@eslint/js@9.35.0":
|
"@eslint/js@9.36.0":
|
||||||
version "9.35.0"
|
version "9.36.0"
|
||||||
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.35.0.tgz#ffbc7e13cf1204db18552e9cd9d4a8e17c692d07"
|
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.36.0.tgz#b1a3893dd6ce2defed5fd49de805ba40368e8fef"
|
||||||
integrity sha512-30iXE9whjlILfWobBkNerJo+TXYsgVM5ERQwMcMKCHckHflCmf7wXDAHlARoWnh0s1U72WqlbeyE7iAcCzuCPw==
|
integrity sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==
|
||||||
|
|
||||||
"@eslint/object-schema@^2.1.6":
|
"@eslint/object-schema@^2.1.6":
|
||||||
version "2.1.6"
|
version "2.1.6"
|
||||||
@@ -1428,10 +1428,10 @@
|
|||||||
"@resvg/resvg-js-win32-ia32-msvc" "2.4.1"
|
"@resvg/resvg-js-win32-ia32-msvc" "2.4.1"
|
||||||
"@resvg/resvg-js-win32-x64-msvc" "2.4.1"
|
"@resvg/resvg-js-win32-x64-msvc" "2.4.1"
|
||||||
|
|
||||||
"@rolldown/pluginutils@1.0.0-beta.35":
|
"@rolldown/pluginutils@1.0.0-beta.38":
|
||||||
version "1.0.0-beta.35"
|
version "1.0.0-beta.38"
|
||||||
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.35.tgz#1a477e7742b154b67519d40e4fc17485de338e7a"
|
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz#95253608c4629eb2a5f3d656009ac9ba031eb292"
|
||||||
integrity sha512-slYrCpoxJUqzFDDNlvrOYRazQUNRvWPjXA17dAOISY3rDMxX6k8K4cj2H+hEYMHF81HO3uNd5rHVigAWRM5dSg==
|
integrity sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==
|
||||||
|
|
||||||
"@rollup/rollup-android-arm-eabi@4.49.0":
|
"@rollup/rollup-android-arm-eabi@4.49.0":
|
||||||
version "4.49.0"
|
version "4.49.0"
|
||||||
@@ -1548,10 +1548,10 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
deepmerge "^4.2.2"
|
deepmerge "^4.2.2"
|
||||||
|
|
||||||
"@sendgrid/mail@8.1.5":
|
"@sendgrid/mail@8.1.6":
|
||||||
version "8.1.5"
|
version "8.1.6"
|
||||||
resolved "https://registry.yarnpkg.com/@sendgrid/mail/-/mail-8.1.5.tgz#995ef96aaf4664d2f059ec6ca38f79f724d350f2"
|
resolved "https://registry.yarnpkg.com/@sendgrid/mail/-/mail-8.1.6.tgz#9c253c13d49867fdb6f7df1360643825236eef22"
|
||||||
integrity sha512-W+YuMnkVs4+HA/bgfto4VHKcPKLc7NiZ50/NH2pzO6UHCCFuq8/GNB98YJlLEr/ESDyzAaDr7lVE7hoBwFTT3Q==
|
integrity sha512-/ZqxUvKeEztU9drOoPC/8opEPOk+jLlB2q4+xpx6HVLq6aFu3pMpalkTpAQz8XfRfpLp8O25bh6pGPcHDCYpqg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@sendgrid/client" "^8.1.5"
|
"@sendgrid/client" "^8.1.5"
|
||||||
"@sendgrid/helpers" "^8.0.0"
|
"@sendgrid/helpers" "^8.0.0"
|
||||||
@@ -1729,24 +1729,24 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8"
|
resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8"
|
||||||
integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==
|
integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==
|
||||||
|
|
||||||
"@visactor/react-vchart@^2.0.4":
|
"@visactor/react-vchart@^2.0.5":
|
||||||
version "2.0.4"
|
version "2.0.5"
|
||||||
resolved "https://registry.yarnpkg.com/@visactor/react-vchart/-/react-vchart-2.0.4.tgz#221760d3c9707fcee9e94b3b0fd0371540d40db0"
|
resolved "https://registry.yarnpkg.com/@visactor/react-vchart/-/react-vchart-2.0.5.tgz#1eb3339b662f623c08cf20f57c2507760c784468"
|
||||||
integrity sha512-dN0VHEXMF1QTA9JAaV1kZYxajxwwPBpMhLB1vXgY9u41prDFYyboQ7atwweyBB/xSdRdsuQgzYU/SSM/R2gNeg==
|
integrity sha512-D3dAPASde1zuZiorx32jkRe9cMuc9PO3IVurw0Sm/XBzrdQE2MnoLONfM2ktT/BJQggBZaHE6+n8inGE24JyJg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@visactor/vchart" "2.0.4"
|
"@visactor/vchart" "2.0.5"
|
||||||
"@visactor/vchart-extension" "2.0.4"
|
"@visactor/vchart-extension" "2.0.5"
|
||||||
"@visactor/vrender-core" "1.0.13"
|
"@visactor/vrender-core" "1.0.13"
|
||||||
"@visactor/vrender-kits" "1.0.13"
|
"@visactor/vrender-kits" "1.0.13"
|
||||||
"@visactor/vutils" "~1.0.6"
|
"@visactor/vutils" "~1.0.6"
|
||||||
react-is "^18.2.0"
|
react-is "^18.2.0"
|
||||||
|
|
||||||
"@visactor/vchart-extension@2.0.4":
|
"@visactor/vchart-extension@2.0.5":
|
||||||
version "2.0.4"
|
version "2.0.5"
|
||||||
resolved "https://registry.yarnpkg.com/@visactor/vchart-extension/-/vchart-extension-2.0.4.tgz#8ac5e138bc410d9e9b23bb3e60547f01df48bac9"
|
resolved "https://registry.yarnpkg.com/@visactor/vchart-extension/-/vchart-extension-2.0.5.tgz#3c023ebd56bc26531f20c2ad147e45d1fcba67ef"
|
||||||
integrity sha512-KmoeI7nxpfu8vGnn86O9szjoWTtvAomBtUwdtg+cNYkX/EGxZ4LUZLe0lELSpUecRk1aqZxzdeBSFB1wQpNYRw==
|
integrity sha512-GG5cwtJ3wv4/DUM4/RVF7qi6WXRZyDRIv+U0WgWCYAdANINW95egJ3P+NHdcdLhA7VEdAXPde6XFSWOawcK4oQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@visactor/vchart" "2.0.4"
|
"@visactor/vchart" "2.0.5"
|
||||||
"@visactor/vdataset" "~1.0.6"
|
"@visactor/vdataset" "~1.0.6"
|
||||||
"@visactor/vlayouts" "~1.0.6"
|
"@visactor/vlayouts" "~1.0.6"
|
||||||
"@visactor/vrender-animate" "1.0.13"
|
"@visactor/vrender-animate" "1.0.13"
|
||||||
@@ -1767,10 +1767,10 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@visactor/vchart-theme-utils/-/vchart-theme-utils-1.12.2.tgz#bad0035e79dabbe80890bbd6196668551a12c874"
|
resolved "https://registry.yarnpkg.com/@visactor/vchart-theme-utils/-/vchart-theme-utils-1.12.2.tgz#bad0035e79dabbe80890bbd6196668551a12c874"
|
||||||
integrity sha512-PkgSAivtUZukCWVUGCXxKcbTzI/oMj1Ky22VYcVs/KM4VFmmCywU2xjBBe1du0LUey6CAKB7bMlj5bL2jctG0A==
|
integrity sha512-PkgSAivtUZukCWVUGCXxKcbTzI/oMj1Ky22VYcVs/KM4VFmmCywU2xjBBe1du0LUey6CAKB7bMlj5bL2jctG0A==
|
||||||
|
|
||||||
"@visactor/vchart@2.0.4", "@visactor/vchart@^2.0.4":
|
"@visactor/vchart@2.0.5", "@visactor/vchart@^2.0.5":
|
||||||
version "2.0.4"
|
version "2.0.5"
|
||||||
resolved "https://registry.yarnpkg.com/@visactor/vchart/-/vchart-2.0.4.tgz#36770240ae6ffd84fa285b7610192f2e06a56299"
|
resolved "https://registry.yarnpkg.com/@visactor/vchart/-/vchart-2.0.5.tgz#a7041a1fe6df5125ca02ac55946b0211f4e649ed"
|
||||||
integrity sha512-/NWBQFYd5A52I8Bkp+iod2LAhBo4cQcxt+xazrmJ/5L17Gk/LdUqCRpnF5dk3XncHb4ls+SRNGkH4kf0rNH2Mg==
|
integrity sha512-7emhEFGEhUZC8n/PkscVQeJn/yd4757wrta1avMHUKBVY7x9qEWYSFypXT2LJTxjTePB//dqZYE/aPy/plGWNQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@visactor/vdataset" "~1.0.6"
|
"@visactor/vdataset" "~1.0.6"
|
||||||
"@visactor/vlayouts" "~1.0.6"
|
"@visactor/vlayouts" "~1.0.6"
|
||||||
@@ -1780,7 +1780,7 @@
|
|||||||
"@visactor/vrender-kits" "1.0.13"
|
"@visactor/vrender-kits" "1.0.13"
|
||||||
"@visactor/vscale" "~1.0.6"
|
"@visactor/vscale" "~1.0.6"
|
||||||
"@visactor/vutils" "~1.0.6"
|
"@visactor/vutils" "~1.0.6"
|
||||||
"@visactor/vutils-extension" "2.0.4"
|
"@visactor/vutils-extension" "2.0.5"
|
||||||
|
|
||||||
"@visactor/vdataset@~1.0.6":
|
"@visactor/vdataset@~1.0.6":
|
||||||
version "1.0.9"
|
version "1.0.9"
|
||||||
@@ -1869,10 +1869,10 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@visactor/vutils" "1.0.9"
|
"@visactor/vutils" "1.0.9"
|
||||||
|
|
||||||
"@visactor/vutils-extension@2.0.4":
|
"@visactor/vutils-extension@2.0.5":
|
||||||
version "2.0.4"
|
version "2.0.5"
|
||||||
resolved "https://registry.yarnpkg.com/@visactor/vutils-extension/-/vutils-extension-2.0.4.tgz#a369192d0ca5dd9748a21a5f1f6eb3ea094cac6c"
|
resolved "https://registry.yarnpkg.com/@visactor/vutils-extension/-/vutils-extension-2.0.5.tgz#7c713c6c2bdced9c7ab599d5444b37c80ce8f8c7"
|
||||||
integrity sha512-Q0nDVTCLeCbAi8AAj8wAZfzfZDDsYF7xXhuLjjGPrPTuItPG/fHuw/rw6yDFvdhb4XGaPwv0MaUYNPFoOl60GQ==
|
integrity sha512-qQpaANT+AtOQoQAN64qhQQXqhOo9Fn5t+hmih0pFxIye+61yEj3xUSM2GxQF6ubjqCI6DvRG0DaVw0rdcoqbGg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@visactor/vdataset" "~1.0.6"
|
"@visactor/vdataset" "~1.0.6"
|
||||||
"@visactor/vutils" "~1.0.6"
|
"@visactor/vutils" "~1.0.6"
|
||||||
@@ -1895,15 +1895,15 @@
|
|||||||
"@turf/invariant" "^6.5.0"
|
"@turf/invariant" "^6.5.0"
|
||||||
eventemitter3 "^4.0.7"
|
eventemitter3 "^4.0.7"
|
||||||
|
|
||||||
"@vitejs/plugin-react@5.0.3":
|
"@vitejs/plugin-react@5.0.4":
|
||||||
version "5.0.3"
|
version "5.0.4"
|
||||||
resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-5.0.3.tgz#182ea45406d89e55b4e35c92a4a8c2c8388726c8"
|
resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-5.0.4.tgz#d642058e89c5b712655c8cbd13482f5813519602"
|
||||||
integrity sha512-PFVHhosKkofGH0Yzrw1BipSedTH68BFF8ZWy1kfUpCtJcouXXY0+racG8sExw7hw0HoX36813ga5o3LTWZ4FUg==
|
integrity sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@babel/core" "^7.28.4"
|
"@babel/core" "^7.28.4"
|
||||||
"@babel/plugin-transform-react-jsx-self" "^7.27.1"
|
"@babel/plugin-transform-react-jsx-self" "^7.27.1"
|
||||||
"@babel/plugin-transform-react-jsx-source" "^7.27.1"
|
"@babel/plugin-transform-react-jsx-source" "^7.27.1"
|
||||||
"@rolldown/pluginutils" "1.0.0-beta.35"
|
"@rolldown/pluginutils" "1.0.0-beta.38"
|
||||||
"@types/babel__core" "^7.20.5"
|
"@types/babel__core" "^7.20.5"
|
||||||
react-refresh "^0.17.0"
|
react-refresh "^0.17.0"
|
||||||
|
|
||||||
@@ -1966,7 +1966,7 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
color-convert "^2.0.1"
|
color-convert "^2.0.1"
|
||||||
|
|
||||||
ansi-styles@^6.0.0, ansi-styles@^6.1.0, ansi-styles@^6.2.1:
|
ansi-styles@^6.1.0, ansi-styles@^6.2.1:
|
||||||
version "6.2.1"
|
version "6.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5"
|
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5"
|
||||||
integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==
|
integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==
|
||||||
@@ -2197,10 +2197,10 @@ basic-ftp@^5.0.2:
|
|||||||
resolved "https://registry.yarnpkg.com/basic-ftp/-/basic-ftp-5.0.5.tgz#14a474f5fffecca1f4f406f1c26b18f800225ac0"
|
resolved "https://registry.yarnpkg.com/basic-ftp/-/basic-ftp-5.0.5.tgz#14a474f5fffecca1f4f406f1c26b18f800225ac0"
|
||||||
integrity sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==
|
integrity sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==
|
||||||
|
|
||||||
better-sqlite3@^12.2.0:
|
better-sqlite3@^12.4.1:
|
||||||
version "12.2.0"
|
version "12.4.1"
|
||||||
resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-12.2.0.tgz#de7c3466074f2d1a5d260f510647e822e42684d2"
|
resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-12.4.1.tgz#f78df6c80530d1a0b750b538033e6199b7d30d26"
|
||||||
integrity sha512-eGbYq2CT+tos1fBwLQ/tkBt9J5M3JEHjku4hbvQUePCckkvVf14xWj+1m7dGoK81M/fOjFT7yM9UMeKT/+vFLQ==
|
integrity sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
bindings "^1.5.0"
|
bindings "^1.5.0"
|
||||||
prebuild-install "^7.1.1"
|
prebuild-install "^7.1.1"
|
||||||
@@ -2362,10 +2362,10 @@ ccount@^2.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5"
|
resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5"
|
||||||
integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==
|
integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==
|
||||||
|
|
||||||
chai@6.0.1:
|
chai@6.2.0:
|
||||||
version "6.0.1"
|
version "6.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/chai/-/chai-6.0.1.tgz#88c2b4682fb56050647e222d2cf9d6772f2607b3"
|
resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.0.tgz#181bca6a219cddb99c3eeefb82483800ffa550ce"
|
||||||
integrity sha512-/JOoU2//6p5vCXh00FpNgtlw0LjvhGttaWc+y7wpW9yjBm3ys0dI8tSKZxIOgNruz5J0RleccatSIC3uxEZP0g==
|
integrity sha512-aUTnJc/JipRzJrNADXVvpVqi6CO0dn3nx4EVPxijri+fj3LUUDyZQOgVeW54Ob3Y1Xh9Iz8f+CgaCl8v0mn9bA==
|
||||||
|
|
||||||
chalk@^4.0.0, chalk@^4.1.0:
|
chalk@^4.0.0, chalk@^4.1.0:
|
||||||
version "4.1.2"
|
version "4.1.2"
|
||||||
@@ -2375,11 +2375,6 @@ chalk@^4.0.0, chalk@^4.1.0:
|
|||||||
ansi-styles "^4.1.0"
|
ansi-styles "^4.1.0"
|
||||||
supports-color "^7.1.0"
|
supports-color "^7.1.0"
|
||||||
|
|
||||||
chalk@^5.6.0:
|
|
||||||
version "5.6.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.6.0.tgz#a1a8d294ea3526dbb77660f12649a08490e33ab8"
|
|
||||||
integrity sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==
|
|
||||||
|
|
||||||
character-entities-html4@^2.0.0:
|
character-entities-html4@^2.0.0:
|
||||||
version "2.1.0"
|
version "2.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz#1f1adb940c971a4b22ba39ddca6b618dc6e56b2b"
|
resolved "https://registry.yarnpkg.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz#1f1adb940c971a4b22ba39ddca6b618dc6e56b2b"
|
||||||
@@ -2456,10 +2451,10 @@ chownr@^1.1.1:
|
|||||||
resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
|
resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
|
||||||
integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
|
integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
|
||||||
|
|
||||||
chromium-bidi@8.0.0:
|
chromium-bidi@9.1.0:
|
||||||
version "8.0.0"
|
version "9.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/chromium-bidi/-/chromium-bidi-8.0.0.tgz#d73c9beed40317adf2bcfeb9a47087003cd467ec"
|
resolved "https://registry.yarnpkg.com/chromium-bidi/-/chromium-bidi-9.1.0.tgz#356eaea018eecc7977644305ee9fd27874b2b676"
|
||||||
integrity sha512-d1VmE0FD7lxZQHzcDUCKZSNRtRwISXDsdg4HjdTR5+Ll5nQ/vzU12JeNmupD6VWffrPSlrnGhEWlLESKH3VO+g==
|
integrity sha512-rlUzQ4WzIAWdIbY/viPShhZU2n21CxDUgazXVbw4Hu1MwaeUSEksSeM6DqPgpRjCLXRk702AVRxJxoOz0dw4OA==
|
||||||
dependencies:
|
dependencies:
|
||||||
mitt "^3.0.1"
|
mitt "^3.0.1"
|
||||||
zod "^3.24.1"
|
zod "^3.24.1"
|
||||||
@@ -2476,13 +2471,13 @@ cli-cursor@^5.0.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
restore-cursor "^5.0.0"
|
restore-cursor "^5.0.0"
|
||||||
|
|
||||||
cli-truncate@^4.0.0:
|
cli-truncate@^5.0.0:
|
||||||
version "4.0.0"
|
version "5.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-4.0.0.tgz#6cc28a2924fee9e25ce91e973db56c7066e6172a"
|
resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-5.1.0.tgz#bb12607a62f0e4bb91a54aa4653b23347900bb55"
|
||||||
integrity sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==
|
integrity sha512-7JDGG+4Zp0CsknDCedl0DYdaeOhc46QNpXi3NLQblkZpXXgA6LncLDUUyvrjSvZeF3VRQa+KiMGomazQrC1V8g==
|
||||||
dependencies:
|
dependencies:
|
||||||
slice-ansi "^5.0.0"
|
slice-ansi "^7.1.0"
|
||||||
string-width "^7.0.0"
|
string-width "^8.0.0"
|
||||||
|
|
||||||
cliui@^8.0.1:
|
cliui@^8.0.1:
|
||||||
version "8.0.1"
|
version "8.0.1"
|
||||||
@@ -2548,10 +2543,10 @@ commander@2:
|
|||||||
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
|
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
|
||||||
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
|
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
|
||||||
|
|
||||||
commander@^14.0.0:
|
commander@^14.0.1:
|
||||||
version "14.0.0"
|
version "14.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/commander/-/commander-14.0.0.tgz#f244fc74a92343514e56229f16ef5c5e22ced5e9"
|
resolved "https://registry.yarnpkg.com/commander/-/commander-14.0.1.tgz#2f9225c19e6ebd0dc4404dd45821b2caa17ea09b"
|
||||||
integrity sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==
|
integrity sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==
|
||||||
|
|
||||||
compute-scroll-into-view@^1.0.20:
|
compute-scroll-into-view@^1.0.20:
|
||||||
version "1.0.20"
|
version "1.0.20"
|
||||||
@@ -2868,10 +2863,10 @@ devlop@^1.0.0, devlop@^1.1.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
dequal "^2.0.0"
|
dequal "^2.0.0"
|
||||||
|
|
||||||
devtools-protocol@0.0.1495869:
|
devtools-protocol@0.0.1508733:
|
||||||
version "0.0.1495869"
|
version "0.0.1508733"
|
||||||
resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1495869.tgz#f68daef77a48d5dcbcdd55dbfa3265a51989c91b"
|
resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz#047deb3531470efda2c7bf43c10b3ae9e4b3d51b"
|
||||||
integrity sha512-i+bkd9UYFis40RcnkW7XrOprCujXRAHg62IVh/Ah3G8MmNXpCGt1m0dTFhSdx/AVs8XEMbdOGRwdkR1Bcta8AA==
|
integrity sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==
|
||||||
|
|
||||||
diff@^7.0.0:
|
diff@^7.0.0:
|
||||||
version "7.0.0"
|
version "7.0.0"
|
||||||
@@ -3281,10 +3276,10 @@ eslint-visitor-keys@^4.2.1:
|
|||||||
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1"
|
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1"
|
||||||
integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==
|
integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==
|
||||||
|
|
||||||
eslint@9.35.0:
|
eslint@9.36.0:
|
||||||
version "9.35.0"
|
version "9.36.0"
|
||||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.35.0.tgz#7a89054b7b9ee1dfd1b62035d8ce75547773f47e"
|
resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.36.0.tgz#9cc5cbbfb9c01070425d9bfed81b4e79a1c09088"
|
||||||
integrity sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==
|
integrity sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@eslint-community/eslint-utils" "^4.8.0"
|
"@eslint-community/eslint-utils" "^4.8.0"
|
||||||
"@eslint-community/regexpp" "^4.12.1"
|
"@eslint-community/regexpp" "^4.12.1"
|
||||||
@@ -3292,7 +3287,7 @@ eslint@9.35.0:
|
|||||||
"@eslint/config-helpers" "^0.3.1"
|
"@eslint/config-helpers" "^0.3.1"
|
||||||
"@eslint/core" "^0.15.2"
|
"@eslint/core" "^0.15.2"
|
||||||
"@eslint/eslintrc" "^3.3.1"
|
"@eslint/eslintrc" "^3.3.1"
|
||||||
"@eslint/js" "9.35.0"
|
"@eslint/js" "9.36.0"
|
||||||
"@eslint/plugin-kit" "^0.3.5"
|
"@eslint/plugin-kit" "^0.3.5"
|
||||||
"@humanfs/node" "^0.16.6"
|
"@humanfs/node" "^0.16.6"
|
||||||
"@humanwhocodes/module-importer" "^1.0.1"
|
"@humanwhocodes/module-importer" "^1.0.1"
|
||||||
@@ -3721,6 +3716,11 @@ get-east-asian-width@^1.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz#21b4071ee58ed04ee0db653371b55b4299875389"
|
resolved "https://registry.yarnpkg.com/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz#21b4071ee58ed04ee0db653371b55b4299875389"
|
||||||
integrity sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==
|
integrity sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==
|
||||||
|
|
||||||
|
get-east-asian-width@^1.3.0:
|
||||||
|
version "1.4.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz#9bc4caa131702b4b61729cb7e42735bc550c9ee6"
|
||||||
|
integrity sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==
|
||||||
|
|
||||||
get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.2.7, get-intrinsic@^1.3.0:
|
get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.2.7, get-intrinsic@^1.3.0:
|
||||||
version "1.3.0"
|
version "1.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01"
|
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01"
|
||||||
@@ -4222,11 +4222,6 @@ is-fullwidth-code-point@^3.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
|
resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
|
||||||
integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
|
integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
|
||||||
|
|
||||||
is-fullwidth-code-point@^4.0.0:
|
|
||||||
version "4.0.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz#fae3167c729e7463f8461ce512b080a49268aa88"
|
|
||||||
integrity sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==
|
|
||||||
|
|
||||||
is-fullwidth-code-point@^5.0.0:
|
is-fullwidth-code-point@^5.0.0:
|
||||||
version "5.0.0"
|
version "5.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz#9609efced7c2f97da7b60145ef481c787c7ba704"
|
resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz#9609efced7c2f97da7b60145ef481c787c7ba704"
|
||||||
@@ -4279,6 +4274,11 @@ is-number@^7.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
|
resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
|
||||||
integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
|
integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
|
||||||
|
|
||||||
|
is-path-inside@^3.0.3:
|
||||||
|
version "3.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283"
|
||||||
|
integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==
|
||||||
|
|
||||||
is-plain-obj@^2.1.0:
|
is-plain-obj@^2.1.0:
|
||||||
version "2.1.0"
|
version "2.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287"
|
resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287"
|
||||||
@@ -4559,38 +4559,30 @@ levn@^0.4.1:
|
|||||||
prelude-ls "^1.2.1"
|
prelude-ls "^1.2.1"
|
||||||
type-check "~0.4.0"
|
type-check "~0.4.0"
|
||||||
|
|
||||||
lilconfig@^3.1.3:
|
|
||||||
version "3.1.3"
|
|
||||||
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-3.1.3.tgz#a1bcfd6257f9585bf5ae14ceeebb7b559025e4c4"
|
|
||||||
integrity sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==
|
|
||||||
|
|
||||||
lines-and-columns@^1.1.6:
|
lines-and-columns@^1.1.6:
|
||||||
version "1.2.4"
|
version "1.2.4"
|
||||||
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
|
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
|
||||||
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
|
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
|
||||||
|
|
||||||
lint-staged@16.1.6:
|
lint-staged@16.2.3:
|
||||||
version "16.1.6"
|
version "16.2.3"
|
||||||
resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-16.1.6.tgz#b0830df339a71f4207979a47c7be8ab0f38543ad"
|
resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-16.2.3.tgz#790866221d75602510507b5be40b2c7963715960"
|
||||||
integrity sha512-U4kuulU3CKIytlkLlaHcGgKscNfJPNTiDF2avIUGFCv7K95/DCYQ7Ra62ydeRWmgQGg9zJYw2dzdbztwJlqrow==
|
integrity sha512-1OnJEESB9zZqsp61XHH2fvpS1es3hRCxMplF/AJUDa8Ho8VrscYDIuxGrj3m8KPXbcWZ8fT9XTMUhEQmOVKpKw==
|
||||||
dependencies:
|
dependencies:
|
||||||
chalk "^5.6.0"
|
commander "^14.0.1"
|
||||||
commander "^14.0.0"
|
listr2 "^9.0.4"
|
||||||
debug "^4.4.1"
|
|
||||||
lilconfig "^3.1.3"
|
|
||||||
listr2 "^9.0.3"
|
|
||||||
micromatch "^4.0.8"
|
micromatch "^4.0.8"
|
||||||
nano-spawn "^1.0.2"
|
nano-spawn "^1.0.3"
|
||||||
pidtree "^0.6.0"
|
pidtree "^0.6.0"
|
||||||
string-argv "^0.3.2"
|
string-argv "^0.3.2"
|
||||||
yaml "^2.8.1"
|
yaml "^2.8.1"
|
||||||
|
|
||||||
listr2@^9.0.3:
|
listr2@^9.0.4:
|
||||||
version "9.0.3"
|
version "9.0.4"
|
||||||
resolved "https://registry.yarnpkg.com/listr2/-/listr2-9.0.3.tgz#5181284019e1d577dc2d705ca6d3a148cf15adf3"
|
resolved "https://registry.yarnpkg.com/listr2/-/listr2-9.0.4.tgz#2916e633ae6e09d1a3f981172937ac1c5a8fa64f"
|
||||||
integrity sha512-0aeh5HHHgmq1KRdMMDHfhMWQmIT/m7nRDTlxlFqni2Sp0had9baqsjJRvDGdlvgd6NmPE0nPloOipiQJGFtTHQ==
|
integrity sha512-1wd/kpAdKRLwv7/3OKC8zZ5U8e/fajCfWMxacUvB79S5nLrYGPtUI/8chMQhn3LQjsRVErTb9i1ECAwW0ZIHnQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
cli-truncate "^4.0.0"
|
cli-truncate "^5.0.0"
|
||||||
colorette "^2.0.20"
|
colorette "^2.0.20"
|
||||||
eventemitter3 "^5.0.1"
|
eventemitter3 "^5.0.1"
|
||||||
log-update "^6.1.0"
|
log-update "^6.1.0"
|
||||||
@@ -5383,10 +5375,10 @@ mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3:
|
|||||||
resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113"
|
resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113"
|
||||||
integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==
|
integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==
|
||||||
|
|
||||||
mocha@11.7.2:
|
mocha@11.7.4:
|
||||||
version "11.7.2"
|
version "11.7.4"
|
||||||
resolved "https://registry.yarnpkg.com/mocha/-/mocha-11.7.2.tgz#3c0079fe5cc2f8ea86d99124debcc42bb1ab22b5"
|
resolved "https://registry.yarnpkg.com/mocha/-/mocha-11.7.4.tgz#f161b17aeccb0762484b33bdb3f7ab9410ba5c82"
|
||||||
integrity sha512-lkqVJPmqqG/w5jmmFtiRvtA2jkDyNVUcefFJKb2uyX4dekk8Okgqop3cgbFiaIvj8uCRJVTP5x9dfxGyXm2jvQ==
|
integrity sha512-1jYAaY8x0kAZ0XszLWu14pzsf4KV740Gld4HXkhNTXwcHx4AUEDkPzgEHg9CM5dVcW+zv036tjpsEbLraPJj4w==
|
||||||
dependencies:
|
dependencies:
|
||||||
browser-stdout "^1.3.1"
|
browser-stdout "^1.3.1"
|
||||||
chokidar "^4.0.1"
|
chokidar "^4.0.1"
|
||||||
@@ -5396,6 +5388,7 @@ mocha@11.7.2:
|
|||||||
find-up "^5.0.0"
|
find-up "^5.0.0"
|
||||||
glob "^10.4.5"
|
glob "^10.4.5"
|
||||||
he "^1.2.0"
|
he "^1.2.0"
|
||||||
|
is-path-inside "^3.0.3"
|
||||||
js-yaml "^4.1.0"
|
js-yaml "^4.1.0"
|
||||||
log-symbols "^4.1.0"
|
log-symbols "^4.1.0"
|
||||||
minimatch "^9.0.5"
|
minimatch "^9.0.5"
|
||||||
@@ -5414,15 +5407,15 @@ ms@^2.1.1, ms@^2.1.3:
|
|||||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
|
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
|
||||||
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
|
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
|
||||||
|
|
||||||
nano-spawn@^1.0.2:
|
nano-spawn@^1.0.3:
|
||||||
version "1.0.2"
|
version "1.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/nano-spawn/-/nano-spawn-1.0.2.tgz#9853795681f0e96ef6f39104c2e4347b6ba79bf6"
|
resolved "https://registry.yarnpkg.com/nano-spawn/-/nano-spawn-1.0.3.tgz#ef8d89a275eebc8657e67b95fc312a6527a05b8d"
|
||||||
integrity sha512-21t+ozMQDAL/UGgQVBbZ/xXvNO10++ZPuTmKRO8k9V3AClVRht49ahtDjfY8l1q6nSHOrE5ASfthzH3ol6R/hg==
|
integrity sha512-jtpsQDetTnvS2Ts1fiRdci5rx0VYws5jGyC+4IYOTnIQ/wwdf6JdomlHBwqC3bJYOvaKu0C2GSZ1A60anrYpaA==
|
||||||
|
|
||||||
nanoid@5.1.5:
|
nanoid@5.1.6:
|
||||||
version "5.1.5"
|
version "5.1.6"
|
||||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.1.5.tgz#f7597f9d9054eb4da9548cdd53ca70f1790e87de"
|
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.1.6.tgz#30363f664797e7d40429f6c16946d6bd7a3f26c9"
|
||||||
integrity sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==
|
integrity sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==
|
||||||
|
|
||||||
nanoid@^3.3.11:
|
nanoid@^3.3.11:
|
||||||
version "3.3.11"
|
version "3.3.11"
|
||||||
@@ -5975,17 +5968,17 @@ punycode@^2.1.0:
|
|||||||
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
|
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
|
||||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||||
|
|
||||||
puppeteer-core@24.22.0:
|
puppeteer-core@24.23.0:
|
||||||
version "24.22.0"
|
version "24.23.0"
|
||||||
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.22.0.tgz#4d576b1a2b7699c088d3f0e843c32d81df82c3a6"
|
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.23.0.tgz#1f84abafa480358652ae8df340af984438173a14"
|
||||||
integrity sha512-oUeWlIg0pMz8YM5pu0uqakM+cCyYyXkHBxx9di9OUELu9X9+AYrNGGRLK9tNME3WfN3JGGqQIH3b4/E9LGek/w==
|
integrity sha512-yl25C59gb14sOdIiSnJ08XiPP+O2RjuyZmEG+RjYmCXO7au0jcLf7fRiyii96dXGUBW7Zwei/mVKfxMx/POeFw==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@puppeteer/browsers" "2.10.10"
|
"@puppeteer/browsers" "2.10.10"
|
||||||
chromium-bidi "8.0.0"
|
chromium-bidi "9.1.0"
|
||||||
debug "^4.4.3"
|
debug "^4.4.3"
|
||||||
devtools-protocol "0.0.1495869"
|
devtools-protocol "0.0.1508733"
|
||||||
typed-query-selector "^2.12.0"
|
typed-query-selector "^2.12.0"
|
||||||
webdriver-bidi-protocol "0.2.11"
|
webdriver-bidi-protocol "0.3.6"
|
||||||
ws "^8.18.3"
|
ws "^8.18.3"
|
||||||
|
|
||||||
puppeteer-extra-plugin-stealth@^2.11.2:
|
puppeteer-extra-plugin-stealth@^2.11.2:
|
||||||
@@ -6035,16 +6028,16 @@ puppeteer-extra@^3.3.6:
|
|||||||
debug "^4.1.1"
|
debug "^4.1.1"
|
||||||
deepmerge "^4.2.2"
|
deepmerge "^4.2.2"
|
||||||
|
|
||||||
puppeteer@^24.22.0:
|
puppeteer@^24.23.0:
|
||||||
version "24.22.0"
|
version "24.23.0"
|
||||||
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.22.0.tgz#9f6905e9c3d5c316c364adb598903a1dfbfe800f"
|
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.23.0.tgz#fa3c1bffc1b40c3d7a59b9463d444ff4be69f5c7"
|
||||||
integrity sha512-QabGIvu7F0hAMiKGHZCIRHMb6UoH0QAJA2OaqxEU2tL5noXPrxUcotg2l3ttOA4p1PFnVIGkr6PXRAWlM2evVQ==
|
integrity sha512-BVR1Lg8sJGKXY79JARdIssFWK2F6e1j+RyuJP66w4CUmpaXjENicmA3nNpUXA8lcTdDjAndtP+oNdni3T/qQqA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@puppeteer/browsers" "2.10.10"
|
"@puppeteer/browsers" "2.10.10"
|
||||||
chromium-bidi "8.0.0"
|
chromium-bidi "9.1.0"
|
||||||
cosmiconfig "^9.0.0"
|
cosmiconfig "^9.0.0"
|
||||||
devtools-protocol "0.0.1495869"
|
devtools-protocol "0.0.1508733"
|
||||||
puppeteer-core "24.22.0"
|
puppeteer-core "24.23.0"
|
||||||
typed-query-selector "^2.12.0"
|
typed-query-selector "^2.12.0"
|
||||||
|
|
||||||
qs@^6.14.0:
|
qs@^6.14.0:
|
||||||
@@ -6134,17 +6127,17 @@ react-resizable@^3.0.5:
|
|||||||
prop-types "15.x"
|
prop-types "15.x"
|
||||||
react-draggable "^4.0.3"
|
react-draggable "^4.0.3"
|
||||||
|
|
||||||
react-router-dom@7.9.1:
|
react-router-dom@7.9.3:
|
||||||
version "7.9.1"
|
version "7.9.3"
|
||||||
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.9.1.tgz#48044923701773da6362f9003ec46f308f293f15"
|
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.9.3.tgz#67ab1655f67b9b6108fe20ed3d4881b53dccf87a"
|
||||||
integrity sha512-U9WBQssBE9B1vmRjo9qTM7YRzfZ3lUxESIZnsf4VjR/lXYz9MHjvOxHzr/aUm4efpktbVOrF09rL/y4VHa8RMw==
|
integrity sha512-1QSbA0TGGFKTAc/aWjpfW/zoEukYfU4dc1dLkT/vvf54JoGMkW+fNA+3oyo2gWVW1GM7BxjJVHz5GnPJv40rvg==
|
||||||
dependencies:
|
dependencies:
|
||||||
react-router "7.9.1"
|
react-router "7.9.3"
|
||||||
|
|
||||||
react-router@7.9.1:
|
react-router@7.9.3:
|
||||||
version "7.9.1"
|
version "7.9.3"
|
||||||
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.9.1.tgz#b227410c31f24dd416c939ca5d0f8d5c8a1404d4"
|
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.9.3.tgz#f2d5ff6181851de3df3acb4e7364fce0dee5fba2"
|
||||||
integrity sha512-pfAByjcTpX55mqSDGwGnY9vDCpxqBLASg0BMNAuMmpSGESo/TaOUG6BllhAtAkCGx8Rnohik/XtaqiYUJtgW2g==
|
integrity sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==
|
||||||
dependencies:
|
dependencies:
|
||||||
cookie "^1.0.1"
|
cookie "^1.0.1"
|
||||||
set-cookie-parser "^2.6.0"
|
set-cookie-parser "^2.6.0"
|
||||||
@@ -6755,14 +6748,6 @@ slack@11.0.2:
|
|||||||
dependencies:
|
dependencies:
|
||||||
tiny-json-http "^7.0.2"
|
tiny-json-http "^7.0.2"
|
||||||
|
|
||||||
slice-ansi@^5.0.0:
|
|
||||||
version "5.0.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-5.0.0.tgz#b73063c57aa96f9cd881654b15294d95d285c42a"
|
|
||||||
integrity sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==
|
|
||||||
dependencies:
|
|
||||||
ansi-styles "^6.0.0"
|
|
||||||
is-fullwidth-code-point "^4.0.0"
|
|
||||||
|
|
||||||
slice-ansi@^7.1.0:
|
slice-ansi@^7.1.0:
|
||||||
version "7.1.0"
|
version "7.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-7.1.0.tgz#cd6b4655e298a8d1bdeb04250a433094b347b9a9"
|
resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-7.1.0.tgz#cd6b4655e298a8d1bdeb04250a433094b347b9a9"
|
||||||
@@ -6897,6 +6882,14 @@ string-width@^7.0.0:
|
|||||||
get-east-asian-width "^1.0.0"
|
get-east-asian-width "^1.0.0"
|
||||||
strip-ansi "^7.1.0"
|
strip-ansi "^7.1.0"
|
||||||
|
|
||||||
|
string-width@^8.0.0:
|
||||||
|
version "8.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/string-width/-/string-width-8.1.0.tgz#9e9fb305174947cf45c30529414b5da916e9e8d1"
|
||||||
|
integrity sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==
|
||||||
|
dependencies:
|
||||||
|
get-east-asian-width "^1.3.0"
|
||||||
|
strip-ansi "^7.1.0"
|
||||||
|
|
||||||
string.prototype.matchall@^4.0.12:
|
string.prototype.matchall@^4.0.12:
|
||||||
version "4.0.12"
|
version "4.0.12"
|
||||||
resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz#6c88740e49ad4956b1332a911e949583a275d4c0"
|
resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz#6c88740e49ad4956b1332a911e949583a275d4c0"
|
||||||
@@ -7421,10 +7414,10 @@ vfile@^6.0.0:
|
|||||||
"@types/unist" "^3.0.0"
|
"@types/unist" "^3.0.0"
|
||||||
vfile-message "^4.0.0"
|
vfile-message "^4.0.0"
|
||||||
|
|
||||||
vite@7.1.6:
|
vite@7.1.9:
|
||||||
version "7.1.6"
|
version "7.1.9"
|
||||||
resolved "https://registry.yarnpkg.com/vite/-/vite-7.1.6.tgz#336806d29983135677f498a05efb0fd46c5eef2d"
|
resolved "https://registry.yarnpkg.com/vite/-/vite-7.1.9.tgz#ba844410e5d0c0f2a4eaf17a52af60ebea322cbf"
|
||||||
integrity sha512-SRYIB8t/isTwNn8vMB3MR6E+EQZM/WG1aKmmIUCfDXfVvKfc20ZpamngWHKzAmmu9ppsgxsg4b2I7c90JZudIQ==
|
integrity sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild "^0.25.0"
|
esbuild "^0.25.0"
|
||||||
fdir "^6.5.0"
|
fdir "^6.5.0"
|
||||||
@@ -7440,10 +7433,10 @@ web-streams-polyfill@^3.0.3:
|
|||||||
resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b"
|
resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b"
|
||||||
integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==
|
integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==
|
||||||
|
|
||||||
webdriver-bidi-protocol@0.2.11:
|
webdriver-bidi-protocol@0.3.6:
|
||||||
version "0.2.11"
|
version "0.3.6"
|
||||||
resolved "https://registry.yarnpkg.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.2.11.tgz#dba18d9b0a33aed33fab272dbd6e42411ac753cc"
|
resolved "https://registry.yarnpkg.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.6.tgz#55ad4ff9697532e3e04fb0446bb6dd4c158b3ad5"
|
||||||
integrity sha512-Y9E1/oi4XMxcR8AT0ZC4OvYntl34SPgwjmELH+owjBr0korAX4jKgZULBWILGCVGdVCQ0dodTToIETozhG8zvA==
|
integrity sha512-mlGndEOA9yK9YAbvtxaPTqdi/kaCWYYfwrZvGzcmkr/3lWM+tQj53BxtpVd6qbC6+E5OnHXgCcAhre6AkXzxjA==
|
||||||
|
|
||||||
whatwg-encoding@^3.1.1:
|
whatwg-encoding@^3.1.1:
|
||||||
version "3.1.1"
|
version "3.1.1"
|
||||||
|
|||||||