220 Commits

Author SHA1 Message Date
sebthom
e7a3d46d25 fix: display file paths under current working dir as relative in logs 2025-05-15 00:27:10 +02:00
sebthom
e811cd339b ci: improve issue template 2025-05-15 00:27:10 +02:00
sebthom
a863f3c63a ci: improve issue template 2025-05-14 12:35:35 +02:00
Heavenfighter
0faa022e4d fix: Unable to download single ad (#509) 2025-05-14 11:24:16 +02:00
sebthom
8e2385c078 fix: TimeoutError: Unable to close shipping dialog! #505 2025-05-13 21:06:42 +02:00
sebthom
a03b368ccd fix: active: false in ad config is ignored #502 2025-05-13 20:59:15 +02:00
sebthom
9a3c0190ba chore: improve dicts module 2025-05-13 20:42:42 +02:00
sebthom
1f9895850f fix: add missing translations and fix translation loading/testing 2025-05-13 19:27:52 +02:00
sebthom
21d7cc557d feat: extend utils.save_dict 2025-05-13 13:45:58 +02:00
sebthom
58f6ae960f refact: simplify XPATH expressions 2025-05-12 18:28:28 +02:00
sebthom
50c0323921 fix: random RuntimeError: dictionary changed size during iteration 2025-05-12 17:50:08 +02:00
sebthom
ee4146f57c fix: auto-restart when captcha was encountered 2025-05-12 17:20:51 +02:00
airwave1981
65738926ae fix: TypeError in CustomFormatter.format 2025-05-12 17:11:47 +02:00
sebthom
f2e6f0b20b chore: update pyproject.toml 2025-05-12 14:08:50 +02:00
DreckSoft
ed83052fa4 fix: Unable to close shipping dialog (#501)
Co-authored-by: Sebastian Thomschke <sebthom@users.noreply.github.com>
2025-05-11 20:29:10 +02:00
sebthom
314285583e ci: add pip-audit check 2025-05-11 20:14:38 +02:00
kleinanzeigen-bot-tu[bot]
aa00d734ea chore: Update Python dependencies (#500)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-11 20:00:22 +02:00
kleinanzeigen-bot-tu[bot]
8584311305 chore: ✔ Update pyinstaller-hooks-contrib 2025.3 -> 2025.4 (#499)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-04 12:37:03 +02:00
kleinanzeigen-bot-tu[bot]
03dd3ebb10 chore: ✔ Update setuptools 80.1.0 -> 80.3.0 (#498)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-03 12:17:29 +02:00
kleinanzeigen-bot-tu[bot]
138d365713 chore: ✔ Update ruff 0.11.7 -> 0.11.8 (#497)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-02 12:22:00 +02:00
kleinanzeigen-bot-tu[bot]
6c2c6a0064 chore: ✔ Update setuptools 80.0.1 -> 80.1.0 (#496)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-02 12:07:42 +02:00
Benedikt
8b2d61b1d4 fix: improve login detection with fallback element (#493)
- Add fallback check for user-email element when mr-medium is not found
- Improve login detection reliability
- Add test case for alternative login element
2025-04-30 17:50:58 +02:00
kleinanzeigen-bot-tu[bot]
7852985de9 chore: Update Python dependencies (#492)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-30 17:50:42 +02:00
Benedikt
9bcc669c48 feat: add support for multiple matching shipping options (#483) 2025-04-29 21:02:09 +02:00
sebthom
3e8072973a build: use yamlfix for yaml formatting 2025-04-28 13:17:23 +02:00
sebthom
bda0acf943 refact: enable ruff preview rules 2025-04-28 13:17:23 +02:00
sebthom
f98251ade3 fix: improve ad description length validation 2025-04-28 13:17:23 +02:00
sebthom
ef923a8337 refact: apply consistent formatting 2025-04-28 12:55:28 +02:00
sebthom
fe33a0e461 refact: replace pyright with basedpyright 2025-04-28 12:52:18 +02:00
sebthom
376ec76226 refact: use ruff instead of autopep8,bandit,pylint for linting 2025-04-28 12:51:51 +02:00
sebthom
f0b84ab335 build: simplify pytest config 2025-04-28 12:43:53 +02:00
sebthom
634cc3d9ee build: upgrade to Python 3.13.3 2025-04-28 12:43:42 +02:00
sebthom
52e1682dba fix: avoid "[PYI-28040:ERROR]" log message when run via pyinstaller 2025-04-27 14:34:56 +02:00
sebthom
7b0774874e fix: harden extract_ad_id_from_ad_url 2025-04-27 14:23:56 +02:00
DreckSoft
23929a62cc fix: logon detection and duplicate suffix in ad description (#488)
Co-authored-by: Sebastian Thomschke <sebthom@users.noreply.github.com>
2025-04-27 14:21:40 +02:00
github-actions[bot]
3909218531 chore: ✔ Update certifi 2025.1.31 -> 2025.4.26 2025-04-26 14:41:39 +02:00
Airwave1981
d87ae6e740 feat: allow auto-restart on captcha (#481)
Co-authored-by: sebthom <sebthom@users.noreply.github.com>
2025-04-26 14:40:47 +02:00
sebthom
4891c142a9 feat: add misc.format_timedelta/parse_duration 2025-04-25 21:06:25 +02:00
github-actions[bot]
e417750548 chore: Update Python dependencies 2025-04-25 21:01:11 +02:00
marvinkcode
79af6ba861 fix: Correct pagination selectors and logic for issue #477 (#479) 2025-04-21 20:26:02 +02:00
Heavenfighter
c144801d2e fixes #474
Now using ID to identify checkbox for custom shipping
2025-04-21 20:24:23 +02:00
github-actions[bot]
a03328e308 chore: Update Python dependencies 2025-04-18 13:44:48 +02:00
Heavenfighter
20f3f87864 fixes #475 CSS selector 'button' not found
Element button was changed to em.
2025-04-18 13:44:00 +02:00
sebthom
27c7bb56ca fix: downgrading nodriver to 0.39 to address failing browser launch #470 2025-04-07 22:40:41 +02:00
sebthom
79701e2833 feat: debug log web_execute 2025-04-07 22:40:41 +02:00
sebthom
21835d9d86 test: don't require translations for debug messages 2025-04-07 22:40:41 +02:00
sebthom
aeaf77e5d4 refact: use named parameters 2025-04-07 21:57:51 +02:00
github-actions[bot]
b66c9d37bf chore: Update Python dependencies 2025-04-07 20:43:19 +02:00
github-actions[bot]
b07633e661 chore: Update Python dependencies 2025-03-26 11:31:13 +01:00
github-actions[bot]
fd58f3fa45 chore: Update Python dependencies 2025-03-20 11:20:16 +01:00
github-actions[bot]
13965b8607 chore: ✔ Update setuptools 76.0.0 -> 76.1.0 2025-03-18 12:08:55 +01:00
github-actions[bot]
4a9c2ff5a8 chore: ✔ Update coverage 7.6.12 -> 7.7.0 2025-03-17 11:48:21 +01:00
Heavenfighter
33f58811cd Fixes setting shipping costs to zero.
Empty shipping costs lead to
default shipping.
2025-03-16 21:28:44 +01:00
Heavenfighter
57c89a6f64 Adding condition "Defekt" (#461) 2025-03-15 18:25:26 +01:00
Heavenfighter
9183909188 fix: setting shipping options properly (#457) 2025-03-14 12:34:39 +01:00
Heavenfighter
7742196043 fix: set custom shipping due css update #448 (#450) 2025-03-13 12:13:23 +01:00
Jens Bergmann
6bd5ba98d2 fix: Clean up obsolete translations in German language file
- Remove unused translation entries from translations.de.yaml
- Improve translation test to better detect obsolete entries
- Add KNOWN_NEEDED_MODULES for special cases
- Add helper function _message_exists_in_code for better translation verification
- Improve error messages to show both original and translated text
- Fix import sorting in test file

This commit improves the maintainability of the translation system by
removing unused entries and enhancing the verification process.
2025-03-13 12:05:46 +01:00
github-actions[bot]
a6d2d2dc5a chore: ✔ Update nodriver 0.40 -> 0.41 2025-03-13 11:58:51 +01:00
Jens Bergmann
1b004a2a3e Revert "feat: Introduce isort and Python-based code quality tools (#446)"
This reverts commit cfe2b900c7.

The custom scripts introduced to auto-format imports (to enforce project guidelines) caused issues. Specifically, isort’s hardcoded behavior for expanded standard library imports with “as” imports led to unintended formatting. This commit reverts those changes and removes the custom scripts, restoring the project to its previous, stable state.
2025-03-13 11:55:31 +01:00
github-actions[bot]
21f118ba8e chore: Update Python dependencies 2025-03-09 23:14:52 -06:00
Jens Bergmann
cfe2b900c7 feat: Introduce isort and Python-based code quality tools (#446) 2025-03-10 06:09:49 +01:00
kleinanzeigen-bot-tu[bot]
4243ba698a chore: ✔ Update nodriver 0.39 -> 0.40 (#443)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-03-01 12:02:17 -05:00
Jens Bergmann
772326003f fix: Separate 'changed' and 'due' ad selectors (#442)
This commit implements a new 'changed' selector for the --ads option that
publishes only ads that have been modified since their last publication.
The 'due' selector now only republishes ads based on the time interval,
without considering content changes.

The implementation allows combining selectors with commas (e.g., --ads=changed,due)
to publish both changed and due ads. Documentation and translations have been
updated accordingly.

Fixes #411
2025-02-28 14:53:53 -05:00
github-actions[bot]
6b3da5bc0a chore: Update Python dependencies 2025-02-28 11:21:12 -05:00
NME
7b9412677e fix: Update css class selectors fixing #440 (#441)
* fixes #440 css update
* fixed class selector
* added missing translation
---------

Co-authored-by: Jens Bergmann <1742418+1cu@users.noreply.github.com>
2025-02-28 11:16:49 -05:00
github-actions[bot]
b99be81158 chore: Update Python dependencies 2025-02-20 04:53:31 +01:00
Jens Bergmann
c7f7b832b2 fix: Make description field optional in ad_defaults
The description field in the main configuration (ad_defaults) is now optional.
Previously, the bot would fail if no description or affixes were provided in
the main configuration. This change addresses issue #435.

Changes:
- Add fallback to empty string ("") when all description prefix/suffix sources
  are None in __get_description_with_affixes method
- Add comprehensive test suite for description handling in test_init.py
- Fix coverage path in pyproject.toml from 'kleinanzeigen_bot' to
  'src/kleinanzeigen_bot'

New tests cover:
- Description handling without main config description
- New format affixes in configuration
- Mixed old/new format affixes
- Ad-level affix precedence
- None value handling in affixes
- Email address @ symbol replacement

This change maintains backward compatibility while making the description
field optional in the main configuration, improving flexibility for users.
2025-02-18 21:39:53 +01:00
kleinanzeigen-bot-tu[bot]
a8f6817c5c chore: update psutil 6.1.1 -> 7.0.0 (#430)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-02-16 21:47:13 +01:00
Jens Bergmann
610615367c fix: consolidate description text processing (#432)
Consolidate description text processing into __get_description_with_affixes method:
- Move @ -> (at) replacement into the method
- Remove duplicate prefix/suffix handling code
- Ensure consistent description text processing in one place

This fixes #432 by ensuring consistent handling of description affixes
and text transformations.
2025-02-15 19:58:09 +01:00
github-actions[bot]
34b2bc6550 chore: ✔ Update pyright 1.1.393 -> 1.1.394 2025-02-13 17:26:15 +01:00
Heavenfighter
543d46631c fix: Setting shipping options fails for commercial accounts. Fixes #394 (#424)
Co-authored-by: Jens Bergmann <1742418+1cu@users.noreply.github.com>
2025-02-13 17:13:32 +01:00
Jens Bergmann
e43ac4f1f9 feat: extend translations and add translation unit test (#427) 2025-02-12 22:25:05 +01:00
sebthom
c61c14709f ci: add PR title validation 2025-02-12 22:16:16 +01:00
github-actions[bot]
8270554507 chore: ✔ Update coverage 7.6.11 -> 7.6.12 2025-02-12 21:45:54 +01:00
sebthom
9f19cd85bd docs: fix build status badge 2025-02-12 21:40:45 +01:00
Jens Bergmann
4051620aed enh: allow per-ad overriding of global description affixes (#416) 2025-02-11 23:39:26 +01:00
Heavenfighter
a67112d936 fix: handle delayed ad publication #414 (#422) 2025-02-11 20:43:33 +01:00
Heavenfighter
820ae8966e fix: download all ads not working anymore #420 (#421)
renamed h2 to h3
2025-02-11 12:33:32 -06:00
sebthom
f3beb795b4 refact: minor cleanup 2025-02-10 22:06:03 +01:00
sebthom
5ade82b54d chore: update pyproject config 2025-02-10 21:16:38 +01:00
sebthom
367ef07798 refact: improve logger handling 2025-02-10 20:34:58 +01:00
sebthom
ec7ffedcd6 ci: add build timeout to all jobs 2025-02-10 18:51:54 +01:00
sebthom
2402ba2572 refact: reorganize utility modules 2025-02-10 06:23:17 +01:00
sebthom
e8d342dc68 docs: document ad config defaults 2025-02-10 03:23:33 +01:00
sebthom
7169975d2a fix: logging file handler not closed on bot shutdown. Fixes #405 2025-02-09 04:23:24 +01:00
github-actions[bot]
b4658407a3 chore: Update Python dependencies 2025-02-09 03:45:17 +01:00
Jens Bergmann
affde0debf test: Enhance test coverage for KleinanzeigenBot initialization and core functionality (#408) 2025-02-09 03:33:01 +01:00
Jens Bergmann
dd5f2ba5e4 fix: Ensure Consistent Content Hash Calculation (#415)
This commit addresses an issue where the content hash was being calculated on the current configuration (`ad_cfg`) instead of the original configuration (`ad_cfg_orig`). This could lead to inconsistent hash values, especially when the configuration is updated during the execution of the program.

The fix involves calculating the content hash on the original configuration (`ad_cfg_orig`) in both the `__check_ad_republication` and `publish_ad` methods. This ensures that the hash value is consistent and matches what was stored.

The relevant code changes are as follows:

- In the `__check_ad_republication` method, the content hash is now calculated on `ad_cfg_orig` instead of `ad_cfg`.
- In the `publish_ad` method, the content hash is also calculated on `ad_cfg_orig` to ensure consistency.

These changes should improve the reliability of the content hash comparison and the overall stability of the application.
2025-02-09 03:14:19 +01:00
sebthom
042525eb91 build: upgrade to Python 3.13.2 2025-02-08 22:18:16 +01:00
DreckSoft
b12118361d feat: display actual num of chars of description when too long (#403) 2025-02-08 04:03:54 +01:00
github-actions[bot]
20fb47a6e2 chore: Update Python dependencies 2025-02-05 16:39:22 -06:00
1cu
f4f00b9563 test: Add comprehensive test suite for extract.py (#400) 2025-02-05 23:35:45 +01:00
sebthom
08197eabae docs: improve disclaimer 2025-02-03 22:06:30 +01:00
sebthom
9cd4fdd693 build: use Python 3.13.1 when building with act 2025-02-03 21:12:45 +01:00
github-actions[bot]
67fd0e2724 chore: Update Python dependencies 2025-02-03 17:06:06 +01:00
1cu
76b0901166 test: reorganized unit/integration tests (#398) 2025-02-03 17:05:14 +01:00
Jens Bergmann
100f2fd8c5 style: ensure all comments and strings are in English - Update test descriptions and comments 2025-02-03 14:15:47 +01:00
Jens Bergmann
be8eee6aa0 fix: Handle None values in calculate_content_hash - Add test case to reproduce TypeError with None values - Fix handling of None values in special_attributes, shipping_options and images - Ensure consistent empty value handling (empty string instead of 'None') - Fixes #395 2025-02-03 14:15:47 +01:00
github-actions[bot]
f51dab0c3f chore: Update Python dependencies 2025-01-29 20:57:08 +01:00
1cu
fa0d43efa8 fix: Make doctests locale-independent (#390) (#391) 2025-01-27 09:22:15 +01:00
1cu
f01109c956 feat: add hash-based ad change detection (#343) (#388)
Co-authored-by: sebthom <sebthom@users.noreply.github.com>
2025-01-26 23:37:33 +01:00
sebthom
3d27755207 docs: update README 2025-01-26 20:05:28 +01:00
kleinanzeigen-bot-tu[bot]
ed7fd21272 chore: Update deprecated 1.2.15 -> 1.2.17 (#389)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-01-26 19:50:07 +01:00
sebthom
236740fc2b chore: update pyproject.toml 2025-01-24 22:13:59 +01:00
sebthom
d2eb3adc77 chore: update PR template 2025-01-24 21:50:06 +01:00
Heavenfighter
66634ce636 fix: fixed shipping button selector #385 (#387) 2025-01-20 21:40:28 +01:00
sebthom
7d9b857a46 docs: Update doc 2025-01-20 21:40:28 +01:00
Jens Bergmann
2f93e0dfda fix: correct city selection when multiple cities are available for a ZIP code
When multiple cities are available for a ZIP code, the bot now correctly selects
the city specified in the YAML file's location field instead of always choosing
the first option.

The change:
- Adds logic to select the correct city from dropdown based on location field
- Adds a small delay after ZIP code input to allow dropdown to populate
- Uses proper WebScrapingMixin method to read dropdown options
2025-01-20 12:22:16 +01:00
github-actions[bot]
46e585b96d chore: Update Python dependencies 2025-01-20 12:21:57 +01:00
sebthom
d4d5514cc0 fix: better commit message for dependency updates 2025-01-14 14:18:50 +01:00
kleinanzeigen-bot-tu[bot]
49ac8baf5c chore: Update bandit 1.8.0 -> 1.8.2 (#381)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-01-14 14:04:14 +01:00
kleinanzeigen-bot-tu[bot]
70aef618a0 chore: Update wrapt 1.17.0 -> 1.17.1 (#379)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-01-11 11:17:04 +01:00
sebthom
677c48628d fix: remove temporary workaround for #368 2025-01-10 16:21:44 +01:00
Heavenfighter
ca876e628b fix shipping options when downloading. Fixes #375 (#376) 2025-01-10 16:05:11 +01:00
github-actions[bot]
640b748b1d chore: Update Python dependencies 2025-01-10 12:30:24 +01:00
sebthom
6820a946c9 fix: escape metachars in ID and Names for selector queries #368 2025-01-09 21:14:13 +01:00
Heavenfighter
33a43e3ff6 fix: setting shipping options regression #367 (#374)
Button with given label occurs too often. Path must be corrected.
2025-01-09 20:30:24 +01:00
Heavenfighter
f9eb6185c7 fix: failed to set special attributes #334 (#370) 2025-01-09 17:01:48 +01:00
Heavenfighter
e590a32aa2 fix: re-publishing without images #371 (#372)
added detection of image-question
2025-01-09 17:00:51 +01:00
kleinanzeigen-bot-tu[bot]
7668026eda chore: Update setuptools 75.7.0 -> 75.8.0 (#369)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-01-09 17:00:15 +01:00
Heavenfighter
5829df66e4 fix setting shipping options #367 2025-01-09 16:59:38 +01:00
Heavenfighter
f3a7cf0150 feat: don't republish reserved ads. fixes #365 (#366) 2025-01-08 18:21:34 +01:00
github-actions[bot]
cd955a5506 chore: Update Python dependencies 2025-01-08 18:16:36 +01:00
sebthom
be78ec9736 fix: don't create a new release on every cron scheduled run 2025-01-01 16:52:07 +01:00
sebthom
2705dc7e43 refact: use colorama.just_fix_windows_console instead of colorama.init 2024-12-28 19:25:53 +01:00
sebthom
679d08502c chore: regenerate pdm.lock 2024-12-28 19:25:26 +01:00
sebthom
aec051826a chore: update project meta 2024-12-28 18:03:09 +01:00
sebthom
05f6ceb5b9 don't fail python dep update job if no updates were found 2024-12-28 17:50:25 +01:00
sebthom
e077f8d86d feat: improve colorized logging 2024-12-27 15:35:58 +01:00
sebthom
f90f848cba fix: improve online help 2024-12-27 15:33:45 +01:00
sebthom
47614887e7 fix: improve logging 2024-12-27 14:19:20 +01:00
sebthom
9841f6f48f ci: fix release build 2024-12-27 13:49:38 +01:00
sebthom
1e782beabc fix: update help text 2024-12-27 13:49:05 +01:00
sebthom
9d54a949e7 feat: add multi-language support 2024-12-27 13:04:30 +01:00
sebthom
0aa1975325 chore: Update Python dependencies 2024-12-27 12:54:36 +01:00
sebthom
7b579900c3 ci: update workflow config 2024-12-27 12:54:21 +01:00
sebthom
cde3250ab8 ci: update workflow config 2024-12-22 22:20:23 +01:00
sebthom
a738f0748d docs: add related OSS projects section 2024-12-22 22:19:44 +01:00
sebthom
8acaf7b25f chore: Update Python dependencies 2024-12-22 20:49:29 +01:00
provinzio
09f4d0f16f FIX login check has to be done case insensitive 2024-12-13 18:40:56 +01:00
github-actions[bot]
f1ae6ff8de chore: Update Python dependencies 2024-12-12 22:34:18 +01:00
sebthom
97ed41d96e ci: update issue templates 2024-12-12 22:28:25 +01:00
sebthom
ab953111d4 ci: fix linux builds 2024-12-12 22:09:57 +01:00
Heavenfighter
9a826452f9 fix: No HTML element found using CSS selector (#354)
Fixes #351
Fixes #353
2024-12-08 18:46:29 +01:00
kleinanzeigen-bot-tu[bot]
e89e311043 chore: Update Python dependencies (#350)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-12-08 18:37:17 +01:00
sebthom
26f05b5506 fix: category value incomplete when downloading ads 2024-11-25 00:03:48 +01:00
sebthom
a83ee4883e refact: minor cleanup 2024-11-25 00:03:18 +01:00
sebthom
e8dcb78951 fix: using shipping type PICKUP fails #346 2024-11-24 21:12:43 +01:00
sebthom
f7ef3c2b2e fix: don't auto delete ads directly after publishing 2024-11-22 23:54:53 +01:00
sebthom
b259977198 feat: if a category is not found try to lookup fallback category 2024-11-22 14:27:32 +01:00
sebthom
50ac195229 feat: extend categories.yaml 2024-11-22 13:43:15 +01:00
sebthom
a876add5a7 feat: by default delete old ads after republishing #338 2024-11-22 12:41:34 +01:00
sebthom
f9fdf4d158 refact: update categories 2024-11-22 12:40:27 +01:00
sebthom
a419c48805 refact: remove redundant comments 2024-11-22 12:30:50 +01:00
sebthom
ee09bb40a2 refact: add comment 2024-11-22 00:12:50 +01:00
sebthom
01d78bb000 feat: support shipping for WANTED ads #349 2024-11-21 23:53:26 +01:00
sebthom
6a315c97ce feat: remove default prefix/suffix text from downloaded ads 2024-11-21 23:28:13 +01:00
sebthom
5086721082 feat: use YAML | block style for multi-line strings on add download 2024-11-21 23:11:36 +01:00
sebthom
735e564c76 fix: save location #296 2024-11-21 22:53:49 +01:00
sebthom
86c3aeea85 fix: downloaded images have wrong file extension #348 2024-11-21 22:53:35 +01:00
sebthom
fe13131dee chore: update deps 2024-11-21 22:05:56 +01:00
sebthom
f6748de2b1 fix: add missing await keyword 2024-11-21 22:04:32 +01:00
sebthom
6e76b0ff4c build: rename "scan" script to "audit" 2024-11-21 22:04:15 +01:00
sebthom
1b326c1ce8 chore: upgrade to Python 3.13 and update deps 2024-11-15 13:31:29 +01:00
Julian Hackinger
4a3fb230f5 fix: double login required (#344) 2024-11-15 13:05:08 +01:00
sebthom
dc951d54e4 ci: remove deprecated parameter 2024-10-24 20:07:11 +02:00
github-actions[bot]
6518a1f890 chore: Update Python dependencies 2024-10-24 19:38:35 +02:00
sebthom
9b320c1d3c chore: update issue config 2024-10-08 21:41:29 +02:00
sebthom
ba6a40e373 chore: upgrade to Python 3.12.6 2024-09-16 12:10:05 +02:00
sebthom
6c5c1940e1 chore: Update Python dependencies 2024-09-16 11:56:34 +02:00
dependabot[bot]
7f9046a26d ci(deps): bump peter-evans/create-pull-request from 6 to 7
Bumps [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) from 6 to 7.
- [Release notes](https://github.com/peter-evans/create-pull-request/releases)
- [Commits](https://github.com/peter-evans/create-pull-request/compare/v6...v7)

---
updated-dependencies:
- dependency-name: peter-evans/create-pull-request
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-09 20:08:57 +02:00
Saghalt
b9e1f8c327 fix: ValueError when downloading ads without special_attributes (#330) 2024-09-02 20:55:21 +02:00
Saghalt
315400534b Disable search engine popup with chrome 2024-08-22 13:11:46 +02:00
sebthom
0491636666 fix: SSL: CERTIFICATE_VERIFY_FAILED when running compiled version 2024-08-05 13:50:43 +02:00
sebthom
a74c618b36 fix: ModuleNotFoundError: No module named 'backports' 2024-08-05 13:50:43 +02:00
sebthom
69de3d07f5 chore: Update Python dependencies 2024-08-05 13:43:38 +02:00
Jeppy
c1272626aa FIX id of web element to select special attribute changed 2024-07-23 12:14:19 +02:00
Jeppy
c967e901ac FIX select condition from new dialog instead 2024-07-23 12:14:19 +02:00
Jeppy
71eb632191 FIX extract special attributes from ad page
Format of special attribute changed to "key:value|key:value".
Instead of transforming the string to JSON, directly create a dictionary from belen_conf.
2024-07-23 11:42:41 +02:00
sebthom
53f155f6c0 chore: use oldest supported Python version for dep updates 2024-07-19 17:13:03 +02:00
github-actions[bot]
39f9545d9b chore: Update Python dependencies 2024-07-07 20:08:38 +02:00
github-actions[bot]
effc91c269 chore: Update Python dependencies 2024-06-11 10:56:46 +02:00
Saghalt
eab9874bdb fix: special attributes cannot be parsed as JSON #312 2024-06-11 10:55:03 +02:00
github-actions[bot]
0f87e5573a chore: Update Python dependencies 2024-05-30 22:14:37 +02:00
sebthom
ef6b25fb46 Scan final build results using clamscan 2024-05-30 22:03:16 +02:00
sebthom
1e0990580d Log Github context 2024-05-30 21:46:15 +02:00
sebthom
9d0755c359 add MacOS ARM builds 2024-05-30 21:41:02 +02:00
Jeppy
4a8b6ecdf3 FIX selection of shipping options (#307) 2024-05-30 20:54:30 +02:00
Jeppy
929459a08d FIX selecting price type
selecting the wanted index doesn't trigger a change event which is necessary to update  internal variables regarding the price type
2024-05-30 20:27:25 +02:00
Jeppy
72283bf069 UPDATE wait for user interaction to solve captcha on publishing ad (closes Second-Hand-Friends/kleinanzeigen-bot#301) 2024-05-30 20:26:39 +02:00
Jeppy
b30867ca48 FIX extract sell directly from ad page
Web element with id `j-buy-now` does not exist anymore. Fetch the `payment-buttons-sidebar` instead and check the text for `Direkt kaufen`
2024-05-30 19:26:37 +02:00
Kjell Knudsen
ba73ebb393 fix navigation button selector 2024-05-11 15:49:03 +02:00
sebthom
822d3b7e7c upgrade dependencies
- setuptools 69.1.1 -> 69.5.1
- pytest-rerunfailures 13.0 -> 14.0
- autopep8 2.0.4 -> 2.1.0
- typing-extensions 4.10.0 -> 4.11.0
- pyright 1.1.353 -> 1.1.359
- pyinstaller 6.5.0 -> 6.6.0
- pyinstaller-hooks-contrib 2024.3 -> 2024.4
- nodriver 0.27rc3 -> 0.27rc4
2024-04-17 17:49:11 +02:00
sebthom
12974285ad start clamav before checkout 2024-04-04 19:00:38 +02:00
sebthom
657eadaa59 update workflow config 2024-04-04 14:24:01 +02:00
Maksim Bock
d1f50e9b16 fix broken link to categories in config_defaults.yaml 2024-04-03 21:46:49 +02:00
Tobias Faber
2c7d165b6e Fix download on given IDs list 2024-04-01 23:03:27 +02:00
dependabot[bot]
88d9e053cb ci(deps): bump toblux/start-clamd-github-action from 0.1 to 0.2
Bumps [toblux/start-clamd-github-action](https://github.com/toblux/start-clamd-github-action) from 0.1 to 0.2.
- [Release notes](https://github.com/toblux/start-clamd-github-action/releases)
- [Commits](https://github.com/toblux/start-clamd-github-action/compare/v0.1...v0.2)

---
updated-dependencies:
- dependency-name: toblux/start-clamd-github-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-01 19:58:07 +02:00
Moritz Graf
b3cc8ef5cd Fixing missing src directory in README (#288) 2024-04-01 18:09:50 +02:00
Tobias Faber
114afb6a73 fix: download of shipping info. Fixes #282 (#286) 2024-03-29 14:45:21 +01:00
Tobias Faber
db465af9b7 Fix VB Price with thousand separator 2024-03-29 13:39:09 +01:00
SphaeroX
5c8e00df52 fix: No HTML element found with ID 'my-manageads-adlist' (#284) 2024-03-28 19:45:42 +01:00
sebthom
46b901d0cc ci: remove unused token 2024-03-18 19:08:47 +01:00
github-actions[bot]
78c9b16058 chore: Update Python dependencies 2024-03-18 19:06:22 +01:00
dependabot[bot]
750f6a0ef2 ci(deps): bump geekyeggo/delete-artifact from 4 to 5
Bumps [geekyeggo/delete-artifact](https://github.com/geekyeggo/delete-artifact) from 4 to 5.
- [Release notes](https://github.com/geekyeggo/delete-artifact/releases)
- [Changelog](https://github.com/GeekyEggo/delete-artifact/blob/main/CHANGELOG.md)
- [Commits](https://github.com/geekyeggo/delete-artifact/compare/v4...v5)

---
updated-dependencies:
- dependency-name: geekyeggo/delete-artifact
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-18 19:06:12 +01:00
sebthom
ef3429435b fix: root CA certs missing in docker image 2024-03-16 22:09:02 +01:00
sebthom
7c982ad502 fix: don't hardcode republication_interval. Fixes #271 2024-03-14 12:51:19 +01:00
sebthom
a8290500e7 build kleinanzeigen-bot-windows-amd64-uncompressed.exe 2024-03-11 23:08:30 +01:00
dependabot[bot]
e75936da75 ci(deps): bump actions/checkout from 2 to 4
Bumps [actions/checkout](https://github.com/actions/checkout) from 2 to 4.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v2...v4)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-11 19:56:51 +01:00
sebthom
d5ae070bb3 chore: Update Python dependencies 2024-03-11 00:41:20 +01:00
sebthom
f943078d44 ci: configure clamd anti-virus/malware daemon 2024-03-11 00:13:50 +01:00
sebthom
61f362afb4 remove obsolete chrome-driver from docker image 2024-03-08 15:21:17 +01:00
sebthom
7133b26c37 update stale config 2024-03-08 13:00:14 +01:00
Samuel
d7fec9e4ce Fix: Crash on downloading ads with prices >=1000 Eur (#267)
Co-authored-by: Sebastian Thomschke <sebthom@users.noreply.github.com>
2024-03-08 12:06:47 +01:00
Sebastian Thomschke
e99f74bc58 Handle quotes in commit messages 2024-03-08 00:08:42 +01:00
sebthom
c9f12bfeea add "pdm debug" task 2024-03-07 23:21:50 +01:00
sebthom
e7c7ba90be support re-using already open browser window 2024-03-07 23:07:23 +01:00
sebthom
d1f33bb44a improve check if already logged in 2024-03-07 22:12:26 +01:00
sebthom
a5c1219faf update workflow config 2024-03-07 20:33:34 +01:00
sebthom
a441c5de73 replace selenium with nodriver 2024-03-07 20:33:23 +01:00
55 changed files with 9225 additions and 2556 deletions

View File

@@ -1,3 +1,3 @@
{
"act": true
"act": true
}

2
.actrc
View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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
View 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
View File

@@ -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

View File

@@ -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"

View File

@@ -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
View 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

View File

@@ -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
View 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
View File

@@ -21,6 +21,12 @@ __pycache__
/.mypy_cache
/.pdm-build/
/.pdm-python
/.ruff_cache
# test coverage
/.coverage
/htmlcov/
/coverage.xml
# Eclipse
/.project

183
README.md
View File

@@ -1,6 +1,6 @@
# kleinanzeigen-bot
[![Build Status](https://github.com/Second-Hand-Friends/kleinanzeigen-bot/workflows/Build/badge.svg "GitHub Actions")](https://github.com/Second-Hand-Friends/kleinanzeigen-bot/actions?query=workflow%3A%22Build%22)
[![Build Status](https://github.com/Second-Hand-Friends/kleinanzeigen-bot/actions/workflows/build.yml/badge.svg)](https://github.com/Second-Hand-Friends/kleinanzeigen-bot/actions/workflows/build.yml)
[![License](https://img.shields.io/github/license/Second-Hand-Friends/kleinanzeigen-bot.svg?color=blue)](LICENSE.txt)
[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-v2.1%20adopted-ff69b4.svg)](CODE_OF_CONDUCT.md)
[![Maintainability](https://api.codeclimate.com/v1/badges/77b4ed9cc0dd8cfe373c/maintainability)](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).

View File

@@ -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

1738
pdm.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -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,
)

View File

@@ -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
View 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 doublequote 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

View File

@@ -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

View 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 ""

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View 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

View File

@@ -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

View 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]..."

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,3 @@
"""
This module contains generic, reusable code.
"""

View 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

View 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)

View 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

View 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))

View 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"

View 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)

View 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)

View 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()

View 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

View 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

View File

@@ -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
View 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
]

View 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())

View File

@@ -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()

View File

@@ -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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

View 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.")

View 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)