基於Internet Explorer核心的網頁資訊抓取程式

farcall發表於2011-04-13

程式開發背景

  本程式來源於我們專案組最近正在開發的一個開源專案——網頁分塊工具。其目的是作為一個底層的資訊抽取模組,為後期分析提供儘可能詳盡的分塊線索,包括儘可能完整的HTML原始碼和網頁元素的位置、顏色、字型、背景色等資訊。程式還 要具有較好的適應性,能夠支援多種網頁,而事實上很多網頁都是不標準的。從通用性考慮,程式應該能夠支援多種應用,而不僅限於網頁分塊。

 

預期目標分析

  程式應達到以下幾點設計要求:

  • 能夠指定要處理的網頁的URL。
  • 能夠為HTML原始碼新增附件資訊,如元素位置。
  • 對於JavaScript等動態指令碼具有良好的解析能力。
  • 通過命令列呼叫,提供良好的通用性。
  • 通過socket套接字返回HTML原始碼。
  • 支援延時讀取,保證抓取的成功率。
  • 支援超時退出,保證程式不會因為載入不成功而卡死。

 

使用IE核心的原因

  本程式的核心部分使用的是IE核心。至於為什麼要基於IE核心,而不使用其他瀏覽器的核心,有以下幾方面的原因:

  首先,firefox、google chrome等瀏覽器雖然是開放原始碼的,但是其原始碼的閱讀難度相當大,想在短時間內弄明白是很困難的。

  其次,IE的相關開發文件比較完整,開發環境比較容易構建,開發起來更容易上手。

  最後,從網頁的相容性考慮,得益於IE的廣泛的市場佔有率,其相容性明顯要比其他瀏覽器要好很多,儘管對很多標準都支援得不是很好。

  綜上,就可以確定本程式使用IE核心進行開發,實驗證明,這個做法是正確的。

 

Internet Explorer的程式結構

  對於本程式來說,其中最重要的的就是網頁內容處理層,所用到的介面也都位於mshtml.dll檔案中。

Internet Explorer程式架構

 

開發環境

  系統:Windows XP

  IDE:Visual Studio 2005中文版

  IE版本:Internet Explorer 6

 

