티스토리 스킨을 원격으로 조작할 수 있다? 티스토리 스킨 API 만들기
포트폴리오

티스토리 스킨을 원격으로 조작할 수 있다? 티스토리 스킨 API 만들기

[티도리 프레임워크 개발 - 2부]

 

티도리를 만들때 또 한 가지 주목해야 했던 점은, 티스토리 스킨 API 를 뜯어보는 일이었다. 치환자는 어떤 경로를 통해 치환되며 사용자에게 어떻게 미리보기를 보여주는가와 같은 것이며 티스토리 스킨 그 자체보다는 티스토리 플랫폼을 이해한다고 보면 될 것 같다. 이는 구현의 관점이 아니라, API(Application Programming Interface)의 관점에서 티스토리 서버와 브라우저가 어떻게 소통하는지만 대략적으로 분석하면 되는 것이다. 이것을 구현하게 되면 프로그래머블하게 티스토리 스킨을 원격지에서 조작할 수 있다.

 

자, 제일 먼저 해야할 것은 스킨 편집으로 들어가 개발자 도구를 열고 네트워크 탭을 활성화하는 일이다.

 

한 가지 알아두어야 할 점은, 티도리 프레임워크를 사용해서 나오는 결과물은 티스토리 스킨에서 사용하는 치환자가 포함된 스킨 코드다. 따라서 스킨 코드를 티스토리 서버가 어떻게 해석하고 치환자를 파악하여 각 섹션에 해당되는 코드를 우리에게 보여주는지까지는 알아둘 필요가 없다.

 

github.com/tistory-project/tistory-skin

 

tistory-project/tistory-skin

Tistory Skin API for Javascript -- Unofficial. Contribute to tistory-project/tistory-skin development by creating an account on GitHub.

github.com

이 라이브러리는 티도리 프레임워크에서 필수적으로 사용되지만, 라이브러리 자체는 독립적이므로 어디에서든 포함하여 사용할 수 있다. 따라서 해당 레포는 티도리 프레임워크 그룹이 아니라, 내 개인 계정에 담겨있다.

티스토리 서버와 소통하기

current.json

이 파일은 티스토리 서버에서 현재 티스토리 스킨의 상태를 얻어오는 친구다. 여기에 스킨의 이름과 홈 커버 여부, 변수 설정, index.xml 파일에 우리가 기재하는 스킨 설정 등이 포함되어 있다.

 

preview.php

이 친구는 프리뷰를 보여주는 친구다. 실제로 티도리 프레임워크에서 프리뷰를 보여줄 때 이녀석한테 요청을 보내게된다. Form Data 를 주목해보자. 티도리로 개발을 해봤다면 tidory.config.js 에 대해서 알 것이다. preview 항목에 포함되는 요소는 이 녀석이 보내는 파라매터와 같다.

 

반환 값도 살펴볼 필요가 있는데, 반환을 하는 것은 바로 치환자가 해석된 html 응답이다! 이것이 티도리 프레임워크에서 프리뷰 서버를 이용할 수 있는 이유다.

 

html.json

이 녀석이 제일 중요하다. 스킨 편집 버튼을 누르면 먼저 요청된다. 이는 스킨을 준비하는 과정이라고 생각하면 된다. 스킨을 편집하기 전에 반드시 요청해야 동작한다. 처음 요청할 때는 GET, 그 외에는 POST 요청을 보낼 때가 있는데, 바로 치환자가 포함된 html, css 코드를 이 녀석에게 요청한다. previewSkin.php 에 요구되는 skin 이름은 여기서 얻어온다.

 

프리뷰를 위한 html, css, 또는 스킨에 직접 코드를 적용하기 위해 해당 파일을 사용하여 POST 요청을 보낸다. Request Payload 부분을 주목하자. isPreview  true 이면 프리뷰 모드이고, false 이면 배포 모드인 것이다.

 

