Compare commits

..

130 Commits

Author SHA1 Message Date
orangecoding
996b841cfb adding ability to add proxies for cloak 2026-05-24 20:49:27 +02:00
orangecoding
b2e294e38c next release version 2026-05-21 21:46:13 +02:00
orangecoding
8afeaa05d9 fixing cloakbrowser connection issue 2026-05-21 21:45:57 +02:00
orangecoding
ec47137b89 upgrading dependencies 2026-05-21 21:40:35 +02:00
orangecoding
33161de087 upgrading dependencies 2026-05-19 09:15:12 +02:00
Stephan
acab23207e fix: kleinanzige listing might have a different structure when it was with no image created (#308) 2026-05-16 14:16:31 +02:00
orangecoding
2896d531e4 upgrading pois 2026-05-13 08:14:57 +02:00
orangecoding
0cbfa25062 upgrading pois 2026-05-12 19:28:58 +02:00
orangecoding
bcd3042026 fixing error messages not being shown properly in user table 2026-05-12 13:24:13 +02:00
orangecoding
0ce93acaf6 more demo fixes 2026-05-12 13:12:26 +02:00
orangecoding
cabef973a2 forbid backuo/restore in demo mode 2026-05-12 12:42:25 +02:00
orangecoding
3d0fa87d19 upgrading dependencies 2026-05-12 09:23:52 +02:00
orangecoding
8b012ef2f1 upgrading dependencies / new pois 2026-05-11 09:18:32 +02:00
orangecoding
6816b0aded next release version 2026-05-10 15:43:13 +02:00
Christian Kellner
ac02817d4e Switch browser engine from puppeteer-extra/stealth to CloakBrowser (#307)
* Switch browser engine from puppeteer-extra/stealth to CloakBrowser

- Replace puppeteer, puppeteer-extra, puppeteer-extra-plugin-stealth with
  cloakbrowser + puppeteer-core; CloakBrowser applies 49 source-level C++
  fingerprint patches that cannot be detected at the JS layer.
- Enable humanize:true in launchBrowser() for Bézier mouse curves, natural
  keyboard timing, and realistic scroll physics.
- Remove manual userDataDir management and ARM64 executablePath override;
  CloakBrowser ships its own binary for x86_64 and arm64.
- Proxy is now passed via CloakBrowser's native proxy option instead of
  --proxy-server Chrome flag.
- Dockerfile: add fonts-noto-color-emoji + fonts-freefont-ttf so canvas
  fingerprint hashes match real browsers (required for Kasada/Akamai);
  replace npx puppeteer browsers install with node ensureBinary() call;
  remove TARGETARCH ARG and ARM64 system-Chromium branch.
- Update test mock to reflect simplified browser object (no __fredy_* fields).

https://claude.ai/code/session_01WXzA3orbwE2hdk723c6MgH

* Add --ignore-certificate-errors for CloakBrowser's custom Chromium

CloakBrowser ships its own Chromium binary with an independent CA bundle.
This flag prevents ERR_CERT_AUTHORITY_INVALID failures in environments with
SSL-inspecting proxies or non-standard root CAs (Docker CI, corporate networks).

https://claude.ai/code/session_01WXzA3orbwE2hdk723c6MgH

* Harden CloakBrowser integration and fix kleinanzeigen detail test

- Remove all CDP overrides (applyBotPreventionToPage, applyLanguagePersistence,
  applyPostNavigationHumanSignals) that created detectable inconsistencies on top
  of CloakBrowser's C++ patches; pass locale to CloakBrowser launch instead
- Drop --lang arg (replaced by CloakBrowser locale flag)
- Extend immowelt puppeteerTimeout to 90 s to accommodate React SPA rendering
  latency under CloakBrowser's humanise delays
- Fix kleinanzeigen detail test: serve the offline fixture for the search URL
  so only individual detail pages are fetched live, avoiding rate limiting from
  a second fresh session hitting the same search endpoint

https://claude.ai/code/session_01WXzA3orbwE2hdk723c6MgH

* Fix immowelt bot detection with two-phase navigation and fixture-backed detail test

Immowelt's CDN challenges cold browser sessions before React can render the
listing grid, causing the old waitForSelector approach to silently timeout.

- Add preNavigateUrl option to puppeteerExtractor: visits a warm-up page
  first so the site sees an established session before the search URL
- Add waitForNetworkIdle option: a second idle-wait phase after domcontentloaded
  that catches React's listing API round-trip (which fires long after the
  initial HTML is parsed); errors are swallowed so partial DOM is still used
- Switch immowelt config to waitForSelector=null + networkidle warm-up so
  page.content() is returned after the SPA has loaded its data
- Set immowelt preNavigateUrl to the homepage to warm the session
- In the detail enrichment test, spy on puppeteerExtractor to serve the
  offline fixture for the search URL; only individual listing detail pages
  are fetched live (they are far less aggressively protected)

https://claude.ai/code/session_01WXzA3orbwE2hdk723c6MgH

* Ensure CloakBrowser binary is present before any live test runs

Add a Vitest globalSetup that calls ensureBinary() once in the main process
before workers start. Without this, running yarn test on a fresh checkout
(or after the binary cache is cleared) immediately fails every browser-based
test with "Failed to launch the browser process" before any useful output
appears. The setup is a no-op in offline mode and when the binary is already
cached.

https://claude.ai/code/session_01WXzA3orbwE2hdk723c6MgH

* Ensure CloakBrowser binary at startup for non-Docker installs

Direct runs (yarn start:backend) on a fresh checkout have no binary and
only crash when the first scraping job fires. Calling ensureBinary() at
startup downloads it on first run and is instant when already cached.
In Docker it stays a no-op since the binary is pre-baked during docker build.

https://claude.ai/code/session_01WXzA3orbwE2hdk723c6MgH

* Fix --no-zygote comment: ICU crash was corrupted .4 binary, not fd issue

The "Invalid file descriptor to ICU data received" crash seen in Sparkasse
tests was caused by a partially-extracted CloakBrowser .4 binary that
contained only the chrome executable but was missing icudtl.dat and other
resource files. The ensureBinary() function returned this incomplete
installation because latest_version_linux-x64 pointed to .4.

The --no-zygote flag is kept as a safeguard for container environments
with limited kernel namespaces, but the comment now accurately describes
its purpose rather than attributing it to a non-existent fd inheritance issue.

https://claude.ai/code/session_01WXzA3orbwE2hdk723c6MgH

* Add ensureValidBinary() to detect and auto-heal corrupt CloakBrowser installs

CloakBrowser's ensureBinary() only checks that the chrome executable exists,
not that required resource files (icudtl.dat, resources.pak) are present.
A partial extraction — e.g. an interrupted update — can leave a directory
that passes ensureBinary()'s check but causes Chrome to crash immediately
with "Invalid file descriptor to ICU data received".

ensureValidBinary() wraps ensureBinary() with a completeness check:
- If the required resource files are missing it removes the corrupt directory
  and all latest_version* markers, then calls ensureBinary() again so it
  falls back to (or re-downloads) a complete build.
- It pins the validated path via CLOAKBROWSER_BINARY_PATH so CloakBrowser's
  own internal ensureBinary() call inside launch() always uses the same,
  verified binary.

Used in index.js (app startup) and test/globalSetup.js (before live tests).

https://claude.ai/code/session_01WXzA3orbwE2hdk723c6MgH

* Fix sparkasse detail test: serve search URL from fixture to avoid rate-limiting

The second sparkasse test launched a fresh browser against the live search
endpoint right after the first test already did, leaving the IP in a suspicious
state that caused bot detection or rate-limiting to return empty results.
When getListings() returns nothing, execute() resolves to undefined and
expect(listings).toBeInstanceOf(Array) fails.

Apply the same hybrid fixture approach used by kleinanzeigen and immowelt:
intercept puppeteerExtractor calls whose pathname matches the search URL and
return the offline fixture, while letting individual detail page requests go
live (they are less aggressively rate-limited than the search endpoint).

https://claude.ai/code/session_01WXzA3orbwE2hdk723c6MgH

* Fix sparkasse detail test: shared browser, direct fetchDetails call

Remove the fixture-backed spy — live tests must hit the real server.

Root problem: two cold browser sessions hitting sparkasse in quick succession
triggered bot detection, causing the second search request to return empty
results and execute() to resolve undefined.

Fix:
- One browser launched in beforeAll and reused across both tests, so both
  the search and detail requests come from the same warm session.
- The detail test calls provider.config.fetchDetails() directly on the
  listings returned by the first test instead of re-running the full pipeline.
  This avoids a redundant second scrape of the search page while still
  exercising the live detail endpoint.

https://claude.ai/code/session_01WXzA3orbwE2hdk723c6MgH

* Eliminate fixture spies and double live requests in all provider detail tests

All five provider tests with a 'with provider_details enabled' describe block
were either (a) intercepting the search URL with an offline fixture to avoid
hitting the live server twice, or (b) re-running the full execute() pipeline
with a fresh browser, which triggered rate-limiting / bot detection on the
second cold request.

Pattern applied to all five:
- immowelt, kleinanzeigen, wgGesucht, immobilienDe: launch one browser in
  beforeAll/afterAll, pass it to the first test's Fredy constructor, and call
  provider.config.fetchDetails() directly in the second test using the listings
  and browser already in hand. One warm session, two live endpoints tested.
- immoscout: API-based (no browser), so no browser sharing needed. Second test
  calls provider.config.fetchDetails() directly on liveListings[0] from the
  first test instead of re-querying the search API.

Removed: all readFixture spies, getKnownListingHashesForJobAndProvider mocks,
and the puppeteerExtractorMod imports that were only needed for the spy.

https://claude.ai/code/session_01WXzA3orbwE2hdk723c6MgH

* Fix ensureValidBinary for macOS: platform-aware completeness check

On macOS the CloakBrowser binary lives at:
  ~/.cloakbrowser/chromium-X.Y.Z/Chromium.app/Contents/MacOS/Chromium

path.dirname() gave Contents/MacOS/ — but icudtl.dat and resources.pak
are inside Contents/Frameworks/…, not next to the binary. So the old
code incorrectly flagged every macOS installation as corrupt, deleted only
the MacOS/ subdirectory (not the full versioned dir), then failed again.

Fixes:
- isBinaryComplete: on macOS check for Info.plist and Frameworks/ inside
  Chromium.app/Contents/ instead of looking for Linux resource files next
  to the binary. On Linux/Windows the existing check is unchanged.
- getVersionedDir: resolves the full chromium-X.Y.Z/ directory regardless
  of platform (4 levels up on macOS, 1 on Linux/Windows) so
  removeCorruptInstallation always deletes the entire versioned tree.
- missingDescription: reports the correct missing items per platform.

https://claude.ai/code/session_01WXzA3orbwE2hdk723c6MgH

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-10 15:42:31 +02:00
datenwurm
fe0a09fe1c fix: Sort direction toggle not working in listings overview (#306)
Co-authored-by: datenwurm <git@datenwurm.net>
2026-05-08 09:24:20 +02:00
orangecoding
2f00966f27 next release version 2026-05-07 19:12:17 +02:00
orangecoding
921057252d adding immoscout shape search 2026-05-07 19:11:47 +02:00
orangecoding
703c602527 table overview for jobs 2026-05-07 15:59:55 +02:00
orangecoding
0e29c9b9c6 next release version 2026-05-07 12:45:52 +02:00
datenwurm
f60c5859f9 feat: Add grid/table view toggle to listings overview (#305)
* feat: Add delete button to listing detail view

* feat: Add grid/table view toggle to listings overview

---------

Co-authored-by: datenwurm <git@datenwurm.net>
2026-05-07 12:12:49 +02:00
datenwurm
ee54cc495b feat: Add delete button to listing detail view (#304)
Co-authored-by: datenwurm <git@datenwurm.net>
2026-05-07 11:53:51 +02:00
orangecoding
96582ecff4 gmx example 2026-05-02 20:07:03 +02:00
orangecoding
3de82dfa41 fixing error message when passwords do not match / fixing placeholder image 2026-05-02 20:00:11 +02:00
orangecoding
d7ee4f6909 adding gitattributed§ 2026-05-01 20:12:58 +02:00
orangecoding
bf4bae9bf5 upgrading dependencies 2026-05-01 20:12:40 +02:00
orangecoding
3d10dc6042 moving from restana to fastify 2026-04-27 16:56:04 +02:00
orangecoding
fef6d06a9d next release version 2026-04-27 16:04:05 +02:00
orangecoding
951b69a67f downgrading restana 2026-04-27 16:03:45 +02:00
orangecoding
8a7b14c079 fixing toasts not showing on certain pages / adding statement about ai 🤖 2026-04-27 15:58:41 +02:00
Christian Kellner
f30ec4645c feat: Fredy UI redesign
* New design :)
2026-04-22 21:11:18 +02:00
orangecoding
c78472bd19 adding 'open in fredy' 2026-04-21 19:42:39 +02:00
orangecoding
8c5607e20b adding test fixtures so that we can run tests 'offline' 2026-04-21 13:37:00 +02:00
orangecoding
64d0515c79 next release version 2026-04-20 10:14:26 +02:00
bytedream
cc0164b689 change average price to median price on the dashboard (#300)
* change average price to median price on the dashboard

* Use more efficient median calculation

Co-authored-by: Christian Kellner <weakmap@gmail.com>

* Fix applied suggestion artifacts

* Update sql query and js sort function

* Group sql statement by id

* Revert sort function change

---------

Co-authored-by: Christian Kellner <weakmap@gmail.com>
2026-04-20 10:13:11 +02:00
orangecoding
522bbc2282 upgrade dependencies 2026-04-16 12:07:22 +02:00
Adrian Bartnik
c384781137 Add toggle for plain text message to telegram notification adapter (#299) 2026-04-16 12:05:57 +02:00
orangecoding
e2d10d179e next release version 2026-04-12 09:21:08 +02:00
Stephan
10c94eea0a Feature/spec filter (#276)
* feat(): create map component, add area filtering to the job config

* feat(): filter listings by area filter

* chore(): cleanup

* feat(): solve feedback

* feat(): solve most providers

* feat(): solve maybe other providers

* feat(): add specFilter config, also add rooms to listing

* feat(): change tests

* feat(): fix kleinanzeigen parser

* feat(): add spec filter switch for listing overviiews

* feat(): add rooms and size to the overview and detail of a listing

* feat(): rem label

* feat(): add types, update providers, they now return specs as numbers

* feat(): add jsonconfig to enable type checks

* feat: add type for prividerConfig, add fieldNames per provider

* feat: fix tests, provider, add formatListing

* chore: remov duplicates

* feat(): fix tests

* feat: fix immoscout

* chore: geojson typing

* feat: solve requested changes
2026-04-12 09:17:23 +02:00
orangecoding
05f74f99ef adding tool to receive photo of listing 2026-04-09 11:51:42 +02:00
orangecoding
f3ad529107 fixing migration file 2026-04-07 20:22:16 +02:00
orangecoding
791822e7c8 next release version 2026-04-07 19:55:33 +02:00
Christian Kellner
cdc0cbda2f Feature/kleinanzeigen new (#292)
* Feature/Kleinanzeigen addresses (#289)

* upgrade dependencies

* immoscout_details -> provider_details

* fetching details more generic

* removing claude action

* fixing sparkassen selector

* improvements

* fixing immobilienDE test

* upgrading dependencies

* settings for many provider

---------

Co-authored-by: Adrian Bach <65734063+realDayaa@users.noreply.github.com>
2026-04-07 19:53:40 +02:00
Adrian Bach
7888c5b340 fix: broken filters (#294) 2026-04-04 12:26:34 +02:00
orangecoding
d7f46d6c68 security update 2026-03-31 13:33:01 +02:00
orangecoding
1c9d7c9d92 storing filter settings in url 2026-03-31 11:46:22 +02:00
orangecoding
bc73de6703 upgrade dependencies 2026-03-31 10:38:50 +02:00
orangecoding
568e0abfa1 fixing login not showing if username or password is incorrect 2026-03-31 09:18:49 +02:00
Stephan
3992a9c81c fix: maplibre-gl runtime errors in production build by isoliting it into a chunk (#288) 2026-03-31 09:14:49 +02:00
Christian Kellner
7346075b9d Add Claude Code GitHub Workflow (#285)
* "Claude PR Assistant workflow"

* "Claude Code Review workflow"
2026-03-24 08:40:04 +01:00
Christian Kellner
8c039f0026 UI improvements (#283)
* ui-improvements

* improving dashboard and settings

* improve job overview

* improving job card

* improving grid view of listings+

* restructuring settings

* next release version
2026-03-23 13:22:34 +01:00
orangecoding
a1289acf15 fixing some docker issues 2026-03-22 09:41:20 +01:00
orangecoding
8501fc7266 upgrading dependencies 2026-03-21 08:09:15 +01:00
orangecoding
4960846cd7 fixing docker run 2026-03-21 08:08:52 +01:00
orangecoding
3ed17f4442 fixing broken puppeteer providers in docker caused by alpine chromium 146 crashing / switched to debian slim with puppeteer's own chrome for testing / dropped 2-stage build / run as non-root / purge build tools after install, improve docker-test.sh to verify it all works. That's it. ;) 2026-03-20 19:19:20 +01:00
orangecoding
b531a7b77a fixing mcp issue, adding claude example 2026-03-20 13:45:42 +01:00
Adrian Bach
3523057221 feat: add smtp adapter (#279) 2026-03-20 11:37:28 +01:00
orangecoding
77311cf39d next release version 2026-03-17 11:26:39 +01:00
orangecoding
556c0aff35 fixing duplicate migration 2026-03-17 11:26:23 +01:00
orangecoding
c40d275e52 cleanup 2026-03-16 14:48:41 +01:00
orangecoding
cbf2766783 cleanup 2026-03-16 14:48:01 +01:00
orangecoding
1b39e345b6 moving from jest to vitest 2026-03-16 14:26:58 +01:00
orangecoding
6ccbdd8afc upgrading dependencies 2026-03-16 10:41:53 +01:00
orangecoding
2a30c89eb2 improving version banner 2026-03-16 10:37:36 +01:00
orangecoding
4878dc98e3 Merge branch 'master' of github.com:orangecoding/fredy 2026-03-11 15:26:56 +01:00
orangecoding
dc2704997d upgrading dependencies 2026-03-11 15:26:25 +01:00
orangecoding
e107b0fb00 next release version 2026-03-11 15:25:20 +01:00
Promises
6c08675fee Add new properties to real estate translation mappings (#275)
Added few more properties for buying a house
2026-03-11 14:49:21 +01:00
orangecoding
34c4de7267 fixing stdin for mcp 2026-03-10 09:27:04 +01:00
orangecoding
b64a118a18 moving mcp into lib to make it available in docker setup 2026-03-09 16:26:53 +01:00
orangecoding
03cb4d18cb fix formatting 2026-03-09 15:40:29 +01:00
orangecoding
be5c4af3cf adding an MCP Server 🎉 2026-03-09 15:35:29 +01:00
orangecoding
a460b813c1 adding news info 2026-03-08 10:07:51 +01:00
orangecoding
4596442f64 upgrading dependencies | mark listings as 'manually_removed' when filtered 2026-03-08 09:55:46 +01:00
Stephan
0bcfa1d4ad feat(): map area filter (#273)
* feat(): create map component, add area filtering to the job config

* feat(): filter listings by area filter

* chore(): cleanup

* feat(): solve feedback

* feat(): solve most providers

* feat(): solve maybe other providers
2026-03-08 09:44:18 +01:00
orangecoding
0cad05124a fixing immowelt provider 2026-03-08 09:29:40 +01:00
Noah Elijah Till
eb53b68d45 🕵️ More immoscout details (#258)
* 🕵️ More immoscout details

- Added more details to immoscout api - description is now populated with a lot of data from the expose using app API
- You can ignore certificates, if deploying locally and using the http notification adapter
- More details for the test call/example for easier testing + placeholder image + actual values + address (famous Erika Mustermans address see https://de.wikipedia.org/wiki/Mustermann)
- Grater timeout for geocode since the api is sometimes slow in germany
- uiElement, type boolean, now has a label as well

* 👀 Requested changes + some extra

Req:
- using logger
- using node-fetch

Extra:
- boolean input fields will trigger the validate check, because they are set undefined at first - setting them to false if they are undefined now
- added more data to the description (phone number and name of the agent)

*  Fixed import

* ️ Toggle immoscout detail fetching

* ️ Requested change
2026-03-08 09:08:40 +01:00
Tom Dohrmann
ba0732e1f6 add support for fulltext parameter for immoscout (#274) 2026-02-24 09:53:14 +01:00
orangecoding
aa67647bbb adding resend as net notification adapter 2026-02-20 17:08:38 +01:00
orangecoding
7a9d49899b improve reusing of puppeteer by adding a safeguard for broken chrome 2026-02-18 20:16:55 +01:00
orangecoding
9a87c58d3e next release version 2026-02-18 20:06:40 +01:00
orangecoding
fdd7e835e8 improve default puppeteer timeout 2026-02-18 20:06:22 +01:00
Christian Kellner
00d6a12b30 Puppeteer improvements (#270)
* improve puppeteer handling. Now only 1 puppeteer instance is being used which is WAY more efficient

* removing package-lock

* reduce logging

* removing problematic docker command

* Remove Immonet. They now belong to immowelt
2026-02-18 20:05:02 +01:00
orangecoding
05218800d2 fixing app init 2026-02-17 14:28:08 +01:00
orangecoding
19d4721f9f improve welcome screen 2026-02-17 14:03:15 +01:00
orangecoding
a794645393 fixing login route 2026-02-17 12:50:21 +01:00
orangecoding
fd7e228972 adding welcome screen 2026-02-17 12:35:39 +01:00
orangecoding
b86e351007 fixing lint even harder 2026-02-16 13:50:50 +01:00
orangecoding
19c4860da7 fixing eslint harder 2026-02-16 12:59:34 +01:00
orangecoding
d98e06cfdf fixing eslint 2026-02-16 12:40:41 +01:00
orangecoding
6ae0c9749b update dependencies 2026-02-16 12:30:59 +01:00
orangecoding
10e40e038e adding check if fredy is running in docker 2026-02-16 12:29:02 +01:00
orangecoding
4ba6828939 adding release tool 2026-02-05 12:02:18 +01:00
orangecoding
d09770dae2 fancy, almost impossible to see animation on dashboard 2026-02-05 09:54:42 +01:00
orangecoding
248e4d2562 improve tracking 2026-02-04 14:41:55 +01:00
orangecoding
7b8e961b49 adding confirmation dialog if to remove listing entirely from db or just hide it 2026-02-03 14:04:40 +01:00
orangecoding
f66ceccbb4 next release version 2026-01-29 13:01:39 +01:00
orangecoding
a3db725af6 fixing image rendering 2026-01-29 13:01:07 +01:00
orangecoding
0663bd945f smaller demo improvements 2026-01-29 09:46:23 +01:00
orangecoding
bc355fb5fe fixing some bugs the wife found ;) 2026-01-28 21:25:48 +01:00
orangecoding
797421f0d5 hardening demo handling 2026-01-28 16:29:59 +01:00
orangecoding
0b2b42fc75 improve geocoding 2026-01-28 15:55:23 +01:00
Christian Kellner
472169693f Improvements 01 28 (#264)
* improving footer

* improve ui

* upgrading dependencies

* adding glow to all boxes on dashboard

* introducing single listing view

* next release version

* improve screenshots and login page
2026-01-28 14:27:03 +01:00
orangecoding
3117044139 fixing immoscout scraper 2026-01-26 19:52:37 +01:00
orangecoding
7879d0e94a next release version 2026-01-26 12:35:57 +01:00
orangecoding
afd1048c9e hardening the check if a listing is active 2026-01-26 12:34:49 +01:00
orangecoding
acbaab05ed next release version 2026-01-26 12:07:43 +01:00
orangecoding
72fffc526b deleting a listing now sets it to deleted in the db, preventing it from reappearing when scraping happens 2026-01-26 12:07:21 +01:00
orangecoding
9e5989ece3 zoom into map where most markers are 2026-01-26 11:54:47 +01:00
orangecoding
afc200c9e1 improved tooltip in map, improved user-settings handling 2026-01-26 11:50:16 +01:00
orangecoding
59226491f2 improved tooltip in map, improved user-settings handling 2026-01-26 11:20:02 +01:00
orangecoding
28f7760120 adapt link to listing in grid view to behave like a real link 2026-01-26 10:43:38 +01:00
orangecoding
2465514b7a fixing immoscout translator, allowing balcony and garden for purchases 2026-01-26 10:20:21 +01:00
Christian Kellner
9dde377fe6 possibility to display distance (#262) 2026-01-25 13:52:56 +01:00
Katrin Leinweber
28a3a7f372 Use EUR-symbol to match Map.jsx (see d43c5b3) (#261)
Co-authored-by: Katrin Leinweber <katrinleinweber@noreply.github.com>
2026-01-25 12:32:11 +01:00
orangecoding
e859250545 next release version 2026-01-22 15:10:31 +00:00
Christian Kellner
4dd0370ec1 Calculating the distance (#255)
* migra for distance

* adding distance calculator

* adding ability to store home address

* improve distance calculation

* calculating distance

* show distance in grid view

* upgrading dependencies

* moving to react 19

* ability to clone a job

* fixing tests

* polishing
2026-01-22 16:09:36 +01:00
orangecoding
51b4e51f3f fixing setting kleinanzeigen listings to inactive if not available anymore 2026-01-16 11:36:51 +01:00
orangecoding
fa1899765c fixing some rendering issues in map 2026-01-16 10:46:50 +01:00
Christian Kellner
d43c5b3f97 Map View in Fredy :D (#253)
* init map view

* switching off 3d buildings when sattelite view is on

* rename menu items

* upgrading dependencies, adding provider to popups

* adding screenshot for map view

* fixing readme

* next release version
2026-01-12 15:00:36 +01:00
orangecoding
7fd8be07a2 adding wohnungsboerse provider 2026-01-09 11:37:03 +01:00
orangecoding
2926ee7e08 upgrading dependencies 2026-01-06 09:51:04 +01:00
Christian Kellner
9506d1a9db next release version 2026-01-06 08:13:39 +01:00
Christian Kellner
feaa06c132 Update LICENSE to 2026 2026-01-04 06:46:32 +01:00
Timur
ad46500d4e Fix: correct baseUrl for ohne-makler provider - Fixes #251 (#252) 2026-01-02 08:36:39 +01:00
Christian Kellner
3c209a8f97 Redesigning listing table (#248)
* redesigning listing table

* getting rid of old listing table view

* improving listing grid
2025-12-23 08:47:51 +01:00
orangecoding
398259ff20 next release version 2025-12-18 19:25:33 +01:00
orangecoding
cf030bfa39 next release version / fixing valuers not being shown when editing a notification adapter 2025-12-18 19:24:48 +01:00
orangecoding
5dc976c7e3 ability to start jobs individually 2025-12-18 19:16:28 +01:00
orangecoding
05f1bc61c9 fixing tests 2025-12-17 16:35:24 +01:00
302 changed files with 76193 additions and 6572 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
test/testFixtures/** linguist-vendored

View File

@@ -62,6 +62,7 @@ jobs:
- name: Test container with docker compose
run: |
echo "Starting container with docker compose..."
mkdir -p ./db ./conf && chmod 777 ./db ./conf
docker compose up --build -d
echo "Waiting for container to be ready (60 seconds for start_period)..."
sleep 60

View File

@@ -19,4 +19,4 @@ jobs:
cache: 'yarn'
- run: yarn install
- run: yarn testGH
- run: yarn test:offline

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ npm-debug.log
.DS_Store
.idea
.vscode
tools/release/config.json

View File

@@ -1,94 +0,0 @@
Newer release changelog see https://github.com/orangecoding/fredy/releases
---
###### [V5.5.0]
- Upgrading dependencies
- fixing provider
- allow multiple instances of 1 provider
- **BREAKING**: Minimum node version is now 16
###### [V5.4.6]
- Adding Instana node.js monitoring
-
###### [V5.4.5]
- Adding Instana node.js monitoring
###### [V5.4.4]
- Add support for Immo Südwest Presse (immo.swp.de)
- Telegram: Use job name instead of ID and link in title
- Fix race condition if user ID is in session but not in user store
- Allow visiting the original provider URL
###### [V5.4.3]
- re-writing readme
- improving docker build
- using github's actions to build docker and test automatically
###### [V5.4.2]
- Fixing prod build
###### [V5.4.1]
- Upgrading dependencies
- Provider urls are now automagically been changed to include the correct sort order for search results
```
Note: It has been an point of confusion since the very beginning of Fredy, that people simply copied the url, but
did not take care of sorting the search results by date. If this is not done, Fredy will most likely not see the latest
results, thus cannot report them. This release fixes it by adding the necessary params (or replaces them).
```
###### [V5.3.0]
- Upgrading dependencies
- It's now possible to send mails to multiple receiver using comma separation for MailJet & Sendgrid
- Fixing Immowelt scraping
###### [V5.2.0]
- Upgrading dependencies
- Adding new similarity check layer (Duplicates are being removed now)
- Adding paging for search results
###### [V5.1.0]
- Upgrading dependencies
- NodeJS 12.13 is now the minimum supported version
- Adding general settings as new configuration page to ui
- Adding new feature working hours
###### [V5.0.0]
- Upgrading dependencies
- NodeJS 12 is now the minimum supported version
###### [V4.0.0]
Bringing back Immoscout :tada:
###### [V3.0.0]
This is basically a re-write, your old config file will not be compatible anymore. Please re-created your search jobs
on the new ui and use the values from your previous config file if needed.
```
- We're getting rid of manual config changes, Fredy, now ships with a UI so that it's easy for you to create and edit jobs
```
###### [V2.0.0]
```
- Fredy can now run multiple search job on one instance
- Changed lot's of the structure of Fredy to make this happen
[BREAKING CHANGES]
- The config has been changed, the config of V1.x will not work any longer
- Sources have been renamed to provider
```

120
CLAUDE.md Normal file
View File

@@ -0,0 +1,120 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Fredy is a self-hosted real estate finder for Germany. It scrapes German real estate portals (ImmoScout24, Immowelt, Immonet, Kleinanzeigen, WG-Gesucht, etc.), deduplicates results across providers, and sends notifications via Slack, Telegram, Email, Discord, ntfy, etc. It includes a React web UI and a built-in MCP server for LLM access to listings data.
- Node.js >= 22, ESM-only (`"type": "module"`)
- Default port: 9998, default login: admin / admin
- SQLite via `better-sqlite3` (synchronous - all DB ops are sync; only network I/O is async)
## Commands
```bash
# Development
yarn run start:backend:dev # nodemon backend
yarn run start:frontend:dev # Vite dev server (proxies /api → :9998)
# Production
yarn run start:backend # NODE_ENV=production node index.js
yarn run build:frontend # vite build → ui/public/
# Tests
yarn test # Live tests (hits actual providers)
yarn test:offline # Offline tests using HTML/JSON fixtures (fast, preferred)
yarn test:download-fixtures # Re-download fresh provider HTML fixtures
# Single test file
TEST_MODE=offline npx vitest run test/provider/immoscout.test.js
# Lint / Format
yarn lint && yarn lint:fix
yarn format && yarn format:check
# DB migrations
yarn migratedb
```
## Architecture
### Core data flow
```
index.js (startup)
├── runMigrations()
├── getProviders() # lazily imports lib/provider/*.js
├── similarityCache.init() # preloads hash cache from DB
├── api.js # starts restana HTTP server
└── initJobExecutionService() # registers event-bus listeners + starts scheduler
scheduler (every N minutes) or manual trigger via POST /api/jobs/:id/run
└── FredyPipelineExecutioner.execute()
1. queryStringMutator(url) # inject sort-by-date param
2. provider.getListings() # API or Puppeteer+Cheerio
3. provider.normalize(listing) # raw → ParsedListing
4. provider.filter(listing) # blacklist + required fields
5. filter to hashes not yet in DB
6. provider.fetchDetails() # optional enrichment
7. geocodeAddress() # optional lat/lng
8. storeListings()
9. similarityCache.checkAndAddEntry() # cross-provider dedup
10. _filterBySpecs() + _filterByArea()
11. notify.send() # fan-out to all adapters
```
### Plugin systems
**Providers** (`lib/provider/*.js`) - each module exports:
- `metaInformation` - `{ id, name, baseUrl }`
- `config` - `ProviderConfig` with `requiredFieldNames`, `crawlContainer`, `crawlFields`, `sortByDateParam`, `normalize()`, `filter()`, optional `getListings()`, `fetchDetails()`, `activeTester()`
- `init(sourceConfig, blacklist)` - called before each job run; providers are **stateful modules** holding mutable `url` and `appliedBlackList` at module scope
**Notification adapters** (`lib/notification/adapter/*.js`) - each exports:
- `config` - `{ id, name, description, fields }` (drives the UI form)
- `send({ serviceName, newListings, notificationConfig, jobKey, baseUrl })`
- Loaded dynamically at startup via `fs.readdirSync`
### Key services
| Service | Location | Notes |
|---|---|---|
| Event bus | `lib/services/events/event-bus.js` | Plain `EventEmitter`; events: `jobs:runAll`, `jobs:runOne`, `jobs:status` |
| SSE broker | `lib/services/sse/sse-broker.js` | Per-userId `Set<ServerResponse>`; heartbeat every 25s; pushes job status to UI |
| Similarity cache | `lib/services/similarity-check/` | In-memory SHA-256 Set; refreshes hourly; cross-provider dedup by title+price+address |
| SqliteConnection | `lib/services/storage/SqliteConnection.js` | Singleton, WAL mode; `execute()`, `query()`, `withTransaction()` |
| Migrations | `lib/services/storage/migrations/` | Numbered JS files each exporting `up(db)`; checksum-tracked in `schema_migrations` |
| Extractor | `lib/services/extractor/` | Orchestrates Puppeteer + Cheerio; shared browser instance per job |
### Frontend
- React 19 SPA, Vite build → `ui/public/` (served as static by backend)
- State: Zustand single store with per-domain slices
- UI library: `@douyinfe/semi-ui`
- Map: MapLibre GL + `@mapbox/mapbox-gl-draw` + `@turf/boolean-point-in-polygon` for GeoJSON polygon filters
- In dev: Vite proxies `/api` to `:9998`
### MCP server
Two transports:
1. **stdio** (`lib/mcp/stdio.js`) - for Claude Desktop/LM Studio; opens its own DB connection (main process need not be running)
2. **HTTP** (`/api/mcp`) - authenticated via Bearer token (`mcp_token` column in `users` table)
Tools: `list_jobs`, `get_job`, `list_listings`, `get_listing`, `get_current_date_time`. Responses are Markdown via `lib/mcp/mcpNormalizer.js`.
## Key Conventions
- **ESM only** - `import`/`export` everywhere, no CommonJS
- **JSDoc typedefs** (no TypeScript) in `lib/types/` - `listing.js`, `job.js`, `filter.js`, `providerConfig.js`
- **Copyright header** required on all `.js` files - enforced by `lint-staged` pre-commit hook via `copyright.js`
- **`NoNewListingsWarning`** (`lib/errors.js`) is used as control flow to short-circuit the pipeline (not an error)
- **Test fixtures** in `test/testFixtures/` - HTML/JSON snapshots per provider; `TEST_MODE=offline` mocks `puppeteerExtractor` and global `fetch` via `test/offlineFixtures.js`
- **`conf/config.json`** is the only runtime config file; created with defaults if missing
## Coding
- After building the task, run the linter
- After building the task, run the tests
- New features must be tested
- New features must be properly documented with JsDoc
- You do **not** commit any changes, you do **not** create a new branch unless I told you so

View File

@@ -1,68 +1,54 @@
# ================================
# Stage 1: Build stage
# ================================
FROM node:22-alpine AS builder
FROM node:22-slim
WORKDIR /build
# System deps for CloakBrowser + build tools for native modules (better-sqlite3)
# fonts-noto-color-emoji and fonts-freefont-ttf are required so canvas fingerprint
# hashes match real browsers; missing emoji fonts cause bot detection on Kasada/Akamai.
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates fonts-liberation libasound2 \
libatk-bridge2.0-0 libatk1.0-0 libcups2 libdbus-1-3 \
libdrm2 libgbm1 libgtk-3-0 libnspr4 libnss3 \
libx11-xcb1 libxcomposite1 libxdamage1 libxrandr2 xdg-utils \
fonts-noto-color-emoji fonts-freefont-ttf \
python3 make g++ \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /db /conf /fredy
# Install build dependencies needed for native modules (better-sqlite3)
RUN apk add --no-cache python3 make g++
WORKDIR /fredy
ENV NODE_ENV=production \
IS_DOCKER=true
# Copy package files first for better layer caching
COPY package.json yarn.lock ./
# Install all dependencies (including devDependencies for building)
# Install dependencies and purge build tools (only needed to compile better-sqlite3)
RUN yarn config set network-timeout 600000 \
&& yarn --frozen-lockfile
&& yarn --frozen-lockfile \
&& yarn cache clean
# Pre-download the CloakBrowser stealth Chromium binary (supports x86_64 and arm64)
RUN node -e "import('cloakbrowser').then(({ensureBinary}) => ensureBinary())"
# Purge build tools now that native modules are compiled
RUN apt-get purge -y python3 make g++ \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
# Copy source files needed for build
COPY index.html vite.config.js ./
COPY ui ./ui
COPY lib ./lib
# Build frontend assets
RUN yarn build:frontend
# ================================
# Stage 2: Production stage
# ================================
FROM node:22-alpine
WORKDIR /fredy
# Install Chromium and curl (for healthcheck)
# Using Alpine's chromium package which is much smaller
RUN apk add --no-cache chromium curl
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
# Install build dependencies for native modules, then remove them after yarn install
COPY package.json yarn.lock ./
RUN apk add --no-cache --virtual .build-deps python3 make g++ \
&& yarn config set network-timeout 600000 \
&& yarn --frozen-lockfile --production \
&& yarn cache clean \
&& apk del .build-deps
# Copy built frontend from builder stage
COPY --from=builder /build/ui/public ./ui/public
# Copy application source (only what's needed at runtime)
COPY index.js ./
COPY index.html ./
COPY lib ./lib
# Prepare runtime directories and symlinks for data and config
RUN mkdir -p /db /conf \
&& chown 1000:1000 /db /conf \
&& chmod 777 /db /conf \
&& ln -s /db /fredy/db \
RUN ln -s /db /fredy/db \
&& ln -s /conf /fredy/conf
EXPOSE 9998
VOLUME /db
VOLUME /conf
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl -f http://localhost:9998/ || exit 1
CMD ["node", "index.js"]

View File

@@ -210,5 +210,5 @@ different name or branding without the explicit written permission of the
original copyright holder.
Copyright (c) 2025 Christian Kellner
Copyright (c) 2026 Christian Kellner
Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause

View File

@@ -119,7 +119,7 @@ Should you use [Unraid](https://unraid.net/), you can now install Fredy from the
## 📸 Screenshots
| Fredy Main Overview | Job Configuration | Found Listings |
| Fredy Maps View | Dashboard | Found Listings |
|--------------------------------------------------|-----------------------------------------------------------------------|-----------------------------------------------------------------------------|
| ![Screenshot showing Fredy](doc/screenshot1.png) | ![Screenshot showing job configuration in Fredy](doc/screenshot3.png) | ![Screenshot showing found listings in Fredy](doc/screenshot2.png) |
@@ -154,12 +154,53 @@ to Slack + Telegram."\
Jobs run automatically at the interval you configure (see
`/conf/config.json`).
### MCP Server 🤖
Starting with **V20**, Fredy ships with a built-in **MCP Server **. This allows you to connect Fredy to LLMs (like Claude, ChatGPT, or local models via LM Studio) and query your real estate data using natural language.
The local LLM can even enrich existing listings by checking the listing online.
For more information on how to set it up and use it, please refer to the [MCP Readme](lib/mcp/README.md).
------------------------------------------------------------------------
## Immoscout
Immoscout has implemented advanced bot detection. In order to work around this, we are using a reversed engineered version of their mobile api. See [Immoscout Reverse Engineering Documentation](https://github.com/orangecoding/fredy/blob/master/reverse-engineered-immoscout.md)
## 🛡️ Bot Detection & Proxies
Most browser-based providers (immowelt, immonet, kleinanzeigen, ...) are scraped through a hardened headless browser ([CloakBrowser](https://www.npmjs.com/package/cloakbrowser)). It makes the **browser fingerprint** indistinguishable from a real Chrome, which is enough when you run Fredy on a normal home connection.
On a **server / VPS the requests usually originate from a datacenter IP**, and providers behind anti-bot systems (e.g. AWS CloudFront/WAF) block those based on **IP reputation alone**, no matter how perfect the fingerprint is. The typical symptom: it works locally but you get `We have been detected as a bot :-/` on the server.
### The fix: a residential proxy
A **residential proxy** routes Fredy's browser through the internet connection of a real household, so the provider sees a "normal user" IP instead of a datacenter. For German portals, use a **German (DE) residential** (or mobile/4G) proxy. Plain VPNs and **datacenter proxies do not help** here, they share the same bad reputation as your server.
**Configure it** under **Settings → Execution → Proxy URL**. Supported formats:
```
http://user:pass@host:port
socks5://user:pass@host:port
```
Leave the field empty to disable. The proxy applies to all headless-browser providers and takes effect on the next job run (no restart needed). Immoscout uses a separate mobile API and is not affected.
### Where to get a residential proxy
Residential proxies are a paid service (usually billed per GB, Fredy's traffic is small). Well-known providers offering German residential IPs include:
| Provider | Notes |
|---|---|
| [IPRoyal](https://iproyal.com) | Pay-as-you-go, no monthly minimum, good for low volume |
| [Webshare](https://www.webshare.io) | Cheap entry tier, has a small free plan to test with |
| [Decodo (formerly Smartproxy)](https://decodo.com) | Easy setup, country/city targeting |
| [SOAX](https://soax.com) | Residential + mobile, fine-grained geo-targeting |
| [Bright Data](https://brightdata.com) | Largest pool, most features, higher complexity/price |
| [Oxylabs](https://oxylabs.io) | Enterprise-grade, larger plans |
This is not an endorsement, pick whatever fits your budget. For low-volume use like Fredy, a pay-as-you-go plan (e.g. IPRoyal) or a cheap entry tier (e.g. Webshare) is usually plenty. Make sure to select **Germany** as the proxy location and keep the search interval reasonable (the higher the interval, the less you look like a bot).
## Analytics
Fredy is completely free (and will always remain free). However, it would be a huge help if youd allow me to collect some analytical data.
@@ -181,10 +222,25 @@ You should now be able to access _Fredy_ from your browser. Check your Terminal
### Run Tests
## "Online" tests
These tests are directly executed against the actual providers.
``` bash
yarn run test
```
## "Offline" tests
These tests are using the test fixtures instead of the actual providers. Much faster and "good enough" to test the core functionality.
``` bash
yarn run test:offline
```
## Download new fixtures
If you have to refresh the fixtures (every once in a while needed because the providers change their code), run this command:
``` bash
yarn run download-fixtures
```
------------------------------------------------------------------------
## 📐 Architecture
@@ -206,7 +262,7 @@ flowchart TD
F2["Adapter 2"]
end
A1 --> B["FredyPipeline"]
A1 --> B["FredyPipelineExecutioner"]
A2 --> B
A3 --> B
B --> C1 & C2 & C3
@@ -218,6 +274,20 @@ flowchart TD
F1 --> F2
```
------------------------------------------------------------------------
## 🤖 Using AI such as Claude Code
When I started building Fredy, LLMs were still basically the wet dream of a few nerdy scientists.
Nowadays, its easier than ever to throw a prompt into the LLM of your choice and let 'the AI' build your stuff. Im not against that. I use Claude Code myself for smaller tasks, and I do think these tools can be really useful.
That said, I still believe humans should stay in charge. AI is great-ish at writing code, but it still lacks creativity, context, and the ability to see the full picture.
So, if you want to contribute to Fredy, using AI tools to get things done is totally fine. Just please dont stop thinking.
Ive had one too many PRs full of hallucinated bullshit.
**Thanks ;)**
------------------------------------------------------------------------
## 👐 Contributing

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
@@ -30,12 +30,16 @@ async function getAllFiles(dir = '.') {
/* eslint-disable no-console */
async function addCopyright(files) {
const oldCopyrightRegex =
/^(\/\*\n \* Copyright \(c\) \d{4} by Christian Kellner\.\n \* Licensed under Apache-2.0 with Commons Clause and Attribution\/Naming Clause\n \*\/\n\n)+/;
for (let file of files) {
try {
let content = await fs.readFile(file, 'utf8');
if (!content.startsWith(COPYRIGHT)) {
await fs.writeFile(file, COPYRIGHT + content);
console.log(`Added copyright to ${file}`);
const strippedContent = content.replace(oldCopyrightRegex, '');
const newContent = COPYRIGHT + strippedContent;
if (content !== newContent) {
await fs.writeFile(file, newContent);
console.log(`Added/Updated copyright in ${file}`);
}
} catch (err) {
console.error(`Error processing ${file}: ${err}`);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 512 KiB

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 402 KiB

After

Width:  |  Height:  |  Size: 531 KiB

View File

@@ -5,6 +5,8 @@ services:
context: .
dockerfile: Dockerfile
image: ghcr.io/orangecoding/fredy
environment:
- NODE_ENV=production
volumes:
- ./conf:/conf
- ./db:/db

View File

@@ -7,12 +7,72 @@ if [ "$(docker ps -aq -f name=fredy)" ]; then
docker rm fredy || true
fi
# On Apple Silicon, force linux/amd64 to match production CI and avoid arm64/x86_64
# Chrome mismatch under Rosetta. On native Linux (amd64 or arm64) let Docker pick naturally. That took me fucking 1 hour to figure out.
PLATFORM=""
if [ "$(uname -m)" = "arm64" ] && [ "$(uname -s)" = "Darwin" ]; then
PLATFORM="linux/amd64"
fi
# Build image from local Dockerfile, forcing a fresh build without cache
docker build --no-cache -t fredy:local .
if [ -n "$PLATFORM" ]; then
docker build --no-cache --platform "$PLATFORM" -t fredy:local .
else
docker build --no-cache -t fredy:local .
fi
# 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
if [ -n "$PLATFORM" ]; then
docker run -d --name fredy --platform "$PLATFORM" -v fredy_conf:/conf -v fredy_db:/db -p 9998:9998 fredy:local
else
docker run -d --name fredy -v fredy_conf:/conf -v fredy_db:/db -p 9998:9998 fredy:local
fi
echo "Waiting for app to be ready..."
for i in $(seq 1 30); do
if docker exec fredy curl -sf http://localhost:9998/ > /dev/null 2>&1; then
echo "App is up"
break
fi
if [ "$i" = "30" ]; then
echo "App did not come up in time"
docker logs fredy
exit 1
fi
sleep 2
done
# Verify the DB is readable/writable via the API.
# /api/demo is unauthenticated and reads the settings table - if SQLite is broken this returns an error.
echo "Testing DB via API (/api/demo)..."
DEMO_RESPONSE=$(docker exec fredy curl -sf http://localhost:9998/api/demo 2>&1)
if echo "$DEMO_RESPONSE" | grep -q "demoMode"; then
echo "DB is readable (got demoMode from /api/demo)"
else
echo "DB check failed - unexpected response from /api/demo: $DEMO_RESPONSE"
docker logs fredy
exit 1
fi
# Verify Chrome launches without crashing.
# On amd64: Chrome for Testing lives in the puppeteer cache.
# On arm64: system Chromium is used instead.
echo "Testing Chrome..."
CHROME=$(docker exec fredy find /root/.cache/puppeteer /home -name chrome -type f 2>/dev/null | head -1)
if [ -z "$CHROME" ]; then
CHROME=$(docker exec fredy which chromium 2>/dev/null || true)
fi
if [ -z "$CHROME" ]; then
echo "Chrome/Chromium binary not found"
exit 1
fi
if docker exec fredy "$CHROME" --headless --no-sandbox --disable-gpu --dump-dom https://example.com 2>&1 | grep -q "<html"; then
echo "Chrome works"
else
echo "Chrome failed to render a page"
docker exec fredy "$CHROME" --headless --no-sandbox --disable-gpu --dump-dom https://example.com 2>&1 | head -20
exit 1
fi
echo ""
echo "All checks passed."

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
@@ -8,94 +8,41 @@ import js from '@eslint/js';
import prettier from 'eslint-config-prettier';
import globals from 'globals';
import react from 'eslint-plugin-react';
import babelParser from '@babel/eslint-parser';
export default [
{
files: ['**/*.{js,jsx,ts,tsx}'],
ignores: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/public/**', 'db/**', 'conf/**'],
},
js.configs.recommended,
prettier,
{
files: ['**/*.{js,jsx}'],
languageOptions: {
parser: babelParser,
ecmaVersion: 'latest',
sourceType: 'module',
ecmaVersion: 2021,
parserOptions: {
ecmaFeatures: { jsx: true },
},
globals: {
...globals.browser,
...globals.node,
...globals.mocha,
...globals.jest,
Promise: 'readonly',
fetch: 'readonly',
describe: 'readonly',
after: 'readonly',
it: 'readonly',
beforeEach: 'readonly',
afterEach: 'readonly',
vi: 'readonly',
},
parserOptions: { requireConfigFile: false },
},
plugins: { react },
rules: {
eqeqeq: [2, 'allow-null'],
strict: 0,
'no-redeclare': [2, { builtinGlobals: false }],
'class-methods-use-this': 'off',
indent: ['off', 2],
'linebreak-style': ['error', 'unix'],
quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: true }],
semi: ['error', 'always'],
'no-console': ['error', { allow: ['warn', 'error'] }],
'jsx-quotes': ['error', 'prefer-double'],
'react/display-name': 'off',
'react/forbid-prop-types': 'off',
'react/jsx-closing-bracket-location': 'off',
'react/jsx-curly-spacing': 'off',
'react/jsx-handler-names': ['off', { eventHandlerPrefix: 'handle', eventHandlerPropPrefix: 'on' }],
'react/jsx-indent-props': 'off',
'react/jsx-key': 'off',
'react/jsx-max-props-per-line': 'off',
'react/jsx-no-bind': ['error', { ignoreRefs: true, allowArrowFunctions: true, allowBind: false }],
'react/jsx-no-duplicate-props': ['error', { ignoreCase: true }],
'react/jsx-no-literals': 'off',
'react/jsx-no-undef': 'error',
'react/jsx-pascal-case': ['error', { allowAllCaps: true, ignore: [] }],
'react/sort-prop-types': ['off', { ignoreCase: true, callbacksLast: false, requiredFirst: false }],
'react/jsx-sort-prop-types': 'off',
'react/jsx-sort-props': 'off',
'react/jsx-uses-react': 'error',
'react/jsx-uses-vars': 'error',
'react/no-danger': 'warn',
'react/no-deprecated': 'error',
'react/no-did-mount-set-state': 'error',
'react/no-did-update-set-state': 'warn',
'react/no-direct-mutation-state': 'off',
'react/no-is-mounted': 'error',
'react/no-set-state': 'off',
'react/no-string-refs': 'warn',
'react/no-unknown-property': 'error',
'react/prop-types': ['error', { ignore: [], customValidators: [], skipUndeclared: true }],
'react/react-in-jsx-scope': 'error',
'react/require-extension': 'off',
'react/require-render-return': 'error',
'react/self-closing-comp': 'warn',
'react/sort-comp': 'off',
'react/jsx-wrap-multilines': ['warn', { declaration: true, assignment: true, return: true }],
'react/wrap-multilines': 'off',
'react/jsx-first-prop-new-line': 'off',
'react/jsx-equals-spacing': ['warn', 'never'],
'react/jsx-no-target-blank': 'error',
'react/jsx-filename-extension': ['error', { extensions: ['.jsx'] }],
'react/jsx-no-comment-textnodes': 'error',
'react/no-comment-textnodes': 'off',
'react/no-render-return-value': 'error',
'react/require-optimization': ['off', { allowDecorators: [] }],
'react/no-find-dom-node': 'warn',
'react/forbid-component-props': ['off', { forbid: [] }],
'react/no-danger-with-children': 'error',
'react/no-unused-prop-types': ['warn', { customValidators: [], skipShapeProps: true }],
'react/style-prop-object': 'error',
'react/no-children-prop': 'warn',
},
settings: { react: { version: 'detect' } },
rules: {
...js.configs.recommended.rules,
'no-console': ['error', { allow: ['warn', 'error'] }],
},
},
prettier,
];

View File

@@ -7,10 +7,13 @@
content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
/>
<meta name="google" content="notranslate" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>Fredy || Real Estate Finder</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head>
<body theme-mode="dark">
<div id="fredy" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0"></div>

View File

@@ -1,23 +1,29 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import fs from 'fs';
import { checkIfConfigIsAccessible, 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 FredyPipeline from './lib/FredyPipeline.js';
import { duringWorkingHoursOrNotSet } from './lib/utils.js';
import { runMigrations } from './lib/services/storage/migrations/migrate.js';
import { ensureDemoUserExists, ensureAdminUserExists } from './lib/services/storage/userStorage.js';
import { cleanupDemoAtMidnight } from './lib/services/crons/demoCleanup-cron.js';
import { initTrackerCron } from './lib/services/crons/tracker-cron.js';
import logger from './lib/services/logger.js';
import { bus } from './lib/services/events/event-bus.js';
import { initActiveCheckerCron } from './lib/services/crons/listing-alive-cron.js';
import { initGeocodingCron } from './lib/services/crons/geocoding-cron.js';
import { getSettings } from './lib/services/storage/settingsStorage.js';
import SqliteConnection, { computeDbPath } from './lib/services/storage/SqliteConnection.js';
import { initJobExecutionService } from './lib/services/jobs/jobExecutionService.js';
import { ensureValidBinary } from './lib/services/ensureValidBinary.js';
// Ensure the CloakBrowser stealth Chromium binary is present and complete before
// jobs run. ensureValidBinary() also detects and auto-heals partial extractions
// (e.g. a newer version that was downloaded but only the chrome executable was
// written) so Chrome never crashes with "Invalid file descriptor to ICU data".
logger.info('Checking CloakBrowser binary...');
await ensureValidBinary();
logger.info('CloakBrowser binary ready.');
//in the config, we store the path of the sqlite file, thus we must check if it is available
const isConfigAccessible = await checkIfConfigIsAccessible();
@@ -36,7 +42,7 @@ await runMigrations();
const settings = await getSettings();
// Ensure sqlite directory exists before loading anything else (based on config.sqlitepath)
// Ensure the sqlite directory exists before loading anything else (based on config.sqlitepath)
const { dir: sqliteDir } = await computeDbPath();
if (!fs.existsSync(sqliteDir)) {
fs.mkdirSync(sqliteDir, { recursive: true });
@@ -56,55 +62,16 @@ await import('./lib/api/api.js');
if (settings.demoMode) {
logger.info('Running in demo mode');
cleanupDemoAtMidnight();
}
logger.info(`Started Fredy successfully. Ui can be accessed via http://localhost:${settings.port}`);
ensureAdminUserExists();
ensureDemoUserExists();
await initTrackerCron();
//do not wait for this to finish, let it run in the background
initActiveCheckerCron();
initGeocodingCron();
bus.on('jobs:runAll', () => {
logger.debug('Running Fredy Job manually');
execute();
});
logger.info(`Started Fredy successfully. Ui can be accessed via http://localhost:${settings.port}`);
const execute = () => {
const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(settings, Date.now());
if (!settings.demoMode) {
if (isDuringWorkingHoursOrNotSet) {
settings.lastRun = Date.now();
jobStorage
.getJobs()
.filter((job) => job.enabled)
.forEach((job) => {
job.provider
.filter((p) => providers.find((loaded) => loaded.metaInformation.id === p.id) != null)
.forEach(async (prov) => {
try {
const matchedProvider = providers.find((loaded) => loaded.metaInformation.id === prov.id);
matchedProvider.init(prov, job.blacklist);
await new FredyPipeline(
matchedProvider.config,
job.notificationAdapter,
prov.id,
job.id,
similarityCache,
).execute();
} catch (error) {
logger.error(error);
}
});
});
} else {
logger.debug('Working hours set. Skipping as outside of working hours.');
}
}
};
setInterval(execute, INTERVAL);
//start once at startup
execute();
// Initialize the lean Job Execution Service (schedules and bus listeners)
initJobExecutionService({ providers, settings, intervalMs: INTERVAL });

12
jsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ESNext",
"checkJs": true,
"allowJs": true,
"noEmit": true,
"strict": false
},
"exclude": ["node_modules", "ui"]
}

View File

@@ -1,221 +0,0 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { NoNewListingsWarning } from './errors.js';
import { storeListings, getKnownListingHashesForJobAndProvider } from './services/storage/listingsStorage.js';
import * as notify from './notification/notify.js';
import Extractor from './services/extractor/extractor.js';
import urlModifier from './services/queryStringMutator.js';
import logger from './services/logger.js';
/**
* @typedef {Object} Listing
* @property {string} id Stable unique identifier (hash) of the listing.
* @property {string} title Title or headline of the listing.
* @property {string} [address] Optional address/location text.
* @property {string} [price] Optional price text/value.
* @property {string} [url] Link to the listing detail page.
* @property {any} [meta] Provider-specific additional metadata.
*/
/**
* @typedef {Object} SimilarityCache
* @property {(title:string, address?:string)=>boolean} hasSimilarEntries Returns true if a similar entry is known.
* @property {(title:string, address?:string)=>void} addCacheEntry Adds a new entry to the similarity cache.
*/
/**
* Runtime orchestrator for fetching, normalizing, filtering, deduplicating, storing,
* and notifying about new listings from a configured provider.
*
* The execution flow is:
* 1) Prepare provider URL (sorting, etc.)
* 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) Persist new listings
* 7) Filter out entries similar to already seen ones
* 8) Dispatch notifications
*/
class FredyPipeline {
/**
* Create a new runtime instance for a single provider/job execution.
*
* @param {Object} providerConfig Provider configuration.
* @param {string} providerConfig.url Base URL to crawl.
* @param {string} [providerConfig.sortByDateParam] Query parameter used to enforce sorting by date (provider-specific).
* @param {string} [providerConfig.waitForSelector] CSS selector to wait for before parsing content.
* @param {Object.<string, string>} providerConfig.crawlFields Mapping of field names to selectors/paths to extract.
* @param {string} providerConfig.crawlContainer CSS selector for the container holding listing items.
* @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 {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.
*/
constructor(providerConfig, notificationConfig, providerId, jobKey, similarityCache) {
this._providerConfig = providerConfig;
this._notificationConfig = notificationConfig;
this._providerId = providerId;
this._jobKey = jobKey;
this._similarityCache = similarityCache;
}
/**
* Execute the end-to-end pipeline for a single provider run.
*
* @returns {Promise<Listing[]|void>} Resolves to the list of new (and similarity-filtered) listings
* after notifications have been sent; resolves to void when there are no new listings.
*/
execute() {
return Promise.resolve(urlModifier(this._providerConfig.url, this._providerConfig.sortByDateParam))
.then(this._providerConfig.getListings?.bind(this) ?? this._getListings.bind(this))
.then(this._normalize.bind(this))
.then(this._filter.bind(this))
.then(this._findNew.bind(this))
.then(this._save.bind(this))
.then(this._filterBySimilarListings.bind(this))
.then(this._notify.bind(this))
.catch(this._handleError.bind(this));
}
/**
* Fetch listings from the provider, using the default Extractor flow unless
* a provider-specific getListings override is supplied.
*
* @param {string} url The provider URL to fetch from.
* @returns {Promise<Listing[]>} Resolves with an array of listings (empty when none found).
*/
_getListings(url) {
const extractor = new Extractor();
return new Promise((resolve, reject) => {
extractor
.execute(url, this._providerConfig.waitForSelector)
.then(() => {
const listings = extractor.parseResponseText(
this._providerConfig.crawlContainer,
this._providerConfig.crawlFields,
url,
);
resolve(listings == null ? [] : listings);
})
.catch((err) => {
reject(err);
logger.error(err);
});
});
}
/**
* Normalize raw listings into the provider-specific Listing shape.
*
* @param {any[]} listings Raw listing entries from the extractor or override.
* @returns {Listing[]} Normalized listings.
*/
_normalize(listings) {
return listings.map(this._providerConfig.normalize);
}
/**
* Filter out listings that are missing required fields and those rejected by the
* provider's blacklist/filter function.
*
* @param {Listing[]} listings Listings to filter.
* @returns {Listing[]} Filtered listings that pass validation and provider filter.
*/
_filter(listings) {
const keys = Object.keys(this._providerConfig.crawlFields);
const filteredListings = listings.filter((item) => keys.every((key) => key in item));
return filteredListings.filter(this._providerConfig.filter);
}
/**
* Determine which listings are new by comparing their IDs against stored hashes.
*
* @param {Listing[]} listings Listings to evaluate for novelty.
* @returns {Listing[]} New listings not seen before.
* @throws {NoNewListingsWarning} When no new listings are found.
*/
_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));
if (newListings.length === 0) {
throw new NoNewListingsWarning();
}
return newListings;
}
/**
* Send notifications for new listings using the configured notification adapter(s).
*
* @param {Listing[]} newListings New listings to notify about.
* @returns {Promise<Listing[]>} Resolves to the provided listings after notifications complete.
* @throws {NoNewListingsWarning} When there are no listings to notify about.
*/
_notify(newListings) {
if (newListings.length === 0) {
throw new NoNewListingsWarning();
}
const sendNotifications = notify.send(this._providerId, newListings, this._notificationConfig, this._jobKey);
return Promise.all(sendNotifications).then(() => newListings);
}
/**
* Persist new listings and pass them through.
*
* @param {Listing[]} newListings Listings to store.
* @returns {Listing[]} The same listings, unchanged.
*/
_save(newListings) {
logger.debug(`Storing ${newListings.length} new listings (Provider: '${this._providerId}')`);
storeListings(this._jobKey, this._providerId, newListings);
return newListings;
}
/**
* Remove listings that are similar to already known entries according to the similarity cache.
* Adds the remaining listings to the cache.
*
* @param {Listing[]} listings Listings to filter by similarity.
* @returns {Listing[]} Listings considered unique enough to keep.
*/
_filterBySimilarListings(listings) {
return listings.filter((listing) => {
const similar = this._similarityCache.checkAndAddEntry({
title: listing.title,
address: listing.address,
price: listing.price,
});
if (similar) {
logger.debug(
`Filtering similar entry for title '${listing.title}' and address '${listing.address}' (Provider: '${this._providerId}')`,
);
}
return !similar;
});
}
/**
* Handle errors occurring in the pipeline, logging levels depending on type.
*
* @param {Error} err Error instance thrown by previous steps.
* @returns {void}
*/
_handleError(err) {
if (err.name === 'NoNewListingsWarning') {
logger.debug(`No new listings found (Provider: '${this._providerId}').`);
} else {
logger.error(err);
}
}
}
export default FredyPipeline;

414
lib/FredyPipelineExecutioner.js Executable file
View File

@@ -0,0 +1,414 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { NoNewListingsWarning } from './errors.js';
import {
storeListings,
getKnownListingHashesForJobAndProvider,
deleteListingsById,
} from './services/storage/listingsStorage.js';
import { getJob } from './services/storage/jobStorage.js';
import * as notify from './notification/notify.js';
import Extractor from './services/extractor/extractor.js';
import urlModifier from './services/queryStringMutator.js';
import logger from './services/logger.js';
import { geocodeAddress } from './services/geocoding/geoCodingService.js';
import { distanceMeters } from './services/listings/distanceCalculator.js';
import { getUserSettings, getSettings } from './services/storage/settingsStorage.js';
import { updateListingDistance } from './services/storage/listingsStorage.js';
import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
import { formatListing } from './utils/formatListing.js';
/** @import { ParsedListing } from './types/listing.js' */
/** @import { Job } from './types/job.js' */
/** @import { ProviderConfig } from './types/providerConfig.js' */
/** @import { SpecFilter, SpatialFilter } from './types/filter.js' */
/** @import { SimilarityCache } from './types/similarityCache.js' */
/** @import { Browser } from './types/browser.js' */
/**
* Runtime orchestrator for fetching, normalizing, filtering, deduplicating, storing,
* and notifying about new listings from a configured provider.
*
* The execution flow is:
* 1) Prepare provider URL (sorting, etc.)
* 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) Persist new listings
* 7) Filter out entries similar to already seen ones
* 8) Filter out entries that do not match the job's specFilter
* 9) Filter out entries that do not match the job's spatialFilter
* 10) Dispatch notifications
*/
class FredyPipelineExecutioner {
/**
* Create a new runtime instance for a single provider/job execution.
*
* @param {ProviderConfig} providerConfig Provider configuration.
* @param {Job} job Job configuration.
* @param {string} providerId The ID of the provider currently in use.
* @param {SimilarityCache} similarityCache Cache instance for checking similar entries.
* @param {Browser} browser Puppeteer browser instance.
*/
constructor(providerConfig, job, providerId, similarityCache, browser) {
/** @type {ProviderConfig} */
this._providerConfig = providerConfig;
/** @type {Object} */
this._jobNotificationConfig = job.notificationAdapter;
/** @type {string} */
this._jobKey = job.id;
/** @type {SpecFilter | null} */
this._jobSpecFilter = job.specFilter;
/** @type {SpatialFilter | null} */
this._jobSpatialFilter = job.spatialFilter;
/** @type {string} */
this._providerId = providerId;
/** @type {SimilarityCache} */
this._similarityCache = similarityCache;
/** @type {Browser} */
this._browser = browser;
}
/**
* Execute the end-to-end pipeline for a single provider run.
*
* @returns {Promise<ParsedListing[]|void>} Resolves to the list of new (and similarity-filtered) listings
* after notifications have been sent; resolves to void when there are no new listings.
*/
execute() {
return Promise.resolve(urlModifier(this._providerConfig.url, this._providerConfig.sortByDateParam))
.then(this._providerConfig.getListings?.bind(this) ?? this._getListings.bind(this))
.then(this._normalize.bind(this))
.then(this._filter.bind(this))
.then(this._findNew.bind(this))
.then(this._fetchDetails.bind(this))
.then(this._geocode.bind(this))
.then(this._save.bind(this))
.then(this._calculateDistance.bind(this))
.then(this._filterBySimilarListings.bind(this))
.then(this._filterBySpecs.bind(this))
.then(this._filterByArea.bind(this))
.then(this._notify.bind(this))
.catch(this._handleError.bind(this));
}
/**
* Optionally enrich new listings with data from their detail pages.
* Only called when the provider config defines a `fetchDetails` function.
* Runs all fetches in parallel. Each individual fetch must handle its own errors
* and always resolve (never reject) to avoid aborting other listings.
*
* @param {Listing[]} newListings New listings to enrich.
* @returns {Promise<Listing[]>} Resolves with enriched listings.
*/
async _fetchDetails(newListings) {
if (typeof this._providerConfig.fetchDetails !== 'function') {
return newListings;
}
const userId = getJob(this._jobKey)?.userId;
const enabledProviders = getUserSettings(userId)?.provider_details ?? [];
if (!userId || !Array.isArray(enabledProviders) || !enabledProviders.includes(this._providerId)) {
return newListings;
}
const listingsToEnrich = process.env.NODE_ENV === 'test' ? newListings.slice(0, 1) : newListings;
const enriched = [];
for (const listing of listingsToEnrich) {
enriched.push(await this._providerConfig.fetchDetails(listing, this._browser));
}
return enriched;
}
/**
* Geocode new listings.
*
* @param {ParsedListing[]} newListings New listings to geocode.
* @returns {Promise<ParsedListing[]>} Resolves with the listings (potentially with added coordinates).
*/
async _geocode(newListings) {
for (const listing of newListings) {
if (listing.address) {
const coords = await geocodeAddress(listing.address);
if (coords) {
listing.latitude = coords.lat;
listing.longitude = coords.lng;
}
}
}
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 {ParsedListing[]} newListings New listings to filter by area.
* @returns {ParsedListing[]} Resolves with listings that are within the area (or not filtered if no area is set).
*/
_filterByArea(newListings) {
const polygonFeatures = this._jobSpatialFilter?.features?.filter((f) => f.geometry?.type === 'Polygon');
// If no area filter is set, return all listings
if (!polygonFeatures?.length) {
return newListings;
}
const toDeleteListingByIds = [];
// Filter listings by area - keep only those within the polygon
const keptListings = 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));
if (!isInPolygon) {
toDeleteListingByIds.push(listing.id);
}
return isInPolygon;
});
if (toDeleteListingByIds.length > 0) {
deleteListingsById(toDeleteListingByIds);
}
return keptListings;
}
/**
* Filter listings based on its specifications (minRooms, minSize, maxPrice).
*
* @param {ParsedListing[]} newListings New listings to filter.
* @returns {ParsedListing[]} Resolves with listings that pass the specification filters.
*/
_filterBySpecs(newListings) {
const { minRooms, minSize, maxPrice } = this._jobSpecFilter || {};
// If no specs are set, return all listings
if (!minRooms && !minSize && !maxPrice) {
return newListings;
}
const toDeleteListingByIds = [];
const keptListings = newListings.filter((listing) => {
const filterOut =
(minRooms && listing.rooms && listing.rooms < minRooms) ||
(minSize && listing.size && listing.size < minSize) ||
(maxPrice && listing.price && listing.price > maxPrice);
if (filterOut) {
toDeleteListingByIds.push(listing.id);
}
return !filterOut;
});
if (toDeleteListingByIds.length > 0) {
deleteListingsById(toDeleteListingByIds);
}
return keptListings;
}
/**
* Fetch listings from the provider, using the default Extractor flow unless
* a provider-specific getListings override is supplied.
*
* @param {string} url The provider URL to fetch from.
* @returns {Promise<ParsedListing[]>} Resolves with an array of listings (empty when none found).
*/
_getListings(url) {
const extractor = new Extractor({ ...this._providerConfig.puppeteerOptions, browser: this._browser });
return new Promise((resolve, reject) => {
extractor
.execute(url, this._providerConfig.waitForSelector, this._providerId)
.then(() => {
const listings = extractor.parseResponseText(
this._providerConfig.crawlContainer,
this._providerConfig.crawlFields,
url,
);
resolve(listings == null ? [] : listings);
})
.catch((err) => {
reject(err);
logger.error(err);
});
});
}
/**
* Normalize raw listings into the provider-specific ParsedListing shape.
*
* @param {any[]} listings Raw listing entries from the extractor or override.
* @returns {ParsedListing[]} Normalized listings.
*/
_normalize(listings) {
return listings.map((listing) => this._providerConfig.normalize(listing));
}
/**
* Filter out listings that are missing required fields and those rejected by the
* provider's blacklist/filter function.
*
* @param {ParsedListing[]} listings Listings to filter.
* @returns {ParsedListing[]} Filtered listings that pass validation and provider filter.
*/
_filter(listings) {
const requiredKeys = this._providerConfig.requiredFieldNames;
const requireValues = ['id', 'link', 'title'];
const filteredListings = listings
// this should never filter some listings out, because the normalize function should always extract all fields.
.filter((item) => requiredKeys.every((key) => key in item))
// TODO: move blacklist filter to this file, so it will handle for all providers in same way.
.filter(this._providerConfig.filter)
// filter out listings that are missing required fields
.filter((item) => requireValues.every((key) => item[key] != null));
return filteredListings;
}
/**
* Determine which listings are new by comparing their IDs against stored hashes.
*
* @param {ParsedListing[]} listings Listings to evaluate for novelty.
* @returns {ParsedListing[]} New listings not seen before.
* @throws {NoNewListingsWarning} When no new listings are found.
*/
_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));
if (newListings.length === 0) {
throw new NoNewListingsWarning();
}
return newListings;
}
/**
* Send notifications for new listings using the configured notification adapter(s).
*
* @param {ParsedListing[]} newListings New listings to notify about.
* @returns {Promise<ParsedListing[]>} Resolves to the provided listings after notifications complete.
* @throws {NoNewListingsWarning} When there are no listings to notify about.
*/
async _notify(newListings) {
if (newListings.length === 0) {
throw new NoNewListingsWarning();
}
const formattedListings = newListings.map(formatListing);
const settings = await getSettings();
const baseUrl = settings?.baseUrl ?? '';
const sendNotifications = notify.send(
this._providerId,
formattedListings,
this._jobNotificationConfig,
this._jobKey,
baseUrl,
);
return Promise.all(sendNotifications).then(() => newListings);
}
/**
* Persist new listings and pass them through.
*
* @param {ParsedListing[]} newListings Listings to store.
* @returns {ParsedListing[]} The same listings, unchanged.
*/
_save(newListings) {
logger.debug(`Storing ${newListings.length} new listings (Provider: '${this._providerId}')`);
storeListings(this._jobKey, this._providerId, newListings);
return newListings;
}
/**
* Calculate distance for new listings.
*
* @param {ParsedListing[]} listings
* @returns {ParsedListing[]}
* @private
*/
_calculateDistance(listings) {
if (listings.length === 0) return [];
const job = getJob(this._jobKey);
const userId = job?.userId;
if (userId == null || typeof userId !== 'string') {
logger.debug('Skipping distance calculation: userId is missing or invalid');
return listings;
}
const userSettings = getUserSettings(userId);
const homeAddress = userSettings?.home_address;
if (!homeAddress || !homeAddress.coords) {
return listings;
}
const { lat, lng } = homeAddress.coords;
for (const listing of listings) {
if (listing.latitude != null && listing.longitude != null) {
const dist = distanceMeters(lat, lng, listing.latitude, listing.longitude);
updateListingDistance(listing.id, dist);
listing.distance_to_destination = dist;
}
}
return listings;
}
/**
* Remove listings that are similar to already known entries according to the similarity cache.
* Adds the remaining listings to the cache.
*
* @param {ParsedListing[]} listings Listings to filter by similarity.
* @returns {ParsedListing[]} Listings considered unique enough to keep.
*/
_filterBySimilarListings(listings) {
const filteredIds = [];
const keptListings = listings.filter((listing) => {
const similar = this._similarityCache.checkAndAddEntry({
title: listing.title,
address: listing.address,
price: listing.price,
});
if (similar) {
logger.debug(
`Filtering similar entry for title '${listing.title}' and address '${listing.address}' (Provider: '${this._providerId}')`,
);
filteredIds.push(listing.id);
}
return !similar;
});
if (filteredIds.length > 0) {
deleteListingsById(filteredIds);
}
return keptListings;
}
/**
* Handle errors occurring in the pipeline, logging levels depending on type.
*
* @param {Error} err Error instance thrown by previous steps.
* @returns {void}
*/
_handleError(err) {
if (err.name === 'NoNewListingsWarning') {
logger.debug(`No new listings found (Provider: '${this._providerId}').`);
} else {
logger.error(err);
}
}
}
export default FredyPipelineExecutioner;

14
lib/TRACKING_POIS.js Normal file
View File

@@ -0,0 +1,14 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
export const TRACKING_POIS = {
DISTANCE_ADDRESS_ENTERED: 'DISTANCE_ADDRESS_ENTERED',
WELCOME_FINISHED: 'WELCOME_FINISHED',
WELCOME_SKIPPED: 'WELCOME_SKIPPED',
JOBS_TABLE_VIEW: 'JOBS_TABLE_VIEW',
LISTING_TABLE_VIEW: 'LISTING_TABLE_VIEW',
BASE_URL_SETTING: 'BASE_URL_SETTING',
DETECTED_AS_BOT: 'DETECTED_AS_BOT',
};

View File

@@ -1,58 +1,102 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { notificationAdapterRouter } from './routes/notificationAdapterRouter.js';
import { authInterceptor, cookieSession, adminInterceptor } from './security.js';
import { generalSettingsRouter } from './routes/generalSettingsRoute.js';
import { providerRouter } from './routes/providerRouter.js';
import { versionRouter } from './routes/versionRouter.js';
import { loginRouter } from './routes/loginRoute.js';
import { userRouter } from './routes/userRoute.js';
import { jobRouter } from './routes/jobRouter.js';
import bodyParser from 'body-parser';
import restana from 'restana';
import files from 'serve-static';
import Fastify from 'fastify';
import fastifyHelmet from '@fastify/helmet';
import fastifyCookie from '@fastify/cookie';
import fastifySession from '@fastify/session';
import fastifyStatic from '@fastify/static';
import path from 'path';
import { getDirName } from '../utils.js';
import { demoRouter } from './routes/demoRouter.js';
import { getSettings, getOrCreateSessionSecret } from '../services/storage/settingsStorage.js';
import logger from '../services/logger.js';
import { listingsRouter } from './routes/listingsRouter.js';
import { getSettings } from '../services/storage/settingsStorage.js';
import { featureRouter } from './routes/featureRouter.js';
import { dashboardRouter } from './routes/dashboardRouter.js';
import { backupRouter } from './routes/backupRouter.js';
const service = restana();
const staticService = files(path.join(getDirName(), '../ui/public'));
import { authHook, adminHook } from './security.js';
import loginPlugin from './routes/loginRoute.js';
import demoPlugin from './routes/demoRouter.js';
import jobPlugin from './routes/jobRouter.js';
import versionPlugin from './routes/versionRouter.js';
import listingsPlugin from './routes/listingsRouter.js';
import dashboardPlugin from './routes/dashboardRouter.js';
import userSettingsPlugin from './routes/userSettingsRoute.js';
import trackingPlugin from './routes/trackingRoute.js';
import generalSettingsPlugin from './routes/generalSettingsRoute.js';
import backupPlugin from './routes/backupRouter.js';
import userPlugin from './routes/userRoute.js';
import notificationAdapterPlugin from './routes/notificationAdapterRouter.js';
import providerPlugin from './routes/providerRouter.js';
import { registerMcpRoutes } from '../mcp/mcpHttpRoute.js';
const PORT = (await getSettings()).port || 9998;
const sessionSecret = await getOrCreateSessionSecret();
const SESSION_MAX_AGE = 2 * 60 * 60 * 1000;
service.use(bodyParser.json());
service.use(cookieSession());
service.use(staticService);
service.use('/api/admin', authInterceptor());
service.use('/api/jobs', authInterceptor());
service.use('/api/version', authInterceptor());
service.use('/api/listings', authInterceptor());
service.use('/api/dashboard', authInterceptor());
service.use('/api/features', authInterceptor());
// /admin can only be accessed when user is having admin permissions
service.use('/api/admin', adminInterceptor());
service.use('/api/jobs/notificationAdapter', notificationAdapterRouter);
service.use('/api/admin/generalSettings', generalSettingsRouter);
service.use('/api/admin/backup', backupRouter);
service.use('/api/jobs/provider', providerRouter);
service.use('/api/admin/users', userRouter);
service.use('/api/version', versionRouter);
service.use('/api/jobs', jobRouter);
service.use('/api/login', loginRouter);
service.use('/api/listings', listingsRouter);
service.use('/api/features', featureRouter);
service.use('/api/dashboard', dashboardRouter);
//this route is unsecured intentionally as it is being queried from the login page
service.use('/api/demo', demoRouter);
service.start(PORT).then(() => {
logger.debug(`Started API service on port ${PORT}`);
const fastify = Fastify({
logger: false,
bodyLimit: 50 * 1024 * 1024, // 50 MB for backup uploads
});
// Security headers (CSP disabled to avoid breaking the SPA)
await fastify.register(fastifyHelmet, { contentSecurityPolicy: false });
// Cookie + session (in-memory store, signed cookie)
await fastify.register(fastifyCookie);
await fastify.register(fastifySession, {
secret: sessionSecret,
cookieName: 'fredy-admin-session',
cookie: {
maxAge: SESSION_MAX_AGE,
httpOnly: true,
secure: false,
sameSite: 'lax',
},
saveUninitialized: false,
});
// Serve the React SPA from ui/public/
await fastify.register(fastifyStatic, {
root: path.join(getDirName(), '../ui/public'),
wildcard: false,
});
// Public routes - no auth required
fastify.register(loginPlugin, { prefix: '/api/login' });
fastify.register(demoPlugin, { prefix: '/api/demo' });
// User-authenticated routes
fastify.register(async (app) => {
app.addHook('preHandler', authHook);
app.register(jobPlugin, { prefix: '/api/jobs' });
app.register(notificationAdapterPlugin, { prefix: '/api/jobs/notificationAdapter' });
app.register(providerPlugin, { prefix: '/api/jobs/provider' });
app.register(versionPlugin, { prefix: '/api/version' });
app.register(listingsPlugin, { prefix: '/api/listings' });
app.register(dashboardPlugin, { prefix: '/api/dashboard' });
app.register(userSettingsPlugin, { prefix: '/api/user/settings' });
app.register(trackingPlugin, { prefix: '/api/tracking' });
app.register(generalSettingsPlugin, { prefix: '/api/admin/generalSettings' });
});
// Admin-only routes
fastify.register(async (app) => {
app.addHook('preHandler', authHook);
app.addHook('preHandler', adminHook);
app.register(backupPlugin, { prefix: '/api/admin/backup' });
app.register(userPlugin, { prefix: '/api/admin/users' });
});
// MCP Streamable HTTP (Bearer token auth - no session)
registerMcpRoutes(fastify);
// SPA fallback - serve index.html for all non-API GET requests
fastify.setNotFoundHandler((request, reply) => {
if (!request.url.startsWith('/api/')) {
return reply.sendFile('index.html');
}
return reply.code(404).send({ error: 'Not found' });
});
await fastify.listen({ port: PORT, host: '0.0.0.0' });
logger.debug(`Started API service on port ${PORT}`);

View File

@@ -1,75 +1,63 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import restana from 'restana';
import {
buildBackupFileName,
createBackupZip,
precheckRestore,
restoreFromZip,
} from '../../services/storage/backupRestoreService.js';
import { getSettings } from '../../services/storage/settingsStorage.js';
import { isAdmin } from '../security.js';
const DEMO_MODE_ERROR = 'Backup and restore are not available in demo mode.';
/**
* Backup & Restore Admin Router
*
* Endpoints:
* - GET /api/admin/backup
* Returns the current database as a zip download. Content-Type: application/zip
* - POST /api/admin/backup/restore?dryRun=true
* Accepts a zip file (raw body). Returns a compatibility report, does not restore.
* - POST /api/admin/backup/restore?force=true|false
* Accepts a zip file (raw body). Restores the database; when incompatible and force=false, returns 400.
* @param {import('fastify').FastifyInstance} fastify
*/
const service = restana();
const backupRouter = service.newRouter();
export default async function backupPlugin(fastify) {
// Parse raw binary uploads as Buffer
fastify.addContentTypeParser(
['application/zip', 'application/octet-stream'],
{ parseAs: 'buffer' },
(req, body, done) => done(null, body),
);
backupRouter.get('/', async (req, res) => {
const zipBuffer = await createBackupZip();
const fileName = await buildBackupFileName();
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
res.send(zipBuffer);
});
fastify.get('/', async (request, reply) => {
const settings = await getSettings();
if (settings.demoMode && !isAdmin(request)) {
return reply.code(403).send({ error: DEMO_MODE_ERROR });
}
const zipBuffer = await createBackupZip();
const fileName = await buildBackupFileName();
reply.header('Content-Type', 'application/zip');
reply.header('Content-Disposition', `attachment; filename="${fileName}"`);
return reply.send(zipBuffer);
});
/**
* Read the full request body as a Buffer. Used for raw zip uploads.
* @param {import('http').IncomingMessage} req
* @returns {Promise<Buffer>}
*/
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', (e) => reject(e));
fastify.post('/restore', async (request, reply) => {
const settings = await getSettings();
if (settings.demoMode && !isAdmin(request)) {
return reply.code(403).send({ error: DEMO_MODE_ERROR });
}
const { dryRun = 'false', force = 'false' } = request.query || {};
const doDryRun = String(dryRun) === 'true';
const doForce = String(force) === 'true';
const body = request.body; // Buffer from addContentTypeParser
if (doDryRun) {
return precheckRestore(body);
}
try {
return restoreFromZip(body, { force: doForce });
} catch (e) {
return reply.code(400).send({
message: e?.message || 'Restore failed',
details: e?.payload || null,
});
}
});
}
// Upload endpoint. Accepts raw zip (Content-Type: application/zip or application/octet-stream)
// Query parameters:
// - dryRun=true => only validate and return compatibility info
// - force=true => proceed even if incompatible
backupRouter.post('/restore', async (req, res) => {
const { dryRun = 'false', force = 'false' } = req.query || {};
const doDryRun = String(dryRun) === 'true';
const doForce = String(force) === 'true';
const body = await readBody(req);
if (doDryRun) {
res.body = await precheckRestore(body);
return res.send();
}
try {
res.body = await restoreFromZip(body, { force: doForce });
return res.send();
} catch (e) {
res.statusCode = 400;
res.body = { message: e?.message || 'Restore failed', details: e?.payload || null };
return res.send();
}
});
export { backupRouter };

View File

@@ -1,25 +1,16 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import restana from 'restana';
import * as jobStorage from '../../services/storage/jobStorage.js';
import * as userStorage from '../../services/storage/userStorage.js';
import { getListingsKpisForJobIds, getProviderDistributionForJobIds } from '../../services/storage/listingsStorage.js';
import { getSettings } from '../../services/storage/settingsStorage.js';
import { isAdmin } from '../security.js';
const service = restana();
export const dashboardRouter = service.newRouter();
function isAdmin(req) {
const user = req.session?.currentUser ? userStorage.getUser(req.session.currentUser) : null;
return !!user?.isAdmin;
}
function getAccessibleJobs(req) {
const currentUser = req.session.currentUser;
const admin = isAdmin(req);
function getAccessibleJobs(request) {
const currentUser = request.session.currentUser;
const admin = isAdmin(request);
return jobStorage
.getJobs()
.filter((job) => admin || job.userId === currentUser || job.shared_with_user.includes(currentUser));
@@ -29,43 +20,45 @@ function cap(val) {
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
}
dashboardRouter.get('/', async (req, res) => {
const jobs = getAccessibleJobs(req);
const settings = await getSettings();
/**
* @param {import('fastify').FastifyInstance} fastify
*/
export default async function dashboardPlugin(fastify) {
fastify.get('/', async (request) => {
const jobs = getAccessibleJobs(request);
const settings = await getSettings();
// KPIs
const totalJobs = jobs.length;
const totalListings = jobs.reduce((sum, j) => sum + (j.numberOfFoundListings || 0), 0);
const jobIds = jobs.map((j) => j.id);
const { numberOfActiveListings, avgPriceOfListings } = getListingsKpisForJobIds(jobIds);
// Build Pie data in a simple shape the frontend can consume directly
// Shape: { labels: string[], values: number[] } with values as percentages
const providerPieRaw = getProviderDistributionForJobIds(jobIds);
const providerPie = Array.isArray(providerPieRaw)
? {
labels: providerPieRaw.map((p) => cap(p.type)),
values: providerPieRaw.map((p) => Number(p.value) || 0),
}
: providerPieRaw && typeof providerPieRaw === 'object'
const totalJobs = jobs.length;
const totalListings = jobs.reduce((sum, j) => sum + (j.numberOfFoundListings || 0), 0);
const jobIds = jobs.map((j) => j.id);
const { numberOfActiveListings, medianPriceOfListings } = getListingsKpisForJobIds(jobIds);
const providerPieRaw = getProviderDistributionForJobIds(jobIds);
const providerPie = Array.isArray(providerPieRaw)
? {
labels: Array.isArray(providerPieRaw.labels) ? providerPieRaw.labels : [],
values: Array.isArray(providerPieRaw.values) ? providerPieRaw.values : [],
labels: providerPieRaw.map((p) => cap(p.type)),
values: providerPieRaw.map((p) => Number(p.value) || 0),
}
: { labels: [], values: [] };
: providerPieRaw && typeof providerPieRaw === 'object'
? {
labels: Array.isArray(providerPieRaw.labels) ? providerPieRaw.labels : [],
values: Array.isArray(providerPieRaw.values) ? providerPieRaw.values : [],
}
: { labels: [], values: [] };
res.body = {
general: {
interval: settings.interval,
lastRun: settings.lastRun || null,
nextRun: settings.lastRun == null ? 0 : settings.lastRun + settings.interval * 60000,
},
kpis: {
totalJobs,
totalListings,
numberOfActiveListings,
avgPriceOfListings,
},
pie: providerPie,
};
res.send();
});
return {
general: {
interval: settings.interval,
lastRun: settings.lastRun || null,
nextRun: settings.lastRun == null ? 0 : settings.lastRun + settings.interval * 60000,
},
kpis: {
totalJobs,
totalListings,
numberOfActiveListings,
medianPriceOfListings,
},
pie: providerPie,
};
});
}

View File

@@ -1,17 +1,16 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import restana from 'restana';
import { getSettings } from '../../services/storage/settingsStorage.js';
const service = restana();
const demoRouter = service.newRouter();
demoRouter.get('/', async (req, res) => {
const settings = await getSettings();
res.body = Object.assign({}, { demoMode: settings.demoMode });
res.send();
});
export { demoRouter };
/**
* @param {import('fastify').FastifyInstance} fastify
*/
export default async function demoPlugin(fastify) {
fastify.get('/', async () => {
const settings = await getSettings();
return { demoMode: settings.demoMode };
});
}

View File

@@ -1,17 +0,0 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import restana from 'restana';
import getFeatures from '../../features.js';
const service = restana();
const featureRouter = service.newRouter();
featureRouter.get('/', async (req, res) => {
const features = getFeatures();
res.body = Object.assign({}, { features });
res.send();
});
export { featureRouter };

View File

@@ -1,41 +1,53 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import restana from 'restana';
import { getDirName } from '../../utils.js';
import fs from 'fs';
import { ensureDemoUserExists } from '../../services/storage/userStorage.js';
import logger from '../../services/logger.js';
import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
const service = restana();
const generalSettingsRouter = service.newRouter();
import { isAdmin } from '../security.js';
import { trackPoi } from '../../services/tracking/Tracker.js';
import { TRACKING_POIS } from '../../TRACKING_POIS.js';
generalSettingsRouter.get('/', async (req, res) => {
res.body = Object.assign({}, await getSettings());
res.send();
});
generalSettingsRouter.post('/', async (req, res) => {
const { sqlitepath, ...appSettings } = req.body || {};
const localSettings = await getSettings();
/**
* @param {import('fastify').FastifyInstance} fastify
*/
export default async function generalSettingsPlugin(fastify) {
fastify.get('/', async () => {
return Object.assign({}, await getSettings());
});
if (localSettings.demoMode) {
res.send(new Error('In demo mode, it is not allowed to change these settings.'));
return;
}
try {
if (typeof sqlitepath !== 'undefined') {
fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ sqlitepath }));
fastify.post('/', async (request, reply) => {
const { sqlitepath, ...appSettings } = request.body || {};
if (typeof appSettings.baseUrl === 'string') {
appSettings.baseUrl = appSettings.baseUrl.trim().replace(/\/$/, '');
}
upsertSettings(appSettings);
ensureDemoUserExists();
} catch (err) {
logger.error(err);
res.send(new Error('Error while trying to write settings.'));
return;
}
res.send();
});
export { generalSettingsRouter };
const localSettings = await getSettings();
if (!isAdmin(request)) {
const reason = localSettings.demoMode
? 'In demo mode, it is not allowed to change these settings.'
: 'Only admins can change these settings.';
return reply.code(403).send({ error: reason });
}
try {
if (typeof sqlitepath !== 'undefined') {
fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ sqlitepath }));
}
upsertSettings(appSettings);
ensureDemoUserExists();
if (appSettings.baseUrl != null) {
await trackPoi(TRACKING_POIS.BASE_URL_SETTING);
}
} catch (err) {
logger.error(err);
return reply.code(500).send({ error: 'Error while trying to write settings.' });
}
return reply.send();
});
}

View File

@@ -1,128 +1,245 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import restana from 'restana';
import * as jobStorage from '../../services/storage/jobStorage.js';
import * as userStorage from '../../services/storage/userStorage.js';
import { isAdmin } from '../security.js';
import logger from '../../services/logger.js';
import { bus } from '../../services/events/event-bus.js';
import { isRunning as isJobRunning } from '../../services/jobs/run-state.js';
import { addClient as addSseClient, removeClient } from '../../services/sse/sse-broker.js';
import { getSettings } from '../../services/storage/settingsStorage.js';
const service = restana();
const jobRouter = service.newRouter();
const DEMO_JOB_NAME = 'Demo-Job';
function doesJobBelongsToUser(job, req) {
const userId = req.session.currentUser;
if (userId == null) {
return false;
}
function doesJobBelongsToUser(job, request) {
const userId = request.session.currentUser;
if (userId == null) return false;
const user = userStorage.getUser(userId);
if (user == null) {
return false;
}
if (user == null) return false;
return user.isAdmin || job.userId === user.id;
}
jobRouter.get('/', async (req, res) => {
const isUserAdmin = isAdmin(req);
//show only the jobs which belongs to the user (or all of the user is an admin)
res.body = jobStorage
.getJobs()
.filter(
(job) =>
isUserAdmin || job.userId === req.session.currentUser || job.shared_with_user.includes(req.session.currentUser),
)
.map((job) => {
return {
/**
* @param {import('fastify').FastifyInstance} fastify
*/
export default async function jobPlugin(fastify) {
fastify.get('/', async (request) => {
const isUserAdmin = isAdmin(request);
return jobStorage
.getJobs()
.filter(
(job) =>
isUserAdmin ||
job.userId === request.session.currentUser ||
job.shared_with_user.includes(request.session.currentUser),
)
.map((job) => ({
...job,
running: isJobRunning(job.id),
isOnlyShared:
!isUserAdmin &&
job.userId !== req.session.currentUser &&
job.shared_with_user.includes(req.session.currentUser),
};
job.userId !== request.session.currentUser &&
job.shared_with_user.includes(request.session.currentUser),
}));
});
fastify.get('/data', async (request) => {
const {
page,
pageSize = 50,
activityFilter,
sortfield = null,
sortdir = 'asc',
freeTextFilter,
} = request.query || {};
const toBool = (v) => {
if (v === true || v === 'true' || v === 1 || v === '1') return true;
if (v === false || v === 'false' || v === 0 || v === '0') return false;
return null;
};
const normalizedActivity = toBool(activityFilter);
const queryResult = jobStorage.queryJobs({
page: page ? parseInt(page, 10) : 1,
pageSize: pageSize ? parseInt(pageSize, 10) : 50,
freeTextFilter: freeTextFilter || null,
activityFilter: normalizedActivity,
sortField: sortfield || null,
sortDir: sortdir === 'desc' ? 'desc' : 'asc',
userId: request.session.currentUser,
isAdmin: isAdmin(request),
});
res.send();
});
const isUserAdmin = isAdmin(request);
queryResult.result = queryResult.result.map((job) => ({
...job,
running: isJobRunning(job.id),
isOnlyShared:
!isUserAdmin &&
job.userId !== request.session.currentUser &&
job.shared_with_user.includes(request.session.currentUser),
}));
jobRouter.post('/startAll', async (req, res) => {
bus.emit('jobs:runAll');
res.send();
});
return queryResult;
});
jobRouter.post('/', async (req, res) => {
const { provider, notificationAdapter, name, blacklist = [], jobId, enabled, shareWithUsers = [] } = req.body;
try {
let jobFromDb = jobStorage.getJob(jobId);
if (jobFromDb && !doesJobBelongsToUser(jobFromDb, req)) {
res.send(new Error('You are trying to change a job that is not associated to your user.'));
return;
// Server-Sent Events for real-time job status updates
fastify.get('/events', async (request, reply) => {
const userId = request.session?.currentUser;
if (userId == null) {
return reply.code(401).send({ message: 'Unauthorized' });
}
jobStorage.upsertJob({
userId: req.session.currentUser,
jobId,
enabled,
name,
blacklist,
reply.hijack();
const raw = reply.raw;
raw.setHeader('Content-Type', 'text/event-stream');
raw.setHeader('Cache-Control', 'no-cache');
raw.setHeader('Connection', 'keep-alive');
try {
raw.write(': connected\n\n');
addSseClient(userId, raw);
const onClose = () => removeClient(userId, raw);
request.raw.on('close', onClose);
} catch (e) {
logger.error('Error establishing SSE connection', e);
try {
raw.end();
} catch {
/* noop */
}
}
});
fastify.post('/startAll', async (request, reply) => {
try {
const userId = request.session.currentUser;
bus.emit('jobs:runAll', { userId });
return reply.code(202).send({ message: 'Run all accepted' });
} catch (err) {
logger.error('Failed to trigger startAll', err);
return reply.code(500).send({ message: 'Unexpected error' });
}
});
fastify.post('/:jobId/run', async (request, reply) => {
const { jobId } = request.params;
try {
const job = jobStorage.getJob(jobId);
if (!job) {
return reply.code(404).send({ message: 'Job not found' });
}
if (!doesJobBelongsToUser(job, request)) {
return reply.code(403).send({ message: 'You are trying to run a job that is not associated to your user' });
}
if (isJobRunning(jobId)) {
return reply.code(409).send({ message: 'Job is already running' });
}
bus.emit('jobs:runOne', { jobId });
return reply.code(202).send({ message: 'Job run accepted' });
} catch (error) {
logger.error(error);
return reply.code(500).send({ message: 'Unexpected error triggering job' });
}
});
fastify.post('/', async (request, reply) => {
const {
provider,
notificationAdapter,
shareWithUsers,
});
} catch (error) {
res.send(new Error(error));
logger.error(error);
}
res.send();
});
name,
blacklist = [],
jobId,
enabled,
shareWithUsers = [],
spatialFilter = null,
specFilter = null,
} = request.body;
const settings = await getSettings();
try {
const jobFromDb = jobStorage.getJob(jobId);
jobRouter.delete('', async (req, res) => {
const { jobId } = req.body;
try {
const job = jobStorage.getJob(jobId);
if (!doesJobBelongsToUser(job, req)) {
res.send(new Error('You are trying to remove a job that is not associated to your user'));
} else {
jobStorage.removeJob(jobId);
}
} catch (error) {
res.send(new Error(error));
logger.error(error);
}
res.send();
});
jobRouter.put('/:jobId/status', async (req, res) => {
const { status } = req.body;
const { jobId } = req.params;
try {
const job = jobStorage.getJob(jobId);
if (!doesJobBelongsToUser(job, req)) {
res.send(new Error('You are trying change a job that is not associated to your user'));
} else {
jobStorage.setJobStatus({
if (jobFromDb && !doesJobBelongsToUser(jobFromDb, request)) {
return reply.code(403).send({ error: 'You are trying to change a job that is not associated to your user.' });
}
if (settings.demoMode && !isAdmin(request) && jobFromDb && jobFromDb.name === DEMO_JOB_NAME) {
return reply.code(403).send({ error: 'Sorry, but you cannot change the Status of our Demo Job ;)' });
}
jobStorage.upsertJob({
userId: request.session.currentUser,
jobId,
status,
enabled,
name,
blacklist,
provider,
notificationAdapter,
shareWithUsers,
spatialFilter,
specFilter,
});
} catch (error) {
logger.error(error);
return reply.code(500).send({ error: error.message });
}
} catch (error) {
res.send(new Error(error));
logger.error(error);
}
res.send();
});
return reply.send();
});
jobRouter.get('/shareableUserList', async (req, res) => {
const currentUser = req.session.currentUser;
const users = userStorage.getUsers(false);
res.body = users
.filter((user) => !user.isAdmin && user.id !== currentUser)
.map((user) => ({
id: user.id,
name: user.username,
}));
res.send();
});
export { jobRouter };
fastify.delete('/', async (request, reply) => {
const { jobId } = request.body;
const settings = await getSettings();
try {
const job = jobStorage.getJob(jobId);
if (settings.demoMode && !isAdmin(request) && job.name === DEMO_JOB_NAME) {
return reply.code(403).send({ error: 'Sorry, but you cannot remove the Demo Job ;)' });
}
if (!doesJobBelongsToUser(job, request)) {
return reply.code(403).send({ error: 'You are trying to remove a job that is not associated to your user' });
}
jobStorage.removeJob(jobId);
} catch (error) {
logger.error(error);
return reply.code(500).send({ error: error.message });
}
return reply.send();
});
fastify.put('/:jobId/status', async (request, reply) => {
const { status } = request.body;
const { jobId } = request.params;
const settings = await getSettings();
try {
const job = jobStorage.getJob(jobId);
if (settings.demoMode && !isAdmin(request) && job.name === DEMO_JOB_NAME) {
return reply.code(403).send({ error: 'Sorry, but you cannot change the Status of our Demo Job ;)' });
}
if (!doesJobBelongsToUser(job, request)) {
return reply.code(403).send({ error: 'You are trying change a job that is not associated to your user' });
}
jobStorage.setJobStatus({ jobId, status });
} catch (error) {
logger.error(error);
return reply.code(500).send({ error: error.message });
}
return reply.send();
});
fastify.get('/shareableUserList', async (request) => {
const currentUser = request.session.currentUser;
const users = userStorage.getUsers(false);
return users
.filter((user) => !user.isAdmin && user.id !== currentUser)
.map((user) => ({
id: user.id,
name: user.username,
}));
});
}

View File

@@ -1,105 +1,124 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
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';
import { getSettings } from '../../services/storage/settingsStorage.js';
const service = restana();
/**
* @param {import('fastify').FastifyInstance} fastify
*/
export default async function listingsPlugin(fastify) {
fastify.get('/table', async (request) => {
const {
page,
pageSize = 50,
activityFilter,
jobNameFilter,
providerFilter,
watchListFilter,
sortfield = null,
sortdir = 'asc',
freeTextFilter,
} = request.query || {};
const listingsRouter = service.newRouter();
const toBool = (v) => {
if (v === true || v === 'true' || v === 1 || v === '1') return true;
if (v === false || v === 'false' || v === 0 || v === '0') return false;
return null;
};
const normalizedActivity = toBool(activityFilter);
const normalizedWatch = toBool(watchListFilter);
listingsRouter.get('/table', async (req, res) => {
const {
page,
pageSize = 50,
activityFilter,
jobNameFilter,
providerFilter,
watchListFilter,
sortfield = null,
sortdir = 'asc',
freeTextFilter,
} = req.query || {};
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;
}
// 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),
return 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: request.session.currentUser,
isAdmin: isAdminFn(request),
});
});
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();
fastify.get('/map', async (request) => {
const { jobId } = request.query || {};
return listingStorage.getListingsForMap({
jobId: nullOrEmpty(jobId) ? null : jobId,
userId: request.session.currentUser,
isAdmin: isAdminFn(request),
});
});
fastify.get('/:listingId', async (request, reply) => {
const { listingId } = request.params;
const listing = listingStorage.getListingById(listingId, request.session.currentUser, isAdminFn(request));
if (!listing) {
return reply.code(404).send({ message: 'Listing not found' });
}
watchListStorage.toggleWatch(listingId, userId);
} catch (error) {
logger.error(error);
res.statusCode = 500;
res.body = { message: 'Failed to toggle watch' };
}
res.send();
});
return listing;
});
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);
fastify.post('/watch', async (request, reply) => {
try {
const { listingId } = request.body || {};
const userId = request.session?.currentUser;
if (!listingId || !userId) {
return reply.code(400).send({ message: 'listingId or user not provided' });
}
watchListStorage.toggleWatch(listingId, userId);
} catch (error) {
logger.error(error);
return reply.code(500).send({ message: 'Failed to toggle watch' });
}
} catch (error) {
res.send(new Error(error));
logger.error(error);
}
res.send();
});
return reply.send();
});
export { listingsRouter };
fastify.delete('/job', async (request, reply) => {
const { jobId, hardDelete = false } = request.body;
const settings = await getSettings();
try {
if (settings.demoMode && !isAdminFn(request)) {
return reply.code(403).send({ error: 'Sorry, but you cannot remove listings in demo mode ;)' });
}
listingStorage.deleteListingsByJobId(jobId, hardDelete);
} catch (error) {
logger.error(error);
return reply.code(500).send({ error: error.message });
}
return reply.send();
});
fastify.delete('/', async (request, reply) => {
const { ids, hardDelete = false } = request.body;
try {
if (Array.isArray(ids) && ids.length > 0) {
listingStorage.deleteListingsById(ids, hardDelete);
}
} catch (error) {
logger.error(error);
return reply.code(500).send({ error: error.message });
}
return reply.send();
});
}

View File

@@ -1,53 +1,79 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import restana from 'restana';
import * as userStorage from '../../services/storage/userStorage.js';
import * as hasher from '../../services/security/hash.js';
import { trackDemoAccessed } from '../../services/tracking/Tracker.js';
import logger from '../../services/logger.js';
import { getSettings } from '../../services/storage/settingsStorage.js';
const service = restana();
const loginRouter = service.newRouter();
loginRouter.get('/user', async (req, res) => {
const currentUserId = req.session.currentUser;
const currentUser = currentUserId == null ? null : userStorage.getUser(currentUserId);
if (currentUser == null) {
res.body = {};
} else {
res.body = {
const MAX_LOGIN_ATTEMPTS = 10;
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
const loginAttempts = new Map();
function getClientIp(request) {
const forwarded = request.headers['x-forwarded-for'];
return (forwarded ? forwarded.split(',')[0] : request.socket?.remoteAddress) || 'unknown';
}
function isRateLimited(ip) {
const now = Date.now();
const record = loginAttempts.get(ip);
if (!record || now - record.firstAttempt > LOGIN_WINDOW_MS) {
loginAttempts.set(ip, { count: 1, firstAttempt: now });
return false;
}
record.count++;
return record.count > MAX_LOGIN_ATTEMPTS;
}
/**
* @param {import('fastify').FastifyInstance} fastify
*/
export default async function loginPlugin(fastify) {
fastify.get('/user', async (request) => {
const currentUserId = request.session?.currentUser;
const currentUser = currentUserId == null ? null : userStorage.getUser(currentUserId);
if (currentUser == null) {
return {};
}
return {
userId: currentUser.id,
isAdmin: currentUser.isAdmin,
};
}
res.send();
});
loginRouter.post('/', async (req, res) => {
const settings = await getSettings();
const { username, password } = req.body;
const user = userStorage.getUsers(true).find((user) => user.username === username);
if (user == null) {
res.send(401);
return;
}
if (user.password === hasher.hash(password)) {
if (settings.demoMode) {
await trackDemoAccessed();
}
});
req.session.currentUser = user.id;
userStorage.setLastLoginToNow({ userId: user.id });
res.send(200);
return;
} else {
logger.error(`User ${username} tried to login, but password was wrong.`);
}
res.send(401);
});
loginRouter.post('/logout', async (req, res) => {
req.session = null;
res.send(200);
});
export { loginRouter };
fastify.post('/', async (request, reply) => {
const ip = getClientIp(request);
if (isRateLimited(ip)) {
logger.error(`Login rate limit exceeded for IP ${ip}`);
return reply.code(429).send();
}
const settings = await getSettings();
const { username, password } = request.body;
const user = userStorage.getUsers(true).find((u) => u.username === username);
if (user == null) {
return reply.code(401).send();
}
if (user.password === hasher.hash(password)) {
if (settings.demoMode) {
await trackDemoAccessed();
}
request.session.currentUser = user.id;
request.session.createdAt = Date.now();
loginAttempts.delete(ip);
userStorage.setLastLoginToNow({ userId: user.id });
return reply.code(200).send();
} else {
logger.error(`User ${username} tried to login, but password was wrong.`);
}
return reply.code(401).send();
});
fastify.post('/logout', async (request, reply) => {
await request.session.destroy();
return reply.code(200).send();
});
}

View File

@@ -1,56 +1,112 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import fs from 'fs';
import restana from 'restana';
const service = restana();
const notificationAdapterRouter = service.newRouter();
import logger from '../../services/logger.js';
const notificationAdapterList = fs.readdirSync('./lib//notification/adapter').filter((file) => file.endsWith('.js'));
const notificationAdapter = await Promise.all(
notificationAdapterList.map(async (pro) => {
return await import(`../../notification/adapter/${pro}`);
}),
);
notificationAdapterRouter.post('/try', async (req, res) => {
const { id, fields } = req.body;
const adapter = notificationAdapter.find((adapter) => adapter.config.id === id);
if (adapter == null) {
res.send(404);
}
const notificationConfig = [];
const notificationObject = {};
Object.keys(fields).forEach((key) => {
notificationObject[key] = fields[key].value;
/**
* @param {import('fastify').FastifyInstance} fastify
*/
export default async function notificationAdapterPlugin(fastify) {
fastify.get('/', async () => {
return notificationAdapter.map((adapter) => adapter.config);
});
notificationConfig.push({
fields: { ...notificationObject },
enabled: true,
id,
});
try {
await adapter.send({
serviceName: 'TestCall',
newListings: [
{
price: '42 €',
title: 'This is a test listing',
address: 'some address',
size: '666 2m',
link: 'https://www.orange-coding.net',
},
],
notificationConfig,
jobKey: 'TestJob',
fastify.post('/try', async (request, reply) => {
const { id, fields } = request.body;
const adapter = notificationAdapter.find((adapter) => adapter.config.id === id);
if (adapter == null) {
return reply.code(404).send();
}
const notificationConfig = [];
const notificationObject = {};
Object.keys(fields).forEach((key) => {
notificationObject[key] = fields[key].value;
});
res.send();
} catch (Exception) {
res.send(new Error(Exception));
}
});
notificationAdapterRouter.get('/', async (req, res) => {
res.body = notificationAdapter.map((adapter) => adapter.config);
res.send();
});
export { notificationAdapterRouter };
notificationConfig.push({
fields: { ...notificationObject },
enabled: true,
id,
});
try {
await adapter.send({
serviceName: 'TestCall',
newListings: [
{
address: 'Heidestrasse 17, 51147 Köln',
description: exampleDescription,
id: '1',
imageUrl: 'https://placehold.co/600x400/png',
price: '1.000 €',
size: '76 m²',
title: 'Stilvolle gepflegte 3-Raum-Wohnung mit gehobener Innenausstattung',
url: 'https://www.orange-coding.net',
},
],
notificationConfig,
jobKey: 'TestJob',
});
return reply.send();
} catch (Exception) {
logger.error('Error during notification adapter test:', Exception);
return reply.code(500).send({ error: String(Exception) });
}
});
}
const exampleDescription = `
Wohnungstyp: Etagenwohnung
Nutzfläche: 76 m²
Etage: 2 von 3
Schlafzimmer: 1
Badezimmer: 1
Bezugsfrei ab: 1.4.2026
Haustiere: Nein
Garage/Stellplatz: Tiefgarage
Anzahl Garage/Stellplatz: 1
Kaltmiete (zzgl. Nebenkosten): 1.000 €
Preis/m²: 13,16 €/m²
Nebenkosten: 230 €
Heizkosten in Nebenkosten enthalten: Ja
Gesamtmiete: 1.230 €
Kaution: 3.000,00
Preis pro Parkfläche: 60 €
Baujahr: 2000
Objektzustand: Modernisiert
Qualität der Ausstattung: Gehoben
Heizungsart: Fernwärme
Energieausweistyp: Verbrauchsausweis
Energieausweis: liegt vor
Endenergieverbrauch: 72 kWh/(m²∙a)
Baujahr laut Energieausweis: 2000
Diese moderne 3-Zimmer-Wohnung liegt direkt neben einem Park und nur wenige Minuten von der S-Bahn-Haltestelle entfernt. Das Stadtzentrum sowie Freizeiteinrichtungen sind 1,5 km entfernt.
Die Wohnung ist ideal für Paare oder kleine Familien geeignet.
Ausstattung:
- neuer hochwertiger Bodenbelag (Holzoptik) in allen Räumen außer Bad/Küche
- sonniger Balkon (Süd)
- Tiefgaragenstellplatz
- Kellerabteil
- gepflegtes Mehrfamilienhaus
Die Küche ist vom Mieter nach eigenen Wünschen einzurichten.
Vermietung direkt vom Eigentümer - provisionsfrei!
Lage:
• Park: 1 Minute zu Fuß
• S-Bahn Station: 2 Minuten zu Fuß
• Supermärkte, Restaurants, täglicher Bedarf in der Nähe
• Gute Anbindung Richtung Großstadt und Flughafen
`;

View File

@@ -1,20 +1,18 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import fs from 'fs';
import restana from 'restana';
const service = restana();
const providerRouter = service.newRouter();
const providerList = fs.readdirSync('./lib/provider').filter((file) => file.endsWith('.js'));
const provider = await Promise.all(
providerList.map(async (pro) => {
return await import(`../../provider/${pro}`);
}),
);
providerRouter.get('/', async (req, res) => {
res.body = provider.map((p) => p.metaInformation);
res.send();
});
export { providerRouter };
const providers = await Promise.all(providerList.map(async (pro) => import(`../../provider/${pro}`)));
/**
* @param {import('fastify').FastifyInstance} fastify
*/
export default async function providerPlugin(fastify) {
fastify.get('/', async () => {
return providers.map((p) => p.metaInformation);
});
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { trackPoi } from '../../services/tracking/Tracker.js';
import { TRACKING_POIS } from '../../TRACKING_POIS.js';
import logger from '../../services/logger.js';
/**
* @param {import('fastify').FastifyInstance} fastify
*/
export default async function trackingPlugin(fastify) {
fastify.get('/trackingPois', async () => {
return TRACKING_POIS;
});
fastify.post('/poi', async (request, reply) => {
const { poi } = request.body;
if (!poi) {
return reply.code(400).send({ error: 'Feature name is required' });
}
try {
await trackPoi(poi);
return { success: true };
} catch (error) {
logger.error('Error tracking feature', error);
return reply.code(500).send({ error: error.message });
}
});
}

View File

@@ -1,83 +1,75 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import restana from 'restana';
import * as userStorage from '../../services/storage/userStorage.js';
import * as jobStorage from '../../services/storage/jobStorage.js';
import { getSettings } from '../../services/storage/settingsStorage.js';
const service = restana();
const userRouter = service.newRouter();
import { isAdmin as isAdminUser } from '../security.js';
function checkIfAnyAdminAfterRemovingUser(userIdToBeRemoved, allUser) {
return allUser.filter((user) => user.id !== userIdToBeRemoved && user.isAdmin).length > 0;
}
function checkIfUserToBeRemovedIsLoggedIn(userIdToBeRemoved, req) {
return req.session.currentUser === userIdToBeRemoved;
function checkIfUserToBeRemovedIsLoggedIn(userIdToBeRemoved, request) {
return request.session.currentUser === userIdToBeRemoved;
}
const nullOrEmpty = (str) => str == null || str.length === 0;
userRouter.get('/', async (req, res) => {
res.body = userStorage.getUsers(false);
res.send();
});
userRouter.get('/:userId', async (req, res) => {
const { userId } = req.params;
res.body = userStorage.getUser(userId);
res.send();
});
userRouter.delete('/', async (req, res) => {
const settings = await getSettings();
if (settings.demoMode) {
res.send(new Error('In demo mode, it is not allowed to remove user.'));
return;
}
const { userId } = req.body;
const allUser = userStorage.getUsers(false);
if (!checkIfAnyAdminAfterRemovingUser(userId, allUser)) {
res.send(new Error('You are trying to remove the last admin user. This is prohibited.'));
return;
}
if (checkIfUserToBeRemovedIsLoggedIn(userId, req)) {
res.send(new Error('You are trying to remove yourself. This is prohibited.'));
return;
}
//TODO: Remove also analytics
jobStorage.removeJobsByUserId(userId);
userStorage.removeUser(userId);
res.send();
});
userRouter.post('/', async (req, res) => {
const settings = await getSettings();
if (settings.demoMode) {
res.send(new Error('In demo mode, it is not allowed to change or add user.'));
return;
}
const { username, password, password2, isAdmin, userId } = req.body;
if (password !== password2) {
res.send(new Error('Passwords does not match'));
return;
}
if (nullOrEmpty(username) || nullOrEmpty(password) || nullOrEmpty(password2)) {
res.send(new Error('Username and password are mandatory.'));
return;
}
const allUser = userStorage.getUsers(false);
if (!isAdmin && !checkIfAnyAdminAfterRemovingUser(userId, allUser)) {
res.send(
new Error('You cannot change the admin flag for this user as otherwise, there is no other user in the system'),
);
return;
}
userStorage.upsertUser({
userId,
username,
password,
isAdmin,
/**
* @param {import('fastify').FastifyInstance} fastify
*/
export default async function userPlugin(fastify) {
fastify.get('/', async () => {
return userStorage.getUsers(false);
});
res.send();
});
export { userRouter };
fastify.get('/:userId', async (request) => {
const { userId } = request.params;
return userStorage.getUser(userId);
});
fastify.delete('/', async (request, reply) => {
const settings = await getSettings();
if (settings.demoMode && !isAdminUser(request)) {
return reply.code(403).send({ error: 'In demo mode, it is not allowed to remove user.' });
}
const { userId } = request.body;
const allUser = userStorage.getUsers(false);
if (!checkIfAnyAdminAfterRemovingUser(userId, allUser)) {
return reply.code(400).send({ error: 'You are trying to remove the last admin user. This is prohibited.' });
}
if (checkIfUserToBeRemovedIsLoggedIn(userId, request)) {
return reply.code(400).send({ error: 'You are trying to remove yourself. This is prohibited.' });
}
jobStorage.removeJobsByUserId(userId);
userStorage.removeUser(userId);
return reply.send();
});
fastify.post('/', async (request, reply) => {
const settings = await getSettings();
if (settings.demoMode && !isAdminUser(request)) {
return reply.code(403).send({ error: 'In demo mode, it is not allowed to change or add user.' });
}
const { username, password, password2, isAdmin, userId } = request.body;
if (password !== password2) {
return reply.code(400).send({ error: 'Passwords do not match.' });
}
if (nullOrEmpty(username) || nullOrEmpty(password) || nullOrEmpty(password2)) {
return reply.code(400).send({ error: 'Username and password are mandatory.' });
}
const allUser = userStorage.getUsers(false);
if (!isAdmin && !checkIfAnyAdminAfterRemovingUser(userId, allUser)) {
return reply.code(400).send({
error: 'You cannot change the admin flag for this user as otherwise, there is no other user in the system',
});
}
userStorage.upsertUser({ userId, username, password, isAdmin });
return reply.send();
});
}

View File

@@ -0,0 +1,154 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import SqliteConnection from '../../services/storage/SqliteConnection.js';
import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
import { isAdmin } from '../security.js';
import { resetGeocoordinatesAndDistanceForUser } from '../../services/storage/listingsStorage.js';
import { geocodeAddress } from '../../services/geocoding/geoCodingService.js';
import { autocompleteAddress } from '../../services/geocoding/autocompleteService.js';
import { fromJson } from '../../utils.js';
import { trackPoi } from '../../services/tracking/Tracker.js';
import { TRACKING_POIS } from '../../TRACKING_POIS.js';
import logger from '../../services/logger.js';
import { runGeoCordTask } from '../../services/crons/geocoding-cron.js';
/**
* @param {import('fastify').FastifyInstance} fastify
*/
export default async function userSettingsPlugin(fastify) {
fastify.get('/', async (request) => {
const userId = request.session.currentUser;
const rows = SqliteConnection.query('SELECT name, value FROM settings WHERE user_id = @userId', { userId });
const settings = {};
for (const r of rows) {
settings[r.name] = fromJson(r.value, null);
}
return settings;
});
fastify.get('/autocomplete', async (request, reply) => {
const { q } = request.query;
try {
const results = await autocompleteAddress(q);
return results;
} catch (error) {
return reply.code(500).send({ error: error.message });
}
});
fastify.post('/home-address', async (request, reply) => {
const userId = request.session.currentUser;
const { home_address } = request.body;
const settings = await getSettings();
if (settings.demoMode && !isAdmin(request)) {
return reply.code(403).send({ error: 'In demo mode, it is not allowed to change the home address.' });
}
try {
if (home_address) {
await trackPoi(TRACKING_POIS.DISTANCE_ADDRESS_ENTERED);
const coords = await geocodeAddress(home_address);
if (coords && coords.lat !== -1) {
upsertSettings({ home_address: { address: home_address, coords } }, userId);
resetGeocoordinatesAndDistanceForUser(userId);
runGeoCordTask();
return { success: true, coords };
} else {
return reply.code(400).send({ error: 'Could not geocode address' });
}
} else {
upsertSettings({ home_address: null }, userId);
return { success: true };
}
} catch (error) {
logger.error('Error updating home address settings', error);
return reply.code(500).send({ error: error.message });
}
});
fastify.post('/news-hash', async (request, reply) => {
const userId = request.session.currentUser;
const { news_hash } = request.body;
const globalSettings = await getSettings();
if (globalSettings.demoMode && !isAdmin(request)) {
return reply.code(403).send({ error: 'In demo mode, it is not allowed to change settings.' });
}
try {
upsertSettings({ news_hash }, userId);
return { success: true };
} catch (error) {
logger.error('Error updating news hash', error);
return reply.code(500).send({ error: error.message });
}
});
fastify.post('/provider-details', async (request, reply) => {
const userId = request.session.currentUser;
const { provider_details } = request.body;
const globalSettings = await getSettings();
if (globalSettings.demoMode && !isAdmin(request)) {
return reply.code(403).send({ error: 'In demo mode, it is not allowed to change settings.' });
}
if (!Array.isArray(provider_details)) {
return reply.code(400).send({ error: 'provider_details must be an array of provider ids.' });
}
try {
upsertSettings({ provider_details }, userId);
return { success: true };
} catch (error) {
logger.error('Error updating provider details setting', error);
return reply.code(500).send({ error: error.message });
}
});
fastify.post('/listings-view-mode', async (request, reply) => {
const userId = request.session.currentUser;
const { listings_view_mode } = request.body;
if (listings_view_mode !== 'grid' && listings_view_mode !== 'table') {
return reply.code(400).send({ error: 'listings_view_mode must be "grid" or "table".' });
}
if (listings_view_mode === 'table') {
await trackPoi(TRACKING_POIS.LISTING_TABLE_VIEW);
}
try {
upsertSettings({ listings_view_mode }, userId);
return { success: true };
} catch (error) {
logger.error('Error updating listings view mode setting', error);
return reply.code(500).send({ error: error.message });
}
});
fastify.post('/jobs-view-mode', async (request, reply) => {
const userId = request.session.currentUser;
const { jobs_view_mode } = request.body;
if (jobs_view_mode !== 'grid' && jobs_view_mode !== 'table') {
return reply.code(400).send({ error: 'jobs_view_mode must be "grid" or "table".' });
}
if (jobs_view_mode === 'table') {
await trackPoi(TRACKING_POIS.JOBS_TABLE_VIEW);
}
try {
upsertSettings({ jobs_view_mode }, userId);
return { success: true };
} catch (error) {
logger.error('Error updating jobs view mode setting', error);
return reply.code(500).send({ error: error.message });
}
});
}

View File

@@ -1,29 +1,12 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
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();
@@ -40,4 +23,13 @@ async function getCurrentVersionFromGithub() {
};
}
export { versionRouter };
/**
* @param {import('fastify').FastifyInstance} fastify
*/
export default async function versionPlugin(fastify) {
fastify.get('/', async () => {
const versionPayload = await getCurrentVersionFromGithub();
const localFredyVersion = await getPackageVersion();
return versionPayload ?? { newVersion: false, localFredyVersion };
});
}

View File

@@ -1,52 +1,53 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import * as userStorage from '../services/storage/userStorage.js';
import cookieSession from 'cookie-session';
import { nanoid } from 'nanoid';
const unauthorized = (res) => {
return res.send(401);
};
const isUnauthorized = (req) => {
return req.session.currentUser == null;
};
const isAdmin = (req) => {
if (!isUnauthorized(req)) {
const user = userStorage.getUser(req.session.currentUser);
return user != null && user.isAdmin;
}
const SESSION_MAX_AGE = 2 * 60 * 60 * 1000; // 2 hours
/**
* Returns true when the request has no valid, non-expired session.
* @param {import('fastify').FastifyRequest} request
* @returns {boolean}
*/
export function isUnauthorized(request) {
if (!request.session?.currentUser) return true;
if (Date.now() - (request.session.createdAt || 0) > SESSION_MAX_AGE) return true;
return false;
};
const authInterceptor = () => {
return (req, res, next) => {
if (isUnauthorized(req)) {
return unauthorized(res);
} else {
next();
}
};
};
const adminInterceptor = () => {
return (req, res, next) => {
if (!isAdmin(req)) {
return unauthorized(res);
} else {
next();
}
};
};
const cookieSession$0 = (userId) => {
return cookieSession({
name: 'fredy-admin-session',
keys: ['fredy', 'super', 'fancy', 'key', nanoid()],
userId,
maxAge: 2 * 60 * 60 * 1000, // 2 hours
});
};
export { cookieSession$0 as cookieSession };
export { adminInterceptor };
export { authInterceptor };
export { isUnauthorized };
export { isAdmin };
}
/**
* Returns true when the session belongs to an admin user.
* @param {import('fastify').FastifyRequest} request
* @returns {boolean}
*/
export function isAdmin(request) {
if (isUnauthorized(request)) return false;
const user = userStorage.getUser(request.session.currentUser);
return user != null && user.isAdmin;
}
/**
* Fastify preHandler hook - rejects unauthenticated requests with 401.
* @param {import('fastify').FastifyRequest} request
* @param {import('fastify').FastifyReply} reply
*/
export async function authHook(request, reply) {
if (isUnauthorized(request)) {
reply.code(401).send();
}
}
/**
* Fastify preHandler hook - rejects non-admin requests with 401.
* Apply after authHook.
* @param {import('fastify').FastifyRequest} request
* @param {import('fastify').FastifyReply} reply
*/
export async function adminHook(request, reply) {
if (!isAdmin(request)) {
reply.code(401).send();
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/

View File

@@ -1,14 +0,0 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
const FEATURES = {
WATCHLIST_MANAGEMENT: false,
};
export default function getFeatures() {
return {
...FEATURES,
};
}

323
lib/mcp/README.md Normal file
View File

@@ -0,0 +1,323 @@
# Fredy MCP Server
The Fredy MCP Server exposes your real estate jobs and listings data to LLM clients. It supports two transports:
- **Stdio**: for local LLM clients (Claude Desktop, LM Studio, llm-cli, mcp-cli, etc.)
- **Streamable HTTP**: for remote LLM clients (ChatGPT, cloud-hosted agents, etc.)
## Authentication
All MCP access is **token-based** based. Every Fredy user is automatically assigned a **permanent, non-expiring MCP token** when their account is created. This token is a secret and should be treated like a password.
### Where to find your token
MCP tokens are displayed in the **User Management** list (Admin → Users). Each user's token is shown in the **"MCP Token"** column.
> **Important:** MCP tokens never expire. They are permanent secrets tied to each user account. If a token is compromised, you must change the token! If you chose to use a token from an admin account, the LLM can query information from ALL jobs/listings.
## Available Tools
| Tool | Description |
|------|--------------------------------------------------------------------------------|
| `list_jobs` | List real estate search jobs with pagination and text filtering |
| `get_job` | Get detailed information about a specific job |
| `list_listings` | Search and list real estate listings with pagination, text search, and filters |
| `get_listing` | Get full details of a single listing |
| `get_current_date_time` | Gets the current date/time for the llm to be used |
### Tool Details
#### list_jobs
- `page` (number, optional) Page number (default: 1)
- `pageSize` (number, optional) Results per page (default: 50, max: 1000). Use pagination to fetch more.
- `filter` (string, optional) Free-text filter on job name
Response: markdown table with columns ID, Name, Enabled, Active Listings. Includes summary and pagination info.
#### get_job
- `jobId` (string, required) The job ID to retrieve
#### list_listings
- `page` (number, optional) Page number (default: 1)
- `pageSize` (number, optional) Results per page (default: 50, max: 1000). Use pagination to fetch more.
- `filter` (string, optional) Free-text search across title, address, provider, link
- `jobId` (string, optional) Filter listings by job ID
- `activeOnly` (boolean, optional) When true, only show active listings
- `provider` (string, optional) Filter by provider name
- `createdAfter` (number, optional) Only include listings created at or after this unix timestamp in milliseconds (e.g. `1772008362564`). Useful for queries like "give me all listings from today".
- `createdBefore` (number, optional) Only include listings created at or before this unix timestamp in milliseconds (e.g. `1772008362564`).
- `minPrice` (number, optional) Only include listings with price >= this value (e.g. `500`). Numeric, no currency symbol.
- `maxPrice` (number, optional) Only include listings with price <= this value (e.g. `1500`). Numeric, no currency symbol.
- `sortField` (string, optional) Sort by: created_at, price, size, provider, title, is_active
- `sortDir` (string, optional) Sort direction: asc or desc
Response: markdown table with columns ID, Title, Address, Price, Size, Provider, Active, Created, Job. Includes summary and pagination info. Use `get_listing` for full details.
> **Note:** All timestamps are **unix timestamps in milliseconds** (e.g. `1772008362564`), not seconds.
#### get_listing
- `listingId` (string, required) The listing ID to retrieve
## Usage with Local LLM (stdio transport)
The stdio transport communicates over stdin/stdout and is ideal for local LLM tools.
### Quick Start
```bash
MCP_TOKEN=fredy_<your-token> node mcp/stdio.js
# or
MCP_TOKEN=fredy_<your-token> yarn mcp:stdio
```
### Testing with MCP Inspector
The [MCP Inspector](https://github.com/modelcontextprotocol/inspector) lets you interactively test your MCP server in a browser UI.
```bash
npx @modelcontextprotocol/inspector -e MCP_TOKEN=fredy_<your-token> -- node mcp/stdio.js
```
Once the inspector is running, open the URL shown in your terminal (usually `http://localhost:6274`). You can then:
1. Click **Connect** to establish the stdio connection
2. Go to the **Tools** tab to see all available tools
3. Select a tool, fill in parameters, and click **Run** to test it
### LM Studio Configuration
[LM Studio](https://lmstudio.ai/) supports MCP servers natively, allowing your local LLM to access Fredy's jobs and listings data.
#### Setup
1. Open **LM Studio** and load a model that supports tool use (e.g., Qwen 2.5, Llama 3.1, Mistral, etc.)
2. In the right side under **Integrations** click on "# install" and "edit mcp.json"
3. Edit the LM Studio MCP config file directly (`~/.lmstudio/config/mcp.json` or via the UI export):
```json
{
"mcpServers": {
"fredy": {
"command": "node",
"args": ["/absolute/path/to/fredy/mcp/stdio.js"],
"env": {
"MCP_TOKEN": "fredy_<your-token>"
}
}
}
}
```
4. Toggle the server **on**: LM Studio will spawn the stdio process and connect
5. You should see the Fredy tools appear as available tools
#### Suggestion on LLM
After testing numerous LLM's, I got the best results with Qwen 3.5 or Qwen 2.5.. E.g. `Qwen2.5-14B-Instruct-1M-8bit`.
#### Usage
Once connected, simply ask your LLM about your real estate data in natural language:
- *"Show me all my active search jobs"*
- *"List the latest listings from my Berlin apartment search"*
- *"Get details for listing XYZ"*
- *"What are the cheapest listings across all my jobs?"*
The LLM will automatically call the appropriate Fredy MCP tools and present the results.
> **Tip:** Make sure Fredy is running and the database is accessible before starting the MCP server in LM Studio. The stdio transport initializes its own database connection, so Fredy's main process does not need to be running, but the database file must exist and be up-to-date (migrations applied).
### Claude Desktop Configuration
[Claude Desktop](https://claude.ai/download) supports MCP servers natively via its developer settings.
#### Setup
1. Open **Claude Desktop**
2. Go to **Settings → Developer → Edit Config** - this opens the `claude_desktop_config.json` file
3. Add the `fredy` server to the `mcpServers` object:
```json
{
"mcpServers": {
"fredy": {
"command": "/opt/homebrew/opt/node@22/bin/node",
"args": ["/absolute/path/to/fredy/lib/mcp/stdio.js"],
"env": {
"MCP_TOKEN": "fredy_<your-token>"
}
}
}
}
```
Replace `/absolute/path/to/fredy` with the actual path on your machine (e.g. `/Users/you/dev/fredy`).
> **Important:** Claude Desktop launches with a restricted `PATH` and often cannot find `node` by name. Always use the **full absolute path** to the node binary. Find yours by running `which node` in a terminal. Common locations:
> - Homebrew (default): `/opt/homebrew/bin/node`
> - Homebrew (versioned, e.g. node@22): `/opt/homebrew/opt/node@22/bin/node`
> - nvm: `/Users/<you>/.nvm/versions/node/<version>/bin/node`
4. Save the file and **restart Claude Desktop**
5. You should see a hammer icon (🔨) in the chat input - click it to confirm the Fredy tools are listed
#### Usage
Once connected, simply ask Claude about your real estate data:
- *"Show me all my active search jobs"*
- *"List the latest listings from my Berlin apartment search"*
- *"What are the cheapest apartments added this week?"*
Claude will automatically call the appropriate Fredy MCP tools.
> **Note:** Fredy's main web process does not need to be running - the stdio transport opens its own database connection directly. But the SQLite database file must exist and migrations must have been applied.
---
## Usage with Remote LLM (Streamable HTTP transport)
The HTTP transport is automatically available when Fredy is running. It uses the MCP Streamable HTTP protocol at:
```
POST /api/mcp JSON-RPC messages (initialize, tool calls)
GET /api/mcp SSE stream for server-initiated notifications
DELETE /api/mcp Terminate session
```
### Authentication
All requests must include the token as a Bearer token:
```
Authorization: Bearer fredy_<your-token>
```
### Example: Initialize a session
```bash
curl -X POST http://localhost:9998/api/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer fredy_<your-token>" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": { "name": "test-client", "version": "1.0.0" }
}
}'
```
### Example: Call a tool
```bash
curl -X POST http://localhost:9998/api/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer fredy_<your-token>" \
-H "Mcp-Session-Id: <session-id-from-init-response>" \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "list_jobs",
"arguments": { "page": 1, "pageSize": 10 }
}
}'
```
## Security
- Every user is automatically assigned a permanent MCP token at account creation **tokens never expire**
- Tokens are cryptographically random (256-bit) and prefixed with `fredy_`
- Each token is scoped to a single user the LLM can only access that user's data
- Non-admin users only see their own jobs and jobs shared with them
- Tokens are stored in the `mcp_token` column of the `users` table
- Tokens are deleted automatically when the owning user is removed
- The `/api/mcp` endpoint uses Bearer token auth (independent of cookie-session)
- Treat MCP tokens like passwords do not share them publicly
## Response Format
All tool responses use **markdown** instead of JSON for maximum LLM readability and token efficiency:
- **List responses** (list_jobs, list_listings) use markdown tables with a summary line and pagination footer
- **Detail responses** (get_job, get_listing) use markdown key-value lists
- **Error responses** include the tool name and error message
Example list response:
```
**Tool:** list_listings | **Status:** OK
Found **85** listing(s). Showing page 1 of 2 (50 on this page). More pages available - use page=2 to continue.
| ID | Title | Address | Price | Size | Provider | Active | Created | Job |
|----|-------|---------|-------|------|----------|--------|---------|-----|
| abc123 | Nice flat | Berlin | 1200 | 70 | immoscout | yes | 2026-02-25 10:30:00 | My Search |
| ... | ... | ... | ... | ... | ... | ... | ... | ... |
Use **get_listing** with an ID for full details (description, link, image).
**Page:** 1/2 | **Has more:** yes
```
Example detail response:
```
**Tool:** get_listing | **Status:** OK
### Listing: Nice flat
- **ID:** abc123
- **Title:** Nice flat
- **Address:** Berlin
- **Price:** 1200
- **Size:** 70
- **Provider:** immoscout
- **Link:** https://...
- **Active:** yes
- **Created:** 2026-02-25 10:30:00
```
Markdown is used because it is significantly more token-efficient than JSON (~40-60% fewer tokens for tabular data) and natively understood by all LLMs.
## Architecture
```
┌─────────────────┐ stdio ┌──────────────┐
│ Local LLM │◄──────────────►│ mcp/stdio.js│
│ (LM Studio, │ │ (transport) │
│ Claude, etc.) │ │ │
└─────────────────┘ └──────┬───────┘
┌─────────────────┐ Streamable HTTP ┌────┴────────┐
│ Remote LLM │◄───────────────►│ /api/mcp │
│ │ (Bearer token) │ (transport) │
└─────────────────┘ └──────┬───────┘
┌──────────┴──────────┐
│ mcpAuthentication │
│ (token validation, │
│ access control) │
└──────────┬──────────┘
┌────────┴────────┐
│ mcpAdapter.js │
│ (tool routing │
│ + data fetch) │
└────────┬────────┘
┌────────┴────────┐
│ mcpNormalizer.js│
│ (markdown │
│ formatting) │
└────────┬────────┘
┌──────┴───────┐
│ Fredy DB │
│ (SQLite) │
└──────────────┘
```

347
lib/mcp/mcpAdapter.js Normal file
View File

@@ -0,0 +1,347 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { queryJobs, getJob } from '../services/storage/jobStorage.js';
import { queryListings, getListingById } from '../services/storage/listingsStorage.js';
import { authenticateToolCall, checkJobAccess } from './mcpAuthentication.js';
import {
normalizeListJobs,
normalizeGetJob,
normalizeListListings,
normalizeGetListing,
normalizeError,
} from './mcpNormalizer.js';
/**
* Create a configured MCP server instance with all Fredy tools registered.
*
* The adapter fetches raw data from storage and delegates response formatting
* to the normalizer layer (mcpNormalizer.js) which produces a consistent
* { ok, summary, data, meta } envelope for every tool response.
*
* Each tool call requires a userId (resolved from the MCP token before invocation).
* Tools respect user scoping: non-admin users only see their own jobs/listings.
*
* @returns {McpServer}
*/
export function createMcpServer() {
const server = new McpServer(
{
name: 'fredy-mcp',
version: '1.0.0',
},
{
capabilities: {
tools: {},
},
instructions:
'Fredy MCP Server query real estate jobs and listings. ' +
'All timestamps are unix timestamps in milliseconds (e.g. 1772008362564). ' +
'Use list_jobs to browse jobs, get_job for details, ' +
'list_listings to search listings (supports time filters like createdAfter/createdBefore), ' +
'and get_listing for full details of a single listing. ' +
'Responses are formatted as markdown with a summary, data (tables for lists, key-value for details), and pagination info. ' +
'Always present results to the user as soon as you have them - do NOT call the tool again unless you need additional pages or different data.',
},
);
// ── list_jobs ───────────────────────────────────────────────────────
server.tool(
'list_jobs',
'List real estate search jobs for the authenticated user. ' +
'Returns up to 50 jobs per page by default. Use pagination (page parameter) to fetch more. ' +
'Check meta.hasMore to know if there are additional pages.',
{
page: z.number().optional().describe('Page number (default: 1)'),
pageSize: z
.number()
.optional()
.describe('Results per page (default: 50, max: 1000). Start with the default and paginate if needed.'),
filter: z.string().optional().describe('Free-text filter on job name'),
},
async ({ page, pageSize, filter }, extra) => {
const { user, error } = authenticateToolCall(extra, 'list_jobs');
if (error) return normalizeError(error, 'list_jobs');
const safePage = page ?? 1;
const safePageSize = pageSize ?? 50;
const result = queryJobs({
page: safePage,
pageSize: safePageSize,
freeTextFilter: filter,
userId: user.id,
isAdmin: user.isAdmin,
});
return normalizeListJobs(result, { page: safePage, pageSize: safePageSize });
},
);
// ── get_job ─────────────────────────────────────────────────────────
server.tool(
'get_job',
'Get detailed information about a specific job by its ID.',
{
jobId: z.string().describe('The job ID to retrieve'),
},
async ({ jobId }, extra) => {
const { user, error } = authenticateToolCall(extra, 'get_job');
if (error) return normalizeError(error, 'get_job');
const job = getJob(jobId);
if (!job) {
return normalizeError('Job not found.', 'get_job');
}
if (!checkJobAccess(user, job)) {
return normalizeError('Access denied.', 'get_job');
}
return normalizeGetJob(job);
},
);
// ── list_listings ───────────────────────────────────────────────────
server.tool(
'list_listings',
'Search and list real estate listings. Returns up to 50 listings per page by default. ' +
'Use pagination (page parameter) to fetch more. Check meta.hasMore in the response. ' +
'Supports text search, time filtering, and various filters. ' +
'All timestamps are unix timestamps in milliseconds (e.g. 1772008362564). ' +
'Use createdAfter/createdBefore to filter by time, e.g. "give me all listings from today". ' +
'Use get_listing to get full details (description, link, image) for a specific listing.',
{
page: z.number().optional().describe('Page number (default: 1)'),
pageSize: z
.number()
.optional()
.describe('Results per page (default: 50, max: 1000). Start with the default and paginate if needed.'),
filter: z.string().optional().describe('Free-text search across title, address, provider, link'),
jobId: z.string().optional().describe('Filter listings by job ID'),
activeOnly: z.boolean().optional().describe('When true, only show active listings'),
provider: z.string().optional().describe('Filter by provider name'),
createdAfter: z
.number()
.optional()
.describe(
'Only include listings created at or after this unix timestamp in milliseconds (e.g. 1772008362564). Useful for queries like "listings from today".',
),
createdBefore: z
.number()
.optional()
.describe(
'Only include listings created at or before this unix timestamp in milliseconds (e.g. 1772008362564).',
),
minPrice: z
.number()
.optional()
.describe(
'Only include listings with price >= this value (e.g. 500). Price is a numeric value (no currency symbol).',
),
maxPrice: z
.number()
.optional()
.describe(
'Only include listings with price <= this value (e.g. 1500). Price is a numeric value (no currency symbol).',
),
sortField: z.string().optional().describe('Sort by: created_at, price, size, provider, title, is_active'),
sortDir: z.string().optional().describe('Sort direction: asc or desc'),
},
async (
{
page,
pageSize,
filter,
jobId,
activeOnly,
provider,
createdAfter,
createdBefore,
minPrice,
maxPrice,
sortField,
sortDir,
},
extra,
) => {
const { user, error } = authenticateToolCall(extra, 'list_listings');
if (error) return normalizeError(error, 'list_listings');
const safePage = page ?? 1;
const safePageSize = pageSize ?? 50;
const result = queryListings({
page: safePage,
pageSize: safePageSize,
freeTextFilter: filter,
jobIdFilter: jobId,
activityFilter: activeOnly === true ? true : activeOnly === false ? false : undefined,
providerFilter: provider,
createdAfter: createdAfter ?? null,
createdBefore: createdBefore ?? null,
minPrice: minPrice ?? null,
maxPrice: maxPrice ?? null,
sortField: sortField ?? null,
sortDir: sortDir ?? 'desc',
userId: user.id,
isAdmin: user.isAdmin,
});
return normalizeListListings(result, { page: safePage, pageSize: safePageSize });
},
);
// ── get_listing ─────────────────────────────────────────────────────
server.tool(
'get_listing',
'Get full details of a single listing by its ID.',
{
listingId: z.string().describe('The listing ID to retrieve'),
},
async ({ listingId }, extra) => {
const { user, error } = authenticateToolCall(extra, 'get_listing');
if (error) return normalizeError(error, 'get_listing');
const listing = getListingById(listingId, user.id, user.isAdmin);
if (!listing) {
return normalizeError('Listing not found or access denied.', 'get_listing');
}
return normalizeGetListing(listing);
},
);
// ── get_photo_for_listing ─────────────────────────────────────────────────────
server.tool(
'get_photo_for_listing',
'Fetch and return the photo of a listing by its ID as an image for vision analysis.',
{
listingId: z.string().describe('The listing ID whose photo to fetch'),
},
async ({ listingId }, extra) => {
const { user, error } = authenticateToolCall(extra, 'get_photo_for_listing');
if (error) return normalizeError(error, 'get_photo_for_listing');
const listing = getListingById(listingId, user.id, user.isAdmin);
if (!listing) {
return normalizeError('Listing not found or access denied.', 'get_photo_for_listing');
}
const imageUrl = listing.image_url;
if (!imageUrl) {
return normalizeError('No image available for this listing.', 'get_photo_for_listing');
}
const SUPPORTED_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
let response;
try {
response = await fetch(imageUrl, {
signal: AbortSignal.timeout(10_000),
headers: {
'User-Agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
Accept: 'image/jpeg,image/png,image/webp,image/gif,image/*,*/*',
},
});
} catch (fetchErr) {
return normalizeError(`Failed to fetch image: ${fetchErr.message}`, 'get_photo_for_listing');
}
if (!response.ok) {
return normalizeError(
`Image fetch returned HTTP ${response.status}. Image URL: ${imageUrl}`,
'get_photo_for_listing',
);
}
const contentType = response.headers.get('content-type') ?? '';
const headerMimeType = contentType.split(';')[0].trim().toLowerCase();
let buffer;
try {
buffer = await response.arrayBuffer();
} catch (readErr) {
return normalizeError(`Failed to read image body: ${readErr.message}`, 'get_photo_for_listing');
}
const bytes = new Uint8Array(buffer);
if (bytes.length < 12) {
return normalizeError(
`Downloaded file is too small to determine image type. Image URL: ${imageUrl}`,
'get_photo_for_listing',
);
}
let resolvedMime;
if (SUPPORTED_MIME_TYPES.has(headerMimeType)) {
resolvedMime = headerMimeType;
} else {
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
resolvedMime = 'image/jpeg';
} else if (
bytes[0] === 0x89 &&
bytes[1] === 0x50 &&
bytes[2] === 0x4e &&
bytes[3] === 0x47 &&
bytes[4] === 0x0d &&
bytes[5] === 0x0a &&
bytes[6] === 0x1a &&
bytes[7] === 0x0a
) {
resolvedMime = 'image/png';
} else if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) {
resolvedMime = 'image/gif';
} else if (
bytes[0] === 0x52 &&
bytes[1] === 0x49 &&
bytes[2] === 0x46 &&
bytes[3] === 0x46 &&
bytes[8] === 0x57 &&
bytes[9] === 0x45 &&
bytes[10] === 0x42 &&
bytes[11] === 0x50
) {
resolvedMime = 'image/webp';
} else {
return normalizeError(
`Image format not supported by vision models (header: ${headerMimeType || 'unknown'}). Image URL: ${imageUrl}`,
'get_photo_for_listing',
);
}
}
const base64 = Buffer.from(buffer).toString('base64');
return {
content: [
{
type: 'image',
data: base64,
mimeType: resolvedMime,
},
],
};
},
);
// ── get_current_date_ime ─────────────────────────────────────────────────────
server.tool('get_current_date_time', 'Returns the current date and time.', {}, () => {
return {
content: [{ type: 'text', text: `Timestring: ${new Date().toLocaleString()}, MS since 1970: ${Date.now()}` }],
};
});
return server;
}

View File

@@ -0,0 +1,66 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
/**
* MCP Authentication Layer
*
* Centralizes all authentication and authorization logic for MCP tool calls
* and HTTP requests. Ensures consistent access control across all transports.
*/
import { getUser, validateMcpToken } from '../services/storage/userStorage.js';
/**
* Authenticate an MCP tool call by extracting and validating the user from authInfo.
*
* @param {{ authInfo?: { userId?: string } }} extra - The extra context passed by the MCP SDK.
* @returns {{ user: object|null, error: string|null }}
* - On success: { user: <userObject>, error: null }
* - On failure: { user: null, error: <errorMessage> }
*/
export function authenticateToolCall(extra) {
const userId = extra?.authInfo?.userId;
if (!userId) {
return { user: null, error: 'Authentication required. Please provide a valid MCP API token.' };
}
const user = getUser(userId);
if (!user) {
return { user: null, error: 'Authentication required. Please provide a valid MCP API token.' };
}
return { user, error: null };
}
/**
* Check whether a user has access to a specific job.
* Admins have access to all jobs. Non-admins can only access their own jobs
* or jobs explicitly shared with them.
*
* @param {object} user - The authenticated user object.
* @param {object} job - The job object from storage.
* @returns {boolean} True if the user is allowed to access this job.
*/
export function checkJobAccess(user, job) {
if (user.isAdmin) return true;
if (job.userId === user.id) return true;
if (Array.isArray(job.shared_with_user) && job.shared_with_user.includes(user.id)) return true;
return false;
}
/**
* Authenticate an HTTP request by extracting and validating the Bearer token
* from the Authorization header.
*
* @param {import('http').IncomingMessage} req
* @returns {{ userId: string } | null} The authenticated user info, or null if invalid.
*/
export function authenticateRequest(req) {
const authHeader = req.headers['authorization'] || '';
if (!authHeader.startsWith('Bearer ')) return null;
const token = authHeader.slice(7).trim();
if (!token) return null;
return validateMcpToken(token);
}

114
lib/mcp/mcpHttpRoute.js Normal file
View File

@@ -0,0 +1,114 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { createMcpServer } from './mcpAdapter.js';
import { authenticateRequest } from './mcpAuthentication.js';
import logger from '../services/logger.js';
import crypto from 'crypto';
/**
* Active transports keyed by session id.
* @type {Map<string, { server: McpServer, transport: StreamableHTTPServerTransport }>}
*/
const sessions = new Map();
/**
* @param {string|undefined} sessionId
* @param {{ userId: string }} auth
*/
function getOrCreateSession(sessionId, auth) {
if (sessionId && sessions.has(sessionId)) {
return sessions.get(sessionId);
}
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
onsessioninitialized: (sid) => {
sessions.set(sid, entry);
logger.debug(`MCP session created: ${sid}`);
},
});
const server = createMcpServer();
const entry = { server, transport, userId: auth.userId };
transport.onclose = () => {
const sid = transport.sessionId;
if (sid) {
sessions.delete(sid);
logger.debug(`MCP session closed: ${sid}`);
}
};
return entry;
}
/**
* Register MCP Streamable HTTP routes on a fastify instance.
*
* POST /api/mcp JSON-RPC messages
* GET /api/mcp SSE stream for server-initiated notifications
* DELETE /api/mcp session termination
*
* All endpoints require a valid Bearer token in the Authorization header.
*
* @param {import('fastify').FastifyInstance} fastify
*/
export function registerMcpRoutes(fastify) {
fastify.post('/api/mcp', async (request, reply) => {
const auth = authenticateRequest(request.raw);
if (!auth) {
return reply.code(401).send({ error: 'Unauthorized. Provide a valid Bearer token.' });
}
const sessionId = request.raw.headers['mcp-session-id'];
const { server, transport } = getOrCreateSession(sessionId, auth);
if (!transport.onmessage) {
await server.connect(transport);
}
request.raw.auth = { userId: auth.userId };
reply.hijack();
await transport.handleRequest(request.raw, reply.raw, request.body);
});
fastify.get('/api/mcp', async (request, reply) => {
const auth = authenticateRequest(request.raw);
if (!auth) {
return reply.code(401).send({ error: 'Unauthorized. Provide a valid Bearer token.' });
}
const sessionId = request.raw.headers['mcp-session-id'];
if (!sessionId || !sessions.has(sessionId)) {
return reply.code(400).send({ error: 'Invalid or missing session. Send an initialize request first.' });
}
const { transport } = sessions.get(sessionId);
reply.hijack();
await transport.handleRequest(request.raw, reply.raw);
});
fastify.delete('/api/mcp', async (request, reply) => {
const auth = authenticateRequest(request.raw);
if (!auth) {
return reply.code(401).send({ error: 'Unauthorized. Provide a valid Bearer token.' });
}
const sessionId = request.raw.headers['mcp-session-id'];
if (!sessionId || !sessions.has(sessionId)) {
return reply.code(404).send({ error: 'Session not found.' });
}
const { transport } = sessions.get(sessionId);
await transport.close();
sessions.delete(sessionId);
return { ok: true };
});
logger.debug('MCP Streamable HTTP endpoint registered at /api/mcp');
}

180
lib/mcp/mcpNormalizer.js Normal file
View File

@@ -0,0 +1,180 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
/**
* MCP Response Normalizer
*
* Transforms raw adapter data into LLM-friendly markdown responses.
* Markdown is significantly better than JSON for LLM consumption because:
* - LLMs are trained extensively on markdown text
* - Markdown tables are ~40-60% more token-efficient than JSON arrays
* - Less syntactic noise (no quotes, brackets, commas around every value)
* - Natively readable and structured
*
* Each response follows a consistent structure:
* 1. Status line (OK/ERROR + tool name)
* 2. Summary (human-readable description)
* 3. Data (markdown table for lists, key-value for single items)
* 4. Pagination info (for list responses)
*/
/**
* Wrap a markdown string as an MCP text content result.
* @param {string} markdown
* @param {boolean} [isError=false]
* @returns {{ content: Array, isError?: boolean }}
*/
function toMcpResponse(markdown, isError = false) {
const result = {
content: [{ type: 'text', text: markdown }],
};
if (isError) result.isError = true;
return result;
}
/**
* Format a unix timestamp (ms) as a human-readable date string.
* @param {number|null|undefined} ts
* @returns {string}
*/
function formatDate(ts) {
if (ts == null) return '';
return new Date(ts)
.toISOString()
.replace('T', ' ')
.replace(/\.\d{3}Z$/, '');
}
/**
* Escape pipe characters in table cell values.
* @param {*} val
* @returns {string}
*/
function cell(val) {
if (val == null) return '';
return String(val).replace(/\|/g, '\\|').replace(/\n/g, ' ');
}
/**
* Normalize a list_jobs response.
* @param {{ totalNumber: number, page: number, result: object[] }} queryResult
* @param {{ page: number, pageSize: number }} params
* @returns {{ content: Array }}
*/
export function normalizeListJobs(queryResult, { page, pageSize }) {
const maxPage = Math.max(1, Math.ceil(queryResult.totalNumber / pageSize));
const hasMore = page < maxPage;
const jobs = queryResult.result;
let md = `**Tool:** list_jobs | **Status:** OK\n\n`;
md += `Found **${queryResult.totalNumber}** job(s). Showing page ${page} of ${maxPage} (${jobs.length} on this page).`;
if (hasMore) md += ` More pages available - use page=${page + 1} to continue.`;
md += '\n\n';
if (jobs.length > 0) {
md += `| ID | Name | Enabled | Active Listings |\n`;
md += `|----|------|---------|----------------|\n`;
for (const j of jobs) {
md += `| ${cell(j.id)} | ${cell(j.name)} | ${j.enabled ? 'yes' : 'no'} | ${j.numberOfFoundListings ?? 0} |\n`;
}
} else {
md += `No jobs found.\n`;
}
md += `\n**Page:** ${page}/${maxPage} | **Has more:** ${hasMore ? 'yes' : 'no'}`;
return toMcpResponse(md);
}
/**
* Normalize a get_job response.
* @param {object} job - The job object from storage.
* @returns {{ content: Array }}
*/
export function normalizeGetJob(job) {
const providers = (job.provider ?? []).map((p) => p.id || p);
let md = `**Tool:** get_job | **Status:** OK\n\n`;
md += `### Job: ${job.name || job.id}\n\n`;
md += `- **ID:** ${job.id}\n`;
md += `- **Name:** ${job.name || ''}\n`;
md += `- **Enabled:** ${job.enabled ? 'yes' : 'no'}\n`;
md += `- **Active Listings:** ${job.numberOfFoundListings ?? 0}\n`;
md += `- **Providers:** ${providers.length > 0 ? providers.join(', ') : ''}\n`;
md += `- **Blacklist:** ${(job.blacklist ?? []).length > 0 ? job.blacklist.join(', ') : ''}\n`;
return toMcpResponse(md);
}
/**
* Normalize a list_listings response.
* @param {{ totalNumber: number, page: number, result: object[] }} queryResult
* @param {{ page: number, pageSize: number }} params
* @returns {{ content: Array }}
*/
export function normalizeListListings(queryResult, { page, pageSize }) {
const maxPage = Math.max(1, Math.ceil(queryResult.totalNumber / pageSize));
const hasMore = page < maxPage;
const listings = queryResult.result;
let md = `**Tool:** list_listings | **Status:** OK\n\n`;
md += `Found **${queryResult.totalNumber}** listing(s). Showing page ${page} of ${maxPage} (${listings.length} on this page).`;
if (hasMore) md += ` More pages available - use page=${page + 1} to continue.`;
md += '\n\n';
if (listings.length > 0) {
md += `| ID | Title | Address | Price | Size | Provider | Active | Created | Job |\n`;
md += `|----|-------|---------|-------|------|----------|--------|---------|-----|\n`;
for (const l of listings) {
md += `| ${cell(l.id)} | ${cell(l.title)} | ${cell(l.address)} | ${cell(l.price)} | ${cell(l.size)} | ${cell(l.provider)} | ${l.is_active ? 'yes' : 'no'} | ${formatDate(l.created_at)} | ${cell(l.job_name)} |\n`;
}
md += `\nUse **get_listing** with an ID for full details (description, link, image).\n`;
} else {
md += `No listings found.\n`;
}
md += `\n**Page:** ${page}/${maxPage} | **Has more:** ${hasMore ? 'yes' : 'no'}`;
return toMcpResponse(md);
}
/**
* Normalize a get_listing response.
* @param {object} listing - The listing object from storage.
* @returns {{ content: Array }}
*/
export function normalizeGetListing(listing) {
let md = `**Tool:** get_listing | **Status:** OK\n\n`;
md += `### Listing: ${listing.title || listing.id}\n\n`;
md += `- **ID:** ${listing.id}\n`;
md += `- **Title:** ${listing.title || ''}\n`;
md += `- **Description:** ${listing.description || ''}\n`;
md += `- **Address:** ${listing.address || ''}\n`;
md += `- **Price:** ${listing.price ?? ''}\n`;
md += `- **Size:** ${listing.size ?? ''}\n`;
md += `- **Provider:** ${listing.provider || ''}\n`;
md += `- **Link:** ${listing.link || ''}\n`;
md += `- **Image:** ${listing.image_url || ''}\n`;
md += `- **Active:** ${listing.is_active ? 'yes' : 'no'}\n`;
md += `- **Created:** ${formatDate(listing.created_at)}\n`;
md += `- **Job:** ${listing.job_name || ''}\n`;
if (listing.latitude != null && listing.longitude != null) {
md += `- **Location:** ${listing.latitude}, ${listing.longitude}\n`;
}
if (listing.distance_to_destination != null) {
md += `- **Distance to destination:** ${listing.distance_to_destination}\n`;
}
return toMcpResponse(md);
}
/**
* Normalize an error response.
* @param {string} message - The error message.
* @param {string} [tool] - Optional tool name for context.
* @returns {{ content: Array, isError: boolean }}
*/
export function normalizeError(message, tool) {
const md = `**Tool:** ${tool ?? 'unknown'} | **Status:** ERROR\n\n${message}`;
return toMcpResponse(md, true);
}

76
lib/mcp/stdio.js Normal file
View File

@@ -0,0 +1,76 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
/**
* Fredy MCP Server stdio transport
*
* Launches the MCP server over stdin/stdout so that local LLM clients
* (e.g. Claude Desktop, llm-cli, mcp-cli) can connect directly.
*
* Usage:
* MCP_TOKEN=fredy_<your-token> node mcp/stdio.js
*
* The MCP_TOKEN environment variable must contain a valid Fredy MCP token.
* Each user has a permanent, non-expiring token shown in the user management list.
*/
import { fileURLToPath } from 'url';
import path from 'path';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import SqliteConnection from '../services/storage/SqliteConnection.js';
import { runMigrations } from '../services/storage/migrations/migrate.js';
import { createMcpServer } from './mcpAdapter.js';
import { validateMcpToken } from '../services/storage/userStorage.js';
// Ensure cwd is the project root so that relative DB/config paths resolve correctly
// (LM Studio and other MCP hosts may spawn this process from an arbitrary directory)
const __dirname = path.dirname(fileURLToPath(import.meta.url));
process.chdir(path.resolve(__dirname, '..', '..'));
// Initialize the database (required for standalone usage)
await SqliteConnection.init();
await runMigrations();
const token = process.env.MCP_TOKEN;
if (!token) {
process.stderr.write('Error: MCP_TOKEN environment variable is required.\n');
process.stderr.write('Each user has a permanent MCP token shown in the user management list.\n');
process.exit(1);
}
const auth = validateMcpToken(token);
if (!auth) {
process.stderr.write('Error: Invalid MCP_TOKEN. Token not found or user no longer exists.\n');
process.exit(1);
}
const mcpServer = createMcpServer();
// Wrap the stdio transport to inject authInfo into every message
const transport = new StdioServerTransport();
// Patch: the MCP SDK passes authInfo through the transport's onmessage extra param.
// For stdio we inject the resolved user from the token.
const patchedTransport = new Proxy(transport, {
set(target, prop, value) {
if (prop === 'onmessage') {
target.onmessage = (message, extra) => {
value(message, { ...extra, authInfo: { userId: auth.userId } });
};
return true;
}
target[prop] = value;
return true;
},
get(target, prop) {
return target[prop];
},
});
await mcpServer.connect(patchedTransport);
process.stderr.write('Fredy MCP Server running on stdio\n');

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
@@ -7,13 +7,14 @@ import { markdown2Html } from '../../services/markdown.js';
import { getJob } from '../../services/storage/jobStorage.js';
import fetch from 'node-fetch';
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
export const send = ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
const { server } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
const job = getJob(jobKey);
const jobName = job == null ? jobKey : job.name;
const promises = newListings.map((newListing) => {
const title = `${jobName} at ${serviceName}: ${newListing.title}`;
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}`;
const fredyLine = baseUrl && newListing.id ? `\nOpen in Fredy: ${baseUrl}/listings/listing/${newListing.id}` : '';
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}${fredyLine}`;
return fetch(server, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },

View File

@@ -1,13 +1,22 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { markdown2Html } from '../../services/markdown.js';
export const send = ({ serviceName, newListings, jobKey }) => {
export const send = ({ serviceName, newListings, jobKey, baseUrl }) => {
/* eslint-disable no-console */
return [Promise.resolve(console.info(`Found entry from service ${serviceName}, Job: ${jobKey}:`, newListings))];
const fredyLinks = baseUrl ? newListings.map((l) => `${baseUrl}/listings/listing/${l.id}`).join(', ') : null;
return [
Promise.resolve(
console.info(
`Found entry from service ${serviceName}, Job: ${jobKey}:`,
newListings,
...(fredyLinks ? [`Open in Fredy: ${fredyLinks}`] : []),
),
),
];
/* eslint-enable no-console */
};
export const config = {

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
@@ -39,9 +39,10 @@ const generateColorFromString = (str) => {
*
* @param {string} jobKey - Key of job (used to set embed color)
* @param {object} listing - Object holding listing details
* @param baseUrl
* @returns {object} Discord webhook embed
*/
const buildEmbed = (jobKey, listing) => {
const buildEmbed = (jobKey, listing, baseUrl) => {
const maxTitleLength = 252; // Max embed title length is 256 characters
let title = String(listing.title ?? 'N/A');
if (title.length > maxTitleLength) {
@@ -79,10 +80,18 @@ const buildEmbed = (jobKey, listing) => {
};
}
if (baseUrl && listing.id) {
fields.push({
name: 'Open in Fredy',
value: `[Open in Fredy](${baseUrl}/listings/listing/${listing.id})`,
inline: false,
});
}
return embed;
};
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
export const send = ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
const adapter = notificationConfig.find((adapter) => adapter.id === config.id);
const webhookUrl = adapter?.fields?.webhookUrl;
if (!webhookUrl || newListings.length === 0) return Promise.resolve([]);
@@ -90,7 +99,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
const job = getJob(jobKey);
const jobName = job?.name || jobKey;
const embeds = newListings.map((listing) => buildEmbed(jobKey, listing));
const embeds = newListings.map((listing) => buildEmbed(jobKey, listing, baseUrl));
const maxEmbedsPerMessage = 10; // Discord only allows up to 10 embeds
const webhookPromises = [];

View File

@@ -1,11 +1,11 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { markdown2Html } from '../../services/markdown.js';
const mapListing = (listing) => ({
const mapListing = (listing, baseUrl) => ({
address: listing.address,
description: listing.description,
id: listing.id,
@@ -14,12 +14,13 @@ const mapListing = (listing) => ({
size: listing.size,
title: listing.title,
url: listing.link,
fredyUrl: baseUrl && listing.id ? `${baseUrl}/listings/listing/${listing.id}` : null,
});
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
const { authToken, endpointUrl } = notificationConfig.find((a) => a.id === config.id).fields;
export const send = async ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
const { authToken, endpointUrl, selfSignedCerts } = notificationConfig.find((a) => a.id === config.id).fields;
const listings = newListings.map(mapListing);
const listings = newListings.map((l) => mapListing(l, baseUrl));
const body = {
jobId: jobKey,
timestamp: new Date().toISOString(),
@@ -34,11 +35,20 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
headers['Authorization'] = `Bearer ${authToken}`;
}
return fetch(endpointUrl, {
let fetchOptions = {
method: 'POST',
headers: headers,
headers,
timeout: 10000,
body: JSON.stringify(body),
});
};
if (selfSignedCerts === true) {
fetchOptions.dispatcher = new (await import('undici')).Agent({
connect: { rejectUnauthorized: false },
});
}
return fetch(endpointUrl, fetchOptions);
};
export const config = {
@@ -52,6 +62,10 @@ export const config = {
label: 'Endpoint URL',
type: 'text',
},
selfSignedCerts: {
label: 'Self-signed certificates',
type: 'boolean',
},
authToken: {
description: "Your application's auth token, if required by your endpoint.",
label: 'Auth token (optional)',

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
@@ -35,7 +35,7 @@ const toBase64 = async (url) => {
}
};
const mapListingsWithCid = async (serviceName, jobKey, listings) => {
const mapListingsWithCid = async (serviceName, jobKey, listings, baseUrl) => {
const out = [];
const attachments = [];
@@ -53,6 +53,7 @@ const mapListingsWithCid = async (serviceName, jobKey, listings) => {
jobKey,
hasImage: false,
imageCid: '',
fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null,
};
if (imgUrl) {
@@ -78,7 +79,7 @@ const mapListingsWithCid = async (serviceName, jobKey, listings) => {
return { listings: out, attachments };
};
export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => {
export const send = async ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
const { apiPublicKey, apiPrivateKey, receiver, from } = notificationConfig.find(
(adapter) => adapter.id === config.id,
).fields;
@@ -89,7 +90,7 @@ export const send = async ({ serviceName, newListings, notificationConfig, jobKe
.map((r) => ({ Email: r.trim() }))
.filter((r) => r.Email.length > 0);
const { listings, attachments } = await mapListingsWithCid(serviceName, jobKey, newListings);
const { listings, attachments } = await mapListingsWithCid(serviceName, jobKey, newListings, baseUrl);
const html = emailTemplate({
serviceName: `Job: (${jobKey}) | Service: ${serviceName}`,

View File

@@ -1,20 +1,25 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { markdown2Html } from '../../services/markdown.js';
import { getJob } from '../../services/storage/jobStorage.js';
import fetch from 'node-fetch';
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
export const send = ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
const { webhook, channel } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
const job = getJob(jobKey);
const jobName = job == null ? jobKey : job.name;
let message = `### *${jobName}* (${serviceName}) found **${newListings.length}** new listings:\n\n`;
message += `| Title | Address | Size | Price |\n|:----|:----|:----|:----|\n`;
message += newListings.map(
(o) => `| [${o.title}](${o.link}) | ` + [o.address, o.size.replace(/2m/g, '$m^2$'), o.price].join(' | ') + ' |\n',
);
message += `| Title | Address | Size | Price |${baseUrl ? ' Open in Fredy |' : ''}\n|:----|:----|:----|:----|${baseUrl ? ':----|\n' : '\n'}`;
message += newListings.map((o) => {
const fredyCell = baseUrl && o.id ? ` [Open in Fredy](${baseUrl}/listings/listing/${o.id}) |` : '';
return (
`| [${o.title}](${o.link}) | ` +
[o.address, o.size.replace(/2m/g, '$m^2$'), o.price].join(' | ') +
` |${fredyCell}\n`
);
});
return fetch(webhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
@@ -8,17 +8,18 @@ import { getJob } from '../../services/storage/jobStorage.js';
import fetch from 'node-fetch';
import { normalizeImageUrl } from '../../utils.js';
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
export const send = ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
const { priority, server, topic } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
const job = getJob(jobKey);
const jobName = job == null ? jobKey : job.name;
const promises = newListings.map((newListing) => {
const fredyLine = baseUrl && newListing.id ? `\nOpen in Fredy: ${baseUrl}/listings/listing/${newListing.id}` : '';
const message = `
Address: ${newListing.address}
Size: ${newListing.size == null ? 'N/A' : newListing.size.replace(/2m/g, '$m^2$')}
Price: ${newListing.price}
Link: ${newListing.link}`;
Link: ${newListing.link}${fredyLine}`;
const sanitizeHeaderValue = (value) =>
String(value ?? '')

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
@@ -7,7 +7,7 @@ import { markdown2Html } from '../../services/markdown.js';
import { getJob } from '../../services/storage/jobStorage.js';
import fetch from 'node-fetch';
export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => {
export const send = async ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
const { token, user, device } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
const job = getJob(jobKey);
const jobName = job == null ? jobKey : job.name;
@@ -15,7 +15,8 @@ export const send = async ({ serviceName, newListings, notificationConfig, jobKe
const results = await Promise.all(
newListings.map(async (newListing) => {
const title = `${jobName} at ${serviceName}: ${newListing.title}`;
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}`;
const fredyLine = baseUrl && newListing.id ? `\nOpen in Fredy: ${baseUrl}/listings/listing/${newListing.id}` : '';
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}${fredyLine}`;
const form = new FormData();
form.append('token', token);

View File

@@ -0,0 +1,89 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { Resend } from 'resend';
import path from 'path';
import fs from 'fs';
import Handlebars from 'handlebars';
import { markdown2Html } from '../../services/markdown.js';
import { getDirName, normalizeImageUrl } from '../../utils.js';
const __dirname = getDirName();
const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTemplate/template.hbs'), 'utf8');
const emailTemplate = Handlebars.compile(template);
const mapListings = (serviceName, jobKey, listings, baseUrl) =>
listings.map((l) => {
const image = normalizeImageUrl(l.image);
return {
title: l.title || '',
link: l.link || '',
address: l.address || '',
size: l.size || '',
price: l.price || '',
image,
hasImage: Boolean(image),
fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null,
serviceName,
jobKey,
};
});
export const send = async ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
const { apiKey, receiver, from } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
const to = receiver
.trim()
.split(',')
.map((r) => r.trim())
.filter(Boolean);
const resend = new Resend(apiKey);
const listings = mapListings(serviceName, jobKey, newListings, baseUrl);
const html = emailTemplate({
serviceName: `Job: (${jobKey}) | Service: ${serviceName}`,
numberOfListings: listings.length,
listings,
});
const { error } = await resend.emails.send({
from,
to,
subject: `Fredy found ${listings.length} new listing(s) for ${serviceName}`,
html,
});
if (!error) {
return Promise.resolve();
} else {
return Promise.reject(error.message);
}
};
export const config = {
id: 'resend',
name: 'Resend',
description: 'Resend is being used to send new listings via mail.',
readme: markdown2Html('lib/notification/adapter/resend.md'),
fields: {
apiKey: {
type: 'text',
label: 'Api Key',
description: 'The Resend API key used to send emails.',
},
receiver: {
type: 'email',
label: 'Receiver Email',
description: 'Comma-separated email addresses Fredy will send notifications to.',
},
from: {
type: 'email',
label: 'Sender Email',
description: 'The verified email address or domain you send from in Resend.',
},
},
};

View File

@@ -0,0 +1,17 @@
### Resend Adapter
Resend is a modern email delivery service that Fredy can use to send notifications.
Setup:
- Create a Resend account: https://resend.com/
- Create an API key and add it to Fredy's configuration.
- Choose the sender address (e.g., you@yourdomain.com). Verify the domain (https://resend.com/domains/) in Resend before using it.
- Optional for local testing: you can use `onboarding@resend.dev`, but Resend may restrict who you can send to when using test domains.
Multiple recipients:
- Separate email addresses with commas (e.g., some@email.com, someOther@email.com).
Notes & Troubleshooting:
- Ensure the `from` address is verified or belongs to a verified domain in Resend.
- If emails don't arrive, check your spam folder and Resend dashboard logs.
- The template displays listing images via their public URLs; make sure images are reachable.

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
@@ -7,7 +7,7 @@ import sgMail from '@sendgrid/mail';
import { markdown2Html } from '../../services/markdown.js';
import { normalizeImageUrl } from '../../utils.js';
const mapListings = (serviceName, jobKey, listings) =>
const mapListings = (serviceName, jobKey, listings, baseUrl) =>
listings.map((l) => {
const image = normalizeImageUrl(l.image);
return {
@@ -20,12 +20,13 @@ const mapListings = (serviceName, jobKey, listings) =>
hasImage: Boolean(image),
// optional plain text snippet
snippet: [l.address, l.price, l.size].filter(Boolean).join(' | '),
fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null,
serviceName,
jobKey,
};
});
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
export const send = ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
const { apiKey, receiver, from, templateId } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
sgMail.setApiKey(apiKey);
@@ -36,7 +37,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
.map((r) => r.trim())
.filter(Boolean);
const listings = mapListings(serviceName, jobKey, newListings);
const listings = mapListings(serviceName, jobKey, newListings, baseUrl);
const msg = {
templateId,

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
@@ -7,7 +7,7 @@ import Slack from 'slack';
import { markdown2Html } from '../../services/markdown.js';
import { normalizeImageUrl } from '../../utils.js';
const buildBlocks = (serviceName, jobKey, p) => {
const buildBlocks = (serviceName, jobKey, p, baseUrl) => {
const blocks = [
{
type: 'header',
@@ -36,6 +36,13 @@ const buildBlocks = (serviceName, jobKey, p) => {
});
}
if (baseUrl && p.id) {
blocks.push({
type: 'section',
text: { type: 'mrkdwn', text: `<${baseUrl}/listings/listing/${p.id}|Open in Fredy>` },
});
}
blocks.push({
type: 'context',
elements: [{ type: 'mrkdwn', text: 'Powered by Fredy' }],
@@ -44,7 +51,7 @@ const buildBlocks = (serviceName, jobKey, p) => {
return blocks;
};
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
export const send = ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
const { token, channel } = notificationConfig.find((a) => a.id === config.id).fields;
return Promise.allSettled(
@@ -53,7 +60,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
token,
channel,
text: `${serviceName} ${jobKey}: ${p.title}`,
blocks: buildBlocks(serviceName, jobKey, p),
blocks: buildBlocks(serviceName, jobKey, p, baseUrl),
unfurl_links: false,
unfurl_media: false,
}),

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
@@ -7,7 +7,7 @@ import fetch from 'node-fetch';
import { markdown2Html } from '../../services/markdown.js';
import { normalizeImageUrl } from '../../utils.js';
const buildBlocks = (serviceName, jobKey, p) => {
const buildBlocks = (serviceName, jobKey, p, baseUrl) => {
const blocks = [
{
type: 'header',
@@ -36,6 +36,13 @@ const buildBlocks = (serviceName, jobKey, p) => {
});
}
if (baseUrl && p.id) {
blocks.push({
type: 'section',
text: { type: 'mrkdwn', text: `<${baseUrl}/listings/listing/${p.id}|Open in Fredy>` },
});
}
blocks.push({
type: 'context',
elements: [{ type: 'mrkdwn', text: 'Powered by Fredy' }],
@@ -51,7 +58,7 @@ const postJson = (url, body) =>
body,
});
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
export const send = ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
const adapter = notificationConfig.find((a) => a.id === config.id);
const webhookUrl = adapter?.fields?.webhookUrl;
if (!webhookUrl) return Promise.resolve([]);
@@ -59,7 +66,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
const promises = newListings.map((p) => {
const body = JSON.stringify({
text: `${serviceName} ${jobKey}: ${p.title}`,
blocks: buildBlocks(serviceName, jobKey, p),
blocks: buildBlocks(serviceName, jobKey, p, baseUrl),
unfurl_links: false,
unfurl_media: false,
});

View File

@@ -0,0 +1,113 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import nodemailer from 'nodemailer';
import path from 'path';
import fs from 'fs';
import Handlebars from 'handlebars';
import { markdown2Html } from '../../services/markdown.js';
import { getDirName, normalizeImageUrl } from '../../utils.js';
const __dirname = getDirName();
const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTemplate/template.hbs'), 'utf8');
const emailTemplate = Handlebars.compile(template);
const mapListings = (serviceName, jobKey, listings, baseUrl) =>
listings.map((l) => {
const image = normalizeImageUrl(l.image);
return {
title: l.title || '',
link: l.link || '',
address: l.address || '',
size: l.size || '',
price: l.price || '',
image,
hasImage: Boolean(image),
fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null,
serviceName,
jobKey,
};
});
export const send = async ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
const { host, port, secure, username, password, receiver, from } = notificationConfig.find(
(adapter) => adapter.id === config.id,
).fields;
const to = receiver
.trim()
.split(',')
.map((r) => r.trim())
.filter(Boolean);
const transporter = nodemailer.createTransport({
host,
port: Number(port),
secure: secure === 'true',
auth: {
user: username,
pass: password,
},
});
const listings = mapListings(serviceName, jobKey, newListings, baseUrl);
const html = emailTemplate({
serviceName: `Job: (${jobKey}) | Service: ${serviceName}`,
numberOfListings: listings.length,
listings,
});
return transporter.sendMail({
from,
to: to.join(','),
subject: `Fredy found ${listings.length} new listing(s) for ${serviceName}`,
html,
});
};
export const config = {
id: 'smtp',
name: 'SMTP',
description: 'Send notifications via any SMTP server using Nodemailer.',
readme: markdown2Html('lib/notification/adapter/smtp.md'),
fields: {
host: {
type: 'text',
label: 'SMTP Host',
description: 'The hostname of the SMTP server (e.g., smtp.gmail.com).',
},
port: {
type: 'text',
label: 'SMTP Port',
description: 'The port of the SMTP server (e.g., 587 for STARTTLS, 465 for SSL).',
},
secure: {
type: 'text',
label: 'Secure (SSL/TLS)',
description: 'Set to "true" for port 465 (SSL). Leave empty or "false" for STARTTLS on port 587.',
},
username: {
type: 'text',
label: 'Username',
description: 'The username for SMTP authentication.',
},
password: {
type: 'text',
label: 'Password',
description: 'The password (or app password) for SMTP authentication.',
},
receiver: {
type: 'text',
label: 'Receiver Email(s)',
description: 'Comma-separated email addresses Fredy will send notifications to.',
},
from: {
type: 'email',
label: 'Sender Email',
description: 'The email address Fredy sends from.',
},
},
};

View File

@@ -0,0 +1,23 @@
### SMTP Adapter
Send notifications through any SMTP server using [Nodemailer](https://nodemailer.com/).
This works with Gmail, Outlook, self-hosted mail servers, or any provider that supports SMTP.
Setup:
- Provide the SMTP host and port of your mail server.
- For **SSL/TLS** (port 465), set Secure to `true`.
- For **STARTTLS** (port 587), leave Secure empty or set it to `false`.
- Enter the username and password for authentication. For Gmail, use an [App Password](https://support.google.com/accounts/answer/185833).
- Set the sender email address (must be allowed by your SMTP server).
Multiple recipients:
- Separate email addresses with commas (e.g., `some@email.com`, `someOther@email.com`).
Common SMTP settings:
- **Gmail** - `smtp.gmail.com`, port 587, secure: false
- **Outlook** - `smtp.office365.com`, port 587, secure: false
- **Yahoo** - `smtp.mail.yahoo.com`, port 465, secure: true
- **Gmx** - `mail.gmx.net`, port 587, secure: true

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
@@ -80,12 +80,14 @@ function escapeHtml(s = '') {
* @param {string} [o.link]
* @returns {string}
*/
function buildCaption(jobName, serviceName, o) {
function buildCaption(jobName, serviceName, o, baseUrl) {
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
const fredyLink =
baseUrl && o.id ? `\n<a href='${escapeHtml(`${baseUrl}/listings/listing/${o.id}`)}'>Open in Fredy</a>` : '';
return `<i>${escapeHtml(jobName)}</i> (${escapeHtml(serviceName)})\n<a href='${escapeHtml(
o.link || '',
)}'><b>${escapeHtml(title)}</b></a>\n${escapeHtml(meta)}`.slice(0, 1024);
)}'><b>${escapeHtml(title)}</b></a>\n${escapeHtml(meta)}${fredyLink}`.slice(0, 1024);
}
/**
@@ -95,16 +97,47 @@ function buildCaption(jobName, serviceName, o) {
* @param {Object} o - Listing object
* @returns {string}
*/
function buildText(jobName, serviceName, o) {
function buildText(jobName, serviceName, o, baseUrl) {
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
const fredyLink =
baseUrl && o.id ? `\n<a href='${escapeHtml(`${baseUrl}/listings/listing/${o.id}`)}'>Open in Fredy</a>` : '';
return (
`<i>${escapeHtml(jobName)}</i> (${escapeHtml(serviceName)})\n` +
`<a href='${escapeHtml(o.link || '')}'><b>${escapeHtml(title)}</b></a>\n` +
`${escapeHtml(meta)}`
`${escapeHtml(meta)}${fredyLink}`
);
}
/**
* Build a plain text Telegram photo caption (max 4096 characters).
* @param {string} jobName
* @param {string} serviceName
* @param {Object} o - Listing object
* @param baseUrl
* @returns {string}
*/
function buildCaptionPlain(jobName, serviceName, o, baseUrl) {
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
const fredyLine = baseUrl && o.id ? `\nOpen in Fredy: ${baseUrl}/listings/listing/${o.id}` : '';
return `${jobName} (${serviceName})\n${title}\n${meta}\n\n${o.link || ''}${fredyLine}`.slice(0, 4096);
}
/**
* Build a plain text Telegram message.
* @param {string} jobName
* @param {string} serviceName
* @param {Object} o - Listing object
* @returns {string}
*/
function buildTextPlain(jobName, serviceName, o, baseUrl) {
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
const fredyLine = baseUrl && o.id ? `\nOpen in Fredy: ${baseUrl}/listings/listing/${o.id}` : '';
return `${jobName} (${serviceName})\n${title}\n${o.link || ''}\n${meta}${fredyLine}`;
}
/**
* Send new listings to Telegram.
* - Respects per-chat Telegram rate limits using a lightweight throttle cache.
@@ -117,12 +150,12 @@ function buildText(jobName, serviceName, o) {
* @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 }) => {
export const send = ({ serviceName, newListings = [], notificationConfig, jobKey, baseUrl }) => {
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, messageThreadId } = adapterCfg.fields;
const { token, chatId, messageThreadId, plainText } = adapterCfg.fields;
if (!token || !chatId) {
throw new Error("Telegram 'token' and 'chatId' must be provided in notification config");
}
@@ -163,8 +196,8 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
const img = normalizeImageUrl(o.image);
const textPayload = {
chat_id: chatId,
text: buildText(jobName, serviceName, o),
parse_mode: 'HTML',
text: plainText ? buildTextPlain(jobName, serviceName, o, baseUrl) : buildText(jobName, serviceName, o, baseUrl),
...(plainText ? {} : { parse_mode: 'HTML' }),
disable_web_page_preview: true,
...(message_thread_id ? { message_thread_id } : {}),
};
@@ -178,8 +211,10 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
return await throttledCall('sendPhoto', {
chat_id: chatId,
photo: img,
caption: buildCaption(jobName, serviceName, o),
parse_mode: 'HTML',
caption: plainText
? buildCaptionPlain(jobName, serviceName, o, baseUrl)
: buildCaption(jobName, serviceName, o, baseUrl),
...(plainText ? {} : { parse_mode: 'HTML' }),
...(message_thread_id ? { message_thread_id } : {}),
}).catch(async (e) => {
logger.error(`Error sending photo to Telegram and use a fallback: ${e.message}`);
@@ -220,5 +255,11 @@ export const config = {
description:
'Optional: The topic/thread id within a supergroup to post into (Telegram message_thread_id). Provide a positive integer.',
},
plainText: {
type: 'boolean',
optional: true,
label: 'Send as plain text',
description: 'Send messages as plain text instead of HTML formatted.',
},
},
};

View File

@@ -106,6 +106,9 @@
<![endif]-->
<!--[if !mso]><!-- -->
<a href="{{this.link}}" class="btn" target="_blank">View Listing</a>
{{#if this.fredyUrl}}
<a href="{{this.fredyUrl}}" class="btn" style="background:#1a6fff;color:#ffffff;margin-left:8px;" target="_blank">Open in Fredy</a>
{{/if}}
<!--<![endif]-->
</td>
</tr>

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
@@ -20,10 +20,10 @@ if (adapter.length === 0) {
const findAdapter = (notificationAdapter) => {
return adapter.find((a) => a.config.id === notificationAdapter.id);
};
export const send = (serviceName, newListings, notificationConfig, jobKey) => {
export const send = (serviceName, newListings, notificationConfig, jobKey, baseUrl) => {
//this is not being used in tests, therefore adapter are always set
return notificationConfig
.filter((notificationAdapter) => findAdapter(notificationAdapter) != null)
.map((notificationAdapter) => findAdapter(notificationAdapter))
.map((a) => a.send({ serviceName, newListings, notificationConfig, jobKey }));
.map((a) => a.send({ serviceName, newListings, notificationConfig, jobKey, baseUrl }));
};

View File

@@ -1,12 +1,20 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { buildHash, isOneOf } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
import { extractNumber } from '../utils/extract-number.js';
/** @import { ParsedListing } from '../types/listing.js' */
/** @import { ProviderConfig } from '../types/providerConfig.js' */
let appliedBlackList = [];
/**
* @param {any} o
* @returns {ParsedListing}
*/
function normalize(o) {
const baseUrl = 'https://www.1a-immobilienmarkt.de';
const link = `${baseUrl}/expose/${o.id}.html`;
@@ -14,7 +22,17 @@ function normalize(o) {
const id = buildHash(o.id, price);
const image = baseUrl + o.image;
const address = o.address == null ? null : o.address.trim().replaceAll('/', ',');
return Object.assign(o, { id, price, link, image, address });
return {
id,
link,
title: o.title || '',
price: extractNumber(price),
size: extractNumber(o.size),
rooms: extractNumber(o.rooms),
address,
image,
description: undefined,
};
}
/**
@@ -34,13 +52,19 @@ function normalizePrice(price) {
}
return result[0];
}
/**
* @param {ParsedListing} o
* @returns {boolean}
*/
function applyBlacklist(o) {
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
return titleNotBlacklisted && descNotBlacklisted;
}
/** @type {ProviderConfig} */
const config = {
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
url: null,
crawlContainer: '.tabelle',
sortByDateParam: 'sort_type=newest',
@@ -48,7 +72,8 @@ const config = {
crawlFields: {
id: '.inner_object_data input[name="marker_objekt_id"]@value | int',
price: '.inner_object_data .single_data_price | removeNewline | trim',
size: '.tabelle .tabelle_inhalt_infos .single_data_box | removeNewline | trim',
size: '.tabelle .tabelle_inhalt_infos .single_data_box:nth-of-type(1) | removeNewline | trim',
rooms: '.tabelle .tabelle_inhalt_infos .single_data_box:nth-of-type(2) | removeNewline | trim',
title: '.inner_object_data .tabelle_inhalt_titel_black | removeNewline | trim',
image: '.inner_object_pic img@src',
address: '.tabelle .tabelle_inhalt_infos .left_information > div:nth-child(2) | removeNewline | trim',

View File

@@ -1,57 +1,129 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { buildHash, isOneOf } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
import { extractNumber } from '../utils/extract-number.js';
import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js';
import * as cheerio from 'cheerio';
import logger from '../services/logger.js';
/** @import { ParsedListing } from '../types/listing.js' */
/** @import { ProviderConfig } from '../types/providerConfig.js' */
let appliedBlackList = [];
function shortenLink(link) {
return link.substring(0, link.indexOf('?'));
if (!link) return '';
const index = link.indexOf('?');
return index === -1 ? link : link.substring(0, index);
}
function parseId(shortenedLink) {
return shortenedLink.substring(shortenedLink.lastIndexOf('/') + 1);
}
async function fetchDetails(listing, browser) {
try {
const html = await puppeteerExtractor(listing.link, null, { browser, name: 'immobilienDe_details' });
if (!html) return listing;
const $ = cheerio.load(html);
// Try JSON-LD first
let description = null;
let address = listing.address;
$('script[type="application/ld+json"]').each((_, el) => {
if (description) return;
try {
const data = JSON.parse($(el).text());
const nodes = Array.isArray(data) ? data : [data];
for (const node of nodes) {
if (node.description && !description) description = String(node.description).replace(/\s+/g, ' ').trim();
const addr = node.address || node?.mainEntity?.address;
if (addr && addr.streetAddress && address === listing.address) {
const parts = [addr.streetAddress, addr.postalCode, addr.addressLocality].filter(Boolean);
if (parts.length) address = parts.join(' ');
}
}
} catch {
// ignore malformed JSON-LD
}
});
// Fallback: common description selectors used by immobilien.de
if (!description) {
const sel = ['.beschreibung', '.freitext', '.objektbeschreibung', '.description'].find((s) => $(s).length > 0);
if (sel) description = $(sel).text().replace(/\s+/g, ' ').trim();
}
return {
...listing,
address,
description: description || listing.description,
};
} catch (error) {
logger.warn(`Could not fetch immobilien.de detail page for listing '${listing.id}'.`, error?.message || error);
return listing;
}
}
/**
* @param {any} o
* @returns {ParsedListing}
*/
function normalize(o) {
const baseUrl = 'https://www.immobilien.de';
const size = o.size || null;
const price = o.price || null;
const title = o.title || 'No title available';
const title = o.title || '';
const address = o.address || null;
const shortLink = shortenLink(o.link);
const link = `${baseUrl}/${shortLink}`;
const image = baseUrl + o.image;
const link = shortLink ? (shortLink.startsWith('http') ? shortLink : baseUrl + shortLink) : baseUrl;
const image = o.image ? (o.image.startsWith('http') ? o.image : baseUrl + o.image) : null;
const id = buildHash(parseId(shortLink), o.price);
return Object.assign(o, { id, price, size, title, address, link, image });
return {
id,
link,
title,
price: extractNumber(o.price),
size: extractNumber(o.size),
rooms: extractNumber(o.rooms),
address,
image,
description: o.description,
};
}
/**
* @param {ParsedListing} o
* @returns {boolean}
*/
function applyBlacklist(o) {
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
return titleNotBlacklisted && descNotBlacklisted;
}
/** @type {ProviderConfig} */
const config = {
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
url: null,
crawlContainer: '._ref',
crawlContainer: 'a.lr-card',
sortByDateParam: 'sort_col=*created_ts&sort_dir=desc',
waitForSelector: 'body',
waitForSelector: null,
crawlFields: {
id: '@href', //will be transformed later
price: '.list_entry .immo_preis .label_info',
size: '.list_entry .flaeche .label_info | removeNewline | trim',
title: '.list_entry .part_text h3 span',
description: '.list_entry .description | trim',
price: '.lr-card__price-amount | trim',
size: '.lr-card__fact:has(.lr-card__fact-label:contains("Fläche")) .lr-card__fact-value | trim',
rooms: '.zimmer .label_info',
title: '.lr-card__title | trim',
description: '.description | trim',
link: '@href',
address: '.list_entry .place',
image: '.list_entry img@src',
address: '.lr-card__address span | trim',
image: 'img.lr-card__gallery-img@src',
},
normalize: normalize,
normalize,
filter: applyBlacklist,
fetchDetails,
activeTester: checkIfListingIsActive,
};
export const init = (sourceConfig, blacklist) => {

View File

@@ -1,53 +0,0 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { isOneOf, buildHash } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
let appliedBlackList = [];
function normalize(o) {
const size = o.size != null ? o.size.replace('Wohnfläche ', '') : 'N/A m²';
const price = o.price.replace('Kaufpreis ', '');
const address = o.address?.split(' • ')?.pop() ?? null;
const title = o.title || 'No title available';
const link = o.link != null ? decodeURIComponent(o.link) : config.url;
const id = buildHash(title, price);
return Object.assign(o, { id, address, price, 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: 'div[data-testid="serp-core-classified-card-testid"]',
sortByDateParam: 'sortby=19',
waitForSelector: 'div[data-testid="serp-gridcontainer-testid"]',
crawlFields: {
id: 'button@title |trim',
title: 'button@title |trim',
price: 'div[data-testid="cardmfe-price-testid"] | trim',
size: 'div[data-testid="cardmfe-keyfacts-testid"] | trim',
address: 'div[data-testid="cardmfe-description-box-address"] | trim',
image: 'div[data-testid="cardmfe-picture-box-test-id"] img@src',
link: 'button@data-base',
description: 'div[data-testid="cardmfe-description-text-test-id"] | 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: 'Immonet',
baseUrl: 'https://www.immonet.de/',
id: 'immonet',
};
export { config };

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
@@ -8,7 +8,7 @@
*
* The mobile API provides the following endpoints:
* - GET /search/total?{search parameters}: Returns the total number of listings for the given query
* Example: `curl -H "User-Agent: ImmoScout_27.3_26.0_._" https://api.mobile.immobilienscout24.de/search/total?searchType=region&realestatetype=apartmentrent&pricetype=calculatedtotalrent&geocodes=%2Fde%2Fberlin%2Fberlin `
* Example: `curl -H "User-Agent: ImmoScout_27.12_26.2_._" https://api.mobile.immobilienscout24.de/search/total?searchType=region&realestatetype=apartmentrent&pricetype=calculatedtotalrent&geocodes=%2Fde%2Fberlin%2Fberlin `
*
* - POST /search/list?{search parameters}: Actually retrieves the listings. Body is json encoded and contains
* data specifying additional results (advertisements) to return. The format is as follows:
@@ -20,12 +20,12 @@
* ```
* It is not necessary to provide data for the specified keys.
*
* Example: `curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region&geocodes=%2Fde%2Fberlin%2Fberlin&pagenumber=1' -H "Connection: keep-alive" -H "User-Agent: ImmoScout_27.3_26.0_._" -H "Accept: application/json" -H "Content-Type: application/json" -d '{"supportedResultListType": [], "userData": {}}'`
* Example: `curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region&geocodes=%2Fde%2Fberlin%2Fberlin&pagenumber=1' -H "Connection: keep-alive" -H "User-Agent: ImmoScout_27.12_26.2_._" -H "Accept: application/json" -H "Content-Type: application/json" -d '{"supportedResultListType": [], "userData": {}}'`
* - GET /expose/{id} - Returns the details of a listing. The response contains additional details not included in the
* listing response.
*
* Example: `curl -H "User-Agent: ImmoScout_27.3_26.0_._" "https://api.mobile.immobilienscout24.de/expose/158382494"`
* Example: `curl -H "User-Agent: ImmoScout_27.12_26.2_._" "https://api.mobile.immobilienscout24.de/expose/158382494"`
*
*
* It is necessary to set the correct User Agent (see `getListings`) in the request header.
@@ -46,13 +46,17 @@ import {
convertWebToMobile,
} from '../services/immoscout/immoscout-web-translator.js';
import logger from '../services/logger.js';
import { extractNumber } from '../utils/extract-number.js';
/** @import { ParsedListing } from '../types/listing.js' */
/** @import { ProviderConfig } from '../types/providerConfig.js' */
let appliedBlackList = [];
async function getListings(url) {
const response = await fetch(url, {
method: 'POST',
headers: {
'User-Agent': 'ImmoScout_27.3_26.0_._',
'User-Agent': 'ImmoScout_27.12_26.2_._',
'Content-Type': 'application/json',
},
body: JSON.stringify({
@@ -71,13 +75,12 @@ async function getListings(url) {
.map((expose) => {
const item = expose.item;
const [price, size] = item.attributes;
const image = item?.titlePicture?.preview ?? null;
const image = item?.titlePicture?.full ?? item?.titlePicture?.preview ?? null;
return {
id: item.id,
price: price?.value,
size: size?.value,
title: item.title,
description: item.description,
link: `${metaInformation.baseUrl}expose/${item.id}`,
address: item.address?.line,
image,
@@ -85,10 +88,72 @@ async function getListings(url) {
});
}
async function fetchDetails(listing) {
return pushDetails(listing);
}
async function pushDetails(listing) {
const exposeId = listing.link?.split('/').pop();
const detailed = await fetch(`https://api.mobile.immobilienscout24.de/expose/${exposeId}`, {
headers: {
'User-Agent': 'ImmoScout_27.3_26.0_._',
'Content-Type': 'application/json',
},
});
if (!detailed.ok) {
logger.warn(
`Error fetching listing details from ImmoScout Mobile API for id: ${exposeId} Status: ${detailed.statusText}`,
);
return listing;
}
const detailBody = await detailed.json();
listing.description = buildDescription(detailBody);
return listing;
}
function buildDescription(detailBody) {
const sections = detailBody.sections || [];
const contact = detailBody.contact || {};
const cData = contact?.contactData || {};
const agentName = cData?.agent?.name || '';
const agentCompany = cData?.agent?.company || '';
const stars = cData?.agent?.rating?.numberOfStars || '';
const phoneNumbers = contact?.phoneNumbers || [];
const phoneNumbersMapped = phoneNumbers
.map((p) => `${p.label}: ${p.text}`)
.join('\n')
.trim();
const attributes = sections
.filter((s) => s.type === 'ATTRIBUTE_LIST')
.flatMap((s) => s.attributes)
.filter((attr) => attr.label && attr.text)
.map((attr) => `${attr.label} ${attr.text}`)
.join('\n');
const freeText = sections
.filter((s) => s.type === 'TEXT_AREA')
.map((s) => {
return `${s.title}\n${s.text}`;
})
.join('\n\n');
return (
`Agent: ${agentName ? agentName : 'Unbekannt'} ${agentCompany ? `(${agentCompany}) ` : ''}${stars ? `- ${stars} stars` : ''}\n` +
(phoneNumbersMapped ? `Phone Numbers:\n${phoneNumbersMapped}` : '') +
'\n\n' +
attributes.trim() +
'\n\n' +
freeText.trim()
);
}
async function isListingActive(link) {
const result = await fetch(convertImmoscoutListingToMobileListing(link), {
headers: {
'User-Agent': 'ImmoScout_27.3_26.0_._',
'User-Agent': 'ImmoScout_27.12_26.2_._',
},
});
@@ -107,22 +172,44 @@ async function isListingActive(link) {
function nullOrEmpty(val) {
return val == null || val.length === 0;
}
/**
* @param {any} o
* @returns {ParsedListing}
*/
function normalize(o) {
const title = nullOrEmpty(o.title) ? 'NO TITLE FOUND' : o.title.replace('NEU', '');
const title = (o.title || '').replace('NEU', '').trim();
const address = nullOrEmpty(o.address) ? 'NO ADDRESS FOUND' : (o.address || '').replace(/\(.*\),.*$/, '').trim();
const id = buildHash(o.id, o.price);
return Object.assign(o, { id, title, address });
return {
id,
link: o.link,
title,
price: extractNumber(o.price),
size: extractNumber(o.size),
rooms: extractNumber(o.rooms),
address,
image: o.image,
description: o.description,
};
}
/**
* @param {ParsedListing} o
* @returns {boolean}
*/
function applyBlacklist(o) {
return !isOneOf(o.title, appliedBlackList);
}
/** @type {ProviderConfig} */
const config = {
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
url: null,
crawlFields: {
id: 'id',
title: 'title',
price: 'price',
size: 'size',
rooms: 'rooms',
link: 'link',
address: 'address',
},
@@ -131,6 +218,7 @@ const config = {
normalize: normalize,
filter: applyBlacklist,
getListings: getListings,
fetchDetails: fetchDetails,
activeTester: isListingActive,
};
export const init = (sourceConfig, blacklist) => {

View File

@@ -1,31 +1,50 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { isOneOf, buildHash } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
import { extractNumber } from '../utils/extract-number.js';
/** @import { ParsedListing } from '../types/listing.js' */
/** @import { ProviderConfig } from '../types/providerConfig.js' */
let appliedBlackList = [];
/**
* @param {any} o
* @returns {ParsedListing}
*/
function normalize(o) {
const size = o.size || 'N/A m²';
const price = (o.price || '--- €').replace('Preis auf Anfrage', '--- €');
const title = o.title || 'No title available';
const immoId = o.id.substring(o.id.indexOf('-') + 1, o.id.length);
const link = `https://immo.swp.de/immobilien/${immoId}`;
const description = o.description;
const id = buildHash(immoId, price);
return Object.assign(o, { id, price, size, title, link, description });
const id = buildHash(immoId, o.price);
return {
id,
link,
title: o.title || '',
price: extractNumber(o.price),
size: extractNumber(o.size),
rooms: extractNumber(o.rooms),
address: o.address,
image: o.image,
description: undefined,
};
}
/**
* @param {ParsedListing} o
* @returns {boolean}
*/
function applyBlacklist(o) {
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
return titleNotBlacklisted && descNotBlacklisted;
}
/** @type {ProviderConfig} */
const config = {
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
url: null,
crawlContainer: '.js-serp-item',
sortByDateParam: 's=most_recently_updated_first',
@@ -34,9 +53,10 @@ const config = {
id: '.js-bookmark-btn@data-id',
price: 'div.align-items-start div:first-child | trim',
size: 'div.align-items-start div:nth-child(3) | trim',
rooms: 'div.align-items-start div:nth-child(2) | trim',
address: '.js-bookmark-btn@data-address',
title: '.js-item-title-link@title | trim',
link: '.ci-search-result__link@href',
description: '.js-show-more-item-sm | removeNewline | trim',
image: 'img@src',
},
normalize: normalize,

View File

@@ -1,34 +1,110 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { buildHash, isOneOf } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
import { extractNumber } from '../utils/extract-number.js';
import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js';
import * as cheerio from 'cheerio';
import logger from '../services/logger.js';
/** @import { ParsedListing } from '../types/listing.js' */
/** @import { ProviderConfig } from '../types/providerConfig.js' */
let appliedBlackList = [];
function normalize(o) {
const id = buildHash(o.id, o.price);
return Object.assign(o, { id });
async function fetchDetails(listing, browser) {
try {
const html = await puppeteerExtractor(listing.link, null, { browser, name: 'immowelt_details' });
if (!html) return listing;
const $ = cheerio.load(html);
const nextDataRaw = $('#__NEXT_DATA__').text();
if (!nextDataRaw) return listing;
const classified = JSON.parse(nextDataRaw)?.props?.pageProps?.classified;
if (!classified) return listing;
const description = (classified.Texts || [])
.map((t) => [t.Title, t.Content].filter(Boolean).join('\n'))
.filter(Boolean)
.join('\n\n');
const addr = classified.EstateAddress;
let address = listing.address;
if (addr) {
const street = [addr.Street, addr.HouseNumber].filter(Boolean).join(' ');
const cityLine = [addr.ZipCode, addr.District || addr.City].filter(Boolean).join(' ');
const full = [street, cityLine].filter(Boolean).join(', ');
if (full) address = full;
}
return {
...listing,
address,
description: description || listing.description,
};
} catch (error) {
logger.warn(`Could not fetch immowelt detail page for listing '${listing.id}'.`, error?.message || error);
return listing;
}
}
/**
* @param {any} o
* @returns {ParsedListing}
*/
function normalize(o) {
const id = buildHash(o.id, o.price);
return {
id,
link: o.link,
title: o.title || '',
price: extractNumber(o.price),
size: extractNumber(o.size),
rooms: extractNumber(o.rooms),
address: o.address,
image: o.image,
description: o.description,
};
}
/**
* @param {ParsedListing} o
* @returns {boolean}
*/
function applyBlacklist(o) {
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
return titleNotBlacklisted && descNotBlacklisted;
}
/** @type {ProviderConfig} */
const config = {
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
url: null,
crawlContainer:
'div[data-testid="serp-core-scrollablelistview-testid"]:not(div[data-testid="serp-enlargementlist-testid"] div[data-testid="serp-card-testid"]) div[data-testid="serp-core-classified-card-testid"]',
sortByDateParam: 'order=DateDesc',
waitForSelector: 'div[data-testid="serp-gridcontainer-testid"]',
// waitForSelector is null: extract the full page via page.content() so the
// Cheerio crawler can search anywhere in the rendered document.
// preNavigateUrl visits the homepage first to establish a trusted session
// before hitting the search URL; this prevents CDN-level bot challenges that
// fire on cold sessions. waitForNetworkIdle (phase 2) then catches React's
// listing API round-trip that fires well after domcontentloaded.
waitForSelector: null,
puppeteerOptions: {
puppeteerTimeout: 60_000,
preNavigateUrl: 'https://www.immowelt.de/',
waitForNetworkIdle: true,
waitForNetworkIdleTimeout: 60_000,
},
crawlFields: {
id: 'a@href',
price: 'div[data-testid="cardmfe-price-testid"] | removeNewline | trim',
size: 'div[data-testid="cardmfe-keyfacts-testid"] | removeNewline | trim',
size: 'div[data-testid="cardmfe-keyfacts-testid"] div:nth-of-type(3) | removeNewline | trim',
rooms: 'div[data-testid="cardmfe-keyfacts-testid"] div:nth-of-type(1) | removeNewline | trim',
title: 'div[data-testid="cardmfe-description-box-text-test-id"] > div:nth-of-type(2)',
link: 'a@href',
description: 'div[data-testid="cardmfe-description-text-test-id"] > div:nth-of-type(2) | removeNewline | trim',
@@ -37,6 +113,7 @@ const config = {
},
normalize: normalize,
filter: applyBlacklist,
fetchDetails: fetchDetails,
activeTester: checkIfListingIsActive,
};
export const init = (sourceConfig, blacklist) => {

View File

@@ -1,21 +1,181 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { buildHash, isOneOf } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
import { extractNumber } from '../utils/extract-number.js';
/** @import { ParsedListing } from '../types/listing.js' */
/** @import { ProviderConfig } from '../types/providerConfig.js' */
import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js';
import logger from '../services/logger.js';
import * as cheerio from 'cheerio';
let appliedBlackList = [];
let appliedBlacklistedDistricts = [];
function normalize(o) {
const size = o.size || '--- m²';
const id = buildHash(o.id, o.price);
const link = `https://www.kleinanzeigen.de${o.link}`;
return Object.assign(o, { id, size, link });
function toAbsoluteLink(link) {
if (!link) return null;
return link.startsWith('http') ? link : `https://www.kleinanzeigen.de${link}`;
}
function cleanText(value) {
if (value == null) return '';
return String(value)
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function buildAddressFromJsonLd(address) {
if (!address || typeof address !== 'object') return null;
const locality = cleanText(address.addressLocality);
const region = cleanText(address.addressRegion);
const postalCode = cleanText(address.postalCode);
const streetAddress = cleanText(address.streetAddress);
const cityPart = [region, locality].filter(Boolean).join(' - ');
const tail = [postalCode, cityPart || locality || region].filter(Boolean).join(' ');
const fullAddress = [streetAddress, tail].filter(Boolean).join(', ');
return fullAddress || null;
}
function flattenJsonLdNodes(node, acc = []) {
if (node == null) return acc;
if (Array.isArray(node)) {
node.forEach((item) => flattenJsonLdNodes(item, acc));
return acc;
}
if (typeof node !== 'object') return acc;
acc.push(node);
if (Array.isArray(node['@graph'])) {
node['@graph'].forEach((item) => flattenJsonLdNodes(item, acc));
}
if (node.mainEntity) {
flattenJsonLdNodes(node.mainEntity, acc);
}
if (node.itemOffered) {
flattenJsonLdNodes(node.itemOffered, acc);
}
return acc;
}
function extractDetailFromHtml(html) {
const $ = cheerio.load(html);
const nodes = [];
// Prefer the rendered postal address block from the detail page because
// it contains the street line that is missing from list results.
const streetFromDom = cleanText($('#street-address').first().text());
const localityFromDom = cleanText($('#viewad-locality').first().text());
const domAddress = [streetFromDom, localityFromDom].filter(Boolean).join(' ');
$('script[type="application/ld+json"]').each((_, element) => {
const content = $(element).text();
if (!content) return;
try {
const parsed = JSON.parse(content);
flattenJsonLdNodes(parsed, nodes);
} catch {
// Ignore broken JSON-LD blocks from ads/trackers and keep trying others.
}
});
let detailAddress = null;
let detailDescription = null;
if (domAddress) {
detailAddress = domAddress;
}
for (const node of nodes) {
const candidateAddress = buildAddressFromJsonLd(
node.address || node?.itemOffered?.address || node?.offers?.address,
);
if (!detailAddress && candidateAddress) {
detailAddress = candidateAddress;
}
const candidateDescription = cleanText(node.description || node?.itemOffered?.description);
if (!detailDescription && candidateDescription) {
detailDescription = candidateDescription;
}
if (detailAddress && detailDescription) {
break;
}
}
return {
detailAddress,
detailDescription,
};
}
async function enrichListingFromDetails(listing, browser) {
const absoluteLink = toAbsoluteLink(listing.link);
if (!absoluteLink) return listing;
try {
const html = await puppeteerExtractor(absoluteLink, null, { browser, name: 'kleinanzeigen_details' });
if (!html) return { ...listing, link: absoluteLink };
const { detailAddress, detailDescription } = extractDetailFromHtml(html);
return {
...listing,
link: absoluteLink,
address: detailAddress || listing.address,
description: detailDescription || listing.description,
};
} catch (error) {
logger.warn(`Could not fetch Kleinanzeigen detail page for listing '${listing.id}'.`, error?.message || error);
return { ...listing, link: absoluteLink };
}
}
async function fetchDetails(listing, browser) {
return enrichListingFromDetails(listing, browser);
}
/**
* @param {any} o
* @returns {ParsedListing}
*/
function normalize(o) {
const parts = (o.tags || '').split('·').map((p) => p.trim());
const size = parts.find((p) => p.includes('m²'));
const rooms = parts.find((p) => p.includes('Zi.'));
const id = buildHash(o.id, o.price);
return {
id,
title: o.title,
link: toAbsoluteLink(o.link) || o.link,
price: extractNumber(o.price),
size: extractNumber(size),
rooms: extractNumber(rooms),
address: o.address,
description: o.description,
image: o.image,
};
}
/**
* @param {ParsedListing} o
* @returns {boolean}
*/
function applyBlacklist(o) {
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
@@ -24,28 +184,31 @@ function applyBlacklist(o) {
return o.title != null && !isBlacklistedDistrict && titleNotBlacklisted && descNotBlacklisted;
}
/** @type {ProviderConfig} */
const config = {
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
url: null,
crawlContainer: '#srchrslt-adtable .ad-listitem ',
//sort by date is standard oO
sortByDateParam: null,
waitForSelector: 'body',
crawlFields: {
id: '.aditem@data-adid | int',
id: '.aditem@data-adid',
price: '.aditem-main--middle--price-shipping--price | removeNewline | trim',
size: '.aditem-main .text-module-end | removeNewline | trim',
title: '.aditem-main .text-module-begin a | removeNewline | trim',
link: '.aditem-main .text-module-begin a@href | removeNewline | trim',
tags: '.aditem-main--middle--tags | removeNewline | trim',
title: '.aditem-main .text-module-begin | removeNewline | trim',
link: '.aditem@data-href',
description: '.aditem-main .aditem-main--middle--description | removeNewline | trim',
address: '.aditem-main--top--left | trim | removeNewline',
image: 'img@src',
},
fetchDetails,
normalize: normalize,
filter: applyBlacklist,
activeTester: checkIfListingIsActive,
};
export const metaInformation = {
name: 'Ebay Kleinanzeigen',
name: 'Kleinanzeigen',
baseUrl: 'https://www.kleinanzeigen.de/',
id: 'kleinanzeigen',
};

View File

@@ -1,27 +1,50 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { isOneOf, buildHash } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
import { extractNumber } from '../utils/extract-number.js';
/** @import { ParsedListing } from '../types/listing.js' */
/** @import { ProviderConfig } from '../types/providerConfig.js' */
let appliedBlackList = [];
/**
* @param {any} o
* @returns {ParsedListing}
*/
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 link = o.link != null ? `https://www.mcmakler.de${o.link}` : o.link;
const [rooms, size] = o.tags.split(' | ');
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 });
return {
id,
link,
title: o.title || '',
price: extractNumber(o.price),
size: extractNumber(size),
rooms: extractNumber(rooms),
address,
image: o.image,
description: undefined,
};
}
/**
* @param {ParsedListing} o
* @returns {boolean}
*/
function applyBlacklist(o) {
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
return titleNotBlacklisted && descNotBlacklisted;
}
/** @type {ProviderConfig} */
const config = {
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
url: null,
crawlContainer: 'article[data-testid="propertyCard"]',
sortByDateParam: 'sortBy=DATE&sortOn=DESC',
@@ -30,7 +53,7 @@ const config = {
id: 'h2 a@href',
title: 'h2 a | removeNewline | trim',
price: 'footer > p:first-of-type | trim',
size: 'footer > p:nth-of-type(2) | trim',
tags: 'footer > p:nth-of-type(2) | trim',
address: 'div > h2 + p | removeNewline | trim',
image: 'img@src',
link: 'h2 a@href',

View File

@@ -1,10 +1,13 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { isOneOf, buildHash } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
import { extractNumber } from '../utils/extract-number.js';
/** @import { ParsedListing } from '../types/listing.js' */
/** @import { ProviderConfig } from '../types/providerConfig.js' */
let appliedBlackList = [];
@@ -12,19 +15,39 @@ function nullOrEmpty(val) {
return val == null || val.length === 0;
}
/**
* @param {any} o
* @returns {ParsedListing}
*/
function normalize(o) {
const link = nullOrEmpty(o.link)
? 'NO LINK'
: `https://www.neubaukompass.de${o.link.substring(o.link.indexOf('/neubau'))}`;
const id = buildHash(o.link, o.price);
return Object.assign(o, { id, link });
return {
id,
link,
title: o.title || '',
price: extractNumber(o.price),
size: extractNumber(o.size),
rooms: extractNumber(o.rooms),
address: o.address,
image: o.image,
description: o.description,
};
}
/**
* @param {ParsedListing} o
* @returns {boolean}
*/
function applyBlacklist(o) {
return !isOneOf(o.title, appliedBlackList);
}
/** @type {ProviderConfig} */
const config = {
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
url: null,
crawlContainer: '.col-12.mb-4',
sortByDateParam: 'Sortierung=Id&Richtung=DESC',
@@ -34,7 +57,9 @@ const config = {
title: 'a@title | removeNewline | trim',
link: 'a@href',
address: '.nbk-project-card__description | removeNewline | trim',
price: '.nbk-project-card__spec-item .nbk-project-card__spec-value | removeNewline | trim',
price: '.nbk-project-card__spec-item:nth-child(1) .nbk-project-card__spec-value | removeNewline | trim',
size: '.nbk-project-card__spec-item:nth-child(2) .nbk-project-card__spec-value | removeNewline | trim',
rooms: '.nbk-project-card__spec-item:nth-child(3) .nbk-project-card__spec-value | removeNewline | trim',
image: '.nbk-project-card__image@src',
},
normalize: normalize,

View File

@@ -1,23 +1,47 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { isOneOf, buildHash } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
import { extractNumber } from '../utils/extract-number.js';
/** @import { ParsedListing } from '../types/listing.js' */
/** @import { ProviderConfig } from '../types/providerConfig.js' */
let appliedBlackList = [];
/**
* @param {any} o
* @returns {ParsedListing}
*/
function normalize(o) {
const link = metaInformation.baseUrl + o.link;
const id = buildHash(o.title, o.link, o.price);
return Object.assign(o, { link, id });
return {
id,
link,
title: o.title || '',
price: extractNumber(o.price),
size: extractNumber(o.size),
rooms: extractNumber(o.rooms),
address: o.address,
image: o.image,
description: o.description,
};
}
/**
* @param {ParsedListing} o
* @returns {boolean}
*/
function applyBlacklist(o) {
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
return titleNotBlacklisted && descNotBlacklisted;
}
/** @type {ProviderConfig} */
const config = {
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
url: null,
crawlContainer: 'div[data-livecomponent-id*="search/property_list"] .grid > div',
sortByDateParam: null,
@@ -27,6 +51,7 @@ const config = {
title: 'h4 | removeNewline | trim',
price: '.text-xl | trim',
size: 'div[title="Wohnfläche"] | trim',
rooms: 'div[title="Zimmer"] | trim',
address: '.text-slate-800 | removeNewline | trim',
image: 'img@src',
link: 'a@href',
@@ -44,7 +69,7 @@ export const init = (sourceConfig, blacklist) => {
export const metaInformation = {
name: 'OhneMakler',
baseUrl: 'https://www.ohne-makler.net/immobilien',
baseUrl: 'https://www.ohne-makler.net',
id: 'ohneMakler',
};
export { config };

View File

@@ -1,28 +1,51 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { isOneOf, buildHash } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
import { extractNumber } from '../utils/extract-number.js';
/** @import { ParsedListing } from '../types/listing.js' */
/** @import { ProviderConfig } from '../types/providerConfig.js' */
let appliedBlackList = [];
/**
* @param {any} o
* @returns {ParsedListing}
*/
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;
const 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 });
return {
id,
link,
title: o.title || '',
price: extractNumber(o.price),
size: extractNumber(o.size),
rooms: extractNumber(o.rooms),
address,
image,
description: o.description,
};
}
/**
* @param {ParsedListing} o
* @returns {boolean}
*/
function applyBlacklist(o) {
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
return titleNotBlacklisted && descNotBlacklisted;
}
/** @type {ProviderConfig} */
const config = {
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
url: null,
crawlContainer: '.listentry-content',
sortByDateParam: null, // sort by date is standard
@@ -32,6 +55,7 @@ const config = {
title: 'h2 | trim',
price: '.listentry-details-price .listentry-details-v | trim',
size: '.listentry-details-size .listentry-details-v | trim',
rooms: '.listentry-details-rooms .listentry-details-v | trim',
address: '.listentry-adress | trim',
image: '.listentry-img@style',
link: '.shariff@data-url',

View File

@@ -1,42 +1,114 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { isOneOf, buildHash } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js';
import * as cheerio from 'cheerio';
import logger from '../services/logger.js';
import { extractNumber } from '../utils/extract-number.js';
/** @import { ParsedListing } from '../types/listing.js' */
/** @import { ProviderConfig } from '../types/providerConfig.js' */
let appliedBlackList = [];
async function fetchDetails(listing, browser) {
try {
const html = await puppeteerExtractor(listing.link, 'body', { browser, name: 'sparkasse_details' });
const $ = cheerio.load(html);
const nextDataRaw = $('#__NEXT_DATA__').text;
if (!nextDataRaw) return listing;
const estate = JSON.parse(nextDataRaw)?.props?.pageProps?.estate;
if (!estate) return listing;
const description = (estate.frontendItems || [])
.map((item) => {
const texts = (item.contents || [])
.filter((c) => c.type === 'contentBoxes')
.flatMap((c) => c.data || [])
.filter((d) => d.type === 'text' && d.content)
.map((d) => d.content);
if (!texts.length) return null;
return [item.label, ...texts].filter(Boolean).join('\n');
})
.filter(Boolean)
.join('\n\n');
const addr = estate.address;
let address = listing.address;
if (addr) {
const street = [addr.street, addr.streetNumber].filter(Boolean).join(' ');
const cityLine = [addr.zip, addr.city].filter(Boolean).join(' ');
const full = [street, cityLine].filter(Boolean).join(', ');
if (full) address = full;
}
return {
...listing,
address,
description: description || listing.description,
};
} catch (error) {
logger.warn(`Could not fetch Sparkasse detail page for listing '${listing.id}'.`, error?.message || error);
return listing;
}
}
/**
* @param {any} o
* @returns {ParsedListing}
*/
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 });
return {
id,
link,
title: o.title || '',
price: extractNumber(o.price),
size: extractNumber(o.size),
rooms: extractNumber(o.rooms),
address: o.address,
image: o.image,
description: o.description,
};
}
/**
* @param {ParsedListing} o
* @returns {boolean}
*/
function applyBlacklist(o) {
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
return titleNotBlacklisted && descNotBlacklisted;
}
/** @type {ProviderConfig} */
const config = {
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
url: null,
crawlContainer: '.estate-list-item-row',
crawlContainer: 'div[data-testid="estate-link"]',
sortByDateParam: 'sortBy=date_desc',
waitForSelector: 'body',
crawlFields: {
id: 'div[data-testid="estate-link"] a@href',
id: 'a@href',
title: 'h3 | trim',
price: '.estate-list-price | trim',
size: '.estate-mainfact:first-child span | trim',
size: '.estate-mainfact:nth-child(1) span | trim',
rooms: '.estate-mainfact:nth-child(2) span | trim',
address: 'h6 | trim',
image: '.estate-list-item-image-container img@src',
link: 'div[data-testid="estate-link"] a@href',
image: 'img@src',
link: 'a@href',
},
normalize: normalize,
filter: applyBlacklist,
activeTester: checkIfListingIsActive,
fetchDetails,
activeTester: (url) => checkIfListingIsActive(url, 'Angebot nicht gefunden'),
};
export const init = (sourceConfig, blacklist) => {
config.enabled = sourceConfig.enabled;

View File

@@ -1,26 +1,73 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { isOneOf, buildHash } from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
import { extractNumber } from '../utils/extract-number.js';
import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js';
import * as cheerio from 'cheerio';
import logger from '../services/logger.js';
/** @import { ParsedListing } from '../types/listing.js' */
/** @import { ProviderConfig } from '../types/providerConfig.js' */
let appliedBlackList = [];
async function fetchDetails(listing, browser) {
try {
const html = await puppeteerExtractor(listing.link, null, { browser, name: 'wgGesucht_details' });
if (!html) return listing;
const $ = cheerio.load(html);
$('#freitext_0 script').remove();
const description = $('#freitext_0').text().replace(/\s+/g, ' ').trim();
const address = $('a[href="#map_container"] .section_panel_detail').text().replace(/\s+/g, ' ').trim();
return {
...listing,
address: address || listing.address,
description: description || listing.description,
};
} catch (error) {
logger.warn(`Could not fetch wgGesucht detail page for listing '${listing.id}'.`, error?.message || error);
return listing;
}
}
/**
* @param {any} o
* @returns {ParsedListing}
*/
function normalize(o) {
const id = buildHash(o.id, o.price);
const link = `https://www.wg-gesucht.de${o.link}`;
const image = o.image != null ? o.image.replace('small', 'large') : null;
return Object.assign(o, { id, link, image });
const [rooms, city, road] = o.details?.split(' | ') || [];
return {
id,
link,
title: o.title || '',
price: extractNumber(o.price),
size: extractNumber(o.size),
rooms: extractNumber(rooms),
address: `${city}, ${road}`,
image,
description: o.description,
};
}
/**
* @param {ParsedListing} o
* @returns {boolean}
*/
function applyBlacklist(o) {
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
return o.id != null && titleNotBlacklisted && descNotBlacklisted;
}
/** @type {ProviderConfig} */
const config = {
url: null,
crawlContainer: '#main_column .wgg_card',
@@ -31,12 +78,16 @@ const config = {
details: '.row .noprint .col-xs-11 |removeNewline |trim',
price: '.middle .col-xs-3 |removeNewline |trim',
size: '.middle .text-right |removeNewline |trim',
rooms: '.middle .text-right |removeNewline |trim',
title: '.truncate_title a |removeNewline |trim',
link: '.truncate_title a@href',
image: '.img-responsive@src',
description: '.row .noprint .col-xs-11 |removeNewline |trim',
},
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
normalize: normalize,
filter: applyBlacklist,
fetchDetails,
activeTester: checkIfListingIsActive,
};
export const init = (sourceConfig, blacklist) => {

View File

@@ -0,0 +1,77 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import * as utils from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
import { extractNumber } from '../utils/extract-number.js';
/** @import { ParsedListing } from '../types/listing.js' */
/** @import { ProviderConfig } from '../types/providerConfig.js' */
let appliedBlackList = [];
/**
* @param {any} o
* @returns {ParsedListing}
*/
function normalize(o) {
const [city = '', part = ''] = (o.description || '').split('-').map((v) => v.trim());
const address = `${part}, ${city}`;
return {
id: o.link.split('/').pop(),
link: o.link,
title: o.title || '',
price: extractNumber(o.price),
size: extractNumber(o.size),
rooms: extractNumber(o.rooms),
address,
image: o.image,
description: o.description,
};
}
/**
* @param {ParsedListing} o
* @returns {boolean}
*/
function applyBlacklist(o) {
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
return o.id != null && o.title != null && titleNotBlacklisted && descNotBlacklisted && o.link.startsWith(o.link);
}
/** @type {ProviderConfig} */
const config = {
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
url: null,
sortByDateParam: null,
waitForSelector: 'body',
crawlContainer: '.search_result_container > a',
crawlFields: {
id: '*',
title: 'h3 | trim',
price: 'dl:nth-of-type(1) dd | removeNewline | trim',
rooms: 'dl:nth-of-type(2) dd | removeNewline | trim',
size: 'dl:nth-of-type(3) dd | removeNewline | trim',
description: 'div.before\\:icon-location_marker | trim',
link: '@href',
image: 'img@src',
},
normalize: normalize,
filter: applyBlacklist,
activeTester: checkIfListingIsActive,
};
export const init = (sourceConfig, blacklistTerms) => {
config.url = sourceConfig.url;
appliedBlackList = blacklistTerms || [];
};
export const metaInformation = {
name: 'Wohnungsboerse',
baseUrl: 'https://www.wohnungsboerse.net',
id: 'wohnungsboerse',
};
export { config };

View File

@@ -1,29 +0,0 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { removeJobsByUserId } from '../storage/jobStorage.js';
import { getUsers } from '../storage/userStorage.js';
import logger from '../logger.js';
import cron from 'node-cron';
import { getSettings } from '../storage/settingsStorage.js';
/**
* if we are running in demo environment, we have to cleanup the db files (specifically the jobs table)
*/
export function cleanupDemoAtMidnight() {
cron.schedule('0 0 * * *', cleanup);
}
async function cleanup() {
const settings = await getSettings();
if (settings.demoMode) {
const demoUser = getUsers(false).find((user) => user.username === 'demo');
if (demoUser == null) {
logger.error('Demo user not found, cannot remove Jobs');
return Promise.resolve();
}
removeJobsByUserId(demoUser.id);
}
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import cron from 'node-cron';
import { getListingsToGeocode, updateListingGeocoordinates } from '../storage/listingsStorage.js';
import { geocodeAddress, isGeocodingPaused } from '../geocoding/geoCodingService.js';
import { getJobs } from '../storage/jobStorage.js';
import { calculateDistanceForJob } from '../geocoding/distanceService.js';
import { getSettings } from '../storage/settingsStorage.js';
import logger from '../logger.js';
export async function runGeoCordTask() {
const listings = getListingsToGeocode();
if (listings.length > 0) {
for (const listing of listings) {
if (isGeocodingPaused()) {
break;
}
const coords = await geocodeAddress(listing.address);
if (coords) {
updateListingGeocoordinates(listing.id, coords.lat, coords.lng);
}
}
}
//additional run
const jobs = getJobs();
for (const job of jobs) {
calculateDistanceForJob(job.id, job.userId);
}
}
export async function initGeocodingCron() {
const settings = await getSettings();
if (settings.demoMode) {
logger.info('Do not start geo service as we are in demo mode');
return;
}
// run directly on start
await runGeoCordTask();
// then every 6 hours
cron.schedule('0 */6 * * *', runGeoCordTask);
}

View File

@@ -1,16 +1,23 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import cron from 'node-cron';
import runActiveChecker from '../listings/listingActiveService.js';
import logger from '../logger.js';
import { getSettings } from '../storage/settingsStorage.js';
async function runTask() {
await runActiveChecker();
}
export async function initActiveCheckerCron() {
const settings = await getSettings();
if (settings.demoMode) {
logger.info('Do not start listing active checker as we are in demo mode');
return;
}
//run directly on start
await runTask();
// then every day at 1 am

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/

View File

@@ -0,0 +1,147 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { ensureBinary } from 'cloakbrowser';
import fs from 'fs';
import path from 'path';
import os from 'os';
/**
* Resource files required on Linux/Windows — they must live next to the chrome binary.
* macOS packages these inside the .app bundle's Frameworks directory so a different
* check is used there (see isBinaryComplete).
*/
const LINUX_WIN_REQUIRED_FILES = ['icudtl.dat', 'resources.pak'];
/**
* Return the top-level versioned installation directory for any platform.
*
* - Linux/Windows: binaryPath is ~/.cloakbrowser/chromium-X.Y.Z/chrome
* → dirname ~/.cloakbrowser/chromium-X.Y.Z/
* - macOS: binaryPath is ~/.cloakbrowser/chromium-X.Y.Z/Chromium.app/Contents/MacOS/Chromium
* → 4 levels up ~/.cloakbrowser/chromium-X.Y.Z/
*
* @param {string} binaryPath
* @returns {string}
*/
function getVersionedDir(binaryPath) {
if (process.platform === 'darwin') {
return path.resolve(path.dirname(binaryPath), '../../..');
}
return path.dirname(binaryPath);
}
/**
* Return true when the binary at binaryPath belongs to a complete installation.
*
* On macOS the binary lives inside an .app bundle:
* Chromium.app/Contents/MacOS/Chromium
* Resource files (icudtl.dat etc.) are deep inside
* Chromium.app/Contents/Frameworks/…
* so checking for them next to the binary is wrong. Instead we verify the two
* structural markers that are only present after a full extraction: Info.plist
* and the Frameworks directory inside Contents/.
*
* On Linux/Windows the binary and all resource files are siblings in the same
* directory.
*
* @param {string} binaryPath
* @returns {boolean}
*/
function isBinaryComplete(binaryPath) {
if (process.platform === 'darwin') {
const contentsDir = path.resolve(path.dirname(binaryPath), '..');
return fs.existsSync(path.join(contentsDir, 'Info.plist')) && fs.existsSync(path.join(contentsDir, 'Frameworks'));
}
const dir = path.dirname(binaryPath);
return LINUX_WIN_REQUIRED_FILES.every((f) => fs.existsSync(path.join(dir, f)));
}
/**
* Return a human-readable description of which required files/dirs are missing.
*
* @param {string} binaryPath
* @returns {string}
*/
function missingDescription(binaryPath) {
if (process.platform === 'darwin') {
const contentsDir = path.resolve(path.dirname(binaryPath), '..');
return ['Info.plist', 'Frameworks'].filter((f) => !fs.existsSync(path.join(contentsDir, f))).join(', ');
}
const dir = path.dirname(binaryPath);
return LINUX_WIN_REQUIRED_FILES.filter((f) => !fs.existsSync(path.join(dir, f))).join(', ');
}
/**
* Remove a corrupt binary installation and all `latest_version*` markers from
* the CloakBrowser cache so the next `ensureBinary()` call falls back to the
* package-bundled version.
*
* Removes the full versioned directory (e.g. chromium-X.Y.Z/) on all platforms,
* not just the subdirectory that contains the binary.
*
* @param {string} binaryPath - Path to the (corrupt) chrome/Chromium binary.
*/
function removeCorruptInstallation(binaryPath) {
const versionedDir = getVersionedDir(binaryPath);
const cacheDir = process.env.CLOAKBROWSER_CACHE_DIR || path.join(os.homedir(), '.cloakbrowser');
fs.rmSync(versionedDir, { recursive: true, force: true });
try {
for (const entry of fs.readdirSync(cacheDir)) {
if (entry.startsWith('latest_version')) {
fs.rmSync(path.join(cacheDir, entry), { force: true });
}
}
} catch {
// Cache dir may not exist if versionedDir was the only entry — ignore.
}
}
/**
* Ensure the CloakBrowser stealth Chromium binary is present **and** complete.
*
* `cloakbrowser`'s own `ensureBinary()` only checks that the chrome/Chromium
* file exists. An incomplete extraction (e.g. interrupted download, disk full)
* can leave a directory that contains the executable but is missing essential
* resource files. Chrome then crashes immediately on launch.
*
* This wrapper validates the path returned by `ensureBinary()`. If the
* installation is incomplete it removes the corrupt directory, clears the
* version marker files, and calls `ensureBinary()` again so it falls back to
* (or re-downloads) a complete build.
*
* The validated path is also pinned via `CLOAKBROWSER_BINARY_PATH` so that
* CloakBrowser's own internal `ensureBinary()` call inside `launch()` always
* picks up the same, verified binary.
*
* @returns {Promise<string>} Absolute path to the validated binary.
* @throws {Error} When even the fallback binary is incomplete.
*/
export async function ensureValidBinary() {
const binaryPath = await ensureBinary();
if (isBinaryComplete(binaryPath)) {
process.env.CLOAKBROWSER_BINARY_PATH = binaryPath;
return binaryPath;
}
console.warn(
`[fredy] CloakBrowser installation at ${getVersionedDir(binaryPath)} is missing: ${missingDescription(binaryPath)}. Removing and retrying.`,
);
removeCorruptInstallation(binaryPath);
const fallbackPath = await ensureBinary();
if (!isBinaryComplete(fallbackPath)) {
throw new Error(
`CloakBrowser binary at ${getVersionedDir(fallbackPath)} is still missing required files after re-download: ${missingDescription(fallbackPath)}`,
);
}
process.env.CLOAKBROWSER_BINARY_PATH = fallbackPath;
return fallbackPath;
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
@@ -94,12 +94,34 @@ export async function applyBotPreventionToPage(page, cfg) {
// webdriver
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
// chrome runtime
// chrome runtime - expose loadTimes, csi and app like real Chrome
// @ts-ignore
if (!window.chrome) {
window.chrome = {
runtime: {},
// @ts-ignore
window.chrome = { runtime: {} };
}
loadTimes: () => ({
requestTime: performance.timeOrigin / 1000,
startLoadTime: performance.timeOrigin / 1000,
commitLoadTime: performance.timeOrigin / 1000 + 0.1,
finishDocumentLoadTime: 0,
finishLoadTime: 0,
firstPaintTime: 0,
firstPaintAfterLoadTime: 0,
navigationType: 'Other',
wasFetchedViaSpdy: false,
wasNpnNegotiated: false,
npnNegotiatedProtocol: '',
wasAlternateProtocolAvailable: false,
connectionInfo: 'http/1.1',
}),
// @ts-ignore
csi: () => ({ startE: performance.timeOrigin, onloadT: Date.now(), pageT: performance.now(), tran: 15 }),
app: {
isInstalled: false,
InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' },
RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' },
},
};
// languages
// @ts-ignore
@@ -107,23 +129,38 @@ export async function applyBotPreventionToPage(page, cfg) {
get: () => (window.localStorage.getItem('__LANGS__') || 'de-DE,de').split(','),
});
// plugins
// plugins - mimic real Chrome's built-in PDF plugins
const makePlugin = (name, filename, description, mimeType, mimeTypeSuffix) => {
const mimeObj = { type: mimeType, suffixes: mimeTypeSuffix, description, enabledPlugin: null };
const plugin = { name, filename, description, length: 1, 0: mimeObj };
mimeObj.enabledPlugin = plugin;
return plugin;
};
const fakePlugins = [
makePlugin('PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format', 'application/pdf', 'pdf'),
makePlugin('Chrome PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format', 'application/pdf', 'pdf'),
makePlugin('Chromium PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format', 'application/pdf', 'pdf'),
makePlugin(
'Microsoft Edge PDF Viewer',
'internal-pdf-viewer',
'Portable Document Format',
'application/pdf',
'pdf',
),
makePlugin('WebKit built-in PDF', 'internal-pdf-viewer', 'Portable Document Format', 'application/pdf', 'pdf'),
];
// @ts-ignore
Object.defineProperty(navigator, 'plugins', {
get: () => [{}, {}, {}],
});
Object.defineProperty(navigator, 'plugins', { get: () => fakePlugins });
// @ts-ignore
Object.defineProperty(navigator, 'mimeTypes', { get: () => [fakePlugins[0][0]] });
// platform and concurrency hints
// @ts-ignore
Object.defineProperty(navigator, 'platform', { get: () => 'Win32' });
// @ts-ignore
if (typeof navigator.hardwareConcurrency === 'number' && navigator.hardwareConcurrency < 2) {
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 4 });
}
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
// @ts-ignore
if (typeof navigator.deviceMemory === 'number' && navigator.deviceMemory < 2) {
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
}
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
// userAgentData (Client Hints)
try {
@@ -236,6 +273,21 @@ export async function applyBotPreventionToPage(page, cfg) {
} catch {
//noop
}
// document.hasFocus - headless returns false; real active tabs return true
try {
document.hasFocus = () => true;
} catch {
//noop
}
// screen color depth - normalise in case headless reports 0
try {
Object.defineProperty(screen, 'colorDepth', { get: () => 24 });
Object.defineProperty(screen, 'pixelDepth', { get: () => 24 });
} catch {
//noop
}
} catch {
//noop
}
@@ -273,6 +325,8 @@ export async function applyPostNavigationHumanSignals(page, cfg) {
const my = Math.floor(vh * (0.3 + Math.random() * 0.4));
await page.mouse.move(mx, my, { steps: 10 + Math.floor(Math.random() * 10) });
await page.mouse.wheel({ deltaY: 100 + Math.floor(Math.random() * 200) });
await new Promise((res) => setTimeout(res, 150 + Math.floor(Math.random() * 200)));
await page.mouse.wheel({ deltaY: -(30 + Math.floor(Math.random() * 60)) });
} catch {
// ignore if mouse is unavailable
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
@@ -29,11 +29,12 @@ export default class Extractor {
* your response will never contain what you are really looking for
* @param url
* @param waitForSelector
* @param jobKey
*/
execute = async (url, waitForSelector = null) => {
execute = async (url, waitForSelector = null, jobKey = null) => {
this.responseText = null;
try {
this.responseText = await puppeteerExtractor(url, waitForSelector, this.options);
this.responseText = await puppeteerExtractor(url, waitForSelector, { ...this.options, name: jobKey });
if (this.responseText != null) {
loadParser(this.responseText);
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/

View File

@@ -1,89 +1,135 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import puppeteer from 'puppeteer-extra';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
import { debug, botDetected } from './utils.js';
import {
getPreLaunchConfig,
applyBotPreventionToPage,
applyLanguagePersistence,
applyPostNavigationHumanSignals,
} from './botPrevention.js';
import { launch } from 'cloakbrowser/puppeteer';
import { botDetected, debug } from './utils.js';
import { getPreLaunchConfig } from './botPrevention.js';
import logger from '../logger.js';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { trackPoi } from '../tracking/Tracker.js';
import { TRACKING_POIS } from '../../TRACKING_POIS.js';
puppeteer.use(StealthPlugin());
/**
* Launch a CloakBrowser/Puppeteer browser instance with stealth and humanizer enabled.
*
* CloakBrowser applies 49 C++ source-level patches (canvas, WebGL, audio, WebRTC,
* navigator.*, automation signals) that are indistinguishable from a real browser.
* All fingerprinting and human-behaviour simulation is handled natively; no CDP
* overrides (setUserAgent, setExtraHTTPHeaders, evaluateOnNewDocument) are applied
* here because they would create detectable inconsistencies on top of the C++ patches.
*
* @param {string} url - Initial URL (used to derive locale/timezone hints).
* @param {object} [options]
* @param {boolean} [options.puppeteerHeadless]
* @param {number} [options.puppeteerTimeout]
* @param {string} [options.proxyUrl]
* @param {string} [options.timezone]
* @param {string} [options.acceptLanguage]
* @param {object} [options.viewport]
* @returns {Promise<import('puppeteer-core').Browser>}
*/
export async function launchBrowser(url, options) {
const preCfg = getPreLaunchConfig(url, options || {});
export default async function execute(url, waitForSelector, options) {
let browser;
let page;
let result = null;
let userDataDir;
let removeUserDataDir = false;
// Docker requires --no-sandbox; CloakBrowser handles all stealth args internally.
// --ignore-certificate-errors is needed because CloakBrowser ships its own Chromium
// binary with an independent CA bundle that may not trust proxies or interceptors
// present in the host environment.
const args = [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--no-first-run',
'--no-default-browser-check',
'--ignore-certificate-errors',
// Disables the zygote process model. Required in some container environments
// (e.g. limited kernel namespaces) where the zygote cannot acquire the
// locks it needs and exits with "Invalid file descriptor to ICU data received".
'--no-zygote',
preCfg.windowSizeArg,
];
return await launch({
headless: options?.puppeteerHeadless ?? true,
humanize: true,
args,
// locale sets Accept-Language headers and JS navigator.language consistently
locale: preCfg.langForFlag,
...(options?.proxyUrl ? { proxy: options.proxyUrl } : {}),
...(preCfg.timezone ? { timezone: preCfg.timezone } : {}),
});
}
/**
* Close a browser instance returned by {@link launchBrowser}.
*
* @param {import('puppeteer-core').Browser | null} browser
*/
export async function closeBrowser(browser) {
if (!browser) return;
try {
debug(`Sending request to ${url} using Puppeteer.`);
await browser.close();
} catch {
// ignore
}
}
// 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;
/**
* Open a page in a (possibly reused) browser, navigate to `url`, and return the HTML source.
* Returns `null` when a bot-detection page is encountered or on timeout.
*
* @param {string} url
* @param {string | null} waitForSelector
* @param {object} [options]
* @returns {Promise<string | null>}
*/
export default async function execute(url, waitForSelector, options) {
let browser = options?.browser;
let isExternalBrowser = !!browser;
let page;
let result;
try {
debug(`Sending request to ${url} using CloakBrowser.`);
if (!isExternalBrowser) {
browser = await launchBrowser(url, options);
}
const launchArgs = [
'--no-sandbox',
'--disable-gpu',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-crash-reporter',
'--no-first-run',
'--no-default-browser-check',
];
if (options?.proxyUrl) {
launchArgs.push(`--proxy-server=${options.proxyUrl}`);
}
// Prepare bot prevention pre-launch config
const preCfg = getPreLaunchConfig(url, options || {});
launchArgs.push(preCfg.langArg);
launchArgs.push(preCfg.windowSizeArg);
launchArgs.push(...preCfg.extraArgs);
browser = await puppeteer.launch({
headless: options?.puppeteerHeadless ?? true,
args: launchArgs,
timeout: options?.puppeteerTimeout || 30_000,
userDataDir,
executablePath: options?.executablePath, // allow using system Chrome
});
page = await browser.newPage();
await applyBotPreventionToPage(page, preCfg);
// Provide languages value before navigation
await applyLanguagePersistence(page, preCfg);
// Optional cookies
if (Array.isArray(options?.cookies) && options.cookies.length > 0) {
await page.setCookie(...options.cookies);
}
// Navigation
// Warm-up navigation: visit a trusted page first so the site sees an
// established session before the actual target URL. Silently ignored on
// failure so it never blocks the main request.
if (options?.preNavigateUrl) {
try {
await page.goto(options.preNavigateUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 });
await new Promise((r) => setTimeout(r, 1500 + Math.random() * 2000));
} catch {
// ignore
}
}
const response = await page.goto(url, {
waitUntil: options?.waitUntil || 'domcontentloaded',
timeout: options?.puppeteerTimeout || 60000,
});
// Optionally wait and add subtle human-like interactions
await applyPostNavigationHumanSignals(page, preCfg);
// Optional second idle wait: useful for React SPAs that trigger API calls
// after domcontentloaded. Times out silently so we use whatever is rendered.
if (options?.waitForNetworkIdle) {
try {
await page.waitForNetworkIdle({ timeout: options?.waitForNetworkIdleTimeout ?? 60_000 });
} catch {
// ignore — we proceed with whatever the DOM contains at this point
}
}
let pageSource;
// if we're extracting data from a SPA, we must wait for the selector
if (waitForSelector != null) {
const selectorTimeout = options?.puppeteerSelectorTimeout ?? options?.puppeteerTimeout ?? 30_000;
await page.waitForSelector(waitForSelector, { timeout: selectorTimeout });
@@ -99,12 +145,23 @@ export default async function execute(url, waitForSelector, options) {
if (botDetected(pageSource, statusCode)) {
logger.warn('We have been detected as a bot :-/ Tried url: => ', url);
if (options != null && options.name != null) {
await trackPoi(TRACKING_POIS.DETECTED_AS_BOT + '_' + options.name);
} else {
await trackPoi(TRACKING_POIS.DETECTED_AS_BOT);
}
result = null;
} else {
result = pageSource || (await page.content());
}
} catch (error) {
logger.warn('Error executing with puppeteer executor', error);
if (error?.name?.includes('Timeout')) {
logger.debug('Error executing with CloakBrowser executor', error);
} else {
logger.warn('Error executing with CloakBrowser executor', error);
}
result = null;
} finally {
try {
@@ -114,19 +171,8 @@ export default async function execute(url, waitForSelector, options) {
} 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
if (browser != null && !isExternalBrowser) {
await closeBrowser(browser);
}
}
return result;

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { autocomplete as nominatimAutocomplete } from './client/nominatimClient.js';
import logger from '../logger.js';
/**
* Autocompletes an address using Nominatim.
*
* @param {string} query - The search query.
* @returns {Promise<string[]>} List of matching addresses.
*/
export async function autocompleteAddress(query) {
if (!query) {
return [];
}
try {
return await nominatimAutocomplete(query);
} catch (error) {
logger.error('Error during address autocomplete:', error);
return [];
}
}

View File

@@ -0,0 +1,153 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import os from 'os';
import crypto from 'crypto';
import https from 'https';
import fetch from 'node-fetch';
import pThrottle from 'p-throttle';
import logger from '../../logger.js';
const API_URL = 'https://nominatim.openstreetmap.org/search';
const agent = new https.Agent({
keepAlive: true,
keepAliveMsecs: 1000,
});
const throttle = pThrottle({
limit: 1,
interval: 1000,
});
function computeMachineId() {
const hostname = os.hostname() || 'unknown-host';
const nets = os.networkInterfaces?.() || {};
const macs = [];
for (const ifname of Object.keys(nets)) {
for (const addr of nets[ifname] || []) {
if (!addr) continue;
if (addr.internal) continue;
if (addr.mac && addr.mac !== '00:00:00:00:00:00') macs.push(addr.mac);
}
}
macs.sort();
const raw = [hostname, os.platform(), os.arch(), ...macs].join('|');
return crypto.createHash('sha256').update(raw).digest('hex').slice(0, 20);
}
/**
* Nominatim requires a specific User-Agent.
* Since Fredy is self-hosted, we use a unique machine ID to make it specific.
*/
const userAgent = `Fredy-Self-Hosted (${computeMachineId()}; https://github.com/orangecoding/fredy)`;
let last429 = 0;
const PAUSE_DURATION = 3600000; // 1 hour
/**
* Geocodes an address using Nominatim.
*
* @param {string} address - The address to geocode.
* @returns {Promise<{lat: number, lng: number}|null>} The geocoordinates or null if error. {lat: -1, lng: -1} if not found.
*/
async function doGeocode(address) {
if (Date.now() - last429 < PAUSE_DURATION) {
return null;
}
const url = `${API_URL}?q=${encodeURIComponent(address)}&format=json&countrycodes=de`;
try {
const response = await fetch(url, {
agent,
timeout: 60000,
headers: {
'User-Agent': userAgent,
},
});
if (response.status === 429) {
logger.warn('Nominatim rate limit hit. Pausing for 1 hour.');
last429 = Date.now();
return null;
}
if (!response.ok) {
logger.error(`Nominatim API error: ${response.status} ${response.statusText}`);
return null;
}
const data = await response.json();
if (Array.isArray(data) && data.length > 0) {
const result = data[0];
return {
lat: parseFloat(result.lat),
lng: parseFloat(result.lon),
};
}
return { lat: -1, lng: -1 };
} catch (error) {
logger.error('Error during Nominatim geocoding:', error);
return null;
}
}
/**
* Autocompletes an address using Nominatim.
*
* @param {string} query - The search query.
* @returns {Promise<string[]>} List of matching addresses.
*/
async function doAutocomplete(query) {
if (Date.now() - last429 < PAUSE_DURATION) {
return [];
}
const url = `${API_URL}?q=${encodeURIComponent(query)}&format=json&addressdetails=1&limit=5&countrycodes=de`;
try {
const response = await fetch(url, {
agent,
headers: {
'User-Agent': userAgent,
},
});
if (response.status === 429) {
logger.warn('Nominatim rate limit hit. Pausing for 1 hour.');
last429 = Date.now();
return [];
}
if (!response.ok) {
logger.error(`Nominatim API error: ${response.status} ${response.statusText}`);
return [];
}
const data = await response.json();
if (Array.isArray(data)) {
return data.map((item) => item.display_name);
}
return [];
} catch (error) {
logger.error('Error during Nominatim autocomplete:', error);
return [];
}
}
export const geocode = throttle(doGeocode);
export const autocomplete = throttle(doAutocomplete);
export const isPaused = () => Date.now() - last429 < PAUSE_DURATION;

View File

@@ -0,0 +1,61 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { distanceMeters } from '../listings/distanceCalculator.js';
import {
getListingsToCalculateDistance,
getListingsForUserToCalculateDistance,
updateListingDistance,
} from '../storage/listingsStorage.js';
import { getUserSettings } from '../storage/settingsStorage.js';
/**
* Calculates and updates distances for listings of a specific job.
* Only processes listings where distance_to_destination is null.
*
* @param {string} jobId
* @param {string} userId
* @returns {void}
*/
export function calculateDistanceForJob(jobId, userId) {
const userSettings = getUserSettings(userId);
const homeAddress = userSettings.home_address;
if (!homeAddress || !homeAddress.coords) {
return;
}
const listings = getListingsToCalculateDistance(jobId);
const { lat, lng } = homeAddress.coords;
for (const listing of listings) {
const dist = distanceMeters(lat, lng, listing.latitude, listing.longitude);
updateListingDistance(listing.id, dist);
}
}
/**
* Calculates and updates distances for all active listings of a user.
* Usually called when the user updates their home address.
*
* @param {string} userId
* @returns {void}
*/
export function calculateDistanceForUser(userId) {
const userSettings = getUserSettings(userId);
const homeAddress = userSettings.home_address;
if (!homeAddress || !homeAddress.coords) {
return;
}
const listings = getListingsForUserToCalculateDistance(userId);
const { lat, lng } = homeAddress.coords;
for (const listing of listings) {
const dist = distanceMeters(lat, lng, listing.latitude, listing.longitude);
updateListingDistance(listing.id, dist);
}
}

View File

@@ -0,0 +1,43 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { getGeocoordinatesByAddress } from '../storage/listingsStorage.js';
import { geocode as nominatimGeocode, isPaused as isNominatimPaused } from './client/nominatimClient.js';
import logger from '../logger.js';
/**
* Geocodes an address using Nominatim or cached results from the database.
*
* @param {string} address - The address to geocode.
* @returns {Promise<{lat: number, lng: number}|null>} The geocoordinates or null if error. {lat: -1, lng: -1} if not found.
*/
export async function geocodeAddress(address) {
if (!address) {
return null;
}
try {
// 1. Check if we already have this address geocoded in our database
const cachedCoordinates = getGeocoordinatesByAddress(address);
if (cachedCoordinates) {
logger.debug(`Found cached geocoordinates for address: ${address}`);
return cachedCoordinates;
}
// 2. If not, use Nominatim
return await nominatimGeocode(address);
} catch (error) {
logger.error('Error during geocoding:', error);
return null;
}
}
/**
* Checks if we are currently in a rate limit pause.
* @returns {boolean}
*/
export function isGeocodingPaused() {
return isNominatimPaused();
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
@@ -79,6 +79,8 @@ const PARAM_NAME_MAP = {
price: 'price',
constructionyear: 'constructionyear',
apartmenttypes: 'apartmenttypes',
buildingtypes: 'buildingtypes',
ground: 'ground',
pricetype: 'pricetype',
floor: 'floor',
geocodes: 'geocodes',
@@ -86,6 +88,7 @@ const PARAM_NAME_MAP = {
shape: 'shape',
sorting: 'sorting',
newbuilding: 'newbuilding',
fulltext: 'fulltext',
};
const EQUIPMENT_MAP = {
@@ -97,19 +100,28 @@ const EQUIPMENT_MAP = {
guesttoilet: 'guestToilet',
balcony: 'balcony',
handicappedaccessible: 'handicappedAccessible',
lodgerflat: 'lodgerflat',
};
const REAL_ESTATE_TYPE = {
'haus-mieten': 'houserent',
'wohnung-mieten': 'apartmentrent',
'wohnung-kaufen': 'apartmentbuy',
'wohnung-kaufen-mit-balkon': 'apartmentbuy',
'eigentumswohnung-mit-garten': 'apartmentbuy',
'haus-kaufen': 'housebuy',
'haus-mit-keller-kaufen': 'housebuy',
'luxushaus-kaufen': 'housebuy',
'villa-kaufen': 'housebuy',
'neubauhaus-kaufen': 'housebuy',
};
const WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP = {
// Category "Balkon/Terrasse"
'wohnung-mit-balkon-mieten': { equipment: ['balcony'] },
'wohnung-kaufen-mit-balkon': { equipment: ['balcony'] },
'wohnung-mit-garten-mieten': { equipment: ['garden'] },
'eigentumswohnung-mit-garten': { equipment: ['garden'] },
// Category "Wohnungstyp"
'souterrainwohnung-mieten': { apartmenttypes: ['halfbasement'] },
'erdgeschosswohnung-mieten': { apartmenttypes: ['groundfloor'] },
@@ -144,7 +156,7 @@ export function convertWebToMobile(webUrl) {
const realTypeKey = segments.at(-1);
let realType = REAL_ESTATE_TYPE[realTypeKey];
let additionalParamsFromWebPath;
let additionalParamsFromWebPath = WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP[realTypeKey] || null;
if (!realType) {
// Test for seo optimized apartment path (only used on the ImmoScout web app)
@@ -156,29 +168,38 @@ export function convertWebToMobile(webUrl) {
}
}
if (segments.includes('shape')) {
throw new Error('Shape is currently not supported using Immoscout');
}
const { query: rawParams } = queryString.parseUrl(webUrl, { arrayFormat: 'comma' });
const webParams = Object.fromEntries(
Object.entries(rawParams).filter(([key]) => key !== 'enteredFrom' && PARAM_NAME_MAP[key]),
);
const geocodes = `/${segments.slice(2, 5).join('/')}`;
const geocodes = `/${segments.slice(2, segments.length - 1).join('/')}`;
const isRadius = segments.includes('radius');
const isShape = segments.includes('shape');
const mobileParams = {
searchType: isRadius ? 'radius' : 'region',
searchType: isRadius ? 'radius' : isShape ? 'shape' : 'region',
realestatetype: realType,
...(isRadius ? {} : { geocodes }),
...(isRadius || isShape ? {} : { geocodes }),
...additionalParamsFromWebPath,
};
if (isShape && !webParams.shape) {
throw new Error('Shape search URL is missing the required "shape" query parameter');
}
if (isShape && webParams.shape) {
const browserShape = webParams.shape;
const normalized = browserShape.replace(/\.\./g, '==').replace(/\./g, '=');
const polyline = Buffer.from(normalized, 'base64').toString('utf-8');
mobileParams.shape = polyline;
}
if (webParams.geocoordinates) {
mobileParams.geocoordinates = webParams.geocoordinates;
}
for (const [key, val] of Object.entries(webParams)) {
if (key === 'shape') continue;
if (key === 'equipment') {
const items = [].concat(val).flatMap((v) => `${v}`.split(','));
const currentEquipmentParams = mobileParams[PARAM_NAME_MAP[key]];

View File

@@ -0,0 +1,207 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import logger from '../logger.js';
import { bus } from '../events/event-bus.js';
import * as jobStorage from '../storage/jobStorage.js';
import * as userStorage from '../storage/userStorage.js';
import { getUser } from '../storage/userStorage.js';
import { duringWorkingHoursOrNotSet } from '../../utils.js';
import FredyPipelineExecutioner from '../../FredyPipelineExecutioner.js';
import * as similarityCache from '../similarity-check/similarityCache.js';
import { isRunning, markFinished, markRunning } from './run-state.js';
import { sendToUsers } from '../sse/sse-broker.js';
import * as puppeteerExtractor from '../extractor/puppeteerExtractor.js';
import { getSettings } from '../storage/settingsStorage.js';
/**
* Initializes the job execution service.
* - Registers event-bus listeners for `jobs:runAll`, `jobs:runOne`, and `jobs:status`.
* - Starts the periodic scheduler (if `intervalMs` > 0) and performs an initial run respecting working hours.
* - Forwards job status updates to affected users via Server-Sent Events (SSE).
*
* This function is intentionally side-effectful and exposes no external API.
*
* @param {Object} deps - Dependencies required to initialize the service.
* @param {Array<Object>} deps.providers - Loaded provider modules. Each module must expose `metaInformation.id`, `config`, and `init(config, blacklist)`.
* @param {Object} deps.settings - Global settings object (read/write). Must include `demoMode`, `interval`, and working-hours attributes used by `duringWorkingHoursOrNotSet`.
* @param {number} deps.intervalMs - Scheduler interval in milliseconds. If not finite or <= 0, the scheduler is not started.
* @returns {void}
*/
export function initJobExecutionService({ providers, settings, intervalMs }) {
// Forward job status via SSE to relevant recipients
bus.on('jobs:status', ({ jobId, running }) => {
try {
const recipients = resolveRecipients(jobId);
if (recipients.length > 0) {
sendToUsers(recipients, 'jobStatus', { jobId, running });
}
} catch (err) {
logger.warn('Failed to forward job status', jobId, err);
}
});
// Listen for "run all" requests (admin = all, user = own)
bus.on('jobs:runAll', (payload) => {
const userId = payload?.userId ?? null;
const user = userId ? getUser(userId) : null;
const isAdmin = !!user?.isAdmin;
if (isAdmin) {
logger.debug('Running all jobs manually (admin request)');
} else if (userId) {
logger.debug(`Running all jobs manually for user ${userId}`);
} else {
logger.debug('Running all jobs manually (no user provided)');
}
runAll(false, { userId, isAdmin });
});
// Listen for single job run requests
bus.on('jobs:runOne', ({ jobId }) => {
logger.debug(`Running single job manually: ${jobId}`);
// fire and forget, do not block the bus
runSingle(jobId);
});
// Start scheduler and initial run
if (Number.isFinite(intervalMs) && intervalMs > 0) {
setInterval(() => runAll(true), intervalMs);
}
// start once at startup, respecting working hours
runAll(true);
/**
* Resolve all recipients who should receive SSE updates for a job.
* Includes job owner, users with whom the job is shared, and all admins.
*
* @param {string} jobId
* @returns {string[]} unique userIds
*/
function resolveRecipients(jobId) {
const job = jobStorage.getJob(jobId);
if (!job) return [];
const admins = (userStorage.getUsers && userStorage.getUsers(false)) || [];
const adminIds = admins.filter((u) => u.isAdmin).map((u) => u.id);
const shared = Array.isArray(job.shared_with_user) ? job.shared_with_user : [];
const recipients = [job.userId, ...shared, ...adminIds].filter(Boolean);
return Array.from(new Set(recipients));
}
/**
* Execute all enabled jobs, optionally filtering by context (admin/owner) and respecting working hours.
*
* @param {boolean} [respectWorkingHours=true] - If true, skip execution when outside configured working hours.
* @param {{userId?: string, isAdmin?: boolean}} [context] - Who requested the run; determines job filtering.
* @returns {void}
*/
async function runAll(respectWorkingHours = true, context = undefined) {
if (settings.demoMode) return;
const now = Date.now();
const withinHours = duringWorkingHoursOrNotSet(settings, now);
if (respectWorkingHours && !withinHours) {
logger.debug('Working hours set. Skipping as outside of working hours.');
return;
}
settings.lastRun = now;
const jobs = jobStorage
.getJobs()
.filter((job) => job.enabled)
.filter((job) => {
if (!context) return true; // startup/cron → all
if (context.isAdmin) return true; // admin → all
return context.userId ? job.userId === context.userId : false; // user → own
});
for (const job of jobs) {
await executeJob(job);
}
}
/**
* Execute a single job by id.
* Manual runs are allowed even if the job is disabled, but never duplicated when already running.
*
* @param {string} jobId
* @returns {Promise<void>}
*/
async function runSingle(jobId) {
if (settings.demoMode) return;
const job = jobStorage.getJob(jobId);
if (!job) return;
// allow manual run even if disabled; keep guard to avoid duplicates
await executeJob(job);
}
/**
* Executes one job across all of its configured providers.
* Emits SSE start/finish events via the bus and ensures the run-state guard is always cleared.
* Provider errors are surfaced via logging but do not abort other providers.
*
* @param {Object} job
* @param {string} job.id
* @param {Array<{id:string}>} job.provider
* @param {Array<string>} [job.blacklist]
* @param {*} job.notificationAdapter
* @returns {Promise<void>}
*/
async function executeJob(job) {
if (isRunning(job.id)) {
logger.debug(`Job ${job.id} is already running. Skipping.`);
return;
}
const acquired = markRunning(job.id);
if (!acquired) return;
// notify listeners (SSE) that the job started
try {
bus.emit('jobs:status', { jobId: job.id, running: true });
} catch (err) {
logger.warn('Failed to emit start status for job', job.id, err);
}
let browser;
try {
// Read the proxy live (not from the startup snapshot) so changing it in the
// UI takes effect on the next run without a backend restart. An empty value
// disables the proxy. Routing the headless browser through a (German
// residential) proxy avoids datacenter-IP based bot detection on the
// Puppeteer-based providers (immowelt, immonet, kleinanzeigen, ...).
const liveSettings = await getSettings();
const proxyUrl = typeof liveSettings?.proxyUrl === 'string' ? liveSettings.proxyUrl.trim() : '';
const jobProviders = job.provider.filter(
(p) => providers.find((loaded) => loaded.metaInformation.id === p.id) != null,
);
for (const prov of jobProviders) {
try {
const matchedProvider = providers.find((loaded) => loaded.metaInformation.id === prov.id);
matchedProvider.init({ ...prov, userId: job.userId }, job.blacklist);
if (browser && !browser.connected) {
logger.debug('Browser is disconnected, nullifying to launch a new one.');
await puppeteerExtractor.closeBrowser(browser);
browser = null;
}
if (!browser && matchedProvider.config.getListings == null) {
browser = await puppeteerExtractor.launchBrowser(matchedProvider.config.url, proxyUrl ? { proxyUrl } : {});
}
await new FredyPipelineExecutioner(matchedProvider.config, job, prov.id, similarityCache, browser).execute();
} catch (err) {
logger.error(err);
}
}
} finally {
if (browser) {
await puppeteerExtractor.closeBrowser(browser);
}
markFinished(job.id);
try {
bus.emit('jobs:status', { jobId: job.id, running: false });
} catch (err) {
logger.warn('Failed to emit finish status for job', job.id, err);
}
}
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
/**
* Simple in-memory running state registry for jobs.
* Prevents concurrent execution of the same job within a single process.
* This registry is reset on process restart.
* @type {Set<string>}
*/
const running = new Set();
/**
* Check if a job is currently marked as running.
* @param {string} jobId
* @returns {boolean}
*/
export function isRunning(jobId) {
return running.has(jobId);
}
/**
* Try to mark a job as running.
* If it was already running, returns false and does not modify the set.
* @param {string} jobId
* @returns {boolean} true if the job was successfully marked as running
*/
export function markRunning(jobId) {
if (running.has(jobId)) return false;
running.add(jobId);
return true;
}
/**
* Mark a job as finished (remove from the running registry).
* @param {string} jobId
* @returns {void}
*/
export function markFinished(jobId) {
running.delete(jobId);
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
const R = 6371000; // Earth radius in meters
/**
* Calculate the great-circle distance between two points on Earth using the Haversine formula.
* This is to calculate the distance between the listing address & the address provided by the user. I know, it is only
* a rough estimation as this calculates the distance as a straight line, but it's more convenient than using an external
* service and still gives a good approximation for sorting purposes.
* Returns distance in meters.
*
* @param {number} lat1
* @param {number} lon1
* @param {number} lat2
* @param {number} lon2
* @returns {number}
*/
export function distanceMeters(lat1, lon1, lat2, lon2) {
const toRad = (deg) => (deg * Math.PI) / 180;
const phi1 = toRad(lat1);
const phi2 = toRad(lat2);
const dPhi = toRad(lat2 - lat1);
const dLambda = toRad(lon2 - lon1);
const a =
Math.sin(dPhi / 2) * Math.sin(dPhi / 2) +
Math.cos(phi1) * Math.cos(phi2) * Math.sin(dLambda / 2) * Math.sin(dLambda / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return Math.round(R * c * 10) / 10;
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/

Some files were not shown because too many files have changed in this diff Show More