最近在写一个爬虫项目,本来打算用C/C++来实现,在网上查找有关资料的时候发现了微软的这个MSHTML库,最后发现在解析动态页面的时候它的表现实在是太差:在项目中需要像浏览器那样,执行JavaScript等脚本然后形成静态的HTML页面,最后才分析这个静态页面。但是MSHTML在执行JavaScript等脚本时需要配合WebBroswer这个ActiveX控件,这个控件又必须在GUI程序中使用,但是我做的这个功能最终是嵌入到公司产品中发布,不可能为它专门生成一个GUI页面,所以这个方案就作废了。虽然最终没有采用这个方案,但是我在开始学习MSHTML并写Demo的过程中还是收益匪浅,所以在这记录下我的成果

解析Html页面

MSHTML是一个典型的DOM类型的解析库,它基于COM组件,在解析Html页面时需要一个IHTMLDocument2类型的接口。在GUI程序中很容易就获取这个接口,获取它的方法很容易就可以在网上找到,在这主要说一下如何通过一段HTML字符串来生成对应的IHTMLDocument2接口。至于如何生成这个HTML字符串,我们可以通过向web服务器发送http请求,并获取它的返回,解析这个返回的数据包即可获取到对应的HTML页面数据。
获取这个接口主要需要经过下面的几个步骤:
1. 使用CoCreateInstance创建一个接口,对于IHTMLDocument2接口一般是使用下面的语句:

HRESULT hr = CoCreateInstance(CLSID_HTMLDocument, NULL, CLSCTX_INPROC_SERVER,
IID_IHTMLDocument2, (void**)&m_spDoc);

2.创建一个COM中的数组,将HTML字符串写到数组中。这个数组主要用来进行VC与VB的交互,以便VB程序能够很方便的使用COM接口。在使用这个数组时不需要关注它的具体成员,VC提供了具体的接口来使用它,在初始化它的时候只需要调用下面几个:
a)SafeArrayCreateVector:这个函数用来创建一个对应的数组结构。函数有三个参数,第一个参数表示数组中元素类型,一般给VT_VARIANT表示它是一个自动类型,第二个参数数组元素起始位置的下标,对于VC来说,数组元素总是从0开始,所以这个位置一般给0,第三个参数是数组的维数,在这我们只是简单的将它作为一个字符数组,所以它是一个一维数组。
b)SafeArrayAccessData:允许用户操作这个数组,在需要读写这个数组时都需要调用这个函数,以便获取这个数组的操作权。它有两个参数,第一个参数是数组变量,第二个参数是一个输出参数,当调用这个函数成功,会提供一个缓冲区,我们操作这个缓冲区就相当于操作了这个数组。
c)SafeArrayUnaccessData:每当操作数组完成时需要调用这个函数,函数与SafeArrayAccessData配套使用,这个函数用来回收这个权限,并使我们对数组的操作生效
3. 调用接口的write方法,将接口与HTML字符串绑定
经过这样几步就可以利用这个接口来访问HTML中的元素了,下面是它的详细代码:

IHTMLDocument2* CreateIHTMLDocument2(const string &strHtml)
{
IHTMLDocument2 *m_spDoc = NULL;
HRESULT hr = CoCreateInstance(CLSID_HTMLDocument, NULL, CLSCTX_INPROC_SERVER,
IID_IHTMLDocument2, (void**)&m_spDoc);

HRESULT hresult = S_OK;
VARIANT *param;
SAFEARRAY *sfArray;

// Creates a new one-dimensional array
sfArray = SafeArrayCreateVector(VT_VARIANT, 0, 1);
if (sfArray == NULL || m_spDoc == NULL)
{
return;
}

hresult = SafeArrayAccessData(sfArray,(LPVOID*) &param);
param->vt = VT_BSTR;
param->bstrVal = _com_util::ConvertStringToBSTR(strHtml.c_str());
hresult = SafeArrayUnaccessData(sfArray);
hresult = m_spDoc->write(sfArray);
return m_spDoc;
}

HTML元素的遍历

