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 地址,可选
token: str

用户 token,只读

header: Dict[str, str]

请求头,只读

base_param: Dict[str, str]

请求参数,只读

base_url: str

请求地址,只读

login_url: str

登陆请求地址,只读

hole_url: str

树洞请求地址,只读

holes_url: str

首页及搜索树洞请求地址,只读

comment_url: str

树洞评论请求地址,只读

follow_url: str

关注列表请求地址,只读

image_url: str

树洞图片请求地址,只读

attention_url: str

设置关注请求地址,只读

report_url: str

举报请求地址,只读

store_url: str

举报请求地址,只读

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

  1. 图片二进制数据,不包含图片或请求错误则返回 None
  2. 图片类型,不包含图片或请求错误则返回 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

  1. 图片二进制数据,不包含图片或请求错误则返回 None
  2. 图片类型,不包含图片或请求错误则返回 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

  1. 评论列表,请求错误则返回 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

  1. 评论列表,请求错误则返回 None
def get_hole(self, pid: Union[int, str]) -> Optional[treehole.models.Hole]:
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

  1. 树洞,请求错误则返回 None
async def get_hole_async(self, pid: Union[int, str]) -> Optional[treehole.models.Hole]:
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

  1. 树洞,请求错误则返回 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

  1. 首页树洞列表,请求错误则返回 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

  1. 首页树洞列表,请求错误则返回 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

  1. 关注树洞列表,请求错误则返回 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

  1. 关注树洞列表,请求错误则返回 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

  1. 搜索结果,请求错误则返回 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

  1. 是否发布成功,请求错误则返回 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

  1. 是否发布成功,请求错误则返回 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

  1. 回复是否成功,请求错误则返回 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

  1. 回复是否成功,请求错误则返回 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

  1. 是否成功切换关注状态,请求错误则返回 None
  2. 当前关注状态,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

  1. 是否成功切换关注状态,请求错误则返回 None
  2. 当前关注状态,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

  1. 是否举报成功,请求错误则返回 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

  1. 是否举报成功,请求错误则返回 None