[2. 공부]/[2.3 AI]

자동 발행(GAS+OpenAI): 하루 3편 안전하게 내보내는 파이프라인

경이경이:) 2025. 11. 16. 15:11
반응형

핵심은 “많이 내보내기”가 아니라, 품질 게이트를 통과한 글만 정해진 시각에 꾸준히 발행하는 것입니다. 이 글은 Google Apps Script(이하 GAS) + OpenAI API + WordPress REST API로 하루 3편을 안정적으로 발행하는 프로덕션 규격을 제시합니다.

개요와 원칙

원칙 1) 초안→검수→발행 단계를 코드로 분리합니다.
원칙 2) “표 1개(버퍼 행 포함), FAQ 3개, 태그 10개, 내부링크 2개”를 품질 게이트로 정의합니다.
원칙 3) 발행 실패 대비: 재시도·중복 차단·원클릭 롤백을 내장합니다.

시트 스키마(콘텐츠 대장)

복사-붙여넣기 시 헤더 유실 방지를 위해 첫 행에 숫자 버퍼(1·2·3)를 둡니다.

1 2 3 4 5 6 7 8 9 10 11 12
id status publish_at(KST) category title slug keywords outline content_html meta_hash wp_post_id log
2025-11-13-01 ready 2025-11-13 09:30 GPT 워크플로우 버퍼 행 표 포맷 buffer-row-table 표, 복붙, 헤더 H2/H3 개요 <h2>...</h2> abc123...   생성/검수 로그

status: idea/draft/ready/published/error. publish_at은 Asia/Seoul 기준.

비밀 관리와 환경변수

GAS에서 스크립트 속성(PropertiesService)을 사용합니다. 키 예시는 아래와 같습니다.

OPENAI_API_KEY=sk-******** WP_BASE_URL=https://example.com WP_POSTS_URL=https://example.com/wp-json/wp/v2/posts WP_JWT_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... // 또는 Application Password 사용 시: WP_APP_USER, WP_APP_PASS DEFAULT_CATEGORY_ID=12 DEFAULT_TAG_IDS= // 콤마로 구분된 숫자 목록(선택) TIMEZONE=Asia/Seoul

GAS 핵심 코드(요약)

1) OpenAI로 초안 생성

// File: content_generator.gs function generateDraft_(outline, keywords) { const key = PropertiesService.getScriptProperties().getProperty('OPENAI_API_KEY'); const url = 'https://api.openai.com/v1/chat/completions'; const sys = "You are a professional Korean blog writer. Keep sentences concise. Include at least one HTML table with a buffer row (1,2,3), three FAQs, and ten tags. No emojis."; const user = `키워드: ${keywords}\n개요:\n${outline}\n요구: H2/H3 구조, 내부링크 2개, 표 1개(버퍼 행), FAQ 3, 태그 10개, 1,800~2,300단어 HTML`; const payload = { model: "gpt-4o-mini", messages: [{role:"system", content:sys},{role:"user", content:user}], temperature: 0.7 }; const resp = UrlFetchApp.fetch(url, { method: "post", contentType: "application/json", headers: { Authorization: `Bearer ${key}` }, payload: JSON.stringify(payload), muteHttpExceptions: true }); const data = JSON.parse(resp.getContentText()); if (!data.choices) throw new Error("OpenAI 응답 오류: " + resp.getContentText()); return data.choices[0].message.content; }

2) 품질 게이트 검사(정규식 기반 라이트 버전)

// File: quality_gate.gs function passesGate_(html) { const hasTable = /<table[\s\S]*<thead>[\s\S]*<tr>\s*<th>1<\/th>/i.test(html); const hasFAQ = /<h2>.*FAQ|자주 묻는 질문.*<\/h2>/i.test(html) && (html.match(/<h3>/g)||[]).length >= 3; const hasTags = /<div class="tags">[\s\S]*<span class="tag"/i.test(html) || /태그[:]/i.test(html); const hasInternalLinks = (html.match(/href="\/|rel="internal"/g)||[]).length >= 2; const minWords = html.replace(/<[^&]*>/g," ").trim().split(/\s+/).length >= 800; // 대략 체크 return hasTable && hasFAQ && hasTags && hasInternalLinks && minWords; }

3) WordPress로 게시

// File: publisher_wp.gs function postToWordPress_(title, slug, html, categoryId, tagIds) { const postsUrl = PropertiesService.getScriptProperties().getProperty('WP_POSTS_URL'); const token = PropertiesService.getScriptProperties().getProperty('WP_JWT_TOKEN'); const payload = { title: title, slug: slug, content: html, status: "publish", // "draft"로 두고 사람이 승인 후 발행도 가능 categories: [Number(categoryId)], tags: (tagIds||"").split(",").map(x=>Number(x.trim())).filter(Boolean) }; const resp = UrlFetchApp.fetch(postsUrl, { method: "post", contentType: "application/json", headers: { Authorization: `Bearer ${token}` }, payload: JSON.stringify(payload), muteHttpExceptions: true }); const code = resp.getResponseCode(); if (code >= 400) throw new Error("WP 게시 실패: " + resp.getContentText()); const data = JSON.parse(resp.getContentText()); return data.id; // wp_post_id }

4) 메인 플로우: 한 건 출고

