如何使用 Nokogiri 解析 HTML 表格?

16

我想解析一个表格,但不知道如何保存其中的数据。我希望能将每行数据保存成以下形式:

['Raw name 1', 2,094, 0,017, 0,098, 0,113, 0,452]

示例表格如下:

html = <<EOT
    <table class="open">
        <tr>
            <th>Table name</th>
            <th>Column name 1</th>
            <th>Column name 2</th>
            <th>Column name 3</th>
            <th>Column name 4</th>
            <th>Column name 5</th>
        </tr>
        <tr>
            <th>Raw name 1</th>
            <td>2,094</td>
            <td>0,017</td>
            <td>0,098</td>
            <td>0,113</td>
            <td>0,452</td>         
        </tr>
        .
        .
        .
        <tr>
            <th>Raw name 5</th>
            <td>2,094</td>
            <td>0,017</td>
            <td>0,098</td>
            <td>0,113</td>
            <td>0,452</td>         
        </tr>
    </table>
EOT

我的网络爬虫代码是:

  doc = Nokogiri::HTML(open(html), nil, 'UTF-8')
  tables = doc.css('div.open')

  @tablesArray = []

  tables.each do |table|
    title = table.css('tr[1] > th').text
    cell_data = table.css('tr > td').text
    raw_name = table.css('tr > th').text
    @tablesArray << Table.new(cell_data, raw_name)
  end

  render template: 'scrape_krasecology'
  end
  end
当我尝试在HTML页面中显示数据时,看起来所有的列名都存储在一个数组元素中,数据也是同样的方式。

1
请将您的代码精简到最小,以展示问题所必需的部分。在问题本身中提供一个演示问题的HTML的最小示例。不要要求我们前往页面提取HTML或构建必要的周围代码来测试您的代码。请参阅“[ask]”、“[mcve]”和 http://codeblog.jonskeet.uk/2010/08/29/writing-the-perfect-question/。 - the Tin Man
@the-tin-man 谢谢。我已经更新了我的代码。相信现在它看起来更好了吧? - verrom
针对那些想要了解这个主题的人们,以下是一些通用信息:http://ruby.bastardsbook.com/chapters/web-crawling/ - benjamin
虽然这样更易读,但仍无法进行测试,甚至无法运行。这就是上面提到的链接的意义;我们需要能够测试您的代码以复制问题。我们可以删除一些代码并使其可运行,但我们不应该这样做。您的HTML没有任何divs,但您的代码显示您正在尝试找到它们。Table是什么?为什么要有render template和两个终止的end?我们必须删除那些东西来进行测试。展示一份返回数据的最小样本,减去使用自定义类的部分。 - the Tin Man
此外,您想要的输出格式很可能无法给您想要的结果。永远不会。将其粘贴到IRb中并查看Ruby认为它意味着什么。编程是一门非常严谨的科学;您必须用同样严谨的术语来描述它(提出问题)。 - the Tin Man
3个回答

24
问题的关键在于对多个结果调用#text会返回每个单独元素的#text串联起来的结果。
让我们分析一下每个步骤的作用:
# Finds all <table>s with class open
# I'm assuming you have only one <table> so
#  you don't actually have to loop through
#  all tables, instead you can just operate
#  on the first one. If that is not the case,
#  you can use a loop the way you did
tables = doc.css('table.open')

# The text of all <th>s in <tr> one in the table
title = table.css('tr[1] > th').text

# The text of all <td>s in all <tr>s in the table
# You obviously wanted just the <td>s in one <tr>
cell_data = table.css('tr > td').text

# The text of all <th>s in all <tr>s in the table
# You obviously wanted just the <th>s in one <tr>
raw_name = table.css('tr > th').text

