크롤링( Crawling )이란 여러 웹 페이지에서 필요한 정보들을 골라내어 데이터를 수집하는 활동입니다.

예를들면, 네이버의 실시간 영화 예매 순위 정보를 얻어내기 위해서는 네이버 영화 사이트의 DOM 구조를 분석하여 영화 제목만 가져오면 될 것입니다.


이번 글에서는 페이스북 Graph API를 사용하여 JTBC 뉴스 페이지의 뉴스 기사들을 크롤링하도록 하겠습니다.



1. 준비 작업

1)

우선 페이스북 개발자 센터에서 앱을 생성합니다.



2)

앱을 생성한 후, 상단의 도구 탭을 클릭하여 " 그래프 API 탐색기 "를 클릭합니다.



3)

" 페이지 액세스 토큰 받기 "를 클릭합니다.



액세스 토큰 값은 나중에 페이스북 API에 접근하기 위해 사용되므로, 지금 당장 할 것은 없습니다.





2. 그래프 API

그래프 API를 사용하기 위해 token 값을 받았는데, 그래프 API는 무엇일까요?


그래프 API 통해 다음과 같이 분류하여 데이터를 가져옵니다.

노드( Node )  : 사용자, 사진, 페이지, 댓글과 같은 기본적인 항목

에지( Edge )   : 페이지의 사진, 사진의 댓글 등 항목 간의 연결 링크  

필드( Field )   : 생일, 페이지의 이름 등의 실제 항목에 대한 정보


그래프 API는 데이터를 가져오거나 게시하는 HTTP 기반의 API이며, Node와 Edge에 대하여 GET 요청을 통해 데이터를 가져옵니다.

그리고 그래프 요청을 하기 위해서는 엑세스 토큰이 필요합니다.



페이스북 API 요청 URL은 다음과 같은 양식입니다.

https://graph.facebook.com/v2.8/[Node, Edge]/?parameters

보통 https://graph.facebook.com/v2.8 가 기본( base )가 되는 url이며, 그 다음에는 Node, Edge 가 붙습니다.

그리고 Node, Edge에 대한 추가적인 정보들, 즉 파라미터들은 Query String 형태로 붙게 됩니다.



파라미터 종류는 다음과 같습니다.

1) fileds

id( 포스트 ID ),                                              comments( 댓글정보 ),                                             created_time( 생성일 )

from( 포스트한 사용자 프로필 ),        link( 링크 정보 ),                                                       message( 포스트 내용 ),

name( 링크의 이름 ),                                 object_id( 업로드한 사진 , 동영상 id ),             parent_id( 부모 포스트 )

picture( 포함된 사진들의 링크 ),        place( 포스트한 위치 ),                                           reactions( 리액션 정보 ),

shares( 공유한 숫자 ),                               updated_time(최종 수정일),                                  type( 포스트의 형식{link, status, photo, video, offer} )


2) since, until

언제부터 언제까지 게시글을 가져올 것인지 작성합니다.

페이스북에서는 유닉스 타임 스태프, ‘ YYYY-MM-DD ’ 형식의 날짜 포맷 문자열을 사용합니다.


3) limit

한 번 요청에 가져올 포스트 수





3. 크롤링 구현

크롤링 구현하기에 앞서, 프로젝트 구조는 다음과 같습니다.


[ collection / json_request.py ]

json_request는 크롤링을 수행하는 함수에서 사용하는 모듈입니다.

즉 json_request() 함수를 호출해서 URL 경로로 필요한 데이터를 요청하고, JSON으로 반환하는 함수입니다.

import sys
from urllib.request import Request, urlopen
from datetime import *
import json

# error 로그 출력
def json_request_error(e):
print('{0}: {1}'.format(e, datetime.now()), file=sys.stderr)

