Compare commits
104 Commits
improvemen
...
19.3.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc355fb5fe | ||
|
|
797421f0d5 | ||
|
|
0b2b42fc75 | ||
|
|
472169693f | ||
|
|
3117044139 | ||
|
|
7879d0e94a | ||
|
|
afd1048c9e | ||
|
|
acbaab05ed | ||
|
|
72fffc526b | ||
|
|
9e5989ece3 | ||
|
|
afc200c9e1 | ||
|
|
59226491f2 | ||
|
|
28f7760120 | ||
|
|
2465514b7a | ||
|
|
9dde377fe6 | ||
|
|
28a3a7f372 | ||
|
|
e859250545 | ||
|
|
4dd0370ec1 | ||
|
|
51b4e51f3f | ||
|
|
fa1899765c | ||
|
|
d43c5b3f97 | ||
|
|
7fd8be07a2 | ||
|
|
2926ee7e08 | ||
|
|
9506d1a9db | ||
|
|
feaa06c132 | ||
|
|
ad46500d4e | ||
|
|
3c209a8f97 | ||
|
|
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"]
|
|
||||||
|
|||||||
227
LICENSE
@@ -1,21 +1,214 @@
|
|||||||
MIT License
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
Copyright (c) 2025 Christian Kellner
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
1. Definitions.
|
||||||
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
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
copies or substantial portions of the Software.
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
the copyright owner that is granting the License.
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
other entities that control, are controlled by, or are under common
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
control with that entity. For the purposes of this definition,
|
||||||
SOFTWARE.
|
"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) 2026 Christian Kellner
|
||||||
|
Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
|||||||
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 Maps View | Dashboard | 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"}
|
||||||
52
copyright.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 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) {
|
||||||
|
const oldCopyrightRegex =
|
||||||
|
/^(\/\*\n \* Copyright \(c\) \d{4} by Christian Kellner\.\n \* Licensed under Apache-2.0 with Commons Clause and Attribution\/Naming Clause\n \*\/\n\n)+/;
|
||||||
|
for (let file of files) {
|
||||||
|
try {
|
||||||
|
let content = await fs.readFile(file, 'utf8');
|
||||||
|
const strippedContent = content.replace(oldCopyrightRegex, '');
|
||||||
|
const newContent = COPYRIGHT + strippedContent;
|
||||||
|
if (content !== newContent) {
|
||||||
|
await fs.writeFile(file, newContent);
|
||||||
|
console.log(`Added/Updated copyright in ${file}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error processing ${file}: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* 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: 3.7 MiB |
BIN
doc/screenshot2.png
Normal file
|
After Width: | Height: | Size: 4.8 MiB |
BIN
doc/screenshot3.png
Normal file
|
After Width: | Height: | Size: 531 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) 2026 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="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>
|
||||||
|
|
||||||
|
|||||||
88
index.js
@@ -1,88 +1,68 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 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 { 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 { initGeocodingCron } from './lib/services/crons/geocoding-cron.js';
|
||||||
|
import { getSettings } from './lib/services/storage/settingsStorage.js';
|
||||||
|
import SqliteConnection, { computeDbPath } from './lib/services/storage/SqliteConnection.js';
|
||||||
|
import { initJobExecutionService } from './lib/services/jobs/jobExecutionService.js';
|
||||||
|
|
||||||
|
//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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
||||||
|
initGeocodingCron();
|
||||||
|
|
||||||
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();
|
|
||||||
|
|||||||
283
lib/FredyPipelineExecutioner.js
Executable file
@@ -0,0 +1,283 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NoNewListingsWarning } from './errors.js';
|
||||||
|
import { storeListings, getKnownListingHashesForJobAndProvider } from './services/storage/listingsStorage.js';
|
||||||
|
import { getJob } from './services/storage/jobStorage.js';
|
||||||
|
import * as notify from './notification/notify.js';
|
||||||
|
import Extractor from './services/extractor/extractor.js';
|
||||||
|
import urlModifier from './services/queryStringMutator.js';
|
||||||
|
import logger from './services/logger.js';
|
||||||
|
import { geocodeAddress } from './services/geocoding/geoCodingService.js';
|
||||||
|
import { distanceMeters } from './services/listings/distanceCalculator.js';
|
||||||
|
import { getUserSettings } from './services/storage/settingsStorage.js';
|
||||||
|
import { updateListingDistance } from './services/storage/listingsStorage.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._geocode.bind(this))
|
||||||
|
.then(this._save.bind(this))
|
||||||
|
.then(this._calculateDistance.bind(this))
|
||||||
|
.then(this._filterBySimilarListings.bind(this))
|
||||||
|
.then(this._notify.bind(this))
|
||||||
|
.catch(this._handleError.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geocode new listings.
|
||||||
|
*
|
||||||
|
* @param {Listing[]} newListings New listings to geocode.
|
||||||
|
* @returns {Promise<Listing[]>} Resolves with the listings (potentially with added coordinates).
|
||||||
|
*/
|
||||||
|
async _geocode(newListings) {
|
||||||
|
for (const listing of newListings) {
|
||||||
|
if (listing.address) {
|
||||||
|
const coords = await geocodeAddress(listing.address);
|
||||||
|
if (coords) {
|
||||||
|
listing.latitude = coords.lat;
|
||||||
|
listing.longitude = coords.lng;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newListings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate distance for new listings.
|
||||||
|
*
|
||||||
|
* @param {Listing[]} listings
|
||||||
|
* @returns {Listing[]}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_calculateDistance(listings) {
|
||||||
|
if (listings.length === 0) return [];
|
||||||
|
|
||||||
|
const job = getJob(this._jobKey);
|
||||||
|
const userId = job?.userId;
|
||||||
|
|
||||||
|
if (userId == null || typeof userId !== 'string') {
|
||||||
|
logger.debug('Skipping distance calculation: userId is missing or invalid');
|
||||||
|
return listings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userSettings = getUserSettings(userId);
|
||||||
|
const homeAddress = userSettings?.home_address;
|
||||||
|
|
||||||
|
if (!homeAddress || !homeAddress.coords) {
|
||||||
|
return listings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { lat, lng } = homeAddress.coords;
|
||||||
|
for (const listing of listings) {
|
||||||
|
if (listing.latitude != null && listing.longitude != null) {
|
||||||
|
const dist = distanceMeters(lat, lng, listing.latitude, listing.longitude);
|
||||||
|
updateListingDistance(listing.id, dist);
|
||||||
|
listing.distance_to_destination = dist;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return listings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove listings that are similar to already known entries according to the similarity cache.
|
||||||
|
* Adds the remaining listings to the cache.
|
||||||
|
*
|
||||||
|
* @param {Listing[]} listings Listings to filter by similarity.
|
||||||
|
* @returns {Listing[]} Listings considered unique enough to keep.
|
||||||
|
*/
|
||||||
|
_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,17 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import { notificationAdapterRouter } from './routes/notificationAdapterRouter.js';
|
import { 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 { userSettingsRouter } from './routes/userSettingsRoute.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 +20,12 @@ 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 { 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/user/settings', 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/user/settings', userSettingsRouter);
|
||||||
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/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) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import restana from 'restana';
|
||||||
|
import {
|
||||||
|
buildBackupFileName,
|
||||||
|
createBackupZip,
|
||||||
|
precheckRestore,
|
||||||
|
restoreFromZip,
|
||||||
|
} from '../../services/storage/backupRestoreService.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import restana from 'restana';
|
||||||
|
import * as jobStorage from '../../services/storage/jobStorage.js';
|
||||||
|
import * as userStorage from '../../services/storage/userStorage.js';
|
||||||
|
import { getListingsKpisForJobIds, getProviderDistributionForJobIds } from '../../services/storage/listingsStorage.js';
|
||||||
|
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||||
|
|
||||||
|
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) 2026 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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,35 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 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,14 +1,23 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 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';
|
||||||
|
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||||
|
|
||||||
const service = restana();
|
const service = restana();
|
||||||
const jobRouter = service.newRouter();
|
const jobRouter = service.newRouter();
|
||||||
|
|
||||||
|
const DEMO_JOB_NAME = 'Demo-Job';
|
||||||
|
|
||||||
function doesJobBelongsToUser(job, req) {
|
function doesJobBelongsToUser(job, req) {
|
||||||
const userId = req.session.currentUser;
|
const userId = req.session.currentUser;
|
||||||
if (userId == null) {
|
if (userId == null) {
|
||||||
@@ -24,25 +33,151 @@ 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 = {
|
jobRouter.get('/data', async (req, res) => {
|
||||||
interval: config.interval,
|
const { page, pageSize = 50, activityFilter, sortfield = null, sortdir = 'asc', freeTextFilter } = req.query || {};
|
||||||
lastRun: config.lastRun || null,
|
|
||||||
|
// normalize booleans
|
||||||
|
const toBool = (v) => {
|
||||||
|
if (v === true || v === 'true' || v === 1 || v === '1') return true;
|
||||||
|
if (v === false || v === 'false' || v === 0 || v === '0') return false;
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
const normalizedActivity = toBool(activityFilter);
|
||||||
|
|
||||||
|
const queryResult = jobStorage.queryJobs({
|
||||||
|
page: page ? parseInt(page, 10) : 1,
|
||||||
|
pageSize: pageSize ? parseInt(pageSize, 10) : 50,
|
||||||
|
freeTextFilter: freeTextFilter || null,
|
||||||
|
activityFilter: normalizedActivity,
|
||||||
|
sortField: sortfield || null,
|
||||||
|
sortDir: sortdir === 'desc' ? 'desc' : 'asc',
|
||||||
|
userId: req.session.currentUser,
|
||||||
|
isAdmin: isAdmin(req),
|
||||||
|
});
|
||||||
|
|
||||||
|
const isUserAdmin = isAdmin(req);
|
||||||
|
|
||||||
|
// Map result to include runtime status
|
||||||
|
queryResult.result = queryResult.result.map((job) => {
|
||||||
|
return {
|
||||||
|
...job,
|
||||||
|
running: isJobRunning(job.id),
|
||||||
|
isOnlyShared:
|
||||||
|
!isUserAdmin &&
|
||||||
|
job.userId !== req.session.currentUser &&
|
||||||
|
job.shared_with_user.includes(req.session.currentUser),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.body = queryResult;
|
||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Server-Sent Events for job status updates
|
||||||
|
jobRouter.get('/events', async (req, res) => {
|
||||||
|
const userId = req.session.currentUser;
|
||||||
|
if (userId == null) {
|
||||||
|
res.send({ message: 'Unauthorized' }, 401);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// SSE headers
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
try {
|
||||||
|
// Initial comment to establish stream
|
||||||
|
res.write(': connected\n\n');
|
||||||
|
addSseClient(userId, res);
|
||||||
|
// Cleanup on close/aborted
|
||||||
|
const onClose = () => removeClient(userId, res);
|
||||||
|
// restana exposes original req/res; use both close and finish
|
||||||
|
req.on('close', onClose);
|
||||||
|
req.on('aborted', onClose);
|
||||||
|
res.on('close', onClose);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Error establishing SSE connection', e);
|
||||||
|
try {
|
||||||
|
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;
|
||||||
|
const settings = await getSettings();
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.demoMode && jobFromDb && jobFromDb.name === DEMO_JOB_NAME) {
|
||||||
|
res.send(new Error('Sorry, but you cannot change the Status of our Demo Job ;)'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
jobStorage.upsertJob({
|
jobStorage.upsertJob({
|
||||||
userId: req.session.currentUser,
|
userId: req.session.currentUser,
|
||||||
jobId,
|
jobId,
|
||||||
@@ -51,6 +186,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,10 +194,17 @@ 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;
|
||||||
|
const settings = await getSettings();
|
||||||
try {
|
try {
|
||||||
const job = jobStorage.getJob(jobId);
|
const job = jobStorage.getJob(jobId);
|
||||||
|
if (settings.demoMode && job.name === DEMO_JOB_NAME) {
|
||||||
|
res.send(new Error('Sorry, but you cannot remove the Demo Job ;)'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!doesJobBelongsToUser(job, req)) {
|
if (!doesJobBelongsToUser(job, req)) {
|
||||||
res.send(new Error('You are trying to remove a job that is not associated to your user'));
|
res.send(new Error('You are trying to remove a job that is not associated to your user'));
|
||||||
} else {
|
} else {
|
||||||
@@ -76,8 +219,15 @@ jobRouter.delete('', async (req, res) => {
|
|||||||
jobRouter.put('/:jobId/status', async (req, res) => {
|
jobRouter.put('/:jobId/status', async (req, res) => {
|
||||||
const { status } = req.body;
|
const { status } = req.body;
|
||||||
const { jobId } = req.params;
|
const { jobId } = req.params;
|
||||||
|
const settings = await getSettings();
|
||||||
try {
|
try {
|
||||||
const job = jobStorage.getJob(jobId);
|
const job = jobStorage.getJob(jobId);
|
||||||
|
|
||||||
|
if (settings.demoMode && job.name === DEMO_JOB_NAME) {
|
||||||
|
res.send(new Error('Sorry, but you cannot change the Status of our Demo Job ;)'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!doesJobBelongsToUser(job, req)) {
|
if (!doesJobBelongsToUser(job, req)) {
|
||||||
res.send(new Error('You are trying change a job that is not associated to your user'));
|
res.send(new Error('You are trying change a job that is not associated to your user'));
|
||||||
} else {
|
} else {
|
||||||
@@ -92,4 +242,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,139 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 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';
|
||||||
|
import { getSettings } from '../../services/storage/settingsStorage.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' for true; false, 'false', 0, '0' for false)
|
||||||
|
const toBool = (v) => {
|
||||||
|
if (v === true || v === 'true' || v === 1 || v === '1') return true;
|
||||||
|
if (v === false || v === 'false' || v === 0 || v === '0') return false;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const normalizedActivity = toBool(activityFilter);
|
||||||
|
const normalizedWatch = toBool(watchListFilter);
|
||||||
|
|
||||||
|
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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
listingsRouter.get('/map', async (req, res) => {
|
||||||
|
const { jobId } = req.query || {};
|
||||||
|
|
||||||
|
res.body = listingStorage.getListingsForMap({
|
||||||
|
jobId: nullOrEmpty(jobId) ? null : jobId,
|
||||||
|
userId: req.session.currentUser,
|
||||||
|
isAdmin: isAdminFn(req),
|
||||||
|
});
|
||||||
|
res.send();
|
||||||
|
});
|
||||||
|
|
||||||
|
listingsRouter.get('/:listingId', async (req, res) => {
|
||||||
|
const { listingId } = req.params;
|
||||||
|
const listing = listingStorage.getListingById(listingId, req.session.currentUser, isAdminFn(req));
|
||||||
|
if (!listing) {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.body = { message: 'Listing not found' };
|
||||||
|
return res.send();
|
||||||
|
}
|
||||||
|
res.body = listing;
|
||||||
|
res.send();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle watch state for the current user on a listing
|
||||||
|
listingsRouter.post('/watch', async (req, res) => {
|
||||||
|
try {
|
||||||
|
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;
|
||||||
|
const settings = await getSettings();
|
||||||
|
try {
|
||||||
|
if (settings.demoMode) {
|
||||||
|
res.send(new Error('Sorry, but you cannot remove listings in demo mode ;)'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) 2026 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) 2026 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) 2026 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) 2026 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;
|
||||||
}
|
}
|
||||||
|
|||||||
79
lib/api/routes/userSettingsRoute.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import restana from 'restana';
|
||||||
|
import SqliteConnection from '../../services/storage/SqliteConnection.js';
|
||||||
|
import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
|
||||||
|
import { resetGeocoordinatesAndDistanceForUser } from '../../services/storage/listingsStorage.js';
|
||||||
|
import { geocodeAddress } from '../../services/geocoding/geoCodingService.js';
|
||||||
|
import { autocompleteAddress } from '../../services/geocoding/autocompleteService.js';
|
||||||
|
import { fromJson } from '../../utils.js';
|
||||||
|
import { trackFeature } from '../../services/tracking/Tracker.js';
|
||||||
|
import { FEATURES } from '../../features.js';
|
||||||
|
import logger from '../../services/logger.js';
|
||||||
|
import { runGeoCordTask } from '../../services/crons/geocoding-cron.js';
|
||||||
|
|
||||||
|
const service = restana();
|
||||||
|
const userSettingsRouter = service.newRouter();
|
||||||
|
|
||||||
|
userSettingsRouter.get('/', async (req, res) => {
|
||||||
|
const userId = req.session.currentUser;
|
||||||
|
const rows = SqliteConnection.query('SELECT name, value FROM settings WHERE user_id = @userId', { userId });
|
||||||
|
const settings = {};
|
||||||
|
for (const r of rows) {
|
||||||
|
settings[r.name] = fromJson(r.value, null);
|
||||||
|
}
|
||||||
|
res.body = settings;
|
||||||
|
res.send();
|
||||||
|
});
|
||||||
|
|
||||||
|
userSettingsRouter.get('/autocomplete', async (req, res) => {
|
||||||
|
const { q } = req.query;
|
||||||
|
try {
|
||||||
|
const results = await autocompleteAddress(q);
|
||||||
|
res.body = results;
|
||||||
|
res.send();
|
||||||
|
} catch (error) {
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
userSettingsRouter.post('/home-address', async (req, res) => {
|
||||||
|
const userId = req.session.currentUser;
|
||||||
|
const { home_address } = req.body;
|
||||||
|
const settings = await getSettings();
|
||||||
|
|
||||||
|
if (settings.demoMode) {
|
||||||
|
res.send(new Error('In demo mode, it is not allowed to change the home address.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (home_address) {
|
||||||
|
await trackFeature(FEATURES.DISTANCE_ADDRESS_ENTERED);
|
||||||
|
const coords = await geocodeAddress(home_address);
|
||||||
|
if (coords && coords.lat !== -1) {
|
||||||
|
upsertSettings({ home_address: { address: home_address, coords } }, userId);
|
||||||
|
resetGeocoordinatesAndDistanceForUser(userId);
|
||||||
|
//we do NOT wait for this to finish, as we don't want to block the response
|
||||||
|
runGeoCordTask();
|
||||||
|
res.send({ success: true, coords });
|
||||||
|
} else {
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.send({ error: 'Could not geocode address' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
upsertSettings({ home_address: null }, userId);
|
||||||
|
res.send({ success: true });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating home address settings', error);
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { userSettingsRouter };
|
||||||
@@ -1,13 +1,26 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 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) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import * as userStorage from '../services/storage/userStorage.js';
|
import * 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) 2026 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) 2026 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);
|
||||||
|
|||||||
8
lib/features.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const FEATURES = {
|
||||||
|
DISTANCE_ADDRESS_ENTERED: 'DISTANCE_ADDRESS_ENTERED',
|
||||||
|
};
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import { markdown2Html } from '../../services/markdown.js';
|
import { 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) 2026 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) 2026 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) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
|
|
||||||
|
const mapListing = (listing) => ({
|
||||||
|
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) 2026 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) 2026 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) 2026 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) 2026 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) 2026 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) 2026 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) 2026 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) 2026 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) 2026 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) 2026 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) 2026 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) 2026 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) 2026 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,9 +1,14 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 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.
|
||||||
*
|
*
|
||||||
* The mobile API provides the following endpoints:
|
* The mobile API provides the following endpoints:
|
||||||
* - GET /search/total?{search parameters}: Returns the total number of listings for the given query
|
* - GET /search/total?{search parameters}: Returns the total number of listings for the given query
|
||||||
* Example: `curl -H "User-Agent: ImmoScout_27.3_26.0_._" https://api.mobile.immobilienscout24.de/search/total?searchType=region&realestatetype=apartmentrent&pricetype=calculatedtotalrent&geocodes=%2Fde%2Fberlin%2Fberlin `
|
* Example: `curl -H "User-Agent: ImmoScout_27.12_26.2_._" https://api.mobile.immobilienscout24.de/search/total?searchType=region&realestatetype=apartmentrent&pricetype=calculatedtotalrent&geocodes=%2Fde%2Fberlin%2Fberlin `
|
||||||
*
|
*
|
||||||
* - POST /search/list?{search parameters}: Actually retrieves the listings. Body is json encoded and contains
|
* - POST /search/list?{search parameters}: Actually retrieves the listings. Body is json encoded and contains
|
||||||
* data specifying additional results (advertisements) to return. The format is as follows:
|
* data specifying additional results (advertisements) to return. The format is as follows:
|
||||||
@@ -15,12 +20,12 @@
|
|||||||
* ```
|
* ```
|
||||||
* It is not necessary to provide data for the specified keys.
|
* It is not necessary to provide data for the specified keys.
|
||||||
*
|
*
|
||||||
* Example: `curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region&geocodes=%2Fde%2Fberlin%2Fberlin&pagenumber=1' -H "Connection: keep-alive" -H "User-Agent: ImmoScout_27.3_26.0_._" -H "Accept: application/json" -H "Content-Type: application/json" -d '{"supportedResultListType": [], "userData": {}}'`
|
* Example: `curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region&geocodes=%2Fde%2Fberlin%2Fberlin&pagenumber=1' -H "Connection: keep-alive" -H "User-Agent: ImmoScout_27.12_26.2_._" -H "Accept: application/json" -H "Content-Type: application/json" -d '{"supportedResultListType": [], "userData": {}}'`
|
||||||
|
|
||||||
* - GET /expose/{id} - Returns the details of a listing. The response contains additional details not included in the
|
* - GET /expose/{id} - Returns the details of a listing. The response contains additional details not included in the
|
||||||
* listing response.
|
* listing response.
|
||||||
*
|
*
|
||||||
* Example: `curl -H "User-Agent: ImmoScout_27.3_26.0_._" "https://api.mobile.immobilienscout24.de/expose/158382494"`
|
* Example: `curl -H "User-Agent: ImmoScout_27.12_26.2_._" "https://api.mobile.immobilienscout24.de/expose/158382494"`
|
||||||
*
|
*
|
||||||
*
|
*
|
||||||
* It is necessary to set the correct User Agent (see `getListings`) in the request header.
|
* It is necessary to set the correct User Agent (see `getListings`) in the request header.
|
||||||
@@ -47,7 +52,7 @@ async function getListings(url) {
|
|||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'ImmoScout_27.3_26.0_._',
|
'User-Agent': 'ImmoScout_27.12_26.2_._',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -83,7 +88,7 @@ async function getListings(url) {
|
|||||||
async function isListingActive(link) {
|
async function isListingActive(link) {
|
||||||
const result = await fetch(convertImmoscoutListingToMobileListing(link), {
|
const result = await fetch(convertImmoscoutListingToMobileListing(link), {
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'ImmoScout_27.3_26.0_._',
|
'User-Agent': 'ImmoScout_27.12_26.2_._',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import { isOneOf, buildHash } from '../utils.js';
|
import { 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) 2026 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) 2026 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) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
function normalize(o) {
|
||||||
|
const 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) 2026 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) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
function normalize(o) {
|
||||||
|
const 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',
|
||||||
|
id: 'ohneMakler',
|
||||||
|
};
|
||||||
|
export { config };
|
||||||
54
lib/provider/regionalimmobilien24.js
Executable file
@@ -0,0 +1,54 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
function normalize(o) {
|
||||||
|
const 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) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isOneOf, buildHash } from '../utils.js';
|
||||||
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
function normalize(o) {
|
||||||
|
const 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: (url) => checkIfListingIsActive(url, 'Angebot nicht gefunden'),
|
||||||
|
};
|
||||||
|
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) 2026 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';
|
||||||
|
|
||||||
|
|||||||
58
lib/provider/wohnungsboerse.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as utils from '../utils.js';
|
||||||
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
|
||||||
|
let appliedBlackList = [];
|
||||||
|
|
||||||
|
function normalize(o) {
|
||||||
|
const id = o.link.split('/').pop();
|
||||||
|
const price = o.price;
|
||||||
|
const size = o.size;
|
||||||
|
const rooms = o.rooms;
|
||||||
|
const [city = '', part = ''] = (o.description || '').split('-').map((v) => v.trim());
|
||||||
|
const address = `${part}, ${city}`;
|
||||||
|
return Object.assign(o, { id, price, size, rooms, address });
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBlacklist(o) {
|
||||||
|
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
||||||
|
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
||||||
|
return o.id != null && o.title != null && titleNotBlacklisted && descNotBlacklisted && o.link.startsWith(o.link);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
url: null,
|
||||||
|
sortByDateParam: null,
|
||||||
|
waitForSelector: 'body',
|
||||||
|
crawlContainer: '.search_result_container > a',
|
||||||
|
crawlFields: {
|
||||||
|
id: '*',
|
||||||
|
title: 'h3 | trim',
|
||||||
|
price: 'dl:nth-of-type(1) dd | removeNewline | trim',
|
||||||
|
rooms: 'dl:nth-of-type(2) dd | removeNewline | trim',
|
||||||
|
size: 'dl:nth-of-type(3) dd | removeNewline | trim',
|
||||||
|
description: 'div.before\\:icon-location_marker | trim',
|
||||||
|
link: '@href',
|
||||||
|
imageUrl: 'img@src',
|
||||||
|
},
|
||||||
|
normalize: normalize,
|
||||||
|
filter: applyBlacklist,
|
||||||
|
activeTester: checkIfListingIsActive,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const init = (sourceConfig, blacklistTerms) => {
|
||||||
|
config.url = sourceConfig.url;
|
||||||
|
appliedBlackList = blacklistTerms || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const metaInformation = {
|
||||||
|
name: 'Wohnungsboerse',
|
||||||
|
baseUrl: 'https://www.wohnungsboerse.net',
|
||||||
|
id: 'wohnungsboerse',
|
||||||
|
};
|
||||||
|
|
||||||
|
export { config };
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { removeJobsByUserId } from '../storage/jobStorage.js';
|
|
||||||
import { config } from '../../utils.js';
|
|
||||||
import { getUsers } from '../storage/userStorage.js';
|
|
||||||
import logger from '../logger.js';
|
|
||||||
import cron from 'node-cron';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* if we are running in demo environment, we have to cleanup the db files (specifically the jobs table)
|
|
||||||
*/
|
|
||||||
export function cleanupDemoAtMidnight() {
|
|
||||||
cron.schedule('0 0 * * *', cleanup);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanup() {
|
|
||||||
if (config.demoMode) {
|
|
||||||
const demoUser = getUsers(false).find((user) => user.username === 'demo');
|
|
||||||
if (demoUser == null) {
|
|
||||||
logger.error('Demo user not found, cannot remove Jobs');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
removeJobsByUserId(demoUser.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
39
lib/services/crons/geocoding-cron.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import cron from 'node-cron';
|
||||||
|
import { getListingsToGeocode, updateListingGeocoordinates } from '../storage/listingsStorage.js';
|
||||||
|
import { geocodeAddress, isGeocodingPaused } from '../geocoding/geoCodingService.js';
|
||||||
|
import { getJobs } from '../storage/jobStorage.js';
|
||||||
|
import { calculateDistanceForJob } from '../geocoding/distanceService.js';
|
||||||
|
|
||||||
|
export async function runGeoCordTask() {
|
||||||
|
const listings = getListingsToGeocode();
|
||||||
|
if (listings.length > 0) {
|
||||||
|
for (const listing of listings) {
|
||||||
|
if (isGeocodingPaused()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coords = await geocodeAddress(listing.address);
|
||||||
|
if (coords) {
|
||||||
|
updateListingGeocoordinates(listing.id, coords.lat, coords.lng);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//additional run
|
||||||
|
const jobs = getJobs();
|
||||||
|
for (const job of jobs) {
|
||||||
|
calculateDistanceForJob(job.id, job.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initGeocodingCron() {
|
||||||
|
// run directly on start
|
||||||
|
await runGeoCordTask();
|
||||||
|
// then every 6 hours
|
||||||
|
cron.schedule('0 */6 * * *', runGeoCordTask);
|
||||||
|
}
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 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) 2026 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) 2026 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) 2026 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) 2026 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) 2026 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) 2026 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,11 @@ 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);
|
if (error?.message?.includes('Timeout')) {
|
||||||
|
logger.debug('Error executing with puppeteer executor', error);
|
||||||
|
} else {
|
||||||
|
logger.warn('Error executing with puppeteer executor', error);
|
||||||
|
}
|
||||||
result = null;
|
result = null;
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 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;
|
||||||
|
|||||||
26
lib/services/geocoding/autocompleteService.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { autocomplete as nominatimAutocomplete } from './client/nominatimClient.js';
|
||||||
|
import logger from '../logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Autocompletes an address using Nominatim.
|
||||||
|
*
|
||||||
|
* @param {string} query - The search query.
|
||||||
|
* @returns {Promise<string[]>} List of matching addresses.
|
||||||
|
*/
|
||||||
|
export async function autocompleteAddress(query) {
|
||||||
|
if (!query) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await nominatimAutocomplete(query);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error during address autocomplete:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
152
lib/services/geocoding/client/nominatimClient.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import os from 'os';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import https from 'https';
|
||||||
|
import fetch from 'node-fetch';
|
||||||
|
import pThrottle from 'p-throttle';
|
||||||
|
import logger from '../../logger.js';
|
||||||
|
|
||||||
|
const API_URL = 'https://nominatim.openstreetmap.org/search';
|
||||||
|
|
||||||
|
const agent = new https.Agent({
|
||||||
|
keepAlive: true,
|
||||||
|
keepAliveMsecs: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const throttle = pThrottle({
|
||||||
|
limit: 1,
|
||||||
|
interval: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
function computeMachineId() {
|
||||||
|
const hostname = os.hostname() || 'unknown-host';
|
||||||
|
const nets = os.networkInterfaces?.() || {};
|
||||||
|
const macs = [];
|
||||||
|
|
||||||
|
for (const ifname of Object.keys(nets)) {
|
||||||
|
for (const addr of nets[ifname] || []) {
|
||||||
|
if (!addr) continue;
|
||||||
|
if (addr.internal) continue;
|
||||||
|
if (addr.mac && addr.mac !== '00:00:00:00:00:00') macs.push(addr.mac);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
macs.sort();
|
||||||
|
|
||||||
|
const raw = [hostname, os.platform(), os.arch(), ...macs].join('|');
|
||||||
|
|
||||||
|
return crypto.createHash('sha256').update(raw).digest('hex').slice(0, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nominatim requires a specific User-Agent.
|
||||||
|
* Since Fredy is self-hosted, we use a unique machine ID to make it specific.
|
||||||
|
*/
|
||||||
|
const userAgent = `Fredy-Self-Hosted (${computeMachineId()}; https://github.com/orangecoding/fredy)`;
|
||||||
|
|
||||||
|
let last429 = 0;
|
||||||
|
const PAUSE_DURATION = 3600000; // 1 hour
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geocodes an address using Nominatim.
|
||||||
|
*
|
||||||
|
* @param {string} address - The address to geocode.
|
||||||
|
* @returns {Promise<{lat: number, lng: number}|null>} The geocoordinates or null if error. {lat: -1, lng: -1} if not found.
|
||||||
|
*/
|
||||||
|
async function doGeocode(address) {
|
||||||
|
if (Date.now() - last429 < PAUSE_DURATION) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${API_URL}?q=${encodeURIComponent(address)}&format=json&countrycodes=de`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
agent,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': userAgent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
logger.warn('Nominatim rate limit hit. Pausing for 1 hour.');
|
||||||
|
last429 = Date.now();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.error(`Nominatim API error: ${response.status} ${response.statusText}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (Array.isArray(data) && data.length > 0) {
|
||||||
|
const result = data[0];
|
||||||
|
return {
|
||||||
|
lat: parseFloat(result.lat),
|
||||||
|
lng: parseFloat(result.lon),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { lat: -1, lng: -1 };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error during Nominatim geocoding:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Autocompletes an address using Nominatim.
|
||||||
|
*
|
||||||
|
* @param {string} query - The search query.
|
||||||
|
* @returns {Promise<string[]>} List of matching addresses.
|
||||||
|
*/
|
||||||
|
async function doAutocomplete(query) {
|
||||||
|
if (Date.now() - last429 < PAUSE_DURATION) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${API_URL}?q=${encodeURIComponent(query)}&format=json&addressdetails=1&limit=5&countrycodes=de`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
agent,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': userAgent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
logger.warn('Nominatim rate limit hit. Pausing for 1 hour.');
|
||||||
|
last429 = Date.now();
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.error(`Nominatim API error: ${response.status} ${response.statusText}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return data.map((item) => item.display_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error during Nominatim autocomplete:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const geocode = throttle(doGeocode);
|
||||||
|
|
||||||
|
export const autocomplete = throttle(doAutocomplete);
|
||||||
|
|
||||||
|
export const isPaused = () => Date.now() - last429 < PAUSE_DURATION;
|
||||||
61
lib/services/geocoding/distanceService.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { distanceMeters } from '../listings/distanceCalculator.js';
|
||||||
|
import {
|
||||||
|
getListingsToCalculateDistance,
|
||||||
|
getListingsForUserToCalculateDistance,
|
||||||
|
updateListingDistance,
|
||||||
|
} from '../storage/listingsStorage.js';
|
||||||
|
import { getUserSettings } from '../storage/settingsStorage.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates and updates distances for listings of a specific job.
|
||||||
|
* Only processes listings where distance_to_destination is null.
|
||||||
|
*
|
||||||
|
* @param {string} jobId
|
||||||
|
* @param {string} userId
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function calculateDistanceForJob(jobId, userId) {
|
||||||
|
const userSettings = getUserSettings(userId);
|
||||||
|
const homeAddress = userSettings.home_address;
|
||||||
|
|
||||||
|
if (!homeAddress || !homeAddress.coords) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const listings = getListingsToCalculateDistance(jobId);
|
||||||
|
const { lat, lng } = homeAddress.coords;
|
||||||
|
|
||||||
|
for (const listing of listings) {
|
||||||
|
const dist = distanceMeters(lat, lng, listing.latitude, listing.longitude);
|
||||||
|
updateListingDistance(listing.id, dist);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates and updates distances for all active listings of a user.
|
||||||
|
* Usually called when the user updates their home address.
|
||||||
|
*
|
||||||
|
* @param {string} userId
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function calculateDistanceForUser(userId) {
|
||||||
|
const userSettings = getUserSettings(userId);
|
||||||
|
const homeAddress = userSettings.home_address;
|
||||||
|
|
||||||
|
if (!homeAddress || !homeAddress.coords) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const listings = getListingsForUserToCalculateDistance(userId);
|
||||||
|
const { lat, lng } = homeAddress.coords;
|
||||||
|
|
||||||
|
for (const listing of listings) {
|
||||||
|
const dist = distanceMeters(lat, lng, listing.latitude, listing.longitude);
|
||||||
|
updateListingDistance(listing.id, dist);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
lib/services/geocoding/geoCodingService.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getGeocoordinatesByAddress } from '../storage/listingsStorage.js';
|
||||||
|
import { geocode as nominatimGeocode, isPaused as isNominatimPaused } from './client/nominatimClient.js';
|
||||||
|
import logger from '../logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geocodes an address using Nominatim or cached results from the database.
|
||||||
|
*
|
||||||
|
* @param {string} address - The address to geocode.
|
||||||
|
* @returns {Promise<{lat: number, lng: number}|null>} The geocoordinates or null if error. {lat: -1, lng: -1} if not found.
|
||||||
|
*/
|
||||||
|
export async function geocodeAddress(address) {
|
||||||
|
if (!address) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Check if we already have this address geocoded in our database
|
||||||
|
const cachedCoordinates = getGeocoordinatesByAddress(address);
|
||||||
|
if (cachedCoordinates) {
|
||||||
|
logger.debug(`Found cached geocoordinates for address: ${address}`);
|
||||||
|
return cachedCoordinates;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. If not, use Nominatim
|
||||||
|
return await nominatimGeocode(address);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error during geocoding:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if we are currently in a rate limit pause.
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isGeocodingPaused() {
|
||||||
|
return isNominatimPaused();
|
||||||
|
}
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Rent a flat
|
Rent a flat
|
||||||
Web:
|
Web:
|
||||||
@@ -98,13 +103,17 @@ const REAL_ESTATE_TYPE = {
|
|||||||
'haus-mieten': 'houserent',
|
'haus-mieten': 'houserent',
|
||||||
'wohnung-mieten': 'apartmentrent',
|
'wohnung-mieten': 'apartmentrent',
|
||||||
'wohnung-kaufen': 'apartmentbuy',
|
'wohnung-kaufen': 'apartmentbuy',
|
||||||
|
'wohnung-kaufen-mit-balkon': 'apartmentbuy',
|
||||||
|
'eigentumswohnung-mit-garten': 'apartmentbuy',
|
||||||
'haus-kaufen': 'housebuy',
|
'haus-kaufen': 'housebuy',
|
||||||
};
|
};
|
||||||
|
|
||||||
const WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP = {
|
const WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP = {
|
||||||
// Category "Balkon/Terrasse"
|
// Category "Balkon/Terrasse"
|
||||||
'wohnung-mit-balkon-mieten': { equipment: ['balcony'] },
|
'wohnung-mit-balkon-mieten': { equipment: ['balcony'] },
|
||||||
|
'wohnung-kaufen-mit-balkon': { equipment: ['balcony'] },
|
||||||
'wohnung-mit-garten-mieten': { equipment: ['garden'] },
|
'wohnung-mit-garten-mieten': { equipment: ['garden'] },
|
||||||
|
'eigentumswohnung-mit-garten': { equipment: ['garden'] },
|
||||||
// Category "Wohnungstyp"
|
// Category "Wohnungstyp"
|
||||||
'souterrainwohnung-mieten': { apartmenttypes: ['halfbasement'] },
|
'souterrainwohnung-mieten': { apartmenttypes: ['halfbasement'] },
|
||||||
'erdgeschosswohnung-mieten': { apartmenttypes: ['groundfloor'] },
|
'erdgeschosswohnung-mieten': { apartmenttypes: ['groundfloor'] },
|
||||||
@@ -139,7 +148,7 @@ export function convertWebToMobile(webUrl) {
|
|||||||
|
|
||||||
const realTypeKey = segments.at(-1);
|
const realTypeKey = segments.at(-1);
|
||||||
let realType = REAL_ESTATE_TYPE[realTypeKey];
|
let realType = REAL_ESTATE_TYPE[realTypeKey];
|
||||||
let additionalParamsFromWebPath;
|
let additionalParamsFromWebPath = WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP[realTypeKey] || null;
|
||||||
|
|
||||||
if (!realType) {
|
if (!realType) {
|
||||||
// Test for seo optimized apartment path (only used on the ImmoScout web app)
|
// Test for seo optimized apartment path (only used on the ImmoScout web app)
|
||||||
@@ -160,7 +169,7 @@ export function convertWebToMobile(webUrl) {
|
|||||||
Object.entries(rawParams).filter(([key]) => key !== 'enteredFrom' && PARAM_NAME_MAP[key]),
|
Object.entries(rawParams).filter(([key]) => key !== 'enteredFrom' && PARAM_NAME_MAP[key]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const geocodes = `/${segments.slice(2, 5).join('/')}`;
|
const geocodes = `/${segments.slice(2, segments.length - 1).join('/')}`;
|
||||||
const isRadius = segments.includes('radius');
|
const isRadius = segments.includes('radius');
|
||||||
const mobileParams = {
|
const mobileParams = {
|
||||||
searchType: isRadius ? 'radius' : 'region',
|
searchType: isRadius ? 'radius' : 'region',
|
||||||
|
|||||||
187
lib/services/jobs/jobExecutionService.js
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import logger from '../logger.js';
|
||||||
|
import { bus } from '../events/event-bus.js';
|
||||||
|
import * as jobStorage from '../storage/jobStorage.js';
|
||||||
|
import * as userStorage from '../storage/userStorage.js';
|
||||||
|
import { getUser } from '../storage/userStorage.js';
|
||||||
|
import { duringWorkingHoursOrNotSet } from '../../utils.js';
|
||||||
|
import FredyPipelineExecutioner from '../../FredyPipelineExecutioner.js';
|
||||||
|
import * as similarityCache from '../similarity-check/similarityCache.js';
|
||||||
|
import { isRunning, markFinished, markRunning } from './run-state.js';
|
||||||
|
import { sendToUsers } from '../sse/sse-broker.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
lib/services/jobs/run-state.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple in-memory running state registry for jobs.
|
||||||
|
* Prevents concurrent execution of the same job within a single process.
|
||||||
|
* This registry is reset on process restart.
|
||||||
|
* @type {Set<string>}
|
||||||
|
*/
|
||||||
|
const running = new Set();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a job is currently marked as running.
|
||||||
|
* @param {string} jobId
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isRunning(jobId) {
|
||||||
|
return running.has(jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to mark a job as running.
|
||||||
|
* If it was already running, returns false and does not modify the set.
|
||||||
|
* @param {string} jobId
|
||||||
|
* @returns {boolean} true if the job was successfully marked as running
|
||||||
|
*/
|
||||||
|
export function markRunning(jobId) {
|
||||||
|
if (running.has(jobId)) return false;
|
||||||
|
running.add(jobId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a job as finished (remove from the running registry).
|
||||||
|
* @param {string} jobId
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function markFinished(jobId) {
|
||||||
|
running.delete(jobId);
|
||||||
|
}
|
||||||
35
lib/services/listings/distanceCalculator.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
const R = 6371000; // Earth radius in meters
|
||||||
|
/**
|
||||||
|
* Calculate the great-circle distance between two points on Earth using the Haversine formula.
|
||||||
|
* This is to calculate the distance between the listing address & the address provided by the user. I know, it is only
|
||||||
|
* a rough estimation as this calculates the distance as a straight line, but it's more convenient than using an external
|
||||||
|
* service and still gives a good approximation for sorting purposes.
|
||||||
|
* Returns distance in meters.
|
||||||
|
*
|
||||||
|
* @param {number} lat1
|
||||||
|
* @param {number} lon1
|
||||||
|
* @param {number} lat2
|
||||||
|
* @param {number} lon2
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export function distanceMeters(lat1, lon1, lat2, lon2) {
|
||||||
|
const toRad = (deg) => (deg * Math.PI) / 180;
|
||||||
|
|
||||||
|
const phi1 = toRad(lat1);
|
||||||
|
const phi2 = toRad(lat2);
|
||||||
|
const dPhi = toRad(lat2 - lat1);
|
||||||
|
const dLambda = toRad(lon2 - lon1);
|
||||||
|
|
||||||
|
const a =
|
||||||
|
Math.sin(dPhi / 2) * Math.sin(dPhi / 2) +
|
||||||
|
Math.cos(phi1) * Math.cos(phi2) * Math.sin(dLambda / 2) * Math.sin(dLambda / 2);
|
||||||
|
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
|
||||||
|
return Math.round(R * c * 10) / 10;
|
||||||
|
}
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 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,39 +1,78 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 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';
|
||||||
|
|
||||||
const maxAttempts = 3;
|
const maxAttempts = 3;
|
||||||
|
|
||||||
|
const userAgents = [
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||||
|
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15',
|
||||||
|
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a listing is still active with up to 3 attempts and exponential backoff.
|
* Check if a listing is still active with up to 5 attempts and exponential backoff.
|
||||||
* Backoff waits are capped and the last wait is at most 2000 ms.
|
* Backoff waits are randomized and capped.
|
||||||
*
|
*
|
||||||
* Rules:
|
* Rules:
|
||||||
* - HTTP 200 => return 1
|
* - HTTP 200 => return 1 (if checkForText is provided and found, returns 0)
|
||||||
* - HTTP 401/403 => return -1 (most certainly detected as a bot)
|
* - HTTP 401/403 => return -1 (most certainly detected as a bot)
|
||||||
* - HTTP 404 => return 0
|
* - HTTP 404 => return 0
|
||||||
* - Other statuses or network errors => retry until attempts are exhausted
|
* - Other statuses or network errors => retry until attempts are exhausted
|
||||||
*
|
*
|
||||||
* @returns {Promise<Integer>} 1 if active, o if not active and -1 if detected as bot
|
* @returns {Promise<Integer>} 1 if active, 0 if not active and -1 if detected as bot
|
||||||
*/
|
*/
|
||||||
export default async function checkIfListingIsActive(link) {
|
export default async function checkIfListingIsActive(link, checkForText = null) {
|
||||||
await sleep(randomBetween(50, 100));
|
await sleep(randomBetween(50, 100));
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
try {
|
try {
|
||||||
|
const userAgent = userAgents[Math.floor(Math.random() * userAgents.length)];
|
||||||
const res = await fetch(link, {
|
const res = await fetch(link, {
|
||||||
|
redirect: 'manual',
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent':
|
'User-Agent': userAgent,
|
||||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36',
|
Accept:
|
||||||
'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8',
|
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||||
|
'Accept-Language': 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7',
|
||||||
|
'Accept-Encoding': 'gzip, deflate, br',
|
||||||
|
'Cache-Control': 'max-age=0',
|
||||||
|
'Sec-Ch-Ua': '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
|
||||||
|
'Sec-Ch-Ua-Mobile': '?0',
|
||||||
|
'Sec-Ch-Ua-Platform': '"macOS"',
|
||||||
|
'Sec-Fetch-Dest': 'document',
|
||||||
|
'Sec-Fetch-Mode': 'navigate',
|
||||||
|
'Sec-Fetch-Site': 'none',
|
||||||
|
'Sec-Fetch-User': '?1',
|
||||||
|
'Upgrade-Insecure-Requests': '1',
|
||||||
|
Referer: 'https://www.google.com/',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
|
if (checkForText) {
|
||||||
|
const htmText = await res.text();
|
||||||
|
if (htmText.includes(checkForText)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
if (res.status === 401) return -1;
|
if (res.status === 401 || res.status === 403) {
|
||||||
if (res.status === 403) return -1;
|
if (attempt < maxAttempts) {
|
||||||
if (res.status === 404) return 0;
|
await sleep(backoffDelay(attempt));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (res.status === 404 || res.status === 410) return 0;
|
||||||
|
|
||||||
// For any other status, only retry if attempts remain
|
// For any other status, only retry if attempts remain
|
||||||
if (attempt < maxAttempts) {
|
if (attempt < maxAttempts) {
|
||||||
@@ -56,13 +95,13 @@ export default async function checkIfListingIsActive(link) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exponential backoff delay with cap.
|
* Exponential backoff delay with cap and jitter.
|
||||||
* attempt: 1 -> 500ms, 2 -> 1000ms, 3 -> 2000ms (cap)
|
|
||||||
* @param {number} attempt 1-based attempt index
|
* @param {number} attempt 1-based attempt index
|
||||||
* @returns {number} delay in ms
|
* @returns {number} delay in ms
|
||||||
*/
|
*/
|
||||||
function backoffDelay(attempt) {
|
function backoffDelay(attempt) {
|
||||||
const base = 500;
|
const base = 500;
|
||||||
const cap = 2000;
|
const cap = 2000;
|
||||||
return Math.min(base * 2 ** (attempt - 1), cap);
|
const delay = Math.min(base * 2 ** (attempt - 1), cap);
|
||||||
|
return delay + randomBetween(0, 1000);
|
||||||
}
|
}
|
||||||
|
|||||||