Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions lib/FredyPipelineExecutioner.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { geocodeAddress } from './services/geocoding/geoCodingService.js';
import { distanceMeters } from './services/listings/distanceCalculator.js';
import { getUserSettings } from './services/storage/settingsStorage.js';
import { updateListingDistance } from './services/storage/listingsStorage.js';
import booleanPointInPolygon from '@turf/boolean-point-in-polygon';

/**
* @typedef {Object} Listing
Expand Down Expand Up @@ -58,16 +59,17 @@ class FredyPipelineExecutioner {
* @param {(raw:any)=>Listing} providerConfig.normalize Function to convert raw scraped data into a Listing shape.
* @param {(listing:Listing)=>boolean} providerConfig.filter Function to filter out unwanted listings.
* @param {(url:string, waitForSelector?:string)=>Promise<void>|Promise<Listing[]>} [providerConfig.getListings] Optional override to fetch listings.
*
* @param {Object} notificationConfig Notification configuration passed to notification adapters.
* @param {Object} spatialFilter Optional spatial filter configuration.
* @param {string} providerId The ID of the provider currently in use.
* @param {string} jobKey Key of the job that is currently running (from within the config).
* @param {SimilarityCache} similarityCache Cache instance for checking similar entries.
* @param browser
*/
constructor(providerConfig, notificationConfig, providerId, jobKey, similarityCache, browser) {
constructor(providerConfig, notificationConfig, spatialFilter, providerId, jobKey, similarityCache, browser) {
this._providerConfig = providerConfig;
this._notificationConfig = notificationConfig;
this._spatialFilter = spatialFilter;
this._providerId = providerId;
this._jobKey = jobKey;
this._similarityCache = similarityCache;
Expand All @@ -87,6 +89,7 @@ class FredyPipelineExecutioner {
.then(this._filter.bind(this))
.then(this._findNew.bind(this))
.then(this._geocode.bind(this))
.then(this._filterByArea.bind(this))
.then(this._save.bind(this))
Comment on lines +92 to 93

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Store dedup state before applying area filter

Running _filterByArea before _save means listings outside the polygon are never persisted, so _findNew cannot mark them as seen on later runs. In jobs with an active spatial filter, the same out-of-area listings are reprocessed and re-geocoded every execution, which can repeatedly hit Nominatim and degrade run time until rate limiting kicks in. Persisting hash/dedup information before area filtering (or otherwise recording filtered-out IDs) avoids this repeated external work.

Useful? React with 👍 / 👎.

.then(this._calculateDistance.bind(this))
.then(this._filterBySimilarListings.bind(this))
Expand All @@ -113,6 +116,38 @@ class FredyPipelineExecutioner {
return newListings;
}

/**
* Filter listings by area using the provider's area filter if available.
* Only filters if areaFilter is set on the provider AND the listing has coordinates.
*
* @param {Listing[]} newListings New listings to filter by area.
* @returns {Promise<Listing[]>} Resolves with listings that are within the area (or not filtered if no area is set).
*/
_filterByArea(newListings) {
const polygonFeatures = this._spatialFilter?.features?.filter((f) => f.geometry?.type === 'Polygon');
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As codex said, this is a rather powerful feature, but you probably want to store it and put int just BEFORE the notification so that the entry itself is stored but not forwarded. Otherwise it is going to be rescraped all the time


// If no area filter is set, return all listings
if (!polygonFeatures?.length) {
return newListings;
}

// Filter listings by area - keep only those within the polygon
const filteredListings = newListings.filter((listing) => {
// If listing doesn't have coordinates, keep it (don't filter out)
if (listing.latitude == null || listing.longitude == null) {
return true;
}

// Check if the point is inside the polygons
const point = [listing.longitude, listing.latitude]; // GeoJSON format: [lon, lat]
const isInPolygon = polygonFeatures.some((feature) => booleanPointInPolygon(point, feature));

return isInPolygon;
});

return filteredListings;
}

/**
* Fetch listings from the provider, using the default Extractor flow unless
* a provider-specific getListings override is supplied.
Expand Down
12 changes: 11 additions & 1 deletion lib/api/routes/jobRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,16 @@ jobRouter.post('/:jobId/run', async (req, res) => {
});

jobRouter.post('/', async (req, res) => {
const { provider, notificationAdapter, name, blacklist = [], jobId, enabled, shareWithUsers = [] } = req.body;
const {
provider,
notificationAdapter,
name,
blacklist = [],
jobId,
enabled,
shareWithUsers = [],
spatialFilter = null,
} = req.body;
const settings = await getSettings();
try {
let jobFromDb = jobStorage.getJob(jobId);
Expand All @@ -187,6 +196,7 @@ jobRouter.post('/', async (req, res) => {
provider,
notificationAdapter,
shareWithUsers,
spatialFilter,
});
} catch (error) {
res.send(new Error(error));
Expand Down
1 change: 1 addition & 0 deletions lib/services/jobs/jobExecutionService.js
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
await new FredyPipelineExecutioner(
matchedProvider.config,
job.notificationAdapter,
job.spatialFilter,
prov.id,
job.id,
similarityCache,
Expand Down
16 changes: 13 additions & 3 deletions lib/services/storage/jobStorage.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const upsertJob = ({
notificationAdapter,
userId,
shareWithUsers = [],
spatialFilter = null,
}) => {
const id = jobId || nanoid();
const existing = SqliteConnection.query(`SELECT id, user_id FROM jobs WHERE id = @id LIMIT 1`, { id })[0];
Expand All @@ -42,7 +43,8 @@ export const upsertJob = ({
blacklist = @blacklist,
provider = @provider,
notification_adapter = @notification_adapter,
shared_with_user = @shareWithUsers
shared_with_user = @shareWithUsers,
spatial_filter = @spatialFilter
WHERE id = @id`,
{
id,
Expand All @@ -52,12 +54,13 @@ export const upsertJob = ({
shareWithUsers: toJson(shareWithUsers ?? []),
provider: toJson(provider ?? []),
notification_adapter: toJson(notificationAdapter ?? []),
spatialFilter: spatialFilter ? toJson(spatialFilter) : null,
},
);
} else {
SqliteConnection.execute(
`INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter, shared_with_user)
VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter, @shareWithUsers)`,
`INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter, shared_with_user, spatial_filter)
VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter, @shareWithUsers, @spatialFilter)`,
{
id,
user_id: ownerId,
Expand All @@ -67,6 +70,7 @@ export const upsertJob = ({
provider: toJson(provider ?? []),
shareWithUsers: toJson(shareWithUsers ?? []),
notification_adapter: toJson(notificationAdapter ?? []),
spatialFilter: spatialFilter ? toJson(spatialFilter) : null,
},
);
}
Expand All @@ -87,6 +91,7 @@ export const getJob = (jobId) => {
j.provider,
j.shared_with_user,
j.notification_adapter AS notificationAdapter,
j.spatial_filter AS spatialFilter,
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
FROM jobs j
WHERE j.id = @id
Expand All @@ -101,6 +106,7 @@ export const getJob = (jobId) => {
provider: fromJson(row.provider, []),
shared_with_user: fromJson(row.shared_with_user, []),
notificationAdapter: fromJson(row.notificationAdapter, []),
spatialFilter: fromJson(row.spatialFilter, null),
};
};

Expand Down Expand Up @@ -150,6 +156,7 @@ export const getJobs = () => {
j.provider,
j.shared_with_user,
j.notification_adapter AS notificationAdapter,
j.spatial_filter AS spatialFilter,
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
FROM jobs j
WHERE j.enabled = 1
Expand All @@ -162,6 +169,7 @@ export const getJobs = () => {
provider: fromJson(row.provider, []),
shared_with_user: fromJson(row.shared_with_user, []),
notificationAdapter: fromJson(row.notificationAdapter, []),
spatialFilter: fromJson(row.spatialFilter, null),
}));
};

Expand Down Expand Up @@ -251,6 +259,7 @@ export const queryJobs = ({
j.provider,
j.shared_with_user,
j.notification_adapter AS notificationAdapter,
j.spatial_filter AS spatialFilter,
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
FROM jobs j
${whereSql}
Expand All @@ -266,6 +275,7 @@ export const queryJobs = ({
provider: fromJson(row.provider, []),
shared_with_user: fromJson(row.shared_with_user, []),
notificationAdapter: fromJson(row.notificationAdapter, []),
spatialFilter: fromJson(row.spatialFilter, null),
}));

return { totalNumber, page: safePage, result };
Expand Down
17 changes: 17 additions & 0 deletions lib/services/storage/migrations/sql/11.add-spatial-filter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/

// Migration: Add spatial_filter column to jobs table for storing GeoJSON-based spatial filters
export function up(db) {
db.exec(`
ALTER TABLE jobs ADD COLUMN spatial_filter JSONB DEFAULT NULL;
`);
}

export function down(db) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A down function is not needed..

db.exec(`
ALTER TABLE jobs DROP COLUMN spatial_filter;
`);
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,15 @@
"@douyinfe/semi-icons": "^2.91.0",
"@douyinfe/semi-ui": "2.91.0",
"@douyinfe/semi-ui-19": "^2.91.0",
"@mapbox/mapbox-gl-draw": "^1.5.1",
"@sendgrid/mail": "8.1.6",
"@vitejs/plugin-react": "5.1.4",
"adm-zip": "^0.5.16",
"better-sqlite3": "^12.6.2",
"body-parser": "2.2.2",
"chart.js": "^4.5.1",
"cheerio": "^1.2.0",
"@turf/boolean-point-in-polygon": "^7.0.0",
"cookie-session": "2.1.1",
"handlebars": "4.7.8",
"lodash": "4.17.23",
Expand Down
Loading
Loading