ogu-api 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ogu_api/__init__.py ADDED
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.metadata import PackageNotFoundError, version as _package_version
4
+
5
+ from ._http import HttpClient, HttpClientConfig
6
+ from .client import OguClient
7
+ from .errors import (
8
+ OguAPIError,
9
+ OguAuthenticationError,
10
+ OguAuthorizationError,
11
+ OguCreditsError,
12
+ OguError,
13
+ OguLoginError,
14
+ OguNetworkError,
15
+ OguNotFoundError,
16
+ OguParseError,
17
+ OguRateLimitError,
18
+ OguReputationError,
19
+ OguServerError,
20
+ OguSessionError,
21
+ OguTimeoutError,
22
+ OguValidationError,
23
+ )
24
+ from .models import ActionResult, Message, RecentTransaction, ReputationPage, UserProfile
25
+ from .proxy import Proxy
26
+
27
+ try:
28
+ __version__ = _package_version('ogu-api')
29
+
30
+ except PackageNotFoundError:
31
+ __version__ = '0.0.0+unknown'
32
+
33
+ __all__ = [
34
+ '__version__',
35
+ 'OguClient',
36
+ 'HttpClient',
37
+ 'HttpClientConfig',
38
+ 'Proxy',
39
+ 'ActionResult',
40
+ 'Message',
41
+ 'RecentTransaction',
42
+ 'ReputationPage',
43
+ 'UserProfile',
44
+ 'OguError',
45
+ 'OguAPIError',
46
+ 'OguAuthenticationError',
47
+ 'OguAuthorizationError',
48
+ 'OguNotFoundError',
49
+ 'OguValidationError',
50
+ 'OguRateLimitError',
51
+ 'OguServerError',
52
+ 'OguNetworkError',
53
+ 'OguTimeoutError',
54
+ 'OguParseError',
55
+ 'OguSessionError',
56
+ 'OguLoginError',
57
+ 'OguReputationError',
58
+ 'OguCreditsError',
59
+ ]
ogu_api/_http.py ADDED
@@ -0,0 +1,338 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Mapping, MutableMapping
7
+
8
+ import tls_client
9
+ import tls_client.response
10
+
11
+ from .errors import (
12
+ OguAPIError,
13
+ OguAuthenticationError,
14
+ OguAuthorizationError,
15
+ OguNetworkError,
16
+ OguNotFoundError,
17
+ OguRateLimitError,
18
+ OguServerError,
19
+ OguTimeoutError,
20
+ OguValidationError,
21
+ )
22
+ from .proxy import Proxy
23
+
24
+ __all__ = [
25
+ 'HttpClientConfig',
26
+ 'HttpClient',
27
+ ]
28
+
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ DEFAULT_BASE_URL = 'https://oguser.com'
34
+
35
+ DEFAULT_TIMEOUT_SECONDS = 30.0
36
+
37
+ DEFAULT_RETRIES = 0
38
+
39
+ DEFAULT_RETRY_BACKOFF_SECONDS = 0.5
40
+
41
+ DEFAULT_CLIENT_IDENTIFIER = 'chrome131'
42
+
43
+ DEFAULT_HEADERS: Mapping[str, str] = {
44
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
45
+ 'Accept-Encoding': 'gzip, deflate, br, zstd',
46
+ 'Accept-Language': 'en-US,en;q=0.5',
47
+ 'Connection': 'keep-alive',
48
+ 'Host': 'oguser.com',
49
+ 'Priority': 'u=0, i',
50
+ 'Referer': 'https://oguser.com/',
51
+ 'Sec-Fetch-Dest': 'document',
52
+ 'Sec-Fetch-Mode': 'navigate',
53
+ 'Sec-Fetch-Site': 'same-origin',
54
+ 'Sec-Fetch-User': '?1',
55
+ 'Sec-GPC': '1',
56
+ 'TE': 'trailers',
57
+ 'Upgrade-Insecure-Requests': '1',
58
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0',
59
+ }
60
+
61
+ RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({429, 500, 502, 503, 504})
62
+
63
+
64
+ @dataclass(frozen = True)
65
+ class HttpClientConfig:
66
+ base_url: str = DEFAULT_BASE_URL
67
+ timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS
68
+ max_retries: int = DEFAULT_RETRIES
69
+ retry_backoff_seconds: float = DEFAULT_RETRY_BACKOFF_SECONDS
70
+ client_identifier: str = DEFAULT_CLIENT_IDENTIFIER
71
+ default_headers: Mapping[str, str] = field(default_factory = lambda: dict(DEFAULT_HEADERS))
72
+
73
+
74
+ class HttpClient:
75
+ def __init__(
76
+ self,
77
+ *,
78
+ proxy: str | None = None,
79
+ config: HttpClientConfig | None = None,
80
+ session: tls_client.Session | None = None,
81
+ ) -> None:
82
+ self._config: HttpClientConfig = config or HttpClientConfig()
83
+ self._proxy: Proxy = Proxy(proxy)
84
+ self._session: tls_client.Session = session or self._create_session(
85
+ self._proxy,
86
+ client_identifier = self._config.client_identifier,
87
+ )
88
+ self._owns_session: bool = session is None
89
+
90
+ self._session.cookies.set('ogumybbuser', '1')
91
+ self._session.cookies.set('oguloginattempts', '1')
92
+
93
+ async def __aenter__(self) -> HttpClient:
94
+ return self
95
+
96
+ async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
97
+ self.close()
98
+
99
+ def close(self) -> None:
100
+ if self._owns_session:
101
+ close = getattr(self._session, 'close', None)
102
+ if callable(close):
103
+ close()
104
+
105
+ @property
106
+ def session(self) -> tls_client.Session:
107
+ return self._session
108
+
109
+ @property
110
+ def cookies(self) -> Any:
111
+ return self._session.cookies
112
+
113
+ @property
114
+ def base_url(self) -> str:
115
+ return self._config.base_url
116
+
117
+ async def get(
118
+ self,
119
+ path: str,
120
+ *,
121
+ query: Mapping[str, Any] | None = None,
122
+ extra_headers: Mapping[str, str] | None = None,
123
+ allow_redirects: bool = True,
124
+ ) -> tls_client.response.Response:
125
+ return await self.request(
126
+ 'GET',
127
+ path,
128
+ query = query,
129
+ extra_headers = extra_headers,
130
+ allow_redirects = allow_redirects,
131
+ )
132
+
133
+ async def post(
134
+ self,
135
+ path: str,
136
+ *,
137
+ data: Mapping[str, Any] | None = None,
138
+ json_body: Any = None,
139
+ query: Mapping[str, Any] | None = None,
140
+ extra_headers: Mapping[str, str] | None = None,
141
+ allow_redirects: bool = True,
142
+ ) -> tls_client.response.Response:
143
+ return await self.request(
144
+ 'POST',
145
+ path,
146
+ data = data,
147
+ json_body = json_body,
148
+ query = query,
149
+ extra_headers = extra_headers,
150
+ allow_redirects = allow_redirects,
151
+ )
152
+
153
+ async def request(
154
+ self,
155
+ method: str,
156
+ path: str,
157
+ *,
158
+ query: Mapping[str, Any] | None = None,
159
+ data: Mapping[str, Any] | None = None,
160
+ json_body: Any = None,
161
+ extra_headers: Mapping[str, str] | None = None,
162
+ allow_redirects: bool = True,
163
+ ) -> tls_client.response.Response:
164
+ url = self._build_url(path)
165
+ headers = self._build_headers(extra_headers, has_form_body = data is not None)
166
+
167
+ attempts = max(1, self._config.max_retries + 1)
168
+ last_error: Exception | None = None
169
+ for attempt in range(1, attempts + 1):
170
+ try:
171
+ response = await asyncio.to_thread(
172
+ self._dispatch,
173
+ method = method,
174
+ url = url,
175
+ query = query,
176
+ data = data,
177
+ json_body = json_body,
178
+ headers = headers,
179
+ allow_redirects = allow_redirects,
180
+ )
181
+
182
+ except OguTimeoutError as E:
183
+ last_error = E
184
+ if attempt >= attempts:
185
+ raise
186
+
187
+ self._sleep_for_retry(attempt)
188
+ continue
189
+
190
+ except OguNetworkError as E:
191
+ last_error = E
192
+ if attempt >= attempts:
193
+ raise
194
+
195
+ self._sleep_for_retry(attempt)
196
+ continue
197
+
198
+ if response.status_code in RETRYABLE_STATUS_CODES and attempt < attempts:
199
+ self._sleep_for_retry(attempt)
200
+ continue
201
+
202
+ return self._handle_response(response, method = method, url = url)
203
+
204
+ if last_error is not None:
205
+ raise last_error
206
+
207
+ raise OguNetworkError(f'Exhausted retries for {method} {url}')
208
+
209
+ @staticmethod
210
+ def _create_session(proxy: Proxy, *, client_identifier: str) -> tls_client.Session:
211
+ session = tls_client.Session(
212
+ client_identifier = client_identifier,
213
+ random_tls_extension_order = True,
214
+ )
215
+ if proxy.proxy:
216
+ session.proxies.update({
217
+ 'http': proxy.proxy,
218
+ 'https': proxy.proxy,
219
+ })
220
+
221
+ return session
222
+
223
+ def _dispatch(
224
+ self,
225
+ *,
226
+ method: str,
227
+ url: str,
228
+ query: Mapping[str, Any] | None,
229
+ data: Mapping[str, Any] | None,
230
+ json_body: Any,
231
+ headers: Mapping[str, str],
232
+ allow_redirects: bool,
233
+ ) -> tls_client.response.Response:
234
+ request_func = getattr(self._session, method.lower())
235
+ try:
236
+ return request_func(
237
+ url,
238
+ params = dict(query) if query else None,
239
+ data = dict(data) if data else None,
240
+ json = json_body,
241
+ headers = dict(headers),
242
+ allow_redirects = allow_redirects,
243
+ )
244
+
245
+ except Exception as E:
246
+ message = str(E) or E.__class__.__name__
247
+ if 'timeout' in message.lower() or 'timed out' in message.lower():
248
+ raise OguTimeoutError(f'Request timed out: {method} {url}') from E
249
+
250
+ raise OguNetworkError(f'Network error during {method} {url}: {message}') from E
251
+
252
+ def _build_url(self, path: str) -> str:
253
+ if path.startswith(('http://', 'https://')):
254
+ return path
255
+
256
+ base = self._config.base_url.rstrip('/')
257
+ if not path.startswith('/'):
258
+ path = '/' + path
259
+
260
+ return f'{base}{path}'
261
+
262
+ def _build_headers(
263
+ self,
264
+ extra_headers: Mapping[str, str] | None,
265
+ *,
266
+ has_form_body: bool,
267
+ ) -> MutableMapping[str, str]:
268
+ headers: MutableMapping[str, str] = dict(self._config.default_headers)
269
+ if has_form_body:
270
+ headers['Content-Type'] = 'application/x-www-form-urlencoded'
271
+
272
+ if extra_headers:
273
+ for key, value in extra_headers.items():
274
+ headers[key] = value
275
+
276
+ return headers
277
+
278
+ def _sleep_for_retry(self, attempt: int) -> None:
279
+ delay = self._config.retry_backoff_seconds * (2 ** (attempt - 1))
280
+ logger.debug('Retrying in %.2fs (attempt %d)', delay, attempt)
281
+
282
+ def _handle_response(
283
+ self,
284
+ response: tls_client.response.Response,
285
+ *,
286
+ method: str,
287
+ url: str,
288
+ ) -> tls_client.response.Response:
289
+ status = response.status_code
290
+ if status < 400:
291
+ return response
292
+
293
+ body = response.text if hasattr(response, 'text') else None
294
+ message = f'{method} {url} -> {status}'
295
+
296
+ if status == 401:
297
+ raise OguAuthenticationError(message, status_code = status, method = method, url = url, body = body)
298
+
299
+ if status == 403:
300
+ raise OguAuthorizationError(message, status_code = status, method = method, url = url, body = body)
301
+
302
+ if status == 404:
303
+ raise OguNotFoundError(message, status_code = status, method = method, url = url, body = body)
304
+
305
+ if status in (400, 422):
306
+ raise OguValidationError(message, status_code = status, method = method, url = url, body = body)
307
+
308
+ if status == 429:
309
+ retry_after = _parse_retry_after(response)
310
+ raise OguRateLimitError(
311
+ message,
312
+ status_code = status,
313
+ method = method,
314
+ url = url,
315
+ body = body,
316
+ retry_after_seconds = retry_after,
317
+ )
318
+
319
+ if 500 <= status < 600:
320
+ raise OguServerError(message, status_code = status, method = method, url = url, body = body)
321
+
322
+ raise OguAPIError(message, status_code = status, method = method, url = url, body = body)
323
+
324
+
325
+ def _parse_retry_after(response: tls_client.response.Response) -> float | None:
326
+ raw = None
327
+ headers = getattr(response, 'headers', None)
328
+ if headers is not None:
329
+ raw = headers.get('Retry-After') or headers.get('retry-after')
330
+
331
+ if raw is None:
332
+ return None
333
+
334
+ try:
335
+ return float(raw)
336
+
337
+ except (TypeError, ValueError):
338
+ return None
ogu_api/client.py ADDED
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import tls_client
6
+
7
+ from ._http import HttpClient, HttpClientConfig
8
+ from .resources import (
9
+ CreditsResource,
10
+ FeedResource,
11
+ MembersResource,
12
+ MessagesResource,
13
+ NotificationsResource,
14
+ ReputationResource,
15
+ SearchResource,
16
+ SessionResource,
17
+ ThreadsResource,
18
+ UserCPResource,
19
+ UsersResource,
20
+ )
21
+
22
+ __all__ = ['OguClient']
23
+
24
+
25
+ class OguClient:
26
+ def __init__(
27
+ self,
28
+ *,
29
+ proxy: str | None = None,
30
+ base_url: str | None = None,
31
+ timeout_seconds: float | None = None,
32
+ max_retries: int | None = None,
33
+ retry_backoff_seconds: float | None = None,
34
+ client_identifier: str | None = None,
35
+ config: HttpClientConfig | None = None,
36
+ session: tls_client.Session | None = None,
37
+ ) -> None:
38
+ resolved_config = self._resolve_config(
39
+ config = config,
40
+ base_url = base_url,
41
+ timeout_seconds = timeout_seconds,
42
+ max_retries = max_retries,
43
+ retry_backoff_seconds = retry_backoff_seconds,
44
+ client_identifier = client_identifier,
45
+ )
46
+ self._http: HttpClient = HttpClient(
47
+ proxy = proxy,
48
+ config = resolved_config,
49
+ session = session,
50
+ )
51
+
52
+ self.session: SessionResource = SessionResource(self._http)
53
+ self.users: UsersResource = UsersResource(self._http)
54
+ self.usercp: UserCPResource = UserCPResource(self._http)
55
+ self.reputation: ReputationResource = ReputationResource(self._http)
56
+ self.credits: CreditsResource = CreditsResource(self._http)
57
+ self.messages: MessagesResource = MessagesResource(self._http)
58
+ self.notifications: NotificationsResource = NotificationsResource(self._http)
59
+ self.feed: FeedResource = FeedResource(self._http)
60
+ self.threads: ThreadsResource = ThreadsResource(self._http)
61
+ self.search: SearchResource = SearchResource(self._http)
62
+ self.members: MembersResource = MembersResource(self._http)
63
+
64
+ async def __aenter__(self) -> OguClient:
65
+ return self
66
+
67
+ async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
68
+ self.close()
69
+
70
+ def close(self) -> None:
71
+ self._http.close()
72
+
73
+ @property
74
+ def http(self) -> HttpClient:
75
+ return self._http
76
+
77
+ @property
78
+ def cookies(self) -> Any:
79
+ return self._http.cookies
80
+
81
+ @staticmethod
82
+ def _resolve_config(
83
+ *,
84
+ config: HttpClientConfig | None,
85
+ base_url: str | None,
86
+ timeout_seconds: float | None,
87
+ max_retries: int | None,
88
+ retry_backoff_seconds: float | None,
89
+ client_identifier: str | None,
90
+ ) -> HttpClientConfig:
91
+ if config is None:
92
+ base = HttpClientConfig()
93
+
94
+ else:
95
+ base = config
96
+
97
+ return HttpClientConfig(
98
+ base_url = base_url if base_url is not None else base.base_url,
99
+ timeout_seconds = (
100
+ timeout_seconds if timeout_seconds is not None else base.timeout_seconds
101
+ ),
102
+ max_retries = max_retries if max_retries is not None else base.max_retries,
103
+ retry_backoff_seconds = (
104
+ retry_backoff_seconds
105
+ if retry_backoff_seconds is not None
106
+ else base.retry_backoff_seconds
107
+ ),
108
+ client_identifier = (
109
+ client_identifier if client_identifier is not None else base.client_identifier
110
+ ),
111
+ default_headers = base.default_headers,
112
+ )
ogu_api/errors.py ADDED
@@ -0,0 +1,129 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ __all__ = [
6
+ 'OguError',
7
+ 'OguAPIError',
8
+ 'OguAuthenticationError',
9
+ 'OguAuthorizationError',
10
+ 'OguNotFoundError',
11
+ 'OguValidationError',
12
+ 'OguRateLimitError',
13
+ 'OguServerError',
14
+ 'OguNetworkError',
15
+ 'OguTimeoutError',
16
+ 'OguParseError',
17
+ 'OguSessionError',
18
+ 'OguLoginError',
19
+ 'OguReputationError',
20
+ 'OguCreditsError',
21
+ ]
22
+
23
+
24
+ class OguError(Exception):
25
+ ...
26
+
27
+
28
+ class OguNetworkError(OguError):
29
+ ...
30
+
31
+
32
+ class OguTimeoutError(OguNetworkError):
33
+ ...
34
+
35
+
36
+ class OguAPIError(OguError):
37
+ def __init__(
38
+ self,
39
+ message: str,
40
+ *,
41
+ status_code: int,
42
+ method: str,
43
+ url: str,
44
+ body: str | None = None,
45
+ ) -> None:
46
+ super().__init__(message)
47
+
48
+ self.status_code: int = status_code
49
+ self.method: str = method
50
+ self.url: str = url
51
+ self.body: str | None = body
52
+
53
+ def __str__(self) -> str:
54
+ base = super().__str__()
55
+ return f'{base} [{self.method} {self.url} -> {self.status_code}]'
56
+
57
+
58
+ class OguAuthenticationError(OguAPIError):
59
+ ...
60
+
61
+
62
+ class OguAuthorizationError(OguAPIError):
63
+ ...
64
+
65
+
66
+ class OguNotFoundError(OguAPIError):
67
+ ...
68
+
69
+
70
+ class OguValidationError(OguAPIError):
71
+ ...
72
+
73
+
74
+ class OguRateLimitError(OguAPIError):
75
+ def __init__(
76
+ self,
77
+ message: str,
78
+ *,
79
+ status_code: int,
80
+ method: str,
81
+ url: str,
82
+ body: str | None = None,
83
+ retry_after_seconds: float | None = None,
84
+ ) -> None:
85
+ super().__init__(
86
+ message,
87
+ status_code = status_code,
88
+ method = method,
89
+ url = url,
90
+ body = body,
91
+ )
92
+
93
+ self.retry_after_seconds: float | None = retry_after_seconds
94
+
95
+
96
+ class OguServerError(OguAPIError):
97
+ ...
98
+
99
+
100
+ class OguParseError(OguError):
101
+ def __init__(self, message: str = 'Failed to parse response') -> None:
102
+ super().__init__(message)
103
+
104
+
105
+ class OguSessionError(OguError):
106
+ def __init__(self, message: str = 'Session is invalid or expired') -> None:
107
+ super().__init__(message)
108
+
109
+
110
+ class OguLoginError(OguError):
111
+ def __init__(self, message: str = 'Login failed') -> None:
112
+ super().__init__(message)
113
+
114
+
115
+ class OguReputationError(OguError):
116
+ def __init__(
117
+ self,
118
+ message: str = 'Reputation request failed',
119
+ *,
120
+ valid_amounts: list[str] | None = None,
121
+ ) -> None:
122
+ super().__init__(message)
123
+
124
+ self.valid_amounts: list[str] | None = valid_amounts
125
+
126
+
127
+ class OguCreditsError(OguError):
128
+ def __init__(self, message: str = 'Credits request failed') -> None:
129
+ super().__init__(message)
ogu_api/models.py ADDED
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+ __all__ = [
7
+ 'UserProfile',
8
+ 'ReputationPage',
9
+ 'ActionResult',
10
+ 'Message',
11
+ 'RecentTransaction',
12
+ ]
13
+
14
+
15
+ @dataclass(frozen = True)
16
+ class UserProfile:
17
+ user_id: str
18
+ username: str | None = None
19
+ reputation: str | None = None
20
+ vouches: str | None = None
21
+ credits: str | None = None
22
+
23
+
24
+ @dataclass(frozen = True)
25
+ class ReputationPage:
26
+ values: list[str] = field(default_factory = list)
27
+ hidden: dict[str, Any] = field(default_factory = dict)
28
+
29
+
30
+ @dataclass(frozen = True)
31
+ class ActionResult:
32
+ success: bool
33
+ message: str = ''
34
+
35
+
36
+ @dataclass(frozen = True)
37
+ class Message:
38
+ username: str
39
+ date: int
40
+ message: str
41
+
42
+
43
+ @dataclass(frozen = True)
44
+ class RecentTransaction:
45
+ sender: str
46
+ recipient: str
47
+ amount: int
48
+ date: int