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:
+ # 多用户以上格式添加
+ # 井号后为注释