In [143]:
import pandas as pd
import requests
import json
import plotly.express as px
import cufflinks as cf
from tqdm import tqdm_notebook
from bs4 import BeautifulSoup
import numpy as np
import seaborn as sns
import matplotlib.pyplot as plt

cf.go_offline(connected=True)
plt.rcParams.update({
    'font.family': 'AppleGothic',
    'font.size': 14,
    'figure.figsize': (20, 10),
})

access_token 은 아래 링크에 들어가시면 얻을 수 있습니다.
본인의 tistory app_id 와 callback_url 을 넣은 뒤에 사용하세요.
일정 시간 주기로 토큰이 파기되니 데이터 다시 로드할 때마다 다시 받아와야 합니다 ㅠㅠ (좀 귀찮.. 하지만 금방 함)

https://www.tistory.com/oauth/authorize?client_id={app_id}&redirect_uri={callback_url}&response_type=token

참고 :

In [144]:
# 위에서 얻은 토큰 값을 아래 변수에 넣어서 주석 푼 뒤 사용!
# access_token = 

글 목록 확인

In [145]:
def get_posts_list(page):
    url = "https://www.tistory.com/apis/post/list"
    params = {
        'output': 'json',
        'access_token': access_token,
        'blogName': 'dailyheumsi',
        'page': page
    }
    return requests.get(url, params)
In [146]:
df_posts = pd.DataFrame()

page = 1
while True:
    res = get_posts_list(page)
    if res.status_code != 200:
        break
    
    res = json.loads(res.content)
    if 'posts' not in res['tistory']['item']:
        break
        
    posts = res['tistory']['item']['posts']
    
    df_posts = df_posts.append(posts, ignore_index=True)
    page += 1
In [147]:
# 데이터 확인
df_posts.head()
Out[147]:
id title postUrl visibility categoryId comments trackbacks date
0 205 [취준생의 데이터 분야의 커리어 고민 3] 엔지니어가 되자 https://dailyheumsi.tistory.com/205 20 864097 4 0 2020-03-01 21:07:57
1 204 [취준생의 데이터 분야의 커리어 고민 2] 분석으로 취업은 힘들다 https://dailyheumsi.tistory.com/204 20 864097 7 0 2020-02-26 18:07:20
2 203 스프링 부트를 활용한 간단한 웹 사이트 https://dailyheumsi.tistory.com/203 20 801880 0 0 2020-02-24 00:55:55
3 202 [스프링 프레임워크 핵심 기술] AOP https://dailyheumsi.tistory.com/202 20 874866 0 0 2020-02-23 23:36:01
4 201 [디자인 패턴 9편] 구조 패턴, 프록시(Proxy) https://dailyheumsi.tistory.com/201 20 855210 0 0 2020-02-23 21:49:14
In [148]:
# 자료형을 살펴보면 다음과 같다.
df_posts.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 193 entries, 0 to 192
Data columns (total 8 columns):
 #   Column      Non-Null Count  Dtype 
---  ------      --------------  ----- 
 0   id          193 non-null    object
 1   title       193 non-null    object
 2   postUrl     193 non-null    object
 3   visibility  193 non-null    object
 4   categoryId  193 non-null    object
 5   comments    193 non-null    object
 6   trackbacks  193 non-null    object
 7   date        193 non-null    object
dtypes: object(8)
memory usage: 12.2+ KB
In [149]:
# 일부 컬럼들의 자료형을 바꿔준다.
df_posts['id'] = df_posts['id'].astype('int')
df_posts['date'] = pd.to_datetime(df_posts['date'])
df_posts[['comments', 'trackbacks']] = df_posts[['comments', 'trackbacks']].astype('int')
df_posts[['visibility', 'categoryId']] = df_posts[['visibility', 'categoryId']].astype('category')
In [150]:
# 공개된 글 데이터만 남겨둠.
df_posts = df_posts[df_posts['visibility'] == '20']
len(df_posts)
Out[150]:
184

총 184개의 글을 올렸음.

In [151]:
df_posts['year'] = df_posts['date'].dt.year
df_posts['month'] = df_posts['date'].dt.month
df_posts['day'] = df_posts['date'].dt.day

