From 2d0317d97cc918321782118f102f2660de31b646 Mon Sep 17 00:00:00 2001 From: Gokce Date: Fri, 7 Aug 2026 18:46:54 +0300 Subject: [PATCH] release 1.5.0 --- CHANGELOG.md | 18 +- README.md | 14 +- docs/mcp.md | 6 +- docs/python-sdk.md | 30 +- eksiapi/__init__.py | 3 +- eksiapi/async_client.py | 167 +- eksiapi/client.py | 166 +- eksiapi/mcp/server.py | 44 +- eksiapi/models.py | 52 + openapi.yaml | 381 ++- .../eksi-sozluk-api.postman_collection.json | 2086 ++++++++++++++--- pyproject.toml | 2 +- tests/test_client.py | 10 +- tests/test_mcp_interactive.py | 4 +- tests/test_mcp_server.py | 9 +- tests/test_openapi_public_docs.py | 4 +- tests/test_stage3_client.py | 113 +- uv.lock | 2 +- 18 files changed, 2622 insertions(+), 489 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48791a1..b2c5315 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +## [1.5.0] - 2026-08-07 + +### Added + +- 🧭 Başlık terimlerini numeric topic ID'lerine çözen `query_topic`, doğrudan topic sayfalarını okuyan `topic` ve typed `Topic` modeli eklendi. +- 🔎 Global başlık araması, topic içi basit ve gelişmiş entry araması, query/nick autocomplete ve canlı debe akışı sync/async SDK'ya eklendi. +- 🖥️ TUI ve diğer adaptörlerin `today`, `popular`, `debe` ve hesap oturumlu `agenda` akışlarını tek girişten çağırabilmesi için `feed()` eklendi. +- 🤖 MCP'ye topic çözümleme ve autocomplete araçları eklendi; feed aracı debe desteği kazandı. + +### Fixed + +- 🛠️ Çalışmayan varsayımsal topic, entry search ve autocomplete rotaları APK 2.4.10 Retrofit sözleşmeleri ve canlı anonim testlerle doğrulanan rotalarla değiştirildi. +- 🗞️ `agenda()` gerçek `/v2/index/olay/` rotasına taşındı ve bu akışın hesap oturumu gerektirdiği belgelendi. +- 📚 OpenAPI ve Postman koleksiyonu yeni doğrulanmış endpoint yapısıyla eşitlendi. + ## [1.4.0] - 2026-08-07 ### Added @@ -79,7 +94,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Python 3.10–3.14 CI, coverage gate, clean wheel installation checks, and linting. - Trusted Publishing workflows for TestPyPI and PyPI, artifact attestations, and GitHub Releases. -[Unreleased]: https://github.com/agmmnn/eksiapi/compare/v1.4.0...HEAD +[Unreleased]: https://github.com/agmmnn/eksiapi/compare/v1.5.0...HEAD +[1.5.0]: https://github.com/agmmnn/eksiapi/compare/v1.4.0...v1.5.0 [1.4.0]: https://github.com/agmmnn/eksiapi/compare/v1.3.0...v1.4.0 [1.3.0]: https://github.com/agmmnn/eksiapi/compare/v1.2.0...v1.3.0 [1.2.0]: https://github.com/agmmnn/eksiapi/compare/v1.1.0...v1.2.0 diff --git a/README.md b/README.md index beedce3..30cdaab 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,8 @@ from eksiapi import EksiClient with EksiClient.anonymous(raw_response=False) as eksi: today = eksi.today() popular = eksi.popular() + debe = eksi.debe() + python = eksi.topic_entries("python") profile = eksi.user("agmmnn") ``` @@ -152,6 +154,15 @@ async with AsyncEksiClient.anonymous(raw_response=False) as eksi: [Authentication, responses, pagination, writes and async usage →](./docs/python-sdk.md) +## 🖥️ Terminal UI + +[`eksitui`](https://github.com/agmmnn/eksitui) is the separate keyboard-focused terminal interface for browsing Ekşi Sözlük. It includes feeds, search, entry pagination, themes and mouse support. + +```bash +uv tool install eksitui +eksi +``` + ## 🟠 Postman 1. [Open the public collection](https://documenter.getpostman.com/view/24047519/2sBY4VLHxb) and select **Run in Postman**. @@ -168,13 +179,14 @@ The collection generates the required authentication values and stores the sessi | Python application or script | Sync/async SDK | `pip install eksiapi` | | Read access for an AI agent | Read-only MCP | `uv tool install "eksiapi[mcp]"` | | Account actions from an AI agent | Interactive MCP | `eksiapi mcp --mode interactive` | +| Terminal application | Textual TUI | `uv tool install eksitui` | | HTTP route reference | Postman collection | [Public API documentation](https://documenter.getpostman.com/view/24047519/2sBY4VLHxb) | ## ✨ Features | Feature | Included | | --------------- | ----------------------------------------------------------------------------------------------------------------------- | -| 🔎 API coverage | Today and popular feeds, topic and entry search, profiles, comments, channels, user history and pagination | +| 🔎 API coverage | Today, popular and debe feeds, topic resolution and entry search, profiles, comments, channels, user history and pagination | | 🐍 Python SDK | Matching sync and async clients, typed views, safe-read retries, token refresh, rate-limit metadata and test transports | | 🤖 MCP server | Structured results, canonical source URLs, bounded topic research and read-only anonymous access | | 🛡️ Write safety | Deterministic dry runs, no automatic write retries, secret-free audit events and human-approved MCP execution | diff --git a/docs/mcp.md b/docs/mcp.md index 409aa53..b98855b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -152,13 +152,15 @@ EKSI_MCP_MIN_INTERVAL=0.35 These tools are available in both modes: - `eksi_search_topics` -- `eksi_search_entries` +- `eksi_resolve_topic` +- `eksi_autocomplete` +- `eksi_search_entries` (requires a numeric topic id) - `eksi_get_topic_entries` - `eksi_get_entry` - `eksi_get_user` - `eksi_get_user_entries` - `eksi_get_user_favorites` -- `eksi_get_feed` (`today`, `popular`, or `agenda`) +- `eksi_get_feed` (`today`, `popular`, `agenda`, or `debe`) - `eksi_get_account_summary` - `eksi_get_notifications` - `eksi_get_channels` diff --git a/docs/python-sdk.md b/docs/python-sdk.md index 35c3d30..b303680 100644 --- a/docs/python-sdk.md +++ b/docs/python-sdk.md @@ -70,7 +70,7 @@ with EksiClient.anonymous(raw_response=False) as eksi: data = eksi.entry(1) ``` -Typed helpers include `entry_typed()`, `me_typed()` and `page()`. Existing dictionary-returning methods remain available. +Typed helpers include `entry_typed()`, `topic_typed()`, `me_typed()` and `page()`. Existing dictionary-returning methods remain available. ## Common reads @@ -78,13 +78,20 @@ Typed helpers include `entry_typed()`, `me_typed()` and `page()`. Existing dicti # Feeds and search eksi.today(page=1) eksi.popular(page=1) -eksi.agenda(page=1) +eksi.agenda(page=1) # logged-in accounts +eksi.debe(page=1) +eksi.feed("today", page=1) eksi.search_topics("python", page=1) -eksi.search_entries("python", page=1) +eksi.autocomplete("pyth") # Topics, entries and comments eksi.entry(1) +result = eksi.query_topic("python") +eksi.topic(109286, page=1) +eksi.topic(109286, page=1, action="popular") eksi.topic_entries("python", page=1) +eksi.search_entries(109286, "asyncio", page=1) +eksi.search_entries_advanced(109286, {"Author": "ssg"}, page=1) eksi.comments(1, page=1, size=20) eksi.entry_likes(1) eksi.entry_favorites(1) @@ -105,12 +112,25 @@ eksi.trash(page=1) See [`openapi.yaml`](../openapi.yaml) for the complete documented route inventory and request shapes. +## Application and TUI adapters + +The high-level read methods keep application adapters small. `feed()` handles named lists, `resolve_topic_id()` turns a title or slug into an ID, `topic()` reads a numeric topic and `entry()` reads a numeric entry. + +```python +topics = eksi.feed("today", page=1) +topic_id = eksi.resolve_topic_id("python") +topic = eksi.topic(topic_id, page=1) +entry = eksi.entry(1) +``` + +`topic_entries()` accepts either a numeric ID or a title/slug. Passing a title performs topic resolution once before reading the requested page, and the pagination iterator reuses the resolved ID across pages. + ## Pagination Use a typed page view when you want metadata: ```python -payload = eksi.topic_entries("python", page=1) +payload = eksi.topic_entries(109286, page=1) page = eksi.page(payload) print(page.items, page.has_more) ``` @@ -118,7 +138,7 @@ print(page.items, page.has_more) Or stream bounded pages: ```python -for entry in eksi.iter_topic_entries("python", max_pages=3): +for entry in eksi.iter_topic_entries(109286, max_pages=3): print(entry) ``` diff --git a/eksiapi/__init__.py b/eksiapi/__init__.py index 0ec33cc..40d027a 100644 --- a/eksiapi/__init__.py +++ b/eksiapi/__init__.py @@ -11,7 +11,7 @@ EksiRateLimitError, EksiTransportError, ) -from .models import Entry, Page, RateLimitInfo, User, WritePreview, WriteResult +from .models import Entry, Page, RateLimitInfo, Topic, User, WritePreview, WriteResult from .transport import AsyncMockSession, MockResponse, MockSession, RetryPolicy try: @@ -35,6 +35,7 @@ "Page", "RateLimitInfo", "RetryPolicy", + "Topic", "User", "WritePreview", "WriteResult", diff --git a/eksiapi/async_client.py b/eksiapi/async_client.py index 1529e6c..5a4b2cf 100644 --- a/eksiapi/async_client.py +++ b/eksiapi/async_client.py @@ -11,7 +11,13 @@ from curl_cffi import requests from .auth import generate_api_secret -from .client import BASE, DEFAULT_FINGERPRINT, _positive, _required_text +from .client import ( + BASE, + DEFAULT_FINGERPRINT, + _positive, + _required_text, + _topic_id_from_query, +) from .config import AndroidFingerprint from .errors import EksiApiError, EksiAuthenticationError, EksiTransportError from .formatting import unwrap_response @@ -22,6 +28,7 @@ Page, RateLimitInfo, TokenInfo, + Topic, User, WritePreview, WriteResult, @@ -359,14 +366,57 @@ async def entry_typed(self, entry_id: int) -> Entry: payload = ApiResponse.from_payload(await self.entry(entry_id)).data return Entry.from_mapping(payload if isinstance(payload, Mapping) else {}) - async def topic_entries(self, topic_slug: str, page: int = 1) -> Any: + async def query_topic(self, term: str) -> Any: + """Resolve a title, slug or URL-like term using the app's topic router.""" return await self._get( - "/v2/entry/entriesbytopic", - params={ - "title": _required_text(topic_slug, "topic_slug", maximum=200), - "p": page, - }, + "/v2/topic/query/", + params={"term": _required_text(term, "term", maximum=500)}, + ) + + async def resolve_topic_id(self, term: str) -> int: + """Resolve a title or slug directly to a numeric topic id.""" + return _topic_id_from_query(await self.query_topic(term)) + + async def topic( + self, + topic_id: int, + page: int = 1, + *, + action: Literal["popular", "today"] | None = None, + ) -> Any: + """Read a topic page, optionally filtered to popular or today's entries.""" + topic_id = _positive(topic_id, "topic_id") + path = f"/v2/topic/{topic_id}" + if action is not None: + path += f"/{action}" + return await self._get(path, params={"p": _positive(page, "page")}) + + async def topic_typed( + self, + topic_id: int, + page: int = 1, + *, + action: Literal["popular", "today"] | None = None, + ) -> Topic: + payload = ApiResponse.from_payload( + await self.topic(topic_id, page, action=action) + ).data + return Topic.from_mapping(payload if isinstance(payload, Mapping) else {}) + + async def topic_entries(self, topic: int | str, page: int = 1) -> Any: + """Read entries by numeric topic id or resolve a title/slug first.""" + topic_id = ( + topic if isinstance(topic, int) else await self.resolve_topic_id(topic) ) + return await self.topic(_positive(topic_id, "topic"), page=page) + + async def topic_popular(self, topic_id: int, page: int = 1) -> Any: + """Read a topic's popular entries.""" + return await self.topic(topic_id, page=page, action="popular") + + async def topic_today(self, topic_id: int, page: int = 1) -> Any: + """Read entries added to a topic today.""" + return await self.topic(topic_id, page=page, action="today") async def user_entries(self, nick: str, page: int = 1) -> Any: nick = quote(_required_text(nick, "nick", maximum=60), safe="") @@ -381,14 +431,15 @@ def page(self, payload: Any, *, page: int = 1) -> Page[Any]: return Page.from_payload(payload, page=page) async def iter_topic_entries( - self, topic_slug: str, *, start_page: int = 1, max_pages: int | None = None + self, topic: int | str, *, start_page: int = 1, max_pages: int | None = None ) -> AsyncIterator[Any]: + topic_id = ( + topic if isinstance(topic, int) else await self.resolve_topic_id(topic) + ) current = _positive(start_page, "start_page") fetched = 0 while max_pages is None or fetched < max_pages: - page = Page.from_payload( - await self.topic_entries(topic_slug, current), page=current - ) + page = Page.from_payload(await self.topic(topic_id, current), page=current) for item in page.items: yield item fetched += 1 @@ -426,33 +477,95 @@ async def today(self, page: int = 1) -> Any: return await self._get("/v2/index/today", params={"p": page}) async def agenda(self, page: int = 1) -> Any: - return await self._get("/v2/entry/agenda", params={"p": page}) + """Return the account-only olay/agenda feed.""" + return await self._get("/v2/index/olay/", params={"p": _positive(page, "page")}) async def filter_channels(self) -> Any: return await self._get("/v2/index/getfilterchannels") - async def search_topics(self, query: str, page: int = 1) -> Any: - return await self._get( - "/v2/topic/search", - params={ - "searchTerm": _required_text(query, "query", maximum=200), - "p": page, + async def debe(self, page: int = 1) -> Any: + """Return yesterday's most-liked entries feed.""" + return await self._get("/v2/index/debe/", params={"p": _positive(page, "page")}) + + async def feed( + self, + kind: Literal["today", "popular", "debe", "agenda"], + page: int = 1, + *, + channel_filters: list[str] | None = None, + ) -> Any: + """Read a named feed through one adapter-friendly method.""" + if kind == "popular": + return await self.popular(page=page, channel_filters=channel_filters) + if channel_filters: + raise ValueError("channel_filters can only be used with the popular feed") + if kind == "today": + return await self.today(page=page) + if kind == "debe": + return await self.debe(page=page) + return await self.agenda(page=page) + + async def search_topics( + self, + query: str, + page: int = 1, + *, + sort_order: int = 1, + favorited_only: bool = False, + nice_only: bool = False, + ) -> Any: + """Search topic titles through the Android app's index search.""" + return await self._post( + "/v2/index/search/", + params={"p": _positive(page, "page")}, + json_body={ + "Keywords": _required_text(query, "query", maximum=200), + "SortOrder": sort_order, + "FavoritedOnly": favorited_only, + "NiceOnly": nice_only, }, + retryable=True, ) - async def search_entries(self, query: str, page: int = 1) -> Any: - return await self._get( - "/v2/entry/search", - params={ - "searchTerm": _required_text(query, "query", maximum=200), - "p": page, + async def search_entries(self, topic_id: int, query: str, page: int = 1) -> Any: + """Search entry bodies inside one topic.""" + return await self._post( + "/v2/topic/search", + params={"p": _positive(page, "page")}, + json_body={ + "TopicId": _positive(topic_id, "topic_id"), + "Keywords": _required_text(query, "query", maximum=200), }, + retryable=True, + ) + + async def search_entries_advanced( + self, topic_id: int, filters: Mapping[str, Any], page: int = 1 + ) -> Any: + """Run the app's advanced entry search inside one topic.""" + body = dict(filters) + body["TopicId"] = _positive(topic_id, "topic_id") + return await self._post( + "/v2/topic/search/advanced", + params={"p": _positive(page, "page")}, + json_body=body, + retryable=True, ) async def autocomplete(self, query: str) -> Any: - return await self._get( - "/v2/topic/autocomplete", - params={"searchTerm": _required_text(query, "query", maximum=200)}, + """Return title, query and nick suggestions for a partial term.""" + return await self._post( + "/v2/autocomplete/query", + form_body={"Term": _required_text(query, "query", maximum=200)}, + retryable=True, + ) + + async def autocomplete_nicks(self, query: str) -> Any: + """Return nick suggestions for a partial term.""" + return await self._post( + "/v2/autocomplete/nick", + form_body={"Term": _required_text(query, "query", maximum=200)}, + retryable=True, ) async def notification_count(self) -> Any: diff --git a/eksiapi/client.py b/eksiapi/client.py index ba120a4..109b052 100644 --- a/eksiapi/client.py +++ b/eksiapi/client.py @@ -28,6 +28,7 @@ Page, RateLimitInfo, TokenInfo, + Topic, User, WritePreview, WriteResult, @@ -61,6 +62,19 @@ def _positive(value: int, label: str) -> int: return value +def _topic_id_from_query(payload: Any) -> int: + """Extract a topic id from raw or unwrapped topic-query responses.""" + current = payload + if isinstance(current, Mapping) and isinstance(current.get("Data"), Mapping): + current = current["Data"] + if isinstance(current, Mapping) and isinstance(current.get("QueryData"), Mapping): + current = current["QueryData"] + value = current.get("TopicId") if isinstance(current, Mapping) else None + if not isinstance(value, int | str) or not str(value).isdigit(): + raise EksiApiError("Ekşi API could not resolve the requested topic") + return int(value) + + class EksiClient: """Sync client supporting public reads and authenticated account actions.""" @@ -404,15 +418,56 @@ def entry_typed(self, entry_id: int) -> Entry: payload = ApiResponse.from_payload(self.entry(entry_id)).data return Entry.from_mapping(payload if isinstance(payload, Mapping) else {}) - def topic_entries(self, topic_slug: str, page: int = 1) -> Any: + def query_topic(self, term: str) -> Any: + """Resolve a title, slug or URL-like term using the app's topic router.""" return self._get( - "/v2/entry/entriesbytopic", - params={ - "title": _required_text(topic_slug, "topic_slug", maximum=200), - "p": page, - }, + "/v2/topic/query/", + params={"term": _required_text(term, "term", maximum=500)}, ) + def resolve_topic_id(self, term: str) -> int: + """Resolve a title or slug directly to a numeric topic id.""" + return _topic_id_from_query(self.query_topic(term)) + + def topic( + self, + topic_id: int, + page: int = 1, + *, + action: Literal["popular", "today"] | None = None, + ) -> Any: + """Read a topic page, optionally filtered to popular or today's entries.""" + topic_id = _positive(topic_id, "topic_id") + path = f"/v2/topic/{topic_id}" + if action is not None: + path += f"/{action}" + return self._get(path, params={"p": _positive(page, "page")}) + + def topic_typed( + self, + topic_id: int, + page: int = 1, + *, + action: Literal["popular", "today"] | None = None, + ) -> Topic: + payload = ApiResponse.from_payload( + self.topic(topic_id, page, action=action) + ).data + return Topic.from_mapping(payload if isinstance(payload, Mapping) else {}) + + def topic_entries(self, topic: int | str, page: int = 1) -> Any: + """Read entries by numeric topic id or resolve a title/slug first.""" + topic_id = topic if isinstance(topic, int) else self.resolve_topic_id(topic) + return self.topic(_positive(topic_id, "topic"), page=page) + + def topic_popular(self, topic_id: int, page: int = 1) -> Any: + """Read a topic's popular entries.""" + return self.topic(topic_id, page=page, action="popular") + + def topic_today(self, topic_id: int, page: int = 1) -> Any: + """Read entries added to a topic today.""" + return self.topic(topic_id, page=page, action="today") + def user_entries(self, nick: str, page: int = 1) -> Any: nick = quote(_required_text(nick, "nick", maximum=60), safe="") return self._get(f"/v2/user/{nick}/entries", params={"p": page}) @@ -425,10 +480,11 @@ def page(self, payload: Any, *, page: int = 1) -> Page[Any]: return Page.from_payload(payload, page=page) def iter_topic_entries( - self, topic_slug: str, *, start_page: int = 1, max_pages: int | None = None + self, topic: int | str, *, start_page: int = 1, max_pages: int | None = None ) -> Iterator[Any]: + topic_id = topic if isinstance(topic, int) else self.resolve_topic_id(topic) yield from self._iterate_pages( - lambda page: self.topic_entries(topic_slug, page=page), + lambda page: self.topic(topic_id, page=page), start_page, max_pages, ) @@ -465,33 +521,95 @@ def today(self, page: int = 1) -> Any: return self._get("/v2/index/today", params={"p": page}) def agenda(self, page: int = 1) -> Any: - return self._get("/v2/entry/agenda", params={"p": page}) + """Return the account-only olay/agenda feed.""" + return self._get("/v2/index/olay/", params={"p": _positive(page, "page")}) def filter_channels(self) -> Any: return self._get("/v2/index/getfilterchannels") - def search_topics(self, query: str, page: int = 1) -> Any: - return self._get( - "/v2/topic/search", - params={ - "searchTerm": _required_text(query, "query", maximum=200), - "p": page, + def debe(self, page: int = 1) -> Any: + """Return yesterday's most-liked entries feed.""" + return self._get("/v2/index/debe/", params={"p": _positive(page, "page")}) + + def feed( + self, + kind: Literal["today", "popular", "debe", "agenda"], + page: int = 1, + *, + channel_filters: list[str] | None = None, + ) -> Any: + """Read a named feed through one adapter-friendly method.""" + if kind == "popular": + return self.popular(page=page, channel_filters=channel_filters) + if channel_filters: + raise ValueError("channel_filters can only be used with the popular feed") + if kind == "today": + return self.today(page=page) + if kind == "debe": + return self.debe(page=page) + return self.agenda(page=page) + + def search_topics( + self, + query: str, + page: int = 1, + *, + sort_order: int = 1, + favorited_only: bool = False, + nice_only: bool = False, + ) -> Any: + """Search topic titles through the Android app's index search.""" + return self._post( + "/v2/index/search/", + params={"p": _positive(page, "page")}, + json_body={ + "Keywords": _required_text(query, "query", maximum=200), + "SortOrder": sort_order, + "FavoritedOnly": favorited_only, + "NiceOnly": nice_only, }, + retryable=True, ) def autocomplete(self, query: str) -> Any: - return self._get( - "/v2/topic/autocomplete", - params={"searchTerm": _required_text(query, "query", maximum=200)}, + """Return title, query and nick suggestions for a partial term.""" + return self._post( + "/v2/autocomplete/query", + form_body={"Term": _required_text(query, "query", maximum=200)}, + retryable=True, ) - def search_entries(self, query: str, page: int = 1) -> Any: - return self._get( - "/v2/entry/search", - params={ - "searchTerm": _required_text(query, "query", maximum=200), - "p": page, + def autocomplete_nicks(self, query: str) -> Any: + """Return nick suggestions for a partial term.""" + return self._post( + "/v2/autocomplete/nick", + form_body={"Term": _required_text(query, "query", maximum=200)}, + retryable=True, + ) + + def search_entries(self, topic_id: int, query: str, page: int = 1) -> Any: + """Search entry bodies inside one topic.""" + return self._post( + "/v2/topic/search", + params={"p": _positive(page, "page")}, + json_body={ + "TopicId": _positive(topic_id, "topic_id"), + "Keywords": _required_text(query, "query", maximum=200), }, + retryable=True, + ) + + def search_entries_advanced( + self, topic_id: int, filters: Mapping[str, Any], page: int = 1 + ) -> Any: + """Run the app's advanced entry search inside one topic.""" + body = dict(filters) + body["TopicId"] = _positive(topic_id, "topic_id") + return self._post( + "/v2/topic/search/advanced", + params={"p": _positive(page, "page")}, + json_body=body, + retryable=True, ) def notification_count(self) -> Any: diff --git a/eksiapi/mcp/server.py b/eksiapi/mcp/server.py index 6054ed7..fafdfbd 100644 --- a/eksiapi/mcp/server.py +++ b/eksiapi/mcp/server.py @@ -31,6 +31,7 @@ Query = Annotated[str, Field(min_length=1, max_length=200)] Nick = Annotated[str, Field(min_length=1, max_length=60)] EntryId = Annotated[int, Field(gt=0)] +TopicId = Annotated[int, Field(gt=0)] READ_ONLY = ToolAnnotations( readOnlyHint=True, @@ -204,11 +205,33 @@ def eksi_search_topics(query: Query, page: Page = 1) -> ToolResponse: ) @server.tool(annotations=READ_ONLY) - def eksi_search_entries(query: Query, page: Page = 1) -> ToolResponse: - """Search Ekşi Sözlük entry bodies matching a query.""" + def eksi_resolve_topic(term: Query) -> ToolResponse: + """Resolve a topic title, slug or URL-like term to its numeric topic id.""" + term = _clean(term, "term") + return ToolResponse( + data=service.call("resolve_topic_id", term), + source_url=f"https://eksisozluk.com/?q={quote(term, safe='')}", + ) + + @server.tool(annotations=READ_ONLY) + def eksi_autocomplete( + query: Query, kind: Literal["query", "nick"] = "query" + ) -> ToolResponse: + """Get title/query suggestions or nick suggestions for a partial term.""" query = _clean(query, "query") + method = "autocomplete" if kind == "query" else "autocomplete_nicks" return ToolResponse( - data=service.call("search_entries", query, page=page), + data=service.call(method, query), source_url="https://eksisozluk.com/" + ) + + @server.tool(annotations=READ_ONLY) + def eksi_search_entries( + topic_id: TopicId, query: Query, page: Page = 1 + ) -> ToolResponse: + """Search entry bodies inside one numeric topic id.""" + query = _clean(query, "query") + return ToolResponse( + data=service.call("search_entries", topic_id, query, page=page), source_url=f"https://eksisozluk.com/entry/ara?q={quote(query, safe='')}", page=page, ) @@ -262,11 +285,11 @@ def eksi_get_user_favorites(nick: Nick, page: Page = 1) -> ToolResponse: @server.tool(annotations=READ_ONLY) def eksi_get_feed( - feed: Literal["today", "popular", "agenda"], + feed: Literal["today", "popular", "agenda", "debe"], page: Page = 1, channel_filters: list[str] | None = None, ) -> ToolResponse: - """Get the today, popular, or agenda feed; channel filters apply to popular.""" + """Get the today, popular, agenda or debe feed; channel filters apply to popular.""" if feed != "popular" and channel_filters: raise ValueError("channel_filters can only be used with the popular feed") if channel_filters and len(channel_filters) > 20: @@ -274,15 +297,18 @@ def eksi_get_feed( if feed == "popular": data = service.call( - "popular", page=page, channel_filters=channel_filters or [] + "feed", feed, page=page, channel_filters=channel_filters or [] ) url = f"https://eksisozluk.com/basliklar/populer?p={page}" elif feed == "today": - data = service.call("today", page=page) + data = service.call("feed", feed, page=page) url = f"https://eksisozluk.com/basliklar/gundem?p={page}" - else: - data = service.call("agenda", page=page) + elif feed == "agenda": + data = service.call("feed", feed, page=page) url = f"https://eksisozluk.com/basliklar/olay?p={page}" + else: + data = service.call("feed", feed, page=page) + url = f"https://eksisozluk.com/debe?p={page}" return ToolResponse(data=data, source_url=url, page=page) @server.tool(annotations=READ_ONLY) diff --git a/eksiapi/models.py b/eksiapi/models.py index c5ff837..41c2562 100644 --- a/eksiapi/models.py +++ b/eksiapi/models.py @@ -138,6 +138,50 @@ def from_mapping(cls, data: Mapping[str, Any]) -> Entry: ) +@dataclass(frozen=True, slots=True) +class Topic: + """Normalized topic page returned by the Android API.""" + + id: int | None + title: str | None + slug: str | None + entries: tuple[Entry, ...] + page: int | None + page_count: int | None + raw: Mapping[str, Any] = field(repr=False) + + @classmethod + def from_mapping(cls, data: Mapping[str, Any]) -> Topic: + def parsed(*names: str) -> int | None: + value = _pick(data, *names) + return ( + int(value) + if isinstance(value, int | str) and str(value).isdigit() + else None + ) + + raw_entries = _pick(data, "Entries", "entries", default=[]) + entries = ( + tuple( + Entry.from_mapping(item) + for item in raw_entries + if isinstance(item, Mapping) + ) + if isinstance(raw_entries, Sequence) + and not isinstance(raw_entries, str | bytes | bytearray) + else () + ) + return cls( + id=parsed("Id", "id", "TopicId", "topicId"), + title=_pick(data, "Title", "title"), + slug=_pick(data, "Slug", "slug"), + entries=entries, + page=parsed("PageIndex", "pageIndex", "Page", "page"), + page_count=parsed("PageCount", "pageCount", "TotalPages", "totalPages"), + raw=data, + ) + + @dataclass(frozen=True, slots=True) class User: nick: str | None @@ -193,6 +237,8 @@ def parsed(*names: str) -> int | None: _ITEM_KEYS = ( + "Topics", + "topics", "Entries", "entries", "EntryList", @@ -205,6 +251,12 @@ def parsed(*names: str) -> int | None: "messages", "Results", "results", + "DebeItems", + "debeItems", + "Comments", + "comments", + "Users", + "users", ) diff --git a/openapi.yaml b/openapi.yaml index a3453de..991abe1 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -846,85 +846,14 @@ paths: $ref: '#/components/responses/RateLimited' '404': $ref: '#/components/responses/NotFound' - /v2/entry/entriesbytopic: - get: - tags: - - Entries - summary: Entries for a topic - operationId: entriesForATopic - description: Entries for a topic. An anonymous or account bearer token and the Client-Secret header are required. - x-eksi-auth-mode: anonymous - parameters: - - name: title - in: query - required: true - schema: - type: string - description: Topic slug or URL-encoded title - example: python - - name: p - in: query - schema: - type: integer - default: 1 - description: Page number starting at 1. - example: 1 - - $ref: '#/components/parameters/ClientSecretHeader' - responses: - '200': - description: Paginated entries for topic - content: - application/json: - schema: - $ref: '#/components/schemas/EntryTopicResponse' - '401': - $ref: '#/components/responses/Unauthorized' - '429': - $ref: '#/components/responses/RateLimited' - /v2/entry/search: - get: - tags: - - Entries - summary: Search entries - operationId: searchEntries - description: Search entries. An anonymous or account bearer token and the Client-Secret header are required. - x-eksi-auth-mode: anonymous - parameters: - - name: searchTerm - in: query - required: true - schema: - type: string - description: Text to search for. - example: python - - name: p - in: query - schema: - type: integer - default: 1 - description: Page number starting at 1. - example: 1 - - $ref: '#/components/parameters/ClientSecretHeader' - responses: - '200': - description: Search results - content: - application/json: - schema: - $ref: '#/components/schemas/ApiResponse' - - '401': - $ref: '#/components/responses/Unauthorized' - '429': - $ref: '#/components/responses/RateLimited' - /v2/entry/agenda: + /v2/index/olay/: get: tags: - Entries summary: Agenda entries operationId: listAgendaEntries - description: Agenda entries. An anonymous or account bearer token and the Client-Secret header are required. - x-eksi-auth-mode: anonymous + description: Agenda entries. A logged-in account bearer token and the Client-Secret header are required. + x-eksi-auth-mode: account parameters: - name: p in: query @@ -1065,65 +994,295 @@ paths: schema: $ref: '#/components/schemas/ApiResponse' - # ── Topics / Search ───────────────────────────────────────────────────────── - '401': $ref: '#/components/responses/Unauthorized' '429': $ref: '#/components/responses/RateLimited' - /v2/topic/search: - get: + /v2/index/search/: + post: tags: - Topics - summary: Search topics + summary: Search topic titles operationId: searchTopics - description: Search topics. An anonymous or account bearer token and the Client-Secret header are required. + description: Searches topic titles using the Android app's index search. x-eksi-auth-mode: anonymous parameters: - - name: searchTerm + - name: p in: query - required: true schema: - type: string - description: Text to search for. - example: python + type: integer + default: 1 + minimum: 1 + - $ref: '#/components/parameters/ClientSecretHeader' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [Keywords] + properties: + Keywords: + type: string + SortOrder: + type: integer + default: 1 + FavoritedOnly: + type: boolean + default: false + NiceOnly: + type: boolean + default: false + example: + Keywords: python + SortOrder: 1 + FavoritedOnly: false + NiceOnly: false + responses: + '200': + description: Paginated topic search results + content: + application/json: + schema: + $ref: '#/components/schemas/TopicListResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '429': + $ref: '#/components/responses/RateLimited' + /v2/index/debe/: + get: + tags: + - Feeds + summary: Yesterday's most-liked entries + operationId: listDebeEntries + description: Returns the live debe feed exposed by the Android app. + x-eksi-auth-mode: anonymous + parameters: - name: p in: query schema: type: integer default: 1 - description: Page number starting at 1. - example: 1 + minimum: 1 - $ref: '#/components/parameters/ClientSecretHeader' responses: '200': - description: Topic search results + description: Debe feed content: application/json: schema: $ref: '#/components/schemas/ApiResponse' - '401': $ref: '#/components/responses/Unauthorized' '429': $ref: '#/components/responses/RateLimited' - /v2/topic/autocomplete: + + # ── Topics / Search ───────────────────────────────────────────────────────── + + /v2/topic/query/: get: tags: - Topics - summary: Topic autocomplete suggestions - operationId: topicAutocompleteSuggestions - description: Topic autocomplete suggestions. An anonymous or account bearer token and the Client-Secret header are required. + summary: Resolve a topic term + operationId: resolveTopic + description: Resolves a title, slug or URL-like term to the topic router result used by the Android app. x-eksi-auth-mode: anonymous parameters: - - name: searchTerm + - name: term in: query required: true schema: type: string - description: Text to search for. example: python - $ref: '#/components/parameters/ClientSecretHeader' + responses: + '200': + description: Topic routing result containing QueryData.TopicId when a topic matches + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '429': + $ref: '#/components/responses/RateLimited' + /v2/topic/{topicId}: + get: + tags: + - Topics + summary: Read a topic page + operationId: getTopic + description: Returns topic metadata and one page of entries. + x-eksi-auth-mode: anonymous + parameters: + - name: topicId + in: path + required: true + schema: + type: integer + minimum: 1 + example: 109286 + - name: p + in: query + schema: + type: integer + default: 1 + minimum: 1 + - $ref: '#/components/parameters/ClientSecretHeader' + responses: + '200': + description: Topic metadata and paginated entries + content: + application/json: + schema: + $ref: '#/components/schemas/EntryTopicResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/RateLimited' + /v2/topic/{topicId}/{actionName}: + get: + tags: + - Topics + summary: Read a filtered topic page + operationId: getFilteredTopic + description: Returns popular or today's entries for a topic. + x-eksi-auth-mode: anonymous + parameters: + - name: topicId + in: path + required: true + schema: + type: integer + minimum: 1 + - name: actionName + in: path + required: true + schema: + type: string + enum: [popular, today] + - name: p + in: query + schema: + type: integer + default: 1 + minimum: 1 + - $ref: '#/components/parameters/ClientSecretHeader' + responses: + '200': + description: Filtered topic entries + content: + application/json: + schema: + $ref: '#/components/schemas/EntryTopicResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '429': + $ref: '#/components/responses/RateLimited' + /v2/topic/search: + post: + tags: + - Entries + summary: Search entries inside a topic + operationId: searchTopicEntries + description: Searches entry bodies within one numeric topic id. + x-eksi-auth-mode: anonymous + parameters: + - name: p + in: query + schema: + type: integer + default: 1 + minimum: 1 + - $ref: '#/components/parameters/ClientSecretHeader' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [TopicId, Keywords] + properties: + TopicId: + type: integer + minimum: 1 + Keywords: + type: string + example: + TopicId: 109286 + Keywords: python + responses: + '200': + description: Matching entries and topic metadata + content: + application/json: + schema: + $ref: '#/components/schemas/EntryTopicResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '429': + $ref: '#/components/responses/RateLimited' + /v2/topic/search/advanced: + post: + tags: + - Entries + summary: Advanced search inside a topic + operationId: advancedSearchTopicEntries + description: Runs the Android app's advanced entry filters within one topic. + x-eksi-auth-mode: anonymous + parameters: + - name: p + in: query + schema: + type: integer + default: 1 + minimum: 1 + - $ref: '#/components/parameters/ClientSecretHeader' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [TopicId] + additionalProperties: true + properties: + TopicId: + type: integer + minimum: 1 + responses: + '200': + description: Matching entries and topic metadata + content: + application/json: + schema: + $ref: '#/components/schemas/EntryTopicResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '429': + $ref: '#/components/responses/RateLimited' + /v2/autocomplete/query: + post: + tags: + - Topics + summary: Autocomplete topics and queries + operationId: autocompleteQuery + description: Returns title, query and nick suggestions for a partial term. + x-eksi-auth-mode: anonymous + parameters: + - $ref: '#/components/parameters/ClientSecretHeader' + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + required: [Term] + properties: + Term: + type: string responses: '200': description: Autocomplete suggestions @@ -1131,7 +1290,37 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiResponse' - + '401': + $ref: '#/components/responses/Unauthorized' + '429': + $ref: '#/components/responses/RateLimited' + /v2/autocomplete/nick: + post: + tags: + - Profiles + summary: Autocomplete nicknames + operationId: autocompleteNicks + description: Returns nickname suggestions for a partial term. + x-eksi-auth-mode: anonymous + parameters: + - $ref: '#/components/parameters/ClientSecretHeader' + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + required: [Term] + properties: + Term: + type: string + responses: + '200': + description: Nickname suggestions + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' '401': $ref: '#/components/responses/Unauthorized' '429': diff --git a/postman/eksi-sozluk-api.postman_collection.json b/postman/eksi-sozluk-api.postman_collection.json index 5e7b2a4..87303fc 100644 --- a/postman/eksi-sozluk-api.postman_collection.json +++ b/postman/eksi-sozluk-api.postman_collection.json @@ -2146,23 +2146,34 @@ } }, { - "name": "Available channels", + "name": "Yesterday's most-liked entries", "request": { - "name": "Available channels", + "name": "Yesterday's most-liked entries", "description": { - "content": "Available channels. An anonymous or account bearer token and the Client-Secret header are required.", + "content": "Returns the live debe feed exposed by the Android app.", "type": "text/plain" }, "url": { "path": [ "v2", - "channel", - "list" + "index", + "debe", + "" ], "host": [ "{{baseUrl}}" ], - "query": [], + "query": [ + { + "disabled": true, + "description": { + "content": "", + "type": "text/plain" + }, + "key": "p", + "value": "1" + } + ], "variable": [] }, "header": [ @@ -2186,18 +2197,29 @@ }, "response": [ { - "name": "Channel list", + "name": "Debe feed", "originalRequest": { "url": { "path": [ "v2", - "channel", - "list" + "index", + "debe", + "" ], "host": [ "{{baseUrl}}" ], - "query": [], + "query": [ + { + "disabled": true, + "description": { + "content": "", + "type": "text/plain" + }, + "key": "p", + "value": "1" + } + ], "variable": [] }, "header": [ @@ -2226,7 +2248,7 @@ "value": "application/json" } ], - "body": "{\n \"Success\": true,\n \"Message\": null,\n \"Data\": {\n \"AllChannels\": [\n {\n \"Id\": 1,\n \"Name\": \"gundem\",\n \"DisplayName\": \"Gündem\",\n \"Description\": \"\",\n \"Priority\": 1,\n \"IsPublic\": true\n }\n ]\n }\n}", + "body": "{\n \"Success\": true,\n \"Data\": \"\",\n \"Message\": null,\n \"StatusCode\": 200\n}", "cookie": [], "_postman_previewlanguage": "json" }, @@ -2236,13 +2258,24 @@ "url": { "path": [ "v2", - "channel", - "list" + "index", + "debe", + "" ], "host": [ "{{baseUrl}}" ], - "query": [], + "query": [ + { + "disabled": true, + "description": { + "content": "", + "type": "text/plain" + }, + "key": "p", + "value": "1" + } + ], "variable": [] }, "header": [ @@ -2281,13 +2314,24 @@ "url": { "path": [ "v2", - "channel", - "list" + "index", + "debe", + "" ], "host": [ "{{baseUrl}}" ], - "query": [], + "query": [ + { + "disabled": true, + "description": { + "content": "", + "type": "text/plain" + }, + "key": "p", + "value": "1" + } + ], "variable": [] }, "header": [ @@ -2336,33 +2380,23 @@ } }, { - "name": "Read the offline debe feed", + "name": "Available channels", "request": { - "name": "Read the offline debe feed", + "name": "Available channels", "description": { - "content": "Read the offline debe feed. An anonymous or account bearer token and the Client-Secret header are required.", + "content": "Available channels. An anonymous or account bearer token and the Client-Secret header are required.", "type": "text/plain" }, "url": { "path": [ "v2", - "index", - "offlinedebe" + "channel", + "list" ], "host": [ "{{baseUrl}}" ], - "query": [ - { - "disabled": true, - "description": { - "content": "Return records starting from this date.", - "type": "text/plain" - }, - "key": "fromDate", - "value": "2026-08-07" - } - ], + "query": [], "variable": [] }, "header": [ @@ -2386,28 +2420,18 @@ }, "response": [ { - "name": "Debe entries", + "name": "Channel list", "originalRequest": { "url": { "path": [ "v2", - "index", - "offlinedebe" + "channel", + "list" ], "host": [ "{{baseUrl}}" ], - "query": [ - { - "disabled": true, - "description": { - "content": "Return records starting from this date.", - "type": "text/plain" - }, - "key": "fromDate", - "value": "2026-08-07" - } - ], + "query": [], "variable": [] }, "header": [ @@ -2436,7 +2460,7 @@ "value": "application/json" } ], - "body": "{\n \"Success\": true,\n \"Data\": \"\",\n \"Message\": null,\n \"StatusCode\": 200\n}", + "body": "{\n \"Success\": true,\n \"Message\": null,\n \"Data\": {\n \"AllChannels\": [\n {\n \"Id\": 1,\n \"Name\": \"gundem\",\n \"DisplayName\": \"Gündem\",\n \"Description\": \"\",\n \"Priority\": 1,\n \"IsPublic\": true\n }\n ]\n }\n}", "cookie": [], "_postman_previewlanguage": "json" }, @@ -2446,23 +2470,13 @@ "url": { "path": [ "v2", - "index", - "offlinedebe" + "channel", + "list" ], "host": [ "{{baseUrl}}" ], - "query": [ - { - "disabled": true, - "description": { - "content": "Return records starting from this date.", - "type": "text/plain" - }, - "key": "fromDate", - "value": "2026-08-07" - } - ], + "query": [], "variable": [] }, "header": [ @@ -2501,23 +2515,13 @@ "url": { "path": [ "v2", - "index", - "offlinedebe" + "channel", + "list" ], "host": [ "{{baseUrl}}" ], - "query": [ - { - "disabled": true, - "description": { - "content": "Return records starting from this date.", - "type": "text/plain" - }, - "key": "fromDate", - "value": "2026-08-07" - } - ], + "query": [], "variable": [] }, "header": [ @@ -2564,48 +2568,33 @@ "protocolProfileBehavior": { "disableBodyPruning": true } - } - ] - }, - { - "name": "Topics", - "description": "Topic search, autocomplete, metadata and follow state.", - "item": [ + }, { - "name": "Search topics", + "name": "Read the offline debe feed", "request": { - "name": "Search topics", + "name": "Read the offline debe feed", "description": { - "content": "Search topics. An anonymous or account bearer token and the Client-Secret header are required.", + "content": "Read the offline debe feed. An anonymous or account bearer token and the Client-Secret header are required.", "type": "text/plain" }, "url": { "path": [ "v2", - "topic", - "search" + "index", + "offlinedebe" ], "host": [ "{{baseUrl}}" ], "query": [ - { - "disabled": false, - "description": { - "content": "(Required) Text to search for.", - "type": "text/plain" - }, - "key": "searchTerm", - "value": "python" - }, { "disabled": true, "description": { - "content": "Page number starting at 1.", + "content": "Return records starting from this date.", "type": "text/plain" }, - "key": "p", - "value": "1" + "key": "fromDate", + "value": "2026-08-07" } ], "variable": [] @@ -2631,35 +2620,26 @@ }, "response": [ { - "name": "Topic search results", + "name": "Debe entries", "originalRequest": { "url": { "path": [ "v2", - "topic", - "search" + "index", + "offlinedebe" ], "host": [ "{{baseUrl}}" ], "query": [ - { - "disabled": false, - "description": { - "content": "(Required) Text to search for.", - "type": "text/plain" - }, - "key": "searchTerm", - "value": "python" - }, { "disabled": true, "description": { - "content": "Page number starting at 1.", + "content": "Return records starting from this date.", "type": "text/plain" }, - "key": "p", - "value": "1" + "key": "fromDate", + "value": "2026-08-07" } ], "variable": [] @@ -2700,30 +2680,21 @@ "url": { "path": [ "v2", - "topic", - "search" + "index", + "offlinedebe" ], "host": [ "{{baseUrl}}" ], "query": [ - { - "disabled": false, - "description": { - "content": "(Required) Text to search for.", - "type": "text/plain" - }, - "key": "searchTerm", - "value": "python" - }, { "disabled": true, "description": { - "content": "Page number starting at 1.", + "content": "Return records starting from this date.", "type": "text/plain" }, - "key": "p", - "value": "1" + "key": "fromDate", + "value": "2026-08-07" } ], "variable": [] @@ -2764,30 +2735,21 @@ "url": { "path": [ "v2", - "topic", - "search" + "index", + "offlinedebe" ], "host": [ "{{baseUrl}}" ], "query": [ - { - "disabled": false, - "description": { - "content": "(Required) Text to search for.", - "type": "text/plain" - }, - "key": "searchTerm", - "value": "python" - }, { "disabled": true, "description": { - "content": "Page number starting at 1.", + "content": "Return records starting from this date.", "type": "text/plain" }, - "key": "p", - "value": "1" + "key": "fromDate", + "value": "2026-08-07" } ], "variable": [] @@ -2836,33 +2798,40 @@ "protocolProfileBehavior": { "disableBodyPruning": true } - }, + } + ] + }, + { + "name": "Topics", + "description": "Topic search, autocomplete, metadata and follow state.", + "item": [ { - "name": "Topic autocomplete suggestions", + "name": "Search topic titles", "request": { - "name": "Topic autocomplete suggestions", + "name": "Search topic titles", "description": { - "content": "Topic autocomplete suggestions. An anonymous or account bearer token and the Client-Secret header are required.", + "content": "Searches topic titles using the Android app's index search.", "type": "text/plain" }, "url": { "path": [ "v2", - "topic", - "autocomplete" + "index", + "search", + "" ], "host": [ "{{baseUrl}}" ], "query": [ { - "disabled": false, + "disabled": true, "description": { - "content": "(Required) Text to search for.", + "content": "", "type": "text/plain" }, - "key": "searchTerm", - "value": "python" + "key": "p", + "value": "1" } ], "variable": [] @@ -2877,37 +2846,51 @@ "key": "Client-Secret", "value": "{{clientSecret}}" }, + { + "key": "Content-Type", + "value": "application/json" + }, { "key": "Accept", "value": "application/json" } ], - "method": "GET", - "body": {}, + "method": "POST", + "body": { + "mode": "raw", + "raw": "{\n \"Keywords\": \"python\",\n \"SortOrder\": 1,\n \"FavoritedOnly\": false,\n \"NiceOnly\": false\n}", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + } + }, "auth": null }, "response": [ { - "name": "Autocomplete suggestions", + "name": "Paginated topic search results", "originalRequest": { "url": { "path": [ "v2", - "topic", - "autocomplete" + "index", + "search", + "" ], "host": [ "{{baseUrl}}" ], "query": [ { - "disabled": false, + "disabled": true, "description": { - "content": "(Required) Text to search for.", + "content": "", "type": "text/plain" }, - "key": "searchTerm", - "value": "python" + "key": "p", + "value": "1" } ], "variable": [] @@ -2922,13 +2905,26 @@ "key": "Client-Secret", "value": "{{clientSecret}}" }, + { + "key": "Content-Type", + "value": "application/json" + }, { "key": "Accept", "value": "application/json" } ], - "method": "GET", - "body": {} + "method": "POST", + "body": { + "mode": "raw", + "raw": "{\n \"Keywords\": \"python\",\n \"SortOrder\": 1,\n \"FavoritedOnly\": false,\n \"NiceOnly\": false\n}", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + } + } }, "status": "OK", "code": 200, @@ -2938,18 +2934,223 @@ "value": "application/json" } ], - "body": "{\n \"Success\": true,\n \"Data\": \"\",\n \"Message\": null,\n \"StatusCode\": 200\n}", + "body": "{\n \"Success\": true,\n \"Data\": {\n \"Topics\": [\n {\n \"TopicId\": 123,\n \"Title\": \"example topic\",\n \"MatchedCount\": 24,\n \"FullCount\": 1888,\n \"Day\": \"string\"\n },\n {\n \"TopicId\": 123,\n \"Title\": \"example topic\",\n \"MatchedCount\": 24,\n \"FullCount\": 1888,\n \"Day\": \"string\"\n }\n ],\n \"PageCount\": 1,\n \"PageSize\": 50,\n \"PageIndex\": 1\n },\n \"Message\": null,\n \"StatusCode\": 200\n}", "cookie": [], "_postman_previewlanguage": "json" }, { "name": "The bearer token, Client-Secret or authentication flow is missing or invalid.", + "originalRequest": { + "url": { + "path": [ + "v2", + "index", + "search", + "" + ], + "host": [ + "{{baseUrl}}" + ], + "query": [ + { + "disabled": true, + "description": { + "content": "", + "type": "text/plain" + }, + "key": "p", + "value": "1" + } + ], + "variable": [] + }, + "header": [ + { + "disabled": false, + "description": { + "content": "(Required) Per-session client UUID. Use a local Postman variable and never publish a real value.", + "type": "text/plain" + }, + "key": "Client-Secret", + "value": "{{clientSecret}}" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "POST", + "body": { + "mode": "raw", + "raw": "{\n \"Keywords\": \"python\",\n \"SortOrder\": 1,\n \"FavoritedOnly\": false,\n \"NiceOnly\": false\n}", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + } + } + }, + "status": "Unauthorized", + "code": 401, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": "{\n \"Success\": false,\n \"Message\": \"Authorization has been denied for this request.\",\n \"Data\": null,\n \"StatusCode\": 401\n}", + "cookie": [], + "_postman_previewlanguage": "json" + }, + { + "name": "The upstream API rate limit was reached.", + "originalRequest": { + "url": { + "path": [ + "v2", + "index", + "search", + "" + ], + "host": [ + "{{baseUrl}}" + ], + "query": [ + { + "disabled": true, + "description": { + "content": "", + "type": "text/plain" + }, + "key": "p", + "value": "1" + } + ], + "variable": [] + }, + "header": [ + { + "disabled": false, + "description": { + "content": "(Required) Per-session client UUID. Use a local Postman variable and never publish a real value.", + "type": "text/plain" + }, + "key": "Client-Secret", + "value": "{{clientSecret}}" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "POST", + "body": { + "mode": "raw", + "raw": "{\n \"Keywords\": \"python\",\n \"SortOrder\": 1,\n \"FavoritedOnly\": false,\n \"NiceOnly\": false\n}", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + } + } + }, + "status": "Too Many Requests", + "code": 429, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "disabled": true, + "description": { + "content": "Seconds to wait before another safe read.", + "type": "text/plain" + }, + "key": "Retry-After", + "value": "60" + } + ], + "body": "{\n \"Success\": false,\n \"Message\": \"Too many requests.\",\n \"Data\": null,\n \"StatusCode\": 429\n}", + "cookie": [], + "_postman_previewlanguage": "json" + } + ], + "event": [], + "protocolProfileBehavior": { + "disableBodyPruning": true + } + }, + { + "name": "Resolve a topic term", + "request": { + "name": "Resolve a topic term", + "description": { + "content": "Resolves a title, slug or URL-like term to the topic router result used by the Android app.", + "type": "text/plain" + }, + "url": { + "path": [ + "v2", + "topic", + "query", + "" + ], + "host": [ + "{{baseUrl}}" + ], + "query": [ + { + "disabled": false, + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "key": "term", + "value": "python" + } + ], + "variable": [] + }, + "header": [ + { + "disabled": false, + "description": { + "content": "(Required) Per-session client UUID. Use a local Postman variable and never publish a real value.", + "type": "text/plain" + }, + "key": "Client-Secret", + "value": "{{clientSecret}}" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "body": {}, + "auth": null + }, + "response": [ + { + "name": "Topic routing result containing QueryData.TopicId when a topic matches", "originalRequest": { "url": { "path": [ "v2", "topic", - "autocomplete" + "query", + "" ], "host": [ "{{baseUrl}}" @@ -2958,10 +3159,10 @@ { "disabled": false, "description": { - "content": "(Required) Text to search for.", + "content": "(Required) ", "type": "text/plain" }, - "key": "searchTerm", + "key": "term", "value": "python" } ], @@ -2985,26 +3186,27 @@ "method": "GET", "body": {} }, - "status": "Unauthorized", - "code": 401, + "status": "OK", + "code": 200, "header": [ { "key": "Content-Type", "value": "application/json" } ], - "body": "{\n \"Success\": false,\n \"Message\": \"Authorization has been denied for this request.\",\n \"Data\": null,\n \"StatusCode\": 401\n}", + "body": "{\n \"Success\": true,\n \"Data\": \"\",\n \"Message\": null,\n \"StatusCode\": 200\n}", "cookie": [], "_postman_previewlanguage": "json" }, { - "name": "The upstream API rate limit was reached.", + "name": "The bearer token, Client-Secret or authentication flow is missing or invalid.", "originalRequest": { "url": { "path": [ "v2", "topic", - "autocomplete" + "query", + "" ], "host": [ "{{baseUrl}}" @@ -3013,10 +3215,10 @@ { "disabled": false, "description": { - "content": "(Required) Text to search for.", + "content": "(Required) ", "type": "text/plain" }, - "key": "searchTerm", + "key": "term", "value": "python" } ], @@ -3037,8 +3239,980 @@ "value": "application/json" } ], - "method": "GET", - "body": {} + "method": "GET", + "body": {} + }, + "status": "Unauthorized", + "code": 401, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": "{\n \"Success\": false,\n \"Message\": \"Authorization has been denied for this request.\",\n \"Data\": null,\n \"StatusCode\": 401\n}", + "cookie": [], + "_postman_previewlanguage": "json" + }, + { + "name": "The upstream API rate limit was reached.", + "originalRequest": { + "url": { + "path": [ + "v2", + "topic", + "query", + "" + ], + "host": [ + "{{baseUrl}}" + ], + "query": [ + { + "disabled": false, + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "key": "term", + "value": "python" + } + ], + "variable": [] + }, + "header": [ + { + "disabled": false, + "description": { + "content": "(Required) Per-session client UUID. Use a local Postman variable and never publish a real value.", + "type": "text/plain" + }, + "key": "Client-Secret", + "value": "{{clientSecret}}" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "body": {} + }, + "status": "Too Many Requests", + "code": 429, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "disabled": true, + "description": { + "content": "Seconds to wait before another safe read.", + "type": "text/plain" + }, + "key": "Retry-After", + "value": "60" + } + ], + "body": "{\n \"Success\": false,\n \"Message\": \"Too many requests.\",\n \"Data\": null,\n \"StatusCode\": 429\n}", + "cookie": [], + "_postman_previewlanguage": "json" + } + ], + "event": [], + "protocolProfileBehavior": { + "disableBodyPruning": true + } + }, + { + "name": "Read a topic page", + "request": { + "name": "Read a topic page", + "description": { + "content": "Returns topic metadata and one page of entries.", + "type": "text/plain" + }, + "url": { + "path": [ + "v2", + "topic", + ":topicId" + ], + "host": [ + "{{baseUrl}}" + ], + "query": [ + { + "disabled": true, + "description": { + "content": "", + "type": "text/plain" + }, + "key": "p", + "value": "1" + } + ], + "variable": [ + { + "type": "any", + "value": "109286", + "key": "topicId", + "disabled": false, + "description": { + "content": "(Required) ", + "type": "text/plain" + } + } + ] + }, + "header": [ + { + "disabled": false, + "description": { + "content": "(Required) Per-session client UUID. Use a local Postman variable and never publish a real value.", + "type": "text/plain" + }, + "key": "Client-Secret", + "value": "{{clientSecret}}" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "body": {}, + "auth": null + }, + "response": [ + { + "name": "Topic metadata and paginated entries", + "originalRequest": { + "url": { + "path": [ + "v2", + "topic", + ":topicId" + ], + "host": [ + "{{baseUrl}}" + ], + "query": [ + { + "disabled": true, + "description": { + "content": "", + "type": "text/plain" + }, + "key": "p", + "value": "1" + } + ], + "variable": [ + { + "disabled": false, + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "type": "any", + "value": "109286", + "key": "topicId" + } + ] + }, + "header": [ + { + "disabled": false, + "description": { + "content": "(Required) Per-session client UUID. Use a local Postman variable and never publish a real value.", + "type": "text/plain" + }, + "key": "Client-Secret", + "value": "{{clientSecret}}" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "body": {} + }, + "status": "OK", + "code": 200, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": "{\n \"Success\": true,\n \"Data\": {\n \"Id\": 1,\n \"Title\": \"example topic\",\n \"Entries\": [\n {\n \"Id\": 1,\n \"Content\": \"Example entry content.\",\n \"Author\": {\n \"Nick\": \"example-user\",\n \"Id\": 42\n },\n \"Created\": \"2026-08-07T12:00:00Z\",\n \"LastUpdated\": null,\n \"IsFavorite\": false,\n \"FavoriteCount\": 0,\n \"LikeCount\": 0,\n \"CommentCount\": 0,\n \"AvatarUrl\": \"https://example.com/avatar.png\"\n },\n {\n \"Id\": 1,\n \"Content\": \"Example entry content.\",\n \"Author\": {\n \"Nick\": \"example-user\",\n \"Id\": 42\n },\n \"Created\": \"2026-08-07T12:00:00Z\",\n \"LastUpdated\": null,\n \"IsFavorite\": false,\n \"FavoriteCount\": 0,\n \"LikeCount\": 0,\n \"CommentCount\": 0,\n \"AvatarUrl\": \"https://example.com/avatar.png\"\n }\n ],\n \"PageCount\": 1,\n \"PageSize\": 10,\n \"PageIndex\": 1\n },\n \"Message\": null,\n \"StatusCode\": 200\n}", + "cookie": [], + "_postman_previewlanguage": "json" + }, + { + "name": "The bearer token, Client-Secret or authentication flow is missing or invalid.", + "originalRequest": { + "url": { + "path": [ + "v2", + "topic", + ":topicId" + ], + "host": [ + "{{baseUrl}}" + ], + "query": [ + { + "disabled": true, + "description": { + "content": "", + "type": "text/plain" + }, + "key": "p", + "value": "1" + } + ], + "variable": [ + { + "disabled": false, + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "type": "any", + "value": "109286", + "key": "topicId" + } + ] + }, + "header": [ + { + "disabled": false, + "description": { + "content": "(Required) Per-session client UUID. Use a local Postman variable and never publish a real value.", + "type": "text/plain" + }, + "key": "Client-Secret", + "value": "{{clientSecret}}" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "body": {} + }, + "status": "Unauthorized", + "code": 401, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": "{\n \"Success\": false,\n \"Message\": \"Authorization has been denied for this request.\",\n \"Data\": null,\n \"StatusCode\": 401\n}", + "cookie": [], + "_postman_previewlanguage": "json" + }, + { + "name": "The requested topic, entry, user or account object was not found.", + "originalRequest": { + "url": { + "path": [ + "v2", + "topic", + ":topicId" + ], + "host": [ + "{{baseUrl}}" + ], + "query": [ + { + "disabled": true, + "description": { + "content": "", + "type": "text/plain" + }, + "key": "p", + "value": "1" + } + ], + "variable": [ + { + "disabled": false, + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "type": "any", + "value": "109286", + "key": "topicId" + } + ] + }, + "header": [ + { + "disabled": false, + "description": { + "content": "(Required) Per-session client UUID. Use a local Postman variable and never publish a real value.", + "type": "text/plain" + }, + "key": "Client-Secret", + "value": "{{clientSecret}}" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "body": {} + }, + "status": "Not Found", + "code": 404, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": "{\n \"Success\": false,\n \"Message\": \"The requested resource was not found.\",\n \"Data\": null,\n \"StatusCode\": 404\n}", + "cookie": [], + "_postman_previewlanguage": "json" + }, + { + "name": "The upstream API rate limit was reached.", + "originalRequest": { + "url": { + "path": [ + "v2", + "topic", + ":topicId" + ], + "host": [ + "{{baseUrl}}" + ], + "query": [ + { + "disabled": true, + "description": { + "content": "", + "type": "text/plain" + }, + "key": "p", + "value": "1" + } + ], + "variable": [ + { + "disabled": false, + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "type": "any", + "value": "109286", + "key": "topicId" + } + ] + }, + "header": [ + { + "disabled": false, + "description": { + "content": "(Required) Per-session client UUID. Use a local Postman variable and never publish a real value.", + "type": "text/plain" + }, + "key": "Client-Secret", + "value": "{{clientSecret}}" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "body": {} + }, + "status": "Too Many Requests", + "code": 429, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "disabled": true, + "description": { + "content": "Seconds to wait before another safe read.", + "type": "text/plain" + }, + "key": "Retry-After", + "value": "60" + } + ], + "body": "{\n \"Success\": false,\n \"Message\": \"Too many requests.\",\n \"Data\": null,\n \"StatusCode\": 429\n}", + "cookie": [], + "_postman_previewlanguage": "json" + } + ], + "event": [], + "protocolProfileBehavior": { + "disableBodyPruning": true + } + }, + { + "name": "Read a filtered topic page", + "request": { + "name": "Read a filtered topic page", + "description": { + "content": "Returns popular or today's entries for a topic.", + "type": "text/plain" + }, + "url": { + "path": [ + "v2", + "topic", + ":topicId", + ":actionName" + ], + "host": [ + "{{baseUrl}}" + ], + "query": [ + { + "disabled": true, + "description": { + "content": "", + "type": "text/plain" + }, + "key": "p", + "value": "1" + } + ], + "variable": [ + { + "type": "any", + "value": "6532", + "key": "topicId", + "disabled": false, + "description": { + "content": "(Required) ", + "type": "text/plain" + } + }, + { + "type": "any", + "value": "today", + "key": "actionName", + "disabled": false, + "description": { + "content": "(Required) (This can only be one of popular,today)", + "type": "text/plain" + } + } + ] + }, + "header": [ + { + "disabled": false, + "description": { + "content": "(Required) Per-session client UUID. Use a local Postman variable and never publish a real value.", + "type": "text/plain" + }, + "key": "Client-Secret", + "value": "{{clientSecret}}" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "body": {}, + "auth": null + }, + "response": [ + { + "name": "Filtered topic entries", + "originalRequest": { + "url": { + "path": [ + "v2", + "topic", + ":topicId", + ":actionName" + ], + "host": [ + "{{baseUrl}}" + ], + "query": [ + { + "disabled": true, + "description": { + "content": "", + "type": "text/plain" + }, + "key": "p", + "value": "1" + } + ], + "variable": [ + { + "disabled": false, + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "type": "any", + "value": "6532", + "key": "topicId" + }, + { + "disabled": false, + "description": { + "content": "(Required) (This can only be one of popular,today)", + "type": "text/plain" + }, + "type": "any", + "value": "today", + "key": "actionName" + } + ] + }, + "header": [ + { + "disabled": false, + "description": { + "content": "(Required) Per-session client UUID. Use a local Postman variable and never publish a real value.", + "type": "text/plain" + }, + "key": "Client-Secret", + "value": "{{clientSecret}}" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "body": {} + }, + "status": "OK", + "code": 200, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": "{\n \"Success\": true,\n \"Data\": {\n \"Id\": 1,\n \"Title\": \"example topic\",\n \"Entries\": [\n {\n \"Id\": 1,\n \"Content\": \"Example entry content.\",\n \"Author\": {\n \"Nick\": \"example-user\",\n \"Id\": 42\n },\n \"Created\": \"2026-08-07T12:00:00Z\",\n \"LastUpdated\": null,\n \"IsFavorite\": false,\n \"FavoriteCount\": 0,\n \"LikeCount\": 0,\n \"CommentCount\": 0,\n \"AvatarUrl\": \"https://example.com/avatar.png\"\n },\n {\n \"Id\": 1,\n \"Content\": \"Example entry content.\",\n \"Author\": {\n \"Nick\": \"example-user\",\n \"Id\": 42\n },\n \"Created\": \"2026-08-07T12:00:00Z\",\n \"LastUpdated\": null,\n \"IsFavorite\": false,\n \"FavoriteCount\": 0,\n \"LikeCount\": 0,\n \"CommentCount\": 0,\n \"AvatarUrl\": \"https://example.com/avatar.png\"\n }\n ],\n \"PageCount\": 1,\n \"PageSize\": 10,\n \"PageIndex\": 1\n },\n \"Message\": null,\n \"StatusCode\": 200\n}", + "cookie": [], + "_postman_previewlanguage": "json" + }, + { + "name": "The bearer token, Client-Secret or authentication flow is missing or invalid.", + "originalRequest": { + "url": { + "path": [ + "v2", + "topic", + ":topicId", + ":actionName" + ], + "host": [ + "{{baseUrl}}" + ], + "query": [ + { + "disabled": true, + "description": { + "content": "", + "type": "text/plain" + }, + "key": "p", + "value": "1" + } + ], + "variable": [ + { + "disabled": false, + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "type": "any", + "value": "6532", + "key": "topicId" + }, + { + "disabled": false, + "description": { + "content": "(Required) (This can only be one of popular,today)", + "type": "text/plain" + }, + "type": "any", + "value": "today", + "key": "actionName" + } + ] + }, + "header": [ + { + "disabled": false, + "description": { + "content": "(Required) Per-session client UUID. Use a local Postman variable and never publish a real value.", + "type": "text/plain" + }, + "key": "Client-Secret", + "value": "{{clientSecret}}" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "body": {} + }, + "status": "Unauthorized", + "code": 401, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": "{\n \"Success\": false,\n \"Message\": \"Authorization has been denied for this request.\",\n \"Data\": null,\n \"StatusCode\": 401\n}", + "cookie": [], + "_postman_previewlanguage": "json" + }, + { + "name": "The upstream API rate limit was reached.", + "originalRequest": { + "url": { + "path": [ + "v2", + "topic", + ":topicId", + ":actionName" + ], + "host": [ + "{{baseUrl}}" + ], + "query": [ + { + "disabled": true, + "description": { + "content": "", + "type": "text/plain" + }, + "key": "p", + "value": "1" + } + ], + "variable": [ + { + "disabled": false, + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "type": "any", + "value": "6532", + "key": "topicId" + }, + { + "disabled": false, + "description": { + "content": "(Required) (This can only be one of popular,today)", + "type": "text/plain" + }, + "type": "any", + "value": "today", + "key": "actionName" + } + ] + }, + "header": [ + { + "disabled": false, + "description": { + "content": "(Required) Per-session client UUID. Use a local Postman variable and never publish a real value.", + "type": "text/plain" + }, + "key": "Client-Secret", + "value": "{{clientSecret}}" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "body": {} + }, + "status": "Too Many Requests", + "code": 429, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "disabled": true, + "description": { + "content": "Seconds to wait before another safe read.", + "type": "text/plain" + }, + "key": "Retry-After", + "value": "60" + } + ], + "body": "{\n \"Success\": false,\n \"Message\": \"Too many requests.\",\n \"Data\": null,\n \"StatusCode\": 429\n}", + "cookie": [], + "_postman_previewlanguage": "json" + } + ], + "event": [], + "protocolProfileBehavior": { + "disableBodyPruning": true + } + }, + { + "name": "Autocomplete topics and queries", + "request": { + "name": "Autocomplete topics and queries", + "description": { + "content": "Returns title, query and nick suggestions for a partial term.", + "type": "text/plain" + }, + "url": { + "path": [ + "v2", + "autocomplete", + "query" + ], + "host": [ + "{{baseUrl}}" + ], + "query": [], + "variable": [] + }, + "header": [ + { + "disabled": false, + "description": { + "content": "(Required) Per-session client UUID. Use a local Postman variable and never publish a real value.", + "type": "text/plain" + }, + "key": "Client-Secret", + "value": "{{clientSecret}}" + }, + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "POST", + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "disabled": false, + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "key": "Term", + "value": "string" + } + ] + }, + "auth": null + }, + "response": [ + { + "name": "Autocomplete suggestions", + "originalRequest": { + "url": { + "path": [ + "v2", + "autocomplete", + "query" + ], + "host": [ + "{{baseUrl}}" + ], + "query": [], + "variable": [] + }, + "header": [ + { + "disabled": false, + "description": { + "content": "(Required) Per-session client UUID. Use a local Postman variable and never publish a real value.", + "type": "text/plain" + }, + "key": "Client-Secret", + "value": "{{clientSecret}}" + }, + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "POST", + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "disabled": false, + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "key": "Term", + "value": "string" + } + ] + } + }, + "status": "OK", + "code": 200, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": "{\n \"Success\": true,\n \"Data\": \"\",\n \"Message\": null,\n \"StatusCode\": 200\n}", + "cookie": [], + "_postman_previewlanguage": "json" + }, + { + "name": "The bearer token, Client-Secret or authentication flow is missing or invalid.", + "originalRequest": { + "url": { + "path": [ + "v2", + "autocomplete", + "query" + ], + "host": [ + "{{baseUrl}}" + ], + "query": [], + "variable": [] + }, + "header": [ + { + "disabled": false, + "description": { + "content": "(Required) Per-session client UUID. Use a local Postman variable and never publish a real value.", + "type": "text/plain" + }, + "key": "Client-Secret", + "value": "{{clientSecret}}" + }, + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "POST", + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "disabled": false, + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "key": "Term", + "value": "string" + } + ] + } + }, + "status": "Unauthorized", + "code": 401, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": "{\n \"Success\": false,\n \"Message\": \"Authorization has been denied for this request.\",\n \"Data\": null,\n \"StatusCode\": 401\n}", + "cookie": [], + "_postman_previewlanguage": "json" + }, + { + "name": "The upstream API rate limit was reached.", + "originalRequest": { + "url": { + "path": [ + "v2", + "autocomplete", + "query" + ], + "host": [ + "{{baseUrl}}" + ], + "query": [], + "variable": [] + }, + "header": [ + { + "disabled": false, + "description": { + "content": "(Required) Per-session client UUID. Use a local Postman variable and never publish a real value.", + "type": "text/plain" + }, + "key": "Client-Secret", + "value": "{{clientSecret}}" + }, + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "POST", + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "disabled": false, + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "key": "Term", + "value": "string" + } + ] + } }, "status": "Too Many Requests", "code": 429, @@ -4593,32 +5767,24 @@ } }, { - "name": "Entries for a topic", + "name": "Agenda entries", "request": { - "name": "Entries for a topic", + "name": "Agenda entries", "description": { - "content": "Entries for a topic. An anonymous or account bearer token and the Client-Secret header are required.", + "content": "Agenda entries. A logged-in account bearer token and the Client-Secret header are required.", "type": "text/plain" }, "url": { "path": [ "v2", - "entry", - "entriesbytopic" + "index", + "olay", + "" ], "host": [ "{{baseUrl}}" ], "query": [ - { - "disabled": false, - "description": { - "content": "(Required) Topic slug or URL-encoded title", - "type": "text/plain" - }, - "key": "title", - "value": "python" - }, { "disabled": true, "description": { @@ -4652,27 +5818,19 @@ }, "response": [ { - "name": "Paginated entries for topic", + "name": "Agenda entries", "originalRequest": { "url": { "path": [ "v2", - "entry", - "entriesbytopic" + "index", + "olay", + "" ], "host": [ "{{baseUrl}}" ], "query": [ - { - "disabled": false, - "description": { - "content": "(Required) Topic slug or URL-encoded title", - "type": "text/plain" - }, - "key": "title", - "value": "python" - }, { "disabled": true, "description": { @@ -4711,7 +5869,7 @@ "value": "application/json" } ], - "body": "{\n \"Success\": true,\n \"Data\": {\n \"Id\": 1,\n \"Title\": \"example topic\",\n \"Entries\": [\n {\n \"Id\": 1,\n \"Content\": \"Example entry content.\",\n \"Author\": {\n \"Nick\": \"example-user\",\n \"Id\": 42\n },\n \"Created\": \"2026-08-07T12:00:00Z\",\n \"LastUpdated\": null,\n \"IsFavorite\": false,\n \"FavoriteCount\": 0,\n \"LikeCount\": 0,\n \"CommentCount\": 0,\n \"AvatarUrl\": \"https://example.com/avatar.png\"\n },\n {\n \"Id\": 1,\n \"Content\": \"Example entry content.\",\n \"Author\": {\n \"Nick\": \"example-user\",\n \"Id\": 42\n },\n \"Created\": \"2026-08-07T12:00:00Z\",\n \"LastUpdated\": null,\n \"IsFavorite\": false,\n \"FavoriteCount\": 0,\n \"LikeCount\": 0,\n \"CommentCount\": 0,\n \"AvatarUrl\": \"https://example.com/avatar.png\"\n }\n ],\n \"PageCount\": 1,\n \"PageSize\": 10,\n \"PageIndex\": 1\n },\n \"Message\": null,\n \"StatusCode\": 200\n}", + "body": "{\n \"Success\": true,\n \"Data\": \"\",\n \"Message\": null,\n \"StatusCode\": 200\n}", "cookie": [], "_postman_previewlanguage": "json" }, @@ -4721,22 +5879,14 @@ "url": { "path": [ "v2", - "entry", - "entriesbytopic" + "index", + "olay", + "" ], "host": [ "{{baseUrl}}" ], "query": [ - { - "disabled": false, - "description": { - "content": "(Required) Topic slug or URL-encoded title", - "type": "text/plain" - }, - "key": "title", - "value": "python" - }, { "disabled": true, "description": { @@ -4785,22 +5935,14 @@ "url": { "path": [ "v2", - "entry", - "entriesbytopic" + "index", + "olay", + "" ], "host": [ "{{baseUrl}}" ], "query": [ - { - "disabled": false, - "description": { - "content": "(Required) Topic slug or URL-encoded title", - "type": "text/plain" - }, - "key": "title", - "value": "python" - }, { "disabled": true, "description": { @@ -4859,36 +6001,27 @@ } }, { - "name": "Search entries", + "name": "Search entries inside a topic", "request": { - "name": "Search entries", + "name": "Search entries inside a topic", "description": { - "content": "Search entries. An anonymous or account bearer token and the Client-Secret header are required.", + "content": "Searches entry bodies within one numeric topic id.", "type": "text/plain" }, "url": { "path": [ "v2", - "entry", + "topic", "search" ], "host": [ "{{baseUrl}}" ], "query": [ - { - "disabled": false, - "description": { - "content": "(Required) Text to search for.", - "type": "text/plain" - }, - "key": "searchTerm", - "value": "python" - }, { "disabled": true, "description": { - "content": "Page number starting at 1.", + "content": "", "type": "text/plain" }, "key": "p", @@ -4907,42 +6040,46 @@ "key": "Client-Secret", "value": "{{clientSecret}}" }, + { + "key": "Content-Type", + "value": "application/json" + }, { "key": "Accept", "value": "application/json" } ], - "method": "GET", - "body": {}, + "method": "POST", + "body": { + "mode": "raw", + "raw": "{\n \"TopicId\": 1,\n \"Keywords\": \"python\"\n}", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + } + }, "auth": null }, "response": [ { - "name": "Search results", + "name": "Matching entries and topic metadata", "originalRequest": { "url": { "path": [ "v2", - "entry", + "topic", "search" ], "host": [ "{{baseUrl}}" ], "query": [ - { - "disabled": false, - "description": { - "content": "(Required) Text to search for.", - "type": "text/plain" - }, - "key": "searchTerm", - "value": "python" - }, { "disabled": true, "description": { - "content": "Page number starting at 1.", + "content": "", "type": "text/plain" }, "key": "p", @@ -4961,13 +6098,26 @@ "key": "Client-Secret", "value": "{{clientSecret}}" }, + { + "key": "Content-Type", + "value": "application/json" + }, { "key": "Accept", "value": "application/json" } ], - "method": "GET", - "body": {} + "method": "POST", + "body": { + "mode": "raw", + "raw": "{\n \"TopicId\": 1,\n \"Keywords\": \"python\"\n}", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + } + } }, "status": "OK", "code": 200, @@ -4977,7 +6127,7 @@ "value": "application/json" } ], - "body": "{\n \"Success\": true,\n \"Data\": \"\",\n \"Message\": null,\n \"StatusCode\": 200\n}", + "body": "{\n \"Success\": true,\n \"Data\": {\n \"Id\": 1,\n \"Title\": \"example topic\",\n \"Entries\": [\n {\n \"Id\": 1,\n \"Content\": \"Example entry content.\",\n \"Author\": {\n \"Nick\": \"example-user\",\n \"Id\": 42\n },\n \"Created\": \"2026-08-07T12:00:00Z\",\n \"LastUpdated\": null,\n \"IsFavorite\": false,\n \"FavoriteCount\": 0,\n \"LikeCount\": 0,\n \"CommentCount\": 0,\n \"AvatarUrl\": \"https://example.com/avatar.png\"\n },\n {\n \"Id\": 1,\n \"Content\": \"Example entry content.\",\n \"Author\": {\n \"Nick\": \"example-user\",\n \"Id\": 42\n },\n \"Created\": \"2026-08-07T12:00:00Z\",\n \"LastUpdated\": null,\n \"IsFavorite\": false,\n \"FavoriteCount\": 0,\n \"LikeCount\": 0,\n \"CommentCount\": 0,\n \"AvatarUrl\": \"https://example.com/avatar.png\"\n }\n ],\n \"PageCount\": 1,\n \"PageSize\": 10,\n \"PageIndex\": 1\n },\n \"Message\": null,\n \"StatusCode\": 200\n}", "cookie": [], "_postman_previewlanguage": "json" }, @@ -4987,26 +6137,17 @@ "url": { "path": [ "v2", - "entry", + "topic", "search" ], "host": [ "{{baseUrl}}" ], "query": [ - { - "disabled": false, - "description": { - "content": "(Required) Text to search for.", - "type": "text/plain" - }, - "key": "searchTerm", - "value": "python" - }, { "disabled": true, "description": { - "content": "Page number starting at 1.", + "content": "", "type": "text/plain" }, "key": "p", @@ -5025,13 +6166,26 @@ "key": "Client-Secret", "value": "{{clientSecret}}" }, + { + "key": "Content-Type", + "value": "application/json" + }, { "key": "Accept", "value": "application/json" } ], - "method": "GET", - "body": {} + "method": "POST", + "body": { + "mode": "raw", + "raw": "{\n \"TopicId\": 1,\n \"Keywords\": \"python\"\n}", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + } + } }, "status": "Unauthorized", "code": 401, @@ -5051,26 +6205,17 @@ "url": { "path": [ "v2", - "entry", + "topic", "search" ], "host": [ "{{baseUrl}}" ], "query": [ - { - "disabled": false, - "description": { - "content": "(Required) Text to search for.", - "type": "text/plain" - }, - "key": "searchTerm", - "value": "python" - }, { "disabled": true, "description": { - "content": "Page number starting at 1.", + "content": "", "type": "text/plain" }, "key": "p", @@ -5089,13 +6234,26 @@ "key": "Client-Secret", "value": "{{clientSecret}}" }, + { + "key": "Content-Type", + "value": "application/json" + }, { "key": "Accept", "value": "application/json" } ], - "method": "GET", - "body": {} + "method": "POST", + "body": { + "mode": "raw", + "raw": "{\n \"TopicId\": 1,\n \"Keywords\": \"python\"\n}", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + } + } }, "status": "Too Many Requests", "code": 429, @@ -5125,18 +6283,19 @@ } }, { - "name": "Agenda entries", + "name": "Advanced search inside a topic", "request": { - "name": "Agenda entries", + "name": "Advanced search inside a topic", "description": { - "content": "Agenda entries. An anonymous or account bearer token and the Client-Secret header are required.", + "content": "Runs the Android app's advanced entry filters within one topic.", "type": "text/plain" }, "url": { "path": [ "v2", - "entry", - "agenda" + "topic", + "search", + "advanced" ], "host": [ "{{baseUrl}}" @@ -5145,7 +6304,7 @@ { "disabled": true, "description": { - "content": "Page number starting at 1.", + "content": "", "type": "text/plain" }, "key": "p", @@ -5164,24 +6323,38 @@ "key": "Client-Secret", "value": "{{clientSecret}}" }, + { + "key": "Content-Type", + "value": "application/json" + }, { "key": "Accept", "value": "application/json" } ], - "method": "GET", - "body": {}, + "method": "POST", + "body": { + "mode": "raw", + "raw": "{\n \"TopicId\": 1\n}", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + } + }, "auth": null }, "response": [ { - "name": "Agenda entries", + "name": "Matching entries and topic metadata", "originalRequest": { "url": { "path": [ "v2", - "entry", - "agenda" + "topic", + "search", + "advanced" ], "host": [ "{{baseUrl}}" @@ -5190,7 +6363,7 @@ { "disabled": true, "description": { - "content": "Page number starting at 1.", + "content": "", "type": "text/plain" }, "key": "p", @@ -5209,13 +6382,26 @@ "key": "Client-Secret", "value": "{{clientSecret}}" }, + { + "key": "Content-Type", + "value": "application/json" + }, { "key": "Accept", "value": "application/json" } ], - "method": "GET", - "body": {} + "method": "POST", + "body": { + "mode": "raw", + "raw": "{\n \"TopicId\": 1\n}", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + } + } }, "status": "OK", "code": 200, @@ -5225,7 +6411,7 @@ "value": "application/json" } ], - "body": "{\n \"Success\": true,\n \"Data\": \"\",\n \"Message\": null,\n \"StatusCode\": 200\n}", + "body": "{\n \"Success\": true,\n \"Data\": {\n \"Id\": 1,\n \"Title\": \"example topic\",\n \"Entries\": [\n {\n \"Id\": 1,\n \"Content\": \"Example entry content.\",\n \"Author\": {\n \"Nick\": \"example-user\",\n \"Id\": 42\n },\n \"Created\": \"2026-08-07T12:00:00Z\",\n \"LastUpdated\": null,\n \"IsFavorite\": false,\n \"FavoriteCount\": 0,\n \"LikeCount\": 0,\n \"CommentCount\": 0,\n \"AvatarUrl\": \"https://example.com/avatar.png\"\n },\n {\n \"Id\": 1,\n \"Content\": \"Example entry content.\",\n \"Author\": {\n \"Nick\": \"example-user\",\n \"Id\": 42\n },\n \"Created\": \"2026-08-07T12:00:00Z\",\n \"LastUpdated\": null,\n \"IsFavorite\": false,\n \"FavoriteCount\": 0,\n \"LikeCount\": 0,\n \"CommentCount\": 0,\n \"AvatarUrl\": \"https://example.com/avatar.png\"\n }\n ],\n \"PageCount\": 1,\n \"PageSize\": 10,\n \"PageIndex\": 1\n },\n \"Message\": null,\n \"StatusCode\": 200\n}", "cookie": [], "_postman_previewlanguage": "json" }, @@ -5235,8 +6421,9 @@ "url": { "path": [ "v2", - "entry", - "agenda" + "topic", + "search", + "advanced" ], "host": [ "{{baseUrl}}" @@ -5245,7 +6432,7 @@ { "disabled": true, "description": { - "content": "Page number starting at 1.", + "content": "", "type": "text/plain" }, "key": "p", @@ -5264,13 +6451,26 @@ "key": "Client-Secret", "value": "{{clientSecret}}" }, + { + "key": "Content-Type", + "value": "application/json" + }, { "key": "Accept", "value": "application/json" } ], - "method": "GET", - "body": {} + "method": "POST", + "body": { + "mode": "raw", + "raw": "{\n \"TopicId\": 1\n}", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + } + } }, "status": "Unauthorized", "code": 401, @@ -5290,8 +6490,9 @@ "url": { "path": [ "v2", - "entry", - "agenda" + "topic", + "search", + "advanced" ], "host": [ "{{baseUrl}}" @@ -5300,7 +6501,7 @@ { "disabled": true, "description": { - "content": "Page number starting at 1.", + "content": "", "type": "text/plain" }, "key": "p", @@ -5319,13 +6520,26 @@ "key": "Client-Secret", "value": "{{clientSecret}}" }, + { + "key": "Content-Type", + "value": "application/json" + }, { "key": "Accept", "value": "application/json" } - ], - "method": "GET", - "body": {} + ], + "method": "POST", + "body": { + "mode": "raw", + "raw": "{\n \"TopicId\": 1\n}", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + } + } }, "status": "Too Many Requests", "code": 429, @@ -11288,6 +12502,264 @@ "disableBodyPruning": true } }, + { + "name": "Autocomplete nicknames", + "request": { + "name": "Autocomplete nicknames", + "description": { + "content": "Returns nickname suggestions for a partial term.", + "type": "text/plain" + }, + "url": { + "path": [ + "v2", + "autocomplete", + "nick" + ], + "host": [ + "{{baseUrl}}" + ], + "query": [], + "variable": [] + }, + "header": [ + { + "disabled": false, + "description": { + "content": "(Required) Per-session client UUID. Use a local Postman variable and never publish a real value.", + "type": "text/plain" + }, + "key": "Client-Secret", + "value": "{{clientSecret}}" + }, + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "POST", + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "disabled": false, + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "key": "Term", + "value": "string" + } + ] + }, + "auth": null + }, + "response": [ + { + "name": "Nickname suggestions", + "originalRequest": { + "url": { + "path": [ + "v2", + "autocomplete", + "nick" + ], + "host": [ + "{{baseUrl}}" + ], + "query": [], + "variable": [] + }, + "header": [ + { + "disabled": false, + "description": { + "content": "(Required) Per-session client UUID. Use a local Postman variable and never publish a real value.", + "type": "text/plain" + }, + "key": "Client-Secret", + "value": "{{clientSecret}}" + }, + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "POST", + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "disabled": false, + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "key": "Term", + "value": "string" + } + ] + } + }, + "status": "OK", + "code": 200, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": "{\n \"Success\": true,\n \"Data\": \"\",\n \"Message\": null,\n \"StatusCode\": 200\n}", + "cookie": [], + "_postman_previewlanguage": "json" + }, + { + "name": "The bearer token, Client-Secret or authentication flow is missing or invalid.", + "originalRequest": { + "url": { + "path": [ + "v2", + "autocomplete", + "nick" + ], + "host": [ + "{{baseUrl}}" + ], + "query": [], + "variable": [] + }, + "header": [ + { + "disabled": false, + "description": { + "content": "(Required) Per-session client UUID. Use a local Postman variable and never publish a real value.", + "type": "text/plain" + }, + "key": "Client-Secret", + "value": "{{clientSecret}}" + }, + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "POST", + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "disabled": false, + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "key": "Term", + "value": "string" + } + ] + } + }, + "status": "Unauthorized", + "code": 401, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": "{\n \"Success\": false,\n \"Message\": \"Authorization has been denied for this request.\",\n \"Data\": null,\n \"StatusCode\": 401\n}", + "cookie": [], + "_postman_previewlanguage": "json" + }, + { + "name": "The upstream API rate limit was reached.", + "originalRequest": { + "url": { + "path": [ + "v2", + "autocomplete", + "nick" + ], + "host": [ + "{{baseUrl}}" + ], + "query": [], + "variable": [] + }, + "header": [ + { + "disabled": false, + "description": { + "content": "(Required) Per-session client UUID. Use a local Postman variable and never publish a real value.", + "type": "text/plain" + }, + "key": "Client-Secret", + "value": "{{clientSecret}}" + }, + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "POST", + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "disabled": false, + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "key": "Term", + "value": "string" + } + ] + } + }, + "status": "Too Many Requests", + "code": 429, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "disabled": true, + "description": { + "content": "Seconds to wait before another safe read.", + "type": "text/plain" + }, + "key": "Retry-After", + "value": "60" + } + ], + "body": "{\n \"Success\": false,\n \"Message\": \"Too many requests.\",\n \"Data\": null,\n \"StatusCode\": 429\n}", + "cookie": [], + "_postman_previewlanguage": "json" + } + ], + "event": [], + "protocolProfileBehavior": { + "disableBodyPruning": true + } + }, { "name": "List accounts followed by a user", "request": { diff --git a/pyproject.toml b/pyproject.toml index 588d9e4..6373f63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "eksiapi" -version = "1.4.0" +version = "1.5.0" description = "Unofficial Python client and MCP server for Ekşi Sözlük" readme = "README.md" license = "MIT" diff --git a/tests/test_client.py b/tests/test_client.py index d127d5c..79b6fe1 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -42,7 +42,7 @@ def close(self) -> None: def test_package_exposes_version() -> None: - assert __version__ == "1.4.0" + assert __version__ == "1.5.0" def test_client_sets_auth_and_timeout() -> None: @@ -292,16 +292,18 @@ def test_endpoint_helpers_build_expected_requests() -> None: methods = [ ("is_developer", (), {}), ("entry", (42,), {}), - ("topic_entries", ("python",), {"page": 2}), + ("topic_entries", (109286,), {"page": 2}), ("user_entries", ("alice/bob",), {"page": 3}), ("user_favorites", ("alice",), {"page": 4}), ("popular", (), {"page": 5}), ("today", (), {"page": 6}), ("agenda", (), {"page": 7}), ("filter_channels", (), {}), + ("debe", (), {"page": 1}), ("search_topics", ("python",), {"page": 8}), ("autocomplete", ("py",), {}), - ("search_entries", ("python",), {"page": 9}), + ("autocomplete_nicks", ("py",), {}), + ("search_entries", (109286, "python"), {"page": 9}), ("notification_count", (), {}), ("notifications", (), {"page": 10}), ("unread_topic_count", (), {}), @@ -320,6 +322,8 @@ def test_endpoint_helpers_build_expected_requests() -> None: assert len(session.calls) == len(methods) assert "%2F" in session.calls[3][1] assert session.calls[5][2]["json"] == {"Filters": []} + assert session.calls[10][0] == "POST" + assert session.calls[10][2]["json"]["Keywords"] == "python" def test_post_supports_form_body() -> None: diff --git a/tests/test_mcp_interactive.py b/tests/test_mcp_interactive.py index 979e4d9..5636401 100644 --- a/tests/test_mcp_interactive.py +++ b/tests/test_mcp_interactive.py @@ -43,7 +43,7 @@ async def run() -> None: create_server(lambda: WriteClient(), min_interval=0) ) as client: tools = (await client.list_tools()).tools - assert len(tools) == 11 + assert len(tools) == 13 assert all(tool.annotations.read_only_hint for tool in tools) server = create_server( @@ -51,7 +51,7 @@ async def run() -> None: ) async with Client(server) as client: tools = {tool.name: tool for tool in (await client.list_tools()).tools} - assert len(tools) == 23 + assert len(tools) == 25 assert "eksi_publish_entry" in tools schema = tools["eksi_publish_entry"].input_schema assert set(schema["properties"]) == {"preview_token"} diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 38d51e0..9a66432 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -38,12 +38,13 @@ def test_tools_are_read_only_and_search_returns_structured_data() -> None: async def run() -> None: async with Client(server) as client: tools = (await client.list_tools()).tools - assert len(tools) == 11 + assert len(tools) == 13 assert all(tool.annotations.read_only_hint is True for tool in tools) assert all(tool.annotations.open_world_hint is True for tool in tools) result = await client.call_tool( - "eksi_search_entries", {"query": " yapay zeka ", "page": 2} + "eksi_search_entries", + {"topic_id": 123, "query": " yapay zeka ", "page": 2}, ) assert result.is_error is False assert result.structured_content is not None @@ -109,6 +110,8 @@ def test_all_read_tools_route_to_client() -> None: server = create_server(lambda: fake, min_interval=0) calls = [ ("eksi_search_topics", {"query": "python", "page": 2}), + ("eksi_resolve_topic", {"term": "python"}), + ("eksi_autocomplete", {"query": "py", "kind": "query"}), ("eksi_get_topic_entries", {"topic": "python", "page": 3}), ("eksi_get_entry", {"entry_id": 42}), ("eksi_get_user", {"nick": "alice/bob"}), @@ -128,7 +131,7 @@ async def run() -> None: assert result.is_error is False asyncio.run(run()) - assert any(name == "popular" for name, _, _ in fake.calls) + assert any(name == "feed" and args == ("popular",) for name, args, _ in fake.calls) server._eksi_service.close() assert fake.closed is True diff --git a/tests/test_openapi_public_docs.py b/tests/test_openapi_public_docs.py index fa19568..c0e551a 100644 --- a/tests/test_openapi_public_docs.py +++ b/tests/test_openapi_public_docs.py @@ -59,7 +59,7 @@ def test_openapi_is_valid_and_ready_for_public_docs() -> None: all_operations = list(operations(spec)) operation_ids = [operation["operationId"] for _, _, operation in all_operations] - assert len(all_operations) == 88 + assert len(all_operations) == 93 assert len(operation_ids) == len(set(operation_ids)) for path, _, operation in all_operations: @@ -102,7 +102,7 @@ def test_generated_postman_collection_matches_openapi() -> None: for header in item["request"].get("header", []) ) ] - assert len(client_secret_paths) == 88 + assert len(client_secret_paths) == 93 assert "/v2/clientsettings/time" not in client_secret_paths authentication = next( diff --git a/tests/test_stage3_client.py b/tests/test_stage3_client.py index d1c14b6..5ef7bb6 100644 --- a/tests/test_stage3_client.py +++ b/tests/test_stage3_client.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import inspect import json from pathlib import Path @@ -16,11 +17,25 @@ MockSession, RetryPolicy, ) -from eksiapi.models import Entry, WritePreview +from eksiapi.models import Entry, Topic, WritePreview FIXTURES = Path(__file__).parent / "fixtures" / "apk-2.4.10" +def test_sync_and_async_public_method_names_match() -> None: + sync_methods = { + name + for name, value in inspect.getmembers(EksiClient, inspect.isfunction) + if not name.startswith("_") + } + async_methods = { + name + for name, value in inspect.getmembers(AsyncEksiClient, inspect.isfunction) + if not name.startswith("_") + } + assert sync_methods == async_methods + + def test_safe_get_retries_and_write_never_retries() -> None: session = MockSession( [MockResponse(503, {}), MockResponse(200, {"Data": {"ok": True}})] @@ -113,7 +128,7 @@ def test_pagination_iterator_and_write_preview_audit() -> None: ) audits = [] client = EksiClient(session=session, audit_sink=audits.append) - assert [item["EntryId"] for item in client.iter_topic_entries("python")] == [1, 2] + assert [item["EntryId"] for item in client.iter_topic_entries(109286)] == [1, 2] preview = client.create_entry("başlık", "içerik", dry_run=True) assert isinstance(preview, WritePreview) @@ -125,6 +140,96 @@ def test_pagination_iterator_and_write_preview_audit() -> None: assert not hasattr(audits[0], "access_token") +def test_verified_topic_helpers_and_typed_topic() -> None: + session = MockSession( + [ + MockResponse(200, {"Data": {"QueryData": {"TopicId": 109286}}}), + MockResponse( + 200, + { + "Data": { + "Id": 109286, + "Title": "python", + "Slug": "python", + "Entries": [{"Id": 1, "Content": "hello"}], + "PageIndex": 1, + "PageCount": 2, + } + }, + ), + MockResponse(200, {"Data": {"Id": 109286}}), + MockResponse(200, {"Data": {"Id": 109286}}), + ] + ) + client = EksiClient(session=session) + + assert client.resolve_topic_id("python") == 109286 + topic = client.topic_typed(109286) + assert isinstance(topic, Topic) + assert topic.title == "python" + assert topic.entries[0].id == 1 + client.topic_popular(109286) + client.topic_today(109286) + + assert session.calls[0][1].endswith("/v2/topic/query/") + assert session.calls[1][1].endswith("/v2/topic/109286") + assert session.calls[2][1].endswith("/v2/topic/109286/popular") + assert session.calls[3][1].endswith("/v2/topic/109286/today") + + +def test_verified_search_and_autocomplete_request_contracts() -> None: + session = MockSession([MockResponse(200, {}) for _ in range(5)]) + client = EksiClient(session=session) + + client.search_topics("python", page=2, nice_only=True) + client.search_entries(109286, "asyncio", page=3) + client.search_entries_advanced(109286, {"Author": "ssg"}, page=4) + client.autocomplete("pyth") + client.autocomplete_nicks("ss") + + assert session.calls[0][0] == "POST" + assert session.calls[0][1].endswith("/v2/index/search/") + assert session.calls[0][2]["json"] == { + "Keywords": "python", + "SortOrder": 1, + "FavoritedOnly": False, + "NiceOnly": True, + } + assert session.calls[1][2]["json"] == {"TopicId": 109286, "Keywords": "asyncio"} + assert session.calls[2][2]["json"] == {"Author": "ssg", "TopicId": 109286} + assert session.calls[3][2]["data"] == {"Term": "pyth"} + assert session.calls[4][2]["data"] == {"Term": "ss"} + + +def test_unified_feed_routes_and_rejects_misplaced_filters() -> None: + session = MockSession([MockResponse(200, {}) for _ in range(4)]) + client = EksiClient(session=session) + + client.feed("today", page=2) + client.feed("popular", page=3, channel_filters=["spor"]) + client.feed("debe", page=4) + client.feed("agenda", page=5) + + assert session.calls[0][1].endswith("/v2/index/today") + assert session.calls[1][2]["json"] == {"Filters": ["spor"]} + assert session.calls[2][1].endswith("/v2/index/debe/") + assert session.calls[3][1].endswith("/v2/index/olay/") + with pytest.raises(ValueError, match="popular"): + client.feed("today", channel_filters=["spor"]) + + +def test_async_unified_feed_matches_sync_routes() -> None: + async def run() -> None: + session = AsyncMockSession([MockResponse(200, {}) for _ in range(2)]) + client = AsyncEksiClient(session=session) + await client.feed("today", page=2) + await client.feed("debe", page=3) + assert session.calls[0][1].endswith("/v2/index/today") + assert session.calls[1][1].endswith("/v2/index/debe/") + + asyncio.run(run()) + + def test_write_rejects_unsuccessful_api_envelope_and_audits_failure() -> None: audits = [] client = EksiClient( @@ -286,13 +391,13 @@ async def run() -> None: assert token["refresh_token"] == "refresh" await client.me() await client.user("a/b") - await client.topic_entries("topic", 2) + await client.topic_entries(109286, 2) await client.user_entries("nick", 2) await client.popular(2, ["spor"]) await client.today(2) await client.agenda(2) await client.search_topics("q", 2) - await client.search_entries("q", 2) + await client.search_entries(109286, "q", 2) assert isinstance( await client.create_entry("t", "c", dry_run=True), WritePreview ) diff --git a/uv.lock b/uv.lock index b4fa22e..b4d2b4e 100644 --- a/uv.lock +++ b/uv.lock @@ -517,7 +517,7 @@ wheels = [ [[package]] name = "eksiapi" -version = "1.4.0" +version = "1.5.0" source = { editable = "." } dependencies = [ { name = "cryptography" },