mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a7b14c079 | ||
|
|
f30ec4645c | ||
|
|
c78472bd19 | ||
|
|
8c5607e20b | ||
|
|
64d0515c79 | ||
|
|
cc0164b689 | ||
|
|
522bbc2282 | ||
|
|
c384781137 | ||
|
|
e2d10d179e | ||
|
|
10c94eea0a | ||
|
|
05f74f99ef | ||
|
|
f3ad529107 | ||
|
|
791822e7c8 | ||
|
|
cdc0cbda2f | ||
|
|
7888c5b340 | ||
|
|
d7f46d6c68 | ||
|
|
1c9d7c9d92 | ||
|
|
bc73de6703 | ||
|
|
568e0abfa1 | ||
|
|
3992a9c81c | ||
|
|
7346075b9d | ||
|
|
8c039f0026 | ||
|
|
a1289acf15 | ||
|
|
8501fc7266 | ||
|
|
4960846cd7 | ||
|
|
3ed17f4442 | ||
|
|
b531a7b77a | ||
|
|
3523057221 | ||
|
|
77311cf39d | ||
|
|
556c0aff35 | ||
|
|
c40d275e52 | ||
|
|
cbf2766783 | ||
|
|
1b39e345b6 | ||
|
|
6ccbdd8afc | ||
|
|
2a30c89eb2 | ||
|
|
4878dc98e3 | ||
|
|
dc2704997d | ||
|
|
e107b0fb00 | ||
|
|
6c08675fee | ||
|
|
34c4de7267 | ||
|
|
b64a118a18 | ||
|
|
03cb4d18cb | ||
|
|
be5c4af3cf | ||
|
|
a460b813c1 | ||
|
|
4596442f64 | ||
|
|
0bcfa1d4ad | ||
|
|
0cad05124a | ||
|
|
eb53b68d45 | ||
|
|
ba0732e1f6 | ||
|
|
aa67647bbb | ||
|
|
7a9d49899b | ||
|
|
9a87c58d3e | ||
|
|
fdd7e835e8 | ||
|
|
00d6a12b30 | ||
|
|
05218800d2 | ||
|
|
19d4721f9f | ||
|
|
a794645393 | ||
|
|
fd7e228972 | ||
|
|
b86e351007 | ||
|
|
19c4860da7 | ||
|
|
d98e06cfdf | ||
|
|
6ae0c9749b | ||
|
|
10e40e038e | ||
|
|
4ba6828939 | ||
|
|
d09770dae2 | ||
|
|
248e4d2562 | ||
|
|
7b8e961b49 | ||
|
|
f66ceccbb4 | ||
|
|
a3db725af6 | ||
|
|
0663bd945f | ||
|
|
bc355fb5fe | ||
|
|
797421f0d5 | ||
|
|
0b2b42fc75 | ||
|
|
472169693f | ||
|
|
3117044139 | ||
|
|
7879d0e94a | ||
|
|
afd1048c9e | ||
|
|
acbaab05ed | ||
|
|
72fffc526b | ||
|
|
9e5989ece3 | ||
|
|
afc200c9e1 | ||
|
|
59226491f2 | ||
|
|
28f7760120 | ||
|
|
2465514b7a | ||
|
|
9dde377fe6 | ||
|
|
28a3a7f372 | ||
|
|
e859250545 | ||
|
|
4dd0370ec1 |
1
.github/workflows/docker.yml
vendored
1
.github/workflows/docker.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -19,4 +19,4 @@ jobs:
|
||||
cache: 'yarn'
|
||||
|
||||
- run: yarn install
|
||||
- run: yarn testGH
|
||||
- run: yarn test:offline
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ npm-debug.log
|
||||
.DS_Store
|
||||
.idea
|
||||
.vscode
|
||||
tools/release/config.json
|
||||
|
||||
94
CHANGELOG.md
94
CHANGELOG.md
@@ -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
120
CLAUDE.md
Normal 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
|
||||
80
Dockerfile
80
Dockerfile
@@ -1,69 +1,55 @@
|
||||
# ================================
|
||||
# Stage 1: Build stage
|
||||
# ================================
|
||||
FROM node:22-alpine AS builder
|
||||
FROM node:22-slim
|
||||
|
||||
WORKDIR /build
|
||||
ARG TARGETARCH
|
||||
|
||||
# Install build dependencies needed for native modules (better-sqlite3)
|
||||
RUN apk add --no-cache python3 make g++
|
||||
# System deps for Chrome for Testing + build tools for native modules (better-sqlite3)
|
||||
# On ARM64 we also install system Chromium (Chrome for Testing has no ARM64 binary)
|
||||
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 \
|
||||
python3 make g++ \
|
||||
&& if [ "$TARGETARCH" = "arm64" ]; then apt-get install -y --no-install-recommends chromium; fi \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& mkdir -p /db /conf /fredy
|
||||
|
||||
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
|
||||
|
||||
# on arm64 use the system Chromium installed above
|
||||
RUN if [ "$TARGETARCH" != "arm64" ]; then npx puppeteer browsers install chrome; fi
|
||||
|
||||
# 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 NODE_ENV=production \
|
||||
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"]
|
||||
|
||||
36
README.md
36
README.md
@@ -154,6 +154,13 @@ 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
|
||||
@@ -181,10 +188,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
|
||||
@@ -218,6 +240,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, it’s easier than ever to throw a prompt into the LLM of your choice and let 'the AI' build your stuff. I’m 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 don’t stop thinking.
|
||||
|
||||
I’ve had one too many PRs full of hallucinated bullshit.
|
||||
|
||||
**Thanks ;)**
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
## 👐 Contributing
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.7 MiB After Width: | Height: | Size: 4.8 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 402 KiB After Width: | Height: | Size: 531 KiB |
@@ -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."
|
||||
|
||||
2371
docs/superpowers/plans/2026-04-22-fredy-ui-redesign.md
Normal file
2371
docs/superpowers/plans/2026-04-22-fredy-ui-redesign.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
2
index.js
2
index.js
@@ -8,7 +8,6 @@ import { checkIfConfigIsAccessible, getProviders, refreshConfig } from './lib/ut
|
||||
import * as similarityCache from './lib/services/similarity-check/similarityCache.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 { initActiveCheckerCron } from './lib/services/crons/listing-alive-cron.js';
|
||||
@@ -54,7 +53,6 @@ await import('./lib/api/api.js');
|
||||
|
||||
if (settings.demoMode) {
|
||||
logger.info('Running in demo mode');
|
||||
cleanupDemoAtMidnight();
|
||||
}
|
||||
|
||||
ensureAdminUserExists();
|
||||
|
||||
12
jsconfig.json
Normal file
12
jsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"target": "ESNext",
|
||||
"checkJs": true,
|
||||
"allowJs": true,
|
||||
"noEmit": true,
|
||||
"strict": false
|
||||
},
|
||||
"exclude": ["node_modules", "ui"]
|
||||
}
|
||||
@@ -4,28 +4,29 @@
|
||||
*/
|
||||
|
||||
import { NoNewListingsWarning } from './errors.js';
|
||||
import { storeListings, getKnownListingHashesForJobAndProvider } from './services/storage/listingsStorage.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';
|
||||
|
||||
/**
|
||||
* @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.
|
||||
*/
|
||||
/** @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,
|
||||
@@ -39,39 +40,43 @@ import { geocodeAddress } from './services/geocoding/geoCodingService.js';
|
||||
* 5) Identify new listings (vs. previously stored hashes)
|
||||
* 6) Persist new listings
|
||||
* 7) Filter out entries similar to already seen ones
|
||||
* 8) Dispatch notifications
|
||||
* 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 {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 {ProviderConfig} providerConfig Provider configuration.
|
||||
* @param {Job} job Job configuration.
|
||||
* @param {string} providerId The ID of the provider currently in use.
|
||||
* @param {string} jobKey Key of the job that is currently running (from within the config).
|
||||
* @param {SimilarityCache} similarityCache Cache instance for checking similar entries.
|
||||
* @param {Browser} browser Puppeteer browser instance.
|
||||
*/
|
||||
constructor(providerConfig, notificationConfig, providerId, jobKey, similarityCache) {
|
||||
constructor(providerConfig, job, providerId, similarityCache, browser) {
|
||||
/** @type {ProviderConfig} */
|
||||
this._providerConfig = providerConfig;
|
||||
this._notificationConfig = notificationConfig;
|
||||
/** @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;
|
||||
this._jobKey = jobKey;
|
||||
/** @type {SimilarityCache} */
|
||||
this._similarityCache = similarityCache;
|
||||
/** @type {Browser} */
|
||||
this._browser = browser;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @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() {
|
||||
@@ -80,18 +85,48 @@ class FredyPipelineExecutioner {
|
||||
.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 {Listing[]} newListings New listings to geocode.
|
||||
* @returns {Promise<Listing[]>} Resolves with the listings (potentially with added coordinates).
|
||||
* @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) {
|
||||
@@ -106,15 +141,90 @@ class FredyPipelineExecutioner {
|
||||
return newListings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter listings by area using the provider's area filter if available.
|
||||
* Only filters if areaFilter is set on the provider AND the listing has coordinates.
|
||||
*
|
||||
* @param {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<Listing[]>} Resolves with an array of listings (empty when none found).
|
||||
* @returns {Promise<ParsedListing[]>} Resolves with an array of listings (empty when none found).
|
||||
*/
|
||||
_getListings(url) {
|
||||
const extractor = new Extractor();
|
||||
const extractor = new Extractor({ ...this._providerConfig.puppeteerOptions, browser: this._browser });
|
||||
return new Promise((resolve, reject) => {
|
||||
extractor
|
||||
.execute(url, this._providerConfig.waitForSelector)
|
||||
@@ -134,33 +244,42 @@ class FredyPipelineExecutioner {
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize raw listings into the provider-specific Listing shape.
|
||||
* Normalize raw listings into the provider-specific ParsedListing shape.
|
||||
*
|
||||
* @param {any[]} listings Raw listing entries from the extractor or override.
|
||||
* @returns {Listing[]} Normalized listings.
|
||||
* @returns {ParsedListing[]} Normalized listings.
|
||||
*/
|
||||
_normalize(listings) {
|
||||
return listings.map(this._providerConfig.normalize);
|
||||
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 {Listing[]} listings Listings to filter.
|
||||
* @returns {Listing[]} Filtered listings that pass validation and provider filter.
|
||||
* @param {ParsedListing[]} listings Listings to filter.
|
||||
* @returns {ParsedListing[]} 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);
|
||||
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 {Listing[]} listings Listings to evaluate for novelty.
|
||||
* @returns {Listing[]} New listings not seen before.
|
||||
* @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) {
|
||||
@@ -177,23 +296,32 @@ class FredyPipelineExecutioner {
|
||||
/**
|
||||
* 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.
|
||||
* @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.
|
||||
*/
|
||||
_notify(newListings) {
|
||||
async _notify(newListings) {
|
||||
if (newListings.length === 0) {
|
||||
throw new NoNewListingsWarning();
|
||||
}
|
||||
const sendNotifications = notify.send(this._providerId, newListings, this._notificationConfig, this._jobKey);
|
||||
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 {Listing[]} newListings Listings to store.
|
||||
* @returns {Listing[]} The same listings, unchanged.
|
||||
* @param {ParsedListing[]} newListings Listings to store.
|
||||
* @returns {ParsedListing[]} The same listings, unchanged.
|
||||
*/
|
||||
_save(newListings) {
|
||||
logger.debug(`Storing ${newListings.length} new listings (Provider: '${this._providerId}')`);
|
||||
@@ -201,15 +329,52 @@ class FredyPipelineExecutioner {
|
||||
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 {Listing[]} listings Listings to filter by similarity.
|
||||
* @returns {Listing[]} Listings considered unique enough to keep.
|
||||
* @param {ParsedListing[]} listings Listings to filter by similarity.
|
||||
* @returns {ParsedListing[]} Listings considered unique enough to keep.
|
||||
*/
|
||||
_filterBySimilarListings(listings) {
|
||||
return listings.filter((listing) => {
|
||||
const filteredIds = [];
|
||||
const keptListings = listings.filter((listing) => {
|
||||
const similar = this._similarityCache.checkAndAddEntry({
|
||||
title: listing.title,
|
||||
address: listing.address,
|
||||
@@ -219,9 +384,16 @@ class FredyPipelineExecutioner {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
10
lib/TRACKING_POIS.js
Normal file
10
lib/TRACKING_POIS.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* 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',
|
||||
};
|
||||
@@ -10,6 +10,7 @@ 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 { userSettingsRouter } from './routes/userSettingsRoute.js';
|
||||
import { jobRouter } from './routes/jobRouter.js';
|
||||
import bodyParser from 'body-parser';
|
||||
import restana from 'restana';
|
||||
@@ -19,23 +20,26 @@ import { getDirName } from '../utils.js';
|
||||
import { demoRouter } from './routes/demoRouter.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 { getSettings, getOrCreateSessionSecret } from '../services/storage/settingsStorage.js';
|
||||
import { dashboardRouter } from './routes/dashboardRouter.js';
|
||||
import { backupRouter } from './routes/backupRouter.js';
|
||||
import { trackingRouter } from './routes/trackingRoute.js';
|
||||
import { registerMcpRoutes } from '../mcp/mcpHttpRoute.js';
|
||||
const service = restana();
|
||||
const staticService = files(path.join(getDirName(), '../ui/public'));
|
||||
const PORT = (await getSettings()).port || 9998;
|
||||
const sessionSecret = await getOrCreateSessionSecret();
|
||||
|
||||
service.use(bodyParser.json());
|
||||
service.use(cookieSession());
|
||||
service.use(cookieSession(sessionSecret));
|
||||
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());
|
||||
service.use('/api/user/settings', authInterceptor());
|
||||
service.use('/api/tracking', authInterceptor());
|
||||
|
||||
// /admin can only be accessed when user is having admin permissions
|
||||
service.use('/api/admin', adminInterceptor());
|
||||
@@ -44,15 +48,19 @@ 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/user/settings', userSettingsRouter);
|
||||
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);
|
||||
service.use('/api/tracking', trackingRouter);
|
||||
//this route is unsecured intentionally as it is being queried from the login page
|
||||
service.use('/api/demo', demoRouter);
|
||||
|
||||
// MCP Streamable HTTP endpoint (secured via Bearer token, not cookie-session)
|
||||
registerMcpRoutes(service);
|
||||
|
||||
service.start(PORT).then(() => {
|
||||
logger.debug(`Started API service on port ${PORT}`);
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ dashboardRouter.get('/', async (req, res) => {
|
||||
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);
|
||||
const { numberOfActiveListings, medianPriceOfListings } = 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);
|
||||
@@ -63,7 +63,7 @@ dashboardRouter.get('/', async (req, res) => {
|
||||
totalJobs,
|
||||
totalListings,
|
||||
numberOfActiveListings,
|
||||
avgPriceOfListings,
|
||||
medianPriceOfListings,
|
||||
},
|
||||
pie: providerPie,
|
||||
};
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 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 };
|
||||
@@ -9,6 +9,7 @@ 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';
|
||||
import { isAdmin } from '../security.js';
|
||||
const service = restana();
|
||||
const generalSettingsRouter = service.newRouter();
|
||||
|
||||
@@ -18,9 +19,12 @@ generalSettingsRouter.get('/', async (req, res) => {
|
||||
});
|
||||
generalSettingsRouter.post('/', async (req, res) => {
|
||||
const { sqlitepath, ...appSettings } = req.body || {};
|
||||
if (typeof appSettings.baseUrl === 'string') {
|
||||
appSettings.baseUrl = appSettings.baseUrl.trim().replace(/\/$/, '');
|
||||
}
|
||||
const localSettings = await getSettings();
|
||||
|
||||
if (localSettings.demoMode) {
|
||||
if (localSettings.demoMode && !isAdmin(req)) {
|
||||
res.send(new Error('In demo mode, it is not allowed to change these settings.'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -11,10 +11,13 @@ 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) {
|
||||
@@ -160,7 +163,18 @@ jobRouter.post('/:jobId/run', async (req, res) => {
|
||||
});
|
||||
|
||||
jobRouter.post('/', async (req, res) => {
|
||||
const { provider, notificationAdapter, name, blacklist = [], jobId, enabled, shareWithUsers = [] } = req.body;
|
||||
const {
|
||||
provider,
|
||||
notificationAdapter,
|
||||
name,
|
||||
blacklist = [],
|
||||
jobId,
|
||||
enabled,
|
||||
shareWithUsers = [],
|
||||
spatialFilter = null,
|
||||
specFilter = null,
|
||||
} = req.body;
|
||||
const settings = await getSettings();
|
||||
try {
|
||||
let jobFromDb = jobStorage.getJob(jobId);
|
||||
|
||||
@@ -169,6 +183,11 @@ jobRouter.post('/', async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (settings.demoMode && !isAdmin(req) && jobFromDb && jobFromDb.name === DEMO_JOB_NAME) {
|
||||
res.send(new Error('Sorry, but you cannot change the Status of our Demo Job ;)'));
|
||||
return;
|
||||
}
|
||||
|
||||
jobStorage.upsertJob({
|
||||
userId: req.session.currentUser,
|
||||
jobId,
|
||||
@@ -178,6 +197,8 @@ jobRouter.post('/', async (req, res) => {
|
||||
provider,
|
||||
notificationAdapter,
|
||||
shareWithUsers,
|
||||
spatialFilter,
|
||||
specFilter,
|
||||
});
|
||||
} catch (error) {
|
||||
res.send(new Error(error));
|
||||
@@ -188,8 +209,14 @@ jobRouter.post('/', async (req, res) => {
|
||||
|
||||
jobRouter.delete('', async (req, res) => {
|
||||
const { jobId } = req.body;
|
||||
const settings = await getSettings();
|
||||
try {
|
||||
const job = jobStorage.getJob(jobId);
|
||||
if (settings.demoMode && !isAdmin(req) && job.name === DEMO_JOB_NAME) {
|
||||
res.send(new Error('Sorry, but you cannot remove the Demo Job ;)'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!doesJobBelongsToUser(job, req)) {
|
||||
res.send(new Error('You are trying to remove a job that is not associated to your user'));
|
||||
} else {
|
||||
@@ -204,8 +231,15 @@ jobRouter.delete('', async (req, res) => {
|
||||
jobRouter.put('/:jobId/status', async (req, res) => {
|
||||
const { status } = req.body;
|
||||
const { jobId } = req.params;
|
||||
const settings = await getSettings();
|
||||
try {
|
||||
const job = jobStorage.getJob(jobId);
|
||||
|
||||
if (settings.demoMode && !isAdmin(req) && job.name === DEMO_JOB_NAME) {
|
||||
res.send(new Error('Sorry, but you cannot change the Status of our Demo Job ;)'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!doesJobBelongsToUser(job, req)) {
|
||||
res.send(new Error('You are trying change a job that is not associated to your user'));
|
||||
} else {
|
||||
|
||||
@@ -10,6 +10,7 @@ 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();
|
||||
|
||||
@@ -74,6 +75,18 @@ listingsRouter.get('/map', async (req, res) => {
|
||||
res.send();
|
||||
});
|
||||
|
||||
listingsRouter.get('/:listingId', async (req, res) => {
|
||||
const { listingId } = req.params;
|
||||
const listing = listingStorage.getListingById(listingId, req.session.currentUser, isAdminFn(req));
|
||||
if (!listing) {
|
||||
res.statusCode = 404;
|
||||
res.body = { message: 'Listing not found' };
|
||||
return res.send();
|
||||
}
|
||||
res.body = listing;
|
||||
res.send();
|
||||
});
|
||||
|
||||
// Toggle watch state for the current user on a listing
|
||||
listingsRouter.post('/watch', async (req, res) => {
|
||||
try {
|
||||
@@ -94,9 +107,15 @@ listingsRouter.post('/watch', async (req, res) => {
|
||||
});
|
||||
|
||||
listingsRouter.delete('/job', async (req, res) => {
|
||||
const { jobId } = req.body;
|
||||
const { jobId, hardDelete = false } = req.body;
|
||||
const settings = await getSettings();
|
||||
try {
|
||||
listingStorage.deleteListingsByJobId(jobId);
|
||||
if (settings.demoMode && !isAdminFn(req)) {
|
||||
res.send(new Error('Sorry, but you cannot remove listings in demo mode ;)'));
|
||||
return;
|
||||
}
|
||||
|
||||
listingStorage.deleteListingsByJobId(jobId, hardDelete);
|
||||
} catch (error) {
|
||||
res.send(new Error(error));
|
||||
logger.error(error);
|
||||
@@ -105,10 +124,10 @@ listingsRouter.delete('/job', async (req, res) => {
|
||||
});
|
||||
|
||||
listingsRouter.delete('/', async (req, res) => {
|
||||
const { ids } = req.body;
|
||||
const { ids, hardDelete = false } = req.body;
|
||||
try {
|
||||
if (Array.isArray(ids) && ids.length > 0) {
|
||||
listingStorage.deleteListingsById(ids);
|
||||
listingStorage.deleteListingsById(ids, hardDelete);
|
||||
}
|
||||
} catch (error) {
|
||||
res.send(new Error(error));
|
||||
|
||||
@@ -9,6 +9,27 @@ 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 MAX_LOGIN_ATTEMPTS = 10;
|
||||
const LOGIN_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
|
||||
const loginAttempts = new Map(); // ip -> { count, firstAttempt }
|
||||
|
||||
function getClientIp(req) {
|
||||
const forwarded = req.headers['x-forwarded-for'];
|
||||
return (forwarded ? forwarded.split(',')[0] : req.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;
|
||||
}
|
||||
|
||||
const service = restana();
|
||||
const loginRouter = service.newRouter();
|
||||
loginRouter.get('/user', async (req, res) => {
|
||||
@@ -25,6 +46,12 @@ loginRouter.get('/user', async (req, res) => {
|
||||
res.send();
|
||||
});
|
||||
loginRouter.post('/', async (req, res) => {
|
||||
const ip = getClientIp(req);
|
||||
if (isRateLimited(ip)) {
|
||||
logger.error(`Login rate limit exceeded for IP ${ip}`);
|
||||
res.send(429);
|
||||
return;
|
||||
}
|
||||
const settings = await getSettings();
|
||||
const { username, password } = req.body;
|
||||
const user = userStorage.getUsers(true).find((user) => user.username === username);
|
||||
@@ -38,6 +65,8 @@ loginRouter.post('/', async (req, res) => {
|
||||
}
|
||||
|
||||
req.session.currentUser = user.id;
|
||||
req.session.createdAt = Date.now();
|
||||
loginAttempts.delete(ip);
|
||||
userStorage.setLastLoginToNow({ userId: user.id });
|
||||
res.send(200);
|
||||
return;
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
import fs from 'fs';
|
||||
import restana from 'restana';
|
||||
import logger from '../../services/logger.js';
|
||||
|
||||
const service = restana();
|
||||
const notificationAdapterRouter = service.newRouter();
|
||||
const notificationAdapterList = fs.readdirSync('./lib//notification/adapter').filter((file) => file.endsWith('.js'));
|
||||
@@ -34,11 +36,14 @@ notificationAdapterRouter.post('/try', async (req, res) => {
|
||||
serviceName: 'TestCall',
|
||||
newListings: [
|
||||
{
|
||||
price: '42 €',
|
||||
title: 'This is a test listing',
|
||||
address: 'some address',
|
||||
size: '666 2m',
|
||||
link: 'https://www.orange-coding.net',
|
||||
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,
|
||||
@@ -46,6 +51,7 @@ notificationAdapterRouter.post('/try', async (req, res) => {
|
||||
});
|
||||
res.send();
|
||||
} catch (Exception) {
|
||||
logger.error('Error during notification adapter test:', Exception);
|
||||
res.send(new Error(Exception));
|
||||
}
|
||||
});
|
||||
@@ -54,3 +60,51 @@ notificationAdapterRouter.get('/', async (req, res) => {
|
||||
res.send();
|
||||
});
|
||||
export { notificationAdapterRouter };
|
||||
|
||||
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
|
||||
`;
|
||||
|
||||
37
lib/api/routes/trackingRoute.js
Normal file
37
lib/api/routes/trackingRoute.js
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import restana from 'restana';
|
||||
import { trackPoi } from '../../services/tracking/Tracker.js';
|
||||
import { TRACKING_POIS } from '../../TRACKING_POIS.js';
|
||||
import logger from '../../services/logger.js';
|
||||
|
||||
const service = restana();
|
||||
const trackingRouter = service.newRouter();
|
||||
|
||||
trackingRouter.get('/trackingPois', async (req, res) => {
|
||||
res.body = TRACKING_POIS;
|
||||
res.send();
|
||||
});
|
||||
|
||||
trackingRouter.post('/poi', async (req, res) => {
|
||||
const { poi } = req.body;
|
||||
if (!poi) {
|
||||
res.statusCode = 400;
|
||||
res.send({ error: 'Feature name is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await trackPoi(poi);
|
||||
res.send({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Error tracking feature', error);
|
||||
res.statusCode = 500;
|
||||
res.send({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export { trackingRouter };
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
import { isAdmin as isAdminUser } from '../security.js';
|
||||
const service = restana();
|
||||
const userRouter = service.newRouter();
|
||||
function checkIfAnyAdminAfterRemovingUser(userIdToBeRemoved, allUser) {
|
||||
@@ -29,7 +30,7 @@ userRouter.get('/:userId', async (req, res) => {
|
||||
});
|
||||
userRouter.delete('/', async (req, res) => {
|
||||
const settings = await getSettings();
|
||||
if (settings.demoMode) {
|
||||
if (settings.demoMode && !isAdminUser(req)) {
|
||||
res.send(new Error('In demo mode, it is not allowed to remove user.'));
|
||||
return;
|
||||
}
|
||||
@@ -51,7 +52,7 @@ userRouter.delete('/', async (req, res) => {
|
||||
});
|
||||
userRouter.post('/', async (req, res) => {
|
||||
const settings = await getSettings();
|
||||
if (settings.demoMode) {
|
||||
if (settings.demoMode && !isAdminUser(req)) {
|
||||
res.send(new Error('In demo mode, it is not allowed to change or add user.'));
|
||||
return;
|
||||
}
|
||||
|
||||
128
lib/api/routes/userSettingsRoute.js
Normal file
128
lib/api/routes/userSettingsRoute.js
Normal file
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import restana from 'restana';
|
||||
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';
|
||||
|
||||
const service = restana();
|
||||
const userSettingsRouter = service.newRouter();
|
||||
|
||||
userSettingsRouter.get('/', async (req, res) => {
|
||||
const userId = req.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);
|
||||
}
|
||||
res.body = settings;
|
||||
res.send();
|
||||
});
|
||||
|
||||
userSettingsRouter.get('/autocomplete', async (req, res) => {
|
||||
const { q } = req.query;
|
||||
try {
|
||||
const results = await autocompleteAddress(q);
|
||||
res.body = results;
|
||||
res.send();
|
||||
} catch (error) {
|
||||
res.statusCode = 500;
|
||||
res.send({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
userSettingsRouter.post('/home-address', async (req, res) => {
|
||||
const userId = req.session.currentUser;
|
||||
const { home_address } = req.body;
|
||||
const settings = await getSettings();
|
||||
|
||||
if (settings.demoMode && !isAdmin(req)) {
|
||||
res.send(new Error('In demo mode, it is not allowed to change the home address.'));
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
//we do NOT wait for this to finish, as we don't want to block the response
|
||||
runGeoCordTask();
|
||||
res.send({ success: true, coords });
|
||||
} else {
|
||||
res.statusCode = 400;
|
||||
res.send({ error: 'Could not geocode address' });
|
||||
}
|
||||
} else {
|
||||
upsertSettings({ home_address: null }, userId);
|
||||
res.send({ success: true });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error updating home address settings', error);
|
||||
res.statusCode = 500;
|
||||
res.send({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
userSettingsRouter.post('/news-hash', async (req, res) => {
|
||||
const userId = req.session.currentUser;
|
||||
const { news_hash } = req.body;
|
||||
|
||||
const globalSettings = await getSettings();
|
||||
if (globalSettings.demoMode && !isAdmin(req)) {
|
||||
res.statusCode = 403;
|
||||
res.send({ error: 'In demo mode, it is not allowed to change settings.' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
upsertSettings({ news_hash }, userId);
|
||||
res.send({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Error updating news hash', error);
|
||||
res.statusCode = 500;
|
||||
res.send({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
userSettingsRouter.post('/provider-details', async (req, res) => {
|
||||
const userId = req.session.currentUser;
|
||||
const { provider_details } = req.body;
|
||||
|
||||
const globalSettings = await getSettings();
|
||||
if (globalSettings.demoMode && !isAdmin(req)) {
|
||||
res.statusCode = 403;
|
||||
res.send({ error: 'In demo mode, it is not allowed to change settings.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(provider_details)) {
|
||||
res.statusCode = 400;
|
||||
res.send({ error: 'provider_details must be an array of provider ids.' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
upsertSettings({ provider_details }, userId);
|
||||
res.send({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Error updating provider details setting', error);
|
||||
res.statusCode = 500;
|
||||
res.send({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export { userSettingsRouter };
|
||||
@@ -5,12 +5,17 @@
|
||||
|
||||
import * as userStorage from '../services/storage/userStorage.js';
|
||||
import cookieSession from 'cookie-session';
|
||||
import { nanoid } from 'nanoid';
|
||||
const SESSION_MAX_AGE = 2 * 60 * 60 * 1000; // 2 hours
|
||||
const unauthorized = (res) => {
|
||||
return res.send(401);
|
||||
};
|
||||
const isUnauthorized = (req) => {
|
||||
return req.session.currentUser == null;
|
||||
if (req.session.currentUser == null) return true;
|
||||
if (Date.now() - req.session.createdAt > SESSION_MAX_AGE) {
|
||||
req.session = null;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const isAdmin = (req) => {
|
||||
if (!isUnauthorized(req)) {
|
||||
@@ -37,12 +42,11 @@ const adminInterceptor = () => {
|
||||
}
|
||||
};
|
||||
};
|
||||
const cookieSession$0 = (userId) => {
|
||||
const cookieSession$0 = (secret) => {
|
||||
return cookieSession({
|
||||
name: 'fredy-admin-session',
|
||||
keys: ['fredy', 'super', 'fancy', 'key', nanoid()],
|
||||
userId,
|
||||
maxAge: 2 * 60 * 60 * 1000, // 2 hours
|
||||
keys: [secret],
|
||||
maxAge: SESSION_MAX_AGE,
|
||||
});
|
||||
};
|
||||
export { cookieSession$0 as cookieSession };
|
||||
|
||||
323
lib/mcp/README.md
Normal file
323
lib/mcp/README.md
Normal 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
347
lib/mcp/mcpAdapter.js
Normal 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;
|
||||
}
|
||||
66
lib/mcp/mcpAuthentication.js
Normal file
66
lib/mcp/mcpAuthentication.js
Normal 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);
|
||||
}
|
||||
131
lib/mcp/mcpHttpRoute.js
Normal file
131
lib/mcp/mcpHttpRoute.js
Normal file
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* 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 { 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.
|
||||
* Each session gets its own McpServer + StreamableHTTPServerTransport pair.
|
||||
* @type {Map<string, { server: McpServer, transport: StreamableHTTPServerTransport }>}
|
||||
*/
|
||||
const sessions = new Map();
|
||||
|
||||
/**
|
||||
* Get or create a session for the given session id with authentication.
|
||||
* @param {string|undefined} sessionId
|
||||
* @param {{ userId: string }} auth
|
||||
* @returns {{ server: McpServer, transport: StreamableHTTPServerTransport }}
|
||||
*/
|
||||
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 restana service.
|
||||
*
|
||||
* Mounts handlers at /api/mcp to handle the MCP Streamable HTTP protocol:
|
||||
* - POST /api/mcp – JSON-RPC messages (initialize, tool calls, etc.)
|
||||
* - 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('restana').Service} service - The restana service instance.
|
||||
*/
|
||||
export function registerMcpRoutes(service) {
|
||||
// POST – main JSON-RPC endpoint
|
||||
service.post('/api/mcp', async (req, res) => {
|
||||
const auth = authenticateRequest(req);
|
||||
if (!auth) {
|
||||
res.statusCode = 401;
|
||||
return res.send({ error: 'Unauthorized. Provide a valid Bearer token.' });
|
||||
}
|
||||
|
||||
const sessionId = req.headers['mcp-session-id'];
|
||||
const { server, transport } = getOrCreateSession(sessionId, auth);
|
||||
|
||||
// Connect server to transport if not already connected
|
||||
if (!transport.onmessage) {
|
||||
await server.connect(transport);
|
||||
}
|
||||
|
||||
// Inject authInfo so tools can access the authenticated user
|
||||
req.auth = { userId: auth.userId };
|
||||
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
});
|
||||
|
||||
// GET – SSE stream for server-initiated messages
|
||||
service.get('/api/mcp', async (req, res) => {
|
||||
const auth = authenticateRequest(req);
|
||||
if (!auth) {
|
||||
res.statusCode = 401;
|
||||
return res.send({ error: 'Unauthorized. Provide a valid Bearer token.' });
|
||||
}
|
||||
|
||||
const sessionId = req.headers['mcp-session-id'];
|
||||
if (!sessionId || !sessions.has(sessionId)) {
|
||||
res.statusCode = 400;
|
||||
return res.send({ error: 'Invalid or missing session. Send an initialize request first.' });
|
||||
}
|
||||
|
||||
const { transport } = sessions.get(sessionId);
|
||||
await transport.handleRequest(req, res);
|
||||
});
|
||||
|
||||
// DELETE – terminate session
|
||||
service.delete('/api/mcp', async (req, res) => {
|
||||
const auth = authenticateRequest(req);
|
||||
if (!auth) {
|
||||
res.statusCode = 401;
|
||||
return res.send({ error: 'Unauthorized. Provide a valid Bearer token.' });
|
||||
}
|
||||
|
||||
const sessionId = req.headers['mcp-session-id'];
|
||||
if (!sessionId || !sessions.has(sessionId)) {
|
||||
res.statusCode = 404;
|
||||
return res.send({ error: 'Session not found.' });
|
||||
}
|
||||
|
||||
const { transport } = sessions.get(sessionId);
|
||||
await transport.close();
|
||||
sessions.delete(sessionId);
|
||||
res.statusCode = 200;
|
||||
res.send({ ok: true });
|
||||
});
|
||||
|
||||
logger.debug('MCP Streamable HTTP endpoint registered at /api/mcp');
|
||||
}
|
||||
180
lib/mcp/mcpNormalizer.js
Normal file
180
lib/mcp/mcpNormalizer.js
Normal 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
76
lib/mcp/stdio.js
Normal 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');
|
||||
@@ -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' },
|
||||
|
||||
@@ -5,9 +5,18 @@
|
||||
|
||||
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 = {
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
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)',
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -6,15 +6,20 @@
|
||||
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' },
|
||||
|
||||
@@ -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 ?? '')
|
||||
|
||||
@@ -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);
|
||||
|
||||
89
lib/notification/adapter/resend.js
Executable file
89
lib/notification/adapter/resend.js
Executable 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.',
|
||||
},
|
||||
},
|
||||
};
|
||||
17
lib/notification/adapter/resend.md
Normal file
17
lib/notification/adapter/resend.md
Normal 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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
113
lib/notification/adapter/smtp.js
Normal file
113
lib/notification/adapter/smtp.js
Normal 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.',
|
||||
},
|
||||
},
|
||||
};
|
||||
22
lib/notification/adapter/smtp.md
Normal file
22
lib/notification/adapter/smtp.md
Normal file
@@ -0,0 +1,22 @@
|
||||
### 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
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }));
|
||||
};
|
||||
|
||||
@@ -5,8 +5,16 @@
|
||||
|
||||
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',
|
||||
|
||||
@@ -5,53 +5,125 @@
|
||||
|
||||
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 });
|
||||
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) => {
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
* 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';
|
||||
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 };
|
||||
@@ -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) => {
|
||||
|
||||
@@ -5,27 +5,46 @@
|
||||
|
||||
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,
|
||||
|
||||
@@ -5,21 +5,84 @@
|
||||
|
||||
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 });
|
||||
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"]',
|
||||
@@ -28,7 +91,8 @@ const config = {
|
||||
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 +101,7 @@ const config = {
|
||||
},
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
fetchDetails: fetchDetails,
|
||||
activeTester: checkIfListingIsActive,
|
||||
};
|
||||
export const init = (sourceConfig, blacklist) => {
|
||||
|
||||
@@ -5,17 +5,177 @@
|
||||
|
||||
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 });
|
||||
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',
|
||||
tags: '.aditem-main--middle--tags | removeNewline | trim',
|
||||
title: '.aditem-main .text-module-begin a | removeNewline | trim',
|
||||
link: '.aditem-main .text-module-begin a@href | removeNewline | trim',
|
||||
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',
|
||||
};
|
||||
|
||||
@@ -5,23 +5,46 @@
|
||||
|
||||
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',
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
|
||||
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,
|
||||
|
||||
@@ -5,19 +5,43 @@
|
||||
|
||||
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',
|
||||
|
||||
@@ -5,24 +5,47 @@
|
||||
|
||||
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',
|
||||
|
||||
@@ -5,38 +5,110 @@
|
||||
|
||||
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 });
|
||||
|
||||
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;
|
||||
|
||||
@@ -5,22 +5,69 @@
|
||||
|
||||
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 });
|
||||
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) => {
|
||||
|
||||
@@ -5,26 +5,45 @@
|
||||
|
||||
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 id = o.link.split('/').pop();
|
||||
const price = o.price;
|
||||
const size = o.size;
|
||||
const rooms = o.rooms;
|
||||
const [city = '', part = ''] = (o.description || '').split('-').map((v) => v.trim());
|
||||
const address = `${part}, ${city}`;
|
||||
return Object.assign(o, { id, price, size, rooms, address });
|
||||
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',
|
||||
@@ -37,7 +56,7 @@ const config = {
|
||||
size: 'dl:nth-of-type(3) dd | removeNewline | trim',
|
||||
description: 'div.before\\:icon-location_marker | trim',
|
||||
link: '@href',
|
||||
imageUrl: 'img@src',
|
||||
image: 'img@src',
|
||||
},
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 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);
|
||||
}
|
||||
}
|
||||
@@ -6,28 +6,41 @@
|
||||
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';
|
||||
|
||||
async function runTask() {
|
||||
export async function runGeoCordTask() {
|
||||
const listings = getListingsToGeocode();
|
||||
if (listings.length === 0) {
|
||||
return;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 runTask();
|
||||
await runGeoCordTask();
|
||||
// then every 6 hours
|
||||
cron.schedule('0 */6 * * *', runTask);
|
||||
cron.schedule('0 */6 * * *', runGeoCordTask);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,19 @@
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -19,52 +19,85 @@ import path from 'path';
|
||||
|
||||
puppeteer.use(StealthPlugin());
|
||||
|
||||
export default async function execute(url, waitForSelector, options) {
|
||||
let browser;
|
||||
let page;
|
||||
let result = null;
|
||||
export async function launchBrowser(url, options) {
|
||||
const preCfg = getPreLaunchConfig(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',
|
||||
preCfg.langArg,
|
||||
preCfg.windowSizeArg,
|
||||
...preCfg.extraArgs,
|
||||
];
|
||||
if (options?.proxyUrl) {
|
||||
launchArgs.push(`--proxy-server=${options.proxyUrl}`);
|
||||
}
|
||||
|
||||
let userDataDir;
|
||||
let removeUserDataDir = false;
|
||||
if (options && options.userDataDir) {
|
||||
userDataDir = options.userDataDir;
|
||||
} else {
|
||||
const prefix = path.join(os.tmpdir(), 'puppeteer-fredy-');
|
||||
userDataDir = fs.mkdtempSync(prefix);
|
||||
removeUserDataDir = true;
|
||||
}
|
||||
|
||||
// On ARM64 Docker, Chrome for Testing has no native binary — use system Chromium instead.
|
||||
const executablePath =
|
||||
options?.executablePath ||
|
||||
(process.arch === 'arm64' && process.env.IS_DOCKER === 'true' ? '/usr/bin/chromium' : undefined);
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: options?.puppeteerHeadless ?? true,
|
||||
args: launchArgs,
|
||||
timeout: options?.puppeteerTimeout || 45_000,
|
||||
userDataDir,
|
||||
executablePath,
|
||||
});
|
||||
|
||||
browser.__fredy_userDataDir = userDataDir;
|
||||
browser.__fredy_removeUserDataDir = removeUserDataDir;
|
||||
|
||||
return browser;
|
||||
}
|
||||
|
||||
export async function closeBrowser(browser) {
|
||||
if (!browser) return;
|
||||
const userDataDir = browser.__fredy_userDataDir;
|
||||
const removeUserDataDir = browser.__fredy_removeUserDataDir;
|
||||
try {
|
||||
await browser.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (removeUserDataDir && userDataDir) {
|
||||
try {
|
||||
await fs.promises.rm(userDataDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 Puppeteer.`);
|
||||
|
||||
// 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;
|
||||
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();
|
||||
const preCfg = getPreLaunchConfig(url, options || {});
|
||||
await applyBotPreventionToPage(page, preCfg);
|
||||
// Provide languages value before navigation
|
||||
await applyLanguagePersistence(page, preCfg);
|
||||
@@ -77,6 +110,7 @@ export default async function execute(url, waitForSelector, options) {
|
||||
// Navigation
|
||||
const response = await page.goto(url, {
|
||||
waitUntil: options?.waitUntil || 'domcontentloaded',
|
||||
timeout: options?.puppeteerTimeout || 60000,
|
||||
});
|
||||
|
||||
// Optionally wait and add subtle human-like interactions
|
||||
@@ -104,7 +138,7 @@ export default async function execute(url, waitForSelector, options) {
|
||||
result = pageSource || (await page.content());
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.message?.includes('Timeout')) {
|
||||
if (error?.name?.includes('Timeout')) {
|
||||
logger.debug('Error executing with puppeteer executor', error);
|
||||
} else {
|
||||
logger.warn('Error executing with puppeteer executor', error);
|
||||
@@ -118,19 +152,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;
|
||||
|
||||
26
lib/services/geocoding/autocompleteService.js
Normal file
26
lib/services/geocoding/autocompleteService.js
Normal 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 [];
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,7 @@ async function doGeocode(address) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
agent,
|
||||
timeout: 60000,
|
||||
headers: {
|
||||
'User-Agent': userAgent,
|
||||
},
|
||||
@@ -100,6 +101,53 @@ async function doGeocode(address) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
61
lib/services/geocoding/distanceService.js
Normal file
61
lib/services/geocoding/distanceService.js
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -165,7 +177,7 @@ export function convertWebToMobile(webUrl) {
|
||||
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 mobileParams = {
|
||||
searchType: isRadius ? 'radius' : 'region',
|
||||
|
||||
@@ -13,6 +13,7 @@ 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';
|
||||
|
||||
/**
|
||||
* Initializes the job execution service.
|
||||
@@ -94,7 +95,7 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
|
||||
* @param {{userId?: string, isAdmin?: boolean}} [context] - Who requested the run; determines job filtering.
|
||||
* @returns {void}
|
||||
*/
|
||||
function runAll(respectWorkingHours = true, context = undefined) {
|
||||
async function runAll(respectWorkingHours = true, context = undefined) {
|
||||
if (settings.demoMode) return;
|
||||
const now = Date.now();
|
||||
const withinHours = duringWorkingHoursOrNotSet(settings, now);
|
||||
@@ -103,15 +104,18 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
|
||||
return;
|
||||
}
|
||||
settings.lastRun = now;
|
||||
jobStorage
|
||||
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
|
||||
})
|
||||
.forEach((job) => executeJob(job));
|
||||
});
|
||||
|
||||
for (const job of jobs) {
|
||||
await executeJob(job);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,28 +158,35 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
|
||||
} catch (err) {
|
||||
logger.warn('Failed to emit start status for job', job.id, err);
|
||||
}
|
||||
let browser;
|
||||
try {
|
||||
const jobProviders = job.provider.filter(
|
||||
(p) => providers.find((loaded) => loaded.metaInformation.id === p.id) != null,
|
||||
);
|
||||
const executions = jobProviders.map(async (prov) => {
|
||||
const matchedProvider = providers.find((loaded) => loaded.metaInformation.id === prov.id);
|
||||
matchedProvider.init(prov, job.blacklist);
|
||||
await new FredyPipelineExecutioner(
|
||||
matchedProvider.config,
|
||||
job.notificationAdapter,
|
||||
prov.id,
|
||||
job.id,
|
||||
similarityCache,
|
||||
).execute();
|
||||
});
|
||||
const results = await Promise.allSettled(executions);
|
||||
for (const r of results) {
|
||||
if (r.status === 'rejected') {
|
||||
logger.error(r.reason);
|
||||
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.isConnected()) {
|
||||
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, {});
|
||||
}
|
||||
|
||||
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 });
|
||||
|
||||
35
lib/services/listings/distanceCalculator.js
Normal file
35
lib/services/listings/distanceCalculator.js
Normal 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;
|
||||
}
|
||||
@@ -8,38 +8,71 @@ import { randomBetween, sleep } from '../../utils.js';
|
||||
|
||||
const maxAttempts = 3;
|
||||
|
||||
const userAgents = [
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15',
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if a listing is still active with up to 3 attempts and exponential backoff.
|
||||
* Backoff waits are capped and the last wait is at most 2000 ms.
|
||||
* Check if a listing is still active with up to 5 attempts and exponential backoff.
|
||||
* Backoff waits are randomized and capped.
|
||||
*
|
||||
* Rules:
|
||||
* - HTTP 200 => return 1
|
||||
* - HTTP 200 => return 1 (if checkForText is provided and found, returns 0)
|
||||
* - HTTP 401/403 => return -1 (most certainly detected as a bot)
|
||||
* - HTTP 404 => return 0
|
||||
* - Other statuses or network errors => retry until attempts are exhausted
|
||||
*
|
||||
* @returns {Promise<Integer>} 1 if active, o if not active and -1 if detected as bot
|
||||
* @returns {Promise<Integer>} 1 if active, 0 if not active and -1 if detected as bot
|
||||
*/
|
||||
export default async function checkIfListingIsActive(link) {
|
||||
export default async function checkIfListingIsActive(link, checkForText = null) {
|
||||
await sleep(randomBetween(50, 100));
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
const userAgent = userAgents[Math.floor(Math.random() * userAgents.length)];
|
||||
const res = await fetch(link, {
|
||||
redirect: 'manual',
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36',
|
||||
'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8',
|
||||
'User-Agent': userAgent,
|
||||
Accept:
|
||||
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||
'Accept-Language': 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Cache-Control': 'max-age=0',
|
||||
'Sec-Ch-Ua': '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
|
||||
'Sec-Ch-Ua-Mobile': '?0',
|
||||
'Sec-Ch-Ua-Platform': '"macOS"',
|
||||
'Sec-Fetch-Dest': 'document',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-Site': 'none',
|
||||
'Sec-Fetch-User': '?1',
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
Referer: 'https://www.google.com/',
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status === 200) {
|
||||
if (checkForText) {
|
||||
const htmText = await res.text();
|
||||
if (htmText.includes(checkForText)) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
if (res.status === 401) return -1;
|
||||
if (res.status === 403) return -1;
|
||||
if (res.status === 404) return 0;
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
if (attempt < maxAttempts) {
|
||||
await sleep(backoffDelay(attempt));
|
||||
continue;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
if (res.status === 404 || res.status === 410) return 0;
|
||||
|
||||
// For any other status, only retry if attempts remain
|
||||
if (attempt < maxAttempts) {
|
||||
@@ -62,13 +95,13 @@ export default async function checkIfListingIsActive(link) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Exponential backoff delay with cap.
|
||||
* attempt: 1 -> 500ms, 2 -> 1000ms, 3 -> 2000ms (cap)
|
||||
* Exponential backoff delay with cap and jitter.
|
||||
* @param {number} attempt 1-based attempt index
|
||||
* @returns {number} delay in ms
|
||||
*/
|
||||
function backoffDelay(attempt) {
|
||||
const base = 500;
|
||||
const cap = 2000;
|
||||
return Math.min(base * 2 ** (attempt - 1), cap);
|
||||
const delay = Math.min(base * 2 ** (attempt - 1), cap);
|
||||
return delay + randomBetween(0, 1000);
|
||||
}
|
||||
|
||||
@@ -34,9 +34,8 @@ class SqliteConnection {
|
||||
|
||||
static async init() {
|
||||
if (this.#sqlLiteCfg == null) {
|
||||
readConfigFromStorage().then((c) => {
|
||||
this.#sqlLiteCfg = c.sqlitepath;
|
||||
});
|
||||
const c = await readConfigFromStorage();
|
||||
this.#sqlLiteCfg = c.sqlitepath;
|
||||
}
|
||||
}
|
||||
/**
|
||||
|
||||
@@ -30,6 +30,8 @@ export const upsertJob = ({
|
||||
notificationAdapter,
|
||||
userId,
|
||||
shareWithUsers = [],
|
||||
spatialFilter = null,
|
||||
specFilter = null,
|
||||
}) => {
|
||||
const id = jobId || nanoid();
|
||||
const existing = SqliteConnection.query(`SELECT id, user_id FROM jobs WHERE id = @id LIMIT 1`, { id })[0];
|
||||
@@ -37,12 +39,14 @@ export const upsertJob = ({
|
||||
if (existing) {
|
||||
SqliteConnection.execute(
|
||||
`UPDATE jobs
|
||||
SET enabled = @enabled,
|
||||
name = @name,
|
||||
blacklist = @blacklist,
|
||||
provider = @provider,
|
||||
notification_adapter = @notification_adapter,
|
||||
shared_with_user = @shareWithUsers
|
||||
SET enabled = @enabled,
|
||||
name = @name,
|
||||
blacklist = @blacklist,
|
||||
provider = @provider,
|
||||
notification_adapter = @notification_adapter,
|
||||
shared_with_user = @shareWithUsers,
|
||||
spatial_filter = @spatialFilter,
|
||||
spec_filter = @specFilter
|
||||
WHERE id = @id`,
|
||||
{
|
||||
id,
|
||||
@@ -52,12 +56,14 @@ export const upsertJob = ({
|
||||
shareWithUsers: toJson(shareWithUsers ?? []),
|
||||
provider: toJson(provider ?? []),
|
||||
notification_adapter: toJson(notificationAdapter ?? []),
|
||||
spatialFilter: spatialFilter ? toJson(spatialFilter) : null,
|
||||
specFilter: specFilter ? toJson(specFilter) : null,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
SqliteConnection.execute(
|
||||
`INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter, shared_with_user)
|
||||
VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter, @shareWithUsers)`,
|
||||
`INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter, shared_with_user, spatial_filter, spec_filter)
|
||||
VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter, @shareWithUsers, @spatialFilter, @specFilter)`,
|
||||
{
|
||||
id,
|
||||
user_id: ownerId,
|
||||
@@ -67,6 +73,8 @@ export const upsertJob = ({
|
||||
provider: toJson(provider ?? []),
|
||||
shareWithUsers: toJson(shareWithUsers ?? []),
|
||||
notification_adapter: toJson(notificationAdapter ?? []),
|
||||
spatialFilter: spatialFilter ? toJson(spatialFilter) : null,
|
||||
specFilter: specFilter ? toJson(specFilter) : null,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -87,10 +95,12 @@ export const getJob = (jobId) => {
|
||||
j.provider,
|
||||
j.shared_with_user,
|
||||
j.notification_adapter AS notificationAdapter,
|
||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
|
||||
FROM jobs j
|
||||
WHERE j.id = @id
|
||||
LIMIT 1`,
|
||||
j.spatial_filter AS spatialFilter,
|
||||
j.spec_filter AS specFilter,
|
||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
|
||||
FROM jobs j
|
||||
WHERE j.id = @id
|
||||
LIMIT 1`,
|
||||
{ id: jobId },
|
||||
)[0];
|
||||
if (!row) return null;
|
||||
@@ -101,6 +111,8 @@ export const getJob = (jobId) => {
|
||||
provider: fromJson(row.provider, []),
|
||||
shared_with_user: fromJson(row.shared_with_user, []),
|
||||
notificationAdapter: fromJson(row.notificationAdapter, []),
|
||||
spatialFilter: fromJson(row.spatialFilter, null),
|
||||
specFilter: fromJson(row.specFilter, null),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -150,9 +162,12 @@ export const getJobs = () => {
|
||||
j.provider,
|
||||
j.shared_with_user,
|
||||
j.notification_adapter AS notificationAdapter,
|
||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
|
||||
FROM jobs j
|
||||
ORDER BY j.name IS NULL, j.name`,
|
||||
j.spatial_filter AS spatialFilter,
|
||||
j.spec_filter AS specFilter,
|
||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
|
||||
FROM jobs j
|
||||
WHERE j.enabled = 1
|
||||
ORDER BY j.name IS NULL, j.name`,
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
@@ -161,6 +176,8 @@ export const getJobs = () => {
|
||||
provider: fromJson(row.provider, []),
|
||||
shared_with_user: fromJson(row.shared_with_user, []),
|
||||
notificationAdapter: fromJson(row.notificationAdapter, []),
|
||||
spatialFilter: fromJson(row.spatialFilter, null),
|
||||
specFilter: fromJson(row.specFilter, null),
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -189,7 +206,7 @@ export const queryJobs = ({
|
||||
isAdmin = false,
|
||||
} = {}) => {
|
||||
// sanitize inputs
|
||||
const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(500, Math.floor(pageSize)) : 50;
|
||||
const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(1000, Math.floor(pageSize)) : 50;
|
||||
const safePage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1;
|
||||
const offset = (safePage - 1) * safePageSize;
|
||||
|
||||
@@ -250,11 +267,13 @@ export const queryJobs = ({
|
||||
j.provider,
|
||||
j.shared_with_user,
|
||||
j.notification_adapter AS notificationAdapter,
|
||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
|
||||
j.spatial_filter AS spatialFilter,
|
||||
j.spec_filter AS specFilter,
|
||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
|
||||
FROM jobs j
|
||||
${whereSql}
|
||||
${orderSql}
|
||||
LIMIT @limit OFFSET @offset`,
|
||||
${orderSql}
|
||||
LIMIT @limit OFFSET @offset`,
|
||||
params,
|
||||
);
|
||||
|
||||
@@ -265,6 +284,8 @@ export const queryJobs = ({
|
||||
provider: fromJson(row.provider, []),
|
||||
shared_with_user: fromJson(row.shared_with_user, []),
|
||||
notificationAdapter: fromJson(row.notificationAdapter, []),
|
||||
spatialFilter: fromJson(row.spatialFilter, null),
|
||||
specFilter: fromJson(row.specFilter, null),
|
||||
}));
|
||||
|
||||
return { totalNumber, page: safePage, result };
|
||||
|
||||
@@ -29,32 +29,47 @@ export const getKnownListingHashesForJobAndProvider = (jobId, providerId) => {
|
||||
* Compute KPI aggregates for a given set of job IDs from the listings table.
|
||||
*
|
||||
* - numberOfActiveListings: count of listings where is_active = 1
|
||||
* - avgPriceOfListings: average of numeric price, rounded to nearest integer
|
||||
* - medianPriceOfListings: median of numeric price, rounded to nearest integer
|
||||
*
|
||||
* When no jobIds are provided, returns zeros.
|
||||
*
|
||||
* @param {string[]} jobIds
|
||||
* @returns {{ numberOfActiveListings: number, avgPriceOfListings: number }}
|
||||
* @returns {{ numberOfActiveListings: number, medianPriceOfListings: number }}
|
||||
*/
|
||||
export const getListingsKpisForJobIds = (jobIds = []) => {
|
||||
if (!Array.isArray(jobIds) || jobIds.length === 0) {
|
||||
return { numberOfActiveListings: 0, avgPriceOfListings: 0 };
|
||||
return { numberOfActiveListings: 0, medianPriceOfListings: 0 };
|
||||
}
|
||||
|
||||
const placeholders = jobIds.map(() => '?').join(',');
|
||||
const row =
|
||||
SqliteConnection.query(
|
||||
`SELECT
|
||||
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) AS activeCount,
|
||||
AVG(price) AS avgPrice
|
||||
FROM listings
|
||||
WHERE job_id IN (${placeholders})`,
|
||||
jobIds,
|
||||
)[0] || {};
|
||||
const rows = SqliteConnection.query(
|
||||
`SELECT
|
||||
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) OVER() AS active_count,
|
||||
price
|
||||
FROM listings
|
||||
WHERE job_id IN (${placeholders})
|
||||
AND manually_deleted = 0
|
||||
GROUP BY
|
||||
id`,
|
||||
jobIds,
|
||||
);
|
||||
|
||||
const activeCount = rows[0]?.active_count ?? 0;
|
||||
|
||||
const prices = rows
|
||||
.map((r) => r.price)
|
||||
.filter((p) => p !== null)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
let medianPrice = 0;
|
||||
if (prices.length > 0) {
|
||||
const mid = Math.floor(prices.length / 2);
|
||||
medianPrice = prices.length % 2 !== 0 ? prices[mid] : (prices[mid - 1] + prices[mid]) / 2;
|
||||
}
|
||||
|
||||
return {
|
||||
numberOfActiveListings: Number(row.activeCount || 0),
|
||||
avgPriceOfListings: row?.avgPrice == null ? 0 : Math.round(Number(row.avgPrice)),
|
||||
numberOfActiveListings: activeCount,
|
||||
medianPriceOfListings: medianPrice,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -80,6 +95,7 @@ export const getProviderDistributionForJobIds = (jobIds = []) => {
|
||||
`SELECT provider, COUNT(*) AS cnt
|
||||
FROM listings
|
||||
WHERE job_id IN (${placeholders})
|
||||
AND manually_deleted = 0
|
||||
GROUP BY provider
|
||||
ORDER BY cnt DESC`,
|
||||
jobIds,
|
||||
@@ -118,8 +134,8 @@ export const getActiveOrUnknownListings = () => {
|
||||
return SqliteConnection.query(
|
||||
`SELECT *
|
||||
FROM listings
|
||||
WHERE is_active is null
|
||||
OR is_active = 1
|
||||
WHERE (is_active is null OR is_active = 1)
|
||||
AND manually_deleted = 0
|
||||
ORDER BY provider`,
|
||||
);
|
||||
};
|
||||
@@ -172,9 +188,9 @@ export const storeListings = (jobId, providerId, listings) => {
|
||||
|
||||
SqliteConnection.withTransaction((db) => {
|
||||
const stmt = db.prepare(
|
||||
`INSERT INTO listings (id, hash, provider, job_id, price, size, title, image_url, description, address,
|
||||
`INSERT INTO listings (id, hash, provider, job_id, price, size, rooms, title, image_url, description, address,
|
||||
link, created_at, is_active, latitude, longitude)
|
||||
VALUES (@id, @hash, @provider, @job_id, @price, @size, @title, @image_url, @description, @address, @link,
|
||||
VALUES (@id, @hash, @provider, @job_id, @price, @size, @rooms, @title, @image_url, @description, @address, @link,
|
||||
@created_at, 1, @latitude, @longitude)
|
||||
ON CONFLICT(job_id, hash) DO NOTHING`,
|
||||
);
|
||||
@@ -185,8 +201,9 @@ export const storeListings = (jobId, providerId, listings) => {
|
||||
hash: item.id,
|
||||
provider: providerId,
|
||||
job_id: jobId,
|
||||
price: extractNumber(item.price),
|
||||
size: extractNumber(item.size),
|
||||
price: item.price,
|
||||
size: item.size,
|
||||
rooms: item.rooms,
|
||||
title: item.title,
|
||||
image_url: item.image,
|
||||
description: item.description,
|
||||
@@ -200,19 +217,6 @@ export const storeListings = (jobId, providerId, listings) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Extract the first number from a string like "1.234 €" or "70 m²".
|
||||
* Removes dots/commas before parsing. Returns null on invalid input.
|
||||
* @param {string|undefined|null} str
|
||||
* @returns {number|null}
|
||||
*/
|
||||
function extractNumber(str) {
|
||||
if (!str) return null;
|
||||
const cleaned = str.replace(/\./g, '').replace(',', '.');
|
||||
const num = parseFloat(cleaned);
|
||||
return isNaN(num) ? null : num;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove any parentheses segments (including surrounding whitespace) from a string.
|
||||
* Returns null for empty input.
|
||||
@@ -240,6 +244,8 @@ export const storeListings = (jobId, providerId, listings) => {
|
||||
* @param {object} [params.watchListFilter]
|
||||
* @param {string|null} [params.sortField=null] - One of: 'created_at','price','size','provider','title'.
|
||||
* @param {('asc'|'desc')} [params.sortDir='asc']
|
||||
* @param {number} [params.createdAfter] - Only include listings created at or after this unix timestamp (ms).
|
||||
* @param {number} [params.createdBefore] - Only include listings created at or before this unix timestamp (ms).
|
||||
* @param {string} [params.userId] - Current user id used to scope listings (ignored for admins).
|
||||
* @param {boolean} [params.isAdmin=false] - When true, returns all listings.
|
||||
* @returns {{ totalNumber:number, page:number, result:Object[] }}
|
||||
@@ -255,11 +261,15 @@ export const queryListings = ({
|
||||
freeTextFilter,
|
||||
sortField = null,
|
||||
sortDir = 'asc',
|
||||
createdAfter = null,
|
||||
createdBefore = null,
|
||||
minPrice = null,
|
||||
maxPrice = null,
|
||||
userId = null,
|
||||
isAdmin = false,
|
||||
} = {}) => {
|
||||
// sanitize inputs
|
||||
const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(500, Math.floor(pageSize)) : 50;
|
||||
const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(1000, Math.floor(pageSize)) : 50;
|
||||
const safePage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1;
|
||||
const offset = (safePage - 1) * safePageSize;
|
||||
|
||||
@@ -305,6 +315,27 @@ export const queryListings = ({
|
||||
} else if (watchListFilter === false) {
|
||||
whereParts.push('(wl.id IS NULL)');
|
||||
}
|
||||
// Time range filters (unix timestamps in milliseconds)
|
||||
if (Number.isFinite(createdAfter) && createdAfter > 0) {
|
||||
params.createdAfter = createdAfter;
|
||||
whereParts.push('(created_at >= @createdAfter)');
|
||||
}
|
||||
if (Number.isFinite(createdBefore) && createdBefore > 0) {
|
||||
params.createdBefore = createdBefore;
|
||||
whereParts.push('(created_at <= @createdBefore)');
|
||||
}
|
||||
// Price range filters
|
||||
if (Number.isFinite(minPrice) && minPrice >= 0) {
|
||||
params.minPrice = minPrice;
|
||||
whereParts.push('(l.price >= @minPrice)');
|
||||
}
|
||||
if (Number.isFinite(maxPrice) && maxPrice >= 0) {
|
||||
params.maxPrice = maxPrice;
|
||||
whereParts.push('(l.price <= @maxPrice)');
|
||||
}
|
||||
|
||||
// Build whereSql (filtering by manually_deleted = 0)
|
||||
whereParts.push('(l.manually_deleted = 0)');
|
||||
|
||||
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||||
const whereSqlWithAlias = whereSql
|
||||
@@ -365,13 +396,21 @@ export const queryListings = ({
|
||||
* Delete all listings for a given job id.
|
||||
*
|
||||
* @param {string} jobId - The job identifier whose listings should be removed.
|
||||
* @returns {any} The result from SqliteConnection.execute (may contain changes count).
|
||||
* @param {boolean} [hardDelete=false] - Whether to hard delete from DB or just mark as deleted.
|
||||
* @returns {any} The result from SqliteConnection.execute.
|
||||
*/
|
||||
export const deleteListingsByJobId = (jobId) => {
|
||||
export const deleteListingsByJobId = (jobId, hardDelete = false) => {
|
||||
if (!jobId) return;
|
||||
if (hardDelete) {
|
||||
return SqliteConnection.execute(
|
||||
`DELETE FROM listings
|
||||
WHERE job_id = @jobId`,
|
||||
{ jobId },
|
||||
);
|
||||
}
|
||||
return SqliteConnection.execute(
|
||||
`DELETE
|
||||
FROM listings
|
||||
`UPDATE listings
|
||||
SET manually_deleted = 1
|
||||
WHERE job_id = @jobId`,
|
||||
{ jobId },
|
||||
);
|
||||
@@ -381,15 +420,23 @@ export const deleteListingsByJobId = (jobId) => {
|
||||
* Delete listings by a list of listing IDs.
|
||||
*
|
||||
* @param {string[]} ids - Array of listing IDs to delete.
|
||||
* @param {boolean} [hardDelete=false] - Whether to hard delete from DB or just mark as deleted.
|
||||
* @returns {any} The result from SqliteConnection.execute.
|
||||
*/
|
||||
export const deleteListingsById = (ids) => {
|
||||
export const deleteListingsById = (ids, hardDelete = false) => {
|
||||
if (!Array.isArray(ids) || ids.length === 0) return;
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
if (hardDelete) {
|
||||
return SqliteConnection.execute(
|
||||
`DELETE FROM listings
|
||||
WHERE id IN (${placeholders})`,
|
||||
ids,
|
||||
);
|
||||
}
|
||||
return SqliteConnection.execute(
|
||||
`DELETE
|
||||
FROM listings
|
||||
WHERE id IN (${placeholders})`,
|
||||
`UPDATE listings
|
||||
SET manually_deleted = 1
|
||||
WHERE id IN (${placeholders})`,
|
||||
ids,
|
||||
);
|
||||
};
|
||||
@@ -404,6 +451,7 @@ export const getListingsToGeocode = () => {
|
||||
`SELECT id, address
|
||||
FROM listings
|
||||
WHERE is_active = 1
|
||||
AND manually_deleted = 0
|
||||
AND address IS NOT NULL
|
||||
AND (latitude IS NULL OR longitude IS NULL)`,
|
||||
);
|
||||
@@ -443,6 +491,7 @@ export const getListingsForMap = ({ jobId, userId = null, isAdmin = false } = {}
|
||||
'l.latitude != -1',
|
||||
'l.longitude != -1',
|
||||
'l.is_active = 1',
|
||||
'l.manually_deleted = 0',
|
||||
];
|
||||
const params = { userId: userId || '__NO_USER__' };
|
||||
|
||||
@@ -479,7 +528,7 @@ export const getListingsForMap = ({ jobId, userId = null, isAdmin = false } = {}
|
||||
* @returns {{title: string|null, address: string|null, price: number|null}[]}
|
||||
*/
|
||||
export const getAllEntriesFromListings = () => {
|
||||
return SqliteConnection.query(`SELECT title, address, price FROM listings`);
|
||||
return SqliteConnection.query(`SELECT title, address, price FROM listings WHERE manually_deleted = 0`);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -493,6 +542,7 @@ export const getGeocoordinatesByAddress = (address) => {
|
||||
`SELECT latitude, longitude
|
||||
FROM listings
|
||||
WHERE address = @address
|
||||
AND manually_deleted = 0
|
||||
AND latitude IS NOT NULL
|
||||
AND longitude IS NOT NULL
|
||||
AND latitude != -1
|
||||
@@ -502,3 +552,105 @@ export const getGeocoordinatesByAddress = (address) => {
|
||||
)[0];
|
||||
return row ? { lat: row.latitude, lng: row.longitude } : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return all active listings for a given job that have geocoordinates but no distance set.
|
||||
*
|
||||
* @param {string} jobId
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
export const getListingsToCalculateDistance = (jobId) => {
|
||||
return SqliteConnection.query(
|
||||
`SELECT id, latitude, longitude
|
||||
FROM listings
|
||||
WHERE job_id = @jobId
|
||||
AND is_active = 1
|
||||
AND manually_deleted = 0
|
||||
AND latitude IS NOT NULL
|
||||
AND longitude IS NOT NULL
|
||||
AND distance_to_destination IS NULL`,
|
||||
{ jobId },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Return all active listings for a given user (across all jobs) that have geocoordinates.
|
||||
*
|
||||
* @param {string} userId
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
export const getListingsForUserToCalculateDistance = (userId) => {
|
||||
return SqliteConnection.query(
|
||||
`SELECT l.id, l.latitude, l.longitude
|
||||
FROM listings l
|
||||
JOIN jobs j ON l.job_id = j.id
|
||||
WHERE j.user_id = @userId
|
||||
AND l.is_active = 1
|
||||
AND l.manually_deleted = 0
|
||||
AND l.latitude IS NOT NULL
|
||||
AND l.longitude IS NOT NULL`,
|
||||
{ userId },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update the distance to destination for a listing.
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {number} distance
|
||||
* @returns {void}
|
||||
*/
|
||||
export const updateListingDistance = (id, distance) => {
|
||||
SqliteConnection.execute(
|
||||
`UPDATE listings
|
||||
SET distance_to_destination = @distance
|
||||
WHERE id = @id`,
|
||||
{ id, distance },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Return a single listing by id.
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {string} userId
|
||||
* @param {boolean} isAdmin
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
export const getListingById = (id, userId = null, isAdmin = false) => {
|
||||
const params = { id, userId: userId || '__NO_USER__' };
|
||||
let whereScoping = '';
|
||||
if (!isAdmin) {
|
||||
whereScoping = `AND (j.user_id = @userId OR EXISTS (SELECT 1 FROM json_each(j.shared_with_user) AS sw WHERE sw.value = @userId))`;
|
||||
}
|
||||
return (
|
||||
SqliteConnection.query(
|
||||
`SELECT l.*, j.name AS job_name, CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END AS isWatched
|
||||
FROM listings l
|
||||
LEFT JOIN jobs j ON j.id = l.job_id
|
||||
LEFT JOIN watch_list wl ON wl.listing_id = l.id AND wl.user_id = @userId
|
||||
WHERE l.id = @id AND l.manually_deleted = 0 ${whereScoping}`,
|
||||
params,
|
||||
)[0] || null
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Resets geocoordinates and distance for all listings related to a user.
|
||||
*
|
||||
* @param {string} userId
|
||||
* @returns {void}
|
||||
*/
|
||||
export const resetGeocoordinatesAndDistanceForUser = (userId) => {
|
||||
SqliteConnection.execute(
|
||||
`UPDATE listings
|
||||
SET latitude = NULL,
|
||||
longitude = NULL,
|
||||
distance_to_destination = NULL
|
||||
WHERE job_id IN (
|
||||
SELECT id FROM jobs j
|
||||
WHERE j.user_id = @userId
|
||||
)`,
|
||||
{ userId },
|
||||
);
|
||||
};
|
||||
|
||||
@@ -29,12 +29,12 @@
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { pathToFileURL, fileURLToPath } from 'url';
|
||||
import crypto from 'crypto';
|
||||
import SqliteConnection from '../SqliteConnection.js';
|
||||
import logger from '../../logger.js';
|
||||
|
||||
const ROOT = path.resolve('.');
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..');
|
||||
/**
|
||||
* Absolute path to the migrations directory (lib/services/storage/migrations/sql).
|
||||
* @type {string}
|
||||
|
||||
@@ -88,7 +88,7 @@ export function up(db) {
|
||||
}
|
||||
} catch (e) {
|
||||
// If parsing fails, let it throw to rollback the migration
|
||||
throw new Error(`Failed to import users from ${usersJsonPath}: ${e.message}`);
|
||||
throw new Error(`Failed to import users from ${usersJsonPath}: ${e.message}`, { cause: e });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ export function up(db) {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to import jobs from ${jobsJsonPath}: ${e.message}`);
|
||||
throw new Error(`Failed to import jobs from ${jobsJsonPath}: ${e.message}`, { cause: e });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
export function up(db) {
|
||||
// 1. Add manually_deleted column
|
||||
db.exec(`ALTER TABLE listings ADD COLUMN manually_deleted INTEGER NOT NULL DEFAULT 0;`);
|
||||
|
||||
// 2. Remove change_set column
|
||||
try {
|
||||
db.exec(`ALTER TABLE listings DROP COLUMN change_set;`);
|
||||
} catch {
|
||||
// if column does not exists for whatever reason
|
||||
}
|
||||
}
|
||||
11
lib/services/storage/migrations/sql/11.add-spatial-filter.js
Normal file
11
lib/services/storage/migrations/sql/11.add-spatial-filter.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
// Migration: Add spatial_filter column to jobs table for storing GeoJSON-based spatial filters
|
||||
export function up(db) {
|
||||
db.exec(`
|
||||
ALTER TABLE jobs ADD COLUMN spatial_filter JSONB DEFAULT NULL;
|
||||
`);
|
||||
}
|
||||
28
lib/services/storage/migrations/sql/12.mcp-tokens.js
Normal file
28
lib/services/storage/migrations/sql/12.mcp-tokens.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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 crypto from 'crypto';
|
||||
|
||||
// Migration: Add mcp_token column to users table.
|
||||
// Each user gets a permanent, non-expiring secret token used for MCP API authentication.
|
||||
// Tokens are auto-generated for all existing users during this migration.
|
||||
export function up(db) {
|
||||
const columns = db.prepare(`PRAGMA table_info(users)`).all();
|
||||
if (!columns.some((col) => col.name === 'mcp_token')) {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN mcp_token TEXT`);
|
||||
}
|
||||
|
||||
// Backfill all existing users that don't have a token yet
|
||||
const users = db.prepare(`SELECT id FROM users WHERE mcp_token IS NULL`).all();
|
||||
const update = db.prepare(`UPDATE users SET mcp_token = @token WHERE id = @id`);
|
||||
for (const user of users) {
|
||||
const token = `fredy_${crypto.randomBytes(32).toString('hex')}`;
|
||||
update.run({ id: user.id, token });
|
||||
}
|
||||
}
|
||||
17
lib/services/storage/migrations/sql/13.provider-details.js
Normal file
17
lib/services/storage/migrations/sql/13.provider-details.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
// We have moved the previous immoscout_details setting to provider_details and enable this by default
|
||||
// We also set it to false per default as this is increasing the chance to be detected as a bot by a lot
|
||||
export function up(db) {
|
||||
db.exec(`
|
||||
UPDATE settings
|
||||
SET name = 'provider_details', value = false
|
||||
WHERE name = 'immoscout_details'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM settings WHERE name = 'provider_details'
|
||||
);
|
||||
`);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
// Convert provider_details from a boolean to an array of provider id strings.
|
||||
// Users will re-configure which providers they want to fetch details from.
|
||||
export function up(db) {
|
||||
const row = db.prepare("SELECT value FROM settings WHERE name = 'provider_details'").get();
|
||||
if (row) {
|
||||
db.prepare("UPDATE settings SET value = ? WHERE name = 'provider_details'").run(JSON.stringify([]));
|
||||
} else {
|
||||
db.prepare("INSERT INTO settings (name, value, create_date) VALUES ('provider_details', ?, ?)").run(
|
||||
JSON.stringify([]),
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,8 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
const FEATURES = {
|
||||
WATCHLIST_MANAGEMENT: false,
|
||||
};
|
||||
|
||||
export default function getFeatures() {
|
||||
return {
|
||||
...FEATURES,
|
||||
};
|
||||
export function up(db) {
|
||||
db.exec(`
|
||||
ALTER TABLE jobs ADD COLUMN spec_filter JSONB DEFAULT NULL;
|
||||
`);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
export function up(db) {
|
||||
db.exec(`
|
||||
ALTER TABLE listings ADD COLUMN rooms INTEGER;
|
||||
`);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import { guessBaseUrl } from '../../../../utils/detectBaseUrl.js';
|
||||
|
||||
export function up(db) {
|
||||
const exists = db.prepare(`SELECT 1 FROM settings WHERE name = 'baseUrl' AND user_id IS NULL LIMIT 1`).get();
|
||||
if (exists) return;
|
||||
|
||||
const portRow = db.prepare(`SELECT value FROM settings WHERE name = 'port' AND user_id IS NULL LIMIT 1`).get();
|
||||
let port = 9998;
|
||||
try {
|
||||
port = JSON.parse(portRow?.value ?? '9998');
|
||||
} catch {
|
||||
/* keep default */
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO settings (id, create_date, name, value, user_id)
|
||||
VALUES (@id, @create_date, 'baseUrl', @value, NULL)`,
|
||||
).run({ id: nanoid(), create_date: Date.now(), value: JSON.stringify(guessBaseUrl(port)) });
|
||||
}
|
||||
12
lib/services/storage/migrations/sql/8.distances.js
Normal file
12
lib/services/storage/migrations/sql/8.distances.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
// Migration: Removing city field and adding distance field
|
||||
|
||||
export function up(db) {
|
||||
db.exec(`
|
||||
ALTER TABLE listings ADD COLUMN distance_to_destination INTEGER;
|
||||
`);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
export function up(db) {
|
||||
// 1. Remove old unique index
|
||||
db.exec(`DROP INDEX IF EXISTS idx_settings_name;`);
|
||||
|
||||
// 2. Add new unique index for name and user_id.
|
||||
// Since user_id can be NULL, we need a special index or use coalesce for the index.
|
||||
// In SQLite, multiple NULLs are allowed in a UNIQUE index, which is fine for our global settings (user_id IS NULL).
|
||||
// But we want only one global setting for a given name.
|
||||
// Actually, in SQLite, UNIQUE allows multiple NULL values.
|
||||
// To have only one NULL user_id for a name, we can use a partial index or COALESCE.
|
||||
|
||||
db.exec(`
|
||||
CREATE UNIQUE INDEX idx_settings_name_user_id ON settings (name, IFNULL(user_id, 'GLOBAL_SETTING'));
|
||||
`);
|
||||
}
|
||||
@@ -37,12 +37,25 @@ function compileSettings(rows, configValues) {
|
||||
* @returns {Record<string, any>}
|
||||
*/
|
||||
export async function refreshSettingsCache() {
|
||||
const rows = SqliteConnection.query(`SELECT name, value FROM settings`);
|
||||
const rows = SqliteConnection.query(`SELECT name, value FROM settings WHERE user_id IS NULL`);
|
||||
const configValues = await readConfigFromStorage();
|
||||
cachedSettingsConfig = compileSettings(rows, configValues);
|
||||
return cachedSettingsConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves user-specific settings from the database.
|
||||
* @param {string} userId
|
||||
* @returns {Record<string, any>}
|
||||
*/
|
||||
export function getUserSettings(userId) {
|
||||
if (!userId || typeof userId !== 'string') {
|
||||
return {};
|
||||
}
|
||||
const userRows = SqliteConnection.query(`SELECT name, value FROM settings WHERE user_id = @userId`, { userId });
|
||||
return compileSettings(userRows, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the compiled settings config. Loads it once and caches the result.
|
||||
* @returns {Record<string, any>}
|
||||
@@ -54,6 +67,19 @@ export async function getSettings() {
|
||||
return cachedSettingsConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a persistent session signing secret.
|
||||
* Generated once and stored in the settings table under the key 'session_secret'.
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function getOrCreateSessionSecret() {
|
||||
const settings = await getSettings();
|
||||
if (settings.session_secret) return settings.session_secret;
|
||||
const secret = nanoid(64);
|
||||
upsertSettings({ session_secret: secret });
|
||||
return secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert settings rows.
|
||||
* - Accepts an object map of name -> value, or an entry {name, value}.
|
||||
@@ -77,16 +103,28 @@ export function upsertSettings(settingsMapOrEntry, userId = null) {
|
||||
: Object.entries(settingsMapOrEntry || {});
|
||||
|
||||
for (const [name, rawValue] of entries) {
|
||||
const id = nanoid();
|
||||
const create_date = Date.now();
|
||||
const json = toJson(rawValue);
|
||||
SqliteConnection.execute(
|
||||
`INSERT INTO settings (id, create_date, name, value, user_id)
|
||||
if (rawValue === null) {
|
||||
SqliteConnection.execute(
|
||||
`DELETE FROM settings WHERE name = @name AND (user_id = @userId OR (user_id IS NULL AND @userId IS NULL))`,
|
||||
{
|
||||
name,
|
||||
userId,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const id = nanoid();
|
||||
const create_date = Date.now();
|
||||
const json = toJson(rawValue);
|
||||
SqliteConnection.execute(
|
||||
`INSERT INTO settings (id, create_date, name, value, user_id)
|
||||
VALUES (@id, @create_date, @name, @value, @userId)
|
||||
ON CONFLICT(name) DO UPDATE SET value = excluded.value`,
|
||||
{ id, create_date, name, value: json, userId },
|
||||
);
|
||||
ON CONFLICT(name, IFNULL(user_id, 'GLOBAL_SETTING')) DO UPDATE SET value = excluded.value`,
|
||||
{ id, create_date, name, value: json, userId },
|
||||
);
|
||||
}
|
||||
}
|
||||
// keep cache in sync (only for global settings)
|
||||
if (userId == null) {
|
||||
refreshSettingsCache();
|
||||
}
|
||||
// keep cache in sync
|
||||
refreshSettingsCache();
|
||||
}
|
||||
|
||||
@@ -5,8 +5,17 @@
|
||||
|
||||
import * as hasher from '../security/hash.js';
|
||||
import { nanoid } from 'nanoid';
|
||||
import crypto from 'crypto';
|
||||
import SqliteConnection from './SqliteConnection.js';
|
||||
import { getSettings } from './settingsStorage.js';
|
||||
import { inDevMode } from '../../utils.js';
|
||||
|
||||
/**
|
||||
* Generate a permanent, non-expiring MCP API token.
|
||||
* These tokens are secrets that never expire and are used for MCP authentication.
|
||||
* @returns {string}
|
||||
*/
|
||||
const generateMcpToken = () => `fredy_${crypto.randomBytes(32).toString('hex')}`;
|
||||
|
||||
/**
|
||||
* Get all users.
|
||||
@@ -20,7 +29,7 @@ import { getSettings } from './settingsStorage.js';
|
||||
*/
|
||||
export const getUsers = (withPassword) => {
|
||||
const rows = SqliteConnection.query(
|
||||
`SELECT u.id, u.username, u.password, u.last_login AS lastLogin, u.is_admin AS isAdmin,
|
||||
`SELECT u.id, u.username, u.password, u.last_login AS lastLogin, u.is_admin AS isAdmin, u.mcp_token AS mcpToken,
|
||||
(SELECT COUNT(1) FROM jobs j WHERE j.user_id = u.id) AS numberOfJobs
|
||||
FROM users u
|
||||
ORDER BY u.username`,
|
||||
@@ -40,7 +49,7 @@ export const getUsers = (withPassword) => {
|
||||
*/
|
||||
export const getUser = (id) => {
|
||||
const rows = SqliteConnection.query(
|
||||
`SELECT u.id, u.username, u.password, u.last_login AS lastLogin, u.is_admin AS isAdmin,
|
||||
`SELECT u.id, u.username, u.password, u.last_login AS lastLogin, u.is_admin AS isAdmin, u.mcp_token AS mcpToken,
|
||||
(SELECT COUNT(1) FROM jobs j WHERE j.user_id = u.id) AS numberOfJobs
|
||||
FROM users u
|
||||
WHERE u.id = @id
|
||||
@@ -87,14 +96,15 @@ export const upsertUser = ({ username, password, userId, isAdmin }) => {
|
||||
}
|
||||
} else {
|
||||
SqliteConnection.execute(
|
||||
`INSERT INTO users (id, username, password, last_login, is_admin)
|
||||
VALUES (@id, @username, @password, @last_login, @is_admin)`,
|
||||
`INSERT INTO users (id, username, password, last_login, is_admin, mcp_token)
|
||||
VALUES (@id, @username, @password, @last_login, @is_admin, @mcp_token)`,
|
||||
{
|
||||
id,
|
||||
username,
|
||||
password: hasher.hash(password || ''),
|
||||
last_login: null,
|
||||
is_admin: isAdmin ? 1 : 0,
|
||||
mcp_token: generateMcpToken(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -137,17 +147,21 @@ export const removeUser = (userId) => {
|
||||
export const ensureDemoUserExists = async () => {
|
||||
const settings = await getSettings();
|
||||
if (!settings.demoMode) {
|
||||
// Remove demo user (and cascade delete their jobs/listings)
|
||||
SqliteConnection.execute(`DELETE FROM users WHERE username = 'demo'`);
|
||||
if (!inDevMode()) {
|
||||
// Remove demo user (and cascade delete their jobs/listings)
|
||||
SqliteConnection.execute(`DELETE
|
||||
FROM users
|
||||
WHERE username = 'demo'`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Ensure demo user exists when demo mode is on
|
||||
const existing = SqliteConnection.query(`SELECT id FROM users WHERE username = 'demo' LIMIT 1`);
|
||||
if (existing.length === 0) {
|
||||
SqliteConnection.execute(
|
||||
`INSERT INTO users (id, username, password, last_login, is_admin)
|
||||
VALUES (@id, 'demo', @password, NULL, 1)`,
|
||||
{ id: nanoid(), password: hasher.hash('demo') },
|
||||
`INSERT INTO users (id, username, password, last_login, is_admin, mcp_token)
|
||||
VALUES (@id, 'demo', @password, NULL, 1, @mcp_token)`,
|
||||
{ id: nanoid(), password: hasher.hash('demo'), mcp_token: generateMcpToken() },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -162,13 +176,25 @@ export const ensureDemoUserExists = async () => {
|
||||
* Security: On a fresh instance, a default admin/admin is created; change this password immediately.
|
||||
* @returns {void}
|
||||
*/
|
||||
/**
|
||||
* Validate an MCP API token and return the associated user id.
|
||||
* MCP tokens are permanent secrets stored in the users table that never expire.
|
||||
* @param {string} token - The raw token string (e.g. fredy_...).
|
||||
* @returns {{ userId: string } | null} The user id or null if invalid.
|
||||
*/
|
||||
export const validateMcpToken = (token) => {
|
||||
if (!token) return null;
|
||||
const row = SqliteConnection.query(`SELECT id FROM users WHERE mcp_token = @token LIMIT 1`, { token })[0];
|
||||
return row ? { userId: row.id } : null;
|
||||
};
|
||||
|
||||
export const ensureAdminUserExists = () => {
|
||||
const anyUser = SqliteConnection.query(`SELECT id FROM users LIMIT 1`).length > 0;
|
||||
if (!anyUser) {
|
||||
SqliteConnection.execute(
|
||||
`INSERT INTO users (id, username, password, last_login, is_admin)
|
||||
VALUES (@id, 'admin', @password, @last_login, 1)`,
|
||||
{ id: nanoid(), password: hasher.hash('admin'), last_login: Date.now() },
|
||||
`INSERT INTO users (id, username, password, last_login, is_admin, mcp_token)
|
||||
VALUES (@id, 'admin', @password, @last_login, 1, @mcp_token)`,
|
||||
{ id: nanoid(), password: hasher.hash('admin'), last_login: Date.now(), mcp_token: generateMcpToken() },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -14,73 +14,89 @@ import { getSettings } from '../storage/settingsStorage.js';
|
||||
const deviceId = getUniqueId() || 'N/A';
|
||||
const version = await getPackageVersion();
|
||||
const FREDY_TRACKING_URL = 'https://fredy.orange-coding.net/tracking';
|
||||
const isDocker = process.env.IS_DOCKER != null;
|
||||
|
||||
export const trackMainEvent = async () => {
|
||||
const staticTrackingData = {
|
||||
operatingSystem: os.platform(),
|
||||
osVersion: os.release(),
|
||||
isDocker,
|
||||
arch: process.arch,
|
||||
language: process.env.LANG || 'en',
|
||||
nodeVersion: process.version || 'N/A',
|
||||
deviceId,
|
||||
version,
|
||||
};
|
||||
|
||||
const shouldTrack = async () => {
|
||||
const settings = await getSettings();
|
||||
return settings.analyticsEnabled && !inDevMode();
|
||||
};
|
||||
|
||||
const sendTrackingData = async (endpoint, payload) => {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
if (settings.analyticsEnabled && !inDevMode()) {
|
||||
const activeProvider = new Set();
|
||||
const activeAdapter = new Set();
|
||||
|
||||
const jobs = getJobs();
|
||||
|
||||
if (jobs != null && jobs.length > 0) {
|
||||
jobs.forEach((job) => {
|
||||
job.provider.forEach((provider) => activeProvider.add(provider.id));
|
||||
job.notificationAdapter.forEach((adapter) => activeAdapter.add(adapter.id));
|
||||
});
|
||||
|
||||
const trackingObj = enrichTrackingObject({
|
||||
adapter: Array.from(activeAdapter),
|
||||
provider: Array.from(activeProvider),
|
||||
});
|
||||
|
||||
await fetch(`${FREDY_TRACKING_URL}/main`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(trackingObj),
|
||||
});
|
||||
}
|
||||
const response = await fetch(`${FREDY_TRACKING_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
logger.warn(`Error sending tracking data to ${endpoint}. Status: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Error sending tracking data', error);
|
||||
logger.warn(`Error sending tracking data to ${endpoint}`, error);
|
||||
}
|
||||
};
|
||||
|
||||
export const trackMainEvent = async () => {
|
||||
if (!(await shouldTrack())) return;
|
||||
|
||||
const activeProvider = new Set();
|
||||
const activeAdapter = new Set();
|
||||
|
||||
const jobs = getJobs();
|
||||
|
||||
if (jobs != null && jobs.length > 0) {
|
||||
jobs.forEach((job) => {
|
||||
job.provider.forEach((provider) => activeProvider.add(provider.id));
|
||||
job.notificationAdapter.forEach((adapter) => activeAdapter.add(adapter.id));
|
||||
});
|
||||
|
||||
const trackingObj = await enrichTrackingObject({
|
||||
adapter: Array.from(activeAdapter),
|
||||
provider: Array.from(activeProvider),
|
||||
});
|
||||
|
||||
await sendTrackingData('/main', trackingObj);
|
||||
}
|
||||
};
|
||||
|
||||
export const trackPoi = async (poi) => {
|
||||
if (!(await shouldTrack())) return;
|
||||
|
||||
const trackingObj = await enrichTrackingObject({
|
||||
feature: poi,
|
||||
});
|
||||
|
||||
await sendTrackingData('/feature', trackingObj);
|
||||
};
|
||||
|
||||
/**
|
||||
* Note, this will only be used when Fredy runs in demo mode
|
||||
*/
|
||||
export async function trackDemoAccessed() {
|
||||
const settings = await getSettings();
|
||||
if (settings.analyticsEnabled && !inDevMode() && settings.demoMode) {
|
||||
try {
|
||||
await fetch(`${FREDY_TRACKING_URL}/demo/accessed`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('Error sending tracking data', error);
|
||||
}
|
||||
const trackingObj = await enrichTrackingObject({});
|
||||
await sendTrackingData('/demo/accessed', trackingObj);
|
||||
}
|
||||
}
|
||||
|
||||
async function enrichTrackingObject(trackingObject) {
|
||||
const settings = await getSettings();
|
||||
const operatingSystem = os.platform();
|
||||
const osVersion = os.release();
|
||||
const arch = process.arch;
|
||||
const language = process.env.LANG || 'en';
|
||||
const nodeVersion = process.version || 'N/A';
|
||||
|
||||
return {
|
||||
...trackingObject,
|
||||
...staticTrackingData,
|
||||
isDemo: settings.demoMode,
|
||||
operatingSystem,
|
||||
osVersion,
|
||||
arch,
|
||||
nodeVersion,
|
||||
language,
|
||||
deviceId,
|
||||
version,
|
||||
};
|
||||
}
|
||||
|
||||
10
lib/types/browser.js
Normal file
10
lib/types/browser.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {import('puppeteer').Browser} Browser
|
||||
*/
|
||||
|
||||
export {};
|
||||
19
lib/types/filter.js
Normal file
19
lib/types/filter.js
Normal file
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} SpecFilter
|
||||
* @property {number} [minRooms] Minimum number of rooms.
|
||||
* @property {number} [minSize] Minimum size in m².
|
||||
* @property {number} [maxPrice] Maximum price.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} SpatialFilter GeoJSON FeatureCollection.
|
||||
* @property {Array<Object>} [features] GeoJSON features for spatial filtering (typically Polygons).
|
||||
* @property {string} [type] Type 'FeatureCollection'.
|
||||
*/
|
||||
|
||||
export {};
|
||||
23
lib/types/job.js
Normal file
23
lib/types/job.js
Normal file
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
/** @import { SpecFilter, SpatialFilter } from './filter.js' */
|
||||
|
||||
/**
|
||||
* @typedef {Object} Job
|
||||
* @property {string} id Job ID.
|
||||
* @property {string} [userId] Owner user id.
|
||||
* @property {string} [name] Job display name.
|
||||
* @property {boolean} [enabled] Whether the job is enabled.
|
||||
* @property {Array<any>} [blacklist] Blacklist entries.
|
||||
* @property {Array<any>} [provider] Provider configuration list.
|
||||
* @property {Object} [notificationAdapter] Notification configuration.
|
||||
* @property {Array<string>} [shared_with_user] Users this job is shared with.
|
||||
* @property {SpatialFilter | null} [spatialFilter] Optional spatial filter configuration as GeoJSON FeatureCollection.
|
||||
* @property {SpecFilter | null} [specFilter] Optional listing specifications.
|
||||
* @property {number} [numberOfFoundListings] Count of active listings for this job.
|
||||
*/
|
||||
|
||||
export {};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user