Compare commits
77 Commits
improvemen
...
16.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
398259ff20 | ||
|
|
cf030bfa39 | ||
|
|
5dc976c7e3 | ||
|
|
05f1bc61c9 | ||
|
|
6e8a35a836 | ||
|
|
87771655a8 | ||
|
|
87b5673bf0 | ||
|
|
9291155cc2 | ||
|
|
ac90d4122b | ||
|
|
790c559316 | ||
|
|
2a815c92e6 | ||
|
|
cef9b5c8fc | ||
|
|
1e2476a375 | ||
|
|
78b762bd9e | ||
|
|
3e5cd97400 | ||
|
|
5cfa674d7f | ||
|
|
5bd4219743 | ||
|
|
ea24eb4374 | ||
|
|
9f67e30ff4 | ||
|
|
20d44b60ad | ||
|
|
22df683969 | ||
|
|
4aab850b4f | ||
|
|
3eb3f6ee66 | ||
|
|
1b2fc79536 | ||
|
|
0606122736 | ||
|
|
53d5098cec | ||
|
|
32c7518454 | ||
|
|
db3702ed33 | ||
|
|
e3c62d4696 | ||
|
|
79a8420dfb | ||
|
|
d433b13db6 | ||
|
|
41d9274dfd | ||
|
|
0436c7f7d7 | ||
|
|
a1cb57318e | ||
|
|
2566db9805 | ||
|
|
b48f786fd3 | ||
|
|
9c74129489 | ||
|
|
33120ebeca | ||
|
|
de2dd05c70 | ||
|
|
e4784e5960 | ||
|
|
2e537ce0be | ||
|
|
f0f1244baa | ||
|
|
b858529f06 | ||
|
|
c9bd5dc161 | ||
|
|
daa4a7b8f1 | ||
|
|
035f0e9f83 | ||
|
|
a5efd9af32 | ||
|
|
9f1e27d011 | ||
|
|
ebc57702dc | ||
|
|
3aa30bc1e2 | ||
|
|
f97fb48e51 | ||
|
|
4b15894603 | ||
|
|
31a14a0352 | ||
|
|
eecbe91dbd | ||
|
|
9dd3947cb7 | ||
|
|
c151f4f76e | ||
|
|
b6755497e4 | ||
|
|
412e24b1e3 | ||
|
|
0a5785fa1a | ||
|
|
7ebd73c9cf | ||
|
|
95cd4028d7 | ||
|
|
eb01c2107c | ||
|
|
42cd4fa0ae | ||
|
|
6d96fd2bf8 | ||
|
|
ff1d2317a1 | ||
|
|
a47fa41278 | ||
|
|
9654e56846 | ||
|
|
43094640a8 | ||
|
|
fa234d2d78 | ||
|
|
7cb0d6e382 | ||
|
|
d79f8d2664 | ||
|
|
4d37e890ab | ||
|
|
7589f20a18 | ||
|
|
702ffabc1a | ||
|
|
9387de1cd9 | ||
|
|
facd683d45 | ||
|
|
8324357edb |
@@ -1,7 +1,47 @@
|
|||||||
|
# Dependencies (will be installed fresh in container)
|
||||||
node_modules/
|
node_modules/
|
||||||
npm-debug.log
|
|
||||||
test/
|
# Database and config (mounted as volumes)
|
||||||
db/
|
db/
|
||||||
conf/
|
conf/
|
||||||
|
|
||||||
|
# Git
|
||||||
.git/
|
.git/
|
||||||
.github/
|
.github/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# IDE and editor
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
test/
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
doc/
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
|
||||||
|
# Development config files
|
||||||
|
.babelrc
|
||||||
|
.husky/
|
||||||
|
.nvmrc
|
||||||
|
.prettierrc
|
||||||
|
.prettierignore
|
||||||
|
eslint.config.js
|
||||||
|
|
||||||
|
# Docker files (not needed inside container)
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
docker-test.sh
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log
|
||||||
|
|
||||||
|
# Build artifacts (built fresh in container)
|
||||||
|
dist/
|
||||||
|
|||||||
2
.github/workflows/test.yml
vendored
@@ -19,4 +19,4 @@ jobs:
|
|||||||
cache: 'yarn'
|
cache: 'yarn'
|
||||||
|
|
||||||
- run: yarn install
|
- run: yarn install
|
||||||
- run: yarn test
|
- run: yarn testGH
|
||||||
|
|||||||
1
.gitignore
vendored
@@ -5,3 +5,4 @@ db/*.db*
|
|||||||
npm-debug.log
|
npm-debug.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.idea
|
.idea
|
||||||
|
.vscode
|
||||||
|
|||||||
71
Dockerfile
@@ -1,27 +1,59 @@
|
|||||||
FROM node:22-slim
|
# ================================
|
||||||
|
# Stage 1: Build stage
|
||||||
|
# ================================
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Install build dependencies needed for native modules (better-sqlite3)
|
||||||
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
|
# Copy package files first for better layer caching
|
||||||
|
COPY package.json yarn.lock ./
|
||||||
|
|
||||||
|
# Install all dependencies (including devDependencies for building)
|
||||||
|
RUN yarn config set network-timeout 600000 \
|
||||||
|
&& yarn --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy source files needed for build
|
||||||
|
COPY index.html vite.config.js ./
|
||||||
|
COPY ui ./ui
|
||||||
|
COPY lib ./lib
|
||||||
|
|
||||||
|
# Build frontend assets
|
||||||
|
RUN yarn build:frontend
|
||||||
|
|
||||||
|
# ================================
|
||||||
|
# Stage 2: Production stage
|
||||||
|
# ================================
|
||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
WORKDIR /fredy
|
WORKDIR /fredy
|
||||||
|
|
||||||
# Install Chromium and curl without extra recommended packages and clean apt cache
|
# Install Chromium and curl (for healthcheck)
|
||||||
# curl is needed for the health check
|
# Using Alpine's chromium package which is much smaller
|
||||||
RUN apt-get update \
|
RUN apk add --no-cache chromium curl
|
||||||
&& apt-get install -y --no-install-recommends chromium curl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
|
ENV NODE_ENV=production \
|
||||||
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
|
||||||
|
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
||||||
|
|
||||||
# Copy lockfiles first to leverage cache for dependencies
|
# Install build dependencies for native modules, then remove them after yarn install
|
||||||
COPY package.json yarn.lock .
|
COPY package.json yarn.lock ./
|
||||||
|
|
||||||
# Set Yarn timeout, install dependencies and PM2 globally
|
RUN apk add --no-cache --virtual .build-deps python3 make g++ \
|
||||||
RUN yarn config set network-timeout 600000 \
|
&& yarn config set network-timeout 600000 \
|
||||||
&& yarn --frozen-lockfile \
|
&& yarn --frozen-lockfile --production \
|
||||||
&& yarn global add pm2
|
&& yarn cache clean \
|
||||||
|
&& apk del .build-deps
|
||||||
|
|
||||||
# Copy application source and build production assets
|
# Copy built frontend from builder stage
|
||||||
COPY . .
|
COPY --from=builder /build/ui/public ./ui/public
|
||||||
RUN yarn build:frontend
|
|
||||||
|
# Copy application source (only what's needed at runtime)
|
||||||
|
COPY index.js ./
|
||||||
|
COPY index.html ./
|
||||||
|
COPY lib ./lib
|
||||||
|
|
||||||
# Prepare runtime directories and symlinks for data and config
|
# Prepare runtime directories and symlinks for data and config
|
||||||
RUN mkdir -p /db /conf \
|
RUN mkdir -p /db /conf \
|
||||||
@@ -31,6 +63,7 @@ RUN mkdir -p /db /conf \
|
|||||||
&& ln -s /conf /fredy/conf
|
&& ln -s /conf /fredy/conf
|
||||||
|
|
||||||
EXPOSE 9998
|
EXPOSE 9998
|
||||||
|
VOLUME /db
|
||||||
|
VOLUME /conf
|
||||||
|
|
||||||
# Start application using PM2 runtime
|
CMD ["node", "index.js"]
|
||||||
CMD ["pm2-runtime", "index.js"]
|
|
||||||
|
|||||||
231
LICENSE
@@ -1,21 +1,214 @@
|
|||||||
MIT License
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor
|
||||||
|
be liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
|
||||||
|
Additional License Condition – Commons Clause
|
||||||
|
|
||||||
|
The Licensed Work is provided under the terms of this license and is also
|
||||||
|
subject to the following additional condition ("Commons Clause"):
|
||||||
|
|
||||||
|
"License Condition v1.0":
|
||||||
|
|
||||||
|
The Licensed Work and its derivative works may not be used by any person or
|
||||||
|
organization to Sell the Licensed Work (as defined below).
|
||||||
|
|
||||||
|
"Sell" or "Selling" means practicing any or all of the rights granted to you
|
||||||
|
under the License to provide to third parties, for a fee or other consideration
|
||||||
|
(including without limitation fees for hosting or consulting/support services
|
||||||
|
related to the Software), a product or service whose value derives, entirely or
|
||||||
|
substantially, from the functionality of the Licensed Work.
|
||||||
|
|
||||||
|
A non-exhaustive list of activities considered "Selling" includes:
|
||||||
|
- Using the Licensed Work to provide paid hosted services or managed services
|
||||||
|
- Distributing the Licensed Work as part of a commercial product or service
|
||||||
|
for which a fee is charged primarily for the value of the Licensed Work
|
||||||
|
|
||||||
|
This restriction does not apply to the use of the Licensed Work for internal
|
||||||
|
business purposes or non-commercial use.
|
||||||
|
|
||||||
|
|
||||||
|
Attribution and Naming Clause
|
||||||
|
|
||||||
|
Any derivative work based on this software must include clear and visible
|
||||||
|
attribution to the original project "Fredy" and its author(s).
|
||||||
|
Derivative works may not be distributed, published, or presented under a
|
||||||
|
different name or branding without the explicit written permission of the
|
||||||
|
original copyright holder.
|
||||||
|
|
||||||
|
|
||||||
Copyright (c) 2025 Christian Kellner
|
Copyright (c) 2025 Christian Kellner
|
||||||
|
Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
|
|||||||
36
README.md
@@ -9,10 +9,18 @@
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||

|
<p align="center">
|
||||||
[](https://github.com/orangecoding/fredy/actions/workflows/docker.yml)
|
<a href="https://fredy.orange-coding.net/" target="_blank">Website</a> |
|
||||||

|
<a href="https://fredy-demo.orange-coding.net/" target="_blank">Demo</a>
|
||||||

|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://github.com/orangecoding/fredy/actions/workflows/test.yml/badge.svg" alt="Tests" />
|
||||||
|
<img src="https://github.com/orangecoding/fredy/actions/workflows/docker.yml/badge.svg" alt="Docker" />
|
||||||
|
<img src="https://github.com/orangecoding/fredy/actions/workflows/check_source.yml/badge.svg" alt="Source" />
|
||||||
|
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fghcr-badge.elias.eu.org%2Fapi%2Forangecoding%2Ffredy%2Ffredy&query=%24.downloadCount&label=Docker%20Pulls" alt="Docker Pulls" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Fredy 🏡 – Your Self-Hosted Real Estate Finder for Germany
|
# Fredy 🏡 – Your Self-Hosted Real Estate Finder for Germany
|
||||||
@@ -21,15 +29,13 @@ Finding an apartment or house in Germany can be stressful and
|
|||||||
time-consuming.\
|
time-consuming.\
|
||||||
**Fredy** makes it easier: it automatically scrapes **ImmoScout24,
|
**Fredy** makes it easier: it automatically scrapes **ImmoScout24,
|
||||||
Immowelt, Immonet, eBay Kleinanzeigen, and WG-Gesucht** and notifies you
|
Immowelt, Immonet, eBay Kleinanzeigen, and WG-Gesucht** and notifies you
|
||||||
instantly via **Slack, Telegram, Email, ntfy, and more** when new
|
instantly via **Slack, Telegram, Email, ntfy, discord and more** when new
|
||||||
listings appear.
|
listings appear.
|
||||||
|
|
||||||
With a modern architecture, Fredy provides a **clean Web UI**, removes
|
With a modern architecture, Fredy provides a **clean Web UI**, removes
|
||||||
duplicates across platforms, and stores results so you never see the
|
duplicates across platforms, and stores results so you never see the
|
||||||
same listing twice.
|
same listing twice.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
## ✨ Key Features
|
## ✨ Key Features
|
||||||
@@ -37,7 +43,7 @@ same listing twice.
|
|||||||
- 🏠 Scrapes **ImmoScout24, Immowelt, Immonet, eBay Kleinanzeigen,
|
- 🏠 Scrapes **ImmoScout24, Immowelt, Immonet, eBay Kleinanzeigen,
|
||||||
WG-Gesucht**
|
WG-Gesucht**
|
||||||
- ⚡ Instant notifications: Slack, Telegram, Email (SendGrid,
|
- ⚡ Instant notifications: Slack, Telegram, Email (SendGrid,
|
||||||
Mailjet), ntfy
|
Mailjet), ntfy, discord
|
||||||
- 🔎 Uses the **ImmoScout Mobile API** (reverse engineered)
|
- 🔎 Uses the **ImmoScout Mobile API** (reverse engineered)
|
||||||
- 🌍 Runs anywhere: Docker, Node.js, self-hosted
|
- 🌍 Runs anywhere: Docker, Node.js, self-hosted
|
||||||
- 🖥️ Intuitive **Web UI** to manage searches
|
- 🖥️ Intuitive **Web UI** to manage searches
|
||||||
@@ -101,6 +107,10 @@ yarn run start:frontend # in another terminal
|
|||||||
|
|
||||||
👉 Open <http://localhost:9998>
|
👉 Open <http://localhost:9998>
|
||||||
|
|
||||||
|
### With Unraid
|
||||||
|
|
||||||
|
Should you use [Unraid](https://unraid.net/), you can now install Fredy from the community store :)
|
||||||
|
|
||||||
**Default Login:**
|
**Default Login:**
|
||||||
- Username: `admin`
|
- Username: `admin`
|
||||||
- Password: `admin`
|
- Password: `admin`
|
||||||
@@ -109,9 +119,9 @@ yarn run start:frontend # in another terminal
|
|||||||
|
|
||||||
## 📸 Screenshots
|
## 📸 Screenshots
|
||||||
|
|
||||||
| Job Configuration | Job Analytics | Job Overview |
|
| Fredy Main Overview | Job Configuration | Found Listings |
|
||||||
|-------------------|--------------|--------------|
|
|--------------------------------------------------|-----------------------------------------------------------------------|-----------------------------------------------------------------------------|
|
||||||
|  |  |  |
|
|  |  |  |
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -131,7 +141,7 @@ picks up the newest listings first.
|
|||||||
### Adapter 📡
|
### Adapter 📡
|
||||||
|
|
||||||
An **adapter** is the channel through which Fredy notifies you (Slack,
|
An **adapter** is the channel through which Fredy notifies you (Slack,
|
||||||
Telegram, Email, ntfy, ...).\
|
Telegram, Email, ntfy, discord ...).\
|
||||||
Each adapter has its own configuration (e.g. API keys, webhook URLs).\
|
Each adapter has its own configuration (e.g. API keys, webhook URLs).\
|
||||||
You can use multiple adapters at once --- Fredy will send new listings
|
You can use multiple adapters at once --- Fredy will send new listings
|
||||||
through all of them.
|
through all of them.
|
||||||
@@ -196,7 +206,7 @@ flowchart TD
|
|||||||
F2["Adapter 2"]
|
F2["Adapter 2"]
|
||||||
end
|
end
|
||||||
|
|
||||||
A1 --> B["FredyRuntime"]
|
A1 --> B["FredyPipelineExecutioner"]
|
||||||
A2 --> B
|
A2 --> B
|
||||||
A3 --> B
|
A3 --> B
|
||||||
B --> C1 & C2 & C3
|
B --> C1 & C2 & C3
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"interval":"60","port":9998,"workingHours":{"from":"","to":""},"demoMode":false,"analyticsEnabled":true,"sqlitepath":"/db"}
|
{"sqlitepath":"/db"}
|
||||||
48
copyright.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const COPYRIGHT = `/*
|
||||||
|
* Copyright (c) ${new Date().getFullYear()} by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
async function getAllFiles(dir = '.') {
|
||||||
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||||
|
let files = [];
|
||||||
|
for (let entry of entries) {
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
||||||
|
files = files.concat(await getAllFiles(fullPath));
|
||||||
|
} else if (fullPath.endsWith('.js') || fullPath.endsWith('.jsx')) {
|
||||||
|
files.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
async function addCopyright(files) {
|
||||||
|
for (let file of files) {
|
||||||
|
try {
|
||||||
|
let content = await fs.readFile(file, 'utf8');
|
||||||
|
if (!content.startsWith(COPYRIGHT)) {
|
||||||
|
await fs.writeFile(file, COPYRIGHT + content);
|
||||||
|
console.log(`Added copyright to ${file}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error processing ${file}: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* eslint-enable no-console */
|
||||||
|
|
||||||
|
const filesToProcess = process.argv.length > 2 ? process.argv.slice(2) : await getAllFiles();
|
||||||
|
await addCopyright(filesToProcess);
|
||||||
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 197 KiB |
BIN
doc/screenshot2.png
Normal file
|
After Width: | Height: | Size: 512 KiB |
BIN
doc/screenshot3.png
Normal file
|
After Width: | Height: | Size: 402 KiB |
|
Before Width: | Height: | Size: 323 KiB |
|
Before Width: | Height: | Size: 93 KiB |
BIN
doc/unraid_fredy_logo.png
Normal file
|
After Width: | Height: | Size: 417 KiB |
@@ -1,22 +1,26 @@
|
|||||||
services:
|
services:
|
||||||
fredy:
|
fredy:
|
||||||
container_name: fredy
|
container_name: fredy
|
||||||
# build from empty build folder to reduce size of image
|
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: fredy/fredy
|
image: ghcr.io/orangecoding/fredy
|
||||||
# map existing config and database
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
volumes:
|
volumes:
|
||||||
- ./conf:/conf
|
- ./conf:/conf
|
||||||
- ./db:/db
|
- ./db:/db
|
||||||
ports:
|
ports:
|
||||||
- "9998:9998"
|
- "9998:9998"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
# Resource limits to prevent runaway memory usage from Chromium
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 1G
|
||||||
healthcheck:
|
healthcheck:
|
||||||
# The container will immediately stop when health check fails after retries
|
test: ["CMD", "curl", "--fail", "--silent", "--show-error", "--max-time", "5", "http://localhost:9998/"]
|
||||||
test: ["CMD-SHELL", "curl --fail --silent --show-error --max-time 5 http://localhost:9998/ || exit 1"]
|
|
||||||
interval: 120s
|
interval: 120s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 1
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 30s
|
||||||
|
|||||||
18
docker-test.sh
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Stop and remove old container if it exists
|
||||||
|
if [ "$(docker ps -aq -f name=fredy)" ]; then
|
||||||
|
docker stop fredy || true
|
||||||
|
docker rm fredy || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build image from local Dockerfile, forcing a fresh build without cache
|
||||||
|
docker build --no-cache -t fredy:local .
|
||||||
|
|
||||||
|
# Run container with volumes and port mapping
|
||||||
|
docker run -d --name fredy \
|
||||||
|
-v fredy_conf:/conf \
|
||||||
|
-v fredy_db:/db \
|
||||||
|
-p 9998:9998 \
|
||||||
|
fredy:local
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
// eslint.config.js
|
// eslint.config.js
|
||||||
import js from '@eslint/js';
|
import js from '@eslint/js';
|
||||||
import prettier from 'eslint-config-prettier';
|
import prettier from 'eslint-config-prettier';
|
||||||
|
|||||||
@@ -7,11 +7,14 @@
|
|||||||
content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
|
content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
|
||||||
/>
|
/>
|
||||||
<meta name="google" content="notranslate" />
|
<meta name="google" content="notranslate" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
|
||||||
<title>Fredy</title>
|
<title>Fredy || Real Estate Finder</title>
|
||||||
</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>
|
||||||
</body>
|
</body>
|
||||||
<script type="module" src="/ui/src/Index.jsx"></script>
|
<script type="module" src="/ui/src/Index.jsx"></script>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
84
index.js
@@ -1,88 +1,68 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import { checkIfConfigIsAccessible, getProviders, refreshConfig } from './lib/utils.js';
|
||||||
import { config, getProviders, refreshConfig } from './lib/utils.js';
|
|
||||||
import * as similarityCache from './lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from './lib/services/similarity-check/similarityCache.js';
|
||||||
import * as jobStorage from './lib/services/storage/jobStorage.js';
|
|
||||||
import FredyRuntime from './lib/FredyRuntime.js';
|
|
||||||
import { duringWorkingHoursOrNotSet } from './lib/utils.js';
|
|
||||||
import { runMigrations } from './lib/services/storage/migrations/migrate.js';
|
import { runMigrations } from './lib/services/storage/migrations/migrate.js';
|
||||||
import { ensureDemoUserExists, ensureAdminUserExists } from './lib/services/storage/userStorage.js';
|
import { ensureDemoUserExists, ensureAdminUserExists } from './lib/services/storage/userStorage.js';
|
||||||
import { cleanupDemoAtMidnight } from './lib/services/crons/demoCleanup-cron.js';
|
import { cleanupDemoAtMidnight } from './lib/services/crons/demoCleanup-cron.js';
|
||||||
import { initTrackerCron } from './lib/services/crons/tracker-cron.js';
|
import { initTrackerCron } from './lib/services/crons/tracker-cron.js';
|
||||||
import logger from './lib/services/logger.js';
|
import logger from './lib/services/logger.js';
|
||||||
import { bus } from './lib/services/events/event-bus.js';
|
|
||||||
import { initActiveCheckerCron } from './lib/services/crons/listing-alive-cron.js';
|
import { initActiveCheckerCron } from './lib/services/crons/listing-alive-cron.js';
|
||||||
|
import { getSettings } from './lib/services/storage/settingsStorage.js';
|
||||||
|
import SqliteConnection, { computeDbPath } from './lib/services/storage/SqliteConnection.js';
|
||||||
|
import { initJobExecutionService } from './lib/services/jobs/jobExecutionService.js';
|
||||||
|
|
||||||
|
//in the config, we store the path of the sqlite file, thus we must check if it is available
|
||||||
|
const isConfigAccessible = await checkIfConfigIsAccessible();
|
||||||
|
await SqliteConnection.init();
|
||||||
|
|
||||||
// Load configuration before any other startup steps
|
// Load configuration before any other startup steps
|
||||||
await refreshConfig();
|
await refreshConfig();
|
||||||
|
|
||||||
// Ensure sqlite directory exists before loading anything else (based on config.sqlitepath)
|
if (!isConfigAccessible) {
|
||||||
const rawDir = config.sqlitepath || '/db';
|
logger.error('Configuration exists, but is not accessible. Please check the file permission');
|
||||||
const relDir = rawDir.startsWith('/') ? rawDir.slice(1) : rawDir;
|
process.exit(1);
|
||||||
const absDir = path.isAbsolute(relDir) ? relDir : path.join(process.cwd(), relDir);
|
|
||||||
if (!fs.existsSync(absDir)) {
|
|
||||||
fs.mkdirSync(absDir, { recursive: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run DB migrations once at startup and block until finished
|
// Run DB migrations once at startup and block until finished
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
|
|
||||||
|
const settings = await getSettings();
|
||||||
|
|
||||||
|
// Ensure the sqlite directory exists before loading anything else (based on config.sqlitepath)
|
||||||
|
const { dir: sqliteDir } = await computeDbPath();
|
||||||
|
if (!fs.existsSync(sqliteDir)) {
|
||||||
|
fs.mkdirSync(sqliteDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
// Load provider modules once at startup
|
// Load provider modules once at startup
|
||||||
const providers = await getProviders();
|
const providers = await getProviders();
|
||||||
|
|
||||||
|
similarityCache.initSimilarityCache();
|
||||||
|
similarityCache.startSimilarityCacheReloader();
|
||||||
|
|
||||||
//assuming interval is always in minutes
|
//assuming interval is always in minutes
|
||||||
const INTERVAL = config.interval * 60 * 1000;
|
const INTERVAL = settings.interval * 60 * 1000;
|
||||||
|
|
||||||
// Initialize API only after migrations completed
|
// Initialize API only after migrations completed
|
||||||
await import('./lib/api/api.js');
|
await import('./lib/api/api.js');
|
||||||
|
|
||||||
if (config.demoMode) {
|
if (settings.demoMode) {
|
||||||
logger.info('Running in demo mode');
|
logger.info('Running in demo mode');
|
||||||
cleanupDemoAtMidnight();
|
cleanupDemoAtMidnight();
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Started Fredy successfully. Ui can be accessed via http://localhost:${config.port}`);
|
|
||||||
|
|
||||||
ensureAdminUserExists();
|
ensureAdminUserExists();
|
||||||
ensureDemoUserExists();
|
ensureDemoUserExists();
|
||||||
await initTrackerCron();
|
await initTrackerCron();
|
||||||
//do not wait for this to finish, let it run in the background
|
//do not wait for this to finish, let it run in the background
|
||||||
initActiveCheckerCron();
|
initActiveCheckerCron();
|
||||||
|
|
||||||
bus.on('jobs:runAll', () => {
|
logger.info(`Started Fredy successfully. Ui can be accessed via http://localhost:${settings.port}`);
|
||||||
logger.debug('Running Fredy Job manually');
|
|
||||||
execute();
|
|
||||||
});
|
|
||||||
|
|
||||||
const execute = () => {
|
// Initialize the lean Job Execution Service (schedules and bus listeners)
|
||||||
const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(config, Date.now());
|
initJobExecutionService({ providers, settings, intervalMs: INTERVAL });
|
||||||
if (!config.demoMode) {
|
|
||||||
if (isDuringWorkingHoursOrNotSet) {
|
|
||||||
config.lastRun = Date.now();
|
|
||||||
jobStorage
|
|
||||||
.getJobs()
|
|
||||||
.filter((job) => job.enabled)
|
|
||||||
.forEach((job) => {
|
|
||||||
job.provider
|
|
||||||
.filter((p) => providers.find((loaded) => loaded.metaInformation.id === p.id) != null)
|
|
||||||
.forEach(async (prov) => {
|
|
||||||
const matchedProvider = providers.find((loaded) => loaded.metaInformation.id === prov.id);
|
|
||||||
matchedProvider.init(prov, job.blacklist);
|
|
||||||
await new FredyRuntime(
|
|
||||||
matchedProvider.config,
|
|
||||||
job.notificationAdapter,
|
|
||||||
prov.id,
|
|
||||||
job.id,
|
|
||||||
similarityCache,
|
|
||||||
).execute();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
logger.debug('Working hours set. Skipping as outside of working hours.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
setInterval(execute, INTERVAL);
|
|
||||||
//start once at startup
|
|
||||||
execute();
|
|
||||||
|
|||||||
221
lib/FredyPipelineExecutioner.js
Executable file
@@ -0,0 +1,221 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NoNewListingsWarning } from './errors.js';
|
||||||
|
import { storeListings, getKnownListingHashesForJobAndProvider } from './services/storage/listingsStorage.js';
|
||||||
|
import * as notify from './notification/notify.js';
|
||||||
|
import Extractor from './services/extractor/extractor.js';
|
||||||
|
import urlModifier from './services/queryStringMutator.js';
|
||||||
|
import logger from './services/logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Listing
|
||||||
|
* @property {string} id Stable unique identifier (hash) of the listing.
|
||||||
|
* @property {string} title Title or headline of the listing.
|
||||||
|
* @property {string} [address] Optional address/location text.
|
||||||
|
* @property {string} [price] Optional price text/value.
|
||||||
|
* @property {string} [url] Link to the listing detail page.
|
||||||
|
* @property {any} [meta] Provider-specific additional metadata.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} SimilarityCache
|
||||||
|
* @property {(title:string, address?:string)=>boolean} hasSimilarEntries Returns true if a similar entry is known.
|
||||||
|
* @property {(title:string, address?:string)=>void} addCacheEntry Adds a new entry to the similarity cache.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime orchestrator for fetching, normalizing, filtering, deduplicating, storing,
|
||||||
|
* and notifying about new listings from a configured provider.
|
||||||
|
*
|
||||||
|
* The execution flow is:
|
||||||
|
* 1) Prepare provider URL (sorting, etc.)
|
||||||
|
* 2) Extract raw listings from the provider
|
||||||
|
* 3) Normalize listings to the provider schema
|
||||||
|
* 4) Filter out incomplete/blacklisted listings
|
||||||
|
* 5) Identify new listings (vs. previously stored hashes)
|
||||||
|
* 6) Persist new listings
|
||||||
|
* 7) Filter out entries similar to already seen ones
|
||||||
|
* 8) Dispatch notifications
|
||||||
|
*/
|
||||||
|
class FredyPipelineExecutioner {
|
||||||
|
/**
|
||||||
|
* Create a new runtime instance for a single provider/job execution.
|
||||||
|
*
|
||||||
|
* @param {Object} providerConfig Provider configuration.
|
||||||
|
* @param {string} providerConfig.url Base URL to crawl.
|
||||||
|
* @param {string} [providerConfig.sortByDateParam] Query parameter used to enforce sorting by date (provider-specific).
|
||||||
|
* @param {string} [providerConfig.waitForSelector] CSS selector to wait for before parsing content.
|
||||||
|
* @param {Object.<string, string>} providerConfig.crawlFields Mapping of field names to selectors/paths to extract.
|
||||||
|
* @param {string} providerConfig.crawlContainer CSS selector for the container holding listing items.
|
||||||
|
* @param {(raw:any)=>Listing} providerConfig.normalize Function to convert raw scraped data into a Listing shape.
|
||||||
|
* @param {(listing:Listing)=>boolean} providerConfig.filter Function to filter out unwanted listings.
|
||||||
|
* @param {(url:string, waitForSelector?:string)=>Promise<void>|Promise<Listing[]>} [providerConfig.getListings] Optional override to fetch listings.
|
||||||
|
*
|
||||||
|
* @param {Object} notificationConfig Notification configuration passed to notification adapters.
|
||||||
|
* @param {string} providerId The ID of the provider currently in use.
|
||||||
|
* @param {string} jobKey Key of the job that is currently running (from within the config).
|
||||||
|
* @param {SimilarityCache} similarityCache Cache instance for checking similar entries.
|
||||||
|
*/
|
||||||
|
constructor(providerConfig, notificationConfig, providerId, jobKey, similarityCache) {
|
||||||
|
this._providerConfig = providerConfig;
|
||||||
|
this._notificationConfig = notificationConfig;
|
||||||
|
this._providerId = providerId;
|
||||||
|
this._jobKey = jobKey;
|
||||||
|
this._similarityCache = similarityCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the end-to-end pipeline for a single provider run.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Listing[]|void>} Resolves to the list of new (and similarity-filtered) listings
|
||||||
|
* after notifications have been sent; resolves to void when there are no new listings.
|
||||||
|
*/
|
||||||
|
execute() {
|
||||||
|
return Promise.resolve(urlModifier(this._providerConfig.url, this._providerConfig.sortByDateParam))
|
||||||
|
.then(this._providerConfig.getListings?.bind(this) ?? this._getListings.bind(this))
|
||||||
|
.then(this._normalize.bind(this))
|
||||||
|
.then(this._filter.bind(this))
|
||||||
|
.then(this._findNew.bind(this))
|
||||||
|
.then(this._save.bind(this))
|
||||||
|
.then(this._filterBySimilarListings.bind(this))
|
||||||
|
.then(this._notify.bind(this))
|
||||||
|
.catch(this._handleError.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch listings from the provider, using the default Extractor flow unless
|
||||||
|
* a provider-specific getListings override is supplied.
|
||||||
|
*
|
||||||
|
* @param {string} url The provider URL to fetch from.
|
||||||
|
* @returns {Promise<Listing[]>} Resolves with an array of listings (empty when none found).
|
||||||
|
*/
|
||||||
|
_getListings(url) {
|
||||||
|
const extractor = new Extractor();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
extractor
|
||||||
|
.execute(url, this._providerConfig.waitForSelector)
|
||||||
|
.then(() => {
|
||||||
|
const listings = extractor.parseResponseText(
|
||||||
|
this._providerConfig.crawlContainer,
|
||||||
|
this._providerConfig.crawlFields,
|
||||||
|
url,
|
||||||
|
);
|
||||||
|
resolve(listings == null ? [] : listings);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
reject(err);
|
||||||
|
logger.error(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize raw listings into the provider-specific Listing shape.
|
||||||
|
*
|
||||||
|
* @param {any[]} listings Raw listing entries from the extractor or override.
|
||||||
|
* @returns {Listing[]} Normalized listings.
|
||||||
|
*/
|
||||||
|
_normalize(listings) {
|
||||||
|
return listings.map(this._providerConfig.normalize);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter out listings that are missing required fields and those rejected by the
|
||||||
|
* provider's blacklist/filter function.
|
||||||
|
*
|
||||||
|
* @param {Listing[]} listings Listings to filter.
|
||||||
|
* @returns {Listing[]} Filtered listings that pass validation and provider filter.
|
||||||
|
*/
|
||||||
|
_filter(listings) {
|
||||||
|
const keys = Object.keys(this._providerConfig.crawlFields);
|
||||||
|
const filteredListings = listings.filter((item) => keys.every((key) => key in item));
|
||||||
|
return filteredListings.filter(this._providerConfig.filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine which listings are new by comparing their IDs against stored hashes.
|
||||||
|
*
|
||||||
|
* @param {Listing[]} listings Listings to evaluate for novelty.
|
||||||
|
* @returns {Listing[]} New listings not seen before.
|
||||||
|
* @throws {NoNewListingsWarning} When no new listings are found.
|
||||||
|
*/
|
||||||
|
_findNew(listings) {
|
||||||
|
logger.debug(`Checking ${listings.length} listings for new entries (Provider: '${this._providerId}')`);
|
||||||
|
const hashes = getKnownListingHashesForJobAndProvider(this._jobKey, this._providerId) || [];
|
||||||
|
|
||||||
|
const newListings = listings.filter((o) => !hashes.includes(o.id));
|
||||||
|
if (newListings.length === 0) {
|
||||||
|
throw new NoNewListingsWarning();
|
||||||
|
}
|
||||||
|
return newListings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send notifications for new listings using the configured notification adapter(s).
|
||||||
|
*
|
||||||
|
* @param {Listing[]} newListings New listings to notify about.
|
||||||
|
* @returns {Promise<Listing[]>} Resolves to the provided listings after notifications complete.
|
||||||
|
* @throws {NoNewListingsWarning} When there are no listings to notify about.
|
||||||
|
*/
|
||||||
|
_notify(newListings) {
|
||||||
|
if (newListings.length === 0) {
|
||||||
|
throw new NoNewListingsWarning();
|
||||||
|
}
|
||||||
|
const sendNotifications = notify.send(this._providerId, newListings, this._notificationConfig, this._jobKey);
|
||||||
|
return Promise.all(sendNotifications).then(() => newListings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist new listings and pass them through.
|
||||||
|
*
|
||||||
|
* @param {Listing[]} newListings Listings to store.
|
||||||
|
* @returns {Listing[]} The same listings, unchanged.
|
||||||
|
*/
|
||||||
|
_save(newListings) {
|
||||||
|
logger.debug(`Storing ${newListings.length} new listings (Provider: '${this._providerId}')`);
|
||||||
|
storeListings(this._jobKey, this._providerId, newListings);
|
||||||
|
return newListings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove listings that are similar to already known entries according to the similarity cache.
|
||||||
|
* Adds the remaining listings to the cache.
|
||||||
|
*
|
||||||
|
* @param {Listing[]} listings Listings to filter by similarity.
|
||||||
|
* @returns {Listing[]} Listings considered unique enough to keep.
|
||||||
|
*/
|
||||||
|
_filterBySimilarListings(listings) {
|
||||||
|
return listings.filter((listing) => {
|
||||||
|
const similar = this._similarityCache.checkAndAddEntry({
|
||||||
|
title: listing.title,
|
||||||
|
address: listing.address,
|
||||||
|
price: listing.price,
|
||||||
|
});
|
||||||
|
if (similar) {
|
||||||
|
logger.debug(
|
||||||
|
`Filtering similar entry for title '${listing.title}' and address '${listing.address}' (Provider: '${this._providerId}')`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return !similar;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle errors occurring in the pipeline, logging levels depending on type.
|
||||||
|
*
|
||||||
|
* @param {Error} err Error instance thrown by previous steps.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
_handleError(err) {
|
||||||
|
if (err.name === 'NoNewListingsWarning') {
|
||||||
|
logger.debug(`No new listings found (Provider: '${this._providerId}').`);
|
||||||
|
} else {
|
||||||
|
logger.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FredyPipelineExecutioner;
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import { NoNewListingsWarning } from './errors.js';
|
|
||||||
import { storeListings, getKnownListingHashesForJobAndProvider } from './services/storage/listingsStorage.js';
|
|
||||||
import * as notify from './notification/notify.js';
|
|
||||||
import Extractor from './services/extractor/extractor.js';
|
|
||||||
import urlModifier from './services/queryStringMutator.js';
|
|
||||||
import logger from './services/logger.js';
|
|
||||||
|
|
||||||
class FredyRuntime {
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param providerConfig the config for the specific provider, we're going to query at the moment
|
|
||||||
* @param notificationConfig the config for all notifications
|
|
||||||
* @param providerId the id of the provider currently in use
|
|
||||||
* @param jobKey key of the job that is currently running (from within the config)
|
|
||||||
* @param similarityCache cache instance holding values to check for similarity of entries
|
|
||||||
*/
|
|
||||||
constructor(providerConfig, notificationConfig, providerId, jobKey, similarityCache) {
|
|
||||||
this._providerConfig = providerConfig;
|
|
||||||
this._notificationConfig = notificationConfig;
|
|
||||||
this._providerId = providerId;
|
|
||||||
this._jobKey = jobKey;
|
|
||||||
this._similarityCache = similarityCache;
|
|
||||||
}
|
|
||||||
|
|
||||||
execute() {
|
|
||||||
return (
|
|
||||||
//modify the url to make sure search order is correctly set
|
|
||||||
Promise.resolve(urlModifier(this._providerConfig.url, this._providerConfig.sortByDateParam))
|
|
||||||
//scraping the site and try finding new listings
|
|
||||||
.then(this._providerConfig.getListings?.bind(this) ?? this._getListings.bind(this))
|
|
||||||
//bring them in a proper form (dictated by the provider)
|
|
||||||
.then(this._normalize.bind(this))
|
|
||||||
//filter listings with stuff tagged by the blacklist of the provider
|
|
||||||
.then(this._filter.bind(this))
|
|
||||||
//check if new listings available. if so proceed
|
|
||||||
.then(this._findNew.bind(this))
|
|
||||||
//store everything in db
|
|
||||||
.then(this._save.bind(this))
|
|
||||||
//check for similar listings. if found, remove them before notifying
|
|
||||||
.then(this._filterBySimilarListings.bind(this))
|
|
||||||
//notify the user using the configured notification adapter
|
|
||||||
.then(this._notify.bind(this))
|
|
||||||
//if an error occurred on the way, handle it here.
|
|
||||||
.catch(this._handleError.bind(this))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
_getListings(url) {
|
|
||||||
const extractor = new Extractor();
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
extractor
|
|
||||||
.execute(url, this._providerConfig.waitForSelector)
|
|
||||||
.then(() => {
|
|
||||||
const listings = extractor.parseResponseText(
|
|
||||||
this._providerConfig.crawlContainer,
|
|
||||||
this._providerConfig.crawlFields,
|
|
||||||
url,
|
|
||||||
);
|
|
||||||
resolve(listings == null ? [] : listings);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
reject(err);
|
|
||||||
logger.error(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
_normalize(listings) {
|
|
||||||
return listings.map(this._providerConfig.normalize);
|
|
||||||
}
|
|
||||||
|
|
||||||
_filter(listings) {
|
|
||||||
//only return those where all the fields have been found
|
|
||||||
const keys = Object.keys(this._providerConfig.crawlFields);
|
|
||||||
const filteredListings = listings.filter((item) => keys.every((key) => key in item));
|
|
||||||
return filteredListings.filter(this._providerConfig.filter);
|
|
||||||
}
|
|
||||||
|
|
||||||
_findNew(listings) {
|
|
||||||
const hashes = getKnownListingHashesForJobAndProvider(this._jobKey, this._providerId) || [];
|
|
||||||
|
|
||||||
const newListings = listings.filter((o) => !hashes.includes(o.id));
|
|
||||||
if (newListings.length === 0) {
|
|
||||||
throw new NoNewListingsWarning();
|
|
||||||
}
|
|
||||||
return newListings;
|
|
||||||
}
|
|
||||||
|
|
||||||
_notify(newListings) {
|
|
||||||
if (newListings.length === 0) {
|
|
||||||
throw new NoNewListingsWarning();
|
|
||||||
}
|
|
||||||
const sendNotifications = notify.send(this._providerId, newListings, this._notificationConfig, this._jobKey);
|
|
||||||
return Promise.all(sendNotifications).then(() => newListings);
|
|
||||||
}
|
|
||||||
|
|
||||||
_save(newListings) {
|
|
||||||
storeListings(this._jobKey, this._providerId, newListings);
|
|
||||||
return newListings;
|
|
||||||
}
|
|
||||||
|
|
||||||
_filterBySimilarListings(listings) {
|
|
||||||
const filteredList = listings.filter((listing) => {
|
|
||||||
const similar = this._similarityCache.hasSimilarEntries(listing.title, listing.address);
|
|
||||||
if (similar) {
|
|
||||||
logger.debug(`Filtering similar entry for title: ${listing.title} and address ${listing.address}`);
|
|
||||||
}
|
|
||||||
return !similar;
|
|
||||||
});
|
|
||||||
filteredList.forEach((filter) => this._similarityCache.addCacheEntry(filter.title, filter.address));
|
|
||||||
return filteredList;
|
|
||||||
}
|
|
||||||
|
|
||||||
_handleError(err) {
|
|
||||||
if (err.name !== 'NoNewListingsWarning') logger.error(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default FredyRuntime;
|
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import { notificationAdapterRouter } from './routes/notificationAdapterRouter.js';
|
import { notificationAdapterRouter } from './routes/notificationAdapterRouter.js';
|
||||||
import { authInterceptor, cookieSession, adminInterceptor } from './security.js';
|
import { authInterceptor, cookieSession, adminInterceptor } from './security.js';
|
||||||
import { generalSettingsRouter } from './routes/generalSettingsRoute.js';
|
import { generalSettingsRouter } from './routes/generalSettingsRoute.js';
|
||||||
import { analyticsRouter } from './routes/analyticsRouter.js';
|
|
||||||
import { providerRouter } from './routes/providerRouter.js';
|
import { providerRouter } from './routes/providerRouter.js';
|
||||||
import { versionRouter } from './routes/versionRouter.js';
|
import { versionRouter } from './routes/versionRouter.js';
|
||||||
import { loginRouter } from './routes/loginRoute.js';
|
import { loginRouter } from './routes/loginRoute.js';
|
||||||
import { userRouter } from './routes/userRoute.js';
|
import { userRouter } from './routes/userRoute.js';
|
||||||
import { jobRouter } from './routes/jobRouter.js';
|
import { jobRouter } from './routes/jobRouter.js';
|
||||||
import { config } from '../utils.js';
|
|
||||||
import bodyParser from 'body-parser';
|
import bodyParser from 'body-parser';
|
||||||
import restana from 'restana';
|
import restana from 'restana';
|
||||||
import files from 'serve-static';
|
import files from 'serve-static';
|
||||||
@@ -16,9 +19,13 @@ import { getDirName } from '../utils.js';
|
|||||||
import { demoRouter } from './routes/demoRouter.js';
|
import { demoRouter } from './routes/demoRouter.js';
|
||||||
import logger from '../services/logger.js';
|
import logger from '../services/logger.js';
|
||||||
import { listingsRouter } from './routes/listingsRouter.js';
|
import { listingsRouter } from './routes/listingsRouter.js';
|
||||||
|
import { getSettings } from '../services/storage/settingsStorage.js';
|
||||||
|
import { featureRouter } from './routes/featureRouter.js';
|
||||||
|
import { dashboardRouter } from './routes/dashboardRouter.js';
|
||||||
|
import { backupRouter } from './routes/backupRouter.js';
|
||||||
const service = restana();
|
const service = restana();
|
||||||
const staticService = files(path.join(getDirName(), '../ui/public'));
|
const staticService = files(path.join(getDirName(), '../ui/public'));
|
||||||
const PORT = config.port || 9998;
|
const PORT = (await getSettings()).port || 9998;
|
||||||
|
|
||||||
service.use(bodyParser.json());
|
service.use(bodyParser.json());
|
||||||
service.use(cookieSession());
|
service.use(cookieSession());
|
||||||
@@ -27,18 +34,22 @@ service.use('/api/admin', authInterceptor());
|
|||||||
service.use('/api/jobs', authInterceptor());
|
service.use('/api/jobs', authInterceptor());
|
||||||
service.use('/api/version', authInterceptor());
|
service.use('/api/version', authInterceptor());
|
||||||
service.use('/api/listings', authInterceptor());
|
service.use('/api/listings', authInterceptor());
|
||||||
|
service.use('/api/dashboard', authInterceptor());
|
||||||
|
service.use('/api/features', authInterceptor());
|
||||||
|
|
||||||
// /admin can only be accessed when user is having admin permissions
|
// /admin can only be accessed when user is having admin permissions
|
||||||
service.use('/api/admin', adminInterceptor());
|
service.use('/api/admin', adminInterceptor());
|
||||||
service.use('/api/jobs/notificationAdapter', notificationAdapterRouter);
|
service.use('/api/jobs/notificationAdapter', notificationAdapterRouter);
|
||||||
service.use('/api/admin/generalSettings', generalSettingsRouter);
|
service.use('/api/admin/generalSettings', generalSettingsRouter);
|
||||||
|
service.use('/api/admin/backup', backupRouter);
|
||||||
service.use('/api/jobs/provider', providerRouter);
|
service.use('/api/jobs/provider', providerRouter);
|
||||||
service.use('/api/jobs/insights', analyticsRouter);
|
|
||||||
service.use('/api/admin/users', userRouter);
|
service.use('/api/admin/users', userRouter);
|
||||||
service.use('/api/version', versionRouter);
|
service.use('/api/version', versionRouter);
|
||||||
service.use('/api/jobs', jobRouter);
|
service.use('/api/jobs', jobRouter);
|
||||||
service.use('/api/login', loginRouter);
|
service.use('/api/login', loginRouter);
|
||||||
service.use('/api/listings', listingsRouter);
|
service.use('/api/listings', listingsRouter);
|
||||||
|
service.use('/api/features', featureRouter);
|
||||||
|
service.use('/api/dashboard', dashboardRouter);
|
||||||
//this route is unsecured intentionally as it is being queried from the login page
|
//this route is unsecured intentionally as it is being queried from the login page
|
||||||
service.use('/api/demo', demoRouter);
|
service.use('/api/demo', demoRouter);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import restana from 'restana';
|
|
||||||
import * as listingStorage from '../../services/storage/listingsStorage.js';
|
|
||||||
const service = restana();
|
|
||||||
const analyticsRouter = service.newRouter();
|
|
||||||
analyticsRouter.get('/:jobId', async (req, res) => {
|
|
||||||
const { jobId } = req.params;
|
|
||||||
res.body = listingStorage.getListingProviderDataForAnalytics(jobId) || {};
|
|
||||||
res.send();
|
|
||||||
});
|
|
||||||
export { analyticsRouter };
|
|
||||||
75
lib/api/routes/backupRouter.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import restana from 'restana';
|
||||||
|
import {
|
||||||
|
buildBackupFileName,
|
||||||
|
createBackupZip,
|
||||||
|
precheckRestore,
|
||||||
|
restoreFromZip,
|
||||||
|
} from '../../services/storage/backupRestoreService.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backup & Restore Admin Router
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* - GET /api/admin/backup
|
||||||
|
* Returns the current database as a zip download. Content-Type: application/zip
|
||||||
|
* - POST /api/admin/backup/restore?dryRun=true
|
||||||
|
* Accepts a zip file (raw body). Returns a compatibility report, does not restore.
|
||||||
|
* - POST /api/admin/backup/restore?force=true|false
|
||||||
|
* Accepts a zip file (raw body). Restores the database; when incompatible and force=false, returns 400.
|
||||||
|
*/
|
||||||
|
const service = restana();
|
||||||
|
const backupRouter = service.newRouter();
|
||||||
|
|
||||||
|
backupRouter.get('/', async (req, res) => {
|
||||||
|
const zipBuffer = await createBackupZip();
|
||||||
|
const fileName = await buildBackupFileName();
|
||||||
|
res.setHeader('Content-Type', 'application/zip');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
|
||||||
|
res.send(zipBuffer);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the full request body as a Buffer. Used for raw zip uploads.
|
||||||
|
* @param {import('http').IncomingMessage} req
|
||||||
|
* @returns {Promise<Buffer>}
|
||||||
|
*/
|
||||||
|
function readBody(req) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks = [];
|
||||||
|
req.on('data', (c) => chunks.push(c));
|
||||||
|
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
req.on('error', (e) => reject(e));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 };
|
||||||
71
lib/api/routes/dashboardRouter.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import restana from 'restana';
|
||||||
|
import * as jobStorage from '../../services/storage/jobStorage.js';
|
||||||
|
import * as userStorage from '../../services/storage/userStorage.js';
|
||||||
|
import { getListingsKpisForJobIds, getProviderDistributionForJobIds } from '../../services/storage/listingsStorage.js';
|
||||||
|
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||||
|
|
||||||
|
const service = restana();
|
||||||
|
export const dashboardRouter = service.newRouter();
|
||||||
|
|
||||||
|
function isAdmin(req) {
|
||||||
|
const user = req.session?.currentUser ? userStorage.getUser(req.session.currentUser) : null;
|
||||||
|
return !!user?.isAdmin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAccessibleJobs(req) {
|
||||||
|
const currentUser = req.session.currentUser;
|
||||||
|
const admin = isAdmin(req);
|
||||||
|
return jobStorage
|
||||||
|
.getJobs()
|
||||||
|
.filter((job) => admin || job.userId === currentUser || job.shared_with_user.includes(currentUser));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cap(val) {
|
||||||
|
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
dashboardRouter.get('/', async (req, res) => {
|
||||||
|
const jobs = getAccessibleJobs(req);
|
||||||
|
const settings = await getSettings();
|
||||||
|
|
||||||
|
// KPIs
|
||||||
|
const totalJobs = jobs.length;
|
||||||
|
const totalListings = jobs.reduce((sum, j) => sum + (j.numberOfFoundListings || 0), 0);
|
||||||
|
const jobIds = jobs.map((j) => j.id);
|
||||||
|
const { numberOfActiveListings, avgPriceOfListings } = getListingsKpisForJobIds(jobIds);
|
||||||
|
// Build Pie data in a simple shape the frontend can consume directly
|
||||||
|
// Shape: { labels: string[], values: number[] } with values as percentages
|
||||||
|
const providerPieRaw = getProviderDistributionForJobIds(jobIds);
|
||||||
|
const providerPie = Array.isArray(providerPieRaw)
|
||||||
|
? {
|
||||||
|
labels: providerPieRaw.map((p) => cap(p.type)),
|
||||||
|
values: providerPieRaw.map((p) => Number(p.value) || 0),
|
||||||
|
}
|
||||||
|
: providerPieRaw && typeof providerPieRaw === 'object'
|
||||||
|
? {
|
||||||
|
labels: Array.isArray(providerPieRaw.labels) ? providerPieRaw.labels : [],
|
||||||
|
values: Array.isArray(providerPieRaw.values) ? providerPieRaw.values : [],
|
||||||
|
}
|
||||||
|
: { labels: [], values: [] };
|
||||||
|
|
||||||
|
res.body = {
|
||||||
|
general: {
|
||||||
|
interval: settings.interval,
|
||||||
|
lastRun: settings.lastRun || null,
|
||||||
|
nextRun: settings.lastRun == null ? 0 : settings.lastRun + settings.interval * 60000,
|
||||||
|
},
|
||||||
|
kpis: {
|
||||||
|
totalJobs,
|
||||||
|
totalListings,
|
||||||
|
numberOfActiveListings,
|
||||||
|
avgPriceOfListings,
|
||||||
|
},
|
||||||
|
pie: providerPie,
|
||||||
|
};
|
||||||
|
res.send();
|
||||||
|
});
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import restana from 'restana';
|
import restana from 'restana';
|
||||||
import { config } from '../../utils.js';
|
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||||
const service = restana();
|
const service = restana();
|
||||||
const demoRouter = service.newRouter();
|
const demoRouter = service.newRouter();
|
||||||
|
|
||||||
demoRouter.get('/', async (req, res) => {
|
demoRouter.get('/', async (req, res) => {
|
||||||
res.body = Object.assign({}, { demoMode: config.demoMode });
|
const settings = await getSettings();
|
||||||
|
res.body = Object.assign({}, { demoMode: settings.demoMode });
|
||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
17
lib/api/routes/featureRouter.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import restana from 'restana';
|
||||||
|
import getFeatures from '../../features.js';
|
||||||
|
const service = restana();
|
||||||
|
const featureRouter = service.newRouter();
|
||||||
|
|
||||||
|
featureRouter.get('/', async (req, res) => {
|
||||||
|
const features = getFeatures();
|
||||||
|
res.body = Object.assign({}, { features });
|
||||||
|
res.send();
|
||||||
|
});
|
||||||
|
|
||||||
|
export { featureRouter };
|
||||||
@@ -1,24 +1,35 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import restana from 'restana';
|
import restana from 'restana';
|
||||||
import { config, getDirName, readConfigFromStorage, refreshConfig } 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';
|
||||||
const service = restana();
|
const service = restana();
|
||||||
const generalSettingsRouter = service.newRouter();
|
const generalSettingsRouter = service.newRouter();
|
||||||
|
|
||||||
generalSettingsRouter.get('/', async (req, res) => {
|
generalSettingsRouter.get('/', async (req, res) => {
|
||||||
res.body = Object.assign({}, config);
|
res.body = Object.assign({}, await getSettings());
|
||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
generalSettingsRouter.post('/', async (req, res) => {
|
generalSettingsRouter.post('/', async (req, res) => {
|
||||||
const settings = req.body;
|
const { sqlitepath, ...appSettings } = req.body || {};
|
||||||
|
const localSettings = await getSettings();
|
||||||
|
|
||||||
|
if (localSettings.demoMode) {
|
||||||
|
res.send(new Error('In demo mode, it is not allowed to change these settings.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (config.demoMode) {
|
if (typeof sqlitepath !== 'undefined') {
|
||||||
res.send(new Error('In demo mode, it is not allowed to change these settings.'));
|
fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ sqlitepath }));
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const currentConfig = await readConfigFromStorage();
|
upsertSettings(appSettings);
|
||||||
fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ ...currentConfig, ...settings }));
|
|
||||||
await refreshConfig();
|
|
||||||
ensureDemoUserExists();
|
ensureDemoUserExists();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(err);
|
logger.error(err);
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import restana from 'restana';
|
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 { config } from '../../utils.js';
|
|
||||||
import { isAdmin } from '../security.js';
|
import { isAdmin } from '../security.js';
|
||||||
import logger from '../../services/logger.js';
|
import logger from '../../services/logger.js';
|
||||||
import { bus } from '../../services/events/event-bus.js';
|
import { bus } from '../../services/events/event-bus.js';
|
||||||
|
import { isRunning as isJobRunning } from '../../services/jobs/run-state.js';
|
||||||
|
import { addClient as addSseClient, removeClient } from '../../services/sse/sse-broker.js';
|
||||||
|
|
||||||
const service = restana();
|
const service = restana();
|
||||||
const jobRouter = service.newRouter();
|
const jobRouter = service.newRouter();
|
||||||
@@ -24,25 +30,105 @@ function doesJobBelongsToUser(job, req) {
|
|||||||
jobRouter.get('/', async (req, res) => {
|
jobRouter.get('/', async (req, res) => {
|
||||||
const isUserAdmin = isAdmin(req);
|
const isUserAdmin = isAdmin(req);
|
||||||
//show only the jobs which belongs to the user (or all of the user is an admin)
|
//show only the jobs which belongs to the user (or all of the user is an admin)
|
||||||
res.body = jobStorage.getJobs().filter((job) => isUserAdmin || job.userId === req.session.currentUser);
|
res.body = jobStorage
|
||||||
|
.getJobs()
|
||||||
|
.filter(
|
||||||
|
(job) =>
|
||||||
|
isUserAdmin || job.userId === req.session.currentUser || job.shared_with_user.includes(req.session.currentUser),
|
||||||
|
)
|
||||||
|
.map((job) => {
|
||||||
|
return {
|
||||||
|
...job,
|
||||||
|
running: isJobRunning(job.id),
|
||||||
|
isOnlyShared:
|
||||||
|
!isUserAdmin &&
|
||||||
|
job.userId !== req.session.currentUser &&
|
||||||
|
job.shared_with_user.includes(req.session.currentUser),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
jobRouter.get('/processingTimes', async (req, res) => {
|
|
||||||
res.body = {
|
// Server-Sent Events for job status updates
|
||||||
interval: config.interval,
|
jobRouter.get('/events', async (req, res) => {
|
||||||
lastRun: config.lastRun || null,
|
const userId = req.session.currentUser;
|
||||||
};
|
if (userId == null) {
|
||||||
res.send();
|
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 {
|
||||||
|
res.end();
|
||||||
|
} catch {
|
||||||
|
//noop
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
jobRouter.post('/startAll', async (req, res) => {
|
jobRouter.post('/startAll', async (req, res) => {
|
||||||
bus.emit('jobs:runAll');
|
try {
|
||||||
res.send();
|
const userId = req.session.currentUser;
|
||||||
|
// Emit only the userId; handler will decide based on admin/ownership
|
||||||
|
bus.emit('jobs:runAll', { userId });
|
||||||
|
res.send({ message: 'Run all accepted' }, 202);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to trigger startAll', err);
|
||||||
|
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) => {
|
jobRouter.post('/', async (req, res) => {
|
||||||
const { provider, notificationAdapter, name, blacklist = [], jobId, enabled } = req.body;
|
const { provider, notificationAdapter, name, blacklist = [], jobId, enabled, shareWithUsers = [] } = req.body;
|
||||||
try {
|
try {
|
||||||
|
let jobFromDb = jobStorage.getJob(jobId);
|
||||||
|
|
||||||
|
if (jobFromDb && !doesJobBelongsToUser(jobFromDb, req)) {
|
||||||
|
res.send(new Error('You are trying to change a job that is not associated to your user.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
jobStorage.upsertJob({
|
jobStorage.upsertJob({
|
||||||
userId: req.session.currentUser,
|
userId: req.session.currentUser,
|
||||||
jobId,
|
jobId,
|
||||||
@@ -51,6 +137,7 @@ jobRouter.post('/', async (req, res) => {
|
|||||||
blacklist,
|
blacklist,
|
||||||
provider,
|
provider,
|
||||||
notificationAdapter,
|
notificationAdapter,
|
||||||
|
shareWithUsers,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.send(new Error(error));
|
res.send(new Error(error));
|
||||||
@@ -58,6 +145,7 @@ jobRouter.post('/', async (req, res) => {
|
|||||||
}
|
}
|
||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
|
|
||||||
jobRouter.delete('', async (req, res) => {
|
jobRouter.delete('', async (req, res) => {
|
||||||
const { jobId } = req.body;
|
const { jobId } = req.body;
|
||||||
try {
|
try {
|
||||||
@@ -92,4 +180,16 @@ jobRouter.put('/:jobId/status', async (req, res) => {
|
|||||||
}
|
}
|
||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
jobRouter.get('/shareableUserList', async (req, res) => {
|
||||||
|
const currentUser = req.session.currentUser;
|
||||||
|
const users = userStorage.getUsers(false);
|
||||||
|
res.body = users
|
||||||
|
.filter((user) => !user.isAdmin && user.id !== currentUser)
|
||||||
|
.map((user) => ({
|
||||||
|
id: user.id,
|
||||||
|
name: user.username,
|
||||||
|
}));
|
||||||
|
res.send();
|
||||||
|
});
|
||||||
export { jobRouter };
|
export { jobRouter };
|
||||||
|
|||||||
@@ -1,23 +1,105 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import restana from 'restana';
|
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 { isAdmin as isAdminFn } from '../security.js';
|
import { isAdmin as isAdminFn } from '../security.js';
|
||||||
|
import logger from '../../services/logger.js';
|
||||||
|
import { nullOrEmpty } from '../../utils.js';
|
||||||
|
import { getJobs } from '../../services/storage/jobStorage.js';
|
||||||
|
|
||||||
const service = restana();
|
const service = restana();
|
||||||
|
|
||||||
const listingsRouter = service.newRouter();
|
const listingsRouter = service.newRouter();
|
||||||
|
|
||||||
listingsRouter.get('/table', async (req, res) => {
|
listingsRouter.get('/table', async (req, res) => {
|
||||||
const { page, pageSize = 50, filter, sortfield = null, sortdir = 'asc' } = req.query || {};
|
const {
|
||||||
|
page,
|
||||||
|
pageSize = 50,
|
||||||
|
activityFilter,
|
||||||
|
jobNameFilter,
|
||||||
|
providerFilter,
|
||||||
|
watchListFilter,
|
||||||
|
sortfield = null,
|
||||||
|
sortdir = 'asc',
|
||||||
|
freeTextFilter,
|
||||||
|
} = req.query || {};
|
||||||
|
|
||||||
const result = listingStorage.queryListings({
|
// normalize booleans (accept true, 'true', 1, '1')
|
||||||
|
const toBool = (v) => v === true || v === 'true' || v === 1 || v === '1';
|
||||||
|
const normalizedActivity = toBool(activityFilter) ? true : null;
|
||||||
|
const normalizedWatch = toBool(watchListFilter) ? true : null;
|
||||||
|
|
||||||
|
let jobFilter = null;
|
||||||
|
let jobIdFilter = null;
|
||||||
|
const jobs = getJobs();
|
||||||
|
if (!nullOrEmpty(jobNameFilter)) {
|
||||||
|
const job = jobs.find((j) => j.id === jobNameFilter);
|
||||||
|
jobFilter = job != null ? job.name : null;
|
||||||
|
jobIdFilter = job != null ? job.id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.body = listingStorage.queryListings({
|
||||||
page: page ? parseInt(page, 10) : 1,
|
page: page ? parseInt(page, 10) : 1,
|
||||||
pageSize: pageSize ? parseInt(pageSize, 10) : 50,
|
pageSize: pageSize ? parseInt(pageSize, 10) : 50,
|
||||||
filter: filter || undefined,
|
freeTextFilter: freeTextFilter || null,
|
||||||
|
activityFilter: normalizedActivity,
|
||||||
|
jobNameFilter: jobFilter,
|
||||||
|
jobIdFilter: jobIdFilter,
|
||||||
|
providerFilter,
|
||||||
|
watchListFilter: normalizedWatch,
|
||||||
sortField: sortfield || null,
|
sortField: sortfield || null,
|
||||||
sortDir: sortdir === 'desc' ? 'desc' : 'asc',
|
sortDir: sortdir === 'desc' ? 'desc' : 'asc',
|
||||||
userId: req.session.currentUser,
|
userId: req.session.currentUser,
|
||||||
isAdmin: isAdminFn(req),
|
isAdmin: isAdminFn(req),
|
||||||
});
|
});
|
||||||
res.body = result;
|
|
||||||
res.send();
|
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);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.body = { message: 'Failed to toggle watch' };
|
||||||
|
}
|
||||||
|
res.send();
|
||||||
|
});
|
||||||
|
|
||||||
|
listingsRouter.delete('/job', async (req, res) => {
|
||||||
|
const { jobId } = req.body;
|
||||||
|
try {
|
||||||
|
listingStorage.deleteListingsByJobId(jobId);
|
||||||
|
} catch (error) {
|
||||||
|
res.send(new Error(error));
|
||||||
|
logger.error(error);
|
||||||
|
}
|
||||||
|
res.send();
|
||||||
|
});
|
||||||
|
|
||||||
|
listingsRouter.delete('/', async (req, res) => {
|
||||||
|
const { ids } = req.body;
|
||||||
|
try {
|
||||||
|
if (Array.isArray(ids) && ids.length > 0) {
|
||||||
|
listingStorage.deleteListingsById(ids);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
res.send(new Error(error));
|
||||||
|
logger.error(error);
|
||||||
|
}
|
||||||
|
res.send();
|
||||||
|
});
|
||||||
|
|
||||||
export { listingsRouter };
|
export { listingsRouter };
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import restana from 'restana';
|
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 { config } from '../../utils.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';
|
||||||
const service = restana();
|
const service = restana();
|
||||||
const loginRouter = service.newRouter();
|
const loginRouter = service.newRouter();
|
||||||
loginRouter.get('/user', async (req, res) => {
|
loginRouter.get('/user', async (req, res) => {
|
||||||
@@ -20,6 +25,7 @@ loginRouter.get('/user', async (req, res) => {
|
|||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
loginRouter.post('/', async (req, res) => {
|
loginRouter.post('/', async (req, res) => {
|
||||||
|
const settings = await getSettings();
|
||||||
const { username, password } = req.body;
|
const { username, password } = req.body;
|
||||||
const user = userStorage.getUsers(true).find((user) => user.username === username);
|
const user = userStorage.getUsers(true).find((user) => user.username === username);
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
@@ -27,7 +33,7 @@ loginRouter.post('/', async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (user.password === hasher.hash(password)) {
|
if (user.password === hasher.hash(password)) {
|
||||||
if (config.demoMode) {
|
if (settings.demoMode) {
|
||||||
await trackDemoAccessed();
|
await trackDemoAccessed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import restana from 'restana';
|
import restana from 'restana';
|
||||||
const service = restana();
|
const service = restana();
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import restana from 'restana';
|
import restana from 'restana';
|
||||||
const service = restana();
|
const service = restana();
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import restana from 'restana';
|
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 { config } from '../../utils.js';
|
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||||
const service = restana();
|
const service = restana();
|
||||||
const userRouter = service.newRouter();
|
const userRouter = service.newRouter();
|
||||||
function checkIfAnyAdminAfterRemovingUser(userIdToBeRemoved, allUser) {
|
function checkIfAnyAdminAfterRemovingUser(userIdToBeRemoved, allUser) {
|
||||||
@@ -11,17 +16,20 @@ function checkIfUserToBeRemovedIsLoggedIn(userIdToBeRemoved, req) {
|
|||||||
return req.session.currentUser === userIdToBeRemoved;
|
return req.session.currentUser === userIdToBeRemoved;
|
||||||
}
|
}
|
||||||
const nullOrEmpty = (str) => str == null || str.length === 0;
|
const nullOrEmpty = (str) => str == null || str.length === 0;
|
||||||
|
|
||||||
userRouter.get('/', async (req, res) => {
|
userRouter.get('/', async (req, res) => {
|
||||||
res.body = userStorage.getUsers(false);
|
res.body = userStorage.getUsers(false);
|
||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
|
|
||||||
userRouter.get('/:userId', async (req, res) => {
|
userRouter.get('/:userId', async (req, res) => {
|
||||||
const { userId } = req.params;
|
const { userId } = req.params;
|
||||||
res.body = userStorage.getUser(userId);
|
res.body = userStorage.getUser(userId);
|
||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
userRouter.delete('/', async (req, res) => {
|
userRouter.delete('/', async (req, res) => {
|
||||||
if (config.demoMode) {
|
const settings = await getSettings();
|
||||||
|
if (settings.demoMode) {
|
||||||
res.send(new Error('In demo mode, it is not allowed to remove user.'));
|
res.send(new Error('In demo mode, it is not allowed to remove user.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -42,7 +50,8 @@ userRouter.delete('/', async (req, res) => {
|
|||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
userRouter.post('/', async (req, res) => {
|
userRouter.post('/', async (req, res) => {
|
||||||
if (config.demoMode) {
|
const settings = await getSettings();
|
||||||
|
if (settings.demoMode) {
|
||||||
res.send(new Error('In demo mode, it is not allowed to change or add user.'));
|
res.send(new Error('In demo mode, it is not allowed to change or add user.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,26 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import restana from 'restana';
|
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';
|
||||||
|
|
||||||
const service = restana();
|
const service = restana();
|
||||||
const versionRouter = service.newRouter();
|
const versionRouter = service.newRouter();
|
||||||
|
|
||||||
versionRouter.get('/', async (req, res) => {
|
versionRouter.get('/', async (req, res) => {
|
||||||
const versionPayload = await getCurrentVersionFromGithub();
|
const versionPayload = await getCurrentVersionFromGithub();
|
||||||
res.body = versionPayload == null ? { newVersion: false } : versionPayload;
|
const localFredyVersion = await getPackageVersion();
|
||||||
|
res.body =
|
||||||
|
versionPayload == null
|
||||||
|
? {
|
||||||
|
newVersion: false,
|
||||||
|
localFredyVersion,
|
||||||
|
}
|
||||||
|
: versionPayload;
|
||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -15,7 +28,7 @@ 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();
|
||||||
const localFredyVersion = await getPackageVersion();
|
const localFredyVersion = await getPackageVersion();
|
||||||
if (localFredyVersion === data.tag_name) {
|
if (data.tag_name == null || semver.gte(localFredyVersion, data.tag_name)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import * as userStorage from '../services/storage/userStorage.js';
|
import * as userStorage from '../services/storage/userStorage.js';
|
||||||
import cookieSession from 'cookie-session';
|
import cookieSession from 'cookie-session';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
export const DEFAULT_CONFIG = {
|
export const DEFAULT_CONFIG = {
|
||||||
interval: '60',
|
|
||||||
port: 9998,
|
|
||||||
workingHours: { from: '', to: '' },
|
|
||||||
demoMode: false,
|
|
||||||
analyticsEnabled: null,
|
|
||||||
// Default path for sqlite storage directory. Interpreted relative to project root.
|
// Default path for sqlite storage directory. Interpreted relative to project root.
|
||||||
sqlitepath: '/db',
|
sqlitepath: '/db',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
class ExtendableError extends Error {
|
class ExtendableError extends Error {
|
||||||
constructor(message) {
|
constructor(message) {
|
||||||
super(message);
|
super(message);
|
||||||
|
|||||||
14
lib/features.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FEATURES = {
|
||||||
|
WATCHLIST_MANAGEMENT: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function getFeatures() {
|
||||||
|
return {
|
||||||
|
...FEATURES,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
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';
|
||||||
@@ -8,7 +13,7 @@ export const send = ({ serviceName, newListings, notificationConfig, 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}\nink: ${newListing.link}`;
|
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}`;
|
||||||
return fetch(server, {
|
return fetch(server, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
### Apprise Adapter
|
### Apprise Adapter
|
||||||
|
|
||||||
Refer to the [instructions](https://github.com/caronc/apprise-api#installation) on how to set up an Apprise instance and how to configure your preferred notification service.
|
Use [Apprise](https://github.com/caronc/apprise-api#installation) to forward notifications to many different services.
|
||||||
|
|
||||||
|
Quick start:
|
||||||
|
- Set up an Apprise API instance (see the installation guide linked above).
|
||||||
|
- Configure your preferred notification service(s) within Apprise.
|
||||||
|
- In Fredy, point the Apprise adapter to your Apprise API endpoint.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import { markdown2Html } from '../../services/markdown.js';
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
|
|
||||||
export const send = ({ serviceName, newListings, jobKey }) => {
|
export const send = ({ serviceName, newListings, jobKey }) => {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
### Console Adapter
|
### Console Adapter
|
||||||
|
|
||||||
The console adapter prints everything found by Fredy into the console (not sending any notifications to you). This can be useful when you want to check if your search
|
The console adapter prints everything found by Fredy to the console (it does not send notifications). This is useful to verify that your search criteria work as expected before enabling a real notification service.
|
||||||
criteria meet the expectations.
|
|
||||||
|
|||||||
135
lib/notification/adapter/discord_webhook.js
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fetch from 'node-fetch';
|
||||||
|
import { getJob } from '../../services/storage/jobStorage.js';
|
||||||
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
|
import { normalizeImageUrl } from '../../utils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an idempotent decimal color code. The input string-based color code is
|
||||||
|
* generated using the djb2 hash algorithm.
|
||||||
|
*
|
||||||
|
* @param {string} str - Input string as color code base
|
||||||
|
* @returns {number} Generated decimal color code (0 - 16777215)
|
||||||
|
*/
|
||||||
|
const generateColorFromString = (str) => {
|
||||||
|
let hash = 5381; // initial value
|
||||||
|
const input = String(str);
|
||||||
|
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
// hash * 33 + charCode
|
||||||
|
hash = (hash << 5) + hash + input.charCodeAt(i);
|
||||||
|
// Ensure the hash is 32 bit
|
||||||
|
hash |= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let positiveHash = hash >>> 0;
|
||||||
|
const maxColorValue = 16777215;
|
||||||
|
const colorDecimal = positiveHash % maxColorValue;
|
||||||
|
|
||||||
|
return colorDecimal;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an embed per listing
|
||||||
|
* (-> see https://birdie0.github.io/discord-webhooks-guide/structure/embeds.html).
|
||||||
|
*
|
||||||
|
* @param {string} jobKey - Key of job (used to set embed color)
|
||||||
|
* @param {object} listing - Object holding listing details
|
||||||
|
* @returns {object} Discord webhook embed
|
||||||
|
*/
|
||||||
|
const buildEmbed = (jobKey, listing) => {
|
||||||
|
const maxTitleLength = 252; // Max embed title length is 256 characters
|
||||||
|
let title = String(listing.title ?? 'N/A');
|
||||||
|
if (title.length > maxTitleLength) {
|
||||||
|
title = title.substring(0, maxTitleLength) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = [
|
||||||
|
{
|
||||||
|
name: 'Price',
|
||||||
|
value: String(listing.price ?? 'n/a'),
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Size',
|
||||||
|
value: listing?.size?.replace(/2m/g, 'm²') ?? 'n/a',
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Address',
|
||||||
|
value: String(listing.address ?? 'n/a'),
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const embed = {
|
||||||
|
title: title,
|
||||||
|
color: generateColorFromString(jobKey),
|
||||||
|
url: listing.link,
|
||||||
|
fields: fields,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (listing.image) {
|
||||||
|
embed.image = {
|
||||||
|
url: normalizeImageUrl(listing.image),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
|
const adapter = notificationConfig.find((adapter) => adapter.id === config.id);
|
||||||
|
const webhookUrl = adapter?.fields?.webhookUrl;
|
||||||
|
if (!webhookUrl || newListings.length === 0) return Promise.resolve([]);
|
||||||
|
|
||||||
|
const job = getJob(jobKey);
|
||||||
|
const jobName = job?.name || jobKey;
|
||||||
|
|
||||||
|
const embeds = newListings.map((listing) => buildEmbed(jobKey, listing));
|
||||||
|
|
||||||
|
const maxEmbedsPerMessage = 10; // Discord only allows up to 10 embeds
|
||||||
|
const webhookPromises = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < embeds.length; i += maxEmbedsPerMessage) {
|
||||||
|
// Send multiple Discord messages with up to 10 embeds per message
|
||||||
|
const embedChunk = embeds.slice(i, i + maxEmbedsPerMessage);
|
||||||
|
|
||||||
|
const content = i === 0 ? `*${jobName}:* ${serviceName} found **${newListings.length}** new listings.` : '';
|
||||||
|
const body = JSON.stringify({
|
||||||
|
content: content,
|
||||||
|
embeds: embedChunk,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchPromise = fetch(webhookUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error(`Error sending Discord webhook for chunk starting at ${i}:`, error);
|
||||||
|
return Promise.reject(new Error(`Webhook failed: ${error.message}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
webhookPromises.push(fetchPromise);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.allSettled(webhookPromises);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
id: 'discord_webhook',
|
||||||
|
name: 'Discord Webhook',
|
||||||
|
readme: markdown2Html('lib/notification/adapter/discord_webhook.md'),
|
||||||
|
description: 'Fredy will send new listings to the Discord channel of your choice.',
|
||||||
|
fields: {
|
||||||
|
webhookUrl: {
|
||||||
|
type: 'text',
|
||||||
|
label: 'Webhook URL',
|
||||||
|
description: 'The URL of the Discord webhook to send messages to.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
8
lib/notification/adapter/discord_webhook.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
### Discord Webhook Adapter
|
||||||
|
|
||||||
|
Use a Discord channel webhook to receive notifications.
|
||||||
|
|
||||||
|
Quick start:
|
||||||
|
- Create a webhook in your target Discord channel. See the "Intro to Webhooks" guide on the Discord support site: https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks
|
||||||
|
- Copy the generated webhook URL.
|
||||||
|
- In Fredy, configure the Discord adapter with this webhook URL.
|
||||||
62
lib/notification/adapter/http.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
|
|
||||||
|
const mapListing = (listing) => ({
|
||||||
|
address: listing.address,
|
||||||
|
description: listing.description,
|
||||||
|
id: listing.id,
|
||||||
|
imageUrl: listing.image,
|
||||||
|
price: listing.price,
|
||||||
|
size: listing.size,
|
||||||
|
title: listing.title,
|
||||||
|
url: listing.link,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
|
const { authToken, endpointUrl } = notificationConfig.find((a) => a.id === config.id).fields;
|
||||||
|
|
||||||
|
const listings = newListings.map(mapListing);
|
||||||
|
const body = {
|
||||||
|
jobId: jobKey,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
provider: serviceName,
|
||||||
|
listings,
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
if (authToken != null) {
|
||||||
|
headers['Authorization'] = `Bearer ${authToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(endpointUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
id: 'http',
|
||||||
|
name: 'HTTP',
|
||||||
|
readme: markdown2Html('lib/notification/adapter/http.md'),
|
||||||
|
description: 'Fredy will send a generic HTTP POST request.',
|
||||||
|
fields: {
|
||||||
|
endpointUrl: {
|
||||||
|
description: "Your application's endpoint URL.",
|
||||||
|
label: 'Endpoint URL',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
authToken: {
|
||||||
|
description: "Your application's auth token, if required by your endpoint.",
|
||||||
|
label: 'Auth token (optional)',
|
||||||
|
optional: true,
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
43
lib/notification/adapter/http.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
### HTTP Adapter
|
||||||
|
|
||||||
|
This is a generic adapter for sending notifications via HTTP requests.
|
||||||
|
You can leverage this adapter to integrate with various webhooks or APIs that accept HTTP requests. (e.g. Supabase
|
||||||
|
Functions, a Node.js server, etc.)
|
||||||
|
|
||||||
|
HTTP adapter supports a `authToken` field, which can be used to include an authorization token in the request headers.
|
||||||
|
Your token would be included as a Bearer token in the `Authorization` header, which is a common method for securing API requests.
|
||||||
|
|
||||||
|
Request Details:
|
||||||
|
<details>
|
||||||
|
Request Method: POST
|
||||||
|
|
||||||
|
Headers:
|
||||||
|
|
||||||
|
```
|
||||||
|
Content Type: `application/json`
|
||||||
|
Authorization: Bearer {your-optional-auth-token}
|
||||||
|
```
|
||||||
|
|
||||||
|
Body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jobId": "mg1waX4RHmIzL5NDYtYp-",
|
||||||
|
"provider": "immoscout",
|
||||||
|
"timestamp": "2024-06-15T12:34:56Z",
|
||||||
|
"listings": [
|
||||||
|
{
|
||||||
|
"address": "Str. 123, Bielefeld, Germany",
|
||||||
|
"description": "Möbliert: Einziehen & wohlfühlen: Neu möbliert.",
|
||||||
|
"id": "123456789",
|
||||||
|
"imageUrl": "https://<target-url>.com/listings/123456789.jpg",
|
||||||
|
"price": "1.240 €",
|
||||||
|
"size": "38 m²",
|
||||||
|
"title": "Schöne 1-Zimmer-Wohnung in Bielefeld",
|
||||||
|
"url": "https://<target-url>.com/listings/123456789"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import mailjet from 'node-mailjet';
|
import mailjet from 'node-mailjet';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
### MailJet Adapter
|
### Mailjet Adapter
|
||||||
|
|
||||||
To use [MailJet](https://mailjet.com), you need to create an account. You'll need to decide from which email address you want Fredy to send from.
|
To use [Mailjet](https://mailjet.com), create an account and decide which email address Fredy should send from.
|
||||||
|
|
||||||
E.g. if you use yourGmailAccount@gmail.com, you have to add this to MailJet and verify it as well.
|
For example, if you use yourGmailAccount@gmail.com, add and verify this address in Mailjet.
|
||||||
The given public/private api keys are needed in order to use MailJet with Fredy. Fredy will use the same template, it is using for SendGrid.
|
Provide your public/private API keys in Fredy's configuration. Fredy uses the same email template as for SendGrid.
|
||||||
|
|
||||||
If this email should be sent to multiple receiver, use a comma separator (some@email.com, someOther@email.com).
|
To send to multiple recipients, separate email addresses with commas (e.g., some@email.com, someOther@email.com).
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
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';
|
||||||
@@ -13,10 +18,10 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
|||||||
return fetch(webhook, {
|
return fetch(webhook, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: {
|
body: JSON.stringify({
|
||||||
channel: channel,
|
channel: channel,
|
||||||
text: message,
|
text: message,
|
||||||
},
|
}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
export const config = {
|
export const config = {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
### Mattermost Adapter
|
### Mattermost Adapter
|
||||||
|
|
||||||
For Mattermost, you need to create a incoming webhook. This is pretty easy. Please visit the steps in the [developer docs](https://docs.mattermost.com/developer/webhooks-incoming.html) and follow the instructions.
|
Receive notifications in Mattermost via an incoming webhook.
|
||||||
|
|
||||||
As a result, you get the webhook URL for configuration in fredy. In addition, the target channel must be defined.
|
Quick start:
|
||||||
|
- Create an incoming webhook following the Mattermost developer docs: https://docs.mattermost.com/developer/webhooks-incoming.html
|
||||||
|
- Copy the webhook URL.
|
||||||
|
- In Fredy, configure the Mattermost adapter with this URL and the target channel.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
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';
|
||||||
@@ -15,11 +20,17 @@ 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}`;
|
||||||
|
|
||||||
|
const sanitizeHeaderValue = (value) =>
|
||||||
|
String(value ?? '')
|
||||||
|
.replace(/[\r\n]+/g, ' ')
|
||||||
|
.replace(/[^\x20-\x7E]/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
Title: newListing.title,
|
Title: sanitizeHeaderValue(newListing.title),
|
||||||
Priority: String(priority),
|
Priority: sanitizeHeaderValue(priority),
|
||||||
Tags: `${serviceName},${jobName}`,
|
Tags: sanitizeHeaderValue(`${serviceName},${jobName}`),
|
||||||
Click: newListing.link,
|
Click: sanitizeHeaderValue(newListing.link),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (newListing.image && typeof newListing.image === 'string') {
|
if (newListing.image && typeof newListing.image === 'string') {
|
||||||
@@ -30,7 +41,17 @@ Link: ${newListing.link}`;
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body: message,
|
body: message,
|
||||||
});
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Ntfy message could not be sent. Status code: ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.text();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
// Ensure we reject with an Error object and prevent unhandled rejections
|
||||||
|
throw error instanceof Error ? error : new Error(String(error));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return Promise.all(promises);
|
return Promise.all(promises);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
### ntfy Adapter
|
### ntfy Adapter
|
||||||
|
|
||||||
For ntfy, you need to create a topic on your preferred ntfy instance. This is pretty easy. Please visit the steps in the [docs](https://docs.ntfy.sh/publish/) and follow the instructions.
|
Send push notifications using an ntfy topic.
|
||||||
|
|
||||||
As a result, you get the URL for configuration in fredy. In addition, the priority must be defined.
|
Quick start:
|
||||||
|
- Create or choose a topic on your preferred ntfy instance (see docs: https://docs.ntfy.sh/publish/).
|
||||||
|
- Copy the publish URL for that topic.
|
||||||
|
- In Fredy, configure the ntfy adapter with the topic URL and set a priority.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
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';
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
### Pushover Adapter
|
### Pushover Adapter
|
||||||
|
|
||||||
Refer to the [instructions](https://support.pushover.net/i7-what-is-pushover-and-how-do-i-use-it) to set up your Pushover application.
|
Use Pushover to receive push notifications on your devices.
|
||||||
|
|
||||||
After setting up the application, please enter both your newly created User key and API token.
|
Setup:
|
||||||
|
- Follow Pushover's getting-started guide: https://support.pushover.net/i7-what-is-pushover-and-how-do-i-use-it
|
||||||
|
- Create an application and obtain your User Key and API Token.
|
||||||
|
- In Fredy, configure the Pushover adapter with both values.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import sgMail from '@sendgrid/mail';
|
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';
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
### SendGrid Adapter
|
### SendGrid Adapter
|
||||||
|
|
||||||
SendGrid is a free email service (free as in "you cannot send more than 100(Sendgrid) and 200(Mailjet) emails a day"), which is more than enough for Fredy.
|
SendGrid is an email delivery service with a generous free tier, which is more than enough for Fredy.
|
||||||
|
|
||||||
To use [SendGrid](https://sendgrid.com/), you need to create an account. You'll need to decided from which email address you want Fredy to send from. E.g. if you use yourGmailAccount@gmail.com, you have to add this to sendgrid and verify it as well.
|
Setup:
|
||||||
|
- Create a SendGrid account: https://sendgrid.com/
|
||||||
|
- Decide which email address Fredy should send from (e.g., yourGmailAccount@gmail.com), add it to SendGrid, and complete the verification.
|
||||||
|
- Create an API key and add it to Fredy's configuration.
|
||||||
|
- Create a Dynamic Template in SendGrid. You can copy the template from `/lib/notification/emailTemplate/template.hbs`.
|
||||||
|
|
||||||
Lastly you have to create an api-key and feed it into Fredy's config, as well as creating a new dynamic template. For this new template, I recommend copying and pasting the code from the one I have provided under `/lib/notification/emailTemplate/template.hbs`.
|
Sending to multiple recipients:
|
||||||
|
- Separate email addresses with commas (e.g., some@email.com, someOther@email.com).
|
||||||
If this email should be sent to multiple receiver use a comma separator (some@email.com, someOther@email.com).
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import Slack from 'slack';
|
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';
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
### Slack Adapter
|
### Slack Adapter (Legacy)
|
||||||
IMPORTANT:
|
|
||||||
Don't use this adapter anymore, it is outdated and only here for backwards compatability reasons. Use the new Slack Adapter with webhooks!
|
*IMPORTANT:*
|
||||||
|
This legacy adapter is outdated and kept only for backward compatibility. Please use the Slack adapter with webhooks instead.
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import fetch from 'node-fetch';
|
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';
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
### Slack Adapter
|
### Slack Adapter (Webhooks)
|
||||||
|
|
||||||
IMPORTANT:
|
*IMPORTANT:*
|
||||||
This is the new version of the Slack adapter. I strongly encourage you to use it, the old version is now unmaintained and only kept due to backwards compatability reasons.
|
This is the recommended Slack adapter. The old Slack adapter is unmaintained and kept only for backward compatibility.
|
||||||
|
|
||||||
In order to use [Slack](https://slack.com), you need to create an account. When done, create a new channel and add the Webhook integration to that channel. Copy the webhook url. That's it.
|
Setup:
|
||||||
|
- Create a Slack account and workspace if you don't have one: https://slack.com
|
||||||
|
- Create a channel where you want to receive notifications.
|
||||||
|
- Add the Incoming Webhooks integration to that channel and copy the Webhook URL.
|
||||||
|
- In Fredy, configure the Slack Webhook adapter with this URL.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import { markdown2Html } from '../../services/markdown.js';
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
### SQLite Adapter
|
### SQLite Adapter
|
||||||
|
|
||||||
This adapter stores search results in an SQLite database. By default, the database is located at `db/listings.db`, but you can configure a custom location. This file can be used for further analysis later.
|
This adapter stores search results in an SQLite database. By default, the database is located at `db/listings.db`, but you can configure a custom location. The file can be used for analysis later.
|
||||||
|
|
||||||
The database table contains the following columns (all stored as `TEXT` type):
|
The table contains the following columns (all stored as `TEXT`):
|
||||||
|
|
||||||
```
|
```json
|
||||||
['serviceName', 'jobKey', 'id', 'size', 'rooms', 'price', 'address', 'title', 'link', 'description', 'image']
|
[
|
||||||
|
"serviceName",
|
||||||
|
"jobKey",
|
||||||
|
"id",
|
||||||
|
"size",
|
||||||
|
"rooms",
|
||||||
|
"price",
|
||||||
|
"address",
|
||||||
|
"title",
|
||||||
|
"link",
|
||||||
|
"description",
|
||||||
|
"image"
|
||||||
|
]
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
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';
|
||||||
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';
|
||||||
|
|
||||||
const RATE_LIMIT_INTERVAL = 1000;
|
const RATE_LIMIT_INTERVAL = 1000;
|
||||||
const chatThrottleMap = new Map();
|
const chatThrottleMap = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes stale throttled call entries to keep memory bounded.
|
||||||
|
*/
|
||||||
function cleanupOldThrottles() {
|
function cleanupOldThrottles() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const maxAge = RATE_LIMIT_INTERVAL + 1000;
|
const maxAge = RATE_LIMIT_INTERVAL + 1000;
|
||||||
@@ -17,6 +26,15 @@ function cleanupOldThrottles() {
|
|||||||
for (const chatId of toBeDeleted) chatThrottleMap.delete(chatId);
|
for (const chatId of toBeDeleted) chatThrottleMap.delete(chatId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a throttled wrapper for a chatId to limit Telegram API calls.
|
||||||
|
* Uses p-throttle with 1 request per RATE_LIMIT_INTERVAL per chat.
|
||||||
|
*
|
||||||
|
* @template {Function} T
|
||||||
|
* @param {string|number} chatId
|
||||||
|
* @param {T} call - async function (endpoint: string, body: any) => Promise<Response>
|
||||||
|
* @returns {T}
|
||||||
|
*/
|
||||||
function getThrottled(chatId, call) {
|
function getThrottled(chatId, call) {
|
||||||
cleanupOldThrottles();
|
cleanupOldThrottles();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -30,15 +48,38 @@ function getThrottled(chatId, call) {
|
|||||||
return throttled;
|
return throttled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shorten a string to a maximum length with an ellipsis suffix.
|
||||||
|
* @param {string} str
|
||||||
|
* @param {number} [len=90]
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
function shorten(str, len = 90) {
|
function shorten(str, len = 90) {
|
||||||
if (!str) return '';
|
if (!str) return '';
|
||||||
return str.length > len ? str.substring(0, len).trim() + '...' : str;
|
return str.length > len ? str.substring(0, len).trim() + '...' : str;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape basic HTML entities for Telegram HTML parse mode.
|
||||||
|
* @param {string} [s='']
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
function escapeHtml(s = '') {
|
function escapeHtml(s = '') {
|
||||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Telegram photo caption (max 1024 characters) using HTML parse mode.
|
||||||
|
* @param {string} jobName
|
||||||
|
* @param {string} serviceName
|
||||||
|
* @param {Object} o - Listing object
|
||||||
|
* @param {string} [o.title]
|
||||||
|
* @param {string} [o.address]
|
||||||
|
* @param {string|number} [o.price]
|
||||||
|
* @param {string|number} [o.size]
|
||||||
|
* @param {string} [o.link]
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
function buildCaption(jobName, serviceName, o) {
|
function buildCaption(jobName, serviceName, o) {
|
||||||
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(' | ');
|
||||||
@@ -47,6 +88,13 @@ function buildCaption(jobName, serviceName, o) {
|
|||||||
)}'><b>${escapeHtml(title)}</b></a>\n${escapeHtml(meta)}`.slice(0, 1024);
|
)}'><b>${escapeHtml(title)}</b></a>\n${escapeHtml(meta)}`.slice(0, 1024);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Telegram message text using HTML parse mode.
|
||||||
|
* @param {string} jobName
|
||||||
|
* @param {string} serviceName
|
||||||
|
* @param {Object} o - Listing object
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
function buildText(jobName, serviceName, o) {
|
function buildText(jobName, serviceName, o) {
|
||||||
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(' | ');
|
||||||
@@ -57,8 +105,41 @@ function buildText(jobName, serviceName, o) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
/**
|
||||||
const { token, chatId } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
* Send new listings to Telegram.
|
||||||
|
* - Respects per-chat Telegram rate limits using a lightweight throttle cache.
|
||||||
|
* - Falls back to sendMessage when sendPhoto fails or image is missing.
|
||||||
|
*
|
||||||
|
* @param {Object} params
|
||||||
|
* @param {string} params.serviceName - Name of the crawler/service producing the listings.
|
||||||
|
* @param {Array<Object>} params.newListings - Array of new listing objects.
|
||||||
|
* @param {Array<Object>} params.notificationConfig - Notification adapters configuration array.
|
||||||
|
* @param {string} params.jobKey - Storage job key to resolve the human readable job name.
|
||||||
|
* @returns {Promise<Array<Response>>} Promise resolving when all send operations complete.
|
||||||
|
*/
|
||||||
|
export const send = ({ serviceName, newListings = [], notificationConfig, jobKey }) => {
|
||||||
|
const adapterCfg = notificationConfig.find((adapter) => adapter.id === config.id);
|
||||||
|
if (!adapterCfg || !adapterCfg.fields) {
|
||||||
|
throw new Error(`Telegram adapter configuration missing for job '${jobKey || ''}'`);
|
||||||
|
}
|
||||||
|
const { token, chatId, messageThreadId } = adapterCfg.fields;
|
||||||
|
if (!token || !chatId) {
|
||||||
|
throw new Error("Telegram 'token' and 'chatId' must be provided in notification config");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional Telegram topic/thread support (supergroups)
|
||||||
|
let message_thread_id;
|
||||||
|
if (messageThreadId !== undefined && messageThreadId !== null && `${messageThreadId}`.trim() !== '') {
|
||||||
|
const n = Number(messageThreadId);
|
||||||
|
if (Number.isInteger(n) && n > 0) {
|
||||||
|
message_thread_id = n;
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
`Telegram adapter: 'messageThreadId' is invalid ('${messageThreadId}'). It must be a positive integer. Ignoring.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const job = getJob(jobKey);
|
const job = getJob(jobKey);
|
||||||
const jobName = job == null ? jobKey : job.name;
|
const jobName = job == null ? jobKey : job.name;
|
||||||
|
|
||||||
@@ -68,9 +149,16 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorBody = await res.text();
|
||||||
|
throw new Error(`API error for '${jobName}'. '${endpoint}' returned ${errorBody}`);
|
||||||
|
}
|
||||||
return res;
|
return res;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!Array.isArray(newListings) || newListings.length === 0) return Promise.resolve([]);
|
||||||
|
|
||||||
const promises = newListings.map(async (o) => {
|
const promises = newListings.map(async (o) => {
|
||||||
const img = normalizeImageUrl(o.image);
|
const img = normalizeImageUrl(o.image);
|
||||||
const textPayload = {
|
const textPayload = {
|
||||||
@@ -78,31 +166,37 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
|||||||
text: buildText(jobName, serviceName, o),
|
text: buildText(jobName, serviceName, o),
|
||||||
parse_mode: 'HTML',
|
parse_mode: 'HTML',
|
||||||
disable_web_page_preview: true,
|
disable_web_page_preview: true,
|
||||||
|
...(message_thread_id ? { message_thread_id } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!img) {
|
if (!img) {
|
||||||
return throttledCall('sendMessage', textPayload);
|
return await throttledCall('sendMessage', textPayload).catch(async (e) => {
|
||||||
|
logger.error(`Error sending message to Telegram: ${e.message}`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return await throttledCall('sendPhoto', {
|
||||||
return await throttledCall('sendPhoto', {
|
chat_id: chatId,
|
||||||
chat_id: chatId,
|
photo: img,
|
||||||
photo: img,
|
caption: buildCaption(jobName, serviceName, o),
|
||||||
caption: buildCaption(jobName, serviceName, o),
|
parse_mode: 'HTML',
|
||||||
parse_mode: 'HTML',
|
...(message_thread_id ? { message_thread_id } : {}),
|
||||||
|
}).catch(async (e) => {
|
||||||
|
logger.error(`Error sending photo to Telegram and use a fallback: ${e.message}`);
|
||||||
|
return await throttledCall('sendMessage', textPayload).catch((e) => {
|
||||||
|
logger.error(`Error sending message to Telegram: ${e.message}`);
|
||||||
|
throw e;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
});
|
||||||
// If we see a timeout due to sending an image, try sending it without
|
|
||||||
if (e && (e.code === 'ETIMEDOUT' || e.errno === 'ETIMEDOUT')) {
|
|
||||||
return throttledCall('sendMessage', textPayload);
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return Promise.all(promises);
|
return Promise.all(promises);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram notification adapter configuration schema.
|
||||||
|
* @type {{id:string,name:string,readme:string,description:string,fields:{token:{type:string,label:string,description:string},chatId:{type:string,label:string,description:string},messageThreadId?:{type:string,label:string,description:string}}}}
|
||||||
|
*/
|
||||||
export const config = {
|
export const config = {
|
||||||
id: 'telegram',
|
id: 'telegram',
|
||||||
name: 'Telegram',
|
name: 'Telegram',
|
||||||
@@ -119,5 +213,12 @@ export const config = {
|
|||||||
label: 'Chat Id',
|
label: 'Chat Id',
|
||||||
description: 'The chat id to send messages to you.',
|
description: 'The chat id to send messages to you.',
|
||||||
},
|
},
|
||||||
|
messageThreadId: {
|
||||||
|
type: 'text',
|
||||||
|
optional: true,
|
||||||
|
label: 'Message Thread Id (optional)',
|
||||||
|
description:
|
||||||
|
'Optional: The topic/thread id within a supergroup to post into (Telegram message_thread_id). Provide a positive integer.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,55 @@
|
|||||||
### Telegram Adapter
|
### Telegram Adapter
|
||||||
|
|
||||||
For Telegram, you need to create a Bot. This is pretty easy. Open [this](https://telegram.me/BotFather) url on your smartphone and follow the instructions.
|
Use this adapter to send notifications to Telegram via a bot. You will need:
|
||||||
|
- A Telegram Bot token (from BotFather)
|
||||||
|
- A chat ID (where messages will be sent)
|
||||||
|
- Optionally: a thread ID if you want to post into a specific forum topic in a group
|
||||||
|
|
||||||
A telegram bot is not allowed to send messages directly to a user, you as a user need to first contact the bot to get a chatId.
|
#### Create a bot
|
||||||
After the user has send a message to your bot the first time, you can gather the chatId like this:
|
Create a bot with BotFather: open https://telegram.me/BotFather on your phone or in Telegram Desktop and follow the instructions to get your bot token.
|
||||||
|
|
||||||
|
#### Getting the chat ID
|
||||||
|
A Telegram bot cannot message a user first; you must create a conversation (or add the bot to a group/channel) so Telegram assigns a chat the bot can access.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Start a chat with your bot in Telegram (or add the bot to your group/supergroup/channel) and send any message.
|
||||||
|
2. Fetch recent updates from the Bot API:
|
||||||
|
```
|
||||||
|
curl -X GET "https://api.telegram.org/bot{YOUR_TELEGRAM_TOKEN}/getUpdates"
|
||||||
|
```
|
||||||
|
3. In the JSON response, find the message that you just sent and read `message.chat.id`. That value is your `chatId`.
|
||||||
|
- Private chats: `chat.id` is a positive number
|
||||||
|
- Groups/supergroups: `chat.id` is a negative number
|
||||||
|
|
||||||
|
Keep your bot token secret. If `getUpdates` returns an empty list, send a new message and try again, or make sure your bot’s privacy settings allow it to see group messages when used in groups.
|
||||||
|
|
||||||
|
#### Getting the thread ID (this is optional to be used for forum topics)
|
||||||
|
If you want messages to appear inside a specific forum topic of a supergroup with Topics enabled, you also need a thread ID. In the Telegram Bot API this is called `message_thread_id`.
|
||||||
|
|
||||||
|
When you need it:
|
||||||
|
- Required only for supergroups with Topics enabled when targeting a topic
|
||||||
|
- Not used for private chats, basic groups without Topics, or channels
|
||||||
|
|
||||||
|
Steps to obtain it:
|
||||||
|
1. In your supergroup, enable Topics (Group settings → Manage group → Topics → Enable). Now add a new topic.
|
||||||
|
2. Add your created bot to the topic. (Click on the bot and on "Add to group")
|
||||||
|
3. Open the desired topic (or create a new one) and send any message inside that topic.
|
||||||
|
4. Call `getUpdates` again:
|
||||||
|
```
|
||||||
|
curl -X GET "https://api.telegram.org/bot{YOUR_TELEGRAM_TOKEN}/getUpdates"
|
||||||
|
```
|
||||||
|
4. In the update for the message you sent inside the topic, read `message.message_thread_id`. That number is your `threadId` for this topic.
|
||||||
|
|
||||||
|
Example (truncated):
|
||||||
```
|
```
|
||||||
curl -X GET https://api.telegram.org/bot{YOUR_TELEGRAM_TOKEN}/getUpdates
|
{
|
||||||
|
"message": {
|
||||||
|
"chat": { "id": -1001234567890, "type": "supergroup" },
|
||||||
|
"message_thread_id": 42,
|
||||||
|
"text": "hello from the topic"
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
Use `chat.id` as `chatId` and `message_thread_id` as `threadId` in your configuration.
|
||||||
|
|
||||||
A more detailed list of instructions can be found here [https://core.telegram.org/bots#botfather](https://core.telegram.org/bots#botfather)
|
More details about bots and BotFather: https://core.telegram.org/bots#botfather
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
const path = './adapter';
|
const path = './adapter';
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
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';
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
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';
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import { isOneOf, buildHash } from '../utils.js';
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
@@ -29,6 +34,7 @@ const config = {
|
|||||||
address: 'div[data-testid="cardmfe-description-box-address"] | trim',
|
address: 'div[data-testid="cardmfe-description-box-address"] | trim',
|
||||||
image: 'div[data-testid="cardmfe-picture-box-test-id"] img@src',
|
image: 'div[data-testid="cardmfe-picture-box-test-id"] img@src',
|
||||||
link: 'button@data-base',
|
link: 'button@data-base',
|
||||||
|
description: 'div[data-testid="cardmfe-description-text-test-id"] | trim',
|
||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ImmoScout provider using the mobile API to retrieve listings.
|
* ImmoScout provider using the mobile API to retrieve listings.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import { isOneOf, buildHash } from '../utils.js';
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
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';
|
||||||
|
|
||||||
@@ -26,8 +31,9 @@ const config = {
|
|||||||
size: 'div[data-testid="cardmfe-keyfacts-testid"] | removeNewline | trim',
|
size: 'div[data-testid="cardmfe-keyfacts-testid"] | 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',
|
||||||
address: 'div[data-testid="cardmfe-description-box-address"] | removeNewline | trim',
|
address: 'div[data-testid="cardmfe-description-box-address"] | removeNewline | trim',
|
||||||
image: 'div[data-testid="cardMfe-card-pictureBox-opacity"] img@src',
|
image: 'div[data-testid="cardmfe-picture-box-opacity-layer-test-id"] img@src',
|
||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
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';
|
||||||
|
|
||||||
|
|||||||
52
lib/provider/mcMakler.js
Executable file
@@ -0,0 +1,52 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
function normalize(o) {
|
||||||
|
const originalId = o.id.split('/').pop();
|
||||||
|
const id = buildHash(originalId, o.price);
|
||||||
|
const size = o.size ?? 'N/A m²';
|
||||||
|
const title = o.title || 'No title available';
|
||||||
|
const address = o.address?.replace(' / ', ' ') || null;
|
||||||
|
const link = o.link != null ? `https://www.mcmakler.de${o.link}` : config.url;
|
||||||
|
return Object.assign(o, { id, size, title, link, address });
|
||||||
|
}
|
||||||
|
function applyBlacklist(o) {
|
||||||
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
|
}
|
||||||
|
const config = {
|
||||||
|
url: null,
|
||||||
|
crawlContainer: 'article[data-testid="propertyCard"]',
|
||||||
|
sortByDateParam: 'sortBy=DATE&sortOn=DESC',
|
||||||
|
waitForSelector: 'ul[data-testid="listsContainer"]',
|
||||||
|
crawlFields: {
|
||||||
|
id: 'h2 a@href',
|
||||||
|
title: 'h2 a | removeNewline | trim',
|
||||||
|
price: 'footer > p:first-of-type | trim',
|
||||||
|
size: 'footer > p:nth-of-type(2) | trim',
|
||||||
|
address: 'div > h2 + p | removeNewline | trim',
|
||||||
|
image: 'img@src',
|
||||||
|
link: 'h2 a@href',
|
||||||
|
},
|
||||||
|
normalize: normalize,
|
||||||
|
filter: applyBlacklist,
|
||||||
|
activeTester: checkIfListingIsActive,
|
||||||
|
};
|
||||||
|
export const init = (sourceConfig, blacklist) => {
|
||||||
|
config.enabled = sourceConfig.enabled;
|
||||||
|
config.url = sourceConfig.url;
|
||||||
|
appliedBlackList = blacklist || [];
|
||||||
|
};
|
||||||
|
export const metaInformation = {
|
||||||
|
name: 'McMakler',
|
||||||
|
baseUrl: 'https://www.mcmakler.de/immobilien/',
|
||||||
|
id: 'mcMakler',
|
||||||
|
};
|
||||||
|
export { config };
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import { isOneOf, buildHash } from '../utils.js';
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
|
||||||
|
|||||||
50
lib/provider/ohneMakler.js
Executable file
@@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
function normalize(o) {
|
||||||
|
const link = metaInformation.baseUrl + o.link;
|
||||||
|
const id = buildHash(o.title, o.link, o.price);
|
||||||
|
return Object.assign(o, { link, id });
|
||||||
|
}
|
||||||
|
function applyBlacklist(o) {
|
||||||
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
|
}
|
||||||
|
const config = {
|
||||||
|
url: null,
|
||||||
|
crawlContainer: 'div[data-livecomponent-id*="search/property_list"] .grid > div',
|
||||||
|
sortByDateParam: null,
|
||||||
|
waitForSelector: null,
|
||||||
|
crawlFields: {
|
||||||
|
id: 'a@href',
|
||||||
|
title: 'h4 | removeNewline | trim',
|
||||||
|
price: '.text-xl | trim',
|
||||||
|
size: 'div[title="Wohnfläche"] | trim',
|
||||||
|
address: '.text-slate-800 | removeNewline | trim',
|
||||||
|
image: 'img@src',
|
||||||
|
link: 'a@href',
|
||||||
|
},
|
||||||
|
normalize: normalize,
|
||||||
|
filter: applyBlacklist,
|
||||||
|
activeTester: checkIfListingIsActive,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const init = (sourceConfig, blacklist) => {
|
||||||
|
config.enabled = sourceConfig.enabled;
|
||||||
|
config.url = sourceConfig.url;
|
||||||
|
appliedBlackList = blacklist || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const metaInformation = {
|
||||||
|
name: 'OhneMakler',
|
||||||
|
baseUrl: 'https://www.ohne-makler.net/immobilien',
|
||||||
|
id: 'ohneMakler',
|
||||||
|
};
|
||||||
|
export { config };
|
||||||
54
lib/provider/regionalimmobilien24.js
Executable file
@@ -0,0 +1,54 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
function normalize(o) {
|
||||||
|
const id = buildHash(o.id, o.price);
|
||||||
|
const address = o.address?.replace(/^adresse /i, '') ?? null;
|
||||||
|
const title = o.title || 'No title available';
|
||||||
|
const link = o.link != null ? decodeURIComponent(o.link) : config.url;
|
||||||
|
|
||||||
|
const urlReg = new RegExp(/url\((.*?)\)/gim);
|
||||||
|
const image = o.image != null ? urlReg.exec(o.image)[1] : null;
|
||||||
|
return Object.assign(o, { id, address, title, link, image });
|
||||||
|
}
|
||||||
|
function applyBlacklist(o) {
|
||||||
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
|
}
|
||||||
|
const config = {
|
||||||
|
url: null,
|
||||||
|
crawlContainer: '.listentry-content',
|
||||||
|
sortByDateParam: null, // sort by date is standard
|
||||||
|
waitForSelector: 'body',
|
||||||
|
crawlFields: {
|
||||||
|
id: '.listentry-iconbar-share@data-sid | trim',
|
||||||
|
title: 'h2 | trim',
|
||||||
|
price: '.listentry-details-price .listentry-details-v | trim',
|
||||||
|
size: '.listentry-details-size .listentry-details-v | trim',
|
||||||
|
address: '.listentry-adress | trim',
|
||||||
|
image: '.listentry-img@style',
|
||||||
|
link: '.shariff@data-url',
|
||||||
|
description: '.listentry-extras | trim',
|
||||||
|
},
|
||||||
|
normalize: normalize,
|
||||||
|
filter: applyBlacklist,
|
||||||
|
activeTester: checkIfListingIsActive,
|
||||||
|
};
|
||||||
|
export const init = (sourceConfig, blacklist) => {
|
||||||
|
config.enabled = sourceConfig.enabled;
|
||||||
|
config.url = sourceConfig.url;
|
||||||
|
appliedBlackList = blacklist || [];
|
||||||
|
};
|
||||||
|
export const metaInformation = {
|
||||||
|
name: 'Regionalimmobilien24',
|
||||||
|
baseUrl: 'https://www.regionalimmobilien24.de/',
|
||||||
|
id: 'regionalimmobilien24',
|
||||||
|
};
|
||||||
|
export { config };
|
||||||
51
lib/provider/sparkasse.js
Executable file
@@ -0,0 +1,51 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
function normalize(o) {
|
||||||
|
const originalId = o.id.split('/').pop().replace('.html', '');
|
||||||
|
const id = buildHash(originalId, o.price);
|
||||||
|
const size = o.size?.replace(' Wohnfläche', '') ?? null;
|
||||||
|
const title = o.title || 'No title available';
|
||||||
|
const link = o.link != null ? `https://immobilien.sparkasse.de${o.link}` : config.url;
|
||||||
|
return Object.assign(o, { id, size, title, link });
|
||||||
|
}
|
||||||
|
function applyBlacklist(o) {
|
||||||
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
|
}
|
||||||
|
const config = {
|
||||||
|
url: null,
|
||||||
|
crawlContainer: '.estate-list-item-row',
|
||||||
|
sortByDateParam: 'sortBy=date_desc',
|
||||||
|
waitForSelector: 'body',
|
||||||
|
crawlFields: {
|
||||||
|
id: 'div[data-testid="estate-link"] a@href',
|
||||||
|
title: 'h3 | trim',
|
||||||
|
price: '.estate-list-price | trim',
|
||||||
|
size: '.estate-mainfact:first-child span | trim',
|
||||||
|
address: 'h6 | trim',
|
||||||
|
image: '.estate-list-item-image-container img@src',
|
||||||
|
link: 'div[data-testid="estate-link"] a@href',
|
||||||
|
},
|
||||||
|
normalize: normalize,
|
||||||
|
filter: applyBlacklist,
|
||||||
|
activeTester: checkIfListingIsActive,
|
||||||
|
};
|
||||||
|
export const init = (sourceConfig, blacklist) => {
|
||||||
|
config.enabled = sourceConfig.enabled;
|
||||||
|
config.url = sourceConfig.url;
|
||||||
|
appliedBlackList = blacklist || [];
|
||||||
|
};
|
||||||
|
export const metaInformation = {
|
||||||
|
name: 'Sparkasse Immobilien',
|
||||||
|
baseUrl: 'https://immobilien.sparkasse.de/',
|
||||||
|
id: 'sparkasse',
|
||||||
|
};
|
||||||
|
export { config };
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import { isOneOf, buildHash } from '../utils.js';
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import { removeJobsByUserId } from '../storage/jobStorage.js';
|
import { removeJobsByUserId } from '../storage/jobStorage.js';
|
||||||
import { config } from '../../utils.js';
|
|
||||||
import { getUsers } from '../storage/userStorage.js';
|
import { getUsers } from '../storage/userStorage.js';
|
||||||
import logger from '../logger.js';
|
import logger from '../logger.js';
|
||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
|
import { getSettings } from '../storage/settingsStorage.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* if we are running in demo environment, we have to cleanup the db files (specifically the jobs table)
|
* if we are running in demo environment, we have to cleanup the db files (specifically the jobs table)
|
||||||
@@ -11,12 +16,13 @@ export function cleanupDemoAtMidnight() {
|
|||||||
cron.schedule('0 0 * * *', cleanup);
|
cron.schedule('0 0 * * *', cleanup);
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanup() {
|
async function cleanup() {
|
||||||
if (config.demoMode) {
|
const settings = await getSettings();
|
||||||
|
if (settings.demoMode) {
|
||||||
const demoUser = getUsers(false).find((user) => user.username === 'demo');
|
const demoUser = getUsers(false).find((user) => user.username === 'demo');
|
||||||
if (demoUser == null) {
|
if (demoUser == null) {
|
||||||
logger.error('Demo user not found, cannot remove Jobs');
|
logger.error('Demo user not found, cannot remove Jobs');
|
||||||
return;
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
removeJobsByUserId(demoUser.id);
|
removeJobsByUserId(demoUser.id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import runActiveChecker from '../listings/listingActiveService.js';
|
import runActiveChecker from '../listings/listingActiveService.js';
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import { config, inDevMode } from '../../utils.js';
|
import { inDevMode } from '../../utils.js';
|
||||||
import { trackMainEvent } from '../tracking/Tracker.js';
|
import { trackMainEvent } from '../tracking/Tracker.js';
|
||||||
|
import { getSettings } from '../storage/settingsStorage.js';
|
||||||
|
|
||||||
async function runTask() {
|
async function runTask() {
|
||||||
|
const settings = await getSettings();
|
||||||
//make sure to only send tracking events if the user gave us the green light and we are not in dev mode
|
//make sure to only send tracking events if the user gave us the green light and we are not in dev mode
|
||||||
if (config.analyticsEnabled && !inDevMode()) {
|
if (settings.analyticsEnabled && !inDevMode()) {
|
||||||
await trackMainEvent();
|
await trackMainEvent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,7 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
export const bus = new EventEmitter();
|
export const bus = new EventEmitter();
|
||||||
|
|||||||
279
lib/services/extractor/botPrevention.js
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DEFAULT_HEADER } from './utils.js';
|
||||||
|
|
||||||
|
// Helper to safely coerce numbers
|
||||||
|
const toInt = (v, d) => {
|
||||||
|
const n = parseInt(v, 10);
|
||||||
|
return Number.isFinite(n) ? n : d;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute pre-launch configuration and flags for Puppeteer with bot prevention in mind.
|
||||||
|
* Returns language, user agent, viewport (with optional jitter), and additional launch args.
|
||||||
|
*
|
||||||
|
* @param {string} url
|
||||||
|
* @param {object} [options]
|
||||||
|
*/
|
||||||
|
export function getPreLaunchConfig(url, options = {}) {
|
||||||
|
const { hostname } = new URL(url);
|
||||||
|
|
||||||
|
const acceptLanguage = options.acceptLanguage || 'de-DE,de;q=0.9,en-US;q=0.7,en;q=0.5';
|
||||||
|
const langForFlag = acceptLanguage.split(',')[0];
|
||||||
|
|
||||||
|
const baseViewport = { width: 1366, height: 768, deviceScaleFactor: 1 };
|
||||||
|
const jitter = options.viewportJitter !== false ? Math.floor(Math.random() * 6) : 0; // 0..5 px
|
||||||
|
const width = toInt(options?.viewport?.width, baseViewport.width) + jitter;
|
||||||
|
const height = toInt(options?.viewport?.height, baseViewport.height) + jitter;
|
||||||
|
const deviceScaleFactor = toInt(options?.viewport?.deviceScaleFactor, baseViewport.deviceScaleFactor);
|
||||||
|
const viewport = { width, height, deviceScaleFactor };
|
||||||
|
|
||||||
|
const userAgent =
|
||||||
|
options.userAgent ||
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
|
||||||
|
|
||||||
|
const windowSizeArg = `--window-size=${viewport.width},${viewport.height}`;
|
||||||
|
const langArg = `--lang=${langForFlag}`;
|
||||||
|
|
||||||
|
const extraArgs = [
|
||||||
|
'--disable-blink-features=AutomationControlled',
|
||||||
|
'--force-webrtc-ip-handling-policy=disable_non_proxied_udp',
|
||||||
|
'--webrtc-ip-handling-policy=default_public_interface_only',
|
||||||
|
'--proxy-bypass-list=<-loopback>',
|
||||||
|
];
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
...DEFAULT_HEADER,
|
||||||
|
'Accept-Language': acceptLanguage,
|
||||||
|
'User-Agent': userAgent,
|
||||||
|
Referer: options?.referer || `https://${hostname}/`,
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
DNT: '1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const timezone = options?.timezone || 'Europe/Berlin';
|
||||||
|
|
||||||
|
return {
|
||||||
|
acceptLanguage,
|
||||||
|
langForFlag,
|
||||||
|
userAgent,
|
||||||
|
viewport,
|
||||||
|
windowSizeArg,
|
||||||
|
langArg,
|
||||||
|
extraArgs,
|
||||||
|
headers,
|
||||||
|
timezone,
|
||||||
|
humanDelay: options?.humanDelay !== false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply bot-prevention hardening to a Puppeteer page.
|
||||||
|
* Sets UA, viewport, JS enabled, headers, timezone and injects stealth-like patches.
|
||||||
|
*
|
||||||
|
* @param {import('puppeteer').Page} page
|
||||||
|
* @param {ReturnType<typeof getPreLaunchConfig>} cfg
|
||||||
|
*/
|
||||||
|
export async function applyBotPreventionToPage(page, cfg) {
|
||||||
|
await page.setUserAgent(cfg.userAgent);
|
||||||
|
await page.setViewport(cfg.viewport);
|
||||||
|
await page.setJavaScriptEnabled(true);
|
||||||
|
await page.setExtraHTTPHeaders(cfg.headers);
|
||||||
|
try {
|
||||||
|
if (cfg.timezone) await page.emulateTimezone(cfg.timezone);
|
||||||
|
} catch {
|
||||||
|
// ignore timezone failures
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject patches as early as possible
|
||||||
|
await page.evaluateOnNewDocument(() => {
|
||||||
|
try {
|
||||||
|
// webdriver
|
||||||
|
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
||||||
|
|
||||||
|
// chrome runtime
|
||||||
|
// @ts-ignore
|
||||||
|
if (!window.chrome) {
|
||||||
|
// @ts-ignore
|
||||||
|
window.chrome = { runtime: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
// languages
|
||||||
|
// @ts-ignore
|
||||||
|
Object.defineProperty(navigator, 'languages', {
|
||||||
|
get: () => (window.localStorage.getItem('__LANGS__') || 'de-DE,de').split(','),
|
||||||
|
});
|
||||||
|
|
||||||
|
// plugins
|
||||||
|
// @ts-ignore
|
||||||
|
Object.defineProperty(navigator, 'plugins', {
|
||||||
|
get: () => [{}, {}, {}],
|
||||||
|
});
|
||||||
|
|
||||||
|
// platform and concurrency hints
|
||||||
|
// @ts-ignore
|
||||||
|
Object.defineProperty(navigator, 'platform', { get: () => 'Win32' });
|
||||||
|
// @ts-ignore
|
||||||
|
if (typeof navigator.hardwareConcurrency === 'number' && navigator.hardwareConcurrency < 2) {
|
||||||
|
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 4 });
|
||||||
|
}
|
||||||
|
// @ts-ignore
|
||||||
|
if (typeof navigator.deviceMemory === 'number' && navigator.deviceMemory < 2) {
|
||||||
|
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// userAgentData (Client Hints)
|
||||||
|
try {
|
||||||
|
// @ts-ignore
|
||||||
|
if ('userAgentData' in navigator) {
|
||||||
|
// @ts-ignore
|
||||||
|
Object.defineProperty(navigator, 'userAgentData', {
|
||||||
|
get: () => ({
|
||||||
|
brands: [
|
||||||
|
{ brand: 'Chromium', version: '126' },
|
||||||
|
{ brand: 'Google Chrome', version: '126' },
|
||||||
|
],
|
||||||
|
mobile: false,
|
||||||
|
platform: 'Windows',
|
||||||
|
getHighEntropyValues: async (hints) => {
|
||||||
|
const values = {
|
||||||
|
platform: 'Windows',
|
||||||
|
platformVersion: '15.0.0',
|
||||||
|
architecture: 'x86',
|
||||||
|
model: '',
|
||||||
|
uaFullVersion: '126.0.0.0',
|
||||||
|
bitness: '64',
|
||||||
|
};
|
||||||
|
const out = {};
|
||||||
|
for (const k of hints || []) if (k in values) out[k] = values[k];
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
//noop
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permissions API
|
||||||
|
const origQuery = navigator.permissions && navigator.permissions.query;
|
||||||
|
if (origQuery) {
|
||||||
|
// @ts-ignore
|
||||||
|
navigator.permissions.query = (parameters) =>
|
||||||
|
origQuery.call(navigator.permissions, parameters).then((result) => {
|
||||||
|
if (parameters && parameters.name === 'notifications') {
|
||||||
|
Object.defineProperty(result, 'state', { get: () => Notification.permission });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebGL vendor/renderer
|
||||||
|
const patchWebGL = (proto) => {
|
||||||
|
if (!proto || !proto.getParameter) return;
|
||||||
|
const getParameter = proto.getParameter;
|
||||||
|
// @ts-ignore
|
||||||
|
proto.getParameter = function (param) {
|
||||||
|
const UNMASKED_VENDOR_WEBGL = 0x9245;
|
||||||
|
const UNMASKED_RENDERER_WEBGL = 0x9246;
|
||||||
|
if (param === UNMASKED_VENDOR_WEBGL) return 'Google Inc.';
|
||||||
|
if (param === UNMASKED_RENDERER_WEBGL)
|
||||||
|
return 'ANGLE (NVIDIA, NVIDIA GeForce GTX 1660 Ti Direct3D11 vs_5_0 ps_5_0)';
|
||||||
|
return getParameter.call(this, param);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
// @ts-ignore
|
||||||
|
patchWebGL(WebGLRenderingContext?.prototype);
|
||||||
|
// @ts-ignore
|
||||||
|
patchWebGL(WebGL2RenderingContext?.prototype);
|
||||||
|
|
||||||
|
// AudioContext timestamp rounding consistency
|
||||||
|
const patchAudio = (Ctx) => {
|
||||||
|
try {
|
||||||
|
if (!Ctx) return;
|
||||||
|
const proto = Ctx.prototype;
|
||||||
|
const createOsc = proto.createOscillator;
|
||||||
|
proto.createOscillator = function () {
|
||||||
|
const osc = createOsc.call(this);
|
||||||
|
const start = osc.start;
|
||||||
|
osc.start = function (when) {
|
||||||
|
return start.call(this, when || 0);
|
||||||
|
};
|
||||||
|
return osc;
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
//noop
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// @ts-ignore
|
||||||
|
patchAudio(window.AudioContext);
|
||||||
|
// @ts-ignore
|
||||||
|
patchAudio(window.OfflineAudioContext);
|
||||||
|
|
||||||
|
// Navigator.connection
|
||||||
|
try {
|
||||||
|
// @ts-ignore
|
||||||
|
Object.defineProperty(navigator, 'connection', { get: () => undefined });
|
||||||
|
} catch {
|
||||||
|
//noop
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consistent outer sizes
|
||||||
|
try {
|
||||||
|
const calcOuter = () => {
|
||||||
|
const w = window.innerWidth + 16;
|
||||||
|
const h = window.innerHeight + 88;
|
||||||
|
return { w, h };
|
||||||
|
};
|
||||||
|
const { w: outerW, h: outerH } = calcOuter();
|
||||||
|
// @ts-ignore
|
||||||
|
Object.defineProperty(window, 'outerWidth', { get: () => outerW });
|
||||||
|
// @ts-ignore
|
||||||
|
Object.defineProperty(window, 'outerHeight', { get: () => outerH });
|
||||||
|
} catch {
|
||||||
|
//noop
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
//noop
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist languages value before navigation via localStorage.
|
||||||
|
* @param {import('puppeteer').Page} page
|
||||||
|
* @param {ReturnType<typeof getPreLaunchConfig>} cfg
|
||||||
|
*/
|
||||||
|
export async function applyLanguagePersistence(page, cfg) {
|
||||||
|
await page.evaluateOnNewDocument((langs) => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem('__LANGS__', langs);
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
}, cfg.acceptLanguage.split(';')[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform subtle human-like interactions post navigation.
|
||||||
|
* @param {import('puppeteer').Page} page
|
||||||
|
* @param {ReturnType<typeof getPreLaunchConfig>} cfg
|
||||||
|
*/
|
||||||
|
export async function applyPostNavigationHumanSignals(page, cfg) {
|
||||||
|
if (!cfg.humanDelay) return;
|
||||||
|
const delay = 200 + Math.floor(Math.random() * 400);
|
||||||
|
await new Promise((res) => setTimeout(res, delay));
|
||||||
|
try {
|
||||||
|
const vw = cfg.viewport.width;
|
||||||
|
const vh = cfg.viewport.height;
|
||||||
|
const mx = Math.floor(vw * (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.wheel({ deltaY: 100 + Math.floor(Math.random() * 200) });
|
||||||
|
} catch {
|
||||||
|
// ignore if mouse is unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import { setDebug } from './utils.js';
|
import { setDebug } from './utils.js';
|
||||||
import puppeteerExtractor from './puppeteerExtractor.js';
|
import puppeteerExtractor from './puppeteerExtractor.js';
|
||||||
import { loadParser, parse } from './parser/parser.js';
|
import { loadParser, parse } from './parser/parser.js';
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import logger from '../../logger.js';
|
import logger from '../../logger.js';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import puppeteer from 'puppeteer-extra';
|
import puppeteer from 'puppeteer-extra';
|
||||||
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
||||||
import { debug, DEFAULT_HEADER, botDetected } from './utils.js';
|
import { debug, botDetected } from './utils.js';
|
||||||
|
import {
|
||||||
|
getPreLaunchConfig,
|
||||||
|
applyBotPreventionToPage,
|
||||||
|
applyLanguagePersistence,
|
||||||
|
applyPostNavigationHumanSignals,
|
||||||
|
} from './botPrevention.js';
|
||||||
import logger from '../logger.js';
|
import logger from '../logger.js';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
@@ -27,23 +38,50 @@ export default async function execute(url, waitForSelector, options) {
|
|||||||
removeUserDataDir = true;
|
removeUserDataDir = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const launchArgs = [
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-gpu',
|
||||||
|
'--disable-setuid-sandbox',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
'--disable-crash-reporter',
|
||||||
|
'--no-first-run',
|
||||||
|
'--no-default-browser-check',
|
||||||
|
];
|
||||||
|
if (options?.proxyUrl) {
|
||||||
|
launchArgs.push(`--proxy-server=${options.proxyUrl}`);
|
||||||
|
}
|
||||||
|
// Prepare bot prevention pre-launch config
|
||||||
|
const preCfg = getPreLaunchConfig(url, options || {});
|
||||||
|
launchArgs.push(preCfg.langArg);
|
||||||
|
launchArgs.push(preCfg.windowSizeArg);
|
||||||
|
launchArgs.push(...preCfg.extraArgs);
|
||||||
|
|
||||||
browser = await puppeteer.launch({
|
browser = await puppeteer.launch({
|
||||||
headless: options.puppeteerHeadless ?? true,
|
headless: options?.puppeteerHeadless ?? true,
|
||||||
args: [
|
args: launchArgs,
|
||||||
'--no-sandbox',
|
timeout: options?.puppeteerTimeout || 30_000,
|
||||||
'--disable-gpu',
|
|
||||||
'--disable-setuid-sandbox',
|
|
||||||
'--disable-dev-shm-usage',
|
|
||||||
'--disable-crash-reporter',
|
|
||||||
],
|
|
||||||
timeout: options.puppeteerTimeout || 30_000,
|
|
||||||
userDataDir,
|
userDataDir,
|
||||||
|
executablePath: options?.executablePath, // allow using system Chrome
|
||||||
});
|
});
|
||||||
|
|
||||||
page = await browser.newPage();
|
page = await browser.newPage();
|
||||||
await page.setExtraHTTPHeaders(DEFAULT_HEADER);
|
await applyBotPreventionToPage(page, preCfg);
|
||||||
|
// Provide languages value before navigation
|
||||||
|
await applyLanguagePersistence(page, preCfg);
|
||||||
|
|
||||||
|
// Optional cookies
|
||||||
|
if (Array.isArray(options?.cookies) && options.cookies.length > 0) {
|
||||||
|
await page.setCookie(...options.cookies);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation
|
||||||
const response = await page.goto(url, {
|
const response = await page.goto(url, {
|
||||||
waitUntil: 'domcontentloaded',
|
waitUntil: options?.waitUntil || 'domcontentloaded',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Optionally wait and add subtle human-like interactions
|
||||||
|
await applyPostNavigationHumanSignals(page, preCfg);
|
||||||
|
|
||||||
let pageSource;
|
let pageSource;
|
||||||
// if we're extracting data from a SPA, we must wait for the selector
|
// if we're extracting data from a SPA, we must wait for the selector
|
||||||
if (waitForSelector != null) {
|
if (waitForSelector != null) {
|
||||||
@@ -57,7 +95,7 @@ export default async function execute(url, waitForSelector, options) {
|
|||||||
pageSource = await page.content();
|
pageSource = await page.content();
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusCode = response.status();
|
const statusCode = response?.status?.() ?? 200;
|
||||||
|
|
||||||
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);
|
||||||
@@ -66,7 +104,7 @@ export default async function execute(url, waitForSelector, options) {
|
|||||||
result = pageSource || (await page.content());
|
result = pageSource || (await page.content());
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error executing with puppeteer executor', error);
|
logger.warn('Error executing with puppeteer executor', error);
|
||||||
result = null;
|
result = null;
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import logger from '../logger.js';
|
import logger from '../logger.js';
|
||||||
|
|
||||||
let debuggingOn = false;
|
let debuggingOn = false;
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Rent a flat
|
Rent a flat
|
||||||
Web:
|
Web:
|
||||||
|
|||||||
187
lib/services/jobs/jobExecutionService.js
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import logger from '../logger.js';
|
||||||
|
import { bus } from '../events/event-bus.js';
|
||||||
|
import * as jobStorage from '../storage/jobStorage.js';
|
||||||
|
import * as userStorage from '../storage/userStorage.js';
|
||||||
|
import { getUser } from '../storage/userStorage.js';
|
||||||
|
import { duringWorkingHoursOrNotSet } from '../../utils.js';
|
||||||
|
import FredyPipelineExecutioner from '../../FredyPipelineExecutioner.js';
|
||||||
|
import * as similarityCache from '../similarity-check/similarityCache.js';
|
||||||
|
import { isRunning, markFinished, markRunning } from './run-state.js';
|
||||||
|
import { sendToUsers } from '../sse/sse-broker.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the job execution service.
|
||||||
|
* - Registers event-bus listeners for `jobs:runAll`, `jobs:runOne`, and `jobs:status`.
|
||||||
|
* - Starts the periodic scheduler (if `intervalMs` > 0) and performs an initial run respecting working hours.
|
||||||
|
* - Forwards job status updates to affected users via Server-Sent Events (SSE).
|
||||||
|
*
|
||||||
|
* This function is intentionally side-effectful and exposes no external API.
|
||||||
|
*
|
||||||
|
* @param {Object} deps - Dependencies required to initialize the service.
|
||||||
|
* @param {Array<Object>} deps.providers - Loaded provider modules. Each module must expose `metaInformation.id`, `config`, and `init(config, blacklist)`.
|
||||||
|
* @param {Object} deps.settings - Global settings object (read/write). Must include `demoMode`, `interval`, and working-hours attributes used by `duringWorkingHoursOrNotSet`.
|
||||||
|
* @param {number} deps.intervalMs - Scheduler interval in milliseconds. If not finite or <= 0, the scheduler is not started.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function initJobExecutionService({ providers, settings, intervalMs }) {
|
||||||
|
// Forward job status via SSE to relevant recipients
|
||||||
|
bus.on('jobs:status', ({ jobId, running }) => {
|
||||||
|
try {
|
||||||
|
const recipients = resolveRecipients(jobId);
|
||||||
|
if (recipients.length > 0) {
|
||||||
|
sendToUsers(recipients, 'jobStatus', { jobId, running });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to forward job status', jobId, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for "run all" requests (admin = all, user = own)
|
||||||
|
bus.on('jobs:runAll', (payload) => {
|
||||||
|
const userId = payload?.userId ?? null;
|
||||||
|
const user = userId ? getUser(userId) : null;
|
||||||
|
const isAdmin = !!user?.isAdmin;
|
||||||
|
if (isAdmin) {
|
||||||
|
logger.debug('Running all jobs manually (admin request)');
|
||||||
|
} else if (userId) {
|
||||||
|
logger.debug(`Running all jobs manually for user ${userId}`);
|
||||||
|
} else {
|
||||||
|
logger.debug('Running all jobs manually (no user provided)');
|
||||||
|
}
|
||||||
|
runAll(false, { userId, isAdmin });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for single job run requests
|
||||||
|
bus.on('jobs:runOne', ({ jobId }) => {
|
||||||
|
logger.debug(`Running single job manually: ${jobId}`);
|
||||||
|
// fire and forget, do not block the bus
|
||||||
|
runSingle(jobId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start scheduler and initial run
|
||||||
|
if (Number.isFinite(intervalMs) && intervalMs > 0) {
|
||||||
|
setInterval(() => runAll(true), intervalMs);
|
||||||
|
}
|
||||||
|
// start once at startup, respecting working hours
|
||||||
|
runAll(true);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve all recipients who should receive SSE updates for a job.
|
||||||
|
* Includes job owner, users with whom the job is shared, and all admins.
|
||||||
|
*
|
||||||
|
* @param {string} jobId
|
||||||
|
* @returns {string[]} unique userIds
|
||||||
|
*/
|
||||||
|
function resolveRecipients(jobId) {
|
||||||
|
const job = jobStorage.getJob(jobId);
|
||||||
|
if (!job) return [];
|
||||||
|
const admins = (userStorage.getUsers && userStorage.getUsers(false)) || [];
|
||||||
|
const adminIds = admins.filter((u) => u.isAdmin).map((u) => u.id);
|
||||||
|
const shared = Array.isArray(job.shared_with_user) ? job.shared_with_user : [];
|
||||||
|
const recipients = [job.userId, ...shared, ...adminIds].filter(Boolean);
|
||||||
|
return Array.from(new Set(recipients));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute all enabled jobs, optionally filtering by context (admin/owner) and respecting working hours.
|
||||||
|
*
|
||||||
|
* @param {boolean} [respectWorkingHours=true] - If true, skip execution when outside configured working hours.
|
||||||
|
* @param {{userId?: string, isAdmin?: boolean}} [context] - Who requested the run; determines job filtering.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function runAll(respectWorkingHours = true, context = undefined) {
|
||||||
|
if (settings.demoMode) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const withinHours = duringWorkingHoursOrNotSet(settings, now);
|
||||||
|
if (respectWorkingHours && !withinHours) {
|
||||||
|
logger.debug('Working hours set. Skipping as outside of working hours.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settings.lastRun = now;
|
||||||
|
jobStorage
|
||||||
|
.getJobs()
|
||||||
|
.filter((job) => job.enabled)
|
||||||
|
.filter((job) => {
|
||||||
|
if (!context) return true; // startup/cron → all
|
||||||
|
if (context.isAdmin) return true; // admin → all
|
||||||
|
return context.userId ? job.userId === context.userId : false; // user → own
|
||||||
|
})
|
||||||
|
.forEach((job) => executeJob(job));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a single job by id.
|
||||||
|
* Manual runs are allowed even if the job is disabled, but never duplicated when already running.
|
||||||
|
*
|
||||||
|
* @param {string} jobId
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function runSingle(jobId) {
|
||||||
|
if (settings.demoMode) return;
|
||||||
|
const job = jobStorage.getJob(jobId);
|
||||||
|
if (!job) return;
|
||||||
|
// allow manual run even if disabled; keep guard to avoid duplicates
|
||||||
|
await executeJob(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes one job across all of its configured providers.
|
||||||
|
* Emits SSE start/finish events via the bus and ensures the run-state guard is always cleared.
|
||||||
|
* Provider errors are surfaced via logging but do not abort other providers.
|
||||||
|
*
|
||||||
|
* @param {Object} job
|
||||||
|
* @param {string} job.id
|
||||||
|
* @param {Array<{id:string}>} job.provider
|
||||||
|
* @param {Array<string>} [job.blacklist]
|
||||||
|
* @param {*} job.notificationAdapter
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function executeJob(job) {
|
||||||
|
if (isRunning(job.id)) {
|
||||||
|
logger.debug(`Job ${job.id} is already running. Skipping.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const acquired = markRunning(job.id);
|
||||||
|
if (!acquired) return;
|
||||||
|
// notify listeners (SSE) that the job started
|
||||||
|
try {
|
||||||
|
bus.emit('jobs:status', { jobId: job.id, running: true });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to emit start status for job', job.id, err);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const jobProviders = job.provider.filter(
|
||||||
|
(p) => providers.find((loaded) => loaded.metaInformation.id === p.id) != null,
|
||||||
|
);
|
||||||
|
const executions = jobProviders.map(async (prov) => {
|
||||||
|
const matchedProvider = providers.find((loaded) => loaded.metaInformation.id === prov.id);
|
||||||
|
matchedProvider.init(prov, job.blacklist);
|
||||||
|
await new FredyPipelineExecutioner(
|
||||||
|
matchedProvider.config,
|
||||||
|
job.notificationAdapter,
|
||||||
|
prov.id,
|
||||||
|
job.id,
|
||||||
|
similarityCache,
|
||||||
|
).execute();
|
||||||
|
});
|
||||||
|
const results = await Promise.allSettled(executions);
|
||||||
|
for (const r of results) {
|
||||||
|
if (r.status === 'rejected') {
|
||||||
|
logger.error(r.reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
markFinished(job.id);
|
||||||
|
try {
|
||||||
|
bus.emit('jobs:status', { jobId: job.id, running: false });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to emit finish status for job', job.id, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
lib/services/jobs/run-state.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple in-memory running state registry for jobs.
|
||||||
|
* Prevents concurrent execution of the same job within a single process.
|
||||||
|
* This registry is reset on process restart.
|
||||||
|
* @type {Set<string>}
|
||||||
|
*/
|
||||||
|
const running = new Set();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a job is currently marked as running.
|
||||||
|
* @param {string} jobId
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isRunning(jobId) {
|
||||||
|
return running.has(jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to mark a job as running.
|
||||||
|
* If it was already running, returns false and does not modify the set.
|
||||||
|
* @param {string} jobId
|
||||||
|
* @returns {boolean} true if the job was successfully marked as running
|
||||||
|
*/
|
||||||
|
export function markRunning(jobId) {
|
||||||
|
if (running.has(jobId)) return false;
|
||||||
|
running.add(jobId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a job as finished (remove from the running registry).
|
||||||
|
* @param {string} jobId
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function markFinished(jobId) {
|
||||||
|
running.delete(jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve all currently running job IDs.
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export function getRunningJobIds() {
|
||||||
|
return Array.from(running);
|
||||||
|
}
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import { deactivateListings, getActiveOrUnknownListings } from '../storage/listingsStorage.js';
|
import { deactivateListings, getActiveOrUnknownListings } from '../storage/listingsStorage.js';
|
||||||
import { getProviders } from '../../utils.js';
|
import { getProviders } from '../../utils.js';
|
||||||
import logger from '../../services/logger.js';
|
import logger from '../../services/logger.js';
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
import { randomBetween, sleep } from '../../utils.js';
|
import { randomBetween, sleep } from '../../utils.js';
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
const COLORS = {
|
const COLORS = {
|
||||||
debug: '\x1b[36m',
|
debug: '\x1b[36m',
|
||||||
info: '\x1b[32m',
|
info: '\x1b[32m',
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import markdown$0 from 'markdown';
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
const markdown = markdown$0.markdown;
|
|
||||||
export function markdown2Html(filePath) {
|
export function markdown2Html(filePath) {
|
||||||
return markdown.toHTML(fs.readFileSync(filePath, 'utf8'));
|
return fs.readFileSync(filePath, 'utf8');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import queryString from 'query-string';
|
import queryString from 'query-string';
|
||||||
export default (_url, sortByDateParam) => {
|
export default (_url, sortByDateParam) => {
|
||||||
//if no mutation is necessary, just return the original url
|
//if no mutation is necessary, just return the original url
|
||||||
|
|||||||
@@ -1,2 +1,7 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
export const hash = (x) => crypto.createHash('sha256').update(x, 'utf8').digest('hex');
|
export const hash = (x) => crypto.createHash('sha256').update(x, 'utf8').digest('hex');
|
||||||
|
|||||||
@@ -1,116 +1,99 @@
|
|||||||
import crypto from 'crypto';
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
const retention = 60 * 60 * 1000;
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
/**
|
|
||||||
* Internal cache storage.
|
|
||||||
* Maps a SHA-256 hash (string) to its expiry timestamp (number in ms).
|
|
||||||
* @type {Map<string, number>}
|
|
||||||
*/
|
*/
|
||||||
const entries = new Map();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reference to the currently scheduled cleanup timer.
|
* Similarity cache
|
||||||
* @type {NodeJS.Timeout | null}
|
|
||||||
*/
|
|
||||||
let timer = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a SHA-256 hash from a list of input strings.
|
|
||||||
* Null or undefined values are ignored.
|
|
||||||
*
|
*
|
||||||
* @param {...(string|null|undefined)} strings - Input values to hash
|
* Maintains an in-memory Set of content hashes to detect whether a listing
|
||||||
|
* (identified by a tuple of title, price and address) has been seen before.
|
||||||
|
*
|
||||||
|
* Design notes:
|
||||||
|
* - The cache is refreshed periodically from persistent storage. To avoid
|
||||||
|
* modification-during-iteration issues, the refresh builds a new Set and
|
||||||
|
* atomically swaps the reference instead of mutating in place.
|
||||||
|
* - Hashing ignores null/undefined values but preserves falsy-yet-valid values
|
||||||
|
* like 0. Non-string values are coerced to strings before hashing.
|
||||||
|
*
|
||||||
|
* This module has no persistence of its own; it relies on
|
||||||
|
* getAllEntriesFromListings() for data hydration.
|
||||||
|
* @module similarityCache
|
||||||
|
*/
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { getAllEntriesFromListings } from '../storage/listingsStorage.js';
|
||||||
|
|
||||||
|
/** @type {number} Refresh interval in milliseconds (defaults to one hour). */
|
||||||
|
const reloadCycle = 60 * 60 * 1000; // every hour, refresh
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal cache of content hashes for known listings.
|
||||||
|
*
|
||||||
|
* Each entry is an SHA-256 hex digest produced by toHash(title, price, address).
|
||||||
|
* @type {Set<string>}
|
||||||
|
*/
|
||||||
|
let cache = new Set();
|
||||||
|
|
||||||
|
export const startSimilarityCacheReloader = () => {
|
||||||
|
// Periodically refresh the cache from storage
|
||||||
|
setInterval(() => {
|
||||||
|
initSimilarityCache();
|
||||||
|
}, reloadCycle);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize or refresh the similarity cache from persistent storage.
|
||||||
|
*
|
||||||
|
* Reads all stored listings via getAllEntriesFromListings(), computes a hash for
|
||||||
|
* each, and swaps the in-memory Set atomically to avoid in-place mutations that
|
||||||
|
* could interfere with concurrent iteration.
|
||||||
|
*
|
||||||
|
* This function is idempotent and safe to call at any time.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export const initSimilarityCache = () => {
|
||||||
|
const allEntries = getAllEntriesFromListings();
|
||||||
|
const newCache = new Set();
|
||||||
|
for (const entry of allEntries) {
|
||||||
|
newCache.add(toHash(entry?.title, entry?.price, entry?.address));
|
||||||
|
}
|
||||||
|
// Atomic swap to avoid mutating the cache while it may be iterated elsewhere
|
||||||
|
cache = newCache;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a listing is already known and add it to the cache if not.
|
||||||
|
*
|
||||||
|
* The listing is identified by the combination of its title, price and
|
||||||
|
* address. Null/undefined fields are ignored during hashing. Falsy-but-valid
|
||||||
|
* values (e.g., price 0) are preserved.
|
||||||
|
*
|
||||||
|
* @param {Object} params - Listing fields
|
||||||
|
* @param {string|undefined|null} params.title - The listing title
|
||||||
|
* @param {string|undefined|null} params.address - The listing address
|
||||||
|
* @param {number|string|undefined|null} params.price - The listing price
|
||||||
|
* @returns {boolean} true if the entry already existed in the cache (duplicate), otherwise false
|
||||||
|
*/
|
||||||
|
export const checkAndAddEntry = ({ title, address, price }) => {
|
||||||
|
const hash = toHash(title, price, address);
|
||||||
|
if (cache.has(hash)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
cache.add(hash);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an SHA-256 hash from a list of input values.
|
||||||
|
* Null or undefined values are ignored. Falsy but valid values like 0 are preserved.
|
||||||
|
* Non-string values are coerced to strings prior to hashing.
|
||||||
|
*
|
||||||
|
* @param {...(string|number|null|undefined)} strings - Input values to hash
|
||||||
* @returns {string} Hexadecimal hash
|
* @returns {string} Hexadecimal hash
|
||||||
*/
|
*/
|
||||||
function toHash(...strings) {
|
function toHash(...strings) {
|
||||||
return crypto.createHash('sha256').update(strings.filter(Boolean).join('|')).digest('hex');
|
const normalized = strings
|
||||||
}
|
.filter((v) => v !== null && v !== undefined)
|
||||||
|
.map((v) => (typeof v === 'string' ? v : String(v)));
|
||||||
/**
|
return crypto.createHash('sha256').update(normalized.join('|')).digest('hex');
|
||||||
* Cleanup expired cache entries and schedule the next cleanup run.
|
|
||||||
* This function is invoked automatically by scheduled timers.
|
|
||||||
*
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function runCleanup() {
|
|
||||||
const now = Date.now();
|
|
||||||
for (const [hash, expiry] of entries) {
|
|
||||||
if (expiry <= now) entries.delete(hash);
|
|
||||||
}
|
|
||||||
scheduleNext();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the soonest expiry timestamp among all cache entries
|
|
||||||
* and schedule a one-shot timer that will trigger at that time.
|
|
||||||
* Cancels any existing timer before scheduling a new one.
|
|
||||||
*
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function scheduleNext() {
|
|
||||||
if (timer) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
timer = null;
|
|
||||||
}
|
|
||||||
let next = Infinity;
|
|
||||||
const now = Date.now();
|
|
||||||
for (const expiry of entries.values()) {
|
|
||||||
if (expiry > now && expiry < next) next = expiry;
|
|
||||||
}
|
|
||||||
if (next !== Infinity) {
|
|
||||||
timer = setTimeout(runCleanup, Math.max(0, next - now));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add or refresh a cache entry for the given title and address.
|
|
||||||
* The entry will automatically expire after the configured retention window.
|
|
||||||
*
|
|
||||||
* @param {string} title - The title used to build the cache key
|
|
||||||
* @param {string} address - The address used to build the cache key
|
|
||||||
*/
|
|
||||||
export function addCacheEntry(title, address) {
|
|
||||||
const hash = toHash(title, address);
|
|
||||||
const expiry = Date.now() + retention;
|
|
||||||
entries.set(hash, expiry);
|
|
||||||
scheduleNext();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a cache entry with the same title and address exists
|
|
||||||
* and is still valid (not expired).
|
|
||||||
*
|
|
||||||
* @param {string} title - The title used to build the cache key
|
|
||||||
* @param {string} address - The address used to build the cache key
|
|
||||||
* @returns {boolean} True if a valid cache entry exists, false otherwise
|
|
||||||
*/
|
|
||||||
export function hasSimilarEntries(title, address) {
|
|
||||||
const hash = toHash(title, address);
|
|
||||||
const expiry = entries.get(hash);
|
|
||||||
if (expiry == null) return false;
|
|
||||||
if (expiry <= Date.now()) {
|
|
||||||
entries.delete(hash);
|
|
||||||
scheduleNext();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop any scheduled cleanup timers and prevent further automatic cleanup.
|
|
||||||
* Entries that are already in the cache will remain until removed manually
|
|
||||||
* or until cleanup is started again by adding new entries.
|
|
||||||
*/
|
|
||||||
export function stopCacheCleanup() {
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
timer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* this is only for test purposes
|
|
||||||
*/
|
|
||||||
export function invalidateAllForTest() {
|
|
||||||
for (const key of entries.keys()) {
|
|
||||||
entries.set(key, 0);
|
|
||||||
}
|
|
||||||
runCleanup();
|
|
||||||
}
|
}
|
||||||
|
|||||||
108
lib/services/sse/sse-broker.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory SSE client registry.
|
||||||
|
* Maps a userId to a Set of Node.js ServerResponse objects representing open streams.
|
||||||
|
* @type {Map<string, Set<import('http').ServerResponse>>}
|
||||||
|
*/
|
||||||
|
const clients = new Map(); // Map<userId, Set<ServerResponse>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a single SSE event frame to a response.
|
||||||
|
*
|
||||||
|
* @param {import('http').ServerResponse} res - The open SSE HTTP response.
|
||||||
|
* @param {string} [event] - Optional event name (sent as `event:`). If omitted, a generic message is sent.
|
||||||
|
* @param {any} [data] - Optional payload. Objects are JSON.stringified.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function writeEvent(res, event, data) {
|
||||||
|
try {
|
||||||
|
if (event) {
|
||||||
|
res.write(`event: ${event}\n`);
|
||||||
|
}
|
||||||
|
if (data !== undefined) {
|
||||||
|
const payload = typeof data === 'string' ? data : JSON.stringify(data);
|
||||||
|
res.write(`data: ${payload}\n`);
|
||||||
|
}
|
||||||
|
res.write('\n');
|
||||||
|
} catch {
|
||||||
|
// ignore write errors here; cleanup happens on close
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new SSE client for the given user.
|
||||||
|
*
|
||||||
|
* @param {string} userId
|
||||||
|
* @param {import('http').ServerResponse} res
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function addClient(userId, res) {
|
||||||
|
let set = clients.get(userId);
|
||||||
|
if (!set) {
|
||||||
|
set = new Set();
|
||||||
|
clients.set(userId, set);
|
||||||
|
}
|
||||||
|
set.add(res);
|
||||||
|
// send a hello event
|
||||||
|
writeEvent(res, 'hello', { ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister a specific SSE client for a user. Removes the user entry when empty.
|
||||||
|
*
|
||||||
|
* @param {string} userId
|
||||||
|
* @param {import('http').ServerResponse} res
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function removeClient(userId, res) {
|
||||||
|
const set = clients.get(userId);
|
||||||
|
if (!set) return;
|
||||||
|
set.delete(res);
|
||||||
|
if (set.size === 0) clients.delete(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an SSE event to all open connections of a user.
|
||||||
|
*
|
||||||
|
* @param {string} userId
|
||||||
|
* @param {string} event
|
||||||
|
* @param {any} data
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function sendToUser(userId, event, data) {
|
||||||
|
const set = clients.get(userId);
|
||||||
|
if (!set) return;
|
||||||
|
for (const res of set) {
|
||||||
|
writeEvent(res, event, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast an SSE event to multiple users (unique by id).
|
||||||
|
*
|
||||||
|
* @param {string[]} userIds
|
||||||
|
* @param {string} event
|
||||||
|
* @param {any} data
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function sendToUsers(userIds, event, data) {
|
||||||
|
const unique = Array.from(new Set(userIds));
|
||||||
|
unique.forEach((id) => sendToUser(id, event, data));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heartbeat to keep connections alive on proxies (every 25s)
|
||||||
|
setInterval(() => {
|
||||||
|
for (const set of clients.values()) {
|
||||||
|
for (const res of set) {
|
||||||
|
try {
|
||||||
|
res.write(`: ping ${Date.now()}\n\n`);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 25000);
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import logger from '../../services/logger.js';
|
import logger from '../../services/logger.js';
|
||||||
import { config } from '../../utils.js';
|
import { readConfigFromStorage } from '../../utils.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SqliteConnection
|
* SqliteConnection
|
||||||
@@ -25,6 +30,15 @@ import { config } from '../../utils.js';
|
|||||||
class SqliteConnection {
|
class SqliteConnection {
|
||||||
static #db = null;
|
static #db = null;
|
||||||
|
|
||||||
|
static #sqlLiteCfg = null;
|
||||||
|
|
||||||
|
static async init() {
|
||||||
|
if (this.#sqlLiteCfg == null) {
|
||||||
|
readConfigFromStorage().then((c) => {
|
||||||
|
this.#sqlLiteCfg = c.sqlitepath;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Returns a singleton instance of better-sqlite3 Database.
|
* Returns a singleton instance of better-sqlite3 Database.
|
||||||
* Respects env var SQLITE_DB_PATH and defaults to db/listings.db.
|
* Respects env var SQLITE_DB_PATH and defaults to db/listings.db.
|
||||||
@@ -32,9 +46,12 @@ class SqliteConnection {
|
|||||||
static getConnection() {
|
static getConnection() {
|
||||||
if (this.#db) return this.#db;
|
if (this.#db) return this.#db;
|
||||||
|
|
||||||
|
if (this.#sqlLiteCfg == null) {
|
||||||
|
logger.warn('No sqlitepath configured. Using default db/listings.db');
|
||||||
|
}
|
||||||
|
|
||||||
// Interpret config.sqlitepath as a directory relative to project root when it starts with '/'
|
// Interpret config.sqlitepath as a directory relative to project root when it starts with '/'
|
||||||
const cfg = typeof config === 'object' && config ? config.sqlitepath : undefined;
|
const rawDir = this.#sqlLiteCfg && this.#sqlLiteCfg.length > 0 ? this.#sqlLiteCfg : '/db';
|
||||||
const rawDir = cfg && cfg.length > 0 ? cfg : '/db';
|
|
||||||
const relDir = rawDir.startsWith('/') ? rawDir.slice(1) : rawDir;
|
const relDir = rawDir.startsWith('/') ? rawDir.slice(1) : rawDir;
|
||||||
const absDir = path.isAbsolute(relDir) ? relDir : path.join(process.cwd(), relDir);
|
const absDir = path.isAbsolute(relDir) ? relDir : path.join(process.cwd(), relDir);
|
||||||
const dbPath = path.join(absDir, 'listings.db');
|
const dbPath = path.join(absDir, 'listings.db');
|
||||||
@@ -138,3 +155,21 @@ class SqliteConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default SqliteConnection;
|
export default SqliteConnection;
|
||||||
|
|
||||||
|
// Centralized DB path computation to avoid duplication across modules
|
||||||
|
// Returns: { dir, dbPath }
|
||||||
|
/**
|
||||||
|
* Compute the absolute SQLite database directory and file path based on configuration.
|
||||||
|
* Ensures the directory exists on disk.
|
||||||
|
* @returns {Promise<{dir:string, dbPath:string}>} Absolute directory and database file path.
|
||||||
|
*/
|
||||||
|
export async function computeDbPath() {
|
||||||
|
const cfg = await readConfigFromStorage();
|
||||||
|
const rawDir = cfg?.sqlitepath && cfg.sqlitepath.length > 0 ? cfg.sqlitepath : '/db';
|
||||||
|
const relDir = rawDir.startsWith('/') ? rawDir.slice(1) : rawDir;
|
||||||
|
const absDir = path.isAbsolute(relDir) ? relDir : path.join(process.cwd(), relDir);
|
||||||
|
const dbPath = path.join(absDir, 'listings.db');
|
||||||
|
const dir = path.dirname(dbPath);
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
return { dir: absDir, dbPath };
|
||||||
|
}
|
||||||
|
|||||||