现在我们知道了问题所在,这里提供一种可能的解决方案:
html = <<EOT
    <table class="open">
        <tr>
            <th>Table name</th>
            <th>Column name 1</th>
            <th>Column name 2</th>
            <th>Column name 3</th>
            <th>Column name 4</th>
            <th>Column name 5</th>
        </tr>
        <tr>
            <th>Raw name 1</th>
            <td>1001</td>
            <td>1002</td>
            <td>1003</td>
            <td>1004</td>
            <td>1005</td>         
        </tr>
        <tr>
            <th>Raw name 2</th>
            <td>2001</td>
            <td>2002</td>
            <td>2003</td>
            <td>2004</td>
            <td>2005</td>         
        </tr>
        <tr>
            <th>Raw name 3</th>
            <td>3001</td>
            <td>3002</td>
            <td>3003</td>
            <td>3004</td>
            <td>3005</td>         
        </tr>
    </table>
EOT

doc = Nokogiri::HTML(html, nil, 'UTF-8')

# Fetches only the first <table>. If you have
#  more than one, you can loop the way you
#  originally did.
table = doc.css('table.open').first

# Fetches all rows (<tr>s)
rows = table.css('tr')

# The column names are the first row (shift returns
#  the first element and removes it from the array).
# On that row we get the text of each individual <th>
# This will be Table name, Column name 1, Column name 2...
column_names = rows.shift.css('th').map(&:text)

# On each of the remaining rows
text_all_rows = rows.map do |row|

  # We get the name (<th>)
  # On the first row this will be Raw name 1
  #  on the second - Raw name 2, etc.
  row_name = row.css('th').text

  # We get the text of each individual value (<td>)
  # On the first row this will be 1001, 1002, 1003...
  #  on the second - 2001, 2002, 2003... etc
  row_values = row.css('td').map(&:text)

  # We map the name, followed by all the values
  [row_name, *row_values]
end

p column_names  # => ["Table name", "Column name 1", "Column name 2",
                #     "Column name 3", "Column name 4", "Column name 5"]
p text_all_rows # => [["Raw name 1", "1001", "1002", "1003", "1004", "1005"],
                #     ["Raw name 2", "2001", "2002", "2003", "2004", "2005"],
                #     ["Raw name 3", "3001", "3002", "3003", "3004", "3005"]]

# If you want to combine them
text_all_rows.each do |row_as_text|
  p column_names.zip(row_as_text).to_h
end # =>
    # {"Table name"=>"Raw name 1", "Column name 1"=>"1001", "Column name 2"=>"1002", "Column name 3"=>"1003", "Column name 4"=>"1004", "Column name 5"=>"1005"}
    # {"Table name"=>"Raw name 2", "Column name 1"=>"2001", "Column name 2"=>"2002", "Column name 3"=>"2003", "Column name 4"=>"2004", "Column name 5"=>"2005"}
    # {"Table name"=>"Raw name 3", "Column name 1"=>"3001", "Column name 2"=>"3002", "Column name 3"=>"3003", "Column name 4"=>"3004", "Column name 5"=>"3005"}

不要使用 css(...).first,而要使用 at_css(...) 或其兄弟节点。它更易读且更短。此外,不要养成使用 css('...').text 的习惯。它可能会给你带来麻烦。请参阅 https://stackoverflow.com/q/43594656/128421 获取更多信息。 - the Tin Man
2
谢谢您。这真的帮了我很大的忙。 - Paul Danelli

2
您想要的输出是无意义的:
['Raw name 1', 2,094, 0,017, 0,098, 0,113, 0,452]
# ~> -:1: Invalid octal digit
# ~> ['Raw name 1', 2,094, 0,017, 0,098, 0,113, 0,452]

我会假设你想要引用的数字。
在去除使代码无法工作的内容并将HTML减少到更易管理的示例后,然后运行它:
require 'nokogiri'

html = <<EOT
    <table class="open">
        <tr>
            <th>Table name</th>
            <th>Column name 1</th>
            <th>Column name 2</th>
        </tr>
        <tr>
            <th>Raw name 1</th>
            <td>2,094</td>
            <td>0,017</td>
        </tr>
        <tr>
            <th>Raw name 5</th>
            <td>2,094</td>
            <td>0,017</td>
        </tr>
    </table>
