brunch

관세 낮췄다더니… 현대차·기아 8.4조 날릴 위기

by 리포테라

# 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}")


keyword
작가의 이전글러시아와 잘나가더니… 인도의 돌변, 속내는