如何定制一款12306抢票浏览器——处理预订页面和验证码自动识别功能

2019-01-16 10:35:32 浏览数 (1)

判断是否进入预订页面

        我们先看一下预订页面的结构(转载请指明出于breaksoftware的csdn博客)

        可以见得,这个页面也是嵌入了两个IFrame。关于IFrame的跨域问题,我已经在前一篇文章中讲述了解决办法。

        我判断是否是预订页面是通过两个依据:

        1 URL是否是http://www.12306.cn/mormhweb/kyfw/

        2 是否可以在最里层IFrame中找到class是“table_qr”的元素该元素对应于

        具体的查找过程我这儿就不再赘述,我们通过代码来解读

代码语言:javascript复制
BOOL CDeal12306WebPage::IsBookingPage( CComPtr<IHTMLDocument2> & spDoc, CComBSTR & bstrUrl )
{
    HRESULT hr = E_FAIL;
    do  {
        CString cstrUrl = CString((LPWSTR)bstrUrl);
        if ( 0 == cstrUrl.CompareNoCase(LOGIN12306URL) ) {
            CComPtr<IHTMLElement> spTableQrTbody;
            hr = GetTableQrTbody( spDoc, spTableQrTbody);
            CHECKHRPOINTER(hr, spTableQrTbody);
        }
    } while (0);
    return FAILED(hr) ? FALSE : TRUE;
}
代码语言:javascript复制
HRESULT CDeal12306WebPage::GetTableQrTbody( CComPtr<IHTMLDocument2> & spDoc,
    CComPtr<IHTMLElement> & spElem )
{
    HRESULT hr = E_FAIL;
    do  {
        CComPtr<IHTMLDocument2> spMainDoc;
        hr = GetMainDoc( spDoc, spMainDoc);
        CHECKHRPOINTER(hr, spMainDoc);

        CComPtr<IHTMLElement> spEnter_wElem;
        hr = GetEnter_wElement(spMainDoc, spEnter_wElem );
        CHECKHRPOINTER(hr, spEnter_wElem);

        CComPtr<IHTMLElement> spForm;
        hr = GetElementByID( spEnter_wElem, L"confirmPassenger", spForm);
        CHECKHRPOINTER(hr, spForm);

        CComPtr<IHTMLElement> spTable;
        hr = GetElementByClassName( spForm, L"table_qr", spTable);
        CHECKHRPOINTER(hr, spTable);

        hr = GetElementByIndex( spTable, 0, spElem);
        CHECKHRPOINTER(hr, spElem);
    } while (0);
    return hr;
}

插入用户信息,并设置相应的选项

        我们看下用户填写信息的位置的HTML代码结构

        我们可以看到5个passenger可填写区域。目前只有第一个显示出来,而其他四个还没有显示。在上图的最下面是个超链接,其对应于“添加1位乘车人”按钮。可以想象,该按钮的一个操作就是将不能显示的tr显示出来。我们“人”线程填写用户信息的过程和人的行为是一致的:填写一个人信息后 ,点击“添加1位乘车人”,再填写一个……我们用代码说明这个过程。

代码语言:javascript复制
HRESULT CDeal12306WebPage::AddPassengerInfo( CComPtr<IHTMLElement>& spTableQrTbody,
    const VecStSinglePassengerInfo& vecStSingleinfo )
{
    HRESULT hr = E_FAIL;
    do {
        // 下标没有从0开始!
        int i = 1;
        for ( VecStSinglePassengerInfoCIter it = vecStSingleinfo.begin(); 
            it != vecStSingleinfo.end();i   ) {
                CString cstrPassengerId;
                cstrPassengerId.Format(PASSENGERID, i);
                hr = BookSinglePassenger( spTableQrTbody, cstrPassengerId, it);
                CHECKHR(hr);

                it  ;
                if ( it != vecStSingleinfo.end() ) {
                    AddPassenger(spTableQrTbody);
                }
        }
    } while (0);
    return hr;
}

        上面代码我们将枚举用户设置的乘客信息。第12行,我们将在table中填写一个乘客信息。第16行,我们将判断最新加入的用户是否是最后一个,如果不是最后一个,则点击“添加1位乘车人”。

