feat: list online account ads (#1321)

## ℹ️ Description

Add a `list` command to inspect the authenticated account's online ads,
including IDs, titles, status and expiry dates. It fetches account pages
without loading local ad definitions or modifying listings.

Split from #1309 as the independent online-listing feature. No separate
issue.

## 📋 Changes Summary

- Register and dispatch `list`, and document it in CLI help and README.
- Fetch all account pages in strict mode so incomplete or malformed
pagination produces an error instead of a partial listing.
- Render translated labels/status and handle missing optional fields;
close the browser session on success or failure.
- Use English gettext source text and honor an explicitly requested help
language, addressing the CodeRabbit comment in #1309.
- Include the explicit-return and test-subclass fixes for the two CodeQL
comments from #1309.

No dependencies on the other contributions and no new project
dependencies.

### ⚙️ Type of Change

- [ ] 🐞 Bug fix
- [x] ✨ New feature
- [ ] 💥 Breaking change (changes that might break existing user setups,
scripts, or configurations)

## ✅ Checklist

- [x] I have reviewed my changes to ensure they meet the project's
standards.
- [x] I have tested the changed behavior with targeted standalone checks
and the combined-series `pdm run test` described below.
- [x] I have formatted the code (`pdm run format`).
- [x] I have verified that linting passes (`pdm run lint`).
- [x] I have updated documentation where necessary.

Standalone `pdm run format` and `pdm run lint` pass. Listing, CLI and
translation tests pass: 60 tests. The generated schema, default
configuration and README verification passes.

Full combined-series validation on upstream main `e4bca13` with Python
3.12 and Windows/Edge: `pdm run format`, `pdm run lint`, `pdm run test`
and generated-artifact verification pass; 1,976 tests passed, 2 skipped,
93.81% coverage. This full-suite evidence belongs to the integration of
all seven contributions. Standalone targeted evidence for this PR is
listed above. Browser fixtures and account responses are local or
mocked; tests do not contact Kleinanzeigen.

By submitting this pull request, I confirm that you can use, modify,
copy, and redistribute this contribution, under the terms of your
choice.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added a `list` command to view online ads with their ID, title,
status, and expiry date. The command does not download or modify
listings or local ad files.
* Added localized output in English and German, including messages for
empty lists.
* The command reports an error if it cannot retrieve the complete
listing.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Jens <1742418+1cu@users.noreply.github.com>
This commit is contained in:
Pommel4711andJens authored and GitHub committed 2026-10-06 11:17:54 +02:00
1 parent fc1b97774c
commit ab79e10c93
7 files changed
+295 -4

No files matched your search

+8
View File
@@ -31,6 +31,7 @@
- **Smart Republishing**: Automatically republish listings at configurable intervals to keep them at the top of search results
- **Bulk Management**: Update or delete multiple listings at once
- **Download Listings**: Download existing listings from your profile to local configuration files
- **List Online Listings**: View current account listings with IDs, status, and expiry dates
- **Extend Listings**: Extend ads close to expiry to keep watchers/savers and preserve the monthly ad quota
- **Browser Automation**: Uses Chromium-based browsers (Chrome, Edge, Chromium) for reliable automation
- **Flexible Configuration**: Configure defaults once, override per listing as needed
@@ -219,6 +220,7 @@ Commands:
create-config - creates a new default configuration file if one does not exist
diagnose - diagnoses browser connection issues and shows troubleshooting information
status - shows ad status and APR preview details
list - lists current online ads with ID, title, state, and expiry date
--
help - displays this help (default command)
version - displays the application version
@@ -263,6 +265,12 @@ Options:
```
<!-- readme-usage:generated:end -->
`kleinanzeigen-bot list` logs in and reads your current online listings, showing
each listing's ID, title, status, and expiry date when available. It does not
download listings or change them. In a source checkout, use `pdm run app list`.
This differs from `status`, which describes only local ad files. If the online
list cannot be fetched completely, `list` fails instead of showing a partial list.
`publish` and `update` process the remaining ads when an individual ad fails, then
exit with status `1` if any ads failed. Visible form-validation errors include the
field labels and messages; correct the affected ad configuration before retrying.
+11 -1
View File
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any, Final, cast
import certifi
from . import ad_loading, ad_status, delete_flow, download_flow, extend_flow, reserve_flow
from . import ad_loading, ad_status, delete_flow, download_flow, extend_flow, published_ads, reserve_flow
from . import login_flow as _login_flow
from . import publishing_workflow as _publishing_workflow
from . import runtime_config as _runtime_config
@@ -89,6 +89,7 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
return self._workspace_or_raise().state_dir / "update_check_state.json"
async def run(self, args:list[str]) -> None:
"""Parse CLI options, dispatch the requested command, and close browser resources."""
_cli = importlib.import_module("kleinanzeigen_bot.cli")
parsed = _cli.parse_args(args)
self.command = parsed.command
@@ -138,6 +139,8 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
self._handle_update_content_hash()
case "status":
self._handle_status()
case "list":
await self._handle_list()
case "publish":
await self._handle_publish()
case "update":
@@ -264,6 +267,13 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
output = ad_status.render_status_rows(rows, color = use_color)
print(output)
async def _handle_list(self) -> None:
"""Show current online ads without loading or changing local ad files."""
self._bootstrap_runtime()
await self._open_logged_in_browser()
ads = await published_ads.fetch_published_ads(self, self.root_url, strict = True)
print(published_ads.render_published_ads(ads))
async def _handle_publish(self) -> None:
self._bootstrap_runtime()
self._check_for_updates()
+11 -1
View File
@@ -19,6 +19,7 @@ import signal
import sys
import textwrap
from dataclasses import dataclass
from gettext import gettext as _
from pathlib import Path
from typing import TYPE_CHECKING, Final
@@ -139,8 +140,15 @@ def _help_executable() -> str:
def help_text(*, executable:str | None = None, language:str | None = None) -> str:
"""Render CLI help in the requested language without changing the current locale."""
exe = executable if executable is not None else _help_executable()
lang = language if language is not None else get_current_locale().language
current_locale = get_current_locale()
lang = language if language is not None else current_locale.language
try:
set_current_locale(Locale(lang))
list_description = _("lists current online ads with ID, title, state, and expiry date")
finally:
set_current_locale(current_locale)
if lang == "de":
return textwrap.dedent(
f"""\
@@ -162,6 +170,7 @@ def help_text(*, executable:str | None = None, language:str | None = None) -> st
create-config - Erstellt eine neue Standard-Konfigurationsdatei, falls noch nicht vorhanden
diagnose - Diagnostiziert Browser-Verbindungsprobleme und zeigt Troubleshooting-Informationen
status - Zeigt Anzeigenstatus und APR-Vorschau an
list - {list_description}
--
help - Zeigt diese Hilfe an (Standardbefehl)
version - Zeigt die Version der Anwendung an
@@ -226,6 +235,7 @@ def help_text(*, executable:str | None = None, language:str | None = None) -> st
create-config - creates a new default configuration file if one does not exist
diagnose - diagnoses browser connection issues and shows troubleshooting information
status - shows ad status and APR preview details
list - {list_description}
--
help - displays this help (default command)
version - displays the application version
+35 -1
View File
@@ -1,7 +1,7 @@
# SPDX-FileCopyrightText: © Jens Bergmann and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
"""Published ads fetching with API pagination."""
"""Published ads fetching with API pagination and online list display."""
import json
from gettext import gettext as _
@@ -16,6 +16,40 @@ type PublishedAd = dict[str, Any]
"""A raw published ad entry from the Kleinanzeigen manage-ads JSON API."""
def _published_ad_state_label(state:Any) -> str:
"""Translate known account states and preserve unknown or missing API values."""
match state:
case "active":
return _("active")
case "paused":
# The API reports a reserved ad as "paused" (see reserve_flow.STATE_RESERVED).
return _("reserved")
case "inactive":
return _("inactive")
return str(state) if state is not None else "-"
def render_published_ads(ads:list[PublishedAd]) -> str:
"""Render current account ads while preserving unknown states and server dates."""
if not ads:
return _("No online ads found.")
id_label = _("ID")
title_label = _("title")
status_label = _("status")
expiry_label = _("expires")
blocks = [
"\n".join([
f"{id_label}: {ad['id']}",
f" {title_label}: {ad.get('title') or '-'}",
f" {status_label}: {_published_ad_state_label(ad.get('state'))}",
f" {expiry_label}: {ad.get('endDate') or '-'}",
])
for ad in ads
]
return "\n\n".join(blocks)
def ad_matches_id(ad:PublishedAd, target_id:int | None) -> bool:
"""Check if a published ad matches the given target ID.
@@ -30,6 +30,9 @@ kleinanzeigen_bot/captcha_flow.py:
#################################################
kleinanzeigen_bot/cli.py:
#################################################
help_text:
"lists current online ads with ID, title, state, and expiry date": "Zeigt aktuelle Online-Anzeigen mit ID, Titel, Status und Ablaufdatum an"
parse_args:
"Use --help to display available options.": "Mit --help können die verfügbaren Optionen angezeigt werden."
"Unknown command: %s": "Unbekannter Befehl: %s"
@@ -240,6 +243,18 @@ kleinanzeigen_bot/publishing_workflow.py:
#################################################
kleinanzeigen_bot/published_ads.py:
#################################################
_published_ad_state_label:
"active": "aktiv"
"reserved": "reserviert"
"inactive": "inaktiv"
render_published_ads:
"No online ads found.": "Keine Online-Anzeigen gefunden."
"ID": "ID"
"title": "Titel"
"status": "Status"
"expires": "Ablaufdatum"
fetch_published_ads:
"Stopping pagination after %s pages to avoid infinite loop": "Stoppe die Seitenaufschaltung nach %s Seiten, um eine Endlosschleife zu vermeiden"
"Pagination request failed on page %s: %s": "Seitenabfrage auf Seite %s fehlgeschlagen: %s"
+1 -1
View File
@@ -36,7 +36,7 @@ WORKSPACE_FREE_COMMANDS:Final[frozenset[str]] = frozenset({"help", "version", "c
VALID_COMMANDS:Final[frozenset[str]] = frozenset({
"help", "version", "create-config", "diagnose", "verify",
"update-check", "update-content-hash",
"publish", "status", "update", "delete", "extend", "download",
"publish", "status", "list", "update", "delete", "extend", "download",
"reserve", "activate",
})
+214
View File
@@ -0,0 +1,214 @@
# SPDX-FileCopyrightText: © Pommel4711 and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
"""Read-only online listing tests using fake browser responses."""
from __future__ import annotations
import copy
import json
from typing import TYPE_CHECKING, Any
import pytest
from kleinanzeigen_bot import published_ads
from kleinanzeigen_bot.app import KleinanzeigenBot
from kleinanzeigen_bot.cli import help_text, parse_args
from kleinanzeigen_bot.published_ads import PublishedAdsFetchIncompleteError
from kleinanzeigen_bot.runtime_config import VALID_COMMANDS
from kleinanzeigen_bot.utils import i18n
if TYPE_CHECKING:
from collections.abc import Iterable
from pathlib import Path
from kleinanzeigen_bot.model.ad_model import Ad
pytestmark = pytest.mark.unit
type PageResponse = dict[str, Any] | TimeoutError
class FakeListBot(KleinanzeigenBot):
"""Exercise the real CLI dispatch and pagination without real browser or file loading."""
def __init__(self, responses:list[PageResponse]) -> None:
"""Initialize canned API responses and track the read-only session lifecycle."""
super().__init__()
self.responses = responses
self.requests:list[str] = []
self.bootstrapped = False
self.logged_in = False
self.closed = False
def _bootstrap_runtime(self) -> None:
"""Record runtime initialization before simulated account access."""
self.bootstrapped = True
def _check_for_updates(self) -> None:
"""Fail if listing attempts an unrelated update-check request."""
pytest.fail("Online listing must not perform an update-check request")
async def _open_logged_in_browser(self) -> None:
"""Record simulated login only after runtime initialization."""
assert self.bootstrapped
self.logged_in = True
async def close_browser_session(self) -> None:
"""Record browser cleanup and clear the simulated login state."""
self.closed = True
self.logged_in = False
async def web_request(
self,
url:str,
method:str = "GET",
valid_response_codes:int | Iterable[int] = 200,
headers:dict[str, str] | None = None,
) -> Any:
"""Return canned pages while rejecting unexpected requests or account mutations."""
assert self.logged_in, "Online listing requires an authenticated session"
assert method == "GET", "Online listing must not mutate account data"
expected_page = len(self.requests) + 1
assert url == f"{self.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT&pageNum={expected_page}"
self.requests.append(url)
response = self.responses[expected_page - 1]
if isinstance(response, TimeoutError):
raise response
return response
def load_ads(self, *, ignore_inactive:bool = True, exclude_ads_with_id:bool = True) -> list[tuple[str, Ad, dict[str, Any]]]:
"""Fail if online listing reads local ad definitions."""
pytest.fail("Online listing must not load local ads")
def _response(ads:list[published_ads.PublishedAd], page:int = 1, last:int = 1) -> dict[str, Any]:
"""Encode one account page with API-style pagination metadata."""
paging:dict[str, int] = {"pageNum": page, "last": last}
if page < last:
paging["next"] = page + 1
return {"content": json.dumps({"ads": ads, "paging": paging})}
def _snapshot_files(directory:Path) -> dict[Path, bytes]:
"""Capture workspace contents to detect accidental local changes."""
return {path.relative_to(directory): path.read_bytes() for path in directory.rglob("*") if path.is_file()}
def _list_args(tmp_path:Path) -> list[str]:
"""Build CLI arguments using an isolated portable workspace."""
return ["app", "list", "--config", str(tmp_path / "config.yaml"), "--workspace-mode", "portable"]
def test_list_is_accepted_and_documented() -> None:
"""Expose the list command in argument parsing and both supported help languages."""
assert "list" in VALID_COMMANDS
assert parse_args(["app", "list"]).command == "list"
assert "list -" in help_text(language = "en")
assert "list -" in help_text(language = "de")
@pytest.mark.parametrize("initial_language", ["en", "de"])
@pytest.mark.parametrize(("language", "description"), [
("en", "lists current online ads with ID, title, state, and expiry date"),
("de", "Zeigt aktuelle Online-Anzeigen mit ID, Titel, Status und Ablaufdatum an"),
])
def test_list_help_honors_explicit_language_and_preserves_locale(initial_language:str, language:str, description:str) -> None:
"""Honor the requested help language without changing the caller locale."""
locale = i18n.Locale(initial_language, "TEST")
i18n.set_current_locale(locale)
assert f"list - {description}" in help_text(language = language)
assert i18n.get_current_locale() == locale
@pytest.mark.asyncio
async def test_list_shows_every_online_page_without_local_or_account_mutations(tmp_path:Path, capsys:pytest.CaptureFixture[str]) -> None:
"""List all account pages while preserving local files and fetched account data."""
(tmp_path / "config.yaml").write_text("configuration sentinel\n", encoding = "utf-8")
(tmp_path / "car.yaml").write_text("id: 999\ntitle: Local car sentinel\n", encoding = "utf-8")
(tmp_path / "car.jpg").write_bytes(b"image sentinel")
files_before = _snapshot_files(tmp_path)
ads:list[published_ads.PublishedAd] = [
{"id": 123, "title": "Audi A3", "state": "active", "endDate": "05.10.2026"},
{"id": "456", "title": "VW Golf", "state": "paused", "endDate": "06.10.2026"},
{"id": 789, "title": "Opel Corsa", "state": "inactive", "endDate": "07.10.2026"},
]
responses:list[PageResponse] = [_response(ads[:2], last = 2), _response(ads[2:], page = 2, last = 2)]
responses_before = copy.deepcopy(responses)
bot = FakeListBot(responses)
await bot.run(_list_args(tmp_path))
output = capsys.readouterr().out
assert output == (
"ID: 123\n title: Audi A3\n status: active\n expires: 05.10.2026\n\n"
"ID: 456\n title: VW Golf\n status: reserved\n expires: 06.10.2026\n\n"
"ID: 789\n title: Opel Corsa\n status: inactive\n expires: 07.10.2026\n"
)
assert len(bot.requests) == 2
assert bot.bootstrapped
assert bot.closed
assert not bot.logged_in
assert responses == responses_before
assert _snapshot_files(tmp_path) == files_before
@pytest.mark.asyncio
async def test_list_reports_empty_account_and_closes_browser(tmp_path:Path, capsys:pytest.CaptureFixture[str]) -> None:
"""Report an empty account and clean up the simulated browser session."""
bot = FakeListBot([_response([])])
await bot.run(_list_args(tmp_path))
assert capsys.readouterr().out == "No online ads found.\n"
assert len(bot.requests) == 1
assert bot.closed
assert not bot.logged_in
@pytest.mark.asyncio
@pytest.mark.parametrize("responses", [
[_response([{"id": 123, "title": "Missing state"}])],
[{"content": json.dumps({"ads": [{"id": 123, "state": "active"}]})}],
[_response([{"id": 123, "state": "active", "title": "Incomplete car"}], last = 2), {"content": "invalid JSON"}],
[_response([{"id": 123, "state": "active", "title": "Incomplete car"}], last = 2), TimeoutError("fake second page timeout")],
])
async def test_list_rejects_malformed_or_partial_results_and_closes_browser(
responses:list[PageResponse], tmp_path:Path, capsys:pytest.CaptureFixture[str],
) -> None:
"""Reject incomplete account responses without emitting a misleading partial list."""
bot = FakeListBot(responses)
with pytest.raises(PublishedAdsFetchIncompleteError):
await bot.run(_list_args(tmp_path))
assert not capsys.readouterr().out, "Partial account results must not look like a successful complete listing"
assert bot.closed
assert not bot.logged_in
@pytest.mark.parametrize("optional_fields", [{}, {"title": None, "endDate": None}, {"title": "", "endDate": ""}])
@pytest.mark.parametrize(("state", "state_label"), [("under_review", "under_review"), (None, "-")])
def test_render_list_preserves_unknown_status_and_handles_missing_optional_fields(
optional_fields:dict[str, Any], state:str | None, state_label:str,
) -> None:
"""Render missing fields and unknown statuses without modifying the supplied ad."""
ad:published_ads.PublishedAd = {"id": 123, "state": state, **optional_fields}
ad_before = copy.deepcopy(ad)
assert published_ads.render_published_ads([ad]) == f"ID: 123\n title: -\n status: {state_label}\n expires: -"
assert ad == ad_before
@pytest.mark.parametrize(("language", "expected", "empty_expected"), [
("en", "ID: 123\n title: Audi A3\n status: reserved\n expires: 05.10.2026", "No online ads found."),
("de", "ID: 123\n Titel: Audi A3\n Status: reserviert\n Ablaufdatum: 05.10.2026", "Keine Online-Anzeigen gefunden."),
])
def test_render_list_translates_labels_status_and_empty_account(language:str, expected:str, empty_expected:str) -> None:
"""Translate account-list labels, reservation status, and the empty-account message."""
i18n.set_current_locale(i18n.Locale(language))
ads:list[published_ads.PublishedAd] = [{"id": 123, "title": "Audi A3", "state": "paused", "endDate": "05.10.2026"}]
assert published_ads.render_published_ads(ads) == expected
assert published_ads.render_published_ads([]) == empty_expected