본문 바로가기
Software/C++&MFC

HTML Parcer 예제프로그램 분석과 코드 활용방안

by lovey25 2018. 12. 4.
반응형

Intro

이전 글에서 CodeProject.com에서 받은 코드를 컴파일하는 간단한 방법을 소개한 적이 있었는데 그때 받았던 코드를 본인의 프로그램에 사용하기 위해서 나름대로 로직을 분석한 결과를 정리해 두려 합니다.

CodeProject.com에서 다운받은 C++ 프로젝트 컴파일 하는 방법

다운받았던 프로그램은 "HtmlParser"라는 프로그램으로 웹페이지 주소를 넣으면 해당 코드를 받아와서 HTML 코드를 분석해서 코드의 계층구조를 트리 형태로 시각화 시켜주고 원하는 특정 태그나 속성을 필터링 할 수도 있는 그런 기능을 합니다.

저는 전자공시 사이트인 Dart에서 필요한 기업정보를 가져오기 위해서 웹페이지 크롤링 방법을 고민하고 있는 중입니다. 당현히 HTML파싱이 빠질수 없기 때문에 잘 만들어진 예제를 찾았고 그걸 분석해서 응용할수 있는 부분이 있는지 보고자 합니다.

원하는 데이터의 형태에 접근할 수 있는 상황이 될때까지 제어 흐름을 따라가고 원하는 데이터가 나오면 어떤 식으로 받아야 할지 등을 고민해 본 글입니다.

분석

시작은 버튼 "Get" 입니다. 이버튼이 호출하는 함수를 시작으로 추적을 해 보겠습니다.

 

버튼 클릭으로 호출되는 함수는 "OnBnClickedButtonGet()"이고 아래는 해당 코드입니다.

void CHtmlParserDlg::OnBnClickedButtonGet()
{
	CString szUrl;
	GetDlgItemText(IDC_EDIT_URL,szUrl);    // 파싱할 페이지의 URL을 변수에 대입
	if(szUrl.IsEmpty())
		return;

	CWebGrab grab;
	//set all params
	grab.SetTimeOut(2000);
	//call init
	grab.Initialise(_T("Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1) "),NULL);

	CString szBuff;

	if(!grab.GetFile(szUrl, szBuff, _T("Opera"),NULL ))
		return;

#ifdef _UNICODE
	CString szPageW = UTF8Util::ConvertUTF8ToUTF16((char*)szBuff.GetBuffer());
	m_szHtmlPage = szPageW.GetBuffer();
#else
	m_szHtmlPage = szBuff;
#endif

	CLiteHTMLReader theReader;
	CHtmlElementCollection theElementCollectionHandler;
	theReader.setEventHandler(&theElementCollectionHandler);
	theElementCollectionHandler.InitWantedTag(_T(""));//style

	if(theReader.Read(m_szHtmlPage)){
		int iNoElements = theElementCollectionHandler.GetNumElements(); // 요소 갯수 저장
		CString szTxt;
		szTxt.Format(_T("% i - Html Elements found"),iNoElements);
		SetDlgItemText(IDC_STATIC_TAGS,szTxt);
		HtmlTree hTree =  theElementCollectionHandler.GetTree();        // 모든 요소를 HtmlTree 타입의 hTree 변수에 저장
		FillList(hTree);                                                // List 작성함수 호출
	}
}

코드의 내용을 보자면,

4행; 에디트 상자에 입력된 url을 가져와서 "szUrl"이라는 변수에 넣습니다.

16행; "GetFile()"이라는 함수를 통해서 url에 있는 페이지의 HTML 코드를 "szBuff"라는 변수에 집어 넣습니다.

20~21행; 유니코드를 사용하고 있기 때문에 저장된 코드를 여기서 유니코드의 인코딩으로 변경된 다음 "m_szHtmlPage"라는 변수에 저장됩니다. 여기까지 변수형은 CString 입니다.

26행; CLiteHTMLReader클래스의 객체를 생성해서 "theReader"라고 지정합니다. 뒤에 간단히 설명을 드리겠지만 코드를 분석하는 역할을 해주는 객체입니다.

27행; CHtmlElementCollection클래스의 객체가 선언되는데 분석한 코드를 사용하기 쉬운 구조로 변환해서 저장하는 클래스라고 봐야 할것 같네요.

28행; "theReader"의 코드분석 결과를 CHtmlElementCollection 클래스의 객체인 "theElementCollectionHandler"가 핸들이 되도록 지정해 줍니다.

