mirror of
https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git
synced 2026-03-12 02:31:45 +01:00
Compare commits
220 Commits
2024-03-04
...
2025-05-15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7a3d46d25 | ||
|
|
e811cd339b | ||
|
|
a863f3c63a | ||
|
|
0faa022e4d | ||
|
|
8e2385c078 | ||
|
|
a03b368ccd | ||
|
|
9a3c0190ba | ||
|
|
1f9895850f | ||
|
|
21d7cc557d | ||
|
|
58f6ae960f | ||
|
|
50c0323921 | ||
|
|
ee4146f57c | ||
|
|
65738926ae | ||
|
|
f2e6f0b20b | ||
|
|
ed83052fa4 | ||
|
|
314285583e | ||
|
|
aa00d734ea | ||
|
|
8584311305 | ||
|
|
03dd3ebb10 | ||
|
|
138d365713 | ||
|
|
6c2c6a0064 | ||
|
|
8b2d61b1d4 | ||
|
|
7852985de9 | ||
|
|
9bcc669c48 | ||
|
|
3e8072973a | ||
|
|
bda0acf943 | ||
|
|
f98251ade3 | ||
|
|
ef923a8337 | ||
|
|
fe33a0e461 | ||
|
|
376ec76226 | ||
|
|
f0b84ab335 | ||
|
|
634cc3d9ee | ||
|
|
52e1682dba | ||
|
|
7b0774874e | ||
|
|
23929a62cc | ||
|
|
3909218531 | ||
|
|
d87ae6e740 | ||
|
|
4891c142a9 | ||
|
|
e417750548 | ||
|
|
79af6ba861 | ||
|
|
c144801d2e | ||
|
|
a03328e308 | ||
|
|
20f3f87864 | ||
|
|
27c7bb56ca | ||
|
|
79701e2833 | ||
|
|
21835d9d86 | ||
|
|
aeaf77e5d4 | ||
|
|
b66c9d37bf | ||
|
|
b07633e661 | ||
|
|
fd58f3fa45 | ||
|
|
13965b8607 | ||
|
|
4a9c2ff5a8 | ||
|
|
33f58811cd | ||
|
|
57c89a6f64 | ||
|
|
9183909188 | ||
|
|
7742196043 | ||
|
|
6bd5ba98d2 | ||
|
|
a6d2d2dc5a | ||
|
|
1b004a2a3e | ||
|
|
21f118ba8e | ||
|
|
cfe2b900c7 | ||
|
|
4243ba698a | ||
|
|
772326003f | ||
|
|
6b3da5bc0a | ||
|
|
7b9412677e | ||
|
|
b99be81158 | ||
|
|
c7f7b832b2 | ||
|
|
a8f6817c5c | ||
|
|
610615367c | ||
|
|
34b2bc6550 | ||
|
|
543d46631c | ||
|
|
e43ac4f1f9 | ||
|
|
c61c14709f | ||
|
|
8270554507 | ||
|
|
9f19cd85bd | ||
|
|
4051620aed | ||
|
|
a67112d936 | ||
|
|
820ae8966e | ||
|
|
f3beb795b4 | ||
|
|
5ade82b54d | ||
|
|
367ef07798 | ||
|
|
ec7ffedcd6 | ||
|
|
2402ba2572 | ||
|
|
e8d342dc68 | ||
|
|
7169975d2a | ||
|
|
b4658407a3 | ||
|
|
affde0debf | ||
|
|
dd5f2ba5e4 | ||
|
|
042525eb91 | ||
|
|
b12118361d | ||
|
|
20fb47a6e2 | ||
|
|
f4f00b9563 | ||
|
|
08197eabae | ||
|
|
9cd4fdd693 | ||
|
|
67fd0e2724 | ||
|
|
76b0901166 | ||
|
|
100f2fd8c5 | ||
|
|
be8eee6aa0 | ||
|
|
f51dab0c3f | ||
|
|
fa0d43efa8 | ||
|
|
f01109c956 | ||
|
|
3d27755207 | ||
|
|
ed7fd21272 | ||
|
|
236740fc2b | ||
|
|
d2eb3adc77 | ||
|
|
66634ce636 | ||
|
|
7d9b857a46 | ||
|
|
2f93e0dfda | ||
|
|
46e585b96d | ||
|
|
d4d5514cc0 | ||
|
|
49ac8baf5c | ||
|
|
70aef618a0 | ||
|
|
677c48628d | ||
|
|
ca876e628b | ||
|
|
640b748b1d | ||
|
|
6820a946c9 | ||
|
|
33a43e3ff6 | ||
|
|
f9eb6185c7 | ||
|
|
e590a32aa2 | ||
|
|
7668026eda | ||
|
|
5829df66e4 | ||
|
|
f3a7cf0150 | ||
|
|
cd955a5506 | ||
|
|
be78ec9736 | ||
|
|
2705dc7e43 | ||
|
|
679d08502c | ||
|
|
aec051826a | ||
|
|
05f6ceb5b9 | ||
|
|
e077f8d86d | ||
|
|
f90f848cba | ||
|
|
47614887e7 | ||
|
|
9841f6f48f | ||
|
|
1e782beabc | ||
|
|
9d54a949e7 | ||
|
|
0aa1975325 | ||
|
|
7b579900c3 | ||
|
|
cde3250ab8 | ||
|
|
a738f0748d | ||
|
|
8acaf7b25f | ||
|
|
09f4d0f16f | ||
|
|
f1ae6ff8de | ||
|
|
97ed41d96e | ||
|
|
ab953111d4 | ||
|
|
9a826452f9 | ||
|
|
e89e311043 | ||
|
|
26f05b5506 | ||
|
|
a83ee4883e | ||
|
|
e8dcb78951 | ||
|
|
f7ef3c2b2e | ||
|
|
b259977198 | ||
|
|
50ac195229 | ||
|
|
a876add5a7 | ||
|
|
f9fdf4d158 | ||
|
|
a419c48805 | ||
|
|
ee09bb40a2 | ||
|
|
01d78bb000 | ||
|
|
6a315c97ce | ||
|
|
5086721082 | ||
|
|
735e564c76 | ||
|
|
86c3aeea85 | ||
|
|
fe13131dee | ||
|
|
f6748de2b1 | ||
|
|
6e76b0ff4c | ||
|
|
1b326c1ce8 | ||
|
|
4a3fb230f5 | ||
|
|
dc951d54e4 | ||
|
|
6518a1f890 | ||
|
|
9b320c1d3c | ||
|
|
ba6a40e373 | ||
|
|
6c5c1940e1 | ||
|
|
7f9046a26d | ||
|
|
b9e1f8c327 | ||
|
|
315400534b | ||
|
|
0491636666 | ||
|
|
a74c618b36 | ||
|
|
69de3d07f5 | ||
|
|
c1272626aa | ||
|
|
c967e901ac | ||
|
|
71eb632191 | ||
|
|
53f155f6c0 | ||
|
|
39f9545d9b | ||
|
|
effc91c269 | ||
|
|
eab9874bdb | ||
|
|
0f87e5573a | ||
|
|
ef6b25fb46 | ||
|
|
1e0990580d | ||
|
|
9d0755c359 | ||
|
|
4a8b6ecdf3 | ||
|
|
929459a08d | ||
|
|
72283bf069 | ||
|
|
b30867ca48 | ||
|
|
ba73ebb393 | ||
|
|
822d3b7e7c | ||
|
|
12974285ad | ||
|
|
657eadaa59 | ||
|
|
d1f50e9b16 | ||
|
|
2c7d165b6e | ||
|
|
88d9e053cb | ||
|
|
b3cc8ef5cd | ||
|
|
114afb6a73 | ||
|
|
db465af9b7 | ||
|
|
5c8e00df52 | ||
|
|
46b901d0cc | ||
|
|
78c9b16058 | ||
|
|
750f6a0ef2 | ||
|
|
ef3429435b | ||
|
|
7c982ad502 | ||
|
|
a8290500e7 | ||
|
|
e75936da75 | ||
|
|
d5ae070bb3 | ||
|
|
f943078d44 | ||
|
|
61f362afb4 | ||
|
|
7133b26c37 | ||
|
|
d7fec9e4ce | ||
|
|
e99f74bc58 | ||
|
|
c9f12bfeea | ||
|
|
e7c7ba90be | ||
|
|
d1f33bb44a | ||
|
|
a5c1219faf | ||
|
|
a441c5de73 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"act": true
|
||||
"act": true
|
||||
}
|
||||
2
.actrc
2
.actrc
@@ -6,4 +6,4 @@
|
||||
-W .github/workflows/build.yml
|
||||
-j build
|
||||
--matrix os:ubuntu-latest
|
||||
--matrix PYTHON_VERSION:3.12.1
|
||||
--matrix PYTHON_VERSION:3.13.2
|
||||
|
||||
18
.github/ISSUE_TEMPLATE/bug.yml
vendored
18
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -6,7 +6,23 @@ labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Thanks for taking the time to fill out this bug report!
|
||||
value: |
|
||||
Thank you for taking the time to submit a bug report!
|
||||
|
||||
This project is run by volunteers, and we depend on users like you to improve it.
|
||||
|
||||
Please try to investigate the issue yourself, and if possible submit a pull request with a fix.
|
||||
|
||||
- type: checkboxes
|
||||
id: reproduce-latest
|
||||
attributes:
|
||||
label: 🔄 Tested on Latest Release
|
||||
description: |
|
||||
Only open issues for problems reproducible with the latest release:
|
||||
https://github.com/Second-Hand-Friends/kleinanzeigen-bot/releases/tag/latest
|
||||
options:
|
||||
- label: I confirm that I can reproduce this issue on the latest version
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-behaviour
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/config.yml
vendored
4
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,2 +1,6 @@
|
||||
# disable blank issue creation
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Community Support
|
||||
url: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/discussions
|
||||
about: Please ask and answer questions here.
|
||||
|
||||
7
.github/ISSUE_TEMPLATE/enhancement.yaml
vendored
7
.github/ISSUE_TEMPLATE/enhancement.yaml
vendored
@@ -6,7 +6,12 @@ labels: [enhancement]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Thanks for taking the time to fill out this enhancement request!
|
||||
value: |
|
||||
Thanks for taking the time to fill out this enhancement request!
|
||||
|
||||
This project is run by volunteers, and we depend on users like you to improve it.
|
||||
|
||||
Please consider implementing the enhancement yourself and submitting a pull request with your changes.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
|
||||
25
.github/PULL_REQUEST_TEMPLATE.md
vendored
25
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,6 +1,27 @@
|
||||
*Issue #, if available:*
|
||||
## ℹ️ Description
|
||||
*Provide a concise summary of the changes introduced in this pull request.*
|
||||
|
||||
*Description of changes:*
|
||||
- Link to the related issue(s): Issue #
|
||||
- Describe the motivation and context for this change.
|
||||
|
||||
## 📋 Changes Summary
|
||||
|
||||
Bullet-point key changes introduced.
|
||||
Mention any dependencies, configuration changes, or additional requirements introduced.
|
||||
|
||||
### ⚙️ Type of Change
|
||||
Select the type(s) of change(s) included in this pull request:
|
||||
- [ ] 🐞 Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] ✨ New feature (adds new functionality without breaking existing usage)
|
||||
- [ ] 💥 Breaking change (changes that might break existing user setups, scripts, or configurations)
|
||||
|
||||
|
||||
## ✅ Checklist
|
||||
Before requesting a review, confirm the following:
|
||||
- [ ] I have reviewed my changes to ensure they meet the project's standards.
|
||||
- [ ] I have tested my changes and ensured that all tests pass (`pdm run test`).
|
||||
- [ ] I have formatted the code (`pdm run format`).
|
||||
- [ ] I have verified that linting passes (`pdm run lint`).
|
||||
- [ ] I have updated documentation where necessary.
|
||||
|
||||
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.
|
||||
|
||||
30
.github/dependabot.yml
vendored
30
.github/dependabot.yml
vendored
@@ -1,17 +1,17 @@
|
||||
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
time: "17:00"
|
||||
commit-message:
|
||||
prefix: fix
|
||||
prefix-development: chore
|
||||
include: scope
|
||||
labels:
|
||||
- pinned
|
||||
- dependencies
|
||||
- gha
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
time: "14:00"
|
||||
commit-message:
|
||||
prefix: ci
|
||||
prefix-development: ci
|
||||
include: scope
|
||||
labels:
|
||||
- dependencies
|
||||
- gha
|
||||
- pinned
|
||||
|
||||
15
.github/labeler.yml
vendored
Normal file
15
.github/labeler.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# see https://github.com/srvaroa/labeler
|
||||
version: 1
|
||||
issues: False
|
||||
labels:
|
||||
- label: "bug"
|
||||
title: "^fix(\\(.*\\))?:.*"
|
||||
- label: "dependencies"
|
||||
title: "^deps(\\(.*\\))?:.*"
|
||||
- label: "documentation"
|
||||
title: "^docs(\\(.*\\))?:.*"
|
||||
- label: "enhancement"
|
||||
title: "^(enh|feat)(\\(.*\\))?:.*"
|
||||
- label: "work-in-progress"
|
||||
title: "^WIP:.*"
|
||||
mergeable: false
|
||||
26
.github/stale.yml
vendored
26
.github/stale.yml
vendored
@@ -1,26 +0,0 @@
|
||||
# Configuration for probot-stale - https://github.com/probot/stale
|
||||
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 120
|
||||
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 14
|
||||
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- enhancement
|
||||
- pinned
|
||||
- security
|
||||
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: wontfix
|
||||
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed in 7 days if no further activity occurs.
|
||||
If the issue is still valid, please add a respective comment to prevent this
|
||||
issue from being closed automatically. Thank you for your contributions.
|
||||
|
||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||
closeComment: false
|
||||
239
.github/workflows/build.yml
vendored
239
.github/workflows/build.yml
vendored
@@ -1,41 +1,50 @@
|
||||
# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot
|
||||
#
|
||||
# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
|
||||
# https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions
|
||||
name: Build
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 15 1 * *'
|
||||
push:
|
||||
branches-ignore: # build all branches except:
|
||||
- 'dependabot/**' # prevent GHA triggered twice (once for commit to the branch and once for opening/syncing the PR)
|
||||
- 'dependencies/pdm' # prevent GHA triggered twice (once for commit to the branch and once for opening/syncing the PR)
|
||||
tags-ignore: # don't build tags
|
||||
branches-ignore: # build all branches except:
|
||||
- 'dependabot/**' # prevent GHA triggered twice (once for commit to the branch and once for opening/syncing the PR)
|
||||
- 'dependencies/pdm' # prevent GHA triggered twice (once for commit to the branch and once for opening/syncing the PR)
|
||||
tags-ignore: # don't build tags
|
||||
- '**'
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
- '.act*'
|
||||
- '.editorconfig'
|
||||
- '.git*'
|
||||
- '.github/*.yml'
|
||||
- '.github/ISSUE_TEMPLATE/*'
|
||||
- '.github/workflows/codeql-analysis.yml'
|
||||
- '.github/workflows/stale.yml'
|
||||
- '.github/workflows/update-python-deps.yml'
|
||||
- '.github/workflows/validate-pr.yml'
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
- '.act*'
|
||||
- '.editorconfig'
|
||||
- '.git*'
|
||||
- '.github/*.yml'
|
||||
- '.github/ISSUE_TEMPLATE/*'
|
||||
- '.github/workflows/codeql-analysis.yml'
|
||||
- '.github/workflows/stale.yml'
|
||||
- '.github/workflows/update-python-deps.yml'
|
||||
workflow_dispatch:
|
||||
# https://github.blog/changelog/2020-07-06-github-actions-manual-triggers-with-workflow_dispatch/
|
||||
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
|
||||
jobs:
|
||||
|
||||
###########################################################
|
||||
@@ -45,7 +54,10 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
- os: macos-13 # X86
|
||||
PYTHON_VERSION: "3.10"
|
||||
PUBLISH_RELEASE: false
|
||||
- os: macos-latest # ARM
|
||||
PYTHON_VERSION: "3.10"
|
||||
PUBLISH_RELEASE: false
|
||||
- os: ubuntu-latest
|
||||
@@ -54,28 +66,41 @@ jobs:
|
||||
- os: windows-latest
|
||||
PYTHON_VERSION: "3.10"
|
||||
PUBLISH_RELEASE: false
|
||||
- os: macos-latest
|
||||
PYTHON_VERSION: "3.12.1"
|
||||
- os: macos-13 # X86
|
||||
PYTHON_VERSION: "3.13.3"
|
||||
PUBLISH_RELEASE: true
|
||||
- os: macos-latest # ARM
|
||||
PYTHON_VERSION: "3.13.3"
|
||||
PUBLISH_RELEASE: true
|
||||
- os: ubuntu-latest
|
||||
PYTHON_VERSION: "3.12.1"
|
||||
PYTHON_VERSION: "3.13.3"
|
||||
PUBLISH_RELEASE: true
|
||||
- os: windows-latest
|
||||
PYTHON_VERSION: "3.12.1"
|
||||
PYTHON_VERSION: "3.13.3"
|
||||
PUBLISH_RELEASE: true
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }} # https://github.com/actions/runner-images#available-images
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Git checkout
|
||||
uses: actions/checkout@v4 # https://github.com/actions/checkout
|
||||
- name: "Show: GitHub context"
|
||||
env:
|
||||
GITHUB_CONTEXT: ${{ toJSON(github) }}
|
||||
run: echo $GITHUB_CONTEXT
|
||||
|
||||
|
||||
- name: "Show: environment variables"
|
||||
run: env | sort
|
||||
|
||||
|
||||
- name: Configure Fast APT Mirror
|
||||
uses: vegardit/fast-apt-mirror.sh@v1
|
||||
|
||||
|
||||
- name: Install Chromium Browser
|
||||
- name: Git Checkout
|
||||
uses: actions/checkout@v4 # https://github.com/actions/checkout
|
||||
|
||||
- name: "Install: Chromium Browser"
|
||||
if: env.ACT == 'true' && startsWith(matrix.os, 'ubuntu')
|
||||
run: |
|
||||
if ! hash google-chrome &>/dev/null; then
|
||||
@@ -85,11 +110,11 @@ jobs:
|
||||
fi
|
||||
|
||||
|
||||
- name: "Install Python and PDM" # https://github.com/pdm-project/setup-pdm
|
||||
- name: "Install: Python and PDM" # https://github.com/pdm-project/setup-pdm
|
||||
uses: pdm-project/setup-pdm@v4
|
||||
with:
|
||||
python-version: "${{ matrix.PYTHON_VERSION }}"
|
||||
cache: true
|
||||
cache: ${{ !startsWith(matrix.os, 'macos') }} # https://github.com/pdm-project/setup-pdm/issues/55
|
||||
|
||||
|
||||
- name: "Install: Python dependencies"
|
||||
@@ -102,23 +127,33 @@ jobs:
|
||||
if [[ ! -e .venv ]]; then
|
||||
pdm venv create || true
|
||||
fi
|
||||
pdm install -v
|
||||
pdm sync --clean -v
|
||||
|
||||
|
||||
- name: Display project metadata
|
||||
run: pdm show
|
||||
|
||||
|
||||
- name: Security scan
|
||||
run: pdm run scan
|
||||
- name: Check with pip-audit
|
||||
# until https://github.com/astral-sh/ruff/issues/8277
|
||||
run:
|
||||
pdm run pip-audit --progress-spinner off --skip-editable --verbose
|
||||
|
||||
|
||||
- name: Check code style
|
||||
run: pdm run lint
|
||||
- name: Check with ruff
|
||||
run: pdm run ruff check
|
||||
|
||||
|
||||
- name: Check with mypy
|
||||
run: pdm run mypy
|
||||
|
||||
|
||||
- name: Check with basedpyright
|
||||
run: pdm run basedpyright
|
||||
|
||||
|
||||
- name: Run unit tests
|
||||
run: pdm run utest
|
||||
run: pdm run utest:cov --cov=src/kleinanzeigen_bot
|
||||
|
||||
|
||||
- name: Run integration tests
|
||||
@@ -128,9 +163,9 @@ jobs:
|
||||
case "${{ matrix.os }}" in
|
||||
ubuntu-*)
|
||||
sudo apt-get install --no-install-recommends -y xvfb
|
||||
xvfb-run pdm run itest
|
||||
xvfb-run pdm run itest:cov
|
||||
;;
|
||||
*) pdm run itest
|
||||
*) pdm run itest:cov
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -171,10 +206,16 @@ jobs:
|
||||
|
||||
/tmp/upx/upx.exe --version
|
||||
|
||||
|
||||
- name: Build self-contained executable
|
||||
run: |
|
||||
set -eux
|
||||
|
||||
if [[ "${{ runner.os }}" == "Windows" ]]; then
|
||||
NO_UPX=1 pdm run compile
|
||||
mv dist/kleinanzeigen-bot.exe dist/kleinanzeigen-bot-uncompressed.exe
|
||||
fi
|
||||
|
||||
pdm run compile
|
||||
|
||||
ls -l dist
|
||||
@@ -191,7 +232,7 @@ jobs:
|
||||
|
||||
- name: Upload self-contained executable
|
||||
uses: actions/upload-artifact@v4
|
||||
if: ${{ github.ref_name == 'main' && matrix.PUBLISH_RELEASE && !env.ACT }}
|
||||
if: (github.ref_name == 'main' || github.ref_name == 'release') && matrix.PUBLISH_RELEASE && !env.ACT
|
||||
with:
|
||||
name: artifacts-${{ matrix.os }}
|
||||
path: dist/kleinanzeigen-bot*
|
||||
@@ -208,7 +249,7 @@ jobs:
|
||||
|
||||
|
||||
- name: Publish Docker image
|
||||
if: ${{ github.ref_name == 'main' && matrix.PUBLISH_RELEASE && startsWith(matrix.os, 'ubuntu') && !env.ACT }}
|
||||
if: github.repository_owner == 'Second-Hand-Friends' && github.ref_name == 'main' && matrix.PUBLISH_RELEASE && startsWith(matrix.os, 'ubuntu') && !env.ACT
|
||||
run: |
|
||||
set -eux
|
||||
|
||||
@@ -223,15 +264,30 @@ jobs:
|
||||
publish-release:
|
||||
###########################################################
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
needs:
|
||||
- build
|
||||
if: ${{ github.ref_name == 'main' && !github.event.act }}
|
||||
concurrency: publish-latest-release # https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idconcurrency
|
||||
if: (github.ref_name == 'main' || github.ref_name == 'release') && !github.event.act
|
||||
concurrency: publish-${{ github.ref_name }}-release # https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idconcurrency
|
||||
|
||||
steps:
|
||||
- name: Git checkout
|
||||
- name: "Show: GitHub context"
|
||||
env:
|
||||
GITHUB_CONTEXT: ${{ toJSON(github) }}
|
||||
run: echo $GITHUB_CONTEXT
|
||||
|
||||
|
||||
- name: "Show: environment variables"
|
||||
run: env | sort
|
||||
|
||||
|
||||
- name: Configure Fast APT Mirror
|
||||
uses: vegardit/fast-apt-mirror.sh@v1
|
||||
|
||||
|
||||
- name: Git Checkout
|
||||
# only required by "gh release create" to prevent "fatal: Not a git repository"
|
||||
uses: actions/checkout@v4 #https://github.com/actions/checkout
|
||||
uses: actions/checkout@v4 # https://github.com/actions/checkout
|
||||
|
||||
|
||||
- name: Delete untagged docker image
|
||||
@@ -248,40 +304,129 @@ jobs:
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
|
||||
- name: "Delete previous 'latest' release"
|
||||
- name: Rename build artifacts
|
||||
run: |
|
||||
mv artifacts-macos-13/kleinanzeigen-bot kleinanzeigen-bot-darwin-amd64
|
||||
mv artifacts-macos-latest/kleinanzeigen-bot kleinanzeigen-bot-darwin-arm64
|
||||
mv artifacts-ubuntu-latest/kleinanzeigen-bot kleinanzeigen-bot-linux-amd64
|
||||
mv artifacts-windows-latest/kleinanzeigen-bot-uncompressed.exe kleinanzeigen-bot-windows-amd64-uncompressed.exe
|
||||
mv artifacts-windows-latest/kleinanzeigen-bot.exe kleinanzeigen-bot-windows-amd64.exe
|
||||
|
||||
|
||||
- name: Install ClamAV
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y clamav
|
||||
sudo systemctl stop clamav-freshclam.service
|
||||
sudo freshclam
|
||||
|
||||
|
||||
- name: Scan build artifacts
|
||||
run: clamscan kleinanzeigen-*
|
||||
|
||||
|
||||
- name: "Determine release name"
|
||||
id: release
|
||||
if: github.event_name != 'schedule'
|
||||
run: |
|
||||
case "$GITHUB_REF_NAME" in
|
||||
main)
|
||||
echo "name=preview" >>"$GITHUB_OUTPUT"
|
||||
;;
|
||||
release)
|
||||
echo "name=latest" >>"$GITHUB_OUTPUT"
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
- name: "Delete previous '${{ steps.release.outputs.name }}' release"
|
||||
if: steps.release.outputs.name && steps.release.outputs.name != ''
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_NAME: latest
|
||||
RELEASE_NAME: ${{ steps.release.outputs.name }}
|
||||
# https://cli.github.com/manual/gh_release_delete
|
||||
run: |
|
||||
GH_DEBUG=1 gh release delete "$RELEASE_NAME" --yes --cleanup-tag || true
|
||||
|
||||
|
||||
- name: "Create 'latest' release"
|
||||
- name: "Create '${{ steps.release.outputs.name }}' Release"
|
||||
if: steps.release.outputs.name && steps.release.outputs.name != ''
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_NAME: latest
|
||||
RELEASE_NAME: ${{ steps.release.outputs.name }}
|
||||
COMMIT_MSG: | # https://stackoverflow.com/a/78420438/5116073
|
||||
${{ github.event.head_commit.message }}
|
||||
|
||||
---
|
||||
#### ⚠️ Rechtlicher Hinweis
|
||||
<p>Die Verwendung dieses Programms kann unter Umständen gegen die zum jeweiligen Zeitpunkt bei kleinanzeigen.de geltenden Nutzungsbedingungen verstoßen.
|
||||
Es liegt in Ihrer Verantwortung, die rechtliche Zulässigkeit der Nutzung dieses Programms zu prüfen.
|
||||
Die Entwickler übernehmen keinerlei Haftung für mögliche Schäden oder rechtliche Konsequenzen.
|
||||
Die Nutzung erfolgt auf eigenes Risiko. Jede rechtswidrige Verwendung ist untersagt.</p>
|
||||
|
||||
#### ⚠️ Legal notice
|
||||
<p>The use of this program could violate the terms of service of kleinanzeigen.de valid at the time of use.
|
||||
It is your responsibility to ensure the legal compliance of its use.
|
||||
The developers assume no liability for any damages or legal consequences.
|
||||
Use is at your own risk. Any unlawful use is strictly prohibited.</p>
|
||||
|
||||
# https://cli.github.com/manual/gh_release_create
|
||||
run: |
|
||||
set -eux
|
||||
|
||||
mv artifacts-macos-latest/kleinanzeigen-bot kleinanzeigen-bot-darwin-amd64
|
||||
mv artifacts-ubuntu-latest/kleinanzeigen-bot kleinanzeigen-bot-linux-amd64
|
||||
mv artifacts-windows-latest/kleinanzeigen-bot.exe kleinanzeigen-bot-windows-amd64.exe
|
||||
|
||||
# https://cli.github.com/manual/gh_release_create
|
||||
GH_DEBUG=1 gh release create "$RELEASE_NAME" \
|
||||
--title "$RELEASE_NAME" \
|
||||
--latest \
|
||||
--notes "${{ github.event.head_commit.message }}" \
|
||||
${{ steps.release.outputs.name == 'latest' && '--latest' || '' }} \
|
||||
${{ steps.release.outputs.name == 'preview' && '--prerelease' || '' }} \
|
||||
--notes "$COMMIT_MSG" \
|
||||
--target "${{ github.sha }}" \
|
||||
kleinanzeigen-bot-darwin-amd64 \
|
||||
kleinanzeigen-bot-darwin-arm64 \
|
||||
kleinanzeigen-bot-linux-amd64 \
|
||||
kleinanzeigen-bot-windows-amd64.exe
|
||||
kleinanzeigen-bot-windows-amd64.exe \
|
||||
kleinanzeigen-bot-windows-amd64-uncompressed.exe
|
||||
|
||||
|
||||
- name: "Delete intermediate build artifacts"
|
||||
uses: geekyeggo/delete-artifact@v4 # https://github.com/GeekyEggo/delete-artifact/
|
||||
uses: geekyeggo/delete-artifact@v5 # https://github.com/GeekyEggo/delete-artifact/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
name: "*"
|
||||
failOnError: false
|
||||
|
||||
|
||||
###########################################################
|
||||
dependabot-pr-auto-merge:
|
||||
###########################################################
|
||||
needs: build
|
||||
if: github.event_name == 'pull_request' && github.actor == 'dependabot[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Merge Dependabot PR
|
||||
env:
|
||||
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
||||
PR_URL: ${{github.event.pull_request.html_url}}
|
||||
run: gh pr merge --auto --rebase "$PR_URL"
|
||||
|
||||
|
||||
###########################################################
|
||||
pdm-pr-auto-merge:
|
||||
###########################################################
|
||||
needs: build
|
||||
if: github.event_name == 'pull_request' && github.actor == 'kleinanzeigen-bot-tu[bot]' && github.head_ref == 'dependencies/pdm'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Merge Dependabot PR
|
||||
env:
|
||||
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
||||
PR_URL: ${{github.event.pull_request.html_url}}
|
||||
run: gh pr merge --auto --rebase "$PR_URL"
|
||||
|
||||
35
.github/workflows/codeql-analysis.yml
vendored
35
.github/workflows/codeql-analysis.yml
vendored
@@ -1,17 +1,23 @@
|
||||
# https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning
|
||||
# https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
branches-ignore: # build all branches except:
|
||||
- 'dependabot/**' # prevent GHA triggered twice (once for commit to the branch and once for opening/syncing the PR)
|
||||
- 'dependencies/pdm' # prevent GHA triggered twice (once for commit to the branch and once for opening/syncing the PR)
|
||||
tags-ignore:
|
||||
- '**'
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
- '.act*'
|
||||
- '.editorconfig'
|
||||
- '.git*'
|
||||
- '.github/ISSUE_TEMPLATE/*'
|
||||
- '.github/workflows/build.yml'
|
||||
- '.github/workflows/stale.yml'
|
||||
- '.github/workflows/update-python-deps.yml'
|
||||
- '.github/workflows/validate-pr.yml'
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
@@ -25,7 +31,7 @@ defaults:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: "3.12"
|
||||
PYTHON_VERSION: "3.13.2"
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -33,23 +39,24 @@ jobs:
|
||||
analyze:
|
||||
###########################################################
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Show environment variables
|
||||
run: env | sort
|
||||
|
||||
|
||||
- name: Git checkout
|
||||
uses: actions/checkout@v4 # https://github.com/actions/checkout
|
||||
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- name: "Install Python and PDM" # https://github.com/pdm-project/setup-pdm
|
||||
uses: pdm-project/setup-pdm@v4
|
||||
with:
|
||||
python-version: "${{ env.PYTHON_VERSION }}"
|
||||
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: __pypackages__
|
||||
key: ${{ runner.os }}-pypackages-${{ hashFiles('pdm.lock') }}
|
||||
cache: true
|
||||
|
||||
|
||||
- name: "Install: Python dependencies"
|
||||
@@ -59,14 +66,16 @@ jobs:
|
||||
python --version
|
||||
python -m pip install --upgrade pip
|
||||
pip install --upgrade pdm
|
||||
if [[ ! -e .venv ]]; then
|
||||
pdm venv create || true
|
||||
fi
|
||||
pdm install -v
|
||||
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@v3 # https://github.com/github/codeql-action/blob/main/init/action.yml
|
||||
with:
|
||||
languages: python
|
||||
setup-python-dependencies: false
|
||||
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
|
||||
55
.github/workflows/stale.yml
vendored
Normal file
55
.github/workflows/stale.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
# https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions
|
||||
name: Stale issues
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 15 1,15 * *'
|
||||
workflow_dispatch:
|
||||
# https://github.blog/changelog/2020-07-06-github-actions-manual-triggers-with-workflow_dispatch/
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Git checkout
|
||||
uses: actions/checkout@v4 # https://github.com/actions/checkout
|
||||
|
||||
- name: Run stale action
|
||||
uses: actions/stale@v9 # https://github.com/actions/stale
|
||||
with:
|
||||
days-before-stale: 90
|
||||
days-before-close: 14
|
||||
stale-issue-message: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed in 14 days if no further activity occurs.
|
||||
If the issue is still valid, please add a respective comment to prevent this
|
||||
issue from being closed automatically. Thank you for your contributions.
|
||||
stale-issue-label: stale
|
||||
close-issue-label: wontfix
|
||||
exempt-issue-labels: |
|
||||
enhancement
|
||||
pinned
|
||||
security
|
||||
|
||||
- name: Run stale action (for enhancements)
|
||||
uses: actions/stale@v9 # https://github.com/actions/stale
|
||||
with:
|
||||
days-before-stale: 360
|
||||
days-before-close: 14
|
||||
stale-issue-message: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed in 14 days if no further activity occurs.
|
||||
If the issue is still valid, please add a respective comment to prevent this
|
||||
issue from being closed automatically. Thank you for your contributions.
|
||||
stale-issue-label: stale
|
||||
close-issue-label: wontfix
|
||||
only-labels: enhancement
|
||||
exempt-issue-labels: |
|
||||
pinned
|
||||
security
|
||||
51
.github/workflows/update-python-deps.yml
vendored
51
.github/workflows/update-python-deps.yml
vendored
@@ -2,13 +2,13 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
#
|
||||
# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
|
||||
# https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions
|
||||
name: Update Python Dependencies
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# https://docs.github.com/en/free-pro-team@latest/actions/reference/events-that-trigger-workflows
|
||||
- cron: '0 5 * * *' # daily at 5 a.m.
|
||||
# https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows
|
||||
- cron: '0 10 * * *' # daily at 10 a.m.
|
||||
workflow_dispatch:
|
||||
# https://github.blog/changelog/2020-07-06-github-actions-manual-triggers-with-workflow_dispatch/
|
||||
|
||||
@@ -17,7 +17,7 @@ defaults:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: "3.12"
|
||||
PYTHON_VERSION: "3.10"
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -25,10 +25,21 @@ jobs:
|
||||
update-python-deps:
|
||||
###########################################################
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: "Show: GitHub context"
|
||||
env:
|
||||
GITHUB_CONTEXT: ${{ toJSON(github) }}
|
||||
run: echo $GITHUB_CONTEXT
|
||||
|
||||
|
||||
- name: "Show: environment variables"
|
||||
run: env | sort
|
||||
|
||||
|
||||
- name: Generate GitHub Access Token
|
||||
uses: tibdex/github-app-token@v2 #https://github.com/tibdex/github-app-token
|
||||
uses: tibdex/github-app-token@v2 # https://github.com/tibdex/github-app-token
|
||||
id: generate_token
|
||||
# see https://github.com/peter-evans/create-pull-request/blob/main/docs/concepts-guidelines.md#authenticating-with-github-app-generated-tokens
|
||||
with:
|
||||
@@ -37,54 +48,66 @@ jobs:
|
||||
private_key: ${{ secrets.DEPS_UPDATER_PRIVATE_KEY }}
|
||||
|
||||
|
||||
- name: Git checkout
|
||||
- name: Git Checkout
|
||||
uses: actions/checkout@v4 # https://github.com/actions/checkout
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- name: "Install: Python and PDM" # https://github.com/pdm-project/setup-pdm
|
||||
uses: pdm-project/setup-pdm@v4
|
||||
with:
|
||||
python-version: "${{ env.PYTHON_VERSION }}"
|
||||
cache: true
|
||||
|
||||
|
||||
- name: Install Python dependencies
|
||||
- name: "Install: Python dependencies"
|
||||
run: |
|
||||
set -eux
|
||||
|
||||
python --version
|
||||
python -m pip install --upgrade pip
|
||||
pip install --upgrade pdm
|
||||
pdm install -v
|
||||
if [[ ! -e .venv ]]; then
|
||||
pdm venv create || true
|
||||
fi
|
||||
pdm sync --clean -v
|
||||
|
||||
|
||||
- name: Update Python dependencies
|
||||
id: update_deps
|
||||
run: |
|
||||
set -euo pipefail
|
||||
set -x
|
||||
|
||||
exec 5>&1
|
||||
updates=$(pdm update --update-all 2>&1 |tee /dev/fd/5)
|
||||
updates=$(pdm update --update-all 2>&1 | tee /dev/fd/5)
|
||||
|
||||
if git diff --exit-code pdm.lock; then
|
||||
echo "updates=" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
updates="$(echo "$updates" | grep Update | grep -v kleinanzeigen-bot || true)"
|
||||
if [[ $(wc -l <<< "$updates") -eq 1 ]]; then
|
||||
echo "title=$(echo "$updates" | head -n 1 | sed 's/ successful//')" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "title=Update Python dependencies" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
# https://github.com/orgs/community/discussions/26288#discussioncomment-3876281
|
||||
delimiter="$(openssl rand -hex 8)"
|
||||
echo "updates<<${delimiter}" >> "${GITHUB_OUTPUT}"
|
||||
echo "$(echo "$updates" | grep Update | grep -v kleinanzeigen-bot)" >> "${GITHUB_OUTPUT}"
|
||||
echo "$updates" >> "${GITHUB_OUTPUT}"
|
||||
echo "${delimiter}" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
|
||||
|
||||
- name: Create PR
|
||||
uses: peter-evans/create-pull-request@v6 # https://github.com/peter-evans/create-pull-request
|
||||
uses: peter-evans/create-pull-request@v7 # https://github.com/peter-evans/create-pull-request
|
||||
if: "${{ steps.update_deps.outputs.updates != '' }}"
|
||||
with:
|
||||
title: "chore: Update Python dependencies"
|
||||
title: "chore: ${{ steps.update_deps.outputs.title }}"
|
||||
author: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>"
|
||||
committer: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>"
|
||||
commit-message: "chore: Update Python dependencies"
|
||||
commit-message: "chore: ${{ steps.update_deps.outputs.title }}"
|
||||
body: ${{ steps.update_deps.outputs.updates }}
|
||||
add-paths: pdm.lock
|
||||
branch: dependencies/pdm
|
||||
|
||||
47
.github/workflows/validate-pr-title.yml
vendored
Normal file
47
.github/workflows/validate-pr-title.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
# https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions
|
||||
name: "Validate PR Title"
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- synchronize
|
||||
- reopened
|
||||
|
||||
jobs:
|
||||
build:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: "Validate semantic PR title"
|
||||
uses: amannn/action-semantic-pull-request@v5 # https://github.com/amannn/action-semantic-pull-request
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
# https://mazer.dev/en/git/best-practices/git-semantic-commits/
|
||||
# https://github.com/commitizen/conventional-commit-types/blob/master/index.json
|
||||
types: |
|
||||
build
|
||||
ci
|
||||
chore
|
||||
docs
|
||||
fix
|
||||
enh
|
||||
feat
|
||||
refact
|
||||
revert
|
||||
perf
|
||||
style
|
||||
test
|
||||
scopes: |
|
||||
deps
|
||||
i18n
|
||||
requireScope: false
|
||||
|
||||
- name: "Label PR"
|
||||
uses: srvaroa/labeler@v1.13.0 # https://github.com/srvaroa/labeler
|
||||
env:
|
||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -21,6 +21,12 @@ __pycache__
|
||||
/.mypy_cache
|
||||
/.pdm-build/
|
||||
/.pdm-python
|
||||
/.ruff_cache
|
||||
|
||||
# test coverage
|
||||
/.coverage
|
||||
/htmlcov/
|
||||
/coverage.xml
|
||||
|
||||
# Eclipse
|
||||
/.project
|
||||
|
||||
183
README.md
183
README.md
@@ -1,6 +1,6 @@
|
||||
# kleinanzeigen-bot
|
||||
|
||||
[](https://github.com/Second-Hand-Friends/kleinanzeigen-bot/actions?query=workflow%3A%22Build%22)
|
||||
[](https://github.com/Second-Hand-Friends/kleinanzeigen-bot/actions/workflows/build.yml)
|
||||
[](LICENSE.txt)
|
||||
[](CODE_OF_CONDUCT.md)
|
||||
[](https://codeclimate.com/github/Second-Hand-Friends/kleinanzeigen-bot/maintainability)
|
||||
@@ -10,27 +10,33 @@
|
||||
1. [About](#about)
|
||||
1. [Installation](#installation)
|
||||
1. [Usage](#usage)
|
||||
1. [Configuration](#config)
|
||||
1. [Main configuration](#main-config)
|
||||
1. [Ad configuration](#ad-config)
|
||||
1. [Using an existing browser window](#existing-browser)
|
||||
1. [Development Notes](#development)
|
||||
1. [Related Open-Source Projects](#related)
|
||||
1. [License](#license)
|
||||
|
||||
|
||||
## <a name="about"></a>About
|
||||
|
||||
**kleinanzeigen-bot** is a console based application to ease publishing of ads to [kleinanzeigen.de](https://kleinanzeigen.de).
|
||||
**kleinanzeigen-bot** is a console-based application to simplify the process of publishing ads on kleinanzeigen.de.
|
||||
It is a spiritual successor to [Second-Hand-Friends/ebayKleinanzeigen](https://github.com/Second-Hand-Friends/ebayKleinanzeigen).
|
||||
|
||||
It is the spiritual successor to [Second-Hand-Friends/ebayKleinanzeigen](https://github.com/Second-Hand-Friends/ebayKleinanzeigen) with the following advantages:
|
||||
- supports Microsoft Edge browser (Chromium based)
|
||||
- compatible chromedriver is installed automatically
|
||||
- better captcha handling
|
||||
- config:
|
||||
- use YAML or JSON for config files
|
||||
- one config file per ad
|
||||
- use globbing (wildcards) to select images from local disk via [wcmatch](https://facelessuser.github.io/wcmatch/glob/#syntax)
|
||||
- reference categories by name (looked up from [categories.yaml](https://github.com/Second-Hand-Friends/kleinanzeigen-bot/blob/main/kleinanzeigen_bot/resources/categories.yaml))
|
||||
- logging is configurable and colorized
|
||||
- provided as self-contained executable for Windows, Linux and macOS
|
||||
- source code is pylint checked and uses Python type hints
|
||||
- CI builds
|
||||
### ⚠️ Legal Disclaimer
|
||||
|
||||
The use of this program could violate the terms of service of kleinanzeigen.de applicable at the time of use.
|
||||
It is your responsibility to ensure the legal compliance of its use.
|
||||
The developers assume no liability for any damages or legal consequences.
|
||||
Use is at your own risk. Any unlawful use is strictly prohibited.
|
||||
|
||||
### ⚠️ Rechtliche Hinweise
|
||||
|
||||
Die Verwendung dieses Programms kann unter Umständen gegen die zum jeweiligen Zeitpunkt bei kleinanzeigen.de geltenden Nutzungsbedingungen verstoßen.
|
||||
Es liegt in Ihrer Verantwortung, die rechtliche Zulässigkeit der Nutzung dieses Programms zu prüfen.
|
||||
Die Entwickler übernehmen keinerlei Haftung für mögliche Schäden oder rechtliche Konsequenzen.
|
||||
Die Nutzung erfolgt auf eigenes Risiko. Jede rechtswidrige Verwendung ist untersagt.
|
||||
|
||||
|
||||
## <a name="installation"></a>Installation
|
||||
@@ -186,12 +192,14 @@ Commands:
|
||||
version - displays the application version
|
||||
|
||||
Options:
|
||||
--ads=all|due|new|<id(s)> (publish) - specifies which ads to (re-)publish (DEFAULT: due)
|
||||
--ads=all|due|new|changed|<id(s)> (publish) - specifies which ads to (re-)publish (DEFAULT: due)
|
||||
Possible values:
|
||||
* all: (re-)publish all ads ignoring republication_interval
|
||||
* due: publish all new ads and republish ads according the republication_interval
|
||||
* new: only publish new ads (i.e. ads that have no id in the config file)
|
||||
* changed: only publish ads that have been modified since last publication
|
||||
* <id(s)>: provide one or several ads by ID to (re-)publish, like e.g. "--ads=1,2,3" ignoring republication_interval
|
||||
* Combinations: You can combine multiple selectors with commas, e.g. "--ads=changed,due" to publish both changed and due ads
|
||||
--ads=all|new|<id(s)> (download) - specifies which ads to download (DEFAULT: new)
|
||||
Possible values:
|
||||
* all: downloads all ads from your profile
|
||||
@@ -201,16 +209,17 @@ Options:
|
||||
--keep-old - don't delete old ads on republication
|
||||
--config=<PATH> - path to the config YAML or JSON file (DEFAULT: ./config.yaml)
|
||||
--logfile=<PATH> - path to the logfile (DEFAULT: ./kleinanzeigen-bot.log)
|
||||
--lang=en|de - display language (STANDARD: system language if supported, otherwise English)
|
||||
-v, --verbose - enables verbose output - only useful when troubleshooting issues
|
||||
```
|
||||
|
||||
Limitation of `download`: It's only possible to extract the cheapest given shipping option.
|
||||
|
||||
### Configuration
|
||||
## <a name="config"></a>Configuration
|
||||
|
||||
All configuration files can be in YAML or JSON format.
|
||||
|
||||
#### 1) Main configuration
|
||||
### <a name="main-config"></a>1) Main configuration
|
||||
|
||||
When executing the app it by default looks for a `config.yaml` file in the current directory. If it does not exist it will be created automatically.
|
||||
|
||||
@@ -220,7 +229,7 @@ Valid file extensions are `.json`, `.yaml` and `.yml`
|
||||
The following parameters can be configured:
|
||||
|
||||
```yaml
|
||||
# wild card patterns to select ad configuration files
|
||||
# glob (wildcard) patterns to select ad configuration files
|
||||
# if relative paths are specified, then they are relative to this configuration file
|
||||
ad_files:
|
||||
- "./**/ad_*.{json,yml,yaml}"
|
||||
@@ -229,9 +238,10 @@ ad_files:
|
||||
ad_defaults:
|
||||
active: true
|
||||
type: OFFER # one of: OFFER, WANTED
|
||||
description:
|
||||
prefix: ""
|
||||
suffix: ""
|
||||
|
||||
description_prefix: ""
|
||||
description_suffix: ""
|
||||
|
||||
price_type: NEGOTIABLE # one of: FIXED, NEGOTIABLE, GIVE_AWAY, NOT_APPLICABLE
|
||||
shipping_type: SHIPPING # one of: PICKUP, SHIPPING, NOT_APPLICABLE
|
||||
shipping_costs: # e.g. 2.95
|
||||
@@ -244,10 +254,24 @@ ad_defaults:
|
||||
republication_interval: 7 # every X days ads should be re-published
|
||||
|
||||
# additional name to category ID mappings, see default list at
|
||||
# https://github.com/Second-Hand-Friends/kleinanzeigen-bot/blob/main/kleinanzeigen_bot/resources/categories.yaml
|
||||
# https://github.com/Second-Hand-Friends/kleinanzeigen-bot/blob/main/src/kleinanzeigen_bot/resources/categories.yaml
|
||||
categories:
|
||||
#Notebooks: 161/278 # Elektronik > Notebooks
|
||||
#Autoteile: 210/223/sonstige_autoteile # Auto, Rad & Boot > Autoteile & Reifen > Weitere Autoteile
|
||||
Verschenken & Tauschen > Tauschen: 272/273
|
||||
Verschenken & Tauschen > Verleihen: 272/274
|
||||
Verschenken & Tauschen > Verschenken: 272/192
|
||||
|
||||
# publishing configuration
|
||||
publishing:
|
||||
delete_old_ads: "AFTER_PUBLISH" # one of: AFTER_PUBLISH, BEFORE_PUBLISH, NEVER
|
||||
delete_old_ads_by_title: true # only works if delete_old_ads is set to BEFORE_PUBLISH
|
||||
|
||||
# captcha-Handling (optional)
|
||||
# To ensure that the bot does not require manual confirmation after a captcha, but instead automatically pauses for a defined period and then restarts, you can enable the captcha section:
|
||||
|
||||
captcha:
|
||||
auto_restart: true # If true, the bot aborts when a Captcha appears and retries publishing later
|
||||
# If false (default), the Captcha must be solved manually to continue
|
||||
restart_delay: 1h 30m # Time to wait before retrying after a Captcha was encountered (default: 6h)
|
||||
|
||||
# browser configuration
|
||||
browser:
|
||||
@@ -268,10 +292,9 @@ browser:
|
||||
login:
|
||||
username: ""
|
||||
password: ""
|
||||
|
||||
```
|
||||
|
||||
#### 2) Ad configuration
|
||||
### <a name="ad-config"></a>2) Ad configuration
|
||||
|
||||
Each ad is described in a separate JSON or YAML file with prefix `ad_<filename>`. The prefix is configurable in config file.
|
||||
|
||||
@@ -280,23 +303,26 @@ Parameter values specified in the `ad_defaults` section of the `config.yaml` fil
|
||||
The following parameters can be configured:
|
||||
|
||||
```yaml
|
||||
active: # true or false
|
||||
type: # one of: OFFER, WANTED
|
||||
active: # true or false (default: true)
|
||||
type: # one of: OFFER, WANTED (default: OFFER)
|
||||
title:
|
||||
description: # can be multiline, see syntax here https://yaml-multiline.info/
|
||||
|
||||
# built-in category name as specified in https://github.com/Second-Hand-Friends/kleinanzeigen-bot/blob/main/kleinanzeigen_bot/resources/categories.yaml
|
||||
# or custom category name as specified in config.yaml
|
||||
# or category ID (e.g. 161/27)
|
||||
category: Notebooks
|
||||
description_prefix: # optional prefix to be added to the description overriding the default prefix
|
||||
description_suffix: # optional suffix to be added to the description overriding the default suffix
|
||||
|
||||
price:
|
||||
price_type: # one of: FIXED, NEGOTIABLE, GIVE_AWAY
|
||||
# built-in category name as specified in https://github.com/Second-Hand-Friends/kleinanzeigen-bot/blob/main/src/kleinanzeigen_bot/resources/categories.yaml
|
||||
# or custom category name as specified in config.yaml
|
||||
# or category ID (e.g. 161/278)
|
||||
category: # e.g. "Elektronik > Notebooks"
|
||||
|
||||
price: # without decimals, e.g. 75
|
||||
price_type: # one of: FIXED, NEGOTIABLE, GIVE_AWAY (default: NEGOTIABLE)
|
||||
|
||||
special_attributes:
|
||||
# haus_mieten.zimmer_d: value # Zimmer
|
||||
|
||||
shipping_type: # one of: PICKUP, SHIPPING, NOT_APPLICABLE
|
||||
shipping_type: # one of: PICKUP, SHIPPING, NOT_APPLICABLE (default: SHIPPING)
|
||||
shipping_costs: # e.g. 2.95
|
||||
|
||||
# specify shipping options / packages
|
||||
@@ -310,10 +336,11 @@ shipping_costs: # e.g. 2.95
|
||||
# - Hermes_M
|
||||
# possible package types for size L:
|
||||
# - DHL_10
|
||||
# - DHL_20
|
||||
# - DHL_31,5
|
||||
# - Hermes_L
|
||||
shipping_options: []
|
||||
sell_directly: # true or false, requires shipping_options to take effect
|
||||
sell_directly: # true or false, requires shipping_options to take effect (default: false)
|
||||
|
||||
# list of wildcard patterns to select images
|
||||
# if relative paths are specified, then they are relative to this ad configuration file
|
||||
@@ -326,23 +353,78 @@ contact:
|
||||
zipcode:
|
||||
phone: "" # IMPORTANT: surround phone number with quotes to prevent removal of leading zeros
|
||||
|
||||
republication_interval: # every X days the ad should be re-published
|
||||
republication_interval: # every X days the ad should be re-published (default: 7)
|
||||
|
||||
id: # set automatically
|
||||
created_on: # set automatically
|
||||
updated_on: # set automatically
|
||||
# The following fields are automatically managed by the bot:
|
||||
id: # the ID assigned by kleinanzeigen.de
|
||||
created_on: # ISO timestamp when the ad was first published
|
||||
updated_on: # ISO timestamp when the ad was last published
|
||||
content_hash: # hash of the ad content, used to detect changes
|
||||
```
|
||||
|
||||
### <a name="description-prefix-suffix"></a>3) Description Prefix and Suffix
|
||||
|
||||
You can add prefix and suffix text to your ad descriptions in two ways:
|
||||
|
||||
#### New Format (Recommended)
|
||||
|
||||
In your config.yaml file you can specify a `description_prefix` and `description_suffix` under the `ad_defaults` section.
|
||||
|
||||
```yaml
|
||||
ad_defaults:
|
||||
description_prefix: "Prefix text"
|
||||
description_suffix: "Suffix text"
|
||||
```
|
||||
|
||||
#### Legacy Format
|
||||
|
||||
In your ad configuration file you can specify a `description_prefix` and `description_suffix` under the `description` section.
|
||||
|
||||
```yaml
|
||||
description:
|
||||
prefix: "Prefix text"
|
||||
suffix: "Suffix text"
|
||||
```
|
||||
|
||||
#### Precedence
|
||||
|
||||
The new format has precedence over the legacy format. If you specify both the new and the legacy format in your config, the new format will be used. We recommend using the new format as it is more flexible and easier to manage.
|
||||
|
||||
### <a name="existing-browser"></a>4) Using an existing browser window
|
||||
|
||||
By default a new browser process will be launched. To reuse a manually launched browser window/process follow these steps:
|
||||
|
||||
1. Manually launch your browser from the command line with the `--remote-debugging-port=<NUMBER>` flag.
|
||||
You are free to choose an unused port number 1025 and 65535, e.g.:
|
||||
- `chrome --remote-debugging-port=9222`
|
||||
- `chromium --remote-debugging-port=9222`
|
||||
- `msedge --remote-debugging-port=9222`
|
||||
|
||||
This runs the browser in debug mode which allows it to be remote controlled by the bot.
|
||||
|
||||
1. In your config.yaml specify the same flag as browser argument, e.g.:
|
||||
```yaml
|
||||
browser:
|
||||
arguments:
|
||||
- --remote-debugging-port=9222
|
||||
```
|
||||
|
||||
1. When now publishing ads the manually launched browser will be re-used.
|
||||
|
||||
> NOTE: If an existing browser is used all other settings configured under `browser` in your config.yaml file will ignored
|
||||
because they are only used to programmatically configure/launch a dedicated browser instance.
|
||||
|
||||
## <a name="development"></a>Development Notes
|
||||
|
||||
> Please read [CONTRIBUTING.md](CONTRIBUTING.md) before contributing code. Thank you!
|
||||
|
||||
- Format source code: `pdm run format`
|
||||
- Run tests:
|
||||
- unit tests: `pdm run utest`
|
||||
- integration tests: `pdm run itest`
|
||||
- all tests: `pdm run test`
|
||||
- Run linter: `pdm run lint`
|
||||
- unit tests: `pdm run utest` - with coverage: `pdm run utest:cov`
|
||||
- integration tests: `pdm run itest` - with coverage: `pdm run itest:cov`
|
||||
- all tests: `pdm run test` - with coverage: `pdm run test:cov`
|
||||
- Run syntax checks: `pdm run lint`
|
||||
- Linting issues found by ruff can be auto-fixed using `pdm run lint:fix`
|
||||
- Create platform-specific executable: `pdm run compile`
|
||||
- Application bootstrap works like this:
|
||||
```python
|
||||
@@ -354,6 +436,17 @@ updated_on: # set automatically
|
||||
````
|
||||
|
||||
|
||||
## <a name="related"></a>Related Open-Source projects
|
||||
|
||||
- [DanielWTE/ebay-kleinanzeigen-api](https://github.com/DanielWTE/ebay-kleinanzeigen-api) (Python) API interface to get random listings from kleinanzeigen.de
|
||||
- [f-rolf/ebaykleinanzeiger](https://github.com/f-rolf/ebaykleinanzeiger) (Python) Discord bot that watches search results
|
||||
- [r-unruh/kleinanzeigen-filter](https://github.com/r-unruh/kleinanzeigen-filter) (JavaScript) Chrome extension that filters out unwanted results from searches on kleinanzeigen.de
|
||||
- [simonsagstetter/Feinanzeigen](https://github.com/simonsagstetter/feinanzeigen) (JavaScript) Chrome extension that improves search on kleinanzeigen.de
|
||||
- [Superschnizel/Kleinanzeigen-Telegram-Bot](https://github.com/Superschnizel/Kleinanzeigen-Telegram-Bot) (Python) Telegram bot to scrape kleinanzeigen.de
|
||||
- [tillvogt/KleinanzeigenScraper](https://github.com/tillvogt/KleinanzeigenScraper) (Python) Webscraper which stores scraped info from kleinanzeigen.de in an SQL database
|
||||
- [TLINDEN/Kleingebäck](https://github.com/TLINDEN/kleingebaeck) (Go) kleinanzeigen.de Backup
|
||||
|
||||
|
||||
## <a name="license"></a>License
|
||||
|
||||
All files in this repository are released under the [GNU Affero General Public License v3.0 or later](LICENSE.txt).
|
||||
|
||||
@@ -23,9 +23,15 @@ RUN <<EOF
|
||||
apt-get update
|
||||
|
||||
echo "#################################################"
|
||||
echo "Install Chromium + Driver..."
|
||||
echo "Installing root CAs..."
|
||||
echo "#################################################"
|
||||
apt-get install --no-install-recommends -y chromium chromium-driver
|
||||
apt-get install --no-install-recommends -y ca-certificates
|
||||
update-ca-certificates
|
||||
|
||||
echo "#################################################"
|
||||
echo "Installing Chromium..."
|
||||
echo "#################################################"
|
||||
apt-get install --no-install-recommends -y chromium
|
||||
|
||||
apt-get clean autoclean
|
||||
apt-get autoremove --purge -y
|
||||
@@ -42,7 +48,7 @@ EOF
|
||||
######################
|
||||
|
||||
# https://hub.docker.com/_/python/tags?name=3-slim
|
||||
FROM python:3.12-slim AS build-image
|
||||
FROM python:3.13-slim AS build-image
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG LC_ALL=C
|
||||
|
||||
@@ -10,7 +10,6 @@ from PyInstaller.utils.hooks import collect_data_files
|
||||
|
||||
datas = [
|
||||
* collect_data_files("kleinanzeigen_bot"), # embeds *.yaml files
|
||||
* collect_data_files("selenium_stealth"), # embeds *.js files
|
||||
|
||||
# required to get version info via 'importlib.metadata.version(__package__)'
|
||||
# but we use https://backend.pdm-project.org/metadata/#writing-dynamic-version-to-file
|
||||
@@ -20,32 +19,22 @@ datas = [
|
||||
excluded_modules = [
|
||||
"_aix_support",
|
||||
"argparse",
|
||||
"backports",
|
||||
"bz2",
|
||||
"cryptography.hazmat",
|
||||
"distutils",
|
||||
"doctest",
|
||||
"ftplib",
|
||||
"lzma",
|
||||
"pep517",
|
||||
"pdb",
|
||||
"pip",
|
||||
"pydoc",
|
||||
"pydoc_data",
|
||||
"optparse",
|
||||
"setuptools",
|
||||
"six",
|
||||
"smtplib",
|
||||
"statistics",
|
||||
"test",
|
||||
"unittest",
|
||||
"xml.sax"
|
||||
"tracemalloc",
|
||||
"xml.sax",
|
||||
"xmlrpc"
|
||||
]
|
||||
|
||||
from sys import platform
|
||||
if platform != "darwin":
|
||||
excluded_modules.append("_osx_support")
|
||||
|
||||
# https://github.com/pyinstaller/pyinstaller/blob/f563dce1e83fd5ec72a20dffd2ac24be3e647150/PyInstaller/building/build_main.py#L320
|
||||
# https://github.com/pyinstaller/pyinstaller/blob/adceeab4c2901fba853b29f9ae2db7bb67667030/PyInstaller/building/build_main.py#L399
|
||||
analysis = Analysis(
|
||||
['src/kleinanzeigen_bot/__main__.py'],
|
||||
# pathex = [],
|
||||
@@ -60,19 +49,20 @@ analysis = Analysis(
|
||||
# win_no_prefer_redirets = False, # Deprecated
|
||||
# win_private_assemblies = False, # Deprecated
|
||||
# noarchive = False,
|
||||
# module_collection_mode = None
|
||||
# module_collection_mode = None,
|
||||
# optimize = -1
|
||||
)
|
||||
|
||||
# https://github.com/pyinstaller/pyinstaller/blob/f563dce1e83fd5ec72a20dffd2ac24be3e647150/PyInstaller/building/api.py#L51
|
||||
# https://github.com/pyinstaller/pyinstaller/blob/adceeab4c2901fba853b29f9ae2db7bb67667030/PyInstaller/building/api.py#L52
|
||||
pyz = PYZ(
|
||||
analysis.pure, # tocs
|
||||
analysis.zipped_data,
|
||||
# name = None
|
||||
)
|
||||
|
||||
import shutil
|
||||
import os, shutil
|
||||
|
||||
# https://github.com/pyinstaller/pyinstaller/blob/f563dce1e83fd5ec72a20dffd2ac24be3e647150/PyInstaller/building/api.py#L338
|
||||
# https://github.com/pyinstaller/pyinstaller/blob/adceeab4c2901fba853b29f9ae2db7bb67667030/PyInstaller/building/api.py#L363
|
||||
exe = EXE(pyz,
|
||||
analysis.scripts,
|
||||
analysis.binaries,
|
||||
@@ -95,7 +85,7 @@ exe = EXE(pyz,
|
||||
# contents_directory = "_internal",
|
||||
# using strip on windows results in "ImportError: Can't connect to HTTPS URL because the SSL module is not available."
|
||||
strip = not platform.startswith("win") and shutil.which("strip") is not None,
|
||||
upx = shutil.which("upx") is not None,
|
||||
upx = shutil.which("upx") is not None and not os.getenv("NO_UPX"),
|
||||
upx_exclude = [],
|
||||
runtime_tmpdir = None,
|
||||
)
|
||||
|
||||
389
pyproject.toml
389
pyproject.toml
@@ -5,7 +5,7 @@
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
#
|
||||
|
||||
[build-system] # https://backend.pdm-project.org/
|
||||
[build-system] # https://backend.pdm-project.org/
|
||||
requires = ["pdm-backend"]
|
||||
build-backend = "pdm.backend"
|
||||
|
||||
@@ -15,73 +15,100 @@ dynamic = ["version"]
|
||||
description = "Command line tool to publish ads on kleinanzeigen.de"
|
||||
readme = "README.md"
|
||||
authors = [
|
||||
{name = "sebthom", email = "sebthom@users.noreply.github.com"},
|
||||
{name = "sebthom", email = "sebthom@users.noreply.github.com"},
|
||||
]
|
||||
license = {text = "AGPL-3.0-or-later"}
|
||||
|
||||
classifiers = [ # https://pypi.org/classifiers/
|
||||
"Development Status :: 4 - Beta",
|
||||
"Environment :: Console",
|
||||
"Operating System :: OS Independent",
|
||||
"Private :: Do Not Upload",
|
||||
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
"Topic :: Office/Business",
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Environment :: Console",
|
||||
"Operating System :: OS Independent",
|
||||
|
||||
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
|
||||
"Programming Language :: Python :: 3.10"
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
"Topic :: Office/Business",
|
||||
|
||||
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10"
|
||||
]
|
||||
requires-python = ">=3.10,<3.13" # <3.12 required for pyinstaller
|
||||
requires-python = ">=3.10,<3.14"
|
||||
dependencies = [
|
||||
"colorama~=0.4",
|
||||
"coloredlogs~=15.0",
|
||||
"overrides~=7.4",
|
||||
"ruamel.yaml~=0.18",
|
||||
"pywin32==306; sys_platform == 'win32'",
|
||||
"selenium~=4.18",
|
||||
"selenium_stealth~=1.0",
|
||||
"wcmatch~=8.5",
|
||||
"certifi",
|
||||
"colorama",
|
||||
"jaraco.text", # required by pkg_resources during runtime
|
||||
"nodriver==0.39.0", # 0.40-0.44 have issues starting browsers and evaluating self.web_execute("window.BelenConf") fails
|
||||
"ruamel.yaml",
|
||||
"psutil",
|
||||
"wcmatch",
|
||||
]
|
||||
|
||||
[dependency-groups] # https://peps.python.org/pep-0735/
|
||||
dev = [
|
||||
# security
|
||||
"pip-audit",
|
||||
# testing:
|
||||
"pytest>=8.3.4",
|
||||
"pytest-asyncio>=0.25.3",
|
||||
"pytest-rerunfailures",
|
||||
"pytest-cov>=6.0.0",
|
||||
# linting:
|
||||
"ruff",
|
||||
"mypy",
|
||||
"basedpyright",
|
||||
# formatting
|
||||
"autopep8",
|
||||
"yamlfix",
|
||||
# packaging:
|
||||
"pyinstaller",
|
||||
"platformdirs", # required by pyinstaller
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/Second-Hand-Friends/kleinanzeigen-bot"
|
||||
Repository = "https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git"
|
||||
Homepage = "https://github.com/Second-Hand-Friends/kleinanzeigen-bot"
|
||||
Repository = "https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git"
|
||||
Documentation = "https://github.com/Second-Hand-Friends/kleinanzeigen-bot/README.md"
|
||||
Issues = "https://github.com/Second-Hand-Friends/kleinanzeigen-bot/issues"
|
||||
Issues = "https://github.com/Second-Hand-Friends/kleinanzeigen-bot/issues"
|
||||
CI = "https://github.com/Second-Hand-Friends/kleinanzeigen-bot/actions"
|
||||
|
||||
|
||||
#####################
|
||||
# pdm https://github.com/pdm-project/pdm/
|
||||
#####################
|
||||
[tool.pdm.version] # https://backend.pdm-project.org/metadata/#dynamic-project-version
|
||||
[tool.pdm.version] # https://backend.pdm-project.org/metadata/#dynamic-project-version
|
||||
source = "call"
|
||||
getter = "version:get_version"
|
||||
getter = "version:get_version" # uses get_version() of <project_root>/version.py
|
||||
write_to = "kleinanzeigen_bot/_version.py"
|
||||
write_template = "__version__ = '{}'\n"
|
||||
|
||||
[tool.pdm.dev-dependencies]
|
||||
dev = [
|
||||
"autopep8~=2.0",
|
||||
"bandit~=1.7",
|
||||
"toml", # required by bandit
|
||||
"tomli", # required by bandit
|
||||
"pydantic~=2.6",
|
||||
"pytest~=8.1",
|
||||
"pyinstaller~=6.4",
|
||||
"psutil",
|
||||
"pylint~=3.1",
|
||||
"mypy~=1.8",
|
||||
]
|
||||
[tool.pdm.scripts] # https://pdm-project.org/latest/usage/scripts/
|
||||
app = "python -m kleinanzeigen_bot"
|
||||
debug = "python -m pdb -m kleinanzeigen_bot"
|
||||
|
||||
[tool.pdm.scripts] # https://pdm-project.org/latest/usage/scripts/
|
||||
app = "python -m kleinanzeigen_bot"
|
||||
# build & packaging
|
||||
generate-schemas = "python scripts/generate_schemas.py"
|
||||
compile.cmd = "python -O -m PyInstaller pyinstaller.spec --clean"
|
||||
compile.env = {PYTHONHASHSEED = "1", SOURCE_DATE_EPOCH = "0"} # https://pyinstaller.org/en/stable/advanced-topics.html#creating-a-reproducible-build
|
||||
format = "autopep8 --recursive --in-place kleinanzeigen_bot tests --verbose"
|
||||
lint = {shell = "pylint -v src tests && autopep8 -v --exit-code --recursive --diff src tests && echo No issues found."}
|
||||
scan = "bandit -c pyproject.toml -r kleinanzeigen_bot"
|
||||
compile.env = {PYTHONHASHSEED = "1", SOURCE_DATE_EPOCH = "0"} # https://pyinstaller.org/en/stable/advanced-topics.html#creating-a-reproducible-build
|
||||
|
||||
# format & lint
|
||||
format = { composite = ["format:py", "format:yaml"] }
|
||||
"format:py" = { shell = "autopep8 --recursive --in-place scripts src tests --verbose && python scripts/post_autopep8.py scripts src tests" }
|
||||
"format:yaml" = "yamlfix scripts/ src/ tests/"
|
||||
|
||||
lint = { composite = ["lint:ruff", "lint:mypy", "lint:pyright"] }
|
||||
"lint:ruff" = "ruff check --preview"
|
||||
"lint:mypy" = "mypy"
|
||||
"lint:pyright" = "basedpyright"
|
||||
"lint:fix" = {shell = "ruff check --preview --fix" }
|
||||
|
||||
# tests
|
||||
test = "python -m pytest --capture=tee-sys -v"
|
||||
utest = "python -m pytest --capture=tee-sys -v -m 'not itest'"
|
||||
itest = "python -m pytest --capture=tee-sys -v -m 'itest'"
|
||||
"test:cov" = { composite = ["test --cov=src/kleinanzeigen_bot"] }
|
||||
"utest:cov" = { composite = ["utest --cov=src/kleinanzeigen_bot"] }
|
||||
"itest:cov" = { composite = ["itest --cov=src/kleinanzeigen_bot"] }
|
||||
|
||||
|
||||
#####################
|
||||
@@ -92,21 +119,151 @@ itest = "python -m pytest --capture=tee-sys -v -m 'itest'"
|
||||
[tool.autopep8]
|
||||
max_line_length = 160
|
||||
ignore = [ # https://github.com/hhatto/autopep8#features
|
||||
"E124", # Don't change indention of multi-line statements
|
||||
"E128", # Don't change indention of multi-line statements
|
||||
"E231", # Don't add whitespace after colon (:) on type declaration
|
||||
"E251", # Don't remove whitespace around parameter '=' sign.
|
||||
"E401" # Don't put imports on separate lines
|
||||
"E124", # Don't change indention of multi-line statements
|
||||
"E128", # Don't change indention of multi-line statements
|
||||
"E231", # Don't add whitespace after colon (:) on type declaration
|
||||
"E251", # Don't remove whitespace around parameter '=' sign.
|
||||
"E401" # Don't put imports on separate lines
|
||||
]
|
||||
aggressive = 3
|
||||
|
||||
|
||||
#####################
|
||||
# bandit
|
||||
# https://pypi.org/project/bandit/
|
||||
# https://github.com/PyCQA/bandit
|
||||
# ruff
|
||||
# https://pypi.org/project/ruff/
|
||||
# https://docs.astral.sh/ruff/configuration/
|
||||
#####################
|
||||
[tool.bandit]
|
||||
[tool.ruff]
|
||||
include = ["pyproject.toml", "scripts/**/*.py", "src/**/*.py", "tests/**/*.py"]
|
||||
line-length = 160
|
||||
indent-width = 4
|
||||
target-version = "py310"
|
||||
|
||||
[tool.ruff.lint]
|
||||
# https://docs.astral.sh/ruff/rules/
|
||||
select = [
|
||||
"A", # flake8-builtins
|
||||
"ARG", # flake8-unused-arguments
|
||||
"ANN", # flake8-annotations
|
||||
"ASYNC", # flake8-async
|
||||
#"BLE", # flake8-blind-except
|
||||
"B", # flake8-bugbear
|
||||
"C4", # flake8-comprehensions
|
||||
"COM", # flake8-commas
|
||||
"CPY", # flake8-copyright
|
||||
"DTZ", # flake8-datetimez
|
||||
#"EM", # flake8-errmsg
|
||||
#"ERA", # eradicate commented-out code
|
||||
"EXE", # flake8-executable
|
||||
"FA", # flake8-future-annotations
|
||||
"FBT", # flake8-boolean-trap
|
||||
"FIX", # flake8-fixme
|
||||
"G", # flake8-logging-format
|
||||
"ICN", # flake8-import-conventions
|
||||
"ISC", # flake8-implicit-str-concat
|
||||
"INP", # flake8-no-pep420
|
||||
"INT", # flake8-gettext
|
||||
"LOG", # flake8-logging
|
||||
"PIE", # flake8-pie
|
||||
"PT", # flake8-pytest-style
|
||||
#"PTH", # flake8-use-pathlib
|
||||
"PYI", # flake8-pyi
|
||||
"Q", # flake8-quotes
|
||||
"RET", # flake8-return
|
||||
"RSE", # flake8-raise
|
||||
"S", # flake8-bandit
|
||||
"SIM", # flake8-simplify
|
||||
"SLF", # flake8-self
|
||||
"SLOT", # flake8-slots
|
||||
"T10", # flake8-debugger
|
||||
#"T20", # flake8-print
|
||||
"TC", # flake8-type-checking
|
||||
"TD", # flake8-todo
|
||||
"TID", # flake8-flake8-tidy-import
|
||||
"YTT", # flake8-2020
|
||||
|
||||
"E", # pycodestyle-errors
|
||||
"W", # pycodestyle-warnings
|
||||
|
||||
#"C90", # mccabe
|
||||
"D", # pydocstyle
|
||||
"F", # pyflakes
|
||||
"FLY", # flynt
|
||||
"I", # isort
|
||||
"PERF", # perflint
|
||||
"PGH", # pygrep-hooks
|
||||
"PL", # pylint
|
||||
]
|
||||
ignore = [
|
||||
"ANN401", # Dynamically typed expressions (typing.Any) are disallowed
|
||||
"ASYNC210", # TODO Async functions should not call blocking HTTP methods
|
||||
"ASYNC230", # TODO Async functions should not open files with blocking methods like `open`
|
||||
"COM812", # Trailing comma missing
|
||||
"D1", # Missing docstring in ...
|
||||
"D200", # One-line docstring should fit on one line
|
||||
"D202", # No blank lines allowed after function docstring (found 1)
|
||||
"D203", # 1 blank line required before class docstring
|
||||
"D204", # 1 blank line required after class docstring
|
||||
"D205", # 1 blank line required between summary line and description
|
||||
"D209", # Multi-line docstring closing quotes should be on a separate line"
|
||||
"D212", # Multi-line docstring summary should start at the first line
|
||||
"D213", # Multi-line docstring summary should start at the second line
|
||||
"D400", # First line should end with a period
|
||||
"D401", # First line of docstring should be in imperative mood
|
||||
"D402", # First line should not be the function's signature
|
||||
"D404", # First word of the docstring should not be "This"
|
||||
"D413", # Missing blank line after last section ("Returns")"
|
||||
"D415", # First line should end with a period, question mark, or exclamation point
|
||||
"D417", # Missing argument description in the docstring for
|
||||
#"E124", # Don't change indention of multi-line statements
|
||||
#"E128", # Don't change indention of multi-line statements
|
||||
"E231", # Don't add whitespace after colon (:) on type declaration
|
||||
"E251", # Don't remove whitespace around parameter '=' sign.
|
||||
"E401", # Don't put imports on separate lines
|
||||
"PERF203", # `try`-`except` within a loop incurs performance overhead
|
||||
"RET504", # Unnecessary assignment to `...` before `return` statement
|
||||
"PLR6301", # Method `...` could be a function, class method, or static method
|
||||
"PYI041", # Use `float` instead of `int | float`
|
||||
"SIM102", # Use a single `if` statement instead of nested `if` statements
|
||||
"SIM105", # Use `contextlib.suppress(TimeoutError)` instead of `try`-`except`-`pass`
|
||||
"SIM114", # Combine `if` branches using logical `or` operator
|
||||
"TC006", # Add quotes to type expression in `typing.cast()`
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"scripts/**/*.py" = [
|
||||
"INP001", # File `...` is part of an implicit namespace package. Add an `__init__.py`.
|
||||
]
|
||||
"tests/**/*.py" = [
|
||||
"ARG",
|
||||
"B",
|
||||
"FBT",
|
||||
"INP",
|
||||
"SLF",
|
||||
"S101", # Use of `assert` detected
|
||||
"PLR0904", # Too many public methods (12 > 10)
|
||||
"PLR2004", # Magic value used in comparison
|
||||
]
|
||||
|
||||
[tool.ruff.lint.flake8-copyright]
|
||||
notice-rgx = "SPDX-FileCopyrightText: .*"
|
||||
min-file-size = 256
|
||||
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
# combine-straight-imports = true # not (yet) supported by ruff
|
||||
|
||||
[tool.ruff.lint.pylint]
|
||||
# https://pylint.pycqa.org/en/latest/user_guide/configuration/all-options.html#design-checker
|
||||
# https://pylint.pycqa.org/en/latest/user_guide/checkers/features.html#design-checker-messages
|
||||
max-args = 5 # max. number of args for function / method (R0913)
|
||||
# max-attributes = 15 # max. number of instance attrs for a class (R0902)
|
||||
max-branches = 40 # max. number of branch for function / method body (R0912)
|
||||
max-locals = 30 # max. number of local vars for function / method body (R0914)
|
||||
max-returns = 15 # max. number of return / yield for function / method body (R0911)
|
||||
max-statements = 150 # max. number of statements in function / method body (R0915)
|
||||
max-public-methods = 20 # max. number of public methods for a class (R0904)
|
||||
# max-positional-arguments = 5 # max. number of positional args for function / method (R0917)
|
||||
|
||||
|
||||
#####################
|
||||
@@ -117,6 +274,7 @@ aggressive = 3
|
||||
# https://mypy.readthedocs.io/en/stable/config_file.html
|
||||
#mypy_path = "$MYPY_CONFIG_FILE_DIR/tests/stubs"
|
||||
python_version = "3.10"
|
||||
files = "scripts,src,tests"
|
||||
strict = true
|
||||
disallow_untyped_calls = false
|
||||
disallow_untyped_defs = true
|
||||
@@ -128,84 +286,15 @@ verbosity = 0
|
||||
|
||||
|
||||
#####################
|
||||
# pylint
|
||||
# https://pypi.org/project/pylint/
|
||||
# https://github.com/PyCQA/pylint
|
||||
# basedpyright
|
||||
# https://github.com/detachhead/basedpyright
|
||||
#####################
|
||||
[tool.pylint.master]
|
||||
extension-pkg-whitelist = "win32api"
|
||||
ignore = "version.py"
|
||||
jobs = 4
|
||||
persistent = "no"
|
||||
|
||||
# https://pylint.pycqa.org/en/latest/user_guide/checkers/extensions.html
|
||||
load-plugins = [
|
||||
"pylint.extensions.bad_builtin",
|
||||
#"pylint.extensions.broad_try_clause",
|
||||
"pylint.extensions.check_elif",
|
||||
"pylint.extensions.code_style",
|
||||
"pylint.extensions.comparison_placement",
|
||||
#"pylint.extensions.confusing_elif",
|
||||
"pylint.extensions.consider_ternary_expression",
|
||||
"pylint.extensions.consider_refactoring_into_while_condition",
|
||||
"pylint.extensions.dict_init_mutate",
|
||||
"pylint.extensions.docstyle",
|
||||
#"pylint.extensions.docparams",
|
||||
"pylint.extensions.dunder",
|
||||
"pylint.extensions.empty_comment",
|
||||
"pylint.extensions.eq_without_hash",
|
||||
"pylint.extensions.for_any_all",
|
||||
#"pylint.extensions.magic_value",
|
||||
#"pylint.extensions.mccabe",
|
||||
"pylint.extensions.set_membership",
|
||||
"pylint.extensions.no_self_use",
|
||||
"pylint.extensions.overlapping_exceptions",
|
||||
"pylint.extensions.private_import",
|
||||
"pylint.extensions.redefined_loop_name",
|
||||
"pylint.extensions.redefined_variable_type",
|
||||
"pylint.extensions.set_membership",
|
||||
"pylint.extensions.typing",
|
||||
#"pylint.extensions.while_used"
|
||||
]
|
||||
|
||||
[tool.pylint.basic]
|
||||
good-names = ["i", "j", "k", "v", "by", "ex", "fd", "_", "T"]
|
||||
|
||||
[tool.pylint.format]
|
||||
# https://pylint.pycqa.org/en/latest/technical_reference/features.html#format-checker
|
||||
# https://pylint.pycqa.org/en/latest/user_guide/checkers/features.html#format-checker-messages
|
||||
max-line-length = 160 # maximum number of characters on a single line (C0301)
|
||||
max-module-lines = 2000 # maximum number of lines in a module (C0302)
|
||||
|
||||
[tool.pylint.logging]
|
||||
logging-modules = "logging"
|
||||
|
||||
[tool.pylint.messages_control]
|
||||
# https://pylint.pycqa.org/en/latest/technical_reference/features.html#messages-control-options
|
||||
disable= [
|
||||
"broad-except",
|
||||
"consider-using-assignment-expr",
|
||||
"docstring-first-line-empty",
|
||||
"missing-docstring",
|
||||
"multiple-imports",
|
||||
"multiple-statements",
|
||||
"no-self-use",
|
||||
"too-few-public-methods"
|
||||
]
|
||||
|
||||
[tool.pylint.miscelaneous]
|
||||
# https://pylint.pycqa.org/en/latest/user_guide/configuration/all-options.html#miscellaneous-checker
|
||||
notes = [ "FIXME", "XXX", "TODO" ] # list of note tags to take in consideration
|
||||
|
||||
[tool.pylint.design]
|
||||
# https://pylint.pycqa.org/en/latest/user_guide/configuration/all-options.html#design-checker
|
||||
# https://pylint.pycqa.org/en/latest/user_guide/checkers/features.html#design-checker-messages
|
||||
max-attributes = 15 # maximum number of instance attributes for a class (R0902)
|
||||
max-branches = 30 # maximum number of branch for function / method body (R0912)
|
||||
max-locals = 30 # maximum number of local variables for function / method body (R0914)
|
||||
max-returns = 10 # maximum number of return / yield for function / method body (R0911)
|
||||
max-statements = 100 # maximum number of statements in function / method body (R0915)
|
||||
max-public-methods = 30 # maximum number of public methods for a class (R0904)
|
||||
[tool.basedpyright]
|
||||
# https://docs.basedpyright.com/latest/configuration/config-files/
|
||||
include = ["scripts", "src", "tests"]
|
||||
defineConstant = { DEBUG = false }
|
||||
pythonVersion = "3.10"
|
||||
typeCheckingMode = "standard"
|
||||
|
||||
|
||||
#####################
|
||||
@@ -213,8 +302,44 @@ max-public-methods = 30 # maximum number of public methods for a class (R0904)
|
||||
# https://pypi.org/project/pytest/
|
||||
#####################
|
||||
[tool.pytest.ini_options]
|
||||
# https://docs.pytest.org/en/stable/reference.html#confval-addopts
|
||||
addopts = "--strict-markers -p no:cacheprovider --doctest-modules --ignore=kleinanzeigen_bot/__main__.py"
|
||||
markers = [
|
||||
"itest: marks a test as an integration test (i.e. a test with external dependencies)"
|
||||
testpaths = [
|
||||
"src",
|
||||
"tests"
|
||||
]
|
||||
# https://docs.pytest.org/en/stable/reference.html#confval-addopts
|
||||
addopts = """
|
||||
--strict-markers
|
||||
-p no:cacheprovider
|
||||
--doctest-modules
|
||||
--cov-report=term-missing
|
||||
"""
|
||||
markers = [
|
||||
"itest: marks a test as an integration test (i.e. a test with external dependencies)",
|
||||
"asyncio: mark test as async"
|
||||
]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
filterwarnings = [
|
||||
"ignore:Exception ignored in:pytest.PytestUnraisableExceptionWarning",
|
||||
"ignore::DeprecationWarning"
|
||||
]
|
||||
|
||||
|
||||
#####################
|
||||
# yamlfix
|
||||
# https://lyz-code.github.io/yamlfix/
|
||||
#####################
|
||||
[tool.yamlfix]
|
||||
allow_duplicate_keys = true
|
||||
comments_min_spaces_from_content = 2
|
||||
comments_require_starting_space = false # FIXME should be true but rule is buggy
|
||||
comments_whitelines = 1
|
||||
section_whitelines = 1
|
||||
explicit_start = false
|
||||
indentation = 2
|
||||
line_length = 1024
|
||||
preserve_quotes = true
|
||||
quote_basic_values = false
|
||||
quote_keys_and_basic_values = false
|
||||
quote_representation = '"'
|
||||
whitelines = 1
|
||||
|
||||
317
scripts/post_autopep8.py
Normal file
317
scripts/post_autopep8.py
Normal file
@@ -0,0 +1,317 @@
|
||||
# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
import ast, logging, re, sys # isort: skip
|
||||
from pathlib import Path
|
||||
from typing import Final, List, Protocol, Tuple
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
# Configure basic logging
|
||||
logging.basicConfig(level = logging.INFO, format = "%(levelname)s: %(message)s")
|
||||
LOG:Final[logging.Logger] = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FormatterRule(Protocol):
|
||||
"""
|
||||
A code processor that can modify source lines based on the AST.
|
||||
"""
|
||||
|
||||
def apply(self, tree:ast.AST, lines:List[str], path:Path) -> List[str]:
|
||||
...
|
||||
|
||||
|
||||
class NoSpaceAfterColonInTypeAnnotationRule(FormatterRule):
|
||||
"""
|
||||
Removes whitespace between the colon (:) and the type annotation in variable and function parameter declarations.
|
||||
|
||||
This rule enforces `a:int` instead of `a: int`.
|
||||
It is the opposite behavior of autopep8 rule E231.
|
||||
|
||||
Example:
|
||||
# Before
|
||||
def foo(a: int, b : str) -> None:
|
||||
pass
|
||||
|
||||
# After
|
||||
def foo(a:int, b:str) -> None:
|
||||
pass
|
||||
"""
|
||||
|
||||
@override
|
||||
def apply(self, tree:ast.AST, lines:List[str], path:Path) -> List[str]:
|
||||
ann_positions:List[Tuple[int, int]] = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.arg) and node.annotation is not None:
|
||||
ann_positions.append((node.annotation.lineno - 1, node.annotation.col_offset))
|
||||
elif isinstance(node, ast.AnnAssign) and node.annotation is not None:
|
||||
ann = node.annotation
|
||||
ann_positions.append((ann.lineno - 1, ann.col_offset))
|
||||
|
||||
if not ann_positions:
|
||||
return lines
|
||||
|
||||
new_lines:List[str] = []
|
||||
for idx, line in enumerate(lines):
|
||||
if line.lstrip().startswith("#"):
|
||||
new_lines.append(line)
|
||||
continue
|
||||
|
||||
chars = list(line)
|
||||
offsets = [col for (lin, col) in ann_positions if lin == idx]
|
||||
for col in sorted(offsets, reverse = True):
|
||||
prefix = "".join(chars[:col])
|
||||
colon_idx = prefix.rfind(":")
|
||||
if colon_idx == -1:
|
||||
continue
|
||||
j = colon_idx + 1
|
||||
while j < len(chars) and chars[j].isspace():
|
||||
del chars[j]
|
||||
new_lines.append("".join(chars))
|
||||
|
||||
return new_lines
|
||||
|
||||
|
||||
class EqualSignSpacingInDefaultsAndNamedArgsRule(FormatterRule):
|
||||
"""
|
||||
Ensures that the '=' sign in default values for function parameters and keyword arguments in function calls
|
||||
is surrounded by exactly one space on each side.
|
||||
|
||||
This rule enforces `a:int = 3` instead of `a:int=3`, and `x = 42` instead of `x=42` or `x =42`.
|
||||
It is the opposite behavior of autopep8 rule E251.
|
||||
|
||||
Example:
|
||||
# Before
|
||||
def foo(a:int=3, b :str= "bar"):
|
||||
pass
|
||||
|
||||
foo(x=42,y = "hello")
|
||||
|
||||
# After
|
||||
def foo(a:int = 3, b:str = "bar"):
|
||||
pass
|
||||
|
||||
foo(x = 42, y = "hello")
|
||||
"""
|
||||
|
||||
@override
|
||||
def apply(self, tree:ast.AST, lines:List[str], path:Path) -> List[str]:
|
||||
equals_positions:List[Tuple[int, int]] = []
|
||||
for node in ast.walk(tree):
|
||||
# --- Defaults in function definitions, async defs & lambdas ---
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
|
||||
# positional defaults
|
||||
equals_positions.extend(
|
||||
(d.lineno - 1, d.col_offset)
|
||||
for d in node.args.defaults
|
||||
if d is not None
|
||||
)
|
||||
# keyword-only defaults (only on defs, not lambdas)
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
equals_positions.extend(
|
||||
(d.lineno - 1, d.col_offset)
|
||||
for d in node.args.kw_defaults
|
||||
if d is not None
|
||||
)
|
||||
|
||||
# --- Keyword arguments in calls ---
|
||||
if isinstance(node, ast.Call):
|
||||
equals_positions.extend(
|
||||
(kw.value.lineno - 1, kw.value.col_offset)
|
||||
for kw in node.keywords
|
||||
if kw.arg is not None
|
||||
)
|
||||
|
||||
if not equals_positions:
|
||||
return lines
|
||||
|
||||
new_lines:List[str] = []
|
||||
for line_idx, line in enumerate(lines):
|
||||
if line.lstrip().startswith("#"):
|
||||
new_lines.append(line)
|
||||
continue
|
||||
|
||||
chars = list(line)
|
||||
equals_offsets = [col for (lineno, col) in equals_positions if lineno == line_idx]
|
||||
for col in sorted(equals_offsets, reverse = True):
|
||||
prefix = "".join(chars[:col])
|
||||
equal_sign_idx = prefix.rfind("=")
|
||||
if equal_sign_idx == -1:
|
||||
continue
|
||||
|
||||
# remove spaces before '='
|
||||
left_index = equal_sign_idx - 1
|
||||
while left_index >= 0 and chars[left_index].isspace():
|
||||
del chars[left_index]
|
||||
equal_sign_idx -= 1
|
||||
left_index -= 1
|
||||
|
||||
# remove spaces after '='
|
||||
right_index = equal_sign_idx + 1
|
||||
while right_index < len(chars) and chars[right_index].isspace():
|
||||
del chars[right_index]
|
||||
|
||||
# insert single spaces
|
||||
chars.insert(equal_sign_idx, " ")
|
||||
chars.insert(equal_sign_idx + 2, " ")
|
||||
new_lines.append("".join(chars))
|
||||
|
||||
return new_lines
|
||||
|
||||
|
||||
class PreferDoubleQuotesRule(FormatterRule):
|
||||
"""
|
||||
Ensures string literals use double quotes unless the content contains a double quote.
|
||||
|
||||
Example:
|
||||
# Before
|
||||
foo = 'hello'
|
||||
bar = 'a "quote" inside'
|
||||
|
||||
# After
|
||||
foo = "hello"
|
||||
bar = 'a "quote" inside' # kept as-is, because it contains a double quote
|
||||
"""
|
||||
|
||||
@override
|
||||
def apply(self, tree:ast.AST, lines:List[str], path:Path) -> List[str]:
|
||||
new_lines = lines.copy()
|
||||
|
||||
# Track how much each line has shifted so far
|
||||
line_shifts:dict[int, int] = dict.fromkeys(range(len(lines)), 0)
|
||||
|
||||
# Build a parent map for f-string detection
|
||||
parent_map:dict[ast.AST, ast.AST] = {}
|
||||
for parent in ast.walk(tree):
|
||||
for child in ast.iter_child_nodes(parent):
|
||||
parent_map[child] = parent
|
||||
|
||||
def is_in_fstring(node:ast.AST) -> bool:
|
||||
p = parent_map.get(node)
|
||||
while p:
|
||||
if isinstance(p, ast.JoinedStr):
|
||||
return True
|
||||
p = parent_map.get(p)
|
||||
return False
|
||||
|
||||
# Regex to locate a single- or triple-quoted literal:
|
||||
# (?P<prefix>[rRbuUfF]*) optional string flags (r, b, u, f, etc.), case-insensitive
|
||||
# (?P<quote>'{3}|') the opening delimiter: either three single-quotes (''') or one ('),
|
||||
# but never two in a row (so we won't mis-interpret adjacent quotes)
|
||||
# (?P<content>.*?) the literal's content, non-greedy up to the next same delimiter
|
||||
# (?P=quote) the matching closing delimiter (same length as the opener)
|
||||
literal_re = re.compile(
|
||||
r"(?P<prefix>[rRbuUfF]*)(?P<quote>'{3}|')(?P<content>.*?)(?P=quote)",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
for node in ast.walk(tree):
|
||||
# only handle simple string constants
|
||||
if not (isinstance(node, ast.Constant) and isinstance(node.value, str)):
|
||||
continue
|
||||
|
||||
# skip anything inside an f-string, at any depth
|
||||
if is_in_fstring(node):
|
||||
continue
|
||||
|
||||
starting_line_number = getattr(node, "lineno", None)
|
||||
starting_col_offset = getattr(node, "col_offset", None)
|
||||
if starting_line_number is None or starting_col_offset is None:
|
||||
continue
|
||||
|
||||
start_line = starting_line_number - 1
|
||||
shift = line_shifts[start_line]
|
||||
raw = new_lines[start_line]
|
||||
# apply shift so we match against current edited line
|
||||
idx = starting_col_offset + shift
|
||||
if idx >= len(raw) or raw[idx] not in {"'", "r", "u", "b", "f", "R", "U", "B", "F"}:
|
||||
continue
|
||||
|
||||
# match literal at that column
|
||||
m = literal_re.match(raw[idx:])
|
||||
if not m:
|
||||
continue
|
||||
|
||||
prefix = m.group("prefix")
|
||||
quote = m.group("quote") # either "'" or "'''"
|
||||
content = m.group("content") # what's inside
|
||||
|
||||
# skip if content has a double-quote already
|
||||
if '"' in content:
|
||||
continue
|
||||
|
||||
# build new literal with the same prefix, but double‐quote delimiter
|
||||
delim = '"' * len(quote)
|
||||
escaped = content.replace(delim, "\\" + delim)
|
||||
new_literal = f"{prefix}{delim}{escaped}{delim}"
|
||||
|
||||
literal_len = m.end() # how many chars we're replacing
|
||||
before = raw[:idx]
|
||||
after = raw[idx + literal_len:]
|
||||
new_lines[start_line] = before + new_literal + after
|
||||
|
||||
# record shift delta for any further edits on this line
|
||||
line_shifts[start_line] += len(new_literal) - literal_len
|
||||
|
||||
return new_lines
|
||||
|
||||
|
||||
FORMATTER_RULES:List[FormatterRule] = [
|
||||
NoSpaceAfterColonInTypeAnnotationRule(),
|
||||
EqualSignSpacingInDefaultsAndNamedArgsRule(),
|
||||
PreferDoubleQuotesRule(),
|
||||
]
|
||||
|
||||
|
||||
def format_file(path:Path) -> None:
|
||||
# Read without newline conversion
|
||||
with path.open("r", encoding = "utf-8", newline = "") as rf:
|
||||
original_text = rf.read()
|
||||
|
||||
# Initial parse
|
||||
try:
|
||||
tree = ast.parse(original_text)
|
||||
except SyntaxError as e:
|
||||
LOG.error(
|
||||
"Syntax error parsing %s[%d:%d]: %r -> %s",
|
||||
path, e.lineno, e.offset, (e.text or "").rstrip(), e.msg
|
||||
)
|
||||
return
|
||||
|
||||
lines = original_text.splitlines(keepends = True)
|
||||
formatted_text = original_text
|
||||
success = True
|
||||
for rule in FORMATTER_RULES:
|
||||
lines = rule.apply(tree, lines, path)
|
||||
formatted_text = "".join(lines)
|
||||
|
||||
# Re-parse the updated text
|
||||
try:
|
||||
tree = ast.parse(formatted_text)
|
||||
except SyntaxError as e:
|
||||
LOG.error(
|
||||
"Syntax error after %s at %s[%d:%d]: %r -> %s",
|
||||
rule.__class__.__name__, path, e.lineno, e.offset, (e.text or "").rstrip(), e.msg
|
||||
)
|
||||
success = False
|
||||
break
|
||||
|
||||
if success and formatted_text != original_text:
|
||||
with path.open("w", encoding = "utf-8", newline = "") as wf:
|
||||
wf.write(formatted_text)
|
||||
LOG.info("Formatted [%s].", path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2: # noqa: PLR2004 Magic value used in comparison
|
||||
script_path = Path(sys.argv[0])
|
||||
print(f"Usage: python {script_path} <directory1> [<directory2> ...]")
|
||||
sys.exit(1)
|
||||
|
||||
for dir_arg in sys.argv[1:]:
|
||||
root = Path(dir_arg)
|
||||
if not root.exists():
|
||||
LOG.warning("Directory [%s] does not exist, skipping...", root)
|
||||
continue
|
||||
for py_file in root.rglob("*.py"):
|
||||
format_file(py_file)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,22 @@
|
||||
"""
|
||||
SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
"""
|
||||
import sys
|
||||
import kleinanzeigen_bot
|
||||
# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
import sys, time # isort: skip
|
||||
from gettext import gettext as _
|
||||
|
||||
kleinanzeigen_bot.main(sys.argv)
|
||||
import kleinanzeigen_bot
|
||||
from kleinanzeigen_bot.utils.exceptions import CaptchaEncountered
|
||||
from kleinanzeigen_bot.utils.misc import format_timedelta
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Main loop: run bot → if captcha → sleep → restart
|
||||
# --------------------------------------------------------------------------- #
|
||||
while True:
|
||||
try:
|
||||
kleinanzeigen_bot.main(sys.argv) # runs & returns when finished
|
||||
sys.exit(0) # not using `break` to prevent process closing issues
|
||||
except CaptchaEncountered as ex:
|
||||
delay = ex.restart_delay
|
||||
print(_("[INFO] Captcha detected. Sleeping %s before restart...") % format_timedelta(delay))
|
||||
time.sleep(delay.total_seconds())
|
||||
# loop continues and starts a fresh run
|
||||
|
||||
88
src/kleinanzeigen_bot/ads.py
Normal file
88
src/kleinanzeigen_bot/ads.py
Normal file
@@ -0,0 +1,88 @@
|
||||
# SPDX-FileCopyrightText: © Jens Bergman and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
import hashlib, json, os # isort: skip
|
||||
from typing import Any, Final
|
||||
|
||||
from .utils import dicts
|
||||
|
||||
MAX_DESCRIPTION_LENGTH:Final[int] = 4000
|
||||
|
||||
|
||||
def calculate_content_hash(ad_cfg:dict[str, Any]) -> str:
|
||||
"""Calculate a hash for user-modifiable fields of the ad."""
|
||||
|
||||
# Relevant fields for the hash
|
||||
content = {
|
||||
"active": bool(ad_cfg.get("active", True)), # Explicitly convert to bool
|
||||
"type": str(ad_cfg.get("type", "")), # Explicitly convert to string
|
||||
"title": str(ad_cfg.get("title", "")),
|
||||
"description": str(ad_cfg.get("description", "")),
|
||||
"category": str(ad_cfg.get("category", "")),
|
||||
"price": str(ad_cfg.get("price", "")), # Price always as string
|
||||
"price_type": str(ad_cfg.get("price_type", "")),
|
||||
"special_attributes": dict(ad_cfg.get("special_attributes") or {}), # Handle None case
|
||||
"shipping_type": str(ad_cfg.get("shipping_type", "")),
|
||||
"shipping_costs": str(ad_cfg.get("shipping_costs", "")),
|
||||
"shipping_options": sorted([str(x) for x in (ad_cfg.get("shipping_options") or [])]), # Handle None case
|
||||
"sell_directly": bool(ad_cfg.get("sell_directly", False)), # Explicitly convert to bool
|
||||
"images": sorted([os.path.basename(str(img)) if img is not None else "" for img in (ad_cfg.get("images") or [])]), # Handle None values in images
|
||||
"contact": {
|
||||
"name": str(ad_cfg.get("contact", {}).get("name", "")),
|
||||
"street": str(ad_cfg.get("contact", {}).get("street", "")), # Changed from "None" to empty string for consistency
|
||||
"zipcode": str(ad_cfg.get("contact", {}).get("zipcode", "")),
|
||||
"phone": str(ad_cfg.get("contact", {}).get("phone", ""))
|
||||
}
|
||||
}
|
||||
|
||||
# Create sorted JSON string for consistent hashes
|
||||
content_str = json.dumps(content, sort_keys = True)
|
||||
return hashlib.sha256(content_str.encode()).hexdigest()
|
||||
|
||||
|
||||
def get_description_affixes(config:dict[str, Any], *, prefix:bool = True) -> str:
|
||||
"""Get prefix or suffix for description with proper precedence.
|
||||
|
||||
This function handles both the new flattened format and legacy nested format:
|
||||
|
||||
New format (flattened):
|
||||
ad_defaults:
|
||||
description_prefix: "Global Prefix"
|
||||
description_suffix: "Global Suffix"
|
||||
|
||||
Legacy format (nested):
|
||||
ad_defaults:
|
||||
description:
|
||||
prefix: "Legacy Prefix"
|
||||
suffix: "Legacy Suffix"
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary containing ad_defaults
|
||||
prefix: If True, get prefix, otherwise get suffix
|
||||
|
||||
Returns:
|
||||
The appropriate affix string, empty string if none found
|
||||
|
||||
Example:
|
||||
>>> config = {"ad_defaults": {"description_prefix": "Hello", "description": {"prefix": "Hi"}}}
|
||||
>>> get_description_affixes(config, prefix=True)
|
||||
'Hello'
|
||||
"""
|
||||
# Handle edge cases
|
||||
if not isinstance(config, dict):
|
||||
return ""
|
||||
|
||||
affix_type = "prefix" if prefix else "suffix"
|
||||
|
||||
# First try new flattened format (description_prefix/description_suffix)
|
||||
flattened_key = f"description_{affix_type}"
|
||||
flattened_value = dicts.safe_get(config, "ad_defaults", flattened_key)
|
||||
if isinstance(flattened_value, str):
|
||||
return flattened_value
|
||||
|
||||
# Then try legacy nested format (description.prefix/description.suffix)
|
||||
nested_value = dicts.safe_get(config, "ad_defaults", "description", affix_type)
|
||||
if isinstance(nested_value, str):
|
||||
return nested_value
|
||||
|
||||
return ""
|
||||
@@ -1,238 +1,521 @@
|
||||
"""
|
||||
SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
"""
|
||||
import json
|
||||
from decimal import DecimalException
|
||||
from typing import Any
|
||||
# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
import json, mimetypes, os, shutil # isort: skip
|
||||
import urllib.request as urllib_request
|
||||
from datetime import datetime
|
||||
from typing import Any, Final
|
||||
|
||||
from selenium.common.exceptions import NoSuchElementException
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.remote.webdriver import WebDriver
|
||||
import selenium.webdriver.support.expected_conditions as EC
|
||||
from .ads import calculate_content_hash, get_description_affixes
|
||||
from .utils import dicts, i18n, loggers, misc, reflect
|
||||
from .utils.web_scraping_mixin import Browser, By, Element, WebScrapingMixin
|
||||
|
||||
from .selenium_mixin import SeleniumMixin
|
||||
from .utils import parse_decimal, pause
|
||||
__all__ = [
|
||||
"AdExtractor",
|
||||
]
|
||||
|
||||
LOG:Final[loggers.Logger] = loggers.get_logger(__name__)
|
||||
|
||||
|
||||
class AdExtractor(SeleniumMixin):
|
||||
class AdExtractor(WebScrapingMixin):
|
||||
"""
|
||||
Wrapper class for ad extraction that uses an active bot´s web driver to extract specific elements from an ad page.
|
||||
Wrapper class for ad extraction that uses an active bot´s browser session to extract specific elements from an ad page.
|
||||
"""
|
||||
|
||||
def __init__(self, driver:WebDriver):
|
||||
def __init__(self, browser:Browser, config:dict[str, Any]) -> None:
|
||||
super().__init__()
|
||||
self.webdriver = driver
|
||||
self.browser = browser
|
||||
self.config = config
|
||||
|
||||
def extract_category_from_ad_page(self) -> str:
|
||||
async def download_ad(self, ad_id:int) -> None:
|
||||
"""
|
||||
Downloads an ad to a specific location, specified by config and ad ID.
|
||||
NOTE: Requires that the driver session currently is on the ad page.
|
||||
|
||||
:param ad_id: the ad ID
|
||||
"""
|
||||
|
||||
# create sub-directory for ad(s) to download (if necessary):
|
||||
relative_directory = "downloaded-ads"
|
||||
# make sure configured base directory exists
|
||||
if not os.path.exists(relative_directory) or not os.path.isdir(relative_directory):
|
||||
os.mkdir(relative_directory)
|
||||
LOG.info("Created ads directory at ./%s.", relative_directory)
|
||||
|
||||
new_base_dir = os.path.join(relative_directory, f"ad_{ad_id}")
|
||||
if os.path.exists(new_base_dir):
|
||||
LOG.info("Deleting current folder of ad %s...", ad_id)
|
||||
shutil.rmtree(new_base_dir)
|
||||
os.mkdir(new_base_dir)
|
||||
LOG.info("New directory for ad created at %s.", new_base_dir)
|
||||
|
||||
# call extraction function
|
||||
info = await self._extract_ad_page_info(new_base_dir, ad_id)
|
||||
ad_file_path = new_base_dir + "/" + f"ad_{ad_id}.yaml"
|
||||
dicts.save_dict(ad_file_path, info)
|
||||
|
||||
async def _download_images_from_ad_page(self, directory:str, ad_id:int) -> list[str]:
|
||||
"""
|
||||
Downloads all images of an ad.
|
||||
|
||||
:param directory: the path of the directory created for this ad
|
||||
:param ad_id: the ID of the ad to download the images from
|
||||
:return: the relative paths for all downloaded images
|
||||
"""
|
||||
|
||||
n_images:int
|
||||
img_paths = []
|
||||
try:
|
||||
# download all images from box
|
||||
image_box = await self.web_find(By.CLASS_NAME, "galleryimage-large")
|
||||
|
||||
n_images = len(await self.web_find_all(By.CSS_SELECTOR, ".galleryimage-element[data-ix]", parent = image_box))
|
||||
LOG.info("Found %s.", i18n.pluralize("image", n_images))
|
||||
|
||||
img_element:Element = await self.web_find(By.CSS_SELECTOR, "div:nth-child(1) > img", parent = image_box)
|
||||
img_fn_prefix = "ad_" + str(ad_id) + "__img"
|
||||
|
||||
img_nr = 1
|
||||
dl_counter = 0
|
||||
while img_nr <= n_images: # scrolling + downloading
|
||||
current_img_url = img_element.attrs["src"] # URL of the image
|
||||
if current_img_url is None:
|
||||
continue
|
||||
|
||||
with urllib_request.urlopen(current_img_url) as response: # noqa: S310 Audit URL open for permitted schemes.
|
||||
content_type = response.info().get_content_type()
|
||||
file_ending = mimetypes.guess_extension(content_type)
|
||||
img_path = f"{directory}/{img_fn_prefix}{img_nr}{file_ending}"
|
||||
with open(img_path, "wb") as f:
|
||||
shutil.copyfileobj(response, f)
|
||||
dl_counter += 1
|
||||
img_paths.append(img_path.rsplit("/", maxsplit = 1)[-1])
|
||||
|
||||
# navigate to next image (if exists)
|
||||
if img_nr < n_images:
|
||||
try:
|
||||
# click next button, wait, and re-establish reference
|
||||
await (await self.web_find(By.CLASS_NAME, "galleryimage--navigation--next")).click()
|
||||
new_div = await self.web_find(By.CSS_SELECTOR, f"div.galleryimage-element:nth-child({img_nr + 1})")
|
||||
img_element = await self.web_find(By.TAG_NAME, "img", parent = new_div)
|
||||
except TimeoutError:
|
||||
LOG.error("NEXT button in image gallery somehow missing, aborting image fetching.")
|
||||
break
|
||||
img_nr += 1
|
||||
LOG.info("Downloaded %s.", i18n.pluralize("image", dl_counter))
|
||||
|
||||
except TimeoutError: # some ads do not require images
|
||||
LOG.warning("No image area found. Continuing without downloading images.")
|
||||
|
||||
return img_paths
|
||||
|
||||
def extract_ad_id_from_ad_url(self, url:str) -> int:
|
||||
"""
|
||||
Extracts the ID of an ad, given by its reference link.
|
||||
|
||||
:param url: the URL to the ad page
|
||||
:return: the ad ID, a (ten-digit) integer number
|
||||
"""
|
||||
num_part = url.split("/")[-1] # suffix
|
||||
id_part = num_part.split("-")[0]
|
||||
|
||||
try:
|
||||
path = url.split("?", 1)[0] # Remove query string if present
|
||||
last_segment = path.rstrip("/").split("/")[-1] # Get last path component
|
||||
id_part = last_segment.split("-")[0] # Extract part before first hyphen
|
||||
return int(id_part)
|
||||
except (IndexError, ValueError) as ex:
|
||||
LOG.warning("Failed to extract ad ID from URL '%s': %s", url, ex)
|
||||
return -1
|
||||
|
||||
async def extract_own_ads_urls(self) -> list[str]:
|
||||
"""
|
||||
Extracts the references to all own ads.
|
||||
|
||||
:return: the links to your ad pages
|
||||
"""
|
||||
# navigate to "your ads" page
|
||||
await self.web_open("https://www.kleinanzeigen.de/m-meine-anzeigen.html")
|
||||
await self.web_sleep(2000, 3000) # Consider replacing with explicit waits later
|
||||
|
||||
# Try to find the main ad list container first
|
||||
try:
|
||||
ad_list_container = await self.web_find(By.ID, "my-manageitems-adlist")
|
||||
except TimeoutError:
|
||||
LOG.warning("Ad list container #my-manageitems-adlist not found. Maybe no ads present?")
|
||||
return []
|
||||
|
||||
# --- Pagination handling ---
|
||||
multi_page = False
|
||||
try:
|
||||
# Correct selector: Use uppercase '.Pagination'
|
||||
pagination_section = await self.web_find(By.CSS_SELECTOR, ".Pagination", timeout = 10) # Increased timeout slightly
|
||||
# Correct selector: Use 'aria-label'
|
||||
# Also check if the button is actually present AND potentially enabled (though enabled check isn't strictly necessary here, only for clicking later)
|
||||
next_buttons = await self.web_find_all(By.CSS_SELECTOR, 'button[aria-label="Nächste"]', parent = pagination_section)
|
||||
if next_buttons:
|
||||
# Check if at least one 'Nächste' button is not disabled (optional but good practice)
|
||||
enabled_next_buttons = [btn for btn in next_buttons if not btn.attrs.get("disabled")]
|
||||
if enabled_next_buttons:
|
||||
multi_page = True
|
||||
LOG.info("Multiple ad pages detected.")
|
||||
else:
|
||||
LOG.info("Next button found but is disabled. Assuming single effective page.")
|
||||
|
||||
else:
|
||||
LOG.info('No "Naechste" button found within pagination. Assuming single page.')
|
||||
except TimeoutError:
|
||||
# This will now correctly trigger only if the '.Pagination' div itself is not found
|
||||
LOG.info("No pagination controls found. Assuming single page.")
|
||||
except Exception as e:
|
||||
LOG.exception("Error during pagination detection: %s", e)
|
||||
LOG.info("Assuming single page due to error during pagination check.")
|
||||
# --- End Pagination Handling ---
|
||||
|
||||
refs:list[str] = []
|
||||
current_page = 1
|
||||
while True: # Loop reference extraction
|
||||
LOG.info("Extracting ads from page %s...", current_page)
|
||||
# scroll down to load dynamically if necessary
|
||||
await self.web_scroll_page_down()
|
||||
await self.web_sleep(2000, 3000) # Consider replacing with explicit waits
|
||||
|
||||
# Re-find the ad list container on the current page/state
|
||||
try:
|
||||
ad_list_container = await self.web_find(By.ID, "my-manageitems-adlist")
|
||||
list_items = await self.web_find_all(By.CLASS_NAME, "cardbox", parent = ad_list_container)
|
||||
LOG.info("Found %s ad items on page %s.", len(list_items), current_page)
|
||||
except TimeoutError:
|
||||
LOG.warning("Could not find ad list container or items on page %s.", current_page)
|
||||
break # Stop if ads disappear
|
||||
|
||||
# Extract references using the CORRECTED selector
|
||||
try:
|
||||
page_refs = [
|
||||
(await self.web_find(By.CSS_SELECTOR, "div.manageitems-item-ad h3 a.text-onSurface", parent = li)).attrs["href"]
|
||||
for li in list_items
|
||||
]
|
||||
refs.extend(page_refs)
|
||||
LOG.info("Successfully extracted %s refs from page %s.", len(page_refs), current_page)
|
||||
except Exception as e:
|
||||
# Log the error if extraction fails for some items, but try to continue
|
||||
LOG.exception("Error extracting refs on page %s: %s", current_page, e)
|
||||
|
||||
if not multi_page: # only one iteration for single-page overview
|
||||
break
|
||||
|
||||
# --- Navigate to next page ---
|
||||
try:
|
||||
# Find the pagination section again (scope might have changed after scroll/wait)
|
||||
pagination_section = await self.web_find(By.CSS_SELECTOR, ".Pagination", timeout = 5)
|
||||
# Find the "Next" button using the correct aria-label selector and ensure it's not disabled
|
||||
next_button_element = None
|
||||
possible_next_buttons = await self.web_find_all(By.CSS_SELECTOR, 'button[aria-label="Nächste"]', parent = pagination_section)
|
||||
for btn in possible_next_buttons:
|
||||
if not btn.attrs.get("disabled"): # Check if the button is enabled
|
||||
next_button_element = btn
|
||||
break # Found an enabled next button
|
||||
|
||||
if next_button_element:
|
||||
LOG.info("Navigating to next page...")
|
||||
await next_button_element.click()
|
||||
current_page += 1
|
||||
# Wait for page load - consider waiting for a specific element on the new page instead of fixed sleep
|
||||
await self.web_sleep(3000, 4000)
|
||||
else:
|
||||
LOG.info('Last ad overview page explored (no enabled "Naechste" button found).')
|
||||
break
|
||||
except TimeoutError:
|
||||
# This might happen if pagination disappears on the last page after loading
|
||||
LOG.info("No pagination controls found after scrolling/waiting. Assuming last page.")
|
||||
break
|
||||
except Exception as e:
|
||||
LOG.exception("Error during pagination navigation: %s", e)
|
||||
break
|
||||
# --- End Navigation ---
|
||||
|
||||
if not refs:
|
||||
LOG.warning("No ad URLs were extracted.")
|
||||
|
||||
return refs
|
||||
|
||||
async def navigate_to_ad_page(self, id_or_url: int | str) -> bool:
|
||||
"""
|
||||
Navigates to an ad page specified with an ad ID; or alternatively by a given URL.
|
||||
:return: whether the navigation to the ad page was successful
|
||||
"""
|
||||
if reflect.is_integer(id_or_url):
|
||||
# navigate to search page
|
||||
await self.web_open("https://www.kleinanzeigen.de/s-suchanfrage.html?keywords={0}".format(id_or_url))
|
||||
else:
|
||||
await self.web_open(str(id_or_url)) # navigate to URL directly given
|
||||
await self.web_sleep()
|
||||
|
||||
# handle the case that invalid ad ID given
|
||||
if self.page.url.endswith("k0"):
|
||||
LOG.error("There is no ad under the given ID.")
|
||||
return False
|
||||
|
||||
# close (warning) popup, if given
|
||||
try:
|
||||
await self.web_find(By.ID, "vap-ovrly-secure")
|
||||
LOG.warning("A popup appeared!")
|
||||
await self.web_click(By.CLASS_NAME, "mfp-close")
|
||||
await self.web_sleep()
|
||||
except TimeoutError:
|
||||
pass
|
||||
return True
|
||||
|
||||
async def _extract_ad_page_info(self, directory:str, ad_id:int) -> dict[str, Any]:
|
||||
"""
|
||||
Extracts all necessary information from an ad´s page.
|
||||
|
||||
:param directory: the path of the ad´s previously created directory
|
||||
:param ad_id: the ad ID, already extracted by a calling function
|
||||
:return: a dictionary with the keys as given in an ad YAML, and their respective values
|
||||
"""
|
||||
info:dict[str, Any] = {"active": True}
|
||||
|
||||
# extract basic info
|
||||
info["type"] = "OFFER" if "s-anzeige" in self.page.url else "WANTED"
|
||||
title:str = await self.web_text(By.ID, "viewad-title")
|
||||
LOG.info('Extracting information from ad with title "%s"', title)
|
||||
|
||||
info["category"] = await self._extract_category_from_ad_page()
|
||||
info["title"] = title
|
||||
|
||||
# Get raw description text
|
||||
raw_description = (await self.web_text(By.ID, "viewad-description-text")).strip()
|
||||
|
||||
# Get prefix and suffix from config
|
||||
prefix = get_description_affixes(self.config, prefix = True)
|
||||
suffix = get_description_affixes(self.config, prefix = False)
|
||||
|
||||
# Remove prefix and suffix if present
|
||||
description_text = raw_description
|
||||
if prefix and description_text.startswith(prefix.strip()):
|
||||
description_text = description_text[len(prefix.strip()):]
|
||||
if suffix and description_text.endswith(suffix.strip()):
|
||||
description_text = description_text[:-len(suffix.strip())]
|
||||
|
||||
info["description"] = description_text.strip()
|
||||
|
||||
info["special_attributes"] = await self._extract_special_attributes_from_ad_page()
|
||||
if "art_s" in info["special_attributes"]:
|
||||
# change e.g. category "161/172" to "161/172/lautsprecher_kopfhoerer"
|
||||
info["category"] = f"{info['category']}/{info['special_attributes']['art_s']}"
|
||||
del info["special_attributes"]["art_s"]
|
||||
if "schaden_s" in info["special_attributes"]:
|
||||
# change f to 'nein' and 't' to 'ja'
|
||||
info["special_attributes"]["schaden_s"] = info["special_attributes"]["schaden_s"].translate(str.maketrans({"t": "ja", "f": "nein"}))
|
||||
info["price"], info["price_type"] = await self._extract_pricing_info_from_ad_page()
|
||||
info["shipping_type"], info["shipping_costs"], info["shipping_options"] = await self._extract_shipping_info_from_ad_page()
|
||||
info["sell_directly"] = await self._extract_sell_directly_from_ad_page()
|
||||
info["images"] = await self._download_images_from_ad_page(directory, ad_id)
|
||||
info["contact"] = await self._extract_contact_from_ad_page()
|
||||
info["id"] = ad_id
|
||||
|
||||
try: # try different locations known for creation date element
|
||||
creation_date = await self.web_text(By.XPATH,
|
||||
"/html/body/div[1]/div[2]/div/section[2]/section/section/article/div[3]/div[2]/div[2]/div[1]/span")
|
||||
except TimeoutError:
|
||||
creation_date = await self.web_text(By.CSS_SELECTOR, "#viewad-extra-info > div:nth-child(1) > span:nth-child(2)")
|
||||
|
||||
# convert creation date to ISO format
|
||||
created_parts = creation_date.split(".")
|
||||
creation_date = created_parts[2] + "-" + created_parts[1] + "-" + created_parts[0] + " 00:00:00"
|
||||
creation_date = datetime.fromisoformat(creation_date).isoformat()
|
||||
info["created_on"] = creation_date
|
||||
info["updated_on"] = None # will be set later on
|
||||
|
||||
# Calculate the initial hash for the downloaded ad
|
||||
info["content_hash"] = calculate_content_hash(info)
|
||||
|
||||
return info
|
||||
|
||||
async def _extract_category_from_ad_page(self) -> str:
|
||||
"""
|
||||
Extracts a category of an ad in numerical form.
|
||||
Assumes that the web driver currently shows an ad page.
|
||||
|
||||
:return: a category string of form abc/def, where a-f are digits
|
||||
"""
|
||||
category_line = self.webdriver.find_element(By.XPATH, '//*[@id="vap-brdcrmb"]')
|
||||
category_first_part = category_line.find_element(By.XPATH, './/a[2]')
|
||||
category_second_part = category_line.find_element(By.XPATH, './/a[3]')
|
||||
cat_num_first = category_first_part.get_attribute('href').split('/')[-1][1:]
|
||||
cat_num_second = category_second_part.get_attribute('href').split('/')[-1][1:]
|
||||
category:str = cat_num_first + '/' + cat_num_second
|
||||
category_line = await self.web_find(By.ID, "vap-brdcrmb")
|
||||
category_first_part = await self.web_find(By.CSS_SELECTOR, "a:nth-of-type(2)", parent = category_line)
|
||||
category_second_part = await self.web_find(By.CSS_SELECTOR, "a:nth-of-type(3)", parent = category_line)
|
||||
cat_num_first = category_first_part.attrs["href"].split("/")[-1][1:]
|
||||
cat_num_second = category_second_part.attrs["href"].split("/")[-1][1:]
|
||||
category:str = cat_num_first + "/" + cat_num_second
|
||||
|
||||
return category
|
||||
|
||||
def extract_special_attributes_from_ad_page(self) -> dict[str, Any]:
|
||||
async def _extract_special_attributes_from_ad_page(self) -> dict[str, Any]:
|
||||
"""
|
||||
Extracts the special attributes from an ad page.
|
||||
If no items are available then special_attributes is empty
|
||||
|
||||
:return: a dictionary (possibly empty) where the keys are the attribute names, mapped to their values
|
||||
"""
|
||||
belen_conf = self.webdriver.execute_script("return window.BelenConf")
|
||||
belen_conf = await self.web_execute("window.BelenConf")
|
||||
|
||||
# e.g. "art_s:lautsprecher_kopfhoerer|condition_s:like_new|versand_s:t"
|
||||
special_attributes_str = belen_conf["universalAnalyticsOpts"]["dimensions"]["dimension108"]
|
||||
special_attributes = json.loads(special_attributes_str)
|
||||
if not isinstance(special_attributes, dict):
|
||||
raise ValueError(
|
||||
"Failed to parse special attributes from ad page."
|
||||
f"Expected a dictionary, but got a {type(special_attributes)}"
|
||||
)
|
||||
special_attributes = {k: v for k, v in special_attributes.items() if not k.endswith('.versand_s')}
|
||||
|
||||
special_attributes = dict(item.split(":") for item in special_attributes_str.split("|") if ":" in item)
|
||||
special_attributes = {k: v for k, v in special_attributes.items() if not k.endswith(".versand_s") and k != "versand_s"}
|
||||
return special_attributes
|
||||
|
||||
def extract_pricing_info_from_ad_page(self) -> tuple[float | None, str]:
|
||||
async def _extract_pricing_info_from_ad_page(self) -> tuple[float | None, str]:
|
||||
"""
|
||||
Extracts the pricing information (price and pricing type) from an ad page.
|
||||
|
||||
:return: the price of the offer (optional); and the pricing type
|
||||
"""
|
||||
try:
|
||||
price_str:str = self.webdriver.find_element(By.CLASS_NAME, 'boxedarticle--price').text
|
||||
price_type:str
|
||||
price:float | None = -1
|
||||
price_str:str = await self.web_text(By.ID, "viewad-price")
|
||||
price:int | None = None
|
||||
match price_str.split()[-1]:
|
||||
case '€':
|
||||
price_type = 'FIXED'
|
||||
price = float(parse_decimal(price_str.split()[0].replace('.', '')))
|
||||
case 'VB': # can be either 'X € VB', or just 'VB'
|
||||
price_type = 'NEGOTIABLE'
|
||||
try:
|
||||
price = float(parse_decimal(price_str.split()[0].replace('.', '')))
|
||||
except DecimalException:
|
||||
price = None
|
||||
case 'verschenken':
|
||||
price_type = 'GIVE_AWAY'
|
||||
price = None
|
||||
case "€":
|
||||
price_type = "FIXED"
|
||||
# replace('.', '') is to remove the thousands separator before parsing as int
|
||||
price = int(price_str.replace(".", "").split()[0])
|
||||
case "VB":
|
||||
price_type = "NEGOTIABLE"
|
||||
if price_str != "VB": # can be either 'X € VB', or just 'VB'
|
||||
price = int(price_str.replace(".", "").split()[0])
|
||||
case "verschenken":
|
||||
price_type = "GIVE_AWAY"
|
||||
case _:
|
||||
price_type = 'NOT_APPLICABLE'
|
||||
price_type = "NOT_APPLICABLE"
|
||||
return price, price_type
|
||||
except NoSuchElementException: # no 'commercial' ad, has no pricing box etc.
|
||||
return None, 'NOT_APPLICABLE'
|
||||
except TimeoutError: # no 'commercial' ad, has no pricing box etc.
|
||||
return None, "NOT_APPLICABLE"
|
||||
|
||||
def extract_shipping_info_from_ad_page(self) -> tuple[str, float | None, list[str] | None]:
|
||||
async def _extract_shipping_info_from_ad_page(self) -> tuple[str, float | None, list[str] | None]:
|
||||
"""
|
||||
Extracts shipping information from an ad page.
|
||||
|
||||
:return: the shipping type, and the shipping price (optional)
|
||||
"""
|
||||
ship_type, ship_costs, shipping_options = 'NOT_APPLICABLE', None, None
|
||||
ship_type, ship_costs, shipping_options = "NOT_APPLICABLE", None, None
|
||||
try:
|
||||
shipping_text = self.webdriver.find_element(By.CSS_SELECTOR, '.boxedarticle--details--shipping') \
|
||||
.text.strip()
|
||||
shipping_text = await self.web_text(By.CLASS_NAME, "boxedarticle--details--shipping")
|
||||
# e.g. '+ Versand ab 5,49 €' OR 'Nur Abholung'
|
||||
if shipping_text == 'Nur Abholung':
|
||||
ship_type = 'PICKUP'
|
||||
elif shipping_text == 'Versand möglich':
|
||||
ship_type = 'SHIPPING'
|
||||
elif '€' in shipping_text:
|
||||
shipping_price_parts = shipping_text.split(' ')
|
||||
ship_type = 'SHIPPING'
|
||||
ship_costs = float(parse_decimal(shipping_price_parts[-2]))
|
||||
if shipping_text == "Nur Abholung":
|
||||
ship_type = "PICKUP"
|
||||
elif shipping_text == "Versand möglich":
|
||||
ship_type = "SHIPPING"
|
||||
elif "€" in shipping_text:
|
||||
shipping_price_parts = shipping_text.split(" ")
|
||||
ship_type = "SHIPPING"
|
||||
ship_costs = float(misc.parse_decimal(shipping_price_parts[-2]))
|
||||
|
||||
# extract shipping options
|
||||
# It is only possible the extract the cheapest shipping option,
|
||||
# as the other options are not shown
|
||||
# reading shipping option from kleinanzeigen
|
||||
# and find the right one by price
|
||||
shipping_costs = json.loads(
|
||||
(await self.web_request("https://gateway.kleinanzeigen.de/postad/api/v1/shipping-options?posterType=PRIVATE"))
|
||||
["content"])["data"]["shippingOptionsResponse"]["options"]
|
||||
|
||||
# map to internal shipping identifiers used by kleinanzeigen-bot
|
||||
shipping_option_mapping = {
|
||||
"DHL_2": "5,49",
|
||||
"Hermes_Päckchen": "4,50",
|
||||
"Hermes_S": "4,95",
|
||||
"DHL_5": "6,99",
|
||||
"Hermes_M": "5,95",
|
||||
"DHL_10": "9,49",
|
||||
"DHL_31,5": "16,49",
|
||||
"Hermes_L": "10,95",
|
||||
"DHL_001": "DHL_2",
|
||||
"DHL_002": "DHL_5",
|
||||
"DHL_003": "DHL_10",
|
||||
"DHL_004": "DHL_31,5",
|
||||
"DHL_005": "DHL_20",
|
||||
"HERMES_001": "Hermes_Päckchen",
|
||||
"HERMES_002": "Hermes_S",
|
||||
"HERMES_003": "Hermes_M",
|
||||
"HERMES_004": "Hermes_L"
|
||||
}
|
||||
for shipping_option, shipping_price in shipping_option_mapping.items():
|
||||
if shipping_price in shipping_text:
|
||||
shipping_options = [shipping_option]
|
||||
break
|
||||
except NoSuchElementException: # no pricing box -> no shipping given
|
||||
ship_type = 'NOT_APPLICABLE'
|
||||
|
||||
# Convert Euro to cents and round to nearest integer
|
||||
price_in_cent = round(ship_costs * 100)
|
||||
|
||||
# Get excluded shipping options from config
|
||||
excluded_options = self.config.get("download", {}).get("excluded_shipping_options", [])
|
||||
|
||||
# If include_all_matching_shipping_options is enabled, get all options for the same package size
|
||||
if self.config.get("download", {}).get("include_all_matching_shipping_options", False):
|
||||
# Find all options with the same price to determine the package size
|
||||
matching_options = [opt for opt in shipping_costs if opt["priceInEuroCent"] == price_in_cent]
|
||||
if not matching_options:
|
||||
return "NOT_APPLICABLE", ship_costs, shipping_options
|
||||
|
||||
# Use the package size of the first matching option
|
||||
matching_size = matching_options[0]["packageSize"]
|
||||
|
||||
# Get all options of the same size
|
||||
shipping_options = [
|
||||
shipping_option_mapping[opt["id"]]
|
||||
for opt in shipping_costs
|
||||
if opt["packageSize"] == matching_size
|
||||
and opt["id"] in shipping_option_mapping
|
||||
and shipping_option_mapping[opt["id"]] not in excluded_options
|
||||
]
|
||||
else:
|
||||
# Only use the matching option if it's not excluded
|
||||
matching_option = next((x for x in shipping_costs if x["priceInEuroCent"] == price_in_cent), None)
|
||||
if not matching_option:
|
||||
return "NOT_APPLICABLE", ship_costs, shipping_options
|
||||
|
||||
shipping_option = shipping_option_mapping.get(matching_option["id"])
|
||||
if not shipping_option or shipping_option in excluded_options:
|
||||
return "NOT_APPLICABLE", ship_costs, shipping_options
|
||||
shipping_options = [shipping_option]
|
||||
|
||||
except TimeoutError: # no pricing box -> no shipping given
|
||||
ship_type = "NOT_APPLICABLE"
|
||||
|
||||
return ship_type, ship_costs, shipping_options
|
||||
|
||||
def extract_sell_directly_from_ad_page(self) -> bool | None:
|
||||
async def _extract_sell_directly_from_ad_page(self) -> bool | None:
|
||||
"""
|
||||
Extracts the sell directly option from an ad page.
|
||||
|
||||
:return: a boolean indicating whether the sell directly option is active (optional)
|
||||
"""
|
||||
try:
|
||||
buy_now_is_active = self.webdriver.find_element(By.ID, 'j-buy-now').text == "Direkt kaufen"
|
||||
buy_now_is_active:bool = "Direkt kaufen" in (await self.web_text(By.ID, "payment-buttons-sidebar"))
|
||||
return buy_now_is_active
|
||||
except NoSuchElementException:
|
||||
except TimeoutError:
|
||||
return None
|
||||
|
||||
def extract_contact_from_ad_page(self) -> dict[str, (str | None)]:
|
||||
async def _extract_contact_from_ad_page(self) -> dict[str, (str | None)]:
|
||||
"""
|
||||
Processes the address part involving street (optional), zip code + city, and phone number (optional).
|
||||
|
||||
:return: a dictionary containing the address parts with their corresponding values
|
||||
"""
|
||||
contact:dict[str, (str | None)] = {}
|
||||
address_element = self.webdriver.find_element(By.CSS_SELECTOR, '#viewad-locality')
|
||||
address_text = address_element.text.strip()
|
||||
address_text = await self.web_text(By.ID, "viewad-locality")
|
||||
# format: e.g. (Beispiel Allee 42,) 12345 Bundesland - Stadt
|
||||
try:
|
||||
street_element = self.webdriver.find_element(By.XPATH, '//*[@id="street-address"]')
|
||||
street = street_element.text[:-2] # trailing comma and whitespace
|
||||
contact['street'] = street
|
||||
except NoSuchElementException:
|
||||
print('No street given in the contact.')
|
||||
# construct remaining address
|
||||
address_halves = address_text.split(' - ')
|
||||
address_left_parts = address_halves[0].split(' ') # zip code and region/city
|
||||
contact['zipcode'] = address_left_parts[0]
|
||||
street = (await self.web_text(By.ID, "street-address"))[:-1] # trailing comma
|
||||
contact["street"] = street
|
||||
except TimeoutError:
|
||||
LOG.info("No street given in the contact.")
|
||||
|
||||
contact_person_element = self.webdriver.find_element(By.CSS_SELECTOR, '#viewad-contact')
|
||||
name_element = contact_person_element.find_element(By.CLASS_NAME, 'iconlist-text')
|
||||
(zipcode, location) = address_text.split(" ", 1)
|
||||
contact["zipcode"] = zipcode # e.g. 19372
|
||||
contact["location"] = location # e.g. Mecklenburg-Vorpommern - Steinbeck
|
||||
|
||||
contact_person_element:Element = await self.web_find(By.ID, "viewad-contact")
|
||||
name_element = await self.web_find(By.CLASS_NAME, "iconlist-text", parent = contact_person_element)
|
||||
try:
|
||||
name = name_element.find_element(By.TAG_NAME, 'a').text
|
||||
except NoSuchElementException: # edge case: name without link
|
||||
name = name_element.find_element(By.TAG_NAME, 'span').text
|
||||
contact['name'] = name
|
||||
name = await self.web_text(By.TAG_NAME, "a", parent = name_element)
|
||||
except TimeoutError: # edge case: name without link
|
||||
name = await self.web_text(By.TAG_NAME, "span", parent = name_element)
|
||||
contact["name"] = name
|
||||
|
||||
if 'street' not in contact:
|
||||
contact['street'] = None
|
||||
if "street" not in contact:
|
||||
contact["street"] = None
|
||||
try: # phone number is unusual for non-professional sellers today
|
||||
phone_element = self.webdriver.find_element(By.CSS_SELECTOR, '#viewad-contact-phone')
|
||||
phone_number = phone_element.find_element(By.TAG_NAME, 'a').text
|
||||
contact['phone'] = ''.join(phone_number.replace('-', ' ').split(' ')).replace('+49(0)', '0')
|
||||
except NoSuchElementException:
|
||||
contact['phone'] = None # phone seems to be a deprecated feature (for non-professional users)
|
||||
phone_element = await self.web_find(By.ID, "viewad-contact-phone")
|
||||
phone_number = await self.web_text(By.TAG_NAME, "a", parent = phone_element)
|
||||
contact["phone"] = "".join(phone_number.replace("-", " ").split(" ")).replace("+49(0)", "0")
|
||||
except TimeoutError:
|
||||
contact["phone"] = None # phone seems to be a deprecated feature (for non-professional users)
|
||||
# also see 'https://themen.kleinanzeigen.de/hilfe/deine-anzeigen/Telefon/
|
||||
|
||||
return contact
|
||||
|
||||
def extract_own_ads_references(self) -> list[str]:
|
||||
"""
|
||||
Extracts the references to all own ads.
|
||||
|
||||
:return: the links to your ad pages
|
||||
"""
|
||||
# navigate to your ads page
|
||||
self.webdriver.get('https://www.kleinanzeigen.de/m-meine-anzeigen.html')
|
||||
self.web_await(EC.url_contains('meine-anzeigen'), 15)
|
||||
pause(2000, 3000)
|
||||
|
||||
# collect ad references:
|
||||
|
||||
pagination_section = self.webdriver.find_element(By.CSS_SELECTOR, '.l-splitpage')\
|
||||
.find_element(By.XPATH, './/section[4]')
|
||||
# scroll down to load dynamically
|
||||
self.web_scroll_page_down()
|
||||
pause(2000, 3000)
|
||||
# detect multi-page
|
||||
try:
|
||||
pagination = pagination_section.find_element(By.XPATH, './/div/div[2]/div[2]/div') # Pagination
|
||||
except NoSuchElementException: # 0 ads - no pagination area
|
||||
print('There currently seem to be no ads on your profile!')
|
||||
return []
|
||||
|
||||
n_buttons = len(pagination.find_element(By.XPATH, './/div[1]').find_elements(By.TAG_NAME, 'button'))
|
||||
multi_page:bool
|
||||
if n_buttons > 1:
|
||||
multi_page = True
|
||||
print('It seems like you have many ads!')
|
||||
else:
|
||||
multi_page = False
|
||||
print('It seems like all your ads fit on one overview page.')
|
||||
|
||||
refs:list[str] = []
|
||||
while True: # loop reference extraction until no more forward page
|
||||
# extract references
|
||||
list_section = self.webdriver.find_element(By.XPATH, '//*[@id="my-manageads-adlist"]')
|
||||
list_items = list_section.find_elements(By.CLASS_NAME, 'cardbox')
|
||||
refs += [li.find_element(By.XPATH, 'article/section/section[2]/h2/div/a').get_attribute('href') for li in list_items]
|
||||
|
||||
if not multi_page: # only one iteration for single-page overview
|
||||
break
|
||||
# check if last page
|
||||
nav_button = self.webdriver.find_elements(By.CSS_SELECTOR, 'button.jsx-2828608826')[-1]
|
||||
if nav_button.get_attribute('title') != 'Nächste':
|
||||
print('Last ad overview page explored.')
|
||||
break
|
||||
# navigate to next overview page
|
||||
nav_button.click()
|
||||
pause(2000, 3000)
|
||||
self.web_scroll_page_down()
|
||||
|
||||
return refs
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
active: # one of: true, false
|
||||
type: # one of: OFFER, WANTED
|
||||
active: # one of: true, false
|
||||
type: # one of: OFFER, WANTED
|
||||
title:
|
||||
description:
|
||||
category:
|
||||
special_attributes: {}
|
||||
price:
|
||||
price_type: # one of: FIXED, NEGOTIABLE, GIVE_AWAY, NOT_APPLICABLE
|
||||
shipping_type: # one of: PICKUP, SHIPPING, NOT_APPLICABLE
|
||||
price_type: # one of: FIXED, NEGOTIABLE, GIVE_AWAY, NOT_APPLICABLE
|
||||
shipping_type: # one of: PICKUP, SHIPPING, NOT_APPLICABLE
|
||||
shipping_costs:
|
||||
shipping_options: [] # see README.md for more information
|
||||
sell_directly: # requires shipping_options to take effect
|
||||
shipping_options: [] # see README.md for more information
|
||||
sell_directly: # requires shipping_options to take effect
|
||||
images: []
|
||||
|
||||
contact:
|
||||
name:
|
||||
street:
|
||||
zipcode:
|
||||
phone:
|
||||
|
||||
republication_interval:
|
||||
id:
|
||||
created_on:
|
||||
|
||||
@@ -1,198 +1,582 @@
|
||||
# Elektronik
|
||||
Auto, Rad & Boot: 210/241
|
||||
|
||||
Auto, Rad & Boot > Autos: 210/216/sonstige_autos
|
||||
Auto, Rad & Boot > Autos > Alfa Romeo: 210/216/alfa_romeo
|
||||
Auto, Rad & Boot > Autos > Audi: 210/216/audi
|
||||
Auto, Rad & Boot > Autos > BMW: 210/216/bmw
|
||||
Auto, Rad & Boot > Autos > Chevrolet: 210/216/chevrolet
|
||||
Auto, Rad & Boot > Autos > Chrysler: 210/216/chrysler
|
||||
Auto, Rad & Boot > Autos > Citroen: 210/216/citroen
|
||||
Auto, Rad & Boot > Autos > Dacia: 210/216/dacia
|
||||
Auto, Rad & Boot > Autos > Daewoo: 210/216/daewoo
|
||||
Auto, Rad & Boot > Autos > Daihatsu: 210/216/daihatsu
|
||||
Auto, Rad & Boot > Autos > Fiat: 210/216/fiat
|
||||
Auto, Rad & Boot > Autos > Ford: 210/216/ford
|
||||
Auto, Rad & Boot > Autos > Honda: 210/216/honda
|
||||
Auto, Rad & Boot > Autos > Hyundai: 210/216/hyundai
|
||||
Auto, Rad & Boot > Autos > Jaguar: 210/216/jaguar
|
||||
Auto, Rad & Boot > Autos > Jeep: 210/216/jeep
|
||||
Auto, Rad & Boot > Autos > Kia: 210/216/kia
|
||||
Auto, Rad & Boot > Autos > Lada: 210/216/lada
|
||||
Auto, Rad & Boot > Autos > Lancia: 210/216/lancia
|
||||
Auto, Rad & Boot > Autos > Land Rover: 210/216/land_rover
|
||||
Auto, Rad & Boot > Autos > Lexus: 210/216/lexus
|
||||
Auto, Rad & Boot > Autos > Mazda: 210/216/mazda
|
||||
Auto, Rad & Boot > Autos > Mercedes Benz: 210/216/mercedes_benz
|
||||
Auto, Rad & Boot > Autos > Mini: 210/216/mini
|
||||
Auto, Rad & Boot > Autos > Mitsubishi: 210/216/mitsubishi
|
||||
Auto, Rad & Boot > Autos > Nissan: 210/216/nissan
|
||||
Auto, Rad & Boot > Autos > Opel: 210/216/opel
|
||||
Auto, Rad & Boot > Autos > Peugeot: 210/216/peugeot
|
||||
Auto, Rad & Boot > Autos > Porsche: 210/216/porsche
|
||||
Auto, Rad & Boot > Autos > Renault: 210/216/renault
|
||||
Auto, Rad & Boot > Autos > Rover: 210/216/rover
|
||||
Auto, Rad & Boot > Autos > Saab: 210/216/saab
|
||||
Auto, Rad & Boot > Autos > Seat: 210/216/seat
|
||||
Auto, Rad & Boot > Autos > Skoda: 210/216/skoda
|
||||
Auto, Rad & Boot > Autos > Smart: 210/216/smart
|
||||
Auto, Rad & Boot > Autos > Subaru: 210/216/subaru
|
||||
Auto, Rad & Boot > Autos > Suzuki: 210/216/suzuki
|
||||
Auto, Rad & Boot > Autos > Tesla: 210/216/tesla
|
||||
Auto, Rad & Boot > Autos > Toyota: 210/216/toyota
|
||||
Auto, Rad & Boot > Autos > Trabant: 210/216/trabant
|
||||
Auto, Rad & Boot > Autos > Volkswagen: 210/216/volkswagen
|
||||
Auto, Rad & Boot > Autos > Volvo: 210/216/volvo
|
||||
|
||||
Auto, Rad & Boot > Autoteile & Reifen: 210/223/sonstige_autoteile
|
||||
Auto, Rad & Boot > Autoteile & Reifen > Auto Hifi & Navigation: 210/223/auto_hifi_navigation
|
||||
Auto, Rad & Boot > Autoteile & Reifen > Ersatz- & Reparaturteile: 210/223/ersatz_reparaturteile
|
||||
Auto, Rad & Boot > Autoteile & Reifen > Reifen & Felgen: 210/223/reifen_felgen
|
||||
Auto, Rad & Boot > Autoteile & Reifen > Tuning & Styling: 210/223/tuning_styling
|
||||
Auto, Rad & Boot > Autoteile & Reifen > Werkzeug: 210/223/werkzeug
|
||||
|
||||
Auto, Rad & Boot > Boote & Bootszubehör: 210/211/andere
|
||||
Auto, Rad & Boot > Boote & Bootszubehör > Motorboote: 210/211/motorboote
|
||||
Auto, Rad & Boot > Boote & Bootszubehör > Segelboote: 210/211/segelboote
|
||||
Auto, Rad & Boot > Boote & Bootszubehör > Kleinboote: 210/211/kleinboote
|
||||
Auto, Rad & Boot > Boote & Bootszubehör > Schlauchboote: 210/211/schlauchboote
|
||||
Auto, Rad & Boot > Boote & Bootszubehör > Jetski: 210/211/jetski
|
||||
Auto, Rad & Boot > Boote & Bootszubehör > Bootstrailer: 210/211/bootstrailer
|
||||
Auto, Rad & Boot > Boote & Bootszubehör > Bootsliegeplätze: 210/211/bootsliegeplaetze
|
||||
Auto, Rad & Boot > Boote & Bootszubehör > Bootszubehör: 210/211/bootszubehoer
|
||||
|
||||
Auto, Rad & Boot > Fahrräder & Zubehör: 210/217/weiteres
|
||||
Auto, Rad & Boot > Fahrräder & Zubehör > Damen: 210/217/damen
|
||||
Auto, Rad & Boot > Fahrräder & Zubehör > Herren: 210/217/herren
|
||||
Auto, Rad & Boot > Fahrräder & Zubehör > Kinder: 210/217/kinder
|
||||
Auto, Rad & Boot > Fahrräder & Zubehör > Zubehör: 210/217/zubehoer
|
||||
|
||||
Auto, Rad & Boot > Motorräder & Motorroller > Mofas & Mopeds: 210/305/mofa
|
||||
Auto, Rad & Boot > Motorräder & Motorroller > Motorräder: 210/305/motorrad
|
||||
Auto, Rad & Boot > Motorräder & Motorroller > Motorroller & Scooter: 210/305/roller
|
||||
Auto, Rad & Boot > Motorräder & Motorroller > Quads: 210/305/quad
|
||||
|
||||
Auto, Rad & Boot > Motorradteile & Zubehör > Ersatz- & Reparaturteile: 210/306/teile
|
||||
Auto, Rad & Boot > Motorradteile & Zubehör > Reifen & Felgen: 210/306/reifen_felgen
|
||||
Auto, Rad & Boot > Motorradteile & Zubehör > Motorradbekleidung: 210/306/kleidung
|
||||
|
||||
Auto, Rad & Boot > Nutzfahrzeuge & Anhänger: 210/276/andere
|
||||
Auto, Rad & Boot > Nutzfahrzeuge & Anhänger > Agrarfahrzeuge: 210/276/agrarfahrzeuge
|
||||
Auto, Rad & Boot > Nutzfahrzeuge & Anhänger > Anhänger: 210/276/anhaenger
|
||||
Auto, Rad & Boot > Nutzfahrzeuge & Anhänger > Baumaschinen: 210/276/baumaschinen
|
||||
Auto, Rad & Boot > Nutzfahrzeuge & Anhänger > Busse: 210/276/busse
|
||||
Auto, Rad & Boot > Nutzfahrzeuge & Anhänger > LKW: 210/276/lkw
|
||||
Auto, Rad & Boot > Nutzfahrzeuge & Anhänger > Sattelzugmaschinen & Auflieger: 210/276/sattelzugmaschinen_auflieger
|
||||
Auto, Rad & Boot > Nutzfahrzeuge & Anhänger > Stapler: 210/276/stapler
|
||||
Auto, Rad & Boot > Nutzfahrzeuge & Anhänger > Traktoren: 210/276/traktoren
|
||||
Auto, Rad & Boot > Nutzfahrzeuge & Anhänger > Transporter: 210/276/transporter
|
||||
Auto, Rad & Boot > Nutzfahrzeuge & Anhänger > Nutzfahrzeugteile & Zubehör: 210/276/zubehoer
|
||||
|
||||
Auto, Rad & Boot > Reparaturen & Dienstleistungen: 210/280
|
||||
|
||||
Auto, Rad & Boot > Wohnwagen & -mobile: 210/220/andere
|
||||
Auto, Rad & Boot > Wohnwagen & -mobile > Alkoven: 210/220/alkoven
|
||||
Auto, Rad & Boot > Wohnwagen & -mobile > Integrierter: 210/220/integrierter
|
||||
Auto, Rad & Boot > Wohnwagen & -mobile > Kastenwagen: 210/220/kastenwagen
|
||||
Auto, Rad & Boot > Wohnwagen & -mobile > Teilintegrierter: 210/220/teilintegrierter
|
||||
Auto, Rad & Boot > Wohnwagen & -mobile > Wohnwagen: 210/220/wohnwagen
|
||||
|
||||
Dienstleistungen: 297/298
|
||||
Dienstleistungen > Altenpflege: 297/288
|
||||
Dienstleistungen > Auto, Rad & Boot: 297/289
|
||||
Dienstleistungen > Babysitter/-in & Kinderbetreuung: 297/290
|
||||
Dienstleistungen > Elektronik: 297/293
|
||||
|
||||
Dienstleistungen > Haus & Garten: 297/291/sonstige
|
||||
Dienstleistungen > Haus & Garten > Bau & Handwerk: 297/291/bau_handwerk
|
||||
Dienstleistungen > Haus & Garten > Garten- & Landschaftsbau: 297/291/garten_landschaftsbau
|
||||
Dienstleistungen > Haus & Garten > Haushaltshilfe: 297/291/haushaltshilfe
|
||||
Dienstleistungen > Haus & Garten > Reinigungsservice: 297/291/reingungsservice
|
||||
Dienstleistungen > Haus & Garten > Reparaturen: 297/291/reparaturen
|
||||
Dienstleistungen > Haus & Garten > Wohnungsauflösungen: 297/291/wohnungsaufloesungen
|
||||
Dienstleistungen > Künstler/-in & Musiker/-in: 297/292
|
||||
Dienstleistungen > Reise & Event: 297/294
|
||||
Dienstleistungen > Tierbetreuung & Training: 297/295
|
||||
Dienstleistungen > Umzug & Transport: 297/296
|
||||
|
||||
Eintrittskarten & Tickets: 231/256
|
||||
Eintrittskarten & Tickets > Bahn & ÖPNV: 231/286
|
||||
Eintrittskarten & Tickets > Comedy & Kabarett: 231/254
|
||||
Eintrittskarten & Tickets > Gutscheine: 231/287
|
||||
Eintrittskarten & Tickets > Kinder: 231/252
|
||||
Eintrittskarten & Tickets > Konzerte: 231/255
|
||||
Eintrittskarten & Tickets > Sport: 231/257
|
||||
Eintrittskarten & Tickets > Theater & Musical: 231/251
|
||||
|
||||
Elektronik: 161/168
|
||||
Elektronik > Audio & Hifi: 161/172/sonstiges
|
||||
Elektronik > Audio & Hifi > CD Player: 161/172/cd_player
|
||||
Elektronik > Audio & Hifi > Lautsprecher & Kopfhörer: 161/172/lautsprecher_kopfhoerer
|
||||
Elektronik > Audio & Hifi > MP3 Player: 161/172/mp3_player
|
||||
Elektronik > Audio & Hifi > Radio & Receiver: 161/172/radio_receiver
|
||||
Elektronik > Audio & Hifi > Stereoanlagen: 161/172/stereoanlagen
|
||||
|
||||
## Audio & Hifi
|
||||
Audio_und_Hifi: 161/172/sonstiges
|
||||
Elektronik > Dienstleistungen Elektronik: 161/226
|
||||
|
||||
CD_Player: 161/172/cd_player
|
||||
Kopfhörer: 161/172/lautsprecher_kopfhoerer
|
||||
Lautsprecher: 161/172/lautsprecher_kopfhoerer
|
||||
MP3_Player: 161/172/mp3_player
|
||||
Radio: 161/172/radio_receiver
|
||||
Reciver: 161/172/radio_receiver
|
||||
Stereoanlagen: 161/172/stereoanlagen
|
||||
Elektronik > Foto: 161/245/other
|
||||
Elektronik > Foto > Kamera: 161/245/camera
|
||||
Elektronik > Foto > Objektiv: 161/245/lens
|
||||
Elektronik > Foto > Zubehör: 161/245/equipment
|
||||
Elektronik > Foto > Kamera & Zubehör: 161/245/camera_and_equipment
|
||||
|
||||
## Dienstleistungen Elektronik
|
||||
Dienstleistungen_Elektronik: 161/226
|
||||
Elektronik > Handy & Telefon: 161/173/sonstige
|
||||
Elektronik > Handy & Telefon > Apple: 161/173/apple
|
||||
Elektronik > Handy & Telefon > Google: 161/173/google_handy
|
||||
Elektronik > Handy & Telefon > Huawei: 161/173/huawai_handy
|
||||
Elektronik > Handy & Telefon > HTC: 161/173/htc_handy
|
||||
Elektronik > Handy & Telefon > LG: 161/173/lg_handy
|
||||
Elektronik > Handy & Telefon > Motorola: 161/173/motorola_handy
|
||||
Elektronik > Handy & Telefon > Nokia: 161/173/nokia_handy
|
||||
Elektronik > Handy & Telefon > Samsung: 161/173/samsung_handy
|
||||
Elektronik > Handy & Telefon > Siemens: 161/173/siemens_handy
|
||||
Elektronik > Handy & Telefon > Sony: 161/173/sony_handy
|
||||
Elektronik > Handy & Telefon > Xiaomi: 161/173/xiaomi_handy
|
||||
Elektronik > Handy & Telefon > Faxgeräte: 161/173/faxgeraete
|
||||
Elektronik > Handy & Telefon > Telefone: 161/173/telefone
|
||||
|
||||
## Foto
|
||||
Foto: 161/245/other
|
||||
Elektronik > Haushaltsgeräte: 161/176/sonstige
|
||||
Elektronik > Haushaltsgeräte > Haushaltskleingeräte: 161/176/haushaltskleingeraete
|
||||
Elektronik > Haushaltsgeräte > Herde & Backöfen: 161/176/herde_backoefen
|
||||
Elektronik > Haushaltsgeräte > Kaffee- & Espressomaschinen: 161/176/kaffee_espressomaschinen
|
||||
Elektronik > Haushaltsgeräte > Kühlschränke & Gefriergeräte: 161/176/kuehlschraenke_gefriergeraete
|
||||
Elektronik > Haushaltsgeräte > Spülmaschinen: 161/176/spuelmaschinen
|
||||
Elektronik > Haushaltsgeräte > Staubsauger: 161/176/staubsauger
|
||||
Elektronik > Haushaltsgeräte > Waschmaschinen & Trockner: 161/176/waschmaschinen_trockner
|
||||
|
||||
Kameras: 161/245/camera
|
||||
Objektive: 161/245/lens
|
||||
Foto_Zubehör: 161/245/equipment
|
||||
Kamera_Equipment: 161/245/camera_and_equipment
|
||||
Elektronik > Konsolen: 161/279/weitere
|
||||
Elektronik > Konsolen > Pocket Konsolen: 161/279/dsi_psp
|
||||
Elektronik > Konsolen > Playstation: 161/279/playstation
|
||||
Elektronik > Konsolen > Xbox: 161/279/xbox
|
||||
Elektronik > Konsolen > Wii: 161/279/wii
|
||||
|
||||
## Handy & Telefon
|
||||
Handys: 161/173/sonstige
|
||||
Elektronik > Notebooks: 161/278
|
||||
Elektronik > PCs: 161/228
|
||||
Elektronik > PC-Zubehör & Software: 161/225/sonstiges
|
||||
Elektronik > PC-Zubehör & Software > Drucker & Scanner: 161/225/drucker_scanner
|
||||
Elektronik > PC-Zubehör & Software > Festplatten & Laufwerke: 161/225/festplatten_laufwerke
|
||||
Elektronik > PC-Zubehör & Software > Gehäuse: 161/225/gehaeuse
|
||||
Elektronik > PC-Zubehör & Software > Grafikkarten: 161/225/grafikkarten
|
||||
Elektronik > PC-Zubehör & Software > Kabel & Adapter: 161/225/kabel_adapter
|
||||
Elektronik > PC-Zubehör & Software > Mainboards: 161/225/mainboards
|
||||
Elektronik > PC-Zubehör & Software > Monitore: 161/225/monitore
|
||||
Elektronik > PC-Zubehör & Software > Multimedia: 161/225/multimedia
|
||||
Elektronik > PC-Zubehör & Software > Netzwerk & Modem: 161/225/netzwerk_modem
|
||||
Elektronik > PC-Zubehör & Software > Prozessoren / CPUs: 161/225/prozessor_cpu
|
||||
Elektronik > PC-Zubehör & Software > Speicher: 161/225/speicher
|
||||
Elektronik > PC-Zubehör & Software > Software: 161/225/software
|
||||
Elektronik > PC-Zubehör & Software > Tastatur & Maus: 161/225/tastatur_maus
|
||||
|
||||
Handy_Apple: 161/173/apple
|
||||
Handy_HTC: 161/173/htc_handy
|
||||
Handy_LG: 161/173/lg_handy
|
||||
Handy_Motorola: 161/173/motorola_handy
|
||||
Handy_Nokia: 161/173/nokia_handy
|
||||
Handy_Samsung: 161/173/samsung_handy
|
||||
Handy_Siemens: 161/173/siemens_handy
|
||||
Handy_Sony: 161/173/sony_handy
|
||||
Faxgeräte: 161/173/faxgeraete
|
||||
Telefone: 161/173/telefone
|
||||
Elektronik > Tablets Reader: 161/285/weitere
|
||||
Elektronik > Tablets & Reader > iPad: 161/285/ipad
|
||||
Elektronik > Tablets & Reader > Kindle: 161/285/kindle
|
||||
Elektronik > Tablets & Reader > Samsung Tablets: 161/285/samsung_tablets
|
||||
|
||||
## Haushaltsgeräte
|
||||
Haushaltsgeräte: 161/176/sonstige
|
||||
Elektronik > TV & Video: 161/175/weitere
|
||||
Elektronik > TV & Video > DVD-Player & Recorder: 161/175/dvdplayer_recorder
|
||||
Elektronik > TV & Video > Fernseher: 161/175/fernseher
|
||||
Elektronik > TV & Video > TV-Receiver: 161/175/tv_receiver
|
||||
|
||||
Haushaltkleingeräte: 161/176/haushaltskleingeraete
|
||||
Herde: 161/176/herde_backoefen
|
||||
Backöfen: 161/176/herde_backoefen
|
||||
Kaffemaschinen: 161/176/kaffee_espressomaschinen
|
||||
Espressomaschinen: 161/176/kaffee_espressomaschinen
|
||||
Kühlschränke: 161/176/kuehlschraenke_gefriergeraete
|
||||
Gefriergeräte: 161/176/kuehlschraenke_gefriergeraete
|
||||
Spülmaschinen: 161/176/spuelmaschinen
|
||||
Staubsauger: 161/176/staubsauger
|
||||
Waschmaschinen: 161/176/waschmaschinen_trockner
|
||||
Trockner: 161/176/waschmaschinen_trockner
|
||||
Elektronik > Videospiele: 161/227/sonstige
|
||||
Elektronik > Videospiele > DS(i)- & PSP Spiele: 161/227/dsi_psp
|
||||
Elektronik > Videospiele > Nintendo Spiele: 161/227/nintendo
|
||||
Elektronik > Videospiele > PlayStation Spiele: 161/227/playstation
|
||||
Elektronik > Videospiele > Xbox Spiele: 161/227/xbox
|
||||
Elektronik > Videospiele > Wii Spiele: 161/227/wii
|
||||
Elektronik > Videospiele > PC Spiele: 161/227/pc_spiele
|
||||
|
||||
## Konsolen
|
||||
Konsolen: 161/279/weitere
|
||||
Familie, Kind & Baby: 17/18
|
||||
Familie, Kind & Baby > Altenpflege: 17/236
|
||||
|
||||
Pocket_Konsolen: 161/279/dsi_psp
|
||||
Playstation: 161/279/playstation
|
||||
XBox: 161/279/xbox
|
||||
Wii: 161/279/wii
|
||||
Familie, Kind & Baby > Baby- & Kinderkleidung: 17/22/sonstiges
|
||||
Familie, Kind & Baby > Baby- & Kinderkleidung > Hosen & Jeans: 17/22/hosen_jeans
|
||||
Familie, Kind & Baby > Baby- & Kinderkleidung > Kleider & Röcke: 17/22/kleider_roecke
|
||||
Familie, Kind & Baby > Baby- & Kinderkleidung > Shirts & Tops: 17/22/shirts_tops
|
||||
Familie, Kind & Baby > Baby- & Kinderkleidung > Hemden: 17/22/hemden
|
||||
Familie, Kind & Baby > Baby- & Kinderkleidung > Jacken & Mäntel: 17/22/jacken_mantel
|
||||
Familie, Kind & Baby > Baby- & Kinderkleidung > Pullover & Strickjacken: 17/22/pullover_strickjacken
|
||||
Familie, Kind & Baby > Baby- & Kinderkleidung > Wäsche: 17/22/wasche
|
||||
Familie, Kind & Baby > Baby- & Kinderkleidung > Sportbekleidung: 17/22/sportbekleidung
|
||||
Familie, Kind & Baby > Baby- & Kinderkleidung > Bademode: 17/22/bademode
|
||||
Familie, Kind & Baby > Baby- & Kinderkleidung > Accessoires: 17/22/accessoires
|
||||
Familie, Kind & Baby > Baby- & Kinderkleidung > Kleidungspakete: 17/22/kleidungspakete
|
||||
|
||||
## Notebooks
|
||||
Notebooks: 161/278
|
||||
Familie, Kind & Baby > Baby- & Kinderschuhe: 17/19/sonstiges
|
||||
Familie, Kind & Baby > Baby- & Kinderschuhe > Ballerinas: 17/19/ballerinas
|
||||
Familie, Kind & Baby > Baby- & Kinderschuhe > Halb- & Schnürschuhe: 17/19/halb_schnuerschuhe
|
||||
Familie, Kind & Baby > Baby- & Kinderschuhe > Hausschuhe: 17/19/hausschuhe
|
||||
Familie, Kind & Baby > Baby- & Kinderschuhe > Sandalen: 17/19/sandalen
|
||||
Familie, Kind & Baby > Baby- & Kinderschuhe > Outdoor & Wanderschuhe: 17/19/outdoor_wanderschuhe
|
||||
Familie, Kind & Baby > Baby- & Kinderschuhe > Sneaker & Sportschuhe: 17/19/sneaker_sportschuhe
|
||||
Familie, Kind & Baby > Baby- & Kinderschuhe > Stiefel & Stiefeletten: 17/19/stiefel_stiefeletten
|
||||
Familie, Kind & Baby > Baby- & Kinderschuhe > Badeschuhe: 17/19/badeschuhe
|
||||
|
||||
## PCs
|
||||
PCs: 161/228
|
||||
Familie, Kind & Baby > Baby-Ausstattung: 17/258
|
||||
Familie, Kind & Baby > Babyschalen & Kindersitze: 17/21
|
||||
Familie, Kind & Baby > Babysitter/-in & Kinderbetreuung: 17/237
|
||||
Familie, Kind & Baby > Kinderwagen & Buggys: 17/25
|
||||
|
||||
## PC-Zubehör & Software
|
||||
PC-Zubehör: 161/225/sonstiges
|
||||
Familie, Kind & Baby > Kinderzimmermöbel: 17/20/sonstige
|
||||
Familie, Kind & Baby > Kinderzimmermöbel > Betten & Wiegen: 17/20/betten_wiegen
|
||||
Familie, Kind & Baby > Kinderzimmermöbel > Hochstühle & Laufställe: 17/20/hochstuehle_laufstaelle
|
||||
Familie, Kind & Baby > Kinderzimmermöbel > Schränke & Kommoden: 17/20/schraenke_kommoden
|
||||
Familie, Kind & Baby > Kinderzimmermöbel > Wickeltische & Zubehör: 17/20/wickeltische_zubehoer
|
||||
Familie, Kind & Baby > Kinderzimmermöbel > Wippen & Schaukeln: 17/20/wippen_schaukeln
|
||||
|
||||
Drucker: 161/225/drucker_scanner
|
||||
Scanner: 161/225/drucker_scanner
|
||||
Festplatten: 161/225/festplatten_laufwerke
|
||||
Laufwerke: 161/225/festplatten_laufwerke
|
||||
Gehäuse: 161/225/gehaeuse
|
||||
Grafikkarten: 161/225/grafikkarten
|
||||
Kabel: 161/225/kabel_adapter
|
||||
Adapter: 161/225/kabel_adapter
|
||||
Mainboards: 161/225/mainboards
|
||||
Monitore: 161/225/monitore
|
||||
Multimedia: 161/225/multimedia
|
||||
Netzwerk: 161/225/netzwerk_modem
|
||||
CPUs: 161/225/prozessor_cpu
|
||||
Prozessoren: 161/225/prozessor_cpu
|
||||
Speicher: 161/225/speicher
|
||||
Software: 161/225/software
|
||||
Mäuse: 161/225/tastatur_maus
|
||||
Tastaturen: 161/225/tastatur_maus
|
||||
Familie, Kind & Baby > Spielzeug: 17/23/sonstiges
|
||||
Familie, Kind & Baby > Spielzeug > Action- & Spielfiguren: 17/23/actionfiguren
|
||||
Familie, Kind & Baby > Spielzeug > Babyspielzeug: 17/23/babyspielzeug
|
||||
Familie, Kind & Baby > Spielzeug > Barbie & Co: 17/23/barbie
|
||||
Familie, Kind & Baby > Spielzeug > Dreirad & Co: 17/23/dreirad
|
||||
Familie, Kind & Baby > Spielzeug > Gesellschaftsspiele: 17/23/gesellschaftsspiele
|
||||
Familie, Kind & Baby > Spielzeug > Holzspielzeug: 17/23/holzspielzeug
|
||||
Familie, Kind & Baby > Spielzeug > LEGO & Duplo: 17/23/lego_duplo
|
||||
Familie, Kind & Baby > Spielzeug > Lernspielzeug: 17/23/lernspielzeug
|
||||
Familie, Kind & Baby > Spielzeug > Playmobil: 17/23/playmobil
|
||||
Familie, Kind & Baby > Spielzeug > Puppen: 17/23/puppen
|
||||
Familie, Kind & Baby > Spielzeug > Spielzeugautos: 17/23/spielzeug_autos
|
||||
Familie, Kind & Baby > Spielzeug > Spielzeug für draußen: 17/23/spielzeug_draussen
|
||||
Familie, Kind & Baby > Spielzeug > Stofftiere: 17/23/stofftiere
|
||||
|
||||
## Tablets & Reader
|
||||
Tablets_Reader: 161/285/weitere
|
||||
Freizeit, Hobby & Nachbarschaft: 185/242
|
||||
Freizeit, Hobby & Nachbarschaft > Esoterik & Spirituelles: 185/232
|
||||
Freizeit, Hobby & Nachbarschaft > Essen & Trinken: 185/248
|
||||
Freizeit, Hobby & Nachbarschaft > Freizeitaktivitäten: 185/187
|
||||
Freizeit, Hobby & Nachbarschaft > Handarbeit, Basteln & Kunsthandwerk: 185/282
|
||||
Freizeit, Hobby & Nachbarschaft > Kunst & Antiquitäten: 185/240
|
||||
Freizeit, Hobby & Nachbarschaft > Künstler/-in & Musiker/-in: 185/191
|
||||
Freizeit, Hobby & Nachbarschaft > Modellbau: 185/249
|
||||
Freizeit, Hobby & Nachbarschaft > Reise & Eventservices: 185/233
|
||||
|
||||
iPad: 161/285/ipad
|
||||
Kindle: 161/285/kindle
|
||||
Tablets_Samsung: 161/285/samsung_tablets
|
||||
Freizeit, Hobby & Nachbarschaft > Sammeln: 185/234/sonstige
|
||||
Freizeit, Hobby & Nachbarschaft > Sammeln > Ansichts- & Postkarten: 185/234/ansichts_postkarten
|
||||
Freizeit, Hobby & Nachbarschaft > Sammeln > Autogramme: 185/234/autogramme
|
||||
Freizeit, Hobby & Nachbarschaft > Sammeln > Bierkrüge & -gläser: 185/234/bierkruege_glaeser
|
||||
Freizeit, Hobby & Nachbarschaft > Sammeln > Briefmarken: 185/234/briefmarken
|
||||
Freizeit, Hobby & Nachbarschaft > Sammeln > Comics: 185/234/comics
|
||||
Freizeit, Hobby & Nachbarschaft > Sammeln > Flaggen: 185/234/flaggen
|
||||
Freizeit, Hobby & Nachbarschaft > Sammeln > Münzen: 185/234/muenzen
|
||||
Freizeit, Hobby & Nachbarschaft > Sammeln > Porzellan: 185/234/porzellan
|
||||
Freizeit, Hobby & Nachbarschaft > Sammeln > Puppen & Puppenzubehör: 185/234/puppen_puppenzubehoer
|
||||
Freizeit, Hobby & Nachbarschaft > Sammeln > Sammelbilder & Sticker: 185/234/sammelbilder_sticker
|
||||
Freizeit, Hobby & Nachbarschaft > Sammeln > Sammelkartenspiele: 185/234/sammelkartenspiele
|
||||
Freizeit, Hobby & Nachbarschaft > Sammeln > Überraschungseier: 185/234/ueberraschungseier
|
||||
Freizeit, Hobby & Nachbarschaft > Sammeln > Werbeartikel: 185/234/werbeartikel
|
||||
|
||||
## TV & Video
|
||||
TV_Video: 161/175/weitere
|
||||
Freizeit, Hobby & Nachbarschaft > Sport & Camping: 185/230/sonstige
|
||||
Freizeit, Hobby & Nachbarschaft > Sport & Camping > Ballsport: 185/230/ballsport
|
||||
Freizeit, Hobby & Nachbarschaft > Sport & Camping > Camping & Outdoor: 185/230/camping
|
||||
Freizeit, Hobby & Nachbarschaft > Sport & Camping > Fitness: 185/230/fitness
|
||||
Freizeit, Hobby & Nachbarschaft > Sport & Camping > Radsport: 185/230/radsport
|
||||
Freizeit, Hobby & Nachbarschaft > Sport & Camping > Tanzen & Laufen: 185/230/tanzen_laufen
|
||||
Freizeit, Hobby & Nachbarschaft > Sport & Camping > Wassersport: 185/230/wassersport
|
||||
Freizeit, Hobby & Nachbarschaft > Sport & Camping > Wintersport: 185/230/wintersport
|
||||
|
||||
DVD-Player: 161/175/dvdplayer_recorder
|
||||
Recorder: 161/175/dvdplayer_recorder
|
||||
Fernseher: 161/175/fernseher
|
||||
Reciever: 161/175/tv_receiver
|
||||
Freizeit, Hobby & Nachbarschaft > Trödel: 185/250
|
||||
Freizeit, Hobby & Nachbarschaft > Verloren & Gefunden: 185/189
|
||||
|
||||
## Videospiele
|
||||
Videospiele: 161/227/sonstige
|
||||
Haus & Garten: 80/87
|
||||
Haus & Garten > Badezimmer: 80/91
|
||||
Haus & Garten > Büro: 80/93
|
||||
|
||||
Videospiele_DS: 161/227/dsi_psp
|
||||
Videospiele_PSP: 161/227/dsi_psp
|
||||
Videospiele_Nintendo: 161/227/nintendo
|
||||
Videospiele_Playstation: 161/227/playstation
|
||||
Videospiele_XBox: 161/227/xbox
|
||||
Videospiele_Wii: 161/227/wii
|
||||
Videospiele_PC: 161/227/pc_spiele
|
||||
Haus & Garten > Dekoration: 80/246/weitere
|
||||
Haus & Garten > Dekoration > Bilder & Poster: 80/246/bilder_poster
|
||||
Haus & Garten > Dekoration > Kerzen & Kerzenhalter: 80/246/kerzen_kerzenhalter
|
||||
Haus & Garten > Dekoration > Spiegel: 80/246/spiegel
|
||||
Haus & Garten > Dekoration > Vasen: 80/246/vasen
|
||||
|
||||
Haus & Garten > Dienstleistungen Haus & Garten: 80/239/sonstige
|
||||
Haus & Garten > Dienstleistungen Haus & Garten > Bau & Handwerk: 80/239/bau_handwerk
|
||||
Haus & Garten > Dienstleistungen Haus & Garten > Garten- & Landschaftsbau: 80/239/garten_landschaftsbau
|
||||
Haus & Garten > Dienstleistungen Haus & Garten > Haushaltshilfe: 80/239/haushaltshilfe
|
||||
Haus & Garten > Dienstleistungen Haus & Garten > Reinigungsservice: 80/239/reingungsservice
|
||||
Haus & Garten > Dienstleistungen Haus & Garten > Reparaturen: 80/239/reparaturen
|
||||
Haus & Garten > Dienstleistungen Haus & Garten > Wohnungsauflösungen: 80/239/wohnungsaufloesungen
|
||||
|
||||
#Auto, Rad & Boot
|
||||
Autoreifen: 210/223/reifen_felgen
|
||||
Haus & Garten > Gartenzubehör & Pflanzen: 80/89/sonstige
|
||||
Haus & Garten > Gartenzubehör & Pflanzen > Blumentöpfe: 80/89/blumentoepfe
|
||||
Haus & Garten > Gartenzubehör & Pflanzen > Dekoration: 80/89/dekoration
|
||||
Haus & Garten > Gartenzubehör & Pflanzen > Gartengeräte: 80/89/gartengeraete
|
||||
Haus & Garten > Gartenzubehör & Pflanzen > Gartenmöbel: 80/89/gartenmoebel
|
||||
Haus & Garten > Gartenzubehör & Pflanzen > Pflanzen: 80/89/pflanzen
|
||||
|
||||
# Freizeit, Hobby & Nachbarschaft
|
||||
Sammeln: 185/234/sonstige
|
||||
Haus & Garten > Heimtextilien: 80/90
|
||||
Haus & Garten > Heimwerken: 80/84
|
||||
|
||||
# Mode & Beauty
|
||||
Beauty: 153/224/sonstiges
|
||||
Gesundheit: 153/224/gesundheit
|
||||
Mode: 153/155
|
||||
Haus & Garten > Küche & Esszimmer: 80/86/sonstige
|
||||
Haus & Garten > Küche & Esszimmer > Besteck: 80/86/besteck
|
||||
Haus & Garten > Küche & Esszimmer > Geschirr: 80/86/geschirr
|
||||
Haus & Garten > Küche & Esszimmer > Gläser: 80/86/glaeser
|
||||
Haus & Garten > Küche & Esszimmer > Kleingeräte: 80/86/kuechengeraete
|
||||
Haus & Garten > Küche & Esszimmer > Küchenschränke: 80/86/kuechenschraenke
|
||||
Haus & Garten > Küche & Esszimmer > Stühle: 80/86/stuehle
|
||||
Haus & Garten > Küche & Esszimmer > Tische: 80/86/tische
|
||||
|
||||
# Mode & Beauty > Damenschuhe
|
||||
Damenschuhe: 153/159/sonstiges
|
||||
Damen_Ballerinas: 153/159/ballerinas
|
||||
Damen_Halbschuhe: 153/159/halb_schnuerschuhe
|
||||
Damen_Hausschuhe: 153/159/hausschuhe
|
||||
Damen_High_Heels: 153/159/pumps
|
||||
Damen_Pumps: 153/159/pumps
|
||||
Damen_Sandalen: 153/159/sandalen
|
||||
Damen_Schnürschuhe: 153/159/halb_schnuerschuhe
|
||||
Damen_Sportschuche: 153/159/sneaker_sportschuhe
|
||||
Damen_Sneaker: 153/159/sneaker_sportschuhe
|
||||
Damen_Stiefel: 153/159/stiefel
|
||||
Damen_Stiefeletten: 153/159/stiefel
|
||||
Damen_Outdoorschuhe: 153/159/outdoor_wanderschuhe
|
||||
Damen_Wanderschuhe: 153/159/outdoor_wanderschuhe
|
||||
Haus & Garten > Lampen & Licht: 80/82
|
||||
|
||||
# Mode & Beauty > Herrenschuhe
|
||||
Herrenschuhe: 153/158/sonstiges
|
||||
Herren_Halbschuhe: 153/158/halb_schnuerschuhe
|
||||
Herren_Hausschuhe: 153/158/hausschuhe
|
||||
Herren_Sandalen: 153/158/sandalen
|
||||
Herren_Schnürschuhe: 153/158/halb_schnuerschuhe
|
||||
Herren_Sportschuche: 153/158/sneaker_sportschuhe
|
||||
Herren_Sneaker: 153/158/sneaker_sportschuhe
|
||||
Herren_Stiefel: 153/158/stiefel
|
||||
Herren_Stiefeletten: 153/158/stiefel
|
||||
Herren_Outdoorschuhe: 153/158/outdoor_wanderschuhe
|
||||
Herren_Wanderschuhe: 153/158/outdoor_wanderschuhe
|
||||
Haus & Garten > Schlafzimmer: 80/81/sonstiges
|
||||
Haus & Garten > Schlafzimmer > Betten: 80/81/betten
|
||||
Haus & Garten > Schlafzimmer > Lattenroste: 80/81/lattenroste
|
||||
Haus & Garten > Schlafzimmer > Matratzen: 80/81/matratzen
|
||||
Haus & Garten > Schlafzimmer > Nachttische: 80/81/nachttische
|
||||
Haus & Garten > Schlafzimmer > Schränke: 80/81/schraenke
|
||||
|
||||
#Familie, Kind & Baby
|
||||
Familie_Kind_Baby: 17/18
|
||||
Altenpflege: 17/236
|
||||
Babysitter: 17/237
|
||||
Buggys: 17/25
|
||||
Babyschalen: 17/21
|
||||
Baby-Ausstattung: 17/258
|
||||
Kinderbetreuung: 17/237
|
||||
Kindersitze: 17/21
|
||||
Kinderwagen: 17/25
|
||||
Haus & Garten > Wohnzimmer: 80/88/sonstiges
|
||||
Haus & Garten > Wohnzimmer > Regale: 80/88/regale
|
||||
Haus & Garten > Wohnzimmer > Schränke & Schrankwände: 80/88/schraenke
|
||||
Haus & Garten > Wohnzimmer > Sitzmöbel: 80/88/sitzmoebel
|
||||
Haus & Garten > Wohnzimmer > Sofas & Sitzgarnituren: 80/88/sofas_sitzgarnituren
|
||||
Haus & Garten > Wohnzimmer > Tische: 80/88/tische
|
||||
Haus & Garten > Wohnzimmer > TV & Phonomöbel: 80/88/tv_moebel
|
||||
|
||||
# Familie, Kind & Baby > Spielzeug
|
||||
Spielzeug: 17/23/sonstiges
|
||||
Actionfiguren: 17/23/actionfiguren
|
||||
Babyspielzeug: 17/23/babyspielzeug
|
||||
Barbie: 17/23/barbie
|
||||
Dreirad: 17/23/dreirad
|
||||
Gesellschaftsspiele: 17/23/gesellschaftsspiele
|
||||
Holzspielzeug: 17/23/holzspielzeug
|
||||
Duplo: 17/23/lego_duplo
|
||||
LEGO: 17/23/lego_duplo
|
||||
Lernspielzeug: 17/23/lernspielzeug
|
||||
Playmobil: 17/23/playmobil
|
||||
Puppen: 17/23/puppen
|
||||
Spielzeugautos: 17/23/spielzeug_autos
|
||||
Spielzeug_draussen: 17/23/spielzeug_draussen
|
||||
Stofftiere: 17/23/stofftiere
|
||||
Haustiere > Fische: 130/138/sonstige
|
||||
Haustiere > Fische > Aquariumfische: 130/138/aquarium
|
||||
Haustiere > Fische > Barsche: 130/138/barsche
|
||||
Haustiere > Fische > Diskusfische: 130/138/diskusfische
|
||||
Haustiere > Fische > Garnelen & Krebse: 130/138/garnelen_krebse
|
||||
Haustiere > Fische > Koi: 130/138/koi
|
||||
Haustiere > Fische > Schnecken: 130/138/schnecken
|
||||
Haustiere > Fische > Wasserpflanzen: 130/138/wasserpflanzen
|
||||
Haustiere > Fische > Welse: 130/138/welse
|
||||
|
||||
# Haus & Garten > Wohnzimmer
|
||||
Wohnzimmer_Regale: 80/88/regale
|
||||
Wohnzimmer_Schraenke: 80/88/schraenke
|
||||
Wohnzimmer_Sitzmoebel: 80/88/sitzmoebel
|
||||
Wohnzimmer_Sofas_Sitzgarnituren: 80/88/sofas_sitzgarnituren
|
||||
Wohnzimmer_Tische: 80/88/tische
|
||||
Wohnzimmer_TV_Moebel: 80/88/tv_moebel
|
||||
Wohnzimmer_Sonstiges: 80/88/sonstiges
|
||||
Haustiere > Hunde: 130/134/sonstige
|
||||
Haustiere > Hunde > Mischlinge: 130/134/mischlinge
|
||||
Haustiere > Hunde > Beagle: 130/134/beagle
|
||||
Haustiere > Hunde > Bernhardiner: 130/134/bernhardiner
|
||||
Haustiere > Hunde > Border Collie: 130/134/border_collie
|
||||
Haustiere > Hunde > Boxer: 130/134/boxer
|
||||
Haustiere > Hunde > Cocker Spaniel: 130/134/cocker_spaniel
|
||||
Haustiere > Hunde > Collie: 130/134/collie
|
||||
Haustiere > Hunde > Dackel: 130/134/dackel
|
||||
Haustiere > Hunde > Dalmatiner: 130/134/dalmatiner
|
||||
Haustiere > Hunde > Dobermann: 130/134/dobermann
|
||||
Haustiere > Hunde > Dogge: 130/134/dogge
|
||||
Haustiere > Hunde > Golden Retriever: 130/134/goldenretriever
|
||||
Haustiere > Hunde > Husky: 130/134/husky
|
||||
Haustiere > Hunde > Jack Russell Terrier: 130/134/jack_russel_terrier
|
||||
Haustiere > Hunde > Labrador: 130/134/labrador
|
||||
Haustiere > Hunde > Malteser: 130/134/malteser
|
||||
Haustiere > Hunde > Pudel: 130/134/pudel
|
||||
Haustiere > Hunde > Schäferhunde: 130/134/schaeferhund
|
||||
Haustiere > Hunde > Spitz: 130/134/spitz
|
||||
Haustiere > Hunde > Terrier: 130/134/terrier
|
||||
|
||||
# Verschenken & Tauschen
|
||||
Tauschen: 272/273
|
||||
Verleihen: 272/274
|
||||
Verschenken: 272/192
|
||||
Haustiere > Katzen: 130/136/sonstige
|
||||
Haustiere > Katzen > Britisch Kurzhaar: 130/136/britisch_kurzhaar
|
||||
Haustiere > Katzen > Hauskatze: 130/136/hauskatze
|
||||
Haustiere > Katzen > Maine Coon: 130/136/maine_coon
|
||||
Haustiere > Katzen > Siam: 130/136/siam
|
||||
|
||||
Haustiere > Kleintiere: 130/132/sonstige
|
||||
Haustiere > Kleintiere > Hamster: 130/132/hamster
|
||||
Haustiere > Kleintiere > Hasen & Kaninchen: 130/132/hasen_kaninchen
|
||||
Haustiere > Kleintiere > Mäuse & Ratten: 130/132/maeuse_ratten
|
||||
Haustiere > Kleintiere > Meerschweinchen: 130/132/meerschweinchen
|
||||
|
||||
Haustiere > Nutztiere: 130/135
|
||||
Haustiere > Pferde > Großpferde: 130/139/grosspferde
|
||||
Haustiere > Pferde > Kleinpferde & Ponys: 130/139/kleinpferde_ponys
|
||||
Haustiere > Tierbetreuung & Training: 130/133
|
||||
Haustiere > Vermisste Tiere > Entlaufen: 130/283/entlaufen
|
||||
Haustiere > Vermisste Tiere > Gefunden: 130/283/gefunden
|
||||
Haustiere > Vögel: 130/243
|
||||
|
||||
Haustiere > Zubehör: 130/313/sonstiges
|
||||
Haustiere > Zubehör > Fische: 130/313/fische
|
||||
Haustiere > Zubehör > Hunde: 130/313/hunde
|
||||
Haustiere > Zubehör > Katzen: 130/313/katzen
|
||||
Haustiere > Zubehör > Kleintiere: 130/313/kleintiere
|
||||
Haustiere > Zubehör > Pferde: 130/313/pferde
|
||||
Haustiere > Zubehör > Reptilien: 130/313/reptilien
|
||||
Haustiere > Zubehör > Vögel: 130/313/voegel
|
||||
|
||||
Immobilien: 195/198
|
||||
Immobilien > Auf Zeit & WG > Gesamte Unterkunft: 195/199/entire_accommodation
|
||||
Immobilien > Auf Zeit & WG > Privatzimmer: 195/199/private_room
|
||||
Immobilien > Auf Zeit & WG > Gemeinsames Zimmer: 195/199/shared_room
|
||||
Immobilien > Eigentumswohnungen: 195/196
|
||||
Immobilien > Ferien- & Auslandsimmobilien > Kaufen: 195/275/kaufen
|
||||
Immobilien > Ferien- & Auslandsimmobilien > Mieten: 195/275/mieten
|
||||
Immobilien > Garagen & Stellplätze > Kaufen: 195/197/kaufen
|
||||
Immobilien > Garagen & Stellplätze > Mieten: 195/197/mieten
|
||||
Immobilien > Gewerbeimmobilien > Kaufen: 195/277/kaufen
|
||||
Immobilien > Gewerbeimmobilien > Mieten: 195/277/mieten
|
||||
Immobilien > Grundstücke & Gärten: 195/207/andere
|
||||
Immobilien > Grundstücke & Gärten > Baugrundstück: 195/207/baugrundstueck
|
||||
Immobilien > Grundstücke & Gärten > Garten: 195/207/garten
|
||||
Immobilien > Grundstücke & Gärten > Land-/Forstwirtschaft: 195/207/land_forstwirtschaft
|
||||
Immobilien > Häuser zum Kauf: 195/208
|
||||
Immobilien > Häuser zur Miete: 195/205
|
||||
Immobilien > Mietwohnungen: 195/203
|
||||
Immobilien > Umzug & Transport: 195/238
|
||||
|
||||
Jobs > Ausbildung: 102/118
|
||||
Jobs > Bau, Handwerk & Produktion: 102/111/weitere
|
||||
Jobs > Bau, Handwerk & Produktion > Bauhelfer/-in: 102/111/bauhelfer
|
||||
Jobs > Bau, Handwerk & Produktion > Dachdecker/-in: 102/111/dachdecker
|
||||
Jobs > Bau, Handwerk & Produktion > Elektriker/-in: 102/111/elektriker
|
||||
Jobs > Bau, Handwerk & Produktion > Fliesenleger/-in: 102/111/fliesenleger
|
||||
Jobs > Bau, Handwerk & Produktion > Maler/-in: 102/111/maler
|
||||
Jobs > Bau, Handwerk & Produktion > Maurer/-in: 102/111/maurer
|
||||
Jobs > Bau, Handwerk & Produktion > Produktionshelfer/-in: 102/111/produktionshelfer
|
||||
Jobs > Bau, Handwerk & Produktion > Schlosser/-in: 102/111/schlosser
|
||||
Jobs > Bau, Handwerk & Produktion > Tischler/-in: 102/111/tischler
|
||||
Jobs > Büroarbeit & Verwaltung: 102/114/weitere
|
||||
Jobs > Büroarbeit & Verwaltung > Buchhalter/-in: 102/114/buchhalter
|
||||
Jobs > Büroarbeit & Verwaltung > Bürokaufmann/-frau: 102/114/buerokauf
|
||||
Jobs > Büroarbeit & Verwaltung > Sachbearbeiter/-in: 102/114/sachbearbeiter
|
||||
Jobs > Büroarbeit & Verwaltung > Sekretär/-in: 102/114/sekretaer
|
||||
Jobs > Gastronomie & Tourismus: 102/110/weitere
|
||||
Jobs > Gastronomie & Tourismus > Barkeeper/-in: 102/110/barkeeper
|
||||
Jobs > Gastronomie & Tourismus > Hotelfachmann/-frau: 102/110/hotelfach
|
||||
Jobs > Gastronomie & Tourismus > Housekeeping: 102/110/zimmermaedchen
|
||||
Jobs > Gastronomie & Tourismus > Kellner/-in: 102/110/kellner
|
||||
Jobs > Gastronomie & Tourismus > Koch/Köchin: 102/110/koch
|
||||
Jobs > Gastronomie & Tourismus > Küchenhilfe: 102/110/kuechenhilfe
|
||||
Jobs > Gastronomie & Tourismus > Servicekraft: 102/110/servicekraft
|
||||
Jobs > Kundenservice & Call Center: 102/105
|
||||
Jobs > Mini- & Nebenjobs: 102/107
|
||||
Jobs > Praktika: 102/125
|
||||
Jobs > Sozialer Sektor & Pflege: 102/123/weitere
|
||||
Jobs > Sozialer Sektor & Pflege > Altenpfleger/-in: 102/123/altenpfleger
|
||||
Jobs > Sozialer Sektor & Pflege > Arzthelfer/-in: 102/123/artzhelfer
|
||||
Jobs > Sozialer Sektor & Pflege > Erzieher/-in: 102/123/erzieher
|
||||
Jobs > Sozialer Sektor & Pflege > Krankenpfleger/-in: 102/123/krankenschwester
|
||||
Jobs > Sozialer Sektor & Pflege > Physiotherapeut/-in: 102/123/physiotherapeut
|
||||
Jobs > Transport, Logistik & Verkehr: 102/247/weitere
|
||||
Jobs > Transport, Logistik & Verkehr > Kraftfahrer/-in: 102/247/kraftfahrer
|
||||
Jobs > Transport, Logistik & Verkehr > Kurierfahrer/-in: 102/247/kurierfahrer
|
||||
Jobs > Transport, Logistik & Verkehr > Lagerhelfer/-in: 102/247/lagerhelfer
|
||||
Jobs > Transport, Logistik & Verkehr > Staplerfahrer/-in: 102/247/staplerfahrer
|
||||
Jobs > Vertrieb, Einkauf & Verkauf: 102/117/weitere
|
||||
Jobs > Vertrieb, Einkauf & Verkauf > Buchhalter/-in: 102/117/buchhalter
|
||||
Jobs > Vertrieb, Einkauf & Verkauf > Immobilienmakler/-in: 102/117/immobilienmakler
|
||||
Jobs > Vertrieb, Einkauf & Verkauf > Kaufmann/-frau: 102/117/kauffrau
|
||||
Jobs > Vertrieb, Einkauf & Verkauf > Verkäufer/-in: 102/117/verkaeufer
|
||||
Jobs > Weitere Jobs: 102/109/weitere
|
||||
Jobs > Weitere Jobs > Designer/-in & Grafiker/-in: 102/109/designer_grafiker
|
||||
Jobs > Weitere Jobs > Friseur/-in: 102/109/friseur
|
||||
Jobs > Weitere Jobs > Haushaltshilfe: 102/109/haushaltshilfe
|
||||
Jobs > Weitere Jobs > Hausmeister/-in: 102/109/hausmeister
|
||||
Jobs > Weitere Jobs > Reinigungskraft: 102/109/reinigungskraft
|
||||
|
||||
Mode & Beauty: 153/155
|
||||
|
||||
Mode & Beauty > Beauty & Gesundheit: 153/224/sonstiges
|
||||
Mode & Beauty > Beauty & Gesundheit > Make-Up & Gesichtspflege: 153/224/make_up
|
||||
Mode & Beauty > Beauty & Gesundheit > Haarpflege: 153/224/haarpflege
|
||||
Mode & Beauty > Beauty & Gesundheit > Körperpflege: 153/224/koerperpflege
|
||||
Mode & Beauty > Beauty & Gesundheit > Hand- & Nagelpflege: 153/224/handpflege
|
||||
Mode & Beauty > Beauty & Gesundheit > Gesundheit: 153/224/gesundheit
|
||||
|
||||
Mode & Beauty > Damenbekleidung: 153/154/sonstige
|
||||
Mode & Beauty > Damenbekleidung > Anzüge: 153/154/anzuege
|
||||
Mode & Beauty > Damenbekleidung > Bademode: 153/154/bademode
|
||||
Mode & Beauty > Damenbekleidung > Hemden & Blusen: 153/154/hemden_blusen
|
||||
Mode & Beauty > Damenbekleidung > Hochzeitsmode: 153/154/hochzeitsmode
|
||||
Mode & Beauty > Damenbekleidung > Hosen: 153/154/hosen
|
||||
Mode & Beauty > Damenbekleidung > Jacken & Mäntel: 153/154/jacken_maentel
|
||||
Mode & Beauty > Damenbekleidung > Jeans: 153/154/jeans
|
||||
Mode & Beauty > Damenbekleidung > Kostüme & Verkleidungen: 153/154/kostueme_verkleidungen
|
||||
Mode & Beauty > Damenbekleidung > Pullover: 153/154/pullover
|
||||
Mode & Beauty > Damenbekleidung > Röcke & Kleider: 153/154/roecke_kleider
|
||||
Mode & Beauty > Damenbekleidung > Shirts & Tops: 153/154/shirts_tops
|
||||
Mode & Beauty > Damenbekleidung > Shorts: 153/154/shorts
|
||||
Mode & Beauty > Damenbekleidung > Sportbekleidung: 153/154/sportbekleidung
|
||||
Mode & Beauty > Damenbekleidung > Umstandsmode: 153/154/umstandsmode
|
||||
|
||||
Mode & Beauty > Damenschuhe: 153/159/sonstiges
|
||||
Mode & Beauty > Damenschuhe > Ballerinas: 153/159/ballerinas
|
||||
Mode & Beauty > Damenschuhe > Halb- & Schnürschuhe: 153/159/halb_schnuerschuhe
|
||||
Mode & Beauty > Damenschuhe > Hausschuhe: 153/159/hausschuhe
|
||||
Mode & Beauty > Damenschuhe > Outdoor & Wanderschuhe: 153/159/outdoor_wanderschuhe
|
||||
Mode & Beauty > Damenschuhe > Pumps & High Heels: 153/159/pumps
|
||||
Mode & Beauty > Damenschuhe > Sandalen: 153/159/sandalen
|
||||
Mode & Beauty > Damenschuhe > Sneaker & Sportschuhe: 153/159/sneaker_sportschuhe
|
||||
Mode & Beauty > Damenschuhe > Stiefel & Stiefeletten: 153/159/stiefel
|
||||
|
||||
Mode & Beauty > Herrenbekleidung: 153/160/sonstige
|
||||
Mode & Beauty > Herrenbekleidung > Anzüge: 153/160/anzuege
|
||||
Mode & Beauty > Herrenbekleidung > Bademode: 153/160/bademode
|
||||
Mode & Beauty > Herrenbekleidung > Hemden: 153/160/hemden
|
||||
Mode & Beauty > Herrenbekleidung > Hochzeitsmode: 153/160/hochzeitsmode
|
||||
Mode & Beauty > Herrenbekleidung > Hosen: 153/160/hosen
|
||||
Mode & Beauty > Herrenbekleidung > Jacken & Mäntel: 153/160/jacken_maentel
|
||||
Mode & Beauty > Herrenbekleidung > Jeans: 153/160/jeans
|
||||
Mode & Beauty > Herrenbekleidung > Kostüme & Verkleidungen: 153/160/kostueme_verkleidungen
|
||||
Mode & Beauty > Herrenbekleidung > Pullover: 153/160/pullover
|
||||
Mode & Beauty > Herrenbekleidung > Shirts: 153/160/shirts
|
||||
Mode & Beauty > Herrenbekleidung > Shorts: 153/160/shorts
|
||||
Mode & Beauty > Herrenbekleidung > Sportbekleidung: 153/160/sportbekleidung
|
||||
|
||||
Mode & Beauty > Herrenschuhe: 153/158/sonstiges
|
||||
Mode & Beauty > Herrenschuhe > Halb- & Schnürschuhe: 153/158/halb_schnuerschuhe
|
||||
Mode & Beauty > Herrenschuhe > Hausschuhe: 153/158/hausschuhe
|
||||
Mode & Beauty > Herrenschuhe > Sandalen: 153/158/sandalen
|
||||
Mode & Beauty > Herrenschuhe > Sneaker & Sportschuhe: 153/158/sneaker_sportschuhe
|
||||
Mode & Beauty > Herrenschuhe > Stiefel & Stiefeletten: 153/158/stiefel
|
||||
Mode & Beauty > Herrenschuhe > Outdoor & Wanderschuhe: 153/158/outdoor_wanderschuhe
|
||||
|
||||
Mode & Beauty > Taschen & Accessoires: 153/156/sonstiges
|
||||
Mode & Beauty > Taschen & Accessoires > Mützen, Schals & Handschuhe: 153/156/muetzen_schals_handschuhe
|
||||
Mode & Beauty > Taschen & Accessoires > Sonnenbrillen: 153/156/sonnenbrillen
|
||||
Mode & Beauty > Taschen & Accessoires > Taschen & Rucksäcke: 153/156/taschen_rucksaecke
|
||||
|
||||
Mode & Beauty > Uhren & Schmuck > Schmuck: 153/157/schmuck
|
||||
Mode & Beauty > Uhren & Schmuck > Uhren: 153/157/uhren
|
||||
|
||||
Musik, Filme & Bücher: 73/75
|
||||
Musik, Filme & Bücher > Bücher & Zeitschriften: 73/76
|
||||
Musik, Filme & Bücher > Bücher & Zeitschriften > Antiquarische Bücher: 73/76/antiquarische_buecher
|
||||
Musik, Filme & Bücher > Bücher & Zeitschriften > Kinderbücher: 73/76/kinderbuecher
|
||||
Musik, Filme & Bücher > Bücher & Zeitschriften > Krimis & Thriller: 73/76/krimis_thriller
|
||||
Musik, Filme & Bücher > Bücher & Zeitschriften > Kunst & Kultur: 73/76/kunst_kultur
|
||||
Musik, Filme & Bücher > Bücher & Zeitschriften > Sachbücher: 73/76/sachbuecher
|
||||
Musik, Filme & Bücher > Bücher & Zeitschriften > Science Fiction: 73/76/science_fiction
|
||||
Musik, Filme & Bücher > Bücher & Zeitschriften > Unterhaltungsliteratur: 73/76/unterhaltungsliteratur
|
||||
Musik, Filme & Bücher > Bücher & Zeitschriften > Zeitgenössische Literatur & Klassiker: 73/76/zeitgenoessische_literatur_klassiker
|
||||
Musik, Filme & Bücher > Bücher & Zeitschriften > Zeitschriften: 73/76/zeitschriften
|
||||
Musik, Filme & Bücher > Büro & Schreibwaren: 73/281
|
||||
Musik, Filme & Bücher > Comics: 73/284
|
||||
Musik, Filme & Bücher > Fachbücher, Schule & Studium: 73/77
|
||||
Musik, Filme & Bücher > Film & DVD: 73/79
|
||||
Musik, Filme & Bücher > Musik & CDs: 73/78
|
||||
Musik, Filme & Bücher > Musikinstrumente: 73/74
|
||||
|
||||
Nachbarschaftshilfe: 400/401
|
||||
|
||||
Unterricht & Kurse: 235/270
|
||||
Unterricht & Kurse > Beauty & Gesundheit: 235/269
|
||||
Unterricht & Kurse > Computerkurse: 235/260
|
||||
Unterricht & Kurse > Esoterik & Spirituelles: 235/265
|
||||
Unterricht & Kurse > Kochen & Backen: 235/263
|
||||
Unterricht & Kurse > Kunst & Gestaltung: 235/264
|
||||
Unterricht & Kurse > Musik & Gesang: 235/262
|
||||
Unterricht & Kurse > Nachhilfe: 235/268
|
||||
Unterricht & Kurse > Sportkurse: 235/261
|
||||
Unterricht & Kurse > Sprachkurse: 235/271
|
||||
Unterricht & Kurse > Tanzkurse: 235/267
|
||||
Unterricht & Kurse > Weiterbildung: 235/266
|
||||
|
||||
Verschenken & Tauschen > Tauschen: 272/273
|
||||
Verschenken & Tauschen > Verleihen: 272/274
|
||||
Verschenken & Tauschen > Verschenken: 272/192
|
||||
|
||||
200
src/kleinanzeigen_bot/resources/categories_old.yaml
Normal file
200
src/kleinanzeigen_bot/resources/categories_old.yaml
Normal file
@@ -0,0 +1,200 @@
|
||||
###############################################################################
|
||||
# Deprecated category names for backward compatiblity, don't use them anymore!
|
||||
###############################################################################
|
||||
# Elektronik
|
||||
Elektronik: 161/168
|
||||
|
||||
## Audio & Hifi
|
||||
Audio_und_Hifi: 161/172/sonstiges
|
||||
|
||||
CD_Player: 161/172/cd_player
|
||||
Kopfhörer: 161/172/lautsprecher_kopfhoerer
|
||||
Lautsprecher: 161/172/lautsprecher_kopfhoerer
|
||||
MP3_Player: 161/172/mp3_player
|
||||
Radio: 161/172/radio_receiver
|
||||
Reciver: 161/172/radio_receiver
|
||||
Stereoanlagen: 161/172/stereoanlagen
|
||||
|
||||
## Dienstleistungen Elektronik
|
||||
Dienstleistungen_Elektronik: 161/226
|
||||
|
||||
## Foto
|
||||
Foto: 161/245/other
|
||||
|
||||
Kameras: 161/245/camera
|
||||
Objektive: 161/245/lens
|
||||
Foto_Zubehör: 161/245/equipment
|
||||
Kamera_Equipment: 161/245/camera_and_equipment
|
||||
|
||||
## Handy & Telefon
|
||||
Handys: 161/173/sonstige
|
||||
|
||||
Handy_Apple: 161/173/apple
|
||||
Handy_HTC: 161/173/htc_handy
|
||||
Handy_LG: 161/173/lg_handy
|
||||
Handy_Motorola: 161/173/motorola_handy
|
||||
Handy_Nokia: 161/173/nokia_handy
|
||||
Handy_Samsung: 161/173/samsung_handy
|
||||
Handy_Siemens: 161/173/siemens_handy
|
||||
Handy_Sony: 161/173/sony_handy
|
||||
Faxgeräte: 161/173/faxgeraete
|
||||
Telefone: 161/173/telefone
|
||||
|
||||
## Haushaltsgeräte
|
||||
Haushaltsgeräte: 161/176/sonstige
|
||||
|
||||
Haushaltkleingeräte: 161/176/haushaltskleingeraete
|
||||
Herde: 161/176/herde_backoefen
|
||||
Backöfen: 161/176/herde_backoefen
|
||||
Kaffemaschinen: 161/176/kaffee_espressomaschinen
|
||||
Espressomaschinen: 161/176/kaffee_espressomaschinen
|
||||
Kühlschränke: 161/176/kuehlschraenke_gefriergeraete
|
||||
Gefriergeräte: 161/176/kuehlschraenke_gefriergeraete
|
||||
Spülmaschinen: 161/176/spuelmaschinen
|
||||
Staubsauger: 161/176/staubsauger
|
||||
Waschmaschinen: 161/176/waschmaschinen_trockner
|
||||
Trockner: 161/176/waschmaschinen_trockner
|
||||
|
||||
## Konsolen
|
||||
Konsolen: 161/279/weitere
|
||||
|
||||
Pocket_Konsolen: 161/279/dsi_psp
|
||||
Playstation: 161/279/playstation
|
||||
XBox: 161/279/xbox
|
||||
Wii: 161/279/wii
|
||||
|
||||
## Notebooks
|
||||
Notebooks: 161/278
|
||||
|
||||
## PCs
|
||||
PCs: 161/228
|
||||
|
||||
## PC-Zubehör & Software
|
||||
PC-Zubehör: 161/225/sonstiges
|
||||
|
||||
Drucker: 161/225/drucker_scanner
|
||||
Scanner: 161/225/drucker_scanner
|
||||
Festplatten: 161/225/festplatten_laufwerke
|
||||
Laufwerke: 161/225/festplatten_laufwerke
|
||||
Gehäuse: 161/225/gehaeuse
|
||||
Grafikkarten: 161/225/grafikkarten
|
||||
Kabel: 161/225/kabel_adapter
|
||||
Adapter: 161/225/kabel_adapter
|
||||
Mainboards: 161/225/mainboards
|
||||
Monitore: 161/225/monitore
|
||||
Multimedia: 161/225/multimedia
|
||||
Netzwerk: 161/225/netzwerk_modem
|
||||
CPUs: 161/225/prozessor_cpu
|
||||
Prozessoren: 161/225/prozessor_cpu
|
||||
Speicher: 161/225/speicher
|
||||
Software: 161/225/software
|
||||
Mäuse: 161/225/tastatur_maus
|
||||
Tastaturen: 161/225/tastatur_maus
|
||||
|
||||
## Tablets & Reader
|
||||
Tablets_Reader: 161/285/weitere
|
||||
|
||||
iPad: 161/285/ipad
|
||||
Kindle: 161/285/kindle
|
||||
Tablets_Samsung: 161/285/samsung_tablets
|
||||
|
||||
## TV & Video
|
||||
TV_Video: 161/175/weitere
|
||||
|
||||
DVD-Player: 161/175/dvdplayer_recorder
|
||||
Recorder: 161/175/dvdplayer_recorder
|
||||
Fernseher: 161/175/fernseher
|
||||
Reciever: 161/175/tv_receiver
|
||||
|
||||
## Videospiele
|
||||
Videospiele: 161/227/sonstige
|
||||
|
||||
Videospiele_DS: 161/227/dsi_psp
|
||||
Videospiele_PSP: 161/227/dsi_psp
|
||||
Videospiele_Nintendo: 161/227/nintendo
|
||||
Videospiele_Playstation: 161/227/playstation
|
||||
Videospiele_XBox: 161/227/xbox
|
||||
Videospiele_Wii: 161/227/wii
|
||||
Videospiele_PC: 161/227/pc_spiele
|
||||
|
||||
# Auto, Rad & Boot
|
||||
Autoreifen: 210/223/reifen_felgen
|
||||
|
||||
# Freizeit, Hobby & Nachbarschaft
|
||||
Sammeln: 185/234/sonstige
|
||||
|
||||
# Mode & Beauty
|
||||
Beauty: 153/224/sonstiges
|
||||
Gesundheit: 153/224/gesundheit
|
||||
Mode: 153/155
|
||||
|
||||
# Mode & Beauty > Damenschuhe
|
||||
Damenschuhe: 153/159/sonstiges
|
||||
Damen_Ballerinas: 153/159/ballerinas
|
||||
Damen_Halbschuhe: 153/159/halb_schnuerschuhe
|
||||
Damen_Hausschuhe: 153/159/hausschuhe
|
||||
Damen_High_Heels: 153/159/pumps
|
||||
Damen_Pumps: 153/159/pumps
|
||||
Damen_Sandalen: 153/159/sandalen
|
||||
Damen_Schnürschuhe: 153/159/halb_schnuerschuhe
|
||||
Damen_Sportschuche: 153/159/sneaker_sportschuhe
|
||||
Damen_Sneaker: 153/159/sneaker_sportschuhe
|
||||
Damen_Stiefel: 153/159/stiefel
|
||||
Damen_Stiefeletten: 153/159/stiefel
|
||||
Damen_Outdoorschuhe: 153/159/outdoor_wanderschuhe
|
||||
Damen_Wanderschuhe: 153/159/outdoor_wanderschuhe
|
||||
|
||||
# Mode & Beauty > Herrenschuhe
|
||||
Herrenschuhe: 153/158/sonstiges
|
||||
Herren_Halbschuhe: 153/158/halb_schnuerschuhe
|
||||
Herren_Hausschuhe: 153/158/hausschuhe
|
||||
Herren_Sandalen: 153/158/sandalen
|
||||
Herren_Schnürschuhe: 153/158/halb_schnuerschuhe
|
||||
Herren_Sportschuche: 153/158/sneaker_sportschuhe
|
||||
Herren_Sneaker: 153/158/sneaker_sportschuhe
|
||||
Herren_Stiefel: 153/158/stiefel
|
||||
Herren_Stiefeletten: 153/158/stiefel
|
||||
Herren_Outdoorschuhe: 153/158/outdoor_wanderschuhe
|
||||
Herren_Wanderschuhe: 153/158/outdoor_wanderschuhe
|
||||
|
||||
# Familie, Kind & Baby
|
||||
Familie_Kind_Baby: 17/18
|
||||
Altenpflege: 17/236
|
||||
Babysitter: 17/237
|
||||
Buggys: 17/25
|
||||
Babyschalen: 17/21
|
||||
Baby-Ausstattung: 17/258
|
||||
Kinderbetreuung: 17/237
|
||||
Kindersitze: 17/21
|
||||
Kinderwagen: 17/25
|
||||
|
||||
# Familie, Kind & Baby > Spielzeug
|
||||
Spielzeug: 17/23/sonstiges
|
||||
Actionfiguren: 17/23/actionfiguren
|
||||
Babyspielzeug: 17/23/babyspielzeug
|
||||
Barbie: 17/23/barbie
|
||||
Dreirad: 17/23/dreirad
|
||||
Gesellschaftsspiele: 17/23/gesellschaftsspiele
|
||||
Holzspielzeug: 17/23/holzspielzeug
|
||||
Duplo: 17/23/lego_duplo
|
||||
LEGO: 17/23/lego_duplo
|
||||
Lernspielzeug: 17/23/lernspielzeug
|
||||
Playmobil: 17/23/playmobil
|
||||
Puppen: 17/23/puppen
|
||||
Spielzeugautos: 17/23/spielzeug_autos
|
||||
Spielzeug_draussen: 17/23/spielzeug_draussen
|
||||
Stofftiere: 17/23/stofftiere
|
||||
|
||||
# Haus & Garten > Wohnzimmer
|
||||
Wohnzimmer_Regale: 80/88/regale
|
||||
Wohnzimmer_Schraenke: 80/88/schraenke
|
||||
Wohnzimmer_Sitzmoebel: 80/88/sitzmoebel
|
||||
Wohnzimmer_Sofas_Sitzgarnituren: 80/88/sofas_sitzgarnituren
|
||||
Wohnzimmer_Tische: 80/88/tische
|
||||
Wohnzimmer_TV_Moebel: 80/88/tv_moebel
|
||||
Wohnzimmer_Sonstiges: 80/88/sonstiges
|
||||
|
||||
# Verschenken & Tauschen
|
||||
Tauschen: 272/273
|
||||
Verleihen: 272/274
|
||||
Verschenken: 272/192
|
||||
@@ -4,39 +4,46 @@ ad_files:
|
||||
# default values for ads, can be overwritten in each ad configuration file
|
||||
ad_defaults:
|
||||
active: true
|
||||
type: OFFER # one of: OFFER, WANTED
|
||||
description:
|
||||
prefix: ""
|
||||
suffix: ""
|
||||
price_type: NEGOTIABLE # one of: FIXED, NEGOTIABLE, GIVE_AWAY, NOT_APPLICABLE
|
||||
shipping_type: SHIPPING # one of: PICKUP, SHIPPING, NOT_APPLICABLE
|
||||
sell_directly: false # requires shipping_options to take effect
|
||||
type: OFFER # one of: OFFER, WANTED
|
||||
description_prefix: "" # prefix for the ad description
|
||||
description_suffix: "" # suffix for the ad description
|
||||
|
||||
price_type: NEGOTIABLE # one of: FIXED, NEGOTIABLE, GIVE_AWAY, NOT_APPLICABLE
|
||||
shipping_type: SHIPPING # one of: PICKUP, SHIPPING, NOT_APPLICABLE
|
||||
sell_directly: false # requires shipping_options to take effect
|
||||
contact:
|
||||
name: ""
|
||||
street: ""
|
||||
zipcode:
|
||||
phone: "" # IMPORTANT: surround phone number with quotes to prevent removal of leading zeros
|
||||
republication_interval: 7 # every X days ads should be re-published
|
||||
phone: "" # IMPORTANT: surround phone number with quotes to prevent removal of leading zeros
|
||||
republication_interval: 7 # every X days ads should be re-published
|
||||
|
||||
# additional name to category ID mappings, see default list at
|
||||
# https://github.com/Second-Hand-Friends/kleinanzeigen-bot/blob/main/kleinanzeigen_bot/resources/categories.yaml
|
||||
# Notebooks: 161/278 # Elektronik > Notebooks
|
||||
# Autoteile: 210/223/sonstige_autoteile # Auto, Rad & Boot > Autoteile & Reifen > Weitere Autoteile
|
||||
categories: []
|
||||
# https://github.com/Second-Hand-Friends/kleinanzeigen-bot/blob/main/src/kleinanzeigen_bot/resources/categories.yaml
|
||||
#
|
||||
# categories:
|
||||
# Elektronik > Notebooks: 161/278
|
||||
# Jobs > Praktika: 102/125
|
||||
categories: {}
|
||||
|
||||
download:
|
||||
# if true, all shipping options matching the package size will be included
|
||||
include_all_matching_shipping_options: false
|
||||
# list of shipping options to exclude, e.g. ["DHL_2", "DHL_5"]
|
||||
excluded_shipping_options: []
|
||||
|
||||
publishing:
|
||||
delete_old_ads: "AFTER_PUBLISH" # one of: AFTER_PUBLISH, BEFORE_PUBLISH, NEVER
|
||||
delete_old_ads_by_title: true # only works if delete_old_ads is set to BEFORE_PUBLISH
|
||||
|
||||
# browser configuration
|
||||
browser:
|
||||
# https://peter.sh/experiments/chromium-command-line-switches/
|
||||
arguments:
|
||||
# https://stackoverflow.com/a/50725918/5116073
|
||||
- --disable-dev-shm-usage
|
||||
- --no-sandbox
|
||||
# --headless
|
||||
# --start-maximized
|
||||
binary_location: # path to custom browser executable, if not specified will be looked up on PATH
|
||||
extensions: [] # a list of .crx extension files to be loaded
|
||||
arguments: []
|
||||
binary_location: # path to custom browser executable, if not specified will be looked up on PATH
|
||||
extensions: [] # a list of .crx extension files to be loaded
|
||||
use_private_window: true
|
||||
user_data_dir: "" # see https://github.com/chromium/chromium/blob/main/docs/user_data_dir.md
|
||||
user_data_dir: "" # see https://github.com/chromium/chromium/blob/main/docs/user_data_dir.md
|
||||
profile_name: ""
|
||||
|
||||
# login credentials
|
||||
|
||||
266
src/kleinanzeigen_bot/resources/translations.de.yaml
Normal file
266
src/kleinanzeigen_bot/resources/translations.de.yaml
Normal file
@@ -0,0 +1,266 @@
|
||||
#################################################
|
||||
getopt.py:
|
||||
#################################################
|
||||
do_longs:
|
||||
"option --%s requires argument": "Option --%s benötigt ein Argument"
|
||||
"option --%s must not have an argument": "Option --%s darf kein Argument haben"
|
||||
long_has_args:
|
||||
"option --%s not recognized": "Option --%s unbekannt"
|
||||
"option --%s not a unique prefix": "Option --%s ist kein eindeutiger Prefix"
|
||||
do_shorts:
|
||||
"option -%s requires argument": "Option -%s benötigt ein Argument"
|
||||
short_has_arg:
|
||||
"option -%s not recognized": "Option -%s unbekannt"
|
||||
|
||||
#################################################
|
||||
kleinanzeigen_bot/__main__.py:
|
||||
#################################################
|
||||
module:
|
||||
"[INFO] Captcha detected. Sleeping %s before restart...": "[INFO] Captcha erkannt. Warte %s h bis zum Neustart..."
|
||||
|
||||
#################################################
|
||||
kleinanzeigen_bot/__init__.py:
|
||||
#################################################
|
||||
module:
|
||||
"Direct execution not supported. Use 'pdm run app'": "Direkte Ausführung nicht unterstützt. Bitte 'pdm run app' verwenden"
|
||||
|
||||
configure_file_logging:
|
||||
"Logging to [%s]...": "Protokollierung in [%s]..."
|
||||
"App version: %s": "App Version: %s"
|
||||
"Python version: %s": "Python Version: %s"
|
||||
|
||||
__check_ad_changed:
|
||||
"Hash comparison for [%s]:": "Hash-Vergleich für [%s]:"
|
||||
" Stored hash: %s": " Gespeicherter Hash: %s"
|
||||
" Current hash: %s": " Aktueller Hash: %s"
|
||||
"Changes detected in ad [%s], will republish": "Änderungen in Anzeige [%s] erkannt, wird erneut veröffentlicht"
|
||||
|
||||
load_ads:
|
||||
"Searching for ad config files...": "Suche nach Anzeigendateien..."
|
||||
" -> found %s": "-> %s gefunden"
|
||||
"ad config file": "Anzeigendatei"
|
||||
"Start fetch task for the ad(s) with id(s):": "Starte Abrufaufgabe für die Anzeige(n) mit ID(s):"
|
||||
" -> SKIPPED: inactive ad [%s]": " -> ÜBERSPRUNGEN: inaktive Anzeige [%s]"
|
||||
" -> SKIPPED: ad [%s] is not in list of given ids.": " -> ÜBERSPRUNGEN: Anzeige [%s] ist nicht in der Liste der angegebenen IDs."
|
||||
" -> SKIPPED: ad [%s] is not new. already has an id assigned.": " -> ÜBERSPRUNGEN: Anzeige [%s] ist nicht neu. Eine ID wurde bereits zugewiesen."
|
||||
"Category [%s] unknown. Using category [%s] with ID [%s] instead.": "Kategorie [%s] unbekannt. Verwende stattdessen Kategorie [%s] mit ID [%s]."
|
||||
"Loaded %s": "%s geladen"
|
||||
"ad": "Anzeige"
|
||||
|
||||
load_config:
|
||||
"Config file %s does not exist. Creating it with default values...": "Konfigurationsdatei %s existiert nicht. Erstelle sie mit Standardwerten..."
|
||||
"config": "Konfiguration"
|
||||
" -> found %s": "-> %s gefunden"
|
||||
"category": "Kategorie"
|
||||
|
||||
login:
|
||||
"Checking if already logged in...": "Überprüfe, ob bereits eingeloggt..."
|
||||
"Already logged in as [%s]. Skipping login.": "Bereits eingeloggt als [%s]. Überspringe Anmeldung."
|
||||
"Opening login page...": "Öffne Anmeldeseite..."
|
||||
"# Captcha present! Please solve the captcha.": "# Captcha vorhanden! Bitte lösen Sie das Captcha."
|
||||
|
||||
handle_after_login_logic:
|
||||
"# Device verification message detected. Please follow the instruction displayed in the Browser.": "# Nachricht zur Geräteverifizierung erkannt. Bitte den Anweisungen im Browser folgen."
|
||||
"Press ENTER when done...": "EINGABETASTE drücken, wenn erledigt..."
|
||||
"Handling GDPR disclaimer...": "Verarbeite DSGVO-Hinweis..."
|
||||
|
||||
delete_ads:
|
||||
"Processing %s/%s: '%s' from [%s]...": "Verarbeite %s/%s: '%s' von [%s]..."
|
||||
"DONE: Deleted %s": "FERTIG: %s gelöscht"
|
||||
"ad": "Anzeige"
|
||||
|
||||
delete_ad:
|
||||
"Deleting ad '%s' if already present...": "Lösche Anzeige '%s', falls bereits vorhanden..."
|
||||
"Expected CSRF Token not found in HTML content!": "Erwartetes CSRF-Token wurde im HTML-Inhalt nicht gefunden!"
|
||||
" -> deleting %s '%s'...": " -> lösche %s '%s'..."
|
||||
|
||||
publish_ads:
|
||||
"Processing %s/%s: '%s' from [%s]...": "Verarbeite %s/%s: '%s' von [%s]..."
|
||||
"Skipping because ad is reserved": "Überspringen, da Anzeige reserviert ist"
|
||||
"DONE: (Re-)published %s": "FERTIG: %s (erneut) veröffentlicht"
|
||||
"ad": "Anzeige"
|
||||
|
||||
publish_ad:
|
||||
"Publishing ad '%s'...": "Veröffentliche Anzeige '%s'..."
|
||||
"Failed to set shipping attribute for type '%s'!": "Fehler beim setzen des Versandattributs für den Typ '%s'!"
|
||||
"# Captcha present! Please solve the captcha.": "# Captcha vorhanden! Bitte lösen Sie das Captcha."
|
||||
"Press a key to continue...": "Eine Taste drücken, um fortzufahren..."
|
||||
" -> SUCCESS: ad published with ID %s": " -> ERFOLG: Anzeige mit ID %s veröffentlicht"
|
||||
" -> effective ad meta:": " -> effektive Anzeigen-Metadaten:"
|
||||
"Could not set city from location": "Stadt konnte nicht aus dem Standort gesetzt werden"
|
||||
"Captcha recognized - auto-restart enabled, abort run...": "Captcha erkannt - Auto-Neustart aktiviert, Durchlauf wird beendet..."
|
||||
|
||||
__set_condition:
|
||||
"Unable to close condition dialog!": "Kann den Dialog für Artikelzustand nicht schließen!"
|
||||
"Unable to open condition dialog and select condition [%s]": "Zustandsdialog konnte nicht geöffnet und Zustand [%s] nicht ausgewählt werden"
|
||||
"Unable to select condition [%s]": "Zustand [%s] konnte nicht ausgewählt werden"
|
||||
|
||||
__upload_images:
|
||||
" -> found %s": "-> %s gefunden"
|
||||
"image": "Bild"
|
||||
" -> uploading image [%s]": " -> Lade Bild [%s] hoch"
|
||||
|
||||
__check_ad_republication:
|
||||
" -> SKIPPED: ad [%s] was last published %d days ago. republication is only required every %s days": " -> ÜBERSPRUNGEN: Anzeige [%s] wurde zuletzt vor %d Tagen veröffentlicht. Erneute Veröffentlichung ist erst nach %s Tagen erforderlich"
|
||||
|
||||
__set_special_attributes:
|
||||
"Found %i special attributes": "%i spezielle Attribute gefunden"
|
||||
"Setting special attribute [%s] to [%s]...": "Setze spezielles Attribut [%s] auf [%s]..."
|
||||
"Successfully set attribute field [%s] to [%s]...": "Attributfeld [%s] erfolgreich auf [%s] gesetzt..."
|
||||
"Attribute field '%s' could not be found.": "Attributfeld '%s' konnte nicht gefunden werden."
|
||||
"Attribute field '%s' seems to be a select...": "Attributfeld '%s' scheint ein Auswahlfeld zu sein..."
|
||||
"Attribute field '%s' is not of kind radio button.": "Attributfeld '%s' ist kein Radiobutton."
|
||||
"Attribute field '%s' seems to be a checkbox...": "Attributfeld '%s' scheint eine Checkbox zu sein..."
|
||||
"Attribute field '%s' seems to be a text input...": "Attributfeld '%s' scheint ein Texteingabefeld zu sein..."
|
||||
|
||||
download_ads:
|
||||
"Scanning your ad overview...": "Scanne Anzeigenübersicht..."
|
||||
"%s found.": "%s gefunden."
|
||||
"ad": "Anzeige"
|
||||
"Starting download of all ads...": "Starte den Download aller Anzeigen..."
|
||||
"%d of %d ads were downloaded from your profile.": "%d von %d Anzeigen wurden aus Ihrem Profil heruntergeladen."
|
||||
"Starting download of not yet downloaded ads...": "Starte den Download noch nicht heruntergeladener Anzeigen..."
|
||||
"The ad with id %d has already been saved.": "Die Anzeige mit der ID %d wurde bereits gespeichert."
|
||||
"%s were downloaded from your profile.": "%s wurden aus Ihrem Profil heruntergeladen."
|
||||
"new ad": "neue Anzeige"
|
||||
"Starting download of ad(s) with the id(s):": "Starte Download der Anzeige(n) mit den ID(s):"
|
||||
"Downloaded ad with id %d": "Anzeige mit der ID %d heruntergeladen"
|
||||
"The page with the id %d does not exist!": "Die Seite mit der ID %d existiert nicht!"
|
||||
|
||||
parse_args:
|
||||
"Use --help to display available options.": "Mit --help können die verfügbaren Optionen angezeigt werden."
|
||||
"More than one command given: %s": "Mehr als ein Befehl angegeben: %s"
|
||||
|
||||
run:
|
||||
"DONE: No configuration errors found.": "FERTIG: Keine Konfigurationsfehler gefunden."
|
||||
"You provided no ads selector. Defaulting to \"due\".": "Es wurden keine Anzeigen-Selektor angegeben. Es wird \"due\" verwendet."
|
||||
"DONE: No new/outdated ads found.": "FERTIG: Keine neuen/veralteten Anzeigen gefunden."
|
||||
"DONE: No ads to delete found.": "FERTIG: Keine zu löschnenden Anzeigen gefunden."
|
||||
"You provided no ads selector. Defaulting to \"new\".": "Es wurden keine Anzeigen-Selektor angegeben. Es wird \"new\" verwendet."
|
||||
"Unknown command: %s": "Unbekannter Befehl: %s"
|
||||
|
||||
fill_login_data_and_send:
|
||||
"Logging in as [%s]...": "Anmeldung als [%s]..."
|
||||
|
||||
__set_shipping:
|
||||
"Unable to close shipping dialog!": "Versanddialog konnte nicht geschlossen werden!"
|
||||
|
||||
__set_shipping_options:
|
||||
"Unable to close shipping dialog!": "Versanddialog konnte nicht geschlossen werden!"
|
||||
|
||||
#################################################
|
||||
kleinanzeigen_bot/extract.py:
|
||||
#################################################
|
||||
download_ad:
|
||||
"Created ads directory at ./%s.": "Verzeichnis für Anzeigen erstellt unter ./%s."
|
||||
"Deleting current folder of ad %s...": "Lösche aktuellen Ordner der Anzeige %s..."
|
||||
"New directory for ad created at %s.": "Neues Verzeichnis für Anzeige erstellt unter %s."
|
||||
|
||||
_download_images_from_ad_page:
|
||||
"Found %s.": "%s gefunden."
|
||||
"Downloaded %s.": "%s heruntergeladen."
|
||||
"NEXT button in image gallery somehow missing, aborting image fetching.": "WEITER-Button in der Bildergalerie fehlt, breche Bildabruf ab."
|
||||
"No image area found. Continuing without downloading images.": "Keine Bildbereiche gefunden. Fahre ohne Bilder-Download fort."
|
||||
|
||||
extract_ad_id_from_ad_url:
|
||||
"Failed to extract ad ID from URL '%s': %s": "Fehler beim Extrahieren der Anzeigen-ID aus der URL '%s': %s"
|
||||
|
||||
extract_own_ads_urls:
|
||||
"Ad list container #my-manageitems-adlist not found. Maybe no ads present?": "Anzeigenlistencontainer #my-manageitems-adlist nicht gefunden. Vielleicht sind keine Anzeigen vorhanden?"
|
||||
"Multiple ad pages detected.": "Mehrere Anzeigenseiten erkannt."
|
||||
"Next button found but is disabled. Assuming single effective page.": "Weiter-Button gefunden, aber deaktiviert. Es wird von einer einzelnen effektiven Seite ausgegangen."
|
||||
"No \"Naechste\" button found within pagination. Assuming single page.": "Kein \"Nächste\"-Button in der Paginierung gefunden. Es wird von einer einzelnen Seite ausgegangen."
|
||||
"No pagination controls found. Assuming single page.": "Keine Paginierungssteuerung gefunden. Es wird von einer einzelnen Seite ausgegangen."
|
||||
"Assuming single page due to error during pagination check.": "Es wird von einer einzelnen Seite ausgegangen wegen eines Fehlers bei der Paginierungsprüfung."
|
||||
"Navigating to next page...": "Navigiere zur nächsten Seite..."
|
||||
"Last ad overview page explored (no enabled \"Naechste\" button found).": "Letzte Anzeigenübersichtsseite erkundet (kein aktivierter \"Nächste\"-Button gefunden)."
|
||||
"No pagination controls found after scrolling/waiting. Assuming last page.": "Keine Paginierungssteuerung nach dem Scrollen/Warten gefunden. Es wird von der letzten Seite ausgegangen."
|
||||
"No ad URLs were extracted.": "Es wurden keine Anzeigen-URLs extrahiert."
|
||||
"Could not find ad list container or items on page %s.": "Anzeigenlistencontainer oder Elemente auf Seite %s nicht gefunden."
|
||||
"Error during pagination detection: %s": "Fehler bei der Paginierungserkennung: %s"
|
||||
"Error during pagination navigation: %s": "Fehler bei der Paginierungsnavigation: %s"
|
||||
"Error extracting refs on page %s: %s": "Fehler beim Extrahieren der Referenzen auf Seite %s: %s"
|
||||
"Extracting ads from page %s...": "Extrahiere Anzeigen von Seite %s..."
|
||||
"Found %s ad items on page %s.": "%s Anzeigen-Elemente auf Seite %s gefunden."
|
||||
"Successfully extracted %s refs from page %s.": "%s Referenzen von Seite %s erfolgreich extrahiert."
|
||||
|
||||
navigate_to_ad_page:
|
||||
"There is no ad under the given ID.": "Es gibt keine Anzeige unter der angegebenen ID."
|
||||
"A popup appeared!": "Ein Popup ist erschienen!"
|
||||
|
||||
_extract_ad_page_info:
|
||||
"Extracting information from ad with title \"%s\"": "Extrahiere Informationen aus Anzeige mit Titel \"%s\""
|
||||
|
||||
_extract_contact_from_ad_page:
|
||||
"No street given in the contact.": "Keine Straße in den Kontaktdaten angegeben."
|
||||
|
||||
#################################################
|
||||
kleinanzeigen_bot/utils/i18n.py:
|
||||
#################################################
|
||||
_detect_locale:
|
||||
"Error detecting language on Windows": "Fehler bei der Spracherkennung unter Windows"
|
||||
|
||||
#################################################
|
||||
kleinanzeigen_bot/utils/error_handlers.py:
|
||||
#################################################
|
||||
on_sigint:
|
||||
"Aborted on user request.": "Auf Benutzeranfrage abgebrochen."
|
||||
on_exception:
|
||||
"%s: %s": "%s: %s"
|
||||
"Unknown exception occurred (missing exception info): ex_type=%s, ex_value=%s": "Unbekannter Fehler aufgetreten (fehlende Fehlerinformation): ex_type=%s, ex_value=%s"
|
||||
|
||||
#################################################
|
||||
kleinanzeigen_bot/utils/loggers.py:
|
||||
#################################################
|
||||
format:
|
||||
"CRITICAL": "KRITISCH"
|
||||
"ERROR": "FEHLER"
|
||||
"WARNING": "WARNUNG"
|
||||
|
||||
#################################################
|
||||
kleinanzeigen_bot/utils/dicts.py:
|
||||
#################################################
|
||||
load_dict_if_exists:
|
||||
"Loading %s[%s]...": "Lade %s[%s]..."
|
||||
" from ": " von "
|
||||
"Unsupported file type. The filename \"%s\" must end with *.json, *.yaml, or *.yml": "Nicht unterstützter Dateityp. Der Dateiname \"%s\" muss mit *.json, *.yaml oder *.yml enden"
|
||||
save_dict:
|
||||
"Saving [%s]...": "Speichere [%s]..."
|
||||
load_dict_from_module:
|
||||
"Loading %s[%s.%s]...": "Lade %s[%s.%s]..."
|
||||
|
||||
#################################################
|
||||
kleinanzeigen_bot/utils/web_scraping_mixin.py:
|
||||
#################################################
|
||||
create_browser_session:
|
||||
"Creating Browser session...": "Erstelle Browser-Sitzung..."
|
||||
"Using existing browser process at %s:%s": "Verwende existierenden Browser-Prozess unter %s:%s"
|
||||
"New Browser session is %s": "Neue Browser-Sitzung ist %s"
|
||||
" -> Browser binary location: %s": " -> Browser-Programmpfad: %s"
|
||||
" -> Browser profile name: %s": " -> Browser-Profilname: %s"
|
||||
" -> Browser user data dir: %s": " -> Browser-Benutzerdatenverzeichnis: %s"
|
||||
" -> Custom Browser argument: %s": " -> Benutzerdefiniertes Browser-Argument: %s"
|
||||
" -> Setting chrome prefs [%s]...": " -> Setze Chrome-Einstellungen [%s]..."
|
||||
" -> Adding Browser extension: [%s]": " -> Füge Browser-Erweiterung hinzu: [%s]"
|
||||
|
||||
web_check:
|
||||
"Unsupported attribute: %s": "Nicht unterstütztes Attribut: %s"
|
||||
|
||||
web_find:
|
||||
"Unsupported selector type: %s": "Nicht unterstützter Selektor-Typ: %s"
|
||||
|
||||
web_find_all:
|
||||
"Unsupported selector type: %s": "Nicht unterstützter Selektor-Typ: %s"
|
||||
close_browser_session:
|
||||
"Closing Browser session...": "Schließe Browser-Sitzung..."
|
||||
|
||||
get_compatible_browser:
|
||||
"Installed browser could not be detected": "Installierter Browser konnte nicht erkannt werden"
|
||||
"Installed browser for OS %s could not be detected": "Installierter Browser für Betriebssystem %s konnte nicht erkannt werden"
|
||||
|
||||
web_open:
|
||||
" => skipping, [%s] is already open": " => überspringe, [%s] ist bereits geöffnet"
|
||||
" -> Opening [%s]...": " -> Öffne [%s]..."
|
||||
|
||||
web_request:
|
||||
" -> HTTP %s [%s]...": " -> HTTP %s [%s]..."
|
||||
@@ -1,322 +0,0 @@
|
||||
"""
|
||||
SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
"""
|
||||
import logging, os, platform, shutil, time
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import Any, Final, TypeVar
|
||||
|
||||
from selenium import webdriver
|
||||
from selenium.common.exceptions import NoSuchElementException, TimeoutException, WebDriverException
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.chromium.options import ChromiumOptions
|
||||
from selenium.webdriver.chromium.webdriver import ChromiumDriver
|
||||
from selenium.webdriver.remote.webdriver import WebDriver
|
||||
from selenium.webdriver.remote.webelement import WebElement
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.webdriver.support.ui import Select, WebDriverWait
|
||||
import selenium_stealth
|
||||
from .utils import ensure, pause, T
|
||||
|
||||
LOG:Final[logging.Logger] = logging.getLogger("kleinanzeigen_bot.selenium_mixin")
|
||||
|
||||
|
||||
class BrowserConfig:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.arguments:Iterable[str] = []
|
||||
self.binary_location:str | None = None
|
||||
self.extensions:Iterable[str] = []
|
||||
self.use_private_window:bool = True
|
||||
self.user_data_dir:str = ""
|
||||
self.profile_name:str = ""
|
||||
|
||||
|
||||
CHROMIUM_OPTIONS = TypeVar('CHROMIUM_OPTIONS', bound = ChromiumOptions) # pylint: disable=invalid-name
|
||||
|
||||
|
||||
class SeleniumMixin:
|
||||
|
||||
def __init__(self) -> None:
|
||||
os.environ["SE_AVOID_STATS"] = "true" # see https://www.selenium.dev/documentation/selenium_manager/
|
||||
self.browser_config:Final[BrowserConfig] = BrowserConfig()
|
||||
self.webdriver:WebDriver = None
|
||||
|
||||
def _init_browser_options(self, browser_options:CHROMIUM_OPTIONS) -> CHROMIUM_OPTIONS:
|
||||
if self.browser_config.use_private_window:
|
||||
if isinstance(browser_options, webdriver.EdgeOptions):
|
||||
browser_options.add_argument("-inprivate")
|
||||
else:
|
||||
browser_options.add_argument("--incognito")
|
||||
|
||||
if self.browser_config.user_data_dir:
|
||||
LOG.info(" -> Browser User Data Dir: %s", self.browser_config.user_data_dir)
|
||||
browser_options.add_argument(f"--user-data-dir={self.browser_config.user_data_dir}")
|
||||
|
||||
if self.browser_config.profile_name:
|
||||
LOG.info(" -> Browser Profile Name: %s", self.browser_config.profile_name)
|
||||
browser_options.add_argument(f"--profile-directory={self.browser_config.profile_name}")
|
||||
|
||||
browser_options.add_argument("--disable-crash-reporter")
|
||||
browser_options.add_argument("--no-first-run")
|
||||
browser_options.add_argument("--no-service-autorun")
|
||||
for chrome_option in self.browser_config.arguments:
|
||||
LOG.info(" -> Custom chrome argument: %s", chrome_option)
|
||||
browser_options.add_argument(chrome_option)
|
||||
LOG.debug("Effective browser arguments: %s", browser_options.arguments)
|
||||
|
||||
for crx_extension in self.browser_config.extensions:
|
||||
ensure(os.path.exists(crx_extension), f"Configured extension-file [{crx_extension}] does not exist.")
|
||||
browser_options.add_extension(crx_extension)
|
||||
LOG.debug("Effective browser extensions: %s", browser_options.extensions)
|
||||
|
||||
browser_options.add_experimental_option("excludeSwitches", ["enable-automation"])
|
||||
browser_options.add_experimental_option("useAutomationExtension", False)
|
||||
browser_options.add_experimental_option("prefs", {
|
||||
"credentials_enable_service": False,
|
||||
"profile.password_manager_enabled": False,
|
||||
"profile.default_content_setting_values.notifications": 2, # 1 = allow, 2 = block browser notifications
|
||||
"devtools.preferences.currentDockState": "\"bottom\""
|
||||
})
|
||||
|
||||
if not LOG.isEnabledFor(logging.DEBUG):
|
||||
browser_options.add_argument("--log-level=3") # INFO: 0, WARNING: 1, ERROR: 2, FATAL: 3
|
||||
|
||||
LOG.debug("Effective experimental options: %s", browser_options.experimental_options)
|
||||
|
||||
if self.browser_config.binary_location:
|
||||
browser_options.binary_location = self.browser_config.binary_location
|
||||
LOG.info(" -> Chrome binary location: %s", self.browser_config.binary_location)
|
||||
return browser_options
|
||||
|
||||
def create_webdriver_session(self) -> None:
|
||||
LOG.info("Creating WebDriver session...")
|
||||
|
||||
if self.browser_config.binary_location:
|
||||
ensure(os.path.exists(self.browser_config.binary_location), f"Specified browser binary [{self.browser_config.binary_location}] does not exist.")
|
||||
else:
|
||||
self.browser_config.binary_location = self.get_compatible_browser()
|
||||
|
||||
if "edge" in self.browser_config.binary_location.lower():
|
||||
os.environ["MSEDGEDRIVER_TELEMETRY_OPTOUT"] = "1" # https://docs.microsoft.com/en-us/microsoft-edge/privacy-whitepaper/#microsoft-edge-driver
|
||||
browser_options = self._init_browser_options(webdriver.EdgeOptions())
|
||||
browser_options.binary_location = self.browser_config.binary_location
|
||||
self.webdriver = webdriver.Edge(options = browser_options)
|
||||
else:
|
||||
browser_options = self._init_browser_options(webdriver.ChromeOptions())
|
||||
browser_options.binary_location = self.browser_config.binary_location
|
||||
self.webdriver = webdriver.Chrome(options = browser_options)
|
||||
|
||||
LOG.info(" -> Chrome driver: %s", self.webdriver.service.path)
|
||||
|
||||
# workaround to support Edge, see https://github.com/diprajpatra/selenium-stealth/pull/25
|
||||
selenium_stealth.Driver = ChromiumDriver
|
||||
|
||||
selenium_stealth.stealth(self.webdriver, # https://github.com/diprajpatra/selenium-stealth#args
|
||||
languages = ("de-DE", "de", "en-US", "en"),
|
||||
platform = "Win32",
|
||||
fix_hairline = True,
|
||||
)
|
||||
|
||||
LOG.info("New WebDriver session is: %s %s", self.webdriver.session_id, self.webdriver.command_executor._url) # pylint: disable=protected-access
|
||||
|
||||
def get_compatible_browser(self) -> str | None:
|
||||
match platform.system():
|
||||
case "Linux":
|
||||
browser_paths = [
|
||||
shutil.which("chromium"),
|
||||
shutil.which("chromium-browser"),
|
||||
shutil.which("google-chrome"),
|
||||
shutil.which("microsoft-edge")
|
||||
]
|
||||
|
||||
case "Darwin":
|
||||
browser_paths = [
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
]
|
||||
|
||||
case "Windows":
|
||||
browser_paths = [
|
||||
os.environ.get("ProgramFiles", "C:\\Program Files") + r'\Microsoft\Edge\Application\msedge.exe',
|
||||
os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)") + r'\Microsoft\Edge\Application\msedge.exe',
|
||||
|
||||
os.environ["ProgramFiles"] + r'\Chromium\Application\chrome.exe',
|
||||
os.environ["ProgramFiles(x86)"] + r'\Chromium\Application\chrome.exe',
|
||||
os.environ["LOCALAPPDATA"] + r'\Chromium\Application\chrome.exe',
|
||||
|
||||
os.environ["ProgramFiles"] + r'\Chrome\Application\chrome.exe',
|
||||
os.environ["ProgramFiles(x86)"] + r'\Chrome\Application\chrome.exe',
|
||||
os.environ["LOCALAPPDATA"] + r'\Chrome\Application\chrome.exe',
|
||||
|
||||
shutil.which("msedge.exe"),
|
||||
shutil.which("chromium.exe"),
|
||||
shutil.which("chrome.exe")
|
||||
]
|
||||
|
||||
case _ as os_name:
|
||||
LOG.warning("Installed browser for OS [%s] could not be detected", os_name)
|
||||
return None
|
||||
|
||||
for browser_path in browser_paths:
|
||||
if browser_path and os.path.isfile(browser_path):
|
||||
return browser_path
|
||||
|
||||
raise AssertionError("Installed browser could not be detected")
|
||||
|
||||
def web_await(self, condition: Callable[[WebDriver], T], timeout:float = 5, exception_on_timeout: Callable[[], Exception] | None = None) -> T:
|
||||
"""
|
||||
Blocks/waits until the given condition is met.
|
||||
|
||||
:param timeout: timeout in seconds
|
||||
:raises TimeoutException: if element could not be found within time
|
||||
"""
|
||||
max_attempts = 2
|
||||
for attempt in range(max_attempts + 1)[1:]:
|
||||
try:
|
||||
return WebDriverWait(self.webdriver, timeout).until(condition) # type: ignore[no-any-return]
|
||||
except TimeoutException as ex:
|
||||
if exception_on_timeout:
|
||||
raise exception_on_timeout() from ex
|
||||
raise ex
|
||||
except WebDriverException as ex:
|
||||
# temporary workaround for:
|
||||
# - https://groups.google.com/g/chromedriver-users/c/Z_CaHJTJnLw
|
||||
# - https://bugs.chromium.org/p/chromedriver/issues/detail?id=4048
|
||||
if ex.msg == "target frame detached" and attempt < max_attempts:
|
||||
LOG.warning(ex)
|
||||
else:
|
||||
raise ex
|
||||
|
||||
raise AssertionError("Should never be reached.")
|
||||
|
||||
def web_click(self, selector_type:By, selector_value:str, timeout:float = 5) -> WebElement:
|
||||
"""
|
||||
:param timeout: timeout in seconds
|
||||
:raises NoSuchElementException: if element could not be found within time
|
||||
"""
|
||||
elem = self.web_await(
|
||||
EC.element_to_be_clickable((selector_type, selector_value)),
|
||||
timeout,
|
||||
lambda: NoSuchElementException(f"Element {selector_type}:{selector_value} not found or not clickable")
|
||||
)
|
||||
elem.click()
|
||||
pause()
|
||||
return elem
|
||||
|
||||
def web_execute(self, javascript:str) -> Any:
|
||||
"""
|
||||
Executes the given JavaScript code in the context of the current page.
|
||||
|
||||
:return: The command's JSON response
|
||||
"""
|
||||
return self.webdriver.execute_script(javascript)
|
||||
|
||||
def web_find(self, selector_type:By, selector_value:str, timeout:float = 5) -> WebElement:
|
||||
"""
|
||||
Locates an HTML element.
|
||||
|
||||
:param timeout: timeout in seconds
|
||||
:raises NoSuchElementException: if element could not be found within time
|
||||
"""
|
||||
return self.web_await(
|
||||
EC.presence_of_element_located((selector_type, selector_value)),
|
||||
timeout,
|
||||
lambda: NoSuchElementException(f"Element {selector_type}='{selector_value}' not found")
|
||||
)
|
||||
|
||||
def web_input(self, selector_type:By, selector_value:str, text:str, timeout:float = 5) -> WebElement:
|
||||
"""
|
||||
Enters text into an HTML input field.
|
||||
|
||||
:param timeout: timeout in seconds
|
||||
:raises NoSuchElementException: if element could not be found within time
|
||||
"""
|
||||
input_field = self.web_find(selector_type, selector_value, timeout)
|
||||
input_field.clear()
|
||||
input_field.send_keys(text)
|
||||
pause()
|
||||
return input_field
|
||||
|
||||
def web_open(self, url:str, timeout:float = 15, reload_if_already_open:bool = False) -> None:
|
||||
"""
|
||||
:param url: url to open in browser
|
||||
:param timeout: timespan in seconds within the page needs to be loaded
|
||||
:param reload_if_already_open: if False does nothing if the URL is already open in the browser
|
||||
:raises TimeoutException: if page did not open within given timespan
|
||||
"""
|
||||
LOG.debug(" -> Opening [%s]...", url)
|
||||
if not reload_if_already_open and url == self.webdriver.current_url:
|
||||
LOG.debug(" => skipping, [%s] is already open", url)
|
||||
return
|
||||
self.webdriver.get(url)
|
||||
WebDriverWait(self.webdriver, timeout).until(lambda _: self.web_execute("return document.readyState") == "complete")
|
||||
|
||||
# pylint: disable=dangerous-default-value
|
||||
def web_request(self, url:str, method:str = "GET", valid_response_codes:Iterable[int] = [200], headers:dict[str, str] | None = None) -> dict[str, Any]:
|
||||
method = method.upper()
|
||||
LOG.debug(" -> HTTP %s [%s]...", method, url)
|
||||
response:dict[str, Any] = self.webdriver.execute_async_script(f"""
|
||||
var callback = arguments[arguments.length - 1];
|
||||
fetch("{url}", {{
|
||||
method: "{method}",
|
||||
redirect: "follow",
|
||||
headers: {headers or {}}
|
||||
}})
|
||||
.then(response => response.text().then(responseText => {{
|
||||
headers = {{}};
|
||||
response.headers.forEach((v, k) => headers[k] = v);
|
||||
callback({{
|
||||
"statusCode": response.status,
|
||||
"statusMessage": response.statusText,
|
||||
"headers": headers,
|
||||
"content": responseText
|
||||
}})
|
||||
}}))
|
||||
""")
|
||||
ensure(
|
||||
response["statusCode"] in valid_response_codes,
|
||||
f'Invalid response "{response["statusCode"]} response["statusMessage"]" received for HTTP {method} to {url}'
|
||||
)
|
||||
return response
|
||||
# pylint: enable=dangerous-default-value
|
||||
|
||||
def web_scroll_page_down(self, scroll_length: int = 10, scroll_speed: int = 10000, scroll_back_top: bool = False) -> None:
|
||||
"""
|
||||
Smoothly scrolls the current web page down.
|
||||
|
||||
:param scroll_length: the length of a single scroll iteration, determines smoothness of scrolling, lower is smoother
|
||||
:param scroll_speed: the speed of scrolling, higher is faster
|
||||
:param scroll_back_top: whether to scroll the page back to the top after scrolling to the bottom
|
||||
"""
|
||||
current_y_pos = 0
|
||||
bottom_y_pos: int = self.webdriver.execute_script('return document.body.scrollHeight;') # get bottom position by JS
|
||||
while current_y_pos < bottom_y_pos: # scroll in steps until bottom reached
|
||||
current_y_pos += scroll_length
|
||||
self.webdriver.execute_script(f'window.scrollTo(0, {current_y_pos});') # scroll one step
|
||||
time.sleep(scroll_length / scroll_speed)
|
||||
|
||||
if scroll_back_top: # scroll back to top in same style
|
||||
while current_y_pos > 0:
|
||||
current_y_pos -= scroll_length
|
||||
self.webdriver.execute_script(f'window.scrollTo(0, {current_y_pos});')
|
||||
time.sleep(scroll_length / scroll_speed / 2) # double speed
|
||||
|
||||
def web_select(self, selector_type:By, selector_value:str, selected_value:Any, timeout:float = 5) -> WebElement:
|
||||
"""
|
||||
Selects an <option/> of a <select/> HTML element.
|
||||
|
||||
:param timeout: timeout in seconds
|
||||
:raises NoSuchElementException: if element could not be found within time
|
||||
:raises UnexpectedTagNameException: if element is not a <select> element
|
||||
"""
|
||||
elem = self.web_await(
|
||||
EC.element_to_be_clickable((selector_type, selector_value)),
|
||||
timeout,
|
||||
lambda: NoSuchElementException(f"Element {selector_type}='{selector_value}' not found or not clickable")
|
||||
)
|
||||
Select(elem).select_by_value(selected_value)
|
||||
pause()
|
||||
return elem
|
||||
@@ -1,291 +0,0 @@
|
||||
"""
|
||||
SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
"""
|
||||
import copy, decimal, json, logging, os, re, secrets, sys, traceback, time
|
||||
from importlib.resources import read_text as get_resource_as_string
|
||||
from collections.abc import Callable, Sized
|
||||
from datetime import datetime
|
||||
from types import FrameType, ModuleType, TracebackType
|
||||
from typing import Any, Final, TypeVar
|
||||
|
||||
import coloredlogs
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
LOG_ROOT:Final[logging.Logger] = logging.getLogger()
|
||||
LOG:Final[logging.Logger] = logging.getLogger("kleinanzeigen_bot.utils")
|
||||
|
||||
# https://mypy.readthedocs.io/en/stable/generics.html#generic-functions
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
def abspath(relative_path:str, relative_to:str | None = None) -> str:
|
||||
"""
|
||||
Makes a given relative path absolute based on another file/folder
|
||||
"""
|
||||
if os.path.isabs(relative_path):
|
||||
return relative_path
|
||||
|
||||
if not relative_to:
|
||||
return os.path.abspath(relative_path)
|
||||
|
||||
if os.path.isfile(relative_to):
|
||||
relative_to = os.path.dirname(relative_to)
|
||||
|
||||
return os.path.normpath(os.path.join(relative_to, relative_path))
|
||||
|
||||
|
||||
def ensure(condition:Any | bool | Callable[[], bool], error_message:str, timeout:float = 5, poll_requency:float = 0.5) -> None:
|
||||
"""
|
||||
:param timeout: timespan in seconds until when the condition must become `True`, default is 5 seconds
|
||||
:param poll_requency: sleep interval between calls in seconds, default is 0.5 seconds
|
||||
:raises AssertionError: if condition did not come `True` within given timespan
|
||||
"""
|
||||
if not isinstance(condition, Callable): # type: ignore[arg-type] # https://github.com/python/mypy/issues/6864
|
||||
if condition:
|
||||
return
|
||||
raise AssertionError(error_message)
|
||||
|
||||
if timeout < 0:
|
||||
raise AssertionError("[timeout] must be >= 0")
|
||||
if poll_requency < 0:
|
||||
raise AssertionError("[poll_requency] must be >= 0")
|
||||
|
||||
start_at = time.time()
|
||||
while not condition(): # type: ignore[operator]
|
||||
elapsed = time.time() - start_at
|
||||
if elapsed >= timeout:
|
||||
raise AssertionError(error_message)
|
||||
time.sleep(poll_requency)
|
||||
|
||||
|
||||
def is_frozen() -> bool:
|
||||
"""
|
||||
>>> is_frozen()
|
||||
False
|
||||
"""
|
||||
return getattr(sys, "frozen", False)
|
||||
|
||||
|
||||
def apply_defaults(
|
||||
target:dict[Any, Any],
|
||||
defaults:dict[Any, Any],
|
||||
ignore:Callable[[Any, Any], bool] = lambda _k, _v: False,
|
||||
override:Callable[[Any, Any], bool] = lambda _k, _v: False
|
||||
) -> dict[Any, Any]:
|
||||
"""
|
||||
>>> apply_defaults({}, {"foo": "bar"})
|
||||
{'foo': 'bar'}
|
||||
>>> apply_defaults({"foo": "foo"}, {"foo": "bar"})
|
||||
{'foo': 'foo'}
|
||||
>>> apply_defaults({"foo": ""}, {"foo": "bar"})
|
||||
{'foo': ''}
|
||||
>>> apply_defaults({}, {"foo": "bar"}, ignore = lambda k, _: k == "foo")
|
||||
{}
|
||||
>>> apply_defaults({"foo": ""}, {"foo": "bar"}, override = lambda _, v: v == "")
|
||||
{'foo': 'bar'}
|
||||
>>> apply_defaults({"foo": None}, {"foo": "bar"}, override = lambda _, v: v == "")
|
||||
{'foo': None}
|
||||
"""
|
||||
for key, default_value in defaults.items():
|
||||
if key in target:
|
||||
if isinstance(target[key], dict) and isinstance(default_value, dict):
|
||||
apply_defaults(target[key], default_value, ignore = ignore)
|
||||
elif override(key, target[key]):
|
||||
target[key] = copy.deepcopy(default_value)
|
||||
elif not ignore(key, default_value):
|
||||
target[key] = copy.deepcopy(default_value)
|
||||
return target
|
||||
|
||||
|
||||
def safe_get(a_map:dict[Any, Any], *keys:str) -> Any:
|
||||
"""
|
||||
>>> safe_get({"foo": {}}, "foo", "bar") is None
|
||||
True
|
||||
>>> safe_get({"foo": {"bar": "some_value"}}, "foo", "bar")
|
||||
'some_value'
|
||||
"""
|
||||
if a_map:
|
||||
for key in keys:
|
||||
try:
|
||||
a_map = a_map[key]
|
||||
except (KeyError, TypeError):
|
||||
return None
|
||||
return a_map
|
||||
|
||||
|
||||
def configure_console_logging() -> None:
|
||||
stdout_log = logging.StreamHandler(sys.stderr)
|
||||
stdout_log.setLevel(logging.DEBUG)
|
||||
stdout_log.setFormatter(coloredlogs.ColoredFormatter("[%(levelname)s] %(message)s"))
|
||||
stdout_log.addFilter(type("", (logging.Filter,), {
|
||||
"filter": lambda rec: rec.levelno <= logging.INFO
|
||||
}))
|
||||
LOG_ROOT.addHandler(stdout_log)
|
||||
|
||||
stderr_log = logging.StreamHandler(sys.stderr)
|
||||
stderr_log.setLevel(logging.WARNING)
|
||||
stderr_log.setFormatter(coloredlogs.ColoredFormatter("[%(levelname)s] %(message)s"))
|
||||
LOG_ROOT.addHandler(stderr_log)
|
||||
|
||||
|
||||
def on_exception(ex_type:type[BaseException], ex_value:Any, ex_traceback:TracebackType | None) -> None:
|
||||
if issubclass(ex_type, KeyboardInterrupt):
|
||||
sys.__excepthook__(ex_type, ex_value, ex_traceback)
|
||||
elif LOG.isEnabledFor(logging.DEBUG) or isinstance(ex_value, (AttributeError, ImportError, NameError, TypeError)):
|
||||
LOG.error("".join(traceback.format_exception(ex_type, ex_value, ex_traceback)))
|
||||
elif isinstance(ex_value, AssertionError):
|
||||
LOG.error(ex_value)
|
||||
else:
|
||||
LOG.error("%s: %s", ex_type.__name__, ex_value)
|
||||
|
||||
|
||||
def on_exit() -> None:
|
||||
for handler in LOG_ROOT.handlers:
|
||||
handler.flush()
|
||||
|
||||
|
||||
def on_sigint(_sig:int, _frame:FrameType | None) -> None:
|
||||
LOG.warning("Aborted on user request.")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def pause(min_ms:int = 200, max_ms:int = 2000) -> None:
|
||||
duration = max_ms <= min_ms and min_ms or secrets.randbelow(max_ms - min_ms) + min_ms
|
||||
LOG.log(logging.INFO if duration > 1500 else logging.DEBUG, " ... pausing for %d ms ...", duration)
|
||||
time.sleep(duration / 1000)
|
||||
|
||||
|
||||
def pluralize(noun:str, count:int | Sized, prefix_with_count:bool = True) -> str:
|
||||
"""
|
||||
>>> pluralize("field", 1)
|
||||
'1 field'
|
||||
>>> pluralize("field", 2)
|
||||
'2 fields'
|
||||
>>> pluralize("field", 2, prefix_with_count = False)
|
||||
'fields'
|
||||
"""
|
||||
if isinstance(count, Sized):
|
||||
count = len(count)
|
||||
|
||||
prefix = f"{count} " if prefix_with_count else ""
|
||||
|
||||
if count == 1:
|
||||
return f"{prefix}{noun}"
|
||||
if noun.endswith('s') or noun.endswith('sh') or noun.endswith('ch') or noun.endswith('x') or noun.endswith('z'):
|
||||
return f"{prefix}{noun}es"
|
||||
if noun.endswith('y'):
|
||||
return f"{prefix}{noun[:-1]}ies"
|
||||
return f"{prefix}{noun}s"
|
||||
|
||||
|
||||
def load_dict(filepath:str, content_label:str = "") -> dict[str, Any]:
|
||||
"""
|
||||
:raises FileNotFoundError
|
||||
"""
|
||||
data = load_dict_if_exists(filepath, content_label)
|
||||
if data is None:
|
||||
raise FileNotFoundError(filepath)
|
||||
return data
|
||||
|
||||
|
||||
def load_dict_if_exists(filepath:str, content_label:str = "") -> dict[str, Any] | None:
|
||||
filepath = os.path.abspath(filepath)
|
||||
LOG.info("Loading %s[%s]...", content_label and content_label + " from " or "", filepath)
|
||||
|
||||
_, file_ext = os.path.splitext(filepath)
|
||||
if file_ext not in [".json", ".yaml", ".yml"]:
|
||||
raise ValueError(f'Unsupported file type. The file name "{filepath}" must end with *.json, *.yaml, or *.yml')
|
||||
|
||||
if not os.path.exists(filepath):
|
||||
return None
|
||||
|
||||
with open(filepath, encoding = "utf-8") as file:
|
||||
return json.load(file) if filepath.endswith(".json") else YAML().load(file) # type: ignore[no-any-return] # mypy
|
||||
|
||||
|
||||
def load_dict_from_module(module:ModuleType, filename:str, content_label:str = "") -> dict[str, Any]:
|
||||
"""
|
||||
:raises FileNotFoundError
|
||||
"""
|
||||
LOG.debug("Loading %s[%s.%s]...", content_label and content_label + " from " or "", module.__name__, filename)
|
||||
|
||||
_, file_ext = os.path.splitext(filename)
|
||||
if file_ext not in (".json", ".yaml", ".yml"):
|
||||
raise ValueError(f'Unsupported file type. The file name "{filename}" must end with *.json, *.yaml, or *.yml')
|
||||
|
||||
content = get_resource_as_string(module, filename) # pylint: disable=deprecated-method
|
||||
return json.loads(content) if filename.endswith(".json") else YAML().load(content) # type: ignore[no-any-return] # mypy
|
||||
|
||||
|
||||
def save_dict(filepath:str, content:dict[str, Any]) -> None:
|
||||
filepath = os.path.abspath(filepath)
|
||||
LOG.info("Saving [%s]...", filepath)
|
||||
with open(filepath, "w", encoding = "utf-8") as file:
|
||||
if filepath.endswith(".json"):
|
||||
file.write(json.dumps(content, indent = 2, ensure_ascii = False))
|
||||
else:
|
||||
yaml = YAML()
|
||||
yaml.indent(mapping = 2, sequence = 4, offset = 2)
|
||||
yaml.allow_duplicate_keys = False
|
||||
yaml.explicit_start = False
|
||||
yaml.dump(content, file)
|
||||
|
||||
|
||||
def parse_decimal(number:float | int | str) -> decimal.Decimal:
|
||||
"""
|
||||
>>> parse_decimal(5)
|
||||
Decimal('5')
|
||||
>>> parse_decimal(5.5)
|
||||
Decimal('5.5')
|
||||
>>> parse_decimal("5.5")
|
||||
Decimal('5.5')
|
||||
>>> parse_decimal("5,5")
|
||||
Decimal('5.5')
|
||||
>>> parse_decimal("1.005,5")
|
||||
Decimal('1005.5')
|
||||
>>> parse_decimal("1,005.5")
|
||||
Decimal('1005.5')
|
||||
"""
|
||||
try:
|
||||
return decimal.Decimal(number)
|
||||
except decimal.InvalidOperation as ex:
|
||||
parts = re.split("[.,]", str(number))
|
||||
try:
|
||||
return decimal.Decimal("".join(parts[:-1]) + "." + parts[-1])
|
||||
except decimal.InvalidOperation:
|
||||
raise decimal.DecimalException(f"Invalid number format: {number}") from ex
|
||||
|
||||
|
||||
def parse_datetime(date:datetime | str | None) -> datetime | None:
|
||||
"""
|
||||
>>> parse_datetime(datetime(2020, 1, 1, 0, 0))
|
||||
datetime.datetime(2020, 1, 1, 0, 0)
|
||||
>>> parse_datetime("2020-01-01T00:00:00")
|
||||
datetime.datetime(2020, 1, 1, 0, 0)
|
||||
>>> parse_datetime(None)
|
||||
|
||||
"""
|
||||
if date is None:
|
||||
return None
|
||||
if isinstance(date, datetime):
|
||||
return date
|
||||
return datetime.fromisoformat(date)
|
||||
|
||||
|
||||
def extract_ad_id_from_ad_link(url: str) -> int:
|
||||
"""
|
||||
Extracts the ID of an ad, given by its reference link.
|
||||
|
||||
:param url: the URL to the ad page
|
||||
:return: the ad ID, a (ten-digit) integer number
|
||||
"""
|
||||
num_part = url.split('/')[-1] # suffix
|
||||
id_part = num_part.split('-')[0]
|
||||
|
||||
try:
|
||||
return int(id_part)
|
||||
except ValueError:
|
||||
print('The ad ID could not be extracted from the given ad reference!')
|
||||
return -1
|
||||
3
src/kleinanzeigen_bot/utils/__init__.py
Normal file
3
src/kleinanzeigen_bot/utils/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
This module contains generic, reusable code.
|
||||
"""
|
||||
145
src/kleinanzeigen_bot/utils/dicts.py
Normal file
145
src/kleinanzeigen_bot/utils/dicts.py
Normal file
@@ -0,0 +1,145 @@
|
||||
# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
import copy, json, os # isort: skip
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from gettext import gettext as _
|
||||
from importlib.resources import read_text as get_resource_as_string
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any, Final
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from . import files, loggers # pylint: disable=cyclic-import
|
||||
from .misc import K, V
|
||||
|
||||
LOG:Final[loggers.Logger] = loggers.get_logger(__name__)
|
||||
|
||||
|
||||
def apply_defaults(
|
||||
target:dict[Any, Any],
|
||||
defaults:dict[Any, Any],
|
||||
ignore:Callable[[Any, Any], bool] = lambda _k, _v: False,
|
||||
override:Callable[[Any, Any], bool] = lambda _k, _v: False
|
||||
) -> dict[Any, Any]:
|
||||
"""
|
||||
>>> apply_defaults({}, {'a': 'b'})
|
||||
{'a': 'b'}
|
||||
>>> apply_defaults({'a': 'b'}, {'a': 'c'})
|
||||
{'a': 'b'}
|
||||
>>> apply_defaults({'a': ''}, {'a': 'b'})
|
||||
{'a': ''}
|
||||
>>> apply_defaults({}, {'a': 'b'}, ignore = lambda k, _: k == 'a')
|
||||
{}
|
||||
>>> apply_defaults({'a': ''}, {'a': 'b'}, override = lambda _, v: v == '')
|
||||
{'a': 'b'}
|
||||
>>> apply_defaults({'a': None}, {'a': 'b'}, override = lambda _, v: v == '')
|
||||
{'a': None}
|
||||
>>> apply_defaults({'a': {'x': 1}}, {'a': {'x': 0, 'y': 2}})
|
||||
{'a': {'x': 1, 'y': 2}}
|
||||
>>> apply_defaults({'a': {'b': False}}, {'a': { 'b': True}})
|
||||
{'a': {'b': False}}
|
||||
"""
|
||||
for key, default_value in defaults.items():
|
||||
if key in target:
|
||||
if isinstance(target[key], dict) and isinstance(default_value, dict):
|
||||
apply_defaults(
|
||||
target = target[key],
|
||||
defaults = default_value,
|
||||
ignore = ignore,
|
||||
override = override
|
||||
)
|
||||
elif override(key, target[key]): # force overwrite if override says so
|
||||
target[key] = copy.deepcopy(default_value)
|
||||
elif not ignore(key, default_value): # only set if not explicitly ignored
|
||||
target[key] = copy.deepcopy(default_value)
|
||||
return target
|
||||
|
||||
|
||||
def defaultdict_to_dict(d: defaultdict[K, V]) -> dict[K, V]:
|
||||
"""Recursively convert defaultdict to dict."""
|
||||
result: dict[K, V] = {}
|
||||
for key, value in d.items():
|
||||
if isinstance(value, defaultdict):
|
||||
result[key] = defaultdict_to_dict(value) # type: ignore[assignment]
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def load_dict(filepath:str, content_label:str = "") -> dict[str, Any]:
|
||||
"""
|
||||
:raises FileNotFoundError
|
||||
"""
|
||||
data = load_dict_if_exists(filepath, content_label)
|
||||
if data is None:
|
||||
raise FileNotFoundError(filepath)
|
||||
return data
|
||||
|
||||
|
||||
def load_dict_if_exists(filepath:str, content_label:str = "") -> dict[str, Any] | None:
|
||||
abs_filepath = files.abspath(filepath)
|
||||
LOG.info("Loading %s[%s]...", content_label and content_label + _(" from ") or "", abs_filepath)
|
||||
|
||||
__, file_ext = os.path.splitext(filepath)
|
||||
if file_ext not in {".json", ".yaml", ".yml"}:
|
||||
raise ValueError(_('Unsupported file type. The filename "%s" must end with *.json, *.yaml, or *.yml') % filepath)
|
||||
|
||||
if not os.path.exists(filepath):
|
||||
return None
|
||||
|
||||
with open(filepath, encoding = "utf-8") as file:
|
||||
return json.load(file) if filepath.endswith(".json") else YAML().load(file) # type: ignore[no-any-return] # mypy
|
||||
|
||||
|
||||
def load_dict_from_module(module:ModuleType, filename:str, content_label:str = "") -> dict[str, Any]:
|
||||
"""
|
||||
:raises FileNotFoundError
|
||||
"""
|
||||
LOG.debug("Loading %s[%s.%s]...", content_label and content_label + " from " or "", module.__name__, filename)
|
||||
|
||||
__, file_ext = os.path.splitext(filename)
|
||||
if file_ext not in {".json", ".yaml", ".yml"}:
|
||||
raise ValueError(f'Unsupported file type. The filename "{filename}" must end with *.json, *.yaml, or *.yml')
|
||||
|
||||
content = get_resource_as_string(module, filename) # pylint: disable=deprecated-method
|
||||
return json.loads(content) if filename.endswith(".json") else YAML().load(content) # type: ignore[no-any-return] # mypy
|
||||
|
||||
|
||||
def save_dict(filepath:str | Path, content:dict[str, Any], *, header:str | None = None) -> None:
|
||||
filepath = Path(filepath).resolve(strict = False)
|
||||
LOG.info("Saving [%s]...", filepath)
|
||||
with open(filepath, "w", encoding = "utf-8") as file:
|
||||
if header:
|
||||
file.write(header)
|
||||
file.write("\n")
|
||||
if filepath.suffix == ".json":
|
||||
file.write(json.dumps(content, indent = 2, ensure_ascii = False))
|
||||
else:
|
||||
yaml = YAML()
|
||||
yaml.indent(mapping = 2, sequence = 4, offset = 2)
|
||||
yaml.representer.add_representer(str, # use YAML | block style for multi-line strings
|
||||
lambda dumper, data:
|
||||
dumper.represent_scalar("tag:yaml.org,2002:str", data, style = "|" if "\n" in data else None)
|
||||
)
|
||||
yaml.allow_duplicate_keys = False
|
||||
yaml.explicit_start = False
|
||||
yaml.dump(content, file)
|
||||
|
||||
|
||||
def safe_get(a_map:dict[Any, Any], *keys:str) -> Any:
|
||||
"""
|
||||
>>> safe_get({"foo": {}}, "foo", "bar") is None
|
||||
True
|
||||
>>> safe_get({"foo": {"bar": "some_value"}}, "foo", "bar")
|
||||
'some_value'
|
||||
"""
|
||||
if a_map:
|
||||
try:
|
||||
for key in keys:
|
||||
a_map = a_map[key]
|
||||
except (KeyError, TypeError):
|
||||
return None
|
||||
return a_map
|
||||
31
src/kleinanzeigen_bot/utils/error_handlers.py
Normal file
31
src/kleinanzeigen_bot/utils/error_handlers.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
import sys, traceback # isort: skip
|
||||
from types import FrameType, TracebackType
|
||||
from typing import Final
|
||||
|
||||
from . import loggers
|
||||
|
||||
LOG:Final[loggers.Logger] = loggers.get_logger(__name__)
|
||||
|
||||
|
||||
def on_exception(ex_type:type[BaseException] | None, ex_value:BaseException | None, ex_traceback:TracebackType | None) -> None:
|
||||
if ex_type is None or ex_value is None:
|
||||
LOG.error("Unknown exception occurred (missing exception info): ex_type=%s, ex_value=%s", ex_type, ex_value)
|
||||
return
|
||||
|
||||
if issubclass(ex_type, KeyboardInterrupt):
|
||||
sys.__excepthook__(ex_type, ex_value, ex_traceback)
|
||||
elif loggers.is_debug(LOG) or isinstance(ex_value, (AttributeError, ImportError, NameError, TypeError)):
|
||||
LOG.error("".join(traceback.format_exception(ex_type, ex_value, ex_traceback)))
|
||||
elif isinstance(ex_value, AssertionError):
|
||||
LOG.error(ex_value)
|
||||
else:
|
||||
LOG.error("%s: %s", ex_type.__name__, ex_value)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def on_sigint(_sig:int, _frame:FrameType | None) -> None:
|
||||
LOG.warning("Aborted on user request.")
|
||||
sys.exit(0)
|
||||
16
src/kleinanzeigen_bot/utils/exceptions.py
Normal file
16
src/kleinanzeigen_bot/utils/exceptions.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
class KleinanzeigenBotError(RuntimeError):
|
||||
"""Base class for all custom bot-related exceptions."""
|
||||
|
||||
|
||||
class CaptchaEncountered(KleinanzeigenBotError):
|
||||
"""Raised when a Captcha was detected and auto-restart is enabled."""
|
||||
|
||||
def __init__(self, restart_delay:timedelta) -> None:
|
||||
super().__init__()
|
||||
self.restart_delay = restart_delay
|
||||
20
src/kleinanzeigen_bot/utils/files.py
Normal file
20
src/kleinanzeigen_bot/utils/files.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
import os
|
||||
|
||||
|
||||
def abspath(relative_path:str, relative_to:str | None = None) -> str:
|
||||
"""
|
||||
Makes a given relative path absolute based on another file/folder
|
||||
"""
|
||||
if not relative_to:
|
||||
return os.path.abspath(relative_path)
|
||||
|
||||
if os.path.isabs(relative_path):
|
||||
return relative_path
|
||||
|
||||
if os.path.isfile(relative_to):
|
||||
relative_to = os.path.dirname(relative_to)
|
||||
|
||||
return os.path.normpath(os.path.join(relative_to, relative_path))
|
||||
199
src/kleinanzeigen_bot/utils/i18n.py
Normal file
199
src/kleinanzeigen_bot/utils/i18n.py
Normal file
@@ -0,0 +1,199 @@
|
||||
# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
import ctypes, gettext, inspect, locale, logging, os, sys # isort: skip
|
||||
from collections.abc import Sized
|
||||
from typing import Any, Final, NamedTuple
|
||||
|
||||
from kleinanzeigen_bot import resources
|
||||
|
||||
from . import dicts, reflect
|
||||
|
||||
__all__ = [
|
||||
"Locale",
|
||||
"get_current_locale",
|
||||
"pluralize",
|
||||
"set_current_locale",
|
||||
"translate"
|
||||
]
|
||||
|
||||
LOG:Final[logging.Logger] = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Locale(NamedTuple):
|
||||
|
||||
language:str # Language code (e.g., "en", "de")
|
||||
region:str | None = None # Region code (e.g., "US", "DE")
|
||||
encoding:str = "UTF-8" # Encoding format (e.g., "UTF-8")
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""
|
||||
>>> str(Locale("en", "US", "UTF-8"))
|
||||
'en_US.UTF-8'
|
||||
>>> str(Locale("en", "US"))
|
||||
'en_US.UTF-8'
|
||||
>>> str(Locale("en"))
|
||||
'en.UTF-8'
|
||||
>>> str(Locale("de", None, "UTF-8"))
|
||||
'de.UTF-8'
|
||||
"""
|
||||
region_part = f"_{self.region}" if self.region else ""
|
||||
encoding_part = f".{self.encoding}" if self.encoding else ""
|
||||
return f"{self.language}{region_part}{encoding_part}"
|
||||
|
||||
@staticmethod
|
||||
def of(locale_string:str) -> "Locale":
|
||||
"""
|
||||
>>> Locale.of("en_US.UTF-8")
|
||||
Locale(language='en', region='US', encoding='UTF-8')
|
||||
>>> Locale.of("de.UTF-8")
|
||||
Locale(language='de', region=None, encoding='UTF-8')
|
||||
>>> Locale.of("de_DE")
|
||||
Locale(language='de', region='DE', encoding='UTF-8')
|
||||
>>> Locale.of("en")
|
||||
Locale(language='en', region=None, encoding='UTF-8')
|
||||
>>> Locale.of("en.UTF-8")
|
||||
Locale(language='en', region=None, encoding='UTF-8')
|
||||
"""
|
||||
parts = locale_string.split(".")
|
||||
language_and_region = parts[0]
|
||||
encoding = parts[1].upper() if len(parts) > 1 else "UTF-8"
|
||||
|
||||
parts = language_and_region.split("_")
|
||||
language = parts[0]
|
||||
region = parts[1].upper() if len(parts) > 1 else None
|
||||
|
||||
return Locale(language = language, region = region, encoding = encoding)
|
||||
|
||||
|
||||
def _detect_locale() -> Locale:
|
||||
"""
|
||||
Detects the system language, returning a tuple of (language, region, encoding).
|
||||
- On macOS/Linux, it uses the LANG environment variable.
|
||||
- On Windows, it uses the Windows API via ctypes to get the default UI language.
|
||||
|
||||
Returns:
|
||||
(language, region, encoding): e.g. ("en", "US", "UTF-8")
|
||||
"""
|
||||
lang = os.environ.get("LANG", None)
|
||||
|
||||
if not lang and os.name == "nt": # Windows
|
||||
try:
|
||||
lang = locale.windows_locale.get(ctypes.windll.kernel32.GetUserDefaultUILanguage(), "en_US") # type: ignore[attr-defined,unused-ignore] # mypy
|
||||
except Exception:
|
||||
LOG.warning("Error detecting language on Windows", exc_info = True)
|
||||
|
||||
return Locale.of(lang) if lang else Locale("en", "US", "UTF-8")
|
||||
|
||||
|
||||
_CURRENT_LOCALE:Locale = _detect_locale()
|
||||
_TRANSLATIONS:dict[str, Any] | None = None
|
||||
|
||||
|
||||
def translate(text:object, caller:inspect.FrameInfo | None) -> str:
|
||||
text = str(text)
|
||||
if not caller:
|
||||
return text
|
||||
|
||||
global _TRANSLATIONS # noqa: PLW0603 Using the global statement to update `...` is discouraged
|
||||
if _TRANSLATIONS is None:
|
||||
try:
|
||||
_TRANSLATIONS = dicts.load_dict_from_module(resources, f"translations.{_CURRENT_LOCALE[0]}.yaml")
|
||||
except FileNotFoundError:
|
||||
_TRANSLATIONS = {}
|
||||
|
||||
if not _TRANSLATIONS:
|
||||
return text
|
||||
|
||||
module_name = caller.frame.f_globals.get("__name__") # pylint: disable=redefined-outer-name
|
||||
file_basename = os.path.splitext(os.path.basename(caller.filename))[0]
|
||||
if module_name and module_name.endswith(f".{file_basename}"):
|
||||
module_name = module_name[:-(len(file_basename) + 1)]
|
||||
if module_name:
|
||||
module_name = module_name.replace(".", "/")
|
||||
file_key = f"{file_basename}.py" if module_name == file_basename else f"{module_name}/{file_basename}.py"
|
||||
translation = dicts.safe_get(_TRANSLATIONS,
|
||||
file_key,
|
||||
caller.function,
|
||||
text
|
||||
)
|
||||
return translation if translation else text
|
||||
|
||||
|
||||
# replace gettext.gettext with custom _translate function
|
||||
_original_gettext = gettext.gettext
|
||||
gettext.gettext = lambda message: translate(_original_gettext(message), reflect.get_caller())
|
||||
for module_name, module in sys.modules.copy().items():
|
||||
if module is None or module_name in sys.builtin_module_names:
|
||||
continue
|
||||
if hasattr(module, "_") and module._ is _original_gettext:
|
||||
module._ = gettext.gettext # type: ignore[attr-defined]
|
||||
if hasattr(module, "gettext") and module.gettext is _original_gettext:
|
||||
module.gettext = gettext.gettext # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def get_current_locale() -> Locale:
|
||||
return _CURRENT_LOCALE
|
||||
|
||||
|
||||
def set_current_locale(new_locale:Locale) -> None:
|
||||
global _CURRENT_LOCALE, _TRANSLATIONS # noqa: PLW0603 Using the global statement to update `...` is discouraged
|
||||
if new_locale.language != _CURRENT_LOCALE.language:
|
||||
_TRANSLATIONS = None
|
||||
_CURRENT_LOCALE = new_locale
|
||||
|
||||
|
||||
def pluralize(noun:str, count:int | Sized, *, prefix_with_count:bool = True) -> str:
|
||||
"""
|
||||
>>> set_current_locale(Locale("en")) # Setup for doctests
|
||||
>>> pluralize("field", 1)
|
||||
'1 field'
|
||||
>>> pluralize("field", 2)
|
||||
'2 fields'
|
||||
>>> pluralize("field", 2, prefix_with_count = False)
|
||||
'fields'
|
||||
"""
|
||||
noun = translate(noun, reflect.get_caller())
|
||||
|
||||
if isinstance(count, Sized):
|
||||
count = len(count)
|
||||
|
||||
prefix = f"{count} " if prefix_with_count else ""
|
||||
|
||||
if count == 1:
|
||||
return f"{prefix}{noun}"
|
||||
|
||||
# German
|
||||
if _CURRENT_LOCALE.language == "de":
|
||||
# Special cases
|
||||
irregular_plurals = {
|
||||
"Attribute": "Attribute",
|
||||
"Bild": "Bilder",
|
||||
"Feld": "Felder",
|
||||
}
|
||||
if noun in irregular_plurals:
|
||||
return f"{prefix}{irregular_plurals[noun]}"
|
||||
for singular_suffix, plural_suffix in irregular_plurals.items():
|
||||
if noun.lower().endswith(singular_suffix):
|
||||
pluralized = noun[:-len(singular_suffix)] + plural_suffix.lower()
|
||||
return f"{prefix}{pluralized}"
|
||||
|
||||
# Very simplified German rules
|
||||
if noun.endswith("ei"):
|
||||
return f"{prefix}{noun}en" # Datei -> Dateien
|
||||
if noun.endswith("e"):
|
||||
return f"{prefix}{noun}n" # Blume -> Blumen
|
||||
if noun.endswith(("el", "er", "en")):
|
||||
return f"{prefix}{noun}" # Keller -> Keller
|
||||
if noun[-1] in "aeiou":
|
||||
return f"{prefix}{noun}s" # Auto -> Autos
|
||||
return f"{prefix}{noun}e" # Hund -> Hunde
|
||||
|
||||
# English
|
||||
if len(noun) < 2: # noqa: PLR2004 Magic value used in comparison
|
||||
return f"{prefix}{noun}s"
|
||||
if noun.endswith(("s", "sh", "ch", "x", "z")):
|
||||
return f"{prefix}{noun}es"
|
||||
if noun.endswith("y") and noun[-2].lower() not in "aeiou":
|
||||
return f"{prefix}{noun[:-1]}ies"
|
||||
return f"{prefix}{noun}s"
|
||||
200
src/kleinanzeigen_bot/utils/loggers.py
Normal file
200
src/kleinanzeigen_bot/utils/loggers.py
Normal file
@@ -0,0 +1,200 @@
|
||||
# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
import copy, logging, os, re, sys # isort: skip
|
||||
from gettext import gettext as _
|
||||
from logging import CRITICAL, DEBUG, ERROR, INFO, WARNING, Logger
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from typing import Any, Final # @UnusedImport
|
||||
|
||||
import colorama
|
||||
|
||||
from . import i18n, reflect
|
||||
|
||||
__all__ = [
|
||||
"Logger",
|
||||
"LogFileHandle",
|
||||
"DEBUG",
|
||||
"INFO",
|
||||
"configure_console_logging",
|
||||
"configure_file_logging",
|
||||
"flush_all_handlers",
|
||||
"get_logger",
|
||||
"is_debug"
|
||||
]
|
||||
|
||||
LOG_ROOT:Final[logging.Logger] = logging.getLogger()
|
||||
|
||||
|
||||
class _MaxLevelFilter(logging.Filter):
|
||||
|
||||
def __init__(self, level:int) -> None:
|
||||
super().__init__()
|
||||
self.level = level
|
||||
|
||||
def filter(self, record:logging.LogRecord) -> bool:
|
||||
return record.levelno <= self.level
|
||||
|
||||
|
||||
def configure_console_logging() -> None:
|
||||
# if a StreamHandler already exists, do not append it again
|
||||
if any(isinstance(h, logging.StreamHandler) for h in LOG_ROOT.handlers):
|
||||
return
|
||||
|
||||
class CustomFormatter(logging.Formatter):
|
||||
LEVEL_COLORS = {
|
||||
DEBUG: colorama.Fore.BLACK + colorama.Style.BRIGHT,
|
||||
INFO: colorama.Fore.BLACK + colorama.Style.BRIGHT,
|
||||
WARNING: colorama.Fore.YELLOW,
|
||||
ERROR: colorama.Fore.RED,
|
||||
CRITICAL: colorama.Fore.RED,
|
||||
}
|
||||
MESSAGE_COLORS = {
|
||||
DEBUG: colorama.Fore.BLACK + colorama.Style.BRIGHT,
|
||||
INFO: colorama.Fore.RESET,
|
||||
WARNING: colorama.Fore.YELLOW,
|
||||
ERROR: colorama.Fore.RED,
|
||||
CRITICAL: colorama.Fore.RED + colorama.Style.BRIGHT,
|
||||
}
|
||||
VALUE_COLORS = {
|
||||
DEBUG: colorama.Fore.BLACK + colorama.Style.BRIGHT,
|
||||
INFO: colorama.Fore.MAGENTA,
|
||||
WARNING: colorama.Fore.MAGENTA,
|
||||
ERROR: colorama.Fore.MAGENTA,
|
||||
CRITICAL: colorama.Fore.MAGENTA,
|
||||
}
|
||||
|
||||
def _relativize_paths_under_cwd(self, record: logging.LogRecord) -> None:
|
||||
"""
|
||||
Mutate record.args in-place, converting any absolute-path strings
|
||||
under the current working directory into relative paths.
|
||||
"""
|
||||
|
||||
if not record.args:
|
||||
return
|
||||
|
||||
cwd = os.getcwd()
|
||||
|
||||
def _rel_if_subpath(val: Any) -> Any:
|
||||
if isinstance(val, str) and os.path.isabs(val):
|
||||
# don't relativize log-file paths
|
||||
if val.endswith(".log"):
|
||||
return val
|
||||
|
||||
try:
|
||||
if os.path.commonpath([cwd, val]) == cwd:
|
||||
return os.path.relpath(val, cwd)
|
||||
except ValueError:
|
||||
return val
|
||||
return val
|
||||
|
||||
if isinstance(record.args, tuple):
|
||||
record.args = tuple(_rel_if_subpath(a) for a in record.args)
|
||||
elif isinstance(record.args, dict):
|
||||
record.args = {k: _rel_if_subpath(v) for k, v in record.args.items()}
|
||||
|
||||
def format(self, record:logging.LogRecord) -> str:
|
||||
# Deep copy fails if record.args contains objects with
|
||||
# __init__(...) parameters (e.g., CaptchaEncountered).
|
||||
# A shallow copy is sufficient to preserve the original.
|
||||
record = copy.copy(record)
|
||||
|
||||
self._relativize_paths_under_cwd(record)
|
||||
|
||||
level_color = self.LEVEL_COLORS.get(record.levelno, "")
|
||||
msg_color = self.MESSAGE_COLORS.get(record.levelno, "")
|
||||
value_color = self.VALUE_COLORS.get(record.levelno, "")
|
||||
|
||||
# translate and colorize log level name
|
||||
levelname = _(record.levelname) if record.levelno > DEBUG else record.levelname
|
||||
record.levelname = f"{level_color}[{levelname}]{colorama.Style.RESET_ALL}"
|
||||
|
||||
# highlight message values enclosed by [...], "...", and '...'
|
||||
record.msg = re.sub(
|
||||
r"\[([^\]]+)\]|\"([^\"]+)\"|\'([^\']+)\'",
|
||||
lambda match: f"[{value_color}{match.group(1) or match.group(2) or match.group(3)}{colorama.Fore.RESET}{msg_color}]",
|
||||
str(record.msg),
|
||||
)
|
||||
|
||||
# colorize message
|
||||
record.msg = f"{msg_color}{record.msg}{colorama.Style.RESET_ALL}"
|
||||
|
||||
return super().format(record)
|
||||
|
||||
formatter = CustomFormatter("%(levelname)s %(message)s")
|
||||
|
||||
stdout_log = logging.StreamHandler(sys.stderr)
|
||||
stdout_log.setLevel(DEBUG)
|
||||
stdout_log.addFilter(_MaxLevelFilter(INFO))
|
||||
stdout_log.setFormatter(formatter)
|
||||
LOG_ROOT.addHandler(stdout_log)
|
||||
|
||||
stderr_log = logging.StreamHandler(sys.stderr)
|
||||
stderr_log.setLevel(WARNING)
|
||||
stderr_log.setFormatter(formatter)
|
||||
LOG_ROOT.addHandler(stderr_log)
|
||||
|
||||
|
||||
class LogFileHandle:
|
||||
"""Encapsulates a log file handler with close and status methods."""
|
||||
|
||||
def __init__(self, file_path:str, handler:RotatingFileHandler, logger:logging.Logger) -> None:
|
||||
self.file_path = file_path
|
||||
self._handler:RotatingFileHandler | None = handler
|
||||
self._logger = logger
|
||||
|
||||
def close(self) -> None:
|
||||
"""Flushes, removes, and closes the log handler."""
|
||||
if self._handler:
|
||||
self._handler.flush()
|
||||
self._logger.removeHandler(self._handler)
|
||||
self._handler.close()
|
||||
self._handler = None
|
||||
|
||||
def is_closed(self) -> bool:
|
||||
"""Returns whether the log handler has been closed."""
|
||||
return not self._handler
|
||||
|
||||
|
||||
def configure_file_logging(log_file_path:str) -> LogFileHandle:
|
||||
"""
|
||||
Sets up a file logger and returns a callable to flush, remove, and close it.
|
||||
|
||||
@param log_file_path: Path to the log file.
|
||||
@return: Callable[[], None]: A function that cleans up the log handler.
|
||||
"""
|
||||
fh = RotatingFileHandler(
|
||||
filename = log_file_path,
|
||||
maxBytes = 10 * 1024 * 1024, # 10 MB
|
||||
backupCount = 10,
|
||||
encoding = "utf-8"
|
||||
)
|
||||
fh.setLevel(DEBUG)
|
||||
fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
|
||||
LOG_ROOT.addHandler(fh)
|
||||
return LogFileHandle(log_file_path, fh, LOG_ROOT)
|
||||
|
||||
|
||||
def flush_all_handlers() -> None:
|
||||
for handler in LOG_ROOT.handlers:
|
||||
handler.flush()
|
||||
|
||||
|
||||
def get_logger(name:str | None = None) -> logging.Logger:
|
||||
"""
|
||||
Returns a localized logger
|
||||
"""
|
||||
|
||||
class TranslatingLogger(logging.Logger):
|
||||
|
||||
def _log(self, level:int, msg:object, *args:Any, **kwargs:Any) -> None:
|
||||
if level != DEBUG: # debug messages should not be translated
|
||||
msg = i18n.translate(msg, reflect.get_caller(2))
|
||||
super()._log(level, msg, *args, **kwargs)
|
||||
|
||||
logging.setLoggerClass(TranslatingLogger)
|
||||
return logging.getLogger(name)
|
||||
|
||||
|
||||
def is_debug(logger:Logger) -> bool:
|
||||
return logger.isEnabledFor(DEBUG)
|
||||
206
src/kleinanzeigen_bot/utils/misc.py
Normal file
206
src/kleinanzeigen_bot/utils/misc.py
Normal file
@@ -0,0 +1,206 @@
|
||||
# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
import asyncio, decimal, re, sys, time # isort: skip
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from gettext import gettext as _
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from . import i18n
|
||||
|
||||
# https://mypy.readthedocs.io/en/stable/generics.html#generic-functions
|
||||
T = TypeVar("T")
|
||||
K = TypeVar("K")
|
||||
V = TypeVar("V")
|
||||
|
||||
|
||||
def ensure(
|
||||
condition:Any | bool | Callable[[], bool], # noqa: FBT001 Boolean-typed positional argument in function definition
|
||||
error_message:str,
|
||||
timeout:float = 5,
|
||||
poll_requency:float = 0.5
|
||||
) -> None:
|
||||
"""
|
||||
:param timeout: timespan in seconds until when the condition must become `True`, default is 5 seconds
|
||||
:param poll_requency: sleep interval between calls in seconds, default is 0.5 seconds
|
||||
:raises AssertionError: if condition did not come `True` within given timespan
|
||||
"""
|
||||
if not isinstance(condition, Callable): # type: ignore[arg-type] # https://github.com/python/mypy/issues/6864
|
||||
if condition:
|
||||
return
|
||||
raise AssertionError(_(error_message))
|
||||
|
||||
if timeout < 0:
|
||||
raise AssertionError("[timeout] must be >= 0")
|
||||
if poll_requency < 0:
|
||||
raise AssertionError("[poll_requency] must be >= 0")
|
||||
|
||||
start_at = time.time()
|
||||
while not condition(): # type: ignore[operator]
|
||||
elapsed = time.time() - start_at
|
||||
if elapsed >= timeout:
|
||||
raise AssertionError(_(error_message))
|
||||
time.sleep(poll_requency)
|
||||
|
||||
|
||||
def now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def is_frozen() -> bool:
|
||||
"""
|
||||
>>> is_frozen()
|
||||
False
|
||||
"""
|
||||
return getattr(sys, "frozen", False)
|
||||
|
||||
|
||||
async def ainput(prompt:str) -> str:
|
||||
return await asyncio.to_thread(input, f"{prompt} ")
|
||||
|
||||
|
||||
def parse_decimal(number:float | int | str) -> decimal.Decimal:
|
||||
"""
|
||||
>>> parse_decimal(5)
|
||||
Decimal('5')
|
||||
|
||||
>>> parse_decimal(5.5)
|
||||
Decimal('5.5')
|
||||
|
||||
>>> parse_decimal("5.5")
|
||||
Decimal('5.5')
|
||||
|
||||
>>> parse_decimal("5,5")
|
||||
Decimal('5.5')
|
||||
|
||||
>>> parse_decimal("1.005,5")
|
||||
Decimal('1005.5')
|
||||
|
||||
>>> parse_decimal("1,005.5")
|
||||
Decimal('1005.5')
|
||||
"""
|
||||
try:
|
||||
return decimal.Decimal(number)
|
||||
except decimal.InvalidOperation as ex:
|
||||
parts = re.split("[.,]", str(number))
|
||||
try:
|
||||
return decimal.Decimal("".join(parts[:-1]) + "." + parts[-1])
|
||||
except decimal.InvalidOperation:
|
||||
raise decimal.DecimalException(f"Invalid number format: {number}") from ex
|
||||
|
||||
|
||||
def parse_datetime(
|
||||
date:datetime | str | None,
|
||||
*,
|
||||
add_timezone_if_missing:bool = True,
|
||||
use_local_timezone:bool = True
|
||||
) -> datetime | None:
|
||||
"""
|
||||
Parses a datetime object or ISO-formatted string.
|
||||
|
||||
Args:
|
||||
date: The input datetime object or ISO string.
|
||||
add_timezone_if_missing: If True, add timezone info if missing.
|
||||
use_local_timezone: If True, use local timezone; otherwise UTC if adding timezone.
|
||||
|
||||
Returns:
|
||||
A timezone-aware or naive datetime object, depending on parameters.
|
||||
|
||||
>>> parse_datetime(datetime(2020, 1, 1, 0, 0), add_timezone_if_missing = False)
|
||||
datetime.datetime(2020, 1, 1, 0, 0)
|
||||
|
||||
>>> parse_datetime("2020-01-01T00:00:00", add_timezone_if_missing = False)
|
||||
datetime.datetime(2020, 1, 1, 0, 0)
|
||||
|
||||
>>> parse_datetime(None)
|
||||
|
||||
"""
|
||||
if date is None:
|
||||
return None
|
||||
|
||||
dt = date if isinstance(date, datetime) else datetime.fromisoformat(date)
|
||||
|
||||
if dt.tzinfo is None and add_timezone_if_missing:
|
||||
dt = (
|
||||
dt.astimezone() if use_local_timezone
|
||||
else dt.replace(tzinfo = timezone.utc)
|
||||
)
|
||||
|
||||
return dt
|
||||
|
||||
|
||||
def parse_duration(text:str) -> timedelta:
|
||||
"""
|
||||
Parses a human-readable duration string into a datetime.timedelta.
|
||||
|
||||
Supported units:
|
||||
- d: days
|
||||
- h: hours
|
||||
- m: minutes
|
||||
- s: seconds
|
||||
|
||||
Examples:
|
||||
>>> parse_duration("1h 30m")
|
||||
datetime.timedelta(seconds=5400)
|
||||
|
||||
>>> parse_duration("2d 4h 15m 10s")
|
||||
datetime.timedelta(days=2, seconds=15310)
|
||||
|
||||
>>> parse_duration("45m")
|
||||
datetime.timedelta(seconds=2700)
|
||||
|
||||
>>> parse_duration("3d")
|
||||
datetime.timedelta(days=3)
|
||||
|
||||
>>> parse_duration("5h 5h")
|
||||
datetime.timedelta(seconds=36000)
|
||||
|
||||
>>> parse_duration("invalid input")
|
||||
datetime.timedelta(0)
|
||||
"""
|
||||
pattern = re.compile(r"(\d+)\s*([dhms])")
|
||||
parts = pattern.findall(text.lower())
|
||||
kwargs:dict[str, int] = {}
|
||||
for value, unit in parts:
|
||||
if unit == "d":
|
||||
kwargs["days"] = kwargs.get("days", 0) + int(value)
|
||||
elif unit == "h":
|
||||
kwargs["hours"] = kwargs.get("hours", 0) + int(value)
|
||||
elif unit == "m":
|
||||
kwargs["minutes"] = kwargs.get("minutes", 0) + int(value)
|
||||
elif unit == "s":
|
||||
kwargs["seconds"] = kwargs.get("seconds", 0) + int(value)
|
||||
return timedelta(**kwargs)
|
||||
|
||||
|
||||
def format_timedelta(td:timedelta) -> str:
|
||||
"""
|
||||
Formats a timedelta into a human-readable string using the pluralize utility.
|
||||
|
||||
>>> format_timedelta(timedelta(seconds=90))
|
||||
'1 minute, 30 seconds'
|
||||
>>> format_timedelta(timedelta(hours=1))
|
||||
'1 hour'
|
||||
>>> format_timedelta(timedelta(days=2, hours=5))
|
||||
'2 days, 5 hours'
|
||||
>>> format_timedelta(timedelta(0))
|
||||
'0 seconds'
|
||||
"""
|
||||
days = td.days
|
||||
seconds = td.seconds
|
||||
hours, remainder = divmod(seconds, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
|
||||
parts = []
|
||||
|
||||
if days:
|
||||
parts.append(i18n.pluralize("day", days))
|
||||
if hours:
|
||||
parts.append(i18n.pluralize("hour", hours))
|
||||
if minutes:
|
||||
parts.append(i18n.pluralize("minute", minutes))
|
||||
if seconds:
|
||||
parts.append(i18n.pluralize("second", seconds))
|
||||
|
||||
return ", ".join(parts) if parts else i18n.pluralize("second", 0)
|
||||
18
src/kleinanzeigen_bot/utils/net.py
Normal file
18
src/kleinanzeigen_bot/utils/net.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
import socket
|
||||
|
||||
|
||||
def is_port_open(host:str, port:int) -> bool:
|
||||
s:socket.socket | None = None
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(1)
|
||||
s.connect((host, port))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
finally:
|
||||
if s:
|
||||
s.close()
|
||||
24
src/kleinanzeigen_bot/utils/reflect.py
Normal file
24
src/kleinanzeigen_bot/utils/reflect.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
import inspect
|
||||
from typing import Any
|
||||
|
||||
|
||||
def get_caller(depth:int = 1) -> inspect.FrameInfo | None:
|
||||
stack = inspect.stack()
|
||||
try:
|
||||
for frame in stack[depth + 1:]:
|
||||
if frame.function and frame.function != "<lambda>":
|
||||
return frame
|
||||
return None
|
||||
finally:
|
||||
del stack # Clean up the stack to avoid reference cycles
|
||||
|
||||
|
||||
def is_integer(obj:Any) -> bool:
|
||||
try:
|
||||
int(obj)
|
||||
return True
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
574
src/kleinanzeigen_bot/utils/web_scraping_mixin.py
Normal file
574
src/kleinanzeigen_bot/utils/web_scraping_mixin.py
Normal file
@@ -0,0 +1,574 @@
|
||||
# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
import asyncio, enum, inspect, json, os, platform, secrets, shutil # isort: skip
|
||||
from collections.abc import Callable, Coroutine, Iterable
|
||||
from gettext import gettext as _
|
||||
from typing import Any, Final, cast
|
||||
|
||||
try:
|
||||
from typing import Never # type: ignore[attr-defined,unused-ignore] # mypy
|
||||
except ImportError:
|
||||
from typing import NoReturn as Never # Python <3.11
|
||||
|
||||
import nodriver, psutil # isort: skip
|
||||
from nodriver.core.browser import Browser
|
||||
from nodriver.core.config import Config
|
||||
from nodriver.core.element import Element
|
||||
from nodriver.core.tab import Tab as Page
|
||||
|
||||
from . import loggers, net
|
||||
from .misc import T, ensure
|
||||
|
||||
__all__ = [
|
||||
"Browser",
|
||||
"BrowserConfig",
|
||||
"By",
|
||||
"Element",
|
||||
"Page",
|
||||
"Is",
|
||||
"WebScrapingMixin",
|
||||
]
|
||||
|
||||
LOG:Final[loggers.Logger] = loggers.get_logger(__name__)
|
||||
|
||||
# see https://api.jquery.com/category/selectors/
|
||||
METACHAR_ESCAPER:Final[dict[int, str]] = str.maketrans({ch: f"\\{ch}" for ch in '!"#$%&\'()*+,./:;<=>?@[\\]^`{|}~'})
|
||||
|
||||
|
||||
class By(enum.Enum):
|
||||
ID = enum.auto()
|
||||
CLASS_NAME = enum.auto()
|
||||
CSS_SELECTOR = enum.auto()
|
||||
TAG_NAME = enum.auto()
|
||||
TEXT = enum.auto()
|
||||
XPATH = enum.auto()
|
||||
|
||||
|
||||
class Is(enum.Enum):
|
||||
CLICKABLE = enum.auto()
|
||||
DISPLAYED = enum.auto()
|
||||
DISABLED = enum.auto()
|
||||
READONLY = enum.auto()
|
||||
SELECTED = enum.auto()
|
||||
|
||||
|
||||
class BrowserConfig:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.arguments:Iterable[str] = []
|
||||
self.binary_location:str | None = None
|
||||
self.extensions:Iterable[str] = []
|
||||
self.use_private_window:bool = True
|
||||
self.user_data_dir:str = ""
|
||||
self.profile_name:str = ""
|
||||
|
||||
|
||||
class WebScrapingMixin:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.browser_config:Final[BrowserConfig] = BrowserConfig()
|
||||
self.browser:Browser = None # pyright: ignore[reportAttributeAccessIssue]
|
||||
self.page:Page = None # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
async def create_browser_session(self) -> None:
|
||||
LOG.info("Creating Browser session...")
|
||||
|
||||
if self.browser_config.binary_location:
|
||||
ensure(os.path.exists(self.browser_config.binary_location), f"Specified browser binary [{self.browser_config.binary_location}] does not exist.")
|
||||
else:
|
||||
self.browser_config.binary_location = self.get_compatible_browser()
|
||||
LOG.info(" -> Browser binary location: %s", self.browser_config.binary_location)
|
||||
|
||||
########################################################
|
||||
# check if an existing browser instance shall be used...
|
||||
########################################################
|
||||
remote_host = "127.0.0.1"
|
||||
remote_port = 0
|
||||
for arg in self.browser_config.arguments:
|
||||
if arg.startswith("--remote-debugging-host="):
|
||||
remote_host = arg.split("=", 2)[1]
|
||||
if arg.startswith("--remote-debugging-port="):
|
||||
remote_port = int(arg.split("=", 2)[1])
|
||||
|
||||
if remote_port > 0:
|
||||
LOG.info("Using existing browser process at %s:%s", remote_host, remote_port)
|
||||
ensure(net.is_port_open(remote_host, remote_port),
|
||||
f"Browser process not reachable at {remote_host}:{remote_port}. "
|
||||
f"Start the browser with --remote-debugging-port={remote_port} or remove this port from your config.yaml")
|
||||
cfg = Config(
|
||||
browser_executable_path = self.browser_config.binary_location # actually not necessary but nodriver fails without
|
||||
)
|
||||
cfg.host = remote_host
|
||||
cfg.port = remote_port
|
||||
self.browser = await nodriver.start(cfg)
|
||||
LOG.info("New Browser session is %s", self.browser.websocket_url)
|
||||
return
|
||||
|
||||
########################################################
|
||||
# configure and initialize new browser instance...
|
||||
########################################################
|
||||
|
||||
# default_browser_args: @ https://github.com/ultrafunkamsterdam/nodriver/blob/main/nodriver/core/config.py
|
||||
# https://peter.sh/experiments/chromium-command-line-switches/
|
||||
# https://github.com/GoogleChrome/chrome-launcher/blob/main/docs/chrome-flags-for-tools.md
|
||||
browser_args = [
|
||||
# "--disable-dev-shm-usage", # https://stackoverflow.com/a/50725918/5116073
|
||||
"--disable-crash-reporter",
|
||||
"--disable-domain-reliability",
|
||||
"--disable-sync",
|
||||
"--no-experiments",
|
||||
"--disable-search-engine-choice-screen",
|
||||
|
||||
"--disable-features=MediaRouter",
|
||||
"--use-mock-keychain",
|
||||
|
||||
"--test-type", # https://stackoverflow.com/a/36746675/5116073
|
||||
# https://chromium.googlesource.com/chromium/src/+/master/net/dns/README.md#request-remapping
|
||||
'--host-resolver-rules="MAP connect.facebook.net 127.0.0.1, MAP securepubads.g.doubleclick.net 127.0.0.1, MAP www.googletagmanager.com 127.0.0.1"'
|
||||
]
|
||||
|
||||
is_edge = "edge" in self.browser_config.binary_location.lower()
|
||||
|
||||
if is_edge:
|
||||
os.environ["MSEDGEDRIVER_TELEMETRY_OPTOUT"] = "1" # https://docs.microsoft.com/en-us/microsoft-edge/privacy-whitepaper/#microsoft-edge-driver
|
||||
|
||||
if self.browser_config.use_private_window:
|
||||
browser_args.append("-inprivate" if is_edge else "--incognito")
|
||||
|
||||
if self.browser_config.profile_name:
|
||||
LOG.info(" -> Browser profile name: %s", self.browser_config.profile_name)
|
||||
browser_args.append(f"--profile-directory={self.browser_config.profile_name}")
|
||||
|
||||
for browser_arg in self.browser_config.arguments:
|
||||
LOG.info(" -> Custom Browser argument: %s", browser_arg)
|
||||
browser_args.append(browser_arg)
|
||||
|
||||
if not loggers.is_debug(LOG):
|
||||
browser_args.append("--log-level=3") # INFO: 0, WARNING: 1, ERROR: 2, FATAL: 3
|
||||
|
||||
if self.browser_config.user_data_dir:
|
||||
LOG.info(" -> Browser user data dir: %s", self.browser_config.user_data_dir)
|
||||
|
||||
cfg = Config(
|
||||
headless = False,
|
||||
browser_executable_path = self.browser_config.binary_location,
|
||||
browser_args = browser_args,
|
||||
user_data_dir = self.browser_config.user_data_dir
|
||||
)
|
||||
|
||||
# already logged by nodriver:
|
||||
# LOG.debug("-> Effective browser arguments: \n\t\t%s", "\n\t\t".join(cfg.browser_args))
|
||||
|
||||
profile_dir = os.path.join(cfg.user_data_dir, self.browser_config.profile_name or "Default")
|
||||
os.makedirs(profile_dir, exist_ok = True)
|
||||
prefs_file = os.path.join(profile_dir, "Preferences")
|
||||
if not os.path.exists(prefs_file):
|
||||
LOG.info(" -> Setting chrome prefs [%s]...", prefs_file)
|
||||
with open(prefs_file, "w", encoding = "UTF-8") as fd:
|
||||
json.dump({
|
||||
"credentials_enable_service": False,
|
||||
"enable_do_not_track": True,
|
||||
"google": {
|
||||
"services": {
|
||||
"consented_to_sync": False
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"default_content_setting_values": {
|
||||
"popups": 0,
|
||||
"notifications": 2 # 1 = allow, 2 = block browser notifications
|
||||
},
|
||||
"password_manager_enabled": False
|
||||
},
|
||||
"signin": {
|
||||
"allowed": False
|
||||
},
|
||||
"translate_site_blacklist": [
|
||||
"www.kleinanzeigen.de"
|
||||
],
|
||||
"devtools": {
|
||||
"preferences": {
|
||||
"currentDockState": '"bottom"'
|
||||
}
|
||||
}
|
||||
}, fd)
|
||||
|
||||
# load extensions
|
||||
for crx_extension in self.browser_config.extensions:
|
||||
LOG.info(" -> Adding Browser extension: [%s]", crx_extension)
|
||||
ensure(os.path.exists(crx_extension), f"Configured extension-file [{crx_extension}] does not exist.")
|
||||
cfg.add_extension(crx_extension)
|
||||
|
||||
self.browser = await nodriver.start(cfg)
|
||||
LOG.info("New Browser session is %s", self.browser.websocket_url)
|
||||
|
||||
def close_browser_session(self) -> None:
|
||||
if self.browser:
|
||||
LOG.debug("Closing Browser session...")
|
||||
self.page = None # pyright: ignore[reportAttributeAccessIssue]
|
||||
browser_process = psutil.Process(self.browser._process_pid) # noqa: SLF001 Private member accessed
|
||||
browser_children:list[psutil.Process] = browser_process.children()
|
||||
self.browser.stop()
|
||||
for p in browser_children:
|
||||
if p.is_running():
|
||||
p.kill() # terminate orphaned browser processes
|
||||
self.browser = None # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
def get_compatible_browser(self) -> str:
|
||||
match platform.system():
|
||||
case "Linux":
|
||||
browser_paths = [
|
||||
shutil.which("chromium"),
|
||||
shutil.which("chromium-browser"),
|
||||
shutil.which("google-chrome"),
|
||||
shutil.which("microsoft-edge")
|
||||
]
|
||||
|
||||
case "Darwin":
|
||||
browser_paths = [
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
]
|
||||
|
||||
case "Windows":
|
||||
browser_paths = [
|
||||
os.environ.get("PROGRAMFILES", "C:\\Program Files") + r"\Microsoft\Edge\Application\msedge.exe",
|
||||
os.environ.get("PROGRAMFILES(X86)", "C:\\Program Files (x86)") + r"\Microsoft\Edge\Application\msedge.exe",
|
||||
|
||||
os.environ["PROGRAMFILES"] + r"\Chromium\Application\chrome.exe",
|
||||
os.environ["PROGRAMFILES(X86)"] + r"\Chromium\Application\chrome.exe",
|
||||
os.environ["LOCALAPPDATA"] + r"\Chromium\Application\chrome.exe",
|
||||
|
||||
os.environ["PROGRAMFILES"] + r"\Chrome\Application\chrome.exe",
|
||||
os.environ["PROGRAMFILES(X86)"] + r"\Chrome\Application\chrome.exe",
|
||||
os.environ["LOCALAPPDATA"] + r"\Chrome\Application\chrome.exe",
|
||||
|
||||
shutil.which("msedge.exe"),
|
||||
shutil.which("chromium.exe"),
|
||||
shutil.which("chrome.exe")
|
||||
]
|
||||
|
||||
case _ as os_name:
|
||||
raise AssertionError(_("Installed browser for OS %s could not be detected") % os_name)
|
||||
|
||||
for browser_path in browser_paths:
|
||||
if browser_path and os.path.isfile(browser_path):
|
||||
return browser_path
|
||||
|
||||
raise AssertionError(_("Installed browser could not be detected"))
|
||||
|
||||
async def web_await(self, condition:Callable[[], T | Never | Coroutine[Any, Any, T | Never]], *,
|
||||
timeout:int | float = 5, timeout_error_message:str = "") -> T:
|
||||
"""
|
||||
Blocks/waits until the given condition is met.
|
||||
|
||||
:param timeout: timeout in seconds
|
||||
:raises TimeoutError: if element could not be found within time
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
start_at = loop.time()
|
||||
|
||||
while True:
|
||||
await self.page
|
||||
ex:Exception | None = None
|
||||
try:
|
||||
result_raw = condition()
|
||||
result:T = cast(T, await result_raw if inspect.isawaitable(result_raw) else result_raw)
|
||||
if result:
|
||||
return result
|
||||
except Exception as ex1:
|
||||
ex = ex1
|
||||
if loop.time() - start_at > timeout:
|
||||
if ex:
|
||||
raise ex
|
||||
raise TimeoutError(timeout_error_message or f"Condition not met within {timeout} seconds")
|
||||
await self.page.sleep(0.5)
|
||||
|
||||
async def web_check(self, selector_type:By, selector_value:str, attr:Is, *, timeout:int | float = 5) -> bool:
|
||||
"""
|
||||
Locates an HTML element and returns a state.
|
||||
|
||||
:param timeout: timeout in seconds
|
||||
:raises TimeoutError: if element could not be found within time
|
||||
"""
|
||||
|
||||
def is_disabled(elem:Element) -> bool:
|
||||
return elem.attrs.get("disabled") is not None
|
||||
|
||||
async def is_displayed(elem:Element) -> bool:
|
||||
return cast(bool, await elem.apply("""
|
||||
function (element) {
|
||||
var style = window.getComputedStyle(element);
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& style.opacity !== '0'
|
||||
&& element.offsetWidth > 0
|
||||
&& element.offsetHeight > 0
|
||||
}
|
||||
"""))
|
||||
|
||||
elem:Element = await self.web_find(selector_type, selector_value, timeout = timeout)
|
||||
|
||||
match attr:
|
||||
case Is.CLICKABLE:
|
||||
return not is_disabled(elem) or await is_displayed(elem)
|
||||
case Is.DISPLAYED:
|
||||
return await is_displayed(elem)
|
||||
case Is.DISABLED:
|
||||
return is_disabled(elem)
|
||||
case Is.READONLY:
|
||||
return elem.attrs.get("readonly") is not None
|
||||
case Is.SELECTED:
|
||||
return cast(bool, await elem.apply("""
|
||||
function (element) {
|
||||
if (element.tagName.toLowerCase() === 'input') {
|
||||
if (element.type === 'checkbox' || element.type === 'radio') {
|
||||
return element.checked
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
"""))
|
||||
raise AssertionError(_("Unsupported attribute: %s") % attr)
|
||||
|
||||
async def web_click(self, selector_type:By, selector_value:str, *, timeout:int | float = 5) -> Element:
|
||||
"""
|
||||
Locates an HTML element by ID.
|
||||
|
||||
:param timeout: timeout in seconds
|
||||
:raises TimeoutError: if element could not be found within time
|
||||
"""
|
||||
elem = await self.web_find(selector_type, selector_value, timeout = timeout)
|
||||
await elem.click()
|
||||
await self.web_sleep()
|
||||
return elem
|
||||
|
||||
async def web_execute(self, jscode:str) -> Any:
|
||||
"""
|
||||
Executes the given JavaScript code in the context of the current page.
|
||||
|
||||
:return: The javascript's return value
|
||||
"""
|
||||
result = await self.page.evaluate(jscode, await_promise = True, return_by_value = True)
|
||||
|
||||
# debug log the jscode but avoid excessive debug logging of window.scrollTo calls
|
||||
_prev_jscode:str = getattr(self.__class__.web_execute, "_prev_jscode", "")
|
||||
if not (jscode == _prev_jscode or (jscode.startswith("window.scrollTo") and _prev_jscode.startswith("window.scrollTo"))):
|
||||
LOG.debug("web_execute(`%s`) = `%s`", jscode, result)
|
||||
self.__class__.web_execute._prev_jscode = jscode # type: ignore[attr-defined] # noqa: SLF001 Private member accessed
|
||||
|
||||
return result
|
||||
|
||||
async def web_find(self, selector_type:By, selector_value:str, *, parent:Element | None = None, timeout:int | float = 5) -> Element:
|
||||
"""
|
||||
Locates an HTML element by the given selector type and value.
|
||||
|
||||
:param timeout: timeout in seconds
|
||||
:raises TimeoutError: if element could not be found within time
|
||||
"""
|
||||
match selector_type:
|
||||
case By.ID:
|
||||
escaped_id = selector_value.translate(METACHAR_ESCAPER)
|
||||
return await self.web_await(
|
||||
lambda: self.page.query_selector(f"#{escaped_id}", parent),
|
||||
timeout = timeout,
|
||||
timeout_error_message = f"No HTML element found with ID '{selector_value}' within {timeout} seconds.")
|
||||
case By.CLASS_NAME:
|
||||
escaped_classname = selector_value.translate(METACHAR_ESCAPER)
|
||||
return await self.web_await(
|
||||
lambda: self.page.query_selector(f".{escaped_classname}", parent),
|
||||
timeout = timeout,
|
||||
timeout_error_message = f"No HTML element found with CSS class '{selector_value}' within {timeout} seconds.")
|
||||
case By.TAG_NAME:
|
||||
return await self.web_await(
|
||||
lambda: self.page.query_selector(selector_value, parent),
|
||||
timeout = timeout,
|
||||
timeout_error_message = f"No HTML element found of tag <{selector_value}> within {timeout} seconds.")
|
||||
case By.CSS_SELECTOR:
|
||||
return await self.web_await(
|
||||
lambda: self.page.query_selector(selector_value, parent),
|
||||
timeout = timeout,
|
||||
timeout_error_message = f"No HTML element found using CSS selector '{selector_value}' within {timeout} seconds.")
|
||||
case By.TEXT:
|
||||
ensure(not parent, f"Specifying a parent element currently not supported with selector type: {selector_type}")
|
||||
return await self.web_await(
|
||||
lambda: self.page.find_element_by_text(selector_value, best_match = True),
|
||||
timeout = timeout,
|
||||
timeout_error_message = f"No HTML element found containing text '{selector_value}' within {timeout} seconds.")
|
||||
case By.XPATH:
|
||||
ensure(not parent, f"Specifying a parent element currently not supported with selector type: {selector_type}")
|
||||
return await self.web_await(
|
||||
lambda: self.page.find_element_by_text(selector_value, best_match = True),
|
||||
timeout = timeout,
|
||||
timeout_error_message = f"No HTML element found using XPath '{selector_value}' within {timeout} seconds.")
|
||||
|
||||
raise AssertionError(_("Unsupported selector type: %s") % selector_type)
|
||||
|
||||
async def web_find_all(self, selector_type:By, selector_value:str, *, parent:Element | None = None, timeout:int | float = 5) -> list[Element]:
|
||||
"""
|
||||
Locates an HTML element by ID.
|
||||
|
||||
:param timeout: timeout in seconds
|
||||
:raises TimeoutError: if element could not be found within time
|
||||
"""
|
||||
match selector_type:
|
||||
case By.CLASS_NAME:
|
||||
escaped_classname = selector_value.translate(METACHAR_ESCAPER)
|
||||
return await self.web_await(
|
||||
lambda: self.page.query_selector_all(f".{escaped_classname}", parent),
|
||||
timeout = timeout,
|
||||
timeout_error_message = f"No HTML elements found with CSS class '{selector_value}' within {timeout} seconds.")
|
||||
case By.CSS_SELECTOR:
|
||||
return await self.web_await(
|
||||
lambda: self.page.query_selector_all(selector_value, parent),
|
||||
timeout = timeout,
|
||||
timeout_error_message = f"No HTML elements found using CSS selector '{selector_value}' within {timeout} seconds.")
|
||||
case By.TAG_NAME:
|
||||
return await self.web_await(
|
||||
lambda: self.page.query_selector_all(selector_value, parent),
|
||||
timeout = timeout,
|
||||
timeout_error_message = f"No HTML elements found of tag <{selector_value}> within {timeout} seconds.")
|
||||
case By.TEXT:
|
||||
ensure(not parent, f"Specifying a parent element currently not supported with selector type: {selector_type}")
|
||||
return await self.web_await(
|
||||
lambda: self.page.find_elements_by_text(selector_value),
|
||||
timeout = timeout,
|
||||
timeout_error_message = f"No HTML elements found containing text '{selector_value}' within {timeout} seconds.")
|
||||
case By.XPATH:
|
||||
ensure(not parent, f"Specifying a parent element currently not supported with selector type: {selector_type}")
|
||||
return await self.web_await(
|
||||
lambda: self.page.find_elements_by_text(selector_value),
|
||||
timeout = timeout,
|
||||
timeout_error_message = f"No HTML elements found using XPath '{selector_value}' within {timeout} seconds.")
|
||||
|
||||
raise AssertionError(_("Unsupported selector type: %s") % selector_type)
|
||||
|
||||
async def web_input(self, selector_type:By, selector_value:str, text:str | int, *, timeout:int | float = 5) -> Element:
|
||||
"""
|
||||
Enters text into an HTML input field.
|
||||
|
||||
:param timeout: timeout in seconds
|
||||
:raises TimeoutError: if element could not be found within time
|
||||
"""
|
||||
input_field = await self.web_find(selector_type, selector_value, timeout = timeout)
|
||||
await input_field.clear_input()
|
||||
await input_field.send_keys(str(text))
|
||||
await self.web_sleep()
|
||||
return input_field
|
||||
|
||||
async def web_open(self, url:str, *, timeout:int | float = 15_000, reload_if_already_open:bool = False) -> None:
|
||||
"""
|
||||
:param url: url to open in browser
|
||||
:param timeout: timespan in seconds within the page needs to be loaded
|
||||
:param reload_if_already_open: if False does nothing if the URL is already open in the browser
|
||||
:raises TimeoutException: if page did not open within given timespan
|
||||
"""
|
||||
LOG.debug(" -> Opening [%s]...", url)
|
||||
if not reload_if_already_open and self.page and url == self.page.url:
|
||||
LOG.debug(" => skipping, [%s] is already open", url)
|
||||
return
|
||||
self.page = await self.browser.get(url = url, new_tab = False, new_window = False)
|
||||
await self.web_await(lambda: self.web_execute("document.readyState == 'complete'"), timeout = timeout,
|
||||
timeout_error_message = f"Page did not finish loading within {timeout} seconds.")
|
||||
|
||||
async def web_text(self, selector_type:By, selector_value:str, *, parent:Element | None = None, timeout:int | float = 5) -> str:
|
||||
return str(await (await self.web_find(selector_type, selector_value, parent = parent, timeout = timeout)).apply("""
|
||||
function (elem) {
|
||||
let sel = window.getSelection()
|
||||
sel.removeAllRanges()
|
||||
let range = document.createRange()
|
||||
range.selectNode(elem)
|
||||
sel.addRange(range)
|
||||
let visibleText = sel.toString().trim()
|
||||
sel.removeAllRanges()
|
||||
return visibleText
|
||||
}
|
||||
"""))
|
||||
|
||||
async def web_sleep(self, min_ms:int = 1_000, max_ms:int = 2_500) -> None:
|
||||
duration = max_ms <= min_ms and min_ms or secrets.randbelow(max_ms - min_ms) + min_ms
|
||||
LOG.log(loggers.INFO if duration > 1_500 else loggers.DEBUG, # noqa: PLR2004 Magic value used in comparison
|
||||
" ... pausing for %d ms ...", duration)
|
||||
await self.page.sleep(duration / 1_000)
|
||||
|
||||
async def web_request(self, url:str, method:str = "GET", valid_response_codes:int | Iterable[int] = 200,
|
||||
headers:dict[str, str] | None = None) -> dict[str, Any]:
|
||||
method = method.upper()
|
||||
LOG.debug(" -> HTTP %s [%s]...", method, url)
|
||||
response = cast(dict[str, Any], await self.page.evaluate(f"""
|
||||
fetch("{url}", {{
|
||||
method: "{method}",
|
||||
redirect: "follow",
|
||||
headers: {headers or {}}
|
||||
}})
|
||||
.then(response => response.text().then(responseText => {{
|
||||
headers = {{}};
|
||||
response.headers.forEach((v, k) => headers[k] = v);
|
||||
return {{
|
||||
statusCode: response.status,
|
||||
statusMessage: response.statusText,
|
||||
headers: headers,
|
||||
content: responseText
|
||||
}}
|
||||
}}))
|
||||
""", await_promise = True, return_by_value = True))
|
||||
if isinstance(valid_response_codes, int):
|
||||
valid_response_codes = [valid_response_codes]
|
||||
ensure(
|
||||
response["statusCode"] in valid_response_codes,
|
||||
f'Invalid response "{response["statusCode"]} response["statusMessage"]" received for HTTP {method} to {url}'
|
||||
)
|
||||
return response
|
||||
# pylint: enable=dangerous-default-value
|
||||
|
||||
async def web_scroll_page_down(self, scroll_length:int = 10, scroll_speed:int = 10_000, *, scroll_back_top:bool = False) -> None:
|
||||
"""
|
||||
Smoothly scrolls the current web page down.
|
||||
|
||||
:param scroll_length: the length of a single scroll iteration, determines smoothness of scrolling, lower is smoother
|
||||
:param scroll_speed: the speed of scrolling, higher is faster
|
||||
:param scroll_back_top: whether to scroll the page back to the top after scrolling to the bottom
|
||||
"""
|
||||
current_y_pos = 0
|
||||
bottom_y_pos:int = await self.web_execute("document.body.scrollHeight") # get bottom position
|
||||
while current_y_pos < bottom_y_pos: # scroll in steps until bottom reached
|
||||
current_y_pos += scroll_length
|
||||
await self.web_execute(f"window.scrollTo(0, {current_y_pos})") # scroll one step
|
||||
await asyncio.sleep(scroll_length / scroll_speed)
|
||||
|
||||
if scroll_back_top: # scroll back to top in same style
|
||||
while current_y_pos > 0:
|
||||
current_y_pos -= scroll_length
|
||||
await self.web_execute(f"window.scrollTo(0, {current_y_pos})")
|
||||
await asyncio.sleep(scroll_length / scroll_speed / 2) # double speed
|
||||
|
||||
async def web_select(self, selector_type:By, selector_value:str, selected_value:Any, timeout:int | float = 5) -> Element:
|
||||
"""
|
||||
Selects an <option/> of a <select/> HTML element.
|
||||
|
||||
:param timeout: timeout in seconds
|
||||
:raises TimeoutError: if element could not be found within time
|
||||
:raises UnexpectedTagNameException: if element is not a <select> element
|
||||
"""
|
||||
await self.web_await(
|
||||
lambda: self.web_check(selector_type, selector_value, Is.CLICKABLE), timeout = timeout,
|
||||
timeout_error_message = f"No clickable HTML element with selector: {selector_type}='{selector_value}' found"
|
||||
)
|
||||
elem = await self.web_find(selector_type, selector_value)
|
||||
await elem.apply(f"""
|
||||
function (element) {{
|
||||
for(let i=0; i < element.options.length; i++)
|
||||
{{
|
||||
if(element.options[i].value == "{selected_value}") {{
|
||||
element.selectedIndex = i;
|
||||
element.dispatchEvent(new Event('change', {{ bubbles: true }}));
|
||||
break;
|
||||
}}
|
||||
}}
|
||||
throw new Error("Option with value {selected_value} not found.");
|
||||
}}
|
||||
""")
|
||||
await self.web_sleep()
|
||||
return elem
|
||||
@@ -1,14 +0,0 @@
|
||||
"""
|
||||
SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
"""
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
from kleinanzeigen_bot import utils
|
||||
|
||||
utils.configure_console_logging()
|
||||
|
||||
LOG:Final[logging.Logger] = logging.getLogger("kleinanzeigen_bot")
|
||||
LOG.setLevel(logging.DEBUG)
|
||||
202
tests/conftest.py
Normal file
202
tests/conftest.py
Normal file
@@ -0,0 +1,202 @@
|
||||
# SPDX-FileCopyrightText: © Jens Bergmann and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
import os
|
||||
from typing import Any, Final
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from kleinanzeigen_bot import KleinanzeigenBot
|
||||
from kleinanzeigen_bot.extract import AdExtractor
|
||||
from kleinanzeigen_bot.utils import loggers
|
||||
from kleinanzeigen_bot.utils.web_scraping_mixin import Browser
|
||||
|
||||
loggers.configure_console_logging()
|
||||
|
||||
LOG:Final[loggers.Logger] = loggers.get_logger("kleinanzeigen_bot")
|
||||
LOG.setLevel(loggers.DEBUG)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_data_dir(tmp_path:str) -> str:
|
||||
"""Provides a temporary directory for test data.
|
||||
|
||||
This fixture uses pytest's built-in tmp_path fixture to create a temporary
|
||||
directory that is automatically cleaned up after each test.
|
||||
"""
|
||||
return str(tmp_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_config() -> dict[str, Any]:
|
||||
"""Provides a basic sample configuration for testing.
|
||||
|
||||
This configuration includes all required fields for the bot to function:
|
||||
- Login credentials (username/password)
|
||||
- Browser settings
|
||||
- Ad defaults (description prefix/suffix)
|
||||
- Publishing settings
|
||||
"""
|
||||
return {
|
||||
"login": {
|
||||
"username": "testuser",
|
||||
"password": "testpass"
|
||||
},
|
||||
"browser": {
|
||||
"arguments": [],
|
||||
"binary_location": None,
|
||||
"extensions": [],
|
||||
"use_private_window": True,
|
||||
"user_data_dir": None,
|
||||
"profile_name": None
|
||||
},
|
||||
"ad_defaults": {
|
||||
"description": {
|
||||
"prefix": "Test Prefix",
|
||||
"suffix": "Test Suffix"
|
||||
}
|
||||
},
|
||||
"publishing": {
|
||||
"delete_old_ads": "BEFORE_PUBLISH",
|
||||
"delete_old_ads_by_title": False
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_bot(sample_config:dict[str, Any]) -> KleinanzeigenBot:
|
||||
"""Provides a fresh KleinanzeigenBot instance for all test classes.
|
||||
|
||||
Dependencies:
|
||||
- sample_config: Used to initialize the bot with a valid configuration
|
||||
"""
|
||||
bot_instance = KleinanzeigenBot()
|
||||
bot_instance.config = sample_config
|
||||
return bot_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def browser_mock() -> MagicMock:
|
||||
"""Provides a mock browser instance for testing.
|
||||
|
||||
This mock is configured with the Browser spec to ensure it has all
|
||||
the required methods and attributes of a real Browser instance.
|
||||
"""
|
||||
return MagicMock(spec = Browser)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def log_file_path(test_data_dir:str) -> str:
|
||||
"""Provides a temporary path for log files.
|
||||
|
||||
Dependencies:
|
||||
- test_data_dir: Used to create the log file in the temporary test directory
|
||||
"""
|
||||
return os.path.join(str(test_data_dir), "test.log")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_extractor(browser_mock:MagicMock, sample_config:dict[str, Any]) -> AdExtractor:
|
||||
"""Provides a fresh AdExtractor instance for testing.
|
||||
|
||||
Dependencies:
|
||||
- browser_mock: Used to mock browser interactions
|
||||
- sample_config: Used to initialize the extractor with a valid configuration
|
||||
"""
|
||||
return AdExtractor(browser_mock, sample_config)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def description_test_cases() -> list[tuple[dict[str, Any], str, str]]:
|
||||
"""Provides test cases for description prefix/suffix handling.
|
||||
|
||||
Returns tuples of (config, raw_description, expected_description)
|
||||
"""
|
||||
return [
|
||||
# Test case 1: New flattened format
|
||||
(
|
||||
{
|
||||
"ad_defaults": {
|
||||
"description_prefix": "Global Prefix\n",
|
||||
"description_suffix": "\nGlobal Suffix"
|
||||
}
|
||||
},
|
||||
"Original Description", # Raw description without affixes
|
||||
"Global Prefix\nOriginal Description\nGlobal Suffix" # Expected with affixes
|
||||
),
|
||||
# Test case 2: Legacy nested format
|
||||
(
|
||||
{
|
||||
"ad_defaults": {
|
||||
"description": {
|
||||
"prefix": "Legacy Prefix\n",
|
||||
"suffix": "\nLegacy Suffix"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Original Description",
|
||||
"Legacy Prefix\nOriginal Description\nLegacy Suffix"
|
||||
),
|
||||
# Test case 3: Both formats - new format takes precedence
|
||||
(
|
||||
{
|
||||
"ad_defaults": {
|
||||
"description_prefix": "New Prefix\n",
|
||||
"description_suffix": "\nNew Suffix",
|
||||
"description": {
|
||||
"prefix": "Legacy Prefix\n",
|
||||
"suffix": "\nLegacy Suffix"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Original Description",
|
||||
"New Prefix\nOriginal Description\nNew Suffix"
|
||||
),
|
||||
# Test case 4: Empty config
|
||||
(
|
||||
{"ad_defaults": {}},
|
||||
"Original Description",
|
||||
"Original Description"
|
||||
),
|
||||
# Test case 5: None values in config
|
||||
(
|
||||
{
|
||||
"ad_defaults": {
|
||||
"description_prefix": None,
|
||||
"description_suffix": None,
|
||||
"description": {
|
||||
"prefix": None,
|
||||
"suffix": None
|
||||
}
|
||||
}
|
||||
},
|
||||
"Original Description",
|
||||
"Original Description"
|
||||
),
|
||||
# Test case 6: Non-string values in config
|
||||
(
|
||||
{
|
||||
"ad_defaults": {
|
||||
"description_prefix": 123,
|
||||
"description_suffix": True,
|
||||
"description": {
|
||||
"prefix": [],
|
||||
"suffix": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Original Description",
|
||||
"Original Description"
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_web_text_responses() -> list[str]:
|
||||
"""Provides common mock responses for web_text calls."""
|
||||
return [
|
||||
"Test Title", # Title
|
||||
"Test Description", # Description
|
||||
"03.02.2025" # Creation date
|
||||
]
|
||||
39
tests/integration/test_web_scraping_mixin.py
Normal file
39
tests/integration/test_web_scraping_mixin.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
import os
|
||||
import platform
|
||||
from typing import cast
|
||||
|
||||
import nodriver
|
||||
import pytest
|
||||
|
||||
from kleinanzeigen_bot.utils import loggers
|
||||
from kleinanzeigen_bot.utils.misc import ensure
|
||||
from kleinanzeigen_bot.utils.web_scraping_mixin import WebScrapingMixin
|
||||
|
||||
if os.environ.get("CI"):
|
||||
loggers.get_logger("kleinanzeigen_bot").setLevel(loggers.DEBUG)
|
||||
loggers.get_logger("nodriver").setLevel(loggers.DEBUG)
|
||||
|
||||
|
||||
async def atest_init() -> None:
|
||||
web_scraping_mixin = WebScrapingMixin()
|
||||
if platform.system() == "Linux":
|
||||
# required for Ubuntu 24.04 or newer
|
||||
cast(list[str], web_scraping_mixin.browser_config.arguments).append("--no-sandbox")
|
||||
|
||||
browser_path = web_scraping_mixin.get_compatible_browser()
|
||||
ensure(browser_path is not None, "Browser not auto-detected")
|
||||
|
||||
web_scraping_mixin.close_browser_session()
|
||||
try:
|
||||
await web_scraping_mixin.create_browser_session()
|
||||
finally:
|
||||
web_scraping_mixin.close_browser_session()
|
||||
|
||||
|
||||
@pytest.mark.flaky(reruns = 4, reruns_delay = 5)
|
||||
@pytest.mark.itest
|
||||
def test_init() -> None:
|
||||
nodriver.loop().run_until_complete(atest_init())
|
||||
@@ -1,22 +0,0 @@
|
||||
"""
|
||||
SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from kleinanzeigen_bot.selenium_mixin import SeleniumMixin
|
||||
from kleinanzeigen_bot import utils
|
||||
|
||||
|
||||
@pytest.mark.itest
|
||||
def test_webdriver_auto_init():
|
||||
selenium_mixin = SeleniumMixin()
|
||||
selenium_mixin.browser_config.arguments = ["--no-sandbox"]
|
||||
|
||||
browser_path = selenium_mixin.get_compatible_browser()
|
||||
utils.ensure(browser_path is not None, "Browser not auto-detected")
|
||||
|
||||
selenium_mixin.webdriver = None
|
||||
selenium_mixin.create_webdriver_session()
|
||||
selenium_mixin.webdriver.quit()
|
||||
@@ -1,41 +0,0 @@
|
||||
"""
|
||||
SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
"""
|
||||
import os, sys, time
|
||||
import pytest
|
||||
from kleinanzeigen_bot import utils
|
||||
|
||||
|
||||
def test_ensure():
|
||||
utils.ensure(True, "TRUE")
|
||||
utils.ensure("Some Value", "TRUE")
|
||||
utils.ensure(123, "TRUE")
|
||||
utils.ensure(-123, "TRUE")
|
||||
utils.ensure(lambda: True, "TRUE")
|
||||
|
||||
with pytest.raises(AssertionError):
|
||||
utils.ensure(False, "FALSE")
|
||||
|
||||
with pytest.raises(AssertionError):
|
||||
utils.ensure(0, "FALSE")
|
||||
|
||||
with pytest.raises(AssertionError):
|
||||
utils.ensure("", "FALSE")
|
||||
|
||||
with pytest.raises(AssertionError):
|
||||
utils.ensure(None, "FALSE")
|
||||
|
||||
with pytest.raises(AssertionError):
|
||||
utils.ensure(lambda: False, "FALSE", timeout = 2)
|
||||
|
||||
|
||||
def test_pause():
|
||||
start = time.time()
|
||||
utils.pause(100, 100)
|
||||
elapsed = 1000 * (time.time() - start)
|
||||
if sys.platform == "darwin" and os.getenv("GITHUB_ACTIONS", "true") == "true":
|
||||
assert 99 < elapsed < 300
|
||||
else:
|
||||
assert 99 < elapsed < 120
|
||||
176
tests/unit/test_ads.py
Normal file
176
tests/unit/test_ads.py
Normal file
@@ -0,0 +1,176 @@
|
||||
# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from kleinanzeigen_bot import ads
|
||||
|
||||
|
||||
def test_calculate_content_hash_with_none_values() -> None:
|
||||
"""Test calculate_content_hash with None values in the ad configuration."""
|
||||
ad_cfg = {
|
||||
# Minimal configuration with None values as described in bug report
|
||||
"id": "123456789",
|
||||
"created_on": "2022-07-19T07:30:20.489289",
|
||||
"updated_on": "2025-01-22T19:46:46.735896",
|
||||
"title": "Test Ad",
|
||||
"description": "Test Description",
|
||||
"images": [None, "/path/to/image.jpg", None], # List containing None values
|
||||
"shipping_options": None, # None instead of list
|
||||
"special_attributes": None, # None instead of dictionary
|
||||
"contact": {
|
||||
"street": None # None value in contact
|
||||
}
|
||||
}
|
||||
|
||||
# Should not raise TypeError
|
||||
hash_value = ads.calculate_content_hash(ad_cfg)
|
||||
assert isinstance(hash_value, str)
|
||||
assert len(hash_value) == 64 # SHA-256 hash is 64 characters long
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("config", "prefix", "expected"), [
|
||||
# Test new flattened format - prefix
|
||||
(
|
||||
{"ad_defaults": {"description_prefix": "Hello"}},
|
||||
True,
|
||||
"Hello"
|
||||
),
|
||||
# Test new flattened format - suffix
|
||||
(
|
||||
{"ad_defaults": {"description_suffix": "Bye"}},
|
||||
False,
|
||||
"Bye"
|
||||
),
|
||||
# Test legacy nested format - prefix
|
||||
(
|
||||
{"ad_defaults": {"description": {"prefix": "Hi"}}},
|
||||
True,
|
||||
"Hi"
|
||||
),
|
||||
# Test legacy nested format - suffix
|
||||
(
|
||||
{"ad_defaults": {"description": {"suffix": "Ciao"}}},
|
||||
False,
|
||||
"Ciao"
|
||||
),
|
||||
# Test precedence (new format over legacy) - prefix
|
||||
(
|
||||
{
|
||||
"ad_defaults": {
|
||||
"description_prefix": "Hello",
|
||||
"description": {"prefix": "Hi"}
|
||||
}
|
||||
},
|
||||
True,
|
||||
"Hello"
|
||||
),
|
||||
# Test precedence (new format over legacy) - suffix
|
||||
(
|
||||
{
|
||||
"ad_defaults": {
|
||||
"description_suffix": "Bye",
|
||||
"description": {"suffix": "Ciao"}
|
||||
}
|
||||
},
|
||||
False,
|
||||
"Bye"
|
||||
),
|
||||
# Test empty config
|
||||
(
|
||||
{"ad_defaults": {}},
|
||||
True,
|
||||
""
|
||||
),
|
||||
# Test None values
|
||||
(
|
||||
{"ad_defaults": {"description_prefix": None, "description_suffix": None}},
|
||||
True,
|
||||
""
|
||||
),
|
||||
# Test non-string values
|
||||
(
|
||||
{"ad_defaults": {"description_prefix": 123, "description_suffix": True}},
|
||||
True,
|
||||
""
|
||||
),
|
||||
# Add test for malformed config
|
||||
(
|
||||
{}, # Empty config
|
||||
True,
|
||||
""
|
||||
),
|
||||
# Test for missing ad_defaults
|
||||
(
|
||||
{"some_other_key": {}},
|
||||
True,
|
||||
""
|
||||
),
|
||||
# Test for non-dict ad_defaults
|
||||
(
|
||||
{"ad_defaults": "invalid"},
|
||||
True,
|
||||
""
|
||||
),
|
||||
# Test for invalid type in description field
|
||||
(
|
||||
{"ad_defaults": {"description": 123}},
|
||||
True,
|
||||
""
|
||||
)
|
||||
])
|
||||
def test_get_description_affixes(
|
||||
config:dict[str, Any],
|
||||
prefix:bool,
|
||||
expected:str
|
||||
) -> None:
|
||||
"""Test get_description_affixes function with various inputs."""
|
||||
result = ads.get_description_affixes(config, prefix = prefix)
|
||||
assert result == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("config", "prefix", "expected"), [
|
||||
# Add test for malformed config
|
||||
(
|
||||
{}, # Empty config
|
||||
True,
|
||||
""
|
||||
),
|
||||
# Test for missing ad_defaults
|
||||
(
|
||||
{"some_other_key": {}},
|
||||
True,
|
||||
""
|
||||
),
|
||||
# Test for non-dict ad_defaults
|
||||
(
|
||||
{"ad_defaults": "invalid"},
|
||||
True,
|
||||
""
|
||||
),
|
||||
# Test for invalid type in description field
|
||||
(
|
||||
{"ad_defaults": {"description": 123}},
|
||||
True,
|
||||
""
|
||||
)
|
||||
])
|
||||
def test_get_description_affixes_edge_cases(config:dict[str, Any], prefix:bool, expected:str) -> None:
|
||||
"""Test edge cases for description affix handling."""
|
||||
assert ads.get_description_affixes(config, prefix = prefix) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("config", "expected"), [
|
||||
(None, ""), # Test with None
|
||||
([], ""), # Test with an empty list
|
||||
("string", ""), # Test with a string
|
||||
(123, ""), # Test with an integer
|
||||
(3.14, ""), # Test with a float
|
||||
(set(), ""), # Test with an empty set
|
||||
])
|
||||
def test_get_description_affixes_edge_cases_non_dict(config:Any, expected:str) -> None:
|
||||
"""Test get_description_affixes function with non-dict inputs."""
|
||||
result = ads.get_description_affixes(config, prefix = True)
|
||||
assert result == expected
|
||||
50
tests/unit/test_bot.py
Normal file
50
tests/unit/test_bot.py
Normal file
@@ -0,0 +1,50 @@
|
||||
# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
import gc, pytest # isort: skip
|
||||
|
||||
from kleinanzeigen_bot import KleinanzeigenBot
|
||||
|
||||
|
||||
class TestKleinanzeigenBot:
|
||||
|
||||
@pytest.fixture
|
||||
def bot(self) -> KleinanzeigenBot:
|
||||
return KleinanzeigenBot()
|
||||
|
||||
def test_parse_args_help(self, bot:KleinanzeigenBot) -> None:
|
||||
"""Test parsing of help command"""
|
||||
bot.parse_args(["app", "help"])
|
||||
assert bot.command == "help"
|
||||
assert bot.ads_selector == "due"
|
||||
assert not bot.keep_old_ads
|
||||
|
||||
def test_parse_args_publish(self, bot:KleinanzeigenBot) -> None:
|
||||
"""Test parsing of publish command with options"""
|
||||
bot.parse_args(["app", "publish", "--ads=all", "--keep-old"])
|
||||
assert bot.command == "publish"
|
||||
assert bot.ads_selector == "all"
|
||||
assert bot.keep_old_ads
|
||||
|
||||
def test_get_version(self, bot:KleinanzeigenBot) -> None:
|
||||
"""Test version retrieval"""
|
||||
version = bot.get_version()
|
||||
assert isinstance(version, str)
|
||||
assert len(version) > 0
|
||||
|
||||
def test_file_log_closed_after_bot_shutdown(self) -> None:
|
||||
"""Ensure the file log handler is properly closed after the bot is deleted"""
|
||||
|
||||
# Directly instantiate the bot to control its lifecycle within the test
|
||||
bot = KleinanzeigenBot()
|
||||
|
||||
bot.configure_file_logging()
|
||||
file_log = bot.file_log
|
||||
assert file_log is not None
|
||||
assert not file_log.is_closed()
|
||||
|
||||
# Delete and garbage collect the bot instance to ensure the destructor (__del__) is called
|
||||
del bot
|
||||
gc.collect()
|
||||
|
||||
assert file_log.is_closed()
|
||||
783
tests/unit/test_extract.py
Normal file
783
tests/unit/test_extract.py
Normal file
@@ -0,0 +1,783 @@
|
||||
# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
import json, os # isort: skip
|
||||
from typing import Any, TypedDict
|
||||
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from kleinanzeigen_bot.extract import AdExtractor
|
||||
from kleinanzeigen_bot.utils.web_scraping_mixin import Browser, By, Element
|
||||
|
||||
|
||||
class _DimensionsDict(TypedDict):
|
||||
dimension108:str
|
||||
|
||||
|
||||
class _UniversalAnalyticsOptsDict(TypedDict):
|
||||
dimensions:_DimensionsDict
|
||||
|
||||
|
||||
class _BelenConfDict(TypedDict):
|
||||
universalAnalyticsOpts:_UniversalAnalyticsOptsDict
|
||||
|
||||
|
||||
class _SpecialAttributesDict(TypedDict, total = False):
|
||||
art_s:str
|
||||
condition_s:str
|
||||
|
||||
|
||||
class _TestCaseDict(TypedDict): # noqa: PYI049 Private TypedDict `...` is never used
|
||||
belen_conf:_BelenConfDict
|
||||
expected:_SpecialAttributesDict
|
||||
|
||||
|
||||
class TestAdExtractorBasics:
|
||||
"""Basic synchronous tests for AdExtractor."""
|
||||
|
||||
def test_constructor(self, browser_mock:MagicMock, sample_config:dict[str, Any]) -> None:
|
||||
"""Test the constructor of AdExtractor"""
|
||||
extractor = AdExtractor(browser_mock, sample_config)
|
||||
assert extractor.browser == browser_mock
|
||||
assert extractor.config == sample_config
|
||||
|
||||
@pytest.mark.parametrize(("url", "expected_id"), [
|
||||
("https://www.kleinanzeigen.de/s-anzeige/test-title/12345678", 12345678),
|
||||
("https://www.kleinanzeigen.de/s-anzeige/another-test/98765432", 98765432),
|
||||
("https://www.kleinanzeigen.de/s-anzeige/invalid-id/abc", -1),
|
||||
("https://www.kleinanzeigen.de/invalid-url", -1),
|
||||
])
|
||||
def test_extract_ad_id_from_ad_url(self, test_extractor:AdExtractor, url:str, expected_id:int) -> None:
|
||||
"""Test extraction of ad ID from different URL formats."""
|
||||
assert test_extractor.extract_ad_id_from_ad_url(url) == expected_id
|
||||
|
||||
|
||||
class TestAdExtractorPricing:
|
||||
"""Tests for pricing related functionality."""
|
||||
|
||||
@pytest.mark.parametrize(("price_text", "expected_price", "expected_type"), [
|
||||
("50 €", 50, "FIXED"),
|
||||
("1.234 €", 1234, "FIXED"),
|
||||
("50 € VB", 50, "NEGOTIABLE"),
|
||||
("VB", None, "NEGOTIABLE"),
|
||||
("Zu verschenken", None, "GIVE_AWAY"),
|
||||
])
|
||||
@pytest.mark.asyncio
|
||||
# pylint: disable=protected-access
|
||||
async def test_extract_pricing_info(
|
||||
self, test_extractor:AdExtractor, price_text:str, expected_price:int | None, expected_type:str
|
||||
) -> None:
|
||||
"""Test price extraction with different formats"""
|
||||
with patch.object(test_extractor, "web_text", new_callable = AsyncMock, return_value = price_text):
|
||||
price, price_type = await test_extractor._extract_pricing_info_from_ad_page()
|
||||
assert price == expected_price
|
||||
assert price_type == expected_type
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# pylint: disable=protected-access
|
||||
async def test_extract_pricing_info_timeout(self, test_extractor:AdExtractor) -> None:
|
||||
"""Test price extraction when element is not found"""
|
||||
with patch.object(test_extractor, "web_text", new_callable = AsyncMock, side_effect = TimeoutError):
|
||||
price, price_type = await test_extractor._extract_pricing_info_from_ad_page()
|
||||
assert price is None
|
||||
assert price_type == "NOT_APPLICABLE"
|
||||
|
||||
|
||||
class TestAdExtractorShipping:
|
||||
"""Tests for shipping related functionality."""
|
||||
|
||||
@pytest.mark.parametrize(("shipping_text", "expected_type", "expected_cost"), [
|
||||
("+ Versand ab 2,99 €", "SHIPPING", 2.99),
|
||||
("Nur Abholung", "PICKUP", None),
|
||||
("Versand möglich", "SHIPPING", None),
|
||||
])
|
||||
@pytest.mark.asyncio
|
||||
# pylint: disable=protected-access
|
||||
async def test_extract_shipping_info(
|
||||
self, test_extractor:AdExtractor, shipping_text:str, expected_type:str, expected_cost:float | None
|
||||
) -> None:
|
||||
"""Test shipping info extraction with different text formats."""
|
||||
with patch.object(test_extractor, "page", MagicMock()), \
|
||||
patch.object(test_extractor, "web_text", new_callable = AsyncMock, return_value = shipping_text), \
|
||||
patch.object(test_extractor, "web_request", new_callable = AsyncMock) as mock_web_request:
|
||||
|
||||
if expected_cost:
|
||||
shipping_response:dict[str, Any] = {
|
||||
"data": {
|
||||
"shippingOptionsResponse": {
|
||||
"options": [
|
||||
{"id": "DHL_001", "priceInEuroCent": int(expected_cost * 100), "packageSize": "SMALL"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
mock_web_request.return_value = {"content": json.dumps(shipping_response)}
|
||||
|
||||
shipping_type, costs, options = await test_extractor._extract_shipping_info_from_ad_page()
|
||||
|
||||
assert shipping_type == expected_type
|
||||
assert costs == expected_cost
|
||||
if expected_cost:
|
||||
assert options == ["DHL_2"]
|
||||
else:
|
||||
assert options is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# pylint: disable=protected-access
|
||||
async def test_extract_shipping_info_with_options(self, test_extractor:AdExtractor) -> None:
|
||||
"""Test shipping info extraction with shipping options."""
|
||||
shipping_response = {
|
||||
"content": json.dumps({
|
||||
"data": {
|
||||
"shippingOptionsResponse": {
|
||||
"options": [
|
||||
{"id": "DHL_001", "priceInEuroCent": 549, "packageSize": "SMALL"}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
with patch.object(test_extractor, "page", MagicMock()), \
|
||||
patch.object(test_extractor, "web_text", new_callable = AsyncMock, return_value = "+ Versand ab 5,49 €"), \
|
||||
patch.object(test_extractor, "web_request", new_callable = AsyncMock, return_value = shipping_response):
|
||||
|
||||
shipping_type, costs, options = await test_extractor._extract_shipping_info_from_ad_page()
|
||||
|
||||
assert shipping_type == "SHIPPING"
|
||||
assert costs == 5.49
|
||||
assert options == ["DHL_2"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# pylint: disable=protected-access
|
||||
async def test_extract_shipping_info_with_all_matching_options(self, test_extractor:AdExtractor) -> None:
|
||||
"""Test shipping info extraction with all matching options enabled."""
|
||||
shipping_response = {
|
||||
"content": json.dumps({
|
||||
"data": {
|
||||
"shippingOptionsResponse": {
|
||||
"options": [
|
||||
{"id": "HERMES_001", "priceInEuroCent": 489, "packageSize": "SMALL"},
|
||||
{"id": "HERMES_002", "priceInEuroCent": 549, "packageSize": "SMALL"},
|
||||
{"id": "DHL_001", "priceInEuroCent": 619, "packageSize": "SMALL"}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
# Enable all matching options in config
|
||||
test_extractor.config["download"] = {"include_all_matching_shipping_options": True}
|
||||
|
||||
with patch.object(test_extractor, "page", MagicMock()), \
|
||||
patch.object(test_extractor, "web_text", new_callable = AsyncMock, return_value = "+ Versand ab 4,89 €"), \
|
||||
patch.object(test_extractor, "web_request", new_callable = AsyncMock, return_value = shipping_response):
|
||||
|
||||
shipping_type, costs, options = await test_extractor._extract_shipping_info_from_ad_page()
|
||||
|
||||
assert shipping_type == "SHIPPING"
|
||||
assert costs == 4.89
|
||||
if options is not None:
|
||||
assert sorted(options) == ["DHL_2", "Hermes_Päckchen", "Hermes_S"]
|
||||
else:
|
||||
assert options is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# pylint: disable=protected-access
|
||||
async def test_extract_shipping_info_with_excluded_options(self, test_extractor:AdExtractor) -> None:
|
||||
"""Test shipping info extraction with excluded options."""
|
||||
shipping_response = {
|
||||
"content": json.dumps({
|
||||
"data": {
|
||||
"shippingOptionsResponse": {
|
||||
"options": [
|
||||
{"id": "HERMES_001", "priceInEuroCent": 489, "packageSize": "SMALL"},
|
||||
{"id": "HERMES_002", "priceInEuroCent": 549, "packageSize": "SMALL"},
|
||||
{"id": "DHL_001", "priceInEuroCent": 619, "packageSize": "SMALL"}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
# Enable all matching options and exclude DHL in config
|
||||
test_extractor.config["download"] = {
|
||||
"include_all_matching_shipping_options": True,
|
||||
"excluded_shipping_options": ["DHL_2"]
|
||||
}
|
||||
|
||||
with patch.object(test_extractor, "page", MagicMock()), \
|
||||
patch.object(test_extractor, "web_text", new_callable = AsyncMock, return_value = "+ Versand ab 4,89 €"), \
|
||||
patch.object(test_extractor, "web_request", new_callable = AsyncMock, return_value = shipping_response):
|
||||
|
||||
shipping_type, costs, options = await test_extractor._extract_shipping_info_from_ad_page()
|
||||
|
||||
assert shipping_type == "SHIPPING"
|
||||
assert costs == 4.89
|
||||
if options is not None:
|
||||
assert sorted(options) == ["Hermes_Päckchen", "Hermes_S"]
|
||||
else:
|
||||
assert options is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# pylint: disable=protected-access
|
||||
async def test_extract_shipping_info_with_excluded_matching_option(self, test_extractor:AdExtractor) -> None:
|
||||
"""Test shipping info extraction when the matching option is excluded."""
|
||||
shipping_response = {
|
||||
"content": json.dumps({
|
||||
"data": {
|
||||
"shippingOptionsResponse": {
|
||||
"options": [
|
||||
{"id": "HERMES_001", "priceInEuroCent": 489, "packageSize": "SMALL"},
|
||||
{"id": "HERMES_002", "priceInEuroCent": 549, "packageSize": "SMALL"}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
# Exclude the matching option
|
||||
test_extractor.config["download"] = {
|
||||
"excluded_shipping_options": ["Hermes_Päckchen"]
|
||||
}
|
||||
|
||||
with patch.object(test_extractor, "page", MagicMock()), \
|
||||
patch.object(test_extractor, "web_text", new_callable = AsyncMock, return_value = "+ Versand ab 4,89 €"), \
|
||||
patch.object(test_extractor, "web_request", new_callable = AsyncMock, return_value = shipping_response):
|
||||
|
||||
shipping_type, costs, options = await test_extractor._extract_shipping_info_from_ad_page()
|
||||
|
||||
assert shipping_type == "NOT_APPLICABLE"
|
||||
assert costs == 4.89
|
||||
assert options is None
|
||||
|
||||
|
||||
class TestAdExtractorNavigation:
|
||||
"""Tests for navigation related functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_navigate_to_ad_page_with_url(self, test_extractor:AdExtractor) -> None:
|
||||
"""Test navigation to ad page using a URL."""
|
||||
page_mock = AsyncMock()
|
||||
page_mock.url = "https://www.kleinanzeigen.de/s-anzeige/test/12345"
|
||||
|
||||
with patch.object(test_extractor, "page", page_mock), \
|
||||
patch.object(test_extractor, "web_open", new_callable = AsyncMock) as mock_web_open, \
|
||||
patch.object(test_extractor, "web_find", new_callable = AsyncMock, side_effect = TimeoutError):
|
||||
|
||||
result = await test_extractor.navigate_to_ad_page("https://www.kleinanzeigen.de/s-anzeige/test/12345")
|
||||
assert result is True
|
||||
mock_web_open.assert_called_with("https://www.kleinanzeigen.de/s-anzeige/test/12345")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_navigate_to_ad_page_with_id(self, test_extractor:AdExtractor) -> None:
|
||||
"""Test navigation to ad page using an ID."""
|
||||
ad_id = 12345
|
||||
page_mock = AsyncMock()
|
||||
page_mock.url = "https://www.kleinanzeigen.de/s-anzeige/test/{0}".format(ad_id)
|
||||
|
||||
popup_close_mock = AsyncMock()
|
||||
popup_close_mock.click = AsyncMock()
|
||||
popup_close_mock.apply = AsyncMock(return_value = True)
|
||||
|
||||
def find_mock(selector_type:By, selector_value:str, **_:Any) -> Element | None:
|
||||
if selector_type == By.CLASS_NAME and selector_value == "mfp-close":
|
||||
return popup_close_mock
|
||||
return None
|
||||
|
||||
with patch.object(test_extractor, "page", page_mock), \
|
||||
patch.object(test_extractor, "web_open", new_callable = AsyncMock) as mock_web_open, \
|
||||
patch.object(test_extractor, "web_find", new_callable = AsyncMock, side_effect = find_mock):
|
||||
|
||||
result = await test_extractor.navigate_to_ad_page(ad_id)
|
||||
assert result is True
|
||||
mock_web_open.assert_called_with("https://www.kleinanzeigen.de/s-suchanfrage.html?keywords={0}".format(ad_id))
|
||||
popup_close_mock.click.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_navigate_to_ad_page_with_popup(self, test_extractor:AdExtractor) -> None:
|
||||
"""Test navigation to ad page with popup handling."""
|
||||
page_mock = AsyncMock()
|
||||
page_mock.url = "https://www.kleinanzeigen.de/s-anzeige/test/12345"
|
||||
|
||||
input_mock = AsyncMock()
|
||||
input_mock.clear_input = AsyncMock()
|
||||
input_mock.send_keys = AsyncMock()
|
||||
input_mock.apply = AsyncMock(return_value = True)
|
||||
|
||||
with patch.object(test_extractor, "page", page_mock), \
|
||||
patch.object(test_extractor, "web_open", new_callable = AsyncMock), \
|
||||
patch.object(test_extractor, "web_find", new_callable = AsyncMock, return_value = input_mock), \
|
||||
patch.object(test_extractor, "web_click", new_callable = AsyncMock) as mock_web_click, \
|
||||
patch.object(test_extractor, "web_check", new_callable = AsyncMock, return_value = True):
|
||||
|
||||
result = await test_extractor.navigate_to_ad_page(12345)
|
||||
assert result is True
|
||||
mock_web_click.assert_called_with(By.CLASS_NAME, "mfp-close")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_navigate_to_ad_page_invalid_id(self, test_extractor:AdExtractor) -> None:
|
||||
"""Test navigation to ad page with invalid ID."""
|
||||
page_mock = AsyncMock()
|
||||
page_mock.url = "https://www.kleinanzeigen.de/s-suchen.html?k0"
|
||||
|
||||
input_mock = AsyncMock()
|
||||
input_mock.clear_input = AsyncMock()
|
||||
input_mock.send_keys = AsyncMock()
|
||||
input_mock.apply = AsyncMock(return_value = True)
|
||||
input_mock.attrs = {}
|
||||
|
||||
with patch.object(test_extractor, "page", page_mock), \
|
||||
patch.object(test_extractor, "web_open", new_callable = AsyncMock), \
|
||||
patch.object(test_extractor, "web_find", new_callable = AsyncMock, return_value = input_mock):
|
||||
|
||||
result = await test_extractor.navigate_to_ad_page(99999)
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_own_ads_urls(self, test_extractor:AdExtractor) -> None:
|
||||
"""Test extraction of own ads URLs - basic test."""
|
||||
with patch.object(test_extractor, "web_open", new_callable = AsyncMock), \
|
||||
patch.object(test_extractor, "web_sleep", new_callable = AsyncMock), \
|
||||
patch.object(test_extractor, "web_find", new_callable = AsyncMock) as mock_web_find, \
|
||||
patch.object(test_extractor, "web_find_all", new_callable = AsyncMock) as mock_web_find_all, \
|
||||
patch.object(test_extractor, "web_scroll_page_down", new_callable = AsyncMock), \
|
||||
patch.object(test_extractor, "web_execute", new_callable = AsyncMock):
|
||||
|
||||
# --- Setup mock objects for DOM elements ---
|
||||
# Mocks needed for the actual execution flow
|
||||
ad_list_container_mock = MagicMock()
|
||||
pagination_section_mock = MagicMock()
|
||||
cardbox_mock = MagicMock() # Represents the <li> element
|
||||
link_mock = MagicMock() # Represents the <a> element
|
||||
link_mock.attrs = {"href": "/s-anzeige/test/12345"} # Configure the desired output
|
||||
|
||||
# Mocks for elements potentially checked but maybe not strictly needed for output
|
||||
# (depending on how robust the mocking is)
|
||||
# next_button_mock = MagicMock() # If needed for multi_page logic
|
||||
|
||||
# --- Setup mock responses for web_find and web_find_all in CORRECT ORDER ---
|
||||
|
||||
# 1. Initial find for ad list container (before loop)
|
||||
# 2. Find for pagination section (pagination check)
|
||||
# 3. Find for ad list container (inside loop)
|
||||
# 4. Find for the link (inside list comprehension)
|
||||
mock_web_find.side_effect = [
|
||||
ad_list_container_mock, # Call 1: find #my-manageitems-adlist (before loop)
|
||||
pagination_section_mock, # Call 2: find .Pagination
|
||||
ad_list_container_mock, # Call 3: find #my-manageitems-adlist (inside loop)
|
||||
link_mock # Call 4: find 'div.manageitems-item-ad h3 a.text-onSurface'
|
||||
# Add more mocks here if the pagination navigation logic calls web_find again
|
||||
]
|
||||
|
||||
# 1. Find all 'Nächste' buttons (pagination check) - Return empty list for single page test case
|
||||
# 2. Find all '.cardbox' elements (inside loop)
|
||||
mock_web_find_all.side_effect = [
|
||||
[], # Call 1: find 'button[aria-label="Nächste"]' -> No next button = single page
|
||||
[cardbox_mock] # Call 2: find .cardbox -> One ad item
|
||||
# Add more mocks here if pagination navigation calls web_find_all
|
||||
]
|
||||
|
||||
# --- Execute test and verify results ---
|
||||
refs = await test_extractor.extract_own_ads_urls()
|
||||
|
||||
# --- Assertions ---
|
||||
assert refs == ["/s-anzeige/test/12345"] # Now it should match
|
||||
|
||||
# Optional: Verify calls were made as expected
|
||||
mock_web_find.assert_has_calls([
|
||||
call(By.ID, "my-manageitems-adlist"),
|
||||
call(By.CSS_SELECTOR, ".Pagination", timeout = 10),
|
||||
call(By.ID, "my-manageitems-adlist"),
|
||||
call(By.CSS_SELECTOR, "div.manageitems-item-ad h3 a.text-onSurface", parent = cardbox_mock),
|
||||
], any_order = False) # Check order if important
|
||||
|
||||
mock_web_find_all.assert_has_calls([
|
||||
call(By.CSS_SELECTOR, 'button[aria-label="Nächste"]', parent = pagination_section_mock),
|
||||
call(By.CLASS_NAME, "cardbox", parent = ad_list_container_mock),
|
||||
], any_order = False)
|
||||
|
||||
|
||||
class TestAdExtractorContent:
|
||||
"""Tests for content extraction functionality."""
|
||||
# pylint: disable=protected-access
|
||||
|
||||
@pytest.fixture
|
||||
def extractor_with_config(self) -> AdExtractor:
|
||||
"""Create extractor with specific config for testing prefix/suffix handling."""
|
||||
browser_mock = MagicMock(spec = Browser)
|
||||
return AdExtractor(browser_mock, {}) # Empty config, will be overridden in tests
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_description_with_affixes(
|
||||
self,
|
||||
test_extractor:AdExtractor,
|
||||
description_test_cases:list[tuple[dict[str, Any], str, str]]
|
||||
) -> None:
|
||||
"""Test extraction of description with various prefix/suffix configurations."""
|
||||
# Mock the page
|
||||
page_mock = MagicMock()
|
||||
page_mock.url = "https://www.kleinanzeigen.de/s-anzeige/test/12345"
|
||||
test_extractor.page = page_mock
|
||||
|
||||
for config, raw_description, _ in description_test_cases: # Changed to _ since we don't use expected_description
|
||||
test_extractor.config = config
|
||||
|
||||
with patch.multiple(test_extractor,
|
||||
web_text = AsyncMock(side_effect = [
|
||||
"Test Title", # Title
|
||||
raw_description, # Raw description (without affixes)
|
||||
"03.02.2025" # Creation date
|
||||
]),
|
||||
_extract_category_from_ad_page = AsyncMock(return_value = "160"),
|
||||
_extract_special_attributes_from_ad_page = AsyncMock(return_value = {}),
|
||||
_extract_pricing_info_from_ad_page = AsyncMock(return_value = (None, "NOT_APPLICABLE")),
|
||||
_extract_shipping_info_from_ad_page = AsyncMock(return_value = ("NOT_APPLICABLE", None, None)),
|
||||
_extract_sell_directly_from_ad_page = AsyncMock(return_value = False),
|
||||
_download_images_from_ad_page = AsyncMock(return_value = []),
|
||||
_extract_contact_from_ad_page = AsyncMock(return_value = {})
|
||||
):
|
||||
info = await test_extractor._extract_ad_page_info("/some/dir", 12345)
|
||||
assert info["description"] == raw_description
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_description_with_affixes_timeout(
|
||||
self,
|
||||
test_extractor:AdExtractor
|
||||
) -> None:
|
||||
"""Test handling of timeout when extracting description."""
|
||||
# Mock the page
|
||||
page_mock = MagicMock()
|
||||
page_mock.url = "https://www.kleinanzeigen.de/s-anzeige/test/12345"
|
||||
test_extractor.page = page_mock
|
||||
|
||||
with patch.multiple(test_extractor,
|
||||
web_text = AsyncMock(side_effect = [
|
||||
"Test Title", # Title succeeds
|
||||
TimeoutError("Timeout"), # Description times out
|
||||
"03.02.2025" # Date succeeds
|
||||
]),
|
||||
_extract_category_from_ad_page = AsyncMock(return_value = "160"),
|
||||
_extract_special_attributes_from_ad_page = AsyncMock(return_value = {}),
|
||||
_extract_pricing_info_from_ad_page = AsyncMock(return_value = (None, "NOT_APPLICABLE")),
|
||||
_extract_shipping_info_from_ad_page = AsyncMock(return_value = ("NOT_APPLICABLE", None, None)),
|
||||
_extract_sell_directly_from_ad_page = AsyncMock(return_value = False),
|
||||
_download_images_from_ad_page = AsyncMock(return_value = []),
|
||||
_extract_contact_from_ad_page = AsyncMock(return_value = {})
|
||||
):
|
||||
try:
|
||||
info = await test_extractor._extract_ad_page_info("/some/dir", 12345)
|
||||
assert not info["description"]
|
||||
except TimeoutError:
|
||||
# This is also acceptable - depends on how we want to handle timeouts
|
||||
pass
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_description_with_affixes_no_affixes(
|
||||
self,
|
||||
test_extractor:AdExtractor
|
||||
) -> None:
|
||||
"""Test extraction of description without any affixes in config."""
|
||||
# Mock the page
|
||||
page_mock = MagicMock()
|
||||
page_mock.url = "https://www.kleinanzeigen.de/s-anzeige/test/12345"
|
||||
test_extractor.page = page_mock
|
||||
test_extractor.config = {"ad_defaults": {}} # Empty config
|
||||
raw_description = "Original Description"
|
||||
|
||||
with patch.multiple(test_extractor,
|
||||
web_text = AsyncMock(side_effect = [
|
||||
"Test Title", # Title
|
||||
raw_description, # Description without affixes
|
||||
"03.02.2025" # Creation date
|
||||
]),
|
||||
_extract_category_from_ad_page = AsyncMock(return_value = "160"),
|
||||
_extract_special_attributes_from_ad_page = AsyncMock(return_value = {}),
|
||||
_extract_pricing_info_from_ad_page = AsyncMock(return_value = (None, "NOT_APPLICABLE")),
|
||||
_extract_shipping_info_from_ad_page = AsyncMock(return_value = ("NOT_APPLICABLE", None, None)),
|
||||
_extract_sell_directly_from_ad_page = AsyncMock(return_value = False),
|
||||
_download_images_from_ad_page = AsyncMock(return_value = []),
|
||||
_extract_contact_from_ad_page = AsyncMock(return_value = {})
|
||||
):
|
||||
info = await test_extractor._extract_ad_page_info("/some/dir", 12345)
|
||||
assert info["description"] == raw_description
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_sell_directly(self, test_extractor:AdExtractor) -> None:
|
||||
"""Test extraction of sell directly option."""
|
||||
test_cases = [
|
||||
("Direkt kaufen", True),
|
||||
("Other text", False),
|
||||
]
|
||||
|
||||
for text, expected in test_cases:
|
||||
with patch.object(test_extractor, "web_text", new_callable = AsyncMock, return_value = text):
|
||||
result = await test_extractor._extract_sell_directly_from_ad_page()
|
||||
assert result is expected
|
||||
|
||||
with patch.object(test_extractor, "web_text", new_callable = AsyncMock, side_effect = TimeoutError):
|
||||
result = await test_extractor._extract_sell_directly_from_ad_page()
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestAdExtractorCategory:
|
||||
"""Tests for category extraction functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def extractor(self) -> AdExtractor:
|
||||
browser_mock = MagicMock(spec = Browser)
|
||||
config_mock = {
|
||||
"ad_defaults": {
|
||||
"description": {
|
||||
"prefix": "Test Prefix",
|
||||
"suffix": "Test Suffix"
|
||||
}
|
||||
}
|
||||
}
|
||||
return AdExtractor(browser_mock, config_mock)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# pylint: disable=protected-access
|
||||
async def test_extract_category(self, extractor:AdExtractor) -> None:
|
||||
"""Test category extraction from breadcrumb."""
|
||||
category_line = MagicMock()
|
||||
first_part = MagicMock()
|
||||
first_part.attrs = {"href": "/s-familie-kind-baby/c17"}
|
||||
second_part = MagicMock()
|
||||
second_part.attrs = {"href": "/s-spielzeug/c23"}
|
||||
|
||||
with patch.object(extractor, "web_find", new_callable = AsyncMock) as mock_web_find:
|
||||
mock_web_find.side_effect = [
|
||||
category_line,
|
||||
first_part,
|
||||
second_part
|
||||
]
|
||||
|
||||
result = await extractor._extract_category_from_ad_page()
|
||||
assert result == "17/23"
|
||||
|
||||
mock_web_find.assert_any_call(By.ID, "vap-brdcrmb")
|
||||
mock_web_find.assert_any_call(By.CSS_SELECTOR, "a:nth-of-type(2)", parent = category_line)
|
||||
mock_web_find.assert_any_call(By.CSS_SELECTOR, "a:nth-of-type(3)", parent = category_line)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# pylint: disable=protected-access
|
||||
async def test_extract_special_attributes_empty(self, extractor:AdExtractor) -> None:
|
||||
"""Test extraction of special attributes when empty."""
|
||||
with patch.object(extractor, "web_execute", new_callable = AsyncMock) as mock_web_execute:
|
||||
mock_web_execute.return_value = {
|
||||
"universalAnalyticsOpts": {
|
||||
"dimensions": {
|
||||
"dimension108": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
result = await extractor._extract_special_attributes_from_ad_page()
|
||||
assert result == {}
|
||||
|
||||
|
||||
class TestAdExtractorContact:
|
||||
"""Tests for contact information extraction."""
|
||||
|
||||
@pytest.fixture
|
||||
def extractor(self) -> AdExtractor:
|
||||
browser_mock = MagicMock(spec = Browser)
|
||||
config_mock = {
|
||||
"ad_defaults": {
|
||||
"description": {
|
||||
"prefix": "Test Prefix",
|
||||
"suffix": "Test Suffix"
|
||||
}
|
||||
}
|
||||
}
|
||||
return AdExtractor(browser_mock, config_mock)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# pylint: disable=protected-access
|
||||
async def test_extract_contact_info(self, extractor:AdExtractor) -> None:
|
||||
"""Test extraction of contact information."""
|
||||
with patch.object(extractor, "page", MagicMock()), \
|
||||
patch.object(extractor, "web_text", new_callable = AsyncMock) as mock_web_text, \
|
||||
patch.object(extractor, "web_find", new_callable = AsyncMock) as mock_web_find:
|
||||
|
||||
mock_web_text.side_effect = [
|
||||
"12345 Berlin - Mitte",
|
||||
"Example Street 123,",
|
||||
"Test User",
|
||||
]
|
||||
|
||||
mock_web_find.side_effect = [
|
||||
MagicMock(), # contact person element
|
||||
MagicMock(), # name element
|
||||
TimeoutError(), # phone element (simulating no phone)
|
||||
]
|
||||
|
||||
contact_info = await extractor._extract_contact_from_ad_page()
|
||||
assert isinstance(contact_info, dict)
|
||||
assert contact_info["street"] == "Example Street 123"
|
||||
assert contact_info["zipcode"] == "12345"
|
||||
assert contact_info["location"] == "Berlin - Mitte"
|
||||
assert contact_info["name"] == "Test User"
|
||||
assert contact_info["phone"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# pylint: disable=protected-access
|
||||
async def test_extract_contact_info_timeout(self, extractor:AdExtractor) -> None:
|
||||
"""Test contact info extraction when elements are not found."""
|
||||
with patch.object(extractor, "page", MagicMock()), \
|
||||
patch.object(extractor, "web_text", new_callable = AsyncMock, side_effect = TimeoutError()), \
|
||||
patch.object(extractor, "web_find", new_callable = AsyncMock, side_effect = TimeoutError()), \
|
||||
pytest.raises(TimeoutError):
|
||||
|
||||
await extractor._extract_contact_from_ad_page()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# pylint: disable=protected-access
|
||||
async def test_extract_contact_info_with_phone(self, extractor:AdExtractor) -> None:
|
||||
"""Test extraction of contact information including phone number."""
|
||||
with patch.object(extractor, "page", MagicMock()), \
|
||||
patch.object(extractor, "web_text", new_callable = AsyncMock) as mock_web_text, \
|
||||
patch.object(extractor, "web_find", new_callable = AsyncMock) as mock_web_find:
|
||||
|
||||
mock_web_text.side_effect = [
|
||||
"12345 Berlin - Mitte",
|
||||
"Example Street 123,",
|
||||
"Test User",
|
||||
"+49(0)1234 567890"
|
||||
]
|
||||
|
||||
phone_element = MagicMock()
|
||||
mock_web_find.side_effect = [
|
||||
MagicMock(), # contact person element
|
||||
MagicMock(), # name element
|
||||
phone_element, # phone element
|
||||
]
|
||||
|
||||
contact_info = await extractor._extract_contact_from_ad_page()
|
||||
assert isinstance(contact_info, dict)
|
||||
assert contact_info["phone"] == "01234567890" # Normalized phone number
|
||||
|
||||
|
||||
class TestAdExtractorDownload:
|
||||
"""Tests for download functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def extractor(self) -> AdExtractor:
|
||||
browser_mock = MagicMock(spec = Browser)
|
||||
config_mock = {
|
||||
"ad_defaults": {
|
||||
"description": {
|
||||
"prefix": "Test Prefix",
|
||||
"suffix": "Test Suffix"
|
||||
}
|
||||
}
|
||||
}
|
||||
return AdExtractor(browser_mock, config_mock)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_ad_existing_directory(self, extractor:AdExtractor) -> None:
|
||||
"""Test downloading an ad when the directory already exists."""
|
||||
with patch("os.path.exists") as mock_exists, \
|
||||
patch("os.path.isdir") as mock_isdir, \
|
||||
patch("os.makedirs") as mock_makedirs, \
|
||||
patch("os.mkdir") as mock_mkdir, \
|
||||
patch("shutil.rmtree") as mock_rmtree, \
|
||||
patch("kleinanzeigen_bot.extract.dicts.save_dict", autospec = True) as mock_save_dict, \
|
||||
patch.object(extractor, "_extract_ad_page_info", new_callable = AsyncMock) as mock_extract:
|
||||
|
||||
base_dir = "downloaded-ads"
|
||||
ad_dir = os.path.join(base_dir, "ad_12345")
|
||||
yaml_path = os.path.join(ad_dir, "ad_12345.yaml")
|
||||
|
||||
# Configure mocks for directory checks
|
||||
existing_paths = {base_dir, ad_dir}
|
||||
mock_exists.side_effect = lambda path: path in existing_paths
|
||||
mock_isdir.side_effect = lambda path: path == base_dir
|
||||
|
||||
mock_extract.return_value = {
|
||||
"title": "Test Advertisement Title",
|
||||
"description": "Test Description",
|
||||
"price": 100,
|
||||
"images": [],
|
||||
"contact": {
|
||||
"name": "Test User",
|
||||
"street": "Test Street 123",
|
||||
"zipcode": "12345",
|
||||
"location": "Test City"
|
||||
}
|
||||
}
|
||||
|
||||
await extractor.download_ad(12345)
|
||||
|
||||
# Verify the correct functions were called
|
||||
mock_extract.assert_called_once()
|
||||
mock_rmtree.assert_called_once_with(ad_dir)
|
||||
mock_mkdir.assert_called_once_with(ad_dir)
|
||||
mock_makedirs.assert_not_called() # Directory already exists
|
||||
|
||||
# Get the actual call arguments
|
||||
# Workaround for hard-coded path in download_ad
|
||||
actual_call = mock_save_dict.call_args
|
||||
assert actual_call is not None
|
||||
actual_path = actual_call[0][0].replace("/", os.path.sep)
|
||||
assert actual_path == yaml_path
|
||||
assert actual_call[0][1] == mock_extract.return_value
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# pylint: disable=protected-access
|
||||
async def test_download_images_no_images(self, extractor:AdExtractor) -> None:
|
||||
"""Test image download when no images are found."""
|
||||
with patch.object(extractor, "web_find", new_callable = AsyncMock, side_effect = TimeoutError):
|
||||
image_paths = await extractor._download_images_from_ad_page("/some/dir", 12345)
|
||||
assert len(image_paths) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_ad(self, extractor:AdExtractor) -> None:
|
||||
"""Test downloading an entire ad."""
|
||||
with patch("os.path.exists") as mock_exists, \
|
||||
patch("os.path.isdir") as mock_isdir, \
|
||||
patch("os.makedirs") as mock_makedirs, \
|
||||
patch("os.mkdir") as mock_mkdir, \
|
||||
patch("shutil.rmtree") as mock_rmtree, \
|
||||
patch("kleinanzeigen_bot.extract.dicts.save_dict", autospec = True) as mock_save_dict, \
|
||||
patch.object(extractor, "_extract_ad_page_info", new_callable = AsyncMock) as mock_extract:
|
||||
|
||||
base_dir = "downloaded-ads"
|
||||
ad_dir = os.path.join(base_dir, "ad_12345")
|
||||
yaml_path = os.path.join(ad_dir, "ad_12345.yaml")
|
||||
|
||||
# Configure mocks for directory checks
|
||||
mock_exists.return_value = False
|
||||
mock_isdir.return_value = False
|
||||
|
||||
mock_extract.return_value = {
|
||||
"title": "Test Advertisement Title",
|
||||
"description": "Test Description",
|
||||
"price": 100,
|
||||
"images": [],
|
||||
"contact": {
|
||||
"name": "Test User",
|
||||
"street": "Test Street 123",
|
||||
"zipcode": "12345",
|
||||
"location": "Test City"
|
||||
}
|
||||
}
|
||||
|
||||
await extractor.download_ad(12345)
|
||||
|
||||
# Verify the correct functions were called
|
||||
mock_extract.assert_called_once()
|
||||
mock_rmtree.assert_not_called() # No directory to remove
|
||||
mock_mkdir.assert_has_calls([
|
||||
call(base_dir),
|
||||
call(ad_dir)
|
||||
])
|
||||
mock_makedirs.assert_not_called() # Using mkdir instead
|
||||
|
||||
# Get the actual call arguments
|
||||
actual_call = mock_save_dict.call_args
|
||||
assert actual_call is not None
|
||||
actual_path = actual_call[0][0].replace("/", os.path.sep)
|
||||
assert actual_path == yaml_path
|
||||
assert actual_call[0][1] == mock_extract.return_value
|
||||
57
tests/unit/test_i18n.py
Normal file
57
tests/unit/test_i18n.py
Normal file
@@ -0,0 +1,57 @@
|
||||
# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
import pytest
|
||||
from _pytest.monkeypatch import MonkeyPatch # pylint: disable=import-private-name
|
||||
|
||||
from kleinanzeigen_bot.utils import i18n
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("lang", "expected"), [
|
||||
(None, ("en", "US", "UTF-8")), # Test with no LANG variable (should default to ("en", "US", "UTF-8"))
|
||||
("fr", ("fr", None, "UTF-8")), # Test with just a language code
|
||||
("fr_CA", ("fr", "CA", "UTF-8")), # Test with language + region, no encoding
|
||||
("pt_BR.iso8859-1", ("pt", "BR", "ISO8859-1")), # Test with language + region + encoding
|
||||
])
|
||||
def test_detect_locale(monkeypatch:MonkeyPatch, lang:str | None, expected:i18n.Locale) -> None:
|
||||
"""
|
||||
Pytest test case to verify detect_system_language() behavior under various LANG values.
|
||||
"""
|
||||
# Clear or set the LANG environment variable as needed.
|
||||
if lang is None:
|
||||
monkeypatch.delenv("LANG", raising = False)
|
||||
else:
|
||||
monkeypatch.setenv("LANG", lang)
|
||||
|
||||
# Call the function and compare the result to the expected output.
|
||||
result = i18n._detect_locale() # pylint: disable=protected-access
|
||||
assert result == expected, f"For LANG={lang}, expected {expected} but got {result}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("lang", "noun", "count", "prefix_with_count", "expected"), [
|
||||
("en", "field", 1, True, "1 field"),
|
||||
("en", "field", 2, True, "2 fields"),
|
||||
("en", "field", 2, False, "fields"),
|
||||
("en", "attribute", 2, False, "attributes"),
|
||||
("en", "bus", 2, False, "buses"),
|
||||
("en", "city", 2, False, "cities"),
|
||||
("de", "Feld", 1, True, "1 Feld"),
|
||||
("de", "Feld", 2, True, "2 Felder"),
|
||||
("de", "Feld", 2, False, "Felder"),
|
||||
("de", "Anzeige", 2, False, "Anzeigen"),
|
||||
("de", "Attribute", 2, False, "Attribute"),
|
||||
("de", "Bild", 2, False, "Bilder"),
|
||||
("de", "Datei", 2, False, "Dateien"),
|
||||
("de", "Kategorie", 2, False, "Kategorien")
|
||||
])
|
||||
def test_pluralize(
|
||||
lang:str,
|
||||
noun:str,
|
||||
count:int,
|
||||
prefix_with_count:bool,
|
||||
expected:str
|
||||
) -> None:
|
||||
i18n.set_current_locale(i18n.Locale(lang, "US", "UTF_8"))
|
||||
|
||||
result = i18n.pluralize(noun, count, prefix_with_count = prefix_with_count)
|
||||
assert result == expected, f"For LANG={lang}, expected {expected} but got {result}"
|
||||
1302
tests/unit/test_init.py
Normal file
1302
tests/unit/test_init.py
Normal file
File diff suppressed because it is too large
Load Diff
436
tests/unit/test_translations.py
Normal file
436
tests/unit/test_translations.py
Normal file
@@ -0,0 +1,436 @@
|
||||
# SPDX-FileCopyrightText: © Jens Bergmann and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
"""
|
||||
This module contains tests for verifying the completeness and correctness of translations in the project.
|
||||
|
||||
It ensures that:
|
||||
1. All log messages in the code have corresponding translations
|
||||
2. All translations in the YAML files are actually used in the code
|
||||
3. No obsolete translations exist in the YAML files
|
||||
|
||||
The tests work by:
|
||||
1. Extracting all translatable messages from Python source files
|
||||
2. Loading translations from YAML files
|
||||
3. Comparing the extracted messages with translations
|
||||
4. Verifying no unused translations exist
|
||||
"""
|
||||
import ast, os # isort: skip
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from importlib.resources import files
|
||||
|
||||
import pytest
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from kleinanzeigen_bot import resources
|
||||
|
||||
# Messages that are intentionally not translated (internal/debug messages)
|
||||
EXCLUDED_MESSAGES:dict[str, set[str]] = {
|
||||
"kleinanzeigen_bot/__init__.py": {"############################################"}
|
||||
}
|
||||
|
||||
# Special modules that are known to be needed even if not in messages_by_file
|
||||
KNOWN_NEEDED_MODULES = {"getopt.py"}
|
||||
|
||||
# Type aliases for better readability
|
||||
ModulePath = str
|
||||
FunctionName = str
|
||||
Message = str
|
||||
TranslationDict = dict[ModulePath, dict[FunctionName, dict[Message, str]]]
|
||||
MessageDict = dict[FunctionName, dict[Message, set[Message]]]
|
||||
MissingDict = dict[FunctionName, dict[Message, set[Message]]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class MessageLocation:
|
||||
"""Represents the location of a message in the codebase."""
|
||||
module:str
|
||||
function:str
|
||||
message:str
|
||||
|
||||
|
||||
def _get_function_name(node:ast.AST) -> str:
|
||||
"""
|
||||
Get the name of the function containing this AST node.
|
||||
This matches i18n.py's behavior which only uses the function name for translation lookups.
|
||||
For module-level code, returns "module" to match i18n.py's convention.
|
||||
|
||||
Args:
|
||||
node: The AST node to analyze
|
||||
|
||||
Returns:
|
||||
The function name or "module" for module-level code
|
||||
"""
|
||||
|
||||
def find_parent_context(n:ast.AST) -> tuple[str | None, str | None]:
|
||||
"""Find the containing class and function names."""
|
||||
class_name = None
|
||||
function_name = None
|
||||
current = n
|
||||
|
||||
while hasattr(current, "_parent"):
|
||||
current = getattr(current, "_parent")
|
||||
if isinstance(current, ast.ClassDef) and not class_name:
|
||||
class_name = current.name
|
||||
elif isinstance(current, ast.FunctionDef) or isinstance(current, ast.AsyncFunctionDef) and not function_name:
|
||||
function_name = current.name
|
||||
break # We only need the immediate function name
|
||||
return class_name, function_name
|
||||
|
||||
_, function_name = find_parent_context(node)
|
||||
if function_name:
|
||||
return function_name
|
||||
return "module" # For module-level code
|
||||
|
||||
|
||||
def _extract_log_messages(file_path:str, exclude_debug:bool = False) -> MessageDict:
|
||||
"""
|
||||
Extract all translatable messages from a Python file with their function context.
|
||||
|
||||
Args:
|
||||
file_path: Path to the Python file to analyze
|
||||
|
||||
Returns:
|
||||
Dictionary mapping function names to their messages
|
||||
"""
|
||||
with open(file_path, "r", encoding = "utf-8") as file:
|
||||
tree = ast.parse(file.read(), filename = file_path)
|
||||
|
||||
# Add parent references for context tracking
|
||||
for parent in ast.walk(tree):
|
||||
for child in ast.iter_child_nodes(parent):
|
||||
setattr(child, "_parent", parent)
|
||||
|
||||
messages:MessageDict = defaultdict(lambda: defaultdict(set))
|
||||
|
||||
def add_message(function:str, msg:str) -> None:
|
||||
"""Add a message to the messages dictionary."""
|
||||
if function not in messages:
|
||||
messages[function] = defaultdict(set)
|
||||
if msg not in messages[function]:
|
||||
messages[function][msg] = {msg}
|
||||
|
||||
def extract_string_constant(node:ast.AST) -> str | None:
|
||||
"""Safely extract string value from an AST node."""
|
||||
if isinstance(node, ast.Constant):
|
||||
value = getattr(node, "value", None)
|
||||
return value if isinstance(value, str) else None
|
||||
return None
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
|
||||
function_name = _get_function_name(node)
|
||||
|
||||
# Extract messages from various call types
|
||||
|
||||
# 1) Logging calls: LOG.info(…), logger.warning(…), etc.
|
||||
if (
|
||||
isinstance(node.func, ast.Attribute) and
|
||||
isinstance(node.func.value, ast.Name) and
|
||||
node.func.value.id in {"LOG", "logger", "logging"} and
|
||||
node.func.attr in {None if exclude_debug else "debug", "info", "warning", "error", "exception", "critical"}
|
||||
):
|
||||
if node.args:
|
||||
msg = extract_string_constant(node.args[0])
|
||||
if msg:
|
||||
add_message(function_name, msg)
|
||||
|
||||
# 2) gettext: _("…") or obj.gettext("…")
|
||||
elif (
|
||||
(isinstance(node.func, ast.Name) and node.func.id == "_") or
|
||||
(isinstance(node.func, ast.Attribute) and node.func.attr == "gettext")
|
||||
):
|
||||
if node.args:
|
||||
msg = extract_string_constant(node.args[0])
|
||||
if msg:
|
||||
add_message(function_name, msg)
|
||||
|
||||
# Handle other translatable function calls
|
||||
elif isinstance(node.func, ast.Name) and node.func.id in {"ainput", "pluralize", "ensure"}:
|
||||
arg_index = 1 if node.func.id == "ensure" else 0
|
||||
if len(node.args) > arg_index:
|
||||
msg = extract_string_constant(node.args[arg_index])
|
||||
if msg:
|
||||
add_message(function_name, msg)
|
||||
|
||||
print(f"Messages: {len(messages)} in {file_path}")
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
def _get_all_log_messages(exclude_debug:bool = False) -> dict[str, MessageDict]:
|
||||
"""
|
||||
Get all translatable messages from all Python files in the project.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping module paths to their function messages
|
||||
"""
|
||||
src_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "src", "kleinanzeigen_bot")
|
||||
print(f"\nScanning for messages in directory: {src_dir}")
|
||||
|
||||
messages_by_file:dict[str, MessageDict] = {
|
||||
# Special case for getopt.py which is imported
|
||||
"getopt.py": {
|
||||
"do_longs": {
|
||||
"option --%s requires argument": {"option --%s requires argument"},
|
||||
"option --%s must not have an argument": {"option --%s must not have an argument"}
|
||||
},
|
||||
"long_has_args": {
|
||||
"option --%s not recognized": {"option --%s not recognized"},
|
||||
"option --%s not a unique prefix": {"option --%s not a unique prefix"}
|
||||
},
|
||||
"do_shorts": {
|
||||
"option -%s requires argument": {"option -%s requires argument"}
|
||||
},
|
||||
"short_has_arg": {
|
||||
"option -%s not recognized": {"option -%s not recognized"}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for root, _, filenames in os.walk(src_dir):
|
||||
for filename in filenames:
|
||||
if filename.endswith(".py"):
|
||||
file_path = os.path.join(root, filename)
|
||||
relative_path = os.path.relpath(file_path, src_dir)
|
||||
if relative_path.startswith("resources/"):
|
||||
continue
|
||||
messages = _extract_log_messages(file_path, exclude_debug)
|
||||
if messages:
|
||||
module_path = os.path.join("kleinanzeigen_bot", relative_path)
|
||||
module_path = module_path.replace(os.sep, "/")
|
||||
messages_by_file[module_path] = messages
|
||||
|
||||
return messages_by_file
|
||||
|
||||
|
||||
def _get_available_languages() -> list[str]:
|
||||
"""
|
||||
Get list of available translation languages from translation files.
|
||||
|
||||
Returns:
|
||||
List of language codes (e.g. ['de', 'en'])
|
||||
"""
|
||||
languages = []
|
||||
resources_path = files(resources)
|
||||
for file in resources_path.iterdir():
|
||||
if file.name.startswith("translations.") and file.name.endswith(".yaml"):
|
||||
lang = file.name[13:-5] # Remove "translations." and ".yaml"
|
||||
languages.append(lang)
|
||||
return sorted(languages)
|
||||
|
||||
|
||||
def _get_translations_for_language(lang:str) -> TranslationDict:
|
||||
"""
|
||||
Get translations for a specific language from its YAML file.
|
||||
|
||||
Args:
|
||||
lang: Language code (e.g. 'de')
|
||||
|
||||
Returns:
|
||||
Dictionary containing all translations for the language
|
||||
"""
|
||||
yaml = YAML(typ = "safe")
|
||||
translation_file = f"translations.{lang}.yaml"
|
||||
print(f"Loading translations from {translation_file}")
|
||||
content = files(resources).joinpath(translation_file).read_text()
|
||||
translations = yaml.load(content) or {}
|
||||
return translations
|
||||
|
||||
|
||||
def _find_translation(translations:TranslationDict,
|
||||
module:str,
|
||||
function:str,
|
||||
message:str) -> bool:
|
||||
"""
|
||||
Check if a translation exists for a given message in the exact location where i18n.py will look.
|
||||
This matches the lookup logic in i18n.py which uses dicts.safe_get().
|
||||
|
||||
Args:
|
||||
translations: Dictionary of all translations
|
||||
module: Module path
|
||||
function: Function name
|
||||
message: Message to find translation for
|
||||
|
||||
Returns:
|
||||
True if translation exists in the correct location, False otherwise
|
||||
"""
|
||||
# Special case for getopt.py
|
||||
if module == "getopt.py":
|
||||
return bool(translations.get(module, {}).get(function, {}).get(message))
|
||||
|
||||
# Add kleinanzeigen_bot/ prefix if not present
|
||||
module_path = f"kleinanzeigen_bot/{module}" if not module.startswith("kleinanzeigen_bot/") else module
|
||||
|
||||
# Check if module exists in translations
|
||||
module_trans = translations.get(module_path, {})
|
||||
if not isinstance(module_trans, dict):
|
||||
print(f"Module {module_path} translations is not a dictionary")
|
||||
return False
|
||||
|
||||
# Check if function exists in module translations
|
||||
function_trans = module_trans.get(function, {})
|
||||
if not isinstance(function_trans, dict):
|
||||
print(f"Function {function} translations in module {module_path} is not a dictionary")
|
||||
return False
|
||||
|
||||
# Check if message exists in function translations
|
||||
has_translation = message in function_trans
|
||||
|
||||
return has_translation
|
||||
|
||||
|
||||
def _message_exists_in_code(code_messages:dict[str, MessageDict],
|
||||
module:str,
|
||||
function:str,
|
||||
message:str) -> bool:
|
||||
"""
|
||||
Check if a message exists in the code at the given location.
|
||||
This is the reverse of _find_translation - it checks if a translation's message
|
||||
exists in the code messages.
|
||||
|
||||
Args:
|
||||
code_messages: Dictionary of all code messages
|
||||
module: Module path
|
||||
function: Function name
|
||||
message: Message to find in code
|
||||
|
||||
Returns:
|
||||
True if message exists in the code, False otherwise
|
||||
"""
|
||||
# Special case for getopt.py
|
||||
if module == "getopt.py":
|
||||
return bool(code_messages.get(module, {}).get(function, {}).get(message))
|
||||
|
||||
# Remove kleinanzeigen_bot/ prefix if present for code message lookup
|
||||
module_path = module[len("kleinanzeigen_bot/"):] if module.startswith("kleinanzeigen_bot/") else module
|
||||
module_path = f"kleinanzeigen_bot/{module_path}"
|
||||
|
||||
# Check if module exists in code messages
|
||||
module_msgs = code_messages.get(module_path)
|
||||
if not module_msgs:
|
||||
return False
|
||||
|
||||
# Check if function exists in module messages
|
||||
function_msgs = module_msgs.get(function)
|
||||
if not function_msgs:
|
||||
return False
|
||||
|
||||
# Check if message exists in any of the function's message sets
|
||||
return any(message in msg_dict for msg_dict in function_msgs.values())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lang", _get_available_languages())
|
||||
def test_all_log_messages_have_translations(lang:str) -> None:
|
||||
"""
|
||||
Test that all translatable messages in the code have translations for each language.
|
||||
|
||||
This test ensures that no untranslated messages exist in the codebase.
|
||||
"""
|
||||
messages_by_file = _get_all_log_messages(exclude_debug = True)
|
||||
translations = _get_translations_for_language(lang)
|
||||
|
||||
missing_translations = []
|
||||
|
||||
for module, functions in messages_by_file.items():
|
||||
excluded = EXCLUDED_MESSAGES.get(module, set())
|
||||
for function, messages in functions.items():
|
||||
for message in messages:
|
||||
# Skip excluded messages
|
||||
if message in excluded:
|
||||
continue
|
||||
if not _find_translation(translations, module, function, message):
|
||||
missing_translations.append(MessageLocation(module, function, message))
|
||||
|
||||
if missing_translations:
|
||||
missing_str = f"\nPlease add the following missing translations for language [{lang}]:\n"
|
||||
|
||||
def make_inner_dict() -> defaultdict[str, set[str]]:
|
||||
return defaultdict(set)
|
||||
|
||||
by_module:defaultdict[str, defaultdict[str, set[str]]] = defaultdict(make_inner_dict)
|
||||
|
||||
for loc in missing_translations:
|
||||
assert isinstance(loc.module, str), "Module must be a string"
|
||||
assert isinstance(loc.function, str), "Function must be a string"
|
||||
assert isinstance(loc.message, str), "Message must be a string"
|
||||
by_module[loc.module][loc.function].add(loc.message)
|
||||
|
||||
# There is a type error here, but it's not a problem
|
||||
for module, functions in sorted(by_module.items()): # type: ignore[assignment]
|
||||
missing_str += f" {module}:\n"
|
||||
for function, messages in sorted(functions.items()):
|
||||
missing_str += f" {function}:\n"
|
||||
for message in sorted(messages):
|
||||
missing_str += f' "{message}"\n'
|
||||
raise AssertionError(missing_str)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lang", _get_available_languages())
|
||||
def test_no_obsolete_translations(lang:str) -> None:
|
||||
"""
|
||||
Test that all translations in each language YAML file are actually used in the code.
|
||||
|
||||
This test ensures there are no obsolete translations that should be removed.
|
||||
The translations file has the structure:
|
||||
module:
|
||||
function:
|
||||
"original message": "translated message"
|
||||
"""
|
||||
messages_by_file = _get_all_log_messages(exclude_debug = False)
|
||||
translations = _get_translations_for_language(lang)
|
||||
|
||||
# ignore values that are not in code
|
||||
del translations["kleinanzeigen_bot/utils/loggers.py"]["format"]["CRITICAL"]
|
||||
del translations["kleinanzeigen_bot/utils/loggers.py"]["format"]["ERROR"]
|
||||
del translations["kleinanzeigen_bot/utils/loggers.py"]["format"]["WARNING"]
|
||||
|
||||
obsolete_items:list[tuple[str, str, str]] = []
|
||||
|
||||
for module, module_trans in translations.items():
|
||||
if not isinstance(module_trans, dict):
|
||||
continue
|
||||
|
||||
# Skip known needed modules
|
||||
if module in KNOWN_NEEDED_MODULES:
|
||||
continue
|
||||
|
||||
for function, function_trans in module_trans.items():
|
||||
if not isinstance(function_trans, dict):
|
||||
continue
|
||||
|
||||
for original_message in function_trans:
|
||||
# Check if this message exists in the code
|
||||
message_exists = _message_exists_in_code(messages_by_file, module, function, original_message)
|
||||
|
||||
if not message_exists:
|
||||
obsolete_items.append((module, function, original_message))
|
||||
|
||||
# Fail the test if obsolete translations are found
|
||||
if obsolete_items:
|
||||
obsolete_str = f"\nObsolete translations found for language [{lang}]:\n"
|
||||
|
||||
# Group by module and function for better readability
|
||||
by_module:defaultdict[str, defaultdict[str, list[str]]] = defaultdict(lambda: defaultdict(list))
|
||||
|
||||
for module, function, message in obsolete_items:
|
||||
by_module[module][function].append(message)
|
||||
|
||||
for module, functions in sorted(by_module.items()):
|
||||
obsolete_str += f" {module}:\n"
|
||||
for function, messages in sorted(functions.items()):
|
||||
obsolete_str += f" {function}:\n"
|
||||
for message in sorted(messages):
|
||||
obsolete_str += f' "{message}": "{translations[module][function][message]}"\n'
|
||||
|
||||
raise AssertionError(obsolete_str)
|
||||
|
||||
|
||||
def test_translation_files_exist() -> None:
|
||||
"""Test that at least one translation file exists."""
|
||||
languages = _get_available_languages()
|
||||
if not languages:
|
||||
raise AssertionError("No translation files found! Expected at least one translations.*.yaml file.")
|
||||
29
tests/unit/test_utils_misc.py
Normal file
29
tests/unit/test_utils_misc.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
import pytest
|
||||
|
||||
from kleinanzeigen_bot.utils import misc
|
||||
|
||||
|
||||
def test_ensure() -> None:
|
||||
misc.ensure(True, "TRUE")
|
||||
misc.ensure("Some Value", "TRUE")
|
||||
misc.ensure(123, "TRUE")
|
||||
misc.ensure(-123, "TRUE")
|
||||
misc.ensure(lambda: True, "TRUE")
|
||||
|
||||
with pytest.raises(AssertionError):
|
||||
misc.ensure(False, "FALSE")
|
||||
|
||||
with pytest.raises(AssertionError):
|
||||
misc.ensure(0, "FALSE")
|
||||
|
||||
with pytest.raises(AssertionError):
|
||||
misc.ensure("", "FALSE")
|
||||
|
||||
with pytest.raises(AssertionError):
|
||||
misc.ensure(None, "FALSE")
|
||||
|
||||
with pytest.raises(AssertionError):
|
||||
misc.ensure(lambda: False, "FALSE", timeout = 2)
|
||||
Reference in New Issue
Block a user