노트앱 노마드는 이제 그만! - 노트 처리봇 만들기 (2): 노트 처리 자동화

지난 시간에 이어, 노트처리봇 만들기를 계속 진행해보겠습니다.

참고: #노트앱 노마드는 이제 그만! - 노트 처리봇 만들기 (1): 메타데이터 자동생성 프롬프팅

이제 메타데이터 생성을 잘 해주도록 프롬프트를 짰으니, 노트를 처리하는 과정을 자동화 시켜보는 걸 해보겠습니다.

제가 원하는 것:

- 처리하고 싶은 노트들을 제가 하나의 폴더(A)에 모아둡니다.

- 처리한 노트들은 새로운 폴더(B)에 모아둡니다. 처리된 노트는 폴더(A)에서 제거합니다.

- 처리는 두 단계로 이루어집니다.

- 1) 모든 형식의 파일을 모두 마크다운 형식의 파일로 변환합니다.

- 2) 메타데이터를 생성하고 삽입해서 새로운 파일을 만듭니다.

- 이 모든 과정을 스트림릿으로 구현합니다. 제가 폴더 A, B의 경로를 입력하면 '노트봇'이 이상의 작업들을 쭉 수행할 수 있게요.

먼저 제가 원하는 과정을 단계별로 상세하게 설명하고, 클로드에게 코드를 짜달라고 했습니다.

한꺼번에 코드를 주면 제가 이해를 못하기도 하고, 또 나중에 수정할 때 어렵더라고요.

그래서 단계별로 코드를 짜서 나중에 합치려고, 차근차근 가이드해달라고 했습니다.

그랬더니 코드를 잘 짜주었습니다. 우선 성공한 샷 보여드릴게요!

제가 파일을 넣어뒀던 폴더는 처리 안된 파일 빼고 싹 비워져 있습니다. 처리에 실패한 파일은 그냥 두라고 했거든요. 보아하니 .pages 확장자는 처리를 못하는 모양이군요. 나중에 고쳐야겠습니다.

그리고 새로운 노트들은 아예 옵시디언 Inbox 폴더로 받았습니다. 메타데이터 형식을 “- - -”로 시작하게 만들어서 노트의 제일 첫 줄에 넣어달라고 했거든요. 이렇게 해야 옵시디언에서 바로 property로 형식이 바뀌니까요! 잘 처리된 모양입니다. 모든 노트가 이런식으로 가지런히 쫙 정리되었습니다!


아래는 최종 코드입니다.

import os
import subprocess
import shutil
from pathlib import Path
from pdfminer.high_level import extract_text
from pdfminer.layout import LAParams
from openai import OpenAI
from dotenv import load_dotenv
import streamlit as st

# .env 파일에서 환경 변수 로드
load_dotenv()

# OpenAI 클라이언트 초기화
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))


def convert_to_markdown(input_file: str) -> str:
    input_path = Path(input_file)
    output_path = input_path.with_suffix('.md')

    if input_path.suffix.lower() == '.md':
        return str(input_path)

    try:
        if input_path.suffix.lower() == '.hwp':
            temp_txt = output_path.with_suffix('.txt')
            subprocess.run(['hwp5txt', str(input_path), '--output', str(temp_txt)], check=True)
            temp_txt.rename(output_path)
        elif input_path.suffix.lower() == '.hwpx':
            raise ValueError("HWPX 형식은 지원되지 않습니다. HWP 형식으로 저장한 후 다시 시도해주세요.")
        elif input_path.suffix.lower() == '.docx':
            subprocess.run(['pandoc', str(input_path), '-o', str(output_path)], check=True)
        elif input_path.suffix.lower() == '.pdf':
            text = extract_text(str(input_path), laparams=LAParams())
            with open(output_path, 'w', encoding='utf-8') as f:
                f.write(text)
        elif input_path.suffix.lower() == '.pages':
            temp_docx = output_path.with_suffix('.docx')
            subprocess.run(['textutil', '-convert', 'docx', '-output', str(temp_docx), str(input_path)], check=True)
            subprocess.run(['pandoc', str(temp_docx), '-o', str(output_path)], check=True)
            temp_docx.unlink()
        else:
            raise ValueError(f"지원되지 않는 파일 형식: {input_path.suffix}")

        if not output_path.exists():
            raise FileNotFoundError(f"출력 파일 생성 실패: {output_path}")

        return str(output_path)
    except Exception as e:
        print(f"Error converting {input_path}: {e}")
        return None