월별 포스팅된 글 개수

In [152]:
# 2018-08 ~ 2020-02 까지의 데이터프레임을 하나 만들어 둠.
size_by_month = pd.DataFrame({'date': pd.date_range('2018-08', '2020-03', freq='m')})
size_by_month['year'] = size_by_month['date'].dt.year
size_by_month['month'] = size_by_month['date'].dt.month
size_by_month.drop('date', 1, inplace=True)
In [153]:
# 위에서 구한 df_posts 를 위 데이터프레임에 합침.
tmp = df_posts.groupby(['year', 'month']).size().reset_index()
tmp.columns = ['year', 'month', '글 개수']

size_by_month = pd.merge(size_by_month, tmp, how='left').fillna(0)
In [154]:
# 년/월별 글 개수를 시각화 해보자.
tmp = pd.DataFrame()
tmp['년/월'] = size_by_month.apply(lambda x: "%d/%d" %(x['year'], x['month']), axis=1)
tmp['글 개수'] = size_by_month['글 개수']
tmp.set_index('년/월', inplace=True)
In [155]:
tmp.iplot(mode='markers+lines')

각 글들을 카테고리로 나눠서 보면??

먼저 카테고리 id만 있으므로, 카테고리 관련 데이터를 받아오자.

In [156]:
def get_category_list():
    url = "https://www.tistory.com/apis/category/list"
    params = {
        'output': 'json',
        'access_token': access_token,
        'blogName': 'dailyheumsi',
    }
    return requests.get(url, params)
In [157]:
# category data 를 받아옴.
res = get_category_list()
res = json.loads(res.content)

df_categories = pd.DataFrame(res['tistory']['item']['categories'])
df_categories.head()
Out[157]:
id name parent label entries entriesInLogin
0 804355 언젠가 또 기억하고 싶은거 864096 일상, 생각, 경험/언젠가 또 기억하고 싶은거 1 1
1 864096 일상, 생각, 경험 일상, 생각, 경험 10 10
2 854906 데이터 시각화 815369 공부하며 적어놓기 1/데이터 시각화 13 14
3 855210 빽 투더 기본기 800526 취업과 기본기 튼튼/빽 투더 기본기 29 29
4 882621 자바로 개발하기 882620 공부하며 적어놓기 2/자바로 개발하기 2 3
In [158]:
df_categories.set_index('id', inplace=True)
In [159]:
categories = [] # ['id', 'category_1', 'category_2'] 의 페어 리스트를 담음.
for idx, row in df_categories.iterrows():
    if row['parent'] == '':
        categories.append([idx, row['name'], row['name']])
    else:
        categories.append([idx, df_categories.loc[row['parent'], 'name'], row['name']])

# category 와 id 를 담는 데이터프레임을 다시 구성
df_categories = pd.DataFrame(categories, columns=['categoryId', 'category_1', 'category_2'])
In [160]:
df_categories.head()
Out[160]:
categoryId category_1 category_2
0 804355 일상, 생각, 경험 언젠가 또 기억하고 싶은거
1 864096 일상, 생각, 경험 일상, 생각, 경험
2 854906 공부하며 적어놓기 1 데이터 시각화
3 855210 취업과 기본기 튼튼 빽 투더 기본기
4 882621 공부하며 적어놓기 2 자바로 개발하기
In [161]:
# 카테고리1 (큰 카테고리) 에 해당하는 카테고리 수
df_categories['category_1'].nunique()
Out[161]:
8
In [162]:
# 카테고리2 (작은 카테고리) 에 해당하는 카테고리 수
df_categories['category_2'].nunique()
Out[162]:
20
In [163]:
# category 데이터프레임을 기존 posts 데이터프레임과 합침.
df_posts = pd.merge(df_posts, df_categories, how='left')
df_posts.head()
Out[163]:
id title postUrl visibility categoryId comments trackbacks date year month day category_1 category_2
0 205 [취준생의 데이터 분야의 커리어 고민 3] 엔지니어가 되자 https://dailyheumsi.tistory.com/205 20 864097 4 0 2020-03-01 21:07:57 2020 3 1 일상, 생각, 경험 그냥 얘기
1 204 [취준생의 데이터 분야의 커리어 고민 2] 분석으로 취업은 힘들다 https://dailyheumsi.tistory.com/204 20 864097 7 0 2020-02-26 18:07:20 2020 2 26 일상, 생각, 경험 그냥 얘기
2 203 스프링 부트를 활용한 간단한 웹 사이트 https://dailyheumsi.tistory.com/203 20 801880 0 0 2020-02-24 00:55:55 2020 2 24 프로젝트들 프로젝트들
3 202 [스프링 프레임워크 핵심 기술] AOP https://dailyheumsi.tistory.com/202 20 874866 0 0 2020-02-23 23:36:01 2020 2 23 공부하며 적어놓기 2 웹 백엔드 with 자바
4 201 [디자인 패턴 9편] 구조 패턴, 프록시(Proxy) https://dailyheumsi.tistory.com/201 20 855210 0 0 2020-02-23 21:49:14 2020 2 23 취업과 기본기 튼튼 빽 투더 기본기

