Nokogiri 問題求救


#1

最近在練習Nokogiri,
下面是我要爬的網頁,結構很簡單。

學歷、經歷、專科證書名稱,這三個項目我可以爬出來。

不過,
我想要把臺北醫學大學附設醫院婦產部產科主任、臺北醫學大學附設醫院產後護理之家主任、臺北醫學大學醫學院專任講師,這三項都放到一個Array裡面。
另外,再把婦產科專科醫師證書、超音波專業醫師證書、台灣周產期專科醫師證書、國際認證泌乳顧問、高危險妊娠照護專家證書,這五項放到另一個Array裡面去,

結構雖然很簡單,但因為他們都是p tag,所以我還找不到正確的方法,
只好厚著臉皮上來問一下,請問各位大大有解法嗎?
感恩~

<div class="col-sm-8">
                    <p><span style="font-size:14px;"><strong>學歷</strong></span></p>

<p>•&nbsp;臺北醫學大學 醫學系</p>

<p><span style="font-size:14px;"><strong>經歷</strong></span></p>

<p>•&nbsp;臺北醫學大學附設醫院婦產部產科主任</p>

<p>•&nbsp;臺北醫學大學附設醫院產後護理之家主任</p>

<p>•&nbsp;臺北醫學大學醫學院專任講師</p>

<p><span style="font-size:14px;"><strong><span style="line-height: 1.6;">專科證書名稱</span></strong></span></p>

<p>•&nbsp;<span style="color: rgb(90, 90, 90); font-family: verdana, 微軟正黑體; font-size: 14px; line-height: 25px;">&nbsp;</span>婦產科專科醫師證書</p>

<p>•&nbsp;超音波專業醫師證書</p>

<p>•&nbsp;台灣周產期專科醫師證書</p>

<p>•&nbsp;國際認證泌乳顧問</p>

<p>•&nbsp;高危險妊娠照護專家證書</p>

<p style="text-align: right;">&nbsp;</p>
                </div>

#2

我覺得 你直接先把所有 p tag 選出來 再依照內容過濾就好
因為印象裡 你這個情況沒有對應的 css selector 可以做


#3

嗯,不用怕丟臉,多問點沒關係,單純我最近很無聊沒東西可以挑戰…哈哈,& 抱歉我單純貼我自己寫的模式,可能有點難和深入,其他的人可以用自己寫的方式補充,事情解決掉即可,沒有對錯之分

# source_txt = File.read('txt.txt') 讀取上面原文
ans = source_txt.scan(/>([^\n><]+)</).flatten.map{|i|i.gsub(/•/ , '').gsub(/&nbsp;/ , '').strip}.delete_if{|i|i.empty?}
#["學歷", "臺北醫學大學 醫學系", "經歷", "臺北醫學大學附設醫院婦產部產科主任", "臺北醫學大學附設醫院產後護理之家主任", "臺北醫學大學醫學院專任講師", "專科證書名稱", "婦產科專科醫師證書", "超音波專業醫師證書", "台灣周產期專科醫師證書", "國際認證泌乳顧問", "高危險妊娠照護專家證書"]

