小村のポートフォリオサイト開発(11) DjangoRestFramework はてなブログの記事のデータ格納(3)

  • こんばんは、小村だよ!

  • 下記のポートフォリオサイトを構築していくよ

  • 前回ひとまずはてなブログ記事をひとまずDBに書き込むことに成功しました

  • 今回はそれを実働レベルに持っていきます!



目次

  1. 管理メニューのタイトル表示を修正
  2. はてな記事全件取得するまで回す



記録

管理メニューのタイトル表示を修正

  • 前回、はてな記事を自前のDBに格納することができました

  • それを管理ページから確認した結果が下記

f:id:kom314_prog:20210821151236p:plain


  • なにがなんだかわからないね!

  • というわけで管理画面表記を変更します

  • admin.pyを下記の通り変更

from django.contrib import admin

from .models import Entry


@admin.register(Entry)
class Entry(admin.ModelAdmin):
    list_display = (
        'entry_id',
        'hatena_entry_id',
        'title',
        'updated_at',
        'edited_at',
    )


  • はてなブログAPIで取得できる項目が、

    • edited:更新日時
    • updated:公開日時
  • という謎な仕様が判明したので合わせてmodelも変更してますが割愛

  • 最終的な管理画面が下記。見やすくなったね!

f:id:kom314_prog:20210821161712p:plain



はてな記事全件取得するまで回す

  • はてな記事API~~/entryにアクセスすると10件分の記事を取得します

  • 次の10件は、下図のnextにあるリンクにより次のページ分が見れます

f:id:kom314_prog:20210821171215p:plain


  • このURLが存在する間、ぐるぐる回るようにコードを修正します。

  • そして間違えて永久に(2分ぐらい)APIを呼び続けてしまった/(^q^)\

  • 垢BANされないか心配だ!

  • そんなわけで、今後に備えて念のためAPIを呼ぶ感覚に0.5秒の感覚を設けました

  • 修正箇所がこちら

f:id:kom314_prog:20210821172530p:plain
f:id:kom314_prog:20210821172559p:plain


  • これにより下記の通り過去投稿した全記事を格納できました!

  • 振られるIDが投稿日時の降順なの気になるな……後日修正しよう

f:id:kom314_prog:20210821172835p:plain



おわりに

  • 記事の取得処理はもう大詰め!

  • とはいえ今のままだとすべての処理をViewsに書いてるから汚い汚い

  • リファクタリングちゃんとしなくちゃね!

  • ではでは今日はこの辺で!ちゃお~~~!



小村のポートフォリオサイト開発(10) DjangoRestFramework はてなブログの記事のデータ格納(2)



目次

  1. はてな記事をentryに書き込み
  2. 出たエラーとその対策



記録

はてな記事をentryに書き込み

  • 前回、はてなブログAPIで取得してたデータを、格納する形に変換してたんだよね

  • 引き続きもくもくとコーディングして下記になりました!

  • はてなブログのIDが一致するものがあれば更新をかけて、なければ新規作成します

# coding: utf-8

import os
import requests
import xmltodict

from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.response import Response

from ..models import Entry
from ..serializer import EntryAllSerializer, EntryCreateAndUpdateSerializer


