Notion 이미지 만료 이슈 개선

작성일: 2023-07-03

Prerequisite

  • Notion 테이블 글을 Markdown 파일로 생성하는 스크립트 작성

Notion S3 이미지 만료 이슈

지난번 작성한 스크립트를 실행하면 Markdown 파일이 잘 생성되고, 이미지가 있는 글도 아래와 같이 확인할 수 있습니다.

해당 링크는 처음에는 잘 동작하지만, 일정 시간이 지나면 만료가 되어 이미지가 나오지 않습니다.

이미지가 만료되는 이유는 Notion에서 사용하는 Amazon S3 이미지 링크에 만료 기간이 설정되어 있기 때문입니다.

이를 해결하기 위해 자체 S3를 생성하고 여기에 이미지를 관리하도록 스크립트를 수정하였습니다.

Amazon S3 설정

버킷 생성

버킷 이름과 지역을 설정하고 모든 퍼블릭 엑세스 차단 체크박스를 해제하고 버킷을 생성합니다.

정책 설정

생성한 버킷을 클릭하고 권한 탭에 들어가 버킷 정책에서 버킷에 대한 읽기, 쓰기, 삭제가 가능하도록 정책을 등록합니다.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Statement1",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:*",
            "Resource": "arn:aws:s3:::devshjeon-blog-images/*"
        }
    ]
}

Access Key 등록

우측 상단 사용자명 → 보안 자격 증명 → 액세스 관리 → 사용자 → 사용자 추가를 클릭하여 사용자를 생성합니다.

권한 옵션에서 직접 정책 연결을 클릭하고 AmazonS3FullAccess 정책을 선택합니다.

생성한 사용자를 클릭하고 보안 자격 증명 탭에서 액세스 키 만들기를 클릭 후 사용 사례 중 아무거나 클릭 후 Access Key를 생성합니다.

생성한 Access Key와 Secret Access Key는 안전하게 보관합니다.

Amazon S3 업로드 스크립트 작성

기존 스크립트에 이미지 다운로드 및 S3 업로드하는 코드를 추가하였습니다.

const { Client } = require("@notionhq/client")
const { NotionToMarkdown } = require("notion-to-md")
const moment = require("moment")
const moment_timezone = require("moment-timezone")
const path = require("path")
const fs = require("fs")
const https = require("https")
const AWS = require("aws-sdk")
// or
// import {NotionToMarkdown} from "notion-to-md";

const notion = new Client({
  auth: process.env.NOTION_TOKEN,
})

AWS.config.update({
  accessKeyId: process.env.AWS_ACCESS_KEY,
  secretAccessKey: process.env.AWS_SECRET_KEY,
  region: "ap-northeast-2",
})

const s3 = new AWS.S3()

// passing notion client to the option
const n2m = new NotionToMarkdown({ notionClient: notion })
const regexPattern = "https:\/\/.*s3.us-west-2.amazonaws.com.+x-id=GetObject"

function findImageUrl(str) {
  const regex = new RegExp(regexPattern, "g")
  const matches = str.match(regex)
  return matches || []
}

async function deleteAllFiles(folderPath) {
  const files = await fs.promises.readdir(folderPath)

  for (let i = 0; i < files.length; i++) {
    const filePath = path.join(folderPath, files[i])
    try {
      await fs.promises.unlink(filePath)
    } catch (err) {
      console.error("Error deleting file:", filePath, err)
    }
  }

  await fs.promises.rm("_images", { recursive: true })
}

function downloadImage(url, fileName) {
  return new Promise((resolve, reject) => {
    const file = fs.createWriteStream(fileName)
    https.get(url, (response) => {
      response.pipe(file)
      file.on("finish", () => {
        file.close(resolve)
      })
    }).on("error", (err) => {
      fs.unlink(fileName, () => {
        reject(err)
      })
    })
  })
}

async function downloadImages(path, imageUrls) {
  let number = 1
  const s3Urls = []
  for (let url of imageUrls) {
    const ext = imageUrls[0]?.split(".")?.pop()?.split("?")[0] || "png"
    const fileName = `${path}/${number}.${ext}`
    await downloadImage(url, fileName)

    const fileContent = await fs.promises.readFile(fileName)
    const params = {
      Bucket: "devshjeon-blog-images",
      Key: fileName,
      Body: fileContent,
    }

    const uploadResult = await s3.upload(params).promise()
    s3Urls.push(uploadResult.Location)
    number++
  }

  await deleteAllFiles(path)

  return s3Urls
}