이제 포스트 데이터에 카테고리가 추가되었으므로, 월별 카테고리 글 개수를 살펴보자.

In [164]:
# 2018-08 ~ 2020-02 까지의 데이터프레임을 하나 만들어 둠.
size_by_month = pd.DataFrame({'date': pd.date_range('2018-08', '2020-03', freq='m')})
size_by_month['year'] = size_by_month['date'].dt.year
size_by_month['month'] = size_by_month['date'].dt.month
size_by_month.drop('date', 1, inplace=True)
In [165]:
pvt = df_posts.pivot_table(index=['year', 'month'], columns='category_1', aggfunc='size', fill_value=0).reset_index()
In [166]:
size_by_month = pd.merge(size_by_month, pvt, how='left').fillna(0)
size_by_month.set_index(['year', 'month'], inplace=True)
In [167]:
size_by_month.iplot('area', fill=True, )

시간대별 글 개수

혹시 내가 자주 글을 쓰는 시간대가 있었을까?

In [168]:
df_posts['hour'] = df_posts['date'].dt.hour
In [169]:
df_posts.groupby('hour').size().iplot('bar')

카테고리별로 비율을 보면?

In [170]:
pvt = df_posts.pivot_table(index='hour', columns='category_1', aggfunc='size', fill_value=0)
pvt = pvt.div(pvt.sum(axis=1), axis=0)
pvt.iplot('bar', barmode='stack')

나는 어떤 글들을 썼을까?

In [171]:
tmp = df_posts.groupby('category_1').size().reset_index()
tmp.columns = ['category', 'size']
tmp.iplot('pie', labels='category', values='size', hole=.4)
In [172]:
tmp = df_posts.groupby(['category_1', 'category_2']).size().reset_index()
tmp.rename({0: '포스팅 수'}, axis=1, inplace=True)

categories = ['취업과 기본기 튼튼', '공부하며 적어놓기 1', '공부하며 적어놓기 2']
tmp = tmp[tmp['category_1'].isin(categories)]
In [173]:
grp = tmp.groupby('category_1')
for grp_name, grp_df in grp:
    size_by_cat2 = grp_df.groupby('category_2').sum().reset_index()
    size_by_cat2.iplot(kind='pie', labels='category_2', values='포스팅 수', title=grp_name, hole=0.4)

원래는 아래 코드와 같이 파이 그래프 3개를 하나로 묶어서 표현하려고 했으나,
plotly subplots 에는 각각 파이마다 레전드를 달 수가 없음 (현재 지원 x)
따라서 위 같이 그냥 하나씩 그린 후에 그냥 포토샵으로 묶어야 겠다...._

엑셀로 그리니까 편하다 ㅠㅠㅠㅠ
아래 코드들 다 안씀.

In [174]:
# grp = tmp.groupby('category_1')
# plots = []
# for grp_name, grp_df in grp:
#     size_by_cat2 = grp_df.groupby('category_2').sum().reset_index()
#     plots.append(size_by_cat2.figure(kind='pie', labels='category_2', values='포스팅 수')['data'][0])