# success, error에 함수를 등록해주면 그 함수를 실행시키겠다는 의미.
# js에서 ajax success, error와 유사
def json_request(url = '', encoding = 'utf-8',
success = None,
error = json_request_error):
try:
req = Request(url) # request 객체 생성
res = urlopen(req) # URL에 연결하여 response 객체 반환
if res.getcode() == 200:
res_body = res.read().decode(encoding) # json string
# print(res_body, type(res_body))
res_json = json.loads(res_body) # python 자료형인 Dictionary로 반환
# print(json_result, type(json_result))

if callable(success) is False:
return res_json
success(res_json)

except Exception as e:
callable(error) and error('%s %s' % (str(e), url))

1) import

요청( req ), 응답( res ) 객체를 생성하기 위해서 urllib.reqeust 모듈의 Reqeust, urlopen 함수를 import 합니다.

또한 날짜 관련 모듈인 datetime 모듈을 import 하여 에러 로그를 남기는데 사용하고,

json과 관련된 조작을 위해 json 모듈을 import 합니다.



2) json_request 함수

json_request 함수에 매개변수로 url, encoding, success, error를 받으며, 인자가 전달되지 않을 경우를 위해 기본 값을 세팅했습니다.


Request() 함수를 호출할 때 접근하려는 URL 주소를 넘겨주면, 요청 객체가 생성됩니다.

urlopen() 함수를 호출할 때 요청 객체를 넘겨주면, 응답 객체가 생성됩니다.


응답 객체의 상태 코드가 200일 경우, 응답 객체를 읽어서 decoding 합니다.

그러면 요청 URL에 대한 데이터들이 JSON으로 반환됩니다.


이어서 json 모듈의 loads() 함수를 호출하면 JSON을 python이 사용할 수 있도록 dictionary 자료형으로 반환합니다.




[ collection / fb_api.py ]

다음으로는 위에서 작성한 json_request 모듈을 사용하여 크롤링을 수행하는 코드를 작성해보겠습니다. ( fb_fetch_post )

먼저 앞에서 살펴봤던 페이스북 API 요청 URL 양식에 맞게 URL을 생성하도록 해야 합니다. ( fb_generate_url )

그리고 크롤링 하고자 하는 페이지에 접근하기 위해 페이지의 id를 얻어야 합니다. ( fb_name_to_id )

from urllib.parse import urlencode
from collection import json_request as jr

BASE_URL_FB_API = 'https://graph.facebook.com/v3.0'
ACCESS_TOKEN = '엑세스 토큰 값 입력'


# 여러 파라미터에 대하여, url을 생성
def fb_generate_url(base = BASE_URL_FB_API, node = '', **param):
return '%s/%s/?%s' % (base, node, urlencode(param))


# API를 사용할 때 'JTBC 뉴스' 라는 페이지 이름이 아닌, 페이지의 id가 필요하다.
# 여기서 매개변수 pagename JTBC 뉴스 페이지 URL( https://www.facebook.com/jtbcnews/?ref=br_rs )에 붙은 것을 의미한다.
def fb_name_to_id(pagename):
url = fb_generate_url(node = pagename, access_token = ACCESS_TOKEN)
# print(url)
json_result = jr.json_request(url)
# print(json_result) # {'name': 'JTBC 뉴스', 'id': '240263402699918'}
return json_result.get('id')


# 게시글 가져오기 - 크롤러는 최종적으로 이 함수를 사용한다.
# 인자로 페이스북 페이지명과 게시글 일자 기간을 넘겨준다.
def fb_fetch_post(pagename, since, until):
# URL 생성 시, 여러 파라미터를 전달
url = fb_generate_url(
node = fb_name_to_id( pagename ) + '/posts',
fields = 'id, message, link, name, type, shares, created_time,\
reactions.limit(0).summary(true),\
comments.limit(0).summary(true)',
since = since, # 시작 날짜
until = until, # 끝 날짜
limit = 30, # 개수
access_token = ACCESS_TOKEN
)
    # print(url)

json_result = jr.json_request(url)
return json_result

posts = fb_fetch_post('jtbcnews', '2018-05-01', '2018-05-30')
print(posts)

1) fb_generate_url 함수

url을 생성하는 함수입니다.

