# core/platforms/base_tistory.py
# Version: 1.3.2
# Last Updated: 2025-11-14
# Description: 티스토리 플랫폼 자동화 클래스 - 카카오 계정을 통한 로그인
import sys
import time
import logging
import os
import re
import requests
from pathlib import Path
from selenium.common.exceptions import TimeoutException, NoSuchElementException
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.alert import Alert
from core.platforms.base_platform import BasePlatform, PostingResult
class BaseTistoryPlatform(BasePlatform):
"""티스토리 플랫폼 기본 클래스"""
def __init__(self, site_name: str, site_config: dict, chrome_manager):
"""
티스토리 플랫폼 초기화
Args:
site_name: 사이트 이름 (econmingle, withcar, reportera 등)
site_config: 사이트 설정 딕셔너리
chrome_manager: Chrome 드라이버 관리자
"""
self.chrome_manager = chrome_manager
self.site_name = site_name
self.site_config = site_config
# 티스토리 전용 드라이버 생성
driver = chrome_manager.get_or_create_driver('tistory')
if not driver:
raise Exception("티스토리 플랫폼용 드라이버를 생성할 수 없습니다.")
# Airtable 관리자 로드
airtable_manager = self._get_airtable_manager(site_name, site_config)
# BasePlatform 초기화
super().__init__(driver, site_config, airtable_manager, "tistory")
# 이미지 다운로드 임시 폴더 설정
self.temp_upload_dir = Path(__file__).parent.parent.parent / "temp_tistory_uploads"
self.temp_upload_dir.mkdir(exist_ok=True)
# 티스토리 관련 설정
self.tistory_url = "https://www.tistory.com/"
self.login_url = "https://www.tistory.com/"
# 셀렉터 정의
self.selectors = {
'start_button': '#mArticle > div > div.marticle_right > div > div.my_tistory.box_mylogin > a',
'kakao_login_button': 'body > div.login_layer.login_page > div > div > a.btn_login.link_kakao_id',
'kakao_id_input': "input[name='loginId']",
'kakao_password_input': "input[name='password']",
'kakao_submit_button': "button.submit",
# 로그인 상태 확인용
'my_tistory_logged_in': 'a.link_login',
# 글쓰기 관련
'write_button': '#mArticle > div > div.marticle_right > div > div.my_tistory.border_box > div.wrap_link > a:nth-child(1)',
'write_button_xpath': '//*[@id="mArticle"]/div/div[2]/div/div[1]/div[2]/a[1]',
'title_input': '#post-title-inp',
'title_input_xpath': '//*[@id="post-title-inp"]',
'content_iframe': [
'iframe', # 성공한 셀렉터를 맨 위로 배치
'iframe[id*="tinymce"]',
'iframe[src*="tinymce"]',
'iframe.mce-content-body',
],
'content_input': [
'#tinymce',
'body#tinymce',
'//*[@id="tinymce"]',
'//body[@id="tinymce"]',
],
# h2 스타일 설정 관련
'format_button': ['#mceu_1-open', '//*[@id="mceu_1-open"]'],
'h2_option': ['#mceu_37', '//*[@id="mceu_37"]'],
# 이미지 업로드 관련
'attach_button': ['#mceu_0-open', '//*[@id="mceu_0-open"]'],
'image_button': ['#attach-image', '//*[@id="attach-image"]'],
# 이미지 캡션 관련
'image_caption': ['#tinymce > figure > figcaption', '//*[@id="tinymce"]/figure/figcaption'],
}
# 로거 설정
self._setup_logger()
# 환경변수에서 카카오 계정 정보 로드
self._load_kakao_credentials()
self.logger.info(f"✅ {site_name} 티스토리 플랫폼 초기화 완료")
def _setup_logger(self):
"""로거 설정"""
self.logger.handlers.clear()
self.logger.propagate = False
log_format = self.site_config.get('logging', {}).get('format',
'%(asctime)s - %(name)s - %(levelname)s - %(message)s')
formatter = logging.Formatter(log_format)
# 콘솔 핸들러
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
console_handler.setLevel(logging.INFO)
self.logger.addHandler(console_handler)
# 파일 핸들러 (티스토리 전용 로그)
log_dir = f"logs/{self.site_name}"
os.makedirs(log_dir, exist_ok=True)
file_handler = logging.FileHandler(f"{log_dir}/tistory.log", encoding='utf-8')
file_handler.setFormatter(formatter)
file_handler.setLevel(logging.INFO)
self.logger.addHandler(file_handler)
self.logger.setLevel(logging.INFO)
def _load_kakao_credentials(self):
"""환경변수에서 카카오 계정 정보를 로드합니다."""
from dotenv import load_dotenv
env_file = self.site_config.get('airtable', {}).get('env_file', f"env/{self.site_name}.env")
if os.path.exists(env_file):
load_dotenv(env_file, override=True)
self.logger.info(f"� 환경변수 파일 로드: {env_file}")
# 환경변수 prefix 설정
if self.site_name == "withcar":
env_prefix = "WITHCAR"
elif self.site_name == "econmingle":
env_prefix = "ECONMINGLE"
elif self.site_name == "reportera":
env_prefix = "REPORTERA"
elif self.site_name == "kookbangtimes":
env_prefix = "KOOKBANG"
else:
env_prefix = self.site_name.upper()
# 티스토리 카카오 계정 정보 로드
self.kakao_id = os.getenv(f"{env_prefix}_TISTORY_KAKAO_ID", "")
self.kakao_password = os.getenv(f"{env_prefix}_TISTORY_KAKAO_PASSWORD", "")
if self.kakao_id:
self.logger.info(f"� 카카오 자동 로그인 계정 설정됨")
else:
self.logger.info(f"� 카카오 수동 로그인 모드")
def _get_airtable_manager(self, site_name: str, site_config: dict):
"""사이트별 Airtable 관리자 가져오기"""
import importlib
try:
site_module_path = f"sites.{site_name}"
airtable_module = importlib.import_module(f"{site_module_path}.{site_name}_airtable")
AirtableManager = getattr(airtable_module, 'AirtableManager')
return AirtableManager(site_config)
except Exception as e:
self.logger.error(f"❌ Airtable 관리자 로드 실패: {e}")
return None
def get_platform_specific_urls(self) -> dict:
"""티스토리 관련 URL 반환"""
return {
'login_url': self.login_url,
'main_url': self.tistory_url,
}
def check_login_status(self) -> bool:
"""
티스토리 로그인 상태 확인
Returns:
로그인 여부
"""
try:
self.logger.info("� 티스토리 로그인 상태를 확인합니다...")
# 현재 URL 저장
original_url = self.driver.current_url
# 티스토리 메인 페이지로 이동
if 'tistory.com' not in original_url:
self.driver.get(self.tistory_url)
time.sleep(3)
# ✨ 글쓰기 버튼 존재 확인 (클릭하지 않음)
try:
# CSS 셀렉터로 글쓰기 버튼 찾기
write_button = self.driver.find_element(By.CSS_SELECTOR, self.selectors['write_button'])
if write_button.is_displayed():
self.logger.info("✅ 글쓰기 버튼 발견 - 로그인 상태 확인")
return True
except NoSuchElementException:
pass
except Exception as e:
self.logger.debug(f"CSS 셀렉터로 글쓰기 버튼 찾기 실패: {e}")
# XPath로 글쓰기 버튼 찾기 시도
try:
write_button = self.driver.find_element(By.XPATH, self.selectors['write_button_xpath'])
if write_button.is_displayed():
self.logger.info("✅ 글쓰기 버튼 발견 (XPath) - 로그인 상태 확인")
return True
except NoSuchElementException:
pass
except Exception as e:
self.logger.debug(f"XPath로 글쓰기 버튼 찾기 실패: {e}")
self.logger.info("❌ 로그인 필요")
return False
except Exception as e:
self.logger.error(f"❌ 로그인 상태 확인 실패: {str(e)}", exc_info=True)
return False
def perform_login(self) -> bool:
"""
티스토리 로그인 수행 (카카오 계정 사용)
Returns:
로그인 성공 여부
"""
self.logger.info("� 티스토리 로그인 프로세스 시작")
try:
# 1. 티스토리 메인 페이지로 이동
self.logger.info("� 티스토리 메인 페이지로 이동...")
self.driver.get(self.tistory_url)
time.sleep(3)
# 이미 로그인되어 있는지 확인
if self.check_login_status():
self.logger.info("✅ 이미 로그인되어 있습니다.")
return True
# 2. "시작하기" 버튼 클릭
self.logger.info("�️ '시작하기' 버튼 클릭...")
try:
start_button = WebDriverWait(self.driver, 10).until(
EC.element_to_be_clickable((By.CSS_SELECTOR, self.selectors['start_button']))
)
start_button.click()
time.sleep(2)
self.logger.info("✅ '시작하기' 버튼 클릭 완료")
except TimeoutException:
self.logger.error("❌ '시작하기' 버튼을 찾을 수 없습니다")
return False
# 3. "카카오계정으로 로그인" 버튼 클릭
self.logger.info("� '카카오계정으로 로그인' 버튼 클릭...")
try:
kakao_button = WebDriverWait(self.driver, 10).until(
EC.element_to_be_clickable((By.CSS_SELECTOR, self.selectors['kakao_login_button']))
)
kakao_button.click()
time.sleep(3)
self.logger.info("✅ '카카오계정으로 로그인' 버튼 클릭 완료")
except TimeoutException:
self.logger.error("❌ '카카오계정으로 로그인' 버튼을 찾을 수 없습니다")
return False
# 4. 카카오 로그인 페이지로 이동했는지 확인
current_url = self.driver.current_url
self.logger.info(f"� 현재 URL: {current_url}")
# 5. 자동 로그인 시도 (계정 정보가 있는 경우)
if self.kakao_id and self.kakao_password:
return self._attempt_kakao_auto_login()
else:
# 수동 로그인 대기
return self._wait_for_kakao_manual_login()
except Exception as e:
self.logger.error(f"❌ 로그인 프로세스 중 오류 발생: {str(e)}", exc_info=True)
return False
def _attempt_kakao_auto_login(self) -> bool:
"""카카오 자동 로그인 시도"""
try:
self.logger.info("� 카카오 자동 로그인을 시도합니다...")
# 카카오 로그인 페이지인지 확인
if 'accounts.kakao.com' not in self.driver.current_url:
self.logger.warning("⚠️ 카카오 로그인 페이지가 아닙니다. 이미 로그인된 상태일 수 있습니다.")
time.sleep(2)
return self.check_login_status()
# 저장된 로그인 정보 확인 및 클릭 시도
try:
self.logger.info("� 저장된 로그인 정보 확인 중...")
saved_account_selectors = [
'#mainContent > div > div > ul > li:nth-child(1) > a',
'#mainContent li:first-child a',
'.list_account li:first-child a'
]
saved_account_clicked = False
for selector in saved_account_selectors:
try:
saved_account = WebDriverWait(self.driver, 3).until(
EC.element_to_be_clickable((By.CSS_SELECTOR, selector))
)
if saved_account.is_displayed():
saved_account.click()
time.sleep(2)
self.logger.info("✅ 저장된 로그인 정보로 로그인 시도")
saved_account_clicked = True
break
except TimeoutException:
continue
# CSS로 안되면 XPath로 시도
if not saved_account_clicked:
try:
saved_account = WebDriverWait(self.driver, 3).until(
EC.element_to_be_clickable((By.XPATH, '//*[@id="mainContent"]/div/div/ul/li[1]/a'))
)
saved_account.click()
time.sleep(2)
self.logger.info("✅ 저장된 로그인 정보로 로그인 시도 (XPath)")
saved_account_clicked = True
except TimeoutException:
self.logger.info("ℹ️ 저장된 로그인 정보가 없습니다. 직접 입력을 시도합니다.")
# 저장된 계정으로 로그인했다면 확인
if saved_account_clicked:
time.sleep(3)
# 티스토리로 돌아왔는지 확인
try:
WebDriverWait(self.driver, 15).until(
lambda d: 'tistory.com' in d.current_url and 'accounts.kakao.com' not in d.current_url
)
self.logger.info("✅ 티스토리로 리다이렉트 완료")
except TimeoutException:
self.logger.warning("⚠️ 티스토리 리다이렉트 대기 시간 초과")
if self.check_login_status():
self.logger.info("� 저장된 계정으로 로그인 성공!")
return True
except Exception as e:
self.logger.debug(f"저장된 로그인 정보 확인 중 오류: {e}")
# 저장된 계정이 없거나 실패한 경우, 직접 입력 시도
# 아이디 입력
try:
id_input = WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.CSS_SELECTOR, self.selectors['kakao_id_input']))
)
id_input.clear()
id_input.send_keys(self.kakao_id)
self.logger.info("✅ 카카오 ID 입력 완료")
except TimeoutException:
self.logger.error("❌ 카카오 ID 입력창을 찾을 수 없습니다")
return False
# 비밀번호 입력
try:
pw_input = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.CSS_SELECTOR, self.selectors['kakao_password_input']))
)
pw_input.clear()
pw_input.send_keys(self.kakao_password)
self.logger.info("✅ 카카오 비밀번호 입력 완료")
except TimeoutException:
self.logger.error("❌ 카카오 비밀번호 입력창을 찾을 수 없습니다")
return False
time.sleep(1)
# 로그인 버튼 클릭
try:
login_button = self.driver.find_element(By.CSS_SELECTOR, self.selectors['kakao_submit_button'])
login_button.click()
self.logger.info("✅ 카카오 로그인 버튼 클릭 완료")
except NoSuchElementException:
self.logger.error("❌ 카카오 로그인 버튼을 찾을 수 없습니다")
return False
# 로그인 완료 대기 (티스토리로 리다이렉트)
try:
self.logger.info("⏳ 티스토리로 리다이렉트 대기 중...")
WebDriverWait(self.driver, 15).until(
lambda d: 'tistory.com' in d.current_url and 'accounts.kakao.com' not in d.current_url
)
self.logger.info("✅ 티스토리로 리다이렉트 완료")
time.sleep(2)
# 최종 로그인 상태 확인
if self.check_login_status():
self.logger.info("� 자동 로그인 성공!")
return True
else:
self.logger.warning("⚠️ 리다이렉트는 완료되었으나 로그인 상태 확인 실패")
return False
except TimeoutException:
self.logger.warning("⚠️ 자동 로그인 후 페이지 전환 시간 초과")
return self.check_login_status()
except Exception as e:
self.logger.error(f"❌ 자동 로그인 실패: {e}")
self.logger.info("⚠️ 수동 로그인으로 전환합니다.")
return self._wait_for_kakao_manual_login()
def _wait_for_kakao_manual_login(self) -> bool:
"""카카오 수동 로그인 대기"""
try:
wait_time = self.manual_login_wait
self.logger.info(f"� 브라우저에서 카카오 로그인을 {wait_time}초 안에 완료해주세요.")
# 수동 로그인 대기
time.sleep(wait_time)
# 티스토리로 돌아왔는지 확인
try:
WebDriverWait(self.driver, 15).until(
lambda d: 'tistory.com' in d.current_url and 'accounts.kakao.com' not in d.current_url
)
self.logger.info("✅ 티스토리로 리다이렉트 확인")
except TimeoutException:
self.logger.warning("⚠️ 티스토리로 리다이렉트 대기 시간 초과")
# 최종 로그인 상태 확인
if self.check_login_status():
self.logger.info("✅ 수동 로그인 성공!")
return True
else:
self.logger.error("❌ 수동 로그인 실패")
return False
except Exception as e:
self.logger.error(f"❌ 수동 로그인 대기 중 오류: {e}")
return False
def _click_write_button(self) -> bool:
"""
티스토리 글쓰기 버튼 클릭
Returns:
클릭 성공 여부
"""
try:
self.logger.info("�️ 티스토리 글쓰기 버튼 찾기 시작...")
# 1. 티스토리 메인 페이지로 이동
current_url = self.driver.current_url
if 'tistory.com' not in current_url or '/manage' in current_url:
self.logger.info("� 티스토리 메인 페이지로 이동...")
self.driver.get(self.tistory_url)
time.sleep(3)
# 2. CSS 셀렉터로 글쓰기 버튼 클릭
try:
self.logger.info(f" -> 시도: CSS 셀렉터")
write_btn = WebDriverWait(self.driver, 5).until(
EC.element_to_be_clickable((By.CSS_SELECTOR, self.selectors['write_button']))
)
if write_btn.is_displayed():
write_btn.click()
time.sleep(2)
self.logger.info("✅ CSS 셀렉터로 글쓰기 버튼 클릭 완료")
return True
except TimeoutException:
self.logger.error("❌ CSS 셀렉터로 버튼을 찾지 못했습니다.")
return False
except Exception as e:
self.logger.error(f"❌ CSS 셀렉터 시도 중 오류: {e}", exc_info=True)
return False
except Exception as e:
self.logger.error(f"❌ 글쓰기 버튼 클릭 중 예외 발생: {e}", exc_info=True)
return False
def _handle_write_popup(self) -> bool:
"""
티스토리 '작성 중인 글이 있습니다' 팝업 처리
- 팝업이 뜨면 '취소'를 눌러서 새 글 쓰기 모드로 진입
"""
try:
self.logger.info("� 팝업 확인 중 (3초 대기)...")
# 3초 동안 Alert(경고창)이 뜨는지 기다림
WebDriverWait(self.driver, 3).until(EC.alert_is_present())
# Alert 창으로 제어권 이동
alert = self.driver.switch_to.alert
msg = alert.text
self.logger.info(f"⚠️ 알림창 발견: {msg}")
# 팝업 닫기 (dismiss = 취소 = 새 글 쓰기)
# 만약 이어쓰기를 원하면 alert.accept() 사용
alert.dismiss()
self.logger.info("✅ 알림창 '취소' 선택 완료 (새 글 작성)")
return True
except TimeoutException:
self.logger.info("✅ 알림창이 뜨지 않았습니다. 바로 진행합니다.")
return True
except Exception as e:
self.logger.warning(f"⚠️ 알림 처리 중 예외 발생 (무시하고 진행): {e}")
return True
def _close_old_post_modal(self) -> bool:
"""
티스토리 글쓰기 페이지에서 뜨는
'이전 글 이어쓰기 / 과거 글 가져오기' 모달을 닫는다.
간단하게 모달 영역 클릭 + Enter 키 입력
"""
try:
self.logger.info("� 이전 글 가져오기 모달 확인 중...")
# 모달이 뜨는 시간을 조금 기다려줌
time.sleep(2)
# 1. 모달 영역 클릭 (포커스를 모달로 이동)
try:
# body를 클릭하여 모달에 포커스
body = self.driver.find_element(By.TAG_NAME, 'body')
body.click()
self.logger.info("✅ 모달 영역 클릭 완료")
time.sleep(0.5)
except Exception as e:
self.logger.debug(f"모달 클릭 중 오류: {e}")
# 2. Enter 키 입력으로 모달 닫기
try:
ActionChains(self.driver).send_keys(Keys.ENTER).perform()
self.logger.info("✅ Enter 키 입력 완료 - 모달 닫힘")
time.sleep(1)
return True
except Exception as e:
self.logger.warning(f"⚠️ Enter 키 입력 중 오류: {e}")
return False
except Exception as e:
self.logger.warning(f"⚠️ 이전 글 모달 닫기 중 예외 발생 (무시하고 진행): {e}")
return False
def _navigate_to_write_page(self) -> bool:
"""
글쓰기 페이지로 이동
Returns:
성공 여부
"""
try:
self.logger.info("✍️ 글쓰기 페이지로 이동 시작")
# 1. 글쓰기 페이지 URL로 직접 이동
write_url = "https://militarypost.tistory.com/manage/newpost"
self.logger.info(f"� 글쓰기 페이지로 직접 이동: {write_url}")
self.driver.get(write_url)
self.logger.info("✅ 글쓰기 페이지 로드 완료")
# 2. 2초 대기
self.logger.info("⏳ 2초 대기...")
time.sleep(2)
self.logger.info("✅ 2초 대기 완료")
# 3. Alert 창 처리 (임시 저장 글 확인)
self.logger.info("� Alert 창 확인 중...")
try:
alert = self.driver.switch_to.alert
alert_text = alert.text
self.logger.info(f"⚠️ Alert 발견: {alert_text}")
# "취소" 선택 (새 글 작성)
alert.dismiss()
self.logger.info("✅ Alert '취소' 선택 완료 (새 글 작성)")
time.sleep(1)
except:
# Alert가 없으면 계속 진행
self.logger.info("ℹ️ Alert 창이 없습니다. 계속 진행...")
# 4. 모달 클릭 + Enter (혹시 남아있을 경우 대비)
self.logger.info("� 모달 좌표 클릭 및 Enter 처리...")
try:
# JavaScript로 모달 위치의 좌표 클릭 (상단 1/5 지점)
self.logger.info(" -> 모달 위치 좌표 클릭...")
click_result = self.driver.execute_script("""
var centerX = window.innerWidth / 2;
var modalY = window.innerHeight / 5; // 화면 세로의 1/5 지점 (상단)
// 계산된 좌표에 클릭 이벤트 발생
var element = document.elementFromPoint(centerX, modalY);
if (element) {
element.click();
return {
success: true,
x: centerX,
y: modalY,
elementTag: element.tagName,
elementClass: element.className,
elementId: element.id
};
} else {
return {
success: false,
x: centerX,
y: modalY,
message: 'No element found at coordinates'
};
}
""")
# 클릭 결과 로그 출력
if click_result:
self.logger.info(f" -> 클릭 좌표: ({click_result.get('x')}, {click_result.get('y')})")
if click_result.get('success'):
self.logger.info(f" -> 클릭한 요소: <{click_result.get('elementTag')}> "
f"class='{click_result.get('elementClass')}' "
f"id='{click_result.get('elementId')}'")
else:
self.logger.info(f" -> {click_result.get('message')}")
time.sleep(0.5)
# Enter 키 입력
self.logger.info(" -> Enter 키 입력...")
ActionChains(self.driver).send_keys(Keys.ENTER).perform()
time.sleep(1)
self.logger.info("✅ 모달 처리 완료")
except Exception as e:
# 모달이 없어도 성공으로 간주
self.logger.info(f"ℹ️ 모달 처리 중 예외 (무시하고 진행): {e}")
# 5. 현재 URL 확인
current_url = self.driver.current_url
self.logger.info(f"� 현재 URL: {current_url}")
self.logger.info("✅ 글쓰기 페이지 진입 완료")
return True
except Exception as e:
self.logger.error(f"❌ 글쓰기 페이지 이동 중 예외 발생: {e}", exc_info=True)
return False
def _get_tistory_cms_info(self) -> dict:
"""
Tistory CMS 테이블에서 데이터 가져오기 (랜덤 선택)
Returns:
Tistory CMS 정보 딕셔너리 (tistory_title, tistory_text, record_id)
"""
try:
self.logger.info("� Tistory CMS 정보 조회 시작 (랜덤 선택)")
# airtable_manager의 공통 메서드 호출
result = self.airtable_manager.get_tistory_cms_item_random()
if not result:
self.logger.warning("⚠️ Tistory CMS에서 조건에 맞는 레코드를 찾을 수 없음")
return {}
return result
except Exception as e:
self.logger.error(f"❌ Tistory CMS 정보 조회 실패: {e}", exc_info=True)
return {}
def _input_title(self, content_data: dict) -> bool:
"""제목 입력 (타이핑 방식)"""
try:
# CSS 셀렉터로 먼저 시도
title_input = self.wait_for_element(self.selectors['title_input'], timeout=10)
# CSS 셀렉터 실패 시 XPath 시도
if not title_input:
title_input = self.wait_for_element(self.selectors['title_input_xpath'], timeout=10)
if not title_input:
self.logger.error("❌ 제목 입력 필드를 찾을 수 없습니다")
return False
# Tistory CMS에서 제목 가져오기
title_text = content_data.get('tistory_title', '').strip()
if not title_text:
self.logger.error("❌ tistory_title이 없습니다")
return False
# 기존 값 클리어
title_input.clear()
time.sleep(0.2)
# 제목 클릭하여 포커스
title_input.click()
time.sleep(0.2)
# 문자별로 타이핑 (네이버 방식 참고)
self.logger.info(f"⌨️ 제목 타이핑 시작: {title_text[:30]}...")
for char in title_text:
ActionChains(self.driver).send_keys(char).perform()
time.sleep(0.03) # 각 문자 사이 0.03초 지연
time.sleep(0.5)
self.logger.info("✅ 제목 입력 완료")
return True
except Exception as e:
self.logger.error(f"❌ 제목 입력 중 오류: {e}")
return False
def _get_copy_key(self):
"""운영체제에 맞는 복사/붙여넣기 키 반환"""
return Keys.COMMAND if sys.platform == 'darwin' else Keys.CONTROL
def _input_content(self, content_data: dict) -> bool:
"""본문 입력 (타이핑 방식 - HTML 파싱, iframe 처리)"""
try:
# Tistory CMS에서 본문 가져오기
tistory_text = content_data.get('tistory_text', '').strip()
if not tistory_text:
self.logger.error("❌ tistory_text가 없습니다")
return False
# 1. 이미 iframe 내부에 있는지 확인 (#tinymce 요소 찾기 시도)
self.logger.info("� 현재 iframe 내부에 있는지 확인 중...")
editor_area = None
already_in_iframe = False
# 먼저 현재 컨텍스트에서 #tinymce 요소를 찾아봅니다
for selector in self.selectors['content_input']:
try:
if selector.startswith('//'):
# XPath
editor_area = self.driver.find_element(By.XPATH, selector)
else:
# CSS 셀렉터
editor_area = self.driver.find_element(By.CSS_SELECTOR, selector)
if editor_area:
self.logger.info(f"✅ 이미 iframe 내부에 있습니다. 에디터 영역 발견: {selector}")
already_in_iframe = True
break
except:
continue
# 2. iframe 내부에 있지 않다면 iframe 찾기 및 전환
if not already_in_iframe:
self.logger.info("� TinyMCE iframe 찾는 중...")
iframe_element = None
for iframe_selector in self.selectors['content_iframe']:
try:
if iframe_selector.startswith('//'):
# XPath
iframe_element = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.XPATH, iframe_selector))
)
else:
# CSS 셀렉터
iframe_element = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.CSS_SELECTOR, iframe_selector))
)
if iframe_element:
self.logger.info(f"✅ iframe 발견: {iframe_selector}")
break
except TimeoutException:
continue
except Exception as e:
self.logger.debug(f"iframe 셀렉터 {iframe_selector} 시도 실패: {e}")
continue
if not iframe_element:
self.logger.error("❌ TinyMCE iframe을 찾을 수 없습니다")
return False
# iframe으로 전환
self.logger.info("� iframe으로 전환 중...")
self.driver.switch_to.frame(iframe_element)
time.sleep(1)
# iframe 내부에서 #tinymce 요소 찾기
self.logger.info("� iframe 내부에서 에디터 영역 찾는 중...")
editor_area = None
for selector in self.selectors['content_input']:
try:
if selector.startswith('//'):
# XPath
editor_area = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.XPATH, selector))
)
else:
# CSS 셀렉터
editor_area = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.CSS_SELECTOR, selector))
)
if editor_area:
self.logger.info(f"✅ 에디터 영역 발견: {selector}")
break
except TimeoutException:
continue
except Exception as e:
self.logger.debug(f"셀렉터 {selector} 시도 실패: {e}")
continue
# iframe 내부에서 찾지 못했으면 default_content로 복귀
if not editor_area:
self.logger.error("❌ iframe 내부에서 본문 입력 필드를 찾을 수 없습니다")
self.driver.switch_to.default_content()
return False
# 3. 에디터 영역 클릭 (이미 iframe 내부에 있으면 건너뛰기)
if not already_in_iframe:
# iframe으로 전환한 경우에만 에디터 영역 클릭
editor_area.click()
time.sleep(1)
else:
# 이미 iframe 내부에 있고 Enter로 본문 영역에 포커스가 있으므로 클릭 건너뛰기
self.logger.info("✅ 이미 본문 영역에 포커스가 있으므로 클릭 건너뛰기")
# 5. HTML 구조를 파싱하면서 타이핑 (네이버 방식 참고)
self.logger.info("⌨️ HTML 구조에 따라 텍스트 타이핑 시작...")
# 이미지 정보 가져오기 (5줄마다 이미지 삽입용)
downloaded_images_info = content_data.get('downloaded_images_info', [])
line_counter = 0 # 줄 카운터
image_index = 1 # 이미지 인덱스 (1부터 시작, 첫 번째 이미지는 이미 삽입됨)
# HTML을 요소별로 분리 (텍스트와 태그 구분)
html_parts = re.split(r'(<br>|</p><p>|</p>|<p>|<h2>|</h2>|<[^>]+>)', tistory_text)
# 이전 part를 추적하여 <h2> 이전에 Enter를 칠 수 있도록 함
prev_part = None
for part in html_parts:
if not part:
continue
# <h2> 태그를 만나기 전에 Enter를 2번 쳐서 공간 확보
if part == '<h2>' and prev_part and prev_part.strip():
# 이전 텍스트가 있으면 Enter 2번으로 새 줄 만들기
ActionChains(self.driver).send_keys(Keys.ENTER).perform()
time.sleep(0.1)
ActionChains(self.driver).send_keys(Keys.ENTER).perform()
time.sleep(0.2)
if part == '<br>':
# <br> 태그는 Enter 두 번 (카운트하지 않음)
ActionChains(self.driver).send_keys(Keys.ENTER).perform()
time.sleep(0.1)
ActionChains(self.driver).send_keys(Keys.ENTER).perform()
time.sleep(0.1)
elif part == '<p>':
# <p> 태그 시작: 아무것도 하지 않음 (텍스트가 있을 때 카운트)
pass
elif part == '</p><p>' or part == '</p>':
# 단락 구분도 Enter 두 번 (카운트하지 않음)
if part == '</p><p>':
ActionChains(self.driver).send_keys(Keys.ENTER).perform()
time.sleep(0.1)
ActionChains(self.driver).send_keys(Keys.ENTER).perform()
time.sleep(0.1)
elif part == '<h2>':
# h2 태그 시작: 텍스트 입력 준비 (텍스트가 있을 때 카운트)
# (이전에 Enter는 이미 쳤음)
time.sleep(0.2)
# h2 스타일은 텍스트 입력 후 적용
elif part == '</h2>':
# h2 태그 끝: 텍스트 선택 후 h2 스타일 적용
# 현재 줄의 텍스트를 선택 (Shift + Home 또는 Ctrl/Cmd + Shift + Left)
modifier = Keys.COMMAND if sys.platform == 'darwin' else Keys.CONTROL
ActionChains(self.driver).key_down(modifier).key_down(Keys.SHIFT).send_keys(Keys.HOME).key_up(Keys.SHIFT).key_up(modifier).perform()
time.sleep(0.1)
# h2 스타일 적용
if not self._set_h2_style():
self.logger.warning("⚠️ h2 스타일 적용 실패, 계속 진행")
# ✅ 개선: 이미 찾았던 iframe_element 재사용
try:
self.driver.switch_to.frame(iframe_element)
time.sleep(0.1)
except:
# 혹시 iframe이 사라졌다면 다시 찾기
for iframe_selector in self.selectors['content_iframe']:
try:
if iframe_selector.startswith('//'):
temp_iframe = self.driver.find_element(By.XPATH, iframe_selector)
else:
temp_iframe = self.driver.find_element(By.CSS_SELECTOR, iframe_selector)
self.driver.switch_to.frame(temp_iframe)
iframe_element = temp_iframe # 재사용을 위해 저장
break
except:
continue
# Enter 2번으로 다음 줄로 이동하여 본문으로 넘어가기
ActionChains(self.driver).send_keys(Keys.ENTER).perform()
time.sleep(0.05)
ActionChains(self.driver).send_keys(Keys.ENTER).perform()
time.sleep(0.1)
# h2 태그는 텍스트 입력 시 카운트됨
elif part.startswith('<') and part.endswith('>'):
# 다른 HTML 태그는 무시
prev_part = part
continue
else:
# 일반 텍스트는 문자별로 타이핑
clean_text = re.sub(r'<[^>]+>', '', part) # 혹시 남은 태그 제거
if clean_text.strip():
# 빈칸이 아닌 텍스트 블록(p, h2, 또는 태그 없는 텍스트)을 1줄로 카운트
line_counter += 1
for char in clean_text:
ActionChains(self.driver).send_keys(char).perform()
time.sleep(0.03) # 각 문자 사이 0.03초 지연
# 5줄마다 이미지 삽입 (5, 10, 15...)
# 텍스트를 입력한 *직후*에 이미지 삽입 여부 체크
if line_counter % 5 == 0 and image_index < len(downloaded_images_info):
self.logger.info(f"�️ {line_counter}줄 후 이미지 {image_index + 1}번째 삽입...")
# default_content로 전환하여 이미지 업로드 버튼 클릭
self.driver.switch_to.default_content()
time.sleep(0.2)
# 이미지 버튼 클릭
if self._click_attach_and_image_buttons():
img_info = downloaded_images_info[image_index]
# 이미지 업로드
if self._upload_single_image(img_info['file_path']):
self.logger.info("✅ 이미지 업로드 완료")
# 캡션 입력
if img_info.get('caption'):
self.logger.info(f"� 이미지 캡션 입력 시작: {img_info['caption'][:30]}...")
if self._input_image_caption(img_info['caption']):
self.logger.info("✅ 이미지 캡션 입력 완료")
# 캡션 입력 후 Enter로 이미 본문 영역(iframe 내부)에 있으므로
# 추가 iframe 전환 없이 바로 본문 입력 계속 진행
else:
self.logger.warning("⚠️ 이미지 캡션 입력 실패")
# 캡션 입력 실패 시에도 iframe으로 복귀하여 본문 입력 계속
try:
self.driver.switch_to.frame(iframe_element)
time.sleep(0.2)
except:
# iframe을 다시 찾기
for iframe_selector in self.selectors['content_iframe']:
try:
if iframe_selector.startswith('//'):
iframe_element = self.driver.find_element(By.XPATH, iframe_selector)
else:
iframe_element = self.driver.find_element(By.CSS_SELECTOR, iframe_selector)
self.driver.switch_to.frame(iframe_element)
break
except:
continue
else:
# 캡션이 없으면 iframe으로 전환 후 Enter만 입력하여 본문 영역으로 복귀
try:
self.driver.switch_to.frame(iframe_element)
time.sleep(0.1)
ActionChains(self.driver).send_keys(Keys.ENTER).perform()
time.sleep(0.2)
except:
# iframe을 다시 찾기
for iframe_selector in self.selectors['content_iframe']:
try:
if iframe_selector.startswith('//'):
iframe_element = self.driver.find_element(By.XPATH, iframe_selector)
else:
iframe_element = self.driver.find_element(By.CSS_SELECTOR, iframe_selector)
self.driver.switch_to.frame(iframe_element)
ActionChains(self.driver).send_keys(Keys.ENTER).perform()
time.sleep(0.2)
break
except:
continue
# 캡션 입력 후 Enter로 이미 본문 영역(iframe 내부)에 있으므로
# 추가 iframe 전환 없이 바로 본문 입력 계속 진행
image_index += 1
# line_counter += 1 제거: 이중 카운트 방지 (텍스트 입력 시 이미 카운트됨)
self.logger.info(f"✅ 이미지 {image_index}번째 삽입 완료")
else:
self.logger.warning("⚠️ 이미지 업로드 실패")
# 업로드 실패 시에도 iframe으로 복귀하여 본문 입력 계속
try:
self.driver.switch_to.frame(iframe_element)
time.sleep(0.2)
except:
# iframe을 다시 찾기
for iframe_selector in self.selectors['content_iframe']:
try:
if iframe_selector.startswith('//'):
iframe_element = self.driver.find_element(By.XPATH, iframe_selector)
else:
iframe_element = self.driver.find_element(By.CSS_SELECTOR, iframe_selector)
self.driver.switch_to.frame(iframe_element)
break
except:
continue
else:
self.logger.warning("⚠️ 이미지 버튼 클릭 실패")
# 버튼 클릭 실패 시에도 iframe으로 복귀하여 본문 입력 계속
try:
self.driver.switch_to.frame(iframe_element)
time.sleep(0.2)
except:
# iframe을 다시 찾기
for iframe_selector in self.selectors['content_iframe']:
try:
if iframe_selector.startswith('//'):
iframe_element = self.driver.find_element(By.XPATH, iframe_selector)
else:
iframe_element = self.driver.find_element(By.CSS_SELECTOR, iframe_selector)
self.driver.switch_to.frame(iframe_element)
break
except:
continue
elif line_counter % 5 == 0 and image_index >= len(downloaded_images_info):
# 이미지가 더 이상 없으면 로그만 남기고 계속 진행
self.logger.info(f"ℹ️ {line_counter}줄 시점: 남은 이미지가 없어 텍스트만 계속 입력")
# 이전 part 업데이트
prev_part = part
self.logger.info("✅ 본문 타이핑 완료")
# 6. default_content로 복귀
self.driver.switch_to.default_content()
time.sleep(0.5)
self.logger.info("✅ 본문 입력 완료")
return True
except Exception as e:
self.logger.error(f"❌ 본문 입력 중 오류: {e}")
# 오류 발생 시 default_content로 복귀 시도
try:
self.driver.switch_to.default_content()
except:
pass
return False
def _set_h2_style(self) -> bool:
"""
현재 커서 위치의 텍스트에 h2 스타일 적용
Returns:
성공 여부
"""
try:
self.logger.info("� h2 스타일 설정 시작")
# 1. default_content로 전환 (iframe에서 나오기)
self.driver.switch_to.default_content()
time.sleep(0.5)
# 2. 설정 버튼 찾기 및 클릭
format_button = None
for selector in self.selectors['format_button']:
try:
if selector.startswith('//'):
# XPath
format_button = WebDriverWait(self.driver, 5).until(
EC.element_to_be_clickable((By.XPATH, selector))
)
else:
# CSS 셀렉터
format_button = WebDriverWait(self.driver, 5).until(
EC.element_to_be_clickable((By.CSS_SELECTOR, selector))
)
if format_button:
self.logger.info(f"✅ 설정 버튼 발견: {selector}")
break
except TimeoutException:
continue
except Exception as e:
self.logger.debug(f"설정 버튼 셀렉터 {selector} 시도 실패: {e}")
continue
if not format_button:
self.logger.error("❌ 설정 버튼을 찾을 수 없습니다")
return False
# 설정 버튼 클릭
format_button.click()
time.sleep(1.0) # 메뉴가 나타날 때까지 충분한 대기 시간
# 3. h2 옵션 찾기 및 클릭 (설정 버튼 클릭 후 메뉴가 나타날 때까지 대기)
h2_option = None
# 우선적으로 "제목2" 텍스트로 찾기
try:
self.logger.info("� 텍스트로 '제목2' 찾기 시도 (우선)")
# "제목2" 텍스트를 포함하는 요소 찾기
h2_option = WebDriverWait(self.driver, 1).until(
EC.presence_of_element_located((By.XPATH, "//span[contains(text(), '제목2')]"))
)
if h2_option:
self.logger.info("✅ '제목2' 텍스트로 h2 옵션 발견")
except TimeoutException:
self.logger.warning("⚠️ '제목2' 텍스트로 찾을 수 없음, ID 셀렉터 시도")
except Exception as e:
self.logger.debug(f"'제목2' 텍스트 찾기 실패: {e}, ID 셀렉터 시도")
# 텍스트로 찾지 못하면 ID 셀렉터로 시도
if not h2_option:
self.logger.info("� ID 셀렉터로 h2 옵션 찾기 시도 (폴백)")
for selector in self.selectors['h2_option']:
try:
if selector.startswith('//'):
# XPath - 명시적으로 대기
h2_option = WebDriverWait(self.driver, 1).until(
EC.presence_of_element_located((By.XPATH, selector))
)
else:
# CSS 셀렉터 - 명시적으로 대기
h2_option = WebDriverWait(self.driver, 1).until(
EC.presence_of_element_located((By.CSS_SELECTOR, selector))
)
if h2_option:
self.logger.info(f"✅ h2 옵션 발견 (ID 셀렉터): {selector}")
break
except TimeoutException:
self.logger.warning(f"⚠️ h2 옵션 셀렉터 {selector} 타임아웃, 다음 셀렉터 시도")
continue
except Exception as e:
self.logger.debug(f"h2 옵션 셀렉터 {selector} 시도 실패: {e}")
continue
if not h2_option:
self.logger.error("❌ h2 옵션을 찾을 수 없습니다")
return False
# 클릭 가능할 때까지 대기
try:
WebDriverWait(self.driver, 0.5).until(
EC.element_to_be_clickable(h2_option)
)
except TimeoutException:
self.logger.warning("⚠️ h2 옵션이 클릭 가능하지 않지만 시도합니다")
# JavaScript로 클릭 시도 (더 안정적)
try:
self.driver.execute_script("arguments[0].click();", h2_option)
self.logger.info("✅ JavaScript로 h2 옵션 클릭 성공")
except Exception as e:
self.logger.warning(f"⚠️ JavaScript 클릭 실패, 일반 클릭 시도: {e}")
try:
h2_option.click()
self.logger.info("✅ 일반 클릭으로 h2 옵션 클릭 성공")
except Exception as e2:
self.logger.error(f"❌ 일반 클릭도 실패: {e2}")
return False
time.sleep(0.3)
self.logger.info("✅ h2 스타일 설정 완료")
return True
except Exception as e:
self.logger.error(f"❌ h2 스타일 설정 중 오류: {e}", exc_info=True)
return False
def _extract_wp_images_and_captions(self) -> list:
"""
현재 WP 페이지에서 article 태그 내 이미지와 캡션 추출
Returns:
이미지 정보 리스트 (각 항목은 {'image_url': str, 'caption': str})
"""
try:
self.logger.info("�️ article 태그 내 이미지/캡션 추출 시작...")
image_data = []
# article 태그 찾기
try:
article = self.driver.find_element(By.CSS_SELECTOR, 'article')
self.logger.info(f"� article 태그 내 이미지 탐색 중...")
except:
self.logger.warning("⚠️ article 태그를 찾을 수 없어 전체 페이지에서 검색...")
article = self.driver.find_element(By.TAG_NAME, 'body')
# article 내부의 이미지 요소들 찾기
img_elements = article.find_elements(By.CSS_SELECTOR, 'img')
self.logger.info(f"� article 태그 내 이미지 {len(img_elements)}개 발견")
for i, img in enumerate(img_elements):
try:
src = img.get_attribute('src')
alt = img.get_attribute('alt')
# 유효한 이미지인지 확인
if src and src.startswith('http') and not any(skip in src.lower() for skip in ['logo', 'icon', 'avatar', 'button', 'emoji']):
# 캡션 찾기 (figcaption 우선)
caption = ""
# 1. figcaption 먼저 찾기 (1순위)
try:
parent = img.find_element(By.XPATH, '..')
figcaption = parent.find_element(By.CSS_SELECTOR, 'figcaption')
if figcaption.text.strip():
caption = figcaption.text.strip()
self.logger.debug(f"✓ figcaption 발견: {caption[:30]}...")
except:
pass
# 2. figcaption이 없으면 alt 속성 사용 (2순위)
if not caption:
alt = img.get_attribute('alt')
if alt and alt.strip():
caption = alt.strip()
self.logger.debug(f"✓ alt 속성 사용: {caption[:30]}...")
# 3. 둘 다 없으면 빈 문자열
if not caption:
self.logger.debug("✓ 캡션 없음")
image_info = {
'image_url': src,
'caption': caption
}
image_data.append(image_info)
self.logger.info(f" ✓ 이미지 {len(image_data)}: {src[:50]}... (캡션: {caption[:30] if caption else '없음'})")
except Exception as e:
self.logger.warning(f"⚠️ 이미지 {i+1} 처리 중 오류: {e}")
continue
self.logger.info(f"✅ article 태그 내 이미지 {len(image_data)}개 추출 완료")
return image_data
except Exception as e:
self.logger.error(f"❌ WP 이미지 추출 실패: {e}", exc_info=True)
return []
def _download_image_to_temp(self, image_url: str) -> str | None:
"""
이미지를 로컬 temp 폴더에 다운로드
Args:
image_url: 다운로드할 이미지 URL
Returns:
다운로드된 파일의 절대 경로 또는 None
"""
try:
# URL에서 파일명 추출
filename = os.path.basename(image_url.split('?')[0])
# 저장 경로
save_path = self.temp_upload_dir / filename
self.logger.info(f"⬇️ 이미지 다운로드 시작: {image_url[:50]}...")
# 이미지 다운로드
response = requests.get(image_url, timeout=10, stream=True)
response.raise_for_status()
# 파일로 저장
with open(save_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
# 파일 존재 확인
if save_path.exists() and save_path.stat().st_size > 0:
self.logger.info(f"✅ 이미지 다운로드 성공: {os.path.basename(save_path)}")
return str(save_path.absolute())
else:
self.logger.error(f"❌ 다운로드된 파일이 비어있음: {save_path}")
return None
except Exception as e:
self.logger.error(f"❌ 이미지 다운로드 실패: {image_url[:50]}... - {e}")
return None
def _click_attach_and_image_buttons(self) -> bool:
"""
티스토리 에디터에서 첨부 버튼을 클릭한 다음 사진 버튼을 클릭
Returns:
성공 여부
"""
try:
self.logger.info("� 첨부 버튼 및 사진 버튼 클릭 시작")
# default_content로 전환 (iframe에서 나오기)
self.driver.switch_to.default_content()
time.sleep(0.5)
# 1. 첨부 버튼 찾기 및 클릭
attach_button = None
for selector in self.selectors['attach_button']:
try:
if selector.startswith('//'):
# XPath
attach_button = WebDriverWait(self.driver, 5).until(
EC.element_to_be_clickable((By.XPATH, selector))
)
else:
# CSS 셀렉터
attach_button = WebDriverWait(self.driver, 5).until(
EC.element_to_be_clickable((By.CSS_SELECTOR, selector))
)
if attach_button:
self.logger.info(f"✅ 첨부 버튼 발견: {selector}")
break
except TimeoutException:
continue
except Exception as e:
self.logger.debug(f"첨부 버튼 셀렉터 {selector} 시도 실패: {e}")
continue
if not attach_button:
self.logger.error("❌ 첨부 버튼을 찾을 수 없습니다")
return False
# 첨부 버튼 클릭
try:
attach_button.click()
self.logger.info("✅ 첨부 버튼 클릭 성공 (일반 클릭)")
except Exception as e:
try:
self.driver.execute_script("arguments[0].click();", attach_button)
self.logger.info("✅ 첨부 버튼 클릭 성공 (JavaScript 클릭)")
except Exception as e2:
self.logger.error(f"❌ 첨부 버튼 클릭 실패: {e2}")
return False
# 메뉴가 나타날 때까지 대기
time.sleep(1.0)
# 2. 사진 버튼 찾기 및 클릭
image_button = None
for selector in self.selectors['image_button']:
try:
if selector.startswith('//'):
# XPath
image_button = WebDriverWait(self.driver, 5).until(
EC.element_to_be_clickable((By.XPATH, selector))
)
else:
# CSS 셀렉터
image_button = WebDriverWait(self.driver, 5).until(
EC.element_to_be_clickable((By.CSS_SELECTOR, selector))
)
if image_button:
self.logger.info(f"✅ 사진 버튼 발견: {selector}")
break
except TimeoutException:
continue
except Exception as e:
self.logger.debug(f"사진 버튼 셀렉터 {selector} 시도 실패: {e}")
continue
if not image_button:
self.logger.error("❌ 사진 버튼을 찾을 수 없습니다")
return False
# 사진 버튼 클릭
try:
image_button.click()
self.logger.info("✅ 사진 버튼 클릭 성공 (일반 클릭)")
except Exception as e:
try:
self.driver.execute_script("arguments[0].click();", image_button)
self.logger.info("✅ 사진 버튼 클릭 성공 (JavaScript 클릭)")
except Exception as e2:
self.logger.error(f"❌ 사진 버튼 클릭 실패: {e2}")
return False
time.sleep(0.5)
self.logger.info("✅ 첨부 버튼 및 사진 버튼 클릭 완료")
return True
except Exception as e:
self.logger.error(f"❌ 첨부 버튼 및 사진 버튼 클릭 중 오류: {e}", exc_info=True)
return False
def _upload_single_image(self, image_file: str) -> bool:
"""
티스토리 에디터에 이미지 1장 업로드
Args:
image_file: 업로드할 이미지 파일 경로
Returns:
성공 여부
"""
try:
self.logger.info(f"� 이미지 업로드 시작: {os.path.basename(image_file)}")
# default_content로 전환 (iframe에서 나오기)
self.driver.switch_to.default_content()
time.sleep(0.5)
# 파일 input 요소 찾기
file_input_selectors = [
"input[type='file']",
"//input[@type='file']",
"#attach-image + input[type='file']",
"input[type='file'][accept*='image']",
]
file_input = None
for selector in file_input_selectors:
try:
if selector.startswith('//'):
# XPath
file_input = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.XPATH, selector))
)
else:
# CSS 셀렉터
file_input = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.CSS_SELECTOR, selector))
)
if file_input:
self.logger.info(f"✅ 파일 input 발견: {selector}")
break
except TimeoutException:
continue
except Exception as e:
self.logger.debug(f"파일 input 셀렉터 {selector} 시도 실패: {e}")
continue
if not file_input:
self.logger.error("❌ 파일 input을 찾을 수 없습니다")
return False
# 파일 input을 숨김 처리하여 클릭 이벤트 방지
try:
self.driver.execute_script("""
const fileInput = arguments[0];
fileInput.style.display = 'none';
fileInput.style.visibility = 'hidden';
fileInput.style.position = 'absolute';
fileInput.style.left = '-9999px';
""", file_input)
except Exception as e:
self.logger.debug(f"파일 input 숨김 처리 실패 (무시): {e}")
# 파일 경로를 절대 경로로 변환
absolute_path = os.path.abspath(image_file)
# send_keys()로 직접 업로드
file_input.send_keys(absolute_path)
time.sleep(3) # 업로드 완료 대기
self.logger.info("✅ 이미지 업로드 완료")
return True
except Exception as e:
self.logger.error(f"❌ 이미지 업로드 실패: {e}", exc_info=True)
return False
def _input_image_caption(self, caption: str) -> bool:
"""
티스토리 에디터에서 이미지 캡션 입력
Args:
caption: 입력할 캡션 텍스트
Returns:
성공 여부
"""
try:
if not caption or not caption.strip():
self.logger.warning("⚠️ 캡션이 없어 입력을 건너뜁니다")
return True
self.logger.info(f"� 이미지 캡션 입력 시작: {caption[:30]}...")
# iframe으로 전환 (성공한 셀렉터를 맨 위로 배치하여 빠르게 찾기)
iframe_element = None
for iframe_selector in self.selectors['content_iframe']:
try:
if iframe_selector.startswith('//'):
iframe_element = WebDriverWait(self.driver, 1).until(
EC.presence_of_element_located((By.XPATH, iframe_selector))
)
else:
iframe_element = WebDriverWait(self.driver, 1).until(
EC.presence_of_element_located((By.CSS_SELECTOR, iframe_selector))
)
if iframe_element:
self.logger.info(f"✅ iframe 발견: {iframe_selector}")
break
except TimeoutException:
continue
except Exception as e:
self.logger.debug(f"iframe 셀렉터 {iframe_selector} 시도 실패: {e}")
continue
if not iframe_element:
self.logger.error("❌ iframe을 찾을 수 없습니다")
return False
self.driver.switch_to.frame(iframe_element)
time.sleep(0.2)
# figcaption 요소 찾기 (가장 최근에 삽입된 이미지의 figcaption을 찾기 위해 모든 figcaption을 찾고 마지막 것을 선택)
caption_element = None
# 모든 figure의 figcaption을 찾기 위한 셀렉터
all_figcaptions_xpath = "//*[@id='tinymce']//figure//figcaption"
all_figcaptions_css = "#tinymce figure figcaption"
try:
# CSS 셀렉터로 먼저 시도
caption_elements = WebDriverWait(self.driver, 5).until(
lambda d: [el for el in d.find_elements(By.CSS_SELECTOR, all_figcaptions_css) if el]
)
if caption_elements:
caption_element = caption_elements[-1] # 마지막 요소 선택
self.logger.info(f"✅ figcaption 발견 (CSS, 총 {len(caption_elements)}개 중 마지막 것)")
except TimeoutException:
# CSS로 실패하면 XPath로 시도
try:
caption_elements = WebDriverWait(self.driver, 5).until(
lambda d: [el for el in d.find_elements(By.XPATH, all_figcaptions_xpath) if el]
)
if caption_elements:
caption_element = caption_elements[-1] # 마지막 요소 선택
self.logger.info(f"✅ figcaption 발견 (XPath, 총 {len(caption_elements)}개 중 마지막 것)")
except TimeoutException:
self.logger.warning("⚠️ figcaption 요소를 찾을 수 없습니다 (타임아웃)")
except Exception as e:
self.logger.debug(f"figcaption 찾기 실패: {e}")
if not caption_element:
self.logger.error("❌ figcaption 요소를 찾을 수 없습니다")
self.driver.switch_to.default_content()
return False
# figcaption 클릭하여 편집 모드 활성화
try:
caption_element.click()
time.sleep(0.2) # 대기 시간 단축 (0.5초 → 0.2초)
self.logger.info("✅ figcaption 클릭 완료")
except Exception as e:
self.logger.warning(f"⚠️ figcaption 클릭 실패, JavaScript로 시도: {e}")
try:
self.driver.execute_script("arguments[0].click();", caption_element)
time.sleep(0.2) # 대기 시간 단축 (0.5초 → 0.2초)
except Exception as e2:
self.logger.error(f"❌ JavaScript 클릭도 실패: {e2}")
self.driver.switch_to.default_content()
return False
# 캡션 텍스트 입력
try:
# 짧은 캡션(20자 이하)은 한 번에 입력, 긴 캡션은 빠르게 타이핑
if len(caption) <= 20:
caption_element.send_keys(caption)
self.logger.info("✅ 캡션 입력 완료 (한 번에 입력)")
else:
for char in caption:
caption_element.send_keys(char)
time.sleep(0.01) # 타이핑 간격 단축 (0.05초 → 0.01초)
self.logger.info("✅ 캡션 입력 완료")
except Exception as e:
self.logger.error(f"❌ 캡션 입력 실패: {e}")
self.driver.switch_to.default_content()
return False
# Enter 키 1번 입력하여 본문 편집 영역으로 복귀
try:
self.logger.info("⌨️ Enter 키 1번 입력하여 본문 영역으로 복귀...")
ActionChains(self.driver).send_keys(Keys.ENTER).perform()
time.sleep(0.2)
except Exception as e:
self.logger.warning(f"⚠️ Enter 키 입력 실패 (계속 진행): {e}")
# Enter 후 이미 본문 영역(iframe 내부)에 있으므로 default_content로 복귀하지 않음
# 본문 입력이 바로 계속될 수 있도록 iframe 내부에 그대로 유지
# self.driver.switch_to.default_content() 제거
return True
except Exception as e:
self.logger.error(f"❌ 캡션 입력 중 오류: {e}", exc_info=True)
try:
self.driver.switch_to.default_content()
except:
pass
return False
def perform_posting(self, content_data: dict) -> PostingResult:
"""
실제 포스팅 수행 (제목, 본문 입력 등)
Args:
content_data: 포스팅할 콘텐츠 데이터
Returns:
포스팅 결과
"""
try:
self.logger.info("� 포스팅 데이터 입력 시작")
# 1. Tistory CMS에서 데이터 가져오기 (랜덤 선택)
tistory_cms_info = self._get_tistory_cms_info()
if not tistory_cms_info:
self.logger.error("❌ Tistory CMS에서 조건에 맞는 레코드를 찾을 수 없습니다")
return PostingResult(success=False, message="Tistory CMS 레코드 없음")
# 필수 필드 체크
tistory_title = tistory_cms_info.get('tistory_title', '').strip()
tistory_text = tistory_cms_info.get('tistory_text', '').strip()
wp_link = tistory_cms_info.get('wp_link', '').strip()
record_id = tistory_cms_info.get('record_id', '')
if not tistory_title:
self.logger.error("❌ Tistory CMS에서 tistory_title을 찾을 수 없습니다")
return PostingResult(success=False, message="tistory_title 필수 필드 누락")
if not tistory_text:
self.logger.error("❌ Tistory CMS에서 tistory_text를 찾을 수 없습니다")
return PostingResult(success=False, message="tistory_text 필수 필드 누락")
self.logger.info("✅ Tistory CMS 필수 필드 검증 완료")
self.logger.info(f" - tistory_title: {tistory_title[:30]}...")
self.logger.info(f" - tistory_text: {len(tistory_text)}자")
self.logger.info(f" - wp_link: {wp_link[:50] if wp_link else '없음'}...")
self.logger.info(f" - record_id: {record_id}")
# 2. content_data에 Tistory CMS 데이터 추가
content_data['tistory_title'] = tistory_title
content_data['tistory_text'] = tistory_text
content_data['record_id'] = record_id
content_data['wp_link'] = wp_link
# 3. 제목 입력
self.logger.info("� 제목 입력 시작...")
if not self._input_title(content_data):
return PostingResult(success=False, message="제목 입력 실패")
self.logger.info("✅ 제목 입력 완료")
# 4. WP에서 이미지 다운로드 (wp_link가 있는 경우) - 본문 입력 전에 수행
downloaded_images_info = [] # 다운로드된 이미지 정보 저장 (캡션 포함)
if wp_link:
self.logger.info("�️ 워드프레스에서 이미지 다운로드 시작...")
# 현재 창 핸들 저장
original_window = self.driver.current_window_handle
try:
# 새 탭에서 WP 페이지 열기
self.driver.execute_script(f"window.open('{wp_link}', '_blank');")
# 새 탭으로 전환
all_windows = self.driver.window_handles
new_window = [w for w in all_windows if w != original_window][0]
self.driver.switch_to.window(new_window)
# 페이지 로드 완료 대기
WebDriverWait(self.driver, 15).until(
lambda d: d.execute_script('return document.readyState') == 'complete'
)
time.sleep(3) # 추가 대기 시간
# 이미지 추출
image_data = self._extract_wp_images_and_captions()
# 이미지 다운로드
downloaded_files = []
downloaded_images_info = [] # 다운로드된 이미지 정보 저장 (캡션 포함)
if image_data:
self.logger.info(f"⬇️ {len(image_data)}개 이미지 다운로드 시작...")
for idx, img_info in enumerate(image_data, 1):
image_url = img_info.get('image_url')
if image_url:
file_path = self._download_image_to_temp(image_url)
if file_path:
downloaded_files.append(file_path)
# 이미지 정보 저장 (캡션 포함)
downloaded_images_info.append({
'file_path': file_path,
'caption': img_info.get('caption', '')
})
self.logger.info(f"✅ 이미지 {idx} 다운로드 완료: {os.path.basename(file_path)}")
self.logger.info(f"✅ 총 {len(downloaded_files)}개 이미지 다운로드 완료")
else:
self.logger.warning("⚠️ WP에서 이미지를 찾지 못했습니다")
# 원래 창으로 돌아가기
self.driver.close() # 새 탭 닫기
self.driver.switch_to.window(original_window)
except Exception as e:
self.logger.error(f"❌ 이미지 다운로드 중 오류: {e}", exc_info=True)
# 오류 발생 시에도 원래 창으로 돌아가기
try:
all_windows = self.driver.window_handles
if original_window in all_windows:
self.driver.switch_to.window(original_window)
else:
# 원래 창이 없으면 첫 번째 창으로
self.driver.switch_to.window(self.driver.window_handles[0])
except:
pass
else:
self.logger.warning("⚠️ wp_link가 없어 이미지 다운로드를 건너뜁니다")
# 5. 본문 입력 전에 첫 번째 이미지 삽입 (네이버 블로그 방식)
if downloaded_images_info:
self.logger.info("�️ 본문 입력 전에 첫 번째 이미지 삽입...")
if not self._click_attach_and_image_buttons():
self.logger.warning("⚠️ 첨부 버튼 및 사진 버튼 클릭 실패")
else:
first_image_info = downloaded_images_info[0]
first_image = first_image_info['file_path']
first_caption = first_image_info.get('caption', '')
self.logger.info(f"� 첫 번째 이미지 업로드 시작: {os.path.basename(first_image)}")
if self._upload_single_image(first_image):
self.logger.info("✅ 첫 번째 이미지 업로드 완료")
# 캡션 입력
if first_caption:
self.logger.info(f"� 첫 번째 이미지 캡션 입력 시작: {first_caption[:30]}...")
if self._input_image_caption(first_caption):
self.logger.info("✅ 첫 번째 이미지 캡션 입력 완료")
else:
self.logger.warning("⚠️ 첫 번째 이미지 캡션 입력 실패")
else:
# 캡션이 없으면 Enter만 입력하여 본문 영역으로 복귀
try:
self.driver.switch_to.default_content()
ActionChains(self.driver).send_keys(Keys.ENTER).perform()
time.sleep(0.2)
except:
pass
else:
self.logger.warning("⚠️ 첫 번째 이미지 업로드 실패")
# 6. 본문 입력 (5줄마다 이미지 삽입)
self.logger.info("� 본문 입력 시작...")
# downloaded_images_info를 content_data에 추가하여 본문 입력 중 사용
content_data['downloaded_images_info'] = downloaded_images_info
if not self._input_content(content_data):
return PostingResult(success=False, message="본문 입력 실패")
self.logger.info("✅ 본문 입력 완료")
# TODO: 카테고리 설정
# TODO: 발행 버튼 클릭
return PostingResult(
success=True,
message="제목 및 본문 입력 완료 (카테고리 및 발행 기능은 추후 구현)"
)
except Exception as e:
self.logger.error(f"❌ 포스팅 중 오류: {e}", exc_info=True)
return PostingResult(success=False, message=f"포스팅 오류: {e}")
def post_content(self, content_data: dict) -> PostingResult:
"""
티스토리 포스팅 전체 프로세스
Args:
content_data: 포스팅할 콘텐츠 데이터
Returns:
포스팅 결과
"""
try:
self.logger.info("� 티스토리 포스팅 프로세스 시작")
# 1. 로그인 확인
if not self.login():
return PostingResult(success=False, message="로그인 실패")
self.logger.info("✅ 로그인 확인 완료")
# 2. 글쓰기 페이지로 이동
if not self._navigate_to_write_page():
return PostingResult(success=False, message="글쓰기 페이지 진입 실패")
self.logger.info("✅ 글쓰기 페이지 진입 완료")
# 3. 실제 포스팅 수행
return self.perform_posting(content_data)
except Exception as e:
self.logger.error(f"❌ 티스토리 포스팅 프로세스 오류: {e}", exc_info=True)
return PostingResult(success=False, message=f"프로세스 오류: {e}")
return PostingResult(success=False, message=f"프로세스 오류: {e}")