웹에 접속하면 어떤 일이 이루어지는가?
- 서버를 향해 라우터들을 거쳐 이동하게 된다
- 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))