├── BilibiliComic.py ├── README.md ├── bilicomic_old.py ├── config.sample.toml ├── config.toml ├── requirements.txt └── search.py /BilibiliComic.py: -------------------------------------------------------------------------------- 1 | # encoding:UTF-8 2 | # python3.6 3 | 4 | import hashlib 5 | import json 6 | import os 7 | import queue 8 | import threading 9 | import time 10 | import zipfile 11 | from io import BytesIO 12 | from urllib.parse import urlencode, urlparse, parse_qs 13 | 14 | import qrcode 15 | import requests 16 | import toml 17 | from func_timeout import FunctionTimedOut, func_set_timeout 18 | from tenacity import * 19 | 20 | download_timeout = 60 21 | max_threads = 10 22 | epName_rule = "[@ord] @short_title @title" 23 | epName_filter = False 24 | bonusName_rule = "[@id] @title @detail" 25 | bonusName_filter = False 26 | 27 | 28 | def find_index(list, key): 29 | try: 30 | index = list.index(key) 31 | except ValueError: 32 | index = None 33 | return index 34 | 35 | 36 | class Bili: 37 | pc_headers = { 38 | "User-Agent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.97 Safari/537.36", 39 | "Accept": "application/json, text/plain, */*", 40 | "Accept-Language": "zh-CN,zh;q=0.9", 41 | "accept-encoding": "gzip, deflate", 42 | } 43 | app_headers = { 44 | "User-Agent": "Mozilla/5.0 BiliDroid/5.58.0 (bbcallen@gmail.com)", 45 | # "Accept-encoding": "gzip", 46 | # "Buvid": "XZ11bfe2654a9a42d885520a680b3574582eb3", 47 | # "Display-ID": "146771405-1521008435", 48 | # "Device-Guid": "2d0bbec5-df49-43c5-8a27-ceba3f74ffd7", 49 | # "Device-Id": "469a6aaf431b46f8b58a1d4a91d0d95b202004211125026456adffe85ddcb44818", 50 | # "Accept-Language": "zh-CN", 51 | # "Accept": "text/html,application/xhtml+xml,*/*;q=0.8", 52 | # "Connection": "keep-alive", 53 | } 54 | app_params = { 55 | "appkey": "4409e2ce8ffd12b8", 56 | } 57 | app_secret = "59b43e04ad6965f34319062b478f83dd" 58 | URL_TEST_PC_LOGIN = "https://api.bilibili.com/nav" 59 | URL_TEST_APP_LOGIN = "https://app.bilibili.com/x/v2/account/myinfo" 60 | URL_RENEW_KEY = "https://account.bilibili.com/api/login/renewToken" 61 | URL_KEY_TO_COOKIE = "https://passport.bilibili.com/api/login/sso" 62 | URL_COOKIE_TO_KEY = "https://passport.bilibili.com/login/app/third?appkey=27eb53fc9058f8c3&api=http://link.acg.tv/forum.php&sign=67ec798004373253d60114caaad89a8c" 63 | 64 | cookies = {} 65 | login_platform = set() 66 | 67 | def __init__(self, s, dict_user=None): 68 | # s requests.session() 69 | # dict_user dict 从配置文件中读取的用户登录信息 70 | self.s = s 71 | if "access_key" in dict_user: 72 | # api接口要求access_key在params中排第一 73 | if dict_user["access_key"] != "": 74 | params = {"access_key": dict_user["access_key"]} 75 | params.update(self.app_params) 76 | self.app_params = params.copy() 77 | if "cookies" in dict_user: 78 | cookiesStr = dict_user["cookies"] 79 | if cookiesStr != "": 80 | cookies = {} 81 | for line in cookiesStr.split(";"): 82 | if line == "": 83 | break 84 | key, value = line.strip().split("=", 1) 85 | cookies[key] = value 86 | self.cookies = cookies 87 | 88 | def _session(self, method, url, platform="pc", level=1, **kwargs): 89 | if platform == "app": 90 | # api接口要求在params中access_key排第一,sign排最末。 91 | # py3.6及之后dict中item顺序为插入顺序 92 | if "params" in kwargs: 93 | params = self.app_params.copy() 94 | params.update(kwargs["params"]) 95 | kwargs["params"] = params 96 | else: 97 | kwargs["params"] = self.app_params 98 | kwargs["params"]["ts"] = str(int(time.time())) 99 | kwargs["params"]["sign"] = self.calc_sign(kwargs["params"]) 100 | if not "headers" in kwargs: 101 | kwargs["headers"] = ( 102 | Bili.pc_headers if platform == "pc" else Bili.app_headers 103 | ) 104 | r = self.s.request(method, url, **kwargs) 105 | return r.json()["data"] if level == 2 else r.json() if level == 1 else r 106 | 107 | def calc_sign(self, params: dict): 108 | params_list = sorted(params.items()) 109 | params_str = urlencode(params_list) 110 | sign_hash = hashlib.md5() 111 | sign_hash.update(f"{params_str}{Bili.app_secret}".encode()) 112 | return sign_hash.hexdigest() 113 | 114 | def isLogin(self, platform="pc"): 115 | if platform == "pc": 116 | if self.cookies: 117 | r = self._session("get", self.URL_TEST_PC_LOGIN, cookies=self.cookies) 118 | if r["code"] == 0: 119 | self.s.cookies = requests.utils.cookiejar_from_dict( 120 | self.cookies, cookiejar=None, overwrite=True 121 | ) 122 | else: 123 | r = self._session("get", self.URL_TEST_PC_LOGIN) 124 | status = True if r["code"] == 0 else False 125 | if status: 126 | self.login_platform.add("pc") 127 | else: 128 | self.login_platform.discard("pc") 129 | else: 130 | r = self._session("get", self.URL_TEST_APP_LOGIN, platform="app") 131 | status = True if r["code"] == 0 else False 132 | if status: 133 | self.login_platform.add("app") 134 | else: 135 | self.login_platform.discard("app") 136 | return status 137 | 138 | def key2cookie(self): 139 | params = { 140 | "gourl": "https://account.bilibili.com/account/home", 141 | } 142 | r = self._session("get", self.URL_KEY_TO_COOKIE, level=0, params=params) 143 | return requests.utils.dict_from_cookiejar(self.s.cookies) 144 | 145 | def cookie2key(self): 146 | r = self._session("get", self.URL_COOKIE_TO_KEY, platform="pc") 147 | if r["status"]: 148 | confirm_uri = r["data"]["confirm_uri"] 149 | r = self._session( 150 | "get", confirm_uri, platform="pc", level=0, allow_redirects=False 151 | ) 152 | redirect_url = r.headers.get("Location") 153 | access_key = parse_qs(urlparse(redirect_url).query)["access_key"][0] 154 | return access_key 155 | else: 156 | raise Exception(f"由cookies获取access_key失败:{r}") 157 | 158 | def renewToken(self): 159 | r = self._session("get", self.URL_RENEW_KEY) 160 | if r["code"] == 0: 161 | str_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(r["expires"])) 162 | print(f"access_key的有效期已延长至{str_time}") 163 | return True 164 | else: 165 | print(f"access_key的有效期延长失败,{r['message']}") 166 | return False 167 | 168 | def login_qrcode(self, path=None): 169 | # path QR码图片的存储位置 170 | def get_qrcode(): 171 | r = self._session("get", "https://passport.bilibili.com/qrcode/getLoginUrl") 172 | if r["status"]: 173 | code_url = r["data"]["url"] 174 | img = qrcode.make(code_url) 175 | self.oauthKey = r["data"]["oauthKey"] 176 | return img 177 | else: 178 | raise Exception(f"请求登录二维码失败:{r}") 179 | 180 | def get_qrcodeInfo(): 181 | while True: 182 | r = self._session( 183 | "post", 184 | "https://passport.bilibili.com/qrcode/getLoginInfo", 185 | data={"oauthKey": self.oauthKey}, 186 | ) 187 | # print(r) 188 | if r["status"]: 189 | break 190 | elif r["data"] == -2: 191 | raise Exception("二维码已过期") 192 | elif r["data"] == -1: 193 | raise Exception("oauthKey错误") 194 | time.sleep(2) 195 | return r["status"] 196 | 197 | if path is None: 198 | path = os.getcwd() 199 | qr = get_qrcode() 200 | qr.save(os.path.join(path, "QR.jpg")) 201 | print("请打开图片QR.jpg,用app扫码") 202 | info = get_qrcodeInfo() 203 | if info: 204 | self.login_platform.add("pc") 205 | print("扫码登录成功") 206 | return True 207 | else: 208 | print("扫码登录失败") 209 | return False 210 | 211 | def login_qrcode_tv(self, path=None): 212 | if path is None: 213 | path = os.getcwd() 214 | r = self._session( 215 | "post", 216 | "http://passport.bilibili.com/x/passport-tv-login/qrcode/auth_code", 217 | platform="app", 218 | params={"local_id": "0"}, 219 | ) 220 | if r["code"] == 0: 221 | code_url = r["data"]["url"] 222 | img = qrcode.make(code_url) 223 | self.auth_code = r["data"]["auth_code"] 224 | img.save(os.path.join(path, "QR.jpg")) 225 | print("请打开图片QR.jpg,用app扫码") 226 | elif r["code"] == -3: 227 | raise Exception("API校验密匙错误") 228 | elif r["code"] == -400: 229 | raise Exception("请求错误") 230 | 231 | input("app扫码确认完毕后,按任意键继续……") 232 | while True: 233 | r = self._session( 234 | "post", 235 | "http://passport.bilibili.com/x/passport-tv-login/qrcode/poll", 236 | platform="app", 237 | data={"auth_code": self.auth_code, "local_id": "0"}, 238 | ) 239 | # print(r) 240 | if r["code"] == 0: 241 | break 242 | elif r["code"] == 86038: 243 | raise Exception("二维码已过期") 244 | elif r["code"] == -3: 245 | raise Exception("API校验密匙错误") 246 | elif r["code"] == -400: 247 | raise Exception("请求错误") 248 | time.sleep(2) 249 | info = r["data"] 250 | params = {"access_key": info["access_token"]} 251 | self.app_params.pop("access_key", None) 252 | params.update(self.app_params) 253 | self.app_params = params.copy() 254 | self.login_platform.add("app") 255 | print("扫码(tv)登录成功") 256 | return True 257 | 258 | 259 | class DownloadThread(threading.Thread): 260 | def __init__(self, queue, overwrite=True): 261 | threading.Thread.__init__(self) 262 | self.queue = queue 263 | self.overwrite = overwrite 264 | 265 | def run(self): 266 | while True: 267 | if self.queue.empty(): 268 | break 269 | url, path = self.queue.get_nowait() 270 | try: 271 | if not self.overwrite and os.path.exists(path): 272 | print(f"图片{os.path.basename(path)}已存在,跳过") 273 | else: 274 | self.download(url, path) 275 | except Exception as e: 276 | print(f"{url} download fail:{e}") 277 | self.queue.task_done() 278 | time.sleep(1) 279 | 280 | @func_set_timeout(download_timeout) 281 | @retry(stop=stop_after_attempt(3), wait=wait_fixed(5)) 282 | def download(self, url, path): 283 | r = requests.get(url, stream=True) 284 | r.raise_for_status() 285 | f = open(path, "wb") 286 | for chunk in r.iter_content(chunk_size=1024): 287 | if chunk: 288 | f.write(chunk) 289 | f.close() 290 | r.close() 291 | 292 | 293 | class BiliManga: 294 | comicId = None 295 | platform = "pc" 296 | pc_headers = { 297 | "User-Agent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.97 Safari/537.36", 298 | "Accept": "application/json, text/plain, */*", 299 | "Accept-Language": "zh-CN,zh;q=0.9", 300 | "accept-encoding": "gzip, deflate", 301 | } 302 | app_headers = { 303 | "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", 304 | "user-agent": "Mozilla/5.0 BiliComic/3.0.0", 305 | "Host": "manga.bilibili.com", 306 | "accept-encoding": "gzip", 307 | } 308 | okhttp_headers = {"User-Agent": "okhttp/3.10.0", "Host": "manga.hdslb.com"} 309 | app_params = { 310 | "access_key": "", 311 | "device": "android", 312 | "mobi_app": "android_comic", 313 | "platform": "android", 314 | "version": "3.0.0", 315 | "buuild": "30000001", 316 | "is_teenager": "0", 317 | "appkey": "cc8617fd6961e070", 318 | } 319 | pc_params = { 320 | "device": "pc", 321 | "platform": "web", 322 | } 323 | URL_DETAIL = "https://manga.bilibili.com/twirp/comic.v2.Comic/ComicDetail" 324 | URL_IMAGE_INDEX = "https://manga.bilibili.com/twirp/comic.v1.Comic/GetImageIndex" 325 | URL_IMAGE_TOKEN = "https://manga.bilibili.com/twirp/comic.v1.Comic/ImageToken" 326 | URL_BONUS = "https://manga.bilibili.com/twirp/comic.v1.Comic/GetComicAlbumPlus" 327 | 328 | def __init__(self, s, comicId, platform="pc", access_key=None): 329 | self.s = s 330 | self.comicId = int(comicId) 331 | self.platform = platform 332 | if access_key is not None: 333 | self.app_params["access_key"] = access_key 334 | 335 | def _session(self, method, url, level=2, **kwargs): 336 | if not "headers" in kwargs: 337 | kwargs["headers"] = ( 338 | self.pc_headers if self.platform == "pc" else self.app_headers 339 | ) 340 | if self.platform == "app": 341 | if "data" in kwargs: 342 | kwargs["data"].update(self.app_params) 343 | elif self.platform == "pc": 344 | if "params" not in kwargs: 345 | kwargs["params"] = self.pc_params 346 | r = self.s.request(method, url, **kwargs) 347 | return r.json()["data"] if level == 2 else r.json() if level == 1 else r.content 348 | 349 | def getComicDetail(self, comicId=None): 350 | if comicId is None: 351 | comicId = self.comicId 352 | try: 353 | detail = self._session("post", self.URL_DETAIL, data={"comic_id": comicId}) 354 | epData = {} 355 | for ep in detail["ep_list"]: 356 | epData[str(ep["ord"])] = ep 357 | detail["epData"] = epData 358 | self.detail = detail 359 | return detail 360 | except Exception as e: 361 | print(f"getComicDetail fail,id={comicId},{e}") 362 | raise e 363 | 364 | def getBonusData(self, comicId=None): 365 | if comicId is None: 366 | comicId = self.comicId 367 | try: 368 | detail = self._session("post", self.URL_BONUS, data={"comic_id": comicId}) 369 | epData = {} 370 | for ep in detail["list"]: 371 | bonus_item = ep["item"] 372 | bonus_item["is_locked"] = ep["isLock"] 373 | epData[str(bonus_item["id"])] = bonus_item 374 | self.BonusData = epData 375 | return epData 376 | except Exception as e: 377 | print(f"getBonus fail,id={comicId},{e}") 378 | raise e 379 | 380 | def printList(self, path, ep_list=None, filter=True, isBonus=False): 381 | if isBonus: 382 | if ep_list is None: 383 | ep_list = list(self.BonusData.values()) 384 | filename = "漫画详情(特典).txt" 385 | else: 386 | if ep_list is None: 387 | ep_list = self.detail["ep_list"] 388 | filename = "漫画详情.txt" 389 | file = os.path.join(path, filename) 390 | text = "" 391 | for ep in ep_list: 392 | if filter: 393 | if ( 394 | not isBonus 395 | and ep.get("is_locked", False) 396 | and not ep.get("is_in_free", True) 397 | ): 398 | continue 399 | elif isBonus and ep.get("is_locked", False): 400 | continue 401 | if not isBonus: 402 | text = ( 403 | text 404 | + f"ord:{ep['ord']:<6} 章节id:{ep['id']},章节名:{ep['short_title']} {ep['title']}\n" 405 | ) 406 | else: 407 | text = text + f"id:{ep['id']:<6} 特典标题:{ep['title']} 详情:{ep['detail']}\n" 408 | if text == "": 409 | text = "不存在可以下载的章节" 410 | with open(file, "w+", encoding="utf-8") as f: 411 | f.write(text) 412 | 413 | def getindex(self, content, ep_id, comicId=None): 414 | content = content[9:] 415 | if comicId is None: 416 | comicId = self.comicId 417 | key = [ 418 | ep_id & 0xFF, 419 | ep_id >> 8 & 0xFF, 420 | ep_id >> 16 & 0xFF, 421 | ep_id >> 24 & 0xFF, 422 | comicId & 0xFF, 423 | comicId >> 8 & 0xFF, 424 | comicId >> 16 & 0xFF, 425 | comicId >> 24 & 0xFF, 426 | ] 427 | for i in range(len(content)): 428 | content[i] ^= key[i % 8] 429 | file = BytesIO(content) 430 | zf = zipfile.ZipFile(file) 431 | data = json.loads(zf.read("index.dat")) 432 | zf.close() 433 | file.close() 434 | return data 435 | 436 | def getImages(self, ep_id): 437 | ep_id = int(ep_id) 438 | c = self.comicId 439 | data = self._session("post", self.URL_IMAGE_INDEX, data={"ep_id": ep_id}) 440 | pics = ["{}".format(image["path"]) for image in data["images"]] 441 | # url = data['host'] + data['path'].replace(r"\u003d", "=") 442 | # content = bytearray(self._session('get', url, level=0, 443 | # headers=self.okhttp_headers)) 444 | # data = self.getindex(content, ep_id) 445 | # return data["pics"] 446 | return pics 447 | 448 | def getImageToken(self, imageUrls): 449 | data = self._session( 450 | "post", self.URL_IMAGE_TOKEN, data={"urls": json.dumps(imageUrls)} 451 | ) 452 | pic_list = [] 453 | for i in data: 454 | pic_list.append(f"{i['url']}?token={i['token']}") 455 | return pic_list 456 | 457 | def downloadEp(self, ep_data, path, overwrite=True, isBonus=False): 458 | if isBonus: 459 | if ep_data.get("is_locked", False): 460 | return 461 | epName = self.custom_name(ep_data, bonusName_filter, bonusName_rule) 462 | epDir = os.path.join(path, epName) 463 | os.makedirs(epDir, exist_ok=True) 464 | imageUrls = ep_data["pic"] 465 | filetype = imageUrls[0].split(".")[-1].split("?")[0] 466 | else: 467 | if ep_data.get("is_locked", False) and not ep_data.get("is_in_free", True): 468 | return 469 | epName = self.custom_name(ep_data, epName_filter, epName_rule) 470 | ep_id = ep_data["id"] 471 | pic_list = [ 472 | "https://manga.hdslb.com{}".format(url) for url in self.getImages(ep_id) 473 | ] 474 | filetype = pic_list[0].split(".")[-1] 475 | imageUrls = self.getImageToken(pic_list) 476 | 477 | epDir = os.path.join(path, epName) 478 | os.makedirs(epDir, exist_ok=True) 479 | q = queue.Queue() 480 | for n, url in enumerate(imageUrls, 1): 481 | imgPath = os.path.join(epDir, f"{n}.{filetype}") 482 | q.put((url, imgPath)) 483 | num = min(len(imageUrls), max_threads) 484 | for i in range(num): 485 | t = DownloadThread(q, overwrite) 486 | t.setDaemon(True) 487 | t.start() 488 | q.join() 489 | 490 | def parser_ep_str(self, ep_str, isBonus=False): 491 | if isBonus: 492 | epData = self.BonusData 493 | sortKey = "id" 494 | else: 495 | epData = self.detail["epData"] 496 | sortKey = "ord" 497 | chapter_list = [] 498 | if ep_str.lower() == "all": 499 | appeared = set(epData.keys()) 500 | else: 501 | keys = list(epData.keys()) 502 | keys.sort(key=lambda x: float(x)) 503 | appeared = set() 504 | for block in ep_str.split(","): 505 | if "-" in block: 506 | start, end = block.split("-", 1) 507 | start = start if float(start) > float(keys[0]) else keys[0] 508 | end = end if float(end) < float(keys[-1]) else keys[-1] 509 | ep_range = lambda elem: float(elem) <= float(end) and float( 510 | elem 511 | ) >= float(start) 512 | for key in filter(ep_range, keys): 513 | if key not in appeared: 514 | appeared.add(key) 515 | else: 516 | key = block 517 | if key not in appeared and epData.get(key): 518 | appeared.add(key) 519 | 520 | for key in appeared: 521 | ep = epData[key] 522 | if ( 523 | not isBonus 524 | and ep.get("is_locked", False) 525 | and not ep.get("is_in_free", True) 526 | ): 527 | continue 528 | elif isBonus and ep.get("is_locked", False): 529 | continue 530 | chapter_list.append(epData[key]) 531 | chapter_list.sort(key=lambda x: float(x[sortKey])) 532 | return chapter_list 533 | 534 | def custom_name(self, ep_data, filter=False, name=epName_rule): 535 | trans_dict = { 536 | "@ord": str(ep_data.get("ord", "")), 537 | "@id": str(ep_data.get("id", "")), 538 | "@short_title": ep_data.get("short_title", ""), 539 | "@title": ep_data.get("title", ""), 540 | "@detail": ep_data.get("detail", ""), 541 | } 542 | # 重复的变量会被忽略,避免名称中重复出现几个词 543 | if filter: 544 | appeared = set() 545 | for k, v in trans_dict.items(): 546 | if v in appeared: 547 | trans_dict[k] = "" 548 | else: 549 | appeared.add(v) 550 | for k, v in trans_dict.items(): 551 | name = name.replace(k, v) 552 | return safe_filename(name) 553 | 554 | 555 | def safe_filename(filename, replace=" "): 556 | """文件名过滤非法字符串""" 557 | filename = filename.rstrip("\t") 558 | ILLEGAL_STR = r'\/:*?"<>|' 559 | replace_illegal_str = str.maketrans(ILLEGAL_STR, replace * len(ILLEGAL_STR)) 560 | new_filename = filename.translate(replace_illegal_str).strip() 561 | if new_filename: 562 | return new_filename 563 | raise Exception("文件名不合法. new_filename={}".format(new_filename)) 564 | 565 | 566 | def load_config(conf="config.toml"): 567 | with open(conf, encoding="utf-8") as f: 568 | dict_conf = toml.load(f) 569 | is_ok = True 570 | if "user" not in dict_conf: 571 | is_ok = False 572 | elif "access_key" not in dict_conf["user"]: 573 | is_ok = False 574 | elif "cookies" not in dict_conf["user"]: 575 | is_ok = False 576 | 577 | if "comic" not in dict_conf: 578 | is_ok = False 579 | elif "comicId" not in dict_conf["comic"]: 580 | is_ok = False 581 | elif "ep_str" not in dict_conf["comic"]: 582 | is_ok = False 583 | if not is_ok: 584 | print("配置文件缺少内容") 585 | exit() 586 | if "setting" in dict_conf: 587 | global max_threads, epName_rule, epName_filter, bonusName_rule, bonusName_filter 588 | setting = dict_conf["setting"] 589 | max_threads = setting.get("max_threads", max_threads) 590 | epName_rule = setting.get("epName_rule", epName_rule) 591 | epName_filter = ( 592 | True if setting.get("epName_filter", epName_filter) == "True" else False 593 | ) 594 | bonusName_rule = setting.get("bonusName_rule", bonusName_rule) 595 | bonusName_filter = ( 596 | True 597 | if setting.get("bonusName_filter", bonusName_filter) == "True" 598 | else False 599 | ) 600 | return dict_conf 601 | 602 | 603 | def cookies2conf(cookies: dict, conf="config.toml"): 604 | cookiesStr = "" 605 | for k, v in cookies.items(): 606 | cookiesStr = cookiesStr + f"{k}={v};" 607 | with open(conf, "r", encoding="utf-8") as f: 608 | dict_conf = toml.load(f) 609 | dict_conf["user"]["cookies"] = cookiesStr 610 | with open(conf, "w", encoding="utf-8") as f: 611 | toml.dump(dict_conf, f) 612 | 613 | 614 | def ak2conf(access_key: str, conf="config.toml"): 615 | with open(conf, "r", encoding="utf-8") as f: 616 | dict_conf = toml.load(f) 617 | dict_conf["user"]["access_key"] = access_key 618 | with open(conf, "w", encoding="utf-8") as f: 619 | toml.dump(dict_conf, f) 620 | 621 | 622 | def main(): 623 | workDir = os.getcwd() 624 | global config 625 | config = os.path.join(workDir, "config.toml") 626 | if os.path.exists(config): 627 | dict_conf = load_config(config) 628 | dict_user = dict_conf["user"] 629 | dict_comic = dict_conf["comic"] 630 | else: 631 | print("未找到配置文件") 632 | exit() 633 | 634 | if dict_comic["comicId"] == "": 635 | comicId = int(input("输入mc号(纯数字):")) 636 | else: 637 | comicId = int(dict_comic["comicId"]) 638 | 639 | s = requests.session() 640 | bili = Bili(s, dict_user) 641 | 642 | if dict_user["access_key"] != "" and bili.isLogin("app"): 643 | print("成功使用app端登录") 644 | manga = BiliManga(s, comicId, "app", dict_user["access_key"]) 645 | elif dict_user["cookies"] != "" and bili.isLogin("pc"): 646 | print("成功使用pc端登录") 647 | # manga = BiliManga(s, comicId) 648 | access_key = bili.cookie2key() 649 | dict_user["access_key"] = access_key 650 | ak2conf(access_key, config) 651 | manga = BiliManga(s, comicId, platform="app", access_key=access_key) 652 | 653 | else: 654 | choise = input("目前未登录,输入0继续下载,输入1进行扫码登录(网页),输入2进行扫码登录(app):") 655 | 656 | if choise == "1": 657 | if bili.login_qrcode(workDir): 658 | cookies = requests.utils.dict_from_cookiejar(s.cookies) 659 | cookies2conf(cookies, config) 660 | # manga = BiliManga(s, comicId) 661 | access_key = bili.cookie2key() 662 | dict_user["access_key"] = access_key 663 | ak2conf(access_key, config) 664 | manga = BiliManga(s, comicId, platform="app", access_key=access_key) 665 | else: 666 | choise = "0" if input("扫码登录(网页)失败,按回车退出,按其他键以未登录身份下载:") else "-1" 667 | elif choise == "2": 668 | if bili.login_qrcode_tv(workDir): 669 | access_key = bili.app_params["access_key"] 670 | ak2conf(access_key, config) 671 | manga = BiliManga(s, comicId, platform="app", access_key=access_key) 672 | else: 673 | choise = "0" if input("扫码登录(app)失败,按回车退出,按其他键以未登录身份下载:") else "-1" 674 | elif choise == "0": 675 | manga = BiliManga(s, comicId) 676 | else: 677 | exit() 678 | 679 | manga.getComicDetail() 680 | comicName = safe_filename(manga.detail["title"]) 681 | mangaDir = os.path.join(workDir, comicName) 682 | os.makedirs(mangaDir, exist_ok=True) 683 | print(f"已获取漫画《{comicName}》详情,并建立文件夹。") 684 | while True: 685 | if manga.platform == "app" and manga.detail.get("album_count", 0) > 0: 686 | choice = input("下载漫画章节输入y,下载特典输入n:(y/n)") 687 | download_mode = "normal" if choice.lower() == "y" else "bonus" 688 | else: 689 | download_mode = "normal" 690 | 691 | if download_mode == "normal": 692 | manga.printList(mangaDir) 693 | if dict_comic["ep_str"] != "": 694 | ep_str = dict_comic["ep_str"] 695 | else: 696 | print( 697 | "#" * 10 698 | + "\n如何输入下载范围:\n输入1-4表示下载ord(序号)1至4的章节\n输入3,5表示下载ord(序号)3、5的章节\n同理,可混合输入1-5,9,55-60" 699 | + "\n输入“all”可以下载所有章节" 700 | ) 701 | print(f"漫画章节详情见“{comicName}/漫画详情.txt”文件(只列出了目前可下载的章节)") 702 | print("ps:请使用英文输入法,按回车键结束输入\n" + "#" * 10) 703 | ep_str = input("请输入下载范围:") 704 | download_list = manga.parser_ep_str(ep_str) 705 | print("已获取章节列表") 706 | 707 | for ep in download_list: 708 | manga.downloadEp(ep, mangaDir) 709 | print(f"已下载章节“{ep['title']}”,章节id:{ep['id']},ord:{ep['ord']}") 710 | elif download_mode == "bonus": 711 | manga.getBonusData() 712 | manga.printList(mangaDir, isBonus=True) 713 | print( 714 | "#" * 10 715 | + "\n如何输入下载范围:\n输入1-4表示下载id(序号)1至4的章节\n输入3,5表示下载id(序号)3、5的章节\n同理,可混合输入1-5,9,55-60" 716 | ) 717 | print(f"漫画特典详情见“{comicName}/漫画详情(特典).txt”文件(只列出了目前可下载的特典)") 718 | print("ps:请使用英文输入法,按回车键结束输入\n" + "#" * 10) 719 | ep_str = input("请输入下载范围:") 720 | download_list = manga.parser_ep_str(ep_str, isBonus=True) 721 | print("已获取章节列表") 722 | 723 | for ep in download_list: 724 | manga.downloadEp(ep, mangaDir, isBonus=True) 725 | print(f"已下载特典“{ep['title']}{ep['detail']}”,章节id:{ep['id']}") 726 | 727 | print(f"漫画《{comicName}》的下载任务已完成!\n" + "#" * 10) 728 | if input("按任意键继续,输入y退出").lower() == "y": 729 | break 730 | 731 | 732 | if __name__ == "__main__": 733 | main() 734 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bilibilicomic 2 | 仅供学习研究使用,不可使用本代码侵犯他人合法权益。 3 | 4 | ~~部分关键代码来自@lossme~~由于接口变化不再使用相关代码。根据[哔哩哔哩-API收集整理](https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/login/login_action/QR.md)完成登录部分代码,特此感谢。 5 | 6 | ## 功能 7 | 1. 下载免费/付费章节 8 | 2. 下载特典 9 | 3. 通过扫码登录账户(web端或app端) 10 | 4. 自定义章节名称 11 | 12 | ## 使用说明 13 | 1. 安装Python3.6或以上版本 14 | 2. 安装依赖:`pip install -r requirements.txt` 15 | 3. (可选)修改配置文件`config.toml`,示例参考`config.sample.toml` 16 | 4. 运行:`python BilibiliComic.py`,按照提示进行操作 17 | -------------------------------------------------------------------------------- /bilicomic_old.py: -------------------------------------------------------------------------------- 1 | # encoding:UTF-8 2 | # python3.6 3 | # support by BiliApi(https://api.kaaass.net/biliapi/) 4 | 5 | import tempfile 6 | import io 7 | import json 8 | import os 9 | import zipfile 10 | import requests 11 | import threading 12 | import queue 13 | import time 14 | import toml 15 | 16 | conf = 'config.toml' 17 | workDir = os.getcwd() 18 | 19 | # access_key = "59f81208b0b95a55c2f99e4e7eddc461" 20 | # appkey = "cc8617fd6961e070" 21 | # comicId = 26399 22 | # beginId = 0 23 | # endId = 99999999999 24 | 25 | with open(conf, encoding="utf-8") as f: 26 | dict_conf = toml.load(f) 27 | user = dict_conf['user']['user'] 28 | passwd = dict_conf['user']['passwd'] 29 | access_key = dict_conf['user']['access_key'] 30 | appkey = dict_conf['user']['appkey'] 31 | comicId = dict_conf['comic']['comicId'] 32 | beginId = int(dict_conf['comic']['beginId']) 33 | endId = int(dict_conf['comic']['endId']) 34 | 35 | if access_key != "": 36 | payload = {'access_key': access_key} 37 | r = requests.get( 38 | 'https://api.kaaass.net/biliapi/user/info', params=payload) 39 | if r.status_code == 200: 40 | isLogin = True 41 | requests.get( 42 | 'https://api.kaaass.net/biliapi/user/refreshToken', params=payload) 43 | else: 44 | isLogin = False 45 | else: 46 | isLogin = False 47 | 48 | 49 | if not isLogin: 50 | if user == "" or passwd == "": 51 | print("access_key已失效,且缺少用户信息(user,passwd),无法登录获取acess_key") 52 | input('按任意键退出') 53 | exit() 54 | data = {'user': user, 'passwd': passwd} 55 | r = requests.post('https://api.kaaass.net/biliapi/user/login', data=data) 56 | if r.status_code == 200: 57 | result = r.json() 58 | access_key = result['access_key'] 59 | dict_conf['user']['access_key'] = access_key 60 | with open(conf, "w", encoding="utf-8") as f: 61 | toml.dump(dict_conf, f) 62 | else: 63 | print("access_key已失效,且用户信息(user,passwd)错误,无法登录获取acess_key") 64 | input('按任意键退出') 65 | exit() 66 | 67 | headers = { 68 | 'Content-Type': "application/x-www-form-urlencoded; charset=UTF-8", 69 | 'user-agent': "Mozilla/5.0 BiliComic/2.0.3", 70 | 'Host': "manga.bilibili.com", 71 | } 72 | getHeaders = { 73 | 'User-Agent': 'okhttp/3.10.0', 74 | 'Host': 'manga.hdslb.com' 75 | } 76 | 77 | 78 | def makeDir(dirPath): 79 | if os.path.isdir(dirPath) == False: 80 | os.makedirs(dirPath) 81 | else: 82 | pass 83 | 84 | 85 | def getComicDetail(comicId): 86 | url = "https://manga.bilibili.com/twirp/comic.v2.Comic/ComicDetail" 87 | data = { 88 | 'access_key': access_key, 89 | 'appkey': appkey, 90 | 'comic_id': comicId, 91 | 'device': 'android', 92 | } 93 | r = requests.post(url, data=data, headers=headers) 94 | if r.status_code == requests.codes.ok: 95 | try: 96 | data = r.json() 97 | return data['data'] 98 | except Exception as e: 99 | print(e) 100 | else: 101 | print(f"getComicDetail fail,id={comicId},{r.status_code}") 102 | return 0 103 | 104 | def printList(ep_list,path): 105 | file=os.path.join(path,"漫画详情.txt") 106 | text="" 107 | for ep in ep_list: 108 | text=text+"章节id:{},章节名:{} {}\n".format(ep['id'],ep['short_title'],ep["title"]) 109 | with open(file,"w+", encoding="utf-8") as f: 110 | f.write(text) 111 | 112 | 113 | 114 | def getEpList(ep_list, filter=True, beginId=0, endId=9999999): 115 | EpList = [] 116 | for ep in ep_list: 117 | n = int(ep['id']) 118 | if n <= beginId or n > endId: 119 | continue 120 | epDict = {"episodeId": ep['id'], "name": ep['short_title']} 121 | if filter: 122 | if ep["is_locked"] == False or ep["is_in_free"]: 123 | EpList.append(epDict) 124 | else: 125 | pass 126 | else: 127 | EpList.append(epDict) 128 | return EpList 129 | 130 | 131 | def getEpIndex(comicId, episodeId): 132 | def generateHashKey(comicId, episodeId): 133 | n = [None for i in range(8)] 134 | e = int(comicId) 135 | t = int(episodeId) 136 | n[0] = t 137 | n[1] = t >> 8 138 | n[2] = t >> 16 139 | n[3] = t >> 24 140 | n[4] = e 141 | n[5] = e >> 8 142 | n[6] = e >> 16 143 | n[7] = e >> 24 144 | for idx in range(8): 145 | n[idx] = n[idx] % 256 146 | return n 147 | 148 | def unhashContent(hashKey, indexData): 149 | for idx in range(len(indexData)): 150 | indexData[idx] ^= hashKey[idx % 8] 151 | return bytes(indexData) 152 | 153 | url = "https://manga.bilibili.com/twirp/comic.v1.Comic/GetImageIndex" 154 | payload = f"access_key={access_key}&appkey={appkey}&device=android&ep_id={episodeId}&mobi_app=android_comic&platform=android" 155 | r = requests.post(url, headers=headers, data=payload) 156 | data = r.json()["data"]["host"]+r.json()["data"]["path"].replace(r"\u003d", r"=") 157 | 158 | r = requests.get(data, headers=getHeaders) 159 | indexData = r.content 160 | hashKey = generateHashKey(comicId, episodeId) 161 | indexData = list(indexData)[9:] 162 | indexData = unhashContent(hashKey=hashKey, indexData=indexData) 163 | 164 | file = io.BytesIO(indexData) 165 | tmp_dir = tempfile.TemporaryDirectory() 166 | obj = zipfile.ZipFile(file) 167 | obj.extractall(tmp_dir.name) 168 | json_file = os.path.join(tmp_dir.name, "index.dat") 169 | 170 | return json.load(open(json_file)) 171 | 172 | 173 | def getImageToken(imageUrls): 174 | url = "https://manga.bilibili.com/twirp/comic.v1.Comic/ImageToken" 175 | data = { 176 | 'access_key': access_key, 177 | 'appkey': appkey, 178 | # 必须使用json.dumps否则会400,可能是requests对数组处理有问题 179 | 'urls': json.dumps(imageUrls), 180 | } 181 | r = requests.post(url, data=data, headers=headers) 182 | token = r.json()["data"] 183 | return token 184 | 185 | 186 | def download(url, token, imgPath): 187 | url = url+f"?token={token}" 188 | r = requests.get(url, stream=True) 189 | if r.status_code == 200: 190 | with open(imgPath, 'wb') as f: 191 | for chunk in r: 192 | f.write(chunk) 193 | else: 194 | print(f"图片{url}下载失败!") 195 | 196 | 197 | def DownloadThread(q): 198 | while True: 199 | try: 200 | # 不阻塞的读取队列数据 201 | task = q.get_nowait() 202 | url = task["url"] 203 | token = task["token"] 204 | imgPath = task["imgPath"] 205 | download(url, token, imgPath) 206 | time.sleep(1) 207 | except queue.Empty as e: 208 | break 209 | except Exception as e: 210 | print(e) 211 | print("{}?token={}".format(task["url"],task["token"])) 212 | break 213 | q.task_done() 214 | 215 | 216 | def main(): 217 | detail = getComicDetail(comicId) 218 | comicName = detail["title"].replace(r"\t","").rstrip() 219 | comicDir = os.path.join(workDir, comicId) 220 | makeDir(comicDir) 221 | print(f"已获取漫画《{comicName}》详情,并建立文件夹/{comicId}") 222 | 223 | ep_list = detail["ep_list"] 224 | printList(ep_list,comicDir) 225 | if detail["discount_type"] == 2: 226 | EpList = getEpList(ep_list, filter=False, beginId=beginId, endId=endId) 227 | else: 228 | EpList = getEpList(ep_list, filter=True, beginId=beginId, endId=endId) 229 | print("已获取章节列表") 230 | 231 | for ep in EpList: 232 | episodeId = ep["episodeId"] 233 | epDir = os.path.join(comicDir, f"{ep['name']} #{episodeId}#") 234 | makeDir(epDir) 235 | 236 | indexData = getEpIndex(comicId, episodeId) 237 | imageUrls = ["https://manga.hdslb.com{}".format(url) 238 | for url in indexData["pics"]] 239 | data = getImageToken(imageUrls) 240 | print(f"已获取章节{ep['name']}的图片链接,章节id:{episodeId}") 241 | 242 | 243 | n = 1 244 | q = queue.Queue() 245 | for task in data: 246 | imgPath = os.path.join(epDir, f"{n}.jpg".zfill(6)) 247 | n = n+1 248 | task["imgPath"] = imgPath 249 | q.put(task) 250 | threads = [] 251 | for i in range(10): 252 | # 第一个参数是线程函数变量,第二个参数args是一个数组变量参数, 253 | # 如果只传递一个值,就只需要q, 如果需要传递多个参数,那么还可以继续传递下去其他的参数, 254 | # 其中的逗号不能少,少了就不是数组了,就会出错。 255 | thread = threading.Thread(target=DownloadThread, args=(q,)) 256 | thread.start() 257 | threads.append(thread) 258 | for thread in threads: 259 | thread.join() 260 | print(f"已下载章节{ep['name']},章节id:{episodeId}") 261 | 262 | 263 | print(f"漫画《{comicName}》下载完毕!\n"+"#"*10) 264 | input('按任意键退出') 265 | 266 | 267 | if __name__ == '__main__': 268 | main() 269 | -------------------------------------------------------------------------------- /config.sample.toml: -------------------------------------------------------------------------------- 1 | [user] 2 | # 用户信息,只填一个或不填并使用程序完成登录 3 | # access_key可以通过app端抓包获得 4 | # cookies可以在已登录的B站网页上获得 5 | access_key = "xxxxxxx" 6 | cookies = "sid=xxxxx;DedeUserID=xxxxx; DedeUserID__ckMd5=xxxx; SESSDATA=xxx; bili_jct=xxxx" 7 | 8 | [comic] 9 | # comicId为漫画链接中mc后的数字,可不填,在程序中输入也可 10 | # 例如链接https://manga.bilibili.com/detail/mc26009 中26009即为comicId或者说mc号 11 | # ep_str输入要下载的章节序号范围(不是第x章),章节与序号对应关系请查看程序运行后生成的漫画详情.txt文件,不懂可以留空 12 | # 输入1-4表示下载ord(序号)1至4的章节;输入3,5表示下载ord(序号)3、5的章节;同理,可混合输入1-5,9,55-60 13 | # 如果填入“all”,则表示下载全部 14 | # 注意,应使用英文逗号! 15 | comicId = "xxxx" 16 | ep_str = "1-10" 17 | 18 | [setting] 19 | # max_threads 同时下载的文件数,请根据网速斟酌 20 | max_threads = 10 21 | # 章节文件夹命名格式,可用变量如下: 22 | # @ord 章节序号(特典章节缺少此属性) 23 | # @id 章节唯一id 24 | # @short_title 章节短标题(特典章节缺少此属性) 25 | # @title 章节完整标题 26 | # @detail 章节详情(仅特典章节存在此属性) 27 | epName_rule = "[@ord] @short_title @title" 28 | epName_filter = "False" 29 | bonusName_rule = "[@id] @title @detail" 30 | bonusName_filter = "False" 31 | -------------------------------------------------------------------------------- /config.toml: -------------------------------------------------------------------------------- 1 | [user] 2 | access_key = "" 3 | cookies = "" 4 | 5 | [comic] 6 | comicId = "" 7 | ep_str = "" 8 | 9 | [setting] 10 | max_threads = 10 11 | epName_rule = "[@ord] @short_title @title" 12 | epName_filter = "False" 13 | bonusName_rule = "[@id] @title @detail" 14 | bonusName_filter = "False" 15 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | qrcode 3 | pillow 4 | func_timeout 5 | tenacity 6 | toml 7 | -------------------------------------------------------------------------------- /search.py: -------------------------------------------------------------------------------- 1 | # encoding:UTF-8 2 | # python3.6 3 | 4 | import requests 5 | 6 | url = "https://manga.bilibili.com/twirp/comic.v1.Comic/Search" 7 | headers = { 8 | 'Content-Type': "application/x-www-form-urlencoded", 9 | 'user-agent': "Mozilla/5.0 BiliComic/2.0.3", 10 | 'Host': "manga.bilibili.com", 11 | } 12 | while True: 13 | keyword=input('您想要搜索的漫画关键词:') 14 | payload = f"key_word={keyword}&page_size=10&page_num=1" 15 | r = requests.post(url, data=payload.encode('utf-8'), headers=headers) 16 | data=r.json()['data']['list'] 17 | for item in data: 18 | comicId=item['id'] 19 | title=item['org_title'] 20 | print(f"comicId={comicId},{title}") 21 | print("\n") --------------------------------------------------------------------------------