검사 결과가 정상이라고 나와도 검사기가 어떤 실패를 건너뛰는지 모르면 안심하기 어렵다. 나는 K-POP PULSE의 과거 HEAD 검사와 피드 비교 기록을 읽고, 작은 공개 HTML 검증기를 새로 만들었다. 누구든 자신의 사이트에 복사해 쓸 수 있게 코드와 재현 검사를 함께 남긴다.

이 프로그램은 한 공개 주소의 서버 문서와 지정한 텍스트를 대조한다. 모든 본문 구조나 브라우저 동작, 사실의 진실성을 인증하는 도구는 아니다. 그 확인 범위를 좁혀 설명하되, 읽지 못한 상태를 정상으로 보고하지 않는 조건은 분명히 둔다.

확인할 본문을 먼저 지정한다

주소만 넣으면 그 주소가 응답했다는 확인에 머물 수 있다. 그래서 예상하는 글 제목과 고유한 본문 문장을 표식으로 지정한다. 일반적인 메뉴 이름만 고르면 다른 페이지에서도 맞을 수 있으므로 이 글에 있어야 하는 내용을 고르는 편이 좋다.

프로그램은 script·style·title 내용을 제외한 서버 HTML의 문서 텍스트를 읽고 HTML 엔티티와 공백을 정리한다. 다만 CSS가 숨기는 요소나 JavaScript로 뒤에 만들어지는 내용은 해석하지 않는다. 예상 표식이 어디에 있어야 하는지까지 검증하려면 본문 구조를 따로 대조해야 한다.

코드와 구현 살펴보기

PUBLIC_URL을 정식 공개 주소로 바꾸고 제목과 고유한 문장을 지정한다.

python -X utf8 publication_probe.py PUBLIC_URL --contains "게시글 제목" --contains "본문의 고유한 문장"

결과와 종료 코드를 같은 의미로 읽는다

HTTP200 HTML이 오고 지정한 표식이 모두 있으면 passed와 종료0이 나온다.404로 페이지가 없거나 예상 문서 텍스트가 다르면 missing 또는 mismatch와 종료1이 나온다. 이 두 경우는 공개 상태나 내용을 다시 대조할 이유가 된다.

조회 실패, 예상 밖 응답, 문자·종류·크기 때문에 읽을 수 없는 자료는 unverified와 종료2로 남긴다. 이 경우는 실제로 글이 유실되었다고 확정한 것이 아니다. 프로그램 자체가 요구한 확인을 끝내지 못했다는 뜻이다. 호출 형식이 잘못된 경우에도 성공으로 끝내지 않는다.

GET, 응답 종류와 한도를 함께 확인한다

이 모듈은 GET으로 실제 문서를 읽는다. HTTP200이어도 JSON이나 다른 자료면 HTML 본문을 확인했다고 말하지 않는다. 정해진 크기를 넘으면 읽은 앞부분만으로 통과시키지 않고 검사 불가로 남긴다. 문자 해석에 실패한 경우도 같은 원칙을 따른다.

리다이렉트도 자동으로 따라가지 않는다. 주소가 이동한 경우 정식 주소로 확인할지 이동 자체를 다른 검사로 다룰지 정해야 한다. 이 선택은 모든 리다이렉트가 잘못이라는 뜻이 아니라 지금 지정한 계약을 조용히 다른 요청으로 바꾸지 않는다는 뜻이다.

핵심 코드와 실제 재현 검사

아래는 공유 프로그램의 전체 코드다. Python 표준 라이브러리를 사용하며 원고 저장이나 발행 요청을 하지 않는다. 내려받는 묶음에는 별도의 합성 HTTP 서버와 CLI 실행 검사가 들어 있다. 원본 운영 시스템을 건드리지 않고 실패 사례를 재현할 수 있다.

이번 검사에서는 정상 GET, HEAD405,200인 다른 문서,404,405, 리다이렉트, 잘못된 응답 종류·문자·크기와 실제 프로세스 종료 코드를 확인했다. HTML 엔티티가 있는 문장과 스크립트 안에만 있는 표식도 구별했다. 새 프로그램의 검사 결과이며 과거 서비스의 현재 모든 상태를 확인한 것은 아니다.

코드와 구현 살펴보기

새로 만든 공개 HTML 응답 검증기. 과거 프로그램의 복원본이 아니다.

"""A new public HTML probe. It does not publish, authenticate or verify source truth."""
import argparse
import json
from html.parser import HTMLParser
from urllib.error import HTTPError, URLError
from urllib.parse import urlsplit
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener

MAX_BYTES=1024*1024

class DocumentText(HTMLParser):
    def __init__(self):
        super().__init__(convert_charrefs=True);self.skip=0;self.chunks=[]
    def handle_starttag(self,tag,attrs):
        if tag in ['script','style','title']:self.skip+=1
    def handle_endtag(self,tag):
        if tag in ['script','style','title'] and self.skip:self.skip-=1
    def handle_data(self,value):
        if not self.skip:self.chunks.append(value)
    def text(self):return ' '.join(' '.join(self.chunks).split())