代码语言:javascript复制
HRESULT CDeal12306WebPage::AddPassenger( CComPtr<IHTMLElement> & spTableQrTbody )
{
    HRESULT hr = E_FAIL;
    do {
        CComPtr<IHTMLElement> spTr;
        hr = GetElementByIndex(spTableQrTbody, 6, spTr);
        CHECKHRPOINTER(hr, spTr);

        CComPtr<IHTMLElement> spTd;
        hr = GetElementByIndex(spTr, 1, spTd);
        CHECKHRPOINTER(hr, spTd);

        CComPtr<IHTMLElement> spA;
        hr = GetElementByIndex(spTd, 0, spA);
        CHECKHRPOINTER(hr, spA);

        hr = spA->click();
    } while (0);
    return hr;
}

        填写每个乘客信息的代码是

代码语言:javascript复制
HRESULT CDeal12306WebPage::BookSinglePassenger( CComPtr<IHTMLElement> & spElem, 
    const CString& cstrPassengerID, VecStSinglePassengerInfoCIter iter )
{
    HRESULT hr = E_FAIL;
    do  {
        CComPtr<IHTMLElement> spTr;
        hr = GetElementByID( spElem, cstrPassengerID, spTr );
        CHECKHRPOINTER(hr, spTr);
        
        hr = SetName(spTr, iter->cstrName);
        CHECKHR(hr);

        hr = SetCardNo(spTr, iter->cstrCardNo);
        CHECKHR(hr);

        hr = SetMobileNo(spTr, iter->cstrMobileNo);
        CHECKHR(hr);

        hr = SetTicket(spTr, iter->cstrTicket);
        CHECKHR(hr);

        hr = SetCardtype(spTr, iter->cstrCardtype);
        CHECKHR(hr);

        hr = SetSeat(spTr, iter->ListSeat);

    } while (0);
    return hr;
}

        其中填写姓名的操作很简单,只要找到相应控件,并向该控件中插入文字即可

代码语言:javascript复制
HRESULT CDeal12306WebPage::SetName( CComPtr<IHTMLElement> & spElem, const CString& cstrName )
{
    return SetInputHelper(spElem, cstrName, 4);
}
HRESULT CDeal12306WebPage::SetInputHelper( CComPtr<IHTMLElement> & spElem, 
    const CString& cstrValue, long lIndex )
{
    HRESULT hr = E_FAIL;
    do  {
        CComPtr<IHTMLElement> spTd;
        hr = GetElementByIndex( spElem, lIndex, spTd );
        CHECKHRPOINTER(hr, spTd);

        CComPtr<IHTMLElement> spInputElem;
        hr = GetElementByIndex(spTd, 0, spInputElem);
        CHECKHRPOINTER(hr, spInputElem);

        CComPtr<IHTMLInputElement> spInput;
        hr = spInputElem->QueryInterface(IID_IHTMLInputElement, (LPVOID*)&spInput);
        CHECKHRPOINTER(hr, spInput);

        hr = spInput->put_value( CComBSTR(cstrValue.GetString()) );
        CHECKHR(hr);
    } while (0);
    return hr;
}

        设置席别这类Select选项则稍微复杂点,其实原理是一致的

代码语言:javascript复制
HRESULT CDeal12306WebPage::SetSeat( CComPtr<IHTMLElement> & spElem, 
    const CString& cstrSeat )
{
    return SetOptionHelper( spElem, cstrSeat, 2);
}
HRESULT CDeal12306WebPage::SetOptionHelper( CComPtr<IHTMLElement> & spElem, 
    const CString& cstrValue, long lIndex )
{
    HRESULT hr = E_FAIL;
    do  {
        CComPtr<IHTMLElement> spTd;
        hr = GetElementByIndex( spElem, lIndex, spTd );
        CHECKHRPOINTER(hr, spTd);

        CComPtr<IHTMLElement> spSelectElem;
        hr = GetElementByIndex(spTd, 0, spSelectElem);
        CHECKHRPOINTER(hr, spSelectElem);

        hr = SetOptionSelect( spSelectElem, cstrValue);
        CHECKHR(hr);
    } while (0);
    return hr;
}
HRESULT CDeal12306WebPage::SetOptionSelect( CComPtr<IHTMLElement> & spElem, const CString& cstrValue )
{
    HRESULT hRes = E_FAIL;
    HRESULT hr = E_FAIL;
    do {
        CComPtr<IHTMLElementCollection> spElemCollection;
        hr = GetElementCollection(spElem, spElemCollection );
        CHECKHRPOINTER(hr, spElemCollection);

        long lCount = 0;
        hr = spElemCollection->get_length(&lCount);
        CHECKHR(hr);
        for ( long lindex = 0; lindex < lCount; lindex   ) {
            CComVariant VarIndex = lindex;
            CComPtr<IDispatch> spDispatchElem;
            hr = spElemCollection->item( VarIndex, VarIndex, &spDispatchElem );
            CHECKHRPOINTER(hr,spDispatchElem);

            CComPtr<IHTMLOptionElement> spOption;
            hr = spDispatchElem->QueryInterface(IID_IHTMLOptionElement, (LPVOID*)& spOption);
            if ( FAILED(hr) || NULL == spOption ) {
                continue;
            }

            CComBSTR bstrValue;
            hr = spOption->get_value(&bstrValue);
            if ( FAILED(hr) ) {
                continue;
            }

            CString cstrReadValue(bstrValue);
            if (  0 == cstrReadValue.Compare(cstrValue) ) {
                hRes = spOption->put_selected(VARIANT_TRUE);
                break;
            }
        }
    } while (0);
    return hRes;
}

        如此自动填写乘客信息的操作就完成了。

