kleinanzeigen-api 0.1.0__py3-none-any.whl
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_api/__init__.py +25 -0
- kleinanzeigen_api/__main__.py +4 -0
- kleinanzeigen_api/categories.py +157 -0
- kleinanzeigen_api/cli.py +120 -0
- kleinanzeigen_api/client.py +418 -0
- kleinanzeigen_api/data/categories.json +956 -0
- kleinanzeigen_api/py.typed +0 -0
- kleinanzeigen_api-0.1.0.dist-info/METADATA +214 -0
- kleinanzeigen_api-0.1.0.dist-info/RECORD +12 -0
- kleinanzeigen_api-0.1.0.dist-info/WHEEL +4 -0
- kleinanzeigen_api-0.1.0.dist-info/entry_points.txt +2 -0
- kleinanzeigen_api-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Unofficial Python client for the kleinanzeigen.de mobile JSON API.
|
|
2
|
+
|
|
3
|
+
This calls the real api.kleinanzeigen.de REST API used by the Android app of
|
|
4
|
+
Germany's Kleinanzeigen marketplace. It can search any category and returns
|
|
5
|
+
structured data the website doesn't show: GPS coordinates, exact result counts,
|
|
6
|
+
typed attributes, all image sizes, ISO timestamps and the price type.
|
|
7
|
+
|
|
8
|
+
Not affiliated with or endorsed by Kleinanzeigen GmbH / Adevinta. See the README
|
|
9
|
+
for the legal notes and rate-limiting advice.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from .categories import Category, all_categories, find_categories, get_category
|
|
14
|
+
from .client import KleinanzeigenAPI, Listing
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
__all__ = [
|
|
18
|
+
"KleinanzeigenAPI",
|
|
19
|
+
"Listing",
|
|
20
|
+
"Category",
|
|
21
|
+
"find_categories",
|
|
22
|
+
"all_categories",
|
|
23
|
+
"get_category",
|
|
24
|
+
"__version__",
|
|
25
|
+
]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Category catalog: look up categories, search by name, and convert names to ids.
|
|
2
|
+
|
|
3
|
+
All ~159 categories are stored in data/categories.json, so lookups work without
|
|
4
|
+
a network request. Use KleinanzeigenAPI.fetch_categories() to download an
|
|
5
|
+
updated list if the categories change on the site.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import asdict, dataclass
|
|
11
|
+
from functools import lru_cache
|
|
12
|
+
from importlib.resources import files
|
|
13
|
+
from typing import List, Optional
|
|
14
|
+
|
|
15
|
+
_DATA_FILE = "categories.json"
|
|
16
|
+
_CAT_NS = "{http://www.ebayclassifiedsgroup.com/schema/category/v1}categories"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Category:
|
|
21
|
+
id: str
|
|
22
|
+
name: str
|
|
23
|
+
path: str # e.g. "Auto, Rad & Boot > Fahrräder & Zubehör"
|
|
24
|
+
real_estate: bool = False
|
|
25
|
+
|
|
26
|
+
def to_dict(self) -> dict:
|
|
27
|
+
return asdict(self)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@lru_cache(maxsize=1)
|
|
31
|
+
def all_categories() -> List[Category]:
|
|
32
|
+
"""Return the bundled catalog as a list of Category objects (cached)."""
|
|
33
|
+
text = (files("kleinanzeigen_api") / "data" / _DATA_FILE).read_text(encoding="utf-8")
|
|
34
|
+
return [
|
|
35
|
+
Category(str(c["id"]), c["name"], c["path"], bool(c.get("real_estate")))
|
|
36
|
+
for c in json.loads(text)
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@lru_cache(maxsize=1)
|
|
41
|
+
def _by_id() -> dict:
|
|
42
|
+
return {c.id: c for c in all_categories()}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_category(category_id) -> Optional[Category]:
|
|
46
|
+
"""Return the Category with this id, or None if the id is not found."""
|
|
47
|
+
if category_id is None:
|
|
48
|
+
return None
|
|
49
|
+
return _by_id().get(str(category_id))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def find_categories(query: str, limit: int = 8) -> List[Category]:
|
|
53
|
+
"""Return up to `limit` categories that match `query`, best matches first.
|
|
54
|
+
|
|
55
|
+
Matches are ranked in this order: exact name, name starts with the query,
|
|
56
|
+
name contains the query, then path contains the query.
|
|
57
|
+
"""
|
|
58
|
+
q = (query or "").lower().strip()
|
|
59
|
+
if not q:
|
|
60
|
+
return []
|
|
61
|
+
scored = []
|
|
62
|
+
for c in all_categories():
|
|
63
|
+
name, path = c.name.lower(), c.path.lower()
|
|
64
|
+
if name == q:
|
|
65
|
+
s = 0
|
|
66
|
+
elif name.startswith(q):
|
|
67
|
+
s = 1
|
|
68
|
+
elif q in name:
|
|
69
|
+
s = 2
|
|
70
|
+
elif q in path:
|
|
71
|
+
s = 3
|
|
72
|
+
else:
|
|
73
|
+
continue
|
|
74
|
+
scored.append((s, len(c.name), c))
|
|
75
|
+
scored.sort(key=lambda x: (x[0], x[1]))
|
|
76
|
+
return [c for _, _, c in scored[:limit]]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def resolve_category(value) -> Optional[str]:
|
|
80
|
+
"""Convert a category name or id into an id string.
|
|
81
|
+
|
|
82
|
+
Rules:
|
|
83
|
+
- None or "" returns None, which means "search all categories".
|
|
84
|
+
- A number or numeric string is returned unchanged.
|
|
85
|
+
- A name is looked up. A case-insensitive exact match is used first,
|
|
86
|
+
otherwise the single best match from find_categories().
|
|
87
|
+
|
|
88
|
+
Raises ValueError if the name is unknown or matches more than one category.
|
|
89
|
+
The error message lists the possible matches.
|
|
90
|
+
"""
|
|
91
|
+
if value is None:
|
|
92
|
+
return None
|
|
93
|
+
s = str(value).strip()
|
|
94
|
+
if not s:
|
|
95
|
+
return None
|
|
96
|
+
if s.isdigit():
|
|
97
|
+
return s
|
|
98
|
+
|
|
99
|
+
exact = [c for c in all_categories() if c.name.lower() == s.lower()]
|
|
100
|
+
if len(exact) == 1:
|
|
101
|
+
return exact[0].id
|
|
102
|
+
if len(exact) > 1:
|
|
103
|
+
opts = ", ".join(f"{c.id} ({c.path})" for c in exact)
|
|
104
|
+
raise ValueError(
|
|
105
|
+
f"Category name {value!r} is ambiguous: {opts}. Pass the numeric id instead."
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
cands = find_categories(s, limit=6)
|
|
109
|
+
if len(cands) == 1:
|
|
110
|
+
return cands[0].id
|
|
111
|
+
if not cands:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"Unknown category {value!r}. Browse with find_categories({value!r}) "
|
|
114
|
+
f"or KleinanzeigenAPI().find_categories({value!r})."
|
|
115
|
+
)
|
|
116
|
+
opts = "; ".join(f"{c.name} (id {c.id})" for c in cands)
|
|
117
|
+
raise ValueError(
|
|
118
|
+
f"Ambiguous category {value!r}. Did you mean: {opts}? "
|
|
119
|
+
f"Pass an exact name or the numeric id."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def flatten_api_categories(payload: dict) -> List[dict]:
|
|
124
|
+
"""Convert a raw /api/categories.json response into the flat format used by
|
|
125
|
+
data/categories.json: [{"id", "name", "path", "real_estate"}, ...].
|
|
126
|
+
|
|
127
|
+
fetch_categories() uses this to rebuild the bundled file when categories are
|
|
128
|
+
added or renamed on the site.
|
|
129
|
+
"""
|
|
130
|
+
root = payload[_CAT_NS]
|
|
131
|
+
node = root.get("value", root) if isinstance(root, dict) else root
|
|
132
|
+
|
|
133
|
+
def name_of(cat: dict) -> str:
|
|
134
|
+
return ((cat.get("localized-name") or {}).get("value")
|
|
135
|
+
or (cat.get("id-name") or {}).get("value") or "")
|
|
136
|
+
|
|
137
|
+
out: List[dict] = []
|
|
138
|
+
|
|
139
|
+
def walk(cat: dict, parts: list, real_estate: bool) -> None:
|
|
140
|
+
nm = name_of(cat)
|
|
141
|
+
path_parts = parts + [nm] if nm else parts
|
|
142
|
+
cid = cat.get("id")
|
|
143
|
+
if cid: # skip the synthetic "Alle Kategorien" root (no id)
|
|
144
|
+
out.append({
|
|
145
|
+
"id": str(cid),
|
|
146
|
+
"name": nm,
|
|
147
|
+
"path": " > ".join(path_parts),
|
|
148
|
+
"real_estate": real_estate,
|
|
149
|
+
})
|
|
150
|
+
for child in cat.get("category", []) or []:
|
|
151
|
+
walk(child, path_parts, real_estate)
|
|
152
|
+
|
|
153
|
+
for alle in node.get("category", []) or []: # "Alle Kategorien"
|
|
154
|
+
for branch in alle.get("category", []) or []: # top-level branches
|
|
155
|
+
is_re = (branch.get("id-name") or {}).get("value") == "Immobilien"
|
|
156
|
+
walk(branch, [], is_re)
|
|
157
|
+
return out
|
kleinanzeigen_api/cli.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Command-line interface. Run as `kleinanzeigen-api` or `python -m kleinanzeigen_api`."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from .client import KleinanzeigenAPI, Listing
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _print_table(items: list) -> None:
|
|
12
|
+
"""Print the listings as a short text table."""
|
|
13
|
+
for l in items:
|
|
14
|
+
price = f"{int(l.price)} €" if l.price else (l.price_type or "—")
|
|
15
|
+
loc = f"{l.zip_code} {l.city}".strip() or "—"
|
|
16
|
+
extra = []
|
|
17
|
+
if l.rooms:
|
|
18
|
+
extra.append(f"{l.rooms:g} Zi")
|
|
19
|
+
if l.size_m2:
|
|
20
|
+
extra.append(f"{l.size_m2:g} m²")
|
|
21
|
+
nk = l.attributes.get("Warmmiete")
|
|
22
|
+
if nk:
|
|
23
|
+
extra.append(f"warm {nk}€")
|
|
24
|
+
tail = (" | " + " · ".join(extra)) if extra else ""
|
|
25
|
+
print(f"[{l.id}] {price:>9} | {loc}{tail}")
|
|
26
|
+
print(f" {l.title[:90]}")
|
|
27
|
+
print(f" {l.url}")
|
|
28
|
+
print()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main(argv=None) -> int:
|
|
32
|
+
"""Parse the command-line arguments, run the search, and print the results.
|
|
33
|
+
|
|
34
|
+
Returns the process exit code (0 on success, 2 on a bad argument or an
|
|
35
|
+
API error).
|
|
36
|
+
"""
|
|
37
|
+
p = argparse.ArgumentParser(
|
|
38
|
+
prog="kleinanzeigen-api",
|
|
39
|
+
description="Unofficial search client for kleinanzeigen.de (Germany). "
|
|
40
|
+
"Searches all categories by default.")
|
|
41
|
+
p.add_argument("location", nargs="?", help="city/region name or numeric location id")
|
|
42
|
+
p.add_argument("--category", default=None, metavar="NAME_OR_ID",
|
|
43
|
+
help="restrict to a category by NAME or id (default: all categories; "
|
|
44
|
+
"e.g. \"Fahrräder & Zubehör\" or 217). Browse with --categories.")
|
|
45
|
+
p.add_argument("--categories", nargs="?", const="", metavar="QUERY",
|
|
46
|
+
help="list category ids matching QUERY (or all of them) and exit")
|
|
47
|
+
p.add_argument("--distance", type=int, help="radius km")
|
|
48
|
+
p.add_argument("--min-price", type=int)
|
|
49
|
+
p.add_argument("--max-price", type=int)
|
|
50
|
+
p.add_argument("--min-rooms", type=float)
|
|
51
|
+
p.add_argument("--max-rooms", type=float)
|
|
52
|
+
p.add_argument("--min-size", type=float)
|
|
53
|
+
p.add_argument("--max-size", type=float)
|
|
54
|
+
p.add_argument("--q", help="keyword (server-side)")
|
|
55
|
+
p.add_argument("--exclude", action="append", metavar="TERM",
|
|
56
|
+
help="drop results containing TERM in title/description "
|
|
57
|
+
"(repeatable, or comma-separated; client-side)")
|
|
58
|
+
p.add_argument("--ad-type", choices=["offered", "wanted"], default="offered",
|
|
59
|
+
help="OFFERED listings (default) or WANTED ads")
|
|
60
|
+
p.add_argument("--pages", type=int, default=1)
|
|
61
|
+
p.add_argument("--size", type=int, default=25, help="results per page (max ~25)")
|
|
62
|
+
p.add_argument("--sort", choices=["new", "cheap", "expensive", "near"],
|
|
63
|
+
help="server-side sort: new=newest, cheap/expensive=price, near=distance")
|
|
64
|
+
p.add_argument("--sort-price", action="store_true", help="alias for --sort cheap")
|
|
65
|
+
p.add_argument("--rate", type=float, default=1.5, help="min seconds between requests")
|
|
66
|
+
p.add_argument("--json", action="store_true", help="emit JSON instead of a table")
|
|
67
|
+
p.add_argument("--out", help="write JSON to this file")
|
|
68
|
+
args = p.parse_args(argv)
|
|
69
|
+
|
|
70
|
+
# --categories: list categories from the bundled file and exit (no network)
|
|
71
|
+
if args.categories is not None:
|
|
72
|
+
from .categories import all_categories, find_categories
|
|
73
|
+
cats = find_categories(args.categories) if args.categories else all_categories()
|
|
74
|
+
for c in cats:
|
|
75
|
+
flag = " [real estate]" if c.real_estate else ""
|
|
76
|
+
print(f"{c.id:>5} {c.path}{flag}")
|
|
77
|
+
if args.categories and not cats:
|
|
78
|
+
print(f"no categories match {args.categories!r}", file=sys.stderr)
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
sort_map = {"new": "DATE_DESCENDING", "cheap": "PRICE_ASCENDING",
|
|
82
|
+
"expensive": "PRICE_DESCENDING", "near": "DISTANCE_ASCENDING"}
|
|
83
|
+
sort_type = sort_map.get(args.sort) or ("PRICE_ASCENDING" if args.sort_price else None)
|
|
84
|
+
|
|
85
|
+
exclude = []
|
|
86
|
+
for chunk in (args.exclude or []):
|
|
87
|
+
exclude.extend(part.strip() for part in chunk.split(",") if part.strip())
|
|
88
|
+
|
|
89
|
+
api = KleinanzeigenAPI(rate_limit=args.rate)
|
|
90
|
+
try:
|
|
91
|
+
items = api.search(
|
|
92
|
+
location=args.location, q=args.q, exclude=exclude or None,
|
|
93
|
+
category=args.category, distance_km=args.distance,
|
|
94
|
+
min_price=args.min_price, max_price=args.max_price,
|
|
95
|
+
min_rooms=args.min_rooms, max_rooms=args.max_rooms,
|
|
96
|
+
min_size=args.min_size, max_size=args.max_size,
|
|
97
|
+
ad_type=args.ad_type.upper(),
|
|
98
|
+
sort_type=sort_type, pages=args.pages, size=args.size)
|
|
99
|
+
except (ValueError, RuntimeError) as e:
|
|
100
|
+
# ValueError: unknown/ambiguous category or location.
|
|
101
|
+
# RuntimeError: rotated credentials (401/403) or network failure from the API.
|
|
102
|
+
print(f"error: {e}", file=sys.stderr)
|
|
103
|
+
return 2
|
|
104
|
+
|
|
105
|
+
if args.json or args.out:
|
|
106
|
+
text = json.dumps([l.to_dict() for l in items], ensure_ascii=False, indent=2)
|
|
107
|
+
if args.out:
|
|
108
|
+
with open(args.out, "w", encoding="utf-8") as fh:
|
|
109
|
+
fh.write(text)
|
|
110
|
+
print(f"wrote {len(items)} listings -> {args.out}", file=sys.stderr)
|
|
111
|
+
else:
|
|
112
|
+
print(text)
|
|
113
|
+
else:
|
|
114
|
+
_print_table(items)
|
|
115
|
+
print(f"{len(items)} listings", file=sys.stderr)
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
raise SystemExit(main())
|