# from plotly.subplots import make_subplots

# fig = make_subplots(rows=1, cols=3, specs=[[{'type':'domain'}, {'type':'domain'}, {'type':'domain'}]]) # pie 라서 ..
# fig.add_traces(plots, rows=[1,1,1], cols=[1,2,3])
# fig.update_traces(hole=.4)
# fig.show()
In [175]:
# tmp = tmp[tmp['category_1'] != tmp['category_2']]

# category_1 = tmp['category_1'].unique().tolist()
# category_2 = tmp['category_2'].tolist()
# parents = len(category_1)*[""] + tmp['category_1'].tolist()
# # values = tmp.groupby('category_1')['포스팅 수'].sum().tolist() + tmp['포스팅 수'].tolist()
# values = len(category_1)*[0] + tmp['포스팅 수'].tolist()
In [176]:
# fig = go.Figure(go.Sunburst(
#     labels=category_1+category_2,
#     parents=parents,
#     values=values
# ))
# fig.update_layout(margin = dict(t=0, l=0, r=0, b=0))
# fig.show()

준비 작업

먼저 각 포스팅 데이터를 받아오자

In [177]:
def get_post_content(post_id):
    url = "https://www.tistory.com/apis/post/read"
    params = {
        'output': 'json',
        'access_token': access_token,
        'blogName': 'dailyheumsi',
        'postId': post_id,
    }
    return requests.get(url, params)
In [178]:
# id 0 부터 끝까지 넣어보며 post 받아오기.
# 나는 마지막 포스팅 id 가 204 임을 확인함
detail_posts = []

for post_id in tqdm_notebook(range(205)):
    res = get_post_content(post_id)
    if res.status_code != 200:
        continue
    
    res = json.loads(res.content)
    if res['tistory']['item']['visibility'] != '20':
        continue
        
    content = res['tistory']['item']['content']
    detail_posts.append([post_id, content])

In [179]:
df_detail_posts = pd.DataFrame(detail_posts, columns=['id', 'content'])
df_detail_posts.head()
Out[179]:
id content
0 4 <p>1 * 2 * .. * n 과 같은 꼴을 팩토리얼이라 하고, 기호로 n! 이라...
1 5 <p cid="n0" mdtype="paragraph" class="md-end-b...
2 6 <p>당신이 컴퓨터공학과 출신이거나 혹은 신입 개발자 취업 준비를 해왔다면, 수 많...
3 8 <p>만약에, 면접장에서 큐를 구현해보라는 말을 들으면 어떻게 해야할까? 이런 기본...
4 9 <p>[##_Image|[email protected]/btqzyhu4JHV/S8kXHSV2Rk...
In [180]:
df_detail_posts.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 184 entries, 0 to 183
Data columns (total 2 columns):
 #   Column   Non-Null Count  Dtype 
---  ------   --------------  ----- 
 0   id       184 non-null    int64 
 1   content  184 non-null    object
dtypes: int64(1), object(1)
memory usage: 3.0+ KB
In [181]:
# content 에서 html tag 모두 제거
def remove_html(html):
    bs = BeautifulSoup(html)
    return bs.get_text()

df_detail_posts['content'] = df_detail_posts['content'].apply(lambda x: remove_html(x))
df_detail_posts['content'] = df_detail_posts['content'].str.replace('\n', ' ')
/Users/heumsi/anaconda3/envs/py36/lib/python3.6/site-packages/bs4/__init__.py:181: UserWarning:

No parser was explicitly specified, so I'm using the best available HTML parser for this system ("lxml"). This usually isn't a problem, but if you run this code on another system, or in a different virtual environment, it may use a different parser and behave differently.

The code that caused this warning is on line 193 of the file /Users/heumsi/anaconda3/envs/py36/lib/python3.6/runpy.py. To get rid of this warning, change code that looks like this:

 BeautifulSoup(YOUR_MARKUP})

to this:

 BeautifulSoup(YOUR_MARKUP, "lxml")


