HTML逆向生成Markdown -- Part 1
HTML逆向生成Markdown – Part 1
之前想做一个能够提取网页中文章并转换为Markdown格式的Chrome插件,所以才有了这个项目。 结果我低估了解析的难度,花了十几天才做出来一个半成品。遂放弃,记录下实现的思路,等以后水平提升了再来完善。
解析过程分为四个阶段。以下是各个阶段的简要说明。
- 分词:将HTML原始文本分割为HTML标签
- 生成虚拟DOM节点:将分割后的HTML标签转换成对应的节点
- 构建虚拟DOM树:将节点根据其顺序生成相应的DOM树
- 生成Markdown文本:根据预先定义HTML To Markdown的转换规则,对DOM树进行转换。我参考的转换规则
st=>start: 源HTML文本
lexer=>operation: 分词
parser=>operation: 生成虚拟DOM节点
filter=>inputoutput: 过滤无效节点
vdomt=>operation: 构建虚拟DOM树
filterAgain=>inputoutput: 根据转换规则再次过滤节点
md=>operation: 生成Markdown文本
ed=>end: 输出Markdown文本
st(right)->lexer->parser->filter->vdomt->filterAgain->md(right)->ed
下面这段HTML文本将作为解析的样例文本:
1<h2 id="逆向解析HTMl">逆向解析HTMl</h2>
2<p><a href="https://www.baidu.com" rel="nofollow" target="_blank">Markdown</a>解析过程分为四个阶段</p>
3<ul>
4<li>分词</li>
5<li>生成虚拟DOM节点</li>
6<li>构建虚拟DOM树</li>
7<li><p>生成Markdown文本</p></li>
8</ul>
分词
我们将源HTML文本按照HTML元素的语法,分解为Opening tag
Closing tag
Enclosed text content
。
因为HTML元素内部很可能还有嵌套的元素,所以还要继续分割Enclosed text content
直到只剩下文字文本
。
从上图来看很明显,Opening tag
和Closing tag
都是由<
>
这两个符号包裹的,那我们只需要对愿HTML文本进行一次搜索,将被<
>
包裹起来的字符串提取出来,放入一个数组中。搜索结束后数组就是我们分词的结果。
原始文本经过分割后,如下所示:
1const result = [
2 '<h2 id="逆向解析HTMl">',
3 '逆向解析HTMl',
4 '</h2>',
5 '<p>',
6 '<a href="https://www.baidu.com" rel="nofollow" target="_blank">',
7 'Markdown',
8 '</a>',
9 '解析过程分为四个阶段',
10 '</p>',
11 '<ul>',
12 '<li>',
13 '分词',
14 '</li>',
15 '<li>',
16 '生成虚拟DOM节点',
17 '</li>',
18 '<li>',
19 '构建虚拟DOM树',
20 '</li>',
21 '<li>',
22 '<p>',
23 '生成Markdown文本',
24 '</p>',
25 '</li>',
26 '</ul>'
27]
需要注意的是,html标签中的属性值是允许出现<
和>
这两个符号的,也就是说会出现类似<div data-demo="<demo>asd</demo>">
这样的文本。
这里要注意的是不能直接从头到尾搜索<
>
然后提取里面的字符串,不然会出现提取到<div data-demo="<demo>
这样的结果。
我实现的方法比较简单,是利用栈来判断HTML标签的开始和结束。
- 首先从下标0开始,遍历字符串
2.
- 如果当前的字符是
<
,则将其压入栈中。 - 如果当前的字符是
>
,且栈顶是<
,则表示一个HTML标签的结束。 然后将开始符号<
和结束符号>
之间的字符串提取出来保存到结果数组就好了。 - 如果当前的字符是
"
,且栈顶不是"
,则将其压入栈中。 - 如果当前的字符是
"
,且栈顶是"
,则将栈顶元素弹出。
- 如果当前的字符是
具体实现见lexer.js
生成虚拟DOM节点
在这一阶段,主要要对节点的属性的过滤,HTML标签内部的大部分属性都是不需要的。除了a
img
等几个HTML元素。
得到分词后的结果之后,就可以解析HTML标签字符串生成一个个包含HTML标签信息的对象。
对象类型如下:
1const obj = {
2 // 固定属性
3 tag, // HTML标签名。如`div`, `span`
4 type, // 自定义的HTML标签名所对应的数字。
5 position, // 标签所在的位置。开始标签(Opening tag):1,结束标签(Closing tag):2,空元素(empty tag)和文本节点(text node):3
6 // 可选属性
7 attr, // 标签内属性的键值对,这是一个对象。一些需要保留属性的元素如`a`元素需要保留`href` `title`用来生成Markdown文本。
8 content // 文本节点特有,用来保存文本
9}
这一过程得到的结果如下(有点多,这里只截取前6个比较有代表性的):
1const result = [
2 {
3 tag: 'h2',
4 type: 42, // 不要在意`type`属性,这是自定义的,42代表`h2`元素对应数字
5 position: 1
6 },
7 {
8 tag: 'textNode',
9 type: 1,
10 position: 3,
11 content: '逆向解析HTMl'
12 },
13 {
14 tag: 'h2',
15 type: 42,
16 position: 2
17 },
18 {
19 tag: 'p',
20 type: 6,
21 position: 1
22 },
23 {
24 tag: 'a',
25 type: 2,
26 position: 1,
27 attr: {
28 href: 'https://www.baidu.com'
29 }
30 },
31 {
32 tag: 'textNode',
33 type: 1,
34 position: 3,
35 content: 'Markdown'
36 },
37]
这部分的实现思路也比较简单,基本上都是字符串处理。
tag
:HTML标签的结构很简单,大致就以下几种:(最后一种不需要处理,可以忽略)<tagName attrKey="attrValue" attrKey>
<tagName attrKey="attrValue" attrKey >
<tagName/>
<tagName />
</tagName>
(忽略)
很容易发现要想得到tagName只需要找到在
<
和(空格
或/
)之间的字符串就可以了。type
:这个属性是为了方便之后的类型处理添加的,毕竟数字相对字符串来说更好处理。我在配置文件里写了一个映射表(配置文件),以
tag
作为key对应数字作为value。这样就能很方便的对应起来。position
:这个属性虽然叫position
,其实type
才更适合它,因为它标识了开始标签(Opening tag):1,结束标签(Closing tag):2,空元素(empty tag)和文本节点(text node):3position
的判断我写的比较简单,只考虑到了上文tag
所列的几种情况(但也已经能包括大部分情况了)。从上面那几种情况来说。 只要判断tag
开始位置的下标索引是/不是1
,就能知道是/不是Opening tag了。关于文本节点的判断:文本节点是没有
tag
的,如果无法搜索到tag
,就可以将节点标识为文本节点。attr
:attr
里面保存着解析成Markdown文本所需要的一些属性。得益于Markdown语法的简洁,HTML标签大部分的属性都是可以忽略的。基本上只需要src
,title
,alt
,id
这几种,下面是相对应的语法:- Markdown规范中的与链接有关的语法(
Links
Images
Heading IDs
Footnotes
)。Links
:src
title
Image
:src
title
alt
Heading IDs
:id
Footnotes
:id
- Markdown规范中的与链接有关的语法(
content
:是文本节点独有的属性,表示文本节点的内容。
具体实现见parser.js
结束
反向解析要详细讲比较繁琐,这是第一部分,预计分三章讲完。