내가 이 정도야

티스토리 백업(Tistory Backup) 개발 리뷰

티스토리 백업

티스토리 백업은 티스토리의 백업기능을 만들어보고자 하는 아이디어가 문뜩 떠올라 단기간에 만든 데스크탑 어플리케이션이다. electron-vue 를 사용하였기에 렌더링을 vue.js 프레임워크를 사용하여 진행하게 된다. 난 윈도우 밖에 사용하지 않아서 Mac OS 전용으로는 배포할 수 없었지만, 타 개발자분의 도움으로 배포할 수 있게 되었다.

 

https://pronist.tistory.com/52

 

티스토리 블로그 백업 프로그램을 만들었습니다.

티스토리 백업 티스토리 블로그의 백업 기능은 이전에 사라졌습니다. 그래서, 직접 만들어보기로 했습니다. 이는 티스토리 Open API 를 사용한 것입니다. 해당 프로그램은 아주 단순하며, 그저 티�

pronist.tistory.com

라이브러리와 프레임워크

해당 어플리케이션에서 사용한 Electron, Vue.js 프레임워크 이외에 라이브러리는 대표적으로 3가지이며 tistory.js, JSZip, SweetAlert2 이다. tistory.js 모듈의 경우는 내가 직접 만든 것이다.

Vuex

일단 상태관리는 Vuex 로 하였다. 이곳에 모든 컴포넌트에서 쓰일 상태를 저장해놓을 예정이다. 여기에는 티스토리 API 에 관한 정보들, 클라이언트 아이디, 시크릿 키, 응답 타입, 콜백 URL 등이 포함되며, 더욱이 중요한 Access Token 을 포함한다.

const state = {
  tistory: {
    clientId: '9d95acf2e27190b1c9d70e7adc88ca6f',
    secret: '9d95acf2e27190b1c9d70e7adc88ca6f569946d18b3cfddb75aa935f963446e1fedb83ab',
    responseType: 'code',
    redirectUri: 'http://localhost:9080/#/'
  },
  accessToken: null
}

const mutations = {
  setAccessToken (state, accessToken) {
    state.accessToken = accessToken
  }
}

const actions = {
  setAccessToken ({ commit }, payload) {
    commit('setAccessToken', payload.accessToken)
  }
}

export default {
  state,
  mutations,
  actions
}

Auth.vue

해당 화면에 대한 템플릿은 아래와 같다. 여기서 살펴봐야 할 부분이 있다면 역시 이벤트 핸들러부분이 아닐까. login(), logout() 이벤트 핸들러를 살펴보자. 로그아웃 버튼은 상태에 Access Token 이 설정되어 있지 않다면 표시되지 않도록 하였다.

<template lang="pug">
  #auth
    nav#nav
      a(href='https://tistory.com' target='_blank')
        img(src='static/images/logo.png')
    main#main
      h1 티스토리 백업
      h2 티스토리 블로그에 담겨있는 소중한 포스트들을 백업해보세요!
      button#tistory-auth(@click='login') 티스토리 인증하기
      button#logout(@click='logout' v-if='this.$store.state.tistory.accessToken') 로그아웃
</template>

login()

아래의 코드를 살펴보자. 티스토리 로그인을 위해 내가 취한 전략은 새로운 창을 열어 로그인을 거치고 올바르게 콜백이 되면 창을 닫고 Access Token 을 얻어오는 것이다. 만약 인증이 정상적으로 완료 되면 Vuex 에 있는 Access Token 을 갱신하고 /extractor 로 이동한다. 라우팅은 vue-router 를 쓴다.

const tistoryWindow = new electron.BrowserWindow(this.windowOptions)
const contents = tistoryWindow.webContents

contents.on('did-finish-load', async () => {
  if (contents.getURL().includes(this.$store.state.tistory.tistory.redirectUri)) {
    tistoryWindow.close()

    const accessToken = await this.getAccessToken(contents.getURL())
    if (accessToken) {
      this.$store.dispatch('setAccessToken', { accessToken })
      return this.$router.push('/extractor')
    }
    return Swal.fire({ icon: 'error', title: '이런!', text: '티스토리 인증에 실패했습니다.' })
  }
})