또한 위의 쿠키에서 TSSESSION 을 주목하자. 티스토리 플랫폼이 아닌, 외부에서 요청을 보내려면 반드시 해당 세션값이 필요하다. 그래서 tidory.config.js 에서 세션 값을 요구하는 것이라고 볼 수 있다.

Tistory Skin API

RemoteController

자 이제 코드를 알아볼 시간이다. 제공하는 API 는 제법 많지만, 핵심적인 것들만 알아보도록 하자. 프리뷰, 배포와 같은 것들 말이다. 가장 기본이 되는 RemoteController.request() 메서드를 먼저보자. 기본적으로 해당 프로젝트에서는 티스토리 스킨, 스킨 저장소를 조작할 수 있으므로 두 주제에 해당하는 클래스는 RemoteController 의 자식이다.

const axios = require('axios')

class RemoteController {
  /**
   * Create TistorySkin Instance
   *
   * @param {string} BLOG_URL
   * @param {string} TSSESSION
   */
  constructor (BLOG_URL, TSSESSION) { /* ... */ }

  async request (method, url, config) {
    const axiosInstance = axios.create({
      baseURL: this.BLOG_URL,
      headers: {
        'Content-Type': 'application/json',
        Referer: this.BLOG_URL,
        Cookie: 'TSSESSION=' + this.TSSESSION
      }
    })
    const response = await axiosInstance.request(
      Object.assign({
        method,
        url
      }, config)
    ).catch(err => console.error(err))

    return response.data
  }
}

module.exports = RemoteController

axios 를 사용하여 HTTP Request 를 쏘는 것이 기본 코드이며 요청한 응답을 리턴해주는 것이 전부다. 여기서 Referer, Cookie 는 필수로 설정해주어야 하는데, TSSESSION 부분은 특히나 중요한 부분이라고 볼 수 있다. 티도리 프레임워크에서 세션 값을 직접 받아야하는 이유가 바로 이것 때문이다. 세션 값이 없으면 스킨을 조작할 수 없다. 이는 비공식 라이브러리이기 때문이다.

Storage

Storage 클래스는 볼게 많이 없어서 메서드를 상당수 생략했는데, 볼만한 것은 Storage.store() 메서드 정도뿐이다. 해당 메서드는 스킨 저장소에 스킨을 저장한다. 파라매터로는 배포 디렉토리의 경로와 저장할 스킨의 이름이다. Storage.upload() 메서드는 스킨 저장소에 파일을 업로드한다. TistorySkin.upload() 와 헷갈려서는 안 된다. 또한 Storage.save() 메서드는 말 그대로 저장소에 저장을 해준다.

const FormData = require('form-data')
const fs = require('fs')
const walk = require('walk')

const RemoteController = require('./RemoteController')

class Storage extends RemoteController {
  /**
   * Store a Tistory Skin on Skin Storage
   *
   * @param {string} dist
   * @param {string} skinname
   */
  store (dist, skinname) {
    const walker = walk.walk(dist, { followLinks: false })

    walker.on('file', async (root, stat, next) => {
      await this.upload(root + '/' + stat.name)
      next()
    })
    walker.on('end', async () => {
      await this.save(skinname)
    })
  }

  // Storage Methods...
}

module.exports = Storage

이 함수는 tidory 에서 다음과 같이 사용된다. deploy 절차와 마찬가지로 저장소의 디렉토리를 비우는데, 이 경우에는 스킨 저장소를 타겟으로 한다. 그래서 호출이 skin.storage.clear() 의 형태를 띄고 있다.

/**
 * -> tidory store
 */
tidory
  .command('store')
  .description('Store tistory skin on skin storage')
  .action(async () => {
    await skin.storage.clear()
    await skin.storage.store(dist, tidoryConfig.skinname)
  })

TistorySkin