class EntryViewSet(viewsets.ModelViewSet):
    queryset = Entry.objects.all()
    serializer_class = EntryAllSerializer

    @action(detail=False, methods=['post'])
    def capture(self, request):
        url = self.getHatenaApiUrl('entry')
        auth = self.getHatenaApiAuth()
        hatena_list = requests.get(url, auth=auth)
        dictData = xmltodict.parse(hatena_list.text, encoding='utf-8')
        entries = dictData['feed']['entry']
        for entry in entries:
            if not isinstance(entry, dict):
                continue

            # hatena_entry_id取得
            hatena_entry_id = entry['id'][
                entry['id'].rfind('-') + 1:] if 'id' in entry else ''

            # category取得
            if 'category' in entry:
                if isinstance(entry['category'], list):
                    category = entry['category'][0]['@term']
                else:
                    category = entry['category']['@term']
            else:
                category = ''

            # title取得
            title = entry['title'] if 'title' in entry else ''

            # summary取得
            summary = entry['summary']['#text'] if 'summary' in entry else ''

            # content_md取得
            content_md = entry['content']['#text'] if 'content' in entry else ''

            # content_html取得
            content_html = entry['hatena:formatted-content']['#text'] if 'hatena:formatted-content' in entry else ''

            # draft取得
            draft = entry['app:control']['app:draft'] if 'app:control' in entry else ''

            # published_at取得
            published_at = entry['published'] if 'published' in entry else None

            # edited_at取得
            edited_at = entry['app:edited'] if 'app:edited' in entry else None

            # updated_at取得
            updated_at = entry['updated'] if 'updated' in entry else None

            # 更新用パラメータ
            param = {
                'hatena_entry_id': hatena_entry_id,
                'category': category,
                'title': title,
                'summary': summary,
                'content_md': content_md,
                'content_html': content_html,
                'draft': draft,
                'published_at': published_at,
                'edited_at': edited_at,
                'updated_at': updated_at,
            }

            entry = Entry.objects.filter(
                hatena_entry_id=hatena_entry_id).first()

            if not entry:
                # 新規作成
                serializer = EntryCreateAndUpdateSerializer(data=param)
            else:
                # 更新
                serializer = EntryCreateAndUpdateSerializer(entry, data=param)

            if serializer.is_valid():
                serializer.save()
                print('valid-OK')
            else:
                print('valid-NG')

        return Response(entries)

    def getHatenaApiUrl(self, action):
        HATENA_API_URL_HEADER = 'https://blog.hatena.ne.jp'
        HATENA_API_USER = os.environ.get('HATENA_API_USER')
        HATENA_API_BLOG = os.environ.get('HATENA_API_BLOG')
        HATENA_API_URL_FUTTER = 'atom'

        url = [
            HATENA_API_URL_HEADER,
            HATENA_API_USER,
            HATENA_API_BLOG,
            HATENA_API_URL_FUTTER,
            action
        ]
        return os.path.join(*url)

    def getHatenaApiAuth(self):
        HATENA_API_USER = os.environ.get('HATENA_API_USER')
        HATENA_API_KEY = os.environ.get('HATENA_API_KEY')

        return (HATENA_API_USER, HATENA_API_KEY)
  • serializerは下記
# coding: utf-8

from rest_framework import serializers

from ..models import Entry


class EntryAllSerializer(serializers.ModelSerializer):
    class Meta:
        model = Entry
        fields = '__all__'


class EntryCreateAndUpdateSerializer(serializers.ModelSerializer):
    class Meta:
        model = Entry
        fields = (
            'entry_id',
            'hatena_entry_id',
            'title',
            'summary',
            'content_md',
            'content_html',
            'draft',
            'published_at',
            'edited_at',
            'updated_at',
        )



f:id:kom314_prog:20210821151236p:plain
  • entryモデルに10件のレコードが登録されることが確認できました。

  • 再度実行して更新されることも確認。ひゃっほ!



出たエラーとその対策

  • やっていく中でエラーとの格闘が勃発したのでそのメモ


TypeError: 'in <string>' requires string as left operand, not collections.OrderedDict

  • 下記のコードが原因で発生。辞書型のentrycategoryというkeyがあるかを確認しようとしている

  • しむらー!ぎゃくぎゃく!!!

            if entry in 'category':
  • 正解は下記
            if 'category' in entry:



おわりに

  • APIの第一歩がようやく踏み出せた感ありますね!

  • この先やっていくこととして

    • はてな記事全件取得するまで回す
    • content_mdを正しく取得
    • 記事の削除処理
    • リファクタリング
    • デプロイ
    • 毎日0:00に自動キック
  • あたりの作業がまっております。がんばるぞい!

  • ではでは。ちゃお~~~!



小村のポートフォリオサイト開発(9) DjangoRestFramework はてなブログの記事のデータ格納



目次

  1. 不要なmodel削除
  2. models作成
  3. views.blog.pyの内容をviews.entry.pyに移植
  4. はてな記事をentryに書き込み



記録

運用ルールの決定

  • APIで取得したデータを眺めながら、いくつか運用ルールを決めました

  • 今後の記事を書く際に気を付けたいと思います

    • 記事タイトルは最大100文字

      • それ以上の場合は切って格納
    • カテゴリは1記事につき1つ

      • 複数ある場合は最初の1つのみDBに格納



不要なmodels削除

  • これから本格的にmodelを用意するので、これまでテストで使用していたmodelを用済みです!

  • さくっと削除しましょう!

  • 下記の通り、Userモデルを削除し、紐づいていた処理を全て削除しました!

f:id:kom314_prog:20210817225300p:plain



models作成

  • ではでははてなブログの記事を格納するモデルを作っていくよ!

  • 一般的にこういった記事はEntryという名前が多いのでそれに習います

  • もともと存在していたしていたEntryモデルを下記の通り修正

from django.db import models

