옵시디언 노트를 고스트로 보내기 위한 빡센 여정

Web, 인터넷 2024년 12월 11일
고스트와 노트

옵시디언 AI 글쓰기 기능

옵시디언의 커뮤니티 플러그인 중에 AI를 이용해서 글을 작성할 수 있는 것들이 있습니다. Cursor AI가 유명하지만 개인적으로 스마트 컴포저(https://github.com/glowingjade/obsidian-smart-composer)가 더 마음에 들어서 이걸 이용해 글을 작성하고 있습니다.

물론 현재 글은 경험담이라 굳이 AI를 쓰지 않아도 됩니다. 어쨌든, AI를 이용해 글쓰는 속도가 빨라졌는데 옵시디언에서 적은 글을 옵시디언 발행 서비스로 발행하지는 않습니다.(유료니까요.) 기존에 운영하던 고스트 블로그로 보내서 발행하려고 했습니다.

옵시디언 플러그인

당연히 처음에 플러그인을 뒤졌습니다. 옵시디언 커뮤니티 플러그인 중에 고스트로 보내는 기능이 있는 것들이 있습니다.

Send to Ghost

옵시디언 플러그인 - Send to Ghost
  • https://github.com/Southpaw1496/obsidian-send-to-ghost

유일하게 하나 검색되는 플러그인은 Send to Ghost라는 플러그인입니다. 글 발행은 되지만 slug지원 안하고, excerpt도 전달이 안되고, 이미지도 업로드가 불가능합니다.

다른 플러그인

커뮤니티 플러그인에서 검색이 안되는 다른 플러그인을 찾아봤습니다. github주소로 3개를 찾았습니다.

  • https://github.com/techbitsio/obsidian-ghost-publish
  • https://github.com/bramses/obsidian-ghost-publish
  • https://github.com/Hailst0rm1/obsidian-ghost-publish

빌드가 안되어 있는 타입스크립트 코드라 빌드해서 테스트해봤는데 3개 다 제대로 안됐습니다. node.js라는 걸 말만 들었지 처음 깔아봤습니다.

마지막 커밋(업데이트)이 2년전인 코드들은 당연히 안됐고, 하나는 최근에 커밋된 코드지만 제대로 되지 않았습니다.

직접 뜯어고치기

그냥 고스트로 텍스트만 보내고 이미지를 개별 업로드하면서 쓸까, 이미지 전송까지 되도록 직접 고쳐서 쓸까 고민했습니다. 요새는 연말이라고 망년회하는 일도 별로 없고 한가하긴합니다. 그런데 빌드 전 상태에서 타입스크립트로 수정하고 다시 빌드하거나 빌드 후에 자바스크립트를 뜯어 고치거나 해야하는데 자바스크립트를 전혀 몰라서 엄두가 안났습니다.

코드 고르기

마지막 github주소에 있는 플러그인이 기능도 많고, 이미지 업로드를 지원한다고 되어 있어서 그걸로 하려고 했는데 코드가 너무 길고(4만행 넘음) 코드가 별로 아름다워 보이지가 않았습니다. 그래서 제일 처음에 설치했던 Send to Ghost플러그인을 뜯어 고치기로 했습니다.

타입스크립트 or 자바스크립트

둘 다 전혀 모르는 수준이지만 운영하는 웹사이트나 블로그에서 간단한 자바스크립트 수정할 일이 있어서 자바스크립트 코드는 그래도 몇 번 본 기억이 있는데다가 실시간 로그 보면서 수정해야 하는 초보라서 그냥 자바스크립트 상태로 수정하기로 했습니다.

간단한 수정부터

예전에 파이썬으로 티스토리 글을 고스트로 전송하는 스크립트는 만들어봤기 때문에 고스트의 adminAPI로 토큰을 만드는 과정이나 json데이터 전송은 할 줄 알았습니다. 우선 옵시디언 frontmatter의 slug(포스트의 url 뒷부분)전송이나 excerpt(요약)이 전송되지 않는 것 부터 고쳤습니다.

고스트의 태그는 띄어쓰기를 허용하는데 옵시디언 태그는 띄어쓰기를 허용하지 않아서 별도의 태그 항목을 만들었습니다. 옵시디언에서 폴더를 만들고 해당 폴더의 이름을 고스트 태그로 자동 적용하도록 templater를 이용했습니다.

옵시디언 frontmatter

옵시디언 노트에서 위와 같이 프론트메터를 작성하면 고스트로 전달이 됩니다.

function extractFrontmatter(metaMatter, file) {
  return {
    title: metaMatter?.title || file.basename,
    slug: metaMatter?.slug || undefined,
    tags: metaMatter?.ghosttags ? [metaMatter.ghosttags] : [],
    featured: metaMatter?.featured || false,
    status: metaMatter?.published ? "published" : "draft",
    custom_excerpt: metaMatter?.excerpt || undefined,
    feature_image: metaMatter?.feature_image || undefined,
  };
}

원래 코드에서 누락된 slug항목을 추가했고, 요약을 excerpt로 전송하면 고스트가 못알아먹는 현상이 있어서 키값을 custom_excerpt로 변경했습니다. 태그도 별도의 태그 (ghosttags)를 전달하도록 수정했습니다.

이미지 업로드 빡셈

다음으로 이미지 업로드를 구현하려고 했는데 결론은 바로 전송은 포기하고 우회하는 방법을 썼습니다.

이미지 업로드 구현을 위한 삽질

파이썬이나 curl을 이용해서 고스트에 이미지 업로드하는 건 간단합니다. adminAPI로 토큰 얻는 과정이 좀 귀찮지만 토큰만 제대로 얻으면 코드 몇 줄로 끝납니다.

curl -X POST -F 'file=@/mnt/test.png' -F 'ref=/mnt/test.png' -H "Authorization: Ghost $(token)" -H "Accept-Version: v5.0" https://ghosturl.com/ghost/api/admin/images/upload/

그런데 자바스크립트, 특히 옵시디언 플러그인에서는 아니었습니다. 왜 저 많은 플러그인들이 이미지 업로드를 지원하지 않는지 알겠더군요.

Form DATA 전송 실패

옵시디언에서 저장소 내의 이미지 파일 정보를 읽어서 formdata로 전송해야 하는데 기본 formdata 기능으로 전송하면 "an object could not be cloned." 오류가 나거나, 고스트가 못알아 먹고 파일 저장을 못해서 "Please select an image"오류가 나거나, CORS 정책 위반이라고 나옵니다.

나중에 알게 된 사실인데 CORS 정책을 변경해도 파일 정보를 제대로 전달 못하면 CORS 오류로 나오는 이상한 현상이 있습니다. CORS때문에 막힌게 아니라 파일 정보를 제대로 로딩 못하거나 전달 못한건데 CORS 오류로 나옵니다.

이 부분 때문에 8시간 넘게 삽질을 하다가 결국 포기했습니다.

터미널에서 node.js로 전송할 때도 그냥 기본 FormData로 전송하면 안되고 const FormData = require('form-data');로 별도의 패키지 form-data를 사용해야 성공했습니다.

분명히 node.js의 기본 FormData도 멀티파트를 지원한다고 되어 있는데 왜 안되는 건지 전혀 모르겠습니다. 그래서 쌍욕을 했습니다.

옵시디언 플러그인에서 form-data패키지 를 추가하려면 다시 타입스크립트 소스코드 상태에서 form-data패키지를 추가해야 하는데 원래 코드가 pnpm을 이용하는 걸로 되어 있었습니다. node.js도 처음 깔아보고, npm이 뭔지도 모르는 놈이다보니 form-data 패키지를 main.js에 넣는 법을 모르겠습니다. 그래서 또 쌍욕을 하고 다른 방법을 강구했습니다. 해보진 못했지만 아마 form-data를 추가할 수 있으면 고스트로 이미지 전송이 될 거 같습니다.

전송 경유

고스트로 바로 보내기가 안되니 중간에 경유해서 보내기로 했습니다. 요즘에 재미지게 잘 가지고 노는 N8N을 이용해서 경유하기로 했습니다.

옵시디언 이미지를 N8N 서버의 로컬 저장소에 저장 → 성공
N8N 서버의 로컬 이미지를 고스트로 전송 → 성공

이 두개를 테스트 해본 후에 로컬 저장소 저장 없이 바로 전송으로 완성했습니다.

옵시디언 이미지와 추가정보를 N8N으로 전송 → 바이너리 데이터 및 추가정보를 고스트로 전송

N8N에서 뜻밖의 문제 발생

N8N에서 이미지를 경유하던 중 뜻밖의 문제에 봉착했습니다. 이미지를 바이너리 데이터로 전달 할 때 바로 다음 노드까지만 전달 가능하다는 치명적인 단점이 있었습니다. (그래도 다행히 merge, switch 등은 패싱 가능합니다.)

그냥 무작정 업로드면 상관없는데 http요청+if를 이용해 이미 업로드된 이미지는 업로드하지 않도록 하려다 보니 문제가 생겼습니다.

N8N 커뮤니티를 뒤져보니 개발자들이 나중에 해당 기능 지원한다고는 하는데 어느 세월에 될지는 모르겠습니다. 더 뒤져보니 merge를 이용한 얍삽이가 있길래 적용했습니다.

N8N 바이너리 전달 우회

여러 고스트 블로그에 동시 적용

다른 용도로 고스트 블로그를 하나 더 만들어서 총 2개를 운영중입니다. workflow를 하나 더 만들까 했는데 다행히 switch도 바이너리 패싱이 되길래 하나에 통합해서 넣었습니다. 사이트 구분을 위해 옵시디언에서 이미지만 전송하는게 아니고 추가정보(사이트URL, filename)도 전송하도록 수정했습니다.

완성된 이미지 전송함수

js로 코드를 처음 짜봤는데 코드 순서에 따라 실행여부가 지랄맞아서 설정값을 다른 함수에서도 쉽게 불러오기 위해 그냥 전역변수로 만들어 버렸습니다. 고스트로 바로 이미지 전송 실패해서 어차피 배포는 포기했기 때문에 "어떻게든 돌아가기만 하면 되는 거 아냐?"라는 생각밖에 없었습니다. (이래서 파이썬으로 시작한 놈들은...)

업로드 전에 기존에 업로드된 파일이 있는지 체크하는데 비동기로 실행되다보니 파일이 업로드되는 중간에 또 확인을 하다보니 중복 업로드가 되는 일이 있었습니다. (하나의 이미지를 여러 곳에서 쓰거나 하는 등의 경우) 그래서 전송 요청 전에 1~5초를 랜덤으로 대기하도록 했습니다.

// 시간 대기 함수(1~5초 랜덤)
// 사용법 await sleep();
function sleep() {
  const randomSeconds = Math.floor(Math.random() * 5) + 1; // 1에서 5초 사이의 랜덤 값
  return new Promise(resolve => setTimeout(resolve, randomSeconds * 1000));
}

/* N8N으로 이미지 업로드
 */
const fs = require('fs').promises; // fs.promises를 사용하여 비동기 파일 읽기
const path = require('path');

async function imageUpload2Ghost(fileName) {
  const filePath = `${IMAGE_PATH}/${fileName}`;
  const basePath = app.vault.adapter.getBasePath();  
  const filePathab = path.join(basePath, IMAGE_PATH, fileName);

  // 파일 경로가 존재하는지 확인(상대경로 넣어야 함)
  const file = app.vault.getAbstractFileByPath(filePath);
  // console.log('file:', file)

  if (!file) {
    console.error(`${filePath} 파일을 찾을 수 없습니다.`);
    return; // 파일을 찾을 수 없으면 함수 종료
  } else {
    console.log(`${filePath} 파일을 업로드합니다.`);
  }

  // 파일 확장자 확인
  const fileExtension = file.extension ? file.extension.toLowerCase() : '';
  // console.log('확장자:', fileExtension);

  try {
    // 랜덤 1초~5초 시간대기
    await sleep();
        
    // 파일 경로에서 파일 내용을 읽기 (비동기 처리)
    const fileContent = await fs.readFile(filePathab); // 파일이 존재하면 절대 경로로 파일 읽기
    // console.log('파일 읽기 완료:', fileContent);

    const contentType = "image/png"; // 파일 타입을 설정 (여기서는 png로 예시)
    const fileBlob = new Blob([fileContent], { type: contentType });

    // FormData 생성
    const formData = new FormData();
    formData.append("file", fileBlob, fileName); // 파일과 함께 이름 추가
    formData.append("purpose", "image");
    formData.append("ref", filePath); // 참조용 파일 경로 추가
    formData.append("site", SITE_URL); // 고스트 URL 정보 추가
    formData.append("filename", fileName); // 파일명 정보 추가

    // API 요청을 보내기
    const response = await fetch(`${N8N_URL}`, {
      method: "POST",
      headers: {        
        "Accept-Version": "v5.0",
      },
      body: formData,
    });

    // 응답 처리
    if (response.ok) {
      const data = await response.json();
      console.log('Upload Success:', data);
    } else {
      console.error('Error uploading image:', response.status, response.statusText);
      const errorDetails = await response.text();
      console.error('Error Details:', errorDetails);
    }
  } catch (err) {
    console.error("파일을 읽거나 업로드하는 중에 오류 발생:", err.name, err.message);
    if (err.stack) console.error(err.stack);
  }
}

옵시디언에서 의외의 태클

이쯤하면 잘될법도 한데 옵시디언에서 마크다운 형식 관련해서 또 다른 태클이 들어왔습니다.

이미지 파일에 대한 마크다운 표준 형식은 다음과 같습니다.

![설명](url)

그런데 옵시디언에서 이미지를 드래근 하거나 첨부파일을 불러올 경우 다음과 같이 나왔습니다.

![[파일명]]

알아보니 마크다운을 HTML로 바꿀 때 markdown-it이라는 놈이 마크다운 코드를 분류해서 text인지, link인지, image인지 판별한다는데 코드를 고칠 수가 없었습니다.

다행히 옵시디언에서 표준 형식으로 마크다운을 작성하는 옵션이 있길래 적용했습니다.

설정 - 파일 및 링크 - [[wikilink]] 기능 끄기

마크다운-HTML 변환시 이미지 주소 수정

고스트에서 API를 이용해 이미지를 업로드할 때 /content/images/현재연도/현재월/파일명과 같이 업로드되기 때문에 HTML 변환시 이미지 src 주소를 자동으로 변환하도록 했습니다. 이 부분은 코드가 길어서 생략.

테스트 중

이제 베타 테스트는 이만하면 됐다 판단해서 직접 포스팅을 하면서 테스트를 하고 있습니다. 현재는 이미지 파일만 지원하는데 가능하다면 다른 형식의 파일도 업로드할 수 있도록 고쳐볼까 합니다.

JS 코딩, 옵시디언 플러그인 개조 후기

파이썬만 쓰다가 js보니 코드도 더럽게 생긴게 이상한 부분에서 까탈스러웠습니다. 저같은 X밥들도 수정할 수 있는 인터프리터 언어 주제에 빠르다는건 신기하고, 활용도가 커서 이런저런 짓도 할 수 있겠구만 라는 느낌이 들긴 했습니다. 그치만 이걸로 밥벌어먹고 살것도 아니니 별로 마주하고 싶지 않은 놈이란 결론을 내렸습니다.

태그

BoniK

협업, 의뢰, 레슨 등 문의 : mail@bonik.me, open.kakao.com/me/bonik