def generate_metadata(content):
    prompt = """
    [프롬프트 내용 생략. 프롬프트가 궁금하신 분은 이전 게시글을 참고해주세요.]
    """
    try:
        response = client.chat.completions.create(
            model="gpt-4o",
            messages=[
                {"role": "system", "content": prompt},
                {"role": "user", "content": content}
            ],
            temperature=0.2,
            max_tokens=1000
        )
        return response.choices[0].message.content
    except Exception as e:
        print(f"Error generating metadata: {e}")
        return None


def insert_metadata(file_path, metadata):
    try:
        with open(file_path, 'r', encoding='utf-8') as file:
            content = file.read()

        with open(file_path, 'w', encoding='utf-8') as file:
            file.write(f"{metadata}\n\n{content}")
        return True
    except Exception as e:
        print(f"Error inserting metadata to {file_path}: {e}")
        return False


import os


import os
import shutil

def rename_file_with_title(file_path, output_folder):
    try:
        with open(file_path, 'r', encoding='utf-8') as file:
            content = file.read()

        # 메타데이터 섹션 찾기
        metadata_start = content.find('---')
        metadata_end = content.find('---', metadata_start + 3)
        if metadata_start != -1 and metadata_end != -1:
            metadata_content = content[metadata_start:metadata_end]

            # 메타데이터에서 title 찾기
            title_line = next((line for line in metadata_content.split('\n') if line.startswith('title:')), None)
            if title_line:
                title = title_line.split(':', 1)[1].strip()
                # 파일명에 사용할 수 없는 문자 제거
                title = "".join(c for c in title if c not in r'\/:*?"<>|')
                new_file_name = f"{title}.md"
            else:
                new_file_name = os.path.basename(file_path)
                print(f"No title found in metadata for {file_path}")
        else:
            new_file_name = os.path.basename(file_path)
            print(f"No metadata found for {file_path}")

        # 새 파일 경로 생성 (출력 폴더 + 새 파일명)
        new_file_path = os.path.join(output_folder, new_file_name)

        # 동일한 이름의 파일이 이미 존재하는 경우 처리
        counter = 1
        base_name, ext = os.path.splitext(new_file_name)
        while os.path.exists(new_file_path):
            new_file_name = f"{base_name} ({counter}){ext}"
            new_file_path = os.path.join(output_folder, new_file_name)
            counter += 1

        # 파일 이동 및 이름 변경
        shutil.move(file_path, new_file_path)
        return new_file_path

    except Exception as e:
        print(f"Error renaming and moving {file_path}: {e}")
        return file_path

def process_folder(input_folder: str, output_folder: str):
    supported_extensions = ['.hwp', '.docx', '.pdf', '.pages', '.md']
    processed_files = []

    # 출력 폴더 생성
    os.makedirs(output_folder, exist_ok=True)

    for root, _, files in os.walk(input_folder):
        for file in files:
            input_file = os.path.join(root, file)
            if any(file.lower().endswith(ext) for ext in supported_extensions):
                try:
                    # 1. 마크다운으로 변환
                    converted_file = convert_to_markdown(input_file)
                    if not converted_file:
                        print(f"Failed to convert: {input_file}")
                        continue  # 원본 파일을 그대로 둠

                    # 2. 메타데이터 생성
                    with open(converted_file, 'r', encoding='utf-8') as f:
                        content = f.read()
                    metadata = generate_metadata(content)

                    if not metadata:
                        print(f"Failed to generate metadata: {converted_file}")
                        if converted_file != input_file:
                            os.remove(input_file)  # 원본 파일 삭제
                        continue  # 변환된 마크다운 파일을 그대로 둠

                    # 3. 메타데이터 삽입
                    if not insert_metadata(converted_file, metadata):
                        print(f"Failed to insert metadata: {converted_file}")
                        if converted_file != input_file:
                            os.remove(input_file)  # 원본 파일 삭제
                        continue  # 변환된 마크다운 파일을 그대로 둠

                    # 4. 파일명 변경 및 새 폴더로 이동
                    new_file_path = rename_file_with_title(converted_file, output_folder)

                    print(f"Successfully processed and moved: {new_file_path}")

                    # 숨김 파일이 아닌 경우에만 리스트에 추가
                    if not os.path.basename(new_file_path).startswith('.'):
                        processed_files.append(os.path.basename(new_file_path))

                    # 5. 원본 파일 삭제
                    if os.path.exists(input_file) and input_file != converted_file:
                        os.remove(input_file)

                except Exception as e:
                    print(f"Error processing {input_file}: {e}")

    return processed_files