return tistoryWindow.loadURL(tistory.auth.getPermissionUrl(
  this.$store.state.tistory.tistory.clientId,
  this.$store.state.tistory.tistory.redirectUri,
  this.$store.state.tistory.tistory.responseType
))

getAccessToken()

code 라는 URL 파라매터를 얻기 위한 코드다. 해당 값을 얻고 티스토리 서버에 Access Token 을 요청하면 된다.

const parts = url.split('?')
if (parts.length > 1) {
  const queries = qs.parse(parts[1])
  if (queries.code) {
    const { data } = await tistory.auth.getAccessToken(
      this.$store.state.tistory.tistory.clientId,
      this.$store.state.tistory.tistory.secret,
      this.$store.state.tistory.tistory.redirectUri,
      queries.code
    )
    return data.access_token
  }
}

logout()

로그아웃에서도 로그인과 똑같은 전략으로 윈도우를 하나 더 열되, 이번에는 티스토리 로그아웃 URL 로 연결시킨다. 그렇게되면 알아서 로그아웃 시켜줄 것이다. 물론 이 방법에 대해서는 여러가지 의구심이 들긴한다만, 개발에 정답이 어디에 있는가?

const logoutWindow = new electron.BrowserWindow(this.windowOptions)
logoutWindow.loadURL('https://tistory.com/auth/logout')

const contents = logoutWindow.webContents
contents.on('did-get-redirect-request', (event, oldURL, newURL) => {
  if (newURL === 'https://www.tistory.com/') {
    logoutWindow.close()

    this.$store.dispatch('setAccessToken', { accessToken: null })
  }
})
이제와서 보는거지만, electron 이벤트 핸들러에서 login() 에서는 did-finish-load, 로그아웃에선 did-get-redirect-request 를 사용했다. 둘은 실행시점이 다르다. did-get-redirect-request 는 요청 단에서 발생하기 때문에 did-finish-load 보다 먼저 발생한다.

Extractor.vue

여기서 가장 애먹은 것은 이미지 부분이다. 티스토리 API 에서 현재까지 올바른 이미지 경로를 제공해주지 않아서 강제로 변환해주어야 하는데, 아래에서 같이 살펴보게 될 것이다. 일단, 템플릿 먼저 보자. 아래에서 살펴보면 좋은 부분은 zip() 이벤트 핸들러와 다운받기 버튼이다. 다운받기 버튼은 기본적으로 display:none 처리가 되어있어서 보이지 않고, 백업이 완료되면 나타나게 된다.

<template lang="pug">
  #extractor
    nav#nav(class='uk-navbar-container uk-navbar-transparent' uk-navbar)
      div(class='uk-navbar-left')
        ul(class='uk-navbar-nav')
          li: router-link(to='/' uk-icon='icon: arrow-left; ratio: 1.4')
      div(class='uk-navbar-right')
        ul(class='uk-navbar-nav')
          li: img(:src='userProfile')
    main#main
      h3.warning *글만 백업되며, 페이지와 공지사항은 미포함입니다. (티스토리 오픈 API 미지원)
      table(class='uk-table uk-table-middle uk-table-divider')
        thead
          tr
            th
            th 블로그 정보
            th 주소
            th 대표 블로그
        tbody
          tr(v-for="blog in blogs")
            td: checkbox(class='uk-checkbox' type='checkbox' :val='blog.name' v-model='checkedNames')
            td {{ blog.title }}
            td {{ blog.url }}
            td {{ blog.default }}
      button#extract(ref='extract' @click='zip(checkedNames)') 시작하기
      a#download(href='#' ref='download') 다운받기
      #metainfo
        .url {{ url }}
        .message {{ message }}
        .errors
          .e(v-for='error in errors') {{ error }}
</template>

zip()

