好友
阅读权限20
听众
最后登录1970-1-1
|
之前在本专区内看到了cc字幕转srt的软件,还是2021年的,现在B站的API更新了,很多旧的API被屏蔽了,所以写了个兼容最新版API的GO语言版
需要自己配置cookie.txt,要从B站网页上的请求标头复制cookie到文件上,go语言代码如下:
[Golang] 纯文本查看 复制代码
package main
import (
"bufio"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"os"
"regexp"
"strconv"
"strings"
"time"
)
// 定义操作模式
type OperationMode int
const (
ModeUndefined OperationMode = iota
ModeDownload
ModeConvert
ModeDownloadConvert
)
// 全局配置
type Config struct {
Mode OperationMode
InputFile string
OutputFile string
StartPID int
EndPID int
AutoConvert bool
}
// CCJSONDownloader 字幕下载器
type CCJSONDownloader struct {
client *http.Client
cookies string
}
// CCJSONConverter 字幕转换器
type CCJSONConverter struct{}
func main() {
fmt.Println("Bilibili JSON格式CC字幕下载器 Go版 Ver 1.0.0")
fmt.Println("cookie.txt文件的编码必须是无BOM的UTF-8")
if err := checkCookie(); err != nil {
fmt.Println("错误:", err)
}
// 解析命令行参数
config := parseCommandLine()
// 验证参数
if err := validateConfig(&config); err != nil {
fmt.Fprintf(os.Stderr, "错误: %v\n", err)
printUsage()
os.Exit(1)
}
// 执行相应操作
if err := executeOperation(config); err != nil {
fmt.Fprintf(os.Stderr, "操作失败: %v\n", err)
os.Exit(1)
}
fmt.Println("操作完成")
}
func checkCookie() error {
const filename = "cookie.txt"
// 检查文件是否存在
_, err := os.Stat(filename)
if err == nil {
fmt.Println(filename, "已存在")
return nil
}
// 如果文件不存在(IsNotExist错误)
if os.IsNotExist(err) {
// 创建文件
file, err := os.Create(filename)
if err != nil {
return fmt.Errorf("创建文件失败: %v", err)
}
defer file.Close()
fmt.Println(filename, "已创建")
return fmt.Errorf("请先配置cookie.txt,配置好后再运行本程序")
}
// 其他类型的错误(如权限问题)
return fmt.Errorf("检查文件失败: %v", err)
}
// 解析命令行参数
func parseCommandLine() Config {
var config Config
// 定义命令行标志
downloadFlag := flag.Bool("d", false, "下载JSON字幕")
convertFlag := flag.Bool("c", false, "转换JSON字幕为SRT格式")
outputFlag := flag.String("o", "", "指定输出文件")
startFlag := flag.Int("s", 0, "指定起始PID")
endFlag := flag.Int("e", 0, "指定结束PID")
helpFlag := flag.Bool("h", false, "显示帮助信息")
flag.Parse()
// 处理帮助标志
if *helpFlag {
printUsage()
os.Exit(0)
}
// 确定操作模式
switch {
case *downloadFlag && *convertFlag:
config.Mode = ModeDownloadConvert
config.AutoConvert = true
case *downloadFlag:
config.Mode = ModeDownload
case *convertFlag:
config.Mode = ModeConvert
default:
config.Mode = ModeUndefined
}
// 设置其他参数
config.OutputFile = *outputFlag
config.StartPID = *startFlag
config.EndPID = *endFlag
// 获取输入文件(最后一个非标志参数)
args := flag.Args()
if len(args) > 0 {
config.InputFile = args[len(args)-1]
}
return config
}
// 验证配置
func validateConfig(config *Config) error {
if config.Mode == ModeUndefined {
return fmt.Errorf("必须指定操作模式 (-d 或 -c)")
}
if config.InputFile == "" {
return fmt.Errorf("必须指定输入文件或URL")
}
if config.Mode == ModeConvert && config.OutputFile == "" {
config.OutputFile = config.InputFile
}
if config.Mode == ModeDownload && config.OutputFile != "" {
fmt.Fprintf(os.Stderr, "警告: -o 选项在下载模式下不可用,已忽略\n")
config.OutputFile = ""
}
if config.Mode == ModeDownloadConvert && config.OutputFile != "" {
fmt.Fprintf(os.Stderr, "警告: -o 选项在下载模式下不可用,已忽略\n")
config.OutputFile = ""
}
return nil
}
// 执行操作
func executeOperation(config Config) error {
data, _ := os.ReadFile("cookie.txt")
fmt.Println("从" + config.InputFile + "的请求标头中复制完整cookie")
fmt.Println(string(data))
cookieStr := string(data)
switch config.Mode {
case ModeDownload:
downloader := NewCCJSONDownloader()
downloader.SetCookies(cookieStr)
return downloader.DownloadJSON(config.InputFile, config.StartPID, config.EndPID, false)
case ModeConvert:
converter := NewCCJSONConverter()
return converter.Convert(config.InputFile, config.OutputFile)
case ModeDownloadConvert:
downloader := NewCCJSONDownloader()
downloader.SetCookies(cookieStr)
return downloader.DownloadJSON(config.InputFile, config.StartPID, config.EndPID, true)
default:
return fmt.Errorf("未知操作模式")
}
}
// 打印使用说明
func printUsage() {
fmt.Printf("用法: %s MODE [-o 输出文件] 输入文件/URL\n", os.Args[0])
fmt.Println("模式:")
fmt.Println(" -d 下载JSON字幕")
fmt.Println(" -c 将JSON字幕转换为SRT格式")
fmt.Println(" -h 显示此帮助信息")
fmt.Println("选项:")
fmt.Println(" -o 指定输出文件 (仅转换模式可用)")
fmt.Println(" -s 指定起始PID")
fmt.Println(" -e 指定结束PID")
fmt.Println("注意: -o 选项在下载模式下不可用!")
bufio.NewReader(os.Stdin).ReadBytes('\n')
}
func (d *CCJSONDownloader) SetCookies(cookieStr string) {
d.cookies = cookieStr
}
// NewCCJSONDownloader 创建新的下载器实例
func NewCCJSONDownloader() *CCJSONDownloader {
jar, _ := cookiejar.New(nil)
return &CCJSONDownloader{
client: &http.Client{
Jar: jar,
Timeout: time.Second * 30, // 设置超时
},
}
}
// DownloadJSON 主下载函数
func (d *CCJSONDownloader) DownloadJSON(inputURL string, pStart, pEnd int, autoConvert bool) error {
// 解析视频信息
partBVID, partAID, err := d.extractVideoInfo(inputURL)
if err != nil {
return err
}
// 提取PID
hasPID, pid := d.extractPID(inputURL)
if !hasPID {
pid = 1
}
// 获取播放列表
playlist, err := d.getPlaylist(partBVID)
if err != nil {
return fmt.Errorf("failed to get playlist: %v", err)
}
// 验证和调整pStart和pEnd
pStart, pEnd = d.adjustPageRange(pStart, pEnd, pid, hasPID, playlist)
// 下载每个页面的字幕
for curPid := pStart; curPid <= pEnd; curPid++ {
if err := d.downloadSubtitlesForPage(curPid, partAID, partBVID, playlist, autoConvert); err != nil {
return fmt.Errorf("failed to download subtitles for page %d: %v", curPid, err)
}
}
return nil
}
// bvid转成aid
func (d *CCJSONDownloader) bv2av(bvid string) string {
type Response struct {
Data struct {
Bvid string `json:"bvid"`
Aid int64 `json:"aid"`
Title string `json:"title"`
} `json:"data"`
}
bvURL := "https://api.bilibili.com/x/web-interface/view?bvid=" + bvid
resp, _ := d.simpleGet(bvURL)
var result Response
_ = json.Unmarshal([]byte(resp), &result)
avid := result.Data.Aid
if avid == 0 {
return "123456789"
}
aid := strconv.FormatInt(avid, 10)
return aid
}
// extractVideoInfo 从HTML中提取视频信息
func (d *CCJSONDownloader) extractVideoInfo(bvurl string) (string, string, error) {
pattern := `BV[0-9A-Za-z]{10}`
re := regexp.MustCompile(pattern)
// 提取BVID
bvid := strings.TrimRight(re.FindString(bvurl), "/")
if bvid == "" {
return "", "", fmt.Errorf("无法从URL中提取BVID")
}
// 提取AID
aid := d.bv2av(bvid)
return bvid, aid, nil
}
// extractPID 从URL中提取PID
func (d *CCJSONDownloader) extractPID(url string) (bool, int) {
re := regexp.MustCompile(`p=\d+`)
match := re.FindString(url)
if match == "" {
return false, 0
}
pidStr := regexp.MustCompile(`\d+`).FindString(match)
pid, err := strconv.Atoi(pidStr)
if err != nil {
return false, 0
}
return true, pid
}
// getPlaylist 获取视频播放列表
func (d *CCJSONDownloader) getPlaylist(bvid string) (map[string]interface{}, error) {
url := fmt.Sprintf("https://api.bilibili.com/x/web-interface/view?bvid=%s", bvid)
resp, err := d.simpleGet(url)
if err != nil {
return nil, err
}
var playlist map[string]interface{}
if err := json.Unmarshal([]byte(resp), &playlist); err != nil {
return nil, fmt.Errorf("failed to parse playlist JSON: %v", err)
}
return playlist, nil
}
// adjustPageRange 调整页面范围
func (d *CCJSONDownloader) adjustPageRange(pStart, pEnd, pid int, hasPID bool, playlist map[string]interface{}) (int, int) {
data := SafeGetArray(playlist, "data", "pages")
if data == nil {
return 1, 1
}
if pStart == 0 && pEnd == 0 {
// 默认下载所有页面
pStart = 1
pEnd = len(data)
} else if pStart != 0 && pEnd == 0 {
// 只下载指定页面
pEnd = pStart
}
// 正确的边界检查
if pStart < 1 {
pStart = 1
}
if pEnd > len(data) {
pEnd = len(data)
}
if pStart > len(data) {
return 1, 1 // 没有有效页面
}
if hasPID {
pStart = pid
pEnd = pid
}
return pStart, pEnd
}
// SafeGet 安全获取嵌套字段(类似JS的可选链)
func SafeGet(data interface{}, keys ...string) interface{} {
current := data
for _, key := range keys {
switch v := current.(type) {
case map[string]interface{}:
if next, exists := v[key]; exists {
current = next
} else {
return nil
}
case []interface{}:
// 如果是数组,尝试将key转换为索引
index, err := stringToInt(key)
if err != nil || index < 0 || index >= len(v) {
return nil
}
current = v[index]
default:
return nil
}
}
return current
}
// SafeGetString 安全获取字符串字段
func SafeGetString(data interface{}, keys ...string) string {
if val := SafeGet(data, keys...); val != nil {
if str, ok := val.(string); ok {
return str
}
}
return ""
}
// SafeGetBool 安全获取布尔字段
func SafeGetBool(data interface{}, keys ...string) bool {
if val := SafeGet(data, keys...); val != nil {
if b, ok := val.(bool); ok {
return b
}
}
return false
}
// SafeGetInt 安全获取整数字段
func SafeGetInt(data interface{}, keys ...string) int {
if val := SafeGet(data, keys...); val != nil {
switch v := val.(type) {
case float64:
return int(v)
case int:
return v
case int64:
return int(v)
}
}
return 0
}
// SafeGetArray 安全获取数组字段
func SafeGetArray(data interface{}, keys ...string) []interface{} {
if val := SafeGet(data, keys...); val != nil {
if arr, ok := val.([]interface{}); ok {
return arr
}
}
return nil
}
// stringToInt 字符串转整数(用于数组索引)
func stringToInt(s string) (int, error) {
var index int
_, err := fmt.Sscanf(s, "%d", &index)
return index, err
}
// downloadSubtitlesForPage 下载指定页面的字幕
func (d *CCJSONDownloader) downloadSubtitlesForPage(pid int, aid, bvid string, playlist map[string]interface{}, autoConvert bool) error {
data := SafeGetArray(playlist, "data", "pages")
if data == nil || pid-1 >= len(data) {
return fmt.Errorf("invalid page ID %d", pid)
}
pageData, ok := data[pid-1].(map[string]interface{})
if !ok {
return fmt.Errorf("invalid page data for PID %d", pid)
}
cidFloat, ok := pageData["cid"].(float64)
if !ok {
return fmt.Errorf("cannot get CID for PID %d", pid)
}
cid := int(cidFloat)
subtitleInfo, err := d.simpleGet(fmt.Sprintf("https://api.bilibili.com/x/player/wbi/v2?aid=%s&cid=%d", aid, cid))
if err != nil {
return fmt.Errorf("failed to get subtitle info: %v", err)
}
// 解析新的JSON格式
var subtitleResponse interface{}
if err := json.Unmarshal([]byte(subtitleInfo), &subtitleResponse); err != nil {
return fmt.Errorf("failed to parse subtitle response JSON: %v", err)
}
subtitles := SafeGetArray(subtitleResponse, "data", "subtitle", "subtitles")
if subtitles == nil {
fmt.Println("未找到字幕信息")
return nil
}
fmt.Printf("找到 %d 个字幕:\n", len(subtitles))
for i, subtitle := range subtitles {
if subMap, ok := subtitle.(map[string]interface{}); ok {
url := SafeGetString(subMap, "subtitle_url")
lan := SafeGetString(subMap, "lan")
lanDoc := SafeGetString(subMap, "lan_doc")
fmt.Printf("\n字幕 %d:\n", i+1)
fmt.Printf(" 语言: %s (%s)\n", lan, lanDoc)
fmt.Printf(" URL: %s\n", url)
// 下载字幕文件
if url != "" {
url = "https:" + url
outputFile := fmt.Sprintf("subtitle_%d_%s.json", pid, lan)
if err := d.downloadFile(url, outputFile); err != nil {
return fmt.Errorf("failed to download subtitle file: %v", err)
}
if autoConvert {
converter := NewCCJSONConverter()
if err := converter.Convert(outputFile, outputFile); err != nil {
return fmt.Errorf("failed to convert subtitle: %v", err)
}
}
}
}
}
return nil
}
// 3 发送HTTP GET请求
func (d *CCJSONDownloader) simpleGet(url string) (string, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", err
}
data, _ := os.ReadFile("cookie.txt")
cookieStr := string(data)
// 如果设置了Cookie字符串,就添加到请求中
if cookieStr != "" {
req.Header.Set("Cookie", cookieStr)
}
// 设置常用请求头
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
req.Header.Set("Referer", "https://www.bilibili.com/")
req.Header.Set("Accept", "*/*")
resp, err := d.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(body), nil
}
// downloadFile 下载文件
func (d *CCJSONDownloader) downloadFile(url, outputPath string) error {
resp, err := d.client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
return os.WriteFile(outputPath, body, 0644)
}
// NewCCJSONConverter 创建新的转换器实例
func NewCCJSONConverter() *CCJSONConverter {
return &CCJSONConverter{}
}
// Convert 主转换函数
func (c *CCJSONConverter) Convert(inputFile, outputFile string) error {
// 替换文件扩展名为.srt
if !strings.HasSuffix(outputFile, ".json") {
outputFile += ".srt"
} else {
outputFile = strings.TrimSuffix(outputFile, ".json") + ".srt"
}
fmt.Printf("%s ==> %s\n", inputFile, outputFile)
// 读取输入文件
inputData, err := os.ReadFile(inputFile)
if err != nil {
return fmt.Errorf("failed to open input file: %v", err)
}
// 解析JSON
var root map[string]interface{}
if err := json.Unmarshal(inputData, &root); err != nil {
return fmt.Errorf("failed to parse json document: %v", err)
}
// 检查body字段
body, ok := root["body"].([]interface{})
if !ok {
return fmt.Errorf("wrong CC format: body is missing or not an array")
}
// 创建输出文件
output, err := os.Create(outputFile)
if err != nil {
return fmt.Errorf("failed to create output file: %v", err)
}
defer output.Close()
// 处理每个字幕条目
for i, item := range body {
subtitle, ok := item.(map[string]interface{})
if !ok {
return fmt.Errorf("wrong CC format: subtitle item %d is not an object", i)
}
// 检查必要字段
from, err := c.getSubtitleTime(subtitle, "from")
if err != nil {
return fmt.Errorf("wrong CC format in item %d: %v", i, err)
}
to, err := c.getSubtitleTime(subtitle, "to")
if err != nil {
return fmt.Errorf("wrong CC format in item %d: %v", i, err)
}
content, ok := subtitle["content"].(string)
if !ok {
return fmt.Errorf("wrong CC format in item %d: content is missing or not a string", i)
}
// 写入SRT格式
if err := c.writeSRTEntry(output, i+1, from, to, content); err != nil {
return fmt.Errorf("failed to write SRT entry %d: %v", i+1, err)
}
}
return nil
}
// getSubtitleTime 获取字幕时间
func (c *CCJSONConverter) getSubtitleTime(subtitle map[string]interface{}, key string) (string, error) {
value, ok := subtitle[key]
if !ok {
return "", fmt.Errorf("%s is missing", key)
}
switch v := value.(type) {
case float64:
return strconv.FormatFloat(v, 'f', -1, 64), nil
case int:
return strconv.Itoa(v), nil
default:
return "", fmt.Errorf("%s is not a number", key)
}
}
// writeSRTEntry 写入SRT条目
func (c *CCJSONConverter) writeSRTEntry(output *os.File, index int, from, to, content string) error {
// 转换时间格式
fromTime, err := c.timeConvert(from)
if err != nil {
return err
}
toTime, err := c.timeConvert(to)
if err != nil {
return err
}
// 处理换行符
content = strings.ReplaceAll(content, "\r\n", "\\N")
content = strings.ReplaceAll(content, "\n", "\\N")
// 写入SRT格式
_, err = fmt.Fprintf(output, "%d\n%s --> %s\n%s\n\n", index, fromTime, toTime, content)
return err
}
// timeConvert 时间格式转换
func (c *CCJSONConverter) timeConvert(raw string) (string, error) {
var h, m, s, ms int
parts := strings.Split(raw, ".")
if len(parts) > 2 {
return "", fmt.Errorf("invalid time format: %s", raw)
}
// 解析秒部分
seconds, err := strconv.Atoi(parts[0])
if err != nil {
return "", fmt.Errorf("invalid seconds: %s", parts[0])
}
h = seconds / 3600
m = (seconds - 3600*h) / 60
s = seconds % 60
// 解析毫秒部分
if len(parts) == 2 {
msStr := parts[1]
// 去除末尾的0
msStr = strings.TrimRight(msStr, "0")
if msStr == "" {
msStr = "0"
}
// 处理超过3位的毫秒
if len(msStr) > 3 {
msStr = msStr[:3]
}
ms, err = strconv.Atoi(msStr)
if err != nil {
return "", fmt.Errorf("invalid milliseconds: %s", parts[1])
}
}
// 格式化输出
msStr := fmt.Sprintf("%03d", ms)
if len(msStr) > 3 {
msStr = msStr[:3]
}
return fmt.Sprintf("%02d:%02d:%02d,%s", h, m, s, msStr), nil
}
|
免费评分
-
查看全部评分
|