1. 程式人生 > >使用MSHTML解析HTML頁面

使用MSHTML解析HTML頁面

最近在寫一個爬蟲專案,本來打算用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的下載地址:http://download.csdn.net/detail/lanuage/9857075