function replaceUrl(body, imageUrls, s3Urls) {
  if (s3Urls.length === imageUrls.length) {
    for (let i = 0; i < s3Urls.length; i++) {
      body = body.replace(imageUrls[i], s3Urls[i])
    }
  }
  return body
}

(async () => {
  // ensure directory exists
  const root = `docs`
  const imageRoot = "_images"

  const databaseId = process.env.DATABASE_ID
  const response = await notion.databases.query({
    database_id: databaseId,
    filter: {
      "and": [
        {
          property: "공개",
          checkbox: {
            equals: true,
          },
        },
        {
          property: "배포",
          checkbox: {
            equals: true,
          },
        },
      ],
    },
  })
  for (const r of response.results) {
    const id = r.id

    // 최상위폴더
    let upUpFolder = ""
    let pUpUpFolder = r.properties?.["최상위폴더"]?.["rich_text"]
    if (pUpUpFolder) {
      upUpFolder = pUpUpFolder[0]?.["plain_text"]
    }

    // 상위폴더
    let upFolder = ""
    let pUpFolder = r.properties?.["상위폴더"]?.["rich_text"]
    if (pUpFolder) {
      upFolder = pUpFolder[0]?.["plain_text"]
    }

    // 순번
    let navOrder = r.properties?.["순번"]?.["number"] || ""

    // 제목
    let title = id
    let pTitle = r.properties?.["제목"]?.["title"]
    if (pTitle?.length > 0) {
      title = pTitle[0]?.["plain_text"]
    }

    // 메인
    let hasChild = r.properties?.["메인"]?.["checkbox"] || false

    // 작성일
    let date = moment(r.created_time).tz("Asia/Seoul").format("YYYY-MM-DD HH:mm")
    // let pDate = r.properties?.["최종수정일"]?.["last_edited_time"]
    // if (pDate) {
    //   date = moment(pDate).tz("Asia/Seoul").format("YYYY-MM-DD HH:mm")
    // }

    let header = `---
layout: default
title: ${title}
has_children: ${hasChild}
last_modified_date: ${date}`

    if (navOrder) {
      header += `
nav_order: ${navOrder}`
    }

    if (hasChild) {
      if (upFolder) {
        header += `
parent: ${upUpFolder}`
      }
    } else {
      header += `
grand_parent: ${upUpFolder}`
      if (upFolder) {
        header += `
parent: ${upFolder}`
      }
    }
    header += `
---`

    const folderPath = upFolder ? `${root}/${upUpFolder}/${upFolder}` : `${root}/${upUpFolder}`
    const imagePath = upFolder ? `${imageRoot}/${upUpFolder}/${upFolder}/${title}` : `${imageRoot}/${upUpFolder}/${title}`
    fs.mkdirSync(folderPath, { recursive: true })

    const mdBlocks = await n2m.pageToMarkdown(id)
    let body = n2m.toMarkdownString(mdBlocks)["parent"]

    // download image
    const imageUrls = findImageUrl(body)
    let s3Urls = []
    if (imageUrls.length > 0) {
      fs.mkdirSync(imagePath, { recursive: true })
      s3Urls = await downloadImages(imagePath, imageUrls)
      body = replaceUrl(body, imageUrls, s3Urls)
    }

    //writing to file
    const fTitle = `${title}.md`
    fs.writeFile(path.join(folderPath, fTitle), header + body, (err) => {
      if (err) {
        console.log(err)
      }
    })
  }
})()

해당 스크립트를 실행하면 이미지 URL이 Notion에서 제가 생성한 버킷으로 변경된 것을 확인할 수 있습니다.

스크립트 변경 전
![%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA_2023-06-28_18.32.41.png](...secure.notion-static.com/42267d26-5423-49d2-994b-1daf6dd10e97/%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA_2023-06-28_18.32.41.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=AKIAT73L2G45EIPT3X45%2F20231019%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20231019T182154Z&X-Amz-Expires=3600&X-Amz-Signature=36305deba4c6559289eadb114a1b627040e407b2e2dcbe27bf3cea3a64e82020&X-Amz-SignedHeaders=host&x-id=GetObject)

스크립트 변경 후
![%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA_2023-06-28_18.32.41.png](...devshjeon-blog.../_images/%EA%B0%9C%EC%9D%B8%20%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8/%EA%B0%9C%EC%9D%B8%20%EB%B8%94%EB%A1%9C%EA%B7%B8/GitHub%20%EB%B8%94%EB%A1%9C%EA%B7%B8%20%EB%A7%8C%EB%93%A4%EA%B8%B0%20%28Jekyll%29/1.png)

Reference

AWS S3로 이미지 업로드