├── requirements.txt ├── text.txt ├── .gitignore ├── dulunche ├── check_env.py ├── danmaku.py ├── login.py ├── __init__.py ├── dmc.py └── biliapi.py ├── config.yml ├── main.py ├── README.md └── old.py /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp 2 | pillow 3 | pyyaml 4 | requests 5 | qrcode -------------------------------------------------------------------------------- /text.txt: -------------------------------------------------------------------------------- 1 | #room_23197314_16240 2 | #room_23197314_16241 3 | #room_23197314_16242 4 | #room_23197314_16243 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.vscode 2 | 3 | /cookies** 4 | /test** 5 | **.exe 6 | **.pyc 7 | **.mp4 8 | **.png 9 | **.json -------------------------------------------------------------------------------- /dulunche/check_env.py: -------------------------------------------------------------------------------- 1 | import os 2 | try: 3 | import yaml 4 | import PIL 5 | import requests 6 | import aiohttp 7 | import qrcode 8 | except ImportError: 9 | input('Python环境未正确安装,回车自动安装:') 10 | os.system("python -m pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple") 11 | print('Python环境安装完成.') -------------------------------------------------------------------------------- /config.yml: -------------------------------------------------------------------------------- 1 | # B站房间号 2 | room_id: 23197314 3 | # cookies保存文件名称 4 | cookies: cookies.txt 5 | # 检测时长t 6 | check_length: 30 7 | # 最小弹幕频率f 8 | min_freq: 1 9 | # 发送间隔 10 | # 此项为一个键值对,左边表示弹幕频率,右边表示间隔 11 | # 例如:发送频率1-1.5时,间隔18s 12 | # 发送频率1.5-2.0时,间隔15s 13 | # 发送频率2.0-2.5时,间隔12.5s 14 | # 另外,此项也可以为一个纯数字,表示不使用动态发送间隔 15 | interval: 16 | 1: 18 17 | 1.5: 15 18 | 2.0: 12.5 19 | 2.5: 10 20 | 3: 8 21 | 4: 7 22 | 5: 6 23 | # 挑选的弹幕个数n 24 | random_size: 3 25 | # 过滤没有粉丝牌的弹幕 26 | # medal表示带牌子就行,fans表示必须是当前主播的粉丝牌,none表示不过滤 27 | filter_medal: medal 28 | # 过滤自己发送的弹幕,默认true 29 | filter_self: true -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import yaml 3 | import logging 4 | from dulunche import AutoDuLunChe 5 | 6 | if __name__ == '__main__': 7 | parser = argparse.ArgumentParser() 8 | parser.add_argument('-c','--config',type=str,default='./config.yml') 9 | args = parser.parse_args() 10 | 11 | logging.basicConfig( 12 | level=logging.INFO, 13 | format="[%(asctime)s]: %(message)s", 14 | datefmt='%Y-%m-%d %H:%M:%S', 15 | ) 16 | 17 | with open(args.config,'r',encoding='utf-8') as f: 18 | config = yaml.safe_load(f) 19 | 20 | dlc = AutoDuLunChe(**config) 21 | dlc.start() 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DuLunChe 2 | Python独轮车工具。 3 | 专门为飞天狙直播间开发的独轮车、说书体一化工具。 4 | 主要特点: 5 | - 全自动独轮车,不用设置文本,别人车啥我车啥 6 | - 根据弹幕数量自动调节开车速度,不用担心车速过快过慢 7 | - 支持发送B站表情 8 | 9 | ## 新功能 10 | **全自动独轮车,不用设置文本,自动调节车速!别人车啥我车啥,别人车得快我也车得快!安装好环境之后双击main.py启动。** 11 | 12 | ## 前置 13 | - Python 3.7+ 14 | - aiohttp, pillow, pyyaml, requests 15 | 16 | **关于登录:** 17 | 请使用[biliuprs](https://github.com/biliup/biliup-rs?tab=readme-ov-file#windows-%E6%BC%94%E7%A4%BA)登录,并且使用相应标准的cookies文件。 18 | 19 | ## 使用 20 | 本程序有两个不同的模式: 21 | - 全自动独轮车(啥都不用设置,打开就自动开车),启动时双击`main.py`打开,配置文件在`config.yml`里面调(大部分情况下不用改)。 22 | 23 | - 传统独轮车(本程序v1版的设计)。启动时双击`old.py`打开文本放在text.txt里面,只会车已经有的文本,程序会自动识别文本是独轮车还是书,然后智能选择是开独轮车还是说书。 24 | 25 | **注意:登录信息会保存在cookies.txt内,此cookies包含了B站的登陆信息,不要将它分享给任何人!** 26 | 27 | ## 全自动独轮车原理 28 | 全自动独轮车的原理是:采集一段时间t=30s内的弹幕,若检测到当前大家发送弹幕的平均速度大于f=1(条每秒),则选择发送次数最多的n=3条弹幕,最后随机挑选其中的一条发送,发送完成后,根据当前弹幕数量休息8-18秒不等的时间,继续下一次发送,其中t,n,f等参数可以在配置文件中设置。 29 | 另外,为了防止独轮车被片哥影响,可以调节独轮车只收集带粉丝牌的人发送的弹幕。 30 | 31 | ## 传统独轮车特性 32 | **表情独轮车**:可以发送B站表情包,表情包应该以#开头,后面接表情ID,例如`#room_23197314_16240`,表情ID可以访问`https://api.live.bilibili.com/xlive/web-ucenter/v2/emoticon/GetEmoticons?platform=pc&room_id=<需要查的直播间号>`,返回数据里面会有表情ID的。 33 | **表情独轮车一些好玩的特性**: 34 | - 发送速度不受限制(它会限制单个表情包的发送速度,但是你几个表情轮流发送就不会有问题) 35 | - 可以在A直播间发送B直播间的表情包,只要你填写了正确的表情ID(但是要保证A直播间是有表情的直播间) 36 | 37 | 可选参数: 38 | - `--cookies` 设置cookies的路径,默认./cookies.txt 39 | - `--rid` 设置房间号,默认飞狙直播间(23197314) 40 | - `--interval` 设置独轮车间隔,默认20秒/条 41 | - `--txt` 设置文本路径,默认./text.txt 42 | - `--mode` 设置模式,可选auto,shuoshu,dulunche,默认auto(由程序自动判断,如果文本里面有超过40字符的句子就认为是说书,否则开独轮车) 43 | 44 | -------------------------------------------------------------------------------- /dulunche/danmaku.py: -------------------------------------------------------------------------------- 1 | import threading 2 | from datetime import datetime, timedelta 3 | 4 | class Danmaku(dict): 5 | def __init__( 6 | self, 7 | dmid:int, 8 | dmtype:str, 9 | streamer:str, 10 | sender:str, 11 | stime:datetime, 12 | content:str, 13 | color:str 14 | ) -> None: 15 | super().__init__() 16 | self['dmid'] = dmid 17 | self['dmtype'] = dmtype 18 | self['streamer'] = streamer 19 | self['sender'] = sender 20 | self['stime'] = stime 21 | self['content'] = content 22 | self['color'] = color 23 | 24 | def __getattribute__(self, __name: str): 25 | try: 26 | return super().__getattribute__(__name) 27 | except AttributeError: 28 | return self[__name] 29 | 30 | class DanmakuList(): 31 | def __init__(self, duration=60) -> None: 32 | self.duration = duration 33 | self.lock = threading.Lock() 34 | self.dmlist = [] 35 | 36 | def refresh(self): 37 | t0 = datetime.now() - timedelta(seconds=self.duration) 38 | while len(self.dmlist) > 0: 39 | if self.dmlist[0].stime < t0: 40 | with self.lock: 41 | self.dmlist.pop(0) 42 | else: 43 | break 44 | 45 | def add(self, danmu): 46 | self.refresh() 47 | with self.lock: 48 | self.dmlist.append(danmu) 49 | 50 | def __len__(self): 51 | self.refresh() 52 | return len(self.dmlist) 53 | 54 | def count(self, top=0, type='all') -> dict: 55 | """ 56 | return: List[tuple(Danmaku, cnt),...] 57 | """ 58 | self.refresh() 59 | res = {} 60 | with self.lock: 61 | for dm in self.dmlist: 62 | if type == 'all' or type == dm.dmtype: 63 | k = dm.content 64 | if not res.get(k): 65 | res[k] = (1, dm) 66 | else: 67 | res[k] = (res[k][0]+1, dm) 68 | 69 | res = sorted(res.items(), key=lambda x: x[1][0], reverse=True) 70 | if top>0: 71 | res = res[:top] 72 | res = [(it[1][1],it[1][0]) for it in res] 73 | return res 74 | 75 | -------------------------------------------------------------------------------- /dulunche/login.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import qrcode 3 | from threading import Thread 4 | import time 5 | import requests 6 | from io import BytesIO 7 | import http.cookiejar as cookielib 8 | from PIL import Image 9 | import os 10 | 11 | requests.packages.urllib3.disable_warnings() 12 | 13 | headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.63 Safari/537.36 Edg/102.0.1245.30",'Referer': "https://www.bilibili.com/"} 14 | headerss = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.63 Safari/537.36 Edg/102.0.1245.30",'Host': 'passport.bilibili.com','Referer': "https://passport.bilibili.com/login"} 15 | 16 | class showpng(Thread): 17 | def __init__(self, data): 18 | Thread.__init__(self) 19 | self.data = data 20 | 21 | def run(self): 22 | img = Image.open(BytesIO(self.data)).resize((512,512)) 23 | print('请打开文件QRcode.png扫码.') 24 | img.save('QRcode.png') 25 | img.show() 26 | 27 | 28 | def islogin(session): 29 | try: 30 | session.cookies.load(ignore_discard=True) 31 | except Exception: 32 | pass 33 | loginurl = session.get("https://api.bilibili.com/x/web-interface/nav", verify=False, headers=headers).json() 34 | if loginurl['code'] == 0: 35 | print('Cookies值有效,',loginurl['data']['uname'],',已登录!') 36 | return session, True 37 | else: 38 | print('Cookies值已经失效,请重新扫码登录!') 39 | return session, False 40 | 41 | 42 | def bzlogin(cookies): 43 | if not os.path.exists(cookies): 44 | with open(cookies, 'w') as f: 45 | f.write("") 46 | session = requests.session() 47 | session.cookies = cookielib.LWPCookieJar(filename=cookies) 48 | session, status = islogin(session) 49 | if not status: 50 | getlogin = session.get('https://passport.bilibili.com/qrcode/getLoginUrl', headers=headers).json() 51 | loginurl = requests.get(getlogin['data']['url'], headers=headers).url 52 | oauthKey = getlogin['data']['oauthKey'] 53 | qr = qrcode.QRCode() 54 | qr.add_data(loginurl) 55 | img = qr.make_image() 56 | a = BytesIO() 57 | img.save(a, 'png') 58 | png = a.getvalue() 59 | a.close() 60 | t = showpng(png) 61 | t.start() 62 | tokenurl = 'https://passport.bilibili.com/qrcode/getLoginInfo' 63 | while 1: 64 | qrcodedata = session.post(tokenurl, data={'oauthKey': oauthKey, 'gourl': 'https://www.bilibili.com/'}, headers=headerss).json() 65 | print(qrcodedata) 66 | if '-4' in str(qrcodedata['data']): 67 | print('二维码未失效,请扫码!') 68 | elif '-5' in str(qrcodedata['data']): 69 | print('已扫码,请确认!') 70 | elif '-2' in str(qrcodedata['data']): 71 | print('二维码已失效,请重新运行!') 72 | elif 'True' in str(qrcodedata['status']): 73 | print('已确认,登入成功!') 74 | session.get(qrcodedata['data']['url'], headers=headers) 75 | break 76 | else: 77 | print('其他:', qrcodedata) 78 | time.sleep(5) 79 | session.cookies.save() 80 | return session 81 | 82 | if __name__ == '__main__': 83 | bzlogin() -------------------------------------------------------------------------------- /old.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import json 3 | import re 4 | import time 5 | from dulunche.biliapi import BiliLiveAPI 6 | 7 | def read_text(fpath,mode): 8 | text = [] 9 | 10 | if '独轮车' in mode: 11 | with open(fpath,'r',encoding='utf-8') as f: 12 | text_list = f.readlines() 13 | for t in text_list: 14 | t = t.strip() 15 | # t = re.sub(r"[\n,,.。~!、;;]",' ',t) 16 | if len(t) > 0 and not t.startswith('//'): 17 | text.append(t[:30]) 18 | if t == '//': 19 | break 20 | else: 21 | with open(fpath,'r',encoding='utf-8') as f: 22 | text_list = f.readlines() 23 | for line in text_list: 24 | line = line.strip() 25 | if line == '//': 26 | break 27 | str_list = re.split(r"[,,.。~!、;;]",line) 28 | str_list = [s for s in str_list if s and len(s.strip())>0] 29 | p = 0 30 | while p < len(str_list): 31 | t = str_list[p] 32 | if len(t) < 10: 33 | while p < len(str_list)-1 and len(t+' '+str_list[p+1]) < 25: 34 | t += ' '+str_list[p+1] 35 | p += 1 36 | text.append(t) 37 | p += 1 38 | elif len(t) > 30: 39 | if len(t) < 60: 40 | t0 = t[:len(t)//2] 41 | t1 = t[len(t)//2:] 42 | else: 43 | t0 = t[0:30] 44 | t1 = t[30:60] 45 | text.append(t0) 46 | text.append(t1) 47 | p += 1 48 | else: 49 | text.append(t) 50 | p += 1 51 | return text 52 | 53 | def get_mode(fpath): 54 | with open(fpath,'r',encoding='utf-8') as f: 55 | text_list = f.readlines() 56 | sen_max = max([len(x) for x in text_list]) 57 | if sen_max > 40: 58 | mode = '说书' 59 | else: 60 | mode = '独轮车' 61 | return mode 62 | 63 | if __name__ == '__main__': 64 | parser = argparse.ArgumentParser() 65 | parser.add_argument('--cookies',type=str,default='./cookies.json') 66 | parser.add_argument('-r','--rid',type=str,default='23197314') 67 | parser.add_argument('-t','--txt',type=str,default='./text.txt') 68 | parser.add_argument('-i','--interval',type=float,default=10) 69 | parser.add_argument('--mode',choices=['auto','shuoshu','dulunche'],default='auto') 70 | args = parser.parse_args() 71 | 72 | if args.mode == 'auto': 73 | args.mode = get_mode(args.txt) 74 | mode = '独轮车' if args.mode == 'dulunche' else '说书' 75 | text = read_text(args.txt,mode=args.mode) 76 | 77 | with open(args.cookies, encoding='utf8') as f: 78 | cookies = json.load(f) 79 | cookies = {it['name']:it['value'] for it in cookies['cookie_info']['cookies']} 80 | bapi = BiliLiveAPI(cookies=cookies) 81 | 82 | login_info = bapi.get_user_info(args.rid) 83 | if login_info['code'] != 0: 84 | input('未登录,请使用biliuprs进行登录:https://github.com/biliup/biliup-rs') 85 | exit(1) 86 | else: 87 | data = login_info['data'] 88 | if data['medal']['is_weared']: 89 | print(f"正在使用账号 {data['info']['uname']} 独轮车,佩戴 {data['medal']['curr_weared']['medal_name']} {data['medal']['curr_weared']['level']}级 牌子.") 90 | else: 91 | print(f"正在使用账号 {data['info']['uname']} 独轮车,未戴牌子.") 92 | 93 | dm_cnt = 0 94 | kill_cnt = 0 95 | 96 | while 1: 97 | if len(text) < 1: 98 | print('No text, waiting...') 99 | time.sleep(1) 100 | 101 | mode = get_mode(args.txt) 102 | new_text = read_text(args.txt,mode=mode) 103 | if new_text != text: 104 | text = new_text 105 | print('refresh text.') 106 | break 107 | continue 108 | 109 | for word_cnt,txt in enumerate(text): 110 | if txt.startswith('#') and not txt.startswith('##'): 111 | txt = txt[1:] 112 | mode = '表情独轮车' 113 | 114 | try: 115 | rt = bapi.send_danmu(roomid=args.rid,msg=txt,emoticon=int(mode=='表情独轮车')) 116 | if rt['msg'] == '': 117 | status = True 118 | else: 119 | status = False 120 | msg = rt['msg'] 121 | except Exception as e: 122 | print(e) 123 | continue 124 | 125 | dm_cnt += 1 126 | if status: 127 | print(f'{mode} {word_cnt+1}/{len(text)}, total {dm_cnt:04d}: {txt}.') 128 | time.sleep(args.interval) 129 | else: 130 | kill_cnt += 1 131 | print(f'{mode} {word_cnt+1}/{len(text)} was killed ({msg}): {txt}.') 132 | time.sleep(min(args.interval,5)) 133 | 134 | mode = get_mode(args.txt) 135 | new_text = read_text(args.txt,mode=mode) 136 | if new_text != text: 137 | text = new_text 138 | print('Refresh text...') 139 | break -------------------------------------------------------------------------------- /dulunche/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | import dulunche.check_env 3 | import asyncio 4 | import random 5 | import time 6 | import queue 7 | import logging 8 | import requests 9 | import http.cookiejar as cookielib 10 | 11 | from datetime import datetime 12 | from concurrent.futures import ThreadPoolExecutor, as_completed 13 | 14 | from dulunche.danmaku import Danmaku, DanmakuList 15 | from dulunche.biliapi import BiliLiveAPI 16 | from dulunche.dmc import DanmakuClient 17 | 18 | class AutoDuLunChe(): 19 | def __init__(self, 20 | room_id, 21 | cookies, 22 | check_length=60, 23 | min_freq=1, 24 | interval=15, 25 | random_size=3, 26 | filter_medal=True, 27 | filter_self=True, 28 | **kwargs) -> None: 29 | self.room_id = room_id 30 | self.check_length = check_length 31 | self.min_freq = min_freq 32 | if not isinstance(interval, dict): 33 | self.interval = {0:float(interval)} 34 | else: 35 | self.interval = interval 36 | self.random_size = random_size 37 | self.filter_medal = filter_medal 38 | self.filter_self = filter_self 39 | self.kwargs = kwargs 40 | 41 | if isinstance(cookies, str): 42 | with open(cookies, encoding='utf8') as f: 43 | cookies = json.load(f) 44 | cookies = {it['name']:it['value'] for it in cookies['cookie_info']['cookies']} 45 | self.cookies = cookies 46 | self.bapi = BiliLiveAPI(cookies=self.cookies) 47 | 48 | login_info = self.bapi.get_user_info(self.room_id) 49 | if login_info['code'] != 0: 50 | input('未登录,请使用biliuprs进行登录:https://github.com/biliup/biliup-rs') 51 | exit(1) 52 | else: 53 | data = login_info['data'] 54 | self.up_medal = data['medal']['up_medal']['medal_name'] 55 | self.uname = data['info']['uname'] 56 | if data['medal']['is_weared']: 57 | logging.info(f"正在使用账号 {data['info']['uname']} 独轮车,佩戴 {data['medal']['curr_weared']['medal_name']} {data['medal']['curr_weared']['level']}级 牌子.") 58 | else: 59 | logging.info(f"正在使用账号 {data['info']['uname']} 独轮车,未戴牌子.") 60 | 61 | self.dmlist = DanmakuList(duration=self.check_length) 62 | self.total_cnt = 0 63 | self.stoped = True 64 | 65 | def start_dmc(self): 66 | async def danmu_monitor(): 67 | q = asyncio.Queue() 68 | self.dmc = DanmakuClient(url=f'https://live.bilibili.com/{self.room_id}', q=q) 69 | 70 | async def dmc_task(): 71 | while not self.stoped: 72 | try: 73 | await self.dmc.start() 74 | except asyncio.CancelledError: 75 | await self.dmc.stop() 76 | except Exception as e: 77 | await self.dmc.stop() 78 | logging.error(e) 79 | 80 | asyncio.create_task(dmc_task()) 81 | 82 | while not self.stoped: 83 | dm = await q.get() 84 | if self.dmavailable(dm): 85 | dm = Danmaku( 86 | dmid=0, 87 | dmtype=dm['msg_type'], 88 | streamer=None, 89 | sender=dm['name'], 90 | stime=dm['time'], 91 | content=dm['content'], 92 | color=dm['color'] 93 | ) 94 | self.dmlist.add(dm) 95 | 96 | new_loop = asyncio.new_event_loop() 97 | asyncio.set_event_loop(new_loop) 98 | asyncio.get_event_loop().run_until_complete(danmu_monitor()) 99 | 100 | def dmavailable(self, dm): 101 | if dm['msg_type'] in ['danmaku','emoticon']: 102 | medal = dm['raw_data']['info'][3] 103 | if self.filter_self and dm['name'] == self.uname: 104 | return False 105 | if self.filter_medal == 'medal': 106 | return bool(medal) 107 | elif self.filter_medal == 'fans': 108 | return bool(self.up_medal == medal[1]) 109 | else: 110 | return True 111 | return False 112 | 113 | def start_sender(self): 114 | logging.info('正在收集弹幕数据...') 115 | time.sleep(self.check_length) 116 | 117 | while not self.stoped: 118 | num = len(self.dmlist) 119 | if num < self.check_length*self.min_freq: 120 | logging.info(f'弹幕过少,设置频率阈值 {self.min_freq}条/秒,实际发送速率 {num/self.check_length}条/秒,暂停开车.') 121 | time.sleep(self.check_length) 122 | continue 123 | top_danmu = self.dmlist.count(top=self.random_size) 124 | dm, _ = random.choice(top_danmu) 125 | 126 | try: 127 | rt = self.bapi.send_danmu(self.room_id, msg=dm.content, emoticon=int(dm.dmtype=='emoticon')) 128 | if rt['msg'] == '': 129 | self.total_cnt += 1 130 | logging.info(f'独轮车 {self.total_cnt:04d}: {dm.content} 发送成功.') 131 | else: 132 | logging.error(f"独轮车 {dm.content} 发送失败, {rt['msg']}.") 133 | time.sleep(5) 134 | continue 135 | except Exception as e: 136 | logging.error(f'独轮车 {dm.content} 发送失败, {e}.') 137 | time.sleep(5) 138 | continue 139 | 140 | sleep_time = self.check_length 141 | for n, t in self.interval.items(): 142 | if num/self.check_length > n: 143 | sleep_time = t 144 | time.sleep(sleep_time) 145 | 146 | def start(self): 147 | self.stoped = False 148 | self.futures = [] 149 | pool = ThreadPoolExecutor(max_workers=2) 150 | self.futures.append(pool.submit(self.start_dmc)) 151 | self.futures.append(pool.submit(self.start_sender)) 152 | for future in as_completed(self.futures): 153 | future.result() 154 | 155 | def stop(self): 156 | self.stoped = True 157 | 158 | 159 | -------------------------------------------------------------------------------- /dulunche/dmc.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import re, asyncio, aiohttp 3 | 4 | __all__ = ["DanmakuClient"] 5 | 6 | class DanmakuClient: 7 | def __init__(self, url, q, **kargs): 8 | self.__url = "" 9 | self.__site = None 10 | self.__usite = None 11 | self.__hs = None 12 | self.__ws = None 13 | self.__stop = False 14 | self.__dm_queue = q 15 | self.__link_status = True 16 | self.__extra_data = kargs 17 | if "http://" == url[:7] or "https://" == url[:8]: 18 | self.__url = url 19 | else: 20 | self.__url = "http://" + url 21 | self.__site = Bilibili 22 | self.__hs = aiohttp.ClientSession() 23 | 24 | async def init_ws(self): 25 | ws_url, reg_datas = await self.__site.get_ws_info(self.__url) 26 | self.__ws = await self.__hs.ws_connect(ws_url, headers=self.__site.headers) 27 | for reg_data in reg_datas: 28 | if type(reg_data) == str: 29 | await self.__ws.send_str(reg_data) 30 | else: 31 | await self.__ws.send_bytes(reg_data) 32 | 33 | async def heartbeats(self): 34 | while self.__stop != True: 35 | # print('heartbeat') 36 | await asyncio.sleep(20) 37 | try: 38 | if type(self.__site.heartbeat) == str: 39 | await self.__ws.send_str(self.__site.heartbeat) 40 | else: 41 | await self.__ws.send_bytes(self.__site.heartbeat) 42 | except: 43 | pass 44 | 45 | async def fetch_danmaku(self): 46 | while self.__stop != True: 47 | async for msg in self.__ws: 48 | # self.__link_status = True 49 | ms = self.__site.decode_msg(msg.data) 50 | for m in ms: 51 | if not m.get('time',0): 52 | m['time'] = datetime.now() 53 | await self.__dm_queue.put(m) 54 | if self.__stop != True: 55 | await asyncio.sleep(1) 56 | await self.init_ws() 57 | await asyncio.sleep(1) 58 | 59 | async def start(self): 60 | if self.__site != None: 61 | await self.init_ws() 62 | await asyncio.gather( 63 | self.heartbeats(), 64 | self.fetch_danmaku(), 65 | ) 66 | else: 67 | await self.__usite.run(self.__url, self.__dm_queue, self.__hs, **self.__extra_data) 68 | 69 | async def stop(self): 70 | self.__stop = True 71 | if self.__site != None: 72 | await self.__hs.close() 73 | else: 74 | await self.__usite.stop() 75 | await self.__hs.close() 76 | 77 | from datetime import datetime 78 | import json, re, select, random, traceback 79 | import asyncio, aiohttp, zlib, brotli 80 | from struct import pack, unpack 81 | 82 | class Bilibili(): 83 | heartbeat = b"\x00\x00\x00\x1f\x00\x10\x00\x01\x00\x00\x00\x02\x00\x00\x00\x01\x5b\x6f\x62\x6a\x65\x63\x74\x20\x4f\x62\x6a\x65\x63\x74\x5d" 84 | headers = { 85 | 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 86 | 'Accept-Encoding': 'gzip, deflate', 87 | 'Accept-Language': 'zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3', 88 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36', 89 | 'Referer': 'https://live.bilibili.com/', 90 | } 91 | interval = 30 92 | 93 | async def get_ws_info(url): 94 | url = "https://api.live.bilibili.com/room/v1/Room/room_init?id=" + url.split("/")[-1] 95 | reg_datas = [] 96 | async with aiohttp.ClientSession(headers=Bilibili.headers) as session: 97 | async with session.get(url) as resp: 98 | room_json = await resp.json() 99 | room_id = room_json["data"]["room_id"] 100 | 101 | async with aiohttp.ClientSession(headers=Bilibili.headers) as session: 102 | async with session.get(f'https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id={room_id}') as resp: 103 | room_json = await resp.json() 104 | token = room_json['data']['token'] 105 | 106 | data = json.dumps({ 107 | "roomid": room_id, 108 | "uid": 0, 109 | "protover": 3, 110 | "key": token, 111 | "type":2, 112 | "platform": "web", 113 | },separators=(",", ":"),).encode("ascii") 114 | data = ( 115 | pack(">i", len(data) + 16) 116 | + pack(">h", 16) 117 | + pack(">h", 1) 118 | + pack(">i", 7) 119 | + pack(">i", 1) 120 | + data 121 | ) 122 | reg_datas.append(data) 123 | 124 | return "wss://broadcastlv.chat.bilibili.com/sub", reg_datas 125 | 126 | def decode_msg(data): 127 | dm_list = [] 128 | msgs = [] 129 | 130 | def decode_packet(packet_data): 131 | dm_list = [] 132 | while True: 133 | try: 134 | packet_len, header_len, ver, op, seq = unpack('!IHHII', packet_data[0:16]) 135 | except Exception: 136 | break 137 | if len(packet_data) < packet_len: 138 | break 139 | 140 | if ver == 2: 141 | dm_list.extend(decode_packet(zlib.decompress(packet_data[16:packet_len])))\ 142 | # version3: 参考https://github.com/biliup/biliup/blob/master/biliup/plugins/Danmaku/bilibili.py 143 | elif ver == 3: 144 | dm_list.extend(decode_packet(brotli.decompress(packet_data[16:packet_len]))) 145 | elif ver == 0 or ver == 1: 146 | dm_list.append({ 147 | 'type': op, 148 | 'body': packet_data[16:packet_len] 149 | }) 150 | else: 151 | break 152 | 153 | if len(packet_data) == packet_len: 154 | break 155 | else: 156 | packet_data = packet_data[packet_len:] 157 | return dm_list 158 | 159 | dm_list = decode_packet(data) 160 | 161 | for i, dm in enumerate(dm_list): 162 | try: 163 | msg = {} 164 | if dm.get('type') == 5: 165 | j = json.loads(dm.get('body')) 166 | msg['msg_type'] = { 167 | 'SEND_GIFT': 'gift', 168 | 'DANMU_MSG': 'danmaku', 169 | 'WELCOME': 'enter', 170 | 'NOTICE_MSG': 'broadcast', 171 | 'LIVE_INTERACTIVE_GAME': 'interactive_danmaku' # 新增互动弹幕,经测试与弹幕内容一致 172 | }.get(j.get('cmd'), 'other') 173 | 174 | if 'DANMU_MSG' in j.get('cmd'): 175 | msg["msg_type"] = "danmaku" 176 | 177 | if msg["msg_type"] == "danmaku": 178 | msg["name"] = j.get("info", ["", "", ["", ""]])[2][1] or j.get( 179 | "data", {} 180 | ).get("uname", "") 181 | msg["color"] = f"{j.get('info', [[0, 0, 0, 16777215]])[0][3]:06x}" 182 | msg["content"] = j.get("info")[1] 183 | try: 184 | msg['time'] = datetime.fromtimestamp(j.get('info')[0][4]/1000) 185 | if j.get('info')[13] != r'{}': 186 | emoticon_info = j.get('info')[0][13] 187 | emoticon_url = emoticon_info['url'] 188 | emoticon_desc = j.get('info')[1] 189 | msg["content"] = json.dumps({'url':emoticon_url,'desc':emoticon_desc},ensure_ascii=False) 190 | msg['msg_type'] = 'emoticon' 191 | except: 192 | pass 193 | 194 | elif msg['msg_type'] == 'interactive_danmaku': 195 | msg["msg_type"] = "danmaku" 196 | msg['name'] = j.get('data', {}).get('uname', '') 197 | msg['content'] = j.get('data', {}).get('msg', '') 198 | msg["color"] = 'ffffff' 199 | 200 | elif msg["msg_type"] == "broadcast": 201 | msg["type"] = j.get("msg_type", 0) 202 | msg["roomid"] = j.get("real_roomid", 0) 203 | msg["content"] = j.get("msg_common", "none") 204 | msg["raw"] = j 205 | 206 | msg["raw_data"] = j 207 | else: 208 | msg = {"name": "", "content": dm.get('body'), "msg_type": "other"} 209 | msgs.append(msg) 210 | except Exception as e: 211 | # traceback.print_exc() 212 | # print(e) 213 | pass 214 | 215 | return msgs 216 | -------------------------------------------------------------------------------- /dulunche/biliapi.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import json 4 | import re 5 | import time 6 | from typing import List, Union 7 | import requests 8 | import http.cookiejar as cookielib 9 | 10 | class BaseAPI: 11 | headers = { 12 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.63 Safari/537.36 Edg/102.0.1245.30", 13 | } 14 | def __init__(self,timeout=(3.05,5)): 15 | self.timeout=timeout 16 | 17 | 18 | def set_default_timeout(self,timeout=(3.05,5)): 19 | self.timeout=timeout 20 | 21 | class BiliLiveAPI(BaseAPI): 22 | def __init__(self,cookies:Union[List[str],str,dict],timeout=(3.05,5)): 23 | """B站直播相关API""" 24 | super().__init__(timeout) 25 | self.headers = dict(self.headers, 26 | Origin="https://live.bilibili.com", 27 | Referer="https://live.bilibili.com/") 28 | self.sessions = [] 29 | self.csrfs = [] 30 | self.rnd=int(time.time()) 31 | if isinstance(cookies,str): cookies=[cookies] 32 | if isinstance(cookies,list): 33 | for i in range(len(cookies)): 34 | self.sessions.append(requests.session()) 35 | self.csrfs.append("") 36 | self.update_cookie(cookies[i],i) 37 | if isinstance(cookies,dict): 38 | self.sessions.append(requests.session()) 39 | self.csrfs.append(cookies.get('bili_jct')) 40 | requests.utils.add_dict_to_cookiejar(self.sessions[0].cookies,cookies) 41 | 42 | def get_room_info(self,roomid,timeout=None) -> dict: 43 | """获取直播间标题、简介等信息""" 44 | url="https://api.live.bilibili.com/xlive/web-room/v1/index/getInfoByRoom" 45 | params={"room_id":roomid} 46 | if timeout is None: timeout=self.timeout 47 | res=requests.get(url=url,headers=self.headers,params=params,timeout=timeout) 48 | return json.loads(res.text) 49 | 50 | def get_danmu_config(self,roomid,number=0,timeout=None) -> dict: 51 | """获取用户在直播间内的可用弹幕颜色、弹幕位置等信息""" 52 | url="https://api.live.bilibili.com/xlive/web-room/v1/dM/GetDMConfigByGroup" 53 | params={"room_id":roomid} 54 | if timeout is None: timeout=self.timeout 55 | res=self.sessions[number].get(url=url,headers=self.headers,params=params,timeout=timeout) 56 | return json.loads(res.text) 57 | 58 | def get_user_info(self,roomid,number=0,timeout=None) -> dict: 59 | """获取用户在直播间内的当前弹幕颜色、弹幕位置、发言字数限制等信息""" 60 | url="https://api.live.bilibili.com/xlive/web-room/v1/index/getInfoByUser" 61 | params={"room_id":roomid} 62 | if timeout is None: timeout=self.timeout 63 | res=self.sessions[number].get(url=url,headers=self.headers,params=params,timeout=timeout) 64 | return json.loads(res.text) 65 | 66 | def set_danmu_config(self,roomid,color=None,mode=None,number=0,timeout=None) -> dict: 67 | """设置用户在直播间内的弹幕颜色或弹幕位置 68 | :(颜色参数为十六进制字符串,颜色和位置不能同时设置)""" 69 | url="https://api.live.bilibili.com/xlive/web-room/v1/dM/AjaxSetConfig" 70 | data={ 71 | "room_id": roomid, 72 | "color": color, 73 | "mode": mode, 74 | "csrf_token": self.csrfs[number], 75 | "csrf": self.csrfs[number], 76 | } 77 | if timeout is None: timeout=self.timeout 78 | res=self.sessions[number].post(url=url,headers=self.headers,data=data,timeout=timeout) 79 | return json.loads(res.text) 80 | 81 | def send_danmu(self,roomid,msg,mode=1,number=0,timeout=None,emoticon=0) -> dict: 82 | """向直播间发送弹幕""" 83 | url="https://api.live.bilibili.com/msg/send" 84 | data={ 85 | "color": 16777215, 86 | "fontsize": 25, 87 | "mode": mode, 88 | "bubble": 0, 89 | "dm_type": emoticon, 90 | "msg": msg, 91 | "roomid": roomid, 92 | "rnd": self.rnd, 93 | "csrf_token": self.csrfs[number], 94 | "csrf": self.csrfs[number], 95 | } 96 | if timeout is None: timeout=self.timeout 97 | res=self.sessions[number].post(url=url,headers=self.headers,data=data,timeout=timeout) 98 | return json.loads(res.text) 99 | 100 | def get_slient_user_list(self,roomid,number=0,timeout=None): 101 | """获取房间被禁言用户列表""" 102 | url="https://api.live.bilibili.com/xlive/web-ucenter/v1/banned/GetSilentUserList" 103 | params={ 104 | "room_id": roomid, 105 | "ps": 1, 106 | } 107 | if timeout is None: timeout=self.timeout 108 | res=self.sessions[number].get(url=url,headers=self.headers,params=params,timeout=timeout) 109 | return json.loads(res.text) 110 | 111 | def add_slient_user(self,roomid,uid,number=0,timeout=None): 112 | """禁言用户""" 113 | url="https://api.live.bilibili.com/xlive/web-ucenter/v1/banned/AddSilentUser" 114 | data={ 115 | "room_id": roomid, 116 | "tuid": uid, 117 | "mobile_app": "web", 118 | "csrf_token": self.csrfs[number], 119 | "csrf": self.csrfs[number], 120 | } 121 | if timeout is None: timeout=self.timeout 122 | res=self.sessions[number].post(url=url,headers=self.headers,data=data,timeout=timeout) 123 | return json.loads(res.text) 124 | 125 | def del_slient_user(self,roomid,silent_id,number=0,timeout=None): 126 | """解除用户禁言""" 127 | url="https://api.live.bilibili.com/banned_service/v1/Silent/del_room_block_user" 128 | data={ 129 | "roomid": roomid, 130 | "id": silent_id, 131 | "csrf_token": self.csrfs[number], 132 | "csrf": self.csrfs[number], 133 | } 134 | if timeout is None: timeout=self.timeout 135 | res=self.sessions[number].post(url=url,headers=self.headers,data=data,timeout=timeout) 136 | return json.loads(res.text) 137 | 138 | def get_shield_keyword_list(self,roomid,number=0,timeout=None): 139 | """获取房间屏蔽词列表""" 140 | url="https://api.live.bilibili.com/xlive/web-ucenter/v1/banned/GetShieldKeywordList" 141 | params={ 142 | "room_id": roomid, 143 | "ps": 2, 144 | } 145 | if timeout is None: timeout=self.timeout 146 | res=self.sessions[number].get(url=url,headers=self.headers,params=params,timeout=timeout) 147 | return json.loads(res.text) 148 | 149 | def add_shield_keyword(self,roomid,keyword,number=0,timeout=None): 150 | """添加房间屏蔽词""" 151 | url="https://api.live.bilibili.com/xlive/web-ucenter/v1/banned/AddShieldKeyword" 152 | data={ 153 | "room_id": roomid, 154 | "keyword": keyword, 155 | "csrf_token": self.csrfs[number], 156 | "csrf": self.csrfs[number], 157 | } 158 | if timeout is None: timeout=self.timeout 159 | res=self.sessions[number].post(url=url,headers=self.headers,data=data,timeout=timeout) 160 | return json.loads(res.text) 161 | 162 | def del_shield_keyword(self,roomid,keyword,number=0,timeout=None): 163 | """删除房间屏蔽词""" 164 | url="https://api.live.bilibili.com/xlive/web-ucenter/v1/banned/DelShieldKeyword" 165 | data={ 166 | "room_id": roomid, 167 | "keyword": keyword, 168 | "csrf_token": self.csrfs[number], 169 | "csrf": self.csrfs[number], 170 | } 171 | if timeout is None: timeout=self.timeout 172 | res=self.sessions[number].post(url=url,headers=self.headers,data=data,timeout=timeout) 173 | return json.loads(res.text) 174 | 175 | def search_live_users(self,keyword,page_size=10,timeout=None) -> dict: 176 | """根据关键字搜索直播用户""" 177 | url="https://api.bilibili.com/x/web-interface/search/type" 178 | params={ 179 | "keyword": keyword, 180 | "search_type": "live_user", 181 | "page_size": page_size, 182 | } 183 | if timeout is None: timeout=self.timeout 184 | res=requests.get(url=url,headers=self.headers,params=params,timeout=timeout) 185 | return json.loads(res.text) 186 | 187 | def get_login_url(self,timeout=None): 188 | """获取登录链接""" 189 | url="https://passport.bilibili.com/qrcode/getLoginUrl" 190 | if timeout is None: timeout=self.timeout 191 | res=requests.get(url=url,headers=self.headers,timeout=timeout) 192 | return json.loads(res.text) 193 | 194 | def get_login_info(self,oauthKey,timeout=None): 195 | """检查登录链接状态,获取登录信息""" 196 | url="https://passport.bilibili.com/qrcode/getLoginInfo" 197 | data={ 198 | "oauthKey": oauthKey, 199 | } 200 | if timeout is None: timeout=self.timeout 201 | res=requests.post(url=url,headers=self.headers,data=data,timeout=timeout) 202 | return json.loads(res.text) 203 | 204 | def update_cookie(self,cookie:str,number=0) -> str: 205 | """更新账号Cookie信息 206 | :返回cookie中buvid3,SESSDATA,bili_jct三项的合并内容""" 207 | cookie = re.sub(r"\s+", "", cookie) 208 | mo1 = re.search(r"buvid3=([^;]+)", cookie) 209 | mo2 = re.search(r"SESSDATA=([^;]+)", cookie) 210 | mo3 = re.search(r"bili_jct=([^;]+)", cookie) 211 | buvid3,sessdata,bili_jct=mo1.group(1) if mo1 else "",mo2.group(1) if mo2 else "",mo3.group(1) if mo3 else "" 212 | cookie="buvid3=%s;SESSDATA=%s;bili_jct=%s"%(buvid3,sessdata,bili_jct) 213 | requests.utils.add_dict_to_cookiejar(self.sessions[number].cookies,{"Cookie": cookie}) 214 | self.csrfs[number]=bili_jct 215 | return cookie 216 | 217 | class JsdelivrAPI(BaseAPI): 218 | def __init__(self, timeout=(6.05,5)): 219 | """Jsdelivr公共CDN的API""" 220 | super().__init__(timeout) 221 | 222 | def get_latest_bili_live_shield_words(self,domain="cdn",timeout=None) -> str: 223 | """获取最新的B站直播屏蔽词处理脚本(Github项目:FHChen0420/bili_live_shield_words)""" 224 | url=f"https://{domain}.jsdelivr.net/gh/FHChen0420/bili_live_shield_words@main/BiliLiveShieldWords.py" 225 | if timeout is None: timeout=self.timeout 226 | res=requests.get(url,headers=self.headers,timeout=timeout) 227 | return res.text 228 | 229 | if __name__ == '__main__': 230 | cookies = '' 231 | bapi = BiliLiveAPI(cookies=cookies) 232 | rid = '23197314' 233 | res = bapi.send_danmu(rid,'test, from python') 234 | bapi.get_shield_keyword_list(rid) 235 | a = 1 --------------------------------------------------------------------------------