验证码的自动识别

        说来惭愧,这个模块本来是我这个软件的一个亮点。可是随着12306将验证码生成方法改变,导致我原来的逻辑产生了很大的误差。其实图像识别这块,我使用的是第三方库tesseract-ocr。之前12306的验证码相对比较简单,但是仍然加入了噪点和干扰线,使得tesseract-ocr识别率非常不准。于是我写了一个bmp文件格式分析和图片转换类去处理原始验证码图片,使得验证码变得清晰,同时提高了tesseract-ocr的识别准确率。我列一些以前的处理结果对比图

        网上有使用2012编译tesseract-ocr的介绍。我做了点改动:在tesseract-ocr的init函数中,提供了一个指定相关目录的参数,但是代码底层却优先读取了系统环境变量TESSDATA_PREFIX的值作为相关目录。我修改了源代码中的这部分:即只使用我指明的程序路径,而不是使用系统环境变量TESSDATA_PREFIX的值。

        我封装了一个文字识别的类COcr。其内容也很简单

代码语言:javascript复制
BOOL COcr::Init(const CString& cstrSetupFloder)
{
    std::string sSetupFloder = CW2A(cstrSetupFloder.GetString());
    int nstatus = m_Tesseract.Init(sSetupFloder.c_str(), "eng", tesseract::OEM_TESSERACT_ONLY);
    if ( nstatus < 0 ) {
        return FALSE;
    }
    m_Tesseract.SetPageSegMode(tesseract::PSM_SINGLE_BLOCK);
    nstatus = m_Tesseract.SetVariable( "tessedit_char_whitelist", "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwsyz" );
    return nstatus > 0 ? TRUE : FALSE;
}

BOOL COcr::GetText( const CString& cstrImgPath, CString & cstrText )
{
    std::string sImgPath = CW2A(cstrImgPath.GetString());
    STRING text_out;
    if (!m_Tesseract.ProcessPages(sImgPath.c_str(), NULL, 0, &text_out)) {
        return FALSE;
    }
    std::string sText = text_out.string();
    cstrText = CA2W(sText.c_str());
    return TRUE;
}

        简单说明下上述代码。代码第4行,我们设置了语言是eng,即英语体系。因为目前12306的验证码还只是数字和字母。代码第9行,告诉tesseract-ocr验证码中只是包含0~9A~Za~z字符。之前12306的验证码只有数字和大写字母,所以那个时候设置这个参数为0~9A~Z是非常必要的。

        代码识别模块ok后,就是如何保存验证码图片的问题了。

如何保存验证码图片

        仔细看过12306验证码区域的HTML代码的朋友,应该知道,该处的IMG的src不是指向的是一个图片,而是一个随机地址。

代码语言:javascript复制
<img title="单击刷新验证码" id="img_rrand_code" style="vertical-align: text-bottom; cursor: hand;" onclick="this.src=this.src '&' Math.random();" src="/otsweb/passCodeAction.do?rand=randp" border="0"/>

        我之前想通过Src下载图片的方法明显是行不通的。那么就得使用截屏技术了。下面的代码,将验证码区域复制到剪贴板中,然后再将剪贴板中的图片保存为一个32位真彩色的bmp图片。

