mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ceac25aa6 | ||
|
|
34b68e1f52 | ||
|
|
6428e7ad78 | ||
|
|
2bcec04d55 | ||
|
|
ee2112a24d | ||
|
|
5a54448288 | ||
|
|
f1b8709ab7 | ||
|
|
b56e13aa16 | ||
|
|
a834abc31c | ||
|
|
573868eccb | ||
|
|
1a210d7c1c | ||
|
|
996b841cfb | ||
|
|
b2e294e38c | ||
|
|
8afeaa05d9 | ||
|
|
ec47137b89 | ||
|
|
33161de087 | ||
|
|
acab23207e | ||
|
|
2896d531e4 | ||
|
|
0cbfa25062 | ||
|
|
bcd3042026 | ||
|
|
0ce93acaf6 | ||
|
|
cabef973a2 | ||
|
|
3d0fa87d19 | ||
|
|
8b012ef2f1 | ||
|
|
6816b0aded | ||
|
|
ac02817d4e | ||
|
|
fe0a09fe1c | ||
|
|
2f00966f27 | ||
|
|
921057252d | ||
|
|
703c602527 | ||
|
|
0e29c9b9c6 | ||
|
|
f60c5859f9 | ||
|
|
ee54cc495b | ||
|
|
96582ecff4 | ||
|
|
3de82dfa41 | ||
|
|
d7ee4f6909 | ||
|
|
bf4bae9bf5 | ||
|
|
3d10dc6042 | ||
|
|
fef6d06a9d | ||
|
|
951b69a67f | ||
|
|
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 |
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
test/testFixtures/** linguist-vendored
|
||||||
1
.github/workflows/docker.yml
vendored
1
.github/workflows/docker.yml
vendored
@@ -62,6 +62,7 @@ jobs:
|
|||||||
- name: Test container with docker compose
|
- name: Test container with docker compose
|
||||||
run: |
|
run: |
|
||||||
echo "Starting container with docker compose..."
|
echo "Starting container with docker compose..."
|
||||||
|
mkdir -p ./db ./conf && chmod 777 ./db ./conf
|
||||||
docker compose up --build -d
|
docker compose up --build -d
|
||||||
echo "Waiting for container to be ready (60 seconds for start_period)..."
|
echo "Waiting for container to be ready (60 seconds for start_period)..."
|
||||||
sleep 60
|
sleep 60
|
||||||
|
|||||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -19,4 +19,4 @@ jobs:
|
|||||||
cache: 'yarn'
|
cache: 'yarn'
|
||||||
|
|
||||||
- run: yarn install
|
- run: yarn install
|
||||||
- run: yarn testGH
|
- run: yarn test:offline
|
||||||
|
|||||||
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,70 +1,54 @@
|
|||||||
# ================================
|
FROM node:22-slim
|
||||||
# Stage 1: Build stage
|
|
||||||
# ================================
|
|
||||||
FROM node:22-alpine AS builder
|
|
||||||
|
|
||||||
WORKDIR /build
|
# System deps for CloakBrowser + build tools for native modules (better-sqlite3)
|
||||||
|
# fonts-noto-color-emoji and fonts-freefont-ttf are required so canvas fingerprint
|
||||||
|
# hashes match real browsers; missing emoji fonts cause bot detection on Kasada/Akamai.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl ca-certificates fonts-liberation libasound2 \
|
||||||
|
libatk-bridge2.0-0 libatk1.0-0 libcups2 libdbus-1-3 \
|
||||||
|
libdrm2 libgbm1 libgtk-3-0 libnspr4 libnss3 \
|
||||||
|
libx11-xcb1 libxcomposite1 libxdamage1 libxrandr2 xdg-utils \
|
||||||
|
fonts-noto-color-emoji fonts-freefont-ttf \
|
||||||
|
python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& mkdir -p /db /conf /fredy
|
||||||
|
|
||||||
# Install build dependencies needed for native modules (better-sqlite3)
|
WORKDIR /fredy
|
||||||
RUN apk add --no-cache python3 make g++
|
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
IS_DOCKER=true
|
||||||
|
|
||||||
# Copy package files first for better layer caching
|
|
||||||
COPY package.json yarn.lock ./
|
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 \
|
RUN yarn config set network-timeout 600000 \
|
||||||
&& yarn --frozen-lockfile
|
&& yarn --frozen-lockfile \
|
||||||
|
&& yarn cache clean
|
||||||
|
|
||||||
|
# Pre-download the CloakBrowser stealth Chromium binary (supports x86_64 and arm64)
|
||||||
|
RUN node -e "import('cloakbrowser').then(({ensureBinary}) => ensureBinary())"
|
||||||
|
|
||||||
|
# Purge build tools now that native modules are compiled
|
||||||
|
RUN apt-get purge -y python3 make g++ \
|
||||||
|
&& apt-get autoremove -y \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy source files needed for build
|
|
||||||
COPY index.html vite.config.js ./
|
COPY index.html vite.config.js ./
|
||||||
COPY ui ./ui
|
COPY ui ./ui
|
||||||
COPY lib ./lib
|
COPY lib ./lib
|
||||||
|
|
||||||
# Build frontend assets
|
|
||||||
RUN yarn build:frontend
|
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 \
|
|
||||||
IS_DOCKER=true \
|
|
||||||
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.js ./
|
||||||
COPY index.html ./
|
|
||||||
COPY lib ./lib
|
|
||||||
|
|
||||||
# Prepare runtime directories and symlinks for data and config
|
RUN ln -s /db /fredy/db \
|
||||||
RUN mkdir -p /db /conf \
|
|
||||||
&& chown 1000:1000 /db /conf \
|
|
||||||
&& chmod 777 /db /conf \
|
|
||||||
&& ln -s /db /fredy/db \
|
|
||||||
&& ln -s /conf /fredy/conf
|
&& ln -s /conf /fredy/conf
|
||||||
|
|
||||||
EXPOSE 9998
|
EXPOSE 9998
|
||||||
VOLUME /db
|
VOLUME /db
|
||||||
VOLUME /conf
|
VOLUME /conf
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:9998/ || exit 1
|
||||||
|
|
||||||
CMD ["node", "index.js"]
|
CMD ["node", "index.js"]
|
||||||
|
|||||||
65
README.md
65
README.md
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Fredy 🏡 – Your Self-Hosted Real Estate Finder for Germany
|
# Fredy 🏡 - Your Self-Hosted Real Estate Finder for Germany
|
||||||
|
|
||||||
Finding an apartment or house in Germany can be stressful and
|
Finding an apartment or house in Germany can be stressful and
|
||||||
time-consuming.\
|
time-consuming.\
|
||||||
@@ -167,6 +167,40 @@ For more information on how to set it up and use it, please refer to the [MCP Re
|
|||||||
|
|
||||||
Immoscout has implemented advanced bot detection. In order to work around this, we are using a reversed engineered version of their mobile api. See [Immoscout Reverse Engineering Documentation](https://github.com/orangecoding/fredy/blob/master/reverse-engineered-immoscout.md)
|
Immoscout has implemented advanced bot detection. In order to work around this, we are using a reversed engineered version of their mobile api. See [Immoscout Reverse Engineering Documentation](https://github.com/orangecoding/fredy/blob/master/reverse-engineered-immoscout.md)
|
||||||
|
|
||||||
|
## 🛡️ Bot Detection & Proxies
|
||||||
|
|
||||||
|
Most browser-based providers (immowelt, immonet, kleinanzeigen, ...) are scraped through a hardened headless browser ([CloakBrowser](https://www.npmjs.com/package/cloakbrowser)). It makes the **browser fingerprint** indistinguishable from a real Chrome, which is enough when you run Fredy on a normal home connection.
|
||||||
|
|
||||||
|
On a **server / VPS the requests usually originate from a datacenter IP**, and providers behind anti-bot systems (e.g. AWS CloudFront/WAF) block those based on **IP reputation alone**, no matter how perfect the fingerprint is. The typical symptom: it works locally but you get `We have been detected as a bot :-/` on the server.
|
||||||
|
|
||||||
|
### The fix: a residential proxy
|
||||||
|
|
||||||
|
A **residential proxy** routes Fredy's browser through the internet connection of a real household, so the provider sees a "normal user" IP instead of a datacenter. For German portals, use a **German (DE) residential** (or mobile/4G) proxy. Plain VPNs and **datacenter proxies do not help** here, they share the same bad reputation as your server.
|
||||||
|
|
||||||
|
**Configure it** under **Settings → Execution → Proxy URL**. Supported formats:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://user:pass@host:port
|
||||||
|
socks5://user:pass@host:port
|
||||||
|
```
|
||||||
|
|
||||||
|
Leave the field empty to disable. The proxy applies to all headless-browser providers and takes effect on the next job run (no restart needed). Immoscout uses a separate mobile API and is not affected.
|
||||||
|
|
||||||
|
### Where to get a residential proxy
|
||||||
|
|
||||||
|
Residential proxies are a paid service (usually billed per GB, Fredy's traffic is small). Well-known providers offering German residential IPs include:
|
||||||
|
|
||||||
|
| Provider | Notes |
|
||||||
|
|---|---|
|
||||||
|
| [IPRoyal](https://iproyal.com) | Pay-as-you-go, no monthly minimum, good for low volume |
|
||||||
|
| [Webshare](https://www.webshare.io) | Cheap entry tier, has a small free plan to test with |
|
||||||
|
| [Decodo (formerly Smartproxy)](https://decodo.com) | Easy setup, country/city targeting |
|
||||||
|
| [SOAX](https://soax.com) | Residential + mobile, fine-grained geo-targeting |
|
||||||
|
| [Bright Data](https://brightdata.com) | Largest pool, most features, higher complexity/price |
|
||||||
|
| [Oxylabs](https://oxylabs.io) | Enterprise-grade, larger plans |
|
||||||
|
|
||||||
|
This is not an endorsement, pick whatever fits your budget. For low-volume use like Fredy, a pay-as-you-go plan (e.g. IPRoyal) or a cheap entry tier (e.g. Webshare) is usually plenty. Make sure to select **Germany** as the proxy location and keep the search interval reasonable (the higher the interval, the less you look like a bot).
|
||||||
|
|
||||||
## Analytics
|
## Analytics
|
||||||
|
|
||||||
Fredy is completely free (and will always remain free). However, it would be a huge help if you’d allow me to collect some analytical data.
|
Fredy is completely free (and will always remain free). However, it would be a huge help if you’d allow me to collect some analytical data.
|
||||||
@@ -188,10 +222,25 @@ You should now be able to access _Fredy_ from your browser. Check your Terminal
|
|||||||
|
|
||||||
### Run Tests
|
### Run Tests
|
||||||
|
|
||||||
|
## "Online" tests
|
||||||
|
These tests are directly executed against the actual providers.
|
||||||
``` bash
|
``` bash
|
||||||
yarn run test
|
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
|
## 📐 Architecture
|
||||||
@@ -225,6 +274,20 @@ flowchart TD
|
|||||||
F1 --> F2
|
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
|
## 👐 Contributing
|
||||||
|
|||||||
@@ -7,12 +7,72 @@ if [ "$(docker ps -aq -f name=fredy)" ]; then
|
|||||||
docker rm fredy || true
|
docker rm fredy || true
|
||||||
fi
|
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
|
# 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
|
# Run container with volumes and port mapping
|
||||||
docker run -d --name fredy \
|
if [ -n "$PLATFORM" ]; then
|
||||||
-v fredy_conf:/conf \
|
docker run -d --name fredy --platform "$PLATFORM" -v fredy_conf:/conf -v fredy_db:/db -p 9998:9998 fredy:local
|
||||||
-v fredy_db:/db \
|
else
|
||||||
-p 9998:9998 \
|
docker run -d --name fredy -v fredy_conf:/conf -v fredy_db:/db -p 9998:9998 fredy:local
|
||||||
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."
|
||||||
|
|||||||
@@ -25,12 +25,15 @@ export default [
|
|||||||
globals: {
|
globals: {
|
||||||
...globals.browser,
|
...globals.browser,
|
||||||
...globals.node,
|
...globals.node,
|
||||||
...globals.mocha,
|
...globals.jest,
|
||||||
Promise: 'readonly',
|
Promise: 'readonly',
|
||||||
fetch: 'readonly',
|
fetch: 'readonly',
|
||||||
describe: 'readonly',
|
describe: 'readonly',
|
||||||
after: 'readonly',
|
after: 'readonly',
|
||||||
it: 'readonly',
|
it: 'readonly',
|
||||||
|
beforeEach: 'readonly',
|
||||||
|
afterEach: 'readonly',
|
||||||
|
vi: 'readonly',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: { react },
|
plugins: { react },
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
|
||||||
<title>Fredy || Real Estate Finder</title>
|
<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>
|
</head>
|
||||||
<body theme-mode="dark">
|
<body theme-mode="dark">
|
||||||
<div id="fredy" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0"></div>
|
<div id="fredy" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0"></div>
|
||||||
|
|||||||
9
index.js
9
index.js
@@ -15,6 +15,15 @@ import { initGeocodingCron } from './lib/services/crons/geocoding-cron.js';
|
|||||||
import { getSettings } from './lib/services/storage/settingsStorage.js';
|
import { getSettings } from './lib/services/storage/settingsStorage.js';
|
||||||
import SqliteConnection, { computeDbPath } from './lib/services/storage/SqliteConnection.js';
|
import SqliteConnection, { computeDbPath } from './lib/services/storage/SqliteConnection.js';
|
||||||
import { initJobExecutionService } from './lib/services/jobs/jobExecutionService.js';
|
import { initJobExecutionService } from './lib/services/jobs/jobExecutionService.js';
|
||||||
|
import { ensureValidBinary } from './lib/services/ensureValidBinary.js';
|
||||||
|
|
||||||
|
// Ensure the CloakBrowser stealth Chromium binary is present and complete before
|
||||||
|
// jobs run. ensureValidBinary() also detects and auto-heals partial extractions
|
||||||
|
// (e.g. a newer version that was downloaded but only the chrome executable was
|
||||||
|
// written) so Chrome never crashes with "Invalid file descriptor to ICU data".
|
||||||
|
logger.info('Checking CloakBrowser binary...');
|
||||||
|
await ensureValidBinary();
|
||||||
|
logger.info('CloakBrowser binary ready.');
|
||||||
|
|
||||||
//in the config, we store the path of the sqlite file, thus we must check if it is available
|
//in the config, we store the path of the sqlite file, thus we must check if it is available
|
||||||
const isConfigAccessible = await checkIfConfigIsAccessible();
|
const isConfigAccessible = await checkIfConfigIsAccessible();
|
||||||
|
|||||||
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"]
|
||||||
|
}
|
||||||
@@ -5,9 +5,10 @@
|
|||||||
|
|
||||||
import { NoNewListingsWarning } from './errors.js';
|
import { NoNewListingsWarning } from './errors.js';
|
||||||
import {
|
import {
|
||||||
storeListings,
|
|
||||||
getKnownListingHashesForJobAndProvider,
|
|
||||||
deleteListingsById,
|
deleteListingsById,
|
||||||
|
getKnownListingHashesForJobAndProvider,
|
||||||
|
storeListings,
|
||||||
|
updateListingDistance,
|
||||||
} from './services/storage/listingsStorage.js';
|
} from './services/storage/listingsStorage.js';
|
||||||
import { getJob } from './services/storage/jobStorage.js';
|
import { getJob } from './services/storage/jobStorage.js';
|
||||||
import * as notify from './notification/notify.js';
|
import * as notify from './notification/notify.js';
|
||||||
@@ -16,25 +17,16 @@ import urlModifier from './services/queryStringMutator.js';
|
|||||||
import logger from './services/logger.js';
|
import logger from './services/logger.js';
|
||||||
import { geocodeAddress } from './services/geocoding/geoCodingService.js';
|
import { geocodeAddress } from './services/geocoding/geoCodingService.js';
|
||||||
import { distanceMeters } from './services/listings/distanceCalculator.js';
|
import { distanceMeters } from './services/listings/distanceCalculator.js';
|
||||||
import { getUserSettings } from './services/storage/settingsStorage.js';
|
import { getSettings, getUserSettings } from './services/storage/settingsStorage.js';
|
||||||
import { updateListingDistance } from './services/storage/listingsStorage.js';
|
|
||||||
import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
|
import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
|
||||||
|
import { formatListing } from './utils/formatListing.js';
|
||||||
|
|
||||||
/**
|
/** @import { ParsedListing } from './types/listing.js' */
|
||||||
* @typedef {Object} Listing
|
/** @import { Job } from './types/job.js' */
|
||||||
* @property {string} id Stable unique identifier (hash) of the listing.
|
/** @import { ProviderConfig } from './types/providerConfig.js' */
|
||||||
* @property {string} title Title or headline of the listing.
|
/** @import { SpecFilter, SpatialFilter } from './types/filter.js' */
|
||||||
* @property {string} [address] Optional address/location text.
|
/** @import { SimilarityCache } from './types/similarityCache.js' */
|
||||||
* @property {string} [price] Optional price text/value.
|
/** @import { Browser } from './types/browser.js' */
|
||||||
* @property {string} [url] Link to the listing detail page.
|
|
||||||
* @property {any} [meta] Provider-specific additional metadata.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} SimilarityCache
|
|
||||||
* @property {(title:string, address?:string)=>boolean} hasSimilarEntries Returns true if a similar entry is known.
|
|
||||||
* @property {(title:string, address?:string)=>void} addCacheEntry Adds a new entry to the similarity cache.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runtime orchestrator for fetching, normalizing, filtering, deduplicating, storing,
|
* Runtime orchestrator for fetching, normalizing, filtering, deduplicating, storing,
|
||||||
@@ -48,42 +40,43 @@ import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
|
|||||||
* 5) Identify new listings (vs. previously stored hashes)
|
* 5) Identify new listings (vs. previously stored hashes)
|
||||||
* 6) Persist new listings
|
* 6) Persist new listings
|
||||||
* 7) Filter out entries similar to already seen ones
|
* 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 {
|
class FredyPipelineExecutioner {
|
||||||
/**
|
/**
|
||||||
* Create a new runtime instance for a single provider/job execution.
|
* Create a new runtime instance for a single provider/job execution.
|
||||||
*
|
*
|
||||||
* @param {Object} providerConfig Provider configuration.
|
* @param {ProviderConfig} providerConfig Provider configuration.
|
||||||
* @param {string} providerConfig.url Base URL to crawl.
|
* @param {Job} job Job configuration.
|
||||||
* @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 {Object} spatialFilter Optional spatial filter configuration.
|
|
||||||
* @param {string} providerId The ID of the provider currently in use.
|
* @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 {SimilarityCache} similarityCache Cache instance for checking similar entries.
|
||||||
* @param browser
|
* @param {Browser} browser Puppeteer browser instance.
|
||||||
*/
|
*/
|
||||||
constructor(providerConfig, notificationConfig, spatialFilter, providerId, jobKey, similarityCache, browser) {
|
constructor(providerConfig, job, providerId, similarityCache, browser) {
|
||||||
|
/** @type {ProviderConfig} */
|
||||||
this._providerConfig = providerConfig;
|
this._providerConfig = providerConfig;
|
||||||
this._notificationConfig = notificationConfig;
|
/** @type {Object} */
|
||||||
this._spatialFilter = spatialFilter;
|
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._providerId = providerId;
|
||||||
this._jobKey = jobKey;
|
/** @type {SimilarityCache} */
|
||||||
this._similarityCache = similarityCache;
|
this._similarityCache = similarityCache;
|
||||||
|
/** @type {Browser} */
|
||||||
this._browser = browser;
|
this._browser = browser;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute the end-to-end pipeline for a single provider run.
|
* 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.
|
* after notifications have been sent; resolves to void when there are no new listings.
|
||||||
*/
|
*/
|
||||||
execute() {
|
execute() {
|
||||||
@@ -92,26 +85,54 @@ class FredyPipelineExecutioner {
|
|||||||
.then(this._normalize.bind(this))
|
.then(this._normalize.bind(this))
|
||||||
.then(this._filter.bind(this))
|
.then(this._filter.bind(this))
|
||||||
.then(this._findNew.bind(this))
|
.then(this._findNew.bind(this))
|
||||||
|
.then(this._fetchDetails.bind(this))
|
||||||
.then(this._geocode.bind(this))
|
.then(this._geocode.bind(this))
|
||||||
.then(this._save.bind(this))
|
.then(this._save.bind(this))
|
||||||
.then(this._calculateDistance.bind(this))
|
.then(this._calculateDistance.bind(this))
|
||||||
.then(this._filterBySimilarListings.bind(this))
|
.then(this._filterBySimilarListings.bind(this))
|
||||||
|
.then(this._filterBySpecs.bind(this))
|
||||||
.then(this._filterByArea.bind(this))
|
.then(this._filterByArea.bind(this))
|
||||||
.then(this._notify.bind(this))
|
.then(this._notify.bind(this))
|
||||||
.catch(this._handleError.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 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.
|
* Geocode new listings.
|
||||||
*
|
*
|
||||||
* @param {Listing[]} newListings New listings to geocode.
|
* @param {ParsedListing[]} newListings New listings to geocode.
|
||||||
* @returns {Promise<Listing[]>} Resolves with the listings (potentially with added coordinates).
|
* @returns {Promise<ParsedListing[]>} Resolves with the listings (potentially with added coordinates).
|
||||||
*/
|
*/
|
||||||
async _geocode(newListings) {
|
async _geocode(newListings) {
|
||||||
for (const listing of newListings) {
|
for (const listing of newListings) {
|
||||||
if (listing.address) {
|
if (listing.address) {
|
||||||
const coords = await geocodeAddress(listing.address);
|
const coords = await geocodeAddress(listing.address);
|
||||||
if (coords) {
|
if (coords && coords.lat !== -1 && coords.lng !== -1) {
|
||||||
listing.latitude = coords.lat;
|
listing.latitude = coords.lat;
|
||||||
listing.longitude = coords.lng;
|
listing.longitude = coords.lng;
|
||||||
}
|
}
|
||||||
@@ -124,18 +145,18 @@ class FredyPipelineExecutioner {
|
|||||||
* Filter listings by area using the provider's area filter if available.
|
* 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.
|
* Only filters if areaFilter is set on the provider AND the listing has coordinates.
|
||||||
*
|
*
|
||||||
* @param {Listing[]} newListings New listings to filter by area.
|
* @param {ParsedListing[]} newListings New listings to filter by area.
|
||||||
* @returns {Promise<Listing[]>} Resolves with listings that are within the area (or not filtered if no area is set).
|
* @returns {ParsedListing[]} Resolves with listings that are within the area (or not filtered if no area is set).
|
||||||
*/
|
*/
|
||||||
_filterByArea(newListings) {
|
_filterByArea(newListings) {
|
||||||
const polygonFeatures = this._spatialFilter?.features?.filter((f) => f.geometry?.type === 'Polygon');
|
const polygonFeatures = this._jobSpatialFilter?.features?.filter((f) => f.geometry?.type === 'Polygon');
|
||||||
|
|
||||||
// If no area filter is set, return all listings
|
// If no area filter is set, return all listings
|
||||||
if (!polygonFeatures?.length) {
|
if (!polygonFeatures?.length) {
|
||||||
return newListings;
|
return newListings;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredIds = [];
|
const toDeleteListingByIds = [];
|
||||||
// Filter listings by area - keep only those within the polygon
|
// Filter listings by area - keep only those within the polygon
|
||||||
const keptListings = newListings.filter((listing) => {
|
const keptListings = newListings.filter((listing) => {
|
||||||
// If listing doesn't have coordinates, keep it (don't filter out)
|
// If listing doesn't have coordinates, keep it (don't filter out)
|
||||||
@@ -148,14 +169,48 @@ class FredyPipelineExecutioner {
|
|||||||
const isInPolygon = polygonFeatures.some((feature) => booleanPointInPolygon(point, feature));
|
const isInPolygon = polygonFeatures.some((feature) => booleanPointInPolygon(point, feature));
|
||||||
|
|
||||||
if (!isInPolygon) {
|
if (!isInPolygon) {
|
||||||
filteredIds.push(listing.id);
|
toDeleteListingByIds.push(listing.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return isInPolygon;
|
return isInPolygon;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (filteredIds.length > 0) {
|
if (toDeleteListingByIds.length > 0) {
|
||||||
deleteListingsById(filteredIds);
|
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;
|
return keptListings;
|
||||||
@@ -166,13 +221,13 @@ class FredyPipelineExecutioner {
|
|||||||
* a provider-specific getListings override is supplied.
|
* a provider-specific getListings override is supplied.
|
||||||
*
|
*
|
||||||
* @param {string} url The provider URL to fetch from.
|
* @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) {
|
_getListings(url) {
|
||||||
const extractor = new Extractor({ ...this._providerConfig.puppeteerOptions, browser: this._browser });
|
const extractor = new Extractor({ ...this._providerConfig.puppeteerOptions, browser: this._browser });
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
extractor
|
extractor
|
||||||
.execute(url, this._providerConfig.waitForSelector)
|
.execute(url, this._providerConfig.waitForSelector, this._providerId)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
const listings = extractor.parseResponseText(
|
const listings = extractor.parseResponseText(
|
||||||
this._providerConfig.crawlContainer,
|
this._providerConfig.crawlContainer,
|
||||||
@@ -189,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.
|
* @param {any[]} listings Raw listing entries from the extractor or override.
|
||||||
* @returns {Listing[]} Normalized listings.
|
* @returns {ParsedListing[]} Normalized listings.
|
||||||
*/
|
*/
|
||||||
_normalize(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
|
* Filter out listings that are missing required fields and those rejected by the
|
||||||
* provider's blacklist/filter function.
|
* provider's blacklist/filter function.
|
||||||
*
|
*
|
||||||
* @param {Listing[]} listings Listings to filter.
|
* @param {ParsedListing[]} listings Listings to filter.
|
||||||
* @returns {Listing[]} Filtered listings that pass validation and provider filter.
|
* @returns {ParsedListing[]} Filtered listings that pass validation and provider filter.
|
||||||
*/
|
*/
|
||||||
_filter(listings) {
|
_filter(listings) {
|
||||||
const keys = Object.keys(this._providerConfig.crawlFields);
|
const requiredKeys = this._providerConfig.requiredFieldNames;
|
||||||
const filteredListings = listings.filter((item) => keys.every((key) => key in item));
|
const requireValues = ['id', 'link', 'title'];
|
||||||
return filteredListings.filter(this._providerConfig.filter);
|
|
||||||
|
return (
|
||||||
|
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))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine which listings are new by comparing their IDs against stored hashes.
|
* Determine which listings are new by comparing their IDs against stored hashes.
|
||||||
*
|
*
|
||||||
* @param {Listing[]} listings Listings to evaluate for novelty.
|
* @param {ParsedListing[]} listings Listings to evaluate for novelty.
|
||||||
* @returns {Listing[]} New listings not seen before.
|
* @returns {ParsedListing[]} New listings not seen before.
|
||||||
* @throws {NoNewListingsWarning} When no new listings are found.
|
* @throws {NoNewListingsWarning} When no new listings are found.
|
||||||
*/
|
*/
|
||||||
_findNew(listings) {
|
_findNew(listings) {
|
||||||
@@ -232,23 +296,32 @@ class FredyPipelineExecutioner {
|
|||||||
/**
|
/**
|
||||||
* Send notifications for new listings using the configured notification adapter(s).
|
* Send notifications for new listings using the configured notification adapter(s).
|
||||||
*
|
*
|
||||||
* @param {Listing[]} newListings New listings to notify about.
|
* @param {ParsedListing[]} newListings New listings to notify about.
|
||||||
* @returns {Promise<Listing[]>} Resolves to the provided listings after notifications complete.
|
* @returns {Promise<ParsedListing[]>} Resolves to the provided listings after notifications complete.
|
||||||
* @throws {NoNewListingsWarning} When there are no listings to notify about.
|
* @throws {NoNewListingsWarning} When there are no listings to notify about.
|
||||||
*/
|
*/
|
||||||
_notify(newListings) {
|
async _notify(newListings) {
|
||||||
if (newListings.length === 0) {
|
if (newListings.length === 0) {
|
||||||
throw new NoNewListingsWarning();
|
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);
|
return Promise.all(sendNotifications).then(() => newListings);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist new listings and pass them through.
|
* Persist new listings and pass them through.
|
||||||
*
|
*
|
||||||
* @param {Listing[]} newListings Listings to store.
|
* @param {ParsedListing[]} newListings Listings to store.
|
||||||
* @returns {Listing[]} The same listings, unchanged.
|
* @returns {ParsedListing[]} The same listings, unchanged.
|
||||||
*/
|
*/
|
||||||
_save(newListings) {
|
_save(newListings) {
|
||||||
logger.debug(`Storing ${newListings.length} new listings (Provider: '${this._providerId}')`);
|
logger.debug(`Storing ${newListings.length} new listings (Provider: '${this._providerId}')`);
|
||||||
@@ -259,8 +332,8 @@ class FredyPipelineExecutioner {
|
|||||||
/**
|
/**
|
||||||
* Calculate distance for new listings.
|
* Calculate distance for new listings.
|
||||||
*
|
*
|
||||||
* @param {Listing[]} listings
|
* @param {ParsedListing[]} listings
|
||||||
* @returns {Listing[]}
|
* @returns {ParsedListing[]}
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
_calculateDistance(listings) {
|
_calculateDistance(listings) {
|
||||||
@@ -296,8 +369,8 @@ class FredyPipelineExecutioner {
|
|||||||
* Remove listings that are similar to already known entries according to the similarity cache.
|
* Remove listings that are similar to already known entries according to the similarity cache.
|
||||||
* Adds the remaining listings to the cache.
|
* Adds the remaining listings to the cache.
|
||||||
*
|
*
|
||||||
* @param {Listing[]} listings Listings to filter by similarity.
|
* @param {ParsedListing[]} listings Listings to filter by similarity.
|
||||||
* @returns {Listing[]} Listings considered unique enough to keep.
|
* @returns {ParsedListing[]} Listings considered unique enough to keep.
|
||||||
*/
|
*/
|
||||||
_filterBySimilarListings(listings) {
|
_filterBySimilarListings(listings) {
|
||||||
const filteredIds = [];
|
const filteredIds = [];
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ export const TRACKING_POIS = {
|
|||||||
DISTANCE_ADDRESS_ENTERED: 'DISTANCE_ADDRESS_ENTERED',
|
DISTANCE_ADDRESS_ENTERED: 'DISTANCE_ADDRESS_ENTERED',
|
||||||
WELCOME_FINISHED: 'WELCOME_FINISHED',
|
WELCOME_FINISHED: 'WELCOME_FINISHED',
|
||||||
WELCOME_SKIPPED: 'WELCOME_SKIPPED',
|
WELCOME_SKIPPED: 'WELCOME_SKIPPED',
|
||||||
|
JOBS_TABLE_VIEW: 'JOBS_TABLE_VIEW',
|
||||||
|
LISTING_TABLE_VIEW: 'LISTING_TABLE_VIEW',
|
||||||
|
BASE_URL_SETTING: 'BASE_URL_SETTING',
|
||||||
|
SET_PROXY_SETTING: 'SET_PROXY_SETTING',
|
||||||
|
DETECTED_AS_BOT: 'DETECTED_AS_BOT',
|
||||||
};
|
};
|
||||||
|
|||||||
143
lib/api/api.js
143
lib/api/api.js
@@ -3,63 +3,100 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { notificationAdapterRouter } from './routes/notificationAdapterRouter.js';
|
import Fastify from 'fastify';
|
||||||
import { authInterceptor, cookieSession, adminInterceptor } from './security.js';
|
import fastifyHelmet from '@fastify/helmet';
|
||||||
import { generalSettingsRouter } from './routes/generalSettingsRoute.js';
|
import fastifyCookie from '@fastify/cookie';
|
||||||
import { providerRouter } from './routes/providerRouter.js';
|
import fastifySession from '@fastify/session';
|
||||||
import { versionRouter } from './routes/versionRouter.js';
|
import fastifyStatic from '@fastify/static';
|
||||||
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';
|
|
||||||
import files from 'serve-static';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { getDirName } from '../utils.js';
|
import { getDirName } from '../utils.js';
|
||||||
import { demoRouter } from './routes/demoRouter.js';
|
import { getSettings, getOrCreateSessionSecret } from '../services/storage/settingsStorage.js';
|
||||||
import logger from '../services/logger.js';
|
import logger from '../services/logger.js';
|
||||||
import { listingsRouter } from './routes/listingsRouter.js';
|
import { authHook, adminHook } from './security.js';
|
||||||
import { getSettings } from '../services/storage/settingsStorage.js';
|
|
||||||
import { dashboardRouter } from './routes/dashboardRouter.js';
|
import loginPlugin from './routes/loginRoute.js';
|
||||||
import { backupRouter } from './routes/backupRouter.js';
|
import demoPlugin from './routes/demoRouter.js';
|
||||||
import { trackingRouter } from './routes/trackingRoute.js';
|
import jobPlugin from './routes/jobRouter.js';
|
||||||
|
import versionPlugin from './routes/versionRouter.js';
|
||||||
|
import listingsPlugin from './routes/listingsRouter.js';
|
||||||
|
import dashboardPlugin from './routes/dashboardRouter.js';
|
||||||
|
import userSettingsPlugin from './routes/userSettingsRoute.js';
|
||||||
|
import trackingPlugin from './routes/trackingRoute.js';
|
||||||
|
import generalSettingsPlugin from './routes/generalSettingsRoute.js';
|
||||||
|
import backupPlugin from './routes/backupRouter.js';
|
||||||
|
import userPlugin from './routes/userRoute.js';
|
||||||
|
import notificationAdapterPlugin from './routes/notificationAdapterRouter.js';
|
||||||
|
import providerPlugin from './routes/providerRouter.js';
|
||||||
import { registerMcpRoutes } from '../mcp/mcpHttpRoute.js';
|
import { registerMcpRoutes } from '../mcp/mcpHttpRoute.js';
|
||||||
const service = restana();
|
|
||||||
const staticService = files(path.join(getDirName(), '../ui/public'));
|
|
||||||
const PORT = (await getSettings()).port || 9998;
|
const PORT = (await getSettings()).port || 9998;
|
||||||
|
const sessionSecret = await getOrCreateSessionSecret();
|
||||||
|
const SESSION_MAX_AGE = 2 * 60 * 60 * 1000;
|
||||||
|
|
||||||
service.use(bodyParser.json());
|
const fastify = Fastify({
|
||||||
service.use(cookieSession());
|
logger: false,
|
||||||
service.use(staticService);
|
bodyLimit: 50 * 1024 * 1024, // 50 MB for backup uploads
|
||||||
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/user/settings', authInterceptor());
|
|
||||||
service.use('/api/tracking', authInterceptor());
|
|
||||||
|
|
||||||
// /admin can only be accessed when user is having admin permissions
|
|
||||||
service.use('/api/admin', adminInterceptor());
|
|
||||||
service.use('/api/jobs/notificationAdapter', notificationAdapterRouter);
|
|
||||||
service.use('/api/admin/generalSettings', generalSettingsRouter);
|
|
||||||
service.use('/api/admin/backup', backupRouter);
|
|
||||||
service.use('/api/jobs/provider', providerRouter);
|
|
||||||
service.use('/api/admin/users', userRouter);
|
|
||||||
service.use('/api/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/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}`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Security headers (CSP disabled to avoid breaking the SPA)
|
||||||
|
await fastify.register(fastifyHelmet, { contentSecurityPolicy: false });
|
||||||
|
|
||||||
|
// Cookie + session (in-memory store, signed cookie)
|
||||||
|
await fastify.register(fastifyCookie);
|
||||||
|
await fastify.register(fastifySession, {
|
||||||
|
secret: sessionSecret,
|
||||||
|
cookieName: 'fredy-admin-session',
|
||||||
|
cookie: {
|
||||||
|
maxAge: SESSION_MAX_AGE,
|
||||||
|
httpOnly: true,
|
||||||
|
secure: false,
|
||||||
|
sameSite: 'lax',
|
||||||
|
},
|
||||||
|
saveUninitialized: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve the React SPA from ui/public/
|
||||||
|
await fastify.register(fastifyStatic, {
|
||||||
|
root: path.join(getDirName(), '../ui/public'),
|
||||||
|
wildcard: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Public routes - no auth required
|
||||||
|
fastify.register(loginPlugin, { prefix: '/api/login' });
|
||||||
|
fastify.register(demoPlugin, { prefix: '/api/demo' });
|
||||||
|
|
||||||
|
// User-authenticated routes
|
||||||
|
fastify.register(async (app) => {
|
||||||
|
app.addHook('preHandler', authHook);
|
||||||
|
app.register(jobPlugin, { prefix: '/api/jobs' });
|
||||||
|
app.register(notificationAdapterPlugin, { prefix: '/api/jobs/notificationAdapter' });
|
||||||
|
app.register(providerPlugin, { prefix: '/api/jobs/provider' });
|
||||||
|
app.register(versionPlugin, { prefix: '/api/version' });
|
||||||
|
app.register(listingsPlugin, { prefix: '/api/listings' });
|
||||||
|
app.register(dashboardPlugin, { prefix: '/api/dashboard' });
|
||||||
|
app.register(userSettingsPlugin, { prefix: '/api/user/settings' });
|
||||||
|
app.register(trackingPlugin, { prefix: '/api/tracking' });
|
||||||
|
app.register(generalSettingsPlugin, { prefix: '/api/admin/generalSettings' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin-only routes
|
||||||
|
fastify.register(async (app) => {
|
||||||
|
app.addHook('preHandler', authHook);
|
||||||
|
app.addHook('preHandler', adminHook);
|
||||||
|
app.register(backupPlugin, { prefix: '/api/admin/backup' });
|
||||||
|
app.register(userPlugin, { prefix: '/api/admin/users' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// MCP Streamable HTTP (Bearer token auth - no session)
|
||||||
|
registerMcpRoutes(fastify);
|
||||||
|
|
||||||
|
// SPA fallback - serve index.html for all non-API GET requests
|
||||||
|
fastify.setNotFoundHandler((request, reply) => {
|
||||||
|
if (!request.url.startsWith('/api/')) {
|
||||||
|
return reply.sendFile('index.html');
|
||||||
|
}
|
||||||
|
return reply.code(404).send({ error: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await fastify.listen({ port: PORT, host: '0.0.0.0' });
|
||||||
|
logger.debug(`Started API service on port ${PORT}`);
|
||||||
|
|||||||
@@ -3,73 +3,61 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import restana from 'restana';
|
|
||||||
import {
|
import {
|
||||||
buildBackupFileName,
|
buildBackupFileName,
|
||||||
createBackupZip,
|
createBackupZip,
|
||||||
precheckRestore,
|
precheckRestore,
|
||||||
restoreFromZip,
|
restoreFromZip,
|
||||||
} from '../../services/storage/backupRestoreService.js';
|
} from '../../services/storage/backupRestoreService.js';
|
||||||
|
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||||
|
import { isAdmin } from '../security.js';
|
||||||
|
|
||||||
|
const DEMO_MODE_ERROR = 'Backup and restore are not available in demo mode.';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backup & Restore Admin Router
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
*
|
|
||||||
* Endpoints:
|
|
||||||
* - GET /api/admin/backup
|
|
||||||
* Returns the current database as a zip download. Content-Type: application/zip
|
|
||||||
* - POST /api/admin/backup/restore?dryRun=true
|
|
||||||
* Accepts a zip file (raw body). Returns a compatibility report, does not restore.
|
|
||||||
* - POST /api/admin/backup/restore?force=true|false
|
|
||||||
* Accepts a zip file (raw body). Restores the database; when incompatible and force=false, returns 400.
|
|
||||||
*/
|
*/
|
||||||
const service = restana();
|
export default async function backupPlugin(fastify) {
|
||||||
const backupRouter = service.newRouter();
|
// Parse raw binary uploads as Buffer
|
||||||
|
fastify.addContentTypeParser(
|
||||||
|
['application/zip', 'application/octet-stream'],
|
||||||
|
{ parseAs: 'buffer' },
|
||||||
|
(req, body, done) => done(null, body),
|
||||||
|
);
|
||||||
|
|
||||||
backupRouter.get('/', async (req, res) => {
|
fastify.get('/', async (request, reply) => {
|
||||||
const zipBuffer = await createBackupZip();
|
const settings = await getSettings();
|
||||||
const fileName = await buildBackupFileName();
|
if (settings.demoMode && !isAdmin(request)) {
|
||||||
res.setHeader('Content-Type', 'application/zip');
|
return reply.code(403).send({ error: DEMO_MODE_ERROR });
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
|
}
|
||||||
res.send(zipBuffer);
|
const zipBuffer = await createBackupZip();
|
||||||
});
|
const fileName = await buildBackupFileName();
|
||||||
|
reply.header('Content-Type', 'application/zip');
|
||||||
|
reply.header('Content-Disposition', `attachment; filename="${fileName}"`);
|
||||||
|
return reply.send(zipBuffer);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
fastify.post('/restore', async (request, reply) => {
|
||||||
* Read the full request body as a Buffer. Used for raw zip uploads.
|
const settings = await getSettings();
|
||||||
* @param {import('http').IncomingMessage} req
|
if (settings.demoMode && !isAdmin(request)) {
|
||||||
* @returns {Promise<Buffer>}
|
return reply.code(403).send({ error: DEMO_MODE_ERROR });
|
||||||
*/
|
}
|
||||||
function readBody(req) {
|
const { dryRun = 'false', force = 'false' } = request.query || {};
|
||||||
return new Promise((resolve, reject) => {
|
const doDryRun = String(dryRun) === 'true';
|
||||||
const chunks = [];
|
const doForce = String(force) === 'true';
|
||||||
req.on('data', (c) => chunks.push(c));
|
const body = request.body; // Buffer from addContentTypeParser
|
||||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
||||||
req.on('error', (e) => reject(e));
|
if (doDryRun) {
|
||||||
|
return precheckRestore(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return restoreFromZip(body, { force: doForce });
|
||||||
|
} catch (e) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
message: e?.message || 'Restore failed',
|
||||||
|
details: e?.payload || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload endpoint. Accepts raw zip (Content-Type: application/zip or application/octet-stream)
|
|
||||||
// Query parameters:
|
|
||||||
// - dryRun=true => only validate and return compatibility info
|
|
||||||
// - force=true => proceed even if incompatible
|
|
||||||
backupRouter.post('/restore', async (req, res) => {
|
|
||||||
const { dryRun = 'false', force = 'false' } = req.query || {};
|
|
||||||
const doDryRun = String(dryRun) === 'true';
|
|
||||||
const doForce = String(force) === 'true';
|
|
||||||
const body = await readBody(req);
|
|
||||||
|
|
||||||
if (doDryRun) {
|
|
||||||
res.body = await precheckRestore(body);
|
|
||||||
return res.send();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
res.body = await restoreFromZip(body, { force: doForce });
|
|
||||||
return res.send();
|
|
||||||
} catch (e) {
|
|
||||||
res.statusCode = 400;
|
|
||||||
res.body = { message: e?.message || 'Restore failed', details: e?.payload || null };
|
|
||||||
return res.send();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export { backupRouter };
|
|
||||||
|
|||||||
@@ -3,23 +3,14 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import restana from 'restana';
|
|
||||||
import * as jobStorage from '../../services/storage/jobStorage.js';
|
import * as jobStorage from '../../services/storage/jobStorage.js';
|
||||||
import * as userStorage from '../../services/storage/userStorage.js';
|
|
||||||
import { getListingsKpisForJobIds, getProviderDistributionForJobIds } from '../../services/storage/listingsStorage.js';
|
import { getListingsKpisForJobIds, getProviderDistributionForJobIds } from '../../services/storage/listingsStorage.js';
|
||||||
import { getSettings } from '../../services/storage/settingsStorage.js';
|
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||||
|
import { isAdmin } from '../security.js';
|
||||||
|
|
||||||
const service = restana();
|
function getAccessibleJobs(request) {
|
||||||
export const dashboardRouter = service.newRouter();
|
const currentUser = request.session.currentUser;
|
||||||
|
const admin = isAdmin(request);
|
||||||
function isAdmin(req) {
|
|
||||||
const user = req.session?.currentUser ? userStorage.getUser(req.session.currentUser) : null;
|
|
||||||
return !!user?.isAdmin;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAccessibleJobs(req) {
|
|
||||||
const currentUser = req.session.currentUser;
|
|
||||||
const admin = isAdmin(req);
|
|
||||||
return jobStorage
|
return jobStorage
|
||||||
.getJobs()
|
.getJobs()
|
||||||
.filter((job) => admin || job.userId === currentUser || job.shared_with_user.includes(currentUser));
|
.filter((job) => admin || job.userId === currentUser || job.shared_with_user.includes(currentUser));
|
||||||
@@ -29,43 +20,45 @@ function cap(val) {
|
|||||||
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
|
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
dashboardRouter.get('/', async (req, res) => {
|
/**
|
||||||
const jobs = getAccessibleJobs(req);
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
const settings = await getSettings();
|
*/
|
||||||
|
export default async function dashboardPlugin(fastify) {
|
||||||
|
fastify.get('/', async (request) => {
|
||||||
|
const jobs = getAccessibleJobs(request);
|
||||||
|
const settings = await getSettings();
|
||||||
|
|
||||||
// KPIs
|
const totalJobs = jobs.length;
|
||||||
const totalJobs = jobs.length;
|
const totalListings = jobs.reduce((sum, j) => sum + (j.numberOfFoundListings || 0), 0);
|
||||||
const totalListings = jobs.reduce((sum, j) => sum + (j.numberOfFoundListings || 0), 0);
|
const jobIds = jobs.map((j) => j.id);
|
||||||
const jobIds = jobs.map((j) => j.id);
|
const { numberOfActiveListings, medianPriceOfListings } = getListingsKpisForJobIds(jobIds);
|
||||||
const { numberOfActiveListings, avgPriceOfListings } = getListingsKpisForJobIds(jobIds);
|
|
||||||
// Build Pie data in a simple shape the frontend can consume directly
|
const providerPieRaw = getProviderDistributionForJobIds(jobIds);
|
||||||
// Shape: { labels: string[], values: number[] } with values as percentages
|
const providerPie = Array.isArray(providerPieRaw)
|
||||||
const providerPieRaw = getProviderDistributionForJobIds(jobIds);
|
|
||||||
const providerPie = Array.isArray(providerPieRaw)
|
|
||||||
? {
|
|
||||||
labels: providerPieRaw.map((p) => cap(p.type)),
|
|
||||||
values: providerPieRaw.map((p) => Number(p.value) || 0),
|
|
||||||
}
|
|
||||||
: providerPieRaw && typeof providerPieRaw === 'object'
|
|
||||||
? {
|
? {
|
||||||
labels: Array.isArray(providerPieRaw.labels) ? providerPieRaw.labels : [],
|
labels: providerPieRaw.map((p) => cap(p.type)),
|
||||||
values: Array.isArray(providerPieRaw.values) ? providerPieRaw.values : [],
|
values: providerPieRaw.map((p) => Number(p.value) || 0),
|
||||||
}
|
}
|
||||||
: { labels: [], values: [] };
|
: providerPieRaw && typeof providerPieRaw === 'object'
|
||||||
|
? {
|
||||||
|
labels: Array.isArray(providerPieRaw.labels) ? providerPieRaw.labels : [],
|
||||||
|
values: Array.isArray(providerPieRaw.values) ? providerPieRaw.values : [],
|
||||||
|
}
|
||||||
|
: { labels: [], values: [] };
|
||||||
|
|
||||||
res.body = {
|
return {
|
||||||
general: {
|
general: {
|
||||||
interval: settings.interval,
|
interval: settings.interval,
|
||||||
lastRun: settings.lastRun || null,
|
lastRun: settings.lastRun || null,
|
||||||
nextRun: settings.lastRun == null ? 0 : settings.lastRun + settings.interval * 60000,
|
nextRun: settings.lastRun == null ? 0 : settings.lastRun + settings.interval * 60000,
|
||||||
},
|
},
|
||||||
kpis: {
|
kpis: {
|
||||||
totalJobs,
|
totalJobs,
|
||||||
totalListings,
|
totalListings,
|
||||||
numberOfActiveListings,
|
numberOfActiveListings,
|
||||||
avgPriceOfListings,
|
medianPriceOfListings,
|
||||||
},
|
},
|
||||||
pie: providerPie,
|
pie: providerPie,
|
||||||
};
|
};
|
||||||
res.send();
|
});
|
||||||
});
|
}
|
||||||
|
|||||||
@@ -3,15 +3,14 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import restana from 'restana';
|
|
||||||
import { getSettings } from '../../services/storage/settingsStorage.js';
|
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||||
const service = restana();
|
|
||||||
const demoRouter = service.newRouter();
|
|
||||||
|
|
||||||
demoRouter.get('/', async (req, res) => {
|
/**
|
||||||
const settings = await getSettings();
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
res.body = Object.assign({}, { demoMode: settings.demoMode });
|
*/
|
||||||
res.send();
|
export default async function demoPlugin(fastify) {
|
||||||
});
|
fastify.get('/', async () => {
|
||||||
|
const settings = await getSettings();
|
||||||
export { demoRouter };
|
return { demoMode: settings.demoMode };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,39 +3,54 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import restana from 'restana';
|
|
||||||
import { getDirName } from '../../utils.js';
|
import { getDirName } from '../../utils.js';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { ensureDemoUserExists } from '../../services/storage/userStorage.js';
|
import { ensureDemoUserExists } from '../../services/storage/userStorage.js';
|
||||||
import logger from '../../services/logger.js';
|
import logger from '../../services/logger.js';
|
||||||
import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
|
import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
|
||||||
const service = restana();
|
import { isAdmin } from '../security.js';
|
||||||
const generalSettingsRouter = service.newRouter();
|
import { trackPoi } from '../../services/tracking/Tracker.js';
|
||||||
|
import { TRACKING_POIS } from '../../TRACKING_POIS.js';
|
||||||
|
|
||||||
generalSettingsRouter.get('/', async (req, res) => {
|
/**
|
||||||
res.body = Object.assign({}, await getSettings());
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
res.send();
|
*/
|
||||||
});
|
export default async function generalSettingsPlugin(fastify) {
|
||||||
generalSettingsRouter.post('/', async (req, res) => {
|
fastify.get('/', async () => {
|
||||||
const { sqlitepath, ...appSettings } = req.body || {};
|
return Object.assign({}, await getSettings());
|
||||||
const localSettings = await getSettings();
|
});
|
||||||
|
|
||||||
if (localSettings.demoMode) {
|
fastify.post('/', async (request, reply) => {
|
||||||
res.send(new Error('In demo mode, it is not allowed to change these settings.'));
|
const { sqlitepath, ...appSettings } = request.body || {};
|
||||||
return;
|
if (typeof appSettings.baseUrl === 'string') {
|
||||||
}
|
appSettings.baseUrl = appSettings.baseUrl.trim().replace(/\/$/, '');
|
||||||
|
|
||||||
try {
|
|
||||||
if (typeof sqlitepath !== 'undefined') {
|
|
||||||
fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ sqlitepath }));
|
|
||||||
}
|
}
|
||||||
upsertSettings(appSettings);
|
const localSettings = await getSettings();
|
||||||
ensureDemoUserExists();
|
|
||||||
} catch (err) {
|
if (!isAdmin(request)) {
|
||||||
logger.error(err);
|
const reason = localSettings.demoMode
|
||||||
res.send(new Error('Error while trying to write settings.'));
|
? 'In demo mode, it is not allowed to change these settings.'
|
||||||
return;
|
: 'Only admins can change these settings.';
|
||||||
}
|
return reply.code(403).send({ error: reason });
|
||||||
res.send();
|
}
|
||||||
});
|
|
||||||
export { generalSettingsRouter };
|
try {
|
||||||
|
if (typeof sqlitepath !== 'undefined') {
|
||||||
|
fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ sqlitepath }));
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertSettings(appSettings);
|
||||||
|
ensureDemoUserExists();
|
||||||
|
if (appSettings.baseUrl != null) {
|
||||||
|
await trackPoi(TRACKING_POIS.BASE_URL_SETTING);
|
||||||
|
}
|
||||||
|
if (appSettings.proxyUrl != null) {
|
||||||
|
await trackPoi(TRACKING_POIS.SET_PROXY_SETTING);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(err);
|
||||||
|
return reply.code(500).send({ error: 'Error while trying to write settings.' });
|
||||||
|
}
|
||||||
|
return reply.send();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import restana from 'restana';
|
|
||||||
import * as jobStorage from '../../services/storage/jobStorage.js';
|
import * as jobStorage from '../../services/storage/jobStorage.js';
|
||||||
import * as userStorage from '../../services/storage/userStorage.js';
|
import * as userStorage from '../../services/storage/userStorage.js';
|
||||||
import { isAdmin } from '../security.js';
|
import { isAdmin } from '../security.js';
|
||||||
@@ -13,255 +12,234 @@ import { isRunning as isJobRunning } from '../../services/jobs/run-state.js';
|
|||||||
import { addClient as addSseClient, removeClient } from '../../services/sse/sse-broker.js';
|
import { addClient as addSseClient, removeClient } from '../../services/sse/sse-broker.js';
|
||||||
import { getSettings } from '../../services/storage/settingsStorage.js';
|
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||||
|
|
||||||
const service = restana();
|
|
||||||
const jobRouter = service.newRouter();
|
|
||||||
|
|
||||||
const DEMO_JOB_NAME = 'Demo-Job';
|
const DEMO_JOB_NAME = 'Demo-Job';
|
||||||
|
|
||||||
function doesJobBelongsToUser(job, req) {
|
function doesJobBelongsToUser(job, request) {
|
||||||
const userId = req.session.currentUser;
|
const userId = request.session.currentUser;
|
||||||
if (userId == null) {
|
if (userId == null) return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const user = userStorage.getUser(userId);
|
const user = userStorage.getUser(userId);
|
||||||
if (user == null) {
|
if (user == null) return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return user.isAdmin || job.userId === user.id;
|
return user.isAdmin || job.userId === user.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
jobRouter.get('/', async (req, res) => {
|
/**
|
||||||
const isUserAdmin = isAdmin(req);
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
//show only the jobs which belongs to the user (or all of the user is an admin)
|
*/
|
||||||
res.body = jobStorage
|
export default async function jobPlugin(fastify) {
|
||||||
.getJobs()
|
fastify.get('/', async (request) => {
|
||||||
.filter(
|
const isUserAdmin = isAdmin(request);
|
||||||
(job) =>
|
return jobStorage
|
||||||
isUserAdmin || job.userId === req.session.currentUser || job.shared_with_user.includes(req.session.currentUser),
|
.getJobs()
|
||||||
)
|
.filter(
|
||||||
.map((job) => {
|
(job) =>
|
||||||
return {
|
isUserAdmin ||
|
||||||
|
job.userId === request.session.currentUser ||
|
||||||
|
job.shared_with_user.includes(request.session.currentUser),
|
||||||
|
)
|
||||||
|
.map((job) => ({
|
||||||
...job,
|
...job,
|
||||||
running: isJobRunning(job.id),
|
running: isJobRunning(job.id),
|
||||||
isOnlyShared:
|
isOnlyShared:
|
||||||
!isUserAdmin &&
|
!isUserAdmin &&
|
||||||
job.userId !== req.session.currentUser &&
|
job.userId !== request.session.currentUser &&
|
||||||
job.shared_with_user.includes(req.session.currentUser),
|
job.shared_with_user.includes(request.session.currentUser),
|
||||||
};
|
}));
|
||||||
});
|
|
||||||
|
|
||||||
res.send();
|
|
||||||
});
|
|
||||||
|
|
||||||
jobRouter.get('/data', async (req, res) => {
|
|
||||||
const { page, pageSize = 50, activityFilter, sortfield = null, sortdir = 'asc', freeTextFilter } = req.query || {};
|
|
||||||
|
|
||||||
// normalize booleans
|
|
||||||
const toBool = (v) => {
|
|
||||||
if (v === true || v === 'true' || v === 1 || v === '1') return true;
|
|
||||||
if (v === false || v === 'false' || v === 0 || v === '0') return false;
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
const normalizedActivity = toBool(activityFilter);
|
|
||||||
|
|
||||||
const queryResult = jobStorage.queryJobs({
|
|
||||||
page: page ? parseInt(page, 10) : 1,
|
|
||||||
pageSize: pageSize ? parseInt(pageSize, 10) : 50,
|
|
||||||
freeTextFilter: freeTextFilter || null,
|
|
||||||
activityFilter: normalizedActivity,
|
|
||||||
sortField: sortfield || null,
|
|
||||||
sortDir: sortdir === 'desc' ? 'desc' : 'asc',
|
|
||||||
userId: req.session.currentUser,
|
|
||||||
isAdmin: isAdmin(req),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const isUserAdmin = isAdmin(req);
|
fastify.get('/data', async (request) => {
|
||||||
|
const {
|
||||||
|
page,
|
||||||
|
pageSize = 50,
|
||||||
|
activityFilter,
|
||||||
|
sortfield = null,
|
||||||
|
sortdir = 'asc',
|
||||||
|
freeTextFilter,
|
||||||
|
} = request.query || {};
|
||||||
|
|
||||||
// Map result to include runtime status
|
const toBool = (v) => {
|
||||||
queryResult.result = queryResult.result.map((job) => {
|
if (v === true || v === 'true' || v === 1 || v === '1') return true;
|
||||||
return {
|
if (v === false || v === 'false' || v === 0 || v === '0') return false;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const normalizedActivity = toBool(activityFilter);
|
||||||
|
|
||||||
|
const queryResult = jobStorage.queryJobs({
|
||||||
|
page: page ? parseInt(page, 10) : 1,
|
||||||
|
pageSize: pageSize ? parseInt(pageSize, 10) : 50,
|
||||||
|
freeTextFilter: freeTextFilter || null,
|
||||||
|
activityFilter: normalizedActivity,
|
||||||
|
sortField: sortfield || null,
|
||||||
|
sortDir: sortdir === 'desc' ? 'desc' : 'asc',
|
||||||
|
userId: request.session.currentUser,
|
||||||
|
isAdmin: isAdmin(request),
|
||||||
|
});
|
||||||
|
|
||||||
|
const isUserAdmin = isAdmin(request);
|
||||||
|
queryResult.result = queryResult.result.map((job) => ({
|
||||||
...job,
|
...job,
|
||||||
running: isJobRunning(job.id),
|
running: isJobRunning(job.id),
|
||||||
isOnlyShared:
|
isOnlyShared:
|
||||||
!isUserAdmin &&
|
!isUserAdmin &&
|
||||||
job.userId !== req.session.currentUser &&
|
job.userId !== request.session.currentUser &&
|
||||||
job.shared_with_user.includes(req.session.currentUser),
|
job.shared_with_user.includes(request.session.currentUser),
|
||||||
};
|
}));
|
||||||
|
|
||||||
|
return queryResult;
|
||||||
});
|
});
|
||||||
|
|
||||||
res.body = queryResult;
|
// Server-Sent Events for real-time job status updates
|
||||||
res.send();
|
fastify.get('/events', async (request, reply) => {
|
||||||
});
|
const userId = request.session?.currentUser;
|
||||||
|
if (userId == null) {
|
||||||
|
return reply.code(401).send({ message: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.hijack();
|
||||||
|
const raw = reply.raw;
|
||||||
|
raw.setHeader('Content-Type', 'text/event-stream');
|
||||||
|
raw.setHeader('Cache-Control', 'no-cache');
|
||||||
|
raw.setHeader('Connection', 'keep-alive');
|
||||||
|
|
||||||
// Server-Sent Events for job status updates
|
|
||||||
jobRouter.get('/events', async (req, res) => {
|
|
||||||
const userId = req.session.currentUser;
|
|
||||||
if (userId == null) {
|
|
||||||
res.send({ message: 'Unauthorized' }, 401);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// SSE headers
|
|
||||||
res.setHeader('Content-Type', 'text/event-stream');
|
|
||||||
res.setHeader('Cache-Control', 'no-cache');
|
|
||||||
res.setHeader('Connection', 'keep-alive');
|
|
||||||
try {
|
|
||||||
// Initial comment to establish stream
|
|
||||||
res.write(': connected\n\n');
|
|
||||||
addSseClient(userId, res);
|
|
||||||
// Cleanup on close/aborted
|
|
||||||
const onClose = () => removeClient(userId, res);
|
|
||||||
// restana exposes original req/res; use both close and finish
|
|
||||||
req.on('close', onClose);
|
|
||||||
req.on('aborted', onClose);
|
|
||||||
res.on('close', onClose);
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('Error establishing SSE connection', e);
|
|
||||||
try {
|
try {
|
||||||
res.end();
|
raw.write(': connected\n\n');
|
||||||
} catch {
|
addSseClient(userId, raw);
|
||||||
//noop
|
const onClose = () => removeClient(userId, raw);
|
||||||
|
request.raw.on('close', onClose);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Error establishing SSE connection', e);
|
||||||
|
try {
|
||||||
|
raw.end();
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
|
|
||||||
jobRouter.post('/startAll', async (req, res) => {
|
fastify.post('/startAll', async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.session.currentUser;
|
const userId = request.session.currentUser;
|
||||||
// Emit only the userId; handler will decide based on admin/ownership
|
bus.emit('jobs:runAll', { userId });
|
||||||
bus.emit('jobs:runAll', { userId });
|
return reply.code(202).send({ message: 'Run all accepted' });
|
||||||
res.send({ message: 'Run all accepted' }, 202);
|
} catch (err) {
|
||||||
} catch (err) {
|
logger.error('Failed to trigger startAll', err);
|
||||||
logger.error('Failed to trigger startAll', err);
|
return reply.code(500).send({ message: 'Unexpected error' });
|
||||||
res.send({ message: 'Unexpected error' }, 500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Trigger a single job run
|
|
||||||
jobRouter.post('/:jobId/run', async (req, res) => {
|
|
||||||
const { jobId } = req.params;
|
|
||||||
try {
|
|
||||||
const job = jobStorage.getJob(jobId);
|
|
||||||
if (!job) {
|
|
||||||
res.send({ message: 'Job not found' }, 404);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (!doesJobBelongsToUser(job, req)) {
|
});
|
||||||
res.send({ message: 'You are trying to run a job that is not associated to your user' }, 403);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isJobRunning(jobId)) {
|
|
||||||
res.send({ message: 'Job is already running' }, 409);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// fire and forget; actual execution handled by index.js listener
|
|
||||||
bus.emit('jobs:runOne', { jobId });
|
|
||||||
res.send({ message: 'Job run accepted' }, 202);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
res.send({ message: 'Unexpected error triggering job' }, 500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
jobRouter.post('/', async (req, res) => {
|
fastify.post('/:jobId/run', async (request, reply) => {
|
||||||
const {
|
const { jobId } = request.params;
|
||||||
provider,
|
try {
|
||||||
notificationAdapter,
|
const job = jobStorage.getJob(jobId);
|
||||||
name,
|
if (!job) {
|
||||||
blacklist = [],
|
return reply.code(404).send({ message: 'Job not found' });
|
||||||
jobId,
|
}
|
||||||
enabled,
|
if (!doesJobBelongsToUser(job, request)) {
|
||||||
shareWithUsers = [],
|
return reply.code(403).send({ message: 'You are trying to run a job that is not associated to your user' });
|
||||||
spatialFilter = null,
|
}
|
||||||
} = req.body;
|
if (isJobRunning(jobId)) {
|
||||||
const settings = await getSettings();
|
return reply.code(409).send({ message: 'Job is already running' });
|
||||||
try {
|
}
|
||||||
let jobFromDb = jobStorage.getJob(jobId);
|
bus.emit('jobs:runOne', { jobId });
|
||||||
|
return reply.code(202).send({ message: 'Job run accepted' });
|
||||||
if (jobFromDb && !doesJobBelongsToUser(jobFromDb, req)) {
|
} catch (error) {
|
||||||
res.send(new Error('You are trying to change a job that is not associated to your user.'));
|
logger.error(error);
|
||||||
return;
|
return reply.code(500).send({ message: 'Unexpected error triggering job' });
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (settings.demoMode && jobFromDb && jobFromDb.name === DEMO_JOB_NAME) {
|
fastify.post('/', async (request, reply) => {
|
||||||
res.send(new Error('Sorry, but you cannot change the Status of our Demo Job ;)'));
|
const {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
jobStorage.upsertJob({
|
|
||||||
userId: req.session.currentUser,
|
|
||||||
jobId,
|
|
||||||
enabled,
|
|
||||||
name,
|
|
||||||
blacklist,
|
|
||||||
provider,
|
provider,
|
||||||
notificationAdapter,
|
notificationAdapter,
|
||||||
shareWithUsers,
|
name,
|
||||||
spatialFilter,
|
blacklist = [],
|
||||||
});
|
jobId,
|
||||||
} catch (error) {
|
enabled,
|
||||||
res.send(new Error(error));
|
shareWithUsers = [],
|
||||||
logger.error(error);
|
spatialFilter = null,
|
||||||
}
|
specFilter = null,
|
||||||
res.send();
|
} = request.body;
|
||||||
});
|
const settings = await getSettings();
|
||||||
|
try {
|
||||||
|
const jobFromDb = jobStorage.getJob(jobId);
|
||||||
|
|
||||||
jobRouter.delete('', async (req, res) => {
|
if (jobFromDb && !doesJobBelongsToUser(jobFromDb, request)) {
|
||||||
const { jobId } = req.body;
|
return reply.code(403).send({ error: 'You are trying to change a job that is not associated to your user.' });
|
||||||
const settings = await getSettings();
|
}
|
||||||
try {
|
|
||||||
const job = jobStorage.getJob(jobId);
|
|
||||||
if (settings.demoMode && job.name === DEMO_JOB_NAME) {
|
|
||||||
res.send(new Error('Sorry, but you cannot remove the Demo Job ;)'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!doesJobBelongsToUser(job, req)) {
|
if (settings.demoMode && !isAdmin(request) && jobFromDb && jobFromDb.name === DEMO_JOB_NAME) {
|
||||||
res.send(new Error('You are trying to remove a job that is not associated to your user'));
|
return reply.code(403).send({ error: 'Sorry, but you cannot change the Status of our Demo Job ;)' });
|
||||||
} else {
|
}
|
||||||
jobStorage.removeJob(jobId);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
res.send(new Error(error));
|
|
||||||
logger.error(error);
|
|
||||||
}
|
|
||||||
res.send();
|
|
||||||
});
|
|
||||||
jobRouter.put('/:jobId/status', async (req, res) => {
|
|
||||||
const { status } = req.body;
|
|
||||||
const { jobId } = req.params;
|
|
||||||
const settings = await getSettings();
|
|
||||||
try {
|
|
||||||
const job = jobStorage.getJob(jobId);
|
|
||||||
|
|
||||||
if (settings.demoMode && job.name === DEMO_JOB_NAME) {
|
jobStorage.upsertJob({
|
||||||
res.send(new Error('Sorry, but you cannot change the Status of our Demo Job ;)'));
|
userId: request.session.currentUser,
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!doesJobBelongsToUser(job, req)) {
|
|
||||||
res.send(new Error('You are trying change a job that is not associated to your user'));
|
|
||||||
} else {
|
|
||||||
jobStorage.setJobStatus({
|
|
||||||
jobId,
|
jobId,
|
||||||
status,
|
enabled,
|
||||||
|
name,
|
||||||
|
blacklist,
|
||||||
|
provider,
|
||||||
|
notificationAdapter,
|
||||||
|
shareWithUsers,
|
||||||
|
spatialFilter,
|
||||||
|
specFilter,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return reply.code(500).send({ error: error.message });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
return reply.send();
|
||||||
res.send(new Error(error));
|
});
|
||||||
logger.error(error);
|
|
||||||
}
|
|
||||||
res.send();
|
|
||||||
});
|
|
||||||
|
|
||||||
jobRouter.get('/shareableUserList', async (req, res) => {
|
fastify.delete('/', async (request, reply) => {
|
||||||
const currentUser = req.session.currentUser;
|
const { jobId } = request.body;
|
||||||
const users = userStorage.getUsers(false);
|
const settings = await getSettings();
|
||||||
res.body = users
|
try {
|
||||||
.filter((user) => !user.isAdmin && user.id !== currentUser)
|
const job = jobStorage.getJob(jobId);
|
||||||
.map((user) => ({
|
if (settings.demoMode && !isAdmin(request) && job.name === DEMO_JOB_NAME) {
|
||||||
id: user.id,
|
return reply.code(403).send({ error: 'Sorry, but you cannot remove the Demo Job ;)' });
|
||||||
name: user.username,
|
}
|
||||||
}));
|
|
||||||
res.send();
|
if (!doesJobBelongsToUser(job, request)) {
|
||||||
});
|
return reply.code(403).send({ error: 'You are trying to remove a job that is not associated to your user' });
|
||||||
export { jobRouter };
|
}
|
||||||
|
jobStorage.removeJob(jobId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return reply.code(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
return reply.send();
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.put('/:jobId/status', async (request, reply) => {
|
||||||
|
const { status } = request.body;
|
||||||
|
const { jobId } = request.params;
|
||||||
|
const settings = await getSettings();
|
||||||
|
try {
|
||||||
|
const job = jobStorage.getJob(jobId);
|
||||||
|
|
||||||
|
if (settings.demoMode && !isAdmin(request) && job.name === DEMO_JOB_NAME) {
|
||||||
|
return reply.code(403).send({ error: 'Sorry, but you cannot change the Status of our Demo Job ;)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!doesJobBelongsToUser(job, request)) {
|
||||||
|
return reply.code(403).send({ error: 'You are trying change a job that is not associated to your user' });
|
||||||
|
}
|
||||||
|
jobStorage.setJobStatus({ jobId, status });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return reply.code(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
return reply.send();
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get('/shareableUserList', async (request) => {
|
||||||
|
const currentUser = request.session.currentUser;
|
||||||
|
const users = userStorage.getUsers(false);
|
||||||
|
return users
|
||||||
|
.filter((user) => !user.isAdmin && user.id !== currentUser)
|
||||||
|
.map((user) => ({
|
||||||
|
id: user.id,
|
||||||
|
name: user.username,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import restana from 'restana';
|
|
||||||
import * as listingStorage from '../../services/storage/listingsStorage.js';
|
import * as listingStorage from '../../services/storage/listingsStorage.js';
|
||||||
import * as watchListStorage from '../../services/storage/watchListStorage.js';
|
import * as watchListStorage from '../../services/storage/watchListStorage.js';
|
||||||
import { isAdmin as isAdminFn } from '../security.js';
|
import { isAdmin as isAdminFn } from '../security.js';
|
||||||
@@ -12,128 +11,114 @@ import { nullOrEmpty } from '../../utils.js';
|
|||||||
import { getJobs } from '../../services/storage/jobStorage.js';
|
import { getJobs } from '../../services/storage/jobStorage.js';
|
||||||
import { getSettings } from '../../services/storage/settingsStorage.js';
|
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||||
|
|
||||||
const service = restana();
|
/**
|
||||||
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
|
*/
|
||||||
|
export default async function listingsPlugin(fastify) {
|
||||||
|
fastify.get('/table', async (request) => {
|
||||||
|
const {
|
||||||
|
page,
|
||||||
|
pageSize = 50,
|
||||||
|
activityFilter,
|
||||||
|
jobNameFilter,
|
||||||
|
providerFilter,
|
||||||
|
watchListFilter,
|
||||||
|
sortfield = null,
|
||||||
|
sortdir = 'asc',
|
||||||
|
freeTextFilter,
|
||||||
|
} = request.query || {};
|
||||||
|
|
||||||
const listingsRouter = service.newRouter();
|
const toBool = (v) => {
|
||||||
|
if (v === true || v === 'true' || v === 1 || v === '1') return true;
|
||||||
|
if (v === false || v === 'false' || v === 0 || v === '0') return false;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const normalizedActivity = toBool(activityFilter);
|
||||||
|
const normalizedWatch = toBool(watchListFilter);
|
||||||
|
|
||||||
listingsRouter.get('/table', async (req, res) => {
|
let jobFilter = null;
|
||||||
const {
|
let jobIdFilter = null;
|
||||||
page,
|
const jobs = getJobs();
|
||||||
pageSize = 50,
|
if (!nullOrEmpty(jobNameFilter)) {
|
||||||
activityFilter,
|
const job = jobs.find((j) => j.id === jobNameFilter);
|
||||||
jobNameFilter,
|
jobFilter = job != null ? job.name : null;
|
||||||
providerFilter,
|
jobIdFilter = job != null ? job.id : null;
|
||||||
watchListFilter,
|
}
|
||||||
sortfield = null,
|
|
||||||
sortdir = 'asc',
|
|
||||||
freeTextFilter,
|
|
||||||
} = req.query || {};
|
|
||||||
|
|
||||||
// normalize booleans (accept true, 'true', 1, '1' for true; false, 'false', 0, '0' for false)
|
return listingStorage.queryListings({
|
||||||
const toBool = (v) => {
|
page: page ? parseInt(page, 10) : 1,
|
||||||
if (v === true || v === 'true' || v === 1 || v === '1') return true;
|
pageSize: pageSize ? parseInt(pageSize, 10) : 50,
|
||||||
if (v === false || v === 'false' || v === 0 || v === '0') return false;
|
freeTextFilter: freeTextFilter || null,
|
||||||
return null;
|
activityFilter: normalizedActivity,
|
||||||
};
|
jobNameFilter: jobFilter,
|
||||||
const normalizedActivity = toBool(activityFilter);
|
jobIdFilter: jobIdFilter,
|
||||||
const normalizedWatch = toBool(watchListFilter);
|
providerFilter,
|
||||||
|
watchListFilter: normalizedWatch,
|
||||||
let jobFilter = null;
|
sortField: sortfield || null,
|
||||||
let jobIdFilter = null;
|
sortDir: sortdir === 'desc' ? 'desc' : 'asc',
|
||||||
const jobs = getJobs();
|
userId: request.session.currentUser,
|
||||||
if (!nullOrEmpty(jobNameFilter)) {
|
isAdmin: isAdminFn(request),
|
||||||
const job = jobs.find((j) => j.id === jobNameFilter);
|
});
|
||||||
jobFilter = job != null ? job.name : null;
|
|
||||||
jobIdFilter = job != null ? job.id : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.body = listingStorage.queryListings({
|
|
||||||
page: page ? parseInt(page, 10) : 1,
|
|
||||||
pageSize: pageSize ? parseInt(pageSize, 10) : 50,
|
|
||||||
freeTextFilter: freeTextFilter || null,
|
|
||||||
activityFilter: normalizedActivity,
|
|
||||||
jobNameFilter: jobFilter,
|
|
||||||
jobIdFilter: jobIdFilter,
|
|
||||||
providerFilter,
|
|
||||||
watchListFilter: normalizedWatch,
|
|
||||||
sortField: sortfield || null,
|
|
||||||
sortDir: sortdir === 'desc' ? 'desc' : 'asc',
|
|
||||||
userId: req.session.currentUser,
|
|
||||||
isAdmin: isAdminFn(req),
|
|
||||||
});
|
});
|
||||||
res.send();
|
|
||||||
});
|
|
||||||
|
|
||||||
listingsRouter.get('/map', async (req, res) => {
|
fastify.get('/map', async (request) => {
|
||||||
const { jobId } = req.query || {};
|
const { jobId } = request.query || {};
|
||||||
|
return listingStorage.getListingsForMap({
|
||||||
res.body = listingStorage.getListingsForMap({
|
jobId: nullOrEmpty(jobId) ? null : jobId,
|
||||||
jobId: nullOrEmpty(jobId) ? null : jobId,
|
userId: request.session.currentUser,
|
||||||
userId: req.session.currentUser,
|
isAdmin: isAdminFn(request),
|
||||||
isAdmin: isAdminFn(req),
|
});
|
||||||
});
|
});
|
||||||
res.send();
|
|
||||||
});
|
|
||||||
|
|
||||||
listingsRouter.get('/:listingId', async (req, res) => {
|
fastify.get('/:listingId', async (request, reply) => {
|
||||||
const { listingId } = req.params;
|
const { listingId } = request.params;
|
||||||
const listing = listingStorage.getListingById(listingId, req.session.currentUser, isAdminFn(req));
|
const listing = listingStorage.getListingById(listingId, request.session.currentUser, isAdminFn(request));
|
||||||
if (!listing) {
|
if (!listing) {
|
||||||
res.statusCode = 404;
|
return reply.code(404).send({ message: 'Listing not found' });
|
||||||
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 {
|
|
||||||
const { listingId } = req.body || {};
|
|
||||||
const userId = req.session?.currentUser;
|
|
||||||
if (!listingId || !userId) {
|
|
||||||
res.statusCode = 400;
|
|
||||||
res.body = { message: 'listingId or user not provided' };
|
|
||||||
return res.send();
|
|
||||||
}
|
}
|
||||||
watchListStorage.toggleWatch(listingId, userId);
|
return listing;
|
||||||
} catch (error) {
|
});
|
||||||
logger.error(error);
|
|
||||||
res.statusCode = 500;
|
|
||||||
res.body = { message: 'Failed to toggle watch' };
|
|
||||||
}
|
|
||||||
res.send();
|
|
||||||
});
|
|
||||||
|
|
||||||
listingsRouter.delete('/job', async (req, res) => {
|
fastify.post('/watch', async (request, reply) => {
|
||||||
const { jobId, hardDelete = false } = req.body;
|
try {
|
||||||
const settings = await getSettings();
|
const { listingId } = request.body || {};
|
||||||
try {
|
const userId = request.session?.currentUser;
|
||||||
if (settings.demoMode) {
|
if (!listingId || !userId) {
|
||||||
res.send(new Error('Sorry, but you cannot remove listings in demo mode ;)'));
|
return reply.code(400).send({ message: 'listingId or user not provided' });
|
||||||
return;
|
}
|
||||||
|
watchListStorage.toggleWatch(listingId, userId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return reply.code(500).send({ message: 'Failed to toggle watch' });
|
||||||
}
|
}
|
||||||
|
return reply.send();
|
||||||
|
});
|
||||||
|
|
||||||
listingStorage.deleteListingsByJobId(jobId, hardDelete);
|
fastify.delete('/job', async (request, reply) => {
|
||||||
} catch (error) {
|
const { jobId, hardDelete = false } = request.body;
|
||||||
res.send(new Error(error));
|
const settings = await getSettings();
|
||||||
logger.error(error);
|
try {
|
||||||
}
|
if (settings.demoMode && !isAdminFn(request)) {
|
||||||
res.send();
|
return reply.code(403).send({ error: 'Sorry, but you cannot remove listings in demo mode ;)' });
|
||||||
});
|
}
|
||||||
|
listingStorage.deleteListingsByJobId(jobId, hardDelete);
|
||||||
listingsRouter.delete('/', async (req, res) => {
|
} catch (error) {
|
||||||
const { ids, hardDelete = false } = req.body;
|
logger.error(error);
|
||||||
try {
|
return reply.code(500).send({ error: error.message });
|
||||||
if (Array.isArray(ids) && ids.length > 0) {
|
|
||||||
listingStorage.deleteListingsById(ids, hardDelete);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
return reply.send();
|
||||||
res.send(new Error(error));
|
});
|
||||||
logger.error(error);
|
|
||||||
}
|
|
||||||
res.send();
|
|
||||||
});
|
|
||||||
|
|
||||||
export { listingsRouter };
|
fastify.delete('/', async (request, reply) => {
|
||||||
|
const { ids, hardDelete = false } = request.body;
|
||||||
|
try {
|
||||||
|
if (Array.isArray(ids) && ids.length > 0) {
|
||||||
|
listingStorage.deleteListingsById(ids, hardDelete);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return reply.code(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
return reply.send();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,51 +3,77 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import restana from 'restana';
|
|
||||||
import * as userStorage from '../../services/storage/userStorage.js';
|
import * as userStorage from '../../services/storage/userStorage.js';
|
||||||
import * as hasher from '../../services/security/hash.js';
|
import * as hasher from '../../services/security/hash.js';
|
||||||
import { trackDemoAccessed } from '../../services/tracking/Tracker.js';
|
import { trackDemoAccessed } from '../../services/tracking/Tracker.js';
|
||||||
import logger from '../../services/logger.js';
|
import logger from '../../services/logger.js';
|
||||||
import { getSettings } from '../../services/storage/settingsStorage.js';
|
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||||
const service = restana();
|
|
||||||
const loginRouter = service.newRouter();
|
const MAX_LOGIN_ATTEMPTS = 10;
|
||||||
loginRouter.get('/user', async (req, res) => {
|
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
||||||
const currentUserId = req.session.currentUser;
|
const loginAttempts = new Map();
|
||||||
const currentUser = currentUserId == null ? null : userStorage.getUser(currentUserId);
|
|
||||||
if (currentUser == null) {
|
function getClientIp(request) {
|
||||||
res.body = {};
|
const forwarded = request.headers['x-forwarded-for'];
|
||||||
} else {
|
return (forwarded ? forwarded.split(',')[0] : request.socket?.remoteAddress) || 'unknown';
|
||||||
res.body = {
|
}
|
||||||
|
|
||||||
|
function isRateLimited(ip) {
|
||||||
|
const now = Date.now();
|
||||||
|
const record = loginAttempts.get(ip);
|
||||||
|
if (!record || now - record.firstAttempt > LOGIN_WINDOW_MS) {
|
||||||
|
loginAttempts.set(ip, { count: 1, firstAttempt: now });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
record.count++;
|
||||||
|
return record.count > MAX_LOGIN_ATTEMPTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
|
*/
|
||||||
|
export default async function loginPlugin(fastify) {
|
||||||
|
fastify.get('/user', async (request) => {
|
||||||
|
const currentUserId = request.session?.currentUser;
|
||||||
|
const currentUser = currentUserId == null ? null : userStorage.getUser(currentUserId);
|
||||||
|
if (currentUser == null) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return {
|
||||||
userId: currentUser.id,
|
userId: currentUser.id,
|
||||||
isAdmin: currentUser.isAdmin,
|
isAdmin: currentUser.isAdmin,
|
||||||
};
|
};
|
||||||
}
|
});
|
||||||
res.send();
|
|
||||||
});
|
|
||||||
loginRouter.post('/', async (req, res) => {
|
|
||||||
const settings = await getSettings();
|
|
||||||
const { username, password } = req.body;
|
|
||||||
const user = userStorage.getUsers(true).find((user) => user.username === username);
|
|
||||||
if (user == null) {
|
|
||||||
res.send(401);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (user.password === hasher.hash(password)) {
|
|
||||||
if (settings.demoMode) {
|
|
||||||
await trackDemoAccessed();
|
|
||||||
}
|
|
||||||
|
|
||||||
req.session.currentUser = user.id;
|
fastify.post('/', async (request, reply) => {
|
||||||
userStorage.setLastLoginToNow({ userId: user.id });
|
const ip = getClientIp(request);
|
||||||
res.send(200);
|
if (isRateLimited(ip)) {
|
||||||
return;
|
logger.error(`Login rate limit exceeded for IP ${ip}`);
|
||||||
} else {
|
return reply.code(429).send();
|
||||||
logger.error(`User ${username} tried to login, but password was wrong.`);
|
}
|
||||||
}
|
const settings = await getSettings();
|
||||||
res.send(401);
|
const { username, password } = request.body;
|
||||||
});
|
const user = userStorage.getUsers(true).find((u) => u.username === username);
|
||||||
loginRouter.post('/logout', async (req, res) => {
|
if (user == null) {
|
||||||
req.session = null;
|
return reply.code(401).send();
|
||||||
res.send(200);
|
}
|
||||||
});
|
if (user.password === hasher.hash(password)) {
|
||||||
export { loginRouter };
|
if (settings.demoMode) {
|
||||||
|
await trackDemoAccessed();
|
||||||
|
}
|
||||||
|
request.session.currentUser = user.id;
|
||||||
|
request.session.createdAt = Date.now();
|
||||||
|
loginAttempts.delete(ip);
|
||||||
|
userStorage.setLastLoginToNow({ userId: user.id });
|
||||||
|
return reply.code(200).send();
|
||||||
|
} else {
|
||||||
|
logger.error(`User ${username} tried to login, but password was wrong.`);
|
||||||
|
}
|
||||||
|
return reply.code(401).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post('/logout', async (request, reply) => {
|
||||||
|
await request.session.destroy();
|
||||||
|
return reply.code(200).send();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,62 +4,64 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import restana from 'restana';
|
|
||||||
import logger from '../../services/logger.js';
|
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'));
|
const notificationAdapterList = fs.readdirSync('./lib//notification/adapter').filter((file) => file.endsWith('.js'));
|
||||||
const notificationAdapter = await Promise.all(
|
const notificationAdapter = await Promise.all(
|
||||||
notificationAdapterList.map(async (pro) => {
|
notificationAdapterList.map(async (pro) => {
|
||||||
return await import(`../../notification/adapter/${pro}`);
|
return await import(`../../notification/adapter/${pro}`);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
notificationAdapterRouter.post('/try', async (req, res) => {
|
|
||||||
const { id, fields } = req.body;
|
/**
|
||||||
const adapter = notificationAdapter.find((adapter) => adapter.config.id === id);
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
if (adapter == null) {
|
*/
|
||||||
res.send(404);
|
export default async function notificationAdapterPlugin(fastify) {
|
||||||
}
|
fastify.get('/', async () => {
|
||||||
const notificationConfig = [];
|
return notificationAdapter.map((adapter) => adapter.config).filter(Boolean);
|
||||||
const notificationObject = {};
|
|
||||||
Object.keys(fields).forEach((key) => {
|
|
||||||
notificationObject[key] = fields[key].value;
|
|
||||||
});
|
});
|
||||||
notificationConfig.push({
|
|
||||||
fields: { ...notificationObject },
|
fastify.post('/try', async (request, reply) => {
|
||||||
enabled: true,
|
const { id, fields } = request.body;
|
||||||
id,
|
const adapter = notificationAdapter.find((adapter) => adapter.config.id === id);
|
||||||
});
|
if (adapter == null) {
|
||||||
try {
|
return reply.code(404).send();
|
||||||
await adapter.send({
|
}
|
||||||
serviceName: 'TestCall',
|
const notificationConfig = [];
|
||||||
newListings: [
|
const notificationObject = {};
|
||||||
{
|
Object.keys(fields).forEach((key) => {
|
||||||
address: 'Heidestrasse 17, 51147 Köln',
|
notificationObject[key] = fields[key].value;
|
||||||
description: exampleDescription,
|
|
||||||
id: '1',
|
|
||||||
imageUrl: 'https://placehold.co/600x400/png',
|
|
||||||
price: '1.000 €',
|
|
||||||
size: '76 m²',
|
|
||||||
title: 'Stilvolle gepflegte 3-Raum-Wohnung mit gehobener Innenausstattung',
|
|
||||||
url: 'https://www.orange-coding.net',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
notificationConfig,
|
|
||||||
jobKey: 'TestJob',
|
|
||||||
});
|
});
|
||||||
res.send();
|
notificationConfig.push({
|
||||||
} catch (Exception) {
|
fields: { ...notificationObject },
|
||||||
logger.error('Error during notification adapter test:', Exception);
|
enabled: true,
|
||||||
res.send(new Error(Exception));
|
id,
|
||||||
}
|
});
|
||||||
});
|
try {
|
||||||
notificationAdapterRouter.get('/', async (req, res) => {
|
await adapter.send({
|
||||||
res.body = notificationAdapter.map((adapter) => adapter.config);
|
serviceName: 'TestCall',
|
||||||
res.send();
|
newListings: [
|
||||||
});
|
{
|
||||||
export { notificationAdapterRouter };
|
address: 'Heidestrasse 17, 51147 Köln',
|
||||||
|
description: exampleDescription,
|
||||||
|
id: '1',
|
||||||
|
imageUrl: 'https://placehold.co/600x400/png',
|
||||||
|
price: '1.000 €',
|
||||||
|
size: '76 m²',
|
||||||
|
title: 'Stilvolle gepflegte 3-Raum-Wohnung mit gehobener Innenausstattung',
|
||||||
|
url: 'https://www.orange-coding.net',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
notificationConfig,
|
||||||
|
jobKey: 'TestJob',
|
||||||
|
});
|
||||||
|
return reply.send();
|
||||||
|
} catch (Exception) {
|
||||||
|
logger.error('Error during notification adapter test:', Exception);
|
||||||
|
return reply.code(500).send({ error: String(Exception) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const exampleDescription = `
|
const exampleDescription = `
|
||||||
Wohnungstyp: Etagenwohnung
|
Wohnungstyp: Etagenwohnung
|
||||||
@@ -94,7 +96,7 @@ Die Wohnung ist ideal für Paare oder kleine Familien geeignet.
|
|||||||
Ausstattung:
|
Ausstattung:
|
||||||
- neuer hochwertiger Bodenbelag (Holzoptik) in allen Räumen außer Bad/Küche
|
- neuer hochwertiger Bodenbelag (Holzoptik) in allen Räumen außer Bad/Küche
|
||||||
- sonniger Balkon (Süd)
|
- sonniger Balkon (Süd)
|
||||||
- Tiefgaragenstellplatz
|
- Tiefgaragenstellplatz
|
||||||
- Kellerabteil
|
- Kellerabteil
|
||||||
- gepflegtes Mehrfamilienhaus
|
- gepflegtes Mehrfamilienhaus
|
||||||
|
|
||||||
@@ -104,7 +106,7 @@ Vermietung direkt vom Eigentümer - provisionsfrei!
|
|||||||
|
|
||||||
Lage:
|
Lage:
|
||||||
• Park: 1 Minute zu Fuß
|
• Park: 1 Minute zu Fuß
|
||||||
• S-Bahn Station: 2 Minuten zu Fuß
|
• S-Bahn Station: 2 Minuten zu Fuß
|
||||||
• Supermärkte, Restaurants, täglicher Bedarf in der Nähe
|
• Supermärkte, Restaurants, täglicher Bedarf in der Nähe
|
||||||
• Gute Anbindung Richtung Großstadt und Flughafen
|
• Gute Anbindung Richtung Großstadt und Flughafen
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -4,17 +4,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import restana from 'restana';
|
|
||||||
const service = restana();
|
|
||||||
const providerRouter = service.newRouter();
|
|
||||||
const providerList = fs.readdirSync('./lib/provider').filter((file) => file.endsWith('.js'));
|
const providerList = fs.readdirSync('./lib/provider').filter((file) => file.endsWith('.js'));
|
||||||
const provider = await Promise.all(
|
const providers = await Promise.all(providerList.map(async (pro) => import(`../../provider/${pro}`)));
|
||||||
providerList.map(async (pro) => {
|
|
||||||
return await import(`../../provider/${pro}`);
|
/**
|
||||||
}),
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
);
|
*/
|
||||||
providerRouter.get('/', async (req, res) => {
|
export default async function providerPlugin(fastify) {
|
||||||
res.body = provider.map((p) => p.metaInformation);
|
fastify.get('/', async () => {
|
||||||
res.send();
|
return providers.map((p) => p.metaInformation);
|
||||||
});
|
});
|
||||||
export { providerRouter };
|
}
|
||||||
|
|||||||
@@ -3,35 +3,29 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import restana from 'restana';
|
|
||||||
import { trackPoi } from '../../services/tracking/Tracker.js';
|
import { trackPoi } from '../../services/tracking/Tracker.js';
|
||||||
import { TRACKING_POIS } from '../../TRACKING_POIS.js';
|
import { TRACKING_POIS } from '../../TRACKING_POIS.js';
|
||||||
import logger from '../../services/logger.js';
|
import logger from '../../services/logger.js';
|
||||||
|
|
||||||
const service = restana();
|
/**
|
||||||
const trackingRouter = service.newRouter();
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
|
*/
|
||||||
|
export default async function trackingPlugin(fastify) {
|
||||||
|
fastify.get('/trackingPois', async () => {
|
||||||
|
return TRACKING_POIS;
|
||||||
|
});
|
||||||
|
|
||||||
trackingRouter.get('/trackingPois', async (req, res) => {
|
fastify.post('/poi', async (request, reply) => {
|
||||||
res.body = TRACKING_POIS;
|
const { poi } = request.body;
|
||||||
res.send();
|
if (!poi) {
|
||||||
});
|
return reply.code(400).send({ error: 'Feature name is required' });
|
||||||
|
}
|
||||||
trackingRouter.post('/poi', async (req, res) => {
|
try {
|
||||||
const { poi } = req.body;
|
await trackPoi(poi);
|
||||||
if (!poi) {
|
return { success: true };
|
||||||
res.statusCode = 400;
|
} catch (error) {
|
||||||
res.send({ error: 'Feature name is required' });
|
logger.error('Error tracking feature', error);
|
||||||
return;
|
return reply.code(500).send({ error: error.message });
|
||||||
}
|
}
|
||||||
|
});
|
||||||
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 };
|
|
||||||
|
|||||||
@@ -3,81 +3,73 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import restana from 'restana';
|
|
||||||
import * as userStorage from '../../services/storage/userStorage.js';
|
import * as userStorage from '../../services/storage/userStorage.js';
|
||||||
import * as jobStorage from '../../services/storage/jobStorage.js';
|
import * as jobStorage from '../../services/storage/jobStorage.js';
|
||||||
import { getSettings } from '../../services/storage/settingsStorage.js';
|
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||||
const service = restana();
|
import { isAdmin as isAdminUser } from '../security.js';
|
||||||
const userRouter = service.newRouter();
|
|
||||||
function checkIfAnyAdminAfterRemovingUser(userIdToBeRemoved, allUser) {
|
function checkIfAnyAdminAfterRemovingUser(userIdToBeRemoved, allUser) {
|
||||||
return allUser.filter((user) => user.id !== userIdToBeRemoved && user.isAdmin).length > 0;
|
return allUser.filter((user) => user.id !== userIdToBeRemoved && user.isAdmin).length > 0;
|
||||||
}
|
}
|
||||||
function checkIfUserToBeRemovedIsLoggedIn(userIdToBeRemoved, req) {
|
|
||||||
return req.session.currentUser === userIdToBeRemoved;
|
function checkIfUserToBeRemovedIsLoggedIn(userIdToBeRemoved, request) {
|
||||||
|
return request.session.currentUser === userIdToBeRemoved;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nullOrEmpty = (str) => str == null || str.length === 0;
|
const nullOrEmpty = (str) => str == null || str.length === 0;
|
||||||
|
|
||||||
userRouter.get('/', async (req, res) => {
|
/**
|
||||||
res.body = userStorage.getUsers(false);
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
res.send();
|
*/
|
||||||
});
|
export default async function userPlugin(fastify) {
|
||||||
|
fastify.get('/', async () => {
|
||||||
userRouter.get('/:userId', async (req, res) => {
|
return userStorage.getUsers(false);
|
||||||
const { userId } = req.params;
|
|
||||||
res.body = userStorage.getUser(userId);
|
|
||||||
res.send();
|
|
||||||
});
|
|
||||||
userRouter.delete('/', async (req, res) => {
|
|
||||||
const settings = await getSettings();
|
|
||||||
if (settings.demoMode) {
|
|
||||||
res.send(new Error('In demo mode, it is not allowed to remove user.'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { userId } = req.body;
|
|
||||||
const allUser = userStorage.getUsers(false);
|
|
||||||
if (!checkIfAnyAdminAfterRemovingUser(userId, allUser)) {
|
|
||||||
res.send(new Error('You are trying to remove the last admin user. This is prohibited.'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (checkIfUserToBeRemovedIsLoggedIn(userId, req)) {
|
|
||||||
res.send(new Error('You are trying to remove yourself. This is prohibited.'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
//TODO: Remove also analytics
|
|
||||||
jobStorage.removeJobsByUserId(userId);
|
|
||||||
userStorage.removeUser(userId);
|
|
||||||
res.send();
|
|
||||||
});
|
|
||||||
userRouter.post('/', async (req, res) => {
|
|
||||||
const settings = await getSettings();
|
|
||||||
if (settings.demoMode) {
|
|
||||||
res.send(new Error('In demo mode, it is not allowed to change or add user.'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { username, password, password2, isAdmin, userId } = req.body;
|
|
||||||
if (password !== password2) {
|
|
||||||
res.send(new Error('Passwords does not match'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (nullOrEmpty(username) || nullOrEmpty(password) || nullOrEmpty(password2)) {
|
|
||||||
res.send(new Error('Username and password are mandatory.'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const allUser = userStorage.getUsers(false);
|
|
||||||
if (!isAdmin && !checkIfAnyAdminAfterRemovingUser(userId, allUser)) {
|
|
||||||
res.send(
|
|
||||||
new Error('You cannot change the admin flag for this user as otherwise, there is no other user in the system'),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
userStorage.upsertUser({
|
|
||||||
userId,
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
isAdmin,
|
|
||||||
});
|
});
|
||||||
res.send();
|
|
||||||
});
|
fastify.get('/:userId', async (request) => {
|
||||||
export { userRouter };
|
const { userId } = request.params;
|
||||||
|
return userStorage.getUser(userId);
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.delete('/', async (request, reply) => {
|
||||||
|
const settings = await getSettings();
|
||||||
|
if (settings.demoMode && !isAdminUser(request)) {
|
||||||
|
return reply.code(403).send({ error: 'In demo mode, it is not allowed to remove user.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId } = request.body;
|
||||||
|
const allUser = userStorage.getUsers(false);
|
||||||
|
if (!checkIfAnyAdminAfterRemovingUser(userId, allUser)) {
|
||||||
|
return reply.code(400).send({ error: 'You are trying to remove the last admin user. This is prohibited.' });
|
||||||
|
}
|
||||||
|
if (checkIfUserToBeRemovedIsLoggedIn(userId, request)) {
|
||||||
|
return reply.code(400).send({ error: 'You are trying to remove yourself. This is prohibited.' });
|
||||||
|
}
|
||||||
|
jobStorage.removeJobsByUserId(userId);
|
||||||
|
userStorage.removeUser(userId);
|
||||||
|
return reply.send();
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post('/', async (request, reply) => {
|
||||||
|
const settings = await getSettings();
|
||||||
|
if (settings.demoMode && !isAdminUser(request)) {
|
||||||
|
return reply.code(403).send({ error: 'In demo mode, it is not allowed to change or add user.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { username, password, password2, isAdmin, userId } = request.body;
|
||||||
|
if (password !== password2) {
|
||||||
|
return reply.code(400).send({ error: 'Passwords do not match.' });
|
||||||
|
}
|
||||||
|
if (nullOrEmpty(username) || nullOrEmpty(password) || nullOrEmpty(password2)) {
|
||||||
|
return reply.code(400).send({ error: 'Username and password are mandatory.' });
|
||||||
|
}
|
||||||
|
const allUser = userStorage.getUsers(false);
|
||||||
|
if (!isAdmin && !checkIfAnyAdminAfterRemovingUser(userId, allUser)) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'You cannot change the admin flag for this user as otherwise, there is no other user in the system',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
userStorage.upsertUser({ userId, username, password, isAdmin });
|
||||||
|
return reply.send();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import restana from 'restana';
|
|
||||||
import SqliteConnection from '../../services/storage/SqliteConnection.js';
|
import SqliteConnection from '../../services/storage/SqliteConnection.js';
|
||||||
import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
|
import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
|
||||||
|
import { isAdmin } from '../security.js';
|
||||||
import { resetGeocoordinatesAndDistanceForUser } from '../../services/storage/listingsStorage.js';
|
import { resetGeocoordinatesAndDistanceForUser } from '../../services/storage/listingsStorage.js';
|
||||||
import { geocodeAddress } from '../../services/geocoding/geoCodingService.js';
|
import { geocodeAddress } from '../../services/geocoding/geoCodingService.js';
|
||||||
import { autocompleteAddress } from '../../services/geocoding/autocompleteService.js';
|
import { autocompleteAddress } from '../../services/geocoding/autocompleteService.js';
|
||||||
@@ -15,107 +15,164 @@ import { TRACKING_POIS } from '../../TRACKING_POIS.js';
|
|||||||
import logger from '../../services/logger.js';
|
import logger from '../../services/logger.js';
|
||||||
import { runGeoCordTask } from '../../services/crons/geocoding-cron.js';
|
import { runGeoCordTask } from '../../services/crons/geocoding-cron.js';
|
||||||
|
|
||||||
const service = restana();
|
/**
|
||||||
const userSettingsRouter = service.newRouter();
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
|
*/
|
||||||
userSettingsRouter.get('/', async (req, res) => {
|
export default async function userSettingsPlugin(fastify) {
|
||||||
const userId = req.session.currentUser;
|
fastify.get('/', async (request) => {
|
||||||
const rows = SqliteConnection.query('SELECT name, value FROM settings WHERE user_id = @userId', { userId });
|
const userId = request.session.currentUser;
|
||||||
const settings = {};
|
const rows = SqliteConnection.query('SELECT name, value FROM settings WHERE user_id = @userId', { userId });
|
||||||
for (const r of rows) {
|
const settings = {};
|
||||||
settings[r.name] = fromJson(r.value, null);
|
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) {
|
|
||||||
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) {
|
return settings;
|
||||||
logger.error('Error updating home address settings', error);
|
});
|
||||||
res.statusCode = 500;
|
|
||||||
res.send({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
userSettingsRouter.post('/news-hash', async (req, res) => {
|
fastify.get('/autocomplete', async (request, reply) => {
|
||||||
const userId = req.session.currentUser;
|
const { q } = request.query;
|
||||||
const { news_hash } = req.body;
|
try {
|
||||||
|
const results = await autocompleteAddress(q);
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
return reply.code(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const globalSettings = await getSettings();
|
fastify.post('/home-address', async (request, reply) => {
|
||||||
if (globalSettings.demoMode) {
|
const userId = request.session.currentUser;
|
||||||
res.statusCode = 403;
|
const { home_address } = request.body;
|
||||||
res.send({ error: 'In demo mode, it is not allowed to change settings.' });
|
const settings = await getSettings();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
if (settings.demoMode && !isAdmin(request)) {
|
||||||
upsertSettings({ news_hash }, userId);
|
return reply.code(403).send({ error: 'In demo mode, it is not allowed to change the home address.' });
|
||||||
res.send({ success: true });
|
}
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error updating news hash', error);
|
|
||||||
res.statusCode = 500;
|
|
||||||
res.send({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
userSettingsRouter.post('/immoscout-details', async (req, res) => {
|
try {
|
||||||
const userId = req.session.currentUser;
|
if (home_address) {
|
||||||
const { immoscout_details } = req.body;
|
await trackPoi(TRACKING_POIS.DISTANCE_ADDRESS_ENTERED);
|
||||||
|
const coords = await geocodeAddress(home_address);
|
||||||
|
if (coords && coords.lat !== -1) {
|
||||||
|
upsertSettings({ home_address: { address: home_address, coords } }, userId);
|
||||||
|
resetGeocoordinatesAndDistanceForUser(userId);
|
||||||
|
runGeoCordTask();
|
||||||
|
return { success: true, coords };
|
||||||
|
} else {
|
||||||
|
return reply.code(400).send({ error: 'Could not geocode address' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
upsertSettings({ home_address: null }, userId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating home address settings', error);
|
||||||
|
return reply.code(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const globalSettings = await getSettings();
|
fastify.post('/news-hash', async (request, reply) => {
|
||||||
if (globalSettings.demoMode) {
|
const userId = request.session.currentUser;
|
||||||
res.statusCode = 403;
|
const { news_hash } = request.body;
|
||||||
res.send({ error: 'In demo mode, it is not allowed to change settings.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
const globalSettings = await getSettings();
|
||||||
upsertSettings({ immoscout_details: !!immoscout_details }, userId);
|
if (globalSettings.demoMode && !isAdmin(request)) {
|
||||||
res.send({ success: true });
|
return reply.code(403).send({ error: 'In demo mode, it is not allowed to change settings.' });
|
||||||
} catch (error) {
|
}
|
||||||
logger.error('Error updating immoscout details setting', error);
|
|
||||||
res.statusCode = 500;
|
|
||||||
res.send({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export { userSettingsRouter };
|
try {
|
||||||
|
upsertSettings({ news_hash }, userId);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating news hash', error);
|
||||||
|
return reply.code(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post('/provider-details', async (request, reply) => {
|
||||||
|
const userId = request.session.currentUser;
|
||||||
|
const { provider_details } = request.body;
|
||||||
|
|
||||||
|
const globalSettings = await getSettings();
|
||||||
|
if (globalSettings.demoMode && !isAdmin(request)) {
|
||||||
|
return reply.code(403).send({ error: 'In demo mode, it is not allowed to change settings.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(provider_details)) {
|
||||||
|
return reply.code(400).send({ error: 'provider_details must be an array of provider ids.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
upsertSettings({ provider_details }, userId);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating provider details setting', error);
|
||||||
|
return reply.code(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post('/listings-view-mode', async (request, reply) => {
|
||||||
|
const userId = request.session.currentUser;
|
||||||
|
const { listings_view_mode } = request.body;
|
||||||
|
|
||||||
|
if (listings_view_mode !== 'grid' && listings_view_mode !== 'table') {
|
||||||
|
return reply.code(400).send({ error: 'listings_view_mode must be "grid" or "table".' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listings_view_mode === 'table') {
|
||||||
|
await trackPoi(TRACKING_POIS.LISTING_TABLE_VIEW);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
upsertSettings({ listings_view_mode }, userId);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating listings view mode setting', error);
|
||||||
|
return reply.code(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post('/jobs-view-mode', async (request, reply) => {
|
||||||
|
const userId = request.session.currentUser;
|
||||||
|
const { jobs_view_mode } = request.body;
|
||||||
|
|
||||||
|
if (jobs_view_mode !== 'grid' && jobs_view_mode !== 'table') {
|
||||||
|
return reply.code(400).send({ error: 'jobs_view_mode must be "grid" or "table".' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jobs_view_mode === 'table') {
|
||||||
|
await trackPoi(TRACKING_POIS.JOBS_TABLE_VIEW);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
upsertSettings({ jobs_view_mode }, userId);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating jobs view mode setting', error);
|
||||||
|
return reply.code(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post('/listing-deletion-preference', async (request, reply) => {
|
||||||
|
const userId = request.session.currentUser;
|
||||||
|
const { listing_deletion_preference } = request.body;
|
||||||
|
|
||||||
|
const globalSettings = await getSettings();
|
||||||
|
if (globalSettings.demoMode && !isAdmin(request)) {
|
||||||
|
return reply.code(403).send({ error: 'In demo mode, it is not allowed to change settings.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listing_deletion_preference == null) {
|
||||||
|
return reply.code(400).send({ error: 'listing_deletion_preference is required.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { skipPrompt, hardDelete } = listing_deletion_preference;
|
||||||
|
|
||||||
|
try {
|
||||||
|
upsertSettings({ listing_deletion_preference: { skipPrompt, hardDelete } }, userId);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating listing deletion preference', error);
|
||||||
|
return reply.code(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,27 +3,10 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import restana from 'restana';
|
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
import { getPackageVersion } from '../../utils.js';
|
import { getPackageVersion } from '../../utils.js';
|
||||||
import semver from 'semver';
|
import semver from 'semver';
|
||||||
|
|
||||||
const service = restana();
|
|
||||||
const versionRouter = service.newRouter();
|
|
||||||
|
|
||||||
versionRouter.get('/', async (req, res) => {
|
|
||||||
const versionPayload = await getCurrentVersionFromGithub();
|
|
||||||
const localFredyVersion = await getPackageVersion();
|
|
||||||
res.body =
|
|
||||||
versionPayload == null
|
|
||||||
? {
|
|
||||||
newVersion: false,
|
|
||||||
localFredyVersion,
|
|
||||||
}
|
|
||||||
: versionPayload;
|
|
||||||
res.send();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function getCurrentVersionFromGithub() {
|
async function getCurrentVersionFromGithub() {
|
||||||
const raw = await fetch('https://api.github.com/repos/orangecoding/fredy/releases/latest');
|
const raw = await fetch('https://api.github.com/repos/orangecoding/fredy/releases/latest');
|
||||||
const data = await raw.json();
|
const data = await raw.json();
|
||||||
@@ -40,4 +23,13 @@ async function getCurrentVersionFromGithub() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { versionRouter };
|
/**
|
||||||
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
|
*/
|
||||||
|
export default async function versionPlugin(fastify) {
|
||||||
|
fastify.get('/', async () => {
|
||||||
|
const versionPayload = await getCurrentVersionFromGithub();
|
||||||
|
const localFredyVersion = await getPackageVersion();
|
||||||
|
return versionPayload ?? { newVersion: false, localFredyVersion };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,49 +4,50 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as userStorage from '../services/storage/userStorage.js';
|
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);
|
/**
|
||||||
};
|
* Returns true when the request has no valid, non-expired session.
|
||||||
const isUnauthorized = (req) => {
|
* @param {import('fastify').FastifyRequest} request
|
||||||
return req.session.currentUser == null;
|
* @returns {boolean}
|
||||||
};
|
*/
|
||||||
const isAdmin = (req) => {
|
export function isUnauthorized(request) {
|
||||||
if (!isUnauthorized(req)) {
|
if (!request.session?.currentUser) return true;
|
||||||
const user = userStorage.getUser(req.session.currentUser);
|
if (Date.now() - (request.session.createdAt || 0) > SESSION_MAX_AGE) return true;
|
||||||
return user != null && user.isAdmin;
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
};
|
}
|
||||||
const authInterceptor = () => {
|
|
||||||
return (req, res, next) => {
|
/**
|
||||||
if (isUnauthorized(req)) {
|
* Returns true when the session belongs to an admin user.
|
||||||
return unauthorized(res);
|
* @param {import('fastify').FastifyRequest} request
|
||||||
} else {
|
* @returns {boolean}
|
||||||
next();
|
*/
|
||||||
}
|
export function isAdmin(request) {
|
||||||
};
|
if (isUnauthorized(request)) return false;
|
||||||
};
|
const user = userStorage.getUser(request.session.currentUser);
|
||||||
const adminInterceptor = () => {
|
return user != null && user.isAdmin;
|
||||||
return (req, res, next) => {
|
}
|
||||||
if (!isAdmin(req)) {
|
|
||||||
return unauthorized(res);
|
/**
|
||||||
} else {
|
* Fastify preHandler hook - rejects unauthenticated requests with 401.
|
||||||
next();
|
* @param {import('fastify').FastifyRequest} request
|
||||||
}
|
* @param {import('fastify').FastifyReply} reply
|
||||||
};
|
*/
|
||||||
};
|
export async function authHook(request, reply) {
|
||||||
const cookieSession$0 = (userId) => {
|
if (isUnauthorized(request)) {
|
||||||
return cookieSession({
|
reply.code(401).send();
|
||||||
name: 'fredy-admin-session',
|
}
|
||||||
keys: ['fredy', 'super', 'fancy', 'key', nanoid()],
|
}
|
||||||
userId,
|
|
||||||
maxAge: 2 * 60 * 60 * 1000, // 2 hours
|
/**
|
||||||
});
|
* Fastify preHandler hook - rejects non-admin requests with 401.
|
||||||
};
|
* Apply after authHook.
|
||||||
export { cookieSession$0 as cookieSession };
|
* @param {import('fastify').FastifyRequest} request
|
||||||
export { adminInterceptor };
|
* @param {import('fastify').FastifyReply} reply
|
||||||
export { authInterceptor };
|
*/
|
||||||
export { isUnauthorized };
|
export async function adminHook(request, reply) {
|
||||||
export { isAdmin };
|
if (!isAdmin(request)) {
|
||||||
|
reply.code(401).send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Fredy MCP Server
|
# Fredy MCP Server
|
||||||
|
|
||||||
The Fredy MCP Server exposes your real estate jobs and listings data to LLM clients. It supports two transports:
|
The Fredy MCP Server exposes your real estate jobs and listings data to LLM clients. It supports two transports:
|
||||||
|
|
||||||
@@ -126,6 +126,54 @@ The LLM will automatically call the appropriate Fredy MCP tools and present the
|
|||||||
|
|
||||||
> **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).
|
> **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)
|
## 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:
|
The HTTP transport is automatically available when Fredy is running. It uses the MCP Streamable HTTP protocol at:
|
||||||
@@ -204,7 +252,7 @@ Example list response:
|
|||||||
```
|
```
|
||||||
**Tool:** list_listings | **Status:** OK
|
**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.
|
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 |
|
| ID | Title | Address | Price | Size | Provider | Active | Created | Job |
|
||||||
|----|-------|---------|-------|------|----------|--------|---------|-----|
|
|----|-------|---------|-------|------|----------|--------|---------|-----|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function createMcpServer() {
|
|||||||
'list_listings to search listings (supports time filters like createdAfter/createdBefore), ' +
|
'list_listings to search listings (supports time filters like createdAfter/createdBefore), ' +
|
||||||
'and get_listing for full details of a single listing. ' +
|
'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. ' +
|
'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.',
|
'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.',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -220,6 +220,122 @@ export function createMcpServer() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── 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 ─────────────────────────────────────────────────────
|
// ── get_current_date_ime ─────────────────────────────────────────────────────
|
||||||
server.tool('get_current_date_time', 'Returns the current date and time.', {}, () => {
|
server.tool('get_current_date_time', 'Returns the current date and time.', {}, () => {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3,10 +3,6 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* 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 { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
import { createMcpServer } from './mcpAdapter.js';
|
import { createMcpServer } from './mcpAdapter.js';
|
||||||
import { authenticateRequest } from './mcpAuthentication.js';
|
import { authenticateRequest } from './mcpAuthentication.js';
|
||||||
@@ -15,16 +11,13 @@ import crypto from 'crypto';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Active transports keyed by session id.
|
* Active transports keyed by session id.
|
||||||
* Each session gets its own McpServer + StreamableHTTPServerTransport pair.
|
|
||||||
* @type {Map<string, { server: McpServer, transport: StreamableHTTPServerTransport }>}
|
* @type {Map<string, { server: McpServer, transport: StreamableHTTPServerTransport }>}
|
||||||
*/
|
*/
|
||||||
const sessions = new Map();
|
const sessions = new Map();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or create a session for the given session id with authentication.
|
|
||||||
* @param {string|undefined} sessionId
|
* @param {string|undefined} sessionId
|
||||||
* @param {{ userId: string }} auth
|
* @param {{ userId: string }} auth
|
||||||
* @returns {{ server: McpServer, transport: StreamableHTTPServerTransport }}
|
|
||||||
*/
|
*/
|
||||||
function getOrCreateSession(sessionId, auth) {
|
function getOrCreateSession(sessionId, auth) {
|
||||||
if (sessionId && sessions.has(sessionId)) {
|
if (sessionId && sessions.has(sessionId)) {
|
||||||
@@ -54,77 +47,67 @@ function getOrCreateSession(sessionId, auth) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register MCP Streamable HTTP routes on a restana service.
|
* Register MCP Streamable HTTP routes on a fastify instance.
|
||||||
*
|
*
|
||||||
* Mounts handlers at /api/mcp to handle the MCP Streamable HTTP protocol:
|
* POST /api/mcp – JSON-RPC messages
|
||||||
* - POST /api/mcp – JSON-RPC messages (initialize, tool calls, etc.)
|
* GET /api/mcp – SSE stream for server-initiated notifications
|
||||||
* - GET /api/mcp – SSE stream for server-initiated notifications
|
* DELETE /api/mcp – session termination
|
||||||
* - DELETE /api/mcp – session termination
|
|
||||||
*
|
*
|
||||||
* All endpoints require a valid Bearer token in the Authorization header.
|
* All endpoints require a valid Bearer token in the Authorization header.
|
||||||
*
|
*
|
||||||
* @param {import('restana').Service} service - The restana service instance.
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
*/
|
*/
|
||||||
export function registerMcpRoutes(service) {
|
export function registerMcpRoutes(fastify) {
|
||||||
// POST – main JSON-RPC endpoint
|
fastify.post('/api/mcp', async (request, reply) => {
|
||||||
service.post('/api/mcp', async (req, res) => {
|
const auth = authenticateRequest(request.raw);
|
||||||
const auth = authenticateRequest(req);
|
|
||||||
if (!auth) {
|
if (!auth) {
|
||||||
res.statusCode = 401;
|
return reply.code(401).send({ error: 'Unauthorized. Provide a valid Bearer token.' });
|
||||||
return res.send({ error: 'Unauthorized. Provide a valid Bearer token.' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = req.headers['mcp-session-id'];
|
const sessionId = request.raw.headers['mcp-session-id'];
|
||||||
const { server, transport } = getOrCreateSession(sessionId, auth);
|
const { server, transport } = getOrCreateSession(sessionId, auth);
|
||||||
|
|
||||||
// Connect server to transport if not already connected
|
|
||||||
if (!transport.onmessage) {
|
if (!transport.onmessage) {
|
||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject authInfo so tools can access the authenticated user
|
request.raw.auth = { userId: auth.userId };
|
||||||
req.auth = { userId: auth.userId };
|
|
||||||
|
|
||||||
await transport.handleRequest(req, res, req.body);
|
reply.hijack();
|
||||||
|
await transport.handleRequest(request.raw, reply.raw, request.body);
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET – SSE stream for server-initiated messages
|
fastify.get('/api/mcp', async (request, reply) => {
|
||||||
service.get('/api/mcp', async (req, res) => {
|
const auth = authenticateRequest(request.raw);
|
||||||
const auth = authenticateRequest(req);
|
|
||||||
if (!auth) {
|
if (!auth) {
|
||||||
res.statusCode = 401;
|
return reply.code(401).send({ error: 'Unauthorized. Provide a valid Bearer token.' });
|
||||||
return res.send({ error: 'Unauthorized. Provide a valid Bearer token.' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = req.headers['mcp-session-id'];
|
const sessionId = request.raw.headers['mcp-session-id'];
|
||||||
if (!sessionId || !sessions.has(sessionId)) {
|
if (!sessionId || !sessions.has(sessionId)) {
|
||||||
res.statusCode = 400;
|
return reply.code(400).send({ error: 'Invalid or missing session. Send an initialize request first.' });
|
||||||
return res.send({ error: 'Invalid or missing session. Send an initialize request first.' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { transport } = sessions.get(sessionId);
|
const { transport } = sessions.get(sessionId);
|
||||||
await transport.handleRequest(req, res);
|
reply.hijack();
|
||||||
|
await transport.handleRequest(request.raw, reply.raw);
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE – terminate session
|
fastify.delete('/api/mcp', async (request, reply) => {
|
||||||
service.delete('/api/mcp', async (req, res) => {
|
const auth = authenticateRequest(request.raw);
|
||||||
const auth = authenticateRequest(req);
|
|
||||||
if (!auth) {
|
if (!auth) {
|
||||||
res.statusCode = 401;
|
return reply.code(401).send({ error: 'Unauthorized. Provide a valid Bearer token.' });
|
||||||
return res.send({ error: 'Unauthorized. Provide a valid Bearer token.' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = req.headers['mcp-session-id'];
|
const sessionId = request.raw.headers['mcp-session-id'];
|
||||||
if (!sessionId || !sessions.has(sessionId)) {
|
if (!sessionId || !sessions.has(sessionId)) {
|
||||||
res.statusCode = 404;
|
return reply.code(404).send({ error: 'Session not found.' });
|
||||||
return res.send({ error: 'Session not found.' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { transport } = sessions.get(sessionId);
|
const { transport } = sessions.get(sessionId);
|
||||||
await transport.close();
|
await transport.close();
|
||||||
sessions.delete(sessionId);
|
sessions.delete(sessionId);
|
||||||
res.statusCode = 200;
|
return { ok: true };
|
||||||
res.send({ ok: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.debug('MCP Streamable HTTP endpoint registered at /api/mcp');
|
logger.debug('MCP Streamable HTTP endpoint registered at /api/mcp');
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function normalizeListJobs(queryResult, { page, pageSize }) {
|
|||||||
|
|
||||||
let md = `**Tool:** list_jobs | **Status:** OK\n\n`;
|
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).`;
|
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.`;
|
if (hasMore) md += ` More pages available - use page=${page + 1} to continue.`;
|
||||||
md += '\n\n';
|
md += '\n\n';
|
||||||
|
|
||||||
if (jobs.length > 0) {
|
if (jobs.length > 0) {
|
||||||
@@ -120,7 +120,7 @@ export function normalizeListListings(queryResult, { page, pageSize }) {
|
|||||||
|
|
||||||
let md = `**Tool:** list_listings | **Status:** OK\n\n`;
|
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).`;
|
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.`;
|
if (hasMore) md += ` More pages available - use page=${page + 1} to continue.`;
|
||||||
md += '\n\n';
|
md += '\n\n';
|
||||||
|
|
||||||
if (listings.length > 0) {
|
if (listings.length > 0) {
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ import { markdown2Html } from '../../services/markdown.js';
|
|||||||
import { getJob } from '../../services/storage/jobStorage.js';
|
import { getJob } from '../../services/storage/jobStorage.js';
|
||||||
import fetch from 'node-fetch';
|
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 { server } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
const job = getJob(jobKey);
|
const job = getJob(jobKey);
|
||||||
const jobName = job == null ? jobKey : job.name;
|
const jobName = job == null ? jobKey : job.name;
|
||||||
const promises = newListings.map((newListing) => {
|
const promises = newListings.map((newListing) => {
|
||||||
const title = `${jobName} at ${serviceName}: ${newListing.title}`;
|
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, {
|
return fetch(server, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -5,9 +5,18 @@
|
|||||||
|
|
||||||
import { markdown2Html } from '../../services/markdown.js';
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
|
|
||||||
export const send = ({ serviceName, newListings, jobKey }) => {
|
export const send = ({ serviceName, newListings, jobKey, baseUrl }) => {
|
||||||
/* eslint-disable no-console */
|
/* 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 */
|
/* eslint-enable no-console */
|
||||||
};
|
};
|
||||||
export const config = {
|
export const config = {
|
||||||
|
|||||||
@@ -39,9 +39,10 @@ const generateColorFromString = (str) => {
|
|||||||
*
|
*
|
||||||
* @param {string} jobKey - Key of job (used to set embed color)
|
* @param {string} jobKey - Key of job (used to set embed color)
|
||||||
* @param {object} listing - Object holding listing details
|
* @param {object} listing - Object holding listing details
|
||||||
|
* @param baseUrl
|
||||||
* @returns {object} Discord webhook embed
|
* @returns {object} Discord webhook embed
|
||||||
*/
|
*/
|
||||||
const buildEmbed = (jobKey, listing) => {
|
const buildEmbed = (jobKey, listing, baseUrl) => {
|
||||||
const maxTitleLength = 252; // Max embed title length is 256 characters
|
const maxTitleLength = 252; // Max embed title length is 256 characters
|
||||||
let title = String(listing.title ?? 'N/A');
|
let title = String(listing.title ?? 'N/A');
|
||||||
if (title.length > maxTitleLength) {
|
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;
|
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 adapter = notificationConfig.find((adapter) => adapter.id === config.id);
|
||||||
const webhookUrl = adapter?.fields?.webhookUrl;
|
const webhookUrl = adapter?.fields?.webhookUrl;
|
||||||
if (!webhookUrl || newListings.length === 0) return Promise.resolve([]);
|
if (!webhookUrl || newListings.length === 0) return Promise.resolve([]);
|
||||||
@@ -90,7 +99,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
|||||||
const job = getJob(jobKey);
|
const job = getJob(jobKey);
|
||||||
const jobName = job?.name || 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 maxEmbedsPerMessage = 10; // Discord only allows up to 10 embeds
|
||||||
const webhookPromises = [];
|
const webhookPromises = [];
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { markdown2Html } from '../../services/markdown.js';
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
|
|
||||||
const mapListing = (listing) => ({
|
const mapListing = (listing, baseUrl) => ({
|
||||||
address: listing.address,
|
address: listing.address,
|
||||||
description: listing.description,
|
description: listing.description,
|
||||||
id: listing.id,
|
id: listing.id,
|
||||||
@@ -14,12 +14,13 @@ const mapListing = (listing) => ({
|
|||||||
size: listing.size,
|
size: listing.size,
|
||||||
title: listing.title,
|
title: listing.title,
|
||||||
url: listing.link,
|
url: listing.link,
|
||||||
|
fredyUrl: baseUrl && listing.id ? `${baseUrl}/listings/listing/${listing.id}` : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = async ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
|
||||||
const { authToken, endpointUrl, selfSignedCerts } = notificationConfig.find((a) => a.id === config.id).fields;
|
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 = {
|
const body = {
|
||||||
jobId: jobKey,
|
jobId: jobKey,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const toBase64 = async (url) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapListingsWithCid = async (serviceName, jobKey, listings) => {
|
const mapListingsWithCid = async (serviceName, jobKey, listings, baseUrl) => {
|
||||||
const out = [];
|
const out = [];
|
||||||
const attachments = [];
|
const attachments = [];
|
||||||
|
|
||||||
@@ -53,6 +53,7 @@ const mapListingsWithCid = async (serviceName, jobKey, listings) => {
|
|||||||
jobKey,
|
jobKey,
|
||||||
hasImage: false,
|
hasImage: false,
|
||||||
imageCid: '',
|
imageCid: '',
|
||||||
|
fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (imgUrl) {
|
if (imgUrl) {
|
||||||
@@ -78,7 +79,7 @@ const mapListingsWithCid = async (serviceName, jobKey, listings) => {
|
|||||||
return { listings: out, attachments };
|
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(
|
const { apiPublicKey, apiPrivateKey, receiver, from } = notificationConfig.find(
|
||||||
(adapter) => adapter.id === config.id,
|
(adapter) => adapter.id === config.id,
|
||||||
).fields;
|
).fields;
|
||||||
@@ -89,7 +90,7 @@ export const send = async ({ serviceName, newListings, notificationConfig, jobKe
|
|||||||
.map((r) => ({ Email: r.trim() }))
|
.map((r) => ({ Email: r.trim() }))
|
||||||
.filter((r) => r.Email.length > 0);
|
.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({
|
const html = emailTemplate({
|
||||||
serviceName: `Job: (${jobKey}) | Service: ${serviceName}`,
|
serviceName: `Job: (${jobKey}) | Service: ${serviceName}`,
|
||||||
|
|||||||
@@ -6,15 +6,20 @@
|
|||||||
import { markdown2Html } from '../../services/markdown.js';
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
import { getJob } from '../../services/storage/jobStorage.js';
|
import { getJob } from '../../services/storage/jobStorage.js';
|
||||||
import fetch from 'node-fetch';
|
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 { webhook, channel } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
const job = getJob(jobKey);
|
const job = getJob(jobKey);
|
||||||
const jobName = job == null ? jobKey : job.name;
|
const jobName = job == null ? jobKey : job.name;
|
||||||
let message = `### *${jobName}* (${serviceName}) found **${newListings.length}** new listings:\n\n`;
|
let message = `### *${jobName}* (${serviceName}) found **${newListings.length}** new listings:\n\n`;
|
||||||
message += `| Title | Address | Size | Price |\n|:----|:----|:----|:----|\n`;
|
message += `| Title | Address | Size | Price |${baseUrl ? ' Open in Fredy |' : ''}\n|:----|:----|:----|:----|${baseUrl ? ':----|\n' : '\n'}`;
|
||||||
message += newListings.map(
|
message += newListings.map((o) => {
|
||||||
(o) => `| [${o.title}](${o.link}) | ` + [o.address, o.size.replace(/2m/g, '$m^2$'), o.price].join(' | ') + ' |\n',
|
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, {
|
return fetch(webhook, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -8,17 +8,18 @@ import { getJob } from '../../services/storage/jobStorage.js';
|
|||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
import { normalizeImageUrl } from '../../utils.js';
|
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 { priority, server, topic } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
const job = getJob(jobKey);
|
const job = getJob(jobKey);
|
||||||
const jobName = job == null ? jobKey : job.name;
|
const jobName = job == null ? jobKey : job.name;
|
||||||
|
|
||||||
const promises = newListings.map((newListing) => {
|
const promises = newListings.map((newListing) => {
|
||||||
|
const fredyLine = baseUrl && newListing.id ? `\nOpen in Fredy: ${baseUrl}/listings/listing/${newListing.id}` : '';
|
||||||
const message = `
|
const message = `
|
||||||
Address: ${newListing.address}
|
Address: ${newListing.address}
|
||||||
Size: ${newListing.size == null ? 'N/A' : newListing.size.replace(/2m/g, '$m^2$')}
|
Size: ${newListing.size == null ? 'N/A' : newListing.size.replace(/2m/g, '$m^2$')}
|
||||||
Price: ${newListing.price}
|
Price: ${newListing.price}
|
||||||
Link: ${newListing.link}`;
|
Link: ${newListing.link}${fredyLine}`;
|
||||||
|
|
||||||
const sanitizeHeaderValue = (value) =>
|
const sanitizeHeaderValue = (value) =>
|
||||||
String(value ?? '')
|
String(value ?? '')
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { markdown2Html } from '../../services/markdown.js';
|
|||||||
import { getJob } from '../../services/storage/jobStorage.js';
|
import { getJob } from '../../services/storage/jobStorage.js';
|
||||||
import fetch from 'node-fetch';
|
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 { token, user, device } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
const job = getJob(jobKey);
|
const job = getJob(jobKey);
|
||||||
const jobName = job == null ? jobKey : job.name;
|
const jobName = job == null ? jobKey : job.name;
|
||||||
@@ -15,7 +15,8 @@ export const send = async ({ serviceName, newListings, notificationConfig, jobKe
|
|||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
newListings.map(async (newListing) => {
|
newListings.map(async (newListing) => {
|
||||||
const title = `${jobName} at ${serviceName}: ${newListing.title}`;
|
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();
|
const form = new FormData();
|
||||||
form.append('token', token);
|
form.append('token', token);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const __dirname = getDirName();
|
|||||||
const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTemplate/template.hbs'), 'utf8');
|
const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTemplate/template.hbs'), 'utf8');
|
||||||
const emailTemplate = Handlebars.compile(template);
|
const emailTemplate = Handlebars.compile(template);
|
||||||
|
|
||||||
const mapListings = (serviceName, jobKey, listings) =>
|
const mapListings = (serviceName, jobKey, listings, baseUrl) =>
|
||||||
listings.map((l) => {
|
listings.map((l) => {
|
||||||
const image = normalizeImageUrl(l.image);
|
const image = normalizeImageUrl(l.image);
|
||||||
return {
|
return {
|
||||||
@@ -25,12 +25,13 @@ const mapListings = (serviceName, jobKey, listings) =>
|
|||||||
price: l.price || '',
|
price: l.price || '',
|
||||||
image,
|
image,
|
||||||
hasImage: Boolean(image),
|
hasImage: Boolean(image),
|
||||||
|
fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null,
|
||||||
serviceName,
|
serviceName,
|
||||||
jobKey,
|
jobKey,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = async ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
|
||||||
const { apiKey, receiver, from } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
const { apiKey, receiver, from } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
|
|
||||||
const to = receiver
|
const to = receiver
|
||||||
@@ -41,7 +42,7 @@ export const send = async ({ serviceName, newListings, notificationConfig, jobKe
|
|||||||
|
|
||||||
const resend = new Resend(apiKey);
|
const resend = new Resend(apiKey);
|
||||||
|
|
||||||
const listings = mapListings(serviceName, jobKey, newListings);
|
const listings = mapListings(serviceName, jobKey, newListings, baseUrl);
|
||||||
|
|
||||||
const html = emailTemplate({
|
const html = emailTemplate({
|
||||||
serviceName: `Job: (${jobKey}) | Service: ${serviceName}`,
|
serviceName: `Job: (${jobKey}) | Service: ${serviceName}`,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import sgMail from '@sendgrid/mail';
|
|||||||
import { markdown2Html } from '../../services/markdown.js';
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
import { normalizeImageUrl } from '../../utils.js';
|
import { normalizeImageUrl } from '../../utils.js';
|
||||||
|
|
||||||
const mapListings = (serviceName, jobKey, listings) =>
|
const mapListings = (serviceName, jobKey, listings, baseUrl) =>
|
||||||
listings.map((l) => {
|
listings.map((l) => {
|
||||||
const image = normalizeImageUrl(l.image);
|
const image = normalizeImageUrl(l.image);
|
||||||
return {
|
return {
|
||||||
@@ -20,12 +20,13 @@ const mapListings = (serviceName, jobKey, listings) =>
|
|||||||
hasImage: Boolean(image),
|
hasImage: Boolean(image),
|
||||||
// optional plain text snippet
|
// optional plain text snippet
|
||||||
snippet: [l.address, l.price, l.size].filter(Boolean).join(' | '),
|
snippet: [l.address, l.price, l.size].filter(Boolean).join(' | '),
|
||||||
|
fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null,
|
||||||
serviceName,
|
serviceName,
|
||||||
jobKey,
|
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;
|
const { apiKey, receiver, from, templateId } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
|
|
||||||
sgMail.setApiKey(apiKey);
|
sgMail.setApiKey(apiKey);
|
||||||
@@ -36,7 +37,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
|||||||
.map((r) => r.trim())
|
.map((r) => r.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
const listings = mapListings(serviceName, jobKey, newListings);
|
const listings = mapListings(serviceName, jobKey, newListings, baseUrl);
|
||||||
|
|
||||||
const msg = {
|
const msg = {
|
||||||
templateId,
|
templateId,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import Slack from 'slack';
|
|||||||
import { markdown2Html } from '../../services/markdown.js';
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
import { normalizeImageUrl } from '../../utils.js';
|
import { normalizeImageUrl } from '../../utils.js';
|
||||||
|
|
||||||
const buildBlocks = (serviceName, jobKey, p) => {
|
const buildBlocks = (serviceName, jobKey, p, baseUrl) => {
|
||||||
const blocks = [
|
const blocks = [
|
||||||
{
|
{
|
||||||
type: 'header',
|
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({
|
blocks.push({
|
||||||
type: 'context',
|
type: 'context',
|
||||||
elements: [{ type: 'mrkdwn', text: 'Powered by Fredy' }],
|
elements: [{ type: 'mrkdwn', text: 'Powered by Fredy' }],
|
||||||
@@ -44,7 +51,7 @@ const buildBlocks = (serviceName, jobKey, p) => {
|
|||||||
return blocks;
|
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;
|
const { token, channel } = notificationConfig.find((a) => a.id === config.id).fields;
|
||||||
|
|
||||||
return Promise.allSettled(
|
return Promise.allSettled(
|
||||||
@@ -53,7 +60,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
|||||||
token,
|
token,
|
||||||
channel,
|
channel,
|
||||||
text: `${serviceName} ${jobKey}: ${p.title}`,
|
text: `${serviceName} ${jobKey}: ${p.title}`,
|
||||||
blocks: buildBlocks(serviceName, jobKey, p),
|
blocks: buildBlocks(serviceName, jobKey, p, baseUrl),
|
||||||
unfurl_links: false,
|
unfurl_links: false,
|
||||||
unfurl_media: false,
|
unfurl_media: false,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import fetch from 'node-fetch';
|
|||||||
import { markdown2Html } from '../../services/markdown.js';
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
import { normalizeImageUrl } from '../../utils.js';
|
import { normalizeImageUrl } from '../../utils.js';
|
||||||
|
|
||||||
const buildBlocks = (serviceName, jobKey, p) => {
|
const buildBlocks = (serviceName, jobKey, p, baseUrl) => {
|
||||||
const blocks = [
|
const blocks = [
|
||||||
{
|
{
|
||||||
type: 'header',
|
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({
|
blocks.push({
|
||||||
type: 'context',
|
type: 'context',
|
||||||
elements: [{ type: 'mrkdwn', text: 'Powered by Fredy' }],
|
elements: [{ type: 'mrkdwn', text: 'Powered by Fredy' }],
|
||||||
@@ -51,7 +58,7 @@ const postJson = (url, body) =>
|
|||||||
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 adapter = notificationConfig.find((a) => a.id === config.id);
|
||||||
const webhookUrl = adapter?.fields?.webhookUrl;
|
const webhookUrl = adapter?.fields?.webhookUrl;
|
||||||
if (!webhookUrl) return Promise.resolve([]);
|
if (!webhookUrl) return Promise.resolve([]);
|
||||||
@@ -59,7 +66,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
|||||||
const promises = newListings.map((p) => {
|
const promises = newListings.map((p) => {
|
||||||
const body = JSON.stringify({
|
const body = JSON.stringify({
|
||||||
text: `${serviceName} ${jobKey}: ${p.title}`,
|
text: `${serviceName} ${jobKey}: ${p.title}`,
|
||||||
blocks: buildBlocks(serviceName, jobKey, p),
|
blocks: buildBlocks(serviceName, jobKey, p, baseUrl),
|
||||||
unfurl_links: false,
|
unfurl_links: false,
|
||||||
unfurl_media: 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.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
23
lib/notification/adapter/smtp.md
Normal file
23
lib/notification/adapter/smtp.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
### SMTP Adapter
|
||||||
|
|
||||||
|
Send notifications through any SMTP server using [Nodemailer](https://nodemailer.com/).
|
||||||
|
This works with Gmail, Outlook, self-hosted mail servers, or any provider that supports SMTP.
|
||||||
|
|
||||||
|
Setup:
|
||||||
|
|
||||||
|
- Provide the SMTP host and port of your mail server.
|
||||||
|
- For **SSL/TLS** (port 465), set Secure to `true`.
|
||||||
|
- For **STARTTLS** (port 587), leave Secure empty or set it to `false`.
|
||||||
|
- Enter the username and password for authentication. For Gmail, use an [App Password](https://support.google.com/accounts/answer/185833).
|
||||||
|
- Set the sender email address (must be allowed by your SMTP server).
|
||||||
|
|
||||||
|
Multiple recipients:
|
||||||
|
|
||||||
|
- Separate email addresses with commas (e.g., `some@email.com`, `someOther@email.com`).
|
||||||
|
|
||||||
|
Common SMTP settings:
|
||||||
|
|
||||||
|
- **Gmail** - `smtp.gmail.com`, port 587, secure: false
|
||||||
|
- **Outlook** - `smtp.office365.com`, port 587, secure: false
|
||||||
|
- **Yahoo** - `smtp.mail.yahoo.com`, port 465, secure: true
|
||||||
|
- **Gmx** - `mail.gmx.net`, port 587, secure: true
|
||||||
@@ -9,6 +9,7 @@ import fetch from 'node-fetch';
|
|||||||
import pThrottle from 'p-throttle';
|
import pThrottle from 'p-throttle';
|
||||||
import { normalizeImageUrl } from '../../utils.js';
|
import { normalizeImageUrl } from '../../utils.js';
|
||||||
import logger from '../../services/logger.js';
|
import logger from '../../services/logger.js';
|
||||||
|
import { shouldUseMultipart, buildPhotoFormData } from './telegramPhotoUploader.js';
|
||||||
|
|
||||||
const RATE_LIMIT_INTERVAL = 1000;
|
const RATE_LIMIT_INTERVAL = 1000;
|
||||||
const chatThrottleMap = new Map();
|
const chatThrottleMap = new Map();
|
||||||
@@ -80,12 +81,14 @@ function escapeHtml(s = '') {
|
|||||||
* @param {string} [o.link]
|
* @param {string} [o.link]
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
function buildCaption(jobName, serviceName, o) {
|
function buildCaption(jobName, serviceName, o, baseUrl) {
|
||||||
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
||||||
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
|
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(
|
return `<i>${escapeHtml(jobName)}</i> (${escapeHtml(serviceName)})\n<a href='${escapeHtml(
|
||||||
o.link || '',
|
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 +98,47 @@ function buildCaption(jobName, serviceName, o) {
|
|||||||
* @param {Object} o - Listing object
|
* @param {Object} o - Listing object
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
function buildText(jobName, serviceName, o) {
|
function buildText(jobName, serviceName, o, baseUrl) {
|
||||||
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
||||||
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
|
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 (
|
return (
|
||||||
`<i>${escapeHtml(jobName)}</i> (${escapeHtml(serviceName)})\n` +
|
`<i>${escapeHtml(jobName)}</i> (${escapeHtml(serviceName)})\n` +
|
||||||
`<a href='${escapeHtml(o.link || '')}'><b>${escapeHtml(title)}</b></a>\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.
|
* Send new listings to Telegram.
|
||||||
* - Respects per-chat Telegram rate limits using a lightweight throttle cache.
|
* - Respects per-chat Telegram rate limits using a lightweight throttle cache.
|
||||||
@@ -117,12 +151,12 @@ function buildText(jobName, serviceName, o) {
|
|||||||
* @param {string} params.jobKey - Storage job key to resolve the human readable job name.
|
* @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.
|
* @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);
|
const adapterCfg = notificationConfig.find((adapter) => adapter.id === config.id);
|
||||||
if (!adapterCfg || !adapterCfg.fields) {
|
if (!adapterCfg || !adapterCfg.fields) {
|
||||||
throw new Error(`Telegram adapter configuration missing for job '${jobKey || ''}'`);
|
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) {
|
if (!token || !chatId) {
|
||||||
throw new Error("Telegram 'token' and 'chatId' must be provided in notification config");
|
throw new Error("Telegram 'token' and 'chatId' must be provided in notification config");
|
||||||
}
|
}
|
||||||
@@ -144,11 +178,13 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
|
|||||||
const jobName = job == null ? jobKey : job.name;
|
const jobName = job == null ? jobKey : job.name;
|
||||||
|
|
||||||
const throttledCall = getThrottled(chatId, async function (endpoint, body) {
|
const throttledCall = getThrottled(chatId, async function (endpoint, body) {
|
||||||
const res = await fetch(`https://api.telegram.org/bot${token}/${endpoint}`, {
|
// FormData (multipart) vs JSON. node-fetch sets its own multipart boundary
|
||||||
method: 'post',
|
// header, so we must NOT supply Content-Type ourselves in that case.
|
||||||
body: JSON.stringify(body),
|
const isFormData = body instanceof FormData;
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const opts = isFormData
|
||||||
});
|
? { method: 'post', body }
|
||||||
|
: { method: 'post', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } };
|
||||||
|
const res = await fetch(`https://api.telegram.org/bot${token}/${endpoint}`, opts);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errorBody = await res.text();
|
const errorBody = await res.text();
|
||||||
@@ -163,8 +199,8 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
|
|||||||
const img = normalizeImageUrl(o.image);
|
const img = normalizeImageUrl(o.image);
|
||||||
const textPayload = {
|
const textPayload = {
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
text: buildText(jobName, serviceName, o),
|
text: plainText ? buildTextPlain(jobName, serviceName, o, baseUrl) : buildText(jobName, serviceName, o, baseUrl),
|
||||||
parse_mode: 'HTML',
|
...(plainText ? {} : { parse_mode: 'HTML' }),
|
||||||
disable_web_page_preview: true,
|
disable_web_page_preview: true,
|
||||||
...(message_thread_id ? { message_thread_id } : {}),
|
...(message_thread_id ? { message_thread_id } : {}),
|
||||||
};
|
};
|
||||||
@@ -175,14 +211,28 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return await throttledCall('sendPhoto', {
|
const caption = plainText
|
||||||
chat_id: chatId,
|
? buildCaptionPlain(jobName, serviceName, o, baseUrl)
|
||||||
photo: img,
|
: buildCaption(jobName, serviceName, o, baseUrl);
|
||||||
caption: buildCaption(jobName, serviceName, o),
|
const parseMode = plainText ? undefined : 'HTML';
|
||||||
parse_mode: 'HTML',
|
|
||||||
...(message_thread_id ? { message_thread_id } : {}),
|
// .webp URLs (Immowelt/Cloudimage) fail Telegram's URL-based sendPhoto with
|
||||||
}).catch(async (e) => {
|
// "failed to get HTTP URL content". Upload the bytes via multipart instead;
|
||||||
logger.error(`Error sending photo to Telegram and use a fallback: ${e.message}`);
|
// the rendered chat message is identical.
|
||||||
|
const photoCall = shouldUseMultipart(img)
|
||||||
|
? buildPhotoFormData({ chatId, imageUrl: img, caption, parseMode, messageThreadId: message_thread_id }).then(
|
||||||
|
(fd) => throttledCall('sendPhoto', fd),
|
||||||
|
)
|
||||||
|
: throttledCall('sendPhoto', {
|
||||||
|
chat_id: chatId,
|
||||||
|
photo: img,
|
||||||
|
caption,
|
||||||
|
...(parseMode ? { parse_mode: parseMode } : {}),
|
||||||
|
...(message_thread_id ? { message_thread_id } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return await photoCall.catch(async (e) => {
|
||||||
|
logger.warn(`Error sending photo to Telegram and use a fallback: ${e.message}`);
|
||||||
return await throttledCall('sendMessage', textPayload).catch((e) => {
|
return await throttledCall('sendMessage', textPayload).catch((e) => {
|
||||||
logger.error(`Error sending message to Telegram: ${e.message}`);
|
logger.error(`Error sending message to Telegram: ${e.message}`);
|
||||||
throw e;
|
throw e;
|
||||||
@@ -220,5 +270,11 @@ export const config = {
|
|||||||
description:
|
description:
|
||||||
'Optional: The topic/thread id within a supergroup to post into (Telegram message_thread_id). Provide a positive integer.',
|
'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
lib/notification/adapter/telegramPhotoUploader.js
Normal file
106
lib/notification/adapter/telegramPhotoUploader.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helpers for sending photos to Telegram via `multipart/form-data` instead of
|
||||||
|
* the HTTP-URL path. Used when the URL is one that Telegram's URL-fetcher will
|
||||||
|
* reject - notably `.webp` images from Cloudimage (mms.immowelt.de), which
|
||||||
|
* Telegram refuses with "Bad Request: failed to get HTTP URL content".
|
||||||
|
*
|
||||||
|
* The HTTP-URL path is faster and is still the default in telegram.js; this
|
||||||
|
* module is the fallback for URLs whose extension makes Telegram fail.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Telegram's sendPhoto limit when uploading bytes via multipart/form-data. */
|
||||||
|
const TELEGRAM_MULTIPART_MAX_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** Accept header used when re-fetching the image ourselves.
|
||||||
|
* Deliberately excludes `image/webp` so CDNs that content-negotiate
|
||||||
|
* (like Cloudimage on mms.immowelt.de) transcode WEBP to JPEG. */
|
||||||
|
const NON_WEBP_ACCEPT = 'image/jpeg,image/png,image/*;q=0.8';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the URL's path ends in a `.webp` extension. Such URLs need
|
||||||
|
* multipart upload because Telegram identifies media types from the URL path
|
||||||
|
* and rejects `.webp` in sendPhoto via HTTP URL.
|
||||||
|
*
|
||||||
|
* Conservative: returns false for null/empty/non-string input, malformed URLs,
|
||||||
|
* and non-https schemes.
|
||||||
|
*
|
||||||
|
* @param {string|null|undefined} url
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function shouldUseMultipart(url) {
|
||||||
|
if (typeof url !== 'string' || url.length === 0) return false;
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = new URL(url);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== 'https:') return false;
|
||||||
|
return /\.webp$/i.test(parsed.pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch an image from `imageUrl` and build a `FormData` body suitable for
|
||||||
|
* POSTing to `https://api.telegram.org/bot<token>/sendPhoto`.
|
||||||
|
*
|
||||||
|
* - Sends an `Accept` header that excludes `image/webp` so origin/CDN servers
|
||||||
|
* that content-negotiate return JPEG bytes.
|
||||||
|
* - Rejects images larger than Telegram's 10 MB multipart limit, both
|
||||||
|
* advertised via `Content-Length` and (defensively) after download.
|
||||||
|
* - The `photo` field is named with a `.jpg` extension because Telegram
|
||||||
|
* identifies file type from the filename.
|
||||||
|
*
|
||||||
|
* Throws if the image fetch fails, the size limit is exceeded, or the URL is
|
||||||
|
* unreachable. The caller is responsible for catching and falling back.
|
||||||
|
*
|
||||||
|
* @param {Object} args
|
||||||
|
* @param {string|number} args.chatId
|
||||||
|
* @param {string} args.imageUrl
|
||||||
|
* @param {string} args.caption
|
||||||
|
* @param {string} [args.parseMode] - Telegram parse_mode, e.g. 'HTML'.
|
||||||
|
* @param {number} [args.messageThreadId] - Telegram supergroup topic id.
|
||||||
|
* @returns {Promise<FormData>}
|
||||||
|
*/
|
||||||
|
export async function buildPhotoFormData({ chatId, imageUrl, caption, parseMode, messageThreadId }) {
|
||||||
|
const res = await fetch(imageUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: NON_WEBP_ACCEPT },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to fetch image for multipart upload (${res.status}): ${imageUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const advertised = Number(res.headers.get('content-length'));
|
||||||
|
if (Number.isFinite(advertised) && advertised > TELEGRAM_MULTIPART_MAX_BYTES) {
|
||||||
|
throw new Error(
|
||||||
|
`Image exceeds Telegram multipart size limit (advertised ${advertised} bytes, max ${TELEGRAM_MULTIPART_MAX_BYTES}): ${imageUrl}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buf = await res.arrayBuffer();
|
||||||
|
if (buf.byteLength > TELEGRAM_MULTIPART_MAX_BYTES) {
|
||||||
|
throw new Error(
|
||||||
|
`Image exceeds Telegram multipart size limit (downloaded ${buf.byteLength} bytes, max ${TELEGRAM_MULTIPART_MAX_BYTES}): ${imageUrl}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Telegram identifies the media type from the filename extension. We always
|
||||||
|
// upload as .jpg because the Accept header forces JPEG bytes from CDNs that
|
||||||
|
// honor it; for the rare CDN that ignores Accept and still returns WEBP, the
|
||||||
|
// .jpg filename is a small lie but Telegram's image pipeline accepts it.
|
||||||
|
const blob = new Blob([buf], { type: 'image/jpeg' });
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('chat_id', String(chatId));
|
||||||
|
fd.append('caption', caption);
|
||||||
|
if (parseMode) fd.append('parse_mode', parseMode);
|
||||||
|
if (messageThreadId != null) fd.append('message_thread_id', String(messageThreadId));
|
||||||
|
fd.append('photo', blob, 'photo.jpg');
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
@@ -106,6 +106,9 @@
|
|||||||
<![endif]-->
|
<![endif]-->
|
||||||
<!--[if !mso]><!-- -->
|
<!--[if !mso]><!-- -->
|
||||||
<a href="{{this.link}}" class="btn" target="_blank">View Listing</a>
|
<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]-->
|
<!--<![endif]-->
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ if (adapter.length === 0) {
|
|||||||
const findAdapter = (notificationAdapter) => {
|
const findAdapter = (notificationAdapter) => {
|
||||||
return adapter.find((a) => a.config.id === notificationAdapter.id);
|
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
|
//this is not being used in tests, therefore adapter are always set
|
||||||
return notificationConfig
|
return notificationConfig
|
||||||
.filter((notificationAdapter) => findAdapter(notificationAdapter) != null)
|
.filter((notificationAdapter) => findAdapter(notificationAdapter) != null)
|
||||||
.map((notificationAdapter) => findAdapter(notificationAdapter))
|
.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 { buildHash, isOneOf } from '../utils.js';
|
||||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.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 = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} o
|
||||||
|
* @returns {ParsedListing}
|
||||||
|
*/
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const baseUrl = 'https://www.1a-immobilienmarkt.de';
|
const baseUrl = 'https://www.1a-immobilienmarkt.de';
|
||||||
const link = `${baseUrl}/expose/${o.id}.html`;
|
const link = `${baseUrl}/expose/${o.id}.html`;
|
||||||
@@ -14,7 +22,17 @@ function normalize(o) {
|
|||||||
const id = buildHash(o.id, price);
|
const id = buildHash(o.id, price);
|
||||||
const image = baseUrl + o.image;
|
const image = baseUrl + o.image;
|
||||||
const address = o.address == null ? null : o.address.trim().replaceAll('/', ',');
|
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];
|
return result[0];
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* @param {ParsedListing} o
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {ProviderConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '.tabelle',
|
crawlContainer: '.tabelle',
|
||||||
sortByDateParam: 'sort_type=newest',
|
sortByDateParam: 'sort_type=newest',
|
||||||
@@ -48,7 +72,8 @@ const config = {
|
|||||||
crawlFields: {
|
crawlFields: {
|
||||||
id: '.inner_object_data input[name="marker_objekt_id"]@value | int',
|
id: '.inner_object_data input[name="marker_objekt_id"]@value | int',
|
||||||
price: '.inner_object_data .single_data_price | removeNewline | trim',
|
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',
|
title: '.inner_object_data .tabelle_inhalt_titel_black | removeNewline | trim',
|
||||||
image: '.inner_object_pic img@src',
|
image: '.inner_object_pic img@src',
|
||||||
address: '.tabelle .tabelle_inhalt_infos .left_information > div:nth-child(2) | removeNewline | trim',
|
address: '.tabelle .tabelle_inhalt_infos .left_information > div:nth-child(2) | removeNewline | trim',
|
||||||
|
|||||||
@@ -5,6 +5,12 @@
|
|||||||
|
|
||||||
import { buildHash, isOneOf } from '../utils.js';
|
import { buildHash, isOneOf } from '../utils.js';
|
||||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.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 = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
@@ -18,42 +24,106 @@ function parseId(shortenedLink) {
|
|||||||
return shortenedLink.substring(shortenedLink.lastIndexOf('/') + 1);
|
return shortenedLink.substring(shortenedLink.lastIndexOf('/') + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchDetails(listing, browser) {
|
||||||
|
try {
|
||||||
|
const html = await puppeteerExtractor(listing.link, null, { browser, name: 'immobilienDe_details' });
|
||||||
|
if (!html) return listing;
|
||||||
|
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
|
// Try JSON-LD first
|
||||||
|
let description = null;
|
||||||
|
let address = listing.address;
|
||||||
|
$('script[type="application/ld+json"]').each((_, el) => {
|
||||||
|
if (description) return;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse($(el).text());
|
||||||
|
const nodes = Array.isArray(data) ? data : [data];
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.description && !description) description = String(node.description).replace(/\s+/g, ' ').trim();
|
||||||
|
const addr = node.address || node?.mainEntity?.address;
|
||||||
|
if (addr && addr.streetAddress && address === listing.address) {
|
||||||
|
const parts = [addr.streetAddress, addr.postalCode, addr.addressLocality].filter(Boolean);
|
||||||
|
if (parts.length) address = parts.join(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore malformed JSON-LD
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fallback: common description selectors used by immobilien.de
|
||||||
|
if (!description) {
|
||||||
|
const sel = ['.beschreibung', '.freitext', '.objektbeschreibung', '.description'].find((s) => $(s).length > 0);
|
||||||
|
if (sel) description = $(sel).text().replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...listing,
|
||||||
|
address,
|
||||||
|
description: description || listing.description,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Could not fetch immobilien.de detail page for listing '${listing.id}'.`, error?.message || error);
|
||||||
|
return listing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @param {any} o
|
||||||
|
* @returns {ParsedListing}
|
||||||
|
*/
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const baseUrl = 'https://www.immobilien.de';
|
const baseUrl = 'https://www.immobilien.de';
|
||||||
const size = o.size || null;
|
const title = o.title || '';
|
||||||
const price = o.price || null;
|
|
||||||
const title = o.title || 'No title available';
|
|
||||||
const address = o.address || null;
|
const address = o.address || null;
|
||||||
const shortLink = shortenLink(o.link);
|
const shortLink = shortenLink(o.link);
|
||||||
const link = baseUrl + shortLink;
|
const link = shortLink ? (shortLink.startsWith('http') ? shortLink : baseUrl + shortLink) : baseUrl;
|
||||||
const image = baseUrl + o.image;
|
const image = o.image ? (o.image.startsWith('http') ? o.image : baseUrl + o.image) : null;
|
||||||
const id = buildHash(parseId(shortLink), o.price);
|
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) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {ProviderConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: 'a:has(div.list_entry)',
|
crawlContainer: 'a.lr-card',
|
||||||
sortByDateParam: 'sort_col=*created_ts&sort_dir=desc',
|
sortByDateParam: 'sort_col=*created_ts&sort_dir=desc',
|
||||||
waitForSelector: 'body',
|
waitForSelector: null,
|
||||||
crawlFields: {
|
crawlFields: {
|
||||||
id: '@href', //will be transformed later
|
id: '@href', //will be transformed later
|
||||||
price: '.immo_preis .label_info',
|
price: '.lr-card__price-amount | trim',
|
||||||
size: '.flaeche .label_info | removeNewline | trim',
|
size: '.lr-card__fact:has(.lr-card__fact-label:contains("Fläche")) .lr-card__fact-value | trim',
|
||||||
title: 'h3 span',
|
rooms: '.zimmer .label_info',
|
||||||
|
title: '.lr-card__title | trim',
|
||||||
description: '.description | trim',
|
description: '.description | trim',
|
||||||
link: '@href',
|
link: '@href',
|
||||||
address: '.place',
|
address: '.lr-card__address span | trim',
|
||||||
image: 'img@src',
|
image: 'img.lr-card__gallery-img@src',
|
||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
fetchDetails,
|
||||||
activeTester: checkIfListingIsActive,
|
activeTester: checkIfListingIsActive,
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
|
|||||||
@@ -46,9 +46,11 @@ import {
|
|||||||
convertWebToMobile,
|
convertWebToMobile,
|
||||||
} from '../services/immoscout/immoscout-web-translator.js';
|
} from '../services/immoscout/immoscout-web-translator.js';
|
||||||
import logger from '../services/logger.js';
|
import logger from '../services/logger.js';
|
||||||
import { getUserSettings } from '../services/storage/settingsStorage.js';
|
import { extractNumber } from '../utils/extract-number.js';
|
||||||
|
/** @import { ParsedListing } from '../types/listing.js' */
|
||||||
|
/** @import { ProviderConfig } from '../types/providerConfig.js' */
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
let currentUserId = null;
|
|
||||||
|
|
||||||
async function getListings(url) {
|
async function getListings(url) {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
@@ -68,42 +70,40 @@ async function getListings(url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const responseBody = await response.json();
|
const responseBody = await response.json();
|
||||||
return Promise.all(
|
return responseBody.resultListItems
|
||||||
responseBody.resultListItems
|
.filter((item) => item.type === 'EXPOSE_RESULT')
|
||||||
.filter((item) => item.type === 'EXPOSE_RESULT')
|
.map((expose) => {
|
||||||
.map(async (expose) => {
|
const item = expose.item;
|
||||||
const item = expose.item;
|
const [price, size] = item.attributes;
|
||||||
const [price, size] = item.attributes;
|
const image = item?.titlePicture?.full ?? item?.titlePicture?.preview ?? null;
|
||||||
const image = item?.titlePicture?.full ?? item?.titlePicture?.preview ?? null;
|
return {
|
||||||
let listing = {
|
id: item.id,
|
||||||
id: item.id,
|
price: price?.value,
|
||||||
price: price?.value,
|
size: size?.value,
|
||||||
size: size?.value,
|
title: item.title,
|
||||||
title: item.title,
|
link: `${metaInformation.baseUrl}expose/${item.id}`,
|
||||||
link: `${metaInformation.baseUrl}expose/${item.id}`,
|
address: item.address?.line,
|
||||||
address: item.address?.line,
|
image,
|
||||||
image,
|
};
|
||||||
};
|
});
|
||||||
if (currentUserId) {
|
}
|
||||||
const userSettings = getUserSettings(currentUserId);
|
|
||||||
if (userSettings.immoscout_details) {
|
async function fetchDetails(listing) {
|
||||||
return await pushDetails(listing);
|
return pushDetails(listing);
|
||||||
}
|
|
||||||
}
|
|
||||||
return listing;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pushDetails(listing) {
|
async function pushDetails(listing) {
|
||||||
const detailed = await fetch(`https://api.mobile.immobilienscout24.de/expose/${listing.id}`, {
|
const exposeId = listing.link?.split('/').pop();
|
||||||
|
const detailed = await fetch(`https://api.mobile.immobilienscout24.de/expose/${exposeId}`, {
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'ImmoScout_27.3_26.0_._',
|
'User-Agent': 'ImmoScout_27.3_26.0_._',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!detailed.ok) {
|
if (!detailed.ok) {
|
||||||
logger.error('Error fetching listing details from ImmoScout Mobile API:', detailed.statusText);
|
logger.warn(
|
||||||
|
`Error fetching listing details from ImmoScout Mobile API for id: ${exposeId} Status: ${detailed.statusText}`,
|
||||||
|
);
|
||||||
return listing;
|
return listing;
|
||||||
}
|
}
|
||||||
const detailBody = await detailed.json();
|
const detailBody = await detailed.json();
|
||||||
@@ -172,22 +172,44 @@ async function isListingActive(link) {
|
|||||||
function nullOrEmpty(val) {
|
function nullOrEmpty(val) {
|
||||||
return val == null || val.length === 0;
|
return val == null || val.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} o
|
||||||
|
* @returns {ParsedListing}
|
||||||
|
*/
|
||||||
function normalize(o) {
|
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 address = nullOrEmpty(o.address) ? 'NO ADDRESS FOUND' : (o.address || '').replace(/\(.*\),.*$/, '').trim();
|
||||||
const id = buildHash(o.id, o.price);
|
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) {
|
function applyBlacklist(o) {
|
||||||
return !isOneOf(o.title, appliedBlackList);
|
return !isOneOf(o.title, appliedBlackList);
|
||||||
}
|
}
|
||||||
|
/** @type {ProviderConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
|
||||||
url: null,
|
url: null,
|
||||||
crawlFields: {
|
crawlFields: {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
title: 'title',
|
title: 'title',
|
||||||
price: 'price',
|
price: 'price',
|
||||||
size: 'size',
|
size: 'size',
|
||||||
|
rooms: 'rooms',
|
||||||
link: 'link',
|
link: 'link',
|
||||||
address: 'address',
|
address: 'address',
|
||||||
},
|
},
|
||||||
@@ -196,13 +218,13 @@ const config = {
|
|||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
getListings: getListings,
|
getListings: getListings,
|
||||||
|
fetchDetails: fetchDetails,
|
||||||
activeTester: isListingActive,
|
activeTester: isListingActive,
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
config.enabled = sourceConfig.enabled;
|
config.enabled = sourceConfig.enabled;
|
||||||
config.url = convertWebToMobile(sourceConfig.url);
|
config.url = convertWebToMobile(sourceConfig.url);
|
||||||
appliedBlackList = blacklist || [];
|
appliedBlackList = blacklist || [];
|
||||||
currentUserId = sourceConfig.userId || null;
|
|
||||||
};
|
};
|
||||||
export const metaInformation = {
|
export const metaInformation = {
|
||||||
name: 'Immoscout',
|
name: 'Immoscout',
|
||||||
|
|||||||
@@ -5,27 +5,46 @@
|
|||||||
|
|
||||||
import { isOneOf, buildHash } from '../utils.js';
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.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 = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} o
|
||||||
|
* @returns {ParsedListing}
|
||||||
|
*/
|
||||||
function normalize(o) {
|
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 immoId = o.id.substring(o.id.indexOf('-') + 1, o.id.length);
|
||||||
const link = `https://immo.swp.de/immobilien/${immoId}`;
|
const link = `https://immo.swp.de/immobilien/${immoId}`;
|
||||||
const description = o.description;
|
const id = buildHash(immoId, o.price);
|
||||||
const id = buildHash(immoId, price);
|
return {
|
||||||
return Object.assign(o, { id, price, size, title, link, description });
|
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) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {ProviderConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '.js-serp-item',
|
crawlContainer: '.js-serp-item',
|
||||||
sortByDateParam: 's=most_recently_updated_first',
|
sortByDateParam: 's=most_recently_updated_first',
|
||||||
@@ -34,9 +53,10 @@ const config = {
|
|||||||
id: '.js-bookmark-btn@data-id',
|
id: '.js-bookmark-btn@data-id',
|
||||||
price: 'div.align-items-start div:first-child | trim',
|
price: 'div.align-items-start div:first-child | trim',
|
||||||
size: 'div.align-items-start div:nth-child(3) | 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',
|
title: '.js-item-title-link@title | trim',
|
||||||
link: '.ci-search-result__link@href',
|
link: '.ci-search-result__link@href',
|
||||||
description: '.js-show-more-item-sm | removeNewline | trim',
|
|
||||||
image: 'img@src',
|
image: 'img@src',
|
||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
|
|||||||
@@ -5,30 +5,106 @@
|
|||||||
|
|
||||||
import { buildHash, isOneOf } from '../utils.js';
|
import { buildHash, isOneOf } from '../utils.js';
|
||||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.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 = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
function normalize(o) {
|
async function fetchDetails(listing, browser) {
|
||||||
const id = buildHash(o.id, o.price);
|
try {
|
||||||
return Object.assign(o, { id });
|
const html = await puppeteerExtractor(listing.link, null, { browser, name: 'immowelt_details' });
|
||||||
|
if (!html) return listing;
|
||||||
|
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
const nextDataRaw = $('#__NEXT_DATA__').text();
|
||||||
|
if (!nextDataRaw) return listing;
|
||||||
|
|
||||||
|
const classified = JSON.parse(nextDataRaw)?.props?.pageProps?.classified;
|
||||||
|
if (!classified) return listing;
|
||||||
|
|
||||||
|
const description = (classified.Texts || [])
|
||||||
|
.map((t) => [t.Title, t.Content].filter(Boolean).join('\n'))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n');
|
||||||
|
|
||||||
|
const addr = classified.EstateAddress;
|
||||||
|
let address = listing.address;
|
||||||
|
if (addr) {
|
||||||
|
const street = [addr.Street, addr.HouseNumber].filter(Boolean).join(' ');
|
||||||
|
const cityLine = [addr.ZipCode, addr.District || addr.City].filter(Boolean).join(' ');
|
||||||
|
const full = [street, cityLine].filter(Boolean).join(', ');
|
||||||
|
if (full) address = full;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...listing,
|
||||||
|
address,
|
||||||
|
description: description || listing.description,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Could not fetch immowelt detail page for listing '${listing.id}'.`, error?.message || error);
|
||||||
|
return listing;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} o
|
||||||
|
* @returns {ParsedListing}
|
||||||
|
*/
|
||||||
|
function normalize(o) {
|
||||||
|
const id = buildHash(o.id, o.price);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
link: o.link,
|
||||||
|
title: o.title || '',
|
||||||
|
price: extractNumber(o.price),
|
||||||
|
size: extractNumber(o.size),
|
||||||
|
rooms: extractNumber(o.rooms),
|
||||||
|
address: o.address,
|
||||||
|
image: o.image,
|
||||||
|
description: o.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ParsedListing} o
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {ProviderConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer:
|
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"]',
|
'div[data-testid="serp-core-scrollablelistview-testid"]:not(div[data-testid="serp-enlargementlist-testid"] div[data-testid="serp-card-testid"]) div[data-testid="serp-core-classified-card-testid"]',
|
||||||
sortByDateParam: 'order=DateDesc',
|
sortByDateParam: 'order=DateDesc',
|
||||||
waitForSelector: 'div[data-testid="serp-gridcontainer-testid"]',
|
// waitForSelector is null: extract the full page via page.content() so the
|
||||||
|
// Cheerio crawler can search anywhere in the rendered document.
|
||||||
|
// preNavigateUrl visits the homepage first to establish a trusted session
|
||||||
|
// before hitting the search URL; this prevents CDN-level bot challenges that
|
||||||
|
// fire on cold sessions. waitForNetworkIdle (phase 2) then catches React's
|
||||||
|
// listing API round-trip that fires well after domcontentloaded.
|
||||||
|
waitForSelector: null,
|
||||||
|
puppeteerOptions: {
|
||||||
|
puppeteerTimeout: 60_000,
|
||||||
|
preNavigateUrl: 'https://www.immowelt.de/',
|
||||||
|
waitForNetworkIdle: true,
|
||||||
|
waitForNetworkIdleTimeout: 60_000,
|
||||||
|
},
|
||||||
crawlFields: {
|
crawlFields: {
|
||||||
id: 'a@href',
|
id: 'a@href',
|
||||||
price: 'div[data-testid="cardmfe-price-testid"] | removeNewline | trim',
|
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)',
|
title: 'div[data-testid="cardmfe-description-box-text-test-id"] > div:nth-of-type(2)',
|
||||||
link: 'a@href',
|
link: 'a@href',
|
||||||
description: 'div[data-testid="cardmfe-description-text-test-id"] > div:nth-of-type(2) | removeNewline | trim',
|
description: 'div[data-testid="cardmfe-description-text-test-id"] > div:nth-of-type(2) | removeNewline | trim',
|
||||||
@@ -37,6 +113,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
fetchDetails: fetchDetails,
|
||||||
activeTester: checkIfListingIsActive,
|
activeTester: checkIfListingIsActive,
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
|
|||||||
@@ -5,17 +5,177 @@
|
|||||||
|
|
||||||
import { buildHash, isOneOf } from '../utils.js';
|
import { buildHash, isOneOf } from '../utils.js';
|
||||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.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 appliedBlackList = [];
|
||||||
let appliedBlacklistedDistricts = [];
|
let appliedBlacklistedDistricts = [];
|
||||||
|
|
||||||
function normalize(o) {
|
function toAbsoluteLink(link) {
|
||||||
const size = o.size || '--- m²';
|
if (!link) return null;
|
||||||
const id = buildHash(o.id, o.price);
|
return link.startsWith('http') ? link : `https://www.kleinanzeigen.de${link}`;
|
||||||
const link = `https://www.kleinanzeigen.de${o.link}`;
|
|
||||||
return Object.assign(o, { id, size, link });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanText(value) {
|
||||||
|
if (value == null) return '';
|
||||||
|
return String(value)
|
||||||
|
.replace(/<[^>]*>/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAddressFromJsonLd(address) {
|
||||||
|
if (!address || typeof address !== 'object') return null;
|
||||||
|
|
||||||
|
const locality = cleanText(address.addressLocality);
|
||||||
|
const region = cleanText(address.addressRegion);
|
||||||
|
const postalCode = cleanText(address.postalCode);
|
||||||
|
const streetAddress = cleanText(address.streetAddress);
|
||||||
|
|
||||||
|
const cityPart = [region, locality].filter(Boolean).join(' - ');
|
||||||
|
const tail = [postalCode, cityPart || locality || region].filter(Boolean).join(' ');
|
||||||
|
const fullAddress = [streetAddress, tail].filter(Boolean).join(', ');
|
||||||
|
|
||||||
|
return fullAddress || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenJsonLdNodes(node, acc = []) {
|
||||||
|
if (node == null) return acc;
|
||||||
|
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
node.forEach((item) => flattenJsonLdNodes(item, acc));
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof node !== 'object') return acc;
|
||||||
|
|
||||||
|
acc.push(node);
|
||||||
|
|
||||||
|
if (Array.isArray(node['@graph'])) {
|
||||||
|
node['@graph'].forEach((item) => flattenJsonLdNodes(item, acc));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.mainEntity) {
|
||||||
|
flattenJsonLdNodes(node.mainEntity, acc);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.itemOffered) {
|
||||||
|
flattenJsonLdNodes(node.itemOffered, acc);
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDetailFromHtml(html) {
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
const nodes = [];
|
||||||
|
|
||||||
|
// Prefer the rendered postal address block from the detail page because
|
||||||
|
// it contains the street line that is missing from list results.
|
||||||
|
const streetFromDom = cleanText($('#street-address').first().text());
|
||||||
|
const localityFromDom = cleanText($('#viewad-locality').first().text());
|
||||||
|
const domAddress = [streetFromDom, localityFromDom].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
$('script[type="application/ld+json"]').each((_, element) => {
|
||||||
|
const content = $(element).text();
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
flattenJsonLdNodes(parsed, nodes);
|
||||||
|
} catch {
|
||||||
|
// Ignore broken JSON-LD blocks from ads/trackers and keep trying others.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let detailAddress = null;
|
||||||
|
let detailDescription = null;
|
||||||
|
|
||||||
|
if (domAddress) {
|
||||||
|
detailAddress = domAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
const candidateAddress = buildAddressFromJsonLd(
|
||||||
|
node.address || node?.itemOffered?.address || node?.offers?.address,
|
||||||
|
);
|
||||||
|
if (!detailAddress && candidateAddress) {
|
||||||
|
detailAddress = candidateAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateDescription = cleanText(node.description || node?.itemOffered?.description);
|
||||||
|
if (!detailDescription && candidateDescription) {
|
||||||
|
detailDescription = candidateDescription;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detailAddress && detailDescription) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
detailAddress,
|
||||||
|
detailDescription,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrichListingFromDetails(listing, browser) {
|
||||||
|
const absoluteLink = toAbsoluteLink(listing.link);
|
||||||
|
if (!absoluteLink) return listing;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const html = await puppeteerExtractor(absoluteLink, null, { browser, name: 'kleinanzeigen_details' });
|
||||||
|
if (!html) return { ...listing, link: absoluteLink };
|
||||||
|
|
||||||
|
const { detailAddress, detailDescription } = extractDetailFromHtml(html);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...listing,
|
||||||
|
link: absoluteLink,
|
||||||
|
address: detailAddress || listing.address,
|
||||||
|
description: detailDescription || listing.description,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Could not fetch Kleinanzeigen detail page for listing '${listing.id}'.`, error?.message || error);
|
||||||
|
return { ...listing, link: absoluteLink };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDetails(listing, browser) {
|
||||||
|
return enrichListingFromDetails(listing, browser);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} o
|
||||||
|
* @returns {ParsedListing}
|
||||||
|
*/
|
||||||
|
function normalize(o) {
|
||||||
|
const parts = (o.tags || '').split('·').map((p) => p.trim());
|
||||||
|
const size = parts.find((p) => p.includes('m²'));
|
||||||
|
const rooms = parts.find((p) => p.includes('Zi.'));
|
||||||
|
const id = buildHash(o.id, o.price);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: o.title,
|
||||||
|
link: toAbsoluteLink(o.link) || o.link,
|
||||||
|
price: extractNumber(o.price),
|
||||||
|
size: extractNumber(size),
|
||||||
|
rooms: extractNumber(rooms),
|
||||||
|
address: o.address,
|
||||||
|
description: o.description,
|
||||||
|
image: o.image,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ParsedListing} o
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
@@ -24,28 +184,31 @@ function applyBlacklist(o) {
|
|||||||
return o.title != null && !isBlacklistedDistrict && titleNotBlacklisted && descNotBlacklisted;
|
return o.title != null && !isBlacklistedDistrict && titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {ProviderConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '#srchrslt-adtable .ad-listitem ',
|
crawlContainer: '#srchrslt-adtable .ad-listitem ',
|
||||||
//sort by date is standard oO
|
//sort by date is standard oO
|
||||||
sortByDateParam: null,
|
sortByDateParam: null,
|
||||||
waitForSelector: 'body',
|
waitForSelector: 'body',
|
||||||
crawlFields: {
|
crawlFields: {
|
||||||
id: '.aditem@data-adid | int',
|
id: '.aditem@data-adid',
|
||||||
price: '.aditem-main--middle--price-shipping--price | removeNewline | trim',
|
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',
|
title: '.aditem-main .text-module-begin | removeNewline | trim',
|
||||||
link: '.aditem-main .text-module-begin a@href | removeNewline | trim',
|
link: '.aditem@data-href',
|
||||||
description: '.aditem-main .aditem-main--middle--description | removeNewline | trim',
|
description: '.aditem-main .aditem-main--middle--description | removeNewline | trim',
|
||||||
address: '.aditem-main--top--left | trim | removeNewline',
|
address: '.aditem-main--top--left | trim | removeNewline',
|
||||||
image: 'img@src',
|
image: 'img@src',
|
||||||
},
|
},
|
||||||
|
fetchDetails,
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
activeTester: checkIfListingIsActive,
|
activeTester: checkIfListingIsActive,
|
||||||
};
|
};
|
||||||
export const metaInformation = {
|
export const metaInformation = {
|
||||||
name: 'Ebay Kleinanzeigen',
|
name: 'Kleinanzeigen',
|
||||||
baseUrl: 'https://www.kleinanzeigen.de/',
|
baseUrl: 'https://www.kleinanzeigen.de/',
|
||||||
id: 'kleinanzeigen',
|
id: 'kleinanzeigen',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,23 +5,46 @@
|
|||||||
|
|
||||||
import { isOneOf, buildHash } from '../utils.js';
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.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 = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} o
|
||||||
|
* @returns {ParsedListing}
|
||||||
|
*/
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const originalId = o.id.split('/').pop();
|
const originalId = o.id.split('/').pop();
|
||||||
const id = buildHash(originalId, o.price);
|
const id = buildHash(originalId, o.price);
|
||||||
const size = o.size ?? 'N/A m²';
|
const link = o.link != null ? `https://www.mcmakler.de${o.link}` : o.link;
|
||||||
const title = o.title || 'No title available';
|
const [rooms, size] = o.tags.split(' | ');
|
||||||
const address = o.address?.replace(' / ', ' ') || null;
|
const address = o.address?.replace(' / ', ' ') || null;
|
||||||
const link = o.link != null ? `https://www.mcmakler.de${o.link}` : config.url;
|
return {
|
||||||
return Object.assign(o, { id, size, title, link, address });
|
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) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
/** @type {ProviderConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: 'article[data-testid="propertyCard"]',
|
crawlContainer: 'article[data-testid="propertyCard"]',
|
||||||
sortByDateParam: 'sortBy=DATE&sortOn=DESC',
|
sortByDateParam: 'sortBy=DATE&sortOn=DESC',
|
||||||
@@ -30,7 +53,7 @@ const config = {
|
|||||||
id: 'h2 a@href',
|
id: 'h2 a@href',
|
||||||
title: 'h2 a | removeNewline | trim',
|
title: 'h2 a | removeNewline | trim',
|
||||||
price: 'footer > p:first-of-type | 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',
|
address: 'div > h2 + p | removeNewline | trim',
|
||||||
image: 'img@src',
|
image: 'img@src',
|
||||||
link: 'h2 a@href',
|
link: 'h2 a@href',
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
|
|
||||||
import { isOneOf, buildHash } from '../utils.js';
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.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 = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
@@ -12,19 +15,39 @@ function nullOrEmpty(val) {
|
|||||||
return val == null || val.length === 0;
|
return val == null || val.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} o
|
||||||
|
* @returns {ParsedListing}
|
||||||
|
*/
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const link = nullOrEmpty(o.link)
|
const link = nullOrEmpty(o.link)
|
||||||
? 'NO LINK'
|
? 'NO LINK'
|
||||||
: `https://www.neubaukompass.de${o.link.substring(o.link.indexOf('/neubau'))}`;
|
: `https://www.neubaukompass.de${o.link.substring(o.link.indexOf('/neubau'))}`;
|
||||||
const id = buildHash(o.link, o.price);
|
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) {
|
function applyBlacklist(o) {
|
||||||
return !isOneOf(o.title, appliedBlackList);
|
return !isOneOf(o.title, appliedBlackList);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {ProviderConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '.col-12.mb-4',
|
crawlContainer: '.col-12.mb-4',
|
||||||
sortByDateParam: 'Sortierung=Id&Richtung=DESC',
|
sortByDateParam: 'Sortierung=Id&Richtung=DESC',
|
||||||
@@ -34,7 +57,9 @@ const config = {
|
|||||||
title: 'a@title | removeNewline | trim',
|
title: 'a@title | removeNewline | trim',
|
||||||
link: 'a@href',
|
link: 'a@href',
|
||||||
address: '.nbk-project-card__description | removeNewline | trim',
|
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',
|
image: '.nbk-project-card__image@src',
|
||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
|
|||||||
@@ -5,19 +5,43 @@
|
|||||||
|
|
||||||
import { isOneOf, buildHash } from '../utils.js';
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.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 = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} o
|
||||||
|
* @returns {ParsedListing}
|
||||||
|
*/
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const link = metaInformation.baseUrl + o.link;
|
const link = metaInformation.baseUrl + o.link;
|
||||||
const id = buildHash(o.title, o.link, o.price);
|
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) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
/** @type {ProviderConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: 'div[data-livecomponent-id*="search/property_list"] .grid > div',
|
crawlContainer: 'div[data-livecomponent-id*="search/property_list"] .grid > div',
|
||||||
sortByDateParam: null,
|
sortByDateParam: null,
|
||||||
@@ -27,6 +51,7 @@ const config = {
|
|||||||
title: 'h4 | removeNewline | trim',
|
title: 'h4 | removeNewline | trim',
|
||||||
price: '.text-xl | trim',
|
price: '.text-xl | trim',
|
||||||
size: 'div[title="Wohnfläche"] | trim',
|
size: 'div[title="Wohnfläche"] | trim',
|
||||||
|
rooms: 'div[title="Zimmer"] | trim',
|
||||||
address: '.text-slate-800 | removeNewline | trim',
|
address: '.text-slate-800 | removeNewline | trim',
|
||||||
image: 'img@src',
|
image: 'img@src',
|
||||||
link: 'a@href',
|
link: 'a@href',
|
||||||
|
|||||||
@@ -5,24 +5,47 @@
|
|||||||
|
|
||||||
import { isOneOf, buildHash } from '../utils.js';
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.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 = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} o
|
||||||
|
* @returns {ParsedListing}
|
||||||
|
*/
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const id = buildHash(o.id, o.price);
|
const id = buildHash(o.id, o.price);
|
||||||
const address = o.address?.replace(/^adresse /i, '') ?? null;
|
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 link = o.link != null ? decodeURIComponent(o.link) : config.url;
|
||||||
|
|
||||||
const urlReg = new RegExp(/url\((.*?)\)/gim);
|
const urlReg = new RegExp(/url\((.*?)\)/gim);
|
||||||
const image = o.image != null ? urlReg.exec(o.image)[1] : null;
|
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) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
/** @type {ProviderConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '.listentry-content',
|
crawlContainer: '.listentry-content',
|
||||||
sortByDateParam: null, // sort by date is standard
|
sortByDateParam: null, // sort by date is standard
|
||||||
@@ -32,6 +55,7 @@ const config = {
|
|||||||
title: 'h2 | trim',
|
title: 'h2 | trim',
|
||||||
price: '.listentry-details-price .listentry-details-v | trim',
|
price: '.listentry-details-price .listentry-details-v | trim',
|
||||||
size: '.listentry-details-size .listentry-details-v | trim',
|
size: '.listentry-details-size .listentry-details-v | trim',
|
||||||
|
rooms: '.listentry-details-rooms .listentry-details-v | trim',
|
||||||
address: '.listentry-adress | trim',
|
address: '.listentry-adress | trim',
|
||||||
image: '.listentry-img@style',
|
image: '.listentry-img@style',
|
||||||
link: '.shariff@data-url',
|
link: '.shariff@data-url',
|
||||||
|
|||||||
@@ -5,37 +5,109 @@
|
|||||||
|
|
||||||
import { isOneOf, buildHash } from '../utils.js';
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.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 = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
async function fetchDetails(listing, browser) {
|
||||||
|
try {
|
||||||
|
const html = await puppeteerExtractor(listing.link, 'body', { browser, name: 'sparkasse_details' });
|
||||||
|
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
const nextDataRaw = $('#__NEXT_DATA__').text;
|
||||||
|
if (!nextDataRaw) return listing;
|
||||||
|
|
||||||
|
const estate = JSON.parse(nextDataRaw)?.props?.pageProps?.estate;
|
||||||
|
if (!estate) return listing;
|
||||||
|
|
||||||
|
const description = (estate.frontendItems || [])
|
||||||
|
.map((item) => {
|
||||||
|
const texts = (item.contents || [])
|
||||||
|
.filter((c) => c.type === 'contentBoxes')
|
||||||
|
.flatMap((c) => c.data || [])
|
||||||
|
.filter((d) => d.type === 'text' && d.content)
|
||||||
|
.map((d) => d.content);
|
||||||
|
if (!texts.length) return null;
|
||||||
|
return [item.label, ...texts].filter(Boolean).join('\n');
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n');
|
||||||
|
|
||||||
|
const addr = estate.address;
|
||||||
|
let address = listing.address;
|
||||||
|
if (addr) {
|
||||||
|
const street = [addr.street, addr.streetNumber].filter(Boolean).join(' ');
|
||||||
|
const cityLine = [addr.zip, addr.city].filter(Boolean).join(' ');
|
||||||
|
const full = [street, cityLine].filter(Boolean).join(', ');
|
||||||
|
if (full) address = full;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...listing,
|
||||||
|
address,
|
||||||
|
description: description || listing.description,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Could not fetch Sparkasse detail page for listing '${listing.id}'.`, error?.message || error);
|
||||||
|
return listing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} o
|
||||||
|
* @returns {ParsedListing}
|
||||||
|
*/
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const originalId = o.id.split('/').pop().replace('.html', '');
|
const originalId = o.id.split('/').pop().replace('.html', '');
|
||||||
const id = buildHash(originalId, o.price);
|
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;
|
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) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
/** @type {ProviderConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '.estate-list-item-row',
|
crawlContainer: 'div[data-testid="estate-link"]',
|
||||||
sortByDateParam: 'sortBy=date_desc',
|
sortByDateParam: 'sortBy=date_desc',
|
||||||
waitForSelector: 'body',
|
waitForSelector: 'body',
|
||||||
crawlFields: {
|
crawlFields: {
|
||||||
id: 'div[data-testid="estate-link"] a@href',
|
id: 'a@href',
|
||||||
title: 'h3 | trim',
|
title: 'h3 | trim',
|
||||||
price: '.estate-list-price | 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',
|
address: 'h6 | trim',
|
||||||
image: '.estate-list-item-image-container img@src',
|
image: 'img@src',
|
||||||
link: 'div[data-testid="estate-link"] a@href',
|
link: 'a@href',
|
||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
fetchDetails,
|
||||||
activeTester: (url) => checkIfListingIsActive(url, 'Angebot nicht gefunden'),
|
activeTester: (url) => checkIfListingIsActive(url, 'Angebot nicht gefunden'),
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
|
|||||||
@@ -5,22 +5,69 @@
|
|||||||
|
|
||||||
import { isOneOf, buildHash } from '../utils.js';
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.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 = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
async function fetchDetails(listing, browser) {
|
||||||
|
try {
|
||||||
|
const html = await puppeteerExtractor(listing.link, null, { browser, name: 'wgGesucht_details' });
|
||||||
|
if (!html) return listing;
|
||||||
|
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
|
$('#freitext_0 script').remove();
|
||||||
|
const description = $('#freitext_0').text().replace(/\s+/g, ' ').trim();
|
||||||
|
const address = $('a[href="#map_container"] .section_panel_detail').text().replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...listing,
|
||||||
|
address: address || listing.address,
|
||||||
|
description: description || listing.description,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Could not fetch wgGesucht detail page for listing '${listing.id}'.`, error?.message || error);
|
||||||
|
return listing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @param {any} o
|
||||||
|
* @returns {ParsedListing}
|
||||||
|
*/
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const id = buildHash(o.id, o.price);
|
const id = buildHash(o.id, o.price);
|
||||||
const link = `https://www.wg-gesucht.de${o.link}`;
|
const link = `https://www.wg-gesucht.de${o.link}`;
|
||||||
const image = o.image != null ? o.image.replace('small', 'large') : null;
|
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) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
return o.id != null && titleNotBlacklisted && descNotBlacklisted;
|
return o.id != null && titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {ProviderConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '#main_column .wgg_card',
|
crawlContainer: '#main_column .wgg_card',
|
||||||
@@ -31,12 +78,16 @@ const config = {
|
|||||||
details: '.row .noprint .col-xs-11 |removeNewline |trim',
|
details: '.row .noprint .col-xs-11 |removeNewline |trim',
|
||||||
price: '.middle .col-xs-3 |removeNewline |trim',
|
price: '.middle .col-xs-3 |removeNewline |trim',
|
||||||
size: '.middle .text-right |removeNewline |trim',
|
size: '.middle .text-right |removeNewline |trim',
|
||||||
|
rooms: '.middle .text-right |removeNewline |trim',
|
||||||
title: '.truncate_title a |removeNewline |trim',
|
title: '.truncate_title a |removeNewline |trim',
|
||||||
link: '.truncate_title a@href',
|
link: '.truncate_title a@href',
|
||||||
image: '.img-responsive@src',
|
image: '.img-responsive@src',
|
||||||
|
description: '.row .noprint .col-xs-11 |removeNewline |trim',
|
||||||
},
|
},
|
||||||
|
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
fetchDetails,
|
||||||
activeTester: checkIfListingIsActive,
|
activeTester: checkIfListingIsActive,
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
|
|||||||
@@ -5,26 +5,45 @@
|
|||||||
|
|
||||||
import * as utils from '../utils.js';
|
import * as utils from '../utils.js';
|
||||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.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 = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} o
|
||||||
|
* @returns {ParsedListing}
|
||||||
|
*/
|
||||||
function normalize(o) {
|
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 [city = '', part = ''] = (o.description || '').split('-').map((v) => v.trim());
|
||||||
const address = `${part}, ${city}`;
|
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) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
||||||
return o.id != null && o.title != null && titleNotBlacklisted && descNotBlacklisted && o.link.startsWith(o.link);
|
return o.id != null && o.title != null && titleNotBlacklisted && descNotBlacklisted && o.link.startsWith(o.link);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {ProviderConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'image', 'description'],
|
||||||
url: null,
|
url: null,
|
||||||
sortByDateParam: null,
|
sortByDateParam: null,
|
||||||
waitForSelector: 'body',
|
waitForSelector: 'body',
|
||||||
@@ -37,7 +56,7 @@ const config = {
|
|||||||
size: 'dl:nth-of-type(3) dd | removeNewline | trim',
|
size: 'dl:nth-of-type(3) dd | removeNewline | trim',
|
||||||
description: 'div.before\\:icon-location_marker | trim',
|
description: 'div.before\\:icon-location_marker | trim',
|
||||||
link: '@href',
|
link: '@href',
|
||||||
imageUrl: 'img@src',
|
image: 'img@src',
|
||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
|||||||
147
lib/services/ensureValidBinary.js
Normal file
147
lib/services/ensureValidBinary.js
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ensureBinary } from 'cloakbrowser';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resource files required on Linux/Windows — they must live next to the chrome binary.
|
||||||
|
* macOS packages these inside the .app bundle's Frameworks directory so a different
|
||||||
|
* check is used there (see isBinaryComplete).
|
||||||
|
*/
|
||||||
|
const LINUX_WIN_REQUIRED_FILES = ['icudtl.dat', 'resources.pak'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the top-level versioned installation directory for any platform.
|
||||||
|
*
|
||||||
|
* - Linux/Windows: binaryPath is ~/.cloakbrowser/chromium-X.Y.Z/chrome
|
||||||
|
* → dirname ~/.cloakbrowser/chromium-X.Y.Z/
|
||||||
|
* - macOS: binaryPath is ~/.cloakbrowser/chromium-X.Y.Z/Chromium.app/Contents/MacOS/Chromium
|
||||||
|
* → 4 levels up ~/.cloakbrowser/chromium-X.Y.Z/
|
||||||
|
*
|
||||||
|
* @param {string} binaryPath
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function getVersionedDir(binaryPath) {
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
return path.resolve(path.dirname(binaryPath), '../../..');
|
||||||
|
}
|
||||||
|
return path.dirname(binaryPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return true when the binary at binaryPath belongs to a complete installation.
|
||||||
|
*
|
||||||
|
* On macOS the binary lives inside an .app bundle:
|
||||||
|
* Chromium.app/Contents/MacOS/Chromium
|
||||||
|
* Resource files (icudtl.dat etc.) are deep inside
|
||||||
|
* Chromium.app/Contents/Frameworks/…
|
||||||
|
* so checking for them next to the binary is wrong. Instead we verify the two
|
||||||
|
* structural markers that are only present after a full extraction: Info.plist
|
||||||
|
* and the Frameworks directory inside Contents/.
|
||||||
|
*
|
||||||
|
* On Linux/Windows the binary and all resource files are siblings in the same
|
||||||
|
* directory.
|
||||||
|
*
|
||||||
|
* @param {string} binaryPath
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isBinaryComplete(binaryPath) {
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
const contentsDir = path.resolve(path.dirname(binaryPath), '..');
|
||||||
|
return fs.existsSync(path.join(contentsDir, 'Info.plist')) && fs.existsSync(path.join(contentsDir, 'Frameworks'));
|
||||||
|
}
|
||||||
|
const dir = path.dirname(binaryPath);
|
||||||
|
return LINUX_WIN_REQUIRED_FILES.every((f) => fs.existsSync(path.join(dir, f)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a human-readable description of which required files/dirs are missing.
|
||||||
|
*
|
||||||
|
* @param {string} binaryPath
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function missingDescription(binaryPath) {
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
const contentsDir = path.resolve(path.dirname(binaryPath), '..');
|
||||||
|
return ['Info.plist', 'Frameworks'].filter((f) => !fs.existsSync(path.join(contentsDir, f))).join(', ');
|
||||||
|
}
|
||||||
|
const dir = path.dirname(binaryPath);
|
||||||
|
return LINUX_WIN_REQUIRED_FILES.filter((f) => !fs.existsSync(path.join(dir, f))).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a corrupt binary installation and all `latest_version*` markers from
|
||||||
|
* the CloakBrowser cache so the next `ensureBinary()` call falls back to the
|
||||||
|
* package-bundled version.
|
||||||
|
*
|
||||||
|
* Removes the full versioned directory (e.g. chromium-X.Y.Z/) on all platforms,
|
||||||
|
* not just the subdirectory that contains the binary.
|
||||||
|
*
|
||||||
|
* @param {string} binaryPath - Path to the (corrupt) chrome/Chromium binary.
|
||||||
|
*/
|
||||||
|
function removeCorruptInstallation(binaryPath) {
|
||||||
|
const versionedDir = getVersionedDir(binaryPath);
|
||||||
|
const cacheDir = process.env.CLOAKBROWSER_CACHE_DIR || path.join(os.homedir(), '.cloakbrowser');
|
||||||
|
|
||||||
|
fs.rmSync(versionedDir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const entry of fs.readdirSync(cacheDir)) {
|
||||||
|
if (entry.startsWith('latest_version')) {
|
||||||
|
fs.rmSync(path.join(cacheDir, entry), { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Cache dir may not exist if versionedDir was the only entry — ignore.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the CloakBrowser stealth Chromium binary is present **and** complete.
|
||||||
|
*
|
||||||
|
* `cloakbrowser`'s own `ensureBinary()` only checks that the chrome/Chromium
|
||||||
|
* file exists. An incomplete extraction (e.g. interrupted download, disk full)
|
||||||
|
* can leave a directory that contains the executable but is missing essential
|
||||||
|
* resource files. Chrome then crashes immediately on launch.
|
||||||
|
*
|
||||||
|
* This wrapper validates the path returned by `ensureBinary()`. If the
|
||||||
|
* installation is incomplete it removes the corrupt directory, clears the
|
||||||
|
* version marker files, and calls `ensureBinary()` again so it falls back to
|
||||||
|
* (or re-downloads) a complete build.
|
||||||
|
*
|
||||||
|
* The validated path is also pinned via `CLOAKBROWSER_BINARY_PATH` so that
|
||||||
|
* CloakBrowser's own internal `ensureBinary()` call inside `launch()` always
|
||||||
|
* picks up the same, verified binary.
|
||||||
|
*
|
||||||
|
* @returns {Promise<string>} Absolute path to the validated binary.
|
||||||
|
* @throws {Error} When even the fallback binary is incomplete.
|
||||||
|
*/
|
||||||
|
export async function ensureValidBinary() {
|
||||||
|
const binaryPath = await ensureBinary();
|
||||||
|
|
||||||
|
if (isBinaryComplete(binaryPath)) {
|
||||||
|
process.env.CLOAKBROWSER_BINARY_PATH = binaryPath;
|
||||||
|
return binaryPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
`[fredy] CloakBrowser installation at ${getVersionedDir(binaryPath)} is missing: ${missingDescription(binaryPath)}. Removing and retrying.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
removeCorruptInstallation(binaryPath);
|
||||||
|
|
||||||
|
const fallbackPath = await ensureBinary();
|
||||||
|
if (!isBinaryComplete(fallbackPath)) {
|
||||||
|
throw new Error(
|
||||||
|
`CloakBrowser binary at ${getVersionedDir(fallbackPath)} is still missing required files after re-download: ${missingDescription(fallbackPath)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env.CLOAKBROWSER_BINARY_PATH = fallbackPath;
|
||||||
|
return fallbackPath;
|
||||||
|
}
|
||||||
@@ -94,12 +94,34 @@ export async function applyBotPreventionToPage(page, cfg) {
|
|||||||
// webdriver
|
// webdriver
|
||||||
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
||||||
|
|
||||||
// chrome runtime
|
// chrome runtime - expose loadTimes, csi and app like real Chrome
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (!window.chrome) {
|
window.chrome = {
|
||||||
|
runtime: {},
|
||||||
// @ts-ignore
|
// @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
|
// languages
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -107,23 +129,38 @@ export async function applyBotPreventionToPage(page, cfg) {
|
|||||||
get: () => (window.localStorage.getItem('__LANGS__') || 'de-DE,de').split(','),
|
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
|
// @ts-ignore
|
||||||
Object.defineProperty(navigator, 'plugins', {
|
Object.defineProperty(navigator, 'plugins', { get: () => fakePlugins });
|
||||||
get: () => [{}, {}, {}],
|
// @ts-ignore
|
||||||
});
|
Object.defineProperty(navigator, 'mimeTypes', { get: () => [fakePlugins[0][0]] });
|
||||||
|
|
||||||
// platform and concurrency hints
|
// platform and concurrency hints
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
Object.defineProperty(navigator, 'platform', { get: () => 'Win32' });
|
Object.defineProperty(navigator, 'platform', { get: () => 'Win32' });
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (typeof navigator.hardwareConcurrency === 'number' && navigator.hardwareConcurrency < 2) {
|
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
|
||||||
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 4 });
|
|
||||||
}
|
|
||||||
// @ts-ignore
|
// @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)
|
// userAgentData (Client Hints)
|
||||||
try {
|
try {
|
||||||
@@ -236,6 +273,21 @@ export async function applyBotPreventionToPage(page, cfg) {
|
|||||||
} catch {
|
} catch {
|
||||||
//noop
|
//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 {
|
} catch {
|
||||||
//noop
|
//noop
|
||||||
}
|
}
|
||||||
@@ -273,6 +325,8 @@ export async function applyPostNavigationHumanSignals(page, cfg) {
|
|||||||
const my = Math.floor(vh * (0.3 + Math.random() * 0.4));
|
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.move(mx, my, { steps: 10 + Math.floor(Math.random() * 10) });
|
||||||
await page.mouse.wheel({ deltaY: 100 + Math.floor(Math.random() * 200) });
|
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 {
|
} catch {
|
||||||
// ignore if mouse is unavailable
|
// ignore if mouse is unavailable
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,11 +29,12 @@ export default class Extractor {
|
|||||||
* your response will never contain what you are really looking for
|
* your response will never contain what you are really looking for
|
||||||
* @param url
|
* @param url
|
||||||
* @param waitForSelector
|
* @param waitForSelector
|
||||||
|
* @param jobKey
|
||||||
*/
|
*/
|
||||||
execute = async (url, waitForSelector = null) => {
|
execute = async (url, waitForSelector = null, jobKey = null) => {
|
||||||
this.responseText = null;
|
this.responseText = null;
|
||||||
try {
|
try {
|
||||||
this.responseText = await puppeteerExtractor(url, waitForSelector, this.options);
|
this.responseText = await puppeteerExtractor(url, waitForSelector, { ...this.options, name: jobKey });
|
||||||
if (this.responseText != null) {
|
if (this.responseText != null) {
|
||||||
loadParser(this.responseText);
|
loadParser(this.responseText);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,115 +3,133 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import puppeteer from 'puppeteer-extra';
|
import { launch } from 'cloakbrowser/puppeteer';
|
||||||
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
import { botDetected, debug } from './utils.js';
|
||||||
import { debug, botDetected } from './utils.js';
|
import { getPreLaunchConfig } from './botPrevention.js';
|
||||||
import {
|
|
||||||
getPreLaunchConfig,
|
|
||||||
applyBotPreventionToPage,
|
|
||||||
applyLanguagePersistence,
|
|
||||||
applyPostNavigationHumanSignals,
|
|
||||||
} from './botPrevention.js';
|
|
||||||
import logger from '../logger.js';
|
import logger from '../logger.js';
|
||||||
import fs from 'fs';
|
import { trackPoi } from '../tracking/Tracker.js';
|
||||||
import os from 'os';
|
import { TRACKING_POIS } from '../../TRACKING_POIS.js';
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
puppeteer.use(StealthPlugin());
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch a CloakBrowser/Puppeteer browser instance with stealth and humanizer enabled.
|
||||||
|
*
|
||||||
|
* CloakBrowser applies 49 C++ source-level patches (canvas, WebGL, audio, WebRTC,
|
||||||
|
* navigator.*, automation signals) that are indistinguishable from a real browser.
|
||||||
|
* All fingerprinting and human-behaviour simulation is handled natively; no CDP
|
||||||
|
* overrides (setUserAgent, setExtraHTTPHeaders, evaluateOnNewDocument) are applied
|
||||||
|
* here because they would create detectable inconsistencies on top of the C++ patches.
|
||||||
|
*
|
||||||
|
* @param {string} url - Initial URL (used to derive locale/timezone hints).
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {boolean} [options.puppeteerHeadless]
|
||||||
|
* @param {number} [options.puppeteerTimeout]
|
||||||
|
* @param {string} [options.proxyUrl]
|
||||||
|
* @param {string} [options.timezone]
|
||||||
|
* @param {string} [options.acceptLanguage]
|
||||||
|
* @param {object} [options.viewport]
|
||||||
|
* @returns {Promise<import('puppeteer-core').Browser>}
|
||||||
|
*/
|
||||||
export async function launchBrowser(url, options) {
|
export async function launchBrowser(url, options) {
|
||||||
const preCfg = getPreLaunchConfig(url, options || {});
|
const preCfg = getPreLaunchConfig(url, options || {});
|
||||||
const launchArgs = [
|
|
||||||
|
// Docker requires --no-sandbox; CloakBrowser handles all stealth args internally.
|
||||||
|
// --ignore-certificate-errors is needed because CloakBrowser ships its own Chromium
|
||||||
|
// binary with an independent CA bundle that may not trust proxies or interceptors
|
||||||
|
// present in the host environment.
|
||||||
|
const args = [
|
||||||
'--no-sandbox',
|
'--no-sandbox',
|
||||||
'--disable-gpu',
|
|
||||||
'--disable-setuid-sandbox',
|
'--disable-setuid-sandbox',
|
||||||
'--disable-dev-shm-usage',
|
'--disable-dev-shm-usage',
|
||||||
'--disable-crash-reporter',
|
|
||||||
'--no-first-run',
|
'--no-first-run',
|
||||||
'--no-default-browser-check',
|
'--no-default-browser-check',
|
||||||
preCfg.langArg,
|
'--ignore-certificate-errors',
|
||||||
|
// Disables the zygote process model. Required in some container environments
|
||||||
|
// (e.g. limited kernel namespaces) where the zygote cannot acquire the
|
||||||
|
// locks it needs and exits with "Invalid file descriptor to ICU data received".
|
||||||
|
'--no-zygote',
|
||||||
preCfg.windowSizeArg,
|
preCfg.windowSizeArg,
|
||||||
...preCfg.extraArgs,
|
|
||||||
];
|
];
|
||||||
if (options?.proxyUrl) {
|
|
||||||
launchArgs.push(`--proxy-server=${options.proxyUrl}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let userDataDir;
|
return await launch({
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const browser = await puppeteer.launch({
|
|
||||||
headless: options?.puppeteerHeadless ?? true,
|
headless: options?.puppeteerHeadless ?? true,
|
||||||
args: launchArgs,
|
humanize: true,
|
||||||
timeout: options?.puppeteerTimeout || 45_000,
|
args,
|
||||||
userDataDir,
|
// locale sets Accept-Language headers and JS navigator.language consistently
|
||||||
executablePath: options?.executablePath,
|
locale: preCfg.langForFlag,
|
||||||
|
...(options?.proxyUrl ? { proxy: options.proxyUrl } : {}),
|
||||||
|
...(preCfg.timezone ? { timezone: preCfg.timezone } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
browser.__fredy_userDataDir = userDataDir;
|
|
||||||
browser.__fredy_removeUserDataDir = removeUserDataDir;
|
|
||||||
|
|
||||||
return browser;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close a browser instance returned by {@link launchBrowser}.
|
||||||
|
*
|
||||||
|
* @param {import('puppeteer-core').Browser | null} browser
|
||||||
|
*/
|
||||||
export async function closeBrowser(browser) {
|
export async function closeBrowser(browser) {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
const userDataDir = browser.__fredy_userDataDir;
|
|
||||||
const removeUserDataDir = browser.__fredy_removeUserDataDir;
|
|
||||||
try {
|
try {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
if (removeUserDataDir && userDataDir) {
|
|
||||||
try {
|
|
||||||
await fs.promises.rm(userDataDir, { recursive: true, force: true });
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a page in a (possibly reused) browser, navigate to `url`, and return the HTML source.
|
||||||
|
* Returns `null` when a bot-detection page is encountered or on timeout.
|
||||||
|
*
|
||||||
|
* @param {string} url
|
||||||
|
* @param {string | null} waitForSelector
|
||||||
|
* @param {object} [options]
|
||||||
|
* @returns {Promise<string | null>}
|
||||||
|
*/
|
||||||
export default async function execute(url, waitForSelector, options) {
|
export default async function execute(url, waitForSelector, options) {
|
||||||
let browser = options?.browser;
|
let browser = options?.browser;
|
||||||
let isExternalBrowser = !!browser;
|
let isExternalBrowser = !!browser;
|
||||||
let page;
|
let page;
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
debug(`Sending request to ${url} using Puppeteer.`);
|
debug(`Sending request to ${url} using CloakBrowser.`);
|
||||||
|
|
||||||
if (!isExternalBrowser) {
|
if (!isExternalBrowser) {
|
||||||
browser = await launchBrowser(url, options);
|
browser = await launchBrowser(url, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
page = await browser.newPage();
|
page = await browser.newPage();
|
||||||
const preCfg = getPreLaunchConfig(url, options || {});
|
|
||||||
await applyBotPreventionToPage(page, preCfg);
|
|
||||||
// Provide languages value before navigation
|
|
||||||
await applyLanguagePersistence(page, preCfg);
|
|
||||||
|
|
||||||
// Optional cookies
|
|
||||||
if (Array.isArray(options?.cookies) && options.cookies.length > 0) {
|
if (Array.isArray(options?.cookies) && options.cookies.length > 0) {
|
||||||
await page.setCookie(...options.cookies);
|
await page.setCookie(...options.cookies);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigation
|
// Warm-up navigation: visit a trusted page first so the site sees an
|
||||||
|
// established session before the actual target URL. Silently ignored on
|
||||||
|
// failure so it never blocks the main request.
|
||||||
|
if (options?.preNavigateUrl) {
|
||||||
|
try {
|
||||||
|
await page.goto(options.preNavigateUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
||||||
|
await new Promise((r) => setTimeout(r, 1500 + Math.random() * 2000));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const response = await page.goto(url, {
|
const response = await page.goto(url, {
|
||||||
waitUntil: options?.waitUntil || 'domcontentloaded',
|
waitUntil: options?.waitUntil || 'domcontentloaded',
|
||||||
|
timeout: options?.puppeteerTimeout || 60000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Optionally wait and add subtle human-like interactions
|
// Optional second idle wait: useful for React SPAs that trigger API calls
|
||||||
await applyPostNavigationHumanSignals(page, preCfg);
|
// after domcontentloaded. Times out silently so we use whatever is rendered.
|
||||||
|
if (options?.waitForNetworkIdle) {
|
||||||
|
try {
|
||||||
|
await page.waitForNetworkIdle({ timeout: options?.waitForNetworkIdleTimeout ?? 60_000 });
|
||||||
|
} catch {
|
||||||
|
// ignore — we proceed with whatever the DOM contains at this point
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let pageSource;
|
let pageSource;
|
||||||
// if we're extracting data from a SPA, we must wait for the selector
|
|
||||||
if (waitForSelector != null) {
|
if (waitForSelector != null) {
|
||||||
const selectorTimeout = options?.puppeteerSelectorTimeout ?? options?.puppeteerTimeout ?? 30_000;
|
const selectorTimeout = options?.puppeteerSelectorTimeout ?? options?.puppeteerTimeout ?? 30_000;
|
||||||
await page.waitForSelector(waitForSelector, { timeout: selectorTimeout });
|
await page.waitForSelector(waitForSelector, { timeout: selectorTimeout });
|
||||||
@@ -127,15 +145,22 @@ export default async function execute(url, waitForSelector, options) {
|
|||||||
|
|
||||||
if (botDetected(pageSource, statusCode)) {
|
if (botDetected(pageSource, statusCode)) {
|
||||||
logger.warn('We have been detected as a bot :-/ Tried url: => ', url);
|
logger.warn('We have been detected as a bot :-/ Tried url: => ', url);
|
||||||
|
|
||||||
|
if (options != null && options.name != null) {
|
||||||
|
await trackPoi(TRACKING_POIS.DETECTED_AS_BOT + '_' + options.name);
|
||||||
|
} else {
|
||||||
|
await trackPoi(TRACKING_POIS.DETECTED_AS_BOT);
|
||||||
|
}
|
||||||
|
|
||||||
result = null;
|
result = null;
|
||||||
} else {
|
} else {
|
||||||
result = pageSource || (await page.content());
|
result = pageSource || (await page.content());
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.name?.includes('Timeout')) {
|
if (error?.name?.includes('Timeout')) {
|
||||||
logger.debug('Error executing with puppeteer executor', error);
|
logger.debug('Error executing with CloakBrowser executor', error);
|
||||||
} else {
|
} else {
|
||||||
logger.warn('Error executing with puppeteer executor', error);
|
logger.warn('Error executing with CloakBrowser executor', error);
|
||||||
}
|
}
|
||||||
result = null;
|
result = null;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -141,6 +141,43 @@ const WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP = {
|
|||||||
'barrierefreie-wohnung-mieten': { equipment: ['handicappedaccessible'] },
|
'barrierefreie-wohnung-mieten': { equipment: ['handicappedaccessible'] },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// SEO-optimized rental paths used by the ImmoScout web UI when the user
|
||||||
|
// configures a maximum warmrent. Example: "wohnung-bis-800-euro-warm" means
|
||||||
|
// "apartment for rent up to 800 EUR warmrent". The web UI generates these
|
||||||
|
// paths instead of explicit `price` / `pricetype` query parameters.
|
||||||
|
// Note: only the warmrent variant uses an SEO slug; max coldrent searches
|
||||||
|
// use the regular "wohnung-mieten" path with explicit `price` and
|
||||||
|
// `pricetype=rentpermonth` query params, which the existing translator
|
||||||
|
// already handles.
|
||||||
|
const SEO_RENT_TYPE_TO_REAL_ESTATE_TYPE = {
|
||||||
|
wohnung: 'apartmentrent',
|
||||||
|
haus: 'houserent',
|
||||||
|
};
|
||||||
|
const SEO_MAX_WARMRENT_PATH_PATTERN = /^(?<type>wohnung|haus)-bis-(?<price>\d+)-euro-warm$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses SEO-optimized ImmoScout web paths that encode a maximum warmrent, such
|
||||||
|
* as "wohnung-bis-800-euro-warm". Returns the corresponding mobile API real
|
||||||
|
* estate type and the implicit price/pricetype parameters, or null if the path
|
||||||
|
* does not match the known SEO max-warmrent pattern.
|
||||||
|
*
|
||||||
|
* @param {string} realTypeKey The last segment of the URL path.
|
||||||
|
* @returns {{ realType: string, additionalParams: Record<string, string> } | null}
|
||||||
|
*/
|
||||||
|
function parseSeoMaxWarmrentPath(realTypeKey) {
|
||||||
|
const match = realTypeKey.match(SEO_MAX_WARMRENT_PATH_PATTERN);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const { type, price } = match.groups;
|
||||||
|
return {
|
||||||
|
realType: SEO_RENT_TYPE_TO_REAL_ESTATE_TYPE[type],
|
||||||
|
additionalParams: {
|
||||||
|
price: `-${price}`,
|
||||||
|
pricetype: 'calculatedtotalrent',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function convertWebToMobile(webUrl) {
|
export function convertWebToMobile(webUrl) {
|
||||||
let url;
|
let url;
|
||||||
try {
|
try {
|
||||||
@@ -164,14 +201,17 @@ export function convertWebToMobile(webUrl) {
|
|||||||
additionalParamsFromWebPath = WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP[realTypeKey];
|
additionalParamsFromWebPath = WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP[realTypeKey];
|
||||||
realType = REAL_ESTATE_TYPE['wohnung-mieten'];
|
realType = REAL_ESTATE_TYPE['wohnung-mieten'];
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Real estate type not found: ${realTypeKey}`);
|
// Test for SEO max-warmrent path, e.g. "wohnung-bis-800-euro-warm"
|
||||||
|
const seoMaxWarmrent = parseSeoMaxWarmrentPath(realTypeKey);
|
||||||
|
if (seoMaxWarmrent) {
|
||||||
|
realType = seoMaxWarmrent.realType;
|
||||||
|
additionalParamsFromWebPath = seoMaxWarmrent.additionalParams;
|
||||||
|
} else {
|
||||||
|
throw new Error(`Real estate type not found: ${realTypeKey}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (segments.includes('shape')) {
|
|
||||||
throw new Error('Shape is currently not supported using Immoscout');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { query: rawParams } = queryString.parseUrl(webUrl, { arrayFormat: 'comma' });
|
const { query: rawParams } = queryString.parseUrl(webUrl, { arrayFormat: 'comma' });
|
||||||
const webParams = Object.fromEntries(
|
const webParams = Object.fromEntries(
|
||||||
Object.entries(rawParams).filter(([key]) => key !== 'enteredFrom' && PARAM_NAME_MAP[key]),
|
Object.entries(rawParams).filter(([key]) => key !== 'enteredFrom' && PARAM_NAME_MAP[key]),
|
||||||
@@ -179,18 +219,31 @@ export function convertWebToMobile(webUrl) {
|
|||||||
|
|
||||||
const geocodes = `/${segments.slice(2, segments.length - 1).join('/')}`;
|
const geocodes = `/${segments.slice(2, segments.length - 1).join('/')}`;
|
||||||
const isRadius = segments.includes('radius');
|
const isRadius = segments.includes('radius');
|
||||||
|
const isShape = segments.includes('shape');
|
||||||
const mobileParams = {
|
const mobileParams = {
|
||||||
searchType: isRadius ? 'radius' : 'region',
|
searchType: isRadius ? 'radius' : isShape ? 'shape' : 'region',
|
||||||
realestatetype: realType,
|
realestatetype: realType,
|
||||||
...(isRadius ? {} : { geocodes }),
|
...(isRadius || isShape ? {} : { geocodes }),
|
||||||
...additionalParamsFromWebPath,
|
...additionalParamsFromWebPath,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isShape && !webParams.shape) {
|
||||||
|
throw new Error('Shape search URL is missing the required "shape" query parameter');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isShape && webParams.shape) {
|
||||||
|
const browserShape = webParams.shape;
|
||||||
|
const normalized = browserShape.replace(/\.\./g, '==').replace(/\./g, '=');
|
||||||
|
const polyline = Buffer.from(normalized, 'base64').toString('utf-8');
|
||||||
|
mobileParams.shape = polyline;
|
||||||
|
}
|
||||||
|
|
||||||
if (webParams.geocoordinates) {
|
if (webParams.geocoordinates) {
|
||||||
mobileParams.geocoordinates = webParams.geocoordinates;
|
mobileParams.geocoordinates = webParams.geocoordinates;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [key, val] of Object.entries(webParams)) {
|
for (const [key, val] of Object.entries(webParams)) {
|
||||||
|
if (key === 'shape') continue;
|
||||||
if (key === 'equipment') {
|
if (key === 'equipment') {
|
||||||
const items = [].concat(val).flatMap((v) => `${v}`.split(','));
|
const items = [].concat(val).flatMap((v) => `${v}`.split(','));
|
||||||
const currentEquipmentParams = mobileParams[PARAM_NAME_MAP[key]];
|
const currentEquipmentParams = mobileParams[PARAM_NAME_MAP[key]];
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import * as similarityCache from '../similarity-check/similarityCache.js';
|
|||||||
import { isRunning, markFinished, markRunning } from './run-state.js';
|
import { isRunning, markFinished, markRunning } from './run-state.js';
|
||||||
import { sendToUsers } from '../sse/sse-broker.js';
|
import { sendToUsers } from '../sse/sse-broker.js';
|
||||||
import * as puppeteerExtractor from '../extractor/puppeteerExtractor.js';
|
import * as puppeteerExtractor from '../extractor/puppeteerExtractor.js';
|
||||||
|
import { getSettings } from '../storage/settingsStorage.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the job execution service.
|
* Initializes the job execution service.
|
||||||
@@ -160,6 +161,14 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
|
|||||||
}
|
}
|
||||||
let browser;
|
let browser;
|
||||||
try {
|
try {
|
||||||
|
// Read the proxy live (not from the startup snapshot) so changing it in the
|
||||||
|
// UI takes effect on the next run without a backend restart. An empty value
|
||||||
|
// disables the proxy. Routing the headless browser through a (German
|
||||||
|
// residential) proxy avoids datacenter-IP based bot detection on the
|
||||||
|
// Puppeteer-based providers (immowelt, immonet, kleinanzeigen, ...).
|
||||||
|
const liveSettings = await getSettings();
|
||||||
|
const proxyUrl = typeof liveSettings?.proxyUrl === 'string' ? liveSettings.proxyUrl.trim() : '';
|
||||||
|
|
||||||
const jobProviders = job.provider.filter(
|
const jobProviders = job.provider.filter(
|
||||||
(p) => providers.find((loaded) => loaded.metaInformation.id === p.id) != null,
|
(p) => providers.find((loaded) => loaded.metaInformation.id === p.id) != null,
|
||||||
);
|
);
|
||||||
@@ -168,25 +177,17 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
|
|||||||
const matchedProvider = providers.find((loaded) => loaded.metaInformation.id === prov.id);
|
const matchedProvider = providers.find((loaded) => loaded.metaInformation.id === prov.id);
|
||||||
matchedProvider.init({ ...prov, userId: job.userId }, job.blacklist);
|
matchedProvider.init({ ...prov, userId: job.userId }, job.blacklist);
|
||||||
|
|
||||||
if (browser && !browser.isConnected()) {
|
if (browser && !browser.connected) {
|
||||||
logger.debug('Browser is disconnected, nullifying to launch a new one.');
|
logger.debug('Browser is disconnected, nullifying to launch a new one.');
|
||||||
await puppeteerExtractor.closeBrowser(browser);
|
await puppeteerExtractor.closeBrowser(browser);
|
||||||
browser = null;
|
browser = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!browser && matchedProvider.config.getListings == null) {
|
if (!browser && matchedProvider.config.getListings == null) {
|
||||||
browser = await puppeteerExtractor.launchBrowser(matchedProvider.config.url, {});
|
browser = await puppeteerExtractor.launchBrowser(matchedProvider.config.url, proxyUrl ? { proxyUrl } : {});
|
||||||
}
|
}
|
||||||
|
|
||||||
await new FredyPipelineExecutioner(
|
await new FredyPipelineExecutioner(matchedProvider.config, job, prov.id, similarityCache, browser).execute();
|
||||||
matchedProvider.config,
|
|
||||||
job.notificationAdapter,
|
|
||||||
job.spatialFilter,
|
|
||||||
prov.id,
|
|
||||||
job.id,
|
|
||||||
similarityCache,
|
|
||||||
browser,
|
|
||||||
).execute();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(err);
|
logger.error(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const upsertJob = ({
|
|||||||
userId,
|
userId,
|
||||||
shareWithUsers = [],
|
shareWithUsers = [],
|
||||||
spatialFilter = null,
|
spatialFilter = null,
|
||||||
|
specFilter = null,
|
||||||
}) => {
|
}) => {
|
||||||
const id = jobId || nanoid();
|
const id = jobId || nanoid();
|
||||||
const existing = SqliteConnection.query(`SELECT id, user_id FROM jobs WHERE id = @id LIMIT 1`, { id })[0];
|
const existing = SqliteConnection.query(`SELECT id, user_id FROM jobs WHERE id = @id LIMIT 1`, { id })[0];
|
||||||
@@ -44,7 +45,8 @@ export const upsertJob = ({
|
|||||||
provider = @provider,
|
provider = @provider,
|
||||||
notification_adapter = @notification_adapter,
|
notification_adapter = @notification_adapter,
|
||||||
shared_with_user = @shareWithUsers,
|
shared_with_user = @shareWithUsers,
|
||||||
spatial_filter = @spatialFilter
|
spatial_filter = @spatialFilter,
|
||||||
|
spec_filter = @specFilter
|
||||||
WHERE id = @id`,
|
WHERE id = @id`,
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
@@ -55,12 +57,13 @@ export const upsertJob = ({
|
|||||||
provider: toJson(provider ?? []),
|
provider: toJson(provider ?? []),
|
||||||
notification_adapter: toJson(notificationAdapter ?? []),
|
notification_adapter: toJson(notificationAdapter ?? []),
|
||||||
spatialFilter: spatialFilter ? toJson(spatialFilter) : null,
|
spatialFilter: spatialFilter ? toJson(spatialFilter) : null,
|
||||||
|
specFilter: specFilter ? toJson(specFilter) : null,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
SqliteConnection.execute(
|
SqliteConnection.execute(
|
||||||
`INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter, shared_with_user, spatial_filter)
|
`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)`,
|
VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter, @shareWithUsers, @spatialFilter, @specFilter)`,
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
user_id: ownerId,
|
user_id: ownerId,
|
||||||
@@ -71,6 +74,7 @@ export const upsertJob = ({
|
|||||||
shareWithUsers: toJson(shareWithUsers ?? []),
|
shareWithUsers: toJson(shareWithUsers ?? []),
|
||||||
notification_adapter: toJson(notificationAdapter ?? []),
|
notification_adapter: toJson(notificationAdapter ?? []),
|
||||||
spatialFilter: spatialFilter ? toJson(spatialFilter) : null,
|
spatialFilter: spatialFilter ? toJson(spatialFilter) : null,
|
||||||
|
specFilter: specFilter ? toJson(specFilter) : null,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -92,6 +96,7 @@ export const getJob = (jobId) => {
|
|||||||
j.shared_with_user,
|
j.shared_with_user,
|
||||||
j.notification_adapter AS notificationAdapter,
|
j.notification_adapter AS notificationAdapter,
|
||||||
j.spatial_filter AS spatialFilter,
|
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
|
(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
|
FROM jobs j
|
||||||
WHERE j.id = @id
|
WHERE j.id = @id
|
||||||
@@ -107,6 +112,7 @@ export const getJob = (jobId) => {
|
|||||||
shared_with_user: fromJson(row.shared_with_user, []),
|
shared_with_user: fromJson(row.shared_with_user, []),
|
||||||
notificationAdapter: fromJson(row.notificationAdapter, []),
|
notificationAdapter: fromJson(row.notificationAdapter, []),
|
||||||
spatialFilter: fromJson(row.spatialFilter, null),
|
spatialFilter: fromJson(row.spatialFilter, null),
|
||||||
|
specFilter: fromJson(row.specFilter, null),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -157,6 +163,7 @@ export const getJobs = () => {
|
|||||||
j.shared_with_user,
|
j.shared_with_user,
|
||||||
j.notification_adapter AS notificationAdapter,
|
j.notification_adapter AS notificationAdapter,
|
||||||
j.spatial_filter AS spatialFilter,
|
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
|
(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
|
FROM jobs j
|
||||||
WHERE j.enabled = 1
|
WHERE j.enabled = 1
|
||||||
@@ -170,6 +177,7 @@ export const getJobs = () => {
|
|||||||
shared_with_user: fromJson(row.shared_with_user, []),
|
shared_with_user: fromJson(row.shared_with_user, []),
|
||||||
notificationAdapter: fromJson(row.notificationAdapter, []),
|
notificationAdapter: fromJson(row.notificationAdapter, []),
|
||||||
spatialFilter: fromJson(row.spatialFilter, null),
|
spatialFilter: fromJson(row.spatialFilter, null),
|
||||||
|
specFilter: fromJson(row.specFilter, null),
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -260,6 +268,7 @@ export const queryJobs = ({
|
|||||||
j.shared_with_user,
|
j.shared_with_user,
|
||||||
j.notification_adapter AS notificationAdapter,
|
j.notification_adapter AS notificationAdapter,
|
||||||
j.spatial_filter AS spatialFilter,
|
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
|
(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
|
FROM jobs j
|
||||||
${whereSql}
|
${whereSql}
|
||||||
@@ -276,6 +285,7 @@ export const queryJobs = ({
|
|||||||
shared_with_user: fromJson(row.shared_with_user, []),
|
shared_with_user: fromJson(row.shared_with_user, []),
|
||||||
notificationAdapter: fromJson(row.notificationAdapter, []),
|
notificationAdapter: fromJson(row.notificationAdapter, []),
|
||||||
spatialFilter: fromJson(row.spatialFilter, null),
|
spatialFilter: fromJson(row.spatialFilter, null),
|
||||||
|
specFilter: fromJson(row.specFilter, null),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { totalNumber, page: safePage, result };
|
return { totalNumber, page: safePage, result };
|
||||||
|
|||||||
@@ -29,33 +29,47 @@ export const getKnownListingHashesForJobAndProvider = (jobId, providerId) => {
|
|||||||
* Compute KPI aggregates for a given set of job IDs from the listings table.
|
* Compute KPI aggregates for a given set of job IDs from the listings table.
|
||||||
*
|
*
|
||||||
* - numberOfActiveListings: count of listings where is_active = 1
|
* - 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.
|
* When no jobIds are provided, returns zeros.
|
||||||
*
|
*
|
||||||
* @param {string[]} jobIds
|
* @param {string[]} jobIds
|
||||||
* @returns {{ numberOfActiveListings: number, avgPriceOfListings: number }}
|
* @returns {{ numberOfActiveListings: number, medianPriceOfListings: number }}
|
||||||
*/
|
*/
|
||||||
export const getListingsKpisForJobIds = (jobIds = []) => {
|
export const getListingsKpisForJobIds = (jobIds = []) => {
|
||||||
if (!Array.isArray(jobIds) || jobIds.length === 0) {
|
if (!Array.isArray(jobIds) || jobIds.length === 0) {
|
||||||
return { numberOfActiveListings: 0, avgPriceOfListings: 0 };
|
return { numberOfActiveListings: 0, medianPriceOfListings: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const placeholders = jobIds.map(() => '?').join(',');
|
const placeholders = jobIds.map(() => '?').join(',');
|
||||||
const row =
|
const rows = SqliteConnection.query(
|
||||||
SqliteConnection.query(
|
`SELECT
|
||||||
`SELECT
|
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) OVER() AS active_count,
|
||||||
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) AS activeCount,
|
price
|
||||||
AVG(price) AS avgPrice
|
FROM listings
|
||||||
FROM listings
|
WHERE job_id IN (${placeholders})
|
||||||
WHERE job_id IN (${placeholders})
|
AND manually_deleted = 0
|
||||||
AND manually_deleted = 0`,
|
GROUP BY
|
||||||
jobIds,
|
id`,
|
||||||
)[0] || {};
|
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 {
|
return {
|
||||||
numberOfActiveListings: Number(row.activeCount || 0),
|
numberOfActiveListings: activeCount,
|
||||||
avgPriceOfListings: row?.avgPrice == null ? 0 : Math.round(Number(row.avgPrice)),
|
medianPriceOfListings: medianPrice,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -174,9 +188,9 @@ export const storeListings = (jobId, providerId, listings) => {
|
|||||||
|
|
||||||
SqliteConnection.withTransaction((db) => {
|
SqliteConnection.withTransaction((db) => {
|
||||||
const stmt = db.prepare(
|
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)
|
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)
|
@created_at, 1, @latitude, @longitude)
|
||||||
ON CONFLICT(job_id, hash) DO NOTHING`,
|
ON CONFLICT(job_id, hash) DO NOTHING`,
|
||||||
);
|
);
|
||||||
@@ -187,8 +201,9 @@ export const storeListings = (jobId, providerId, listings) => {
|
|||||||
hash: item.id,
|
hash: item.id,
|
||||||
provider: providerId,
|
provider: providerId,
|
||||||
job_id: jobId,
|
job_id: jobId,
|
||||||
price: extractNumber(item.price),
|
price: item.price,
|
||||||
size: extractNumber(item.size),
|
size: item.size,
|
||||||
|
rooms: item.rooms,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
image_url: item.image,
|
image_url: item.image,
|
||||||
description: item.description,
|
description: item.description,
|
||||||
@@ -199,22 +214,11 @@ export const storeListings = (jobId, providerId, listings) => {
|
|||||||
longitude: item.longitude || null,
|
longitude: item.longitude || null,
|
||||||
};
|
};
|
||||||
stmt.run(params);
|
stmt.run(params);
|
||||||
|
// Propagate the DB primary key back so downstream pipeline steps use the correct id
|
||||||
|
item.id = params.id;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
* Remove any parentheses segments (including surrounding whitespace) from a string.
|
||||||
* Returns null for empty input.
|
* Returns null for empty input.
|
||||||
@@ -415,9 +419,10 @@ export const deleteListingsByJobId = (jobId, hardDelete = false) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete listings by a list of listing IDs.
|
* Delete listings by a list of listing IDs (the nanoid primary key stored in the `id` column).
|
||||||
|
* Used by API routes that receive row IDs from the client.
|
||||||
*
|
*
|
||||||
* @param {string[]} ids - Array of listing IDs to delete.
|
* @param {string[]} ids - Array of DB row IDs to delete.
|
||||||
* @param {boolean} [hardDelete=false] - Whether to hard delete from DB or just mark as deleted.
|
* @param {boolean} [hardDelete=false] - Whether to hard delete from DB or just mark as deleted.
|
||||||
* @returns {any} The result from SqliteConnection.execute.
|
* @returns {any} The result from SqliteConnection.execute.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -29,12 +29,12 @@
|
|||||||
*/
|
*/
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { pathToFileURL } from 'url';
|
import { pathToFileURL, fileURLToPath } from 'url';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import SqliteConnection from '../SqliteConnection.js';
|
import SqliteConnection from '../SqliteConnection.js';
|
||||||
import logger from '../../logger.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).
|
* Absolute path to the migrations directory (lib/services/storage/migrations/sql).
|
||||||
* @type {string}
|
* @type {string}
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ import crypto from 'crypto';
|
|||||||
// Each user gets a permanent, non-expiring secret token used for MCP API authentication.
|
// 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.
|
// Tokens are auto-generated for all existing users during this migration.
|
||||||
export function up(db) {
|
export function up(db) {
|
||||||
db.exec(`ALTER TABLE users ADD COLUMN mcp_token TEXT`);
|
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
|
// 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 users = db.prepare(`SELECT id FROM users WHERE mcp_token IS NULL`).all();
|
||||||
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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
lib/services/storage/migrations/sql/15.add-listing-specs.js
Normal file
10
lib/services/storage/migrations/sql/15.add-listing-specs.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 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)) });
|
||||||
|
}
|
||||||
@@ -67,6 +67,19 @@ export async function getSettings() {
|
|||||||
return cachedSettingsConfig;
|
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.
|
* Upsert settings rows.
|
||||||
* - Accepts an object map of name -> value, or an entry {name, value}.
|
* - Accepts an object map of name -> value, or an entry {name, value}.
|
||||||
|
|||||||
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 {};
|
||||||
22
lib/types/listing.js
Normal file
22
lib/types/listing.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} ParsedListing
|
||||||
|
* @property {string} id Stable unique identifier (hash) of the listing.
|
||||||
|
* @property {string} link Link to the listing detail page.
|
||||||
|
* @property {string} image Link to the listing image.
|
||||||
|
* @property {string} title Title or headline of the listing.
|
||||||
|
* @property {string} [description] Description of the listing.
|
||||||
|
* @property {string} [address] Optional address/location text.
|
||||||
|
* @property {number} [price] Optional price of the listing.
|
||||||
|
* @property {number} [size] Optional size of the listing.
|
||||||
|
* @property {number} [rooms] Optional number of rooms.
|
||||||
|
* @property {number} [latitude] Optional latitude.
|
||||||
|
* @property {number} [longitude] Optional longitude.
|
||||||
|
* @property {number} [distance_to_destination] Optional distance to destination.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {};
|
||||||
25
lib/types/providerConfig.js
Normal file
25
lib/types/providerConfig.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @import { ParsedListing } from './listing.js' */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} ProviderConfig
|
||||||
|
* @property {string} [url] Base URL to crawl.
|
||||||
|
* @property {string} [sortByDateParam] Query parameter used to enforce sorting by date.
|
||||||
|
* @property {string} [waitForSelector] CSS selector to wait for before parsing content.
|
||||||
|
* @property {Object.<string, string>} crawlFields Mapping of field names to selectors/paths.
|
||||||
|
* @property {string[]} requiredFieldNames List of field names that this provider supports.
|
||||||
|
* @property {string} [crawlContainer] CSS selector for the container holding listing items.
|
||||||
|
* @property {(raw: any) => ParsedListing} normalize Function to convert raw scraped data into a ParsedListing shape.
|
||||||
|
* @property {(listing: ParsedListing) => boolean} filter Function to filter out unwanted listings.
|
||||||
|
* @property {(url: string, waitForSelector?: string) => Promise<any[]>} [getListings] Optional override to fetch listings.
|
||||||
|
* @property {(listing:ParsedListing, browser:any)=>Promise<ParsedListing>} [providerConfig.fetchDetails] Optional per-listing detail enrichment. Called in parallel for each new listing after deduplication. Receives the shared browser instance. Must always resolve (never reject).
|
||||||
|
* @property {Object} [puppeteerOptions] Puppeteer specific options.
|
||||||
|
* @property {boolean} [enabled] Whether the provider is enabled.
|
||||||
|
* @property {(url: string) => Promise<number> | number} [activeTester] Function to check if a listing is still active.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {};
|
||||||
11
lib/types/similarityCache.js
Normal file
11
lib/types/similarityCache.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
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} SimilarityCache
|
||||||
|
* @property {(params: { title?: string, address?: string, price?: number|string }) => boolean} checkAndAddEntry Checks if a listing is similar and adds it if not.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {};
|
||||||
55
lib/utils/detectBaseUrl.js
Normal file
55
lib/utils/detectBaseUrl.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import os from 'os';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const DOCKER_BRIDGE_PREFIXES = ['172.17.', '172.18.', '172.19.', '172.20.'];
|
||||||
|
|
||||||
|
export function isRunningInDocker() {
|
||||||
|
if (process.env.FREDY_DOCKER === '1') return true;
|
||||||
|
try {
|
||||||
|
fs.accessSync('/.dockerenv');
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
/* not docker */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const cgroup = fs.readFileSync('/proc/self/cgroup', 'utf8');
|
||||||
|
return /docker|containerd|kubepods/.test(cgroup);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDockerBridgeIp(addr) {
|
||||||
|
return DOCKER_BRIDGE_PREFIXES.some((prefix) => addr.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectLocalIp() {
|
||||||
|
if (isRunningInDocker()) {
|
||||||
|
return process.env.FREDY_HOST_IP ?? '172.17.0.1';
|
||||||
|
}
|
||||||
|
const ifaces = os.networkInterfaces();
|
||||||
|
for (const preferred of ['en0', 'eth0', 'wlan0', 'ens3', 'ens18']) {
|
||||||
|
for (const entry of ifaces[preferred] ?? []) {
|
||||||
|
if (entry.family === 'IPv4' && !entry.internal && !isDockerBridgeIp(entry.address)) {
|
||||||
|
return entry.address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const iface of Object.values(ifaces)) {
|
||||||
|
for (const entry of iface ?? []) {
|
||||||
|
if (entry.family === 'IPv4' && !entry.internal && !isDockerBridgeIp(entry.address)) {
|
||||||
|
return entry.address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'localhost';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function guessBaseUrl(port) {
|
||||||
|
return `http://${detectLocalIp()}:${port}`;
|
||||||
|
}
|
||||||
18
lib/utils/extract-number.js
Normal file
18
lib/utils/extract-number.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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}
|
||||||
|
*/
|
||||||
|
export const extractNumber = (str) => {
|
||||||
|
if (str == null) return 0;
|
||||||
|
if (typeof str === 'number') return str;
|
||||||
|
const cleaned = str.replace(/\./g, '').replace(',', '.');
|
||||||
|
const num = parseFloat(cleaned);
|
||||||
|
return isNaN(num) ? null : num;
|
||||||
|
};
|
||||||
29
lib/utils/formatListing.js
Normal file
29
lib/utils/formatListing.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @import { ParsedListing } from '../types/listing.js' */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Omit<import('../types/listing.js').ParsedListing, 'price' | 'size' | 'rooms'> & {
|
||||||
|
* price: string | null,
|
||||||
|
* size: string | null,
|
||||||
|
* rooms: string | null,
|
||||||
|
* }} FormattedListing
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a listing's numerical fields (price, size, rooms) into strings with their respective units.
|
||||||
|
*
|
||||||
|
* @param {import('../types/listing.js').ParsedListing} listing The original listing object.
|
||||||
|
* @returns {FormattedListing} A copy of the listing with formatted strings for price, size, and rooms.
|
||||||
|
*/
|
||||||
|
export const formatListing = (listing) => {
|
||||||
|
return {
|
||||||
|
...listing,
|
||||||
|
price: listing.price != null ? `${listing.price} €` : null,
|
||||||
|
size: listing.size != null ? `${listing.size} m²` : null,
|
||||||
|
rooms: listing.rooms != null ? `${listing.rooms} Zimmer` : null,
|
||||||
|
};
|
||||||
|
};
|
||||||
85
package.json
85
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fredy",
|
"name": "fredy",
|
||||||
"version": "20.0.4",
|
"version": "22.2.2",
|
||||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
@@ -11,8 +11,9 @@
|
|||||||
"build:frontend": "vite build",
|
"build:frontend": "vite build",
|
||||||
"format": "prettier --write \"**/*.js\"",
|
"format": "prettier --write \"**/*.js\"",
|
||||||
"format:check": "prettier --check \"**/*.js\"",
|
"format:check": "prettier --check \"**/*.js\"",
|
||||||
"test": "node --import ./test/esmock-loader.mjs ./node_modules/mocha/bin/mocha.js --timeout 60000 test/**/*.test.js",
|
"test": "x-var TEST_MODE=live vitest run",
|
||||||
"testGH": "node --import ./test/esmock-loader.mjs ./node_modules/mocha/bin/mocha.js --timeout 60000 --exclude test/provider/immonet.test.js --exclude test/provider/immobilienDe.test.js --exclude test/provider/immowelt.test.js test/**/*.test.js",
|
"test:offline": "x-var TEST_MODE=offline vitest run",
|
||||||
|
"test:download-fixtures": "node tools/testFixtures/downloadFixtures.js",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"mcp:stdio": "node lib/mcp/stdio.js",
|
"mcp:stdio": "node lib/mcp/stdio.js",
|
||||||
"lint:fix": "yarn lint --fix",
|
"lint:fix": "yarn lint --fix",
|
||||||
@@ -61,67 +62,65 @@
|
|||||||
"Firefox ESR"
|
"Firefox ESR"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@douyinfe/semi-icons": "^2.93.0",
|
"@douyinfe/semi-icons": "^2.99.3",
|
||||||
"@douyinfe/semi-ui": "2.93.0",
|
"@douyinfe/semi-ui": "2.99.3",
|
||||||
"@douyinfe/semi-ui-19": "^2.93.0",
|
"@douyinfe/semi-ui-19": "^2.99.3",
|
||||||
|
"@fastify/cookie": "^11.0.2",
|
||||||
|
"@fastify/helmet": "^13.0.2",
|
||||||
|
"@fastify/session": "^11.1.1",
|
||||||
|
"@fastify/static": "^9.1.3",
|
||||||
"@mapbox/mapbox-gl-draw": "^1.5.1",
|
"@mapbox/mapbox-gl-draw": "^1.5.1",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"@sendgrid/mail": "8.1.6",
|
"@sendgrid/mail": "8.1.6",
|
||||||
"@vitejs/plugin-react": "6.0.1",
|
"@turf/boolean-point-in-polygon": "^7.3.5",
|
||||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
"@vitejs/plugin-react": "6.0.2",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.17",
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.10.0",
|
||||||
"body-parser": "2.2.2",
|
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"cheerio": "^1.2.0",
|
"cheerio": "^1.2.0",
|
||||||
"@turf/boolean-point-in-polygon": "^7.3.4",
|
"cloakbrowser": "^0.3.31",
|
||||||
"cookie-session": "2.1.1",
|
"fastify": "^5.8.5",
|
||||||
"handlebars": "4.7.8",
|
"handlebars": "4.7.9",
|
||||||
"lodash": "4.17.23",
|
"maplibre-gl": "^5.24.0",
|
||||||
"maplibre-gl": "^5.20.1",
|
"nanoid": "5.1.11",
|
||||||
"nanoid": "5.1.7",
|
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"node-fetch": "3.3.2",
|
"node-fetch": "3.3.2",
|
||||||
"node-mailjet": "6.0.11",
|
"node-mailjet": "6.0.11",
|
||||||
|
"nodemailer": "^8.0.10",
|
||||||
"p-throttle": "^8.1.0",
|
"p-throttle": "^8.1.0",
|
||||||
"package-up": "^5.0.0",
|
"package-up": "^5.0.0",
|
||||||
"puppeteer": "^24.39.1",
|
"puppeteer-core": "^25.1.0",
|
||||||
"puppeteer-extra": "^3.3.6",
|
"query-string": "9.4.0",
|
||||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
"react": "19.2.7",
|
||||||
"query-string": "9.3.1",
|
|
||||||
"react": "19.2.4",
|
|
||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.7",
|
||||||
"react-range-slider-input": "^3.3.2",
|
"react-range-slider-input": "^3.3.5",
|
||||||
"react-router": "7.13.1",
|
"react-router": "7.16.0",
|
||||||
"react-router-dom": "7.13.1",
|
"react-router-dom": "7.16.0",
|
||||||
"resend": "^6.9.3",
|
"resend": "^6.12.4",
|
||||||
"restana": "5.1.0",
|
"semver": "^7.8.1",
|
||||||
"semver": "^7.7.4",
|
|
||||||
"serve-static": "2.2.1",
|
|
||||||
"slack": "11.0.2",
|
"slack": "11.0.2",
|
||||||
"vite": "8.0.0",
|
"vite": "8.0.16",
|
||||||
"x-var": "^3.0.1",
|
"x-var": "^3.0.1",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.29.0",
|
"@babel/core": "7.29.7",
|
||||||
"@babel/eslint-parser": "7.28.6",
|
"@babel/eslint-parser": "7.29.7",
|
||||||
"@babel/preset-env": "7.29.0",
|
"@babel/preset-env": "7.29.7",
|
||||||
"@babel/preset-react": "7.28.5",
|
"@babel/preset-react": "7.29.7",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"chai": "6.2.2",
|
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"eslint": "10.0.3",
|
"eslint": "10.4.1",
|
||||||
"eslint-config-prettier": "10.1.8",
|
"eslint-config-prettier": "10.1.8",
|
||||||
"eslint-plugin-react": "7.37.5",
|
"eslint-plugin-react": "7.37.5",
|
||||||
"esmock": "2.7.3",
|
"globals": "^17.6.0",
|
||||||
"globals": "^17.4.0",
|
|
||||||
"history": "5.3.0",
|
"history": "5.3.0",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"less": "4.6.4",
|
"less": "4.6.4",
|
||||||
"lint-staged": "16.4.0",
|
"lint-staged": "17.0.7",
|
||||||
"mocha": "11.7.5",
|
|
||||||
"nodemon": "^3.1.14",
|
"nodemon": "^3.1.14",
|
||||||
"prettier": "3.8.1"
|
"prettier": "3.8.3",
|
||||||
|
"vitest": "^4.1.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { expect } from 'chai';
|
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||||
import esmock from 'esmock';
|
|
||||||
|
|
||||||
describe('services/storage/backupRestoreService.js - precheck & filename', () => {
|
describe('services/storage/backupRestoreService.js - precheck & filename', () => {
|
||||||
let svc;
|
let svc;
|
||||||
@@ -14,7 +13,7 @@ describe('services/storage/backupRestoreService.js - precheck & filename', () =>
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
calls = { logger: { info: [], warn: [], error: [] } };
|
calls = { logger: { info: [], warn: [], error: [] } };
|
||||||
|
|
||||||
// Mock AdmZip with configurable state via globalThis (avoid esmock export name pitfalls)
|
// Mock AdmZip with configurable state via globalThis (avoid mock export name pitfalls)
|
||||||
globalThis.__ADM_ZIP_STATE__ = { hasDb: false, meta: null };
|
globalThis.__ADM_ZIP_STATE__ = { hasDb: false, meta: null };
|
||||||
setZipState = (s) => {
|
setZipState = (s) => {
|
||||||
globalThis.__ADM_ZIP_STATE__ = { ...globalThis.__ADM_ZIP_STATE__, ...s };
|
globalThis.__ADM_ZIP_STATE__ = { ...globalThis.__ADM_ZIP_STATE__, ...s };
|
||||||
@@ -77,67 +76,61 @@ describe('services/storage/backupRestoreService.js - precheck & filename', () =>
|
|||||||
|
|
||||||
const utilsMock = { getPackageVersion: async () => '16.2.0' };
|
const utilsMock = { getPackageVersion: async () => '16.2.0' };
|
||||||
|
|
||||||
const admZipPath = path.join(ROOT, 'node_modules', 'adm-zip', 'adm-zip.js');
|
vi.resetModules();
|
||||||
const mod = await esmock(
|
vi.doMock('adm-zip', () => admZipMock);
|
||||||
path.join(ROOT, 'lib', 'services', 'storage', 'backupRestoreService.js'),
|
vi.doMock(migratePath, () => migrateMock);
|
||||||
{},
|
vi.doMock(sqlitePath, () => sqliteMock);
|
||||||
{
|
vi.doMock(loggerPath, () => loggerMock);
|
||||||
'adm-zip': admZipMock,
|
vi.doMock(utilsPath, () => utilsMock);
|
||||||
[admZipPath]: admZipMock,
|
|
||||||
[migratePath]: migrateMock,
|
|
||||||
[sqlitePath]: sqliteMock,
|
|
||||||
[loggerPath]: loggerMock,
|
|
||||||
[utilsPath]: utilsMock,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
|
const mod = await import(path.join(ROOT, 'lib', 'services', 'storage', 'backupRestoreService.js'));
|
||||||
svc = mod;
|
svc = mod;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('precheck: empty upload yields danger', async () => {
|
it('precheck: empty upload yields danger', async () => {
|
||||||
const res = await svc.precheckRestore(Buffer.alloc(0));
|
const res = await svc.precheckRestore(Buffer.alloc(0));
|
||||||
expect(res.compatible).to.equal(false);
|
expect(res.compatible).toBe(false);
|
||||||
expect(res.severity).to.equal('danger');
|
expect(res.severity).toBe('danger');
|
||||||
expect(res.message).to.contain('Empty upload');
|
expect(res.message).toContain('Empty upload');
|
||||||
expect(res.requiredMigration).to.equal(10);
|
expect(res.requiredMigration).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('precheck: missing listings.db yields danger', async () => {
|
it('precheck: missing listings.db yields danger', async () => {
|
||||||
setZipState({ hasDb: false, meta: { dbMigration: 9 } });
|
setZipState({ hasDb: false, meta: { dbMigration: 9 } });
|
||||||
const res = await svc.precheckRestore(Buffer.from('dummy'));
|
const res = await svc.precheckRestore(Buffer.from('dummy'));
|
||||||
expect(res.compatible).to.equal(false);
|
expect(res.compatible).toBe(false);
|
||||||
expect(res.severity).to.equal('danger');
|
expect(res.severity).toBe('danger');
|
||||||
expect(res.message).to.match(/missing the database file/i);
|
expect(res.message).toMatch(/missing the database file/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('precheck: older backup is compatible with warning', async () => {
|
it('precheck: older backup is compatible with warning', async () => {
|
||||||
setZipState({ hasDb: true, meta: { dbMigration: 5, fredyVersion: '16.0.0' } });
|
setZipState({ hasDb: true, meta: { dbMigration: 5, fredyVersion: '16.0.0' } });
|
||||||
const res = await svc.precheckRestore(Buffer.from('zip'));
|
const res = await svc.precheckRestore(Buffer.from('zip'));
|
||||||
expect(res.compatible).to.equal(true);
|
expect(res.compatible).toBe(true);
|
||||||
expect(res.severity).to.equal('warning');
|
expect(res.severity).toBe('warning');
|
||||||
expect(res.message).to.match(/automatic migrations/i);
|
expect(res.message).toMatch(/automatic migrations/i);
|
||||||
expect(res.backupMigration).to.equal(5);
|
expect(res.backupMigration).toBe(5);
|
||||||
expect(res.requiredMigration).to.equal(10);
|
expect(res.requiredMigration).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('precheck: equal backup is compatible with info', async () => {
|
it('precheck: equal backup is compatible with info', async () => {
|
||||||
setZipState({ hasDb: true, meta: { dbMigration: 10 } });
|
setZipState({ hasDb: true, meta: { dbMigration: 10 } });
|
||||||
const res = await svc.precheckRestore(Buffer.from('zip'));
|
const res = await svc.precheckRestore(Buffer.from('zip'));
|
||||||
expect(res.compatible).to.equal(true);
|
expect(res.compatible).toBe(true);
|
||||||
expect(res.severity).to.equal('info');
|
expect(res.severity).toBe('info');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('precheck: newer backup yields danger', async () => {
|
it('precheck: newer backup yields danger', async () => {
|
||||||
setZipState({ hasDb: true, meta: { dbMigration: 11 } });
|
setZipState({ hasDb: true, meta: { dbMigration: 11 } });
|
||||||
const res = await svc.precheckRestore(Buffer.from('zip'));
|
const res = await svc.precheckRestore(Buffer.from('zip'));
|
||||||
expect(res.compatible).to.equal(false);
|
expect(res.compatible).toBe(false);
|
||||||
expect(res.severity).to.equal('danger');
|
expect(res.severity).toBe('danger');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('buildBackupFileName: matches pattern and includes version', async () => {
|
it('buildBackupFileName: matches pattern and includes version', async () => {
|
||||||
const name = await svc.buildBackupFileName();
|
const name = await svc.buildBackupFileName();
|
||||||
expect(name).to.match(/^\d{4}-\d{2}-\d{2}-FredyBackup-/);
|
expect(name).toMatch(/^\d{4}-\d{2}-\d{2}-FredyBackup-/);
|
||||||
expect(name).to.include('16.2.0');
|
expect(name).toContain('16.2.0');
|
||||||
expect(name).to.match(/\.zip$/);
|
expect(name).toMatch(/\.zip$/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { expect } from 'chai';
|
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import esmock from 'esmock';
|
|
||||||
|
|
||||||
// We will fully mock fs, crypto, SqliteConnection, and dynamic import of migration modules
|
// We will fully mock fs, crypto, SqliteConnection, and dynamic import of migration modules
|
||||||
|
|
||||||
@@ -85,22 +84,18 @@ describe('db/migrations/migrate.js - runMigrations', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// esmock with dependency replacements
|
|
||||||
const path = await import('node:path');
|
const path = await import('node:path');
|
||||||
const ROOT = path.resolve('.');
|
const ROOT = path.resolve('.');
|
||||||
const sqlPath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js');
|
const sqlPath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js');
|
||||||
const loggerPath = path.join(ROOT, 'lib', 'services', 'logger.js');
|
const loggerPath = path.join(ROOT, 'lib', 'services', 'logger.js');
|
||||||
const mod = await esmock(
|
|
||||||
'../../../db/migrations/migrate.js',
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
fs: fsMock,
|
|
||||||
crypto: cryptoMock,
|
|
||||||
[sqlPath]: sqlMock,
|
|
||||||
[loggerPath]: loggerMock,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock('fs', () => ({ default: fsMock, ...fsMock }));
|
||||||
|
vi.doMock('crypto', () => ({ default: cryptoMock, ...cryptoMock }));
|
||||||
|
vi.doMock(sqlPath, () => ({ default: sqlMock }));
|
||||||
|
vi.doMock(loggerPath, () => ({ default: loggerMock }));
|
||||||
|
|
||||||
|
const mod = await import('../../../lib/services/storage/migrations/migrate.js');
|
||||||
runMigrations = mod.runMigrations;
|
runMigrations = mod.runMigrations;
|
||||||
|
|
||||||
// remember original exitCode to restore later
|
// remember original exitCode to restore later
|
||||||
@@ -114,9 +109,9 @@ describe('db/migrations/migrate.js - runMigrations', () => {
|
|||||||
|
|
||||||
it('logs and returns when no migration files are found', async () => {
|
it('logs and returns when no migration files are found', async () => {
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
expect(calls.logs.info.some((a) => String(a[0]).includes('No migration files'))).to.equal(true);
|
expect(calls.logs.info.some((a) => String(a[0]).includes('No migration files'))).toBe(true);
|
||||||
expect(calls.sql.getConnection).to.equal(0);
|
expect(calls.sql.getConnection).toBe(0);
|
||||||
expect(calls.sql.optimize).to.equal(0);
|
expect(calls.sql.optimize).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies a single new migration inside a transaction and records it', async () => {
|
it('applies a single new migration inside a transaction and records it', async () => {
|
||||||
@@ -165,11 +160,6 @@ describe('db/migrations/migrate.js - runMigrations', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// We need to intercept dynamic import by esmock: provide a stub for import(url)
|
|
||||||
// esmock supports mocking via a virtual module using URL matching, but simpler approach:
|
|
||||||
// place the file path that migrate.js will compute and make Node import resolve to our stub
|
|
||||||
// We simulate by mocking url.pathToFileURL is still used, but dynamic import will be handled by esmock when we map the computed path.
|
|
||||||
|
|
||||||
const path = await import('node:path');
|
const path = await import('node:path');
|
||||||
const ROOT = path.resolve('.');
|
const ROOT = path.resolve('.');
|
||||||
|
|
||||||
@@ -178,26 +168,22 @@ describe('db/migrations/migrate.js - runMigrations', () => {
|
|||||||
// Use global importer hook to bypass dynamic import
|
// Use global importer hook to bypass dynamic import
|
||||||
globalThis.__TEST_MIGRATE_IMPORT__ = async () => migrationModule;
|
globalThis.__TEST_MIGRATE_IMPORT__ = async () => migrationModule;
|
||||||
|
|
||||||
const mod = await esmock(
|
vi.resetModules();
|
||||||
'../../../db/migrations/migrate.js',
|
vi.doMock('fs', () => ({ default: fsMock, ...fsMock }));
|
||||||
{},
|
vi.doMock('crypto', () => ({ default: cryptoMock, ...cryptoMock }));
|
||||||
{
|
vi.doMock(sqlPath, () => ({ default: sqlMock }));
|
||||||
fs: fsMock,
|
vi.doMock(loggerPath, () => ({ default: loggerMock }));
|
||||||
crypto: cryptoMock,
|
|
||||||
[sqlPath]: sqlMock,
|
|
||||||
[loggerPath]: loggerMock,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
|
const mod = await import('../../../lib/services/storage/migrations/migrate.js');
|
||||||
runMigrations = mod.runMigrations;
|
runMigrations = mod.runMigrations;
|
||||||
|
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
|
|
||||||
// Should have started a transaction and inserted into schema_migrations
|
// Should have started a transaction and inserted into schema_migrations
|
||||||
expect(calls.sql.withTransaction.length).to.equal(1);
|
expect(calls.sql.withTransaction.length).toBe(1);
|
||||||
const inserted = calls.sql.execute.find((e) => String(e.sql).includes('INSERT INTO schema_migrations'));
|
const inserted = calls.sql.execute.find((e) => String(e.sql).includes('INSERT INTO schema_migrations'));
|
||||||
expect(!!inserted).to.equal(true);
|
expect(!!inserted).toBe(true);
|
||||||
expect(calls.sql.optimize).to.equal(1);
|
expect(calls.sql.optimize).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips already executed migration with same checksum', async () => {
|
it('skips already executed migration with same checksum', async () => {
|
||||||
@@ -242,24 +228,20 @@ describe('db/migrations/migrate.js - runMigrations', () => {
|
|||||||
|
|
||||||
globalThis.__TEST_MIGRATE_IMPORT__ = async () => ({ up: () => {} });
|
globalThis.__TEST_MIGRATE_IMPORT__ = async () => ({ up: () => {} });
|
||||||
|
|
||||||
const mod = await esmock(
|
vi.resetModules();
|
||||||
'../../../db/migrations/migrate.js',
|
vi.doMock('fs', () => ({ default: fsMock, ...fsMock }));
|
||||||
{},
|
vi.doMock('crypto', () => ({ default: cryptoMock, ...cryptoMock }));
|
||||||
{
|
vi.doMock(sqlPath, () => ({ default: sqlMock }));
|
||||||
fs: fsMock,
|
vi.doMock(loggerPath, () => ({ default: loggerMock }));
|
||||||
crypto: cryptoMock,
|
|
||||||
[sqlPath]: sqlMock,
|
|
||||||
[loggerPath]: loggerMock,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
|
const mod = await import('../../../lib/services/storage/migrations/migrate.js');
|
||||||
runMigrations = mod.runMigrations;
|
runMigrations = mod.runMigrations;
|
||||||
|
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
|
|
||||||
// Should not run transaction because it's skipped
|
// Should not run transaction because it's skipped
|
||||||
expect(calls.sql.withTransaction.length).to.equal(0);
|
expect(calls.sql.withTransaction.length).toBe(0);
|
||||||
expect(calls.sql.optimize).to.equal(1);
|
expect(calls.sql.optimize).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('aborts with exitCode=1 when a migration throws, without applying insert', async () => {
|
it('aborts with exitCode=1 when a migration throws, without applying insert', async () => {
|
||||||
@@ -311,24 +293,20 @@ describe('db/migrations/migrate.js - runMigrations', () => {
|
|||||||
const sqlPath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js');
|
const sqlPath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js');
|
||||||
const loggerPath = path.join(ROOT, 'lib', 'services', 'logger.js');
|
const loggerPath = path.join(ROOT, 'lib', 'services', 'logger.js');
|
||||||
|
|
||||||
const mod = await esmock(
|
vi.resetModules();
|
||||||
'../../../lib/services/storage/migrations/migrate.js',
|
vi.doMock('fs', () => ({ default: fsMock, ...fsMock }));
|
||||||
{},
|
vi.doMock('crypto', () => ({ default: cryptoMock, ...cryptoMock }));
|
||||||
{
|
vi.doMock(sqlPath, () => ({ default: sqlMock }));
|
||||||
fs: fsMock,
|
vi.doMock(loggerPath, () => ({ default: loggerMock }));
|
||||||
crypto: cryptoMock,
|
|
||||||
[sqlPath]: sqlMock,
|
|
||||||
[loggerPath]: loggerMock,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
|
const mod = await import('../../../lib/services/storage/migrations/migrate.js');
|
||||||
runMigrations = mod.runMigrations;
|
runMigrations = mod.runMigrations;
|
||||||
|
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
|
|
||||||
expect(process.exitCode).to.equal(1);
|
expect(process.exitCode).toBe(1);
|
||||||
// No insert into schema_migrations should be recorded since transaction failed
|
// No insert into schema_migrations should be recorded since transaction failed
|
||||||
const inserted = calls.sql.execute.find((e) => String(e.sql).includes('INSERT INTO schema_migrations'));
|
const inserted = calls.sql.execute.find((e) => String(e.sql).includes('INSERT INTO schema_migrations'));
|
||||||
expect(inserted).to.equal(undefined);
|
expect(inserted).toBe(undefined);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
import { register } from 'node:module';
|
|
||||||
import { pathToFileURL } from 'node:url';
|
|
||||||
|
|
||||||
register('esmock', pathToFileURL('./'));
|
|
||||||
18
test/globalSetup.js
Normal file
18
test/globalSetup.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ensureValidBinary } from '../lib/services/ensureValidBinary.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vitest global setup — runs once in the main process before any workers start.
|
||||||
|
* Downloads and validates the CloakBrowser stealth Chromium binary.
|
||||||
|
* ensureValidBinary() also removes and re-downloads partial/corrupt installations
|
||||||
|
* so tests never fail with "Invalid file descriptor to ICU data received".
|
||||||
|
* Skipped in offline mode because the browser is fully mocked there.
|
||||||
|
*/
|
||||||
|
export async function setup() {
|
||||||
|
if (process.env.TEST_MODE === 'offline') return;
|
||||||
|
await ensureValidBinary();
|
||||||
|
}
|
||||||
@@ -21,6 +21,10 @@ export function getUserSettings(userId) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getSettings() {
|
||||||
|
return { baseUrl: '' };
|
||||||
|
}
|
||||||
|
|
||||||
export const updateListingDistance = (id, distance) => {
|
export const updateListingDistance = (id, distance) => {
|
||||||
// noop
|
// noop
|
||||||
};
|
};
|
||||||
@@ -28,4 +32,7 @@ export const deletedIds = [];
|
|||||||
export const deleteListingsById = (ids) => {
|
export const deleteListingsById = (ids) => {
|
||||||
deletedIds.push(...ids);
|
deletedIds.push(...ids);
|
||||||
};
|
};
|
||||||
|
export const deleteListingsByHash = (hashes) => {
|
||||||
|
deletedIds.push(...hashes);
|
||||||
|
};
|
||||||
/* eslint-enable no-unused-vars */
|
/* eslint-enable no-unused-vars */
|
||||||
|
|||||||
360
test/notification/telegram.test.js
Normal file
360
test/notification/telegram.test.js
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
// Mock external deps BEFORE importing the module under test.
|
||||||
|
vi.mock('node-fetch', () => ({ default: vi.fn() }));
|
||||||
|
vi.mock('../../lib/services/storage/jobStorage.js', () => ({
|
||||||
|
getJob: (jobKey) => ({ id: jobKey, name: jobKey }),
|
||||||
|
}));
|
||||||
|
vi.mock('../../lib/services/markdown.js', () => ({
|
||||||
|
markdown2Html: () => '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helpers to build mock fetch responses.
|
||||||
|
function jsonOk(body = { ok: true }) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
text: async () => JSON.stringify(body),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonErr(status, body) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status,
|
||||||
|
text: async () => JSON.stringify(body),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageOk(bytes = new Uint8Array([0xff, 0xd8, 0xff])) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
get: (h) => {
|
||||||
|
const k = h.toLowerCase();
|
||||||
|
if (k === 'content-type') return 'image/jpeg';
|
||||||
|
if (k === 'content-length') return String(bytes.byteLength);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Globals are mocked too so buildPhotoFormData (which uses global fetch) can be
|
||||||
|
// intercepted by the same single mock.
|
||||||
|
let mockNodeFetch;
|
||||||
|
let mockGlobalFetch;
|
||||||
|
let send;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Reset modules to get a fresh import with our mocks applied.
|
||||||
|
vi.resetModules();
|
||||||
|
const nodeFetchMod = await import('node-fetch');
|
||||||
|
mockNodeFetch = nodeFetchMod.default;
|
||||||
|
mockNodeFetch.mockReset();
|
||||||
|
|
||||||
|
mockGlobalFetch = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', mockGlobalFetch);
|
||||||
|
|
||||||
|
({ send } = await import('../../lib/notification/adapter/telegram.js'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseConfig = {
|
||||||
|
id: 'telegram',
|
||||||
|
fields: { token: 'TKN', chatId: '999' },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('telegram send() - HTTP URL path (default for .jpg / .png)', () => {
|
||||||
|
it('POSTs JSON to sendPhoto for a .jpg image URL', async () => {
|
||||||
|
mockNodeFetch.mockResolvedValueOnce(jsonOk());
|
||||||
|
|
||||||
|
await send({
|
||||||
|
serviceName: 'immowelt',
|
||||||
|
newListings: [
|
||||||
|
{
|
||||||
|
id: 'a',
|
||||||
|
title: 'Listing',
|
||||||
|
link: 'https://example.com/a',
|
||||||
|
address: 'Addr',
|
||||||
|
price: '500€',
|
||||||
|
size: '50m²',
|
||||||
|
image: 'https://mms.immowelt.de/x/y/z/w/abc.jpg?ci_seal=hash&w=525&h=394',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
notificationConfig: [baseConfig],
|
||||||
|
jobKey: 'Berlin',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockNodeFetch).toHaveBeenCalledTimes(1);
|
||||||
|
const [url, opts] = mockNodeFetch.mock.calls[0];
|
||||||
|
expect(url).toBe('https://api.telegram.org/botTKN/sendPhoto');
|
||||||
|
expect(opts.method).toBe('post');
|
||||||
|
expect(opts.headers?.['Content-Type']).toBe('application/json');
|
||||||
|
const body = JSON.parse(opts.body);
|
||||||
|
expect(body.chat_id).toBe('999');
|
||||||
|
expect(body.photo).toBe('https://mms.immowelt.de/x/y/z/w/abc.jpg?ci_seal=hash&w=525&h=394');
|
||||||
|
expect(body.parse_mode).toBe('HTML');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT pre-fetch the image when using HTTP URL path', async () => {
|
||||||
|
mockNodeFetch.mockResolvedValueOnce(jsonOk());
|
||||||
|
|
||||||
|
await send({
|
||||||
|
serviceName: 'immowelt',
|
||||||
|
newListings: [
|
||||||
|
{
|
||||||
|
id: 'a',
|
||||||
|
title: 't',
|
||||||
|
link: 'l',
|
||||||
|
address: 'a',
|
||||||
|
price: '',
|
||||||
|
size: '',
|
||||||
|
image: 'https://example.com/x.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
notificationConfig: [baseConfig],
|
||||||
|
jobKey: 'Berlin',
|
||||||
|
});
|
||||||
|
|
||||||
|
// global fetch (used by buildPhotoFormData) must not be called
|
||||||
|
expect(mockGlobalFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to sendMessage when sendPhoto fails', async () => {
|
||||||
|
mockNodeFetch
|
||||||
|
.mockResolvedValueOnce(jsonErr(400, { ok: false, description: 'boom' }))
|
||||||
|
.mockResolvedValueOnce(jsonOk());
|
||||||
|
|
||||||
|
await send({
|
||||||
|
serviceName: 'immowelt',
|
||||||
|
newListings: [
|
||||||
|
{
|
||||||
|
id: 'a',
|
||||||
|
title: 't',
|
||||||
|
link: 'l',
|
||||||
|
address: 'a',
|
||||||
|
price: '',
|
||||||
|
size: '',
|
||||||
|
image: 'https://example.com/x.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
notificationConfig: [baseConfig],
|
||||||
|
jobKey: 'Berlin',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockNodeFetch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockNodeFetch.mock.calls[0][0]).toBe('https://api.telegram.org/botTKN/sendPhoto');
|
||||||
|
expect(mockNodeFetch.mock.calls[1][0]).toBe('https://api.telegram.org/botTKN/sendMessage');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('telegram send() - multipart path (.webp URLs)', () => {
|
||||||
|
it('pre-fetches the image then POSTs FormData to sendPhoto for a .webp URL', async () => {
|
||||||
|
// 1st: GET image via global fetch
|
||||||
|
mockGlobalFetch.mockResolvedValueOnce(imageOk());
|
||||||
|
// 2nd: POST sendPhoto via node-fetch
|
||||||
|
mockNodeFetch.mockResolvedValueOnce(jsonOk());
|
||||||
|
|
||||||
|
await send({
|
||||||
|
serviceName: 'immowelt',
|
||||||
|
newListings: [
|
||||||
|
{
|
||||||
|
id: 'a',
|
||||||
|
title: 'Listing',
|
||||||
|
link: 'https://example.com/a',
|
||||||
|
address: 'Addr',
|
||||||
|
price: '500€',
|
||||||
|
size: '50m²',
|
||||||
|
image: 'https://mms.immowelt.de/1/1/6/5/abc.webp?ci_seal=hash&w=525&h=394',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
notificationConfig: [baseConfig],
|
||||||
|
jobKey: 'Berlin',
|
||||||
|
});
|
||||||
|
|
||||||
|
// image was fetched
|
||||||
|
expect(mockGlobalFetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockGlobalFetch.mock.calls[0][0]).toBe('https://mms.immowelt.de/1/1/6/5/abc.webp?ci_seal=hash&w=525&h=394');
|
||||||
|
|
||||||
|
// sendPhoto called via node-fetch with FormData
|
||||||
|
expect(mockNodeFetch).toHaveBeenCalledTimes(1);
|
||||||
|
const [url, opts] = mockNodeFetch.mock.calls[0];
|
||||||
|
expect(url).toBe('https://api.telegram.org/botTKN/sendPhoto');
|
||||||
|
expect(opts.method).toBe('post');
|
||||||
|
expect(opts.body).toBeInstanceOf(FormData);
|
||||||
|
// No explicit Content-Type header - fetch sets multipart boundary itself
|
||||||
|
expect(opts.headers).toBeUndefined();
|
||||||
|
expect(opts.body.get('chat_id')).toBe('999');
|
||||||
|
expect(opts.body.get('parse_mode')).toBe('HTML');
|
||||||
|
const photo = opts.body.get('photo');
|
||||||
|
expect(photo).toBeTruthy();
|
||||||
|
expect(photo.size).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to sendMessage when the image pre-fetch fails for a .webp URL', async () => {
|
||||||
|
// image fetch fails (404 from CDN)
|
||||||
|
mockGlobalFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
headers: { get: () => null },
|
||||||
|
arrayBuffer: async () => new ArrayBuffer(0),
|
||||||
|
});
|
||||||
|
// then sendMessage succeeds via node-fetch
|
||||||
|
mockNodeFetch.mockResolvedValueOnce(jsonOk());
|
||||||
|
|
||||||
|
await send({
|
||||||
|
serviceName: 'immowelt',
|
||||||
|
newListings: [
|
||||||
|
{
|
||||||
|
id: 'a',
|
||||||
|
title: 't',
|
||||||
|
link: 'l',
|
||||||
|
address: 'a',
|
||||||
|
price: '',
|
||||||
|
size: '',
|
||||||
|
image: 'https://example.com/gone.webp',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
notificationConfig: [baseConfig],
|
||||||
|
jobKey: 'Berlin',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockNodeFetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockNodeFetch.mock.calls[0][0]).toBe('https://api.telegram.org/botTKN/sendMessage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to sendMessage when multipart sendPhoto returns a Telegram error', async () => {
|
||||||
|
mockGlobalFetch.mockResolvedValueOnce(imageOk());
|
||||||
|
mockNodeFetch
|
||||||
|
.mockResolvedValueOnce(jsonErr(400, { description: 'broke' })) // multipart sendPhoto
|
||||||
|
.mockResolvedValueOnce(jsonOk()); // sendMessage fallback
|
||||||
|
|
||||||
|
await send({
|
||||||
|
serviceName: 'immowelt',
|
||||||
|
newListings: [
|
||||||
|
{
|
||||||
|
id: 'a',
|
||||||
|
title: 't',
|
||||||
|
link: 'l',
|
||||||
|
address: 'a',
|
||||||
|
price: '',
|
||||||
|
size: '',
|
||||||
|
image: 'https://example.com/x.webp',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
notificationConfig: [baseConfig],
|
||||||
|
jobKey: 'Berlin',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockNodeFetch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockNodeFetch.mock.calls[1][0]).toBe('https://api.telegram.org/botTKN/sendMessage');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('telegram send() - mixed batch (regression-safety)', () => {
|
||||||
|
it('handles a batch with both .jpg and .webp - jpg uses URL, webp uses multipart', async () => {
|
||||||
|
// .webp image fetch
|
||||||
|
mockGlobalFetch.mockResolvedValueOnce(imageOk());
|
||||||
|
// both sendPhoto calls succeed
|
||||||
|
mockNodeFetch
|
||||||
|
.mockResolvedValueOnce(jsonOk()) // could be either listing first
|
||||||
|
.mockResolvedValueOnce(jsonOk());
|
||||||
|
|
||||||
|
await send({
|
||||||
|
serviceName: 'immowelt',
|
||||||
|
newListings: [
|
||||||
|
{
|
||||||
|
id: 'jpg-listing',
|
||||||
|
title: 'a',
|
||||||
|
link: 'l',
|
||||||
|
address: 'a',
|
||||||
|
price: '',
|
||||||
|
size: '',
|
||||||
|
image: 'https://example.com/a.jpg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'webp-listing',
|
||||||
|
title: 'b',
|
||||||
|
link: 'l',
|
||||||
|
address: 'a',
|
||||||
|
price: '',
|
||||||
|
size: '',
|
||||||
|
image: 'https://example.com/b.webp',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
notificationConfig: [baseConfig],
|
||||||
|
jobKey: 'Berlin',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockGlobalFetch).toHaveBeenCalledTimes(1); // only webp pre-fetches
|
||||||
|
expect(mockNodeFetch).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// Verify one call had FormData and one had JSON body
|
||||||
|
const bodies = mockNodeFetch.mock.calls.map((c) => c[1].body);
|
||||||
|
const hasFormData = bodies.some((b) => b instanceof FormData);
|
||||||
|
const hasJson = bodies.some((b) => typeof b === 'string' && b.startsWith('{'));
|
||||||
|
expect(hasFormData).toBe(true);
|
||||||
|
expect(hasJson).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses sendMessage (not sendPhoto) when image is null', async () => {
|
||||||
|
mockNodeFetch.mockResolvedValueOnce(jsonOk());
|
||||||
|
|
||||||
|
await send({
|
||||||
|
serviceName: 'immowelt',
|
||||||
|
newListings: [
|
||||||
|
{
|
||||||
|
id: 'a',
|
||||||
|
title: 't',
|
||||||
|
link: 'l',
|
||||||
|
address: 'a',
|
||||||
|
price: '',
|
||||||
|
size: '',
|
||||||
|
image: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
notificationConfig: [baseConfig],
|
||||||
|
jobKey: 'Berlin',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockNodeFetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockNodeFetch.mock.calls[0][0]).toBe('https://api.telegram.org/botTKN/sendMessage');
|
||||||
|
expect(mockGlobalFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('telegram send() - config validation', () => {
|
||||||
|
it('throws when telegram adapter config is missing', () => {
|
||||||
|
expect(() =>
|
||||||
|
send({
|
||||||
|
serviceName: 's',
|
||||||
|
newListings: [],
|
||||||
|
notificationConfig: [],
|
||||||
|
jobKey: 'k',
|
||||||
|
}),
|
||||||
|
).toThrow(/configuration missing/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when token or chatId is missing', () => {
|
||||||
|
expect(() =>
|
||||||
|
send({
|
||||||
|
serviceName: 's',
|
||||||
|
newListings: [],
|
||||||
|
notificationConfig: [{ id: 'telegram', fields: { token: '' } }],
|
||||||
|
jobKey: 'k',
|
||||||
|
}),
|
||||||
|
).toThrow(/token.*chatId/);
|
||||||
|
});
|
||||||
|
});
|
||||||
287
test/notification/telegramPhotoUploader.test.js
Normal file
287
test/notification/telegramPhotoUploader.test.js
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { shouldUseMultipart, buildPhotoFormData } from '../../lib/notification/adapter/telegramPhotoUploader.js';
|
||||||
|
|
||||||
|
describe('shouldUseMultipart', () => {
|
||||||
|
it('returns true for .webp URL with query string', () => {
|
||||||
|
expect(shouldUseMultipart('https://mms.immowelt.de/1/1/6/5/abc.webp?ci_seal=hash&w=525&h=394')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for .webp URL without query string', () => {
|
||||||
|
expect(shouldUseMultipart('https://example.com/photo.webp')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for uppercase .WEBP extension', () => {
|
||||||
|
expect(shouldUseMultipart('https://example.com/IMG.WEBP?x=1')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for .jpg URL with query string', () => {
|
||||||
|
expect(shouldUseMultipart('https://mms.immowelt.de/a/b/c/d/xyz.jpg?ci_seal=hash&w=525&h=394')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for .jpeg URL', () => {
|
||||||
|
expect(shouldUseMultipart('https://example.com/photo.jpeg')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for .png URL with query string', () => {
|
||||||
|
expect(shouldUseMultipart('https://example.com/photo.png?w=100')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for .gif URL', () => {
|
||||||
|
expect(shouldUseMultipart('https://example.com/photo.gif')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for null', () => {
|
||||||
|
expect(shouldUseMultipart(null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for undefined', () => {
|
||||||
|
expect(shouldUseMultipart(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for empty string', () => {
|
||||||
|
expect(shouldUseMultipart('')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for malformed URL', () => {
|
||||||
|
expect(shouldUseMultipart('not a url')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for URL where webp is in the query but not the path', () => {
|
||||||
|
expect(shouldUseMultipart('https://example.com/photo.jpg?format=webp')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for URL with no extension at all', () => {
|
||||||
|
expect(shouldUseMultipart('https://example.com/photo')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for non-https schemes', () => {
|
||||||
|
// file/data/ftp URLs should not be relevant; safer to skip multipart
|
||||||
|
expect(shouldUseMultipart('http://example.com/photo.webp')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildPhotoFormData', () => {
|
||||||
|
let mockFetch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetch = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeImageResponse({ contentType = 'image/jpeg', bytes = new Uint8Array([0xff, 0xd8, 0xff]) } = {}) {
|
||||||
|
const buf = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
get: (h) =>
|
||||||
|
h.toLowerCase() === 'content-type'
|
||||||
|
? contentType
|
||||||
|
: h.toLowerCase() === 'content-length'
|
||||||
|
? String(bytes.byteLength)
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
arrayBuffer: async () => buf,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('fetches image with Accept header that excludes webp so the CDN transcodes to JPEG', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(makeImageResponse());
|
||||||
|
|
||||||
|
await buildPhotoFormData({
|
||||||
|
chatId: '123',
|
||||||
|
imageUrl: 'https://example.com/photo.webp',
|
||||||
|
caption: 'hi',
|
||||||
|
parseMode: 'HTML',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
const [url, opts] = mockFetch.mock.calls[0];
|
||||||
|
expect(url).toBe('https://example.com/photo.webp');
|
||||||
|
expect(opts?.headers?.Accept || opts?.headers?.accept).toMatch(/image\/jpeg/);
|
||||||
|
expect(opts?.headers?.Accept || opts?.headers?.accept).not.toMatch(/image\/webp/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns FormData containing chat_id, caption, parse_mode, and photo fields', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(makeImageResponse());
|
||||||
|
|
||||||
|
const fd = await buildPhotoFormData({
|
||||||
|
chatId: '12345',
|
||||||
|
imageUrl: 'https://example.com/abc.webp',
|
||||||
|
caption: 'My caption',
|
||||||
|
parseMode: 'HTML',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fd).toBeInstanceOf(FormData);
|
||||||
|
expect(fd.get('chat_id')).toBe('12345');
|
||||||
|
expect(fd.get('caption')).toBe('My caption');
|
||||||
|
expect(fd.get('parse_mode')).toBe('HTML');
|
||||||
|
const photo = fd.get('photo');
|
||||||
|
expect(photo).toBeTruthy();
|
||||||
|
// File-like (Blob); has a name and a size
|
||||||
|
expect(typeof photo.name).toBe('string');
|
||||||
|
expect(photo.size).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a .jpg filename (Telegram uses URL/filename extension for type detection)', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(makeImageResponse());
|
||||||
|
|
||||||
|
const fd = await buildPhotoFormData({
|
||||||
|
chatId: '1',
|
||||||
|
imageUrl: 'https://example.com/source.webp',
|
||||||
|
caption: 'c',
|
||||||
|
parseMode: 'HTML',
|
||||||
|
});
|
||||||
|
|
||||||
|
const photo = fd.get('photo');
|
||||||
|
expect(photo.name).toMatch(/\.jpg$/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes message_thread_id when provided', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(makeImageResponse());
|
||||||
|
|
||||||
|
const fd = await buildPhotoFormData({
|
||||||
|
chatId: '1',
|
||||||
|
imageUrl: 'https://example.com/source.webp',
|
||||||
|
caption: 'c',
|
||||||
|
parseMode: 'HTML',
|
||||||
|
messageThreadId: 42,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fd.get('message_thread_id')).toBe('42');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits message_thread_id when not provided', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(makeImageResponse());
|
||||||
|
|
||||||
|
const fd = await buildPhotoFormData({
|
||||||
|
chatId: '1',
|
||||||
|
imageUrl: 'https://example.com/source.webp',
|
||||||
|
caption: 'c',
|
||||||
|
parseMode: 'HTML',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fd.get('message_thread_id')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits parse_mode when not provided (plain text mode)', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(makeImageResponse());
|
||||||
|
|
||||||
|
const fd = await buildPhotoFormData({
|
||||||
|
chatId: '1',
|
||||||
|
imageUrl: 'https://example.com/source.webp',
|
||||||
|
caption: 'c',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fd.get('parse_mode')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when the image fetch returns non-200', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
headers: { get: () => null },
|
||||||
|
arrayBuffer: async () => new ArrayBuffer(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
buildPhotoFormData({
|
||||||
|
chatId: '1',
|
||||||
|
imageUrl: 'https://example.com/gone.webp',
|
||||||
|
caption: 'c',
|
||||||
|
parseMode: 'HTML',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/404/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when the image fetch throws (network error)', async () => {
|
||||||
|
mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
buildPhotoFormData({
|
||||||
|
chatId: '1',
|
||||||
|
imageUrl: 'https://example.com/x.webp',
|
||||||
|
caption: 'c',
|
||||||
|
parseMode: 'HTML',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/ECONNREFUSED/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when the image exceeds 10 MB (Telegram multipart limit)', async () => {
|
||||||
|
// 11 MB
|
||||||
|
const big = new Uint8Array(11 * 1024 * 1024);
|
||||||
|
mockFetch.mockResolvedValueOnce(makeImageResponse({ bytes: big }));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
buildPhotoFormData({
|
||||||
|
chatId: '1',
|
||||||
|
imageUrl: 'https://example.com/huge.webp',
|
||||||
|
caption: 'c',
|
||||||
|
parseMode: 'HTML',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/size|large|10/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects early when content-length header advertises > 10 MB (avoids download)', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
get: (h) => {
|
||||||
|
const k = h.toLowerCase();
|
||||||
|
if (k === 'content-type') return 'image/jpeg';
|
||||||
|
if (k === 'content-length') return String(50 * 1024 * 1024);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
arrayBuffer: async () => {
|
||||||
|
throw new Error('should not be called - size check should reject first');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
buildPhotoFormData({
|
||||||
|
chatId: '1',
|
||||||
|
imageUrl: 'https://example.com/huge.webp',
|
||||||
|
caption: 'c',
|
||||||
|
parseMode: 'HTML',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/size|large|10/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts exactly 10 MB images (boundary)', async () => {
|
||||||
|
const bytes = new Uint8Array(10 * 1024 * 1024);
|
||||||
|
mockFetch.mockResolvedValueOnce(makeImageResponse({ bytes }));
|
||||||
|
|
||||||
|
const fd = await buildPhotoFormData({
|
||||||
|
chatId: '1',
|
||||||
|
imageUrl: 'https://example.com/exact.webp',
|
||||||
|
caption: 'c',
|
||||||
|
parseMode: 'HTML',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fd.get('photo').size).toBe(10 * 1024 * 1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coerces non-string chatId (number) to string in form data', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(makeImageResponse());
|
||||||
|
|
||||||
|
const fd = await buildPhotoFormData({
|
||||||
|
chatId: 999,
|
||||||
|
imageUrl: 'https://example.com/x.webp',
|
||||||
|
caption: 'c',
|
||||||
|
parseMode: 'HTML',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fd.get('chat_id')).toBe('999');
|
||||||
|
});
|
||||||
|
});
|
||||||
116
test/offlineFixtures.js
Normal file
116
test/offlineFixtures.js
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFile } from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const FIXTURES_DIR = path.join(__dirname, 'testFixtures');
|
||||||
|
|
||||||
|
const testProviderConfig = JSON.parse(
|
||||||
|
await readFile(new URL('./provider/testProvider.json', import.meta.url), 'utf-8'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// hostname → providerName, built from testProvider.json
|
||||||
|
const hostnameToProvider = {};
|
||||||
|
// providerName → list page pathname (for distinguishing list vs detail URLs)
|
||||||
|
const providerListPath = {};
|
||||||
|
|
||||||
|
for (const [name, cfg] of Object.entries(testProviderConfig)) {
|
||||||
|
if (!cfg.url) continue;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(cfg.url);
|
||||||
|
hostnameToProvider[parsed.hostname] = name;
|
||||||
|
providerListPath[name] = parsed.pathname;
|
||||||
|
} catch {
|
||||||
|
// skip malformed URLs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryReadFile(filepath) {
|
||||||
|
try {
|
||||||
|
return await readFile(filepath, 'utf-8');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withRealEstateType(data, realEstateType) {
|
||||||
|
if (!realEstateType?.length || !Array.isArray(data?.resultListItems)) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloned = typeof structuredClone === 'function' ? structuredClone(data) : JSON.parse(JSON.stringify(data));
|
||||||
|
for (const item of cloned.resultListItems) {
|
||||||
|
if (item?.type === 'EXPOSE_RESULT' && item?.item) {
|
||||||
|
item.item.realEstateType = realEstateType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns fixture HTML for the given URL by mapping hostname → provider name,
|
||||||
|
* then distinguishing list vs detail pages by comparing the URL path against
|
||||||
|
* the configured list URL path from testProvider.json.
|
||||||
|
*/
|
||||||
|
export async function readFixture(url) {
|
||||||
|
let hostname, pathname;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
hostname = parsed.hostname;
|
||||||
|
pathname = parsed.pathname;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerName = hostnameToProvider[hostname];
|
||||||
|
if (!providerName) return null;
|
||||||
|
|
||||||
|
if (providerListPath[providerName] === pathname) {
|
||||||
|
return tryReadFile(path.join(FIXTURES_DIR, `${providerName}.html`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detail page: prefer dedicated detail fixture, fall back to list fixture
|
||||||
|
const detailHtml = await tryReadFile(path.join(FIXTURES_DIR, `${providerName}_detail.html`));
|
||||||
|
if (detailHtml) return detailHtml;
|
||||||
|
return tryReadFile(path.join(FIXTURES_DIR, `${providerName}.html`));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a fetch replacement that intercepts immoscout mobile API calls and
|
||||||
|
* serves pre-downloaded JSON fixtures. Throws for any other URL to prevent
|
||||||
|
* accidental live network traffic in offline mode.
|
||||||
|
*/
|
||||||
|
export function buildFetchMock() {
|
||||||
|
let listData = null;
|
||||||
|
let detailData = null;
|
||||||
|
|
||||||
|
return async (url) => {
|
||||||
|
const urlStr = String(url);
|
||||||
|
|
||||||
|
if (urlStr.includes('api.mobile.immobilienscout24.de/search/list')) {
|
||||||
|
if (!listData) {
|
||||||
|
const raw = await tryReadFile(path.join(FIXTURES_DIR, 'immoscout_list.json'));
|
||||||
|
listData = raw ? JSON.parse(raw) : { resultListItems: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedType = new URL(urlStr).searchParams.get('realestatetype');
|
||||||
|
const responseData = withRealEstateType(listData, requestedType);
|
||||||
|
return { ok: true, status: 200, json: () => Promise.resolve(responseData) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urlStr.includes('api.mobile.immobilienscout24.de/expose/')) {
|
||||||
|
if (!detailData) {
|
||||||
|
const raw = await tryReadFile(path.join(FIXTURES_DIR, 'immoscout_detail.json'));
|
||||||
|
detailData = raw ? JSON.parse(raw) : { sections: [], contact: {} };
|
||||||
|
}
|
||||||
|
return { ok: true, status: 200, json: () => Promise.resolve(detailData) };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Network request blocked in offline mode: ${urlStr}`);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { expect } from 'chai';
|
import { expect } from 'vitest';
|
||||||
import { mockFredy } from './utils.js';
|
import { mockFredy } from './utils.js';
|
||||||
import * as mockStore from './mocks/mockStore.js';
|
import * as mockStore from './mocks/mockStore.js';
|
||||||
|
|
||||||
@@ -17,13 +17,22 @@ describe('Issue reproduction: listings filtered by similarity or area should be
|
|||||||
|
|
||||||
const providerConfig = {
|
const providerConfig = {
|
||||||
url: 'http://example.com',
|
url: 'http://example.com',
|
||||||
getListings: () => Promise.resolve([{ id: '1', title: 'test', address: 'addr', price: '100' }]),
|
getListings: () =>
|
||||||
|
Promise.resolve([{ id: '1', title: 'test', address: 'addr', price: '100', link: 'http://example.com/1' }]),
|
||||||
normalize: (l) => l,
|
normalize: (l) => l,
|
||||||
filter: () => true,
|
filter: () => true,
|
||||||
crawlFields: { id: 'id', title: 'title', address: 'address', price: 'price' },
|
crawlFields: { id: 'id', title: 'title', address: 'address', price: 'price' },
|
||||||
|
requiredFieldNames: ['id', 'title', 'address', 'price'],
|
||||||
};
|
};
|
||||||
|
|
||||||
const fredy = new Fredy(providerConfig, null, null, 'test-provider', 'test-job', mockSimilarityCache);
|
const mockedJob = {
|
||||||
|
id: 'test-job',
|
||||||
|
notificationAdapter: null,
|
||||||
|
specFilter: null,
|
||||||
|
spatialFilter: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fredy = new Fredy(providerConfig, mockedJob, 'test-provider', mockSimilarityCache, undefined);
|
||||||
|
|
||||||
// Clear deletedIds before test
|
// Clear deletedIds before test
|
||||||
mockStore.deletedIds.length = 0;
|
mockStore.deletedIds.length = 0;
|
||||||
@@ -34,7 +43,7 @@ describe('Issue reproduction: listings filtered by similarity or area should be
|
|||||||
// Might throw NoNewListingsWarning if all are filtered out
|
// Might throw NoNewListingsWarning if all are filtered out
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(mockStore.deletedIds).to.include('1');
|
expect(mockStore.deletedIds).toContain('1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call deleteListingsById when listings are filtered by area', async () => {
|
it('should call deleteListingsById when listings are filtered by area', async () => {
|
||||||
@@ -64,18 +73,35 @@ describe('Issue reproduction: listings filtered by similarity or area should be
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockedJob = {
|
||||||
|
id: 'test-job',
|
||||||
|
notificationAdapter: null,
|
||||||
|
specFilter: null,
|
||||||
|
spatialFilter: spatialFilter,
|
||||||
|
};
|
||||||
|
|
||||||
const providerConfig = {
|
const providerConfig = {
|
||||||
url: 'http://example.com',
|
url: 'http://example.com',
|
||||||
getListings: () =>
|
getListings: () =>
|
||||||
Promise.resolve([{ id: '2', title: 'test', address: 'addr', price: '100', latitude: 2, longitude: 2 }]), // outside polygon
|
Promise.resolve([
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: 'test',
|
||||||
|
address: 'addr',
|
||||||
|
price: '100',
|
||||||
|
latitude: 2,
|
||||||
|
longitude: 2,
|
||||||
|
link: 'http://example.com/2',
|
||||||
|
},
|
||||||
|
]), // outside polygon
|
||||||
normalize: (l) => l,
|
normalize: (l) => l,
|
||||||
filter: () => true,
|
filter: () => true,
|
||||||
crawlFields: { id: 'id', title: 'title', address: 'address', price: 'price' },
|
crawlFields: { id: 'id', title: 'title', address: 'address', price: 'price' },
|
||||||
|
requiredFieldNames: ['id', 'title', 'address', 'price'],
|
||||||
};
|
};
|
||||||
|
|
||||||
const fredy = new Fredy(providerConfig, null, spatialFilter, 'test-provider', 'test-job', mockSimilarityCache);
|
const fredy = new Fredy(providerConfig, mockedJob, 'test-provider', mockSimilarityCache, undefined);
|
||||||
|
|
||||||
// Clear deletedIds before test
|
|
||||||
mockStore.deletedIds.length = 0;
|
mockStore.deletedIds.length = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -84,6 +110,6 @@ describe('Issue reproduction: listings filtered by similarity or area should be
|
|||||||
// Might throw NoNewListingsWarning if all are filtered out
|
// Might throw NoNewListingsWarning if all are filtered out
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(mockStore.deletedIds).to.include('2');
|
expect(mockStore.deletedIds).toContain('2');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,39 +6,44 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { providerConfig, mockFredy } from '../utils.js';
|
import { providerConfig, mockFredy } from '../utils.js';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'vitest';
|
||||||
import * as provider from '../../lib/provider/einsAImmobilien.js';
|
import * as provider from '../../lib/provider/einsAImmobilien.js';
|
||||||
|
|
||||||
describe('#einsAImmobilien testsuite()', () => {
|
describe('#einsAImmobilien testsuite()', () => {
|
||||||
provider.init(providerConfig.einsAImmobilien, [], []);
|
provider.init(providerConfig.einsAImmobilien, []);
|
||||||
it('should test einsAImmobilien provider', async () => {
|
it('should test einsAImmobilien provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
return await new Promise((resolve) => {
|
const mockedJob = {
|
||||||
const fredy = new Fredy(
|
id: 'einsAImmobilien',
|
||||||
provider.config,
|
notificationAdapter: null,
|
||||||
null,
|
spatialFilter: null,
|
||||||
null,
|
specFilter: null,
|
||||||
provider.metaInformation.id,
|
};
|
||||||
'einsAImmobilien',
|
return await new Promise((resolve, reject) => {
|
||||||
similarityCache,
|
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
|
||||||
);
|
|
||||||
fredy.execute().then((listings) => {
|
fredy.execute().then((listings) => {
|
||||||
expect(listings).to.be.a('array');
|
if (listings == null || listings.length === 0) {
|
||||||
|
reject('Listings is empty!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(listings).toBeInstanceOf(Array);
|
||||||
const notificationObj = get();
|
const notificationObj = get();
|
||||||
expect(notificationObj).to.be.a('object');
|
expect(notificationObj).toBeTypeOf('object');
|
||||||
expect(notificationObj.serviceName).to.equal('einsAImmobilien');
|
expect(notificationObj.serviceName).toBe('einsAImmobilien');
|
||||||
notificationObj.payload.forEach((notify) => {
|
notificationObj.payload.forEach((notify) => {
|
||||||
/** check the actual structure **/
|
/** check the actual structure **/
|
||||||
expect(notify.id).to.be.a('string');
|
expect(notify.id).toBeTypeOf('string');
|
||||||
expect(notify.price).to.be.a('string');
|
expect(notify.price).toBeTypeOf('string');
|
||||||
expect(notify.size).to.be.a('string');
|
expect(notify.price).toContain('€');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.size).toBeTypeOf('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.size).toContain('m²');
|
||||||
expect(notify.address).to.be.a('string');
|
expect(notify.title).toBeTypeOf('string');
|
||||||
|
expect(notify.link).toBeTypeOf('string');
|
||||||
|
expect(notify.address).toBeTypeOf('string');
|
||||||
/** check the values if possible **/
|
/** check the values if possible **/
|
||||||
expect(notify.size).to.be.not.empty;
|
expect(notify.size).toBeTypeOf('string');
|
||||||
expect(notify.title).to.be.not.empty;
|
expect(notify.title).not.toBe('');
|
||||||
expect(notify.link).that.does.include('https://www.1a-immobilienmarkt.de');
|
expect(notify.link).toContain('https://www.1a-immobilienmarkt.de');
|
||||||
});
|
});
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,37 +6,89 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { providerConfig, mockFredy } from '../utils.js';
|
import { providerConfig, mockFredy } from '../utils.js';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'vitest';
|
||||||
import * as provider from '../../lib/provider/immobilienDe.js';
|
import * as provider from '../../lib/provider/immobilienDe.js';
|
||||||
|
import { launchBrowser, closeBrowser } from '../../lib/services/extractor/puppeteerExtractor.js';
|
||||||
|
|
||||||
|
// One browser shared across the whole suite so both requests (search + detail)
|
||||||
|
// come from the same warm session, avoiding double cold-start bot detection.
|
||||||
|
const TEST_TIMEOUT = 120_000;
|
||||||
|
|
||||||
describe('#immobilien.de testsuite()', () => {
|
describe('#immobilien.de testsuite()', () => {
|
||||||
provider.init(providerConfig.immobilienDe, [], []);
|
provider.init(providerConfig.immobilienDe, [], []);
|
||||||
it('should test immobilien.de provider', async () => {
|
|
||||||
const Fredy = await mockFredy();
|
let browser;
|
||||||
return await new Promise((resolve) => {
|
let liveListings;
|
||||||
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'test1', similarityCache);
|
|
||||||
fredy.execute().then((listing) => {
|
beforeAll(async () => {
|
||||||
expect(listing).to.be.a('array');
|
browser = await launchBrowser(providerConfig.immobilienDe.url);
|
||||||
const notificationObj = get();
|
}, TEST_TIMEOUT);
|
||||||
expect(notificationObj).to.be.a('object');
|
|
||||||
expect(notificationObj.serviceName).to.equal('immobilienDe');
|
afterAll(async () => {
|
||||||
notificationObj.payload.forEach((notify) => {
|
await closeBrowser(browser);
|
||||||
/** check the actual structure **/
|
});
|
||||||
expect(notify.id).to.be.a('string');
|
|
||||||
expect(notify.price).to.be.a('string');
|
it(
|
||||||
expect(notify.size).to.be.a('string');
|
'should test immobilien.de provider',
|
||||||
expect(notify.title).to.be.a('string');
|
async () => {
|
||||||
expect(notify.link).to.be.a('string');
|
const mockedJob = {
|
||||||
expect(notify.address).to.be.a('string');
|
id: 'test1',
|
||||||
/** check the values if possible **/
|
notificationAdapter: null,
|
||||||
expect(notify.price).that.does.include('€');
|
spatialFilter: null,
|
||||||
expect(notify.size).that.does.include('m²');
|
specFilter: null,
|
||||||
expect(notify.title).to.be.not.empty;
|
};
|
||||||
expect(notify.link).that.does.include('https://www.immobilien.de');
|
|
||||||
expect(notify.address).to.be.not.empty;
|
const Fredy = await mockFredy();
|
||||||
});
|
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, browser);
|
||||||
resolve();
|
liveListings = await fredy.execute();
|
||||||
|
|
||||||
|
if (liveListings == null || liveListings.length === 0) {
|
||||||
|
throw new Error('Listings is empty!');
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(liveListings).toBeInstanceOf(Array);
|
||||||
|
const notificationObj = get();
|
||||||
|
expect(notificationObj).toBeTypeOf('object');
|
||||||
|
expect(notificationObj.serviceName).toBe('immobilienDe');
|
||||||
|
notificationObj.payload.forEach((notify) => {
|
||||||
|
/** check the actual structure **/
|
||||||
|
expect(notify.id).toBeTypeOf('string');
|
||||||
|
expect(notify.price).toBeTypeOf('string');
|
||||||
|
expect(notify.size).toBeTypeOf('string');
|
||||||
|
expect(notify.title).toBeTypeOf('string');
|
||||||
|
expect(notify.link).toBeTypeOf('string');
|
||||||
|
expect(notify.address).toBeTypeOf('string');
|
||||||
|
/** check the values if possible **/
|
||||||
|
expect(notify.price).toContain('€');
|
||||||
|
expect(notify.size).toContain('m²');
|
||||||
|
expect(notify.title).not.toBe('');
|
||||||
|
expect(notify.link).toContain('https://www.immobilien.de');
|
||||||
|
expect(notify.address).not.toBe('');
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('with provider_details enabled', () => {
|
||||||
|
it(
|
||||||
|
'should enrich listings with details',
|
||||||
|
async () => {
|
||||||
|
if (!liveListings?.length) throw new Error('No listings from first test to enrich');
|
||||||
|
|
||||||
|
// Call fetchDetails directly on the first live listing — no need to
|
||||||
|
// re-scrape the search page. The shared browser keeps the session warm.
|
||||||
|
const enriched = await provider.config.fetchDetails(liveListings[0], browser);
|
||||||
|
|
||||||
|
if (enriched == null) return;
|
||||||
|
expect(enriched.link).toContain('https://www.immobilien.de');
|
||||||
|
expect(enriched.address).toBeTypeOf('string');
|
||||||
|
expect(enriched.address).not.toBe('');
|
||||||
|
// description may be null if selectors don't match yet — falls back gracefully
|
||||||
|
if (enriched.description != null) {
|
||||||
|
expect(enriched.description).toBeTypeOf('string');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user