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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ Fredy is in the [Unraid](https://unraid.net/) community store.
- 📎 **Document uploads**: attach the exposé, floor plans and photos to a listing, so they survive
the portal taking the ad down. A listing with documents is never cleaned up automatically
- 🖥️ **Web UI** in several languages, with configurable search intervals and working hours
- **Location exclusions per job**: enter district names or postal codes beside the job's blacklist
to reject new listings whose address contains them, ignoring case. Each search has its own list.
Addresses are checked before and after detail enrichment; unknown addresses remain allowed.
Existing listings are kept, but their matching price-change alerts are suppressed for that job.
- 🤖 **[MCP server](lib/mcp/README.md)**: query your listings from Claude, ChatGPT or a local LLM
- 🌍 Runs anywhere: Docker, Node.js, self-hosted, and uses the reverse engineered
**ImmoScout Mobile API**
Expand Down
35 changes: 29 additions & 6 deletions lib/FredyPipelineExecutioner.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { currencyForCountries, formatListing } from './utils/formatListing.js';
import { addressesWithBudget, exceedsCommuteBudget, normalizeCommuteFilter } from './utils/commuteBudget.js';
import { sleep } from './utils.js';
import { enqueuePreviewCapture } from './services/listings/previewCaptureQueue.js';
import { compileExcludedLocations, matchesExcludedLocation } from './utils/excludedLocations.js';

/** The placeholder ImmoScout writes for a listing without an address; never worth a lookup. */
const NO_ADDRESS = 'NO ADDRESS FOUND';
Expand Down Expand Up @@ -79,12 +80,13 @@ function isLocated(listing) {
* 2) Extract raw listings from the provider
* 3) Normalize listings to the provider schema
* 4) Filter out incomplete/blacklisted listings
* 5) Identify new listings (vs. previously stored hashes)
* 6) Filter out entries that do not match the job's specFilter
* 5) Identify new listings (vs. previously stored hashes) and record existing adverts' price changes
* 6) Filter out addresses excluded by the job and entries that do not match its specFilter
* 7) Optionally enrich new listings via provider.fetchDetails
* 8) Optionally re-apply the provider blacklist using the (now enriched)
* description - only when the user opted in via
* `blacklist_filter_on_provider_details`
* Always recheck the job's location exclusions against the enriched address too.
* 9) Geocode
* 10) Persist new listings
* 11) Calculate distances to the user's addresses
Expand All @@ -96,10 +98,9 @@ function isLocated(listing) {
* 17) Apply the job's commute filter to the entries that are too far away
* 18) Dispatch notifications
*
* The ordering is deliberate in two places. The spec filter sits as early as it can - it needs
* nothing but the normalized price, size and rooms - so an excluded listing never pays for a
* detail fetch, a geocode or an insert. The similarity and area filters, by contrast, run after
* the insert and soft-delete what they reject: the stored row is what makes step 5 skip the
* The location and spec filters follow price-change tracking so existing adverts keep their price
* history, then reject new listings before a detail fetch, a geocode or an insert. The similarity
* and area filters run after the insert and soft-delete what they reject: the stored row makes step 5 skip the
* listing next time, and without it an out-of-area listing would be geocoded again on every run.
*/
class FredyPipelineExecutioner {
Expand All @@ -124,6 +125,8 @@ class FredyPipelineExecutioner {
this._jobKey = job.id;
/** @type {SpecFilter | null} */
this._jobSpecFilter = job.specFilter;
/** @type {RegExp[]} */
this._excludedLocationMatchers = compileExcludedLocations(job.excludedLocations);
/** @type {SpatialFilter | null} */
this._jobSpatialFilter = job.spatialFilter;
/** @type {import('./utils/commuteBudget.js').CommuteFilter | null} */
Expand Down Expand Up @@ -156,12 +159,14 @@ class FredyPipelineExecutioner {
.then(this._filter.bind(this))
.then(this._findNew.bind(this))
.then(this._splitOffPriceChanges.bind(this))
.then(this._filterByExcludedLocations.bind(this))
// The spec filter runs here, before anything expensive. It only reads price, size and rooms,
// which normalize() has already produced, so a listing the user excluded by price never
// costs a detail page fetch, a Nominatim lookup or a database round trip. It used to run
// last, after all three.
.then(this._filterBySpecs.bind(this))
.then(this._fetchDetails.bind(this))
.then(this._filterByExcludedLocations.bind(this))
.then(this._filterAfterDetails.bind(this))
// Again after the details: the first pass lets a listing without size or rooms through, and
// the detail page is often where those arrive. Without it a 35 m² flat passed a 60 m² minimum.
Expand Down Expand Up @@ -417,6 +422,24 @@ class FredyPipelineExecutioner {
return listings.map((listing) => this._providerConfig.normalize(listing));
}

/**
* Apply this job's address exclusions to new listings after existing adverts' price changes are
* recorded, then again after details supply an address. Rejected entries never reach geocoding
* or storage.
*
* @param {ParsedListing[]} listings
* @returns {ParsedListing[]}
* @throws {NoNewListingsWarning} When every listing is excluded.
*/
_filterByExcludedLocations(listings) {
if (this._excludedLocationMatchers.length === 0) return listings;
const kept = listings.filter(
(listing) => !matchesExcludedLocation(listing.address, this._excludedLocationMatchers),
);
if (kept.length === 0) throw new NoNewListingsWarning();
return kept;
}

/**
* Filter out listings that are missing required fields and those rejected by the
* provider's blacklist/filter function.
Expand Down
5 changes: 5 additions & 0 deletions lib/api/routes/jobRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ export default async function jobPlugin(fastify) {
notificationAdapter,
name,
blacklist = [],
excludedLocations,
jobId,
enabled,
shareWithUsers = [],
Expand Down Expand Up @@ -251,6 +252,9 @@ export default async function jobPlugin(fastify) {
if (!isStringArray(blacklist) || !isStringArray(shareWithUsers)) {
return reply.code(400).send({ error: 'blacklist and shareWithUsers must be arrays of strings.' });
}
if (excludedLocations !== undefined && !isStringArray(excludedLocations)) {
return reply.code(400).send({ error: 'excludedLocations must be an array of strings.' });
}

// Jobs now reference channels rather than carrying their configuration. Without this check
// any authenticated user could attach an admin-only channel by putting its id in the body.
Expand Down Expand Up @@ -282,6 +286,7 @@ export default async function jobPlugin(fastify) {
enabled,
name,
blacklist,
excludedLocations,
provider,
notificationAdapter: channelRefs.map((configuredAdapterId) => ({ configuredAdapterId })),
shareWithUsers,
Expand Down
2 changes: 1 addition & 1 deletion lib/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ A search job needs four things a model cannot invent - a name, at least one sear
2. **providerUrls** - the portal is worked out from the URL's host, so the user is never asked to name it. A URL that is only the portal's homepage is refused: it saves cleanly, runs on schedule and quietly finds nothing.
3. **dealType** - renting or buying. The URLs usually give it away, and the draft offers that reading, but the question is still asked.
4. **channelIds** - one or more of the user's own channels. A user with none is told where to create one; the draft waits.
5. **refinements** - price, size, rooms, blacklist words, travel-time limits, sharing, and whether the job starts switched on. Asked once, and answerable with "skip".
5. **refinements** - price, size, rooms, blacklist words, excluded locations matched against listing addresses for this job, travel-time limits, sharing, and whether the job starts switched on. Asked once, and answerable with "skip".
6. **ready** - the summary is read back to the user, and only then does `create_job_from_draft` with `confirmed=true` create anything.

Why the server keeps the state: putting the interview inside the model works with Claude and does not work with the local models this server is mostly pointed at. They send one guessed payload, or forget an answer given two turns ago.
Expand Down
19 changes: 15 additions & 4 deletions lib/mcp/jobDraftStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import { hostsOf, normalizeHost, resolveProviderForUrl, validateProviderUrl } from '../services/jobs/providerUrl.js';
import { DEAL_TYPES, normalizeDealType, detectDealTypeForJob } from '../services/dealType.js';
import { normalizeCommuteFilter } from '../utils/commuteBudget.js';
import { normalizeExcludedLocations } from '../utils/excludedLocations.js';

/**
* How long a draft outlives its last update. Long enough to go and create a notification channel in
Expand All @@ -46,6 +47,7 @@ const MAX_NAME_LENGTH = 40;
* @property {'rent'|'buy'|null} dealType
* @property {string[]} channelIds
* @property {string[]} blacklist
* @property {string[]} excludedLocations - Case-insensitive address terms excluded from this job.
* @property {{maxPrice?: number, minSize?: number, minRooms?: number}|null} specFilter
* @property {import('../utils/commuteBudget.js').CommuteFilter|null} commuteFilter
* @property {string[]} shareWithUsers
Expand Down Expand Up @@ -79,6 +81,7 @@ export function emptyDraft() {
dealType: null,
channelIds: [],
blacklist: [],
excludedLocations: [],
specFilter: null,
commuteFilter: null,
shareWithUsers: [],
Expand Down Expand Up @@ -162,9 +165,9 @@ function positiveNumber(value) {
* at once. Half-applying would leave the user's next answer landing on a draft that is not the one
* the question was computed from.
*
* Arrays replace rather than merge. `providerUrls`, `channelIds`, `blacklist` and `shareWithUsers`
* are always the complete desired list - an LLM asked to "add one more" reliably resends the whole
* list, and much less reliably works out a delta.
* Arrays replace rather than merge. `providerUrls`, `channelIds`, `blacklist`, `excludedLocations`
* and `shareWithUsers` are always the complete desired list - an LLM asked to "add one more"
* reliably resends the whole list, and much less reliably works out a delta.
*
* @param {JobDraft} draft
* @param {object} patch
Expand All @@ -179,6 +182,7 @@ export function applyDraftUpdate(draft, patch, ctx) {
providers: [...draft.providers],
channelIds: [...draft.channelIds],
blacklist: [...draft.blacklist],
excludedLocations: [...(draft.excludedLocations ?? [])],
shareWithUsers: [...draft.shareWithUsers],
};
let touchedRefinement = patch.skipRefinements === true;
Expand Down Expand Up @@ -264,6 +268,11 @@ export function applyDraftUpdate(draft, patch, ctx) {
next.blacklist = words;
}

if (patch.excludedLocations !== undefined) {
touchedRefinement = true;
next.excludedLocations = normalizeExcludedLocations(patch.excludedLocations);
}

if (patch.specFilter !== undefined) {
touchedRefinement = true;
if (patch.specFilter == null) {
Expand Down Expand Up @@ -398,6 +407,7 @@ export function describeDraft(draft, ctx) {
`- **Portals:** ${draft.providers.map((p) => `${p.name} (${p.url})`).join(', ') || '–'}`,
`- **Notify via:** ${draft.channelIds.map(channelName).join(', ') || '–'}`,
`- **Blacklist:** ${draft.blacklist.join(', ') || '–'}`,
`- **Excluded locations:** ${(draft.excludedLocations ?? []).join(', ') || '–'}`,
`- **Filters:** ${
spec == null
? '–'
Expand Down Expand Up @@ -479,7 +489,7 @@ export function nextStep(draft, ctx) {
key: 'refinements',
question:
'Optional refinements - answer with any of these, or say "skip": maximum price in EUR, minimum size in m², ' +
'minimum number of rooms, words that should exclude a listing, ' +
'minimum number of rooms, words that should exclude a listing, locations to exclude from listing addresses, ' +
(addresses.length > 0
? `travel-time limits in minutes for your saved addresses (${addresses.join(', ')}), `
: 'travel-time limits (you have no saved addresses yet - they are added in the web UI under Settings), ') +
Expand Down Expand Up @@ -518,6 +528,7 @@ export function toUpsertPayload(draft, userId, jobId) {
name: draft.name,
enabled: draft.enabled,
blacklist: draft.blacklist,
excludedLocations: normalizeExcludedLocations(draft.excludedLocations),
provider: draft.providers.map(({ id, name, url, enabled }) => ({ id, name, url, enabled })),
notificationAdapter: draft.channelIds.map((configuredAdapterId) => ({ configuredAdapterId })),
shareWithUsers: draft.shareWithUsers,
Expand Down
7 changes: 7 additions & 0 deletions lib/mcp/mcpAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,12 @@ export function createMcpServer() {
.array(z.string())
.optional()
.describe('Words that exclude a listing when they appear in it. The full list; an empty list clears it.'),
excludedLocations: z
.array(z.string())
.optional()
.describe(
'Locations that exclude a listing when its address contains them, ignoring case. Applies only to this job. The full list; an empty list clears it.',
),
specFilter: z
.object({
maxPrice: z.number().positive().nullable().optional().describe('Maximum price in EUR'),
Expand Down Expand Up @@ -909,6 +915,7 @@ function toDraftPatch(draft) {
dealType: draft.dealType ?? undefined,
channelIds: draft.channelIds,
blacklist: draft.blacklist,
excludedLocations: draft.excludedLocations,
specFilter: draft.specFilter,
commuteFilter: draft.commuteFilter,
shareWithUsers: draft.shareWithUsers,
Expand Down
2 changes: 2 additions & 0 deletions lib/mcp/mcpNormalizer.js
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ export function normalizeGetJob(job) {
md += `- **Deal type:** ${dealTypeLabel(job.dealType)}\n`;
md += `- **Providers:** ${providers.length > 0 ? providers.join(', ') : '–'}\n`;
md += `- **Blacklist:** ${(job.blacklist ?? []).length > 0 ? job.blacklist.join(', ') : '–'}\n`;
md += `- **Excluded locations:** ${(job.excludedLocations ?? []).join(', ') || '–'}\n`;

return toMcpResponse(md);
}
Expand Down Expand Up @@ -537,6 +538,7 @@ export function normalizeJobCreated(job) {
md += `- **Deal type:** ${dealTypeLabel(job.dealType)}\n`;
md += `- **Portals:** ${providers.length > 0 ? providers.join(', ') : '–'}\n`;
md += `- **Blacklist:** ${(job.blacklist ?? []).length > 0 ? job.blacklist.join(', ') : '–'}\n`;
md += `- **Excluded locations:** ${(job.excludedLocations ?? []).join(', ') || '–'}\n`;

md += `\n#### Next steps\n\n`;
md += `- The job runs at the next scheduled interval; it is not run now.\n`;
Expand Down
Binary file modified lib/services/listings/priceHistoryService.js
Binary file not shown.
18 changes: 16 additions & 2 deletions lib/services/storage/jobStorage.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { toJson, fromJson } from '../../utils.js';
import { DEAL_TYPES } from '../dealType.js';
import { getAllChannels } from './configuredAdapterStorage.js';
import { SHARED_WITH_USER_SQL } from '../security/access.js';
import { normalizeExcludedLocations } from '../../utils/excludedLocations.js';

/**
* Every channel on the instance, keyed by id.
Expand Down Expand Up @@ -62,6 +63,8 @@ const hydrateNotificationAdapter = (raw, channelMap) => {
* @param {string} [params.jobId] - Existing job id to update; omit to insert a new job.
* @param {string} [params.name] - Job display name.
* @param {Array<any>} [params.blacklist] - Blacklist entries; defaults to empty array.
* @param {string[]} [params.excludedLocations] - Address substrings excluded by this job. Omitted
* on an update, the stored list is kept; omitted on an insert, it defaults to []. An empty list clears it.
* @param {boolean} [params.enabled] - Whether the job is enabled; defaults to true.
* @param {Array<any>} params.provider - Provider configuration list.
* @param {Array<{configuredAdapterId: string}>} params.notificationAdapter - References into
Expand All @@ -77,6 +80,7 @@ export const upsertJob = ({
jobId,
name,
blacklist = [],
excludedLocations,
enabled = true,
provider,
notificationAdapter,
Expand All @@ -96,6 +100,7 @@ export const upsertJob = ({
SET enabled = @enabled,
name = @name,
blacklist = @blacklist,
excluded_locations = COALESCE(@excludedLocations, excluded_locations),
provider = @provider,
notification_adapter = @notification_adapter,
shared_with_user = @shareWithUsers,
Expand All @@ -111,6 +116,8 @@ export const upsertJob = ({
enabled: enabled ? 1 : 0,
name: name ?? null,
blacklist: toJson(blacklist ?? []),
excludedLocations:
excludedLocations === undefined ? null : toJson(normalizeExcludedLocations(excludedLocations)),
shareWithUsers: toJson(shareWithUsers ?? []),
provider: toJson(provider ?? []),
notification_adapter: toJson(notificationAdapter ?? []),
Expand All @@ -122,14 +129,15 @@ export const upsertJob = ({
);
} else {
SqliteConnection.execute(
`INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter, shared_with_user, spatial_filter, spec_filter, commute_filter, deal_type)
VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter, @shareWithUsers, @spatialFilter, @specFilter, @commuteFilter, @dealType)`,
`INSERT INTO jobs (id, user_id, enabled, name, blacklist, excluded_locations, provider, notification_adapter, shared_with_user, spatial_filter, spec_filter, commute_filter, deal_type)
VALUES (@id, @user_id, @enabled, @name, @blacklist, @excludedLocations, @provider, @notification_adapter, @shareWithUsers, @spatialFilter, @specFilter, @commuteFilter, @dealType)`,
{
id,
user_id: ownerId,
enabled: enabled ? 1 : 0,
name: name ?? null,
blacklist: toJson(blacklist ?? []),
excludedLocations: toJson(normalizeExcludedLocations(excludedLocations)),
provider: toJson(provider ?? []),
shareWithUsers: toJson(shareWithUsers ?? []),
notification_adapter: toJson(notificationAdapter ?? []),
Expand All @@ -154,6 +162,7 @@ export const getJob = (jobId) => {
j.enabled,
j.name,
j.blacklist,
j.excluded_locations AS excludedLocations,
j.provider,
j.shared_with_user,
j.notification_adapter AS notificationAdapter,
Expand All @@ -174,6 +183,7 @@ export const getJob = (jobId) => {
...row,
enabled: !!row.enabled,
blacklist: fromJson(row.blacklist, []),
excludedLocations: normalizeExcludedLocations(fromJson(row.excludedLocations, [])),
provider: fromJson(row.provider, []),
shared_with_user: fromJson(row.shared_with_user, []),
notificationAdapter: hydrateNotificationAdapter(row.notificationAdapter, channelMap),
Expand Down Expand Up @@ -306,6 +316,7 @@ export const getJobs = ({ includeDisabled = false, visibleTo = null } = {}) => {
j.enabled,
j.name,
j.blacklist,
j.excluded_locations AS excludedLocations,
j.provider,
j.shared_with_user,
j.notification_adapter AS notificationAdapter,
Expand All @@ -325,6 +336,7 @@ export const getJobs = ({ includeDisabled = false, visibleTo = null } = {}) => {
...row,
enabled: !!row.enabled,
blacklist: fromJson(row.blacklist, []),
excludedLocations: normalizeExcludedLocations(fromJson(row.excludedLocations, [])),
provider: fromJson(row.provider, []),
shared_with_user: fromJson(row.shared_with_user, []),
notificationAdapter: hydrateNotificationAdapter(row.notificationAdapter, channelMap),
Expand Down Expand Up @@ -417,6 +429,7 @@ export const queryJobs = ({
j.enabled,
j.name,
j.blacklist,
j.excluded_locations AS excludedLocations,
j.provider,
j.shared_with_user,
j.notification_adapter AS notificationAdapter,
Expand All @@ -438,6 +451,7 @@ export const queryJobs = ({
...row,
enabled: !!row.enabled,
blacklist: fromJson(row.blacklist, []),
excludedLocations: normalizeExcludedLocations(fromJson(row.excludedLocations, [])),
provider: fromJson(row.provider, []),
shared_with_user: fromJson(row.shared_with_user, []),
notificationAdapter: hydrateNotificationAdapter(row.notificationAdapter, channelMap),
Expand Down
Loading