# Streamlit 인터페이스 구현
st.title("노트봇")

st.header("폴더 경로 설정")
input_folder = st.text_input("처리할 문서가 있는 폴더 경로(A)")
output_folder = st.text_input("처리된 마크다운 파일을 저장할 폴더 경로(B)")

if st.button("처리 시작"):
    if not input_folder or not output_folder:
        st.error("모든 폴더 경로를 입력해주세요.")
    else:
        processed_files = process_folder(input_folder, output_folder)
        st.success("처리가 완료되었습니다!")

        # 처리 결과 표시 (숨김 파일 제외)
        st.write("처리된 파일 목록:")
        for file in processed_files:
            st.write(file)



물론 과정은 아름답지 않았습니다...ㅋㅋㅋㅋㅋ

길고 길었던 코드 수정의 여정… 그중 두 가지 지점만 공유해볼게요.

셀레니움으로 로그인해서 클로드 대화창을 통해 작업을 수행하게 하기: 실패

지난 시간 #11기문과생도AI 수업에서 배운대로 셀레니움을 활용해서 클로드에 로그인 상태를 유지시키려고 했는데, 몇 시간에 걸친 고투 끝에 실패했습니다... 네이버는 로그인이 되는데, 클로드에서는 안되더라구요. 크롬 프로필 경유도 해보고, 2단계 보안인증 설정도 지우고 별짓을 다해봤는데, 실패했습니다....

API Key를 이용하지 않고 셀레니움으로 로그인 시킨 상태에서 대화창을 통해 메타데이터를 생성시키려고 했던 이유는 클로드 3.5 소넷을 활용하고 싶어서였습니다. 아직 Claude API에서는 클로드 3 소넷까지밖에 지원을 안해주더라구요. (클로드에도 API Key가 있다는 걸 알려주신 구요한 님께 감사...)

결국 API Key를 이용하는 방식으로 코드를 짜기로 했습니다. 그래서 아쉽지만 클로드는 버리고 챗지로 다시 돌아왔습니다. API를 쓰려고 하면, 클로드 3이냐 지피티 4o냐의 경쟁인데, 그럼 당연히 후자의 성능이 더 좋으니까요.

새로운 폴더로 처리한 파일을 옮기는 과정에서의 문제: 해결

문서 형식을 변환하고 메타데이터 생성/삽입까지 했다고 뜨는데, 제가 지정한 새로운 폴더(B)를 열어보면 아무것도 없더라구요. 파일을 변환하고 옮기고 기존 파일을 지우고 등등 여러 단계를 수행하는 과정에서 뭔가 충돌이 있었던 것 같습니다. 스트림릿 돌리고 지켜보니까 새로운 폴더에 파일을 생성했다가 지우더라고요...ㅎㅎ 이걸 수정하기 위해 여러번 대화를 했는데, 대화가 누적될수록 코드는 이상해지고...

그래서 그냥 다시 명확하게 말했습니다. 컴퓨터식 사고법으로다가... 그랬더니 무사히 성공했습니다.


여기까지입니다!!

이제 스트림릿에다가 개별 노트를 업로드해도 처리해달라는 요청만 추가해보려고 해요~

원하시는 분들이 계시다면, 노트처리 ‘프롬프트’를 직접 입력하는 칸을 만들어서 스트림릿 버전을 배포할게요~ (박정기 님이 스트림릿 배포하는 방법 알려주셔서 시도해보려구요!)

필요하신 분이 있다면 댓글로 달아주세요!


감사합니다.


#11기연구지식관리 #11기문과생도AI

8
4개의 답글

👉 이 게시글도 읽어보세요