hacomono TECH BLOG

フィットネスクラブやスクールなどの顧客管理・予約・決済を行う、業界特化型SaaS「hacomono」を提供する会社のテックブログです!

Notionで下書きした記事をはてなブログに自動転写するシステムを開発した話

こんにちは!hacomonoの開発基盤グループでインターンとして活動しているゆーたです。
2024年7月にhacomonoにジョインしました。これまで約2年間、主にアプリケーション開発を担当するインターンやバイトをしていましたが、hacomonoでは開発基盤チームに所属していることもあり、インフラも含めて広く触れる機会をいただいています。
先日、初めてのタスクを完遂したので、今回はその内容を振り返りたいと思います。

TL;DR

  • Notionで管理しているテックブログ記事を、はてなブログへ自動転写するアプリケーションを開発
  • 記事の手動転写による30分〜1時間のリードタイムを、数分に短縮することに成功


背景

hacomonoでは、記事の管理をNotionで行い、投稿先にははてなブログを利用しています。
これまで、Notionで執筆した記事を手動でコピーし、はてなブログに貼り付けていました。しかし、Markdown記法の違いなどから手動で調整する必要があり、一つの記事を公開するのに、30分から1時間を要している状況でした。そのため、作業の効率化とリードタイムの短縮を目的に、可能な限り自動化できないかと検討することになりました。
主な課題は以下の通りです。

  • Notionとはてなブログで使用するMarkdown記法が異なり、手動での変換が必要だった
  • Notionの下書きに含まれる画像リンクはNotion外で使用することができない
  • はてなブログ側で改行が正しく反映されない


システム概要

hacomonoでは、以下のようなデータベースで投稿用記事の下書きを管理しています。今回作成したシステムでは、記事に紐づくステータスの変更を検知します。ステータスが『Waiting to publish』になった記事を対象として、はてなブログへの自動転写を実行します。


技術概要

使用技術

  • Golang
  • Typescript
  • Docker (LocalStack)
  • AWS (SAM, ECR, Lambda, EventBridge, SSM Parameter Store)
  • Terraform
  • Github Actions
  • 外部API (Slack API, Notion API, はてなフォトライフAtomAPI, はてなブログAtomPub)

構成図

本システムはごく小規模なマイクロサービス構成となっています。TypeScriptで実装されたLambda関数では、主にNotionから取得したJSONデータをHatenaブログ用のMarkdown形式に変換し、投稿実行などはGolangで実装されたLambda関数が担当しています。
システム概要で述べたように、本システムではNotion記事に紐づくステータスプロパティが『Waiting to publish』に設定されたものを検知し、転写を実行するようにしています。しかし、Notionは記事プロパティの変更に伴うWebhookを提供していないため、今回はEventBridgeで5分おきにスケジュールを実行させています。
このシステムは社内ツールであり、メンテナンスに時間をかけずに運用したいため、後方互換性が強みのGolangでの実装を考えていました。ただ、Markdown変換ライブラリに関してはTypeScriptのものが優れていたため、このようなハイブリッド構成になっています。

Markdown変換

Notion APIから取得されるデータはJSON形式です。そのため、このJSONデータをMarkdown形式に変換するためにはライブラリを利用する必要があります。しかし、公式の変換ライブラリは現時点で提供されていません。
最も広く利用されているライブラリの一つとして、以下が挙げられます。

このライブラリは、Notion APIから取得したJSONをNotionのMarkdown形式に変換しますが、HatenaブログのMarkdown形式に対応させるにはカスタマイズが必要です。このライブラリは内部の変換処理をカスタマイズする機能を備えているため、Hatena用に変換を行うことが可能です。

以下は、埋め込みURLをHatenaブログのMarkdown形式に変換するカスタム処理の例です。

const n2m = new NotionToMarkdown({ notionClient: notion });
setupTransformers(n2m);
export const setupTransformers = (n2m: NotionToMarkdown) => {
  n2m.setCustomTransformer("embed", embedTransformer);
};
export const embedTransformer = async (block: any) => {
  return `[${block.embed.url}:embed]`;
};