class Entry(models.Model):
    CHOICE_DRAFT = (('yes', '下書き'), ('no', '公開'))
    entry_id = models.AutoField(verbose_name='記事Id', primary_key=True)
    hatena_entry_id = models.BigIntegerField(verbose_name='はてな記事Id', unique=True)
    category = models.CharField(verbose_name='カテゴリ', max_length=100, null=True, blank=True, default='')
    title = models.CharField(verbose_name='タイトル', max_length=200, null=True, blank=True, default='')
    summary = models.CharField(verbose_name='サムネイル文', max_length=1000, null=True, blank=True, default='')
    content_md = models.TextField(verbose_name='内容_MarkDown', null=True, blank=True, default='')
    content_html = models.TextField(verbose_name='内容_HTML', null=True, blank=True, default='')
    draft = models.CharField(verbose_name='下書き区分', max_length=10, choices=CHOICE_DRAFT, null=True, blank=True, default='yes')
    published_at = models.DateTimeField(verbose_name='公開日時', null=True, blank=True)
    edited_at = models.DateTimeField(verbose_name='作成日時', null=True, blank=True)
    updated_at = models.DateTimeField(verbose_name='更新日時', null=True, blank=True)

  class Meta:
        db_table = 'entry'


  • それからserializers/entry.pyも下記の通り修正
# coding: utf-8

from rest_framework import serializers

from ..models import Entry


class EntrySerializer(serializers.ModelSerializer):
    class Meta:
        model = Entry
        fields = '__all__'


f:id:kom314_prog:20210817225611p:plain



views.blog.pyの内容をviews.entry.pyに移植

  • はてなブログAPIを呼び出すviews.blog.pyの処理をentry.pyに移植します

  • そして用済みになったviews.blog.pyはおさらば!!!

  • だいぶ構成がすっきりしました!

f:id:kom314_prog:20210817230414p:plain
f:id:kom314_prog:20210817230517p:plain



はてな記事をentry用に加工

  • メインの処理!

  • 取得したはてな記事をEntryモデル用に加工するよ

  • entry_idを取得した時点で時間が来てしまったので続きは次に回すよ!

f:id:kom314_prog:20210817233946p:plain



おわりに

  • 中途半端!!!

  • entryに登録までやりたかったけどできませんでした!

  • 次がんばるぞ~~~!

  • ではでは、ちゃお~~~!



小村のポートフォリオサイト開発(8) DjangoRestFramework はてなブログの記事の必要データ選定



目次

  1. xml形式のデータを辞書形式に加工
  2. json形式のデータから必要な情報を選定



記録

xml形式のデータをjson形式に加工

f:id:kom314_prog:20210815172514p:plain


  • 参考サイトのxmltodictを使用してDict型に変換したところで止めるよ!

  • 内容を見るとこんな感じ。

f:id:kom314_prog:20210815172615p:plain



辞書形式のデータから必要な情報をピックアップ

  • 参考:はてなブログAtomPub

  • ではでは、必要なデータを実際のデータと公式ページ見ながら考えていきます

  • ふむふむ!!

  • まずentryの中身がリスト型になっていて、最新7件分のブログデータが入ってるね

  • 必要そうなものをピックアップするとこんな感じ

項目 内容 理由
id ユニークキー おそらく20桁以内の数値。ブログ記事のユニークキー
title 記事タイトル
summary.#text 改行とかない本文 トップページ用。詳細だとなし
content.#text markdown形式の本文 文字数制限につき一覧ではなく詳細で取得する必要あり
hatena:formatted-content.#text html形式の本文 一覧でも全部取得可能
category カテゴリ
app:control.app:draft 下書きか否か
published 公開日
app:edited 作成日
updated 更新日
  • こんな感じかな!



おわりに

  • 今回はちょっと短いですがこの辺で!

  • 次回は実際にmodelsを作成して必要なデータを取得していきたいと思います!

  • ではでは!ちゃお~~~!



小村のポートフォリオサイト開発(7) DjangoRestFramework はてなブログの記事取得



目次

  1. はてなブログの記事を取得するためのAPIの調査
  2. 認証方法を決める
  3. はてなブログAPI呼び出し用のAPIを用意(空処理)
  4. PythonはてなブログAPIを呼び出す



記録

はてなブログの記事を取得するためのAPIの調査



