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.
- kleinanzeigen-0.1.0/.github/workflows/ci.yml +45 -0
- kleinanzeigen-0.1.0/.github/workflows/publish.yml +27 -0
- kleinanzeigen-0.1.0/.gitignore +7 -0
- kleinanzeigen-0.1.0/.mcp.json +13 -0
- kleinanzeigen-0.1.0/.pre-commit-config.yaml +30 -0
- kleinanzeigen-0.1.0/AGENTS.md +74 -0
- kleinanzeigen-0.1.0/PKG-INFO +158 -0
- kleinanzeigen-0.1.0/README.md +143 -0
- kleinanzeigen-0.1.0/mise.toml +11 -0
- kleinanzeigen-0.1.0/pyproject.toml +41 -0
- kleinanzeigen-0.1.0/src/kleinanzeigen/__init__.py +27 -0
- kleinanzeigen-0.1.0/src/kleinanzeigen/_version.py +10 -0
- kleinanzeigen-0.1.0/src/kleinanzeigen/cli.py +418 -0
- kleinanzeigen-0.1.0/src/kleinanzeigen/client.py +251 -0
- kleinanzeigen-0.1.0/src/kleinanzeigen/format.py +49 -0
- kleinanzeigen-0.1.0/src/kleinanzeigen/mcp_server.py +111 -0
- kleinanzeigen-0.1.0/src/kleinanzeigen/models.py +110 -0
- kleinanzeigen-0.1.0/src/kleinanzeigen/parsing.py +559 -0
- kleinanzeigen-0.1.0/tests/conftest.py +28 -0
- kleinanzeigen-0.1.0/tests/fixtures/detail.html +198 -0
- kleinanzeigen-0.1.0/tests/fixtures/search.html +170 -0
- kleinanzeigen-0.1.0/tests/test_cli.py +64 -0
- kleinanzeigen-0.1.0/tests/test_parsing.py +178 -0
- kleinanzeigen-0.1.0/uv.lock +1147 -0
|
@@ -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,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,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"
|