In [182]:
# 이를 다시 df_posts 로 모아주자.
df_posts = pd.merge(df_posts, df_detail_posts, how='left')

근데 이후에 결국에 글 내용은 결국 안쓰고, 타이틀만 씀...

LDA 로 주제 분석

비율이 가장 높았던 3개의 카테고리 내 포스팅들은 어떤 주제를 가지고 있나 살펴보자.

In [183]:
categories = [
    '공부하며 적어놓기 1',
    '공부하며 적어놓기 2',
    '취업과 기본기 튼튼'
]
In [184]:
def get_tokenized_corpus(corpus, tagger):
    # 참고: https://ratsgo.github.io/korean%20linguistics/2017/03/15/words/#%EC%A3%BC%EA%B2%A9%EC%A1%B0%EC%82%AC
    stopwords = list("[]().,[email protected]#$%^&*~+-/<>\n") + list(map(str, range(10))) + list("은는이가을를의과와만도로의에") + ['\xa0']
    tokenized_corpus = []

    for title in corpus:
        # words = tagger.nouns(title) # <- 1) 성능이 너무 안나온다.
        words = []
        for word, tag in tagger.pos(title):
            if tag in ['Foreign', 'Alpha', 'Noun']: # <- 2) 따라서 수동으로 단어들을 찾아냄.
                words.append(word)

        words = [word for word in words if word not in stopwords]
        tokenized_corpus.append(words)
    return tokenized_corpus
In [185]:
def draw_word_score_heatmap(topn, cmap, num_topic, lda_model, dictionary):
    word_score = []

    for topic_id in range(num_topic):
        for word_id, score in lda_model.get_topic_terms(topic_id, topn=topn):
            word_score.append([topic_id, dictionary[word_id], score])
            
    df_topic_words = pd.DataFrame(word_score, columns=['topic_id', 'word', 'score'])
    
    # 참고 : https://brunch.co.kr/@goodvc78/13#comment
    # 참고에 해당하는 topic - score heatmap 을 만들어보려함.

    labels, scores = [], []
    for grp_name, grp_df in df_topic_words.groupby('topic_id'):
        grp_df.sort_values('score', inplace=True, ascending=False)

        labels.append(grp_df['word'].tolist())
        scores.append(grp_df['score'].tolist())
        
    tmp = pd.DataFrame(scores)

    plt.figure()
    ax = sns.heatmap(tmp, cmap=cmap, square=True, annot=np.array(labels), fmt='', cbar_kws={"shrink": 0.5})
    plt.show()
In [186]:
from konlpy.tag import Okt
from gensim import corpora
from gensim.models import LdaModel

cmaps = ['Oranges', 'Blues', 'Wistia']
plots = []
for category, cmap in zip(categories, cmaps):
    print(category)
    
    # 해당 카테고리에 해당하는 데이터만 가져오기.
    df_category = df_posts[df_posts['category_1'] == category]
    df_category.reset_index(drop=True, inplace=True)
    
    # title 를 기준으로 tokenized 된 corpus 얻기.
    corpus = get_tokenized_corpus(df_category['title'], Okt())
    
    # lda 모델링 전 데이터 전처리.
    dictionary = corpora.Dictionary(corpus)
    corpus = [dictionary.doc2bow(words) for words in corpus]
    
    # 토픽 수는 하위 카테고리 수 만큼.
    num_topics = df_category['category_2'].nunique()
    
    # LDA 모델 구축.
    lda = LdaModel(corpus, num_topics=num_topics, id2word=dictionary, passes=10)
    
    # 토픽별 word-score 를 히트맵으로 그리기.
    draw_word_score_heatmap(10, cmap, num_topics, lda, dictionary)
공부하며 적어놓기 1
공부하며 적어놓기 2
취업과 기본기 튼튼

어떤 글들이 인기 많았을까?

구글 애널리틱스에서 받은 데이터를 불러오자.