31행; "m_szHtmlPage" 변수에 저장되어 있던 HTML코드 원본은 "Read()"함수의 파라미터로 전달이 되는군요.

"Read()"함수 계속 따라가 보겠습니다.

UINT CLiteHTMLReader::Read(LPCTSTR lpszString)
{
	ASSERT(AfxIsValidString(lpszString));

	m_dwBufLen = ::_tcslen(lpszString);
	if (m_dwBufLen)
	{
		m_lpszBuffer = lpszString;
		return (parseDocument());
	}

	return (0U);
}

8행; 전달된 HTML코드는 "m_lpszBuffer" 변수로 복사가 되는데 이 변수는 "CLiteHTMLReader"라는 클래스의 멤버변수이고 LPCTSTR 형으로 선언되어 있습니다. 이 클래스는 이름 그대로 HTML코드 파싱에 기본이 되는 코드 분석을 하는 녀석이라고 합니다.

더보기
/**
 * CLiteHTMLReader
 * This class allows you to parse HTML text in a simple, and fast 
 * way by handling events that it generates as it finds specific 
 * symbols in the text. This class is similar to the SAX (Simple 
 * API for XML) implementation, which is an XML DOM parser. Like 
 * SAX, the CLiteHTMLReader class reads a section of HTML text, 
 * generates an event, and moves on to the next section. This 
 * results in low memory consumption.
 *
 * @version 1.0 (Mar 26, 2004)
 * @author Gurmeet S. Kochar
 *
 * @todo add support for multiple event handlers.
 * @todo add support for tag validation, a new interface, that 
 *       validator classes must implement, so reader can then 
 *       make a call, such as isValidTag(...), to validate tag 
 *       information and act accordingly.
 * @todo add more reader options (ReaderOptionsEnum). Until now, 
 *       there is only one.
 */

9행; 정상적인 흐름이라면 여기서 "parseDocument()"함수를 호출합니다. 

계속 다음 함수 따라가 보겠습니다.

파싱에 핵심이 되는 코드인 만큼 코드가 너무 길어서 숨기기 기본으로 처리하였습니다. 참고만 해주세요. 어차피 저는 파싱 알고리즘을 만들기가 버거워서 똘똘한 코드 가져다가 쓰는 입장이기 때문에 내부 알고리즘 파는건 배보다 배꼽이 더 커지는 경우가 될테니 나중을 위해 아껴두겠습니다. (분석할 능력이 없음은 비밀! ㅋㅋ 이 알고리즘만 파는데 하루종일 걸릴듯 합니다 ㅡ.,ㅡ)