EOT


doc = Nokogiri::HTML(html)
tables = doc.css('table.open')

tables_data = []

tables.each do |table|
  title = table.css('tr[1] > th').text # !> assigned but unused variable - title
  cell_data = table.css('tr > td').text
  raw_name = table.css('tr > th').text
  tables_data << [cell_data, raw_name]
end

这句话的意思是“导致了什么结果:”。
tables_data
# => [["2,0940,0172,0940,017",
#      "Table nameColumn name 1Column name 2Raw name 1Raw name 5"]]

首先要注意的是您没有使用title,尽管您对其进行了赋值。可能是在清理代码时出现了这种情况,作为一个示例。 csssearchxpath返回一个NodeSet,类似于Node数组。当您在NodeSet上使用textinner_text时,它会返回每个节点的文本连接成一个字符串:

获取所有包含的Node对象的内部文本。

这是它的行为:
require 'nokogiri'

doc = Nokogiri::HTML('<html><body><p>foo</p><p>bar</p></body></html>')

doc.css('p').text # => "foobar"

相反,您应该遍历找到的每个节点,并单独提取其文本。这在 Stack Overflow 上已经被多次介绍了。
doc.css('p').map{ |node| node.text } # => ["foo", "bar"]

这可以简化为:
doc.css('p').map(&:text) # => ["foo", "bar"]

请参考 "如何在爬取时避免合并节点中的所有文本"。
关于使用 Node 时的 contenttextinner_text,文档中是这样描述的:

返回此节点的内容。

而实际上,你需要获取单个节点的文本内容。
require 'nokogiri'

html = <<EOT
    <table class="open">
        <tr>
            <th>Table name</th>
            <th>Column name 1</th>
            <th>Column name 2</th>
            <th>Column name 3</th>
            <th>Column name 4</th>
            <th>Column name 5</th>
        </tr>
        <tr>
            <th>Raw name 1</th>
            <td>2,094</td>
            <td>0,017</td>
            <td>0,098</td>
            <td>0,113</td>
            <td>0,452</td>         
        </tr>
        <tr>
            <th>Raw name 5</th>
            <td>2,094</td>
            <td>0,017</td>
            <td>0,098</td>
            <td>0,113</td>
            <td>0,452</td>         
        </tr>
    </table>
EOT


tables_data = []

doc = Nokogiri::HTML(html)

doc.css('table.open').each do |table|

  # find all rows in the current table, then iterate over the second all the way to the final one...
  table.css('tr')[1..-1].each do |tr|

    # collect the cell data and raw names from the remaining rows' cells...
    raw_name = tr.at('th').text
    cell_data = tr.css('td').map(&:text)

    # aggregate it...
    tables_data += [raw_name, cell_data]
  end
end

现在的结果是:
tables_data
# => ["Raw name 1",
#     ["2,094", "0,017", "0,098", "0,113", "0,452"],
#     "Raw name 5",
#     ["2,094", "0,017", "0,098", "0,113", "0,452"]]

你可以想办法将引用的数字转换为 Ruby 可接受的十进制数,或者随意操作内部数组。

非常感谢您的回答和解释!这个答案非常有用,帮了我大忙! - verrom

0

我猜你是从这里或其他相关的参考资料中借用了一些代码(或者我给出了错误的参考)- http://quabr.com/34781600/ruby-nokogiri-parse-html-table

然而,如果你想捕获所有的行,你可以修改以下代码 -

希望这能帮助你解决问题。

doc = Nokogiri::HTML(open(html), nil, 'UTF-8')

# We need .open tr, because we want to capture all the columns from a specific table's row

@tablesArray = doc.css('table.open tr').reduce([]) do |array, row|
  # This will allow us to create result as this your illustrated one
  # ie. ['Raw name 1', 2,094, 0,017, 0,098, 0,113, 0,452]
  array << row.css('th, td').map(&:text)
end

render template: 'scrape_krasecology'

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接