여기서는 체크박스에 해당되는 블로그를 얻은 다음 기본적인 .zip 파일을 만들기 위해 폴더를 구성하게 될 것이다. rootFolder = zip.folder(blogName) 이 해당부분이다. 그런 다음, tistory.post.list() 를 사용하여 포스트를 얻어오고 buildZip() 메서드를 통해 컨텐츠를 구성한다. 그리고는 마지막으로 generateZip() 메서드를 통해 사용자가 다운로드 할 수 있는 .zip 파일을 구성하고 링크를 만든다. 

async zip (checkedNames) {
  if (checkedNames.length <= 0) {
    Swal.fire({ icon: 'error', title: '이런!', text: '티스토리 블로그를 백업하려면 블로그 선택해야합니다.' })
    return this.errors.push('티스토리 블로그를 백업하려면 블로그 선택해야합니다.')
  }
  this.isValidAccessToken().then(async () => {
    const zip = new JSZip()

    for (const blogName of checkedNames) {
      const rootFolder = zip.folder(blogName)
      this.message = blogName

      let page = 1

      while (true) {
        const postList = await tistory.post.list(this.$store.state.tistory.accessToken, { blogName, page: page++ }).catch(reason => {
          Swal.fire({ icon: 'error', title: '이런!', text: `https://${blogName}.tistory.com/${page - 1} 에 해당하는 글 목록을 불러올 수 없습니다.` })
          this.errors.push(`https://${blogName}.tistory.com/${page - 1} 에 해당하는 글 목록을 불러올 수 없습니다.`)
        })

        if (postList.data.tistory.item.hasOwnProperty('posts')) {
          await this.buildZip(rootFolder, blogName, postList.data.tistory.item.posts)
        } else break
      }
    }
    this.generateZip(zip)
  })
}
프론트엔드에서 백엔드 없이 .zip 파일을 구성할 수 있다는 사실은 해당 프로젝트를 하면서 처음 알았다. jszip 모듈을 사용해보자.

buildZip()

프로그램을 사용해보았다면 알겠지만, 폴더 구조는 블로그/포스트이다. 따라서 zip() 함수에서 블로그에 대한 폴더를 만들었으니 여기서는 각 포스트에 대한 폴더를 만든다. 그런다음 collectZipContents() 에서 이미지나 글을 수집하는 것이다.

async buildZip (rootFolder, blogName, posts) {
  for (const post of posts) {
    const postDetail = await tistory.post.read(this.$store.state.tistory.accessToken, { blogName, postId: post.id }).catch(reason => {
      Swal.fire({ icon: 'error', title: '이런!', text: `${post.postUrl} 에 해당하는 포스트를 찾을 수 없습니다.` })
      this.errors.push(`${post.postUrl} 에 해당하는 포스트를 찾을 수 없습니다.`)
    })
    const postFolder = rootFolder.folder(post.title)

    this.url = post.postUrl
    this.message = `${blogName}/${post.title}`

    this.collectZipContents(postDetail.data.tistory.item.content, postFolder)
  }
}

collectZipContents()

여기서는 포스트의 내용과 이미지를 수집한다. 수집은 별도로 빼놓았기 때문에 해당 메서드의 코드 자체는 간단하다. 여기서 DOMParser() 를 사용하여 document 로 만들어버리는 이유는 넘겨받는 함수들 쪽에서 DOM API 를 사용하기 때문이다.

async collectZipContents (html, postFolder) {
  const parser = new DOMParser()
  const doc = parser.parseFromString(html, 'text/html')
  const pathname = this.message

  await this.images(doc, postFolder, pathname)
  this.html(doc, postFolder, pathname)
}

images()

이 함수가 핵심이다. 코드를 보면, /kage@(.*)/ 이라는 정규식을 사용하여 필터링을 하고 있는 것을 볼 수 있는데, 이는 티스토리 API 에서 주는 이미지 경로가 부정확하여 없는 주소로 나오기 때문에 변환을 해주기 위함이다. 이건 티스토리 개발팀에서 수정을 해주어야 하는 상황이나, 지금은 그렇지 않기 때문에 일단 그렇게 처리한 상황이다.

 