結束,regex很美的,不用嗎?(好啦,這是自修幾年的心得 X"DD)

內容解釋,String.scan 是選出 regex match 的 group,所以 regex 內用 ( ) 包括就會出項目,returnarray,挑選方式很簡單,選 >< 中間『^ 不包含』 \n 斷行符號,還有左右邊界> <的字串『+ 一字元以上』,之後出的會類似 [[data]...],會多一層 array,所以用 Array.flatten 切成同層,之後看你要用一個還是多個 String.gsub 移除不要的字串,String.strip移除頭尾空白,並用 Array.delete_ifobj.empty? 來移除空字串,應該就是你要的結果哩

剩下的,你可以用『學歷,經歷,專科證書名稱』來做 array 的切割即可,類似

temp = []
ans.each do |str|
  case str
  when "學歷" , "經歷" , "專科證書名稱"
    temp << []
  else
    temp[-1] << str
  end
end
#temp => [["臺北醫學大學 醫學系"], ["臺北醫學大學附設醫院婦產部產科主任", "臺北醫學大學附設醫院產後護理之家主任", "臺北醫學大學醫學院專任講師"], ["婦產科專科醫師證書", "超音波專業醫師證書", "台灣周產期專科醫師證書", "國際認證泌乳顧問", "高危險妊娠照護專家證書"]]

結束,一切都是流程和應用而已,& 相關 doc 在

https://ruby-doc.org/core-2.2.0/String.html
https://ruby-doc.org/core-2.2.0/Array.html
https://ruby-doc.org/core-2.2.0/Regexp.html

而你可以先用 nokogiri 切出多個子集合,然後再用上面的方式處理即可,招式很多可以彼此切換使用哩,以上


#4

如果都是 p tag 沒有辦法由 css selector 來處理
那你可以用 finite state machine 的概念來處理
p 偵測到文字為學歷之後,下面的 p 都儲存到學歷的array
p 偵測到文字為經歷之後,下面的 p 都儲存到經歷的array

hash = {}
hash["學歷"] = []
hash["經歷"] = []
@paragraphs.each do |p|
  if p.text.match(/學歷/)
   status = 0
 elsif p.text.match(/經歷/)
  status = 1
 elsif p.text.gsub("&nbsp;", "").size == 0
  status  = 2 
 end
 hash["學歷"] << p.text() if status == 0
 hash["經歷"] << p.text() if status == 1
end

這邊正規表達式沒有下的很嚴格,越嚴格越能保證資料沒有取得錯誤。


#5

感謝各位大大的回覆。
不過,為什麼我跑出來的還是不一樣的東西?

require 'open-uri'
require 'nokogiri'
html = open('http://www.tmuh.org.tw/team/team/053/053/050216').read
doc = Nokogiri::HTML(html)
regex =  doc.css('.col-sm-8 > p').to_html
ans = regex.scan(/>([^\n><]+)</).flatten.map{|i|i.gsub(/•/ , '').gsub(/&nbsp;/ , '').strip}.delete_if{|i|i.empty?}

regex會變成:
#"<p><span style=\"font-size:14px;\"><strong>學歷</strong></span></p><p>• 臺北醫學大學 醫學系</p><p><span style=\"font-size:14px;\"><strong>經歷</strong></span></p><p>• 臺北醫學大學附設醫院婦產部產科主任</p><p>• 臺北醫學大學附設醫院產後護理之家主任</p><p>• 臺北醫學大學醫學院專任講師</p><p><span style=\"font-size:14px;\"><strong><span style=\"line-height: 1.6;\">專科證書名稱</span></strong></span></p><p>• <span style=\"color: rgb(90, 90, 90); font-family: verdana, 微軟正黑體; font-size: 14px; line-height: 25px;\"> </span>婦產科專科醫師證書</p><p>• 超音波專業醫師證書</p><p>• 台灣周產期專科醫師證書</p><p>• 國際認證泌乳顧問</p><p>• 高危險妊娠照護專家證書</p><p style=\"text-align: right;\"> </p>"

ans 會變成:
#["學歷", " 臺北醫學大學 醫學系", "經歷", " 臺北醫學大學附設醫院婦產部產科主任", " 臺北醫學大學附設醫院產後護理之家主任", " 臺北醫學大學醫學院專任講師", "專科證書名稱", " ", " ", "婦產科專科醫師證書", " 超音波專業醫師證書", " 台灣周產期專科醫師證書", " 國際認證泌乳顧問", " 高危險妊娠照護專家證書", " "]

/•/ 雖然把•弄掉了,可是•後面的空格還是留著,就算我把它改成/• /也是一樣。
(" 臺北醫學大學 醫學系",臺前面會多一個空格)
lstrip跟strip都用過了
而且後面還多了兩個 " " ," " ,到底是怎麼一回事???


#6

你看到的空白,我剛看一下是 160
正常空白是 32


你可以單獨取一個字元出來,然後用類似

" ".ord #=> 32 / 160 # a0 / 20 是16進位表示而已
160.to_s(16) #=> 'a0'  #decimal to hexadecimal

就知道該編號多少,眼不見得為憑,尤其在字串編碼的時候(你應該知道全形和半形空白的分別對唄?事實上這世界還有"很多種"空白的啊),strip 記得是把 /\r\n\t\s/ 去除而已 ,其中 \s 應該只有 32,然後你的 code 寫得很糟糕,我重新寫一份唄

require 'open-uri'
require 'nokogiri'
require 'awesome_print' #多掛一個gem來顯示,請自己安裝哩
html = open('http://www.tmuh.org.tw/team/team/053/053/050216').read
doc = Nokogiri::HTML(html)

ans = []

#定位到你需要的外框的 dom 即可
doc.css('.col-sm-8').each do |main_dom| #改用列表做法
  ans << main_dom.to_html.scan(/>([^\n><]+)</).flatten.map{|i|i.gsub(/(•|&nbsp;)/ , '').gsub(/\u00a0/ , ' ').strip}.delete_if(&:empty?)
  #合併 • , &nbsp; 過濾
  #不准在code內貼奇怪的空白,所以用\u00a0指定char,事實上 • 也該這樣指定
  #順便把特殊空白取代成一般空白,這樣文內的空白也才正常
  #之前delete_if + empty?無法刪除的原因是字串長度不等於0,因為裡面還有特殊空白不是?
  #delete_if + empty? 改用 call method name 的寫法,用 & 丟進一個 symbol name 來 call
end

ap ans

anyway 有因有果,自己動些腦袋哩

& 附註:很多種空白

https://www.cs.tut.fi/~jkorpela/chars/spaces.html

而最簡單的就一招,先看 unicode 編碼才去看他是啥,然後想辦法過濾即可
unicode 還有很多東西好玩,類似 RTL / LTR 的控制符號

… 還有一票鬼東西,相信你 code 寫得越久,都會遇到才是,以上


#7

感謝JC大,特殊空白的問題解決了。

不過,我把你寫的code存成一個 jc.rb。
然後,為了可以放到我自己的doctor model裡面去,
在console裡面輸入這兩行,是可以執行的。
name = doc.css(’//h2’).text.strip
@doctor = Doctor.create( :name => name)
所以我就把這兩行加到jc.rb去了

回到原本網站的目錄下,我執行ruby jc.rb
則會出現uninitialized constant Play (NameError)

網路上面好像找不到說爬蟲下來的資料怎麼放到網站去。
有看到可以把爬蟲寫成rake task,
又好像在console裡面慢慢輸入也是可以,
請問有什麼建議的做法嗎?


#8

我們 Youtube 新手教學內有教你如何寫個 Ruby lib 弄到 Rails 去,甚至寫個機器人都有教你,可以和 Rails / rake 無關的(因為 rake 要啟動整個 rails 很慢的啊…)

甚至這邊也有教你如何在純 Ruby 下用 ActiveRecord 所以機器人可以和 Rails 拖勾就是(存取同一個 DB 即可)其餘的就是你該做的工作了


#9

收到!回去再把youtube看一遍。


#10

又厚著臉皮回來了…

延續上面抓回來的資料
Doctor model 有一個 attribute 叫做 經歷:string,
可是現在爬回來的資料有 [“臺北醫學大學附設醫院 超音波科主任”, “臺北醫學大學副教授”],

可是好像沒有辦法把 經歷 改成 Array,再把那兩筆存進去,然後用each的方式把那兩筆資料叫出來。

雖然說好像可以改成 Doctor has many 經歷,但是經歷好像又沒有重要到需要另外新增一個table,
google找到的答案是用Serialize :經歷,可是 stack overflow 好像不是很推薦這樣的用法,
請問各位大大有什麼比較推薦的方法嗎?


#11

這是資料分割的方式了,如果這筆資料很多,那我建議你切成一對一的表,也就是 has_one + belongs_to

如果你知道什麼是 devise 或是會員登入系統,一個會員資料可能分為

User [ id , Email , 密碼 , 暱稱 , 姓名 , 地址 , 手機 , 家用電話 ]

但只有Email,暱稱和登入資料會一直使用,類似把 mail 和暱稱顯示在畫面上方,但地址都不會顯示,且地址一定都是超過 N 字元以上,每次都要 SELECT 出來並不是好事,所以通常會變成

User [ id , Email , 密碼 , 暱稱 ]

Profile [ user_id , 姓名 , 地址 , 手機 , 家用電話 ]
        ( index user_id unique 或乾脆把 user_id 變成 primary key 但不自動遞增 )

強制一比一的對照表即可,這樣每次都找 User 而其餘必要時才去找 Profile,其中如果是有 text 欄位或是過長的 string 通常都要認真考慮要不要切成一比一表,否則沒注意,每次都拉一票垃圾出來不是好事哩

所以使用者列表時只需要拉 User,User show 頁時才再多拉一個 Profile,這樣不是很漂亮咪?

當然如果 text 過大過長,就不建議用 DB 來存了,你可以選用其他的 DB,類似 mongo db / elasticsearch / ssdb 甚至是純粹的檔案… 單純 key 的參照用 RDBMS 的為主即可,類似把上面的 Profile 丟到別的世界去,然後還能找回來即可

至於用不用 serialize,這點還好,實務上我有用過 marshal / json / csv / base64 ,甚至自己用類似 “|” 來作分隔自己切都是好方法,想要的目的達到即可,沒有好與壞的分別哩,反而是上面的切割方式會重要些就是了,因為沒切好就是比你 serialize 的問題,還差上百倍千倍的狀況,傳輸損耗和效能損耗之類的

至於最後的就是"人性"了,上面的所有的說法都是針對超過一萬筆以上才有明顯的差別,什麼用不用 serialize 的問題之類的,如果你的網站人流少,能看就好,和效能無關,差一點點沒關係,就塞在一起唄,把事情先做好能交差比較重要,不過把這點放在心上,以後發覺有效能問題的時候,再來拆解這部分即可,也就是:『不要過早最佳化』,你還有別的事情比你目前的還需要專注完成的不是嗎?

以上


#12

目前覺得這些資料似乎沒有重要到必須用relation,
所以決定先在console裡面用array.join("\n"),
然後在view裡面加上simple_format去顯示就好了。

感恩JC大的開示。

http://apidock.com/ruby/Array/join
http://apidock.com/rails/ActionView/Helpers/TextHelper/simple_format


#13

這幾天繼續試著練習nokogiri,
想要用google,然後把台灣所有的婦產科爬下來。

發現搜尋出來的google頁面,不能夠用nokogiri去分析。本來以後要用什麼api的才行,可是又意外發現,nokogiri不能抓javascript,所以要把url改一下才能夠分析。

參考頁面是這個:

一個問題剛解決了,但又立馬有第二個問題:
發現在搜尋結果的第一頁,
雖然最下面可以找到前十頁的連結,但是沒辦法連到第十一頁。
是可以用page=1…n這樣一直下去,不過想要問看看有沒有比較聰明的方法,
參考這個頁面,可是也跑不出來:


page 1
page = Nokogiri::HTML(open(start_url))
do_something_with page

repeat until no more “next” links
while a = page.at(‘a[title=“Next page”]’)
page = Nokogiri::HTML(open(a[:href]))
do_something_with page
end

手機打字,請見諒。

這邊是我拚出來的:

html = open(https://www.google.com.tw/search?q=%E5%A9%A6%E7%94%A2%E7%A7%91).read
page  = Nokogiri::HTML(html)
這個會有東西:     
page.css('td.b').css('a')[0].css('span').text
可是這個會跑出來nil
page.at('td.b').css('a')[0].css('span[text = "下一頁"]')

at 這個功能在這裡:
http://nokogiri.rubyforge.org/nokogiri/Nokogiri/XML/Node.html#M000251


#14

單純因為你的迴圈沒控制好唄?

歷遍的迴圈的基本寫法應該是大概如下,我用接近自然語言寫流程給你看(非實際code)

#處理單頁下載
def get_page(url)
  return Nokogiri::HTML(RestClient.get(url).body)
end

#這邊處理單一頁面,增加回傳有沒有"下一頁"這件事
def get_page_and_split_list(page_flag)
  datas = {}
  doc = get_page("https://domain/page={page_flag}")
  datas[:has_next_page] = doc.css('.paginate a').map{|a|a.inner_html.to_i}.sort[-1] > page_flag
  datas[:body] = doc.css('section').map{|i|i.inner_html}
  return datas
end

#主要流程控制
def go!
  page_flag = 1
  begin
    datas = get_page_and_split_list(page_flag)
    break unless check_duplicate(datas[:body])
    datas[:body].each do |doc|
      #...do something
    end
    page_flag += 1 if datas[:has_next_page]
  end while !datas[:body].empty? && datas[:has_next_page]
end

大概的觀念就這樣而已哩,當然還會更細分些之類的,不過起手式應該都是

begin
  #...
end while condition

而不是一般的有固定邊界的寫法(x...y)因為你的邊界是依照內容而定不是咪?

大概就這樣唄,以上


#15

可是為什麼我的
page.at('td.b').css('a')[0].css('span[text = "下一頁"]')
會跑出來nil?


#16

問程式前,要給對方足夠顯示 bug 的資料才是禮貌哩,不然你就在叫人觀落陰了

所以你要給類似 input 的 HTML 或網址,中間的程式,才能來問為什麼 output 是 nil 不是嗎?


#17

感謝大大,這邊的應該就可以了:
html = open('https://www.google.com.tw/search?q=%E5%A9%A6%E7%94%A2%E7%A7%91').read
page = Nokogiri::HTML(html)
這個會有東西:
page.css('td.b a').css('span')[1].text => "下一頁"
可是這個會跑出來[]:
page.at('td.b a').css('span[1][text="下一頁"]')


#18

這樣做太粗糙了…且你沒確認你抓下來的東西你自己是否看得懂

html = open('https://www.google.com.tw/search?q=%E5%A9%A6%E7%94%A2%E7%A7%91').read 
file = File.open('temp.html' , 'w')
file.write(html)
file.close

然後把你下載的檔案打開來看如何?通常會和你想像中的差很多才是,這和你上面問的字符問題一樣,通常眼見不一定為憑

一個好的機器人至少要模仿 User Agent / Referer 之類的,必要時甚至連 session 和 cookie 都必須模仿,所以建議用 RestClient 的 gem 來弄唄, Ruby 下的 open-uri 太簡陋了

然而第二方面,其實我不會建議爬 google 的資料,因為他們的阻擋方式很多很多,你到後面會放棄的,且裡面太多廣告和無用連結,我建議你去找純淨的資料來源,類似婦產科協會,啥鬼黃頁之類的,而非 Google 唄?


#19

婦產科協會之前查過了,但要會員才看的到會員資料,可是我不是婦產科醫生。
衛福部有所有婦產科的名單,可是只有診所名字,沒有網頁連結。殘念。

其實我只是先要把"診所名稱"跟"連結"找到,然後再去一間一間診所網站抓婦產科醫生的資料。
想說這樣的要求應該不會太高,所以應該用open-uri就夠了。
現階段大概就只差在如果找到下一頁了,如果沒有比較聰明的方法,
不然我手動輸入第十一頁到最後一頁,應該也是可行。
感謝JC大。


#20

用Mechanize之後(好用很多耶),把我要的資料都跑出來了。
不過多跑幾次後,出現了:Mechanize::ResponseCodeError: 503 => Net::HTTPServiceUnavailable for https://ipv4.google.com/sorry/index?continue=https://www.google.com.tw/search%3Fq%3D%25E5%25A9%25A6%25E7%2594%25A2%25E7%25A7%2591&q=EgRox8zDGL7ijcQFIhkA8aeDS8AQo_ifuRzZZtPzI5neRV8wiFgfMgNyY24 -- unhandled response

mechanize = Mechanize.new
page = mechanize.get('https://www.google.com.tw/search?q=%E5%A9%A6%E7%94%A2%E7%A7%91')

next_page = nil
next_page = !page.link_with(text: '下一頁').nil? ? page.link_with(text: '下一頁') : nil

counter = 1
begin
new_page = next_page.click if !next_page.nil?
  puts next_page.href
  new_page.css('//h3').each do |h|
    puts h.text
  end
  next_page = !new_page.link_with(text: '下一頁').nil? ? new_page.link_with(text: '下一頁') : nil
  puts next_page.href if !next_page.nil?
  new_page = !next_page.nil? ? next_page.click : nil
  counter += 1 if !next_page.nil?
end while !next_page.nil?