吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 1984|回复: 11
收起左侧

[其他原创] go语言版 B站CC字幕转srt

  [复制链接]
zwb83925462 发表于 2025-9-17 16:05
之前在本专区内看到了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
}

免费评分

参与人数 2吾爱币 +8 热心值 +1 收起 理由
红叶落尽 + 1 热心回复!
hrh123 + 7 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

 楼主| zwb83925462 发表于 2025-9-18 11:33
如果要下载好几个视频的字幕可能回碰到重名的情况,建议找到这句  outputFile := fmt.Sprintf("subtitle_%d_%s.json", pid, lan)   然后改成  outputFile := fmt.Sprintf("%s_%d_%s.json", bvid, pid, lan)
这样就能以BV号区分下载的字幕是哪个视频的了.
 楼主| zwb83925462 发表于 2025-9-17 20:23
wxq_hz 发表于 2025-9-17 19:16
不错不错,学习一下

如果想要别的语言的版本,可以用ai,比如腾讯元宝,转成nodejs之类的都是完美转换的,直接下载就能运行了
wxq_hz 发表于 2025-9-17 19:16
px307 发表于 2025-9-17 20:28
学习一下,大佬厉害 !  
shuangxi520 发表于 2025-9-18 09:12
学习了,go语言!
光之继承者 发表于 2025-9-18 09:54
Go语言是最好的
Shapooo 发表于 2025-9-18 09:57
最近刚好要用,没想到推到首页看到了。

已经用上了。

支持一下
mnb666100 发表于 2025-9-18 10:46
厉害,学习了
 楼主| zwb83925462 发表于 2025-9-18 11:34
Shapooo 发表于 2025-9-18 09:57
最近刚好要用,没想到推到首页看到了。

已经用上了。

下个多个视频的字幕时可能会重名,建议看下置顶的内容做下修改
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-9-9 15:36

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表