代码语言:javascript复制
HRESULT CDeal12306WebPage::SaveImg( CComPtr<IHTMLElement> spElement, 
    const CString& cstrFilePath )
{
    HRESULT hr = E_FAIL;
    do {
        
        CComPtr<IDispatch> spDispDoc;
        hr = spElement->get_document(&spDispDoc);
        CHECKHRPOINTER(hr, spDispDoc);

        CComPtr<IHTMLDocument2> spMainDoc;
        hr = spDispDoc->QueryInterface(IID_IHTMLDocument2, (LPVOID*)&spMainDoc);
        CHECKHRPOINTER(hr, spMainDoc);

        CComPtr<IHTMLElement> spBody;
        hr = spMainDoc->get_body(&spBody);
        CHECKHRPOINTER(hr, spBody);
        
        CComPtr<IHTMLElement2> spBody2;
        hr = spBody->QueryInterface(IID_IHTMLElement2, (LPVOID*)&spBody2);
        CHECKHRPOINTER(hr, spBody2);

        CComPtr<IDispatch> spDisp;
        hr = spBody2->createControlRange(&spDisp);
        CHECKHRPOINTER(hr, spDisp);

        CComPtr<IHTMLControlRange> spControlRange;
        hr = spDisp->QueryInterface(IID_IHTMLControlRange, (LPVOID*)&spControlRange);
        CHECKHRPOINTER(hr, spControlRange);

        CComPtr<IHTMLControlElement> spControlElem;
        hr = spElement->QueryInterface(IID_IHTMLControlElement, (LPVOID*)&spControlElem);
        CHECKHRPOINTER(hr, spControlElem);

        hr = spControlRange->add(spControlElem);
        CHECKHR(hr);

        VARIANT_BOOL vbReturn = VARIANT_FALSE;
        CComVariant vEmpty;
        CComBSTR bstrCmd(L"Copy");
        hr = spControlRange->execCommand(bstrCmd, VARIANT_FALSE, vEmpty, &vbReturn );
        CHECKHR(hr);

        if ( VARIANT_FALSE == vbReturn ) {
            hr = E_FAIL;
            break;
        }

        if(OpenClipboard(NULL)){
            //获得剪贴板数据
            HBITMAP handle = (HBITMAP)GetClipboardData(CF_BITMAP);
            if ( NULL != handle ) {
                CImage Img;
                Img.Attach(handle);
                hr = Img.Save(cstrFilePath);
            }
            else {
                hr = E_FAIL;
            }
           
            CloseClipboard();
        }

    } while (0);
    return hr;
}

截屏、识别、输入验证码的逻辑

代码语言:javascript复制
HRESULT CDeal12306WebPage::SetCaptcha( CComPtr<IHTMLElement> & spTableQrTbody )
{
    HRESULT hr = E_FAIL;
    do {
        CComPtr<IHTMLElement> spImg;
        hr = GetCaptchaImgElem( spTableQrTbody, spImg);
        CHECKHRPOINTER(hr, spImg);

        CComPtr<IHTMLElement> spInput;
        hr = GetCaptchaInputElem( spTableQrTbody, spInput );
        CHECKHRPOINTER(hr, spInput);

        CString cstrImgPath;
        cstrImgPath.Format(L"%s%d.bmp", m_cstrFloder, GetTickCount());

        hr = SaveImg( spImg, cstrImgPath);
        CHECKHR(hr);

        CString cstrNewImgPath = cstrImgPath   ".bmp";
        CBmp bmp;
        bmp.SetFilePath( cstrImgPath, cstrNewImgPath );
        if ( FALSE == bmp.DealBmp() ) {
            hr = E_FAIL;
            break;
        }
        CString cstrTxet;
        if ( FALSE == m_ocr.GetText( cstrNewImgPath, cstrTxet) ) {
            hr = E_FAIL;
            break;
        }

        if ( CAPTCHACOUNT > cstrTxet.GetLength() ) {
            hr = E_FAIL;
            break;
        }

        cstrTxet = cstrTxet.Left(CAPTCHACOUNT);

        CComPtr<IHTMLInputElement> spInputElem;
        hr = spInput->QueryInterface(IID_IHTMLInputElement, (LPVOID*)&spInputElem);
        CHECKHRPOINTER(hr, spInputElem);

        hr = spInputElem->put_value( CComBSTR(cstrTxet.GetString()) );
        CHECKHR(hr);
    } while (0);
    return hr;
}

        如果识别的字符数不对,则会认为失败,这样我们会刷新验证码,并重新识别。