황당한 것은 그룹 이미지와 같은 것은 , 를 구분자로 하여 이미지 경로가 나뉘어 있다는 것이다. 그래서 sources 변수를 선언하고 초기화할때 split(',') 처리를 해준 것을 알 수 있다.

 

imagesFilename 부분은 변수의 뜻 그대로 이미지의 파일 이름을 만들어주는 부분이다. 그 아래 부분에서 newImg.setAttribute() 부분의 경우, 그룹 이미지와 같이 여러개의 이미지가 쓰인 경우에는 여러번 반복되어 i > 0 일 것이다. 첫 번째의 경우 옵션만 바꾼다 쳐도, 두 번째 부터는 그냥 새로운 이미지를 만들고 추가해주는 형태로 해줬다.

async images (doc, postFolder, pathname) {
  for (const img of doc.querySelectorAll('img')) {
    const sources = img.getAttribute('src').split(',')
    const imgRegex = /kage@(.*)/

    for (let i = 0; i < sources.length; i++) {
      if (imgRegex.test(sources[i])) {
        const imageSourceName = imgRegex.exec(sources[i])[1]

        const response = await fetch(`https://blog.kakaocdn.net/dn/${imageSourceName}`)
        switch (response.status) {
          case 404: {
            this.errors.push(`https://blog.kakaocdn.net/dn/${imageSourceName} 를 가져오는데 실패했습니다.`)
          }
        }

        let imageFilename = imageSourceName.replace(/\//g, '_')
        imageFilename = imageFilename.substring(0, imageFilename.length - 8)
        imageFilename = imageFilename + '.' + response.headers.get('Content-Type').substring(6)

        postFolder.file(imageFilename, response.blob())

        if (i > 0) {
          const newImg = doc.createElement('img')
          newImg.setAttribute('src', './' + imageFilename)
          img.parentNode.appendChild(newImg)
        } else {
          img.setAttribute('src', './' + imageFilename)
        }

        this.message = `${pathname}/${imageFilename}`
      }
    }
  }
}

html()

해당 함수는 넘겨받은 document 객체를 다시 HTML 스트링으로 만들고 index.html 이라는 이름으로 저장한다. 넘겨받은 html 을 그냥 주어도 괜찮았을듯 싶지만, 티스토리 API 에서 받는 데이터가 깨진 경우가 제법 많아서 말이다. 한 번 더 작업하게 되었다.

html (doc, postFolder, pathname) {
  postFolder.file('index.html', new XMLSerializer().serializeToString(doc))
  this.message = `${pathname}/index.html`
}

generateZip()

이제 유저에게 다운로드할 수 있는 .zip 파일의 경로를 만들어주면 된다.

generateZip (zip) {
  zip.generateAsync({ type: 'blob' }).then(blob => {
    const file = new Blob([ blob ], { type: 'application/zip' })
    const fileURL = URL.createObjectURL(file)

    this.$refs.extract.style.display = 'none'

    this.$refs.download.setAttribute('href', fileURL)
    this.$refs.download.setAttribute('download', moment().format('X_YYYY_MM_DD'))
    this.$refs.download.style.display = 'inline-block'

    Swal.fire({ icon: 'success', title: '백업 성공!', text: '티스토리 블로그의 백업이 완료되었습니다. 다운로드 버튼을 눌러 결과를 확인해보세요.' })
  })
}

마치며

티스토리 개발팀에서 이미지 경로나 패치해줬으면 좋겠다. 언제까지 저렇게 방치해두려고! 그 외에 이 어플리케이션을 만들면서 어려웠던 점은 아무래도 언어가 자바스크립트이기 때문에 실행 문맥을 맞추기가 쉽지 않았다는 것이다. .zip 에 아이템을 넣어야하는데 문맥이 달라서 아이템이 들어가지 않는다거나 빈 텍스트가 들어갔다거나 그런 경우가 생겼기 때문이다.