MSHTML中,将元素的对应信息封装为IHTMLElement接口,得到对应元素的接口后可以使用它里面的get系列方法来获取它里面的各种信息,这些函数我没有一一列举,当需要时看看MSDN即可。
当获取到了HTML文档的IID_IHTMLDocument2接口时,可以使用下面的步骤进行元素的遍历:
1. 接口的get_all方法获取所有的标签节点。这个函数通过一个输出参数输出IHTMLElementCollection类型的接口指针
2. 然后通过IHTMLElementCollection接口的get_length方法获取标签的总数量,根据这个数量写一个循环,在循环进行元素的遍历
3. 在循环中使用IHTMLElementCollection接口的item方法进行迭代,依次获取各个元素对应的IDispatch接口指针
4. 调用IDispatch接口指针的QueryInterface方法生成对应的IHTMLElement接口。通过这个接口获取元素的各中信息。
它对应的代码如下:

void EnumElements(IHTMLDocument2* m_spDoc)
{
CComPtr<IHTMLElementCollection> pCollec;
m_spDoc->get_all(&pCollec);
if (NULL == pCollec)
{
return ;
}
VARIANT varName;
long len = 0;
pCollec->get_length(&len);
for (int i = 0; i < len; i++)
{
varName.vt = VT_I4;
varName.llVal = i;
CComPtr<IHTMLElement> pElement;
CComPtr<IDispatch> pDisp;
pCollec->item(varName, varName, &pDisp);
if (NULL == pDisp)
{
continue;
}

pDisp->QueryInterface(IID_IHTMLElement, (LPVOID*)&pElement);
if (NULL != pElement)
{
BSTR bstrTag;
pElement->get_tagName(&bstrTag);
string strTag = _com_util::ConvertBSTRToString(bstrTag);
cout<<strTag.c_str()<<endl;
}
}
}

这个方法不能很好的体现各个元素的层次结构,它可以遍历所有的元素,但是默认将元素都作为同一层来表示,如果需要得到对应的子节点,可以调用get_children方法,它可以获取下面的所有子节点,使用方法与get_all类似

调用JavaScript方法

在这,调用JavaScript函数只能想调用普通的函数一样,根据函数名,给它参数,并获取返回值,但是不能得到它执行到中间的某个步骤,比如说这样一个函数

function add(a, b){
window.location.href = "https://www.baidu.com";
return a + b
}

调用这个函数,只能得到a + b的值,但是并不知道它会跳转到另一个页面,在编写爬虫时如果存在这样的跳转或者通过某条语句生成了一个链接,那么使用后面说的方法是获取不到的
言归正传,下面来说下如何实现调用JavaScript。
调用JavaScript方法一般是使用IDispatch接口中的Invoke方法,但是使用这个略显麻烦,我在网上找到了更简单的方法,就是使用CComDispatchDriver接口中的Invoke方法,这个接口中主要有Invoke0、Invoke1、Invoke2、InvokeN几个用于调用JavaScript函数的方法,分别表示传入0个参数、1个参数、2个参数、任意个参数。
一般使用如下步骤来调用:
1.调用IID_IHTMLDocument2的get_Script方法,获取CComDispatchDriver接口
2. 调用CComDispatchDriver接口的GetIDOfName,传入JavaScript函数名称,获取JS函数对应的元素接口,这个函数会通过一个输出参数输出一个DISPID类型的变量。这个主要是一个ID,用来唯一标识一个js函数
3. 调用CComDispatchDriver接口的invoke函数,传入对应的参数,并调用js函数。下面是一个例子代码:

bool CallJScript(IID_IHTMLDocument2* m_spDoc, const CString strFunc, CComVariant* paramArray,int nArgCnt,CComVariant* pVarResult)
{
CComDispatchDriver spScript;
GetJScript(spScript);
if (NULL == spScript)
{
return false;
}

DISPID pispid;
BSTR bstrText = _com_util::ConvertStringToBSTR(strFunc);
spScript.GetIDOfName(bstrText, &pispid);
HRESULT hr = spScript.InvokeN(pispid, paramArray, nArgCnt, pVarResult);

if(FAILED(hr))
{
ShowError(GetSystemErrorMessage(hr));
return false;
}

return true;
}

在调用的时候需要组织一个CComVariant类型的数组,并提供一个数组元素个数作为参数。而对于Invoke0这样有确定函数参数的情况则要简单的多。

获取js函数返回值

js返回参数最终会被包装成一个VARIANT结构,在COM中为了方便操作这个结构,封装了一个CComVariant类。在操作返回值时就是围绕着CComVariant类来进行

返回确定值

当它返回一个确定值时很好解决,由于事先知道返回值得类型,只需要调用结构体的不同成员即可

CComVariant varResult;
parse.CallJScript("Add", CComVariant(1), CComVariant(2), &varResult);
cout<<varResult.lVal<<endl;

