抓取xml文件的工作就不多说了,很简单的事,只要在播放页面看看源文件就能确定xml文件的地址进行抓取了。
本文主要是讲述xml内的弹幕转字幕的过程。
除去xml文件开头结尾的一些七七八八的东西,弹幕主体是这样的:
<d p="51.593,5,25,16711680,1408852480,7fa769b4,576008622">怒求 up 自己配音!</d> <d p="10.286,1,16777215,1408852600,a3af4d0d,576011065">颜艺?</d> <d p="12.65,1408852761,24570b5a,576014281">我的女神!</d> <d p="19.033,1408852789,cb20d1c7,576014847">前!!!</d> <d p="66.991,1408852886,a78e484d,576016806">已撸</d>
如果它把弹幕的各种属性分开表示,我就用encoding/xml包来解码,但是丫把弹幕的属性都放在p里面了,所以我使用正则表达式来提取的。
以上表第一条弹幕为例。很明显的,p属性开始的浮点数,与播放时一比对,就能知道,表示的是弹幕应该出现的播放时间。
随后的1和25先不管;
16777215,目测应该是颜色(因为该值表示为十六进制是FFFFFF);
1408852480,在弹幕中是递增的,感觉应该是个unix时间,用这个数(d),求:d/86400/365.2425+1970,结果约为2014.6。看来确实是unix时间。估计是创建弹幕的时间。
0,不知道,抓取了很多视频的弹幕,这个位置都是0,暂且不管它。
7fa769b4,估计是创建者的ID,因为同一xml文件会出现多次,而且看起来是十六进制数,恰好有些hash函数就是返回4字节整数。
576008622,也是递增的,不用猜也知道,这个肯定就是弹幕的ID了。
事后再核对一下,果然,1代表弹幕的类型(从右向左移动啊,出现在下方或者上方啊……),25是字体大小,16777125是字体颜色。
所以,我们就只要捕获每条弹幕的时间、类型、大小、颜色、文本就行了。
正则表达式:
<d\sp="([\d\.]+),([145]),(\d+),\d+,\w+,\d+">([^<>]+?)</d>捕获弹幕很简单,关键是排布弹幕为字幕的算法。
关于这个算法我就很坑爹的弄了个乱七八糟的算法,采用的是固定移动速度,最小重叠的排布原则。
对游动弹幕,会倾向于选择下面一行的位置,如果会重叠,则选择更下一行(最低行会循环到最上面一行),如果没有不重叠的行,会选择重叠文本最少的行。
对上现隐/下现隐的固定弹幕,会选择最接近上方/下方,且不重叠的行;如果没有不重叠的行,则选择重叠时间最短的行,居中放置字幕。
默认字体微软雅黑,默认大小25,默认白色黑边;默认占满整个屏幕,共计12行;默认屏幕大小640x360。
高级弹幕真的超出我的能力范围了,全部忽略掉。
go源代码如下:
// 将bilibili的xml弹幕文件转换为ass字幕文件。 // xml文件中,弹幕的格式如下: // <d p="32.066,1409046965,017d3f58,579516441">地板好评</d> // p的属性为时间、弹幕类型、字体大小、字体颜色、创建时间、?、创建者ID、弹幕ID。 // p的属性中,后4项对ass字幕无用,舍弃。被<d>和</d>包围的是弹幕文本。 // 只处理右往左、上现隐、下现隐三种类型的普通弹幕。 package main import ( "fmt" "io" "io/IoUtil" "math" "os" "regexp" "sort" "strconv" "strings" ) // ass文件的头部 const header = `[Script Info] ScriptType: v4.00+ Collisions: Normal playResX: 640 playResY: 360 [V4+ Styles] Format: Name,Fontname,Fontsize,primaryColour,SecondaryColour,OutlineColour,BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,Encoding Style: Default,Microsoft YaHei,28,&H00FFFFFF,&H00000000,100,0.00,2,10,0 [Events] Format: Layer,Start,End,Style,Name,Effect,Text ` // 正则匹配获取弹幕原始信息 var line = regexp.MustCompile(`<d\sp="([\d\.]+),\d+">([^<>]+?)</d>`) // 用来保管弹幕的信息 type Danmu struct { text string time float64 kind byte size int color int } // 使[]Danmu实现sort.Interface接口,以便排序 type Danmus []Danmu func (d Danmus) Len() int { return len(d) } func (d Danmus) Less(i,j int) bool { return d[i].time < d[j].time } func (d Danmus) Swap(i,j int) { d[i],d[j] = d[j],d[i] } // 将正则匹配到的数据填写入Danmu类型里 func fill(d *Danmu,s [][]byte) { d.time,_ = strconv.ParseFloat(string(s[1]),64) d.kind = s[2][0] - '0' d.size,_ = strconv.Atoi(string(s[3])) bgr,_ := strconv.Atoi(string(s[4])) d.color = ((bgr >> 16) & 255) | (bgr & (255 << 8)) | ((bgr & 255) << 16) d.text = string(s[5]) } // 返回文本的长度,假设ascii字符都是0.5个字长,其余都是1个字长 func length(s string) float64 { l := 0.0 for _,r := range s { if r < 127 { l += 0.5 } else { l += 1 } } return l } // 生成时间点的ass格式表示:`0:00:00.00` func timespot(f float64) string { h,f := math.Modf(f / 3600) m,f := math.Modf(f * 60) return fmt.Sprintf("%d:%02d:%05.2f",int(h),int(m),f*60) } // 读取文件并获取其中的弹幕 func open(name string) ([]Danmu,error) { data,err := IoUtil.ReadFile(name) if err != nil { return nil,err } dan := line.FindAllSubmatch(data,-1) ans := make([]Danmu,len(dan)) for i := len(dan) - 1; i >= 0; i-- { fill(&ans[i],dan[i]) } return ans,nil } // 将弹幕排布并写入w,采用的简单的固定移速、最小重叠排布算法 func save(w io.Writer,dans []Danmu) { p1 := make([]float64,36) p2 := make([]float64,36) p3 := make([]float64,36) t := 0 max := func(x []float64) float64 { i := x[0] for _,j := range x[1:] { if i < j { i = j } } return i } set := func(x []float64,f float64) { for i,_ := range x { x[i] = f } } find := func(p []float64,f float64,i,d int) int { i = (i/d + 1) * d % 36 m,k := f+10000,0 for j := 0; j < 36; j += d { t := (i + j) % 36 if n := max(p[t : t+d]); n <= f { k = t break } else if m > n { k = t m = n } } return k } for _,dan := range dans { s,l := "",length(dan.text) if l == 0 { continue } switch { case dan.size < 25: dan.size,l,s = 2,l*18,"\\fs18" case dan.size == 25: dan.size,l = 3,l*28 case dan.size > 25: dan.size,s = 4,l*38,"\\fs38" } if dan.color != 0x00FFFFFF { s += fmt.Sprintf("\\c&H%06X",dan.color) } switch dan.kind { case 1: // 右往左 t := find(p1,dan.time,t,dan.size) set(p1[t:t+dan.size],dan.time+8) h := (t+dan.size)*10 - 1 s += fmt.Sprintf("\\move(%d,%d,%d)",640+int(l/2),h,-int(l/2),h) fmt.Fprintf(w,"Dialogue: 1,%s,Default,0000,{%s}%s\n",timespot(dan.time+0),timespot(dan.time+8),s,dan.text) case 4: // 下现隐 j := find(p2,35,dan.size) set(p2[j:j+dan.size],dan.time+4) s += fmt.Sprintf("\\pos(%d,320,(36-j)*10-1) fmt.Fprintf(w,"Dialogue: 2,timespot(dan.time+4),dan.text) case 5: // 上现隐 j := find(p3,dan.size) set(p3[j:j+dan.size],(j+dan.size)*10-1) fmt.Fprintf(w,"Dialogue: 3,dan.text) } } } // 主函数,实现了命令行 func main() { if len(os.Args) <= 1 { os.Exit(0) } for _,name := range os.Args[1:] { dans,err := open(name) if err != nil { os.Exit(1) } if n := strings.LastIndex(name,"."); n != -1 { name = name[:n] } name += ".ass" file,err := os.Create(name) if err != nil { os.Exit(2) } file.WriteString(header) sort.Sort(Danmus(dans)) save(file,dans) file.Close() } }
2014.9.2 9:30am更新:对字体排布进行了修正。
2014.9.2 9:50am更新:算法修改为固定出现时间,最小重叠排布,最终版本。