TistorySkin 클래스는 Storage 객체를 가지고 있다. 밖으로 내보내는 클래스가 오직 TistorySkin 클래스이기 때문에 Storage 클래스는 외부에서 접근할 일이 없도록 해놓았다. 그래서 내부에 객체를 생성해놓은 것이다. RemoteController 의 자식이기 때문에 부모 생성자에서 요구하는 생성자 파라매터를 받고 넘겨준다. 핵심은 역시 TSSESSION 이다.

const url = require('url')
const FormData = require('form-data')
const fs = require('fs')
const walk = require('walk')
const path = require('path')

const Storage = require('./Storage')
const RemoteController = require('./RemoteController')

/**
 * Q. How to Check Response properties?
 * A. Reference Your skin edit page with developer tools
 */
class TistorySkin extends RemoteController {
  /**
   * Create TistorySkin Instance
   *
   * @param {string} BLOG_URL
   * @param {string} TSSESSION
   */
  constructor (BLOG_URL, TSSESSION) {
    super(BLOG_URL, TSSESSION)

    /**
     * Tistory Skin Storage
     */
    this.storage = new Storage(BLOG_URL, TSSESSION)
  }
  
  // TistorySkin Methods...
}

module.exports = TistorySkin

TistorySkin.preview()

TistorySkin.settings() 메서드는 current.json 에 요청하여 현재 스킨의 설정 값을 얻어온다. 파라매터로 받아오는 값은 tidoryConfig.preview 이다. tidory.config.js 에 설정한 preview 에 해당하는 값을 넣는다. 1부에서 언급한 html() 에서 그 모습을 확인할 수 있다.

/** Tistory Skin */
const skin = new TistorySkin(tidoryConfig.url, tidoryConfig.ts_session)

await skin.prepare()
await skin.upload(path.join(process.cwd(), tidoryConfig.path.docs, 'index.xml'))
await skin.change(pretty(he.decode($.html()), { ocd: true }), css, true)

/**
 * FOR PREVIEW
 *
 * Replace TISTORY CDN PATH to local for preview
 *
 * <img src="https://tistory1.daumcdn.net/tistory/2710108/skin/images/logo.png" /> -> <img src="images/logo.png" />
 */
