Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
130aa4c
Add Homegate.ch provider with Swiss support
domisko Jun 17, 2026
475e960
trigger CI
domisko Jun 17, 2026
1698f24
Add DeepL translation for listing descriptions
domisko Jun 17, 2026
164f204
Merge pull request #1 from domisko/feature/description-translation
domisko Jun 18, 2026
9d76034
Add commute times feature via OpenRouteService
domisko Jun 18, 2026
17582a9
Merge pull request #2 from domisko/feature/commute-times
domisko Jun 18, 2026
bb2a3ac
Fix news modal not dismissing / reappearing on reload
domisko Jun 18, 2026
38d280d
Merge branch 'orangecoding:master' into master
domisko Jun 18, 2026
56b7b7f
Merge remote-tracking branch 'origin/master' into fix/news-modal-dismiss
domisko Jun 18, 2026
fff48b5
Replace ORS with Transitous for commute times
domisko Jun 18, 2026
cc2ea8b
Show all commute modes even when unavailable (grey tag with dash)
domisko Jun 18, 2026
526294e
Add walk estimate fallback, localStorage cache, fix User-Agent
domisko Jun 18, 2026
811324e
Add approx. label with tooltip to commute times section
domisko Jun 18, 2026
8d43b5e
Merge pull request #3 from domisko/fix/news-modal-dismiss
domisko Jun 18, 2026
02e4d45
Merge pull request #4 from domisko/feature/commute-transitous
domisko Jun 18, 2026
89d10bf
Fix Homegate only returning ~4 listings due to lazy rendering
domisko Jun 19, 2026
475abca
Fix inconsistent Homegate results caused by virtual list rendering
domisko Jun 19, 2026
8fc59ba
Fix similarity dedup loop and Homegate virtual-list dedup
domisko Jun 19, 2026
0ba9e88
Update .nvmrc to Node 22 and document fork in README
domisko Jun 19, 2026
d9e4b90
Add Homegate mobile API (disabled) and ARM64 SwiftShader fix
domisko Jun 19, 2026
e79f61c
Replace Homegate mobile API with __INITIAL_STATE__ HTML extraction
domisko Jun 19, 2026
3c8558f
Migrate Homegate provider to CloakBrowser for DataDome-bypass and rel…
domisko Jun 19, 2026
63666b9
Add proxy confirmation logging and warm-up navigation for Homegate Da…
domisko Jun 19, 2026
8d62fd3
Add ImmoScout24 CH provider using reverse-engineered mobile API
domisko Jun 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ node_modules/
ui/public/
db/*.json
db/*.db*
conf/*.db
conf/*.db-shm
conf/*.db-wal
npm-debug.log
.DS_Store
.idea
Expand Down
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
16.14.0
22
45 changes: 35 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@



> **This is a personal fork of [orangecoding/fredy](https://github.com/orangecoding/fredy) maintained by [@domisko](https://github.com/domisko).**
> It adds features on top of the upstream project — see [Custom Features](#-custom-features) below.

# Fredy 🏡 - Your Self-Hosted Real Estate Finder for Germany

Finding an apartment or house in Germany can be stressful and
Expand All @@ -38,12 +41,22 @@ same listing twice.

------------------------------------------------------------------------

## 🔧 Custom Features

Features added in this fork on top of the upstream Fredy:

- **Homegate (CH) provider** — scrapes Swiss listings from [homegate.ch](https://www.homegate.ch), with full virtual-list scroll support so all listings on the page are captured
- **Commute times via [Transitous](https://transitous.org/)** — walking, cycling, driving and public transit times from each listing to your destination; no API key required; results cached in the browser for 24 h
- **Bug fix: news modal dismiss** — the "what's new" modal no longer reappears on every page reload

------------------------------------------------------------------------

## ✨ Key Features

- 🏠 Scrapes **ImmoScout24, Immowelt, Immonet, eBay Kleinanzeigen,
WG-Gesucht**
WG-Gesucht, Homegate (CH)**
- ⚡ Instant notifications: Slack, Telegram, Email (SendGrid,
Mailjet), ntfy, discord
Mailjet), ntfy, discord
- 🔎 Uses the **ImmoScout Mobile API** (reverse engineered)
- 🌍 Runs anywhere: Docker, Node.js, self-hosted
- 🖥️ Intuitive **Web UI** to manage searches
Expand Down Expand Up @@ -78,25 +91,37 @@ You can try out Fredy here: [Fredy Demo](https://fredy-demo.orange-coding.net/)

## 🚀 Quick Start

### With Docker
### With Docker (this fork)

> [!NOTE]
> In order to start Fredy, you must provide a config.json. As a start, use the one in this repo: https://github.com/orangecoding/fredy/blob/master/conf/config.json
> This fork is not published to a container registry. Build the image locally from the repo.

``` bash
docker run -d --name fredy \
-v fredy_conf:/conf \
-v fredy_db:/db \
-p 9998:9998 \
ghcr.io/orangecoding/fredy:master
# Clone and build
git clone https://github.com/domisko/fredy.git
cd fredy
docker compose up -d --build
```

Logs:

``` bash
docker logs fredy -f
docker compose logs -f
```

To update after pulling new changes:

``` bash
git pull
docker compose up -d --build
```

> **Syncing with upstream:** to pull in fixes from the original Fredy project, add it as a remote once:
> ```bash
> git remote add upstream https://github.com/orangecoding/fredy.git
> ```
> Then periodically: `git fetch upstream && git merge upstream/master`

### Manual (Node.js)

- Requirement: **Node.js 22 or higher**
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ services:
build:
context: .
dockerfile: Dockerfile
image: ghcr.io/orangecoding/fredy
image: fredy-custom
environment:
- NODE_ENV=production
volumes:
Expand Down
14 changes: 9 additions & 5 deletions lib/FredyPipelineExecutioner.js
Original file line number Diff line number Diff line change
Expand Up @@ -415,22 +415,26 @@ class FredyPipelineExecutioner {
_filterBySimilarListings(listings) {
const filteredIds = [];
const keptListings = listings.filter((listing) => {
const similar = this._similarityCache.checkAndAddEntry({
const { duplicate, source } = this._similarityCache.checkAndAddEntry({
title: listing.title,
address: listing.address,
price: listing.price,
});
if (similar) {
if (duplicate) {
const origin = source ? ` (first seen: provider='${source.provider}' job='${source.jobId}')` : '';
logger.debug(
`Filtering similar entry for title '${listing.title}' and address '${listing.address}' (Provider: '${this._providerId}')`,
`Filtering similar entry for title '${listing.title}' and address '${listing.address}' (Provider: '${this._providerId}')${origin}`,
);
filteredIds.push(listing.id);
}
return !similar;
return !duplicate;
});

if (filteredIds.length > 0) {
deleteListingsById(filteredIds);
// Soft-delete so duplicates are hidden from the overview but their hashes remain
// in DB — _findNew will skip them on the next run without hitting the similarity
// cache again. A user hard-delete clears these rows and allows a fresh start.
deleteListingsById(filteredIds, false);
}

return keptListings;
Expand Down
8 changes: 7 additions & 1 deletion lib/api/routes/generalSettingsRoute.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@ import { TRACKING_POIS } from '../../TRACKING_POIS.js';
*/
export default async function generalSettingsPlugin(fastify) {
fastify.get('/', async () => {
return Object.assign({}, await getSettings());
const settings = Object.assign({}, await getSettings());
// Never expose the raw API key to the frontend — return a boolean flag instead
settings.deepl_api_key_set = !!settings.deepl_api_key;
delete settings.deepl_api_key;
settings.immoscout24ch_datadome_set = !!settings.immoscout24ch_datadome;
delete settings.immoscout24ch_datadome;
return settings;
});

fastify.post('/', async (request, reply) => {
Expand Down
77 changes: 77 additions & 0 deletions lib/api/routes/listingsRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import { getJob } from '../../services/storage/jobStorage.js';
import { getSettings } from '../../services/storage/settingsStorage.js';
import { trackPoi } from '../../services/tracking/Tracker.js';
import { TRACKING_POIS } from '../../TRACKING_POIS.js';
import { initSimilarityCache } from '../../services/similarity-check/similarityCache.js';
import { translate as deeplTranslate } from '../../services/translation/deeplClient.js';
import { getAllRoutes as transitousGetAllRoutes } from '../../services/routing/transitousClient.js';
import { getUserSettings } from '../../services/storage/settingsStorage.js';

/**
* @param {import('fastify').FastifyInstance} fastify
Expand Down Expand Up @@ -172,6 +176,7 @@ export default async function listingsPlugin(fastify) {
.send({ error: 'You are trying to remove listings for a job that is not associated to your user' });
}
listingStorage.deleteListingsByJobId(jobId, hardDelete);
if (hardDelete) initSimilarityCache();
} catch (error) {
logger.error(error);
return reply.code(500).send({ error: error.message });
Expand All @@ -188,6 +193,7 @@ export default async function listingsPlugin(fastify) {
}
if (Array.isArray(ids) && ids.length > 0) {
listingStorage.deleteListingsById(ids, hardDelete);
if (hardDelete) initSimilarityCache();
}
} catch (error) {
logger.error(error);
Expand All @@ -196,6 +202,77 @@ export default async function listingsPlugin(fastify) {
return reply.send();
});

fastify.post('/:listingId/translate', async (request, reply) => {
const { listingId } = request.params;
const { targetLanguage } = request.body || {};

if (!targetLanguage) {
return reply.code(400).send({ error: 'targetLanguage is required' });
}

const settings = await getSettings();
if (!settings.deepl_api_key) {
return reply.code(400).send({ error: 'DeepL API key not configured' });
}

try {
const listing = listingStorage.getListingById(listingId, request.session.currentUser, isAdminFn(request));
if (!listing) {
return reply.code(404).send({ error: 'Listing not found' });
}

if (!listing.description) {
return reply.code(400).send({ error: 'Listing has no description to translate' });
}

const translations = listing.translations ? JSON.parse(listing.translations) : {};
const lang = targetLanguage.toLowerCase();
if (translations[lang]) {
return reply.send({ text: translations[lang], cached: true });
}

const translated = await deeplTranslate(
listing.description,
targetLanguage.toUpperCase(),
settings.deepl_api_key,
);
listingStorage.setListingTranslation(listingId, lang, translated);
return reply.send({ text: translated, cached: false });
} catch (error) {
logger.error(error);
return reply.code(500).send({ error: error.message });
}
});

fastify.post('/:listingId/commute', async (request, reply) => {
const { listingId } = request.params;

try {
const listing = listingStorage.getListingById(listingId, request.session.currentUser, isAdminFn(request));
if (!listing) return reply.code(404).send({ error: 'Listing not found' });
if (!listing.latitude || listing.latitude === -1) {
return reply.code(400).send({ error: 'Listing has no coordinates' });
}

const userSettings = getUserSettings(request.session.currentUser);
const destination = userSettings?.home_address?.coords;
if (!destination) {
return reply.code(400).send({ error: 'No commute destination set in user settings' });
}

const result = await transitousGetAllRoutes(
listing.latitude,
listing.longitude,
destination.lat,
destination.lng,
);
return reply.send(result);
} catch (error) {
logger.error(error);
return reply.code(500).send({ error: error.message });
}
});

fastify.post('/restore', async (request, reply) => {
const { ids } = request.body || {};
const settings = await getSettings();
Expand Down
Loading