페이지 이름을 id로 변환하는 fb_name_to_id 함수와 게시글 데이터를 가져오는 fb_fetch_post 함수에서 호출됩니다.

생성한 URL양식은 앞에서 살펴 본 페이스북 API 요청 URL과 같습니다.



2) fb_name_to_id 함수

페이지 이름을 id로 변환하는 fb_name_to_id 함수입니다.

fb_generate_url로 생성된 URL에 접근하면 pagename과 그 id를 프로퍼티로 갖는 JSON을 얻을 수 있습니다.



3) fb_fetch_post 함수

데이터를 가져오는 함수입니다.

필요한 정보를 얻기 위해 파라미터를 추가하여 URL을 생성하고, json_request 함수를 호출합니다.


fb_fetch_post 함수를 호출할 때 넘겨주는 pagename은 페이스북 페이지에 접속할 때 보이는 URL의 이름입니다.


fb_fetch_post 함수 호출 결과 콘솔을 보시면, 리스트 안에 데이터를 가져온 것을 확인할 수 있습니다.

URL을 생성할 때 limit을 30으로 잡았기 때문에, 리스트 안에는 30개의 데이터가 있습니다.

그런데 한 달 간의 뉴스가 30개 일리는 없겠죠?

그래서 모든 뉴스를 가져올 수 있도록 수정을 할 것입니다.





4. 리팩토링

그러기 위해서는 json_request() 호출 결과, 반환 되는 JSON 구조를 살펴볼 필요가 있습니다.

콘솔에 출력되는 posts의 값을 복사하여 json parser online 사이트 ( 링크 )에 붙여 넣기 하여 JSON 구조를 확인해보겠습니다.



보시는 바와 같이 data와 paging 이라는 프로퍼티가 있으며,

paging 프로퍼티의 값은 cursors와 next 프로퍼티를 갖는 객체입니다.


지금 상황은 30개의 데이터를 가져왔는데, paging에 next 프로퍼티가 존재하므로 더 많은 데이터가 있다는 것을 의미합니다.

따라서 반복문을 돌면서 next 프로퍼티가 없을 때까지 데이터를 계속 가져오도록 하겠습니다.


[ fb_fetch_post 함수 ]

def fb_fetch_post(pagename, since, until):
url = fb_generate_url(
node = fb_name_to_id( pagename ) + '/posts',
fields = 'id, message, link, name, type, shares, created_time,\
reactions.limit(0).summary(true),\
comments.limit(0).summary(true)',
since = since, # 시작 날짜
until = until, # 끝 날짜
limit = 30, # 개수
access_token = ACCESS_TOKEN
)

isnext = True
while isnext is True:
json_result = jr.json_request(url)

paging = None if json_result is None else json_result.get('paging')
url = None if paging is None else paging.get('next')
isnext = url is not None

posts = None if json_result is None else json_result.get('data')
# generator를 사용해서 fb_fetch_post(...) 함수를 for 푸르안에서 사용 가능하도록 수정한다.
yield posts

for posts in fb_fetch_post('jtbcnews', '2018-05-01', '2018-05-30'):
print(posts)

while 문을 살펴보겠습니다.

json_reqeust 호출 결과로 반환된 JSON에 대하여, paging 프로퍼티의 next 프로퍼티의 값을 확인 합니다.

원래 next 프로퍼티의 값은 URL이기 때문에 url 변수에 할당하면 될 것이며, 만약 next가 없을 경우 None이 할당될 것입니다.


그리고 게시글 데이터는 JSON의 data 프로퍼티 값이기 때문에 posts 변수에 data 프로퍼티의 값을 할당했습니다.


또한 여기서 게시글 데이터인 posts를 return하지 않고 yield로 처리했습니다.

yield자신을 호출한 함수에게 권한을 위임하는 역할을 합니다.

즉 fb_fetch_post 함수의 결과 값을 갖고 어떤 로직을 수행할 수 있게 됩니다.



콘솔에 출력되는 결과는 위와 같습니다.

총 10개의 리스트가 존재하며, 하나의 리스트에는 30개의 Dictionary가 원소로 존재합니다.