return (await skin.preview(tidoryConfig.preview)).replace(
  /(src|href)=["']https?:\/\/tistory[0-9]{1}.daumcdn.net\/tistory\/[0-9]*\/skin\/(images\/.*?)["']/gim,
  '$1="$2"'
)

previewSkin.php 에 값을 보낼 때는 Form Data 형식으로 넣어야하기 때문에 일부러 저렇게 사용했다. 티도리 공식문서에서 tidory.config.js 에 들어갈 수 있는 필드로 skinSettings 는 언급하지 않았는데, 큰 의미는 없기 때문이다. 물론 그래도 시뮬레이션은 할 수 있도록 넣을 수 있다.  

/**
 * Get preview
 *
 * **mode** is able to be \
 * 'index', 'entry', 'category', 'tag', 'location', 'media', 'guestbook'
 *
 * @param {string} mode
 *
 * @return {string} HTML
 */
async preview (settings) {
  const stat = await this.settings()
  const formData = new FormData()

  formData.append('skin', stat.skin.name)

  formData.append('mode', settings.mode)
  formData.append('homeType', settings.homeType || stat.home.type)
  formData.append('coverSettings', JSON.stringify(Object.assign(stat.home.cover.settings, settings.coverSettings)))
  formData.append('variableSettings', JSON.stringify(Object.assign(stat.variableSettings, settings.variableSettings)))
  formData.append('skinSettings', JSON.stringify(Object.assign(stat.skinSettings, settings.skinSettings)))

  return this.request('post', '/manage/previewSkin.php', {
    data: formData,
    headers: formData.getHeaders()
  })
}

TistorySkin.settings() 메서드로 얻어온 스킨 기본설정을 기본 값으로 사용할 수 있어서, 만약에 tidory.config.js 에 해당 값이 없는 경우에는 스킨 기본설정으로 설정하여 사용하도록 한다.

TistorySkin.deploy()

TistorySkin.deploy() 를 할 때 주의할 점은 skin.html, style.css 파일은 제외시켜야 한다는 점이다. 왜냐하면 해당 파일들은 이미 TistorySkin.change() 메서드를 통해 전송하므로 굳이 포함시킬 이유가 없다. 이 함수는 티스토리 서버에 변경사항을 제출하고 tidory.config.js 에 설정된 티스토리 스킨에 코드를 즉시 적용시킨다. 

 

여기서 업로드 하기전에 TistorySkin.prepare() 를 호출하여 스킨을 준비시키고, TistorySkin.change() 를 호출하여 skin.html, style.css 를 업로드 함을 보면 된다. walk 모듈은 디렉토리를 돌면서 TistorySkin.upload() 를 호출하여 개별 파일을 업로드한다.

/**
 * Deploy Tistory Skin
 *
 * @param {string} dist
 */
async deploy (dist) {
  const guards = { skin: 'skin.html', css: 'style.css' }

  Object.keys(guards).forEach(key => {
    const content = fs.readFileSync(path.join(dist, guards[key]), 'utf8')
    guards[key] = content
  })

  await this.prepare()
  await this.change(guards.skin, guards.css, false)

  const walker = walk.walk(dist, { followLinks: false })

  walker.on('file', async (root, stat, next) => {
    if (!Object.values(guards).includes(stat.name)) {
      await this.upload(root + '/' + stat.name)
    }
    next()
  })
}

파라매터로 넣는 값은 dist 폴더의 경로다. 1부에서 언급한 tidory 에서는 다음과 같이 확인할 수 있다. skin.clear() 를 호출하여 현재 업로드된 스킨 리소스를 날리고, skin.deploy() 를 호출하여 파일을 업로드한다.

// ./dist
const dist = path.join(wd, tidoryConfig.path.dist)

/**
 * -> tidory deploy
 */
tidory
  .command('deploy')
  .description('Deploy tistory skin')
  .action(async () => {
    await skin.clear()
    await skin.deploy(dist)
  })

마치며

기존에 없던 것을 새롭게 만든다는 것은 수 많은 고민을 하게 만들었다. 비록 남들이 만들어 놓은 것들을 결합하고 응용하는 것 뿐이라곤 해도, 어떠한 것을 분석하고 그것에 맞게 새롭게 환경을 구성한다는 일은 결코 쉬운 일이 아니었다. 단순히 파일을 나눠서 개발해보면 어떨까라고 생각한 프로젝트가 이렇게까지 될 줄은 정말 몰랐다.

 

결론적으로는 티스토리 개발팀에게는 괜찮은 영감을 준것으로 생각하고 나라는 존재에 대해 좋은 이미지를 심어주었다는 생각은 든다. 다른 기업에 포트폴리오로 제출할 때는 조금 애매한 감도 있긴 하다 :(

 

하지만, 누군가 해당 프로그램을 좋아해주고 칭찬해주며 좋은 반응을 이끌어 낸것만으로도 나처럼 경험이 많이 없는 개발자에게 있어서는 성공한 것이라고 생각한다. 티도리 프레임워크로 벌어들인 돈은 빵원이지만, 돈을 목적으로 시작한 것도 아니었으니 괜찮다. 개발자로서 의미있는 성공을 하게 되었으니 그것만으로 만족한다. 사실 티도리로 만든 티스토리 스킨이 꽤 잘나가고 있어서 기쁜게 제일 크지만!

더 읽을거리

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

티스토리 스킨 프레임워크, 티도리는 어떻게 동작할까?

티스토리 구독 서비스 이전에 존재했던, 티스토리 이웃서비스 티네스(Tines) 개발 돌아보기

hELLO. 티스토리 스킨을 소개합니다.

내가 개발한 티스토리 프로젝트 정리!