mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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/
|
||||
npm-debug.log
|
||||
test/
|
||||
|
||||
# Database and config (mounted as volumes)
|
||||
db/
|
||||
conf/
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.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
2
.github/workflows/test.yml
vendored
@@ -19,4 +19,4 @@ jobs:
|
||||
cache: 'yarn'
|
||||
|
||||
- run: yarn install
|
||||
- run: yarn test
|
||||
- run: yarn testGH
|
||||
|
||||
66
Dockerfile
66
Dockerfile
@@ -1,27 +1,58 @@
|
||||
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
|
||||
|
||||
# Install Chromium and curl without extra recommended packages and clean apt cache
|
||||
# curl is needed for the health check
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends chromium curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Install Chromium and curl (for healthcheck)
|
||||
# Using Alpine's chromium package which is much smaller
|
||||
RUN apk add --no-cache chromium curl
|
||||
|
||||
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
|
||||
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
||||
|
||||
# Copy lockfiles first to leverage cache for dependencies
|
||||
COPY package.json yarn.lock .
|
||||
# Install build dependencies for native modules, then remove them after yarn install
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
# Set Yarn timeout, install dependencies and PM2 globally
|
||||
RUN yarn config set network-timeout 600000 \
|
||||
&& yarn --frozen-lockfile \
|
||||
&& yarn global add pm2
|
||||
RUN apk add --no-cache --virtual .build-deps python3 make g++ \
|
||||
&& yarn config set network-timeout 600000 \
|
||||
&& yarn --frozen-lockfile --production \
|
||||
&& yarn cache clean \
|
||||
&& apk del .build-deps
|
||||
|
||||
# Copy application source and build production assets
|
||||
COPY . .
|
||||
RUN yarn build:frontend
|
||||
# Copy built frontend from builder stage
|
||||
COPY --from=builder /build/ui/public ./ui/public
|
||||
|
||||
# Copy application source (only what's needed at runtime)
|
||||
COPY index.js ./
|
||||
COPY index.html ./
|
||||
COPY lib ./lib
|
||||
|
||||
# Prepare runtime directories and symlinks for data and config
|
||||
RUN mkdir -p /db /conf \
|
||||
@@ -34,5 +65,4 @@ EXPOSE 9998
|
||||
VOLUME /db
|
||||
VOLUME /conf
|
||||
|
||||
# Start application using PM2 runtime
|
||||
CMD ["pm2-runtime", "index.js"]
|
||||
CMD ["node", "index.js"]
|
||||
|
||||
231
LICENSE
231
LICENSE
@@ -1,21 +1,214 @@
|
||||
MIT License
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor
|
||||
be liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
|
||||
Additional License Condition – Commons Clause
|
||||
|
||||
The Licensed Work is provided under the terms of this license and is also
|
||||
subject to the following additional condition ("Commons Clause"):
|
||||
|
||||
"License Condition v1.0":
|
||||
|
||||
The Licensed Work and its derivative works may not be used by any person or
|
||||
organization to Sell the Licensed Work (as defined below).
|
||||
|
||||
"Sell" or "Selling" means practicing any or all of the rights granted to you
|
||||
under the License to provide to third parties, for a fee or other consideration
|
||||
(including without limitation fees for hosting or consulting/support services
|
||||
related to the Software), a product or service whose value derives, entirely or
|
||||
substantially, from the functionality of the Licensed Work.
|
||||
|
||||
A non-exhaustive list of activities considered "Selling" includes:
|
||||
- Using the Licensed Work to provide paid hosted services or managed services
|
||||
- Distributing the Licensed Work as part of a commercial product or service
|
||||
for which a fee is charged primarily for the value of the Licensed Work
|
||||
|
||||
This restriction does not apply to the use of the Licensed Work for internal
|
||||
business purposes or non-commercial use.
|
||||
|
||||
|
||||
Attribution and Naming Clause
|
||||
|
||||
Any derivative work based on this software must include clear and visible
|
||||
attribution to the original project "Fredy" and its author(s).
|
||||
Derivative works may not be distributed, published, or presented under a
|
||||
different name or branding without the explicit written permission of the
|
||||
original copyright holder.
|
||||
|
||||
|
||||
Copyright (c) 2025 Christian Kellner
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
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>
|
||||
|
||||
### With Unraid
|
||||
|
||||
Should you use [Unraid](https://unraid.net/), you can now install Fredy from the community store :)
|
||||
|
||||
**Default Login:**
|
||||
- Username: `admin`
|
||||
- Password: `admin`
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"interval":"60","port":9998,"workingHours":{"from":"","to":""},"demoMode":false,"analyticsEnabled":true,"sqlitepath":"/db"}
|
||||
{"sqlitepath":"/db"}
|
||||
48
copyright.js
Normal file
48
copyright.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
const COPYRIGHT = `/*
|
||||
* Copyright (c) ${new Date().getFullYear()} by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
`;
|
||||
|
||||
async function getAllFiles(dir = '.') {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
let files = [];
|
||||
for (let entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
||||
files = files.concat(await getAllFiles(fullPath));
|
||||
} else if (fullPath.endsWith('.js') || fullPath.endsWith('.jsx')) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/* eslint-disable no-console */
|
||||
async function addCopyright(files) {
|
||||
for (let file of files) {
|
||||
try {
|
||||
let content = await fs.readFile(file, 'utf8');
|
||||
if (!content.startsWith(COPYRIGHT)) {
|
||||
await fs.writeFile(file, COPYRIGHT + content);
|
||||
console.log(`Added copyright to ${file}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error processing ${file}: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
/* eslint-enable no-console */
|
||||
|
||||
const filesToProcess = process.argv.length > 2 ? process.argv.slice(2) : await getAllFiles();
|
||||
await addCopyright(filesToProcess);
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 372 KiB After Width: | Height: | Size: 402 KiB |
BIN
doc/unraid_fredy_logo.png
Normal file
BIN
doc/unraid_fredy_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 417 KiB |
@@ -1,22 +1,24 @@
|
||||
services:
|
||||
fredy:
|
||||
container_name: fredy
|
||||
# build from empty build folder to reduce size of image
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: ghcr.io/orangecoding/fredy
|
||||
# map existing config and database
|
||||
volumes:
|
||||
- ./conf:/conf
|
||||
- ./db:/db
|
||||
ports:
|
||||
- "9998:9998"
|
||||
restart: unless-stopped
|
||||
# Resource limits to prevent runaway memory usage from Chromium
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1G
|
||||
healthcheck:
|
||||
# The container will immediately stop when health check fails after retries
|
||||
test: ["CMD-SHELL", "curl --fail --silent --show-error --max-time 5 http://localhost:9998/ || exit 1"]
|
||||
test: ["CMD", "curl", "--fail", "--silent", "--show-error", "--max-time", "5", "http://localhost:9998/"]
|
||||
interval: 120s
|
||||
timeout: 10s
|
||||
retries: 1
|
||||
start_period: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
// eslint.config.js
|
||||
import js from '@eslint/js';
|
||||
import prettier from 'eslint-config-prettier';
|
||||
|
||||
62
index.js
62
index.js
@@ -1,6 +1,11 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { checkIfConfigIsAccessible, config, getProviders, refreshConfig } from './lib/utils.js';
|
||||
import { checkIfConfigIsAccessible, getProviders, refreshConfig } from './lib/utils.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';
|
||||
@@ -12,43 +17,52 @@ import { initTrackerCron } from './lib/services/crons/tracker-cron.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 { getSettings } from './lib/services/storage/settingsStorage.js';
|
||||
import SqliteConnection from './lib/services/storage/SqliteConnection.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
|
||||
await refreshConfig();
|
||||
|
||||
const isConfigAccessible = await checkIfConfigIsAccessible();
|
||||
|
||||
if (!isConfigAccessible) {
|
||||
logger.error('Configuration exists, but is not accessible. Please check the file permission');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Run DB migrations once at startup and block until finished
|
||||
await runMigrations();
|
||||
|
||||
const settings = await getSettings();
|
||||
|
||||
// Ensure sqlite directory exists before loading anything else (based on config.sqlitepath)
|
||||
const rawDir = config.sqlitepath || '/db';
|
||||
const rawDir = settings.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
|
||||
await runMigrations();
|
||||
|
||||
// Load provider modules once at startup
|
||||
const providers = await getProviders();
|
||||
|
||||
similarityCache.initSimilarityCache();
|
||||
similarityCache.startSimilarityCacheReloader();
|
||||
|
||||
//assuming interval is always in minutes
|
||||
const INTERVAL = config.interval * 60 * 1000;
|
||||
const INTERVAL = settings.interval * 60 * 1000;
|
||||
|
||||
// Initialize API only after migrations completed
|
||||
await import('./lib/api/api.js');
|
||||
|
||||
if (config.demoMode) {
|
||||
if (settings.demoMode) {
|
||||
logger.info('Running in demo mode');
|
||||
cleanupDemoAtMidnight();
|
||||
}
|
||||
|
||||
logger.info(`Started Fredy successfully. Ui can be accessed via http://localhost:${config.port}`);
|
||||
logger.info(`Started Fredy successfully. Ui can be accessed via http://localhost:${settings.port}`);
|
||||
|
||||
ensureAdminUserExists();
|
||||
ensureDemoUserExists();
|
||||
@@ -62,10 +76,10 @@ bus.on('jobs:runAll', () => {
|
||||
});
|
||||
|
||||
const execute = () => {
|
||||
const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(config, Date.now());
|
||||
if (!config.demoMode) {
|
||||
const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(settings, Date.now());
|
||||
if (!settings.demoMode) {
|
||||
if (isDuringWorkingHoursOrNotSet) {
|
||||
config.lastRun = Date.now();
|
||||
settings.lastRun = Date.now();
|
||||
jobStorage
|
||||
.getJobs()
|
||||
.filter((job) => job.enabled)
|
||||
@@ -73,15 +87,19 @@ const execute = () => {
|
||||
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();
|
||||
try {
|
||||
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();
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { NoNewListingsWarning } from './errors.js';
|
||||
import { storeListings, getKnownListingHashesForJobAndProvider } from './services/storage/listingsStorage.js';
|
||||
import * as notify from './notification/notify.js';
|
||||
@@ -183,8 +188,12 @@ class FredyPipeline {
|
||||
* @returns {Listing[]} Listings considered unique enough to keep.
|
||||
*/
|
||||
_filterBySimilarListings(listings) {
|
||||
const filteredList = listings.filter((listing) => {
|
||||
const similar = this._similarityCache.hasSimilarEntries(listing.title, listing.address);
|
||||
return listings.filter((listing) => {
|
||||
const similar = this._similarityCache.checkAndAddEntry({
|
||||
title: listing.title,
|
||||
address: listing.address,
|
||||
price: listing.price,
|
||||
});
|
||||
if (similar) {
|
||||
logger.debug(
|
||||
`Filtering similar entry for title '${listing.title}' and address '${listing.address}' (Provider: '${this._providerId}')`,
|
||||
@@ -192,8 +201,6 @@ class FredyPipeline {
|
||||
}
|
||||
return !similar;
|
||||
});
|
||||
filteredList.forEach((filter) => this._similarityCache.addCacheEntry(filter.title, filter.address));
|
||||
return filteredList;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { notificationAdapterRouter } from './routes/notificationAdapterRouter.js';
|
||||
import { authInterceptor, cookieSession, adminInterceptor } from './security.js';
|
||||
import { generalSettingsRouter } from './routes/generalSettingsRoute.js';
|
||||
import { analyticsRouter } from './routes/analyticsRouter.js';
|
||||
import { providerRouter } from './routes/providerRouter.js';
|
||||
import { versionRouter } from './routes/versionRouter.js';
|
||||
import { loginRouter } from './routes/loginRoute.js';
|
||||
import { userRouter } from './routes/userRoute.js';
|
||||
import { jobRouter } from './routes/jobRouter.js';
|
||||
import { config } from '../utils.js';
|
||||
import bodyParser from 'body-parser';
|
||||
import restana from 'restana';
|
||||
import files from 'serve-static';
|
||||
@@ -16,9 +19,12 @@ import { getDirName } from '../utils.js';
|
||||
import { demoRouter } from './routes/demoRouter.js';
|
||||
import logger from '../services/logger.js';
|
||||
import { listingsRouter } from './routes/listingsRouter.js';
|
||||
import { getSettings } from '../services/storage/settingsStorage.js';
|
||||
import { featureRouter } from './routes/featureRouter.js';
|
||||
import { dashboardRouter } from './routes/dashboardRouter.js';
|
||||
const service = restana();
|
||||
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(cookieSession());
|
||||
@@ -27,18 +33,21 @@ service.use('/api/admin', authInterceptor());
|
||||
service.use('/api/jobs', authInterceptor());
|
||||
service.use('/api/version', authInterceptor());
|
||||
service.use('/api/listings', authInterceptor());
|
||||
service.use('/api/dashboard', authInterceptor());
|
||||
service.use('/api/features', authInterceptor());
|
||||
|
||||
// /admin can only be accessed when user is having admin permissions
|
||||
service.use('/api/admin', adminInterceptor());
|
||||
service.use('/api/jobs/notificationAdapter', notificationAdapterRouter);
|
||||
service.use('/api/admin/generalSettings', generalSettingsRouter);
|
||||
service.use('/api/jobs/provider', providerRouter);
|
||||
service.use('/api/jobs/insights', analyticsRouter);
|
||||
service.use('/api/admin/users', userRouter);
|
||||
service.use('/api/version', versionRouter);
|
||||
service.use('/api/jobs', jobRouter);
|
||||
service.use('/api/login', loginRouter);
|
||||
service.use('/api/listings', listingsRouter);
|
||||
service.use('/api/features', featureRouter);
|
||||
service.use('/api/dashboard', dashboardRouter);
|
||||
//this route is unsecured intentionally as it is being queried from the login page
|
||||
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 };
|
||||
71
lib/api/routes/dashboardRouter.js
Normal file
71
lib/api/routes/dashboardRouter.js
Normal file
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import restana from 'restana';
|
||||
import * as jobStorage from '../../services/storage/jobStorage.js';
|
||||
import * as userStorage from '../../services/storage/userStorage.js';
|
||||
import { getListingsKpisForJobIds, getProviderDistributionForJobIds } from '../../services/storage/listingsStorage.js';
|
||||
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||
|
||||
const service = restana();
|
||||
export const dashboardRouter = service.newRouter();
|
||||
|
||||
function isAdmin(req) {
|
||||
const user = req.session?.currentUser ? userStorage.getUser(req.session.currentUser) : null;
|
||||
return !!user?.isAdmin;
|
||||
}
|
||||
|
||||
function getAccessibleJobs(req) {
|
||||
const currentUser = req.session.currentUser;
|
||||
const admin = isAdmin(req);
|
||||
return jobStorage
|
||||
.getJobs()
|
||||
.filter((job) => admin || job.userId === currentUser || job.shared_with_user.includes(currentUser));
|
||||
}
|
||||
|
||||
function cap(val) {
|
||||
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
|
||||
}
|
||||
|
||||
dashboardRouter.get('/', async (req, res) => {
|
||||
const jobs = getAccessibleJobs(req);
|
||||
const settings = await getSettings();
|
||||
|
||||
// KPIs
|
||||
const totalJobs = jobs.length;
|
||||
const totalListings = jobs.reduce((sum, j) => sum + (j.numberOfFoundListings || 0), 0);
|
||||
const jobIds = jobs.map((j) => j.id);
|
||||
const { numberOfActiveListings, avgPriceOfListings } = getListingsKpisForJobIds(jobIds);
|
||||
// Build Pie data in a simple shape the frontend can consume directly
|
||||
// Shape: { labels: string[], values: number[] } with values as percentages
|
||||
const providerPieRaw = getProviderDistributionForJobIds(jobIds);
|
||||
const providerPie = Array.isArray(providerPieRaw)
|
||||
? {
|
||||
labels: providerPieRaw.map((p) => cap(p.type)),
|
||||
values: providerPieRaw.map((p) => Number(p.value) || 0),
|
||||
}
|
||||
: providerPieRaw && typeof providerPieRaw === 'object'
|
||||
? {
|
||||
labels: Array.isArray(providerPieRaw.labels) ? providerPieRaw.labels : [],
|
||||
values: Array.isArray(providerPieRaw.values) ? providerPieRaw.values : [],
|
||||
}
|
||||
: { labels: [], values: [] };
|
||||
|
||||
res.body = {
|
||||
general: {
|
||||
interval: settings.interval,
|
||||
lastRun: settings.lastRun || null,
|
||||
nextRun: settings.lastRun == null ? 0 : settings.lastRun + settings.interval * 60000,
|
||||
},
|
||||
kpis: {
|
||||
totalJobs,
|
||||
totalListings,
|
||||
numberOfActiveListings,
|
||||
avgPriceOfListings,
|
||||
},
|
||||
pie: providerPie,
|
||||
};
|
||||
res.send();
|
||||
});
|
||||
@@ -1,10 +1,16 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import restana from 'restana';
|
||||
import { config } from '../../utils.js';
|
||||
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||
const service = restana();
|
||||
const demoRouter = service.newRouter();
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
17
lib/api/routes/featureRouter.js
Normal file
17
lib/api/routes/featureRouter.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import restana from 'restana';
|
||||
import getFeatures from '../../features.js';
|
||||
const service = restana();
|
||||
const featureRouter = service.newRouter();
|
||||
|
||||
featureRouter.get('/', async (req, res) => {
|
||||
const features = getFeatures();
|
||||
res.body = Object.assign({}, { features });
|
||||
res.send();
|
||||
});
|
||||
|
||||
export { featureRouter };
|
||||
@@ -1,24 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import restana from 'restana';
|
||||
import { config, getDirName, readConfigFromStorage, refreshConfig } from '../../utils.js';
|
||||
import { getDirName } from '../../utils.js';
|
||||
import fs from 'fs';
|
||||
import { ensureDemoUserExists } from '../../services/storage/userStorage.js';
|
||||
import logger from '../../services/logger.js';
|
||||
import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
|
||||
const service = restana();
|
||||
const generalSettingsRouter = service.newRouter();
|
||||
|
||||
generalSettingsRouter.get('/', async (req, res) => {
|
||||
res.body = Object.assign({}, config);
|
||||
res.body = Object.assign({}, await getSettings());
|
||||
res.send();
|
||||
});
|
||||
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 {
|
||||
if (config.demoMode) {
|
||||
res.send(new Error('In demo mode, it is not allowed to change these settings.'));
|
||||
return;
|
||||
if (typeof sqlitepath !== 'undefined') {
|
||||
fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ sqlitepath }));
|
||||
}
|
||||
const currentConfig = await readConfigFromStorage();
|
||||
fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ ...currentConfig, ...settings }));
|
||||
await refreshConfig();
|
||||
upsertSettings(appSettings);
|
||||
ensureDemoUserExists();
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import restana from 'restana';
|
||||
import * as jobStorage from '../../services/storage/jobStorage.js';
|
||||
import * as userStorage from '../../services/storage/userStorage.js';
|
||||
import { config } from '../../utils.js';
|
||||
import { isAdmin } from '../security.js';
|
||||
import logger from '../../services/logger.js';
|
||||
import { bus } from '../../services/events/event-bus.js';
|
||||
@@ -43,14 +47,6 @@ jobRouter.get('/', async (req, res) => {
|
||||
res.send();
|
||||
});
|
||||
|
||||
jobRouter.get('/processingTimes', async (req, res) => {
|
||||
res.body = {
|
||||
interval: config.interval,
|
||||
lastRun: config.lastRun || null,
|
||||
};
|
||||
res.send();
|
||||
});
|
||||
|
||||
jobRouter.post('/startAll', async (req, res) => {
|
||||
bus.emit('jobs:runAll');
|
||||
res.send();
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import restana from 'restana';
|
||||
import * as listingStorage from '../../services/storage/listingsStorage.js';
|
||||
import * as watchListStorage from '../../services/storage/watchListStorage.js';
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import restana from 'restana';
|
||||
import * as userStorage from '../../services/storage/userStorage.js';
|
||||
import * as hasher from '../../services/security/hash.js';
|
||||
import { config } from '../../utils.js';
|
||||
import { trackDemoAccessed } from '../../services/tracking/Tracker.js';
|
||||
import logger from '../../services/logger.js';
|
||||
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||
const service = restana();
|
||||
const loginRouter = service.newRouter();
|
||||
loginRouter.get('/user', async (req, res) => {
|
||||
@@ -20,6 +25,7 @@ loginRouter.get('/user', async (req, res) => {
|
||||
res.send();
|
||||
});
|
||||
loginRouter.post('/', async (req, res) => {
|
||||
const settings = await getSettings();
|
||||
const { username, password } = req.body;
|
||||
const user = userStorage.getUsers(true).find((user) => user.username === username);
|
||||
if (user == null) {
|
||||
@@ -27,7 +33,7 @@ loginRouter.post('/', async (req, res) => {
|
||||
return;
|
||||
}
|
||||
if (user.password === hasher.hash(password)) {
|
||||
if (config.demoMode) {
|
||||
if (settings.demoMode) {
|
||||
await trackDemoAccessed();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import restana from 'restana';
|
||||
const service = restana();
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import restana from 'restana';
|
||||
const service = restana();
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import restana from 'restana';
|
||||
import * as userStorage from '../../services/storage/userStorage.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 userRouter = service.newRouter();
|
||||
function checkIfAnyAdminAfterRemovingUser(userIdToBeRemoved, allUser) {
|
||||
@@ -23,7 +28,8 @@ userRouter.get('/:userId', async (req, res) => {
|
||||
res.send();
|
||||
});
|
||||
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.'));
|
||||
return;
|
||||
}
|
||||
@@ -44,7 +50,8 @@ userRouter.delete('/', async (req, res) => {
|
||||
res.send();
|
||||
});
|
||||
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.'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import restana from 'restana';
|
||||
import fetch from 'node-fetch';
|
||||
import { getPackageVersion } from '../../utils.js';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import * as userStorage from '../services/storage/userStorage.js';
|
||||
import cookieSession from 'cookie-session';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
export const DEFAULT_CONFIG = {
|
||||
interval: '60',
|
||||
port: 9998,
|
||||
workingHours: { from: '', to: '' },
|
||||
demoMode: false,
|
||||
analyticsEnabled: null,
|
||||
// Default path for sqlite storage directory. Interpreted relative to project root.
|
||||
sqlitepath: '/db',
|
||||
};
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
class ExtendableError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
|
||||
14
lib/features.js
Normal file
14
lib/features.js
Normal file
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
const FEATURES = {
|
||||
WATCHLIST_MANAGEMENT: false,
|
||||
};
|
||||
|
||||
export default function getFeatures() {
|
||||
return {
|
||||
...FEATURES,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { markdown2Html } from '../../services/markdown.js';
|
||||
import { getJob } from '../../services/storage/jobStorage.js';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
### Apprise Adapter
|
||||
|
||||
Refer to the [instructions](https://github.com/caronc/apprise-api#installation) on how to set up an Apprise instance and how to configure your preferred notification service.
|
||||
Use [Apprise](https://github.com/caronc/apprise-api#installation) to forward notifications to many different services.
|
||||
|
||||
Quick start:
|
||||
- Set up an Apprise API instance (see the installation guide linked above).
|
||||
- Configure your preferred notification service(s) within Apprise.
|
||||
- In Fredy, point the Apprise adapter to your Apprise API endpoint.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { markdown2Html } from '../../services/markdown.js';
|
||||
|
||||
export const send = ({ serviceName, newListings, jobKey }) => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
### 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
|
||||
criteria meet the expectations.
|
||||
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.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import fetch from 'node-fetch';
|
||||
import { getJob } from '../../services/storage/jobStorage.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).
|
||||
Once you have created a webhook, copy and paste the webhook URL.
|
||||
Use a Discord channel webhook to receive notifications.
|
||||
|
||||
Quick start:
|
||||
- Create a webhook in your target Discord channel. See the "Intro to Webhooks" guide on the Discord support site: https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks
|
||||
- Copy the generated webhook URL.
|
||||
- In Fredy, configure the Discord adapter with this webhook URL.
|
||||
|
||||
62
lib/notification/adapter/http.js
Normal file
62
lib/notification/adapter/http.js
Normal file
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { markdown2Html } from '../../services/markdown.js';
|
||||
|
||||
const mapListing = (listing) => ({
|
||||
address: listing.address,
|
||||
description: listing.description,
|
||||
id: listing.id,
|
||||
imageUrl: listing.image,
|
||||
price: listing.price,
|
||||
size: listing.size,
|
||||
title: listing.title,
|
||||
url: listing.link,
|
||||
});
|
||||
|
||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||
const { authToken, endpointUrl } = notificationConfig.find((a) => a.id === config.id).fields;
|
||||
|
||||
const listings = newListings.map(mapListing);
|
||||
const body = {
|
||||
jobId: jobKey,
|
||||
timestamp: new Date().toISOString(),
|
||||
provider: serviceName,
|
||||
listings,
|
||||
};
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (authToken != null) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
return fetch(endpointUrl, {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
};
|
||||
|
||||
export const config = {
|
||||
id: 'http',
|
||||
name: 'HTTP',
|
||||
readme: markdown2Html('lib/notification/adapter/http.md'),
|
||||
description: 'Fredy will send a generic HTTP POST request.',
|
||||
fields: {
|
||||
endpointUrl: {
|
||||
description: "Your application's endpoint URL.",
|
||||
label: 'Endpoint URL',
|
||||
type: 'text',
|
||||
},
|
||||
authToken: {
|
||||
description: "Your application's auth token, if required by your endpoint.",
|
||||
label: 'Auth token (optional)',
|
||||
optional: true,
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
};
|
||||
43
lib/notification/adapter/http.md
Normal file
43
lib/notification/adapter/http.md
Normal file
@@ -0,0 +1,43 @@
|
||||
### HTTP Adapter
|
||||
|
||||
This is a generic adapter for sending notifications via HTTP requests.
|
||||
You can leverage this adapter to integrate with various webhooks or APIs that accept HTTP requests. (e.g. Supabase
|
||||
Functions, a Node.js server, etc.)
|
||||
|
||||
HTTP adapter supports a `authToken` field, which can be used to include an authorization token in the request headers.
|
||||
Your token would be included as a Bearer token in the `Authorization` header, which is a common method for securing API requests.
|
||||
|
||||
Request Details:
|
||||
<details>
|
||||
Request Method: POST
|
||||
|
||||
Headers:
|
||||
|
||||
```
|
||||
Content Type: `application/json`
|
||||
Authorization: Bearer {your-optional-auth-token}
|
||||
```
|
||||
|
||||
Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"jobId": "mg1waX4RHmIzL5NDYtYp-",
|
||||
"provider": "immoscout",
|
||||
"timestamp": "2024-06-15T12:34:56Z",
|
||||
"listings": [
|
||||
{
|
||||
"address": "Str. 123, Bielefeld, Germany",
|
||||
"description": "Möbliert: Einziehen & wohlfühlen: Neu möbliert.",
|
||||
"id": "123456789",
|
||||
"imageUrl": "https://<target-url>.com/listings/123456789.jpg",
|
||||
"price": "1.240 €",
|
||||
"size": "38 m²",
|
||||
"title": "Schöne 1-Zimmer-Wohnung in Bielefeld",
|
||||
"url": "https://<target-url>.com/listings/123456789"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import mailjet from 'node-mailjet';
|
||||
import path from 'path';
|
||||
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.
|
||||
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.
|
||||
For example, if you use yourGmailAccount@gmail.com, add and verify this address in Mailjet.
|
||||
Provide your public/private API keys in Fredy's configuration. Fredy uses the same email template as for SendGrid.
|
||||
|
||||
If this email should be sent to multiple receiver, use a comma separator (some@email.com, someOther@email.com).
|
||||
To send to multiple recipients, separate email addresses with commas (e.g., some@email.com, someOther@email.com).
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { markdown2Html } from '../../services/markdown.js';
|
||||
import { getJob } from '../../services/storage/jobStorage.js';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
### Mattermost Adapter
|
||||
|
||||
For Mattermost, you need to create a incoming webhook. This is pretty easy. Please visit the steps in the [developer docs](https://docs.mattermost.com/developer/webhooks-incoming.html) and follow the instructions.
|
||||
Receive notifications in Mattermost via an incoming webhook.
|
||||
|
||||
As a result, you get the webhook URL for configuration in fredy. In addition, the target channel must be defined.
|
||||
Quick start:
|
||||
- Create an incoming webhook following the Mattermost developer docs: https://docs.mattermost.com/developer/webhooks-incoming.html
|
||||
- Copy the webhook URL.
|
||||
- In Fredy, configure the Mattermost adapter with this URL and the target channel.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { markdown2Html } from '../../services/markdown.js';
|
||||
import { getJob } from '../../services/storage/jobStorage.js';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
### ntfy Adapter
|
||||
|
||||
For ntfy, you need to create a topic on your preferred ntfy instance. This is pretty easy. Please visit the steps in the [docs](https://docs.ntfy.sh/publish/) and follow the instructions.
|
||||
Send push notifications using an ntfy topic.
|
||||
|
||||
As a result, you get the URL for configuration in fredy. In addition, the priority must be defined.
|
||||
Quick start:
|
||||
- Create or choose a topic on your preferred ntfy instance (see docs: https://docs.ntfy.sh/publish/).
|
||||
- Copy the publish URL for that topic.
|
||||
- In Fredy, configure the ntfy adapter with the topic URL and set a priority.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { markdown2Html } from '../../services/markdown.js';
|
||||
import { getJob } from '../../services/storage/jobStorage.js';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
### Pushover Adapter
|
||||
|
||||
Refer to the [instructions](https://support.pushover.net/i7-what-is-pushover-and-how-do-i-use-it) to set up your Pushover application.
|
||||
Use Pushover to receive push notifications on your devices.
|
||||
|
||||
After setting up the application, please enter both your newly created User key and API token.
|
||||
Setup:
|
||||
- Follow Pushover's getting-started guide: https://support.pushover.net/i7-what-is-pushover-and-how-do-i-use-it
|
||||
- Create an application and obtain your User Key and API Token.
|
||||
- In Fredy, configure the Pushover adapter with both values.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import sgMail from '@sendgrid/mail';
|
||||
import { markdown2Html } from '../../services/markdown.js';
|
||||
import { normalizeImageUrl } from '../../utils.js';
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
### 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`.
|
||||
|
||||
If this email should be sent to multiple receiver use a comma separator (some@email.com, someOther@email.com).
|
||||
Sending to multiple recipients:
|
||||
- Separate email addresses with commas (e.g., some@email.com, someOther@email.com).
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import Slack from 'slack';
|
||||
import { markdown2Html } from '../../services/markdown.js';
|
||||
import { normalizeImageUrl } from '../../utils.js';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
### Slack Adapter
|
||||
IMPORTANT:
|
||||
Don't use this adapter anymore, it is outdated and only here for backwards compatability reasons. Use the new Slack Adapter with webhooks!
|
||||
### Slack Adapter (Legacy)
|
||||
|
||||
*IMPORTANT:*
|
||||
This legacy adapter is outdated and kept only for backward compatibility. Please use the Slack adapter with webhooks instead.
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import fetch from 'node-fetch';
|
||||
import { markdown2Html } from '../../services/markdown.js';
|
||||
import { normalizeImageUrl } from '../../utils.js';
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
### Slack Adapter
|
||||
### Slack Adapter (Webhooks)
|
||||
|
||||
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.
|
||||
*IMPORTANT:*
|
||||
This is the recommended Slack adapter. The old Slack adapter is unmaintained and kept only for backward compatibility.
|
||||
|
||||
In order to use [Slack](https://slack.com), you need to create an account. When done, create a new channel and add the Webhook integration to that channel. Copy the webhook url. That's it.
|
||||
Setup:
|
||||
- Create a Slack account and workspace if you don't have one: https://slack.com
|
||||
- Create a channel where you want to receive notifications.
|
||||
- Add the Incoming Webhooks integration to that channel and copy the Webhook URL.
|
||||
- In Fredy, configure the Slack Webhook adapter with this URL.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { markdown2Html } from '../../services/markdown.js';
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
### 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`):
|
||||
|
||||
```
|
||||
['serviceName', 'jobKey', 'id', 'size', 'rooms', 'price', 'address', 'title', 'link', 'description', 'image']
|
||||
```json
|
||||
[
|
||||
"serviceName",
|
||||
"jobKey",
|
||||
"id",
|
||||
"size",
|
||||
"rooms",
|
||||
"price",
|
||||
"address",
|
||||
"title",
|
||||
"link",
|
||||
"description",
|
||||
"image"
|
||||
]
|
||||
```
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { markdown2Html } from '../../services/markdown.js';
|
||||
import { getJob } from '../../services/storage/jobStorage.js';
|
||||
import fetch from 'node-fetch';
|
||||
@@ -117,10 +122,24 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
|
||||
if (!adapterCfg || !adapterCfg.fields) {
|
||||
throw new Error(`Telegram adapter configuration missing for job '${jobKey || ''}'`);
|
||||
}
|
||||
const { token, chatId } = adapterCfg.fields;
|
||||
const { token, chatId, messageThreadId } = adapterCfg.fields;
|
||||
if (!token || !chatId) {
|
||||
throw new Error("Telegram 'token' and 'chatId' must be provided in notification config");
|
||||
}
|
||||
|
||||
// Optional Telegram topic/thread support (supergroups)
|
||||
let message_thread_id;
|
||||
if (messageThreadId !== undefined && messageThreadId !== null && `${messageThreadId}`.trim() !== '') {
|
||||
const n = Number(messageThreadId);
|
||||
if (Number.isInteger(n) && n > 0) {
|
||||
message_thread_id = n;
|
||||
} else {
|
||||
logger.warn(
|
||||
`Telegram adapter: 'messageThreadId' is invalid ('${messageThreadId}'). It must be a positive integer. Ignoring.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const job = getJob(jobKey);
|
||||
const jobName = job == null ? jobKey : job.name;
|
||||
|
||||
@@ -147,6 +166,7 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
|
||||
text: buildText(jobName, serviceName, o),
|
||||
parse_mode: 'HTML',
|
||||
disable_web_page_preview: true,
|
||||
...(message_thread_id ? { message_thread_id } : {}),
|
||||
};
|
||||
|
||||
if (!img) {
|
||||
@@ -160,6 +180,7 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
|
||||
photo: img,
|
||||
caption: buildCaption(jobName, serviceName, o),
|
||||
parse_mode: 'HTML',
|
||||
...(message_thread_id ? { message_thread_id } : {}),
|
||||
}).catch(async (e) => {
|
||||
logger.error(`Error sending photo to Telegram and use a fallback: ${e.message}`);
|
||||
return await throttledCall('sendMessage', textPayload).catch((e) => {
|
||||
@@ -174,7 +195,7 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
|
||||
|
||||
/**
|
||||
* 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 = {
|
||||
id: 'telegram',
|
||||
@@ -192,5 +213,12 @@ export const config = {
|
||||
label: 'Chat Id',
|
||||
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
|
||||
|
||||
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.
|
||||
After the user has send a message to your bot the first time, you can gather the chatId like this:
|
||||
#### Create a bot
|
||||
Create a bot with BotFather: open https://telegram.me/BotFather on your phone or in Telegram Desktop and follow the instructions to get your bot token.
|
||||
|
||||
#### Getting the chat ID
|
||||
A Telegram bot cannot message a user first; you must create a conversation (or add the bot to a group/channel) so Telegram assigns a chat the bot can access.
|
||||
|
||||
Steps:
|
||||
1. Start a chat with your bot in Telegram (or add the bot to your group/supergroup/channel) and send any message.
|
||||
2. Fetch recent updates from the Bot API:
|
||||
```
|
||||
curl -X GET "https://api.telegram.org/bot{YOUR_TELEGRAM_TOKEN}/getUpdates"
|
||||
```
|
||||
3. In the JSON response, find the message that you just sent and read `message.chat.id`. That value is your `chatId`.
|
||||
- Private chats: `chat.id` is a positive number
|
||||
- Groups/supergroups: `chat.id` is a negative number
|
||||
|
||||
Keep your bot token secret. If `getUpdates` returns an empty list, send a new message and try again, or make sure your bot’s privacy settings allow it to see group messages when used in groups.
|
||||
|
||||
#### Getting the thread ID (this is optional to be used for forum topics)
|
||||
If you want messages to appear inside a specific forum topic of a supergroup with Topics enabled, you also need a thread ID. In the Telegram Bot API this is called `message_thread_id`.
|
||||
|
||||
When you need it:
|
||||
- Required only for supergroups with Topics enabled when targeting a topic
|
||||
- Not used for private chats, basic groups without Topics, or channels
|
||||
|
||||
Steps to obtain it:
|
||||
1. In your supergroup, enable Topics (Group settings → Manage group → Topics → Enable). Now add a new topic.
|
||||
2. Add your created bot to the topic. (Click on the bot and on "Add to group")
|
||||
3. Open the desired topic (or create a new one) and send any message inside that topic.
|
||||
4. Call `getUpdates` again:
|
||||
```
|
||||
curl -X GET "https://api.telegram.org/bot{YOUR_TELEGRAM_TOKEN}/getUpdates"
|
||||
```
|
||||
4. In the update for the message you sent inside the topic, read `message.message_thread_id`. That number is your `threadId` for this topic.
|
||||
|
||||
Example (truncated):
|
||||
```
|
||||
curl -X GET https://api.telegram.org/bot{YOUR_TELEGRAM_TOKEN}/getUpdates
|
||||
{
|
||||
"message": {
|
||||
"chat": { "id": -1001234567890, "type": "supergroup" },
|
||||
"message_thread_id": 42,
|
||||
"text": "hello from the topic"
|
||||
}
|
||||
}
|
||||
```
|
||||
Use `chat.id` as `chatId` and `message_thread_id` as `threadId` in your configuration.
|
||||
|
||||
A more detailed list of instructions can be found here [https://core.telegram.org/bots#botfather](https://core.telegram.org/bots#botfather)
|
||||
More details about bots and BotFather: https://core.telegram.org/bots#botfather
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
const path = './adapter';
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { buildHash, isOneOf } from '../utils.js';
|
||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||
let appliedBlackList = [];
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { buildHash, isOneOf } from '../utils.js';
|
||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { isOneOf, buildHash } from '../utils.js';
|
||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||
let appliedBlackList = [];
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
/**
|
||||
* ImmoScout provider using the mobile API to retrieve listings.
|
||||
*
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { isOneOf, buildHash } from '../utils.js';
|
||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { buildHash, isOneOf } from '../utils.js';
|
||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { buildHash, isOneOf } from '../utils.js';
|
||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { isOneOf, buildHash } from '../utils.js';
|
||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||
let appliedBlackList = [];
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { isOneOf, buildHash } from '../utils.js';
|
||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||
|
||||
|
||||
50
lib/provider/ohneMakler.js
Executable file
50
lib/provider/ohneMakler.js
Executable file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { isOneOf, buildHash } from '../utils.js';
|
||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||
let appliedBlackList = [];
|
||||
|
||||
function normalize(o) {
|
||||
const link = metaInformation.baseUrl + o.link;
|
||||
const id = buildHash(o.title, o.link, o.price);
|
||||
return Object.assign(o, { link, id });
|
||||
}
|
||||
function applyBlacklist(o) {
|
||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||
return titleNotBlacklisted && descNotBlacklisted;
|
||||
}
|
||||
const config = {
|
||||
url: null,
|
||||
crawlContainer: 'div[data-livecomponent-id*="search/property_list"] .grid > div',
|
||||
sortByDateParam: null,
|
||||
waitForSelector: null,
|
||||
crawlFields: {
|
||||
id: 'a@href',
|
||||
title: 'h4 | removeNewline | trim',
|
||||
price: '.text-xl | trim',
|
||||
size: 'div[title="Wohnfläche"] | trim',
|
||||
address: '.text-slate-800 | removeNewline | trim',
|
||||
image: 'img@src',
|
||||
link: 'a@href',
|
||||
},
|
||||
normalize: normalize,
|
||||
filter: applyBlacklist,
|
||||
activeTester: checkIfListingIsActive,
|
||||
};
|
||||
|
||||
export const init = (sourceConfig, blacklist) => {
|
||||
config.enabled = sourceConfig.enabled;
|
||||
config.url = sourceConfig.url;
|
||||
appliedBlackList = blacklist || [];
|
||||
};
|
||||
|
||||
export const metaInformation = {
|
||||
name: 'OhneMakler',
|
||||
baseUrl: 'https://www.ohne-makler.net/immobilien',
|
||||
id: 'ohneMakler',
|
||||
};
|
||||
export { config };
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { isOneOf, buildHash } from '../utils.js';
|
||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||
let appliedBlackList = [];
|
||||
@@ -8,7 +13,7 @@ function normalize(o) {
|
||||
const title = o.title || 'No title available';
|
||||
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;
|
||||
return Object.assign(o, { id, address, title, link, image });
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { isOneOf, buildHash } from '../utils.js';
|
||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||
let appliedBlackList = [];
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { isOneOf, buildHash } from '../utils.js';
|
||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { removeJobsByUserId } from '../storage/jobStorage.js';
|
||||
import { config } from '../../utils.js';
|
||||
import { getUsers } from '../storage/userStorage.js';
|
||||
import logger from '../logger.js';
|
||||
import cron from 'node-cron';
|
||||
import { getSettings } from '../storage/settingsStorage.js';
|
||||
|
||||
/**
|
||||
* if we are running in demo environment, we have to cleanup the db files (specifically the jobs table)
|
||||
@@ -11,12 +16,13 @@ export function cleanupDemoAtMidnight() {
|
||||
cron.schedule('0 0 * * *', cleanup);
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (config.demoMode) {
|
||||
async function cleanup() {
|
||||
const settings = await getSettings();
|
||||
if (settings.demoMode) {
|
||||
const demoUser = getUsers(false).find((user) => user.username === 'demo');
|
||||
if (demoUser == null) {
|
||||
logger.error('Demo user not found, cannot remove Jobs');
|
||||
return;
|
||||
return Promise.resolve();
|
||||
}
|
||||
removeJobsByUserId(demoUser.id);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import cron from 'node-cron';
|
||||
import runActiveChecker from '../listings/listingActiveService.js';
|
||||
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import cron from 'node-cron';
|
||||
import { config, inDevMode } from '../../utils.js';
|
||||
import { inDevMode } from '../../utils.js';
|
||||
import { trackMainEvent } from '../tracking/Tracker.js';
|
||||
import { getSettings } from '../storage/settingsStorage.js';
|
||||
|
||||
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
|
||||
if (config.analyticsEnabled && !inDevMode()) {
|
||||
if (settings.analyticsEnabled && !inDevMode()) {
|
||||
await trackMainEvent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
export const bus = new EventEmitter();
|
||||
|
||||
279
lib/services/extractor/botPrevention.js
Normal file
279
lib/services/extractor/botPrevention.js
Normal file
@@ -0,0 +1,279 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { DEFAULT_HEADER } from './utils.js';
|
||||
|
||||
// Helper to safely coerce numbers
|
||||
const toInt = (v, d) => {
|
||||
const n = parseInt(v, 10);
|
||||
return Number.isFinite(n) ? n : d;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute pre-launch configuration and flags for Puppeteer with bot prevention in mind.
|
||||
* Returns language, user agent, viewport (with optional jitter), and additional launch args.
|
||||
*
|
||||
* @param {string} url
|
||||
* @param {object} [options]
|
||||
*/
|
||||
export function getPreLaunchConfig(url, options = {}) {
|
||||
const { hostname } = new URL(url);
|
||||
|
||||
const acceptLanguage = options.acceptLanguage || 'de-DE,de;q=0.9,en-US;q=0.7,en;q=0.5';
|
||||
const langForFlag = acceptLanguage.split(',')[0];
|
||||
|
||||
const baseViewport = { width: 1366, height: 768, deviceScaleFactor: 1 };
|
||||
const jitter = options.viewportJitter !== false ? Math.floor(Math.random() * 6) : 0; // 0..5 px
|
||||
const width = toInt(options?.viewport?.width, baseViewport.width) + jitter;
|
||||
const height = toInt(options?.viewport?.height, baseViewport.height) + jitter;
|
||||
const deviceScaleFactor = toInt(options?.viewport?.deviceScaleFactor, baseViewport.deviceScaleFactor);
|
||||
const viewport = { width, height, deviceScaleFactor };
|
||||
|
||||
const userAgent =
|
||||
options.userAgent ||
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
|
||||
|
||||
const windowSizeArg = `--window-size=${viewport.width},${viewport.height}`;
|
||||
const langArg = `--lang=${langForFlag}`;
|
||||
|
||||
const extraArgs = [
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--force-webrtc-ip-handling-policy=disable_non_proxied_udp',
|
||||
'--webrtc-ip-handling-policy=default_public_interface_only',
|
||||
'--proxy-bypass-list=<-loopback>',
|
||||
];
|
||||
|
||||
const headers = {
|
||||
...DEFAULT_HEADER,
|
||||
'Accept-Language': acceptLanguage,
|
||||
'User-Agent': userAgent,
|
||||
Referer: options?.referer || `https://${hostname}/`,
|
||||
Connection: 'keep-alive',
|
||||
DNT: '1',
|
||||
};
|
||||
|
||||
const timezone = options?.timezone || 'Europe/Berlin';
|
||||
|
||||
return {
|
||||
acceptLanguage,
|
||||
langForFlag,
|
||||
userAgent,
|
||||
viewport,
|
||||
windowSizeArg,
|
||||
langArg,
|
||||
extraArgs,
|
||||
headers,
|
||||
timezone,
|
||||
humanDelay: options?.humanDelay !== false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply bot-prevention hardening to a Puppeteer page.
|
||||
* Sets UA, viewport, JS enabled, headers, timezone and injects stealth-like patches.
|
||||
*
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @param {ReturnType<typeof getPreLaunchConfig>} cfg
|
||||
*/
|
||||
export async function applyBotPreventionToPage(page, cfg) {
|
||||
await page.setUserAgent(cfg.userAgent);
|
||||
await page.setViewport(cfg.viewport);
|
||||
await page.setJavaScriptEnabled(true);
|
||||
await page.setExtraHTTPHeaders(cfg.headers);
|
||||
try {
|
||||
if (cfg.timezone) await page.emulateTimezone(cfg.timezone);
|
||||
} catch {
|
||||
// ignore timezone failures
|
||||
}
|
||||
|
||||
// Inject patches as early as possible
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
try {
|
||||
// webdriver
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
||||
|
||||
// chrome runtime
|
||||
// @ts-ignore
|
||||
if (!window.chrome) {
|
||||
// @ts-ignore
|
||||
window.chrome = { runtime: {} };
|
||||
}
|
||||
|
||||
// languages
|
||||
// @ts-ignore
|
||||
Object.defineProperty(navigator, 'languages', {
|
||||
get: () => (window.localStorage.getItem('__LANGS__') || 'de-DE,de').split(','),
|
||||
});
|
||||
|
||||
// plugins
|
||||
// @ts-ignore
|
||||
Object.defineProperty(navigator, 'plugins', {
|
||||
get: () => [{}, {}, {}],
|
||||
});
|
||||
|
||||
// platform and concurrency hints
|
||||
// @ts-ignore
|
||||
Object.defineProperty(navigator, 'platform', { get: () => 'Win32' });
|
||||
// @ts-ignore
|
||||
if (typeof navigator.hardwareConcurrency === 'number' && navigator.hardwareConcurrency < 2) {
|
||||
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 4 });
|
||||
}
|
||||
// @ts-ignore
|
||||
if (typeof navigator.deviceMemory === 'number' && navigator.deviceMemory < 2) {
|
||||
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
|
||||
}
|
||||
|
||||
// userAgentData (Client Hints)
|
||||
try {
|
||||
// @ts-ignore
|
||||
if ('userAgentData' in navigator) {
|
||||
// @ts-ignore
|
||||
Object.defineProperty(navigator, 'userAgentData', {
|
||||
get: () => ({
|
||||
brands: [
|
||||
{ brand: 'Chromium', version: '126' },
|
||||
{ brand: 'Google Chrome', version: '126' },
|
||||
],
|
||||
mobile: false,
|
||||
platform: 'Windows',
|
||||
getHighEntropyValues: async (hints) => {
|
||||
const values = {
|
||||
platform: 'Windows',
|
||||
platformVersion: '15.0.0',
|
||||
architecture: 'x86',
|
||||
model: '',
|
||||
uaFullVersion: '126.0.0.0',
|
||||
bitness: '64',
|
||||
};
|
||||
const out = {};
|
||||
for (const k of hints || []) if (k in values) out[k] = values[k];
|
||||
return out;
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
//noop
|
||||
}
|
||||
|
||||
// Permissions API
|
||||
const origQuery = navigator.permissions && navigator.permissions.query;
|
||||
if (origQuery) {
|
||||
// @ts-ignore
|
||||
navigator.permissions.query = (parameters) =>
|
||||
origQuery.call(navigator.permissions, parameters).then((result) => {
|
||||
if (parameters && parameters.name === 'notifications') {
|
||||
Object.defineProperty(result, 'state', { get: () => Notification.permission });
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
// WebGL vendor/renderer
|
||||
const patchWebGL = (proto) => {
|
||||
if (!proto || !proto.getParameter) return;
|
||||
const getParameter = proto.getParameter;
|
||||
// @ts-ignore
|
||||
proto.getParameter = function (param) {
|
||||
const UNMASKED_VENDOR_WEBGL = 0x9245;
|
||||
const UNMASKED_RENDERER_WEBGL = 0x9246;
|
||||
if (param === UNMASKED_VENDOR_WEBGL) return 'Google Inc.';
|
||||
if (param === UNMASKED_RENDERER_WEBGL)
|
||||
return 'ANGLE (NVIDIA, NVIDIA GeForce GTX 1660 Ti Direct3D11 vs_5_0 ps_5_0)';
|
||||
return getParameter.call(this, param);
|
||||
};
|
||||
};
|
||||
// @ts-ignore
|
||||
patchWebGL(WebGLRenderingContext?.prototype);
|
||||
// @ts-ignore
|
||||
patchWebGL(WebGL2RenderingContext?.prototype);
|
||||
|
||||
// AudioContext timestamp rounding consistency
|
||||
const patchAudio = (Ctx) => {
|
||||
try {
|
||||
if (!Ctx) return;
|
||||
const proto = Ctx.prototype;
|
||||
const createOsc = proto.createOscillator;
|
||||
proto.createOscillator = function () {
|
||||
const osc = createOsc.call(this);
|
||||
const start = osc.start;
|
||||
osc.start = function (when) {
|
||||
return start.call(this, when || 0);
|
||||
};
|
||||
return osc;
|
||||
};
|
||||
} catch {
|
||||
//noop
|
||||
}
|
||||
};
|
||||
// @ts-ignore
|
||||
patchAudio(window.AudioContext);
|
||||
// @ts-ignore
|
||||
patchAudio(window.OfflineAudioContext);
|
||||
|
||||
// Navigator.connection
|
||||
try {
|
||||
// @ts-ignore
|
||||
Object.defineProperty(navigator, 'connection', { get: () => undefined });
|
||||
} catch {
|
||||
//noop
|
||||
}
|
||||
|
||||
// Consistent outer sizes
|
||||
try {
|
||||
const calcOuter = () => {
|
||||
const w = window.innerWidth + 16;
|
||||
const h = window.innerHeight + 88;
|
||||
return { w, h };
|
||||
};
|
||||
const { w: outerW, h: outerH } = calcOuter();
|
||||
// @ts-ignore
|
||||
Object.defineProperty(window, 'outerWidth', { get: () => outerW });
|
||||
// @ts-ignore
|
||||
Object.defineProperty(window, 'outerHeight', { get: () => outerH });
|
||||
} catch {
|
||||
//noop
|
||||
}
|
||||
} catch {
|
||||
//noop
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist languages value before navigation via localStorage.
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @param {ReturnType<typeof getPreLaunchConfig>} cfg
|
||||
*/
|
||||
export async function applyLanguagePersistence(page, cfg) {
|
||||
await page.evaluateOnNewDocument((langs) => {
|
||||
try {
|
||||
window.localStorage.setItem('__LANGS__', langs);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}, cfg.acceptLanguage.split(';')[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform subtle human-like interactions post navigation.
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @param {ReturnType<typeof getPreLaunchConfig>} cfg
|
||||
*/
|
||||
export async function applyPostNavigationHumanSignals(page, cfg) {
|
||||
if (!cfg.humanDelay) return;
|
||||
const delay = 200 + Math.floor(Math.random() * 400);
|
||||
await new Promise((res) => setTimeout(res, delay));
|
||||
try {
|
||||
const vw = cfg.viewport.width;
|
||||
const vh = cfg.viewport.height;
|
||||
const mx = Math.floor(vw * (0.3 + Math.random() * 0.4));
|
||||
const my = Math.floor(vh * (0.3 + Math.random() * 0.4));
|
||||
await page.mouse.move(mx, my, { steps: 10 + Math.floor(Math.random() * 10) });
|
||||
await page.mouse.wheel({ deltaY: 100 + Math.floor(Math.random() * 200) });
|
||||
} catch {
|
||||
// ignore if mouse is unavailable
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { setDebug } from './utils.js';
|
||||
import puppeteerExtractor from './puppeteerExtractor.js';
|
||||
import { loadParser, parse } from './parser/parser.js';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import * as cheerio from 'cheerio';
|
||||
import logger from '../../logger.js';
|
||||
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import puppeteer from 'puppeteer-extra';
|
||||
import 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 fs from 'fs';
|
||||
import os from 'os';
|
||||
@@ -27,23 +38,50 @@ export default async function execute(url, waitForSelector, options) {
|
||||
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({
|
||||
headless: options.puppeteerHeadless ?? true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-gpu',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-crash-reporter',
|
||||
],
|
||||
timeout: options.puppeteerTimeout || 30_000,
|
||||
headless: options?.puppeteerHeadless ?? true,
|
||||
args: launchArgs,
|
||||
timeout: options?.puppeteerTimeout || 30_000,
|
||||
userDataDir,
|
||||
executablePath: options?.executablePath, // allow using system Chrome
|
||||
});
|
||||
|
||||
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, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
waitUntil: options?.waitUntil || 'domcontentloaded',
|
||||
});
|
||||
|
||||
// Optionally wait and add subtle human-like interactions
|
||||
await applyPostNavigationHumanSignals(page, preCfg);
|
||||
|
||||
let pageSource;
|
||||
// if we're extracting data from a SPA, we must wait for the selector
|
||||
if (waitForSelector != null) {
|
||||
@@ -57,7 +95,7 @@ export default async function execute(url, waitForSelector, options) {
|
||||
pageSource = await page.content();
|
||||
}
|
||||
|
||||
const statusCode = response.status();
|
||||
const statusCode = response?.status?.() ?? 200;
|
||||
|
||||
if (botDetected(pageSource, statusCode)) {
|
||||
logger.warn('We have been detected as a bot :-/ Tried url: => ', url);
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import logger from '../logger.js';
|
||||
|
||||
let debuggingOn = false;
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
/*
|
||||
Rent a flat
|
||||
Web:
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { deactivateListings, getActiveOrUnknownListings } from '../storage/listingsStorage.js';
|
||||
import { getProviders } from '../../utils.js';
|
||||
import logger from '../../services/logger.js';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import fetch from 'node-fetch';
|
||||
import { randomBetween, sleep } from '../../utils.js';
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
const COLORS = {
|
||||
debug: '\x1b[36m',
|
||||
info: '\x1b[32m',
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import markdown$0 from 'markdown';
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
const markdown = markdown$0.markdown;
|
||||
export function markdown2Html(filePath) {
|
||||
return markdown.toHTML(fs.readFileSync(filePath, 'utf8'));
|
||||
return fs.readFileSync(filePath, 'utf8');
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import queryString from 'query-string';
|
||||
export default (_url, sortByDateParam) => {
|
||||
//if no mutation is necessary, just return the original url
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
export const hash = (x) => crypto.createHash('sha256').update(x, 'utf8').digest('hex');
|
||||
|
||||
@@ -1,116 +1,99 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
const retention = 60 * 60 * 1000;
|
||||
/**
|
||||
* Internal cache storage.
|
||||
* Maps a SHA-256 hash (string) to its expiry timestamp (number in ms).
|
||||
* @type {Map<string, number>}
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
const entries = new Map();
|
||||
|
||||
/**
|
||||
* Reference to the currently scheduled cleanup timer.
|
||||
* @type {NodeJS.Timeout | null}
|
||||
*/
|
||||
let timer = null;
|
||||
|
||||
/**
|
||||
* Generate a SHA-256 hash from a list of input strings.
|
||||
* Null or undefined values are ignored.
|
||||
* Similarity cache
|
||||
*
|
||||
* @param {...(string|null|undefined)} strings - Input values to hash
|
||||
* Maintains an in-memory Set of content hashes to detect whether a listing
|
||||
* (identified by a tuple of title, price and address) has been seen before.
|
||||
*
|
||||
* Design notes:
|
||||
* - The cache is refreshed periodically from persistent storage. To avoid
|
||||
* modification-during-iteration issues, the refresh builds a new Set and
|
||||
* atomically swaps the reference instead of mutating in place.
|
||||
* - Hashing ignores null/undefined values but preserves falsy-yet-valid values
|
||||
* like 0. Non-string values are coerced to strings before hashing.
|
||||
*
|
||||
* This module has no persistence of its own; it relies on
|
||||
* getAllEntriesFromListings() for data hydration.
|
||||
* @module similarityCache
|
||||
*/
|
||||
import crypto from 'crypto';
|
||||
import { getAllEntriesFromListings } from '../storage/listingsStorage.js';
|
||||
|
||||
/** @type {number} Refresh interval in milliseconds (defaults to one hour). */
|
||||
const reloadCycle = 60 * 60 * 1000; // every hour, refresh
|
||||
|
||||
/**
|
||||
* Internal cache of content hashes for known listings.
|
||||
*
|
||||
* Each entry is an SHA-256 hex digest produced by toHash(title, price, address).
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
let cache = new Set();
|
||||
|
||||
export const startSimilarityCacheReloader = () => {
|
||||
// Periodically refresh the cache from storage
|
||||
setInterval(() => {
|
||||
initSimilarityCache();
|
||||
}, reloadCycle);
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize or refresh the similarity cache from persistent storage.
|
||||
*
|
||||
* Reads all stored listings via getAllEntriesFromListings(), computes a hash for
|
||||
* each, and swaps the in-memory Set atomically to avoid in-place mutations that
|
||||
* could interfere with concurrent iteration.
|
||||
*
|
||||
* This function is idempotent and safe to call at any time.
|
||||
* @returns {void}
|
||||
*/
|
||||
export const initSimilarityCache = () => {
|
||||
const allEntries = getAllEntriesFromListings();
|
||||
const newCache = new Set();
|
||||
for (const entry of allEntries) {
|
||||
newCache.add(toHash(entry?.title, entry?.price, entry?.address));
|
||||
}
|
||||
// Atomic swap to avoid mutating the cache while it may be iterated elsewhere
|
||||
cache = newCache;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a listing is already known and add it to the cache if not.
|
||||
*
|
||||
* The listing is identified by the combination of its title, price and
|
||||
* address. Null/undefined fields are ignored during hashing. Falsy-but-valid
|
||||
* values (e.g., price 0) are preserved.
|
||||
*
|
||||
* @param {Object} params - Listing fields
|
||||
* @param {string|undefined|null} params.title - The listing title
|
||||
* @param {string|undefined|null} params.address - The listing address
|
||||
* @param {number|string|undefined|null} params.price - The listing price
|
||||
* @returns {boolean} true if the entry already existed in the cache (duplicate), otherwise false
|
||||
*/
|
||||
export const checkAndAddEntry = ({ title, address, price }) => {
|
||||
const hash = toHash(title, price, address);
|
||||
if (cache.has(hash)) {
|
||||
return true;
|
||||
}
|
||||
cache.add(hash);
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate an SHA-256 hash from a list of input values.
|
||||
* Null or undefined values are ignored. Falsy but valid values like 0 are preserved.
|
||||
* Non-string values are coerced to strings prior to hashing.
|
||||
*
|
||||
* @param {...(string|number|null|undefined)} strings - Input values to hash
|
||||
* @returns {string} Hexadecimal hash
|
||||
*/
|
||||
function toHash(...strings) {
|
||||
return crypto.createHash('sha256').update(strings.filter(Boolean).join('|')).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup expired cache entries and schedule the next cleanup run.
|
||||
* This function is invoked automatically by scheduled timers.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
function runCleanup() {
|
||||
const now = Date.now();
|
||||
for (const [hash, expiry] of entries) {
|
||||
if (expiry <= now) entries.delete(hash);
|
||||
}
|
||||
scheduleNext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the soonest expiry timestamp among all cache entries
|
||||
* and schedule a one-shot timer that will trigger at that time.
|
||||
* Cancels any existing timer before scheduling a new one.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
function scheduleNext() {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
let next = Infinity;
|
||||
const now = Date.now();
|
||||
for (const expiry of entries.values()) {
|
||||
if (expiry > now && expiry < next) next = expiry;
|
||||
}
|
||||
if (next !== Infinity) {
|
||||
timer = setTimeout(runCleanup, Math.max(0, next - now));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or refresh a cache entry for the given title and address.
|
||||
* The entry will automatically expire after the configured retention window.
|
||||
*
|
||||
* @param {string} title - The title used to build the cache key
|
||||
* @param {string} address - The address used to build the cache key
|
||||
*/
|
||||
export function addCacheEntry(title, address) {
|
||||
const hash = toHash(title, address);
|
||||
const expiry = Date.now() + retention;
|
||||
entries.set(hash, expiry);
|
||||
scheduleNext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a cache entry with the same title and address exists
|
||||
* and is still valid (not expired).
|
||||
*
|
||||
* @param {string} title - The title used to build the cache key
|
||||
* @param {string} address - The address used to build the cache key
|
||||
* @returns {boolean} True if a valid cache entry exists, false otherwise
|
||||
*/
|
||||
export function hasSimilarEntries(title, address) {
|
||||
const hash = toHash(title, address);
|
||||
const expiry = entries.get(hash);
|
||||
if (expiry == null) return false;
|
||||
if (expiry <= Date.now()) {
|
||||
entries.delete(hash);
|
||||
scheduleNext();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop any scheduled cleanup timers and prevent further automatic cleanup.
|
||||
* Entries that are already in the cache will remain until removed manually
|
||||
* or until cleanup is started again by adding new entries.
|
||||
*/
|
||||
export function stopCacheCleanup() {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* this is only for test purposes
|
||||
*/
|
||||
export function invalidateAllForTest() {
|
||||
for (const key of entries.keys()) {
|
||||
entries.set(key, 0);
|
||||
}
|
||||
runCleanup();
|
||||
const normalized = strings
|
||||
.filter((v) => v !== null && v !== undefined)
|
||||
.map((v) => (typeof v === 'string' ? v : String(v)));
|
||||
return crypto.createHash('sha256').update(normalized.join('|')).digest('hex');
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import Database from 'better-sqlite3';
|
||||
import logger from '../../services/logger.js';
|
||||
import { config } from '../../utils.js';
|
||||
import { readConfigFromStorage } from '../../utils.js';
|
||||
|
||||
/**
|
||||
* SqliteConnection
|
||||
@@ -25,6 +30,15 @@ import { config } from '../../utils.js';
|
||||
class SqliteConnection {
|
||||
static #db = null;
|
||||
|
||||
static #sqlLiteCfg = null;
|
||||
|
||||
static async init() {
|
||||
if (this.#sqlLiteCfg == null) {
|
||||
readConfigFromStorage().then((c) => {
|
||||
this.#sqlLiteCfg = c.sqlitepath;
|
||||
});
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Returns a singleton instance of better-sqlite3 Database.
|
||||
* Respects env var SQLITE_DB_PATH and defaults to db/listings.db.
|
||||
@@ -32,9 +46,12 @@ class SqliteConnection {
|
||||
static getConnection() {
|
||||
if (this.#db) return this.#db;
|
||||
|
||||
if (this.#sqlLiteCfg == null) {
|
||||
logger.warn('No sqlitepath configured. Using default db/listings.db');
|
||||
}
|
||||
|
||||
// Interpret config.sqlitepath as a directory relative to project root when it starts with '/'
|
||||
const cfg = typeof config === 'object' && config ? config.sqlitepath : undefined;
|
||||
const rawDir = cfg && cfg.length > 0 ? cfg : '/db';
|
||||
const rawDir = this.#sqlLiteCfg && this.#sqlLiteCfg.length > 0 ? this.#sqlLiteCfg : '/db';
|
||||
const relDir = rawDir.startsWith('/') ? rawDir.slice(1) : rawDir;
|
||||
const absDir = path.isAbsolute(relDir) ? relDir : path.join(process.cwd(), relDir);
|
||||
const dbPath = path.join(absDir, 'listings.db');
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import SqliteConnection from './SqliteConnection.js';
|
||||
import logger from '../logger.js';
|
||||
|
||||
@@ -1,41 +1,12 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { nullOrEmpty } from '../../utils.js';
|
||||
import SqliteConnection from './SqliteConnection.js';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
/**
|
||||
* Build analytics data for a given job by grouping all listings by provider and
|
||||
* mapping each listing hash to its creation timestamp.
|
||||
*
|
||||
* SQL shape:
|
||||
* SELECT json_group_object(provider, json_object(hash, created_at)) AS result
|
||||
* FROM listings WHERE job_id = @jobId;
|
||||
*
|
||||
* The resulting object has the shape:
|
||||
* {
|
||||
* providerA: { "<hash1>": <created_at_ms>, "<hash2>": <created_at_ms>, ... },
|
||||
* providerB: { ... }
|
||||
* }
|
||||
*
|
||||
* @param {string} jobId - ID of the job whose listings should be aggregated.
|
||||
* @returns {Record<string, Record<string, number>>} Object grouped by provider mapping listing-hash -> created_at epoch ms.
|
||||
*/
|
||||
export const getListingProviderDataForAnalytics = (jobId) => {
|
||||
const row = SqliteConnection.query(
|
||||
`SELECT COALESCE(
|
||||
json_group_object(provider, json(provider_map)),
|
||||
json('{}')
|
||||
) AS result
|
||||
FROM (SELECT provider,
|
||||
json_group_object(hash, created_at) AS provider_map
|
||||
FROM listings
|
||||
WHERE job_id = @jobId
|
||||
GROUP BY provider);`,
|
||||
{ jobId },
|
||||
);
|
||||
|
||||
return row?.length > 0 ? JSON.parse(row[0].result) : {};
|
||||
};
|
||||
|
||||
/**
|
||||
* Return a list of known listing hashes for a given job and provider.
|
||||
* Useful to de-duplicate before inserting new listings.
|
||||
@@ -54,6 +25,89 @@ export const getKnownListingHashesForJobAndProvider = (jobId, providerId) => {
|
||||
).map((r) => r.hash);
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute KPI aggregates for a given set of job IDs from the listings table.
|
||||
*
|
||||
* - numberOfActiveListings: count of listings where is_active = 1
|
||||
* - avgPriceOfListings: average of numeric price, rounded to nearest integer
|
||||
*
|
||||
* When no jobIds are provided, returns zeros.
|
||||
*
|
||||
* @param {string[]} jobIds
|
||||
* @returns {{ numberOfActiveListings: number, avgPriceOfListings: number }}
|
||||
*/
|
||||
export const getListingsKpisForJobIds = (jobIds = []) => {
|
||||
if (!Array.isArray(jobIds) || jobIds.length === 0) {
|
||||
return { numberOfActiveListings: 0, avgPriceOfListings: 0 };
|
||||
}
|
||||
|
||||
const placeholders = jobIds.map(() => '?').join(',');
|
||||
const row =
|
||||
SqliteConnection.query(
|
||||
`SELECT
|
||||
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) AS activeCount,
|
||||
AVG(price) AS avgPrice
|
||||
FROM listings
|
||||
WHERE job_id IN (${placeholders})`,
|
||||
jobIds,
|
||||
)[0] || {};
|
||||
|
||||
return {
|
||||
numberOfActiveListings: Number(row.activeCount || 0),
|
||||
avgPriceOfListings: row?.avgPrice == null ? 0 : Math.round(Number(row.avgPrice)),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute distribution of listings by provider for the given set of job IDs.
|
||||
* Returns data ready for the pie chart component with fields `type` and `value` (percentage).
|
||||
*
|
||||
* Example return:
|
||||
* [ { type: 'immoscout', value: 62 }, { type: 'immowelt', value: 38 } ]
|
||||
*
|
||||
* When no jobIds are provided or no listings exist, returns empty array.
|
||||
*
|
||||
* @param {string[]} jobIds
|
||||
* @returns {{ type: string, value: number }[]}
|
||||
*/
|
||||
export const getProviderDistributionForJobIds = (jobIds = []) => {
|
||||
if (!Array.isArray(jobIds) || jobIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const placeholders = jobIds.map(() => '?').join(',');
|
||||
const rows = SqliteConnection.query(
|
||||
`SELECT provider, COUNT(*) AS cnt
|
||||
FROM listings
|
||||
WHERE job_id IN (${placeholders})
|
||||
GROUP BY provider
|
||||
ORDER BY cnt DESC`,
|
||||
jobIds,
|
||||
);
|
||||
|
||||
const total = rows.reduce((acc, r) => acc + Number(r.cnt || 0), 0);
|
||||
if (total === 0) return [];
|
||||
|
||||
// Map counts to integer percentage values (0-100). Ensure sum is ~100 by rounding.
|
||||
const percentages = rows.map((r) => ({
|
||||
type: r.provider,
|
||||
value: Math.round((Number(r.cnt) / total) * 100),
|
||||
}));
|
||||
|
||||
// Adjust rounding drift to keep sum at 100 (optional minor correction)
|
||||
const drift = 100 - percentages.reduce((s, p) => s + p.value, 0);
|
||||
if (drift !== 0 && percentages.length > 0) {
|
||||
// apply drift to the largest slice to keep UX simple
|
||||
let maxIdx = 0;
|
||||
for (let i = 1; i < percentages.length; i++) {
|
||||
if (percentages[i].value > percentages[maxIdx].value) maxIdx = i;
|
||||
}
|
||||
percentages[maxIdx].value = Math.max(0, percentages[maxIdx].value + drift);
|
||||
}
|
||||
|
||||
return percentages;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return a list of listing that either are active or have an unknown status
|
||||
* to constantly check if they are still online
|
||||
@@ -152,8 +206,9 @@ export const storeListings = (jobId, providerId, listings) => {
|
||||
*/
|
||||
function extractNumber(str) {
|
||||
if (!str) return null;
|
||||
const match = str.replace(/[.,]/g, '').match(/\d+/);
|
||||
return match ? +match[0] : null;
|
||||
const cleaned = str.replace(/\./g, '').replace(',', '.');
|
||||
const num = parseFloat(cleaned);
|
||||
return isNaN(num) ? null : num;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -310,8 +365,8 @@ export const deleteListingsByJobId = (jobId) => {
|
||||
if (!jobId) return;
|
||||
return SqliteConnection.execute(
|
||||
`DELETE
|
||||
FROM listings
|
||||
WHERE job_id = @jobId`,
|
||||
FROM listings
|
||||
WHERE job_id = @jobId`,
|
||||
{ jobId },
|
||||
);
|
||||
};
|
||||
@@ -332,3 +387,13 @@ export const deleteListingsById = (ids) => {
|
||||
ids,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Return all listings with only the fields: title, address, and price.
|
||||
* This is the single helper requested for simple consumers.
|
||||
*
|
||||
* @returns {{title: string|null, address: string|null, price: number|null}[]}
|
||||
*/
|
||||
export const getAllEntriesFromListings = () => {
|
||||
return SqliteConnection.query(`SELECT title, address, price FROM listings`);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
/**
|
||||
* Migration Runner for better-sqlite3
|
||||
* I know there are external libs out there, but
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
// Initial migration: creates schema_migrations table used by the migration runner.
|
||||
//
|
||||
export function up(db) {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
// Migration: Create fredy's base structure (users, jobs and listings) import initial
|
||||
// data from JSON files if present. (This applies only for jobs and users, for the old jobListingData,
|
||||
// I cannot migrate the data as the new format is totally different.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
// Migration: there needs to be a unique index on job_id and hash as only
|
||||
// this makes the listing indeed unique
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
// Migration: there needs to be a unique index on job_id and hash as only
|
||||
// this makes the listing indeed unique
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
// Migration: Adding a changeset field to the listings table in preparation for
|
||||
// a price watch feature
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
// Migration: Adding a new table to store if somebody "watches" (a.k.a favorite) a listing
|
||||
|
||||
export function up(db) {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
// Migration: Adding a new table to store if somebody shared a job with someone
|
||||
|
||||
export function up(db) {
|
||||
|
||||
84
lib/services/storage/migrations/sql/6.settings.js
Normal file
84
lib/services/storage/migrations/sql/6.settings.js
Normal file
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
// Migration: Adding a settings table to store important (config) settings instead of using config file
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { nanoid } from 'nanoid';
|
||||
import logger from '../../../logger.js';
|
||||
import { DEFAULT_CONFIG } from '../../../../defaultConfig.js';
|
||||
import { getDirName } from '../../../../utils.js';
|
||||
|
||||
export function up(db) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS settings
|
||||
(
|
||||
id TEXT PRIMARY KEY,
|
||||
create_date INTEGER NOT NULL,
|
||||
user_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
value jsonb NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_settings_name ON settings (name);
|
||||
`);
|
||||
|
||||
// Helper to insert one setting row
|
||||
const insertSetting = (name, rawValue) => {
|
||||
try {
|
||||
const id = nanoid();
|
||||
const createDate = Date.now();
|
||||
const value = JSON.stringify(rawValue);
|
||||
db.prepare(
|
||||
`INSERT INTO settings (id, create_date, name, value)
|
||||
VALUES (@id, @create_date, @name, @value)`,
|
||||
).run({ id, create_date: createDate, name, value });
|
||||
} catch {
|
||||
// Ignore duplicate inserts if any (unique by name)
|
||||
}
|
||||
};
|
||||
|
||||
// Migrate currently existing config.json into settings
|
||||
try {
|
||||
const configPath = path.resolve(process.cwd(), 'conf', 'config.json');
|
||||
|
||||
// Defaults
|
||||
const defaults = {
|
||||
interval: '60',
|
||||
port: 9998,
|
||||
workingHours: { from: '', to: '' },
|
||||
demoMode: false,
|
||||
analyticsEnabled: true,
|
||||
};
|
||||
|
||||
let config = {};
|
||||
if (fs.existsSync(configPath)) {
|
||||
const file = fs.readFileSync(configPath, 'utf8');
|
||||
try {
|
||||
config = JSON.parse(file) || {};
|
||||
} catch (parseErr) {
|
||||
// If parsing fails, still proceed with defaults
|
||||
logger.error(parseErr);
|
||||
config = {};
|
||||
}
|
||||
}
|
||||
|
||||
// Insert each known setting, using the value from config when present, otherwise default
|
||||
insertSetting('interval', config.interval != null ? config.interval : defaults.interval);
|
||||
insertSetting('port', config.port != null ? config.port : defaults.port);
|
||||
insertSetting('workingHours', config.workingHours != null ? config.workingHours : defaults.workingHours);
|
||||
insertSetting('demoMode', config.demoMode != null ? config.demoMode : defaults.demoMode);
|
||||
insertSetting(
|
||||
'analyticsEnabled',
|
||||
config.analyticsEnabled != null ? config.analyticsEnabled : defaults.analyticsEnabled,
|
||||
);
|
||||
|
||||
//now making sure only sqlite path remains in the config
|
||||
const sqlitepath = config.sqlitepath || DEFAULT_CONFIG.sqlitepath;
|
||||
fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ sqlitepath }));
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
}
|
||||
}
|
||||
92
lib/services/storage/settingsStorage.js
Normal file
92
lib/services/storage/settingsStorage.js
Normal file
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import SqliteConnection from './SqliteConnection.js';
|
||||
import { fromJson, readConfigFromStorage, toJson } from '../../utils.js';
|
||||
|
||||
// In-memory cache for compiled settings config
|
||||
/** @type {Record<string, any>|null} */
|
||||
let cachedSettingsConfig = null;
|
||||
|
||||
/**
|
||||
* Build a config object from DB rows of settings.
|
||||
* - Unwraps stored shape { value: any } into raw values.
|
||||
* - Add additional config values from file config. E.g. sqlite part cannot be stored in db for obvious reasons ;)
|
||||
* @param {{name:string, value:string|null}[]} rows
|
||||
* @param {{name:value}} configValues
|
||||
* @returns {Record<string, any>}
|
||||
*/
|
||||
function compileSettings(rows, configValues) {
|
||||
const config = {};
|
||||
for (const r of rows) {
|
||||
const parsed = fromJson(r.value, null);
|
||||
// unwrap { value: any } if present
|
||||
config[r.name] = parsed && typeof parsed === 'object' && 'value' in parsed ? parsed.value : parsed;
|
||||
}
|
||||
return {
|
||||
...configValues,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Force reload the settings config cache from DB and return it.
|
||||
* @returns {Record<string, any>}
|
||||
*/
|
||||
export async function refreshSettingsCache() {
|
||||
const rows = SqliteConnection.query(`SELECT name, value FROM settings`);
|
||||
const configValues = await readConfigFromStorage();
|
||||
cachedSettingsConfig = compileSettings(rows, configValues);
|
||||
return cachedSettingsConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the compiled settings config. Loads it once and caches the result.
|
||||
* @returns {Record<string, any>}
|
||||
*/
|
||||
export async function getSettings() {
|
||||
if (cachedSettingsConfig == null) {
|
||||
return refreshSettingsCache();
|
||||
}
|
||||
return cachedSettingsConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert settings rows.
|
||||
* - Accepts an object map of name -> value, or an entry {name, value}.
|
||||
* - id: random string (nanoid) when inserting
|
||||
* - create_date: epoch ms when inserting
|
||||
* - name: unique key
|
||||
* - value: JSON string of the raw value (no wrapper)
|
||||
* @param {Record<string, any>|{name:string, value:any}|[string, any][]} settingsMapOrEntry
|
||||
* @returns {void}
|
||||
*/
|
||||
// Upsert one or more settings by name. Accepts either a single pair or an object map.
|
||||
// Preferred usage: upsertSettings({ settingName: any, another: any })
|
||||
export function upsertSettings(settingsMapOrEntry, userId = null) {
|
||||
const entries = Array.isArray(settingsMapOrEntry)
|
||||
? settingsMapOrEntry
|
||||
: typeof settingsMapOrEntry === 'object' &&
|
||||
settingsMapOrEntry != null &&
|
||||
'name' in settingsMapOrEntry &&
|
||||
'value' in settingsMapOrEntry
|
||||
? [[settingsMapOrEntry.name, settingsMapOrEntry.value]]
|
||||
: Object.entries(settingsMapOrEntry || {});
|
||||
|
||||
for (const [name, rawValue] of entries) {
|
||||
const id = nanoid();
|
||||
const create_date = Date.now();
|
||||
const json = toJson(rawValue);
|
||||
SqliteConnection.execute(
|
||||
`INSERT INTO settings (id, create_date, name, value, user_id)
|
||||
VALUES (@id, @create_date, @name, @value, @userId)
|
||||
ON CONFLICT(name) DO UPDATE SET value = excluded.value`,
|
||||
{ id, create_date, name, value: json, userId },
|
||||
);
|
||||
}
|
||||
// keep cache in sync
|
||||
refreshSettingsCache();
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user