In [187]:
df_pv = pd.read_excel('data/ga_pv.xlsx', sheet_name="데이터세트1")
df_pv.head()
Out[187]:
페이지 페이지뷰 수 순 페이지뷰 수 평균 페이지에 머문 시간 방문수 이탈률 종료율(%) 페이지 값
0 /33 12220 7687 42.677364 7666 0.472998 0.617512 0
1 /36 10331 5862 49.533925 5670 0.335979 0.542058 0
2 /67 6175 3455 50.803914 3434 0.326150 0.553198 0
3 /105 5871 2582 74.029271 2552 0.160658 0.429739 0
4 /85 4867 2710 137.404666 2584 0.400542 0.489213 0
In [188]:
df_pv.rename({'페이지': 'id'}, axis=1, inplace=True)
df_pv['id'] = df_pv['id'].str.replace('/', '')
In [189]:
tmp = df_posts[['id', 'title', 'category_1', 'category_2', 'comments']]
tmp['id'] = tmp['id'].apply(str)

df_pv = pd.merge(df_pv,
                 tmp, 
                 how='inner')
df_pv.head()
Out[189]:
id 페이지뷰 수 순 페이지뷰 수 평균 페이지에 머문 시간 방문수 이탈률 종료율(%) 페이지 값 title category_1 category_2 comments
0 33 12220 7687 42.677364 7666 0.472998 0.617512 0 pip3? pip? 및 conda 내 pip 정리 공부하며 적어놓기 1 데이터 with 파이썬 3
1 36 10331 5862 49.533925 5670 0.335979 0.542058 0 파이썬으로 데이터 시각화하기 1편. matplotlib. 공부하며 적어놓기 1 데이터 시각화 1
2 67 6175 3455 50.803914 3434 0.326150 0.553198 0 파이썬 정렬, 다중 조건으로 한 번에 하기. 공부하며 적어놓기 1 데이터 with 파이썬 1
3 105 5871 2582 74.029271 2552 0.160658 0.429739 0 python 멀티 프로세싱은 parmap 으로 하자. 공부하며 적어놓기 1 데이터 with 파이썬 5
4 85 4867 2710 137.404666 2584 0.400542 0.489213 0 folium 의 plugins 패키지 샘플 살펴보기 공부하며 적어놓기 1 데이터 시각화 4
In [190]:
df_pv.shape
Out[190]:
(181, 12)

PV 로만 살펴보면

In [191]:
topn = 10
df_pv_topn = df_pv.sort_values('페이지뷰 수', ascending=False)[:topn]
In [192]:
df_pv_topn.set_index('title')['페이지뷰 수'].sort_values().iplot('barh')

수치형 변수들간의 상관관계를 살펴보면

In [193]:
df_pv.corr()
Out[193]:
페이지뷰 수 순 페이지뷰 수 평균 페이지에 머문 시간 방문수 이탈률 종료율(%) 페이지 값 comments
페이지뷰 수 1.000000 0.993271 0.034142 0.991482 0.084858 0.157099 NaN 0.306384
순 페이지뷰 수 0.993271 1.000000 0.032642 0.999707 0.112546 0.177879 NaN 0.257182
평균 페이지에 머문 시간 0.034142 0.032642 1.000000 0.030804 0.198782 0.112584 NaN 0.117134
방문수 0.991482 0.999707 0.030804 1.000000 0.117920 0.184239 NaN 0.245301
이탈률 0.084858 0.112546 0.198782 0.117920 1.000000 0.848446 NaN -0.052540
종료율(%) 0.157099 0.177879 0.112584 0.184239 0.848446 1.000000 NaN -0.069448
페이지 값 NaN NaN NaN NaN NaN NaN NaN NaN
comments 0.306384 0.257182 0.117134 0.245301 -0.052540 -0.069448 NaN 1.000000

별로 유의미한 관계는 안보이네.

PV 를 카테고리별로 묶어서 봐보면 좀 압도적으로 차지하는 카테고리가 있을까?

In [194]:
tmp = df_pv.groupby('category_1')['페이지뷰 수'].sum().reset_index()

tmp.iplot('pie', labels='category_1', values='페이지뷰 수')
# tmp.iplot('barh')
In [195]:
tmp = df_pv.groupby('category_2')['페이지뷰 수'].sum().reset_index()

tmp.iplot('pie', labels='category_2', values='페이지뷰 수')