网页结构相似度检测算法——Go(动态规划)

上次记录了基于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的前i1个孩子树与B的前j个孩子树的共有结点数A的前i个孩子树与B的前j1个孩子树的共有结点数A的前i1个孩子与B的前j1个孩子的共有结点数+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中前iDOM树与nodes2中前jDOM树的共有结点数=1+ nodes1中前i1DOM树与nodes2中前jDOM树的共有结点数nodes1中前iDOM树与nodes2中前j1DOM树的共有结点数nodes1中前i1DOM树与nodes2中前j1DOM树的共有结点数+nodes1中第i个与nodes2中第jDOM树的共有结点数
(ps:上述对 ij 的描述与代码中写法不同,原因同上,因为数组从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的协程并发确实快很多,速度成倍下降!


【道阻且长,行则将至】

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值