Refactor Python package and simplify entry

Remove legacy engine and many plugin modules, refactor upload plugins to standalone classes, and simplify package entry points. __init__.py now provides a safe __version__ fallback; __main__.py directly invokes stream_gears.main_loop. BiliWeb/BiliWebAsync plugins were adapted to use a FileInfo NamedTuple and no longer depend on engine.UploadBase/Plugin decorator. Documentation updated (README expanded with new commands and CLI/SKILL integration) and a new SKILL.md added. Workspace version bumped to 1.2.1 in Cargo.toml (lock updated accordingly).
This commit is contained in:
ForgQi committed 2026-06-06 03:22:54 +08:00
1 parent 431d1f61ab
commit b5ddff0564
26 files changed
+480 -1064

No files matched your search

Generated
+4 -4
View File
@@ -387,7 +387,7 @@ checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a"
[[package]]
name = "biliup"
version = "1.2.0"
version = "1.2.1"
dependencies = [
"async-trait",
"base64",
@@ -424,7 +424,7 @@ dependencies = [
[[package]]
name = "biliup-cli"
version = "1.2.0"
version = "1.2.1"
dependencies = [
"async-channel",
"async-trait",
@@ -978,7 +978,7 @@ dependencies = [
[[package]]
name = "danmaku"
version = "1.2.0"
version = "1.2.1"
dependencies = [
"async-trait",
"base64",
@@ -3969,7 +3969,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "stream-gears"
version = "1.2.0"
version = "1.2.1"
dependencies = [
"async-channel",
"async-trait",
+1 -1
View File
@@ -8,7 +8,7 @@ members = [
resolver = "3"
[workspace.package]
version = "1.2.0"
version = "1.2.1"
[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
+51 -29
View File
@@ -56,6 +56,8 @@ Commands:
upload 上传视频
append 是否要对某稿件追加视频
show 打印视频详情
comments 查看视频评论
reply 回复视频评论,默认只打印将要回复的内容
dump-flv 输出flv元数据
download 下载视频
server 启动web服务,默认端口19159
@@ -76,12 +78,31 @@ Options:
Usage: biliup server [OPTIONS]
Options:
-b, --bind <BIND> Specify bind address [default: 0.0.0.0]
-p, --port <PORT> Port to use [default: 19159]
--auth 开启登录密码认证
-h, --help Print help
-b, --bind <BIND> Specify bind address [default: 0.0.0.0]
-p, --port <PORT> Port to use [default: 19159]
--auth 开启登录密码认证
-c, --config <FILE> 使用 biliup 1.0.7 风格配置文件启动录制
-h, --help Print help
```
### 命令行工具 / Skill
`biliup` 可以作为自动化流程中的命令行工具封装使用。常用调用方式:
```shell
# 启动 Web 服务
biliup server --bind 0.0.0.0 --port 19159 --auth
# 使用 1.0.7 风格配置文件启动录制
biliup server --config config.toml
# 查看命令帮助
biliup --help
biliup server --help
```
通过 `npx skills add biliup/biliup` 可为 Agent 添加 `biliup` 命令行能力。添加后可直接让 Agent 执行 Web 服务启动、配置文件启动、命令帮助查看以及常用上传/下载命令。
- [使用文档 »](https://docs.biliup.rs)
## 🚀 快速开始
@@ -108,55 +129,56 @@ Options:
### 架构概览
Rust后端 + Python引擎 + Next.js前端的混合架构。
Rust后端 + 精简 Python 包 + Next.js前端的混合架构。
```mermaid
graph TB
subgraph "🌐 前端层"
UI[Next.js Web界面<br/>React + TypeScript<br/>Semi UI组件库]
end
subgraph "⚡ Rust后端服务"
CLI[Web API服务器<br/>biliup-cli<br/>用户认证 & REST API]
CORE[核心上传库<br/>biliup<br/>Bilibili API客户端]
GEARS[Python绑定<br/>stream-gears<br/>性能优化桥接]
CLI[命令行与 Web API<br/>biliup-cli<br/>REST API / WebUI / 配置导入]
CORE[核心库<br/>biliup<br/>直播解析 / 下载 / 上传]
DANMAKU[弹幕库<br/>danmaku<br/>多平台协议 / XML输出]
GEARS[Python绑定<br/>stream-gears<br/>python -m biliup 入口]
end
subgraph "🐍 Python引擎"
ENGINE[下载引擎<br/>biliup<br/>任务调度 & 流处理]
PLUGINS[插件系统<br/>20+平台支持<br/>斗鱼/虎牙/Twitch等]
DANMAKU[弹幕系统<br/>实时弹幕获取<br/>多平台协议支持]
subgraph "🐍 Python包"
PYENTRY[最小入口<br/>biliup.__main__<br/>调用 stream_gears.main_loop]
PYUPLOAD[投稿库<br/>bili_webup / bili_webup_sync<br/>供外部项目调用]
end
subgraph "🗄️ 数据层"
DB[(SQLite数据库<br/>配置存储<br/>任务状态 & 日志)]
FILES[文件系统<br/>临时视频存储<br/>缓存管理]
FILES[文件系统<br/>视频分段 / 弹幕XML<br/>缓存与临时文件]
end
subgraph "🌍 外部服务"
BILI[Bilibili API<br/>视频上传服务]
STREAMS[直播平台<br/>斗鱼/虎牙/B站等<br/>实时流媒体]
STREAMS[直播平台<br/>B站/斗鱼/虎牙/抖音/Twitch等]
end
UI --> CLI
CLI --> CORE
CLI --> ENGINE
CLI --> DANMAKU
CLI --> DB
GEARS --> ENGINE
ENGINE --> PLUGINS
ENGINE --> DANMAKU
ENGINE --> FILES
CLI --> FILES
CORE --> STREAMS
CORE --> BILI
PLUGINS --> STREAMS
DANMAKU --> STREAMS
DANMAKU --> FILES
GEARS --> CLI
PYENTRY --> GEARS
PYUPLOAD --> BILI
style UI fill:#e1f5fe
style CLI fill:#f3e5f5
style CORE fill:#f3e5f5
style DANMAKU fill:#f3e5f5
style GEARS fill:#f3e5f5
style ENGINE fill:#e8f5e8
style PLUGINS fill:#e8f5e8
style DANMAKU fill:#e8f5e8
style PYENTRY fill:#e8f5e8
style PYUPLOAD fill:#e8f5e8
style DB fill:#fff3e0
style FILES fill:#fff3e0
style BILI fill:#ffebee
+189
View File
@@ -0,0 +1,189 @@
---
name: biliup
description: Use the biliup command-line tool to start the WebUI server, run recording tasks from config files, inspect help, and operate common upload/download commands.
---
# biliup
Use this skill when the user wants to install or operate the `biliup` command-line tool.
`biliup` can start the WebUI server, run recording tasks from config files, log in, upload videos, append videos, inspect video information, download videos, and list uploaded videos.
## Install flow
When the user needs to install `biliup`, choose one installation path based on the user's operating system and preference.
### GitHub Releases prebuilt package
Use this path when the user wants to install a prebuilt binary from GitHub Releases.
For Linux or macOS, generate and run this script. Set `INSTALL_DIR` to the user's target directory when needed.
```bash
set -euo pipefail
REPO="biliup/biliup"
INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}"
OS="$(uname -s)"
ARCH="$(uname -m)"
case "$OS" in
Linux) os="linux" ;;
Darwin) os="macos" ;;
*) echo "Unsupported OS: $OS" >&2; exit 1 ;;
esac
case "$ARCH" in
x86_64|amd64) arch="x86_64" ;;
aarch64|arm64) arch="aarch64" ;;
armv7l|armv7*) arch="arm" ;;
*) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;;
esac
asset="$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \
| python3 -c 'import json,sys; data=json.load(sys.stdin); os=sys.argv[1]; arch=sys.argv[2]; assets=[a["name"] for a in data["assets"]]; matches=[n for n in assets if n.endswith(".tar.xz") and f"-{arch}-{os}.tar.xz" in n]; print(matches[0] if matches else "")' "$os" "$arch")"
if [ -z "$asset" ]; then
echo "No matching biliup release asset for ${arch}-${os}" >&2
exit 1
fi
url="https://github.com/${REPO}/releases/latest/download/${asset}"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
curl -fL "$url" -o "$tmp/$asset"
tar -xJf "$tmp/$asset" -C "$tmp"
mkdir -p "$INSTALL_DIR"
install -m 755 "$(find "$tmp" -type f -name biliup | head -n 1)" "$INSTALL_DIR/biliup"
"$INSTALL_DIR/biliup" --help
```
For Windows PowerShell, generate and run this script. Set `$InstallDir` to the user's target directory when needed.
```powershell
$ErrorActionPreference = "Stop"
$Repo = "biliup/biliup"
$InstallDir = if ($env:INSTALL_DIR) { $env:INSTALL_DIR } else { Join-Path $env:USERPROFILE "bin" }
$Arch = if ([Environment]::Is64BitOperatingSystem) { "x86_64" } else { throw "Unsupported Windows architecture" }
$Release = Invoke-RestMethod "https://api.github.com/repos/$Repo/releases/latest"
$Asset = $Release.assets | Where-Object { $_.name -like "biliupR-v*-$Arch-windows.zip" } | Select-Object -First 1
if (-not $Asset) { throw "No matching biliup release asset for $Arch-windows" }
$Tmp = New-Item -ItemType Directory -Force (Join-Path ([IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString()))
try {
$Zip = Join-Path $Tmp.FullName $Asset.name
Invoke-WebRequest $Asset.browser_download_url -OutFile $Zip
Expand-Archive $Zip -DestinationPath $Tmp.FullName -Force
New-Item -ItemType Directory -Force $InstallDir | Out-Null
$Exe = Get-ChildItem $Tmp.FullName -Recurse -Filter biliup.exe | Select-Object -First 1
if (-not $Exe) { throw "biliup.exe not found in release archive" }
Copy-Item $Exe.FullName (Join-Path $InstallDir "biliup.exe") -Force
& (Join-Path $InstallDir "biliup.exe") --help
} finally {
Remove-Item $Tmp.FullName -Recurse -Force
}
```
If the target directory is not on `PATH`, tell the user to run `biliup` by its full path or add the target directory to `PATH`.
### Windows winget
Use this path when the user is on Windows and wants to install from the command line:
```bash
winget install biliup
```
Then verify:
```bash
biliup --help
```
### Linux or macOS uv
Use this path when the user is on Linux or macOS and has `uv` available:
```bash
uv tool install biliup
```
Then verify:
```bash
biliup --help
```
## Operation flow
When the user asks to start the WebUI server with authentication, use:
```bash
biliup server --auth
```
When the user provides an address or port, include them explicitly:
```bash
biliup server --bind 0.0.0.0 --port 19159 --auth
```
When the user asks to run from a biliup 1.0.7 style config file, use:
```bash
biliup server --config config.toml
```
Replace `config.toml` with the user's config file path.
When the user wants to run the server in the background on Linux or macOS, use:
```bash
nohup biliup server --auth &
```
## Help flow
When the user asks what commands are available, show:
```bash
biliup --help
```
When the user asks about server options, show:
```bash
biliup server --help
```
When the user asks about a specific command, use:
```bash
biliup <command> --help
```
Replace `<command>` with the requested subcommand.
## Commands
The CLI includes these subcommands:
```text
login
renew
upload
append
show
comments
reply
dump-flv
download
server
list
```
When generating a command, include the concrete paths required by that command, such as cookie files, config files, and video file paths.
+5 -54
View File
@@ -1,55 +1,6 @@
import logging
import sys
from importlib.metadata import version
from importlib.metadata import PackageNotFoundError, version
__version__ = version("biliup")
LOG_CONF = {
'version': 1,
'formatters': {
'verbose': {
'format': "%(asctime)s %(filename)s[line:%(lineno)d](Pid:%(process)d "
"Tname:%(threadName)s) %(levelname)s %(message)s",
# 'datefmt': "%Y-%m-%d %H:%M:%S"
},
'simple': {
'format': '%(asctime)s %(filename)s%(lineno)d[%(levelname)s]Tname:%(threadName)s %(message)s'
},
},
'handlers': {
'console': {
'level': logging.DEBUG,
'class': 'logging.StreamHandler',
'stream': sys.stdout,
'formatter': 'simple'
},
'file': {
'level': logging.DEBUG,
'class': 'logging.handlers.TimedRotatingFileHandler',
'when': 'W0',
'interval': 1,
'backupCount': 1,
'filename': 'ds_update.log',
'formatter': 'verbose',
'encoding': 'utf-8'
}
},
'root': {
'handlers': ['console'],
'level': logging.INFO,
},
'loggers': {
'biliup': {
'handlers': ['file'],
'level': logging.INFO,
},
}
}
IS_FROZEN = False
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
import multiprocessing
multiprocessing.freeze_support()
IS_FROZEN = True
print('running in a PyInstaller bundle')
try:
__version__ = version("biliup")
except PackageNotFoundError:
__version__ = "0.0.0"
+4 -26
View File
@@ -1,33 +1,11 @@
#!/usr/bin/python3
# coding:utf8
import asyncio
import logging
import logging.config
import shutil
import stream_gears
from biliup import LOG_CONF
main = stream_gears.main_loop
class DebugLevelFilter(logging.Filter):
def filter(self, record):
return logging.getLogger().isEnabledFor(logging.DEBUG)
def arg_parser():
logging.config.dictConfig(LOG_CONF)
logging.getLogger('httpx').addFilter(DebugLevelFilter())
asyncio.run(main())
async def main():
# 启动时删除临时文件夹
shutil.rmtree('./cache/temp', ignore_errors=True)
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, stream_gears.main_loop)
if __name__ == '__main__':
arg_parser()
if __name__ == "__main__":
main()
-15
View File
@@ -1,15 +0,0 @@
from .decorators import Plugin
def invert_dict(d: dict):
inverse_dict = {}
for k, v in d.items():
if isinstance(v, list):
for item in v:
inverse_dict[item] = k
else:
inverse_dict[v] = k
return inverse_dict
__all__ = ['invert_dict', 'Plugin']
-40
View File
@@ -1,40 +0,0 @@
import functools
import importlib
import pkgutil
class Plugin:
upload_plugins = {}
def __init__(self, pkg):
self.load_plugins(pkg)
@staticmethod
def upload(platform):
def decorator(cls):
@functools.wraps(cls)
def wrapper(*args, **kw):
print(f"args {args}")
print(f"kw {kw}")
return cls(*args, **kw)
Plugin.upload_plugins[platform] = wrapper
return wrapper
return decorator
def load_plugins(self, pkg):
"""Attempt to load plugins from the path specified.
engine.plugins.__path__[0]: full path to a directory where to look for plugins
"""
plugins = []
for loader, name, ispkg in pkgutil.iter_modules([pkg.__path__[0]]):
module_name = f"{pkg.__name__}.{name}"
module = importlib.import_module(module_name)
if ispkg:
self.load_plugins(module)
continue
if module in plugins:
continue
plugins.append(module)
return plugins
-21
View File
@@ -1,21 +0,0 @@
import logging
from typing import NamedTuple, Optional, List
logger = logging.getLogger('biliup')
class UploadBase:
class FileInfo(NamedTuple):
video: str
danmaku: Optional[str]
def __init__(self, principal, data, persistence_path=None, postprocessor=None):
self.principal = principal
self.persistence_path = persistence_path
self.data: dict = data
self.post_processor = postprocessor
def upload(self, file_list: List[FileInfo]) -> List[FileInfo]:
raise NotImplementedError()
+1 -156
View File
@@ -1,156 +1 @@
import logging
import html
import re
import hashlib
import time
import json
import uuid
from urllib.parse import urlencode, quote
from typing import (
Any,
Callable,
Dict,
List,
Optional,
Tuple,
Union,
)
logger = logging.getLogger('biliup')
_DECODER = json.JSONDecoder()
def match1(text, *patterns):
if len(patterns) == 1:
pattern = patterns[0]
match = re.search(pattern, text)
if match:
return match.group(1)
else:
return None
else:
ret = []
for pattern in patterns:
match = re.search(pattern, text)
if match:
ret.append(match.group(1))
return ret
def random_user_agent(device: str = 'desktop') -> str:
import random
chrome_version = random.randint(100, 120)
if device == 'mobile':
android_version = random.randint(9, 14)
mobile = random.choice([
'SM-G981B', 'SM-G9910', 'SM-S9080', 'SM-S9110', 'SM-S921B',
'Pixel 5', 'Pixel 6', 'Pixel 7', 'Pixel 7 Pro', 'Pixel 8',
])
return f'Mozilla/5.0 (Linux; Android {android_version}; {mobile}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{chrome_version}.0.0.0 Mobile Safari/537.36'
return f'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{chrome_version}.0.0.0 Safari/537.36'
def deep_unescape(obj: Any) -> Any:
if isinstance(obj, dict):
return {k: deep_unescape(v) for k, v in obj.items()}
if isinstance(obj, list):
return [deep_unescape(i) for i in obj]
if isinstance(obj, str):
return html.unescape(obj)
return obj
def json_loads(text: Union[str, None]) -> Dict[str, Any]:
if not text:
raise ValueError("Invalid JSON: None or Empty")
try:
data, _ = _DECODER.raw_decode(text)
except (json.JSONDecodeError, TypeError) as e:
logger.debug(f"Invalid JSON at position {getattr(e, 'pos', 'unknown')}, raw text: {text}")
else:
return data
try:
return json.loads(html.unescape(text))
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON: {text}") from e
def test_jsengine():
try:
import jsengine
jsengine.jsengine()
except Exception as e:
logger.exception(f"jsengine test failed: {e}")
return False
return True
def generate_fake_buvid3():
u = uuid.uuid4()
u_str = str(u).upper().replace('-', '')
formatted = f"{u_str[0:8]}-{u_str[8:12]}-{u_str[12:16]}-{u_str[16:20]}-{u_str[20:]}infoc"
return formatted
class SubmitOption:
App = "app"
BCutAndroid = "b-cut-android"
class Wbi:
WTS = "wts"
W_RID = "w_rid"
UPDATE_INTERVAL = 2 * 60 * 60
KEY_MAP = [
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35,
27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13,
37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4,
22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52,
]
def __init__(self):
self.key = None
self.last_update = 0
def update_key(self, img, sub):
"""
更新 key,基于 img 和 sub 的组合。
"""
KEY_LENGTH = 32
full = img + sub
key = [full[self.KEY_MAP[i]] for i in range(KEY_LENGTH)]
self.key = ''.join(key)
self.last_update = int(time.time())
logger.info(f"Updated wbi key successfully")
def sign(self, query: dict, ts: int = 0):
"""
生成签名。
:param query: 请求参数
:param ts: 时间戳(可选),默认为当前时间戳。
"""
if self.key is None:
raise ValueError("Key is not set.")
ts = ts or int(time.time())
ts_str = str(ts)
sanitized_query = {
k: ''.join(c for c in v if c not in "!\'()*")
for k, v in query.items()
}
sanitized_query[self.WTS] = ts_str
sorted_query = dict(sorted(sanitized_query.items()))
content_string = urlencode(sorted_query, quote_via=quote)
md5 = hashlib.md5()
md5.update((content_string + self.key).encode('utf-8'))
sign = md5.hexdigest()
query[self.W_RID] = sign
query[self.WTS] = ts_str
wbi = Wbi()
__all__ = ["bili_webup", "bili_webup_sync"]
-353
View File
@@ -1,353 +0,0 @@
import json
import os
import random
import time
from PIL import Image
from typing import List
from ..engine import Plugin
from ..engine.upload import UploadBase, logger
@Plugin.upload("bilibili")
class BiliChrome(UploadBase):
def __init__(self, principal, data):
import selenium.common
from selenium import webdriver
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support import expected_conditions as ec
from selenium.webdriver.support.ui import WebDriverWait
super().__init__(principal, data, 'engine/bilibili.cookie')
# self.title = title
# self.date_title = None
self.driver = None
@staticmethod
def assemble_videopath(file_list):
root = os.getcwd()
videopath = ''
for i in range(len(file_list)):
file = file_list[i].video
videopath += root + '/' + file + '\n'
videopath = videopath.rstrip()
return videopath
@staticmethod
def is_element_exist(driver, xpath):
s = driver.find_elements_by_xpath(xpath=xpath)
if len(s) == 0:
print("元素未找到:%s" % xpath)
return False
elif len(s) == 1:
return True
else:
print("找到%s个元素:%s" % (len(s), xpath))
return False
def upload(self, file_list: List[UploadBase.FileInfo]) -> List[UploadBase.FileInfo]:
filename = self.persistence_path
videopath = self.assemble_videopath(file_list)
# service_log_path = "{}/chromedriver.log".format('/home')
options = webdriver.ChromeOptions()
options.add_argument('headless')
self.driver = webdriver.Chrome(executable_path=config.get('chromedriver_path'), chrome_options=options)
# service_log_path=service_log_path)
try:
self.driver.get("https://www.bilibili.com")
# driver.delete_all_cookies()
if os.path.isfile(filename):
with open(filename) as f:
new_cookie = json.load(f)
for cookie in new_cookie:
if isinstance(cookie.get("expiry"), float):
cookie["expiry"] = int(cookie["expiry"])
self.driver.add_cookie(cookie)
self.driver.get("https://member.bilibili.com/video/upload.html")
# print(driver.title)
self.add_videos(videopath)
# js = "var q=document.getElementsByClassName('content-header-right')[0].scrollIntoView();"
# driver.execute_script(js)
cookie = self.driver.get_cookies()
with open(filename, "w") as f:
json.dump(cookie, f)
self.add_information()
self.driver.find_element_by_xpath('//*[@class="upload-v2-container"]/div[2]/div[3]/div[5]/span[1]').click()
# screen_shot = driver.save_screenshot('bin/1.png')
# print('截图')
time.sleep(3)
upload_success = self.driver.find_element_by_xpath(r'//*[@id="app"]/div/div[3]/h3').text
if upload_success == '':
self.driver.save_screenshot('err.png')
logger.info('稿件提交失败,截图记录')
return
else:
logger.info(upload_success)
# logger.info('%s提交完成!' % title_)
return file_list
except selenium.common.exceptions.NoSuchElementException:
logger.exception('发生错误')
# except selenium.common.exceptions.TimeoutException:
# logger.exception('超时')
except selenium.common.exceptions.TimeoutException:
self.login(filename)
finally:
self.driver.quit()
logger.info('浏览器驱动退出')
def login(self, filename):
logger.info('准备更新cookie')
# screen_shot = driver.save_screenshot('bin/1.png')
WebDriverWait(self.driver, 10).until(
ec.presence_of_element_located((By.XPATH, r'//*[@id="login-username"]')))
username = self.driver.find_element_by_xpath(r'//*[@id="login-username"]')
username.send_keys(config['user']['account']['username'])
password = self.driver.find_element_by_xpath('//*[@id="login-passwd"]')
password.send_keys(config['user']['account']['password'])
self.driver.find_element_by_class_name("btn-login").click()
# logger.info('第四步')
# try:
cracker = slider_cracker(self.driver)
cracker.crack()
# except:
# logger.exception('出错')
time.sleep(5)
if self.driver.title == '投稿 - 哔哩哔哩弹幕视频网 - ( ゜- ゜)つロ 乾杯~ - bilibili':
cookie = self.driver.get_cookies()
print(cookie)
with open(filename, "w") as f:
json.dump(cookie, f)
logger.info('更新cookie成功')
else:
logger.info('更新cookie失败')
def add_videos(self, videopath):
formate_title = self.data["format_title"]
WebDriverWait(self.driver, 20).until(
ec.presence_of_element_located((By.NAME, 'buploader')))
upload = self.driver.find_element_by_name('buploader')
# logger.info(driver.title)
upload.send_keys(videopath) # send_keys
logger.info('开始上传' + formate_title)
time.sleep(2)
button = r'//*[@class="new-feature-guide-v2-container"]/div/div/div/div/div[1]'
if self.is_element_exist(self.driver, button):
sb = self.driver.find_element_by_xpath(button)
sb.click()
sb.click()
sb.click()
logger.debug('点击')
while True:
try:
info = self.driver.find_elements_by_class_name(r'item-upload-info')
for t in info:
if t.text != '':
print(t.text)
time.sleep(10)
text = self.driver.find_elements_by_xpath(r'//*[@class="item-upload-info"]/span')
aggregate = set()
for s in text:
if s.text != '':
aggregate.add(s.text)
print(s.text)
if len(aggregate) == 1 and ('Upload complete' in aggregate or '上传完成' in aggregate):
break
except selenium.common.exceptions.StaleElementReferenceException:
logger.exception("selenium.common.exceptions.StaleElementReferenceException")
logger.info('上传%s个数%s' % (formate_title, len(info)))
def add_information(self):
link = self.data.get("url")
# 点击模板
self.driver.find_element_by_xpath(r'//*[@class="normal-title-wrp"]/div/p').click()
self.driver.find_element_by_class_name(r'template-list-small-item').click()
# driver.find_element_by_xpath(
# r'//*[@id="app"]/div[3]/div[2]/div[3]/div[1]/div[1]/div/div[2]/div[1]').click()
# 输入转载来源
input_o = self.driver.find_element_by_xpath(
'//*[@class="upload-v2-container"]/div[2]/div[3]/div[1]/div[4]/div[3]/div/div/input')
input_o.send_keys(link)
# 选择分区
# driver.find_element_by_xpath(r'//*[@id="item"]/div/div[2]/div[3]/div[2]/div[2]/div[1]/div[2]/div[2]/div[1]/div[3]/div').click()
# driver.find_element_by_xpath(r'//*[@id="item"]/div/div[2]/div[3]/div[2]/div[2]/div[1]/div[2]/div[2]/div[1]/div[3]/div[2]/div[6]').click()
# 稿件标题
title = self.driver.find_element_by_xpath(
'//*[@class="upload-v2-container"]/div[2]/div[3]/div[1]/div[8]/div[2]/div/div/input')
title.send_keys(Keys.CONTROL + 'a')
title.send_keys(Keys.BACKSPACE)
title.send_keys(self.data["format_title"])
# js = "var q=document.getElementsByClassName('content-tag-list')[0].scrollIntoView();"
# driver.execute_script(js)
# time.sleep(3)
# 输入相关游戏
# driver.save_screenshot('bin/err.png')
# print('截图')
# text_1 = driver.find_element_by_xpath(
# '//*[@id="item"]/div/div[2]/div[3]/div[2]/div[2]/div[1]/div[5]/div/div/div[1]/div[2]/div/div/input')
# text_1.send_keys('星际争霸2')
# 简介
text_2 = self.driver.find_element_by_xpath(
'//*[@class="upload-v2-container"]/div[2]/div[3]/div[1]/div[12]/div[2]/div/textarea')
text_2.send_keys('职业选手直播第一视角录像。这个自动录制上传的小程序开源在Github:'
'http://t.cn/RgapTpf(或者在Github搜索ForgQi)\n'
'交流群:837362626')
class slider_cracker(object):
def __init__(self, driver):
self.driver = driver
self.driver.maximize_window() # 最大化窗口
self.driver.set_window_size(1024, 768)
self.fn = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'true_image.png')
def get_true_image(self, slider_xpath=r'//*[@id="gc-box"]/div/div[3]/div[1]'):
# element = WebDriverWait(self.driver, 50).until(EC.element_to_be_clickable((By.XPATH, slider_xpath)))
element = WebDriverWait(self.driver, 50).until(
EC.element_to_be_clickable((By.CLASS_NAME, "geetest_slider_button")))
ActionChains(self.driver).move_to_element(element).perform() # 鼠标移动到滑动框以显示图片
js = 'document.querySelector("body > div.geetest_panel.geetest_wind ' \
'> div.geetest_panel_box.geetest_no_logo.geetest_panelshowslide ' \
'> div.geetest_panel_next > div > div.geetest_wrap > div.geetest_widget ' \
'> div > a > div.geetest_canvas_img.geetest_absolute > canvas").' \
'style.display = "%s";'
self.driver.execute_script(js % "inline")
time.sleep(1)
true_image = self.get_img(self.fn)
self.driver.execute_script(js % "none")
return true_image
def get_img(self, img_name, img_xpath=r'//*[@id="gc-box"]/div/div[1]/div[2]/div[1]/a[2]'): # 260*116
fn = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'slider_screenshot.png')
screen_shot = self.driver.save_screenshot(fn)
# image_element = self.driver.find_element_by_xpath(img_xpath)
image_element = self.driver.find_element_by_class_name(r'geetest_window')
left = image_element.location['x']
top = image_element.location['y'] # selenium截图并获取验证图片location后将其截出保存
right = image_element.location['x'] + image_element.size['width']
bottom = image_element.location['y'] + image_element.size['height']
image = Image.open(fn)
image = image.crop((left, top, right, bottom))
image.save(img_name)
return image
def analysis(self, true_image, knob_xpath=r'//*[@id="gc-box"]/div/div[3]/div[2]'):
fn = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'img2.png')
img1 = Image.open(self.fn)
# slider_element = self.driver.find_element_by_xpath(knob_xpath)
slider_element = self.driver.find_element_by_class_name("geetest_slider_button")
ActionChains(self.driver).click_and_hold(slider_element).perform() # 点击滑块后截取残缺图
time.sleep(1)
img2 = self.get_img(fn)
img1_width, img1_height = img1.size
img2_width, img2_height = img2.size
left = 0
flag = False
for i in range(69, img1_width): # 遍历x>65的像素点(x<65是拼图块)
for j in range(0, img1_height):
if not self.is_pixel_equal(img1, img2, i, j):
left = i
flag = True
break
if flag:
break
if left >= 73:
left = left - 3 # 误差纠正
else:
left = left
return left
def is_pixel_equal(self, img1, img2, x, y): # 通过比较俩图片像素点RGB值差值判断是否为缺口
pix1 = img1.load()[x, y]
pix2 = img2.load()[x, y]
if (abs(pix1[0] - pix2[0] < 60) and abs(pix1[1] - pix2[1] < 60) and abs(pix1[2] - pix2[2] < 60)):
return True
else:
return False
def get_track(self, distance):
"""
根据偏移量获取移动轨迹
:param distance: 偏移量
:return: 移动轨迹
"""
# 移动轨迹
track = []
# 当前位移
current = 0
# 减速阈值
mid = distance * 4 / 5
# 计算间隔
t = 0.2
# 初速度
v = 0
while current < distance:
if current < mid:
# 加速度为正2
a = 2
else:
# 加速度为负3
a = -3
# 初速度v0
v0 = v
# 当前速度v = v0 + at
v = v0 + a * t
# 移动距离x = v0t + 1/2 * a * t^2
move = v0 * t + 1 / 2 * a * t * t
# 当前位移
current += move
# 加入轨迹
track.append(round(move))
# print(track)
#
# print(sum(track))
track.append(distance - sum(track))
# print(track)
# print(sum(track))
return track
def move_to_gap(self, slider, track):
"""
拖动滑块到缺口处
:param slider: 滑块
:param track: 轨迹
:return:
"""
ActionChains(self.driver).click_and_hold(slider).perform()
for x in track:
ActionChains(self.driver).move_by_offset(xoffset=x, yoffset=random.uniform(-5, 2)).perform()
time.sleep(0.5)
ActionChains(self.driver).release().perform()
def crack(self):
true_image = self.get_true_image()
x_offset = self.analysis(true_image)
print(x_offset)
track = self.get_track(x_offset)
knob_element = WebDriverWait(self.driver, 50).until(
EC.element_to_be_clickable((By.XPATH, r'/html/body/div[2]/div[2]/div[6]/div/div[1]/div[2]/div[2]')))
self.move_to_gap(knob_element, track)
# fn = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'result0.png')
# fn1 = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'result1.png')
# time.sleep(0.02)
# screen_shot = self.driver.save_screenshot(fn)
# time.sleep(2)
# screen_shot = self.driver.save_screenshot(fn1)
+17 -7
View File
@@ -21,12 +21,19 @@ import rsa
import xml.etree.ElementTree as ET
from requests.adapters import HTTPAdapter, Retry
from ..engine import Plugin
from ..engine.upload import UploadBase, logger
import logging
from typing import NamedTuple
@Plugin.upload(platform="bili_web")
class BiliWeb(UploadBase):
logger = logging.getLogger('biliup')
class FileInfo(NamedTuple):
video: str
danmaku: Optional[str] = None
class BiliWeb:
def __init__(
self,
principal,
@@ -63,7 +70,10 @@ class BiliWeb(UploadBase):
:param description: 视频简介
:param credits: ???
"""
super().__init__(principal, data, persistence_path='bili.cookie', postprocessor=postprocessor)
self.principal = principal
self.data: dict = data
self.persistence_path = 'bili.cookie'
self.post_processor = postprocessor
if tags is None:
tags = []
else:
@@ -84,9 +94,9 @@ class BiliWeb(UploadBase):
def upload(
self,
file_list: List[UploadBase.FileInfo],
file_list: List[FileInfo],
database_row_id: int = 0
) -> List[UploadBase.FileInfo]:
) -> List[FileInfo]:
'''
上传视频
:param file_list: 视频文件名列表
+12 -7
View File
@@ -15,7 +15,7 @@ import urllib.parse
from dataclasses import asdict, dataclass, field, InitVar
from json import JSONDecodeError
from os.path import splitext, basename
from typing import Callable, Dict, Union, Any, List
from typing import Callable, Dict, Union, Any, List, NamedTuple, Optional
from urllib import parse
from urllib.parse import quote
@@ -27,22 +27,27 @@ import rsa
import xml.etree.ElementTree as ET
from requests.adapters import HTTPAdapter, Retry
from ..engine import Plugin
from ..engine.upload import UploadBase, logger
logger = logging.getLogger('biliup.engine.bili_web_sync')
@Plugin.upload(platform="bili_web_sync")
class BiliWebAsync(UploadBase):
class FileInfo(NamedTuple):
video: str
danmaku: Optional[str] = None
class BiliWebAsync:
def __init__(
self, principal, data, submit_api=None, copyright=2, postprocessor=None, dtime=None,
dynamic='', lines='AUTO', threads=3, tid=122, tags=None, cover_path=None, description='',
dolby=0, hires=0, no_reprint=0, is_only_self=0, charging_pay=0, credits=None,
user_cookie='cookies.json', copyright_source=None, extra_fields="", video_queue=None
):
super().__init__(principal, data, persistence_path='bili.cookie', postprocessor=postprocessor)
self.principal = principal
self.data: dict = data
self.persistence_path = 'bili.cookie'
self.post_processor = postprocessor
if credits is None:
credits = []
if tags is None:
@@ -74,7 +79,7 @@ class BiliWebAsync(UploadBase):
self.user_cookie = user_cookie
self.video_queue: queue.SimpleQueue = video_queue
def upload(self, total_size: int, stop_event: threading.Event, output_prefix: str, file_name_callback: Callable[[str], None] = None, database_row_id=0) -> List[UploadBase.FileInfo]:
def upload(self, total_size: int, stop_event: threading.Event, output_prefix: str, file_name_callback: Callable[[str], None] = None, database_row_id=0) -> List[FileInfo]:
# print("开始同步上传")
logger.info(f"开始同步上传 {database_row_id}")
file_index = 1
-145
View File
@@ -1,145 +0,0 @@
import multiprocessing as mp
import time
import stream_gears
from typing import List, Optional, Union
from . import SubmitOption
from ..engine import Plugin
from ..engine.upload import UploadBase, logger
@Plugin.upload(platform="biliup-rs")
class BiliWeb(UploadBase):
def __init__(
self, principal, data, submit_api: Optional[Union[SubmitOption, str]] = None,
copyright=2, postprocessor=None, dtime=None,
dynamic='', lines='AUTO', threads=3, tid=122, tags=None, cover_path=None, description='',
dolby=0, hires=0, no_reprint=0, is_only_self=0, charging_pay=0, credits=None,
user_cookie='cookies.json', copyright_source=None, extra_fields = ""
):
super().__init__(principal, data, persistence_path='bili.cookie', postprocessor=postprocessor)
if tags is None:
tags = []
else:
tags = [str(tag).format(streamer=self.data['name']) for tag in tags]
self.lines = lines
self.submit_api = submit_api
self.threads = threads
self.tid = tid
self.tags = tags
if cover_path:
self.cover_path = cover_path
elif "live_cover_path" in self.data:
self.cover_path = self.data["live_cover_path"]
else:
self.cover_path = None
self.desc = description
self.credits = credits if credits else []
self.dynamic = dynamic
self.copyright = copyright
self.dtime = dtime
self.dolby = dolby
self.hires = hires
self.no_reprint = no_reprint
self.is_only_self = is_only_self
self.charging_pay = charging_pay
self.user_cookie = user_cookie
self.copyright_source = copyright_source
if 'extra_fields' in data:
self.extra_fields = data.get('extra_fields', '')
else:
self.extra_fields = extra_fields
def upload(self, file_list: List[UploadBase.FileInfo]) -> List[UploadBase.FileInfo]:
if self.credits:
desc_v2 = self.creditsToDesc_v2()
else:
desc_v2 = [{
"raw_text": self.desc,
"biz_id": "",
"type": 1
}]
ex_parent_conn, ex_child_conn = mp.Pipe()
upload_args = {
"ex_conn": ex_child_conn,
"lines": self.lines,
"submit": self.submit_api,
"video_path": [file.video for file in file_list],
"cookie_file": self.user_cookie,
"title": self.data["format_title"][:80],
"tid": self.tid,
"tag": ','.join(self.tags),
"copyright": self.copyright,
"source": self.copyright_source if self.copyright_source else self.data["url"],
"desc": self.desc,
"dynamic": self.dynamic,
"cover": self.cover_path if self.cover_path is not None else "",
"dolby": self.dolby,
"lossless_music": self.hires,
"no_reprint": self.no_reprint,
"charging_pay": self.charging_pay,
"limit": self.threads,
"desc_v2": desc_v2,
"extra_fields": self.extra_fields,
"dtime": int(time.time() + self.dtime) if self.dtime else None,
}
upload_process = mp.get_context('spawn').Process(target=stream_gears_upload, daemon=True, kwargs=upload_args)
upload_process.start()
upload_process.join()
if ex_parent_conn.poll():
raise RuntimeError(ex_parent_conn.recv())
logger.info(f"上传成功: {self.principal}")
return file_list
def creditsToDesc_v2(self):
desc_v2 = []
desc_v2_tmp = self.desc
for credit in self.credits:
try:
num = desc_v2_tmp.index("@credit")
desc_v2.append({
"raw_text": " " + desc_v2_tmp[:num],
"biz_id": "",
"type": 1
})
desc_v2.append({
"raw_text": credit["username"],
"biz_id": str(credit["uid"]),
"type": 2
})
self.desc = self.desc.replace(
"@credit", "@" + credit["username"] + " ", 1)
desc_v2_tmp = desc_v2_tmp[num + 7:]
except IndexError:
logger.error('简介中的@credit占位符少于credits的数量,替换失败')
desc_v2.append({
"raw_text": " " + desc_v2_tmp,
"biz_id": "",
"type": 1
})
desc_v2[0]["raw_text"] = desc_v2[0]["raw_text"][1:] # 开头空格会导致识别简介过长
return desc_v2
def stream_gears_upload(ex_conn, lines, *args, **kwargs):
try:
if lines == 'bda':
kwargs['line'] = stream_gears.UploadLine.Bda
elif lines == 'bda2':
kwargs['line'] = stream_gears.UploadLine.Bda2
elif lines == 'qn':
kwargs['line'] = stream_gears.UploadLine.Qn
elif lines == 'tx':
kwargs['line'] = stream_gears.UploadLine.Tx
elif lines == 'txa':
kwargs['line'] = stream_gears.UploadLine.Txa
elif lines == 'bldsa':
kwargs['line'] = stream_gears.UploadLine.Bldsa
stream_gears.upload(*args, **kwargs)
except Exception as e:
ex_conn.send(e)
-11
View File
@@ -1,11 +0,0 @@
from typing import List
from ..engine.upload import UploadBase, logger
from ..engine import Plugin
@Plugin.upload(platform="Noop")
class NoopUploader(UploadBase):
def upload(self, file_list: List[UploadBase.FileInfo]) -> List[UploadBase.FileInfo]:
logger.info("NoopUploader")
return file_list
-135
View File
@@ -1,135 +0,0 @@
import time
import stream_gears
from typing import List, Optional, Union
from . import SubmitOption
from ..engine import Plugin
from ..engine.upload import UploadBase, logger
@Plugin.upload(platform="stream_gears")
class BiliWeb(UploadBase):
def __init__(
self, principal, data, submit_api: Optional[Union[SubmitOption, str]] = None,
copyright=2, postprocessor=None, dtime=None,
dynamic='', lines='AUTO', threads=3, tid=122, tags=None, cover_path=None, description='',
dolby=0, hires=0, no_reprint=0, charging_pay=0, credits=None,
user_cookie='cookies.json', copyright_source=None, extra_fields = ""
):
super().__init__(principal, data, persistence_path='bili.cookie', postprocessor=postprocessor)
if credits is None:
credits = []
if tags is None:
tags = []
self.lines = lines
self.submit_api = submit_api
self.threads = threads
self.tid = tid
self.tags = tags
if cover_path:
self.cover_path = cover_path
elif "live_cover_path" in self.data:
self.cover_path = self.data["live_cover_path"]
else:
self.cover_path = None
self.desc = description
self.credits = credits
self.dynamic = dynamic
self.copyright = copyright
self.dtime = dtime
self.dolby = dolby
self.hires = hires
self.no_reprint = no_reprint
self.charging_pay = charging_pay
self.user_cookie = user_cookie
self.copyright_source = copyright_source
if "extra_fields" in self.data:
self.extra_fields = self.data["extra_fields"]
else:
self.extra_fields = extra_fields
def upload(self, file_list) -> List[UploadBase.FileInfo]:
line = None
if self.lines == 'bda':
line = stream_gears.UploadLine.Bda
elif self.lines == 'bda2':
line = stream_gears.UploadLine.Bda2
elif self.lines == 'qn':
line = stream_gears.UploadLine.Qn
elif self.lines == 'tx':
line = stream_gears.UploadLine.Tx
elif self.lines == 'txa':
line = stream_gears.UploadLine.Txa
elif self.lines == 'bldsa':
line = stream_gears.UploadLine.Bldsa
tag = ','.join(self.tags)
if self.credits:
desc_v2 = self.creditsToDesc_v2()
else:
desc_v2 = [{
"raw_text": self.desc,
"biz_id": "",
"type": 1
}]
source = self.copyright_source if self.copyright_source else "https://github.com/biliup/biliup"
cover = self.cover_path if self.cover_path is not None else ""
dtime = None
if self.dtime:
dtime = int(time.time() + self.dtime)
stream_gears.upload(
video_path=file_list,
cookie_file=self.user_cookie,
title=self.data["format_title"][:80],
tid=self.tid,
tag=tag,
copyright=self.copyright,
source=source,
desc=self.desc,
dynamic=self.dynamic,
cover=cover,
dolby=self.dolby,
lossless_music=self.hires,
no_reprint=self.no_reprint,
charging_pay=self.charging_pay,
limit=self.threads,
desc_v2=desc_v2,
dtime=dtime,
line=line,
extra_fields=self.extra_fields,
submit=self.submit_api,
)
logger.info(f"上传成功: {self.principal}")
return file_list
def creditsToDesc_v2(self):
desc_v2 = []
desc_v2_tmp = self.desc
for credit in self.credits:
try:
num = desc_v2_tmp.index("@credit")
desc_v2.append({
"raw_text": " " + desc_v2_tmp[:num],
"biz_id": "",
"type": 1
})
desc_v2.append({
"raw_text": credit["username"],
"biz_id": str(credit["uid"]),
"type": 2
})
self.desc = self.desc.replace(
"@credit", "@" + credit["username"] + " ", 1)
desc_v2_tmp = desc_v2_tmp[num + 7:]
except IndexError:
logger.error('简介中的@credit占位符少于credits的数量,替换失败')
desc_v2.append({
"raw_text": " " + desc_v2_tmp,
"biz_id": "",
"type": 1
})
desc_v2[0]["raw_text"] = desc_v2[0]["raw_text"][1:] # 开头空格会导致识别简介过长
return desc_v2
+15
View File
@@ -526,6 +526,21 @@ mod tests {
assert!(stream.get("vMultiStreamInfo").unwrap().is_array());
}
#[test]
fn extract_stream_json_stops_before_following_player_fields() {
let page = r#"
var hyPlayerConfig = {
stream: {"data":[],"vMultiStreamInfo":[]},
liveLineUrl: "https://example.invalid"
};
"#;
let stream = extract_stream_json(page).unwrap();
assert_eq!(stream.get("data").unwrap().as_array().unwrap().len(), 0);
assert!(stream.get("vMultiStreamInfo").unwrap().is_array());
}
#[test]
fn find_json_value_end_ignores_braces_in_strings() {
let input = r#"{"text":"}; { ]","items":[{"value":1}]}
+8 -4
View File
@@ -62,10 +62,14 @@ impl Segmentable {
result
}
fn elapsed_time(&self) -> Duration {
self.time.current.saturating_sub(self.time.start)
}
/// 检查单独的时间条件
pub fn time_needed(&self) -> bool {
if let Some(expected_time) = self.time.expected {
(self.time.current - self.time.start) >= expected_time
self.elapsed_time() >= expected_time
} else {
false
}
@@ -86,7 +90,7 @@ impl Segmentable {
(true, true) => {
tracing::info!(
"Segmentation needed: Both time ({:?} >= {:?}) and size ({} >= {}) conditions met",
self.time.current - self.time.start,
self.elapsed_time(),
self.time.expected.unwrap(),
self.size.current,
self.size.expected.unwrap()
@@ -95,7 +99,7 @@ impl Segmentable {
(true, false) => {
tracing::info!(
"Segmentation needed: Time condition met ({:?} >= {:?})",
self.time.current - self.time.start,
self.elapsed_time(),
self.time.expected.unwrap()
);
}
@@ -188,7 +192,7 @@ impl Segmentable {
pub fn get_status(&self) -> String {
let time_info = Self::format_progress(
"Time",
(self.time.current - self.time.start).as_secs_f64(),
self.elapsed_time().as_secs_f64(),
self.time.expected.map(|d| d.as_secs_f64()),
"s",
|t| format!("{:.1}", t),
+70
View File
@@ -0,0 +1,70 @@
use std::path::PathBuf;
use std::time::Duration;
use danmaku::{DanmakuRecorder, PlatformContext, RecorderConfig};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let url = std::env::var("DANMAKU_TEST_URL")?;
let seconds = std::env::var("DANMAKU_TEST_SECONDS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(60);
let output = std::env::var("DANMAKU_TEST_OUTPUT")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("target/tmp/danmaku_e2e_%Y%m%d_%H%M%S"));
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.init();
let mut config = RecorderConfig::new(&url, &output);
if let Ok(room_id) = std::env::var("DANMAKU_TEST_ROOM_ID") {
config = config.with_context(PlatformContext::new().with_room_id(room_id));
}
let recorder = DanmakuRecorder::new(config)?;
let handle = recorder.start();
tokio::time::sleep(Duration::from_secs(seconds)).await;
handle.stop().await?;
tokio::time::sleep(Duration::from_secs(2)).await;
let parent = output.parent().unwrap_or_else(|| std::path::Path::new("."));
let stem_prefix = output
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("danmaku_e2e")
.split('%')
.next()
.unwrap_or("danmaku_e2e");
let mut files = std::fs::read_dir(parent)?
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
.filter(|path| {
path.extension().and_then(|ext| ext.to_str()) == Some("xml")
&& path
.file_name()
.and_then(|name| name.to_str())
.map(|name| name.starts_with(stem_prefix))
.unwrap_or(false)
})
.collect::<Vec<_>>();
files.sort();
let xml_path = files
.last()
.ok_or_else(|| format!("no XML file produced under {}", parent.display()))?;
let content = std::fs::read_to_string(xml_path)?;
let has_xml_message =
content.contains("<d ") || content.contains("<s ") || content.contains("<o ");
println!("url={url}");
println!("xml={}", xml_path.display());
println!("has_xml_message={has_xml_message}");
println!("bytes={}", content.len());
if !has_xml_message {
return Err("XML file does not contain danmaku message nodes".into());
}
Ok(())
}
+3 -1
View File
@@ -4,7 +4,9 @@ use std::path::PathBuf;
use std::time::Duration;
use danmaku::output::xml::{XmlWriter, XmlWriterConfig};
use danmaku::protocols::{HeartbeatData, Platform, PlatformContext, RegistrationData, douyu::Douyu};
use danmaku::protocols::{
HeartbeatData, Platform, PlatformContext, RegistrationData, douyu::Douyu,
};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
+9 -3
View File
@@ -22,8 +22,8 @@ use tracing::{debug, error, info, warn};
use crate::error::{DanmakuError, Result};
use crate::output::xml::{XmlWriter, XmlWriterConfig};
use crate::protocols::{
ConnectionInfo, ConnectionTransport, HeartbeatData, Platform, PlatformContext, RegistrationData,
create_platform,
ConnectionInfo, ConnectionTransport, HeartbeatData, Platform, PlatformContext,
RegistrationData, create_platform,
};
/// Configuration for the danmaku recorder.
@@ -444,7 +444,13 @@ impl DanmakuRecorder {
// Send ack if needed
if let Some(ack) = result.ack {
ws_sink.send(Message::Binary(ack.into())).await?;
if result.ack_is_text {
let text = String::from_utf8(ack)
.map_err(|e| DanmakuError::Decode(e.to_string()))?;
ws_sink.send(Message::Text(text.into())).await?;
} else {
ws_sink.send(Message::Binary(ack.into())).await?;
}
}
}
Err(e) => {
+41 -14
View File
@@ -22,7 +22,12 @@ use crate::protocols::{ConnectionInfo, DecodeResult, HeartbeatConfig, Platform,
/// Heartbeat packet.
const HEARTBEAT: &[u8] = b":\x02hb";
const USER_AGENT_STRING: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
const DOUYIN_WS_URL: &str = "wss://webcast100-ws-web-lf.douyin.com/webcast/im/push/v2/";
const DOUYIN_WS_HOSTS: &[&str] = &[
"wss://webcast100-ws-web-lq.douyin.com/webcast/im/push/v2/",
"wss://webcast100-ws-web-hl.douyin.com/webcast/im/push/v2/",
"wss://webcast100-ws-web-lf.douyin.com/webcast/im/push/v2/",
];
const DEFAULT_TTWID: &str = "1%7Cu7ogdHsSmHtxbt4hjDCNvcLfVJz78CTM0TTWU8Hio8w%7C1751545220%7C18aac967e501e9d6c13384335ced3523c46a0b1cc4535c7213bc2506a7f462c8";
const XBOGUS_ALPHABET: &[u8; 64] =
b"Dkdpgh4ZKsQB80/Mfvw36XI1R25+WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe";
const STANDARD_ALPHABET: &[u8; 64] =
@@ -84,7 +89,7 @@ impl Douyin {
}
/// Build WebSocket URL with parameters.
fn build_ws_url(room_id: &str) -> String {
fn build_ws_url(room_id: &str, host: &str) -> String {
let user_unique_id = Self::generate_user_unique_id();
let version_code = "180800";
let webcast_sdk_version = "1.0.15";
@@ -136,11 +141,20 @@ impl Douyin {
let query: String = ws_params
.iter()
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join("&");
format!("{}?{}", DOUYIN_WS_URL, query)
format!("{}?{}", host, query)
}
fn build_connection_info(room_id: &str) -> ConnectionInfo {
let urls = DOUYIN_WS_HOSTS
.iter()
.map(|host| Self::build_ws_url(room_id, host))
.collect::<Vec<_>>();
let mut iter = urls.into_iter();
ConnectionInfo::new(iter.next().unwrap()).with_fallback_ws_urls(iter.collect())
}
fn default_headers(context: &PlatformContext) -> HeaderMap {
@@ -173,10 +187,22 @@ impl Douyin {
);
}
if let Some(cookie) = context.cookie.as_deref().filter(|value| !value.is_empty()) {
if let Ok(value) = HeaderValue::from_str(cookie) {
headers.insert(COOKIE, value);
}
let cookie = context
.cookie
.as_deref()
.filter(|value| !value.is_empty())
.map(|value| {
if value.contains("ttwid") {
value.to_string()
} else if value.ends_with(';') {
format!("{}ttwid={};", value, DEFAULT_TTWID)
} else {
format!("{};ttwid={};", value, DEFAULT_TTWID)
}
})
.unwrap_or_else(|| format!("ttwid={};", DEFAULT_TTWID));
if let Ok(value) = HeaderValue::from_str(&cookie) {
headers.insert(COOKIE, value);
}
headers
@@ -297,7 +323,8 @@ impl Douyin {
fn build_ack(log_id: u64, internal_ext: &str) -> Vec<u8> {
let mut writer = ProtoWriter::new();
writer.write_varint_field(2, log_id);
writer.write_string(7, internal_ext);
writer.write_string(7, "ack");
writer.write_bytes(8, internal_ext.as_bytes());
writer.into_buffer()
}
}
@@ -429,10 +456,10 @@ impl Platform for Douyin {
);
}
let ws_url = Self::build_ws_url(&room_id);
debug!("Douyin WebSocket URL: {}", ws_url);
let ws_info = Self::build_connection_info(&room_id);
debug!("Douyin WebSocket URL: {}", ws_info.ws_url);
Ok(ConnectionInfo::new(ws_url).with_headers(Self::default_headers(context)))
Ok(ws_info.with_headers(Self::default_headers(context)))
}
fn heartbeat_config(&self) -> HeartbeatConfig {
@@ -514,8 +541,8 @@ mod tests {
let fields = reader.parse_all();
assert_eq!(fields[&2][0].as_u64(), Some(12345));
assert_eq!(fields[&7][0].as_str(), Some("internal_src:dim|seq:1"));
assert!(!fields.contains_key(&8));
assert_eq!(fields[&7][0].as_str(), Some("ack"));
assert_eq!(fields[&8][0].as_str(), Some("internal_src:dim|seq:1"));
}
#[test]
+16 -2
View File
@@ -141,6 +141,8 @@ pub struct DecodeResult {
pub events: Vec<DanmakuEvent>,
/// Optional acknowledgment packet to send back (e.g., for Douyin).
pub ack: Option<Vec<u8>>,
/// Whether the acknowledgment should be sent as a text WebSocket message.
pub ack_is_text: bool,
}
impl DecodeResult {
@@ -151,12 +153,24 @@ impl DecodeResult {
/// Create a result with events.
pub fn with_events(events: Vec<DanmakuEvent>) -> Self {
Self { events, ack: None }
Self {
events,
ack: None,
ack_is_text: false,
}
}
/// Add an acknowledgment packet.
/// Add a binary acknowledgment packet.
pub fn with_ack(mut self, ack: Vec<u8>) -> Self {
self.ack = Some(ack);
self.ack_is_text = false;
self
}
/// Add a text acknowledgment packet.
pub fn with_text_ack(mut self, ack: impl Into<String>) -> Self {
self.ack = Some(ack.into().into_bytes());
self.ack_is_text = true;
self
}
}
+10 -10
View File
@@ -8,8 +8,6 @@
//! - Send "PING" heartbeat every 40 seconds
//! - Parse PRIVMSG for chat messages
use std::time::Duration;
use async_trait::async_trait;
use regex::Regex;
@@ -86,20 +84,17 @@ impl Platform for Twitch {
// IRC registration commands
let reg_data = vec![
RegistrationData::Text(
"CAP REQ :twitch.tv/tags twitch.tv/commands twitch.tv/membership".to_string(),
),
RegistrationData::Text("PASS SCHMOOPIIE".to_string()),
RegistrationData::Text("CAP REQ :twitch.tv/tags twitch.tv/commands".to_string()),
RegistrationData::Text("PASS oauth:".to_string()),
RegistrationData::Text(format!("NICK {}", nick)),
RegistrationData::Text(format!("USER {} 8 * :{}", nick, nick)),
RegistrationData::Text(format!("JOIN #{}", room_id)),
];
Ok(ConnectionInfo::new("wss://irc-ws.chat.twitch.tv").with_registration(reg_data))
Ok(ConnectionInfo::new("wss://irc-ws.chat.twitch.tv:443").with_registration(reg_data))
}
fn heartbeat_config(&self) -> HeartbeatConfig {
HeartbeatConfig::text("PING", Duration::from_secs(40))
HeartbeatConfig::none()
}
fn decode_message(&self, msg: &[u8]) -> Result<DecodeResult> {
@@ -110,11 +105,16 @@ impl Platform for Twitch {
let mut events = Vec::new();
for line in text.lines() {
// Skip empty lines
let line = line.trim();
if line.is_empty() {
continue;
}
if line.starts_with("PING") {
let pong_target = line.strip_prefix("PING ").unwrap_or(":tmi.twitch.tv");
return Ok(DecodeResult::empty().with_text_ack(format!("PONG {}", pong_target)));
}
// Try to parse PRIVMSG
if let Some(content_cap) = self.privmsg_regex.captures(line) {
if let Some(content) = content_cap.get(1) {
+22 -5
View File
@@ -36,10 +36,24 @@ impl YouTube {
}
fn extract_channel_id(text: &str) -> Option<String> {
let re = Regex::new(r#"\\?"channelId\\?":\\?"([^"\\]{24})\\?""#).ok()?;
re.captures(text)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string())
[
r#""channelId":"(UC[^"\\]{22})""#,
r#"\\"channelId\\":\\"(UC[^"\\]{22})\\""#,
r#""externalChannelId":"(UC[^"\\]{22})""#,
r#"\\"externalChannelId\\":\\"(UC[^"\\]{22})\\""#,
r#""browseId":"(UC[^"\\]{22})""#,
r#"\\"browseId\\":\\"(UC[^"\\]{22})\\""#,
r#"/channel/(UC[A-Za-z0-9_-]{22})"#,
r#"(UC[A-Za-z0-9_-]{22})"#,
]
.into_iter()
.find_map(|pattern| {
Regex::new(pattern)
.ok()?
.captures(text)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string())
})
}
fn extract_live_video_id(text: &str) -> Option<String> {
@@ -99,7 +113,10 @@ impl YouTube {
async fn initial_continuation(&self, url: &str) -> Result<String> {
let channel_id = self.resolve_channel_id(url).await?;
let video_id = self.resolve_live_video_id(&channel_id).await?;
let video_id = match extract_video_id(url) {
Some(video_id) => video_id,
None => self.resolve_live_video_id(&channel_id).await?,
};
Ok(liveparam(&video_id, &channel_id, 1, false))
}
+2 -21
View File
@@ -20,36 +20,17 @@ keywords = ["bilibili douyu huya"]
urls = { Homepage = "https://github.com/ForgQi/bilibiliupload" }
requires-python = ">= 3.9"
dependencies = [
"tomli-w>=1.0.0",
"psutil >= 5.4.6",
"aiofiles >= 22.1.0",
"yt-dlp >= 2025.7.21",
"aiohttp[speedups] >= 3.9.5",
"Pillow >= 5.2.0",
"aiohttp[speedups] >= 3.9.5", # aio-libs/aiohttp#10519
"aiohttp_cors >= 0.7.0",
"requests >= 2.32.3",
"httpx[http2] >= 0.28.1",
"httpcore >= 1.0.7",
"PyYAML >= 4.2b1",
"streamlink >= 7.5.0",
"ykdl >= 1.8.0",
"rsa >= 4.6",
"tomli >= 1.1.0; python_version < '3.11'",
"protobuf >= 4.23.0",
"SQLAlchemy >= 2.0.0",
"alembic >= 1.12.1",
"m3u8 >= 6.0.0",
"truststore >= 0.10.1; python_version >= '3.10'",
"async-lru >= 2.0.5",
"gmssl >= 3.2.2"
]
dynamic = ["version"]
[project.optional-dependencies]
selenium = ["selenium >= 3.8.1"]
[project.scripts]
biliup = "biliup.__main__:arg_parser"
biliup = "biliup.__main__:main"
#[tool.setuptools]
#license-files = ["LICENSE"]