認証方法を決める

  • はてなブログAPIを利用するのに認証が必要だが、認証方法に下記がある

    • Basic認証

      • ユーザIDとパスワードによるオーソドックスな認証
      • 一番実装が簡単
      • クライアント側にパスワードをあずける必要がある
      • リクエストにユーザIDとパスワードを乗せる関係で盗聴怖い
      • そのためはてなブログAPIではHTTPSでなければBasic認証使用不可
    • WSSE認証

      • HTTPのX-WSSEヘッダを用いて認証用文字列を送信する認証手段
      • WSSE とは?
        • 「パスワードと日時とセキュリティトークンを SHA1 でハッシュしたもの」
      • どうやらHTTP通信の場合はBasic認証よりよいらしいが、HTTPSなら変わらないみたい
    • OAuth認証

      • 参考:OAuth認証をBasic認証と比較してみる
      • あれのことか!最近よくあるログイン機能!
      • GoogleアカウントとかTwitterアカウントでログインとかするやつ
      • あれのこと。外部サービスを利用した認証だね
      • クライアント自体にパスワードを保持する必要がないのがメリット
  • 調べてみて、Basic認証でいいなと判断しました

  • パスワード(APIキー)ははてなブログで管理されてるし、問題ないでしょ



はてなブログAPI呼び出し用のAPIを用意(空処理)

  • まずはblogs/captureにPOSTでアクセスすれば処理が走るようにします

  • やることは下記

    • urlsblogsをルーティング

    • viewsblogpyを用意し、空処理を実行


f:id:kom314_prog:20210814152137p:plain
f:id:kom314_prog:20210814152209p:plain
  • viewsではまだblog用のmodelを用意してませんが、ModelViewsetを使います

    • 将来的にDBを使うことは確定なので
  • ひとまず仮のmodelとして、テスト用に作成していたUserモデルを拝借します

  • その後api/blogs/captureにpostでアクセスし、正常に動作することを確認しました


f:id:kom314_prog:20210814152619p:plain



はてなブログAPIに必要な情報を環境変数に追加

  • 次にはてなブログAPIの認証で使用するAPIKeyを取り扱いましょうか

  • まずははてなブログの設定画面より、AtomPubAPI Keyを確認

  • そしてUbuntuとHerokuの環境変数HATENA_API_KEYを設定します

    • (秘密情報なのでスクショなし)
  • ついでにHATENA_API_USERHATENA_API_BLOGも設定しておきましょう



はてなブログAPIを呼び出す

f:id:kom314_prog:20210814163409p:plain
# coding: utf-8
import os
import requests

from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.response import Response

from ..models import User
from ..serializer import UserSerializer


class BlogViewSet(viewsets.ModelViewSet):
    queryset = User.objects.all()
    serializer_class = UserSerializer

    @action(detail=False, methods=['post'])
    def capture(self, request, pk=None):
        url = self.getHatenaApiUrl('entry')
        auth = self.getHatenaApiAuth()
        res = requests.get(url, auth=auth)
        return Response(res.text)

    def getHatenaApiUrl(self, action):
        HATENA_API_URL_HEADER = 'https://blog.hatena.ne.jp'
        HATENA_API_USER = os.environ.get('HATENA_API_USER')
        HATENA_API_BLOG = os.environ.get('HATENA_API_BLOG')
        HATENA_API_URL_FUTTER = 'atom'

        url = [
            HATENA_API_URL_HEADER,
            HATENA_API_USER,
            HATENA_API_BLOG,
            HATENA_API_URL_FUTTER,
            action
        ]
        return os.path.join(*url)

    def getHatenaApiAuth(self):
        HATENA_API_USER = os.environ.get('HATENA_API_USER')
        HATENA_API_KEY = os.environ.get('HATENA_API_KEY')

        return (HATENA_API_USER, HATENA_API_KEY)
  • views/blog.pyのコードを上記のように変更しました。

  • これでapi/blogs/captureにpostでアクセスすると下記レスポンスになります


f:id:kom314_prog:20210814163913p:plain



おわりに

  • Pythonで問題なくはてなブログAPIを取得ができました!!!

  • 次はこのxmlデータをjsonに加工して、いい感じにmodelに格納が必要ですね!

  • ひとまず今回はここまでにします!

  • またね~~~ちゃお!!!

小村の開発環境構築(21) GCP無料期間終了!!

f:id:kom314_prog:20210814093325p:plain
  • ぎゃーーーす!!!

  • 恐れていた事態が発生だよ!!!

  • GCPの無料トライアル期間が終了してしまいました!!!

  • というわけで、無料期間終了した際のメモを残しておきます



やること

  1. 過去の利用金額の確認
  2. 予算アラートの作成
  3. GCEの自動シャットダウン機能作成



手順

過去の利用金額の確認

  • まずは無料期間中の3か月間で利用した金額について調べましょうか

  • GCPの支払金額は、お支払い - レポート から確認します


