SunsetLog 0.0.1__tar.gz → 0.0.3__tar.gz
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.
- {sunsetlog-0.0.1 → sunsetlog-0.0.3}/.gitignore +1 -0
- sunsetlog-0.0.3/PKG-INFO +137 -0
- sunsetlog-0.0.3/README.md +129 -0
- {sunsetlog-0.0.1 → sunsetlog-0.0.3}/SunsetLog/__init__.py +10 -1
- {sunsetlog-0.0.1 → sunsetlog-0.0.3}/SunsetLog/client.py +36 -4
- {sunsetlog-0.0.1 → sunsetlog-0.0.3}/SunsetLog/models.py +19 -0
- sunsetlog-0.0.3/SunsetLog.code-workspace +57 -0
- sunsetlog-0.0.3/examples/get_channel_list.py +24 -0
- sunsetlog-0.0.3/examples/proxy_server.py +86 -0
- sunsetlog-0.0.3/examples/test_via_proxy.py +31 -0
- {sunsetlog-0.0.1 → sunsetlog-0.0.3}/pyproject.toml +1 -1
- {sunsetlog-0.0.1 → sunsetlog-0.0.3}/uv.lock +2 -2
- sunsetlog-0.0.1/PKG-INFO +0 -10
- sunsetlog-0.0.1/README.md +0 -2
- sunsetlog-0.0.1/SunsetLog.code-workspace +0 -61
- {sunsetlog-0.0.1 → sunsetlog-0.0.3}/.gitattributes +0 -0
- {sunsetlog-0.0.1 → sunsetlog-0.0.3}/.github/workflows/publish-pypi.yml +0 -0
- {sunsetlog-0.0.1 → sunsetlog-0.0.3}/.python-version +0 -0
sunsetlog-0.0.3/PKG-INFO
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: SunsetLog
|
|
3
|
+
Version: 0.0.3
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Requires-Python: <3.15,>=3.10
|
|
6
|
+
Requires-Dist: httpx>=0.28.1
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# SunsetLog
|
|
10
|
+
|
|
11
|
+
Async Python client for [log-api.vmp.ir](https://log-api.vmp.ir): list channels and search logs. The library only returns data; you decide how to store or use it.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
**pip**
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install SunsetLog
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**uv**
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
uv add SunsetLog
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- Python: **3.10+**
|
|
30
|
+
- Dependency: **httpx**
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
You need an API **token** (Bearer). Use the client as an async context manager and call `get_channels` or `search`.
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import asyncio
|
|
40
|
+
from SunsetLog import SunsetLogClient
|
|
41
|
+
|
|
42
|
+
TOKEN = "your-bearer-token"
|
|
43
|
+
|
|
44
|
+
async def main():
|
|
45
|
+
async with SunsetLogClient(TOKEN) as client:
|
|
46
|
+
# List channels for a gang (returns dict of channel_name -> {id, ts})
|
|
47
|
+
channels = await client.get_channels(gang=1)
|
|
48
|
+
print(list(channels.keys()))
|
|
49
|
+
|
|
50
|
+
# Search logs (all channels if channels=None)
|
|
51
|
+
data = await client.search(gang=1, from_offset=0)
|
|
52
|
+
print(data["total"], len(data["hits"]))
|
|
53
|
+
for hit in data["hits"]:
|
|
54
|
+
print(hit.get("index"), hit.get("content"), hit.get("ts"))
|
|
55
|
+
|
|
56
|
+
asyncio.run(main())
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## API overview
|
|
62
|
+
|
|
63
|
+
### `SunsetLogClient(token, *, base_url=..., timeout=30.0, headers=...)`
|
|
64
|
+
|
|
65
|
+
- **token** (str): Bearer token for the API.
|
|
66
|
+
- **base_url**: Default `"https://log-api.vmp.ir"`.
|
|
67
|
+
- **timeout**, **headers**: Optional.
|
|
68
|
+
|
|
69
|
+
Use as `async with SunsetLogClient(TOKEN) as client:` or call `await client.close()` when done.
|
|
70
|
+
|
|
71
|
+
### `get_channels(gang=1)`
|
|
72
|
+
|
|
73
|
+
- **Returns:** `dict[str, ChannelMeta]` — channel name → `{"id": str, "ts": int}`.
|
|
74
|
+
|
|
75
|
+
### `search(*, channels=None, gang=1, q="", from_offset=0, mode="exact", operator="and")`
|
|
76
|
+
|
|
77
|
+
- **channels:** `None` (all channels for the gang), a single channel name (str), or a list of channel names.
|
|
78
|
+
- **gang:** Gang id (default `1`).
|
|
79
|
+
- **q:** Search query string.
|
|
80
|
+
- **from_offset:** Pagination offset (API returns up to 100 hits per request).
|
|
81
|
+
- **Returns:** `{"total": int, "hits": list[LogHit]}`. Each hit has `id`, `index` (channel name), `content`, `ts`, `reactions`.
|
|
82
|
+
|
|
83
|
+
### `SunsetLogAPIError`
|
|
84
|
+
|
|
85
|
+
Raised on non-200 or invalid response. Has `.status_code` and `.body` when available.
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Pagination
|
|
90
|
+
|
|
91
|
+
The search API returns at most **100 hits** per request. To fetch all results, request pages by increasing `from_offset` until a page has fewer than 100 hits:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
async def fetch_all(client, gang=1):
|
|
95
|
+
all_hits = []
|
|
96
|
+
from_offset = 0
|
|
97
|
+
while True:
|
|
98
|
+
data = await client.search(gang=gang, from_offset=from_offset)
|
|
99
|
+
hits = data.get("hits") or []
|
|
100
|
+
all_hits.extend(hits)
|
|
101
|
+
if len(hits) < 100:
|
|
102
|
+
break
|
|
103
|
+
from_offset += len(hits)
|
|
104
|
+
return all_hits
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Saving results
|
|
110
|
+
|
|
111
|
+
The library does **not** write files. You get dicts; you save them (e.g. JSON):
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
import json
|
|
115
|
+
from pathlib import Path
|
|
116
|
+
|
|
117
|
+
def save_to_file(data: dict, path: str) -> None:
|
|
118
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
120
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
121
|
+
|
|
122
|
+
# usage
|
|
123
|
+
data = await client.search(gang=1)
|
|
124
|
+
save_to_file(data, "my_logs.json")
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## Exports
|
|
130
|
+
|
|
131
|
+
From `SunsetLog` you can import:
|
|
132
|
+
|
|
133
|
+
- `SunsetLogClient`
|
|
134
|
+
- `SunsetLogAPIError`
|
|
135
|
+
- `SearchResponse`, `ChannelsResponse`, `LogHit`, `ChannelMeta` (types)
|
|
136
|
+
|
|
137
|
+
---
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# SunsetLog
|
|
2
|
+
|
|
3
|
+
Async Python client for [log-api.vmp.ir](https://log-api.vmp.ir): list channels and search logs. The library only returns data; you decide how to store or use it.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
**pip**
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install SunsetLog
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
**uv**
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
uv add SunsetLog
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
- Python: **3.10+**
|
|
22
|
+
- Dependency: **httpx**
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Quick start
|
|
27
|
+
|
|
28
|
+
You need an API **token** (Bearer). Use the client as an async context manager and call `get_channels` or `search`.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import asyncio
|
|
32
|
+
from SunsetLog import SunsetLogClient
|
|
33
|
+
|
|
34
|
+
TOKEN = "your-bearer-token"
|
|
35
|
+
|
|
36
|
+
async def main():
|
|
37
|
+
async with SunsetLogClient(TOKEN) as client:
|
|
38
|
+
# List channels for a gang (returns dict of channel_name -> {id, ts})
|
|
39
|
+
channels = await client.get_channels(gang=1)
|
|
40
|
+
print(list(channels.keys()))
|
|
41
|
+
|
|
42
|
+
# Search logs (all channels if channels=None)
|
|
43
|
+
data = await client.search(gang=1, from_offset=0)
|
|
44
|
+
print(data["total"], len(data["hits"]))
|
|
45
|
+
for hit in data["hits"]:
|
|
46
|
+
print(hit.get("index"), hit.get("content"), hit.get("ts"))
|
|
47
|
+
|
|
48
|
+
asyncio.run(main())
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## API overview
|
|
54
|
+
|
|
55
|
+
### `SunsetLogClient(token, *, base_url=..., timeout=30.0, headers=...)`
|
|
56
|
+
|
|
57
|
+
- **token** (str): Bearer token for the API.
|
|
58
|
+
- **base_url**: Default `"https://log-api.vmp.ir"`.
|
|
59
|
+
- **timeout**, **headers**: Optional.
|
|
60
|
+
|
|
61
|
+
Use as `async with SunsetLogClient(TOKEN) as client:` or call `await client.close()` when done.
|
|
62
|
+
|
|
63
|
+
### `get_channels(gang=1)`
|
|
64
|
+
|
|
65
|
+
- **Returns:** `dict[str, ChannelMeta]` — channel name → `{"id": str, "ts": int}`.
|
|
66
|
+
|
|
67
|
+
### `search(*, channels=None, gang=1, q="", from_offset=0, mode="exact", operator="and")`
|
|
68
|
+
|
|
69
|
+
- **channels:** `None` (all channels for the gang), a single channel name (str), or a list of channel names.
|
|
70
|
+
- **gang:** Gang id (default `1`).
|
|
71
|
+
- **q:** Search query string.
|
|
72
|
+
- **from_offset:** Pagination offset (API returns up to 100 hits per request).
|
|
73
|
+
- **Returns:** `{"total": int, "hits": list[LogHit]}`. Each hit has `id`, `index` (channel name), `content`, `ts`, `reactions`.
|
|
74
|
+
|
|
75
|
+
### `SunsetLogAPIError`
|
|
76
|
+
|
|
77
|
+
Raised on non-200 or invalid response. Has `.status_code` and `.body` when available.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Pagination
|
|
82
|
+
|
|
83
|
+
The search API returns at most **100 hits** per request. To fetch all results, request pages by increasing `from_offset` until a page has fewer than 100 hits:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
async def fetch_all(client, gang=1):
|
|
87
|
+
all_hits = []
|
|
88
|
+
from_offset = 0
|
|
89
|
+
while True:
|
|
90
|
+
data = await client.search(gang=gang, from_offset=from_offset)
|
|
91
|
+
hits = data.get("hits") or []
|
|
92
|
+
all_hits.extend(hits)
|
|
93
|
+
if len(hits) < 100:
|
|
94
|
+
break
|
|
95
|
+
from_offset += len(hits)
|
|
96
|
+
return all_hits
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Saving results
|
|
102
|
+
|
|
103
|
+
The library does **not** write files. You get dicts; you save them (e.g. JSON):
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
import json
|
|
107
|
+
from pathlib import Path
|
|
108
|
+
|
|
109
|
+
def save_to_file(data: dict, path: str) -> None:
|
|
110
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
112
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
113
|
+
|
|
114
|
+
# usage
|
|
115
|
+
data = await client.search(gang=1)
|
|
116
|
+
save_to_file(data, "my_logs.json")
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## Exports
|
|
122
|
+
|
|
123
|
+
From `SunsetLog` you can import:
|
|
124
|
+
|
|
125
|
+
- `SunsetLogClient`
|
|
126
|
+
- `SunsetLogAPIError`
|
|
127
|
+
- `SearchResponse`, `ChannelsResponse`, `LogHit`, `ChannelMeta` (types)
|
|
128
|
+
|
|
129
|
+
---
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
from SunsetLog.client import SunsetLogAPIError, SunsetLogClient
|
|
2
|
-
from SunsetLog.models import
|
|
2
|
+
from SunsetLog.models import (
|
|
3
|
+
ChannelInfo,
|
|
4
|
+
ChannelListResponse,
|
|
5
|
+
ChannelMeta,
|
|
6
|
+
ChannelsResponse,
|
|
7
|
+
LogHit,
|
|
8
|
+
SearchResponse,
|
|
9
|
+
)
|
|
3
10
|
|
|
4
11
|
__version__ = "0.1.0"
|
|
5
12
|
__all__ = [
|
|
@@ -7,6 +14,8 @@ __all__ = [
|
|
|
7
14
|
"SunsetLogAPIError",
|
|
8
15
|
"SearchResponse",
|
|
9
16
|
"ChannelsResponse",
|
|
17
|
+
"ChannelListResponse",
|
|
18
|
+
"ChannelInfo",
|
|
10
19
|
"LogHit",
|
|
11
20
|
"ChannelMeta",
|
|
12
21
|
]
|
|
@@ -2,13 +2,15 @@ from typing import Any
|
|
|
2
2
|
|
|
3
3
|
import httpx
|
|
4
4
|
|
|
5
|
-
from SunsetLog.models import ChannelsResponse, SearchResponse
|
|
5
|
+
from SunsetLog.models import ChannelListResponse, ChannelsResponse, SearchResponse
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class SunsetLogAPIError(Exception):
|
|
9
9
|
"""Raised when the API returns an error or unexpected response."""
|
|
10
10
|
|
|
11
|
-
def __init__(
|
|
11
|
+
def __init__(
|
|
12
|
+
self, message: str, status_code: int | None = None, body: str | None = None
|
|
13
|
+
):
|
|
12
14
|
super().__init__(message)
|
|
13
15
|
self.status_code = status_code
|
|
14
16
|
self.body = body
|
|
@@ -79,23 +81,50 @@ class SunsetLogClient:
|
|
|
79
81
|
raise SunsetLogAPIError("channels/latest returned non-object", body=r.text)
|
|
80
82
|
return data
|
|
81
83
|
|
|
84
|
+
async def get_channel_list(
|
|
85
|
+
self, gang: int = 1, job: int = 1
|
|
86
|
+
) -> ChannelListResponse:
|
|
87
|
+
"""
|
|
88
|
+
Fetch the available channels for a gang/job.
|
|
89
|
+
GET /user/getChannels?gang={gang}&job={job}
|
|
90
|
+
"""
|
|
91
|
+
client = self._get_client()
|
|
92
|
+
r = await client.get("/user/getChannels", params={"gang": gang, "job": job})
|
|
93
|
+
if r.status_code != 200:
|
|
94
|
+
raise SunsetLogAPIError(
|
|
95
|
+
f"user/getChannels failed: {r.status_code}",
|
|
96
|
+
status_code=r.status_code,
|
|
97
|
+
body=r.text,
|
|
98
|
+
)
|
|
99
|
+
data = r.json()
|
|
100
|
+
if not isinstance(data, dict) or "channels" not in data:
|
|
101
|
+
raise SunsetLogAPIError(
|
|
102
|
+
"user/getChannels returned invalid shape", body=r.text
|
|
103
|
+
)
|
|
104
|
+
return data
|
|
105
|
+
|
|
82
106
|
async def search(
|
|
83
107
|
self,
|
|
84
108
|
*,
|
|
85
109
|
channels: str | list[str] | None = None,
|
|
86
110
|
gang: int = 1,
|
|
111
|
+
job: int = 1,
|
|
87
112
|
q: str = "",
|
|
88
113
|
from_offset: int = 0,
|
|
89
114
|
mode: str = "exact",
|
|
90
115
|
operator: str = "and",
|
|
116
|
+
order: str = "desc",
|
|
117
|
+
hidden: str = "exclude",
|
|
91
118
|
) -> SearchResponse:
|
|
92
119
|
"""
|
|
93
120
|
Search logs. GET /search.
|
|
94
121
|
channels: single channel name or comma-separated list.
|
|
122
|
+
order: "desc" (newest first) or "asc" (oldest first).
|
|
95
123
|
"""
|
|
96
124
|
if channels is None:
|
|
97
|
-
|
|
98
|
-
|
|
125
|
+
channel_list = await self.get_channel_list(gang=gang, job=job)
|
|
126
|
+
indexes = [c["index"] for c in channel_list.get("channels", [])]
|
|
127
|
+
channels = ",".join(indexes) if indexes else "gang_glitch_locker1"
|
|
99
128
|
elif isinstance(channels, list):
|
|
100
129
|
channels = ",".join(channels)
|
|
101
130
|
|
|
@@ -105,8 +134,11 @@ class SunsetLogClient:
|
|
|
105
134
|
"from": from_offset,
|
|
106
135
|
"mode": mode,
|
|
107
136
|
"operator": operator,
|
|
137
|
+
"hidden": hidden,
|
|
108
138
|
"channels": channels,
|
|
139
|
+
"order": order,
|
|
109
140
|
"gang": gang,
|
|
141
|
+
"job": job,
|
|
110
142
|
}
|
|
111
143
|
r = await client.get("/search", params=params)
|
|
112
144
|
if r.status_code != 200:
|
|
@@ -26,3 +26,22 @@ class SearchResponse(TypedDict):
|
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
ChannelsResponse = dict[str, ChannelMeta]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ChannelInfo(TypedDict):
|
|
32
|
+
"""Single channel entry from /user/getChannels."""
|
|
33
|
+
|
|
34
|
+
id: int
|
|
35
|
+
index: str
|
|
36
|
+
label: str
|
|
37
|
+
type: int
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ChannelListResponse(TypedDict):
|
|
41
|
+
"""Response of /user/getChannels."""
|
|
42
|
+
|
|
43
|
+
channels: list[ChannelInfo]
|
|
44
|
+
categories: list[Any]
|
|
45
|
+
admin: bool
|
|
46
|
+
canViewAllGangs: bool
|
|
47
|
+
canViewAllJobs: bool
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"folders": [
|
|
3
|
+
{
|
|
4
|
+
"path": ".",
|
|
5
|
+
},
|
|
6
|
+
],
|
|
7
|
+
"settings": {
|
|
8
|
+
"python.analysis.inlayHints.variableTypes": true,
|
|
9
|
+
"python.analysis.autoImportCompletions": true,
|
|
10
|
+
"python.analysis.completeFunctionParens": true,
|
|
11
|
+
"python.analysis.inlayHints.pytestParameters": true,
|
|
12
|
+
"python.analysis.inlayHints.callArgumentNames": "all",
|
|
13
|
+
"python.analysis.inlayHints.functionReturnTypes": true,
|
|
14
|
+
"ruff.enable": true,
|
|
15
|
+
"ruff.importStrategy": "useBundled",
|
|
16
|
+
"ruff.fixAll": true,
|
|
17
|
+
"ruff.organizeImports": true,
|
|
18
|
+
"ruff.showSyntaxErrors": true,
|
|
19
|
+
"[python]": {
|
|
20
|
+
"editor.defaultFormatter": "charliermarsh.ruff",
|
|
21
|
+
"editor.formatOnSave": true,
|
|
22
|
+
"editor.codeActionsOnSave": {
|
|
23
|
+
"source.fixAll.ruff": "explicit",
|
|
24
|
+
"source.organizeImports.ruff": "explicit",
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
"editor.codeActionsOnSave": {
|
|
28
|
+
"source.fixAll": "explicit",
|
|
29
|
+
"source.organizeImports": "never",
|
|
30
|
+
},
|
|
31
|
+
"remote.localPortHost": "allInterfaces",
|
|
32
|
+
"files.autoSave": "afterDelay",
|
|
33
|
+
},
|
|
34
|
+
"launch": {
|
|
35
|
+
"version": "0.2.0",
|
|
36
|
+
"configurations": [
|
|
37
|
+
{
|
|
38
|
+
"name": "Python Debugger: Current File",
|
|
39
|
+
"type": "debugpy",
|
|
40
|
+
"request": "launch",
|
|
41
|
+
"program": "main.py",
|
|
42
|
+
"console": "integratedTerminal",
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
"compounds": [],
|
|
46
|
+
},
|
|
47
|
+
"extensions": {
|
|
48
|
+
"recommendations": [
|
|
49
|
+
"donjayamanne.python-extension-pack",
|
|
50
|
+
"charliermarsh.ruff",
|
|
51
|
+
"dbaeumer.vscode-eslint",
|
|
52
|
+
"esbenp.prettier-vscode",
|
|
53
|
+
"tamasfe.even-better-toml",
|
|
54
|
+
"Bar.python-import-helper",
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Standalone example: fetch the channel list via /user/getChannels."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from SunsetLog import SunsetLogAPIError, SunsetLogClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def main() -> None:
|
|
10
|
+
token = os.environ["SUNSETLOG_TOKEN"]
|
|
11
|
+
|
|
12
|
+
async with SunsetLogClient(token) as client:
|
|
13
|
+
try:
|
|
14
|
+
result = await client.get_channel_list(gang=1, job=1)
|
|
15
|
+
except SunsetLogAPIError as e:
|
|
16
|
+
print(f"Error: {e} (status={e.status_code})")
|
|
17
|
+
return
|
|
18
|
+
|
|
19
|
+
for channel in result["channels"]:
|
|
20
|
+
print(f"{channel['id']:>6} {channel['index']:<30} {channel['label']}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if __name__ == "__main__":
|
|
24
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Reverse proxy for log-api.vmp.ir, meant to run ON A SERVER INSIDE IRAN
|
|
3
|
+
that already has direct access to the real API. Uses SunsetLogClient
|
|
4
|
+
(this library) internally instead of calling the API directly.
|
|
5
|
+
|
|
6
|
+
An external server (outside Iran, where log-api.vmp.ir is blocked) can then
|
|
7
|
+
point SunsetLogClient's base_url at THIS proxy instead of the real API.
|
|
8
|
+
No separate proxy key: the caller's own site token (the same one used
|
|
9
|
+
against the real API) is forwarded upstream as-is.
|
|
10
|
+
|
|
11
|
+
Run directly (host/port configurable via env vars, no uvicorn CLI needed):
|
|
12
|
+
pip install fastapi uvicorn
|
|
13
|
+
export PROXY_HOST=0.0.0.0 # your Iran server's bind address
|
|
14
|
+
export PROXY_PORT=8000
|
|
15
|
+
python proxy_server.py
|
|
16
|
+
|
|
17
|
+
In /docs, click the "Authorize" lock button at the top and paste just the
|
|
18
|
+
site token (no "Bearer " prefix needed there) — Swagger adds it for you.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
from typing import Annotated
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
from fastapi import Depends, FastAPI, HTTPException, Query
|
|
26
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
27
|
+
|
|
28
|
+
UPSTREAM_BASE_URL = "https://log-api.vmp.ir"
|
|
29
|
+
|
|
30
|
+
app = FastAPI()
|
|
31
|
+
bearer_scheme = HTTPBearer()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def _forward(path: str, params: dict, token: str) -> dict:
|
|
35
|
+
async with httpx.AsyncClient(base_url=UPSTREAM_BASE_URL, timeout=30.0) as client:
|
|
36
|
+
r = await client.get(
|
|
37
|
+
path, params=params, headers={"Authorization": f"Bearer {token}"}
|
|
38
|
+
)
|
|
39
|
+
if r.status_code != 200:
|
|
40
|
+
raise HTTPException(status_code=r.status_code, detail=r.text)
|
|
41
|
+
return r.json()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.get("/user/getChannels")
|
|
45
|
+
async def get_channels(
|
|
46
|
+
creds: Annotated[HTTPAuthorizationCredentials, Depends(bearer_scheme)],
|
|
47
|
+
gang: int = 1,
|
|
48
|
+
job: int = 1,
|
|
49
|
+
):
|
|
50
|
+
return await _forward(
|
|
51
|
+
"/user/getChannels", {"gang": gang, "job": job}, creds.credentials
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.get("/search")
|
|
56
|
+
async def search(
|
|
57
|
+
creds: Annotated[HTTPAuthorizationCredentials, Depends(bearer_scheme)],
|
|
58
|
+
q: str = "",
|
|
59
|
+
from_offset: int = Query(0, alias="from"),
|
|
60
|
+
mode: str = "exact",
|
|
61
|
+
operator: str = "and",
|
|
62
|
+
channels: str = "",
|
|
63
|
+
gang: int = 1,
|
|
64
|
+
):
|
|
65
|
+
return await _forward(
|
|
66
|
+
"/search",
|
|
67
|
+
{
|
|
68
|
+
"q": q,
|
|
69
|
+
"from": from_offset,
|
|
70
|
+
"mode": mode,
|
|
71
|
+
"operator": operator,
|
|
72
|
+
"channels": channels,
|
|
73
|
+
"gang": gang,
|
|
74
|
+
},
|
|
75
|
+
creds.credentials,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
import uvicorn
|
|
81
|
+
|
|
82
|
+
uvicorn.run(
|
|
83
|
+
app,
|
|
84
|
+
host=os.environ.get("PROXY_HOST", "0.0.0.0"),
|
|
85
|
+
port=int(os.environ.get("PROXY_PORT", "8000")),
|
|
86
|
+
)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Run this on the EXTERNAL server (outside Iran) to test that the Iran-side
|
|
3
|
+
proxy (see proxy_server.py) is reachable and working.
|
|
4
|
+
|
|
5
|
+
export PROXY_BASE_URL=http://your-iran-server-ip:8000
|
|
6
|
+
export PROXY_ACCESS_KEY=same_secret_set_on_the_proxy
|
|
7
|
+
python test_via_proxy.py
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
from SunsetLog import SunsetLogAPIError, SunsetLogClient
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def main() -> None:
|
|
17
|
+
base_url = os.environ["PROXY_BASE_URL"]
|
|
18
|
+
access_key = os.environ["PROXY_ACCESS_KEY"]
|
|
19
|
+
|
|
20
|
+
async with SunsetLogClient(access_key, base_url=base_url) as client:
|
|
21
|
+
try:
|
|
22
|
+
channels = await client.get_channel_list(gang=1, job=1)
|
|
23
|
+
print(f"OK - got {len(channels['channels'])} channels")
|
|
24
|
+
for c in channels["channels"][:5]:
|
|
25
|
+
print(f" {c['id']:>6} {c['index']}")
|
|
26
|
+
except SunsetLogAPIError as e:
|
|
27
|
+
print(f"FAILED: {e} (status={e.status_code}, body={e.body})")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
if __name__ == "__main__":
|
|
31
|
+
asyncio.run(main())
|
|
@@ -30,7 +30,7 @@ name = "exceptiongroup"
|
|
|
30
30
|
version = "1.3.1"
|
|
31
31
|
source = { registry = "https://pypi.org/simple" }
|
|
32
32
|
dependencies = [
|
|
33
|
-
{ name = "typing-extensions"
|
|
33
|
+
{ name = "typing-extensions" },
|
|
34
34
|
]
|
|
35
35
|
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
|
36
36
|
wheels = [
|
|
@@ -85,7 +85,7 @@ wheels = [
|
|
|
85
85
|
|
|
86
86
|
[[package]]
|
|
87
87
|
name = "sunsetlog"
|
|
88
|
-
version = "0.0.
|
|
88
|
+
version = "0.0.3"
|
|
89
89
|
source = { editable = "." }
|
|
90
90
|
dependencies = [
|
|
91
91
|
{ name = "httpx" },
|
sunsetlog-0.0.1/PKG-INFO
DELETED
sunsetlog-0.0.1/README.md
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"folders": [
|
|
3
|
-
{
|
|
4
|
-
"path": "."
|
|
5
|
-
}
|
|
6
|
-
],
|
|
7
|
-
"settings": {
|
|
8
|
-
"python.analysis.inlayHints.variableTypes": true,
|
|
9
|
-
"python.analysis.autoImportCompletions": true,
|
|
10
|
-
"python.analysis.completeFunctionParens": true,
|
|
11
|
-
"python.analysis.inlayHints.pytestParameters": true,
|
|
12
|
-
"python.analysis.inlayHints.callArgumentNames": "all",
|
|
13
|
-
"python.analysis.inlayHints.functionReturnTypes": true,
|
|
14
|
-
"ruff.enable": true,
|
|
15
|
-
"ruff.format.preview": true,
|
|
16
|
-
"ruff.fixAll": true,
|
|
17
|
-
"ruff.organizeImports": true,
|
|
18
|
-
"ruff.showSyntaxErrors": true,
|
|
19
|
-
"[python]": {
|
|
20
|
-
"editor.defaultFormatter": "charliermarsh.ruff",
|
|
21
|
-
"editor.formatOnSave": true,
|
|
22
|
-
"editor.codeActionsOnSave": {
|
|
23
|
-
"source.fixAll.ruff": "explicit",
|
|
24
|
-
"source.organizeImports.ruff": "explicit",
|
|
25
|
-
},
|
|
26
|
-
},
|
|
27
|
-
"ruff.configuration": "pyproject.toml",
|
|
28
|
-
|
|
29
|
-
"editor.codeActionsOnSave": {
|
|
30
|
-
"source.fixAll": "explicit",
|
|
31
|
-
"source.organizeImports": "never"
|
|
32
|
-
},
|
|
33
|
-
"remote.localPortHost": "allInterfaces",
|
|
34
|
-
"files.autoSave": "afterDelay",
|
|
35
|
-
"ruff.lineLength": 120,
|
|
36
|
-
},
|
|
37
|
-
"launch": {
|
|
38
|
-
"version": "0.2.0",
|
|
39
|
-
"configurations": [
|
|
40
|
-
{
|
|
41
|
-
"name": "Python Debugger: Current File",
|
|
42
|
-
"type": "debugpy",
|
|
43
|
-
"request": "launch",
|
|
44
|
-
"program": "main.py",
|
|
45
|
-
"console": "integratedTerminal"
|
|
46
|
-
}
|
|
47
|
-
],
|
|
48
|
-
"compounds": []
|
|
49
|
-
},
|
|
50
|
-
"extensions": {
|
|
51
|
-
"recommendations": [
|
|
52
|
-
"donjayamanne.python-extension-pack",
|
|
53
|
-
"charliermarsh.ruff",
|
|
54
|
-
"dbaeumer.vscode-eslint",
|
|
55
|
-
"esbenp.prettier-vscode",
|
|
56
|
-
"tamasfe.even-better-toml",
|
|
57
|
-
"Bar.python-import-helper"
|
|
58
|
-
|
|
59
|
-
]
|
|
60
|
-
}
|
|
61
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|