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:
1 parent
431d1f61ab
commit
b5ddff0564
26 files changed
+480
-1064
No files matched your search
Generated
+4
-4
@@ -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
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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']
|
||||
@@ -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
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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: 视频文件名列表
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}]}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
Reference in new issue
Block a user