f:id:kom314_prog:20210814093529p:plain
f:id:kom314_prog:20210814093753p:plain
  • 無料期間中なのでグラフはありませんが、金額はちゃんと確認できますね。

  • 3カ月合計で1374円分の利用をしていたようです。

  • 何度もGCEを消し忘れたりしたうえ平均500円以下ですし、でかい出費になる問題はなさそうですね。



予算アラートの作成

  • なんやかやで急激に支払料金が増えるリスクに備えて、予算アラートを設定しておきましょう

  • まず基本として、月額500円以内に抑えたいとおもっております

  • 設定するアラートとしては、下記くらいでよさそうですかね

    • 250円(50%)時点
    • 400円(80%)時点
    • 500円(100%)時点
    • 700円(140%)時点
    • 1000円(200%)時点
  • では設定していきます!


f:id:kom314_prog:20210814094910p:plain
  • 終わりました!はや!めちゃ簡単!

  • 設定した内容は下記の通り。後はメールが届いてのお楽しみですね


f:id:kom314_prog:20210814095224p:plain
f:id:kom314_prog:20210814095333p:plain



GCEの自動シャットダウン機能作成

  • 有料化にあたり、GCEの消し忘れもばかにならなくなってきますね

  • なんなら過去の料金の半分以上は消し忘れの時間だと思います

  • というわけで、自動でGCEを落とす仕組みを導入しましょう!



公式チュートリアル通り進める



導入完了

f:id:kom314_prog:20210814105653p:plain
f:id:kom314_prog:20210814105735p:plain
f:id:kom314_prog:20210814105803p:plain
  • 手順通りに実装し(おそらく)問題なく完了!

  • 変えたのはほんとにスケジュールの実行時間ぐらい

  • 毎日午前1時にシャットダウンのPubSubを実行するようにしました!

  • 導入にかかった時間は丁度1時間くらいかな?

  • あとは問題なく稼働することを確認するだけ。今日あたりつけっぱにしておこう



おわりに

  • やらなきゃなーと思っていたGCEの自動シャットダウンが導入できたー!

  • なんでもそうだけど、着手してしまえば案外こんなものかとなりますよね

    • 逆もめっちゃありますが!
  • とはいえ使わない時にこまめに消すのが一番の節約なのは間違いない……

  • 気にしてこまめにシャットダウンしていきたいと思います!

  • ではでは今日はこのへんで!ちゃお~~~!

小村の開発環境構築(20) DRF ディレクトリ構成変更、テスト機能設定

  • こんばんは!小村だよ!

  • 前回まででDjangoRestFrameworkのHerokuへのデプロイが無事完了しました!

  • 今回はDjangoRestFrameworkのテストが実行できるようにしていくよ!

  • よろしくね!



やること

  1. DRFフォルダ構成変更
  2. Pytestの導入



前提条件

  • Ubuntuにリモート接続していること(これまでの環境構築参照)



手順

DRFフォルダ構成変更

f:id:kom314_prog:20210807142743p:plain
  • 現状のフォルダ構成は上記の通り

  • 今後ファイルが増えることを見越して、フォルダ構成を変えていきます

  • 変更後のディレクトリ構成は下記のとおりです


f:id:kom314_prog:20210807145339p:plain
  • 下記4つのファイルをディレクトリに変更し、クラスをそれぞれファイルに分けています

    • models
    • serializer
    • tests
    • views
  • これで無事稼働することを確認!

  • これを稼働させるためのポイントは3つ

    • フォルダ構成が変わったので、from .models となっていたのを from ..modelsに変更

    • フォルダ直下に__init__.pyにを記述して、ファイルをインポートする

      • この際何もしないとlintエラーが出るため、# noqaを記述しエラーを発生させない
    • models内で他のmodelを使うときはダブルクォートで囲う(下図参照)

      • 通常ならfrom user import User を書いたうえでダブルクォートで囲まずUserを使う
      • しかし今回のように__init__.pyで記載があるときはimportが不要となる
f:id:kom314_prog:20210807150411p:plain



Pytestの導入

pip install pytest
pip install pytest-django
pip freeze > requirements.txt


  • pytest.iniを作成して、apiフォルダ配下に設置
f:id:kom314_prog:20210807155705p:plain


  • あとはテストファイルを作成して動作確認

  • pytestを実行して無事に動作することを確認!

  • 本格的な記述は後回しにさせてもらうぜ!

f:id:kom314_prog:20210807160013p:plain



おわりに

  • Djangoの必要最低限の環境構築はできたかなと思います!

  • あとはAPIを実装していくだけ!

  • 本格的なWebサイト作成を再開していこうと思います!

  • ではでは、ちゃお~~~!