Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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))
.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');

// 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) {
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