Help us understand the problem. What is going on with this article?

Qiitaの記事をローカルで書く環境を作るゾ!

こんにちは!
友人(@shigi-p)くんがQiita始めたので便乗して初めてみました!!

初投稿なので, Qiitaの記事投稿周りをローカルでしたいなーみたいなことをします!

作りたいもの

記事をGithubのリポジトリで管理して更新と同時に, ローカルの記事の状態がQiitaへ自動的に反映(新規記事投稿, 既存記事の編集, 既存記事の削除)されるような仕組みを作りたい.

動機

  • Hugo+Netlifyで運営しているブログがあり, エディタで編集してGithubに投げて記事を更新できるフローが快適で, ぜひQiitaも同様のフローで記事を書きたい
  • 記事ファイルを管理化に置きたい

どう実装するか

QiitaはAPIを提供していて, そこから記事の投稿や編集等ができるのであとは気合いでなんとかできるはず!

参考

新規投稿と削除

記事投稿自体は先人様がいらっしゃったので,
Qiita API を利用して記事を投稿する
を参考に投稿部分は丸パクリさせてもらえばたぶんいけそう

あとは, APIから記事一覧を取得して,

  • ローカルには存在するが, Qiitaには存在しない記事 -> POST
  • ローカルには存在しないが, Qiitaには存在する記事 -> DELETE

をすれば良さそう.

編集

こっちは少し悩んだけどとりあえず, リモートのリポジトリを編集があったファイルの比較用に使えばいけそう.

  1. ローカルで記事用のディレクトリをコミット
  2. fetchでリモートの最新版を持ってくる
  3. origin/masterとmasterのdiffを取れば更新があったファイル郡がわかる

と言った感じ.

あとは, 更新されているにも関わらず新規投稿も削除もも行われなかった記事に対してPATCHをすれば編集も反映できるはず.


指針は建ったのでとりあえず実装に移りま!

投稿・編集・削除の実装

先にも述べたように,
Qiita API を利用して記事を投稿する
のコードをベースに実装.

感謝\(^o^)/

main.py

import re
import toml
from config import API_ENDPOINTS


class Post:
    def __init__(self, path):
        self.path = path
        self.load_file()

    def __str__(self):
        return str(self.header)

    def load_file(self):
        with open(self.path) as f:
            buf = f.read()

        header = re.match(
            r'^\+\+\+$.+?^\+\+\+$',
            buf,
            flags=(re.MULTILINE | re.DOTALL))
        body = buf[header.end() + 1:]
        header = toml.loads(
            buf[header.start() + 4:header.end() - 4]
            )

        self.item = {
            'title': header['title'],
            'private': header['draft'],
            'tags': [{'name': tag} for tag in header['tags']],
            'coediting': header['qiita']['coediting'],
            'gist': header['qiita']['coediting'],
            'tweet': header['qiita']['tweet']
        }

        if header['qiita']['id'] != '':
            self.item['id'] = header['qiita']['id']

        self.item['body'] = body
        self.header = header
        self.body = body

    def _add_qiita_id(self, res):
        self.header['qiita']['id'] = res.json()['id']
        data = '+++\n{}+++\n{}'.format(toml.dumps(self.header), self.body)

        with open(self.path, mode='w') as f:
            f.write(data)

    def post(self):
        if 'id' not in self.item.keys():
            res = API_ENDPOINTS['new_post'](json=self.item)
            self._add_qiita_id(res)

    def patch(self):
        return API_ENDPOINTS['update_post'](id=self.item['id'], json=self.item)

config.py

import os
from functools import partial
from requests import get, patch, post, delete

# base config
TOKEN = 'Qiita API用トークン'  # パブリックリポジトリにしたいならos.getenv()で設定

# path
BASE_DIR = os.getcwd()
POSTS_DIR = os.path.join(BASE_DIR, 'posts')

# api endpoint
BASE_URL = 'https://qiita.com/api/v2'
headers = {'Authorization': 'Bearer {}'.format(TOKEN)}


def update_post(id, *ar, **kw):
    return patch(f"{BASE_URL}/items/{id}", headers=headers, *ar, **kw)


def delete_post(id, *ar, **kw):
    return delete(f"{BASE_URL}/items/{id}", headers=headers, *ar, **kw)


API_ENDPOINTS = {
    'new_post': partial(post, BASE_URL + '/items', headers=headers),
    'update_post': update_post,
    'delete_post': delete_post,
    'post_list': partial(get,
                         BASE_URL + f'/authenticated_user/items',
                         headers=headers)
}

編集と削除に関しては投稿と違ってAPI叩けばいいだけなので, 投稿のときに使った処理一部流用しつつさくっと実装.

ローカル・Qiita・リポジトリの同期

