cloudtips 0.1.0__tar.gz → 0.2.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloudtips
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Неофициальная Python-библиотека для CloudTips (получение донатов, поллинг, обновление токенов)
5
5
  Project-URL: Repository, https://github.com/IRRatium/cloudtips-api
6
6
  Project-URL: Issues, https://github.com/IRRatium/cloudtips-api/issues
@@ -4,14 +4,17 @@ cloudtips — неофициальная Python-библиотека для Clou
4
4
 
5
5
  from .auth import CloudTipsAuth, CloudTipsAuthError
6
6
  from .client import CloudTipsAPIError, CloudTipsClient
7
- from .models import Donation, TokenData
7
+ from .models import AccumulationSummary, Card, Donation, PayoutFeeInfo, TokenData
8
8
 
9
- __version__ = "0.1.0"
9
+ __version__ = "0.2.0"
10
10
  __all__ = [
11
11
  "CloudTipsAuth",
12
12
  "CloudTipsAuthError",
13
13
  "CloudTipsClient",
14
14
  "CloudTipsAPIError",
15
15
  "Donation",
16
+ "Card",
17
+ "PayoutFeeInfo",
18
+ "AccumulationSummary",
16
19
  "TokenData",
17
20
  ]
@@ -0,0 +1,309 @@
1
+ """
2
+ Клиент CloudTips API.
3
+ """
4
+ import time
5
+ from datetime import datetime, timezone, timedelta
6
+ from typing import Callable, Iterator, List, Optional
7
+
8
+ import requests
9
+
10
+ from .auth import CloudTipsAuth
11
+ from .models import Donation, Card, PayoutFeeInfo, AccumulationSummary
12
+
13
+ _BASE_URL = "https://api.cloudtips.ru/api"
14
+ _MSK = timezone(timedelta(hours=3))
15
+
16
+ HEADERS_BASE = {
17
+ "Accept": "application/json, text/plain, */*",
18
+ "Accept-Language": "ru-RU,ru;q=0.9",
19
+ "Origin": "https://lk.cloudtips.ru",
20
+ "Referer": "https://lk.cloudtips.ru/",
21
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
22
+ }
23
+
24
+
25
+ class CloudTipsClient:
26
+ """
27
+ Основной клиент для работы с CloudTips API.
28
+
29
+ Пример быстрого старта::
30
+
31
+ from cloudtips import CloudTipsClient, CloudTipsAuth
32
+
33
+ auth = CloudTipsAuth(
34
+ token="...",
35
+ refresh_token="...",
36
+ expires_at=1776099728.0,
37
+ on_token_refresh=lambda td: print("Новый refresh:", td.refresh_token),
38
+ )
39
+ client = CloudTipsClient(auth)
40
+
41
+ donations = client.get_all_donations()
42
+ cards = client.get_cards()
43
+ summary = client.get_accumulation_summary()
44
+ """
45
+
46
+ def __init__(self, auth: CloudTipsAuth, base_url: str = _BASE_URL) -> None:
47
+ self._auth = auth
48
+ self._base_url = base_url.rstrip("/")
49
+ self._session = requests.Session()
50
+
51
+ # ------------------------------------------------------------------
52
+ # Донаты
53
+ # ------------------------------------------------------------------
54
+
55
+ def get_donations(
56
+ self,
57
+ since: Optional[datetime] = None,
58
+ until: Optional[datetime] = None,
59
+ limit: int = 50,
60
+ page: int = 1,
61
+ ) -> List[Donation]:
62
+ """
63
+ Получить донаты за период через /timeline.
64
+
65
+ :param since: начало периода (по умолчанию — 24 часа назад)
66
+ :param until: конец периода (по умолчанию — сейчас)
67
+ :param limit: кол-во на страницу
68
+ :param page: номер страницы
69
+ :return: список :class:`Donation`
70
+ """
71
+ now = datetime.now(_MSK)
72
+ date_from = _ensure_tz(since or (now - timedelta(hours=24)))
73
+ date_to = _ensure_tz(until or now)
74
+
75
+ data = self._get("/timeline", params={
76
+ "page": page,
77
+ "limit": limit,
78
+ "dateFrom": date_from.isoformat(),
79
+ "dateTo": date_to.isoformat(),
80
+ })
81
+
82
+ raw_items = data.get("data", {}).get("items", [])
83
+ result = []
84
+ for item in raw_items:
85
+ if item.get("operationType") != "Transaction":
86
+ continue
87
+ result.append(Donation.from_dict({
88
+ "transaction_id": item.get("transactionId", 0),
89
+ "name": (item.get("payerName") or "Аноним").strip(),
90
+ "amount": int(item.get("paymentAmount", 0)),
91
+ "tg_id": 0,
92
+ "comment": (item.get("comment") or item.get("payerComment") or "").strip(),
93
+ "date": item.get("createdDate", now.isoformat()),
94
+ }))
95
+ return result
96
+
97
+ def get_all_donations(
98
+ self,
99
+ since: Optional[datetime] = None,
100
+ until: Optional[datetime] = None,
101
+ ) -> List[Donation]:
102
+ """
103
+ Получить все донаты за период с автопагинацией.
104
+
105
+ :param since: начало периода
106
+ :param until: конец периода
107
+ :return: полный список :class:`Donation`, отсортированный по дате
108
+ """
109
+ result: List[Donation] = []
110
+ page = 1
111
+ while True:
112
+ batch = self.get_donations(since=since, until=until, limit=50, page=page)
113
+ if not batch:
114
+ break
115
+ result.extend(batch)
116
+ if len(batch) < 50:
117
+ break
118
+ page += 1
119
+ return sorted(result, key=lambda d: d.date)
120
+
121
+ def poll(
122
+ self,
123
+ interval: int = 30,
124
+ since: Optional[datetime] = None,
125
+ callback: Optional[Callable[[Donation], None]] = None,
126
+ ) -> Iterator[Donation]:
127
+ """
128
+ Генератор: бесконечный поллинг новых донатов.
129
+
130
+ Использование как генератора::
131
+
132
+ for donation in client.poll(interval=15):
133
+ print(f"Новый донат: {donation}")
134
+
135
+ Или с колбэком (блокирующий режим)::
136
+
137
+ client.poll(interval=15, callback=handle_donation)
138
+
139
+ :param interval: пауза между запросами в секундах
140
+ :param since: с какого момента начинать (по умолчанию — прямо сейчас)
141
+ :param callback: если передан — метод блокируется и вызывает колбэк
142
+ """
143
+ last_seen_ids: set = set()
144
+ cursor = _ensure_tz(since or datetime.now(_MSK))
145
+
146
+ for d in self.get_all_donations(since=cursor):
147
+ last_seen_ids.add(d.transaction_id)
148
+
149
+ while True:
150
+ time.sleep(interval)
151
+
152
+ try:
153
+ fresh = self.get_all_donations(since=cursor)
154
+ except Exception as exc:
155
+ print(f"[cloudtips] Ошибка при поллинге: {exc}")
156
+ continue
157
+
158
+ for donation in fresh:
159
+ if donation.transaction_id not in last_seen_ids:
160
+ last_seen_ids.add(donation.transaction_id)
161
+ cursor = max(cursor, donation.date)
162
+ if callback:
163
+ callback(donation)
164
+ else:
165
+ yield donation
166
+
167
+ # ------------------------------------------------------------------
168
+ # Карты
169
+ # ------------------------------------------------------------------
170
+
171
+ def get_cards(self) -> List[Card]:
172
+ """
173
+ Получить список привязанных карт.
174
+
175
+ :return: список :class:`Card`
176
+
177
+ Пример::
178
+
179
+ for card in client.get_cards():
180
+ print(card) # MIR *3742 (T-BANK (TINKOFF), до 08/34) [по умолчанию]
181
+ print(card.token) # tk_89e6b3c6827afd4e9ccc36db2d22f
182
+ """
183
+ data = self._get("/cards")
184
+ return [Card.from_dict(item) for item in data.get("data", [])]
185
+
186
+ def delete_card(self, card_token: str) -> bool:
187
+ """
188
+ Удалить привязанную карту.
189
+
190
+ :param card_token: токен карты (``card.token``)
191
+ :return: ``True`` если удаление прошло успешно
192
+
193
+ Пример::
194
+
195
+ for card in client.get_cards():
196
+ client.delete_card(card.token)
197
+ """
198
+ data = self._delete("/cards", json={"cardToken": card_token})
199
+ return data.get("succeed", False)
200
+
201
+ # ------------------------------------------------------------------
202
+ # Выплаты и баланс
203
+ # ------------------------------------------------------------------
204
+
205
+ def get_payout_fee_info(self) -> PayoutFeeInfo:
206
+ """
207
+ Получить информацию о комиссиях при выводе средств.
208
+
209
+ :return: :class:`PayoutFeeInfo`
210
+
211
+ Пример::
212
+
213
+ fee = client.get_payout_fee_info()
214
+ print(fee.text)
215
+ # Стоимость вывода денег на карты Т-Банка — 5%
216
+ # Стоимость вывода денег на карты других банков — 7%*
217
+ """
218
+ data = self._get("/payout/fee/info")
219
+ return PayoutFeeInfo.from_dict(data.get("data", {}))
220
+
221
+ def get_accumulation_summary(self) -> AccumulationSummary:
222
+ """
223
+ Получить сводку по накопленным средствам (баланс к выводу).
224
+
225
+ :return: :class:`AccumulationSummary`
226
+
227
+ Пример::
228
+
229
+ s = client.get_accumulation_summary()
230
+ print(f"Накоплено: {s.accumulated_amount}₽")
231
+ print(f"Комиссия: {s.commission_percent}%")
232
+ print(f"Следующая выплата: {s.next_payout_date or 'не запланирована'}")
233
+ """
234
+ data = self._get("/accumulations/summary")
235
+ return AccumulationSummary.from_dict(data.get("data", {}))
236
+
237
+ def set_payout_method(self, method: str = "Instant") -> bool:
238
+ """
239
+ Установить метод выплат.
240
+
241
+ :param method: ``"Instant"`` (мгновенно) или ``"Accumulation"`` (накопительно)
242
+ :return: ``True`` если успешно
243
+
244
+ Пример::
245
+
246
+ client.set_payout_method("Instant")
247
+ """
248
+ data = self._put("/receivers/payout-method", json={"payoutMethod": method})
249
+ return data.get("succeed", False)
250
+
251
+ # ------------------------------------------------------------------
252
+ # Внутренние HTTP-методы
253
+ # ------------------------------------------------------------------
254
+
255
+ def _get(self, path: str, params: Optional[dict] = None) -> dict:
256
+ headers = {**HEADERS_BASE, **self._auth.headers()}
257
+ response = self._session.get(
258
+ self._base_url + path,
259
+ headers=headers,
260
+ params=params,
261
+ timeout=15,
262
+ )
263
+ _raise_for_status(response)
264
+ return response.json()
265
+
266
+ def _delete(self, path: str, json: Optional[dict] = None) -> dict:
267
+ headers = {**HEADERS_BASE, **self._auth.headers()}
268
+ response = self._session.delete(
269
+ self._base_url + path,
270
+ headers=headers,
271
+ json=json,
272
+ timeout=15,
273
+ )
274
+ _raise_for_status(response)
275
+ return response.json()
276
+
277
+ def _put(self, path: str, json: Optional[dict] = None) -> dict:
278
+ headers = {**HEADERS_BASE, **self._auth.headers()}
279
+ response = self._session.put(
280
+ self._base_url + path,
281
+ headers=headers,
282
+ json=json,
283
+ timeout=15,
284
+ )
285
+ _raise_for_status(response)
286
+ return response.json()
287
+
288
+
289
+ class CloudTipsAPIError(Exception):
290
+ """Ошибка запроса к CloudTips API."""
291
+
292
+ def __init__(self, status_code: int, detail: object) -> None:
293
+ self.status_code = status_code
294
+ self.detail = detail
295
+ super().__init__(f"HTTP {status_code}: {detail}")
296
+
297
+
298
+ def _ensure_tz(dt: datetime) -> datetime:
299
+ """Добавляет МСК таймзону если её нет."""
300
+ return dt if dt.tzinfo else dt.replace(tzinfo=_MSK)
301
+
302
+
303
+ def _raise_for_status(response: requests.Response) -> None:
304
+ if not response.ok:
305
+ try:
306
+ detail = response.json()
307
+ except Exception:
308
+ detail = response.text
309
+ raise CloudTipsAPIError(response.status_code, detail)
@@ -0,0 +1,115 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
3
+ from typing import Optional
4
+
5
+
6
+ @dataclass
7
+ class Donation:
8
+ transaction_id: int
9
+ name: str
10
+ amount: int # рубли
11
+ tg_id: int
12
+ comment: str
13
+ date: datetime
14
+
15
+ @classmethod
16
+ def from_dict(cls, data: dict) -> "Donation":
17
+ raw_date = data["date"]
18
+ try:
19
+ dt = datetime.fromisoformat(raw_date)
20
+ except ValueError:
21
+ dt = datetime.fromisoformat(raw_date[:19])
22
+
23
+ return cls(
24
+ transaction_id=data["transaction_id"],
25
+ name=data.get("name", ""),
26
+ amount=data["amount"],
27
+ tg_id=data.get("tg_id", 0),
28
+ comment=data.get("comment", ""),
29
+ date=dt,
30
+ )
31
+
32
+ def __str__(self) -> str:
33
+ comment_part = f' — "{self.comment}"' if self.comment else ""
34
+ return (
35
+ f"[{self.date.strftime('%Y-%m-%d %H:%M')}] "
36
+ f"{self.name} → {self.amount}₽{comment_part}"
37
+ )
38
+
39
+
40
+ @dataclass
41
+ class Card:
42
+ token: str # токен для удаления и операций
43
+ first_six: str # первые 6 цифр (BIN)
44
+ last_four: str # последние 4 цифры
45
+ card_type: str # MIR / VISA / MASTERCARD
46
+ expiration_date: str # MM/YY
47
+ issuer_code: str # название банка
48
+ is_default: bool # карта по умолчанию для выплат
49
+ commission_hint: str # текст подсказки о комиссии
50
+
51
+ @classmethod
52
+ def from_dict(cls, data: dict) -> "Card":
53
+ return cls(
54
+ token=data["token"],
55
+ first_six=data.get("firstSix", ""),
56
+ last_four=data.get("lastFour", ""),
57
+ card_type=data.get("cardType", ""),
58
+ expiration_date=data.get("cardExpirationDate", ""),
59
+ issuer_code=data.get("issuerCode", ""),
60
+ is_default=data.get("isDefault", False),
61
+ commission_hint=data.get("commissionHint", ""),
62
+ )
63
+
64
+ def __str__(self) -> str:
65
+ default = " [по умолчанию]" if self.is_default else ""
66
+ return (
67
+ f"{self.card_type} *{self.last_four} "
68
+ f"({self.issuer_code}, до {self.expiration_date}){default}"
69
+ )
70
+
71
+
72
+ @dataclass
73
+ class PayoutFeeInfo:
74
+ text: str # текст с описанием комиссий
75
+ downgrade_condition: str
76
+ tinkoff_commission_hint: str
77
+ instant_payout_commission_text: str
78
+
79
+ @classmethod
80
+ def from_dict(cls, data: dict) -> "PayoutFeeInfo":
81
+ return cls(
82
+ text=data.get("text", ""),
83
+ downgrade_condition=data.get("downGradeCondition", ""),
84
+ tinkoff_commission_hint=data.get("tinkoffCommissionHint", ""),
85
+ instant_payout_commission_text=data.get("instantPayoutCommissionText", ""),
86
+ )
87
+
88
+
89
+ @dataclass
90
+ class AccumulationSummary:
91
+ accumulated_amount: float # накоплено (ещё не выведено)
92
+ amount_to_deposit: float # к зачислению
93
+ commission: float # сумма комиссии
94
+ commission_percent: float # процент комиссии
95
+ next_payout_date: Optional[str] # дата следующей выплаты (None если не запланирована)
96
+ commission_hint: str # текст подсказки
97
+
98
+ @classmethod
99
+ def from_dict(cls, data: dict) -> "AccumulationSummary":
100
+ return cls(
101
+ accumulated_amount=data.get("accumulatedAmount", 0.0),
102
+ amount_to_deposit=data.get("amountToDeposit", 0.0),
103
+ commission=data.get("commission", 0.0),
104
+ commission_percent=data.get("commissionPercent", 0.0),
105
+ next_payout_date=data.get("nextPayoutDate"),
106
+ commission_hint=data.get("commissionHint", ""),
107
+ )
108
+
109
+
110
+ @dataclass
111
+ class TokenData:
112
+ """Новые токены, которые библиотека передаёт в on_token_refresh."""
113
+ access_token: str
114
+ refresh_token: str
115
+ expires_at: float # unix timestamp
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cloudtips"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Неофициальная Python-библиотека для CloudTips (получение донатов, поллинг, обновление токенов)"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -1,173 +0,0 @@
1
- """
2
- Клиент CloudTips API.
3
- """
4
- import time
5
- from datetime import datetime, timezone
6
- from typing import Callable, Iterator, List, Optional
7
-
8
- import requests
9
-
10
- from .auth import CloudTipsAuth
11
- from .models import Donation
12
-
13
- _BASE_URL = "https://api.cloudtips.ru"
14
-
15
-
16
- class CloudTipsClient:
17
- """
18
- Основной клиент для работы с CloudTips API.
19
-
20
- Пример быстрого старта::
21
-
22
- from cloudtips import CloudTipsClient, CloudTipsAuth
23
-
24
- auth = CloudTipsAuth(
25
- token="...",
26
- refresh_token="...",
27
- expires_at=1776099728.0,
28
- on_token_refresh=lambda td: print("Новый токен:", td.access_token),
29
- )
30
- client = CloudTipsClient(auth)
31
-
32
- # Получить все донаты
33
- donations = client.get_donations()
34
-
35
- # Поллинг новых донатов каждые 30 секунд
36
- for donation in client.poll(interval=30):
37
- print(donation)
38
- """
39
-
40
- def __init__(self, auth: CloudTipsAuth, base_url: str = _BASE_URL) -> None:
41
- self._auth = auth
42
- self._base_url = base_url.rstrip("/")
43
- self._session = requests.Session()
44
-
45
- # ------------------------------------------------------------------
46
- # Основные методы
47
- # ------------------------------------------------------------------
48
-
49
- def get_donations(
50
- self,
51
- since: Optional[datetime] = None,
52
- limit: int = 100,
53
- page: int = 1,
54
- ) -> List[Donation]:
55
- """
56
- Получить список донатов.
57
-
58
- :param since: фильтр — только донаты после этого момента
59
- :param limit: кол-во на страницу (max обычно 100)
60
- :param page: номер страницы
61
- :return: список :class:`Donation`
62
- """
63
- params: dict = {"pageSize": limit, "page": page}
64
- if since is not None:
65
- params["from"] = since.isoformat()
66
-
67
- data = self._get("/api/payments", params=params)
68
-
69
- items = data if isinstance(data, list) else data.get("items", data.get("payments", []))
70
- return [Donation.from_dict(item) for item in items]
71
-
72
- def get_all_donations(self, since: Optional[datetime] = None) -> List[Donation]:
73
- """
74
- Получить все донаты с автопагинацией.
75
-
76
- :param since: фильтр по дате
77
- :return: полный список :class:`Donation`
78
- """
79
- result: List[Donation] = []
80
- page = 1
81
- while True:
82
- batch = self.get_donations(since=since, limit=100, page=page)
83
- if not batch:
84
- break
85
- result.extend(batch)
86
- if len(batch) < 100:
87
- break
88
- page += 1
89
- return sorted(result, key=lambda d: d.date)
90
-
91
- def poll(
92
- self,
93
- interval: int = 30,
94
- since: Optional[datetime] = None,
95
- callback: Optional[Callable[[Donation], None]] = None,
96
- ) -> Iterator[Donation]:
97
- """
98
- Генератор: бесконечный поллинг новых донатов.
99
-
100
- Каждые ``interval`` секунд опрашивает API и отдаёт только **новые**
101
- донаты (те, что появились после последнего запроса).
102
-
103
- Использование как генератора::
104
-
105
- for donation in client.poll(interval=15):
106
- print(f"Новый донат: {donation}")
107
-
108
- Или с колбэком (блокирующий режим)::
109
-
110
- client.poll(interval=15, callback=handle_donation)
111
-
112
- :param interval: пауза между запросами в секундах
113
- :param since: с какого момента начинать (по умолчанию — «прямо сейчас»)
114
- :param callback: если передан — метод блокируется и вызывает колбэк
115
- """
116
- last_seen_ids: set = set()
117
- cursor = since or datetime.now(tz=timezone.utc)
118
-
119
- # Первый запрос — грузим уже известные, не отдаём как "новые"
120
- for d in self.get_all_donations(since=cursor):
121
- last_seen_ids.add(d.transaction_id)
122
-
123
- while True:
124
- time.sleep(interval)
125
-
126
- try:
127
- fresh = self.get_all_donations(since=cursor)
128
- except Exception as exc:
129
- # Не падаем, просто пробуем снова на следующей итерации
130
- print(f"[cloudtips] Ошибка при поллинге: {exc}")
131
- continue
132
-
133
- for donation in fresh:
134
- if donation.transaction_id not in last_seen_ids:
135
- last_seen_ids.add(donation.transaction_id)
136
- cursor = max(cursor, donation.date)
137
- if callback:
138
- callback(donation)
139
- else:
140
- yield donation
141
-
142
- # ------------------------------------------------------------------
143
- # Вспомогательные
144
- # ------------------------------------------------------------------
145
-
146
- def _get(self, path: str, params: Optional[dict] = None) -> dict:
147
- url = self._base_url + path
148
- response = self._session.get(
149
- url,
150
- headers=self._auth.headers(),
151
- params=params,
152
- timeout=15,
153
- )
154
- _raise_for_status(response)
155
- return response.json()
156
-
157
-
158
- class CloudTipsAPIError(Exception):
159
- """Ошибка запроса к CloudTips API."""
160
-
161
- def __init__(self, status_code: int, detail: object) -> None:
162
- self.status_code = status_code
163
- self.detail = detail
164
- super().__init__(f"HTTP {status_code}: {detail}")
165
-
166
-
167
- def _raise_for_status(response: requests.Response) -> None:
168
- if not response.ok:
169
- try:
170
- detail = response.json()
171
- except Exception:
172
- detail = response.text
173
- raise CloudTipsAPIError(response.status_code, detail)
@@ -1,48 +0,0 @@
1
- from dataclasses import dataclass, field
2
- from datetime import datetime
3
- from typing import Optional
4
-
5
-
6
- @dataclass
7
- class Donation:
8
- transaction_id: int
9
- name: str
10
- amount: int # рубли
11
- tg_id: int
12
- comment: str
13
- date: datetime
14
-
15
- @classmethod
16
- def from_dict(cls, data: dict) -> "Donation":
17
- raw_date = data["date"]
18
- # Убираем offset вида +03:00 → datetime aware через fromisoformat (3.11+)
19
- # или ручной парсинг для 3.9/3.10
20
- try:
21
- dt = datetime.fromisoformat(raw_date)
22
- except ValueError:
23
- # fallback: обрезаем offset
24
- dt = datetime.fromisoformat(raw_date[:19])
25
-
26
- return cls(
27
- transaction_id=data["transaction_id"],
28
- name=data.get("name", ""),
29
- amount=data["amount"],
30
- tg_id=data.get("tg_id", 0),
31
- comment=data.get("comment", ""),
32
- date=dt,
33
- )
34
-
35
- def __str__(self) -> str:
36
- comment_part = f' — "{self.comment}"' if self.comment else ""
37
- return (
38
- f"[{self.date.strftime('%Y-%m-%d %H:%M')}] "
39
- f"{self.name} → {self.amount}₽{comment_part}"
40
- )
41
-
42
-
43
- @dataclass
44
- class TokenData:
45
- """Новые токены, которые библиотека передаёт в on_token_refresh."""
46
- access_token: str
47
- refresh_token: str
48
- expires_at: float # unix timestamp
File without changes
File without changes
File without changes