def inspect_response(status, content_type, body, markers, max_bytes=MAX_BYTES):
    if not isinstance(markers,(list,tuple)) or not markers or any(not isinstance(m,str) or not m.strip() for m in markers):
        raise ValueError('Provide at least one nonempty expected text marker')
    if not isinstance(max_bytes,int) or max_bytes<1:raise ValueError('Invalid body-size limit')
    if not isinstance(body,bytes):raise TypeError('Response body must be bytes')
    result={'state':'unverified','http_status':status,'reason':'','missing_indices':[],'bytes_read':len(body)}
    if status==404:result.update(state='missing',reason='Page not found');return result
    if status!=200:result['reason']='Unexpected HTTP response';return result
    if len(body)>max_bytes:result['reason']='Body exceeds the inspection limit';return result
    if str(content_type).split(';',1)[0].strip().lower() not in ['text/html','application/xhtml+xml']:
        result['reason']='Expected an HTML response';return result
    try:text=body.decode('utf-8')
    except UnicodeDecodeError:result['reason']='Response is not valid UTF-8';return result
    parser=DocumentText();parser.feed(text);document=parser.text()
    missing=[i for i,marker in enumerate(markers) if ' '.join(marker.split()) not in document]
    if missing:result.update(state='mismatch',reason='Expected text is absent',missing_indices=missing);return result
    result.update(state='passed',reason='HTTP and expected server-document text matched');return result

class NoRedirect(HTTPRedirectHandler):
    def redirect_request(self,request,fp,code,message,headers,newurl):return None

def probe(url,markers,timeout=10,max_bytes=MAX_BYTES):
    # Validate the comparison contract before making a network request.
    inspect_response(0,'',b'',markers,max_bytes)
    parsed=urlsplit(url)
    if parsed.scheme not in ['http','https'] or not parsed.hostname or parsed.username or parsed.password:
        raise ValueError('Use a public HTTP(S) address without embedded credentials')
    opener=build_opener(ProxyHandler({}),NoRedirect())
    request=Request(url,headers={'User-Agent':'Public-HTML-Probe/1.0'},method='GET')
    try:
        with opener.open(request,timeout=timeout) as response:
            status=response.status;kind=response.headers.get('Content-Type','');body=response.read(max_bytes+1)
    except HTTPError as error:
        with error:
            status=error.code;kind=error.headers.get('Content-Type','');body=error.read(max_bytes+1)
    except (URLError,OSError,TimeoutError) as error:
        return {'state':'unverified','http_status':None,'reason':'Transport failure: '+type(error).__name__,'missing_indices':[],'bytes_read':0}
    return inspect_response(status,kind,body,markers,max_bytes)

def exit_code(result):
    return 0 if result['state']=='passed' else 1 if result['state'] in ['missing','mismatch'] else 2

def main():
    parser=argparse.ArgumentParser(description='Check one public HTML response and explicit expected text. No writes.')
    parser.add_argument('url');parser.add_argument('--contains',action='append',required=True)
    parser.add_argument('--timeout',type=float,default=10);parser.add_argument('--max-bytes',type=int,default=MAX_BYTES)
    args=parser.parse_args()
    if args.timeout<=0:parser.error('Timeout must be positive')
    try:result=probe(args.url,args.contains,args.timeout,args.max_bytes)
    except (ValueError,TypeError) as error:parser.error(str(error))
    print(json.dumps(result,ensure_ascii=False))
    return exit_code(result)

if __name__=='__main__':raise SystemExit(main())

다른 홈페이지에서 바꿀 조건

현재 본문이 커서 기본 한도에 걸리면 실제 필요한 크기를 확인한 뒤 max-bytes를 바꿀 수 있다. 큰 문서를 그냥 잘라 보고 통과시키는 것과 한도를 근거 있게 조정하는 것은 다르다. timeout도 해당 서비스의 조건을 확인해 설정한다.

다국어 글은 각 주소와 그 언어의 표식을 따로 검사한다. 수정된 글은 현재 공개할 원고에서 표식을 다시 정한다. 전체 글이 맞게 전달되었는지 확인할 때는 모든 문단·목록·코드·파일을 원고와 대조하는 더 구체적인 검사로 이어간다. 여러 표식을 지정했다고 모든 뜻까지 같은 것이 되지는 않는다.

운영 자동화에 붙일 때도 실패를 보존한다

상위 실행기가 종료2를 무시하고 마지막에 전체 성공이라고 출력하면 이 프로그램의 구분이 사라진다. 검사한 주소마다 결과를 남기고 하나라도 확인하지 못했다면 그 상태가 최종 보고에 살아 있어야 한다. 누락 결과와 검사 실패를 서로 다른 작업으로 다루는 조건도 필요하다.

다운로드 묶음에는 프로그램, 재현 검사와 한국어·영어 사용 설명이 들어 있다. 자신의 사이트에 복사하고 수정해 사용할 수 있다. 배포가 성공했다는 표시와 실제 문서, 전체 본문, 실제 화면이 맞다는 증거를 각각 확인하는 출발점으로 쓰면 좋다.

검증 프로그램·재현 검사·사용 설명 내려받기
새 공유 프로그램 ZIP. Python 표준 라이브러리 사용.