mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1c3106ae4 | ||
|
|
dd8d88404a | ||
|
|
f0b146fd7f | ||
|
|
da743c8279 | ||
|
|
aeffddc5a4 | ||
|
|
3f92b5b099 | ||
|
|
34317107be | ||
|
|
0bf211cb93 | ||
|
|
44a84cc3f2 | ||
|
|
d1566cf689 | ||
|
|
36f1bddedd | ||
|
|
220df3f11a | ||
|
|
3a54ab0e31 | ||
|
|
963a309889 | ||
|
|
b66f873a91 | ||
|
|
ae4b6d1f40 | ||
|
|
2b36f868e7 | ||
|
|
206f768b41 | ||
|
|
2302f69ff3 | ||
|
|
9bb33e723a | ||
|
|
cca1463a68 | ||
|
|
314b1818d7 | ||
|
|
25cc7fb650 | ||
|
|
78df4b21a6 | ||
|
|
d89b078237 | ||
|
|
395199a4a2 | ||
|
|
c2680fe49f | ||
|
|
2b862b2d98 | ||
|
|
9065448b6b | ||
|
|
b9f49cb5b2 | ||
|
|
53121742c2 | ||
|
|
1a3eae0390 | ||
|
|
a42905d63f | ||
|
|
9917491728 | ||
|
|
f032e6a724 | ||
|
|
111c154ae3 | ||
|
|
2194ffe0f4 | ||
|
|
cfa25fc0e0 | ||
|
|
d50dd61f3e | ||
|
|
31e7f77bde | ||
|
|
a418d64f1a | ||
|
|
d099872950 |
4
.babelrc
4
.babelrc
@@ -3,9 +3,7 @@
|
|||||||
[
|
[
|
||||||
"@babel/preset-env",
|
"@babel/preset-env",
|
||||||
{
|
{
|
||||||
"exclude": [
|
"exclude": ["transform-regenerator"]
|
||||||
"transform-regenerator"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ node_modules/
|
|||||||
npm-debug.log
|
npm-debug.log
|
||||||
test/
|
test/
|
||||||
db/
|
db/
|
||||||
|
conf/
|
||||||
.git/
|
.git/
|
||||||
.github/
|
.github/
|
||||||
|
|||||||
3
.eslintignore
Normal file
3
.eslintignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/ui/public
|
||||||
|
/db/
|
||||||
|
/conf/
|
||||||
@@ -277,6 +277,5 @@ module.exports = {
|
|||||||
// Prevent passing of children as props
|
// Prevent passing of children as props
|
||||||
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/no-children-prop.md
|
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/no-children-prop.md
|
||||||
'react/no-children-prop': 'warn',
|
'react/no-children-prop': 'warn',
|
||||||
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
73
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
73
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
name: Bug Report
|
||||||
|
description: Help us improve Fredy by reporting a bug
|
||||||
|
title: "[Bug]: "
|
||||||
|
labels: [bug]
|
||||||
|
assignees: []
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Bug Description
|
||||||
|
description: Provide a clear and concise description of the bug.
|
||||||
|
placeholder: e.g. "Fredy crashes when I click on Save."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: steps
|
||||||
|
attributes:
|
||||||
|
label: Steps to Reproduce
|
||||||
|
description: List the steps to reproduce the issue.
|
||||||
|
placeholder: |
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '...'
|
||||||
|
3. Scroll down to '...'
|
||||||
|
4. See error
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: Expected Behavior
|
||||||
|
description: What did you expect to happen?
|
||||||
|
placeholder: "It should save without errors."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: actual
|
||||||
|
attributes:
|
||||||
|
label: Actual Behavior
|
||||||
|
description: What actually happened?
|
||||||
|
placeholder: "Fredy crashed with error XYZ."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: screenshots
|
||||||
|
attributes:
|
||||||
|
label: Screenshots / Logs
|
||||||
|
description: Add screenshots or paste log output to help explain the problem.
|
||||||
|
placeholder: "Drag and drop screenshots here, or paste logs."
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: environment
|
||||||
|
attributes:
|
||||||
|
label: Environment
|
||||||
|
description: Provide details about your environment.
|
||||||
|
placeholder: "OS: macOS 15, Browser: Chrome 124, App version: 1.2.3"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: context
|
||||||
|
attributes:
|
||||||
|
label: Additional Context
|
||||||
|
description: Add any other context about the problem here.
|
||||||
|
placeholder: "Any other information that might help..."
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
24
.github/ISSUE_TEMPLATE/bug_report.md
vendored
24
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,24 +0,0 @@
|
|||||||
---
|
|
||||||
name: Bug report
|
|
||||||
about: Create a report to help us improve
|
|
||||||
title: ''
|
|
||||||
labels: ''
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Describe the bug**
|
|
||||||
A clear and concise description of what the bug is.
|
|
||||||
|
|
||||||
**To Reproduce**
|
|
||||||
Steps to reproduce the behavior:
|
|
||||||
1. Go to '...'
|
|
||||||
2. Click on '....'
|
|
||||||
3. Scroll down to '....'
|
|
||||||
4. See error
|
|
||||||
|
|
||||||
**Expected behavior**
|
|
||||||
A clear and concise description of what you expected to happen.
|
|
||||||
|
|
||||||
**Screenshots**
|
|
||||||
If applicable, add screenshots to help explain your problem.
|
|
||||||
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
51
.github/ISSUE_TEMPLATE/feature.yml
vendored
Normal file
51
.github/ISSUE_TEMPLATE/feature.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
name: Feature Request
|
||||||
|
description: Suggest an improvement or new idea for Fredy
|
||||||
|
title: "[Feature]: "
|
||||||
|
labels: [enhancement]
|
||||||
|
assignees: []
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: Related Problem
|
||||||
|
description: Is your feature request related to a problem? Describe it clearly.
|
||||||
|
placeholder: "Example: It’s difficult to do X when Y happens..."
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
attributes:
|
||||||
|
label: Proposed Feature
|
||||||
|
description: Describe the feature you would like to see.
|
||||||
|
placeholder: "I would like Fredy to automatically..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives Considered
|
||||||
|
description: List any alternative solutions or workarounds you’ve tried or thought about.
|
||||||
|
placeholder: "Instead of this, I also considered..."
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: benefits
|
||||||
|
attributes:
|
||||||
|
label: Benefits
|
||||||
|
description: Explain how this feature would improve Fredy or it's user experience.
|
||||||
|
placeholder: "This would save users time by..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: context
|
||||||
|
attributes:
|
||||||
|
label: Additional Context
|
||||||
|
description: Add any other context, examples, or screenshots that might help clarify your idea.
|
||||||
|
placeholder: "Any other relevant information..."
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,20 +0,0 @@
|
|||||||
---
|
|
||||||
name: Feature request
|
|
||||||
about: Suggest an idea for this project
|
|
||||||
title: ''
|
|
||||||
labels: ''
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Is your feature request related to a problem? Please describe.**
|
|
||||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
|
||||||
|
|
||||||
**Describe the solution you'd like**
|
|
||||||
A clear and concise description of what you want to happen.
|
|
||||||
|
|
||||||
**Describe alternatives you've considered**
|
|
||||||
A clear and concise description of any alternative solutions or features you've considered.
|
|
||||||
|
|
||||||
**Additional context**
|
|
||||||
Add any other context or screenshots about the feature request here.
|
|
||||||
26
.github/workflows/check_source.yml
vendored
Normal file
26
.github/workflows/check_source.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
name: Check the source code
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
jobs:
|
||||||
|
check_source_code:
|
||||||
|
name: Check the source code
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: 'yarn'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: yarn install
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: yarn format:check
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: yarn lint
|
||||||
16
.github/workflows/stales.yml
vendored
16
.github/workflows/stales.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: "Close stale issues and PRs"
|
name: Close stale issues and PRs
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
@@ -12,10 +12,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
days-before-stale: 30
|
days-before-stale: 30
|
||||||
days-before-close: 7
|
days-before-close: 7
|
||||||
stale-issue-message: "This issue has been automatically marked as stale due to inactivity."
|
stale-issue-message: 'This issue has been automatically marked as stale due to inactivity.'
|
||||||
stale-pr-message: "This PR has been automatically marked as stale due to inactivity."
|
stale-pr-message: 'This PR has been automatically marked as stale due to inactivity.'
|
||||||
close-issue-message: "Closing this issue due to prolonged inactivity."
|
close-issue-message: 'Closing this issue due to prolonged inactivity.'
|
||||||
close-pr-message: "Closing this PR due to prolonged inactivity."
|
close-pr-message: 'Closing this PR due to prolonged inactivity.'
|
||||||
exempt-issue-labels: "keep-open"
|
exempt-issue-labels: 'keep-open'
|
||||||
exempt-pr-labels: "keep-open"
|
exempt-pr-labels: 'keep-open'
|
||||||
only: "pulls"
|
only: 'pulls'
|
||||||
|
|||||||
3
.github/workflows/test.yml
vendored
3
.github/workflows/test.yml
vendored
@@ -13,8 +13,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
- uses: actions/setup-node@v4
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: 'yarn'
|
cache: 'yarn'
|
||||||
|
|||||||
6
.prettierignore
Normal file
6
.prettierignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/ui/public
|
||||||
|
/db/
|
||||||
|
/conf/
|
||||||
|
|
||||||
|
# TODO re-write from scratch or fix all html structure issues
|
||||||
|
/lib/notification/emailTemplate/template.hbs
|
||||||
4
.prettierrc
Normal file
4
.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 120
|
||||||
|
}
|
||||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -1,34 +1,42 @@
|
|||||||
Newer release changelog see https://github.com/orangecoding/fredy/releases
|
Newer release changelog see https://github.com/orangecoding/fredy/releases
|
||||||
|
|
||||||
------------
|
---
|
||||||
|
|
||||||
###### [V5.5.0]
|
###### [V5.5.0]
|
||||||
|
|
||||||
- Upgrading dependencies
|
- Upgrading dependencies
|
||||||
- fixing provider
|
- fixing provider
|
||||||
- allow multiple instances of 1 provider
|
- allow multiple instances of 1 provider
|
||||||
- __BREAKING__: Minimum node version is now 16
|
- **BREAKING**: Minimum node version is now 16
|
||||||
|
|
||||||
###### [V5.4.6]
|
###### [V5.4.6]
|
||||||
|
|
||||||
- Adding Instana node.js monitoring
|
- Adding Instana node.js monitoring
|
||||||
-
|
-
|
||||||
|
|
||||||
###### [V5.4.5]
|
###### [V5.4.5]
|
||||||
|
|
||||||
- Adding Instana node.js monitoring
|
- Adding Instana node.js monitoring
|
||||||
|
|
||||||
###### [V5.4.4]
|
###### [V5.4.4]
|
||||||
|
|
||||||
- Add support for Immo Südwest Presse (immo.swp.de)
|
- Add support for Immo Südwest Presse (immo.swp.de)
|
||||||
- Telegram: Use job name instead of ID and link in title
|
- Telegram: Use job name instead of ID and link in title
|
||||||
- Fix race condition if user ID is in session but not in user store
|
- Fix race condition if user ID is in session but not in user store
|
||||||
- Allow visiting the original provider URL
|
- Allow visiting the original provider URL
|
||||||
|
|
||||||
###### [V5.4.3]
|
###### [V5.4.3]
|
||||||
|
|
||||||
- re-writing readme
|
- re-writing readme
|
||||||
- improving docker build
|
- improving docker build
|
||||||
- using github's actions to build docker and test automatically
|
- using github's actions to build docker and test automatically
|
||||||
|
|
||||||
###### [V5.4.2]
|
###### [V5.4.2]
|
||||||
|
|
||||||
- Fixing prod build
|
- Fixing prod build
|
||||||
|
|
||||||
###### [V5.4.1]
|
###### [V5.4.1]
|
||||||
|
|
||||||
- Upgrading dependencies
|
- Upgrading dependencies
|
||||||
- Provider urls are now automagically been changed to include the correct sort order for search results
|
- Provider urls are now automagically been changed to include the correct sort order for search results
|
||||||
|
|
||||||
@@ -39,36 +47,44 @@ results, thus cannot report them. This release fixes it by adding the necessary
|
|||||||
```
|
```
|
||||||
|
|
||||||
###### [V5.3.0]
|
###### [V5.3.0]
|
||||||
|
|
||||||
- Upgrading dependencies
|
- Upgrading dependencies
|
||||||
- It's now possible to send mails to multiple receiver using comma separation for MailJet & Sendgrid
|
- It's now possible to send mails to multiple receiver using comma separation for MailJet & Sendgrid
|
||||||
- Fixing Immowelt scraping
|
- Fixing Immowelt scraping
|
||||||
|
|
||||||
###### [V5.2.0]
|
###### [V5.2.0]
|
||||||
|
|
||||||
- Upgrading dependencies
|
- Upgrading dependencies
|
||||||
- Adding new similarity check layer (Duplicates are being removed now)
|
- Adding new similarity check layer (Duplicates are being removed now)
|
||||||
- Adding paging for search results
|
- Adding paging for search results
|
||||||
|
|
||||||
###### [V5.1.0]
|
###### [V5.1.0]
|
||||||
|
|
||||||
- Upgrading dependencies
|
- Upgrading dependencies
|
||||||
- NodeJS 12.13 is now the minimum supported version
|
- NodeJS 12.13 is now the minimum supported version
|
||||||
- Adding general settings as new configuration page to ui
|
- Adding general settings as new configuration page to ui
|
||||||
- Adding new feature working hours
|
- Adding new feature working hours
|
||||||
|
|
||||||
###### [V5.0.0]
|
###### [V5.0.0]
|
||||||
|
|
||||||
- Upgrading dependencies
|
- Upgrading dependencies
|
||||||
- NodeJS 12 is now the minimum supported version
|
- NodeJS 12 is now the minimum supported version
|
||||||
|
|
||||||
###### [V4.0.0]
|
###### [V4.0.0]
|
||||||
|
|
||||||
Bringing back Immoscout :tada:
|
Bringing back Immoscout :tada:
|
||||||
|
|
||||||
###### [V3.0.0]
|
###### [V3.0.0]
|
||||||
|
|
||||||
This is basically a re-write, your old config file will not be compatible anymore. Please re-created your search jobs
|
This is basically a re-write, your old config file will not be compatible anymore. Please re-created your search jobs
|
||||||
on the new ui and use the values from your previous config file if needed.
|
on the new ui and use the values from your previous config file if needed.
|
||||||
|
|
||||||
```
|
```
|
||||||
- We're getting rid of manual config changes, Fredy, now ships with a UI so that it's easy for you to create and edit jobs
|
- We're getting rid of manual config changes, Fredy, now ships with a UI so that it's easy for you to create and edit jobs
|
||||||
```
|
```
|
||||||
|
|
||||||
###### [V2.0.0]
|
###### [V2.0.0]
|
||||||
|
|
||||||
```
|
```
|
||||||
- Fredy can now run multiple search job on one instance
|
- Fredy can now run multiple search job on one instance
|
||||||
- Changed lot's of the structure of Fredy to make this happen
|
- Changed lot's of the structure of Fredy to make this happen
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
If you want to contribute, please make sure you've executed the tests.
|
If you want to contribute, please make sure you've executed the tests.
|
||||||
|
|
||||||
|
|
||||||
### How to write new provider?
|
### How to write new provider?
|
||||||
|
|
||||||
- create the provider filer under `/lib/provider`
|
- create the provider filer under `/lib/provider`
|
||||||
- create a test under /test and make sure it is running successfully
|
- create a test under /test and make sure it is running successfully
|
||||||
|
|
||||||
@@ -57,11 +57,10 @@ exports.metaInformation = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
exports.config = config;
|
exports.config = config;
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### How to write new notification adapter?
|
### How to write new notification adapter?
|
||||||
|
|
||||||
- create the provider filer under `/lib/notification/adapter`
|
- create the provider filer under `/lib/notification/adapter`
|
||||||
- create a description of the provider under `/lib/notification/adapter/*.md`. Make sure the name of the md file is equal to the notification adapter
|
- create a description of the provider under `/lib/notification/adapter/*.md`. Make sure the name of the md file is equal to the notification adapter
|
||||||
|
|
||||||
@@ -72,7 +71,6 @@ const Slack = require('slack');
|
|||||||
const msg = Slack.chat.postMessage;
|
const msg = Slack.chat.postMessage;
|
||||||
const { markdown2Html } = require('../../services/markdown');
|
const { markdown2Html } = require('../../services/markdown');
|
||||||
|
|
||||||
|
|
||||||
//as a parameter, you will always get the serviceName, newListings and all the values, that
|
//as a parameter, you will always get the serviceName, newListings and all the values, that
|
||||||
//you have defined exports.config.fields. (This is being used for rendering in the frontend)
|
//you have defined exports.config.fields. (This is being used for rendering in the frontend)
|
||||||
exports.send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
exports.send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
@@ -102,18 +100,19 @@ exports.config = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Running Tests
|
#### Running Tests
|
||||||
If you've written a new provider you are an awesome person. If you now write tests for it, you are even more awesome. And who doesn't want to be more awesome right?
|
|
||||||
|
If you've written a new provider you are an awesome person. If you now write tests for it, you are even more awesome. And who doesn't want to be more awesome, right?
|
||||||
|
|
||||||
#### Codestyle
|
#### Codestyle
|
||||||
I'm using Eslint to maintain quote style and quality. Do not skip it...
|
|
||||||
|
|
||||||
##### To do before merging:
|
I'm using ESLint to maintain quote style and quality. Do not skip it...
|
||||||
|
|
||||||
- executed tests? (`pnpm test`)
|
##### To-do before merging:
|
||||||
- sure the changes are useful for everybody? Or is it maybe a custom modification just for your case?
|
|
||||||
|
- Have you executed the tests? (`yarn test`)
|
||||||
|
- Are you sure the changes are useful for everybody? Or is it maybe a custom modification just for your case?
|
||||||
|
|
||||||
_Thanks!_ :heart:
|
_Thanks!_ :heart:
|
||||||
|
|||||||
@@ -11,16 +11,16 @@ ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
|
|||||||
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||||
|
|
||||||
# Copy lockfiles first to leverage cache for dependencies
|
# Copy lockfiles first to leverage cache for dependencies
|
||||||
COPY package.json yarn.lock ./
|
COPY package.json yarn.lock .
|
||||||
|
|
||||||
# Set Yarn timeout, install dependencies and PM2 globally
|
# Set Yarn timeout, install dependencies and PM2 globally
|
||||||
RUN yarn config set network-timeout 600000 \
|
RUN yarn config set network-timeout 600000 \
|
||||||
&& yarn install --frozen-lockfile \
|
&& yarn --frozen-lockfile \
|
||||||
&& yarn global add pm2
|
&& yarn global add pm2
|
||||||
|
|
||||||
# Copy application source and build production assets
|
# Copy application source and build production assets
|
||||||
COPY . ./
|
COPY . .
|
||||||
RUN yarn run prod
|
RUN yarn build:frontend
|
||||||
|
|
||||||
# Prepare runtime directories and symlinks for data and config
|
# Prepare runtime directories and symlinks for data and config
|
||||||
RUN mkdir -p /db /conf \
|
RUN mkdir -p /db /conf \
|
||||||
|
|||||||
83
README.md
83
README.md
@@ -1,6 +1,6 @@
|
|||||||
<img src="https://github.com/orangecoding/fredy/blob/master/doc/logo.png" width="400">
|
<img src="https://github.com/orangecoding/fredy/blob/master/doc/logo.png" width="400">
|
||||||
|
|
||||||
 [](https://github.com/orangecoding/fredy/actions/workflows/docker.yml)
|
 [](https://github.com/orangecoding/fredy/actions/workflows/docker.yml) 
|
||||||
|
|
||||||
Searching an apartment in Germany can be a frustrating task. Not any longer though, as _Fredy_ will take over and will only notify you once new listings have been found that match your requirements.
|
Searching an apartment in Germany can be a frustrating task. Not any longer though, as _Fredy_ will take over and will only notify you once new listings have been found that match your requirements.
|
||||||
|
|
||||||
@@ -8,9 +8,8 @@ _Fredy_ scrapes multiple services (Immonet, Immowelt etc.) and send new listings
|
|||||||
|
|
||||||
If _Fredy_ finds matching results, it will send them to you via Slack, Email, Telegram etc. (More adapters can be configured.) As _Fredy_ stores the listings it has found, new results will not be sent to you twice (and as a side-effect, _Fredy_ can show some statistics). Furthermore, _Fredy_ checks duplicates per scraping so that the same listings are not being sent twice or more when posted on various platforms (which happens more often than one might think).
|
If _Fredy_ finds matching results, it will send them to you via Slack, Email, Telegram etc. (More adapters can be configured.) As _Fredy_ stores the listings it has found, new results will not be sent to you twice (and as a side-effect, _Fredy_ can show some statistics). Furthermore, _Fredy_ checks duplicates per scraping so that the same listings are not being sent twice or more when posted on various platforms (which happens more often than one might think).
|
||||||
|
|
||||||
<a href="https://www.producthunt.com/posts/fredy-find-real-estates-damn-easy?embed=true&utm_source=badge-featured&utm_medium=badge&utm_source=badge-fredy-find-real-estates-damn-easy" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=965690&theme=light&t=1747292331626" alt="Fredy - Find Real Estates Damn EasY  - Your personal real estate search bot | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
|
||||||
|
|
||||||
# Sponsorship [](https://github.com/sponsors/orangecoding)
|
# Sponsorship [](https://github.com/sponsors/orangecoding)
|
||||||
|
|
||||||
If you like my work, consider becoming a sponsor. I'm not expecting anybody to pay for _Fredy_ or any other Open Source Project I'm maintaining, however keep in mind, I'm doing all of this in my spare time :) Thanks.
|
If you like my work, consider becoming a sponsor. I'm not expecting anybody to pay for _Fredy_ or any other Open Source Project I'm maintaining, however keep in mind, I'm doing all of this in my spare time :) Thanks.
|
||||||
|
|
||||||
[](https://jb.gg/OpenSourceSupport)
|
[](https://jb.gg/OpenSourceSupport)
|
||||||
@@ -18,17 +17,20 @@ If you like my work, consider becoming a sponsor. I'm not expecting anybody to p
|
|||||||
_Fredy_ is supported by JetBrains under Open Source Support Program
|
_Fredy_ is supported by JetBrains under Open Source Support Program
|
||||||
|
|
||||||
## Demo
|
## Demo
|
||||||
|
|
||||||
If you want to try out _Fredy_, you can access the demo version [here](https://fredy.orange-coding.net) 🤘
|
If you want to try out _Fredy_, you can access the demo version [here](https://fredy.orange-coding.net) 🤘
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
- Make sure to use Node.js 20 or above
|
- Make sure to use Node.js 20 or above
|
||||||
- Run the following commands:
|
- Run the following commands:
|
||||||
|
|
||||||
```ssh
|
```ssh
|
||||||
yarn (or npm install)
|
yarn
|
||||||
yarn run prod
|
yarn run start:backend
|
||||||
yarn run start
|
yarn run start:frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
_Fredy_ will start with the default port, set to `9998`. You can access _Fredy_ by opening your browser at `http://localhost:9998`. The default login is `admin`, both for username and password. You should change the password as soon as possible when you plan to run Fredy on a server.
|
_Fredy_ will start with the default port, set to `9998`. You can access _Fredy_ by opening your browser at `http://localhost:9998`. The default login is `admin`, both for username and password. You should change the password as soon as possible when you plan to run Fredy on a server.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -40,53 +42,107 @@ _Fredy_ will start with the default port, set to `9998`. You can access _Fredy_
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Understanding the fundamentals
|
## Understanding the fundamentals
|
||||||
|
|
||||||
There are 3 important parts in Fredy, that you need to understand to leverage the full power of _Fredy_.
|
There are 3 important parts in Fredy, that you need to understand to leverage the full power of _Fredy_.
|
||||||
|
|
||||||
#### Provider
|
#### Provider
|
||||||
|
|
||||||
_Fredy_ supports multiple services. Immonet, Immowelt and Ebay are just a few examples. Those services are called providers within _Fredy_. When creating a new job, you can choose one or more providers.
|
_Fredy_ supports multiple services. Immonet, Immowelt and Ebay are just a few examples. Those services are called providers within _Fredy_. When creating a new job, you can choose one or more providers.
|
||||||
A provider contains the URL that points to the search results for the respective service. If you go to immonet.de and search for something, the displayed URL in the browser is what the provider needs to do its magic.
|
A provider contains the URL that points to the search results for the respective service. If you go to immonet.de and search for something, the displayed URL in the browser is what the provider needs to do its magic.
|
||||||
**It is important that you order the search results by date, so that _Fredy_ always picks the latest results first!**
|
**It is important that you order the search results by date, so that _Fredy_ always picks the latest results first!**
|
||||||
|
|
||||||
#### Adapter
|
#### Adapter
|
||||||
_Fredy_ supports multiple adapters, such as Slack, SendGrid, Telegram etc. A search job can have as many adapters as supported by _Fredy_. Each adapter needs different configuration values, which you have to provide when using them. A adapter dictactes how the frontend renders by telling the frontend what information it needs in order to send listings to the user.
|
|
||||||
|
_Fredy_ supports multiple adapters, such as Slack, SendGrid, Telegram etc. A search job can have as many adapters as supported by _Fredy_. Each adapter needs different configuration values, which you have to provide when using them. An adapter dictates how the frontend renders by telling the frontend what information it needs in order to send listings to the user.
|
||||||
|
|
||||||
#### Jobs
|
#### Jobs
|
||||||
|
|
||||||
A Job wraps adapters and providers. _Fredy_ runs the configured jobs in a specific interval (can be configured in `/conf/config.json`).
|
A Job wraps adapters and providers. _Fredy_ runs the configured jobs in a specific interval (can be configured in `/conf/config.json`).
|
||||||
|
|
||||||
## Creating your first job
|
## Creating your first job
|
||||||
|
|
||||||
To create your first job, click on the button "Create New Job" on the job table. The job creation dialog should be self-explanatory, however there is one important thing.
|
To create your first job, click on the button "Create New Job" on the job table. The job creation dialog should be self-explanatory, however there is one important thing.
|
||||||
When configuring providers, before copying the URL from your browser, make sure that you have sorted the results by date to make sure _Fredy_ always picks the latest results first.
|
When configuring providers, before copying the URL from your browser, make sure that you have sorted the results by date to make sure _Fredy_ always picks the latest results first.
|
||||||
|
|
||||||
## User management
|
## User management
|
||||||
|
|
||||||
As an administrator, you can create, edit and remove users from _Fredy_. Be careful, each job is connected to the user that has created the job. If you remove the user, their jobs will also be removed.
|
As an administrator, you can create, edit and remove users from _Fredy_. Be careful, each job is connected to the user that has created the job. If you remove the user, their jobs will also be removed.
|
||||||
|
|
||||||
# Development
|
# Development
|
||||||
|
|
||||||
### Running Fredy in development mode
|
### Running Fredy in development mode
|
||||||
To run _Fredy_ in development mode, you need to run the backend & frontend separately.
|
|
||||||
Start the backend with:
|
Start the backend with:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
yarn run start
|
yarn run start:backend:dev
|
||||||
```
|
```
|
||||||
|
|
||||||
For the frontend, run:
|
For the frontend, run:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
yarn run dev
|
yarn run start:frontend:dev
|
||||||
```
|
```
|
||||||
|
|
||||||
You should now be able to access _Fredy_ from your browser. Check your Terminal to see what port the frontend is running on.
|
You should now be able to access _Fredy_ from your browser. Check your Terminal to see what port the frontend is running on.
|
||||||
|
|
||||||
### Running Tests
|
### Running Tests
|
||||||
|
|
||||||
To run the tests, run
|
To run the tests, run
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
yarn run test
|
yarn run test
|
||||||
```
|
```
|
||||||
|
|
||||||
# Architecture
|
# Architecture
|
||||||

|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
subgraph Jobs["Jobs"]
|
||||||
|
A1["Job 1"]
|
||||||
|
A2["Job 2"]
|
||||||
|
A3["Job 3"]
|
||||||
|
end
|
||||||
|
subgraph Providers["Providers"]
|
||||||
|
C1["Provider 1"]
|
||||||
|
C2["Provider 2"]
|
||||||
|
C3["Provider 3"]
|
||||||
|
end
|
||||||
|
subgraph NotificationAdapters["Notification Adapters"]
|
||||||
|
F1["Notification Adapter 1"]
|
||||||
|
F2["Notification Adapter 2"]
|
||||||
|
end
|
||||||
|
|
||||||
|
A1 --> B["FredyRuntime"]
|
||||||
|
A2 --> B
|
||||||
|
A3 --> B
|
||||||
|
B --> C1 & C2 & C3
|
||||||
|
C1 --> D["Similarity-Check"]
|
||||||
|
C2 --> D
|
||||||
|
C3 --> D
|
||||||
|
D --> E{"Found<br>similarity?"}
|
||||||
|
E -- No --> F1
|
||||||
|
F1 --> F2
|
||||||
|
|
||||||
|
style A1 fill:#fde9a0,stroke:#333333,color:#333333
|
||||||
|
style A2 fill:#fde9a0,stroke:#333333,color:#333333
|
||||||
|
style A3 fill:#fde9a0,stroke:#333333,color:#333333
|
||||||
|
style C1 fill:#c4c9f1,stroke:#333333,color:#333333
|
||||||
|
style C2 fill:#c4c9f1,stroke:#333333,color:#333333
|
||||||
|
style C3 fill:#c4c9f1,stroke:#333333,color:#333333
|
||||||
|
style F1 fill:#d2edba,stroke:#333333,color:#333333
|
||||||
|
style F2 fill:#d2edba,stroke:#333333,color:#333333
|
||||||
|
style B fill:#abd8f9,stroke:#333333,color:#333333
|
||||||
|
style D fill:#fab4a8,stroke:#333333,color:#333333
|
||||||
|
style E fill:#fffbb4,stroke:#333333,color:#333333
|
||||||
|
```
|
||||||
|
|
||||||
### Immoscout
|
### Immoscout
|
||||||
Immoscout has implemented advanced bot detection. In order to work around this, we are using a reversed engineered version of their mobile api. See See [Immoscout Reverse Engineering Documentation](https://github.com/orangecoding/fredy/blob/master/reverse-engineered-immoscout.md)
|
|
||||||
|
Immoscout has implemented advanced bot detection. In order to work around this, we are using a reversed engineered version of their mobile api. See [Immoscout Reverse Engineering Documentation](https://github.com/orangecoding/fredy/blob/master/reverse-engineered-immoscout.md)
|
||||||
|
|
||||||
# Analytics
|
# Analytics
|
||||||
|
|
||||||
Fredy is completely free (and will always remain free). However, it would be a huge help if you’d allow me to collect some analytical data.
|
Fredy is completely free (and will always remain free). However, it would be a huge help if you’d allow me to collect some analytical data.
|
||||||
Before you freak out, let me explain...
|
Before you freak out, let me explain...
|
||||||
If you agree, Fredy will send a ping to my Mixpanel project each time it runs.
|
If you agree, Fredy will send a ping to my Mixpanel project each time it runs.
|
||||||
@@ -94,6 +150,7 @@ The data includes: names of active adapters/providers, OS, architecture, Node ve
|
|||||||
**Thanks**🤘
|
**Thanks**🤘
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
|
|
||||||
Use the Dockerfile in this repository to build an image.
|
Use the Dockerfile in this repository to build an image.
|
||||||
|
|
||||||
Example: `docker build -t fredy/fredy /path/to/your/Dockerfile`
|
Example: `docker build -t fredy/fredy /path/to/your/Dockerfile`
|
||||||
@@ -117,6 +174,7 @@ Example: `docker create --name fredy -v /path/to/your/conf/:/conf -p 9998:9998 f
|
|||||||
You can browse the logs with `docker logs fredy -f`.
|
You can browse the logs with `docker logs fredy -f`.
|
||||||
|
|
||||||
### 👐 Contributing
|
### 👐 Contributing
|
||||||
|
|
||||||
Thanks to all the people who already contributed!
|
Thanks to all the people who already contributed!
|
||||||
|
|
||||||
<a href="https://github.com/orangecoding/fredy/graphs/contributors">
|
<a href="https://github.com/orangecoding/fredy/graphs/contributors">
|
||||||
@@ -125,7 +183,6 @@ Thanks to all the people who already contributed!
|
|||||||
|
|
||||||
See [Contributing](https://github.com/orangecoding/fredy/blob/master/CONTRIBUTING.md)
|
See [Contributing](https://github.com/orangecoding/fredy/blob/master/CONTRIBUTING.md)
|
||||||
|
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
[](https://www.star-history.com/#orangecoding/fredy&Date)
|
[](https://www.star-history.com/#orangecoding/fredy&Date)
|
||||||
|
|||||||
@@ -1 +1,7 @@
|
|||||||
{"interval":"60","port":9998,"workingHours":{"from":"","to":""},"demoMode":false,"analyticsEnabled":null}
|
{
|
||||||
|
"interval": "60",
|
||||||
|
"port": 9998,
|
||||||
|
"workingHours": { "from": "", "to": "" },
|
||||||
|
"demoMode": false,
|
||||||
|
"analyticsEnabled": null
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
<mxfile host="app.diagrams.net" modified="2022-01-29T18:34:51.211Z" agent="5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.99 Safari/537.36" etag="W0jmvptvMSkuHq89hwUy" version="16.5.2" type="github">
|
|
||||||
<diagram id="C5RBs43oDa-KdzZeNtuy" name="Page-1">
|
|
||||||
<mxGraphModel dx="850" dy="907" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
|
|
||||||
<root>
|
|
||||||
<mxCell id="WIyWlLk6GJQsqaUBKTNV-0" />
|
|
||||||
<mxCell id="WIyWlLk6GJQsqaUBKTNV-1" parent="WIyWlLk6GJQsqaUBKTNV-0" />
|
|
||||||
<mxCell id="4kAlOAlRylSy7JMoHAEd-5" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="WIyWlLk6GJQsqaUBKTNV-1" source="WIyWlLk6GJQsqaUBKTNV-3" target="WIyWlLk6GJQsqaUBKTNV-7">
|
|
||||||
<mxGeometry relative="1" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="WIyWlLk6GJQsqaUBKTNV-3" value="Job1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;glass=0;strokeWidth=1;shadow=0;fillColor=#dae8fc;strokeColor=#6c8ebf;" parent="WIyWlLk6GJQsqaUBKTNV-1" vertex="1">
|
|
||||||
<mxGeometry x="100" y="50" width="120" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="4kAlOAlRylSy7JMoHAEd-8" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="WIyWlLk6GJQsqaUBKTNV-1" source="WIyWlLk6GJQsqaUBKTNV-7" target="4kAlOAlRylSy7JMoHAEd-2">
|
|
||||||
<mxGeometry relative="1" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="4kAlOAlRylSy7JMoHAEd-10" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="WIyWlLk6GJQsqaUBKTNV-1" source="WIyWlLk6GJQsqaUBKTNV-7" target="4kAlOAlRylSy7JMoHAEd-3">
|
|
||||||
<mxGeometry relative="1" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="4kAlOAlRylSy7JMoHAEd-11" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="WIyWlLk6GJQsqaUBKTNV-1" source="WIyWlLk6GJQsqaUBKTNV-7" target="4kAlOAlRylSy7JMoHAEd-4">
|
|
||||||
<mxGeometry relative="1" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="WIyWlLk6GJQsqaUBKTNV-7" value="FredyRuntime" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;glass=0;strokeWidth=1;shadow=0;fillColor=#fff2cc;strokeColor=#d6b656;" parent="WIyWlLk6GJQsqaUBKTNV-1" vertex="1">
|
|
||||||
<mxGeometry x="110" y="120" width="360" height="40" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="4kAlOAlRylSy7JMoHAEd-6" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="WIyWlLk6GJQsqaUBKTNV-1" source="4kAlOAlRylSy7JMoHAEd-0" target="WIyWlLk6GJQsqaUBKTNV-7">
|
|
||||||
<mxGeometry relative="1" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="4kAlOAlRylSy7JMoHAEd-0" value="Job2" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;glass=0;strokeWidth=1;shadow=0;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="WIyWlLk6GJQsqaUBKTNV-1">
|
|
||||||
<mxGeometry x="230" y="50" width="120" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="4kAlOAlRylSy7JMoHAEd-7" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="WIyWlLk6GJQsqaUBKTNV-1" source="4kAlOAlRylSy7JMoHAEd-1" target="WIyWlLk6GJQsqaUBKTNV-7">
|
|
||||||
<mxGeometry relative="1" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="4kAlOAlRylSy7JMoHAEd-1" value="Job3" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;glass=0;strokeWidth=1;shadow=0;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="WIyWlLk6GJQsqaUBKTNV-1">
|
|
||||||
<mxGeometry x="360" y="50" width="120" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="4kAlOAlRylSy7JMoHAEd-13" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="WIyWlLk6GJQsqaUBKTNV-1" source="4kAlOAlRylSy7JMoHAEd-2" target="4kAlOAlRylSy7JMoHAEd-12">
|
|
||||||
<mxGeometry relative="1" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="4kAlOAlRylSy7JMoHAEd-2" value="Provider1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;glass=0;strokeWidth=1;shadow=0;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="WIyWlLk6GJQsqaUBKTNV-1">
|
|
||||||
<mxGeometry x="100" y="210" width="120" height="40" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="4kAlOAlRylSy7JMoHAEd-14" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="WIyWlLk6GJQsqaUBKTNV-1" source="4kAlOAlRylSy7JMoHAEd-3">
|
|
||||||
<mxGeometry relative="1" as="geometry">
|
|
||||||
<mxPoint x="290" y="290" as="targetPoint" />
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="4kAlOAlRylSy7JMoHAEd-3" value="Provider2" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;glass=0;strokeWidth=1;shadow=0;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="WIyWlLk6GJQsqaUBKTNV-1">
|
|
||||||
<mxGeometry x="230" y="210" width="120" height="40" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="4kAlOAlRylSy7JMoHAEd-15" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="WIyWlLk6GJQsqaUBKTNV-1" source="4kAlOAlRylSy7JMoHAEd-4" target="4kAlOAlRylSy7JMoHAEd-12">
|
|
||||||
<mxGeometry relative="1" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="4kAlOAlRylSy7JMoHAEd-4" value="Provider3" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;glass=0;strokeWidth=1;shadow=0;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="WIyWlLk6GJQsqaUBKTNV-1">
|
|
||||||
<mxGeometry x="360" y="210" width="120" height="40" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="4kAlOAlRylSy7JMoHAEd-17" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="WIyWlLk6GJQsqaUBKTNV-1" source="4kAlOAlRylSy7JMoHAEd-12" target="4kAlOAlRylSy7JMoHAEd-16">
|
|
||||||
<mxGeometry relative="1" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="4kAlOAlRylSy7JMoHAEd-12" value="Similarity check" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;glass=0;strokeWidth=1;shadow=0;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="WIyWlLk6GJQsqaUBKTNV-1">
|
|
||||||
<mxGeometry x="110" y="290" width="360" height="40" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="4kAlOAlRylSy7JMoHAEd-20" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="WIyWlLk6GJQsqaUBKTNV-1" source="4kAlOAlRylSy7JMoHAEd-16" target="4kAlOAlRylSy7JMoHAEd-18">
|
|
||||||
<mxGeometry relative="1" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="4kAlOAlRylSy7JMoHAEd-16" value="Found similarity" style="rhombus;whiteSpace=wrap;html=1;" vertex="1" parent="WIyWlLk6GJQsqaUBKTNV-1">
|
|
||||||
<mxGeometry x="250" y="360" width="80" height="80" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="4kAlOAlRylSy7JMoHAEd-21" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="WIyWlLk6GJQsqaUBKTNV-1" source="4kAlOAlRylSy7JMoHAEd-18" target="4kAlOAlRylSy7JMoHAEd-19">
|
|
||||||
<mxGeometry relative="1" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="4kAlOAlRylSy7JMoHAEd-18" value="Notification Adapter1" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;glass=0;strokeWidth=1;shadow=0;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="WIyWlLk6GJQsqaUBKTNV-1">
|
|
||||||
<mxGeometry x="230" y="460" width="120" height="40" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="4kAlOAlRylSy7JMoHAEd-19" value="Notification Adapter2" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;glass=0;strokeWidth=1;shadow=0;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="WIyWlLk6GJQsqaUBKTNV-1">
|
|
||||||
<mxGeometry x="230" y="520" width="120" height="40" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="4kAlOAlRylSy7JMoHAEd-22" value="No" style="text;html=1;resizable=0;autosize=1;align=center;verticalAlign=middle;points=[];fillColor=none;strokeColor=none;rounded=0;" vertex="1" parent="WIyWlLk6GJQsqaUBKTNV-1">
|
|
||||||
<mxGeometry x="300" y="440" width="30" height="20" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
</root>
|
|
||||||
</mxGraphModel>
|
|
||||||
</diagram>
|
|
||||||
</mxfile>
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 189 KiB |
@@ -1,4 +1,3 @@
|
|||||||
version: '3.8'
|
|
||||||
services:
|
services:
|
||||||
fredy:
|
fredy:
|
||||||
container_name: fredy
|
container_name: fredy
|
||||||
|
|||||||
13
index.html
13
index.html
@@ -1,16 +1,17 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8"
|
<meta
|
||||||
|
charset="UTF-8"
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1">
|
content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
|
||||||
<meta name="google" content="notranslate">
|
/>
|
||||||
|
<meta name="google" content="notranslate" />
|
||||||
|
|
||||||
<title>Fredy</title>
|
<title>Fredy</title>
|
||||||
</head>
|
</head>
|
||||||
<body theme-mode="dark">
|
<body theme-mode="dark">
|
||||||
|
<div id="fredy" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0"></div>
|
||||||
<div id="fredy" style="position: absolute;top: 0;left: 0;right: 0;bottom: 0;"></div>
|
|
||||||
</body>
|
</body>
|
||||||
<script type="module" src="/ui/src/Index.jsx"></script>
|
<script type="module" src="/ui/src/Index.jsx"></script>
|
||||||
</html>
|
</html>
|
||||||
4
index.js
4
index.js
@@ -25,7 +25,7 @@ if(config.demoMode){
|
|||||||
}
|
}
|
||||||
/* eslint-enable no-console */
|
/* eslint-enable no-console */
|
||||||
const fetchedProvider = await Promise.all(
|
const fetchedProvider = await Promise.all(
|
||||||
provider.filter((provider) => provider.endsWith('.js')).map(async (pro) => import(`${path}/${pro}`))
|
provider.filter((provider) => provider.endsWith('.js')).map(async (pro) => import(`${path}/${pro}`)),
|
||||||
);
|
);
|
||||||
|
|
||||||
handleDemoUser();
|
handleDemoUser();
|
||||||
@@ -58,5 +58,5 @@ setInterval(
|
|||||||
}
|
}
|
||||||
return exec;
|
return exec;
|
||||||
})(),
|
})(),
|
||||||
INTERVAL
|
INTERVAL,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ function doesJobBelongsToUser(job, req) {
|
|||||||
if (user == null) {
|
if (user == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return user.isAdmin || job.userId === job.userId;
|
return user.isAdmin || job.userId === user.id;
|
||||||
}
|
}
|
||||||
jobRouter.get('/', async (req, res) => {
|
jobRouter.get('/', async (req, res) => {
|
||||||
const isUserAdmin = isAdmin(req);
|
const isUserAdmin = isAdmin(req);
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ loginRouter.post('/', async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (user.password === hasher.hash(password)) {
|
if (user.password === hasher.hash(password)) {
|
||||||
|
|
||||||
if (config.demoMode) {
|
if (config.demoMode) {
|
||||||
trackDemoAccessed();
|
trackDemoAccessed();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const notificationAdapterList = fs.readdirSync('./lib//notification/adapter').fi
|
|||||||
const notificationAdapter = await Promise.all(
|
const notificationAdapter = await Promise.all(
|
||||||
notificationAdapterList.map(async (pro) => {
|
notificationAdapterList.map(async (pro) => {
|
||||||
return await import(`../../notification/adapter/${pro}`);
|
return await import(`../../notification/adapter/${pro}`);
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
notificationAdapterRouter.post('/try', async (req, res) => {
|
notificationAdapterRouter.post('/try', async (req, res) => {
|
||||||
const { id, fields } = req.body;
|
const { id, fields } = req.body;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const providerList = fs.readdirSync('./lib/provider').filter((file) => file.ends
|
|||||||
const provider = await Promise.all(
|
const provider = await Promise.all(
|
||||||
providerList.map(async (pro) => {
|
providerList.map(async (pro) => {
|
||||||
return await import(`../../provider/${pro}`);
|
return await import(`../../provider/${pro}`);
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
providerRouter.get('/', async (req, res) => {
|
providerRouter.get('/', async (req, res) => {
|
||||||
res.body = provider.map((p) => p.metaInformation);
|
res.body = provider.map((p) => p.metaInformation);
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ userRouter.delete('/', async (req, res) => {
|
|||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
userRouter.post('/', async (req, res) => {
|
userRouter.post('/', async (req, res) => {
|
||||||
|
|
||||||
if (config.demoMode) {
|
if (config.demoMode) {
|
||||||
res.send(new Error('In demo mode, it is not allowed to change or add user.'));
|
res.send(new Error('In demo mode, it is not allowed to change or add user.'));
|
||||||
return;
|
return;
|
||||||
@@ -60,7 +59,7 @@ userRouter.post('/', async (req, res) => {
|
|||||||
const allUser = userStorage.getUsers(false);
|
const allUser = userStorage.getUsers(false);
|
||||||
if (!isAdmin && !checkIfAnyAdminAfterRemovingUser(userId, allUser)) {
|
if (!isAdmin && !checkIfAnyAdminAfterRemovingUser(userId, allUser)) {
|
||||||
res.send(
|
res.send(
|
||||||
new Error('You cannot change the admin flag for this user as otherwise, there is no other user in the system')
|
new Error('You cannot change the admin flag for this user as otherwise, there is no other user in the system'),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const DEFAULT_CONFIG = {
|
export const DEFAULT_CONFIG = {
|
||||||
'interval': '60',
|
interval: '60',
|
||||||
'port': 9998,
|
port: 9998,
|
||||||
'workingHours': {'from': '', 'to': ''},
|
workingHours: { from: '', to: '' },
|
||||||
'demoMode': false,
|
demoMode: false,
|
||||||
'analyticsEnabled': null
|
analyticsEnabled: null,
|
||||||
};
|
};
|
||||||
@@ -2,42 +2,111 @@ import mailjet from 'node-mailjet';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import Handlebars from 'handlebars';
|
import Handlebars from 'handlebars';
|
||||||
|
import fetch from 'node-fetch';
|
||||||
import { markdown2Html } from '../../services/markdown.js';
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
import { getDirName } from '../../utils.js';
|
import { getDirName, normalizeImageUrl } from '../../utils.js';
|
||||||
|
|
||||||
const __dirname = getDirName();
|
const __dirname = getDirName();
|
||||||
const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTemplate/template.hbs'), 'utf8');
|
const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTemplate/template.hbs'), 'utf8');
|
||||||
const emailTemplate = Handlebars.compile(template);
|
const emailTemplate = Handlebars.compile(template);
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
|
||||||
|
const guessMime = (url) => {
|
||||||
|
const lower = url.split('?')[0].toLowerCase();
|
||||||
|
if (lower.endsWith('.png')) return 'image/png';
|
||||||
|
if (lower.endsWith('.gif')) return 'image/gif';
|
||||||
|
return 'image/jpeg';
|
||||||
|
};
|
||||||
|
|
||||||
|
const toBase64 = async (url) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`Fetch failed with status ${res.status} for URL: ${url}`);
|
||||||
|
const ab = await res.arrayBuffer();
|
||||||
|
return Buffer.from(ab).toString('base64');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching image from ${url}:`, error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapListingsWithCid = async (serviceName, jobKey, listings) => {
|
||||||
|
const out = [];
|
||||||
|
const attachments = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < listings.length; i++) {
|
||||||
|
const l = listings[i] || {};
|
||||||
|
const imgUrl = normalizeImageUrl(l.image);
|
||||||
|
|
||||||
|
const item = {
|
||||||
|
title: l.title || '',
|
||||||
|
link: l.link || '',
|
||||||
|
address: l.address || '',
|
||||||
|
size: l.size || '',
|
||||||
|
price: l.price || '',
|
||||||
|
serviceName,
|
||||||
|
jobKey,
|
||||||
|
hasImage: false,
|
||||||
|
imageCid: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (imgUrl) {
|
||||||
|
try {
|
||||||
|
const base64 = await toBase64(imgUrl);
|
||||||
|
const cid = `listing-${i}`;
|
||||||
|
attachments.push({
|
||||||
|
ContentType: guessMime(imgUrl),
|
||||||
|
Filename: `listing-${i}.${imgUrl.split('.').pop().split('?')[0] || 'jpg'}`,
|
||||||
|
Base64Content: base64,
|
||||||
|
ContentID: cid,
|
||||||
|
});
|
||||||
|
item.hasImage = true;
|
||||||
|
item.imageCid = cid;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Skipping image for listing ${i} due to error: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { listings: out, attachments };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
const { apiPublicKey, apiPrivateKey, receiver, from } = notificationConfig.find(
|
const { apiPublicKey, apiPrivateKey, receiver, from } = notificationConfig.find(
|
||||||
(adapter) => adapter.id === config.id,
|
(adapter) => adapter.id === config.id,
|
||||||
).fields;
|
).fields;
|
||||||
|
|
||||||
const to = receiver
|
const to = receiver
|
||||||
.trim()
|
.trim()
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((r) => ({
|
.map((r) => ({ Email: r.trim() }))
|
||||||
Email: r.trim(),
|
.filter((r) => r.Email.length > 0);
|
||||||
}));
|
|
||||||
|
const { listings, attachments } = await mapListingsWithCid(serviceName, jobKey, newListings);
|
||||||
|
|
||||||
|
const html = emailTemplate({
|
||||||
|
serviceName: `Job: (${jobKey}) | Service: ${serviceName}`,
|
||||||
|
numberOfListings: listings.length,
|
||||||
|
listings,
|
||||||
|
});
|
||||||
|
|
||||||
return mailjet
|
return mailjet
|
||||||
.apiConnect(apiPublicKey, apiPrivateKey)
|
.apiConnect(apiPublicKey, apiPrivateKey)
|
||||||
.post('send', { version: 'v3.1' })
|
.post('send', { version: 'v3.1' })
|
||||||
.request({
|
.request({
|
||||||
Messages: [
|
Messages: [
|
||||||
{
|
{
|
||||||
From: {
|
From: { Email: from, Name: 'Fredy' },
|
||||||
Email: from,
|
|
||||||
Name: 'Fredy',
|
|
||||||
},
|
|
||||||
To: to,
|
To: to,
|
||||||
Subject: `Fredy found ${newListings.length} new listings for ${serviceName}`,
|
Subject: `Fredy found ${listings.length} new listing(s) for ${serviceName}`,
|
||||||
HTMLPart: emailTemplate({
|
HTMLPart: html,
|
||||||
serviceName: `Job: (${jobKey}) | Service: ${serviceName}`,
|
InlinedAttachments: attachments,
|
||||||
numberOfListings: newListings.length,
|
|
||||||
listings: newListings,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
id: 'mailjet',
|
id: 'mailjet',
|
||||||
name: 'MailJet',
|
name: 'MailJet',
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
### MailJet Adapter
|
### MailJet Adapter
|
||||||
|
|
||||||
To use [MailJet](https://mailjet.com), you need to create an account. You'll need to decided from which email address you want Fredy to send from.
|
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.
|
||||||
|
|
||||||
E.g. if you use yourGmailAccount@gmail.com, you have to add this to MailJet and verify it as well.
|
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.
|
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.
|
||||||
|
|
||||||
If this email should be sent to multiple receiver use a comma separator (some@email.com, someOther@email.com).
|
If this email should be sent to multiple receiver, use a comma separator (some@email.com, someOther@email.com).
|
||||||
|
|||||||
@@ -1,32 +1,41 @@
|
|||||||
import { markdown2Html } from '../../services/markdown.js';
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
import { getJob } from '../../services/storage/jobStorage.js';
|
import { getJob } from '../../services/storage/jobStorage.js';
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
|
import { normalizeImageUrl } from '../../utils.js';
|
||||||
|
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
const { priority, server, topic } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
const { priority, server, topic } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
const job = getJob(jobKey);
|
const job = getJob(jobKey);
|
||||||
const jobName = job == null ? jobKey : job.name;
|
const jobName = job == null ? jobKey : job.name;
|
||||||
|
|
||||||
const promises = newListings.map((newListing) => {
|
const promises = newListings.map((newListing) => {
|
||||||
const message = `
|
const message = `
|
||||||
Address: ${newListing.address}
|
Address: ${newListing.address}
|
||||||
Size: ${newListing.size.replace(/2m/g, '$m^2$')}
|
Size: ${newListing.size == null ? 'N/A' : newListing.size.replace(/2m/g, '$m^2$')}
|
||||||
Price: ${newListing.price}
|
Price: ${newListing.price}
|
||||||
Link: ${newListing.link}`;
|
Link: ${newListing.link}`;
|
||||||
return fetch(server, {
|
|
||||||
|
const headers = {
|
||||||
|
Title: newListing.title,
|
||||||
|
Priority: String(priority),
|
||||||
|
Tags: `${serviceName},${jobName}`,
|
||||||
|
Click: newListing.link,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (newListing.image && typeof newListing.image === 'string') {
|
||||||
|
headers.Attach = normalizeImageUrl(newListing.image);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(`${server}/${topic}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
headers,
|
||||||
topic: topic,
|
body: message,
|
||||||
message: message,
|
|
||||||
title: newListing.title,
|
|
||||||
tags: [serviceName, jobName],
|
|
||||||
priority: parseInt(priority),
|
|
||||||
click: newListing.link,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return Promise.all(promises);
|
return Promise.all(promises);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
id: 'ntfy',
|
id: 'ntfy',
|
||||||
name: 'ntfy',
|
name: 'ntfy',
|
||||||
|
|||||||
@@ -2,50 +2,55 @@ import {markdown2Html} from '../../services/markdown.js';
|
|||||||
import { getJob } from '../../services/storage/jobStorage.js';
|
import { getJob } from '../../services/storage/jobStorage.js';
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
|
|
||||||
export const send = ({serviceName, newListings, notificationConfig, jobKey}) => {
|
export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
const { token, user, device } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
const { token, user, device } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
const job = getJob(jobKey);
|
const job = getJob(jobKey);
|
||||||
const jobName = job == null ? jobKey : job.name;
|
const jobName = job == null ? jobKey : job.name;
|
||||||
const promises = newListings.map((newListing) => {
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
newListings.map(async (newListing) => {
|
||||||
const title = `${jobName} at ${serviceName}: ${newListing.title}`;
|
const title = `${jobName} at ${serviceName}: ${newListing.title}`;
|
||||||
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}`;
|
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}`;
|
||||||
return fetch('https://api.pushover.net/1/messages.json', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({
|
|
||||||
token: token,
|
|
||||||
user: user,
|
|
||||||
message: message,
|
|
||||||
device: device,
|
|
||||||
title: title,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return Promise.all(promises)
|
const form = new FormData();
|
||||||
.then((responses) => {
|
form.append('token', token);
|
||||||
// Convert all responses to JSON
|
form.append('user', user);
|
||||||
return Promise.all(responses.map((response) => response.json()));
|
form.append('title', title);
|
||||||
})
|
form.append('message', message);
|
||||||
.then((data) => {
|
if (device) form.append('device', device);
|
||||||
// Check for errors in the data
|
|
||||||
const error = data
|
|
||||||
.map((item) => (item.errors != null && item.errors.length > 0 ? item.errors.join(', ') : null))
|
|
||||||
.filter((err) => err !== null);
|
|
||||||
|
|
||||||
if (error.length > 0) {
|
// Try to attach image if available
|
||||||
// Reject with the combined error messages
|
if (newListing.image && typeof newListing.image === 'string') {
|
||||||
return Promise.reject(error.join('; '));
|
try {
|
||||||
|
const imgRes = await fetch(newListing.image);
|
||||||
|
if (imgRes.ok) {
|
||||||
|
const ab = await imgRes.arrayBuffer();
|
||||||
|
form.append('attachment', new Blob([ab]), 'image.jpg');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fail silently, just skip the image
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
const res = await fetch('https://api.pushover.net/1/messages.json', {
|
||||||
})
|
method: 'POST',
|
||||||
.then(() => {
|
body: form,
|
||||||
return Promise.resolve();
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
return Promise.reject(error);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Collect errors
|
||||||
|
const errors = results
|
||||||
|
.map((r) => (r.errors && r.errors.length > 0 ? r.errors.join(', ') : null))
|
||||||
|
.filter((e) => e !== null);
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return Promise.reject(errors.join('; '));
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
@@ -57,7 +62,7 @@ export const config = {
|
|||||||
token: {
|
token: {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
label: 'API token',
|
label: 'API token',
|
||||||
description: 'Your application\'s API token.',
|
description: "Your application's API token.",
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@@ -67,7 +72,8 @@ export const config = {
|
|||||||
device: {
|
device: {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
label: 'Device name',
|
label: 'Device name',
|
||||||
description: 'The device name to send your notification to. Messages may be addressed to multiple specific devices by joining them with a comma.',
|
description:
|
||||||
|
'The device name to send your notification to. Messages may be addressed to multiple specific devices by joining them with a comma.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,24 +1,53 @@
|
|||||||
import sgMail from '@sendgrid/mail';
|
import sgMail from '@sendgrid/mail';
|
||||||
import { markdown2Html } from '../../services/markdown.js';
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
|
import { normalizeImageUrl } from '../../utils.js';
|
||||||
|
|
||||||
|
const mapListings = (serviceName, jobKey, listings) =>
|
||||||
|
listings.map((l) => {
|
||||||
|
const image = normalizeImageUrl(l.image);
|
||||||
|
return {
|
||||||
|
title: l.title || '',
|
||||||
|
link: l.link || '',
|
||||||
|
address: l.address || '',
|
||||||
|
size: l.size || '',
|
||||||
|
price: l.price || '',
|
||||||
|
image,
|
||||||
|
hasImage: Boolean(image),
|
||||||
|
// optional plain text snippet
|
||||||
|
snippet: [l.address, l.price, l.size].filter(Boolean).join(' | '),
|
||||||
|
serviceName,
|
||||||
|
jobKey,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
const { apiKey, receiver, from, templateId } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
const { apiKey, receiver, from, templateId } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
|
|
||||||
sgMail.setApiKey(apiKey);
|
sgMail.setApiKey(apiKey);
|
||||||
const msg = {
|
|
||||||
templateId,
|
const to = receiver
|
||||||
to: receiver
|
|
||||||
.trim()
|
.trim()
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((r) => r.trim()),
|
.map((r) => r.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const listings = mapListings(serviceName, jobKey, newListings);
|
||||||
|
|
||||||
|
const msg = {
|
||||||
|
templateId,
|
||||||
|
to,
|
||||||
from,
|
from,
|
||||||
subject: `Job ${jobKey} | Service ${serviceName} found ${newListings.length} new listing(s)`,
|
subject: `Job ${jobKey} | Service ${serviceName} found ${newListings.length} new listing(s)`,
|
||||||
dynamic_template_data: {
|
dynamic_template_data: {
|
||||||
serviceName: `Job: (${jobKey}) | Service: ${serviceName}`,
|
serviceName: `Job: (${jobKey}) | Service: ${serviceName}`,
|
||||||
numberOfListings: newListings.length,
|
numberOfListings: newListings.length,
|
||||||
listings: newListings,
|
listings,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return sgMail.send(msg);
|
return sgMail.send(msg);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
id: 'sendgrid',
|
id: 'sendgrid',
|
||||||
name: 'SendGrid',
|
name: 'SendGrid',
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
### SendGrid Adapter
|
### SendGrid Adapter
|
||||||
|
|
||||||
|
|
||||||
SendGrid is a free email service (free as in "you cannot send more than 100(Sendgrid) and 200(Mailjet) emails a day"), which is more than enough for Fredy.
|
SendGrid is 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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|||||||
@@ -1,43 +1,61 @@
|
|||||||
import Slack from 'slack';
|
import Slack from 'slack';
|
||||||
import { markdown2Html } from '../../services/markdown.js';
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
const msg = Slack.chat.postMessage;
|
import { normalizeImageUrl } from '../../utils.js';
|
||||||
|
|
||||||
|
const buildBlocks = (serviceName, jobKey, p) => {
|
||||||
|
const blocks = [
|
||||||
|
{
|
||||||
|
type: 'header',
|
||||||
|
text: { type: 'plain_text', text: `New Listing from ${serviceName} (${jobKey})`, emoji: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'section',
|
||||||
|
text: { type: 'mrkdwn', text: `*<${p.link}|${p.title}>*` },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'section',
|
||||||
|
fields: [
|
||||||
|
{ type: 'mrkdwn', text: `*Price*\n${p.price ?? 'n/a'}` },
|
||||||
|
{ type: 'mrkdwn', text: `*Size*\n${p.size ?? 'n/a'}` },
|
||||||
|
{ type: 'mrkdwn', text: `*Address*\n${p.address ?? 'n/a'}` },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const img = normalizeImageUrl(p.image);
|
||||||
|
if (img) {
|
||||||
|
blocks.push({
|
||||||
|
type: 'image',
|
||||||
|
image_url: img,
|
||||||
|
alt_text: p.title || 'listing image',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks.push({
|
||||||
|
type: 'context',
|
||||||
|
elements: [{ type: 'mrkdwn', text: 'Powered by Fredy' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
};
|
||||||
|
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
const { token, channel } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
const { token, channel } = notificationConfig.find((a) => a.id === config.id).fields;
|
||||||
return newListings.map((payload) =>
|
|
||||||
msg({
|
return Promise.allSettled(
|
||||||
|
newListings.map((p) =>
|
||||||
|
Slack.chat.postMessage({
|
||||||
token,
|
token,
|
||||||
channel,
|
channel,
|
||||||
text: `*(${serviceName} - ${jobKey})* - ${payload.title}`,
|
text: `${serviceName} ${jobKey}: ${p.title}`,
|
||||||
attachments: [
|
blocks: buildBlocks(serviceName, jobKey, p),
|
||||||
{
|
unfurl_links: false,
|
||||||
fallback: payload.title,
|
unfurl_media: false,
|
||||||
color: '#36a64f',
|
|
||||||
title: 'Link to Exposé',
|
|
||||||
title_link: payload.link,
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
title: 'Price',
|
|
||||||
value: payload.price,
|
|
||||||
short: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Size',
|
|
||||||
value: payload.size,
|
|
||||||
short: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Address',
|
|
||||||
value: payload.address,
|
|
||||||
short: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
footer: 'Powered by Fredy',
|
|
||||||
ts: new Date().getTime() / 1000,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
id: 'slack',
|
id: 'slack',
|
||||||
name: 'Slack',
|
name: 'Slack',
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
### Slack Adapter
|
### 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!
|
||||||
|
|
||||||
|
|
||||||
In order to use [Slack](https://slack.com), you need to create an account. When done, you need to create a new App in your workspace. Give it the permission `chat:write:bot` and `chat:write:user`.
|
|
||||||
|
|
||||||
Now you need to create a user token and a channel. Make sure the bot is installed to this channel.
|
|
||||||
|
|||||||
79
lib/notification/adapter/slack_with_webhooks.js
Executable file
79
lib/notification/adapter/slack_with_webhooks.js
Executable file
@@ -0,0 +1,79 @@
|
|||||||
|
import fetch from 'node-fetch';
|
||||||
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
|
import { normalizeImageUrl } from '../../utils.js';
|
||||||
|
|
||||||
|
const buildBlocks = (serviceName, jobKey, p) => {
|
||||||
|
const blocks = [
|
||||||
|
{
|
||||||
|
type: 'header',
|
||||||
|
text: { type: 'plain_text', text: `New Listing from ${serviceName} (${jobKey})`, emoji: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'section',
|
||||||
|
text: { type: 'mrkdwn', text: `*<${p.link}|${p.title}>*` },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'section',
|
||||||
|
fields: [
|
||||||
|
{ type: 'mrkdwn', text: `*Price*\n${p.price ?? 'n/a'}` },
|
||||||
|
{ type: 'mrkdwn', text: `*Size*\n${p.size ?? 'n/a'}` },
|
||||||
|
{ type: 'mrkdwn', text: `*Address*\n${p.address ?? 'n/a'}` },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const img = normalizeImageUrl(p.image);
|
||||||
|
if (img) {
|
||||||
|
blocks.push({
|
||||||
|
type: 'image',
|
||||||
|
image_url: img,
|
||||||
|
alt_text: p.title || 'listing image',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks.push({
|
||||||
|
type: 'context',
|
||||||
|
elements: [{ type: 'mrkdwn', text: 'Powered by Fredy' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
};
|
||||||
|
|
||||||
|
const postJson = (url, body) =>
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
|
const adapter = notificationConfig.find((a) => a.id === config.id);
|
||||||
|
const webhookUrl = adapter?.fields?.webhookUrl;
|
||||||
|
if (!webhookUrl) return Promise.resolve([]);
|
||||||
|
|
||||||
|
const promises = newListings.map((p) => {
|
||||||
|
const body = JSON.stringify({
|
||||||
|
text: `${serviceName} ${jobKey}: ${p.title}`,
|
||||||
|
blocks: buildBlocks(serviceName, jobKey, p),
|
||||||
|
unfurl_links: false,
|
||||||
|
unfurl_media: false,
|
||||||
|
});
|
||||||
|
return postJson(webhookUrl, body);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.allSettled(promises);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
id: 'slack_with_webhooks',
|
||||||
|
name: 'Slack with Webhooks',
|
||||||
|
readme: markdown2Html('lib/notification/adapter/slack_with_webhooks.md'),
|
||||||
|
description: 'Fredy will send new listings to the slack channel of your choice..',
|
||||||
|
fields: {
|
||||||
|
webhookUrl: {
|
||||||
|
type: 'text',
|
||||||
|
label: 'Webhook-Url',
|
||||||
|
description: 'The Url of the Webhook to send messages to.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
6
lib/notification/adapter/slack_with_webhooks.md
Normal file
6
lib/notification/adapter/slack_with_webhooks.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
### Slack Adapter
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -2,7 +2,19 @@ import { markdown2Html } from '../../services/markdown.js';
|
|||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
export const send = ({ serviceName, newListings, jobKey }) => {
|
export const send = ({ serviceName, newListings, jobKey }) => {
|
||||||
const db = new Database('db/listings.db');
|
const db = new Database('db/listings.db');
|
||||||
const fields = ['serviceName', 'jobKey', 'id', 'size', 'rooms', 'price', 'address', 'title', 'link', 'description'];
|
const fields = [
|
||||||
|
'serviceName',
|
||||||
|
'jobKey',
|
||||||
|
'id',
|
||||||
|
'size',
|
||||||
|
'rooms',
|
||||||
|
'price',
|
||||||
|
'address',
|
||||||
|
'title',
|
||||||
|
'link',
|
||||||
|
'description',
|
||||||
|
'image',
|
||||||
|
];
|
||||||
db.prepare(`CREATE TABLE IF NOT EXISTS listing (${fields.join(' TEXT, ')} TEXT);`).run();
|
db.prepare(`CREATE TABLE IF NOT EXISTS listing (${fields.join(' TEXT, ')} TEXT);`).run();
|
||||||
const insert = db.prepare(`INSERT INTO listing (${fields.join(', ')}) VALUES (@${fields.join(', @')})`);
|
const insert = db.prepare(`INSERT INTO listing (${fields.join(', ')}) VALUES (@${fields.join(', @')})`);
|
||||||
newListings.map((listing) => {
|
newListings.map((listing) => {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
### Sqlite Adapter
|
### Sqlite Adapter
|
||||||
|
|
||||||
This adapter stores search results in a sqlite database located in db/listings.db. This file can be used for further analysis later on.
|
This adapter stores search results in a sqlite database located in db/listings.db. This file can be used for further analysis later on.
|
||||||
|
|
||||||
Fields are:
|
Fields are:
|
||||||
|
|
||||||
```
|
```
|
||||||
['serviceName', 'jobKey', 'id', 'size', 'rooms', 'price', 'address', 'title', 'link', 'description']
|
['serviceName', 'jobKey', 'id', 'size', 'rooms', 'price', 'address', 'title', 'link', 'description']
|
||||||
```
|
```
|
||||||
@@ -1,63 +1,98 @@
|
|||||||
import { markdown2Html } from '../../services/markdown.js';
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
import { getJob } from '../../services/storage/jobStorage.js';
|
import { getJob } from '../../services/storage/jobStorage.js';
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
const MAX_ENTITIES_PER_CHUNK = 8;
|
import pThrottle from 'p-throttle';
|
||||||
const RATE_LIMIT_INTERVAL = 1010;
|
import { normalizeImageUrl } from '../../utils.js';
|
||||||
/**
|
|
||||||
* splitting an array into chunks because Telegram only allows for messages up to
|
const RATE_LIMIT_INTERVAL = 1000;
|
||||||
* 4096 chars, thus we have to split messages into chunks
|
const chatThrottleMap = new Map();
|
||||||
* @param inputArray
|
|
||||||
* @param perChunk
|
function cleanupOldThrottles() {
|
||||||
*/
|
const now = Date.now();
|
||||||
const arrayChunks = (inputArray, perChunk) =>
|
const maxAge = RATE_LIMIT_INTERVAL + 1000;
|
||||||
inputArray.reduce((all, one, i) => {
|
const toBeDeleted = [];
|
||||||
const ch = Math.floor(i / perChunk);
|
for (const [chatId, chatThrottle] of chatThrottleMap.entries()) {
|
||||||
all[ch] = [].concat(all[ch] || [], one);
|
if (now - chatThrottle.lastUsedAt > maxAge) toBeDeleted.push(chatId);
|
||||||
return all;
|
|
||||||
}, []);
|
|
||||||
function shorten(str, len = 30) {
|
|
||||||
return str.length > len ? str.substring(0, len) + '...' : str;
|
|
||||||
}
|
}
|
||||||
|
for (const chatId of toBeDeleted) chatThrottleMap.delete(chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getThrottled(chatId, call) {
|
||||||
|
cleanupOldThrottles();
|
||||||
|
const now = Date.now();
|
||||||
|
const chatThrottle = chatThrottleMap.get(chatId);
|
||||||
|
if (chatThrottle) {
|
||||||
|
chatThrottle.lastUsedAt = now;
|
||||||
|
return chatThrottle.throttled;
|
||||||
|
}
|
||||||
|
const throttled = pThrottle({ limit: 1, interval: RATE_LIMIT_INTERVAL })(call);
|
||||||
|
chatThrottleMap.set(chatId, { lastUsedAt: now, throttled });
|
||||||
|
return throttled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shorten(str, len = 90) {
|
||||||
|
if (!str) return '';
|
||||||
|
return str.length > len ? str.substring(0, len).trim() + '...' : str;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s = '') {
|
||||||
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCaption(jobName, serviceName, o) {
|
||||||
|
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
||||||
|
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
|
||||||
|
return `<i>${escapeHtml(jobName)}</i> (${escapeHtml(serviceName)})\n<a href='${escapeHtml(
|
||||||
|
o.link || '',
|
||||||
|
)}'><b>${escapeHtml(title)}</b></a>\n${escapeHtml(meta)}`.slice(0, 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildText(jobName, serviceName, o) {
|
||||||
|
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
||||||
|
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
|
||||||
|
return (
|
||||||
|
`<i>${escapeHtml(jobName)}</i> (${escapeHtml(serviceName)})\n` +
|
||||||
|
`<a href='${escapeHtml(o.link || '')}'><b>${escapeHtml(title)}</b></a>\n` +
|
||||||
|
`${escapeHtml(meta)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
const { token, chatId } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
const { token, chatId } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
const job = getJob(jobKey);
|
const job = getJob(jobKey);
|
||||||
const jobName = job == null ? jobKey : job.name;
|
const jobName = job == null ? jobKey : job.name;
|
||||||
//we have to split messages into chunk, because otherwise messages are going to become too big and will fail
|
|
||||||
const chunks = arrayChunks(newListings, MAX_ENTITIES_PER_CHUNK);
|
const throttledCall = getThrottled(chatId, async function (endpoint, body) {
|
||||||
const promises = chunks.map((chunk) => {
|
await fetch(`https://api.telegram.org/bot${token}/${endpoint}`, {
|
||||||
let message = `<i>${jobName}</i> (${serviceName}) found <b>${newListings.length}</b> new listings:\n\n`;
|
|
||||||
message += chunk.map(
|
|
||||||
(o) =>
|
|
||||||
`<a href='${o.link}'><b>${shorten(o.title.replace(/\*/g, ''), 45).trim()}</b></a>\n` +
|
|
||||||
[o.address, o.price, o.size].join(' | ') +
|
|
||||||
'\n\n',
|
|
||||||
);
|
|
||||||
/**
|
|
||||||
* This is to not break the rate limit. It is to only send 1 message per second
|
|
||||||
*/
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
|
|
||||||
method: 'post',
|
method: 'post',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(body),
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const promises = newListings.map(async (o) => {
|
||||||
|
const img = normalizeImageUrl(o.image);
|
||||||
|
|
||||||
|
if (img) {
|
||||||
|
return throttledCall('sendPhoto', {
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
text: message,
|
photo: img,
|
||||||
|
caption: buildCaption(jobName, serviceName, o),
|
||||||
|
parse_mode: 'HTML',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return throttledCall('sendMessage', {
|
||||||
|
chat_id: chatId,
|
||||||
|
text: buildText(jobName, serviceName, o),
|
||||||
parse_mode: 'HTML',
|
parse_mode: 'HTML',
|
||||||
disable_web_page_preview: true,
|
disable_web_page_preview: true,
|
||||||
}),
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
resolve();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
reject();
|
|
||||||
});
|
|
||||||
}, RATE_LIMIT_INTERVAL);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return Promise.all(promises);
|
return Promise.all(promises);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
id: 'telegram',
|
id: 'telegram',
|
||||||
name: 'Telegram',
|
name: 'Telegram',
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
### Telegram Adapter
|
### Telegram Adapter
|
||||||
|
|
||||||
|
|
||||||
For Telegram, you need to create a Bot. This is pretty easy. Open [this](https://telegram.me/BotFather) url on your smartphone and follow the instructions.
|
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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|||||||
123
lib/notification/emailTemplate/mailjet.hbs
Normal file
123
lib/notification/emailTemplate/mailjet.hbs
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<title>Listings</title>
|
||||||
|
<style type="text/css">
|
||||||
|
body { margin:0; padding:0; background:#000000; }
|
||||||
|
table { border-collapse:collapse; }
|
||||||
|
img { border:0; outline:none; text-decoration:none; display:block; }
|
||||||
|
a { text-decoration:none; }
|
||||||
|
.container { width:100%; max-width:640px; margin:0 auto; }
|
||||||
|
.card { background:#111111; border:1px solid #222222; border-radius:8px; overflow:hidden; }
|
||||||
|
.divider { height:2px; line-height:2px; font-size:0; background:#00dc73; }
|
||||||
|
.h1 { font:700 18px/1.4 Arial, Helvetica, sans-serif; color:#ffffff; margin:0; }
|
||||||
|
.h2 { font:700 16px/1.4 Arial, Helvetica, sans-serif; color:#ffffff; margin:0; }
|
||||||
|
.p { font:400 14px/1.6 Arial, Helvetica, sans-serif; color:#d9d9d9; margin:0; }
|
||||||
|
.meta { font:400 13px/1.5 Arial, Helvetica, sans-serif; color:#bfbfbf; }
|
||||||
|
.btn { background:#00dc73; color:#0b0b0b; font:700 14px/1 Arial, Helvetica, sans-serif; padding:12px 18px; border-radius:6px; display:inline-block; }
|
||||||
|
.sp-8 { height:8px; line-height:8px; font-size:0; }
|
||||||
|
.sp-12 { height:12px; line-height:12px; font-size:0; }
|
||||||
|
.sp-16 { height:16px; line-height:16px; font-size:0; }
|
||||||
|
.sp-20 { height:20px; line-height:20px; font-size:0; }
|
||||||
|
.sp-24 { height:24px; line-height:24px; font-size:0; }
|
||||||
|
@media screen and (max-width:480px){
|
||||||
|
.container { width:100% !important; }
|
||||||
|
.stack { display:block !important; width:100% !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="background:#000000;">
|
||||||
|
<table role="presentation" width="100%" bgcolor="#000000">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" class="container" width="640">
|
||||||
|
<tr><td class="sp-20"></td></tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<h1 class="h1" style="text-align:center;">
|
||||||
|
Service {{serviceName}} found {{numberOfListings}} new listings
|
||||||
|
</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr><td class="sp-12"></td></tr>
|
||||||
|
<tr><td class="divider"></td></tr>
|
||||||
|
<tr><td class="sp-16"></td></tr>
|
||||||
|
|
||||||
|
{{#each listings}}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table role="presentation" class="card" width="100%">
|
||||||
|
{{#if this.hasImage}}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{this.link}}" target="_blank">
|
||||||
|
<img src="cid:{{this.imageCid}}" alt="{{this.title}}" width="640"
|
||||||
|
style="width:100%; height:auto; background:#1a1a1a;" />
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:16px 18px 0 18px;">
|
||||||
|
<a href="{{this.link}}" target="_blank" style="color:#ffffff;">
|
||||||
|
<h2 class="h2">{{this.title}}</h2>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr><td class="sp-8"></td></tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 18px;">
|
||||||
|
<table role="presentation" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td class="stack" style="vertical-align:top; width:50%; padding-right:8px;">
|
||||||
|
<p class="meta"><strong>Price</strong><br/>{{#if this.price}}{{this.price}}{{else}}unknown{{/if}}</p>
|
||||||
|
</td>
|
||||||
|
<td class="stack" style="vertical-align:top; width:50%; padding-left:8px;">
|
||||||
|
<p class="meta"><strong>Size</strong><br/>{{#if this.size}}{{this.size}}{{else}}unknown{{/if}}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr><td class="sp-8"></td><td class="sp-8"></td></tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2">
|
||||||
|
<p class="meta"><strong>Address</strong><br/>{{#if this.address}}{{this.address}}{{else}}unknown{{/if}}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr><td class="sp-16"></td></tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding:0 18px 18px 18px;">
|
||||||
|
<a href="{{this.link}}" class="btn" target="_blank">View Listing</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr><td class="sp-24"></td></tr>
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
<tr><td class="divider"></td></tr>
|
||||||
|
<tr><td class="sp-16"></td></tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<p class="p" style="color:#9f9f9f;">Powered by Fredy</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr><td class="sp-20"></td></tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,237 +1,131 @@
|
|||||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"><html data-editor-version="2" class="sg-campaigns" xmlns="http://www.w3.org/1999/xhtml"><head>
|
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1">
|
<head>
|
||||||
<!--[if !mso]><!-->
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge">
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<!--<![endif]-->
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
<!--[if (gte mso 9)|(IE)]>
|
<title>Fredy found some new listings</title>
|
||||||
<xml>
|
|
||||||
<o:OfficeDocumentSettings>
|
|
||||||
<o:AllowPNG/>
|
|
||||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
|
||||||
</o:OfficeDocumentSettings>
|
|
||||||
</xml>
|
|
||||||
<![endif]-->
|
|
||||||
<!--[if (gte mso 9)|(IE)]>
|
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
body {width: 600px;margin: 0 auto;}
|
body { margin:0; padding:0; background:#000000; }
|
||||||
table { border-collapse:collapse; }
|
table { border-collapse:collapse; }
|
||||||
table, td {mso-table-lspace: 0pt;mso-table-rspace: 0pt;}
|
img { border:0; outline:none; text-decoration:none; display:block; }
|
||||||
img {-ms-interpolation-mode: bicubic;}
|
a { text-decoration:none; }
|
||||||
</style>
|
.container { width:100%; max-width:640px; margin:0 auto; }
|
||||||
<![endif]-->
|
.card { background:#111111; border:1px solid #222222; border-radius:8px; overflow:hidden; }
|
||||||
<style type="text/css">
|
.divider { height:2px; line-height:2px; font-size:0; background:#00dc73; }
|
||||||
body, p, div {
|
.h1 { font:700 18px/1.4 Arial, Helvetica, sans-serif; color:#ffffff; margin:0; }
|
||||||
font-family: arial,helvetica,sans-serif;
|
.h2 { font:700 16px/1.4 Arial, Helvetica, sans-serif; color:#ffffff; margin:0; }
|
||||||
font-size: 14px;
|
.p { font:400 14px/1.6 Arial, Helvetica, sans-serif; color:#d9d9d9; margin:0; }
|
||||||
}
|
.meta { font:400 13px/1.5 Arial, Helvetica, sans-serif; color:#bfbfbf; }
|
||||||
body {
|
.btn { background:#00dc73; color:#0b0b0b; font:700 14px/1 Arial, Helvetica, sans-serif; padding:12px 18px; border-radius:6px; display:inline-block; }
|
||||||
color: #000000;
|
.sp-8 { height:8px; line-height:8px; font-size:0; }
|
||||||
}
|
.sp-12 { height:12px; line-height:12px; font-size:0; }
|
||||||
body a {
|
.sp-16 { height:16px; line-height:16px; font-size:0; }
|
||||||
color: #42ee99;
|
.sp-20 { height:20px; line-height:20px; font-size:0; }
|
||||||
text-decoration: none;
|
.sp-24 { height:24px; line-height:24px; font-size:0; }
|
||||||
}
|
|
||||||
p { margin: 0; padding: 0; }
|
|
||||||
table.wrapper {
|
|
||||||
width:100% !important;
|
|
||||||
table-layout: fixed;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
-moz-text-size-adjust: 100%;
|
|
||||||
-ms-text-size-adjust: 100%;
|
|
||||||
}
|
|
||||||
img.max-width {
|
|
||||||
max-width: 100% !important;
|
|
||||||
}
|
|
||||||
.column.of-2 {
|
|
||||||
width: 50%;
|
|
||||||
}
|
|
||||||
.column.of-3 {
|
|
||||||
width: 33.333%;
|
|
||||||
}
|
|
||||||
.column.of-4 {
|
|
||||||
width: 25%;
|
|
||||||
}
|
|
||||||
@media screen and (max-width:480px){
|
@media screen and (max-width:480px){
|
||||||
.preheader .rightColumnContent,
|
.container { width:100% !important; }
|
||||||
.footer .rightColumnContent {
|
.stack { display:block !important; width:100% !important; }
|
||||||
text-align: left !important;
|
|
||||||
}
|
|
||||||
.preheader .rightColumnContent div,
|
|
||||||
.preheader .rightColumnContent span,
|
|
||||||
.footer .rightColumnContent div,
|
|
||||||
.footer .rightColumnContent span {
|
|
||||||
text-align: left !important;
|
|
||||||
}
|
|
||||||
.preheader .rightColumnContent,
|
|
||||||
.preheader .leftColumnContent {
|
|
||||||
font-size: 80% !important;
|
|
||||||
padding: 5px 0;
|
|
||||||
}
|
|
||||||
table.wrapper-mobile {
|
|
||||||
width: 100% !important;
|
|
||||||
table-layout: fixed;
|
|
||||||
}
|
|
||||||
img.max-width {
|
|
||||||
height: auto !important;
|
|
||||||
max-width: 100% !important;
|
|
||||||
}
|
|
||||||
a.bulletproof-button {
|
|
||||||
display: block !important;
|
|
||||||
width: auto !important;
|
|
||||||
font-size: 80%;
|
|
||||||
padding-left: 0 !important;
|
|
||||||
padding-right: 0 !important;
|
|
||||||
}
|
|
||||||
.columns {
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
.column {
|
|
||||||
display: block !important;
|
|
||||||
width: 100% !important;
|
|
||||||
padding-left: 0 !important;
|
|
||||||
padding-right: 0 !important;
|
|
||||||
margin-left: 0 !important;
|
|
||||||
margin-right: 0 !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<!--user entered Head Start-->
|
|
||||||
|
|
||||||
<!--End Head user entered-->
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body style="background:#000000;">
|
||||||
<center class="wrapper" data-link-color="#42ee99" data-body-style="font-size:14px; font-family:arial,helvetica,sans-serif; color:#000000; background-color:#000000;">
|
<table role="presentation" width="100%" bgcolor="#000000">
|
||||||
<div class="webkit">
|
<tr>
|
||||||
<table cellpadding="0" cellspacing="0" border="0" width="100%" class="wrapper" bgcolor="#000000">
|
<td align="center">
|
||||||
<tbody><tr>
|
<table role="presentation" class="container" width="640">
|
||||||
<td valign="top" bgcolor="#000000" width="100%">
|
<tr><td class="sp-20"></td></tr>
|
||||||
<table width="100%" role="content-container" class="outer" align="center" cellpadding="0" cellspacing="0" border="0">
|
<tr>
|
||||||
<tbody><tr>
|
<td align="center">
|
||||||
<td width="100%">
|
<table role="presentation" width="100%">
|
||||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
<tr>
|
||||||
<tbody><tr>
|
<td align="center">
|
||||||
<td>
|
<h1 class="h1" style="text-align:center;">
|
||||||
<!--[if mso]>
|
Service {{serviceName}} found {{numberOfListings}} new listings
|
||||||
<center>
|
</h1>
|
||||||
<table><tr><td width="600">
|
|
||||||
<![endif]-->
|
|
||||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%; max-width:600px;" align="center">
|
|
||||||
<tbody><tr>
|
|
||||||
<td role="modules-container" style="padding:0px 0px 0px 0px; color:#000000; text-align:left;" bgcolor="#FFFFFF" width="100%" align="left"><table class="module preheader preheader-hide" role="module" data-type="preheader" border="0" cellpadding="0" cellspacing="0" width="100%" style="display: none !important; mso-hide: all; visibility: hidden; opacity: 0; color: transparent; height: 0; width: 0;">
|
|
||||||
</table><table class="module" role="module" data-type="spacer" border="0" cellpadding="0" cellspacing="0" width="100%" style="table-layout: fixed;" data-muid="vB9TDziyvx65CC2nx3oyRH">
|
|
||||||
<tbody><tr>
|
|
||||||
<td style="padding:0px 0px 20px 0px;" role="module-content" bgcolor="#000000">
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody></table><table class="wrapper" role="module" data-type="image" border="0" cellpadding="0" cellspacing="0" width="100%" style="table-layout: fixed;" data-muid="uXsDxMnn1bRMmDcX8NB6rW">
|
<tr><td class="sp-12"></td></tr>
|
||||||
</table><table class="module" role="module" data-type="text" border="0" cellpadding="0" cellspacing="0" width="100%" style="table-layout: fixed;" data-muid="hL6wjQ2qknNd5qDwT1p7Up">
|
<tr><td class="divider"></td></tr>
|
||||||
<tbody><tr>
|
<tr><td class="sp-16"></td></tr>
|
||||||
<td style="background-color:#000000; padding:10px 20px 10px 20px; line-height:40px; text-align:justify;" height="100%" valign="top" bgcolor="#000000"><div><h1 style="text-align: center"><span style="color: #ffffff; font-size: 14px; font-family: verdana,geneva,sans-serif"><strong>Service {{serviceName}} found {{numberOfListings}} new listing(s)!</strong></span></h1><div></div></div></td>
|
|
||||||
</tr>
|
|
||||||
</tbody></table>
|
|
||||||
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" align="center" width="100%" height="1px" style="line-height:3px; font-size:3px;">
|
|
||||||
<tbody><tr>
|
|
||||||
<td style="padding:0px 0px 1px 0px;" bgcolor="#42ee99"></td>
|
|
||||||
</tr>
|
|
||||||
</tbody></table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
<table class="module" role="module" data-type="text" border="0" cellpadding="0" cellspacing="0" width="100%" style="table-layout: fixed;" data-muid="qk51Jjn4bm3rn2Yb31Dxzb">
|
|
||||||
<tbody>
|
|
||||||
{{#each listings}}
|
{{#each listings}}
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:50px 50px 10px 50px; line-height:22px; text-align:center; color:white" bgcolor="#000000" height="100%" valign="top">
|
<td>
|
||||||
<div>
|
<table role="presentation" class="card" width="100%">
|
||||||
<span style="font-size: 12px; font-family: verdana,geneva,sans-serif; color: white;"><b>{{this.title}}</b></span>
|
{{#if this.hasImage}}
|
||||||
<br/>
|
|
||||||
<span style="font-size: 12px; font-family: verdana,geneva,sans-serif; color: white;">Size: {{#if this.size}}{{this.size}}{{else}}unknown{{/if}}</span>
|
|
||||||
<br/>
|
|
||||||
<span style="font-size: 12px; font-family: verdana,geneva,sans-serif; color: white;">Price: {{#if this.price}}{{this.price}}{{else}}unknown{{/if}}</span>
|
|
||||||
<br/>
|
|
||||||
<span style="font-size: 12px; font-family: verdana,geneva,sans-serif; color: white;">{{#if this.address}}{{this.address}}{{else}}unknown{{/if}}</span>
|
|
||||||
<br/>
|
|
||||||
<a href="{{this.link}}" target="_blank" style="color:#00dc73; font-size:13px">{{this.link}}</a>
|
|
||||||
<br/>
|
|
||||||
<span style="color: white;">---------------------------</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{/each}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<table class="module" role="module" data-type="spacer" border="0" cellpadding="0" cellspacing="0" width="100%" style="table-layout: fixed;" data-muid="2ga5f7koD5ApvUfnqUK6aT">
|
|
||||||
<tbody><tr>
|
|
||||||
<td style="padding:0px 0px 30px 0px;" role="module-content" bgcolor="#000000">
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody></table>
|
|
||||||
<table class="module" role="module" data-type="divider" border="0" cellpadding="0" cellspacing="0" width="100%" style="table-layout: fixed;" data-muid="c3nRrjMndqXf1snYDFPSF9">
|
|
||||||
<tbody><tr>
|
|
||||||
<td style="padding:0px 0px 0px 0px;" role="module-content" height="100%" valign="top" bgcolor="#000000">
|
|
||||||
<table border="0" cellpadding="0" cellspacing="0" align="center" width="100%" height="2px" style="line-height:1px; font-size:2px;">
|
|
||||||
<tbody><tr>
|
|
||||||
<td style="padding:0px 0px 2px 0px;" bgcolor="#42ee99"></td>
|
|
||||||
</tr>
|
|
||||||
</tbody></table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<table class="module" role="module" data-type="spacer" border="0" cellpadding="0" cellspacing="0" width="100%" style="table-layout: fixed;" data-muid="pa9PeYjCEFyByuP5878Sd2">
|
|
||||||
<tbody><tr>
|
|
||||||
<td style="padding:0px 0px 30px 0px;" role="module-content" bgcolor="#000000">
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody></table>
|
|
||||||
|
|
||||||
<table class="module" role="module" data-type="social" align="center" border="0" cellpadding="0" cellspacing="0" width="100%" style="table-layout: fixed;" data-muid="n7FceQWVnLmounEt32B1gj">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
<tr>
|
||||||
<td valign="top" style="padding:0px 0px 0px 0px; font-size:6px; line-height:10px; background-color:#000000;" align="center">
|
<td>
|
||||||
<table align="center">
|
<a href="{{this.link}}" target="_blank">
|
||||||
<tbody>
|
<img src="{{this.image}}" alt="{{this.title}}" width="640" style="width:100%;height:auto;background:#1a1a1a;" />
|
||||||
<tr><td style="padding: 0px 5px;">
|
|
||||||
<a href="https://github.com/orangecoding/fredy" target="_blank" alt="Fredy" title="Powered by Fredy" style="color:#00dc73; font-size:17px">
|
|
||||||
Powered by Fredy
|
|
||||||
</a>
|
</a>
|
||||||
</td></tr>
|
</td>
|
||||||
</tbody>
|
</tr>
|
||||||
|
{{/if}}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:16px 18px 0 18px;">
|
||||||
|
<a href="{{this.link}}" target="_blank" style="color:#ffffff;">
|
||||||
|
<h2 class="h2">{{this.title}}</h2>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr><td class="sp-8"></td></tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 18px;">
|
||||||
|
<table role="presentation" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td class="stack" style="vertical-align:top; width:50%; padding-right:8px;">
|
||||||
|
<p class="meta"><strong>Price</strong><br/>{{#if this.price}}{{this.price}}{{else}}unknown{{/if}}</p>
|
||||||
|
</td>
|
||||||
|
<td class="stack" style="vertical-align:top; width:50%; padding-left:8px;">
|
||||||
|
<p class="meta"><strong>Size</strong><br/>{{#if this.size}}{{this.size}}{{else}}unknown{{/if}}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr><td class="sp-8"></td><td class="sp-8"></td></tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2">
|
||||||
|
<p class="meta"><strong>Address</strong><br/>{{#if this.address}}{{this.address}}{{else}}unknown{{/if}}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
<tr><td class="sp-16"></td></tr>
|
||||||
</table></table><table class="module" role="module" data-type="spacer" border="0" cellpadding="0" cellspacing="0" width="100%" style="table-layout: fixed;" data-muid="35xFa9abxGTBYt9yR9BeQ2">
|
<tr>
|
||||||
<tbody><tr>
|
<td align="left" style="padding:0 18px 18px 18px;">
|
||||||
<td style="padding:0px 0px 30px 0px;" role="module-content" bgcolor="#000000">
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody></table></td>
|
|
||||||
</tr>
|
|
||||||
</tbody></table>
|
|
||||||
<!--[if mso]>
|
<!--[if mso]>
|
||||||
|
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" href="{{this.link}}" arcsize="8%" strokecolor="#00dc73" strokeweight="0" fillcolor="#00dc73" style="height:40px;v-text-anchor:middle;width:180px;">
|
||||||
|
<w:anchorlock/>
|
||||||
|
<center style="color:#0b0b0b;font-family:Arial;font-size:14px;font-weight:bold;">
|
||||||
|
View Listing
|
||||||
|
</center>
|
||||||
|
</v:roundrect>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if !mso]><!-- -->
|
||||||
|
<a href="{{this.link}}" class="btn" target="_blank">View Listing</a>
|
||||||
|
<!--<![endif]-->
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</center>
|
|
||||||
<![endif]-->
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody></table>
|
<tr><td class="sp-24"></td></tr>
|
||||||
</td>
|
{{/each}}
|
||||||
</tr>
|
|
||||||
</tbody></table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody></table>
|
|
||||||
</div>
|
|
||||||
</center>
|
|
||||||
|
|
||||||
|
<tr><td class="divider"></td></tr>
|
||||||
</body></html>
|
<tr><td class="sp-16"></td></tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<p class="p" style="color:#9f9f9f;">Powered by Fredy</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr><td class="sp-20"></td></tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const adapter = await Promise.all(
|
|||||||
fs
|
fs
|
||||||
.readdirSync('./lib/notification/adapter')
|
.readdirSync('./lib/notification/adapter')
|
||||||
.filter((file) => file.endsWith('.js'))
|
.filter((file) => file.endsWith('.js'))
|
||||||
.map(async (integPath) => await import(`${path}/${integPath}`))
|
.map(async (integPath) => await import(`${path}/${integPath}`)),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (adapter.length === 0) {
|
if (adapter.length === 0) {
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import utils, { buildHash } from '../utils.js';
|
|||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const link = `https://www.1a-immobilienmarkt.de/expose/${o.id}.html`;
|
const baseUrl = 'https://www.1a-immobilienmarkt.de';
|
||||||
|
const link = `${baseUrl}/expose/${o.id}.html`;
|
||||||
const price = normalizePrice(o.price);
|
const price = normalizePrice(o.price);
|
||||||
const id = buildHash(o.id, price);
|
const id = buildHash(o.id, price);
|
||||||
return Object.assign(o, { id, price, link });
|
const image = baseUrl + o.image;
|
||||||
|
return Object.assign(o, { id, price, link, image });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,6 +43,7 @@ const config = {
|
|||||||
price: '.inner_object_data .single_data_price | removeNewline | trim',
|
price: '.inner_object_data .single_data_price | removeNewline | trim',
|
||||||
size: '.tabelle .tabelle_inhalt_infos .single_data_box | removeNewline | trim',
|
size: '.tabelle .tabelle_inhalt_infos .single_data_box | removeNewline | trim',
|
||||||
title: '.inner_object_data .tabelle_inhalt_titel_black | removeNewline | trim',
|
title: '.inner_object_data .tabelle_inhalt_titel_black | removeNewline | trim',
|
||||||
|
image: '.inner_object_pic img@src',
|
||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
|||||||
@@ -1,26 +1,34 @@
|
|||||||
import utils, { buildHash } from '../utils.js';
|
import utils, { buildHash } from '../utils.js';
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
function shortenLink(link) {
|
function shortenLink(link) {
|
||||||
return link.substring(0, link.indexOf('?'));
|
return link.substring(0, link.indexOf('?'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseId(shortenedLink) {
|
function parseId(shortenedLink) {
|
||||||
return shortenedLink.substring(shortenedLink.lastIndexOf('/') + 1);
|
return shortenedLink.substring(shortenedLink.lastIndexOf('/') + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
|
const baseUrl = 'https://www.immobilien.de';
|
||||||
const size = o.size || 'N/A m²';
|
const size = o.size || 'N/A m²';
|
||||||
const price = o.price || 'N/A €';
|
const price = o.price || 'N/A €';
|
||||||
const title = o.title || 'No title available';
|
const title = o.title || 'No title available';
|
||||||
const address = o.address || 'No address available';
|
const address = o.address || 'No address available';
|
||||||
const shortLink = shortenLink(o.link);
|
const shortLink = shortenLink(o.link);
|
||||||
const link = `https://www.immobilien.de/${shortLink}`;
|
const link = `${baseUrl}/${shortLink}`;
|
||||||
|
const image = baseUrl + o.image;
|
||||||
const id = buildHash(parseId(shortLink), o.price);
|
const id = buildHash(parseId(shortLink), o.price);
|
||||||
return Object.assign(o, { id, price, size, title, address, link });
|
return Object.assign(o, { id, price, size, title, address, link, image });
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '._ref',
|
crawlContainer: '._ref',
|
||||||
@@ -34,6 +42,7 @@ const config = {
|
|||||||
description: '.list_entry .description | trim',
|
description: '.list_entry .description | trim',
|
||||||
link: '@href',
|
link: '@href',
|
||||||
address: '.list_entry .place',
|
address: '.list_entry .place',
|
||||||
|
image: '.list_entry img@src',
|
||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const config = {
|
|||||||
price: 'div[data-testid="cardmfe-price-testid"] | trim',
|
price: 'div[data-testid="cardmfe-price-testid"] | trim',
|
||||||
size: 'div[data-testid="cardmfe-keyfacts-testid"] | trim',
|
size: 'div[data-testid="cardmfe-keyfacts-testid"] | trim',
|
||||||
address: 'div[data-testid="cardmfe-description-box-address"] | trim',
|
address: 'div[data-testid="cardmfe-description-box-address"] | trim',
|
||||||
|
image: 'div[data-testid="cardmfe-picture-box-test-id"] img@src',
|
||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import utils, { buildHash } from '../utils.js';
|
import utils, { buildHash } from '../utils.js';
|
||||||
import { convertWebToMobile } from '../services/immoscout/immoscout-web-translater.js';
|
import { convertWebToMobile } from '../services/immoscout/immoscout-web-translator.js';
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
async function getListings(url) {
|
async function getListings(url) {
|
||||||
@@ -62,6 +62,7 @@ async function getListings(url) {
|
|||||||
.map((expose) => {
|
.map((expose) => {
|
||||||
const item = expose.item;
|
const item = expose.item;
|
||||||
const [price, size] = item.attributes;
|
const [price, size] = item.attributes;
|
||||||
|
const { preview: image } = item.titlePicture;
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
price: price?.value,
|
price: price?.value,
|
||||||
@@ -69,6 +70,7 @@ async function getListings(url) {
|
|||||||
title: item.title,
|
title: item.title,
|
||||||
link: `${metaInformation.baseUrl}expose/${item.id}`,
|
link: `${metaInformation.baseUrl}expose/${item.id}`,
|
||||||
address: item.address?.line,
|
address: item.address?.line,
|
||||||
|
image,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const config = {
|
|||||||
title: '.js-item-title-link@title | trim',
|
title: '.js-item-title-link@title | trim',
|
||||||
link: '.ci-search-result__link@href',
|
link: '.ci-search-result__link@href',
|
||||||
description: '.js-show-more-item-sm | removeNewline | trim',
|
description: '.js-show-more-item-sm | removeNewline | trim',
|
||||||
|
image: 'img@src',
|
||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const config = {
|
|||||||
title: 'div[data-testid="cardmfe-description-box-text-test-id"] > div:nth-of-type(2)',
|
title: 'div[data-testid="cardmfe-description-box-text-test-id"] > div:nth-of-type(2)',
|
||||||
link: 'a@href',
|
link: 'a@href',
|
||||||
address: 'div[data-testid="cardmfe-description-box-address"] | removeNewline | trim',
|
address: 'div[data-testid="cardmfe-description-box-address"] | removeNewline | trim',
|
||||||
|
image: 'div[data-testid="cardMfe-card-pictureBox-opacity"] img@src',
|
||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const config = {
|
|||||||
link: '.aditem-main .text-module-begin a@href | removeNewline | trim',
|
link: '.aditem-main .text-module-begin a@href | removeNewline | trim',
|
||||||
description: '.aditem-main .aditem-main--middle--description | removeNewline | trim',
|
description: '.aditem-main .aditem-main--middle--description | removeNewline | trim',
|
||||||
address: '.aditem-main--top--left | trim | removeNewline',
|
address: '.aditem-main--top--left | trim | removeNewline',
|
||||||
|
image: 'img@src',
|
||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ function nullOrEmpty(val) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const link = nullOrEmpty(o.link) ? 'NO LINK' : `https://www.neubaukompass.de${o.link.substring(o.link.indexOf('/neubau'))}`;
|
const link = nullOrEmpty(o.link)
|
||||||
|
? 'NO LINK'
|
||||||
|
: `https://www.neubaukompass.de${o.link.substring(o.link.indexOf('/neubau'))}`;
|
||||||
const id = buildHash(o.link, o.price);
|
const id = buildHash(o.link, o.price);
|
||||||
return Object.assign(o, { id, link });
|
return Object.assign(o, { id, link });
|
||||||
}
|
}
|
||||||
@@ -27,6 +29,7 @@ const config = {
|
|||||||
link: 'a@href',
|
link: 'a@href',
|
||||||
address: '.nbk-project-card__description | removeNewline | trim',
|
address: '.nbk-project-card__description | removeNewline | trim',
|
||||||
price: '.nbk-project-card__spec-item .nbk-project-card__spec-value | removeNewline | trim',
|
price: '.nbk-project-card__spec-item .nbk-project-card__spec-value | removeNewline | trim',
|
||||||
|
image: '.nbk-project-card__image@src',
|
||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ let appliedBlackList = [];
|
|||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const id = buildHash(o.id, o.price);
|
const id = buildHash(o.id, o.price);
|
||||||
const link = `https://www.wg-gesucht.de${o.link}`;
|
const link = `https://www.wg-gesucht.de${o.link}`;
|
||||||
return Object.assign(o, { id, link });
|
const image = o.image != null ? o.image.replace('small', 'large') : null;
|
||||||
|
return Object.assign(o, { id, link, image });
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
@@ -26,6 +27,7 @@ const config = {
|
|||||||
size: '.middle .text-right |removeNewline |trim',
|
size: '.middle .text-right |removeNewline |trim',
|
||||||
title: '.truncate_title a |removeNewline |trim',
|
title: '.truncate_title a |removeNewline |trim',
|
||||||
link: '.truncate_title a@href',
|
link: '.truncate_title a@href',
|
||||||
|
image: '.img-responsive@src',
|
||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export function loadParser(text) {
|
|||||||
|
|
||||||
export function parse(crawlContainer, crawlFields, text, url) {
|
export function parse(crawlContainer, crawlFields, text, url) {
|
||||||
if (!text) {
|
if (!text) {
|
||||||
console.warn('Cannot parse, text was empty for url ', url);
|
console.warn('No content found for ', url);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export const removeUser = (userId) => {
|
|||||||
db.chain
|
db.chain
|
||||||
.set(
|
.set(
|
||||||
'user',
|
'user',
|
||||||
user.filter((u) => u.id !== userId)
|
user.filter((u) => u.id !== userId),
|
||||||
)
|
)
|
||||||
.value();
|
.value();
|
||||||
db.write();
|
db.write();
|
||||||
@@ -95,8 +95,12 @@ export const removeUser = (userId) => {
|
|||||||
export const handleDemoUser = () => {
|
export const handleDemoUser = () => {
|
||||||
if (!config.demoMode) {
|
if (!config.demoMode) {
|
||||||
const user = db.chain.get('user').value();
|
const user = db.chain.get('user').value();
|
||||||
db.chain.get('user').value();
|
db.chain
|
||||||
db.chain.set('user', user.filter((u) => u.username !== 'demo')).value();
|
.set(
|
||||||
|
'user',
|
||||||
|
user.filter((u) => u.username !== 'demo'),
|
||||||
|
)
|
||||||
|
.value();
|
||||||
db.write();
|
db.write();
|
||||||
} else {
|
} else {
|
||||||
const demoUser = db.chain
|
const demoUser = db.chain
|
||||||
@@ -104,7 +108,8 @@ export const handleDemoUser = () => {
|
|||||||
.filter((u) => u.username === 'demo')
|
.filter((u) => u.username === 'demo')
|
||||||
.value();
|
.value();
|
||||||
if (demoUser == null || demoUser.length === 0) {
|
if (demoUser == null || demoUser.length === 0) {
|
||||||
db.chain.get('user')
|
db.chain
|
||||||
|
.get('user')
|
||||||
.value()
|
.value()
|
||||||
.push({
|
.push({
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
@@ -116,4 +121,3 @@ export const handleDemoUser = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ function enrichTrackingObject(trackingObject) {
|
|||||||
nodeVersion,
|
nodeVersion,
|
||||||
language,
|
language,
|
||||||
distinct_id,
|
distinct_id,
|
||||||
fredy_version: version
|
fredy_version: version,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
35
lib/utils.js
35
lib/utils.js
@@ -9,12 +9,9 @@ function inDevMode(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isOneOf(word, arr) {
|
function isOneOf(word, arr) {
|
||||||
if (arr == null || arr.length === 0) {
|
if (!arr || arr.length === 0 || word == null) return false;
|
||||||
return false;
|
const lowerWord = word.toLowerCase();
|
||||||
}
|
return arr.some((item) => lowerWord.indexOf(item.toLowerCase()) !== -1);
|
||||||
const expression = String.raw`\b(${arr.join('|')})\b`;
|
|
||||||
const blacklist = new RegExp(expression, 'ig');
|
|
||||||
return blacklist.test(word);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function nullOrEmpty(val) {
|
function nullOrEmpty(val) {
|
||||||
@@ -48,13 +45,11 @@ function buildHash(...inputs) {
|
|||||||
if (inputs == null) {
|
if (inputs == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const cleaned = inputs.filter(i => i != null && i.length > 0);
|
const cleaned = inputs.filter((i) => i != null && i.length > 0);
|
||||||
if (cleaned.length === 0) {
|
if (cleaned.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return createHash('sha256')
|
return createHash('sha256').update(cleaned.join(',')).digest('hex');
|
||||||
.update(cleaned.join(','))
|
|
||||||
.digest('hex');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let config = {};
|
let config = {};
|
||||||
@@ -73,9 +68,29 @@ export async function refreshConfig(){
|
|||||||
console.error('Error reading config file', error);
|
console.error('Error reading config file', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RE_GT = />/g;
|
||||||
|
const RE_WEBP = /\/format\/webp/gi;
|
||||||
|
const RE_EXT = /\.(jpe?g|png|gif)(\?.*)?$/i;
|
||||||
|
const HTTPS_PREFIX = 'https://';
|
||||||
|
|
||||||
|
const normalizeImageUrl = (url) => {
|
||||||
|
if (typeof url !== 'string' || url.length === 0) return null;
|
||||||
|
|
||||||
|
let u = url.trim().replace(RE_GT, '');
|
||||||
|
if (RE_WEBP.test(u)) u = u.replace(RE_WEBP, '/format/jpg');
|
||||||
|
if (!u.startsWith(HTTPS_PREFIX)) return null;
|
||||||
|
if (!RE_EXT.test(u)) {
|
||||||
|
const jpgIdx = u.toLowerCase().lastIndexOf('.jpg');
|
||||||
|
if (jpgIdx > -1) u = u.slice(0, jpgIdx + 4);
|
||||||
|
}
|
||||||
|
return u;
|
||||||
|
};
|
||||||
|
|
||||||
await refreshConfig();
|
await refreshConfig();
|
||||||
|
|
||||||
export { isOneOf };
|
export { isOneOf };
|
||||||
|
export { normalizeImageUrl };
|
||||||
export { inDevMode };
|
export { inDevMode };
|
||||||
export { nullOrEmpty };
|
export { nullOrEmpty };
|
||||||
export { duringWorkingHoursOrNotSet };
|
export { duringWorkingHoursOrNotSet };
|
||||||
|
|||||||
65
package.json
65
package.json
@@ -1,21 +1,25 @@
|
|||||||
{
|
{
|
||||||
"name": "fredy",
|
"name": "fredy",
|
||||||
"version": "11.2.2",
|
"version": "11.4.2",
|
||||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node prod.js",
|
"prepare": "husky",
|
||||||
"dev": "yarn && rm -rf ./ui/public/* && vite",
|
"start:backend": "x-var NODE_ENV=production node index.js",
|
||||||
"ui": "rm -rf ./ui/public/* && vite",
|
"start:backend:dev": "nodemon --watch index.js --watch lib",
|
||||||
"prod": "yarn && vite build --emptyOutDir",
|
"start:frontend": "vite -m production",
|
||||||
"format": "prettier --write lib/**/*.js ui/src/**/*.jsx test/**/*.js *.js --single-quote --print-width 120",
|
"start:frontend:dev": "vite",
|
||||||
|
"build:frontend": "vite build",
|
||||||
|
"format": "prettier --write \"**/*.js\"",
|
||||||
|
"format:check": "prettier --check \"**/*.js\"",
|
||||||
"test": "mocha --loader=esmock --timeout 3000000 test/**/*.test.js",
|
"test": "mocha --loader=esmock --timeout 3000000 test/**/*.test.js",
|
||||||
"lint": "eslint ./index.js ./lib/**/*.js ./test/**/*.js ./ui/src/**/*.jsx"
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "yarn lint --fix"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.js": [
|
"*.{js,jsx}": [
|
||||||
"eslint ./index.js ./lib/**/*.js ./test/**/*.js",
|
"yarn lint",
|
||||||
"prettier --single-quote --print-width 120 --write"
|
"yarn format"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
@@ -50,17 +54,17 @@
|
|||||||
"Firefox ESR"
|
"Firefox ESR"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@douyinfe/semi-ui": "2.79.0",
|
"@douyinfe/semi-ui": "2.85.0",
|
||||||
"@rematch/core": "2.2.0",
|
"@rematch/core": "2.2.0",
|
||||||
"@rematch/loading": "2.1.2",
|
"@rematch/loading": "2.1.2",
|
||||||
"@sendgrid/mail": "8.1.5",
|
"@sendgrid/mail": "8.1.5",
|
||||||
"@vitejs/plugin-react": "4.4.1",
|
"@vitejs/plugin-react": "4.7.0",
|
||||||
"better-sqlite3": "^11.10.0",
|
"better-sqlite3": "^11.10.0",
|
||||||
"body-parser": "2.2.0",
|
"body-parser": "2.2.0",
|
||||||
"cheerio": "^1.0.0",
|
"cheerio": "^1.1.2",
|
||||||
"cookie-session": "2.1.0",
|
"cookie-session": "2.1.1",
|
||||||
"handlebars": "4.7.8",
|
"handlebars": "4.7.8",
|
||||||
"highcharts": "12.2.0",
|
"highcharts": "12.3.0",
|
||||||
"highcharts-react-official": "3.2.2",
|
"highcharts-react-official": "3.2.2",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"lowdb": "6.0.1",
|
"lowdb": "6.0.1",
|
||||||
@@ -68,12 +72,13 @@
|
|||||||
"mixpanel": "^0.18.1",
|
"mixpanel": "^0.18.1",
|
||||||
"nanoid": "5.1.5",
|
"nanoid": "5.1.5",
|
||||||
"node-fetch": "3.3.2",
|
"node-fetch": "3.3.2",
|
||||||
"node-mailjet": "6.0.8",
|
"node-mailjet": "6.0.9",
|
||||||
|
"p-throttle": "^7.0.0",
|
||||||
"package-up": "^5.0.0",
|
"package-up": "^5.0.0",
|
||||||
"puppeteer": "^24.8.2",
|
"puppeteer": "^24.17.0",
|
||||||
"puppeteer-extra": "^3.3.6",
|
"puppeteer-extra": "^3.3.6",
|
||||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||||
"query-string": "9.1.2",
|
"query-string": "9.2.2",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-redux": "9.2.0",
|
"react-redux": "9.2.0",
|
||||||
@@ -81,28 +86,30 @@
|
|||||||
"react-router-dom": "5.3.0",
|
"react-router-dom": "5.3.0",
|
||||||
"redux": "5.0.1",
|
"redux": "5.0.1",
|
||||||
"redux-thunk": "3.1.0",
|
"redux-thunk": "3.1.0",
|
||||||
"restana": "4.9.9",
|
"restana": "5.1.0",
|
||||||
"serve-static": "1.16.2",
|
"serve-static": "2.2.0",
|
||||||
"slack": "11.0.2",
|
"slack": "11.0.2",
|
||||||
"string-similarity": "^4.0.4",
|
"string-similarity": "^4.0.4",
|
||||||
"vite": "5.4.11"
|
"vite": "7.1.3",
|
||||||
|
"x-var": "^2.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.27.1",
|
"@babel/core": "7.28.3",
|
||||||
"@babel/eslint-parser": "7.27.1",
|
"@babel/eslint-parser": "7.28.0",
|
||||||
"@babel/preset-env": "7.27.2",
|
"@babel/preset-env": "7.28.3",
|
||||||
"@babel/preset-react": "7.27.1",
|
"@babel/preset-react": "7.27.1",
|
||||||
"chai": "5.2.0",
|
"chai": "5.2.1",
|
||||||
"eslint": "8.56.0",
|
"eslint": "8.56.0",
|
||||||
"eslint-config-prettier": "8.8.0",
|
"eslint-config-prettier": "8.8.0",
|
||||||
"eslint-plugin-react": "7.37.4",
|
"eslint-plugin-react": "7.37.5",
|
||||||
"esmock": "2.7.0",
|
"esmock": "2.7.1",
|
||||||
"history": "5.3.0",
|
"history": "5.3.0",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"less": "4.3.0",
|
"less": "4.4.1",
|
||||||
"lint-staged": "15.5.2",
|
"lint-staged": "15.5.2",
|
||||||
"mocha": "10.8.2",
|
"mocha": "10.8.2",
|
||||||
"prettier": "3.5.3",
|
"nodemon": "^3.1.10",
|
||||||
|
"prettier": "3.6.2",
|
||||||
"redux-logger": "3.0.6"
|
"redux-logger": "3.0.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,21 +23,23 @@ These protections make it extremely difficult to reliably extract data from Immo
|
|||||||
To work around these limitations, we are in the progress of reverse-engineering Immoscout24's mobile API. The mobile applications need to communicate with Immoscout's servers to retrieve listing data, and these API endpoints typically have fewer anti-bot protections than the web interface.
|
To work around these limitations, we are in the progress of reverse-engineering Immoscout24's mobile API. The mobile applications need to communicate with Immoscout's servers to retrieve listing data, and these API endpoints typically have fewer anti-bot protections than the web interface.
|
||||||
|
|
||||||
The mobile API provides several key endpoints:
|
The mobile API provides several key endpoints:
|
||||||
|
|
||||||
- Search total endpoint: Returns the total number of listings for a given query
|
- Search total endpoint: Returns the total number of listings for a given query
|
||||||
- Search list endpoint: Retrieves the actual listings with details
|
- Search list endpoint: Retrieves the actual listings with details
|
||||||
- Expose endpoint: Returns detailed information about a specific listing
|
- Expose endpoint: Returns detailed information about a specific listing
|
||||||
|
|
||||||
Challenges:
|
Challenges:
|
||||||
|
|
||||||
1. Identifying the necessary endpoints and parameters required to perform searches
|
1. Identifying the necessary endpoints and parameters required to perform searches
|
||||||
2. Mapping the mobile API parameters to their web counterparts to maintain compatibility with existing search URLs
|
2. Mapping the mobile API parameters to their web counterparts to maintain compatibility with existing search URLs
|
||||||
|
|
||||||
|
|
||||||
## Api Specs
|
## Api Specs
|
||||||
|
|
||||||
#### Search for Listings
|
#### Search for Listings
|
||||||
|
|
||||||
`GET /search/total?{search parameters}`
|
`GET /search/total?{search parameters}`
|
||||||
*Returns the total number of listings for the given query.*
|
_Returns the total number of listings for the given query._
|
||||||
|
|
||||||
```
|
```
|
||||||
curl -H "User-Agent: ImmoScout24_1410_30_._" \
|
curl -H "User-Agent: ImmoScout24_1410_30_._" \
|
||||||
-H "Accept: application/json" \
|
-H "Accept: application/json" \
|
||||||
@@ -47,14 +49,17 @@ curl -H "User-Agent: ImmoScout24_1410_30_._" \
|
|||||||
---
|
---
|
||||||
|
|
||||||
#### Retrieve the listings
|
#### Retrieve the listings
|
||||||
|
|
||||||
`POST /search/list?{search parameters}`
|
`POST /search/list?{search parameters}`
|
||||||
*The body is json encoded and contains data specifying additional results (advertisements) to return. The format is as follows (It is not necessary to provide data for the specified keys.)*
|
_The body is json encoded and contains data specifying additional results (advertisements) to return. The format is as follows (It is not necessary to provide data for the specified keys.)_
|
||||||
|
|
||||||
```
|
```
|
||||||
{
|
{
|
||||||
"supportedResultListTypes": [],
|
"supportedResultListTypes": [],
|
||||||
"userData": {}
|
"userData": {}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
```
|
```
|
||||||
curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region&geocodes=%2Fde%2Fberlin%2Fberlin&pagenumber=1' \
|
curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region&geocodes=%2Fde%2Fberlin%2Fberlin&pagenumber=1' \
|
||||||
-H "Connection: keep-alive" \
|
-H "Connection: keep-alive" \
|
||||||
@@ -66,15 +71,18 @@ curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calc
|
|||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### Get details of listings
|
#### Get details of listings
|
||||||
|
|
||||||
`GET /expose/{id}`
|
`GET /expose/{id}`
|
||||||
The response contains additional details not included in the listing response.
|
The response contains additional details not included in the listing response.
|
||||||
|
|
||||||
```
|
```
|
||||||
curl -H "User-Agent: ImmoScout24_1410_30_._" \
|
curl -H "User-Agent: ImmoScout24_1410_30_._" \
|
||||||
-H "Accept: application/json" \
|
-H "Accept: application/json" \
|
||||||
"https://api.mobile.immobilienscout24.de/expose/158382494"
|
"https://api.mobile.immobilienscout24.de/expose/158382494"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## Parameters
|
## Parameters
|
||||||
The parameters between web and mobile are very different which is why we have to translate them. Please see `immoscout-web-translator.js`.
|
|
||||||
|
The parameters between web and mobile are very different which is why we have to translate them. Please see [/lib/services/immoscout/immoscout-web-translator.js](https://github.com/orangecoding/fredy/blob/master/lib/services/immoscout/immoscout-web-translator.js).
|
||||||
|
|||||||
@@ -24,10 +24,6 @@
|
|||||||
"url": "https://immo.swp.de/suchergebnisse?l=M%C3%BCnchen&r=0km&_multiselect_r=0km&ut=private&t=apartment%3Arental&a=de.muenchen&pf=&pt=&rf=0&rt=0&sf=50&st=&yf=&yt=&ff=&ft=&s=most_recently_updated_first&pa=&o=&ad=&u=",
|
"url": "https://immo.swp.de/suchergebnisse?l=M%C3%BCnchen&r=0km&_multiselect_r=0km&ut=private&t=apartment%3Arental&a=de.muenchen&pf=&pt=&rf=0&rt=0&sf=50&st=&yf=&yt=&ff=&ft=&s=most_recently_updated_first&pa=&o=&ad=&u=",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"kalaydo": {
|
|
||||||
"url": "https://www.kalaydo.de/immobilien/eigentumswohnung-kaufen/o/duesseldorf/4/?attr_gt_estate_size_living_area=90.0&attr_gt_no_of_rooms=3.5&maxPrice=420000.00&radius=5&resultsPerPage=50&sorting=-date",
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"kleinanzeigen": {
|
"kleinanzeigen": {
|
||||||
"url": "https://www.kleinanzeigen.de/s-immobilien/duesseldorf/anzeige:angebote/wohnung/k0c195l2068r5",
|
"url": "https://www.kleinanzeigen.de/s-immobilien/duesseldorf/anzeige:angebote/wohnung/k0c195l2068r5",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { convertWebToMobile } from '../../../lib/services/immoscout/immoscout-web-translater.js';
|
import { convertWebToMobile } from '../../../lib/services/immoscout/immoscout-web-translator.js';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'chai';
|
||||||
import { readFile } from 'fs/promises';
|
import { readFile } from 'fs/promises';
|
||||||
|
|
||||||
@@ -34,6 +34,7 @@ describe('similarityCheck', () => {
|
|||||||
check.setCacheEntry(
|
check.setCacheEntry(
|
||||||
'where |X| and |Y| are the cardinalities of the two sets (i.e. the number of elements in each set). The Sørensen index equals twice the number of elements common to both sets divided by the sum of the number of elements in each set.',
|
'where |X| and |Y| are the cardinalities of the two sets (i.e. the number of elements in each set). The Sørensen index equals twice the number of elements common to both sets divided by the sum of the number of elements in each set.',
|
||||||
);
|
);
|
||||||
|
expect(check.hasSimilarEntries('unrelated text')).to.be.false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -66,15 +66,17 @@ export default function FredyApp() {
|
|||||||
|
|
||||||
{settings.demoMode && (
|
{settings.demoMode && (
|
||||||
<>
|
<>
|
||||||
<Banner fullMode={true}
|
<Banner
|
||||||
|
fullMode={true}
|
||||||
type="info"
|
type="info"
|
||||||
bordered
|
bordered
|
||||||
closeIcon={null}
|
closeIcon={null}
|
||||||
description="You're currently viewing the demo version of Fredy. Jobs won't scrape websites, and any changes you make will be reverted at midnight."
|
description="You're currently viewing the demo version of Fredy. Jobs won't scrape websites, and any changes you make will be reverted at midnight."
|
||||||
/>
|
/>
|
||||||
<br />
|
<br />
|
||||||
</>)}
|
</>
|
||||||
{(settings.analyticsEnabled === null && !settings.demoMode) && <TrackingModal/>}
|
)}
|
||||||
|
{settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />}
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route name="Insufficient Permission" path={'/403'} component={InsufficientPermission} />
|
<Route name="Insufficient Permission" path={'/403'} component={InsufficientPermission} />
|
||||||
<Route name="Create new Job" path={'/jobs/new'} component={JobMutation} />
|
<Route name="Create new Job" path={'/jobs/new'} component={JobMutation} />
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
background: #31303078 !important;
|
background: #31303078 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui.black.label, .ui.black.labels .label {
|
.ui.black.label,
|
||||||
|
.ui.black.labels .label {
|
||||||
background-color: #31303078 !important;
|
background-color: #31303078 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,3 +42,7 @@ a:active {
|
|||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.semi-icon:not(.semi-tabs-bar .semi-tabs-tab .semi-icon) {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,5 +23,5 @@ root.render(
|
|||||||
<App />
|
<App />
|
||||||
</LocaleProvider>
|
</LocaleProvider>
|
||||||
</HashRouter>
|
</HashRouter>
|
||||||
</Provider>
|
</Provider>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
body, html {
|
body,
|
||||||
|
html {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
.logo {
|
.logo {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: .1rem;
|
top: 0.1rem;
|
||||||
right: 2rem;
|
right: 2rem;
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import { Tabs, TabPane } from '@douyinfe/semi-ui';
|
|||||||
|
|
||||||
import { useLocation } from 'react-router';
|
import { useLocation } from 'react-router';
|
||||||
import { IconUser, IconTerminal, IconSetting } from '@douyinfe/semi-icons';
|
import { IconUser, IconTerminal, IconSetting } from '@douyinfe/semi-icons';
|
||||||
|
import './Menu.less';
|
||||||
|
|
||||||
function parsePathName(name) {
|
function parsePathName(name) {
|
||||||
const split = name.split('/').filter((s) => s.length !== 0);
|
const split = name.split('/').filter((s) => s.length !== 0);
|
||||||
@@ -14,7 +15,12 @@ const TopMenu = function TopMenu({ isAdmin }) {
|
|||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
return (
|
return (
|
||||||
<Tabs type="line" activeKey={parsePathName(location.pathname)} onTabClick={(key) => history.push(key)}>
|
<Tabs
|
||||||
|
className="menu"
|
||||||
|
type="line"
|
||||||
|
activeKey={parsePathName(location.pathname)}
|
||||||
|
onTabClick={(key) => history.push(key)}
|
||||||
|
>
|
||||||
<TabPane
|
<TabPane
|
||||||
itemKey="/jobs"
|
itemKey="/jobs"
|
||||||
tab={
|
tab={
|
||||||
|
|||||||
3
ui/src/components/menu/Menu.less
Normal file
3
ui/src/components/menu/Menu.less
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.menu {
|
||||||
|
margin-top: 3rem;
|
||||||
|
}
|
||||||
@@ -20,17 +20,16 @@
|
|||||||
border-radius: 360px;
|
border-radius: 360px;
|
||||||
animation: pulse 1s infinite ease-in-out;
|
animation: pulse 1s infinite ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0% {
|
0% {
|
||||||
background-color: rgba(165, 165, 165, 0.1)
|
background-color: rgba(165, 165, 165, 0.1);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
background-color: rgba(165, 165, 165, 0.3)
|
background-color: rgba(165, 165, 165, 0.3);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
background-color: rgba(165, 165, 165, 0.1)
|
background-color: rgba(165, 165, 165, 0.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ import React from 'react';
|
|||||||
import { Button, Empty, Table, Switch } from '@douyinfe/semi-ui';
|
import { Button, Empty, Table, Switch } from '@douyinfe/semi-ui';
|
||||||
import { IconDelete, IconEdit, IconHistogram } from '@douyinfe/semi-icons';
|
import { IconDelete, IconEdit, IconHistogram } from '@douyinfe/semi-icons';
|
||||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||||
|
|
||||||
|
import './JobTable.less';
|
||||||
|
|
||||||
const empty = (
|
const empty = (
|
||||||
<Empty
|
<Empty
|
||||||
image={<IllustrationNoResult />}
|
image={<IllustrationNoResult />}
|
||||||
darkModeImage={<IllustrationNoResultDark />}
|
darkModeImage={<IllustrationNoResultDark />}
|
||||||
description={'No jobs available'}
|
description={'No jobs available.'}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -25,25 +28,25 @@ export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged,
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Job Name',
|
title: 'Name',
|
||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Number of findings',
|
title: 'Findings',
|
||||||
dataIndex: 'numberOfFoundListings',
|
dataIndex: 'numberOfFoundListings',
|
||||||
render: (value) => {
|
render: (value) => {
|
||||||
return value || 0;
|
return value || 0;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Active provider',
|
title: 'Providers',
|
||||||
dataIndex: 'provider',
|
dataIndex: 'provider',
|
||||||
render: (value) => {
|
render: (value) => {
|
||||||
return value.length || 0;
|
return value.length || 0;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Active notification adapter',
|
title: 'Notification adapters',
|
||||||
dataIndex: 'notificationAdapter',
|
dataIndex: 'notificationAdapter',
|
||||||
render: (value) => {
|
render: (value) => {
|
||||||
return value.length || 0;
|
return value.length || 0;
|
||||||
@@ -54,19 +57,9 @@ export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged,
|
|||||||
dataIndex: 'tools',
|
dataIndex: 'tools',
|
||||||
render: (_, job) => {
|
render: (_, job) => {
|
||||||
return (
|
return (
|
||||||
<div style={{ float: 'right' }}>
|
<div className="interactions">
|
||||||
<Button
|
<Button type="primary" icon={<IconHistogram />} onClick={() => onJobInsight(job.id)} />
|
||||||
type="primary"
|
<Button type="secondary" icon={<IconEdit />} onClick={() => onJobEdit(job.id)} />
|
||||||
icon={<IconHistogram />}
|
|
||||||
onClick={() => onJobInsight(job.id)}
|
|
||||||
style={{ marginRight: '1rem' }}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="secondary"
|
|
||||||
icon={<IconEdit />}
|
|
||||||
onClick={() => onJobEdit(job.id)}
|
|
||||||
style={{ marginRight: '1rem' }}
|
|
||||||
/>
|
|
||||||
<Button type="danger" icon={<IconDelete />} onClick={() => onJobRemoval(job.id)} />
|
<Button type="danger" icon={<IconDelete />} onClick={() => onJobRemoval(job.id)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
12
ui/src/components/table/JobTable.less
Normal file
12
ui/src/components/table/JobTable.less
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
.interactions {
|
||||||
|
float: right;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.interactions {
|
||||||
|
flex-direction: initial;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,10 +7,10 @@ export default function NotificationAdapterTable({ notificationAdapter = [], onR
|
|||||||
return (
|
return (
|
||||||
<Table
|
<Table
|
||||||
pagination={false}
|
pagination={false}
|
||||||
empty={<Empty description="No Data" />}
|
empty={<Empty description="No notification adapters found." />}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
title: 'Notification Adapter Name',
|
title: 'Name',
|
||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ export default function ProviderTable({ providerData = [], onRemove } = {}) {
|
|||||||
return (
|
return (
|
||||||
<Table
|
<Table
|
||||||
pagination={false}
|
pagination={false}
|
||||||
empty={<Empty description="No Provider available" />}
|
empty={<Empty description="No providers found." />}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
title: 'Provider Name',
|
title: 'Name',
|
||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Provider Url',
|
title: 'URL',
|
||||||
dataIndex: 'url',
|
dataIndex: 'url',
|
||||||
render: (_, data) => {
|
render: (_, data) => {
|
||||||
return (
|
return (
|
||||||
@@ -30,7 +30,7 @@ export default function ProviderTable({ providerData = [], onRemove } = {}) {
|
|||||||
render: (_, record) => {
|
render: (_, record) => {
|
||||||
return (
|
return (
|
||||||
<div style={{ float: 'right' }}>
|
<div style={{ float: 'right' }}>
|
||||||
<Button type="danger" icon={<IconDelete />} onClick={() => onRemove(record.id)} />
|
<Button type="danger" icon={<IconDelete />} onClick={() => onRemove(record.url)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const empty = (
|
|||||||
<Empty
|
<Empty
|
||||||
image={<IllustrationNoResult />}
|
image={<IllustrationNoResult />}
|
||||||
darkModeImage={<IllustrationNoResultDark />}
|
darkModeImage={<IllustrationNoResultDark />}
|
||||||
description={'No user available'}
|
description={'No users found.'}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,21 @@ import Logo from '../logo/Logo.jsx';
|
|||||||
import { xhrPost } from '../../services/xhr.js';
|
import { xhrPost } from '../../services/xhr.js';
|
||||||
|
|
||||||
import './TrackingModal.less';
|
import './TrackingModal.less';
|
||||||
|
import inDevelopment from '../../services/developmentMode.js';
|
||||||
|
|
||||||
const saveResponse = async (analyticsEnabled) => {
|
const saveResponse = async (analyticsEnabled) => {
|
||||||
await xhrPost('/api/admin/generalSettings', {
|
await xhrPost('/api/admin/generalSettings', {
|
||||||
analyticsEnabled
|
analyticsEnabled,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TrackingModal() {
|
export default function TrackingModal() {
|
||||||
|
if (inDevelopment()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return <Modal
|
return (
|
||||||
|
<Modal
|
||||||
visible={true}
|
visible={true}
|
||||||
onOk={async () => {
|
onOk={async () => {
|
||||||
await saveResponse(true);
|
await saveResponse(true);
|
||||||
@@ -32,17 +37,20 @@ export default function TrackingModal() {
|
|||||||
<div className="trackingModal__description">
|
<div className="trackingModal__description">
|
||||||
<p>Hey 👋</p>
|
<p>Hey 👋</p>
|
||||||
<p>Fed up with popups? Yeah, me too. But this one’s important, and I promise it will only appear once ;)</p>
|
<p>Fed up with popups? Yeah, me too. But this one’s important, and I promise it will only appear once ;)</p>
|
||||||
<p>Fredy is completely free (and will always remain free). If you’d like, you can support me by donating
|
<p>
|
||||||
through my GitHub, but there’s absolutely no obligation to do so.</p>
|
Fredy is completely free (and will always remain free). If you’d like, you can support me by donating through
|
||||||
<p>However, it would be a huge
|
my GitHub, but there’s absolutely no obligation to do so.
|
||||||
help if you’d allow me to collect some analytical data. Wait, before you click "no", let me explain. If
|
</p>
|
||||||
you
|
<p>
|
||||||
agree, Fredy will send a ping to my Mixpanel project each time it runs.</p>
|
However, it would be a huge help if you’d allow me to collect some analytical data. Wait, before you click
|
||||||
<p>The data includes: names of
|
"no", let me explain. If you agree, Fredy will send a ping to my Mixpanel project each time it runs.
|
||||||
active adapters/providers, OS, architecture, Node version, and language. The information is entirely
|
</p>
|
||||||
anonymous and helps me understand which adapters/providers are most frequently used.</p>
|
<p>
|
||||||
|
The data includes: names of active adapters/providers, OS, architecture, Node version, and language. The
|
||||||
|
information is entirely anonymous and helps me understand which adapters/providers are most frequently used.
|
||||||
|
</p>
|
||||||
<p>Thanks🤘</p>
|
<p>Thanks🤘</p>
|
||||||
</div>
|
</div>
|
||||||
</Modal>;
|
</Modal>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
4
ui/src/services/developmentMode.js
Normal file
4
ui/src/services/developmentMode.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export default function isDevelopmentMode() {
|
||||||
|
const inDevMode = import.meta.env.MODE;
|
||||||
|
return inDevMode != null && inDevMode === 'development';
|
||||||
|
}
|
||||||
@@ -129,6 +129,6 @@ function parseJSON(response) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => reject('Error while trying to parse json.', error))
|
.catch((error) => reject('Error while trying to parse json.', error)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ import Headline from '../../components/headline/Headline';
|
|||||||
import { xhrPost } from '../../services/xhr';
|
import { xhrPost } from '../../services/xhr';
|
||||||
import { SegmentPart } from '../../components/segment/SegmentPart';
|
import { SegmentPart } from '../../components/segment/SegmentPart';
|
||||||
import { Banner, Toast } from '@douyinfe/semi-ui';
|
import { Banner, Toast } from '@douyinfe/semi-ui';
|
||||||
import {IconSave, IconCalendar, IconRefresh, IconSignal, IconLineChartStroked, IconSearch} from '@douyinfe/semi-icons';
|
import {
|
||||||
|
IconSave,
|
||||||
|
IconCalendar,
|
||||||
|
IconRefresh,
|
||||||
|
IconSignal,
|
||||||
|
IconLineChartStroked,
|
||||||
|
IconSearch,
|
||||||
|
} from '@douyinfe/semi-icons';
|
||||||
import './GeneralSettings.less';
|
import './GeneralSettings.less';
|
||||||
|
|
||||||
function formatFromTimestamp(ts) {
|
function formatFromTimestamp(ts) {
|
||||||
@@ -64,28 +71,20 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
|
|
||||||
const nullOrEmpty = (val) => val == null || val.length === 0;
|
const nullOrEmpty = (val) => val == null || val.length === 0;
|
||||||
|
|
||||||
const throwMessage = (message, type) => {
|
|
||||||
if (type === 'error') {
|
|
||||||
Toast.error(message);
|
|
||||||
} else {
|
|
||||||
Toast.success(message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onStore = async () => {
|
const onStore = async () => {
|
||||||
if (nullOrEmpty(interval)) {
|
if (nullOrEmpty(interval)) {
|
||||||
throwMessage('Interval may not be empty.', 'error');
|
Toast.error('Interval may not be empty.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (nullOrEmpty(port)) {
|
if (nullOrEmpty(port)) {
|
||||||
throwMessage('Port may not be empty.', 'error');
|
Toast.error('Port may not be empty.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
(!nullOrEmpty(workingHourFrom) && nullOrEmpty(workingHourTo)) ||
|
(!nullOrEmpty(workingHourFrom) && nullOrEmpty(workingHourTo)) ||
|
||||||
(nullOrEmpty(workingHourFrom) && !nullOrEmpty(workingHourTo))
|
(nullOrEmpty(workingHourFrom) && !nullOrEmpty(workingHourTo))
|
||||||
) {
|
) {
|
||||||
throwMessage('Working hours to and from must be set if either to or from has been set before.', 'error');
|
Toast.error('Working hours to and from must be set if either to or from has been set before.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -97,18 +96,18 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
to: workingHourTo,
|
to: workingHourTo,
|
||||||
},
|
},
|
||||||
demoMode,
|
demoMode,
|
||||||
analyticsEnabled
|
analyticsEnabled,
|
||||||
});
|
});
|
||||||
} catch (exception) {
|
} catch (exception) {
|
||||||
console.error(exception);
|
console.error(exception);
|
||||||
if (exception?.json?.message != null) {
|
if (exception?.json?.message != null) {
|
||||||
throwMessage(exception.json.message, 'error');
|
Toast.error(exception.json.message);
|
||||||
} else {
|
} else {
|
||||||
throwMessage('Error while trying to store settings.', 'error');
|
Toast.error('Error while trying to store settings.');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throwMessage('Settings stored successfully. We will reload your browser in 3 seconds.', 'success');
|
Toast.success('Settings stored successfully. We will reload your browser in 3 seconds.');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
location.reload();
|
location.reload();
|
||||||
}, 3000);
|
}, 3000);
|
||||||
@@ -175,24 +174,18 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
<Divider margin="1rem" />
|
<Divider margin="1rem" />
|
||||||
|
|
||||||
<SegmentPart
|
<SegmentPart name="Analytics" helpText="Insights into the usage of Fredy." Icon={IconLineChartStroked}>
|
||||||
name="Analytics"
|
|
||||||
helpText="Insights into the usage of Fredy."
|
|
||||||
Icon={IconLineChartStroked}
|
|
||||||
>
|
|
||||||
<Banner
|
<Banner
|
||||||
fullMode={false}
|
fullMode={false}
|
||||||
type="info"
|
type="info"
|
||||||
closeIcon={null}
|
closeIcon={null}
|
||||||
title={
|
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Explanation</div>}
|
||||||
<div style={{fontWeight: 600, fontSize: '14px', lineHeight: '20px'}}>
|
|
||||||
Explanation
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
style={{ marginBottom: '1rem' }}
|
style={{ marginBottom: '1rem' }}
|
||||||
description={
|
description={
|
||||||
<div>
|
<div>
|
||||||
Analytics are disabled by default. If you choose to enable them, we will begin tracking the following:<br/>
|
Analytics are disabled by default. If you choose to enable them, we will begin tracking the
|
||||||
|
following:
|
||||||
|
<br />
|
||||||
<ul>
|
<ul>
|
||||||
<li>Name of active provider (e.g. Immoscout)</li>
|
<li>Name of active provider (e.g. Immoscout)</li>
|
||||||
<li>Name of active adapter (e.g. Console)</li>
|
<li>Name of active adapter (e.g. Console)</li>
|
||||||
@@ -201,35 +194,26 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
<li>node version</li>
|
<li>node version</li>
|
||||||
<li>arch</li>
|
<li>arch</li>
|
||||||
</ul>
|
</ul>
|
||||||
The data is sent anonymously and helps me understand which providers or adapters are being used the most. In the end it helps me to improve fredy.
|
The data is sent anonymously and helps me understand which providers or adapters are being used the
|
||||||
|
most. In the end it helps me to improve fredy.
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Checkbox
|
<Checkbox checked={analyticsEnabled} onChange={(e) => setAnalyticsEnabled(e.target.checked)}>
|
||||||
checked={analyticsEnabled}
|
{' '}
|
||||||
onChange={(e) => setAnalyticsEnabled(e.target.checked)}
|
Enabled
|
||||||
> Enabled
|
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
|
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
|
|
||||||
<Divider margin="1rem" />
|
<Divider margin="1rem" />
|
||||||
|
|
||||||
<SegmentPart
|
<SegmentPart name="Demo Mode" helpText="If enabled, Fredy runs in demo mode." Icon={IconSearch}>
|
||||||
name="Demo Mode"
|
|
||||||
helpText="If enabled, Fredy runs in demo mode."
|
|
||||||
Icon={IconSearch}
|
|
||||||
>
|
|
||||||
<Banner
|
<Banner
|
||||||
fullMode={false}
|
fullMode={false}
|
||||||
type="info"
|
type="info"
|
||||||
closeIcon={null}
|
closeIcon={null}
|
||||||
title={
|
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Explanation</div>}
|
||||||
<div style={{fontWeight: 600, fontSize: '14px', lineHeight: '20px'}}>
|
|
||||||
Explanation
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
style={{ marginBottom: '1rem' }}
|
style={{ marginBottom: '1rem' }}
|
||||||
description={
|
description={
|
||||||
<div>
|
<div>
|
||||||
@@ -239,12 +223,10 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Checkbox
|
<Checkbox checked={demoMode} onChange={(e) => setDemoMode(e.target.checked)}>
|
||||||
checked={demoMode}
|
{' '}
|
||||||
onChange={(e) => setDemoMode(e.target.checked)}
|
Enabled
|
||||||
> Enabled
|
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
|
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
|
|
||||||
<Divider margin="1rem" />
|
<Divider margin="1rem" />
|
||||||
|
|||||||
@@ -9,5 +9,4 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
margin-left: 1rem;
|
margin-left: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { format } from '../../services/time/timeService';
|
import { format } from '../../services/time/timeService';
|
||||||
import {Banner, Descriptions} from '@douyinfe/semi-ui';
|
import { Descriptions } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
export default function ProcessingTimes({ processingTimes = {} }) {
|
export default function ProcessingTimes({ processingTimes = {} }) {
|
||||||
if (Object.keys(processingTimes).length === 0) {
|
if (Object.keys(processingTimes).length === 0) {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default function JobMutator() {
|
|||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
const isSavingEnabled = () => {
|
const isSavingEnabled = () => {
|
||||||
return notificationAdapterData.length > 0 && providerData.length > 0 && name != null && name.length > 0;
|
return Boolean(notificationAdapterData.length && providerData.length && name);
|
||||||
};
|
};
|
||||||
|
|
||||||
const mutateJob = async () => {
|
const mutateJob = async () => {
|
||||||
@@ -94,7 +94,7 @@ export default function JobMutator() {
|
|||||||
<form>
|
<form>
|
||||||
<SegmentPart name="Name">
|
<SegmentPart name="Name">
|
||||||
<Input
|
<Input
|
||||||
autofocus
|
autoFocus
|
||||||
type="text"
|
type="text"
|
||||||
maxLength={40}
|
maxLength={40}
|
||||||
placeholder="Name"
|
placeholder="Name"
|
||||||
@@ -105,13 +105,13 @@ export default function JobMutator() {
|
|||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
<Divider margin="1rem" />
|
<Divider margin="1rem" />
|
||||||
<SegmentPart
|
<SegmentPart
|
||||||
name="Provider"
|
name="Providers"
|
||||||
icon="briefcase"
|
icon="briefcase"
|
||||||
helpText={
|
helpText={`
|
||||||
'A provider is essentially the service (Immowelt etc.) that Fredy is using to search for new listings. When adding a new provider, Fredy will open a new tab pointing ' +
|
A provider is essentially the service (e.g. ImmoScout24, Kleinanzeigen) that Fredy searches for new listings.
|
||||||
'to the website of this provider. You have to adjust your search parameter and click on "Search". If the results are being shown, copy the browser url. This is the url, Fredy will use ' +
|
Fredy will open a new tab pointing to the website of this provider. You have to adjust your search parameter
|
||||||
'to search for new listings.'
|
and click on "Search". If the results are being shown, copy the browser URL in here.
|
||||||
}
|
`}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -124,15 +124,15 @@ export default function JobMutator() {
|
|||||||
|
|
||||||
<ProviderTable
|
<ProviderTable
|
||||||
providerData={providerData}
|
providerData={providerData}
|
||||||
onRemove={(providerId) => {
|
onRemove={(providerUrl) => {
|
||||||
setProviderData(providerData.filter((provider) => provider.id !== providerId));
|
setProviderData(providerData.filter((provider) => provider.url !== providerUrl));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
<Divider margin="1rem" />
|
<Divider margin="1rem" />
|
||||||
<SegmentPart
|
<SegmentPart
|
||||||
icon="bell"
|
icon="bell"
|
||||||
name="Notification Adapter"
|
name="Notification Adapters"
|
||||||
helpText="Fredy supports multiple ways to notify you about new findings. These are called notification adapter. You can chose between email, Telegram etc."
|
helpText="Fredy supports multiple ways to notify you about new findings. These are called notification adapter. You can chose between email, Telegram etc."
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
@@ -172,7 +172,7 @@ export default function JobMutator() {
|
|||||||
<SegmentPart
|
<SegmentPart
|
||||||
icon="play circle outline"
|
icon="play circle outline"
|
||||||
name="Job activation"
|
name="Job activation"
|
||||||
helpText="Whether or not the job is activated. If it is not activated, it will be ignored when Fredy checks for new listings."
|
helpText="Whether or not the job is activated. Inactive jobs will be ignored when Fredy checks for new listings."
|
||||||
>
|
>
|
||||||
<Switch className="jobMutation__spaceTop" onChange={(checked) => setEnabled(checked)} checked={enabled} />
|
<Switch className="jobMutation__spaceTop" onChange={(checked) => setEnabled(checked)} checked={enabled} />
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export default function NotificationAdapterMutator({
|
|||||||
id: selectedAdapter.id,
|
id: selectedAdapter.id,
|
||||||
name: selectedAdapter.name,
|
name: selectedAdapter.name,
|
||||||
fields: selectedAdapter.fields || {},
|
fields: selectedAdapter.fields || {},
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
setSelectedAdapter(null);
|
setSelectedAdapter(null);
|
||||||
@@ -114,7 +114,7 @@ export default function NotificationAdapterMutator({
|
|||||||
setSuccessMessage('It seems like it worked! Please check your service.');
|
setSuccessMessage('It seems like it worked! Please check your service.');
|
||||||
})
|
})
|
||||||
.catch((error) =>
|
.catch((error) =>
|
||||||
setValidationMessage(`This did not work :-( I've received the following error: ${error.json.message}`)
|
setValidationMessage(`This did not work :-( I've received the following error: ${error.json.message}`),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -229,7 +229,7 @@ export default function NotificationAdapterMutator({
|
|||||||
.filter((option) =>
|
.filter((option) =>
|
||||||
editNotificationAdapter != null
|
editNotificationAdapter != null
|
||||||
? true
|
? true
|
||||||
: selected.find((selectedOption) => selectedOption.id === option.key) == null
|
: selected.find((selectedOption) => selectedOption.id === option.key) == null,
|
||||||
)
|
)
|
||||||
.sort(sortAdapter)}
|
.sort(sortAdapter)}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export default function ProviderMutator({ onVisibilityChanged, visible = false,
|
|||||||
url: providerUrl,
|
url: providerUrl,
|
||||||
id: selectedProvider.id,
|
id: selectedProvider.id,
|
||||||
name: selectedProvider.name,
|
name: selectedProvider.name,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
setProviderUrl(null);
|
setProviderUrl(null);
|
||||||
setSelectedProvider(null);
|
setSelectedProvider(null);
|
||||||
@@ -101,7 +101,7 @@ export default function ProviderMutator({ onVisibilityChanged, visible = false,
|
|||||||
description={
|
description={
|
||||||
<div>
|
<div>
|
||||||
<p>
|
<p>
|
||||||
Currently, our Immoscout implementation does not drawing shapes on a map. Use a radius instead.
|
Currently, our Immoscout implementation does not support drawing shapes on a map. Use a radius instead.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Logo from '../../components/logo/Logo';
|
|||||||
import { xhrPost } from '../../services/xhr';
|
import { xhrPost } from '../../services/xhr';
|
||||||
import { useHistory } from 'react-router';
|
import { useHistory } from 'react-router';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import {Input, Button, Banner} from '@douyinfe/semi-ui';
|
import { Input, Button, Banner, Toast } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
import './login.less';
|
import './login.less';
|
||||||
import { IconUser, IconLock } from '@douyinfe/semi-icons';
|
import { IconUser, IconLock } from '@douyinfe/semi-icons';
|
||||||
@@ -27,20 +27,24 @@ export default function Login() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const tryLogin = async () => {
|
const tryLogin = async () => {
|
||||||
if (username.length === 0 || password.length === 0) {
|
if (!username?.trim() || !password) {
|
||||||
setError('Username and password are mandatory.');
|
setError('Username and password are mandatory.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await xhrPost('/api/login', {
|
await xhrPost('/api/login', {
|
||||||
username,
|
username: username.trim(),
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
setError(null);
|
|
||||||
} catch (Exception) {
|
} catch (Exception) {
|
||||||
setError('Login not successful...');
|
Toast.error('Login unsuccessful…');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Toast.success('Login successful!');
|
||||||
|
|
||||||
await dispatch.user.getCurrentUser();
|
await dispatch.user.getCurrentUser();
|
||||||
history.push('/jobs');
|
history.push('/jobs');
|
||||||
};
|
};
|
||||||
@@ -58,7 +62,6 @@ export default function Login() {
|
|||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
value={username}
|
value={username}
|
||||||
showClear
|
showClear
|
||||||
style={{marginTop: error ? '1rem' : '4rem'}}
|
|
||||||
autoFocus
|
autoFocus
|
||||||
onChange={(value) => setUserName(value)}
|
onChange={(value) => setUserName(value)}
|
||||||
onKeyPress={async (e) => {
|
onKeyPress={async (e) => {
|
||||||
@@ -74,7 +77,6 @@ export default function Login() {
|
|||||||
prefix={<IconLock />}
|
prefix={<IconLock />}
|
||||||
value={password}
|
value={password}
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
style={{marginTop: '2rem'}}
|
|
||||||
onChange={(value) => setPassword(value)}
|
onChange={(value) => setPassword(value)}
|
||||||
onKeyPress={async (e) => {
|
onKeyPress={async (e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
@@ -83,16 +85,19 @@ export default function Login() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button type="primary" onClick={tryLogin} theme="solid" style={{marginTop: '3rem'}}>
|
<Button type="primary" onClick={tryLogin} theme="solid" style={{ marginTop: '1rem' }}>
|
||||||
Login
|
Login
|
||||||
</Button>
|
</Button>
|
||||||
<br/>
|
|
||||||
{demoMode && <Banner fullMode={true}
|
{demoMode && (
|
||||||
|
<Banner
|
||||||
|
fullMode={true}
|
||||||
type="info"
|
type="info"
|
||||||
bordered
|
bordered
|
||||||
closeIcon={null}
|
closeIcon={null}
|
||||||
description="This is the demo version of Fredy. Use 'demo' as both the username and password to log in."
|
description="This is the demo version of Fredy. Use 'demo' as both the username and password to log in."
|
||||||
/>}
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,13 +20,13 @@
|
|||||||
&__loginWrapper {
|
&__loginWrapper {
|
||||||
border: 1px solid #555050;
|
border: 1px solid #555050;
|
||||||
border-radius: 30px;
|
border-radius: 30px;
|
||||||
height: 23rem;
|
|
||||||
width: 30rem;
|
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
background-color: #151313ab;
|
background-color: #151313ab;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
form {
|
form {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
chunkSizeWarningLimit: 9999999,
|
chunkSizeWarningLimit: 9999999,
|
||||||
outDir: './ui/public',
|
outDir: './ui/public',
|
||||||
|
emptyOutDir: true,
|
||||||
},
|
},
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
Reference in New Issue
Block a user