Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
```

Expand Down Expand Up @@ -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**.
Expand All @@ -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 |
Expand Down
6 changes: 4 additions & 2 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
30 changes: 25 additions & 5 deletions docs/python-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,21 +70,28 @@ 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

```python
# 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)
Expand All @@ -105,20 +112,33 @@ 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)
```

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)
```

Expand Down
3 changes: 2 additions & 1 deletion eksiapi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -35,6 +35,7 @@
"Page",
"RateLimitInfo",
"RetryPolicy",
"Topic",
"User",
"WritePreview",
"WriteResult",
Expand Down
167 changes: 140 additions & 27 deletions eksiapi/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,6 +28,7 @@
Page,
RateLimitInfo,
TokenInfo,
Topic,
User,
WritePreview,
WriteResult,
Expand Down Expand Up @@ -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="")
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading