arizona-forum-api-async 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.
@@ -0,0 +1,1449 @@
1
+ import aiohttp
2
+ from bs4 import BeautifulSoup
3
+ from re import compile, findall
4
+ import re
5
+ from html import unescape
6
+ from typing import List, Dict, Optional, Union
7
+
8
+ from arizona_forum_async.consts import MAIN_URL, ROLE_COLOR
9
+ from arizona_forum_async.bypass_antibot import bypass_async
10
+
11
+ from arizona_forum_async.exceptions import IncorrectLoginData, ThisIsYouError
12
+ from arizona_forum_async.models.other import Statistic
13
+ from arizona_forum_async.models.post_object import Post, ProfilePost
14
+ from arizona_forum_async.models.member_object import Member, CurrentMember
15
+ from arizona_forum_async.models.thread_object import Thread
16
+ from arizona_forum_async.models.category_object import Category
17
+
18
+
19
+ class ArizonaAPI:
20
+ def __init__(self, user_agent: str, cookie: dict) -> None:
21
+ self.user_agent = user_agent
22
+ self.cookie_str = "; ".join([f"{k}={v}" for k, v in cookie.items()])
23
+ self._session: aiohttp.ClientSession = None
24
+ self._token: str = None
25
+
26
+
27
+ async def connect(self, do_bypass: bool = True):
28
+ """Асинхронный метод для создания сессии, получения токена и обхода анти-бота."""
29
+ if self._session is None or self._session.closed:
30
+ cookies = {}
31
+ for item in self.cookie_str.split('; '):
32
+ name, value = item.strip().split('=', 1)
33
+ cookies[name] = value
34
+
35
+ if do_bypass:
36
+ bypass_cookie_str, _ = await bypass_async(self.user_agent)
37
+ name, value = bypass_cookie_str.split('=', 1)
38
+ cookies[name] = value
39
+
40
+ self._session = aiohttp.ClientSession(
41
+ headers={"user-agent": self.user_agent},
42
+ cookies=cookies
43
+ )
44
+
45
+ try:
46
+ async with self._session.get(f"{MAIN_URL}/account/") as response:
47
+ response.raise_for_status()
48
+ html_content_main = await response.text()
49
+ soup_main = BeautifulSoup(html_content_main, 'lxml')
50
+ html_tag = soup_main.find('html')
51
+ if not html_tag or html_tag.get('data-logged-in') == "false":
52
+ raise IncorrectLoginData("Неверные cookie или сессия истекла.")
53
+
54
+ async with self._session.get(f"{MAIN_URL}/help/terms/") as response:
55
+ response.raise_for_status()
56
+ html_content = await response.text()
57
+ soup = BeautifulSoup(html_content, 'lxml')
58
+ self._token = soup.find('html')['data-csrf']
59
+ if not self._token:
60
+ raise Exception("Не удалось получить CSRF токен.")
61
+
62
+ except (aiohttp.ClientError, IncorrectLoginData, Exception) as e:
63
+ if self._session:
64
+ await self._session.close()
65
+ self._session = None
66
+ raise Exception(f"Ошибка подключения или авторизации: {e}") from e
67
+
68
+ async def close(self):
69
+ """Асинхронный метод для закрытия сессии."""
70
+ if self._session and not self._session.closed:
71
+ await self._session.close()
72
+
73
+ @property
74
+ async def token(self) -> str:
75
+ if not self._session or self._session.closed:
76
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
77
+ if not self._token:
78
+ async with self._session.get(f"{MAIN_URL}/help/terms/") as response:
79
+ response.raise_for_status()
80
+ html_content = await response.text()
81
+ soup = BeautifulSoup(html_content, 'lxml')
82
+ self._token = soup.find('html')['data-csrf']
83
+ return self._token
84
+
85
+ async def get_current_member(self) -> CurrentMember:
86
+ if not self._session or self._session.closed:
87
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
88
+ try:
89
+ async with self._session.get(f"{MAIN_URL}/account/") as response:
90
+ response.raise_for_status()
91
+ html_content = await response.text()
92
+ soup = BeautifulSoup(html_content, 'lxml')
93
+ avatar_span = soup.find('span', {'class': 'avatar--xxs'})
94
+ if not avatar_span or not avatar_span.has_attr('data-user-id'):
95
+ raise Exception("Не удалось найти ID текущего пользователя на странице аккаунта.")
96
+ user_id = int(avatar_span['data-user-id'])
97
+
98
+ member_info = await self.get_member(user_id)
99
+ if not member_info:
100
+ raise Exception(f"Не удалось получить информацию для пользователя с ID {user_id}")
101
+
102
+ return CurrentMember(self, user_id, member_info.username, member_info.user_title,
103
+ member_info.avatar, member_info.roles, member_info.messages_count,
104
+ member_info.reactions_count, member_info.trophies_count, member_info.username_color)
105
+ except aiohttp.ClientError as e:
106
+ print(f"Ошибка сети при получении данных текущего пользователя: {e}")
107
+ return None
108
+ except Exception as e:
109
+ print(f"Неожиданная ошибка при получении данных текущего пользователя: {e}")
110
+ return None
111
+
112
+ async def get_category(self, category_id: int) -> 'Category | None':
113
+ if not self._session or self._session.closed:
114
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
115
+ token = await self.token
116
+ url = f"{MAIN_URL}/forums/{category_id}"
117
+ params = {'_xfResponseType': 'json', '_xfToken': token}
118
+ try:
119
+ async with self._session.get(url, params=params) as response:
120
+ response.raise_for_status()
121
+ data = await response.json()
122
+
123
+ if data.get('status') == 'error':
124
+ return None
125
+
126
+ html_content = unescape(data['html']['content'])
127
+ soup = BeautifulSoup(html_content, 'lxml')
128
+ title = unescape(data['html']['title'])
129
+ try:
130
+ pages_count = int(soup.find_all('li', {'class': 'pageNav-page'})[-1].text)
131
+ except (IndexError, AttributeError, ValueError):
132
+ pages_count = 1
133
+
134
+ return Category(self, category_id, title, pages_count)
135
+ except aiohttp.ClientError as e:
136
+ print(f"Ошибка сети при получении категории {category_id}: {e}")
137
+ return None
138
+ except Exception as e:
139
+ print(f"Неожиданная ошибка при получении категории {category_id}: {e}")
140
+ return None
141
+
142
+
143
+ async def get_member(self, user_id: int) -> 'Member | None':
144
+ if not self._session or self._session.closed:
145
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
146
+ token = await self.token
147
+ url = f"{MAIN_URL}/members/{user_id}"
148
+ params = {'_xfResponseType': 'json', '_xfToken': token}
149
+ try:
150
+ async with self._session.get(url, params=params) as response:
151
+ response.raise_for_status()
152
+ data = await response.json()
153
+
154
+ if data.get('status') == 'error':
155
+ return None
156
+
157
+ html_content = unescape(data['html']['content'])
158
+ soup = BeautifulSoup(html_content, 'lxml')
159
+ username = unescape(data['html']['title'])
160
+
161
+ username_class = soup.find('span', class_='username')
162
+ username_color = '#fff'
163
+ if username_class:
164
+ for style in ROLE_COLOR:
165
+ if style in str(username_class):
166
+ username_color = ROLE_COLOR[style]
167
+ break
168
+
169
+ roles = []
170
+ roles_container = soup.find('div', {'class': 'memberHeader-banners'})
171
+ if roles_container:
172
+ for i in roles_container.children:
173
+ if i.text != '\n': roles.append(i.text.strip()) # Added strip()
174
+
175
+ try:
176
+ user_title_tag = soup.find('span', {'class': 'userTitle'})
177
+ user_title = user_title_tag.text if user_title_tag else None
178
+ except AttributeError:
179
+ user_title = None
180
+
181
+ try:
182
+ avatar_tag = soup.find('a', {'class': 'avatar avatar--l'})
183
+ avatar = MAIN_URL + avatar_tag['href'] if avatar_tag and avatar_tag.has_attr('href') else None
184
+ except TypeError:
185
+ avatar = None
186
+
187
+ messages_count = 0
188
+ reactions_count = 0
189
+ trophies_count = 0
190
+
191
+ try:
192
+ msg_tag = soup.find('a', {'href': f'/search/member?user_id={user_id}'})
193
+ if msg_tag: messages_count = int(msg_tag.text.strip().replace(',', ''))
194
+ except (AttributeError, ValueError): pass
195
+
196
+ try:
197
+ react_tag = soup.find('dl', {'class': 'pairs pairs--rows pairs--rows--centered'})
198
+ if react_tag:
199
+ dd_tag = react_tag.find('dd')
200
+ if dd_tag: reactions_count = int(dd_tag.text.strip().replace(',', ''))
201
+ except (AttributeError, ValueError): pass
202
+
203
+ try:
204
+ trophy_tag = soup.find('a', {'href': f'/members/{user_id}/trophies'})
205
+ if trophy_tag: trophies_count = int(trophy_tag.text.strip().replace(',', ''))
206
+ except (AttributeError, ValueError): pass
207
+
208
+ return Member(self, user_id, username, user_title, avatar, roles, messages_count, reactions_count, trophies_count, username_color)
209
+
210
+ except aiohttp.ClientError as e:
211
+ print(f"Ошибка сети при получении пользователя {user_id}: {e}")
212
+ return None
213
+ except Exception as e:
214
+ print(f"Неожиданная ошибка при получении пользователя {user_id}: {e}")
215
+ return None
216
+
217
+
218
+ async def get_thread(self, thread_id: int) -> 'Thread | None':
219
+ if not self._session or self._session.closed:
220
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
221
+ token = await self.token
222
+ url = f"{MAIN_URL}/threads/{thread_id}/page-1"
223
+ params = {'_xfResponseType': 'json', '_xfToken': token}
224
+ try:
225
+ async with self._session.get(url, params=params) as response:
226
+ response.raise_for_status()
227
+ data = await response.json()
228
+
229
+ if data.get('status') == 'error':
230
+ return None
231
+
232
+ if data.get('redirect'):
233
+ try:
234
+ redirect_path = data['redirect'].strip(MAIN_URL)
235
+ new_thread_id = int(redirect_path.split('/')[1].split('-')[-1])
236
+ return await self.get_thread(new_thread_id)
237
+ except (IndexError, ValueError):
238
+ print(f"Не удалось извлечь thread_id из редиректа: {data['redirect']}")
239
+ return None
240
+
241
+ html_content = unescape(data['html']['content'])
242
+ content_h1_html = unescape(data['html']['h1'])
243
+ content_soup = BeautifulSoup(html_content, 'lxml')
244
+ content_h1_soup = BeautifulSoup(content_h1_html, 'lxml')
245
+
246
+ creator = None
247
+ creator_tag = content_soup.find('a', {'class': 'username'})
248
+ if creator_tag and creator_tag.has_attr('data-user-id'):
249
+ creator_id = int(creator_tag['data-user-id'])
250
+ try:
251
+ creator = await self.get_member(creator_id)
252
+ except Exception as e:
253
+ print(f"Ошибка получения создателя ({creator_id}) для темы {thread_id}: {e}")
254
+ if not creator:
255
+ creator = Member(self, creator_id, creator_tag.text, None, None, None, None, None, None, None)
256
+ else:
257
+ print(f"Не удалось найти информацию о создателе для темы {thread_id}")
258
+ return None
259
+
260
+
261
+ create_date_tag = content_soup.find('time')
262
+ create_date = int(create_date_tag['data-time']) if create_date_tag and create_date_tag.has_attr('data-time') else 0
263
+
264
+ prefix_tag = content_h1_soup.find('span', {'class': 'label'})
265
+ if prefix_tag:
266
+ prefix = prefix_tag.text
267
+ title = content_h1_soup.text.strip(prefix).strip()
268
+ else:
269
+ prefix = ""
270
+ title = content_h1_soup.text.strip()
271
+
272
+ thread_html_content_tag = content_soup.find('div', {'class': 'bbWrapper'})
273
+ thread_html_content = str(thread_html_content_tag) if thread_html_content_tag else ""
274
+ thread_content = thread_html_content_tag.text if thread_html_content_tag else ""
275
+
276
+ try:
277
+ pages_count = int(content_soup.find_all('li', {'class': 'pageNav-page'})[-1].text)
278
+ except (IndexError, AttributeError, ValueError):
279
+ pages_count = 1
280
+
281
+ is_closed = bool(content_soup.find('dl', {'class': 'blockStatus'}))
282
+
283
+ post_article_tag = content_soup.find('article', {'id': compile(r'js-post-\d+')})
284
+ thread_post_id = int(post_article_tag['id'].strip('js-post-')) if post_article_tag and post_article_tag.has_attr('id') else 0
285
+
286
+ return Thread(self, thread_id, creator, create_date, title, prefix, thread_content, thread_html_content, pages_count, thread_post_id, is_closed)
287
+
288
+ except aiohttp.ClientError as e:
289
+ print(f"Ошибка сети при получении темы {thread_id}: {e}")
290
+ return None
291
+ except Exception as e:
292
+ print(f"Неожиданная ошибка при получении темы {thread_id}: {e}")
293
+ return None
294
+
295
+
296
+ async def get_post(self, post_id: int) -> 'Post | None':
297
+ if not self._session or self._session.closed:
298
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
299
+ url = f"{MAIN_URL}/posts/{post_id}"
300
+ try:
301
+ async with self._session.get(url) as response:
302
+ response.raise_for_status()
303
+ html_content = await response.text()
304
+
305
+ content_soup = BeautifulSoup(html_content, 'lxml')
306
+ post_article = content_soup.find('article', {'id': f'js-post-{post_id}'})
307
+ if post_article is None:
308
+ return None
309
+
310
+ creator = None
311
+ creator_info_tag = post_article.find('a', {'data-xf-init': 'member-tooltip'})
312
+ if creator_info_tag and creator_info_tag.has_attr('data-user-id'):
313
+ creator_id = int(creator_info_tag['data-user-id'])
314
+ try:
315
+ creator = await self.get_member(creator_id)
316
+ except Exception as e:
317
+ print(f"Ошибка получения создателя ({creator_id}) для поста {post_id}: {e}")
318
+ if not creator:
319
+ creator = Member(self, creator_id, creator_info_tag.text, None, None, None, None, None, None, None)
320
+ else:
321
+ print(f"Не удалось найти информацию о создателе для поста {post_id}")
322
+ return None
323
+
324
+ thread = None
325
+ html_tag = content_soup.find('html')
326
+ if html_tag and html_tag.has_attr('data-content-key') and html_tag['data-content-key'].startswith('thread-'):
327
+ try:
328
+ thread_id = int(html_tag['data-content-key'].strip('thread-'))
329
+ thread = await self.get_thread(thread_id)
330
+ except (ValueError, Exception) as e:
331
+ print(f"Ошибка получения темы для поста {post_id}: {e}")
332
+ if not thread:
333
+ print(f"Не удалось получить информацию о теме для поста {post_id}")
334
+ return None
335
+
336
+ create_date_tag = post_article.find('time', {'class': 'u-dt'})
337
+ create_date = int(create_date_tag['data-time']) if create_date_tag and create_date_tag.has_attr('data-time') else 0
338
+
339
+ html_content_tag = post_article.find('div', {'class': 'bbWrapper'})
340
+ html_content = str(html_content_tag) if html_content_tag else ""
341
+ text_content = html_content_tag.text if html_content_tag else ""
342
+
343
+ return Post(self, post_id, creator, thread, create_date, html_content, text_content)
344
+
345
+ except aiohttp.ClientError as e:
346
+ print(f"Ошибка сети при получении поста {post_id}: {e}")
347
+ return None
348
+ except Exception as e:
349
+ print(f"Неожиданная ошибка при получении поста {post_id}: {e}")
350
+ return None
351
+
352
+
353
+ async def get_profile_post(self, post_id: int) -> 'ProfilePost | None':
354
+ if not self._session or self._session.closed:
355
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
356
+ url = f"{MAIN_URL}/profile-posts/{post_id}"
357
+ try:
358
+ async with self._session.get(url) as response:
359
+ response.raise_for_status()
360
+ html_content = await response.text()
361
+
362
+ content_soup = BeautifulSoup(html_content, 'lxml')
363
+ post_article = content_soup.find('article', {'id': f'js-profilePost-{post_id}'})
364
+ if post_article is None:
365
+ return None
366
+
367
+ creator = None
368
+ creator_tag = post_article.find('a', {'class': 'username'})
369
+ if creator_tag and creator_tag.has_attr('data-user-id'):
370
+ creator_id = int(creator_tag['data-user-id'])
371
+ creator = await self.get_member(creator_id)
372
+ if not creator:
373
+ print(f"Не удалось получить создателя ({creator_id}) для поста профиля {post_id}")
374
+ return None
375
+ else:
376
+ print(f"Не удалось найти ID создателя для поста профиля {post_id}")
377
+ return None
378
+
379
+ profile_owner = None
380
+ profile_owner_tag = post_article.find('h4', {'class': 'attribution'})
381
+ if profile_owner_tag:
382
+ profile_link = profile_owner_tag.find('a', {'class': 'username'})
383
+ if profile_link and profile_link.has_attr('data-user-id'):
384
+ try:
385
+ profile_id = int(profile_link['data-user-id'])
386
+ profile_owner = await self.get_member(profile_id)
387
+ except (ValueError, Exception) as e:
388
+ print(f"Ошибка получения владельца профиля ({profile_id}) для поста {post_id}: {e}")
389
+
390
+ if not profile_owner:
391
+ print(f"Не удалось определить владельца профиля для поста {post_id}")
392
+ return None
393
+
394
+ create_date_tag = post_article.find('time')
395
+ create_date = int(create_date_tag['data-time']) if create_date_tag and create_date_tag.has_attr('data-time') else 0
396
+
397
+ html_content_tag = post_article.find('div', {'class': 'bbWrapper'})
398
+ html_content = str(html_content_tag) if html_content_tag else ""
399
+ text_content = html_content_tag.text if html_content_tag else ""
400
+
401
+ return ProfilePost(self, post_id, creator, profile_owner, create_date, html_content, text_content)
402
+
403
+ except aiohttp.ClientError as e:
404
+ print(f"Ошибка сети при получении поста профиля {post_id}: {e}")
405
+ return None
406
+ except Exception as e:
407
+ print(f"Неожиданная ошибка при получении поста профиля {post_id}: {e}")
408
+ return None
409
+
410
+ async def get_forum_statistic(self) -> 'Statistic | None':
411
+ if not self._session or self._session.closed:
412
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
413
+ url = MAIN_URL
414
+ try:
415
+ async with self._session.get(url) as response:
416
+ response.raise_for_status()
417
+ html_content = await response.text()
418
+
419
+ content_soup = BeautifulSoup(html_content, 'lxml')
420
+
421
+ threads_count = 0
422
+ posts_count = 0
423
+ users_count = 0
424
+ last_register_member = None
425
+
426
+ try:
427
+ threads_tag = content_soup.find('dl', {'class': 'pairs pairs--justified count--threads'})
428
+ if threads_tag:
429
+ dd_tag = threads_tag.find('dd')
430
+ if dd_tag: threads_count = int(dd_tag.text.replace(',', ''))
431
+ except (AttributeError, ValueError): pass
432
+
433
+ try:
434
+ posts_tag = content_soup.find('dl', {'class': 'pairs pairs--justified count--messages'})
435
+ if posts_tag:
436
+ dd_tag = posts_tag.find('dd')
437
+ if dd_tag: posts_count = int(dd_tag.text.replace(',', ''))
438
+ except (AttributeError, ValueError): pass
439
+
440
+ try:
441
+ users_tag = content_soup.find('dl', {'class': 'pairs pairs--justified count--users'})
442
+ if users_tag:
443
+ dd_tag = users_tag.find('dd')
444
+ if dd_tag: users_count = int(dd_tag.text.replace(',', ''))
445
+ except (AttributeError, ValueError): pass
446
+
447
+ try:
448
+ latest_member_dl = content_soup.find('dl', {'class': 'pairs pairs--justified'})
449
+ if latest_member_dl:
450
+ latest_member_link = latest_member_dl.find('a', {'data-user-id': True})
451
+ if latest_member_link and latest_member_link.has_attr('data-user-id'):
452
+ last_user_id = int(latest_member_link['data-user-id'])
453
+ last_register_member = await self.get_member(last_user_id)
454
+ except (AttributeError, ValueError, Exception) as e:
455
+ print(f"Ошибка получения последнего зарегистрированного пользователя: {e}")
456
+
457
+
458
+ return Statistic(self, threads_count, posts_count, users_count, last_register_member)
459
+
460
+ except aiohttp.ClientError as e:
461
+ print(f"Ошибка сети при получении статистики форума: {e}")
462
+ return None
463
+ except Exception as e:
464
+ print(f"Неожиданная ошибка при получении статистики форума: {e}")
465
+ return None
466
+
467
+
468
+ # ---------------================ МЕТОДЫ ОБЪЕКТОВ ====================--------------------
469
+
470
+ # CATEGORY
471
+ async def create_thread(self, category_id: int, title: str, message_html: str, discussion_type: str = 'discussion', watch_thread: bool = True) -> aiohttp.ClientResponse:
472
+ if not self._session or self._session.closed:
473
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
474
+ token = await self.token
475
+ url = f"{MAIN_URL}/forums/{category_id}/post-thread"
476
+ params = {'inline-mode': '1'}
477
+ payload = {
478
+ '_xfToken': token,
479
+ 'title': title,
480
+ 'message_html': message_html,
481
+ 'discussion_type': discussion_type,
482
+ 'watch_thread': int(watch_thread)
483
+ }
484
+ try:
485
+ response = await self._session.post(url, params=params, data=payload)
486
+ return response
487
+ except aiohttp.ClientError as e:
488
+ print(f"Ошибка сети при создании темы в категории {category_id}: {e}")
489
+ raise e
490
+
491
+
492
+ async def set_read_category(self, category_id: int) -> aiohttp.ClientResponse:
493
+ if not self._session or self._session.closed:
494
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
495
+ token = await self.token
496
+ url = f"{MAIN_URL}/forums/{category_id}/mark-read"
497
+ payload = {'_xfToken': token}
498
+ try:
499
+ response = await self._session.post(url, data=payload)
500
+ return response
501
+ except aiohttp.ClientError as e:
502
+ print(f"Ошибка сети при отметке категории {category_id} как прочитанной: {e}")
503
+ raise e
504
+
505
+
506
+ async def watch_category(self, category_id: int, notify: str, send_alert: bool = True, send_email: bool = False, stop: bool = False) -> aiohttp.ClientResponse:
507
+ if not self._session or self._session.closed:
508
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
509
+ token = await self.token
510
+ url = f"{MAIN_URL}/forums/{category_id}/watch"
511
+ if stop:
512
+ payload = {'_xfToken': token, 'stop': "1"}
513
+ else:
514
+ payload = {
515
+ '_xfToken': token,
516
+ 'send_alert': int(send_alert),
517
+ 'send_email': int(send_email),
518
+ 'notify': notify
519
+ }
520
+ try:
521
+ response = await self._session.post(url, data=payload)
522
+ return response
523
+ except aiohttp.ClientError as e:
524
+ print(f"Ошибка сети при настройке отслеживания категории {category_id}: {e}")
525
+ raise e
526
+
527
+
528
+ async def get_threads(self, category_id: int, page: int = 1) -> Optional[Dict[str, List[int]]]:
529
+ if not self._session or self._session.closed:
530
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
531
+ token = await self.token
532
+ url = f"{MAIN_URL}/forums/{category_id}/page-{page}"
533
+ params = {'_xfResponseType': 'json', '_xfToken': token}
534
+ try:
535
+ async with self._session.get(url, params=params) as response:
536
+ response.raise_for_status()
537
+ data = await response.json()
538
+
539
+ if data.get('status') == 'error':
540
+ return None
541
+
542
+ html_content = unescape(data['html']['content'])
543
+ soup = BeautifulSoup(html_content, "lxml")
544
+ result = {'pins': [], 'unpins': []}
545
+ for thread in soup.find_all('div', compile('structItem structItem--thread.*')):
546
+ link_tags = thread.find_all('div', "structItem-title")[0].find_all("a")
547
+ if not link_tags: continue
548
+ link = link_tags[-1]
549
+ thread_ids = findall(r'\d+', link.get('href', ''))
550
+ if not thread_ids: continue
551
+
552
+ thread_id = int(thread_ids[0])
553
+ if len(thread.find_all('i', {'title': 'Закреплено'})) > 0:
554
+ result['pins'].append(thread_id)
555
+ else:
556
+ result['unpins'].append(thread_id)
557
+ return result
558
+ except aiohttp.ClientError as e:
559
+ print(f"Ошибка сети при получении тем из категории {category_id} (страница {page}): {e}")
560
+ return None
561
+ except Exception as e:
562
+ print(f"Неожиданная ошибка при получении тем из категории {category_id} (страница {page}): {e}")
563
+ return None
564
+
565
+
566
+ async def get_thread_category_detail(self, category_id: int, page: int = 1) -> Optional[List[Dict]]:
567
+ if not self._session or self._session.closed:
568
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
569
+ token = await self.token
570
+ url = f"{MAIN_URL}/forums/{category_id}/page-{page}"
571
+ params = {'_xfResponseType': 'json', '_xfToken': token}
572
+ try:
573
+ async with self._session.get(url, params=params) as response:
574
+ response.raise_for_status()
575
+ data = await response.json()
576
+
577
+ if data.get('status') == 'error':
578
+ return None
579
+
580
+ html_content = unescape(data['html']['content'])
581
+ soup = BeautifulSoup(html_content, "lxml")
582
+ result = []
583
+ seen_thread_ids = set()
584
+
585
+ for thread in soup.find_all('div', class_=compile('structItem structItem--thread.*')):
586
+ title_div = thread.find('div', "structItem-title")
587
+ if not title_div: continue
588
+ link_tags = title_div.find_all("a")
589
+ if not link_tags: continue
590
+ link = link_tags[-1]
591
+
592
+ thread_ids = findall(r'\d+', link.get('href', ''))
593
+ if not thread_ids: continue
594
+ thread_id = int(thread_ids[0])
595
+
596
+ if thread_id in seen_thread_ids:
597
+ continue
598
+ seen_thread_ids.add(thread_id)
599
+
600
+ thread_data = {}
601
+
602
+ minor_div = thread.find('div', 'structItem-cell--main').find('div', 'structItem-minor')
603
+ username_author_tag = minor_div.find('ul', 'structItem-parts').find('a', class_='username') if minor_div else None
604
+ thread_data['username_author'] = username_author_tag.text.strip() if username_author_tag else None
605
+ thread_data['thread_title'] = link.text.strip()
606
+
607
+ prefix_label = title_div.find('span', class_='label')
608
+ thread_data['prefix'] = prefix_label.text.strip() if prefix_label else None
609
+
610
+ thread_data['username_author_color'] = '#fff'
611
+ if username_author_tag:
612
+ for style, color in ROLE_COLOR.items():
613
+ if style in str(username_author_tag):
614
+ thread_data['username_author_color'] = color
615
+ break
616
+
617
+ start_date_li = minor_div.find('li', 'structItem-startDate') if minor_div else None
618
+ time_tag = start_date_li.find('time', class_='u-dt') if start_date_li else None
619
+ created_date = time_tag.get('data-time') if time_tag else None
620
+ thread_data['created_date'] = int(created_date) if created_date and created_date.isdigit() else None
621
+
622
+ latest_cell = thread.find('div', 'structItem-cell--latest')
623
+ last_message_username_tag = latest_cell.find('div', 'structItem-minor').find(class_=compile('username')) if latest_cell else None
624
+ thread_data['username_last_message'] = last_message_username_tag.text.strip() if last_message_username_tag else None
625
+
626
+ thread_data['username_last_message_color'] = '#fff'
627
+ if last_message_username_tag:
628
+ for style, color in ROLE_COLOR.items():
629
+ if style in str(last_message_username_tag):
630
+ thread_data['username_last_message_color'] = color
631
+ break
632
+
633
+ latest_date_tag = latest_cell.find('time', class_='structItem-latestDate') if latest_cell else None
634
+ last_message_date = latest_date_tag.get('data-time') if latest_date_tag else None
635
+ thread_data['last_message_date'] = int(last_message_date) if last_message_date and last_message_date.isdigit() else None
636
+
637
+ thread_data['thread_id'] = thread_id
638
+ thread_data['is_pinned'] = len(thread.find_all('i', {'title': 'Закреплено'})) > 0
639
+ thread_data['is_closed'] = len(thread.find_all('i', {'title': 'Закрыта'})) > 0
640
+
641
+ result.append(thread_data)
642
+
643
+ return result
644
+ except aiohttp.ClientError as e:
645
+ print(f"Ошибка сети при получении расширенных тем из категории {category_id} (страница {page}): {e}")
646
+ return None
647
+ except Exception as e:
648
+ print(f"Неожиданная ошибка при получении расширенных тем из категории {category_id} (страница {page}): {e}")
649
+ return None
650
+
651
+
652
+ async def get_parent_category_of_category(self, category_id: int) -> Optional[Category]:
653
+ if not self._session or self._session.closed:
654
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
655
+ url = f"{MAIN_URL}/forums/{category_id}"
656
+ try:
657
+ async with self._session.get(url) as response:
658
+ response.raise_for_status()
659
+ html_content = await response.text()
660
+ soup = BeautifulSoup(html_content, 'lxml')
661
+
662
+ breadcrumbs = soup.find('ul', {'class': 'p-breadcrumbs'})
663
+ if not breadcrumbs: return None
664
+ parent_li = breadcrumbs.find_all('li')
665
+ if len(parent_li) < 2: return None
666
+ parent_link = parent_li[-1].find('a')
667
+ if not parent_link or not parent_link.get('href'): return None
668
+
669
+ href_parts = parent_link['href'].split('/')
670
+ if len(href_parts) < 3: return None
671
+ parent_category_id_str = href_parts[2]
672
+
673
+ if not parent_category_id_str.isdigit():
674
+ return None
675
+
676
+ parent_category_id = int(parent_category_id_str)
677
+ return await self.get_category(parent_category_id)
678
+ except aiohttp.ClientError as e:
679
+ print(f"Ошибка сети при получении родительской категории для {category_id}: {e}")
680
+ return None
681
+ except Exception as e:
682
+ print(f"Неожиданная ошибка при получении родительской категории для {category_id}: {e}")
683
+ return None
684
+
685
+
686
+ async def get_categories(self, category_id: int) -> Optional[List[int]]:
687
+ if not self._session or self._session.closed:
688
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
689
+ token = await self.token
690
+ url = f"{MAIN_URL}/forums/{category_id}/page-1" # page-1 может быть багом в оригинале?
691
+ params = {'_xfResponseType': 'json', '_xfToken': token}
692
+ try:
693
+ async with self._session.get(url, params=params) as response:
694
+ response.raise_for_status()
695
+ data = await response.json()
696
+
697
+ if data.get('status') == 'error':
698
+ return None
699
+
700
+ html_content = unescape(data['html']['content'])
701
+ soup = BeautifulSoup(html_content, "lxml")
702
+ categories = []
703
+ for category_div in soup.find_all('div', compile('.*node--depth2 node--forum.*')):
704
+ link = category_div.find("a")
705
+ if link and link.get('href'):
706
+ ids = findall(r'\d+', link['href'])
707
+ if ids:
708
+ categories.append(int(ids[0]))
709
+ return categories
710
+ except aiohttp.ClientError as e:
711
+ print(f"Ошибка сети при получении дочерних категорий из {category_id}: {e}")
712
+ return None
713
+ except Exception as e:
714
+ print(f"Неожиданная ошибка при получении дочерних категорий из {category_id}: {e}")
715
+ return None
716
+
717
+
718
+ # MEMBER
719
+ async def follow_member(self, member_id: int) -> aiohttp.ClientResponse:
720
+ if member_id == self.current_member.id:
721
+ raise ThisIsYouError(member_id)
722
+ if not self._session or self._session.closed:
723
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
724
+ token = await self.token
725
+ url = f"{MAIN_URL}/members/{member_id}/follow"
726
+ payload = {'_xfToken': token}
727
+ try:
728
+ response = await self._session.post(url, data=payload)
729
+ return response
730
+ except aiohttp.ClientError as e:
731
+ print(f"Ошибка сети при подписке/отписке от пользователя {member_id}: {e}")
732
+ raise e
733
+
734
+
735
+ async def ignore_member(self, member_id: int) -> aiohttp.ClientResponse:
736
+ if member_id == self.current_member.id:
737
+ raise ThisIsYouError(member_id)
738
+ if not self._session or self._session.closed:
739
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
740
+ token = await self.token
741
+ url = f"{MAIN_URL}/members/{member_id}/ignore"
742
+ payload = {'_xfToken': token}
743
+ try:
744
+ response = await self._session.post(url, data=payload)
745
+ return response
746
+ except aiohttp.ClientError as e:
747
+ print(f"Ошибка сети при игнорировании/отмене игнорирования пользователя {member_id}: {e}")
748
+ raise e
749
+
750
+
751
+ async def add_profile_message(self, member_id: int, message_html: str) -> aiohttp.ClientResponse:
752
+ if not self._session or self._session.closed:
753
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
754
+ token = await self.token
755
+ url = f"{MAIN_URL}/members/{member_id}/post"
756
+ payload = {'_xfToken': token, 'message_html': message_html}
757
+ try:
758
+ response = await self._session.post(url, data=payload)
759
+ return response
760
+ except aiohttp.ClientError as e:
761
+ print(f"Ошибка сети при добавлении сообщения на стену пользователя {member_id}: {e}")
762
+ raise e
763
+
764
+
765
+ async def get_profile_messages(self, member_id: int, page: int = 1) -> Optional[List[int]]:
766
+ if not self._session or self._session.closed:
767
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
768
+ token = await self.token
769
+ url = f"{MAIN_URL}/members/{member_id}/page-{page}"
770
+ params = {'_xfResponseType': 'json', '_xfToken': token}
771
+ try:
772
+ async with self._session.get(url, params=params) as response:
773
+ response.raise_for_status()
774
+ data = await response.json()
775
+
776
+ if data.get('status') == 'error':
777
+ return None
778
+
779
+ html_content = unescape(data['html']['content'])
780
+ soup = BeautifulSoup(html_content, "lxml")
781
+ messages = []
782
+ for post in soup.find_all('article', {'id': compile('js-profilePost-*')}):
783
+ post_id_str = post.get('id', '').strip('js-profilePost-')
784
+ if post_id_str.isdigit():
785
+ messages.append(int(post_id_str))
786
+ return messages
787
+ except aiohttp.ClientError as e:
788
+ print(f"Ошибка сети при получении сообщений профиля {member_id} (страница {page}): {e}")
789
+ return None
790
+ except Exception as e:
791
+ print(f"Неожиданная ошибка при получении сообщений профиля {member_id} (страница {page}): {e}")
792
+ return None
793
+
794
+
795
+ # POST
796
+ async def react_post(self, post_id: int, reaction_id: int = 1) -> aiohttp.ClientResponse:
797
+ if not self._session or self._session.closed:
798
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
799
+ token = await self.token
800
+ url = f'{MAIN_URL}/posts/{post_id}/react'
801
+ params = {'reaction_id': str(reaction_id)}
802
+ payload = {'_xfToken': token}
803
+ try:
804
+ response = await self._session.post(url, params=params, data=payload)
805
+ return response
806
+ except aiohttp.ClientError as e:
807
+ print(f"Ошибка сети при установке реакции на пост {post_id}: {e}")
808
+ raise e
809
+
810
+
811
+ async def edit_post(self, post_id: int, message_html: str) -> aiohttp.ClientResponse:
812
+ if not self._session or self._session.closed:
813
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
814
+ token = await self.token
815
+
816
+ post_info = await self.get_post(post_id)
817
+ if not post_info or not post_info.thread:
818
+ raise ValueError(f"Не удалось получить информацию о посте {post_id} для редактирования")
819
+ else:
820
+ title_of_thread_post = post_info.thread.title
821
+
822
+ url = f"{MAIN_URL}/posts/{post_id}/edit"
823
+ payload = {
824
+ "_xfToken": token,
825
+ "title": title_of_thread_post,
826
+ "message_html": message_html,
827
+ "message": message_html
828
+ }
829
+ try:
830
+ response = await self._session.post(url, data=payload)
831
+ return response
832
+ except aiohttp.ClientError as e:
833
+ print(f"Ошибка сети при редактировании поста {post_id}: {e}")
834
+ raise e
835
+
836
+
837
+ async def delete_post(self, post_id: int, reason: str, hard_delete: bool = False) -> aiohttp.ClientResponse:
838
+ if not self._session or self._session.closed:
839
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
840
+ token = await self.token
841
+ url = f"{MAIN_URL}/posts/{post_id}/delete"
842
+ payload = {
843
+ "_xfToken": token,
844
+ "reason": reason,
845
+ "hard_delete": int(hard_delete)
846
+ }
847
+ try:
848
+ response = await self._session.post(url, data=payload)
849
+ return response
850
+ except aiohttp.ClientError as e:
851
+ print(f"Ошибка сети при удалении поста {post_id}: {e}")
852
+ raise e
853
+
854
+ async def bookmark_post(self, post_id: int) -> aiohttp.ClientResponse:
855
+ if not self._session or self._session.closed:
856
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
857
+ token = await self.token
858
+ url = f"{MAIN_URL}/posts/{post_id}/bookmark"
859
+ payload = {"_xfToken": token}
860
+ try:
861
+ response = await self._session.post(url, data=payload)
862
+ return response
863
+ except aiohttp.ClientError as e:
864
+ print(f"Ошибка сети при добавлении поста {post_id} в закладки: {e}")
865
+ raise e
866
+
867
+ # PROFILE POST
868
+ async def react_profile_post(self, post_id: int, reaction_id: int = 1) -> aiohttp.ClientResponse:
869
+ if not self._session or self._session.closed:
870
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
871
+ token = await self.token
872
+ url = f'{MAIN_URL}/profile-posts/{post_id}/react'
873
+ params = {'reaction_id': str(reaction_id)}
874
+ payload = {'_xfToken': token}
875
+ try:
876
+ response = await self._session.post(url, params=params, data=payload)
877
+ return response
878
+ except aiohttp.ClientError as e:
879
+ print(f"Ошибка сети при установке реакции на пост профиля {post_id}: {e}")
880
+ raise e
881
+
882
+
883
+ async def comment_profile_post(self, post_id: int, message_html: str) -> aiohttp.ClientResponse:
884
+ if not self._session or self._session.closed:
885
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
886
+ token = await self.token
887
+ url = f"{MAIN_URL}/profile-posts/{post_id}/add-comment"
888
+ payload = {"_xfToken": token, "message_html": message_html}
889
+ try:
890
+ response = await self._session.post(url, data=payload)
891
+ return response
892
+ except aiohttp.ClientError as e:
893
+ print(f"Ошибка сети при комментировании поста профиля {post_id}: {e}")
894
+ raise e
895
+
896
+
897
+ async def delete_profile_post(self, post_id: int, reason: str, hard_delete: bool = False) -> aiohttp.ClientResponse:
898
+ if not self._session or self._session.closed:
899
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
900
+ token = await self.token
901
+ url = f"{MAIN_URL}/profile-posts/{post_id}/delete"
902
+ payload = {
903
+ "_xfToken": token,
904
+ "reason": reason,
905
+ "hard_delete": int(hard_delete)
906
+ }
907
+ try:
908
+ response = await self._session.post(url, data=payload)
909
+ return response
910
+ except aiohttp.ClientError as e:
911
+ print(f"Ошибка сети при удалении поста профиля {post_id}: {e}")
912
+ raise e
913
+
914
+
915
+ async def edit_profile_post(self, post_id: int, message_html: str) -> aiohttp.ClientResponse:
916
+ if not self._session or self._session.closed:
917
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
918
+ token = await self.token
919
+ url = f"{MAIN_URL}/profile-posts/{post_id}/edit"
920
+ payload = {
921
+ "_xfToken": token,
922
+ "message_html": message_html,
923
+ "message": message_html
924
+ }
925
+ try:
926
+ response = await self._session.post(url, data=payload)
927
+ return response
928
+ except aiohttp.ClientError as e:
929
+ print(f"Ошибка сети при редактировании поста профиля {post_id}: {e}")
930
+ raise e
931
+
932
+ async def answer_thread(self, thread_id: int, message_html: str) -> aiohttp.ClientResponse:
933
+ if not self._session or self._session.closed:
934
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
935
+ token = await self.token
936
+ url = f"{MAIN_URL}/threads/{thread_id}/add-reply"
937
+ payload = {
938
+ '_xfToken': token,
939
+ 'message_html': message_html
940
+ }
941
+ try:
942
+ async with self._session.post(url, data=payload) as response:
943
+ return response
944
+ except aiohttp.ClientError as e:
945
+ print(f"Ошибка сети при ответе в теме {thread_id}: {e}")
946
+ raise e
947
+
948
+ async def watch_thread(self, thread_id: int, email_subscribe: bool = False, stop: bool = False) -> aiohttp.ClientResponse:
949
+ if not self._session or self._session.closed:
950
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
951
+ token = await self.token
952
+ url = f"{MAIN_URL}/threads/{thread_id}/watch"
953
+ payload = {
954
+ '_xfToken': token,
955
+ 'stop': int(stop),
956
+ 'email_subscribe': int(email_subscribe)
957
+ }
958
+ try:
959
+ async with self._session.post(url, data=payload) as response:
960
+ return response
961
+ except aiohttp.ClientError as e:
962
+ print(f"Ошибка сети при изменении статуса отслеживания темы {thread_id}: {e}")
963
+ raise e
964
+
965
+ async def delete_thread(self, thread_id: int, reason: str, hard_delete: bool = False) -> aiohttp.ClientResponse:
966
+ if not self._session or self._session.closed:
967
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
968
+ token = await self.token
969
+ url = f"{MAIN_URL}/threads/{thread_id}/delete"
970
+ payload = {
971
+ "reason": reason,
972
+ "hard_delete": int(hard_delete),
973
+ "_xfToken": token
974
+ }
975
+ try:
976
+ async with self._session.post(url, data=payload) as response:
977
+ return response
978
+ except aiohttp.ClientError as e:
979
+ print(f"Ошибка сети при удалении темы {thread_id}: {e}")
980
+ raise e
981
+
982
+ async def edit_thread(self, thread_id: int, message_html: str) -> Optional[aiohttp.ClientResponse]:
983
+ if not self._session or self._session.closed:
984
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
985
+ token = await self.token
986
+ get_url = f"{MAIN_URL}/threads/{thread_id}/page-1"
987
+ thread_post_id = None
988
+
989
+ try:
990
+ async with self._session.get(get_url) as response:
991
+ response.raise_for_status()
992
+ html_content = await response.text()
993
+ soup = BeautifulSoup(html_content, 'lxml')
994
+ post_article = soup.find('article', {'id': compile('js-post-*')})
995
+ if post_article and 'id' in post_article.attrs:
996
+ thread_post_id = post_article['id'].strip('js-post-')
997
+ else:
998
+ print(f"Не удалось найти ID первого поста для темы {thread_id}")
999
+ return None
1000
+
1001
+ except aiohttp.ClientError as e:
1002
+ print(f"Ошибка сети при получении информации для редактирования темы {thread_id}: {e}")
1003
+ return None
1004
+ except Exception as e:
1005
+ print(f"Ошибка парсинга при получении информации для редактирования темы {thread_id}: {e}")
1006
+ return None
1007
+
1008
+ if not thread_post_id:
1009
+ return None
1010
+
1011
+ edit_url = f"{MAIN_URL}/posts/{thread_post_id}/edit"
1012
+ payload = {
1013
+ "message_html": message_html,
1014
+ "message": message_html,
1015
+ "_xfToken": token
1016
+ }
1017
+ try:
1018
+ async with self._session.post(edit_url, data=payload) as response:
1019
+ return response
1020
+ except aiohttp.ClientError as e:
1021
+ print(f"Ошибка сети при редактировании темы {thread_id} (пост {thread_post_id}): {e}")
1022
+ raise e
1023
+
1024
+
1025
+ async def edit_thread_info(self, thread_id: int, title: str, prefix_id: Optional[int] = None, sticky: bool = True, opened: bool = True) -> aiohttp.ClientResponse:
1026
+ if not self._session or self._session.closed:
1027
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
1028
+ token = await self.token
1029
+ url = f"{MAIN_URL}/threads/{thread_id}/edit"
1030
+ payload = {
1031
+ "_xfToken": token,
1032
+ 'title': title
1033
+ }
1034
+
1035
+ if prefix_id is not None:
1036
+ payload['prefix_id'] = prefix_id
1037
+ if opened:
1038
+ payload["discussion_open"] = 1
1039
+ else:
1040
+ pass
1041
+ if sticky:
1042
+ payload["sticky"] = 1
1043
+
1044
+ try:
1045
+ async with self._session.post(url, data=payload) as response:
1046
+ return response
1047
+ except aiohttp.ClientError as e:
1048
+ print(f"Ошибка сети при изменении информации темы {thread_id}: {e}")
1049
+ raise e
1050
+
1051
+
1052
+ async def get_thread_category(self, thread_id: int) -> Optional['Category']:
1053
+ if not self._session or self._session.closed:
1054
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
1055
+
1056
+ url = f"{MAIN_URL}/threads/{thread_id}/page-1"
1057
+ try:
1058
+ async with self._session.get(url) as response:
1059
+ response.raise_for_status()
1060
+ html_content = await response.text()
1061
+ soup = BeautifulSoup(html_content, 'lxml')
1062
+
1063
+ html_tag = soup.find('html')
1064
+ if not html_tag or 'data-container-key' not in html_tag.attrs:
1065
+ print(f"Не удалось найти data-container-key для темы {thread_id}")
1066
+ return None
1067
+
1068
+ container_key = html_tag['data-container-key']
1069
+ if not container_key.startswith('node-'):
1070
+ print(f"Некорректный data-container-key '{container_key}' для темы {thread_id}")
1071
+ return None
1072
+
1073
+ category_id_str = container_key.strip('node-')
1074
+ try:
1075
+ category_id = int(category_id_str)
1076
+ except ValueError:
1077
+ print(f"Не удалось преобразовать ID категории '{category_id_str}' в число для темы {thread_id}")
1078
+ return None
1079
+
1080
+ return await self.get_category(category_id)
1081
+
1082
+ except aiohttp.ClientError as e:
1083
+ print(f"Ошибка сети при получении категории темы {thread_id}: {e}")
1084
+ return None
1085
+ except Exception as e:
1086
+ print(f"Ошибка парсинга при получении категории темы {thread_id}: {e}")
1087
+ return None
1088
+
1089
+ async def get_thread_posts(self, thread_id: int, page: int = 1) -> Optional[List[str]]:
1090
+ if not self._session or self._session.closed:
1091
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
1092
+ token = await self.token
1093
+ url = f"{MAIN_URL}/threads/{thread_id}/page-{page}"
1094
+ params = {'_xfResponseType': 'json', '_xfToken': token}
1095
+ try:
1096
+ async with self._session.get(url, params=params) as response:
1097
+ if response.status == 404:
1098
+ return []
1099
+ response.raise_for_status()
1100
+ data = await response.json()
1101
+
1102
+ if data.get('status') == 'error':
1103
+ return None
1104
+
1105
+ if 'html' not in data or 'content' not in data['html']:
1106
+ return []
1107
+
1108
+ soup = BeautifulSoup(unescape(data['html']['content']), "lxml")
1109
+ posts = soup.find_all('article', {'id': compile('js-post-*')})
1110
+ return [i['id'].strip('js-post-') for i in posts if 'id' in i.attrs]
1111
+
1112
+ except aiohttp.ClientError as e:
1113
+ print(f"Ошибка сети при получении постов темы {thread_id}, стр {page}: {e}")
1114
+ return None
1115
+ except Exception as e:
1116
+ print(f"Неожиданная ошибка при получении постов темы {thread_id}, стр {page}: {e}")
1117
+ return None
1118
+
1119
+ async def get_all_thread_posts(self, thread_id: int) -> List[str]:
1120
+ if not self._session or self._session.closed:
1121
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
1122
+ token = await self.token
1123
+
1124
+ all_posts_ids = []
1125
+ page = 1
1126
+ pages_count = 1
1127
+ processed_first_page = False
1128
+
1129
+ while True:
1130
+ url = f"{MAIN_URL}/threads/{thread_id}/page-{page}"
1131
+ params = {'_xfResponseType': 'json', '_xfToken': token}
1132
+ try:
1133
+ async with self._session.get(url, params=params) as response:
1134
+ if response.status == 404 and page > 1:
1135
+ break
1136
+ response.raise_for_status()
1137
+ data = await response.json()
1138
+
1139
+ if data.get('status') == 'error':
1140
+ if page == 1:
1141
+ print(f"API вернуло ошибку на первой странице темы {thread_id}: {data.get('errors')}")
1142
+ break
1143
+
1144
+ if 'html' not in data or 'content' not in data['html']:
1145
+ print(f"Ответ API для темы {thread_id} стр {page} не содержит HTML.")
1146
+ if page == 1:
1147
+ return []
1148
+ else:
1149
+ break
1150
+
1151
+ html_content = unescape(data['html']['content'])
1152
+ soup = BeautifulSoup(html_content, "lxml")
1153
+ current_page_posts = soup.find_all('article', {'id': compile('js-post-*')})
1154
+ post_ids = [i['id'].strip('js-post-') for i in current_page_posts if 'id' in i.attrs]
1155
+
1156
+ if not post_ids and page > 1:
1157
+ break
1158
+
1159
+ all_posts_ids.extend(post_ids)
1160
+
1161
+ if not processed_first_page:
1162
+ pages_count = 1
1163
+ try:
1164
+ page_nav = soup.find('ul', class_='pageNav-main')
1165
+ if page_nav:
1166
+ last_page_li = page_nav.find_all('li', class_='pageNav-page')
1167
+ if last_page_li:
1168
+ pages_count = int(last_page_li[-1].text)
1169
+ except (IndexError, AttributeError, ValueError, TypeError):
1170
+ pages_count = 1
1171
+ processed_first_page = True
1172
+
1173
+ if page >= pages_count:
1174
+ break
1175
+
1176
+ page += 1
1177
+
1178
+ except aiohttp.ClientError as e:
1179
+ print(f"Ошибка сети при получении всех постов темы {thread_id}, стр {page}: {e}")
1180
+ break
1181
+ except Exception as e:
1182
+ print(f"Неожиданная ошибка при получении всех постов темы {thread_id}, стр {page}: {e}")
1183
+ break
1184
+
1185
+ return all_posts_ids
1186
+
1187
+
1188
+ async def react_thread(self, thread_id: int, reaction_id: int = 1) -> Optional[aiohttp.ClientResponse]:
1189
+ if not self._session or self._session.closed:
1190
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
1191
+ token = await self.token
1192
+ get_url = f"{MAIN_URL}/threads/{thread_id}/page-1"
1193
+ thread_post_id = None
1194
+
1195
+ try:
1196
+ async with self._session.get(get_url) as response:
1197
+ response.raise_for_status()
1198
+ html_content = await response.text()
1199
+ soup = BeautifulSoup(html_content, 'lxml')
1200
+ post_article = soup.find('article', {'id': compile('js-post-*')})
1201
+ if post_article and 'id' in post_article.attrs:
1202
+ thread_post_id = post_article['id'].strip('js-post-')
1203
+ else:
1204
+ print(f"Не удалось найти ID первого поста для реакции в теме {thread_id}")
1205
+ return None
1206
+
1207
+ except aiohttp.ClientError as e:
1208
+ print(f"Ошибка сети при получении ID поста для реакции в теме {thread_id}: {e}")
1209
+ return None
1210
+ except Exception as e:
1211
+ print(f"Ошибка парсинга при получении ID поста для реакции в теме {thread_id}: {e}")
1212
+ return None
1213
+
1214
+ if not thread_post_id:
1215
+ return None
1216
+
1217
+ react_url = f'{MAIN_URL}/posts/{thread_post_id}/react'
1218
+ params = {'reaction_id': str(reaction_id)}
1219
+ payload = {'_xfToken': token}
1220
+
1221
+ try:
1222
+ async with self._session.post(react_url, params=params, data=payload) as response:
1223
+ return response
1224
+ except aiohttp.ClientError as e:
1225
+ print(f"Ошибка сети при установке реакции {reaction_id} на пост {thread_post_id} темы {thread_id}: {e}")
1226
+ raise e
1227
+
1228
+ # OTHER
1229
+ async def send_form(self, form_id: int, data: dict) -> aiohttp.ClientResponse:
1230
+ """Заполнить форму
1231
+
1232
+ Attributes:
1233
+ form_id (int): ID формы
1234
+ data (dict): Информация для запонения в виде словаря. Форма словаря: {'question[id вопроса]' = 'необходимая информация'} | Пример: {'question[531]' = '1'}
1235
+
1236
+ Returns:
1237
+ Объект Response модуля aiohttp
1238
+ """
1239
+ if not self._session or self._session.closed:
1240
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
1241
+
1242
+ data.update({'_xfToken': await self.token})
1243
+ try:
1244
+ async with self._session.post(f"{MAIN_URL}/form/{form_id}/submit", data=data) as response:
1245
+ return response
1246
+ except aiohttp.ClientError as e:
1247
+ print(f"Ошибка сети при отправке формы {form_id}: {e}")
1248
+ raise e
1249
+
1250
+ async def get_notifications(self) -> list:
1251
+ if not self._session or self._session.closed:
1252
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
1253
+ url = f"{MAIN_URL}/account/alerts"
1254
+ notifications = []
1255
+ try:
1256
+ async with self._session.get(url) as response:
1257
+ response.raise_for_status()
1258
+ html_content = await response.text()
1259
+ soup = BeautifulSoup(html_content, 'lxml')
1260
+
1261
+ for alert in soup.find_all('li', {'class': 'js-alert'}):
1262
+ if not alert.has_attr('data-alert-id'):
1263
+ continue
1264
+
1265
+ sender = None
1266
+ username_link = alert.find('a', {'class': 'username'})
1267
+ if username_link:
1268
+ sender_id_str = username_link.get('data-user-id', '0')
1269
+ sender = {
1270
+ 'id': int(sender_id_str) if sender_id_str.isdigit() else 0,
1271
+ 'name': unescape(username_link.get_text(strip=True)),
1272
+ 'avatar': None,
1273
+ 'avatar_color': None,
1274
+ 'initials': None
1275
+ }
1276
+
1277
+ avatar_container = alert.find('div', class_='contentRow-figure')
1278
+ if avatar_container:
1279
+ avatar_img = avatar_container.find('img', {'class': 'avatar'})
1280
+ avatar_span = avatar_container.find('span', {'class': 'avatar'})
1281
+
1282
+ if avatar_img and avatar_img.has_attr('src'):
1283
+ sender['avatar'] = avatar_img['src']
1284
+ elif avatar_span and 'avatar--default' in avatar_span.get('class', []):
1285
+ sender['avatar_color'] = avatar_span.get('style')
1286
+ sender['initials'] = unescape(avatar_span.get_text(strip=True)) if avatar_span else None
1287
+
1288
+
1289
+ time_tag = alert.find('time', {'class': 'u-dt'})
1290
+ timestamp = None
1291
+ if time_tag:
1292
+ timestamp = {
1293
+ 'iso': time_tag.get('datetime'),
1294
+ 'unix': int(time_tag['data-time']) if time_tag and time_tag.has_attr('data-time') and time_tag['data-time'].isdigit() else None
1295
+ }
1296
+
1297
+ alert_text_container = alert.find('div', {'class': 'contentRow-main'})
1298
+ alert_text = unescape(alert_text_container.get_text(strip=True)) if alert_text_container else None
1299
+
1300
+ link_tag = alert.find('a', {'class': 'fauxBlockLink-blockLink'})
1301
+ link = link_tag['href'] if link_tag and link_tag.has_attr('href') else None
1302
+
1303
+ alert_data = {
1304
+ 'id': alert.get('data-alert-id'),
1305
+ 'is_unread': 'is-unread' in alert.get('class', []),
1306
+ 'text': alert_text,
1307
+ 'link': f"{MAIN_URL}{link}" if link and link.startswith('/') else link,
1308
+ 'sender': sender,
1309
+ 'timestamp': timestamp
1310
+ }
1311
+
1312
+ notifications.append(alert_data)
1313
+
1314
+ return notifications
1315
+ except aiohttp.ClientError as e:
1316
+ print(f"Ошибка сети при получении уведомлений: {e}")
1317
+ return []
1318
+ except Exception as e:
1319
+ print(f"Неожиданная ошибка при получении уведомлений: {e}")
1320
+ return []
1321
+
1322
+ async def search_threads(self, query: str, sort: str = 'relevance') -> list:
1323
+ """Поиск тем по форуму с заданными параметрами
1324
+
1325
+ Attributes:
1326
+ query (str): Поисковый запрос
1327
+ sort (str): Тип сортировки (relevance, date, etc)
1328
+
1329
+ Returns:
1330
+ Список словарей с информацией о найденных темах
1331
+ """
1332
+ if not self._session or self._session.closed:
1333
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
1334
+
1335
+ url = f"{MAIN_URL}/search/24587779/?q={query}&o={sort}"
1336
+ results = []
1337
+
1338
+ try:
1339
+ async with self._session.get(url) as response:
1340
+ response.raise_for_status()
1341
+ html_content = await response.text()
1342
+ content = BeautifulSoup(html_content, 'lxml')
1343
+
1344
+ for thread in content.find_all('li', {'class': 'block-row'}):
1345
+ title_link = thread.find('h3', {'class': 'contentRow-title'}).find('a')
1346
+ date_tag = thread.find('time', {'class': 'u-dt'})
1347
+ answers_tag = thread.find(text=re.compile('Ответы: '))
1348
+
1349
+ thread_data = {
1350
+ 'title': title_link.text.strip().split('| Причина:')[0].strip(),
1351
+ 'status': thread.find('span', {'class': 'label'}).text if thread.find('span', {'class': 'label'}) else None,
1352
+ 'author': thread['data-author'],
1353
+ 'thread_id': int(title_link['href'].split('/')[-2]),
1354
+ 'create_date': int(date_tag['data-time']) if date_tag else None,
1355
+ 'answers_count': int(answers_tag.split(': ')[1]) if answers_tag else 0,
1356
+ 'forum': thread.find('a', href=re.compile('/forums/')).text if thread.find('a', href=re.compile('/forums/')) else None,
1357
+ 'snippet': thread.find('div', {'class': 'contentRow-snippet'}).text.strip() if thread.find('div', {'class': 'contentRow-snippet'}) else None,
1358
+ 'url': f"{MAIN_URL}{title_link['href']}"
1359
+ }
1360
+
1361
+ results.append(thread_data)
1362
+
1363
+ return results
1364
+ except aiohttp.ClientError as e:
1365
+ print(f"Ошибка сети при поиске тем по запросу '{query}': {e}")
1366
+ return []
1367
+ except Exception as e:
1368
+ print(f"Неожиданная ошибка при поиске тем '{query}': {e}")
1369
+ return []
1370
+
1371
+ async def mark_notifications_read(self, alert_ids: list[int]) -> aiohttp.ClientResponse:
1372
+ """Пометить уведомления как прочитанные"""
1373
+ if not self._session or self._session.closed:
1374
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
1375
+
1376
+ data = {
1377
+ '_xfToken': await self.token,
1378
+ 'alert_id': alert_ids,
1379
+ '_xfAction': 'toggle',
1380
+ '_xfWithData': 1
1381
+ }
1382
+
1383
+ try:
1384
+ async with self._session.post(
1385
+ f"{MAIN_URL}/account/alert-toggle",
1386
+ data=data
1387
+ ) as response:
1388
+ return response
1389
+ except aiohttp.ClientError as e:
1390
+ print(f"Ошибка сети при пометке уведомлений {alert_ids} как прочитанных: {e}")
1391
+ raise e
1392
+
1393
+ async def get_post_bbcode(self, thread_id: int, post_id: int) -> str:
1394
+ """Получить BB-код поста по его ID.
1395
+
1396
+ Сначала получает HTML-содержимое поста,
1397
+ затем отправляет этот HTML для конвертации в BBCode.
1398
+
1399
+ Args:
1400
+ thread_id: ID темы, содержащей пост.
1401
+ post_id: ID поста для получения BB-кода.
1402
+
1403
+ Returns:
1404
+ Строка с BB-кодом поста или пустая строка в случае ошибки.
1405
+
1406
+ Raises:
1407
+ Exception: Если сессия не активна.
1408
+ """
1409
+ if not self._session or self._session.closed:
1410
+ raise Exception("Сессия не активна. Вызовите connect() сначала.")
1411
+ try:
1412
+ token = await self.token
1413
+ except Exception as e:
1414
+ print(f"Не удалось получить токен: {e}")
1415
+ return ''
1416
+
1417
+ html_content = ''
1418
+ try:
1419
+ post = await self.get_post(post_id)
1420
+ html_content = post.html_content
1421
+ except aiohttp.ClientError as e:
1422
+ print(f"Сетевая ошибка при получении HTML для поста {post_id}: {e}")
1423
+ except Exception as e:
1424
+ print(f"Неожиданная ошибка при получении HTML для поста {post_id}: {e}")
1425
+ try:
1426
+ convert_url = f"{MAIN_URL}/index.php?editor/to-bb-code"
1427
+ data_post = {
1428
+ '_xfResponseType': 'json',
1429
+ '_xfRequestUri': f'/threads/{thread_id}/',
1430
+ '_xfWithData': 1,
1431
+ '_xfToken': token,
1432
+ 'html': html_content
1433
+ }
1434
+ async with self._session.post(convert_url, data=data_post) as response:
1435
+ response.raise_for_status()
1436
+ convert_data = await response.json()
1437
+ if convert_data.get("status") == "ok" and "bbCode" in convert_data:
1438
+ bbcode = convert_data.get('bbCode', '')
1439
+ return unescape(bbcode)
1440
+ else:
1441
+ print(f"Ошибка при конвертации BBCode для поста {post_id}. Ответ сервера: {convert_data}")
1442
+ return ''
1443
+
1444
+ except aiohttp.ClientError as e:
1445
+ print(f"Сетевая ошибка при конвертации BBCode для поста {post_id}: {e}")
1446
+ return ''
1447
+ except Exception as e:
1448
+ print(f"Неожиданная ошибка при конвертации BBCode для поста {post_id}: {e}")
1449
+ return ''