このような形でNotionとHatenaで仕様が異なる部分に対してカスタマイズを個別に加えました。(かなり地味ですが考慮しなくてはいけないパターンが多く結構大変でした。。。)

アプリケーションデモ

このアプリケーションによって、どれだけ課題が解消されたかをデモでご紹介します。手動での転写で作成した記事と比較し、どのように効率化されたかを具体的に見ていきます。

画像の転写

Notionの下書きから手動で画像をコピーする場合、手動転写部分にあるように、Notionで使われている画像URLが返されます。しかし、このURLはNotion外からアクセスするとエラーが発生し、適切に表示されません。ただし、Notion APIから取得できる画像リンクは、期限付きで全体公開されているものが返ってきます。しかし、こちらも1時間で期限がきれるため、はてなフォトライフというストレージサービスにシステムからアップロードをして永続的に画像利用ができるようにしています。

下書き

手動転写
![image.png](https://prod-files-secure.s3.us-west-2.amazonaws.com/644026c8-fbb7-450e-9bfc-d9968e5db5ed/844b8b95-6b5a-45c1-992f-205d205e81ea/image.png)

自動転写
[f:id:hacomono-tech:20240913134339p:image:w600]  



埋め込みリンクの転写

手動転写では余計な改行が入っている上、名前付きリンクとして表示されてしまっていました。これはNotionとはてなブログのMarkdown記法の違いによるものです。これらをはてなのMarkdown記法に内部で変換し、改行の判定も適切に行うことで、下書きと同様の見た目に整えられています。

下書き

手動転写
株式会社hacomonoでは一緒に働く仲間を募集しています。
エンジニア採用サイトや採用ウィッシュリストもぜひご覧ください!
[株式会社hacomono](https://www.hacomono.co.jp/recruit/engineer)
[採用ウィッシュリスト(プロダクトチーム向け) | Notion](https://www.notion.so/65f1c0a6e2574d22b2842b40a8e15358)

自動転写
株式会社hacomonoでは一緒に働く仲間を募集しています。  
エンジニア採用サイトや採用ウィッシュリストもぜひご覧ください!  
[https://www.hacomono.co.jp/recruit/engineer:embed]
[https://www.notion.so/65f1c0a6e2574d22b2842b40a8e15358:embed]



Slack通知

自動転写の場合、転写実行後に、はてなブログの編集用URLなどが記載されたメッセージがSlack経由で通知されます。


学び・工夫など

これまでAWSやTerraformにほとんど触れる機会がありませんでしたが、このタスクを通じて挑戦する機会を得ることができ、多くのことを学びました。ここでは、その中から特に印象に残った学びや工夫についてご紹介します。

Terraform、Lambda環境下でのcredential管理

今回のアプリケーションでは外部APIを使用するため、Lambdaから必要なキー情報をセキュアに取得する方法を実装する必要がありました。

考慮点

TerraformでLambdaを実装するにあたって以下のようなことを考慮しました。

  • Lambdaの環境変数に直接secret情報を使わない
    • AWS APIのGetFunctionで平文で返されてしまう等のリスクがあるため。
  • Terraformでsecretは扱わない
    • tfstateファイルにシークレット情報が残ってしまうリスクを避けるため。


実装

これらの考慮点を踏まえ、以下のフローでクレデンシャル情報を扱うことにしました。

1.SSM Parameter Storeのリソース作成

Parameter StoreのSecureStringを使って、トークンなどの管理を行いました。tfstateに残ることを防ぐため、ダミーの値を設定し、変更は無視するように設定しています。

resource "aws_ssm_parameter" "notion_access_token" {
  name        = "/${local.app_name}/notion_access_token"
  description = "notion access token"
  type        = "SecureString"
  value       = "secret"
  # valueはaws cliから上書きするため、変更を無視する
  lifecycle {
    ignore_changes = [value]
  }
}

2.Lambdaの環境変数にはParameter名を設定

Lambdaの環境変数に、クレデンシャル情報ではなくParameter Storeの名前を指定します。

locals {
  go_lambda_environment = {
      # 省略
    NOTION_ACCESS_TOKEN_PARAM_NAME = "/${local.app_name}/notion_access_token"
  }
}
resource "aws_lambda_function" "go_lambda" {
  function_name = local.go_lambda_name
  role          = aws_iam_role.go_lambda_role.arn
  package_type  = "Image"
  image_uri     = "${local.go_image_uri}:latest"
  timeout       = 200
  memory_size   = 128
  environment {
    variables = local.go_lambda_environment
  }
  
  # 省略
}

3.GoからDecryptしたParameter Storeの値を取得

package ssm
import (
    "context"
    "fmt"
    "os"
    "sync"
    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/config"
    "github.com/aws/aws-sdk-go-v2/service/ssm"
)
type AwsSsmInstance struct {
    Client *ssm.Client
}
var awsSsmInstance *AwsSsmInstance
var Lock = &sync.Mutex{}
func GetAwsSsmInstance(env string, awsRegion string) (*AwsSsmInstance, error) {
    if awsSsmInstance == nil {
        Lock.Lock()
        defer Lock.Unlock()
        client, err := getSsmClient(env, awsRegion)
        if err != nil {
            return nil, err
        }
        awsSsmInstance = &AwsSsmInstance{
            Client: client,
        }
    }
    return awsSsmInstance, nil
}
func getSsmClient(env string, region string) (*ssm.Client, error) {
    var cfg aws.Config
    var err error
    cfg, err = config.LoadDefaultConfig(context.TODO(), config.WithRegion(region))
    if err != nil {
        return nil, err
    }
    if env == "local" {
        localEndpoint := os.Getenv("LOCAL_SSM_ENDPOINT")
        if localEndpoint == "" {
            return nil, fmt.Errorf("please set LOCAL_SSM_ENDPOINT")
        }
        return ssm.NewFromConfig(cfg, func (o *ssm.Options) {
            o.BaseEndpoint = aws.String(localEndpoint)
        }), nil
    }
    return ssm.NewFromConfig(cfg), nil
}
func (AwsSsmInstance *AwsSsmInstance) GetParameter(name string, decrypt bool) (string, error) {
    res, err := awsSsmInstance.Client.GetParameter(context.TODO(), &ssm.GetParameterInput{
        Name: &name,
        WithDecryption: aws.Bool(decrypt),
    })
    if err != nil {
        return "", err
    }
    return *res.Parameter.Value, nil
}

4.AWS CLIから上書き

# notion access token
aws ssm put-parameter --name /techblog-automation/notion_access_token --value "***" --type SecureString --overwrite


Typescript側の呼び出しをカプセル化

冒頭の技術概要でも触れたように、このシステムではMarkdownの変換部分のみをTypescriptのLambda関数で実行しています。その理由は、Typescript (Node.js) で利用可能なライブラリがNotionのMarkdown変換において最も広く使用されており、信頼性が高く、また機能性も優れているためです。
しかし、TypescriptやNode.jsのサポートは更新頻度が高く、メンテナンスに多くの時間を割きたくないこの種の社内システムには不向きだと考えます。そのため可能であれば、将来Golangへの移行も視野に入れています。そこで、現在Typescriptを呼び出しているMarkdown変換部分を可能な限り抽象化し、将来的にGolang製のライブラリなどが登場した際に、簡単に移行できるようにしておきたいと考えました。

main.go

mdConverter := mdconverter.NewMdConverter()
go func() {
    defer contentWg.Done()
    contentBody, contentErr = mdConverter.Convert(page.PageID)
}()


mdconverter.go

package mdconverter
import (
  "os"
    "encoding/json"
    "github.com/hacomono/blog_publish_automation/internal/aws/lambda"
    "github.com/hacomono/blog_publish_automation/internal/types"
)
type MdConverter struct {
}
func NewMdConverter() *MdConverter {
    return &MdConverter{}
}
func (c *MdConverter) Convert(pageId string) (types.MdConvertResult, error) {
    var result types.MdConvertResult
    awsLambdaInstance, err := lambda.GetAwsLambdaInstance(os.Getenv("ENV"), os.Getenv("LOCAL_TS_ENDPOINT"), os.Getenv("REGION"))
    if err != nil {
        return result, err
    }
    res, err := awsLambdaInstance.InvokeFunction(`{"pageId": "` + pageId + `"}`, os.Getenv("TS_LAMBDA_FUNCTION_NAME"), "RequestResponse")
    if err != nil {
        return result, err
    }
    var contentResponse struct {
        StatusCode int    `json:"statusCode"`
        Body       string `json:"body"`
    }
    err = json.Unmarshal(res.Payload, &contentResponse)
    if err != nil {
        return result, err
    }
    err = json.Unmarshal([]byte(contentResponse.Body), &result)
    if err != nil {
        return result, err
    }
    return result, nil
}

このように抽象化することで、mdconverterパッケージのConvertメソッドの内部処理だけを変更すれば、Golang等他のライブラリへの移行が容易になります。また、MdConverterという構造体を使用することで、呼び出し元のコードから変換処理の詳細を隠蔽し、コード全体を抽象化しています。

AWS実行環境ローカルでの再現方法

sam localを使ったLambda同士の疎通

Lambdaのローカル環境構築にはsamを使用しました。今回、Goで実装したLambdaAがTypeScriptで実装したLambdaBを呼び出す構成です。以下のGitHub Issueを参考にして環境を構築しました。
参考リンク: SAM CLI Issue 510

template.yml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Resources:
  GoLambdaFunction:
    Type: AWS::Serverless::Function
    Properties:
      PackageType: Image
      Architectures:
        - arm64
      Tracing: Active
      Timeout: 200
      Environment:
        Variables:
          TS_LAMBDA_FUNCTION_NAME: "TsLambdaFunction"
          LOCAL_TS_ENDPOINT: http://host.docker.internal:3001
          # 省略
    Metadata:
      Dockerfile: Dockerfile
      DockerContext: ./go
      DockerTag: latest
      DockerBuildTarget: local
  TsLambdaFunction:
    Type: AWS::Serverless::Function
    Properties:
      PackageType: Image
      Architectures:
        - arm64
      Tracing: Active
      Timeout: 170
      FunctionName: TsLambdaFunction
      Environment:
        Variables:
           # 省略
    Metadata:
      Dockerfile: Dockerfile
      DockerContext: ./ts
      DockerTag: latest
      DockerBuildTarget: local


起動コマンド

sam build
# TypeScriptのLambda関数が、GoのLambda関数からのリクエストを受けられるようにする
sam local start-lambda -n env.json
# GoのLambda関数を実行
sam local invoke GoLambdaFunction -n env.json


SSM parameter storeの再現

現時点ではsam localでSSM Parameter Storeはサポートされていないため、LocalStackを使い再現しました。(Issue #616)
そのため、この部分のみdocker-composeを使用してLocalStackを立ち上げ、LocalStackのParameter Storeを使用する方法に切り替えました。

docker-compose.yml

version: "3.8"
services:
  localstack:
    image: localstack/localstack
    environment:
      - SERVICES=ssm
      - DEBUG=${DEBUG:-0}
      - PERSISTENCE=1
    ports:
      - "4566:4566"
    volumes:
      - volume:/var/lib/localstack
      - "/var/run/docker.sock:/var/run/docker.sock"
      - "./init-ssm.sh:/etc/localstack/init/ready.d/init-aws.sh"

LocalStackのParameter Storeに登録したCredential情報は永続化されないため、毎回登録実行するスクリプトを用意し、起動時に自動的に実行させることで対応しました。

init-ssm.sh

#!/bin/bash
# localstackのssm parameter storeは起動するたびにcredentialの設定をcli経由で実行する必要がある
# 以下のコマンドをdocker composeの起動時に実行させる
export AWS_DEFAULT_REGION=ap-northeast-1
# SSM Parameter Storeに値を設定
aws --endpoint-url=http://localhost:4566 ssm put-parameter --name /techblog-automation/notion_access_token --value "***************" --type SecureString
aws --endpoint-url=http://localhost:4566 ssm put-parameter --name /techblog-automation/hatena_api_key --value "***************" --type SecureString
aws --endpoint-url=http://localhost:4566 ssm put-parameter --name /techblog-automation/slack_bot_token --value "***************" --type SecureString

このようにして、samのコマンド実行前にdocker-compose upを実行すれば、ある程度AWS環境をローカルで再現できるようになりました。

Dockerfileのbest practice

マルチステージビルド

これまで主にPHPを扱ってきた私にとって、GoやTypeScriptのビルドプロセスは新鮮な体験でした。どちらもビルドが必要であるため、マルチステージビルドを活用しました。最終ステージには必要最小限のファイルだけを配置することで、イメージサイズを大幅に削減することができました。これにより、軽量かつ安全なイメージを作成でき、セキュリティ面や運用面でも大きな利点を得ることができました。

ベースイメージの選定

今回のプロジェクトでは、ベースイメージとしてDistrolessを採用しました。以前は軽量で広く使われているAlpine Linuxを利用していましたが、深く考えずに選んでいた部分もありました。
Distrolessの特徴は、不要なファイルやライブラリを含まない点です。これにより攻撃のリスクが低減され、シェルアクセスを利用した攻撃を防ぐことができます。また、不要なファイルがない分、イメージが軽量化され、デプロイ時間の短縮にもつながります。特に、今回のようにGoやTypeScript(Node.js)を使ったプロジェクトには非常に適していました。

  • セキュリティ強化: 最小限の構成により、攻撃リスクを低減。
  • 軽量化: イメージサイズが小さくなり、デプロイが高速化。

なお、Distrolessにはデバッグ用のイメージもありますが、SAM CLIで実行するとエラーが発生したため、ローカル環境ではやむなくLambdaの公式イメージを使用しました。。。

Github Actionsにarm64 runnerがあった

今回、Lambdaをarm64アーキテクチャで動作させる予定でしたが、最初にGitHub Actions経由でデプロイを行った際に失敗しました。原因は、GitHub Actionsがデフォルトでx86_64アーキテクチャで実行されていたためです。
当初この問題を解決するために、以下の記事を参考にしてQEMUを利用し、仮想的にarm64環境を作成してデプロイを試みました。デプロイ自体は成功しましたが、QEMUによるオーバーヘッドが大きく、GitHub Actionsの実行時間が約20分もかかってしまいました。

そこで色々調べたところ、最近のリリースでGitHub Actionsでarm64ランナーを直接指定できることがわかり、これを導入してみました。結果として、実行時間は20分から2分に大幅に短縮されました。料金についても、無料枠を超えた利用がある場合、この方法の方がコストを抑えられるようです。


まとめ

今回hacomonoでの初めてのタスクでしたが、無事に運用まで辿り着くことができ、少しホッとしています。
これまではPHPやLaravelを中心に開発をしてきたため、インフラ関連の業務に関わる機会はほとんどありませんでした。しかし、今回AWSやTerraformといった新しい技術に取り組む機会を頂けて、インフラのコード化や開発環境整備、CICDなど多くを学ぶことができました。経験に関係なく様々なことに挑戦できる環境は、とてもありがたいなと感じました。(hacomonoインターンおすすめです!!)
また、SREチームのメンターの社員 @Diwamoto_ から定期的にレビューをいただけることで、日々新しい知識に触れ、成長を実感しています。
今後も引き続き貢献できるよう頑張ります!!


株式会社hacomonoでは一緒に働く仲間を募集しています。
エンジニア採用サイトや採用ウィッシュリストもぜひご覧ください!

▼インターン募集はこちら