構建基於對話方塊的MFC程式

  執行visual studio 2005(c++),新建一個專案,選擇MFC標籤下的 “MFC應用程式”作為模板,填入專案名稱,確定。此時會彈出一個嚮導,按照以下步驟操作:點選左側的”應用程式型別”,選擇”基於對話方塊”,”在靜態庫中使用MFC”(方便以後釋出),其他保持預設即可。然後單擊完成,程式會自動生成相應的類。

  切換到資源檢視,依次展開,在DIALOG中找到以專案名稱命名的對話方塊,雙擊開啟。刪除“確定”和“取消”按鈕。在對話方塊視窗上單擊右鍵,選擇“插入activeX控制元件”。在新彈出的視窗中選擇”Microsoft web 瀏覽器”,確定。調整好IE控制元件的位置後,在其上單擊右鍵,選擇“新增變數”,輸入名稱m_webBrowser。

  切換到解決方案檢視,開啟對話方塊的原始檔,名稱通常為***Dlg.cpp(***為專案名)。將下面的程式碼新增到對話方塊初始化函式OnInitDialog()中。

  LPCTSTR url = _T(“http://***”);

  m_webBrowser.Navigate(url,&vtEmpty,&vtEmpty,&vtEmpty,&vtEmpty);

 

如何確定WebBrowser控制元件中的網頁載入完成

  當網頁下載完成後,WebBrowser控制元件觸發DocumentComplete 事件。通過在程式中新增響應DocumentComplete事件的程式,我們就可以在網頁下載完成後對其進行分析和處理。

  新增事件處理程式的操作步驟如下:切換到資源檢視,開啟包含WebBrowser控制元件的對話方塊,在WebBrowser控制元件上單擊右鍵,選擇“新增事件處理程式”,然後在彈出的對話方塊中選擇DocumentComplete訊息,點選“新增編輯”以確認。

  WebBrowser控制元件觸發DocumentComplete事件的ReadyState屬性更改為 READYSTATE_COMPLETE時,這表示 WebBrowser 控制元件已完成下載網頁。

  雖然通過響應DocumentComplete事件可以知道網頁是否載入完成,但是有的網頁觸發了不止一次DocumentComplete事件,例如網易首頁會從載入開始到完全載入完畢會激發二十多次DocumentComplete事件。出現這種情況的主要原因是:網頁中包含JavaScript等動態指令碼,而且有可能會改變網頁元素的結構,當這些指令碼完成解析後會觸發DocumentComplete事件;如果網頁是由多個frame框架組成的,每個框架中的網頁載入完成也會觸發DocumentComplete事件。

  針對第二種情況,微軟給出了具體的解決方案,<http://support.microsoft.com/kb/180366/zh-cn>,但是第一種情況仍無法解決。通過查閱相關的社群,我找到了能夠基本解決第一種情況的方法 —— 通過將DocumentComplete事件處理函式的引數中的URL與當前的網頁文件的URL相比較,若相同,則說明整個網頁都已經完成載入,此時再對網頁進行分析和處理,然後退出。按照理論,只需對網頁做一次處理就可以了。然而在測試新浪部落格時,我又發現了問題 —— 在部落格評論載入完成之前觸發很多次DocumentComplete事件,其中的一次事件對應的URL與網頁文件的URL一樣。如果只對網頁處理一次,程式是無法處理獲取載入評論之後的網頁。這就是為什麼程式需要加上延時讀取功能的原因,具體思路請參照下一節。

  當然,在WebBrowser控制元件的事件中,還有其他的事件,比如NavigateComplete2事件。我曾經嘗試在其他事件觸發時對網頁進行分析,但是都會出錯,要麼只能獲取到一部分元素,要麼直接就彈出錯誤資訊。這是因為此時網頁尚未完全載入,很多元素的屬性都沒確定,當然無法確定元素的具體資訊,如元素位置資訊。

 

延時讀取和超時退出

  為了降低網路、機器配置、系統軟體等外界因素對程式的影響,提高讀取的成功率。本程式加入了延時讀取和超時退出的功能。具體實現方法是:

  首先在程式的初始化函式中,如對話方塊的OnInitDialog函式,新增一個固定ID的定時器,使程式定時發出一個WM_TIME訊息。具體函式如下:

  SetTimer(8888,1000,NULL);//8888為該定時器的ID,1000為定時發出WM_TIME訊息的時間,單位為毫秒。

  然後新增一個處理WM_TIME訊息的函式,其程式碼如下:

   1:  void CMyBrowserDlg::OnTimer(UINT_PTR nIDEvent)
   2:  {
   3:      CTime ct;
   4:      CTimeSpan cts(0,0,0,5000);            //程式延時執行時間
   5:      CTimeSpan timeOut(0,0,0,m_timeOut);        //程式超時退出時間
   6:  
   7:      //判斷定時器ID,若非指定的定時器ID則退出
   8:      if(nIDEvent =! 8888){
   9:          CDialog::OnTimer(nIDEvent);
  10:          return;
  11:      }
  12:  
  13:      //獲取當前時間
  14:      ct = CTime::GetCurrentTime();
  15:  
  16:      //超時退出,並輸出錯誤資訊
  17:      if(ct > (m_time+timeOut)){
  18:          ::PostQuitMessage(3);    //強制退出程式,並返回一個int型的值
  19:      }
  20:  
  21:      //獲取IHTMLDocument2指標,以便進行下面的操作
  22:      CComQIPtr < IHTMLDocument2 >  spDoc2 = (IHTMLDocument2 *)m_webBrowser.get_Document();
  23:  
  24:      //判斷網頁載入狀態,若載入完成則繼續處理;否則返回
  25:  
  26:      if(1 != m_flag){    //m_flag為documentComplete事件觸發標誌,1表示已觸發,0表示尚未觸發
  27:          return;
  28:      }else if(m_webBrowser.READYSTATE_COMPLETE != m_webBrowser.get_ReadyState()){
  29:          return;
  30:      }else if(ct <= (m_time+cts)){
  31:          return;
  32:      }
  33:      ……
  34:  }

 

使用IE提供的介面

  網頁內容處理模組的介面都包含在mshtml.h的標頭檔案中,使用IE介面時需將此標頭檔案包含在原始檔中。在VC++平臺中,可以通過使用介面指標來呼叫介面提供的函式。

  下面是該程式中用到的幾個重要的IE介面

介面 功能說明
IHTMLDocument2 獲取HTML檔案的資訊,並審查和修改HTML元素和文字。
IHTMLDocument3 提供檔案物件的額外的屬性和方法。
IHTMLElement 此介面提供了訪問所有元素物件共同的屬性和方法的能力
IHTMLDOMNode 提供方法來訪問所有在文件物件模型( DOM )中的節點 ,包括節點的迭代,插入節點,刪除節點,並得到的屬性節點。
IHTMLDOMChildrenCollection 提供方法來存取子節點的集合。

  接下來,我將針對每個介面,逐個列舉在本程式中較為重要的幾個函式,展示其示例程式碼,以及解析在編寫相關程式時遇到的問題。

 

IHTMLDocument2介面

  下面的程式碼演示的是如何從WebBrowser控制元件中獲取IHTMLDocument介面。

  IHTMLDocument2 * pDoc2 = (IHTMLDocument2 *)m_webBrowser.get_Document();

  IHTMLDocument2介面中有一個比較重要的函式

  HRESULT?get_body(IHTMLElement?**p); 獲取HTML文件中body物件的藉口指標

  通過get_body函式,我們就可以獲得BODY元素的介面指標。在程式中,所有的分析和處理工作都是基於BODY元素的,而不是從HTML文件的根節點開始處理。之所以這麼做,是因為本程式的目的是獲取網頁內容的佈局資訊,而真正能顯示在螢幕上的資訊都是位於BODY標籤內的,因此就沒有必要從根節點開始處理。

  下面是該函式的簡單例項程式碼:

  IHTMLElement * pBody;// IHTMLElement介面指標,指向body物件

  HRESULT hr;//用於存放函式呼叫結果

  hr = pDoc2->get_body(&pBody);//獲取body物件的指標,返回操作結果

  if( SUCCEEDED( hr ) ){//若操作成功,則繼續執行

  // Something to do

  }

 

IHTMLDocument3介面

  前面說過了IHTMLDocument3只是IHTMLDocument2介面的擴充套件,而且在本程式中用到該介面的地方也就一兩處。使用IHTMLDocument3介面的原因是其提供了一個get_documentElement函式,下面是其介紹和簡單的示例:

  HRESULT?get_documentElement(IHTMLElement?**p); 獲取HTML文件中根節點的介面指標

  示例:

  IHTMLElement * pDocElem;// IHTMLElement介面指標,指向body物件

  HRESULT hr;//用於存放函式呼叫結果

  hr = pDoc2->get_documentElement (& pDocElem);//獲取body物件的指標,返回操作結果

  if( SUCCEEDED( hr ) ){//若操作成功,則繼續執行

  // Something to do

  }

  獲取根節點的目的是通過它獲取整個HTML文件的原始碼,具體如何獲得請看下面關於IHTMLElement介面的介紹。

 

IHTMLElement介面

函式原型 功能說明
HRESULT?get_innerHTML(BSTR?*p); 獲取當前物件開始和結束標籤之間的HTML原始碼(動態內容)
HRESULT?get_innerText(BSTR?*p); 獲取當前物件開始和結束標籤之間的文字內容(動態內容)
HRESULT get_outerHTML(BSTR?*p); 獲取物件的HTML的內容(靜態內容)
HRESULT get_outerText(BSTR?*p); 獲取物件的文字內容(靜態內容)

  下面只給出get_innerHTML函式的使用方法示例,另外三個函式類似:

  IHTMLElement * pElem;// IHTMLElement介面指標,指向body物件

  BSTR html;//存放html原始碼

  _bstr_t html_t;//用於將BSTR轉換為cout可以處理的字串 或者用CComBSTR html_t;

  hr = pElem->get_innerHTML(&html);

  if( SUCCEEDED( hr ) ){

  html_t = html;

  cout<<”The html within this element is:”<< html_t;

  }

 

get_innerHTML與get_outerHTML的區別

  對於這四個函式,我所要強調的就是他們之間的區別。InnerHTML和outerHTML函式最大的區別就是前者可以獲取到網頁中動態的HTML原始碼,如利用javascript載入的評論,而後者只能獲取未解析前的靜態內容,其功能與在網頁上單擊右鍵“檢視網頁原始檔”獲取到的內容一致。

  在程式設計的早期階段,先使用get_documentElement獲取根節點docElem,然後再用get_innerHTML獲取完整的HTML原始碼。後來在測試中發現了問題,對於docElem來說,無論是使用get_innerHTML還是get_outerHTML都無法獲取包含javascript解析結果的HTML原始碼。又經過多次的測試後,發現只有通過get_body函式獲取到的bodyElem才能得到真正的動態內容。如何得到完整的真正的動態HTML原始碼?針對這個問題,在本程式中採用了一種比較簡單的解決方案:先從docElem中獲取到完整的HTML原始碼,再從bodyElem中獲取到動態的內容,然後再將原先靜態的HTML中的BODY標籤內的內容用這些動態的內容替換掉,最後就可以得到了完整的包含javascript執行結果的動態HTML原始碼。

  有人可能會問,完整的HTML和body間的內容差別在哪?瞭解HTML的人都知道完整的HTML原始碼不僅包含BODY標籤,還包含了HEAD標籤,而HEAD標籤對於網頁的正常顯示起著很大的作用。出於通用性方面的考慮,本程式就以獲取儘量完整HTML原始碼作為設計要求。

  上面這個問題足足困擾了我一個星期,很奇怪微軟為什麼不允許從根節點獲取動態內容呢?!

 

BSTR和_bstr_t

  細心的話可能會發現,程式碼示例中,在輸出HTML原始碼之前,先將BSTR型別的變數html賦值給了_bstr_t型別的變數html_t,然後再輸出到控制檯中。這裡涉及到得是BSTR型別在VC++平臺中的處理問題。

  BSTR是COM中預設的字串資料格式,和char* 及std::string等不同,BSTR是以 '/0 '結尾,長度為字首的unicode 字串。char *指標指向的是該串的第一個字元,而BSTR的指標是指向該字串的長度。作業系統提供相應的API函式(如SysAllocString、SysFreeString)來管理它以及一些預設的排程程式碼。

  缺點: 對於字串來說理所應當提供的字串操作如 查詢子串,字串比較等函式都沒有。更重要的是,似乎沒有任何函式能複製BSTR。

  BSTR有兩個包裝類,分別是CComBSTR和_bstr_t。_bstr_t是“native COM support”類,而CComBSTR是ATL中的BSTR包裝類。這兩個功能上很相似,都提供了BSTR字串的操作函式,但實現機制不同, _bstr_t更通用些,不過如果使用ATL的話,可能 CComBSTR更方便些。由於本程式是MFC程式,所以使用的是_bstr_t。

  總的來說,_bstr_t的作用就是將BSTR轉換成大多數函式都能處理的型別,從而對BSTR字串的內容進行操作。

 

獲取網頁元素的位置資訊

  在IHTMLElement介面中,還提供了兩個計算網頁元素位置的函式:

  HRESULT?get_offsetLeft(long?*p); 獲取物件相對於父節點左側的位置,即x座標

  HRESULT?get_offsetTop(long?*p); 獲取物件相對於父節點頂部的位置,即y座標

  示例程式碼:

  Node * pNode;//父節點

  …….

  long absX;

  long parentAbsX;

  parentAbsX = pNode->getAbsX();//獲取父節點的絕對座標

  if(SUCCEEDED(spElement->get_offsetLeft(&absX))){

  absX += parentAbsX;

  }

  值得注意的是這兩個函式獲取到的都是相對於父節點的座標,計算元素絕對座標時還需要加上父節點的絕對座標。因此在設計程式時使用了一個自定義的Node類,其中包含著當前節點位置資訊,然後傳遞給子節點,子節點計算出相對座標後再加上該絕對座標就可以得到子節點的絕對座標。

  在IE的記憶體模型中,網頁文件是以DOM(Document Object Model文件物件模型)存放在記憶體中的,對網頁的處理和分析都是基於DOM來操作的,其操作方法與普通的DOM並無太大區別。下面簡單介紹IE處理網頁的兩個DOM相關介面:

 

IHTMLDOMNode介面

  HRESULT?get_childNodes(IDispatch?**p); 獲取指定節點的所有直接後裔節點的集合

  HRESULT?get_nodeType(long?*p); 返回指定節點的型別

  在網頁文件的DOM結構中,標籤的屬性、文字和註釋都是以節點的形式存在的。然而這些節點卻無法使用其他介面來處理,如IHTMLElement介面,如果要對這些型別的節點強行操作,程式就會報錯退出。因此在DOM遞迴時要進行查詢IHTMLElement介面時,就要通過IHTMLDOMNode的nodeType來進行判斷。只有當nodeType為element時才有子節點,向下遞迴才不會出錯。

  nodeType所對應的節點型別:(attribute屬性) 1(element元素) 3(text文字) 8(comment註釋)。

 

IHTMLDOMChildrenCollection介面

  HRESULT?get_length(long?*p); 獲取集合中子節點的個數

  HRESULT?item(?long?index, IDispatch?**ppItem ); 獲取指定索引位置的子節點

 

遍歷DOM中的所有節點

  結合IHTMLDOMNode介面和IHTMLDOMChildrenCollection介面就可以遍歷DOM中的所有節點。下面是示例程式碼:

  void getAllChild(IHTMLDOMNode * pNode){

  CComPtr<IDispatch> spChildrenDisp;//用於子節點的集合

  CComPtr<IDispatch> spChildDisp;//正在處理的子節點

  IHTMLDOMChildrenCollection *spChildrenNode;

  longnodeType;//節點型別

  pNode->get_nodeType(&nodeType);

  pNode->get_childNodes(&spChildrenDisp);

  if( 3 == nodeType ){//判斷節點型別是否為element

  …… //一些額外的操作

  spChildrenNode = (IHTMLDOMChildrenCollection *)spChildrenDisp;

  spChildrenNode->get_length(&childrenNum);//獲取子節點的集合長度

  for(long i = 0 ; i<childrenNum ; i++){//迴圈遞迴遍歷所有孩子節點

  spChildrenNode->item(i,&spChildDisp);

  getAllChild( (IHTMLDOMNode *) spChildDisp );

  if(spChildDisp != NULL){

  spChildDisp.Detach();// spChildDisp每次使用後都需要釋放,

  //因為若spChildDisp在使用時非空會報錯

  }

  }

  }

  }

 

JAVA與C++的程式間通訊

  由於本程式是底層模組,需要被上層的java程式呼叫,因此就設計到了JAVA與c++程式間通訊的問題。經調查瞭解到JAVA與C++通訊方式有幾種:1.JNI 2.CORBA 3.Socket套接字 4.檔案等。我曾嘗試過使用JNI和CORBA,但是都因為太過麻煩而放棄。而利用檔案的方法雖然可以使用,但是開銷太大——要頻繁地進行I/O層的讀取操作,而且效率低、靈活性差。所以暫時決定使用命令列加socket的方式實現程式間通訊,以下是整個程式的架構:

wps_clip_image-0

  具體實現細節可以查閱MSDN和JAVA API關於socket套接字的實現和通過java runtime呼叫exe程式的相關文件,本文就不一一贅述了。

  以上就是本文的所有內容,本人第一次寫文章,如果有問題歡迎指正。

  聯絡方式:pinlin168@tom.com

 

相關資源及連結

  MSDN 技術資源庫: http://msdn.microsoft.com/en-us/library/aa155133.aspx

  VC知識庫: http://www.vckbase.com/

  《Programming Microsoft Internet Explorer 5》 - Scott Roberts

  《深入淺出MFC 第二版》 - 候俊傑

  《VC++深入詳解》 - 孫鑫

(by: pinlin : senior, pinlin168@tom.com

相關文章