Files
biliup/engine/plugins/upload/bili_web.py
T
2021-02-03 20:40:44 +08:00

493 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import base64
import hashlib
import json
import math
import os
import sys
import time
from dataclasses import dataclass, field, InitVar, asdict
from json import JSONDecodeError
from os.path import basename, splitext
from typing import Any, Union
from urllib import parse
from urllib.parse import quote
import aiohttp
import requests
import rsa
from requests import utils
from requests.adapters import HTTPAdapter, Retry
import engine
from common.decorators import Plugin
from engine.plugins.upload import UploadBase, logger
@Plugin.upload(platform="bili_web")
class BiliWeb(UploadBase):
def __init__(self, principal, data, user, lines='AUTO', threads=3, tid=174, tags=None, cover_path=None):
super().__init__(principal, data, persistence_path='engine/bili.cookie')
if tags is None:
tags = ['星际争霸2', '电子竞技']
self.user = user
self.lines = lines
self.threads = threads
self.tid = tid
self.tags = tags
self.cover_path = cover_path
def upload(self, file_list):
video = Data()
with BiliBili(video) as bili:
bili.login(self.persistence_path, self.user)
for file in file_list:
video_part = bili.upload_file(file, self.lines, self.threads) # 上传视频
video.videos.append(video_part) # 添加已经上传的视频
video.title = self.data["format_title"]
video.desc = '''这个自动录制上传的小程序开源在Github:http://t.cn/RgapTpf(或者在Github搜索ForgQi)
交流群:837362626'''
video.source = self.data["url"] # 添加转载地址说明
# 设置视频分区,默认为174 生活,其他分区
video.tid = self.tid
video.set_tag(self.tags)
if self.cover_path:
video.cover = bili.cover_up(self.cover_path).replace('http:', '')
ret = bili.submit() # 提交视频
logger.info(f"上传成功: {ret}")
self.remove_filelist(file_list)
class BiliBili:
def __init__(self, video: 'Data'):
# self.app_key = 'bca7e84c2d947ac6'
self.app_key = 'aae92bc66f3edfab'
self.__session = requests.Session()
self.video = video
self.__session.mount('https://', HTTPAdapter(max_retries=Retry(total=5, method_whitelist=False)))
self.__session.headers.update({
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/63.0.3239.108",
"Referer": "https://www.bilibili.com/", 'Connection': 'keep-alive'
})
self.cookies = None
self.access_token = None
self.refresh_token = None
self.__bili_jct = None
self._auto_os = None
self.persistence_path = 'engine/bili.cookie'
def login(self, persistence_path, user):
self.persistence_path = persistence_path
if os.path.isfile(persistence_path):
print('使用持久化内容上传')
self.load()
if not self.cookies and user.get('cookies'):
self.cookies = user['cookies']
if self.cookies:
try:
self.login_by_cookies(self.cookies)
except:
logger.exception('login error')
self.login_by_password(**user['account'])
else:
self.login_by_password(**user['account'])
self.store()
def load(self):
try:
with open(self.persistence_path) as f:
self.cookies = json.load(f)
self.access_token = self.cookies['access_token']
except (JSONDecodeError, KeyError):
logger.exception('加载cookie出错')
def store(self):
with open(self.persistence_path, "w") as f:
json.dump({**self.cookies,
'access_token': self.access_token,
'refresh_token': self.refresh_token
}, f)
def login_by_password(self, username, password):
print('使用账号上传')
key_hash, pub_key = self.get_key()
encrypt_password = base64.b64encode(rsa.encrypt(f'{key_hash}{password}'.encode(), pub_key))
payload = {
"actionKey": 'appkey',
"appkey": self.app_key,
"build": 6040500,
"captcha": '',
"challenge": '',
"channel": 'bili',
"device": 'phone',
"mobi_app": 'android',
"password": encrypt_password,
"permission": 'ALL',
"platform": 'android',
"seccode": "",
"subid": 1,
"ts": int(time.time()),
"username": username,
"validate": "",
}
response = self.__session.post("https://passport.bilibili.com/api/v3/oauth2/login", timeout=5,
data={**payload, 'sign': self.sign(parse.urlencode(payload))})
r = response.json()
if r['code'] != 0 or r.get('data') is None or r['data'].get('cookie_info') is None:
raise RuntimeError(r)
for cookie in r['data']['cookie_info']['cookies']:
self.__session.cookies.set(cookie['name'], cookie['value'])
if 'bili_jct' == cookie['name']:
self.__bili_jct = cookie['value']
self.cookies = self.__session.cookies.get_dict()
self.access_token = r['data']['token_info']['access_token']
self.refresh_token = r['data']['token_info']['refresh_token']
return r
def login_by_cookies(self, cookie):
print('使用cookies上传')
requests.utils.add_dict_to_cookiejar(self.__session.cookies, cookie)
if 'bili_jct' in cookie:
self.__bili_jct = cookie["bili_jct"]
data = self.__session.get("https://api.bilibili.com/x/web-interface/nav", timeout=5).json()
if data["code"] != 0:
raise Exception(data)
@staticmethod
def sign(param):
# salt = '60698ba2f68e01ce44738920a0ffe768'
salt = 'af125a0d5279fd576c1b4418a3e8276d'
return hashlib.md5(f"{param}{salt}".encode()).hexdigest()
def get_key(self):
url = "https://passport.bilibili.com/api/oauth2/getKey"
payload = {
'appkey': f'{self.app_key}',
'sign': self.sign(f"appkey={self.app_key}"),
}
response = self.__session.post(url, data=payload, timeout=5)
r = response.json()
if r and r["code"] == 0:
return r['data']['hash'], rsa.PublicKey.load_pkcs1_openssl_pem(r['data']['key'].encode())
def probe(self):
ret = self.__session.get('https://member.bilibili.com/preupload?r=probe', timeout=5).json()
logger.info(f"线路:{ret['lines']}")
data, auto_os = None, None
min_cost = 0
if ret['probe'].get('get'):
method = 'get'
else:
method = 'post'
data = bytes(int(1024 * 0.1 * 1024))
for line in ret['lines']:
start = time.perf_counter()
test = self.__session.request(method, f"https:{line['probe_url']}", data=data, timeout=30)
cost = time.perf_counter() - start
print(line['query'], cost)
if test.status_code != 200:
return
if not min_cost or min_cost > cost:
auto_os = line
min_cost = cost
auto_os['cost'] = min_cost
return auto_os
def upload_file(self, filepath: str, lines='AUTO', tasks=3):
"""上传本地视频文件,返回视频信息dict
b站目前支持4种上传线路upos, kodo, gcs, bos
gcs: {"os":"gcs","query":"bucket=bvcupcdngcsus&probe_version=20200810",
"probe_url":"//storage.googleapis.com/bvcupcdngcsus/OK"},
bos: {"os":"bos","query":"bucket=bvcupcdnboshb&probe_version=20200810",
"probe_url":"??"}
"""
if not self._auto_os:
self._auto_os = self.probe()
if lines == 'kodo':
self._auto_os = {"os": "kodo", "query": "bucket=bvcupcdnkodobm&probe_version=20200810",
"probe_url": "//up-na0.qbox.me/crossdomain.xml"}
if lines == 'bda2':
self._auto_os = {"os": "upos", "query": "upcdn=bda2&probe_version=20200810",
"probe_url": "//upos-sz-upcdnbda2.bilivideo.com/OK"}
if lines == 'ws':
self._auto_os = {"os": "upos", "query": "upcdn=ws&probe_version=20200810",
"probe_url": "//upos-sz-upcdnws.bilivideo.com/OK"}
if lines == 'qn':
self._auto_os = {"os": "upos", "query": "upcdn=qn&probe_version=20200810",
"probe_url": "//upos-sz-upcdnqn.bilivideo.com/OK"}
logger.info(f"线路选择{self._auto_os['os']}: {self._auto_os['query']}. time: {self._auto_os.get('cost')}")
if self._auto_os['os'] == 'upos':
upload = self.upos
elif self._auto_os['os'] == 'kodo':
upload = self.kodo
elif self._auto_os['os'] == "gcs":
raise NotImplementedError('gcs')
elif self._auto_os['os'] == "bos":
raise NotImplementedError('bos')
else:
logger.error(f"NoSearch:{self._auto_os['os']}")
raise NotImplementedError(self._auto_os['os'])
total_size = os.path.getsize(filepath)
with open(filepath, 'rb') as f:
query = {
'r': self._auto_os['os'],
'profile': 'ugcupos/bup' if 'upos' == self._auto_os['os'] else "ugcupos/bupfetch",
'ssl': 0,
'version': '2.8.12',
'build': 2081200,
'name': f.name,
'size': total_size,
}
ret = self.__session.get(
f"https://member.bilibili.com/preupload?{self._auto_os['query']}", params=query,
timeout=5)
return asyncio.run(upload(f, total_size, ret.json(), tasks=tasks))
async def kodo(self, file, total_size, ret, chunk_size=4194304, tasks=3):
filename = file.name
bili_filename = ret['bili_filename']
key = ret['key']
endpoint = f"https:{ret['endpoint']}"
token = ret['uptoken']
fetch_url = ret['fetch_url']
fetch_headers = ret['fetch_headers']
url = f'{endpoint}/mkblk'
headers = {
'Authorization': f"UpToken {token}",
}
# 开始上传
parts = [] # 分块信息
chunks = math.ceil(total_size / chunk_size) # 获取分块数量
async def upload_chunk(session, chunks_data, params):
async with session.post(f'{url}/{len(chunks_data)}',
data=chunks_data, headers=headers) as response:
end = time.perf_counter() - start
ctx = await response.json()
parts.append({"index": params['chunk'], "ctx": ctx['ctx']})
sys.stdout.write(f"\r{params['end'] / 1000 / 1000 / end:.2f}MB/s "
f"=> {params['partNumber'] / chunks:.1%}")
start = time.perf_counter()
await self._upload({}, file, chunk_size, upload_chunk, tasks=tasks)
cost = time.perf_counter() - start
logger.info(f'{filename} uploaded >> {total_size / 1000 / 1000 / cost:.2f}MB/s')
parts.sort(key=lambda x: x['index'])
self.__session.post(f"{endpoint}/mkfile/{total_size}/key/{base64.urlsafe_b64encode(key.encode()).decode()}",
data=','.join(map(lambda x: x['ctx'], parts)), headers=headers, timeout=10)
r = self.__session.post(f"https:{fetch_url}", headers=fetch_headers, timeout=5).json()
if r["OK"] != 1:
raise Exception(r)
return {"title": splitext(filename)[0], "filename": bili_filename, "desc": ""}
async def upos(self, file, total_size, ret, tasks=3):
filename = file.name
chunk_size = ret['chunk_size']
auth = ret["auth"]
endpoint = ret["endpoint"]
biz_id = ret["biz_id"]
upos_uri = ret["upos_uri"]
url = f"https:{endpoint}/{upos_uri.replace('upos://', '')}" # 视频上传路径
headers = {
"X-Upos-Auth": auth
}
# 向上传地址申请上传,得到上传id等信息
upload_id = self.__session.post(f'{url}?uploads&output=json', timeout=5,
headers=headers).json()["upload_id"]
# 开始上传
parts = [] # 分块信息
chunks = math.ceil(total_size / chunk_size) # 获取分块数量
async def upload_chunk(session, chunks_data, params):
async with session.put(url, params=params, raise_for_status=True,
data=chunks_data, headers=headers):
end = time.perf_counter() - start
parts.append({"partNumber": params['chunk'] + 1, "eTag": "etag"})
sys.stdout.write(f"\r{params['end'] / 1000 / 1000 / end:.2f}MB/s "
f"=> {params['partNumber'] / chunks:.1%}")
start = time.perf_counter()
await self._upload({
'uploadId': upload_id,
'chunks': chunks,
'total': total_size
}, file, chunk_size, upload_chunk, tasks=tasks)
cost = time.perf_counter() - start
p = {
'name': filename,
'uploadId': upload_id,
'biz_id': biz_id,
'output': 'json',
'profile': 'ugcupos/bup'
}
r = self.__session.post(url, params=p, json={"parts": parts}, headers=headers, timeout=15).json()
logger.info(f'{filename} uploaded >> {total_size / 1000 / 1000 / cost:.2f}MB/s. {r}')
if r.get('OK') != 1:
raise Exception(r)
return {"title": splitext(filename)[0], "filename": splitext(basename(upos_uri))[0], "desc": ""}
@staticmethod
async def _upload(params, file, chunk_size, afunc, tasks=3):
params['chunk'] = -1
async def upload_chunk():
while True:
chunks_data = file.read(chunk_size)
if not chunks_data:
return
params['chunk'] += 1
params['size'] = len(chunks_data)
params['partNumber'] = params['chunk'] + 1
params['start'] = params['chunk'] * chunk_size
params['end'] = params['start'] + params['size']
clone = params.copy()
for i in range(10):
try:
await afunc(session, chunks_data, clone)
break
except (asyncio.TimeoutError, aiohttp.ClientError) as e:
logger.error(f"retry chunk{clone['chunk']} >> {i+1}. {e}")
async with aiohttp.ClientSession() as session:
await asyncio.gather(*[upload_chunk() for _ in range(tasks)])
def submit(self):
if not self.video.title:
self.video.title = self.video.videos[0]["title"]
self.__session.get('https://member.bilibili.com/x/geetest/pre/add', timeout=5)
myinfo = self.__session.get('https://member.bilibili.com/x/web/archive/pre?lang=cn',
timeout=15).json()['data']['myinfo']
myinfo['total_info'] = self.__session.get('https://member.bilibili.com/x/web/index/stat',
timeout=15).json()['data']
user_weight = 2 if myinfo['level'] > 3 \
and myinfo['total_info'] and myinfo['total_info']['total_fans'] > 100 else 1
if user_weight == 2:
logger.info(f'用户权重: {user_weight} => 网页端分p数量不受限制使用网页端api提交')
ret = self.__session.post(f'https://member.bilibili.com/x/vu/web/add?csrf={self.__bili_jct}', timeout=5,
json=asdict(self.video)).json()
if ret["code"] == 0:
return ret
elif ret["code"] == 21138:
logger.info(f'改用客户端接口提交{ret}')
else:
raise Exception(ret)
logger.info(f'用户权重: {user_weight} => 网页端分p数量受到限制使用客户端api端提交')
if not self.access_token:
self.login_by_password(**engine.config['user']['account'])
self.store()
while True:
ret = self.__session.post(f'http://member.bilibili.com/x/vu/client/add?access_key={self.access_token}',
timeout=5, json=asdict(self.video)).json()
if ret['code'] == -101:
logger.info(f'刷新token{ret}')
self.login_by_password(**engine.config['user']['account'])
self.store()
continue
break
if ret["code"] == 0:
return ret
else:
raise Exception(ret)
def cover_up(self, img: str):
"""
:param img: img path or stream
:return: img URL
"""
from PIL import Image
from io import BytesIO
with Image.open(img) as im:
# 宽和高,需要16:10
xsize, ysize = im.size
if xsize / ysize > 1.6:
delta = xsize - ysize * 1.6
region = im.crop((delta / 2, 0, xsize - delta / 2, ysize))
else:
delta = ysize - xsize * 10 / 16
region = im.crop((0, delta / 2, xsize, ysize - delta / 2))
buffered = BytesIO()
region.save(buffered, format=im.format)
r = self.__session.post(
url='https://member.bilibili.com/x/vu/web/cover/up',
data={
'cover': b'data:image/jpeg;base64,' + (base64.b64encode(buffered.getvalue())),
'csrf': self.__bili_jct
}, timeout=30
)
buffered.close()
return r.json()['data']['url']
def get_tags(self, upvideo, typeid="", desc="", cover="", groupid=1, vfea=""):
"""
上传视频后获得推荐标签
:param vfea:
:param groupid:
:param typeid:
:param desc:
:param cover:
:param upvideo:
:return: 返回官方推荐的tag
"""
url = f'https://member.bilibili.com/x/web/archive/tags?' \
f'typeid={typeid}&title={quote(upvideo["title"])}&filename=filename&desc={desc}&cover={cover}' \
f'&groupid={groupid}&vfea={vfea}'
return self.__session.get(url=url, timeout=5).json()
def __enter__(self):
return self
def __exit__(self, e_t, e_v, t_b):
self.close()
def close(self):
"""Closes all adapters and as such the session"""
self.__session.close()
@dataclass
class Data:
"""
cover: 封面图片,可由recovers方法得到视频的帧截图
tid: 分区,174为生活,其他分区
"""
copyright: int = 2
source: str = ''
tid: int = 174
cover: str = ''
title: str = ''
desc_format_id: int = 0
desc: str = ''
dynamic: str = ''
subtitle: dict = field(init=False)
tag: Union[list, str] = ''
videos: list = field(default_factory=list)
dtime: Any = None
open_subtitle: InitVar[bool] = False
# interactive: int = 0
# no_reprint: int 1
# open_elec: int 1
def __post_init__(self, open_subtitle):
self.subtitle = {"open": int(open_subtitle), "lan": ""}
if self.dtime and self.dtime - int(time.time()) <= 14400:
self.dtime = None
if isinstance(self.tag, list):
self.dynamic = f"#{'##'.join(self.tag)}#"
self.tag = ','.join(self.tag)
def delay_time(self, dtime: int):
"""设置延时发布时间,距离提交大于4小时,格式为10位时间戳"""
if dtime - int(time.time()) > 14400:
self.dtime = dtime
def set_tag(self, tag: list):
"""设置标签,tag为数组"""
self.dynamic = f"#{'##'.join(tag)}#"
self.tag = ','.join(tag)