( 실제 데이터 총 개수는 293개였습니다. )





5. 전처리 및 파일 저장

이제 마지막으로 게시글 데이터가 담겨있는 JSON 프로퍼티 구조를 조정하는 전처리와 크롤링 결과를 파일로 저장하도록 하겠습니다.


[ collection / crawler.py ]

from datetime import datetime, timedelta
import json
import os
from collection import fb_api

RESULT_DIRECTORY = '__resultes__/crawling'

# 전처리 함수
# 공유수, 전체 리액션 수, 등을 조회하기 위해서는 깊게 들어가야 하는데,
# 이를 간단하게 처리하게 하기 위함이다.
def pre_precess(post):
# 공유수
# 'shares': {'count': 57} -> 'count_shares': 57
if 'shares' not in post:
post['count_shares'] = 0
else:
post['count_shares'] = post['shares']['count']
del post['shares']

# 전체 리액션 수
# 'reactions': {'data': [], 'summary': {'total_count': 373, 'viewer_reaction': 'NONE'}}
# -> 'count_reactions' : 373
if 'reactions' not in post:
post['count_reactions'] = 0
else:
post['count_reactions'] = post['reactions']['summary']['total_count']
del post['reactions']

# 시간 변경하기 ( 국제시 -> 국내시 )
# 페이스북은 UTC를 사용하는데 KST UTC보다 9가 높다.
kst = datetime.strptime(post['created_time'], '%Y-%m-%dT%H:%M:%S+0000')
kst = kst + timedelta(hours=+9)
post['created_time'] = kst.strftime('%Y-%m-%d %H:%M:%S')


def crawling(pagename, since, until):
result = []
filename = '%s/fb_%s_%s_%s.json' % (RESULT_DIRECTORY, pagename, since, until)

for posts in fb_api.fb_fetch_post(pagename, since, until):
# posts는 데이터가 30개씩 있는 리스트이다.
for post in posts:
pre_precess(post)
result += posts

# save result to file
with open(filename, 'w', encoding='utf-8') as outfile:
# json.dump()는 파이썬의 list, dict 등의 객체를 JSON 문자열로 바꾸는 함수이다.
# indent는 여러행으로 예쁘게 출력하기 위함이다.
# sort_key JSON안의 속성(key)를 정렬한다.
# ensure_ascii false로 하여 ascii가 아닌 문자가 escape 되는 것을 막는다.
json_string = json.dumps(result, indent=4, sort_keys=True, ensure_ascii=False )
outfile.write(json_string)


# 현재 OS에 매개변수로 받은 이름의 디렉터리가 없다면 디렉터리를 생성하라.
if os.path.exists(RESULT_DIRECTORY) is False:
os.makedirs(RESULT_DIRECTORY)

1) pre_precess



2) crawling 함수

fb_fetch_post 함수를 호출하여 얻은 게시글 데이터를 result 리스트에 추가합니다.

그리고 with ~ as ~ 구문으로 RESULT_DIRECOTRY 변수의 값인 __results__/crawling 디렉터리에 결과 데이터를 저장합니다.


마지막에 os.makedirs() 함수를 호출하면, __results__/crawling가 없을 경우 디렉터리를 생성합니다.





6. 실행

[ __main__.py ]

import collection.crawler as crawler

if __name__ == '__main__':
items = [
{
# jtbc 뉴스
'pagename': 'jtbcnews',
'since': '2018-05-01',
'until': '2018-05-30'
},
{ # 대한민국 청와대
'pagename': 'TheBlueHouseKR',
'since': '2018-05-01',
'until': '2018-05-30'
}
]

for item in items:
resultfile = crawler.crawling(**item)

이제 crawling() 함수를 실행하는 main 함수입니다.

여러 개의 페이지를 크롤링 하고 싶으면 위와 같이 items 리스트를 만들어서 전달하면 됩니다 !




이상으로 페이스북 그래프 API를 이용하여 페이스북 페이지를 크롤링 해보았습니다 !