Compare commits

...

14 Commits

Author SHA1 Message Date
orangecoding
b858529f06 next release version 2025-10-05 18:57:52 +02:00
orangecoding
c9bd5dc161 fixing delete listings 2025-10-05 18:57:27 +02:00
orangecoding
daa4a7b8f1 refine telegram adapter 2025-10-05 18:53:17 +02:00
Thomas Brockmöller
035f0e9f83 Check Telegram response (#205) (#211)
* Add error handling and logging to Telegram message sending

* Add debug logging for new listings
2025-10-05 17:06:57 +02:00
Christian Kellner
a5efd9af32 New Feature: Watch Listings (#215)
* adding new feature: watch listings for changes

* adding todo for watch feature

* sort by watch
2025-10-05 14:23:32 +02:00
orangecoding
9f1e27d011 check if fredy config exists and is accessible 2025-10-03 17:23:46 +02:00
orangecoding
ebc57702dc next release version 2025-10-03 13:28:09 +02:00
orangecoding
3aa30bc1e2 remove listings from listingstable when clicked 2025-10-03 13:27:44 +02:00
orangecoding
f97fb48e51 Merge branch 'master' of github.com:orangecoding/fredy 2025-10-03 13:04:56 +02:00
orangecoding
4b15894603 adding buttons to remove listings from a given job 2025-10-03 13:04:35 +02:00
orangecoding
31a14a0352 improve footer and upgrade dependencies 2025-10-03 12:45:48 +02:00
Christian Kellner
eecbe91dbd Update README.md 2025-10-02 22:05:49 +02:00
orangecoding
9dd3947cb7 reverting docker file changes, adding script to test things locally 2025-10-02 09:37:01 +02:00
Iaroslav Postovalov
c151f4f76e Use non-root user in Dockerfile (#214) 2025-10-01 20:04:08 +02:00
25 changed files with 675 additions and 104 deletions

View File

@@ -9,10 +9,18 @@
</a>
</p>
![Tests](https://github.com/orangecoding/fredy/actions/workflows/test.yml/badge.svg)
[![Docker](https://github.com/orangecoding/fredy/actions/workflows/docker.yml/badge.svg)](https://github.com/orangecoding/fredy/actions/workflows/docker.yml)
![Source](https://github.com/orangecoding/fredy/actions/workflows/check_source.yml/badge.svg)
![Docker Pulls](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)
<p align="center">
<a href="https://fredy.orange-coding.net/" target="_blank">Website</a>&nbsp;&nbsp;|&nbsp;&nbsp;
<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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 331 KiB

After

Width:  |  Height:  |  Size: 512 KiB

18
docker-test.sh Normal file
View 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

View File

@@ -1,6 +1,6 @@
import fs from 'fs';
import path from 'path';
import { config, getProviders, refreshConfig } 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 jobStorage from './lib/services/storage/jobStorage.js';
import FredyRuntime from './lib/FredyRuntime.js';
@@ -16,6 +16,13 @@ import { initActiveCheckerCron } from './lib/services/crons/listing-alive-cron.j
// 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)
const rawDir = config.sqlitepath || '/db';
const relDir = rawDir.startsWith('/') ? rawDir.slice(1) : rawDir;

View File

@@ -77,6 +77,7 @@ class FredyRuntime {
}
_findNew(listings) {
logger.debug(`Checking ${listings.length} listings for new entries (Provider: '${this._providerId}')`);
const hashes = getKnownListingHashesForJobAndProvider(this._jobKey, this._providerId) || [];
const newListings = listings.filter((o) => !hashes.includes(o.id));
@@ -95,6 +96,7 @@ class FredyRuntime {
}
_save(newListings) {
logger.debug(`Storing ${newListings.length} new listings (Provider: '${this._providerId}')`);
storeListings(this._jobKey, this._providerId, newListings);
return newListings;
}
@@ -103,7 +105,9 @@ class FredyRuntime {
const filteredList = listings.filter((listing) => {
const similar = this._similarityCache.hasSimilarEntries(listing.title, listing.address);
if (similar) {
logger.debug(`Filtering similar entry for title: ${listing.title} and address ${listing.address}`);
logger.debug(
`Filtering similar entry for title '${listing.title}' and address '${listing.address}' (Provider: '${this._providerId}')`,
);
}
return !similar;
});
@@ -112,7 +116,11 @@ class FredyRuntime {
}
_handleError(err) {
if (err.name !== 'NoNewListingsWarning') logger.error(err);
if (err.name === 'NoNewListingsWarning') {
logger.debug(`No new listings found (Provider: '${this._providerId}').`);
} else {
logger.error(err);
}
}
}

View File

@@ -1,23 +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, filter, sortfield = null, sortdir = 'asc' } = req.query || {};
const {
page,
pageSize = 50,
activityFilter,
jobNameFilter,
providerFilter,
watchListFilter,
sortfield = null,
sortdir = 'asc',
freeTextFilter,
} = req.query || {};
const result = listingStorage.queryListings({
// 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,
filter: filter || undefined,
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.body = result;
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 };

View File

@@ -3,10 +3,14 @@ import { getJob } from '../../services/storage/jobStorage.js';
import fetch from 'node-fetch';
import pThrottle from 'p-throttle';
import { normalizeImageUrl } from '../../utils.js';
import logger from '../../services/logger.js';
const RATE_LIMIT_INTERVAL = 1000;
const chatThrottleMap = new Map();
/**
* Removes stale throttled call entries to keep memory bounded.
*/
function cleanupOldThrottles() {
const now = Date.now();
const maxAge = RATE_LIMIT_INTERVAL + 1000;
@@ -17,6 +21,15 @@ function cleanupOldThrottles() {
for (const chatId of toBeDeleted) chatThrottleMap.delete(chatId);
}
/**
* Return a throttled wrapper for a chatId to limit Telegram API calls.
* Uses p-throttle with 1 request per RATE_LIMIT_INTERVAL per chat.
*
* @template {Function} T
* @param {string|number} chatId
* @param {T} call - async function (endpoint: string, body: any) => Promise<Response>
* @returns {T}
*/
function getThrottled(chatId, call) {
cleanupOldThrottles();
const now = Date.now();
@@ -30,15 +43,38 @@ function getThrottled(chatId, call) {
return throttled;
}
/**
* Shorten a string to a maximum length with an ellipsis suffix.
* @param {string} str
* @param {number} [len=90]
* @returns {string}
*/
function shorten(str, len = 90) {
if (!str) return '';
return str.length > len ? str.substring(0, len).trim() + '...' : str;
}
/**
* Escape basic HTML entities for Telegram HTML parse mode.
* @param {string} [s='']
* @returns {string}
*/
function escapeHtml(s = '') {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/**
* Build a Telegram photo caption (max 1024 characters) using HTML parse mode.
* @param {string} jobName
* @param {string} serviceName
* @param {Object} o - Listing object
* @param {string} [o.title]
* @param {string} [o.address]
* @param {string|number} [o.price]
* @param {string|number} [o.size]
* @param {string} [o.link]
* @returns {string}
*/
function buildCaption(jobName, serviceName, o) {
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
@@ -47,6 +83,13 @@ function buildCaption(jobName, serviceName, o) {
)}'><b>${escapeHtml(title)}</b></a>\n${escapeHtml(meta)}`.slice(0, 1024);
}
/**
* Build a Telegram message text using HTML parse mode.
* @param {string} jobName
* @param {string} serviceName
* @param {Object} o - Listing object
* @returns {string}
*/
function buildText(jobName, serviceName, o) {
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
@@ -57,8 +100,27 @@ function buildText(jobName, serviceName, o) {
);
}
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
const { token, chatId } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
/**
* Send new listings to Telegram.
* - Respects per-chat Telegram rate limits using a lightweight throttle cache.
* - Falls back to sendMessage when sendPhoto fails or image is missing.
*
* @param {Object} params
* @param {string} params.serviceName - Name of the crawler/service producing the listings.
* @param {Array<Object>} params.newListings - Array of new listing objects.
* @param {Array<Object>} params.notificationConfig - Notification adapters configuration array.
* @param {string} params.jobKey - Storage job key to resolve the human readable job name.
* @returns {Promise<Array<Response>>} Promise resolving when all send operations complete.
*/
export const send = ({ serviceName, newListings = [], notificationConfig, jobKey }) => {
const adapterCfg = notificationConfig.find((adapter) => adapter.id === config.id);
if (!adapterCfg || !adapterCfg.fields) {
throw new Error(`Telegram adapter configuration missing for job '${jobKey || ''}'`);
}
const { token, chatId } = adapterCfg.fields;
if (!token || !chatId) {
throw new Error("Telegram 'token' and 'chatId' must be provided in notification config");
}
const job = getJob(jobKey);
const jobName = job == null ? jobKey : job.name;
@@ -68,9 +130,16 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok) {
const errorBody = await res.text();
throw new Error(`API error for '${jobName}'. '${endpoint}' returned ${errorBody}`);
}
return res;
});
if (!Array.isArray(newListings) || newListings.length === 0) return Promise.resolve([]);
const promises = newListings.map(async (o) => {
const img = normalizeImageUrl(o.image);
const textPayload = {
@@ -81,28 +150,32 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
};
if (!img) {
return throttledCall('sendMessage', textPayload);
return await throttledCall('sendMessage', textPayload).catch(async (e) => {
logger.error(`Error sending message to Telegram: ${e.message}`);
});
}
try {
return await throttledCall('sendPhoto', {
chat_id: chatId,
photo: img,
caption: buildCaption(jobName, serviceName, o),
parse_mode: 'HTML',
return await throttledCall('sendPhoto', {
chat_id: chatId,
photo: img,
caption: buildCaption(jobName, serviceName, o),
parse_mode: 'HTML',
}).catch(async (e) => {
logger.error(`Error sending photo to Telegram and use a fallback: ${e.message}`);
return await throttledCall('sendMessage', textPayload).catch((e) => {
logger.error(`Error sending message to Telegram: ${e.message}`);
throw e;
});
} catch (e) {
// If we see a timeout due to sending an image, try sending it without
if (e && (e.code === 'ETIMEDOUT' || e.errno === 'ETIMEDOUT')) {
return throttledCall('sendMessage', textPayload);
}
throw e;
}
});
});
return Promise.all(promises);
};
/**
* Telegram notification adapter configuration schema.
* @type {{id:string,name:string,readme:string,description:string,fields:{token:{type:string,label:string,description:string},chatId:{type:string,label:string,description:string}}}}
*/
export const config = {
id: 'telegram',
name: 'Telegram',

View File

@@ -48,7 +48,8 @@ export const getKnownListingHashesForJobAndProvider = (jobId, providerId) => {
return SqliteConnection.query(
`SELECT hash
FROM listings
WHERE job_id = @jobId AND provider = @providerId`,
WHERE job_id = @jobId
AND provider = @providerId`,
{ jobId, providerId },
).map((r) => r.hash);
};
@@ -63,7 +64,9 @@ export const getActiveOrUnknownListings = () => {
return SqliteConnection.query(
`SELECT *
FROM listings
WHERE is_active is null OR is_active = 1 ORDER BY provider`,
WHERE is_active is null
OR is_active = 1
ORDER BY provider`,
);
};
@@ -173,7 +176,11 @@ export const storeListings = (jobId, providerId, listings) => {
* @param {Object} params
* @param {number} [params.pageSize=50]
* @param {number} [params.page=1]
* @param {string} [params.filter]
* @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).
@@ -183,7 +190,12 @@ export const storeListings = (jobId, providerId, listings) => {
export const queryListings = ({
pageSize = 50,
page = 1,
filter,
activityFilter,
jobNameFilter,
jobIdFilter,
providerFilter,
watchListFilter,
freeTextFilter,
sortField = null,
sortDir = 'asc',
userId = null,
@@ -197,15 +209,39 @@ export const queryListings = ({
// 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) {
params.userId = userId || '__NO_USER__';
whereParts.push(`(j.user_id = @userId)`);
}
if (filter && String(filter).trim().length > 0) {
params.filter = `%${String(filter).trim()}%`;
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')
@@ -213,10 +249,13 @@ export const queryListings = ({
.replace(/\baddress\b/g, 'l.address')
.replace(/\bprovider\b/g, 'l.provider')
.replace(/\blink\b/g, 'l.link')
.replace(/\bj\.user_id\b/g, 'j.user_id');
.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']);
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';
@@ -226,28 +265,67 @@ export const queryListings = ({
.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');
.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
${whereSqlWithAlias}`,
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
`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
${whereSqlWithAlias}
${orderSqlWithAlias}
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,
);
};

View File

@@ -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;
`);
}

View 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);
`);
}

View 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 };
};

View File

@@ -180,6 +180,23 @@ function buildHash(...inputs) {
*/
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.
* @returns {Promise<any>} Parsed configuration object.

View File

@@ -1,6 +1,6 @@
{
"name": "fredy",
"version": "14.0.0",
"version": "14.1.1",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": {
"prepare": "husky",
@@ -76,7 +76,7 @@
"node-mailjet": "6.0.9",
"p-throttle": "^8.0.0",
"package-up": "^5.0.0",
"puppeteer": "^24.22.3",
"puppeteer": "^24.23.0",
"puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"query-string": "9.3.1",
@@ -88,7 +88,7 @@
"semver": "^7.7.2",
"serve-static": "2.2.0",
"slack": "11.0.2",
"vite": "7.1.7",
"vite": "7.1.9",
"x-var": "^3.0.1",
"zustand": "^5.0.8"
},
@@ -106,7 +106,7 @@
"husky": "9.1.7",
"less": "4.4.1",
"lint-staged": "16.2.3",
"mocha": "11.7.2",
"mocha": "11.7.4",
"nodemon": "^3.1.10",
"prettier": "3.6.2"
}

View File

@@ -4,6 +4,7 @@
display: flex;
justify-content: space-between;
align-items: center;
height: 1.7rem;
&__version {
padding-left: .5rem;

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { Button, Empty, Table, Switch } from '@douyinfe/semi-ui';
import { IconDelete, IconEdit, IconHistogram } from '@douyinfe/semi-icons';
import { Button, Empty, Table, Switch, Popover } from '@douyinfe/semi-ui';
import { IconDelete, IconDescend2, IconEdit, IconHistogram } from '@douyinfe/semi-icons';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
import './JobTable.less';
@@ -14,7 +14,16 @@ const empty = (
/>
);
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 (
<Table
pagination={false}
@@ -58,9 +67,18 @@ export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged,
render: (_, job) => {
return (
<div className="interactions">
<Button type="primary" icon={<IconHistogram />} onClick={() => onJobInsight(job.id)} />
<Button type="secondary" icon={<IconEdit />} onClick={() => onJobEdit(job.id)} />
<Button type="danger" icon={<IconDelete />} onClick={() => onJobRemoval(job.id)} />
<Popover content={getPopoverContent('Job Insights')}>
<Button type="primary" icon={<IconHistogram />} onClick={() => onJobInsight(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>
);
},

View File

@@ -5,6 +5,11 @@
gap: 1rem;
}
.jobPopoverContent {
padding: 1rem;
color: white;
}
@media (min-width: 768px) {
.interactions {
flex-direction: initial;

View 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>
);
}

View File

@@ -0,0 +1,4 @@
.listingsFilter {
margin-bottom: 1rem;
background: rgb(53, 54, 60);
}

View File

@@ -1,20 +1,87 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Table, Popover, Input, Descriptions, Tag, Image, Empty } from '@douyinfe/semi-ui';
import { useActions, useSelector } from '../../services/state/store.js';
import { IconClose, IconSearch, IconTick } from '@douyinfe/semi-icons';
import * as timeService from '../../services/time/timeService.js';
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 no_image from '../../../assets/no_image.jpg';
import './ListingsTable.less';
import { format } from '../../services/time/timeService.js';
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: [row.id] });
Toast.success('Listing(s) successfully removed');
row.reloadTable();
} catch (error) {
Toast.error(error);
}
}}
/>
</Popover>
</div>
);
},
},
{
title: 'State',
dataIndex: 'is_active',
width: 58,
width: 84,
sorter: true,
render: (value) => {
return value ? (
@@ -24,7 +91,7 @@ const columns = [
padding: '.4rem',
color: 'var(--semi-color-white)',
}}
content="Listing still online"
content="Listing is still active"
>
<IconTick />
</Popover>
@@ -36,7 +103,7 @@ const columns = [
padding: '.4rem',
color: 'var(--semi-color-white)',
}}
content="Listing not online anymore"
content="Listing is inactive"
>
<IconClose />
</Popover>
@@ -47,15 +114,16 @@ const columns = [
{
title: 'Job-Name',
sorter: true,
ellipsis: true,
dataIndex: 'job_name',
width: 170,
width: 150,
},
{
title: 'Listing date',
width: 130,
dataIndex: 'created_at',
sorter: true,
render: (text) => timeService.format(text),
render: (text) => timeService.format(text, false),
},
{
title: 'Provider',
@@ -81,6 +149,7 @@ const columns = [
title: 'Title',
dataIndex: 'title',
sorter: true,
ellipsis: true,
render: (text, row) => {
return (
<a href={row.url} target="_blank" rel="noopener noreferrer">
@@ -105,13 +174,17 @@ export default function ListingsTable() {
const [page, setPage] = useState(1);
const pageSize = 10;
const [sortData, setSortData] = useState({});
const [filter, setFilter] = useState(null);
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);
};
useEffect(() => {
const loadTable = () => {
let sortfield = null;
let sortdir = null;
@@ -119,10 +192,21 @@ export default function ListingsTable() {
sortfield = sortData.field;
sortdir = sortData.direction;
}
actions.listingsTable.getListingsTable({ page, pageSize, sortfield, sortdir, filter });
}, [page, sortData, filter]);
actions.listingsTable.getListingsTable({
page,
pageSize,
sortfield,
sortdir,
freeTextFilter,
filter: { watchListFilter, jobNameFilter, activityFilter, providerFilter },
});
};
const handleFilterChange = useMemo(() => debounce((value) => setFilter(value), 500), []);
useEffect(() => {
loadTable();
}, [page, sortData, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]);
const handleFilterChange = useMemo(() => debounce((value) => setFreeTextFilter(value), 500), []);
const expandRowRender = (record) => {
return (
@@ -158,6 +242,12 @@ export default function ListingsTable() {
return (
<div>
<ListingsFilter
onActivityFilter={setActivityFilter}
onWatchListFilter={setWatchListFilter}
onJobNameFilter={setJobNameFilter}
onProviderFilter={setProviderFilter}
/>
<Input
prefix={<IconSearch />}
showClear
@@ -172,7 +262,12 @@ export default function ListingsTable() {
sticky={{ top: 5 }}
columns={columns}
expandedRowRender={expandRowRender}
dataSource={tableData?.result || []}
dataSource={(tableData?.result || []).map((row) => {
return {
...row,
reloadTable: loadTable,
};
})}
onChange={(changeSet) => {
if (changeSet?.extra?.changeType === 'sorter') {
setSortData({

View File

@@ -7,4 +7,8 @@
display: flex;
gap: 1rem;
}
&__toolbar {
margin-bottom: 1rem;
}
}

View File

@@ -132,14 +132,22 @@ export const useFredyState = create(
},
},
listingsTable: {
async getListingsTable({ page = 1, pageSize = 20, filter = null, sortfield = null, sortdir = 'asc' }) {
async getListingsTable({
page = 1,
pageSize = 20,
freeTextFilter = null,
sortfield = null,
sortdir = 'asc',
filter,
}) {
try {
const qryString = queryString.stringify({
page,
pageSize,
filter,
freeTextFilter,
sortfield,
sortdir,
...filter,
});
const response = await xhrGet(`/api/listings/table?${qryString}`);
set((state) => ({

View File

@@ -1,11 +1,12 @@
export function format(ts) {
export function format(ts, showSeconds = true) {
return new Intl.DateTimeFormat('default', {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
...(showSeconds ? { second: 'numeric' } : {}),
}).format(ts);
}
export const roundToHour = (ts) => Math.ceil(ts / (1000 * 60 * 60)) * (1000 * 60 * 60);

View File

@@ -16,7 +16,17 @@ export default function Jobs() {
const onJobRemoval = async (jobId) => {
try {
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();
} catch (error) {
Toast.error(error);
@@ -49,6 +59,7 @@ export default function Jobs() {
<JobTable
jobs={jobs || []}
onJobRemoval={onJobRemoval}
onListingRemoval={onListingRemoval}
onJobStatusChanged={onJobStatusChanged}
onJobInsight={(jobId) => navigate(`/jobs/insights/${jobId}`)}
onJobEdit={(jobId) => navigate(`/jobs/edit/${jobId}`)}

View File

@@ -1,6 +1,6 @@
import React from 'react';
import ListingsTable from '../../components/table/ListingsTable.jsx';
import ListingsTable from '../../components/table/listings/ListingsTable.jsx';
export default function Listings() {
return (

View File

@@ -2863,10 +2863,10 @@ devlop@^1.0.0, devlop@^1.1.0:
dependencies:
dequal "^2.0.0"
devtools-protocol@0.0.1495869:
version "0.0.1495869"
resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1495869.tgz#f68daef77a48d5dcbcdd55dbfa3265a51989c91b"
integrity sha512-i+bkd9UYFis40RcnkW7XrOprCujXRAHg62IVh/Ah3G8MmNXpCGt1m0dTFhSdx/AVs8XEMbdOGRwdkR1Bcta8AA==
devtools-protocol@0.0.1508733:
version "0.0.1508733"
resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz#047deb3531470efda2c7bf43c10b3ae9e4b3d51b"
integrity sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==
diff@^7.0.0:
version "7.0.0"
@@ -4274,6 +4274,11 @@ is-number@^7.0.0:
resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
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:
version "2.1.0"
resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287"
@@ -5370,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"
integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==
mocha@11.7.2:
version "11.7.2"
resolved "https://registry.yarnpkg.com/mocha/-/mocha-11.7.2.tgz#3c0079fe5cc2f8ea86d99124debcc42bb1ab22b5"
integrity sha512-lkqVJPmqqG/w5jmmFtiRvtA2jkDyNVUcefFJKb2uyX4dekk8Okgqop3cgbFiaIvj8uCRJVTP5x9dfxGyXm2jvQ==
mocha@11.7.4:
version "11.7.4"
resolved "https://registry.yarnpkg.com/mocha/-/mocha-11.7.4.tgz#f161b17aeccb0762484b33bdb3f7ab9410ba5c82"
integrity sha512-1jYAaY8x0kAZ0XszLWu14pzsf4KV740Gld4HXkhNTXwcHx4AUEDkPzgEHg9CM5dVcW+zv036tjpsEbLraPJj4w==
dependencies:
browser-stdout "^1.3.1"
chokidar "^4.0.1"
@@ -5383,6 +5388,7 @@ mocha@11.7.2:
find-up "^5.0.0"
glob "^10.4.5"
he "^1.2.0"
is-path-inside "^3.0.3"
js-yaml "^4.1.0"
log-symbols "^4.1.0"
minimatch "^9.0.5"
@@ -5962,17 +5968,17 @@ punycode@^2.1.0:
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
puppeteer-core@24.22.3:
version "24.22.3"
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.22.3.tgz#63285a37da6e2c44069c0b31f2171f8ab81bbe23"
integrity sha512-M/Jhg4PWRANSbL/C9im//Yb55wsWBS5wdp+h59iwM+EPicVQQCNs56iC5aEAO7avfDPRfxs4MM16wHjOYHNJEw==
puppeteer-core@24.23.0:
version "24.23.0"
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.23.0.tgz#1f84abafa480358652ae8df340af984438173a14"
integrity sha512-yl25C59gb14sOdIiSnJ08XiPP+O2RjuyZmEG+RjYmCXO7au0jcLf7fRiyii96dXGUBW7Zwei/mVKfxMx/POeFw==
dependencies:
"@puppeteer/browsers" "2.10.10"
chromium-bidi "9.1.0"
debug "^4.4.3"
devtools-protocol "0.0.1495869"
devtools-protocol "0.0.1508733"
typed-query-selector "^2.12.0"
webdriver-bidi-protocol "0.2.11"
webdriver-bidi-protocol "0.3.6"
ws "^8.18.3"
puppeteer-extra-plugin-stealth@^2.11.2:
@@ -6022,16 +6028,16 @@ puppeteer-extra@^3.3.6:
debug "^4.1.1"
deepmerge "^4.2.2"
puppeteer@^24.22.3:
version "24.22.3"
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.22.3.tgz#07dcfabdb4e924b014cb7b96bcc92f43086e637e"
integrity sha512-mnhXzIqSYSJ1SMv1RYH07YMzWP81xCmmQj91Q8iQMZqnf97eVzeHgsGL6kpywiGCi+nQafta/+NkwM4URMy/XQ==
puppeteer@^24.23.0:
version "24.23.0"
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.23.0.tgz#fa3c1bffc1b40c3d7a59b9463d444ff4be69f5c7"
integrity sha512-BVR1Lg8sJGKXY79JARdIssFWK2F6e1j+RyuJP66w4CUmpaXjENicmA3nNpUXA8lcTdDjAndtP+oNdni3T/qQqA==
dependencies:
"@puppeteer/browsers" "2.10.10"
chromium-bidi "9.1.0"
cosmiconfig "^9.0.0"
devtools-protocol "0.0.1495869"
puppeteer-core "24.22.3"
devtools-protocol "0.0.1508733"
puppeteer-core "24.23.0"
typed-query-selector "^2.12.0"
qs@^6.14.0:
@@ -7408,10 +7414,10 @@ vfile@^6.0.0:
"@types/unist" "^3.0.0"
vfile-message "^4.0.0"
vite@7.1.7:
version "7.1.7"
resolved "https://registry.yarnpkg.com/vite/-/vite-7.1.7.tgz#ed3f9f06e21d6574fe1ad425f6b0912d027ffc13"
integrity sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==
vite@7.1.9:
version "7.1.9"
resolved "https://registry.yarnpkg.com/vite/-/vite-7.1.9.tgz#ba844410e5d0c0f2a4eaf17a52af60ebea322cbf"
integrity sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==
dependencies:
esbuild "^0.25.0"
fdir "^6.5.0"
@@ -7427,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"
integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==
webdriver-bidi-protocol@0.2.11:
version "0.2.11"
resolved "https://registry.yarnpkg.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.2.11.tgz#dba18d9b0a33aed33fab272dbd6e42411ac753cc"
integrity sha512-Y9E1/oi4XMxcR8AT0ZC4OvYntl34SPgwjmELH+owjBr0korAX4jKgZULBWILGCVGdVCQ0dodTToIETozhG8zvA==
webdriver-bidi-protocol@0.3.6:
version "0.3.6"
resolved "https://registry.yarnpkg.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.6.tgz#55ad4ff9697532e3e04fb0446bb6dd4c158b3ad5"
integrity sha512-mlGndEOA9yK9YAbvtxaPTqdi/kaCWYYfwrZvGzcmkr/3lWM+tQj53BxtpVd6qbC6+E5OnHXgCcAhre6AkXzxjA==
whatwg-encoding@^3.1.1:
version "3.1.1"