THE DEVLOG

scribbly.

etc

2023.05.23 17:17:06

웹에 접속하면 어떤 일이 이루어지는가?

  • 서버를 향해 라우터들을 거쳐 이동하게 된다
  • Request URL로 GET 요청을 보낸다. (https://www.naver.com)
  • 이때 헤더에 text/html, 쿠키, user agent 등을 담아 보낸다.
  • 200 OK와 함께 utf-8로 인코딩 된 Doc html형식의 문자열을 준다

requests 라이브러리 설치

pip install requests로 requests를 설치할 수 있다.

Get 요청 해보기

"http://api.ipify.org/"
해당 주소로 접속하면 자신의 ip주소를 확인할 수 있다. (필요한 경우 https://api64.ipify.org로 접속하자)

get요청으로 확인해보자

import requests as req

res = req.get("http://api.ipify.org/")
print(res.status_code)
print(res.text)

이때 res.text가 124.04.124.03와 같은 문자열 형식이다.
http://api.ipify.org/에 접속했을 때에는 html 형식이다.

그 이유는 http://api.ipify.org/에 접속하여 나온 응답값은 브라우저에서 html형식으로 파싱했기 때문이다. (경우에 따라서는 서버에서 header를 확인하여 매체에 따라 다른 응답값을 주는 경우도 있다.)

이제 request를 확인해보자

import requests as req

res = req.get("https://api64.ipify.org", headers={"velog": "good"})
print(res.request.headers)

{'User-Agent': 'python-requests/2.31.0', 'Accept-Encoding': 'gzip, deflate', 'Accept': '*/*', 'Connection': 'keep-alive', 'velog': 'good'}

print(res.text)가 문자라면,
print(res.raw)는 byte를 출력한다. 이미지와 같은 파일을 다룰 때에 사용한다.
print(res.elapsed)는 요청 후 응답이 도착할 때까지의 경과 시간을 의미한다.

print(res.raw)
print(res.elapsed)
<urllib3.response.HTTPResponse object at 0x000001D13D2B4880>
0:00:00.604761

XML

<?xml version="1.0" encoding="UTF-8"?>
<note>
  <to>Tove</to>
  <from>Jani</from>
  <heading>Reminder</heading>
  <body>Don't forget me this weekend!</body>
</note>

위 xml은 <?xml version="1.0" encoding="UTF-8"?>라는 헤더를 가지고 있다. UTF-8은 8비트 방식의 유니코드 포맷이라는 뜻이다.

xml에서 html태그처럼 생긴 아이들은 'Node'라고 부른다. 위의 xml파일은 'note'라는 하나의 노드를 지닌 문서이다.

파이썬에서 파싱할 때에는 아래와 같다

from xml.etree.ElementTree import parse #파이썬 기본 라이브러리 ElementTree XML API

tree = parse('test.xml')
root = tree.getroot()

notes = root.findall("note")

headings = [x.findtext("heading") for x in notes]

print(headings) #['Reminder']

html

html에서는 <div>, <span>, <p> 세 가지의 태그 안에 text가 있는 경우가 대부분이다.

BeautifulSoup라는 라이브러리를 이용하여 파싱하게 된다.

JSON

xml이 노드 형태라면,
JSON은 "키" : "밸류" 형태이다.
Javascript Object Notation이라는 이름에서 알 수 있듯, 자바스크립트를 통해 문자열로 표기하고 파싱하는 데이터 타입이다. 자바스크립트 간의 데이터 이동이라는 특징이 있으며, 자바스크립트 기본 사양으로 내장되어 있기 때문에 대부분의 브라우저에서 지원하고 성능상 이점이 있기에 XML을 대체하게 되었다.

개발자도구 - network - XHR을 통해 데이터가 오가는 모습을 확인할 수 있는데, 해당 데이터가 JSON 형식인 것을 확인할 수 있다.

JSON과 파이썬의 dictionary는 "key" : "value" 형태의 해시 구조라는 점에서 유사점을 지니며, 이에 따라 json이라는 라이브러리를 통해 dictionary로 파싱할 수 있다.

import json

# JSON 문자열
json_data = '{"name": "John", "age": 30, "city": "New York"}'

# JSON 파싱
data = json.loads(json_data)

환율 계산기

  • requests를 활용한 파싱
  • find(), split() 을 이용한 문자열 파싱 - 패턴을 찾는다.
  • 정규표현식을 활용한 패턴 검색 - 패턴을 찾고, 예외사항도 우아하게 처리하게 된다.
  • 쿼리 스트링 활용
  • BeautifulSoup을 활용한 HTML 파싱
  • css selector를 활용한 파싱

find(), split()을 이용해보기

네이버 증권 환율 공시 이다. 접속하여 우클릭-페이지 소스보기로 소스를 확인해본다

목표로하는 1,317원의 글자가 있다. 해당 글자를 발라내면 된다.

import requests as req

res = req.get("https://finance.naver.com/marketindex/?tabSel=exchange#tab_section")

print(type(res.text)) #<class 'str'>

요청에 대한 응답으로 html이 스트링 형식으로 반환되고 있다.

import requests as req

res = req.get("https://finance.naver.com/marketindex/?tabSel=exchange#tab_section")

html = res.text

pos = html.find("미국 USD")
print(pos) #1076

이제 find 메서드를 이용해 "미국 USD"라는 문자의 첫번째 위치를 찾아냈다.

해당 위치에서부터 slice를 한 후,
목표로하는 글자 1,317원을 감싼 div 태그를 split으로 발라내자

import requests as req

res = req.get("https://finance.naver.com/marketindex/?tabSel=exchange#tab_section")

html = res.text

pos = html.find("미국 USD")
s = html[pos:].split("""<span class="value">""")[1].split("""</span>""")[0]
print(s)  # 1,316.50

split으로 tag를 지구고 text만 발라낼 때에는 str.split(여는태그)[1].split(닫는태그)[0]의 패턴이 쓰인다.

원하는 1,316.50원이 잘 출력되었다.

정규표현식

파이썬에서 정규표현식을 쓸 때에는 import re를 사용한다.

import re

str = "python is easy"
re.match(r"python", str)
>>> print(m)
<re.Match object; span=(0, 6), match='python'>

이때 패턴 앞에 붙은 r이라는 접두사는 raw string을 의미하며, break 문자를 문자열의 일부로 간주하겠다는 의미이다. HTML 파일의 문자열에 엔터키 등이 \n 등으로 남아있으므로, 정규표현식을 크롤링에 이용할 때에는 접두사 r을 붙이게 된다.

- () : 캡쳐
- [] : 이 중 아무거나
- . : 아무거나
- * : 0개이상
- + : 1개이상
- ? : 없을 수도
- \ : 위 특수 기호 무효화

주로 쓰이는 기호는 위와 같다.

캡처 기능을 이용해 앞서 본 환율을 출력해보자

import re
import requests as req

res = req.get("https://finance.naver.com/marketindex/?tabSel=exchange#tab_section")

html = res.text

pos = html.find("미국 USD")

s = re.findall(r'<span class="value">(.*)</span>', html[pos:])[0]
print(s) # 1,316.50

()라는 매처 안에 아무 문자를 의미하는 .과 갯수제한 없음을 의미하는 *을 써서 금액들을 모두 추출한다.

시작 pos를 기준으로 추출하였으므로, 리스트의 첫 요소가 찾는 환율이다.

이번에는 find도 쓰지 않고, 정규표현식만을 사용해보자.

import re
import requests as req

res = req.get("https://finance.naver.com/marketindex/?tabSel=exchange#tab_section")

html = res.text

r = re.compile(r"미국 USD.*?value\">(.*?)</", re.DOTALL)
captures = r.findall(html)
print(captures) # ['1,315.00']

정규 표현식 객체를 만드는 과정이 필요한데, 이를 compile을 통해 미리 객체를 만들도록 하여 성능상 이점을 가져올 수 있다.

re.DOTALL
.\n과 같은 공백문자는 포함하지 않는다. 공백 문자를 포함시키기 위해서는 .DOTALL 옵션을 추가해주어야 한다.

.*?
앞서 말했듯 .*은 모든 문자를 매칭시키는데, 이때 *은 greedy 매칭의 성격을 갖는다. 때문에 "미국 USD"이후에 있는 모든 문자가 매칭된다. non greedy 매칭의 성격을 갖는 ?를 추가로 삽입하면, 해당 조건이 일치하는 최소한까지 매칭된다.

위를 이용해서 (단위, 환율)의 튜플 리스트로 매칭해보자

r = re.compile(r"blind\">(.*?)</span>.*?value\">(.*?)</", re.DOTALL)
captures = r.findall(html)
print(captures)
[('미국 USD', '1,315.50'), ('원', '951.06'), ('원', '1,420.87'), ('원', '186.28'), ('원', '138.5800'), ('엔', '1.0798'), ('달러', '1.2416'), ('달러', '103.0700'), ('보합', '72.05'), ('달러', '1609.42'), ('원', '1977.2'), ('달러', '82897.54')]

불필요한 데이터들이 가운데에 끼어들어오고 있다.

"미국 USD" 텍스트가 들어있는 <h3 class="h_lst"> 태그를 이용하여 아래와 같이 수정해주자

r = re.compile(r"h_lst.*?blind\">(.*?)</span>.*?value\">(.*?)</", re.DOTALL)
captures = r.findall(html)
print(captures)
[('미국 USD', '1,315.50'), ('일본 JPY(100엔)', '951.06'), ('유럽연합 EUR', '1,420.87'), ('중
국 CNY', '186.28'), ('달러/일본 엔', '138.5800'), ('유로/달러', '1.0798'), ('영국 파운드/달 
러', '1.2416'), ('달러인덱스', '103.0700'), ('WTI', '72.05'), ('휘발유', '1609.42'), ('국제 
금', '1977.2'), ('국내 금', '82897.54')]

이제 예쁘게 출력만 하면 된다.

import re
import requests as req

res = req.get("https://finance.naver.com/marketindex/?tabSel=exchange#tab_section")

html = res.text

r = re.compile(r"h_lst.*?blind\">(.*?)</span>.*?value\">(.*?)</", re.DOTALL)
captures = r.findall(html)

print("오늘의 환율")
print("------")
for c in captures:
    print(f"{c[0]}: {c[1]}원")

print()

usd = float(captures[0][1].replace(",", ""))
won = int(input("달러로 바꾸길 원하는 금액을 입력하세요. :"))
print(f"{int(won/usd)} 달러로 환전 되었습니다.")
오늘의 환율
------
미국 USD: 1,316.00원
일본 JPY(100엔): 951.25원
유럽연합 EUR: 1,421.81원
중국 CNY: 186.37원
달러/일본 엔: 138.5800원
유로/달러: 1.0798원
영국 파운드/달러: 1.2416원
달러인덱스: 103.0700원
WTI: 72.05원
휘발유: 1609.42원
국제 금: 1977.2원
국내 금: 82897.54원

달러로 바꾸길 원하는 금액을 입력하세요. :1023124
777 달러로 환전 되었습니다.

쿼리스트링

네이버에서 '환율'을 검색하면 주소창에 아래와 같은 URL이 나온다.
https://search.naver.com/search.naver?where=nexearch&sm=top_hty&fbm=0&ie=utf8&query=%ED%99%98%EC%9C%A8
이때 'query='라는 키 뒤에 %를 포함한 값이 나오는데, 이는 한글이 utf로 반환한 값에 %를 넣은 것이다. 이를 '퍼센트 인코딩'(혹은 URL 인코딩)이라 부른다.
환율 => ED 99 98 EC 9C A8 => %ED%99%98%EC%9C%A8

쿼리 스트링의 키와 밸류는 개발자 도구를 통해 더 쉽게 볼 수 있다.

필요한 데이터를 쉽게 얻기 위해서 쿼리 스트링을 적절하게 이용하여 GET요청을 보낼 수 있다.

beautifulsoup

아래는 파이썬의 내장 모듈인 html.parser를 이용한 예시이다.

from html.parser import HTMLParser


class MyHTMLParser(HTMLParser):
    def handle_starttag(self, tag, attrs):
        print("Start tag:", tag)
        for attr in attrs:
            print("Attribute:", attr)

    def handle_endtag(self, tag):
        print("End tag:", tag)

    def handle_data(self, data):
        print("Data:", data)

    # 필요에 따라 다른 이벤트 처리 메서드를 오버라이드할 수 있습니다.
    # 예: handle_startendtag(), handle_comment(), handle_entityref() 등


html = "<html><body><h1>제목이다</h1><p>내용이다</p></body></html>"
parser = MyHTMLParser()
parser.feed(html)

아래와 같이 출력된다.

Start tag: html
Start tag: body
Start tag: h1
Data: 제목이다
End tag: h1
Start tag: p
Data: 내용이다
End tag: p
End tag: body
End tag: html

원하는 형식으로 parsing하고 데이터를 추출하기까지 많은 양의 보일러플레이트가 필요할 것을 예상할 수 있다.

이제 beautifulsoup4를 이용하여 보자

pip install beautifulsoup4

import requests as req
from bs4 import BeautifulSoup as BS

url = "https://naver.com"
res = req.get(url)

soup = BS(res.text, "html.parser")
print(soup.title) # <title>NAVER</title>

html.parser를 모듈 이름으로 받아 text를 객체 형식으로 변환해주는 것을 확인할 수 있다. "lxml"과 같은 외부 라이브러리를 사용하면 html.parser보다 빠르게 파싱이 가능하다.

td 태그를 불러와보자

import requests as req
from bs4 import BeautifulSoup as BS

url = "https://finance.naver.com/marketindex/exchangeList.naver"
res = req.get(url)

soup = BS(res.text, "html.parser")

tds = soup.find_all("td")
print(tds)

beautifulsoup의 find_all은 가장 인기있는 메서드이다. 해당하는 모든 태그를 가져온다.

하지만 우리가 사용하는 환율 사이트에는 생각보다 td가 많은데, 그 이유는 네이버의 경우 iframe 태그를 통해 여러 개의 html문서를 각각 불러와 합쳐진 형태이기 때문이다.

코드를 확인하면 iframe를 이용해 상대경로로 html문서를 불러오는 것을 확인할 수 있다. iframe는 html문서를 불러와 렌더링하는 태그이다.

tds에서 통화명을 찾아 렌더링하는 코드를 짜보자
통화명은 <td> <a> 통화명 </a> </td>의 구조로 되어있다.

tds = soup.find_all("td")
for td in tds :
    if len(td.find_all("a")) == 0:
        continue
    print(td.string)

이때 무의미한 공백이 발생하는데, 마지막 print를 아래와 같이 수정한다.

# print(td.string)
print(td.get_text(strip=True))

get_text 메서드에 strip 옵션을 주면 불필요한 공백이 사라진다.

그 밖에 아래와 같은 방식이 있다.

print(td.get_text(strip=True))
print(td.string)

for s in td.strings:
    print(s)

for s in td.stripped_strings:
    print(s)

strings는 문자열을 제너레이터로 저장한다. stripped_strings는 불필요한 공백을 줄인 문자열을 제너레이터로 저장한다.

한편 매매기준가는 class에 "sale"를 갖고 있다.

태그의 어트리뷰트는 attrs로 조회할 수 있으며, class는 배열 형태로 저장된다.

위 내용을 종합하여 names와 prices라는 두 리스트를 만들도록 코드를 수정해보자.

import requests as req
from bs4 import BeautifulSoup as BS

res = req.get("https://finance.naver.com/marketindex/exchangeList.naver")

soup = BS(res.text, "html.parser")

tds = soup.find_all("td")

names = []
for td in tds:
    if len(td.find_all("a")) == 0:
        continue
    names.append(td.get_text(strip=True))

prices = []
for td in tds:
    if "class" in td.attrs:
        if "sale" in td.attrs["class"]:
            prices.append(td.get_text(strip=True))

CSS 셀렉터

.클래스 셀렉터

자바스크립트에서 css 셀렉터를 이용해 요소를 선택할 때 아래와 같이 할 수 있다.
document.querySelectorAll("td.sale")
만일 여러개의 클래스를 일치시키려면 .을 이어서 좋으면 된다.
<td class="btn sale>1,320.50</td>
document.querySelector("td.sale.btn")
(클래스명의 순서는 무관함)

[어트리뷰트=값] 셀렉터

document.querySelector("input[type="text"]:disabled")
위 코드는 type이라는 어트리뷰트의 값이 "text"이고,
UI 요소 상태 셀렉터가 disabled인 요소를 선택한다.
(UI 요소 상태 셀렉터는 :checked, :enabled, :disabled의 세 가지가 있다.)

  • img[alt] : alt라는 attribute가 있을 때 선택한다.
  • img[alt="profile"] : alt가 - "profile"인 요소를 선택한다.
  • img[alt~="profile"] : "profile"이 포함된
  • img[alt-="profile"] : "profile"이거나 "profile-"로 시작하는
  • img[alt^="profile"] : "profile"로 시작하는
  • img[alt*="profile"] : "profile"을 포함하는

한정자

  • * : 모든 노드
  • div, p : 모든 div와 p 태그들
  • div p: div 안에 있는 p 태그들 (Descendant)
  • div > p : div 바로 안의 p 태그들(Child)
  • div ~ p : div 바로 옆의 p 태그들 (Adjacent Sibling)
  • div + p : div의 형제 p 태그들 (General Sibling)

구조 가상 클래스 셀렉터

  • ol > li:nth-child(2n) : ol 요소의 자식 요소인 li 요소 중에서 짝수번째 요소만을 선택
  • ol > li:nth-child(2n+1) : ol 요소의 자식 요소인 li 요소 중에서 홀수번째 요소만을 선택

부정 셀렉터

:not은 해당 셀렉터에서 선택하지 않은 모든 셀렉터를 선택한다.

만일 여러 개의 가상 셀렉터를 선택할 때에는 괄호로 묶으면 된다.

div:not(:nth-of-type(3n+1))
div:not(:nth-of-type(n+4))

beautifulSoup에서의 활용

beautifulSoup의 .select 메서드를 이용하여 CSS 셀렉터를 사용할 수 있다.

soup = BS(res.text, "html.parser")

arr = soup.select("input:enabled")

앞선 환율 예제를 css 셀렉터와 리스트 컴프리핸션으로 표현하면 아래와 같다.

import re
import requests as req
from bs4 import BeautifulSoup as BS

res = req.get("https://finance.naver.com/marketindex/exchangeList.naver")

soup = BS(res.text, "html.parser")


names = [td.get_text(strip=True) for td in soup.select("td a")]
# names = []
# for td in tds:
# if len(td.find_all("a")) == 0:
# continue
# names.append(td.get_text(strip=True))

prices = [td.get_text(strip=True) for td in soup.select("td.sale")]
# prices = []
# for td in tds:
#     if "class" in td.attrs:
#         if "sale" in td.attrs["class"]:
#             prices.append(td.get_text(strip=True))

이처럼 select를 이용해 CSS 셀렉터를 사용하면 복잡한 if문이 한 줄로 처리되는 것을 확인할 수 있다.

쿠팡 예제

쿠팡에서 노트북을 검색해보자

import requests as req
from bs4 import BeautifulSoup as BS

res = req.get("https://www.coupang.com/np/search?component=&q=노트북")

soup = BS(res.text, "html.parser")

request.get의 응답이 오지 않는 것을 확인할 수 있다.

쿠팡 측에서 크롤링을 막기 때문인데,
웹 브라우저에서 개발자도구의 network탭을 연 채로 다시 접속해보자

최초로 HTML을 받아오는 과정에서 헤더의 User-Agent를 넣어 브라우저를 표현하고 있다.

이를 헤더에 추가해주자

import requests as req
from bs4 import BeautifulSoup as BS

res = req.get(
    "https://www.coupang.com/np/search?component=&q=노트북",
    headers={
        "User-Agent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Mobile Safari/537.36"
    },
)

soup = BS(res.text, "html.parser")

광고를 가진 요소를 보자

광고가 없는 요소를 보자

광고가 있는 요소에는 'ad-bagde'라는 클래스를 가진 요소가 삽입되어 있는 것을 확인할 수 있다.

import requests as req
from bs4 import BeautifulSoup as BS


res = req.get(
    "https://www.coupang.com/np/search?component=&q=노트북",
    headers={
        "User-Agent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Mobile Safari/537.36"
    },
)

soup = BS(res.text, "html.parser")
for desc in soup.select("div.descriptions-inner"):
    ads = desc.select("span.ad-badge")
    text = ""
    if len(ads) > 0:
        text = "광고"
    print(text + desc.select("div.name")[0].get_text(strip=True))