kleinanzeigen 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,45 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [master]
6
+ pull_request: ~
7
+
8
+ env:
9
+ FORCE_COLOR: 1
10
+
11
+ jobs:
12
+ lint:
13
+ runs-on: ubuntu-26.04
14
+ steps:
15
+ - uses: actions/checkout@v7
16
+ - uses: jdx/mise-action@v5
17
+ - name: Install pip dependencies
18
+ run: uv sync
19
+ - uses: actions/cache@v6
20
+ with:
21
+ path: ~/.cache/pre-commit/
22
+ key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
23
+ - name: Run pre-commit
24
+ run: pre-commit run --all-files --show-diff-on-failure
25
+ test:
26
+ runs-on: ubuntu-26.04
27
+ strategy:
28
+ matrix:
29
+ python-version: ['3.15', '3.14']
30
+ env:
31
+ MISE_PYTHON_VERSION: ${{ matrix.python-version }}
32
+ steps:
33
+ - uses: actions/checkout@v7
34
+ - uses: jdx/mise-action@v5
35
+ - name: Project python version
36
+ id: project-python-version
37
+ run: |
38
+ ver=$(grep -oP 'python\s*=\s*"\K[^"]+' mise.toml | cut -d. -f1-2)
39
+ echo "python-version=$ver" >> $GITHUB_OUTPUT
40
+ - name: Install python
41
+ run: mise install python@${{ matrix.python-version }}
42
+ - name: Install pip dependencies
43
+ run: uv sync
44
+ - name: Run tests
45
+ run: uv run pytest --cov --junitxml=junit.xml -o junit_family=legacy --cov-branch --cov-report=xml
@@ -0,0 +1,27 @@
1
+ name: Publish
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ env:
8
+ MISE_IDIOMATIC_VERSION_FILE_ENABLE_TOOLS: python
9
+
10
+ jobs:
11
+ publish:
12
+ runs-on: ubuntu-26.04
13
+ environment:
14
+ name: pypi
15
+ url: https://pypi.org/p/kleinanzeigen
16
+ permissions:
17
+ contents: read
18
+ id-token: write
19
+ steps:
20
+ - uses: actions/checkout@v7
21
+ - uses: jdx/mise-action@v5
22
+ - name: Install pip dependencies
23
+ run: uv sync
24
+ - name: Package
25
+ run: uv build
26
+ - name: Publish to PyPI
27
+ uses: pypa/gh-action-pypi-publish@v1.14.2
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ .mypy_cache/
7
+ .enola/
@@ -0,0 +1,13 @@
1
+ {
2
+ "mcpServers": {
3
+ "kleinanzeigen-dev": {
4
+ "command": "uv",
5
+ "args": [
6
+ "run",
7
+ "--project",
8
+ "/home/malachi/code/python/kleinanzeigen",
9
+ "kleinanzeigen-mcp"
10
+ ]
11
+ }
12
+ }
13
+ }
@@ -0,0 +1,30 @@
1
+ repos:
2
+ - repo: https://github.com/pre-commit/pre-commit-hooks
3
+ rev: v6.0.0
4
+ hooks:
5
+ - id: trailing-whitespace
6
+ - id: end-of-file-fixer
7
+ - id: check-yaml
8
+ - id: check-toml
9
+ - id: check-added-large-files
10
+ - repo: https://github.com/astral-sh/ruff-pre-commit
11
+ rev: v0.17.4
12
+ hooks:
13
+ - id: ruff-check
14
+ args:
15
+ - --fix
16
+ - id: ruff-format
17
+ - repo: https://github.com/pre-commit/mirrors-mypy
18
+ rev: v2.4.1
19
+ hooks:
20
+ - id: mypy
21
+ additional_dependencies:
22
+ - types-beautifulsoup4
23
+ - pydantic>=2.0
24
+ - curl-cffi>=0.17.0
25
+ - mcp>=1.0,<2
26
+ - click>=8.5.1
27
+ - humanize>=4.16.0
28
+ - rich>=15.0.0
29
+ args: [src]
30
+ pass_filenames: false
@@ -0,0 +1,74 @@
1
+ # kleinanzeigen
2
+
3
+ Async Python client for [kleinanzeigen.de](https://www.kleinanzeigen.de) over plain
4
+ HTTP (`curl_cffi` with Chrome TLS impersonation + BeautifulSoup). No browser.
5
+
6
+ ## Structure
7
+
8
+ - `src/kleinanzeigen/client.py` — `Kleinanzeigen` async client. `search()` takes
9
+ a `SearchParams` and `search_url()` takes a URL; both return a
10
+ `SearchResult`. `iter_search()` / `iter_search_url()` stream
11
+ `(PageProgress, listings)` per page for progress UIs;
12
+ `iter_search_pages(url_for)` is the low-level primitive, and
13
+ `collect_search_pages()` drains an iterator into a `SearchResult`.
14
+ `get_listing()` fetches a single ad (`None` when gone). All pagination
15
+ policy (delays, URL building) lives here — adapters never touch it.
16
+ - `src/kleinanzeigen/parsing.py` — HTML extraction: search cards, breadcrumb
17
+ totals, ad detail pages, URL building, date/price/location parsing.
18
+ `apply_min_publish_date()` filters a page's listings and reports whether
19
+ an old listing ends pagination.
20
+ - `src/kleinanzeigen/models.py` — frozen pydantic models: `SearchParams`
21
+ (validated request: page/pages/price bounds), `Listing`, `ListingDetail`,
22
+ `SearchResult`, `Price`, `Seller`, `Location`, `AdStatus`. Upstream
23
+ `published_at` is naive Europe/Berlin local time.
24
+ - `src/kleinanzeigen/format.py` — typed JSON response boundary shared by CLI
25
+ and MCP: `SearchResponse` (envelope with `returned` after a response-level
26
+ `limit`), `ErrorResponse`, `search_response*()`. Responses embed core
27
+ models directly; keep adapter-only fields (limit/returned) out of
28
+ `SearchResult`.
29
+ - `src/kleinanzeigen/cli.py` — `kleinanzeigen` command: `search`, `search-url`,
30
+ `get`. Rich table renders `SearchResult` directly; `--json` serializes via
31
+ `search_response_json`. Progress spinner consumes `iter_search*` on a
32
+ worker thread — keep CLI/presentation concerns out of `client.py`.
33
+ - `src/kleinanzeigen/mcp_server.py` — `kleinanzeigen-mcp` MCP server exposing
34
+ the same search/get operations to MCP clients. Tools return typed models
35
+ (`SearchResponse`, `ListingDetail | ErrorResponse`) so FastMCP publishes
36
+ concrete output schemas.
37
+ - `tests/` — pytest suite (currently `test_parsing.py`).
38
+
39
+ ## Setup
40
+
41
+ Managed with `uv`; the venv is `.venv/`.
42
+
43
+ ```sh
44
+ uv sync
45
+ ```
46
+
47
+ ## Run
48
+
49
+ ```sh
50
+ kleinanzeigen search -q "fahrrad" -l "Berlin" --radius 5 # all pages by default
51
+ kleinanzeigen search-url "<saved search URL>" --pages 3
52
+ kleinanzeigen get <url-or-ad-id>
53
+ ```
54
+
55
+ ## Tests
56
+
57
+ ```sh
58
+ uv run pytest
59
+ ```
60
+
61
+ Config lives in `pyproject.toml` (`testpaths = ["tests"]`, `pythonpath = ["src"]`).
62
+ Tests are offline — they exercise `parsing.py` against fixtures, not live HTTP.
63
+
64
+ ## Lint / format
65
+
66
+ Lint/format run through pre-commit only — ruff is not installed in the venv
67
+ (pre-commit pins its own version via `ruff-pre-commit`):
68
+
69
+ ```sh
70
+ pre-commit run -a
71
+ ```
72
+
73
+ Ruff config is in `pyproject.toml` (`src = ["src", "tests"]`); pre-commit's
74
+ pinned ruff version is the single source of truth for the linter version.
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.5
2
+ Name: kleinanzeigen
3
+ Version: 0.1.0
4
+ Summary: Async client for querying kleinanzeigen.de over plain HTTP (no browser)
5
+ License: MIT
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: beautifulsoup4>=4.12
8
+ Requires-Dist: click>=8.5.1
9
+ Requires-Dist: curl-cffi>=0.17.0
10
+ Requires-Dist: humanize>=4.16.0
11
+ Requires-Dist: mcp<2,>=1.0
12
+ Requires-Dist: pydantic>=2.0
13
+ Requires-Dist: rich>=15.0.0
14
+ Description-Content-Type: text/markdown
15
+
16
+ # kleinanzeigen
17
+
18
+ Async Python client for [kleinanzeigen.de](https://www.kleinanzeigen.de) backed by
19
+ plain HTTP (curl_cffi + BeautifulSoup) — no Playwright, no browser. Ships a CLI
20
+ and an MCP server on top of the same client.
21
+
22
+ Requires Python 3.11+.
23
+
24
+ Based off the work done in [ebay-kleinanzeigen-api](https://github.com/DanielWTE/ebay-kleinanzeigen-api/) and ported to run without a browser.
25
+
26
+ > **Note:** this scrapes kleinanzeigen.de without a browser. Pages are fetched
27
+ > sequentially with delays to be polite - don't hammer it, and check
28
+ > kleinanzeigen.de's terms of service for your use case.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ # With uv
34
+ uv add kleinanzeigen
35
+
36
+ # With pip
37
+ pip install kleinanzeigen
38
+
39
+ # With pipx
40
+ pipx install kleinanzeigen
41
+ ```
42
+
43
+ ## Library
44
+
45
+ ```python
46
+ import asyncio
47
+ from datetime import datetime
48
+
49
+ from kleinanzeigen import Kleinanzeigen, SearchParams
50
+
51
+
52
+ async def main():
53
+ async with Kleinanzeigen() as ka:
54
+ # keyword search; pages=0 fetches all pages
55
+ result = await ka.search(
56
+ SearchParams(query="fahrrad", location="Berlin", radius=25, pages=2)
57
+ )
58
+ for listing in result.listings:
59
+ print(listing.title, listing.price, listing.url)
60
+ print(result.total_results, result.total_pages, result.pages_fetched)
61
+
62
+ # any saved/filtered search URL (page numbers injected automatically)
63
+ result = await ka.search_url(
64
+ "https://www.kleinanzeigen.de/s-suche/foo.html", pages=3
65
+ )
66
+
67
+ # drop listings older than a cutoff (naive = Europe/Berlin)
68
+ result = await ka.search(
69
+ SearchParams(query="fahrrad", published_after=datetime(2026, 1, 1))
70
+ )
71
+
72
+ # single ad, by URL or bare id — None when the ad is gone
73
+ detail = await ka.get_listing("123456789")
74
+ if detail:
75
+ print(detail.title, detail.price.amount, detail.seller.name, detail.images)
76
+
77
+ # streaming progress: yields (PageProgress, listings) per page
78
+ async for progress, page_listings in ka.iter_search(
79
+ SearchParams(query="fahrrad")
80
+ ):
81
+ print(progress.page, progress.total_pages, len(page_listings))
82
+
83
+
84
+ asyncio.run(main())
85
+ ```
86
+
87
+ `SearchParams` fields: `query`, `location`, `radius`, `min_price`, `max_price`,
88
+ `page` (first page, 1-based), `pages` (0 = all), `published_after`.
89
+ `iter_search_url(url, pages=n)` is the streaming variant of `search_url`.
90
+ `iter_search_pages(url_for, first_page=, pages=)` is the low-level primitive
91
+ both streaming variants build on.
92
+
93
+ `ListingDetail` fields: `ad_id`, `url`, `title`, `status` (`active`/`sold`/
94
+ `reserved`/`deleted`), `price` (`amount` is `None` when the ad shows no
95
+ numeric price, e.g. "VB" or "Zu verschenken"; `negotiable` flags "VB"),
96
+ `categories`, `description`, `location` (`postal_code`/`city`/`state`),
97
+ `delivery` (`pickup`/`shipping`), `images`, `details`, `features`, `seller`
98
+ (`name`, `user_id`, `member_since`, `type`, `badges`), `created_at`,
99
+ `scraped_at`.
100
+
101
+
102
+ ## CLI
103
+
104
+ Installed as `kleinanzeigen`:
105
+
106
+ ```sh
107
+ # keyword search — all pages by default; Rich table with live spinner
108
+ kleinanzeigen search -q "fahrrad" -l "Berlin" --radius 5 --pages 3
109
+
110
+ # filters; --json prints raw JSON instead of the table
111
+ kleinanzeigen search -q "iphone" --min-price 100 --max-price 500 --json
112
+
113
+ # scrape a saved/filtered search URL
114
+ kleinanzeigen search-url "https://www.kleinanzeigen.de/s-suche/foo.html" --pages 3
115
+
116
+ # single ad by URL or bare id — prints JSON; exits 1 when the ad is gone
117
+ kleinanzeigen get 123456789
118
+ ```
119
+
120
+ `search` options: `-q/--query` (required), `-l/--location`, `--radius`,
121
+ `--min-price`, `--max-price`, `--page`, `--pages`, `--published-after`,
122
+ `--json`. `search-url` accepts `--pages`, `--published-after`, `--json`.
123
+
124
+ ## MCP server
125
+
126
+ Installed as `kleinanzeigen-mcp` (stdio). Tools: `search`, `search_url`,
127
+ `get_listing`. Search tools take the same arguments as the library plus a
128
+ `limit` cap on returned listings; both accept `published_after` (ISO
129
+ date/datetime). Responses are typed `SearchResponse` (`listings`,
130
+ `total_results`, `total_pages`, `pages_fetched`, `returned`) or
131
+ `ListingDetail`; a missing ad returns `{"error": "listing not found or removed"}`.
132
+
133
+ Add the following to your mcp json:
134
+
135
+ ```json
136
+ {
137
+ "mcpServers": {
138
+ "kleinanzeigen": {
139
+ "command": "kleinanzeigen-mcp"
140
+ }
141
+ }
142
+ }
143
+ ```
144
+
145
+ From a source checkout: `uv run kleinanzeigen-mcp`.
146
+
147
+ ## Notes
148
+
149
+ - Pages are fetched **sequentially** with a delay (`page_delay`, default 1s;
150
+ `search_url` enforces at least 2s) — Kleinanzeigen blocks concurrent
151
+ requests from one IP.
152
+ - `published_after` stops pagination early once older listings appear and
153
+ trims them from results. Upstream timestamps are naive Europe/Berlin.
154
+ - `search`/`search_url` return `SearchResult` (`listings`, `total_results`,
155
+ `total_pages`, `pages_fetched`); `get_listing` returns `ListingDetail` or
156
+ `None`.
157
+ - Transient HTTP errors are retried (`max_retries`, exponential backoff);
158
+ exhausted retries raise `KleinanzeigenError`.
@@ -0,0 +1,143 @@
1
+ # kleinanzeigen
2
+
3
+ Async Python client for [kleinanzeigen.de](https://www.kleinanzeigen.de) backed by
4
+ plain HTTP (curl_cffi + BeautifulSoup) — no Playwright, no browser. Ships a CLI
5
+ and an MCP server on top of the same client.
6
+
7
+ Requires Python 3.11+.
8
+
9
+ Based off the work done in [ebay-kleinanzeigen-api](https://github.com/DanielWTE/ebay-kleinanzeigen-api/) and ported to run without a browser.
10
+
11
+ > **Note:** this scrapes kleinanzeigen.de without a browser. Pages are fetched
12
+ > sequentially with delays to be polite - don't hammer it, and check
13
+ > kleinanzeigen.de's terms of service for your use case.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ # With uv
19
+ uv add kleinanzeigen
20
+
21
+ # With pip
22
+ pip install kleinanzeigen
23
+
24
+ # With pipx
25
+ pipx install kleinanzeigen
26
+ ```
27
+
28
+ ## Library
29
+
30
+ ```python
31
+ import asyncio
32
+ from datetime import datetime
33
+
34
+ from kleinanzeigen import Kleinanzeigen, SearchParams
35
+
36
+
37
+ async def main():
38
+ async with Kleinanzeigen() as ka:
39
+ # keyword search; pages=0 fetches all pages
40
+ result = await ka.search(
41
+ SearchParams(query="fahrrad", location="Berlin", radius=25, pages=2)
42
+ )
43
+ for listing in result.listings:
44
+ print(listing.title, listing.price, listing.url)
45
+ print(result.total_results, result.total_pages, result.pages_fetched)
46
+
47
+ # any saved/filtered search URL (page numbers injected automatically)
48
+ result = await ka.search_url(
49
+ "https://www.kleinanzeigen.de/s-suche/foo.html", pages=3
50
+ )
51
+
52
+ # drop listings older than a cutoff (naive = Europe/Berlin)
53
+ result = await ka.search(
54
+ SearchParams(query="fahrrad", published_after=datetime(2026, 1, 1))
55
+ )
56
+
57
+ # single ad, by URL or bare id — None when the ad is gone
58
+ detail = await ka.get_listing("123456789")
59
+ if detail:
60
+ print(detail.title, detail.price.amount, detail.seller.name, detail.images)
61
+
62
+ # streaming progress: yields (PageProgress, listings) per page
63
+ async for progress, page_listings in ka.iter_search(
64
+ SearchParams(query="fahrrad")
65
+ ):
66
+ print(progress.page, progress.total_pages, len(page_listings))
67
+
68
+
69
+ asyncio.run(main())
70
+ ```
71
+
72
+ `SearchParams` fields: `query`, `location`, `radius`, `min_price`, `max_price`,
73
+ `page` (first page, 1-based), `pages` (0 = all), `published_after`.
74
+ `iter_search_url(url, pages=n)` is the streaming variant of `search_url`.
75
+ `iter_search_pages(url_for, first_page=, pages=)` is the low-level primitive
76
+ both streaming variants build on.
77
+
78
+ `ListingDetail` fields: `ad_id`, `url`, `title`, `status` (`active`/`sold`/
79
+ `reserved`/`deleted`), `price` (`amount` is `None` when the ad shows no
80
+ numeric price, e.g. "VB" or "Zu verschenken"; `negotiable` flags "VB"),
81
+ `categories`, `description`, `location` (`postal_code`/`city`/`state`),
82
+ `delivery` (`pickup`/`shipping`), `images`, `details`, `features`, `seller`
83
+ (`name`, `user_id`, `member_since`, `type`, `badges`), `created_at`,
84
+ `scraped_at`.
85
+
86
+
87
+ ## CLI
88
+
89
+ Installed as `kleinanzeigen`:
90
+
91
+ ```sh
92
+ # keyword search — all pages by default; Rich table with live spinner
93
+ kleinanzeigen search -q "fahrrad" -l "Berlin" --radius 5 --pages 3
94
+
95
+ # filters; --json prints raw JSON instead of the table
96
+ kleinanzeigen search -q "iphone" --min-price 100 --max-price 500 --json
97
+
98
+ # scrape a saved/filtered search URL
99
+ kleinanzeigen search-url "https://www.kleinanzeigen.de/s-suche/foo.html" --pages 3
100
+
101
+ # single ad by URL or bare id — prints JSON; exits 1 when the ad is gone
102
+ kleinanzeigen get 123456789
103
+ ```
104
+
105
+ `search` options: `-q/--query` (required), `-l/--location`, `--radius`,
106
+ `--min-price`, `--max-price`, `--page`, `--pages`, `--published-after`,
107
+ `--json`. `search-url` accepts `--pages`, `--published-after`, `--json`.
108
+
109
+ ## MCP server
110
+
111
+ Installed as `kleinanzeigen-mcp` (stdio). Tools: `search`, `search_url`,
112
+ `get_listing`. Search tools take the same arguments as the library plus a
113
+ `limit` cap on returned listings; both accept `published_after` (ISO
114
+ date/datetime). Responses are typed `SearchResponse` (`listings`,
115
+ `total_results`, `total_pages`, `pages_fetched`, `returned`) or
116
+ `ListingDetail`; a missing ad returns `{"error": "listing not found or removed"}`.
117
+
118
+ Add the following to your mcp json:
119
+
120
+ ```json
121
+ {
122
+ "mcpServers": {
123
+ "kleinanzeigen": {
124
+ "command": "kleinanzeigen-mcp"
125
+ }
126
+ }
127
+ }
128
+ ```
129
+
130
+ From a source checkout: `uv run kleinanzeigen-mcp`.
131
+
132
+ ## Notes
133
+
134
+ - Pages are fetched **sequentially** with a delay (`page_delay`, default 1s;
135
+ `search_url` enforces at least 2s) — Kleinanzeigen blocks concurrent
136
+ requests from one IP.
137
+ - `published_after` stops pagination early once older listings appear and
138
+ trims them from results. Upstream timestamps are naive Europe/Berlin.
139
+ - `search`/`search_url` return `SearchResult` (`listings`, `total_results`,
140
+ `total_pages`, `pages_fetched`); `get_listing` returns `ListingDetail` or
141
+ `None`.
142
+ - Transient HTTP errors are retried (`max_retries`, exponential backoff);
143
+ exhausted retries raise `KleinanzeigenError`.
@@ -0,0 +1,11 @@
1
+ [tools]
2
+ python = "3.15.2"
3
+ pre-commit = "latest"
4
+ uv = "0.12.21"
5
+
6
+ [hooks]
7
+ postinstall = "pre-commit install"
8
+
9
+ [tasks.test]
10
+ description = "Run tests"
11
+ run = "uv run pytest"
@@ -0,0 +1,41 @@
1
+ [project]
2
+ name = "kleinanzeigen"
3
+ version = "0.1.0"
4
+ description = "Async client for querying kleinanzeigen.de over plain HTTP (no browser)"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = { text = "MIT" }
8
+ dependencies = [
9
+ "beautifulsoup4>=4.12",
10
+ "click>=8.5.1",
11
+ "mcp>=1.0,<2",
12
+ "rich>=15.0.0",
13
+ "humanize>=4.16.0",
14
+ "curl-cffi>=0.17.0",
15
+ "pydantic>=2.0",
16
+ ]
17
+
18
+ [dependency-groups]
19
+ dev = [
20
+ "pytest>=8",
21
+ "pytest-cov>=7.0.0,<8",
22
+ "pytest-socket>=0.8",
23
+ ]
24
+
25
+ [project.scripts]
26
+ kleinanzeigen = "kleinanzeigen.cli:main"
27
+ kleinanzeigen-mcp = "kleinanzeigen.mcp_server:main"
28
+
29
+ [build-system]
30
+ requires = ["hatchling"]
31
+ build-backend = "hatchling.build"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/kleinanzeigen"]
35
+
36
+ [tool.pytest.ini_options]
37
+ pythonpath = ["src"]
38
+ testpaths = ["tests"]
39
+
40
+ [tool.ruff]
41
+ src = ["src", "tests"]
@@ -0,0 +1,27 @@
1
+ """Async client for querying kleinanzeigen.de without a browser."""
2
+
3
+ from kleinanzeigen.client import Kleinanzeigen, KleinanzeigenError, PageProgress
4
+ from kleinanzeigen.models import (
5
+ AdStatus,
6
+ Listing,
7
+ ListingDetail,
8
+ Location,
9
+ Price,
10
+ SearchParams,
11
+ SearchResult,
12
+ Seller,
13
+ )
14
+
15
+ __all__ = [
16
+ "AdStatus",
17
+ "Kleinanzeigen",
18
+ "KleinanzeigenError",
19
+ "Listing",
20
+ "ListingDetail",
21
+ "Location",
22
+ "PageProgress",
23
+ "Price",
24
+ "SearchParams",
25
+ "SearchResult",
26
+ "Seller",
27
+ ]
@@ -0,0 +1,10 @@
1
+ """Package version from importlib.metadata — pyproject.toml is the source."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+
6
+ def package_version() -> str:
7
+ try:
8
+ return version("kleinanzeigen")
9
+ except PackageNotFoundError:
10
+ return "unknown"