UINT CLiteHTMLReader::parseDocument(void)
{
	ASSERT(m_lpszBuffer != NULL);

	bool	bAbort = false;			// continue parsing or abort?
	bool	bIsClosingTag = false;	// tag parsed is a closing tag?
	bool	bIsOpeningTag = false;	// tag parsed is an opening tag?
	CString	strCharacters;			// character data 
	CString	strComment;				// comment data
	CString	strT;					// temporary storage
	DWORD	dwCharDataStart = 0L;	// starting position of character data
	DWORD	dwCharDataLen = 0L;		// length of character data
	LONG	lTemp = 0L;				// temporary storage
	TCHAR	ch = 0;					// character at current buffer position
	CLiteHTMLTag	oTag;			// tag information
	bool	bInsideScript = 0;

	if ( (!m_lpszBuffer) || (!m_dwBufLen) )
		return (0U);

	// reset seek pointer to beginning
	ResetSeekPointer();

	// notify event handler about parsing startup
	if (getEventNotify(notifyStartStop))
	{
		bAbort = false;
		m_pEventHandler->BeginParse(m_dwAppData, bAbort);
		if (bAbort)	goto LEndParse;
	}

	// skip leading white-space characters
	while (isWhiteSpace(ReadChar()))
		;
	
	ch = UngetChar();
	while ((ch = ReadChar()) != NULL)
	{
		switch (ch)
		{

		// tag starting delimeter?
		case _T('<'):
			{
				UngetChar();
				
				strComment.Empty();
				if (!parseComment(strComment))
				{
					bIsOpeningTag = false;
					bIsClosingTag = false;
					if (!parseTag(oTag, bIsOpeningTag, bIsClosingTag, bInsideScript))
					{
						++dwCharDataLen;

						// manually advance buffer position
						// because the last call to UngetChar()
						// moved it back one character
						ch = ReadChar();

						break;
					}
					else
					{		
						//WE ENTER IN SCRIPT MODE
						if(bIsOpeningTag&&!bInsideScript){
							if(!oTag.getTagName().CompareNoCase(_T("script")))
								if(!oTag.IsTagInline())
									bInsideScript = 1;
						}
						
						if(bIsClosingTag&&bInsideScript){
							if(!oTag.getTagName().CompareNoCase(_T("script")))
								bInsideScript = 0;
						}
					}
				}
				
				// clear pending notifications
				if ( (dwCharDataLen) || (strCharacters.GetLength()) )
				{
					strCharacters += CString(&m_lpszBuffer[dwCharDataStart], dwCharDataLen);
					NormalizeCharacters(strCharacters);
					
					if ( (strCharacters.GetLength()) && 
						 (getEventNotify(notifyCharacters)) )
					{
						bAbort = false;
						m_pEventHandler->Characters(strCharacters, m_dwAppData, bAbort);
						if (bAbort)	goto LEndParse;
					}

					strCharacters.Empty();
				}

				dwCharDataLen = 0L;
				dwCharDataStart = m_dwBufPos;

				if (strComment.GetLength())
				{
					if (getEventNotify(notifyComment))
					{
						bAbort = false;
						m_pEventHandler->Comment(strComment, m_dwAppData, bAbort);
						if (bAbort)	goto LEndParse;
					}
				}
				else
				{
					if ( (bIsOpeningTag) && (getEventNotify(notifyTagStart)) )
					{
						bAbort = false;
						m_pEventHandler->StartTag(&oTag, m_dwAppData, bAbort);
						if (bAbort)	goto LEndParse;
					}

					if ( (bIsClosingTag) && (getEventNotify(notifyTagEnd)) )
					{
						bAbort = false;
						m_pEventHandler->EndTag(&oTag, m_dwAppData, bAbort);
						if (bAbort)	goto LEndParse;
					}
				}

				break;
			}

		// entity reference beginning delimeter?
		case _T('&'):
			{
				UngetChar();

				lTemp = 0;
				if (m_bResolveEntities)
					lTemp = CLiteHTMLEntityResolver::resolveEntity(&m_lpszBuffer[m_dwBufPos], ch);
				
				if (lTemp)
				{
					strCharacters += CString(&m_lpszBuffer[dwCharDataStart], dwCharDataLen) + ch;
					m_dwBufPos += lTemp;
					dwCharDataStart = m_dwBufPos;
					dwCharDataLen = 0L;
				}
				else
				{
					ch = ReadChar();
					++dwCharDataLen;
				}
				
				break;
			}
		
		// any other character
		default:
			{
				++dwCharDataLen;
				break;
			}
		}
	}

	// clear pending notifications
	if ( (dwCharDataLen) || (strCharacters.GetLength()) )
	{
		strCharacters += CString(&m_lpszBuffer[dwCharDataStart], dwCharDataLen) + ch;
		NormalizeCharacters(strCharacters);
		strCharacters.TrimRight();	// explicit trailing white-space removal

		if ( (strCharacters.GetLength()) && 
			 (getEventNotify(notifyCharacters)) )
		{
			bAbort = false;
			m_pEventHandler->Characters(strCharacters, m_dwAppData, bAbort);
			if (bAbort)	goto LEndParse;
		}
	}

LEndParse:
	// notify event handler about parsing completion
	if (getEventNotify(notifyStartStop))
		m_pEventHandler->EndParse(m_dwAppData, bAbort);

	m_lpszBuffer = NULL;
	m_dwBufLen = 0L;
	return (m_dwBufPos);
}

이 함수는 기본적으로 "m_lpszBuffer"변수에 파싱할 코드가 있어야 동작을 합니다. 그리고 문자열을 맨 처음부터 하나씩 읽어서 HTML코드의 시작이되는 "<" 기호가 나왔을 때를 기점으로 문자열을 분리하여 태그, 속성, 값 등 필요한 데이터를 읽어오게 됩니다. 

아무튼 중요한건 이 함수가 가 호출기 전에 맨처음 살펴봤던 "OnBnClickedButtonGet()" 함수의 28번째 행에서 "theElementCollectionHandler"를 "theReader"의 핸들로 지정했기 때문에 "Read()"가 끝나고 나면 파싱된 결과를 핸들을 통해 접근할 수 있게 됩니다.

