基于Internet Explorer內核的網頁信息抓取程序
程序開發背景
本程序來源于我們項目組最近正在開發的一個開源項目——網頁分塊工具。其目的是作為一個底層的信息抽取模塊,為后期分析提供盡可能詳盡的分塊線索,包括盡可能完整的HTML源代碼和網頁元素的位置、顏色、字體、背景色等信息。程序還 要具有較好的適應性,能夠支持多種網頁,而事實上很多網頁都是不標準的。從通用性考慮,程序應該能夠支持多種應用,而不僅限于網頁分塊。
預期目標分析
程序應達到以下幾點設計要求:
- 能夠指定要處理的網頁的URL。
- 能夠為HTML源代碼添加附件信息,如元素位置。
- 對于JavaScript等動態腳本具有良好的解析能力。
- 通過命令行調用,提供良好的通用性。
- 通過socket套接字返回HTML源代碼。
- 支持延時讀取,保證抓取的成功率。
- 支持超時退出,保證程序不會因為加載不成功而卡死。
使用IE內核的原因
本程序的核心部分使用的是IE內核。至于為什么要基于IE內核,而不使用其他瀏覽器的內核,有以下幾方面的原因:
首先,firefox、google chrome等瀏覽器雖然是開放源代碼的,但是其源代碼的閱讀難度相當大,想在短時間內弄明白是很困難的。
其次,IE的相關開發文檔比較完整,開發環境比較容易構建,開發起來更容易上手。
最后,從網頁的兼容性考慮,得益于IE的廣泛的市場占有率,其兼容性明顯要比其他瀏覽器要好很多,盡管對很多標準都支持得不是很好。
綜上,就可以確定本程序使用IE內核進行開發,實驗證明,這個做法是正確的。
Internet Explorer的程序結構
對于本程序來說,其中最重要的的就是網頁內容處理層,所用到的接口也都位于mshtml.dll文件中。
開發環境
系統: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可以處理的字符串
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的方式實現進程間通信,以下是整個程序的架構:
具體實現細節可以查閱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)