基于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)