treehole.client
树洞客户端,处理收发请求
1""" 2树洞客户端,处理收发请求 3""" 4from functools import cache 5from typing import Dict, List, Optional, Tuple, Union 6 7import aiofiles 8import aiohttp 9import requests 10from requests.compat import urljoin 11 12from .models import Comment, Hole, UserName 13from .utils import AuthError, EmptyError, logger 14 15__all__ = ["TreeHoleClient"] 16 17BASE_URL = "https://treehole.pku.edu.cn/api/" 18REQUEST_HEADER = {} 19BASE_QUERY = {} 20 21 22class TreeHoleClient: 23 """ 24 树洞交互客户端,低程度封装 25 """ 26 27 def __init__( 28 self, 29 token: Optional[str] = None, 30 uid: Optional[Union[int, str]] = None, 31 password: Optional[str] = None, 32 header: Optional[Dict[str, str]] = None, 33 base_param: Optional[Dict[str, str]] = None, 34 base_url: Optional[str] = None, 35 ) -> None: 36 """ 37 - token: 38 用户 token(可在树洞 cookies 中 pku_token 字段中获取) 39 - uid: 40 IAAA 账号 ID(可选的登录方式) 41 - password: 42 IAAA 账号密码(可选的登录方式) 43 - header: 44 额外的请求头,可选 45 - base_param: 46 额外的请求参数,可选 47 - base_url: 48 其他树洞 API 地址,可选 49 """ 50 self.__base_url = base_url or BASE_URL 51 if token: 52 self.__token = token 53 elif uid and password: 54 __token = self.__auth(uid, password) 55 if not __token: 56 raise AuthError("Failed to login") 57 self.__token = __token 58 else: 59 raise AuthError("No token or uid and password provided") 60 self.__header = { 61 **REQUEST_HEADER, 62 **{"authorization": "Bearer " + self.__token}, 63 **(header or {}), 64 } 65 self.__base_param = { 66 **BASE_QUERY, 67 **(base_param or {}), 68 } 69 70 def __auth(self, uid: Union[int, str], password: str) -> Optional[str]: 71 """ 72 登录,获取 token 73 74 Parameters 75 ---------- 76 - uid: IAAA 账号 ID 77 - password: IAAA 账号密码 78 79 Returns 80 ------- 81 1. token,登录失败则返回 `None` 82 """ 83 84 if not self.__is_num(uid): 85 raise ValueError("uid must be an integer or string of interger") 86 auth_data = {"uid": uid, "password": password} 87 response = requests.post( 88 self.login_url, 89 data=auth_data, 90 ) 91 if not self.__is_valid_response(response): 92 return None 93 response_dict = response.json() 94 if not response_dict["success"]: 95 logger.error("Failed to login, response: %s", response_dict) 96 return None 97 return response_dict["data"]["jwt"] 98 99 @property 100 def token(self) -> str: 101 """用户 token,只读""" 102 return self.__token 103 104 @property 105 def header(self) -> Dict[str, str]: 106 """请求头,只读""" 107 return self.__header 108 109 @property 110 def base_param(self) -> Dict[str, str]: 111 """请求参数,只读""" 112 return self.__base_param 113 114 @property 115 def base_url(self) -> str: 116 """请求地址,只读""" 117 return self.__base_url 118 119 @property 120 @cache 121 def login_url(self) -> str: 122 """登陆请求地址,只读""" 123 return urljoin(self.base_url, "login/") 124 125 @property 126 @cache 127 def hole_url(self) -> str: 128 """树洞请求地址,只读""" 129 return urljoin(self.base_url, "pku/") 130 131 @property 132 @cache 133 def holes_url(self) -> str: 134 """首页及搜索树洞请求地址,只读""" 135 return urljoin(self.base_url, "pku_hole/") 136 137 @property 138 @cache 139 def comment_url(self) -> str: 140 """树洞评论请求地址,只读""" 141 return urljoin(self.base_url, "pku_comment/") 142 143 @property 144 @cache 145 def follow_url(self) -> str: 146 """关注列表请求地址,只读""" 147 return urljoin(self.base_url, "follow/") 148 149 @property 150 @cache 151 def image_url(self) -> str: 152 """树洞图片请求地址,只读""" 153 return urljoin(self.base_url, "pku_image/") 154 155 @property 156 @cache 157 def attention_url(self) -> str: 158 """设置关注请求地址,只读""" 159 return urljoin(self.base_url, "pku_attention/") 160 161 @property 162 @cache 163 def report_url(self) -> str: 164 """举报请求地址,只读""" 165 return urljoin(self.base_url, "pku_report/") 166 167 @property 168 @cache 169 def store_url(self) -> str: 170 """举报请求地址,只读""" 171 return urljoin(self.base_url, "pku_store") 172 173 @staticmethod 174 def __is_num(pid: Union[int, str]) -> bool: 175 try: 176 int(pid) 177 except ValueError: 178 logger.exception("Invalid number: %s", pid) 179 return False 180 return True 181 182 @staticmethod 183 def __is_valid_response(response: requests.Response) -> bool: 184 if response.status_code != 200: 185 logger.error( 186 "Failed to get reponse, status code: %s, response: %s", 187 response.status_code, 188 response.reason, 189 ) 190 return False 191 else: 192 return True 193 194 @staticmethod 195 def __is_valid_client_response(response: aiohttp.ClientResponse) -> bool: 196 if response.status != 200: 197 logger.error( 198 "Failed to get reponse, status code: %s, response: %s", 199 response.status, 200 response.reason, 201 ) 202 return False 203 else: 204 return True 205 206 def get_hole_image(self, hole: Hole) -> Union[Tuple[bytes, str], Tuple[None, None]]: 207 """ 208 获取树洞图片 209 210 Parameters 211 ---------- 212 - hole: 213 任一树洞类 214 215 Returns 216 ------- 217 1. 图片二进制数据,不包含图片或请求错误则返回 `None` 218 2. 图片类型,不包含图片或请求错误则返回 `None` 219 """ 220 221 if hole.type == "image": 222 response = requests.get( 223 urljoin(self.image_url, str(hole.pid)), headers=self.header 224 ) 225 if self.__is_valid_response(response): 226 return response.content, response.headers["Content-Type"] 227 return (None, None) 228 229 async def get_hole_image_async( 230 self, hole: Hole 231 ) -> Union[Tuple[bytes, str], Tuple[None, None]]: 232 """ 233 异步获取树洞图片 234 235 Parameters 236 ---------- 237 - hole: 任一树洞类 238 239 Returns 240 ------- 241 1. 图片二进制数据,不包含图片或请求错误则返回 `None` 242 2. 图片类型,不包含图片或请求错误则返回 `None` 243 """ 244 245 if hole.type == "image": 246 async with aiohttp.request( 247 "GET", 248 urljoin(self.image_url, str(hole.pid)), 249 headers=self.header, 250 ) as response: 251 if self.__is_valid_client_response(response): 252 return ( 253 await response.content.read(), 254 response.headers["Content-Type"], 255 ) 256 return (None, None) 257 258 def get_comment( 259 self, 260 pid: Union[int, str], 261 page: Union[int, str] = 1, 262 page_size: Union[int, str] = 500, 263 ) -> Optional[List[Comment]]: 264 """ 265 获取树洞评论 266 267 Parameters 268 ---------- 269 - pid: 树洞 ID 270 - page: 页码,默认为 1 271 - page_size: 每页评论数,默认为 500 272 273 Returns 274 ------- 275 1. 评论列表,请求错误则返回 `None` 276 """ 277 278 if not self.__is_num(pid): 279 raise ValueError("pid must be an integer or string of interger") 280 if not self.__is_num(page): 281 raise ValueError("page must be an integer or string of interger") 282 if not self.__is_num(page_size): 283 raise ValueError("page_size must be an integer or string of interger") 284 param = { 285 **self.base_param, 286 **{"page": str(page), "limit": str(page_size)}, 287 } 288 response = requests.get( 289 urljoin(self.comment_url, str(pid)), params=param, headers=self.header 290 ) 291 if not self.__is_valid_response(response): 292 return None 293 response_dict = response.json() 294 if not response_dict["success"]: 295 logger.error("Failed to get comment, response: %s", response_dict) 296 return None 297 return list(map(Comment.from_data, response_dict["data"]["data"])) 298 299 async def get_comment_async( 300 self, 301 pid: Union[int, str], 302 page: Union[int, str] = 1, 303 page_size: Union[int, str] = 500, 304 ) -> Optional[List[Comment]]: 305 """ 306 异步获取树洞评论 307 308 Parameters 309 ---------- 310 - pid: 树洞 ID 311 - page: 页码,默认为 1 312 - page_size: 每页评论数,默认为 500 313 314 Returns 315 ------- 316 1. 评论列表,请求错误则返回 `None` 317 """ 318 319 if not self.__is_num(pid): 320 raise ValueError("pid must be an integer or string of interger") 321 if not self.__is_num(page): 322 raise ValueError("page must be an integer or string of interger") 323 if not self.__is_num(page_size): 324 raise ValueError("page_size must be an integer or string of interger") 325 param = { 326 **self.base_param, 327 **{"page": str(page), "limit": str(page_size)}, 328 } 329 async with aiohttp.request( 330 "GET", 331 urljoin(self.comment_url, str(pid)), 332 params=param, 333 headers=self.header, 334 ) as response: 335 if not self.__is_valid_client_response(response): 336 return None 337 response_dict = await response.json() 338 if not response_dict["success"]: 339 logger.error("Failed to get comment, response: %s", response_dict) 340 return None 341 return list(map(Comment.from_data, response_dict["data"]["data"])) 342 343 def get_hole(self, pid: Union[int, str]) -> Optional[Hole]: 344 """ 345 获取单个树洞 346 347 Parameters 348 ---------- 349 - pid: 树洞 ID 350 351 Returns 352 ------- 353 1. 树洞,请求错误则返回 `None` 354 """ 355 356 if not self.__is_num(pid): 357 raise ValueError("pid must be an integer or string of interger") 358 response = requests.get( 359 urljoin(self.hole_url, str(pid)), 360 params=self.base_param, 361 headers=self.header, 362 ) 363 if not self.__is_valid_response(response): 364 return None 365 response_dict = response.json() 366 if not response_dict["success"]: 367 logger.error("Failed to get hole, response: %s", response_dict) 368 return None 369 return Hole.from_data(response_dict["data"]) 370 371 async def get_hole_async(self, pid: Union[int, str]) -> Optional[Hole]: 372 """ 373 异步获取单个树洞 374 375 Parameters 376 ---------- 377 - pid: 树洞 ID 378 379 Returns 380 ------- 381 1. 树洞,请求错误则返回 `None` 382 """ 383 384 if not self.__is_num(pid): 385 raise ValueError("pid must be an integer or string of interger") 386 async with aiohttp.request( 387 "GET", 388 urljoin(self.hole_url, str(pid)), 389 params=self.base_param, 390 headers=self.header, 391 ) as response: 392 if not self.__is_valid_client_response(response): 393 return None 394 response_dict = await response.json() 395 if not response_dict["success"]: 396 logger.error("Failed to get hole, response: %s", response_dict) 397 return None 398 return Hole.from_data(response_dict["data"]) 399 400 def get_holes( 401 self, page: Union[int, str] = 1, page_size: Union[int, str] = 25 402 ) -> Optional[List[Hole]]: 403 """ 404 获取首页树洞 405 406 Parameters 407 ---------- 408 - page: 列表页码,默认为 1 409 - page_size: 每页数量,默认为 25 410 411 Returns 412 ------- 413 1. 首页树洞列表,请求错误则返回 `None` 414 """ 415 416 if not self.__is_num(page): 417 raise ValueError("page must be an integer or string of interger") 418 if not self.__is_num(page_size): 419 raise ValueError("page_size must be an integer or string of interger") 420 param = { 421 **self.base_param, 422 **{"page": str(page), "limit": str(page_size)}, 423 } 424 response = requests.get(self.holes_url, params=param, headers=self.header) 425 if not self.__is_valid_response(response): 426 return None 427 response_dict = response.json() 428 if not response_dict["success"]: 429 logger.error("Failed to get hole list, response: %s", response_dict) 430 return None 431 return list(map(Hole.from_data, response_dict["data"]["data"])) 432 433 async def get_holes_async( 434 self, page: Union[int, str] = 1, page_size: Union[int, str] = 25 435 ) -> Optional[List[Hole]]: 436 """ 437 异步获取首页树洞 438 439 Parameters 440 ---------- 441 - page: 列表页码,默认为 1 442 - page_size: 每页数量,默认为 25 443 444 Returns 445 ------- 446 1. 首页树洞列表,请求错误则返回 `None` 447 """ 448 449 if not self.__is_num(page): 450 raise ValueError("page must be an integer or string of interger") 451 if not self.__is_num(page_size): 452 raise ValueError("page_size must be an integer or string of interger") 453 param = { 454 **self.base_param, 455 **{"page": str(page), "limit": str(page_size)}, 456 } 457 async with aiohttp.request( 458 "GET", self.holes_url, params=param, headers=self.header 459 ) as response: 460 if not self.__is_valid_client_response(response): 461 return None 462 response_dict = await response.json() 463 if not response_dict["success"]: 464 logger.error("Failed to get hole list, response: %s", response_dict) 465 return None 466 return list(map(Hole.from_data, response_dict["data"]["data"])) 467 468 def get_followed( 469 self, page: Union[int, str] = 1, page_size: Union[int, str] = 25 470 ) -> Optional[List[Hole]]: 471 """ 472 获取关注树洞 473 474 Parameters 475 ---------- 476 - page: 列表页码,默认为 1 477 - page_size: 每页数量,默认为 25 478 479 Returns 480 ------- 481 1. 关注树洞列表,请求错误则返回 `None` 482 """ 483 484 if not self.__is_num(page): 485 raise ValueError("page must be an integer or string of interger") 486 if not self.__is_num(page_size): 487 raise ValueError("page_size must be an integer or string of interger") 488 param = { 489 **self.base_param, 490 **{"page": str(page), "limit": str(page_size)}, 491 } 492 response = requests.get(self.follow_url, params=param, headers=self.header) 493 if not self.__is_valid_response(response): 494 return None 495 response_dict = response.json() 496 if not response_dict["success"]: 497 logger.error( 498 "Failed to get followed hole list, response: %s", response_dict 499 ) 500 return None 501 return list(map(Hole.from_data, response_dict["data"]["data"])) 502 503 async def get_followed_async( 504 self, page: Union[int, str] = 1, page_size: Union[int, str] = 25 505 ) -> Optional[List[Hole]]: 506 """ 507 异步获取关注树洞 508 509 Parameters 510 ---------- 511 - page: 列表页码,默认为 1 512 - page_size: 每页数量,默认为 25 513 514 Returns 515 ------- 516 1. 关注树洞列表,请求错误则返回 `None` 517 """ 518 519 if not self.__is_num(page): 520 raise ValueError("page must be an integer or string of interger") 521 if not self.__is_num(page_size): 522 raise ValueError("page_size must be an integer or string of interger") 523 param = { 524 **self.base_param, 525 **{"page": str(page), "limit": str(page_size)}, 526 } 527 async with aiohttp.request( 528 "GET", self.follow_url, params=param, headers=self.header 529 ) as response: 530 if not self.__is_valid_client_response(response): 531 return None 532 response_dict = await response.json() 533 if not response_dict["success"]: 534 logger.error( 535 "Failed to get followed hole list, response: %s", response_dict 536 ) 537 return None 538 return list(map(Hole.from_data, response_dict["data"]["data"])) 539 540 def get_search( 541 self, 542 keywords: Union[str, List[str]], 543 page: Union[int, str] = 1, 544 page_size: Union[int, str] = 50, 545 ) -> Optional[List[Hole]]: 546 """ 547 搜索树洞 548 549 Parameters 550 ---------- 551 - keywords: 搜索关键词 552 - page: 列表页码,默认为 1 553 - page_size: 每页数量,默认为 50 554 555 Returns 556 ------- 557 1. 搜索结果,请求错误则返回 `None` 558 """ 559 if not self.__is_num(page): 560 raise ValueError("page must be an integer or string of interger") 561 if not self.__is_num(page_size): 562 raise ValueError("page size must be an integer or string of interger") 563 if isinstance(keywords, str): 564 keywords = [keywords] 565 param = { 566 **self.base_param, 567 **{ 568 "page": str(page), 569 "limit": str(page_size), 570 "keyword": " ".join(keywords), 571 }, 572 } 573 response = requests.get(self.holes_url, params=param, headers=self.header) 574 if not self.__is_valid_response(response): 575 return None 576 response_dict = response.json() 577 if not response_dict["success"]: 578 logger.error("Failed to get search result, response: %s", response_dict) 579 return None 580 return list(map(Hole.from_data, response_dict["data"]["data"])) 581 582 async def get_search_async( 583 self, 584 keywords: Union[str, List[str]], 585 page: Union[int, str] = 1, 586 page_size: Union[int, str] = 50, 587 ) -> Optional[List[Hole]]: 588 """ 589 异步搜索树洞 590 591 Parameters 592 ---------- 593 - keywords: 搜索关键词 594 - page: 列表页码,默认为 1 595 - page_size: 每页数量,默认为 50 596 597 Returns 598 ------- 599 1. 搜索结果,请求错误则返回 `None` 600 """ 601 if not self.__is_num(page): 602 raise ValueError("page must be an integer or string of interger") 603 if not self.__is_num(page_size): 604 raise ValueError("page size must be an integer or string of interger") 605 if isinstance(keywords, str): 606 keywords = [keywords] 607 param = { 608 **self.base_param, 609 **{ 610 "page": str(page), 611 "limit": str(page_size), 612 "keyword": " ".join(keywords), 613 }, 614 } 615 async with aiohttp.request( 616 "GET", self.holes_url, params=param, headers=self.header 617 ) as response: 618 if not self.__is_valid_client_response(response): 619 return None 620 response_dict = await response.json() 621 if not response_dict["success"]: 622 logger.error("Failed to get search result, response: %s", response_dict) 623 return None 624 return list(map(Hole.from_data, response_dict["data"]["data"])) 625 626 def post_hole( 627 self, text: str = "", image: Optional[Union[bytes, str]] = None 628 ) -> Optional[bool]: 629 """ 630 发布树洞 631 632 Parameters 633 ---------- 634 - text: 树洞内容 635 - image: 树洞图片 (二进制数据或文件名) 636 637 Returns 638 ------- 639 1. 是否发布成功,请求错误则返回 `None` 640 """ 641 if not text and not image: 642 raise EmptyError("Empty post is not allowed") 643 if image is not None: 644 if isinstance(image, str): 645 try: 646 image = open(image, "rb").read() 647 except FileNotFoundError: 648 logger.error(f"File {image} not found") 649 raise FileNotFoundError("File not found") 650 except Exception as e: 651 logger.error(f"Unknown error: {e}") 652 raise e 653 # load for posting hole with image 654 load = { 655 "text": text, 656 "type": "image", 657 } 658 file = {"data": image} 659 else: 660 load = { 661 "text": text, 662 "type": "text", 663 } 664 file = {} 665 response = requests.post( 666 self.store_url, 667 params=self.base_param, 668 headers=self.header, 669 data=load, 670 files=file, 671 ) 672 if not self.__is_valid_response(response): 673 return None 674 response_dict = response.json() 675 if not response_dict["success"]: 676 logger.exception("Post failed: %s", response_dict["messsage"]) 677 return response_dict["success"] 678 679 async def post_hole_async( 680 self, text: str = "", image: Optional[Union[bytes, str]] = None 681 ) -> Optional[bool]: 682 """ 683 异步发布树洞 684 685 Parameters 686 ---------- 687 - text: 树洞内容 688 - image: 树洞图片 (二进制数据或文件名) 689 690 Returns 691 ------- 692 1. 是否发布成功,请求错误则返回 `None` 693 """ 694 if not text and not image: 695 raise EmptyError("Empty post is not allowed") 696 if image is not None: 697 if isinstance(image, str): 698 try: 699 async with aiofiles.open(image, "rb") as f: 700 image = await f.read() 701 except FileNotFoundError: 702 logger.error(f"File {image} not found") 703 raise FileNotFoundError("File not found") 704 except Exception as e: 705 logger.error(f"Unknown error: {e}") 706 raise e 707 # load for posting hole with image 708 load = { 709 "text": text, 710 "type": "image", 711 "data": image, 712 } 713 else: 714 load = { 715 "text": text, 716 "type": "text", 717 } 718 async with aiohttp.request( 719 "POST", 720 self.store_url, 721 params=self.base_param, 722 headers=self.header, 723 data=load, 724 ) as response: 725 if not self.__is_valid_client_response(response): 726 return None 727 response_dict = await response.json() 728 if not response_dict["success"]: 729 logger.exception("Post failed: %s", response_dict["messsage"]) 730 return response_dict["success"] 731 732 def post_comment( 733 self, 734 pid: Union[int, str], 735 text: str, 736 reply_to: Optional[Union[int, str]] = None, 737 ) -> Optional[bool]: 738 """ 739 发布评论 740 741 Parameters 742 ---------- 743 - pid: 树洞 ID 744 - text: 评论内容 745 - reply_to: 回复的用户昵称或标号(非层号),`None` 为回复洞主(默认) 746 747 Returns 748 ------- 749 1. 回复是否成功,请求错误则返回 `None` 750 """ 751 if not text: 752 raise EmptyError("Empty post is not allowed") 753 if not self.__is_num(pid): 754 raise ValueError("pid must be an integer or string of interger") 755 if reply_to is not None: 756 if isinstance(reply_to, str): 757 assert reply_to in UserName, "Invalid reply_to" 758 reply_to = " ".join([x.capitalize() for x in reply_to.split()]) 759 else: 760 reply_to = UserName[reply_to] 761 text = f"Re {reply_to}: {text}" 762 load = { 763 "pid": str(pid), 764 "text": text, 765 } 766 response = requests.post( 767 self.comment_url, params=self.base_param, headers=self.header, data=load 768 ) 769 if not self.__is_valid_response(response): 770 return None 771 response_dict = response.json() 772 if not response_dict["success"]: 773 logger.exception("Comment failed: %s", response_dict["messsage"]) 774 return response_dict["success"] 775 776 async def post_comment_async( 777 self, 778 pid: Union[int, str], 779 text: str, 780 reply_to: Optional[Union[int, str]] = None, 781 ) -> Optional[bool]: 782 """ 783 异步发布评论 784 785 Parameters 786 ---------- 787 - pid: 树洞 ID 788 - text: 评论内容 789 - reply_to: 回复的用户昵称或标号(非层号),`None` 为回复洞主(默认) 790 791 Returns 792 ------- 793 1. 回复是否成功,请求错误则返回 `None` 794 """ 795 if not text: 796 raise EmptyError("Empty post is not allowed") 797 if not self.__is_num(pid): 798 raise ValueError("pid must be an integer or string of interger") 799 if reply_to is not None: 800 if isinstance(reply_to, str): 801 assert reply_to in UserName, "Invalid reply_to" 802 reply_to = " ".join([x.capitalize() for x in reply_to.split()]) 803 else: 804 reply_to = UserName[reply_to] 805 text = f"Re {reply_to}: {text}" 806 load = { 807 "pid": str(pid), 808 "text": text, 809 } 810 async with aiohttp.request( 811 "POST", 812 self.comment_url, 813 params=self.base_param, 814 headers=self.header, 815 data=load, 816 ) as response: 817 if not self.__is_valid_client_response(response): 818 return None 819 response_dict = await response.json() 820 if not response_dict["success"]: 821 logger.exception("Comment failed: %s", response_dict["messsage"]) 822 return response_dict["success"] 823 824 def post_toggle_followed( 825 self, pid: Union[int, str], two_factor: bool = False 826 ) -> Union[Tuple[bool, int], Tuple[None, None]]: 827 """ 828 切换关注状态 829 830 Parameters 831 ---------- 832 - pid: 树洞 ID 833 - two_factor: 是否启用双重验证(默认为 `False`) 834 835 Returns 836 ------- 837 1. 是否成功切换关注状态,请求错误则返回 `None` 838 2. 当前关注状态,`1` 为关注,`0` 为未关注,请求错误则返回 `None` 839 """ 840 hole = self.get_hole(pid) 841 if hole is None or hole.is_follow is None: 842 logger.exception("Failed to get attention status of pid %s", pid) 843 return (None, None) 844 response = requests.post( 845 urljoin(self.attention_url, str(pid)), 846 params=self.base_param, 847 headers=self.header, 848 ) 849 if not self.__is_valid_response(response): 850 return (None, None) 851 response_dict = response.json() 852 if not response_dict["success"]: 853 logger.exception("Toggle attention failed: %s", response_dict["messsage"]) 854 if not two_factor: 855 return ( 856 response_dict["success"], 857 (1 - hole.is_follow) if response_dict["success"] else hole.is_follow, 858 ) 859 hole_verify = self.get_hole(pid) 860 if hole_verify is None or hole_verify.is_follow is None: 861 logger.exception("Failed to get attention status of pid %s", pid) 862 return ( 863 response_dict["success"], 864 (1 - hole.is_follow) if response_dict["success"] else hole.is_follow, 865 ) 866 return response_dict["success"], hole.is_follow 867 868 async def post_toggle_followed_async( 869 self, pid: Union[int, str], two_factor: bool = False 870 ) -> Union[Tuple[bool, int], Tuple[None, None]]: 871 """ 872 异步切换关注状态 873 874 Parameters 875 ---------- 876 - pid: 树洞 ID 877 - two_factor: 是否启用双重验证(默认为 `False`) 878 879 Returns 880 ------- 881 1. 是否成功切换关注状态,请求错误则返回 `None` 882 2. 当前关注状态,`1` 为关注,`0` 为未关注,请求错误则返回 `None` 883 """ 884 hole = await self.get_hole_async(pid) 885 if hole is None or hole.is_follow is None: 886 logger.exception("Failed to get attention status of pid %s", pid) 887 return (None, None) 888 async with aiohttp.request( 889 "POST", 890 urljoin(self.attention_url, str(pid)), 891 params=self.base_param, 892 headers=self.header, 893 ) as response: 894 if not self.__is_valid_client_response(response): 895 return (None, None) 896 response_dict = await response.json() 897 if not response_dict["success"]: 898 logger.exception( 899 "Toggle attention failed: %s", response_dict["messsage"] 900 ) 901 if not two_factor: 902 return ( 903 response_dict["success"], 904 (1 - hole.is_follow) 905 if response_dict["success"] 906 else hole.is_follow, 907 ) 908 hole_verify = await self.get_hole_async(pid) 909 if hole_verify is None or hole_verify.is_follow is None: 910 logger.exception("Failed to get attention status of pid %s", pid) 911 return ( 912 response_dict["success"], 913 (1 - hole.is_follow) 914 if response_dict["success"] 915 else hole.is_follow, 916 ) 917 return response_dict["success"], hole.is_follow 918 919 def post_report(self, pid: Union[int, str], reason: str = "") -> Optional[bool]: 920 """ 921 举报树洞(注意!举报自己的树洞会导致立刻被删并且禁言) 922 923 Parameters 924 ---------- 925 - pid: 树洞 ID 926 - reason: 举报理由(默认为空) 927 928 Returns 929 ------- 930 1. 是否举报成功,请求错误则返回 `None` 931 """ 932 if not self.__is_num(pid): 933 raise ValueError("pid must be an integer or string of interger") 934 load = {"reason": reason} 935 response = requests.post( 936 urljoin(self.report_url, str(pid)), 937 params=self.base_param, 938 headers=self.header, 939 data=load, 940 ) 941 if not self.__is_valid_response(response): 942 return None 943 response_dict = response.json() 944 if not response_dict["success"]: 945 logger.exception("Report failed: %s", response_dict["messsage"]) 946 return response_dict["success"] 947 948 async def post_report_async( 949 self, pid: Union[int, str], reason: str = "" 950 ) -> Optional[bool]: 951 """ 952 异步举报树洞(注意!举报自己的树洞会导致立刻被删并且禁言) 953 954 Parameters 955 ---------- 956 - pid: 树洞 ID 957 - reason: 举报理由(默认为空) 958 959 Returns 960 ------- 961 1. 是否举报成功,请求错误则返回 `None` 962 """ 963 if not self.__is_num(pid): 964 raise ValueError("pid must be an integer or string of interger") 965 load = {"reason": reason} 966 async with aiohttp.request( 967 "POST", 968 urljoin(self.report_url, str(pid)), 969 params=self.base_param, 970 headers=self.header, 971 data=load, 972 ) as response: 973 if not self.__is_valid_client_response(response): 974 return None 975 response_dict = await response.json() 976 if not response_dict["success"]: 977 logger.exception("Report failed: %s", response_dict["messsage"]) 978 return response_dict["success"]
class
TreeHoleClient:
23class TreeHoleClient: 24 """ 25 树洞交互客户端,低程度封装 26 """ 27 28 def __init__( 29 self, 30 token: Optional[str] = None, 31 uid: Optional[Union[int, str]] = None, 32 password: Optional[str] = None, 33 header: Optional[Dict[str, str]] = None, 34 base_param: Optional[Dict[str, str]] = None, 35 base_url: Optional[str] = None, 36 ) -> None: 37 """ 38 - token: 39 用户 token(可在树洞 cookies 中 pku_token 字段中获取) 40 - uid: 41 IAAA 账号 ID(可选的登录方式) 42 - password: 43 IAAA 账号密码(可选的登录方式) 44 - header: 45 额外的请求头,可选 46 - base_param: 47 额外的请求参数,可选 48 - base_url: 49 其他树洞 API 地址,可选 50 """ 51 self.__base_url = base_url or BASE_URL 52 if token: 53 self.__token = token 54 elif uid and password: 55 __token = self.__auth(uid, password) 56 if not __token: 57 raise AuthError("Failed to login") 58 self.__token = __token 59 else: 60 raise AuthError("No token or uid and password provided") 61 self.__header = { 62 **REQUEST_HEADER, 63 **{"authorization": "Bearer " + self.__token}, 64 **(header or {}), 65 } 66 self.__base_param = { 67 **BASE_QUERY, 68 **(base_param or {}), 69 } 70 71 def __auth(self, uid: Union[int, str], password: str) -> Optional[str]: 72 """ 73 登录,获取 token 74 75 Parameters 76 ---------- 77 - uid: IAAA 账号 ID 78 - password: IAAA 账号密码 79 80 Returns 81 ------- 82 1. token,登录失败则返回 `None` 83 """ 84 85 if not self.__is_num(uid): 86 raise ValueError("uid must be an integer or string of interger") 87 auth_data = {"uid": uid, "password": password} 88 response = requests.post( 89 self.login_url, 90 data=auth_data, 91 ) 92 if not self.__is_valid_response(response): 93 return None 94 response_dict = response.json() 95 if not response_dict["success"]: 96 logger.error("Failed to login, response: %s", response_dict) 97 return None 98 return response_dict["data"]["jwt"] 99 100 @property 101 def token(self) -> str: 102 """用户 token,只读""" 103 return self.__token 104 105 @property 106 def header(self) -> Dict[str, str]: 107 """请求头,只读""" 108 return self.__header 109 110 @property 111 def base_param(self) -> Dict[str, str]: 112 """请求参数,只读""" 113 return self.__base_param 114 115 @property 116 def base_url(self) -> str: 117 """请求地址,只读""" 118 return self.__base_url 119 120 @property 121 @cache 122 def login_url(self) -> str: 123 """登陆请求地址,只读""" 124 return urljoin(self.base_url, "login/") 125 126 @property 127 @cache 128 def hole_url(self) -> str: 129 """树洞请求地址,只读""" 130 return urljoin(self.base_url, "pku/") 131 132 @property 133 @cache 134 def holes_url(self) -> str: 135 """首页及搜索树洞请求地址,只读""" 136 return urljoin(self.base_url, "pku_hole/") 137 138 @property 139 @cache 140 def comment_url(self) -> str: 141 """树洞评论请求地址,只读""" 142 return urljoin(self.base_url, "pku_comment/") 143 144 @property 145 @cache 146 def follow_url(self) -> str: 147 """关注列表请求地址,只读""" 148 return urljoin(self.base_url, "follow/") 149 150 @property 151 @cache 152 def image_url(self) -> str: 153 """树洞图片请求地址,只读""" 154 return urljoin(self.base_url, "pku_image/") 155 156 @property 157 @cache 158 def attention_url(self) -> str: 159 """设置关注请求地址,只读""" 160 return urljoin(self.base_url, "pku_attention/") 161 162 @property 163 @cache 164 def report_url(self) -> str: 165 """举报请求地址,只读""" 166 return urljoin(self.base_url, "pku_report/") 167 168 @property 169 @cache 170 def store_url(self) -> str: 171 """举报请求地址,只读""" 172 return urljoin(self.base_url, "pku_store") 173 174 @staticmethod 175 def __is_num(pid: Union[int, str]) -> bool: 176 try: 177 int(pid) 178 except ValueError: 179 logger.exception("Invalid number: %s", pid) 180 return False 181 return True 182 183 @staticmethod 184 def __is_valid_response(response: requests.Response) -> bool: 185 if response.status_code != 200: 186 logger.error( 187 "Failed to get reponse, status code: %s, response: %s", 188 response.status_code, 189 response.reason, 190 ) 191 return False 192 else: 193 return True 194 195 @staticmethod 196 def __is_valid_client_response(response: aiohttp.ClientResponse) -> bool: 197 if response.status != 200: 198 logger.error( 199 "Failed to get reponse, status code: %s, response: %s", 200 response.status, 201 response.reason, 202 ) 203 return False 204 else: 205 return True 206 207 def get_hole_image(self, hole: Hole) -> Union[Tuple[bytes, str], Tuple[None, None]]: 208 """ 209 获取树洞图片 210 211 Parameters 212 ---------- 213 - hole: 214 任一树洞类 215 216 Returns 217 ------- 218 1. 图片二进制数据,不包含图片或请求错误则返回 `None` 219 2. 图片类型,不包含图片或请求错误则返回 `None` 220 """ 221 222 if hole.type == "image": 223 response = requests.get( 224 urljoin(self.image_url, str(hole.pid)), headers=self.header 225 ) 226 if self.__is_valid_response(response): 227 return response.content, response.headers["Content-Type"] 228 return (None, None) 229 230 async def get_hole_image_async( 231 self, hole: Hole 232 ) -> Union[Tuple[bytes, str], Tuple[None, None]]: 233 """ 234 异步获取树洞图片 235 236 Parameters 237 ---------- 238 - hole: 任一树洞类 239 240 Returns 241 ------- 242 1. 图片二进制数据,不包含图片或请求错误则返回 `None` 243 2. 图片类型,不包含图片或请求错误则返回 `None` 244 """ 245 246 if hole.type == "image": 247 async with aiohttp.request( 248 "GET", 249 urljoin(self.image_url, str(hole.pid)), 250 headers=self.header, 251 ) as response: 252 if self.__is_valid_client_response(response): 253 return ( 254 await response.content.read(), 255 response.headers["Content-Type"], 256 ) 257 return (None, None) 258 259 def get_comment( 260 self, 261 pid: Union[int, str], 262 page: Union[int, str] = 1, 263 page_size: Union[int, str] = 500, 264 ) -> Optional[List[Comment]]: 265 """ 266 获取树洞评论 267 268 Parameters 269 ---------- 270 - pid: 树洞 ID 271 - page: 页码,默认为 1 272 - page_size: 每页评论数,默认为 500 273 274 Returns 275 ------- 276 1. 评论列表,请求错误则返回 `None` 277 """ 278 279 if not self.__is_num(pid): 280 raise ValueError("pid must be an integer or string of interger") 281 if not self.__is_num(page): 282 raise ValueError("page must be an integer or string of interger") 283 if not self.__is_num(page_size): 284 raise ValueError("page_size must be an integer or string of interger") 285 param = { 286 **self.base_param, 287 **{"page": str(page), "limit": str(page_size)}, 288 } 289 response = requests.get( 290 urljoin(self.comment_url, str(pid)), params=param, headers=self.header 291 ) 292 if not self.__is_valid_response(response): 293 return None 294 response_dict = response.json() 295 if not response_dict["success"]: 296 logger.error("Failed to get comment, response: %s", response_dict) 297 return None 298 return list(map(Comment.from_data, response_dict["data"]["data"])) 299 300 async def get_comment_async( 301 self, 302 pid: Union[int, str], 303 page: Union[int, str] = 1, 304 page_size: Union[int, str] = 500, 305 ) -> Optional[List[Comment]]: 306 """ 307 异步获取树洞评论 308 309 Parameters 310 ---------- 311 - pid: 树洞 ID 312 - page: 页码,默认为 1 313 - page_size: 每页评论数,默认为 500 314 315 Returns 316 ------- 317 1. 评论列表,请求错误则返回 `None` 318 """ 319 320 if not self.__is_num(pid): 321 raise ValueError("pid must be an integer or string of interger") 322 if not self.__is_num(page): 323 raise ValueError("page must be an integer or string of interger") 324 if not self.__is_num(page_size): 325 raise ValueError("page_size must be an integer or string of interger") 326 param = { 327 **self.base_param, 328 **{"page": str(page), "limit": str(page_size)}, 329 } 330 async with aiohttp.request( 331 "GET", 332 urljoin(self.comment_url, str(pid)), 333 params=param, 334 headers=self.header, 335 ) as response: 336 if not self.__is_valid_client_response(response): 337 return None 338 response_dict = await response.json() 339 if not response_dict["success"]: 340 logger.error("Failed to get comment, response: %s", response_dict) 341 return None 342 return list(map(Comment.from_data, response_dict["data"]["data"])) 343 344 def get_hole(self, pid: Union[int, str]) -> Optional[Hole]: 345 """ 346 获取单个树洞 347 348 Parameters 349 ---------- 350 - pid: 树洞 ID 351 352 Returns 353 ------- 354 1. 树洞,请求错误则返回 `None` 355 """ 356 357 if not self.__is_num(pid): 358 raise ValueError("pid must be an integer or string of interger") 359 response = requests.get( 360 urljoin(self.hole_url, str(pid)), 361 params=self.base_param, 362 headers=self.header, 363 ) 364 if not self.__is_valid_response(response): 365 return None 366 response_dict = response.json() 367 if not response_dict["success"]: 368 logger.error("Failed to get hole, response: %s", response_dict) 369 return None 370 return Hole.from_data(response_dict["data"]) 371 372 async def get_hole_async(self, pid: Union[int, str]) -> Optional[Hole]: 373 """ 374 异步获取单个树洞 375 376 Parameters 377 ---------- 378 - pid: 树洞 ID 379 380 Returns 381 ------- 382 1. 树洞,请求错误则返回 `None` 383 """ 384 385 if not self.__is_num(pid): 386 raise ValueError("pid must be an integer or string of interger") 387 async with aiohttp.request( 388 "GET", 389 urljoin(self.hole_url, str(pid)), 390 params=self.base_param, 391 headers=self.header, 392 ) as response: 393 if not self.__is_valid_client_response(response): 394 return None 395 response_dict = await response.json() 396 if not response_dict["success"]: 397 logger.error("Failed to get hole, response: %s", response_dict) 398 return None 399 return Hole.from_data(response_dict["data"]) 400 401 def get_holes( 402 self, page: Union[int, str] = 1, page_size: Union[int, str] = 25 403 ) -> Optional[List[Hole]]: 404 """ 405 获取首页树洞 406 407 Parameters 408 ---------- 409 - page: 列表页码,默认为 1 410 - page_size: 每页数量,默认为 25 411 412 Returns 413 ------- 414 1. 首页树洞列表,请求错误则返回 `None` 415 """ 416 417 if not self.__is_num(page): 418 raise ValueError("page must be an integer or string of interger") 419 if not self.__is_num(page_size): 420 raise ValueError("page_size must be an integer or string of interger") 421 param = { 422 **self.base_param, 423 **{"page": str(page), "limit": str(page_size)}, 424 } 425 response = requests.get(self.holes_url, params=param, headers=self.header) 426 if not self.__is_valid_response(response): 427 return None 428 response_dict = response.json() 429 if not response_dict["success"]: 430 logger.error("Failed to get hole list, response: %s", response_dict) 431 return None 432 return list(map(Hole.from_data, response_dict["data"]["data"])) 433 434 async def get_holes_async( 435 self, page: Union[int, str] = 1, page_size: Union[int, str] = 25 436 ) -> Optional[List[Hole]]: 437 """ 438 异步获取首页树洞 439 440 Parameters 441 ---------- 442 - page: 列表页码,默认为 1 443 - page_size: 每页数量,默认为 25 444 445 Returns 446 ------- 447 1. 首页树洞列表,请求错误则返回 `None` 448 """ 449 450 if not self.__is_num(page): 451 raise ValueError("page must be an integer or string of interger") 452 if not self.__is_num(page_size): 453 raise ValueError("page_size must be an integer or string of interger") 454 param = { 455 **self.base_param, 456 **{"page": str(page), "limit": str(page_size)}, 457 } 458 async with aiohttp.request( 459 "GET", self.holes_url, params=param, headers=self.header 460 ) as response: 461 if not self.__is_valid_client_response(response): 462 return None 463 response_dict = await response.json() 464 if not response_dict["success"]: 465 logger.error("Failed to get hole list, response: %s", response_dict) 466 return None 467 return list(map(Hole.from_data, response_dict["data"]["data"])) 468 469 def get_followed( 470 self, page: Union[int, str] = 1, page_size: Union[int, str] = 25 471 ) -> Optional[List[Hole]]: 472 """ 473 获取关注树洞 474 475 Parameters 476 ---------- 477 - page: 列表页码,默认为 1 478 - page_size: 每页数量,默认为 25 479 480 Returns 481 ------- 482 1. 关注树洞列表,请求错误则返回 `None` 483 """ 484 485 if not self.__is_num(page): 486 raise ValueError("page must be an integer or string of interger") 487 if not self.__is_num(page_size): 488 raise ValueError("page_size must be an integer or string of interger") 489 param = { 490 **self.base_param, 491 **{"page": str(page), "limit": str(page_size)}, 492 } 493 response = requests.get(self.follow_url, params=param, headers=self.header) 494 if not self.__is_valid_response(response): 495 return None 496 response_dict = response.json() 497 if not response_dict["success"]: 498 logger.error( 499 "Failed to get followed hole list, response: %s", response_dict 500 ) 501 return None 502 return list(map(Hole.from_data, response_dict["data"]["data"])) 503 504 async def get_followed_async( 505 self, page: Union[int, str] = 1, page_size: Union[int, str] = 25 506 ) -> Optional[List[Hole]]: 507 """ 508 异步获取关注树洞 509 510 Parameters 511 ---------- 512 - page: 列表页码,默认为 1 513 - page_size: 每页数量,默认为 25 514 515 Returns 516 ------- 517 1. 关注树洞列表,请求错误则返回 `None` 518 """ 519 520 if not self.__is_num(page): 521 raise ValueError("page must be an integer or string of interger") 522 if not self.__is_num(page_size): 523 raise ValueError("page_size must be an integer or string of interger") 524 param = { 525 **self.base_param, 526 **{"page": str(page), "limit": str(page_size)}, 527 } 528 async with aiohttp.request( 529 "GET", self.follow_url, params=param, headers=self.header 530 ) as response: 531 if not self.__is_valid_client_response(response): 532 return None 533 response_dict = await response.json() 534 if not response_dict["success"]: 535 logger.error( 536 "Failed to get followed hole list, response: %s", response_dict 537 ) 538 return None 539 return list(map(Hole.from_data, response_dict["data"]["data"])) 540 541 def get_search( 542 self, 543 keywords: Union[str, List[str]], 544 page: Union[int, str] = 1, 545 page_size: Union[int, str] = 50, 546 ) -> Optional[List[Hole]]: 547 """ 548 搜索树洞 549 550 Parameters 551 ---------- 552 - keywords: 搜索关键词 553 - page: 列表页码,默认为 1 554 - page_size: 每页数量,默认为 50 555 556 Returns 557 ------- 558 1. 搜索结果,请求错误则返回 `None` 559 """ 560 if not self.__is_num(page): 561 raise ValueError("page must be an integer or string of interger") 562 if not self.__is_num(page_size): 563 raise ValueError("page size must be an integer or string of interger") 564 if isinstance(keywords, str): 565 keywords = [keywords] 566 param = { 567 **self.base_param, 568 **{ 569 "page": str(page), 570 "limit": str(page_size), 571 "keyword": " ".join(keywords), 572 }, 573 } 574 response = requests.get(self.holes_url, params=param, headers=self.header) 575 if not self.__is_valid_response(response): 576 return None 577 response_dict = response.json() 578 if not response_dict["success"]: 579 logger.error("Failed to get search result, response: %s", response_dict) 580 return None 581 return list(map(Hole.from_data, response_dict["data"]["data"])) 582 583 async def get_search_async( 584 self, 585 keywords: Union[str, List[str]], 586 page: Union[int, str] = 1, 587 page_size: Union[int, str] = 50, 588 ) -> Optional[List[Hole]]: 589 """ 590 异步搜索树洞 591 592 Parameters 593 ---------- 594 - keywords: 搜索关键词 595 - page: 列表页码,默认为 1 596 - page_size: 每页数量,默认为 50 597 598 Returns 599 ------- 600 1. 搜索结果,请求错误则返回 `None` 601 """ 602 if not self.__is_num(page): 603 raise ValueError("page must be an integer or string of interger") 604 if not self.__is_num(page_size): 605 raise ValueError("page size must be an integer or string of interger") 606 if isinstance(keywords, str): 607 keywords = [keywords] 608 param = { 609 **self.base_param, 610 **{ 611 "page": str(page), 612 "limit": str(page_size), 613 "keyword": " ".join(keywords), 614 }, 615 } 616 async with aiohttp.request( 617 "GET", self.holes_url, params=param, headers=self.header 618 ) as response: 619 if not self.__is_valid_client_response(response): 620 return None 621 response_dict = await response.json() 622 if not response_dict["success"]: 623 logger.error("Failed to get search result, response: %s", response_dict) 624 return None 625 return list(map(Hole.from_data, response_dict["data"]["data"])) 626 627 def post_hole( 628 self, text: str = "", image: Optional[Union[bytes, str]] = None 629 ) -> Optional[bool]: 630 """ 631 发布树洞 632 633 Parameters 634 ---------- 635 - text: 树洞内容 636 - image: 树洞图片 (二进制数据或文件名) 637 638 Returns 639 ------- 640 1. 是否发布成功,请求错误则返回 `None` 641 """ 642 if not text and not image: 643 raise EmptyError("Empty post is not allowed") 644 if image is not None: 645 if isinstance(image, str): 646 try: 647 image = open(image, "rb").read() 648 except FileNotFoundError: 649 logger.error(f"File {image} not found") 650 raise FileNotFoundError("File not found") 651 except Exception as e: 652 logger.error(f"Unknown error: {e}") 653 raise e 654 # load for posting hole with image 655 load = { 656 "text": text, 657 "type": "image", 658 } 659 file = {"data": image} 660 else: 661 load = { 662 "text": text, 663 "type": "text", 664 } 665 file = {} 666 response = requests.post( 667 self.store_url, 668 params=self.base_param, 669 headers=self.header, 670 data=load, 671 files=file, 672 ) 673 if not self.__is_valid_response(response): 674 return None 675 response_dict = response.json() 676 if not response_dict["success"]: 677 logger.exception("Post failed: %s", response_dict["messsage"]) 678 return response_dict["success"] 679 680 async def post_hole_async( 681 self, text: str = "", image: Optional[Union[bytes, str]] = None 682 ) -> Optional[bool]: 683 """ 684 异步发布树洞 685 686 Parameters 687 ---------- 688 - text: 树洞内容 689 - image: 树洞图片 (二进制数据或文件名) 690 691 Returns 692 ------- 693 1. 是否发布成功,请求错误则返回 `None` 694 """ 695 if not text and not image: 696 raise EmptyError("Empty post is not allowed") 697 if image is not None: 698 if isinstance(image, str): 699 try: 700 async with aiofiles.open(image, "rb") as f: 701 image = await f.read() 702 except FileNotFoundError: 703 logger.error(f"File {image} not found") 704 raise FileNotFoundError("File not found") 705 except Exception as e: 706 logger.error(f"Unknown error: {e}") 707 raise e 708 # load for posting hole with image 709 load = { 710 "text": text, 711 "type": "image", 712 "data": image, 713 } 714 else: 715 load = { 716 "text": text, 717 "type": "text", 718 } 719 async with aiohttp.request( 720 "POST", 721 self.store_url, 722 params=self.base_param, 723 headers=self.header, 724 data=load, 725 ) as response: 726 if not self.__is_valid_client_response(response): 727 return None 728 response_dict = await response.json() 729 if not response_dict["success"]: 730 logger.exception("Post failed: %s", response_dict["messsage"]) 731 return response_dict["success"] 732 733 def post_comment( 734 self, 735 pid: Union[int, str], 736 text: str, 737 reply_to: Optional[Union[int, str]] = None, 738 ) -> Optional[bool]: 739 """ 740 发布评论 741 742 Parameters 743 ---------- 744 - pid: 树洞 ID 745 - text: 评论内容 746 - reply_to: 回复的用户昵称或标号(非层号),`None` 为回复洞主(默认) 747 748 Returns 749 ------- 750 1. 回复是否成功,请求错误则返回 `None` 751 """ 752 if not text: 753 raise EmptyError("Empty post is not allowed") 754 if not self.__is_num(pid): 755 raise ValueError("pid must be an integer or string of interger") 756 if reply_to is not None: 757 if isinstance(reply_to, str): 758 assert reply_to in UserName, "Invalid reply_to" 759 reply_to = " ".join([x.capitalize() for x in reply_to.split()]) 760 else: 761 reply_to = UserName[reply_to] 762 text = f"Re {reply_to}: {text}" 763 load = { 764 "pid": str(pid), 765 "text": text, 766 } 767 response = requests.post( 768 self.comment_url, params=self.base_param, headers=self.header, data=load 769 ) 770 if not self.__is_valid_response(response): 771 return None 772 response_dict = response.json() 773 if not response_dict["success"]: 774 logger.exception("Comment failed: %s", response_dict["messsage"]) 775 return response_dict["success"] 776 777 async def post_comment_async( 778 self, 779 pid: Union[int, str], 780 text: str, 781 reply_to: Optional[Union[int, str]] = None, 782 ) -> Optional[bool]: 783 """ 784 异步发布评论 785 786 Parameters 787 ---------- 788 - pid: 树洞 ID 789 - text: 评论内容 790 - reply_to: 回复的用户昵称或标号(非层号),`None` 为回复洞主(默认) 791 792 Returns 793 ------- 794 1. 回复是否成功,请求错误则返回 `None` 795 """ 796 if not text: 797 raise EmptyError("Empty post is not allowed") 798 if not self.__is_num(pid): 799 raise ValueError("pid must be an integer or string of interger") 800 if reply_to is not None: 801 if isinstance(reply_to, str): 802 assert reply_to in UserName, "Invalid reply_to" 803 reply_to = " ".join([x.capitalize() for x in reply_to.split()]) 804 else: 805 reply_to = UserName[reply_to] 806 text = f"Re {reply_to}: {text}" 807 load = { 808 "pid": str(pid), 809 "text": text, 810 } 811 async with aiohttp.request( 812 "POST", 813 self.comment_url, 814 params=self.base_param, 815 headers=self.header, 816 data=load, 817 ) as response: 818 if not self.__is_valid_client_response(response): 819 return None 820 response_dict = await response.json() 821 if not response_dict["success"]: 822 logger.exception("Comment failed: %s", response_dict["messsage"]) 823 return response_dict["success"] 824 825 def post_toggle_followed( 826 self, pid: Union[int, str], two_factor: bool = False 827 ) -> Union[Tuple[bool, int], Tuple[None, None]]: 828 """ 829 切换关注状态 830 831 Parameters 832 ---------- 833 - pid: 树洞 ID 834 - two_factor: 是否启用双重验证(默认为 `False`) 835 836 Returns 837 ------- 838 1. 是否成功切换关注状态,请求错误则返回 `None` 839 2. 当前关注状态,`1` 为关注,`0` 为未关注,请求错误则返回 `None` 840 """ 841 hole = self.get_hole(pid) 842 if hole is None or hole.is_follow is None: 843 logger.exception("Failed to get attention status of pid %s", pid) 844 return (None, None) 845 response = requests.post( 846 urljoin(self.attention_url, str(pid)), 847 params=self.base_param, 848 headers=self.header, 849 ) 850 if not self.__is_valid_response(response): 851 return (None, None) 852 response_dict = response.json() 853 if not response_dict["success"]: 854 logger.exception("Toggle attention failed: %s", response_dict["messsage"]) 855 if not two_factor: 856 return ( 857 response_dict["success"], 858 (1 - hole.is_follow) if response_dict["success"] else hole.is_follow, 859 ) 860 hole_verify = self.get_hole(pid) 861 if hole_verify is None or hole_verify.is_follow is None: 862 logger.exception("Failed to get attention status of pid %s", pid) 863 return ( 864 response_dict["success"], 865 (1 - hole.is_follow) if response_dict["success"] else hole.is_follow, 866 ) 867 return response_dict["success"], hole.is_follow 868 869 async def post_toggle_followed_async( 870 self, pid: Union[int, str], two_factor: bool = False 871 ) -> Union[Tuple[bool, int], Tuple[None, None]]: 872 """ 873 异步切换关注状态 874 875 Parameters 876 ---------- 877 - pid: 树洞 ID 878 - two_factor: 是否启用双重验证(默认为 `False`) 879 880 Returns 881 ------- 882 1. 是否成功切换关注状态,请求错误则返回 `None` 883 2. 当前关注状态,`1` 为关注,`0` 为未关注,请求错误则返回 `None` 884 """ 885 hole = await self.get_hole_async(pid) 886 if hole is None or hole.is_follow is None: 887 logger.exception("Failed to get attention status of pid %s", pid) 888 return (None, None) 889 async with aiohttp.request( 890 "POST", 891 urljoin(self.attention_url, str(pid)), 892 params=self.base_param, 893 headers=self.header, 894 ) as response: 895 if not self.__is_valid_client_response(response): 896 return (None, None) 897 response_dict = await response.json() 898 if not response_dict["success"]: 899 logger.exception( 900 "Toggle attention failed: %s", response_dict["messsage"] 901 ) 902 if not two_factor: 903 return ( 904 response_dict["success"], 905 (1 - hole.is_follow) 906 if response_dict["success"] 907 else hole.is_follow, 908 ) 909 hole_verify = await self.get_hole_async(pid) 910 if hole_verify is None or hole_verify.is_follow is None: 911 logger.exception("Failed to get attention status of pid %s", pid) 912 return ( 913 response_dict["success"], 914 (1 - hole.is_follow) 915 if response_dict["success"] 916 else hole.is_follow, 917 ) 918 return response_dict["success"], hole.is_follow 919 920 def post_report(self, pid: Union[int, str], reason: str = "") -> Optional[bool]: 921 """ 922 举报树洞(注意!举报自己的树洞会导致立刻被删并且禁言) 923 924 Parameters 925 ---------- 926 - pid: 树洞 ID 927 - reason: 举报理由(默认为空) 928 929 Returns 930 ------- 931 1. 是否举报成功,请求错误则返回 `None` 932 """ 933 if not self.__is_num(pid): 934 raise ValueError("pid must be an integer or string of interger") 935 load = {"reason": reason} 936 response = requests.post( 937 urljoin(self.report_url, str(pid)), 938 params=self.base_param, 939 headers=self.header, 940 data=load, 941 ) 942 if not self.__is_valid_response(response): 943 return None 944 response_dict = response.json() 945 if not response_dict["success"]: 946 logger.exception("Report failed: %s", response_dict["messsage"]) 947 return response_dict["success"] 948 949 async def post_report_async( 950 self, pid: Union[int, str], reason: str = "" 951 ) -> Optional[bool]: 952 """ 953 异步举报树洞(注意!举报自己的树洞会导致立刻被删并且禁言) 954 955 Parameters 956 ---------- 957 - pid: 树洞 ID 958 - reason: 举报理由(默认为空) 959 960 Returns 961 ------- 962 1. 是否举报成功,请求错误则返回 `None` 963 """ 964 if not self.__is_num(pid): 965 raise ValueError("pid must be an integer or string of interger") 966 load = {"reason": reason} 967 async with aiohttp.request( 968 "POST", 969 urljoin(self.report_url, str(pid)), 970 params=self.base_param, 971 headers=self.header, 972 data=load, 973 ) as response: 974 if not self.__is_valid_client_response(response): 975 return None 976 response_dict = await response.json() 977 if not response_dict["success"]: 978 logger.exception("Report failed: %s", response_dict["messsage"]) 979 return response_dict["success"]
树洞交互客户端,低程度封装
TreeHoleClient( token: Optional[str] = None, uid: Union[int, str, NoneType] = None, password: Optional[str] = None, header: Optional[Dict[str, str]] = None, base_param: Optional[Dict[str, str]] = None, base_url: Optional[str] = None)
28 def __init__( 29 self, 30 token: Optional[str] = None, 31 uid: Optional[Union[int, str]] = None, 32 password: Optional[str] = None, 33 header: Optional[Dict[str, str]] = None, 34 base_param: Optional[Dict[str, str]] = None, 35 base_url: Optional[str] = None, 36 ) -> None: 37 """ 38 - token: 39 用户 token(可在树洞 cookies 中 pku_token 字段中获取) 40 - uid: 41 IAAA 账号 ID(可选的登录方式) 42 - password: 43 IAAA 账号密码(可选的登录方式) 44 - header: 45 额外的请求头,可选 46 - base_param: 47 额外的请求参数,可选 48 - base_url: 49 其他树洞 API 地址,可选 50 """ 51 self.__base_url = base_url or BASE_URL 52 if token: 53 self.__token = token 54 elif uid and password: 55 __token = self.__auth(uid, password) 56 if not __token: 57 raise AuthError("Failed to login") 58 self.__token = __token 59 else: 60 raise AuthError("No token or uid and password provided") 61 self.__header = { 62 **REQUEST_HEADER, 63 **{"authorization": "Bearer " + self.__token}, 64 **(header or {}), 65 } 66 self.__base_param = { 67 **BASE_QUERY, 68 **(base_param or {}), 69 }
- token: 用户 token(可在树洞 cookies 中 pku_token 字段中获取)
- uid: IAAA 账号 ID(可选的登录方式)
- password: IAAA 账号密码(可选的登录方式)
- header: 额外的请求头,可选
- base_param: 额外的请求参数,可选
- base_url: 其他树洞 API 地址,可选
def
get_hole_image( self, hole: treehole.models.Hole) -> Union[Tuple[bytes, str], Tuple[NoneType, NoneType]]:
207 def get_hole_image(self, hole: Hole) -> Union[Tuple[bytes, str], Tuple[None, None]]: 208 """ 209 获取树洞图片 210 211 Parameters 212 ---------- 213 - hole: 214 任一树洞类 215 216 Returns 217 ------- 218 1. 图片二进制数据,不包含图片或请求错误则返回 `None` 219 2. 图片类型,不包含图片或请求错误则返回 `None` 220 """ 221 222 if hole.type == "image": 223 response = requests.get( 224 urljoin(self.image_url, str(hole.pid)), headers=self.header 225 ) 226 if self.__is_valid_response(response): 227 return response.content, response.headers["Content-Type"] 228 return (None, None)
获取树洞图片
Parameters
- hole: 任一树洞类
Returns
- 图片二进制数据,不包含图片或请求错误则返回
None - 图片类型,不包含图片或请求错误则返回
None
async def
get_hole_image_async( self, hole: treehole.models.Hole) -> Union[Tuple[bytes, str], Tuple[NoneType, NoneType]]:
230 async def get_hole_image_async( 231 self, hole: Hole 232 ) -> Union[Tuple[bytes, str], Tuple[None, None]]: 233 """ 234 异步获取树洞图片 235 236 Parameters 237 ---------- 238 - hole: 任一树洞类 239 240 Returns 241 ------- 242 1. 图片二进制数据,不包含图片或请求错误则返回 `None` 243 2. 图片类型,不包含图片或请求错误则返回 `None` 244 """ 245 246 if hole.type == "image": 247 async with aiohttp.request( 248 "GET", 249 urljoin(self.image_url, str(hole.pid)), 250 headers=self.header, 251 ) as response: 252 if self.__is_valid_client_response(response): 253 return ( 254 await response.content.read(), 255 response.headers["Content-Type"], 256 ) 257 return (None, None)
异步获取树洞图片
Parameters
- hole: 任一树洞类
Returns
- 图片二进制数据,不包含图片或请求错误则返回
None - 图片类型,不包含图片或请求错误则返回
None
def
get_comment( self, pid: Union[int, str], page: Union[int, str] = 1, page_size: Union[int, str] = 500) -> Optional[List[treehole.models.Comment]]:
259 def get_comment( 260 self, 261 pid: Union[int, str], 262 page: Union[int, str] = 1, 263 page_size: Union[int, str] = 500, 264 ) -> Optional[List[Comment]]: 265 """ 266 获取树洞评论 267 268 Parameters 269 ---------- 270 - pid: 树洞 ID 271 - page: 页码,默认为 1 272 - page_size: 每页评论数,默认为 500 273 274 Returns 275 ------- 276 1. 评论列表,请求错误则返回 `None` 277 """ 278 279 if not self.__is_num(pid): 280 raise ValueError("pid must be an integer or string of interger") 281 if not self.__is_num(page): 282 raise ValueError("page must be an integer or string of interger") 283 if not self.__is_num(page_size): 284 raise ValueError("page_size must be an integer or string of interger") 285 param = { 286 **self.base_param, 287 **{"page": str(page), "limit": str(page_size)}, 288 } 289 response = requests.get( 290 urljoin(self.comment_url, str(pid)), params=param, headers=self.header 291 ) 292 if not self.__is_valid_response(response): 293 return None 294 response_dict = response.json() 295 if not response_dict["success"]: 296 logger.error("Failed to get comment, response: %s", response_dict) 297 return None 298 return list(map(Comment.from_data, response_dict["data"]["data"]))
获取树洞评论
Parameters
- pid: 树洞 ID
- page: 页码,默认为 1
- page_size: 每页评论数,默认为 500
Returns
- 评论列表,请求错误则返回
None
async def
get_comment_async( self, pid: Union[int, str], page: Union[int, str] = 1, page_size: Union[int, str] = 500) -> Optional[List[treehole.models.Comment]]:
300 async def get_comment_async( 301 self, 302 pid: Union[int, str], 303 page: Union[int, str] = 1, 304 page_size: Union[int, str] = 500, 305 ) -> Optional[List[Comment]]: 306 """ 307 异步获取树洞评论 308 309 Parameters 310 ---------- 311 - pid: 树洞 ID 312 - page: 页码,默认为 1 313 - page_size: 每页评论数,默认为 500 314 315 Returns 316 ------- 317 1. 评论列表,请求错误则返回 `None` 318 """ 319 320 if not self.__is_num(pid): 321 raise ValueError("pid must be an integer or string of interger") 322 if not self.__is_num(page): 323 raise ValueError("page must be an integer or string of interger") 324 if not self.__is_num(page_size): 325 raise ValueError("page_size must be an integer or string of interger") 326 param = { 327 **self.base_param, 328 **{"page": str(page), "limit": str(page_size)}, 329 } 330 async with aiohttp.request( 331 "GET", 332 urljoin(self.comment_url, str(pid)), 333 params=param, 334 headers=self.header, 335 ) as response: 336 if not self.__is_valid_client_response(response): 337 return None 338 response_dict = await response.json() 339 if not response_dict["success"]: 340 logger.error("Failed to get comment, response: %s", response_dict) 341 return None 342 return list(map(Comment.from_data, response_dict["data"]["data"]))
异步获取树洞评论
Parameters
- pid: 树洞 ID
- page: 页码,默认为 1
- page_size: 每页评论数,默认为 500
Returns
- 评论列表,请求错误则返回
None
344 def get_hole(self, pid: Union[int, str]) -> Optional[Hole]: 345 """ 346 获取单个树洞 347 348 Parameters 349 ---------- 350 - pid: 树洞 ID 351 352 Returns 353 ------- 354 1. 树洞,请求错误则返回 `None` 355 """ 356 357 if not self.__is_num(pid): 358 raise ValueError("pid must be an integer or string of interger") 359 response = requests.get( 360 urljoin(self.hole_url, str(pid)), 361 params=self.base_param, 362 headers=self.header, 363 ) 364 if not self.__is_valid_response(response): 365 return None 366 response_dict = response.json() 367 if not response_dict["success"]: 368 logger.error("Failed to get hole, response: %s", response_dict) 369 return None 370 return Hole.from_data(response_dict["data"])
获取单个树洞
Parameters
- pid: 树洞 ID
Returns
- 树洞,请求错误则返回
None
372 async def get_hole_async(self, pid: Union[int, str]) -> Optional[Hole]: 373 """ 374 异步获取单个树洞 375 376 Parameters 377 ---------- 378 - pid: 树洞 ID 379 380 Returns 381 ------- 382 1. 树洞,请求错误则返回 `None` 383 """ 384 385 if not self.__is_num(pid): 386 raise ValueError("pid must be an integer or string of interger") 387 async with aiohttp.request( 388 "GET", 389 urljoin(self.hole_url, str(pid)), 390 params=self.base_param, 391 headers=self.header, 392 ) as response: 393 if not self.__is_valid_client_response(response): 394 return None 395 response_dict = await response.json() 396 if not response_dict["success"]: 397 logger.error("Failed to get hole, response: %s", response_dict) 398 return None 399 return Hole.from_data(response_dict["data"])
异步获取单个树洞
Parameters
- pid: 树洞 ID
Returns
- 树洞,请求错误则返回
None
def
get_holes( self, page: Union[int, str] = 1, page_size: Union[int, str] = 25) -> Optional[List[treehole.models.Hole]]:
401 def get_holes( 402 self, page: Union[int, str] = 1, page_size: Union[int, str] = 25 403 ) -> Optional[List[Hole]]: 404 """ 405 获取首页树洞 406 407 Parameters 408 ---------- 409 - page: 列表页码,默认为 1 410 - page_size: 每页数量,默认为 25 411 412 Returns 413 ------- 414 1. 首页树洞列表,请求错误则返回 `None` 415 """ 416 417 if not self.__is_num(page): 418 raise ValueError("page must be an integer or string of interger") 419 if not self.__is_num(page_size): 420 raise ValueError("page_size must be an integer or string of interger") 421 param = { 422 **self.base_param, 423 **{"page": str(page), "limit": str(page_size)}, 424 } 425 response = requests.get(self.holes_url, params=param, headers=self.header) 426 if not self.__is_valid_response(response): 427 return None 428 response_dict = response.json() 429 if not response_dict["success"]: 430 logger.error("Failed to get hole list, response: %s", response_dict) 431 return None 432 return list(map(Hole.from_data, response_dict["data"]["data"]))
获取首页树洞
Parameters
- page: 列表页码,默认为 1
- page_size: 每页数量,默认为 25
Returns
- 首页树洞列表,请求错误则返回
None
async def
get_holes_async( self, page: Union[int, str] = 1, page_size: Union[int, str] = 25) -> Optional[List[treehole.models.Hole]]:
434 async def get_holes_async( 435 self, page: Union[int, str] = 1, page_size: Union[int, str] = 25 436 ) -> Optional[List[Hole]]: 437 """ 438 异步获取首页树洞 439 440 Parameters 441 ---------- 442 - page: 列表页码,默认为 1 443 - page_size: 每页数量,默认为 25 444 445 Returns 446 ------- 447 1. 首页树洞列表,请求错误则返回 `None` 448 """ 449 450 if not self.__is_num(page): 451 raise ValueError("page must be an integer or string of interger") 452 if not self.__is_num(page_size): 453 raise ValueError("page_size must be an integer or string of interger") 454 param = { 455 **self.base_param, 456 **{"page": str(page), "limit": str(page_size)}, 457 } 458 async with aiohttp.request( 459 "GET", self.holes_url, params=param, headers=self.header 460 ) as response: 461 if not self.__is_valid_client_response(response): 462 return None 463 response_dict = await response.json() 464 if not response_dict["success"]: 465 logger.error("Failed to get hole list, response: %s", response_dict) 466 return None 467 return list(map(Hole.from_data, response_dict["data"]["data"]))
异步获取首页树洞
Parameters
- page: 列表页码,默认为 1
- page_size: 每页数量,默认为 25
Returns
- 首页树洞列表,请求错误则返回
None
def
get_followed( self, page: Union[int, str] = 1, page_size: Union[int, str] = 25) -> Optional[List[treehole.models.Hole]]:
469 def get_followed( 470 self, page: Union[int, str] = 1, page_size: Union[int, str] = 25 471 ) -> Optional[List[Hole]]: 472 """ 473 获取关注树洞 474 475 Parameters 476 ---------- 477 - page: 列表页码,默认为 1 478 - page_size: 每页数量,默认为 25 479 480 Returns 481 ------- 482 1. 关注树洞列表,请求错误则返回 `None` 483 """ 484 485 if not self.__is_num(page): 486 raise ValueError("page must be an integer or string of interger") 487 if not self.__is_num(page_size): 488 raise ValueError("page_size must be an integer or string of interger") 489 param = { 490 **self.base_param, 491 **{"page": str(page), "limit": str(page_size)}, 492 } 493 response = requests.get(self.follow_url, params=param, headers=self.header) 494 if not self.__is_valid_response(response): 495 return None 496 response_dict = response.json() 497 if not response_dict["success"]: 498 logger.error( 499 "Failed to get followed hole list, response: %s", response_dict 500 ) 501 return None 502 return list(map(Hole.from_data, response_dict["data"]["data"]))
获取关注树洞
Parameters
- page: 列表页码,默认为 1
- page_size: 每页数量,默认为 25
Returns
- 关注树洞列表,请求错误则返回
None
async def
get_followed_async( self, page: Union[int, str] = 1, page_size: Union[int, str] = 25) -> Optional[List[treehole.models.Hole]]:
504 async def get_followed_async( 505 self, page: Union[int, str] = 1, page_size: Union[int, str] = 25 506 ) -> Optional[List[Hole]]: 507 """ 508 异步获取关注树洞 509 510 Parameters 511 ---------- 512 - page: 列表页码,默认为 1 513 - page_size: 每页数量,默认为 25 514 515 Returns 516 ------- 517 1. 关注树洞列表,请求错误则返回 `None` 518 """ 519 520 if not self.__is_num(page): 521 raise ValueError("page must be an integer or string of interger") 522 if not self.__is_num(page_size): 523 raise ValueError("page_size must be an integer or string of interger") 524 param = { 525 **self.base_param, 526 **{"page": str(page), "limit": str(page_size)}, 527 } 528 async with aiohttp.request( 529 "GET", self.follow_url, params=param, headers=self.header 530 ) as response: 531 if not self.__is_valid_client_response(response): 532 return None 533 response_dict = await response.json() 534 if not response_dict["success"]: 535 logger.error( 536 "Failed to get followed hole list, response: %s", response_dict 537 ) 538 return None 539 return list(map(Hole.from_data, response_dict["data"]["data"]))
异步获取关注树洞
Parameters
- page: 列表页码,默认为 1
- page_size: 每页数量,默认为 25
Returns
- 关注树洞列表,请求错误则返回
None
def
get_search( self, keywords: Union[str, List[str]], page: Union[int, str] = 1, page_size: Union[int, str] = 50) -> Optional[List[treehole.models.Hole]]:
541 def get_search( 542 self, 543 keywords: Union[str, List[str]], 544 page: Union[int, str] = 1, 545 page_size: Union[int, str] = 50, 546 ) -> Optional[List[Hole]]: 547 """ 548 搜索树洞 549 550 Parameters 551 ---------- 552 - keywords: 搜索关键词 553 - page: 列表页码,默认为 1 554 - page_size: 每页数量,默认为 50 555 556 Returns 557 ------- 558 1. 搜索结果,请求错误则返回 `None` 559 """ 560 if not self.__is_num(page): 561 raise ValueError("page must be an integer or string of interger") 562 if not self.__is_num(page_size): 563 raise ValueError("page size must be an integer or string of interger") 564 if isinstance(keywords, str): 565 keywords = [keywords] 566 param = { 567 **self.base_param, 568 **{ 569 "page": str(page), 570 "limit": str(page_size), 571 "keyword": " ".join(keywords), 572 }, 573 } 574 response = requests.get(self.holes_url, params=param, headers=self.header) 575 if not self.__is_valid_response(response): 576 return None 577 response_dict = response.json() 578 if not response_dict["success"]: 579 logger.error("Failed to get search result, response: %s", response_dict) 580 return None 581 return list(map(Hole.from_data, response_dict["data"]["data"]))
搜索树洞
Parameters
- keywords: 搜索关键词
- page: 列表页码,默认为 1
- page_size: 每页数量,默认为 50
Returns
- 搜索结果,请求错误则返回
None
async def
get_search_async( self, keywords: Union[str, List[str]], page: Union[int, str] = 1, page_size: Union[int, str] = 50) -> Optional[List[treehole.models.Hole]]:
583 async def get_search_async( 584 self, 585 keywords: Union[str, List[str]], 586 page: Union[int, str] = 1, 587 page_size: Union[int, str] = 50, 588 ) -> Optional[List[Hole]]: 589 """ 590 异步搜索树洞 591 592 Parameters 593 ---------- 594 - keywords: 搜索关键词 595 - page: 列表页码,默认为 1 596 - page_size: 每页数量,默认为 50 597 598 Returns 599 ------- 600 1. 搜索结果,请求错误则返回 `None` 601 """ 602 if not self.__is_num(page): 603 raise ValueError("page must be an integer or string of interger") 604 if not self.__is_num(page_size): 605 raise ValueError("page size must be an integer or string of interger") 606 if isinstance(keywords, str): 607 keywords = [keywords] 608 param = { 609 **self.base_param, 610 **{ 611 "page": str(page), 612 "limit": str(page_size), 613 "keyword": " ".join(keywords), 614 }, 615 } 616 async with aiohttp.request( 617 "GET", self.holes_url, params=param, headers=self.header 618 ) as response: 619 if not self.__is_valid_client_response(response): 620 return None 621 response_dict = await response.json() 622 if not response_dict["success"]: 623 logger.error("Failed to get search result, response: %s", response_dict) 624 return None 625 return list(map(Hole.from_data, response_dict["data"]["data"]))
异步搜索树洞
Parameters
- keywords: 搜索关键词
- page: 列表页码,默认为 1
- page_size: 每页数量,默认为 50
Returns
- 搜索结果,请求错误则返回
None
def
post_hole( self, text: str = '', image: Union[bytes, str, NoneType] = None) -> Optional[bool]:
627 def post_hole( 628 self, text: str = "", image: Optional[Union[bytes, str]] = None 629 ) -> Optional[bool]: 630 """ 631 发布树洞 632 633 Parameters 634 ---------- 635 - text: 树洞内容 636 - image: 树洞图片 (二进制数据或文件名) 637 638 Returns 639 ------- 640 1. 是否发布成功,请求错误则返回 `None` 641 """ 642 if not text and not image: 643 raise EmptyError("Empty post is not allowed") 644 if image is not None: 645 if isinstance(image, str): 646 try: 647 image = open(image, "rb").read() 648 except FileNotFoundError: 649 logger.error(f"File {image} not found") 650 raise FileNotFoundError("File not found") 651 except Exception as e: 652 logger.error(f"Unknown error: {e}") 653 raise e 654 # load for posting hole with image 655 load = { 656 "text": text, 657 "type": "image", 658 } 659 file = {"data": image} 660 else: 661 load = { 662 "text": text, 663 "type": "text", 664 } 665 file = {} 666 response = requests.post( 667 self.store_url, 668 params=self.base_param, 669 headers=self.header, 670 data=load, 671 files=file, 672 ) 673 if not self.__is_valid_response(response): 674 return None 675 response_dict = response.json() 676 if not response_dict["success"]: 677 logger.exception("Post failed: %s", response_dict["messsage"]) 678 return response_dict["success"]
发布树洞
Parameters
- text: 树洞内容
- image: 树洞图片 (二进制数据或文件名)
Returns
- 是否发布成功,请求错误则返回
None
async def
post_hole_async( self, text: str = '', image: Union[bytes, str, NoneType] = None) -> Optional[bool]:
680 async def post_hole_async( 681 self, text: str = "", image: Optional[Union[bytes, str]] = None 682 ) -> Optional[bool]: 683 """ 684 异步发布树洞 685 686 Parameters 687 ---------- 688 - text: 树洞内容 689 - image: 树洞图片 (二进制数据或文件名) 690 691 Returns 692 ------- 693 1. 是否发布成功,请求错误则返回 `None` 694 """ 695 if not text and not image: 696 raise EmptyError("Empty post is not allowed") 697 if image is not None: 698 if isinstance(image, str): 699 try: 700 async with aiofiles.open(image, "rb") as f: 701 image = await f.read() 702 except FileNotFoundError: 703 logger.error(f"File {image} not found") 704 raise FileNotFoundError("File not found") 705 except Exception as e: 706 logger.error(f"Unknown error: {e}") 707 raise e 708 # load for posting hole with image 709 load = { 710 "text": text, 711 "type": "image", 712 "data": image, 713 } 714 else: 715 load = { 716 "text": text, 717 "type": "text", 718 } 719 async with aiohttp.request( 720 "POST", 721 self.store_url, 722 params=self.base_param, 723 headers=self.header, 724 data=load, 725 ) as response: 726 if not self.__is_valid_client_response(response): 727 return None 728 response_dict = await response.json() 729 if not response_dict["success"]: 730 logger.exception("Post failed: %s", response_dict["messsage"]) 731 return response_dict["success"]
异步发布树洞
Parameters
- text: 树洞内容
- image: 树洞图片 (二进制数据或文件名)
Returns
- 是否发布成功,请求错误则返回
None
def
post_comment( self, pid: Union[int, str], text: str, reply_to: Union[int, str, NoneType] = None) -> Optional[bool]:
733 def post_comment( 734 self, 735 pid: Union[int, str], 736 text: str, 737 reply_to: Optional[Union[int, str]] = None, 738 ) -> Optional[bool]: 739 """ 740 发布评论 741 742 Parameters 743 ---------- 744 - pid: 树洞 ID 745 - text: 评论内容 746 - reply_to: 回复的用户昵称或标号(非层号),`None` 为回复洞主(默认) 747 748 Returns 749 ------- 750 1. 回复是否成功,请求错误则返回 `None` 751 """ 752 if not text: 753 raise EmptyError("Empty post is not allowed") 754 if not self.__is_num(pid): 755 raise ValueError("pid must be an integer or string of interger") 756 if reply_to is not None: 757 if isinstance(reply_to, str): 758 assert reply_to in UserName, "Invalid reply_to" 759 reply_to = " ".join([x.capitalize() for x in reply_to.split()]) 760 else: 761 reply_to = UserName[reply_to] 762 text = f"Re {reply_to}: {text}" 763 load = { 764 "pid": str(pid), 765 "text": text, 766 } 767 response = requests.post( 768 self.comment_url, params=self.base_param, headers=self.header, data=load 769 ) 770 if not self.__is_valid_response(response): 771 return None 772 response_dict = response.json() 773 if not response_dict["success"]: 774 logger.exception("Comment failed: %s", response_dict["messsage"]) 775 return response_dict["success"]
发布评论
Parameters
- pid: 树洞 ID
- text: 评论内容
- reply_to: 回复的用户昵称或标号(非层号),
None为回复洞主(默认)
Returns
- 回复是否成功,请求错误则返回
None
async def
post_comment_async( self, pid: Union[int, str], text: str, reply_to: Union[int, str, NoneType] = None) -> Optional[bool]:
777 async def post_comment_async( 778 self, 779 pid: Union[int, str], 780 text: str, 781 reply_to: Optional[Union[int, str]] = None, 782 ) -> Optional[bool]: 783 """ 784 异步发布评论 785 786 Parameters 787 ---------- 788 - pid: 树洞 ID 789 - text: 评论内容 790 - reply_to: 回复的用户昵称或标号(非层号),`None` 为回复洞主(默认) 791 792 Returns 793 ------- 794 1. 回复是否成功,请求错误则返回 `None` 795 """ 796 if not text: 797 raise EmptyError("Empty post is not allowed") 798 if not self.__is_num(pid): 799 raise ValueError("pid must be an integer or string of interger") 800 if reply_to is not None: 801 if isinstance(reply_to, str): 802 assert reply_to in UserName, "Invalid reply_to" 803 reply_to = " ".join([x.capitalize() for x in reply_to.split()]) 804 else: 805 reply_to = UserName[reply_to] 806 text = f"Re {reply_to}: {text}" 807 load = { 808 "pid": str(pid), 809 "text": text, 810 } 811 async with aiohttp.request( 812 "POST", 813 self.comment_url, 814 params=self.base_param, 815 headers=self.header, 816 data=load, 817 ) as response: 818 if not self.__is_valid_client_response(response): 819 return None 820 response_dict = await response.json() 821 if not response_dict["success"]: 822 logger.exception("Comment failed: %s", response_dict["messsage"]) 823 return response_dict["success"]
异步发布评论
Parameters
- pid: 树洞 ID
- text: 评论内容
- reply_to: 回复的用户昵称或标号(非层号),
None为回复洞主(默认)
Returns
- 回复是否成功,请求错误则返回
None
def
post_toggle_followed( self, pid: Union[int, str], two_factor: bool = False) -> Union[Tuple[bool, int], Tuple[NoneType, NoneType]]:
825 def post_toggle_followed( 826 self, pid: Union[int, str], two_factor: bool = False 827 ) -> Union[Tuple[bool, int], Tuple[None, None]]: 828 """ 829 切换关注状态 830 831 Parameters 832 ---------- 833 - pid: 树洞 ID 834 - two_factor: 是否启用双重验证(默认为 `False`) 835 836 Returns 837 ------- 838 1. 是否成功切换关注状态,请求错误则返回 `None` 839 2. 当前关注状态,`1` 为关注,`0` 为未关注,请求错误则返回 `None` 840 """ 841 hole = self.get_hole(pid) 842 if hole is None or hole.is_follow is None: 843 logger.exception("Failed to get attention status of pid %s", pid) 844 return (None, None) 845 response = requests.post( 846 urljoin(self.attention_url, str(pid)), 847 params=self.base_param, 848 headers=self.header, 849 ) 850 if not self.__is_valid_response(response): 851 return (None, None) 852 response_dict = response.json() 853 if not response_dict["success"]: 854 logger.exception("Toggle attention failed: %s", response_dict["messsage"]) 855 if not two_factor: 856 return ( 857 response_dict["success"], 858 (1 - hole.is_follow) if response_dict["success"] else hole.is_follow, 859 ) 860 hole_verify = self.get_hole(pid) 861 if hole_verify is None or hole_verify.is_follow is None: 862 logger.exception("Failed to get attention status of pid %s", pid) 863 return ( 864 response_dict["success"], 865 (1 - hole.is_follow) if response_dict["success"] else hole.is_follow, 866 ) 867 return response_dict["success"], hole.is_follow
切换关注状态
Parameters
- pid: 树洞 ID
- two_factor: 是否启用双重验证(默认为
False)
Returns
- 是否成功切换关注状态,请求错误则返回
None - 当前关注状态,
1为关注,0为未关注,请求错误则返回None
async def
post_toggle_followed_async( self, pid: Union[int, str], two_factor: bool = False) -> Union[Tuple[bool, int], Tuple[NoneType, NoneType]]:
869 async def post_toggle_followed_async( 870 self, pid: Union[int, str], two_factor: bool = False 871 ) -> Union[Tuple[bool, int], Tuple[None, None]]: 872 """ 873 异步切换关注状态 874 875 Parameters 876 ---------- 877 - pid: 树洞 ID 878 - two_factor: 是否启用双重验证(默认为 `False`) 879 880 Returns 881 ------- 882 1. 是否成功切换关注状态,请求错误则返回 `None` 883 2. 当前关注状态,`1` 为关注,`0` 为未关注,请求错误则返回 `None` 884 """ 885 hole = await self.get_hole_async(pid) 886 if hole is None or hole.is_follow is None: 887 logger.exception("Failed to get attention status of pid %s", pid) 888 return (None, None) 889 async with aiohttp.request( 890 "POST", 891 urljoin(self.attention_url, str(pid)), 892 params=self.base_param, 893 headers=self.header, 894 ) as response: 895 if not self.__is_valid_client_response(response): 896 return (None, None) 897 response_dict = await response.json() 898 if not response_dict["success"]: 899 logger.exception( 900 "Toggle attention failed: %s", response_dict["messsage"] 901 ) 902 if not two_factor: 903 return ( 904 response_dict["success"], 905 (1 - hole.is_follow) 906 if response_dict["success"] 907 else hole.is_follow, 908 ) 909 hole_verify = await self.get_hole_async(pid) 910 if hole_verify is None or hole_verify.is_follow is None: 911 logger.exception("Failed to get attention status of pid %s", pid) 912 return ( 913 response_dict["success"], 914 (1 - hole.is_follow) 915 if response_dict["success"] 916 else hole.is_follow, 917 ) 918 return response_dict["success"], hole.is_follow
异步切换关注状态
Parameters
- pid: 树洞 ID
- two_factor: 是否启用双重验证(默认为
False)
Returns
- 是否成功切换关注状态,请求错误则返回
None - 当前关注状态,
1为关注,0为未关注,请求错误则返回None
def
post_report(self, pid: Union[int, str], reason: str = '') -> Optional[bool]:
920 def post_report(self, pid: Union[int, str], reason: str = "") -> Optional[bool]: 921 """ 922 举报树洞(注意!举报自己的树洞会导致立刻被删并且禁言) 923 924 Parameters 925 ---------- 926 - pid: 树洞 ID 927 - reason: 举报理由(默认为空) 928 929 Returns 930 ------- 931 1. 是否举报成功,请求错误则返回 `None` 932 """ 933 if not self.__is_num(pid): 934 raise ValueError("pid must be an integer or string of interger") 935 load = {"reason": reason} 936 response = requests.post( 937 urljoin(self.report_url, str(pid)), 938 params=self.base_param, 939 headers=self.header, 940 data=load, 941 ) 942 if not self.__is_valid_response(response): 943 return None 944 response_dict = response.json() 945 if not response_dict["success"]: 946 logger.exception("Report failed: %s", response_dict["messsage"]) 947 return response_dict["success"]
举报树洞(注意!举报自己的树洞会导致立刻被删并且禁言)
Parameters
- pid: 树洞 ID
- reason: 举报理由(默认为空)
Returns
- 是否举报成功,请求错误则返回
None
async def
post_report_async(self, pid: Union[int, str], reason: str = '') -> Optional[bool]:
949 async def post_report_async( 950 self, pid: Union[int, str], reason: str = "" 951 ) -> Optional[bool]: 952 """ 953 异步举报树洞(注意!举报自己的树洞会导致立刻被删并且禁言) 954 955 Parameters 956 ---------- 957 - pid: 树洞 ID 958 - reason: 举报理由(默认为空) 959 960 Returns 961 ------- 962 1. 是否举报成功,请求错误则返回 `None` 963 """ 964 if not self.__is_num(pid): 965 raise ValueError("pid must be an integer or string of interger") 966 load = {"reason": reason} 967 async with aiohttp.request( 968 "POST", 969 urljoin(self.report_url, str(pid)), 970 params=self.base_param, 971 headers=self.header, 972 data=load, 973 ) as response: 974 if not self.__is_valid_client_response(response): 975 return None 976 response_dict = await response.json() 977 if not response_dict["success"]: 978 logger.exception("Report failed: %s", response_dict["messsage"]) 979 return response_dict["success"]
异步举报树洞(注意!举报自己的树洞会导致立刻被删并且禁言)
Parameters
- pid: 树洞 ID
- reason: 举报理由(默认为空)
Returns
- 是否举报成功,请求错误则返回
None