// File: pipeline.gs function publishOne() { const ss = SpreadsheetApp.getActive().getSheetByName('posts'); const tz = PropertiesService.getScriptProperties().getProperty('TIMEZONE') || Session.getScriptTimeZone(); const now = new Date(); const rows = ss.getDataRange().getValues(); // 1행 버퍼, 2행 헤더 가정 for (let i=2;i<rows.length;i++){ const [id,status,publishAt,category,title,slug,keywords,outline,content,metaHash,wpId,log] = rows[i]; if (status!=="ready") continue; if (publishAt && new Date(publishAt) > now) continue; const draft = content || generateDraft_(outline, keywords); if (!passesGate_(draft)) { ss.getRange(i+1,2).setValue("error"); ss.getRange(i+1,12).setValue("Gate fail"); continue; } const checksum = Utilities.base64Encode(Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, (title+slug+draft))); if (metaHash && metaHash===checksum) { ss.getRange(i+1,2).setValue("skipped"); continue; } // 중복 방지 const wpPostId = postToWordPress_(title, slug, draft, PropertiesService.getScriptProperties().getProperty('DEFAULT_CATEGORY_ID'), PropertiesService.getScriptProperties().getProperty('DEFAULT_TAG_IDS')); ss.getRange(i+1,2).setValue("published"); ss.getRange(i+1,10).setValue(checksum); ss.getRange(i+1,11).setValue(wpPostId); ss.getRange(i+1,12).setValue(`OK ${new Date().toISOString()}`); return; // 1건만 처리 } }

5) 롤백(최근 게시물 취소/휴지통)

// File: rollback_wp.gs function rollbackLast() { const ss = SpreadsheetApp.getActive().getSheetByName('posts'); const postsUrl = PropertiesService.getScriptProperties().getProperty('WP_POSTS_URL'); const token = PropertiesService.getScriptProperties().getProperty('WP_JWT_TOKEN'); const lastRow = ss.getLastRow(); const wpId = ss.getRange(lastRow, 11).getValue(); if (!wpId) throw new Error("최근 행에 wp_post_id 없음"); const url = postsUrl + "/" + wpId + "?force=true"; // 휴지통 이동 const resp = UrlFetchApp.fetch(url, { method:"delete", headers:{ Authorization:`Bearer ${token}` }, muteHttpExceptions:true }); if (resp.getResponseCode() >= 400) throw new Error("롤백 실패: " + resp.getContentText()); ss.getRange(lastRow,2).setValue("rolled_back"); }

WordPress 게시 API 요점

  • 엔드포인트: /wp-json/wp/v2/posts
  • 필드: title, content, status(publish/draft), categories, tags, slug
  • 인증: JWT 토큰 또는 Application Password 중 택1
  • 중복 방지: ?search=로 유사 제목 검색 또는 meta_hash를 커스텀 필드로 저장해 비교

품질 게이트(라이트) 규칙

1 2 3 4
항목 합격 기준 자동 점검 비고
최소 1개 <thead>에 1·2·3 버퍼 확인 복붙 호환성
FAQ 3문항 H2=FAQ, H3≥3 명확한 1-2문장 답
태그 10개 .tags 또는 “태그:” 문자열 카테고리와 중복 지양
내부링크 2개 href="/" 또는 rel="internal" 허브-스포크 연결
길이 ≈ 1,800단어 태그 제거 후 토큰 수 요약·예시 포함

재시도·중복 방지·롤백

재시도

// File: backoff.gs function fetchWithBackoff_(fn, max=4){ for (let i=0;i<max;i++){ try { return fn(); } catch(e){ Utilities.sleep(Math.pow(2,i)*500); // 0.5s,1s,2s,4s if (i===max-1) throw e; } } }

중복 방지

meta_hash에 제목+슬러그+본문 해시를 저장하고 동일하면 스킵합니다. 필요 시 WP 커스텀 필드(meta)에도 저장해 서버 측에서 2차 확인합니다.

롤백

최근 게시물의 wp_post_idDELETE /posts/{id}?force=true 호출. 시트 status를 rolled_back으로 변경합니다.

스케줄러(하루 3편, KST)

Apps Script 프로젝트 설정에서 시간대(Asia/Seoul)로 맞춘 뒤, 3개의 트리거를 생성합니다.

// File: triggers.gs function setupTriggers(){ const hours = [9, 13, 17]; // 09:30, 13:30, 17:30 hours.forEach(h=>{ ScriptApp.newTrigger('publishOne') .timeBased().atHour(h).nearMinute(30).everyDays(1).create(); }); }

publishOne은 호출당 1건만 처리합니다. 여러 건을 한 번에 내보내고 싶다면 publishBatch(n)를 별도로 구현하세요.

출고 체크리스트(버퍼 행 포함)

1 2 3 4
구분 확인 항목 합격 기준 메모
비밀 관리 API Key/토큰 스크립트 속성 사용 코드 내 하드코딩 금지
포맷 표/FAQ/태그/내부링크 게이트 충족 버퍼 행 확인
스케줄 트리거 3개 09:30/13:30/17:30 KST
로그 시트 log 필드 에러 메시지 기록 재현 가능성 확보
롤백 최근 게시물 ID rollbackLast 동작 테스트 1회

 

FAQ

JWT 대신 Application Password를 쓰면?

가능합니다. Authorization: Basic base64(user:app_password)로 교체하고 HTTPS를 강제하세요.

이미지 생성은 제외해도 되나요?

네. 본문 품질 관리가 우선입니다. 이미지가 필요하면 편집 단계에서 수동 삽입을 권장합니다.

초안 품질을 더 올리려면?

브리프(키워드·검색의도·H2/H3)를 시트에 먼저 채우고, 모델에는 “근거/의견 분리, 숫자 예시, 내부링크 앵커”를 명시하세요.

반응형