[インデックス 10086] ファイルの概要
このコミットは、Go言語のhtml
パッケージにおけるHTMLテーブルのパース処理の改善を目的としています。特に、"foster parenting"(要素の養子縁組)時の隣接するテキストノードのマージと、</tr>
タグでのテーブル行の適切なクローズ処理に焦点を当てています。これにより、HTMLの仕様に準拠したより堅牢なテーブルパースが実現され、特定のテストケース(tests1.dat
, test 32)が正しく処理されるようになります。
コミット
- コミットハッシュ:
6e318bda6c4236caf5a7f02d5ce545f5365094e0
- Author: Andrew Balholm andybalholm@gmail.com
- Date: Wed Oct 26 11:36:46 2011 +1100
- Subject: html: improve parsing of tables
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/6e318bda6c4236caf5a7f02d5ce545f5365094e0
元コミット内容
html: improve parsing of tables
When foster parenting, merge adjacent text nodes.
Properly close table row at </tr> tag.
Pass tests1.dat, test 32:
<!-----><font><div>hello<table>excite!<b>me!<th><i>please!</tr><!--X-->
| <!-- - -->
| <html>
| <head>
| <body>
| <font>
| <div>
| "helloexcite!"
| <b>
| "me!"
| <table>
| <tbody>
| <tr>
| <th>
| <i>
| "please!"
| <!-- X -->
R=nigeltao
CC=golang-dev
https://golang.org/cl/5323048
変更の背景
HTMLのパースは、その柔軟性と寛容性から非常に複雑なタスクです。特にテーブル要素は、その構造が厳密に定義されている一方で、ブラウザは不正なマークアップに対しても可能な限りレンダリングを試みるため、パースロジックは多くのエッジケースを考慮する必要があります。
このコミットの背景には、主に以下の2つの問題がありました。
- "Foster Parenting"時のテキストノードのマージ不足: HTMLのパースにおいて、特定の状況下で要素が本来の親ではなく、別の要素の子として「養子縁組(foster parenting)」されることがあります。例えば、テーブル内で不正なマークアップがあった場合、その要素がテーブルの外に「養子縁組」されることがあります。この際、隣接するテキストノードが適切にマージされず、DOMツリーが意図しない形で分割されてしまう問題がありました。元のコミットメッセージにあるテストケース
<!-----><font><div>hello<table>excite!<b>me!<th><i>please!</tr><!--X-->
では、"hello"
と"excite!"
が別々のテキストノードとして扱われていた可能性があります。 </tr>
タグでのテーブル行の不適切なクローズ: HTMLのテーブル構造では、<tr>
タグでテーブルの行が始まり、</tr>
タグで閉じられます。しかし、パースのロジックが不完全な場合、</tr>
タグが検出されても、現在のテーブル行が適切に閉じられず、DOMツリーの構造が崩れる可能性がありました。これは、特にネストされたテーブルや複雑なテーブルレイアウトにおいて問題を引き起こす可能性があります。
これらの問題は、HTML5のパース仕様に完全に準拠し、ブラウザの挙動を模倣するために修正が必要でした。
前提知識の解説
HTMLパースの基本
HTMLパースとは、HTMLドキュメントを読み込み、その構造を解析して、ブラウザがレンダリングできるような内部表現(通常はDOMツリー)を構築するプロセスです。このプロセスは大きく以下の2つの段階に分けられます。
- トークン化 (Tokenization): HTMLの生データを、意味のある単位である「トークン」に分解します。例えば、
<p>
は開始タグトークン、</p>
は終了タグトークン、Hello
はテキストトークンなどです。 - ツリー構築 (Tree Construction): トークン化されたストリームを基に、DOM(Document Object Model)ツリーを構築します。DOMツリーは、HTMLドキュメントの論理的な構造を表すツリー構造であり、各ノードはHTML要素、テキスト、コメントなどを表します。
HTMLテーブルのパースルール
HTMLのテーブルは、その構造が厳密に定義されています。<table>
要素は、<caption>
、<colgroup>
、<thead>
、<tbody>
、<tfoot>
、<tr>
、<th>
、<td>
などの子要素を持つことができます。ブラウザは、これらの要素が正しい順序で出現することを期待しますが、不正なマークアップに対してもエラー回復メカニズムを持っています。
例えば、<td>
や<th>
が<tr>
の外に出現した場合、ブラウザは自動的に<tr>
要素を挿入して正しい構造にしようとします。また、<tbody>
が明示的に記述されていなくても、<tr>
要素が出現すれば自動的に<tbody>
が生成されることがあります。
Foster Parenting (要素の養子縁組)
"Foster Parenting"は、HTML5のパースアルゴリズムにおける重要な概念の一つです。これは、特定の要素(特にテーブル関連の要素)が、本来あるべき親要素のスコープ外で出現した場合に、パースエラーを回避するために、別の適切な親要素の「養子」として扱われるメカニズムを指します。
例えば、<table>
要素の直下に<div>
のようなブロック要素が出現した場合、HTMLの仕様ではこれは不正なマークアップです。しかし、ブラウザはエラーでパースを停止するのではなく、この<div>
要素をテーブルの外に「養子縁組」させ、DOMツリーの別の場所に配置しようとします。このプロセスは、ウェブページのレンダリングを中断させないためのブラウザの寛容なエラー回復戦略の一部です。
このコミットでは、この「養子縁組」が行われた際に、隣接するテキストノードが適切に結合されるように修正されています。
スタックと挿入モード
HTML5のパースアルゴリズムは、状態機械と要素のスタック("stack of open elements")を使用して動作します。
- スタック (Stack of Open Elements): 現在開いているHTML要素の階層構造を追跡するために使用されます。新しい要素が開始タグで開かれるとスタックにプッシュされ、終了タグで閉じられるとポップされます。
- 挿入モード (Insertion Mode): 現在パースしているHTMLのコンテキストに基づいて、新しいトークンをどのように処理するかを決定する状態です。例えば、
inBodyIM
(body要素内)、inTableIM
(table要素内)、inRowIM
(tr要素内)など、様々な挿入モードが存在します。各挿入モードには、特定のトークンが検出された場合の処理ルールが定義されています。
技術的詳細
Go言語のhtml
パッケージ(golang.org/x/net/html
)は、HTML5のパース仕様に準拠したHTMLパーサーを提供します。このパッケージは、ウェブスクレイピングやHTMLドキュメントの操作に広く利用されています。
このパッケージのパース処理は、内部的にHTML5の仕様に記述されている複雑なアルゴリズムを実装しています。これには、トークン化、ツリー構築、そして様々な挿入モードと要素のスタック管理が含まれます。
fosterParent
関数
fosterParent
関数は、HTML5のパースアルゴリズムにおける「foster parenting」のロジックを実装しています。これは、特定の状況下で要素が通常の親ではなく、別の適切な親に挿入されるべき場合に呼び出されます。このコミットでは、この関数に隣接するテキストノードをマージするロジックが追加されました。
clearStackToContext
関数
clearStackToContext
関数は、要素のスタックをクリアする(特定の要素が見つかるまでスタックから要素をポップする)ための汎用的な関数です。以前はclearStackToTableContext
というテーブル専用の関数がありましたが、このコミットでより汎用的なclearStackToContext
に置き換えられ、stopTags
という引数で停止タグのリストを受け取るようになりました。これにより、異なるコンテキストでスタックをクリアする際に、コードの再利用性が向上しました。
inTableIM
とinRowIM
挿入モード
inTableIM
はテーブル要素内でのパースを、inRowIM
はテーブル行(<tr>
)要素内でのパースをそれぞれ担当する挿入モードです。これらのモードでは、テーブルの構造を正しく構築するために、特定のタグが検出された際の特別な処理が定義されています。
このコミットでは、inTableIM
内でtbody
, tfoot
, thead
, td
, th
, tr
タグが検出された際に、clearStackToTableContext
の代わりにclearStackToContext(tableScopeStopTags)
が呼び出されるように変更されました。
また、inRowIM
において</tr>
終了タグが検出された際の処理が改善されました。以前はTODO
コメントで示されていた部分が、elementInScope
とclearStackToContext
を使用して、<tr>
要素がスコープ内に存在するかを確認し、存在する場合はtableRowContextStopTags
(tr
またはhtml
)までスタックをクリアし、現在の<tr>
要素をポップするように修正されました。これにより、</tr>
タグが検出された際にテーブル行が適切に閉じられるようになりました。
コアとなるコードの変更箇所
このコミットでは、以下の2つのファイルが変更されています。
src/pkg/html/parse.go
: HTMLパースの主要なロジックが含まれるファイル。tableRowContextStopTags
という新しいグローバル変数が追加されました。fosterParent
関数に、隣接するテキストノードをマージするロジックが追加されました。clearStackToTableContext
関数が削除され、より汎用的なclearStackToContext
関数が追加されました。inTableIM
関数内で、clearStackToTableContext
の呼び出しがclearStackToContext(tableScopeStopTags)
に置き換えられました。inRowIM
関数内で、</tr>
終了タグの処理ロジックが大幅に改善されました。
src/pkg/html/parse_test.go
: HTMLパースのテストが含まれるファイル。TestParser
関数内のテストケースのループ回数が32
から33
に増加しました。これは、新しいテストケース(tests1.dat
, test 32)をカバーするためです。
コアとなるコードの解説
src/pkg/html/parse.go
tableRowContextStopTags
の追加
// stopTags for use in clearStackToContext.
var (
tableRowContextStopTags = []string{"tr", "html"}
)
clearStackToContext
関数で使用される新しい停止タグのリストが定義されました。これは、テーブル行のコンテキストでスタックをクリアする際に、tr
またはhtml
要素が見つかるまでスタックをポップすることを示します。
fosterParent
関数の変更
func (p *parser) fosterParent(n *Node) {
// ... 既存のコード ...
if i > 0 && parent.Child[i-1].Type == TextNode && n.Type == TextNode {
parent.Child[i-1].Data += n.Data
return
}
// ... 既存のコード ...
}
fosterParent
関数に、隣接するテキストノードをマージするロジックが追加されました。
if i > 0 && parent.Child[i-1].Type == TextNode && n.Type == TextNode
: これは、現在のノードn
がテキストノードであり、かつその親要素parent
の直前の兄弟ノード(parent.Child[i-1]
)もテキストノードである場合に真となります。parent.Child[i-1].Data += n.Data
: この条件が満たされた場合、直前のテキストノードのデータに現在のテキストノードのデータを結合(マージ)します。return
: マージが成功した場合、現在のノードn
はDOMツリーに追加する必要がないため、関数を終了します。
これにより、例えば"hello"
と"excite!"
が別々のテキストノードとして生成された場合でも、これらが"helloexcite!"
として一つのテキストノードに結合されるようになります。
clearStackToTableContext
からclearStackToContext
への変更
// 変更前
// func (p *parser) clearStackToTableContext() { ... }
// 変更後
// clearStackToContext pops elements off the stack of open elements
// until an element listed in stopTags is found.
func (p *parser) clearStackToContext(stopTags []string) {
for i := len(p.oe) - 1; i >= 0; i-- {
for _, tag := range stopTags {
if p.oe[i].Data == tag {
p.oe = p.oe[:i+1]
return
}
}
}
}
clearStackToTableContext
関数が削除され、より汎用的なclearStackToContext
関数が導入されました。この新しい関数はstopTags
という文字列スライスを受け取り、スタックをクリアする際に、このリスト内のいずれかのタグが見つかるまで要素をポップします。これにより、テーブルコンテキストだけでなく、他のコンテキストでも同様のスタッククリアロジックを再利用できるようになりました。
inTableIM
関数の変更
func inTableIM(p *parser) (insertionMode, bool) {
case StartTagToken:
switch p.tok.Data {
case "tbody", "tfoot", "thead":
// 変更前: p.clearStackToTableContext()
p.clearStackToContext(tableScopeStopTags)
p.addElement(p.tok.Data, p.tok.Attr)
return inTableBodyIM, true
case "td", "th", "tr":
// 変更前: p.clearStackToTableContext()
p.clearStackToContext(tableScopeStopTags)
p.addElement("tbody", nil)
return inTableBodyIM, false
// ... 既存のコード ...
}
// ... 既存のコード ...
}
inTableIM
関数内で、tbody
, tfoot
, thead
, td
, th
, tr
などの開始タグが検出された際のスタッククリア処理が、clearStackToTableContext()
からclearStackToContext(tableScopeStopTags)
に置き換えられました。tableScopeStopTags
は"html", "table"
を含むため、テーブルのスコープ内でスタックをクリアする挙動は維持されますが、より汎用的な関数が使用されるようになりました。
inRowIM
関数の変更
func inRowIM(p *parser) (insertionMode, bool) {
case EndTagToken:
switch p.tok.Data {
case "tr":
// 変更前: // TODO.
if !p.elementInScope(tableScopeStopTags, "tr") {
return inRowIM, true
}
p.clearStackToContext(tableRowContextStopTags)
p.oe.pop()
return inTableBodyIM, true
// ... 既存のコード ...
}
// ... 既存のコード ...
}
inRowIM
関数内で、</tr>
終了タグが検出された際の処理が大幅に改善されました。
if !p.elementInScope(tableScopeStopTags, "tr")
: まず、tr
要素がtableScopeStopTags
(html
またはtable
)のスコープ内に存在するかどうかを確認します。存在しない場合、これは不正な</tr>
タグであり、現在の挿入モードを維持して処理を続行します。p.clearStackToContext(tableRowContextStopTags)
:tr
要素がスコープ内に存在する場合、tableRowContextStopTags
(tr
またはhtml
)が見つかるまでスタックをクリアします。これにより、現在のtr
要素とその子孫要素がスタックから適切にポップされます。p.oe.pop()
: その後、スタックの最上位にあるtr
要素自体をポップします。return inTableBodyIM, true
: 処理が成功した場合、挿入モードをinTableBodyIM
(テーブルボディ内)に遷移させ、トークンを再処理しないことを示します。
この変更により、</tr>
タグが検出された際に、HTML5の仕様に従ってテーブル行が正しく閉じられるようになりました。
src/pkg/html/parse_test.go
func TestParser(t *testing.T) {
// ... 既存のコード ...
// TODO(nigeltao): Process all test cases, not just a subset.
// 変更前: for i := 0; i < 32; i++ {
for i := 0; i < 33; i++ {
// ... 既存のコード ...
}
// ... 既存のコード ...
}
TestParser
関数内のループ回数が32
から33
に増加しました。これは、tests1.dat
ファイルのテストケース32(インデックス31)をカバーするために行われました。このテストケースは、コミットメッセージに記載されている複雑なテーブルパースのシナリオを検証するためのものです。
関連リンク
- GitHubコミット: https://github.com/golang/go/commit/6e318bda6c4236caf5a7f02d5ce545f5365094e0
- Go Change List: https://golang.org/cl/5323048
参考にした情報源リンク
- HTML Parsing: https://scientyficworld.org/
- HTML Parsing Process: https://medium.com/
- HTML Parsing Tools: https://scrapingant.com/
- HTML Table Parsing Rules (WHATWG): https://whatwg.org/
- HTML Table Parsing Rules (W3C): https://www.w3.org/
- HTML Table Element Hierarchy (Mozilla): https://developer.mozilla.org/
- Go html package parsing (golang.org/x/net/html): https://pkg.go.dev/golang.org/x/net/html
- Go html package parsing (ScrapingAnt): https://scrapingant.com/blog/go-web-scraping
- Go html package parsing (ZenRows): https://www.zenrows.com/blog/go-web-scraping
- Go html package parsing (Medium): https://medium.com/@sagar.g.s/web-scraping-with-go-a-comprehensive-guide-to-golang-s-net-html-package-and-goquery-library-b21212121212
- Go html package parsing (Bright Data): https://brightdata.com/blog/how-to-scrape-websites-with-go
- Go html package parsing (ZetCode): https://zetcode.com/go/html/
- Go html package parsing (Reintech): https://reintech.io/blog/web-scraping-in-go
- HTML Parsing Error Recovery: https://nikodoko.com/
- Foster Parenting (Carteret County NC): https://www.carteretcountync.gov/
- Foster Parenting (Citizens Information): https://www.citizensinformation.ie/
- Foster Parenting (Orange Grove Foster Care): https://orangegrovefostercare.co.uk/
- Foster Parenting (AdoptUSKids): https://www.adoptuskids.org/