자 이제 파싱이 끝났으니 이 모든 루틴이 시작된 "OnBnClickedButtonGet()" 함수의 "Read()"함수를 호출하는 31행으로 다시 돌아갑니다.

    if(theReader.Read(m_szHtmlPage)){
        int iNoElements = theElementCollectionHandler.GetNumElements(); // 요소 갯수 저장
        CString szTxt;
        szTxt.Format(_T("% i - Html Elements found"),iNoElements);
        SetDlgItemText(IDC_STATIC_TAGS,szTxt);
        HtmlTree hTree =  theElementCollectionHandler.GetTree();        // 모든 요소를 HtmlTree 타입의 hTree 변수에 저장
        FillList(hTree);                                                // List 작성함수 호출
    }

36행; "GetTree()"란 함수를 이용해서 어떤 결과를 HtmlTree라는 형식을 갖는 "hTree"변수에 복사를 합니다.

HtmlTree형은 map 형식의 컨테이너 였습니다. 표준라이브러리가 아니라서 인터넷에 찾아봐도 활용방법을 찾을 수가 없었습니다. 고수님들이야 선언문만 봐도 딱 감이 올 수 있을지 모르지만 저는 초보니까 이 프로그램 내에서 이 HtmlTree형인 "hTree" 구조체를 어떻게 이용하는지 알아보도록 하겠습니다.

37행; "hTree"가 매개변수로 "FillList()"함수로 전달이 되는군요. 역시 따라갑니다.

void CHtmlParserDlg::FillList(HtmlTree hTree)
{
	m_tree_html.DeleteAllItems();   // 초기화
	giElements = 0;
	m_dwarrTagStart.RemoveAll();
	m_dwarrTagLen.RemoveAll();

	AddTagToList(hTree, TVI_ROOT);  // Tag를 리스트에 집어넣을 함수 호출
}

앞부분은 어떤 초기화 역할을 하는것 같고 전달된 변수가 또한번 "AddTagToList()"라는 함수로 전달이 되는군요.

끝가지 가 봅시다~

BOOL CHtmlParserDlg::AddTagToList(HtmlNode node, HTREEITEM hItem)
{
	HTREEITEM hTreeNode = hItem;
	int iElements = node.Count;

	for(int n=0;n<iElements;n++) {

		if(!node.Nodes[n]->iFiltered){
			hTreeNode = m_tree_html.InsertItem(node.Nodes[n]->szName,0,0,hItem);
			DWORD dwStart = node.Nodes[n]->lpszStartStart - m_szHtmlPage;
			m_dwarrTagStart.Add(dwStart);
			//elements with end tag
			if(node.Nodes[n]->iComplete){
				DWORD dwLen = node.Nodes[n]->lpszStopStop - node.Nodes[n]->lpszStartStart;
				m_dwarrTagLen.Add(dwLen);
			}
			////elements with no ending tag
			else{
				DWORD dwLen = node.Nodes[n]->lpszStartStop - node.Nodes[n]->lpszStartStart;
				m_dwarrTagLen.Add(dwLen);
			}

			//MAKELONG(dwStart,dwLen)
			m_tree_html.SetItemData(hTreeNode, giElements);
			giElements++;	
		}

		if(!node.Nodes[n].IsLeaf())		// leaf가 아니면
			AddTagToList(node.Nodes[n], hTreeNode);
		else  // 추가코드
		{
			CString test2 = node.Nodes[n]->szName;
			LPCTSTR test3 = node.Nodes[n]->lpszStartStop;
			LPCTSTR test4 = node.Nodes[n]->lpszStopStart;
			if (test2.CompareNoCase(_T("script")) == 0 &&
				(test4-test3) > 100)
			{
				CString my_value(test3, test4 - test3);
				int position = my_value.Find(_T("id: \"13\","));
			}
			
		}
	}

	return FALSE;
}

이쯤왔으면 끝이 보여야 할텐데 말이죠. ㅡㅡ;;

앞에서 함수를 호출하면서 전달된 변수가 함수내부에서는 "node"라는 이름으로 사용되는군요. 이녀석을 주목해야 겠네요.

함수의 전체적인 흐름은 이런것 같습니다. 

HTML코드는 분석이 되어서 태그별로 요소화 되어 map형태의 자료구조를 띄고 있는데, 

6행; 이 자료구조를 root에서부터 하나씩 훑어나가면서 

24행; m_tree_html이라는 트리뷰 객체에 계층에 맞게 넣어주는 역할을 합니다. 