代码语言:javascript复制
HRESULT CDeal12306WebPage::SetCaptchaEx( CComPtr<IHTMLElement>& spTableQrTbody )
{
    HRESULT hr = E_FAIL;
    do {
        for ( int n = 0; n < CAPTCHARETRYCOUNT; n   ) {
            hr = SetCaptcha( spTableQrTbody );
            if ( FAILED(hr) ) {
                // 如果失败刷新验证码再来一次
                CComPtr<IHTMLElement> spImg;
                hr = GetCaptchaImgElem( spTableQrTbody, spImg);
                CHECKHRPOINTER(hr, spImg);
                spImg->click();
                Sleep(CAPTCHAWAITTIME);
            }
            else {
                break;
            }
        }
    } while (0);
    return hr;
}

        验证码输入完毕后,我们将点击“提交订单”按钮。现在有个问题冒出来了:如果我们验证码输入错误,那么网页会alert一下提示“验证码错误”,这个迫使我们得去点击这个按钮。如何去点击这个按钮呢?这个问题困扰了我一下,最后我决定还是绕过这个问题——彻底屏蔽Alert弹框,并记录Alert准备弹出的内容。在点击完按钮后,我将根据保存的Alert准备弹出的内容判断是否成功和失败。

屏蔽Alert

        我们的窗口要继承IDocHostShowUI接口,并修改该接口的一个方法:

代码语言:javascript复制
STDMETHODIMP CBrowserHost::ShowMessage( 
/* [in] */ HWND hwnd, 
/* [annotation][in] */ __in __nullterminated LPOLESTR lpstrText, 
/* [annotation][in] */ __in __nullterminated LPOLESTR lpstrCaption, 
/* [in] */ DWORD dwType, 
/* [annotation][in] */ __in __nullterminated LPOLESTR lpstrHelpFile, 
/* [in] */ DWORD dwHelpContext, 
/* [out] */ LRESULT *plResult )
{
    *plResult = 0;
    return S_OK;
}

        从上面代码看,我并没有记录alert的内容。因为我发现了一个更为有效和简单的办法去判断是否成功了。我们看下提交没有成功时HTML网页结构

        我们再看下提交成功的页面的网页结构

        可以见得,提交成功的页面中新增了两个Div。其中最下面那个Div就是确认信息的HTML代码

        于是完整的预订流程是

代码语言:javascript复制
HRESULT CDeal12306WebPage::BookTickets( CComPtr<IHTMLDocument2> & spDoc )
{
    HRESULT hr = E_FAIL;
    do  {
        CComPtr<IHTMLElement> spTableQrTbody;
        hr = GetTableQrTbody( spDoc, spTableQrTbody);
        CHECKHRPOINTER(hr, spTableQrTbody);

        if ( m_stTrainNoPassenger.vecPassengerInfo.size() > MAXPASSENGERCOUNT) {
            ATLASSERT(FALSE);
        }

        hr = AddPassengerInfo( spTableQrTbody, m_stTrainNoPassenger.vecPassengerInfo );
        CHECKHR(hr);

        DWORD dwCount = 0;

        Sleep(6*1000);

        do {
            hr = SetCaptchaEx( spTableQrTbody );
            CHECKHR(hr);

            hr = ClickSubmitButton(spTableQrTbody);
            CHECKHR(hr);
            dwCount  ;
        } while ( FAILED(ConfirmOrd(spDoc)));

    } while (0);
    return hr;
}
代码语言:javascript复制
HRESULT CDeal12306WebPage::ConfirmOrd( CComPtr<IHTMLDocument2> & spDoc )
{
    HRESULT hr = E_FAIL;
    do {
        CComPtr<IHTMLElement> spDiv;
        hr = GetOrderConfirm( spDoc, spDiv);
        CHECKHRPOINTER(hr, spDiv);

        CComPtr<IHTMLElement> spOkButton;
        hr = GetConfirmOKElem(spDiv, spOkButton);
        CHECKHRPOINTER(hr, spOkButton);

        hr = spOkButton->click();
        CHECKHR(hr);
    } while (0);
    return hr;
}

0 人点赞