diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..da2ef120 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__ +# *.go +*.json +test.py +go.mod +go.sum \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..47080ea6 --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +

+ +

+
+

新 B 站粉丝牌助手 +

+

当前版本:0.1.0

+
+ +**TODO** + +- [x] 每日直播区签到 +- [x] 每日点赞3次直播间 (200*3 亲密度) +- [x] 每日分享5次直播间 (100*5 亲密度) +- [x] 每日弹幕打卡 (100 亲密度) +- [x] 多账号支持 +- [ ] 每日观看30分钟 +- [ ] 微信推送通知 + +ps: 新版B站粉丝牌的亲密度每一个牌子都将单独计算  + +--- + +### 使用说明 + +##### 环境需求:Python 版本大于 3.8 + +> 克隆本项目 安装依赖 + +```shell +git clone https://github.com/XiaoMiku01/fansMedalHelper.git +cd fansMedalHelper +pip install -r requirements.txt +``` + +> 获取B站账号的 access_key + +... + +> 填写配置文件 users.yaml + +```shell +vim users.yaml +``` + +```yaml +USERS: + - access_key: XXXXXX # 注意冒号后的空格 否则会读取失败 + shared_uid: 123,456 # 需要分享房间的房主UID!不是房间号! 多个用逗号分隔,最多28个 + + - access_key: + shared_uid: + # 多用户以上格式添加 +``` + +> 运行主程序 + +```shell +python main.py +``` + +> 效果图 + +[![XiifQP.md.png](https://s1.ax1x.com/2022/05/24/XiifQP.md.png)](https://imgtu.com/i/XiifQP) + +--- + +### 注意事项 + +- 本脚本暂时没有集成定时模块 还需要用户定时运行 +- 由于B站分享接口并不是每次分享都会加亲密度,它会有10分钟的CD,所以设置一个拿满500需要40分钟,以此类推,但是得益于Python3的异步机制,多个账号将并发完成任务,例如:A账号设置了2个房间的分享任务,B账号设置了1个,所需时间为90分钟。所以一天24小时最多可以拿满28个房间的分享亲密度 + +--- + +### 赞助 + +![](http://i0.hdslb.com/bfs/album/c267037c9513b8e44bc6ec95dbf772ff0439dce6.jpg) diff --git a/login.go b/login.go new file mode 100644 index 00000000..2a1356f1 --- /dev/null +++ b/login.go @@ -0,0 +1,174 @@ +package main + +import ( + "crypto/md5" + "encoding/hex" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "sort" + "strings" + "time" + + qrcodeTerminal "github.com/Baozisoftware/qrcode-terminal-go" + gjson "github.com/tidwall/gjson" +) + +var AccessKey string + +var Csrf string + +var Cookies []*http.Cookie + +func Login() { + filename := "login_info.json" + data, err := ioutil.ReadFile(filename) + if err != nil || len(data) == 0 { + fmt.Println("未登录,请扫码登录") + loginBili() + } else { + AccessKey = gjson.Parse(string(data)).Get("data.access_token").String() + for _, c := range gjson.Parse(string(data)).Get("data.cookie_info.cookies").Array() { + Cookies = append(Cookies, &http.Cookie{ + Name: c.Get("name").String(), + Value: c.Get("value").String(), + }) + if c.Get("name").String() == "bili_jct" { + Csrf = c.Get("value").String() + } + } + l, name := is_login() + if l { + fmt.Println("登录成功:", name) + } else { + fmt.Println("登录失败,请重新扫码登录") + loginBili() + } + } + +} + +func is_login() (bool, string) { + api := "https://api.bilibili.com/x/web-interface/nav" + client := http.Client{} + req, _ := http.NewRequest("GET", api, nil) + for _, c := range Cookies { + req.AddCookie(c) + } + resp, err := client.Do(req) + if err != nil { + panic(err) + } + defer resp.Body.Close() + body, _ := ioutil.ReadAll(resp.Body) + data := gjson.ParseBytes(body) + return data.Get("code").Int() == 0, data.Get("data.uname").String() +} + +func get_tv_qrcode_url_and_auth_code() (string, string) { + api := "http://passport.bilibili.com/x/passport-tv-login/qrcode/auth_code" + data := make(map[string]string) + data["local_id"] = "0" + data["ts"] = fmt.Sprintf("%d", time.Now().Unix()) + signature(&data) + data_string := strings.NewReader(map_to_string(data)) + client := http.Client{} + req, _ := http.NewRequest("POST", api, data_string) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := client.Do(req) + if err != nil { + panic(err) + } + defer resp.Body.Close() + body, _ := ioutil.ReadAll(resp.Body) + code := gjson.Parse(string(body)).Get("code").Int() + if code == 0 { + qrcode_url := gjson.Parse(string(body)).Get("data.url").String() + auth_code := gjson.Parse(string(body)).Get("data.auth_code").String() + return qrcode_url, auth_code + } else { + panic("get_tv_qrcode_url_and_auth_code error") + } +} + +func verify_login(auth_code string) { + api := "http://passport.bilibili.com/x/passport-tv-login/qrcode/poll" + data := make(map[string]string) + data["auth_code"] = auth_code + data["local_id"] = "0" + data["ts"] = fmt.Sprintf("%d", time.Now().Unix()) + signature(&data) + data_string := strings.NewReader(map_to_string(data)) + client := http.Client{} + req, _ := http.NewRequest("POST", api, data_string) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for { + resp, err := client.Do(req) + if err != nil { + panic(err) + } + defer resp.Body.Close() + body, _ := ioutil.ReadAll(resp.Body) + code := gjson.Parse(string(body)).Get("code").Int() + AccessKey = gjson.Parse(string(body)).Get("data.access_token").String() + if code == 0 { + fmt.Println("登录成功") + fmt.Println("access_key:", string(AccessKey)) + filename := "login_info.txt" + err := ioutil.WriteFile(filename, []byte(string(AccessKey)), 0644) + if err != nil { + panic(err) + } + fmt.Println("access_key已保存在", filename) + break + } else { + fmt.Println(string(body)) + time.Sleep(time.Second * 3) + } + } +} + +var appkey = "4409e2ce8ffd12b8" +var appsec = "59b43e04ad6965f34319062b478f83dd" + +func signature(params *map[string]string) { + var keys []string + (*params)["appkey"] = appkey + for k := range *params { + keys = append(keys, k) + } + sort.Strings(keys) + var query string + for _, k := range keys { + query += k + "=" + url.QueryEscape((*params)[k]) + "&" + } + query = query[:len(query)-1] + appsec + hash := md5.New() + hash.Write([]byte(query)) + (*params)["sign"] = hex.EncodeToString(hash.Sum(nil)) +} + +func map_to_string(params map[string]string) string { + var query string + for k, v := range params { + query += k + "=" + v + "&" + } + query = query[:len(query)-1] + return query +} + +func loginBili() { + fmt.Println("请最大化窗口,以确保二维码完整显示,回车继续") + fmt.Scanf("%s", "") + login_url, auth_code := get_tv_qrcode_url_and_auth_code() + qrcode := qrcodeTerminal.New() + qrcode.Get([]byte(login_url)).Print() + fmt.Println("或将此链接复制到手机B站打开:", login_url) + verify_login(auth_code) +} + +func main() { + loginBili() + fmt.Scanf("%s", "") +} diff --git a/main.py b/main.py new file mode 100644 index 00000000..606308be --- /dev/null +++ b/main.py @@ -0,0 +1,21 @@ + +import asyncio +import yaml +from src import BiliUser + + +async def main(): + initTasks = [] + startTasks = [] + with open('users.yaml', 'r', encoding='utf-8') as f: + users = yaml.load(f, Loader=yaml.FullLoader) + for user in users['USERS']: + if user['access_key']: + biliUser = BiliUser(user['access_key'], user['shared_uid']) + initTasks.append(biliUser.init()) + startTasks.append(biliUser.start()) + await asyncio.gather(*initTasks) + await asyncio.gather(*startTasks) +if __name__ == '__main__': + loop = asyncio.get_event_loop() + loop.run_until_complete(main()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..561a6a43 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +aiohttp >=3.7.4 +loguru >= 0.5.3 \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 00000000..a3f52bdc --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,2 @@ +from .user import BiliUser +from .api import BiliApi diff --git a/src/api.py b/src/api.py new file mode 100644 index 00000000..0b85aad0 --- /dev/null +++ b/src/api.py @@ -0,0 +1,213 @@ + +import asyncio +from hashlib import md5 +import os +import random +import sys +import time +import json +from typing import Union +from urllib.parse import urlencode + + +from aiohttp import ClientSession +sys.path.append(os.path.dirname( + os.path.dirname(os.path.abspath(__file__)))) + + +class Crypto: + + APPKEY = '1d8b6e7d45233436' + APPSECRET = '560c52ccd288fed045859ed18bffd973' + + @staticmethod + def md5(data: Union[str, bytes]) -> str: + '''generates md5 hex dump of `str` or `bytes`''' + if type(data) == str: + return md5(data.encode()).hexdigest() + return md5(data).hexdigest() + + @staticmethod + def sign(data: Union[str, dict]) -> str: + '''salted sign funtion for `dict`(converts to qs then parse) & `str`''' + if isinstance(data, dict): + _str = urlencode(data) + elif type(data) != str: + raise TypeError + return Crypto.md5(_str + Crypto.APPSECRET) + + +class SingableDict(dict): + @property + def sorted(self): + '''returns a alphabetically sorted version of `self`''' + return dict(sorted(self.items())) + + @property + def signed(self): + '''returns our sorted self with calculated `sign` as a new key-value pair at the end''' + _sorted = self.sorted + return {**_sorted, 'sign': Crypto.sign(_sorted)} + + +class BiliApi: + headers = { + "User-Agent": "Mozilla/5.0 BiliDroid/6.73.1 (bbcallen@gmail.com) os/android model/Mi 10 Pro mobi_app/android build/6731100 channel/xiaomi innerVer/6731110 osVer/12 network/2", + } + from .user import BiliUser + + def __init__(self, u: BiliUser, s: ClientSession): + self.u = u + self.session = s + + def __check_response(self, resp: dict) -> dict: + if resp['code'] != 0: + raise Exception(resp['message']) + return resp['data'] + + async def getFansMedalandRoomID(self) -> dict: + ''' + 获取用户粉丝勋章和直播间ID + ''' + url = "http://api.live.bilibili.com/xlive/app-ucenter/v1/fansMedal/panel" + params = { + "access_key": self.u.access_key, + "actionKey": "appkey", + "appkey": Crypto.APPKEY, + "ts": int(time.time()), + "page": 1, + "page_size": 100, + } + first_flag = True + while True: + async with self.session.get(url, params=SingableDict(params).signed, headers=self.headers) as resp: + data = self.__check_response(await resp.json()) + if first_flag and data['special_list']: + for item in data['special_list']: + yield item + first_flag = False + for item in data['list']: + yield item + if not data['page_info']['has_more']: + break + params['page'] += 1 + + async def likeInteract(self, room_id: int): + ''' + 点赞 *3 + ''' + url = "http://api.live.bilibili.com/xlive/web-ucenter/v1/interact/likeInteract" + data = { + "access_key": self.u.access_key, + "actionKey": "appkey", + "appkey": Crypto.APPKEY, + "ts": int(time.time()), + "roomid": room_id, + } + for _ in range(3): + async with self.session.post(url, data=SingableDict(data).signed, headers=self.headers.update({ + "Content-Type": "application/x-www-form-urlencoded", + })) as resp: + self.__check_response(await resp.json()) + await asyncio.sleep(2) + + async def shareRoom(self, room_id: int): + ''' + 分享直播间 + ''' + url = "http://api.live.bilibili.com/xlive/app-room/v1/index/TrigerInteract" + data = { + "access_key": self.u.access_key, + "actionKey": "appkey", + "appkey": Crypto.APPKEY, + "ts": int(time.time()), + "interact_type": 3, + "roomid": room_id, + } + async with self.session.post(url, data=SingableDict(data).signed, headers=self.headers.update({ + "Content-Type": "application/x-www-form-urlencoded", + })) as resp: + self.__check_response(await resp.json()) + + async def sendDanmaku(self, room_id: int) -> str: + ''' + 发送弹幕 + ''' + url = "http://api.live.bilibili.com/xlive/app-room/v1/dM/sendmsg" + danmakus = [ + "(⌒▽⌒).", + "( ̄▽ ̄).", + "(=・ω・=).", + "(`・ω・´).", + "(〜 ̄△ ̄)〜.", + "(・∀・).", + "(°∀°)ノ.", + "( ̄3 ̄).", + "╮( ̄▽ ̄)╭.", + "_(:3」∠)_.", + "(^・ω・^ ).", + "(● ̄(エ) ̄●).", + "ε=ε=(ノ≧∇≦)ノ.", + "⁄(⁄ ⁄•⁄ω⁄•⁄ ⁄)⁄.", + "←◡←.", + ] + params = { + "access_key": self.u.access_key, + "actionKey": "appkey", + "appkey": Crypto.APPKEY, + "ts": int(time.time()), + } + data = { + "cid": room_id, + "msg": random.choice(danmakus), + "rnd": int(time.time()), + "color": "16777215", + "fontsize": "25", + } + async with self.session.post(url, params=SingableDict(params).signed, data=data, headers=self.headers.update({ + "Content-Type": "application/x-www-form-urlencoded", + })) as resp: + return json.loads(self.__check_response(await resp.json())['mode_info']['extra'])['content'] + + async def loginVerift(self): + ''' + 登录验证 + ''' + url = "http://app.bilibili.com/x/v2/account/mine" + params = { + "access_key": self.u.access_key, + "actionKey": "appkey", + "appkey": Crypto.APPKEY, + "ts": int(time.time()), + } + async with self.session.get(url, params=SingableDict(params).signed, headers=self.headers) as resp: + return self.__check_response(await resp.json()) + + async def doSign(self): + ''' + 直播区签到 + ''' + url = "http://api.live.bilibili.com/rc/v1/Sign/doSign" + params = { + "access_key": self.u.access_key, + "actionKey": "appkey", + "appkey": Crypto.APPKEY, + "ts": int(time.time()), + } + async with self.session.get(url, params=SingableDict(params).signed, headers=self.headers) as resp: + return self.__check_response(await resp.json()) + + async def getUserInfo(self): + ''' + 用户直播等级 + ''' + url = "http://api.live.bilibili.com/xlive/app-ucenter/v1/user/get_user_info" + params = { + "access_key": self.u.access_key, + "actionKey": "appkey", + "appkey": Crypto.APPKEY, + "ts": int(time.time()), + } + async with self.session.get(url, params=SingableDict(params).signed, headers=self.headers) as resp: + return self.__check_response(await resp.json()) + \ No newline at end of file diff --git a/src/user.py b/src/user.py new file mode 100644 index 00000000..0446c63d --- /dev/null +++ b/src/user.py @@ -0,0 +1,146 @@ + +from aiohttp import ClientSession +import sys +import os +import asyncio + +from loguru import logger + +sys.path.append(os.path.dirname( + os.path.dirname(os.path.abspath(__file__)))) + +logger.remove() +logger.add(sys.stdout, colorize=True, + format="{time:YYYY-MM-DD HH:mm:ss} {extra[user]} {message}") + + +class BiliUser: + + def __init__(self, access_token: str, needShareUIDs: str = ''): + from .api import BiliApi + + self.access_key = access_token # 登录凭证 + self.needShareUIDs = needShareUIDs # 需要分享的房间ID "1,2,3" + self.medals = [] # 用户所有勋章 + self.medalsLower20 = [] # 用户所有勋章,等级小于20的 + self.medalsNeedShare = [] # 用户所有勋章,需要分享的 最多28个 + + self.session = ClientSession() + self.api = BiliApi(self, self.session) + + async def loginVerify(self) -> bool: + ''' + 登录验证 + ''' + loginInfo = await self.api.loginVerift() + self.mid, self.name = loginInfo['mid'], loginInfo['name'] + self.log = logger.bind(user=self.name) + if loginInfo['mid'] == 0: + self.isLogin = False + return False + self.log.log("SUCCESS", str(loginInfo['mid']) + " 登录成功") + self.isLogin = True + return True + + async def doSign(self): + try: + signInfo = await self.api.doSign() + self.log.log("SUCCESS", "签到成功,本月签到次数: {}/{}".format(signInfo['hadSignDays'], signInfo['allDays'])) + except Exception as e: + self.log.log("ERROR", e) + userInfo = await self.api.getUserInfo() + self.log.log("INFO", "当前用户UL等级: {} ,还差 {} 经验升级".format(userInfo['exp']['user_level'], userInfo['exp']['unext'])) + + async def getMedals(self): + ''' + 获取用户勋章 + ''' + self.medals.clear() + self.medalsLower20.clear() + async for medal in self.api.getFansMedalandRoomID(): + self.medals.append(medal) if medal['room_info']['room_id'] != 0 else None + [self.medalsLower20.append(medal) for medal in self.medals if medal['medal']['level'] < 20] + try: + self.medalsNeedShare = [ + medal for medal in self.medalsLower20 if medal['medal']['target_id'] in + list(map(lambda x: int(x if x else 0), self.needShareUIDs.split(','))) + ] + except ValueError: + self.medalsNeedShare = [] + self.log.log("ERROR", "需要分享的UID错误") + + async def likeInteract(self): + ''' + 点赞 *3 异步执行 + ''' + self.log.log("INFO", "点赞任务开始....(预计20秒完成)") + likeTasks = [self.api.likeInteract(medal['room_info']['room_id']) for medal in self.medalsLower20] + await asyncio.gather(*likeTasks) + await asyncio.sleep(10) + await self.getMedals() # 刷新勋章 + self.log.log("SUCCESS", "点赞任务完成") + msg = "20级以下牌子共 {} 个,完成点赞 {} 个".format(len(self.medalsLower20), len( + [medla for medla in self.medalsLower20 if medla['medal']['today_feed'] >= 600])) + self.log.log("INFO", msg) + + async def shareRoom(self): + ''' + 分享直播间 CD 600s + ''' + medalsNeedShare = self.medalsNeedShare.copy() + if not medalsNeedShare: + self.log.log("WARNING", "没有设置需要分享的直播间") + return + if len(medalsNeedShare) > 28: + medalsNeedShare = medalsNeedShare[:28] + self.log.log("WARNING", "由于B站分享CD为10分钟,所以一天最多只能分享28个直播间") + needTime = len(medalsNeedShare) * 50 - 10 + self.log.log("INFO", "分享任务开始....(设置了 {} 个房间({}),预计{}分钟完成)".format( + len(medalsNeedShare), "、".join([m['anchor_info']['nick_name'] for m in medalsNeedShare]), needTime)) + for index, medal in enumerate(medalsNeedShare): + if medal['medal']['level'] >= 20: + continue + for i in range(1, 6): + await self.api.shareRoom(medal['room_info']['room_id']) + self.log.log("SUCCESS", "{} 分享成功 {} 次 (还需 {} 分钟完成)".format( + medal['anchor_info']['nick_name'], i, needTime)) + if i == 5: + break + needTime -= 10 + await asyncio.sleep(600) + if index == len(medalsNeedShare) - 1: + break + await asyncio.sleep(600) + self.log.log("SUCCESS", "分享任务完成") + + async def sendDanmaku(self): + ''' + 每日弹幕打卡 + ''' + self.log.log("INFO", "弹幕打卡任务开始....(预计 {} 秒完成)".format(len(self.medals) * 6)) + n = 0 + for medal in self.medals: + try: + danmaku = await self.api.sendDanmaku(medal['room_info']['room_id']) + n += 1 + self.log.log( + "DEBUG", "{} 房间弹幕打卡成功: {} ({}/{})".format(medal['anchor_info']['nick_name'], danmaku, n, len(self.medals))) + except Exception as e: + self.log.log("ERROR", "{} 房间弹幕打卡失败: {}".format(medal['anchor_info']['nick_name'], e)) + finally: + await asyncio.sleep(6) + self.log.log("SUCCESS", "弹幕打卡任务完成") + + async def init(self): + if not await self.loginVerify(): + self.log.log("ERROR", "登录失败") + await self.session.close() + else: + await self.doSign() + await self.getMedals() + + async def start(self): + if self.isLogin: + task = [self.likeInteract(), self.shareRoom(), self.sendDanmaku()] + await asyncio.wait(task) + await self.session.close() diff --git a/users.yaml b/users.yaml new file mode 100644 index 00000000..3420d879 --- /dev/null +++ b/users.yaml @@ -0,0 +1,8 @@ +USERS: + - access_key: XXXXXX # 注意冒号后的空格 否则会读取失败 + shared_uid: 123,456 # 需要分享房间的房主UID!不是房间号! 多个用逗号分隔,最多28个 + + - access_key: + shared_uid: + # 多用户以上格式添加 + # 井号后为注释