28~29행; 요소들을 훑어나갈때 map의 마지막 가지인 leaf에 도달하지 않았다면 다시한번 재귀호출을 해서 leaf에 도달할때 까지 반복하는 흐름을 보이고 있습니다.

map이라는 자료구조를 모르신다면 용어가 생소할 수도 있기 때문에 map구조를 보여주는 그림하나 가져왔습니다.

출처: 한빛출판네트워크 http://www.hanbit.co.kr/channel/category/category_view.html?cms_code=CMS9990721111

오른쪽 구조처럼 나무가지를 거꾸로 해놓은 계층구조를 가진 자료구조를 말하는데 여기서 5번은 root가 되고 9, 30, 35, 20처럼 가지의 맨 지막에 달려있는 요소를 leaf라고 부릅니다. 그리고 이 모든 요소들은 node가 되는거죠. 이건 참고만 하고 넘어가겠습니다.

 

자 드디어 제가 필요한 정보를 간단히 접근할 수 있는 부분에 도달한것 같습니다. "node"라는 클래스를 매개변수로 해서 "AddTagToList"함수가 동작을 하기 때문에 당연히 파싱된 모든 정보가 여기 있을 거라는 걸 알수 있죠. 그래서 위의 "AddToTagList"함수에서 28행에 중단점을 걸고 디버깅 모드로 실행을 해 봤습니다. 저는 나중에 사용할 DART 전자공시자료의 주소를가지고 실행했습니다.

그래서 중단점에서 VS로 제어권이 넘어 왔습니다. 이제 "node"에 어떤 정보가 있는지 살펴보겠습니다. 구조체 내부구조가 복잡하긴 한데 잘 따라가 보면 한눈에 알수가 있습니다.

node >> pData >> pData >> vChilds >> [0] >> pData >> pData >> tData >> pData >> pData >> 

이런 단계를 거쳐서 따라나가 보면 "node"라는 클래스 멤버변수에 분석을 한 알짜 정보가 담겨있습니다.

 

위에 노란색으로 표시한 부분인데요. 의미가 있어 보이는 5개의 변수가 나왔습니다.

"node" 클래스의 원형은 아래와 같습니다.

class HTMLElement
{
public:
	HTMLElement(){
		lpszStartStart = lpszStartStop = lpszStopStart = lpszStopStop = 0;
		iComplete = iFiltered = 0;
	}
	~HTMLElement(){;};

	CString	szName;            // tag명
	LPCTSTR lpszStartStart;    // 선택 tag의 beginning 시작 위치
	LPCTSTR lpszStartStop;     // 선택 tag의 beginning 마지막 위치
	LPCTSTR lpszStopStart;     // 선택 tag의 ending 시작 위치
	LPCTSTR lpszStopStop;      // 선택 tag의 ending 마지막 위치
	BOOL iComplete;
	BOOL iFiltered;
};

각 멤버변수에 주석을 달아놓았는데 실제 코드에서의 해당 위치를 확인해 보겠습니다.

제가 파싱을한 페이지의 HTML 코드는 아래와 같이 생겼습니다. 

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<title>
삼성물산/사업보

... 중간생략 ...

</html>

첫번째 줄은 주석이니 무시가 되고 2번째부터 실제의 코드를 보면 다음과 같습니다.

 

 

크~ 드디어 원하던 부분까지 도달을 했습니다. 이 포인터를 이용하면 특정 tag에 대당하는 data를 손쉽게 가져올 수 있겠네요.

conclusion

제가 목표로 하고있는 궁극의 파서(parser)를 만들기 위해서는 아직 갈길이 멀지만 그래도 잘 만들어진 HTML parser 라이브러리를 얻을 수 있었습니다. 

제가 원하는 사이트에서는 Data가 특정 tag에 한정되어 담겨있기 때뭉에 그 자료들을 모두 가져와서 패턴에 맞게 다시 구조화를 한다면 프로그램에서 활용할 수 있는 데이터로 쓸수 있을 것 같습니다. 

코드를 분석하는 핵심 알고리즘 부분을 아직 살펴보지 않았았기 때문에 활용 가능성은 지금 생각하는 것 보다 더 클거라고 예상합니다. 그리고 tag의 이름 뿐 아니라 속성 등 세부 데이터를 구별할 수 있는 기능이 이미 갖추어진 프로그램이기 때문에 그 부분 까지 이용한다면 더 쉽게 목적물을 만들 수 있을것 같네요.

더 진전된 내용을 준비해서 추가 포스팅 하도록 하겠습니다.

 

끝!

반응형

댓글