├── .gitignore ├── README.md ├── Zlibrary.py ├── _conf_schema.json ├── annas_py ├── __init__.py ├── __pycache__ │ ├── __init__.cpython-310.pyc │ └── utils.cpython-310.pyc ├── extractors │ ├── __init__.py │ ├── __pycache__ │ │ ├── __init__.cpython-310.pyc │ │ ├── download.cpython-310.pyc │ │ ├── generic.cpython-310.pyc │ │ ├── recent.cpython-310.pyc │ │ └── search.cpython-310.pyc │ ├── download.py │ ├── generic.py │ ├── recent.py │ └── search.py ├── models │ ├── __pycache__ │ │ ├── args.cpython-310.pyc │ │ └── data.cpython-310.pyc │ ├── args.py │ └── data.py └── utils.py ├── main.py └── metadata.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/* 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # astrbot_plugin_ebooks 2 | 3 | 一个功能强大的电子书搜索和下载插件,提供多种平台支持,包括 Calibre-Web、Liber3、Z-Library 和 Archive。 4 | 5 | ## 功能说明 6 | 7 | 该插件支持通过多种电子书目录协议(例如 OPDS)进行电子书的搜索、下载和推荐操作,具体包括以下功能: 8 | 9 | ### 电子书搜索 10 | 11 | 支持以下平台的电子书搜索: 12 | 13 | - **Calibre-Web** 14 | - **Liber3** 15 | - **Z-Library** 16 | - **Archive** 17 | 18 | 通过搜索关键词,可以快速找到对应的电子书信息,返回的结果包括标题、作者、简介、封面、出版年份、文件类型和下载链接等。 19 | 20 | ### 电子书下载 21 | 22 | 提供高效的电子书下载功能: 23 | 24 | - 支持通过下载链接、电子书 ID 或哈希值进行下载。 25 | - 自动从响应头解析文件名并保存到本地。 26 | - 支持常见的电子书格式(如 PDF、EPUB 等)。 27 | 28 | ### 随机推荐 29 | 30 | 随机从现有的电子书目录中推荐 n 本书籍,展示推荐的电子书详情,包括封面、作者、简介和下载链接。 31 | 32 | ## 支持平台 33 | 34 | 1. **Calibre-Web** 35 | - 支持通过 OPDS 协议进行搜索与下载。 36 | - 提供随机推荐功能。 37 | 38 | 2. **Liber3** 39 | - 支持电子书的详细信息(如语言、文件大小等)。 40 | - 通过电子书 ID 进行下载。 41 | 42 | 3. **Z-Library** 43 | - 搜索和下载全球最大的免费电子书数据库。 44 | - 支持通过 ID 或哈希值下载电子书。 45 | 46 | 4. **Archive** 47 | - 通过高级搜索 API 搜索电子书。 48 | - 过滤支持的格式(如 PDF/EPUB)。 49 | 50 | ## 使用指南 51 | 52 | ### 命令参考 53 | 54 | 以下为插件提供的命令与相关功能(支持在 AstrBot 中调用): 55 | 56 | #### **Calibre-Web** 57 | 58 | - `/calibre search <关键词>`:搜索 Calibre-Web 中的电子书。例如: 59 | ``` 60 | /calibre search Python 61 | ``` 62 | 63 | - `/calibre download <下载链接/书名>`:通过 Calibre-Web 下载电子书。例如: 64 | ``` 65 | /calibre download 66 | ``` 67 | 68 | - `/calibre recommend <数量>`:随机推荐指定数量的电子书。例如: 69 | ``` 70 | /calibre recommend 5 71 | ``` 72 | 73 | #### **Liber3** 74 | 75 | - `/liber3 search <关键词>`:搜索 Liber3 平台的电子书。例如: 76 | ``` 77 | /liber3 search Python 78 | ``` 79 | 80 | - `/liber3 download `:通过 Liber3 下载电子书。例如: 81 | ``` 82 | /liber3 download 12345 83 | ``` 84 | 85 | #### **Z-Library** 86 | 87 | - `/zlib search <关键词> [数量(可选)]`:搜索 Z-Library 的电子书。例如: 88 | ``` 89 | /zlib search Python (10) 90 | ``` 91 | 92 | - `/zlib download `:通过 Z-Library 平台下载电子书。例如: 93 | ``` 94 | /zlib download 12345 abcde12345 95 | ``` 96 | 97 | #### **Archive** 98 | 99 | - `/archive search <关键词> [数量(可选)]`:搜索 Archive 平台的电子书。例如: 100 | ``` 101 | /archive search Python (10) 102 | ``` 103 | 104 | - `/archive download <下载链接>`:通过 Archive 平台提供的下载 URL 下载电子书。例如: 105 | ``` 106 | /archive download 107 | ``` 108 | 109 | #### 帮助命令 110 | 111 | - `/ebooks help`:显示当前插件的帮助信息。 112 | 113 | ### 输出展示 114 | 115 | - **文本格式**:使用转发消息格式,展示书籍详情,包括封面、标题、作者、简介和链接等。 116 | - **推荐功能**:随机展示指定数量的推荐书籍。 117 | 118 | ## 注意事项 119 | 120 | 1. 所有下载指令均要求提供有效的 ID、哈希值或下载链接。 121 | 2. 推荐的书籍数量需在 1 到 50 之间,以避免生成失败。 122 | 3. 一些功能需要配置环境变量或插件参数(如 Calibre-Web 访问地址和代理设置等)。 123 | 124 | ## 版本信息 125 | 126 | - **插件名称**:ebooks 127 | - **标识符**:buding 128 | - **描述**:一个功能强大的电子书搜索和下载插件 129 | - **版本**:1.0.0 130 | - **源码**:[GitHub 地址](https://github.com/zouyonghe/astrbot_plugin_ebooks) 131 | -------------------------------------------------------------------------------- /Zlibrary.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2023-2024 Bipinkrish 3 | This file is part of the Zlibrary-API by Bipinkrish 4 | Zlibrary-API / Zlibrary.py 5 | 6 | For more information, see: 7 | https://github.com/bipinkrish/Zlibrary-API/ 8 | """ 9 | import os 10 | 11 | import requests 12 | 13 | 14 | class Zlibrary: 15 | def __init__( 16 | self, 17 | email: str = None, 18 | password: str = None, 19 | remix_userid: [int, str] = None, 20 | remix_userkey: str = None, 21 | ): 22 | self.__email: str 23 | self.__name: str 24 | self.__kindle_email: str 25 | self.__remix_userid: [int, str] 26 | self.__remix_userkey: str 27 | self.__domain = "z-library.sk" #z-library.sk 1lib.sk 28 | 29 | self.__loggedin = False 30 | self.__headers = { 31 | "Content-Type": "application/x-www-form-urlencoded", 32 | "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", 33 | "accept-language": "en-US,en;q=0.9", 34 | "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36", 35 | } 36 | self.__cookies = { 37 | "siteLanguageV2": "en", 38 | } 39 | self.__proxies = {"http": proxy, "https": proxy} if (proxy := os.environ.get("https_proxy")) else None 40 | 41 | if email is not None and password is not None: 42 | self.login(email, password) 43 | elif remix_userid is not None and remix_userkey is not None: 44 | self.loginWithToken(remix_userid, remix_userkey) 45 | 46 | def __setValues(self, response) -> dict[str, str]: 47 | if not response["success"]: 48 | return response 49 | self.__email = response["user"]["email"] 50 | self.__name = response["user"]["name"] 51 | self.__kindle_email = response["user"]["kindle_email"] 52 | self.__remix_userid = str(response["user"]["id"]) 53 | self.__remix_userkey = response["user"]["remix_userkey"] 54 | self.__cookies["remix_userid"] = self.__remix_userid 55 | self.__cookies["remix_userkey"] = self.__remix_userkey 56 | self.__loggedin = True 57 | return response 58 | 59 | def __login(self, email, password) -> dict[str, str]: 60 | return self.__setValues( 61 | self.__makePostRequest( 62 | "/eapi/user/login", 63 | data={ 64 | "email": email, 65 | "password": password, 66 | }, 67 | override=True, 68 | ) 69 | ) 70 | 71 | def __checkIDandKey(self, remix_userid, remix_userkey) -> dict[str, str]: 72 | return self.__setValues( 73 | self.__makeGetRequest( 74 | "/eapi/user/profile", 75 | cookies={ 76 | "siteLanguageV2": "en", 77 | "remix_userid": str(remix_userid), 78 | "remix_userkey": remix_userkey, 79 | }, 80 | ) 81 | ) 82 | 83 | def login(self, email: str, password: str) -> dict[str, str]: 84 | return self.__login(email, password) 85 | 86 | def loginWithToken( 87 | self, remix_userid: [int, str], remix_userkey: str 88 | ) -> dict[str, str]: 89 | return self.__checkIDandKey(remix_userid, remix_userkey) 90 | 91 | def __makePostRequest( 92 | self, url: str, data: dict = {}, override=False 93 | ) -> dict[str, str]: 94 | if not self.isLoggedIn() and override is False: 95 | print("Not logged in") 96 | return 97 | 98 | payload = { 99 | "data": data, 100 | "cookies": self.__cookies, 101 | "headers": self.__headers, 102 | } 103 | if self.__proxies: 104 | payload["proxies"] = self.__proxies 105 | 106 | return requests.post( 107 | "https://" + self.__domain + url, 108 | **payload 109 | ).json() 110 | 111 | def __makeGetRequest( 112 | self, url: str, params: dict = {}, cookies=None 113 | ) -> dict[str, str]: 114 | if not self.isLoggedIn() and cookies is None: 115 | print("Not logged in") 116 | return 117 | 118 | payload = { 119 | "params": params, 120 | "cookies": self.__cookies if cookies is None else cookies, 121 | "headers": self.__headers, 122 | } 123 | if self.__proxies: 124 | payload["proxies"] = self.__proxies 125 | 126 | return requests.get( 127 | "https://" + self.__domain + url, 128 | **payload 129 | ).json() 130 | 131 | def getProfile(self) -> dict[str, str]: 132 | return self.__makeGetRequest("/eapi/user/profile") 133 | 134 | def getMostPopular(self, switch_language: str = None) -> dict[str, str]: 135 | if switch_language is not None: 136 | return self.__makeGetRequest( 137 | "/eapi/book/most-popular", {"switch-language": switch_language} 138 | ) 139 | return self.__makeGetRequest("/eapi/book/most-popular") 140 | 141 | def getRecently(self) -> dict[str, str]: 142 | return self.__makeGetRequest("/eapi/book/recently") 143 | 144 | def getUserRecommended(self) -> dict[str, str]: 145 | return self.__makeGetRequest("/eapi/user/book/recommended") 146 | 147 | def deleteUserBook(self, bookid: [int, str]) -> dict[str, str]: 148 | return self.__makeGetRequest(f"/eapi/user/book/{bookid}/delete") 149 | 150 | def unsaveUserBook(self, bookid: [int, str]) -> dict[str, str]: 151 | return self.__makeGetRequest(f"/eapi/user/book/{bookid}/unsave") 152 | 153 | def getBookForamt(self, bookid: [int, str], hashid: str) -> dict[str, str]: 154 | return self.__makeGetRequest(f"/eapi/book/{bookid}/{hashid}/formats") 155 | 156 | def getDonations(self) -> dict[str, str]: 157 | return self.__makeGetRequest("/eapi/user/donations") 158 | 159 | def getUserDownloaded( 160 | self, order: str = None, page: int = None, limit: int = None 161 | ) -> dict[str, str]: 162 | """ 163 | order takes one of the values\n 164 | ["year",...] 165 | """ 166 | params = { 167 | k: v 168 | for k, v in {"order": order, "page": page, "limit": limit}.items() 169 | if v is not None 170 | } 171 | return self.__makeGetRequest("/eapi/user/book/downloaded", params) 172 | 173 | def getExtensions(self) -> dict[str, str]: 174 | return self.__makeGetRequest("/eapi/info/extensions") 175 | 176 | def getDomains(self) -> dict[str, str]: 177 | return self.__makeGetRequest("/eapi/info/domains") 178 | 179 | def getLanguages(self) -> dict[str, str]: 180 | return self.__makeGetRequest("/eapi/info/languages") 181 | 182 | def getPlans(self, switch_language: str = None) -> dict[str, str]: 183 | if switch_language is not None: 184 | return self.__makeGetRequest( 185 | "/eapi/info/plans", {"switch-language": switch_language} 186 | ) 187 | return self.__makeGetRequest("/eapi/info/plans") 188 | 189 | def getUserSaved( 190 | self, order: str = None, page: int = None, limit: int = None 191 | ) -> dict[str, str]: 192 | """ 193 | order takes one of the values\n 194 | ["year",...] 195 | """ 196 | params = { 197 | k: v 198 | for k, v in {"order": order, "page": page, "limit": limit}.items() 199 | if v is not None 200 | } 201 | return self.__makeGetRequest("/eapi/user/book/saved", params) 202 | 203 | def getInfo(self, switch_language: str = None) -> dict[str, str]: 204 | if switch_language is not None: 205 | return self.__makeGetRequest( 206 | "/eapi/info", {"switch-language": switch_language} 207 | ) 208 | return self.__makeGetRequest("/eapi/info") 209 | 210 | def hideBanner(self) -> dict[str, str]: 211 | return self.__makeGetRequest("/eapi/user/hide-banner") 212 | 213 | def recoverPassword(self, email: str) -> dict[str, str]: 214 | return self.__makePostRequest( 215 | "/eapi/user/password-recovery", {"email": email}, override=True 216 | ) 217 | 218 | def makeRegistration(self, email: str, password: str, name: str) -> dict[str, str]: 219 | return self.__makePostRequest( 220 | "/eapi/user/registration", 221 | {"email": email, "password": password, "name": name}, 222 | override=True, 223 | ) 224 | 225 | def resendConfirmation(self) -> dict[str, str]: 226 | return self.__makePostRequest("/eapi/user/email/confirmation/resend") 227 | 228 | def saveBook(self, bookid: [int, str]) -> dict[str, str]: 229 | return self.__makeGetRequest(f"/eapi/user/book/{bookid}/save") 230 | 231 | def sendTo(self, bookid: [int, str], hashid: str, totype: str) -> dict[str, str]: 232 | return self.__makeGetRequest(f"/eapi/book/{bookid}/{hashid}/send-to-{totype}") 233 | 234 | def getBookInfo( 235 | self, bookid: [int, str], hashid: str, switch_language: str = None 236 | ) -> dict[str, str]: 237 | if switch_language is not None: 238 | return self.__makeGetRequest( 239 | f"/eapi/book/{bookid}/{hashid}", {"switch-language": switch_language} 240 | ) 241 | return self.__makeGetRequest(f"/eapi/book/{bookid}/{hashid}") 242 | 243 | def getSimilar(self, bookid: [int, str], hashid: str) -> dict[str, str]: 244 | return self.__makeGetRequest(f"/eapi/book/{bookid}/{hashid}/similar") 245 | 246 | def makeTokenSigin(self, name: str, id_token: str) -> dict[str, str]: 247 | return self.__makePostRequest( 248 | "/eapi/user/token-sign-in", 249 | {"name": name, "id_token": id_token}, 250 | override=True, 251 | ) 252 | 253 | def updateInfo( 254 | self, 255 | email: str = None, 256 | password: str = None, 257 | name: str = None, 258 | kindle_email: str = None, 259 | ) -> dict[str, str]: 260 | return self.__makePostRequest( 261 | "/eapi/user/update", 262 | { 263 | k: v 264 | for k, v in { 265 | "email": email, 266 | "password": password, 267 | "name": name, 268 | "kindle_email": kindle_email, 269 | }.items() 270 | if v is not None 271 | }, 272 | ) 273 | 274 | def search( 275 | self, 276 | message: str = None, 277 | yearFrom: int = None, 278 | yearTo: int = None, 279 | languages: str = None, 280 | extensions: [str] = None, 281 | order: str = None, 282 | page: int = None, 283 | limit: int = None, 284 | ) -> dict[str, str]: 285 | return self.__makePostRequest( 286 | "/eapi/book/search", 287 | { 288 | k: v 289 | for k, v in { 290 | "message": message, 291 | "yearFrom": yearFrom, 292 | "yearTo": yearTo, 293 | "languages": languages, 294 | "extensions[]": extensions, 295 | "order": order, 296 | "page": page, 297 | "limit": limit, 298 | }.items() 299 | if v is not None 300 | }, 301 | ) 302 | 303 | def __getImageData(self, url: str) -> requests.Response.content: 304 | res = requests.get(url, headers=self.__headers) 305 | if res.status_code == 200: 306 | return res.content 307 | 308 | def getImage(self, book: dict[str, str]) -> requests.Response.content: 309 | return self.__getImageData(book["cover"]) 310 | 311 | def __getBookFile(self, bookid: [int, str], hashid: str) -> [(str, bytes), None]: 312 | response = self.__makeGetRequest(f"/eapi/book/{bookid}/{hashid}/file") 313 | filename = response["file"]["description"] 314 | 315 | try: 316 | filename += " (" + response["file"]["author"] + ")" 317 | except: 318 | pass 319 | finally: 320 | filename += "." + response["file"]["extension"] 321 | 322 | ddl = response["file"]["downloadLink"] 323 | headers = self.__headers.copy() 324 | headers["authority"] = ddl.split("/")[2] 325 | 326 | res = requests.get(ddl, headers=headers, timeout=300) 327 | if res.status_code == 200: 328 | return filename, res.content 329 | 330 | def downloadBook(self, book: dict[str, str]) -> [(str, bytes), None]: 331 | return self.__getBookFile(book["id"], book["hash"]) 332 | 333 | def isLoggedIn(self) -> bool: 334 | return self.__loggedin 335 | 336 | def sendCode(self, email: str, password: str, name: str) -> dict[str, str]: 337 | usr_data = { 338 | "email": email, 339 | "password": password, 340 | "name": name, 341 | "rx": 215, 342 | "action": "registration", 343 | "site_mode": "books", 344 | "isSinglelogin": 1, 345 | } 346 | response = self.__makePostRequest( 347 | "/papi/user/verification/send-code", data=usr_data, override=True 348 | ) 349 | if response["success"]: 350 | response["msg"] = ( 351 | "Verification code is sent to mail, use verify_code to complete registration" 352 | ) 353 | return response 354 | 355 | def verifyCode( 356 | self, email: str, password: str, name: str, code: str 357 | ) -> dict[str, str]: 358 | usr_data = { 359 | "email": email, 360 | "password": password, 361 | "name": name, 362 | "verifyCode": code, 363 | "rx": 215, 364 | "action": "registration", 365 | "redirectUrl": "", 366 | "isModa": True, 367 | "gg_json_mode": 1, 368 | } 369 | return self.__makePostRequest("/rpc.php", data=usr_data, override=True) 370 | 371 | def getDownloadsLeft(self) -> int: 372 | user_profile: dict = self.getProfile()["user"] 373 | return user_profile.get("downloads_limit", 10) - user_profile.get( 374 | "downloads_today", 0 375 | ) -------------------------------------------------------------------------------- /_conf_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "enable_calibre": { 3 | "type": "bool", 4 | "description": "启用 Calibre-Web 电子书搜索", 5 | "default": false, 6 | "hint": "若启用需要部署 Calibre-Web 并配置服务的URL" 7 | }, 8 | "enable_liber3": { 9 | "type": "bool", 10 | "description": "启用 Liber3 电子书搜索", 11 | "default": true, 12 | "hint": "默认启用,网络不连通时可禁用" 13 | }, 14 | "enable_archive": { 15 | "type": "bool", 16 | "description": "启用 Archive.org 电子书搜索", 17 | "default": true, 18 | "hint": "默认启用,网络不连通时可禁用" 19 | }, 20 | "enable_zlib": { 21 | "type": "bool", 22 | "description": "启用 Z-Library 电子书搜索", 23 | "default": false, 24 | "hint": "若启用需要配置 Z-Library 的登录账户" 25 | }, 26 | "enable_annas": { 27 | "type": "bool", 28 | "description": "启用 Anna's Archive 电子书搜索", 29 | "default": false, 30 | "hint": "此搜索方式暂不支持直接下载,只能获得下载链接。" 31 | }, 32 | "calibre_web_url": { 33 | "type": "string", 34 | "description": "calibre-web 地址", 35 | "default": "http://127.0.0.1:8083", 36 | "hint": "例如在同主机安装的 calibre-web,地址为 http://127.0.0.1:8083" 37 | }, 38 | "zlib_email": { 39 | "type": "string", 40 | "description": "Z-Library 登录邮箱", 41 | "default": "", 42 | "hint": "登录 Z-Library 的邮箱" 43 | }, 44 | "zlib_password": { 45 | "type": "string", 46 | "description": "Z-Library 登录密码", 47 | "default": "", 48 | "hint": "登录 Z-Library 的密码" 49 | } 50 | } -------------------------------------------------------------------------------- /annas_py/__init__.py: -------------------------------------------------------------------------------- 1 | from .extractors.download import get_information 2 | from .extractors.recent import get_recent_downloads 3 | from .extractors.search import search 4 | -------------------------------------------------------------------------------- /annas_py/__pycache__/__init__.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zouyonghe/astrbot_plugin_ebooks/c6a851a3bbb04b26a44735541fddb83c1a7d8213/annas_py/__pycache__/__init__.cpython-310.pyc -------------------------------------------------------------------------------- /annas_py/__pycache__/utils.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zouyonghe/astrbot_plugin_ebooks/c6a851a3bbb04b26a44735541fddb83c1a7d8213/annas_py/__pycache__/utils.cpython-310.pyc -------------------------------------------------------------------------------- /annas_py/extractors/__init__.py: -------------------------------------------------------------------------------- 1 | BASE_URL = "https://annas-archive.org" 2 | -------------------------------------------------------------------------------- /annas_py/extractors/__pycache__/__init__.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zouyonghe/astrbot_plugin_ebooks/c6a851a3bbb04b26a44735541fddb83c1a7d8213/annas_py/extractors/__pycache__/__init__.cpython-310.pyc -------------------------------------------------------------------------------- /annas_py/extractors/__pycache__/download.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zouyonghe/astrbot_plugin_ebooks/c6a851a3bbb04b26a44735541fddb83c1a7d8213/annas_py/extractors/__pycache__/download.cpython-310.pyc -------------------------------------------------------------------------------- /annas_py/extractors/__pycache__/generic.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zouyonghe/astrbot_plugin_ebooks/c6a851a3bbb04b26a44735541fddb83c1a7d8213/annas_py/extractors/__pycache__/generic.cpython-310.pyc -------------------------------------------------------------------------------- /annas_py/extractors/__pycache__/recent.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zouyonghe/astrbot_plugin_ebooks/c6a851a3bbb04b26a44735541fddb83c1a7d8213/annas_py/extractors/__pycache__/recent.cpython-310.pyc -------------------------------------------------------------------------------- /annas_py/extractors/__pycache__/search.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zouyonghe/astrbot_plugin_ebooks/c6a851a3bbb04b26a44735541fddb83c1a7d8213/annas_py/extractors/__pycache__/search.cpython-310.pyc -------------------------------------------------------------------------------- /annas_py/extractors/download.py: -------------------------------------------------------------------------------- 1 | from html import unescape as html_unescape 2 | from urllib.parse import urljoin 3 | 4 | from bs4 import NavigableString 5 | 6 | from ..models.data import URL, Download 7 | from ..utils import html_parser 8 | from . import BASE_URL 9 | from .generic import extract_file_info, extract_publish_info 10 | 11 | 12 | def remove_search_icon(s: str) -> str: 13 | return s.replace("🔍", "").strip() 14 | 15 | 16 | def get_information(id: str) -> Download: 17 | soup = html_parser(urljoin(BASE_URL, f"md5/{id}")) 18 | 19 | def get_text(tag: str, cls: str): 20 | return soup.find(tag, class_=cls).text 21 | 22 | title = remove_search_icon(get_text("div", "text-3xl font-bold")) 23 | authors = remove_search_icon(get_text("div", "italic")) 24 | description = get_text("div", "js-md5-top-box-description") 25 | thumbnail = soup.find("img").get("src") or None 26 | 27 | publisher, publish_date = extract_publish_info(get_text("div", "text-md")) 28 | 29 | file_info = extract_file_info(get_text("div", "text-sm text-gray-500")) 30 | 31 | # 提取下载链接并去重 32 | raw_links = [ 33 | parse_link(container) 34 | for container in soup.find_all("a", class_="js-download-link") 35 | ] 36 | download_links = list({(link.title, link.url): link for link in raw_links if link}.values()) 37 | 38 | 39 | return Download( 40 | title=html_unescape(title), 41 | description=html_unescape(description[1:-1]), 42 | authors=html_unescape(authors), 43 | file_info=file_info, 44 | urls=download_links, 45 | thumbnail=thumbnail, 46 | publisher=html_unescape(publisher) if publisher else None, 47 | publish_date=publish_date, 48 | ) 49 | 50 | 51 | def parse_link(link: NavigableString) -> URL | None: 52 | url = link.get("href") 53 | if url == "/datasets": 54 | return None 55 | elif url[0] == "/": 56 | url = urljoin(BASE_URL, url[1:]) 57 | return URL(html_unescape(link.text), url) 58 | -------------------------------------------------------------------------------- /annas_py/extractors/generic.py: -------------------------------------------------------------------------------- 1 | from ..models.data import FileInfo 2 | 3 | MAX_SIZE_DESCRIPTION_LENGHT = len("123.4MB") 4 | 5 | 6 | def extract_file_info(raw: str) -> FileInfo: 7 | # Extract file info from raw string given from the website 8 | # it assumes that the string will always have the file format 9 | # and size, but can have language and file name too 10 | # > Cases: 11 | # Language, format, size and file name is provided; 12 | # Language, format and size is provided; 13 | # Format and size is provided. 14 | 15 | # sample data: 16 | # German [de], .azw3, 🚀/zlib, 1.0MB, 📗 Book (unknown) 17 | info_list = raw.split(", ") 18 | 19 | language_parts = [] 20 | while "[" in info_list[0] and "]" in info_list[0]: 21 | language_parts.append(info_list.pop(0)) 22 | language = ", ".join(language_parts) if language_parts else None 23 | 24 | extension = info_list.pop(0).lstrip(".") 25 | library = info_list.pop(0).split("/")[-1] 26 | size = info_list.pop(0) 27 | #_type = info_list.pop(0) 28 | return FileInfo(extension, size, language, library) 29 | 30 | 31 | def extract_publish_info(raw: str) -> tuple[str | None, str | None]: 32 | # Sample data: 33 | # John Wiley and Sons; Wiley (Blackwell Publishing); Blackwell Publishing Inc.; Wiley; JSTOR (ISSN 0020-6598), International Economic Review, #2, 45, pages 327-350, 2004 may 34 | # Cambridge University Press, 10.1017/CBO9780511510854, 2001 35 | # Cambridge University Press, 1, 2008 36 | # Cambridge University Press, 2014 feb 16 37 | # 1, 2008 38 | # 2008 39 | raw = raw.strip() 40 | if not raw: 41 | return (None, None) 42 | info = raw.split(", ") 43 | publisher = info[0] if not info[0].isdecimal() else None 44 | date = info[-1] if info[-1].split()[0].isdecimal() else None 45 | return (publisher, date) 46 | -------------------------------------------------------------------------------- /annas_py/extractors/recent.py: -------------------------------------------------------------------------------- 1 | from html import unescape as html_unescape 2 | from urllib.parse import urljoin 3 | 4 | from requests import get 5 | 6 | from ..models.data import RecentDownload 7 | from . import BASE_URL 8 | 9 | 10 | def get_recent_downloads() -> list[RecentDownload]: 11 | response = get(urljoin(BASE_URL, "dyn/recent_downloads")) 12 | data = response.json() 13 | return [ 14 | RecentDownload( 15 | title=html_unescape(item["title"]), id=item["path"].split("/md5/")[-1] 16 | ) 17 | for item in data 18 | ] 19 | -------------------------------------------------------------------------------- /annas_py/extractors/search.py: -------------------------------------------------------------------------------- 1 | from html import unescape as html_unescape 2 | from urllib.parse import urljoin 3 | 4 | from bs4 import NavigableString 5 | 6 | from ..models.args import FileType, Language, OrderBy 7 | from ..models.data import SearchResult 8 | from ..utils import html_parser 9 | from . import BASE_URL 10 | from .generic import extract_file_info, extract_publish_info 11 | 12 | 13 | def search( 14 | query: str, 15 | language: Language = Language.ANY, 16 | file_type: FileType = FileType.ANY, 17 | order_by: OrderBy = OrderBy.MOST_RELEVANT, 18 | ) -> list[SearchResult]: 19 | if not query.strip(): 20 | raise ValueError("query can not be empty") 21 | params = { 22 | "q": query, 23 | "lang": language.value, 24 | "ext": file_type.value, 25 | "sort": order_by.value, 26 | } 27 | soup = html_parser(urljoin(BASE_URL, "search"), params) 28 | raw_results = soup.find_all("a", class_="js-vim-focus") 29 | return list(filter(lambda i: i is not None, map(parse_result, raw_results))) 30 | 31 | 32 | def parse_result(soup: NavigableString) -> SearchResult | None: 33 | def get_text(selector: str = "") -> str: 34 | return soup.select_one(selector).text 35 | try: 36 | title = get_text("h3").strip() 37 | except AttributeError: 38 | return None 39 | authors = get_text("div:nth-child(2) > div:nth-child(4)") 40 | publisher, publish_date = extract_publish_info(get_text("div:nth-child(2) > div:nth-child(3)")) 41 | file_info = extract_file_info(get_text("div:nth-child(2) > div:nth-child(1)")) 42 | 43 | thumbnail = soup.find("img") 44 | thumbnail = thumbnail.get("src") if thumbnail else None # 如果找到,则获取 src 属性,否则为 None 45 | id = soup.get("href").split("md5/")[-1] 46 | 47 | return SearchResult( 48 | id=id, 49 | title=html_unescape(title), 50 | authors=html_unescape(authors), 51 | file_info=file_info, 52 | thumbnail=thumbnail, 53 | publisher=html_unescape(publisher) if publisher else None, 54 | publish_date=publish_date, 55 | ) 56 | -------------------------------------------------------------------------------- /annas_py/models/__pycache__/args.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zouyonghe/astrbot_plugin_ebooks/c6a851a3bbb04b26a44735541fddb83c1a7d8213/annas_py/models/__pycache__/args.cpython-310.pyc -------------------------------------------------------------------------------- /annas_py/models/__pycache__/data.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zouyonghe/astrbot_plugin_ebooks/c6a851a3bbb04b26a44735541fddb83c1a7d8213/annas_py/models/__pycache__/data.cpython-310.pyc -------------------------------------------------------------------------------- /annas_py/models/args.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class OrderBy(Enum): 5 | MOST_RELEVANT = "" 6 | NEWEST = "newest" 7 | OLDEST = "oldest" 8 | LARGEST = "largest" 9 | SMALLEST = "smallest" 10 | 11 | 12 | class FileType(Enum): 13 | ANY = "" 14 | PDF = ".pdf" 15 | EPUB = ".epub" 16 | MOBI = ".mobi" 17 | AZW3 = ".azw3" 18 | FB2 = ".fb2" 19 | LIT = ".lit" 20 | DJVU = ".djvu" 21 | RTF = ".rtf" 22 | ZIP = ".zip" 23 | RAR = ".rar" 24 | CBR = ".cbr" 25 | TXT = ".txt" 26 | CBZ = ".cbz" 27 | HTML = ".html" 28 | FB2_ZIP = ".fb2.zip" 29 | DOC = ".doc" 30 | HTM = ".htm" 31 | DOCX = ".docx" 32 | LRF = ".lrf" 33 | MHT = ".mht" 34 | 35 | 36 | class Language(Enum): 37 | ANY = "" 38 | EN = "en" 39 | AR = "ar" 40 | BE = "be" 41 | BG = "bg" 42 | BN = "bn" 43 | CA = "ca" 44 | CS = "cs" 45 | DE = "de" 46 | EL = "el" 47 | EO = "eo" 48 | ES = "es" 49 | FA = "fa" 50 | FR = "fr" 51 | HI = "hi" 52 | HU = "hu" 53 | ID = "id" 54 | IT = "it" 55 | JA = "ja" 56 | KO = "ko" 57 | LT = "lt" 58 | ML = "ml" 59 | NL = "nl" 60 | NO = "no" 61 | OR = "or" 62 | PL = "pl" 63 | PT = "pt" 64 | RO = "ro" 65 | RU = "ru" 66 | SK = "sk" 67 | SL = "sl" 68 | SQ = "sq" 69 | SR = "sr" 70 | SV = "sv" 71 | TR = "tr" 72 | TW = "tw" 73 | UK = "uk" 74 | UR = "ur" 75 | VI = "vi" 76 | ZH = "zh" 77 | -------------------------------------------------------------------------------- /annas_py/models/data.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass(slots=True) 5 | class URL: 6 | title: str 7 | url: str 8 | 9 | 10 | @dataclass(slots=True) 11 | class FileInfo: 12 | extension: str 13 | size: str 14 | language: str | None 15 | library: str 16 | 17 | 18 | @dataclass(slots=True) 19 | class RecentDownload: 20 | id: str 21 | title: str 22 | 23 | 24 | @dataclass(slots=True) 25 | class SearchResult: 26 | id: str 27 | title: str 28 | authors: str 29 | file_info: FileInfo 30 | thumbnail: str | None 31 | publisher: str | None 32 | publish_date: str | None 33 | 34 | 35 | @dataclass(slots=True) 36 | class Download: 37 | title: str 38 | description: str 39 | authors: str 40 | file_info: FileInfo 41 | urls: list[URL] 42 | thumbnail: str | None 43 | publisher: str | None 44 | publish_date: str | None 45 | -------------------------------------------------------------------------------- /annas_py/utils.py: -------------------------------------------------------------------------------- 1 | from bs4 import BeautifulSoup, NavigableString 2 | from requests import get 3 | 4 | 5 | class HTTPFailed(Exception): 6 | pass 7 | 8 | 9 | def html_parser(url: str, params: dict = {}) -> NavigableString: 10 | params = dict(filter(lambda i: i[1], params.items())) 11 | response = get(url, params=params) 12 | if response.status_code >= 400: 13 | raise HTTPFailed(f"server returned http status {response.status_code}") 14 | # Uncomment code that would be dynamically rendered by JavaScript 15 | html = response.text.replace("", "") 16 | soup = BeautifulSoup(html, "lxml") 17 | return soup 18 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import io 3 | import random 4 | import re 5 | import xml.etree.ElementTree as ET 6 | from datetime import datetime 7 | from typing import Optional 8 | from urllib.parse import quote_plus, urljoin, unquote, urlparse 9 | from PIL import Image as Img 10 | 11 | import aiofiles 12 | import aiohttp 13 | from aiohttp import ClientPayloadError 14 | from bs4 import BeautifulSoup 15 | 16 | from data.plugins.astrbot_plugin_ebooks.annas_py.models.args import Language 17 | from data.plugins.astrbot_plugin_ebooks.Zlibrary import Zlibrary 18 | from data.plugins.astrbot_plugin_ebooks.annas_py import search as annas_search 19 | from data.plugins.astrbot_plugin_ebooks.annas_py import get_information as get_annas_information 20 | from astrbot.api.all import * 21 | from astrbot.api.event.filter import * 22 | 23 | MAX_ZLIB_RETRY_COUNT = 3 24 | 25 | @register("ebooks", "buding", "一个功能强大的电子书搜索和下载插件", "1.0.9", "https://github.com/zouyonghe/astrbot_plugin_ebooks") 26 | class ebooks(Star): 27 | def __init__(self, context: Context, config: AstrBotConfig): 28 | super().__init__(context) 29 | self.config = config 30 | self.proxy = os.environ.get("https_proxy") 31 | self.TEMP_PATH = os.path.abspath("data/temp") 32 | os.makedirs(self.TEMP_PATH, exist_ok=True) 33 | 34 | # 初始化 Calibre 配置 35 | if self.config.get("enable_calibre", False) and not self.config.get("calibre_web_url", "").strip(): 36 | self.config["enable_calibre"] = False 37 | self.config.save_config() 38 | logger.info("[ebooks] 未设置 Calibre-Web URL,禁用该平台。") 39 | 40 | # 初始化 Z-Library 配置 41 | self.zlibrary = Zlibrary() 42 | if self.config.get("enable_zlib", False): 43 | email = self.config.get("zlib_email", "").strip() 44 | password = self.config.get("zlib_password", "").strip() 45 | 46 | if email and password: 47 | try: 48 | self.zlibrary = Zlibrary(email=email, password=password) 49 | if self.zlibrary.isLoggedIn(): 50 | logger.info("[ebooks] 已登录 Z-Library。") 51 | else: 52 | logger.error("登录 Z-Library 失败。") 53 | except Exception as e: 54 | logger.error(f"登录 Z-Library 失败,报错: {e}") 55 | else: 56 | self._disable_zlib("未设置 Z-Library 账户,禁用该平台。") 57 | 58 | def _disable_zlib(self, reason: str): 59 | """禁用 Z-Library 平台并保存配置""" 60 | self.zlibrary = Zlibrary() 61 | self.config["enable_zlib"] = False 62 | self.config.save_config() 63 | logger.info(f"[ebooks] {reason}") 64 | 65 | async def terminate(self): 66 | if self.zlibrary and self.zlibrary.isLoggedIn(): 67 | self.zlibrary = Zlibrary() 68 | 69 | 70 | async def _is_url_accessible(self, url: str, proxy: bool=True) -> bool: 71 | """ 72 | 异步检查给定的 URL 是否可访问。 73 | 74 | :param url: 要检查的 URL 75 | :param proxy: 是否使用代理 76 | :return: 如果 URL 可访问返回 True,否则返回 False 77 | """ 78 | try: 79 | async with aiohttp.ClientSession() as session: 80 | if proxy: 81 | async with session.head(url, timeout=5, proxy=self.proxy, allow_redirects=True) as response: 82 | return response.status == 200 83 | else: 84 | async with session.head(url, timeout=5, allow_redirects=True) as response: 85 | return response.status == 200 86 | except: 87 | return False # 如果请求失败(超时、连接中断等)则返回 False 88 | 89 | async def _download_and_convert_to_base64(self, cover_url): 90 | try: 91 | async with aiohttp.ClientSession() as session: 92 | async with session.get(cover_url, proxy=self.proxy) as response: 93 | if response.status != 200: 94 | return None 95 | 96 | content_type = response.headers.get('Content-Type', '').lower() 97 | # 如果 Content-Type 包含 html,则说明可能不是直接的图片 98 | if 'html' in content_type: 99 | html_content = await response.text() 100 | # 使用 BeautifulSoup 提取图片地址 101 | soup = BeautifulSoup(html_content, 'html.parser') 102 | img_tag = soup.find('meta', attrs={'property': 'og:image'}) 103 | if img_tag: 104 | cover_url = img_tag.get('content') 105 | # 再次尝试下载真正的图片地址 106 | return await self._download_and_convert_to_base64(cover_url) 107 | else: 108 | return None 109 | 110 | # 如果是图片内容,继续下载并转为 Base64 111 | content = await response.read() 112 | base64_data = base64.b64encode(content).decode("utf-8") 113 | return base64_data 114 | except ClientPayloadError as payload_error: 115 | # 尝试已接收的数据部分 116 | if 'content' in locals(): # 如果部分内容已下载 117 | base64_data = base64.b64encode(content).decode("utf-8") 118 | if self._is_base64_image(base64_data): # 检查 Base64 数据是否有效 119 | return base64_data 120 | except: 121 | return None 122 | 123 | def _is_base64_image(self, base64_data: str) -> bool: 124 | """ 125 | 检测 Base64 数据是否为有效图片 126 | :param base64_data: Base64 编码的字符串 127 | :return: 如果是图片返回 True,否则返回 False 128 | """ 129 | try: 130 | # 解码 Base64 数据 131 | image_data = base64.b64decode(base64_data) 132 | # 尝试用 Pillow 打开图片 133 | image = Img.open(io.BytesIO(image_data)) 134 | # 如果图片能正确被打开,再检查格式是否为支持的图片格式 135 | image.verify() # 验证图片 136 | return True # Base64 是有效图片 137 | except Exception: 138 | return False # 如果解析失败,说明不是图片 139 | 140 | def _truncate_filename(self, filename, max_length=100): 141 | # 保留文件扩展名 142 | base, ext = os.path.splitext(filename) 143 | if len(filename.encode('utf-8')) > max_length: 144 | # 根据最大长度截取文件名,确保文件扩展名完整 145 | truncated = base[:max_length - len(ext.encode('utf-8')) - 7] + " <省略>" 146 | return f"{truncated}{ext}" 147 | return filename 148 | 149 | async def _search_calibre_web(self, query: str, limit: int = None): 150 | '''Call the Calibre-Web Catalog API to search for eBooks.''' 151 | calibre_web_url = self.config.get("calibre_web_url", "http://127.0.0.1:8083") 152 | search_url = f"{calibre_web_url}/opds/search/{query}" # 根据实际路径构造 API URL 153 | 154 | async with aiohttp.ClientSession() as session: 155 | async with session.get(search_url) as response: 156 | if response.status == 200: 157 | content_type = response.headers.get("Content-Type", "") 158 | if "application/atom+xml" in content_type: 159 | data = await response.text() 160 | return self._parse_opds_response(data, limit) # 调用解析方法 161 | else: 162 | logger.error(f"[Calibre-Web] Unexpected content type: {content_type}") 163 | return None 164 | else: 165 | logger.error( 166 | f"[Calibre-Web] Error during search: Calibre-Web returned status code {response.status}") 167 | return None 168 | 169 | def _parse_opds_response(self, xml_data: str, limit: int = None): 170 | '''Parse the opds search result XML data.''' 171 | calibre_web_url = self.config.get("calibre_web_url", "http://127.0.0.1:8083") 172 | 173 | # Remove illegal characters 174 | xml_data = re.sub(r'[^\x09\x0A\x0D\x20-\uD7FF\uE000-\uFFFD]', '', xml_data) 175 | # 消除多余空格 176 | xml_data = re.sub(r'\s+', ' ', xml_data) 177 | 178 | try: 179 | root = ET.fromstring(xml_data) # 把 XML 转换为元素树 180 | namespace = {"default": "http://www.w3.org/2005/Atom"} # 定义命名空间 181 | entries = root.findall("default:entry", namespace) # 查找前20个 节点 182 | 183 | results = [] 184 | for entry in entries: 185 | # 提取电子书标题 186 | title_element = entry.find("default:title", namespace) 187 | title = title_element.text if title_element is not None else "未知" 188 | 189 | # 提取作者,多作者场景 190 | authors = [] 191 | author_elements = entry.findall("default:author/default:name", namespace) 192 | for author in author_elements: 193 | authors.append(author.text if author is not None else "未知") 194 | authors = ", ".join(authors) if authors else "未知" 195 | 196 | # 提取描述() 197 | summary_element = entry.find("default:summary", namespace) 198 | summary = summary_element.text if summary_element is not None else "无描述" 199 | 200 | # 提取出版日期() 201 | published_element = entry.find("default:published", namespace) 202 | #published_date = published_element.text if published_element is not None else "未知出版日期" 203 | if published_element is not None and published_element.text: 204 | try: 205 | # 解析日期字符串为 datetime 对象,并提取年份 206 | year = datetime.fromisoformat(published_element.text).year 207 | except ValueError: 208 | year = "未知" # 日期解析失败时处理 209 | else: 210 | year = "未知" 211 | 212 | # 提取语言(),需注意 namespace 213 | lang_element = entry.find("default:dcterms:language", namespace) 214 | language = lang_element.text if lang_element is not None else "未知" 215 | 216 | # 提取出版社信息() 217 | publisher_element = entry.find("default:publisher/default:name", namespace) 218 | publisher = publisher_element.text if publisher_element is not None else "未知" 219 | 220 | # 提取图书封面链接(rel="http://opds-spec.org/image") 221 | cover_element = entry.find("default:link[@rel='http://opds-spec.org/image']", namespace) 222 | cover_suffix = cover_element.attrib.get("href", "") if cover_element is not None else "" 223 | if cover_suffix and re.match(r"^/opds/cover/\d+$", cover_suffix): 224 | cover_link = urljoin(calibre_web_url, cover_suffix) 225 | else: 226 | cover_link = "" 227 | 228 | # 提取图书缩略图链接(rel="http://opds-spec.org/image/thumbnail") 229 | thumbnail_element = entry.find("default:link[@rel='http://opds-spec.org/image/thumbnail']", namespace) 230 | thumbnail_suffix = thumbnail_element.attrib.get("href", "") if thumbnail_element is not None else "" 231 | if thumbnail_suffix and re.match(r"^/opds/cover/\d+$", thumbnail_suffix): 232 | thumbnail_link = urljoin(calibre_web_url, thumbnail_suffix) 233 | else: 234 | thumbnail_link = "" 235 | 236 | # 提取下载链接及其格式(rel="http://opds-spec.org/acquisition") 237 | acquisition_element = entry.find("default:link[@rel='http://opds-spec.org/acquisition']", namespace) 238 | if acquisition_element is not None: 239 | download_suffix = acquisition_element.attrib.get("href", "") if acquisition_element is not None else "" 240 | if download_suffix and re.match(r"^/opds/download/\d+/[\w]+/$", download_suffix): 241 | download_link = urljoin(calibre_web_url, download_suffix) 242 | else: 243 | download_link = "" 244 | file_type = acquisition_element.attrib.get("type", "未知") 245 | file_size = acquisition_element.attrib.get("length", "未知") 246 | else: 247 | download_link = "" 248 | file_type = "未知" 249 | file_size = "未知" 250 | 251 | # 构建结果 252 | results.append({ 253 | "title": title, 254 | "authors": authors, 255 | "summary": summary, 256 | "year": year, 257 | "publisher": publisher, 258 | "language": language, 259 | "cover_link": cover_link, 260 | "thumbnail_link": thumbnail_link, 261 | "download_link": download_link, 262 | "file_type": file_type, 263 | "file_size": file_size 264 | }) 265 | 266 | return results[:limit] 267 | except ET.ParseError as e: 268 | logger.error(f"[Calibre-Web] Error parsing OPDS response: {e}") 269 | return None 270 | 271 | async def _build_book_chain(self, item: dict) -> list: 272 | """ 273 | 构建对应书籍条目的消息链。 274 | 275 | :param item: 包含书籍信息的字典 276 | :return: 生成的消息链列表 277 | """ 278 | chain = [Plain(f"{item['title']}")] 279 | if item.get("cover_link"): 280 | base64_image = await self._download_and_convert_to_base64(item["cover_link"]) 281 | if self._is_base64_image(base64_image): 282 | chain.append(Image.fromBase64(base64_image)) 283 | else: 284 | chain.append(Plain("\n")) 285 | chain.append(Plain(f"作者: {item.get('authors', '未知')}\n")) 286 | chain.append(Plain(f"年份: {item.get('year', '未知')}\n")) 287 | chain.append(Plain(f"出版社: {item.get('publisher', '未知')}\n")) 288 | description = item.get("summary", "") 289 | if isinstance(description, str) and description != "": 290 | description = description.strip() 291 | description = description[:150] + "..." if len(description) > 150 else description 292 | else: 293 | description = "无简介" 294 | chain.append(Plain(f"简介: {description}\n")) 295 | chain.append(Plain(f"链接(用于下载): {item.get('download_link', '未知')}")) 296 | return chain 297 | 298 | async def _convert_calibre_results_to_nodes(self, event: AstrMessageEvent, results: list): 299 | if not results: 300 | return "[Calibre-Web] 未找到匹配的电子书。" 301 | 302 | async def construct_node(book): 303 | """异步构造单个节点""" 304 | chain = await self._build_book_chain(book) 305 | return Node( 306 | uin=event.get_self_id(), 307 | name="Calibre-Web", 308 | content=chain 309 | ) 310 | 311 | tasks = [construct_node(book) for book in results] 312 | return await asyncio.gather(*tasks) 313 | 314 | async def _search_calibre_nodes(self, event: AstrMessageEvent, query: str, limit: str = "20"): 315 | if not self.config.get("enable_calibre", False): 316 | return "[Calibre-Web] 功能未启用。" 317 | 318 | if not query: 319 | return "[Calibre-Web] 请提供电子书关键词以进行搜索。" 320 | 321 | limit = int(limit) if limit.isdigit() else 20 322 | if not (1 <= limit <= 100): # Validate limit 323 | return "[Calibre-Web] 请确认搜索返回结果数量在 1-100 之间。" 324 | 325 | try: 326 | logger.info(f"[Calibre-Web] Received books search query: {query}, limit: {limit}") 327 | results = await self._search_calibre_web(quote_plus(query), limit) # 调用搜索方法 328 | if not results or len(results) == 0: 329 | return "[Calibre-Web] 未找到匹配的电子书。" 330 | else: 331 | return await self._convert_calibre_results_to_nodes(event, results) 332 | except Exception as e: 333 | logger.error(f"[Calibre-Web] 搜索失败: {e}") 334 | 335 | def _is_valid_calibre_book_url(self, book_url: str) -> bool: 336 | """检测电子书下载链接格式是否合法""" 337 | if not book_url: 338 | return False # URL 不能为空 339 | 340 | # 检测是否是合法的 URL (基础验证) 341 | pattern = re.compile(r'^https?://.+/.+$') 342 | if not pattern.match(book_url): 343 | return False 344 | 345 | # 检查是否满足特定的结构,例如包含 /opds/download/ 346 | if "/opds/download/" not in book_url: 347 | return False 348 | 349 | return True 350 | 351 | @command_group("calibre") 352 | def calibre(self): 353 | pass 354 | 355 | @calibre.command("search") 356 | async def search_calibre(self, event: AstrMessageEvent, query: str, limit: str="20"): 357 | """搜索 calibre-web 电子书""" 358 | result = await self._search_calibre_nodes(event, query, limit) 359 | if isinstance(result, str): 360 | yield event.plain_result(result) 361 | elif isinstance(result, list): 362 | if len(result) <= 30: 363 | ns = Nodes(result) 364 | yield event.chain_result([ns]) 365 | else: 366 | ns = Nodes([]) 367 | for i in range(0, len(result), 30): # 每30条数据分割成一个node 368 | chunk_results = result[i:i + 30] 369 | node = Node( 370 | uin=event.get_self_id(), 371 | name="Calibre-Web", 372 | content=chunk_results, 373 | ) 374 | ns.nodes.append(node) 375 | yield event.chain_result([ns]) 376 | else: 377 | raise ValueError("Unknown result type.") 378 | 379 | @calibre.command("download") 380 | async def download_calibre(self, event: AstrMessageEvent, book_url: str = None): 381 | """下载 calibre-web 电子书""" 382 | if not self.config.get("enable_calibre", False): 383 | yield event.plain_result("[Calibre-Web] 功能未启用。") 384 | return 385 | 386 | if not self._is_valid_calibre_book_url(book_url): 387 | yield event.plain_result("[Calibre-Web] 请提供有效的电子书链接。") 388 | return 389 | 390 | try: 391 | async with aiohttp.ClientSession() as session: 392 | async with session.get(book_url) as response: 393 | if response.status == 200: 394 | # 从 Content-Disposition 提取文件名 395 | content_disposition = response.headers.get("Content-Disposition") 396 | book_name = None 397 | 398 | if content_disposition: 399 | # 先检查是否有 filename*= 条目 400 | book_name_match = re.search(r'filename\*=(?:UTF-8\'\')?([^;]+)', content_disposition) 401 | if book_name_match: 402 | book_name = book_name_match.group(1) 403 | book_name = unquote(book_name) # 解码 URL 编码的文件名 404 | else: 405 | # 如果没有 filename*,则查找普通的 filename 406 | book_name_match = re.search(r'filename=["\']?([^;\']+)["\']?', content_disposition) 407 | if book_name: 408 | book_name = book_name_match.group(1) 409 | 410 | # 如果未获取到文件名,使用默认值 411 | if not book_name or book_name.strip() == "": 412 | logger.error(f"[Calibre-Web] 无法提取书名,电子书地址: {book_url}") 413 | yield event.plain_result("[Calibre-Web] 无法提取书名,取消发送电子书。") 414 | return 415 | 416 | # 发送文件到用户 417 | file = File(name=book_name, file=book_url) 418 | yield event.chain_result([file]) 419 | else: 420 | yield event.plain_result(f"[Calibre-Web] 无法下载电子书,状态码: {response.status}") 421 | except Exception as e: 422 | logger.error(f"[Calibre-Web] 下载失败: {e}") 423 | yield event.plain_result("[Calibre-Web] 下载电子书时发生错误,请稍后再试。") 424 | 425 | @calibre.command("recommend") 426 | async def recommend_calibre(self, event: AstrMessageEvent, n: int): 427 | '''随机推荐 n 本电子书''' 428 | if not self.config.get("enable_calibre", False): 429 | yield event.plain_result("[Calibre-Web] 功能未启用。") 430 | return 431 | 432 | try: 433 | # 调用 Calibre-Web 搜索接口,默认搜索所有电子书 434 | query = "*" # 空查询,可以调出完整书目 435 | results = await self._search_calibre_web(query) 436 | 437 | # 检查是否有电子书可供推荐 438 | if not results: 439 | yield event.plain_result("[Calibre-Web] 未找到可推荐的电子书。") 440 | return 441 | 442 | # 限制推荐数量,防止超出实际电子书数量 443 | if n > len(results): 444 | n = len(results) 445 | 446 | # 随机选择 n 本电子书 447 | recommended_books = random.sample(results, n) 448 | 449 | # 显示推荐电子书 450 | result = await self._convert_calibre_results_to_nodes(event, recommended_books) 451 | 452 | if isinstance(result, str): 453 | yield event.plain_result(result) 454 | elif isinstance(result, list): 455 | guidance = f"[Calibre-Web] 如下是随机推荐的 {n} 本电子书。" 456 | nodes = [Node(uin=event.get_self_id(), name="Calibre-Web", content=guidance)] 457 | nodes.extend(result) 458 | ns = Nodes([]) 459 | ns.nodes = nodes 460 | yield event.chain_result([ns]) 461 | else: 462 | yield event.plain_result("[Calibre-Web] 生成结果失败。") 463 | 464 | except Exception as e: 465 | logger.error(f"[Calibre-Web] 推荐电子书时发生错误: {e}") 466 | yield event.plain_result("[Calibre-Web] 推荐电子书时发生错误,请稍后再试。") 467 | 468 | # @llm_tool("search_calibre_books") 469 | async def search_calibre_books(self, event: AstrMessageEvent, query: str): 470 | """Search books by keywords or title through Calibre-Web. 471 | When to use: 472 | Use this method to search for books in the Calibre-Web catalog when user knows the title or keyword. 473 | This method cannot be used for downloading books and should only be used for searching purposes. 474 | 475 | Args: 476 | query (string): The search keyword or title to find books in the Calibre-Web catalog. 477 | """ 478 | async for result in self.search_calibre(event, query): 479 | yield result 480 | 481 | # @llm_tool("download_calibre_book") 482 | async def download_calibre_book(self, event: AstrMessageEvent, book_url: str): 483 | """Download a book by a precise name or URL through Calibre-Web. 484 | When to use: 485 | Use this method to download a specific book by its name or when a direct download link is available. 486 | 487 | Args: 488 | book_url (string): The book name (exact match) or the URL of the book link. 489 | 490 | """ 491 | async for result in self.download_calibre(event, book_url): 492 | yield result 493 | 494 | @llm_tool("recommend_books") 495 | async def recommend_calibre_books(self, event: AstrMessageEvent, n: str = "5"): 496 | """Randomly recommend n books. 497 | When to use: 498 | Use this method to get a random selection of books when users are unsure what to read. 499 | 500 | Args: 501 | n (string): Number of books to recommend (default is 5). 502 | """ 503 | async for result in self.recommend_calibre(event, int(n)): 504 | yield result 505 | 506 | async def _get_liber3_book_details(self, book_ids: list) -> Optional[dict]: 507 | """通过电子书 ID 获取详细信息""" 508 | detail_url = "https://lgate.glitternode.ru/v1/book" 509 | headers = {"Content-Type": "application/json"} 510 | payload = {"book_ids": book_ids} 511 | 512 | try: 513 | async with aiohttp.ClientSession() as session: 514 | async with session.post(detail_url, headers=headers, json=payload, proxy=self.proxy) as response: 515 | if response.status == 200: 516 | data = await response.json() 517 | return data.get("data", {}).get("book", {}) 518 | else: 519 | logger.error(f"[Liber3] Error during detail request: Status code {response.status}") 520 | return None 521 | except aiohttp.ClientError as e: 522 | logger.error(f"[Liber3] HTTP client error: {e}") 523 | except Exception as e: 524 | logger.error(f"[Liber3] 发生意外错误: {e}") 525 | 526 | return None 527 | 528 | async def _search_liber3_books_with_details(self, word: str, limit: int = 50) -> Optional[dict]: 529 | """搜索电子书并获取前 limit 本电子书的详细信息""" 530 | search_url = "https://lgate.glitternode.ru/v1/searchV2" 531 | headers = {"Content-Type": "application/json"} 532 | payload = { 533 | "address": "", 534 | "word": word 535 | } 536 | 537 | try: 538 | async with aiohttp.ClientSession() as session: 539 | async with session.post(search_url, headers=headers, json=payload, proxy=self.proxy) as response: 540 | if response.status == 200: 541 | data = await response.json() 542 | 543 | # 获取电子书 ID 列表 544 | book_data = data["data"].get("book", []) 545 | if not book_data: 546 | logger.info("[Liber3] 未找到匹配的电子书。") 547 | return None 548 | 549 | book_ids = [item.get("id") for item in book_data[:limit]] 550 | if not book_ids: 551 | logger.info("[Liber3] 未能提取电子书 ID。") 552 | return None 553 | 554 | # 调用详细信息 API 555 | detailed_books = await self._get_liber3_book_details(book_ids) 556 | if not detailed_books: 557 | logger.info("[Liber3] 未获取电子书详细信息。") 558 | return None 559 | 560 | # 返回包含搜索结果及详细信息的数据 561 | return { 562 | "search_results": book_data[:limit], 563 | "detailed_books": detailed_books 564 | } 565 | 566 | else: 567 | logger.error(f"[Liber3] 请求电子书搜索失败,状态码: {response.status}") 568 | return None 569 | except aiohttp.ClientError as e: 570 | logger.error(f"[Liber3] HTTP 客户端错误: {e}") 571 | except Exception as e: 572 | logger.error(f"[Liber3] 发生意外错误: {e}") 573 | 574 | return None 575 | 576 | async def _search_liber3_nodes(self, event: AstrMessageEvent, query: str, limit: str = "20"): 577 | # 检查功能是否启用 578 | if not self.config.get("enable_liber3", False): 579 | return "[Liber3] 功能未启用。" 580 | 581 | # 检查是否提供查询关键词 582 | if not query: 583 | return "[Liber3] 请提供电子书关键词以进行搜索。" 584 | 585 | # 校验 limit 参数 586 | limit = int(limit) if limit.isdigit() else 20 587 | if not (1 <= limit <= 100): # 确保返回的结果数量有效 588 | return "[Liber3] 请确认搜索返回结果数量在 1-100 之间。" 589 | 590 | try: 591 | # 打印日志 592 | logger.info(f"[Liber3] Received books search query: {query}, limit: {limit}") 593 | 594 | # 通过 Liber3 API 搜索获得结果 595 | results = await self._search_liber3_books_with_details(query, limit) 596 | if not results: 597 | return "[Liber3] 未找到匹配的电子书。" 598 | 599 | # 提取搜索结果和详细信息 600 | search_results = results.get("search_results", []) 601 | detailed_books = results.get("detailed_books", {}) 602 | 603 | async def construct_node(book): 604 | """异步构造单个节点""" 605 | book_id = book.get("id") 606 | detail = detailed_books.get(book_id, {}).get("book", {}) 607 | 608 | # 构建电子书信息内容 609 | chain = [ 610 | Plain(f"书名: {book.get('title', '未知')}\n"), 611 | Plain(f"作者: {book.get('author', '未知')}\n"), 612 | Plain(f"年份: {detail.get('year', '未知')}\n"), 613 | Plain(f"出版社: {detail.get('publisher', '未知')}\n"), 614 | Plain(f"语言: {detail.get('language', '未知')}\n"), 615 | Plain(f"文件大小: {detail.get('filesize', '未知')}\n"), 616 | Plain(f"文件类型: {detail.get('extension', '未知')}\n"), 617 | Plain(f"ID(用于下载): L{book_id}"), 618 | ] 619 | 620 | # 构造节点 621 | return Node( 622 | uin=event.get_self_id(), 623 | name="Liber3", 624 | content=chain 625 | ) 626 | tasks = [construct_node(book) for book in search_results] 627 | return await asyncio.gather(*tasks) 628 | except Exception as e: 629 | logger.error(f"[Liber3] 搜索失败: {e}") 630 | return "[Liber3] 发生错误,请稍后再试。" 631 | 632 | def _is_valid_liber3_book_id(self, book_id: str) -> bool: 633 | """检测 Liber3 的 book_id 是否有效""" 634 | if not book_id: 635 | return False # 不能为空 636 | 637 | # 使用正则表达式验证是否是以 L 开头后接 32 位十六进制字符串 638 | pattern = re.compile(r'^L[a-fA-F0-9]{32}$') 639 | return bool(pattern.match(book_id)) 640 | 641 | @command_group("liber3") 642 | def liber3(self): 643 | pass 644 | 645 | @liber3.command("search") 646 | async def search_liber3(self, event: AstrMessageEvent, query: str = None, limit: str="20"): 647 | """搜索 Liber3 电子书""" 648 | result = await self._search_liber3_nodes(event, query, limit) 649 | 650 | # 根据返回值类型处理结果 651 | if isinstance(result, str): 652 | yield event.plain_result(result) 653 | elif isinstance(result, list): 654 | if len(result) <= 30: 655 | ns = Nodes(result) 656 | yield event.chain_result([ns]) 657 | else: 658 | ns = Nodes([]) 659 | for i in range(0, len(result), 30): # 每30条数据分割成一个node 660 | chunk_results = result[i:i + 30] 661 | node = Node( 662 | uin=event.get_self_id(), 663 | name="Liber3", 664 | content=chunk_results, 665 | ) 666 | ns.nodes.append(node) 667 | yield event.chain_result([ns]) 668 | else: 669 | raise ValueError("Unknown result type.") 670 | 671 | @liber3.command("download") 672 | async def download_liber3(self, event: AstrMessageEvent, book_id: str = None): 673 | """下载 liber3 电子书""" 674 | 675 | if not self.config.get("enable_liber3", False): 676 | yield event.plain_result("[Liber3] 功能未启用。") 677 | return 678 | 679 | if not self._is_valid_liber3_book_id(book_id): 680 | yield event.plain_result("[Liber3] 请提供有效的电子书 ID。") 681 | return 682 | 683 | book_id = book_id.lstrip("L") 684 | 685 | # 获取详细的电子书信息 686 | book_details = await self._get_liber3_book_details([book_id]) 687 | if not book_details or book_id not in book_details: 688 | yield event.plain_result("[Liber3] 无法获取电子书元信息,请检查电子书 ID 是否正确。") 689 | return 690 | 691 | # 提取电子书信息 692 | book_info = book_details[book_id].get("book", {}) 693 | book_name = book_info.get("title", "unknown_book").replace(" ", "_") 694 | extension = book_info.get("extension", "unknown_extension") 695 | ipfs_cid = book_info.get("ipfs_cid", "") 696 | 697 | if not ipfs_cid or not extension: 698 | yield event.plain_result("[Liber3] 电子书信息不足,无法完成下载。") 699 | return 700 | 701 | # 构造下载链接 702 | ebook_url = f"https://gateway-ipfs.st/ipfs/{ipfs_cid}?filename={book_name}.{extension}" 703 | 704 | # 使用 File 对象,通过 chain_result 下载 705 | file = File(name=f"{book_name}.{extension}", file=ebook_url) 706 | yield event.chain_result([file]) 707 | 708 | # @llm_tool("search_liber3_books") 709 | async def search_liber3_books(self, event: AstrMessageEvent, query: str): 710 | """Search for books using Liber3 API and return a detailed result list. 711 | 712 | When to use: 713 | Invoke this tool to locate books based on keywords or titles from Liber3's library. 714 | 715 | Args: 716 | query (string): The keyword or title to search for books. 717 | """ 718 | async for result in self.search_liber3(event, query): 719 | yield result 720 | 721 | # @llm_tool("download_liber3_book") 722 | async def download_liber3_book(self, event: AstrMessageEvent, book_id: str): 723 | """Download a book using Liber3's API via its unique ID. 724 | 725 | When to use: 726 | This tool allows you to retrieve a Liber3 book using the unique ID and download it. 727 | 728 | Args: 729 | book_id (string): A valid Liber3 book ID required to download a book. 730 | """ 731 | async for result in self.download_liber3(event, book_id): 732 | yield result 733 | 734 | 735 | async def _search_archive_books(self, query: str, limit: int = 20): 736 | """Search for eBooks through the archive.org API and filter files in PDF or EPUB formats. 737 | Args: 738 | query (str): Search keyword for titles 739 | limit (int): Maximum number of results to return 740 | Returns: 741 | list: A list containing book information and download links that meet the criteria 742 | """ 743 | base_search_url = "https://archive.org/advancedsearch.php" 744 | base_metadata_url = "https://archive.org/metadata/" 745 | formats = ("pdf", "epub") # 支持的电子书格式 746 | 747 | params = { 748 | "q": f'title:"{query}" mediatype:texts', # 根据标题搜索 749 | "fl[]": "identifier,title", # 返回 identifier 和 title 字段 750 | "sort[]": "downloads desc", # 按下载量排序 751 | "rows": limit+10, # 最大结果数量 752 | "page": 1, 753 | "output": "json" # 返回格式为 JSON 754 | } 755 | 756 | async with aiohttp.ClientSession() as session: 757 | # 1. 调用 archive.org 搜索 API 758 | response = await session.get(base_search_url, params=params, proxy=self.proxy) 759 | if response.status != 200: 760 | logger.error( 761 | f"[archive.org] Error during search: archive.org API returned status code {response.status}") 762 | return [] 763 | 764 | result_data = await response.json() 765 | docs = result_data.get("response", {}).get("docs", []) 766 | if not docs: 767 | logger.info("[archive.org] 未找到匹配的电子书。") 768 | return [] 769 | 770 | # 2. 根据 identifier 提取元数据 771 | tasks = [ 772 | self._fetch_metadata(session, base_metadata_url + doc["identifier"], formats) for doc in docs 773 | ] 774 | metadata_results = await asyncio.gather(*tasks) 775 | 776 | # 3. 筛选有效结果并返回 777 | books = [ 778 | { 779 | "title": doc.get("title"), 780 | "cover": metadata.get("cover"), 781 | "authors": metadata.get("authors"), 782 | "language": metadata.get("language"), 783 | "year": metadata.get("year"), 784 | "publisher": metadata.get("publisher"), 785 | "download_url": metadata.get("download_url"), 786 | "description": metadata.get("description") 787 | } 788 | for doc, metadata in zip(docs, metadata_results) if metadata 789 | ][:limit] 790 | return books 791 | 792 | async def _fetch_metadata(self, session: aiohttp.ClientSession, url: str, formats: tuple) -> dict: 793 | """ 794 | Retrieve specific eBook formats from the Metadata API and extract covers and descriptions. 795 | Args: 796 | session (aiohttp.ClientSession): aiohttp session 797 | url (str): URL of the Metadata API 798 | formats (tuple): Required formats (e.g., PDF, EPUB) 799 | Returns: 800 | dict: A dictionary with download links, file type, cover, and description 801 | """ 802 | try: 803 | response = await session.get(url, proxy=self.proxy) 804 | if response.status != 200: 805 | logger.error(f"[archive.org] Error retrieving Metadata: Status code {response.status}") 806 | return {} 807 | 808 | book_detail = await response.json() 809 | 810 | identifier = book_detail.get("metadata", {}).get("identifier", None) 811 | if not identifier: 812 | return {} 813 | files = book_detail.get("files", []) 814 | description = book_detail.get("metadata", {}).get("description", "无简介") 815 | authors = book_detail.get("metadata", {}).get("creator", "未知") 816 | language = book_detail.get("metadata", {}).get("language", "未知") 817 | year = book_detail.get("metadata", {}).get("publicdate", "未知")[:4] if book_detail.get("metadata", {}).get( 818 | "publicdate", "未知") != "未知" else "未知" 819 | publisher = book_detail.get("metadata", {}).get("publisher", "未知") 820 | 821 | # 判断并解析简介 822 | if isinstance(description, str): 823 | if self._is_html(description): 824 | description = self._parse_html_to_text(description) 825 | else: 826 | description = description.strip() 827 | description = description[:150] + "..." if len(description) > 150 else description 828 | else: 829 | description = "无简介" 830 | 831 | # 提取特定格式文件(如 PDF 和 EPUB) 832 | for file in files: 833 | if any(file.get("name", "").lower().endswith(fmt) for fmt in formats): 834 | return { 835 | "cover": f"https://archive.org/services/img/{identifier}", 836 | "authors": authors, 837 | "year": year, 838 | "publisher": publisher, 839 | "language": language, 840 | "description": description, 841 | "download_url": f"https://archive.org/download/{identifier}/{file['name']}", 842 | } 843 | 844 | except Exception as e: 845 | logger.error(f"[archive.org] 获取 Metadata 数据时发生错误: {e}") 846 | return {} 847 | 848 | def _is_html(self, content): 849 | """Determine whether a string is in HTML format.""" 850 | if not isinstance(content, str): 851 | return False 852 | return bool(re.search(r'<[^>]+>', content)) 853 | 854 | def _parse_html_to_text(self, html_content): 855 | """Parse HTML content into plain text.""" 856 | soup = BeautifulSoup(html_content, "html.parser") 857 | return soup.get_text().strip() 858 | 859 | def _is_valid_archive_book_url(self, book_url: str) -> bool: 860 | """检测 archive.org 下载链接格式是否合法""" 861 | if not book_url: 862 | return False # URL 不能为空 863 | 864 | # 使用正则表达式验证链接格式是否合法 865 | pattern = re.compile( 866 | r'^https://archive\.org/download/[^/]+/[^/]+$' 867 | ) 868 | 869 | return bool(pattern.match(book_url)) 870 | 871 | async def _search_archive_nodes(self, event: AstrMessageEvent, query: str = None, limit: str = "20"): 872 | if not self.config.get("enable_archive", False): 873 | return "[archive.org] 功能未启用。" 874 | 875 | if not query: 876 | return "[archive.org] 请提供电子书关键词以进行搜索。" 877 | 878 | if not await self._is_url_accessible("https://archive.org"): 879 | return "[archive.org] 无法连接到 archive.org。" 880 | 881 | limit = int(limit) if limit.isdigit() else 20 882 | if limit < 1: 883 | return "[archive.org] 请确认搜索返回结果数量在 1-60 之间。" 884 | if limit > 60: 885 | limit = 60 886 | 887 | try: 888 | logger.info(f"[archive.org] Received books search query: {query}, limit: {limit}") 889 | results = await self._search_archive_books(query, limit) 890 | 891 | if not results: 892 | return "[archive.org] 未找到匹配的电子书。" 893 | 894 | async def construct_node(book): 895 | """异步构造单个节点""" 896 | chain = [Plain(f"{book.get('title', '未知')}")] 897 | 898 | # 异步下载和处理封面图片 899 | if book.get("cover"): 900 | base64_image = await self._download_and_convert_to_base64(book.get("cover")) 901 | if base64_image and self._is_base64_image(base64_image): 902 | chain.append(Image.fromBase64(base64_image)) 903 | else: 904 | chain.append(Plain("\n")) 905 | else: 906 | chain.append(Plain("\n")) 907 | 908 | # 添加其他信息 909 | chain.append(Plain(f"作者: {book.get('authors', '未知')}\n")) 910 | chain.append(Plain(f"年份: {book.get('year', '未知')}\n")) 911 | chain.append(Plain(f"出版社: {book.get('publisher', '未知')}\n")) 912 | chain.append(Plain(f"语言: {book.get('language', '未知')}\n")) 913 | chain.append(Plain(f"简介: {book.get('description', '无简介')}\n")) 914 | chain.append(Plain(f"链接(用于下载): {book.get('download_url', '未知')}")) 915 | 916 | # 构造 Node 917 | return Node( 918 | uin=event.get_self_id(), 919 | name="archive.org", 920 | content=chain 921 | ) 922 | tasks = [construct_node(book) for book in results] 923 | return await asyncio.gather(*tasks) # 并发执行所有任务 924 | 925 | # return nodes 926 | except Exception as e: 927 | logger.error(f"[archive.org] Error processing archive.org search request: {e}") 928 | return "[archive.org] 搜索电子书时发生错误,请稍后再试。" 929 | 930 | @command_group("archive") 931 | def archive(self): 932 | pass 933 | 934 | @archive.command("search") 935 | async def search_archive(self, event: AstrMessageEvent, query: str = None, limit: str = "20"): 936 | """搜索 archive.org 电子书""" 937 | result = await self._search_archive_nodes(event, query, limit) 938 | 939 | # 根据返回值类型处理结果 940 | if isinstance(result, str): 941 | yield event.plain_result(result) 942 | elif isinstance(result, list): 943 | if len(result) <= 30: 944 | ns = Nodes(result) 945 | yield event.chain_result([ns]) 946 | else: 947 | ns = Nodes([]) 948 | for i in range(0, len(result), 30): # 每30条数据分割成一个node 949 | chunk_results = result[i:i + 30] 950 | node = Node( 951 | uin=event.get_self_id(), 952 | name="archive.org", 953 | content=chunk_results, 954 | ) 955 | ns.nodes.append(node) 956 | yield event.chain_result([ns]) 957 | else: 958 | raise ValueError("Unknown result type.") 959 | 960 | @archive.command("download") 961 | async def download_archive(self, event: AstrMessageEvent, book_url: str = None): 962 | """下载 archive.org 电子书""" 963 | if not self.config.get("enable_archive", False): 964 | yield event.plain_result("[archive.org] 功能未启用。") 965 | return 966 | 967 | if not self._is_valid_archive_book_url(book_url): 968 | yield event.plain_result("[archive.org] 请提供有效的下载链接。") 969 | return 970 | 971 | if not await self._is_url_accessible("https://archive.org"): 972 | yield event.plain_result("[archive.org] 无法连接到 archive.org") 973 | return 974 | 975 | try: 976 | async with aiohttp.ClientSession() as session: 977 | # 发出 GET 请求并跟随跳转 978 | async with session.get(book_url, allow_redirects=True, proxy=self.proxy, timeout=300) as response: 979 | if response.status == 200: 980 | ebook_url = str(response.url) 981 | logger.debug(f"[archive.org] 跳转后的下载地址: {ebook_url}") 982 | 983 | # 从 Content-Disposition 提取文件名 984 | content_disposition = response.headers.get("Content-Disposition", "") 985 | book_name = None 986 | 987 | # 提取文件名 988 | if content_disposition: 989 | book_name_match = re.search(r'filename\*=(?:UTF-8\'\')?([^;]+)', content_disposition) 990 | if book_name_match: 991 | book_name = unquote(book_name_match.group(1)) 992 | else: 993 | book_name_match = re.search(r'filename=["\']?([^;\']+)["\']?', content_disposition) 994 | if book_name_match: 995 | book_name = book_name_match.group(1) 996 | 997 | # 如果未提取到文件名,尝试从 URL 提取 998 | if not book_name or book_name.strip() == "": 999 | parsed_url = urlparse(ebook_url) 1000 | book_name = os.path.basename(parsed_url.path) or "unknown_book" 1001 | 1002 | book_name = self._truncate_filename(book_name) 1003 | 1004 | # 构造临时文件路径 1005 | temp_file_path = os.path.join(self.TEMP_PATH, book_name) 1006 | 1007 | # 保存下载文件到本地 1008 | async with aiofiles.open(temp_file_path, "wb") as temp_file: 1009 | await temp_file.write(await response.read()) 1010 | 1011 | # 打印日志确认保存成功 1012 | logger.info(f"[archive.org] 文件已下载并保存到临时目录:{temp_file_path}") 1013 | 1014 | # 直接传递本地文件路径 1015 | file = File(name=book_name, file=temp_file_path) 1016 | yield event.chain_result([file]) 1017 | os.remove(temp_file_path) 1018 | 1019 | # file = File(name=book_name, file=ebook_url) 1020 | # yield event.chain_result([file]) 1021 | else: 1022 | yield event.plain_result(f"[archive.org] 无法下载电子书,状态码: {response.status}") 1023 | except Exception as e: 1024 | logger.error(f"[archive.org] 下载失败: {e}") 1025 | yield event.plain_result(f"[archive.org] 下载电子书时发生错误,请稍后再试。") 1026 | 1027 | # @llm_tool("search_archive_books") 1028 | async def search_archive_books(self, event: AstrMessageEvent, query: str): 1029 | """Search for eBooks using the archive.org API. 1030 | 1031 | When to use: 1032 | Utilize this method to search books available in supported formats (such as PDF or EPUB) on the archive.org API platform. 1033 | 1034 | Args: 1035 | query (string): The keywords or title to perform the search. 1036 | """ 1037 | async for result in self.search_archive(event, query): 1038 | yield result 1039 | 1040 | # @llm_tool("download_archive_book") 1041 | async def download_archive_book(self, event: AstrMessageEvent, download_url: str): 1042 | """Download an eBook from the archive.org API using its download URL. 1043 | 1044 | When to use: 1045 | Use this method to download a specific book from the archive.org platform using the book's provided download link. 1046 | 1047 | Args: 1048 | download_url (string): A valid and supported archive.org book download URL. 1049 | """ 1050 | async for result in self.download_archive(event, download_url): 1051 | yield result 1052 | 1053 | async def _search_zlib_nodes(self,event: AstrMessageEvent, query: str, limit: str = "20"): 1054 | if not self.config.get("enable_zlib", False): 1055 | return "[Z-Library] 功能未启用。" 1056 | 1057 | if not await self._is_url_accessible("https://z-library.sk"): 1058 | return "[Z-Library] 无法连接到 Z-Library。" 1059 | 1060 | if not query: 1061 | return "[Z-Library] 请提供电子书关键词以进行搜索。" 1062 | 1063 | limit = int(limit) if limit.isdigit() else 20 1064 | if limit < 1: 1065 | return "[Z-Library] 请确认搜索返回结果数量在 1-60 之间。" 1066 | if limit > 60: 1067 | limit = 60 1068 | 1069 | try: 1070 | logger.info(f"[Z-Library] Received books search query: {query}, limit: {limit}") 1071 | 1072 | if not self.zlibrary.isLoggedIn(): 1073 | email = self.config.get("zlib_email", "").strip() 1074 | password = self.config.get("zlib_password", "").strip() 1075 | retry_count = 0 1076 | while retry_count < MAX_ZLIB_RETRY_COUNT: 1077 | try: 1078 | self.zlibrary.login(email, password) # 尝试登录 1079 | if self.zlibrary.isLoggedIn(): # 检查是否登录成功 1080 | break 1081 | except: # 捕获登录过程中的异常 1082 | pass 1083 | retry_count += 1 # 增加重试计数 1084 | if retry_count >= MAX_ZLIB_RETRY_COUNT: # 超过最大重试次数 1085 | return "[Z-Library] 登录失败。" 1086 | 1087 | # 调用 Zlibrary 的 search 方法进行搜索 1088 | results = self.zlibrary.search(message=query, limit=limit) 1089 | 1090 | if not results or not results.get("books"): 1091 | return "[Z-Library] 未找到匹配的电子书。" 1092 | 1093 | # 处理搜索结果 1094 | books = results.get("books", []) 1095 | async def construct_node(book): 1096 | """异步构造单个节点""" 1097 | chain = [Plain(f"{book.get('title', '未知')}")] 1098 | 1099 | # 异步处理封面图片 1100 | if book.get("cover"): 1101 | base64_image = await self._download_and_convert_to_base64(book.get("cover")) 1102 | if base64_image and self._is_base64_image(base64_image): 1103 | chain.append(Image.fromBase64(base64_image)) 1104 | else: 1105 | chain.append(Plain("\n")) 1106 | else: 1107 | chain.append(Plain("\n")) 1108 | 1109 | # 添加书籍信息 1110 | chain.append(Plain(f"作者: {book.get('author', '未知')}\n")) 1111 | chain.append(Plain(f"年份: {book.get('year', '未知')}\n")) 1112 | 1113 | # 处理出版社信息 1114 | publisher = book.get("publisher", None) 1115 | if not publisher or publisher == "None": 1116 | publisher = "未知" 1117 | chain.append(Plain(f"出版社: {publisher}\n")) 1118 | 1119 | # 语言信息 1120 | chain.append(Plain(f"语言: {book.get('language', '未知')}\n")) 1121 | 1122 | # 处理简介 1123 | description = book.get("description", "无简介") 1124 | if isinstance(description, str) and description.strip() != "": 1125 | description = description.strip() 1126 | description = description[:150] + "..." if len(description) > 150 else description 1127 | else: 1128 | description = "无简介" 1129 | chain.append(Plain(f"简介: {description}\n")) 1130 | 1131 | # ID 和 Hash 信息 1132 | chain.append(Plain(f"ID(用于下载): {book.get('id')}\n")) 1133 | chain.append(Plain(f"Hash(用于下载): {book.get('hash')}")) 1134 | 1135 | # 构造节点 1136 | return Node( 1137 | uin=event.get_self_id(), 1138 | name="Z-Library", 1139 | content=chain, 1140 | ) 1141 | tasks = [construct_node(book) for book in books] 1142 | return await asyncio.gather(*tasks) 1143 | except Exception as e: 1144 | logger.error(f"[Z-Library] Error during book search: {e}") 1145 | return "[Z-Library] 搜索电子书时发生错误,请稍后再试。" 1146 | 1147 | def _is_valid_zlib_book_id(self, book_id: str) -> bool: 1148 | """检测 zlib ID 是否为纯数字""" 1149 | if not book_id: 1150 | return False 1151 | return book_id.isdigit() 1152 | 1153 | def _is_valid_zlib_book_hash(self, hash: str) -> bool: 1154 | """检测 zlib Hash 是否为 6 位十六进制""" 1155 | if not hash: 1156 | return False 1157 | pattern = re.compile(r'^[a-f0-9]{6}$', re.IGNORECASE) # 忽略大小写 1158 | return bool(pattern.match(hash)) 1159 | 1160 | @command_group("zlib") 1161 | def zlib(self): 1162 | pass 1163 | 1164 | @zlib.command("search") 1165 | async def search_zlib(self, event: AstrMessageEvent, query: str = None, limit: str = "20"): 1166 | """搜索 Zlibrary 电子书""" 1167 | result = await self._search_zlib_nodes(event, query, limit) 1168 | 1169 | # 根据返回值类型处理结果 1170 | if isinstance(result, str): 1171 | yield event.plain_result(result) 1172 | elif isinstance(result, list): 1173 | if len(result) <= 30: 1174 | ns = Nodes(result) 1175 | yield event.chain_result([ns]) 1176 | else: 1177 | ns = Nodes([]) 1178 | for i in range(0, len(result), 30): # 每30条数据分割成一个node 1179 | chunk_results = result[i:i + 30] 1180 | node = Node( 1181 | uin=event.get_self_id(), 1182 | name="Z-Library", 1183 | content=chunk_results, 1184 | ) 1185 | ns.nodes.append(node) 1186 | yield event.chain_result([ns]) 1187 | else: 1188 | raise ValueError("Unknown result type.") 1189 | 1190 | @zlib.command("download") 1191 | async def download_zlib(self, event: AstrMessageEvent, book_id: str = None, book_hash: str = None): 1192 | """下载 Z-Library 电子书""" 1193 | if not self.config.get("enable_zlib", False): 1194 | yield event.plain_result("[Z-Library] 功能未启用。") 1195 | return 1196 | 1197 | if not self._is_valid_zlib_book_id(book_id) or not self._is_valid_zlib_book_hash(book_hash): 1198 | yield event.plain_result("[Z-Library] 请使用 /zlib download 下载。") 1199 | return 1200 | 1201 | if not await self._is_url_accessible("https://z-library.sk"): 1202 | yield event.plain_result("[Z-Library] 无法连接到 Z-Library。") 1203 | return 1204 | 1205 | try: 1206 | if not self.zlibrary.isLoggedIn(): 1207 | email = self.config.get("zlib_email", "").strip() 1208 | password = self.config.get("zlib_password", "").strip() 1209 | retry_count = 0 1210 | while retry_count < MAX_ZLIB_RETRY_COUNT: 1211 | try: 1212 | self.zlibrary.login(email, password) # 尝试登录 1213 | if self.zlibrary.isLoggedIn(): # 检查是否登录成功 1214 | break 1215 | except: # 捕获登录过程中的异常 1216 | pass 1217 | retry_count += 1 # 增加重试计数 1218 | if retry_count >= MAX_ZLIB_RETRY_COUNT: # 超过最大重试次数 1219 | yield event.plain_result("[Z-Library] 登录失败。") 1220 | return 1221 | 1222 | # 获取电子书详情,确保 ID 合法 1223 | book_details = self.zlibrary.getBookInfo(book_id, hashid=book_hash) 1224 | if not book_details: 1225 | yield event.plain_result("[Z-Library] 无法获取电子书详情,请检查电子书 ID 是否正确。") 1226 | return 1227 | 1228 | # 下载电子书 1229 | downloaded_book = self.zlibrary.downloadBook({"id": book_id, "hash": book_hash}) 1230 | if downloaded_book: 1231 | book_name, book_content = downloaded_book 1232 | book_name = self._truncate_filename(book_name) 1233 | 1234 | # 构造临时文件路径 1235 | temp_file_path = os.path.join(self.TEMP_PATH, book_name) 1236 | 1237 | # 保存电子书文件 1238 | with open(temp_file_path, "wb") as file: 1239 | file.write(book_content) 1240 | 1241 | # 打印日志确认保存成功 1242 | logger.debug(f"[Z-Library] 文件已下载并保存到临时目录:{temp_file_path}") 1243 | 1244 | # 提醒用户下载完成 1245 | file = File(name=book_name, file=str(temp_file_path)) 1246 | yield event.chain_result([file]) 1247 | os.remove(temp_file_path) 1248 | else: 1249 | yield event.plain_result("[Z-Library] 下载电子书时发生错误,请稍后再试。") 1250 | 1251 | except Exception as e: 1252 | logger.error(f"[Z-Library] Error during book download: {e}") 1253 | yield event.plain_result("[Z-Library] 下载电子书时发生错误,请稍后再试。") 1254 | 1255 | # @llm_tool("search_zlib_books") 1256 | async def search_zlib_books(self, event: AstrMessageEvent, query: str): 1257 | """Search Zlibrary for books using given keywords. 1258 | 1259 | When to use: 1260 | Use this method to locate books by keywords or title in Z-Library's database. 1261 | 1262 | Args: 1263 | query (string): The search term to perform the lookup. 1264 | """ 1265 | async for result in self.search_zlib(event, query): 1266 | yield result 1267 | 1268 | # @llm_tool("download_zlib_book") 1269 | async def download_zlib_book(self, event: AstrMessageEvent, book_id: str, book_hash: str): 1270 | """Download a book from Z-Library using its book ID and hash. 1271 | 1272 | When to use: 1273 | Use this method for downloading books from Zlibrary with the provided ID and hash. 1274 | 1275 | Args: 1276 | book_id (string): The unique identifier for the book. 1277 | book_hash (string): Hash value required to authorize and retrieve the download. 1278 | """ 1279 | async for result in self.download_zlib(event, book_id, book_hash): 1280 | yield result 1281 | 1282 | async def _search_annas_nodes(self, event: AstrMessageEvent, query: str, limit: str = "20"): 1283 | if not self.config.get("enable_annas", False): 1284 | return "[Anna's Archive] 功能未启用。" 1285 | 1286 | if not await self._is_url_accessible("https://annas-archive.org"): 1287 | return "[Anna's Archive] 无法连接到 Anna's Archive。" 1288 | 1289 | if not query: 1290 | return "[Anna's Archive] 请提供电子书关键词以进行搜索。" 1291 | 1292 | limit = int(limit) if limit.isdigit() else 20 1293 | if limit < 1: 1294 | return "[Anna's Archive] 请确认搜索返回结果数量在 1-60 之间。" 1295 | if limit > 60: 1296 | limit = 60 1297 | 1298 | try: 1299 | logger.info(f"[Anna's Archive] Received books search query: {query}, limit: {limit}") 1300 | 1301 | # 调用 annas_search 查询 1302 | results = annas_search(query, Language.ZH) 1303 | if not results or len(results) == 0: 1304 | return "[Anna's Archive] 未找到匹配的电子书。" 1305 | 1306 | # 处理搜索结果 1307 | books = results[:limit] # 截取前 limit 条结果 1308 | 1309 | async def construct_node(book): 1310 | """异步构造单个节点""" 1311 | chain = [Plain(f"{book.title}\n")] 1312 | 1313 | # 异步处理封面图片 1314 | if book.thumbnail: 1315 | base64_image = await self._download_and_convert_to_base64(book.thumbnail) 1316 | if base64_image and self._is_base64_image(base64_image): 1317 | chain.append(Image.fromBase64(base64_image)) 1318 | else: 1319 | chain.append(Plain("\n")) 1320 | else: 1321 | chain.append(Plain("\n")) 1322 | 1323 | # 添加书籍信息 1324 | chain.append(Plain(f"作者: {book.authors or '未知'}\n")) 1325 | chain.append(Plain(f"出版社: {book.publisher or '未知'}\n")) 1326 | chain.append(Plain(f"年份: {book.publish_date or '未知'}\n")) 1327 | 1328 | # 语言信息 1329 | language = book.file_info.language if book.file_info else "未知" 1330 | chain.append(Plain(f"语言: {language}\n")) 1331 | 1332 | # 附加文件信息 1333 | extension = book.file_info.extension if book.file_info else "未知" 1334 | chain.append(Plain(f"格式: {extension}\n")) 1335 | 1336 | # ID 信息 1337 | chain.append(Plain(f"ID: A{book.id}")) 1338 | 1339 | # 构造最终节点 1340 | return Node( 1341 | uin=event.get_self_id(), 1342 | name="Anna's Archive", 1343 | content=chain, 1344 | ) 1345 | 1346 | # 遍历所有书籍,构造节点任务 1347 | tasks = [construct_node(book) for book in books] 1348 | return await asyncio.gather(*tasks) 1349 | 1350 | except Exception as e: 1351 | logger.error(f"[Anna's Archive] Error during book search: {e}") 1352 | return "[Anna's Archive] 搜索电子书时发生错误,请稍后再试。" 1353 | 1354 | def _is_valid_annas_book_id(self, book_id: str) -> bool: 1355 | """检测 Liber3 的 book_id 是否有效""" 1356 | if not book_id: 1357 | return False # 不能为空 1358 | 1359 | # 使用正则表达式验证是否是以 A 开头后接 32 位十六进制字符串 1360 | pattern = re.compile(r'^A[a-fA-F0-9]{32}$') 1361 | return bool(pattern.match(book_id)) 1362 | 1363 | @command_group("annas") 1364 | def annas(self): 1365 | pass 1366 | 1367 | @annas.command("search") 1368 | async def search_annas(self, event: AstrMessageEvent, query: str, limit: str = "20"): 1369 | """搜索 anna's archive 电子书""" 1370 | result = await self._search_annas_nodes(event, query, limit) 1371 | 1372 | # 根据返回值类型处理结果 1373 | if isinstance(result, str): 1374 | yield event.plain_result(result) 1375 | elif isinstance(result, list): 1376 | if len(result) <= 30: 1377 | ns = Nodes(result) 1378 | yield event.chain_result([ns]) 1379 | else: 1380 | ns = Nodes([]) 1381 | for i in range(0, len(result), 30): # 每30条数据分割成一个node 1382 | chunk_results = result[i:i + 30] 1383 | node = Node( 1384 | uin=event.get_self_id(), 1385 | name="anna's archive", 1386 | content=chunk_results, 1387 | ) 1388 | ns.nodes.append(node) 1389 | yield event.chain_result([ns]) 1390 | else: 1391 | raise ValueError("Unknown result type.") 1392 | 1393 | @annas.command("download") 1394 | async def download_annas(self, event: AstrMessageEvent, book_id: str = None): 1395 | """从 Anna's Archive 下载电子书""" 1396 | if not self.config.get("enable_annas", False): 1397 | yield event.plain_result("[Anna's Archive] 功能未启用。") 1398 | return 1399 | 1400 | if not book_id: 1401 | yield event.plain_result("[Anna's Archive] 请提供有效的书籍 ID。") 1402 | return 1403 | 1404 | try: 1405 | book_id = book_id.lstrip("A") 1406 | # 获取 Anna's Archive 的书籍信息 1407 | book_info = get_annas_information(book_id) 1408 | urls = book_info.urls 1409 | 1410 | if not urls: 1411 | yield event.plain_result("[Anna's Archive] 未找到任何下载链接!") 1412 | return 1413 | 1414 | chain = [Plain("Anna's Archive\n目前无法直接下载电子书,可以通过访问下列链接手动下载:")] 1415 | 1416 | # 快速链接(需要付费) 1417 | fast_links = [url for url in urls if "Fast Partner Server" in url.title] 1418 | if fast_links: 1419 | chain.append(Plain("\n快速链接(需要付费):\n")) 1420 | for index, url in enumerate(fast_links, 1): 1421 | chain.append(Plain(f"{index}. {url.url}\n")) 1422 | 1423 | # 慢速链接(需要等待) 1424 | slow_links = [url for url in urls if "Slow Partner Server" in url.title] 1425 | if slow_links: 1426 | chain.append(Plain("\n慢速链接(需要等待):\n")) 1427 | for index, url in enumerate(slow_links, 1): 1428 | chain.append(Plain(f"{index}. {url.url}\n")) 1429 | 1430 | # 第三方链接 1431 | other_links = [url for url in urls if 1432 | "Fast Partner Server" not in url.title and "Slow Partner Server" not in url.title] 1433 | if other_links: 1434 | chain.append(Plain("\n第三方链接:\n")) 1435 | for index, url in enumerate(other_links, 1): 1436 | chain.append(Plain(f"{index}. {url.url}\n")) 1437 | 1438 | yield event.chain_result([Node(uin=event.get_self_id(), name="Anna's Archive", content=chain)]) 1439 | 1440 | except Exception as e: 1441 | logger.error(f"[Anna's Archive] 下载失败:{e}") 1442 | yield event.plain_result(f"[Anna's Archive] 下载电子书时发生错误,请稍后再试:{e}") 1443 | 1444 | 1445 | @command_group("ebooks") 1446 | def ebooks(self): 1447 | pass 1448 | 1449 | @ebooks.command("help") 1450 | async def show_help(self, event: AstrMessageEvent): 1451 | '''显示 Calibre-Web 插件帮助信息''' 1452 | help_msg = [ 1453 | "📚 **ebooks 插件使用指南**", 1454 | "", 1455 | "支持通过多平台(Calibre-Web、Liber3、Z-Library、archive.org)搜索、下载电子书。", 1456 | "", 1457 | "---", 1458 | "🔧 **命令列表**:", 1459 | "", 1460 | "- **Calibre-Web**:", 1461 | " - `/calibre search <关键词> [数量]`:搜索 Calibre-Web 中的电子书。例如:`/calibre search Python 20`。", 1462 | " - `/calibre download <下载链接/书名>`:通过 Calibre-Web 下载电子书。例如:`/calibre download `。", 1463 | " - `/calibre recommend <数量>`:随机推荐指定数量的电子书。", 1464 | "", 1465 | "- **archive.org**:", 1466 | " - `/archive search <关键词> [数量]`:搜索 archive.org 电子书。例如:`/archive search Python 20`。", 1467 | " - `/archive download <下载链接>`:通过 archive.org 平台下载电子书。", 1468 | "", 1469 | "- **Z-Library**:", 1470 | " - `/zlib search <关键词> [数量]`:搜索 Z-Library 的电子书。例如:`/zlib search Python 20`。", 1471 | " - `/zlib download `:通过 Z-Library 平台下载电子书。", 1472 | "", 1473 | "- **Liber3**:", 1474 | " - `/liber3 search <关键词> [数量]`:搜索 Liber3 平台上的电子书。例如:`/liber3 search Python 20`。", 1475 | " - `/liber3 download `:通过 Liber3 平台下载电子书。", 1476 | "", 1477 | "- **Anna's Archive**:", 1478 | " - `/annas search <关键词> [数量]`:搜索 Anna's Archive 平台上的电子书。例如:`/annas search Python 20`。", 1479 | " - `/annas download `:获取 Anna's Archive 电子书下载链接。", 1480 | "", 1481 | "- **通用命令**:", 1482 | " - `/ebooks help`:显示当前插件的帮助信息。", 1483 | " - `/ebooks search <关键词> [数量]`:在所有支持的平台中同时搜索电子书。例如:`/ebooks search Python 20`。", 1484 | " - `/ebooks download [Hash]`:通用的电子书下载方式。" 1485 | "", 1486 | "---", 1487 | "📒 **注意事项**:", 1488 | "- `数量` 为可选参数,默认为20,用于限制搜索结果的返回数量,数量超过30会分多个转发发送。", 1489 | "- 下载指令要根据搜索结果,提供有效的 URL、ID 和 Hash 值。", 1490 | "- 推荐功能会从现有书目中随机选择书籍进行展示(目前仅支持Calibre-Web)。", 1491 | "- 目前无法直接从 Anna's Archive 下载电子书。", 1492 | "", 1493 | "---", 1494 | "🌐 **支持平台**:", 1495 | "- Calibre-Web", 1496 | "- Liber3", 1497 | "- Z-Library", 1498 | "- archive.org", 1499 | ] 1500 | yield event.plain_result("\n".join(help_msg)) 1501 | 1502 | @ebooks.command("search") 1503 | async def search_all_platforms(self, event: AstrMessageEvent, query: str = None, limit: str = "20"): 1504 | """ 1505 | 同时在所有支持的平台中搜索电子书,异步运行,每个平台返回自己的搜索结果格式。 1506 | """ 1507 | if not query: 1508 | yield event.plain_result("[ebooks] 请提供电子书关键词以进行搜索。") 1509 | return 1510 | 1511 | if not (1 <= int(limit) <= 100): # Validate limit 1512 | yield event.plain_result("[ebooks] 请确认搜索返回结果数量在 1-100 之间。") 1513 | return 1514 | 1515 | async def consume_async(coro_or_gen): 1516 | """兼容协程或异步生成器""" 1517 | if hasattr(coro_or_gen, "__aiter__"): # 如果是异步生成器 1518 | return [item async for item in coro_or_gen] 1519 | # 普通协程直接返回 1520 | return await coro_or_gen 1521 | 1522 | tasks = [] 1523 | if self.config.get("enable_calibre", False): 1524 | tasks.append(consume_async(self._search_calibre_nodes(event, query, limit))) 1525 | if self.config.get("enable_liber3", False): 1526 | tasks.append(consume_async(self._search_liber3_nodes(event, query, limit))) 1527 | if self.config.get("enable_archive", False): 1528 | tasks.append(consume_async(self._search_archive_nodes(event, query, limit))) 1529 | if self.config.get("enable_zlib", False): 1530 | tasks.append(consume_async(self._search_zlib_nodes(event, query, limit))) 1531 | if self.config.get("enable_annas", False): 1532 | tasks.append(consume_async(self._search_annas_nodes(event, query, limit))) 1533 | 1534 | try: 1535 | # 并发运行所有任务 1536 | search_results = await asyncio.gather(*tasks) 1537 | # 将任务结果逐一发送 1538 | ns = Nodes([]) 1539 | 1540 | for platform_results in search_results: # 遍历每个平台结果 1541 | if isinstance(platform_results, str): 1542 | node = Node( 1543 | uin=event.get_self_id(), 1544 | name="ebooks", 1545 | content=platform_results, 1546 | ) 1547 | ns.nodes.append(node) 1548 | continue 1549 | for i in range(0, len(platform_results), 30): # 每30条数据分割成一个node 1550 | # 创建新的 node 包含不超过 20 条结果 1551 | chunk_results = platform_results[i:i + 30] 1552 | node = Node( 1553 | uin=event.get_self_id(), 1554 | name="ebooks", 1555 | content=chunk_results, 1556 | ) 1557 | ns.nodes.append(node) 1558 | yield event.chain_result([ns]) 1559 | 1560 | except Exception as e: 1561 | logger.error(f"[ebooks] Error during multi-platform search: {e}") 1562 | yield event.plain_result(f"[ebooks] 搜索电子书时发生错误,请稍后再试。") 1563 | 1564 | @ebooks.command("download") 1565 | async def download_all_platforms(self, event: AstrMessageEvent, arg1: str = None, arg2: str = None): 1566 | """ 1567 | 自动解析并识别输入,调用对应的平台下载实现,完成电子书的下载和发送。 1568 | 1569 | :param arg1: 主参数,可能是链接、ID 或其他标识符 1570 | :param arg2: 可选参数,用于补充 Z-Library 下载中的 Hash 值 1571 | """ 1572 | if not arg1: 1573 | yield event.plain_result("[ebooks] 请提供有效的下载链接、ID 或参数!") 1574 | return 1575 | 1576 | try: 1577 | # Z-Library 下载 (基于 ID 和 Hash) 1578 | if arg1 and arg2: # 检查两个参数是否都存在 1579 | try: 1580 | logger.info("[ebooks] 检测到 Z-Library ID 和 Hash,开始下载...") 1581 | async for result in self.download_zlib(event, arg1, arg2): 1582 | yield result 1583 | except Exception as e: 1584 | yield event.plain_result(f"[ebooks] Z-Library 参数解析失败:{e}") 1585 | return 1586 | 1587 | # Calibre-Web 下载 (基于 OPDS 链接) 1588 | if arg1.startswith("http://") or arg1.startswith("https://"): 1589 | if "/opds/download/" in arg1: 1590 | logger.info("[ebooks] 检测到 Calibre-Web 链接,开始下载...") 1591 | async for result in self.download_calibre(event, arg1): 1592 | yield result 1593 | return 1594 | 1595 | # archive.org 下载 1596 | if "archive.org/download/" in arg1: 1597 | logger.info("[ebooks] 检测到 archive.org 链接,开始下载...") 1598 | async for result in self.download_archive(event, arg1): 1599 | yield result 1600 | return 1601 | 1602 | # Liber3 下载 1603 | if len(arg1) == 33 and re.match(r"^L[A-Fa-f0-9]{32}$", arg1): 1604 | logger.info("[ebooks] ⏳ 检测到 Liber3 ID,开始下载...") 1605 | async for result in self.download_liber3(event, arg1): 1606 | yield result 1607 | return 1608 | 1609 | # Annas Archive 下载 1610 | if len(arg1) == 33 and re.match(r"^A[A-Fa-f0-9]{32}$", arg1): 1611 | logger.info("[ebooks] ⏳ 检测到 Annas Archive ID,开始下载...") 1612 | async for result in self.download_annas(event, arg1): 1613 | yield result 1614 | return 1615 | 1616 | # 未知来源的输入 1617 | yield event.plain_result( 1618 | "[ebooks] 未识别的输入格式,请提供以下格式之一:\n" 1619 | "- Calibre-Web 下载链接\n" 1620 | "- archive.org 下载链接\n" 1621 | "- Liber3/Annas Archive 32位 ID\n" 1622 | "- Z-Library 的 ID 和 Hash" 1623 | ) 1624 | 1625 | except Exception: 1626 | # 捕获并处理运行时错误 1627 | yield event.plain_result(f"[ebooks] 下载电子书时发生错误,请稍后再试。") 1628 | 1629 | @llm_tool("search_ebooks") 1630 | async def search_ebooks(self, event: AstrMessageEvent, query: str): 1631 | """Search for eBooks across all supported platforms. 1632 | 1633 | When to use: 1634 | This method performs a unified search across multiple platforms supported by this plugin, 1635 | allowing users to find ebooks by title or keyword. 1636 | Unless a specific platform is explicitly mentioned, this function should be used as the default means for searching books. 1637 | 1638 | 1639 | Args: 1640 | query (string): The keyword or book title for searching. 1641 | """ 1642 | async for result in self.search_all_platforms(event, query, limit="20"): 1643 | yield result 1644 | 1645 | @llm_tool("download_ebook") 1646 | async def download_ebook(self, event: AstrMessageEvent, arg1: str, arg2: str = None): 1647 | """Download eBooks by dispatching to the appropriate platform's download method. 1648 | 1649 | When to use: 1650 | This method facilitates downloading of ebooks by automatically identifying the platform 1651 | from the provided identifier (ID, URL, or Hash), and then calling the corresponding platform's download function. 1652 | Unless the platform is specifically mentioned, this function serves as the default for downloading ebooks. 1653 | 1654 | Args: 1655 | arg1 (string): Primary identifier, such as a URL or book ID. 1656 | arg2 (string): Secondary input, such as a hash, required for Z-Library downloads. 1657 | """ 1658 | async for result in self.download_all_platforms(event, arg1, arg2): 1659 | yield result 1660 | -------------------------------------------------------------------------------- /metadata.yaml: -------------------------------------------------------------------------------- 1 | name: astrbot_plugin_ebooks 2 | desc: 一个功能强大的电子书搜索下载的插件 3 | help: 输入 /ebook help 查看关键词回复帮助。 4 | version: v1.0.9 5 | author: buding 6 | repo: https://github.com/zouyonghe/astrbot_plugin_opds 7 | --------------------------------------------------------------------------------