Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ae0c9749b | ||
|
|
10e40e038e | ||
|
|
4ba6828939 | ||
|
|
d09770dae2 | ||
|
|
248e4d2562 | ||
|
|
7b8e961b49 | ||
|
|
f66ceccbb4 | ||
|
|
a3db725af6 | ||
|
|
0663bd945f | ||
|
|
bc355fb5fe | ||
|
|
797421f0d5 | ||
|
|
0b2b42fc75 | ||
|
|
472169693f | ||
|
|
3117044139 | ||
|
|
7879d0e94a | ||
|
|
afd1048c9e | ||
|
|
acbaab05ed | ||
|
|
72fffc526b | ||
|
|
9e5989ece3 | ||
|
|
afc200c9e1 | ||
|
|
59226491f2 | ||
|
|
28f7760120 | ||
|
|
2465514b7a | ||
|
|
9dde377fe6 | ||
|
|
28a3a7f372 | ||
|
|
e859250545 | ||
|
|
4dd0370ec1 | ||
|
|
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 |
@@ -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
@@ -6,3 +6,4 @@ npm-debug.log
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
|
tools/release/config.json
|
||||||
|
|||||||
94
CHANGELOG.md
@@ -1,94 +0,0 @@
|
|||||||
Newer release changelog see https://github.com/orangecoding/fredy/releases
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
###### [V5.5.0]
|
|
||||||
|
|
||||||
- Upgrading dependencies
|
|
||||||
- fixing provider
|
|
||||||
- allow multiple instances of 1 provider
|
|
||||||
- **BREAKING**: Minimum node version is now 16
|
|
||||||
|
|
||||||
###### [V5.4.6]
|
|
||||||
|
|
||||||
- Adding Instana node.js monitoring
|
|
||||||
-
|
|
||||||
|
|
||||||
###### [V5.4.5]
|
|
||||||
|
|
||||||
- Adding Instana node.js monitoring
|
|
||||||
|
|
||||||
###### [V5.4.4]
|
|
||||||
|
|
||||||
- Add support for Immo Südwest Presse (immo.swp.de)
|
|
||||||
- Telegram: Use job name instead of ID and link in title
|
|
||||||
- Fix race condition if user ID is in session but not in user store
|
|
||||||
- Allow visiting the original provider URL
|
|
||||||
|
|
||||||
###### [V5.4.3]
|
|
||||||
|
|
||||||
- re-writing readme
|
|
||||||
- improving docker build
|
|
||||||
- using github's actions to build docker and test automatically
|
|
||||||
|
|
||||||
###### [V5.4.2]
|
|
||||||
|
|
||||||
- Fixing prod build
|
|
||||||
|
|
||||||
###### [V5.4.1]
|
|
||||||
|
|
||||||
- Upgrading dependencies
|
|
||||||
- Provider urls are now automagically been changed to include the correct sort order for search results
|
|
||||||
|
|
||||||
```
|
|
||||||
Note: It has been an point of confusion since the very beginning of Fredy, that people simply copied the url, but
|
|
||||||
did not take care of sorting the search results by date. If this is not done, Fredy will most likely not see the latest
|
|
||||||
results, thus cannot report them. This release fixes it by adding the necessary params (or replaces them).
|
|
||||||
```
|
|
||||||
|
|
||||||
###### [V5.3.0]
|
|
||||||
|
|
||||||
- Upgrading dependencies
|
|
||||||
- It's now possible to send mails to multiple receiver using comma separation for MailJet & Sendgrid
|
|
||||||
- Fixing Immowelt scraping
|
|
||||||
|
|
||||||
###### [V5.2.0]
|
|
||||||
|
|
||||||
- Upgrading dependencies
|
|
||||||
- Adding new similarity check layer (Duplicates are being removed now)
|
|
||||||
- Adding paging for search results
|
|
||||||
|
|
||||||
###### [V5.1.0]
|
|
||||||
|
|
||||||
- Upgrading dependencies
|
|
||||||
- NodeJS 12.13 is now the minimum supported version
|
|
||||||
- Adding general settings as new configuration page to ui
|
|
||||||
- Adding new feature working hours
|
|
||||||
|
|
||||||
###### [V5.0.0]
|
|
||||||
|
|
||||||
- Upgrading dependencies
|
|
||||||
- NodeJS 12 is now the minimum supported version
|
|
||||||
|
|
||||||
###### [V4.0.0]
|
|
||||||
|
|
||||||
Bringing back Immoscout :tada:
|
|
||||||
|
|
||||||
###### [V3.0.0]
|
|
||||||
|
|
||||||
This is basically a re-write, your old config file will not be compatible anymore. Please re-created your search jobs
|
|
||||||
on the new ui and use the values from your previous config file if needed.
|
|
||||||
|
|
||||||
```
|
|
||||||
- We're getting rid of manual config changes, Fredy, now ships with a UI so that it's easy for you to create and edit jobs
|
|
||||||
```
|
|
||||||
|
|
||||||
###### [V2.0.0]
|
|
||||||
|
|
||||||
```
|
|
||||||
- Fredy can now run multiple search job on one instance
|
|
||||||
- Changed lot's of the structure of Fredy to make this happen
|
|
||||||
[BREAKING CHANGES]
|
|
||||||
- The config has been changed, the config of V1.x will not work any longer
|
|
||||||
- Sources have been renamed to provider
|
|
||||||
```
|
|
||||||
70
Dockerfile
@@ -1,27 +1,60 @@
|
|||||||
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
|
IS_DOCKER=true \
|
||||||
|
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 \
|
||||||
@@ -34,5 +67,4 @@ EXPOSE 9998
|
|||||||
VOLUME /db
|
VOLUME /db
|
||||||
VOLUME /conf
|
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
|
||||||
|
|||||||
@@ -107,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`
|
||||||
@@ -115,7 +119,7 @@ yarn run start:frontend # in another terminal
|
|||||||
|
|
||||||
## 📸 Screenshots
|
## 📸 Screenshots
|
||||||
|
|
||||||
| Fredy Main Overview | Job Configuration | Found Listings |
|
| Fredy Maps View | Dashboard | Found Listings |
|
||||||
|--------------------------------------------------|-----------------------------------------------------------------------|-----------------------------------------------------------------------------|
|
|--------------------------------------------------|-----------------------------------------------------------------------|-----------------------------------------------------------------------------|
|
||||||
|  |  |  |
|
|  |  |  |
|
||||||
|
|
||||||
@@ -202,7 +206,7 @@ flowchart TD
|
|||||||
F2["Adapter 2"]
|
F2["Adapter 2"]
|
||||||
end
|
end
|
||||||
|
|
||||||
A1 --> B["FredyPipeline"]
|
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: 197 KiB After Width: | Height: | Size: 3.7 MiB |
|
Before Width: | Height: | Size: 512 KiB After Width: | Height: | Size: 4.8 MiB |
|
Before Width: | Height: | Size: 372 KiB After Width: | Height: | Size: 531 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: ghcr.io/orangecoding/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
|
||||||
|
|||||||
@@ -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,7 +7,7 @@
|
|||||||
content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
|
content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
|
||||||
/>
|
/>
|
||||||
<meta name="google" content="notranslate" />
|
<meta name="google" content="notranslate" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
|
||||||
<title>Fredy || Real Estate Finder</title>
|
<title>Fredy || Real Estate Finder</title>
|
||||||
|
|||||||
89
index.js
@@ -1,95 +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 { checkIfConfigIsAccessible, 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 FredyPipeline from './lib/FredyPipeline.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();
|
||||||
|
|
||||||
const isConfigAccessible = await checkIfConfigIsAccessible();
|
|
||||||
|
|
||||||
if (!isConfigAccessible) {
|
if (!isConfigAccessible) {
|
||||||
logger.error('Configuration exists, but is not accessible. Please check the file permission');
|
logger.error('Configuration exists, but is not accessible. Please check the file permission');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure sqlite directory exists before loading anything else (based on config.sqlitepath)
|
|
||||||
const rawDir = config.sqlitepath || '/db';
|
|
||||||
const relDir = rawDir.startsWith('/') ? rawDir.slice(1) : rawDir;
|
|
||||||
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 FredyPipeline(
|
|
||||||
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();
|
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import { NoNewListingsWarning } from './errors.js';
|
import { NoNewListingsWarning } from './errors.js';
|
||||||
import { storeListings, getKnownListingHashesForJobAndProvider } from './services/storage/listingsStorage.js';
|
import { storeListings, getKnownListingHashesForJobAndProvider } from './services/storage/listingsStorage.js';
|
||||||
|
import { getJob } from './services/storage/jobStorage.js';
|
||||||
import * as notify from './notification/notify.js';
|
import * as notify from './notification/notify.js';
|
||||||
import Extractor from './services/extractor/extractor.js';
|
import Extractor from './services/extractor/extractor.js';
|
||||||
import urlModifier from './services/queryStringMutator.js';
|
import urlModifier from './services/queryStringMutator.js';
|
||||||
import logger from './services/logger.js';
|
import logger from './services/logger.js';
|
||||||
|
import { geocodeAddress } from './services/geocoding/geoCodingService.js';
|
||||||
|
import { distanceMeters } from './services/listings/distanceCalculator.js';
|
||||||
|
import { getUserSettings } from './services/storage/settingsStorage.js';
|
||||||
|
import { updateListingDistance } from './services/storage/listingsStorage.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} Listing
|
* @typedef {Object} Listing
|
||||||
@@ -35,7 +45,7 @@ import logger from './services/logger.js';
|
|||||||
* 7) Filter out entries similar to already seen ones
|
* 7) Filter out entries similar to already seen ones
|
||||||
* 8) Dispatch notifications
|
* 8) Dispatch notifications
|
||||||
*/
|
*/
|
||||||
class FredyPipeline {
|
class FredyPipelineExecutioner {
|
||||||
/**
|
/**
|
||||||
* Create a new runtime instance for a single provider/job execution.
|
* Create a new runtime instance for a single provider/job execution.
|
||||||
*
|
*
|
||||||
@@ -74,12 +84,33 @@ class FredyPipeline {
|
|||||||
.then(this._normalize.bind(this))
|
.then(this._normalize.bind(this))
|
||||||
.then(this._filter.bind(this))
|
.then(this._filter.bind(this))
|
||||||
.then(this._findNew.bind(this))
|
.then(this._findNew.bind(this))
|
||||||
|
.then(this._geocode.bind(this))
|
||||||
.then(this._save.bind(this))
|
.then(this._save.bind(this))
|
||||||
|
.then(this._calculateDistance.bind(this))
|
||||||
.then(this._filterBySimilarListings.bind(this))
|
.then(this._filterBySimilarListings.bind(this))
|
||||||
.then(this._notify.bind(this))
|
.then(this._notify.bind(this))
|
||||||
.catch(this._handleError.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
|
* Fetch listings from the provider, using the default Extractor flow unless
|
||||||
* a provider-specific getListings override is supplied.
|
* a provider-specific getListings override is supplied.
|
||||||
@@ -175,6 +206,42 @@ class FredyPipeline {
|
|||||||
return 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.
|
* Remove listings that are similar to already known entries according to the similarity cache.
|
||||||
* Adds the remaining listings to the cache.
|
* Adds the remaining listings to the cache.
|
||||||
@@ -183,8 +250,12 @@ class FredyPipeline {
|
|||||||
* @returns {Listing[]} Listings considered unique enough to keep.
|
* @returns {Listing[]} Listings considered unique enough to keep.
|
||||||
*/
|
*/
|
||||||
_filterBySimilarListings(listings) {
|
_filterBySimilarListings(listings) {
|
||||||
const filteredList = listings.filter((listing) => {
|
return listings.filter((listing) => {
|
||||||
const similar = this._similarityCache.hasSimilarEntries(listing.title, listing.address);
|
const similar = this._similarityCache.checkAndAddEntry({
|
||||||
|
title: listing.title,
|
||||||
|
address: listing.address,
|
||||||
|
price: listing.price,
|
||||||
|
});
|
||||||
if (similar) {
|
if (similar) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Filtering similar entry for title '${listing.title}' and address '${listing.address}' (Provider: '${this._providerId}')`,
|
`Filtering similar entry for title '${listing.title}' and address '${listing.address}' (Provider: '${this._providerId}')`,
|
||||||
@@ -192,8 +263,6 @@ class FredyPipeline {
|
|||||||
}
|
}
|
||||||
return !similar;
|
return !similar;
|
||||||
});
|
});
|
||||||
filteredList.forEach((filter) => this._similarityCache.addCacheEntry(filter.title, filter.address));
|
|
||||||
return filteredList;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -211,4 +280,4 @@ class FredyPipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FredyPipeline;
|
export default FredyPipelineExecutioner;
|
||||||
@@ -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) {
|
||||||
@@ -33,6 +42,7 @@ jobRouter.get('/', async (req, res) => {
|
|||||||
.map((job) => {
|
.map((job) => {
|
||||||
return {
|
return {
|
||||||
...job,
|
...job,
|
||||||
|
running: isJobRunning(job.id),
|
||||||
isOnlyShared:
|
isOnlyShared:
|
||||||
!isUserAdmin &&
|
!isUserAdmin &&
|
||||||
job.userId !== req.session.currentUser &&
|
job.userId !== req.session.currentUser &&
|
||||||
@@ -43,21 +53,118 @@ jobRouter.get('/', async (req, res) => {
|
|||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
|
|
||||||
jobRouter.get('/processingTimes', async (req, res) => {
|
jobRouter.get('/data', async (req, res) => {
|
||||||
res.body = {
|
const { page, pageSize = 50, activityFilter, sortfield = null, sortdir = 'asc', freeTextFilter } = req.query || {};
|
||||||
interval: config.interval,
|
|
||||||
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, shareWithUsers = [] } = req.body;
|
const { provider, notificationAdapter, name, blacklist = [], jobId, enabled, shareWithUsers = [] } = req.body;
|
||||||
|
const settings = await getSettings();
|
||||||
try {
|
try {
|
||||||
let jobFromDb = jobStorage.getJob(jobId);
|
let jobFromDb = jobStorage.getJob(jobId);
|
||||||
|
|
||||||
@@ -66,6 +173,11 @@ jobRouter.post('/', async (req, res) => {
|
|||||||
return;
|
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,
|
||||||
@@ -85,8 +197,14 @@ jobRouter.post('/', async (req, res) => {
|
|||||||
|
|
||||||
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 {
|
||||||
@@ -101,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 {
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* 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 * as watchListStorage from '../../services/storage/watchListStorage.js';
|
||||||
@@ -5,6 +10,7 @@ import { isAdmin as isAdminFn } from '../security.js';
|
|||||||
import logger from '../../services/logger.js';
|
import logger from '../../services/logger.js';
|
||||||
import { nullOrEmpty } from '../../utils.js';
|
import { nullOrEmpty } from '../../utils.js';
|
||||||
import { getJobs } from '../../services/storage/jobStorage.js';
|
import { getJobs } from '../../services/storage/jobStorage.js';
|
||||||
|
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||||
|
|
||||||
const service = restana();
|
const service = restana();
|
||||||
|
|
||||||
@@ -23,10 +29,14 @@ listingsRouter.get('/table', async (req, res) => {
|
|||||||
freeTextFilter,
|
freeTextFilter,
|
||||||
} = req.query || {};
|
} = req.query || {};
|
||||||
|
|
||||||
// normalize booleans (accept true, 'true', 1, '1')
|
// normalize booleans (accept true, 'true', 1, '1' for true; false, 'false', 0, '0' for false)
|
||||||
const toBool = (v) => v === true || v === 'true' || v === 1 || v === '1';
|
const toBool = (v) => {
|
||||||
const normalizedActivity = toBool(activityFilter) ? true : null;
|
if (v === true || v === 'true' || v === 1 || v === '1') return true;
|
||||||
const normalizedWatch = toBool(watchListFilter) ? true : null;
|
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 jobFilter = null;
|
||||||
let jobIdFilter = null;
|
let jobIdFilter = null;
|
||||||
@@ -54,6 +64,29 @@ listingsRouter.get('/table', async (req, res) => {
|
|||||||
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
|
// Toggle watch state for the current user on a listing
|
||||||
listingsRouter.post('/watch', async (req, res) => {
|
listingsRouter.post('/watch', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -74,9 +107,15 @@ listingsRouter.post('/watch', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
listingsRouter.delete('/job', async (req, res) => {
|
listingsRouter.delete('/job', async (req, res) => {
|
||||||
const { jobId } = req.body;
|
const { jobId, hardDelete = false } = req.body;
|
||||||
|
const settings = await getSettings();
|
||||||
try {
|
try {
|
||||||
listingStorage.deleteListingsByJobId(jobId);
|
if (settings.demoMode) {
|
||||||
|
res.send(new Error('Sorry, but you cannot remove listings in demo mode ;)'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listingStorage.deleteListingsByJobId(jobId, hardDelete);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.send(new Error(error));
|
res.send(new Error(error));
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
@@ -85,10 +124,10 @@ listingsRouter.delete('/job', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
listingsRouter.delete('/', async (req, res) => {
|
listingsRouter.delete('/', async (req, res) => {
|
||||||
const { ids } = req.body;
|
const { ids, hardDelete = false } = req.body;
|
||||||
try {
|
try {
|
||||||
if (Array.isArray(ids) && ids.length > 0) {
|
if (Array.isArray(ids) && ids.length > 0) {
|
||||||
listingStorage.deleteListingsById(ids);
|
listingStorage.deleteListingsById(ids, hardDelete);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.send(new Error(error));
|
res.send(new Error(error));
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -23,7 +28,8 @@ userRouter.get('/:userId', async (req, res) => {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
@@ -44,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,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* 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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -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 { getJob } from '../../services/storage/jobStorage.js';
|
import { getJob } from '../../services/storage/jobStorage.js';
|
||||||
import { markdown2Html } from '../../services/markdown.js';
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
### Discord Adapter
|
### Discord Webhook Adapter
|
||||||
|
|
||||||
To use the [Discord](https://discord.com/) Adapter, you need to create a webhook on the Discord channel of your choice. You can follow the instructions of _Making A Webhook_ on [this support website](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks).
|
Use a Discord channel webhook to receive notifications.
|
||||||
Once you have created a webhook, copy and paste the webhook URL.
|
|
||||||
|
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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,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';
|
||||||
@@ -117,10 +122,24 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
|
|||||||
if (!adapterCfg || !adapterCfg.fields) {
|
if (!adapterCfg || !adapterCfg.fields) {
|
||||||
throw new Error(`Telegram adapter configuration missing for job '${jobKey || ''}'`);
|
throw new Error(`Telegram adapter configuration missing for job '${jobKey || ''}'`);
|
||||||
}
|
}
|
||||||
const { token, chatId } = adapterCfg.fields;
|
const { token, chatId, messageThreadId } = adapterCfg.fields;
|
||||||
if (!token || !chatId) {
|
if (!token || !chatId) {
|
||||||
throw new Error("Telegram 'token' and 'chatId' must be provided in notification config");
|
throw new Error("Telegram 'token' and 'chatId' must be provided in notification config");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
@@ -147,6 +166,7 @@ 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) {
|
||||||
@@ -160,6 +180,7 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
|
|||||||
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) => {
|
}).catch(async (e) => {
|
||||||
logger.error(`Error sending photo to Telegram and use a fallback: ${e.message}`);
|
logger.error(`Error sending photo to Telegram and use a fallback: ${e.message}`);
|
||||||
return await throttledCall('sendMessage', textPayload).catch((e) => {
|
return await throttledCall('sendMessage', textPayload).catch((e) => {
|
||||||
@@ -174,7 +195,7 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Telegram notification adapter configuration schema.
|
* 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}}}}
|
* @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',
|
||||||
@@ -192,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 = [];
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 = [];
|
||||||
@@ -8,7 +13,7 @@ function normalize(o) {
|
|||||||
const title = o.title || 'No title available';
|
const title = o.title || 'No title available';
|
||||||
const link = o.link != null ? decodeURIComponent(o.link) : config.url;
|
const link = o.link != null ? decodeURIComponent(o.link) : config.url;
|
||||||
|
|
||||||
var urlReg = new RegExp(/url\((.*?)\)/gim);
|
const urlReg = new RegExp(/url\((.*?)\)/gim);
|
||||||
const image = o.image != null ? urlReg.exec(o.image)[1] : null;
|
const image = o.image != null ? urlReg.exec(o.image)[1] : null;
|
||||||
return Object.assign(o, { id, address, title, link, image });
|
return Object.assign(o, { id, address, title, link, image });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
@@ -31,7 +36,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
activeTester: checkIfListingIsActive,
|
activeTester: (url) => checkIfListingIsActive(url, 'Angebot nicht gefunden'),
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
config.enabled = sourceConfig.enabled;
|
config.enabled = sourceConfig.enabled;
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
46
lib/services/crons/geocoding-cron.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/*
|
||||||
|
* 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';
|
||||||
|
import { getSettings } from '../storage/settingsStorage.js';
|
||||||
|
import logger from '../logger.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() {
|
||||||
|
const settings = await getSettings();
|
||||||
|
if (settings.demoMode) {
|
||||||
|
logger.info('Do not start geo service as we are in demo mode');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// run directly on start
|
||||||
|
await runGeoCordTask();
|
||||||
|
// then every 6 hours
|
||||||
|
cron.schedule('0 */6 * * *', runGeoCordTask);
|
||||||
|
}
|
||||||
@@ -1,11 +1,23 @@
|
|||||||
|
/*
|
||||||
|
* 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';
|
||||||
|
import logger from '../logger.js';
|
||||||
|
import { getSettings } from '../storage/settingsStorage.js';
|
||||||
|
|
||||||
async function runTask() {
|
async function runTask() {
|
||||||
await runActiveChecker();
|
await runActiveChecker();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initActiveCheckerCron() {
|
export async function initActiveCheckerCron() {
|
||||||
|
const settings = await getSettings();
|
||||||
|
if (settings.demoMode) {
|
||||||
|
logger.info('Do not start listing active checker as we are in demo mode');
|
||||||
|
return;
|
||||||
|
}
|
||||||
//run directly on start
|
//run directly on start
|
||||||
await runTask();
|
await runTask();
|
||||||
// then every day at 1 am
|
// then every day at 1 am
|
||||||
|
|||||||
@@ -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.warn('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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
const COLORS = {
|
const COLORS = {
|
||||||
debug: '\x1b[36m',
|
debug: '\x1b[36m',
|
||||||
info: '\x1b[32m',
|
info: '\x1b[32m',
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import markdown$0 from 'markdown';
|
/*
|
||||||
|
* Copyright (c) 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 markdown = markdown$0.markdown;
|
|
||||||
export function markdown2Html(filePath) {
|
export function markdown2Html(filePath) {
|
||||||
return markdown.toHTML(fs.readFileSync(filePath, 'utf8'));
|
return fs.readFileSync(filePath, 'utf8');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
import queryString from 'query-string';
|
import queryString from 'query-string';
|
||||||
export default (_url, sortByDateParam) => {
|
export default (_url, sortByDateParam) => {
|
||||||
//if no mutation is necessary, just return the original url
|
//if no mutation is necessary, just return the original url
|
||||||
|
|||||||