とりあえず, リモートとローカルのdiffを取るスクリプトを書く.

diff.sh

#!/bin/sh
git add . &>/dev/null
git commit -m "auto post" &>/dev/null
git fetch &>/dev/null
git diff origin/master --name-only | grep "posts/" | grep -v "template"

記事投稿のテンプレート用ファイルを用意したかったので, それを省きつつpostsディレクトリ下の更新があったファイルを取得できる

あとは実行用スクリプトを書いていく.

manage.py

from config import API_ENDPOINTS, POSTS_DIR
from post import Post
import os
import subprocess
from config import BASE_DIR

posts = []

# local
for path in os.listdir(POSTS_DIR):
    post = Post(os.path.join(POSTS_DIR, path))
    if 'id' in post.item.keys() and post.item['id'] == 'template':
        continue
    else:
        posts.append({
            'local': True,
            'qiita': False,
            'diff': False,
            'content': post
        })

# qiita
for q_post in API_ENDPOINTS['post_list']().json():
    flag = False
    for post in posts:
        if 'id' in post.keys():
            continue
        elif 'id' not in post['content'].header['qiita'].keys():
            continue
        elif post['content'].header['qiita']['id'] == q_post['id']:
            flag = True
            post['qiita'] = True
            break
    if not flag:  # delete
        posts.append({
            'local': False,
            'qiita': True,
            'diff': False,
            'id': q_post['id']
        })

# diff
proc = subprocess.run(
    ['./diff.sh', ],
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE,
    cwd=BASE_DIR
).stdout.decode("utf8").replace(' ', '')[:-1]

for path in proc.split('\n'):
    if os.path.exists(os.path.join(BASE_DIR, path)):
        for post in posts:
            if post['content'].path == os.path.join(BASE_DIR, path):
                post['diff'] = True
                break

# Process
for post in posts:
    if post['local'] and not post['qiita']:
        # 新規作成
        post['content'].post()
        print(f"NEW: {post['content'].header['qiita']['id']}")
    elif not post['local'] and post['qiita']:
        # 削除
        print(f"DELETE: {post['id']}")
        API_ENDPOINTS['delete_post'](id=post['id'])
    elif post['diff']:
        # 更新
        print(f"UPDATE: {post['content'].header['qiita']['id']}")
        post['content'].patch()

やってることは,

  1. localには存在する & qiitaに存在しない -> 新規投稿
  2. localに存在しない & qiitaに存在しない -> 削除
  3. localにもqiitaにも存在するがdiffによって更新が見つかる -> 修正

だけ.

これで一通り完成.

あとはちょっと使いやすくするために,

テンプレートファイルを作成しつつ(こちらも参考記事さんのをそのまま使わせていただいた),

template.md

+++
title = "ここにタイトルを記入"
draft = true
tags = ["test", ]

[qiita]
coediting = false
gist = false
tweet = false
id = "template"
+++

Template Content!

いちいちテンプレートをコピーしたりするのは手間なので, 記事の操作に関するコマンドを予め作成しておく.

.bashrc

function qedit() {
  printf "input file name(or n) >> ";read FILE
  if [ "${FILE}" != "n" ]; then
    qnew ${FILE}
  fi
  code ~/Blogs/Qiita
}

function qpost() {
  cd ~/Blogs/Qiita
  python manage.py
  git add . && git commit -m "auto post"
  git push origin HEAD
}

function qnew() {
  sed "s/template//g" ~/Blogs/Qiita/posts/template.md > ~/Blogs/Qiita/posts/$1
}

# ⇓ publicでやりたいなら ⇓
# export QIITA_TOKEN='Qiita API用TOKEN'

これで, シェルから

$ qedit
input file name(or n) >> test_article.md
# エディタが開く
# 編集する
$ qpost  # Qiitaとリポジトリに反映

みたいにできるようになった!

僕はAtomのが好きだけど,

  • 他のプロジェクトを開いていても別タブ(?)でプロジェクトを開かれる
  • 記事書くくらいなら軽いエディタのが心地よい

って感じがするので, こういう用途ではVSCodeを積極的に使ってます.

Atomとかその他のエディタに置き換えてしまっても問題ないはず!

新規投稿はもちろん, 記事削除と記事の更新も問題なくできてました!わーい!

課題

とりあえずローカルに合わせる感じになっているので,

  • Qiitaで新規作成した記事はローカルに存在しないので削除判定で全部消える
  • ローカルで記事作成 -> Qiita側で編集 -> ローカルで別の編集をするとQiitaの編集が上書きされてしまう

ことが明らかに問題だからその辺をなんとかしたい.

とりあえず, 一応作ったリポジトリを貼りつつこの辺で終わります!

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away