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.
@@ -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,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -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
@@ -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())