当它返回一个数组时,一般需要经过这样几步的处理:
1. 创建一个CComDispatchDriver,并将返回值得pdispVal赋值给它
2. 调用CComDispatchDriver接口的GetPropertyByName方法,将它的第一个参数传入”length”字符串,让其返回数组元素的个数
3. 在循环中调用GetPropertyByName方法,传入索引,获取对应索引位置的CComVariant值。

CComVariant varResult;
parse.CallJScript("Add", CComVariant(1), CComVariant(2), &varResult);

CComVariant varArrayLen;
CComDispatchDriver spDisp = varResult.pdispVal;
spDisp.GetPropertyByName(L"length", &varArrayLen);
for (int i = 0; i < varArrayLen.intVal; i++)
{
CComVariant varValue;
CStringW csIndex;
csIndex.Format(L"%d", i);
spDisp.GetPropertyByName(csIndex, &varValue);
cout<<varValue.intVal<<endl;
}

返回一个object对象

js的object对象中可以有不同的属性,不同的属性对应不同的值,类似于一个字典结构,当返回这个类型,并且我们知道这个对象中的相关属性名称的时候可以通过下面的方法来获取各个属性中的值:
1. 创建一个CComDispatchDriver,并将返回值得pdispVal赋值给它
2. 调用CComDispatchDriver接口的GetPropertyByName方法,将它的第一个参数传入对应属性名称的字符串,让其返回属性的值

//在这假设JavaScript方法返回一个object对象,其中有两个属性,str属性中保存字符串,value属性保存一个整型数据
CComVariant varResult;
parse.CallJScript("Add", CComVariant(1), CComVariant(2), &varResult);

CComVariant varValue;
CComDispatchDriver spDisp = varResult.pdispVal;
spDisp.GetPropertyByName(L"result", &varValue);
cout<<"result:"<<varValue.intVal<<endl;
spDisp.GetPropertyByName(L"str", &varValue);
string strValue = _com_util::ConvertBSTRToString(varValue.bstrVal);
cout<<"str:"<<strValue.c_str()<<endl;

返回类型不确定的object对象

上面这种情况只有当JavaScript代码由自己编写或者与他人进行过相关的约定的时候才可能非常清楚js函数中将会返回何种类型的值,但是大多数情况下,是不知道将会返回何种数据,比如像我们在编写爬虫的时候。这种情况下一般使用IDispatchEx接口来枚举返回对象中的属性名称然后再根据上面的方法来获取属性的值

CComVariant varResult;
parse.CallJScript("Add", CComVariant(1), CComVariant(2), &varResult);
CComQIPtr<IDispatchEx> pDispEx = varResult.pdispVal;
CComDispatchDriver spDisp = varResult.pdispVal;
DISPID dispid;
HRESULT hr = pDispEx->GetNextDispID(fdexEnumAll, DISPID_STARTENUM, &dispid);
//枚举返回对象中所有属性对应的值
while (hr == NOERROR)
{
BSTR bstrName;
pDispEx->GetMemberName(dispid, &bstrName);
if (NULL != bstrName)
{
DISPPARAMS params;
CComVariant varVaule;
cout<<_com_util::ConvertBSTRToString(bstrName)<<endl;
spDisp.GetPropertyByName(bstrName, &varVaule);
SysFreeString(bstrName);
}
hr = pDispEx->GetNextDispID(fdexEnumAll, dispid, &dispid);
}

这些差不多就是我当初学会的一些东西,当初在利用这个方案实现爬虫的时候还是有许多坑,也看到了它的许多局限性,以至于我最终放弃了它,采用其他的解决方案。目前在使用的时候的我发现这样几个问题:
1. 在调用js时,如果不知道函数的名称,目前为止没有方法可以调用,这样就需要我们在HTML中使用正则表达式等方法进行提取,但是在HTML中调用js的方法实在太多,而有的只有一个函数,并没有调用,这些情况给工作带来了很大的挑战
2. MSHTML提供的功能主要是用来与IE进行交互,以便很容易实现一个类似于IE的浏览器或者与IE进行交互,但是如果要在控制台下进行相关功能的编写,则显的力不从心
3. 在控制台下它没有提供一个很好的方式来进行HTML页面的渲染。
4. 在于js进行交互的时候,只能简单的获取到一个VARIANT结构,这个结构可以表示所有常见的类型,但是在很多情况下,我们并不知道它具体代表哪个类型 最后放上demo的下载地址​