上次记录了基于Go的网页内容相似度检测算法
业务需求变更,相似度检测不针对内容而针对网页结构,由此,记录由Go实现的网页结构相似度检测算法
算法思想参考:简单树匹配算法STM-理论篇 、 简单树匹配算法STM-实践篇 (涉及动态规划算法)
S i m i l a r i t y ( T 1 , T 2 ) = S i m p l e T r e e M a t c h i n g ( T 1 , T 2 ) ( ∣ T 1 ∣ + ∣ T 2 ∣ ) / 2 Similarity(T_1, T_2) = \frac{SimpleTreeMatching(T_1,T_2)}{(|T_1|+|T_2|)/2} Similarity(T1,T2)=(∣T1∣+∣T2∣)/2SimpleTreeMatching(T1,T2)
作者使用Python实现,这里用Go复现一遍~
网页结构相似度检测算法
一、提取网页的DOM结点
网页的DOM结点爬取借助工具chromedp("github.com/chromedp/chromedp")
:
ps:chromedp的源码阅读 龟速更新中…
func TravelSubtree(ctx *context.Context, pageUrl string) []*cdp.Node {
//提取web页面的DOM节点
var nodes []*cdp.Node
task := &chromedp.Tasks{
chromedp.Navigate(pageUrl),
chromedp.WaitVisible(`body`, chromedp.ByQuery),
chromedp.Nodes(`body`, &nodes),
//chromedp.ActionFunc(func(c context.Context) error {
// return dom.RequestChildNodes(nodes[0].NodeID).WithDepth(-1).Do(c)
//}),
chromedp.Sleep(time.Second),
}
err := chromedp.Run(*ctx, *task)
if err != nil {
log.Fatal(err)
}
return nodes
}
写法参考官方案例,捕获以 body
为根的DOM树(要等渲染完成哦),其中 chromedp.ActionFunc
执行的对子结点的查询,个人认为没啥影响,所以注释掉了。
对于浏览器的配置,全凭个人需求,这里展示我配的浏览器及上下文环境生成:
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", true),
chromedp.Flag("disable-gpu", true),
chromedp.Flag("no-sandbox", true),
chromedp.Flag("ignore-certificate-errors", true),
//chromedp.Flag("disable-images", true),
chromedp.Flag("disable-web-security", true),
chromedp.Flag("disable-xss-auditor", true),
chromedp.Flag("disable-setuid-sandbox", true),
chromedp.Flag("allow-running-insecure-content", true),
chromedp.Flag("disable-webgl", true),
chromedp.Flag("disable-popup-blocking", true),
chromedp.WindowSize(1920, 1080),
chromedp.Flag("disable-dev-shm-usage", true),
chromedp.UserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"),
)
allocCtx, Acancel := chromedp.NewExecAllocator(context.Background(), opts...)
defer Acancel()
ctx, cancel := chromedp.NewContext(allocCtx,
chromedp.WithLogf(log.Printf),
)
defer cancel()
err := chromedp.Run(ctx) //这里会启动一个空页(若无分页需求可不写)
if err != nil {
log.Fatal(err)
}
chromedp.Run(ctx)
会打开一个空白页,无实际内容。这个写法是为了后续启动协程时,所有页面以分页形式在同一个浏览器内实现,否则并发会启动多个浏览器。
二、动态规划匹配算法 S i m p l e T r e e M a t c h i n g ( T 1 , T 2 ) SimpleTreeMatching(T_1,T_2) SimpleTreeMatching(T1,T2)
1、底层DOM树动态规划算法
确定输入参数:两个DOM结点
确定输出参数:两棵DOM树的最大匹配结点数
动态规划算法:
func SimpleTree(root_a, root_b *cdp.Node) int {
//动态规划实现简单树匹配算法
if root_a == nil || root_b == nil {
return 0
}
if strings.ToLower(root_a.NodeName) != strings.ToLower(root_b.NodeName) {
return 0
}
if root_a.NodeName == "#text" || root_b.NodeName == "#text" {
return 0
}
childrens_a := root_a.Children
childrens_b := root_b.Children
m := len(childrens_a)
n := len(childrens_b)
//初始化二维数组
res_M := make([][]int, m+1)
for i := 0; i < m+1; i++ {
res_M[i] = make([]int, n+1)
}
for i := 1; i < m+1; i++ {
for j := 1; j < n+1; j++ {
res_M[i][j] = tools.MaxInt(tools.MaxInt(res_M[i-1][j], res_M[i][j-1]), res_M[i-1][j-1]+SimpleTree(childrens_a[i-1], childrens_b[j-1]))
}
}
return res_M[m][n] + 1
}
三个 if
语句的目的是:确定递归边界并排除不必要结点。
第一个,确定边界并排除空结点;
第二个,忽视名字(即标签)不一致的结点;
第三个,忽视文本结点。
💥 原作者将第三个判断写为:if not hasattr(root_a, "children") or not hasattr(root_b, "children"):
,即忽视无孩子的结点,用Go可以表达为: if root_a.ChildNodeCount == 0 || root_b.ChildNodeCount == 0
.
💫 个人认为这种方式不妥,考虑到 <input>
、<br>
等标签也没有孩子结点,但它们也是判断页面结构相似度的依据,故在这里只忽视文本结点:if root_a.NodeName == "#text" || root_b.NodeName == "#text"
.
【动态规划原理探索】
i ∈ [ 1 , A 的孩子总数 ] j ∈ [ 1 , B 的孩子总数 ] A 的前 i 个孩子树与 B 的前 j 个孩子树的共有结点数 = 1 + { A 的前 i − 1 个孩子树与 B 的前 j 个孩子树的共有结点数 A 的前 i 个孩子树与 B 的前 j − 1 个孩子树的共有结点数 A 的前 i − 1 个孩子与 B 的前 j − 1 个孩子的共有结点数 + A 中以 i 为根与 B 中以 j 为根的 D O M 树的共有结点数 i \in [1, A的孩子总数] \\ j \in [1, B的孩子总数] \\ A的前i个孩子树与B的前j个孩子树的共有结点数 = 1 + \begin{cases} A的前i-1个孩子树与B的前j个孩子树的共有结点数 \\ A的前i个孩子树与B的前j-1个孩子树的共有结点数 \\ A的前i-1个孩子与B的前j-1个孩子的共有结点数 \\ \qquad + A中以i为根与B中以j为根的DOM树的共有结点数 \end{cases} i∈[1,A的孩子总数]j∈[1,B的孩子总数]A的前i个孩子树与B的前j个孩子树的共有结点数=1+⎩ ⎨ ⎧A的前i−1个孩子树与B的前j个孩子树的共有结点数A的前i个孩子树与B的前j−1个孩子树的共有结点数A的前i−1个孩子与B的前j−1个孩子的共有结点数+A中以i为根与B中以j为根的DOM树的共有结点数
(ps:A指以 root_a
为根的DOM树、B指以 root_b
为根的DOM树)
(ps:1是两棵树的当前根节点,当前根节点一定相同,不同的都被第二个 if
语句过滤掉了)
(ps:“A中以i为根与B中以j为根的DOM树的共有结点数”,在代码中写为 childrens_a[i-1], childrens_b[j-1]
,因为我们的描述是从1开始计数的,而代码中数组从0开始计数)
2、外层DOM树数组动态规划算法
由于前面捕获的DOM树的类型是 []*cdp.Node
,我理解为好多棵树。
上面仅做了对一棵树的动态规划算法,对于多棵树的共有结点数,我们不能通过单纯相加获取。🌰举个例子,可能存在A中的前两棵树与B中的后两棵树更相似,也可能存在A中的后某棵树与B中的前某棵树更相似。
我们判定网页结构相似应考虑更加全面的情况,因此,我们在外层再做一次简单的动态规划:
func Compute(nodes1, nodes2 []*cdp.Node) {
var num1, num2 int
//初始化二维数组
res := make([][]int, len(nodes1)+1)
for i := 0; i < len(nodes1)+1; i++ {
res[i] = make([]int, len(nodes2)+1)
}
for i, node1 := range nodes1 {
for j, node2 := range nodes2 {
res[i+1][j+1] = tools.MaxInt(tools.MaxInt(res[i][j+1], res[i+1][j]), res[i][j] + SimpleTree(node1, node2))
}
}
}
【动态规划原理探索】
i
∈
[
1
,
A
的孩子总数
]
j
∈
[
1
,
B
的孩子总数
]
n
o
d
e
s
1
中前
i
个
D
O
M
树与
n
o
d
e
s
2
中前
j
个
D
O
M
树的共有结点数
=
1
+
{
n
o
d
e
s
1
中前
i
−
1
个
D
O
M
树与
n
o
d
e
s
2
中前
j
个
D
O
M
树的共有结点数
n
o
d
e
s
1
中前
i
个
D
O
M
树与
n
o
d
e
s
2
中前
j
−
1
个
D
O
M
树的共有结点数
n
o
d
e
s
1
中前
i
−
1
个
D
O
M
树与
n
o
d
e
s
2
中前
j
−
1
个
D
O
M
树的共有结点数
+
n
o
d
e
s
1
中第
i
个与
n
o
d
e
s
2
中第
j
个
D
O
M
树的共有结点数
i \in [1, A的孩子总数] \\ j \in [1, B的孩子总数] \\ nodes1中前i个DOM树与nodes2中前j个DOM树的共有结点数 = 1 + \begin{cases} nodes1中前i-1个DOM树与nodes2中前j个DOM树的共有结点数 \\ nodes1中前i个DOM树与nodes2中前j-1个DOM树的共有结点数 \\ nodes1中前i-1个DOM树与nodes2中前j-1个DOM树的共有结点数 \\ \qquad + nodes1中第i个与nodes2中第j个DOM树的共有结点数 \end{cases}
i∈[1,A的孩子总数]j∈[1,B的孩子总数]nodes1中前i个DOM树与nodes2中前j个DOM树的共有结点数=1+⎩
⎨
⎧nodes1中前i−1个DOM树与nodes2中前j个DOM树的共有结点数nodes1中前i个DOM树与nodes2中前j−1个DOM树的共有结点数nodes1中前i−1个DOM树与nodes2中前j−1个DOM树的共有结点数+nodes1中第i个与nodes2中第j个DOM树的共有结点数
(ps:上述对 i
,j
的描述与代码中写法不同,原因同上,因为数组从0开始计数)
此外注意,Go没有提供 int
类型的大小比较方法,所以自己写个:
package tools
func MaxInt(a, b int) int {
if a > b {
return a
} else {
return b
}
}
三、各网页的DOM结点统计 ∣ T 1 ∣ |T_1| ∣T1∣ ∣ T 2 ∣ |T_2| ∣T2∣
确定输入参数:DOM根结点
确定输出参数:该DOM树的结点总数
递归算法:
func GetNodeNum(root *cdp.Node) int {
if root == nil {
return 0
} else if root.NodeName == "#text" {
return 0
} else {
res := 1
for _, child := range root.Children {
res += GetNodeNum(child)
}
return res
}
}
简单来说就是:所有结点数=当前结点 + 孩子树1号拥有的结点 + 孩子树2号拥有的结点 + …
应用举例:
//nodes1, nodes2 []*cdp.Node
var num1, num2 int
for _, node1 := range nodes1 {
num1 += impl.GetNodeNum(node1)
}
for _, node2 := range nodes2 {
num2 += impl.GetNodeNum(node2)
}
现在, S i m p l e T r e e M a t c h i n g ( T 1 , T 2 ) SimpleTreeMatching(T_1,T_2) SimpleTreeMatching(T1,T2) 以及 ∣ T 1 ∣ |T_1| ∣T1∣ 、 ∣ T 2 ∣ |T_2| ∣T2∣ 都已经得到了,最终的网页结构相似度直接套用文章顶部的公式就可以啦~
四、性能优化
现在开始优化工作!
1、遍历优化
由于计算共有结点数和各树的总结点数都需要遍历DOM树数组,因此,我们简化为一次遍历做完全部的工作,结合最终计算,我们将 Compute
函数修改为:
func compute(nodes1, nodes2 []*cdp.Node) {
var num1, num2 int
res := make([][]int, len(nodes1)+1)
for i := 0; i < len(nodes1)+1; i++ {
res[i] = make([]int, len(nodes2)+1)
}
for i, node1 := range nodes1 {
num1 += impl.GetNodeNum(node1)
for j, node2 := range nodes2 {
if i == 0 {
num2 += impl.GetNodeNum(node2)
}
res[i+1][j+1] = tools.MaxInt(tools.MaxInt(res[i][j+1], res[i+1][j]), res[i][j]+impl.SimpleTree(node1, node2))
}
}
similarity := 2 * float64(res[len(nodes1)][len(nodes2)]) / float64(num1+num2)
fmt.Println("两棵树的最大匹配结点数=", res[len(nodes1)][len(nodes2)])
fmt.Println("第一棵树中结点数目=", num1)
fmt.Println("第二棵树中结点数目=", num2)
fmt.Println("网页结构相似度=", similarity)
}
2、协程池优化
经实验发现,项目的速度慢在chromedp加载网页的过程上。为节约时间,我们启动协程池同时加载两个网页。
测试网页随机选择新浪文章及新浪网主页,公有变量如下:
package global
import (
"context"
"github.com/chromedp/cdproto/cdp"
"sync"
)
var (
Nodes1, Nodes2 []*cdp.Node
Ctx *context.Context
TaskWG sync.WaitGroup
)
const (
Url1 = `https://finance.sina.com.cn/jjxw/2023-08-27/doc-imzishxw0942275.shtml`=
Url2 = `https://finance.sina.com.cn/stock/relnews/hk/2023-08-29/doc-imzivaup9556699.shtml`
Url3 = `https://www.sina.com.cn/`
)
首先,我们将加载网页和获取DOM树的过程写为单任务:
func task1() {
defer global.TaskWG.Done()
ctx, cancel := chromedp.NewContext(*global.Ctx)
defer cancel()
global.Nodes1 = impl.TravelSubtree(&ctx, global.Url1)
}
func task2() {
defer global.TaskWG.Done()
ctx, cancel := chromedp.NewContext(*global.Ctx)
defer cancel()
global.Nodes2 = impl.TravelSubtree(&ctx, global.Url2)
}
其次,创建协程池:
//创建协程池
p, _ := ants.NewPool(2)
defer p.Release()
global.TaskWG.Add(1)
//向协程池注册各任务 -- 实现多页面加载
go func() {
//注册并启动协程
err := p.Submit(task1)
if err != nil {
global.TaskWG.Done()
log.Fatal("addTask1 error:", err)
}
}()
global.TaskWG.Add(1)
go func() {
//注册并启动协程
err := p.Submit(task2)
if err != nil {
global.TaskWG.Done()
log.Fatal("addTask2 error:", err)
}
}()
global.TaskWG.Wait()
最后就可以启动我们的 Compute(global.Nodes1, global.Nodes2)
计算啦
3、协程池启动前后的性能测试
测试语句
start := time.Now() // 获取当前时间
//...执行...
elapsed := time.Since(start)
fmt.Println("执行完成耗时:", elapsed)
结果如下:
【相似页面】
Url1 =https://finance.sina.com.cn/jjxw/2023-08-27/doc-imzishxw0942275.shtml
Url2 =https://finance.sina.com.cn/stock/relnews/hk/2023-08-29/doc-imzivaup9556699.shtml
【不相似页面】
Url2 =https://finance.sina.com.cn/stock/relnews/hk/2023-08-29/doc-imzivaup9556699.shtml
Url3 =https://www.sina.com.cn/
结论:Go的协程并发确实快很多,速度成倍下降!
【道阻且长,行则将至】